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:
+227
-25
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
/**
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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%");
|
||||
});
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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 +1,2 @@
|
||||
.baseline/
|
||||
browser/app/dist/
|
||||
|
||||
@@ -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>
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user