Fix stuck gestures and scene graph races; honour commitVelocity
Gestures - Cancel a captured gesture on lostpointercapture, on a new primary pointer-down, and when its component unmounts, so operations are never left stuck mid-transition. Bindings gain onLostpointercapture. - Warn (console.warn) when navigate() fails or the scene rejects an operation; stay quiet when two recognizers race for one origin. - OriginGesture and OriginGestureSurface props are reactive. Changes apply from the next pointer-down; recognizers are kept per list position so re-renders never interrupt a drag. - Remove the legacy direction/edge/threshold/action gesture form (OriginGestureOptions, legacy OriginGesture props, MaybeOriginAction). Scene - Honour choreography commitVelocity (default 0.3, as before). - Scene errors are OriginSceneError with a stable code. - update() during settling now really catches an operation: the spring stops, and only the latest finish() decides the outcome. - Fix graph corruption found by the new property tests, all present on main: replacements escaping a cancelled push, edges left dangling when their current target differs from the original one, revealed targets keeping operations after their back is cancelled, history pointing at removed entries after replace, visual cycles from back through an undecided replace, exposed nodes reactivated as active, and a cancel cascade that could empty the whole scene. Tests - Add property tests checking structural invariants after every step of random operation sequences and pointer streams (PROPERTY_SEEDS). - Add recognizer tests for stuck gestures, warnings, reactive props, and commitVelocity. Drop the main-comparison suites; keep benchmarks. Also fix the origins demo's two pre-existing type errors. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
This commit is contained in:
@@ -1,16 +1,15 @@
|
||||
import type { OriginAction, MaybeOriginAction } from "../navigation";
|
||||
import { computed, onScopeDispose, toValue, type MaybeRefOrGetter } from "vue";
|
||||
import type { OriginAction } from "../navigation";
|
||||
import { OriginSceneError } from "../scene/graph";
|
||||
import { useOrigin, type UseOrigin } from "../scene/scene";
|
||||
import type { OriginContext, OriginOperationHandle } from "../scene/types";
|
||||
import { clamp01, measureRect, type OriginRect } from "../shared";
|
||||
import { clamp01, measureRect, warn, type OriginRect } from "../shared";
|
||||
import type {
|
||||
OriginGestureBinding,
|
||||
OriginGestureCommitHandler,
|
||||
OriginGestureCompletionContext,
|
||||
OriginGestureCompletionPredicate,
|
||||
OriginGestureDefinition,
|
||||
OriginGestureCompletionContext,
|
||||
OriginGestureDirection,
|
||||
OriginGestureDistance,
|
||||
OriginGestureOptions,
|
||||
OriginGesturePoint,
|
||||
OriginGestureStart,
|
||||
} from "./definition";
|
||||
@@ -285,33 +284,13 @@ 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 commitHandlers: readonly OriginGestureCommitHandler[];
|
||||
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 {
|
||||
/** Definition current at pointer-down; later changes do not affect it. */
|
||||
readonly definition: OriginGestureDefinition;
|
||||
readonly pointerId: number;
|
||||
readonly host: HTMLElement;
|
||||
readonly bounds: OriginRect;
|
||||
@@ -322,7 +301,7 @@ interface PointerSession {
|
||||
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. */
|
||||
/** The scene operation, once navigation resolved and `begin()` was called. */
|
||||
operation: Promise<OriginOperationHandle | null> | null;
|
||||
}
|
||||
|
||||
@@ -333,119 +312,58 @@ interface PointerSession {
|
||||
*
|
||||
* 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
|
||||
* 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
|
||||
* policy's completion predicate when present.
|
||||
* 4. **cancel**: the session closes and the operation is cancelled.
|
||||
* 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.
|
||||
*
|
||||
* Action resolution and `begin()` are asynchronous. Each continuation checks
|
||||
* that its session is still current, so a stale resolution never creates or
|
||||
* 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(
|
||||
readonly policy: RecognizerPolicy,
|
||||
/** Reads the current definition; it may change between sessions. */
|
||||
private readonly definitionOf: () => OriginGestureDefinition,
|
||||
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,
|
||||
commitHandlers: definition.commitHandlers ?? [],
|
||||
// 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,
|
||||
commitHandlers: [],
|
||||
};
|
||||
}
|
||||
|
||||
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.isPrimary ||
|
||||
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(this.policy.start, event, host, origin, bounds)) return;
|
||||
if (!matchesStart(definition.start, event, host, origin, bounds)) return;
|
||||
|
||||
// The component containing this declaration is the operation's origin.
|
||||
event.stopPropagation();
|
||||
const horizontal = isHorizontal(this.policy.direction);
|
||||
const horizontal = isHorizontal(definition.direction);
|
||||
this.session = {
|
||||
definition,
|
||||
pointerId: event.pointerId,
|
||||
host,
|
||||
bounds,
|
||||
origin,
|
||||
start: pointOf(event, bounds),
|
||||
startTime: event.timeStamp,
|
||||
tracker: new DirectionalTracker(this.policy.direction, event, () =>
|
||||
tracker: new DirectionalTracker(definition.direction, event, () =>
|
||||
horizontal ? host.clientWidth : host.clientHeight,
|
||||
),
|
||||
captured: false,
|
||||
@@ -458,19 +376,26 @@ export class GestureRecognizer {
|
||||
if (!session) return;
|
||||
|
||||
if (!session.captured) {
|
||||
if (!this.recognizes(session, event)) return;
|
||||
if (!GestureRecognizer.recognizes(session, event)) return;
|
||||
session.captured = true;
|
||||
session.host.setPointerCapture?.(session.pointerId);
|
||||
event.preventDefault();
|
||||
session.tracker.sample(event);
|
||||
const action = await this.resolveAction();
|
||||
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).catch(() => null);
|
||||
session.operation = this.origin.begin(action).catch((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);
|
||||
@@ -489,11 +414,11 @@ export class GestureRecognizer {
|
||||
this.session = null;
|
||||
if (!session.captured) return;
|
||||
|
||||
const { tracker } = session;
|
||||
const { tracker, definition } = session;
|
||||
tracker.sample(event, true);
|
||||
const context: OriginGestureCompletionContext = {
|
||||
origin: session.origin,
|
||||
direction: this.policy.direction,
|
||||
direction: definition.direction,
|
||||
progress: tracker.progress,
|
||||
velocity: tracker.velocity,
|
||||
distance: tracker.distance,
|
||||
@@ -505,7 +430,7 @@ export class GestureRecognizer {
|
||||
start: session.start,
|
||||
current: pointOf(event, session.bounds),
|
||||
};
|
||||
const commit = this.policy.completion?.(context);
|
||||
const commit = definition.completion?.(context);
|
||||
const { progress, velocity } = tracker;
|
||||
|
||||
const handle = session.operation ? await session.operation : null;
|
||||
@@ -515,24 +440,22 @@ export class GestureRecognizer {
|
||||
const settled = handle.finish(
|
||||
commit === undefined ? undefined : { commit },
|
||||
);
|
||||
if (handle.intent === "commit") this.notifyCommit(context);
|
||||
if (handle.intent === "commit")
|
||||
GestureRecognizer.notifyCommit(definition, context);
|
||||
await settled;
|
||||
}
|
||||
|
||||
/** Run commit handlers; a failing handler never interrupts the others. */
|
||||
private notifyCommit(context: OriginGestureCompletionContext) {
|
||||
for (const handler of this.policy.commitHandlers) {
|
||||
try {
|
||||
handler(context);
|
||||
} catch (error) {
|
||||
queueMicrotask(() => {
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
}
|
||||
/** Pointer capture ended without a pointer-up; cancel like pointer-cancel. */
|
||||
pointerCaptureLost(event: PointerEvent): Promise<void> {
|
||||
return this.sessionFor(event) ? this.abandon() : Promise.resolve();
|
||||
}
|
||||
|
||||
async pointerCancel(): Promise<void> {
|
||||
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;
|
||||
@@ -546,30 +469,122 @@ export class GestureRecognizer {
|
||||
}
|
||||
|
||||
/** Directed movement passed the threshold and dominates the cross axis. */
|
||||
private recognizes(session: PointerSession, event: PointerEvent) {
|
||||
private static recognizes(session: PointerSession, event: PointerEvent) {
|
||||
const { recognition } = session.definition;
|
||||
const { distance, crossDistance } = session.tracker.displacement(event);
|
||||
return (
|
||||
distance >= this.policy.threshold &&
|
||||
distance >= crossDistance * this.policy.axisDominance
|
||||
distance >= (recognition.threshold ?? DEFAULT_THRESHOLD) &&
|
||||
distance >=
|
||||
crossDistance * (recognition.axisDominance ?? DEFAULT_AXIS_DOMINANCE)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the policy's action against the origin's current context. A
|
||||
* declined or failed action resolves to `null`.
|
||||
* 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(): Promise<OriginAction | null | undefined> {
|
||||
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(
|
||||
this.policy.action(this.origin.context.value),
|
||||
).catch(() => null);
|
||||
} catch {
|
||||
return Promise.resolve(null);
|
||||
definition.navigation(this.origin.context.value),
|
||||
).then(
|
||||
(navigation) =>
|
||||
navigation
|
||||
? { ...navigation, choreography: definition.choreography }
|
||||
: null,
|
||||
fail,
|
||||
);
|
||||
} catch (error) {
|
||||
return Promise.resolve(fail(error));
|
||||
}
|
||||
}
|
||||
|
||||
/** Run commit handlers; a failing handler never interrupts the others. */
|
||||
private static notifyCommit(
|
||||
definition: OriginGestureDefinition,
|
||||
context: OriginGestureCompletionContext,
|
||||
) {
|
||||
for (const handler of definition.commitHandlers ?? []) {
|
||||
try {
|
||||
handler(context);
|
||||
} catch (error) {
|
||||
queueMicrotask(() => {
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Composable ----------------------------------------------------------------
|
||||
// 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);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Install a component-owned pointer recognizer.
|
||||
@@ -579,14 +594,14 @@ export class GestureRecognizer {
|
||||
*
|
||||
* 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.
|
||||
* 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 legacy recognizer options.
|
||||
* @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`.
|
||||
*
|
||||
@@ -602,21 +617,7 @@ export class GestureRecognizer {
|
||||
* ```
|
||||
*/
|
||||
export function useOriginGesture(
|
||||
definition: OriginGestureDefinition | OriginGestureOptions,
|
||||
definition: MaybeRefOrGetter<OriginGestureDefinition>,
|
||||
): 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(),
|
||||
};
|
||||
return useOriginGestures(() => [toValue(definition)]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user