Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
caf2ad692a | ||
|
|
6889fc0112 | ||
|
|
d4d7feb3b7 | ||
|
|
7033814550 | ||
|
|
a46c411ec4 | ||
|
|
1924d4b52b | ||
|
|
b6f782d8af | ||
|
|
46f030630b |
@@ -7,6 +7,7 @@ import {
|
|||||||
gesture,
|
gesture,
|
||||||
originView,
|
originView,
|
||||||
useOrigin,
|
useOrigin,
|
||||||
|
type OriginView,
|
||||||
} from "@native-vue-router/core-v2";
|
} from "@native-vue-router/core-v2";
|
||||||
import { useDemoInstance } from "../lab-state";
|
import { useDemoInstance } from "../lab-state";
|
||||||
import { unfocusPortal } from "../motions";
|
import { unfocusPortal } from "../motions";
|
||||||
@@ -86,7 +87,7 @@ const slideSets: Record<NestedSceneKind, readonly SlideCopy[]> = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
function nestedView(kind: NestedSceneKind, index: number) {
|
function nestedView(kind: NestedSceneKind, index: number): OriginView {
|
||||||
const slides = slideSets[kind];
|
const slides = slideSets[kind];
|
||||||
const copy = slides[index]!;
|
const copy = slides[index]!;
|
||||||
return originView(
|
return originView(
|
||||||
@@ -98,7 +99,7 @@ function nestedView(kind: NestedSceneKind, index: number) {
|
|||||||
...copy,
|
...copy,
|
||||||
next:
|
next:
|
||||||
index + 1 < slides.length
|
index + 1 < slides.length
|
||||||
? () => nestedView(kind, index + 1)
|
? (): OriginView => nestedView(kind, index + 1)
|
||||||
: undefined,
|
: undefined,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
+2
-1
@@ -49,7 +49,8 @@
|
|||||||
"electron": "npm run build && electron apps/electron/main.mjs",
|
"electron": "npm run build && electron apps/electron/main.mjs",
|
||||||
"cap:sync": "npm run build && npm --prefix apps/capacitor exec cap sync",
|
"cap:sync": "npm run build && npm --prefix apps/capacitor exec cap sync",
|
||||||
"format": "prettier --write \"**/*.{js,jsx,ts,tsx,json,css,md}\"",
|
"format": "prettier --write \"**/*.{js,jsx,ts,tsx,json,css,md}\"",
|
||||||
"prepare": "npm run build --workspace packages/core-v2"
|
"prepare": "npm run build --workspace packages/core-v2",
|
||||||
|
"test:feel": "NODE_ENV=development vite build --mode development --config packages/core-v2/verify/browser/app/vite.config.mjs && node packages/core-v2/verify/browser/run.mjs"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@capacitor/app": "^8.0.0",
|
"@capacitor/app": "^8.0.0",
|
||||||
|
|||||||
+308
-40
@@ -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)
|
- [Scene diagnostics and manual operations](#scene-diagnostics-and-manual-operations)
|
||||||
- [Type reference](#type-reference)
|
- [Type reference](#type-reference)
|
||||||
- [Errors and constraints](#errors-and-constraints)
|
- [Errors and constraints](#errors-and-constraints)
|
||||||
|
- [Gestures in real apps](#gestures-in-real-apps)
|
||||||
|
|
||||||
## Gesture start recognition
|
## Gesture start recognition
|
||||||
|
|
||||||
@@ -43,6 +44,7 @@ gesture.from.top(distance);
|
|||||||
gesture.from.bottom(distance);
|
gesture.from.bottom(distance);
|
||||||
gesture.from.anywhere();
|
gesture.from.anywhere();
|
||||||
gesture.from.when(predicate);
|
gesture.from.when(predicate);
|
||||||
|
gesture.from.scrollEdge(scroller, edge);
|
||||||
```
|
```
|
||||||
|
|
||||||
`distance` accepts a number in CSS pixels or a CSS length string, including
|
`distance` accepts a number in CSS pixels or a CSS length string, including
|
||||||
@@ -80,9 +82,43 @@ const dropDialog = gesture.from
|
|||||||
.animate(dropAnimation);
|
.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
|
`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
|
## Minimum setup
|
||||||
|
|
||||||
@@ -145,7 +181,7 @@ recognizer to it.
|
|||||||
| Prop | Type | Default | Description |
|
| Prop | Type | Default | Description |
|
||||||
| --------- | ------------------------- | ------- | ---------------------------------- |
|
| --------- | ------------------------- | ------- | ---------------------------------- |
|
||||||
| `as` | `string` | `"div"` | HTML tag used for the gesture host |
|
| `as` | `string` | `"div"` | HTML tag used for the gesture host |
|
||||||
| `gesture` | `OriginGestureDefinition` | — | Preferred builder definition |
|
| `gesture` | `OriginGestureDefinition` | — | Builder definition |
|
||||||
|
|
||||||
Attributes, classes, and listeners not consumed as props are forwarded to the
|
Attributes, classes, and listeners not consumed as props are forwarded to the
|
||||||
rendered host.
|
rendered host.
|
||||||
@@ -156,8 +192,9 @@ rendered host.
|
|||||||
</OriginGesture>
|
</OriginGesture>
|
||||||
```
|
```
|
||||||
|
|
||||||
For compatibility, the component also accepts the legacy mutually exclusive
|
The `gesture` prop is reactive. A new definition applies from the next
|
||||||
set of `direction`, `edge`, `threshold`, and `action` props.
|
pointer-down; a gesture already in progress keeps the definition it started
|
||||||
|
with, so a parent re-render never interrupts a drag.
|
||||||
|
|
||||||
Use `useOriginGesture()` instead when an extra wrapper is undesirable.
|
Use `useOriginGesture()` instead when an extra wrapper is undesirable.
|
||||||
|
|
||||||
@@ -183,16 +220,15 @@ const gestures = [forwardGesture, backGesture] as const;
|
|||||||
| `gestures` | `readonly OriginGestureDefinition[]` | required | Fully defined page-owned rules |
|
| `gestures` | `readonly OriginGestureDefinition[]` | required | Fully defined page-owned rules |
|
||||||
|
|
||||||
The component adds no recognition or navigation policy. It installs each
|
The component adds no recognition or navigation policy. It installs each
|
||||||
definition with `useOriginGesture()`, forwards every pointer event to every
|
definition, forwards every pointer event to every recognizer, and derives one
|
||||||
binding, and derives the least-permissive shared `touch-action`:
|
`touch-action` that leaves native scrolling on every pan no gesture needs. A
|
||||||
|
horizontal drawer and a scroll-edge pull-down can share a host with vertical
|
||||||
|
scrolling. See [Scrolling](#scrolling).
|
||||||
|
|
||||||
- horizontal only: `pan-y`;
|
The `gestures` list is reactive, including its length. Recognizers are kept per list position, so a re-render with new
|
||||||
- vertical only: `pan-x`;
|
definition objects never interrupts a gesture in progress; each change applies
|
||||||
- both axes: `none`.
|
from the next pointer-down. The owning page remains the visible declaration
|
||||||
|
point for every `.from`, `.to`, `.complete`, `.navigate`, and `.animate` choice.
|
||||||
Definitions should be immutable and stable for the lifetime of the rendered
|
|
||||||
surface. The owning page remains the visible declaration point for every
|
|
||||||
`.from`, `.to`, `.complete`, `.navigate`, and `.animate` choice.
|
|
||||||
|
|
||||||
## Nested scenes
|
## Nested scenes
|
||||||
|
|
||||||
@@ -424,6 +460,16 @@ change a definition already produced from it.
|
|||||||
| --------------- | ------- | ------------------------------------------------- |
|
| --------------- | ------- | ------------------------------------------------- |
|
||||||
| `threshold` | `8` | Directed CSS pixels required before capture |
|
| `threshold` | `8` | Directed CSS pixels required before capture |
|
||||||
| `axisDominance` | `1.15` | Directed/cross-axis ratio 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
|
If `.complete()` is omitted, the choreography's `commitThreshold` and
|
||||||
`commitVelocity` decide release normally.
|
`commitVelocity` decide release normally.
|
||||||
@@ -449,15 +495,36 @@ const openProfile = gesture.to
|
|||||||
.animate(slideLeft);
|
.animate(slideLeft);
|
||||||
```
|
```
|
||||||
|
|
||||||
`navigator.vibrate` is unavailable in iOS Safari; Capacitor apps can call
|
Handlers run inside the `pointerup` event whenever the operation has already
|
||||||
`Haptics.impact()` from `@capacitor/haptics` instead. The method is named
|
begun, which is the usual case, so APIs that need the user's gesture (iOS
|
||||||
`onCommit` rather than `then` so builder stages never become promise-like
|
haptics, audio, clipboard writes) work there. See [Haptics](#haptics).
|
||||||
"thenables" when awaited or returned from an async function.
|
|
||||||
|
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)`
|
### `useOriginGesture(definition)`
|
||||||
|
|
||||||
Creates one primary-pointer, single-axis recognizer owned by the component that
|
Creates one primary-pointer, single-axis recognizer owned by the component that
|
||||||
calls it.
|
calls it. `definition` may also be a ref or getter; a changed definition
|
||||||
|
applies from the next pointer-down.
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
const open = useOriginGesture(
|
const open = useOriginGesture(
|
||||||
@@ -477,28 +544,63 @@ interface OriginGestureBinding {
|
|||||||
readonly onPointermove: (event: PointerEvent) => void;
|
readonly onPointermove: (event: PointerEvent) => void;
|
||||||
readonly onPointerup: (event: PointerEvent) => void;
|
readonly onPointerup: (event: PointerEvent) => void;
|
||||||
readonly onPointercancel: () => void;
|
readonly onPointercancel: () => void;
|
||||||
|
readonly onLostpointercapture: (event: PointerEvent) => void;
|
||||||
|
readonly listeners: Readonly<Record<string, (event: Event) => void>>;
|
||||||
|
readonly ref: (element: unknown) => void;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Apply all handlers to the same element. The returned style sets dimensions and
|
Apply the pointer handlers, the returned style, and `listeners` to the same
|
||||||
`touch-action` so native scrolling remains available on the cross-axis.
|
element, and pass `ref` as its function ref:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<div
|
||||||
|
:ref="binding.ref"
|
||||||
|
:style="binding.style"
|
||||||
|
v-bind="binding.listeners"
|
||||||
|
@pointerdown="binding.onPointerdown"
|
||||||
|
@pointermove="binding.onPointermove"
|
||||||
|
@pointerup="binding.onPointerup"
|
||||||
|
@pointercancel="binding.onPointercancel"
|
||||||
|
@lostpointercapture="binding.onLostpointercapture"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
The style sets dimensions and the host's `touch-action` (see
|
||||||
|
[Scrolling](#scrolling)). `listeners` is empty for ordinary gestures; for
|
||||||
|
scroll-edge gestures it tracks the scroller, and where the browser lacks
|
||||||
|
directional `touch-action` it adds a `touchmove` handler.
|
||||||
|
|
||||||
Recognition requires:
|
Recognition requires:
|
||||||
|
|
||||||
1. A primary, left-button pointer satisfies the optional start policy.
|
1. A primary, left-button pointer satisfies the optional start policy.
|
||||||
2. The target is not an ignored interactive element.
|
2. The target is not an ignored interactive element.
|
||||||
3. Directed movement reaches `threshold`.
|
3. Directed movement from the pointer-down point reaches `threshold`.
|
||||||
4. Directed movement exceeds cross-axis movement by `axisDominance`.
|
4. Directed movement exceeds cross-axis movement by `axisDominance`.
|
||||||
5. The navigation factory returns an intent.
|
5. The navigation factory returns an intent.
|
||||||
|
|
||||||
Progress is directed distance divided by host width or height. Release velocity
|
Recognition is measured from where the pointer went down and keeps watching
|
||||||
is normalized by the same dimension.
|
until the pointer lifts, so a slightly wobbly or backward start only delays
|
||||||
|
it.
|
||||||
|
|
||||||
|
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
|
An asynchronous navigation factory is supported. If it resolves after the
|
||||||
pointer was released or cancelled, the stale result is discarded.
|
pointer was released or cancelled, the stale result is discarded.
|
||||||
|
|
||||||
The legacy `OriginGestureOptions` object remains accepted. Its `edge` is a
|
A captured gesture is cancelled, never left mid-transition, when its pointer
|
||||||
number inferred from the side opposite `direction`, matching the previous API.
|
ends without a pointer-up: on `pointercancel`, on `lostpointercapture`, when a
|
||||||
|
new primary pointer goes down, or when the component unmounts.
|
||||||
|
|
||||||
|
Failures are reported with `console.warn` and the gesture is abandoned: a
|
||||||
|
`navigate()` factory that throws or rejects, or a scene that rejects the
|
||||||
|
operation (see `OriginSceneError`). Returning `null` declines silently, as
|
||||||
|
does losing a race to another gesture on the same origin.
|
||||||
|
|
||||||
## Choreographies and effects
|
## Choreographies and effects
|
||||||
|
|
||||||
@@ -571,14 +673,18 @@ can run repeatedly during rendering and animation.
|
|||||||
|
|
||||||
### Commit thresholds
|
### Commit thresholds
|
||||||
|
|
||||||
When `finish()` does not explicitly override the decision, a target commits
|
When `finish()` does not explicitly override the decision, the release velocity
|
||||||
when either:
|
decides first, then distance:
|
||||||
|
|
||||||
- `progress >= commitThreshold`, default `0.36`; or
|
- a flick at least `commitVelocity` fast (default `0.2` progress units per
|
||||||
- release `velocity >= 0.3` progress units per second, at any progress.
|
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`.
|
||||||
|
|
||||||
`commitVelocity` is accepted on choreographies but is not currently consulted;
|
The included presets use these defaults.
|
||||||
the flick threshold is the fixed `0.3` above.
|
|
||||||
|
Velocity is normalized by the gesture host's size, so `1` is one full width (or
|
||||||
|
height) per second.
|
||||||
|
|
||||||
The operation's intent becomes final before its spring settles.
|
The operation's intent becomes final before its spring settles.
|
||||||
|
|
||||||
@@ -662,8 +768,13 @@ can immediately begin its own outgoing operation, enabling X → Y → Z chains.
|
|||||||
|
|
||||||
### `OriginOperationHandle`
|
### `OriginOperationHandle`
|
||||||
|
|
||||||
`intent` reports the selected outcome (`undecided`, `commit`, or `cancel`). It
|
`intent` changes synchronously when `finish()` or `cancel()` is called, before
|
||||||
changes synchronously when `finish()` or `cancel()` is called.
|
the settling spring runs.
|
||||||
|
|
||||||
|
Calling `update()` while an operation settles catches it: the spring stops and
|
||||||
|
progress is under control again, for example to grab a transition that is
|
||||||
|
still animating. A later `finish()` decides the outcome, and any earlier
|
||||||
|
`finish()` promise resolves with that final outcome.
|
||||||
|
|
||||||
| Member | Description |
|
| Member | Description |
|
||||||
| ----------------------------- | --------------------------------------------------- |
|
| ----------------------------- | --------------------------------------------------- |
|
||||||
@@ -672,6 +783,7 @@ changes synchronously when `finish()` or `cancel()` is called.
|
|||||||
| `targetKey` | Created or retained target node key |
|
| `targetKey` | Created or retained target node key |
|
||||||
| `update(progress, velocity?)` | Update normalized interactive state |
|
| `update(progress, velocity?)` | Update normalized interactive state |
|
||||||
| `finish(options?)` | Decide, settle, and return whether target committed |
|
| `finish(options?)` | Decide, settle, and return whether target committed |
|
||||||
|
| `intent` | Selected outcome: `undecided`, `commit`, `cancel` |
|
||||||
| `cancel(options?)` | Force cancellation and remove the target branch |
|
| `cancel(options?)` | Force cancellation and remove the target branch |
|
||||||
|
|
||||||
`OriginFinishOptions`:
|
`OriginFinishOptions`:
|
||||||
@@ -737,12 +849,6 @@ Node-local action context:
|
|||||||
- `previous`: recipe belonging to the mounted previous entry.
|
- `previous`: recipe belonging to the mounted previous entry.
|
||||||
- `history`: recipes belonging to all retained previous entries.
|
- `history`: recipes belonging to all retained previous entries.
|
||||||
|
|
||||||
### `MaybeOriginAction`
|
|
||||||
|
|
||||||
```ts
|
|
||||||
OriginAction | null | undefined | Promise<OriginAction | null | undefined>;
|
|
||||||
```
|
|
||||||
|
|
||||||
### `OriginEffect`
|
### `OriginEffect`
|
||||||
|
|
||||||
| Field | Description |
|
| Field | Description |
|
||||||
@@ -765,6 +871,21 @@ OriginAction | null | undefined | Promise<OriginAction | null | undefined>;
|
|||||||
| `originRect` | Origin bounds captured before target mounting |
|
| `originRect` | Origin bounds captured before target mounting |
|
||||||
| `targetRect` | Target bounds measured after mounting |
|
| `targetRect` | Target bounds measured after mounting |
|
||||||
| `viewport` | Scene-container bounds, with browser viewport fallback |
|
| `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.
|
Rect values are viewport CSS pixels.
|
||||||
|
|
||||||
@@ -786,6 +907,21 @@ implementation types. They are exported by the current barrel but marked
|
|||||||
|
|
||||||
## Errors and constraints
|
## Errors and constraints
|
||||||
|
|
||||||
|
Scene operations reject with `OriginSceneError`, whose `code` identifies the
|
||||||
|
cause:
|
||||||
|
|
||||||
|
| Code | Cause |
|
||||||
|
| ------------------- | --------------------------------------------------- |
|
||||||
|
| `missing-node` | The node no longer exists |
|
||||||
|
| `missing-origin` | The origin node does not exist |
|
||||||
|
| `inert-origin` | The origin is parked or exposed |
|
||||||
|
| `origin-busy` | The origin already has a live outgoing operation |
|
||||||
|
| `invalid-action` | The action cannot be executed as written |
|
||||||
|
| `no-previous-entry` | Back was requested without a retained entry |
|
||||||
|
| `undecided-forward` | Back was requested through an unreleased forward |
|
||||||
|
| `target-busy` | The back target is transitioning or still presented |
|
||||||
|
| `outside-scene` | `useOrigin()` was called outside an `<OriginScene>` |
|
||||||
|
|
||||||
- `useOrigin()` and `useOriginGesture()` must run inside a component mounted by
|
- `useOrigin()` and `useOriginGesture()` must run inside a component mounted by
|
||||||
`OriginScene`.
|
`OriginScene`.
|
||||||
- An origin can own only one outgoing operation at a time.
|
- An origin can own only one outgoing operation at a time.
|
||||||
@@ -803,3 +939,135 @@ implementation types. They are exported by the current barrel but marked
|
|||||||
- A choreography creates one target. Chaining supports any number of
|
- A choreography creates one target. Chaining supports any number of
|
||||||
simultaneously mounted targets.
|
simultaneously mounted targets.
|
||||||
- Reduced-motion preference resolves settling immediately.
|
- Reduced-motion preference resolves settling immediately.
|
||||||
|
|
||||||
|
## Gestures in real apps
|
||||||
|
|
||||||
|
### Scrolling
|
||||||
|
|
||||||
|
Native scrolling stays in the browser's hands: gesture hosts never make it
|
||||||
|
wait for JavaScript. Each host gets a `touch-action` that keeps every pan no
|
||||||
|
gesture needs:
|
||||||
|
|
||||||
|
| Gestures on the host | `touch-action` |
|
||||||
|
| --------------------------------------------------------------- | ---------------- |
|
||||||
|
| Horizontal only | `pan-y` |
|
||||||
|
| Vertical only | `pan-x` |
|
||||||
|
| Both axes | `none` |
|
||||||
|
| `.from.scrollEdge(list, "top")` + `.to.down()`, list at the top | `pan-x pan-down` |
|
||||||
|
| The same, list scrolled | `manipulation` |
|
||||||
|
|
||||||
|
The browser therefore scrolls the free axis natively from the first frame,
|
||||||
|
and touches on a gesture's axis reach the recognizer untouched.
|
||||||
|
|
||||||
|
A gesture that shares its axis with a scroller, such as drag-down-to-dismiss
|
||||||
|
above a list, uses `.from.scrollEdge()`. While the list is at that edge, the
|
||||||
|
host reserves only the one pan the gesture needs: at the top, dragging down is
|
||||||
|
the gesture's and dragging up still scrolls the list natively. This is the
|
||||||
|
pull-to-refresh pattern from the Pointer Events specification, and it relies
|
||||||
|
on direction-specific `touch-action` values (`pan-down` and its siblings). The
|
||||||
|
host follows the list's scroll position through a passive scroll listener, so
|
||||||
|
the right value is in place before each touch starts.
|
||||||
|
|
||||||
|
Browsers without direction-specific values (Safari) fall back to deciding in
|
||||||
|
JavaScript: the host keeps the axis pannable and cancels native scrolling
|
||||||
|
from a `touchmove` handler for touches the gesture claims, at the browser's
|
||||||
|
first cancelable `touchmove` (after roughly 15–20px of movement). Blocking
|
||||||
|
touch handlers make the browser wait for JavaScript on every move, so this
|
||||||
|
fallback is used only for scroll-edge gestures and only where it is needed.
|
||||||
|
|
||||||
|
Nested scroll containers: browsers apply `touch-action` only up to the nearest
|
||||||
|
scroll container, so a list scrolling inside a horizontal gesture host would
|
||||||
|
otherwise let the browser take horizontal drags and cancel the gesture. Each
|
||||||
|
host publishes its value as the inherited `--nvo-touch-action` custom property,
|
||||||
|
and the package stylesheet applies it to descendants, so nested lists keep
|
||||||
|
`pan-y` too. This requires `@native-vue-router/core-v2/style.css`.
|
||||||
|
|
||||||
|
### 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. Ignored subtrees
|
||||||
|
also get `touch-action: auto` back from the package stylesheet, so a chip row
|
||||||
|
scrolls horizontally inside a horizontal gesture host.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|||||||
@@ -308,6 +308,10 @@ Each module depends only on the ones listed above it.
|
|||||||
| `gesture/recognizer.ts` | Pointer-down admission, `DirectionalTracker`, `GestureRecognizer`, and `useOriginGesture` |
|
| `gesture/recognizer.ts` | Pointer-down admission, `DirectionalTracker`, `GestureRecognizer`, and `useOriginGesture` |
|
||||||
| `components/` | `OriginScene`, `OriginGesture`, `OriginGestureSurface`, and the internal node host |
|
| `components/` | `OriginScene`, `OriginGesture`, `OriginGestureSurface`, and the internal node host |
|
||||||
|
|
||||||
|
Tests live in `tests/`, outside `src/`. `tests/property.test.ts` checks the
|
||||||
|
scene's structural invariants after every step of random operation sequences
|
||||||
|
and pointer streams; set `PROPERTY_SEEDS` to run more than the default 60.
|
||||||
|
|
||||||
Pure helpers used by a class (for example `matchesStart`, `stepSpring`, or
|
Pure helpers used by a class (for example `matchesStart`, `stepSpring`, or
|
||||||
`composeNodeStyle`) are module-level `export const`s that the class calls, so
|
`composeNodeStyle`) are module-level `export const`s that the class calls, so
|
||||||
they can be tested directly without widening the class's interface.
|
they can be tested directly without widening the class's interface.
|
||||||
|
|||||||
@@ -78,6 +78,12 @@ export interface OriginEffectSet {
|
|||||||
export interface OriginChoreographyContext {
|
export interface OriginChoreographyContext {
|
||||||
/** Normalized operation progress, clamped to the inclusive range `0..1`. */
|
/** Normalized operation progress, clamped to the inclusive range `0..1`. */
|
||||||
progress: number;
|
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
|
* Normalized progress units per second. Positive velocity moves toward
|
||||||
* commit; negative velocity moves back toward cancellation.
|
* commit; negative velocity moves back toward cancellation.
|
||||||
@@ -111,15 +117,15 @@ export interface OriginChoreography {
|
|||||||
/**
|
/**
|
||||||
* Gesture progress required to retain the target after release.
|
* Gesture progress required to retain the target after release.
|
||||||
*
|
*
|
||||||
* @defaultValue `0.36`
|
* @defaultValue `0.25`
|
||||||
*/
|
*/
|
||||||
readonly commitThreshold?: number;
|
readonly commitThreshold?: number;
|
||||||
/**
|
/**
|
||||||
* Reserved: normalized positive release velocity intended to commit a
|
* Release velocity, in progress units per second, that commits a flick
|
||||||
* deliberate flick.
|
* regardless of progress. Velocity is normalized by the gesture host's
|
||||||
|
* size, so `1` means one full host width (or height) per second.
|
||||||
*
|
*
|
||||||
* The scene currently commits any release whose velocity is at least `0.3`,
|
* @defaultValue `0.2`
|
||||||
* regardless of this value. See `FLICK_COMMIT_VELOCITY` in the scene module.
|
|
||||||
*/
|
*/
|
||||||
readonly commitVelocity?: number;
|
readonly commitVelocity?: number;
|
||||||
/**
|
/**
|
||||||
@@ -136,6 +142,27 @@ export interface OriginChoreography {
|
|||||||
* @defaultValue `false`
|
* @defaultValue `false`
|
||||||
*/
|
*/
|
||||||
readonly persistAtRest?: boolean;
|
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({
|
export const slideLeft = defineOriginChoreography({
|
||||||
name: "slide-left",
|
name: "slide-left",
|
||||||
commitThreshold: 0.36,
|
|
||||||
commitVelocity: 0.6,
|
|
||||||
effects: ({ progress }) => ({
|
effects: ({ progress }) => ({
|
||||||
source: {
|
source: {
|
||||||
transform: `translate3d(${percent(progress * -0.24)}, 0, 0) scale(${1 - progress * 0.025})`,
|
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({
|
export const slideRight = defineOriginChoreography({
|
||||||
name: "slide-right",
|
name: "slide-right",
|
||||||
commitThreshold: 0.36,
|
|
||||||
commitVelocity: 0.6,
|
|
||||||
effects: ({ progress }) => ({
|
effects: ({ progress }) => ({
|
||||||
source: {
|
source: {
|
||||||
transform: `translate3d(${percent(progress)}, 0, 0)`,
|
transform: `translate3d(${percent(progress)}, 0, 0)`,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useOriginGesture } from "../gesture/recognizer";
|
|
||||||
import type { OriginGestureProps } from "../gesture/definition";
|
import type { OriginGestureProps } from "../gesture/definition";
|
||||||
|
import { useOriginGesture } from "../gesture/recognizer";
|
||||||
|
|
||||||
defineOptions({ name: "OriginGesture", inheritAttrs: false });
|
defineOptions({ name: "OriginGesture", inheritAttrs: false });
|
||||||
|
|
||||||
@@ -11,29 +11,24 @@ const props = withDefaults(defineProps<OriginGestureProps>(), {
|
|||||||
/*
|
/*
|
||||||
* This convenience component makes the declaration live exactly where the
|
* This convenience component makes the declaration live exactly where the
|
||||||
* developer writes it. `useOriginGesture()` is also public for components that
|
* developer writes it. `useOriginGesture()` is also public for components that
|
||||||
* cannot accept an extra wrapper element. Props are read once, at setup; the
|
* cannot accept an extra wrapper element. A new `gesture` prop applies from
|
||||||
* legacy `action` prop is read lazily so it may change.
|
* the next pointer-down.
|
||||||
*/
|
*/
|
||||||
const gesture = useOriginGesture(
|
const gesture = useOriginGesture(() => props.gesture);
|
||||||
props.gesture ?? {
|
|
||||||
direction: props.direction,
|
|
||||||
edge: props.edge,
|
|
||||||
threshold: props.threshold,
|
|
||||||
action: (context) => props.action?.(context),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<component
|
<component
|
||||||
:is="as"
|
:is="as"
|
||||||
v-bind="$attrs"
|
:ref="gesture.ref"
|
||||||
|
v-bind="{ ...$attrs, ...gesture.listeners }"
|
||||||
class="nvo-gesture"
|
class="nvo-gesture"
|
||||||
:style="gesture.style"
|
:style="gesture.style"
|
||||||
@pointerdown="gesture.onPointerdown"
|
@pointerdown="gesture.onPointerdown"
|
||||||
@pointermove="gesture.onPointermove"
|
@pointermove="gesture.onPointermove"
|
||||||
@pointerup="gesture.onPointerup"
|
@pointerup="gesture.onPointerup"
|
||||||
@pointercancel="gesture.onPointercancel"
|
@pointercancel="gesture.onPointercancel"
|
||||||
|
@lostpointercapture="gesture.onLostpointercapture"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</component>
|
</component>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from "vue";
|
|
||||||
import type { OriginGestureSurfaceProps } from "../gesture/definition";
|
import type { OriginGestureSurfaceProps } from "../gesture/definition";
|
||||||
import { touchActionFor, useOriginGesture } from "../gesture/recognizer";
|
import { useOriginGestures } from "../gesture/recognizer";
|
||||||
|
|
||||||
defineOptions({ name: "OriginGestureSurface", inheritAttrs: false });
|
defineOptions({ name: "OriginGestureSurface", inheritAttrs: false });
|
||||||
|
|
||||||
@@ -14,41 +13,21 @@ const props = withDefaults(defineProps<OriginGestureSurfaceProps>(), {
|
|||||||
* This convenience host only installs them, combines their browser scrolling
|
* This convenience host only installs them, combines their browser scrolling
|
||||||
* requirements, and forwards a pointer sequence to every recognizer.
|
* requirements, and forwards a pointer sequence to every recognizer.
|
||||||
*/
|
*/
|
||||||
const bindings = props.gestures.map((definition) =>
|
const surface = useOriginGestures(() => props.gestures);
|
||||||
useOriginGesture(definition),
|
|
||||||
);
|
|
||||||
|
|
||||||
const surfaceStyle = computed(
|
|
||||||
() =>
|
|
||||||
({
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
touchAction: touchActionFor(
|
|
||||||
props.gestures.map(({ direction }) => direction),
|
|
||||||
),
|
|
||||||
}) as const,
|
|
||||||
);
|
|
||||||
|
|
||||||
const onPointerdown = (event: PointerEvent) =>
|
|
||||||
bindings.forEach((binding) => binding.onPointerdown(event));
|
|
||||||
const onPointermove = (event: PointerEvent) =>
|
|
||||||
bindings.forEach((binding) => binding.onPointermove(event));
|
|
||||||
const onPointerup = (event: PointerEvent) =>
|
|
||||||
bindings.forEach((binding) => binding.onPointerup(event));
|
|
||||||
const onPointercancel = () =>
|
|
||||||
bindings.forEach((binding) => binding.onPointercancel());
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<component
|
<component
|
||||||
:is="as"
|
:is="as"
|
||||||
v-bind="$attrs"
|
:ref="surface.ref"
|
||||||
|
v-bind="{ ...$attrs, ...surface.listeners }"
|
||||||
class="nvo-gesture"
|
class="nvo-gesture"
|
||||||
:style="[$attrs.style, surfaceStyle]"
|
:style="[$attrs.style, surface.style]"
|
||||||
@pointerdown="onPointerdown"
|
@pointerdown="surface.onPointerdown"
|
||||||
@pointermove="onPointermove"
|
@pointermove="surface.onPointermove"
|
||||||
@pointerup="onPointerup"
|
@pointerup="surface.onPointerup"
|
||||||
@pointercancel="onPointercancel"
|
@pointercancel="surface.onPointercancel"
|
||||||
|
@lostpointercapture="surface.onLostpointercapture"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</component>
|
</component>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import type { CSSProperties } from "vue";
|
import type { CSSProperties, MaybeRefOrGetter } from "vue";
|
||||||
import type { OriginChoreography } from "../choreography";
|
import type { OriginChoreography } from "../choreography";
|
||||||
import type { MaybeOriginAction, OriginNavigationIntent } from "../navigation";
|
import type { OriginNavigationIntent } from "../navigation";
|
||||||
import type { OriginContext } from "../scene/types";
|
import type { OriginContext } from "../scene/types";
|
||||||
import type { OriginRect } from "../shared";
|
import type { OriginRect } from "../shared";
|
||||||
|
import { isAtScrollEdge, withinEdge } from "./recognizer";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* What a gesture is: the public gesture types, component props, and the
|
* What a gesture is: the public gesture types, component props, and the
|
||||||
@@ -77,6 +78,15 @@ export type OriginGestureStart =
|
|||||||
/** Recognize pointer-down when application policy returns `true`. */
|
/** Recognize pointer-down when application policy returns `true`. */
|
||||||
readonly kind: "when";
|
readonly kind: "when";
|
||||||
readonly predicate: OriginGestureStartPredicate;
|
readonly predicate: OriginGestureStartPredicate;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* Recognize pointer-down while a scroller is at one edge. The gesture
|
||||||
|
* shares its axis with that scroller's native scrolling.
|
||||||
|
*/
|
||||||
|
readonly kind: "scroll-edge";
|
||||||
|
readonly scroller: OriginGestureScroller;
|
||||||
|
readonly edge: OriginGestureEdge;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Direction-recognition tuning accepted by `.to.left()` and its siblings. */
|
/** Direction-recognition tuning accepted by `.to.left()` and its siblings. */
|
||||||
@@ -93,8 +103,27 @@ export interface OriginGestureDirectionOptions {
|
|||||||
* @defaultValue `1.15`
|
* @defaultValue `1.15`
|
||||||
*/
|
*/
|
||||||
readonly axisDominance?: number;
|
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. */
|
/** Values available when a custom `.complete()` policy runs on pointer-up. */
|
||||||
export interface OriginGestureCompletionContext {
|
export interface OriginGestureCompletionContext {
|
||||||
/** Component instance that originated the gesture. */
|
/** Component instance that originated the gesture. */
|
||||||
@@ -152,7 +181,7 @@ export type OriginGestureNavigationFactory = (
|
|||||||
* represented as an `anywhere` start rule.
|
* represented as an `anywhere` start rule.
|
||||||
*/
|
*/
|
||||||
export interface OriginGestureDefinition {
|
export interface OriginGestureDefinition {
|
||||||
/** Discriminator used by the compatibility recognizer overload. */
|
/** Identifies a definition produced by the {@link gesture} builder. */
|
||||||
readonly kind: "origin-gesture-definition";
|
readonly kind: "origin-gesture-definition";
|
||||||
/** Pointer-down eligibility policy. */
|
/** Pointer-down eligibility policy. */
|
||||||
readonly start: OriginGestureStart;
|
readonly start: OriginGestureStart;
|
||||||
@@ -170,6 +199,10 @@ export interface OriginGestureDefinition {
|
|||||||
readonly choreography: OriginChoreography;
|
readonly choreography: OriginChoreography;
|
||||||
/** Handlers run, in order, when a release commits. See `.onCommit()`. */
|
/** Handlers run, in order, when a release commits. See `.onCommit()`. */
|
||||||
readonly commitHandlers?: readonly OriginGestureCommitHandler[];
|
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 +249,24 @@ export interface OriginGestureFromBuilder {
|
|||||||
anywhere(): OriginGestureFromSelection;
|
anywhere(): OriginGestureFromSelection;
|
||||||
/** Admit pointer-down when a synchronous application predicate succeeds. */
|
/** Admit pointer-down when a synchronous application predicate succeeds. */
|
||||||
when(predicate: OriginGestureStartPredicate): OriginGestureFromSelection;
|
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. */
|
/** Builder stage after direction is known and completion remains optional. */
|
||||||
export interface OriginGestureDirectedBuilder {
|
export interface OriginGestureDirectedBuilder {
|
||||||
/** Override the choreography's default release decision. */
|
/** Override the choreography's default release decision. */
|
||||||
@@ -230,6 +279,13 @@ export interface OriginGestureDirectedBuilder {
|
|||||||
): OriginGestureNavigationBuilder;
|
): OriginGestureNavigationBuilder;
|
||||||
/** Run `handler` when a release commits. Handlers accumulate in order. */
|
/** Run `handler` when a release commits. Handlers accumulate in order. */
|
||||||
onCommit(handler: OriginGestureCommitHandler): OriginGestureDirectedBuilder;
|
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. */
|
/** Builder stage after a custom completion policy has been selected. */
|
||||||
@@ -240,6 +296,13 @@ export interface OriginGestureCompletedBuilder {
|
|||||||
): OriginGestureNavigationBuilder;
|
): OriginGestureNavigationBuilder;
|
||||||
/** Run `handler` when a release commits. Handlers accumulate in order. */
|
/** Run `handler` when a release commits. Handlers accumulate in order. */
|
||||||
onCommit(handler: OriginGestureCommitHandler): OriginGestureCompletedBuilder;
|
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. */
|
/** Final builder stage waiting for visual choreography. */
|
||||||
@@ -248,6 +311,13 @@ export interface OriginGestureNavigationBuilder {
|
|||||||
animate(choreography: OriginChoreography): OriginGestureDefinition;
|
animate(choreography: OriginChoreography): OriginGestureDefinition;
|
||||||
/** Run `handler` when a release commits. Handlers accumulate in order. */
|
/** Run `handler` when a release commits. Handlers accumulate in order. */
|
||||||
onCommit(handler: OriginGestureCommitHandler): OriginGestureNavigationBuilder;
|
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. */
|
/** Root of the immutable fluent gesture-definition API. */
|
||||||
@@ -260,47 +330,19 @@ export interface OriginGestureBuilder {
|
|||||||
readonly to: OriginGestureToBuilder;
|
readonly to: OriginGestureToBuilder;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Configuration consumed by {@link useOriginGesture}. */
|
|
||||||
export interface OriginGestureOptions {
|
|
||||||
/**
|
|
||||||
* Direction in which the pointer moves to advance the operation.
|
|
||||||
*
|
|
||||||
* The starting edge is the opposite side: `right` begins at the left edge,
|
|
||||||
* `left` at the right edge, `down` at the top, and `up` at the bottom.
|
|
||||||
*/
|
|
||||||
direction: OriginGestureDirection;
|
|
||||||
/**
|
|
||||||
* Restrict pointer-down to this many CSS pixels from the gesture host's
|
|
||||||
* starting edge. Omit it to recognize across the entire host.
|
|
||||||
*
|
|
||||||
* This is relative to the bound element, not necessarily the browser
|
|
||||||
* viewport. Use a positive number such as `24` or `36`.
|
|
||||||
*/
|
|
||||||
edge?: number;
|
|
||||||
/**
|
|
||||||
* Minimum directed movement in CSS pixels before the gesture captures.
|
|
||||||
*
|
|
||||||
* Movement must also dominate the cross-axis by a factor of `1.15`.
|
|
||||||
*
|
|
||||||
* @defaultValue `8`
|
|
||||||
*/
|
|
||||||
threshold?: number;
|
|
||||||
/**
|
|
||||||
* Resolve the scene action after directional recognition succeeds.
|
|
||||||
*
|
|
||||||
* Returning no action abandons recognition without modifying the scene.
|
|
||||||
*/
|
|
||||||
action(context: OriginContext): MaybeOriginAction;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DOM bindings returned by {@link useOriginGesture}.
|
* DOM bindings returned by {@link useOriginGesture}.
|
||||||
*
|
*
|
||||||
* Spread or attach all four handlers to the same `HTMLElement`. Apply
|
* Attach the pointer handlers to the same `HTMLElement` (and `onTouchmove`
|
||||||
* {@link style} as well so native scrolling is preserved on the cross-axis.
|
* when {@link needsTouchmove} is true), and apply
|
||||||
|
* {@link style}: its `touch-action` keeps native scrolling on every axis no
|
||||||
|
* gesture claims.
|
||||||
*/
|
*/
|
||||||
export interface OriginGestureBinding {
|
export interface OriginGestureBinding {
|
||||||
/** Required size and `touch-action` styles for the gesture host. */
|
/**
|
||||||
|
* Required size and `touch-action` styles for the gesture host. Reading it
|
||||||
|
* inside a render function tracks changes to the gesture definitions.
|
||||||
|
*/
|
||||||
readonly style: Readonly<CSSProperties>;
|
readonly style: Readonly<CSSProperties>;
|
||||||
/** Pointer-down handler that records a potentially eligible gesture. */
|
/** Pointer-down handler that records a potentially eligible gesture. */
|
||||||
readonly onPointerdown: (event: PointerEvent) => void;
|
readonly onPointerdown: (event: PointerEvent) => void;
|
||||||
@@ -310,64 +352,54 @@ export interface OriginGestureBinding {
|
|||||||
readonly onPointerup: (event: PointerEvent) => void;
|
readonly onPointerup: (event: PointerEvent) => void;
|
||||||
/** Pointer-cancel handler that abandons any captured operation. */
|
/** Pointer-cancel handler that abandons any captured operation. */
|
||||||
readonly onPointercancel: () => void;
|
readonly onPointercancel: () => void;
|
||||||
|
/**
|
||||||
|
* Lost-capture handler. Cancels a captured gesture whose pointer capture
|
||||||
|
* ends without a pointer-up, so the operation never stays stuck mid-way.
|
||||||
|
*/
|
||||||
|
readonly onLostpointercapture: (event: PointerEvent) => void;
|
||||||
|
/**
|
||||||
|
* Touch-move handler for gestures that share an axis with native scrolling
|
||||||
|
* (`.from.scrollEdge()`): it cancels scrolling for a touch such a gesture
|
||||||
|
* claims, and leaves every other touch to the browser. Attach it as a
|
||||||
|
* non-passive listener (Vue's `@touchmove` is), and only when
|
||||||
|
* {@link needsTouchmove} is true.
|
||||||
|
*/
|
||||||
|
readonly onTouchmove: (event: TouchEvent) => void;
|
||||||
|
/**
|
||||||
|
* Whether any gesture shares an axis with scrolling, so `onTouchmove` must
|
||||||
|
* be attached. Leave it off otherwise: a non-passive touch listener makes
|
||||||
|
* the browser wait for JavaScript on every touch move, which costs frames.
|
||||||
|
*/
|
||||||
|
readonly needsTouchmove: boolean;
|
||||||
|
/**
|
||||||
|
* Extra listeners the host needs, as Vue props: `v-bind` them on the host.
|
||||||
|
* Scroll-edge gestures track their scroller here (a passive scroll
|
||||||
|
* listener), plus `onTouchmove` where the browser lacks directional
|
||||||
|
* `touch-action`. Empty for ordinary gestures.
|
||||||
|
*/
|
||||||
|
readonly listeners: Readonly<Record<string, (event: Event) => void>>;
|
||||||
|
/** Function ref for the host element: `:ref="binding.ref"`. */
|
||||||
|
readonly ref: (element: unknown) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Component props ----------------------------------------------------------
|
// Component props ----------------------------------------------------------
|
||||||
|
|
||||||
/** Props shared by both `OriginGesture` declaration styles. */
|
/** Public props accepted by the `OriginGesture` convenience component. */
|
||||||
export interface OriginGestureBaseProps {
|
export interface OriginGestureProps {
|
||||||
/**
|
/**
|
||||||
* Native HTML tag rendered as the gesture host.
|
* Native HTML tag rendered as the gesture host.
|
||||||
*
|
*
|
||||||
* @defaultValue `"div"`
|
* @defaultValue `"div"`
|
||||||
*/
|
*/
|
||||||
as?: string;
|
as?: string;
|
||||||
}
|
/**
|
||||||
|
* Immutable definition produced by the {@link gesture} builder. Replacing
|
||||||
/** Builder-definition props accepted by the `OriginGesture` component. */
|
* it takes effect from the next pointer-down; a gesture in progress keeps
|
||||||
export interface OriginGestureDefinitionProps extends OriginGestureBaseProps {
|
* the definition it started with.
|
||||||
/** Immutable definition produced by the {@link gesture} builder. */
|
*/
|
||||||
gesture: OriginGestureDefinition;
|
gesture: OriginGestureDefinition;
|
||||||
/** Builder definitions already contain direction. */
|
|
||||||
direction?: never;
|
|
||||||
/** Builder definitions already contain their start policy. */
|
|
||||||
edge?: never;
|
|
||||||
/** Builder definitions already contain recognition tuning. */
|
|
||||||
threshold?: never;
|
|
||||||
/** Builder definitions already contain their navigation factory. */
|
|
||||||
action?: never;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Legacy option props accepted by the `OriginGesture` component. */
|
|
||||||
export interface OriginGestureLegacyProps extends OriginGestureBaseProps {
|
|
||||||
/** Legacy component declarations do not provide a builder definition. */
|
|
||||||
gesture?: never;
|
|
||||||
/** Direction in which pointer movement advances operation progress. */
|
|
||||||
direction: OriginGestureDirection;
|
|
||||||
/**
|
|
||||||
* Eligible pointer-down width in CSS pixels from the host's starting edge.
|
|
||||||
* Omit it to allow the full component surface.
|
|
||||||
*/
|
|
||||||
edge?: number;
|
|
||||||
/**
|
|
||||||
* Directed movement required before capture.
|
|
||||||
*
|
|
||||||
* @defaultValue `8`
|
|
||||||
*/
|
|
||||||
threshold?: number;
|
|
||||||
/** Node-local action factory invoked only after recognition succeeds. */
|
|
||||||
action(context: OriginContext): MaybeOriginAction;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Public props accepted by the `OriginGesture` convenience component.
|
|
||||||
*
|
|
||||||
* Prefer the builder-definition form. The legacy direction/action form remains
|
|
||||||
* available for compatibility.
|
|
||||||
*/
|
|
||||||
export type OriginGestureProps =
|
|
||||||
OriginGestureDefinitionProps | OriginGestureLegacyProps;
|
|
||||||
|
|
||||||
/** Public props accepted by the multi-definition gesture surface component. */
|
/** Public props accepted by the multi-definition gesture surface component. */
|
||||||
export interface OriginGestureSurfaceProps {
|
export interface OriginGestureSurfaceProps {
|
||||||
/**
|
/**
|
||||||
@@ -380,7 +412,9 @@ export interface OriginGestureSurfaceProps {
|
|||||||
* Complete immutable definitions installed on the shared host.
|
* Complete immutable definitions installed on the shared host.
|
||||||
*
|
*
|
||||||
* The surface adds no start, direction, completion, navigation, or animation
|
* The surface adds no start, direction, completion, navigation, or animation
|
||||||
* policy. Definitions should be created by the owning page component.
|
* policy. Definitions should be created by the owning page component. The
|
||||||
|
* list may change; a gesture in progress keeps the definition it started
|
||||||
|
* with.
|
||||||
*/
|
*/
|
||||||
gestures: readonly OriginGestureDefinition[];
|
gestures: readonly OriginGestureDefinition[];
|
||||||
}
|
}
|
||||||
@@ -403,16 +437,33 @@ interface GestureDraft {
|
|||||||
readonly recognition: Readonly<OriginGestureDirectionOptions>;
|
readonly recognition: Readonly<OriginGestureDirectionOptions>;
|
||||||
readonly completion?: OriginGestureCompletionPredicate;
|
readonly completion?: OriginGestureCompletionPredicate;
|
||||||
readonly commitHandlers: readonly OriginGestureCommitHandler[];
|
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,
|
draft: Draft,
|
||||||
handler: OriginGestureCommitHandler,
|
stage: (draft: Draft) => Stage,
|
||||||
): Draft =>
|
) {
|
||||||
|
const append = (list: HandlerList) => (handler: OriginGestureCommitHandler) =>
|
||||||
|
stage(
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
...draft,
|
...draft,
|
||||||
commitHandlers: Object.freeze([...draft.commitHandlers, handler]),
|
[list]: Object.freeze([...draft[list], handler]),
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
onCommit: append("commitHandlers"),
|
||||||
|
onArm: append("armHandlers"),
|
||||||
|
onDisarm: append("disarmHandlers"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function navigationStage(
|
function navigationStage(
|
||||||
draft: GestureDraft & { readonly navigation: OriginGestureNavigationFactory },
|
draft: GestureDraft & { readonly navigation: OriginGestureNavigationFactory },
|
||||||
@@ -428,9 +479,10 @@ function navigationStage(
|
|||||||
navigation: draft.navigation,
|
navigation: draft.navigation,
|
||||||
choreography,
|
choreography,
|
||||||
commitHandlers: draft.commitHandlers,
|
commitHandlers: draft.commitHandlers,
|
||||||
|
armHandlers: draft.armHandlers,
|
||||||
|
disarmHandlers: draft.disarmHandlers,
|
||||||
}),
|
}),
|
||||||
onCommit: (handler: OriginGestureCommitHandler) =>
|
...hookMethods(draft, navigationStage),
|
||||||
navigationStage(withCommitHandler(draft, handler)),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -441,8 +493,7 @@ const navigateFrom =
|
|||||||
function completedStage(draft: GestureDraft): OriginGestureCompletedBuilder {
|
function completedStage(draft: GestureDraft): OriginGestureCompletedBuilder {
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
navigate: navigateFrom(draft),
|
navigate: navigateFrom(draft),
|
||||||
onCommit: (handler: OriginGestureCommitHandler) =>
|
...hookMethods(draft, completedStage),
|
||||||
completedStage(withCommitHandler(draft, handler)),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -451,8 +502,7 @@ function directedStage(draft: GestureDraft): OriginGestureDirectedBuilder {
|
|||||||
complete: (completion: OriginGestureCompletionPredicate) =>
|
complete: (completion: OriginGestureCompletionPredicate) =>
|
||||||
completedStage(Object.freeze({ ...draft, completion })),
|
completedStage(Object.freeze({ ...draft, completion })),
|
||||||
navigate: navigateFrom(draft),
|
navigate: navigateFrom(draft),
|
||||||
onCommit: (handler: OriginGestureCommitHandler) =>
|
...hookMethods(draft, directedStage),
|
||||||
directedStage(withCommitHandler(draft, handler)),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -466,6 +516,8 @@ function toStage(start: OriginGestureStart): OriginGestureToBuilder {
|
|||||||
direction,
|
direction,
|
||||||
recognition: Object.freeze({ ...options }),
|
recognition: Object.freeze({ ...options }),
|
||||||
commitHandlers: Object.freeze([]),
|
commitHandlers: Object.freeze([]),
|
||||||
|
armHandlers: Object.freeze([]),
|
||||||
|
disarmHandlers: Object.freeze([]),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
@@ -492,8 +544,60 @@ const fromBuilder: OriginGestureFromBuilder = Object.freeze({
|
|||||||
anywhere: () => fromStage({ kind: "anywhere" }),
|
anywhere: () => fromStage({ kind: "anywhere" }),
|
||||||
when: (predicate: OriginGestureStartPredicate) =>
|
when: (predicate: OriginGestureStartPredicate) =>
|
||||||
fromStage({ kind: "when", predicate }),
|
fromStage({ kind: "when", predicate }),
|
||||||
|
scrollEdge: (scroller: OriginGestureScroller, edge: OriginGestureEdge) =>
|
||||||
|
fromStage({ kind: "scroll-edge", 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 }) =>
|
||||||
|
isAtScrollEdge(scroller, edge, host);
|
||||||
|
|
||||||
|
/** 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.
|
* Root of the immutable gesture builder.
|
||||||
*
|
*
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,7 @@ export type {
|
|||||||
OriginEffectSet,
|
OriginEffectSet,
|
||||||
OriginOperationIntent,
|
OriginOperationIntent,
|
||||||
OriginOperationPhase,
|
OriginOperationPhase,
|
||||||
|
OriginSpring,
|
||||||
} from "./choreography";
|
} from "./choreography";
|
||||||
export {
|
export {
|
||||||
defineOriginChoreography,
|
defineOriginChoreography,
|
||||||
@@ -50,6 +51,7 @@ export {
|
|||||||
|
|
||||||
export type * from "./scene/types";
|
export type * from "./scene/types";
|
||||||
export type { OriginNodeScope, UseOrigin } from "./scene/scene";
|
export type { OriginNodeScope, UseOrigin } from "./scene/scene";
|
||||||
|
export { OriginSceneError, type OriginSceneErrorCode } from "./scene/graph";
|
||||||
export {
|
export {
|
||||||
createOriginScene,
|
createOriginScene,
|
||||||
originNodeScopeKey,
|
originNodeScopeKey,
|
||||||
@@ -57,7 +59,14 @@ export {
|
|||||||
} from "./scene/scene";
|
} from "./scene/scene";
|
||||||
|
|
||||||
export type * from "./gesture/definition";
|
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";
|
export { useOriginGesture } from "./gesture/recognizer";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -147,16 +147,6 @@ export interface OriginAction {
|
|||||||
readonly history?: OriginHistoryMode;
|
readonly history?: OriginHistoryMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Values accepted from gesture action factories.
|
|
||||||
*
|
|
||||||
* Returning `null` or `undefined` declines the recognized gesture. A promise
|
|
||||||
* allows lazy target resolution; stale results are discarded after release or
|
|
||||||
* cancellation.
|
|
||||||
*/
|
|
||||||
export type MaybeOriginAction =
|
|
||||||
OriginAction | null | undefined | Promise<OriginAction | null | undefined>;
|
|
||||||
|
|
||||||
/** Options accepted by {@link originAction}. */
|
/** Options accepted by {@link originAction}. */
|
||||||
export interface OriginActionOptions {
|
export interface OriginActionOptions {
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -24,30 +24,71 @@ export interface SceneNodeRecord {
|
|||||||
incomingOperationId?: number;
|
incomingOperationId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why a scene operation was rejected.
|
||||||
|
*
|
||||||
|
* - `missing-node`: the node no longer exists.
|
||||||
|
* - `missing-origin`: the origin node does not exist.
|
||||||
|
* - `inert-origin`: the origin is parked or exposed and cannot originate.
|
||||||
|
* - `origin-busy`: the origin already has a live outgoing operation.
|
||||||
|
* - `invalid-action`: the action cannot be executed as written.
|
||||||
|
* - `no-previous-entry`: back was requested without a retained entry.
|
||||||
|
* - `undecided-forward`: back was requested through an unreleased forward.
|
||||||
|
* - `target-busy`: the retained back target is transitioning, or still
|
||||||
|
* positions the origin.
|
||||||
|
* - `outside-scene`: `useOrigin()` was called outside an `<OriginScene>` view.
|
||||||
|
*/
|
||||||
|
export type OriginSceneErrorCode =
|
||||||
|
| "missing-node"
|
||||||
|
| "missing-origin"
|
||||||
|
| "inert-origin"
|
||||||
|
| "origin-busy"
|
||||||
|
| "invalid-action"
|
||||||
|
| "no-previous-entry"
|
||||||
|
| "undecided-forward"
|
||||||
|
| "target-busy"
|
||||||
|
| "outside-scene";
|
||||||
|
|
||||||
|
/** Error thrown or rejected by scene operations, with a stable `code`. */
|
||||||
|
export class OriginSceneError extends Error {
|
||||||
|
override readonly name = "OriginSceneError";
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
readonly code: OriginSceneErrorCode,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Progress at which a release commits when the choreography does not supply
|
* Progress at which a release commits when the choreography does not supply
|
||||||
* its own `commitThreshold`.
|
* 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
|
* Release velocity (progress units per second) that commits a flick at any
|
||||||
* progress. Choreographies' `commitVelocity` is not currently consulted.
|
* progress when the choreography does not supply its own `commitVelocity`.
|
||||||
*/
|
*/
|
||||||
export const FLICK_COMMIT_VELOCITY = 0.3;
|
export const DEFAULT_COMMIT_VELOCITY = 0.2;
|
||||||
|
|
||||||
/** Decide a release outcome from explicit options or progress/velocity. */
|
/**
|
||||||
|
* Decide a release outcome from explicit options, or from velocity and
|
||||||
|
* progress: a flick at least `commitVelocity` fast decides by its direction,
|
||||||
|
* forward committing and backward cancelling, regardless of distance. Slower
|
||||||
|
* releases commit once progress reaches `commitThreshold`.
|
||||||
|
*/
|
||||||
export const shouldCommit = (
|
export const shouldCommit = (
|
||||||
operation: MutableOriginOperation,
|
operation: MutableOriginOperation,
|
||||||
options: OriginFinishOptions,
|
options: OriginFinishOptions,
|
||||||
): boolean => {
|
): boolean => {
|
||||||
if (options.commit !== undefined) return options.commit;
|
if (options.commit !== undefined) return options.commit;
|
||||||
const threshold =
|
const { commitThreshold, commitVelocity } = operation.choreography;
|
||||||
operation.choreography.commitThreshold ?? DEFAULT_COMMIT_THRESHOLD;
|
const flick = commitVelocity ?? DEFAULT_COMMIT_VELOCITY;
|
||||||
return (
|
if (operation.velocity >= flick) return true;
|
||||||
operation.progress >= threshold ||
|
if (operation.velocity <= -flick) return false;
|
||||||
(operation.progress >= 0 && operation.velocity >= FLICK_COMMIT_VELOCITY)
|
return operation.progress >= (commitThreshold ?? DEFAULT_COMMIT_THRESHOLD);
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -76,6 +117,14 @@ export class SceneGraph {
|
|||||||
* that back operation's id. Cancelling the back operation restores the edge.
|
* that back operation's id. Cancelling the back operation restores the edge.
|
||||||
*/
|
*/
|
||||||
private readonly suspendedEdges = new Map<number, MutableOriginOperation>();
|
private readonly suspendedEdges = new Map<number, MutableOriginOperation>();
|
||||||
|
/**
|
||||||
|
* Operations indexed by origin key, in insertion order. Every addition and
|
||||||
|
* removal goes through {@link attach} and {@link detach} to keep it in sync
|
||||||
|
* with `operations`.
|
||||||
|
*/
|
||||||
|
private readonly outgoing = shallowReactive(
|
||||||
|
new Map<string, readonly MutableOriginOperation[]>(),
|
||||||
|
);
|
||||||
private nodeSequence = 0;
|
private nodeSequence = 0;
|
||||||
private operationSequence = 0;
|
private operationSequence = 0;
|
||||||
|
|
||||||
@@ -117,10 +166,9 @@ export class SceneGraph {
|
|||||||
|
|
||||||
/** The unfinished operation originated by a node, if any. */
|
/** The unfinished operation originated by a node, if any. */
|
||||||
liveOutgoingOf(nodeKey: string): MutableOriginOperation | undefined {
|
liveOutgoingOf(nodeKey: string): MutableOriginOperation | undefined {
|
||||||
for (const operation of this.operations.values())
|
return this.outgoing
|
||||||
if (operation.originKey === nodeKey && operation.phase !== "finished")
|
.get(nodeKey)
|
||||||
return operation;
|
?.find((operation) => operation.phase !== "finished");
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -128,9 +176,7 @@ export class SceneGraph {
|
|||||||
* {@link liveOutgoingOf}, this includes a finished, connected resting edge.
|
* {@link liveOutgoingOf}, this includes a finished, connected resting edge.
|
||||||
*/
|
*/
|
||||||
presentedOutgoingOf(nodeKey: string): MutableOriginOperation | undefined {
|
presentedOutgoingOf(nodeKey: string): MutableOriginOperation | undefined {
|
||||||
for (const operation of this.operations.values())
|
return this.outgoing.get(nodeKey)?.[0];
|
||||||
if (operation.originKey === nodeKey) return operation;
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Retained entries preceding `node`, oldest first. */
|
/** Retained entries preceding `node`, oldest first. */
|
||||||
@@ -151,7 +197,10 @@ export class SceneGraph {
|
|||||||
contextFor(nodeKey: string): OriginContext {
|
contextFor(nodeKey: string): OriginContext {
|
||||||
const node = this.nodes.get(nodeKey);
|
const node = this.nodes.get(nodeKey);
|
||||||
if (!node)
|
if (!node)
|
||||||
throw new Error(`Origin scene node "${nodeKey}" no longer exists.`);
|
throw new OriginSceneError(
|
||||||
|
"missing-node",
|
||||||
|
`Origin scene node "${nodeKey}" no longer exists.`,
|
||||||
|
);
|
||||||
const history = this.historyOf(node).map((entry) => entry.view);
|
const history = this.historyOf(node).map((entry) => entry.view);
|
||||||
return {
|
return {
|
||||||
nodeKey,
|
nodeKey,
|
||||||
@@ -194,7 +243,8 @@ export class SceneGraph {
|
|||||||
const origin = this.assertCanOriginate(originKey);
|
const origin = this.assertCanOriginate(originKey);
|
||||||
const history = action.history ?? "push";
|
const history = action.history ?? "push";
|
||||||
if (action.choreography.persistAtRest && history !== "push")
|
if (action.choreography.persistAtRest && history !== "push")
|
||||||
throw new Error(
|
throw new OriginSceneError(
|
||||||
|
"invalid-action",
|
||||||
"persistAtRest choreographies require retained-history push navigation.",
|
"persistAtRest choreographies require retained-history push navigation.",
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -224,6 +274,7 @@ export class SceneGraph {
|
|||||||
const operation = this.operations.get(id);
|
const operation = this.operations.get(id);
|
||||||
if (!operation || operation.phase === "finished") return;
|
if (!operation || operation.phase === "finished") return;
|
||||||
operation.progress = clamp01(progress);
|
operation.progress = clamp01(progress);
|
||||||
|
operation.overshoot = progress - operation.progress;
|
||||||
operation.velocity = velocity;
|
operation.velocity = velocity;
|
||||||
if (operation.phase !== "preparing") operation.phase = "interactive";
|
if (operation.phase !== "preparing") operation.phase = "interactive";
|
||||||
}
|
}
|
||||||
@@ -247,6 +298,7 @@ export class SceneGraph {
|
|||||||
|
|
||||||
if (operation.history === "push" && operation.choreography.persistAtRest) {
|
if (operation.history === "push" && operation.choreography.persistAtRest) {
|
||||||
operation.progress = 1;
|
operation.progress = 1;
|
||||||
|
operation.overshoot = 0;
|
||||||
operation.velocity = 0;
|
operation.velocity = 0;
|
||||||
operation.intent = "commit";
|
operation.intent = "commit";
|
||||||
operation.phase = "finished";
|
operation.phase = "finished";
|
||||||
@@ -257,7 +309,7 @@ export class SceneGraph {
|
|||||||
|
|
||||||
this.transferPosition(origin, target);
|
this.transferPosition(origin, target);
|
||||||
operation.phase = "finished";
|
operation.phase = "finished";
|
||||||
this.operations.delete(operation.id);
|
this.detach(operation);
|
||||||
this.suspendedEdges.delete(operation.id);
|
this.suspendedEdges.delete(operation.id);
|
||||||
|
|
||||||
if (operation.history === "push") {
|
if (operation.history === "push") {
|
||||||
@@ -265,7 +317,11 @@ export class SceneGraph {
|
|||||||
origin.state = "parked";
|
origin.state = "parked";
|
||||||
} else {
|
} else {
|
||||||
// Back pops the current entry; replace discards it in favor of the new
|
// Back pops the current entry; replace discards it in favor of the new
|
||||||
// target. Both preserve every earlier retained instance.
|
// target. Both preserve every earlier retained instance, and any entry
|
||||||
|
// still retained above the origin now sits on the target instead.
|
||||||
|
for (const node of this.nodes.values())
|
||||||
|
if (node.previousNodeKey === origin.key && node !== target)
|
||||||
|
node.previousNodeKey = target.key;
|
||||||
this.removeNode(origin.key);
|
this.removeNode(origin.key);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,33 +332,37 @@ export class SceneGraph {
|
|||||||
/**
|
/**
|
||||||
* Finalize a cancelled operation, restoring the origin.
|
* Finalize a cancelled operation, restoring the origin.
|
||||||
*
|
*
|
||||||
* A cancelled back re-parks its retained target, or re-attaches the resting
|
* Everything the edge created or now positions is undone. Entries from the
|
||||||
* edge it suspended. A cancelled push/replace removes its new target branch.
|
* origin's retained history (a back target, or a replacement of one) return
|
||||||
|
* to history; anything newer is discarded. A cancelled back that suspended
|
||||||
|
* a connected resting edge reconnects it instead.
|
||||||
*/
|
*/
|
||||||
cancel(id: number): void {
|
cancel(id: number): void {
|
||||||
const operation = this.operations.get(id);
|
const operation = this.operations.get(id);
|
||||||
if (!operation) return;
|
if (!operation) return;
|
||||||
const origin = this.nodes.get(operation.originKey);
|
const origin = this.nodes.get(operation.originKey);
|
||||||
const target = this.nodes.get(operation.entryTargetKey);
|
const entry = this.nodes.get(operation.entryTargetKey);
|
||||||
|
const current = this.nodes.get(operation.targetKey);
|
||||||
const suspendedEdge = this.suspendedEdges.get(id);
|
const suspendedEdge = this.suspendedEdges.get(id);
|
||||||
|
const retained = origin ? this.retainedKeys(origin) : new Set<string>();
|
||||||
operation.phase = "finished";
|
operation.phase = "finished";
|
||||||
this.operations.delete(id);
|
this.detach(operation);
|
||||||
this.suspendedEdges.delete(id);
|
this.suspendedEdges.delete(id);
|
||||||
|
|
||||||
if (operation.history === "back") {
|
if (suspendedEdge && origin && entry && current === entry) {
|
||||||
if (suspendedEdge && origin && target) {
|
|
||||||
// Put the retained page back into its former position, then reconnect
|
// Put the retained page back into its former position, then reconnect
|
||||||
// the resting page→drawer edge. Both nodes keep their VNodes.
|
// the resting page→drawer edge. Both nodes keep their VNodes.
|
||||||
this.transferPosition(origin, target);
|
this.discardOutgoing(entry.key, new Set(retained));
|
||||||
this.operations.set(suspendedEdge.id, suspendedEdge);
|
this.transferPosition(origin, entry);
|
||||||
|
this.attach(suspendedEdge);
|
||||||
origin.incomingOperationId = suspendedEdge.id;
|
origin.incomingOperationId = suspendedEdge.id;
|
||||||
target.state = "exposed";
|
entry.state = "exposed";
|
||||||
} else if (target) {
|
|
||||||
target.incomingOperationId = undefined;
|
|
||||||
target.state = "parked";
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
this.removeBranch(operation.entryTargetKey);
|
for (const node of new Set([entry, current])) {
|
||||||
|
if (!node || node === origin) continue;
|
||||||
|
if (retained.has(node.key)) this.returnToHistory(node, retained);
|
||||||
|
else this.removeBranch(node.key, new Set(retained));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (origin) this.activate(origin);
|
if (origin) this.activate(origin);
|
||||||
@@ -313,13 +373,18 @@ export class SceneGraph {
|
|||||||
private assertCanOriginate(originKey: string): SceneNodeRecord {
|
private assertCanOriginate(originKey: string): SceneNodeRecord {
|
||||||
const origin = this.nodes.get(originKey);
|
const origin = this.nodes.get(originKey);
|
||||||
if (!origin)
|
if (!origin)
|
||||||
throw new Error(`Cannot animate from missing origin "${originKey}".`);
|
throw new OriginSceneError(
|
||||||
|
"missing-origin",
|
||||||
|
`Cannot animate from missing origin "${originKey}".`,
|
||||||
|
);
|
||||||
if (origin.state === "parked" || origin.state === "exposed")
|
if (origin.state === "parked" || origin.state === "exposed")
|
||||||
throw new Error(
|
throw new OriginSceneError(
|
||||||
|
"inert-origin",
|
||||||
`Cannot animate from ${origin.state} origin "${originKey}".`,
|
`Cannot animate from ${origin.state} origin "${originKey}".`,
|
||||||
);
|
);
|
||||||
if (this.liveOutgoingOf(originKey))
|
if (this.liveOutgoingOf(originKey))
|
||||||
throw new Error(
|
throw new OriginSceneError(
|
||||||
|
"origin-busy",
|
||||||
`Origin "${originKey}" already has an outgoing operation. ` +
|
`Origin "${originKey}" already has an outgoing operation. ` +
|
||||||
"A descendant target may start its own operation instead.",
|
"A descendant target may start its own operation instead.",
|
||||||
);
|
);
|
||||||
@@ -341,12 +406,14 @@ export class SceneGraph {
|
|||||||
} {
|
} {
|
||||||
const previousKey = origin.previousNodeKey;
|
const previousKey = origin.previousNodeKey;
|
||||||
if (!previousKey)
|
if (!previousKey)
|
||||||
throw new Error(
|
throw new OriginSceneError(
|
||||||
|
"no-previous-entry",
|
||||||
`Cannot go back from origin "${origin.key}" without a previous entry.`,
|
`Cannot go back from origin "${origin.key}" without a previous entry.`,
|
||||||
);
|
);
|
||||||
const target = this.nodes.get(previousKey);
|
const target = this.nodes.get(previousKey);
|
||||||
if (!target)
|
if (!target)
|
||||||
throw new Error(
|
throw new OriginSceneError(
|
||||||
|
"no-previous-entry",
|
||||||
`Cannot go back to missing retained entry "${previousKey}".`,
|
`Cannot go back to missing retained entry "${previousKey}".`,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -354,7 +421,8 @@ export class SceneGraph {
|
|||||||
const incoming = this.incomingOf(origin);
|
const incoming = this.incomingOf(origin);
|
||||||
if (incoming?.originKey === target.key) {
|
if (incoming?.originKey === target.key) {
|
||||||
if (incoming.intent !== "commit")
|
if (incoming.intent !== "commit")
|
||||||
throw new Error(
|
throw new OriginSceneError(
|
||||||
|
"undecided-forward",
|
||||||
"Cannot go back through an undecided forward operation.",
|
"Cannot go back through an undecided forward operation.",
|
||||||
);
|
);
|
||||||
if (incoming.phase !== "finished") this.commit(incoming.id);
|
if (incoming.phase !== "finished") this.commit(incoming.id);
|
||||||
@@ -362,19 +430,32 @@ export class SceneGraph {
|
|||||||
const resting = this.operations.get(incoming.id);
|
const resting = this.operations.get(incoming.id);
|
||||||
if (resting?.phase === "finished" && resting.choreography.persistAtRest) {
|
if (resting?.phase === "finished" && resting.choreography.persistAtRest) {
|
||||||
// Re-root A→page→drawer as A→drawer before adding drawer→page.
|
// Re-root A→page→drawer as A→drawer before adding drawer→page.
|
||||||
this.operations.delete(resting.id);
|
this.detach(resting);
|
||||||
this.transferPosition(target, origin);
|
this.transferPosition(target, origin);
|
||||||
target.incomingOperationId = undefined;
|
target.incomingOperationId = undefined;
|
||||||
suspendedEdge = resting;
|
suspendedEdge = resting;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A target still positioning the origin (through edges other than the
|
||||||
|
// one collapsed above) would close a visual cycle.
|
||||||
|
if (
|
||||||
|
this.incomingChain(origin).some(
|
||||||
|
(operation) => operation.originKey === target.key,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw new OriginSceneError(
|
||||||
|
"target-busy",
|
||||||
|
`Retained target "${target.key}" still positions origin "${origin.key}".`,
|
||||||
|
);
|
||||||
if (this.liveOutgoingOf(target.key))
|
if (this.liveOutgoingOf(target.key))
|
||||||
throw new Error(
|
throw new OriginSceneError(
|
||||||
|
"target-busy",
|
||||||
`Retained target "${target.key}" already has an outgoing operation.`,
|
`Retained target "${target.key}" already has an outgoing operation.`,
|
||||||
);
|
);
|
||||||
if (target.incomingOperationId)
|
if (target.incomingOperationId)
|
||||||
throw new Error(
|
throw new OriginSceneError(
|
||||||
|
"target-busy",
|
||||||
`Retained target "${target.key}" is already transitioning.`,
|
`Retained target "${target.key}" is already transitioning.`,
|
||||||
);
|
);
|
||||||
return { target, suspendedEdge };
|
return { target, suspendedEdge };
|
||||||
@@ -387,7 +468,8 @@ export class SceneGraph {
|
|||||||
history: Exclude<OriginHistoryMode, "back">,
|
history: Exclude<OriginHistoryMode, "back">,
|
||||||
): SceneNodeRecord {
|
): SceneNodeRecord {
|
||||||
if (!action.target)
|
if (!action.target)
|
||||||
throw new Error(
|
throw new OriginSceneError(
|
||||||
|
"invalid-action",
|
||||||
`A "${history}" operation requires a target view recipe.`,
|
`A "${history}" operation requires a target view recipe.`,
|
||||||
);
|
);
|
||||||
// A replacement skips the origin in the retained chain. The origin stays
|
// A replacement skips the origin in the retained chain. The origin stays
|
||||||
@@ -397,7 +479,16 @@ export class SceneGraph {
|
|||||||
return this.createNode(action.target, previousKey);
|
return this.createNode(action.target, previousKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make a node presentable again. A node still holding a connected resting
|
||||||
|
* edge (an open drawer, say) stays exposed rather than becoming active.
|
||||||
|
*/
|
||||||
private activate(node: SceneNodeRecord): void {
|
private activate(node: SceneNodeRecord): void {
|
||||||
|
const resting = this.presentedOutgoingOf(node.key);
|
||||||
|
if (resting?.phase === "finished") {
|
||||||
|
node.state = "exposed";
|
||||||
|
return;
|
||||||
|
}
|
||||||
node.state = "active";
|
node.state = "active";
|
||||||
this.refreshPresentedState(node);
|
this.refreshPresentedState(node);
|
||||||
}
|
}
|
||||||
@@ -456,23 +547,72 @@ export class SceneGraph {
|
|||||||
placement: init.placement,
|
placement: init.placement,
|
||||||
history: init.history,
|
history: init.history,
|
||||||
progress: 0,
|
progress: 0,
|
||||||
|
overshoot: 0,
|
||||||
velocity: 0,
|
velocity: 0,
|
||||||
phase: "preparing",
|
phase: "preparing",
|
||||||
intent: "undecided",
|
intent: "undecided",
|
||||||
originRect: init.originRect,
|
originRect: init.originRect,
|
||||||
});
|
});
|
||||||
this.operations.set(operation.id, operation);
|
this.attach(operation);
|
||||||
return operation;
|
return operation;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private attach(operation: MutableOriginOperation): void {
|
||||||
|
this.operations.set(operation.id, operation);
|
||||||
|
const siblings = this.outgoing.get(operation.originKey) ?? [];
|
||||||
|
this.outgoing.set(operation.originKey, [...siblings, operation]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private detach(operation: MutableOriginOperation): void {
|
||||||
|
this.operations.delete(operation.id);
|
||||||
|
const siblings = (this.outgoing.get(operation.originKey) ?? []).filter(
|
||||||
|
(sibling) => sibling.id !== operation.id,
|
||||||
|
);
|
||||||
|
if (siblings.length) this.outgoing.set(operation.originKey, siblings);
|
||||||
|
else this.outgoing.delete(operation.originKey);
|
||||||
|
}
|
||||||
|
|
||||||
private removeNode(nodeKey: string): void {
|
private removeNode(nodeKey: string): void {
|
||||||
this.nodes.delete(nodeKey);
|
this.nodes.delete(nodeKey);
|
||||||
this.roots.value = this.roots.value.filter((key) => key !== nodeKey);
|
this.roots.value = this.roots.value.filter((key) => key !== nodeKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the operations a node started, because the node is leaving
|
||||||
|
* the visible graph: nodes they created (push/replace targets, including
|
||||||
|
* replacements whose history skips this node) are removed, and retained
|
||||||
|
* entries they revealed (back targets) are restored as if that back
|
||||||
|
* operation were cancelled.
|
||||||
|
*/
|
||||||
|
/** Park a retained entry again, discarding what it started meanwhile. */
|
||||||
|
private returnToHistory(node: SceneNodeRecord, retained: Set<string>): void {
|
||||||
|
this.discardOutgoing(node.key, new Set(retained));
|
||||||
|
node.incomingOperationId = undefined;
|
||||||
|
node.state = "parked";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A node's key plus its retained history, which a cancel must keep. */
|
||||||
|
private retainedKeys(node: SceneNodeRecord): Set<string> {
|
||||||
|
return new Set([node.key, ...this.historyOf(node).map(({ key }) => key)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private discardOutgoing(nodeKey: string, visited = new Set<string>()): void {
|
||||||
|
for (const operation of [...(this.outgoing.get(nodeKey) ?? [])]) {
|
||||||
|
// Includes finished resting edges: an open drawer closes with its page.
|
||||||
|
if (operation.history === "back") {
|
||||||
|
this.cancel(operation.id);
|
||||||
|
} else {
|
||||||
|
this.detach(operation);
|
||||||
|
this.removeBranch(operation.entryTargetKey, visited);
|
||||||
|
this.removeBranch(operation.targetKey, visited);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove a node, every node whose retained history passes through it, and
|
* Remove a node, every node whose retained history passes through it, and
|
||||||
* every operation touching any of them. Earlier entries are untouched.
|
* every operation touching any of them. Earlier entries are untouched.
|
||||||
|
* Operations the node itself started are discarded first.
|
||||||
*/
|
*/
|
||||||
private removeBranch(nodeKey: string, visited = new Set<string>()): void {
|
private removeBranch(nodeKey: string, visited = new Set<string>()): void {
|
||||||
if (visited.has(nodeKey)) return;
|
if (visited.has(nodeKey)) return;
|
||||||
@@ -481,13 +621,14 @@ export class SceneGraph {
|
|||||||
for (const node of [...this.nodes.values()])
|
for (const node of [...this.nodes.values()])
|
||||||
if (node.previousNodeKey === nodeKey)
|
if (node.previousNodeKey === nodeKey)
|
||||||
this.removeBranch(node.key, visited);
|
this.removeBranch(node.key, visited);
|
||||||
|
this.discardOutgoing(nodeKey, visited);
|
||||||
for (const operation of [...this.operations.values()])
|
for (const operation of [...this.operations.values()])
|
||||||
if (
|
if (
|
||||||
operation.originKey === nodeKey ||
|
operation.originKey === nodeKey ||
|
||||||
operation.targetKey === nodeKey ||
|
operation.targetKey === nodeKey ||
|
||||||
operation.entryTargetKey === nodeKey
|
operation.entryTargetKey === nodeKey
|
||||||
)
|
)
|
||||||
this.operations.delete(operation.id);
|
this.detach(operation);
|
||||||
this.removeNode(nodeKey);
|
this.removeNode(nodeKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
inject,
|
inject,
|
||||||
markRaw,
|
markRaw,
|
||||||
nextTick,
|
nextTick,
|
||||||
|
shallowRef,
|
||||||
type ComputedRef,
|
type ComputedRef,
|
||||||
type CSSProperties,
|
type CSSProperties,
|
||||||
type InjectionKey,
|
type InjectionKey,
|
||||||
@@ -12,10 +13,14 @@ import {
|
|||||||
normalizedEffect,
|
normalizedEffect,
|
||||||
type OriginEffect,
|
type OriginEffect,
|
||||||
type OriginEffectSet,
|
type OriginEffectSet,
|
||||||
|
type OriginSpring,
|
||||||
type OriginOperationIntent,
|
type OriginOperationIntent,
|
||||||
|
type OriginOperationPhase,
|
||||||
} from "../choreography";
|
} from "../choreography";
|
||||||
import type { OriginAction, OriginView } from "../navigation";
|
import type { OriginAction, OriginView } from "../navigation";
|
||||||
import {
|
import {
|
||||||
|
DEV,
|
||||||
|
warn,
|
||||||
clamp,
|
clamp,
|
||||||
clamp01,
|
clamp01,
|
||||||
measureRect,
|
measureRect,
|
||||||
@@ -23,7 +28,12 @@ import {
|
|||||||
windowRect,
|
windowRect,
|
||||||
type OriginRect,
|
type OriginRect,
|
||||||
} from "../shared";
|
} from "../shared";
|
||||||
import { SceneGraph, shouldCommit, type SceneNodeRecord } from "./graph";
|
import {
|
||||||
|
OriginSceneError,
|
||||||
|
SceneGraph,
|
||||||
|
shouldCommit,
|
||||||
|
type SceneNodeRecord,
|
||||||
|
} from "./graph";
|
||||||
import type {
|
import type {
|
||||||
CreateOriginSceneOptions,
|
CreateOriginSceneOptions,
|
||||||
MutableOriginOperation,
|
MutableOriginOperation,
|
||||||
@@ -66,24 +76,13 @@ const Z_INDEX_PER_LAYER = 10_000;
|
|||||||
* Ancestors' source effects are intentionally excluded: only the initiating
|
* Ancestors' source effects are intentionally excluded: only the initiating
|
||||||
* component receives the source side of an operation.
|
* component receives the source side of an operation.
|
||||||
*
|
*
|
||||||
* @param viewport - Measures the scene bounds supplied to choreographies.
|
* @param effectsOf - The current effect set of an operation's choreography.
|
||||||
*/
|
*/
|
||||||
export const nodeEffectLayers = (
|
export const nodeEffectLayers = (
|
||||||
graph: SceneGraph,
|
graph: SceneGraph,
|
||||||
node: SceneNodeRecord,
|
node: SceneNodeRecord,
|
||||||
viewport: () => OriginRect,
|
effectsOf: (operation: MutableOriginOperation) => OriginEffectSet,
|
||||||
): OriginEffect[] => {
|
): OriginEffect[] => {
|
||||||
const effectsOf = (operation: MutableOriginOperation): OriginEffectSet =>
|
|
||||||
operation.choreography.effects({
|
|
||||||
progress: operation.progress,
|
|
||||||
velocity: operation.velocity,
|
|
||||||
phase: operation.phase,
|
|
||||||
intent: operation.intent,
|
|
||||||
originRect: operation.originRect,
|
|
||||||
targetRect: operation.targetRect,
|
|
||||||
viewport: viewport(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const chain = graph.incomingChain(node);
|
const chain = graph.incomingChain(node);
|
||||||
// Evaluate choreographies node-first, then apply them outermost-first.
|
// Evaluate choreographies node-first, then apply them outermost-first.
|
||||||
const effectSets = chain.map(effectsOf);
|
const effectSets = chain.map(effectsOf);
|
||||||
@@ -111,16 +110,13 @@ export const nodeEffectLayers = (
|
|||||||
export const composeNodeStyle = (
|
export const composeNodeStyle = (
|
||||||
graph: SceneGraph,
|
graph: SceneGraph,
|
||||||
nodeKey: string,
|
nodeKey: string,
|
||||||
viewport: () => OriginRect,
|
effectsOf: (operation: MutableOriginOperation) => OriginEffectSet,
|
||||||
): CSSProperties => {
|
): CSSProperties => {
|
||||||
const node = graph.node(nodeKey);
|
const node = graph.node(nodeKey);
|
||||||
if (!node) return {};
|
if (!node) return {};
|
||||||
|
|
||||||
// Measure the scene at most once, and only when an edge needs it.
|
|
||||||
let measured: OriginRect | undefined;
|
|
||||||
const lazyViewport = () => (measured ??= viewport());
|
|
||||||
const { style, layer } = composeEffects(
|
const { style, layer } = composeEffects(
|
||||||
nodeEffectLayers(graph, node, lazyViewport),
|
nodeEffectLayers(graph, node, effectsOf),
|
||||||
NODE_BASE_STYLE,
|
NODE_BASE_STYLE,
|
||||||
);
|
);
|
||||||
style.zIndex = Z_INDEX_BASE + layer * Z_INDEX_PER_LAYER + node.sequence;
|
style.zIndex = Z_INDEX_BASE + layer * Z_INDEX_PER_LAYER + node.sequence;
|
||||||
@@ -143,8 +139,8 @@ export const composeNodeStyle = (
|
|||||||
* easing on custom choreographies.
|
* easing on custom choreographies.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const STIFFNESS = 280;
|
const DEFAULT_STIFFNESS = 280;
|
||||||
const DAMPING = 30;
|
const DEFAULT_DAMPING = 30;
|
||||||
/** Fixed integration step, in seconds. */
|
/** Fixed integration step, in seconds. */
|
||||||
const STEP = 1 / 120;
|
const STEP = 1 / 120;
|
||||||
/** Frame durations are clamped to this range, in seconds. */
|
/** Frame durations are clamped to this range, in seconds. */
|
||||||
@@ -170,13 +166,16 @@ export const stepSpring = (
|
|||||||
state: SpringState,
|
state: SpringState,
|
||||||
target: number,
|
target: number,
|
||||||
elapsedMs: number,
|
elapsedMs: number,
|
||||||
|
spring: OriginSpring = {},
|
||||||
): SpringState & { readonly done: boolean } => {
|
): 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 elapsed = clamp(elapsedMs / 1000, MIN_FRAME, MAX_FRAME);
|
||||||
const iterations = Math.max(1, Math.ceil(elapsed / STEP));
|
const iterations = Math.max(1, Math.ceil(elapsed / STEP));
|
||||||
const dt = elapsed / iterations;
|
const dt = elapsed / iterations;
|
||||||
let { position, velocity } = state;
|
let { position, velocity } = state;
|
||||||
for (let index = 0; index < iterations; index += 1) {
|
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;
|
velocity += acceleration * dt;
|
||||||
position += velocity * dt;
|
position += velocity * dt;
|
||||||
}
|
}
|
||||||
@@ -190,13 +189,14 @@ export const stepSpring = (
|
|||||||
* Run a spring on `requestAnimationFrame` until it rests at `target` or
|
* Run a spring on `requestAnimationFrame` until it rests at `target` or
|
||||||
* `onFrame` returns `false` because its subject no longer exists.
|
* `onFrame` returns `false` because its subject no longer exists.
|
||||||
*
|
*
|
||||||
* Every reported progress is clamped to `0..1`; the final frame reports
|
* Each frame reports the raw position; the final frame reports exactly
|
||||||
* exactly `target` with zero velocity.
|
* `target` with zero velocity.
|
||||||
*/
|
*/
|
||||||
export const runSpring = (
|
export const runSpring = (
|
||||||
from: SpringState,
|
from: SpringState,
|
||||||
target: number,
|
target: number,
|
||||||
onFrame: (progress: number, velocity: number) => boolean,
|
onFrame: (position: number, velocity: number) => boolean,
|
||||||
|
spring: OriginSpring = {},
|
||||||
): Promise<void> =>
|
): Promise<void> =>
|
||||||
new Promise<void>((resolve) => {
|
new Promise<void>((resolve) => {
|
||||||
let state: SpringState = {
|
let state: SpringState = {
|
||||||
@@ -210,18 +210,66 @@ export const runSpring = (
|
|||||||
let previous = performance.now();
|
let previous = performance.now();
|
||||||
|
|
||||||
const frame = (time: number) => {
|
const frame = (time: number) => {
|
||||||
const next = stepSpring(state, target, time - previous);
|
const next = stepSpring(state, target, time - previous, spring);
|
||||||
previous = time;
|
previous = time;
|
||||||
state = next;
|
state = next;
|
||||||
const alive = next.done
|
const alive = next.done
|
||||||
? onFrame(target, 0)
|
? onFrame(target, 0)
|
||||||
: onFrame(clamp01(next.position), next.velocity);
|
: onFrame(next.position, next.velocity);
|
||||||
if (!alive || next.done) resolve();
|
if (!alive || next.done) resolve();
|
||||||
else window.requestAnimationFrame(frame);
|
else window.requestAnimationFrame(frame);
|
||||||
};
|
};
|
||||||
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 ---------------------------------------------------------------------
|
// Scene ---------------------------------------------------------------------
|
||||||
|
|
||||||
/** Imperative controller for one operation, returned by `Scene.begin()`. */
|
/** Imperative controller for one operation, returned by `Scene.begin()`. */
|
||||||
@@ -278,6 +326,31 @@ class Scene implements OriginScene {
|
|||||||
/** DOM registered by the renderer, used only for measurement. */
|
/** DOM registered by the renderer, used only for measurement. */
|
||||||
private readonly hosts = new Map<string, HTMLElement>();
|
private readonly hosts = new Map<string, HTMLElement>();
|
||||||
private container: HTMLElement | null = null;
|
private container: HTMLElement | null = null;
|
||||||
|
private resizeObserver: ResizeObserver | null = null;
|
||||||
|
/**
|
||||||
|
* Scene-container bounds, or the browser window before mounting. Cached and
|
||||||
|
* refreshed on resize and whenever an operation begins, so compositing
|
||||||
|
* never forces layout.
|
||||||
|
*/
|
||||||
|
private readonly viewport = shallowRef<OriginRect>(windowRect());
|
||||||
|
/**
|
||||||
|
* One memoized effect set per operation. It recomputes only when that
|
||||||
|
* operation's progress, phase, intent, rects, or the viewport change, so
|
||||||
|
* nodes sharing an edge do not evaluate its choreography again.
|
||||||
|
*/
|
||||||
|
/** The latest release (finish call) of each operation. */
|
||||||
|
private readonly releases = new WeakMap<MutableOriginOperation, object>();
|
||||||
|
/** Final outcome of each operation, once a release has decided it. */
|
||||||
|
private readonly outcomes = new WeakMap<MutableOriginOperation, boolean>();
|
||||||
|
/** Interrupted releases waiting for a later release to decide. */
|
||||||
|
private readonly waiters = new WeakMap<
|
||||||
|
MutableOriginOperation,
|
||||||
|
Array<(committed: boolean) => void>
|
||||||
|
>();
|
||||||
|
private readonly effectSets = new WeakMap<
|
||||||
|
MutableOriginOperation,
|
||||||
|
ComputedRef<OriginEffectSet>
|
||||||
|
>();
|
||||||
|
|
||||||
constructor(options: CreateOriginSceneOptions) {
|
constructor(options: CreateOriginSceneOptions) {
|
||||||
// Never proxy the scene: its methods rely on raw internal state.
|
// Never proxy the scene: its methods rely on raw internal state.
|
||||||
@@ -307,6 +380,10 @@ class Scene implements OriginScene {
|
|||||||
originKey: string,
|
originKey: string,
|
||||||
action: OriginAction,
|
action: OriginAction,
|
||||||
): Promise<OriginOperationHandle> {
|
): 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(
|
const operation = this.graph.begin(
|
||||||
originKey,
|
originKey,
|
||||||
action,
|
action,
|
||||||
@@ -319,6 +396,7 @@ class Scene implements OriginScene {
|
|||||||
await nextTick();
|
await nextTick();
|
||||||
operation.targetRect = this.measure(targetKey);
|
operation.targetRect = this.measure(targetKey);
|
||||||
operation.phase = "interactive";
|
operation.phase = "interactive";
|
||||||
|
if (poses) this.warnOnJump(poses, operation);
|
||||||
return new OperationHandle(this, operation, targetKey);
|
return new OperationHandle(this, operation, targetKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,25 +408,77 @@ class Scene implements OriginScene {
|
|||||||
/**
|
/**
|
||||||
* Fix an operation's outcome, settle it, and rewrite the graph.
|
* Fix an operation's outcome, settle it, and rewrite the graph.
|
||||||
*
|
*
|
||||||
|
* Calling `update()` while an operation settles "catches" it: the spring
|
||||||
|
* stops and progress is under control again. A later `finish()` then
|
||||||
|
* decides the outcome; every earlier, interrupted `finish()` resolves with
|
||||||
|
* that final outcome instead of applying its own.
|
||||||
|
*
|
||||||
* @internal Called through {@link OperationHandle}.
|
* @internal Called through {@link OperationHandle}.
|
||||||
*/
|
*/
|
||||||
async finish(id: number, options: OriginFinishOptions = {}) {
|
finish(id: number, options: OriginFinishOptions = {}): Promise<boolean> {
|
||||||
const operation = this.graph.operation(id);
|
const operation = this.graph.operation(id);
|
||||||
if (!operation) return false;
|
if (!operation) return Promise.resolve(false);
|
||||||
if (operation.phase === "finished") return operation.intent === "commit";
|
if (operation.phase === "finished")
|
||||||
|
return Promise.resolve(operation.intent === "commit");
|
||||||
|
const release = {};
|
||||||
|
this.releases.set(operation, release);
|
||||||
|
return this.release(operation, release, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async release(
|
||||||
|
operation: MutableOriginOperation,
|
||||||
|
release: object,
|
||||||
|
options: OriginFinishOptions,
|
||||||
|
): Promise<boolean> {
|
||||||
// The outcome is fixed at release, so the target can originate another
|
// The outcome is fixed at release, so the target can originate another
|
||||||
// operation while this one is only visually settling.
|
// operation while this one is only visually settling.
|
||||||
const commit = shouldCommit(operation, options);
|
const commit = shouldCommit(operation, options);
|
||||||
operation.intent = commit ? "commit" : "cancel";
|
operation.intent = commit ? "commit" : "cancel";
|
||||||
operation.phase = "settling";
|
operation.phase = "settling";
|
||||||
await this.settle(id, commit ? 1 : 0, options.animate ?? true);
|
const current = () =>
|
||||||
|
this.releases.get(operation) === release &&
|
||||||
|
operation.phase === "settling";
|
||||||
|
await this.settle(
|
||||||
|
operation,
|
||||||
|
commit ? 1 : 0,
|
||||||
|
options.animate ?? true,
|
||||||
|
current,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Another release already decided this operation.
|
||||||
|
const decided = this.outcomes.get(operation);
|
||||||
|
if (decided !== undefined) return decided;
|
||||||
// A later operation may already have finalized this edge.
|
// A later operation may already have finalized this edge.
|
||||||
if (!this.graph.operation(id)) return commit;
|
const finished = (operation.phase as OriginOperationPhase) === "finished";
|
||||||
if (commit) return this.graph.commit(id);
|
if (!this.graph.operation(operation.id) || finished)
|
||||||
this.graph.cancel(id);
|
return this.decide(
|
||||||
return false;
|
operation,
|
||||||
|
finished ? operation.intent === "commit" : commit,
|
||||||
|
);
|
||||||
|
// Caught by update() or superseded by a later finish(): wait for the
|
||||||
|
// release that decides.
|
||||||
|
if (!current())
|
||||||
|
return new Promise<boolean>((resolve) =>
|
||||||
|
this.waiters.set(operation, [
|
||||||
|
...(this.waiters.get(operation) ?? []),
|
||||||
|
resolve,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.decide(
|
||||||
|
operation,
|
||||||
|
commit
|
||||||
|
? this.graph.commit(operation.id)
|
||||||
|
: (this.graph.cancel(operation.id), false),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Record an operation's final outcome and settle interrupted releases. */
|
||||||
|
private decide(operation: MutableOriginOperation, committed: boolean) {
|
||||||
|
this.outcomes.set(operation, committed);
|
||||||
|
for (const resolve of this.waiters.get(operation) ?? []) resolve(committed);
|
||||||
|
this.waiters.delete(operation);
|
||||||
|
return committed;
|
||||||
}
|
}
|
||||||
|
|
||||||
registerElement(nodeKey: string, element: HTMLElement | null): void {
|
registerElement(nodeKey: string, element: HTMLElement | null): void {
|
||||||
@@ -357,34 +487,96 @@ class Scene implements OriginScene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
registerContainer(element: HTMLElement | null): void {
|
registerContainer(element: HTMLElement | null): void {
|
||||||
|
if (element === this.container) return;
|
||||||
|
this.resizeObserver?.disconnect();
|
||||||
|
this.resizeObserver = null;
|
||||||
this.container = element;
|
this.container = element;
|
||||||
|
if (!element) return;
|
||||||
|
this.refreshViewport();
|
||||||
|
if (typeof ResizeObserver !== "undefined") {
|
||||||
|
this.resizeObserver = new ResizeObserver(() => this.refreshViewport());
|
||||||
|
this.resizeObserver.observe(element);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
styleForNode(nodeKey: string): CSSProperties {
|
styleForNode(nodeKey: string): CSSProperties {
|
||||||
return composeNodeStyle(this.graph, nodeKey, () => this.viewport());
|
return composeNodeStyle(this.graph, nodeKey, this.effectsOf);
|
||||||
}
|
}
|
||||||
|
|
||||||
isNodeInteractive(nodeKey: string): boolean {
|
isNodeInteractive(nodeKey: string): boolean {
|
||||||
return this.graph.isInteractive(nodeKey);
|
return this.graph.isInteractive(nodeKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Move an operation to 0 or 1, animated unless motion is disabled. */
|
/**
|
||||||
private async settle(id: number, target: 0 | 1, animate: boolean) {
|
* Move an operation to 0 or 1, animated unless motion is disabled. The
|
||||||
const operation = this.graph.operation(id);
|
* spring stops early once `current()` reports the release was caught or
|
||||||
if (!operation) return;
|
* superseded.
|
||||||
|
*/
|
||||||
|
private async settle(
|
||||||
|
operation: MutableOriginOperation,
|
||||||
|
target: 0 | 1,
|
||||||
|
animate: boolean,
|
||||||
|
current: () => boolean,
|
||||||
|
) {
|
||||||
if (!animate || prefersReducedMotion()) {
|
if (!animate || prefersReducedMotion()) {
|
||||||
operation.progress = target;
|
operation.progress = target;
|
||||||
|
operation.overshoot = 0;
|
||||||
operation.velocity = 0;
|
operation.velocity = 0;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const from = { position: operation.progress, velocity: operation.velocity };
|
// Start from the raw position so a rubber-banded drag springs back
|
||||||
await runSpring(from, target, (progress, velocity) => {
|
// continuously instead of snapping its overshoot away.
|
||||||
const live = this.graph.operation(id);
|
const from = {
|
||||||
if (!live) return false;
|
position: operation.progress + operation.overshoot,
|
||||||
live.progress = progress;
|
velocity: operation.velocity,
|
||||||
live.velocity = 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;
|
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 {
|
private measure(nodeKey: string): OriginRect | undefined {
|
||||||
@@ -392,10 +584,40 @@ class Scene implements OriginScene {
|
|||||||
return host ? measureRect(host) : undefined;
|
return host ? measureRect(host) : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Scene-container bounds, or the browser window before mounting. */
|
/** Re-measure the container, replacing the cached rect only on change. */
|
||||||
private viewport(): OriginRect {
|
private refreshViewport(): void {
|
||||||
return this.container ? measureRect(this.container) : windowRect();
|
const next = this.container ? measureRect(this.container) : windowRect();
|
||||||
|
const current = this.viewport.value;
|
||||||
|
if (
|
||||||
|
next.top !== current.top ||
|
||||||
|
next.left !== current.left ||
|
||||||
|
next.width !== current.width ||
|
||||||
|
next.height !== current.height
|
||||||
|
)
|
||||||
|
this.viewport.value = next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private readonly effectsOf = (
|
||||||
|
operation: MutableOriginOperation,
|
||||||
|
): OriginEffectSet => {
|
||||||
|
let effects = this.effectSets.get(operation);
|
||||||
|
if (!effects) {
|
||||||
|
effects = computed(() =>
|
||||||
|
operation.choreography.effects({
|
||||||
|
progress: operation.progress,
|
||||||
|
overshoot: operation.overshoot,
|
||||||
|
velocity: operation.velocity,
|
||||||
|
phase: operation.phase,
|
||||||
|
intent: operation.intent,
|
||||||
|
originRect: operation.originRect,
|
||||||
|
targetRect: operation.targetRect,
|
||||||
|
viewport: this.viewport.value,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
this.effectSets.set(operation, effects);
|
||||||
|
}
|
||||||
|
return effects.value;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -491,7 +713,10 @@ export const originNodeScopeKey: InjectionKey<OriginNodeScope> =
|
|||||||
export function useOrigin(): UseOrigin {
|
export function useOrigin(): UseOrigin {
|
||||||
const scope = inject(originNodeScopeKey);
|
const scope = inject(originNodeScopeKey);
|
||||||
if (!scope)
|
if (!scope)
|
||||||
throw new Error("useOrigin() must be called inside an <OriginScene> view.");
|
throw new OriginSceneError(
|
||||||
|
"outside-scene",
|
||||||
|
"useOrigin() must be called inside an <OriginScene> view.",
|
||||||
|
);
|
||||||
|
|
||||||
const { scene, nodeKey } = scope;
|
const { scene, nodeKey } = scope;
|
||||||
const context = computed(() => scene.contextFor(nodeKey));
|
const context = computed(() => scene.contextFor(nodeKey));
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ export interface OriginOperation {
|
|||||||
readonly history: OriginHistoryMode;
|
readonly history: OriginHistoryMode;
|
||||||
/** Normalized progress in the inclusive range `0..1`. */
|
/** Normalized progress in the inclusive range `0..1`. */
|
||||||
readonly progress: number;
|
readonly progress: number;
|
||||||
|
/** Drag distance past either end of `progress`; usually `0`. */
|
||||||
|
readonly overshoot: number;
|
||||||
/** Latest normalized velocity in progress units per second. */
|
/** Latest normalized velocity in progress units per second. */
|
||||||
readonly velocity: number;
|
readonly velocity: number;
|
||||||
/** Current operation lifecycle phase. */
|
/** Current operation lifecycle phase. */
|
||||||
@@ -126,8 +128,9 @@ export interface OriginOperationHandle {
|
|||||||
/**
|
/**
|
||||||
* Set interactive progress and optional velocity.
|
* Set interactive progress and optional velocity.
|
||||||
*
|
*
|
||||||
* Progress is clamped to `0..1`. Velocity is normalized to progress units
|
* Progress is clamped to `0..1`; any excess is reported to choreographies as
|
||||||
* per second and is used by the choreography's flick threshold.
|
* `overshoot`. Velocity is normalized to progress units per second and is
|
||||||
|
* used by the choreography's flick threshold.
|
||||||
*/
|
*/
|
||||||
update(progress: number, velocity?: number): void;
|
update(progress: number, velocity?: number): void;
|
||||||
/**
|
/**
|
||||||
@@ -212,6 +215,8 @@ export interface MutableOriginOperation {
|
|||||||
history: OriginHistoryMode;
|
history: OriginHistoryMode;
|
||||||
/** Normalized progress. */
|
/** Normalized progress. */
|
||||||
progress: number;
|
progress: number;
|
||||||
|
/** Drag distance past either end of `progress`. */
|
||||||
|
overshoot: number;
|
||||||
/** Normalized velocity. */
|
/** Normalized velocity. */
|
||||||
velocity: number;
|
velocity: number;
|
||||||
/** Current lifecycle phase. */
|
/** Current lifecycle phase. */
|
||||||
|
|||||||
@@ -50,3 +50,16 @@ export function prefersReducedMotion(): boolean {
|
|||||||
Boolean(window.matchMedia?.("(prefers-reduced-motion: reduce)").matches)
|
Boolean(window.matchMedia?.("(prefers-reduced-motion: reduce)").matches)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Report a recoverable problem to developers without interrupting the UI. */
|
||||||
|
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";
|
||||||
|
|||||||
@@ -30,6 +30,23 @@
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Browsers only combine touch-action up to the nearest scroll container, so a
|
||||||
|
* list scrolling inside a horizontal gesture host would otherwise let the
|
||||||
|
* browser take horizontal drags (and cancel the gesture). Each host publishes
|
||||||
|
* its touch-action as an inherited custom property; descendants adopt it.
|
||||||
|
* Zero specificity, so applications can still override any element.
|
||||||
|
*/
|
||||||
|
.nvo-gesture :where(*) {
|
||||||
|
touch-action: var(--nvo-touch-action);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Opted-out subtrees, such as horizontal chip rows, keep their own panning. */
|
||||||
|
.nvo-gesture :where([data-origin-gesture="ignore"]),
|
||||||
|
.nvo-gesture :where([data-origin-gesture="ignore"] *) {
|
||||||
|
touch-action: auto;
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.nvo-node {
|
.nvo-node {
|
||||||
will-change: auto;
|
will-change: auto;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
useOriginGesture,
|
useOriginGesture,
|
||||||
type OriginGestureBinding,
|
type OriginGestureBinding,
|
||||||
type OriginGestureCompletionContext,
|
type OriginGestureCompletionContext,
|
||||||
} from ".";
|
} from "../src";
|
||||||
|
|
||||||
const mountedApps: Array<ReturnType<typeof createApp>> = [];
|
const mountedApps: Array<ReturnType<typeof createApp>> = [];
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
originView,
|
originView,
|
||||||
useOriginGesture,
|
useOriginGesture,
|
||||||
type OriginGestureDefinition,
|
type OriginGestureDefinition,
|
||||||
} from ".";
|
} from "../src";
|
||||||
|
|
||||||
const apps: Array<ReturnType<typeof createApp>> = [];
|
const apps: Array<ReturnType<typeof createApp>> = [];
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -247,6 +247,32 @@ describe(".onCommit()", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("runs synchronously inside the pointer-up event", async () => {
|
||||||
|
let calledDuringDispatch = false;
|
||||||
|
let dispatching = false;
|
||||||
|
const { host } = await mount(
|
||||||
|
gesture.to
|
||||||
|
.left()
|
||||||
|
.navigate(() => forward(originView(Target)))
|
||||||
|
.onCommit(() => {
|
||||||
|
calledDuringDispatch = dispatching;
|
||||||
|
})
|
||||||
|
.animate(motion),
|
||||||
|
);
|
||||||
|
host.dispatchEvent(pointer("pointerdown", 190, 0));
|
||||||
|
host.dispatchEvent(pointer("pointermove", 180, 500));
|
||||||
|
await flush();
|
||||||
|
host.dispatchEvent(pointer("pointermove", 40, 1000));
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
// No awaiting between the event and the handler: iOS only allows
|
||||||
|
// haptics, audio, and clipboard writes during the user's gesture.
|
||||||
|
dispatching = true;
|
||||||
|
host.dispatchEvent(pointer("pointerup", 40, 1000));
|
||||||
|
dispatching = false;
|
||||||
|
expect(calledDuringDispatch).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("fires at release, before the settling animation finishes", async () => {
|
it("fires at release, before the settling animation finishes", async () => {
|
||||||
const frames: FrameRequestCallback[] = [];
|
const frames: FrameRequestCallback[] = [];
|
||||||
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) =>
|
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) =>
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
import {
|
||||||
|
createApp,
|
||||||
|
defineComponent,
|
||||||
|
h,
|
||||||
|
nextTick,
|
||||||
|
shallowRef,
|
||||||
|
type Component,
|
||||||
|
} from "vue";
|
||||||
|
import { afterEach, beforeEach, 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("recognition and native scrolling", () => {
|
||||||
|
const leftward = gesture.to.left().navigate(toTarget).animate(motion);
|
||||||
|
|
||||||
|
it("leaves scrolling to the browser through touch-action alone", async () => {
|
||||||
|
const { scene, host } = await mount(hostFor(leftward));
|
||||||
|
// A horizontal gesture keeps native vertical panning.
|
||||||
|
expect(host.style.touchAction).toBe("pan-y");
|
||||||
|
host.dispatchEvent(pointer("pointerdown", 150, 100, 0));
|
||||||
|
host.dispatchEvent(pointer("pointermove", 140, 101, 16));
|
||||||
|
await flush();
|
||||||
|
const move = touchmove();
|
||||||
|
host.dispatchEvent(move);
|
||||||
|
// Ordinary gestures never block touchmove, so the browser never waits.
|
||||||
|
expect(move.defaultPrevented).toBe(false);
|
||||||
|
expect(scene.operations.value).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recognizes a swipe that starts with a vertical wobble", async () => {
|
||||||
|
const { scene, host } = await mount(hostFor(leftward));
|
||||||
|
host.dispatchEvent(pointer("pointerdown", 150, 100, 0));
|
||||||
|
host.dispatchEvent(pointer("pointermove", 149, 104, 16));
|
||||||
|
host.dispatchEvent(pointer("pointermove", 148, 107, 32));
|
||||||
|
host.dispatchEvent(pointer("pointermove", 120, 108, 48));
|
||||||
|
await flush();
|
||||||
|
expect(scene.operations.value).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recognizes a swipe that starts with a wind-up the wrong way", async () => {
|
||||||
|
const { scene, host } = await mount(hostFor(leftward));
|
||||||
|
host.dispatchEvent(pointer("pointerdown", 150, 100, 0));
|
||||||
|
host.dispatchEvent(pointer("pointermove", 153, 100, 16));
|
||||||
|
host.dispatchEvent(pointer("pointermove", 156, 100, 32));
|
||||||
|
host.dispatchEvent(pointer("pointermove", 120, 100, 48));
|
||||||
|
await flush();
|
||||||
|
expect(scene.operations.value).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("claims touch-action per axis like native scrolling expects", async () => {
|
||||||
|
const down = gesture.to.down().navigate(toTarget).animate(motion);
|
||||||
|
const scroller = shallowRef<HTMLElement | null>(null);
|
||||||
|
const dismiss = gesture.from
|
||||||
|
.scrollEdge(scroller, "top")
|
||||||
|
.to.down()
|
||||||
|
.navigate(toTarget)
|
||||||
|
.animate(motion);
|
||||||
|
const surface = (gestures: OriginGestureDefinition[]) =>
|
||||||
|
defineComponent({
|
||||||
|
setup: () => () =>
|
||||||
|
h(OriginGestureSurface, { id: "host", gestures }, () => "x"),
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
(await mount(surface([leftward, down]))).host.style.touchAction,
|
||||||
|
).toBe("none");
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
// A scroll-edge gesture at its edge reserves only its own pan: with the
|
||||||
|
// horizontal gesture claiming pan-x, only scrolling down remains.
|
||||||
|
expect(
|
||||||
|
(await mount(surface([leftward, dismiss]))).host.style.touchAction,
|
||||||
|
).toBe("pan-down");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
let top = scrollTop;
|
||||||
|
Object.defineProperty(scroller.value!, "scrollTop", {
|
||||||
|
get: () => top,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
const scrollTo = async (value: number) => {
|
||||||
|
top = value;
|
||||||
|
scroller.value!.dispatchEvent(new Event("scroll"));
|
||||||
|
await nextTick();
|
||||||
|
};
|
||||||
|
await scrollTo(scrollTop);
|
||||||
|
return { ...mounted, scrollTo };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("with directional touch-action", () => {
|
||||||
|
it("reserves only the dismiss pan while the list is at the top", async () => {
|
||||||
|
const { host, scrollTo } = await mountWithScroller(0);
|
||||||
|
// Dragging up still scrolls natively; dragging down is the gesture's.
|
||||||
|
expect(host.style.touchAction).toBe("pan-x pan-down");
|
||||||
|
await scrollTo(120);
|
||||||
|
expect(host.style.touchAction).toBe("manipulation");
|
||||||
|
await scrollTo(0);
|
||||||
|
expect(host.style.touchAction).toBe("pan-x pan-down");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never blocks touchmove", async () => {
|
||||||
|
const { host } = await mountWithScroller(0);
|
||||||
|
host.dispatchEvent(pointer("pointerdown", 100, 100, 0));
|
||||||
|
host.dispatchEvent(pointer("pointermove", 100, 118, 16));
|
||||||
|
const move = touchmove();
|
||||||
|
host.dispatchEvent(move);
|
||||||
|
expect(move.defaultPrevented).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
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));
|
||||||
|
scrolled.host.dispatchEvent(pointer("pointermove", 100, 140, 16));
|
||||||
|
await flush();
|
||||||
|
expect(scrolled.scene.operations.value).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("without directional touch-action (Safari)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal("CSS", { supports: () => false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the axis pannable and claims touches from JavaScript", async () => {
|
||||||
|
const atTop = await mountWithScroller(0);
|
||||||
|
expect(atTop.host.style.touchAction).toBe("manipulation");
|
||||||
|
atTop.host.dispatchEvent(pointer("pointerdown", 100, 100, 0));
|
||||||
|
// The browser's first cancelable touchmove arrives after its slop.
|
||||||
|
atTop.host.dispatchEvent(pointer("pointermove", 101, 118, 16));
|
||||||
|
const first = touchmove();
|
||||||
|
atTop.host.dispatchEvent(first);
|
||||||
|
expect(first.defaultPrevented).toBe(true);
|
||||||
|
atTop.host.dispatchEvent(pointer("pointermove", 100, 140, 32));
|
||||||
|
await flush();
|
||||||
|
expect(atTop.scene.operations.value).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets an upward drag at the top 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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,555 @@
|
|||||||
|
/**
|
||||||
|
* Property tests: random operation sequences and pointer streams must keep
|
||||||
|
* the scene's structural invariants after every step, and must never leave
|
||||||
|
* an operation stuck once all pointers are released.
|
||||||
|
*/
|
||||||
|
import { createApp, defineComponent, h, nextTick, type Component } from "vue";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
above,
|
||||||
|
back,
|
||||||
|
createOriginScene,
|
||||||
|
defineOriginChoreography,
|
||||||
|
fade,
|
||||||
|
forward,
|
||||||
|
gesture,
|
||||||
|
OriginGestureSurface,
|
||||||
|
OriginScene,
|
||||||
|
OriginSceneError,
|
||||||
|
originView,
|
||||||
|
replace,
|
||||||
|
slideLeft,
|
||||||
|
slideRight,
|
||||||
|
under,
|
||||||
|
type OriginAction,
|
||||||
|
type OriginOperationHandle,
|
||||||
|
} from "../src";
|
||||||
|
import type { SceneGraph } from "../src/scene/graph";
|
||||||
|
import type { OriginScene as Scene } from "../src/scene/types";
|
||||||
|
|
||||||
|
const SEEDS = Number(process.env.PROPERTY_SEEDS ?? 60);
|
||||||
|
|
||||||
|
// Support -------------------------------------------------------------------
|
||||||
|
|
||||||
|
function rng(seed: number) {
|
||||||
|
let a = seed >>> 0;
|
||||||
|
const next = () => {
|
||||||
|
a = (a + 0x6d2b79f5) >>> 0;
|
||||||
|
let t = a;
|
||||||
|
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||||
|
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
next,
|
||||||
|
int: (n: number) => Math.floor(next() * n),
|
||||||
|
pick: <T>(items: readonly T[]) => items[Math.floor(next() * items.length)]!,
|
||||||
|
chance: (p: number) => next() < p,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deterministic `requestAnimationFrame` and clock. */
|
||||||
|
function installFrames() {
|
||||||
|
let now = 1000;
|
||||||
|
let queue: FrameRequestCallback[] = [];
|
||||||
|
window.requestAnimationFrame = ((cb: FrameRequestCallback) =>
|
||||||
|
queue.push(cb)) as typeof window.requestAnimationFrame;
|
||||||
|
performance.now = () => now;
|
||||||
|
return {
|
||||||
|
advance(ms = 16) {
|
||||||
|
now += ms;
|
||||||
|
const run = queue;
|
||||||
|
queue = [];
|
||||||
|
for (const cb of run) cb(now);
|
||||||
|
},
|
||||||
|
drain() {
|
||||||
|
for (let i = 0; i < 1000 && queue.length; i += 1) this.advance();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flush(rounds = 4) {
|
||||||
|
for (let i = 0; i < rounds; i += 1)
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
const component = (name: string): Component =>
|
||||||
|
defineComponent({ name, render: () => h("div", name) });
|
||||||
|
|
||||||
|
/** One-line summary of a scene, for failure traces. */
|
||||||
|
const describeScene = (scene: Scene) => {
|
||||||
|
const nodes = scene.nodes.value
|
||||||
|
.map(
|
||||||
|
(n) =>
|
||||||
|
`${n.key}:${n.state}:in=${n.incomingOperationId ?? "-"}:prev=${n.previousNodeKey ?? "-"}`,
|
||||||
|
)
|
||||||
|
.join(" ");
|
||||||
|
const operations = scene.operations.value
|
||||||
|
.map(
|
||||||
|
(o) =>
|
||||||
|
`#${o.id} ${o.originKey}→${o.targetKey} ${o.history} ${o.phase}/${o.intent}`,
|
||||||
|
)
|
||||||
|
.join(", ");
|
||||||
|
return ` roots=[${scene.roots.value}] nodes=[${nodes}] ops=[${operations}]`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const graphOf = (scene: Scene) =>
|
||||||
|
(scene as unknown as { graph: SceneGraph }).graph;
|
||||||
|
|
||||||
|
/** Structural invariants that must hold after every step. */
|
||||||
|
function checkInvariants(scene: Scene, label: string, rootCount: number) {
|
||||||
|
const graph = graphOf(scene);
|
||||||
|
const nodes = [...graph.nodes.values()];
|
||||||
|
const roots = scene.roots.value;
|
||||||
|
const fail = (message: string) => {
|
||||||
|
throw new Error(`${label}\n${describeScene(scene)}\n${message}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (roots.length !== rootCount)
|
||||||
|
fail(`expected ${rootCount} roots, found ${roots.length}`);
|
||||||
|
if (new Set(roots).size !== roots.length) fail("duplicate roots");
|
||||||
|
for (const key of roots) {
|
||||||
|
const node = graph.node(key);
|
||||||
|
if (!node) fail(`root ${key} does not exist`);
|
||||||
|
else if (graph.incomingOf(node)) fail(`root ${key} has an incoming edge`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const node of nodes) {
|
||||||
|
// Incoming edges point back at their node.
|
||||||
|
if (node.incomingOperationId !== undefined) {
|
||||||
|
const incoming = graph.operation(node.incomingOperationId);
|
||||||
|
if (!incoming) fail(`${node.key} has a dangling incoming edge`);
|
||||||
|
else if (incoming.targetKey !== node.key)
|
||||||
|
fail(`${node.key}'s incoming edge targets ${incoming.targetKey}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// History links exist and are acyclic.
|
||||||
|
const seen = new Set([node.key]);
|
||||||
|
for (let key = node.previousNodeKey; key;) {
|
||||||
|
const previous = graph.node(key);
|
||||||
|
if (!previous) fail(`${node.key} has a missing previous entry ${key}`);
|
||||||
|
if (seen.has(key)) fail(`${node.key} has cyclic history`);
|
||||||
|
seen.add(key);
|
||||||
|
key = previous?.previousNodeKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.state === "parked") {
|
||||||
|
if (roots.includes(node.key)) fail(`parked ${node.key} is a root`);
|
||||||
|
if (node.incomingOperationId !== undefined)
|
||||||
|
fail(`parked ${node.key} is positioned by an edge`);
|
||||||
|
if (scene.isNodeInteractive(node.key)) fail(`parked ${node.key} is live`);
|
||||||
|
if (scene.styleForNode(node.key).visibility !== "hidden")
|
||||||
|
fail(`parked ${node.key} is visible`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every presented node reaches a root through acyclic incoming edges.
|
||||||
|
const visited = new Set<string>();
|
||||||
|
let current = node;
|
||||||
|
for (;;) {
|
||||||
|
if (visited.has(current.key)) fail(`${node.key} is in a visual cycle`);
|
||||||
|
visited.add(current.key);
|
||||||
|
const incoming = graph.incomingOf(current);
|
||||||
|
if (!incoming) break;
|
||||||
|
const origin = graph.node(incoming.originKey);
|
||||||
|
if (!origin) fail(`${current.key}'s edge comes from a missing origin`);
|
||||||
|
current = origin!;
|
||||||
|
}
|
||||||
|
if (!roots.includes(current.key))
|
||||||
|
fail(`${node.key} is detached from every root`);
|
||||||
|
if (node.state === "exposed" && scene.isNodeInteractive(node.key))
|
||||||
|
fail(`exposed ${node.key} is live`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every live operation joins existing nodes; one per origin.
|
||||||
|
const origins = new Set<string>();
|
||||||
|
for (const operation of scene.operations.value) {
|
||||||
|
if (!graph.node(operation.originKey))
|
||||||
|
fail(`operation ${operation.id} has a missing origin`);
|
||||||
|
if (!graph.node(operation.targetKey))
|
||||||
|
fail(`operation ${operation.id} has a missing target`);
|
||||||
|
if (origins.has(operation.originKey))
|
||||||
|
fail(`${operation.originKey} has two live operations`);
|
||||||
|
origins.add(operation.originKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Additional invariants once every operation has finished. */
|
||||||
|
function checkResting(scene: Scene, rootCount: number, label: string) {
|
||||||
|
checkInvariants(scene, label, rootCount);
|
||||||
|
const states = scene.nodes.value.map((node) => node.state);
|
||||||
|
expect(scene.operations.value, `${label}: live operations`).toEqual([]);
|
||||||
|
expect(states, `${label}: states`).not.toContain("transitioning");
|
||||||
|
expect(
|
||||||
|
states.filter((state) => state === "active"),
|
||||||
|
`${label}: one active entry per root`,
|
||||||
|
).toHaveLength(rootCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scene operations ----------------------------------------------------------
|
||||||
|
|
||||||
|
const drawerOpen = defineOriginChoreography({
|
||||||
|
name: "drawer-open",
|
||||||
|
persistAtRest: true,
|
||||||
|
effects: ({ progress }) => ({
|
||||||
|
source: { transform: `translateX(${progress * 66}%)` },
|
||||||
|
target: { transform: `translateX(${(progress - 1) * 66}%)` },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const drawerClose = defineOriginChoreography({
|
||||||
|
name: "drawer-close",
|
||||||
|
commitThreshold: 0.2,
|
||||||
|
commitVelocity: 0.5,
|
||||||
|
effects: ({ progress }) => ({
|
||||||
|
source: { transform: `translateX(${-progress * 66}%)` },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const choreographies = [slideLeft, slideRight, fade, drawerOpen, drawerClose];
|
||||||
|
const views = ["A", "B", "C", "D"].map((name) =>
|
||||||
|
originView(component(name), undefined, { key: name.toLowerCase() }),
|
||||||
|
);
|
||||||
|
|
||||||
|
function randomAction(r: ReturnType<typeof rng>): OriginAction {
|
||||||
|
const choreography = r.pick(choreographies);
|
||||||
|
const target = r.pick(views);
|
||||||
|
const placement = r.pick(["above", "under", undefined] as const);
|
||||||
|
switch (r.int(6)) {
|
||||||
|
case 0:
|
||||||
|
return forward(target, choreography, { placement });
|
||||||
|
case 1:
|
||||||
|
return replace(target, choreography, { placement });
|
||||||
|
case 2:
|
||||||
|
case 3:
|
||||||
|
return back(choreography, { placement });
|
||||||
|
case 4:
|
||||||
|
return above(target, choreography, {
|
||||||
|
history: r.pick(["push", "replace", "back"] as const),
|
||||||
|
});
|
||||||
|
default:
|
||||||
|
return under(target, choreography);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runOperations(seed: number) {
|
||||||
|
const r = rng(seed);
|
||||||
|
const frames = installFrames();
|
||||||
|
const initial = seed % 3 === 0 ? [views[0]!, views[1]!] : [views[0]!];
|
||||||
|
const scene = createOriginScene({ initial });
|
||||||
|
const handles: OriginOperationHandle[] = [];
|
||||||
|
const unexpected: unknown[] = [];
|
||||||
|
const expectSceneError = (error: unknown) => {
|
||||||
|
if (!(error instanceof OriginSceneError)) unexpected.push(error);
|
||||||
|
};
|
||||||
|
|
||||||
|
const trace: string[] = [];
|
||||||
|
for (let step = 0; step < 80; step += 1) {
|
||||||
|
const label = () =>
|
||||||
|
`seed ${seed} step ${step}\n${trace.slice(-60).join("\n")}`;
|
||||||
|
const roll = r.next();
|
||||||
|
const liveIds = new Set(scene.operations.value.map(({ id }) => id));
|
||||||
|
const live = handles.filter(({ id }) => liveIds.has(id));
|
||||||
|
const pick = () =>
|
||||||
|
live.length && r.chance(0.85) ? r.pick(live) : r.pick(handles);
|
||||||
|
|
||||||
|
if (roll < 0.3 || handles.length === 0) {
|
||||||
|
const originKey = r.pick(scene.nodes.value).key;
|
||||||
|
const action = randomAction(r);
|
||||||
|
scene.begin(originKey, action).then(
|
||||||
|
(handle) => {
|
||||||
|
handles.push(handle);
|
||||||
|
trace.push(
|
||||||
|
` begin #${handle.id} ${originKey}→${handle.targetKey} ${action.history} ${action.choreography.name}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
trace.push(
|
||||||
|
` begin ${originKey} ${action.history} rejected: ${error.code}`,
|
||||||
|
);
|
||||||
|
expectSceneError(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else if (roll < 0.55) {
|
||||||
|
const handle = pick();
|
||||||
|
const p = r.next() * 1.4 - 0.2;
|
||||||
|
const v = r.next() * 3 - 1;
|
||||||
|
trace.push(`update #${handle.id} ${p.toFixed(2)} ${v.toFixed(2)}`);
|
||||||
|
handle.update(p, v);
|
||||||
|
} else if (roll < 0.75) {
|
||||||
|
const handle = pick();
|
||||||
|
const options = {
|
||||||
|
commit: r.pick([true, false, undefined]),
|
||||||
|
animate: r.chance(0.5),
|
||||||
|
};
|
||||||
|
trace.push(`finish #${handle.id} ${JSON.stringify(options)}`);
|
||||||
|
void handle
|
||||||
|
.finish(options)
|
||||||
|
.then((v) => trace.push(` finished #${handle.id} → ${v}`));
|
||||||
|
} else if (roll < 0.82) {
|
||||||
|
const handle = pick();
|
||||||
|
trace.push(`cancel #${handle.id}`);
|
||||||
|
void handle
|
||||||
|
.cancel({ animate: r.chance(0.5) })
|
||||||
|
.then(() => trace.push(` cancelled #${handle.id}`));
|
||||||
|
} else {
|
||||||
|
trace.push("frames");
|
||||||
|
for (let i = 1 + r.int(12); i > 0; i -= 1) frames.advance(4 + r.int(40));
|
||||||
|
}
|
||||||
|
if (!r.chance(0.15)) await flush();
|
||||||
|
trace.push(describeScene(scene));
|
||||||
|
checkInvariants(scene, label(), initial.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release everything: every live operation finishes, then springs drain.
|
||||||
|
await flush();
|
||||||
|
for (const handle of handles) void handle.finish({ animate: r.chance(0.5) });
|
||||||
|
await flush();
|
||||||
|
frames.drain();
|
||||||
|
await flush();
|
||||||
|
checkResting(
|
||||||
|
scene,
|
||||||
|
initial.length,
|
||||||
|
`seed ${seed} at rest\n${trace.slice(-50).join("\n")}`,
|
||||||
|
);
|
||||||
|
expect(unexpected, `seed ${seed}: non-scene errors`).toEqual([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("scene properties", () => {
|
||||||
|
for (let seed = 1; seed <= SEEDS; seed += 1)
|
||||||
|
it(`random operations ${seed}`, () => runOperations(seed));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Gestures ------------------------------------------------------------------
|
||||||
|
|
||||||
|
const apps: Array<ReturnType<typeof createApp>> = [];
|
||||||
|
afterEach(() => {
|
||||||
|
for (const app of apps.splice(0)) app.unmount();
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
const WIDTH = 300;
|
||||||
|
const HEIGHT = 600;
|
||||||
|
|
||||||
|
function stubBounds(element: HTMLElement) {
|
||||||
|
if (element.dataset.stubbed) return;
|
||||||
|
element.dataset.stubbed = "1";
|
||||||
|
Object.defineProperties(element, {
|
||||||
|
clientWidth: { configurable: true, value: WIDTH },
|
||||||
|
clientHeight: { configurable: true, value: HEIGHT },
|
||||||
|
});
|
||||||
|
element.getBoundingClientRect = () =>
|
||||||
|
({ top: 0, left: 0, width: WIDTH, height: HEIGHT }) as DOMRect;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Views whose gestures can grow, collapse, and race chains of operations. */
|
||||||
|
function gestureWorld() {
|
||||||
|
const viewsByName: Record<string, ReturnType<typeof originView>> = {};
|
||||||
|
const slide = defineOriginChoreography({
|
||||||
|
name: "slide",
|
||||||
|
commitVelocity: 0.6,
|
||||||
|
effects: ({ progress }) => ({
|
||||||
|
source: { transform: `translateX(${-progress * 100}px)` },
|
||||||
|
target: { transform: `translateX(${(1 - progress) * 100}px)` },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const surface = (name: string, gestures: () => unknown[]) =>
|
||||||
|
defineComponent({
|
||||||
|
name,
|
||||||
|
setup: () => () =>
|
||||||
|
h(
|
||||||
|
OriginGestureSurface,
|
||||||
|
{ gestures: gestures() as never, "data-gesture-host": "" },
|
||||||
|
() => [h("input", { "data-input": "" }), name],
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
const A = surface("A", () => [
|
||||||
|
gesture.to
|
||||||
|
.left()
|
||||||
|
.navigate(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
return forward(viewsByName.B!);
|
||||||
|
})
|
||||||
|
.animate(slide),
|
||||||
|
gesture.from
|
||||||
|
.left(40)
|
||||||
|
.to.right()
|
||||||
|
.navigate((context) => (context.canGoBack ? back() : null))
|
||||||
|
.animate(slideRight),
|
||||||
|
gesture.from
|
||||||
|
.when(({ point }) => point.localY < HEIGHT / 2)
|
||||||
|
.to.down({ threshold: 12 })
|
||||||
|
.complete(({ progress }) => progress > 0.2)
|
||||||
|
.navigate(() => forward(viewsByName.C!, { placement: "under" }))
|
||||||
|
.animate(drawerOpen),
|
||||||
|
gesture.from
|
||||||
|
.bottom("20%")
|
||||||
|
.to.up()
|
||||||
|
.navigate((context) =>
|
||||||
|
context.canGoBack ? back() : replace(viewsByName.A!),
|
||||||
|
)
|
||||||
|
.animate(fade),
|
||||||
|
]);
|
||||||
|
const B = surface("B", () => [
|
||||||
|
gesture.to
|
||||||
|
.left({ threshold: 6 })
|
||||||
|
.navigate(() => forward(viewsByName.C!))
|
||||||
|
.animate(slide),
|
||||||
|
gesture.to
|
||||||
|
.right({ threshold: 6 })
|
||||||
|
.navigate((context) => (context.canGoBack ? back() : null))
|
||||||
|
.animate(slideRight),
|
||||||
|
]);
|
||||||
|
const C = surface("C", () => [
|
||||||
|
gesture.to
|
||||||
|
.up({ threshold: 4 })
|
||||||
|
.navigate((context) =>
|
||||||
|
context.canGoBack ? back() : forward(viewsByName.B!),
|
||||||
|
)
|
||||||
|
.animate(drawerClose),
|
||||||
|
gesture.to
|
||||||
|
.left()
|
||||||
|
.navigate(() => {
|
||||||
|
throw new Error("intentional navigate failure");
|
||||||
|
})
|
||||||
|
.animate(slide),
|
||||||
|
]);
|
||||||
|
viewsByName.A = originView(A, undefined, { key: "a" });
|
||||||
|
viewsByName.B = originView(B, undefined, { key: "b" });
|
||||||
|
viewsByName.C = originView(C, undefined, { key: "c" });
|
||||||
|
|
||||||
|
const scene = createOriginScene({ initial: viewsByName.A });
|
||||||
|
const root = document.createElement("div");
|
||||||
|
document.body.append(root);
|
||||||
|
const app = createApp({ render: () => h(OriginScene, { scene }) });
|
||||||
|
apps.push(app);
|
||||||
|
app.mount(root);
|
||||||
|
return { scene, root };
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointerEvent(type: string, init: PointerEventInit, timeStamp: number) {
|
||||||
|
const event = new PointerEvent(type, {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
...init,
|
||||||
|
});
|
||||||
|
Object.defineProperty(event, "timeStamp", { value: timeStamp });
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runPointers(seed: number) {
|
||||||
|
const r = rng(seed * 7919);
|
||||||
|
const frames = installFrames();
|
||||||
|
const warn = console.warn;
|
||||||
|
console.warn = () => {};
|
||||||
|
try {
|
||||||
|
const { scene, root } = gestureWorld();
|
||||||
|
await nextTick();
|
||||||
|
let time = 100;
|
||||||
|
let x = 0;
|
||||||
|
let y = 0;
|
||||||
|
let pointerId = 1;
|
||||||
|
let host: HTMLElement | null = null;
|
||||||
|
let heading: "left" | "right" | "up" | "down" = "left";
|
||||||
|
const trace: string[] = [];
|
||||||
|
|
||||||
|
const dispatch = (
|
||||||
|
type: string,
|
||||||
|
init: PointerEventInit,
|
||||||
|
onInput = false,
|
||||||
|
) => {
|
||||||
|
trace.push(
|
||||||
|
`${type} ${host?.closest("[data-origin-node]")?.getAttribute("data-origin-node")} ${JSON.stringify(init)} t=${time}${host?.isConnected ? "" : " (detached)"}`,
|
||||||
|
);
|
||||||
|
if (!host?.isConnected) return;
|
||||||
|
const target = onInput
|
||||||
|
? (host.querySelector("[data-input]") as HTMLElement)
|
||||||
|
: host;
|
||||||
|
target.dispatchEvent(pointerEvent(type, init, time));
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let step = 0; step < 120; step += 1) {
|
||||||
|
time += r.int(40);
|
||||||
|
const roll = r.next();
|
||||||
|
if (roll < 0.15) {
|
||||||
|
// A new primary pointer means the previous one ended: a browser
|
||||||
|
// delivers its pointer-up to the captured host, or, if that was
|
||||||
|
// lost, fires lostpointercapture there.
|
||||||
|
dispatch(r.chance(0.8) ? "pointerup" : "lostpointercapture", {
|
||||||
|
clientX: x,
|
||||||
|
clientY: y,
|
||||||
|
pointerId,
|
||||||
|
});
|
||||||
|
if (r.chance(0.8)) await flush(3);
|
||||||
|
const nodes = scene.nodes.value.filter((node) =>
|
||||||
|
scene.isNodeInteractive(node.key),
|
||||||
|
);
|
||||||
|
const node = r.pick(nodes.length ? nodes : scene.nodes.value);
|
||||||
|
host = root.querySelector(
|
||||||
|
`[data-origin-node="${CSS.escape(node.key)}"] [data-gesture-host]`,
|
||||||
|
);
|
||||||
|
if (host) stubBounds(host);
|
||||||
|
x = r.chance(0.3) ? r.int(50) : r.int(WIDTH);
|
||||||
|
y = r.chance(0.3) ? HEIGHT - r.int(130) : r.int(HEIGHT);
|
||||||
|
pointerId = r.chance(0.8) ? 1 : 2;
|
||||||
|
heading = r.pick(["left", "right", "up", "down"] as const);
|
||||||
|
dispatch(
|
||||||
|
"pointerdown",
|
||||||
|
{
|
||||||
|
clientX: x,
|
||||||
|
clientY: y,
|
||||||
|
pointerId,
|
||||||
|
button: r.chance(0.95) ? 0 : 1,
|
||||||
|
isPrimary: r.chance(0.95),
|
||||||
|
},
|
||||||
|
r.chance(0.08),
|
||||||
|
);
|
||||||
|
} else if (roll < 0.65) {
|
||||||
|
const amount = 5 + r.int(55);
|
||||||
|
if (heading === "left") x -= amount;
|
||||||
|
if (heading === "right") x += amount;
|
||||||
|
if (heading === "up") y -= amount;
|
||||||
|
if (heading === "down") y += amount;
|
||||||
|
dispatch("pointermove", { clientX: x, clientY: y, pointerId });
|
||||||
|
} else if (roll < 0.8) {
|
||||||
|
dispatch("pointerup", { clientX: x, clientY: y, pointerId });
|
||||||
|
} else if (roll < 0.85) {
|
||||||
|
dispatch(r.pick(["pointercancel", "lostpointercapture"]), {
|
||||||
|
pointerId,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
trace.push("frames");
|
||||||
|
for (let i = 1 + r.int(20); i > 0; i -= 1) frames.advance(16);
|
||||||
|
}
|
||||||
|
if (r.chance(0.7)) {
|
||||||
|
frames.advance(16);
|
||||||
|
frames.advance(16);
|
||||||
|
}
|
||||||
|
if (!r.chance(0.2)) await flush(3);
|
||||||
|
trace.push(describeScene(scene));
|
||||||
|
checkInvariants(
|
||||||
|
scene,
|
||||||
|
`seed ${seed} step ${step}\n${trace.slice(-40).join("\n")}`,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lift the pointer; nothing may remain stuck mid-transition.
|
||||||
|
dispatch("pointerup", { clientX: x, clientY: y, pointerId });
|
||||||
|
await flush();
|
||||||
|
frames.drain();
|
||||||
|
await flush();
|
||||||
|
trace.push(describeScene(scene));
|
||||||
|
checkResting(
|
||||||
|
scene,
|
||||||
|
1,
|
||||||
|
`seed ${seed} after release\n${trace.slice(-40).join("\n")}`,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
console.warn = warn;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("gesture properties", () => {
|
||||||
|
for (let seed = 1; seed <= SEEDS; seed += 1)
|
||||||
|
it(`random pointer stream ${seed}`, () => runPointers(seed));
|
||||||
|
});
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
import {
|
||||||
|
createApp,
|
||||||
|
defineComponent,
|
||||||
|
h,
|
||||||
|
nextTick,
|
||||||
|
shallowRef,
|
||||||
|
type Component,
|
||||||
|
} from "vue";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
back,
|
||||||
|
createOriginScene,
|
||||||
|
defineOriginChoreography,
|
||||||
|
forward,
|
||||||
|
gesture,
|
||||||
|
OriginGesture,
|
||||||
|
OriginGestureSurface,
|
||||||
|
OriginScene,
|
||||||
|
OriginSceneError,
|
||||||
|
originView,
|
||||||
|
type OriginGestureDefinition,
|
||||||
|
} from "../src";
|
||||||
|
import { shouldCommit } from "../src/scene/graph";
|
||||||
|
|
||||||
|
const apps: Array<ReturnType<typeof createApp>> = [];
|
||||||
|
afterEach(() => {
|
||||||
|
for (const app of apps.splice(0)) app.unmount();
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const motion = defineOriginChoreography({
|
||||||
|
name: "recognizer-test",
|
||||||
|
effects: ({ progress }) => ({ target: { opacity: progress } }),
|
||||||
|
});
|
||||||
|
const Target: Component = defineComponent({ render: () => h("div", "T") });
|
||||||
|
const toTarget = () => forward(originView(Target));
|
||||||
|
|
||||||
|
function pointer(
|
||||||
|
type: string,
|
||||||
|
clientX: number,
|
||||||
|
timeStamp: number,
|
||||||
|
pointerId = 1,
|
||||||
|
) {
|
||||||
|
const event = new PointerEvent(type, {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
button: 0,
|
||||||
|
isPrimary: true,
|
||||||
|
pointerId,
|
||||||
|
clientX,
|
||||||
|
clientY: 100,
|
||||||
|
});
|
||||||
|
Object.defineProperty(event, "timeStamp", { value: timeStamp });
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flush() {
|
||||||
|
for (let i = 0; i < 4; i += 1) await new Promise((r) => setTimeout(r, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
function stubBounds(host: HTMLElement) {
|
||||||
|
Object.defineProperty(host, "clientWidth", { value: 200 });
|
||||||
|
host.getBoundingClientRect = () =>
|
||||||
|
({ top: 0, left: 0, width: 200, height: 400 }) as DOMRect;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render `root` as the scene's initial view; return the scene and host. */
|
||||||
|
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;
|
||||||
|
stubBounds(host);
|
||||||
|
return { scene, host, app };
|
||||||
|
}
|
||||||
|
|
||||||
|
const gestureHost = (definition: () => OriginGestureDefinition) =>
|
||||||
|
defineComponent({
|
||||||
|
setup: () => () =>
|
||||||
|
h(OriginGesture, { id: "host", gesture: definition() }, () => "host"),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Press, then drag left far enough to capture and begin an operation. */
|
||||||
|
async function dragLeft(host: HTMLElement, pointerId = 1, x = 180) {
|
||||||
|
host.dispatchEvent(pointer("pointerdown", x, 0, pointerId));
|
||||||
|
host.dispatchEvent(pointer("pointermove", x - 60, 100, pointerId));
|
||||||
|
await flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("stuck gestures", () => {
|
||||||
|
const definition = gesture.to.left().navigate(toTarget).animate(motion);
|
||||||
|
|
||||||
|
it("cancels a captured gesture when pointer capture is lost", async () => {
|
||||||
|
const { scene, host } = await mount(gestureHost(() => definition));
|
||||||
|
await dragLeft(host);
|
||||||
|
expect(scene.operations.value).toHaveLength(1);
|
||||||
|
|
||||||
|
host.dispatchEvent(pointer("lostpointercapture", 120, 120));
|
||||||
|
await flush();
|
||||||
|
expect(scene.operations.value).toHaveLength(0);
|
||||||
|
expect(scene.nodes.value).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a child losing implicit touch capture to the host", async () => {
|
||||||
|
const Root = defineComponent({
|
||||||
|
setup: () => () =>
|
||||||
|
h(OriginGesture, { id: "host", gesture: definition }, () =>
|
||||||
|
h("span", { id: "child" }, "x"),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const { scene, host } = await mount(Root);
|
||||||
|
await dragLeft(host);
|
||||||
|
expect(scene.operations.value).toHaveLength(1);
|
||||||
|
|
||||||
|
// Taking capture on the host makes the touched child lose its implicit
|
||||||
|
// capture; that event bubbles to the host.
|
||||||
|
host
|
||||||
|
.querySelector("#child")!
|
||||||
|
.dispatchEvent(pointer("lostpointercapture", 120, 110));
|
||||||
|
await flush();
|
||||||
|
expect(scene.operations.value).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores lostpointercapture after a normal release", async () => {
|
||||||
|
const { scene, host } = await mount(gestureHost(() => definition));
|
||||||
|
await dragLeft(host);
|
||||||
|
host.dispatchEvent(pointer("pointermove", 20, 200));
|
||||||
|
host.dispatchEvent(pointer("pointerup", 20, 200));
|
||||||
|
host.dispatchEvent(pointer("lostpointercapture", 20, 200));
|
||||||
|
await flush();
|
||||||
|
// Committed, not cancelled.
|
||||||
|
expect(scene.nodes.value.map((node) => node.state)).toEqual([
|
||||||
|
"parked",
|
||||||
|
"active",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels a stale gesture when a new primary pointer goes down", async () => {
|
||||||
|
const { scene, host } = await mount(gestureHost(() => definition));
|
||||||
|
await dragLeft(host, 1);
|
||||||
|
expect(scene.operations.value).toHaveLength(1);
|
||||||
|
|
||||||
|
// pointer 1's pointer-up was lost; a new touch starts.
|
||||||
|
host.dispatchEvent(pointer("pointerdown", 150, 300, 2));
|
||||||
|
await flush();
|
||||||
|
expect(scene.operations.value).toHaveLength(0);
|
||||||
|
expect(scene.nodes.value).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels a captured gesture when its component unmounts", async () => {
|
||||||
|
const show = shallowRef(true);
|
||||||
|
const Root = defineComponent({
|
||||||
|
setup: () => () =>
|
||||||
|
h("div", [
|
||||||
|
show.value
|
||||||
|
? h(OriginGesture, { id: "host", gesture: definition }, () => "x")
|
||||||
|
: null,
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
const { scene, host } = await mount(Root);
|
||||||
|
await dragLeft(host);
|
||||||
|
expect(scene.operations.value).toHaveLength(1);
|
||||||
|
|
||||||
|
show.value = false;
|
||||||
|
await flush();
|
||||||
|
expect(scene.operations.value).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("failure warnings", () => {
|
||||||
|
it("warns when navigate() throws, without touching the scene", async () => {
|
||||||
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
const failing = gesture.to
|
||||||
|
.left()
|
||||||
|
.navigate(() => {
|
||||||
|
throw new Error("no route");
|
||||||
|
})
|
||||||
|
.animate(motion);
|
||||||
|
const { scene, host } = await mount(gestureHost(() => failing));
|
||||||
|
await dragLeft(host);
|
||||||
|
expect(scene.operations.value).toHaveLength(0);
|
||||||
|
expect(warn).toHaveBeenCalledWith(
|
||||||
|
"[native-vue-router] A gesture's navigate() factory failed.",
|
||||||
|
new Error("no route"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warns when the scene rejects the operation", async () => {
|
||||||
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
// The initial view has no previous entry, so back() is rejected.
|
||||||
|
const invalid = gesture.to
|
||||||
|
.left()
|
||||||
|
.navigate(() => back())
|
||||||
|
.animate(motion);
|
||||||
|
const { host } = await mount(gestureHost(() => invalid));
|
||||||
|
await dragLeft(host);
|
||||||
|
expect(warn).toHaveBeenCalledTimes(1);
|
||||||
|
const error = warn.mock.calls[0]![1] as OriginSceneError;
|
||||||
|
expect(error).toBeInstanceOf(OriginSceneError);
|
||||||
|
expect(error.code).toBe("no-previous-entry");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays quiet when two gestures race for the same origin", async () => {
|
||||||
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
const both = [
|
||||||
|
gesture.to.left({ threshold: 4 }).navigate(toTarget).animate(motion),
|
||||||
|
gesture.to.left({ threshold: 6 }).navigate(toTarget).animate(motion),
|
||||||
|
];
|
||||||
|
const Root = defineComponent({
|
||||||
|
setup: () => () =>
|
||||||
|
h(OriginGestureSurface, { id: "host", gestures: both }, () => "x"),
|
||||||
|
});
|
||||||
|
const { scene, host } = await mount(Root);
|
||||||
|
await dragLeft(host);
|
||||||
|
expect(scene.operations.value).toHaveLength(1);
|
||||||
|
expect(warn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("declining with null does not warn", async () => {
|
||||||
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
const declined = gesture.to
|
||||||
|
.left()
|
||||||
|
.navigate(() => null)
|
||||||
|
.animate(motion);
|
||||||
|
const { host } = await mount(gestureHost(() => declined));
|
||||||
|
await dragLeft(host);
|
||||||
|
expect(warn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reactive gesture props", () => {
|
||||||
|
it("applies a new definition from the next pointer-down only", async () => {
|
||||||
|
const leftward = gesture.to.left().navigate(toTarget).animate(motion);
|
||||||
|
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");
|
||||||
|
|
||||||
|
// 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", 140, 100));
|
||||||
|
await flush();
|
||||||
|
expect(scene.operations.value).toHaveLength(1);
|
||||||
|
// 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.
|
||||||
|
host.dispatchEvent(pointer("pointerdown", 20, 2000));
|
||||||
|
host.dispatchEvent(pointer("pointermove", 80, 2100));
|
||||||
|
await flush();
|
||||||
|
expect(scene.operations.value).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a gesture in progress across a parent re-render", async () => {
|
||||||
|
const tick = shallowRef(0);
|
||||||
|
// A new but equivalent definition object on every render.
|
||||||
|
const { scene, host } = await mount(
|
||||||
|
gestureHost(() => {
|
||||||
|
void tick.value;
|
||||||
|
return gesture.to.left().navigate(toTarget).animate(motion);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await dragLeft(host);
|
||||||
|
tick.value += 1;
|
||||||
|
await nextTick();
|
||||||
|
host.dispatchEvent(pointer("pointermove", 20, 200));
|
||||||
|
host.dispatchEvent(pointer("pointerup", 20, 200));
|
||||||
|
await flush();
|
||||||
|
expect(scene.nodes.value.map((node) => node.state)).toEqual([
|
||||||
|
"parked",
|
||||||
|
"active",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tracks a surface's changing gesture list and touch-action", async () => {
|
||||||
|
const list = shallowRef<OriginGestureDefinition[]>([
|
||||||
|
gesture.to.left().navigate(toTarget).animate(motion),
|
||||||
|
]);
|
||||||
|
const Root = defineComponent({
|
||||||
|
setup: () => () =>
|
||||||
|
h(
|
||||||
|
OriginGestureSurface,
|
||||||
|
{ id: "host", gestures: list.value },
|
||||||
|
() => "x",
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const { scene, host } = await mount(Root);
|
||||||
|
expect(host.style.touchAction).toBe("pan-y");
|
||||||
|
|
||||||
|
list.value = [
|
||||||
|
...list.value,
|
||||||
|
gesture.to.down().navigate(toTarget).animate(motion),
|
||||||
|
];
|
||||||
|
await nextTick();
|
||||||
|
expect(host.style.touchAction).toBe("none");
|
||||||
|
|
||||||
|
host.dispatchEvent(
|
||||||
|
Object.assign(pointer("pointerdown", 100, 0), { clientY: 0 }),
|
||||||
|
);
|
||||||
|
const down = new PointerEvent("pointermove", {
|
||||||
|
bubbles: true,
|
||||||
|
isPrimary: true,
|
||||||
|
pointerId: 1,
|
||||||
|
clientX: 100,
|
||||||
|
clientY: 80,
|
||||||
|
});
|
||||||
|
host.dispatchEvent(down);
|
||||||
|
await flush();
|
||||||
|
expect(scene.operations.value).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("commitVelocity", () => {
|
||||||
|
const operation = (commitVelocity: number | undefined, velocity: number) =>
|
||||||
|
({
|
||||||
|
progress: 0.1,
|
||||||
|
velocity,
|
||||||
|
choreography: { effects: () => ({}), commitVelocity },
|
||||||
|
}) as unknown as Parameters<typeof shouldCommit>[0];
|
||||||
|
|
||||||
|
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", () => {
|
||||||
|
expect(shouldCommit(operation(0.6, 0.5), {})).toBe(false);
|
||||||
|
expect(shouldCommit(operation(0.6, 0.6), {})).toBe(true);
|
||||||
|
expect(shouldCommit(operation(0.1, 0.15), {})).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets a fast flick's direction veto the distance", () => {
|
||||||
|
const at = (progress: number, velocity: number) =>
|
||||||
|
({
|
||||||
|
progress,
|
||||||
|
velocity,
|
||||||
|
choreography: { effects: () => ({}) },
|
||||||
|
}) as unknown as Parameters<typeof shouldCommit>[0];
|
||||||
|
// Past the 0.36 threshold, but flicked back toward the start: cancels.
|
||||||
|
expect(shouldCommit(at(0.8, -0.5), {})).toBe(false);
|
||||||
|
// Barely started, but flicked forward: commits.
|
||||||
|
expect(shouldCommit(at(0.05, 0.5), {})).toBe(true);
|
||||||
|
// Slow releases fall back to distance, in either direction.
|
||||||
|
expect(shouldCommit(at(0.8, -0.1), {})).toBe(true);
|
||||||
|
expect(shouldCommit(at(0.2, 0.1), {})).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still yields to an explicit decision", () => {
|
||||||
|
expect(shouldCommit(operation(0.1, 5), { commit: false })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
originView,
|
originView,
|
||||||
replace,
|
replace,
|
||||||
under,
|
under,
|
||||||
} from ".";
|
} from "../src";
|
||||||
|
|
||||||
const mountedApps: Array<ReturnType<typeof createApp>> = [];
|
const mountedApps: Array<ReturnType<typeof createApp>> = [];
|
||||||
|
|
||||||
@@ -9,6 +9,5 @@
|
|||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"tsBuildInfoFile": "../../node_modules/.tmp/core-v2.tsbuildinfo"
|
"tsBuildInfoFile": "../../node_modules/.tmp/core-v2.tsbuildinfo"
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts", "src/**/*.vue"],
|
"include": ["src/**/*.ts", "src/**/*.vue"]
|
||||||
"exclude": ["src/**/*.test.ts"]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
.baseline/
|
.baseline/
|
||||||
|
browser/app/dist/
|
||||||
|
|||||||
@@ -1,27 +1,41 @@
|
|||||||
# Differential verification
|
# Benchmarks
|
||||||
|
|
||||||
Checks a core-v2 implementation against an earlier one (by default `main`)
|
Compares core-v2's performance against an earlier implementation (by default
|
||||||
by driving both with identical randomized inputs and requiring identical
|
`main`).
|
||||||
observable results after every step.
|
|
||||||
|
|
||||||
From the repository root:
|
From the repository root:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
packages/core-v2/verify/setup-baseline.sh main # extract into verify/.baseline/
|
packages/core-v2/verify/setup-baseline.sh main # extract into verify/.baseline/
|
||||||
npx vitest run --config packages/core-v2/verify/vitest.config.ts
|
|
||||||
SEEDS=50 npx vitest run --config packages/core-v2/verify/vitest.config.ts # quicker
|
|
||||||
ONLY=rewrite npx vitest bench --run --config packages/core-v2/verify/vitest.config.ts
|
|
||||||
ONLY=baseline npx vitest bench --run --config packages/core-v2/verify/vitest.config.ts
|
ONLY=baseline npx vitest bench --run --config packages/core-v2/verify/vitest.config.ts
|
||||||
|
ONLY=rewrite npx vitest bench --run --config packages/core-v2/verify/vitest.config.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
- `scene.diff.ts` runs random `begin`/`update`/`finish`/`cancel`/animation
|
`scene.bench.ts` measures compositing, push/back cycles, and rendered frames.
|
||||||
frame sequences against both scenes and compares nodes, operations, roots,
|
Run each implementation in its own process with `ONLY=`; running both in one
|
||||||
every node's composited style, interactivity, contexts, and every promise
|
process biases whichever runs second.
|
||||||
result. It also compares action-helper output and the runtime export list.
|
|
||||||
- `gesture.diff.ts` mounts both packages in Vue apps whose views install the
|
Behavioural verification lives with the unit tests: `tests/property.test.ts`
|
||||||
same gestures (builder definitions, legacy options, `OriginGesture`, and
|
drives random operation sequences and pointer streams and checks the scene's
|
||||||
`OriginGestureSurface`), dispatches identical random pointer streams, and
|
structural invariants after every step. Set `PROPERTY_SEEDS` to run more.
|
||||||
compares scene state and every navigation, start, and completion callback.
|
|
||||||
- `scene.bench.ts` measures compositing, push/back cycles, and rendered
|
## Real-browser feel tests
|
||||||
frames. Run each implementation in its own process with `ONLY=`; running
|
|
||||||
both in one process biases whichever runs second.
|
`browser/` drives a development build in Chromium with emulated touch input,
|
||||||
|
which happy-dom cannot model: implicit touch pointer capture, native
|
||||||
|
scrolling, `pointercancel`, frame timing, and rendered styles. It checks:
|
||||||
|
|
||||||
|
- **recognition** on human-like traces: wobbly and diagonal starts, a small
|
||||||
|
wind-up the wrong way, a twitch at lift-off, slow and short drags, flicks,
|
||||||
|
and vertical scrolls that must not trigger gestures;
|
||||||
|
- **frame pacing** while a finger drags (p95 ≤ 20ms, ≤ 5% of frames over
|
||||||
|
20ms) for a horizontal swipe and a scroll-edge dismiss;
|
||||||
|
- **behaviour**: swipes over nested scrollers, native scrolling beside
|
||||||
|
gestures, scroll-edge dismissal, `onCommit` inside `pointerup`, and the
|
||||||
|
development continuity warning.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run test:feel
|
||||||
|
```
|
||||||
|
|
||||||
|
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,141 @@
|
|||||||
|
import { createApp, defineComponent, h, ref } from "vue";
|
||||||
|
import "../../../src/style.css";
|
||||||
|
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,256 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
// 5. Recognition feel: human-like traces on the Home swipe (over a nested
|
||||||
|
// scroller). Real fingers rarely start cleanly; every trace that should open
|
||||||
|
// must open, and the rest must not.
|
||||||
|
{
|
||||||
|
const steady = (total, steps, ms = 16) => Array.from({ length: steps }, () => [total / steps, 0, ms]);
|
||||||
|
const traces = [
|
||||||
|
["clean swipe", steady(-200, 12), true],
|
||||||
|
["short steady drag (31%)", steady(-120, 10), true],
|
||||||
|
["wobbly start, then swipe", [[-1, 4, 16], [-1, 3, 16], [-1, 2, 16], ...steady(-200, 12)], true],
|
||||||
|
["wind-up 6px the wrong way", [[3, 0, 16], [3, 0, 16], ...steady(-206, 12)], true],
|
||||||
|
["diagonal first 10px", [[-5, -5, 16], [-5, -4, 16], ...steady(-190, 11)], true],
|
||||||
|
["backward twitch at lift-off", [...steady(-200, 12), [6, 0, 12]], true],
|
||||||
|
["slow drag to 50%, hold, release", [...steady(-195, 40), [0, 0, 120]], true],
|
||||||
|
["slow drag to 20%, hold, release", [...steady(-80, 20), [0, 0, 120]], false],
|
||||||
|
["quick flick (15%)", steady(-60, 4, 10), true],
|
||||||
|
["vertical scroll", Array.from({ length: 12 }, () => [1, -25, 16]), false],
|
||||||
|
];
|
||||||
|
for (const [name, steps, shouldOpen] of traces) {
|
||||||
|
const t = await open();
|
||||||
|
let [x, y] = [330, 400];
|
||||||
|
await t.cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x, y }] });
|
||||||
|
for (const [dx, dy, ms] of steps) {
|
||||||
|
x += dx; y += dy;
|
||||||
|
if (dx || dy) await t.cdp.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x, y }] });
|
||||||
|
await t.page.waitForTimeout(ms);
|
||||||
|
}
|
||||||
|
await t.cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
|
||||||
|
await t.page.waitForTimeout(800);
|
||||||
|
const opened = (await states(t.page)) === "root:parked detail:active";
|
||||||
|
check(`recognition: ${name} ${shouldOpen ? "opens" : "stays"}`, opened === shouldOpen, opened ? "opened" : "stayed");
|
||||||
|
await t.page.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Frame pacing while a finger drags: no dropped frames from blocking
|
||||||
|
// touch handling. Regressions measured p95 ≈ 28ms and ~30% of frames > 20ms.
|
||||||
|
async function pacing(query, setup, from, to) {
|
||||||
|
const gaps = [];
|
||||||
|
for (let run = 0; run < 6; run++) {
|
||||||
|
const t = await open(query);
|
||||||
|
await setup(t);
|
||||||
|
await t.page.evaluate(() => { window.f = []; const loop = () => { window.f.push(performance.now()); window.r = requestAnimationFrame(loop); }; window.r = requestAnimationFrame(loop); });
|
||||||
|
await t.cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x: from[0], y: from[1] }] });
|
||||||
|
for (let i = 1; i <= 40; i++) {
|
||||||
|
await t.cdp.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x: from[0] + ((to[0] - from[0]) * i) / 40, y: from[1] + ((to[1] - from[1]) * i) / 40 }] });
|
||||||
|
await t.page.waitForTimeout(16);
|
||||||
|
}
|
||||||
|
const f = await t.page.evaluate(() => { cancelAnimationFrame(window.r); return window.f; });
|
||||||
|
await t.cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
|
||||||
|
gaps.push(...f.slice(1).map((v, i) => v - f[i]));
|
||||||
|
await t.page.close();
|
||||||
|
}
|
||||||
|
gaps.sort((a, b) => a - b);
|
||||||
|
return { p95: gaps[Math.floor(0.95 * (gaps.length - 1))], over20: gaps.filter((g) => g > 20).length / gaps.length };
|
||||||
|
}
|
||||||
|
const openDetail = async (t) => { await drag(t, [340, 400], [60, 405]); };
|
||||||
|
for (const [name, result] of [
|
||||||
|
["horizontal swipe drag", await pacing("", async () => {}, [340, 300], [40, 300])],
|
||||||
|
["scroll-edge dismiss drag", await pacing("", openDetail, [200, 200], [200, 600])],
|
||||||
|
]) {
|
||||||
|
check(
|
||||||
|
`pacing: ${name} p95 ≤ 20ms and ≤ 5% of frames over 20ms`,
|
||||||
|
result.p95 <= 20 && result.over20 <= 0.05,
|
||||||
|
`p95=${result.p95.toFixed(1)}ms over20=${(result.over20 * 100).toFixed(1)}%`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
@@ -1,381 +0,0 @@
|
|||||||
/**
|
|
||||||
* Differential test for gesture recognition: the baseline and rewritten
|
|
||||||
* packages each render a scene whose views install equivalent gestures
|
|
||||||
* (builder, legacy options, OriginGesture, OriginGestureSurface). Identical
|
|
||||||
* random pointer streams must yield identical scenes and identical callbacks.
|
|
||||||
*/
|
|
||||||
import { createApp, defineComponent, h, nextTick, type App } from "vue";
|
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
|
||||||
import * as next from "../src";
|
|
||||||
import * as base from "./.baseline";
|
|
||||||
import { flush, installFrames, rng, snapshot } from "./harness";
|
|
||||||
|
|
||||||
type Impl = typeof next;
|
|
||||||
const impls = [base as unknown as Impl, next] as const;
|
|
||||||
const apps: App[] = [];
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
for (const app of apps.splice(0)) app.unmount();
|
|
||||||
document.body.innerHTML = "";
|
|
||||||
});
|
|
||||||
|
|
||||||
const WIDTH = 300;
|
|
||||||
const HEIGHT = 600;
|
|
||||||
|
|
||||||
function stubBounds(element: HTMLElement) {
|
|
||||||
if ((element as { stubbed?: boolean }).stubbed) return;
|
|
||||||
(element as { stubbed?: boolean }).stubbed = true;
|
|
||||||
Object.defineProperties(element, {
|
|
||||||
clientWidth: { configurable: true, value: WIDTH },
|
|
||||||
clientHeight: { configurable: true, value: HEIGHT },
|
|
||||||
});
|
|
||||||
element.getBoundingClientRect = () =>
|
|
||||||
({
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: WIDTH,
|
|
||||||
bottom: HEIGHT,
|
|
||||||
width: WIDTH,
|
|
||||||
height: HEIGHT,
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
toJSON: () => ({}),
|
|
||||||
}) as DOMRect;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pointOf(point: next.OriginGesturePoint) {
|
|
||||||
return [point.clientX, point.clientY, point.localX, point.localY];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build one implementation's views, gestures, and callback recorder. */
|
|
||||||
function buildWorld(M: Impl, seed: number) {
|
|
||||||
const record: unknown[] = [];
|
|
||||||
const r = rng(seed);
|
|
||||||
const tick = async (count: number) => {
|
|
||||||
for (let i = 0; i < count; i += 1) await Promise.resolve();
|
|
||||||
};
|
|
||||||
const views: Record<string, next.OriginView> = {};
|
|
||||||
|
|
||||||
const slide = M.defineOriginChoreography({
|
|
||||||
name: "slide",
|
|
||||||
effects: ({ progress }) => ({
|
|
||||||
source: { transform: `translateX(${-progress * 100}px)` },
|
|
||||||
target: { transform: `translateX(${(1 - progress) * 100}px)` },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const drawer = M.defineOriginChoreography({
|
|
||||||
name: "drawer",
|
|
||||||
persistAtRest: true,
|
|
||||||
effects: ({ progress }) => ({
|
|
||||||
source: { transform: `translateY(${progress * 50}%)` },
|
|
||||||
target: { opacity: progress },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Asynchronous navigation delays are fixed per world so both match.
|
|
||||||
const delays = Array.from({ length: 64 }, () => r.int(4));
|
|
||||||
let delayIndex = 0;
|
|
||||||
const delay = () => tick(delays[delayIndex++ % delays.length]!);
|
|
||||||
|
|
||||||
const surfaceGestures = [
|
|
||||||
M.gesture.to
|
|
||||||
.left()
|
|
||||||
.navigate(async (context) => {
|
|
||||||
record.push(["left", context.nodeKey]);
|
|
||||||
await delay();
|
|
||||||
return M.forward(views.B!);
|
|
||||||
})
|
|
||||||
.animate(slide),
|
|
||||||
M.gesture.from
|
|
||||||
.left(40)
|
|
||||||
.to.right()
|
|
||||||
.navigate((context) => {
|
|
||||||
record.push(["right", context.nodeKey, context.canGoBack]);
|
|
||||||
return context.canGoBack ? M.back() : null;
|
|
||||||
})
|
|
||||||
.animate(M.slideRight),
|
|
||||||
M.gesture.from
|
|
||||||
.when(({ point, bounds, origin }) => {
|
|
||||||
record.push(["when", origin.nodeKey, pointOf(point), bounds.width]);
|
|
||||||
return point.localY < HEIGHT / 2;
|
|
||||||
})
|
|
||||||
.to.down({ threshold: 12, axisDominance: 1.5 })
|
|
||||||
.complete((context) => {
|
|
||||||
record.push([
|
|
||||||
"complete",
|
|
||||||
context.origin.nodeKey,
|
|
||||||
context.direction,
|
|
||||||
context.progress,
|
|
||||||
context.velocity,
|
|
||||||
context.distance,
|
|
||||||
context.crossDistance,
|
|
||||||
context.duration,
|
|
||||||
pointOf(context.start),
|
|
||||||
pointOf(context.current),
|
|
||||||
context.bounds,
|
|
||||||
]);
|
|
||||||
return context.progress > 0.2;
|
|
||||||
})
|
|
||||||
.navigate(() => M.forward(views.C!, { placement: "under" }))
|
|
||||||
.animate(drawer),
|
|
||||||
M.gesture.from
|
|
||||||
.bottom(120)
|
|
||||||
.to.up()
|
|
||||||
.navigate(async (context) => {
|
|
||||||
await delay();
|
|
||||||
if (delays[delayIndex % delays.length] === 0) throw new Error("nav");
|
|
||||||
return context.canGoBack ? M.back() : M.replace(views.A!);
|
|
||||||
})
|
|
||||||
.animate(M.fade),
|
|
||||||
];
|
|
||||||
|
|
||||||
const A = defineComponent({
|
|
||||||
name: "A",
|
|
||||||
setup: () => () =>
|
|
||||||
h(
|
|
||||||
M.OriginGestureSurface,
|
|
||||||
{ gestures: surfaceGestures, "data-gesture-host": "" },
|
|
||||||
() => [h("input", { "data-input": "" }), "A"],
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
const bGestures = [
|
|
||||||
M.gesture.to
|
|
||||||
.left({ threshold: 6 })
|
|
||||||
.navigate((context) => {
|
|
||||||
record.push(["b-left", context.nodeKey, context.history.length]);
|
|
||||||
return M.forward(views.C!);
|
|
||||||
})
|
|
||||||
.animate(slide),
|
|
||||||
M.gesture.to
|
|
||||||
.right({ threshold: 6 })
|
|
||||||
.navigate((context) => (context.canGoBack ? M.back() : null))
|
|
||||||
.animate(M.slideRight),
|
|
||||||
];
|
|
||||||
const B = defineComponent({
|
|
||||||
name: "B",
|
|
||||||
setup() {
|
|
||||||
// Bind each recognizer directly instead of through a component.
|
|
||||||
const bindings = bGestures.map((definition) =>
|
|
||||||
M.useOriginGesture(definition),
|
|
||||||
);
|
|
||||||
const each =
|
|
||||||
(name: "onPointerdown" | "onPointermove" | "onPointerup") =>
|
|
||||||
(event: PointerEvent) =>
|
|
||||||
bindings.forEach((binding) => binding[name](event));
|
|
||||||
return () =>
|
|
||||||
h(
|
|
||||||
"div",
|
|
||||||
{
|
|
||||||
"data-gesture-host": "",
|
|
||||||
style: bindings[0]!.style,
|
|
||||||
onPointerdown: each("onPointerdown"),
|
|
||||||
onPointermove: each("onPointermove"),
|
|
||||||
onPointerup: each("onPointerup"),
|
|
||||||
onPointercancel: () =>
|
|
||||||
bindings.forEach((binding) => binding.onPointercancel()),
|
|
||||||
},
|
|
||||||
[h("input", { "data-input": "" }), "B"],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const C = defineComponent({
|
|
||||||
name: "C",
|
|
||||||
setup: () => () =>
|
|
||||||
h(
|
|
||||||
M.OriginGesture,
|
|
||||||
{
|
|
||||||
"data-gesture-host": "",
|
|
||||||
direction: "up",
|
|
||||||
edge: 200,
|
|
||||||
threshold: 4,
|
|
||||||
action: (context: next.OriginContext) => {
|
|
||||||
record.push(["legacy", context.nodeKey]);
|
|
||||||
return context.canGoBack
|
|
||||||
? M.back(slide)
|
|
||||||
: M.forward(views.B!, slide);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
() => [h("input", { "data-input": "" }), "C"],
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
views.A = M.originView(A, undefined, { key: "a" });
|
|
||||||
views.B = M.originView(B, undefined, { key: "b" });
|
|
||||||
views.C = M.originView(C, undefined, { key: "c" });
|
|
||||||
|
|
||||||
const scene = M.createOriginScene({ initial: views.A });
|
|
||||||
const root = document.createElement("div");
|
|
||||||
document.body.append(root);
|
|
||||||
const app = createApp({ render: () => h(M.OriginScene, { scene }) });
|
|
||||||
apps.push(app);
|
|
||||||
app.mount(root);
|
|
||||||
return { scene, root, record };
|
|
||||||
}
|
|
||||||
|
|
||||||
function hostFor(root: HTMLElement, nodeKey: string) {
|
|
||||||
const host = root.querySelector(
|
|
||||||
`[data-origin-node="${CSS.escape(nodeKey)}"] [data-gesture-host]`,
|
|
||||||
) as HTMLElement | null;
|
|
||||||
if (host) stubBounds(host);
|
|
||||||
return host;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pointerEvent(
|
|
||||||
type: string,
|
|
||||||
init: PointerEventInit,
|
|
||||||
timeStamp: number,
|
|
||||||
): PointerEvent {
|
|
||||||
const event = new PointerEvent(type, {
|
|
||||||
bubbles: true,
|
|
||||||
cancelable: true,
|
|
||||||
...init,
|
|
||||||
});
|
|
||||||
Object.defineProperty(event, "timeStamp", { value: timeStamp });
|
|
||||||
return event;
|
|
||||||
}
|
|
||||||
|
|
||||||
const stats = {
|
|
||||||
maxNodes: 0,
|
|
||||||
maxOps: 0,
|
|
||||||
opSteps: 0,
|
|
||||||
records: {} as Record<string, number>,
|
|
||||||
finalNodes: {} as Record<number, number>,
|
|
||||||
};
|
|
||||||
|
|
||||||
async function runSequence(seed: number, steps: number) {
|
|
||||||
const r = rng(seed * 7919);
|
|
||||||
const frames = installFrames();
|
|
||||||
const worlds = impls.map((M) => buildWorld(M, seed));
|
|
||||||
await nextTick();
|
|
||||||
let time = 100;
|
|
||||||
let x = 0;
|
|
||||||
let y = 0;
|
|
||||||
let pointerId = 1;
|
|
||||||
let nodeKey = "";
|
|
||||||
let heading: next.OriginGestureDirection = "left";
|
|
||||||
let open = false;
|
|
||||||
const log: string[] = [];
|
|
||||||
|
|
||||||
const check = (label: string) => {
|
|
||||||
const message = `${label}\n${log.join("\n")}`;
|
|
||||||
expect(snapshot(worlds[1]!.scene), message).toEqual(
|
|
||||||
snapshot(worlds[0]!.scene),
|
|
||||||
);
|
|
||||||
expect(worlds[1]!.record, message).toEqual(worlds[0]!.record);
|
|
||||||
};
|
|
||||||
|
|
||||||
const dispatch = (type: string, init: PointerEventInit, onInput = false) => {
|
|
||||||
for (const world of worlds) {
|
|
||||||
const host = hostFor(world.root, nodeKey);
|
|
||||||
if (!host) continue;
|
|
||||||
const target = onInput
|
|
||||||
? (host.querySelector("[data-input]") as HTMLElement)
|
|
||||||
: host;
|
|
||||||
target.dispatchEvent(pointerEvent(type, init, time));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
for (let step = 0; step < steps; step += 1) {
|
|
||||||
const roll = r.next();
|
|
||||||
time += r.int(40);
|
|
||||||
if (roll < 0.15) {
|
|
||||||
if (open && r.chance(0.8)) {
|
|
||||||
log.push(`up ${x},${y} t=${time}`);
|
|
||||||
dispatch("pointerup", { clientX: x, clientY: y, pointerId });
|
|
||||||
await flush(3);
|
|
||||||
}
|
|
||||||
open = true;
|
|
||||||
const nodes = worlds[0]!.scene.nodes.value;
|
|
||||||
const scene = worlds[0]!.scene;
|
|
||||||
const interactive = nodes.filter((node) =>
|
|
||||||
scene.isNodeInteractive(node.key),
|
|
||||||
);
|
|
||||||
nodeKey = (
|
|
||||||
interactive.length && r.chance(0.85)
|
|
||||||
? r.pick(interactive)
|
|
||||||
: r.pick(nodes)
|
|
||||||
).key;
|
|
||||||
x = r.chance(0.3) ? r.int(50) : r.int(WIDTH);
|
|
||||||
y = r.chance(0.3) ? HEIGHT - r.int(130) : r.int(HEIGHT);
|
|
||||||
pointerId = r.chance(0.9) ? 1 : 2;
|
|
||||||
const init = {
|
|
||||||
clientX: x,
|
|
||||||
clientY: y,
|
|
||||||
pointerId,
|
|
||||||
button: r.chance(0.95) ? 0 : 1,
|
|
||||||
isPrimary: r.chance(0.95),
|
|
||||||
};
|
|
||||||
heading = r.pick(["left", "right", "up", "down"] as const);
|
|
||||||
log.push(`down ${nodeKey} ${JSON.stringify(init)} t=${time}`);
|
|
||||||
dispatch("pointerdown", init, r.chance(0.08));
|
|
||||||
} else if (roll < 0.65) {
|
|
||||||
const jitter = r.int(9) - 4;
|
|
||||||
if (r.chance(0.75)) {
|
|
||||||
const amount = r.chance(0.1) ? 0 : 5 + r.int(55);
|
|
||||||
if (heading === "left") x -= amount;
|
|
||||||
if (heading === "right") x += amount;
|
|
||||||
if (heading === "up") y -= amount;
|
|
||||||
if (heading === "down") y += amount;
|
|
||||||
if (heading === "left" || heading === "right") y += jitter;
|
|
||||||
else x += jitter;
|
|
||||||
} else {
|
|
||||||
x += r.int(70) - 35;
|
|
||||||
y += r.int(70) - 35;
|
|
||||||
}
|
|
||||||
log.push(`move ${x},${y} t=${time}`);
|
|
||||||
dispatch("pointermove", { clientX: x, clientY: y, pointerId });
|
|
||||||
} else if (roll < 0.85) {
|
|
||||||
open = false;
|
|
||||||
log.push(`up ${x},${y} t=${time}`);
|
|
||||||
dispatch("pointerup", { clientX: x, clientY: y, pointerId });
|
|
||||||
} else if (roll < 0.9) {
|
|
||||||
open = false;
|
|
||||||
log.push(`cancel`);
|
|
||||||
dispatch("pointercancel", { pointerId });
|
|
||||||
} else {
|
|
||||||
const count = 1 + r.int(20);
|
|
||||||
log.push(`frames ${count}`);
|
|
||||||
for (let i = 0; i < count; i += 1) frames.advance(16);
|
|
||||||
}
|
|
||||||
if (r.chance(0.7)) {
|
|
||||||
frames.advance(16);
|
|
||||||
frames.advance(16);
|
|
||||||
}
|
|
||||||
if (!r.chance(0.2)) await flush(3);
|
|
||||||
check(`seed ${seed} step ${step}`);
|
|
||||||
stats.maxNodes = Math.max(
|
|
||||||
stats.maxNodes,
|
|
||||||
worlds[0]!.scene.nodes.value.length,
|
|
||||||
);
|
|
||||||
stats.maxOps = Math.max(
|
|
||||||
stats.maxOps,
|
|
||||||
worlds[0]!.scene.operations.value.length,
|
|
||||||
);
|
|
||||||
stats.opSteps += worlds[0]!.scene.operations.value.length ? 1 : 0;
|
|
||||||
}
|
|
||||||
for (const entry of worlds[0]!.record as unknown[][])
|
|
||||||
stats.records[entry[0] as string] =
|
|
||||||
(stats.records[entry[0] as string] ?? 0) + 1;
|
|
||||||
stats.finalNodes[worlds[0]!.scene.nodes.value.length] =
|
|
||||||
(stats.finalNodes[worlds[0]!.scene.nodes.value.length] ?? 0) + 1;
|
|
||||||
|
|
||||||
for (let i = 0; i < 400 && frames.pending(); i += 1) frames.advance(16);
|
|
||||||
await flush();
|
|
||||||
check(`seed ${seed} drained`);
|
|
||||||
return worlds[0]!.scene.nodes.value.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("gesture: baseline vs rewrite", () => {
|
|
||||||
const seeds = Number(process.env.SEEDS ?? 200);
|
|
||||||
for (let seed = 1; seed <= seeds; seed += 1) {
|
|
||||||
it(`random pointer stream ${seed}`, async () => {
|
|
||||||
await runSequence(seed, 120);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
it("reached multi-node scenes", () => {
|
|
||||||
console.log("STATS", JSON.stringify(stats));
|
|
||||||
expect(stats.maxNodes).toBeGreaterThan(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,110 +1,5 @@
|
|||||||
import { defineComponent, h, type Component } from "vue";
|
import { defineComponent, h, type Component } from "vue";
|
||||||
import type { OriginScene } from "../src";
|
|
||||||
|
|
||||||
export function rng(seed: number) {
|
|
||||||
let a = seed >>> 0;
|
|
||||||
const next = () => {
|
|
||||||
a = (a + 0x6d2b79f5) >>> 0;
|
|
||||||
let t = a;
|
|
||||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
||||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
||||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
next,
|
|
||||||
int: (n: number) => Math.floor(next() * n),
|
|
||||||
pick: <T>(items: readonly T[]) => items[Math.floor(next() * items.length)]!,
|
|
||||||
chance: (p: number) => next() < p,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function component(name: string): Component {
|
export function component(name: string): Component {
|
||||||
return defineComponent({ name, render: () => h("div", name) });
|
return defineComponent({ name, render: () => h("div", name) });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Deterministic rAF + clock shared by both implementations. */
|
|
||||||
export function installFrames() {
|
|
||||||
let now = 1000;
|
|
||||||
let queue: FrameRequestCallback[] = [];
|
|
||||||
window.requestAnimationFrame = ((cb: FrameRequestCallback) => {
|
|
||||||
queue.push(cb);
|
|
||||||
return queue.length;
|
|
||||||
}) as typeof window.requestAnimationFrame;
|
|
||||||
globalThis.requestAnimationFrame = window.requestAnimationFrame;
|
|
||||||
const perf = performance;
|
|
||||||
perf.now = () => now;
|
|
||||||
return {
|
|
||||||
advance(ms = 16) {
|
|
||||||
now += ms;
|
|
||||||
const run = queue;
|
|
||||||
queue = [];
|
|
||||||
for (const cb of run) cb(now);
|
|
||||||
},
|
|
||||||
pending: () => queue.length,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function flush(rounds = 6) {
|
|
||||||
for (let i = 0; i < rounds; i += 1)
|
|
||||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Implementation-agnostic, comparable snapshot of a scene. */
|
|
||||||
export function snapshot(scene: OriginScene) {
|
|
||||||
const nodes = scene.nodes.value;
|
|
||||||
return {
|
|
||||||
nodes: nodes.map((node) => ({
|
|
||||||
key: node.key,
|
|
||||||
sequence: node.sequence,
|
|
||||||
view: node.view.key,
|
|
||||||
previousNodeKey: node.previousNodeKey,
|
|
||||||
state: node.state,
|
|
||||||
incomingOperationId: node.incomingOperationId,
|
|
||||||
history: node.history.map((view) => view.key),
|
|
||||||
})),
|
|
||||||
operations: scene.operations.value.map((operation) => ({
|
|
||||||
id: operation.id,
|
|
||||||
originKey: operation.originKey,
|
|
||||||
targetKey: operation.targetKey,
|
|
||||||
entryTargetKey: operation.entryTargetKey,
|
|
||||||
choreography: operation.choreography.name,
|
|
||||||
placement: operation.placement,
|
|
||||||
history: operation.history,
|
|
||||||
progress: operation.progress,
|
|
||||||
velocity: operation.velocity,
|
|
||||||
phase: operation.phase,
|
|
||||||
intent: operation.intent,
|
|
||||||
})),
|
|
||||||
roots: [...scene.roots.value],
|
|
||||||
styles: Object.fromEntries(
|
|
||||||
nodes.map((node) => [node.key, scene.styleForNode(node.key)]),
|
|
||||||
),
|
|
||||||
interactive: Object.fromEntries(
|
|
||||||
nodes.map((node) => [node.key, scene.isNodeInteractive(node.key)]),
|
|
||||||
),
|
|
||||||
contexts: Object.fromEntries(
|
|
||||||
nodes.map((node) => {
|
|
||||||
const context = scene.contextFor(node.key);
|
|
||||||
return [
|
|
||||||
node.key,
|
|
||||||
{
|
|
||||||
nodeKey: context.nodeKey,
|
|
||||||
view: context.view.key,
|
|
||||||
canGoBack: context.canGoBack,
|
|
||||||
previous: context.previous?.key,
|
|
||||||
history: context.history.map((view) => view.key),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
missingStyle: scene.styleForNode("missing::0"),
|
|
||||||
missingInteractive: scene.isNodeInteractive("missing::0"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function settle<T>(promise: Promise<T>) {
|
|
||||||
return promise.then(
|
|
||||||
(value) => ({ ok: true as const, value }),
|
|
||||||
(error: Error) => ({ ok: false as const, error: error.message }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,285 +0,0 @@
|
|||||||
/**
|
|
||||||
* Differential test: drives the baseline (main) and rewritten scenes with the
|
|
||||||
* same random operation sequences and requires identical observable state
|
|
||||||
* after every step.
|
|
||||||
*/
|
|
||||||
import { afterAll, describe, expect, it } from "vitest";
|
|
||||||
import * as next from "../src";
|
|
||||||
import * as base from "./.baseline";
|
|
||||||
import {
|
|
||||||
component,
|
|
||||||
flush,
|
|
||||||
installFrames,
|
|
||||||
rng,
|
|
||||||
settle,
|
|
||||||
snapshot,
|
|
||||||
} from "./harness";
|
|
||||||
|
|
||||||
type Impl = typeof next;
|
|
||||||
const impls = [base as unknown as Impl, next] as const;
|
|
||||||
|
|
||||||
const css = (style: Record<string, string>) => style as never;
|
|
||||||
|
|
||||||
const choreographies = [
|
|
||||||
next.defineOriginChoreography({
|
|
||||||
name: "layered",
|
|
||||||
effects: ({ progress }) => ({
|
|
||||||
source: { transform: `translateX(${-progress * 100}px)` },
|
|
||||||
target: { transform: `translateX(${(1 - progress) * 100}px)` },
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
next.defineOriginChoreography({
|
|
||||||
name: "probe",
|
|
||||||
commitThreshold: 0.5,
|
|
||||||
effects: (c) => ({
|
|
||||||
frame: { style: css({ filter: `blur(${c.progress}px)` }), layer: 1 },
|
|
||||||
source: {
|
|
||||||
opacity: 1 - c.progress,
|
|
||||||
style: css({
|
|
||||||
opacity: "0.9",
|
|
||||||
transform: `rotate(${c.velocity}deg)`,
|
|
||||||
"--state": `${c.phase}/${c.intent}`,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
target: {
|
|
||||||
transform: c.progress > 0.5 ? "none" : `scale(${c.progress})`,
|
|
||||||
layer: 2,
|
|
||||||
style: css({
|
|
||||||
borderRadius: `${c.viewport.width}x${c.viewport.height}`,
|
|
||||||
"--rects": JSON.stringify([
|
|
||||||
c.originRect ?? null,
|
|
||||||
c.targetRect ?? null,
|
|
||||||
]),
|
|
||||||
position: "fixed",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
next.defineOriginChoreography({
|
|
||||||
name: "drawer-open",
|
|
||||||
persistAtRest: true,
|
|
||||||
effects: ({ progress }) => ({
|
|
||||||
source: { transform: `translateX(${progress * 66.6667}%)` },
|
|
||||||
target: { transform: `translateX(${(progress - 1) * 66.6667}%)` },
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
next.defineOriginChoreography({
|
|
||||||
name: "drawer-close",
|
|
||||||
commitThreshold: 0.2,
|
|
||||||
effects: ({ progress }) => ({
|
|
||||||
source: { transform: `translateX(${-progress * 66.6667}%)` },
|
|
||||||
target: { transform: `translateX(${(1 - progress) * 66.6667}%)` },
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
next.slideLeft,
|
|
||||||
next.slideRight,
|
|
||||||
next.fade,
|
|
||||||
];
|
|
||||||
|
|
||||||
const views = ["A", "B", "C", "D", "E"].map((name) =>
|
|
||||||
next.originView(component(name), undefined, { key: name.toLowerCase() }),
|
|
||||||
);
|
|
||||||
|
|
||||||
function randomAction(r: ReturnType<typeof rng>): next.OriginAction {
|
|
||||||
const choreography = r.pick(choreographies);
|
|
||||||
const target = r.pick(views);
|
|
||||||
const placement = r.pick(["above", "under", undefined] as const);
|
|
||||||
switch (r.int(6)) {
|
|
||||||
case 0:
|
|
||||||
return next.forward(target, choreography, { placement });
|
|
||||||
case 1:
|
|
||||||
return next.replace(target, choreography, { placement });
|
|
||||||
case 2:
|
|
||||||
case 3:
|
|
||||||
return next.back(choreography, { placement });
|
|
||||||
case 4:
|
|
||||||
return next.above(target, choreography, {
|
|
||||||
history: r.pick(["push", "replace", "back", undefined] as const),
|
|
||||||
});
|
|
||||||
default:
|
|
||||||
return next.under(target, choreography, {
|
|
||||||
history: r.pick(["push", "back"] as const),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type Pair<T> = [T, T];
|
|
||||||
export const stats = {
|
|
||||||
undecidedFinishes: 0,
|
|
||||||
boundary: 0,
|
|
||||||
begins: 0,
|
|
||||||
beginOk: 0,
|
|
||||||
};
|
|
||||||
interface Slot {
|
|
||||||
done: [boolean, boolean];
|
|
||||||
value: [unknown, unknown];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runSequence(seed: number, steps: number) {
|
|
||||||
const r = rng(seed);
|
|
||||||
const frames = installFrames();
|
|
||||||
const scenes = impls.map((impl) =>
|
|
||||||
impl.createOriginScene({
|
|
||||||
initial: seed % 3 === 0 ? [views[0]!, views[1]!] : views[0]!,
|
|
||||||
}),
|
|
||||||
) as Pair<next.OriginScene>;
|
|
||||||
const handles: Pair<next.OriginOperationHandle>[] = [];
|
|
||||||
const slots: Slot[] = [];
|
|
||||||
const log: string[] = [];
|
|
||||||
|
|
||||||
const track = (promises: Pair<Promise<unknown>>) => {
|
|
||||||
const slot: Slot = { done: [false, false], value: [undefined, undefined] };
|
|
||||||
promises.forEach(
|
|
||||||
(promise, index) =>
|
|
||||||
void settle(promise).then((value) => {
|
|
||||||
slot.done[index] = true;
|
|
||||||
slot.value[index] = value;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
slots.push(slot);
|
|
||||||
return slot;
|
|
||||||
};
|
|
||||||
|
|
||||||
const check = (label: string) => {
|
|
||||||
expect(snapshot(scenes[1]), `${label}\n${log.join("\n")}`).toEqual(
|
|
||||||
snapshot(scenes[0]),
|
|
||||||
);
|
|
||||||
for (const slot of slots) {
|
|
||||||
expect(slot.done[1], label).toBe(slot.done[0]);
|
|
||||||
expect(slot.value[1], label).toEqual(slot.value[0]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const pickHandle = () => {
|
|
||||||
const liveIds = new Set(scenes[0].operations.value.map((o) => o.id));
|
|
||||||
const live = handles.filter(([handle]) => liveIds.has(handle.id));
|
|
||||||
return live.length && r.chance(0.85) ? r.pick(live) : r.pick(handles);
|
|
||||||
};
|
|
||||||
const boundaryUpdate = (pair: Pair<next.OriginOperationHandle>) => {
|
|
||||||
// Bias toward the commit-decision boundaries.
|
|
||||||
const progress = r.chance(0.4)
|
|
||||||
? r.pick([0, 0.2, 0.35999, 0.36, 0.49999, 0.5, 1])
|
|
||||||
: r.next() * 1.4 - 0.2;
|
|
||||||
const velocity = r.chance(0.2)
|
|
||||||
? undefined
|
|
||||||
: r.chance(0.4)
|
|
||||||
? r.pick([-0.3, 0, 0.29999, 0.3, 0.30001, 0.31])
|
|
||||||
: r.next() * 4 - 2;
|
|
||||||
log.push(`update ${pair[0].id} ${progress} ${velocity}`);
|
|
||||||
pair.forEach((handle) => handle.update(progress, velocity));
|
|
||||||
};
|
|
||||||
|
|
||||||
for (let step = 0; step < steps; step += 1) {
|
|
||||||
const roll = r.next();
|
|
||||||
const nodeKeys = scenes[0].nodes.value.map((node) => node.key);
|
|
||||||
if (roll < 0.3 || handles.length === 0) {
|
|
||||||
const originKey = r.chance(0.03) ? "missing::0" : r.pick(nodeKeys);
|
|
||||||
const action = randomAction(r);
|
|
||||||
log.push(
|
|
||||||
`begin ${originKey} ${action.history} ${action.choreography.name}`,
|
|
||||||
);
|
|
||||||
const slot = track(
|
|
||||||
scenes.map((scene) => scene.begin(originKey, action)) as Pair<
|
|
||||||
Promise<next.OriginOperationHandle>
|
|
||||||
>,
|
|
||||||
);
|
|
||||||
await flush();
|
|
||||||
if (slot.done[0] && slot.done[1]) {
|
|
||||||
const [a, b] = slot.value as Pair<
|
|
||||||
{ ok: true; value: next.OriginOperationHandle } | { ok: false }
|
|
||||||
>;
|
|
||||||
stats.begins += 1;
|
|
||||||
if (a.ok && b.ok) {
|
|
||||||
stats.beginOk += 1;
|
|
||||||
handles.push([a.value, b.value]);
|
|
||||||
// Handles are compared by identity fields only.
|
|
||||||
slot.value = [
|
|
||||||
{ id: a.value.id, o: a.value.originKey, t: a.value.targetKey },
|
|
||||||
{ id: b.value.id, o: b.value.originKey, t: b.value.targetKey },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (roll < 0.55) {
|
|
||||||
boundaryUpdate(pickHandle());
|
|
||||||
} else if (roll < 0.75) {
|
|
||||||
const pair = pickHandle();
|
|
||||||
if (r.chance(0.5)) boundaryUpdate(pair);
|
|
||||||
const options = {
|
|
||||||
commit: r.pick([true, false, undefined]),
|
|
||||||
animate: r.chance(0.5),
|
|
||||||
};
|
|
||||||
log.push(`finish ${pair[0].id} ${JSON.stringify(options)}`);
|
|
||||||
const live = scenes[0].operations.value.find((o) => o.id === pair[0].id);
|
|
||||||
if (live && options.commit === undefined) {
|
|
||||||
stats.undecidedFinishes += 1;
|
|
||||||
if (live.velocity >= 0.3 && live.velocity < 0.31) stats.boundary += 1;
|
|
||||||
}
|
|
||||||
track(
|
|
||||||
pair.map((handle) => handle.finish(options)) as Pair<Promise<boolean>>,
|
|
||||||
);
|
|
||||||
} else if (roll < 0.82) {
|
|
||||||
const pair = pickHandle();
|
|
||||||
const animate = r.pick([true, false, undefined]);
|
|
||||||
log.push(`cancel ${pair[0].id} ${animate}`);
|
|
||||||
track(
|
|
||||||
pair.map((handle) => handle.cancel({ animate })) as Pair<Promise<void>>,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const count = 1 + r.int(12);
|
|
||||||
log.push(`frames ${count}`);
|
|
||||||
for (let i = 0; i < count; i += 1) frames.advance(4 + r.int(40));
|
|
||||||
}
|
|
||||||
if (!r.chance(0.15)) await flush();
|
|
||||||
check(`seed ${seed} step ${step}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Drain every running spring and compare the resting state.
|
|
||||||
for (let i = 0; i < 400 && frames.pending(); i += 1) frames.advance(16);
|
|
||||||
await flush();
|
|
||||||
check(`seed ${seed} drained`);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("scene: baseline vs rewrite", () => {
|
|
||||||
it("produces identical action and intent objects", () => {
|
|
||||||
const target = views[1]!;
|
|
||||||
const c = choreographies[0]!;
|
|
||||||
const calls: Array<(m: Impl) => unknown> = [
|
|
||||||
(m) => m.originAction(target, c),
|
|
||||||
(m) => m.originAction(target, c, { placement: "under", history: "back" }),
|
|
||||||
(m) => m.forward(target),
|
|
||||||
(m) => m.forward(target, { placement: "under" }),
|
|
||||||
(m) => m.forward(target, c),
|
|
||||||
(m) => m.forward(target, c, { placement: "under" }),
|
|
||||||
(m) => m.replace(target),
|
|
||||||
(m) => m.replace(target, c, {}),
|
|
||||||
(m) => m.back(),
|
|
||||||
(m) => m.back({ placement: "above" }),
|
|
||||||
(m) => m.back(c),
|
|
||||||
(m) => m.back(c, { placement: "above" }),
|
|
||||||
(m) => m.above(target),
|
|
||||||
(m) => m.above(target, { history: "replace" }),
|
|
||||||
(m) => m.above(target, c),
|
|
||||||
(m) => m.above(target, c, { history: "back" }),
|
|
||||||
(m) => m.under(target),
|
|
||||||
(m) => m.under(target, c, { history: "back" }),
|
|
||||||
(m) => m.normalizedEffect(undefined),
|
|
||||||
(m) => m.normalizedEffect({ transform: "x", layer: 2 }, -1),
|
|
||||||
];
|
|
||||||
for (const call of calls) {
|
|
||||||
const [a, b] = impls.map(call);
|
|
||||||
expect(b).toStrictEqual(a);
|
|
||||||
expect(Object.keys(b as object)).toEqual(Object.keys(a as object));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => console.log("STATS", JSON.stringify(stats)));
|
|
||||||
|
|
||||||
it("exports the same runtime names", () => {
|
|
||||||
expect(Object.keys(next).sort()).toEqual(Object.keys(base).sort());
|
|
||||||
});
|
|
||||||
|
|
||||||
const seeds = Number(process.env.SEEDS ?? 300);
|
|
||||||
for (let seed = 1; seed <= seeds; seed += 1) {
|
|
||||||
it(`random sequence ${seed}`, () => runSequence(seed, 60));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -2,7 +2,7 @@ import vue from "@vitejs/plugin-vue";
|
|||||||
import { defineConfig } from "vitest/config";
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Differential verification against a previous core-v2 implementation.
|
* Benchmarks against a previous core-v2 implementation.
|
||||||
* Run `setup-baseline.sh <git-ref>` first; see README.md.
|
* Run `setup-baseline.sh <git-ref>` first; see README.md.
|
||||||
*/
|
*/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
@@ -10,7 +10,6 @@ export default defineConfig({
|
|||||||
test: {
|
test: {
|
||||||
root: __dirname,
|
root: __dirname,
|
||||||
environment: "happy-dom",
|
environment: "happy-dom",
|
||||||
include: ["*.diff.ts"],
|
|
||||||
testTimeout: 120_000,
|
testTimeout: 120_000,
|
||||||
benchmark: { include: ["*.bench.ts"] },
|
benchmark: { include: ["*.bench.ts"] },
|
||||||
},
|
},
|
||||||
|
|||||||
+2
-1
@@ -24,6 +24,7 @@
|
|||||||
"apps/demo/src/**/*.ts",
|
"apps/demo/src/**/*.ts",
|
||||||
"apps/demo/src/**/*.vue",
|
"apps/demo/src/**/*.vue",
|
||||||
"packages/*/src/**/*.ts",
|
"packages/*/src/**/*.ts",
|
||||||
"packages/*/src/**/*.vue"
|
"packages/*/src/**/*.vue",
|
||||||
|
"packages/*/tests/**/*.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user