import { computed, onScopeDispose, toValue, type MaybeRefOrGetter } from "vue"; import type { OriginAction } from "../navigation"; import { DEFAULT_COMMIT_THRESHOLD, OriginSceneError } from "../scene/graph"; import { useOrigin, type UseOrigin } from "../scene/scene"; import type { OriginContext, OriginOperationHandle } from "../scene/types"; import { clamp01, measureRect, warn, type OriginRect } from "../shared"; import type { OriginGestureBinding, OriginGestureDefinition, OriginGestureCompletionContext, OriginGestureDirection, OriginGestureDistance, OriginGestureEdge, OriginGesturePoint, OriginGestureProgressDistance, 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 gesture host. Recognizers arbitrate with native * scrolling themselves (see `onTouchmove`), so the browser may pan either axis * and is only kept from double-tap zooming; a host without gestures is left * alone. */ export const touchActionFor = ( directions: Iterable, ): "manipulation" | "auto" => { for (const _ of directions) return "manipulation"; 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; const PIXELS = /^\s*(-?(?:\d+\.?\d*|\.\d+))px\s*$/; const PERCENT = /^\s*(-?(?:\d+\.?\d*|\.\d+))%\s*$/; /** * Resolve a CSS length against a box with the gesture host's size. * * Numbers, pixel lengths, and percentages are computed directly. Anything * else (`rem`, viewport units, `calc()`, `clamp()`) is measured with a * short-lived off-screen box so the browser resolves it exactly. 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 pixels = PIXELS.exec(distance); if (pixels) return nonNegative(Number(pixels[1])); const percent = PERCENT.exec(distance); if (percent) { const size = axis === "horizontal" ? bounds.width : bounds.height; return nonNegative((Number(percent[1]) / 100) * size); } 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 }); return withinEdge(start.edge, start.distance, host, bounds, point); }; /** Whether `point` lies within `distance` of one side of the host. */ export const withinEdge = ( edge: OriginGestureEdge, distance: OriginGestureDistance, host: HTMLElement, bounds: OriginRect, point: OriginGesturePoint, ): boolean => { const horizontal = edge === "left" || edge === "right"; const limit = resolveCssDistance( distance, horizontal ? "horizontal" : "vertical", host, bounds, ); switch (edge) { case "left": return point.localX >= 0 && point.localX <= limit; case "right": return ( point.localX <= bounds.width && bounds.width - point.localX <= limit ); case "top": return point.localY >= 0 && point.localY <= limit; case "bottom": return ( point.localY <= bounds.height && bounds.height - point.localY <= limit ); } }; // Movement tracking --------------------------------------------------------- type PointerSample = Pick; /** Velocity is the trend of the samples in this many recent milliseconds. */ const VELOCITY_WINDOW = 100; /** Shortest span, in ms, velocity is measured over, so bursts stay finite. */ const MIN_VELOCITY_SPAN = 8; /** * Measures one pointer sequence's movement along a gesture direction. * * Progress and velocity are normalized by the gesture distance (by default * the host's size along the gesture axis), so `1` means one full distance. */ 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 ÷ gesture distance, unclamped so it can overshoot. */ rawProgress = 0; /** * Directed velocity in progress units per second: the least-squares trend * of the last {@link VELOCITY_WINDOW} ms, so uneven sample timing and a * final held-still moment do not distort a flick. */ velocity = 0; private readonly samples: { time: number; position: number }[]; constructor( private readonly direction: OriginGestureDirection, private readonly start: PointerSample, /** Gesture distance in CSS pixels, read at every sample. */ private readonly axisSize: () => number, ) { this.samples = [{ time: start.timeStamp, position: 0 }]; } /** Directed distance ÷ gesture distance, clamped to `0..1`. */ get progress(): number { return clamp01(this.rawProgress); } /** 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): void { const { distance, crossDistance } = this.displacement(event); this.distance = distance; this.crossDistance = crossDistance; const size = Math.max(1, this.axisSize()); this.rawProgress = distance / size; const samples = this.samples; samples.push({ time: event.timeStamp, position: distance }); // Keep the window, but always at least two samples to measure between. const since = event.timeStamp - VELOCITY_WINDOW; while (samples.length > 2 && samples[1]!.time <= since) samples.shift(); this.velocity = (DirectionalTracker.trend(samples) * 1000) / size; } /** Least-squares slope of position over time, in pixels per ms. */ private static trend(samples: readonly { time: number; position: number }[]) { const first = samples[0]!; const last = samples[samples.length - 1]!; if (last.time - first.time < MIN_VELOCITY_SPAN) return (last.position - first.position) / MIN_VELOCITY_SPAN; const meanTime = samples.reduce((sum, { time }) => sum + time, 0) / samples.length; const meanPosition = samples.reduce((sum, { position }) => sum + position, 0) / samples.length; let covariance = 0; let variance = 0; for (const { time, position } of samples) { covariance += (time - meanTime) * (position - meanPosition); variance += (time - meanTime) ** 2; } return covariance / variance; } } // 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; /** * Movement in CSS pixels after which a recognizer decides whether a touch is * along its axis. Browsers hold back the first cancelable `touchmove` for * roughly 15–20px, so the decision is made before native scrolling can start. */ const AXIS_LOCK_DISTANCE = 6; /** Resolve a definition's progress distance at pointer-down, in pixels. */ export const resolveProgressDistance = ( distance: OriginGestureProgressDistance | undefined, direction: OriginGestureDirection, host: HTMLElement, bounds: OriginRect, ): (() => number) => { const horizontal = isHorizontal(direction); if (distance === undefined) return () => (horizontal ? host.clientWidth : host.clientHeight); const pixels = typeof distance === "function" ? distance({ host, bounds }) : resolveCssDistance( distance, horizontal ? "horizontal" : "vertical", host, bounds, ); return () => pixels; }; /** The origin capabilities a recognizer needs. */ export type RecognizerOrigin = Pick; /** State of one admitted pointer sequence, from pointer-down to release. */ interface PointerSession { /** Definition current at pointer-down; later changes do not affect it. */ readonly definition: OriginGestureDefinition; 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; /** * Whether the movement so far is along this gesture's axis and direction * (`ours`) or still too small or too diagonal to tell (`pending`). A touch * that turns out to be someone else's closes the session instead. */ axis: "pending" | "ours"; /** Movement passed the recognition threshold and the pointer is captured. */ captured: boolean; /** Releasing now would commit; drives `.onArm()` and `.onDisarm()`. */ armed: boolean; /** The scene operation, once navigation resolved and `begin()` was called. */ operation: Promise | null; /** * The operation's handle once `begin()` resolved, so pointer-up can finish * it synchronously, inside the event, without awaiting the promise. */ handle: OriginOperationHandle | 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 `.navigate()` is resolved; a non-null intent 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 * definition's `.complete()` predicate when present. * 4. **cancel**: pointer-cancel, lost pointer capture, a new primary * pointer-down, or disposal closes the session and cancels its operation. * * Navigation 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( /** Reads the current definition; it may change between sessions. */ private readonly definitionOf: () => OriginGestureDefinition, private readonly origin: RecognizerOrigin, ) {} /** * Whether this recognizer currently holds the touch, so native scrolling * must not start: its session is open and has not ruled the touch out. */ get intercepts(): boolean { return this.session !== null; } pointerDown(event: PointerEvent): void { if (!event.isPrimary) return; // Only one primary pointer exists at a time, so any session still open // lost its pointer-up (for example, released outside the window). void this.abandon(); const host = event.currentTarget; if ( event.button !== 0 || !(host instanceof HTMLElement) || isIgnoredTarget(event.target) ) return; const definition = this.definitionOf(); const bounds = measureRect(host); const origin = this.origin.context.value; if (!matchesStart(definition.start, event, host, origin, bounds)) return; // The component containing this declaration is the operation's origin. event.stopPropagation(); this.session = { definition, pointerId: event.pointerId, host, bounds, origin, start: pointOf(event, bounds), startTime: event.timeStamp, tracker: new DirectionalTracker( definition.direction, event, resolveProgressDistance( definition.recognition.distance, definition.direction, host, bounds, ), ), axis: "pending", captured: false, armed: false, operation: null, handle: null, }; } async pointerMove(event: PointerEvent): Promise { const session = this.sessionFor(event); if (!session) return; if (!session.captured) { if (!this.lockAxis(session, event)) return; if (!GestureRecognizer.recognizes(session, event)) return; session.captured = true; session.host.setPointerCapture?.(session.pointerId); event.preventDefault(); session.tracker.sample(event); this.updateArming(session, event); const action = await this.resolveAction(session.definition); // 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).then( (handle) => (session.handle = handle), (error) => { // Another recognizer on this origin won the race; that is expected. if (!( error instanceof OriginSceneError && error.code === "origin-busy" )) warn("A gesture could not begin its operation.", error); return null; }, ); } else { event.preventDefault(); session.tracker.sample(event); this.updateArming(session, 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.rawProgress, 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, definition } = session; tracker.sample(event); const context = GestureRecognizer.contextOf(session, event); const commit = definition.completion?.(context); const { rawProgress: progress, velocity } = tracker; // Usually the operation began long ago: finish it without awaiting, so // commit handlers run inside this pointer-up (iOS only allows haptics, // audio, and clipboard writes during the user's gesture). const handle = session.handle ?? (session.operation ? await session.operation : null); if (!handle) return; handle.update(progress, velocity); // finish() fixes the outcome synchronously, before the spring settles. const settled = handle.finish( commit === undefined ? undefined : { commit }, ); if (handle.intent === "commit") GestureRecognizer.notify(definition.commitHandlers ?? [], context); await settled; } /** * The host lost pointer capture without a pointer-up; cancel like * pointer-cancel. The event bubbles, and taking capture makes the touched * child lose its implicit touch capture, so only the host's own loss counts. */ pointerCaptureLost(event: PointerEvent): Promise { const session = this.sessionFor(event); return session && event.target === session.host ? this.abandon() : Promise.resolve(); } pointerCancel(): Promise { return this.abandon(); } /** Close the current session, cancelling its operation if captured. */ async abandon(): Promise { const session = this.session; this.session = null; if (!session?.captured) return; const handle = session.operation ? await session.operation : null; await handle?.cancel(); } /** * Decide, early in the movement, whether this touch is along the gesture's * axis and direction. A touch that is clearly someone else's (the other * axis, or the opposite way) closes the session, which hands it to native * scrolling or to another recognizer. * * @returns Whether the session is still open. */ private lockAxis(session: PointerSession, event: PointerEvent): boolean { if (session.axis === "ours") return true; const { distance, crossDistance } = session.tracker.displacement(event); const along = Math.abs(distance); if (Math.max(along, crossDistance) < AXIS_LOCK_DISTANCE) return true; const dominance = session.definition.recognition.axisDominance ?? DEFAULT_AXIS_DOMINANCE; if (along >= crossDistance * dominance && distance > 0) { session.axis = "ours"; return true; } if ( along >= crossDistance * dominance || crossDistance >= along * dominance ) { this.session = null; return false; } return true; // Diagonal: keep watching. } /** Fire `.onArm()` / `.onDisarm()` when the release decision would flip. */ private updateArming(session: PointerSession, event: PointerEvent) { const { definition, tracker } = session; const armHandlers = definition.armHandlers ?? []; const disarmHandlers = definition.disarmHandlers ?? []; if (!armHandlers.length && !disarmHandlers.length) return; const context = GestureRecognizer.contextOf(session, event); // Distance decides arming unless the definition supplies .complete(): // velocity changes too quickly to drive a cue without flickering. const armed = definition.completion ? definition.completion(context) : tracker.progress >= (definition.choreography.commitThreshold ?? DEFAULT_COMMIT_THRESHOLD); if (armed === session.armed) return; session.armed = armed; GestureRecognizer.notify(armed ? armHandlers : disarmHandlers, context); } private static contextOf( session: PointerSession, event: PointerEvent, ): OriginGestureCompletionContext { const { tracker } = session; return { origin: session.origin, direction: session.definition.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), }; } 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 static recognizes(session: PointerSession, event: PointerEvent) { const { recognition } = session.definition; const { distance, crossDistance } = session.tracker.displacement(event); return ( distance >= (recognition.threshold ?? DEFAULT_THRESHOLD) && distance >= crossDistance * (recognition.axisDominance ?? DEFAULT_AXIS_DOMINANCE) ); } /** * Resolve the definition's navigation against the origin's current context * and pair it with its choreography. A declined navigation resolves to * `null`; a failing one also warns. */ private resolveAction( definition: OriginGestureDefinition, ): Promise { const fail = (error: unknown) => { warn("A gesture's navigate() factory failed.", error); return null; }; try { return Promise.resolve( definition.navigation(this.origin.context.value), ).then( (navigation) => navigation ? { ...navigation, choreography: definition.choreography } : null, fail, ); } catch (error) { return Promise.resolve(fail(error)); } } /** Run handlers in order; a failing handler never interrupts the others. */ private static notify( handlers: readonly ((context: OriginGestureCompletionContext) => void)[], context: OriginGestureCompletionContext, ) { for (const handler of handlers) { try { handler(context); } catch (error) { queueMicrotask(() => { throw error; }); } } } } // Composables --------------------------------------------------------------- /** * Bind any number of definitions to one host. Recognizers are kept per list * position, so re-rendering with new definition objects never interrupts a * gesture in progress; each change applies from the next pointer-down. */ export const useOriginGestures = ( definitions: MaybeRefOrGetter, ): OriginGestureBinding => { const origin = useOrigin(); const recognizers: GestureRecognizer[] = []; /** Grow or shrink the recognizer list to match the current definitions. */ const current = () => { const count = toValue(definitions).length; while (recognizers.length < count) { const index = recognizers.length; recognizers.push( new GestureRecognizer(() => toValue(definitions)[index]!, origin), ); } for (const removed of recognizers.splice(count)) void removed.abandon(); return recognizers; }; const style = computed(() => ({ width: "100%", height: "100%", // Preserve native scrolling on any axis no gesture claims. touchAction: touchActionFor( toValue(definitions).map(({ direction }) => direction), ), })); // A recognizer whose component unmounts mid-gesture cancels its operation. onScopeDispose(() => { for (const recognizer of recognizers.splice(0)) void recognizer.abandon(); }); return { get style() { return style.value; }, onPointerdown: (event) => { for (const recognizer of current()) recognizer.pointerDown(event); }, onPointermove: (event) => { for (const recognizer of current()) void recognizer.pointerMove(event); }, onPointerup: (event) => { for (const recognizer of current()) void recognizer.pointerUp(event); }, onPointercancel: () => { for (const recognizer of current()) void recognizer.pointerCancel(); }, onLostpointercapture: (event) => { for (const recognizer of current()) void recognizer.pointerCaptureLost(event); }, onTouchmove: (event) => { // Native scrolling starts at the first touchmove not cancelled, so keep // cancelling while any recognizer still holds the touch. if (event.cancelable && current().some(({ intercepts }) => intercepts)) event.preventDefault(); }, }; }; /** * 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. * * 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 a ref or getter * returning one. A changed definition applies from the next pointer-down. * @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: MaybeRefOrGetter, ): OriginGestureBinding { return useOriginGestures(() => [toValue(definition)]); }