Files
Native-Router-Vue/packages/core-v2/src/gesture/recognizer.ts
T
Claude 6889fc0112 Make gestures cooperate with scrolling and feel smoother
From an app built on core-v2:

Scrolling (#3, #4, #14)
- Each recognizer locks onto a touch within its first 6px: along its axis
  and direction it holds the touch, otherwise it lets go. A new
  onTouchmove binding cancels native scrolling only while a recognizer
  holds the touch, so hosts use touch-action: manipulation and one
  vertical gesture no longer disables vertical scrolling. Several
  gestures on one host share touches through the same rule.
- .from.scrollEdge(scroller, edge) admits drag-to-dismiss only while a
  scroller is at the edge (#5).

Feel
- recognition.distance sets what counts as full progress: pixels, a CSS
  length, or a measuring function (#6).
- Choreographies receive overshoot, the drag past either end of the
  clamped progress, and the settling spring starts from it (#7).
- Velocity is the least-squares trend of the last 100ms (#8).
- Defaults retuned to commitThreshold 0.25 and commitVelocity 0.2; the
  slide presets use them (#9).
- Choreographies may set spring stiffness and damping (#10).

Hooks and helpers
- .onArm()/.onDisarm() run during a drag when releasing would start or
  stop committing (#12).
- startedIn, nearEdge, atScrollEdge, anyOf, allOf start predicates (#15).
- Development builds warn when an operation's first frame moves a node
  that was visibly at rest, the one-frame "pop" of mismatched open and
  close choreographies (#16).

Docs: scrolling, ignored elements, animation performance, haptics, and
operating-system edge gestures including CloseWatcher/popstate for PWAs
(#11, #17, #18).

Verification: a Chromium harness with emulated touch (verify/browser)
checks swipes over scrollers, native scrolling beside gestures,
scroll-edge dismissal, onCommit inside pointerup, and the continuity
warning. Reverting the earlier capture fix makes it fail exactly as
reported ("none of the handles work").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
2026-10-31 08:22:11 +00:00

772 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<OriginGestureDirection>,
): "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<PointerEvent, "clientX" | "clientY">,
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<PointerEvent, "clientX" | "clientY" | "timeStamp">;
/** 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<UseOrigin, "context" | "begin">;
/** 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<OriginOperationHandle | null> | 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<void> {
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<void> {
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<void> {
const session = this.sessionFor(event);
return session && event.target === session.host
? this.abandon()
: Promise.resolve();
}
pointerCancel(): Promise<void> {
return this.abandon();
}
/** Close the current session, cancelling its operation if captured. */
async abandon(): Promise<void> {
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<OriginAction | null> {
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<readonly OriginGestureDefinition[]>,
): 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<OriginGestureDefinition>,
): OriginGestureBinding {
return useOriginGestures(() => [toValue(definition)]);
}