From 8448e910ed211a46765c078aadcb9c484f94388e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 03:08:12 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR --- packages/core-v2/API.md | 25 +++++ packages/core-v2/src/gesture/definition.ts | 121 +++++++++++++++------ packages/core-v2/src/gesture/recognizer.ts | 30 ++++- packages/core-v2/src/haptics.test.ts | 94 ++++++++++++++++ packages/core-v2/src/scene/scene.ts | 23 +++- packages/core-v2/src/scene/types.ts | 5 + 6 files changed, 255 insertions(+), 43 deletions(-) diff --git a/packages/core-v2/API.md b/packages/core-v2/API.md index 903b1cf..c91414e 100644 --- a/packages/core-v2/API.md +++ b/packages/core-v2/API.md @@ -411,6 +411,7 @@ The stages have distinct responsibilities: | `.to.*` | Required movement direction and intent-recognition options | | `.complete(predicate)` | Optional release commit/cancel decision | | `.navigate(factory)` | Required target and retained-history intent | +| `.onCommit(handler)` | Optional side effect when a release commits | | `.animate(routine)` | Required source/target/frame choreography | The builder is persistent and immutable. Reusing an earlier stage cannot @@ -432,6 +433,27 @@ velocity, directed pixel distance, cross-axis distance, duration, pointer-up event, host, bounds, and start/current points. Completion predicates are synchronous because they select operation intent at release. +`.onCommit(handler)` is available at every stage after `.to.*`, returns the +same stage, and may be called more than once; handlers run in order. A handler +runs once, synchronously at release, after the operation commits and before +the settling animation, and receives the same context as `.complete()`. It +never runs for a cancelled release. A throwing handler is reported as an +uncaught error without stopping later handlers or the operation. It is the +place for haptic feedback: + +```ts +const openProfile = gesture.to + .left() + .navigate(() => forward(originView(ProfileView))) + .onCommit(() => navigator.vibrate?.(10)) + .animate(slideLeft); +``` + +`navigator.vibrate` is unavailable in iOS Safari; Capacitor apps can call +`Haptics.impact()` from `@capacitor/haptics` instead. The method is named +`onCommit` rather than `then` so builder stages never become promise-like +"thenables" when awaited or returned from an async function. + ### `useOriginGesture(definition)` Creates one primary-pointer, single-axis recognizer owned by the component that @@ -640,6 +662,9 @@ can immediately begin its own outgoing operation, enabling X → Y → Z chains. ### `OriginOperationHandle` +`intent` reports the selected outcome (`undecided`, `commit`, or `cancel`). It +changes synchronously when `finish()` or `cancel()` is called. + | Member | Description | | ----------------------------- | --------------------------------------------------- | | `id` | Unique operation ID | diff --git a/packages/core-v2/src/gesture/definition.ts b/packages/core-v2/src/gesture/definition.ts index 7bd74b5..9a6487d 100644 --- a/packages/core-v2/src/gesture/definition.ts +++ b/packages/core-v2/src/gesture/definition.ts @@ -168,8 +168,22 @@ export interface OriginGestureDefinition { readonly navigation: OriginGestureNavigationFactory; /** Visual routine paired with the navigation intent. */ readonly choreography: OriginChoreography; + /** Handlers run, in order, when a release commits. See `.onCommit()`. */ + readonly commitHandlers?: readonly OriginGestureCommitHandler[]; } +/** + * Side effect run when a released gesture commits, such as haptic feedback. + * + * It runs once, synchronously at release, after the commit decision and + * before the settling animation. It receives the same context as a + * `.complete()` predicate. Errors are reported without interrupting the + * operation. + */ +export type OriginGestureCommitHandler = ( + context: OriginGestureCompletionContext, +) => void; + /** Direction-selection stage shared by `gesture.to` and `.from.*().to`. */ export interface OriginGestureToBuilder { /** Recognize leftward pointer movement. */ @@ -214,6 +228,8 @@ export interface OriginGestureDirectedBuilder { navigate( factory: OriginGestureNavigationFactory, ): OriginGestureNavigationBuilder; + /** Run `handler` when a release commits. Handlers accumulate in order. */ + onCommit(handler: OriginGestureCommitHandler): OriginGestureDirectedBuilder; } /** Builder stage after a custom completion policy has been selected. */ @@ -222,12 +238,16 @@ export interface OriginGestureCompletedBuilder { navigate( factory: OriginGestureNavigationFactory, ): OriginGestureNavigationBuilder; + /** Run `handler` when a release commits. Handlers accumulate in order. */ + onCommit(handler: OriginGestureCommitHandler): OriginGestureCompletedBuilder; } /** Final builder stage waiting for visual choreography. */ export interface OriginGestureNavigationBuilder { /** Attach visual choreography and produce an executable definition. */ animate(choreography: OriginChoreography): OriginGestureDefinition; + /** Run `handler` when a release commits. Handlers accumulate in order. */ + onCommit(handler: OriginGestureCommitHandler): OriginGestureNavigationBuilder; } /** Root of the immutable fluent gesture-definition API. */ @@ -368,55 +388,86 @@ export interface OriginGestureSurfaceProps { // Builder ------------------------------------------------------------------ /* - * Immutable fluent builder. Each stage is a frozen object closing over the - * choices made so far; the final `.animate()` produces a frozen definition. + * Immutable fluent builder. Each stage is a frozen object holding the choices + * made so far in a frozen draft; the final `.animate()` produces a frozen + * definition. `.onCommit()` is available at every stage after the direction + * and returns the same stage with the handler appended. * * gesture[.from.].to.()[.complete()].navigate().animate() */ -function navigationStage( - start: OriginGestureStart, - direction: OriginGestureDirection, - recognition: Readonly, - completion: OriginGestureCompletionPredicate | undefined, -) { - return ( - navigation: OriginGestureNavigationFactory, - ): OriginGestureNavigationBuilder => - Object.freeze({ - animate: (choreography: OriginChoreography): OriginGestureDefinition => - Object.freeze({ - kind: "origin-gesture-definition", - start, - direction, - recognition, - completion, - navigation, - choreography, - }), - }); +/** Choices accumulated by the builder before `.animate()`. */ +interface GestureDraft { + readonly start: OriginGestureStart; + readonly direction: OriginGestureDirection; + readonly recognition: Readonly; + readonly completion?: OriginGestureCompletionPredicate; + readonly commitHandlers: readonly OriginGestureCommitHandler[]; } -function directedStage( - start: OriginGestureStart, - direction: OriginGestureDirection, - options: OriginGestureDirectionOptions = {}, -): OriginGestureDirectedBuilder { - const recognition = Object.freeze({ ...options }); +const withCommitHandler = ( + draft: Draft, + handler: OriginGestureCommitHandler, +): Draft => + Object.freeze({ + ...draft, + commitHandlers: Object.freeze([...draft.commitHandlers, handler]), + }); + +function navigationStage( + draft: GestureDraft & { readonly navigation: OriginGestureNavigationFactory }, +): OriginGestureNavigationBuilder { + return Object.freeze({ + animate: (choreography: OriginChoreography): OriginGestureDefinition => + Object.freeze({ + kind: "origin-gesture-definition", + start: draft.start, + direction: draft.direction, + recognition: draft.recognition, + completion: draft.completion, + navigation: draft.navigation, + choreography, + commitHandlers: draft.commitHandlers, + }), + onCommit: (handler: OriginGestureCommitHandler) => + navigationStage(withCommitHandler(draft, handler)), + }); +} + +const navigateFrom = + (draft: GestureDraft) => (navigation: OriginGestureNavigationFactory) => + navigationStage(Object.freeze({ ...draft, navigation })); + +function completedStage(draft: GestureDraft): OriginGestureCompletedBuilder { + return Object.freeze({ + navigate: navigateFrom(draft), + onCommit: (handler: OriginGestureCommitHandler) => + completedStage(withCommitHandler(draft, handler)), + }); +} + +function directedStage(draft: GestureDraft): OriginGestureDirectedBuilder { return Object.freeze({ complete: (completion: OriginGestureCompletionPredicate) => - Object.freeze({ - navigate: navigationStage(start, direction, recognition, completion), - }), - navigate: navigationStage(start, direction, recognition, undefined), + completedStage(Object.freeze({ ...draft, completion })), + navigate: navigateFrom(draft), + onCommit: (handler: OriginGestureCommitHandler) => + directedStage(withCommitHandler(draft, handler)), }); } function toStage(start: OriginGestureStart): OriginGestureToBuilder { const direct = (direction: OriginGestureDirection) => - (options?: OriginGestureDirectionOptions) => - directedStage(start, direction, options); + (options: OriginGestureDirectionOptions = {}) => + directedStage( + Object.freeze({ + start, + direction, + recognition: Object.freeze({ ...options }), + commitHandlers: Object.freeze([]), + }), + ); return Object.freeze({ left: direct("left"), right: direct("right"), diff --git a/packages/core-v2/src/gesture/recognizer.ts b/packages/core-v2/src/gesture/recognizer.ts index 31d6229..8f3cfa9 100644 --- a/packages/core-v2/src/gesture/recognizer.ts +++ b/packages/core-v2/src/gesture/recognizer.ts @@ -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 { diff --git a/packages/core-v2/src/haptics.test.ts b/packages/core-v2/src/haptics.test.ts index f41e242..d939045 100644 --- a/packages/core-v2/src/haptics.test.ts +++ b/packages/core-v2/src/haptics.test.ts @@ -175,3 +175,97 @@ describe("haptic feedback", () => { expect(vibrate).toHaveBeenCalledTimes(1); }); }); + +describe(".onCommit()", () => { + it("vibrates once when a release commits, and not when it cancels", async () => { + const vibrate = vi.fn((_ms: number) => true); + const { host } = await mount( + gesture.to + .left() + .navigate(() => forward(originView(Target))) + .onCommit(() => vibrate(10)) + .animate(motion), + ); + + await swipe(host, 20, []); // 10% progress: cancelled + expect(vibrate).not.toHaveBeenCalled(); + await swipe(host, 150, []); // 75% progress: committed + expect(vibrate).toHaveBeenCalledTimes(1); + expect(vibrate).toHaveBeenCalledWith(10); + }); + + it("is available at every stage after the direction, in order", async () => { + const calls: string[] = []; + const definition = gesture.to + .left() + .onCommit(() => calls.push("directed")) + .complete(({ progress }) => progress > 0.5) + .onCommit(() => calls.push("completed")) + .navigate(() => forward(originView(Target))) + .onCommit((context) => calls.push(`navigation ${context.direction}`)) + .animate(motion); + expect(definition.commitHandlers).toHaveLength(3); + expect(Object.isFrozen(definition.commitHandlers)).toBe(true); + + const { host } = await mount(definition); + await swipe(host, 150, []); + expect(calls).toEqual(["directed", "completed", "navigation left"]); + }); + + it("keeps earlier builder stages unchanged", () => { + const base = gesture.to.left().navigate(() => null); + const withHandler = base.onCommit(() => {}); + expect(base.animate(motion).commitHandlers).toEqual([]); + expect(withHandler.animate(motion).commitHandlers).toHaveLength(1); + }); + + it("reports a failing handler without stopping later handlers or the commit", async () => { + const errors: unknown[] = []; + const onError = (error: unknown) => errors.push(error); + process.on("uncaughtException", onError); + try { + const after = vi.fn(); + const { scene, host } = await mount( + gesture.to + .left() + .navigate(() => forward(originView(Target))) + .onCommit(() => { + throw new Error("no vibration motor"); + }) + .onCommit(after) + .animate(motion), + ); + await swipe(host, 150, []); + expect(after).toHaveBeenCalledTimes(1); + expect(scene.nodes.value.map((node) => node.state)).toEqual([ + "parked", + "active", + ]); + expect(errors).toEqual([new Error("no vibration motor")]); + } finally { + process.off("uncaughtException", onError); + } + }); + + it("fires at release, before the settling animation finishes", async () => { + const frames: FrameRequestCallback[] = []; + vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => + frames.push(cb), + ); + const log: string[] = []; + const { scene, host } = await mount( + gesture.to + .left() + .navigate(() => forward(originView(Target))) + .onCommit(() => log.push("commit")) + .animate(motion), + ); + vi.stubGlobal("matchMedia", () => ({ matches: false })); + + await swipe(host, 150, log); + expect(log).toEqual(["recognized", "commit", "released"]); + // The spring is still waiting for frames; the operation is settling. + expect(scene.operations.value[0]?.phase).toBe("settling"); + expect(frames.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/core-v2/src/scene/scene.ts b/packages/core-v2/src/scene/scene.ts index 26da1b7..c744b49 100644 --- a/packages/core-v2/src/scene/scene.ts +++ b/packages/core-v2/src/scene/scene.ts @@ -12,6 +12,7 @@ import { normalizedEffect, type OriginEffect, type OriginEffectSet, + type OriginOperationIntent, } from "../choreography"; import type { OriginAction, OriginView } from "../navigation"; import { @@ -225,13 +226,25 @@ export const runSpring = ( /** Imperative controller for one operation, returned by `Scene.begin()`. */ class OperationHandle implements OriginOperationHandle { + readonly id: number; + readonly originKey: string; + readonly targetKey: string; + constructor( private readonly scene: Scene, - readonly id: number, - readonly originKey: string, - readonly targetKey: string, + /** Kept after the graph drops it, so `intent` stays readable. */ + private readonly operation: MutableOriginOperation, + /** The target at creation; later edge rewrites do not change it. */ + targetKey: string, ) { markRaw(this); + this.id = operation.id; + this.originKey = operation.originKey; + this.targetKey = targetKey; + } + + get intent(): OriginOperationIntent { + return this.operation.intent; } update(progress: number, velocity = 0): void { @@ -299,14 +312,14 @@ class Scene implements OriginScene { action, this.measure(originKey), ); - const { id, targetKey } = operation; + const { targetKey } = operation; // Let Vue mount the target before measuring it. Gesture recognizers // buffer pointer progress while this short preparation step is pending. await nextTick(); operation.targetRect = this.measure(targetKey); operation.phase = "interactive"; - return new OperationHandle(this, id, originKey, targetKey); + return new OperationHandle(this, operation, targetKey); } async perform(originKey: string, action: OriginAction): Promise { diff --git a/packages/core-v2/src/scene/types.ts b/packages/core-v2/src/scene/types.ts index e50f51b..fa84778 100644 --- a/packages/core-v2/src/scene/types.ts +++ b/packages/core-v2/src/scene/types.ts @@ -118,6 +118,11 @@ export interface OriginOperationHandle { readonly originKey: string; /** Key of the newly mounted or retained target node. */ readonly targetKey: string; + /** + * The operation's selected outcome. It becomes `commit` or `cancel` + * synchronously when `finish()` or `cancel()` is called, before settling. + */ + readonly intent: OriginOperationIntent; /** * Set interactive progress and optional velocity. *