11 Commits
Author SHA1 Message Date
Claude caf2ad692a Restore native scrolling and reliable recognition; add feel tests
The previous round regressed the demo against main: dropped frames while
a finger drags, and swipes ignored when they start imperfectly.

Frame drops: hosts used touch-action: manipulation plus a blocking
touchmove listener, so Chrome waited for JavaScript on every touch move
(~30% of frames over 20ms in a drag, p95 28ms). Hosts again use per-axis
touch-action like main (pan-y for horizontal gestures, and so on), and
attach no touch listener.

Flaky recognition: an irreversible decision after 6px dropped swipes
with a wobbly or backward start. Recognition again measures from the
pointer-down point until the pointer lifts, as on main.

Scrolling beside gestures, natively:
- .from.scrollEdge() uses direction-specific touch-action while its
  scroller is at the edge (pan-x pan-down for drag-down at the top), the
  pull-to-refresh pattern from the Pointer Events spec. A passive scroll
  listener keeps it current; no touch handling blocks the browser.
- Where directional values are unsupported (Safari), scroll-edge
  gestures alone fall back to claiming touches at the browser's first
  cancelable touchmove.
- Hosts publish touch-action as --nvo-touch-action and the stylesheet
  applies it to descendants, so nested scroll containers keep it and
  horizontal swipes over lists are not cancelled. Ignored subtrees get
  touch-action: auto back.
- Bindings gain listeners and ref (spread on the host); the components
  use them.

Feel tests (npm run test:feel): Chromium with emulated touch checks
recognition on ten human-like traces, frame pacing budgets while
dragging (p95 <= 20ms, <= 5% over 20ms) for a swipe and a scroll-edge
dismiss, and the behaviour checks. Against the previous code they fail
on exactly the reported regressions; now 23/23 pass. On the demo, all
traces match main and drag frame pacing matches or beats it.

Commit thresholds are unchanged (0.25 distance, 0.2 flick).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
2026-11-03 05:56:26 +00:00
Claude 6889fc0112 Make gestures cooperate with scrolling and feel smoother
From an app built on core-v2:

