Consolidate core-v2 modules and move scene logic onto classes

- 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
This commit is contained in:
Claude
2026-10-16 02:49:56 +00:00
parent 329271691c
commit 511e58c198
35 changed files with 2177 additions and 1989 deletions
+413 -9
View File
@@ -1,12 +1,297 @@
import type { OriginAction } from "../actions/types";
import type { UseOrigin } from "../composables/useOrigin";
import type { OriginAction, MaybeOriginAction } from "../navigation";
import { useOrigin, type UseOrigin } from "../scene/scene";
import type { OriginContext, OriginOperationHandle } from "../scene/types";
import { measureRect, type OriginRect } from "../shared/geometry";
import { isHorizontal } from "./direction";
import type { RecognizerPolicy } from "./policy";
import { isIgnoredTarget, matchesStart, pointOf } from "./start";
import { DirectionalTracker } from "./tracker";
import type { OriginGesturePoint } from "./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">;
@@ -49,10 +334,77 @@ export class GestureRecognizer {
private session: PointerSession | null = null;
constructor(
private readonly policy: RecognizerPolicy,
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 (
@@ -181,3 +533,55 @@ export class GestureRecognizer {
}
}
}
// 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(),
};
}