V2: Origin based animations, Gesture Builder, New Demo, non-url-based-routing. Massive improvements.
This commit is contained in:
720
packages/core-v2/API.md
Normal file
720
packages/core-v2/API.md
Normal file
@@ -0,0 +1,720 @@
|
||||
# Core v2 API reference
|
||||
|
||||
`@native-vue-router/core-v2` is a routeless Vue scene compositor. A mounted
|
||||
component can create another component, animate both through a local operation
|
||||
frame, and retain either side when the operation resolves.
|
||||
|
||||
This document describes the experimental `0.1.0-experimental.0` API.
|
||||
|
||||
## Contents
|
||||
|
||||
- [Gesture start recognition](#gesture-start-recognition)
|
||||
- [Minimum setup](#minimum-setup)
|
||||
- [Components](#components)
|
||||
- [Nested scenes](#nested-scenes)
|
||||
- [Scene and view functions](#scene-and-view-functions)
|
||||
- [Actions and history](#actions-and-history)
|
||||
- [Gestures](#gestures)
|
||||
- [Choreographies and effects](#choreographies-and-effects)
|
||||
- [Node-scoped controls](#node-scoped-controls)
|
||||
- [Scene diagnostics and manual operations](#scene-diagnostics-and-manual-operations)
|
||||
- [Type reference](#type-reference)
|
||||
- [Errors and constraints](#errors-and-constraints)
|
||||
|
||||
## Gesture start recognition
|
||||
|
||||
The builder separates where a gesture begins from the direction it moves:
|
||||
|
||||
```ts
|
||||
const edgeBack = gesture.from
|
||||
.left("clamp(24px, 7vw, 48px)")
|
||||
.to.right()
|
||||
.navigate((context) => (context.canGoBack ? back() : null))
|
||||
.animate(slideRight);
|
||||
```
|
||||
|
||||
Edges are measured from the **gesture host element**, not unconditionally from
|
||||
the browser viewport. Supported start rules are:
|
||||
|
||||
```ts
|
||||
gesture.from.left(distance);
|
||||
gesture.from.right(distance);
|
||||
gesture.from.top(distance);
|
||||
gesture.from.bottom(distance);
|
||||
gesture.from.anywhere();
|
||||
gesture.from.when(predicate);
|
||||
```
|
||||
|
||||
`distance` accepts a number in CSS pixels or a CSS length string, including
|
||||
percentages, `calc()`, and `clamp()`. It is resolved against the current host
|
||||
size at pointer-down.
|
||||
|
||||
`.from` is optional. A chain beginning at `.to` admits pointer-down anywhere:
|
||||
|
||||
```ts
|
||||
gesture.to.right();
|
||||
```
|
||||
|
||||
This is semantically equivalent to:
|
||||
|
||||
```ts
|
||||
gesture.from.anywhere().to.right();
|
||||
```
|
||||
|
||||
It does not capture on the first positive pixel. The recognizer waits until
|
||||
directed movement crosses the intent threshold and dominates the cross-axis.
|
||||
|
||||
Custom shapes, safe-area rules, and exclusion zones belong in `.from.when()`:
|
||||
|
||||
```ts
|
||||
const dropDialog = gesture.from
|
||||
.when(({ point, bounds, event }) => {
|
||||
const rail = Math.max(36, bounds.width * 0.08);
|
||||
const outsideExcludedBand =
|
||||
event.clientY < bounds.top + bounds.height * 0.35 ||
|
||||
event.clientY > bounds.top + bounds.height * 0.65;
|
||||
return point.localX <= rail && outsideExcludedBand;
|
||||
})
|
||||
.to.down()
|
||||
.navigate(() => above(originView(DialogView)))
|
||||
.animate(dropAnimation);
|
||||
```
|
||||
|
||||
Interactive form controls are ignored automatically. Add
|
||||
`data-origin-gesture="ignore"` to any other element or ancestor that should not
|
||||
begin a gesture.
|
||||
|
||||
## Minimum setup
|
||||
|
||||
Import the required compositor stylesheet once:
|
||||
|
||||
```ts
|
||||
import "@native-vue-router/core-v2/style.css";
|
||||
```
|
||||
|
||||
Create a scene:
|
||||
|
||||
```ts
|
||||
import { createOriginScene, originView } from "@native-vue-router/core-v2";
|
||||
import HomeView from "./HomeView.vue";
|
||||
|
||||
export const scene = createOriginScene({
|
||||
initial: originView(HomeView, undefined, {
|
||||
key: "home",
|
||||
name: "Home",
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
Render it:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { OriginScene } from "@native-vue-router/core-v2";
|
||||
import { scene } from "./scene";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OriginScene :scene="scene" />
|
||||
</template>
|
||||
```
|
||||
|
||||
`OriginScene` must have a non-zero width and height through its parent layout.
|
||||
|
||||
## Components
|
||||
|
||||
### `OriginScene`
|
||||
|
||||
Renders every currently mounted scene node as a stable, absolutely positioned
|
||||
sibling.
|
||||
|
||||
| Prop | Type | Required | Description |
|
||||
| ------- | ------------- | -------- | -------------------------------------- |
|
||||
| `scene` | `OriginScene` | yes | Scene created by `createOriginScene()` |
|
||||
|
||||
The component provides node ownership to descendants, registers host elements
|
||||
for measurement, and applies the scene's composited styles. Application views
|
||||
must be rendered through this component before calling `useOrigin()` or
|
||||
`useOriginGesture()`.
|
||||
|
||||
### `OriginGesture`
|
||||
|
||||
Convenience component that renders one HTML element and binds one gesture
|
||||
recognizer to it.
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| --------- | ------------------------- | ------- | ---------------------------------- |
|
||||
| `as` | `string` | `"div"` | HTML tag used for the gesture host |
|
||||
| `gesture` | `OriginGestureDefinition` | — | Preferred builder definition |
|
||||
|
||||
Attributes, classes, and listeners not consumed as props are forwarded to the
|
||||
rendered host.
|
||||
|
||||
```vue
|
||||
<OriginGesture as="main" class="profile" :gesture="openDetailsGesture">
|
||||
...
|
||||
</OriginGesture>
|
||||
```
|
||||
|
||||
For compatibility, the component also accepts the legacy mutually exclusive
|
||||
set of `direction`, `edge`, `threshold`, and `action` props.
|
||||
|
||||
Use `useOriginGesture()` instead when an extra wrapper is undesirable.
|
||||
|
||||
### `OriginGestureSurface`
|
||||
|
||||
Policy-neutral host for multiple completed builder definitions:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const gestures = [forwardGesture, backGesture] as const;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OriginGestureSurface as="main" :gestures="gestures">
|
||||
...
|
||||
</OriginGestureSurface>
|
||||
</template>
|
||||
```
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ---------- | ------------------------------------ | -------- | ------------------------------ |
|
||||
| `as` | `string` | `"div"` | Shared native gesture host |
|
||||
| `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`:
|
||||
|
||||
- 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.
|
||||
|
||||
## Nested scenes
|
||||
|
||||
`OriginScene` is a reusable compositor, not an application-only singleton. A
|
||||
component may render another scene inside its own layout:
|
||||
|
||||
```vue
|
||||
<section class="carousel">
|
||||
<OriginScene :scene="carouselScene" />
|
||||
</section>
|
||||
```
|
||||
|
||||
The child scene gets independent mounted nodes, retained history, measurements,
|
||||
operations, and clipping. `useOrigin()` and `useOriginGesture()` resolve the
|
||||
nearest scene-node provider, so declarations inside a carousel slide operate
|
||||
on carousel components rather than the outer page.
|
||||
|
||||
Parent/child gesture arbitration is currently pointer-down based. An eligible
|
||||
child recognizer stops propagation immediately. If its later `.navigate()`
|
||||
factory returns `null`, that same pointer sequence is not offered to the
|
||||
parent. Cooperative nested components should reserve a start region with
|
||||
`.from.when()` or `.from.left()` that allows the parent handler to receive
|
||||
pointer-down.
|
||||
|
||||
The nested-scenes demo contains both this cooperative policy and an intentional
|
||||
greedy-child conflict. A future gesture arena could delay ownership until
|
||||
direction and navigation availability are known.
|
||||
|
||||
## Scene and view functions
|
||||
|
||||
### `originView(component, props?, options?)`
|
||||
|
||||
Creates a lightweight recipe for mounting a Vue component.
|
||||
|
||||
```ts
|
||||
const profile = originView(
|
||||
ProfileView,
|
||||
{ userId: "42" },
|
||||
{ key: "profile-42", name: "Profile" },
|
||||
);
|
||||
```
|
||||
|
||||
The recipe is not itself a mounted instance. A forward action creates an
|
||||
instance from it, then retains that exact instance while its entry remains in
|
||||
history. Back does not call the recipe again. Component definitions are marked
|
||||
raw so Vue does not proxy them inside reactive scene structures.
|
||||
|
||||
`OriginViewOptions`:
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------ | -------- | --------------------------------------------- |
|
||||
| `key` | `string` | Recipe identity and generated node-key prefix |
|
||||
| `name` | `string` | Human-readable diagnostic label |
|
||||
|
||||
When no key is supplied, one is generated from the component/name and a
|
||||
sequence number.
|
||||
|
||||
### `createOriginScene(options)`
|
||||
|
||||
Creates one independent scene graph, history context, and compositor.
|
||||
|
||||
```ts
|
||||
const scene = createOriginScene({
|
||||
initial: originView(HomeView),
|
||||
});
|
||||
```
|
||||
|
||||
`options.initial` accepts one `OriginView` or an array of independent root
|
||||
views. Scenes do not share nodes, history, operation IDs, or measurements.
|
||||
|
||||
## Actions and history
|
||||
|
||||
### `forward(target, choreography?, options?)`
|
||||
|
||||
With choreography, creates a complete retained-history push action:
|
||||
|
||||
```ts
|
||||
const openProfile = () =>
|
||||
forward(originView(ProfileView, { userId: "42" }), slideLeft);
|
||||
```
|
||||
|
||||
Without choreography, it creates an `OriginNavigationIntent` for a gesture
|
||||
builder:
|
||||
|
||||
```ts
|
||||
.navigate(() => forward(originView(ProfileView, { userId: "42" })))
|
||||
```
|
||||
|
||||
When forward commits, the origin remains mounted but becomes parked. Its DOM,
|
||||
component-local state, and nested scroll positions remain intact.
|
||||
|
||||
`OriginNavigationActionOptions.placement` defaults to `"above"`.
|
||||
|
||||
### `back(choreography?, options?)`
|
||||
|
||||
Creates a retained-history pop action:
|
||||
|
||||
```ts
|
||||
const goBack = (context: OriginContext) =>
|
||||
context.canGoBack ? back(slideRight) : null;
|
||||
```
|
||||
|
||||
Back has no target recipe. When it begins, the scene resolves the origin's
|
||||
`previousNodeKey` and reveals that exact mounted instance. A committed back
|
||||
unmounts only the current entry. A cancelled back hides the previous entry
|
||||
again and leaves the current entry active.
|
||||
|
||||
`back()` without choreography returns an animation-free navigation intent for
|
||||
`.navigate()`. `back(slideRight)` returns a complete programmatic action.
|
||||
|
||||
`OriginNavigationActionOptions.placement` defaults to `"under"`.
|
||||
|
||||
### `originAction(target, choreography, options?)`
|
||||
|
||||
Constructs a complete low-level `OriginAction`. Prefer `forward()` and `back()`
|
||||
when expressing retained navigation.
|
||||
|
||||
```ts
|
||||
const action = originAction(profile, slideLeft, {
|
||||
placement: "above",
|
||||
history: "push",
|
||||
});
|
||||
```
|
||||
|
||||
`OriginActionOptions`:
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
| ----------- | -------------------- | --------- | ---------------------------- |
|
||||
| `placement` | `"above" \| "under"` | `"above"` | Target stacking relationship |
|
||||
| `history` | `OriginHistoryMode` | `"push"` | Target history mutation |
|
||||
|
||||
### `above(target, choreography?, options?)`
|
||||
|
||||
Shorthand for `originAction()` with `placement: "above"`.
|
||||
|
||||
```ts
|
||||
const openProfile = () => above(originView(ProfileView), slideLeft);
|
||||
```
|
||||
|
||||
Placement controls stacking only. It does not imply a movement direction.
|
||||
Omitting choreography returns a navigation intent for a gesture builder.
|
||||
|
||||
### `under(target, choreography?, options?)`
|
||||
|
||||
Shorthand for `originAction()` with `placement: "under"`.
|
||||
|
||||
```ts
|
||||
const goBack = (context: OriginContext) =>
|
||||
context.previous ? back(slideRight) : null;
|
||||
```
|
||||
|
||||
`under()` does not automatically mean history back. It remains available for
|
||||
custom stacking actions; `back()` is the clearer retained-history primitive.
|
||||
Omitting choreography returns a navigation intent for a gesture builder.
|
||||
|
||||
### History modes
|
||||
|
||||
History is a linked chain of mounted scene nodes.
|
||||
|
||||
| Mode | Commit behavior |
|
||||
| -------- | -------------------------------------------------------------- |
|
||||
| `"push"` | Park and retain the origin; activate the new target |
|
||||
| `"back"` | Reuse the retained previous target; pop and unmount the origin |
|
||||
|
||||
Parked entries are `inert`, `aria-hidden`, invisible, and excluded from pointer
|
||||
input. They remain mounted until back pops them or the scene is destroyed.
|
||||
|
||||
## Gestures
|
||||
|
||||
### `gesture`
|
||||
|
||||
Immutable fluent builder for component-owned gesture policy:
|
||||
|
||||
```ts
|
||||
const swipeBack = gesture.from
|
||||
.left(32)
|
||||
.to.right({ threshold: 10 })
|
||||
.complete(({ progress, velocity }) => progress >= 0.4 || velocity >= 0.9)
|
||||
.navigate((context) => (context.canGoBack ? back() : null))
|
||||
.animate(slideRight);
|
||||
```
|
||||
|
||||
The stages have distinct responsibilities:
|
||||
|
||||
| Stage | Responsibility |
|
||||
| ---------------------- | ---------------------------------------------------------- |
|
||||
| `.from.*` | Optional pointer-down eligibility |
|
||||
| `.to.*` | Required movement direction and intent-recognition options |
|
||||
| `.complete(predicate)` | Optional release commit/cancel decision |
|
||||
| `.navigate(factory)` | Required target and retained-history intent |
|
||||
| `.animate(routine)` | Required source/target/frame choreography |
|
||||
|
||||
The builder is persistent and immutable. Reusing an earlier stage cannot
|
||||
change a definition already produced from it.
|
||||
|
||||
`.to.left()`, `.to.right()`, `.to.up()`, and `.to.down()` accept optional
|
||||
`OriginGestureDirectionOptions`:
|
||||
|
||||
| Field | Default | Description |
|
||||
| --------------- | ------- | ------------------------------------------------- |
|
||||
| `threshold` | `8` | Directed CSS pixels required before capture |
|
||||
| `axisDominance` | `1.15` | Directed/cross-axis ratio required before capture |
|
||||
|
||||
If `.complete()` is omitted, the choreography's `commitThreshold` and
|
||||
`commitVelocity` decide release normally.
|
||||
|
||||
The completion context contains the origin, direction, normalized progress and
|
||||
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.
|
||||
|
||||
### `useOriginGesture(definition)`
|
||||
|
||||
Creates one primary-pointer, single-axis recognizer owned by the component that
|
||||
calls it.
|
||||
|
||||
```ts
|
||||
const open = useOriginGesture(
|
||||
gesture.to
|
||||
.left({ threshold: 10 })
|
||||
.navigate(() => forward(originView(DetailsView)))
|
||||
.animate(slideLeft),
|
||||
);
|
||||
```
|
||||
|
||||
The return value contains:
|
||||
|
||||
```ts
|
||||
interface OriginGestureBinding {
|
||||
readonly style: Readonly<CSSProperties>;
|
||||
readonly onPointerdown: (event: PointerEvent) => void;
|
||||
readonly onPointermove: (event: PointerEvent) => void;
|
||||
readonly onPointerup: (event: PointerEvent) => void;
|
||||
readonly onPointercancel: () => 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.
|
||||
|
||||
Recognition requires:
|
||||
|
||||
1. A primary, left-button pointer satisfies the optional start policy.
|
||||
2. The target is not an ignored interactive element.
|
||||
3. Directed movement reaches `threshold`.
|
||||
4. Directed movement exceeds cross-axis movement by `axisDominance`.
|
||||
5. The navigation factory returns an intent.
|
||||
|
||||
Progress is directed distance divided by host width or height. Release velocity
|
||||
is normalized by the same dimension.
|
||||
|
||||
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.
|
||||
|
||||
## Choreographies and effects
|
||||
|
||||
### `defineOriginChoreography(choreography)`
|
||||
|
||||
Type-safe identity helper for declaring custom visual routines.
|
||||
|
||||
```ts
|
||||
const scaleIn = defineOriginChoreography({
|
||||
name: "scale-in",
|
||||
commitThreshold: 0.4,
|
||||
commitVelocity: 0.8,
|
||||
effects: ({ progress, viewport }) => ({
|
||||
source: {
|
||||
transform: `scale(${1 - progress * 0.08})`,
|
||||
opacity: 1 - progress * 0.3,
|
||||
},
|
||||
target: {
|
||||
transform: `translateY(${(1 - progress) * viewport.height}px)`,
|
||||
},
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
The function returns the same object. Its value is type checking and a clear
|
||||
construction point.
|
||||
|
||||
`effects()` may return:
|
||||
|
||||
| Effect | Applied to |
|
||||
| -------- | ---------------------------------------------------------- |
|
||||
| `frame` | Source, target, and descendants on both sides of this edge |
|
||||
| `source` | Component that originated this operation |
|
||||
| `target` | Component created by this operation and its descendants |
|
||||
|
||||
Transforms are concatenated from inherited frames to local frames. Opacity is
|
||||
multiplied. Properties inside `style` use local-last precedence, except
|
||||
`transform` and numeric `opacity`, which are also composed.
|
||||
|
||||
Choreography callbacks should be deterministic and free of side effects. They
|
||||
can run repeatedly during rendering and animation.
|
||||
|
||||
### Commit thresholds
|
||||
|
||||
When `finish()` does not explicitly override the decision, a target commits
|
||||
when either:
|
||||
|
||||
- `progress >= commitThreshold`, default `0.36`; or
|
||||
- `progress >= 0.06` and `velocity >= commitVelocity`, default `0.9`.
|
||||
|
||||
The operation's intent becomes final before its spring settles.
|
||||
|
||||
### Included presets
|
||||
|
||||
| Export | Behavior |
|
||||
| ------------ | ------------------------------------------------------------- |
|
||||
| `slideLeft` | Target enters from the right above a slightly receding source |
|
||||
| `slideRight` | Source exits right and reveals a target underneath |
|
||||
| `fade` | Source fades out as target fades in |
|
||||
|
||||
These are ordinary `OriginChoreography` objects and can be replaced entirely.
|
||||
|
||||
### `normalizedEffect(effect, fallbackLayer?)`
|
||||
|
||||
Internal compositor helper exposed for custom diagnostics or compositors. It
|
||||
returns a defined effect and adds `fallbackLayer` to the effect's own layer.
|
||||
Applications normally return plain effects and let the scene normalize them.
|
||||
|
||||
## Node-scoped controls
|
||||
|
||||
### `useOrigin()`
|
||||
|
||||
Returns controls scoped to the scene node containing the calling component.
|
||||
|
||||
```ts
|
||||
const origin = useOrigin();
|
||||
|
||||
await origin.perform(forward(originView(SettingsView), fade));
|
||||
```
|
||||
|
||||
Return value:
|
||||
|
||||
| Field | Description |
|
||||
| ----------------- | --------------------------------------------------- |
|
||||
| `nodeKey` | Unique key of this mounted node |
|
||||
| `scene` | Containing `OriginScene` |
|
||||
| `context` | Reactive node-local `OriginContext` |
|
||||
| `view` | Reactive shorthand for the current recipe |
|
||||
| `previous` | Recipe belonging to the retained previous instance |
|
||||
| `canGoBack` | Whether a retained previous instance exists |
|
||||
| `begin(action)` | Create a target and return manual operation control |
|
||||
| `perform(action)` | Create and programmatically commit a target |
|
||||
|
||||
The composable throws when called outside a view mounted by `OriginScene`.
|
||||
There is no global `activeView`; the injected node containing the event is the
|
||||
origin.
|
||||
|
||||
## Scene diagnostics and manual operations
|
||||
|
||||
### `OriginScene` fields
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------ | ----------------------------------------- | ------------------------------------- |
|
||||
| `nodes` | `ComputedRef<readonly OriginSceneNode[]>` | Currently mounted Vue component nodes |
|
||||
| `operations` | `ComputedRef<readonly OriginOperation[]>` | Live operation edges |
|
||||
| `roots` | `ShallowRef<readonly string[]>` | Visible operation-graph roots |
|
||||
|
||||
These fields are suitable for inspectors and diagnostics. Do not mutate their
|
||||
contents.
|
||||
|
||||
### `scene.contextFor(nodeKey)`
|
||||
|
||||
Returns the `OriginContext` for a mounted node. Throws if the key no longer
|
||||
exists.
|
||||
|
||||
### `scene.begin(originKey, action)`
|
||||
|
||||
For forward, mounts a new target and waits one Vue tick for measurement. For
|
||||
back, reveals and measures the retained previous node. It then returns an
|
||||
`OriginOperationHandle`.
|
||||
|
||||
```ts
|
||||
const handle = await scene.begin(nodeKey, action);
|
||||
handle.update(0.25, 0.4);
|
||||
const committed = await handle.finish();
|
||||
```
|
||||
|
||||
Only one outgoing operation may exist for a given origin. Its created target
|
||||
can immediately begin its own outgoing operation, enabling X → Y → Z chains.
|
||||
|
||||
### `OriginOperationHandle`
|
||||
|
||||
| Member | Description |
|
||||
| ----------------------------- | --------------------------------------------------- |
|
||||
| `id` | Unique operation ID |
|
||||
| `originKey` | Source node key |
|
||||
| `targetKey` | Created or retained target node key |
|
||||
| `update(progress, velocity?)` | Update normalized interactive state |
|
||||
| `finish(options?)` | Decide, settle, and return whether target committed |
|
||||
| `cancel(options?)` | Force cancellation and remove the target branch |
|
||||
|
||||
`OriginFinishOptions`:
|
||||
|
||||
| Field | Default | Description |
|
||||
| --------- | ------------------ | ---------------------------- |
|
||||
| `commit` | threshold decision | Force commit or cancellation |
|
||||
| `animate` | `true` | Run the settling spring |
|
||||
|
||||
### `scene.perform(originKey, action)`
|
||||
|
||||
Equivalent to beginning an operation and immediately finishing it with
|
||||
`commit: true`. The target still uses the settling spring unless reduced motion
|
||||
is active.
|
||||
|
||||
### Renderer integration methods
|
||||
|
||||
`registerElement()`, `registerContainer()`, `styleForNode()`, and
|
||||
`isNodeInteractive()` are public at the TypeScript boundary because the Vue
|
||||
renderer components consume them. They are internal integration APIs and may
|
||||
change during the experimental series.
|
||||
|
||||
## Type reference
|
||||
|
||||
### `OriginView<Props>`
|
||||
|
||||
A component recipe containing `component`, optional `props`, optional `key`,
|
||||
and optional diagnostic `name`.
|
||||
|
||||
### `OriginAction`
|
||||
|
||||
A choreography, placement, history mode, and—except for back—target recipe.
|
||||
|
||||
### `OriginNavigationIntent`
|
||||
|
||||
An animation-free target, placement, and history mutation returned by
|
||||
`forward()`, `back()`, `above()`, or `under()` when choreography is omitted.
|
||||
Gesture `.animate()` combines it with choreography to create the internal
|
||||
action.
|
||||
|
||||
### Gesture definition types
|
||||
|
||||
- `OriginGestureDefinition`: immutable executable result passed to
|
||||
`useOriginGesture()` or the `OriginGesture` component.
|
||||
- `OriginGestureStart`: anywhere, edge, or predicate start policy.
|
||||
- `OriginGestureDistance`: numeric CSS pixels or a CSS length string.
|
||||
- `OriginGestureStartContext`: pointer-down event, origin, host, bounds, and
|
||||
local/client point.
|
||||
- `OriginGestureCompletionContext`: release metrics and origin/DOM context.
|
||||
- `OriginGestureDirectionOptions`: `threshold` and `axisDominance`.
|
||||
- `OriginGestureBinding`: host style and four pointer handlers.
|
||||
- `OriginGestureSurfaceProps`: shared host tag and completed definition list.
|
||||
- `MaybeOriginNavigationIntent`: synchronous or asynchronous nullable
|
||||
navigation-factory result.
|
||||
|
||||
### `OriginContext`
|
||||
|
||||
Node-local action context:
|
||||
|
||||
- `nodeKey`: mounted origin identity.
|
||||
- `view`: origin recipe.
|
||||
- `canGoBack`: whether a retained previous instance exists.
|
||||
- `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 |
|
||||
| ----------- | ----------------------------------------------- |
|
||||
| `transform` | Composable CSS transform contribution |
|
||||
| `opacity` | Multiplicative opacity contribution |
|
||||
| `layer` | Relative stacking contribution |
|
||||
| `style` | Other CSS properties with local-last precedence |
|
||||
|
||||
`above()` adds a default target layer of `+1`; `under()` adds `-1`.
|
||||
|
||||
### `OriginChoreographyContext`
|
||||
|
||||
| Field | Description |
|
||||
| ------------ | ------------------------------------------------------ |
|
||||
| `progress` | Normalized `0..1` progress |
|
||||
| `velocity` | Normalized progress units per second |
|
||||
| `phase` | `preparing`, `interactive`, `settling`, or `finished` |
|
||||
| `intent` | `undecided`, `commit`, or `cancel` |
|
||||
| `originRect` | Origin bounds captured before target mounting |
|
||||
| `targetRect` | Target bounds measured after mounting |
|
||||
| `viewport` | Scene-container bounds, with browser viewport fallback |
|
||||
|
||||
Rect values are viewport CSS pixels.
|
||||
|
||||
### Diagnostic types
|
||||
|
||||
- `OriginSceneNode`: mounted identity, retained previous key, state, recipe,
|
||||
history, and incoming edge.
|
||||
- `OriginSceneNodeState`: `active`, `transitioning`, or `parked`.
|
||||
- `OriginOperation`: read-only live edge state.
|
||||
- `OriginOperationPhase`: operation lifecycle phase.
|
||||
- `OriginOperationIntent`: selected operation outcome.
|
||||
- `OriginRect`: top, left, width, and height.
|
||||
|
||||
### Internal types
|
||||
|
||||
`OriginNodeScope` and `MutableOriginOperation` are renderer/runtime
|
||||
implementation types. They are exported by the current barrel but marked
|
||||
`@internal` and should not be application dependencies.
|
||||
|
||||
## Errors and constraints
|
||||
|
||||
- `useOrigin()` and `useOriginGesture()` must run inside a component mounted by
|
||||
`OriginScene`.
|
||||
- An origin can own only one outgoing operation at a time.
|
||||
- Parked history entries cannot originate operations until back reveals them.
|
||||
- A target can originate its own operation as soon as its incoming operation's
|
||||
intent becomes commit.
|
||||
- The included recognizer follows one primary pointer and one axis.
|
||||
- Builder edges accept CSS lengths; arbitrary start policy belongs in
|
||||
`.from.when()`.
|
||||
- Every pushed history entry retains its Vue instance and DOM until a committed
|
||||
back operation pops it. There is no eviction policy yet.
|
||||
- Parked instances remain mounted, so their ordinary Vue effects and timers
|
||||
continue running.
|
||||
- A choreography creates one target. Chaining supports any number of
|
||||
simultaneously mounted targets.
|
||||
- Reduced-motion preference resolves settling immediately.
|
||||
234
packages/core-v2/README.md
Normal file
234
packages/core-v2/README.md
Normal file
@@ -0,0 +1,234 @@
|
||||
# Core v2: routeless origins
|
||||
|
||||
`@native-vue-router/core-v2` is an experimental, Vue-only scene compositor. It
|
||||
does not install Vue Router, resolve URLs, select a globally active route, or
|
||||
render through `RouterView`.
|
||||
|
||||
The complete function, component, option, type, gesture-edge, and choreography
|
||||
reference is in [API.md](./API.md).
|
||||
|
||||
The primitive is:
|
||||
|
||||
> A mounted component can originate a routine that creates another component,
|
||||
> moves both components relative to the origin's coordinate frame, and retains
|
||||
> the previous instance until a committed back operation pops the newer entry.
|
||||
|
||||
## Run the experiment
|
||||
|
||||
From the workspace root:
|
||||
|
||||
```sh
|
||||
npm run dev:v2
|
||||
```
|
||||
|
||||
Open the printed URL to explore seven physical labs:
|
||||
|
||||
- a four-view chain that can keep four nodes and three edges live at once;
|
||||
- one view with horizontal, vertical, and edge-only declarations;
|
||||
- programmatic gallery navigation followed by gesture-owned traversal;
|
||||
- a vertically presented media player with local interactive state;
|
||||
- a chat that intentionally declares no back gesture;
|
||||
- a predicate-gated downward gesture that drops a left-edge dialog.
|
||||
- three nested scenes demonstrating cooperative carousels, vertical decks, and
|
||||
an intentional parent/child gesture conflict.
|
||||
|
||||
The expandable inspector reports mounted Vue instances, active operation
|
||||
edges, animation progress, and recent lifecycle events. In the chain lab,
|
||||
swipe rapidly through X → Y → Z → Ω to see all four components mounted while
|
||||
their independent frames are still moving.
|
||||
|
||||
An installable, offline-capable PWA build of the same experiment is hosted at
|
||||
<https://v2.demo.native-router.harvmaster.com/>.
|
||||
|
||||
## Basic usage
|
||||
|
||||
Create a scene with a component recipe:
|
||||
|
||||
```ts
|
||||
import { createOriginScene, originView } from "@native-vue-router/core-v2";
|
||||
import "@native-vue-router/core-v2/style.css";
|
||||
import HomeView from "./HomeView.vue";
|
||||
|
||||
export const scene = createOriginScene({
|
||||
initial: originView(HomeView, { accountId: "42" }, { key: "home" }),
|
||||
});
|
||||
```
|
||||
|
||||
Render it:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { OriginScene } from "@native-vue-router/core-v2";
|
||||
import { scene } from "./scene";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OriginScene :scene="scene" />
|
||||
</template>
|
||||
```
|
||||
|
||||
Declare an interaction inside the component that should originate it:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
OriginGesture,
|
||||
forward,
|
||||
gesture,
|
||||
originView,
|
||||
slideLeft,
|
||||
} from "@native-vue-router/core-v2";
|
||||
import ProfileView from "./ProfileView.vue";
|
||||
|
||||
const openProfile = gesture.to
|
||||
.left()
|
||||
.navigate(() =>
|
||||
forward(originView(ProfileView, { userId: "7" }, { key: "profile-7" })),
|
||||
)
|
||||
.animate(slideLeft);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OriginGesture :gesture="openProfile">
|
||||
<main>Swipe this component left</main>
|
||||
</OriginGesture>
|
||||
</template>
|
||||
```
|
||||
|
||||
Starting directly at `.to.left()` means pointer-down may occur anywhere on the
|
||||
host. Add `.from.left("clamp(24px, 8%, 64px)")` before `.to.right()` for a
|
||||
conventional proportional back edge, or `.from.when(context => ...)` for
|
||||
arbitrary start policy. `.complete()` can override the choreography's release
|
||||
thresholds.
|
||||
|
||||
There is no global navigation declaration. If this component should not
|
||||
support that gesture, it simply does not render `OriginGesture`.
|
||||
|
||||
For an existing element where an additional wrapper is undesirable, use
|
||||
`useOriginGesture()` and attach its four pointer handlers directly.
|
||||
|
||||
For several gestures on one page surface, keep the definitions in the page and
|
||||
pass them to the policy-neutral host:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { OriginGestureSurface } from "@native-vue-router/core-v2";
|
||||
|
||||
const gestures = [forwardGesture, backGesture] as const;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OriginGestureSurface as="main" :gestures="gestures">
|
||||
...
|
||||
</OriginGestureSurface>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Going backward
|
||||
|
||||
Every pushed history entry remains mounted. Back resolves the already-mounted
|
||||
previous node and pops only the current entry after the operation commits:
|
||||
|
||||
```ts
|
||||
import {
|
||||
back,
|
||||
gesture,
|
||||
slideRight,
|
||||
useOriginGesture,
|
||||
} from "@native-vue-router/core-v2";
|
||||
|
||||
const goBack = useOriginGesture(
|
||||
gesture.from
|
||||
.left("max(24px, 6%)")
|
||||
.to.right()
|
||||
.navigate((context) => (context.canGoBack ? back() : null))
|
||||
.animate(slideRight),
|
||||
);
|
||||
```
|
||||
|
||||
Parked entries are visually hidden, inert, and removed from pointer and
|
||||
accessibility interaction. Their Vue instances and DOM remain mounted, so
|
||||
component-local state and nested element scroll positions are preserved
|
||||
naturally. A cancelled back re-parks the previous target; a committed back
|
||||
unmounts the entry being left.
|
||||
|
||||
The application chooses whether this is exposed as a left-edge gesture,
|
||||
toolbar button, keyboard shortcut, Android hardware-back action, or not exposed
|
||||
at all.
|
||||
|
||||
## Custom choreography
|
||||
|
||||
A choreography returns independent effects for its source, target, and their
|
||||
shared frame:
|
||||
|
||||
```ts
|
||||
import { defineOriginChoreography } from "@native-vue-router/core-v2";
|
||||
|
||||
export const zoomFromCard = defineOriginChoreography({
|
||||
name: "zoom-from-card",
|
||||
commitThreshold: 0.42,
|
||||
effects: ({ progress, originRect, viewport }) => ({
|
||||
source: {
|
||||
transform: `scale(${1 - progress * 0.08})`,
|
||||
opacity: 1 - progress * 0.4,
|
||||
},
|
||||
target: {
|
||||
transform: `translateY(${(1 - progress) * viewport.height}px)`,
|
||||
style: {
|
||||
borderRadius: `${(1 - progress) * 24}px`,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
`originRect`, `targetRect`, and the scene viewport are measured after the
|
||||
target mounts. The gesture may update progress interactively or a normal click
|
||||
can call `useOrigin().perform(action)`.
|
||||
|
||||
Transforms are concatenated from the oldest origin frame to the newest local
|
||||
effect. Opacity is multiplied. Other properties in `style` use local-last
|
||||
precedence. Consequently, if X→Y and Y→Z overlap:
|
||||
|
||||
```text
|
||||
Y transform = (X→Y target) × (Y→Z source)
|
||||
Z transform = (X→Y target) × (Y→Z target)
|
||||
```
|
||||
|
||||
## Why scene nodes are flat
|
||||
|
||||
The operation graph is not represented as Vue component ancestry. Every
|
||||
component has one stable, keyed host directly under `OriginScene`.
|
||||
|
||||
If Y were physically moved from an X→Y wrapper to the scene root when an edge
|
||||
collapsed, Vue would unmount and recreate Y. Instead, v2 rewrites graph edges
|
||||
and recalculates Y's effect layers while every retained VNode stays in the same
|
||||
flat host.
|
||||
|
||||
“Y is Z's origin” is a coordinate and retained-history relationship, not Vue
|
||||
component ancestry.
|
||||
|
||||
## Current experimental boundaries
|
||||
|
||||
- One operation creates one target. Chaining operations already permits any
|
||||
number of simultaneous scene nodes; multi-target routines are not yet
|
||||
exposed as a public builder.
|
||||
- A node can originate one outgoing operation at a time. Its created target
|
||||
may immediately originate the next operation.
|
||||
- The included pointer recognizer handles one primary pointer and one axis.
|
||||
Choreographies and scene operations are independent of it.
|
||||
- Multiple recognizers can share a host and arbitrate by start policy and
|
||||
directional intent. A dedicated multi-pointer gesture arena is not exposed.
|
||||
- Nested `OriginScene` components have independent history and measurements.
|
||||
Child recognizers currently claim propagation at pointer-down, so yielding a
|
||||
region to a parent requires an explicit `.from` policy.
|
||||
- Every pushed history entry remains mounted until back pops it. There is not
|
||||
yet an eviction policy, so applications should deliberately reset long-lived
|
||||
navigation contexts when that API is introduced.
|
||||
- Parked instances remain mounted and ordinary Vue timers/watchers continue to
|
||||
run. Engine-specific park/resume lifecycle hooks are not exposed yet.
|
||||
- Arbitrary CSS properties can be used, but only transforms and opacity have
|
||||
defined multi-operation composition rules at present.
|
||||
|
||||
These boundaries are explicit so the experiment can validate the origin
|
||||
primitive before compatibility conveniences become permanent architecture.
|
||||
27
packages/core-v2/package.json
Normal file
27
packages/core-v2/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@native-vue-router/core-v2",
|
||||
"version": "0.1.0-experimental.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"files": [
|
||||
"dist",
|
||||
"API.md",
|
||||
"README.md"
|
||||
],
|
||||
"sideEffects": [
|
||||
"./dist/style.css"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "vite build --config vite.config.ts && vue-tsc -p tsconfig.json --emitDeclarationOnly"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.0"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./style.css": "./dist/style.css"
|
||||
}
|
||||
}
|
||||
41
packages/core-v2/src/components/OriginGesture.vue
Normal file
41
packages/core-v2/src/components/OriginGesture.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useOriginGesture } from "../gesture";
|
||||
import type { OriginGestureProps } from "../types";
|
||||
|
||||
defineOptions({ name: "OriginGesture", inheritAttrs: false });
|
||||
|
||||
const props = withDefaults(defineProps<OriginGestureProps>(), {
|
||||
as: "div",
|
||||
});
|
||||
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
const gesture = useOriginGesture(
|
||||
props.gesture ?? {
|
||||
direction: props.direction,
|
||||
edge: props.edge,
|
||||
threshold: props.threshold,
|
||||
action: (context) => props.action?.(context),
|
||||
},
|
||||
);
|
||||
const touchStyle = computed(() => gesture.style);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="as"
|
||||
v-bind="$attrs"
|
||||
class="nvo-gesture"
|
||||
:style="touchStyle"
|
||||
@pointerdown="gesture.onPointerdown"
|
||||
@pointermove="gesture.onPointermove"
|
||||
@pointerup="gesture.onPointerup"
|
||||
@pointercancel="gesture.onPointercancel"
|
||||
>
|
||||
<slot />
|
||||
</component>
|
||||
</template>
|
||||
73
packages/core-v2/src/components/OriginGestureSurface.vue
Normal file
73
packages/core-v2/src/components/OriginGestureSurface.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useOriginGesture } from "../gesture";
|
||||
import type { OriginGestureSurfaceProps } from "../types";
|
||||
|
||||
defineOptions({ name: "OriginGestureSurface", inheritAttrs: false });
|
||||
|
||||
const props = withDefaults(defineProps<OriginGestureSurfaceProps>(), {
|
||||
as: "div",
|
||||
});
|
||||
|
||||
/*
|
||||
* All interaction policy belongs to the component that built the definitions.
|
||||
* 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();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="as"
|
||||
v-bind="$attrs"
|
||||
class="nvo-gesture"
|
||||
:style="[$attrs.style, surfaceStyle]"
|
||||
@pointerdown="pointerDown"
|
||||
@pointermove="pointerMove"
|
||||
@pointerup="pointerUp"
|
||||
@pointercancel="pointerCancel"
|
||||
>
|
||||
<slot />
|
||||
</component>
|
||||
</template>
|
||||
52
packages/core-v2/src/components/OriginNodeHost.vue
Normal file
52
packages/core-v2/src/components/OriginNodeHost.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, provide, ref, watchEffect } from "vue";
|
||||
import type { OriginScene, OriginSceneNode } from "../types";
|
||||
import { originNodeScopeKey } from "../lifecycle";
|
||||
|
||||
defineOptions({ name: "OriginNodeHost" });
|
||||
|
||||
const props = defineProps<{
|
||||
scene: OriginScene;
|
||||
node: OriginSceneNode;
|
||||
}>();
|
||||
|
||||
/*
|
||||
* This host is the stable physical home of the view component. It is keyed by
|
||||
* the scene node in OriginScene and never nested under another view. Only its
|
||||
* composed CSS style changes while operation edges are created and collapsed.
|
||||
*/
|
||||
const host = ref<HTMLElement | null>(null);
|
||||
provide(originNodeScopeKey, {
|
||||
scene: props.scene,
|
||||
nodeKey: props.node.key,
|
||||
});
|
||||
|
||||
watchEffect(() => {
|
||||
props.scene.registerElement(props.node.key, host.value);
|
||||
});
|
||||
onBeforeUnmount(() => props.scene.registerElement(props.node.key, null));
|
||||
|
||||
const style = computed(() => props.scene.styleForNode(props.node.key));
|
||||
const interactive = computed(() =>
|
||||
props.scene.isNodeInteractive(props.node.key),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
ref="host"
|
||||
class="nvo-node"
|
||||
:style="style"
|
||||
:data-origin-node="node.key"
|
||||
:data-origin-view="node.view.name ?? node.view.key"
|
||||
:data-origin-state="node.state"
|
||||
:aria-hidden="interactive ? undefined : 'true'"
|
||||
:inert="interactive ? undefined : true"
|
||||
>
|
||||
<!--
|
||||
Vue owns the component lifecycle normally. Adding another origin merely
|
||||
adds effect layers to this host; it does not replace this component VNode.
|
||||
-->
|
||||
<component :is="node.view.component" v-bind="node.view.props" />
|
||||
</section>
|
||||
</template>
|
||||
38
packages/core-v2/src/components/OriginScene.vue
Normal file
38
packages/core-v2/src/components/OriginScene.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, ref, watchEffect } from "vue";
|
||||
import type { OriginSceneProps } from "../types";
|
||||
import OriginNodeHost from "./OriginNodeHost.vue";
|
||||
|
||||
defineOptions({ name: "OriginScene" });
|
||||
|
||||
const props = defineProps<OriginSceneProps>();
|
||||
|
||||
const root = ref<HTMLElement | null>(null);
|
||||
watchEffect(() => props.scene.registerContainer(root.value));
|
||||
onBeforeUnmount(() => props.scene.registerContainer(null));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!--
|
||||
All component hosts are siblings. The operation graph is intentionally not
|
||||
mirrored as DOM ancestry because promoting Y after X→Y must not remount Y.
|
||||
-->
|
||||
<main
|
||||
ref="root"
|
||||
class="nvo-scene"
|
||||
:style="{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
isolation: 'isolate',
|
||||
}"
|
||||
>
|
||||
<OriginNodeHost
|
||||
v-for="node in scene.nodes.value"
|
||||
:key="node.key"
|
||||
:scene="scene"
|
||||
:node="node"
|
||||
/>
|
||||
</main>
|
||||
</template>
|
||||
248
packages/core-v2/src/gesture.test.ts
Normal file
248
packages/core-v2/src/gesture.test.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
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 } from "./motion";
|
||||
import { createOriginScene, originView } from "./scene";
|
||||
import type {
|
||||
OriginGestureBinding,
|
||||
OriginGestureCompletionContext,
|
||||
} from "./types";
|
||||
|
||||
const mountedApps: Array<ReturnType<typeof createApp>> = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const app of mountedApps.splice(0)) app.unmount();
|
||||
document.body.innerHTML = "";
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const testMotion = defineOriginChoreography({
|
||||
name: "gesture-test",
|
||||
effects: ({ progress }) => ({
|
||||
source: { opacity: 1 - progress },
|
||||
target: { opacity: progress },
|
||||
}),
|
||||
});
|
||||
|
||||
function component(name: string): Component {
|
||||
return defineComponent({
|
||||
name,
|
||||
render: () => h("div", name),
|
||||
});
|
||||
}
|
||||
|
||||
function pointer(
|
||||
type: string,
|
||||
init: Pick<PointerEventInit, "clientX" | "clientY">,
|
||||
) {
|
||||
return new PointerEvent(type, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button: 0,
|
||||
isPrimary: true,
|
||||
pointerId: 7,
|
||||
...init,
|
||||
});
|
||||
}
|
||||
|
||||
async function flushAsyncHandlers() {
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
describe("gesture builder", () => {
|
||||
it("installs multiple page-owned definitions on a policy-neutral surface", async () => {
|
||||
const horizontal = gesture.to
|
||||
.left()
|
||||
.navigate(() => null)
|
||||
.animate(testMotion);
|
||||
const vertical = gesture.from
|
||||
.top("12%")
|
||||
.to.down()
|
||||
.navigate(() => null)
|
||||
.animate(testMotion);
|
||||
const Initial = defineComponent({
|
||||
name: "SurfaceInitial",
|
||||
render: () =>
|
||||
h(
|
||||
OriginGestureSurface,
|
||||
{
|
||||
as: "section",
|
||||
id: "multi-gesture-surface",
|
||||
gestures: [horizontal, vertical],
|
||||
},
|
||||
() => "surface",
|
||||
),
|
||||
});
|
||||
const scene = createOriginScene({
|
||||
initial: originView(Initial, undefined, { key: "surface-initial" }),
|
||||
});
|
||||
const root = document.createElement("div");
|
||||
document.body.append(root);
|
||||
const app = createApp({ render: () => h(OriginScene, { scene }) });
|
||||
mountedApps.push(app);
|
||||
app.mount(root);
|
||||
await nextTick();
|
||||
|
||||
const surface = root.querySelector("#multi-gesture-surface") as HTMLElement;
|
||||
expect(surface.tagName).toBe("SECTION");
|
||||
expect(surface.classList.contains("nvo-gesture")).toBe(true);
|
||||
expect(surface.style.touchAction).toBe("none");
|
||||
expect(surface.style.width).toBe("100%");
|
||||
expect(surface.style.height).toBe("100%");
|
||||
});
|
||||
|
||||
it("keeps builder navigation intents separate from complete actions", () => {
|
||||
const target = originView(component("Target"));
|
||||
|
||||
expect(forward(target)).toEqual({
|
||||
target,
|
||||
placement: "above",
|
||||
history: "push",
|
||||
});
|
||||
expect(back()).toEqual({
|
||||
placement: "under",
|
||||
history: "back",
|
||||
});
|
||||
expect(forward(target, testMotion)).toMatchObject({
|
||||
target,
|
||||
choreography: testMotion,
|
||||
history: "push",
|
||||
});
|
||||
expect(back(testMotion)).toMatchObject({
|
||||
choreography: testMotion,
|
||||
history: "back",
|
||||
});
|
||||
});
|
||||
|
||||
it("treats a chain beginning at .to as an immutable anywhere gesture", () => {
|
||||
const definition = gesture.to
|
||||
.right({ threshold: 12 })
|
||||
.navigate(() => back())
|
||||
.animate(testMotion);
|
||||
|
||||
expect(definition).toMatchObject({
|
||||
kind: "origin-gesture-definition",
|
||||
start: { kind: "anywhere" },
|
||||
direction: "right",
|
||||
recognition: { threshold: 12 },
|
||||
choreography: testMotion,
|
||||
});
|
||||
expect(Object.isFrozen(definition)).toBe(true);
|
||||
expect(Object.isFrozen(definition.recognition)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps start predicates independent from movement direction", () => {
|
||||
const predicate = vi.fn(() => true);
|
||||
const complete = vi.fn(() => true);
|
||||
const definition = gesture.from
|
||||
.when(predicate)
|
||||
.to.down({ axisDominance: 1.4 })
|
||||
.complete(complete)
|
||||
.navigate(() => forward(originView(component("Dialog"))))
|
||||
.animate(testMotion);
|
||||
|
||||
expect(definition.start).toEqual({ kind: "when", predicate });
|
||||
expect(definition.direction).toBe("down");
|
||||
expect(definition.recognition.axisDominance).toBe(1.4);
|
||||
expect(definition.completion).toBe(complete);
|
||||
});
|
||||
|
||||
it("recognizes .to.right anywhere and lets .complete override release", async () => {
|
||||
vi.stubGlobal(
|
||||
"matchMedia",
|
||||
vi.fn(() => ({ matches: true }) as MediaQueryList),
|
||||
);
|
||||
const Target = component("Target");
|
||||
let binding: OriginGestureBinding | undefined;
|
||||
let completion: OriginGestureCompletionContext | undefined;
|
||||
const definition = gesture.to
|
||||
.right()
|
||||
.complete((context) => {
|
||||
completion = context;
|
||||
return false;
|
||||
})
|
||||
.navigate(() => forward(originView(Target, undefined, { key: "target" })))
|
||||
.animate(testMotion);
|
||||
|
||||
const Initial = defineComponent({
|
||||
name: "Initial",
|
||||
setup() {
|
||||
binding = useOriginGesture(definition);
|
||||
return () =>
|
||||
h(
|
||||
"div",
|
||||
{
|
||||
id: "gesture-host",
|
||||
style: binding!.style,
|
||||
onPointerdown: binding!.onPointerdown,
|
||||
onPointermove: binding!.onPointermove,
|
||||
onPointerup: binding!.onPointerup,
|
||||
onPointercancel: binding!.onPointercancel,
|
||||
},
|
||||
"Initial",
|
||||
);
|
||||
},
|
||||
});
|
||||
const scene = createOriginScene({
|
||||
initial: originView(Initial, undefined, { key: "initial" }),
|
||||
});
|
||||
const root = document.createElement("div");
|
||||
document.body.append(root);
|
||||
const app = createApp({ render: () => h(OriginScene, { scene }) });
|
||||
mountedApps.push(app);
|
||||
app.mount(root);
|
||||
await nextTick();
|
||||
|
||||
const host = root.querySelector("#gesture-host") as HTMLElement;
|
||||
Object.defineProperties(host, {
|
||||
clientWidth: { configurable: true, value: 200 },
|
||||
clientHeight: { configurable: true, value: 400 },
|
||||
});
|
||||
host.getBoundingClientRect = () =>
|
||||
({
|
||||
top: 20,
|
||||
left: 100,
|
||||
right: 300,
|
||||
bottom: 420,
|
||||
width: 200,
|
||||
height: 400,
|
||||
x: 100,
|
||||
y: 20,
|
||||
toJSON: () => ({}),
|
||||
}) as DOMRect;
|
||||
|
||||
// x=250 is nowhere near the left edge. With no `.from`, it is eligible.
|
||||
host.dispatchEvent(pointer("pointerdown", { clientX: 250, clientY: 100 }));
|
||||
host.dispatchEvent(pointer("pointermove", { clientX: 330, clientY: 102 }));
|
||||
await flushAsyncHandlers();
|
||||
expect(scene.operations.value).toHaveLength(1);
|
||||
|
||||
host.dispatchEvent(pointer("pointerup", { clientX: 350, clientY: 102 }));
|
||||
await flushAsyncHandlers();
|
||||
|
||||
expect(completion).toMatchObject({
|
||||
direction: "right",
|
||||
progress: 0.5,
|
||||
distance: 100,
|
||||
crossDistance: 2,
|
||||
});
|
||||
expect(completion?.start).toMatchObject({
|
||||
clientX: 250,
|
||||
localX: 150,
|
||||
});
|
||||
expect(completion?.current).toMatchObject({
|
||||
clientX: 350,
|
||||
localX: 250,
|
||||
});
|
||||
expect(scene.operations.value).toHaveLength(0);
|
||||
expect(scene.nodes.value.map((node) => node.view.name)).toEqual([
|
||||
"Initial",
|
||||
]);
|
||||
});
|
||||
});
|
||||
582
packages/core-v2/src/gesture.ts
Normal file
582
packages/core-v2/src/gesture.ts
Normal file
@@ -0,0 +1,582 @@
|
||||
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(),
|
||||
};
|
||||
}
|
||||
38
packages/core-v2/src/index.ts
Normal file
38
packages/core-v2/src/index.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Routeless, component-owned scene transitions and gesture recognition for Vue.
|
||||
*
|
||||
* The package renders flat, stable Vue component hosts and composes temporary
|
||||
* origin-relative operation frames. It does not depend on Vue Router or choose
|
||||
* a globally active view.
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
export * from "./types";
|
||||
export * from "./scene";
|
||||
export * from "./motion";
|
||||
export * from "./gesture";
|
||||
export * from "./lifecycle";
|
||||
|
||||
/**
|
||||
* Convenience component that binds one `useOriginGesture()` recognizer to a
|
||||
* rendered HTML element. See `OriginGestureProps` for its public props.
|
||||
*/
|
||||
export { default as OriginGesture } from "./components/OriginGesture.vue";
|
||||
|
||||
/**
|
||||
* Policy-neutral host for multiple completed gesture definitions. The owning
|
||||
* page builds each definition; this component only installs their recognizers
|
||||
* and forwards pointer events across the shared surface.
|
||||
*/
|
||||
export { default as OriginGestureSurface } from "./components/OriginGestureSurface.vue";
|
||||
|
||||
/**
|
||||
* Renderer for an `OriginScene`. Every live view is mounted as a stable,
|
||||
* absolutely positioned sibling beneath this component.
|
||||
*/
|
||||
export { default as OriginScene } from "./components/OriginScene.vue";
|
||||
|
||||
// Makes the library build emit dist/style.css. Applications should import the
|
||||
// explicit `@native-vue-router/core-v2/style.css` export as shown in the README.
|
||||
import "./style.css";
|
||||
53
packages/core-v2/src/lifecycle.ts
Normal file
53
packages/core-v2/src/lifecycle.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
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),
|
||||
};
|
||||
}
|
||||
325
packages/core-v2/src/motion.ts
Normal file
325
packages/core-v2/src/motion.ts
Normal file
@@ -0,0 +1,325 @@
|
||||
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}, `"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 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,
|
||||
};
|
||||
}
|
||||
315
packages/core-v2/src/scene.test.ts
Normal file
315
packages/core-v2/src/scene.test.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
import {
|
||||
createApp,
|
||||
defineComponent,
|
||||
h,
|
||||
nextTick,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
type Component,
|
||||
} from "vue";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import OriginScene from "./components/OriginScene.vue";
|
||||
import {
|
||||
above,
|
||||
back,
|
||||
defineOriginChoreography,
|
||||
forward,
|
||||
under,
|
||||
} from "./motion";
|
||||
import { createOriginScene, originView } from "./scene";
|
||||
|
||||
const mountedApps: Array<ReturnType<typeof createApp>> = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const app of mountedApps.splice(0)) app.unmount();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
function component(name: string): Component {
|
||||
return defineComponent({
|
||||
name,
|
||||
render: () => h("div", { "data-test-view": name }, name),
|
||||
});
|
||||
}
|
||||
|
||||
const layeredMotion = defineOriginChoreography({
|
||||
name: "test-layered-motion",
|
||||
effects: ({ progress }) => ({
|
||||
source: { transform: `translateX(${-progress * 100}px)` },
|
||||
target: { transform: `translateX(${(1 - progress) * 100}px)` },
|
||||
}),
|
||||
});
|
||||
|
||||
describe("origin-relative scene graph", () => {
|
||||
it("composes X→Y and Y→Z as independent transform layers", async () => {
|
||||
const x = originView(component("X"), undefined, { key: "x" });
|
||||
const y = originView(component("Y"), undefined, { key: "y" });
|
||||
const z = originView(component("Z"), undefined, { key: "z" });
|
||||
const scene = createOriginScene({ initial: x });
|
||||
const xKey = scene.nodes.value[0]!.key;
|
||||
|
||||
const xy = await scene.begin(xKey, above(y, layeredMotion));
|
||||
xy.update(0.5);
|
||||
const yz = await scene.begin(xy.targetKey, above(z, layeredMotion));
|
||||
yz.update(0.25);
|
||||
|
||||
/*
|
||||
* Y inherits the target half of X→Y, then adds its own source half of
|
||||
* Y→Z. Z inherits X→Y as well, but receives Y→Z's target half.
|
||||
*/
|
||||
expect(scene.styleForNode(xy.targetKey).transform).toBe(
|
||||
"translateX(50px) translateX(-25px)",
|
||||
);
|
||||
expect(scene.styleForNode(xy.targetKey)).toMatchObject({
|
||||
position: "absolute",
|
||||
inset: "0",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
});
|
||||
expect(scene.styleForNode(yz.targetKey).transform).toBe(
|
||||
"translateX(50px) translateX(75px)",
|
||||
);
|
||||
expect(scene.nodes.value.map((node) => node.view.name)).toEqual([
|
||||
"X",
|
||||
"Y",
|
||||
"Z",
|
||||
]);
|
||||
});
|
||||
|
||||
it("splices completed operations in either order without losing descendants", async () => {
|
||||
const scene = createOriginScene({
|
||||
initial: originView(component("X"), undefined, { key: "x" }),
|
||||
});
|
||||
const xKey = scene.nodes.value[0]!.key;
|
||||
const xy = await scene.begin(
|
||||
xKey,
|
||||
above(originView(component("Y"), undefined, { key: "y" }), layeredMotion),
|
||||
);
|
||||
xy.update(0.8);
|
||||
const yz = await scene.begin(
|
||||
xy.targetKey,
|
||||
above(originView(component("Z"), undefined, { key: "z" }), layeredMotion),
|
||||
);
|
||||
yz.update(0.6);
|
||||
|
||||
// Completing the newer edge first parks Y while Z replaces it as the
|
||||
// visual target. Y remains mounted as Z's retained previous entry.
|
||||
await yz.finish({ commit: true, animate: false });
|
||||
expect(scene.nodes.value.map((node) => node.view.name)).toEqual([
|
||||
"X",
|
||||
"Y",
|
||||
"Z",
|
||||
]);
|
||||
expect(
|
||||
scene.nodes.value.find((node) => node.view.name === "Y")?.state,
|
||||
).toBe("parked");
|
||||
expect(scene.operations.value).toHaveLength(1);
|
||||
expect(scene.operations.value[0]?.targetKey).toBe(yz.targetKey);
|
||||
expect(scene.styleForNode(yz.targetKey).transform).toMatch(
|
||||
/^translateX\(19\.9.+px\)$/,
|
||||
);
|
||||
|
||||
await xy.finish({ commit: true, animate: false });
|
||||
expect(
|
||||
scene.nodes.value.map((node) => [node.view.name, node.state]),
|
||||
).toEqual([
|
||||
["X", "parked"],
|
||||
["Y", "parked"],
|
||||
["Z", "active"],
|
||||
]);
|
||||
expect(scene.operations.value).toHaveLength(0);
|
||||
expect(scene.styleForNode(yz.targetKey).transform).toBe("none");
|
||||
expect(scene.styleForNode(xKey).visibility).toBe("hidden");
|
||||
});
|
||||
|
||||
it("does not remount a target when its incoming operation is collapsed", async () => {
|
||||
let yMounts = 0;
|
||||
const X = component("X");
|
||||
const Y = defineComponent({
|
||||
name: "Y",
|
||||
setup() {
|
||||
onMounted(() => {
|
||||
yMounts += 1;
|
||||
});
|
||||
return () => h("div", "Y");
|
||||
},
|
||||
});
|
||||
const Z = component("Z");
|
||||
const scene = createOriginScene({
|
||||
initial: originView(X, undefined, { key: "x" }),
|
||||
});
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const app = createApp({
|
||||
render: () => h(OriginScene, { scene }),
|
||||
});
|
||||
mountedApps.push(app);
|
||||
app.mount(host);
|
||||
expect(
|
||||
(host.querySelector(".nvo-scene") as HTMLElement | null)?.style.position,
|
||||
).toBe("relative");
|
||||
|
||||
const xy = await scene.begin(
|
||||
scene.nodes.value[0]!.key,
|
||||
above(originView(Y, undefined, { key: "y" }), layeredMotion),
|
||||
);
|
||||
const yz = await scene.begin(
|
||||
xy.targetKey,
|
||||
above(originView(Z, undefined, { key: "z" }), layeredMotion),
|
||||
);
|
||||
await nextTick();
|
||||
expect(yMounts).toBe(1);
|
||||
expect(
|
||||
(host.querySelector('[data-origin-view="Y"]') as HTMLElement | null)
|
||||
?.style.position,
|
||||
).toBe("absolute");
|
||||
|
||||
// Y remains the same flat, keyed host while X→Y disappears around it.
|
||||
await xy.finish({ commit: true, animate: false });
|
||||
await nextTick();
|
||||
expect(yMounts).toBe(1);
|
||||
expect(scene.nodes.value.map((node) => node.view.name)).toEqual([
|
||||
"X",
|
||||
"Y",
|
||||
"Z",
|
||||
]);
|
||||
|
||||
await yz.cancel({ animate: false });
|
||||
expect(
|
||||
scene.nodes.value.map((node) => [node.view.name, node.state]),
|
||||
).toEqual([
|
||||
["X", "parked"],
|
||||
["Y", "active"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("reuses the retained previous node and pops only the current entry on back", async () => {
|
||||
let xMounts = 0;
|
||||
let xUnmounts = 0;
|
||||
let yUnmounts = 0;
|
||||
const X = defineComponent({
|
||||
name: "X",
|
||||
setup() {
|
||||
onMounted(() => {
|
||||
xMounts += 1;
|
||||
});
|
||||
onUnmounted(() => {
|
||||
xUnmounts += 1;
|
||||
});
|
||||
return () =>
|
||||
h(
|
||||
"div",
|
||||
{
|
||||
"data-scroll": "feed",
|
||||
style: { height: "100px", overflow: "auto" },
|
||||
},
|
||||
h("div", { style: { height: "2000px" } }, "Feed"),
|
||||
);
|
||||
},
|
||||
});
|
||||
const Y = defineComponent({
|
||||
name: "Y",
|
||||
setup() {
|
||||
onUnmounted(() => {
|
||||
yUnmounts += 1;
|
||||
});
|
||||
return () => h("div", "Y");
|
||||
},
|
||||
});
|
||||
const x = originView(X, { message: "original" }, { key: "x" });
|
||||
const y = originView(Y, undefined, { key: "y" });
|
||||
const scene = createOriginScene({ initial: x });
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const app = createApp({
|
||||
render: () => h(OriginScene, { scene }),
|
||||
});
|
||||
mountedApps.push(app);
|
||||
app.mount(host);
|
||||
await nextTick();
|
||||
|
||||
const xKey = scene.nodes.value[0]!.key;
|
||||
const originalScroller = host.querySelector(
|
||||
'[data-scroll="feed"]',
|
||||
) as HTMLElement;
|
||||
originalScroller.scrollTop = 842;
|
||||
|
||||
const xy = await scene.begin(xKey, forward(y, layeredMotion));
|
||||
await xy.finish({ commit: true, animate: false });
|
||||
await nextTick();
|
||||
|
||||
const yContext = scene.contextFor(xy.targetKey);
|
||||
expect(yContext.previous).toBe(x);
|
||||
expect(yContext.history).toEqual([x]);
|
||||
expect(xMounts).toBe(1);
|
||||
expect(xUnmounts).toBe(0);
|
||||
expect(scene.nodes.value.find((node) => node.key === xKey)?.state).toBe(
|
||||
"parked",
|
||||
);
|
||||
expect(host.querySelector('[data-scroll="feed"]')).toBe(originalScroller);
|
||||
expect(originalScroller.scrollTop).toBe(842);
|
||||
|
||||
const yx = await scene.begin(xy.targetKey, back(layeredMotion));
|
||||
expect(yx.targetKey).toBe(xKey);
|
||||
await nextTick();
|
||||
expect(host.querySelector('[data-scroll="feed"]')).toBe(originalScroller);
|
||||
expect(originalScroller.scrollTop).toBe(842);
|
||||
|
||||
await yx.finish({ commit: true, animate: false });
|
||||
await nextTick();
|
||||
expect(scene.nodes.value.map((node) => node.key)).toEqual([xKey]);
|
||||
expect(scene.contextFor(xKey).history).toEqual([]);
|
||||
expect(scene.nodes.value[0]?.state).toBe("active");
|
||||
expect(xMounts).toBe(1);
|
||||
expect(xUnmounts).toBe(0);
|
||||
expect(yUnmounts).toBe(1);
|
||||
expect(originalScroller.scrollTop).toBe(842);
|
||||
});
|
||||
|
||||
it("re-parks the retained target when a back operation is cancelled", async () => {
|
||||
const x = originView(component("X"), undefined, { key: "x" });
|
||||
const y = originView(component("Y"), undefined, { key: "y" });
|
||||
const scene = createOriginScene({ initial: x });
|
||||
const xKey = scene.nodes.value[0]!.key;
|
||||
const xy = await scene.begin(xKey, above(y, layeredMotion));
|
||||
await xy.finish({ commit: true, animate: false });
|
||||
|
||||
const yx = await scene.begin(
|
||||
xy.targetKey,
|
||||
under(x, layeredMotion, { history: "back" }),
|
||||
);
|
||||
expect(yx.targetKey).toBe(xKey);
|
||||
await yx.cancel({ animate: false });
|
||||
|
||||
expect(scene.operations.value).toHaveLength(0);
|
||||
expect(
|
||||
scene.nodes.value.map((node) => [node.view.name, node.state]),
|
||||
).toEqual([
|
||||
["X", "parked"],
|
||||
["Y", "active"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("hands an immediate back gesture from a settling push to the same instances", async () => {
|
||||
const x = originView(component("X"), undefined, { key: "x" });
|
||||
const y = originView(component("Y"), undefined, { key: "y" });
|
||||
const scene = createOriginScene({ initial: x });
|
||||
const xKey = scene.nodes.value[0]!.key;
|
||||
const xy = await scene.begin(xKey, above(y, layeredMotion));
|
||||
xy.update(0.8, 1);
|
||||
|
||||
const forwardSettlement = xy.finish({ commit: true });
|
||||
const yx = await scene.begin(
|
||||
xy.targetKey,
|
||||
under(x, layeredMotion, { history: "back" }),
|
||||
);
|
||||
expect(yx.targetKey).toBe(xKey);
|
||||
await yx.finish({ commit: true, animate: false });
|
||||
await forwardSettlement;
|
||||
|
||||
expect(scene.operations.value).toHaveLength(0);
|
||||
expect(scene.nodes.value.map((node) => [node.key, node.state])).toEqual([
|
||||
[xKey, "active"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
701
packages/core-v2/src/scene.ts
Normal file
701
packages/core-v2/src/scene.ts
Normal file
@@ -0,0 +1,701 @@
|
||||
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,
|
||||
} 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<
|
||||
Props extends Record<string, unknown> = Record<string, unknown>,
|
||||
>(
|
||||
component: Component,
|
||||
props?: Props,
|
||||
options: OriginViewOptions = {},
|
||||
): OriginView<Props> {
|
||||
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>());
|
||||
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 "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 = [...operations.values()].find(
|
||||
(operation) => operation.originKey === nodeKey,
|
||||
);
|
||||
if (!outgoing) return result;
|
||||
const effects = effectSetFor(outgoing);
|
||||
return [
|
||||
...result,
|
||||
normalizedEffect(effects.frame),
|
||||
normalizedEffect(effects.source),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
);
|
||||
}
|
||||
|
||||
function isNodeInteractive(nodeKey: string) {
|
||||
const node = nodes.get(nodeKey);
|
||||
if (!node || node.state === "parked") 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") return;
|
||||
node.state =
|
||||
node.incomingOperationId || 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.
|
||||
*
|
||||
* Push keeps the origin mounted and parks it. Back reuses the existing
|
||||
* previous node and removes only the entry being popped.
|
||||
*/
|
||||
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;
|
||||
|
||||
spliceVisualTarget(origin, target);
|
||||
operation.phase = "finished";
|
||||
operations.delete(operation.id);
|
||||
|
||||
if (operation.history === "push") {
|
||||
origin.incomingOperationId = undefined;
|
||||
origin.state = "parked";
|
||||
} else {
|
||||
// A committed back pops only the current retained history entry.
|
||||
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);
|
||||
operation.phase = "finished";
|
||||
operations.delete(id);
|
||||
|
||||
if (operation.history === "back") {
|
||||
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;
|
||||
|
||||
const threshold = operation.choreography.commitThreshold ?? 0.36;
|
||||
// const velocityThreshold = operation.choreography.commitVelocity ?? 0.9;
|
||||
const shouldCommit =
|
||||
options.commit ??
|
||||
(operation.progress >= threshold ||
|
||||
(operation.progress >= 0.00 &&
|
||||
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")
|
||||
throw new Error(`Cannot animate from parked 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";
|
||||
let target: SceneNodeState;
|
||||
|
||||
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.",
|
||||
);
|
||||
commitOperation(incoming.id);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// 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()] 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,
|
||||
};
|
||||
}
|
||||
37
packages/core-v2/src/style.css
Normal file
37
packages/core-v2/src/style.css
Normal file
@@ -0,0 +1,37 @@
|
||||
.nvo-scene {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
contain: layout paint;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.nvo-node {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
will-change: transform, opacity;
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
.nvo-node > * {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.nvo-gesture {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.nvo-node {
|
||||
will-change: auto;
|
||||
}
|
||||
}
|
||||
800
packages/core-v2/src/types.ts
Normal file
800
packages/core-v2/src/types.ts
Normal file
@@ -0,0 +1,800 @@
|
||||
import type { Component, ComputedRef, CSSProperties, ShallowRef } from "vue";
|
||||
|
||||
/**
|
||||
* 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<
|
||||
Props extends Record<string, unknown> = Record<string, unknown>,
|
||||
> {
|
||||
/** The Vue component definition that will be mounted for this recipe. */
|
||||
readonly component: Component;
|
||||
/** Props passed to the component when the recipe is mounted. */
|
||||
readonly props?: Readonly<Props>;
|
||||
/**
|
||||
* 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.
|
||||
* - `back`: reuse the mounted previous entry and pop the origin on commit.
|
||||
*/
|
||||
export type OriginHistoryMode = "push" | "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.
|
||||
* - `parked`: retained in history but visually hidden and inert.
|
||||
*/
|
||||
export type OriginSceneNodeState = "active" | "transitioning" | "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;
|
||||
}
|
||||
|
||||
/** A complete request to create and animate a target view from an origin. */
|
||||
export interface OriginAction {
|
||||
/**
|
||||
* Recipe for a newly created 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 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;
|
||||
/** Live operation currently positioning this node as its target. */
|
||||
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, 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;
|
||||
}
|
||||
14
packages/core-v2/tsconfig.json
Normal file
14
packages/core-v2/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "../../tsconfig.app.json",
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"noEmit": false,
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"tsBuildInfoFile": "../../node_modules/.tmp/core-v2.tsbuildinfo"
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
20
packages/core-v2/vite.config.ts
Normal file
20
packages/core-v2/vite.config.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { resolve } from "node:path";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
build: {
|
||||
outDir: "dist",
|
||||
emptyOutDir: true,
|
||||
lib: {
|
||||
entry: resolve(__dirname, "src/index.ts"),
|
||||
formats: ["es"],
|
||||
fileName: "index",
|
||||
cssFileName: "style",
|
||||
},
|
||||
// V2 intentionally has no vue-router dependency. Vue is supplied by the
|
||||
// consuming application so every scene shares the application's renderer.
|
||||
rollupOptions: { external: ["vue"] },
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user