Scrolling (#3, #4, #14)
- Each recognizer locks onto a touch within its first 6px: along its axis
  and direction it holds the touch, otherwise it lets go. A new
  onTouchmove binding cancels native scrolling only while a recognizer
  holds the touch, so hosts use touch-action: manipulation and one
  vertical gesture no longer disables vertical scrolling. Several
  gestures on one host share touches through the same rule.
- .from.scrollEdge(scroller, edge) admits drag-to-dismiss only while a
  scroller is at the edge (#5).

Feel
- recognition.distance sets what counts as full progress: pixels, a CSS
  length, or a measuring function (#6).
- Choreographies receive overshoot, the drag past either end of the
  clamped progress, and the settling spring starts from it (#7).
- Velocity is the least-squares trend of the last 100ms (#8).
- Defaults retuned to commitThreshold 0.25 and commitVelocity 0.2; the
  slide presets use them (#9).
- Choreographies may set spring stiffness and damping (#10).

Hooks and helpers
- .onArm()/.onDisarm() run during a drag when releasing would start or
  stop committing (#12).
- startedIn, nearEdge, atScrollEdge, anyOf, allOf start predicates (#15).
- Development builds warn when an operation's first frame moves a node
  that was visibly at rest, the one-frame "pop" of mismatched open and
  close choreographies (#16).

Docs: scrolling, ignored elements, animation performance, haptics, and
operating-system edge gestures including CloseWatcher/popstate for PWAs
(#11, #17, #18).

Verification: a Chromium harness with emulated touch (verify/browser)
checks swipes over scrollers, native scrolling beside gestures,
scroll-edge dismissal, onCommit inside pointerup, and the continuity
warning. Reverting the earlier capture fix makes it fail exactly as
reported ("none of the handles work").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
2026-10-31 08:22:11 +00:00
Claude d4d7feb3b7 Fix touch gestures cancelling, backward flicks, and late onCommit
- Only the host's own lostpointercapture cancels a gesture. Taking
  capture makes the touched child lose its implicit touch capture, and
  that bubbling event was cancelling every touch gesture immediately.
- A flick at least commitVelocity fast now decides by its direction:
  backward cancels even past the distance threshold, forward commits.
  Slower releases still decide by distance.
- Pointer-up finishes an already-begun operation without awaiting, so
  onCommit runs inside the pointer-up event, where iOS still allows
  haptics, audio, and clipboard writes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
2026-10-31 05:41:51 +00:00
Claude 7033814550 Trim per-release overhead in the scene
Only re-measure the viewport at begin() when a container is mounted, and
only allocate waiting machinery when an interrupted finish() actually
needs to wait for a later release. Interrupted releases now resolve with
the final decided outcome in every case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
2026-10-16 04:28:43 +00:00
Claude a46c411ec4 Move core-v2 tests into packages/core-v2/tests
Tests no longer sit next to the implementation. They import the package
through ../src, the root tsconfig still type-checks them, and the
package build and declarations cover only src/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
2026-10-16 04:24:47 +00:00
Claude 1924d4b52b Close a revealed page's open drawer when its back is cancelled
discardOutgoing skipped finished resting edges, so a persistAtRest drawer
opened by a node revealed during a back operation stayed attached after
that back was cancelled and the node returned to history. Found by the
property tests (1000-seed run).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
2026-10-16 04:10:53 +00:00
Claude b6f782d8af Fix stuck gestures and scene graph races; honour commitVelocity
Gestures
- Cancel a captured gesture on lostpointercapture, on a new primary
  pointer-down, and when its component unmounts, so operations are
  never left stuck mid-transition. Bindings gain onLostpointercapture.
- Warn (console.warn) when navigate() fails or the scene rejects an
  operation; stay quiet when two recognizers race for one origin.
- OriginGesture and OriginGestureSurface props are reactive. Changes
  apply from the next pointer-down; recognizers are kept per list
  position so re-renders never interrupt a drag.
- Remove the legacy direction/edge/threshold/action gesture form
  (OriginGestureOptions, legacy OriginGesture props, MaybeOriginAction).

Scene
- Honour choreography commitVelocity (default 0.3, as before).
- Scene errors are OriginSceneError with a stable code.
- update() during settling now really catches an operation: the spring
  stops, and only the latest finish() decides the outcome.
- Fix graph corruption found by the new property tests, all present on
  main: replacements escaping a cancelled push, edges left dangling when
  their current target differs from the original one, revealed targets
  keeping operations after their back is cancelled, history pointing at
  removed entries after replace, visual cycles from back through an
  undecided replace, exposed nodes reactivated as active, and a cancel
  cascade that could empty the whole scene.

Tests
- Add property tests checking structural invariants after every step of
  random operation sequences and pointer streams (PROPERTY_SEEDS).
- Add recognizer tests for stuck gestures, warnings, reactive props,
  and commitVelocity. Drop the main-comparison suites; keep benchmarks.

Also fix the origins demo's two pre-existing type errors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
2026-10-16 04:09:57 +00:00
Claude 46f030630b Speed up compositing and gesture start without behavior changes
- Memoize each operation's choreography effect set in a computed, so
  nodes sharing an edge stop re-evaluating it (linear, not quadratic,
  in chain depth).
- Cache the scene viewport and refresh it from a ResizeObserver and at
  each begin(), instead of measuring the container per node per frame.
- Index operations by origin key so outgoing lookups no longer scan
  every operation; all map changes go through attach()/detach().
- Resolve px and % gesture edge distances arithmetically; only other
  CSS lengths use the off-screen probe. Verified in Chromium to match
  the probe within its 1/64px layout rounding.

The differential suites against main still match exactly. Benchmarks
vs main: styleForNode 2.0x (3 live ops) and 2.4x (10 live ops),
rendered frames 1.14x.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
2026-10-16 03:26:51 +00:00
Claude 8448e910ed Add .onCommit() to the gesture builder
`.onCommit(handler)` is available at every builder stage after the
direction, returns the same stage, and accumulates handlers in order.
Handlers run once, synchronously at release, when the operation commits
and before the settling animation, with the same context `.complete()`
receives. A throwing handler is reported without stopping later handlers
or the operation. Intended for haptic feedback such as
`navigator.vibrate`.

Named onCommit rather than then so builder stages never become
thenables. The builder now threads one frozen draft through its stages.

OriginOperationHandle gains a read-only `intent`, which the recognizer
reads right after finish() to know whether the release committed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
2026-10-16 03:08:12 +00:00
Claude 511e58c198 Consolidate core-v2 modules and move scene logic onto classes
- Merge the small modules into 12 files: shared, choreography,
  navigation, scene/{types,graph,scene}, gesture/{definition,recognizer}.
- SceneGraph now owns the begin/update/commit/cancel history rules as
  methods, with the step helpers as private methods.
- createOriginScene returns a markRaw'd Scene class; operation handles
  are an OperationHandle class instead of inline object literals.
- GestureRecognizer gains static create()/policyFor() factories, absorbing
  the separate policy module.
- Pure helpers used by classes (matchesStart, pointOf, stepSpring,
  composeNodeStyle, nodeEffectLayers, shouldCommit, ...) are exported
  consts so they can be tested directly.
- Add haptics tests showing where navigator.vibrate can be triggered.

Public exports, types, and behavior are unchanged; the differential
suites against main still pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
2026-10-16 02:49:56 +00:00
Claude 329271691c Restructure core-v2 into layered modules
Rewrite the core-v2 internals without changing the public API or runtime
behavior. The four large files (types, scene, motion, gesture) become
layered modules with one responsibility each:

- shared/: geometry and environment helpers
- view/: originView recipes
- choreography/: effect types, presets, and composeEffects compositing
- actions/: forward/back/replace/above/under via one overload resolver
- scene/: SceneGraph storage, transitions (history rules), compositor
  (styles and interactivity), spring (pure stepper + rAF driver), and
  createOriginScene, which only wires them and owns async timing
- composables/: useOrigin and the node-scope injection key
- gesture/: builder, definition->policy normalization, start admission,
  DirectionalTracker, and a GestureRecognizer session state machine
  replacing fifteen loose closure variables
- components/: renderers and gesture hosts, plus their prop types

Verification (packages/core-v2/verify): differential fuzzing drives
main and this branch with identical random operation sequences and
pointer streams and requires identical scene state, styles, contexts,
callbacks, and promise results at every step. It caught every planted
mutation that applied. Benchmarks are at parity.

Also fix a pre-existing test that called the defineProps macro inside a
runtime defineComponent, and correct API.md's description of the flick
commit threshold to match the runtime.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
2026-10-16 01:15:12 +00:00
41 changed files with 6695 additions and 2735 deletions
@@ -7,6 +7,7 @@ import {
gesture,
originView,
useOrigin,
type OriginView,
} from "@native-vue-router/core-v2";
import { useDemoInstance } from "../lab-state";
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 copy = slides[index]!;
return originView(
@@ -98,7 +99,7 @@ function nestedView(kind: NestedSceneKind, index: number) {
...copy,
next:
index + 1 < slides.length
? () => nestedView(kind, index + 1)
? (): OriginView => nestedView(kind, index + 1)
: undefined,
},
{
+2 -1
View File
@@ -49,7 +49,8 @@
"electron": "npm run build && electron apps/electron/main.mjs",
"cap:sync": "npm run build && npm --prefix apps/capacitor exec cap sync",
"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": {
"@capacitor/app": "^8.0.0",
+328 -32
View File
@@ -20,6 +20,7 @@ This document describes the experimental `0.1.0-experimental.0` API.
- [Scene diagnostics and manual operations](#scene-diagnostics-and-manual-operations)
- [Type reference](#type-reference)
- [Errors and constraints](#errors-and-constraints)
- [Gestures in real apps](#gestures-in-real-apps)
## Gesture start recognition
@@ -43,6 +44,7 @@ gesture.from.top(distance);
gesture.from.bottom(distance);
gesture.from.anywhere();
gesture.from.when(predicate);
gesture.from.scrollEdge(scroller, edge);
```
`distance` accepts a number in CSS pixels or a CSS length string, including
@@ -80,9 +82,43 @@ const dropDialog = gesture.from
.animate(dropAnimation);
```
Interactive form controls are ignored automatically. Add
`.from.scrollEdge(scroller, edge)` admits pointer-down only while a scroll
container is scrolled all the way to `edge`. It is the drag-down-to-dismiss
rule for sheets and pushed pages: at the top, dragging down dismisses and
dragging up still scrolls; anywhere else the list just scrolls. `scroller` is
an element, a ref or getter returning one, or a CSS selector resolved inside
the gesture host. A scroller that is not mounted counts as at the edge.
```ts
const list = ref<HTMLElement | null>(null);
const dismiss = gesture.from
.scrollEdge(list, "top")
.to.down()
.navigate((context) => (context.canGoBack ? back() : null))
.animate(sheetDown);
```
Start rules also come as composable predicates for `.from.when()`:
| Helper | Admits pointer-down |
| ------------------------------ | ------------------------------------------- |
| `startedIn(selector)` | On or inside an element matching `selector` |
| `nearEdge(edge, distance)` | Within `distance` of one side of the host |
| `atScrollEdge(scroller, edge)` | While `scroller` is scrolled to `edge` |
| `anyOf(...predicates)` | When any predicate does |
| `allOf(...predicates)` | When every predicate does |
```ts
gesture.from
.when(anyOf(startedIn(".sheet-handle"), atScrollEdge(list, "top")))
.to.down();
```
Interactive form controls (`input`, `textarea`, `select`, `option`, and
`[contenteditable="true"]`) are ignored automatically. Add
`data-origin-gesture="ignore"` to any other element or ancestor that should not
begin a gesture.
begin a gesture, such as a horizontally scrolling chip row inside a host with a
horizontal gesture.
## Minimum setup
@@ -145,7 +181,7 @@ recognizer to it.
| Prop | Type | Default | Description |
| --------- | ------------------------- | ------- | ---------------------------------- |
| `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
rendered host.
@@ -156,8 +192,9 @@ rendered host.
</OriginGesture>
```
For compatibility, the component also accepts the legacy mutually exclusive
set of `direction`, `edge`, `threshold`, and `action` props.
The `gesture` prop is reactive. A new definition applies from the next
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.
@@ -183,16 +220,15 @@ const gestures = [forwardGesture, backGesture] as const;
| `gestures` | `readonly OriginGestureDefinition[]` | required | Fully defined page-owned rules |
The component adds no recognition or navigation policy. It installs each
definition with `useOriginGesture()`, forwards every pointer event to every
binding, and derives the least-permissive shared `touch-action`:
definition, forwards every pointer event to every recognizer, and derives one
`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`;
- vertical only: `pan-x`;
- both axes: `none`.
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.
The `gestures` list is reactive, including its length. Recognizers are kept per list position, so a re-render with new
definition objects never interrupts a gesture in progress; each change applies
from the next pointer-down. The owning page remains the visible declaration
point for every `.from`, `.to`, `.complete`, `.navigate`, and `.animate` choice.
## Nested scenes
@@ -411,6 +447,7 @@ The stages have distinct responsibilities:
| `.to.*` | Required movement direction and intent-recognition options |
| `.complete(predicate)` | Optional release commit/cancel decision |
| `.navigate(factory)` | Required target and retained-history intent |
| `.onCommit(handler)` | Optional side effect when a release commits |
| `.animate(routine)` | Required source/target/frame choreography |
The builder is persistent and immutable. Reusing an earlier stage cannot
@@ -423,6 +460,16 @@ change a definition already produced from it.
| --------------- | ------- | ------------------------------------------------- |
| `threshold` | `8` | Directed CSS pixels required before capture |
| `axisDominance` | `1.15` | Directed/cross-axis ratio required before capture |
| `distance` | host | Movement that counts as full progress |
`distance` is resolved at pointer-down: a number in pixels, a CSS length
(percentages are of the host), or a function `({ host, bounds }) => pixels`.
Set it when the moving element is smaller than the host, such as a drawer 84%
of the screen wide, so the drawer stays under the finger:
```ts
gesture.to.right({ distance: () => drawer.value?.offsetWidth ?? 300 });
```
If `.complete()` is omitted, the choreography's `commitThreshold` and
`commitVelocity` decide release normally.
@@ -432,10 +479,52 @@ velocity, directed pixel distance, cross-axis distance, duration, pointer-up
event, host, bounds, and start/current points. Completion predicates are
synchronous because they select operation intent at release.
`.onCommit(handler)` is available at every stage after `.to.*`, returns the
same stage, and may be called more than once; handlers run in order. A handler
runs once, synchronously at release, after the operation commits and before
the settling animation, and receives the same context as `.complete()`. It
never runs for a cancelled release. A throwing handler is reported as an
uncaught error without stopping later handlers or the operation. It is the
place for haptic feedback:
```ts
const openProfile = gesture.to
.left()
.navigate(() => forward(originView(ProfileView)))
.onCommit(() => navigator.vibrate?.(10))
.animate(slideLeft);
```
Handlers run inside the `pointerup` event whenever the operation has already
begun, which is the usual case, so APIs that need the user's gesture (iOS
haptics, audio, clipboard writes) work there. See [Haptics](#haptics).
The method is named `onCommit` rather than `then` so builder stages never
become promise-like "thenables" when awaited or returned from an async
function.
`.onArm(handler)` and `.onDisarm(handler)` follow the same rules and run during
the drag: `onArm` when releasing would start to commit, `onDisarm` when it no
longer would. Use them for a "release now" cue or haptic. Arming follows
distance (`progress >= commitThreshold`) because velocity changes too quickly to
drive a cue without flickering; when the definition has a `.complete()`
predicate, that predicate decides arming instead.
```ts
gesture.to
.left()
.navigate(() => forward(originView(Archive)))
.onArm(() => haptic("light"))
.onDisarm(() => haptic("soft"))
.onCommit(() => haptic("medium"))
.animate(slideLeft);
```
### `useOriginGesture(definition)`
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
const open = useOriginGesture(
@@ -455,28 +544,63 @@ interface OriginGestureBinding {
readonly onPointermove: (event: PointerEvent) => void;
readonly onPointerup: (event: PointerEvent) => 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
`touch-action` so native scrolling remains available on the cross-axis.
Apply the pointer handlers, the returned style, and `listeners` to the same
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:
1. A primary, left-button pointer satisfies the optional start policy.
2. The target is not an ignored interactive element.
3. Directed movement reaches `threshold`.
3. Directed movement from the pointer-down point reaches `threshold`.
4. Directed movement exceeds cross-axis movement by `axisDominance`.
5. The navigation factory returns an intent.
Progress is directed distance divided by host width or height. Release velocity
is normalized by the same dimension.
Recognition is measured from where the pointer went down and keeps watching
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
pointer was released or cancelled, the stale result is discarded.
The legacy `OriginGestureOptions` object remains accepted. Its `edge` is a
number inferred from the side opposite `direction`, matching the previous API.
A captured gesture is cancelled, never left mid-transition, when its pointer
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
@@ -549,11 +673,18 @@ can run repeatedly during rendering and animation.
### Commit thresholds
When `finish()` does not explicitly override the decision, a target commits
when either:
When `finish()` does not explicitly override the decision, the release velocity
decides first, then distance:
- `progress >= commitThreshold`, default `0.36`; or
- `progress >= 0.06` and `velocity >= commitVelocity`, default `0.9`.
- a flick at least `commitVelocity` fast (default `0.2` progress units per
second, about 80px/s across a 390px phone) decides by its direction: forward
commits, backward cancels, regardless of progress;
- a slower release commits when `progress >= commitThreshold`, default `0.25`.
The included presets use these defaults.
Velocity is normalized by the gesture host's size, so `1` is one full width (or
height) per second.
The operation's intent becomes final before its spring settles.
@@ -637,6 +768,14 @@ can immediately begin its own outgoing operation, enabling X → Y → Z chains.
### `OriginOperationHandle`
`intent` changes synchronously when `finish()` or `cancel()` is called, before
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 |
| ----------------------------- | --------------------------------------------------- |
| `id` | Unique operation ID |
@@ -644,6 +783,7 @@ can immediately begin its own outgoing operation, enabling X → Y → Z chains.
| `targetKey` | Created or retained target node key |
| `update(progress, velocity?)` | Update normalized interactive state |
| `finish(options?)` | Decide, settle, and return whether target committed |
| `intent` | Selected outcome: `undecided`, `commit`, `cancel` |
| `cancel(options?)` | Force cancellation and remove the target branch |
`OriginFinishOptions`:
@@ -709,12 +849,6 @@ Node-local action context:
- `previous`: recipe belonging to the mounted previous entry.
- `history`: recipes belonging to all retained previous entries.
### `MaybeOriginAction`
```ts
OriginAction | null | undefined | Promise<OriginAction | null | undefined>;
```
### `OriginEffect`
| Field | Description |
@@ -737,6 +871,21 @@ OriginAction | null | undefined | Promise<OriginAction | null | undefined>;
| `originRect` | Origin bounds captured before target mounting |
| `targetRect` | Target bounds measured after mounting |
| `viewport` | Scene-container bounds, with browser viewport fallback |
| `overshoot` | Drag distance past either end of `progress`, else `0` |
`overshoot` is negative when a drag is pulled back beyond the start and
positive beyond full progress. Use it for rubber-banding:
```ts
effects: ({ progress, overshoot }) => ({
target: {
transform: `translateX(${(1 - progress) * 100 - overshoot * 20}%)`,
},
});
```
The settling spring starts from the overshot position, so a rubber-banded drag
springs back continuously.
Rect values are viewport CSS pixels.
@@ -758,6 +907,21 @@ implementation types. They are exported by the current barrel but marked
## 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
`OriginScene`.
- An origin can own only one outgoing operation at a time.
@@ -775,3 +939,135 @@ implementation types. They are exported by the current barrel but marked
- A choreography creates one target. Chaining supports any number of
simultaneously mounted targets.
- 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.
+24
View File
@@ -292,6 +292,30 @@ flat host.
“Y is Z's origin” is a coordinate and retained-history relationship, not Vue
component ancestry.
## Source layout
Each module depends only on the ones listed above it.
| File | Responsibility |
| ----------------------- | ------------------------------------------------------------------------------------------------- |
| `shared.ts` | Geometry and environment helpers |
| `choreography.ts` | Effect types, `defineOriginChoreography()`, presets, and `composeEffects()` style compositing |
| `navigation.ts` | `originView()` recipes and `forward()`, `back()`, `replace()`, `above()`, `under()` actions |
| `scene/types.ts` | Public scene types |
| `scene/graph.ts` | `SceneGraph`: node and edge storage plus the begin/commit/cancel history rules (all synchronous) |
| `scene/scene.ts` | `Scene` and `OperationHandle`: mounting, measuring, settling spring, compositing, and `useOrigin` |
| `gesture/definition.ts` | Gesture types, component props, and the immutable `gesture` builder |
| `gesture/recognizer.ts` | Pointer-down admission, `DirectionalTracker`, `GestureRecognizer`, and `useOriginGesture` |
| `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
`composeNodeStyle`) are module-level `export const`s that the class calls, so
they can be tested directly without widening the class's interface.
## Current experimental boundaries
- One operation creates one target. Chaining operations already permits any
+319
View File
@@ -0,0 +1,319 @@
import type { CSSProperties } from "vue";
import { clamp01, type OriginRect } from "./shared";
/*
* Choreographies: the visual side of an operation. A choreography computes
* effects from operation progress; `composeEffects` collapses the effects
* applied to one node into its inline style. Nothing here knows about history.
*/
/**
* The lifecycle phase of an operation edge.
*
* - `preparing`: the target has been added and Vue is mounting it.
* - `interactive`: progress may be controlled by a gesture or application.
* - `settling`: the commit/cancel decision is fixed and the spring is running.
* - `finished`: the graph rewrite or cancellation cleanup has completed.
*/
export type OriginOperationPhase =
"preparing" | "interactive" | "settling" | "finished";
/**
* The outcome selected for an operation.
*
* Intent remains `undecided` until `finish()` is called. It becomes final
* before the settling animation completes, allowing the retained target to
* originate its own operation immediately.
*/
export type OriginOperationIntent = "undecided" | "commit" | "cancel";
/**
* One composable contribution to a scene node's final visual style.
*
* Transforms are concatenated in origin-to-descendant order and opacity values
* are multiplied. This lets X→Y and Y→Z affect Y simultaneously without either
* routine replacing the other routine's CSS transform.
*/
export interface OriginEffect {
/**
* A CSS transform contribution. Transform strings from inherited and local
* origin frames are concatenated rather than replacing one another.
*/
transform?: string;
/**
* An opacity contribution between `0` and `1`. Contributions from multiple
* frames are multiplied and the final value is clamped.
*/
opacity?: number;
/**
* Relative stacking contribution. `above()` defaults the target to +1 and
* `under()` defaults it to -1.
*/
layer?: number;
/**
* Escape hatch for non-geometric effects such as filter, clipPath, or
* borderRadius. Later/local effects override inherited properties.
*/
style?: CSSProperties;
}
/**
* Independent visual contributions calculated for one operation edge.
*
* Any omitted contribution is treated as an identity effect.
*/
export interface OriginEffectSet {
/**
* Applied to both source and target. This is useful for moving an entire
* origin-relative coordinate frame.
*/
frame?: OriginEffect;
/** Applied to the component that initiated this operation. */
source?: OriginEffect;
/** Applied to the component created by this operation and its descendants. */
target?: OriginEffect;
}
/** Values supplied whenever a choreography calculates its visual effects. */
export interface OriginChoreographyContext {
/** Normalized operation progress, clamped to the inclusive range `0..1`. */
progress: number;
/**
* How far a drag has gone past the ends of `progress`: negative when pulled
* back beyond the start, positive beyond full progress, otherwise `0`. Use
* it for rubber-banding or resistance; most choreographies ignore it.
*/
overshoot: number;
/**
* Normalized progress units per second. Positive velocity moves toward
* commit; negative velocity moves back toward cancellation.
*/
velocity: number;
/** Current lifecycle phase of the operation. */
phase: OriginOperationPhase;
/** Commit/cancel outcome, if release has already selected one. */
intent: OriginOperationIntent;
/** Origin host bounds captured immediately before the target mounts. */
originRect?: OriginRect;
/** Target host bounds measured after Vue mounts the target. */
targetRect?: OriginRect;
/** Scene-container bounds, or the browser viewport when no container exists. */
viewport: OriginRect;
}
/**
* A choreography describes visual relationships only. Component creation,
* history, and cleanup are performed by the scene operation that uses it.
*/
export interface OriginChoreography {
/** Optional diagnostic name surfaced by scene inspectors and devtools. */
readonly name?: string;
/**
* Calculate source, target, and shared-frame contributions for the current
* operation state. This function should be deterministic and side-effect
* free because it can run many times per animation frame.
*/
effects(context: OriginChoreographyContext): OriginEffectSet;
/**
* Gesture progress required to retain the target after release.
*
* @defaultValue `0.25`
*/
readonly commitThreshold?: number;
/**
* Release velocity, in progress units per second, that commits a flick
* regardless of progress. Velocity is normalized by the gesture host's
* size, so `1` means one full host width (or height) per second.
*
* @defaultValue `0.2`
*/
readonly commitVelocity?: number;
/**
* Keep this choreography's progress-`1` effects connected after a committed
* push instead of collapsing the edge and hiding its source.
*
* This is intended for partial presentations such as drawers and inspectors
* where part of the retained source remains visible beside the target. The
* source remains mounted, visible, and inert. A back operation temporarily
* suspends the resting edge; cancelling back restores it.
*
* Only retained-history `push` actions support connected resting effects.
*
* @defaultValue `false`
*/
readonly persistAtRest?: boolean;
/**
* Settling spring after release. A drawer, a sheet, and a full-page push
* usually want different springs.
*/
readonly spring?: OriginSpring;
}
/** Damped-spring settings for settling a released operation. */
export interface OriginSpring {
/**
* Pull toward the resting position. Higher settles faster.
*
* @defaultValue `280`
*/
readonly stiffness?: number;
/**
* Resistance to motion. Lower overshoots and bounces.
*
* @defaultValue `30`
*/
readonly damping?: number;
}
/**
* Preserve type inference while declaring a custom choreography.
*
* The helper performs no runtime transformation. It gives custom routines a
* named, documented construction point and validates their shape in TypeScript.
*
* @param choreography - Side-effect-free visual effect calculator and optional
* release thresholds.
* @returns The same choreography object.
*/
export function defineOriginChoreography(
choreography: OriginChoreography,
): OriginChoreography {
return choreography;
}
/** Distinguish a choreography from a plain options object. */
export const isChoreography = (value: unknown): value is OriginChoreography => {
return (
typeof value === "object" &&
value !== null &&
"effects" in value &&
typeof value.effects === "function"
);
};
/**
* Normalize one contribution before the compositor combines it with effects
* inherited from earlier origin frames.
*
* Most applications should return plain effects from a choreography and let
* the scene call this function. It is exported for custom compositors and
* diagnostics.
*
* @param effect - Optional effect to normalize.
* @param fallbackLayer - Relative layer contribution added to `effect.layer`.
* @returns A defined effect with a numeric layer.
*/
export function normalizedEffect(
effect: OriginEffect | undefined,
fallbackLayer = 0,
): OriginEffect {
return {
...effect,
layer: (effect?.layer ?? 0) + fallbackLayer,
};
}
/** Whether a transform contributes anything to a composed transform list. */
function isComposable(transform: unknown): transform is string {
return (
typeof transform === "string" && transform !== "" && transform !== "none"
);
}
/** Result of collapsing an ordered list of effects into one style. */
export interface ComposedEffects {
/** Merged style with the composed `transform` and `opacity` applied. */
readonly style: CSSProperties;
/** Sum of every effect's relative layer contribution. */
readonly layer: number;
}
/**
* Collapse independent effect layers, ordered from the outermost inherited
* frame to the most local one, into a single style.
*
* Transforms are concatenated and opacities multiplied, including `transform`
* and numeric `opacity` supplied through `effect.style`. Every other `style`
* property uses local-last precedence over `base` and earlier effects.
*/
export const composeEffects = (
effects: readonly OriginEffect[],
base: CSSProperties = {},
): ComposedEffects => {
const style: CSSProperties = { ...base };
const transforms: string[] = [];
let opacity = 1;
let layer = 0;
for (const effect of effects) {
if (isComposable(effect.transform)) transforms.push(effect.transform);
if (effect.opacity !== undefined) opacity *= effect.opacity;
layer += effect.layer ?? 0;
if (effect.style) {
Object.assign(style, effect.style);
if (isComposable(effect.style.transform))
transforms.push(effect.style.transform);
if (effect.style.opacity !== undefined) {
const styleOpacity = Number(effect.style.opacity);
if (Number.isFinite(styleOpacity)) opacity *= styleOpacity;
}
}
}
// Assign the composed properties last so no single effect's `style` can
// replace transform work inherited from other frames.
style.transform = transforms.length ? transforms.join(" ") : "none";
style.opacity = String(clamp01(opacity));
return { style, layer };
};
const percent = (value: number) => `${(value * 100).toFixed(4)}%`;
/**
* Native-style forward motion: the target enters above the source while the
* source recedes slightly. These presets are examples; applications can
* replace them with arbitrary `defineOriginChoreography()` callbacks.
*/
export const slideLeft = defineOriginChoreography({
name: "slide-left",
effects: ({ progress }) => ({
source: {
transform: `translate3d(${percent(progress * -0.24)}, 0, 0) scale(${1 - progress * 0.025})`,
opacity: 1 - progress * 0.16,
},
target: {
transform: `translate3d(${percent(1 - progress)}, 0, 0)`,
},
}),
});
/**
* Back motion reveals the target underneath the source. This is merely a
* visual routine; the `back()` action selects retained-history behavior.
*/
export const slideRight = defineOriginChoreography({
name: "slide-right",
effects: ({ progress }) => ({
source: {
transform: `translate3d(${percent(progress)}, 0, 0)`,
},
target: {
transform: `translate3d(${percent(-0.24 + progress * 0.24)}, 0, 0) scale(${0.975 + progress * 0.025})`,
opacity: 0.84 + progress * 0.16,
},
}),
});
/**
* Cross-fade preset that fades the source out while fading the target in.
*
* The default release thresholds from the scene are used.
*/
export const fade = defineOriginChoreography({
name: "fade",
effects: ({ progress }) => ({
source: { opacity: 1 - progress },
target: { opacity: progress },
}),
});
@@ -1,7 +1,6 @@
<script setup lang="ts">
import { computed } from "vue";
import { useOriginGesture } from "../gesture";
import type { OriginGestureProps } from "../types";
import type { OriginGestureProps } from "../gesture/definition";
import { useOriginGesture } from "../gesture/recognizer";
defineOptions({ name: "OriginGesture", inheritAttrs: false });
@@ -12,29 +11,24 @@ const props = withDefaults(defineProps<OriginGestureProps>(), {
/*
* This convenience component makes the declaration live exactly where the
* developer writes it. `useOriginGesture()` is also public for components that
* cannot accept an extra wrapper element.
* cannot accept an extra wrapper element. A new `gesture` prop applies from
* the next pointer-down.
*/
const gesture = useOriginGesture(
props.gesture ?? {
direction: props.direction,
edge: props.edge,
threshold: props.threshold,
action: (context) => props.action?.(context),
},
);
const touchStyle = computed(() => gesture.style);
const gesture = useOriginGesture(() => props.gesture);
</script>
<template>
<component
:is="as"
v-bind="$attrs"
:ref="gesture.ref"
v-bind="{ ...$attrs, ...gesture.listeners }"
class="nvo-gesture"
:style="touchStyle"
:style="gesture.style"
@pointerdown="gesture.onPointerdown"
@pointermove="gesture.onPointermove"
@pointerup="gesture.onPointerup"
@pointercancel="gesture.onPointercancel"
@lostpointercapture="gesture.onLostpointercapture"
>
<slot />
</component>
@@ -1,7 +1,6 @@
<script setup lang="ts">
import { computed } from "vue";
import { useOriginGesture } from "../gesture";
import type { OriginGestureSurfaceProps } from "../types";
import type { OriginGestureSurfaceProps } from "../gesture/definition";
import { useOriginGestures } from "../gesture/recognizer";
defineOptions({ name: "OriginGestureSurface", inheritAttrs: false });
@@ -14,59 +13,21 @@ const props = withDefaults(defineProps<OriginGestureSurfaceProps>(), {
* This convenience host only installs them, combines their browser scrolling
* requirements, and forwards a pointer sequence to every recognizer.
*/
const bindings = props.gestures.map((definition) =>
useOriginGesture(definition),
);
const surfaceStyle = computed(() => {
const horizontal = props.gestures.some(
({ direction }) => direction === "left" || direction === "right",
);
const vertical = props.gestures.some(
({ direction }) => direction === "up" || direction === "down",
);
return {
width: "100%",
height: "100%",
touchAction:
horizontal && vertical
? "none"
: horizontal
? "pan-y"
: vertical
? "pan-x"
: "auto",
} as const;
});
function pointerDown(event: PointerEvent) {
for (const binding of bindings) binding.onPointerdown(event);
}
function pointerMove(event: PointerEvent) {
for (const binding of bindings) binding.onPointermove(event);
}
function pointerUp(event: PointerEvent) {
for (const binding of bindings) binding.onPointerup(event);
}
function pointerCancel() {
for (const binding of bindings) binding.onPointercancel();
}
const surface = useOriginGestures(() => props.gestures);
</script>
<template>
<component
:is="as"
v-bind="$attrs"
:ref="surface.ref"
v-bind="{ ...$attrs, ...surface.listeners }"
class="nvo-gesture"
:style="[$attrs.style, surfaceStyle]"
@pointerdown="pointerDown"
@pointermove="pointerMove"
@pointerup="pointerUp"
@pointercancel="pointerCancel"
:style="[$attrs.style, surface.style]"
@pointerdown="surface.onPointerdown"
@pointermove="surface.onPointermove"
@pointerup="surface.onPointerup"
@pointercancel="surface.onPointercancel"
@lostpointercapture="surface.onLostpointercapture"
>
<slot />
</component>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, provide, ref, watchEffect } from "vue";
import type { OriginScene, OriginSceneNode } from "../types";
import { originNodeScopeKey } from "../lifecycle";
import { originNodeScopeKey } from "../scene/scene";
import type { OriginScene, OriginSceneNode } from "../scene/types";
defineOptions({ name: "OriginNodeHost" });
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { onBeforeUnmount, ref, watchEffect } from "vue";
import type { OriginSceneProps } from "../types";
import type { OriginSceneProps } from "../scene/types";
import OriginNodeHost from "./OriginNodeHost.vue";
defineOptions({ name: "OriginScene" });
-582
View File
@@ -1,582 +0,0 @@
import type {
MaybeOriginAction,
OriginAction,
OriginContext,
OriginGestureBinding,
OriginGestureBuilder,
OriginGestureCompletionContext,
OriginGestureCompletionPredicate,
OriginGestureDefinition,
OriginGestureDirection,
OriginGestureDirectionOptions,
OriginGestureDistance,
OriginGestureEdge,
OriginGestureFromBuilder,
OriginGestureFromSelection,
OriginGestureNavigationBuilder,
OriginGestureNavigationFactory,
OriginGestureOptions,
OriginGesturePoint,
OriginGestureStart,
OriginGestureStartContext,
OriginGestureStartPredicate,
OriginGestureToBuilder,
OriginOperationHandle,
OriginRect,
} from "./types";
import { useOrigin } from "./lifecycle";
function ignoreGestureTarget(target: EventTarget | null) {
return (
!(target instanceof Element) ||
Boolean(
target.closest(
'[data-origin-gesture="ignore"], input, textarea, select, option, [contenteditable="true"]',
),
)
);
}
function directedDistance(
direction: OriginGestureDirection,
dx: number,
dy: number,
) {
switch (direction) {
case "left":
return -dx;
case "right":
return dx;
case "up":
return -dy;
case "down":
return dy;
}
}
function rectOf(element: HTMLElement): OriginRect {
const rect = element.getBoundingClientRect();
return {
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
};
}
function pointOf(
event: Pick<PointerEvent, "clientX" | "clientY">,
bounds: OriginRect,
): OriginGesturePoint {
return {
clientX: event.clientX,
clientY: event.clientY,
localX: event.clientX - bounds.left,
localY: event.clientY - bounds.top,
};
}
/**
* Resolve an arbitrary CSS length against a box with the gesture host's size.
*
* A short-lived off-screen box lets the browser handle `rem`, viewport units,
* percentages, `calc()`, and `clamp()` consistently. This runs only during
* pointer-down for edge-constrained definitions.
*/
function resolveCssDistance(
distance: OriginGestureDistance,
axis: "horizontal" | "vertical",
host: HTMLElement,
bounds: OriginRect,
) {
if (typeof distance === "number")
return Number.isFinite(distance) ? Math.max(0, distance) : 0;
const document = host.ownerDocument;
if (!document.body) return Math.max(0, Number.parseFloat(distance) || 0);
const container = document.createElement("div");
const probe = document.createElement("div");
Object.assign(container.style, {
position: "fixed",
left: "-100000px",
top: "-100000px",
width: `${bounds.width}px`,
height: `${bounds.height}px`,
visibility: "hidden",
pointerEvents: "none",
contain: "strict",
});
Object.assign(probe.style, {
position: "absolute",
width: axis === "horizontal" ? distance : "0",
height: axis === "vertical" ? distance : "0",
});
container.append(probe);
document.body.append(container);
const resolved =
axis === "horizontal"
? probe.getBoundingClientRect().width
: probe.getBoundingClientRect().height;
container.remove();
return Number.isFinite(resolved) ? Math.max(0, resolved) : 0;
}
function matchesStart(
start: OriginGestureStart,
event: PointerEvent,
host: HTMLElement,
origin: OriginContext,
bounds: OriginRect,
) {
if (start.kind === "anywhere") return true;
const point = pointOf(event, bounds);
if (start.kind === "when") {
const context: OriginGestureStartContext = {
event,
origin,
host,
bounds,
point,
};
return start.predicate(context);
}
const horizontal = start.edge === "left" || start.edge === "right";
const distance = resolveCssDistance(
start.distance,
horizontal ? "horizontal" : "vertical",
host,
bounds,
);
switch (start.edge) {
case "left":
return point.localX >= 0 && point.localX <= distance;
case "right":
return (
point.localX <= bounds.width && bounds.width - point.localX <= distance
);
case "top":
return point.localY >= 0 && point.localY <= distance;
case "bottom":
return (
point.localY <= bounds.height &&
bounds.height - point.localY <= distance
);
}
}
function edgeStart(
edge: OriginGestureEdge,
distance: OriginGestureDistance,
): OriginGestureStart {
return Object.freeze({ kind: "edge", edge, distance });
}
function createNavigationBuilder(
start: OriginGestureStart,
direction: OriginGestureDirection,
recognition: Readonly<OriginGestureDirectionOptions>,
completion: OriginGestureCompletionPredicate | undefined,
navigation: OriginGestureNavigationFactory,
): OriginGestureNavigationBuilder {
return Object.freeze({
animate(choreography: OriginGestureDefinition["choreography"]) {
return Object.freeze({
kind: "origin-gesture-definition",
start,
direction,
recognition,
completion,
navigation,
choreography,
});
},
});
}
function createDirectedBuilder(
start: OriginGestureStart,
direction: OriginGestureDirection,
options: OriginGestureDirectionOptions = {},
) {
const recognition = Object.freeze({ ...options });
const navigate = (
navigation: OriginGestureNavigationFactory,
completion?: OriginGestureCompletionPredicate,
) =>
createNavigationBuilder(
start,
direction,
recognition,
completion,
navigation,
);
return Object.freeze({
complete(completion: OriginGestureCompletionPredicate) {
return Object.freeze({
navigate: (navigation: OriginGestureNavigationFactory) =>
navigate(navigation, completion),
});
},
navigate,
});
}
function createToBuilder(start: OriginGestureStart): OriginGestureToBuilder {
return Object.freeze({
left: (options?: OriginGestureDirectionOptions) =>
createDirectedBuilder(start, "left", options),
right: (options?: OriginGestureDirectionOptions) =>
createDirectedBuilder(start, "right", options),
up: (options?: OriginGestureDirectionOptions) =>
createDirectedBuilder(start, "up", options),
down: (options?: OriginGestureDirectionOptions) =>
createDirectedBuilder(start, "down", options),
});
}
function selectStart(start: OriginGestureStart): OriginGestureFromSelection {
return Object.freeze({ to: createToBuilder(Object.freeze(start)) });
}
const fromBuilder: OriginGestureFromBuilder = Object.freeze({
left: (distance: OriginGestureDistance) =>
selectStart(edgeStart("left", distance)),
right: (distance: OriginGestureDistance) =>
selectStart(edgeStart("right", distance)),
top: (distance: OriginGestureDistance) =>
selectStart(edgeStart("top", distance)),
bottom: (distance: OriginGestureDistance) =>
selectStart(edgeStart("bottom", distance)),
anywhere: () => selectStart({ kind: "anywhere" }),
when: (predicate: OriginGestureStartPredicate) =>
selectStart({ kind: "when", predicate }),
});
/**
* Root of the immutable gesture builder.
*
* `.from` is optional. Starting at `.to` admits pointer-down anywhere on the
* bound host, exactly like `.from.anywhere().to`.
*
* @example Anywhere-to-right back gesture
* ```ts
* const swipeBack = gesture
* .to.right()
* .navigate((context) => context.canGoBack ? back() : null)
* .animate(slideRight);
* ```
*
* @example Predicate-gated gesture with custom completion
* ```ts
* const openPanel = gesture
* .from.when(({ point, bounds }) => point.localX <= bounds.width * 0.08)
* .to.down()
* .complete(({ progress, velocity }) => progress > 0.5 || velocity > 1)
* .navigate(() => above(originView(PanelView)))
* .animate(dropPanel);
* ```
*/
export const gesture: OriginGestureBuilder = Object.freeze({
from: fromBuilder,
to: createToBuilder(Object.freeze({ kind: "anywhere" })),
});
function isDefinition(
value: OriginGestureOptions | OriginGestureDefinition,
): value is OriginGestureDefinition {
return "kind" in value && value.kind === "origin-gesture-definition";
}
interface RuntimeGesturePolicy {
readonly direction: OriginGestureDirection;
readonly start: OriginGestureStart;
readonly threshold: number;
readonly axisDominance: number;
readonly completion?: OriginGestureCompletionPredicate;
readonly action: (context: OriginContext) => MaybeOriginAction;
}
function legacyStart(options: OriginGestureOptions): OriginGestureStart {
if (options.edge === undefined) return { kind: "anywhere" };
switch (options.direction) {
case "left":
return edgeStart("right", options.edge);
case "right":
return edgeStart("left", options.edge);
case "up":
return edgeStart("bottom", options.edge);
case "down":
return edgeStart("top", options.edge);
}
}
function runtimePolicy(
options: OriginGestureOptions | OriginGestureDefinition,
): RuntimeGesturePolicy {
if (!isDefinition(options)) {
return {
direction: options.direction,
start: legacyStart(options),
threshold: options.threshold ?? 8,
axisDominance: 1.15,
action: options.action,
};
}
return {
direction: options.direction,
start: options.start,
threshold: options.recognition.threshold ?? 8,
axisDominance: options.recognition.axisDominance ?? 1.15,
completion: options.completion,
action: async (context) => {
const navigation = await options.navigation(context);
if (!navigation) return navigation;
const action: OriginAction = {
...navigation,
choreography: options.choreography,
};
return action;
},
};
}
/**
* Install a component-owned pointer recognizer.
*
* Recognition is local to the element receiving these handlers. There is no
* application-wide gesture table and no lookup of a currently active view.
*
* Builder definitions may independently describe their pointer-down region
* and movement direction. Omitting `.from` recognizes pointer-down across the
* whole element. The legacy options object remains supported; its `edge` is
* inferred from the opposite side of its movement direction.
*
* Interactive controls and anything inside
* `[data-origin-gesture="ignore"]` are ignored automatically. The recognizer
* preserves native scrolling on the cross-axis through its returned style.
*
* @param definition - An immutable builder result or legacy recognizer options.
* @returns Pointer handlers and required host styles.
* @throws If called outside a component rendered by `OriginScene`.
*
* @example Builder-defined backward gesture
* ```ts
* const swipeBack = useOriginGesture(
* gesture
* .from.left("32px")
* .to.right()
* .navigate((context) => context.canGoBack ? back() : null)
* .animate(slideRight),
* );
* ```
*/
export function useOriginGesture(
definition: OriginGestureDefinition | OriginGestureOptions,
): OriginGestureBinding {
const origin = useOrigin();
const policy = runtimePolicy(definition);
let pointerId = -1;
let element: HTMLElement | null = null;
let bounds: OriginRect | null = null;
let originContext: OriginContext | null = null;
let startPoint: OriginGesturePoint | null = null;
let startTime = 0;
let startX = 0;
let startY = 0;
let lastCoordinate = 0;
let lastTime = 0;
let captured = false;
let generation = 0;
let handlePromise: Promise<OriginOperationHandle | null> | null = null;
let bufferedProgress = 0;
let bufferedVelocity = 0;
let bufferedDistance = 0;
let bufferedCrossDistance = 0;
const horizontal =
policy.direction === "left" || policy.direction === "right";
function reset() {
pointerId = -1;
element = null;
bounds = null;
originContext = null;
startPoint = null;
captured = false;
handlePromise = null;
bufferedProgress = 0;
bufferedVelocity = 0;
bufferedDistance = 0;
bufferedCrossDistance = 0;
}
function onPointerdown(event: PointerEvent) {
const current = event.currentTarget;
if (
!event.isPrimary ||
event.button !== 0 ||
!(current instanceof HTMLElement) ||
ignoreGestureTarget(event.target)
)
return;
const nextBounds = rectOf(current);
const nextOriginContext = origin.context.value;
if (
!matchesStart(policy.start, event, current, nextOriginContext, nextBounds)
)
return;
// The component containing this declaration is the operation's origin.
event.stopPropagation();
generation += 1;
pointerId = event.pointerId;
element = current;
bounds = nextBounds;
originContext = nextOriginContext;
startPoint = pointOf(event, nextBounds);
startTime = event.timeStamp;
startX = event.clientX;
startY = event.clientY;
lastCoordinate = horizontal ? event.clientX : event.clientY;
lastTime = event.timeStamp;
captured = false;
handlePromise = null;
}
function updateMetrics(event: PointerEvent, release = false) {
if (!element) return;
const dx = event.clientX - startX;
const dy = event.clientY - startY;
bufferedDistance = directedDistance(policy.direction, dx, dy);
bufferedCrossDistance = horizontal ? Math.abs(dy) : Math.abs(dx);
const size = Math.max(
1,
horizontal ? element.clientWidth : element.clientHeight,
);
const coordinate = horizontal ? event.clientX : event.clientY;
const coordinateDelta =
policy.direction === "left" || policy.direction === "up"
? lastCoordinate - coordinate
: coordinate - lastCoordinate;
const rawElapsed = event.timeStamp - lastTime;
const elapsed = Math.max(8, rawElapsed);
bufferedProgress = Math.max(0, Math.min(1, bufferedDistance / size));
/*
* Pointer-up commonly repeats the final pointer-move coordinate. Preserve
* that move's flick velocity for a prompt release, but decay it when the
* pointer was held still long enough for the flick to have ended.
*/
if (!release || coordinateDelta !== 0 || rawElapsed > 80)
bufferedVelocity = (coordinateDelta * 1000) / (elapsed * size);
lastCoordinate = coordinate;
lastTime = event.timeStamp;
}
async function onPointermove(event: PointerEvent) {
if (event.pointerId !== pointerId || !element) return;
const dx = event.clientX - startX;
const dy = event.clientY - startY;
const distance = directedDistance(policy.direction, dx, dy);
const crossDistance = horizontal ? Math.abs(dy) : Math.abs(dx);
let metricsUpdated = false;
if (!captured) {
if (
distance < policy.threshold ||
distance < crossDistance * policy.axisDominance
)
return;
captured = true;
element.setPointerCapture?.(pointerId);
event.preventDefault();
updateMetrics(event);
metricsUpdated = true;
const recognitionGeneration = generation;
const action = await Promise.resolve(
policy.action(origin.context.value),
).catch(() => null);
// An asynchronous target resolver may finish after the pointer was
// released or cancelled. It must not create an orphan scene operation.
if (recognitionGeneration !== generation || event.pointerId !== pointerId)
return;
if (!action) return reset();
handlePromise = origin.begin(action).catch(() => null);
}
event.preventDefault();
if (!metricsUpdated) updateMetrics(event);
const pending = handlePromise;
const handle = pending ? await pending : null;
if (pending === handlePromise)
handle?.update(bufferedProgress, bufferedVelocity);
}
async function onPointerup(event: PointerEvent) {
if (event.pointerId !== pointerId) return;
if (captured) updateMetrics(event, true);
const pending = handlePromise;
const shouldFinish = captured;
const progress = bufferedProgress;
const velocity = bufferedVelocity;
const completion =
shouldFinish &&
policy.completion &&
element &&
bounds &&
originContext &&
startPoint
? policy.completion({
origin: originContext,
direction: policy.direction,
progress,
velocity,
distance: bufferedDistance,
crossDistance: bufferedCrossDistance,
duration: Math.max(0, event.timeStamp - startTime),
event,
host: element,
bounds,
start: startPoint,
current: pointOf(event, bounds),
} satisfies OriginGestureCompletionContext)
: undefined;
generation += 1;
reset();
const handle = pending ? await pending : null;
if (!shouldFinish || !handle) return;
handle.update(progress, velocity);
await handle.finish(
completion === undefined ? undefined : { commit: completion },
);
}
async function onPointercancel() {
generation += 1;
const pending = handlePromise;
const shouldCancel = captured;
reset();
const handle = pending ? await pending : null;
if (shouldCancel) await handle?.cancel();
}
return {
style: {
// Preserve native scrolling perpendicular to the declared gesture.
touchAction: horizontal ? "pan-y" : "pan-x",
// OriginGesture is commonly the root returned by a view component.
width: "100%",
height: "100%",
},
onPointerdown,
onPointermove: (event) => void onPointermove(event),
onPointerup: (event) => void onPointerup(event),
onPointercancel: () => void onPointercancel(),
};
}
+628
View File
@@ -0,0 +1,628 @@
import type { CSSProperties, MaybeRefOrGetter } from "vue";
import type { OriginChoreography } from "../choreography";
import type { OriginNavigationIntent } from "../navigation";
import type { OriginContext } from "../scene/types";
import type { OriginRect } from "../shared";
import { isAtScrollEdge, withinEdge } from "./recognizer";
/*
* What a gesture is: the public gesture types, component props, and the
* immutable builder that produces executable definitions. Running them is
* the recognizer's job.
*/
/** Direction in which pointer movement advances operation progress. */
export type OriginGestureDirection = "left" | "right" | "up" | "down";
/** Physical side of the gesture host used to admit pointer-down. */
export type OriginGestureEdge = "left" | "right" | "top" | "bottom";
/**
* CSS length accepted by an edge-based gesture start rule.
*
* Numbers are interpreted as CSS pixels. Strings may use normal CSS lengths,
* percentages, `calc()`, or `clamp()`, and are resolved against the gesture
* host when pointer-down occurs.
*/
export type OriginGestureDistance = number | string;
/** A pointer position measured in viewport and gesture-host coordinates. */
export interface OriginGesturePoint {
/** Viewport-relative horizontal position. */
readonly clientX: number;
/** Viewport-relative vertical position. */
readonly clientY: number;
/** Horizontal position relative to the gesture host's left edge. */
readonly localX: number;
/** Vertical position relative to the gesture host's top edge. */
readonly localY: number;
}
/** Context supplied to a custom `.from.when()` start predicate. */
export interface OriginGestureStartContext {
/** Native pointer-down event being considered. */
readonly event: PointerEvent;
/** Component instance from which the gesture would originate. */
readonly origin: OriginContext;
/** Element carrying the gesture's pointer handlers. */
readonly host: HTMLElement;
/** Gesture-host bounds captured at pointer-down. */
readonly bounds: OriginRect;
/** Pointer position at pointer-down. */
readonly point: OriginGesturePoint;
}
/**
* Synchronous predicate deciding whether a pointer-down may become a gesture.
*
* Directional recognition still occurs later, after movement passes the
* configured intent threshold.
*/
export type OriginGestureStartPredicate = (
context: OriginGestureStartContext,
) => boolean;
/** Start policy stored in a completed gesture definition. */
export type OriginGestureStart =
| {
/** Recognize pointer-down anywhere on the host. */
readonly kind: "anywhere";
}
| {
/** Recognize pointer-down within a CSS distance of one host side. */
readonly kind: "edge";
readonly edge: OriginGestureEdge;
readonly distance: OriginGestureDistance;
}
| {
/** Recognize pointer-down when application policy returns `true`. */
readonly kind: "when";
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. */
export interface OriginGestureDirectionOptions {
/**
* Minimum directed movement in CSS pixels before the gesture captures.
*
* @defaultValue `8`
*/
readonly threshold?: number;
/**
* Ratio by which directed movement must exceed cross-axis movement.
*
* @defaultValue `1.15`
*/
readonly axisDominance?: number;
/**
* Movement that counts as full progress (`1`), resolved at pointer-down.
* A CSS length (numbers are pixels, `%` is of the host), or a function
* returning pixels, for example to measure a drawer narrower than the host.
*
* @defaultValue the host's width (horizontal) or height (vertical)
*/
readonly distance?: OriginGestureProgressDistance;
}
/**
* Movement that counts as full progress: a CSS length, or a function that
* returns pixels, measured at pointer-down.
*/
export type OriginGestureProgressDistance =
| OriginGestureDistance
| ((context: {
readonly host: HTMLElement;
readonly bounds: OriginRect;
}) => number);
/** Values available when a custom `.complete()` policy runs on pointer-up. */
export interface OriginGestureCompletionContext {
/** Component instance that originated the gesture. */
readonly origin: OriginContext;
/** Recognized movement direction. */
readonly direction: OriginGestureDirection;
/** Normalized directed distance, clamped to `0..1`. */
readonly progress: number;
/** Latest normalized progress units per second. */
readonly velocity: number;
/** Directed movement from pointer-down in CSS pixels. */
readonly distance: number;
/** Absolute cross-axis movement from pointer-down in CSS pixels. */
readonly crossDistance: number;
/** Elapsed time since pointer-down in milliseconds. */
readonly duration: number;
/** Native pointer-up event that ended the interaction. */
readonly event: PointerEvent;
/** Element carrying the gesture's pointer handlers. */
readonly host: HTMLElement;
/** Gesture-host bounds captured at pointer-down. */
readonly bounds: OriginRect;
/** Pointer position captured at pointer-down. */
readonly start: OriginGesturePoint;
/** Pointer position at release. */
readonly current: OriginGesturePoint;
}
/** Synchronous commit/cancel policy installed by `.complete()`. */
export type OriginGestureCompletionPredicate = (
context: OriginGestureCompletionContext,
) => boolean;
/**
* Values accepted from a gesture builder's `.navigate()` factory.
*
* A promise supports lazy view selection. Stale resolutions are discarded if
* the pointer has already ended or been cancelled.
*/
export type MaybeOriginNavigationIntent =
| OriginNavigationIntent
| null
| undefined
| Promise<OriginNavigationIntent | null | undefined>;
/** Factory that resolves navigation after directional recognition succeeds. */
export type OriginGestureNavigationFactory = (
context: OriginContext,
) => MaybeOriginNavigationIntent;
/**
* Immutable, executable result of a complete gesture builder chain.
*
* Pass this object to {@link useOriginGesture}. An omitted `.from` step is
* represented as an `anywhere` start rule.
*/
export interface OriginGestureDefinition {
/** Identifies a definition produced by the {@link gesture} builder. */
readonly kind: "origin-gesture-definition";
/** Pointer-down eligibility policy. */
readonly start: OriginGestureStart;
/** Direction in which movement advances operation progress. */
readonly direction: OriginGestureDirection;
/** Directional intent recognition tuning. */
readonly recognition: Readonly<OriginGestureDirectionOptions>;
/**
* Optional release decision. When omitted, choreography thresholds decide.
*/
readonly completion?: OriginGestureCompletionPredicate;
/** Node-local destination/history resolver. */
readonly navigation: OriginGestureNavigationFactory;
/** Visual routine paired with the navigation intent. */
readonly choreography: OriginChoreography;
/** Handlers run, in order, when a release commits. See `.onCommit()`. */
readonly commitHandlers?: readonly OriginGestureCommitHandler[];
/** 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[];
}
/**
* Side effect run when a released gesture commits, such as haptic feedback.
*
* It runs once, synchronously at release, after the commit decision and
* before the settling animation. It receives the same context as a
* `.complete()` predicate. Errors are reported without interrupting the
* operation.
*/
export type OriginGestureCommitHandler = (
context: OriginGestureCompletionContext,
) => void;
/** Direction-selection stage shared by `gesture.to` and `.from.*().to`. */
export interface OriginGestureToBuilder {
/** Recognize leftward pointer movement. */
left(options?: OriginGestureDirectionOptions): OriginGestureDirectedBuilder;
/** Recognize rightward pointer movement. */
right(options?: OriginGestureDirectionOptions): OriginGestureDirectedBuilder;
/** Recognize upward pointer movement. */
up(options?: OriginGestureDirectionOptions): OriginGestureDirectedBuilder;
/** Recognize downward pointer movement. */
down(options?: OriginGestureDirectionOptions): OriginGestureDirectedBuilder;
}
/** Stage produced after a `.from` policy has been selected. */
export interface OriginGestureFromSelection {
/** Select the direction that advances this gesture. */
readonly to: OriginGestureToBuilder;
}
/** Optional pointer-down policy exposed at the start of a gesture chain. */
export interface OriginGestureFromBuilder {
/** Admit pointer-down within `distance` of the host's left edge. */
left(distance: OriginGestureDistance): OriginGestureFromSelection;
/** Admit pointer-down within `distance` of the host's right edge. */
right(distance: OriginGestureDistance): OriginGestureFromSelection;
/** Admit pointer-down within `distance` of the host's top edge. */
top(distance: OriginGestureDistance): OriginGestureFromSelection;
/** Admit pointer-down within `distance` of the host's bottom edge. */
bottom(distance: OriginGestureDistance): OriginGestureFromSelection;
/** Admit pointer-down anywhere on the gesture host. */
anywhere(): OriginGestureFromSelection;
/** Admit pointer-down when a synchronous application predicate succeeds. */
when(predicate: OriginGestureStartPredicate): OriginGestureFromSelection;
/**
* Admit pointer-down only while `scroller` is scrolled to `edge`, for
* example drag-down-to-dismiss only at the top of a list. Movement the
* other way is left to the scroller. See {@link atScrollEdge}.
*/
scrollEdge(
scroller: OriginGestureScroller,
edge: OriginGestureEdge,
): OriginGestureFromSelection;
}
/**
* A scroll container: an element, a ref or getter returning one, or a CSS
* selector resolved inside the gesture host.
*/
export type OriginGestureScroller =
MaybeRefOrGetter<Element | null | undefined> | string;
/** Builder stage after direction is known and completion remains optional. */
export interface OriginGestureDirectedBuilder {
/** Override the choreography's default release decision. */
complete(
predicate: OriginGestureCompletionPredicate,
): OriginGestureCompletedBuilder;
/** Select the target/history mutation while retaining default completion. */
navigate(
factory: OriginGestureNavigationFactory,
): OriginGestureNavigationBuilder;
/** Run `handler` when a release commits. Handlers accumulate in order. */
onCommit(handler: OriginGestureCommitHandler): OriginGestureDirectedBuilder;
/**
* Run `handler` during a drag when releasing would start to commit, for
* example for a "release now" haptic. See `.onArm()` in the API reference.
*/
onArm(handler: OriginGestureCommitHandler): OriginGestureDirectedBuilder;
/** Run `handler` during a drag when releasing would no longer commit. */
onDisarm(handler: OriginGestureCommitHandler): OriginGestureDirectedBuilder;
}
/** Builder stage after a custom completion policy has been selected. */
export interface OriginGestureCompletedBuilder {
/** Select the target/history mutation performed on commit. */
navigate(
factory: OriginGestureNavigationFactory,
): OriginGestureNavigationBuilder;
/** Run `handler` when a release commits. Handlers accumulate in order. */
onCommit(handler: OriginGestureCommitHandler): OriginGestureCompletedBuilder;
/**
* Run `handler` during a drag when releasing would start to commit, for
* example for a "release now" haptic. See `.onArm()` in the API reference.
*/
onArm(handler: OriginGestureCommitHandler): OriginGestureCompletedBuilder;
/** Run `handler` during a drag when releasing would no longer commit. */
onDisarm(handler: OriginGestureCommitHandler): OriginGestureCompletedBuilder;
}
/** Final builder stage waiting for visual choreography. */
export interface OriginGestureNavigationBuilder {
/** Attach visual choreography and produce an executable definition. */
animate(choreography: OriginChoreography): OriginGestureDefinition;
/** Run `handler` when a release commits. Handlers accumulate in order. */
onCommit(handler: OriginGestureCommitHandler): OriginGestureNavigationBuilder;
/**
* 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. */
export interface OriginGestureBuilder {
/** Optionally constrain where pointer-down may begin. */
readonly from: OriginGestureFromBuilder;
/**
* Select movement direction with an implicit `from.anywhere()` start.
*/
readonly to: OriginGestureToBuilder;
}
/**
* DOM bindings returned by {@link useOriginGesture}.
*
* Attach the pointer handlers to the same `HTMLElement` (and `onTouchmove`
* when {@link needsTouchmove} is true), and apply
* {@link style}: its `touch-action` keeps native scrolling on every axis no
* gesture claims.
*/
export interface OriginGestureBinding {
/**
* 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>;
/** Pointer-down handler that records a potentially eligible gesture. */
readonly onPointerdown: (event: PointerEvent) => void;
/** Pointer-move handler that recognizes and updates the operation. */
readonly onPointermove: (event: PointerEvent) => void;
/** Pointer-up handler that commits or cancels using progress and velocity. */
readonly onPointerup: (event: PointerEvent) => void;
/** Pointer-cancel handler that abandons any captured operation. */
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 ----------------------------------------------------------
/** Public props accepted by the `OriginGesture` convenience component. */
export interface OriginGestureProps {
/**
* Native HTML tag rendered as the gesture host.
*
* @defaultValue `"div"`
*/
as?: string;
/**
* Immutable definition produced by the {@link gesture} builder. Replacing
* it takes effect from the next pointer-down; a gesture in progress keeps
* the definition it started with.
*/
gesture: OriginGestureDefinition;
}
/** Public props accepted by the multi-definition gesture surface component. */
export interface OriginGestureSurfaceProps {
/**
* Native HTML tag rendered as the shared gesture host.
*
* @defaultValue `"div"`
*/
as?: string;
/**
* Complete immutable definitions installed on the shared host.
*
* The surface adds no start, direction, completion, navigation, or animation
* 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[];
}
// Builder ------------------------------------------------------------------
/*
* Immutable fluent builder. Each stage is a frozen object holding the choices
* made so far in a frozen draft; the final `.animate()` produces a frozen
* definition. `.onCommit()` is available at every stage after the direction
* and returns the same stage with the handler appended.
*
* gesture[.from.<start>].to.<direction>()[.complete()].navigate().animate()
*/
/** Choices accumulated by the builder before `.animate()`. */
interface GestureDraft {
readonly start: OriginGestureStart;
readonly direction: OriginGestureDirection;
readonly recognition: Readonly<OriginGestureDirectionOptions>;
readonly completion?: OriginGestureCompletionPredicate;
readonly commitHandlers: readonly OriginGestureCommitHandler[];
readonly armHandlers: readonly OriginGestureCommitHandler[];
readonly disarmHandlers: readonly OriginGestureCommitHandler[];
}
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,
stage: (draft: Draft) => Stage,
) {
const append = (list: HandlerList) => (handler: OriginGestureCommitHandler) =>
stage(
Object.freeze({
...draft,
[list]: Object.freeze([...draft[list], handler]),
}),
);
return {
onCommit: append("commitHandlers"),
onArm: append("armHandlers"),
onDisarm: append("disarmHandlers"),
};
}
function navigationStage(
draft: GestureDraft & { readonly navigation: OriginGestureNavigationFactory },
): OriginGestureNavigationBuilder {
return Object.freeze({
animate: (choreography: OriginChoreography): OriginGestureDefinition =>
Object.freeze({
kind: "origin-gesture-definition",
start: draft.start,
direction: draft.direction,
recognition: draft.recognition,
completion: draft.completion,
navigation: draft.navigation,
choreography,
commitHandlers: draft.commitHandlers,
armHandlers: draft.armHandlers,
disarmHandlers: draft.disarmHandlers,
}),
...hookMethods(draft, navigationStage),
});
}
const navigateFrom =
(draft: GestureDraft) => (navigation: OriginGestureNavigationFactory) =>
navigationStage(Object.freeze({ ...draft, navigation }));
function completedStage(draft: GestureDraft): OriginGestureCompletedBuilder {
return Object.freeze({
navigate: navigateFrom(draft),
...hookMethods(draft, completedStage),
});
}
function directedStage(draft: GestureDraft): OriginGestureDirectedBuilder {
return Object.freeze({
complete: (completion: OriginGestureCompletionPredicate) =>
completedStage(Object.freeze({ ...draft, completion })),
navigate: navigateFrom(draft),
...hookMethods(draft, directedStage),
});
}
function toStage(start: OriginGestureStart): OriginGestureToBuilder {
const direct =
(direction: OriginGestureDirection) =>
(options: OriginGestureDirectionOptions = {}) =>
directedStage(
Object.freeze({
start,
direction,
recognition: Object.freeze({ ...options }),
commitHandlers: Object.freeze([]),
armHandlers: Object.freeze([]),
disarmHandlers: Object.freeze([]),
}),
);
return Object.freeze({
left: direct("left"),
right: direct("right"),
up: direct("up"),
down: direct("down"),
});
}
function fromStage(start: OriginGestureStart): OriginGestureFromSelection {
return Object.freeze({ to: toStage(Object.freeze(start)) });
}
const fromEdge =
(edge: OriginGestureEdge) => (distance: OriginGestureDistance) =>
fromStage({ kind: "edge", edge, distance });
const fromBuilder: OriginGestureFromBuilder = Object.freeze({
left: fromEdge("left"),
right: fromEdge("right"),
top: fromEdge("top"),
bottom: fromEdge("bottom"),
anywhere: () => fromStage({ kind: "anywhere" }),
when: (predicate: OriginGestureStartPredicate) =>
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.
*
* `.from` is optional. Starting at `.to` admits pointer-down anywhere on the
* bound host, exactly like `.from.anywhere().to`.
*
* @example Anywhere-to-right back gesture
* ```ts
* const swipeBack = gesture
* .to.right()
* .navigate((context) => context.canGoBack ? back() : null)
* .animate(slideRight);
* ```
*
* @example Predicate-gated gesture with custom completion
* ```ts
* const openPanel = gesture
* .from.when(({ point, bounds }) => point.localX <= bounds.width * 0.08)
* .to.down()
* .complete(({ progress, velocity }) => progress > 0.5 || velocity > 1)
* .navigate(() => above(originView(PanelView)))
* .animate(dropPanel);
* ```
*/
export const gesture: OriginGestureBuilder = Object.freeze({
from: fromBuilder,
to: toStage(Object.freeze({ kind: "anywhere" })),
});
+919
View File
@@ -0,0 +1,919 @@
import {
computed,
onScopeDispose,
shallowRef,
toValue,
type MaybeRefOrGetter,
} from "vue";
import type { OriginAction } from "../navigation";
import { DEFAULT_COMMIT_THRESHOLD, OriginSceneError } from "../scene/graph";
import { useOrigin, type UseOrigin } from "../scene/scene";
import type { OriginContext, OriginOperationHandle } from "../scene/types";
import { clamp01, measureRect, warn, type OriginRect } from "../shared";
import type {
OriginGestureBinding,
OriginGestureDefinition,
OriginGestureCompletionContext,
OriginGestureDirection,
OriginGestureDistance,
OriginGestureEdge,
OriginGesturePoint,
OriginGestureProgressDistance,
OriginGestureScroller,
OriginGestureStart,
} from "./definition";
/*
* Running gestures: pointer-down admission, movement tracking, and the
* recognizer that turns one host's pointer events into a scene operation.
*/
// Direction ----------------------------------------------------------------
export const isHorizontal = (direction: OriginGestureDirection): boolean => {
return direction === "left" || direction === "right";
};
/** Component of a movement `(dx, dy)` along `direction`. */
export const directedDistance = (
direction: OriginGestureDirection,
dx: number,
dy: number,
): number => {
switch (direction) {
case "left":
return -dx;
case "right":
return dx;
case "up":
return -dy;
case "down":
return dy;
}
};
/** Absolute movement perpendicular to `direction`. */
export const crossDistance = (
direction: OriginGestureDirection,
dx: number,
dy: number,
): number => {
return isHorizontal(direction) ? Math.abs(dy) : Math.abs(dx);
};
/** Whether a gesture shares its axis with native scrolling. */
export const sharesScroll = (definition: OriginGestureDefinition): boolean =>
definition.start.kind === "scroll-edge";
/**
* Whether the browser supports direction-specific `touch-action` values
* (`pan-up`, `pan-down`, …). Chromium does; where it is missing (Safari),
* scroll-edge gestures fall back to claiming touches from JavaScript.
*/
export const supportsDirectionalPan = (): boolean =>
typeof CSS !== "undefined" &&
typeof CSS.supports === "function" &&
CSS.supports("touch-action", "pan-down");
type PanDirection = "left" | "right" | "up" | "down";
/**
* The pan a finger moving `direction` would start. A finger moving down pans
* the content up (toward its top), which is what pull-to-refresh reserves.
*/
const PAN_OF: Record<OriginGestureDirection, PanDirection> = {
down: "up",
up: "down",
left: "right",
right: "left",
};
/**
* CSS `touch-action` for a gesture host. The browser keeps every pan that no
* gesture needs, so native scrolling never waits for JavaScript:
*
* - a gesture that owns its axis removes that axis (`pan-y` remains for a
* horizontal gesture);
* - a gesture sharing its axis with a scroller (`.from.scrollEdge()`) removes
* only the one pan its finger direction would start, and only while the
* scroller is at its edge (`pan-x pan-down` for drag-down at the top), the
* pull-to-refresh pattern from the Pointer Events specification.
*
* @param atEdge - Whether a scroll-edge definition's scroller is at its edge.
* @param directional - Whether directional pan values are supported; without
* them, shared axes stay fully pannable and `onTouchmove` claims touches.
*/
export const touchActionFor = (
definitions: Iterable<Pick<OriginGestureDefinition, "direction" | "start">>,
atEdge: (
definition: Pick<OriginGestureDefinition, "start">,
) => boolean = () => false,
directional = supportsDirectionalPan(),
): string => {
const pans = new Set<PanDirection>(["left", "right", "up", "down"]);
let any = false;
for (const definition of definitions) {
any = true;
if (definition.start.kind === "scroll-edge") {
if (directional && atEdge(definition))
pans.delete(PAN_OF[definition.direction]);
continue;
}
const pair: PanDirection[] = isHorizontal(definition.direction)
? ["left", "right"]
: ["up", "down"];
for (const pan of pair) pans.delete(pan);
}
if (!any) return "auto";
if (pans.size === 4) return "manipulation";
if (pans.size === 0) return "none";
const axis = (a: PanDirection, b: PanDirection, both: string) =>
pans.has(a) && pans.has(b)
? [both]
: pans.has(a)
? [`pan-${a}`]
: pans.has(b)
? [`pan-${b}`]
: [];
return [
...axis("left", "right", "pan-x"),
...axis("up", "down", "pan-y"),
].join(" ");
};
// Pointer-down admission ----------------------------------------------------
/*
* Pointer-down admission: which events may begin a gesture at all.
*/
const IGNORED_TARGETS =
'[data-origin-gesture="ignore"], input, textarea, select, option, [contenteditable="true"]';
/** Form controls and opted-out subtrees never start gestures. */
export const isIgnoredTarget = (target: EventTarget | null): boolean => {
return (
!(target instanceof Element) || Boolean(target.closest(IGNORED_TARGETS))
);
};
export const pointOf = (
event: Pick<PointerEvent, "clientX" | "clientY">,
bounds: OriginRect,
): OriginGesturePoint => {
return {
clientX: event.clientX,
clientY: event.clientY,
localX: event.clientX - bounds.left,
localY: event.clientY - bounds.top,
};
};
const nonNegative = (value: number) =>
Number.isFinite(value) ? Math.max(0, value) : 0;
const PIXELS = /^\s*(-?(?:\d+\.?\d*|\.\d+))px\s*$/;
const PERCENT = /^\s*(-?(?:\d+\.?\d*|\.\d+))%\s*$/;
/**
* Resolve a CSS length against a box with the gesture host's size.
*
* Numbers, pixel lengths, and percentages are computed directly. Anything
* else (`rem`, viewport units, `calc()`, `clamp()`) is measured with a
* short-lived off-screen box so the browser resolves it exactly. This runs
* only during pointer-down for edge-constrained definitions.
*/
export const resolveCssDistance = (
distance: OriginGestureDistance,
axis: "horizontal" | "vertical",
host: HTMLElement,
bounds: OriginRect,
): number => {
if (typeof distance === "number") return nonNegative(distance);
const pixels = PIXELS.exec(distance);
if (pixels) return nonNegative(Number(pixels[1]));
const percent = PERCENT.exec(distance);
if (percent) {
const size = axis === "horizontal" ? bounds.width : bounds.height;
return nonNegative((Number(percent[1]) / 100) * size);
}
const document = host.ownerDocument;
if (!document.body) return Math.max(0, Number.parseFloat(distance) || 0);
const container = document.createElement("div");
const probe = document.createElement("div");
Object.assign(container.style, {
position: "fixed",
left: "-100000px",
top: "-100000px",
width: `${bounds.width}px`,
height: `${bounds.height}px`,
visibility: "hidden",
pointerEvents: "none",
contain: "strict",
});
Object.assign(probe.style, {
position: "absolute",
width: axis === "horizontal" ? distance : "0",
height: axis === "vertical" ? distance : "0",
});
container.append(probe);
document.body.append(container);
const rect = probe.getBoundingClientRect();
container.remove();
return nonNegative(axis === "horizontal" ? rect.width : rect.height);
};
/** Whether a pointer-down satisfies a definition's start policy. */
export const matchesStart = (
start: OriginGestureStart,
event: PointerEvent,
host: HTMLElement,
origin: OriginContext,
bounds: OriginRect,
): boolean => {
if (start.kind === "anywhere") return true;
const point = pointOf(event, bounds);
if (start.kind === "when")
return start.predicate({ event, origin, host, bounds, point });
if (start.kind === "scroll-edge")
return isAtScrollEdge(start.scroller, start.edge, host);
return withinEdge(start.edge, start.distance, host, bounds, point);
};
/**
* Whether `scroller` is scrolled all the way to `edge`. A scroller that is
* missing (not yet mounted) counts as at the edge.
*/
export const isAtScrollEdge = (
scroller: OriginGestureScroller,
edge: OriginGestureEdge,
host: HTMLElement,
): boolean => {
const element =
typeof scroller === "string"
? host.querySelector(scroller)
: toValue(scroller);
if (!element) return true;
const { scrollTop, scrollLeft, scrollHeight, scrollWidth } = element;
const { clientHeight, clientWidth } = element;
switch (edge) {
case "top":
return scrollTop <= 0;
case "bottom":
return scrollTop + clientHeight >= scrollHeight - 1;
case "left":
return scrollLeft <= 0;
case "right":
return scrollLeft + clientWidth >= scrollWidth - 1;
}
};
/** Whether `point` lies within `distance` of one side of the host. */
export const withinEdge = (
edge: OriginGestureEdge,
distance: OriginGestureDistance,
host: HTMLElement,
bounds: OriginRect,
point: OriginGesturePoint,
): boolean => {
const horizontal = edge === "left" || edge === "right";
const limit = resolveCssDistance(
distance,
horizontal ? "horizontal" : "vertical",
host,
bounds,
);
switch (edge) {
case "left":
return point.localX >= 0 && point.localX <= limit;
case "right":
return (
point.localX <= bounds.width && bounds.width - point.localX <= limit
);
case "top":
return point.localY >= 0 && point.localY <= limit;
case "bottom":
return (
point.localY <= bounds.height && bounds.height - point.localY <= limit
);
}
};
// Movement tracking ---------------------------------------------------------
type PointerSample = Pick<PointerEvent, "clientX" | "clientY" | "timeStamp">;
/** Velocity is the trend of the samples in this many recent milliseconds. */
const VELOCITY_WINDOW = 100;
/** Shortest span, in ms, velocity is measured over, so bursts stay finite. */
const MIN_VELOCITY_SPAN = 8;
/**
* Measures one pointer sequence's movement along a gesture direction.
*
* Progress and velocity are normalized by the gesture distance (by default
* the host's size along the gesture axis), so `1` means one full distance.
*/
export class DirectionalTracker {
/** Directed distance from the start, in CSS pixels. */
distance = 0;
/** Absolute cross-axis distance from the start, in CSS pixels. */
crossDistance = 0;
/** Directed distance ÷ gesture distance, unclamped so it can overshoot. */
rawProgress = 0;
/**
* Directed velocity in progress units per second: the least-squares trend
* of the last {@link VELOCITY_WINDOW} ms, so uneven sample timing and a
* final held-still moment do not distort a flick.
*/
velocity = 0;
private readonly samples: { time: number; position: number }[];
constructor(
private readonly direction: OriginGestureDirection,
private readonly start: PointerSample,
/** Gesture distance in CSS pixels, read at every sample. */
private readonly axisSize: () => number,
) {
this.samples = [{ time: start.timeStamp, position: 0 }];
}
/** Directed distance ÷ gesture distance, clamped to `0..1`. */
get progress(): number {
return clamp01(this.rawProgress);
}
/** Movement from the start without recording a sample. */
displacement(event: Pick<PointerSample, "clientX" | "clientY">) {
const dx = event.clientX - this.start.clientX;
const dy = event.clientY - this.start.clientY;
return {
distance: directedDistance(this.direction, dx, dy),
crossDistance: crossDistance(this.direction, dx, dy),
};
}
/** Record a sample, updating distance, progress, and velocity. */
sample(event: PointerSample): void {
const { distance, crossDistance } = this.displacement(event);
this.distance = distance;
this.crossDistance = crossDistance;
const size = Math.max(1, this.axisSize());
this.rawProgress = distance / size;
const samples = this.samples;
samples.push({ time: event.timeStamp, position: distance });
// Keep the window, but always at least two samples to measure between.
const since = event.timeStamp - VELOCITY_WINDOW;
while (samples.length > 2 && samples[1]!.time <= since) samples.shift();
this.velocity = (DirectionalTracker.trend(samples) * 1000) / size;
}
/** Least-squares slope of position over time, in pixels per ms. */
private static trend(samples: readonly { time: number; position: number }[]) {
const first = samples[0]!;
const last = samples[samples.length - 1]!;
if (last.time - first.time < MIN_VELOCITY_SPAN)
return (last.position - first.position) / MIN_VELOCITY_SPAN;
const meanTime =
samples.reduce((sum, { time }) => sum + time, 0) / samples.length;
const meanPosition =
samples.reduce((sum, { position }) => sum + position, 0) / samples.length;
let covariance = 0;
let variance = 0;
for (const { time, position } of samples) {
covariance += (time - meanTime) * (position - meanPosition);
variance += (time - meanTime) ** 2;
}
return covariance / variance;
}
}
// Recognizer ----------------------------------------------------------------
/** Directed movement in CSS pixels required before a gesture captures. */
const DEFAULT_THRESHOLD = 8;
/** Ratio by which directed movement must exceed cross-axis movement. */
const DEFAULT_AXIS_DOMINANCE = 1.15;
/** Resolve a definition's progress distance at pointer-down, in pixels. */
export const resolveProgressDistance = (
distance: OriginGestureProgressDistance | undefined,
direction: OriginGestureDirection,
host: HTMLElement,
bounds: OriginRect,
): (() => number) => {
const horizontal = isHorizontal(direction);
if (distance === undefined)
return () => (horizontal ? host.clientWidth : host.clientHeight);
const pixels =
typeof distance === "function"
? distance({ host, bounds })
: resolveCssDistance(
distance,
horizontal ? "horizontal" : "vertical",
host,
bounds,
);
return () => pixels;
};
/** The origin capabilities a recognizer needs. */
export type RecognizerOrigin = Pick<UseOrigin, "context" | "begin">;
/** State of one admitted pointer sequence, from pointer-down to release. */
interface PointerSession {
/** Definition current at pointer-down; later changes do not affect it. */
readonly definition: OriginGestureDefinition;
readonly pointerId: number;
readonly host: HTMLElement;
readonly bounds: OriginRect;
/** Origin context captured at pointer-down. */
readonly origin: OriginContext;
readonly start: OriginGesturePoint;
readonly startTime: number;
readonly tracker: DirectionalTracker;
/**
* For a gesture sharing its axis with scrolling: whether it has claimed
* this touch from native scrolling (`ours`) or not yet decided.
*/
scroll: "pending" | "ours";
/** Latest pointer position, for the touch-move decision. */
last: { readonly clientX: number; readonly clientY: number };
/** Movement passed the recognition threshold and the pointer is captured. */
captured: boolean;
/** Releasing now would commit; drives `.onArm()` and `.onDisarm()`. */
armed: boolean;
/** The scene operation, once navigation resolved and `begin()` was called. */
operation: Promise<OriginOperationHandle | null> | null;
/**
* The operation's handle once `begin()` resolved, so pointer-up can finish
* it synchronously, inside the event, without awaiting the promise.
*/
handle: OriginOperationHandle | null;
}
/**
* Turns one host's pointer events into a scene operation.
*
* Lifecycle of a session:
*
* 1. **down**: an eligible primary pointer opens a session.
* 2. **move**: once directed movement passes the threshold, the pointer is
* captured and `.navigate()` is resolved; a non-null intent begins an
* operation, while a declined one closes the session. Every later move
* updates the operation's progress.
* 3. **up**: the session closes and the operation finishes, using the
* definition's `.complete()` predicate when present.
* 4. **cancel**: pointer-cancel, lost pointer capture, a new primary
* pointer-down, or disposal closes the session and cancels its operation.
*
* Navigation and `begin()` are asynchronous. Each continuation checks that
* its session is still current, so a stale resolution never creates or
* updates an orphan operation.
*/
export class GestureRecognizer {
private session: PointerSession | null = null;
constructor(
/** Reads the current definition; it may change between sessions. */
private readonly definitionOf: () => OriginGestureDefinition,
private readonly origin: RecognizerOrigin,
) {}
/**
* Called for each `touchmove`: whether native scrolling must be cancelled.
*
* Only gestures that share an axis with scrolling take part. The browser
* sends its first cancelable `touchmove` after roughly 15–20px of movement,
* so the decision uses all of that movement: along this gesture's axis and
* direction claims the touch; clearly elsewhere lets it go to the browser
* (which then scrolls and sends `pointercancel`); unclear holds it for one
* more move.
*/
claimTouch(): boolean {
const session = this.session;
if (!session || !sharesScroll(session.definition)) return false;
if (session.captured || session.scroll === "ours") return true;
const { distance, crossDistance } = session.tracker.displacement(
session.last,
);
const dominance =
session.definition.recognition.axisDominance ?? DEFAULT_AXIS_DOMINANCE;
const along = Math.abs(distance);
if (distance > 0 && along >= crossDistance * dominance) {
session.scroll = "ours";
return true;
}
if (
along >= crossDistance * dominance ||
crossDistance >= along * dominance
) {
this.session = null;
return false;
}
return true;
}
pointerDown(event: PointerEvent): void {
if (!event.isPrimary) return;
// Only one primary pointer exists at a time, so any session still open
// lost its pointer-up (for example, released outside the window).
void this.abandon();
const host = event.currentTarget;
if (
event.button !== 0 ||
!(host instanceof HTMLElement) ||
isIgnoredTarget(event.target)
)
return;
const definition = this.definitionOf();
const bounds = measureRect(host);
const origin = this.origin.context.value;
if (!matchesStart(definition.start, event, host, origin, bounds)) return;
// The component containing this declaration is the operation's origin.
event.stopPropagation();
this.session = {
definition,
pointerId: event.pointerId,
host,
bounds,
origin,
start: pointOf(event, bounds),
startTime: event.timeStamp,
tracker: new DirectionalTracker(
definition.direction,
event,
resolveProgressDistance(
definition.recognition.distance,
definition.direction,
host,
bounds,
),
),
scroll: "pending",
last: { clientX: event.clientX, clientY: event.clientY },
captured: false,
armed: false,
operation: null,
handle: null,
};
}
async pointerMove(event: PointerEvent): Promise<void> {
const session = this.sessionFor(event);
if (!session) return;
session.last = { clientX: event.clientX, clientY: event.clientY };
if (!session.captured) {
if (!GestureRecognizer.recognizes(session, event)) return;
session.captured = true;
session.host.setPointerCapture?.(session.pointerId);
event.preventDefault();
session.tracker.sample(event);
this.updateArming(session, event);
const action = await this.resolveAction(session.definition);
// The pointer may have been released or cancelled meanwhile.
if (this.session !== session) return;
if (!action) {
this.session = null;
return;
}
session.operation = this.origin.begin(action).then(
(handle) => (session.handle = handle),
(error) => {
// Another recognizer on this origin won the race; that is expected.
if (!(
error instanceof OriginSceneError && error.code === "origin-busy"
))
warn("A gesture could not begin its operation.", error);
return null;
},
);
} else {
event.preventDefault();
session.tracker.sample(event);
this.updateArming(session, event);
}
// Progress is read after the await so the latest buffered sample wins.
const pending = session.operation;
const handle = pending ? await pending : null;
if (this.session === session && session.operation === pending)
handle?.update(session.tracker.rawProgress, session.tracker.velocity);
}
async pointerUp(event: PointerEvent): Promise<void> {
const session = this.sessionFor(event);
if (!session) return;
this.session = null;
if (!session.captured) return;
const { tracker, definition } = session;
tracker.sample(event);
const context = GestureRecognizer.contextOf(session, event);
const commit = definition.completion?.(context);
const { rawProgress: progress, velocity } = tracker;
// Usually the operation began long ago: finish it without awaiting, so
// commit handlers run inside this pointer-up (iOS only allows haptics,
// audio, and clipboard writes during the user's gesture).
const handle =
session.handle ?? (session.operation ? await session.operation : null);
if (!handle) return;
handle.update(progress, velocity);
// finish() fixes the outcome synchronously, before the spring settles.
const settled = handle.finish(
commit === undefined ? undefined : { commit },
);
if (handle.intent === "commit")
GestureRecognizer.notify(definition.commitHandlers ?? [], context);
await settled;
}
/**
* The host lost pointer capture without a pointer-up; cancel like
* pointer-cancel. The event bubbles, and taking capture makes the touched
* child lose its implicit touch capture, so only the host's own loss counts.
*/
pointerCaptureLost(event: PointerEvent): Promise<void> {
const session = this.sessionFor(event);
return session && event.target === session.host
? this.abandon()
: Promise.resolve();
}
pointerCancel(): Promise<void> {
return this.abandon();
}
/** Close the current session, cancelling its operation if captured. */
async abandon(): Promise<void> {
const session = this.session;
this.session = null;
if (!session?.captured) return;
const handle = session.operation ? await session.operation : null;
await handle?.cancel();
}
/** Fire `.onArm()` / `.onDisarm()` when the release decision would flip. */
private updateArming(session: PointerSession, event: PointerEvent) {
const { definition, tracker } = session;
const armHandlers = definition.armHandlers ?? [];
const disarmHandlers = definition.disarmHandlers ?? [];
if (!armHandlers.length && !disarmHandlers.length) return;
const context = GestureRecognizer.contextOf(session, event);
// Distance decides arming unless the definition supplies .complete():
// velocity changes too quickly to drive a cue without flickering.
const armed = definition.completion
? definition.completion(context)
: tracker.progress >=
(definition.choreography.commitThreshold ?? DEFAULT_COMMIT_THRESHOLD);
if (armed === session.armed) return;
session.armed = armed;
GestureRecognizer.notify(armed ? armHandlers : disarmHandlers, context);
}
private static contextOf(
session: PointerSession,
event: PointerEvent,
): OriginGestureCompletionContext {
const { tracker } = session;
return {
origin: session.origin,
direction: session.definition.direction,
progress: tracker.progress,
velocity: tracker.velocity,
distance: tracker.distance,
crossDistance: tracker.crossDistance,
duration: Math.max(0, event.timeStamp - session.startTime),
event,
host: session.host,
bounds: session.bounds,
start: session.start,
current: pointOf(event, session.bounds),
};
}
private sessionFor(event: PointerEvent) {
const session = this.session;
return session && session.pointerId === event.pointerId ? session : null;
}
/** Directed movement passed the threshold and dominates the cross axis. */
private static recognizes(session: PointerSession, event: PointerEvent) {
const { recognition } = session.definition;
const { distance, crossDistance } = session.tracker.displacement(event);
return (
distance >= (recognition.threshold ?? DEFAULT_THRESHOLD) &&
distance >=
crossDistance * (recognition.axisDominance ?? DEFAULT_AXIS_DOMINANCE)
);
}
/**
* Resolve the definition's navigation against the origin's current context
* and pair it with its choreography. A declined navigation resolves to
* `null`; a failing one also warns.
*/
private resolveAction(
definition: OriginGestureDefinition,
): Promise<OriginAction | null> {
const fail = (error: unknown) => {
warn("A gesture's navigate() factory failed.", error);
return null;
};
try {
return Promise.resolve(
definition.navigation(this.origin.context.value),
).then(
(navigation) =>
navigation
? { ...navigation, choreography: definition.choreography }
: null,
fail,
);
} catch (error) {
return Promise.resolve(fail(error));
}
}
/** Run handlers in order; a failing handler never interrupts the others. */
private static notify(
handlers: readonly ((context: OriginGestureCompletionContext) => void)[],
context: OriginGestureCompletionContext,
) {
for (const handler of handlers) {
try {
handler(context);
} catch (error) {
queueMicrotask(() => {
throw error;
});
}
}
}
}
// Composables ---------------------------------------------------------------
/**
* Bind any number of definitions to one host. Recognizers are kept per list
* position, so re-rendering with new definition objects never interrupts a
* gesture in progress; each change applies from the next pointer-down.
*/
export const useOriginGestures = (
definitions: MaybeRefOrGetter<readonly OriginGestureDefinition[]>,
): OriginGestureBinding => {
const origin = useOrigin();
const recognizers: GestureRecognizer[] = [];
/** Grow or shrink the recognizer list to match the current definitions. */
const current = () => {
const count = toValue(definitions).length;
while (recognizers.length < count) {
const index = recognizers.length;
recognizers.push(
new GestureRecognizer(() => toValue(definitions)[index]!, origin),
);
}
for (const removed of recognizers.splice(count)) void removed.abandon();
return recognizers;
};
// The host element, for scroll-edge state and selector scrollers.
const host = shallowRef<HTMLElement | null>(null);
// Bumped by scroll events; touch-action re-reads scroll edges on change.
const scrolled = shallowRef(0);
const directional = supportsDirectionalPan();
const atEdge = (definition: Pick<OriginGestureDefinition, "start">) => {
const { start } = definition;
if (start.kind !== "scroll-edge") return false;
const element = host.value;
if (!element && typeof start.scroller === "string") return true;
return isAtScrollEdge(start.scroller, start.edge, element!);
};
// A string, so hosts re-render only when touch-action actually changes,
// never merely because something scrolled.
const touchAction = computed(() => {
void scrolled.value;
return touchActionFor(toValue(definitions), atEdge, directional);
});
const sharesScrollAxis = computed(() =>
toValue(definitions).some(sharesScroll),
);
const style = computed(() => {
const value = touchAction.value;
return {
width: "100%",
height: "100%",
// Native scrolling stays on every pan no gesture needs.
touchAction: value,
// Inherited by descendants through the package stylesheet, so nested
// scroll containers (where browsers restart touch-action) keep it too.
"--nvo-touch-action": value,
};
});
// A recognizer whose component unmounts mid-gesture cancels its operation.
onScopeDispose(() => {
for (const recognizer of recognizers.splice(0)) void recognizer.abandon();
});
// Only without directional touch-action do scroll-edge gestures need to
// claim touches from JavaScript, the one path that blocks scrolling.
const needsTouchmove = computed(() => sharesScrollAxis.value && !directional);
const onTouchmove = (event: TouchEvent) => {
if (!event.cancelable) return;
// Ask every recognizer: each one may let go of the touch as it answers.
let claimed = false;
for (const recognizer of current())
if (recognizer.claimTouch()) claimed = true;
if (claimed) event.preventDefault();
};
const onScrollCapture = () => {
scrolled.value += 1;
};
const listeners = computed(() => ({
...(sharesScrollAxis.value ? { onScrollCapture } : {}),
...(needsTouchmove.value ? { onTouchmove } : {}),
}));
return {
get style() {
return style.value;
},
get needsTouchmove() {
return needsTouchmove.value;
},
get listeners() {
return listeners.value as Record<string, (event: Event) => void>;
},
ref: (element) => {
host.value = element instanceof HTMLElement ? element : null;
scrolled.value += 1;
},
onPointerdown: (event) => {
for (const recognizer of current()) recognizer.pointerDown(event);
},
onPointermove: (event) => {
for (const recognizer of current()) void recognizer.pointerMove(event);
},
onPointerup: (event) => {
for (const recognizer of current()) void recognizer.pointerUp(event);
},
onPointercancel: () => {
for (const recognizer of current()) void recognizer.pointerCancel();
},
onLostpointercapture: (event) => {
for (const recognizer of current())
void recognizer.pointerCaptureLost(event);
},
onTouchmove,
};
};
/**
* Install a component-owned pointer recognizer.
*
* Recognition is local to the element receiving these handlers. There is no
* application-wide gesture table and no lookup of a currently active view.
*
* Builder definitions may independently describe their pointer-down region
* and movement direction. Omitting `.from` recognizes pointer-down across the
* whole element.
*
* Interactive controls and anything inside
* `[data-origin-gesture="ignore"]` are ignored automatically. The recognizer
* preserves native scrolling on the cross-axis through its returned style.
*
* @param definition - An immutable builder result, or a ref or getter
* returning one. A changed definition applies from the next pointer-down.
* @returns Pointer handlers and required host styles.
* @throws If called outside a component rendered by `OriginScene`.
*
* @example Builder-defined backward gesture
* ```ts
* const swipeBack = useOriginGesture(
* gesture
* .from.left("32px")
* .to.right()
* .navigate((context) => context.canGoBack ? back() : null)
* .animate(slideRight),
* );
* ```
*/
export function useOriginGesture(
definition: MaybeRefOrGetter<OriginGestureDefinition>,
): OriginGestureBinding {
return useOriginGestures(() => [toValue(definition)]);
}
+60 -5
View File
@@ -5,14 +5,69 @@
* origin-relative operation frames. It does not depend on Vue Router or choose
* a globally active view.
*
* Modules, from the bottom up:
*
* - `shared` geometry and environment helpers
* - `choreography` visual effects, presets, and effect compositing
* - `navigation` view recipes, intents, and actions (`forward`, …)
* - `scene/graph` the scene model and its history rules
* - `scene/scene` timing, measuring, compositing, `useOrigin`
* - `gesture/definition` gesture types and the immutable builder
* - `gesture/recognizer` pointer admission, tracking, and recognition
* - `components/` Vue renderers and gesture hosts
*
* @packageDocumentation
*/
export * from "./types";
export * from "./scene";
export * from "./motion";
export * from "./gesture";
export * from "./lifecycle";
export type { OriginRect } from "./shared";
export type {
OriginChoreography,
OriginChoreographyContext,
OriginEffect,
OriginEffectSet,
OriginOperationIntent,
OriginOperationPhase,
OriginSpring,
} from "./choreography";
export {
defineOriginChoreography,
fade,
normalizedEffect,
slideLeft,
slideRight,
} from "./choreography";
export type * from "./navigation";
export {
above,
back,
forward,
originAction,
originView,
replace,
under,
} from "./navigation";
export type * from "./scene/types";
export type { OriginNodeScope, UseOrigin } from "./scene/scene";
export { OriginSceneError, type OriginSceneErrorCode } from "./scene/graph";
export {
createOriginScene,
originNodeScopeKey,
useOrigin,
} from "./scene/scene";
export type * from "./gesture/definition";
export {
allOf,
anyOf,
atScrollEdge,
gesture,
nearEdge,
startedIn,
} from "./gesture/definition";
export { useOriginGesture } from "./gesture/recognizer";
/**
* Convenience component that binds one `useOriginGesture()` recognizer to a
-53
View File
@@ -1,53 +0,0 @@
import { computed, inject, type InjectionKey } from "vue";
import type { OriginNodeScope, UseOrigin } from "./types";
/**
* Injection key used by the internal scene-node host to establish origin
* ownership for descendant components.
*
* Application code normally calls {@link useOrigin} instead of injecting this
* key directly.
*
* @internal
*/
export const originNodeScopeKey: InjectionKey<OriginNodeScope> =
Symbol("origin-node-scope");
/**
* Access the scene from the component that owns an interaction declaration.
*
* There is deliberately no `activeView`: the injected node is the origin
* because this component is where the event or application action occurred.
*
* @returns Node-scoped scene state and operation controls.
* @throws If called outside a component rendered by `OriginScene`.
*
* @example
* ```ts
* const origin = useOrigin();
*
* function openProfile() {
* return origin.perform(
* forward(originView(ProfileView), slideLeft),
* );
* }
* ```
*/
export function useOrigin(): UseOrigin {
const scope = inject(originNodeScopeKey);
if (!scope)
throw new Error("useOrigin() must be called inside an <OriginScene> view.");
const context = computed(() => scope.scene.contextFor(scope.nodeKey));
return {
nodeKey: scope.nodeKey,
scene: scope.scene,
context,
view: computed(() => context.value.view),
previous: computed(() => context.value.previous),
canGoBack: computed(() => context.value.canGoBack),
begin: (action) => scope.scene.begin(scope.nodeKey, action),
perform: (action) => scope.scene.perform(scope.nodeKey, action),
};
}
-370
View File
@@ -1,370 +0,0 @@
import type {
OriginAction,
OriginChoreography,
OriginEffect,
OriginEffectSet,
OriginHistoryMode,
OriginNavigationIntent,
OriginPlacement,
OriginView,
} from "./types";
/**
* Preserve type inference while declaring a custom choreography.
*
* The helper performs no runtime transformation. It gives custom routines a
* named, documented construction point and validates their shape in TypeScript.
*
* @param choreography - Side-effect-free visual effect calculator and optional
* release thresholds.
* @returns The same choreography object.
*/
export function defineOriginChoreography(
choreography: OriginChoreography,
): OriginChoreography {
return choreography;
}
/** Options accepted by {@link originAction}. */
export interface OriginActionOptions {
/**
* Target stacking relationship during the operation.
*
* @defaultValue `"above"`
*/
placement?: OriginPlacement;
/**
* History mutation assigned to the created target node.
*
* @defaultValue `"push"`
*/
history?: OriginHistoryMode;
}
/** History options shared by the {@link above} and {@link under} helpers. */
export interface OriginPlacementActionOptions {
/**
* History mutation assigned to the created target node.
*
* @defaultValue `"push"`
*/
history?: OriginHistoryMode;
}
/** Stacking options accepted by retained-history navigation helpers. */
export interface OriginNavigationActionOptions {
/**
* Target stacking relationship during the operation.
*
* @defaultValue `"above"` for {@link forward} and {@link replace}, `"under"`
* for {@link back}
*/
placement?: OriginPlacement;
}
function isChoreography(
value: OriginChoreography | object | undefined,
): value is OriginChoreography {
return (
value !== undefined &&
"effects" in value &&
typeof value.effects === "function"
);
}
/**
* Create the scene mutation invoked by a click, gesture, hardware command, or
* any other application event. `above` and `under` affect stacking only; they
* do not imply a universal navigation direction.
*
* @param target - View recipe to create.
* @param choreography - Visual routine controlling the operation.
* @param options - Stacking and history behavior.
* @returns An action that can be passed to `begin()`, `perform()`, or returned
* from a gesture action factory.
*/
export function originAction(
target: OriginView,
choreography: OriginChoreography,
options: OriginActionOptions = {},
): OriginAction {
return {
target,
choreography,
placement: options.placement ?? "above",
history: options.history ?? "push",
};
}
/**
* Create a retained-history push intent or complete action.
*
* Committing the action parks its origin instance and leaves it mounted until
* a later committed {@link back} action pops the new entry. Omit choreography
* when declaring `.navigate()` inside a gesture builder; provide choreography
* when passing the result directly to `begin()` or `perform()`.
*
* @param target - View recipe created if the push commits.
* @param options - Stacking options for an animation-free navigation intent.
* @returns An animation-free intent for use with a gesture builder.
*/
export function forward(
target: OriginView,
options?: OriginNavigationActionOptions,
): OriginNavigationIntent;
export function forward(
target: OriginView,
choreography: OriginChoreography,
options?: OriginNavigationActionOptions,
): OriginAction;
export function forward(
target: OriginView,
choreographyOrOptions:
OriginChoreography | OriginNavigationActionOptions = {},
options: OriginNavigationActionOptions = {},
): OriginAction | OriginNavigationIntent {
if (isChoreography(choreographyOrOptions)) {
return originAction(target, choreographyOrOptions, {
placement: options.placement ?? "above",
history: "push",
});
}
return {
target,
placement: choreographyOrOptions.placement ?? "above",
history: "push",
};
}
/**
* Create a retained-history replacement intent or complete action.
*
* The target inherits the origin's previous mounted entry instead of retaining
* the origin itself. A commit unmounts the origin and activates the target, so
* a later {@link back} skips the replaced entry. A cancellation removes the
* proposed target and restores the origin unchanged.
*
* Omit choreography when declaring `.navigate()` inside a gesture builder;
* provide choreography when passing the result directly to `begin()` or
* `perform()`.
*
* @param target - View recipe created for the replacement.
* @param options - Stacking options for an animation-free navigation intent.
* @returns An animation-free replacement intent for use with a gesture builder.
*/
export function replace(
target: OriginView,
options?: OriginNavigationActionOptions,
): OriginNavigationIntent;
export function replace(
target: OriginView,
choreography: OriginChoreography,
options?: OriginNavigationActionOptions,
): OriginAction;
export function replace(
target: OriginView,
choreographyOrOptions:
OriginChoreography | OriginNavigationActionOptions = {},
options: OriginNavigationActionOptions = {},
): OriginAction | OriginNavigationIntent {
if (isChoreography(choreographyOrOptions)) {
return originAction(target, choreographyOrOptions, {
placement: options.placement ?? "above",
history: "replace",
});
}
return {
target,
placement: choreographyOrOptions.placement ?? "above",
history: "replace",
};
}
/**
* Create a retained-history back intent or complete action.
*
* The action intentionally has no target recipe. At execution time the scene
* resolves the origin's mounted `previousNodeKey`, reveals that exact instance,
* and removes the current instance only if the operation commits. Omit
* choreography inside gesture `.navigate()`; provide it for a programmatic
* action.
*
* @param options - Stacking options for an animation-free navigation intent.
* @returns An animation-free back intent for use with a gesture builder.
*/
export function back(
options?: OriginNavigationActionOptions,
): OriginNavigationIntent;
export function back(
choreography: OriginChoreography,
options?: OriginNavigationActionOptions,
): OriginAction;
export function back(
choreographyOrOptions:
OriginChoreography | OriginNavigationActionOptions = {},
options: OriginNavigationActionOptions = {},
): OriginAction | OriginNavigationIntent {
if (!isChoreography(choreographyOrOptions)) {
return {
placement: choreographyOrOptions.placement ?? "under",
history: "back",
};
}
return {
choreography: choreographyOrOptions,
placement: options.placement ?? "under",
history: "back",
};
}
/**
* Create an intent or action whose target is stacked above its origin.
*
* This helper controls stacking, not movement direction. The supplied
* choreography may move either component however the application chooses.
* Omitting choreography produces an intent for gesture `.navigate()`.
*
* @param target - View recipe to create.
* @param choreography - Visual routine controlling the operation.
* @param options - Optional history behavior.
*/
export function above(
target: OriginView,
options?: OriginPlacementActionOptions,
): OriginNavigationIntent;
export function above(
target: OriginView,
choreography: OriginChoreography,
options?: OriginPlacementActionOptions,
): OriginAction;
export function above(
target: OriginView,
choreographyOrOptions: OriginChoreography | OriginPlacementActionOptions = {},
options: OriginPlacementActionOptions = {},
): OriginAction | OriginNavigationIntent {
if (isChoreography(choreographyOrOptions)) {
return originAction(target, choreographyOrOptions, {
placement: "above",
history: options.history,
});
}
return {
target,
placement: "above",
history: choreographyOrOptions.history ?? "push",
};
}
/**
* Create an intent or action whose target is stacked underneath its origin.
*
* Commonly used for custom reveal effects, but it has no implicit history
* meaning. Prefer {@link back} for retained-history navigation. Omitting
* choreography produces an intent for gesture `.navigate()`.
*
* @param target - View recipe to create underneath the origin.
* @param choreography - Visual routine controlling the operation.
* @param options - Optional history behavior.
*/
export function under(
target: OriginView,
options?: OriginPlacementActionOptions,
): OriginNavigationIntent;
export function under(
target: OriginView,
choreography: OriginChoreography,
options?: OriginPlacementActionOptions,
): OriginAction;
export function under(
target: OriginView,
choreographyOrOptions: OriginChoreography | OriginPlacementActionOptions = {},
options: OriginPlacementActionOptions = {},
): OriginAction | OriginNavigationIntent {
if (isChoreography(choreographyOrOptions)) {
return originAction(target, choreographyOrOptions, {
placement: "under",
history: options.history,
});
}
return {
target,
placement: "under",
history: choreographyOrOptions.history ?? "push",
};
}
const percent = (value: number) => `${(value * 100).toFixed(4)}%`;
/**
* Native-style forward motion: the target enters above the source while the
* source recedes slightly. These presets are examples; applications can
* replace them with arbitrary `defineOriginChoreography()` callbacks.
*/
export const slideLeft = defineOriginChoreography({
name: "slide-left",
commitThreshold: 0.36,
commitVelocity: 0.6,
effects: ({ progress }): OriginEffectSet => ({
source: {
transform: `translate3d(${percent(progress * -0.24)}, 0, 0) scale(${1 - progress * 0.025})`,
opacity: 1 - progress * 0.16,
},
target: {
transform: `translate3d(${percent(1 - progress)}, 0, 0)`,
},
}),
});
/**
* Back motion reveals the target underneath the source. This is merely a
* visual routine; the {@link back} action selects retained-history behavior.
*/
export const slideRight = defineOriginChoreography({
name: "slide-right",
commitThreshold: 0.36,
commitVelocity: 0.6,
effects: ({ progress }): OriginEffectSet => ({
source: {
transform: `translate3d(${percent(progress)}, 0, 0)`,
},
target: {
transform: `translate3d(${percent(-0.24 + progress * 0.24)}, 0, 0) scale(${0.975 + progress * 0.025})`,
opacity: 0.84 + progress * 0.16,
},
}),
});
/**
* Cross-fade preset that fades the source out while fading the target in.
*
* The default release thresholds from the scene are used.
*/
export const fade = defineOriginChoreography({
name: "fade",
effects: ({ progress }): OriginEffectSet => ({
source: { opacity: 1 - progress },
target: { opacity: progress },
}),
});
/**
* Normalize one contribution before the compositor combines it with effects
* inherited from earlier origin frames.
*
* Most applications should return plain effects from a choreography and let
* the scene call this function. It is exported for custom compositors and
* diagnostics.
*
* @param effect - Optional effect to normalize.
* @param fallbackLayer - Relative layer contribution added to `effect.layer`.
* @returns A defined effect with a numeric layer.
*/
export function normalizedEffect(
effect: OriginEffect | undefined,
fallbackLayer = 0,
): OriginEffect {
return {
...effect,
layer: (effect?.layer ?? 0) + fallbackLayer,
};
}
+434
View File
@@ -0,0 +1,434 @@
import { markRaw, type Component } from "vue";
import { isChoreography, type OriginChoreography } from "./choreography";
/*
* Navigation vocabulary: view recipes (what to mount) and the intents and
* actions (how history changes) that operations execute.
*/
/** Props accepted by a Vue component definition. */
export type PropsOf<T> = T extends abstract new (...args: any[]) => {
$props: infer P;
}
? P
: T extends (props: infer P, ...args: any[]) => any
? P
: never;
/**
* A view is a recipe for creating a Vue component, not a mounted instance.
*
* Forward navigation uses the recipe to create a mounted history entry. That
* instance remains mounted until a committed back operation pops it.
*
* @typeParam T - The component mounted by the recipe.
*/
export interface OriginView<T extends Component = Component> {
/** The Vue component definition that will be mounted for this recipe. */
readonly component: T;
/** Props passed to the component when the recipe is mounted. */
readonly props?: Readonly<PropsOf<T>>;
/**
* A developer-facing recipe identity used in diagnostics and as the prefix
* of generated scene-node keys. It does not make Vue reuse an instance.
*/
readonly key?: string;
/** A human-readable label used by diagnostics and DOM data attributes. */
readonly name?: string;
}
/** Optional developer-facing metadata for an {@link OriginView} recipe. */
export interface OriginViewOptions {
/**
* Stable recipe identity used in diagnostics and generated node-key prefixes.
* It does not preserve or reuse a mounted Vue component instance.
*/
key?: string;
/** Human-readable label used by inspectors and DOM data attributes. */
name?: string;
}
let viewSequence = 0;
function componentName(component: Component): string | undefined {
return typeof component === "object" && "name" in component
? String(component.name)
: undefined;
}
/**
* Turn a component and props into a lightweight, reusable view recipe.
*
* Components are marked raw so Vue never attempts to proxy their definitions
* when recipes are placed in reactive scene/history structures.
*
* @param component - Vue component definition to mount.
* @param props - Props captured by the recipe.
* @param options - Optional recipe identity and diagnostic name.
* @returns An immutable, reusable component recipe. It is not a Vue instance.
*
* @example
* ```ts
* const profile = originView(
* ProfileView,
* { userId: "42" },
* { key: "profile-42", name: "Profile" },
* );
* ```
*/
export function originView<T extends Component = Component>(
component: T,
props?: PropsOf<T>,
options: OriginViewOptions = {},
): OriginView<T> {
const name = options.name ?? componentName(component);
return markRaw({
component: markRaw(component),
props,
key: options.key ?? `${name ?? "view"}-${++viewSequence}`,
name,
});
}
/**
* Controls how an operation participates in retained instance history.
*
* - `push`: create a target whose previous entry is the mounted origin.
* - `replace`: create a target that inherits the origin's previous entry, then
* remove the origin on commit.
* - `back`: reuse the mounted previous entry and pop the origin on commit.
*/
export type OriginHistoryMode = "push" | "replace" | "back";
/**
* The target's stacking relationship to its origin while an operation exists.
*/
export type OriginPlacement = "above" | "under";
/**
* A scene mutation without visual choreography.
*
* Gesture builders keep navigation intent separate from animation so the same
* destination can be paired with different component-local interactions.
* Calling `.animate()` materializes this intent as an {@link OriginAction}.
*/
export interface OriginNavigationIntent {
/**
* Recipe for a newly created push or replacement target. Back navigation
* omits this because the scene resolves the retained previous instance.
*/
readonly target?: OriginView;
/** Target stacking relationship while the gesture operation is visible. */
readonly placement?: OriginPlacement;
/** Retained-history mutation performed if the gesture commits. */
readonly history: OriginHistoryMode;
}
/** A complete request to create and animate a target view from an origin. */
export interface OriginAction {
/**
* Recipe for a newly created push or replacement target. A back action omits
* this because the scene resolves its already-mounted previous entry.
*/
readonly target?: OriginView;
/** Visual relationship applied to the source, target, and shared frame. */
readonly choreography: OriginChoreography;
/**
* Target stacking relationship during the operation.
*
* @defaultValue `"above"`
*/
readonly placement?: OriginPlacement;
/**
* History mutation applied to the target recipe.
*
* @defaultValue `"push"`
*/
readonly history?: OriginHistoryMode;
}
/** Options accepted by {@link originAction}. */
export interface OriginActionOptions {
/**
* Target stacking relationship during the operation.
*
* @defaultValue `"above"`
*/
placement?: OriginPlacement;
/**
* History mutation assigned to the created target node.
*
* @defaultValue `"push"`
*/
history?: OriginHistoryMode;
}
/** History options shared by the {@link above} and {@link under} helpers. */
export interface OriginPlacementActionOptions {
/**
* History mutation assigned to the created target node.
*
* @defaultValue `"push"`
*/
history?: OriginHistoryMode;
}
/** Stacking options accepted by retained-history navigation helpers. */
export interface OriginNavigationActionOptions {
/**
* Target stacking relationship during the operation.
*
* @defaultValue `"above"` for {@link forward} and {@link replace}, `"under"`
* for {@link back}
*/
placement?: OriginPlacement;
}
/*
* Every helper accepts either `(…, options?)`, producing an animation-free
* navigation intent for gesture `.navigate()`, or `(…, choreography,
* options?)`, producing a complete action for `begin()`/`perform()`.
*/
interface HelperArguments<Options> {
readonly choreography?: OriginChoreography;
readonly options: Options;
}
function helperArguments<Options extends object>(
choreographyOrOptions: OriginChoreography | Options | undefined,
options: Options | undefined,
): HelperArguments<Partial<Options>> {
return isChoreography(choreographyOrOptions)
? { choreography: choreographyOrOptions, options: options ?? {} }
: { options: choreographyOrOptions ?? {} };
}
function navigation(
target: OriginView | undefined,
placement: OriginPlacement,
history: OriginHistoryMode,
): OriginNavigationIntent {
return { target, placement, history };
}
/** Back navigation resolves its target at execution time, so it has none. */
function targetlessNavigation(
placement: OriginPlacement,
history: OriginHistoryMode,
): OriginNavigationIntent {
return { placement, history };
}
/** Pair a navigation intent with choreography, or return it unchanged. */
function materialize(
intent: OriginNavigationIntent,
choreography: OriginChoreography | undefined,
): OriginNavigationIntent | OriginAction {
if (!choreography) return intent;
const { placement, history } = intent;
return "target" in intent
? { target: intent.target, choreography, placement, history }
: { choreography, placement, history };
}
/**
* Create the scene mutation invoked by a click, gesture, hardware command, or
* any other application event. `above` and `under` affect stacking only; they
* do not imply a universal navigation direction.
*
* @param target - View recipe to create.
* @param choreography - Visual routine controlling the operation.
* @param options - Stacking and history behavior.
* @returns An action that can be passed to `begin()`, `perform()`, or returned
* from a gesture action factory.
*/
export function originAction(
target: OriginView,
choreography: OriginChoreography,
options: OriginActionOptions = {},
): OriginAction {
return materialize(
navigation(target, options.placement ?? "above", options.history ?? "push"),
choreography,
) as OriginAction;
}
/**
* Create a retained-history push intent or complete action.
*
* Committing the action parks its origin instance and leaves it mounted until
* a later committed {@link back} action pops the new entry. Omit choreography
* when declaring `.navigate()` inside a gesture builder; provide choreography
* when passing the result directly to `begin()` or `perform()`.
*
* @param target - View recipe created if the push commits.
* @param options - Stacking options for an animation-free navigation intent.
* @returns An animation-free intent for use with a gesture builder.
*/
export function forward(
target: OriginView,
options?: OriginNavigationActionOptions,
): OriginNavigationIntent;
export function forward(
target: OriginView,
choreography: OriginChoreography,
options?: OriginNavigationActionOptions,
): OriginAction;
export function forward(
target: OriginView,
choreographyOrOptions?: OriginChoreography | OriginNavigationActionOptions,
maybeOptions?: OriginNavigationActionOptions,
): OriginAction | OriginNavigationIntent {
const { choreography, options } = helperArguments(
choreographyOrOptions,
maybeOptions,
);
return materialize(
navigation(target, options.placement ?? "above", "push"),
choreography,
);
}
/**
* Create a retained-history replacement intent or complete action.
*
* The target inherits the origin's previous mounted entry instead of retaining
* the origin itself. A commit unmounts the origin and activates the target, so
* a later {@link back} skips the replaced entry. A cancellation removes the
* proposed target and restores the origin unchanged.
*
* Omit choreography when declaring `.navigate()` inside a gesture builder;
* provide choreography when passing the result directly to `begin()` or
* `perform()`.
*
* @param target - View recipe created for the replacement.
* @param options - Stacking options for an animation-free navigation intent.
* @returns An animation-free replacement intent for use with a gesture builder.
*/
export function replace(
target: OriginView,
options?: OriginNavigationActionOptions,
): OriginNavigationIntent;
export function replace(
target: OriginView,
choreography: OriginChoreography,
options?: OriginNavigationActionOptions,
): OriginAction;
export function replace(
target: OriginView,
choreographyOrOptions?: OriginChoreography | OriginNavigationActionOptions,
maybeOptions?: OriginNavigationActionOptions,
): OriginAction | OriginNavigationIntent {
const { choreography, options } = helperArguments(
choreographyOrOptions,
maybeOptions,
);
return materialize(
navigation(target, options.placement ?? "above", "replace"),
choreography,
);
}
/**
* Create a retained-history back intent or complete action.
*
* The action intentionally has no target recipe. At execution time the scene
* resolves the origin's mounted `previousNodeKey`, reveals that exact instance,
* and removes the current instance only if the operation commits. Omit
* choreography inside gesture `.navigate()`; provide it for a programmatic
* action.
*
* @param options - Stacking options for an animation-free navigation intent.
* @returns An animation-free back intent for use with a gesture builder.
*/
export function back(
options?: OriginNavigationActionOptions,
): OriginNavigationIntent;
export function back(
choreography: OriginChoreography,
options?: OriginNavigationActionOptions,
): OriginAction;
export function back(
choreographyOrOptions?: OriginChoreography | OriginNavigationActionOptions,
maybeOptions?: OriginNavigationActionOptions,
): OriginAction | OriginNavigationIntent {
const { choreography, options } = helperArguments(
choreographyOrOptions,
maybeOptions,
);
return materialize(
targetlessNavigation(options.placement ?? "under", "back"),
choreography,
);
}
/**
* Create an intent or action whose target is stacked above its origin.
*
* This helper controls stacking, not movement direction. The supplied
* choreography may move either component however the application chooses.
* Omitting choreography produces an intent for gesture `.navigate()`.
*
* @param target - View recipe to create.
* @param choreography - Visual routine controlling the operation.
* @param options - Optional history behavior.
*/
export function above(
target: OriginView,
options?: OriginPlacementActionOptions,
): OriginNavigationIntent;
export function above(
target: OriginView,
choreography: OriginChoreography,
options?: OriginPlacementActionOptions,
): OriginAction;
export function above(
target: OriginView,
choreographyOrOptions?: OriginChoreography | OriginPlacementActionOptions,
maybeOptions?: OriginPlacementActionOptions,
): OriginAction | OriginNavigationIntent {
const { choreography, options } = helperArguments(
choreographyOrOptions,
maybeOptions,
);
return materialize(
navigation(target, "above", options.history ?? "push"),
choreography,
);
}
/**
* Create an intent or action whose target is stacked underneath its origin.
*
* Commonly used for custom reveal effects, but it has no implicit history
* meaning. Prefer {@link back} for retained-history navigation. Omitting
* choreography produces an intent for gesture `.navigate()`.
*
* @param target - View recipe to create underneath the origin.
* @param choreography - Visual routine controlling the operation.
* @param options - Optional history behavior.
*/
export function under(
target: OriginView,
options?: OriginPlacementActionOptions,
): OriginNavigationIntent;
export function under(
target: OriginView,
choreography: OriginChoreography,
options?: OriginPlacementActionOptions,
): OriginAction;
export function under(
target: OriginView,
choreographyOrOptions?: OriginChoreography | OriginPlacementActionOptions,
maybeOptions?: OriginPlacementActionOptions,
): OriginAction | OriginNavigationIntent {
const { choreography, options } = helperArguments(
choreographyOrOptions,
maybeOptions,
);
return materialize(
navigation(target, "under", options.history ?? "push"),
choreography,
);
}
-773
View File
@@ -1,773 +0,0 @@
import {
computed,
markRaw,
nextTick,
reactive,
shallowReactive,
shallowRef,
type Component,
type CSSProperties,
} from "vue";
import { normalizedEffect } from "./motion";
import type {
MutableOriginOperation,
OriginAction,
OriginChoreographyContext,
OriginContext,
OriginEffect,
OriginEffectSet,
OriginFinishOptions,
OriginOperation,
OriginOperationHandle,
OriginRect,
OriginScene,
OriginSceneNode,
OriginSceneNodeState,
OriginView,
PropsOf,
} from "./types";
interface SceneNodeState {
key: string;
sequence: number;
view: OriginView;
previousNodeKey?: string;
state: OriginSceneNodeState;
incomingOperationId?: number;
}
/** Options used to create an independent origin scene. */
export interface CreateOriginSceneOptions {
/**
* One initial root recipe, or several independent roots rendered in the same
* flat scene. Most applications begin with one root.
*/
initial: OriginView | readonly OriginView[];
}
/** Optional developer-facing metadata for an {@link OriginView} recipe. */
export interface OriginViewOptions {
/**
* Stable recipe identity used in diagnostics and generated node-key prefixes.
* It does not preserve or reuse a mounted Vue component instance.
*/
key?: string;
/** Human-readable label used by inspectors and DOM data attributes. */
name?: string;
}
let viewSequence = 0;
/**
* Turn a component and props into a lightweight, reusable view recipe.
*
* Components are marked raw so Vue never attempts to proxy their definitions
* when recipes are placed in reactive scene/history structures.
*
* @typeParam Props - Props passed to the component when the recipe is mounted.
* @param component - Vue component definition to mount.
* @param props - Props captured by the recipe.
* @param options - Optional recipe identity and diagnostic name.
* @returns An immutable, reusable component recipe. It is not a Vue instance.
*
* @example
* ```ts
* const profile = originView(
* ProfileView,
* { userId: "42" },
* { key: "profile-42", name: "Profile" },
* );
* ```
*/
export function originView<
T extends Component = Component,
>(
component: T,
props?: PropsOf<T>,
options: OriginViewOptions = {},
): OriginView<T> {
const inferredName =
options.name ??
(typeof component === "object" && "name" in component
? String(component.name)
: undefined);
return markRaw({
component: markRaw(component),
props,
key: options.key ?? `${inferredName ?? "view"}-${++viewSequence}`,
name: inferredName,
});
}
function clamp(value: number) {
return Math.max(0, Math.min(1, value));
}
function elementRect(element: HTMLElement | null | undefined) {
if (!element) return undefined;
const rect = element.getBoundingClientRect();
return {
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
};
}
function defaultViewport(element: HTMLElement | null): OriginRect {
return (
elementRect(element) ?? {
top: 0,
left: 0,
width: typeof window === "undefined" ? 1 : window.innerWidth,
height: typeof window === "undefined" ? 1 : window.innerHeight,
}
);
}
function prefersReducedMotion() {
return (
typeof window === "undefined" ||
!window.requestAnimationFrame ||
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
);
}
/**
* Create one independent scene/history context.
*
* The implementation stores nodes flat. Operation edges describe how their
* visual effects compose, but a Vue component never moves to a different VNode
* parent as edges are added or removed. This is what preserves component state
* while X→Y and Y→Z overlap.
*
* @param options - Initial root view recipe or recipes.
* @returns A self-contained reactive scene. Render it through `OriginScene`.
*
* @example
* ```ts
* const scene = createOriginScene({
* initial: originView(HomeView, undefined, { key: "home" }),
* });
* ```
*/
export function createOriginScene(
options: CreateOriginSceneOptions,
): OriginScene {
const nodes = shallowReactive(new Map<string, SceneNodeState>());
const operations = shallowReactive(new Map<number, MutableOriginOperation>());
/*
* A close gesture temporarily removes a connected resting edge to avoid a
* visual cycle (page→drawer→page). If that close is cancelled, this map lets
* us restore the exact same edge and component instances.
*/
const suspendedPresentations = new Map<number, MutableOriginOperation>();
const roots = shallowRef<string[]>([]);
const elements = new Map<string, HTMLElement>();
let container: HTMLElement | null = null;
let nodeSequence = 0;
let operationSequence = 0;
function uniqueNodeKey(view: OriginView) {
return `${view.key ?? view.name ?? "view"}::${++nodeSequence}`;
}
function addNode(
view: OriginView,
previousNodeKey?: string,
incomingOperationId?: number,
) {
const node: SceneNodeState = shallowReactive({
key: uniqueNodeKey(view),
sequence: nodeSequence,
view: markRaw(view),
previousNodeKey,
state: incomingOperationId ? "transitioning" : "active",
incomingOperationId,
});
nodes.set(node.key, node);
return node;
}
const initialViews = Array.isArray(options.initial)
? options.initial
: [options.initial];
for (const initial of initialViews) {
const node = addNode(initial);
roots.value = [...roots.value, node.key];
}
function historyNodesFor(node: SceneNodeState) {
const history: SceneNodeState[] = [];
const visited = new Set<string>([node.key]);
let previousKey = node.previousNodeKey;
while (previousKey && !visited.has(previousKey)) {
visited.add(previousKey);
const previous = nodes.get(previousKey);
if (!previous) break;
history.unshift(previous);
previousKey = previous.previousNodeKey;
}
return history;
}
function contextFor(nodeKey: string): OriginContext {
const node = nodes.get(nodeKey);
if (!node)
throw new Error(`Origin scene node "${nodeKey}" no longer exists.`);
const historyNodes = historyNodesFor(node);
return {
nodeKey,
view: node.view,
canGoBack: Boolean(node.previousNodeKey),
previous: historyNodes.at(-1)?.view,
history: historyNodes.map((entry) => entry.view),
};
}
function previousNodeForAction(origin: SceneNodeState, action: OriginAction) {
switch (action.history ?? "push") {
case "push":
return origin.key;
case "replace":
// Skip the origin in the retained chain. The old entry remains mounted
// until commit so an interactive replacement can still be cancelled.
return origin.previousNodeKey;
case "back":
return origin.previousNodeKey;
}
}
function operationContext(
operation: MutableOriginOperation,
): OriginChoreographyContext {
return {
progress: operation.progress,
velocity: operation.velocity,
phase: operation.phase,
intent: operation.intent,
originRect: operation.originRect,
targetRect: operation.targetRect,
viewport: defaultViewport(container),
};
}
function effectSetFor(operation: MutableOriginOperation): OriginEffectSet {
return operation.choreography.effects(operationContext(operation));
}
/**
* Return the origin-frame effects inherited by a node. The source effect of
* an ancestor is intentionally excluded: descendants inherit the target side
* of an operation, while only the initiating component receives its source
* side.
*/
function inheritedEffects(
nodeKey: string,
visited = new Set<string>(),
): OriginEffect[] {
if (visited.has(nodeKey)) return [];
visited.add(nodeKey);
const node = nodes.get(nodeKey);
const incoming = node?.incomingOperationId
? operations.get(node.incomingOperationId)
: undefined;
if (!node || !incoming) return [];
const effects = effectSetFor(incoming);
const targetLayer = incoming.placement === "above" ? 1 : -1;
return [
...inheritedEffects(incoming.originKey, visited),
normalizedEffect(effects.frame),
normalizedEffect(effects.target, targetLayer),
];
}
function visualEffects(nodeKey: string) {
const result = inheritedEffects(nodeKey);
const outgoing = visualOutgoingFor(nodeKey);
if (!outgoing) return result;
const effects = effectSetFor(outgoing);
return [
...result,
normalizedEffect(effects.frame),
normalizedEffect(effects.source),
];
}
function visualOutgoingFor(nodeKey: string) {
return [...operations.values()].find(
(operation) => operation.originKey === nodeKey,
);
}
/**
* Collapse independent effect layers into one host style. Geometry remains
* composable; arbitrary CSS properties use normal local-last precedence.
*/
function styleForNode(nodeKey: string): CSSProperties {
const node = nodes.get(nodeKey);
if (!node) return {};
const transforms: string[] = [];
let opacity = 1;
let layer = 0;
/*
* These rules are compositor invariants rather than visual theming. Keep
* them inline so a missing optional package stylesheet can never place
* scene nodes back into normal block/flex flow and vertically stack views.
*/
const style: CSSProperties = {
position: "absolute",
inset: "0",
width: "100%",
height: "100%",
overflow: "hidden",
};
for (const effect of visualEffects(nodeKey)) {
if (effect.transform && effect.transform !== "none")
transforms.push(effect.transform);
if (effect.opacity !== undefined) opacity *= effect.opacity;
layer += effect.layer ?? 0;
if (effect.style) {
const effectStyle = effect.style;
Object.assign(style, effectStyle);
if (
typeof effectStyle.transform === "string" &&
effectStyle.transform !== "none"
)
transforms.push(effectStyle.transform);
if (effectStyle.opacity !== undefined) {
const numericOpacity = Number(effectStyle.opacity);
if (Number.isFinite(numericOpacity)) opacity *= numericOpacity;
}
}
}
// Explicitly assign the composited properties after Object.assign so an
// individual effect cannot accidentally replace inherited transform work.
style.transform = transforms.length ? transforms.join(" ") : "none";
style.opacity = String(clamp(opacity));
style.zIndex = 1_000_000 + layer * 10_000 + node.sequence;
style.pointerEvents = isNodeInteractive(nodeKey) ? "auto" : "none";
if (node.state === "parked") {
// Parked entries remain mounted in their original flat hosts. Keeping
// the DOM preserves local component state and nested scroll positions,
// while visibility/inert handling removes them from presentation.
style.visibility = "hidden";
style.contentVisibility = "hidden";
}
return style;
}
function outgoingFor(nodeKey: string) {
return [...operations.values()].find(
(operation) =>
operation.originKey === nodeKey && operation.phase !== "finished",
);
}
function isNodeInteractive(nodeKey: string) {
const node = nodes.get(nodeKey);
if (!node || node.state === "parked" || node.state === "exposed")
return false;
// A target being cancelled is already scheduled to disappear. A source
// settling toward commit has ceded new interactions to the retained scene
// beneath/above it. This is node-local fate, not a global "active view".
const incoming = node.incomingOperationId
? operations.get(node.incomingOperationId)
: undefined;
if (incoming?.intent === "cancel") return false;
return outgoingFor(nodeKey)?.intent !== "commit";
}
function updateOperation(id: number, progress: number, velocity = 0) {
const operation = operations.get(id);
if (!operation || operation.phase === "finished") return;
operation.progress = clamp(progress);
operation.velocity = velocity;
if (operation.phase === "preparing") return;
operation.phase = "interactive";
}
function removeNode(nodeKey: string) {
elements.delete(nodeKey);
nodes.delete(nodeKey);
roots.value = roots.value.filter((key) => key !== nodeKey);
}
/**
* Remove a newly created forward branch while leaving the origin and all
* earlier retained entries untouched.
*/
function removeRetainedBranch(nodeKey: string, visited = new Set<string>()) {
if (visited.has(nodeKey)) return;
visited.add(nodeKey);
for (const node of [...nodes.values()]) {
if (node.previousNodeKey === nodeKey)
removeRetainedBranch(node.key, visited);
}
for (const operation of [...operations.values()]) {
if (
operation.originKey === nodeKey ||
operation.targetKey === nodeKey ||
operation.entryTargetKey === nodeKey
)
operations.delete(operation.id);
}
removeNode(nodeKey);
}
function refreshVisibleState(node: SceneNodeState) {
if (node.state === "parked" || node.state === "exposed") return;
const incoming = node.incomingOperationId
? operations.get(node.incomingOperationId)
: undefined;
node.state =
(incoming && incoming.phase !== "finished") || outgoingFor(node.key)
? "transitioning"
: "active";
}
/**
* Transfer an operation's visual graph position to its target without moving
* either Vue VNode. The history chain is intentionally independent from this
* temporary coordinate graph.
*/
function spliceVisualTarget(origin: SceneNodeState, target: SceneNodeState) {
const parentOperation = origin.incomingOperationId
? operations.get(origin.incomingOperationId)
: undefined;
if (parentOperation) {
parentOperation.targetKey = target.key;
target.incomingOperationId = parentOperation.id;
} else {
roots.value = roots.value.map((key) =>
key === origin.key ? target.key : key,
);
target.incomingOperationId = undefined;
}
}
/**
* Finalize a committed operation.
*
* A normal push parks its origin and collapses the temporary visual edge.
* A connected push keeps the edge at progress 1 and leaves the origin
* exposed but inert. Replace and back remove the origin.
*/
function commitOperation(id: number) {
const operation = operations.get(id);
if (!operation) return false;
const origin = nodes.get(operation.originKey);
const target = nodes.get(operation.targetKey);
if (!origin || !target) return false;
if (operation.history === "push" && operation.choreography.persistAtRest) {
operation.progress = 1;
operation.velocity = 0;
operation.intent = "commit";
operation.phase = "finished";
origin.state = "exposed";
target.state = "active";
refreshVisibleState(target);
return true;
}
spliceVisualTarget(origin, target);
operation.phase = "finished";
operations.delete(operation.id);
suspendedPresentations.delete(operation.id);
if (operation.history === "push") {
origin.incomingOperationId = undefined;
origin.state = "parked";
} else {
// Back pops the current entry; replace discards it in favor of the new
// target. Both preserve every earlier retained instance.
removeNode(origin.key);
}
target.state = "active";
refreshVisibleState(target);
return true;
}
function cancelOperation(id: number) {
const operation = operations.get(id);
if (!operation) return;
const origin = nodes.get(operation.originKey);
const target = nodes.get(operation.entryTargetKey);
const suspendedPresentation = suspendedPresentations.get(id);
operation.phase = "finished";
operations.delete(id);
suspendedPresentations.delete(id);
if (operation.history === "back") {
if (suspendedPresentation && origin && target) {
/*
* Put the retained page back into its former parent/root position, then
* reconnect the settled page→drawer edge. Both nodes keep their VNodes.
*/
spliceVisualTarget(origin, target);
operations.set(suspendedPresentation.id, suspendedPresentation);
origin.incomingOperationId = suspendedPresentation.id;
target.state = "exposed";
} else if (target) {
target.incomingOperationId = undefined;
target.state = "parked";
}
} else {
removeRetainedBranch(operation.entryTargetKey);
}
if (origin) {
origin.state = "active";
refreshVisibleState(origin);
}
}
async function settle(id: number, targetProgress: 0 | 1, animate: boolean) {
const operation = operations.get(id);
if (!operation) return;
if (!animate || prefersReducedMotion()) {
operation.progress = targetProgress;
operation.velocity = 0;
return;
}
await new Promise<void>((resolve) => {
let position = operation.progress;
let velocity = Math.max(-8, Math.min(8, operation.velocity));
let previous = performance.now();
const frame = (time: number) => {
const live = operations.get(id);
if (!live) return resolve();
// A damped spring makes release velocity continuous with pointer motion
// without imposing a fixed-duration easing on custom choreographies.
const elapsed = Math.min(
0.032,
Math.max(0.001, (time - previous) / 1000),
);
previous = time;
const iterations = Math.max(1, Math.ceil(elapsed / (1 / 120)));
const dt = elapsed / iterations;
for (let index = 0; index < iterations; index += 1) {
const acceleration =
(targetProgress - position) * 280 - velocity * 30;
velocity += acceleration * dt;
position += velocity * dt;
}
const done =
Math.abs(targetProgress - position) < 0.002 &&
Math.abs(velocity) < 0.02;
live.progress = done ? targetProgress : clamp(position);
live.velocity = done ? 0 : velocity;
if (done) resolve();
else window.requestAnimationFrame(frame);
};
window.requestAnimationFrame(frame);
});
}
async function finishOperation(
id: number,
options: OriginFinishOptions = {},
) {
const operation = operations.get(id);
if (!operation) return false;
if (operation.phase === "finished") return operation.intent === "commit";
const threshold = operation.choreography.commitThreshold ?? 0.36;
// const velocityThreshold = operation.choreography.commitVelocity ?? 0.9;
const shouldCommit =
options.commit ??
(operation.progress >= threshold ||
(operation.progress >= 0.0 && operation.velocity >= 0.3));
// operation.velocity >= velocityThreshold));
// The outcome is known synchronously at release. The target can therefore
// originate another gesture while this operation is only visually settling.
operation.intent = shouldCommit ? "commit" : "cancel";
operation.phase = "settling";
await settle(id, shouldCommit ? 1 : 0, options.animate ?? true);
if (!operations.has(id)) return shouldCommit;
if (shouldCommit) return commitOperation(id);
cancelOperation(id);
return false;
}
async function begin(
originKey: string,
action: OriginAction,
): Promise<OriginOperationHandle> {
const origin = nodes.get(originKey);
if (!origin)
throw new Error(`Cannot animate from missing origin "${originKey}".`);
if (origin.state === "parked" || origin.state === "exposed")
throw new Error(
`Cannot animate from ${origin.state} origin "${originKey}".`,
);
if (outgoingFor(originKey))
throw new Error(
`Origin "${originKey}" already has an outgoing operation. ` +
"A descendant target may start its own operation instead.",
);
const history = action.history ?? "push";
if (action.choreography.persistAtRest && history !== "push")
throw new Error(
"persistAtRest choreographies require retained-history push navigation.",
);
let target: SceneNodeState;
let suspendedPresentation: MutableOriginOperation | undefined;
if (history === "back") {
const previousKey = origin.previousNodeKey;
if (!previousKey)
throw new Error(
`Cannot go back from origin "${originKey}" without a previous entry.`,
);
const retainedTarget = nodes.get(previousKey);
if (!retainedTarget)
throw new Error(
`Cannot go back to missing retained entry "${previousKey}".`,
);
/*
* If back starts while the immediately preceding forward spring is still
* settling, collapse that already-committed edge first. Creating a new
* edge back to its origin would otherwise form X→Y→X. Reciprocal
* choreographies begin at the same visual endpoints, so this handoff is
* continuous without remounting either node.
*/
const incoming = origin.incomingOperationId
? operations.get(origin.incomingOperationId)
: undefined;
if (incoming?.originKey === retainedTarget.key) {
if (incoming.intent !== "commit")
throw new Error(
"Cannot go back through an undecided forward operation.",
);
if (incoming.phase !== "finished") commitOperation(incoming.id);
const connected = operations.get(incoming.id);
if (
connected?.phase === "finished" &&
connected.choreography.persistAtRest
) {
/*
* Re-root A→page→drawer as A→drawer before adding drawer→page.
* The reverse choreography starts at the same visual endpoints.
*/
operations.delete(connected.id);
spliceVisualTarget(retainedTarget, origin);
retainedTarget.incomingOperationId = undefined;
suspendedPresentation = connected;
}
}
if (outgoingFor(retainedTarget.key))
throw new Error(
`Retained target "${retainedTarget.key}" already has an outgoing operation.`,
);
if (retainedTarget.incomingOperationId)
throw new Error(
`Retained target "${retainedTarget.key}" is already transitioning.`,
);
target = retainedTarget;
} else {
if (!action.target)
throw new Error(
`A "${history}" operation requires a target view recipe.`,
);
target = addNode(action.target, previousNodeForAction(origin, action));
}
const operationId = ++operationSequence;
target.incomingOperationId = operationId;
target.state = "transitioning";
origin.state = "transitioning";
const operation = reactive<MutableOriginOperation>({
id: operationId,
originKey,
targetKey: target.key,
entryTargetKey: target.key,
choreography: markRaw(action.choreography),
placement: action.placement ?? "above",
history,
progress: 0,
velocity: 0,
phase: "preparing",
intent: "undecided",
originRect: elementRect(elements.get(originKey)),
});
operations.set(operation.id, operation);
if (suspendedPresentation)
suspendedPresentations.set(operation.id, suspendedPresentation);
// Let Vue mount the target before measuring it. Gesture composables buffer
// pointer progress while this short preparation step is pending.
await nextTick();
operation.targetRect = elementRect(elements.get(target.key));
operation.phase = "interactive";
return {
id: operation.id,
originKey,
targetKey: target.key,
update: (progress, velocity) =>
updateOperation(operation.id, progress, velocity),
finish: (finishOptions) => finishOperation(operation.id, finishOptions),
cancel: async (cancelOptions) => {
await finishOperation(operation.id, {
commit: false,
animate: cancelOptions?.animate,
});
},
};
}
async function perform(originKey: string, action: OriginAction) {
const operation = await begin(originKey, action);
return operation.finish({ commit: true });
}
return {
nodes: computed(
() =>
[...nodes.values()].map((node) => ({
...node,
history: historyNodesFor(node).map((entry) => entry.view),
})) as readonly OriginSceneNode[],
),
operations: computed(
() =>
[...operations.values()].filter(
(operation) => operation.phase !== "finished",
) as readonly OriginOperation[],
),
roots,
contextFor,
begin,
perform,
registerElement(nodeKey, element) {
if (element) elements.set(nodeKey, element);
else elements.delete(nodeKey);
},
registerContainer(element) {
container = element;
},
styleForNode,
isNodeInteractive,
};
}
+652
View File
@@ -0,0 +1,652 @@
import { markRaw, reactive, shallowReactive, shallowRef } from "vue";
import type {
OriginAction,
OriginHistoryMode,
OriginView,
} from "../navigation";
import { clamp01, type OriginRect } from "../shared";
import type {
MutableOriginOperation,
OriginContext,
OriginFinishOptions,
OriginSceneNodeState,
} from "./types";
/** Mutable storage for one mounted scene node. */
export interface SceneNodeRecord {
key: string;
sequence: number;
view: OriginView;
/** Retained-history link: the entry a committed back returns to. */
previousNodeKey?: string;
state: OriginSceneNodeState;
/** Visual link: the operation edge positioning this node as its target. */
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
* its own `commitThreshold`.
*/
export const DEFAULT_COMMIT_THRESHOLD = 0.25;
/**
* Release velocity (progress units per second) that commits a flick at any
* progress when the choreography does not supply its own `commitVelocity`.
*/
export const DEFAULT_COMMIT_VELOCITY = 0.2;
/**
* 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 = (
operation: MutableOriginOperation,
options: OriginFinishOptions,
): boolean => {
if (options.commit !== undefined) return options.commit;
const { commitThreshold, commitVelocity } = operation.choreography;
const flick = commitVelocity ?? DEFAULT_COMMIT_VELOCITY;
if (operation.velocity >= flick) return true;
if (operation.velocity <= -flick) return false;
return operation.progress >= (commitThreshold ?? DEFAULT_COMMIT_THRESHOLD);
};
/**
* The scene model: nodes, operation edges, and the rules that rewrite them.
*
* Two independent structures share these records:
*
* - **History** links each node to its retained previous entry through
* `previousNodeKey`. It changes only when operations commit or cancel.
* - **Visual graph** links each target to the operation that positions it
* through `incomingOperationId`; each operation links back to its origin.
* Nodes without an incoming edge are listed in `roots`.
*
* Nodes are stored flat so Vue never re-parents a component when edges are
* added, spliced, or removed. Every method is synchronous; timing (mounting,
* measuring, settling) belongs to `Scene`.
*/
export class SceneGraph {
readonly nodes = shallowReactive(new Map<string, SceneNodeRecord>());
readonly operations = shallowReactive(
new Map<number, MutableOriginOperation>(),
);
readonly roots = shallowRef<string[]>([]);
/**
* Connected resting edges temporarily removed by a back operation, keyed by
* that back operation's id. Cancelling the back operation restores the edge.
*/
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 operationSequence = 0;
constructor(initial: readonly OriginView[]) {
this.roots.value = initial.map((view) => this.createNode(view).key);
}
// Queries -----------------------------------------------------------------
node(key: string): SceneNodeRecord | undefined {
return this.nodes.get(key);
}
operation(id: number): MutableOriginOperation | undefined {
return this.operations.get(id);
}
/** The edge positioning `node` as its target, including resting edges. */
incomingOf(node: SceneNodeRecord): MutableOriginOperation | undefined {
return node.incomingOperationId
? this.operations.get(node.incomingOperationId)
: undefined;
}
/** Incoming edges positioning `node`, from the node outward to its root. */
incomingChain(node: SceneNodeRecord): MutableOriginOperation[] {
const chain: MutableOriginOperation[] = [];
const visited = new Set<string>();
let current: SceneNodeRecord | undefined = node;
while (current && !visited.has(current.key)) {
visited.add(current.key);
const incoming = this.incomingOf(current);
if (!incoming) break;
chain.push(incoming);
current = this.nodes.get(incoming.originKey);
}
return chain;
}
/** The unfinished operation originated by a node, if any. */
liveOutgoingOf(nodeKey: string): MutableOriginOperation | undefined {
return this.outgoing
.get(nodeKey)
?.find((operation) => operation.phase !== "finished");
}
/**
* The operation whose source effects apply to a node. Unlike
* {@link liveOutgoingOf}, this includes a finished, connected resting edge.
*/
presentedOutgoingOf(nodeKey: string): MutableOriginOperation | undefined {
return this.outgoing.get(nodeKey)?.[0];
}
/** Retained entries preceding `node`, oldest first. */
historyOf(node: SceneNodeRecord): SceneNodeRecord[] {
const history: SceneNodeRecord[] = [];
const visited = new Set<string>([node.key]);
let previousKey = node.previousNodeKey;
while (previousKey && !visited.has(previousKey)) {
visited.add(previousKey);
const previous = this.nodes.get(previousKey);
if (!previous) break;
history.push(previous);
previousKey = previous.previousNodeKey;
}
return history.reverse();
}
contextFor(nodeKey: string): OriginContext {
const node = this.nodes.get(nodeKey);
if (!node)
throw new OriginSceneError(
"missing-node",
`Origin scene node "${nodeKey}" no longer exists.`,
);
const history = this.historyOf(node).map((entry) => entry.view);
return {
nodeKey,
view: node.view,
canGoBack: Boolean(node.previousNodeKey),
previous: history.at(-1),
history,
};
}
/**
* Whether a node should currently receive input.
*
* Parked and exposed entries are inert. A target being cancelled is already
* scheduled to disappear, and a source settling toward commit has ceded new
* interactions to its target. This is node-local fate, not a global "active
* view".
*/
isInteractive(nodeKey: string): boolean {
const node = this.nodes.get(nodeKey);
if (!node || node.state === "parked" || node.state === "exposed")
return false;
if (this.incomingOf(node)?.intent === "cancel") return false;
return this.liveOutgoingOf(nodeKey)?.intent !== "commit";
}
// Transitions -------------------------------------------------------------
/**
* Validate an action and add its operation edge (and, for push/replace, its
* target node). The edge starts in the `preparing` phase.
*
* @throws If the origin cannot originate, or the action cannot be resolved.
*/
begin(
originKey: string,
action: OriginAction,
originRect: OriginRect | undefined,
): MutableOriginOperation {
const origin = this.assertCanOriginate(originKey);
const history = action.history ?? "push";
if (action.choreography.persistAtRest && history !== "push")
throw new OriginSceneError(
"invalid-action",
"persistAtRest choreographies require retained-history push navigation.",
);
const { target, suspendedEdge } =
history === "back"
? this.resolveBackTarget(origin)
: { target: this.createForwardTarget(origin, action, history) };
const operation = this.createOperation({
originKey,
targetKey: target.key,
entryTargetKey: target.key,
choreography: action.choreography,
placement: action.placement ?? "above",
history,
originRect,
});
target.incomingOperationId = operation.id;
target.state = "transitioning";
origin.state = "transitioning";
if (suspendedEdge) this.suspendedEdges.set(operation.id, suspendedEdge);
return operation;
}
/** Apply interactive progress to an unfinished operation. */
update(id: number, progress: number, velocity: number): void {
const operation = this.operations.get(id);
if (!operation || operation.phase === "finished") return;
operation.progress = clamp01(progress);
operation.overshoot = progress - operation.progress;
operation.velocity = velocity;
if (operation.phase !== "preparing") operation.phase = "interactive";
}
/**
* Finalize a committed operation.
*
* A normal push parks its origin and collapses the temporary visual edge, so
* the target takes the origin's place in the visual graph. A connected
* (`persistAtRest`) push keeps the edge at progress 1 and leaves the origin
* exposed but inert. Replace and back remove the origin.
*
* @returns Whether the operation's nodes still existed to be committed.
*/
commit(id: number): boolean {
const operation = this.operations.get(id);
if (!operation) return false;
const origin = this.nodes.get(operation.originKey);
const target = this.nodes.get(operation.targetKey);
if (!origin || !target) return false;
if (operation.history === "push" && operation.choreography.persistAtRest) {
operation.progress = 1;
operation.overshoot = 0;
operation.velocity = 0;
operation.intent = "commit";
operation.phase = "finished";
origin.state = "exposed";
this.activate(target);
return true;
}
this.transferPosition(origin, target);
operation.phase = "finished";
this.detach(operation);
this.suspendedEdges.delete(operation.id);
if (operation.history === "push") {
origin.incomingOperationId = undefined;
origin.state = "parked";
} else {
// Back pops the current entry; replace discards it in favor of the new
// 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.activate(target);
return true;
}
/**
* Finalize a cancelled operation, restoring the origin.
*
* Everything the edge created or now positions is undone. Entries from the
* 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 {
const operation = this.operations.get(id);
if (!operation) return;
const origin = this.nodes.get(operation.originKey);
const entry = this.nodes.get(operation.entryTargetKey);
const current = this.nodes.get(operation.targetKey);
const suspendedEdge = this.suspendedEdges.get(id);
const retained = origin ? this.retainedKeys(origin) : new Set<string>();
operation.phase = "finished";
this.detach(operation);
this.suspendedEdges.delete(id);
if (suspendedEdge && origin && entry && current === entry) {
// Put the retained page back into its former position, then reconnect
// the resting page→drawer edge. Both nodes keep their VNodes.
this.discardOutgoing(entry.key, new Set(retained));
this.transferPosition(origin, entry);
this.attach(suspendedEdge);
origin.incomingOperationId = suspendedEdge.id;
entry.state = "exposed";
} else {
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);
}
// Transition steps --------------------------------------------------------
private assertCanOriginate(originKey: string): SceneNodeRecord {
const origin = this.nodes.get(originKey);
if (!origin)
throw new OriginSceneError(
"missing-origin",
`Cannot animate from missing origin "${originKey}".`,
);
if (origin.state === "parked" || origin.state === "exposed")
throw new OriginSceneError(
"inert-origin",
`Cannot animate from ${origin.state} origin "${originKey}".`,
);
if (this.liveOutgoingOf(originKey))
throw new OriginSceneError(
"origin-busy",
`Origin "${originKey}" already has an outgoing operation. ` +
"A descendant target may start its own operation instead.",
);
return origin;
}
/**
* Resolve the retained previous entry for a back operation.
*
* If the origin's incoming edge comes from that previous entry (a forward
* push still settling, or a connected resting edge), the edge is collapsed
* first. Creating a new edge back to its origin would otherwise form X→Y→X.
* Reciprocal choreographies begin at the same visual endpoints, so this
* handoff is continuous without remounting either node.
*/
private resolveBackTarget(origin: SceneNodeRecord): {
target: SceneNodeRecord;
suspendedEdge?: MutableOriginOperation;
} {
const previousKey = origin.previousNodeKey;
if (!previousKey)
throw new OriginSceneError(
"no-previous-entry",
`Cannot go back from origin "${origin.key}" without a previous entry.`,
);
const target = this.nodes.get(previousKey);
if (!target)
throw new OriginSceneError(
"no-previous-entry",
`Cannot go back to missing retained entry "${previousKey}".`,
);
let suspendedEdge: MutableOriginOperation | undefined;
const incoming = this.incomingOf(origin);
if (incoming?.originKey === target.key) {
if (incoming.intent !== "commit")
throw new OriginSceneError(
"undecided-forward",
"Cannot go back through an undecided forward operation.",
);
if (incoming.phase !== "finished") this.commit(incoming.id);
const resting = this.operations.get(incoming.id);
if (resting?.phase === "finished" && resting.choreography.persistAtRest) {
// Re-root A→page→drawer as A→drawer before adding drawer→page.
this.detach(resting);
this.transferPosition(target, origin);
target.incomingOperationId = undefined;
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))
throw new OriginSceneError(
"target-busy",
`Retained target "${target.key}" already has an outgoing operation.`,
);
if (target.incomingOperationId)
throw new OriginSceneError(
"target-busy",
`Retained target "${target.key}" is already transitioning.`,
);
return { target, suspendedEdge };
}
/** Create the target node for a push or replace operation. */
private createForwardTarget(
origin: SceneNodeRecord,
action: OriginAction,
history: Exclude<OriginHistoryMode, "back">,
): SceneNodeRecord {
if (!action.target)
throw new OriginSceneError(
"invalid-action",
`A "${history}" operation requires a target view recipe.`,
);
// A replacement skips the origin in the retained chain. The origin stays
// mounted until commit so an interactive replacement can still cancel.
const previousKey =
history === "push" ? origin.key : origin.previousNodeKey;
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 {
const resting = this.presentedOutgoingOf(node.key);
if (resting?.phase === "finished") {
node.state = "exposed";
return;
}
node.state = "active";
this.refreshPresentedState(node);
}
/**
* Recompute whether a presented node is participating in a live operation.
* Parked and exposed nodes keep their retained state.
*/
private refreshPresentedState(node: SceneNodeRecord): void {
if (node.state === "parked" || node.state === "exposed") return;
const incoming = this.incomingOf(node);
node.state =
(incoming && incoming.phase !== "finished") ||
this.liveOutgoingOf(node.key)
? "transitioning"
: "active";
}
// Structural primitives ---------------------------------------------------
private createNode(
view: OriginView,
previousNodeKey?: string,
): SceneNodeRecord {
const sequence = ++this.nodeSequence;
const node: SceneNodeRecord = shallowReactive({
key: `${view.key ?? view.name ?? "view"}::${sequence}`,
sequence,
view: markRaw(view),
previousNodeKey,
state: "active",
incomingOperationId: undefined,
});
this.nodes.set(node.key, node);
return node;
}
private createOperation(
init: Pick<
MutableOriginOperation,
| "originKey"
| "targetKey"
| "entryTargetKey"
| "choreography"
| "placement"
| "history"
| "originRect"
>,
): MutableOriginOperation {
const operation = reactive<MutableOriginOperation>({
id: ++this.operationSequence,
originKey: init.originKey,
targetKey: init.targetKey,
entryTargetKey: init.entryTargetKey,
choreography: markRaw(init.choreography),
placement: init.placement,
history: init.history,
progress: 0,
overshoot: 0,
velocity: 0,
phase: "preparing",
intent: "undecided",
originRect: init.originRect,
});
this.attach(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 {
this.nodes.delete(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
* 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 {
if (visited.has(nodeKey)) return;
visited.add(nodeKey);
for (const node of [...this.nodes.values()])
if (node.previousNodeKey === nodeKey)
this.removeBranch(node.key, visited);
this.discardOutgoing(nodeKey, visited);
for (const operation of [...this.operations.values()])
if (
operation.originKey === nodeKey ||
operation.targetKey === nodeKey ||
operation.entryTargetKey === nodeKey
)
this.detach(operation);
this.removeNode(nodeKey);
}
/**
* Give `target` the visual-graph position currently held by `from`: either
* as the target of `from`'s incoming edge, or as a root. Neither Vue VNode
* moves; only edge bookkeeping changes.
*/
private transferPosition(from: SceneNodeRecord, target: SceneNodeRecord) {
const parent = this.incomingOf(from);
if (parent) {
parent.targetKey = target.key;
target.incomingOperationId = parent.id;
} else {
this.roots.value = this.roots.value.map((key) =>
key === from.key ? target.key : key,
);
target.incomingOperationId = undefined;
}
}
}
+734
View File
@@ -0,0 +1,734 @@
import {
computed,
inject,
markRaw,
nextTick,
shallowRef,
type ComputedRef,
type CSSProperties,
type InjectionKey,
} from "vue";
import {
composeEffects,
normalizedEffect,
type OriginEffect,
type OriginEffectSet,
type OriginSpring,
type OriginOperationIntent,
type OriginOperationPhase,
} from "../choreography";
import type { OriginAction, OriginView } from "../navigation";
import {
DEV,
warn,
clamp,
clamp01,
measureRect,
prefersReducedMotion,
windowRect,
type OriginRect,
} from "../shared";
import {
OriginSceneError,
SceneGraph,
shouldCommit,
type SceneNodeRecord,
} from "./graph";
import type {
CreateOriginSceneOptions,
MutableOriginOperation,
OriginContext,
OriginFinishOptions,
OriginOperation,
OriginOperationHandle,
OriginScene,
OriginSceneNode,
} from "./types";
// Compositing ---------------------------------------------------------------
/**
* Compositor invariants rather than visual theming. They stay inline so a
* missing optional stylesheet can never place scene nodes back into normal
* block/flex flow and vertically stack views.
*
* Deliberately not frozen: it is spread once per node per frame, and V8
* spreads frozen objects markedly slower.
*/
const NODE_BASE_STYLE: Readonly<CSSProperties> = {
position: "absolute",
inset: "0",
width: "100%",
height: "100%",
overflow: "hidden",
};
/** Base z-index keeping every node above content outside the scene. */
const Z_INDEX_BASE = 1_000_000;
/** z-index distance between adjacent relative layers. */
const Z_INDEX_PER_LAYER = 10_000;
/**
* Effect layers applied to a node, ordered outermost frame first.
*
* A node inherits the frame and target effects of every edge on its incoming
* chain, then adds the frame and source effects of the edge it originates.
* Ancestors' source effects are intentionally excluded: only the initiating
* component receives the source side of an operation.
*
* @param effectsOf - The current effect set of an operation's choreography.
*/
export const nodeEffectLayers = (
graph: SceneGraph,
node: SceneNodeRecord,
effectsOf: (operation: MutableOriginOperation) => OriginEffectSet,
): OriginEffect[] => {
const chain = graph.incomingChain(node);
// Evaluate choreographies node-first, then apply them outermost-first.
const effectSets = chain.map(effectsOf);
const layers: OriginEffect[] = [];
for (let index = chain.length - 1; index >= 0; index -= 1) {
const targetLayer = chain[index]!.placement === "above" ? 1 : -1;
layers.push(
normalizedEffect(effectSets[index]!.frame),
normalizedEffect(effectSets[index]!.target, targetLayer),
);
}
const outgoing = graph.presentedOutgoingOf(node.key);
if (outgoing) {
const effects = effectsOf(outgoing);
layers.push(
normalizedEffect(effects.frame),
normalizedEffect(effects.source),
);
}
return layers;
};
/** Collapse every effect layer applied to a node into its host's style. */
export const composeNodeStyle = (
graph: SceneGraph,
nodeKey: string,
effectsOf: (operation: MutableOriginOperation) => OriginEffectSet,
): CSSProperties => {
const node = graph.node(nodeKey);
if (!node) return {};
const { style, layer } = composeEffects(
nodeEffectLayers(graph, node, effectsOf),
NODE_BASE_STYLE,
);
style.zIndex = Z_INDEX_BASE + layer * Z_INDEX_PER_LAYER + node.sequence;
style.pointerEvents = graph.isInteractive(nodeKey) ? "auto" : "none";
if (node.state === "parked") {
// Parked entries remain mounted in their original flat hosts. Keeping the
// DOM preserves local component state and nested scroll positions, while
// visibility/inert handling removes them from presentation.
style.visibility = "hidden";
style.contentVisibility = "hidden";
}
return style;
};
// Settling spring -----------------------------------------------------------
/*
* A damped spring settles an operation toward 0 or 1. It keeps release
* velocity continuous with pointer motion without imposing a fixed-duration
* easing on custom choreographies.
*/
const DEFAULT_STIFFNESS = 280;
const DEFAULT_DAMPING = 30;
/** Fixed integration step, in seconds. */
const STEP = 1 / 120;
/** Frame durations are clamped to this range, in seconds. */
const MIN_FRAME = 0.001;
const MAX_FRAME = 0.032;
/** Release velocities beyond this magnitude are clamped. */
const MAX_INITIAL_VELOCITY = 8;
const POSITION_TOLERANCE = 0.002;
const VELOCITY_TOLERANCE = 0.02;
/** Position and velocity of a spring, in progress units. */
export interface SpringState {
readonly position: number;
readonly velocity: number;
}
/**
* Advance a spring by one animation frame.
*
* @param elapsedMs - Time since the previous frame in milliseconds.
*/
export const stepSpring = (
state: SpringState,
target: number,
elapsedMs: number,
spring: OriginSpring = {},
): SpringState & { readonly done: boolean } => {
const stiffness = spring.stiffness ?? DEFAULT_STIFFNESS;
const damping = spring.damping ?? DEFAULT_DAMPING;
const elapsed = clamp(elapsedMs / 1000, MIN_FRAME, MAX_FRAME);
const iterations = Math.max(1, Math.ceil(elapsed / STEP));
const dt = elapsed / iterations;
let { position, velocity } = state;
for (let index = 0; index < iterations; index += 1) {
const acceleration = (target - position) * stiffness - velocity * damping;
velocity += acceleration * dt;
position += velocity * dt;
}
const done =
Math.abs(target - position) < POSITION_TOLERANCE &&
Math.abs(velocity) < VELOCITY_TOLERANCE;
return { position, velocity, done };
};
/**
* Run a spring on `requestAnimationFrame` until it rests at `target` or
* `onFrame` returns `false` because its subject no longer exists.
*
* Each frame reports the raw position; the final frame reports exactly
* `target` with zero velocity.
*/
export const runSpring = (
from: SpringState,
target: number,
onFrame: (position: number, velocity: number) => boolean,
spring: OriginSpring = {},
): Promise<void> =>
new Promise<void>((resolve) => {
let state: SpringState = {
position: from.position,
velocity: clamp(
from.velocity,
-MAX_INITIAL_VELOCITY,
MAX_INITIAL_VELOCITY,
),
};
let previous = performance.now();
const frame = (time: number) => {
const next = stepSpring(state, target, time - previous, spring);
previous = time;
state = next;
const alive = next.done
? onFrame(target, 0)
: onFrame(next.position, next.velocity);
if (!alive || next.done) resolve();
else window.requestAnimationFrame(frame);
};
window.requestAnimationFrame(frame);
});
// Continuity check (development only) ---------------------------------------
/** A host's rendered transform as a 4×4 matrix, plus its opacity. */
interface Pose {
readonly matrix: readonly number[];
readonly opacity: number;
}
const warnedChoreographies = new WeakSet<object>();
/** Read a visible host's rendered pose; `null` if hidden or unreadable. */
const poseOf = (host: HTMLElement): Pose | null => {
const style = getComputedStyle(host);
if (style.visibility === "hidden") return null;
const matrix = parseMatrix(style.transform);
const opacity = Number(style.opacity);
return matrix && Number.isFinite(opacity) ? { matrix, opacity } : null;
};
/** Parse a computed `transform` ("none", matrix() or matrix3d()). */
const parseMatrix = (transform: string): number[] | null => {
if (!transform || transform === "none")
return [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
const values = /^matrix(3d)?\(([^)]*)\)$/.exec(transform);
if (!values) return null;
const numbers = values[2]!.split(",").map(Number);
if (numbers.some((value) => !Number.isFinite(value))) return null;
if (values[1]) return numbers.length === 16 ? numbers : null;
if (numbers.length !== 6) return null;
const [a, b, c, d, e, f] = numbers as [
number,
number,
number,
number,
number,
number,
];
return [a, b, 0, 0, c, d, 0, 0, 0, 0, 1, 0, e, f, 0, 1];
};
/** Equal within half a pixel of translation and 1% of anything else. */
const posesMatch = (a: Pose, b: Pose): boolean =>
Math.abs(a.opacity - b.opacity) <= 0.01 &&
a.matrix.every((value, index) => {
const tolerance = index === 12 || index === 13 || index === 14 ? 0.5 : 0.01;
return Math.abs(value - b.matrix[index]!) <= tolerance;
});
// Scene ---------------------------------------------------------------------
/** Imperative controller for one operation, returned by `Scene.begin()`. */
class OperationHandle implements OriginOperationHandle {
readonly id: number;
readonly originKey: string;
readonly targetKey: string;
constructor(
private readonly scene: Scene,
/** Kept after the graph drops it, so `intent` stays readable. */
private readonly operation: MutableOriginOperation,
/** The target at creation; later edge rewrites do not change it. */
targetKey: string,
) {
markRaw(this);
this.id = operation.id;
this.originKey = operation.originKey;
this.targetKey = targetKey;
}
get intent(): OriginOperationIntent {
return this.operation.intent;
}
update(progress: number, velocity = 0): void {
this.scene.graph.update(this.id, progress, velocity);
}
finish(options?: OriginFinishOptions): Promise<boolean> {
return this.scene.finish(this.id, options);
}
async cancel(options?: Pick<OriginFinishOptions, "animate">): Promise<void> {
await this.scene.finish(this.id, {
commit: false,
animate: options?.animate,
});
}
}
/**
* One independent scene: the {@link SceneGraph} model plus the timing around
* it — waiting for Vue to mount targets, measuring hosts, and settling
* released operations with a spring.
*/
class Scene implements OriginScene {
/** @internal */
readonly graph: SceneGraph;
readonly nodes: ComputedRef<readonly OriginSceneNode[]>;
readonly operations: ComputedRef<readonly OriginOperation[]>;
readonly roots: SceneGraph["roots"];
/** DOM registered by the renderer, used only for measurement. */
private readonly hosts = new Map<string, HTMLElement>();
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) {
// Never proxy the scene: its methods rely on raw internal state.
markRaw(this);
this.graph = new SceneGraph(
Array.isArray(options.initial) ? options.initial : [options.initial],
);
this.roots = this.graph.roots;
this.nodes = computed(() =>
[...this.graph.nodes.values()].map((node) => ({
...node,
history: this.graph.historyOf(node).map((entry) => entry.view),
})),
);
this.operations = computed(() =>
[...this.graph.operations.values()].filter(
(operation) => operation.phase !== "finished",
),
);
}
contextFor(nodeKey: string): OriginContext {
return this.graph.contextFor(nodeKey);
}
async begin(
originKey: string,
action: OriginAction,
): Promise<OriginOperationHandle> {
if (this.container) this.refreshViewport();
// Only a scene at rest has a pose the new operation must continue from.
const poses =
DEV && this.operations.value.length === 0 ? this.capturePoses() : null;
const operation = this.graph.begin(
originKey,
action,
this.measure(originKey),
);
const { targetKey } = operation;
// Let Vue mount the target before measuring it. Gesture recognizers
// buffer pointer progress while this short preparation step is pending.
await nextTick();
operation.targetRect = this.measure(targetKey);
operation.phase = "interactive";
if (poses) this.warnOnJump(poses, operation);
return new OperationHandle(this, operation, targetKey);
}
async perform(originKey: string, action: OriginAction): Promise<boolean> {
const handle = await this.begin(originKey, action);
return handle.finish({ commit: true });
}
/**
* 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}.
*/
finish(id: number, options: OriginFinishOptions = {}): Promise<boolean> {
const operation = this.graph.operation(id);
if (!operation) return Promise.resolve(false);
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
// operation while this one is only visually settling.
const commit = shouldCommit(operation, options);
operation.intent = commit ? "commit" : "cancel";
operation.phase = "settling";
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.
const finished = (operation.phase as OriginOperationPhase) === "finished";
if (!this.graph.operation(operation.id) || finished)
return this.decide(
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 {
if (element) this.hosts.set(nodeKey, element);
else this.hosts.delete(nodeKey);
}
registerContainer(element: HTMLElement | null): void {
if (element === this.container) return;
this.resizeObserver?.disconnect();
this.resizeObserver = null;
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 {
return composeNodeStyle(this.graph, nodeKey, this.effectsOf);
}
isNodeInteractive(nodeKey: string): boolean {
return this.graph.isInteractive(nodeKey);
}
/**
* Move an operation to 0 or 1, animated unless motion is disabled. The
* spring stops early once `current()` reports the release was caught or
* superseded.
*/
private async settle(
operation: MutableOriginOperation,
target: 0 | 1,
animate: boolean,
current: () => boolean,
) {
if (!animate || prefersReducedMotion()) {
operation.progress = target;
operation.overshoot = 0;
operation.velocity = 0;
return;
}
// Start from the raw position so a rubber-banded drag springs back
// continuously instead of snapping its overshoot away.
const from = {
position: operation.progress + operation.overshoot,
velocity: operation.velocity,
};
await runSpring(
from,
target,
(position, velocity) => {
if (!this.graph.operation(operation.id) || !current()) return false;
operation.progress = clamp01(position);
operation.overshoot = position - operation.progress;
operation.velocity = velocity;
return true;
},
operation.choreography.spring,
);
}
/** Development only: each visible host's rendered transform and opacity. */
private capturePoses(): Map<string, Pose> {
const poses = new Map<string, Pose>();
for (const [key, host] of this.hosts) {
const pose = poseOf(host);
if (pose) poses.set(key, pose);
}
return poses;
}
/**
* Development only: warn when an operation's first frame moved a node that
* was visibly at rest, the one-frame "pop" of mismatched choreographies.
*/
private warnOnJump(
before: Map<string, Pose>,
operation: MutableOriginOperation,
) {
const { choreography } = operation;
if (warnedChoreographies.has(choreography)) return;
for (const [key, pose] of before) {
const host = this.hosts.get(key);
const after = host && poseOf(host);
if (!after || posesMatch(pose, after)) continue;
warnedChoreographies.add(choreography);
warn(
`"${choreography.name ?? "anonymous"}" moved node "${key}" on its ` +
"first frame. At progress 0 its effects should match the pose the " +
"scene was resting in, usually the previous choreography at " +
"progress 1; otherwise the view jumps.",
);
return;
}
}
private measure(nodeKey: string): OriginRect | undefined {
const host = this.hosts.get(nodeKey);
return host ? measureRect(host) : undefined;
}
/** Re-measure the container, replacing the cached rect only on change. */
private refreshViewport(): void {
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;
};
}
/**
* Create one independent scene/history context.
*
* The implementation stores nodes flat. Operation edges describe how their
* visual effects compose, but a Vue component never moves to a different VNode
* parent as edges are added or removed. This is what preserves component state
* while X→Y and Y→Z overlap.
*
* @param options - Initial root view recipe or recipes.
* @returns A self-contained reactive scene. Render it through `OriginScene`.
*
* @example
* ```ts
* const scene = createOriginScene({
* initial: originView(HomeView, undefined, { key: "home" }),
* });
* ```
*/
export function createOriginScene(
options: CreateOriginSceneOptions,
): OriginScene {
return new Scene(options);
}
// Node-scoped access --------------------------------------------------------
/**
* Injection payload provided by each stable scene-node host.
*
* @internal
*/
export interface OriginNodeScope {
/** Scene containing the node. */
readonly scene: OriginScene;
/** Unique key of the mounted node. */
readonly nodeKey: string;
}
/** Node-scoped controls returned by {@link useOrigin}. */
export interface UseOrigin {
/** Key of the mounted node containing the calling component. */
readonly nodeKey: string;
/** Scene containing the calling component. */
readonly scene: OriginScene;
/** Reactive context for the calling component's scene node. */
readonly context: ComputedRef<OriginContext>;
/** Reactive shorthand for `context.value.view`. */
readonly view: ComputedRef<OriginView>;
/** Reactive shorthand for `context.value.previous`. */
readonly previous: ComputedRef<OriginView | undefined>;
/** Reactive shorthand for `context.value.canGoBack`. */
readonly canGoBack: ComputedRef<boolean>;
/** Begin an interactively controlled operation from this component. */
begin(action: OriginAction): Promise<OriginOperationHandle>;
/** Programmatically create and commit a target from this component. */
perform(action: OriginAction): Promise<boolean>;
}
/**
* Injection key used by the internal scene-node host to establish origin
* ownership for descendant components.
*
* Application code normally calls {@link useOrigin} instead of injecting this
* key directly.
*
* @internal
*/
export const originNodeScopeKey: InjectionKey<OriginNodeScope> =
Symbol("origin-node-scope");
/**
* Access the scene from the component that owns an interaction declaration.
*
* There is deliberately no `activeView`: the injected node is the origin
* because this component is where the event or application action occurred.
*
* @returns Node-scoped scene state and operation controls.
* @throws If called outside a component rendered by `OriginScene`.
*
* @example
* ```ts
* const origin = useOrigin();
*
* function openProfile() {
* return origin.perform(
* forward(originView(ProfileView), slideLeft),
* );
* }
* ```
*/
export function useOrigin(): UseOrigin {
const scope = inject(originNodeScopeKey);
if (!scope)
throw new OriginSceneError(
"outside-scene",
"useOrigin() must be called inside an <OriginScene> view.",
);
const { scene, nodeKey } = scope;
const context = computed(() => scene.contextFor(nodeKey));
return {
nodeKey,
scene,
context,
view: computed(() => context.value.view),
previous: computed(() => context.value.previous),
canGoBack: computed(() => context.value.canGoBack),
begin: (action) => scene.begin(nodeKey, action),
perform: (action) => scene.perform(nodeKey, action),
};
}
+236
View File
@@ -0,0 +1,236 @@
import type { ComputedRef, CSSProperties, ShallowRef } from "vue";
import type {
OriginChoreography,
OriginOperationIntent,
OriginOperationPhase,
} from "../choreography";
import type {
OriginAction,
OriginHistoryMode,
OriginPlacement,
OriginView,
} from "../navigation";
import type { OriginRect } from "../shared";
/**
* Visibility/lifecycle role of a mounted scene node.
*
* - `active`: currently exposed for normal interaction.
* - `transitioning`: participating in at least one live operation edge.
* - `exposed`: retained and visually presented behind/beside the active entry,
* but inert.
* - `parked`: retained in history but visually hidden and inert.
*/
export type OriginSceneNodeState =
"active" | "transitioning" | "exposed" | "parked";
/**
* Read-only diagnostic representation of one mounted scene node.
*
* A node corresponds to one currently mounted Vue component instance.
*/
export interface OriginSceneNode {
/** Unique identity for this particular mounted scene node. */
readonly key: string;
/** Monotonically increasing creation order within the scene. */
readonly sequence: number;
/** Recipe used to create the node's component. */
readonly view: OriginView;
/** Recipes for the retained instance chain preceding this node. */
readonly history: readonly OriginView[];
/** Key of the retained mounted entry immediately before this node. */
readonly previousNodeKey?: string;
/** Current visibility/lifecycle role of this mounted instance. */
readonly state: OriginSceneNodeState;
/**
* Operation edge positioning this node as its target. This may identify a
* connected resting edge that is intentionally absent from live-operation
* diagnostics.
*/
readonly incomingOperationId?: number;
}
/** Read-only diagnostic representation of one live operation edge. */
export interface OriginOperation {
/** Unique, monotonically increasing operation identity within the scene. */
readonly id: number;
/** Key of the mounted component that originated the operation. */
readonly originKey: string;
/** Key of the newly created or retained target component. */
readonly targetKey: string;
/** Original history entry targeted before visual-edge rewrites. */
readonly entryTargetKey: string;
/** Choreography currently calculating this edge's effects. */
readonly choreography: OriginChoreography;
/** Target stacking relationship to the origin. */
readonly placement: OriginPlacement;
/** Retained-history behavior performed if this operation commits. */
readonly history: OriginHistoryMode;
/** Normalized progress in the inclusive range `0..1`. */
readonly progress: number;
/** Drag distance past either end of `progress`; usually `0`. */
readonly overshoot: number;
/** Latest normalized velocity in progress units per second. */
readonly velocity: number;
/** Current operation lifecycle phase. */
readonly phase: OriginOperationPhase;
/** Selected operation outcome. */
readonly intent: OriginOperationIntent;
}
/**
* Node-local information supplied to component-owned action factories.
*
* There is deliberately no global `current` or `active` view. The component
* handling the event is the origin represented by this context.
*/
export interface OriginContext {
/** Unique key of the mounted node that owns the interaction. */
readonly nodeKey: string;
/** Recipe used to create the origin node. */
readonly view: OriginView;
/** Whether this retained history entry has a mounted previous instance. */
readonly canGoBack: boolean;
/** Recipe belonging to the retained previous instance, when available. */
readonly previous?: OriginView;
/** Recipes belonging to all retained instances preceding this node. */
readonly history: readonly OriginView[];
}
/** Options controlling how a manually managed operation is resolved. */
export interface OriginFinishOptions {
/**
* Override the choreography's progress/velocity decision. Omit it to use the
* choreography's commit thresholds.
*/
commit?: boolean;
/**
* Whether to run the settling spring before finalizing the graph.
*
* @defaultValue `true`
*/
animate?: boolean;
}
/** Imperative controller for one mounted, live operation edge. */
export interface OriginOperationHandle {
/** Identity of the live operation controlled by this handle. */
readonly id: number;
/** Key of the source node that created the operation. */
readonly originKey: string;
/** Key of the newly mounted or retained target node. */
readonly targetKey: string;
/**
* The operation's selected outcome. It becomes `commit` or `cancel`
* synchronously when `finish()` or `cancel()` is called, before settling.
*/
readonly intent: OriginOperationIntent;
/**
* Set interactive progress and optional velocity.
*
* Progress is clamped to `0..1`; any excess is reported to choreographies as
* `overshoot`. Velocity is normalized to progress units per second and is
* used by the choreography's flick threshold.
*/
update(progress: number, velocity?: number): void;
/**
* Select commit/cancel, run the settling spring, and finalize the scene.
*
* @returns `true` when the target was retained, otherwise `false`.
*/
finish(options?: OriginFinishOptions): Promise<boolean>;
/** Cancel the operation and remove its target branch. */
cancel(options?: Pick<OriginFinishOptions, "animate">): Promise<void>;
}
/** Options used to create an independent origin scene. */
export interface CreateOriginSceneOptions {
/**
* One initial root recipe, or several independent roots rendered in the same
* flat scene. Most applications begin with one root.
*/
initial: OriginView | readonly OriginView[];
}
/**
* A self-contained scene graph, view-recipe history, and animation compositor.
*
* Multiple scenes may coexist and do not share nodes, history, or operations.
*/
export interface OriginScene {
/** Reactive snapshot of every currently mounted node. */
readonly nodes: ComputedRef<readonly OriginSceneNode[]>;
/** Reactive snapshot of every live animation/gesture operation. */
readonly operations: ComputedRef<readonly OriginOperation[]>;
/** Keys of visible operation-graph roots; parked history is excluded. */
readonly roots: Readonly<ShallowRef<readonly string[]>>;
/** Return the node-local action context for a mounted node key. */
contextFor(nodeKey: string): OriginContext;
/**
* Create a forward target or reveal a retained back target, then return
* manual control of its operation.
*
* @throws If the origin is missing, parked/exposed and inert, or already owns
* an outgoing operation.
*/
begin(
originKey: string,
action: OriginAction,
): Promise<OriginOperationHandle>;
/**
* Resolve a target and commit it programmatically using the settling spring.
*
* @returns `true` once the target has been committed.
*/
perform(originKey: string, action: OriginAction): Promise<boolean>;
/** @internal Register or unregister a scene node's host element. */
registerElement(nodeKey: string, element: HTMLElement | null): void;
/** @internal Register or unregister the scene container used for measurement. */
registerContainer(element: HTMLElement | null): void;
/** @internal Calculate the fully composited inline style for one node host. */
styleForNode(nodeKey: string): CSSProperties;
/** @internal Determine whether a node should currently receive pointer input. */
isNodeInteractive(nodeKey: string): boolean;
}
/**
* Mutable operation storage used by the scene implementation.
*
* @internal
*/
export interface MutableOriginOperation {
/** Unique operation identity. */
id: number;
/** Origin node key. */
originKey: string;
/** Newly created or retained target node key. */
targetKey: string;
/** Original history entry targeted before visual-edge rewrites. */
entryTargetKey: string;
/** Visual choreography for this edge. */
choreography: OriginChoreography;
/** Target stacking relationship. */
placement: OriginPlacement;
/** Retained-history behavior selected for this operation. */
history: OriginHistoryMode;
/** Normalized progress. */
progress: number;
/** Drag distance past either end of `progress`. */
overshoot: number;
/** Normalized velocity. */
velocity: number;
/** Current lifecycle phase. */
phase: OriginOperationPhase;
/** Selected outcome. */
intent: OriginOperationIntent;
/** Measured origin bounds, when an element was available. */
originRect?: OriginRect;
/** Measured target bounds, after the target mounted. */
targetRect?: OriginRect;
}
/** Public props accepted by the `OriginScene` renderer component. */
export interface OriginSceneProps {
/** Scene instance whose flat component nodes should be rendered. */
scene: OriginScene;
}
+65
View File
@@ -0,0 +1,65 @@
/** A rectangle measured in viewport CSS pixels. */
export interface OriginRect {
/** Distance from the viewport's top edge in CSS pixels. */
top: number;
/** Distance from the viewport's left edge in CSS pixels. */
left: number;
/** Rectangle width in CSS pixels. */
width: number;
/** Rectangle height in CSS pixels. */
height: number;
}
/** Clamp a value to the inclusive unit range `0..1`. */
export function clamp01(value: number): number {
return Math.max(0, Math.min(1, value));
}
/** Clamp a value to the inclusive range `min..max`. */
export function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
/** Measure an element's viewport bounds as a plain, serializable rectangle. */
export function measureRect(element: Element): OriginRect {
const { top, left, width, height } = element.getBoundingClientRect();
return { top, left, width, height };
}
/** Bounds of the browser window, or a unit rectangle outside a browser. */
export function windowRect(): OriginRect {
const browser = typeof window !== "undefined";
return {
top: 0,
left: 0,
width: browser ? window.innerWidth : 1,
height: browser ? window.innerHeight : 1,
};
}
/**
* Whether animation should resolve immediately.
*
* True outside a browser, when `requestAnimationFrame` is unavailable, or when
* the user prefers reduced motion.
*/
export function prefersReducedMotion(): boolean {
return (
typeof window === "undefined" ||
!window.requestAnimationFrame ||
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";
+17
View File
@@ -30,6 +30,23 @@
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) {
.nvo-node {
will-change: auto;
-831
View File
@@ -1,831 +0,0 @@
import type { Component, ComputedRef, CSSProperties, ShallowRef } from "vue";
export type PropsOf<T> =
T extends abstract new (...args: any[]) => { $props: infer P }
? P
: T extends (props: infer P, ...args: any[]) => any
? P
: never;
/**
* A view is a recipe for creating a Vue component, not a mounted instance.
*
* Forward navigation uses the recipe to create a mounted history entry. That
* instance remains mounted until a committed back operation pops it.
*
* @typeParam Props - The props accepted by the component recipe.
*/
export interface OriginView<
T extends Component = Component,
// Props extends Record<string, unknown> = Record<string, unknown>,
> {
/** The Vue component definition that will be mounted for this recipe. */
readonly component: T;
/** Props passed to the component when the recipe is mounted. */
readonly props?: Readonly<PropsOf<T>>;
/**
* A developer-facing recipe identity used in diagnostics and as the prefix
* of generated scene-node keys. It does not make Vue reuse an instance.
*/
readonly key?: string;
/** A human-readable label used by diagnostics and DOM data attributes. */
readonly name?: string;
}
/**
* The lifecycle phase of an operation edge.
*
* - `preparing`: the target has been added and Vue is mounting it.
* - `interactive`: progress may be controlled by a gesture or application.
* - `settling`: the commit/cancel decision is fixed and the spring is running.
* - `finished`: the graph rewrite or cancellation cleanup has completed.
*/
export type OriginOperationPhase =
"preparing" | "interactive" | "settling" | "finished";
/**
* The outcome selected for an operation.
*
* Intent remains `undecided` until `finish()` is called. It becomes final
* before the settling animation completes, allowing the retained target to
* originate its own operation immediately.
*/
export type OriginOperationIntent = "undecided" | "commit" | "cancel";
/**
* Controls how an operation participates in retained instance history.
*
* - `push`: create a target whose previous entry is the mounted origin.
* - `replace`: create a target that inherits the origin's previous entry, then
* remove the origin on commit.
* - `back`: reuse the mounted previous entry and pop the origin on commit.
*/
export type OriginHistoryMode = "push" | "replace" | "back";
/**
* The target's stacking relationship to its origin while an operation exists.
*/
export type OriginPlacement = "above" | "under";
/**
* Visibility/lifecycle role of a mounted scene node.
*
* - `active`: currently exposed for normal interaction.
* - `transitioning`: participating in at least one live operation edge.
* - `exposed`: retained and visually presented behind/beside the active entry,
* but inert.
* - `parked`: retained in history but visually hidden and inert.
*/
export type OriginSceneNodeState =
"active" | "transitioning" | "exposed" | "parked";
/** A rectangle measured in viewport CSS pixels. */
export interface OriginRect {
/** Distance from the viewport's top edge in CSS pixels. */
top: number;
/** Distance from the viewport's left edge in CSS pixels. */
left: number;
/** Rectangle width in CSS pixels. */
width: number;
/** Rectangle height in CSS pixels. */
height: number;
}
/**
* One composable contribution to a scene node's final visual style.
*
* Transforms are concatenated in origin-to-descendant order and opacity values
* are multiplied. This lets X→Y and Y→Z affect Y simultaneously without either
* routine replacing the other routine's CSS transform.
*/
export interface OriginEffect {
/**
* A CSS transform contribution. Transform strings from inherited and local
* origin frames are concatenated rather than replacing one another.
*/
transform?: string;
/**
* An opacity contribution between `0` and `1`. Contributions from multiple
* frames are multiplied and the final value is clamped.
*/
opacity?: number;
/**
* Relative stacking contribution. `above()` defaults the target to +1 and
* `under()` defaults it to -1.
*/
layer?: number;
/**
* Escape hatch for non-geometric effects such as filter, clipPath, or
* borderRadius. Later/local effects override inherited properties.
*/
style?: CSSProperties;
}
/**
* Independent visual contributions calculated for one operation edge.
*
* Any omitted contribution is treated as an identity effect.
*/
export interface OriginEffectSet {
/**
* Applied to both source and target. This is useful for moving an entire
* origin-relative coordinate frame.
*/
frame?: OriginEffect;
/** Applied to the component that initiated this operation. */
source?: OriginEffect;
/** Applied to the component created by this operation and its descendants. */
target?: OriginEffect;
}
/** Values supplied whenever a choreography calculates its visual effects. */
export interface OriginChoreographyContext {
/** Normalized operation progress, clamped to the inclusive range `0..1`. */
progress: number;
/**
* Normalized progress units per second. Positive velocity moves toward
* commit; negative velocity moves back toward cancellation.
*/
velocity: number;
/** Current lifecycle phase of the operation. */
phase: OriginOperationPhase;
/** Commit/cancel outcome, if release has already selected one. */
intent: OriginOperationIntent;
/** Origin host bounds captured immediately before the target mounts. */
originRect?: OriginRect;
/** Target host bounds measured after Vue mounts the target. */
targetRect?: OriginRect;
/** Scene-container bounds, or the browser viewport when no container exists. */
viewport: OriginRect;
}
/**
* A choreography describes visual relationships only. Component creation,
* history, and cleanup are performed by the scene operation that uses it.
*/
export interface OriginChoreography {
/** Optional diagnostic name surfaced by scene inspectors and devtools. */
readonly name?: string;
/**
* Calculate source, target, and shared-frame contributions for the current
* operation state. This function should be deterministic and side-effect
* free because it can run many times per animation frame.
*/
effects(context: OriginChoreographyContext): OriginEffectSet;
/**
* Gesture progress required to retain the target after release.
*
* @defaultValue `0.36`
*/
readonly commitThreshold?: number;
/**
* Normalized positive release velocity that can commit a deliberate flick
* once progress has reached at least `0.06`.
*
* @defaultValue `0.9`
*/
readonly commitVelocity?: number;
/**
* Keep this choreography's progress-`1` effects connected after a committed
* push instead of collapsing the edge and hiding its source.
*
* This is intended for partial presentations such as drawers and inspectors
* where part of the retained source remains visible beside the target. The
* source remains mounted, visible, and inert. A back operation temporarily
* suspends the resting edge; cancelling back restores it.
*
* Only retained-history `push` actions support connected resting effects.
*
* @defaultValue `false`
*/
readonly persistAtRest?: boolean;
}
/** A complete request to create and animate a target view from an origin. */
export interface OriginAction {
/**
* Recipe for a newly created push or replacement target. A back action omits
* this because the scene resolves its already-mounted previous entry.
*/
readonly target?: OriginView;
/** Visual relationship applied to the source, target, and shared frame. */
readonly choreography: OriginChoreography;
/**
* Target stacking relationship during the operation.
*
* @defaultValue `"above"`
*/
readonly placement?: OriginPlacement;
/**
* History mutation applied to the target recipe.
*
* @defaultValue `"push"`
*/
readonly history?: OriginHistoryMode;
}
/**
* A scene mutation without visual choreography.
*
* Gesture builders keep navigation intent separate from animation so the same
* destination can be paired with different component-local interactions.
* Calling `.animate()` materializes this intent as an {@link OriginAction}.
*/
export interface OriginNavigationIntent {
/**
* Recipe for a newly created push or replacement target. Back navigation
* omits this because the scene resolves the retained previous instance.
*/
readonly target?: OriginView;
/** Target stacking relationship while the gesture operation is visible. */
readonly placement?: OriginPlacement;
/** Retained-history mutation performed if the gesture commits. */
readonly history: OriginHistoryMode;
}
/**
* Read-only diagnostic representation of one mounted scene node.
*
* A node corresponds to one currently mounted Vue component instance.
*/
export interface OriginSceneNode {
/** Unique identity for this particular mounted scene node. */
readonly key: string;
/** Monotonically increasing creation order within the scene. */
readonly sequence: number;
/** Recipe used to create the node's component. */
readonly view: OriginView;
/** Recipes for the retained instance chain preceding this node. */
readonly history: readonly OriginView[];
/** Key of the retained mounted entry immediately before this node. */
readonly previousNodeKey?: string;
/** Current visibility/lifecycle role of this mounted instance. */
readonly state: OriginSceneNodeState;
/**
* Operation edge positioning this node as its target. This may identify a
* connected resting edge that is intentionally absent from live-operation
* diagnostics.
*/
readonly incomingOperationId?: number;
}
/** Read-only diagnostic representation of one live operation edge. */
export interface OriginOperation {
/** Unique, monotonically increasing operation identity within the scene. */
readonly id: number;
/** Key of the mounted component that originated the operation. */
readonly originKey: string;
/** Key of the newly created or retained target component. */
readonly targetKey: string;
/** Original history entry targeted before visual-edge rewrites. */
readonly entryTargetKey: string;
/** Choreography currently calculating this edge's effects. */
readonly choreography: OriginChoreography;
/** Target stacking relationship to the origin. */
readonly placement: OriginPlacement;
/** Retained-history behavior performed if this operation commits. */
readonly history: OriginHistoryMode;
/** Normalized progress in the inclusive range `0..1`. */
readonly progress: number;
/** Latest normalized velocity in progress units per second. */
readonly velocity: number;
/** Current operation lifecycle phase. */
readonly phase: OriginOperationPhase;
/** Selected operation outcome. */
readonly intent: OriginOperationIntent;
}
/**
* Node-local information supplied to component-owned action factories.
*
* There is deliberately no global `current` or `active` view. The component
* handling the event is the origin represented by this context.
*/
export interface OriginContext {
/** Unique key of the mounted node that owns the interaction. */
readonly nodeKey: string;
/** Recipe used to create the origin node. */
readonly view: OriginView;
/** Whether this retained history entry has a mounted previous instance. */
readonly canGoBack: boolean;
/** Recipe belonging to the retained previous instance, when available. */
readonly previous?: OriginView;
/** Recipes belonging to all retained instances preceding this node. */
readonly history: readonly OriginView[];
}
/** Options controlling how a manually managed operation is resolved. */
export interface OriginFinishOptions {
/**
* Override the choreography's progress/velocity decision. Omit it to use the
* choreography's commit thresholds.
*/
commit?: boolean;
/**
* Whether to run the settling spring before finalizing the graph.
*
* @defaultValue `true`
*/
animate?: boolean;
}
/** Imperative controller for one mounted, live operation edge. */
export interface OriginOperationHandle {
/** Identity of the live operation controlled by this handle. */
readonly id: number;
/** Key of the source node that created the operation. */
readonly originKey: string;
/** Key of the newly mounted or retained target node. */
readonly targetKey: string;
/**
* Set interactive progress and optional velocity.
*
* Progress is clamped to `0..1`. Velocity is normalized to progress units
* per second and is used by the choreography's flick threshold.
*/
update(progress: number, velocity?: number): void;
/**
* Select commit/cancel, run the settling spring, and finalize the scene.
*
* @returns `true` when the target was retained, otherwise `false`.
*/
finish(options?: OriginFinishOptions): Promise<boolean>;
/** Cancel the operation and remove its target branch. */
cancel(options?: Pick<OriginFinishOptions, "animate">): Promise<void>;
}
/**
* A self-contained scene graph, view-recipe history, and animation compositor.
*
* Multiple scenes may coexist and do not share nodes, history, or operations.
*/
export interface OriginScene {
/** Reactive snapshot of every currently mounted node. */
readonly nodes: ComputedRef<readonly OriginSceneNode[]>;
/** Reactive snapshot of every live animation/gesture operation. */
readonly operations: ComputedRef<readonly OriginOperation[]>;
/** Keys of visible operation-graph roots; parked history is excluded. */
readonly roots: Readonly<ShallowRef<readonly string[]>>;
/** Return the node-local action context for a mounted node key. */
contextFor(nodeKey: string): OriginContext;
/**
* Create a forward target or reveal a retained back target, then return
* manual control of its operation.
*
* @throws If the origin is missing, parked/exposed and inert, or already owns
* an outgoing operation.
*/
begin(
originKey: string,
action: OriginAction,
): Promise<OriginOperationHandle>;
/**
* Resolve a target and commit it programmatically using the settling spring.
*
* @returns `true` once the target has been committed.
*/
perform(originKey: string, action: OriginAction): Promise<boolean>;
/** @internal Register or unregister a scene node's host element. */
registerElement(nodeKey: string, element: HTMLElement | null): void;
/** @internal Register or unregister the scene container used for measurement. */
registerContainer(element: HTMLElement | null): void;
/** @internal Calculate the fully composited inline style for one node host. */
styleForNode(nodeKey: string): CSSProperties;
/** @internal Determine whether a node should currently receive pointer input. */
isNodeInteractive(nodeKey: string): boolean;
}
/** Node-scoped controls returned by {@link useOrigin}. */
export interface UseOrigin {
/** Key of the mounted node containing the calling component. */
readonly nodeKey: string;
/** Scene containing the calling component. */
readonly scene: OriginScene;
/** Reactive context for the calling component's scene node. */
readonly context: ComputedRef<OriginContext>;
/** Reactive shorthand for `context.value.view`. */
readonly view: ComputedRef<OriginView>;
/** Reactive shorthand for `context.value.previous`. */
readonly previous: ComputedRef<OriginView | undefined>;
/** Reactive shorthand for `context.value.canGoBack`. */
readonly canGoBack: ComputedRef<boolean>;
/** Begin an interactively controlled operation from this component. */
begin(action: OriginAction): Promise<OriginOperationHandle>;
/** Programmatically create and commit a target from this component. */
perform(action: OriginAction): Promise<boolean>;
}
/**
* 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>;
/** Direction in which pointer movement advances operation progress. */
export type OriginGestureDirection = "left" | "right" | "up" | "down";
/** Physical side of the gesture host used to admit pointer-down. */
export type OriginGestureEdge = "left" | "right" | "top" | "bottom";
/**
* CSS length accepted by an edge-based gesture start rule.
*
* Numbers are interpreted as CSS pixels. Strings may use normal CSS lengths,
* percentages, `calc()`, or `clamp()`, and are resolved against the gesture
* host when pointer-down occurs.
*/
export type OriginGestureDistance = number | string;
/** A pointer position measured in viewport and gesture-host coordinates. */
export interface OriginGesturePoint {
/** Viewport-relative horizontal position. */
readonly clientX: number;
/** Viewport-relative vertical position. */
readonly clientY: number;
/** Horizontal position relative to the gesture host's left edge. */
readonly localX: number;
/** Vertical position relative to the gesture host's top edge. */
readonly localY: number;
}
/** Context supplied to a custom `.from.when()` start predicate. */
export interface OriginGestureStartContext {
/** Native pointer-down event being considered. */
readonly event: PointerEvent;
/** Component instance from which the gesture would originate. */
readonly origin: OriginContext;
/** Element carrying the gesture's pointer handlers. */
readonly host: HTMLElement;
/** Gesture-host bounds captured at pointer-down. */
readonly bounds: OriginRect;
/** Pointer position at pointer-down. */
readonly point: OriginGesturePoint;
}
/**
* Synchronous predicate deciding whether a pointer-down may become a gesture.
*
* Directional recognition still occurs later, after movement passes the
* configured intent threshold.
*/
export type OriginGestureStartPredicate = (
context: OriginGestureStartContext,
) => boolean;
/** Start policy stored in a completed gesture definition. */
export type OriginGestureStart =
| {
/** Recognize pointer-down anywhere on the host. */
readonly kind: "anywhere";
}
| {
/** Recognize pointer-down within a CSS distance of one host side. */
readonly kind: "edge";
readonly edge: OriginGestureEdge;
readonly distance: OriginGestureDistance;
}
| {
/** Recognize pointer-down when application policy returns `true`. */
readonly kind: "when";
readonly predicate: OriginGestureStartPredicate;
};
/** Direction-recognition tuning accepted by `.to.left()` and its siblings. */
export interface OriginGestureDirectionOptions {
/**
* Minimum directed movement in CSS pixels before the gesture captures.
*
* @defaultValue `8`
*/
readonly threshold?: number;
/**
* Ratio by which directed movement must exceed cross-axis movement.
*
* @defaultValue `1.15`
*/
readonly axisDominance?: number;
}
/** Values available when a custom `.complete()` policy runs on pointer-up. */
export interface OriginGestureCompletionContext {
/** Component instance that originated the gesture. */
readonly origin: OriginContext;
/** Recognized movement direction. */
readonly direction: OriginGestureDirection;
/** Normalized directed distance, clamped to `0..1`. */
readonly progress: number;
/** Latest normalized progress units per second. */
readonly velocity: number;
/** Directed movement from pointer-down in CSS pixels. */
readonly distance: number;
/** Absolute cross-axis movement from pointer-down in CSS pixels. */
readonly crossDistance: number;
/** Elapsed time since pointer-down in milliseconds. */
readonly duration: number;
/** Native pointer-up event that ended the interaction. */
readonly event: PointerEvent;
/** Element carrying the gesture's pointer handlers. */
readonly host: HTMLElement;
/** Gesture-host bounds captured at pointer-down. */
readonly bounds: OriginRect;
/** Pointer position captured at pointer-down. */
readonly start: OriginGesturePoint;
/** Pointer position at release. */
readonly current: OriginGesturePoint;
}
/** Synchronous commit/cancel policy installed by `.complete()`. */
export type OriginGestureCompletionPredicate = (
context: OriginGestureCompletionContext,
) => boolean;
/**
* Values accepted from a gesture builder's `.navigate()` factory.
*
* A promise supports lazy view selection. Stale resolutions are discarded if
* the pointer has already ended or been cancelled.
*/
export type MaybeOriginNavigationIntent =
| OriginNavigationIntent
| null
| undefined
| Promise<OriginNavigationIntent | null | undefined>;
/** Factory that resolves navigation after directional recognition succeeds. */
export type OriginGestureNavigationFactory = (
context: OriginContext,
) => MaybeOriginNavigationIntent;
/**
* Immutable, executable result of a complete gesture builder chain.
*
* Pass this object to {@link useOriginGesture}. An omitted `.from` step is
* represented as an `anywhere` start rule.
*/
export interface OriginGestureDefinition {
/** Discriminator used by the compatibility recognizer overload. */
readonly kind: "origin-gesture-definition";
/** Pointer-down eligibility policy. */
readonly start: OriginGestureStart;
/** Direction in which movement advances operation progress. */
readonly direction: OriginGestureDirection;
/** Directional intent recognition tuning. */
readonly recognition: Readonly<OriginGestureDirectionOptions>;
/**
* Optional release decision. When omitted, choreography thresholds decide.
*/
readonly completion?: OriginGestureCompletionPredicate;
/** Node-local destination/history resolver. */
readonly navigation: OriginGestureNavigationFactory;
/** Visual routine paired with the navigation intent. */
readonly choreography: OriginChoreography;
}
/** Direction-selection stage shared by `gesture.to` and `.from.*().to`. */
export interface OriginGestureToBuilder {
/** Recognize leftward pointer movement. */
left(options?: OriginGestureDirectionOptions): OriginGestureDirectedBuilder;
/** Recognize rightward pointer movement. */
right(options?: OriginGestureDirectionOptions): OriginGestureDirectedBuilder;
/** Recognize upward pointer movement. */
up(options?: OriginGestureDirectionOptions): OriginGestureDirectedBuilder;
/** Recognize downward pointer movement. */
down(options?: OriginGestureDirectionOptions): OriginGestureDirectedBuilder;
}
/** Stage produced after a `.from` policy has been selected. */
export interface OriginGestureFromSelection {
/** Select the direction that advances this gesture. */
readonly to: OriginGestureToBuilder;
}
/** Optional pointer-down policy exposed at the start of a gesture chain. */
export interface OriginGestureFromBuilder {
/** Admit pointer-down within `distance` of the host's left edge. */
left(distance: OriginGestureDistance): OriginGestureFromSelection;
/** Admit pointer-down within `distance` of the host's right edge. */
right(distance: OriginGestureDistance): OriginGestureFromSelection;
/** Admit pointer-down within `distance` of the host's top edge. */
top(distance: OriginGestureDistance): OriginGestureFromSelection;
/** Admit pointer-down within `distance` of the host's bottom edge. */
bottom(distance: OriginGestureDistance): OriginGestureFromSelection;
/** Admit pointer-down anywhere on the gesture host. */
anywhere(): OriginGestureFromSelection;
/** Admit pointer-down when a synchronous application predicate succeeds. */
when(predicate: OriginGestureStartPredicate): OriginGestureFromSelection;
}
/** Builder stage after direction is known and completion remains optional. */
export interface OriginGestureDirectedBuilder {
/** Override the choreography's default release decision. */
complete(
predicate: OriginGestureCompletionPredicate,
): OriginGestureCompletedBuilder;
/** Select the target/history mutation while retaining default completion. */
navigate(
factory: OriginGestureNavigationFactory,
): OriginGestureNavigationBuilder;
}
/** Builder stage after a custom completion policy has been selected. */
export interface OriginGestureCompletedBuilder {
/** Select the target/history mutation performed on commit. */
navigate(
factory: OriginGestureNavigationFactory,
): OriginGestureNavigationBuilder;
}
/** Final builder stage waiting for visual choreography. */
export interface OriginGestureNavigationBuilder {
/** Attach visual choreography and produce an executable definition. */
animate(choreography: OriginChoreography): OriginGestureDefinition;
}
/** Root of the immutable fluent gesture-definition API. */
export interface OriginGestureBuilder {
/** Optionally constrain where pointer-down may begin. */
readonly from: OriginGestureFromBuilder;
/**
* Select movement direction with an implicit `from.anywhere()` start.
*/
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}.
*
* Spread or attach all four handlers to the same `HTMLElement`. Apply
* {@link style} as well so native scrolling is preserved on the cross-axis.
*/
export interface OriginGestureBinding {
/** Required size and `touch-action` styles for the gesture host. */
readonly style: Readonly<CSSProperties>;
/** Pointer-down handler that records a potentially eligible gesture. */
readonly onPointerdown: (event: PointerEvent) => void;
/** Pointer-move handler that recognizes and updates the operation. */
readonly onPointermove: (event: PointerEvent) => void;
/** Pointer-up handler that commits or cancels using progress and velocity. */
readonly onPointerup: (event: PointerEvent) => void;
/** Pointer-cancel handler that abandons any captured operation. */
readonly onPointercancel: () => void;
}
/** Props shared by both `OriginGesture` declaration styles. */
export interface OriginGestureBaseProps {
/**
* Native HTML tag rendered as the gesture host.
*
* @defaultValue `"div"`
*/
as?: string;
}
/** Builder-definition props accepted by the `OriginGesture` component. */
export interface OriginGestureDefinitionProps extends OriginGestureBaseProps {
/** Immutable definition produced by the {@link gesture} builder. */
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. */
export interface OriginGestureSurfaceProps {
/**
* Native HTML tag rendered as the shared gesture host.
*
* @defaultValue `"div"`
*/
as?: string;
/**
* Complete immutable definitions installed on the shared host.
*
* The surface adds no start, direction, completion, navigation, or animation
* policy. Definitions should be created by the owning page component.
*/
gestures: readonly OriginGestureDefinition[];
}
/** Public props accepted by the `OriginScene` renderer component. */
export interface OriginSceneProps {
/** Scene instance whose flat component nodes should be rendered. */
scene: OriginScene;
}
/**
* Injection payload provided by each stable scene-node host.
*
* @internal
*/
export interface OriginNodeScope {
/** Scene containing the node. */
readonly scene: OriginScene;
/** Unique key of the mounted node. */
readonly nodeKey: string;
}
/**
* Mutable operation storage used by the scene implementation.
*
* @internal
*/
export interface MutableOriginOperation {
/** Unique operation identity. */
id: number;
/** Origin node key. */
originKey: string;
/** Newly created or retained target node key. */
targetKey: string;
/** Original history entry targeted before visual-edge rewrites. */
entryTargetKey: string;
/** Visual choreography for this edge. */
choreography: OriginChoreography;
/** Target stacking relationship. */
placement: OriginPlacement;
/** Retained-history behavior selected for this operation. */
history: OriginHistoryMode;
/** Normalized progress. */
progress: number;
/** Normalized velocity. */
velocity: number;
/** Current lifecycle phase. */
phase: OriginOperationPhase;
/** Selected outcome. */
intent: OriginOperationIntent;
/** Measured origin bounds, when an element was available. */
originRect?: OriginRect;
/** Measured target bounds, after the target mounted. */
targetRect?: OriginRect;
}
@@ -1,14 +1,19 @@
import { createApp, defineComponent, h, nextTick, type Component } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import OriginGestureSurface from "./components/OriginGestureSurface.vue";
import OriginScene from "./components/OriginScene.vue";
import { gesture, useOriginGesture } from "./gesture";
import { back, defineOriginChoreography, forward, replace } from "./motion";
import { createOriginScene, originView } from "./scene";
import type {
OriginGestureBinding,
OriginGestureCompletionContext,
} from "./types";
import {
back,
createOriginScene,
defineOriginChoreography,
forward,
gesture,
OriginGestureSurface,
OriginScene,
originView,
replace,
useOriginGesture,
type OriginGestureBinding,
type OriginGestureCompletionContext,
} from "../src";
const mountedApps: Array<ReturnType<typeof createApp>> = [];
+297
View File
@@ -0,0 +1,297 @@
/**
* Where an application can trigger haptic feedback with the current API.
*/
import {
createApp,
defineComponent,
h,
nextTick,
watch,
type Component,
} from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
createOriginScene,
defineOriginChoreography,
forward,
gesture,
OriginScene,
originView,
useOriginGesture,
type OriginGestureDefinition,
} from "../src";
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: "haptics-test",
effects: ({ progress }) => ({ target: { opacity: progress } }),
});
const Target: Component = defineComponent({ render: () => h("div", "T") });
function pointer(type: string, clientX: number, timeStamp: number) {
const event = new PointerEvent(type, {
bubbles: true,
cancelable: true,
button: 0,
isPrimary: true,
pointerId: 1,
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));
}
/** Mount a scene whose root installs `definition`, then return its host. */
async function mount(definition: OriginGestureDefinition) {
// Reduced motion settles instantly, so release outcomes apply at once.
vi.stubGlobal("matchMedia", () => ({ matches: true }));
const Root = defineComponent({
setup() {
const binding = useOriginGesture(definition);
return () =>
h("div", {
id: "host",
style: binding.style,
onPointerdown: binding.onPointerdown,
onPointermove: binding.onPointermove,
onPointerup: binding.onPointerup,
});
},
});
const scene = createOriginScene({ initial: originView(Root) });
const root = document.createElement("div");
document.body.append(root);
const app = createApp({ render: () => h(OriginScene, { scene }) });
apps.push(app);
app.mount(root);
await nextTick();
const host = root.querySelector("#host") as HTMLElement;
Object.defineProperty(host, "clientWidth", { value: 200 });
host.getBoundingClientRect = () =>
({ top: 0, left: 0, width: 200, height: 400 }) as DOMRect;
return { scene, host };
}
/** Swipe left by `distance` px, slowly enough that velocity cannot commit. */
async function swipe(host: HTMLElement, distance: number, log: string[]) {
host.dispatchEvent(pointer("pointerdown", 190, 0));
host.dispatchEvent(pointer("pointermove", 180, 500));
await flush();
log.push("recognized");
host.dispatchEvent(pointer("pointermove", 190 - distance, 1000));
await flush();
host.dispatchEvent(pointer("pointerup", 190 - distance, 1000));
await flush();
log.push("released");
}
describe("haptic feedback", () => {
it("can vibrate when a gesture is recognized, from .navigate()", async () => {
const log: string[] = [];
const vibrate = vi.fn(() => log.push("vibrate") > 0);
vi.stubGlobal("navigator", { ...navigator, vibrate });
const { host } = await mount(
gesture.to
.left()
.navigate(() => {
navigator.vibrate?.(10);
return forward(originView(Target));
})
.animate(motion),
);
// A short swipe that is cancelled on release still vibrated, because
// .navigate() runs at recognition, before the outcome is known.
await swipe(host, 20, log);
expect(log).toEqual(["vibrate", "recognized", "released"]);
});
it("can vibrate only when a release commits, by watching operation intent", async () => {
const log: string[] = [];
const vibrate = vi.fn(() => log.push("vibrate") > 0);
vi.stubGlobal("navigator", { ...navigator, vibrate });
const { scene, host } = await mount(
gesture.to
.left()
.navigate(() => forward(originView(Target)))
.animate(motion),
);
// Intent becomes "commit" synchronously at release, before settling.
const seen = new Set<number>();
watch(
() => scene.operations.value.filter((o) => o.intent === "commit"),
(committed) => {
for (const operation of committed)
if (!seen.has(operation.id)) {
seen.add(operation.id);
navigator.vibrate?.(10);
}
},
{ flush: "sync" },
);
await swipe(host, 20, log); // 10% progress: cancelled
expect(vibrate).not.toHaveBeenCalled();
await swipe(host, 150, log); // 75% progress: committed
expect(log).toEqual([
"recognized",
"released",
"recognized",
"vibrate",
"released",
]);
});
it("can vibrate on release from a custom .complete() decision", async () => {
const vibrate = vi.fn(() => true);
vi.stubGlobal("navigator", { ...navigator, vibrate });
const { host } = await mount(
gesture.to
.left()
.complete(({ progress }) => {
const commit = progress > 0.5;
if (commit) navigator.vibrate?.(10);
return commit;
})
.navigate(() => forward(originView(Target)))
.animate(motion),
);
await swipe(host, 20, []);
expect(vibrate).not.toHaveBeenCalled();
await swipe(host, 150, []);
expect(vibrate).toHaveBeenCalledTimes(1);
});
});
describe(".onCommit()", () => {
it("vibrates once when a release commits, and not when it cancels", async () => {
const vibrate = vi.fn((_ms: number) => true);
const { host } = await mount(
gesture.to
.left()
.navigate(() => forward(originView(Target)))
.onCommit(() => vibrate(10))
.animate(motion),
);
await swipe(host, 20, []); // 10% progress: cancelled
expect(vibrate).not.toHaveBeenCalled();
await swipe(host, 150, []); // 75% progress: committed
expect(vibrate).toHaveBeenCalledTimes(1);
expect(vibrate).toHaveBeenCalledWith(10);
});
it("is available at every stage after the direction, in order", async () => {
const calls: string[] = [];
const definition = gesture.to
.left()
.onCommit(() => calls.push("directed"))
.complete(({ progress }) => progress > 0.5)
.onCommit(() => calls.push("completed"))
.navigate(() => forward(originView(Target)))
.onCommit((context) => calls.push(`navigation ${context.direction}`))
.animate(motion);
expect(definition.commitHandlers).toHaveLength(3);
expect(Object.isFrozen(definition.commitHandlers)).toBe(true);
const { host } = await mount(definition);
await swipe(host, 150, []);
expect(calls).toEqual(["directed", "completed", "navigation left"]);
});
it("keeps earlier builder stages unchanged", () => {
const base = gesture.to.left().navigate(() => null);
const withHandler = base.onCommit(() => {});
expect(base.animate(motion).commitHandlers).toEqual([]);
expect(withHandler.animate(motion).commitHandlers).toHaveLength(1);
});
it("reports a failing handler without stopping later handlers or the commit", async () => {
const errors: unknown[] = [];
const onError = (error: unknown) => errors.push(error);
process.on("uncaughtException", onError);
try {
const after = vi.fn();
const { scene, host } = await mount(
gesture.to
.left()
.navigate(() => forward(originView(Target)))
.onCommit(() => {
throw new Error("no vibration motor");
})
.onCommit(after)
.animate(motion),
);
await swipe(host, 150, []);
expect(after).toHaveBeenCalledTimes(1);
expect(scene.nodes.value.map((node) => node.state)).toEqual([
"parked",
"active",
]);
expect(errors).toEqual([new Error("no vibration motor")]);
} finally {
process.off("uncaughtException", onError);
}
});
it("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 () => {
const frames: FrameRequestCallback[] = [];
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) =>
frames.push(cb),
);
const log: string[] = [];
const { scene, host } = await mount(
gesture.to
.left()
.navigate(() => forward(originView(Target)))
.onCommit(() => log.push("commit"))
.animate(motion),
);
vi.stubGlobal("matchMedia", () => ({ matches: false }));
await swipe(host, 150, log);
expect(log).toEqual(["recognized", "commit", "released"]);
// The spring is still waiting for frames; the operation is settling.
expect(scene.operations.value[0]?.phase).toBe("settling");
expect(frames.length).toBeGreaterThan(0);
});
});
+400
View File
@@ -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"]);
});
});
+555
View File
@@ -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));
});
+361
View File
@@ -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);
});
});
@@ -8,16 +8,17 @@ import {
type Component,
} from "vue";
import { afterEach, describe, expect, it } from "vitest";
import OriginScene from "./components/OriginScene.vue";
import {
above,
back,
createOriginScene,
defineOriginChoreography,
forward,
OriginScene,
originView,
replace,
under,
} from "./motion";
import { createOriginScene, originView } from "./scene";
} from "../src";
const mountedApps: Array<ReturnType<typeof createApp>> = [];
@@ -221,9 +222,6 @@ describe("origin-relative scene graph", () => {
onUnmounted(() => {
xUnmounts += 1;
});
defineProps<{
message: string;
}>();
return () =>
h(
"div",
+1 -2
View File
@@ -9,6 +9,5 @@
"outDir": "dist",
"tsBuildInfoFile": "../../node_modules/.tmp/core-v2.tsbuildinfo"
},
"include": ["src/**/*.ts", "src/**/*.vue"],
"exclude": ["src/**/*.test.ts"]
"include": ["src/**/*.ts", "src/**/*.vue"]
}
+2
View File
@@ -0,0 +1,2 @@
.baseline/
browser/app/dist/
+41
View File
@@ -0,0 +1,41 @@
# Benchmarks
Compares core-v2's performance against an earlier implementation (by default
`main`).
From the repository root:
```sh
packages/core-v2/verify/setup-baseline.sh main # extract into verify/.baseline/
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.bench.ts` measures compositing, push/back cycles, and rendered frames.
Run each implementation in its own process with `ONLY=`; running both in one
process biases whichever runs second.
Behavioural verification lives with the unit tests: `tests/property.test.ts`
drives random operation sequences and pointer streams and checks the scene's
structural invariants after every step. Set `PROPERTY_SEEDS` to run more.
## Real-browser feel tests
`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>
+141
View File
@@ -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 },
});
+256
View File
@@ -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);
+5
View File
@@ -0,0 +1,5 @@
import { defineComponent, h, type Component } from "vue";
export function component(name: string): Component {
return defineComponent({ name, render: () => h("div", name) });
}
+101
View File
@@ -0,0 +1,101 @@
import { createApp, h, nextTick } from "vue";
import { bench, describe } from "vitest";
import * as next from "../src";
import * as base from "./.baseline";
import { component } from "./harness";
type Impl = typeof next;
const all = { baseline: base as unknown as Impl, rewrite: next };
const impls = process.env.ONLY
? { [process.env.ONLY]: all[process.env.ONLY as keyof typeof all] }
: all;
const motion = next.defineOriginChoreography({
name: "bench",
effects: ({ progress }) => ({
frame: { transform: `translateY(${progress}px)` },
source: {
transform: `translateX(${-progress * 100}px)`,
opacity: 1 - progress,
},
target: {
transform: `translateX(${(1 - progress) * 100}px)`,
style: { filter: "none" },
},
}),
});
/** Long enough for both implementations to reach steady-state JIT. */
const options = { time: 4000, warmupTime: 1500, warmupIterations: 2000 };
const views = Array.from({ length: 12 }, (_, i) =>
next.originView(component(`V${i}`), undefined, { key: `v${i}` }),
);
/** A scene with `depth` simultaneously live, chained operations. */
async function liveChain(M: Impl, depth: number) {
const scene = M.createOriginScene({ initial: views[0]! });
const handles: next.OriginOperationHandle[] = [];
let origin = scene.nodes.value[0]!.key;
for (let i = 1; i <= depth; i += 1) {
const handle = await scene.begin(origin, M.forward(views[i]!, motion));
handle.update(0.5);
handles.push(handle);
origin = handle.targetKey;
}
return { scene, handles };
}
for (const depth of [3, 10]) {
describe(`styleForNode, every node, ${depth} live chained operations`, async () => {
for (const [name, M] of Object.entries(impls)) {
const { scene, handles } = await liveChain(M, depth);
let p = 0;
bench(
name,
() => {
p = (p + 0.013) % 1;
handles[0]!.update(p);
for (const node of scene.nodes.value) scene.styleForNode(node.key);
},
options,
);
}
});
}
describe("push → back cycle (begin + finish, no animation)", () => {
for (const [name, M] of Object.entries(impls)) {
const scene = M.createOriginScene({ initial: views[0]! });
const root = scene.nodes.value[0]!.key;
bench(
name,
async () => {
const push = await scene.begin(root, M.forward(views[1]!, motion));
await push.finish({ commit: true, animate: false });
const pop = await scene.begin(push.targetKey, M.back(motion));
await pop.finish({ commit: true, animate: false });
},
options,
);
}
});
describe("rendered frame: update progress + Vue re-render, 6 live operations", async () => {
for (const [name, M] of Object.entries(impls)) {
const { scene, handles } = await liveChain(M, 6);
const host = document.createElement("div");
document.body.append(host);
createApp({ render: () => h(M.OriginScene, { scene }) }).mount(host);
await nextTick();
let p = 0;
bench(
name,
async () => {
p = (p + 0.013) % 1;
for (const handle of handles) handle.update(p);
await nextTick();
},
options,
);
}
});
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env sh
# Extract core-v2 sources from a git ref into ./.baseline for comparison.
set -eu
ref="${1:-main}"
here="$(cd "$(dirname "$0")" && pwd)"
root="$(git -C "$here" rev-parse --show-toplevel)"
tmp="$(mktemp -d)"
git -C "$root" archive "$ref" packages/core-v2/src | tar -x -C "$tmp"
rm -rf "$here/.baseline"
mv "$tmp/packages/core-v2/src" "$here/.baseline"
rm -f "$here/.baseline"/*.test.ts
rm -rf "$tmp"
echo "Baseline extracted from $ref into $here/.baseline"
+16
View File
@@ -0,0 +1,16 @@
import vue from "@vitejs/plugin-vue";
import { defineConfig } from "vitest/config";
/*
* Benchmarks against a previous core-v2 implementation.
* Run `setup-baseline.sh <git-ref>` first; see README.md.
*/
export default defineConfig({
plugins: [vue() as never],
test: {
root: __dirname,
environment: "happy-dom",
testTimeout: 120_000,
benchmark: { include: ["*.bench.ts"] },
},
});
+2 -1
View File
@@ -24,6 +24,7 @@
"apps/demo/src/**/*.ts",
"apps/demo/src/**/*.vue",
"packages/*/src/**/*.ts",
"packages/*/src/**/*.vue"
"packages/*/src/**/*.vue",
"packages/*/tests/**/*.ts"
]
}