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. *