From 6889fc0112ce2cc784ec317d9aaf374d5c036dd3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 08:22:11 +0000 Subject: [PATCH] Make gestures cooperate with scrolling and feel smoother From an app built on core-v2: Scrolling (#3, #4, #14) - Each recognizer locks onto a touch within its first 6px: along its axis and direction it holds the touch, otherwise it lets go. A new onTouchmove binding cancels native scrolling only while a recognizer holds the touch, so hosts use touch-action: manipulation and one vertical gesture no longer disables vertical scrolling. Several gestures on one host share touches through the same rule. - .from.scrollEdge(scroller, edge) admits drag-to-dismiss only while a scroller is at the edge (#5). Feel - recognition.distance sets what counts as full progress: pixels, a CSS length, or a measuring function (#6). - Choreographies receive overshoot, the drag past either end of the clamped progress, and the settling spring starts from it (#7). - Velocity is the least-squares trend of the last 100ms (#8). - Defaults retuned to commitThreshold 0.25 and commitVelocity 0.2; the slide presets use them (#9). - Choreographies may set spring stiffness and damping (#10). Hooks and helpers - .onArm()/.onDisarm() run during a drag when releasing would start or stop committing (#12). - startedIn, nearEdge, atScrollEdge, anyOf, allOf start predicates (#15). - Development builds warn when an operation's first frame moves a node that was visibly at rest, the one-frame "pop" of mismatched open and close choreographies (#16). Docs: scrolling, ignored elements, animation performance, haptics, and operating-system edge gestures including CloseWatcher/popstate for PWAs (#11, #17, #18). Verification: a Chromium harness with emulated touch (verify/browser) checks swipes over scrollers, native scrolling beside gestures, scroll-edge dismissal, onCommit inside pointerup, and the continuity warning. Reverting the earlier capture fix makes it fail exactly as reported ("none of the handles work"). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR --- packages/core-v2/API.md | 252 +++++++++++-- packages/core-v2/src/choreography.ts | 35 +- .../core-v2/src/components/OriginGesture.vue | 1 + .../src/components/OriginGestureSurface.vue | 1 + packages/core-v2/src/gesture/definition.ts | 186 ++++++++- packages/core-v2/src/gesture/recognizer.ts | 302 ++++++++++----- packages/core-v2/src/index.ts | 10 +- packages/core-v2/src/scene/graph.ts | 7 +- packages/core-v2/src/scene/scene.ts | 137 ++++++- packages/core-v2/src/scene/types.ts | 9 +- packages/core-v2/src/shared.ts | 7 + packages/core-v2/tests/gesture.test.ts | 2 +- packages/core-v2/tests/interaction.test.ts | 355 ++++++++++++++++++ packages/core-v2/tests/recognizer.test.ts | 17 +- packages/core-v2/verify/.gitignore | 1 + packages/core-v2/verify/README.md | 17 + .../core-v2/verify/browser/app/index.html | 34 ++ packages/core-v2/verify/browser/app/main.ts | 140 +++++++ .../verify/browser/app/vite.config.mjs | 8 + packages/core-v2/verify/browser/run.mjs | 189 ++++++++++ 20 files changed, 1547 insertions(+), 163 deletions(-) create mode 100644 packages/core-v2/tests/interaction.test.ts create mode 100644 packages/core-v2/verify/browser/app/index.html create mode 100644 packages/core-v2/verify/browser/app/main.ts create mode 100644 packages/core-v2/verify/browser/app/vite.config.mjs create mode 100644 packages/core-v2/verify/browser/run.mjs diff --git a/packages/core-v2/API.md b/packages/core-v2/API.md index e799a89..a9acf52 100644 --- a/packages/core-v2/API.md +++ b/packages/core-v2/API.md @@ -20,6 +20,7 @@ This document describes the experimental `0.1.0-experimental.0` API. - [Scene diagnostics and manual operations](#scene-diagnostics-and-manual-operations) - [Type reference](#type-reference) - [Errors and constraints](#errors-and-constraints) +- [Gestures in real apps](#gestures-in-real-apps) ## Gesture start recognition @@ -43,6 +44,7 @@ gesture.from.top(distance); gesture.from.bottom(distance); gesture.from.anywhere(); gesture.from.when(predicate); +gesture.from.scrollEdge(scroller, edge); ``` `distance` accepts a number in CSS pixels or a CSS length string, including @@ -80,9 +82,43 @@ const dropDialog = gesture.from .animate(dropAnimation); ``` -Interactive form controls are ignored automatically. Add +`.from.scrollEdge(scroller, edge)` admits pointer-down only while a scroll +container is scrolled all the way to `edge`. It is the drag-down-to-dismiss +rule for sheets and pushed pages: at the top, dragging down dismisses and +dragging up still scrolls; anywhere else the list just scrolls. `scroller` is +an element, a ref or getter returning one, or a CSS selector resolved inside +the gesture host. A scroller that is not mounted counts as at the edge. + +```ts +const list = ref(null); +const dismiss = gesture.from + .scrollEdge(list, "top") + .to.down() + .navigate((context) => (context.canGoBack ? back() : null)) + .animate(sheetDown); +``` + +Start rules also come as composable predicates for `.from.when()`: + +| Helper | Admits pointer-down | +| ------------------------------ | ------------------------------------------- | +| `startedIn(selector)` | On or inside an element matching `selector` | +| `nearEdge(edge, distance)` | Within `distance` of one side of the host | +| `atScrollEdge(scroller, edge)` | While `scroller` is scrolled to `edge` | +| `anyOf(...predicates)` | When any predicate does | +| `allOf(...predicates)` | When every predicate does | + +```ts +gesture.from + .when(anyOf(startedIn(".sheet-handle"), atScrollEdge(list, "top"))) + .to.down(); +``` + +Interactive form controls (`input`, `textarea`, `select`, `option`, and +`[contenteditable="true"]`) are ignored automatically. Add `data-origin-gesture="ignore"` to any other element or ancestor that should not -begin a gesture. +begin a gesture, such as a horizontally scrolling chip row inside a host with a +horizontal gesture. ## Minimum setup @@ -184,15 +220,13 @@ const gestures = [forwardGesture, backGesture] as const; | `gestures` | `readonly OriginGestureDefinition[]` | required | Fully defined page-owned rules | The component adds no recognition or navigation policy. It installs each -definition with `useOriginGesture()`, forwards every pointer event to every -binding, and derives the least-permissive shared `touch-action`: +definition, forwards every pointer and touch event to every recognizer, and +lets the recognizers share the touch: each locks onto a touch moving along its +own axis and direction and lets go of any other, so a horizontal drawer, a +pull-up sheet, and vertical scrolling can live on one host. See +[Scrolling](#scrolling). -- horizontal only: `pan-y`; -- vertical only: `pan-x`; -- both axes: `none`. - -The `gestures` list is reactive, including its length and the derived -`touch-action`. Recognizers are kept per list position, so a re-render with new +The `gestures` list is reactive, including its length. Recognizers are kept per list position, so a re-render with new definition objects never interrupts a gesture in progress; each change applies from the next pointer-down. The owning page remains the visible declaration point for every `.from`, `.to`, `.complete`, `.navigate`, and `.animate` choice. @@ -427,6 +461,16 @@ change a definition already produced from it. | --------------- | ------- | ------------------------------------------------- | | `threshold` | `8` | Directed CSS pixels required before capture | | `axisDominance` | `1.15` | Directed/cross-axis ratio required before capture | +| `distance` | host | Movement that counts as full progress | + +`distance` is resolved at pointer-down: a number in pixels, a CSS length +(percentages are of the host), or a function `({ host, bounds }) => pixels`. +Set it when the moving element is smaller than the host, such as a drawer 84% +of the screen wide, so the drawer stays under the finger: + +```ts +gesture.to.right({ distance: () => drawer.value?.offsetWidth ?? 300 }); +``` If `.complete()` is omitted, the choreography's `commitThreshold` and `commitVelocity` decide release normally. @@ -452,10 +496,30 @@ const openProfile = gesture.to .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. +Handlers run inside the `pointerup` event whenever the operation has already +begun, which is the usual case, so APIs that need the user's gesture (iOS +haptics, audio, clipboard writes) work there. See [Haptics](#haptics). + +The method is named `onCommit` rather than `then` so builder stages never +become promise-like "thenables" when awaited or returned from an async +function. + +`.onArm(handler)` and `.onDisarm(handler)` follow the same rules and run during +the drag: `onArm` when releasing would start to commit, `onDisarm` when it no +longer would. Use them for a "release now" cue or haptic. Arming follows +distance (`progress >= commitThreshold`) because velocity changes too quickly to +drive a cue without flickering; when the definition has a `.complete()` +predicate, that predicate decides arming instead. + +```ts +gesture.to + .left() + .navigate(() => forward(originView(Archive))) + .onArm(() => haptic("light")) + .onDisarm(() => haptic("soft")) + .onCommit(() => haptic("medium")) + .animate(slideLeft); +``` ### `useOriginGesture(definition)` @@ -482,22 +546,32 @@ interface OriginGestureBinding { readonly onPointerup: (event: PointerEvent) => void; readonly onPointercancel: () => void; readonly onLostpointercapture: (event: PointerEvent) => void; + readonly onTouchmove: (event: TouchEvent) => void; } ``` -Apply all handlers to the same element. The returned style sets dimensions and -`touch-action` so native scrolling remains available on the cross-axis. +Apply all handlers to the same element, as non-passive listeners (Vue's +`@touchmove` is), and apply the returned style. The style sets dimensions and +`touch-action: manipulation`; `onTouchmove` is what keeps native scrolling out +of a gesture the recognizer has claimed. Without it, the browser takes over +vertical and horizontal drags with `pointercancel`. Recognition requires: 1. A primary, left-button pointer satisfies the optional start policy. 2. The target is not an ignored interactive element. -3. Directed movement reaches `threshold`. -4. Directed movement exceeds cross-axis movement by `axisDominance`. -5. The navigation factory returns an intent. +3. The first 6px of movement are along the gesture's axis and direction (see + [Scrolling](#scrolling)). +4. Directed movement reaches `threshold`. +5. Directed movement exceeds cross-axis movement by `axisDominance`. +6. The navigation factory returns an intent. -Progress is directed distance divided by host width or height. Release velocity -is normalized by the same dimension. +Progress is directed distance divided by the gesture's `distance` (by default +the host's width or height). Past either end, the excess reaches +choreographies as `overshoot`. Release velocity is normalized by the same +distance and measured as the trend of the last 100ms of samples, so uneven +touch timing does not distort it and a pointer held still before release has +no flick. An asynchronous navigation factory is supported. If it resolves after the pointer was released or cancelled, the stale result is discarded. @@ -585,10 +659,12 @@ can run repeatedly during rendering and animation. When `finish()` does not explicitly override the decision, the release velocity decides first, then distance: -- a flick at least `commitVelocity` fast (default `0.3` progress units per - second) decides by its direction: forward commits, backward cancels, - regardless of progress; -- a slower release commits when `progress >= commitThreshold`, default `0.36`. +- a flick at least `commitVelocity` fast (default `0.2` progress units per + second, about 80px/s across a 390px phone) decides by its direction: forward + commits, backward cancels, regardless of progress; +- a slower release commits when `progress >= commitThreshold`, default `0.25`. + +The included presets use these defaults. Velocity is normalized by the gesture host's size, so `1` is one full width (or height) per second. @@ -778,6 +854,21 @@ Node-local action context: | `originRect` | Origin bounds captured before target mounting | | `targetRect` | Target bounds measured after mounting | | `viewport` | Scene-container bounds, with browser viewport fallback | +| `overshoot` | Drag distance past either end of `progress`, else `0` | + +`overshoot` is negative when a drag is pulled back beyond the start and +positive beyond full progress. Use it for rubber-banding: + +```ts +effects: ({ progress, overshoot }) => ({ + target: { + transform: `translateX(${(1 - progress) * 100 - overshoot * 20}%)`, + }, +}); +``` + +The settling spring starts from the overshot position, so a rubber-banded drag +springs back continuously. Rect values are viewport CSS pixels. @@ -831,3 +922,114 @@ cause: - A choreography creates one target. Chaining supports any number of simultaneously mounted targets. - Reduced-motion preference resolves settling immediately. + +## Gestures in real apps + +### Scrolling + +Gestures and native scrolling share the same touches. Each recognizer decides +within the first 6px of movement whether a touch is its own: + +- **along its axis and direction**: it holds the touch, and the binding's + `onTouchmove` cancels native scrolling while it does; +- **along the other axis, or the opposite way**: it lets go, and the browser + scrolls (or another gesture on the host takes it); +- **diagonal**: it keeps watching until the movement is clear. + +Browsers hold back the first cancelable `touchmove` for roughly 15–20px, so the +decision is made before native scrolling can start. This is why gesture hosts +use `touch-action: manipulation`: the browser may pan either axis, and the +recognizers keep it from doing so only for touches they claim. A vertical +dismiss gesture therefore no longer disables vertical scrolling; combine it +with `.from.scrollEdge()` so it only claims a downward drag at the top. + +Nested scrollers need nothing special. Their `touchmove` events bubble to the +gesture host, which cancels them only while it holds the touch. + +### Ignored elements + +Pointer-down on `input`, `textarea`, `select`, `option`, or +`[contenteditable="true"]` never starts a gesture, so text selection and +form controls keep working. Mark anything else that must keep its own drags +with `data-origin-gesture="ignore"`, for example a horizontally scrolling chip +row or a slider inside a host that has a horizontal gesture. + +### Animation performance + +Animate `transform` and `opacity` only. Browsers composite them without +layout or paint. Animating `filter`, `clip-path`, `box-shadow`, or +`border-radius` through `effect.style` repaints every frame and is the usual +cause of janky transitions; if you need one of them, set it once at rest +rather than per frame. + +### Continuity between choreographies + +In development builds the scene warns when an operation's first frame moves a +node that was visibly at rest. This catches the one-frame "pop" when a close +choreography's progress-0 effects do not match the pose the previous +choreography left behind, typically a `persistAtRest` drawer or sheet whose +open (at 1) and close (at 0) effects disagree. The check runs only in +development (`process.env.NODE_ENV !== "production"`, which bundlers replace) +and only in browsers, since it reads rendered styles. + +### Haptics + +`onArm`, `onDisarm`, and `onCommit` run inside the pointer event, which is +where platforms allow feedback: + +- **Android, Chrome**: `navigator.vibrate(10)`. +- **iOS Safari**: there is no Vibration API. Haptics are only reachable + through a native wrapper, or through an undocumented technique: toggling a + hidden ``, which iOS 18+ Safari renders as a + switch with a system haptic. It only produces a haptic during a user gesture, + so call it from these handlers, never after an `await`, and expect it to + change between iOS releases. +- **Capacitor**: `Haptics.impact()` from `@capacitor/haptics`. + +### Operating-system edge gestures + +The screen edges belong to the operating system first. On iOS, a swipe from the +left edge goes back in Safari's history, including in apps installed to the +home screen (`display: standalone`), which have no back button. On Android +with gesture navigation, a swipe from either side edge is the system back +gesture. Pages cannot claim those touches, so `.from.left()` or `.from.right()` +with a narrow zone competes with them. Prefer a zone that starts a little +inside the edge, or a wider one (`.from.when(nearEdge("left", 56))`), and give +every back gesture an on-screen alternative. + +Web apps cannot disable the operating system's gesture, but an installed +progressive web app can make it do the right thing, so it backs out of the +scene instead of leaving the app: + +- **Android (Chrome 120+)**: listen for close requests with `CloseWatcher`. + The system back gesture and back button send one; route it to the scene's + back operation. A watcher must be created during a user activation (a tap + or a gesture), and after it fires it cannot fire again until the user + interacts again, so recreate it after each navigation: + + ```ts + let watcher: CloseWatcher | undefined; + function watchSystemBack(origin: UseOrigin) { + watcher?.destroy(); + if (!("CloseWatcher" in window) || !origin.canGoBack.value) return; + watcher = new CloseWatcher(); + watcher.onclose = () => origin.perform(back(slideRight)); + } + // Call from a gesture's .onCommit() or a click handler, after navigating. + ``` + + Without `CloseWatcher`, push a history entry when the scene navigates + forward (`history.pushState({ scene: true }, "")`) and handle `popstate` by + performing the scene's back. The system gesture then pops that entry instead + of leaving the app. + +- **iOS (standalone)**: the edge swipe navigates browser history. Scenes never + add history entries, so in an installed app with no history to go back to + the swipe should have nothing to do. If the app also uses the History API, + map `popstate` to the scene's back as above. +- **Browser history swipes** (two-finger trackpad swipes, and horizontal + overscroll navigation in Chromium): `overscroll-behavior-x: none` on the page's + scroll container stops horizontal overscroll from triggering navigation. + +Verify the behaviour on real devices: these platform gestures are not part of +any web standard and change between releases. diff --git a/packages/core-v2/src/choreography.ts b/packages/core-v2/src/choreography.ts index fc756a7..ad475fc 100644 --- a/packages/core-v2/src/choreography.ts +++ b/packages/core-v2/src/choreography.ts @@ -78,6 +78,12 @@ export interface OriginEffectSet { export interface OriginChoreographyContext { /** Normalized operation progress, clamped to the inclusive range `0..1`. */ progress: number; + /** + * How far a drag has gone past the ends of `progress`: negative when pulled + * back beyond the start, positive beyond full progress, otherwise `0`. Use + * it for rubber-banding or resistance; most choreographies ignore it. + */ + overshoot: number; /** * Normalized progress units per second. Positive velocity moves toward * commit; negative velocity moves back toward cancellation. @@ -111,7 +117,7 @@ export interface OriginChoreography { /** * Gesture progress required to retain the target after release. * - * @defaultValue `0.36` + * @defaultValue `0.25` */ readonly commitThreshold?: number; /** @@ -119,7 +125,7 @@ export interface OriginChoreography { * regardless of progress. Velocity is normalized by the gesture host's * size, so `1` means one full host width (or height) per second. * - * @defaultValue `0.3` + * @defaultValue `0.2` */ readonly commitVelocity?: number; /** @@ -136,6 +142,27 @@ export interface OriginChoreography { * @defaultValue `false` */ readonly persistAtRest?: boolean; + /** + * Settling spring after release. A drawer, a sheet, and a full-page push + * usually want different springs. + */ + readonly spring?: OriginSpring; +} + +/** Damped-spring settings for settling a released operation. */ +export interface OriginSpring { + /** + * Pull toward the resting position. Higher settles faster. + * + * @defaultValue `280` + */ + readonly stiffness?: number; + /** + * Resistance to motion. Lower overshoots and bounces. + * + * @defaultValue `30` + */ + readonly damping?: number; } /** @@ -250,8 +277,6 @@ const percent = (value: number) => `${(value * 100).toFixed(4)}%`; */ export const slideLeft = defineOriginChoreography({ name: "slide-left", - commitThreshold: 0.36, - commitVelocity: 0.6, effects: ({ progress }) => ({ source: { transform: `translate3d(${percent(progress * -0.24)}, 0, 0) scale(${1 - progress * 0.025})`, @@ -269,8 +294,6 @@ export const slideLeft = defineOriginChoreography({ */ export const slideRight = defineOriginChoreography({ name: "slide-right", - commitThreshold: 0.36, - commitVelocity: 0.6, effects: ({ progress }) => ({ source: { transform: `translate3d(${percent(progress)}, 0, 0)`, diff --git a/packages/core-v2/src/components/OriginGesture.vue b/packages/core-v2/src/components/OriginGesture.vue index 62f09ad..c7dbcb8 100644 --- a/packages/core-v2/src/components/OriginGesture.vue +++ b/packages/core-v2/src/components/OriginGesture.vue @@ -28,6 +28,7 @@ const gesture = useOriginGesture(() => props.gesture); @pointerup="gesture.onPointerup" @pointercancel="gesture.onPointercancel" @lostpointercapture="gesture.onLostpointercapture" + @touchmove="gesture.onTouchmove" > diff --git a/packages/core-v2/src/components/OriginGestureSurface.vue b/packages/core-v2/src/components/OriginGestureSurface.vue index 6e9fda0..2f46bba 100644 --- a/packages/core-v2/src/components/OriginGestureSurface.vue +++ b/packages/core-v2/src/components/OriginGestureSurface.vue @@ -27,6 +27,7 @@ const surface = useOriginGestures(() => props.gestures); @pointerup="surface.onPointerup" @pointercancel="surface.onPointercancel" @lostpointercapture="surface.onLostpointercapture" + @touchmove="surface.onTouchmove" > diff --git a/packages/core-v2/src/gesture/definition.ts b/packages/core-v2/src/gesture/definition.ts index 82bff96..ff523ff 100644 --- a/packages/core-v2/src/gesture/definition.ts +++ b/packages/core-v2/src/gesture/definition.ts @@ -1,8 +1,9 @@ -import type { CSSProperties } from "vue"; +import { toValue, type CSSProperties, type MaybeRefOrGetter } from "vue"; import type { OriginChoreography } from "../choreography"; import type { OriginNavigationIntent } from "../navigation"; import type { OriginContext } from "../scene/types"; import type { OriginRect } from "../shared"; +import { withinEdge } from "./recognizer"; /* * What a gesture is: the public gesture types, component props, and the @@ -93,8 +94,27 @@ export interface OriginGestureDirectionOptions { * @defaultValue `1.15` */ readonly axisDominance?: number; + /** + * Movement that counts as full progress (`1`), resolved at pointer-down. + * A CSS length (numbers are pixels, `%` is of the host), or a function + * returning pixels, for example to measure a drawer narrower than the host. + * + * @defaultValue the host's width (horizontal) or height (vertical) + */ + readonly distance?: OriginGestureProgressDistance; } +/** + * Movement that counts as full progress: a CSS length, or a function that + * returns pixels, measured at pointer-down. + */ +export type OriginGestureProgressDistance = + | OriginGestureDistance + | ((context: { + readonly host: HTMLElement; + readonly bounds: OriginRect; + }) => number); + /** Values available when a custom `.complete()` policy runs on pointer-up. */ export interface OriginGestureCompletionContext { /** Component instance that originated the gesture. */ @@ -170,6 +190,10 @@ export interface OriginGestureDefinition { readonly choreography: OriginChoreography; /** Handlers run, in order, when a release commits. See `.onCommit()`. */ readonly commitHandlers?: readonly OriginGestureCommitHandler[]; + /** Handlers run when a drag starts to qualify to commit. See `.onArm()`. */ + readonly armHandlers?: readonly OriginGestureCommitHandler[]; + /** Handlers run when a drag stops qualifying to commit. */ + readonly disarmHandlers?: readonly OriginGestureCommitHandler[]; } /** @@ -216,8 +240,24 @@ export interface OriginGestureFromBuilder { anywhere(): OriginGestureFromSelection; /** Admit pointer-down when a synchronous application predicate succeeds. */ when(predicate: OriginGestureStartPredicate): OriginGestureFromSelection; + /** + * Admit pointer-down only while `scroller` is scrolled to `edge`, for + * example drag-down-to-dismiss only at the top of a list. Movement the + * other way is left to the scroller. See {@link atScrollEdge}. + */ + scrollEdge( + scroller: OriginGestureScroller, + edge: OriginGestureEdge, + ): OriginGestureFromSelection; } +/** + * A scroll container: an element, a ref or getter returning one, or a CSS + * selector resolved inside the gesture host. + */ +export type OriginGestureScroller = + MaybeRefOrGetter | string; + /** Builder stage after direction is known and completion remains optional. */ export interface OriginGestureDirectedBuilder { /** Override the choreography's default release decision. */ @@ -230,6 +270,13 @@ export interface OriginGestureDirectedBuilder { ): OriginGestureNavigationBuilder; /** Run `handler` when a release commits. Handlers accumulate in order. */ onCommit(handler: OriginGestureCommitHandler): OriginGestureDirectedBuilder; + /** + * Run `handler` during a drag when releasing would start to commit, for + * example for a "release now" haptic. See `.onArm()` in the API reference. + */ + onArm(handler: OriginGestureCommitHandler): OriginGestureDirectedBuilder; + /** Run `handler` during a drag when releasing would no longer commit. */ + onDisarm(handler: OriginGestureCommitHandler): OriginGestureDirectedBuilder; } /** Builder stage after a custom completion policy has been selected. */ @@ -240,6 +287,13 @@ export interface OriginGestureCompletedBuilder { ): OriginGestureNavigationBuilder; /** Run `handler` when a release commits. Handlers accumulate in order. */ onCommit(handler: OriginGestureCommitHandler): OriginGestureCompletedBuilder; + /** + * Run `handler` during a drag when releasing would start to commit, for + * example for a "release now" haptic. See `.onArm()` in the API reference. + */ + onArm(handler: OriginGestureCommitHandler): OriginGestureCompletedBuilder; + /** Run `handler` during a drag when releasing would no longer commit. */ + onDisarm(handler: OriginGestureCommitHandler): OriginGestureCompletedBuilder; } /** Final builder stage waiting for visual choreography. */ @@ -248,6 +302,13 @@ export interface OriginGestureNavigationBuilder { animate(choreography: OriginChoreography): OriginGestureDefinition; /** Run `handler` when a release commits. Handlers accumulate in order. */ onCommit(handler: OriginGestureCommitHandler): OriginGestureNavigationBuilder; + /** + * Run `handler` during a drag when releasing would start to commit, for + * example for a "release now" haptic. See `.onArm()` in the API reference. + */ + onArm(handler: OriginGestureCommitHandler): OriginGestureNavigationBuilder; + /** Run `handler` during a drag when releasing would no longer commit. */ + onDisarm(handler: OriginGestureCommitHandler): OriginGestureNavigationBuilder; } /** Root of the immutable fluent gesture-definition API. */ @@ -263,8 +324,8 @@ export interface OriginGestureBuilder { /** * DOM bindings returned by {@link useOriginGesture}. * - * Attach all five handlers to the same `HTMLElement`. Apply {@link style} as - * well so native scrolling is preserved on the cross-axis. + * Attach all six handlers to the same `HTMLElement`, and apply + * {@link style}. */ export interface OriginGestureBinding { /** @@ -285,6 +346,12 @@ export interface OriginGestureBinding { * ends without a pointer-up, so the operation never stays stuck mid-way. */ readonly onLostpointercapture: (event: PointerEvent) => void; + /** + * Touch-move handler. Cancels native scrolling while a recognizer holds + * the touch, and leaves it alone once every recognizer has let go. It must + * be attached as a non-passive listener (Vue's `@touchmove` is). + */ + readonly onTouchmove: (event: TouchEvent) => void; } // Component props ---------------------------------------------------------- @@ -342,16 +409,33 @@ interface GestureDraft { readonly recognition: Readonly; readonly completion?: OriginGestureCompletionPredicate; readonly commitHandlers: readonly OriginGestureCommitHandler[]; + readonly armHandlers: readonly OriginGestureCommitHandler[]; + readonly disarmHandlers: readonly OriginGestureCommitHandler[]; } -const withCommitHandler = ( +type HandlerList = "commitHandlers" | "armHandlers" | "disarmHandlers"; + +/** + * `.onCommit()`, `.onArm()`, and `.onDisarm()` for a stage: each rebuilds the + * same stage from the draft with the handler appended. + */ +function hookMethods( draft: Draft, - handler: OriginGestureCommitHandler, -): Draft => - Object.freeze({ - ...draft, - commitHandlers: Object.freeze([...draft.commitHandlers, handler]), - }); + stage: (draft: Draft) => Stage, +) { + const append = (list: HandlerList) => (handler: OriginGestureCommitHandler) => + stage( + Object.freeze({ + ...draft, + [list]: Object.freeze([...draft[list], handler]), + }), + ); + return { + onCommit: append("commitHandlers"), + onArm: append("armHandlers"), + onDisarm: append("disarmHandlers"), + }; +} function navigationStage( draft: GestureDraft & { readonly navigation: OriginGestureNavigationFactory }, @@ -367,9 +451,10 @@ function navigationStage( navigation: draft.navigation, choreography, commitHandlers: draft.commitHandlers, + armHandlers: draft.armHandlers, + disarmHandlers: draft.disarmHandlers, }), - onCommit: (handler: OriginGestureCommitHandler) => - navigationStage(withCommitHandler(draft, handler)), + ...hookMethods(draft, navigationStage), }); } @@ -380,8 +465,7 @@ const navigateFrom = function completedStage(draft: GestureDraft): OriginGestureCompletedBuilder { return Object.freeze({ navigate: navigateFrom(draft), - onCommit: (handler: OriginGestureCommitHandler) => - completedStage(withCommitHandler(draft, handler)), + ...hookMethods(draft, completedStage), }); } @@ -390,8 +474,7 @@ function directedStage(draft: GestureDraft): OriginGestureDirectedBuilder { complete: (completion: OriginGestureCompletionPredicate) => completedStage(Object.freeze({ ...draft, completion })), navigate: navigateFrom(draft), - onCommit: (handler: OriginGestureCommitHandler) => - directedStage(withCommitHandler(draft, handler)), + ...hookMethods(draft, directedStage), }); } @@ -405,6 +488,8 @@ function toStage(start: OriginGestureStart): OriginGestureToBuilder { direction, recognition: Object.freeze({ ...options }), commitHandlers: Object.freeze([]), + armHandlers: Object.freeze([]), + disarmHandlers: Object.freeze([]), }), ); return Object.freeze({ @@ -431,8 +516,77 @@ const fromBuilder: OriginGestureFromBuilder = Object.freeze({ anywhere: () => fromStage({ kind: "anywhere" }), when: (predicate: OriginGestureStartPredicate) => fromStage({ kind: "when", predicate }), + scrollEdge: (scroller: OriginGestureScroller, edge: OriginGestureEdge) => + fromStage({ kind: "when", predicate: atScrollEdge(scroller, edge) }), }); +// Start rules --------------------------------------------------------------- + +/* + * Composable `.from.when()` predicates. Each returns a start predicate, so + * they combine with anyOf() and allOf(): + * + * gesture.from + * .when(anyOf(startedIn(".sheet-handle"), atScrollEdge(list, "top"))) + * .to.down() + */ + +/** Admit pointer-down on an element matching `selector`, or inside one. */ +export const startedIn = + (selector: string): OriginGestureStartPredicate => + ({ event }) => + event.target instanceof Element && event.target.closest(selector) !== null; + +/** Admit pointer-down within `distance` of one side of the gesture host. */ +export const nearEdge = + ( + edge: OriginGestureEdge, + distance: OriginGestureDistance, + ): OriginGestureStartPredicate => + ({ host, bounds, point }) => + withinEdge(edge, distance, host, bounds, point); + +/** + * Admit pointer-down while `scroller` is scrolled all the way to `edge`. + * A scroller that is missing (not yet mounted) counts as at the edge. + */ +export const atScrollEdge = + ( + scroller: OriginGestureScroller, + edge: OriginGestureEdge, + ): OriginGestureStartPredicate => + ({ host }) => { + const element = + typeof scroller === "string" + ? host.querySelector(scroller) + : toValue(scroller); + if (!element) return true; + const { scrollTop, scrollLeft, scrollHeight, scrollWidth } = element; + const { clientHeight, clientWidth } = element; + switch (edge) { + case "top": + return scrollTop <= 0; + case "bottom": + return scrollTop + clientHeight >= scrollHeight - 1; + case "left": + return scrollLeft <= 0; + case "right": + return scrollLeft + clientWidth >= scrollWidth - 1; + } + }; + +/** Admit pointer-down when any of `predicates` does. */ +export const anyOf = + (...predicates: OriginGestureStartPredicate[]): OriginGestureStartPredicate => + (context) => + predicates.some((predicate) => predicate(context)); + +/** Admit pointer-down only when all of `predicates` do. */ +export const allOf = + (...predicates: OriginGestureStartPredicate[]): OriginGestureStartPredicate => + (context) => + predicates.every((predicate) => predicate(context)); + /** * Root of the immutable gesture builder. * diff --git a/packages/core-v2/src/gesture/recognizer.ts b/packages/core-v2/src/gesture/recognizer.ts index 0458a9b..ca62e9c 100644 --- a/packages/core-v2/src/gesture/recognizer.ts +++ b/packages/core-v2/src/gesture/recognizer.ts @@ -1,6 +1,6 @@ import { computed, onScopeDispose, toValue, type MaybeRefOrGetter } from "vue"; import type { OriginAction } from "../navigation"; -import { OriginSceneError } from "../scene/graph"; +import { DEFAULT_COMMIT_THRESHOLD, OriginSceneError } from "../scene/graph"; import { useOrigin, type UseOrigin } from "../scene/scene"; import type { OriginContext, OriginOperationHandle } from "../scene/types"; import { clamp01, measureRect, warn, type OriginRect } from "../shared"; @@ -10,7 +10,9 @@ import type { OriginGestureCompletionContext, OriginGestureDirection, OriginGestureDistance, + OriginGestureEdge, OriginGesturePoint, + OriginGestureProgressDistance, OriginGestureStart, } from "./definition"; @@ -53,21 +55,15 @@ export const crossDistance = ( }; /** - * CSS `touch-action` for a host recognizing the given directions: native - * scrolling is preserved on any axis no gesture claims. + * CSS `touch-action` for a gesture host. Recognizers arbitrate with native + * scrolling themselves (see `onTouchmove`), so the browser may pan either axis + * and is only kept from double-tap zooming; a host without gestures is left + * alone. */ export const touchActionFor = ( directions: Iterable, -): "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"; +): "manipulation" | "auto" => { + for (const _ of directions) return "manipulation"; return "auto"; }; @@ -168,26 +164,36 @@ export const matchesStart = ( 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, + return withinEdge(start.edge, start.distance, host, bounds, point); +}; + +/** Whether `point` lies within `distance` of one side of the host. */ +export const withinEdge = ( + edge: OriginGestureEdge, + distance: OriginGestureDistance, + host: HTMLElement, + bounds: OriginRect, + point: OriginGesturePoint, +): boolean => { + const horizontal = edge === "left" || edge === "right"; + const limit = resolveCssDistance( + distance, horizontal ? "horizontal" : "vertical", host, bounds, ); - switch (start.edge) { + switch (edge) { case "left": - return point.localX >= 0 && point.localX <= distance; + return point.localX >= 0 && point.localX <= limit; case "right": return ( - point.localX <= bounds.width && bounds.width - point.localX <= distance + point.localX <= bounds.width && bounds.width - point.localX <= limit ); case "top": - return point.localY >= 0 && point.localY <= distance; + return point.localY >= 0 && point.localY <= limit; case "bottom": return ( - point.localY <= bounds.height && - bounds.height - point.localY <= distance + point.localY <= bounds.height && bounds.height - point.localY <= limit ); } }; @@ -196,50 +202,45 @@ export const matchesStart = ( type PointerSample = Pick; -/** 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; +/** Velocity is the trend of the samples in this many recent milliseconds. */ +const VELOCITY_WINDOW = 100; +/** Shortest span, in ms, velocity is measured over, so bursts stay finite. */ +const MIN_VELOCITY_SPAN = 8; /** * 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). + * Progress and velocity are normalized by the gesture distance (by default + * the host's size along the gesture axis), so `1` means one full distance. */ 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. */ + /** Directed distance ÷ gesture distance, unclamped so it can overshoot. */ + rawProgress = 0; + /** + * Directed velocity in progress units per second: the least-squares trend + * of the last {@link VELOCITY_WINDOW} ms, so uneven sample timing and a + * final held-still moment do not distort a flick. + */ velocity = 0; - private readonly horizontal: boolean; - /** Whether progress grows as the axis coordinate decreases. */ - private readonly reversed: boolean; - private lastCoordinate: number; - private lastTime: number; + private readonly samples: { time: number; position: number }[]; constructor( private readonly direction: OriginGestureDirection, private readonly start: PointerSample, - /** Host size along the gesture axis, read at every sample. */ + /** Gesture distance in CSS pixels, 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; + this.samples = [{ time: start.timeStamp, position: 0 }]; } - private coordinateOf(event: PointerSample) { - return this.horizontal ? event.clientX : event.clientY; + /** Directed distance ÷ gesture distance, clamped to `0..1`. */ + get progress(): number { + return clamp01(this.rawProgress); } /** Movement from the start without recording a sample. */ @@ -253,27 +254,38 @@ export class DirectionalTracker { } /** Record a sample, updating distance, progress, and velocity. */ - sample(event: PointerSample, release = false): void { + sample(event: PointerSample): 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.rawProgress = distance / size; - this.lastCoordinate = coordinate; - this.lastTime = event.timeStamp; + const samples = this.samples; + samples.push({ time: event.timeStamp, position: distance }); + // Keep the window, but always at least two samples to measure between. + const since = event.timeStamp - VELOCITY_WINDOW; + while (samples.length > 2 && samples[1]!.time <= since) samples.shift(); + this.velocity = (DirectionalTracker.trend(samples) * 1000) / size; + } + + /** Least-squares slope of position over time, in pixels per ms. */ + private static trend(samples: readonly { time: number; position: number }[]) { + const first = samples[0]!; + const last = samples[samples.length - 1]!; + if (last.time - first.time < MIN_VELOCITY_SPAN) + return (last.position - first.position) / MIN_VELOCITY_SPAN; + const meanTime = + samples.reduce((sum, { time }) => sum + time, 0) / samples.length; + const meanPosition = + samples.reduce((sum, { position }) => sum + position, 0) / samples.length; + let covariance = 0; + let variance = 0; + for (const { time, position } of samples) { + covariance += (time - meanTime) * (position - meanPosition); + variance += (time - meanTime) ** 2; + } + return covariance / variance; } } @@ -283,6 +295,34 @@ export class DirectionalTracker { const DEFAULT_THRESHOLD = 8; /** Ratio by which directed movement must exceed cross-axis movement. */ const DEFAULT_AXIS_DOMINANCE = 1.15; +/** + * Movement in CSS pixels after which a recognizer decides whether a touch is + * along its axis. Browsers hold back the first cancelable `touchmove` for + * roughly 15–20px, so the decision is made before native scrolling can start. + */ +const AXIS_LOCK_DISTANCE = 6; + +/** Resolve a definition's progress distance at pointer-down, in pixels. */ +export const resolveProgressDistance = ( + distance: OriginGestureProgressDistance | undefined, + direction: OriginGestureDirection, + host: HTMLElement, + bounds: OriginRect, +): (() => number) => { + const horizontal = isHorizontal(direction); + if (distance === undefined) + return () => (horizontal ? host.clientWidth : host.clientHeight); + const pixels = + typeof distance === "function" + ? distance({ host, bounds }) + : resolveCssDistance( + distance, + horizontal ? "horizontal" : "vertical", + host, + bounds, + ); + return () => pixels; +}; /** The origin capabilities a recognizer needs. */ export type RecognizerOrigin = Pick; @@ -299,8 +339,16 @@ interface PointerSession { readonly start: OriginGesturePoint; readonly startTime: number; readonly tracker: DirectionalTracker; + /** + * Whether the movement so far is along this gesture's axis and direction + * (`ours`) or still too small or too diagonal to tell (`pending`). A touch + * that turns out to be someone else's closes the session instead. + */ + axis: "pending" | "ours"; /** Movement passed the recognition threshold and the pointer is captured. */ captured: boolean; + /** Releasing now would commit; drives `.onArm()` and `.onDisarm()`. */ + armed: boolean; /** The scene operation, once navigation resolved and `begin()` was called. */ operation: Promise | null; /** @@ -338,6 +386,14 @@ export class GestureRecognizer { private readonly origin: RecognizerOrigin, ) {} + /** + * Whether this recognizer currently holds the touch, so native scrolling + * must not start: its session is open and has not ruled the touch out. + */ + get intercepts(): boolean { + return this.session !== null; + } + pointerDown(event: PointerEvent): void { if (!event.isPrimary) return; // Only one primary pointer exists at a time, so any session still open @@ -359,7 +415,6 @@ export class GestureRecognizer { // The component containing this declaration is the operation's origin. event.stopPropagation(); - const horizontal = isHorizontal(definition.direction); this.session = { definition, pointerId: event.pointerId, @@ -368,10 +423,19 @@ export class GestureRecognizer { origin, start: pointOf(event, bounds), startTime: event.timeStamp, - tracker: new DirectionalTracker(definition.direction, event, () => - horizontal ? host.clientWidth : host.clientHeight, + tracker: new DirectionalTracker( + definition.direction, + event, + resolveProgressDistance( + definition.recognition.distance, + definition.direction, + host, + bounds, + ), ), + axis: "pending", captured: false, + armed: false, operation: null, handle: null, }; @@ -382,11 +446,13 @@ export class GestureRecognizer { if (!session) return; if (!session.captured) { + if (!this.lockAxis(session, event)) return; if (!GestureRecognizer.recognizes(session, event)) return; session.captured = true; session.host.setPointerCapture?.(session.pointerId); event.preventDefault(); session.tracker.sample(event); + this.updateArming(session, event); const action = await this.resolveAction(session.definition); // The pointer may have been released or cancelled meanwhile. if (this.session !== session) return; @@ -408,13 +474,14 @@ export class GestureRecognizer { } else { event.preventDefault(); session.tracker.sample(event); + this.updateArming(session, event); } // Progress is read after the await so the latest buffered sample wins. const pending = session.operation; const handle = pending ? await pending : null; if (this.session === session && session.operation === pending) - handle?.update(session.tracker.progress, session.tracker.velocity); + handle?.update(session.tracker.rawProgress, session.tracker.velocity); } async pointerUp(event: PointerEvent): Promise { @@ -424,23 +491,10 @@ export class GestureRecognizer { if (!session.captured) return; const { tracker, definition } = session; - tracker.sample(event, true); - const context: OriginGestureCompletionContext = { - origin: session.origin, - direction: definition.direction, - progress: tracker.progress, - velocity: tracker.velocity, - distance: tracker.distance, - crossDistance: tracker.crossDistance, - duration: Math.max(0, event.timeStamp - session.startTime), - event, - host: session.host, - bounds: session.bounds, - start: session.start, - current: pointOf(event, session.bounds), - }; + tracker.sample(event); + const context = GestureRecognizer.contextOf(session, event); const commit = definition.completion?.(context); - const { progress, velocity } = tracker; + const { rawProgress: progress, velocity } = tracker; // Usually the operation began long ago: finish it without awaiting, so // commit handlers run inside this pointer-up (iOS only allows haptics, @@ -454,7 +508,7 @@ export class GestureRecognizer { commit === undefined ? undefined : { commit }, ); if (handle.intent === "commit") - GestureRecognizer.notifyCommit(definition, context); + GestureRecognizer.notify(definition.commitHandlers ?? [], context); await settled; } @@ -483,6 +537,74 @@ export class GestureRecognizer { await handle?.cancel(); } + /** + * Decide, early in the movement, whether this touch is along the gesture's + * axis and direction. A touch that is clearly someone else's (the other + * axis, or the opposite way) closes the session, which hands it to native + * scrolling or to another recognizer. + * + * @returns Whether the session is still open. + */ + private lockAxis(session: PointerSession, event: PointerEvent): boolean { + if (session.axis === "ours") return true; + const { distance, crossDistance } = session.tracker.displacement(event); + const along = Math.abs(distance); + if (Math.max(along, crossDistance) < AXIS_LOCK_DISTANCE) return true; + const dominance = + session.definition.recognition.axisDominance ?? DEFAULT_AXIS_DOMINANCE; + if (along >= crossDistance * dominance && distance > 0) { + session.axis = "ours"; + return true; + } + if ( + along >= crossDistance * dominance || + crossDistance >= along * dominance + ) { + this.session = null; + return false; + } + return true; // Diagonal: keep watching. + } + + /** Fire `.onArm()` / `.onDisarm()` when the release decision would flip. */ + private updateArming(session: PointerSession, event: PointerEvent) { + const { definition, tracker } = session; + const armHandlers = definition.armHandlers ?? []; + const disarmHandlers = definition.disarmHandlers ?? []; + if (!armHandlers.length && !disarmHandlers.length) return; + const context = GestureRecognizer.contextOf(session, event); + // Distance decides arming unless the definition supplies .complete(): + // velocity changes too quickly to drive a cue without flickering. + const armed = definition.completion + ? definition.completion(context) + : tracker.progress >= + (definition.choreography.commitThreshold ?? DEFAULT_COMMIT_THRESHOLD); + if (armed === session.armed) return; + session.armed = armed; + GestureRecognizer.notify(armed ? armHandlers : disarmHandlers, context); + } + + private static contextOf( + session: PointerSession, + event: PointerEvent, + ): OriginGestureCompletionContext { + const { tracker } = session; + return { + origin: session.origin, + direction: session.definition.direction, + progress: tracker.progress, + velocity: tracker.velocity, + distance: tracker.distance, + crossDistance: tracker.crossDistance, + duration: Math.max(0, event.timeStamp - session.startTime), + event, + host: session.host, + bounds: session.bounds, + start: session.start, + current: pointOf(event, session.bounds), + }; + } + private sessionFor(event: PointerEvent) { const session = this.session; return session && session.pointerId === event.pointerId ? session : null; @@ -526,12 +648,12 @@ export class GestureRecognizer { } } - /** Run commit handlers; a failing handler never interrupts the others. */ - private static notifyCommit( - definition: OriginGestureDefinition, + /** Run handlers in order; a failing handler never interrupts the others. */ + private static notify( + handlers: readonly ((context: OriginGestureCompletionContext) => void)[], context: OriginGestureCompletionContext, ) { - for (const handler of definition.commitHandlers ?? []) { + for (const handler of handlers) { try { handler(context); } catch (error) { @@ -603,6 +725,12 @@ export const useOriginGestures = ( for (const recognizer of current()) void recognizer.pointerCaptureLost(event); }, + onTouchmove: (event) => { + // Native scrolling starts at the first touchmove not cancelled, so keep + // cancelling while any recognizer still holds the touch. + if (event.cancelable && current().some(({ intercepts }) => intercepts)) + event.preventDefault(); + }, }; }; diff --git a/packages/core-v2/src/index.ts b/packages/core-v2/src/index.ts index 20f0daa..28a1dc7 100644 --- a/packages/core-v2/src/index.ts +++ b/packages/core-v2/src/index.ts @@ -28,6 +28,7 @@ export type { OriginEffectSet, OriginOperationIntent, OriginOperationPhase, + OriginSpring, } from "./choreography"; export { defineOriginChoreography, @@ -58,7 +59,14 @@ export { } from "./scene/scene"; export type * from "./gesture/definition"; -export { gesture } from "./gesture/definition"; +export { + allOf, + anyOf, + atScrollEdge, + gesture, + nearEdge, + startedIn, +} from "./gesture/definition"; export { useOriginGesture } from "./gesture/recognizer"; /** diff --git a/packages/core-v2/src/scene/graph.ts b/packages/core-v2/src/scene/graph.ts index bd4496e..0fb1470 100644 --- a/packages/core-v2/src/scene/graph.ts +++ b/packages/core-v2/src/scene/graph.ts @@ -65,13 +65,13 @@ export class OriginSceneError extends Error { * Progress at which a release commits when the choreography does not supply * its own `commitThreshold`. */ -export const DEFAULT_COMMIT_THRESHOLD = 0.36; +export const DEFAULT_COMMIT_THRESHOLD = 0.25; /** * Release velocity (progress units per second) that commits a flick at any * progress when the choreography does not supply its own `commitVelocity`. */ -export const DEFAULT_COMMIT_VELOCITY = 0.3; +export const DEFAULT_COMMIT_VELOCITY = 0.2; /** * Decide a release outcome from explicit options, or from velocity and @@ -274,6 +274,7 @@ export class SceneGraph { const operation = this.operations.get(id); if (!operation || operation.phase === "finished") return; operation.progress = clamp01(progress); + operation.overshoot = progress - operation.progress; operation.velocity = velocity; if (operation.phase !== "preparing") operation.phase = "interactive"; } @@ -297,6 +298,7 @@ export class SceneGraph { if (operation.history === "push" && operation.choreography.persistAtRest) { operation.progress = 1; + operation.overshoot = 0; operation.velocity = 0; operation.intent = "commit"; operation.phase = "finished"; @@ -545,6 +547,7 @@ export class SceneGraph { placement: init.placement, history: init.history, progress: 0, + overshoot: 0, velocity: 0, phase: "preparing", intent: "undecided", diff --git a/packages/core-v2/src/scene/scene.ts b/packages/core-v2/src/scene/scene.ts index f5eb120..7fadf21 100644 --- a/packages/core-v2/src/scene/scene.ts +++ b/packages/core-v2/src/scene/scene.ts @@ -13,11 +13,14 @@ import { normalizedEffect, type OriginEffect, type OriginEffectSet, + type OriginSpring, type OriginOperationIntent, type OriginOperationPhase, } from "../choreography"; import type { OriginAction, OriginView } from "../navigation"; import { + DEV, + warn, clamp, clamp01, measureRect, @@ -136,8 +139,8 @@ export const composeNodeStyle = ( * easing on custom choreographies. */ -const STIFFNESS = 280; -const DAMPING = 30; +const DEFAULT_STIFFNESS = 280; +const DEFAULT_DAMPING = 30; /** Fixed integration step, in seconds. */ const STEP = 1 / 120; /** Frame durations are clamped to this range, in seconds. */ @@ -163,13 +166,16 @@ export const stepSpring = ( state: SpringState, target: number, elapsedMs: number, + spring: OriginSpring = {}, ): SpringState & { readonly done: boolean } => { + const stiffness = spring.stiffness ?? DEFAULT_STIFFNESS; + const damping = spring.damping ?? DEFAULT_DAMPING; const elapsed = clamp(elapsedMs / 1000, MIN_FRAME, MAX_FRAME); const iterations = Math.max(1, Math.ceil(elapsed / STEP)); const dt = elapsed / iterations; let { position, velocity } = state; for (let index = 0; index < iterations; index += 1) { - const acceleration = (target - position) * STIFFNESS - velocity * DAMPING; + const acceleration = (target - position) * stiffness - velocity * damping; velocity += acceleration * dt; position += velocity * dt; } @@ -183,13 +189,14 @@ export const stepSpring = ( * Run a spring on `requestAnimationFrame` until it rests at `target` or * `onFrame` returns `false` because its subject no longer exists. * - * Every reported progress is clamped to `0..1`; the final frame reports - * exactly `target` with zero velocity. + * Each frame reports the raw position; the final frame reports exactly + * `target` with zero velocity. */ export const runSpring = ( from: SpringState, target: number, - onFrame: (progress: number, velocity: number) => boolean, + onFrame: (position: number, velocity: number) => boolean, + spring: OriginSpring = {}, ): Promise => new Promise((resolve) => { let state: SpringState = { @@ -203,18 +210,66 @@ export const runSpring = ( let previous = performance.now(); const frame = (time: number) => { - const next = stepSpring(state, target, time - previous); + const next = stepSpring(state, target, time - previous, spring); previous = time; state = next; const alive = next.done ? onFrame(target, 0) - : onFrame(clamp01(next.position), next.velocity); + : onFrame(next.position, next.velocity); if (!alive || next.done) resolve(); else window.requestAnimationFrame(frame); }; window.requestAnimationFrame(frame); }); +// Continuity check (development only) --------------------------------------- + +/** A host's rendered transform as a 4×4 matrix, plus its opacity. */ +interface Pose { + readonly matrix: readonly number[]; + readonly opacity: number; +} + +const warnedChoreographies = new WeakSet(); + +/** Read a visible host's rendered pose; `null` if hidden or unreadable. */ +const poseOf = (host: HTMLElement): Pose | null => { + const style = getComputedStyle(host); + if (style.visibility === "hidden") return null; + const matrix = parseMatrix(style.transform); + const opacity = Number(style.opacity); + return matrix && Number.isFinite(opacity) ? { matrix, opacity } : null; +}; + +/** Parse a computed `transform` ("none", matrix() or matrix3d()). */ +const parseMatrix = (transform: string): number[] | null => { + if (!transform || transform === "none") + return [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; + const values = /^matrix(3d)?\(([^)]*)\)$/.exec(transform); + if (!values) return null; + const numbers = values[2]!.split(",").map(Number); + if (numbers.some((value) => !Number.isFinite(value))) return null; + if (values[1]) return numbers.length === 16 ? numbers : null; + if (numbers.length !== 6) return null; + const [a, b, c, d, e, f] = numbers as [ + number, + number, + number, + number, + number, + number, + ]; + return [a, b, 0, 0, c, d, 0, 0, 0, 0, 1, 0, e, f, 0, 1]; +}; + +/** Equal within half a pixel of translation and 1% of anything else. */ +const posesMatch = (a: Pose, b: Pose): boolean => + Math.abs(a.opacity - b.opacity) <= 0.01 && + a.matrix.every((value, index) => { + const tolerance = index === 12 || index === 13 || index === 14 ? 0.5 : 0.01; + return Math.abs(value - b.matrix[index]!) <= tolerance; + }); + // Scene --------------------------------------------------------------------- /** Imperative controller for one operation, returned by `Scene.begin()`. */ @@ -326,6 +381,9 @@ class Scene implements OriginScene { action: OriginAction, ): Promise { if (this.container) this.refreshViewport(); + // Only a scene at rest has a pose the new operation must continue from. + const poses = + DEV && this.operations.value.length === 0 ? this.capturePoses() : null; const operation = this.graph.begin( originKey, action, @@ -338,6 +396,7 @@ class Scene implements OriginScene { await nextTick(); operation.targetRect = this.measure(targetKey); operation.phase = "interactive"; + if (poses) this.warnOnJump(poses, operation); return new OperationHandle(this, operation, targetKey); } @@ -461,16 +520,63 @@ class Scene implements OriginScene { ) { if (!animate || prefersReducedMotion()) { operation.progress = target; + operation.overshoot = 0; operation.velocity = 0; return; } - const from = { position: operation.progress, velocity: operation.velocity }; - await runSpring(from, target, (progress, velocity) => { - if (!this.graph.operation(operation.id) || !current()) return false; - operation.progress = progress; - operation.velocity = velocity; - return true; - }); + // Start from the raw position so a rubber-banded drag springs back + // continuously instead of snapping its overshoot away. + const from = { + position: operation.progress + operation.overshoot, + velocity: operation.velocity, + }; + await runSpring( + from, + target, + (position, velocity) => { + if (!this.graph.operation(operation.id) || !current()) return false; + operation.progress = clamp01(position); + operation.overshoot = position - operation.progress; + operation.velocity = velocity; + return true; + }, + operation.choreography.spring, + ); + } + + /** Development only: each visible host's rendered transform and opacity. */ + private capturePoses(): Map { + const poses = new Map(); + for (const [key, host] of this.hosts) { + const pose = poseOf(host); + if (pose) poses.set(key, pose); + } + return poses; + } + + /** + * Development only: warn when an operation's first frame moved a node that + * was visibly at rest, the one-frame "pop" of mismatched choreographies. + */ + private warnOnJump( + before: Map, + operation: MutableOriginOperation, + ) { + const { choreography } = operation; + if (warnedChoreographies.has(choreography)) return; + for (const [key, pose] of before) { + const host = this.hosts.get(key); + const after = host && poseOf(host); + if (!after || posesMatch(pose, after)) continue; + warnedChoreographies.add(choreography); + warn( + `"${choreography.name ?? "anonymous"}" moved node "${key}" on its ` + + "first frame. At progress 0 its effects should match the pose the " + + "scene was resting in, usually the previous choreography at " + + "progress 1; otherwise the view jumps.", + ); + return; + } } private measure(nodeKey: string): OriginRect | undefined { @@ -499,6 +605,7 @@ class Scene implements OriginScene { effects = computed(() => operation.choreography.effects({ progress: operation.progress, + overshoot: operation.overshoot, velocity: operation.velocity, phase: operation.phase, intent: operation.intent, diff --git a/packages/core-v2/src/scene/types.ts b/packages/core-v2/src/scene/types.ts index fa84778..17f9289 100644 --- a/packages/core-v2/src/scene/types.ts +++ b/packages/core-v2/src/scene/types.ts @@ -68,6 +68,8 @@ export interface OriginOperation { readonly history: OriginHistoryMode; /** Normalized progress in the inclusive range `0..1`. */ readonly progress: number; + /** Drag distance past either end of `progress`; usually `0`. */ + readonly overshoot: number; /** Latest normalized velocity in progress units per second. */ readonly velocity: number; /** Current operation lifecycle phase. */ @@ -126,8 +128,9 @@ export interface OriginOperationHandle { /** * Set interactive progress and optional velocity. * - * Progress is clamped to `0..1`. Velocity is normalized to progress units - * per second and is used by the choreography's flick threshold. + * Progress is clamped to `0..1`; any excess is reported to choreographies as + * `overshoot`. Velocity is normalized to progress units per second and is + * used by the choreography's flick threshold. */ update(progress: number, velocity?: number): void; /** @@ -212,6 +215,8 @@ export interface MutableOriginOperation { history: OriginHistoryMode; /** Normalized progress. */ progress: number; + /** Drag distance past either end of `progress`. */ + overshoot: number; /** Normalized velocity. */ velocity: number; /** Current lifecycle phase. */ diff --git a/packages/core-v2/src/shared.ts b/packages/core-v2/src/shared.ts index f6c3576..d57fb33 100644 --- a/packages/core-v2/src/shared.ts +++ b/packages/core-v2/src/shared.ts @@ -56,3 +56,10 @@ export function warn(message: string, error?: unknown): void { if (typeof console !== "undefined") console.warn(`[native-vue-router] ${message}`, ...(error ? [error] : [])); } + +// Bundlers replace `process.env.NODE_ENV` in dependencies at build time, the +// same convention Vue and Vue Router use for their development-only checks. +declare const process: { env: { NODE_ENV?: string } }; + +/** Whether development-only checks and warnings are enabled. */ +export const DEV: boolean = process.env.NODE_ENV !== "production"; diff --git a/packages/core-v2/tests/gesture.test.ts b/packages/core-v2/tests/gesture.test.ts index 95d909f..d877d00 100644 --- a/packages/core-v2/tests/gesture.test.ts +++ b/packages/core-v2/tests/gesture.test.ts @@ -97,7 +97,7 @@ describe("gesture builder", () => { const surface = root.querySelector("#multi-gesture-surface") as HTMLElement; expect(surface.tagName).toBe("SECTION"); expect(surface.classList.contains("nvo-gesture")).toBe(true); - expect(surface.style.touchAction).toBe("none"); + expect(surface.style.touchAction).toBe("manipulation"); expect(surface.style.width).toBe("100%"); expect(surface.style.height).toBe("100%"); }); diff --git a/packages/core-v2/tests/interaction.test.ts b/packages/core-v2/tests/interaction.test.ts new file mode 100644 index 0000000..5cfe95a --- /dev/null +++ b/packages/core-v2/tests/interaction.test.ts @@ -0,0 +1,355 @@ +import { + createApp, + defineComponent, + h, + nextTick, + shallowRef, + type Component, +} from "vue"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + allOf, + anyOf, + atScrollEdge, + createOriginScene, + defineOriginChoreography, + forward, + gesture, + nearEdge, + OriginGesture, + OriginGestureSurface, + OriginScene, + originView, + startedIn, + type OriginChoreographyContext, + type OriginGestureDefinition, + type OriginGestureStartContext, +} from "../src"; +import { DirectionalTracker } from "../src/gesture/recognizer"; +import { stepSpring } from "../src/scene/scene"; + +const apps: Array> = []; +afterEach(() => { + for (const app of apps.splice(0)) app.unmount(); + document.body.innerHTML = ""; + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +const contexts: OriginChoreographyContext[] = []; +const motion = defineOriginChoreography({ + name: "v2-test", + effects: (context) => { + contexts.push(context); + return { target: { opacity: context.progress } }; + }, +}); +const Target: Component = defineComponent({ render: () => h("div", "T") }); +const toTarget = () => forward(originView(Target)); + +function pointer( + type: string, + clientX: number, + clientY: number, + timeStamp: number, +) { + const event = new PointerEvent(type, { + bubbles: true, + cancelable: true, + button: 0, + isPrimary: true, + pointerId: 1, + clientX, + clientY, + }); + Object.defineProperty(event, "timeStamp", { value: timeStamp }); + return event; +} + +function touchmove() { + return new TouchEvent("touchmove", { bubbles: true, cancelable: true }); +} + +async function flush() { + for (let i = 0; i < 4; i += 1) await new Promise((r) => setTimeout(r, 0)); +} + +async function mount(root: Component) { + vi.stubGlobal("matchMedia", () => ({ matches: true })); + const scene = createOriginScene({ initial: originView(root) }); + const element = document.createElement("div"); + document.body.append(element); + const app = createApp({ render: () => h(OriginScene, { scene }) }); + apps.push(app); + app.mount(element); + await nextTick(); + const host = element.querySelector("#host") as HTMLElement; + Object.defineProperties(host, { + clientWidth: { configurable: true, value: 200 }, + clientHeight: { configurable: true, value: 400 }, + }); + host.getBoundingClientRect = () => + ({ top: 0, left: 0, width: 200, height: 400 }) as DOMRect; + return { scene, host }; +} + +const hostFor = ( + definition: OriginGestureDefinition, + children?: () => unknown, +) => + defineComponent({ + setup: () => () => + h(OriginGesture, { id: "host", gesture: definition }, () => + children ? children() : "host", + ), + }); + +describe("axis lock and native scrolling", () => { + const leftward = gesture.to.left().navigate(toTarget).animate(motion); + + it("holds the touch while undecided or along its axis", async () => { + const { scene, host } = await mount(hostFor(leftward)); + host.dispatchEvent(pointer("pointerdown", 150, 100, 0)); + const undecided = touchmove(); + host.dispatchEvent(undecided); + expect(undecided.defaultPrevented).toBe(true); + + host.dispatchEvent(pointer("pointermove", 140, 101, 16)); + await flush(); + const along = touchmove(); + host.dispatchEvent(along); + expect(along.defaultPrevented).toBe(true); + expect(scene.operations.value).toHaveLength(1); + }); + + it("lets go of a touch moving along the other axis", async () => { + const { scene, host } = await mount(hostFor(leftward)); + host.dispatchEvent(pointer("pointerdown", 150, 100, 0)); + host.dispatchEvent(pointer("pointermove", 149, 110, 16)); + const vertical = touchmove(); + host.dispatchEvent(vertical); + expect(vertical.defaultPrevented).toBe(false); + + // Later horizontal movement no longer starts the gesture. + host.dispatchEvent(pointer("pointermove", 90, 115, 32)); + await flush(); + expect(scene.operations.value).toHaveLength(0); + }); + + it("lets go of a touch moving the opposite way", async () => { + const { scene, host } = await mount(hostFor(leftward)); + host.dispatchEvent(pointer("pointerdown", 100, 100, 0)); + host.dispatchEvent(pointer("pointermove", 110, 100, 16)); + const opposite = touchmove(); + host.dispatchEvent(opposite); + expect(opposite.defaultPrevented).toBe(false); + expect(scene.operations.value).toHaveLength(0); + }); + + it("hands a touch between gestures sharing one host", async () => { + const down = gesture.to.down().navigate(toTarget).animate(motion); + const Root = defineComponent({ + setup: () => () => + h( + OriginGestureSurface, + { id: "host", gestures: [leftward, down] }, + () => "x", + ), + }); + const { scene, host } = await mount(Root); + expect(host.style.touchAction).toBe("manipulation"); + host.dispatchEvent(pointer("pointerdown", 100, 100, 0)); + host.dispatchEvent(pointer("pointermove", 101, 140, 16)); + await flush(); + // The downward gesture took it; the leftward one let go. + expect(scene.operations.value).toHaveLength(1); + const move = touchmove(); + host.dispatchEvent(move); + expect(move.defaultPrevented).toBe(true); + }); +}); + +describe("scroll edges", () => { + const scroller = shallowRef(null); + const dismiss = gesture.from + .scrollEdge(scroller, "top") + .to.down() + .navigate(toTarget) + .animate(motion); + + async function mountWithScroller(scrollTop: number) { + const Root = hostFor(dismiss, () => + h("div", { + id: "list", + ref: (element) => (scroller.value = element as HTMLElement | null), + }), + ); + const mounted = await mount(Root); + Object.defineProperty(scroller.value!, "scrollTop", { value: scrollTop }); + return mounted; + } + + it("starts a downward drag only when the scroller is at the top", async () => { + const atTop = await mountWithScroller(0); + atTop.host.dispatchEvent(pointer("pointerdown", 100, 100, 0)); + atTop.host.dispatchEvent(pointer("pointermove", 100, 140, 16)); + await flush(); + expect(atTop.scene.operations.value).toHaveLength(1); + }); + + it("leaves a scrolled list to scroll", async () => { + const scrolled = await mountWithScroller(120); + scrolled.host.dispatchEvent(pointer("pointerdown", 100, 100, 0)); + const move = touchmove(); + scrolled.host.dispatchEvent(move); + expect(move.defaultPrevented).toBe(false); + scrolled.host.dispatchEvent(pointer("pointermove", 100, 140, 16)); + await flush(); + expect(scrolled.scene.operations.value).toHaveLength(0); + }); + + it("at the top, still lets an upward drag scroll the list", async () => { + const atTop = await mountWithScroller(0); + atTop.host.dispatchEvent(pointer("pointerdown", 100, 200, 0)); + atTop.host.dispatchEvent(pointer("pointermove", 100, 180, 16)); + const move = touchmove(); + atTop.host.dispatchEvent(move); + expect(move.defaultPrevented).toBe(false); + }); +}); + +describe("start-rule helpers", () => { + const host = document.createElement("div"); + host.innerHTML = `

`; + const context = (target: Element, localX = 100): OriginGestureStartContext => + ({ + event: { target } as unknown as PointerEvent, + origin: {} as never, + host, + bounds: { top: 0, left: 0, width: 200, height: 400 }, + point: { clientX: localX, clientY: 10, localX, localY: 10 }, + }) as OriginGestureStartContext; + + it("matches pointer-down inside a selector", () => { + expect(startedIn(".handle")(context(host.querySelector("#inner")!))).toBe( + true, + ); + expect(startedIn(".handle")(context(host.querySelector("#body")!))).toBe( + false, + ); + }); + + it("composes with anyOf and allOf", () => { + const body = host.querySelector("#body")!; + const nearLeft = nearEdge("left", 40); + expect(anyOf(startedIn(".handle"), nearLeft)(context(body, 20))).toBe(true); + expect(anyOf(startedIn(".handle"), nearLeft)(context(body, 100))).toBe( + false, + ); + expect(allOf(startedIn("p"), nearLeft)(context(body, 20))).toBe(true); + expect(allOf(startedIn("p"), nearLeft)(context(body, 100))).toBe(false); + }); + + it("checks scroll edges, treating a missing scroller as at the edge", () => { + const list = document.createElement("div"); + Object.defineProperties(list, { + scrollTop: { value: 0 }, + clientHeight: { value: 100 }, + scrollHeight: { value: 300 }, + }); + const body = host.querySelector("#body")!; + expect(atScrollEdge(list, "top")(context(body))).toBe(true); + expect(atScrollEdge(list, "bottom")(context(body))).toBe(false); + expect(atScrollEdge(null, "top")(context(body))).toBe(true); + expect(atScrollEdge(".missing", "top")(context(body))).toBe(true); + }); +}); + +describe("progress", () => { + it("measures against a custom distance and reports overshoot", async () => { + contexts.length = 0; + const drawer = gesture.to + .right({ distance: 100 }) + .navigate(toTarget) + .animate(motion); + const { scene, host } = await mount(hostFor(drawer)); + host.dispatchEvent(pointer("pointerdown", 0, 100, 0)); + host.dispatchEvent(pointer("pointermove", 50, 100, 16)); + await flush(); + host.dispatchEvent(pointer("pointermove", 130, 100, 32)); + await flush(); + // Progress is 130 / 100 on a 200px host: clamped to 1, 0.3 overshoot. + expect(scene.operations.value[0]).toMatchObject({ progress: 1 }); + expect(scene.operations.value[0]!.overshoot).toBeCloseTo(0.3); + scene.styleForNode(scene.operations.value[0]!.targetKey); + expect(contexts.at(-1)!.overshoot).toBeCloseTo(0.3); + + host.dispatchEvent(pointer("pointermove", -20, 100, 48)); + await flush(); + expect(scene.operations.value[0]).toMatchObject({ progress: 0 }); + expect(scene.operations.value[0]!.overshoot).toBeCloseTo(-0.2); + }); + + it("averages velocity over recent samples instead of the last step", () => { + const tracker = new DirectionalTracker( + "right", + { clientX: 0, clientY: 0, timeStamp: 0 }, + () => 100, + ); + // Steady 1px/ms with one jittery, late sample. + for (const [x, t] of [ + [16, 16], + [32, 32], + [48, 48], + [52, 50], + [80, 80], + ] as const) + tracker.sample({ clientX: x, clientY: 0, timeStamp: t }); + expect(tracker.velocity).toBeCloseTo(10, 0); // 1px/ms over 100px. + + // Held still for longer than the window: the flick is over. + tracker.sample({ clientX: 80, clientY: 0, timeStamp: 300 }); + expect(tracker.velocity).toBe(0); + }); +}); + +describe("springs", () => { + it("uses the choreography's stiffness and damping", () => { + const soft = stepSpring({ position: 0, velocity: 0 }, 1, 16, { + stiffness: 80, + }); + const stiff = stepSpring({ position: 0, velocity: 0 }, 1, 16, { + stiffness: 600, + }); + expect(stiff.position).toBeGreaterThan(soft.position); + const unchanged = stepSpring({ position: 0, velocity: 0 }, 1, 16); + expect(unchanged.position).toBeGreaterThan(soft.position); + }); +}); + +describe(".onArm() and .onDisarm()", () => { + it("fire when the drag crosses the commit threshold", async () => { + const events: string[] = []; + const definition = gesture.to + .left() + .navigate(toTarget) + .onArm(({ progress }) => events.push(`arm ${progress.toFixed(2)}`)) + .onDisarm(({ progress }) => events.push(`disarm ${progress.toFixed(2)}`)) + .animate(motion); + const { host } = await mount(hostFor(definition)); + host.dispatchEvent(pointer("pointerdown", 190, 100, 0)); + for (const [x, t] of [ + [180, 16], + [160, 32], + [130, 48], + [100, 64], + [150, 80], + [90, 96], + ] as const) { + host.dispatchEvent(pointer("pointermove", x, 100, t)); + await flush(); + } + // Default arming is distance-based: 25% of the 200px host is 50px. + expect(events).toEqual(["arm 0.30", "disarm 0.20", "arm 0.50"]); + }); +}); diff --git a/packages/core-v2/tests/recognizer.test.ts b/packages/core-v2/tests/recognizer.test.ts index fa85dd5..4e4fe77 100644 --- a/packages/core-v2/tests/recognizer.test.ts +++ b/packages/core-v2/tests/recognizer.test.ts @@ -241,16 +241,17 @@ describe("reactive gesture props", () => { const rightward = gesture.to.right().navigate(toTarget).animate(motion); const current = shallowRef(leftward); const { scene, host } = await mount(gestureHost(() => current.value)); - expect(host.style.touchAction).toBe("pan-y"); + expect(host.style.touchAction).toBe("manipulation"); // Swap mid-gesture: the drag in progress keeps the leftward definition. host.dispatchEvent(pointer("pointerdown", 180, 0)); current.value = rightward; await nextTick(); - host.dispatchEvent(pointer("pointermove", 120, 100)); + host.dispatchEvent(pointer("pointermove", 140, 100)); await flush(); expect(scene.operations.value).toHaveLength(1); - host.dispatchEvent(pointer("pointerup", 120, 1000)); + // A short, held release (20%) cancels, leaving the origin interactive. + host.dispatchEvent(pointer("pointerup", 140, 1000)); await flush(); // The next touch uses the rightward definition. @@ -294,14 +295,14 @@ describe("reactive gesture props", () => { ), }); const { scene, host } = await mount(Root); - expect(host.style.touchAction).toBe("pan-y"); + expect(host.style.touchAction).toBe("manipulation"); list.value = [ ...list.value, gesture.to.down().navigate(toTarget).animate(motion), ]; await nextTick(); - expect(host.style.touchAction).toBe("none"); + expect(host.style.touchAction).toBe("manipulation"); host.dispatchEvent( Object.assign(pointer("pointerdown", 100, 0), { clientY: 0 }), @@ -327,9 +328,9 @@ describe("commitVelocity", () => { choreography: { effects: () => ({}), commitVelocity }, }) as unknown as Parameters[0]; - it("defaults to 0.3", () => { - expect(shouldCommit(operation(undefined, 0.29), {})).toBe(false); - expect(shouldCommit(operation(undefined, 0.3), {})).toBe(true); + it("defaults to 0.2", () => { + expect(shouldCommit(operation(undefined, 0.19), {})).toBe(false); + expect(shouldCommit(operation(undefined, 0.2), {})).toBe(true); }); it("uses the choreography's value when supplied", () => { diff --git a/packages/core-v2/verify/.gitignore b/packages/core-v2/verify/.gitignore index 224e260..7436df1 100644 --- a/packages/core-v2/verify/.gitignore +++ b/packages/core-v2/verify/.gitignore @@ -1 +1,2 @@ .baseline/ +browser/app/dist/ diff --git a/packages/core-v2/verify/README.md b/packages/core-v2/verify/README.md index 9e4771c..92533e8 100644 --- a/packages/core-v2/verify/README.md +++ b/packages/core-v2/verify/README.md @@ -18,3 +18,20 @@ process biases whichever runs second. Behavioural verification lives with the unit tests: `tests/property.test.ts` drives random operation sequences and pointer streams and checks the scene's structural invariants after every step. Set `PROPERTY_SEEDS` to run more. + +## Real-browser touch checks + +`browser/` drives a development build in Chromium with emulated touch input, +which happy-dom cannot model: implicit touch pointer capture, native scrolling, +`pointercancel`, and rendered styles. It checks that swipes over scrollers +commit, that scrolling still works beside gestures, scroll-edge dismissal, +that `onCommit` runs inside `pointerup`, and the development continuity +warning. + +```sh +NODE_ENV=development npx vite build --mode development \ + --config packages/core-v2/verify/browser/app/vite.config.mjs +node packages/core-v2/verify/browser/run.mjs +``` + +Set `CHROMIUM_PATH` to use a Chromium other than Playwright's own. diff --git a/packages/core-v2/verify/browser/app/index.html b/packages/core-v2/verify/browser/app/index.html new file mode 100644 index 0000000..1c04096 --- /dev/null +++ b/packages/core-v2/verify/browser/app/index.html @@ -0,0 +1,34 @@ + + + + + + + + +
+ + + diff --git a/packages/core-v2/verify/browser/app/main.ts b/packages/core-v2/verify/browser/app/main.ts new file mode 100644 index 0000000..21a372f --- /dev/null +++ b/packages/core-v2/verify/browser/app/main.ts @@ -0,0 +1,140 @@ +import { createApp, defineComponent, h, ref } from "vue"; +import { + OriginGesture, + OriginGestureSurface, + OriginScene, + back, + createOriginScene, + defineOriginChoreography, + forward, + gesture, + originView, + slideLeft, + useOrigin, +} from "../../../src"; + +declare global { + interface Window { + log: string[]; + inPointerup: boolean; + scene: ReturnType; + scrollTopOf: (selector: string) => number; + begin: (name: "matched" | "mismatched") => Promise; + } +} +window.log = []; +window.inPointerup = false; +window.addEventListener("pointerup", () => (window.inPointerup = true), true); +window.addEventListener("pointerup", () => + setTimeout(() => (window.inPointerup = false)), +); +const warn = console.warn; +console.warn = (...args: unknown[]) => { + window.log.push("warn: " + String(args[0])); + warn(...args); +}; + +const rows = (n: number) => + Array.from({ length: n }, (_, i) => h("div", { class: "row" }, `row ${i}`)); + +const Detail = defineComponent({ + name: "Detail", + setup() { + const list = ref(null); + const dismiss = gesture.from + .scrollEdge(list, "top") + .to.down() + .navigate((c) => (c.canGoBack ? back() : null)) + .onCommit(() => window.log.push("dismiss committed")) + .animate( + defineOriginChoreography({ + name: "dismiss", + effects: ({ progress }) => ({ + source: { transform: `translateY(${progress * 100}%)` }, + }), + }), + ); + return () => + h(OriginGesture, { gesture: dismiss, class: "page", id: "detail" }, () => + h("div", { class: "list", id: "detail-list", ref: list }, rows(40)), + ); + }, +}); + +const Home = defineComponent({ + name: "Home", + setup() { + const open = gesture.to + .left() + .navigate(() => forward(originView(Detail, undefined, { key: "detail" }))) + .onCommit(() => + window.log.push(`open committed inPointerup=${window.inPointerup}`), + ) + .animate(slideLeft); + return () => + h( + OriginGestureSurface, + { gestures: [open], class: "page", id: "home" }, + () => h("div", { class: "list", id: "home-list" }, rows(40)), + ); + }, +}); + +// Continuity check pair: a drawer that rests at 80% and a close that starts +// either from the same pose (matched) or from 0 (mismatched). +const drawerOpen = defineOriginChoreography({ + name: "drawer-open", + persistAtRest: true, + effects: ({ progress }) => ({ + source: { transform: `translateX(${progress * 80}%)` }, + }), +}); +const closes = { + matched: defineOriginChoreography({ + name: "drawer-close-matched", + effects: ({ progress }) => ({ + target: { transform: `translateX(${(1 - progress) * 80}%)` }, + }), + }), + mismatched: defineOriginChoreography({ + name: "drawer-close-mismatched", + effects: ({ progress }) => ({ + target: { transform: `translateX(${(1 - progress) * 50}%)` }, + }), + }), +}; +const Drawer = defineComponent({ + name: "Drawer", + render: () => h("div", { class: "page" }, "drawer"), +}); +const Plain = defineComponent({ + name: "Plain", + setup() { + const origin = useOrigin(); + (window as unknown as { openDrawer: () => Promise }).openDrawer = + () => + origin.perform( + forward(originView(Drawer, undefined, { key: "drawer" }), drawerOpen), + ); + return () => h("div", { class: "page", id: "plain" }, "plain"); + }, +}); + +const params = new URLSearchParams(location.search); +const initial = params.get("view") === "plain" ? Plain : Home; +const scene = createOriginScene({ + initial: originView(initial, undefined, { key: "root" }), +}); +window.scene = scene; +window.scrollTopOf = (selector) => + document.querySelector(selector)?.scrollTop ?? -1; +window.begin = async (name) => { + await ( + window as unknown as { openDrawer: () => Promise } + ).openDrawer(); + const drawerKey = scene.nodes.value.find((n) => n.view.key === "drawer")!.key; + const handle = await scene.begin(drawerKey, back(closes[name])); + await handle.cancel({ animate: false }); + await handle.finish({ commit: false, animate: false }); +}; +createApp({ render: () => h(OriginScene, { scene }) }).mount("#app"); diff --git a/packages/core-v2/verify/browser/app/vite.config.mjs b/packages/core-v2/verify/browser/app/vite.config.mjs new file mode 100644 index 0000000..d545b79 --- /dev/null +++ b/packages/core-v2/verify/browser/app/vite.config.mjs @@ -0,0 +1,8 @@ +import vue from "@vitejs/plugin-vue"; +import { defineConfig } from "vite"; +export default defineConfig({ + root: import.meta.dirname, + base: "./", + plugins: [vue()], + build: { outDir: "dist", emptyOutDir: true, minify: false }, +}); diff --git a/packages/core-v2/verify/browser/run.mjs b/packages/core-v2/verify/browser/run.mjs new file mode 100644 index 0000000..154cec5 --- /dev/null +++ b/packages/core-v2/verify/browser/run.mjs @@ -0,0 +1,189 @@ +import { chromium } from "@playwright/test"; +import { createServer } from "node:http"; +import { readFile } from "node:fs/promises"; +import { extname, join } from "node:path"; + +const root = join(import.meta.dirname, "app/dist"); +const types = { + ".html": "text/html", + ".js": "text/javascript", + ".css": "text/css", +}; +const server = createServer(async (req, res) => { + const path = join( + root, + new URL(req.url, "http://x").pathname.replace(/\/$/, "/index.html"), + ); + let body; + try { + body = await readFile(path); + } catch { + res.writeHead(404); + return res.end(); + } + res.writeHead(200, { + "content-type": types[extname(path)] ?? "application/octet-stream", + }); + res.end(body); +}).listen(0); +const base = `http://127.0.0.1:${server.address().port}/`; + +// CHROMIUM_PATH overrides Playwright's bundled Chromium. +const browser = await chromium.launch({ + executablePath: process.env.CHROMIUM_PATH, +}); +const context = await browser.newContext({ + hasTouch: true, + isMobile: true, + viewport: { width: 390, height: 844 }, +}); +const results = []; +const check = (name, ok, detail) => { + results.push({ name, ok, detail }); + console.log(`${ok ? "PASS" : "FAIL"} ${name}${detail ? " — " + detail : ""}`); +}; + +async function open(query = "") { + const page = await context.newPage(); + page.on("pageerror", (e) => console.log("pageerror", e.message)); + await page.goto(base + query); + await page.waitForFunction(() => window.scene); + return { page, cdp: await context.newCDPSession(page) }; +} +async function drag({ page, cdp }, from, to, steps = 12, stepMs = 16) { + await cdp.send("Input.dispatchTouchEvent", { + type: "touchStart", + touchPoints: [{ x: from[0], y: from[1] }], + }); + for (let i = 1; i <= steps; i++) { + const x = from[0] + ((to[0] - from[0]) * i) / steps; + const y = from[1] + ((to[1] - from[1]) * i) / steps; + await cdp.send("Input.dispatchTouchEvent", { + type: "touchMove", + touchPoints: [{ x, y }], + }); + await page.waitForTimeout(stepMs); + } + await cdp.send("Input.dispatchTouchEvent", { + type: "touchEnd", + touchPoints: [], + }); + await page.waitForTimeout(700); +} +const states = (page) => + page.evaluate(() => + window.scene.nodes.value.map((n) => `${n.view.key}:${n.state}`).join(" "), + ); +const log = (page) => page.evaluate(() => window.log.slice()); + +// 1. Horizontal swipe over a vertical scroller commits (implicit capture + no pointercancel). +{ + const t = await open(); + await drag(t, [340, 400], [60, 405]); + const s = await states(t.page); + check( + "horizontal swipe over a scroller opens Detail", + s === "root:parked detail:active", + s, + ); + const l = await log(t.page); + check( + "onCommit runs inside the real pointerup event", + l.includes("open committed inPointerup=true"), + l.join(" | "), + ); + await t.page.close(); +} +// 2. Vertical drag on the same host scrolls natively and leaves the gesture alone. +{ + const t = await open(); + await drag(t, [200, 700], [205, 300]); + const s = await states(t.page); + const top = await t.page.evaluate(() => window.scrollTopOf("#home-list")); + check( + "vertical drag scrolls the list natively", + top > 100, + `scrollTop=${top}`, + ); + check( + "vertical drag does not start the horizontal gesture", + s === "root:active", + s, + ); + await t.page.close(); +} +// 3. Scroll-edge dismiss. +{ + const t = await open(); + await drag(t, [340, 400], [60, 405]); + check( + "(setup) Detail open", + (await states(t.page)) === "root:parked detail:active", + ); + await drag(t, [200, 700], [200, 300]); // scroll the list down + const scrolled = await t.page.evaluate(() => + window.scrollTopOf("#detail-list"), + ); + check( + "drag up scrolls the detail list", + scrolled > 100, + `scrollTop=${scrolled}`, + ); + await drag(t, [200, 300], [200, 600]); // scrolled: drag down scrolls back, no dismiss + const afterDown = await t.page.evaluate(() => + window.scrollTopOf("#detail-list"), + ); + check( + "drag down while scrolled scrolls the list, not dismiss", + afterDown < scrolled && + (await states(t.page)) === "root:parked detail:active", + `scrollTop ${scrolled} → ${afterDown}`, + ); + await t.page.evaluate( + () => (document.querySelector("#detail-list").scrollTop = 0), + ); + await drag(t, [200, 300], [200, 800]); // at top: drag down dismisses + const s = await states(t.page); + const l = await log(t.page); + check( + "drag down at the top dismisses", + s === "root:active" && l.includes("dismiss committed"), + s, + ); + await t.page.close(); +} +// 3b. At the top, an upward drag still scrolls the list. +{ + const t = await open(); + await drag(t, [340, 400], [60, 405]); + await drag(t, [200, 700], [200, 300]); + const top = await t.page.evaluate(() => window.scrollTopOf("#detail-list")); + check( + "at the top, drag up still scrolls (dismiss lets go)", + top > 100 && (await states(t.page)) === "root:parked detail:active", + `scrollTop=${top}`, + ); + await t.page.close(); +} +// 4. Continuity warning. +for (const [name, expectWarning] of [ + ["matched", false], + ["mismatched", true], +]) { + const t = await open("?view=plain"); + await t.page.evaluate((n) => window.begin(n), name); + await t.page.waitForTimeout(100); + const warned = (await log(t.page)).some((l) => l.includes("moved node")); + check( + `continuity warning for a ${name} open/close pair: ${expectWarning ? "warns" : "silent"}`, + warned === expectWarning, + (await log(t.page)).join(" | "), + ); + await t.page.close(); +} + +await browser.close(); +server.close(); +const failed = results.filter((r) => !r.ok).length; +console.log(`\n${results.length - failed}/${results.length} passed`); +process.exit(failed ? 1 : 0);