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:
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user