import type { MaybeOriginAction, OriginAction, OriginContext, OriginGestureBinding, OriginGestureBuilder, OriginGestureCompletionContext, OriginGestureCompletionPredicate, OriginGestureDefinition, OriginGestureDirection, OriginGestureDirectionOptions, OriginGestureDistance, OriginGestureEdge, OriginGestureFromBuilder, OriginGestureFromSelection, OriginGestureNavigationBuilder, OriginGestureNavigationFactory, OriginGestureOptions, OriginGesturePoint, OriginGestureStart, OriginGestureStartContext, OriginGestureStartPredicate, OriginGestureToBuilder, OriginOperationHandle, OriginRect, } from "./types"; import { useOrigin } from "./lifecycle"; function ignoreGestureTarget(target: EventTarget | null) { return ( !(target instanceof Element) || Boolean( target.closest( '[data-origin-gesture="ignore"], input, textarea, select, option, [contenteditable="true"]', ), ) ); } function directedDistance( direction: OriginGestureDirection, dx: number, dy: number, ) { switch (direction) { case "left": return -dx; case "right": return dx; case "up": return -dy; case "down": return dy; } } function rectOf(element: HTMLElement): OriginRect { const rect = element.getBoundingClientRect(); return { top: rect.top, left: rect.left, width: rect.width, height: rect.height, }; } function pointOf( event: Pick, bounds: OriginRect, ): OriginGesturePoint { return { clientX: event.clientX, clientY: event.clientY, localX: event.clientX - bounds.left, localY: event.clientY - bounds.top, }; } /** * 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. */ function resolveCssDistance( distance: OriginGestureDistance, axis: "horizontal" | "vertical", host: HTMLElement, bounds: OriginRect, ) { if (typeof distance === "number") return Number.isFinite(distance) ? Math.max(0, distance) : 0; 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 resolved = axis === "horizontal" ? probe.getBoundingClientRect().width : probe.getBoundingClientRect().height; container.remove(); return Number.isFinite(resolved) ? Math.max(0, resolved) : 0; } function matchesStart( start: OriginGestureStart, event: PointerEvent, host: HTMLElement, origin: OriginContext, bounds: OriginRect, ) { if (start.kind === "anywhere") return true; const point = pointOf(event, bounds); if (start.kind === "when") { const context: OriginGestureStartContext = { event, origin, host, bounds, point, }; return start.predicate(context); } 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 ); } } function edgeStart( edge: OriginGestureEdge, distance: OriginGestureDistance, ): OriginGestureStart { return Object.freeze({ kind: "edge", edge, distance }); } function createNavigationBuilder( start: OriginGestureStart, direction: OriginGestureDirection, recognition: Readonly, completion: OriginGestureCompletionPredicate | undefined, navigation: OriginGestureNavigationFactory, ): OriginGestureNavigationBuilder { return Object.freeze({ animate(choreography: OriginGestureDefinition["choreography"]) { return Object.freeze({ kind: "origin-gesture-definition", start, direction, recognition, completion, navigation, choreography, }); }, }); } function createDirectedBuilder( start: OriginGestureStart, direction: OriginGestureDirection, options: OriginGestureDirectionOptions = {}, ) { const recognition = Object.freeze({ ...options }); const navigate = ( navigation: OriginGestureNavigationFactory, completion?: OriginGestureCompletionPredicate, ) => createNavigationBuilder( start, direction, recognition, completion, navigation, ); return Object.freeze({ complete(completion: OriginGestureCompletionPredicate) { return Object.freeze({ navigate: (navigation: OriginGestureNavigationFactory) => navigate(navigation, completion), }); }, navigate, }); } function createToBuilder(start: OriginGestureStart): OriginGestureToBuilder { return Object.freeze({ left: (options?: OriginGestureDirectionOptions) => createDirectedBuilder(start, "left", options), right: (options?: OriginGestureDirectionOptions) => createDirectedBuilder(start, "right", options), up: (options?: OriginGestureDirectionOptions) => createDirectedBuilder(start, "up", options), down: (options?: OriginGestureDirectionOptions) => createDirectedBuilder(start, "down", options), }); } function selectStart(start: OriginGestureStart): OriginGestureFromSelection { return Object.freeze({ to: createToBuilder(Object.freeze(start)) }); } const fromBuilder: OriginGestureFromBuilder = Object.freeze({ left: (distance: OriginGestureDistance) => selectStart(edgeStart("left", distance)), right: (distance: OriginGestureDistance) => selectStart(edgeStart("right", distance)), top: (distance: OriginGestureDistance) => selectStart(edgeStart("top", distance)), bottom: (distance: OriginGestureDistance) => selectStart(edgeStart("bottom", distance)), anywhere: () => selectStart({ kind: "anywhere" }), when: (predicate: OriginGestureStartPredicate) => selectStart({ kind: "when", predicate }), }); /** * Root of the immutable gesture builder. * * `.from` is optional. Starting at `.to` admits pointer-down anywhere on the * bound host, exactly like `.from.anywhere().to`. * * @example Anywhere-to-right back gesture * ```ts * const swipeBack = gesture * .to.right() * .navigate((context) => context.canGoBack ? back() : null) * .animate(slideRight); * ``` * * @example Predicate-gated gesture with custom completion * ```ts * const openPanel = gesture * .from.when(({ point, bounds }) => point.localX <= bounds.width * 0.08) * .to.down() * .complete(({ progress, velocity }) => progress > 0.5 || velocity > 1) * .navigate(() => above(originView(PanelView))) * .animate(dropPanel); * ``` */ export const gesture: OriginGestureBuilder = Object.freeze({ from: fromBuilder, to: createToBuilder(Object.freeze({ kind: "anywhere" })), }); function isDefinition( value: OriginGestureOptions | OriginGestureDefinition, ): value is OriginGestureDefinition { return "kind" in value && value.kind === "origin-gesture-definition"; } interface RuntimeGesturePolicy { readonly direction: OriginGestureDirection; readonly start: OriginGestureStart; readonly threshold: number; readonly axisDominance: number; readonly completion?: OriginGestureCompletionPredicate; readonly action: (context: OriginContext) => MaybeOriginAction; } function legacyStart(options: OriginGestureOptions): OriginGestureStart { if (options.edge === undefined) return { kind: "anywhere" }; switch (options.direction) { case "left": return edgeStart("right", options.edge); case "right": return edgeStart("left", options.edge); case "up": return edgeStart("bottom", options.edge); case "down": return edgeStart("top", options.edge); } } function runtimePolicy( options: OriginGestureOptions | OriginGestureDefinition, ): RuntimeGesturePolicy { if (!isDefinition(options)) { return { direction: options.direction, start: legacyStart(options), threshold: options.threshold ?? 8, axisDominance: 1.15, action: options.action, }; } return { direction: options.direction, start: options.start, threshold: options.recognition.threshold ?? 8, axisDominance: options.recognition.axisDominance ?? 1.15, completion: options.completion, action: async (context) => { const navigation = await options.navigation(context); if (!navigation) return navigation; const action: OriginAction = { ...navigation, choreography: options.choreography, }; return action; }, }; } /** * 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 origin = useOrigin(); const policy = runtimePolicy(definition); let pointerId = -1; let element: HTMLElement | null = null; let bounds: OriginRect | null = null; let originContext: OriginContext | null = null; let startPoint: OriginGesturePoint | null = null; let startTime = 0; let startX = 0; let startY = 0; let lastCoordinate = 0; let lastTime = 0; let captured = false; let generation = 0; let handlePromise: Promise | null = null; let bufferedProgress = 0; let bufferedVelocity = 0; let bufferedDistance = 0; let bufferedCrossDistance = 0; const horizontal = policy.direction === "left" || policy.direction === "right"; function reset() { pointerId = -1; element = null; bounds = null; originContext = null; startPoint = null; captured = false; handlePromise = null; bufferedProgress = 0; bufferedVelocity = 0; bufferedDistance = 0; bufferedCrossDistance = 0; } function onPointerdown(event: PointerEvent) { const current = event.currentTarget; if ( !event.isPrimary || event.button !== 0 || !(current instanceof HTMLElement) || ignoreGestureTarget(event.target) ) return; const nextBounds = rectOf(current); const nextOriginContext = origin.context.value; if ( !matchesStart(policy.start, event, current, nextOriginContext, nextBounds) ) return; // The component containing this declaration is the operation's origin. event.stopPropagation(); generation += 1; pointerId = event.pointerId; element = current; bounds = nextBounds; originContext = nextOriginContext; startPoint = pointOf(event, nextBounds); startTime = event.timeStamp; startX = event.clientX; startY = event.clientY; lastCoordinate = horizontal ? event.clientX : event.clientY; lastTime = event.timeStamp; captured = false; handlePromise = null; } function updateMetrics(event: PointerEvent, release = false) { if (!element) return; const dx = event.clientX - startX; const dy = event.clientY - startY; bufferedDistance = directedDistance(policy.direction, dx, dy); bufferedCrossDistance = horizontal ? Math.abs(dy) : Math.abs(dx); const size = Math.max( 1, horizontal ? element.clientWidth : element.clientHeight, ); const coordinate = horizontal ? event.clientX : event.clientY; const coordinateDelta = policy.direction === "left" || policy.direction === "up" ? lastCoordinate - coordinate : coordinate - lastCoordinate; const rawElapsed = event.timeStamp - lastTime; const elapsed = Math.max(8, rawElapsed); bufferedProgress = Math.max(0, Math.min(1, bufferedDistance / 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 || coordinateDelta !== 0 || rawElapsed > 80) bufferedVelocity = (coordinateDelta * 1000) / (elapsed * size); lastCoordinate = coordinate; lastTime = event.timeStamp; } async function onPointermove(event: PointerEvent) { if (event.pointerId !== pointerId || !element) return; const dx = event.clientX - startX; const dy = event.clientY - startY; const distance = directedDistance(policy.direction, dx, dy); const crossDistance = horizontal ? Math.abs(dy) : Math.abs(dx); let metricsUpdated = false; if (!captured) { if ( distance < policy.threshold || distance < crossDistance * policy.axisDominance ) return; captured = true; element.setPointerCapture?.(pointerId); event.preventDefault(); updateMetrics(event); metricsUpdated = true; const recognitionGeneration = generation; const action = await Promise.resolve( policy.action(origin.context.value), ).catch(() => null); // An asynchronous target resolver may finish after the pointer was // released or cancelled. It must not create an orphan scene operation. if (recognitionGeneration !== generation || event.pointerId !== pointerId) return; if (!action) return reset(); handlePromise = origin.begin(action).catch(() => null); } event.preventDefault(); if (!metricsUpdated) updateMetrics(event); const pending = handlePromise; const handle = pending ? await pending : null; if (pending === handlePromise) handle?.update(bufferedProgress, bufferedVelocity); } async function onPointerup(event: PointerEvent) { if (event.pointerId !== pointerId) return; if (captured) updateMetrics(event, true); const pending = handlePromise; const shouldFinish = captured; const progress = bufferedProgress; const velocity = bufferedVelocity; const completion = shouldFinish && policy.completion && element && bounds && originContext && startPoint ? policy.completion({ origin: originContext, direction: policy.direction, progress, velocity, distance: bufferedDistance, crossDistance: bufferedCrossDistance, duration: Math.max(0, event.timeStamp - startTime), event, host: element, bounds, start: startPoint, current: pointOf(event, bounds), } satisfies OriginGestureCompletionContext) : undefined; generation += 1; reset(); const handle = pending ? await pending : null; if (!shouldFinish || !handle) return; handle.update(progress, velocity); await handle.finish( completion === undefined ? undefined : { commit: completion }, ); } async function onPointercancel() { generation += 1; const pending = handlePromise; const shouldCancel = captured; reset(); const handle = pending ? await pending : null; if (shouldCancel) await handle?.cancel(); } return { style: { // Preserve native scrolling perpendicular to the declared gesture. touchAction: horizontal ? "pan-y" : "pan-x", // OriginGesture is commonly the root returned by a view component. width: "100%", height: "100%", }, onPointerdown, onPointermove: (event) => void onPointermove(event), onPointerup: (event) => void onPointerup(event), onPointercancel: () => void onPointercancel(), }; }