Add .onCommit() to the gesture builder

`.onCommit(handler)` is available at every builder stage after the
direction, returns the same stage, and accumulates handlers in order.
Handlers run once, synchronously at release, when the operation commits
and before the settling animation, with the same context `.complete()`
receives. A throwing handler is reported without stopping later handlers
or the operation. Intended for haptic feedback such as
`navigator.vibrate`.

Named onCommit rather than then so builder stages never become
thenables. The builder now threads one frozen draft through its stages.

OriginOperationHandle gains a read-only `intent`, which the recognizer
reads right after finish() to know whether the release committed.

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 03:08:12 +00:00
parent 511e58c198
commit 8448e910ed
6 changed files with 255 additions and 43 deletions
+27 -3
View File
@@ -4,6 +4,8 @@ import type { OriginContext, OriginOperationHandle } from "../scene/types";
import { clamp01, measureRect, type OriginRect } from "../shared";
import type {
OriginGestureBinding,
OriginGestureCommitHandler,
OriginGestureCompletionContext,
OriginGestureCompletionPredicate,
OriginGestureDefinition,
OriginGestureDirection,
@@ -290,6 +292,7 @@ export interface RecognizerPolicy {
readonly threshold: number;
readonly axisDominance: number;
readonly completion?: OriginGestureCompletionPredicate;
readonly commitHandlers: readonly OriginGestureCommitHandler[];
readonly action: (context: OriginContext) => MaybeOriginAction;
}
@@ -372,6 +375,7 @@ export class GestureRecognizer {
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);
@@ -402,6 +406,7 @@ export class GestureRecognizer {
threshold: options.threshold ?? DEFAULT_THRESHOLD,
axisDominance: DEFAULT_AXIS_DOMINANCE,
action: options.action,
commitHandlers: [],
};
}
@@ -475,7 +480,7 @@ export class GestureRecognizer {
const { tracker } = session;
tracker.sample(event, true);
const commit = this.policy.completion?.({
const context: OriginGestureCompletionContext = {
origin: session.origin,
direction: this.policy.direction,
progress: tracker.progress,
@@ -488,13 +493,32 @@ export class GestureRecognizer {
bounds: session.bounds,
start: session.start,
current: pointOf(event, session.bounds),
});
};
const commit = this.policy.completion?.(context);
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 });
// finish() fixes the outcome synchronously, before the spring settles.
const settled = handle.finish(
commit === undefined ? undefined : { commit },
);
if (handle.intent === "commit") this.notifyCommit(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;
});
}
}
}
async pointerCancel(): Promise<void> {