Restructure core-v2 into layered modules
Rewrite the core-v2 internals without changing the public API or runtime behavior. The four large files (types, scene, motion, gesture) become layered modules with one responsibility each: - shared/: geometry and environment helpers - view/: originView recipes - choreography/: effect types, presets, and composeEffects compositing - actions/: forward/back/replace/above/under via one overload resolver - scene/: SceneGraph storage, transitions (history rules), compositor (styles and interactivity), spring (pure stepper + rAF driver), and createOriginScene, which only wires them and owns async timing - composables/: useOrigin and the node-scope injection key - gesture/: builder, definition->policy normalization, start admission, DirectionalTracker, and a GestureRecognizer session state machine replacing fifteen loose closure variables - components/: renderers and gesture hosts, plus their prop types Verification (packages/core-v2/verify): differential fuzzing drives main and this branch with identical random operation sequences and pointer streams and requires identical scene state, styles, contexts, callbacks, and promise results at every step. It caught every planted mutation that applied. Benchmarks are at parity. Also fix a pre-existing test that called the defineProps macro inside a runtime defineComponent, and correct API.md's description of the flick commit threshold to match the runtime. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
import type { OriginAction } from "../actions/types";
|
||||
import type { UseOrigin } from "../composables/useOrigin";
|
||||
import type { OriginContext, OriginOperationHandle } from "../scene/types";
|
||||
import { measureRect, type OriginRect } from "../shared/geometry";
|
||||
import { isHorizontal } from "./direction";
|
||||
import type { RecognizerPolicy } from "./policy";
|
||||
import { isIgnoredTarget, matchesStart, pointOf } from "./start";
|
||||
import { DirectionalTracker } from "./tracker";
|
||||
import type { OriginGesturePoint } from "./types";
|
||||
|
||||
/** The origin capabilities a recognizer needs. */
|
||||
export type RecognizerOrigin = Pick<UseOrigin, "context" | "begin">;
|
||||
|
||||
/** State of one admitted pointer sequence, from pointer-down to release. */
|
||||
interface PointerSession {
|
||||
readonly pointerId: number;
|
||||
readonly host: HTMLElement;
|
||||
readonly bounds: OriginRect;
|
||||
/** Origin context captured at pointer-down. */
|
||||
readonly origin: OriginContext;
|
||||
readonly start: OriginGesturePoint;
|
||||
readonly startTime: number;
|
||||
readonly tracker: DirectionalTracker;
|
||||
/** Movement passed the recognition threshold and the pointer is captured. */
|
||||
captured: boolean;
|
||||
/** The scene operation, once the action resolved and `begin()` was called. */
|
||||
operation: Promise<OriginOperationHandle | null> | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns one host's pointer events into a scene operation.
|
||||
*
|
||||
* Lifecycle of a session:
|
||||
*
|
||||
* 1. **down**: an eligible primary pointer opens a session.
|
||||
* 2. **move**: once directed movement passes the threshold, the pointer is
|
||||
* captured and the policy's action is resolved; a non-null action begins
|
||||
* an operation, while a declined one closes the session. Every later move
|
||||
* updates the operation's progress.
|
||||
* 3. **up**: the session closes and the operation finishes, using the
|
||||
* policy's completion predicate when present.
|
||||
* 4. **cancel**: the session closes and the operation is cancelled.
|
||||
*
|
||||
* Action resolution and `begin()` are asynchronous. Each continuation checks
|
||||
* that its session is still current, so a stale resolution never creates or
|
||||
* updates an orphan operation.
|
||||
*/
|
||||
export class GestureRecognizer {
|
||||
private session: PointerSession | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly policy: RecognizerPolicy,
|
||||
private readonly origin: RecognizerOrigin,
|
||||
) {}
|
||||
|
||||
pointerDown(event: PointerEvent): void {
|
||||
const host = event.currentTarget;
|
||||
if (
|
||||
!event.isPrimary ||
|
||||
event.button !== 0 ||
|
||||
!(host instanceof HTMLElement) ||
|
||||
isIgnoredTarget(event.target)
|
||||
)
|
||||
return;
|
||||
|
||||
const bounds = measureRect(host);
|
||||
const origin = this.origin.context.value;
|
||||
if (!matchesStart(this.policy.start, event, host, origin, bounds)) return;
|
||||
|
||||
// The component containing this declaration is the operation's origin.
|
||||
event.stopPropagation();
|
||||
const horizontal = isHorizontal(this.policy.direction);
|
||||
this.session = {
|
||||
pointerId: event.pointerId,
|
||||
host,
|
||||
bounds,
|
||||
origin,
|
||||
start: pointOf(event, bounds),
|
||||
startTime: event.timeStamp,
|
||||
tracker: new DirectionalTracker(this.policy.direction, event, () =>
|
||||
horizontal ? host.clientWidth : host.clientHeight,
|
||||
),
|
||||
captured: false,
|
||||
operation: null,
|
||||
};
|
||||
}
|
||||
|
||||
async pointerMove(event: PointerEvent): Promise<void> {
|
||||
const session = this.sessionFor(event);
|
||||
if (!session) return;
|
||||
|
||||
if (!session.captured) {
|
||||
if (!this.recognizes(session, event)) return;
|
||||
session.captured = true;
|
||||
session.host.setPointerCapture?.(session.pointerId);
|
||||
event.preventDefault();
|
||||
session.tracker.sample(event);
|
||||
const action = await this.resolveAction();
|
||||
// The pointer may have been released or cancelled meanwhile.
|
||||
if (this.session !== session) return;
|
||||
if (!action) {
|
||||
this.session = null;
|
||||
return;
|
||||
}
|
||||
session.operation = this.origin.begin(action).catch(() => null);
|
||||
} else {
|
||||
event.preventDefault();
|
||||
session.tracker.sample(event);
|
||||
}
|
||||
|
||||
// Progress is read after the await so the latest buffered sample wins.
|
||||
const pending = session.operation;
|
||||
const handle = pending ? await pending : null;
|
||||
if (this.session === session && session.operation === pending)
|
||||
handle?.update(session.tracker.progress, session.tracker.velocity);
|
||||
}
|
||||
|
||||
async pointerUp(event: PointerEvent): Promise<void> {
|
||||
const session = this.sessionFor(event);
|
||||
if (!session) return;
|
||||
this.session = null;
|
||||
if (!session.captured) return;
|
||||
|
||||
const { tracker } = session;
|
||||
tracker.sample(event, true);
|
||||
const commit = this.policy.completion?.({
|
||||
origin: session.origin,
|
||||
direction: this.policy.direction,
|
||||
progress: tracker.progress,
|
||||
velocity: tracker.velocity,
|
||||
distance: tracker.distance,
|
||||
crossDistance: tracker.crossDistance,
|
||||
duration: Math.max(0, event.timeStamp - session.startTime),
|
||||
event,
|
||||
host: session.host,
|
||||
bounds: session.bounds,
|
||||
start: session.start,
|
||||
current: pointOf(event, session.bounds),
|
||||
});
|
||||
const { progress, velocity } = tracker;
|
||||
|
||||
const handle = session.operation ? await session.operation : null;
|
||||
if (!handle) return;
|
||||
handle.update(progress, velocity);
|
||||
await handle.finish(commit === undefined ? undefined : { commit });
|
||||
}
|
||||
|
||||
async pointerCancel(): Promise<void> {
|
||||
const session = this.session;
|
||||
this.session = null;
|
||||
if (!session?.captured) return;
|
||||
const handle = session.operation ? await session.operation : null;
|
||||
await handle?.cancel();
|
||||
}
|
||||
|
||||
private sessionFor(event: PointerEvent) {
|
||||
const session = this.session;
|
||||
return session && session.pointerId === event.pointerId ? session : null;
|
||||
}
|
||||
|
||||
/** Directed movement passed the threshold and dominates the cross axis. */
|
||||
private recognizes(session: PointerSession, event: PointerEvent) {
|
||||
const { distance, crossDistance } = session.tracker.displacement(event);
|
||||
return (
|
||||
distance >= this.policy.threshold &&
|
||||
distance >= crossDistance * this.policy.axisDominance
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the policy's action against the origin's current context. A
|
||||
* declined or failed action resolves to `null`.
|
||||
*/
|
||||
private resolveAction(): Promise<OriginAction | null | undefined> {
|
||||
try {
|
||||
return Promise.resolve(
|
||||
this.policy.action(this.origin.context.value),
|
||||
).catch(() => null);
|
||||
} catch {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user