import type { OriginAction, MaybeOriginAction } from "../navigation"; import { useOrigin, type UseOrigin } from "../scene/scene"; import type { OriginContext, OriginOperationHandle } from "../scene/types"; import { clamp01, measureRect, type OriginRect } from "../shared"; import type { OriginGestureBinding, OriginGestureCompletionPredicate, OriginGestureDefinition, OriginGestureDirection, OriginGestureDistance, OriginGestureOptions, OriginGesturePoint, OriginGestureStart, } from "./definition"; /* * Running gestures: pointer-down admission, movement tracking, and the * recognizer that turns one host's pointer events into a scene operation. */ // Direction ---------------------------------------------------------------- export const isHorizontal = (direction: OriginGestureDirection): boolean => { return direction === "left" || direction === "right"; }; /** Component of a movement `(dx, dy)` along `direction`. */ export const directedDistance = ( direction: OriginGestureDirection, dx: number, dy: number, ): number => { switch (direction) { case "left": return -dx; case "right": return dx; case "up": return -dy; case "down": return dy; } }; /** Absolute movement perpendicular to `direction`. */ export const crossDistance = ( direction: OriginGestureDirection, dx: number, dy: number, ): number => { return isHorizontal(direction) ? Math.abs(dy) : Math.abs(dx); }; /** * CSS `touch-action` for a host recognizing the given directions: native * scrolling is preserved on any axis no gesture claims. */ export const touchActionFor = ( directions: Iterable, ): "none" | "pan-x" | "pan-y" | "auto" => { let horizontal = false; let vertical = false; for (const direction of directions) { if (isHorizontal(direction)) horizontal = true; else vertical = true; } if (horizontal && vertical) return "none"; if (horizontal) return "pan-y"; if (vertical) return "pan-x"; return "auto"; }; // Pointer-down admission ---------------------------------------------------- /* * Pointer-down admission: which events may begin a gesture at all. */ const IGNORED_TARGETS = '[data-origin-gesture="ignore"], input, textarea, select, option, [contenteditable="true"]'; /** Form controls and opted-out subtrees never start gestures. */ export const isIgnoredTarget = (target: EventTarget | null): boolean => { return ( !(target instanceof Element) || Boolean(target.closest(IGNORED_TARGETS)) ); }; export const pointOf = ( event: Pick, bounds: OriginRect, ): OriginGesturePoint => { return { clientX: event.clientX, clientY: event.clientY, localX: event.clientX - bounds.left, localY: event.clientY - bounds.top, }; }; const nonNegative = (value: number) => Number.isFinite(value) ? Math.max(0, value) : 0; /** * Resolve an arbitrary CSS length against a box with the gesture host's size. * * A short-lived off-screen box lets the browser handle `rem`, viewport units, * percentages, `calc()`, and `clamp()` consistently. This runs only during * pointer-down for edge-constrained definitions. */ export const resolveCssDistance = ( distance: OriginGestureDistance, axis: "horizontal" | "vertical", host: HTMLElement, bounds: OriginRect, ): number => { if (typeof distance === "number") return nonNegative(distance); const document = host.ownerDocument; if (!document.body) return Math.max(0, Number.parseFloat(distance) || 0); const container = document.createElement("div"); const probe = document.createElement("div"); Object.assign(container.style, { position: "fixed", left: "-100000px", top: "-100000px", width: `${bounds.width}px`, height: `${bounds.height}px`, visibility: "hidden", pointerEvents: "none", contain: "strict", }); Object.assign(probe.style, { position: "absolute", width: axis === "horizontal" ? distance : "0", height: axis === "vertical" ? distance : "0", }); container.append(probe); document.body.append(container); const rect = probe.getBoundingClientRect(); container.remove(); return nonNegative(axis === "horizontal" ? rect.width : rect.height); }; /** Whether a pointer-down satisfies a definition's start policy. */ export const matchesStart = ( start: OriginGestureStart, event: PointerEvent, host: HTMLElement, origin: OriginContext, bounds: OriginRect, ): boolean => { if (start.kind === "anywhere") return true; const point = pointOf(event, bounds); if (start.kind === "when") return start.predicate({ event, origin, host, bounds, point }); const horizontal = start.edge === "left" || start.edge === "right"; const distance = resolveCssDistance( start.distance, horizontal ? "horizontal" : "vertical", host, bounds, ); switch (start.edge) { case "left": return point.localX >= 0 && point.localX <= distance; case "right": return ( point.localX <= bounds.width && bounds.width - point.localX <= distance ); case "top": return point.localY >= 0 && point.localY <= distance; case "bottom": return ( point.localY <= bounds.height && bounds.height - point.localY <= distance ); } }; // Movement tracking --------------------------------------------------------- type PointerSample = Pick; /** Shortest frame interval assumed when computing velocity, in ms. */ const MIN_SAMPLE_INTERVAL = 8; /** * A release without movement after this many ms is treated as a hold, so the * last move's flick velocity no longer applies. */ const HOLD_RELEASE_INTERVAL = 80; /** * Measures one pointer sequence's movement along a gesture direction. * * Progress and velocity are normalized by the host's size along the gesture * axis, so `1` means one full host width (or height). */ export class DirectionalTracker { /** Directed distance from the start, in CSS pixels. */ distance = 0; /** Absolute cross-axis distance from the start, in CSS pixels. */ crossDistance = 0; /** Directed distance as a fraction of host size, clamped to `0..1`. */ progress = 0; /** Latest directed velocity in progress units per second. */ velocity = 0; private readonly horizontal: boolean; /** Whether progress grows as the axis coordinate decreases. */ private readonly reversed: boolean; private lastCoordinate: number; private lastTime: number; constructor( private readonly direction: OriginGestureDirection, private readonly start: PointerSample, /** Host size along the gesture axis, read at every sample. */ private readonly axisSize: () => number, ) { this.horizontal = isHorizontal(direction); this.reversed = direction === "left" || direction === "up"; this.lastCoordinate = this.coordinateOf(start); this.lastTime = start.timeStamp; } private coordinateOf(event: PointerSample) { return this.horizontal ? event.clientX : event.clientY; } /** Movement from the start without recording a sample. */ displacement(event: PointerSample) { const dx = event.clientX - this.start.clientX; const dy = event.clientY - this.start.clientY; return { distance: directedDistance(this.direction, dx, dy), crossDistance: crossDistance(this.direction, dx, dy), }; } /** Record a sample, updating distance, progress, and velocity. */ sample(event: PointerSample, release = false): void { const { distance, crossDistance } = this.displacement(event); this.distance = distance; this.crossDistance = crossDistance; const size = Math.max(1, this.axisSize()); const coordinate = this.coordinateOf(event); const step = this.reversed ? this.lastCoordinate - coordinate : coordinate - this.lastCoordinate; const interval = event.timeStamp - this.lastTime; this.progress = clamp01(distance / size); // Pointer-up commonly repeats the final pointer-move coordinate. Preserve // that move's flick velocity for a prompt release, but decay it when the // pointer was held still long enough for the flick to have ended. if (!release || step !== 0 || interval > HOLD_RELEASE_INTERVAL) this.velocity = (step * 1000) / (Math.max(MIN_SAMPLE_INTERVAL, interval) * size); this.lastCoordinate = coordinate; this.lastTime = event.timeStamp; } } // Recognizer ---------------------------------------------------------------- /** Directed movement in CSS pixels required before a gesture captures. */ const DEFAULT_THRESHOLD = 8; /** Ratio by which directed movement must exceed cross-axis movement. */ const DEFAULT_AXIS_DOMINANCE = 1.15; /** Legacy `edge` start side for each movement direction. */ const LEGACY_START_EDGE = { left: "right", right: "left", up: "bottom", down: "top", } as const; /** * The single shape the recognizer executes. Builder definitions and legacy * options objects are both normalized into it. */ export interface RecognizerPolicy { readonly direction: OriginGestureDirection; readonly start: OriginGestureStart; readonly threshold: number; readonly axisDominance: number; readonly completion?: OriginGestureCompletionPredicate; readonly action: (context: OriginContext) => MaybeOriginAction; } /** The origin capabilities a recognizer needs. */ export type RecognizerOrigin = Pick; /** 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 | 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( readonly policy: RecognizerPolicy, private readonly origin: RecognizerOrigin, ) {} /** Create a recognizer for a builder definition or legacy options. */ static create( definition: OriginGestureDefinition | OriginGestureOptions, origin: RecognizerOrigin, ): GestureRecognizer { return new GestureRecognizer( GestureRecognizer.policyFor(definition), origin, ); } /** * Normalize a builder definition or legacy options object into the single * policy shape the recognizer executes. */ static policyFor( definition: OriginGestureDefinition | OriginGestureOptions, ): RecognizerPolicy { return "kind" in definition && definition.kind === "origin-gesture-definition" ? GestureRecognizer.policyFromDefinition(definition) : GestureRecognizer.policyFromOptions(definition as OriginGestureOptions); } private static policyFromDefinition( definition: OriginGestureDefinition, ): RecognizerPolicy { return { direction: definition.direction, start: definition.start, threshold: definition.recognition.threshold ?? DEFAULT_THRESHOLD, axisDominance: definition.recognition.axisDominance ?? DEFAULT_AXIS_DOMINANCE, completion: definition.completion, // Pair the resolved navigation intent with the definition's animation. action: async (context) => { const navigation = await definition.navigation(context); if (!navigation) return navigation; const action: OriginAction = { ...navigation, choreography: definition.choreography, }; return action; }, }; } /** Legacy `edge` is measured from the side opposite the movement. */ private static policyFromOptions( options: OriginGestureOptions, ): RecognizerPolicy { return { direction: options.direction, start: options.edge === undefined ? { kind: "anywhere" } : Object.freeze({ kind: "edge", edge: LEGACY_START_EDGE[options.direction], distance: options.edge, }), threshold: options.threshold ?? DEFAULT_THRESHOLD, axisDominance: DEFAULT_AXIS_DOMINANCE, action: options.action, }; } 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 { 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 { 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 { 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 { try { return Promise.resolve( this.policy.action(this.origin.context.value), ).catch(() => null); } catch { return Promise.resolve(null); } } } // Composable ---------------------------------------------------------------- /** * Install a component-owned pointer recognizer. * * Recognition is local to the element receiving these handlers. There is no * application-wide gesture table and no lookup of a currently active view. * * Builder definitions may independently describe their pointer-down region * and movement direction. Omitting `.from` recognizes pointer-down across the * whole element. The legacy options object remains supported; its `edge` is * inferred from the opposite side of its movement direction. * * Interactive controls and anything inside * `[data-origin-gesture="ignore"]` are ignored automatically. The recognizer * preserves native scrolling on the cross-axis through its returned style. * * @param definition - An immutable builder result or legacy recognizer options. * @returns Pointer handlers and required host styles. * @throws If called outside a component rendered by `OriginScene`. * * @example Builder-defined backward gesture * ```ts * const swipeBack = useOriginGesture( * gesture * .from.left("32px") * .to.right() * .navigate((context) => context.canGoBack ? back() : null) * .animate(slideRight), * ); * ``` */ export function useOriginGesture( definition: OriginGestureDefinition | OriginGestureOptions, ): OriginGestureBinding { const recognizer = GestureRecognizer.create(definition, useOrigin()); return { style: { // Preserve native scrolling perpendicular to the declared gesture. touchAction: touchActionFor([recognizer.policy.direction]), // OriginGesture is commonly the root returned by a view component. width: "100%", height: "100%", }, onPointerdown: (event) => recognizer.pointerDown(event), onPointermove: (event) => void recognizer.pointerMove(event), onPointerup: (event) => void recognizer.pointerUp(event), onPointercancel: () => void recognizer.pointerCancel(), }; }