- Merge the small modules into 12 files: shared, choreography,
navigation, scene/{types,graph,scene}, gesture/{definition,recognizer}.
- SceneGraph now owns the begin/update/commit/cancel history rules as
methods, with the step helpers as private methods.
- createOriginScene returns a markRaw'd Scene class; operation handles
are an OperationHandle class instead of inline object literals.
- GestureRecognizer gains static create()/policyFor() factories, absorbing
the separate policy module.
- Pure helpers used by classes (matchesStart, pointOf, stepSpring,
composeNodeStyle, nodeEffectLayers, shouldCommit, ...) are exported
consts so they can be tested directly.
- Add haptics tests showing where navigator.vibrate can be triggered.
Public exports, types, and behavior are unchanged; the differential
suites against main still pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
588 lines
19 KiB
TypeScript
588 lines
19 KiB
TypeScript
import type { OriginAction, MaybeOriginAction } from "../navigation";
|
|
import { useOrigin, type UseOrigin } from "../scene/scene";
|
|
import type { OriginContext, OriginOperationHandle } from "../scene/types";
|
|
import { clamp01, measureRect, type OriginRect } from "../shared";
|
|
import type {
|
|
OriginGestureBinding,
|
|
OriginGestureCompletionPredicate,
|
|
OriginGestureDefinition,
|
|
OriginGestureDirection,
|
|
OriginGestureDistance,
|
|
OriginGestureOptions,
|
|
OriginGesturePoint,
|
|
OriginGestureStart,
|
|
} from "./definition";
|
|
|
|
/*
|
|
* Running gestures: pointer-down admission, movement tracking, and the
|
|
* recognizer that turns one host's pointer events into a scene operation.
|
|
*/
|
|
|
|
// Direction ----------------------------------------------------------------
|
|
|
|
export const isHorizontal = (direction: OriginGestureDirection): boolean => {
|
|
return direction === "left" || direction === "right";
|
|
};
|
|
|
|
/** Component of a movement `(dx, dy)` along `direction`. */
|
|
export const directedDistance = (
|
|
direction: OriginGestureDirection,
|
|
dx: number,
|
|
dy: number,
|
|
): number => {
|
|
switch (direction) {
|
|
case "left":
|
|
return -dx;
|
|
case "right":
|
|
return dx;
|
|
case "up":
|
|
return -dy;
|
|
case "down":
|
|
return dy;
|
|
}
|
|
};
|
|
|
|
/** Absolute movement perpendicular to `direction`. */
|
|
export const crossDistance = (
|
|
direction: OriginGestureDirection,
|
|
dx: number,
|
|
dy: number,
|
|
): number => {
|
|
return isHorizontal(direction) ? Math.abs(dy) : Math.abs(dx);
|
|
};
|
|
|
|
/**
|
|
* CSS `touch-action` for a host recognizing the given directions: native
|
|
* scrolling is preserved on any axis no gesture claims.
|
|
*/
|
|
export const touchActionFor = (
|
|
directions: Iterable<OriginGestureDirection>,
|
|
): "none" | "pan-x" | "pan-y" | "auto" => {
|
|
let horizontal = false;
|
|
let vertical = false;
|
|
for (const direction of directions) {
|
|
if (isHorizontal(direction)) horizontal = true;
|
|
else vertical = true;
|
|
}
|
|
if (horizontal && vertical) return "none";
|
|
if (horizontal) return "pan-y";
|
|
if (vertical) return "pan-x";
|
|
return "auto";
|
|
};
|
|
|
|
// Pointer-down admission ----------------------------------------------------
|
|
|
|
/*
|
|
* Pointer-down admission: which events may begin a gesture at all.
|
|
*/
|
|
|
|
const IGNORED_TARGETS =
|
|
'[data-origin-gesture="ignore"], input, textarea, select, option, [contenteditable="true"]';
|
|
|
|
/** Form controls and opted-out subtrees never start gestures. */
|
|
export const isIgnoredTarget = (target: EventTarget | null): boolean => {
|
|
return (
|
|
!(target instanceof Element) || Boolean(target.closest(IGNORED_TARGETS))
|
|
);
|
|
};
|
|
|
|
export const pointOf = (
|
|
event: Pick<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;
|
|
|
|
/**
|
|
* Resolve an arbitrary CSS length against a box with the gesture host's size.
|
|
*
|
|
* A short-lived off-screen box lets the browser handle `rem`, viewport units,
|
|
* percentages, `calc()`, and `clamp()` consistently. This runs only during
|
|
* pointer-down for edge-constrained definitions.
|
|
*/
|
|
export const resolveCssDistance = (
|
|
distance: OriginGestureDistance,
|
|
axis: "horizontal" | "vertical",
|
|
host: HTMLElement,
|
|
bounds: OriginRect,
|
|
): number => {
|
|
if (typeof distance === "number") return nonNegative(distance);
|
|
|
|
const document = host.ownerDocument;
|
|
if (!document.body) return Math.max(0, Number.parseFloat(distance) || 0);
|
|
|
|
const container = document.createElement("div");
|
|
const probe = document.createElement("div");
|
|
Object.assign(container.style, {
|
|
position: "fixed",
|
|
left: "-100000px",
|
|
top: "-100000px",
|
|
width: `${bounds.width}px`,
|
|
height: `${bounds.height}px`,
|
|
visibility: "hidden",
|
|
pointerEvents: "none",
|
|
contain: "strict",
|
|
});
|
|
Object.assign(probe.style, {
|
|
position: "absolute",
|
|
width: axis === "horizontal" ? distance : "0",
|
|
height: axis === "vertical" ? distance : "0",
|
|
});
|
|
container.append(probe);
|
|
document.body.append(container);
|
|
const rect = probe.getBoundingClientRect();
|
|
container.remove();
|
|
return nonNegative(axis === "horizontal" ? rect.width : rect.height);
|
|
};
|
|
|
|
/** Whether a pointer-down satisfies a definition's start policy. */
|
|
export const matchesStart = (
|
|
start: OriginGestureStart,
|
|
event: PointerEvent,
|
|
host: HTMLElement,
|
|
origin: OriginContext,
|
|
bounds: OriginRect,
|
|
): boolean => {
|
|
if (start.kind === "anywhere") return true;
|
|
const point = pointOf(event, bounds);
|
|
if (start.kind === "when")
|
|
return start.predicate({ event, origin, host, bounds, point });
|
|
|
|
const horizontal = start.edge === "left" || start.edge === "right";
|
|
const distance = resolveCssDistance(
|
|
start.distance,
|
|
horizontal ? "horizontal" : "vertical",
|
|
host,
|
|
bounds,
|
|
);
|
|
switch (start.edge) {
|
|
case "left":
|
|
return point.localX >= 0 && point.localX <= distance;
|
|
case "right":
|
|
return (
|
|
point.localX <= bounds.width && bounds.width - point.localX <= distance
|
|
);
|
|
case "top":
|
|
return point.localY >= 0 && point.localY <= distance;
|
|
case "bottom":
|
|
return (
|
|
point.localY <= bounds.height &&
|
|
bounds.height - point.localY <= distance
|
|
);
|
|
}
|
|
};
|
|
|
|
// Movement tracking ---------------------------------------------------------
|
|
|
|
type PointerSample = Pick<PointerEvent, "clientX" | "clientY" | "timeStamp">;
|
|
|
|
/** Shortest frame interval assumed when computing velocity, in ms. */
|
|
const MIN_SAMPLE_INTERVAL = 8;
|
|
/**
|
|
* A release without movement after this many ms is treated as a hold, so the
|
|
* last move's flick velocity no longer applies.
|
|
*/
|
|
const HOLD_RELEASE_INTERVAL = 80;
|
|
|
|
/**
|
|
* Measures one pointer sequence's movement along a gesture direction.
|
|
*
|
|
* Progress and velocity are normalized by the host's size along the gesture
|
|
* axis, so `1` means one full host width (or height).
|
|
*/
|
|
export class DirectionalTracker {
|
|
/** Directed distance from the start, in CSS pixels. */
|
|
distance = 0;
|
|
/** Absolute cross-axis distance from the start, in CSS pixels. */
|
|
crossDistance = 0;
|
|
/** Directed distance as a fraction of host size, clamped to `0..1`. */
|
|
progress = 0;
|
|
/** Latest directed velocity in progress units per second. */
|
|
velocity = 0;
|
|
|
|
private readonly horizontal: boolean;
|
|
/** Whether progress grows as the axis coordinate decreases. */
|
|
private readonly reversed: boolean;
|
|
private lastCoordinate: number;
|
|
private lastTime: number;
|
|
|
|
constructor(
|
|
private readonly direction: OriginGestureDirection,
|
|
private readonly start: PointerSample,
|
|
/** Host size along the gesture axis, read at every sample. */
|
|
private readonly axisSize: () => number,
|
|
) {
|
|
this.horizontal = isHorizontal(direction);
|
|
this.reversed = direction === "left" || direction === "up";
|
|
this.lastCoordinate = this.coordinateOf(start);
|
|
this.lastTime = start.timeStamp;
|
|
}
|
|
|
|
private coordinateOf(event: PointerSample) {
|
|
return this.horizontal ? event.clientX : event.clientY;
|
|
}
|
|
|
|
/** Movement from the start without recording a sample. */
|
|
displacement(event: PointerSample) {
|
|
const dx = event.clientX - this.start.clientX;
|
|
const dy = event.clientY - this.start.clientY;
|
|
return {
|
|
distance: directedDistance(this.direction, dx, dy),
|
|
crossDistance: crossDistance(this.direction, dx, dy),
|
|
};
|
|
}
|
|
|
|
/** Record a sample, updating distance, progress, and velocity. */
|
|
sample(event: PointerSample, release = false): void {
|
|
const { distance, crossDistance } = this.displacement(event);
|
|
this.distance = distance;
|
|
this.crossDistance = crossDistance;
|
|
|
|
const size = Math.max(1, this.axisSize());
|
|
const coordinate = this.coordinateOf(event);
|
|
const step = this.reversed
|
|
? this.lastCoordinate - coordinate
|
|
: coordinate - this.lastCoordinate;
|
|
const interval = event.timeStamp - this.lastTime;
|
|
this.progress = clamp01(distance / size);
|
|
// Pointer-up commonly repeats the final pointer-move coordinate. Preserve
|
|
// that move's flick velocity for a prompt release, but decay it when the
|
|
// pointer was held still long enough for the flick to have ended.
|
|
if (!release || step !== 0 || interval > HOLD_RELEASE_INTERVAL)
|
|
this.velocity =
|
|
(step * 1000) / (Math.max(MIN_SAMPLE_INTERVAL, interval) * size);
|
|
|
|
this.lastCoordinate = coordinate;
|
|
this.lastTime = event.timeStamp;
|
|
}
|
|
}
|
|
|
|
// Recognizer ----------------------------------------------------------------
|
|
|
|
/** Directed movement in CSS pixels required before a gesture captures. */
|
|
const DEFAULT_THRESHOLD = 8;
|
|
/** Ratio by which directed movement must exceed cross-axis movement. */
|
|
const DEFAULT_AXIS_DOMINANCE = 1.15;
|
|
|
|
/** Legacy `edge` start side for each movement direction. */
|
|
const LEGACY_START_EDGE = {
|
|
left: "right",
|
|
right: "left",
|
|
up: "bottom",
|
|
down: "top",
|
|
} as const;
|
|
|
|
/**
|
|
* The single shape the recognizer executes. Builder definitions and legacy
|
|
* options objects are both normalized into it.
|
|
*/
|
|
export interface RecognizerPolicy {
|
|
readonly direction: OriginGestureDirection;
|
|
readonly start: OriginGestureStart;
|
|
readonly threshold: number;
|
|
readonly axisDominance: number;
|
|
readonly completion?: OriginGestureCompletionPredicate;
|
|
readonly action: (context: OriginContext) => MaybeOriginAction;
|
|
}
|
|
|
|
/** The origin capabilities a recognizer needs. */
|
|
export type RecognizerOrigin = Pick<UseOrigin, "context" | "begin">;
|
|
|
|
/** State of one admitted pointer sequence, from pointer-down to release. */
|
|
interface PointerSession {
|
|
readonly pointerId: number;
|
|
readonly host: HTMLElement;
|
|
readonly bounds: OriginRect;
|
|
/** Origin context captured at pointer-down. */
|
|
readonly origin: OriginContext;
|
|
readonly start: OriginGesturePoint;
|
|
readonly startTime: number;
|
|
readonly tracker: DirectionalTracker;
|
|
/** Movement passed the recognition threshold and the pointer is captured. */
|
|
captured: boolean;
|
|
/** The scene operation, once the action resolved and `begin()` was called. */
|
|
operation: Promise<OriginOperationHandle | null> | null;
|
|
}
|
|
|
|
/**
|
|
* Turns one host's pointer events into a scene operation.
|
|
*
|
|
* Lifecycle of a session:
|
|
*
|
|
* 1. **down**: an eligible primary pointer opens a session.
|
|
* 2. **move**: once directed movement passes the threshold, the pointer is
|
|
* captured and the policy's action is resolved; a non-null action begins
|
|
* an operation, while a declined one closes the session. Every later move
|
|
* updates the operation's progress.
|
|
* 3. **up**: the session closes and the operation finishes, using the
|
|
* policy's completion predicate when present.
|
|
* 4. **cancel**: the session closes and the operation is cancelled.
|
|
*
|
|
* Action resolution and `begin()` are asynchronous. Each continuation checks
|
|
* that its session is still current, so a stale resolution never creates or
|
|
* updates an orphan operation.
|
|
*/
|
|
export class GestureRecognizer {
|
|
private session: PointerSession | null = null;
|
|
|
|
constructor(
|
|
readonly policy: RecognizerPolicy,
|
|
private readonly origin: RecognizerOrigin,
|
|
) {}
|
|
|
|
/** Create a recognizer for a builder definition or legacy options. */
|
|
static create(
|
|
definition: OriginGestureDefinition | OriginGestureOptions,
|
|
origin: RecognizerOrigin,
|
|
): GestureRecognizer {
|
|
return new GestureRecognizer(
|
|
GestureRecognizer.policyFor(definition),
|
|
origin,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Normalize a builder definition or legacy options object into the single
|
|
* policy shape the recognizer executes.
|
|
*/
|
|
static policyFor(
|
|
definition: OriginGestureDefinition | OriginGestureOptions,
|
|
): RecognizerPolicy {
|
|
return "kind" in definition &&
|
|
definition.kind === "origin-gesture-definition"
|
|
? GestureRecognizer.policyFromDefinition(definition)
|
|
: GestureRecognizer.policyFromOptions(definition as OriginGestureOptions);
|
|
}
|
|
|
|
private static policyFromDefinition(
|
|
definition: OriginGestureDefinition,
|
|
): RecognizerPolicy {
|
|
return {
|
|
direction: definition.direction,
|
|
start: definition.start,
|
|
threshold: definition.recognition.threshold ?? DEFAULT_THRESHOLD,
|
|
axisDominance:
|
|
definition.recognition.axisDominance ?? DEFAULT_AXIS_DOMINANCE,
|
|
completion: definition.completion,
|
|
// Pair the resolved navigation intent with the definition's animation.
|
|
action: async (context) => {
|
|
const navigation = await definition.navigation(context);
|
|
if (!navigation) return navigation;
|
|
const action: OriginAction = {
|
|
...navigation,
|
|
choreography: definition.choreography,
|
|
};
|
|
return action;
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Legacy `edge` is measured from the side opposite the movement. */
|
|
private static policyFromOptions(
|
|
options: OriginGestureOptions,
|
|
): RecognizerPolicy {
|
|
return {
|
|
direction: options.direction,
|
|
start:
|
|
options.edge === undefined
|
|
? { kind: "anywhere" }
|
|
: Object.freeze({
|
|
kind: "edge",
|
|
edge: LEGACY_START_EDGE[options.direction],
|
|
distance: options.edge,
|
|
}),
|
|
threshold: options.threshold ?? DEFAULT_THRESHOLD,
|
|
axisDominance: DEFAULT_AXIS_DOMINANCE,
|
|
action: options.action,
|
|
};
|
|
}
|
|
|
|
pointerDown(event: PointerEvent): void {
|
|
const host = event.currentTarget;
|
|
if (
|
|
!event.isPrimary ||
|
|
event.button !== 0 ||
|
|
!(host instanceof HTMLElement) ||
|
|
isIgnoredTarget(event.target)
|
|
)
|
|
return;
|
|
|
|
const bounds = measureRect(host);
|
|
const origin = this.origin.context.value;
|
|
if (!matchesStart(this.policy.start, event, host, origin, bounds)) return;
|
|
|
|
// The component containing this declaration is the operation's origin.
|
|
event.stopPropagation();
|
|
const horizontal = isHorizontal(this.policy.direction);
|
|
this.session = {
|
|
pointerId: event.pointerId,
|
|
host,
|
|
bounds,
|
|
origin,
|
|
start: pointOf(event, bounds),
|
|
startTime: event.timeStamp,
|
|
tracker: new DirectionalTracker(this.policy.direction, event, () =>
|
|
horizontal ? host.clientWidth : host.clientHeight,
|
|
),
|
|
captured: false,
|
|
operation: null,
|
|
};
|
|
}
|
|
|
|
async pointerMove(event: PointerEvent): Promise<void> {
|
|
const session = this.sessionFor(event);
|
|
if (!session) return;
|
|
|
|
if (!session.captured) {
|
|
if (!this.recognizes(session, event)) return;
|
|
session.captured = true;
|
|
session.host.setPointerCapture?.(session.pointerId);
|
|
event.preventDefault();
|
|
session.tracker.sample(event);
|
|
const action = await this.resolveAction();
|
|
// The pointer may have been released or cancelled meanwhile.
|
|
if (this.session !== session) return;
|
|
if (!action) {
|
|
this.session = null;
|
|
return;
|
|
}
|
|
session.operation = this.origin.begin(action).catch(() => null);
|
|
} else {
|
|
event.preventDefault();
|
|
session.tracker.sample(event);
|
|
}
|
|
|
|
// Progress is read after the await so the latest buffered sample wins.
|
|
const pending = session.operation;
|
|
const handle = pending ? await pending : null;
|
|
if (this.session === session && session.operation === pending)
|
|
handle?.update(session.tracker.progress, session.tracker.velocity);
|
|
}
|
|
|
|
async pointerUp(event: PointerEvent): Promise<void> {
|
|
const session = this.sessionFor(event);
|
|
if (!session) return;
|
|
this.session = null;
|
|
if (!session.captured) return;
|
|
|
|
const { tracker } = session;
|
|
tracker.sample(event, true);
|
|
const commit = this.policy.completion?.({
|
|
origin: session.origin,
|
|
direction: this.policy.direction,
|
|
progress: tracker.progress,
|
|
velocity: tracker.velocity,
|
|
distance: tracker.distance,
|
|
crossDistance: tracker.crossDistance,
|
|
duration: Math.max(0, event.timeStamp - session.startTime),
|
|
event,
|
|
host: session.host,
|
|
bounds: session.bounds,
|
|
start: session.start,
|
|
current: pointOf(event, session.bounds),
|
|
});
|
|
const { progress, velocity } = tracker;
|
|
|
|
const handle = session.operation ? await session.operation : null;
|
|
if (!handle) return;
|
|
handle.update(progress, velocity);
|
|
await handle.finish(commit === undefined ? undefined : { commit });
|
|
}
|
|
|
|
async pointerCancel(): Promise<void> {
|
|
const session = this.session;
|
|
this.session = null;
|
|
if (!session?.captured) return;
|
|
const handle = session.operation ? await session.operation : null;
|
|
await handle?.cancel();
|
|
}
|
|
|
|
private sessionFor(event: PointerEvent) {
|
|
const session = this.session;
|
|
return session && session.pointerId === event.pointerId ? session : null;
|
|
}
|
|
|
|
/** Directed movement passed the threshold and dominates the cross axis. */
|
|
private recognizes(session: PointerSession, event: PointerEvent) {
|
|
const { distance, crossDistance } = session.tracker.displacement(event);
|
|
return (
|
|
distance >= this.policy.threshold &&
|
|
distance >= crossDistance * this.policy.axisDominance
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Resolve the policy's action against the origin's current context. A
|
|
* declined or failed action resolves to `null`.
|
|
*/
|
|
private resolveAction(): Promise<OriginAction | null | undefined> {
|
|
try {
|
|
return Promise.resolve(
|
|
this.policy.action(this.origin.context.value),
|
|
).catch(() => null);
|
|
} catch {
|
|
return Promise.resolve(null);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Composable ----------------------------------------------------------------
|
|
|
|
/**
|
|
* Install a component-owned pointer recognizer.
|
|
*
|
|
* Recognition is local to the element receiving these handlers. There is no
|
|
* application-wide gesture table and no lookup of a currently active view.
|
|
*
|
|
* Builder definitions may independently describe their pointer-down region
|
|
* and movement direction. Omitting `.from` recognizes pointer-down across the
|
|
* whole element. The legacy options object remains supported; its `edge` is
|
|
* inferred from the opposite side of its movement direction.
|
|
*
|
|
* Interactive controls and anything inside
|
|
* `[data-origin-gesture="ignore"]` are ignored automatically. The recognizer
|
|
* preserves native scrolling on the cross-axis through its returned style.
|
|
*
|
|
* @param definition - An immutable builder result or legacy recognizer options.
|
|
* @returns Pointer handlers and required host styles.
|
|
* @throws If called outside a component rendered by `OriginScene`.
|
|
*
|
|
* @example Builder-defined backward gesture
|
|
* ```ts
|
|
* const swipeBack = useOriginGesture(
|
|
* gesture
|
|
* .from.left("32px")
|
|
* .to.right()
|
|
* .navigate((context) => context.canGoBack ? back() : null)
|
|
* .animate(slideRight),
|
|
* );
|
|
* ```
|
|
*/
|
|
export function useOriginGesture(
|
|
definition: OriginGestureDefinition | OriginGestureOptions,
|
|
): OriginGestureBinding {
|
|
const recognizer = GestureRecognizer.create(definition, useOrigin());
|
|
|
|
return {
|
|
style: {
|
|
// Preserve native scrolling perpendicular to the declared gesture.
|
|
touchAction: touchActionFor([recognizer.policy.direction]),
|
|
// OriginGesture is commonly the root returned by a view component.
|
|
width: "100%",
|
|
height: "100%",
|
|
},
|
|
onPointerdown: (event) => recognizer.pointerDown(event),
|
|
onPointermove: (event) => void recognizer.pointerMove(event),
|
|
onPointerup: (event) => void recognizer.pointerUp(event),
|
|
onPointercancel: () => void recognizer.pointerCancel(),
|
|
};
|
|
}
|