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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
This commit is contained in:
Claude
2026-10-31 08:22:11 +00:00
parent d4d7feb3b7
commit 6889fc0112
20 changed files with 1547 additions and 163 deletions
+227 -25
View File
@@ -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<HTMLElement | null>(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 `<input type="checkbox" switch>`, 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.
+29 -6
View File
@@ -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)`,
@@ -28,6 +28,7 @@ const gesture = useOriginGesture(() => props.gesture);
@pointerup="gesture.onPointerup"
@pointercancel="gesture.onPointercancel"
@lostpointercapture="gesture.onLostpointercapture"
@touchmove="gesture.onTouchmove"
>
<slot />
</component>
@@ -27,6 +27,7 @@ const surface = useOriginGestures(() => props.gestures);
@pointerup="surface.onPointerup"
@pointercancel="surface.onPointercancel"
@lostpointercapture="surface.onLostpointercapture"
@touchmove="surface.onTouchmove"
>
<slot />
</component>
+170 -16
View File
@@ -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<Element | null | undefined> | 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<OriginGestureDirectionOptions>;
readonly completion?: OriginGestureCompletionPredicate;
readonly commitHandlers: readonly OriginGestureCommitHandler[];
readonly armHandlers: readonly OriginGestureCommitHandler[];
readonly disarmHandlers: readonly OriginGestureCommitHandler[];
}
const withCommitHandler = <Draft extends GestureDraft>(
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 extends GestureDraft, Stage>(
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.
*
+215 -87
View File
@@ -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<OriginGestureDirection>,
): "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<PointerEvent, "clientX" | "clientY" | "timeStamp">;
/** 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<UseOrigin, "context" | "begin">;
@@ -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<OriginOperationHandle | null> | 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<void> {
@@ -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();
},
};
};
+9 -1
View File
@@ -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";
/**
+5 -2
View File
@@ -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",
+122 -15
View File
@@ -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<void> =>
new Promise<void>((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<object>();
/** 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<OriginOperationHandle> {
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<string, Pose> {
const poses = new Map<string, Pose>();
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<string, Pose>,
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,
+7 -2
View File
@@ -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. */
+7
View File
@@ -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";
+1 -1
View File
@@ -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%");
});
+355
View File
@@ -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<ReturnType<typeof createApp>> = [];
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<HTMLElement | null>(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 = `<div class="handle"><span id="inner"></span></div><p id="body"></p>`;
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"]);
});
});
+9 -8
View File
@@ -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<typeof shouldCommit>[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", () => {
+1
View File
@@ -1 +1,2 @@
.baseline/
browser/app/dist/
+17
View File
@@ -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.
@@ -0,0 +1,34 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<style>
html,
body,
#app {
margin: 0;
height: 100%;
overflow: hidden;
}
.page {
height: 100%;
display: flex;
flex-direction: column;
background: #fff;
}
.list {
flex: 1;
overflow: auto;
}
.row {
height: 80px;
border-bottom: 1px solid #ddd;
}
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="./main.ts"></script>
</body>
</html>
+140
View File
@@ -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<typeof createOriginScene>;
scrollTopOf: (selector: string) => number;
begin: (name: "matched" | "mismatched") => Promise<void>;
}
}
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<HTMLElement | null>(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<boolean> }).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<boolean> }
).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");
@@ -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 },
});
+189
View File
@@ -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);