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
772 lines
26 KiB
TypeScript
772 lines
26 KiB
TypeScript
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)]);
|
||
}
|