Compare commits
3
Commits
main
...
8448e910ed
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8448e910ed | ||
|
|
511e58c198 | ||
|
|
329271691c |
+29
-1
@@ -411,6 +411,7 @@ The stages have distinct responsibilities:
|
|||||||
| `.to.*` | Required movement direction and intent-recognition options |
|
| `.to.*` | Required movement direction and intent-recognition options |
|
||||||
| `.complete(predicate)` | Optional release commit/cancel decision |
|
| `.complete(predicate)` | Optional release commit/cancel decision |
|
||||||
| `.navigate(factory)` | Required target and retained-history intent |
|
| `.navigate(factory)` | Required target and retained-history intent |
|
||||||
|
| `.onCommit(handler)` | Optional side effect when a release commits |
|
||||||
| `.animate(routine)` | Required source/target/frame choreography |
|
| `.animate(routine)` | Required source/target/frame choreography |
|
||||||
|
|
||||||
The builder is persistent and immutable. Reusing an earlier stage cannot
|
The builder is persistent and immutable. Reusing an earlier stage cannot
|
||||||
@@ -432,6 +433,27 @@ velocity, directed pixel distance, cross-axis distance, duration, pointer-up
|
|||||||
event, host, bounds, and start/current points. Completion predicates are
|
event, host, bounds, and start/current points. Completion predicates are
|
||||||
synchronous because they select operation intent at release.
|
synchronous because they select operation intent at release.
|
||||||
|
|
||||||
|
`.onCommit(handler)` is available at every stage after `.to.*`, returns the
|
||||||
|
same stage, and may be called more than once; handlers run in order. A handler
|
||||||
|
runs once, synchronously at release, after the operation commits and before
|
||||||
|
the settling animation, and receives the same context as `.complete()`. It
|
||||||
|
never runs for a cancelled release. A throwing handler is reported as an
|
||||||
|
uncaught error without stopping later handlers or the operation. It is the
|
||||||
|
place for haptic feedback:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const openProfile = gesture.to
|
||||||
|
.left()
|
||||||
|
.navigate(() => forward(originView(ProfileView)))
|
||||||
|
.onCommit(() => navigator.vibrate?.(10))
|
||||||
|
.animate(slideLeft);
|
||||||
|
```
|
||||||
|
|
||||||
|
`navigator.vibrate` is unavailable in iOS Safari; Capacitor apps can call
|
||||||
|
`Haptics.impact()` from `@capacitor/haptics` instead. The method is named
|
||||||
|
`onCommit` rather than `then` so builder stages never become promise-like
|
||||||
|
"thenables" when awaited or returned from an async function.
|
||||||
|
|
||||||
### `useOriginGesture(definition)`
|
### `useOriginGesture(definition)`
|
||||||
|
|
||||||
Creates one primary-pointer, single-axis recognizer owned by the component that
|
Creates one primary-pointer, single-axis recognizer owned by the component that
|
||||||
@@ -553,7 +575,10 @@ When `finish()` does not explicitly override the decision, a target commits
|
|||||||
when either:
|
when either:
|
||||||
|
|
||||||
- `progress >= commitThreshold`, default `0.36`; or
|
- `progress >= commitThreshold`, default `0.36`; or
|
||||||
- `progress >= 0.06` and `velocity >= commitVelocity`, default `0.9`.
|
- release `velocity >= 0.3` progress units per second, at any progress.
|
||||||
|
|
||||||
|
`commitVelocity` is accepted on choreographies but is not currently consulted;
|
||||||
|
the flick threshold is the fixed `0.3` above.
|
||||||
|
|
||||||
The operation's intent becomes final before its spring settles.
|
The operation's intent becomes final before its spring settles.
|
||||||
|
|
||||||
@@ -637,6 +662,9 @@ can immediately begin its own outgoing operation, enabling X → Y → Z chains.
|
|||||||
|
|
||||||
### `OriginOperationHandle`
|
### `OriginOperationHandle`
|
||||||
|
|
||||||
|
`intent` reports the selected outcome (`undecided`, `commit`, or `cancel`). It
|
||||||
|
changes synchronously when `finish()` or `cancel()` is called.
|
||||||
|
|
||||||
| Member | Description |
|
| Member | Description |
|
||||||
| ----------------------------- | --------------------------------------------------- |
|
| ----------------------------- | --------------------------------------------------- |
|
||||||
| `id` | Unique operation ID |
|
| `id` | Unique operation ID |
|
||||||
|
|||||||
@@ -292,6 +292,26 @@ flat host.
|
|||||||
“Y is Z's origin” is a coordinate and retained-history relationship, not Vue
|
“Y is Z's origin” is a coordinate and retained-history relationship, not Vue
|
||||||
component ancestry.
|
component ancestry.
|
||||||
|
|
||||||
|
## Source layout
|
||||||
|
|
||||||
|
Each module depends only on the ones listed above it.
|
||||||
|
|
||||||
|
| File | Responsibility |
|
||||||
|
| ----------------------- | ------------------------------------------------------------------------------------------------- |
|
||||||
|
| `shared.ts` | Geometry and environment helpers |
|
||||||
|
| `choreography.ts` | Effect types, `defineOriginChoreography()`, presets, and `composeEffects()` style compositing |
|
||||||
|
| `navigation.ts` | `originView()` recipes and `forward()`, `back()`, `replace()`, `above()`, `under()` actions |
|
||||||
|
| `scene/types.ts` | Public scene types |
|
||||||
|
| `scene/graph.ts` | `SceneGraph`: node and edge storage plus the begin/commit/cancel history rules (all synchronous) |
|
||||||
|
| `scene/scene.ts` | `Scene` and `OperationHandle`: mounting, measuring, settling spring, compositing, and `useOrigin` |
|
||||||
|
| `gesture/definition.ts` | Gesture types, component props, and the immutable `gesture` builder |
|
||||||
|
| `gesture/recognizer.ts` | Pointer-down admission, `DirectionalTracker`, `GestureRecognizer`, and `useOriginGesture` |
|
||||||
|
| `components/` | `OriginScene`, `OriginGesture`, `OriginGestureSurface`, and the internal node host |
|
||||||
|
|
||||||
|
Pure helpers used by a class (for example `matchesStart`, `stepSpring`, or
|
||||||
|
`composeNodeStyle`) are module-level `export const`s that the class calls, so
|
||||||
|
they can be tested directly without widening the class's interface.
|
||||||
|
|
||||||
## Current experimental boundaries
|
## Current experimental boundaries
|
||||||
|
|
||||||
- One operation creates one target. Chaining operations already permits any
|
- One operation creates one target. Chaining operations already permits any
|
||||||
|
|||||||
@@ -0,0 +1,296 @@
|
|||||||
|
import type { CSSProperties } from "vue";
|
||||||
|
import { clamp01, type OriginRect } from "./shared";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Choreographies: the visual side of an operation. A choreography computes
|
||||||
|
* effects from operation progress; `composeEffects` collapses the effects
|
||||||
|
* applied to one node into its inline style. Nothing here knows about history.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The lifecycle phase of an operation edge.
|
||||||
|
*
|
||||||
|
* - `preparing`: the target has been added and Vue is mounting it.
|
||||||
|
* - `interactive`: progress may be controlled by a gesture or application.
|
||||||
|
* - `settling`: the commit/cancel decision is fixed and the spring is running.
|
||||||
|
* - `finished`: the graph rewrite or cancellation cleanup has completed.
|
||||||
|
*/
|
||||||
|
export type OriginOperationPhase =
|
||||||
|
"preparing" | "interactive" | "settling" | "finished";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The outcome selected for an operation.
|
||||||
|
*
|
||||||
|
* Intent remains `undecided` until `finish()` is called. It becomes final
|
||||||
|
* before the settling animation completes, allowing the retained target to
|
||||||
|
* originate its own operation immediately.
|
||||||
|
*/
|
||||||
|
export type OriginOperationIntent = "undecided" | "commit" | "cancel";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One composable contribution to a scene node's final visual style.
|
||||||
|
*
|
||||||
|
* Transforms are concatenated in origin-to-descendant order and opacity values
|
||||||
|
* are multiplied. This lets X→Y and Y→Z affect Y simultaneously without either
|
||||||
|
* routine replacing the other routine's CSS transform.
|
||||||
|
*/
|
||||||
|
export interface OriginEffect {
|
||||||
|
/**
|
||||||
|
* A CSS transform contribution. Transform strings from inherited and local
|
||||||
|
* origin frames are concatenated rather than replacing one another.
|
||||||
|
*/
|
||||||
|
transform?: string;
|
||||||
|
/**
|
||||||
|
* An opacity contribution between `0` and `1`. Contributions from multiple
|
||||||
|
* frames are multiplied and the final value is clamped.
|
||||||
|
*/
|
||||||
|
opacity?: number;
|
||||||
|
/**
|
||||||
|
* Relative stacking contribution. `above()` defaults the target to +1 and
|
||||||
|
* `under()` defaults it to -1.
|
||||||
|
*/
|
||||||
|
layer?: number;
|
||||||
|
/**
|
||||||
|
* Escape hatch for non-geometric effects such as filter, clipPath, or
|
||||||
|
* borderRadius. Later/local effects override inherited properties.
|
||||||
|
*/
|
||||||
|
style?: CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Independent visual contributions calculated for one operation edge.
|
||||||
|
*
|
||||||
|
* Any omitted contribution is treated as an identity effect.
|
||||||
|
*/
|
||||||
|
export interface OriginEffectSet {
|
||||||
|
/**
|
||||||
|
* Applied to both source and target. This is useful for moving an entire
|
||||||
|
* origin-relative coordinate frame.
|
||||||
|
*/
|
||||||
|
frame?: OriginEffect;
|
||||||
|
/** Applied to the component that initiated this operation. */
|
||||||
|
source?: OriginEffect;
|
||||||
|
/** Applied to the component created by this operation and its descendants. */
|
||||||
|
target?: OriginEffect;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Values supplied whenever a choreography calculates its visual effects. */
|
||||||
|
export interface OriginChoreographyContext {
|
||||||
|
/** Normalized operation progress, clamped to the inclusive range `0..1`. */
|
||||||
|
progress: number;
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
/**
|
||||||
|
* Reserved: normalized positive release velocity intended to commit a
|
||||||
|
* deliberate flick.
|
||||||
|
*
|
||||||
|
* The scene currently commits any release whose velocity is at least `0.3`,
|
||||||
|
* regardless of this value. See `FLICK_COMMIT_VELOCITY` in the scene module.
|
||||||
|
*/
|
||||||
|
readonly commitVelocity?: number;
|
||||||
|
/**
|
||||||
|
* Keep this choreography's progress-`1` effects connected after a committed
|
||||||
|
* push instead of collapsing the edge and hiding its source.
|
||||||
|
*
|
||||||
|
* This is intended for partial presentations such as drawers and inspectors
|
||||||
|
* where part of the retained source remains visible beside the target. The
|
||||||
|
* source remains mounted, visible, and inert. A back operation temporarily
|
||||||
|
* suspends the resting edge; cancelling back restores it.
|
||||||
|
*
|
||||||
|
* Only retained-history `push` actions support connected resting effects.
|
||||||
|
*
|
||||||
|
* @defaultValue `false`
|
||||||
|
*/
|
||||||
|
readonly persistAtRest?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preserve type inference while declaring a custom choreography.
|
||||||
|
*
|
||||||
|
* The helper performs no runtime transformation. It gives custom routines a
|
||||||
|
* named, documented construction point and validates their shape in TypeScript.
|
||||||
|
*
|
||||||
|
* @param choreography - Side-effect-free visual effect calculator and optional
|
||||||
|
* release thresholds.
|
||||||
|
* @returns The same choreography object.
|
||||||
|
*/
|
||||||
|
export function defineOriginChoreography(
|
||||||
|
choreography: OriginChoreography,
|
||||||
|
): OriginChoreography {
|
||||||
|
return choreography;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Distinguish a choreography from a plain options object. */
|
||||||
|
export const isChoreography = (value: unknown): value is OriginChoreography => {
|
||||||
|
return (
|
||||||
|
typeof value === "object" &&
|
||||||
|
value !== null &&
|
||||||
|
"effects" in value &&
|
||||||
|
typeof value.effects === "function"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize one contribution before the compositor combines it with effects
|
||||||
|
* inherited from earlier origin frames.
|
||||||
|
*
|
||||||
|
* Most applications should return plain effects from a choreography and let
|
||||||
|
* the scene call this function. It is exported for custom compositors and
|
||||||
|
* diagnostics.
|
||||||
|
*
|
||||||
|
* @param effect - Optional effect to normalize.
|
||||||
|
* @param fallbackLayer - Relative layer contribution added to `effect.layer`.
|
||||||
|
* @returns A defined effect with a numeric layer.
|
||||||
|
*/
|
||||||
|
export function normalizedEffect(
|
||||||
|
effect: OriginEffect | undefined,
|
||||||
|
fallbackLayer = 0,
|
||||||
|
): OriginEffect {
|
||||||
|
return {
|
||||||
|
...effect,
|
||||||
|
layer: (effect?.layer ?? 0) + fallbackLayer,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a transform contributes anything to a composed transform list. */
|
||||||
|
function isComposable(transform: unknown): transform is string {
|
||||||
|
return (
|
||||||
|
typeof transform === "string" && transform !== "" && transform !== "none"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result of collapsing an ordered list of effects into one style. */
|
||||||
|
export interface ComposedEffects {
|
||||||
|
/** Merged style with the composed `transform` and `opacity` applied. */
|
||||||
|
readonly style: CSSProperties;
|
||||||
|
/** Sum of every effect's relative layer contribution. */
|
||||||
|
readonly layer: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapse independent effect layers, ordered from the outermost inherited
|
||||||
|
* frame to the most local one, into a single style.
|
||||||
|
*
|
||||||
|
* Transforms are concatenated and opacities multiplied, including `transform`
|
||||||
|
* and numeric `opacity` supplied through `effect.style`. Every other `style`
|
||||||
|
* property uses local-last precedence over `base` and earlier effects.
|
||||||
|
*/
|
||||||
|
export const composeEffects = (
|
||||||
|
effects: readonly OriginEffect[],
|
||||||
|
base: CSSProperties = {},
|
||||||
|
): ComposedEffects => {
|
||||||
|
const style: CSSProperties = { ...base };
|
||||||
|
const transforms: string[] = [];
|
||||||
|
let opacity = 1;
|
||||||
|
let layer = 0;
|
||||||
|
|
||||||
|
for (const effect of effects) {
|
||||||
|
if (isComposable(effect.transform)) transforms.push(effect.transform);
|
||||||
|
if (effect.opacity !== undefined) opacity *= effect.opacity;
|
||||||
|
layer += effect.layer ?? 0;
|
||||||
|
|
||||||
|
if (effect.style) {
|
||||||
|
Object.assign(style, effect.style);
|
||||||
|
if (isComposable(effect.style.transform))
|
||||||
|
transforms.push(effect.style.transform);
|
||||||
|
if (effect.style.opacity !== undefined) {
|
||||||
|
const styleOpacity = Number(effect.style.opacity);
|
||||||
|
if (Number.isFinite(styleOpacity)) opacity *= styleOpacity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign the composed properties last so no single effect's `style` can
|
||||||
|
// replace transform work inherited from other frames.
|
||||||
|
style.transform = transforms.length ? transforms.join(" ") : "none";
|
||||||
|
style.opacity = String(clamp01(opacity));
|
||||||
|
return { style, layer };
|
||||||
|
};
|
||||||
|
|
||||||
|
const percent = (value: number) => `${(value * 100).toFixed(4)}%`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Native-style forward motion: the target enters above the source while the
|
||||||
|
* source recedes slightly. These presets are examples; applications can
|
||||||
|
* replace them with arbitrary `defineOriginChoreography()` callbacks.
|
||||||
|
*/
|
||||||
|
export const slideLeft = defineOriginChoreography({
|
||||||
|
name: "slide-left",
|
||||||
|
commitThreshold: 0.36,
|
||||||
|
commitVelocity: 0.6,
|
||||||
|
effects: ({ progress }) => ({
|
||||||
|
source: {
|
||||||
|
transform: `translate3d(${percent(progress * -0.24)}, 0, 0) scale(${1 - progress * 0.025})`,
|
||||||
|
opacity: 1 - progress * 0.16,
|
||||||
|
},
|
||||||
|
target: {
|
||||||
|
transform: `translate3d(${percent(1 - progress)}, 0, 0)`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Back motion reveals the target underneath the source. This is merely a
|
||||||
|
* visual routine; the `back()` action selects retained-history behavior.
|
||||||
|
*/
|
||||||
|
export const slideRight = defineOriginChoreography({
|
||||||
|
name: "slide-right",
|
||||||
|
commitThreshold: 0.36,
|
||||||
|
commitVelocity: 0.6,
|
||||||
|
effects: ({ progress }) => ({
|
||||||
|
source: {
|
||||||
|
transform: `translate3d(${percent(progress)}, 0, 0)`,
|
||||||
|
},
|
||||||
|
target: {
|
||||||
|
transform: `translate3d(${percent(-0.24 + progress * 0.24)}, 0, 0) scale(${0.975 + progress * 0.025})`,
|
||||||
|
opacity: 0.84 + progress * 0.16,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross-fade preset that fades the source out while fading the target in.
|
||||||
|
*
|
||||||
|
* The default release thresholds from the scene are used.
|
||||||
|
*/
|
||||||
|
export const fade = defineOriginChoreography({
|
||||||
|
name: "fade",
|
||||||
|
effects: ({ progress }) => ({
|
||||||
|
source: { opacity: 1 - progress },
|
||||||
|
target: { opacity: progress },
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from "vue";
|
import { useOriginGesture } from "../gesture/recognizer";
|
||||||
import { useOriginGesture } from "../gesture";
|
import type { OriginGestureProps } from "../gesture/definition";
|
||||||
import type { OriginGestureProps } from "../types";
|
|
||||||
|
|
||||||
defineOptions({ name: "OriginGesture", inheritAttrs: false });
|
defineOptions({ name: "OriginGesture", inheritAttrs: false });
|
||||||
|
|
||||||
@@ -12,7 +11,8 @@ const props = withDefaults(defineProps<OriginGestureProps>(), {
|
|||||||
/*
|
/*
|
||||||
* This convenience component makes the declaration live exactly where the
|
* This convenience component makes the declaration live exactly where the
|
||||||
* developer writes it. `useOriginGesture()` is also public for components that
|
* developer writes it. `useOriginGesture()` is also public for components that
|
||||||
* cannot accept an extra wrapper element.
|
* cannot accept an extra wrapper element. Props are read once, at setup; the
|
||||||
|
* legacy `action` prop is read lazily so it may change.
|
||||||
*/
|
*/
|
||||||
const gesture = useOriginGesture(
|
const gesture = useOriginGesture(
|
||||||
props.gesture ?? {
|
props.gesture ?? {
|
||||||
@@ -22,7 +22,6 @@ const gesture = useOriginGesture(
|
|||||||
action: (context) => props.action?.(context),
|
action: (context) => props.action?.(context),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const touchStyle = computed(() => gesture.style);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -30,7 +29,7 @@ const touchStyle = computed(() => gesture.style);
|
|||||||
:is="as"
|
:is="as"
|
||||||
v-bind="$attrs"
|
v-bind="$attrs"
|
||||||
class="nvo-gesture"
|
class="nvo-gesture"
|
||||||
:style="touchStyle"
|
:style="gesture.style"
|
||||||
@pointerdown="gesture.onPointerdown"
|
@pointerdown="gesture.onPointerdown"
|
||||||
@pointermove="gesture.onPointermove"
|
@pointermove="gesture.onPointermove"
|
||||||
@pointerup="gesture.onPointerup"
|
@pointerup="gesture.onPointerup"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
import { useOriginGesture } from "../gesture";
|
import type { OriginGestureSurfaceProps } from "../gesture/definition";
|
||||||
import type { OriginGestureSurfaceProps } from "../types";
|
import { touchActionFor, useOriginGesture } from "../gesture/recognizer";
|
||||||
|
|
||||||
defineOptions({ name: "OriginGestureSurface", inheritAttrs: false });
|
defineOptions({ name: "OriginGestureSurface", inheritAttrs: false });
|
||||||
|
|
||||||
@@ -18,43 +18,25 @@ const bindings = props.gestures.map((definition) =>
|
|||||||
useOriginGesture(definition),
|
useOriginGesture(definition),
|
||||||
);
|
);
|
||||||
|
|
||||||
const surfaceStyle = computed(() => {
|
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%",
|
width: "100%",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
touchAction:
|
touchAction: touchActionFor(
|
||||||
horizontal && vertical
|
props.gestures.map(({ direction }) => direction),
|
||||||
? "none"
|
),
|
||||||
: horizontal
|
}) as const,
|
||||||
? "pan-y"
|
);
|
||||||
: vertical
|
|
||||||
? "pan-x"
|
|
||||||
: "auto",
|
|
||||||
} as const;
|
|
||||||
});
|
|
||||||
|
|
||||||
function pointerDown(event: PointerEvent) {
|
const onPointerdown = (event: PointerEvent) =>
|
||||||
for (const binding of bindings) binding.onPointerdown(event);
|
bindings.forEach((binding) => binding.onPointerdown(event));
|
||||||
}
|
const onPointermove = (event: PointerEvent) =>
|
||||||
|
bindings.forEach((binding) => binding.onPointermove(event));
|
||||||
function pointerMove(event: PointerEvent) {
|
const onPointerup = (event: PointerEvent) =>
|
||||||
for (const binding of bindings) binding.onPointermove(event);
|
bindings.forEach((binding) => binding.onPointerup(event));
|
||||||
}
|
const onPointercancel = () =>
|
||||||
|
bindings.forEach((binding) => binding.onPointercancel());
|
||||||
function pointerUp(event: PointerEvent) {
|
|
||||||
for (const binding of bindings) binding.onPointerup(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
function pointerCancel() {
|
|
||||||
for (const binding of bindings) binding.onPointercancel();
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -63,10 +45,10 @@ function pointerCancel() {
|
|||||||
v-bind="$attrs"
|
v-bind="$attrs"
|
||||||
class="nvo-gesture"
|
class="nvo-gesture"
|
||||||
:style="[$attrs.style, surfaceStyle]"
|
:style="[$attrs.style, surfaceStyle]"
|
||||||
@pointerdown="pointerDown"
|
@pointerdown="onPointerdown"
|
||||||
@pointermove="pointerMove"
|
@pointermove="onPointermove"
|
||||||
@pointerup="pointerUp"
|
@pointerup="onPointerup"
|
||||||
@pointercancel="pointerCancel"
|
@pointercancel="onPointercancel"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</component>
|
</component>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, provide, ref, watchEffect } from "vue";
|
import { computed, onBeforeUnmount, provide, ref, watchEffect } from "vue";
|
||||||
import type { OriginScene, OriginSceneNode } from "../types";
|
import { originNodeScopeKey } from "../scene/scene";
|
||||||
import { originNodeScopeKey } from "../lifecycle";
|
import type { OriginScene, OriginSceneNode } from "../scene/types";
|
||||||
|
|
||||||
defineOptions({ name: "OriginNodeHost" });
|
defineOptions({ name: "OriginNodeHost" });
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onBeforeUnmount, ref, watchEffect } from "vue";
|
import { onBeforeUnmount, ref, watchEffect } from "vue";
|
||||||
import type { OriginSceneProps } from "../types";
|
import type { OriginSceneProps } from "../scene/types";
|
||||||
import OriginNodeHost from "./OriginNodeHost.vue";
|
import OriginNodeHost from "./OriginNodeHost.vue";
|
||||||
|
|
||||||
defineOptions({ name: "OriginScene" });
|
defineOptions({ name: "OriginScene" });
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
import { createApp, defineComponent, h, nextTick, type Component } from "vue";
|
import { createApp, defineComponent, h, nextTick, type Component } from "vue";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import OriginGestureSurface from "./components/OriginGestureSurface.vue";
|
import {
|
||||||
import OriginScene from "./components/OriginScene.vue";
|
back,
|
||||||
import { gesture, useOriginGesture } from "./gesture";
|
createOriginScene,
|
||||||
import { back, defineOriginChoreography, forward, replace } from "./motion";
|
defineOriginChoreography,
|
||||||
import { createOriginScene, originView } from "./scene";
|
forward,
|
||||||
import type {
|
gesture,
|
||||||
OriginGestureBinding,
|
OriginGestureSurface,
|
||||||
OriginGestureCompletionContext,
|
OriginScene,
|
||||||
} from "./types";
|
originView,
|
||||||
|
replace,
|
||||||
|
useOriginGesture,
|
||||||
|
type OriginGestureBinding,
|
||||||
|
type OriginGestureCompletionContext,
|
||||||
|
} from ".";
|
||||||
|
|
||||||
const mountedApps: Array<ReturnType<typeof createApp>> = [];
|
const mountedApps: Array<ReturnType<typeof createApp>> = [];
|
||||||
|
|
||||||
|
|||||||
@@ -1,582 +0,0 @@
|
|||||||
import type {
|
|
||||||
MaybeOriginAction,
|
|
||||||
OriginAction,
|
|
||||||
OriginContext,
|
|
||||||
OriginGestureBinding,
|
|
||||||
OriginGestureBuilder,
|
|
||||||
OriginGestureCompletionContext,
|
|
||||||
OriginGestureCompletionPredicate,
|
|
||||||
OriginGestureDefinition,
|
|
||||||
OriginGestureDirection,
|
|
||||||
OriginGestureDirectionOptions,
|
|
||||||
OriginGestureDistance,
|
|
||||||
OriginGestureEdge,
|
|
||||||
OriginGestureFromBuilder,
|
|
||||||
OriginGestureFromSelection,
|
|
||||||
OriginGestureNavigationBuilder,
|
|
||||||
OriginGestureNavigationFactory,
|
|
||||||
OriginGestureOptions,
|
|
||||||
OriginGesturePoint,
|
|
||||||
OriginGestureStart,
|
|
||||||
OriginGestureStartContext,
|
|
||||||
OriginGestureStartPredicate,
|
|
||||||
OriginGestureToBuilder,
|
|
||||||
OriginOperationHandle,
|
|
||||||
OriginRect,
|
|
||||||
} from "./types";
|
|
||||||
import { useOrigin } from "./lifecycle";
|
|
||||||
|
|
||||||
function ignoreGestureTarget(target: EventTarget | null) {
|
|
||||||
return (
|
|
||||||
!(target instanceof Element) ||
|
|
||||||
Boolean(
|
|
||||||
target.closest(
|
|
||||||
'[data-origin-gesture="ignore"], input, textarea, select, option, [contenteditable="true"]',
|
|
||||||
),
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function directedDistance(
|
|
||||||
direction: OriginGestureDirection,
|
|
||||||
dx: number,
|
|
||||||
dy: number,
|
|
||||||
) {
|
|
||||||
switch (direction) {
|
|
||||||
case "left":
|
|
||||||
return -dx;
|
|
||||||
case "right":
|
|
||||||
return dx;
|
|
||||||
case "up":
|
|
||||||
return -dy;
|
|
||||||
case "down":
|
|
||||||
return dy;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function rectOf(element: HTMLElement): OriginRect {
|
|
||||||
const rect = element.getBoundingClientRect();
|
|
||||||
return {
|
|
||||||
top: rect.top,
|
|
||||||
left: rect.left,
|
|
||||||
width: rect.width,
|
|
||||||
height: rect.height,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function pointOf(
|
|
||||||
event: Pick<PointerEvent, "clientX" | "clientY">,
|
|
||||||
bounds: OriginRect,
|
|
||||||
): OriginGesturePoint {
|
|
||||||
return {
|
|
||||||
clientX: event.clientX,
|
|
||||||
clientY: event.clientY,
|
|
||||||
localX: event.clientX - bounds.left,
|
|
||||||
localY: event.clientY - bounds.top,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve an arbitrary CSS length against a box with the gesture host's size.
|
|
||||||
*
|
|
||||||
* A short-lived off-screen box lets the browser handle `rem`, viewport units,
|
|
||||||
* percentages, `calc()`, and `clamp()` consistently. This runs only during
|
|
||||||
* pointer-down for edge-constrained definitions.
|
|
||||||
*/
|
|
||||||
function resolveCssDistance(
|
|
||||||
distance: OriginGestureDistance,
|
|
||||||
axis: "horizontal" | "vertical",
|
|
||||||
host: HTMLElement,
|
|
||||||
bounds: OriginRect,
|
|
||||||
) {
|
|
||||||
if (typeof distance === "number")
|
|
||||||
return Number.isFinite(distance) ? Math.max(0, distance) : 0;
|
|
||||||
|
|
||||||
const document = host.ownerDocument;
|
|
||||||
if (!document.body) return Math.max(0, Number.parseFloat(distance) || 0);
|
|
||||||
|
|
||||||
const container = document.createElement("div");
|
|
||||||
const probe = document.createElement("div");
|
|
||||||
Object.assign(container.style, {
|
|
||||||
position: "fixed",
|
|
||||||
left: "-100000px",
|
|
||||||
top: "-100000px",
|
|
||||||
width: `${bounds.width}px`,
|
|
||||||
height: `${bounds.height}px`,
|
|
||||||
visibility: "hidden",
|
|
||||||
pointerEvents: "none",
|
|
||||||
contain: "strict",
|
|
||||||
});
|
|
||||||
Object.assign(probe.style, {
|
|
||||||
position: "absolute",
|
|
||||||
width: axis === "horizontal" ? distance : "0",
|
|
||||||
height: axis === "vertical" ? distance : "0",
|
|
||||||
});
|
|
||||||
container.append(probe);
|
|
||||||
document.body.append(container);
|
|
||||||
const resolved =
|
|
||||||
axis === "horizontal"
|
|
||||||
? probe.getBoundingClientRect().width
|
|
||||||
: probe.getBoundingClientRect().height;
|
|
||||||
container.remove();
|
|
||||||
return Number.isFinite(resolved) ? Math.max(0, resolved) : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function matchesStart(
|
|
||||||
start: OriginGestureStart,
|
|
||||||
event: PointerEvent,
|
|
||||||
host: HTMLElement,
|
|
||||||
origin: OriginContext,
|
|
||||||
bounds: OriginRect,
|
|
||||||
) {
|
|
||||||
if (start.kind === "anywhere") return true;
|
|
||||||
const point = pointOf(event, bounds);
|
|
||||||
if (start.kind === "when") {
|
|
||||||
const context: OriginGestureStartContext = {
|
|
||||||
event,
|
|
||||||
origin,
|
|
||||||
host,
|
|
||||||
bounds,
|
|
||||||
point,
|
|
||||||
};
|
|
||||||
return start.predicate(context);
|
|
||||||
}
|
|
||||||
|
|
||||||
const horizontal = start.edge === "left" || start.edge === "right";
|
|
||||||
const distance = resolveCssDistance(
|
|
||||||
start.distance,
|
|
||||||
horizontal ? "horizontal" : "vertical",
|
|
||||||
host,
|
|
||||||
bounds,
|
|
||||||
);
|
|
||||||
switch (start.edge) {
|
|
||||||
case "left":
|
|
||||||
return point.localX >= 0 && point.localX <= distance;
|
|
||||||
case "right":
|
|
||||||
return (
|
|
||||||
point.localX <= bounds.width && bounds.width - point.localX <= distance
|
|
||||||
);
|
|
||||||
case "top":
|
|
||||||
return point.localY >= 0 && point.localY <= distance;
|
|
||||||
case "bottom":
|
|
||||||
return (
|
|
||||||
point.localY <= bounds.height &&
|
|
||||||
bounds.height - point.localY <= distance
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function edgeStart(
|
|
||||||
edge: OriginGestureEdge,
|
|
||||||
distance: OriginGestureDistance,
|
|
||||||
): OriginGestureStart {
|
|
||||||
return Object.freeze({ kind: "edge", edge, distance });
|
|
||||||
}
|
|
||||||
|
|
||||||
function createNavigationBuilder(
|
|
||||||
start: OriginGestureStart,
|
|
||||||
direction: OriginGestureDirection,
|
|
||||||
recognition: Readonly<OriginGestureDirectionOptions>,
|
|
||||||
completion: OriginGestureCompletionPredicate | undefined,
|
|
||||||
navigation: OriginGestureNavigationFactory,
|
|
||||||
): OriginGestureNavigationBuilder {
|
|
||||||
return Object.freeze({
|
|
||||||
animate(choreography: OriginGestureDefinition["choreography"]) {
|
|
||||||
return Object.freeze({
|
|
||||||
kind: "origin-gesture-definition",
|
|
||||||
start,
|
|
||||||
direction,
|
|
||||||
recognition,
|
|
||||||
completion,
|
|
||||||
navigation,
|
|
||||||
choreography,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function createDirectedBuilder(
|
|
||||||
start: OriginGestureStart,
|
|
||||||
direction: OriginGestureDirection,
|
|
||||||
options: OriginGestureDirectionOptions = {},
|
|
||||||
) {
|
|
||||||
const recognition = Object.freeze({ ...options });
|
|
||||||
const navigate = (
|
|
||||||
navigation: OriginGestureNavigationFactory,
|
|
||||||
completion?: OriginGestureCompletionPredicate,
|
|
||||||
) =>
|
|
||||||
createNavigationBuilder(
|
|
||||||
start,
|
|
||||||
direction,
|
|
||||||
recognition,
|
|
||||||
completion,
|
|
||||||
navigation,
|
|
||||||
);
|
|
||||||
|
|
||||||
return Object.freeze({
|
|
||||||
complete(completion: OriginGestureCompletionPredicate) {
|
|
||||||
return Object.freeze({
|
|
||||||
navigate: (navigation: OriginGestureNavigationFactory) =>
|
|
||||||
navigate(navigation, completion),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
navigate,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function createToBuilder(start: OriginGestureStart): OriginGestureToBuilder {
|
|
||||||
return Object.freeze({
|
|
||||||
left: (options?: OriginGestureDirectionOptions) =>
|
|
||||||
createDirectedBuilder(start, "left", options),
|
|
||||||
right: (options?: OriginGestureDirectionOptions) =>
|
|
||||||
createDirectedBuilder(start, "right", options),
|
|
||||||
up: (options?: OriginGestureDirectionOptions) =>
|
|
||||||
createDirectedBuilder(start, "up", options),
|
|
||||||
down: (options?: OriginGestureDirectionOptions) =>
|
|
||||||
createDirectedBuilder(start, "down", options),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectStart(start: OriginGestureStart): OriginGestureFromSelection {
|
|
||||||
return Object.freeze({ to: createToBuilder(Object.freeze(start)) });
|
|
||||||
}
|
|
||||||
|
|
||||||
const fromBuilder: OriginGestureFromBuilder = Object.freeze({
|
|
||||||
left: (distance: OriginGestureDistance) =>
|
|
||||||
selectStart(edgeStart("left", distance)),
|
|
||||||
right: (distance: OriginGestureDistance) =>
|
|
||||||
selectStart(edgeStart("right", distance)),
|
|
||||||
top: (distance: OriginGestureDistance) =>
|
|
||||||
selectStart(edgeStart("top", distance)),
|
|
||||||
bottom: (distance: OriginGestureDistance) =>
|
|
||||||
selectStart(edgeStart("bottom", distance)),
|
|
||||||
anywhere: () => selectStart({ kind: "anywhere" }),
|
|
||||||
when: (predicate: OriginGestureStartPredicate) =>
|
|
||||||
selectStart({ kind: "when", predicate }),
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Root of the immutable gesture builder.
|
|
||||||
*
|
|
||||||
* `.from` is optional. Starting at `.to` admits pointer-down anywhere on the
|
|
||||||
* bound host, exactly like `.from.anywhere().to`.
|
|
||||||
*
|
|
||||||
* @example Anywhere-to-right back gesture
|
|
||||||
* ```ts
|
|
||||||
* const swipeBack = gesture
|
|
||||||
* .to.right()
|
|
||||||
* .navigate((context) => context.canGoBack ? back() : null)
|
|
||||||
* .animate(slideRight);
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* @example Predicate-gated gesture with custom completion
|
|
||||||
* ```ts
|
|
||||||
* const openPanel = gesture
|
|
||||||
* .from.when(({ point, bounds }) => point.localX <= bounds.width * 0.08)
|
|
||||||
* .to.down()
|
|
||||||
* .complete(({ progress, velocity }) => progress > 0.5 || velocity > 1)
|
|
||||||
* .navigate(() => above(originView(PanelView)))
|
|
||||||
* .animate(dropPanel);
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export const gesture: OriginGestureBuilder = Object.freeze({
|
|
||||||
from: fromBuilder,
|
|
||||||
to: createToBuilder(Object.freeze({ kind: "anywhere" })),
|
|
||||||
});
|
|
||||||
|
|
||||||
function isDefinition(
|
|
||||||
value: OriginGestureOptions | OriginGestureDefinition,
|
|
||||||
): value is OriginGestureDefinition {
|
|
||||||
return "kind" in value && value.kind === "origin-gesture-definition";
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RuntimeGesturePolicy {
|
|
||||||
readonly direction: OriginGestureDirection;
|
|
||||||
readonly start: OriginGestureStart;
|
|
||||||
readonly threshold: number;
|
|
||||||
readonly axisDominance: number;
|
|
||||||
readonly completion?: OriginGestureCompletionPredicate;
|
|
||||||
readonly action: (context: OriginContext) => MaybeOriginAction;
|
|
||||||
}
|
|
||||||
|
|
||||||
function legacyStart(options: OriginGestureOptions): OriginGestureStart {
|
|
||||||
if (options.edge === undefined) return { kind: "anywhere" };
|
|
||||||
switch (options.direction) {
|
|
||||||
case "left":
|
|
||||||
return edgeStart("right", options.edge);
|
|
||||||
case "right":
|
|
||||||
return edgeStart("left", options.edge);
|
|
||||||
case "up":
|
|
||||||
return edgeStart("bottom", options.edge);
|
|
||||||
case "down":
|
|
||||||
return edgeStart("top", options.edge);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function runtimePolicy(
|
|
||||||
options: OriginGestureOptions | OriginGestureDefinition,
|
|
||||||
): RuntimeGesturePolicy {
|
|
||||||
if (!isDefinition(options)) {
|
|
||||||
return {
|
|
||||||
direction: options.direction,
|
|
||||||
start: legacyStart(options),
|
|
||||||
threshold: options.threshold ?? 8,
|
|
||||||
axisDominance: 1.15,
|
|
||||||
action: options.action,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
direction: options.direction,
|
|
||||||
start: options.start,
|
|
||||||
threshold: options.recognition.threshold ?? 8,
|
|
||||||
axisDominance: options.recognition.axisDominance ?? 1.15,
|
|
||||||
completion: options.completion,
|
|
||||||
action: async (context) => {
|
|
||||||
const navigation = await options.navigation(context);
|
|
||||||
if (!navigation) return navigation;
|
|
||||||
const action: OriginAction = {
|
|
||||||
...navigation,
|
|
||||||
choreography: options.choreography,
|
|
||||||
};
|
|
||||||
return action;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Install a component-owned pointer recognizer.
|
|
||||||
*
|
|
||||||
* Recognition is local to the element receiving these handlers. There is no
|
|
||||||
* application-wide gesture table and no lookup of a currently active view.
|
|
||||||
*
|
|
||||||
* Builder definitions may independently describe their pointer-down region
|
|
||||||
* and movement direction. Omitting `.from` recognizes pointer-down across the
|
|
||||||
* whole element. The legacy options object remains supported; its `edge` is
|
|
||||||
* inferred from the opposite side of its movement direction.
|
|
||||||
*
|
|
||||||
* Interactive controls and anything inside
|
|
||||||
* `[data-origin-gesture="ignore"]` are ignored automatically. The recognizer
|
|
||||||
* preserves native scrolling on the cross-axis through its returned style.
|
|
||||||
*
|
|
||||||
* @param definition - An immutable builder result or legacy recognizer options.
|
|
||||||
* @returns Pointer handlers and required host styles.
|
|
||||||
* @throws If called outside a component rendered by `OriginScene`.
|
|
||||||
*
|
|
||||||
* @example Builder-defined backward gesture
|
|
||||||
* ```ts
|
|
||||||
* const swipeBack = useOriginGesture(
|
|
||||||
* gesture
|
|
||||||
* .from.left("32px")
|
|
||||||
* .to.right()
|
|
||||||
* .navigate((context) => context.canGoBack ? back() : null)
|
|
||||||
* .animate(slideRight),
|
|
||||||
* );
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function useOriginGesture(
|
|
||||||
definition: OriginGestureDefinition | OriginGestureOptions,
|
|
||||||
): OriginGestureBinding {
|
|
||||||
const origin = useOrigin();
|
|
||||||
const policy = runtimePolicy(definition);
|
|
||||||
let pointerId = -1;
|
|
||||||
let element: HTMLElement | null = null;
|
|
||||||
let bounds: OriginRect | null = null;
|
|
||||||
let originContext: OriginContext | null = null;
|
|
||||||
let startPoint: OriginGesturePoint | null = null;
|
|
||||||
let startTime = 0;
|
|
||||||
let startX = 0;
|
|
||||||
let startY = 0;
|
|
||||||
let lastCoordinate = 0;
|
|
||||||
let lastTime = 0;
|
|
||||||
let captured = false;
|
|
||||||
let generation = 0;
|
|
||||||
let handlePromise: Promise<OriginOperationHandle | null> | null = null;
|
|
||||||
let bufferedProgress = 0;
|
|
||||||
let bufferedVelocity = 0;
|
|
||||||
let bufferedDistance = 0;
|
|
||||||
let bufferedCrossDistance = 0;
|
|
||||||
|
|
||||||
const horizontal =
|
|
||||||
policy.direction === "left" || policy.direction === "right";
|
|
||||||
|
|
||||||
function reset() {
|
|
||||||
pointerId = -1;
|
|
||||||
element = null;
|
|
||||||
bounds = null;
|
|
||||||
originContext = null;
|
|
||||||
startPoint = null;
|
|
||||||
captured = false;
|
|
||||||
handlePromise = null;
|
|
||||||
bufferedProgress = 0;
|
|
||||||
bufferedVelocity = 0;
|
|
||||||
bufferedDistance = 0;
|
|
||||||
bufferedCrossDistance = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function onPointerdown(event: PointerEvent) {
|
|
||||||
const current = event.currentTarget;
|
|
||||||
if (
|
|
||||||
!event.isPrimary ||
|
|
||||||
event.button !== 0 ||
|
|
||||||
!(current instanceof HTMLElement) ||
|
|
||||||
ignoreGestureTarget(event.target)
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
|
|
||||||
const nextBounds = rectOf(current);
|
|
||||||
const nextOriginContext = origin.context.value;
|
|
||||||
if (
|
|
||||||
!matchesStart(policy.start, event, current, nextOriginContext, nextBounds)
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
|
|
||||||
// The component containing this declaration is the operation's origin.
|
|
||||||
event.stopPropagation();
|
|
||||||
generation += 1;
|
|
||||||
pointerId = event.pointerId;
|
|
||||||
element = current;
|
|
||||||
bounds = nextBounds;
|
|
||||||
originContext = nextOriginContext;
|
|
||||||
startPoint = pointOf(event, nextBounds);
|
|
||||||
startTime = event.timeStamp;
|
|
||||||
startX = event.clientX;
|
|
||||||
startY = event.clientY;
|
|
||||||
lastCoordinate = horizontal ? event.clientX : event.clientY;
|
|
||||||
lastTime = event.timeStamp;
|
|
||||||
captured = false;
|
|
||||||
handlePromise = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateMetrics(event: PointerEvent, release = false) {
|
|
||||||
if (!element) return;
|
|
||||||
const dx = event.clientX - startX;
|
|
||||||
const dy = event.clientY - startY;
|
|
||||||
bufferedDistance = directedDistance(policy.direction, dx, dy);
|
|
||||||
bufferedCrossDistance = horizontal ? Math.abs(dy) : Math.abs(dx);
|
|
||||||
const size = Math.max(
|
|
||||||
1,
|
|
||||||
horizontal ? element.clientWidth : element.clientHeight,
|
|
||||||
);
|
|
||||||
const coordinate = horizontal ? event.clientX : event.clientY;
|
|
||||||
const coordinateDelta =
|
|
||||||
policy.direction === "left" || policy.direction === "up"
|
|
||||||
? lastCoordinate - coordinate
|
|
||||||
: coordinate - lastCoordinate;
|
|
||||||
const rawElapsed = event.timeStamp - lastTime;
|
|
||||||
const elapsed = Math.max(8, rawElapsed);
|
|
||||||
bufferedProgress = Math.max(0, Math.min(1, bufferedDistance / size));
|
|
||||||
/*
|
|
||||||
* Pointer-up commonly repeats the final pointer-move coordinate. Preserve
|
|
||||||
* that move's flick velocity for a prompt release, but decay it when the
|
|
||||||
* pointer was held still long enough for the flick to have ended.
|
|
||||||
*/
|
|
||||||
if (!release || coordinateDelta !== 0 || rawElapsed > 80)
|
|
||||||
bufferedVelocity = (coordinateDelta * 1000) / (elapsed * size);
|
|
||||||
lastCoordinate = coordinate;
|
|
||||||
lastTime = event.timeStamp;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onPointermove(event: PointerEvent) {
|
|
||||||
if (event.pointerId !== pointerId || !element) return;
|
|
||||||
const dx = event.clientX - startX;
|
|
||||||
const dy = event.clientY - startY;
|
|
||||||
const distance = directedDistance(policy.direction, dx, dy);
|
|
||||||
const crossDistance = horizontal ? Math.abs(dy) : Math.abs(dx);
|
|
||||||
let metricsUpdated = false;
|
|
||||||
|
|
||||||
if (!captured) {
|
|
||||||
if (
|
|
||||||
distance < policy.threshold ||
|
|
||||||
distance < crossDistance * policy.axisDominance
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
captured = true;
|
|
||||||
element.setPointerCapture?.(pointerId);
|
|
||||||
event.preventDefault();
|
|
||||||
updateMetrics(event);
|
|
||||||
metricsUpdated = true;
|
|
||||||
const recognitionGeneration = generation;
|
|
||||||
const action = await Promise.resolve(
|
|
||||||
policy.action(origin.context.value),
|
|
||||||
).catch(() => null);
|
|
||||||
// An asynchronous target resolver may finish after the pointer was
|
|
||||||
// released or cancelled. It must not create an orphan scene operation.
|
|
||||||
if (recognitionGeneration !== generation || event.pointerId !== pointerId)
|
|
||||||
return;
|
|
||||||
if (!action) return reset();
|
|
||||||
handlePromise = origin.begin(action).catch(() => null);
|
|
||||||
}
|
|
||||||
|
|
||||||
event.preventDefault();
|
|
||||||
if (!metricsUpdated) updateMetrics(event);
|
|
||||||
const pending = handlePromise;
|
|
||||||
const handle = pending ? await pending : null;
|
|
||||||
if (pending === handlePromise)
|
|
||||||
handle?.update(bufferedProgress, bufferedVelocity);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onPointerup(event: PointerEvent) {
|
|
||||||
if (event.pointerId !== pointerId) return;
|
|
||||||
if (captured) updateMetrics(event, true);
|
|
||||||
|
|
||||||
const pending = handlePromise;
|
|
||||||
const shouldFinish = captured;
|
|
||||||
const progress = bufferedProgress;
|
|
||||||
const velocity = bufferedVelocity;
|
|
||||||
const completion =
|
|
||||||
shouldFinish &&
|
|
||||||
policy.completion &&
|
|
||||||
element &&
|
|
||||||
bounds &&
|
|
||||||
originContext &&
|
|
||||||
startPoint
|
|
||||||
? policy.completion({
|
|
||||||
origin: originContext,
|
|
||||||
direction: policy.direction,
|
|
||||||
progress,
|
|
||||||
velocity,
|
|
||||||
distance: bufferedDistance,
|
|
||||||
crossDistance: bufferedCrossDistance,
|
|
||||||
duration: Math.max(0, event.timeStamp - startTime),
|
|
||||||
event,
|
|
||||||
host: element,
|
|
||||||
bounds,
|
|
||||||
start: startPoint,
|
|
||||||
current: pointOf(event, bounds),
|
|
||||||
} satisfies OriginGestureCompletionContext)
|
|
||||||
: undefined;
|
|
||||||
generation += 1;
|
|
||||||
reset();
|
|
||||||
|
|
||||||
const handle = pending ? await pending : null;
|
|
||||||
if (!shouldFinish || !handle) return;
|
|
||||||
handle.update(progress, velocity);
|
|
||||||
await handle.finish(
|
|
||||||
completion === undefined ? undefined : { commit: completion },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onPointercancel() {
|
|
||||||
generation += 1;
|
|
||||||
const pending = handlePromise;
|
|
||||||
const shouldCancel = captured;
|
|
||||||
reset();
|
|
||||||
const handle = pending ? await pending : null;
|
|
||||||
if (shouldCancel) await handle?.cancel();
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
style: {
|
|
||||||
// Preserve native scrolling perpendicular to the declared gesture.
|
|
||||||
touchAction: horizontal ? "pan-y" : "pan-x",
|
|
||||||
// OriginGesture is commonly the root returned by a view component.
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
},
|
|
||||||
onPointerdown,
|
|
||||||
onPointermove: (event) => void onPointermove(event),
|
|
||||||
onPointerup: (event) => void onPointerup(event),
|
|
||||||
onPointercancel: () => void onPointercancel(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,524 @@
|
|||||||
|
import type { CSSProperties } from "vue";
|
||||||
|
import type { OriginChoreography } from "../choreography";
|
||||||
|
import type { MaybeOriginAction, OriginNavigationIntent } from "../navigation";
|
||||||
|
import type { OriginContext } from "../scene/types";
|
||||||
|
import type { OriginRect } from "../shared";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* What a gesture is: the public gesture types, component props, and the
|
||||||
|
* immutable builder that produces executable definitions. Running them is
|
||||||
|
* the recognizer's job.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Direction in which pointer movement advances operation progress. */
|
||||||
|
export type OriginGestureDirection = "left" | "right" | "up" | "down";
|
||||||
|
|
||||||
|
/** Physical side of the gesture host used to admit pointer-down. */
|
||||||
|
export type OriginGestureEdge = "left" | "right" | "top" | "bottom";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSS length accepted by an edge-based gesture start rule.
|
||||||
|
*
|
||||||
|
* Numbers are interpreted as CSS pixels. Strings may use normal CSS lengths,
|
||||||
|
* percentages, `calc()`, or `clamp()`, and are resolved against the gesture
|
||||||
|
* host when pointer-down occurs.
|
||||||
|
*/
|
||||||
|
export type OriginGestureDistance = number | string;
|
||||||
|
|
||||||
|
/** A pointer position measured in viewport and gesture-host coordinates. */
|
||||||
|
export interface OriginGesturePoint {
|
||||||
|
/** Viewport-relative horizontal position. */
|
||||||
|
readonly clientX: number;
|
||||||
|
/** Viewport-relative vertical position. */
|
||||||
|
readonly clientY: number;
|
||||||
|
/** Horizontal position relative to the gesture host's left edge. */
|
||||||
|
readonly localX: number;
|
||||||
|
/** Vertical position relative to the gesture host's top edge. */
|
||||||
|
readonly localY: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Context supplied to a custom `.from.when()` start predicate. */
|
||||||
|
export interface OriginGestureStartContext {
|
||||||
|
/** Native pointer-down event being considered. */
|
||||||
|
readonly event: PointerEvent;
|
||||||
|
/** Component instance from which the gesture would originate. */
|
||||||
|
readonly origin: OriginContext;
|
||||||
|
/** Element carrying the gesture's pointer handlers. */
|
||||||
|
readonly host: HTMLElement;
|
||||||
|
/** Gesture-host bounds captured at pointer-down. */
|
||||||
|
readonly bounds: OriginRect;
|
||||||
|
/** Pointer position at pointer-down. */
|
||||||
|
readonly point: OriginGesturePoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronous predicate deciding whether a pointer-down may become a gesture.
|
||||||
|
*
|
||||||
|
* Directional recognition still occurs later, after movement passes the
|
||||||
|
* configured intent threshold.
|
||||||
|
*/
|
||||||
|
export type OriginGestureStartPredicate = (
|
||||||
|
context: OriginGestureStartContext,
|
||||||
|
) => boolean;
|
||||||
|
|
||||||
|
/** Start policy stored in a completed gesture definition. */
|
||||||
|
export type OriginGestureStart =
|
||||||
|
| {
|
||||||
|
/** Recognize pointer-down anywhere on the host. */
|
||||||
|
readonly kind: "anywhere";
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/** Recognize pointer-down within a CSS distance of one host side. */
|
||||||
|
readonly kind: "edge";
|
||||||
|
readonly edge: OriginGestureEdge;
|
||||||
|
readonly distance: OriginGestureDistance;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/** Recognize pointer-down when application policy returns `true`. */
|
||||||
|
readonly kind: "when";
|
||||||
|
readonly predicate: OriginGestureStartPredicate;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
/** Handlers run, in order, when a release commits. See `.onCommit()`. */
|
||||||
|
readonly commitHandlers?: readonly OriginGestureCommitHandler[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Side effect run when a released gesture commits, such as haptic feedback.
|
||||||
|
*
|
||||||
|
* It runs once, synchronously at release, after the commit decision and
|
||||||
|
* before the settling animation. It receives the same context as a
|
||||||
|
* `.complete()` predicate. Errors are reported without interrupting the
|
||||||
|
* operation.
|
||||||
|
*/
|
||||||
|
export type OriginGestureCommitHandler = (
|
||||||
|
context: OriginGestureCompletionContext,
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
/** Direction-selection stage shared by `gesture.to` and `.from.*().to`. */
|
||||||
|
export interface OriginGestureToBuilder {
|
||||||
|
/** Recognize leftward pointer movement. */
|
||||||
|
left(options?: OriginGestureDirectionOptions): OriginGestureDirectedBuilder;
|
||||||
|
/** Recognize rightward pointer movement. */
|
||||||
|
right(options?: OriginGestureDirectionOptions): OriginGestureDirectedBuilder;
|
||||||
|
/** Recognize upward pointer movement. */
|
||||||
|
up(options?: OriginGestureDirectionOptions): OriginGestureDirectedBuilder;
|
||||||
|
/** Recognize downward pointer movement. */
|
||||||
|
down(options?: OriginGestureDirectionOptions): OriginGestureDirectedBuilder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stage produced after a `.from` policy has been selected. */
|
||||||
|
export interface OriginGestureFromSelection {
|
||||||
|
/** Select the direction that advances this gesture. */
|
||||||
|
readonly to: OriginGestureToBuilder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Optional pointer-down policy exposed at the start of a gesture chain. */
|
||||||
|
export interface OriginGestureFromBuilder {
|
||||||
|
/** Admit pointer-down within `distance` of the host's left edge. */
|
||||||
|
left(distance: OriginGestureDistance): OriginGestureFromSelection;
|
||||||
|
/** Admit pointer-down within `distance` of the host's right edge. */
|
||||||
|
right(distance: OriginGestureDistance): OriginGestureFromSelection;
|
||||||
|
/** Admit pointer-down within `distance` of the host's top edge. */
|
||||||
|
top(distance: OriginGestureDistance): OriginGestureFromSelection;
|
||||||
|
/** Admit pointer-down within `distance` of the host's bottom edge. */
|
||||||
|
bottom(distance: OriginGestureDistance): OriginGestureFromSelection;
|
||||||
|
/** Admit pointer-down anywhere on the gesture host. */
|
||||||
|
anywhere(): OriginGestureFromSelection;
|
||||||
|
/** Admit pointer-down when a synchronous application predicate succeeds. */
|
||||||
|
when(predicate: OriginGestureStartPredicate): OriginGestureFromSelection;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builder stage after direction is known and completion remains optional. */
|
||||||
|
export interface OriginGestureDirectedBuilder {
|
||||||
|
/** Override the choreography's default release decision. */
|
||||||
|
complete(
|
||||||
|
predicate: OriginGestureCompletionPredicate,
|
||||||
|
): OriginGestureCompletedBuilder;
|
||||||
|
/** Select the target/history mutation while retaining default completion. */
|
||||||
|
navigate(
|
||||||
|
factory: OriginGestureNavigationFactory,
|
||||||
|
): OriginGestureNavigationBuilder;
|
||||||
|
/** Run `handler` when a release commits. Handlers accumulate in order. */
|
||||||
|
onCommit(handler: OriginGestureCommitHandler): OriginGestureDirectedBuilder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builder stage after a custom completion policy has been selected. */
|
||||||
|
export interface OriginGestureCompletedBuilder {
|
||||||
|
/** Select the target/history mutation performed on commit. */
|
||||||
|
navigate(
|
||||||
|
factory: OriginGestureNavigationFactory,
|
||||||
|
): OriginGestureNavigationBuilder;
|
||||||
|
/** Run `handler` when a release commits. Handlers accumulate in order. */
|
||||||
|
onCommit(handler: OriginGestureCommitHandler): OriginGestureCompletedBuilder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Final builder stage waiting for visual choreography. */
|
||||||
|
export interface OriginGestureNavigationBuilder {
|
||||||
|
/** Attach visual choreography and produce an executable definition. */
|
||||||
|
animate(choreography: OriginChoreography): OriginGestureDefinition;
|
||||||
|
/** Run `handler` when a release commits. Handlers accumulate in order. */
|
||||||
|
onCommit(handler: OriginGestureCommitHandler): OriginGestureNavigationBuilder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Component props ----------------------------------------------------------
|
||||||
|
|
||||||
|
/** 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[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Builder ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Immutable fluent builder. Each stage is a frozen object holding the choices
|
||||||
|
* made so far in a frozen draft; the final `.animate()` produces a frozen
|
||||||
|
* definition. `.onCommit()` is available at every stage after the direction
|
||||||
|
* and returns the same stage with the handler appended.
|
||||||
|
*
|
||||||
|
* gesture[.from.<start>].to.<direction>()[.complete()].navigate().animate()
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Choices accumulated by the builder before `.animate()`. */
|
||||||
|
interface GestureDraft {
|
||||||
|
readonly start: OriginGestureStart;
|
||||||
|
readonly direction: OriginGestureDirection;
|
||||||
|
readonly recognition: Readonly<OriginGestureDirectionOptions>;
|
||||||
|
readonly completion?: OriginGestureCompletionPredicate;
|
||||||
|
readonly commitHandlers: readonly OriginGestureCommitHandler[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const withCommitHandler = <Draft extends GestureDraft>(
|
||||||
|
draft: Draft,
|
||||||
|
handler: OriginGestureCommitHandler,
|
||||||
|
): Draft =>
|
||||||
|
Object.freeze({
|
||||||
|
...draft,
|
||||||
|
commitHandlers: Object.freeze([...draft.commitHandlers, handler]),
|
||||||
|
});
|
||||||
|
|
||||||
|
function navigationStage(
|
||||||
|
draft: GestureDraft & { readonly navigation: OriginGestureNavigationFactory },
|
||||||
|
): OriginGestureNavigationBuilder {
|
||||||
|
return Object.freeze({
|
||||||
|
animate: (choreography: OriginChoreography): OriginGestureDefinition =>
|
||||||
|
Object.freeze({
|
||||||
|
kind: "origin-gesture-definition",
|
||||||
|
start: draft.start,
|
||||||
|
direction: draft.direction,
|
||||||
|
recognition: draft.recognition,
|
||||||
|
completion: draft.completion,
|
||||||
|
navigation: draft.navigation,
|
||||||
|
choreography,
|
||||||
|
commitHandlers: draft.commitHandlers,
|
||||||
|
}),
|
||||||
|
onCommit: (handler: OriginGestureCommitHandler) =>
|
||||||
|
navigationStage(withCommitHandler(draft, handler)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const navigateFrom =
|
||||||
|
(draft: GestureDraft) => (navigation: OriginGestureNavigationFactory) =>
|
||||||
|
navigationStage(Object.freeze({ ...draft, navigation }));
|
||||||
|
|
||||||
|
function completedStage(draft: GestureDraft): OriginGestureCompletedBuilder {
|
||||||
|
return Object.freeze({
|
||||||
|
navigate: navigateFrom(draft),
|
||||||
|
onCommit: (handler: OriginGestureCommitHandler) =>
|
||||||
|
completedStage(withCommitHandler(draft, handler)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function directedStage(draft: GestureDraft): OriginGestureDirectedBuilder {
|
||||||
|
return Object.freeze({
|
||||||
|
complete: (completion: OriginGestureCompletionPredicate) =>
|
||||||
|
completedStage(Object.freeze({ ...draft, completion })),
|
||||||
|
navigate: navigateFrom(draft),
|
||||||
|
onCommit: (handler: OriginGestureCommitHandler) =>
|
||||||
|
directedStage(withCommitHandler(draft, handler)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toStage(start: OriginGestureStart): OriginGestureToBuilder {
|
||||||
|
const direct =
|
||||||
|
(direction: OriginGestureDirection) =>
|
||||||
|
(options: OriginGestureDirectionOptions = {}) =>
|
||||||
|
directedStage(
|
||||||
|
Object.freeze({
|
||||||
|
start,
|
||||||
|
direction,
|
||||||
|
recognition: Object.freeze({ ...options }),
|
||||||
|
commitHandlers: Object.freeze([]),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return Object.freeze({
|
||||||
|
left: direct("left"),
|
||||||
|
right: direct("right"),
|
||||||
|
up: direct("up"),
|
||||||
|
down: direct("down"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromStage(start: OriginGestureStart): OriginGestureFromSelection {
|
||||||
|
return Object.freeze({ to: toStage(Object.freeze(start)) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromEdge =
|
||||||
|
(edge: OriginGestureEdge) => (distance: OriginGestureDistance) =>
|
||||||
|
fromStage({ kind: "edge", edge, distance });
|
||||||
|
|
||||||
|
const fromBuilder: OriginGestureFromBuilder = Object.freeze({
|
||||||
|
left: fromEdge("left"),
|
||||||
|
right: fromEdge("right"),
|
||||||
|
top: fromEdge("top"),
|
||||||
|
bottom: fromEdge("bottom"),
|
||||||
|
anywhere: () => fromStage({ kind: "anywhere" }),
|
||||||
|
when: (predicate: OriginGestureStartPredicate) =>
|
||||||
|
fromStage({ kind: "when", predicate }),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Root of the immutable gesture builder.
|
||||||
|
*
|
||||||
|
* `.from` is optional. Starting at `.to` admits pointer-down anywhere on the
|
||||||
|
* bound host, exactly like `.from.anywhere().to`.
|
||||||
|
*
|
||||||
|
* @example Anywhere-to-right back gesture
|
||||||
|
* ```ts
|
||||||
|
* const swipeBack = gesture
|
||||||
|
* .to.right()
|
||||||
|
* .navigate((context) => context.canGoBack ? back() : null)
|
||||||
|
* .animate(slideRight);
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @example Predicate-gated gesture with custom completion
|
||||||
|
* ```ts
|
||||||
|
* const openPanel = gesture
|
||||||
|
* .from.when(({ point, bounds }) => point.localX <= bounds.width * 0.08)
|
||||||
|
* .to.down()
|
||||||
|
* .complete(({ progress, velocity }) => progress > 0.5 || velocity > 1)
|
||||||
|
* .navigate(() => above(originView(PanelView)))
|
||||||
|
* .animate(dropPanel);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const gesture: OriginGestureBuilder = Object.freeze({
|
||||||
|
from: fromBuilder,
|
||||||
|
to: toStage(Object.freeze({ kind: "anywhere" })),
|
||||||
|
});
|
||||||
@@ -0,0 +1,611 @@
|
|||||||
|
import type { OriginAction, MaybeOriginAction } from "../navigation";
|
||||||
|
import { useOrigin, type UseOrigin } from "../scene/scene";
|
||||||
|
import type { OriginContext, OriginOperationHandle } from "../scene/types";
|
||||||
|
import { clamp01, measureRect, type OriginRect } from "../shared";
|
||||||
|
import type {
|
||||||
|
OriginGestureBinding,
|
||||||
|
OriginGestureCommitHandler,
|
||||||
|
OriginGestureCompletionContext,
|
||||||
|
OriginGestureCompletionPredicate,
|
||||||
|
OriginGestureDefinition,
|
||||||
|
OriginGestureDirection,
|
||||||
|
OriginGestureDistance,
|
||||||
|
OriginGestureOptions,
|
||||||
|
OriginGesturePoint,
|
||||||
|
OriginGestureStart,
|
||||||
|
} from "./definition";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Running gestures: pointer-down admission, movement tracking, and the
|
||||||
|
* recognizer that turns one host's pointer events into a scene operation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Direction ----------------------------------------------------------------
|
||||||
|
|
||||||
|
export const isHorizontal = (direction: OriginGestureDirection): boolean => {
|
||||||
|
return direction === "left" || direction === "right";
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Component of a movement `(dx, dy)` along `direction`. */
|
||||||
|
export const directedDistance = (
|
||||||
|
direction: OriginGestureDirection,
|
||||||
|
dx: number,
|
||||||
|
dy: number,
|
||||||
|
): number => {
|
||||||
|
switch (direction) {
|
||||||
|
case "left":
|
||||||
|
return -dx;
|
||||||
|
case "right":
|
||||||
|
return dx;
|
||||||
|
case "up":
|
||||||
|
return -dy;
|
||||||
|
case "down":
|
||||||
|
return dy;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Absolute movement perpendicular to `direction`. */
|
||||||
|
export const crossDistance = (
|
||||||
|
direction: OriginGestureDirection,
|
||||||
|
dx: number,
|
||||||
|
dy: number,
|
||||||
|
): number => {
|
||||||
|
return isHorizontal(direction) ? Math.abs(dy) : Math.abs(dx);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSS `touch-action` for a host recognizing the given directions: native
|
||||||
|
* scrolling is preserved on any axis no gesture claims.
|
||||||
|
*/
|
||||||
|
export const touchActionFor = (
|
||||||
|
directions: Iterable<OriginGestureDirection>,
|
||||||
|
): "none" | "pan-x" | "pan-y" | "auto" => {
|
||||||
|
let horizontal = false;
|
||||||
|
let vertical = false;
|
||||||
|
for (const direction of directions) {
|
||||||
|
if (isHorizontal(direction)) horizontal = true;
|
||||||
|
else vertical = true;
|
||||||
|
}
|
||||||
|
if (horizontal && vertical) return "none";
|
||||||
|
if (horizontal) return "pan-y";
|
||||||
|
if (vertical) return "pan-x";
|
||||||
|
return "auto";
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pointer-down admission ----------------------------------------------------
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Pointer-down admission: which events may begin a gesture at all.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const IGNORED_TARGETS =
|
||||||
|
'[data-origin-gesture="ignore"], input, textarea, select, option, [contenteditable="true"]';
|
||||||
|
|
||||||
|
/** Form controls and opted-out subtrees never start gestures. */
|
||||||
|
export const isIgnoredTarget = (target: EventTarget | null): boolean => {
|
||||||
|
return (
|
||||||
|
!(target instanceof Element) || Boolean(target.closest(IGNORED_TARGETS))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pointOf = (
|
||||||
|
event: Pick<PointerEvent, "clientX" | "clientY">,
|
||||||
|
bounds: OriginRect,
|
||||||
|
): OriginGesturePoint => {
|
||||||
|
return {
|
||||||
|
clientX: event.clientX,
|
||||||
|
clientY: event.clientY,
|
||||||
|
localX: event.clientX - bounds.left,
|
||||||
|
localY: event.clientY - bounds.top,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const nonNegative = (value: number) =>
|
||||||
|
Number.isFinite(value) ? Math.max(0, value) : 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
export const resolveCssDistance = (
|
||||||
|
distance: OriginGestureDistance,
|
||||||
|
axis: "horizontal" | "vertical",
|
||||||
|
host: HTMLElement,
|
||||||
|
bounds: OriginRect,
|
||||||
|
): number => {
|
||||||
|
if (typeof distance === "number") return nonNegative(distance);
|
||||||
|
|
||||||
|
const document = host.ownerDocument;
|
||||||
|
if (!document.body) return Math.max(0, Number.parseFloat(distance) || 0);
|
||||||
|
|
||||||
|
const container = document.createElement("div");
|
||||||
|
const probe = document.createElement("div");
|
||||||
|
Object.assign(container.style, {
|
||||||
|
position: "fixed",
|
||||||
|
left: "-100000px",
|
||||||
|
top: "-100000px",
|
||||||
|
width: `${bounds.width}px`,
|
||||||
|
height: `${bounds.height}px`,
|
||||||
|
visibility: "hidden",
|
||||||
|
pointerEvents: "none",
|
||||||
|
contain: "strict",
|
||||||
|
});
|
||||||
|
Object.assign(probe.style, {
|
||||||
|
position: "absolute",
|
||||||
|
width: axis === "horizontal" ? distance : "0",
|
||||||
|
height: axis === "vertical" ? distance : "0",
|
||||||
|
});
|
||||||
|
container.append(probe);
|
||||||
|
document.body.append(container);
|
||||||
|
const rect = probe.getBoundingClientRect();
|
||||||
|
container.remove();
|
||||||
|
return nonNegative(axis === "horizontal" ? rect.width : rect.height);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Whether a pointer-down satisfies a definition's start policy. */
|
||||||
|
export const matchesStart = (
|
||||||
|
start: OriginGestureStart,
|
||||||
|
event: PointerEvent,
|
||||||
|
host: HTMLElement,
|
||||||
|
origin: OriginContext,
|
||||||
|
bounds: OriginRect,
|
||||||
|
): boolean => {
|
||||||
|
if (start.kind === "anywhere") return true;
|
||||||
|
const point = pointOf(event, bounds);
|
||||||
|
if (start.kind === "when")
|
||||||
|
return start.predicate({ event, origin, host, bounds, point });
|
||||||
|
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Movement tracking ---------------------------------------------------------
|
||||||
|
|
||||||
|
type PointerSample = Pick<PointerEvent, "clientX" | "clientY" | "timeStamp">;
|
||||||
|
|
||||||
|
/** Shortest frame interval assumed when computing velocity, in ms. */
|
||||||
|
const MIN_SAMPLE_INTERVAL = 8;
|
||||||
|
/**
|
||||||
|
* A release without movement after this many ms is treated as a hold, so the
|
||||||
|
* last move's flick velocity no longer applies.
|
||||||
|
*/
|
||||||
|
const HOLD_RELEASE_INTERVAL = 80;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Measures one pointer sequence's movement along a gesture direction.
|
||||||
|
*
|
||||||
|
* Progress and velocity are normalized by the host's size along the gesture
|
||||||
|
* axis, so `1` means one full host width (or height).
|
||||||
|
*/
|
||||||
|
export class DirectionalTracker {
|
||||||
|
/** Directed distance from the start, in CSS pixels. */
|
||||||
|
distance = 0;
|
||||||
|
/** Absolute cross-axis distance from the start, in CSS pixels. */
|
||||||
|
crossDistance = 0;
|
||||||
|
/** Directed distance as a fraction of host size, clamped to `0..1`. */
|
||||||
|
progress = 0;
|
||||||
|
/** Latest directed velocity in progress units per second. */
|
||||||
|
velocity = 0;
|
||||||
|
|
||||||
|
private readonly horizontal: boolean;
|
||||||
|
/** Whether progress grows as the axis coordinate decreases. */
|
||||||
|
private readonly reversed: boolean;
|
||||||
|
private lastCoordinate: number;
|
||||||
|
private lastTime: number;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly direction: OriginGestureDirection,
|
||||||
|
private readonly start: PointerSample,
|
||||||
|
/** Host size along the gesture axis, read at every sample. */
|
||||||
|
private readonly axisSize: () => number,
|
||||||
|
) {
|
||||||
|
this.horizontal = isHorizontal(direction);
|
||||||
|
this.reversed = direction === "left" || direction === "up";
|
||||||
|
this.lastCoordinate = this.coordinateOf(start);
|
||||||
|
this.lastTime = start.timeStamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
private coordinateOf(event: PointerSample) {
|
||||||
|
return this.horizontal ? event.clientX : event.clientY;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Movement from the start without recording a sample. */
|
||||||
|
displacement(event: PointerSample) {
|
||||||
|
const dx = event.clientX - this.start.clientX;
|
||||||
|
const dy = event.clientY - this.start.clientY;
|
||||||
|
return {
|
||||||
|
distance: directedDistance(this.direction, dx, dy),
|
||||||
|
crossDistance: crossDistance(this.direction, dx, dy),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Record a sample, updating distance, progress, and velocity. */
|
||||||
|
sample(event: PointerSample, release = false): void {
|
||||||
|
const { distance, crossDistance } = this.displacement(event);
|
||||||
|
this.distance = distance;
|
||||||
|
this.crossDistance = crossDistance;
|
||||||
|
|
||||||
|
const size = Math.max(1, this.axisSize());
|
||||||
|
const coordinate = this.coordinateOf(event);
|
||||||
|
const step = this.reversed
|
||||||
|
? this.lastCoordinate - coordinate
|
||||||
|
: coordinate - this.lastCoordinate;
|
||||||
|
const interval = event.timeStamp - this.lastTime;
|
||||||
|
this.progress = clamp01(distance / size);
|
||||||
|
// Pointer-up commonly repeats the final pointer-move coordinate. Preserve
|
||||||
|
// that move's flick velocity for a prompt release, but decay it when the
|
||||||
|
// pointer was held still long enough for the flick to have ended.
|
||||||
|
if (!release || step !== 0 || interval > HOLD_RELEASE_INTERVAL)
|
||||||
|
this.velocity =
|
||||||
|
(step * 1000) / (Math.max(MIN_SAMPLE_INTERVAL, interval) * size);
|
||||||
|
|
||||||
|
this.lastCoordinate = coordinate;
|
||||||
|
this.lastTime = event.timeStamp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recognizer ----------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Directed movement in CSS pixels required before a gesture captures. */
|
||||||
|
const DEFAULT_THRESHOLD = 8;
|
||||||
|
/** Ratio by which directed movement must exceed cross-axis movement. */
|
||||||
|
const DEFAULT_AXIS_DOMINANCE = 1.15;
|
||||||
|
|
||||||
|
/** Legacy `edge` start side for each movement direction. */
|
||||||
|
const LEGACY_START_EDGE = {
|
||||||
|
left: "right",
|
||||||
|
right: "left",
|
||||||
|
up: "bottom",
|
||||||
|
down: "top",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single shape the recognizer executes. Builder definitions and legacy
|
||||||
|
* options objects are both normalized into it.
|
||||||
|
*/
|
||||||
|
export interface RecognizerPolicy {
|
||||||
|
readonly direction: OriginGestureDirection;
|
||||||
|
readonly start: OriginGestureStart;
|
||||||
|
readonly threshold: number;
|
||||||
|
readonly axisDominance: number;
|
||||||
|
readonly completion?: OriginGestureCompletionPredicate;
|
||||||
|
readonly commitHandlers: readonly OriginGestureCommitHandler[];
|
||||||
|
readonly action: (context: OriginContext) => MaybeOriginAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The origin capabilities a recognizer needs. */
|
||||||
|
export type RecognizerOrigin = Pick<UseOrigin, "context" | "begin">;
|
||||||
|
|
||||||
|
/** State of one admitted pointer sequence, from pointer-down to release. */
|
||||||
|
interface PointerSession {
|
||||||
|
readonly pointerId: number;
|
||||||
|
readonly host: HTMLElement;
|
||||||
|
readonly bounds: OriginRect;
|
||||||
|
/** Origin context captured at pointer-down. */
|
||||||
|
readonly origin: OriginContext;
|
||||||
|
readonly start: OriginGesturePoint;
|
||||||
|
readonly startTime: number;
|
||||||
|
readonly tracker: DirectionalTracker;
|
||||||
|
/** Movement passed the recognition threshold and the pointer is captured. */
|
||||||
|
captured: boolean;
|
||||||
|
/** The scene operation, once the action resolved and `begin()` was called. */
|
||||||
|
operation: Promise<OriginOperationHandle | null> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns one host's pointer events into a scene operation.
|
||||||
|
*
|
||||||
|
* Lifecycle of a session:
|
||||||
|
*
|
||||||
|
* 1. **down**: an eligible primary pointer opens a session.
|
||||||
|
* 2. **move**: once directed movement passes the threshold, the pointer is
|
||||||
|
* captured and the policy's action is resolved; a non-null action begins
|
||||||
|
* an operation, while a declined one closes the session. Every later move
|
||||||
|
* updates the operation's progress.
|
||||||
|
* 3. **up**: the session closes and the operation finishes, using the
|
||||||
|
* policy's completion predicate when present.
|
||||||
|
* 4. **cancel**: the session closes and the operation is cancelled.
|
||||||
|
*
|
||||||
|
* Action resolution and `begin()` are asynchronous. Each continuation checks
|
||||||
|
* that its session is still current, so a stale resolution never creates or
|
||||||
|
* updates an orphan operation.
|
||||||
|
*/
|
||||||
|
export class GestureRecognizer {
|
||||||
|
private session: PointerSession | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
readonly policy: RecognizerPolicy,
|
||||||
|
private readonly origin: RecognizerOrigin,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Create a recognizer for a builder definition or legacy options. */
|
||||||
|
static create(
|
||||||
|
definition: OriginGestureDefinition | OriginGestureOptions,
|
||||||
|
origin: RecognizerOrigin,
|
||||||
|
): GestureRecognizer {
|
||||||
|
return new GestureRecognizer(
|
||||||
|
GestureRecognizer.policyFor(definition),
|
||||||
|
origin,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a builder definition or legacy options object into the single
|
||||||
|
* policy shape the recognizer executes.
|
||||||
|
*/
|
||||||
|
static policyFor(
|
||||||
|
definition: OriginGestureDefinition | OriginGestureOptions,
|
||||||
|
): RecognizerPolicy {
|
||||||
|
return "kind" in definition &&
|
||||||
|
definition.kind === "origin-gesture-definition"
|
||||||
|
? GestureRecognizer.policyFromDefinition(definition)
|
||||||
|
: GestureRecognizer.policyFromOptions(definition as OriginGestureOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static policyFromDefinition(
|
||||||
|
definition: OriginGestureDefinition,
|
||||||
|
): RecognizerPolicy {
|
||||||
|
return {
|
||||||
|
direction: definition.direction,
|
||||||
|
start: definition.start,
|
||||||
|
threshold: definition.recognition.threshold ?? DEFAULT_THRESHOLD,
|
||||||
|
axisDominance:
|
||||||
|
definition.recognition.axisDominance ?? DEFAULT_AXIS_DOMINANCE,
|
||||||
|
completion: definition.completion,
|
||||||
|
commitHandlers: definition.commitHandlers ?? [],
|
||||||
|
// Pair the resolved navigation intent with the definition's animation.
|
||||||
|
action: async (context) => {
|
||||||
|
const navigation = await definition.navigation(context);
|
||||||
|
if (!navigation) return navigation;
|
||||||
|
const action: OriginAction = {
|
||||||
|
...navigation,
|
||||||
|
choreography: definition.choreography,
|
||||||
|
};
|
||||||
|
return action;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legacy `edge` is measured from the side opposite the movement. */
|
||||||
|
private static policyFromOptions(
|
||||||
|
options: OriginGestureOptions,
|
||||||
|
): RecognizerPolicy {
|
||||||
|
return {
|
||||||
|
direction: options.direction,
|
||||||
|
start:
|
||||||
|
options.edge === undefined
|
||||||
|
? { kind: "anywhere" }
|
||||||
|
: Object.freeze({
|
||||||
|
kind: "edge",
|
||||||
|
edge: LEGACY_START_EDGE[options.direction],
|
||||||
|
distance: options.edge,
|
||||||
|
}),
|
||||||
|
threshold: options.threshold ?? DEFAULT_THRESHOLD,
|
||||||
|
axisDominance: DEFAULT_AXIS_DOMINANCE,
|
||||||
|
action: options.action,
|
||||||
|
commitHandlers: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pointerDown(event: PointerEvent): void {
|
||||||
|
const host = event.currentTarget;
|
||||||
|
if (
|
||||||
|
!event.isPrimary ||
|
||||||
|
event.button !== 0 ||
|
||||||
|
!(host instanceof HTMLElement) ||
|
||||||
|
isIgnoredTarget(event.target)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const bounds = measureRect(host);
|
||||||
|
const origin = this.origin.context.value;
|
||||||
|
if (!matchesStart(this.policy.start, event, host, origin, bounds)) return;
|
||||||
|
|
||||||
|
// The component containing this declaration is the operation's origin.
|
||||||
|
event.stopPropagation();
|
||||||
|
const horizontal = isHorizontal(this.policy.direction);
|
||||||
|
this.session = {
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
host,
|
||||||
|
bounds,
|
||||||
|
origin,
|
||||||
|
start: pointOf(event, bounds),
|
||||||
|
startTime: event.timeStamp,
|
||||||
|
tracker: new DirectionalTracker(this.policy.direction, event, () =>
|
||||||
|
horizontal ? host.clientWidth : host.clientHeight,
|
||||||
|
),
|
||||||
|
captured: false,
|
||||||
|
operation: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async pointerMove(event: PointerEvent): Promise<void> {
|
||||||
|
const session = this.sessionFor(event);
|
||||||
|
if (!session) return;
|
||||||
|
|
||||||
|
if (!session.captured) {
|
||||||
|
if (!this.recognizes(session, event)) return;
|
||||||
|
session.captured = true;
|
||||||
|
session.host.setPointerCapture?.(session.pointerId);
|
||||||
|
event.preventDefault();
|
||||||
|
session.tracker.sample(event);
|
||||||
|
const action = await this.resolveAction();
|
||||||
|
// The pointer may have been released or cancelled meanwhile.
|
||||||
|
if (this.session !== session) return;
|
||||||
|
if (!action) {
|
||||||
|
this.session = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
session.operation = this.origin.begin(action).catch(() => null);
|
||||||
|
} else {
|
||||||
|
event.preventDefault();
|
||||||
|
session.tracker.sample(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Progress is read after the await so the latest buffered sample wins.
|
||||||
|
const pending = session.operation;
|
||||||
|
const handle = pending ? await pending : null;
|
||||||
|
if (this.session === session && session.operation === pending)
|
||||||
|
handle?.update(session.tracker.progress, session.tracker.velocity);
|
||||||
|
}
|
||||||
|
|
||||||
|
async pointerUp(event: PointerEvent): Promise<void> {
|
||||||
|
const session = this.sessionFor(event);
|
||||||
|
if (!session) return;
|
||||||
|
this.session = null;
|
||||||
|
if (!session.captured) return;
|
||||||
|
|
||||||
|
const { tracker } = session;
|
||||||
|
tracker.sample(event, true);
|
||||||
|
const context: OriginGestureCompletionContext = {
|
||||||
|
origin: session.origin,
|
||||||
|
direction: this.policy.direction,
|
||||||
|
progress: tracker.progress,
|
||||||
|
velocity: tracker.velocity,
|
||||||
|
distance: tracker.distance,
|
||||||
|
crossDistance: tracker.crossDistance,
|
||||||
|
duration: Math.max(0, event.timeStamp - session.startTime),
|
||||||
|
event,
|
||||||
|
host: session.host,
|
||||||
|
bounds: session.bounds,
|
||||||
|
start: session.start,
|
||||||
|
current: pointOf(event, session.bounds),
|
||||||
|
};
|
||||||
|
const commit = this.policy.completion?.(context);
|
||||||
|
const { progress, velocity } = tracker;
|
||||||
|
|
||||||
|
const handle = session.operation ? await session.operation : null;
|
||||||
|
if (!handle) return;
|
||||||
|
handle.update(progress, velocity);
|
||||||
|
// finish() fixes the outcome synchronously, before the spring settles.
|
||||||
|
const settled = handle.finish(
|
||||||
|
commit === undefined ? undefined : { commit },
|
||||||
|
);
|
||||||
|
if (handle.intent === "commit") this.notifyCommit(context);
|
||||||
|
await settled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run commit handlers; a failing handler never interrupts the others. */
|
||||||
|
private notifyCommit(context: OriginGestureCompletionContext) {
|
||||||
|
for (const handler of this.policy.commitHandlers) {
|
||||||
|
try {
|
||||||
|
handler(context);
|
||||||
|
} catch (error) {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async pointerCancel(): Promise<void> {
|
||||||
|
const session = this.session;
|
||||||
|
this.session = null;
|
||||||
|
if (!session?.captured) return;
|
||||||
|
const handle = session.operation ? await session.operation : null;
|
||||||
|
await handle?.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sessionFor(event: PointerEvent) {
|
||||||
|
const session = this.session;
|
||||||
|
return session && session.pointerId === event.pointerId ? session : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Directed movement passed the threshold and dominates the cross axis. */
|
||||||
|
private recognizes(session: PointerSession, event: PointerEvent) {
|
||||||
|
const { distance, crossDistance } = session.tracker.displacement(event);
|
||||||
|
return (
|
||||||
|
distance >= this.policy.threshold &&
|
||||||
|
distance >= crossDistance * this.policy.axisDominance
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the policy's action against the origin's current context. A
|
||||||
|
* declined or failed action resolves to `null`.
|
||||||
|
*/
|
||||||
|
private resolveAction(): Promise<OriginAction | null | undefined> {
|
||||||
|
try {
|
||||||
|
return Promise.resolve(
|
||||||
|
this.policy.action(this.origin.context.value),
|
||||||
|
).catch(() => null);
|
||||||
|
} catch {
|
||||||
|
return Promise.resolve(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Composable ----------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 recognizer = GestureRecognizer.create(definition, useOrigin());
|
||||||
|
|
||||||
|
return {
|
||||||
|
style: {
|
||||||
|
// Preserve native scrolling perpendicular to the declared gesture.
|
||||||
|
touchAction: touchActionFor([recognizer.policy.direction]),
|
||||||
|
// OriginGesture is commonly the root returned by a view component.
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
},
|
||||||
|
onPointerdown: (event) => recognizer.pointerDown(event),
|
||||||
|
onPointermove: (event) => void recognizer.pointerMove(event),
|
||||||
|
onPointerup: (event) => void recognizer.pointerUp(event),
|
||||||
|
onPointercancel: () => void recognizer.pointerCancel(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
/**
|
||||||
|
* Where an application can trigger haptic feedback with the current API.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
createApp,
|
||||||
|
defineComponent,
|
||||||
|
h,
|
||||||
|
nextTick,
|
||||||
|
watch,
|
||||||
|
type Component,
|
||||||
|
} from "vue";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
createOriginScene,
|
||||||
|
defineOriginChoreography,
|
||||||
|
forward,
|
||||||
|
gesture,
|
||||||
|
OriginScene,
|
||||||
|
originView,
|
||||||
|
useOriginGesture,
|
||||||
|
type OriginGestureDefinition,
|
||||||
|
} from ".";
|
||||||
|
|
||||||
|
const apps: Array<ReturnType<typeof createApp>> = [];
|
||||||
|
afterEach(() => {
|
||||||
|
for (const app of apps.splice(0)) app.unmount();
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const motion = defineOriginChoreography({
|
||||||
|
name: "haptics-test",
|
||||||
|
effects: ({ progress }) => ({ target: { opacity: progress } }),
|
||||||
|
});
|
||||||
|
const Target: Component = defineComponent({ render: () => h("div", "T") });
|
||||||
|
|
||||||
|
function pointer(type: string, clientX: number, timeStamp: number) {
|
||||||
|
const event = new PointerEvent(type, {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
button: 0,
|
||||||
|
isPrimary: true,
|
||||||
|
pointerId: 1,
|
||||||
|
clientX,
|
||||||
|
clientY: 100,
|
||||||
|
});
|
||||||
|
Object.defineProperty(event, "timeStamp", { value: timeStamp });
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flush() {
|
||||||
|
for (let i = 0; i < 4; i += 1) await new Promise((r) => setTimeout(r, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mount a scene whose root installs `definition`, then return its host. */
|
||||||
|
async function mount(definition: OriginGestureDefinition) {
|
||||||
|
// Reduced motion settles instantly, so release outcomes apply at once.
|
||||||
|
vi.stubGlobal("matchMedia", () => ({ matches: true }));
|
||||||
|
const Root = defineComponent({
|
||||||
|
setup() {
|
||||||
|
const binding = useOriginGesture(definition);
|
||||||
|
return () =>
|
||||||
|
h("div", {
|
||||||
|
id: "host",
|
||||||
|
style: binding.style,
|
||||||
|
onPointerdown: binding.onPointerdown,
|
||||||
|
onPointermove: binding.onPointermove,
|
||||||
|
onPointerup: binding.onPointerup,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const scene = createOriginScene({ initial: originView(Root) });
|
||||||
|
const root = document.createElement("div");
|
||||||
|
document.body.append(root);
|
||||||
|
const app = createApp({ render: () => h(OriginScene, { scene }) });
|
||||||
|
apps.push(app);
|
||||||
|
app.mount(root);
|
||||||
|
await nextTick();
|
||||||
|
const host = root.querySelector("#host") as HTMLElement;
|
||||||
|
Object.defineProperty(host, "clientWidth", { value: 200 });
|
||||||
|
host.getBoundingClientRect = () =>
|
||||||
|
({ top: 0, left: 0, width: 200, height: 400 }) as DOMRect;
|
||||||
|
return { scene, host };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Swipe left by `distance` px, slowly enough that velocity cannot commit. */
|
||||||
|
async function swipe(host: HTMLElement, distance: number, log: string[]) {
|
||||||
|
host.dispatchEvent(pointer("pointerdown", 190, 0));
|
||||||
|
host.dispatchEvent(pointer("pointermove", 180, 500));
|
||||||
|
await flush();
|
||||||
|
log.push("recognized");
|
||||||
|
host.dispatchEvent(pointer("pointermove", 190 - distance, 1000));
|
||||||
|
await flush();
|
||||||
|
host.dispatchEvent(pointer("pointerup", 190 - distance, 1000));
|
||||||
|
await flush();
|
||||||
|
log.push("released");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("haptic feedback", () => {
|
||||||
|
it("can vibrate when a gesture is recognized, from .navigate()", async () => {
|
||||||
|
const log: string[] = [];
|
||||||
|
const vibrate = vi.fn(() => log.push("vibrate") > 0);
|
||||||
|
vi.stubGlobal("navigator", { ...navigator, vibrate });
|
||||||
|
|
||||||
|
const { host } = await mount(
|
||||||
|
gesture.to
|
||||||
|
.left()
|
||||||
|
.navigate(() => {
|
||||||
|
navigator.vibrate?.(10);
|
||||||
|
return forward(originView(Target));
|
||||||
|
})
|
||||||
|
.animate(motion),
|
||||||
|
);
|
||||||
|
// A short swipe that is cancelled on release still vibrated, because
|
||||||
|
// .navigate() runs at recognition, before the outcome is known.
|
||||||
|
await swipe(host, 20, log);
|
||||||
|
expect(log).toEqual(["vibrate", "recognized", "released"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can vibrate only when a release commits, by watching operation intent", async () => {
|
||||||
|
const log: string[] = [];
|
||||||
|
const vibrate = vi.fn(() => log.push("vibrate") > 0);
|
||||||
|
vi.stubGlobal("navigator", { ...navigator, vibrate });
|
||||||
|
|
||||||
|
const { scene, host } = await mount(
|
||||||
|
gesture.to
|
||||||
|
.left()
|
||||||
|
.navigate(() => forward(originView(Target)))
|
||||||
|
.animate(motion),
|
||||||
|
);
|
||||||
|
// Intent becomes "commit" synchronously at release, before settling.
|
||||||
|
const seen = new Set<number>();
|
||||||
|
watch(
|
||||||
|
() => scene.operations.value.filter((o) => o.intent === "commit"),
|
||||||
|
(committed) => {
|
||||||
|
for (const operation of committed)
|
||||||
|
if (!seen.has(operation.id)) {
|
||||||
|
seen.add(operation.id);
|
||||||
|
navigator.vibrate?.(10);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ flush: "sync" },
|
||||||
|
);
|
||||||
|
|
||||||
|
await swipe(host, 20, log); // 10% progress: cancelled
|
||||||
|
expect(vibrate).not.toHaveBeenCalled();
|
||||||
|
await swipe(host, 150, log); // 75% progress: committed
|
||||||
|
expect(log).toEqual([
|
||||||
|
"recognized",
|
||||||
|
"released",
|
||||||
|
"recognized",
|
||||||
|
"vibrate",
|
||||||
|
"released",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can vibrate on release from a custom .complete() decision", async () => {
|
||||||
|
const vibrate = vi.fn(() => true);
|
||||||
|
vi.stubGlobal("navigator", { ...navigator, vibrate });
|
||||||
|
const { host } = await mount(
|
||||||
|
gesture.to
|
||||||
|
.left()
|
||||||
|
.complete(({ progress }) => {
|
||||||
|
const commit = progress > 0.5;
|
||||||
|
if (commit) navigator.vibrate?.(10);
|
||||||
|
return commit;
|
||||||
|
})
|
||||||
|
.navigate(() => forward(originView(Target)))
|
||||||
|
.animate(motion),
|
||||||
|
);
|
||||||
|
await swipe(host, 20, []);
|
||||||
|
expect(vibrate).not.toHaveBeenCalled();
|
||||||
|
await swipe(host, 150, []);
|
||||||
|
expect(vibrate).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe(".onCommit()", () => {
|
||||||
|
it("vibrates once when a release commits, and not when it cancels", async () => {
|
||||||
|
const vibrate = vi.fn((_ms: number) => true);
|
||||||
|
const { host } = await mount(
|
||||||
|
gesture.to
|
||||||
|
.left()
|
||||||
|
.navigate(() => forward(originView(Target)))
|
||||||
|
.onCommit(() => vibrate(10))
|
||||||
|
.animate(motion),
|
||||||
|
);
|
||||||
|
|
||||||
|
await swipe(host, 20, []); // 10% progress: cancelled
|
||||||
|
expect(vibrate).not.toHaveBeenCalled();
|
||||||
|
await swipe(host, 150, []); // 75% progress: committed
|
||||||
|
expect(vibrate).toHaveBeenCalledTimes(1);
|
||||||
|
expect(vibrate).toHaveBeenCalledWith(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is available at every stage after the direction, in order", async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const definition = gesture.to
|
||||||
|
.left()
|
||||||
|
.onCommit(() => calls.push("directed"))
|
||||||
|
.complete(({ progress }) => progress > 0.5)
|
||||||
|
.onCommit(() => calls.push("completed"))
|
||||||
|
.navigate(() => forward(originView(Target)))
|
||||||
|
.onCommit((context) => calls.push(`navigation ${context.direction}`))
|
||||||
|
.animate(motion);
|
||||||
|
expect(definition.commitHandlers).toHaveLength(3);
|
||||||
|
expect(Object.isFrozen(definition.commitHandlers)).toBe(true);
|
||||||
|
|
||||||
|
const { host } = await mount(definition);
|
||||||
|
await swipe(host, 150, []);
|
||||||
|
expect(calls).toEqual(["directed", "completed", "navigation left"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps earlier builder stages unchanged", () => {
|
||||||
|
const base = gesture.to.left().navigate(() => null);
|
||||||
|
const withHandler = base.onCommit(() => {});
|
||||||
|
expect(base.animate(motion).commitHandlers).toEqual([]);
|
||||||
|
expect(withHandler.animate(motion).commitHandlers).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a failing handler without stopping later handlers or the commit", async () => {
|
||||||
|
const errors: unknown[] = [];
|
||||||
|
const onError = (error: unknown) => errors.push(error);
|
||||||
|
process.on("uncaughtException", onError);
|
||||||
|
try {
|
||||||
|
const after = vi.fn();
|
||||||
|
const { scene, host } = await mount(
|
||||||
|
gesture.to
|
||||||
|
.left()
|
||||||
|
.navigate(() => forward(originView(Target)))
|
||||||
|
.onCommit(() => {
|
||||||
|
throw new Error("no vibration motor");
|
||||||
|
})
|
||||||
|
.onCommit(after)
|
||||||
|
.animate(motion),
|
||||||
|
);
|
||||||
|
await swipe(host, 150, []);
|
||||||
|
expect(after).toHaveBeenCalledTimes(1);
|
||||||
|
expect(scene.nodes.value.map((node) => node.state)).toEqual([
|
||||||
|
"parked",
|
||||||
|
"active",
|
||||||
|
]);
|
||||||
|
expect(errors).toEqual([new Error("no vibration motor")]);
|
||||||
|
} finally {
|
||||||
|
process.off("uncaughtException", onError);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fires at release, before the settling animation finishes", async () => {
|
||||||
|
const frames: FrameRequestCallback[] = [];
|
||||||
|
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) =>
|
||||||
|
frames.push(cb),
|
||||||
|
);
|
||||||
|
const log: string[] = [];
|
||||||
|
const { scene, host } = await mount(
|
||||||
|
gesture.to
|
||||||
|
.left()
|
||||||
|
.navigate(() => forward(originView(Target)))
|
||||||
|
.onCommit(() => log.push("commit"))
|
||||||
|
.animate(motion),
|
||||||
|
);
|
||||||
|
vi.stubGlobal("matchMedia", () => ({ matches: false }));
|
||||||
|
|
||||||
|
await swipe(host, 150, log);
|
||||||
|
expect(log).toEqual(["recognized", "commit", "released"]);
|
||||||
|
// The spring is still waiting for frames; the operation is settling.
|
||||||
|
expect(scene.operations.value[0]?.phase).toBe("settling");
|
||||||
|
expect(frames.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,14 +5,60 @@
|
|||||||
* origin-relative operation frames. It does not depend on Vue Router or choose
|
* origin-relative operation frames. It does not depend on Vue Router or choose
|
||||||
* a globally active view.
|
* a globally active view.
|
||||||
*
|
*
|
||||||
|
* Modules, from the bottom up:
|
||||||
|
*
|
||||||
|
* - `shared` geometry and environment helpers
|
||||||
|
* - `choreography` visual effects, presets, and effect compositing
|
||||||
|
* - `navigation` view recipes, intents, and actions (`forward`, …)
|
||||||
|
* - `scene/graph` the scene model and its history rules
|
||||||
|
* - `scene/scene` timing, measuring, compositing, `useOrigin`
|
||||||
|
* - `gesture/definition` gesture types and the immutable builder
|
||||||
|
* - `gesture/recognizer` pointer admission, tracking, and recognition
|
||||||
|
* - `components/` Vue renderers and gesture hosts
|
||||||
|
*
|
||||||
* @packageDocumentation
|
* @packageDocumentation
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export * from "./types";
|
export type { OriginRect } from "./shared";
|
||||||
export * from "./scene";
|
|
||||||
export * from "./motion";
|
export type {
|
||||||
export * from "./gesture";
|
OriginChoreography,
|
||||||
export * from "./lifecycle";
|
OriginChoreographyContext,
|
||||||
|
OriginEffect,
|
||||||
|
OriginEffectSet,
|
||||||
|
OriginOperationIntent,
|
||||||
|
OriginOperationPhase,
|
||||||
|
} from "./choreography";
|
||||||
|
export {
|
||||||
|
defineOriginChoreography,
|
||||||
|
fade,
|
||||||
|
normalizedEffect,
|
||||||
|
slideLeft,
|
||||||
|
slideRight,
|
||||||
|
} from "./choreography";
|
||||||
|
|
||||||
|
export type * from "./navigation";
|
||||||
|
export {
|
||||||
|
above,
|
||||||
|
back,
|
||||||
|
forward,
|
||||||
|
originAction,
|
||||||
|
originView,
|
||||||
|
replace,
|
||||||
|
under,
|
||||||
|
} from "./navigation";
|
||||||
|
|
||||||
|
export type * from "./scene/types";
|
||||||
|
export type { OriginNodeScope, UseOrigin } from "./scene/scene";
|
||||||
|
export {
|
||||||
|
createOriginScene,
|
||||||
|
originNodeScopeKey,
|
||||||
|
useOrigin,
|
||||||
|
} from "./scene/scene";
|
||||||
|
|
||||||
|
export type * from "./gesture/definition";
|
||||||
|
export { gesture } from "./gesture/definition";
|
||||||
|
export { useOriginGesture } from "./gesture/recognizer";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convenience component that binds one `useOriginGesture()` recognizer to a
|
* Convenience component that binds one `useOriginGesture()` recognizer to a
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
import { computed, inject, type InjectionKey } from "vue";
|
|
||||||
import type { OriginNodeScope, UseOrigin } from "./types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Injection key used by the internal scene-node host to establish origin
|
|
||||||
* ownership for descendant components.
|
|
||||||
*
|
|
||||||
* Application code normally calls {@link useOrigin} instead of injecting this
|
|
||||||
* key directly.
|
|
||||||
*
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
export const originNodeScopeKey: InjectionKey<OriginNodeScope> =
|
|
||||||
Symbol("origin-node-scope");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Access the scene from the component that owns an interaction declaration.
|
|
||||||
*
|
|
||||||
* There is deliberately no `activeView`: the injected node is the origin
|
|
||||||
* because this component is where the event or application action occurred.
|
|
||||||
*
|
|
||||||
* @returns Node-scoped scene state and operation controls.
|
|
||||||
* @throws If called outside a component rendered by `OriginScene`.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* const origin = useOrigin();
|
|
||||||
*
|
|
||||||
* function openProfile() {
|
|
||||||
* return origin.perform(
|
|
||||||
* forward(originView(ProfileView), slideLeft),
|
|
||||||
* );
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function useOrigin(): UseOrigin {
|
|
||||||
const scope = inject(originNodeScopeKey);
|
|
||||||
if (!scope)
|
|
||||||
throw new Error("useOrigin() must be called inside an <OriginScene> view.");
|
|
||||||
|
|
||||||
const context = computed(() => scope.scene.contextFor(scope.nodeKey));
|
|
||||||
|
|
||||||
return {
|
|
||||||
nodeKey: scope.nodeKey,
|
|
||||||
scene: scope.scene,
|
|
||||||
context,
|
|
||||||
view: computed(() => context.value.view),
|
|
||||||
previous: computed(() => context.value.previous),
|
|
||||||
canGoBack: computed(() => context.value.canGoBack),
|
|
||||||
begin: (action) => scope.scene.begin(scope.nodeKey, action),
|
|
||||||
perform: (action) => scope.scene.perform(scope.nodeKey, action),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,370 +0,0 @@
|
|||||||
import type {
|
|
||||||
OriginAction,
|
|
||||||
OriginChoreography,
|
|
||||||
OriginEffect,
|
|
||||||
OriginEffectSet,
|
|
||||||
OriginHistoryMode,
|
|
||||||
OriginNavigationIntent,
|
|
||||||
OriginPlacement,
|
|
||||||
OriginView,
|
|
||||||
} from "./types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Preserve type inference while declaring a custom choreography.
|
|
||||||
*
|
|
||||||
* The helper performs no runtime transformation. It gives custom routines a
|
|
||||||
* named, documented construction point and validates their shape in TypeScript.
|
|
||||||
*
|
|
||||||
* @param choreography - Side-effect-free visual effect calculator and optional
|
|
||||||
* release thresholds.
|
|
||||||
* @returns The same choreography object.
|
|
||||||
*/
|
|
||||||
export function defineOriginChoreography(
|
|
||||||
choreography: OriginChoreography,
|
|
||||||
): OriginChoreography {
|
|
||||||
return choreography;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Options accepted by {@link originAction}. */
|
|
||||||
export interface OriginActionOptions {
|
|
||||||
/**
|
|
||||||
* Target stacking relationship during the operation.
|
|
||||||
*
|
|
||||||
* @defaultValue `"above"`
|
|
||||||
*/
|
|
||||||
placement?: OriginPlacement;
|
|
||||||
/**
|
|
||||||
* History mutation assigned to the created target node.
|
|
||||||
*
|
|
||||||
* @defaultValue `"push"`
|
|
||||||
*/
|
|
||||||
history?: OriginHistoryMode;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** History options shared by the {@link above} and {@link under} helpers. */
|
|
||||||
export interface OriginPlacementActionOptions {
|
|
||||||
/**
|
|
||||||
* History mutation assigned to the created target node.
|
|
||||||
*
|
|
||||||
* @defaultValue `"push"`
|
|
||||||
*/
|
|
||||||
history?: OriginHistoryMode;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Stacking options accepted by retained-history navigation helpers. */
|
|
||||||
export interface OriginNavigationActionOptions {
|
|
||||||
/**
|
|
||||||
* Target stacking relationship during the operation.
|
|
||||||
*
|
|
||||||
* @defaultValue `"above"` for {@link forward} and {@link replace}, `"under"`
|
|
||||||
* for {@link back}
|
|
||||||
*/
|
|
||||||
placement?: OriginPlacement;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isChoreography(
|
|
||||||
value: OriginChoreography | object | undefined,
|
|
||||||
): value is OriginChoreography {
|
|
||||||
return (
|
|
||||||
value !== undefined &&
|
|
||||||
"effects" in value &&
|
|
||||||
typeof value.effects === "function"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create the scene mutation invoked by a click, gesture, hardware command, or
|
|
||||||
* any other application event. `above` and `under` affect stacking only; they
|
|
||||||
* do not imply a universal navigation direction.
|
|
||||||
*
|
|
||||||
* @param target - View recipe to create.
|
|
||||||
* @param choreography - Visual routine controlling the operation.
|
|
||||||
* @param options - Stacking and history behavior.
|
|
||||||
* @returns An action that can be passed to `begin()`, `perform()`, or returned
|
|
||||||
* from a gesture action factory.
|
|
||||||
*/
|
|
||||||
export function originAction(
|
|
||||||
target: OriginView,
|
|
||||||
choreography: OriginChoreography,
|
|
||||||
options: OriginActionOptions = {},
|
|
||||||
): OriginAction {
|
|
||||||
return {
|
|
||||||
target,
|
|
||||||
choreography,
|
|
||||||
placement: options.placement ?? "above",
|
|
||||||
history: options.history ?? "push",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a retained-history push intent or complete action.
|
|
||||||
*
|
|
||||||
* Committing the action parks its origin instance and leaves it mounted until
|
|
||||||
* a later committed {@link back} action pops the new entry. Omit choreography
|
|
||||||
* when declaring `.navigate()` inside a gesture builder; provide choreography
|
|
||||||
* when passing the result directly to `begin()` or `perform()`.
|
|
||||||
*
|
|
||||||
* @param target - View recipe created if the push commits.
|
|
||||||
* @param options - Stacking options for an animation-free navigation intent.
|
|
||||||
* @returns An animation-free intent for use with a gesture builder.
|
|
||||||
*/
|
|
||||||
export function forward(
|
|
||||||
target: OriginView,
|
|
||||||
options?: OriginNavigationActionOptions,
|
|
||||||
): OriginNavigationIntent;
|
|
||||||
export function forward(
|
|
||||||
target: OriginView,
|
|
||||||
choreography: OriginChoreography,
|
|
||||||
options?: OriginNavigationActionOptions,
|
|
||||||
): OriginAction;
|
|
||||||
export function forward(
|
|
||||||
target: OriginView,
|
|
||||||
choreographyOrOptions:
|
|
||||||
OriginChoreography | OriginNavigationActionOptions = {},
|
|
||||||
options: OriginNavigationActionOptions = {},
|
|
||||||
): OriginAction | OriginNavigationIntent {
|
|
||||||
if (isChoreography(choreographyOrOptions)) {
|
|
||||||
return originAction(target, choreographyOrOptions, {
|
|
||||||
placement: options.placement ?? "above",
|
|
||||||
history: "push",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
target,
|
|
||||||
placement: choreographyOrOptions.placement ?? "above",
|
|
||||||
history: "push",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a retained-history replacement intent or complete action.
|
|
||||||
*
|
|
||||||
* The target inherits the origin's previous mounted entry instead of retaining
|
|
||||||
* the origin itself. A commit unmounts the origin and activates the target, so
|
|
||||||
* a later {@link back} skips the replaced entry. A cancellation removes the
|
|
||||||
* proposed target and restores the origin unchanged.
|
|
||||||
*
|
|
||||||
* Omit choreography when declaring `.navigate()` inside a gesture builder;
|
|
||||||
* provide choreography when passing the result directly to `begin()` or
|
|
||||||
* `perform()`.
|
|
||||||
*
|
|
||||||
* @param target - View recipe created for the replacement.
|
|
||||||
* @param options - Stacking options for an animation-free navigation intent.
|
|
||||||
* @returns An animation-free replacement intent for use with a gesture builder.
|
|
||||||
*/
|
|
||||||
export function replace(
|
|
||||||
target: OriginView,
|
|
||||||
options?: OriginNavigationActionOptions,
|
|
||||||
): OriginNavigationIntent;
|
|
||||||
export function replace(
|
|
||||||
target: OriginView,
|
|
||||||
choreography: OriginChoreography,
|
|
||||||
options?: OriginNavigationActionOptions,
|
|
||||||
): OriginAction;
|
|
||||||
export function replace(
|
|
||||||
target: OriginView,
|
|
||||||
choreographyOrOptions:
|
|
||||||
OriginChoreography | OriginNavigationActionOptions = {},
|
|
||||||
options: OriginNavigationActionOptions = {},
|
|
||||||
): OriginAction | OriginNavigationIntent {
|
|
||||||
if (isChoreography(choreographyOrOptions)) {
|
|
||||||
return originAction(target, choreographyOrOptions, {
|
|
||||||
placement: options.placement ?? "above",
|
|
||||||
history: "replace",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
target,
|
|
||||||
placement: choreographyOrOptions.placement ?? "above",
|
|
||||||
history: "replace",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a retained-history back intent or complete action.
|
|
||||||
*
|
|
||||||
* The action intentionally has no target recipe. At execution time the scene
|
|
||||||
* resolves the origin's mounted `previousNodeKey`, reveals that exact instance,
|
|
||||||
* and removes the current instance only if the operation commits. Omit
|
|
||||||
* choreography inside gesture `.navigate()`; provide it for a programmatic
|
|
||||||
* action.
|
|
||||||
*
|
|
||||||
* @param options - Stacking options for an animation-free navigation intent.
|
|
||||||
* @returns An animation-free back intent for use with a gesture builder.
|
|
||||||
*/
|
|
||||||
export function back(
|
|
||||||
options?: OriginNavigationActionOptions,
|
|
||||||
): OriginNavigationIntent;
|
|
||||||
export function back(
|
|
||||||
choreography: OriginChoreography,
|
|
||||||
options?: OriginNavigationActionOptions,
|
|
||||||
): OriginAction;
|
|
||||||
export function back(
|
|
||||||
choreographyOrOptions:
|
|
||||||
OriginChoreography | OriginNavigationActionOptions = {},
|
|
||||||
options: OriginNavigationActionOptions = {},
|
|
||||||
): OriginAction | OriginNavigationIntent {
|
|
||||||
if (!isChoreography(choreographyOrOptions)) {
|
|
||||||
return {
|
|
||||||
placement: choreographyOrOptions.placement ?? "under",
|
|
||||||
history: "back",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
choreography: choreographyOrOptions,
|
|
||||||
placement: options.placement ?? "under",
|
|
||||||
history: "back",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create an intent or action whose target is stacked above its origin.
|
|
||||||
*
|
|
||||||
* This helper controls stacking, not movement direction. The supplied
|
|
||||||
* choreography may move either component however the application chooses.
|
|
||||||
* Omitting choreography produces an intent for gesture `.navigate()`.
|
|
||||||
*
|
|
||||||
* @param target - View recipe to create.
|
|
||||||
* @param choreography - Visual routine controlling the operation.
|
|
||||||
* @param options - Optional history behavior.
|
|
||||||
*/
|
|
||||||
export function above(
|
|
||||||
target: OriginView,
|
|
||||||
options?: OriginPlacementActionOptions,
|
|
||||||
): OriginNavigationIntent;
|
|
||||||
export function above(
|
|
||||||
target: OriginView,
|
|
||||||
choreography: OriginChoreography,
|
|
||||||
options?: OriginPlacementActionOptions,
|
|
||||||
): OriginAction;
|
|
||||||
export function above(
|
|
||||||
target: OriginView,
|
|
||||||
choreographyOrOptions: OriginChoreography | OriginPlacementActionOptions = {},
|
|
||||||
options: OriginPlacementActionOptions = {},
|
|
||||||
): OriginAction | OriginNavigationIntent {
|
|
||||||
if (isChoreography(choreographyOrOptions)) {
|
|
||||||
return originAction(target, choreographyOrOptions, {
|
|
||||||
placement: "above",
|
|
||||||
history: options.history,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
target,
|
|
||||||
placement: "above",
|
|
||||||
history: choreographyOrOptions.history ?? "push",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create an intent or action whose target is stacked underneath its origin.
|
|
||||||
*
|
|
||||||
* Commonly used for custom reveal effects, but it has no implicit history
|
|
||||||
* meaning. Prefer {@link back} for retained-history navigation. Omitting
|
|
||||||
* choreography produces an intent for gesture `.navigate()`.
|
|
||||||
*
|
|
||||||
* @param target - View recipe to create underneath the origin.
|
|
||||||
* @param choreography - Visual routine controlling the operation.
|
|
||||||
* @param options - Optional history behavior.
|
|
||||||
*/
|
|
||||||
export function under(
|
|
||||||
target: OriginView,
|
|
||||||
options?: OriginPlacementActionOptions,
|
|
||||||
): OriginNavigationIntent;
|
|
||||||
export function under(
|
|
||||||
target: OriginView,
|
|
||||||
choreography: OriginChoreography,
|
|
||||||
options?: OriginPlacementActionOptions,
|
|
||||||
): OriginAction;
|
|
||||||
export function under(
|
|
||||||
target: OriginView,
|
|
||||||
choreographyOrOptions: OriginChoreography | OriginPlacementActionOptions = {},
|
|
||||||
options: OriginPlacementActionOptions = {},
|
|
||||||
): OriginAction | OriginNavigationIntent {
|
|
||||||
if (isChoreography(choreographyOrOptions)) {
|
|
||||||
return originAction(target, choreographyOrOptions, {
|
|
||||||
placement: "under",
|
|
||||||
history: options.history,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
target,
|
|
||||||
placement: "under",
|
|
||||||
history: choreographyOrOptions.history ?? "push",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const percent = (value: number) => `${(value * 100).toFixed(4)}%`;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Native-style forward motion: the target enters above the source while the
|
|
||||||
* source recedes slightly. These presets are examples; applications can
|
|
||||||
* replace them with arbitrary `defineOriginChoreography()` callbacks.
|
|
||||||
*/
|
|
||||||
export const slideLeft = defineOriginChoreography({
|
|
||||||
name: "slide-left",
|
|
||||||
commitThreshold: 0.36,
|
|
||||||
commitVelocity: 0.6,
|
|
||||||
effects: ({ progress }): OriginEffectSet => ({
|
|
||||||
source: {
|
|
||||||
transform: `translate3d(${percent(progress * -0.24)}, 0, 0) scale(${1 - progress * 0.025})`,
|
|
||||||
opacity: 1 - progress * 0.16,
|
|
||||||
},
|
|
||||||
target: {
|
|
||||||
transform: `translate3d(${percent(1 - progress)}, 0, 0)`,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Back motion reveals the target underneath the source. This is merely a
|
|
||||||
* visual routine; the {@link back} action selects retained-history behavior.
|
|
||||||
*/
|
|
||||||
export const slideRight = defineOriginChoreography({
|
|
||||||
name: "slide-right",
|
|
||||||
commitThreshold: 0.36,
|
|
||||||
commitVelocity: 0.6,
|
|
||||||
effects: ({ progress }): OriginEffectSet => ({
|
|
||||||
source: {
|
|
||||||
transform: `translate3d(${percent(progress)}, 0, 0)`,
|
|
||||||
},
|
|
||||||
target: {
|
|
||||||
transform: `translate3d(${percent(-0.24 + progress * 0.24)}, 0, 0) scale(${0.975 + progress * 0.025})`,
|
|
||||||
opacity: 0.84 + progress * 0.16,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cross-fade preset that fades the source out while fading the target in.
|
|
||||||
*
|
|
||||||
* The default release thresholds from the scene are used.
|
|
||||||
*/
|
|
||||||
export const fade = defineOriginChoreography({
|
|
||||||
name: "fade",
|
|
||||||
effects: ({ progress }): OriginEffectSet => ({
|
|
||||||
source: { opacity: 1 - progress },
|
|
||||||
target: { opacity: progress },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalize one contribution before the compositor combines it with effects
|
|
||||||
* inherited from earlier origin frames.
|
|
||||||
*
|
|
||||||
* Most applications should return plain effects from a choreography and let
|
|
||||||
* the scene call this function. It is exported for custom compositors and
|
|
||||||
* diagnostics.
|
|
||||||
*
|
|
||||||
* @param effect - Optional effect to normalize.
|
|
||||||
* @param fallbackLayer - Relative layer contribution added to `effect.layer`.
|
|
||||||
* @returns A defined effect with a numeric layer.
|
|
||||||
*/
|
|
||||||
export function normalizedEffect(
|
|
||||||
effect: OriginEffect | undefined,
|
|
||||||
fallbackLayer = 0,
|
|
||||||
): OriginEffect {
|
|
||||||
return {
|
|
||||||
...effect,
|
|
||||||
layer: (effect?.layer ?? 0) + fallbackLayer,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,444 @@
|
|||||||
|
import { markRaw, type Component } from "vue";
|
||||||
|
import { isChoreography, type OriginChoreography } from "./choreography";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Navigation vocabulary: view recipes (what to mount) and the intents and
|
||||||
|
* actions (how history changes) that operations execute.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Props accepted by a Vue component definition. */
|
||||||
|
export type PropsOf<T> = T extends abstract new (...args: any[]) => {
|
||||||
|
$props: infer P;
|
||||||
|
}
|
||||||
|
? P
|
||||||
|
: T extends (props: infer P, ...args: any[]) => any
|
||||||
|
? P
|
||||||
|
: never;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A view is a recipe for creating a Vue component, not a mounted instance.
|
||||||
|
*
|
||||||
|
* Forward navigation uses the recipe to create a mounted history entry. That
|
||||||
|
* instance remains mounted until a committed back operation pops it.
|
||||||
|
*
|
||||||
|
* @typeParam T - The component mounted by the recipe.
|
||||||
|
*/
|
||||||
|
export interface OriginView<T extends Component = Component> {
|
||||||
|
/** The Vue component definition that will be mounted for this recipe. */
|
||||||
|
readonly component: T;
|
||||||
|
/** Props passed to the component when the recipe is mounted. */
|
||||||
|
readonly props?: Readonly<PropsOf<T>>;
|
||||||
|
/**
|
||||||
|
* A developer-facing recipe identity used in diagnostics and as the prefix
|
||||||
|
* of generated scene-node keys. It does not make Vue reuse an instance.
|
||||||
|
*/
|
||||||
|
readonly key?: string;
|
||||||
|
/** A human-readable label used by diagnostics and DOM data attributes. */
|
||||||
|
readonly name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Optional developer-facing metadata for an {@link OriginView} recipe. */
|
||||||
|
export interface OriginViewOptions {
|
||||||
|
/**
|
||||||
|
* Stable recipe identity used in diagnostics and generated node-key prefixes.
|
||||||
|
* It does not preserve or reuse a mounted Vue component instance.
|
||||||
|
*/
|
||||||
|
key?: string;
|
||||||
|
/** Human-readable label used by inspectors and DOM data attributes. */
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let viewSequence = 0;
|
||||||
|
|
||||||
|
function componentName(component: Component): string | undefined {
|
||||||
|
return typeof component === "object" && "name" in component
|
||||||
|
? String(component.name)
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn a component and props into a lightweight, reusable view recipe.
|
||||||
|
*
|
||||||
|
* Components are marked raw so Vue never attempts to proxy their definitions
|
||||||
|
* when recipes are placed in reactive scene/history structures.
|
||||||
|
*
|
||||||
|
* @param component - Vue component definition to mount.
|
||||||
|
* @param props - Props captured by the recipe.
|
||||||
|
* @param options - Optional recipe identity and diagnostic name.
|
||||||
|
* @returns An immutable, reusable component recipe. It is not a Vue instance.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const profile = originView(
|
||||||
|
* ProfileView,
|
||||||
|
* { userId: "42" },
|
||||||
|
* { key: "profile-42", name: "Profile" },
|
||||||
|
* );
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function originView<T extends Component = Component>(
|
||||||
|
component: T,
|
||||||
|
props?: PropsOf<T>,
|
||||||
|
options: OriginViewOptions = {},
|
||||||
|
): OriginView<T> {
|
||||||
|
const name = options.name ?? componentName(component);
|
||||||
|
return markRaw({
|
||||||
|
component: markRaw(component),
|
||||||
|
props,
|
||||||
|
key: options.key ?? `${name ?? "view"}-${++viewSequence}`,
|
||||||
|
name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controls how an operation participates in retained instance history.
|
||||||
|
*
|
||||||
|
* - `push`: create a target whose previous entry is the mounted origin.
|
||||||
|
* - `replace`: create a target that inherits the origin's previous entry, then
|
||||||
|
* remove the origin on commit.
|
||||||
|
* - `back`: reuse the mounted previous entry and pop the origin on commit.
|
||||||
|
*/
|
||||||
|
export type OriginHistoryMode = "push" | "replace" | "back";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The target's stacking relationship to its origin while an operation exists.
|
||||||
|
*/
|
||||||
|
export type OriginPlacement = "above" | "under";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A scene mutation without visual choreography.
|
||||||
|
*
|
||||||
|
* Gesture builders keep navigation intent separate from animation so the same
|
||||||
|
* destination can be paired with different component-local interactions.
|
||||||
|
* Calling `.animate()` materializes this intent as an {@link OriginAction}.
|
||||||
|
*/
|
||||||
|
export interface OriginNavigationIntent {
|
||||||
|
/**
|
||||||
|
* Recipe for a newly created push or replacement target. Back navigation
|
||||||
|
* omits this because the scene resolves the retained previous instance.
|
||||||
|
*/
|
||||||
|
readonly target?: OriginView;
|
||||||
|
/** Target stacking relationship while the gesture operation is visible. */
|
||||||
|
readonly placement?: OriginPlacement;
|
||||||
|
/** Retained-history mutation performed if the gesture commits. */
|
||||||
|
readonly history: OriginHistoryMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A complete request to create and animate a target view from an origin. */
|
||||||
|
export interface OriginAction {
|
||||||
|
/**
|
||||||
|
* Recipe for a newly created push or replacement target. A back action omits
|
||||||
|
* this because the scene resolves its already-mounted previous entry.
|
||||||
|
*/
|
||||||
|
readonly target?: OriginView;
|
||||||
|
/** Visual relationship applied to the source, target, and shared frame. */
|
||||||
|
readonly choreography: OriginChoreography;
|
||||||
|
/**
|
||||||
|
* Target stacking relationship during the operation.
|
||||||
|
*
|
||||||
|
* @defaultValue `"above"`
|
||||||
|
*/
|
||||||
|
readonly placement?: OriginPlacement;
|
||||||
|
/**
|
||||||
|
* History mutation applied to the target recipe.
|
||||||
|
*
|
||||||
|
* @defaultValue `"push"`
|
||||||
|
*/
|
||||||
|
readonly history?: OriginHistoryMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Values accepted from gesture action factories.
|
||||||
|
*
|
||||||
|
* Returning `null` or `undefined` declines the recognized gesture. A promise
|
||||||
|
* allows lazy target resolution; stale results are discarded after release or
|
||||||
|
* cancellation.
|
||||||
|
*/
|
||||||
|
export type MaybeOriginAction =
|
||||||
|
OriginAction | null | undefined | Promise<OriginAction | null | undefined>;
|
||||||
|
|
||||||
|
/** Options accepted by {@link originAction}. */
|
||||||
|
export interface OriginActionOptions {
|
||||||
|
/**
|
||||||
|
* Target stacking relationship during the operation.
|
||||||
|
*
|
||||||
|
* @defaultValue `"above"`
|
||||||
|
*/
|
||||||
|
placement?: OriginPlacement;
|
||||||
|
/**
|
||||||
|
* History mutation assigned to the created target node.
|
||||||
|
*
|
||||||
|
* @defaultValue `"push"`
|
||||||
|
*/
|
||||||
|
history?: OriginHistoryMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** History options shared by the {@link above} and {@link under} helpers. */
|
||||||
|
export interface OriginPlacementActionOptions {
|
||||||
|
/**
|
||||||
|
* History mutation assigned to the created target node.
|
||||||
|
*
|
||||||
|
* @defaultValue `"push"`
|
||||||
|
*/
|
||||||
|
history?: OriginHistoryMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stacking options accepted by retained-history navigation helpers. */
|
||||||
|
export interface OriginNavigationActionOptions {
|
||||||
|
/**
|
||||||
|
* Target stacking relationship during the operation.
|
||||||
|
*
|
||||||
|
* @defaultValue `"above"` for {@link forward} and {@link replace}, `"under"`
|
||||||
|
* for {@link back}
|
||||||
|
*/
|
||||||
|
placement?: OriginPlacement;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Every helper accepts either `(…, options?)`, producing an animation-free
|
||||||
|
* navigation intent for gesture `.navigate()`, or `(…, choreography,
|
||||||
|
* options?)`, producing a complete action for `begin()`/`perform()`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface HelperArguments<Options> {
|
||||||
|
readonly choreography?: OriginChoreography;
|
||||||
|
readonly options: Options;
|
||||||
|
}
|
||||||
|
|
||||||
|
function helperArguments<Options extends object>(
|
||||||
|
choreographyOrOptions: OriginChoreography | Options | undefined,
|
||||||
|
options: Options | undefined,
|
||||||
|
): HelperArguments<Partial<Options>> {
|
||||||
|
return isChoreography(choreographyOrOptions)
|
||||||
|
? { choreography: choreographyOrOptions, options: options ?? {} }
|
||||||
|
: { options: choreographyOrOptions ?? {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigation(
|
||||||
|
target: OriginView | undefined,
|
||||||
|
placement: OriginPlacement,
|
||||||
|
history: OriginHistoryMode,
|
||||||
|
): OriginNavigationIntent {
|
||||||
|
return { target, placement, history };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Back navigation resolves its target at execution time, so it has none. */
|
||||||
|
function targetlessNavigation(
|
||||||
|
placement: OriginPlacement,
|
||||||
|
history: OriginHistoryMode,
|
||||||
|
): OriginNavigationIntent {
|
||||||
|
return { placement, history };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pair a navigation intent with choreography, or return it unchanged. */
|
||||||
|
function materialize(
|
||||||
|
intent: OriginNavigationIntent,
|
||||||
|
choreography: OriginChoreography | undefined,
|
||||||
|
): OriginNavigationIntent | OriginAction {
|
||||||
|
if (!choreography) return intent;
|
||||||
|
const { placement, history } = intent;
|
||||||
|
return "target" in intent
|
||||||
|
? { target: intent.target, choreography, placement, history }
|
||||||
|
: { choreography, placement, history };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the scene mutation invoked by a click, gesture, hardware command, or
|
||||||
|
* any other application event. `above` and `under` affect stacking only; they
|
||||||
|
* do not imply a universal navigation direction.
|
||||||
|
*
|
||||||
|
* @param target - View recipe to create.
|
||||||
|
* @param choreography - Visual routine controlling the operation.
|
||||||
|
* @param options - Stacking and history behavior.
|
||||||
|
* @returns An action that can be passed to `begin()`, `perform()`, or returned
|
||||||
|
* from a gesture action factory.
|
||||||
|
*/
|
||||||
|
export function originAction(
|
||||||
|
target: OriginView,
|
||||||
|
choreography: OriginChoreography,
|
||||||
|
options: OriginActionOptions = {},
|
||||||
|
): OriginAction {
|
||||||
|
return materialize(
|
||||||
|
navigation(target, options.placement ?? "above", options.history ?? "push"),
|
||||||
|
choreography,
|
||||||
|
) as OriginAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a retained-history push intent or complete action.
|
||||||
|
*
|
||||||
|
* Committing the action parks its origin instance and leaves it mounted until
|
||||||
|
* a later committed {@link back} action pops the new entry. Omit choreography
|
||||||
|
* when declaring `.navigate()` inside a gesture builder; provide choreography
|
||||||
|
* when passing the result directly to `begin()` or `perform()`.
|
||||||
|
*
|
||||||
|
* @param target - View recipe created if the push commits.
|
||||||
|
* @param options - Stacking options for an animation-free navigation intent.
|
||||||
|
* @returns An animation-free intent for use with a gesture builder.
|
||||||
|
*/
|
||||||
|
export function forward(
|
||||||
|
target: OriginView,
|
||||||
|
options?: OriginNavigationActionOptions,
|
||||||
|
): OriginNavigationIntent;
|
||||||
|
export function forward(
|
||||||
|
target: OriginView,
|
||||||
|
choreography: OriginChoreography,
|
||||||
|
options?: OriginNavigationActionOptions,
|
||||||
|
): OriginAction;
|
||||||
|
export function forward(
|
||||||
|
target: OriginView,
|
||||||
|
choreographyOrOptions?: OriginChoreography | OriginNavigationActionOptions,
|
||||||
|
maybeOptions?: OriginNavigationActionOptions,
|
||||||
|
): OriginAction | OriginNavigationIntent {
|
||||||
|
const { choreography, options } = helperArguments(
|
||||||
|
choreographyOrOptions,
|
||||||
|
maybeOptions,
|
||||||
|
);
|
||||||
|
return materialize(
|
||||||
|
navigation(target, options.placement ?? "above", "push"),
|
||||||
|
choreography,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a retained-history replacement intent or complete action.
|
||||||
|
*
|
||||||
|
* The target inherits the origin's previous mounted entry instead of retaining
|
||||||
|
* the origin itself. A commit unmounts the origin and activates the target, so
|
||||||
|
* a later {@link back} skips the replaced entry. A cancellation removes the
|
||||||
|
* proposed target and restores the origin unchanged.
|
||||||
|
*
|
||||||
|
* Omit choreography when declaring `.navigate()` inside a gesture builder;
|
||||||
|
* provide choreography when passing the result directly to `begin()` or
|
||||||
|
* `perform()`.
|
||||||
|
*
|
||||||
|
* @param target - View recipe created for the replacement.
|
||||||
|
* @param options - Stacking options for an animation-free navigation intent.
|
||||||
|
* @returns An animation-free replacement intent for use with a gesture builder.
|
||||||
|
*/
|
||||||
|
export function replace(
|
||||||
|
target: OriginView,
|
||||||
|
options?: OriginNavigationActionOptions,
|
||||||
|
): OriginNavigationIntent;
|
||||||
|
export function replace(
|
||||||
|
target: OriginView,
|
||||||
|
choreography: OriginChoreography,
|
||||||
|
options?: OriginNavigationActionOptions,
|
||||||
|
): OriginAction;
|
||||||
|
export function replace(
|
||||||
|
target: OriginView,
|
||||||
|
choreographyOrOptions?: OriginChoreography | OriginNavigationActionOptions,
|
||||||
|
maybeOptions?: OriginNavigationActionOptions,
|
||||||
|
): OriginAction | OriginNavigationIntent {
|
||||||
|
const { choreography, options } = helperArguments(
|
||||||
|
choreographyOrOptions,
|
||||||
|
maybeOptions,
|
||||||
|
);
|
||||||
|
return materialize(
|
||||||
|
navigation(target, options.placement ?? "above", "replace"),
|
||||||
|
choreography,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a retained-history back intent or complete action.
|
||||||
|
*
|
||||||
|
* The action intentionally has no target recipe. At execution time the scene
|
||||||
|
* resolves the origin's mounted `previousNodeKey`, reveals that exact instance,
|
||||||
|
* and removes the current instance only if the operation commits. Omit
|
||||||
|
* choreography inside gesture `.navigate()`; provide it for a programmatic
|
||||||
|
* action.
|
||||||
|
*
|
||||||
|
* @param options - Stacking options for an animation-free navigation intent.
|
||||||
|
* @returns An animation-free back intent for use with a gesture builder.
|
||||||
|
*/
|
||||||
|
export function back(
|
||||||
|
options?: OriginNavigationActionOptions,
|
||||||
|
): OriginNavigationIntent;
|
||||||
|
export function back(
|
||||||
|
choreography: OriginChoreography,
|
||||||
|
options?: OriginNavigationActionOptions,
|
||||||
|
): OriginAction;
|
||||||
|
export function back(
|
||||||
|
choreographyOrOptions?: OriginChoreography | OriginNavigationActionOptions,
|
||||||
|
maybeOptions?: OriginNavigationActionOptions,
|
||||||
|
): OriginAction | OriginNavigationIntent {
|
||||||
|
const { choreography, options } = helperArguments(
|
||||||
|
choreographyOrOptions,
|
||||||
|
maybeOptions,
|
||||||
|
);
|
||||||
|
return materialize(
|
||||||
|
targetlessNavigation(options.placement ?? "under", "back"),
|
||||||
|
choreography,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an intent or action whose target is stacked above its origin.
|
||||||
|
*
|
||||||
|
* This helper controls stacking, not movement direction. The supplied
|
||||||
|
* choreography may move either component however the application chooses.
|
||||||
|
* Omitting choreography produces an intent for gesture `.navigate()`.
|
||||||
|
*
|
||||||
|
* @param target - View recipe to create.
|
||||||
|
* @param choreography - Visual routine controlling the operation.
|
||||||
|
* @param options - Optional history behavior.
|
||||||
|
*/
|
||||||
|
export function above(
|
||||||
|
target: OriginView,
|
||||||
|
options?: OriginPlacementActionOptions,
|
||||||
|
): OriginNavigationIntent;
|
||||||
|
export function above(
|
||||||
|
target: OriginView,
|
||||||
|
choreography: OriginChoreography,
|
||||||
|
options?: OriginPlacementActionOptions,
|
||||||
|
): OriginAction;
|
||||||
|
export function above(
|
||||||
|
target: OriginView,
|
||||||
|
choreographyOrOptions?: OriginChoreography | OriginPlacementActionOptions,
|
||||||
|
maybeOptions?: OriginPlacementActionOptions,
|
||||||
|
): OriginAction | OriginNavigationIntent {
|
||||||
|
const { choreography, options } = helperArguments(
|
||||||
|
choreographyOrOptions,
|
||||||
|
maybeOptions,
|
||||||
|
);
|
||||||
|
return materialize(
|
||||||
|
navigation(target, "above", options.history ?? "push"),
|
||||||
|
choreography,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an intent or action whose target is stacked underneath its origin.
|
||||||
|
*
|
||||||
|
* Commonly used for custom reveal effects, but it has no implicit history
|
||||||
|
* meaning. Prefer {@link back} for retained-history navigation. Omitting
|
||||||
|
* choreography produces an intent for gesture `.navigate()`.
|
||||||
|
*
|
||||||
|
* @param target - View recipe to create underneath the origin.
|
||||||
|
* @param choreography - Visual routine controlling the operation.
|
||||||
|
* @param options - Optional history behavior.
|
||||||
|
*/
|
||||||
|
export function under(
|
||||||
|
target: OriginView,
|
||||||
|
options?: OriginPlacementActionOptions,
|
||||||
|
): OriginNavigationIntent;
|
||||||
|
export function under(
|
||||||
|
target: OriginView,
|
||||||
|
choreography: OriginChoreography,
|
||||||
|
options?: OriginPlacementActionOptions,
|
||||||
|
): OriginAction;
|
||||||
|
export function under(
|
||||||
|
target: OriginView,
|
||||||
|
choreographyOrOptions?: OriginChoreography | OriginPlacementActionOptions,
|
||||||
|
maybeOptions?: OriginPlacementActionOptions,
|
||||||
|
): OriginAction | OriginNavigationIntent {
|
||||||
|
const { choreography, options } = helperArguments(
|
||||||
|
choreographyOrOptions,
|
||||||
|
maybeOptions,
|
||||||
|
);
|
||||||
|
return materialize(
|
||||||
|
navigation(target, "under", options.history ?? "push"),
|
||||||
|
choreography,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,16 +8,17 @@ import {
|
|||||||
type Component,
|
type Component,
|
||||||
} from "vue";
|
} from "vue";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import OriginScene from "./components/OriginScene.vue";
|
|
||||||
import {
|
import {
|
||||||
above,
|
above,
|
||||||
back,
|
back,
|
||||||
|
createOriginScene,
|
||||||
defineOriginChoreography,
|
defineOriginChoreography,
|
||||||
forward,
|
forward,
|
||||||
|
OriginScene,
|
||||||
|
originView,
|
||||||
replace,
|
replace,
|
||||||
under,
|
under,
|
||||||
} from "./motion";
|
} from ".";
|
||||||
import { createOriginScene, originView } from "./scene";
|
|
||||||
|
|
||||||
const mountedApps: Array<ReturnType<typeof createApp>> = [];
|
const mountedApps: Array<ReturnType<typeof createApp>> = [];
|
||||||
|
|
||||||
@@ -221,9 +222,6 @@ describe("origin-relative scene graph", () => {
|
|||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
xUnmounts += 1;
|
xUnmounts += 1;
|
||||||
});
|
});
|
||||||
defineProps<{
|
|
||||||
message: string;
|
|
||||||
}>();
|
|
||||||
return () =>
|
return () =>
|
||||||
h(
|
h(
|
||||||
"div",
|
"div",
|
||||||
|
|||||||
@@ -1,773 +0,0 @@
|
|||||||
import {
|
|
||||||
computed,
|
|
||||||
markRaw,
|
|
||||||
nextTick,
|
|
||||||
reactive,
|
|
||||||
shallowReactive,
|
|
||||||
shallowRef,
|
|
||||||
type Component,
|
|
||||||
type CSSProperties,
|
|
||||||
} from "vue";
|
|
||||||
import { normalizedEffect } from "./motion";
|
|
||||||
import type {
|
|
||||||
MutableOriginOperation,
|
|
||||||
OriginAction,
|
|
||||||
OriginChoreographyContext,
|
|
||||||
OriginContext,
|
|
||||||
OriginEffect,
|
|
||||||
OriginEffectSet,
|
|
||||||
OriginFinishOptions,
|
|
||||||
OriginOperation,
|
|
||||||
OriginOperationHandle,
|
|
||||||
OriginRect,
|
|
||||||
OriginScene,
|
|
||||||
OriginSceneNode,
|
|
||||||
OriginSceneNodeState,
|
|
||||||
OriginView,
|
|
||||||
PropsOf,
|
|
||||||
} from "./types";
|
|
||||||
|
|
||||||
interface SceneNodeState {
|
|
||||||
key: string;
|
|
||||||
sequence: number;
|
|
||||||
view: OriginView;
|
|
||||||
previousNodeKey?: string;
|
|
||||||
state: OriginSceneNodeState;
|
|
||||||
incomingOperationId?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Options used to create an independent origin scene. */
|
|
||||||
export interface CreateOriginSceneOptions {
|
|
||||||
/**
|
|
||||||
* One initial root recipe, or several independent roots rendered in the same
|
|
||||||
* flat scene. Most applications begin with one root.
|
|
||||||
*/
|
|
||||||
initial: OriginView | readonly OriginView[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Optional developer-facing metadata for an {@link OriginView} recipe. */
|
|
||||||
export interface OriginViewOptions {
|
|
||||||
/**
|
|
||||||
* Stable recipe identity used in diagnostics and generated node-key prefixes.
|
|
||||||
* It does not preserve or reuse a mounted Vue component instance.
|
|
||||||
*/
|
|
||||||
key?: string;
|
|
||||||
/** Human-readable label used by inspectors and DOM data attributes. */
|
|
||||||
name?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let viewSequence = 0;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Turn a component and props into a lightweight, reusable view recipe.
|
|
||||||
*
|
|
||||||
* Components are marked raw so Vue never attempts to proxy their definitions
|
|
||||||
* when recipes are placed in reactive scene/history structures.
|
|
||||||
*
|
|
||||||
* @typeParam Props - Props passed to the component when the recipe is mounted.
|
|
||||||
* @param component - Vue component definition to mount.
|
|
||||||
* @param props - Props captured by the recipe.
|
|
||||||
* @param options - Optional recipe identity and diagnostic name.
|
|
||||||
* @returns An immutable, reusable component recipe. It is not a Vue instance.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* const profile = originView(
|
|
||||||
* ProfileView,
|
|
||||||
* { userId: "42" },
|
|
||||||
* { key: "profile-42", name: "Profile" },
|
|
||||||
* );
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function originView<
|
|
||||||
T extends Component = Component,
|
|
||||||
>(
|
|
||||||
component: T,
|
|
||||||
props?: PropsOf<T>,
|
|
||||||
options: OriginViewOptions = {},
|
|
||||||
): OriginView<T> {
|
|
||||||
const inferredName =
|
|
||||||
options.name ??
|
|
||||||
(typeof component === "object" && "name" in component
|
|
||||||
? String(component.name)
|
|
||||||
: undefined);
|
|
||||||
return markRaw({
|
|
||||||
component: markRaw(component),
|
|
||||||
props,
|
|
||||||
key: options.key ?? `${inferredName ?? "view"}-${++viewSequence}`,
|
|
||||||
name: inferredName,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function clamp(value: number) {
|
|
||||||
return Math.max(0, Math.min(1, value));
|
|
||||||
}
|
|
||||||
|
|
||||||
function elementRect(element: HTMLElement | null | undefined) {
|
|
||||||
if (!element) return undefined;
|
|
||||||
const rect = element.getBoundingClientRect();
|
|
||||||
return {
|
|
||||||
top: rect.top,
|
|
||||||
left: rect.left,
|
|
||||||
width: rect.width,
|
|
||||||
height: rect.height,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultViewport(element: HTMLElement | null): OriginRect {
|
|
||||||
return (
|
|
||||||
elementRect(element) ?? {
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
width: typeof window === "undefined" ? 1 : window.innerWidth,
|
|
||||||
height: typeof window === "undefined" ? 1 : window.innerHeight,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function prefersReducedMotion() {
|
|
||||||
return (
|
|
||||||
typeof window === "undefined" ||
|
|
||||||
!window.requestAnimationFrame ||
|
|
||||||
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create one independent scene/history context.
|
|
||||||
*
|
|
||||||
* The implementation stores nodes flat. Operation edges describe how their
|
|
||||||
* visual effects compose, but a Vue component never moves to a different VNode
|
|
||||||
* parent as edges are added or removed. This is what preserves component state
|
|
||||||
* while X→Y and Y→Z overlap.
|
|
||||||
*
|
|
||||||
* @param options - Initial root view recipe or recipes.
|
|
||||||
* @returns A self-contained reactive scene. Render it through `OriginScene`.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* const scene = createOriginScene({
|
|
||||||
* initial: originView(HomeView, undefined, { key: "home" }),
|
|
||||||
* });
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function createOriginScene(
|
|
||||||
options: CreateOriginSceneOptions,
|
|
||||||
): OriginScene {
|
|
||||||
const nodes = shallowReactive(new Map<string, SceneNodeState>());
|
|
||||||
const operations = shallowReactive(new Map<number, MutableOriginOperation>());
|
|
||||||
/*
|
|
||||||
* A close gesture temporarily removes a connected resting edge to avoid a
|
|
||||||
* visual cycle (page→drawer→page). If that close is cancelled, this map lets
|
|
||||||
* us restore the exact same edge and component instances.
|
|
||||||
*/
|
|
||||||
const suspendedPresentations = new Map<number, MutableOriginOperation>();
|
|
||||||
const roots = shallowRef<string[]>([]);
|
|
||||||
const elements = new Map<string, HTMLElement>();
|
|
||||||
let container: HTMLElement | null = null;
|
|
||||||
let nodeSequence = 0;
|
|
||||||
let operationSequence = 0;
|
|
||||||
|
|
||||||
function uniqueNodeKey(view: OriginView) {
|
|
||||||
return `${view.key ?? view.name ?? "view"}::${++nodeSequence}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function addNode(
|
|
||||||
view: OriginView,
|
|
||||||
previousNodeKey?: string,
|
|
||||||
incomingOperationId?: number,
|
|
||||||
) {
|
|
||||||
const node: SceneNodeState = shallowReactive({
|
|
||||||
key: uniqueNodeKey(view),
|
|
||||||
sequence: nodeSequence,
|
|
||||||
view: markRaw(view),
|
|
||||||
previousNodeKey,
|
|
||||||
state: incomingOperationId ? "transitioning" : "active",
|
|
||||||
incomingOperationId,
|
|
||||||
});
|
|
||||||
nodes.set(node.key, node);
|
|
||||||
return node;
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialViews = Array.isArray(options.initial)
|
|
||||||
? options.initial
|
|
||||||
: [options.initial];
|
|
||||||
for (const initial of initialViews) {
|
|
||||||
const node = addNode(initial);
|
|
||||||
roots.value = [...roots.value, node.key];
|
|
||||||
}
|
|
||||||
|
|
||||||
function historyNodesFor(node: SceneNodeState) {
|
|
||||||
const history: SceneNodeState[] = [];
|
|
||||||
const visited = new Set<string>([node.key]);
|
|
||||||
let previousKey = node.previousNodeKey;
|
|
||||||
while (previousKey && !visited.has(previousKey)) {
|
|
||||||
visited.add(previousKey);
|
|
||||||
const previous = nodes.get(previousKey);
|
|
||||||
if (!previous) break;
|
|
||||||
history.unshift(previous);
|
|
||||||
previousKey = previous.previousNodeKey;
|
|
||||||
}
|
|
||||||
return history;
|
|
||||||
}
|
|
||||||
|
|
||||||
function contextFor(nodeKey: string): OriginContext {
|
|
||||||
const node = nodes.get(nodeKey);
|
|
||||||
if (!node)
|
|
||||||
throw new Error(`Origin scene node "${nodeKey}" no longer exists.`);
|
|
||||||
const historyNodes = historyNodesFor(node);
|
|
||||||
return {
|
|
||||||
nodeKey,
|
|
||||||
view: node.view,
|
|
||||||
canGoBack: Boolean(node.previousNodeKey),
|
|
||||||
previous: historyNodes.at(-1)?.view,
|
|
||||||
history: historyNodes.map((entry) => entry.view),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function previousNodeForAction(origin: SceneNodeState, action: OriginAction) {
|
|
||||||
switch (action.history ?? "push") {
|
|
||||||
case "push":
|
|
||||||
return origin.key;
|
|
||||||
case "replace":
|
|
||||||
// Skip the origin in the retained chain. The old entry remains mounted
|
|
||||||
// until commit so an interactive replacement can still be cancelled.
|
|
||||||
return origin.previousNodeKey;
|
|
||||||
case "back":
|
|
||||||
return origin.previousNodeKey;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function operationContext(
|
|
||||||
operation: MutableOriginOperation,
|
|
||||||
): OriginChoreographyContext {
|
|
||||||
return {
|
|
||||||
progress: operation.progress,
|
|
||||||
velocity: operation.velocity,
|
|
||||||
phase: operation.phase,
|
|
||||||
intent: operation.intent,
|
|
||||||
originRect: operation.originRect,
|
|
||||||
targetRect: operation.targetRect,
|
|
||||||
viewport: defaultViewport(container),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function effectSetFor(operation: MutableOriginOperation): OriginEffectSet {
|
|
||||||
return operation.choreography.effects(operationContext(operation));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return the origin-frame effects inherited by a node. The source effect of
|
|
||||||
* an ancestor is intentionally excluded: descendants inherit the target side
|
|
||||||
* of an operation, while only the initiating component receives its source
|
|
||||||
* side.
|
|
||||||
*/
|
|
||||||
function inheritedEffects(
|
|
||||||
nodeKey: string,
|
|
||||||
visited = new Set<string>(),
|
|
||||||
): OriginEffect[] {
|
|
||||||
if (visited.has(nodeKey)) return [];
|
|
||||||
visited.add(nodeKey);
|
|
||||||
|
|
||||||
const node = nodes.get(nodeKey);
|
|
||||||
const incoming = node?.incomingOperationId
|
|
||||||
? operations.get(node.incomingOperationId)
|
|
||||||
: undefined;
|
|
||||||
if (!node || !incoming) return [];
|
|
||||||
|
|
||||||
const effects = effectSetFor(incoming);
|
|
||||||
const targetLayer = incoming.placement === "above" ? 1 : -1;
|
|
||||||
return [
|
|
||||||
...inheritedEffects(incoming.originKey, visited),
|
|
||||||
normalizedEffect(effects.frame),
|
|
||||||
normalizedEffect(effects.target, targetLayer),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function visualEffects(nodeKey: string) {
|
|
||||||
const result = inheritedEffects(nodeKey);
|
|
||||||
const outgoing = visualOutgoingFor(nodeKey);
|
|
||||||
if (!outgoing) return result;
|
|
||||||
const effects = effectSetFor(outgoing);
|
|
||||||
return [
|
|
||||||
...result,
|
|
||||||
normalizedEffect(effects.frame),
|
|
||||||
normalizedEffect(effects.source),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function visualOutgoingFor(nodeKey: string) {
|
|
||||||
return [...operations.values()].find(
|
|
||||||
(operation) => operation.originKey === nodeKey,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Collapse independent effect layers into one host style. Geometry remains
|
|
||||||
* composable; arbitrary CSS properties use normal local-last precedence.
|
|
||||||
*/
|
|
||||||
function styleForNode(nodeKey: string): CSSProperties {
|
|
||||||
const node = nodes.get(nodeKey);
|
|
||||||
if (!node) return {};
|
|
||||||
|
|
||||||
const transforms: string[] = [];
|
|
||||||
let opacity = 1;
|
|
||||||
let layer = 0;
|
|
||||||
/*
|
|
||||||
* These rules are compositor invariants rather than visual theming. Keep
|
|
||||||
* them inline so a missing optional package stylesheet can never place
|
|
||||||
* scene nodes back into normal block/flex flow and vertically stack views.
|
|
||||||
*/
|
|
||||||
const style: CSSProperties = {
|
|
||||||
position: "absolute",
|
|
||||||
inset: "0",
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
overflow: "hidden",
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const effect of visualEffects(nodeKey)) {
|
|
||||||
if (effect.transform && effect.transform !== "none")
|
|
||||||
transforms.push(effect.transform);
|
|
||||||
if (effect.opacity !== undefined) opacity *= effect.opacity;
|
|
||||||
layer += effect.layer ?? 0;
|
|
||||||
|
|
||||||
if (effect.style) {
|
|
||||||
const effectStyle = effect.style;
|
|
||||||
Object.assign(style, effectStyle);
|
|
||||||
if (
|
|
||||||
typeof effectStyle.transform === "string" &&
|
|
||||||
effectStyle.transform !== "none"
|
|
||||||
)
|
|
||||||
transforms.push(effectStyle.transform);
|
|
||||||
if (effectStyle.opacity !== undefined) {
|
|
||||||
const numericOpacity = Number(effectStyle.opacity);
|
|
||||||
if (Number.isFinite(numericOpacity)) opacity *= numericOpacity;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Explicitly assign the composited properties after Object.assign so an
|
|
||||||
// individual effect cannot accidentally replace inherited transform work.
|
|
||||||
style.transform = transforms.length ? transforms.join(" ") : "none";
|
|
||||||
style.opacity = String(clamp(opacity));
|
|
||||||
style.zIndex = 1_000_000 + layer * 10_000 + node.sequence;
|
|
||||||
style.pointerEvents = isNodeInteractive(nodeKey) ? "auto" : "none";
|
|
||||||
if (node.state === "parked") {
|
|
||||||
// Parked entries remain mounted in their original flat hosts. Keeping
|
|
||||||
// the DOM preserves local component state and nested scroll positions,
|
|
||||||
// while visibility/inert handling removes them from presentation.
|
|
||||||
style.visibility = "hidden";
|
|
||||||
style.contentVisibility = "hidden";
|
|
||||||
}
|
|
||||||
return style;
|
|
||||||
}
|
|
||||||
|
|
||||||
function outgoingFor(nodeKey: string) {
|
|
||||||
return [...operations.values()].find(
|
|
||||||
(operation) =>
|
|
||||||
operation.originKey === nodeKey && operation.phase !== "finished",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isNodeInteractive(nodeKey: string) {
|
|
||||||
const node = nodes.get(nodeKey);
|
|
||||||
if (!node || node.state === "parked" || node.state === "exposed")
|
|
||||||
return false;
|
|
||||||
|
|
||||||
// A target being cancelled is already scheduled to disappear. A source
|
|
||||||
// settling toward commit has ceded new interactions to the retained scene
|
|
||||||
// beneath/above it. This is node-local fate, not a global "active view".
|
|
||||||
const incoming = node.incomingOperationId
|
|
||||||
? operations.get(node.incomingOperationId)
|
|
||||||
: undefined;
|
|
||||||
if (incoming?.intent === "cancel") return false;
|
|
||||||
return outgoingFor(nodeKey)?.intent !== "commit";
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateOperation(id: number, progress: number, velocity = 0) {
|
|
||||||
const operation = operations.get(id);
|
|
||||||
if (!operation || operation.phase === "finished") return;
|
|
||||||
operation.progress = clamp(progress);
|
|
||||||
operation.velocity = velocity;
|
|
||||||
if (operation.phase === "preparing") return;
|
|
||||||
operation.phase = "interactive";
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeNode(nodeKey: string) {
|
|
||||||
elements.delete(nodeKey);
|
|
||||||
nodes.delete(nodeKey);
|
|
||||||
roots.value = roots.value.filter((key) => key !== nodeKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove a newly created forward branch while leaving the origin and all
|
|
||||||
* earlier retained entries untouched.
|
|
||||||
*/
|
|
||||||
function removeRetainedBranch(nodeKey: string, visited = new Set<string>()) {
|
|
||||||
if (visited.has(nodeKey)) return;
|
|
||||||
visited.add(nodeKey);
|
|
||||||
|
|
||||||
for (const node of [...nodes.values()]) {
|
|
||||||
if (node.previousNodeKey === nodeKey)
|
|
||||||
removeRetainedBranch(node.key, visited);
|
|
||||||
}
|
|
||||||
for (const operation of [...operations.values()]) {
|
|
||||||
if (
|
|
||||||
operation.originKey === nodeKey ||
|
|
||||||
operation.targetKey === nodeKey ||
|
|
||||||
operation.entryTargetKey === nodeKey
|
|
||||||
)
|
|
||||||
operations.delete(operation.id);
|
|
||||||
}
|
|
||||||
removeNode(nodeKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
function refreshVisibleState(node: SceneNodeState) {
|
|
||||||
if (node.state === "parked" || node.state === "exposed") return;
|
|
||||||
const incoming = node.incomingOperationId
|
|
||||||
? operations.get(node.incomingOperationId)
|
|
||||||
: undefined;
|
|
||||||
node.state =
|
|
||||||
(incoming && incoming.phase !== "finished") || outgoingFor(node.key)
|
|
||||||
? "transitioning"
|
|
||||||
: "active";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Transfer an operation's visual graph position to its target without moving
|
|
||||||
* either Vue VNode. The history chain is intentionally independent from this
|
|
||||||
* temporary coordinate graph.
|
|
||||||
*/
|
|
||||||
function spliceVisualTarget(origin: SceneNodeState, target: SceneNodeState) {
|
|
||||||
const parentOperation = origin.incomingOperationId
|
|
||||||
? operations.get(origin.incomingOperationId)
|
|
||||||
: undefined;
|
|
||||||
if (parentOperation) {
|
|
||||||
parentOperation.targetKey = target.key;
|
|
||||||
target.incomingOperationId = parentOperation.id;
|
|
||||||
} else {
|
|
||||||
roots.value = roots.value.map((key) =>
|
|
||||||
key === origin.key ? target.key : key,
|
|
||||||
);
|
|
||||||
target.incomingOperationId = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Finalize a committed operation.
|
|
||||||
*
|
|
||||||
* A normal push parks its origin and collapses the temporary visual edge.
|
|
||||||
* A connected push keeps the edge at progress 1 and leaves the origin
|
|
||||||
* exposed but inert. Replace and back remove the origin.
|
|
||||||
*/
|
|
||||||
function commitOperation(id: number) {
|
|
||||||
const operation = operations.get(id);
|
|
||||||
if (!operation) return false;
|
|
||||||
const origin = nodes.get(operation.originKey);
|
|
||||||
const target = nodes.get(operation.targetKey);
|
|
||||||
if (!origin || !target) return false;
|
|
||||||
|
|
||||||
if (operation.history === "push" && operation.choreography.persistAtRest) {
|
|
||||||
operation.progress = 1;
|
|
||||||
operation.velocity = 0;
|
|
||||||
operation.intent = "commit";
|
|
||||||
operation.phase = "finished";
|
|
||||||
origin.state = "exposed";
|
|
||||||
target.state = "active";
|
|
||||||
refreshVisibleState(target);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
spliceVisualTarget(origin, target);
|
|
||||||
operation.phase = "finished";
|
|
||||||
operations.delete(operation.id);
|
|
||||||
suspendedPresentations.delete(operation.id);
|
|
||||||
|
|
||||||
if (operation.history === "push") {
|
|
||||||
origin.incomingOperationId = undefined;
|
|
||||||
origin.state = "parked";
|
|
||||||
} else {
|
|
||||||
// Back pops the current entry; replace discards it in favor of the new
|
|
||||||
// target. Both preserve every earlier retained instance.
|
|
||||||
removeNode(origin.key);
|
|
||||||
}
|
|
||||||
|
|
||||||
target.state = "active";
|
|
||||||
refreshVisibleState(target);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelOperation(id: number) {
|
|
||||||
const operation = operations.get(id);
|
|
||||||
if (!operation) return;
|
|
||||||
const origin = nodes.get(operation.originKey);
|
|
||||||
const target = nodes.get(operation.entryTargetKey);
|
|
||||||
const suspendedPresentation = suspendedPresentations.get(id);
|
|
||||||
operation.phase = "finished";
|
|
||||||
operations.delete(id);
|
|
||||||
suspendedPresentations.delete(id);
|
|
||||||
|
|
||||||
if (operation.history === "back") {
|
|
||||||
if (suspendedPresentation && origin && target) {
|
|
||||||
/*
|
|
||||||
* Put the retained page back into its former parent/root position, then
|
|
||||||
* reconnect the settled page→drawer edge. Both nodes keep their VNodes.
|
|
||||||
*/
|
|
||||||
spliceVisualTarget(origin, target);
|
|
||||||
operations.set(suspendedPresentation.id, suspendedPresentation);
|
|
||||||
origin.incomingOperationId = suspendedPresentation.id;
|
|
||||||
target.state = "exposed";
|
|
||||||
} else if (target) {
|
|
||||||
target.incomingOperationId = undefined;
|
|
||||||
target.state = "parked";
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
removeRetainedBranch(operation.entryTargetKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (origin) {
|
|
||||||
origin.state = "active";
|
|
||||||
refreshVisibleState(origin);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function settle(id: number, targetProgress: 0 | 1, animate: boolean) {
|
|
||||||
const operation = operations.get(id);
|
|
||||||
if (!operation) return;
|
|
||||||
if (!animate || prefersReducedMotion()) {
|
|
||||||
operation.progress = targetProgress;
|
|
||||||
operation.velocity = 0;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
let position = operation.progress;
|
|
||||||
let velocity = Math.max(-8, Math.min(8, operation.velocity));
|
|
||||||
let previous = performance.now();
|
|
||||||
|
|
||||||
const frame = (time: number) => {
|
|
||||||
const live = operations.get(id);
|
|
||||||
if (!live) return resolve();
|
|
||||||
|
|
||||||
// A damped spring makes release velocity continuous with pointer motion
|
|
||||||
// without imposing a fixed-duration easing on custom choreographies.
|
|
||||||
const elapsed = Math.min(
|
|
||||||
0.032,
|
|
||||||
Math.max(0.001, (time - previous) / 1000),
|
|
||||||
);
|
|
||||||
previous = time;
|
|
||||||
const iterations = Math.max(1, Math.ceil(elapsed / (1 / 120)));
|
|
||||||
const dt = elapsed / iterations;
|
|
||||||
for (let index = 0; index < iterations; index += 1) {
|
|
||||||
const acceleration =
|
|
||||||
(targetProgress - position) * 280 - velocity * 30;
|
|
||||||
velocity += acceleration * dt;
|
|
||||||
position += velocity * dt;
|
|
||||||
}
|
|
||||||
|
|
||||||
const done =
|
|
||||||
Math.abs(targetProgress - position) < 0.002 &&
|
|
||||||
Math.abs(velocity) < 0.02;
|
|
||||||
live.progress = done ? targetProgress : clamp(position);
|
|
||||||
live.velocity = done ? 0 : velocity;
|
|
||||||
if (done) resolve();
|
|
||||||
else window.requestAnimationFrame(frame);
|
|
||||||
};
|
|
||||||
window.requestAnimationFrame(frame);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function finishOperation(
|
|
||||||
id: number,
|
|
||||||
options: OriginFinishOptions = {},
|
|
||||||
) {
|
|
||||||
const operation = operations.get(id);
|
|
||||||
if (!operation) return false;
|
|
||||||
if (operation.phase === "finished") return operation.intent === "commit";
|
|
||||||
|
|
||||||
const threshold = operation.choreography.commitThreshold ?? 0.36;
|
|
||||||
// const velocityThreshold = operation.choreography.commitVelocity ?? 0.9;
|
|
||||||
const shouldCommit =
|
|
||||||
options.commit ??
|
|
||||||
(operation.progress >= threshold ||
|
|
||||||
(operation.progress >= 0.0 && operation.velocity >= 0.3));
|
|
||||||
// operation.velocity >= velocityThreshold));
|
|
||||||
|
|
||||||
// The outcome is known synchronously at release. The target can therefore
|
|
||||||
// originate another gesture while this operation is only visually settling.
|
|
||||||
operation.intent = shouldCommit ? "commit" : "cancel";
|
|
||||||
operation.phase = "settling";
|
|
||||||
await settle(id, shouldCommit ? 1 : 0, options.animate ?? true);
|
|
||||||
|
|
||||||
if (!operations.has(id)) return shouldCommit;
|
|
||||||
if (shouldCommit) return commitOperation(id);
|
|
||||||
cancelOperation(id);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function begin(
|
|
||||||
originKey: string,
|
|
||||||
action: OriginAction,
|
|
||||||
): Promise<OriginOperationHandle> {
|
|
||||||
const origin = nodes.get(originKey);
|
|
||||||
if (!origin)
|
|
||||||
throw new Error(`Cannot animate from missing origin "${originKey}".`);
|
|
||||||
if (origin.state === "parked" || origin.state === "exposed")
|
|
||||||
throw new Error(
|
|
||||||
`Cannot animate from ${origin.state} origin "${originKey}".`,
|
|
||||||
);
|
|
||||||
if (outgoingFor(originKey))
|
|
||||||
throw new Error(
|
|
||||||
`Origin "${originKey}" already has an outgoing operation. ` +
|
|
||||||
"A descendant target may start its own operation instead.",
|
|
||||||
);
|
|
||||||
|
|
||||||
const history = action.history ?? "push";
|
|
||||||
if (action.choreography.persistAtRest && history !== "push")
|
|
||||||
throw new Error(
|
|
||||||
"persistAtRest choreographies require retained-history push navigation.",
|
|
||||||
);
|
|
||||||
let target: SceneNodeState;
|
|
||||||
let suspendedPresentation: MutableOriginOperation | undefined;
|
|
||||||
|
|
||||||
if (history === "back") {
|
|
||||||
const previousKey = origin.previousNodeKey;
|
|
||||||
if (!previousKey)
|
|
||||||
throw new Error(
|
|
||||||
`Cannot go back from origin "${originKey}" without a previous entry.`,
|
|
||||||
);
|
|
||||||
const retainedTarget = nodes.get(previousKey);
|
|
||||||
if (!retainedTarget)
|
|
||||||
throw new Error(
|
|
||||||
`Cannot go back to missing retained entry "${previousKey}".`,
|
|
||||||
);
|
|
||||||
|
|
||||||
/*
|
|
||||||
* If back starts while the immediately preceding forward spring is still
|
|
||||||
* settling, collapse that already-committed edge first. Creating a new
|
|
||||||
* edge back to its origin would otherwise form X→Y→X. Reciprocal
|
|
||||||
* choreographies begin at the same visual endpoints, so this handoff is
|
|
||||||
* continuous without remounting either node.
|
|
||||||
*/
|
|
||||||
const incoming = origin.incomingOperationId
|
|
||||||
? operations.get(origin.incomingOperationId)
|
|
||||||
: undefined;
|
|
||||||
if (incoming?.originKey === retainedTarget.key) {
|
|
||||||
if (incoming.intent !== "commit")
|
|
||||||
throw new Error(
|
|
||||||
"Cannot go back through an undecided forward operation.",
|
|
||||||
);
|
|
||||||
if (incoming.phase !== "finished") commitOperation(incoming.id);
|
|
||||||
|
|
||||||
const connected = operations.get(incoming.id);
|
|
||||||
if (
|
|
||||||
connected?.phase === "finished" &&
|
|
||||||
connected.choreography.persistAtRest
|
|
||||||
) {
|
|
||||||
/*
|
|
||||||
* Re-root A→page→drawer as A→drawer before adding drawer→page.
|
|
||||||
* The reverse choreography starts at the same visual endpoints.
|
|
||||||
*/
|
|
||||||
operations.delete(connected.id);
|
|
||||||
spliceVisualTarget(retainedTarget, origin);
|
|
||||||
retainedTarget.incomingOperationId = undefined;
|
|
||||||
suspendedPresentation = connected;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (outgoingFor(retainedTarget.key))
|
|
||||||
throw new Error(
|
|
||||||
`Retained target "${retainedTarget.key}" already has an outgoing operation.`,
|
|
||||||
);
|
|
||||||
if (retainedTarget.incomingOperationId)
|
|
||||||
throw new Error(
|
|
||||||
`Retained target "${retainedTarget.key}" is already transitioning.`,
|
|
||||||
);
|
|
||||||
target = retainedTarget;
|
|
||||||
} else {
|
|
||||||
if (!action.target)
|
|
||||||
throw new Error(
|
|
||||||
`A "${history}" operation requires a target view recipe.`,
|
|
||||||
);
|
|
||||||
target = addNode(action.target, previousNodeForAction(origin, action));
|
|
||||||
}
|
|
||||||
|
|
||||||
const operationId = ++operationSequence;
|
|
||||||
target.incomingOperationId = operationId;
|
|
||||||
target.state = "transitioning";
|
|
||||||
origin.state = "transitioning";
|
|
||||||
const operation = reactive<MutableOriginOperation>({
|
|
||||||
id: operationId,
|
|
||||||
originKey,
|
|
||||||
targetKey: target.key,
|
|
||||||
entryTargetKey: target.key,
|
|
||||||
choreography: markRaw(action.choreography),
|
|
||||||
placement: action.placement ?? "above",
|
|
||||||
history,
|
|
||||||
progress: 0,
|
|
||||||
velocity: 0,
|
|
||||||
phase: "preparing",
|
|
||||||
intent: "undecided",
|
|
||||||
originRect: elementRect(elements.get(originKey)),
|
|
||||||
});
|
|
||||||
operations.set(operation.id, operation);
|
|
||||||
if (suspendedPresentation)
|
|
||||||
suspendedPresentations.set(operation.id, suspendedPresentation);
|
|
||||||
|
|
||||||
// Let Vue mount the target before measuring it. Gesture composables buffer
|
|
||||||
// pointer progress while this short preparation step is pending.
|
|
||||||
await nextTick();
|
|
||||||
operation.targetRect = elementRect(elements.get(target.key));
|
|
||||||
operation.phase = "interactive";
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: operation.id,
|
|
||||||
originKey,
|
|
||||||
targetKey: target.key,
|
|
||||||
update: (progress, velocity) =>
|
|
||||||
updateOperation(operation.id, progress, velocity),
|
|
||||||
finish: (finishOptions) => finishOperation(operation.id, finishOptions),
|
|
||||||
cancel: async (cancelOptions) => {
|
|
||||||
await finishOperation(operation.id, {
|
|
||||||
commit: false,
|
|
||||||
animate: cancelOptions?.animate,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function perform(originKey: string, action: OriginAction) {
|
|
||||||
const operation = await begin(originKey, action);
|
|
||||||
return operation.finish({ commit: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
nodes: computed(
|
|
||||||
() =>
|
|
||||||
[...nodes.values()].map((node) => ({
|
|
||||||
...node,
|
|
||||||
history: historyNodesFor(node).map((entry) => entry.view),
|
|
||||||
})) as readonly OriginSceneNode[],
|
|
||||||
),
|
|
||||||
operations: computed(
|
|
||||||
() =>
|
|
||||||
[...operations.values()].filter(
|
|
||||||
(operation) => operation.phase !== "finished",
|
|
||||||
) as readonly OriginOperation[],
|
|
||||||
),
|
|
||||||
roots,
|
|
||||||
contextFor,
|
|
||||||
begin,
|
|
||||||
perform,
|
|
||||||
registerElement(nodeKey, element) {
|
|
||||||
if (element) elements.set(nodeKey, element);
|
|
||||||
else elements.delete(nodeKey);
|
|
||||||
},
|
|
||||||
registerContainer(element) {
|
|
||||||
container = element;
|
|
||||||
},
|
|
||||||
styleForNode,
|
|
||||||
isNodeInteractive,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,511 @@
|
|||||||
|
import { markRaw, reactive, shallowReactive, shallowRef } from "vue";
|
||||||
|
import type {
|
||||||
|
OriginAction,
|
||||||
|
OriginHistoryMode,
|
||||||
|
OriginView,
|
||||||
|
} from "../navigation";
|
||||||
|
import { clamp01, type OriginRect } from "../shared";
|
||||||
|
import type {
|
||||||
|
MutableOriginOperation,
|
||||||
|
OriginContext,
|
||||||
|
OriginFinishOptions,
|
||||||
|
OriginSceneNodeState,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
/** Mutable storage for one mounted scene node. */
|
||||||
|
export interface SceneNodeRecord {
|
||||||
|
key: string;
|
||||||
|
sequence: number;
|
||||||
|
view: OriginView;
|
||||||
|
/** Retained-history link: the entry a committed back returns to. */
|
||||||
|
previousNodeKey?: string;
|
||||||
|
state: OriginSceneNodeState;
|
||||||
|
/** Visual link: the operation edge positioning this node as its target. */
|
||||||
|
incomingOperationId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Progress at which a release commits when the choreography does not supply
|
||||||
|
* its own `commitThreshold`.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_COMMIT_THRESHOLD = 0.36;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release velocity (progress units per second) that commits a flick at any
|
||||||
|
* progress. Choreographies' `commitVelocity` is not currently consulted.
|
||||||
|
*/
|
||||||
|
export const FLICK_COMMIT_VELOCITY = 0.3;
|
||||||
|
|
||||||
|
/** Decide a release outcome from explicit options or progress/velocity. */
|
||||||
|
export const shouldCommit = (
|
||||||
|
operation: MutableOriginOperation,
|
||||||
|
options: OriginFinishOptions,
|
||||||
|
): boolean => {
|
||||||
|
if (options.commit !== undefined) return options.commit;
|
||||||
|
const threshold =
|
||||||
|
operation.choreography.commitThreshold ?? DEFAULT_COMMIT_THRESHOLD;
|
||||||
|
return (
|
||||||
|
operation.progress >= threshold ||
|
||||||
|
(operation.progress >= 0 && operation.velocity >= FLICK_COMMIT_VELOCITY)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The scene model: nodes, operation edges, and the rules that rewrite them.
|
||||||
|
*
|
||||||
|
* Two independent structures share these records:
|
||||||
|
*
|
||||||
|
* - **History** links each node to its retained previous entry through
|
||||||
|
* `previousNodeKey`. It changes only when operations commit or cancel.
|
||||||
|
* - **Visual graph** links each target to the operation that positions it
|
||||||
|
* through `incomingOperationId`; each operation links back to its origin.
|
||||||
|
* Nodes without an incoming edge are listed in `roots`.
|
||||||
|
*
|
||||||
|
* Nodes are stored flat so Vue never re-parents a component when edges are
|
||||||
|
* added, spliced, or removed. Every method is synchronous; timing (mounting,
|
||||||
|
* measuring, settling) belongs to `Scene`.
|
||||||
|
*/
|
||||||
|
export class SceneGraph {
|
||||||
|
readonly nodes = shallowReactive(new Map<string, SceneNodeRecord>());
|
||||||
|
readonly operations = shallowReactive(
|
||||||
|
new Map<number, MutableOriginOperation>(),
|
||||||
|
);
|
||||||
|
readonly roots = shallowRef<string[]>([]);
|
||||||
|
/**
|
||||||
|
* Connected resting edges temporarily removed by a back operation, keyed by
|
||||||
|
* that back operation's id. Cancelling the back operation restores the edge.
|
||||||
|
*/
|
||||||
|
private readonly suspendedEdges = new Map<number, MutableOriginOperation>();
|
||||||
|
private nodeSequence = 0;
|
||||||
|
private operationSequence = 0;
|
||||||
|
|
||||||
|
constructor(initial: readonly OriginView[]) {
|
||||||
|
this.roots.value = initial.map((view) => this.createNode(view).key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queries -----------------------------------------------------------------
|
||||||
|
|
||||||
|
node(key: string): SceneNodeRecord | undefined {
|
||||||
|
return this.nodes.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
operation(id: number): MutableOriginOperation | undefined {
|
||||||
|
return this.operations.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The edge positioning `node` as its target, including resting edges. */
|
||||||
|
incomingOf(node: SceneNodeRecord): MutableOriginOperation | undefined {
|
||||||
|
return node.incomingOperationId
|
||||||
|
? this.operations.get(node.incomingOperationId)
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Incoming edges positioning `node`, from the node outward to its root. */
|
||||||
|
incomingChain(node: SceneNodeRecord): MutableOriginOperation[] {
|
||||||
|
const chain: MutableOriginOperation[] = [];
|
||||||
|
const visited = new Set<string>();
|
||||||
|
let current: SceneNodeRecord | undefined = node;
|
||||||
|
while (current && !visited.has(current.key)) {
|
||||||
|
visited.add(current.key);
|
||||||
|
const incoming = this.incomingOf(current);
|
||||||
|
if (!incoming) break;
|
||||||
|
chain.push(incoming);
|
||||||
|
current = this.nodes.get(incoming.originKey);
|
||||||
|
}
|
||||||
|
return chain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The unfinished operation originated by a node, if any. */
|
||||||
|
liveOutgoingOf(nodeKey: string): MutableOriginOperation | undefined {
|
||||||
|
for (const operation of this.operations.values())
|
||||||
|
if (operation.originKey === nodeKey && operation.phase !== "finished")
|
||||||
|
return operation;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The operation whose source effects apply to a node. Unlike
|
||||||
|
* {@link liveOutgoingOf}, this includes a finished, connected resting edge.
|
||||||
|
*/
|
||||||
|
presentedOutgoingOf(nodeKey: string): MutableOriginOperation | undefined {
|
||||||
|
for (const operation of this.operations.values())
|
||||||
|
if (operation.originKey === nodeKey) return operation;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Retained entries preceding `node`, oldest first. */
|
||||||
|
historyOf(node: SceneNodeRecord): SceneNodeRecord[] {
|
||||||
|
const history: SceneNodeRecord[] = [];
|
||||||
|
const visited = new Set<string>([node.key]);
|
||||||
|
let previousKey = node.previousNodeKey;
|
||||||
|
while (previousKey && !visited.has(previousKey)) {
|
||||||
|
visited.add(previousKey);
|
||||||
|
const previous = this.nodes.get(previousKey);
|
||||||
|
if (!previous) break;
|
||||||
|
history.push(previous);
|
||||||
|
previousKey = previous.previousNodeKey;
|
||||||
|
}
|
||||||
|
return history.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
contextFor(nodeKey: string): OriginContext {
|
||||||
|
const node = this.nodes.get(nodeKey);
|
||||||
|
if (!node)
|
||||||
|
throw new Error(`Origin scene node "${nodeKey}" no longer exists.`);
|
||||||
|
const history = this.historyOf(node).map((entry) => entry.view);
|
||||||
|
return {
|
||||||
|
nodeKey,
|
||||||
|
view: node.view,
|
||||||
|
canGoBack: Boolean(node.previousNodeKey),
|
||||||
|
previous: history.at(-1),
|
||||||
|
history,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a node should currently receive input.
|
||||||
|
*
|
||||||
|
* Parked and exposed entries are inert. A target being cancelled is already
|
||||||
|
* scheduled to disappear, and a source settling toward commit has ceded new
|
||||||
|
* interactions to its target. This is node-local fate, not a global "active
|
||||||
|
* view".
|
||||||
|
*/
|
||||||
|
isInteractive(nodeKey: string): boolean {
|
||||||
|
const node = this.nodes.get(nodeKey);
|
||||||
|
if (!node || node.state === "parked" || node.state === "exposed")
|
||||||
|
return false;
|
||||||
|
if (this.incomingOf(node)?.intent === "cancel") return false;
|
||||||
|
return this.liveOutgoingOf(nodeKey)?.intent !== "commit";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transitions -------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate an action and add its operation edge (and, for push/replace, its
|
||||||
|
* target node). The edge starts in the `preparing` phase.
|
||||||
|
*
|
||||||
|
* @throws If the origin cannot originate, or the action cannot be resolved.
|
||||||
|
*/
|
||||||
|
begin(
|
||||||
|
originKey: string,
|
||||||
|
action: OriginAction,
|
||||||
|
originRect: OriginRect | undefined,
|
||||||
|
): MutableOriginOperation {
|
||||||
|
const origin = this.assertCanOriginate(originKey);
|
||||||
|
const history = action.history ?? "push";
|
||||||
|
if (action.choreography.persistAtRest && history !== "push")
|
||||||
|
throw new Error(
|
||||||
|
"persistAtRest choreographies require retained-history push navigation.",
|
||||||
|
);
|
||||||
|
|
||||||
|
const { target, suspendedEdge } =
|
||||||
|
history === "back"
|
||||||
|
? this.resolveBackTarget(origin)
|
||||||
|
: { target: this.createForwardTarget(origin, action, history) };
|
||||||
|
|
||||||
|
const operation = this.createOperation({
|
||||||
|
originKey,
|
||||||
|
targetKey: target.key,
|
||||||
|
entryTargetKey: target.key,
|
||||||
|
choreography: action.choreography,
|
||||||
|
placement: action.placement ?? "above",
|
||||||
|
history,
|
||||||
|
originRect,
|
||||||
|
});
|
||||||
|
target.incomingOperationId = operation.id;
|
||||||
|
target.state = "transitioning";
|
||||||
|
origin.state = "transitioning";
|
||||||
|
if (suspendedEdge) this.suspendedEdges.set(operation.id, suspendedEdge);
|
||||||
|
return operation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply interactive progress to an unfinished operation. */
|
||||||
|
update(id: number, progress: number, velocity: number): void {
|
||||||
|
const operation = this.operations.get(id);
|
||||||
|
if (!operation || operation.phase === "finished") return;
|
||||||
|
operation.progress = clamp01(progress);
|
||||||
|
operation.velocity = velocity;
|
||||||
|
if (operation.phase !== "preparing") operation.phase = "interactive";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finalize a committed operation.
|
||||||
|
*
|
||||||
|
* A normal push parks its origin and collapses the temporary visual edge, so
|
||||||
|
* the target takes the origin's place in the visual graph. A connected
|
||||||
|
* (`persistAtRest`) push keeps the edge at progress 1 and leaves the origin
|
||||||
|
* exposed but inert. Replace and back remove the origin.
|
||||||
|
*
|
||||||
|
* @returns Whether the operation's nodes still existed to be committed.
|
||||||
|
*/
|
||||||
|
commit(id: number): boolean {
|
||||||
|
const operation = this.operations.get(id);
|
||||||
|
if (!operation) return false;
|
||||||
|
const origin = this.nodes.get(operation.originKey);
|
||||||
|
const target = this.nodes.get(operation.targetKey);
|
||||||
|
if (!origin || !target) return false;
|
||||||
|
|
||||||
|
if (operation.history === "push" && operation.choreography.persistAtRest) {
|
||||||
|
operation.progress = 1;
|
||||||
|
operation.velocity = 0;
|
||||||
|
operation.intent = "commit";
|
||||||
|
operation.phase = "finished";
|
||||||
|
origin.state = "exposed";
|
||||||
|
this.activate(target);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.transferPosition(origin, target);
|
||||||
|
operation.phase = "finished";
|
||||||
|
this.operations.delete(operation.id);
|
||||||
|
this.suspendedEdges.delete(operation.id);
|
||||||
|
|
||||||
|
if (operation.history === "push") {
|
||||||
|
origin.incomingOperationId = undefined;
|
||||||
|
origin.state = "parked";
|
||||||
|
} else {
|
||||||
|
// Back pops the current entry; replace discards it in favor of the new
|
||||||
|
// target. Both preserve every earlier retained instance.
|
||||||
|
this.removeNode(origin.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.activate(target);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finalize a cancelled operation, restoring the origin.
|
||||||
|
*
|
||||||
|
* A cancelled back re-parks its retained target, or re-attaches the resting
|
||||||
|
* edge it suspended. A cancelled push/replace removes its new target branch.
|
||||||
|
*/
|
||||||
|
cancel(id: number): void {
|
||||||
|
const operation = this.operations.get(id);
|
||||||
|
if (!operation) return;
|
||||||
|
const origin = this.nodes.get(operation.originKey);
|
||||||
|
const target = this.nodes.get(operation.entryTargetKey);
|
||||||
|
const suspendedEdge = this.suspendedEdges.get(id);
|
||||||
|
operation.phase = "finished";
|
||||||
|
this.operations.delete(id);
|
||||||
|
this.suspendedEdges.delete(id);
|
||||||
|
|
||||||
|
if (operation.history === "back") {
|
||||||
|
if (suspendedEdge && origin && target) {
|
||||||
|
// Put the retained page back into its former position, then reconnect
|
||||||
|
// the resting page→drawer edge. Both nodes keep their VNodes.
|
||||||
|
this.transferPosition(origin, target);
|
||||||
|
this.operations.set(suspendedEdge.id, suspendedEdge);
|
||||||
|
origin.incomingOperationId = suspendedEdge.id;
|
||||||
|
target.state = "exposed";
|
||||||
|
} else if (target) {
|
||||||
|
target.incomingOperationId = undefined;
|
||||||
|
target.state = "parked";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.removeBranch(operation.entryTargetKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (origin) this.activate(origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transition steps --------------------------------------------------------
|
||||||
|
|
||||||
|
private assertCanOriginate(originKey: string): SceneNodeRecord {
|
||||||
|
const origin = this.nodes.get(originKey);
|
||||||
|
if (!origin)
|
||||||
|
throw new Error(`Cannot animate from missing origin "${originKey}".`);
|
||||||
|
if (origin.state === "parked" || origin.state === "exposed")
|
||||||
|
throw new Error(
|
||||||
|
`Cannot animate from ${origin.state} origin "${originKey}".`,
|
||||||
|
);
|
||||||
|
if (this.liveOutgoingOf(originKey))
|
||||||
|
throw new Error(
|
||||||
|
`Origin "${originKey}" already has an outgoing operation. ` +
|
||||||
|
"A descendant target may start its own operation instead.",
|
||||||
|
);
|
||||||
|
return origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the retained previous entry for a back operation.
|
||||||
|
*
|
||||||
|
* If the origin's incoming edge comes from that previous entry (a forward
|
||||||
|
* push still settling, or a connected resting edge), the edge is collapsed
|
||||||
|
* first. Creating a new edge back to its origin would otherwise form X→Y→X.
|
||||||
|
* Reciprocal choreographies begin at the same visual endpoints, so this
|
||||||
|
* handoff is continuous without remounting either node.
|
||||||
|
*/
|
||||||
|
private resolveBackTarget(origin: SceneNodeRecord): {
|
||||||
|
target: SceneNodeRecord;
|
||||||
|
suspendedEdge?: MutableOriginOperation;
|
||||||
|
} {
|
||||||
|
const previousKey = origin.previousNodeKey;
|
||||||
|
if (!previousKey)
|
||||||
|
throw new Error(
|
||||||
|
`Cannot go back from origin "${origin.key}" without a previous entry.`,
|
||||||
|
);
|
||||||
|
const target = this.nodes.get(previousKey);
|
||||||
|
if (!target)
|
||||||
|
throw new Error(
|
||||||
|
`Cannot go back to missing retained entry "${previousKey}".`,
|
||||||
|
);
|
||||||
|
|
||||||
|
let suspendedEdge: MutableOriginOperation | undefined;
|
||||||
|
const incoming = this.incomingOf(origin);
|
||||||
|
if (incoming?.originKey === target.key) {
|
||||||
|
if (incoming.intent !== "commit")
|
||||||
|
throw new Error(
|
||||||
|
"Cannot go back through an undecided forward operation.",
|
||||||
|
);
|
||||||
|
if (incoming.phase !== "finished") this.commit(incoming.id);
|
||||||
|
|
||||||
|
const resting = this.operations.get(incoming.id);
|
||||||
|
if (resting?.phase === "finished" && resting.choreography.persistAtRest) {
|
||||||
|
// Re-root A→page→drawer as A→drawer before adding drawer→page.
|
||||||
|
this.operations.delete(resting.id);
|
||||||
|
this.transferPosition(target, origin);
|
||||||
|
target.incomingOperationId = undefined;
|
||||||
|
suspendedEdge = resting;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.liveOutgoingOf(target.key))
|
||||||
|
throw new Error(
|
||||||
|
`Retained target "${target.key}" already has an outgoing operation.`,
|
||||||
|
);
|
||||||
|
if (target.incomingOperationId)
|
||||||
|
throw new Error(
|
||||||
|
`Retained target "${target.key}" is already transitioning.`,
|
||||||
|
);
|
||||||
|
return { target, suspendedEdge };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create the target node for a push or replace operation. */
|
||||||
|
private createForwardTarget(
|
||||||
|
origin: SceneNodeRecord,
|
||||||
|
action: OriginAction,
|
||||||
|
history: Exclude<OriginHistoryMode, "back">,
|
||||||
|
): SceneNodeRecord {
|
||||||
|
if (!action.target)
|
||||||
|
throw new Error(
|
||||||
|
`A "${history}" operation requires a target view recipe.`,
|
||||||
|
);
|
||||||
|
// A replacement skips the origin in the retained chain. The origin stays
|
||||||
|
// mounted until commit so an interactive replacement can still cancel.
|
||||||
|
const previousKey =
|
||||||
|
history === "push" ? origin.key : origin.previousNodeKey;
|
||||||
|
return this.createNode(action.target, previousKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
private activate(node: SceneNodeRecord): void {
|
||||||
|
node.state = "active";
|
||||||
|
this.refreshPresentedState(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recompute whether a presented node is participating in a live operation.
|
||||||
|
* Parked and exposed nodes keep their retained state.
|
||||||
|
*/
|
||||||
|
private refreshPresentedState(node: SceneNodeRecord): void {
|
||||||
|
if (node.state === "parked" || node.state === "exposed") return;
|
||||||
|
const incoming = this.incomingOf(node);
|
||||||
|
node.state =
|
||||||
|
(incoming && incoming.phase !== "finished") ||
|
||||||
|
this.liveOutgoingOf(node.key)
|
||||||
|
? "transitioning"
|
||||||
|
: "active";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Structural primitives ---------------------------------------------------
|
||||||
|
|
||||||
|
private createNode(
|
||||||
|
view: OriginView,
|
||||||
|
previousNodeKey?: string,
|
||||||
|
): SceneNodeRecord {
|
||||||
|
const sequence = ++this.nodeSequence;
|
||||||
|
const node: SceneNodeRecord = shallowReactive({
|
||||||
|
key: `${view.key ?? view.name ?? "view"}::${sequence}`,
|
||||||
|
sequence,
|
||||||
|
view: markRaw(view),
|
||||||
|
previousNodeKey,
|
||||||
|
state: "active",
|
||||||
|
incomingOperationId: undefined,
|
||||||
|
});
|
||||||
|
this.nodes.set(node.key, node);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
private createOperation(
|
||||||
|
init: Pick<
|
||||||
|
MutableOriginOperation,
|
||||||
|
| "originKey"
|
||||||
|
| "targetKey"
|
||||||
|
| "entryTargetKey"
|
||||||
|
| "choreography"
|
||||||
|
| "placement"
|
||||||
|
| "history"
|
||||||
|
| "originRect"
|
||||||
|
>,
|
||||||
|
): MutableOriginOperation {
|
||||||
|
const operation = reactive<MutableOriginOperation>({
|
||||||
|
id: ++this.operationSequence,
|
||||||
|
originKey: init.originKey,
|
||||||
|
targetKey: init.targetKey,
|
||||||
|
entryTargetKey: init.entryTargetKey,
|
||||||
|
choreography: markRaw(init.choreography),
|
||||||
|
placement: init.placement,
|
||||||
|
history: init.history,
|
||||||
|
progress: 0,
|
||||||
|
velocity: 0,
|
||||||
|
phase: "preparing",
|
||||||
|
intent: "undecided",
|
||||||
|
originRect: init.originRect,
|
||||||
|
});
|
||||||
|
this.operations.set(operation.id, operation);
|
||||||
|
return operation;
|
||||||
|
}
|
||||||
|
|
||||||
|
private removeNode(nodeKey: string): void {
|
||||||
|
this.nodes.delete(nodeKey);
|
||||||
|
this.roots.value = this.roots.value.filter((key) => key !== nodeKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a node, every node whose retained history passes through it, and
|
||||||
|
* every operation touching any of them. Earlier entries are untouched.
|
||||||
|
*/
|
||||||
|
private removeBranch(nodeKey: string, visited = new Set<string>()): void {
|
||||||
|
if (visited.has(nodeKey)) return;
|
||||||
|
visited.add(nodeKey);
|
||||||
|
|
||||||
|
for (const node of [...this.nodes.values()])
|
||||||
|
if (node.previousNodeKey === nodeKey)
|
||||||
|
this.removeBranch(node.key, visited);
|
||||||
|
for (const operation of [...this.operations.values()])
|
||||||
|
if (
|
||||||
|
operation.originKey === nodeKey ||
|
||||||
|
operation.targetKey === nodeKey ||
|
||||||
|
operation.entryTargetKey === nodeKey
|
||||||
|
)
|
||||||
|
this.operations.delete(operation.id);
|
||||||
|
this.removeNode(nodeKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Give `target` the visual-graph position currently held by `from`: either
|
||||||
|
* as the target of `from`'s incoming edge, or as a root. Neither Vue VNode
|
||||||
|
* moves; only edge bookkeeping changes.
|
||||||
|
*/
|
||||||
|
private transferPosition(from: SceneNodeRecord, target: SceneNodeRecord) {
|
||||||
|
const parent = this.incomingOf(from);
|
||||||
|
if (parent) {
|
||||||
|
parent.targetKey = target.key;
|
||||||
|
target.incomingOperationId = parent.id;
|
||||||
|
} else {
|
||||||
|
this.roots.value = this.roots.value.map((key) =>
|
||||||
|
key === from.key ? target.key : key,
|
||||||
|
);
|
||||||
|
target.incomingOperationId = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,509 @@
|
|||||||
|
import {
|
||||||
|
computed,
|
||||||
|
inject,
|
||||||
|
markRaw,
|
||||||
|
nextTick,
|
||||||
|
type ComputedRef,
|
||||||
|
type CSSProperties,
|
||||||
|
type InjectionKey,
|
||||||
|
} from "vue";
|
||||||
|
import {
|
||||||
|
composeEffects,
|
||||||
|
normalizedEffect,
|
||||||
|
type OriginEffect,
|
||||||
|
type OriginEffectSet,
|
||||||
|
type OriginOperationIntent,
|
||||||
|
} from "../choreography";
|
||||||
|
import type { OriginAction, OriginView } from "../navigation";
|
||||||
|
import {
|
||||||
|
clamp,
|
||||||
|
clamp01,
|
||||||
|
measureRect,
|
||||||
|
prefersReducedMotion,
|
||||||
|
windowRect,
|
||||||
|
type OriginRect,
|
||||||
|
} from "../shared";
|
||||||
|
import { SceneGraph, shouldCommit, type SceneNodeRecord } from "./graph";
|
||||||
|
import type {
|
||||||
|
CreateOriginSceneOptions,
|
||||||
|
MutableOriginOperation,
|
||||||
|
OriginContext,
|
||||||
|
OriginFinishOptions,
|
||||||
|
OriginOperation,
|
||||||
|
OriginOperationHandle,
|
||||||
|
OriginScene,
|
||||||
|
OriginSceneNode,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
// Compositing ---------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compositor invariants rather than visual theming. They stay inline so a
|
||||||
|
* missing optional stylesheet can never place scene nodes back into normal
|
||||||
|
* block/flex flow and vertically stack views.
|
||||||
|
*
|
||||||
|
* Deliberately not frozen: it is spread once per node per frame, and V8
|
||||||
|
* spreads frozen objects markedly slower.
|
||||||
|
*/
|
||||||
|
const NODE_BASE_STYLE: Readonly<CSSProperties> = {
|
||||||
|
position: "absolute",
|
||||||
|
inset: "0",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
overflow: "hidden",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Base z-index keeping every node above content outside the scene. */
|
||||||
|
const Z_INDEX_BASE = 1_000_000;
|
||||||
|
/** z-index distance between adjacent relative layers. */
|
||||||
|
const Z_INDEX_PER_LAYER = 10_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Effect layers applied to a node, ordered outermost frame first.
|
||||||
|
*
|
||||||
|
* A node inherits the frame and target effects of every edge on its incoming
|
||||||
|
* chain, then adds the frame and source effects of the edge it originates.
|
||||||
|
* Ancestors' source effects are intentionally excluded: only the initiating
|
||||||
|
* component receives the source side of an operation.
|
||||||
|
*
|
||||||
|
* @param viewport - Measures the scene bounds supplied to choreographies.
|
||||||
|
*/
|
||||||
|
export const nodeEffectLayers = (
|
||||||
|
graph: SceneGraph,
|
||||||
|
node: SceneNodeRecord,
|
||||||
|
viewport: () => OriginRect,
|
||||||
|
): OriginEffect[] => {
|
||||||
|
const effectsOf = (operation: MutableOriginOperation): OriginEffectSet =>
|
||||||
|
operation.choreography.effects({
|
||||||
|
progress: operation.progress,
|
||||||
|
velocity: operation.velocity,
|
||||||
|
phase: operation.phase,
|
||||||
|
intent: operation.intent,
|
||||||
|
originRect: operation.originRect,
|
||||||
|
targetRect: operation.targetRect,
|
||||||
|
viewport: viewport(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const chain = graph.incomingChain(node);
|
||||||
|
// Evaluate choreographies node-first, then apply them outermost-first.
|
||||||
|
const effectSets = chain.map(effectsOf);
|
||||||
|
const layers: OriginEffect[] = [];
|
||||||
|
for (let index = chain.length - 1; index >= 0; index -= 1) {
|
||||||
|
const targetLayer = chain[index]!.placement === "above" ? 1 : -1;
|
||||||
|
layers.push(
|
||||||
|
normalizedEffect(effectSets[index]!.frame),
|
||||||
|
normalizedEffect(effectSets[index]!.target, targetLayer),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const outgoing = graph.presentedOutgoingOf(node.key);
|
||||||
|
if (outgoing) {
|
||||||
|
const effects = effectsOf(outgoing);
|
||||||
|
layers.push(
|
||||||
|
normalizedEffect(effects.frame),
|
||||||
|
normalizedEffect(effects.source),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return layers;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Collapse every effect layer applied to a node into its host's style. */
|
||||||
|
export const composeNodeStyle = (
|
||||||
|
graph: SceneGraph,
|
||||||
|
nodeKey: string,
|
||||||
|
viewport: () => OriginRect,
|
||||||
|
): CSSProperties => {
|
||||||
|
const node = graph.node(nodeKey);
|
||||||
|
if (!node) return {};
|
||||||
|
|
||||||
|
// Measure the scene at most once, and only when an edge needs it.
|
||||||
|
let measured: OriginRect | undefined;
|
||||||
|
const lazyViewport = () => (measured ??= viewport());
|
||||||
|
const { style, layer } = composeEffects(
|
||||||
|
nodeEffectLayers(graph, node, lazyViewport),
|
||||||
|
NODE_BASE_STYLE,
|
||||||
|
);
|
||||||
|
style.zIndex = Z_INDEX_BASE + layer * Z_INDEX_PER_LAYER + node.sequence;
|
||||||
|
style.pointerEvents = graph.isInteractive(nodeKey) ? "auto" : "none";
|
||||||
|
if (node.state === "parked") {
|
||||||
|
// Parked entries remain mounted in their original flat hosts. Keeping the
|
||||||
|
// DOM preserves local component state and nested scroll positions, while
|
||||||
|
// visibility/inert handling removes them from presentation.
|
||||||
|
style.visibility = "hidden";
|
||||||
|
style.contentVisibility = "hidden";
|
||||||
|
}
|
||||||
|
return style;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Settling spring -----------------------------------------------------------
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A damped spring settles an operation toward 0 or 1. It keeps release
|
||||||
|
* velocity continuous with pointer motion without imposing a fixed-duration
|
||||||
|
* easing on custom choreographies.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STIFFNESS = 280;
|
||||||
|
const DAMPING = 30;
|
||||||
|
/** Fixed integration step, in seconds. */
|
||||||
|
const STEP = 1 / 120;
|
||||||
|
/** Frame durations are clamped to this range, in seconds. */
|
||||||
|
const MIN_FRAME = 0.001;
|
||||||
|
const MAX_FRAME = 0.032;
|
||||||
|
/** Release velocities beyond this magnitude are clamped. */
|
||||||
|
const MAX_INITIAL_VELOCITY = 8;
|
||||||
|
const POSITION_TOLERANCE = 0.002;
|
||||||
|
const VELOCITY_TOLERANCE = 0.02;
|
||||||
|
|
||||||
|
/** Position and velocity of a spring, in progress units. */
|
||||||
|
export interface SpringState {
|
||||||
|
readonly position: number;
|
||||||
|
readonly velocity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advance a spring by one animation frame.
|
||||||
|
*
|
||||||
|
* @param elapsedMs - Time since the previous frame in milliseconds.
|
||||||
|
*/
|
||||||
|
export const stepSpring = (
|
||||||
|
state: SpringState,
|
||||||
|
target: number,
|
||||||
|
elapsedMs: number,
|
||||||
|
): SpringState & { readonly done: boolean } => {
|
||||||
|
const elapsed = clamp(elapsedMs / 1000, MIN_FRAME, MAX_FRAME);
|
||||||
|
const iterations = Math.max(1, Math.ceil(elapsed / STEP));
|
||||||
|
const dt = elapsed / iterations;
|
||||||
|
let { position, velocity } = state;
|
||||||
|
for (let index = 0; index < iterations; index += 1) {
|
||||||
|
const acceleration = (target - position) * STIFFNESS - velocity * DAMPING;
|
||||||
|
velocity += acceleration * dt;
|
||||||
|
position += velocity * dt;
|
||||||
|
}
|
||||||
|
const done =
|
||||||
|
Math.abs(target - position) < POSITION_TOLERANCE &&
|
||||||
|
Math.abs(velocity) < VELOCITY_TOLERANCE;
|
||||||
|
return { position, velocity, done };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a spring on `requestAnimationFrame` until it rests at `target` or
|
||||||
|
* `onFrame` returns `false` because its subject no longer exists.
|
||||||
|
*
|
||||||
|
* Every reported progress is clamped to `0..1`; the final frame reports
|
||||||
|
* exactly `target` with zero velocity.
|
||||||
|
*/
|
||||||
|
export const runSpring = (
|
||||||
|
from: SpringState,
|
||||||
|
target: number,
|
||||||
|
onFrame: (progress: number, velocity: number) => boolean,
|
||||||
|
): Promise<void> =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
let state: SpringState = {
|
||||||
|
position: from.position,
|
||||||
|
velocity: clamp(
|
||||||
|
from.velocity,
|
||||||
|
-MAX_INITIAL_VELOCITY,
|
||||||
|
MAX_INITIAL_VELOCITY,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let previous = performance.now();
|
||||||
|
|
||||||
|
const frame = (time: number) => {
|
||||||
|
const next = stepSpring(state, target, time - previous);
|
||||||
|
previous = time;
|
||||||
|
state = next;
|
||||||
|
const alive = next.done
|
||||||
|
? onFrame(target, 0)
|
||||||
|
: onFrame(clamp01(next.position), next.velocity);
|
||||||
|
if (!alive || next.done) resolve();
|
||||||
|
else window.requestAnimationFrame(frame);
|
||||||
|
};
|
||||||
|
window.requestAnimationFrame(frame);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Scene ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Imperative controller for one operation, returned by `Scene.begin()`. */
|
||||||
|
class OperationHandle implements OriginOperationHandle {
|
||||||
|
readonly id: number;
|
||||||
|
readonly originKey: string;
|
||||||
|
readonly targetKey: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly scene: Scene,
|
||||||
|
/** Kept after the graph drops it, so `intent` stays readable. */
|
||||||
|
private readonly operation: MutableOriginOperation,
|
||||||
|
/** The target at creation; later edge rewrites do not change it. */
|
||||||
|
targetKey: string,
|
||||||
|
) {
|
||||||
|
markRaw(this);
|
||||||
|
this.id = operation.id;
|
||||||
|
this.originKey = operation.originKey;
|
||||||
|
this.targetKey = targetKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
get intent(): OriginOperationIntent {
|
||||||
|
return this.operation.intent;
|
||||||
|
}
|
||||||
|
|
||||||
|
update(progress: number, velocity = 0): void {
|
||||||
|
this.scene.graph.update(this.id, progress, velocity);
|
||||||
|
}
|
||||||
|
|
||||||
|
finish(options?: OriginFinishOptions): Promise<boolean> {
|
||||||
|
return this.scene.finish(this.id, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancel(options?: Pick<OriginFinishOptions, "animate">): Promise<void> {
|
||||||
|
await this.scene.finish(this.id, {
|
||||||
|
commit: false,
|
||||||
|
animate: options?.animate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One independent scene: the {@link SceneGraph} model plus the timing around
|
||||||
|
* it — waiting for Vue to mount targets, measuring hosts, and settling
|
||||||
|
* released operations with a spring.
|
||||||
|
*/
|
||||||
|
class Scene implements OriginScene {
|
||||||
|
/** @internal */
|
||||||
|
readonly graph: SceneGraph;
|
||||||
|
readonly nodes: ComputedRef<readonly OriginSceneNode[]>;
|
||||||
|
readonly operations: ComputedRef<readonly OriginOperation[]>;
|
||||||
|
readonly roots: SceneGraph["roots"];
|
||||||
|
|
||||||
|
/** DOM registered by the renderer, used only for measurement. */
|
||||||
|
private readonly hosts = new Map<string, HTMLElement>();
|
||||||
|
private container: HTMLElement | null = null;
|
||||||
|
|
||||||
|
constructor(options: CreateOriginSceneOptions) {
|
||||||
|
// Never proxy the scene: its methods rely on raw internal state.
|
||||||
|
markRaw(this);
|
||||||
|
this.graph = new SceneGraph(
|
||||||
|
Array.isArray(options.initial) ? options.initial : [options.initial],
|
||||||
|
);
|
||||||
|
this.roots = this.graph.roots;
|
||||||
|
this.nodes = computed(() =>
|
||||||
|
[...this.graph.nodes.values()].map((node) => ({
|
||||||
|
...node,
|
||||||
|
history: this.graph.historyOf(node).map((entry) => entry.view),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
this.operations = computed(() =>
|
||||||
|
[...this.graph.operations.values()].filter(
|
||||||
|
(operation) => operation.phase !== "finished",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
contextFor(nodeKey: string): OriginContext {
|
||||||
|
return this.graph.contextFor(nodeKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
async begin(
|
||||||
|
originKey: string,
|
||||||
|
action: OriginAction,
|
||||||
|
): Promise<OriginOperationHandle> {
|
||||||
|
const operation = this.graph.begin(
|
||||||
|
originKey,
|
||||||
|
action,
|
||||||
|
this.measure(originKey),
|
||||||
|
);
|
||||||
|
const { targetKey } = operation;
|
||||||
|
|
||||||
|
// Let Vue mount the target before measuring it. Gesture recognizers
|
||||||
|
// buffer pointer progress while this short preparation step is pending.
|
||||||
|
await nextTick();
|
||||||
|
operation.targetRect = this.measure(targetKey);
|
||||||
|
operation.phase = "interactive";
|
||||||
|
return new OperationHandle(this, operation, targetKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
async perform(originKey: string, action: OriginAction): Promise<boolean> {
|
||||||
|
const handle = await this.begin(originKey, action);
|
||||||
|
return handle.finish({ commit: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fix an operation's outcome, settle it, and rewrite the graph.
|
||||||
|
*
|
||||||
|
* @internal Called through {@link OperationHandle}.
|
||||||
|
*/
|
||||||
|
async finish(id: number, options: OriginFinishOptions = {}) {
|
||||||
|
const operation = this.graph.operation(id);
|
||||||
|
if (!operation) return false;
|
||||||
|
if (operation.phase === "finished") return operation.intent === "commit";
|
||||||
|
|
||||||
|
// The outcome is fixed at release, so the target can originate another
|
||||||
|
// operation while this one is only visually settling.
|
||||||
|
const commit = shouldCommit(operation, options);
|
||||||
|
operation.intent = commit ? "commit" : "cancel";
|
||||||
|
operation.phase = "settling";
|
||||||
|
await this.settle(id, commit ? 1 : 0, options.animate ?? true);
|
||||||
|
|
||||||
|
// A later operation may already have finalized this edge.
|
||||||
|
if (!this.graph.operation(id)) return commit;
|
||||||
|
if (commit) return this.graph.commit(id);
|
||||||
|
this.graph.cancel(id);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
registerElement(nodeKey: string, element: HTMLElement | null): void {
|
||||||
|
if (element) this.hosts.set(nodeKey, element);
|
||||||
|
else this.hosts.delete(nodeKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
registerContainer(element: HTMLElement | null): void {
|
||||||
|
this.container = element;
|
||||||
|
}
|
||||||
|
|
||||||
|
styleForNode(nodeKey: string): CSSProperties {
|
||||||
|
return composeNodeStyle(this.graph, nodeKey, () => this.viewport());
|
||||||
|
}
|
||||||
|
|
||||||
|
isNodeInteractive(nodeKey: string): boolean {
|
||||||
|
return this.graph.isInteractive(nodeKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Move an operation to 0 or 1, animated unless motion is disabled. */
|
||||||
|
private async settle(id: number, target: 0 | 1, animate: boolean) {
|
||||||
|
const operation = this.graph.operation(id);
|
||||||
|
if (!operation) return;
|
||||||
|
if (!animate || prefersReducedMotion()) {
|
||||||
|
operation.progress = target;
|
||||||
|
operation.velocity = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const from = { position: operation.progress, velocity: operation.velocity };
|
||||||
|
await runSpring(from, target, (progress, velocity) => {
|
||||||
|
const live = this.graph.operation(id);
|
||||||
|
if (!live) return false;
|
||||||
|
live.progress = progress;
|
||||||
|
live.velocity = velocity;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private measure(nodeKey: string): OriginRect | undefined {
|
||||||
|
const host = this.hosts.get(nodeKey);
|
||||||
|
return host ? measureRect(host) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scene-container bounds, or the browser window before mounting. */
|
||||||
|
private viewport(): OriginRect {
|
||||||
|
return this.container ? measureRect(this.container) : windowRect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create one independent scene/history context.
|
||||||
|
*
|
||||||
|
* The implementation stores nodes flat. Operation edges describe how their
|
||||||
|
* visual effects compose, but a Vue component never moves to a different VNode
|
||||||
|
* parent as edges are added or removed. This is what preserves component state
|
||||||
|
* while X→Y and Y→Z overlap.
|
||||||
|
*
|
||||||
|
* @param options - Initial root view recipe or recipes.
|
||||||
|
* @returns A self-contained reactive scene. Render it through `OriginScene`.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const scene = createOriginScene({
|
||||||
|
* initial: originView(HomeView, undefined, { key: "home" }),
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function createOriginScene(
|
||||||
|
options: CreateOriginSceneOptions,
|
||||||
|
): OriginScene {
|
||||||
|
return new Scene(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Node-scoped access --------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Injection payload provided by each stable scene-node host.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export interface OriginNodeScope {
|
||||||
|
/** Scene containing the node. */
|
||||||
|
readonly scene: OriginScene;
|
||||||
|
/** Unique key of the mounted node. */
|
||||||
|
readonly nodeKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Node-scoped controls returned by {@link useOrigin}. */
|
||||||
|
export interface UseOrigin {
|
||||||
|
/** Key of the mounted node containing the calling component. */
|
||||||
|
readonly nodeKey: string;
|
||||||
|
/** Scene containing the calling component. */
|
||||||
|
readonly scene: OriginScene;
|
||||||
|
/** Reactive context for the calling component's scene node. */
|
||||||
|
readonly context: ComputedRef<OriginContext>;
|
||||||
|
/** Reactive shorthand for `context.value.view`. */
|
||||||
|
readonly view: ComputedRef<OriginView>;
|
||||||
|
/** Reactive shorthand for `context.value.previous`. */
|
||||||
|
readonly previous: ComputedRef<OriginView | undefined>;
|
||||||
|
/** Reactive shorthand for `context.value.canGoBack`. */
|
||||||
|
readonly canGoBack: ComputedRef<boolean>;
|
||||||
|
/** Begin an interactively controlled operation from this component. */
|
||||||
|
begin(action: OriginAction): Promise<OriginOperationHandle>;
|
||||||
|
/** Programmatically create and commit a target from this component. */
|
||||||
|
perform(action: OriginAction): Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Injection key used by the internal scene-node host to establish origin
|
||||||
|
* ownership for descendant components.
|
||||||
|
*
|
||||||
|
* Application code normally calls {@link useOrigin} instead of injecting this
|
||||||
|
* key directly.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export const originNodeScopeKey: InjectionKey<OriginNodeScope> =
|
||||||
|
Symbol("origin-node-scope");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Access the scene from the component that owns an interaction declaration.
|
||||||
|
*
|
||||||
|
* There is deliberately no `activeView`: the injected node is the origin
|
||||||
|
* because this component is where the event or application action occurred.
|
||||||
|
*
|
||||||
|
* @returns Node-scoped scene state and operation controls.
|
||||||
|
* @throws If called outside a component rendered by `OriginScene`.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const origin = useOrigin();
|
||||||
|
*
|
||||||
|
* function openProfile() {
|
||||||
|
* return origin.perform(
|
||||||
|
* forward(originView(ProfileView), slideLeft),
|
||||||
|
* );
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useOrigin(): UseOrigin {
|
||||||
|
const scope = inject(originNodeScopeKey);
|
||||||
|
if (!scope)
|
||||||
|
throw new Error("useOrigin() must be called inside an <OriginScene> view.");
|
||||||
|
|
||||||
|
const { scene, nodeKey } = scope;
|
||||||
|
const context = computed(() => scene.contextFor(nodeKey));
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodeKey,
|
||||||
|
scene,
|
||||||
|
context,
|
||||||
|
view: computed(() => context.value.view),
|
||||||
|
previous: computed(() => context.value.previous),
|
||||||
|
canGoBack: computed(() => context.value.canGoBack),
|
||||||
|
begin: (action) => scene.begin(nodeKey, action),
|
||||||
|
perform: (action) => scene.perform(nodeKey, action),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import type { ComputedRef, CSSProperties, ShallowRef } from "vue";
|
||||||
|
import type {
|
||||||
|
OriginChoreography,
|
||||||
|
OriginOperationIntent,
|
||||||
|
OriginOperationPhase,
|
||||||
|
} from "../choreography";
|
||||||
|
import type {
|
||||||
|
OriginAction,
|
||||||
|
OriginHistoryMode,
|
||||||
|
OriginPlacement,
|
||||||
|
OriginView,
|
||||||
|
} from "../navigation";
|
||||||
|
import type { OriginRect } from "../shared";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Visibility/lifecycle role of a mounted scene node.
|
||||||
|
*
|
||||||
|
* - `active`: currently exposed for normal interaction.
|
||||||
|
* - `transitioning`: participating in at least one live operation edge.
|
||||||
|
* - `exposed`: retained and visually presented behind/beside the active entry,
|
||||||
|
* but inert.
|
||||||
|
* - `parked`: retained in history but visually hidden and inert.
|
||||||
|
*/
|
||||||
|
export type OriginSceneNodeState =
|
||||||
|
"active" | "transitioning" | "exposed" | "parked";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only diagnostic representation of one mounted scene node.
|
||||||
|
*
|
||||||
|
* A node corresponds to one currently mounted Vue component instance.
|
||||||
|
*/
|
||||||
|
export interface OriginSceneNode {
|
||||||
|
/** Unique identity for this particular mounted scene node. */
|
||||||
|
readonly key: string;
|
||||||
|
/** Monotonically increasing creation order within the scene. */
|
||||||
|
readonly sequence: number;
|
||||||
|
/** Recipe used to create the node's component. */
|
||||||
|
readonly view: OriginView;
|
||||||
|
/** Recipes for the retained instance chain preceding this node. */
|
||||||
|
readonly history: readonly OriginView[];
|
||||||
|
/** Key of the retained mounted entry immediately before this node. */
|
||||||
|
readonly previousNodeKey?: string;
|
||||||
|
/** Current visibility/lifecycle role of this mounted instance. */
|
||||||
|
readonly state: OriginSceneNodeState;
|
||||||
|
/**
|
||||||
|
* Operation edge positioning this node as its target. This may identify a
|
||||||
|
* connected resting edge that is intentionally absent from live-operation
|
||||||
|
* diagnostics.
|
||||||
|
*/
|
||||||
|
readonly incomingOperationId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read-only diagnostic representation of one live operation edge. */
|
||||||
|
export interface OriginOperation {
|
||||||
|
/** Unique, monotonically increasing operation identity within the scene. */
|
||||||
|
readonly id: number;
|
||||||
|
/** Key of the mounted component that originated the operation. */
|
||||||
|
readonly originKey: string;
|
||||||
|
/** Key of the newly created or retained target component. */
|
||||||
|
readonly targetKey: string;
|
||||||
|
/** Original history entry targeted before visual-edge rewrites. */
|
||||||
|
readonly entryTargetKey: string;
|
||||||
|
/** Choreography currently calculating this edge's effects. */
|
||||||
|
readonly choreography: OriginChoreography;
|
||||||
|
/** Target stacking relationship to the origin. */
|
||||||
|
readonly placement: OriginPlacement;
|
||||||
|
/** Retained-history behavior performed if this operation commits. */
|
||||||
|
readonly history: OriginHistoryMode;
|
||||||
|
/** Normalized progress in the inclusive range `0..1`. */
|
||||||
|
readonly progress: number;
|
||||||
|
/** Latest normalized velocity in progress units per second. */
|
||||||
|
readonly velocity: number;
|
||||||
|
/** Current operation lifecycle phase. */
|
||||||
|
readonly phase: OriginOperationPhase;
|
||||||
|
/** Selected operation outcome. */
|
||||||
|
readonly intent: OriginOperationIntent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Node-local information supplied to component-owned action factories.
|
||||||
|
*
|
||||||
|
* There is deliberately no global `current` or `active` view. The component
|
||||||
|
* handling the event is the origin represented by this context.
|
||||||
|
*/
|
||||||
|
export interface OriginContext {
|
||||||
|
/** Unique key of the mounted node that owns the interaction. */
|
||||||
|
readonly nodeKey: string;
|
||||||
|
/** Recipe used to create the origin node. */
|
||||||
|
readonly view: OriginView;
|
||||||
|
/** Whether this retained history entry has a mounted previous instance. */
|
||||||
|
readonly canGoBack: boolean;
|
||||||
|
/** Recipe belonging to the retained previous instance, when available. */
|
||||||
|
readonly previous?: OriginView;
|
||||||
|
/** Recipes belonging to all retained instances preceding this node. */
|
||||||
|
readonly history: readonly OriginView[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options controlling how a manually managed operation is resolved. */
|
||||||
|
export interface OriginFinishOptions {
|
||||||
|
/**
|
||||||
|
* Override the choreography's progress/velocity decision. Omit it to use the
|
||||||
|
* choreography's commit thresholds.
|
||||||
|
*/
|
||||||
|
commit?: boolean;
|
||||||
|
/**
|
||||||
|
* Whether to run the settling spring before finalizing the graph.
|
||||||
|
*
|
||||||
|
* @defaultValue `true`
|
||||||
|
*/
|
||||||
|
animate?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Imperative controller for one mounted, live operation edge. */
|
||||||
|
export interface OriginOperationHandle {
|
||||||
|
/** Identity of the live operation controlled by this handle. */
|
||||||
|
readonly id: number;
|
||||||
|
/** Key of the source node that created the operation. */
|
||||||
|
readonly originKey: string;
|
||||||
|
/** Key of the newly mounted or retained target node. */
|
||||||
|
readonly targetKey: string;
|
||||||
|
/**
|
||||||
|
* The operation's selected outcome. It becomes `commit` or `cancel`
|
||||||
|
* synchronously when `finish()` or `cancel()` is called, before settling.
|
||||||
|
*/
|
||||||
|
readonly intent: OriginOperationIntent;
|
||||||
|
/**
|
||||||
|
* Set interactive progress and optional velocity.
|
||||||
|
*
|
||||||
|
* Progress is clamped to `0..1`. Velocity is normalized to progress units
|
||||||
|
* per second and is used by the choreography's flick threshold.
|
||||||
|
*/
|
||||||
|
update(progress: number, velocity?: number): void;
|
||||||
|
/**
|
||||||
|
* Select commit/cancel, run the settling spring, and finalize the scene.
|
||||||
|
*
|
||||||
|
* @returns `true` when the target was retained, otherwise `false`.
|
||||||
|
*/
|
||||||
|
finish(options?: OriginFinishOptions): Promise<boolean>;
|
||||||
|
/** Cancel the operation and remove its target branch. */
|
||||||
|
cancel(options?: Pick<OriginFinishOptions, "animate">): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options used to create an independent origin scene. */
|
||||||
|
export interface CreateOriginSceneOptions {
|
||||||
|
/**
|
||||||
|
* One initial root recipe, or several independent roots rendered in the same
|
||||||
|
* flat scene. Most applications begin with one root.
|
||||||
|
*/
|
||||||
|
initial: OriginView | readonly OriginView[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A self-contained scene graph, view-recipe history, and animation compositor.
|
||||||
|
*
|
||||||
|
* Multiple scenes may coexist and do not share nodes, history, or operations.
|
||||||
|
*/
|
||||||
|
export interface OriginScene {
|
||||||
|
/** Reactive snapshot of every currently mounted node. */
|
||||||
|
readonly nodes: ComputedRef<readonly OriginSceneNode[]>;
|
||||||
|
/** Reactive snapshot of every live animation/gesture operation. */
|
||||||
|
readonly operations: ComputedRef<readonly OriginOperation[]>;
|
||||||
|
/** Keys of visible operation-graph roots; parked history is excluded. */
|
||||||
|
readonly roots: Readonly<ShallowRef<readonly string[]>>;
|
||||||
|
/** Return the node-local action context for a mounted node key. */
|
||||||
|
contextFor(nodeKey: string): OriginContext;
|
||||||
|
/**
|
||||||
|
* Create a forward target or reveal a retained back target, then return
|
||||||
|
* manual control of its operation.
|
||||||
|
*
|
||||||
|
* @throws If the origin is missing, parked/exposed and inert, or already owns
|
||||||
|
* an outgoing operation.
|
||||||
|
*/
|
||||||
|
begin(
|
||||||
|
originKey: string,
|
||||||
|
action: OriginAction,
|
||||||
|
): Promise<OriginOperationHandle>;
|
||||||
|
/**
|
||||||
|
* Resolve a target and commit it programmatically using the settling spring.
|
||||||
|
*
|
||||||
|
* @returns `true` once the target has been committed.
|
||||||
|
*/
|
||||||
|
perform(originKey: string, action: OriginAction): Promise<boolean>;
|
||||||
|
/** @internal Register or unregister a scene node's host element. */
|
||||||
|
registerElement(nodeKey: string, element: HTMLElement | null): void;
|
||||||
|
/** @internal Register or unregister the scene container used for measurement. */
|
||||||
|
registerContainer(element: HTMLElement | null): void;
|
||||||
|
/** @internal Calculate the fully composited inline style for one node host. */
|
||||||
|
styleForNode(nodeKey: string): CSSProperties;
|
||||||
|
/** @internal Determine whether a node should currently receive pointer input. */
|
||||||
|
isNodeInteractive(nodeKey: string): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mutable operation storage used by the scene implementation.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export interface MutableOriginOperation {
|
||||||
|
/** Unique operation identity. */
|
||||||
|
id: number;
|
||||||
|
/** Origin node key. */
|
||||||
|
originKey: string;
|
||||||
|
/** Newly created or retained target node key. */
|
||||||
|
targetKey: string;
|
||||||
|
/** Original history entry targeted before visual-edge rewrites. */
|
||||||
|
entryTargetKey: string;
|
||||||
|
/** Visual choreography for this edge. */
|
||||||
|
choreography: OriginChoreography;
|
||||||
|
/** Target stacking relationship. */
|
||||||
|
placement: OriginPlacement;
|
||||||
|
/** Retained-history behavior selected for this operation. */
|
||||||
|
history: OriginHistoryMode;
|
||||||
|
/** Normalized progress. */
|
||||||
|
progress: number;
|
||||||
|
/** Normalized velocity. */
|
||||||
|
velocity: number;
|
||||||
|
/** Current lifecycle phase. */
|
||||||
|
phase: OriginOperationPhase;
|
||||||
|
/** Selected outcome. */
|
||||||
|
intent: OriginOperationIntent;
|
||||||
|
/** Measured origin bounds, when an element was available. */
|
||||||
|
originRect?: OriginRect;
|
||||||
|
/** Measured target bounds, after the target mounted. */
|
||||||
|
targetRect?: OriginRect;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Public props accepted by the `OriginScene` renderer component. */
|
||||||
|
export interface OriginSceneProps {
|
||||||
|
/** Scene instance whose flat component nodes should be rendered. */
|
||||||
|
scene: OriginScene;
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
/** A rectangle measured in viewport CSS pixels. */
|
||||||
|
export interface OriginRect {
|
||||||
|
/** Distance from the viewport's top edge in CSS pixels. */
|
||||||
|
top: number;
|
||||||
|
/** Distance from the viewport's left edge in CSS pixels. */
|
||||||
|
left: number;
|
||||||
|
/** Rectangle width in CSS pixels. */
|
||||||
|
width: number;
|
||||||
|
/** Rectangle height in CSS pixels. */
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clamp a value to the inclusive unit range `0..1`. */
|
||||||
|
export function clamp01(value: number): number {
|
||||||
|
return Math.max(0, Math.min(1, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clamp a value to the inclusive range `min..max`. */
|
||||||
|
export function clamp(value: number, min: number, max: number): number {
|
||||||
|
return Math.max(min, Math.min(max, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Measure an element's viewport bounds as a plain, serializable rectangle. */
|
||||||
|
export function measureRect(element: Element): OriginRect {
|
||||||
|
const { top, left, width, height } = element.getBoundingClientRect();
|
||||||
|
return { top, left, width, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bounds of the browser window, or a unit rectangle outside a browser. */
|
||||||
|
export function windowRect(): OriginRect {
|
||||||
|
const browser = typeof window !== "undefined";
|
||||||
|
return {
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: browser ? window.innerWidth : 1,
|
||||||
|
height: browser ? window.innerHeight : 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether animation should resolve immediately.
|
||||||
|
*
|
||||||
|
* True outside a browser, when `requestAnimationFrame` is unavailable, or when
|
||||||
|
* the user prefers reduced motion.
|
||||||
|
*/
|
||||||
|
export function prefersReducedMotion(): boolean {
|
||||||
|
return (
|
||||||
|
typeof window === "undefined" ||
|
||||||
|
!window.requestAnimationFrame ||
|
||||||
|
Boolean(window.matchMedia?.("(prefers-reduced-motion: reduce)").matches)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,831 +0,0 @@
|
|||||||
import type { Component, ComputedRef, CSSProperties, ShallowRef } from "vue";
|
|
||||||
|
|
||||||
export type PropsOf<T> =
|
|
||||||
T extends abstract new (...args: any[]) => { $props: infer P }
|
|
||||||
? P
|
|
||||||
: T extends (props: infer P, ...args: any[]) => any
|
|
||||||
? P
|
|
||||||
: never;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A view is a recipe for creating a Vue component, not a mounted instance.
|
|
||||||
*
|
|
||||||
* Forward navigation uses the recipe to create a mounted history entry. That
|
|
||||||
* instance remains mounted until a committed back operation pops it.
|
|
||||||
*
|
|
||||||
* @typeParam Props - The props accepted by the component recipe.
|
|
||||||
*/
|
|
||||||
export interface OriginView<
|
|
||||||
T extends Component = Component,
|
|
||||||
// Props extends Record<string, unknown> = Record<string, unknown>,
|
|
||||||
> {
|
|
||||||
/** The Vue component definition that will be mounted for this recipe. */
|
|
||||||
readonly component: T;
|
|
||||||
/** Props passed to the component when the recipe is mounted. */
|
|
||||||
readonly props?: Readonly<PropsOf<T>>;
|
|
||||||
/**
|
|
||||||
* A developer-facing recipe identity used in diagnostics and as the prefix
|
|
||||||
* of generated scene-node keys. It does not make Vue reuse an instance.
|
|
||||||
*/
|
|
||||||
readonly key?: string;
|
|
||||||
/** A human-readable label used by diagnostics and DOM data attributes. */
|
|
||||||
readonly name?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The lifecycle phase of an operation edge.
|
|
||||||
*
|
|
||||||
* - `preparing`: the target has been added and Vue is mounting it.
|
|
||||||
* - `interactive`: progress may be controlled by a gesture or application.
|
|
||||||
* - `settling`: the commit/cancel decision is fixed and the spring is running.
|
|
||||||
* - `finished`: the graph rewrite or cancellation cleanup has completed.
|
|
||||||
*/
|
|
||||||
export type OriginOperationPhase =
|
|
||||||
"preparing" | "interactive" | "settling" | "finished";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The outcome selected for an operation.
|
|
||||||
*
|
|
||||||
* Intent remains `undecided` until `finish()` is called. It becomes final
|
|
||||||
* before the settling animation completes, allowing the retained target to
|
|
||||||
* originate its own operation immediately.
|
|
||||||
*/
|
|
||||||
export type OriginOperationIntent = "undecided" | "commit" | "cancel";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Controls how an operation participates in retained instance history.
|
|
||||||
*
|
|
||||||
* - `push`: create a target whose previous entry is the mounted origin.
|
|
||||||
* - `replace`: create a target that inherits the origin's previous entry, then
|
|
||||||
* remove the origin on commit.
|
|
||||||
* - `back`: reuse the mounted previous entry and pop the origin on commit.
|
|
||||||
*/
|
|
||||||
export type OriginHistoryMode = "push" | "replace" | "back";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The target's stacking relationship to its origin while an operation exists.
|
|
||||||
*/
|
|
||||||
export type OriginPlacement = "above" | "under";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Visibility/lifecycle role of a mounted scene node.
|
|
||||||
*
|
|
||||||
* - `active`: currently exposed for normal interaction.
|
|
||||||
* - `transitioning`: participating in at least one live operation edge.
|
|
||||||
* - `exposed`: retained and visually presented behind/beside the active entry,
|
|
||||||
* but inert.
|
|
||||||
* - `parked`: retained in history but visually hidden and inert.
|
|
||||||
*/
|
|
||||||
export type OriginSceneNodeState =
|
|
||||||
"active" | "transitioning" | "exposed" | "parked";
|
|
||||||
|
|
||||||
/** A rectangle measured in viewport CSS pixels. */
|
|
||||||
export interface OriginRect {
|
|
||||||
/** Distance from the viewport's top edge in CSS pixels. */
|
|
||||||
top: number;
|
|
||||||
/** Distance from the viewport's left edge in CSS pixels. */
|
|
||||||
left: number;
|
|
||||||
/** Rectangle width in CSS pixels. */
|
|
||||||
width: number;
|
|
||||||
/** Rectangle height in CSS pixels. */
|
|
||||||
height: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One composable contribution to a scene node's final visual style.
|
|
||||||
*
|
|
||||||
* Transforms are concatenated in origin-to-descendant order and opacity values
|
|
||||||
* are multiplied. This lets X→Y and Y→Z affect Y simultaneously without either
|
|
||||||
* routine replacing the other routine's CSS transform.
|
|
||||||
*/
|
|
||||||
export interface OriginEffect {
|
|
||||||
/**
|
|
||||||
* A CSS transform contribution. Transform strings from inherited and local
|
|
||||||
* origin frames are concatenated rather than replacing one another.
|
|
||||||
*/
|
|
||||||
transform?: string;
|
|
||||||
/**
|
|
||||||
* An opacity contribution between `0` and `1`. Contributions from multiple
|
|
||||||
* frames are multiplied and the final value is clamped.
|
|
||||||
*/
|
|
||||||
opacity?: number;
|
|
||||||
/**
|
|
||||||
* Relative stacking contribution. `above()` defaults the target to +1 and
|
|
||||||
* `under()` defaults it to -1.
|
|
||||||
*/
|
|
||||||
layer?: number;
|
|
||||||
/**
|
|
||||||
* Escape hatch for non-geometric effects such as filter, clipPath, or
|
|
||||||
* borderRadius. Later/local effects override inherited properties.
|
|
||||||
*/
|
|
||||||
style?: CSSProperties;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Independent visual contributions calculated for one operation edge.
|
|
||||||
*
|
|
||||||
* Any omitted contribution is treated as an identity effect.
|
|
||||||
*/
|
|
||||||
export interface OriginEffectSet {
|
|
||||||
/**
|
|
||||||
* Applied to both source and target. This is useful for moving an entire
|
|
||||||
* origin-relative coordinate frame.
|
|
||||||
*/
|
|
||||||
frame?: OriginEffect;
|
|
||||||
/** Applied to the component that initiated this operation. */
|
|
||||||
source?: OriginEffect;
|
|
||||||
/** Applied to the component created by this operation and its descendants. */
|
|
||||||
target?: OriginEffect;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Values supplied whenever a choreography calculates its visual effects. */
|
|
||||||
export interface OriginChoreographyContext {
|
|
||||||
/** Normalized operation progress, clamped to the inclusive range `0..1`. */
|
|
||||||
progress: number;
|
|
||||||
/**
|
|
||||||
* Normalized progress units per second. Positive velocity moves toward
|
|
||||||
* commit; negative velocity moves back toward cancellation.
|
|
||||||
*/
|
|
||||||
velocity: number;
|
|
||||||
/** Current lifecycle phase of the operation. */
|
|
||||||
phase: OriginOperationPhase;
|
|
||||||
/** Commit/cancel outcome, if release has already selected one. */
|
|
||||||
intent: OriginOperationIntent;
|
|
||||||
/** Origin host bounds captured immediately before the target mounts. */
|
|
||||||
originRect?: OriginRect;
|
|
||||||
/** Target host bounds measured after Vue mounts the target. */
|
|
||||||
targetRect?: OriginRect;
|
|
||||||
/** Scene-container bounds, or the browser viewport when no container exists. */
|
|
||||||
viewport: OriginRect;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A choreography describes visual relationships only. Component creation,
|
|
||||||
* history, and cleanup are performed by the scene operation that uses it.
|
|
||||||
*/
|
|
||||||
export interface OriginChoreography {
|
|
||||||
/** Optional diagnostic name surfaced by scene inspectors and devtools. */
|
|
||||||
readonly name?: string;
|
|
||||||
/**
|
|
||||||
* Calculate source, target, and shared-frame contributions for the current
|
|
||||||
* operation state. This function should be deterministic and side-effect
|
|
||||||
* free because it can run many times per animation frame.
|
|
||||||
*/
|
|
||||||
effects(context: OriginChoreographyContext): OriginEffectSet;
|
|
||||||
/**
|
|
||||||
* Gesture progress required to retain the target after release.
|
|
||||||
*
|
|
||||||
* @defaultValue `0.36`
|
|
||||||
*/
|
|
||||||
readonly commitThreshold?: number;
|
|
||||||
/**
|
|
||||||
* Normalized positive release velocity that can commit a deliberate flick
|
|
||||||
* once progress has reached at least `0.06`.
|
|
||||||
*
|
|
||||||
* @defaultValue `0.9`
|
|
||||||
*/
|
|
||||||
readonly commitVelocity?: number;
|
|
||||||
/**
|
|
||||||
* Keep this choreography's progress-`1` effects connected after a committed
|
|
||||||
* push instead of collapsing the edge and hiding its source.
|
|
||||||
*
|
|
||||||
* This is intended for partial presentations such as drawers and inspectors
|
|
||||||
* where part of the retained source remains visible beside the target. The
|
|
||||||
* source remains mounted, visible, and inert. A back operation temporarily
|
|
||||||
* suspends the resting edge; cancelling back restores it.
|
|
||||||
*
|
|
||||||
* Only retained-history `push` actions support connected resting effects.
|
|
||||||
*
|
|
||||||
* @defaultValue `false`
|
|
||||||
*/
|
|
||||||
readonly persistAtRest?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A complete request to create and animate a target view from an origin. */
|
|
||||||
export interface OriginAction {
|
|
||||||
/**
|
|
||||||
* Recipe for a newly created push or replacement target. A back action omits
|
|
||||||
* this because the scene resolves its already-mounted previous entry.
|
|
||||||
*/
|
|
||||||
readonly target?: OriginView;
|
|
||||||
/** Visual relationship applied to the source, target, and shared frame. */
|
|
||||||
readonly choreography: OriginChoreography;
|
|
||||||
/**
|
|
||||||
* Target stacking relationship during the operation.
|
|
||||||
*
|
|
||||||
* @defaultValue `"above"`
|
|
||||||
*/
|
|
||||||
readonly placement?: OriginPlacement;
|
|
||||||
/**
|
|
||||||
* History mutation applied to the target recipe.
|
|
||||||
*
|
|
||||||
* @defaultValue `"push"`
|
|
||||||
*/
|
|
||||||
readonly history?: OriginHistoryMode;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A scene mutation without visual choreography.
|
|
||||||
*
|
|
||||||
* Gesture builders keep navigation intent separate from animation so the same
|
|
||||||
* destination can be paired with different component-local interactions.
|
|
||||||
* Calling `.animate()` materializes this intent as an {@link OriginAction}.
|
|
||||||
*/
|
|
||||||
export interface OriginNavigationIntent {
|
|
||||||
/**
|
|
||||||
* Recipe for a newly created push or replacement target. Back navigation
|
|
||||||
* omits this because the scene resolves the retained previous instance.
|
|
||||||
*/
|
|
||||||
readonly target?: OriginView;
|
|
||||||
/** Target stacking relationship while the gesture operation is visible. */
|
|
||||||
readonly placement?: OriginPlacement;
|
|
||||||
/** Retained-history mutation performed if the gesture commits. */
|
|
||||||
readonly history: OriginHistoryMode;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read-only diagnostic representation of one mounted scene node.
|
|
||||||
*
|
|
||||||
* A node corresponds to one currently mounted Vue component instance.
|
|
||||||
*/
|
|
||||||
export interface OriginSceneNode {
|
|
||||||
/** Unique identity for this particular mounted scene node. */
|
|
||||||
readonly key: string;
|
|
||||||
/** Monotonically increasing creation order within the scene. */
|
|
||||||
readonly sequence: number;
|
|
||||||
/** Recipe used to create the node's component. */
|
|
||||||
readonly view: OriginView;
|
|
||||||
/** Recipes for the retained instance chain preceding this node. */
|
|
||||||
readonly history: readonly OriginView[];
|
|
||||||
/** Key of the retained mounted entry immediately before this node. */
|
|
||||||
readonly previousNodeKey?: string;
|
|
||||||
/** Current visibility/lifecycle role of this mounted instance. */
|
|
||||||
readonly state: OriginSceneNodeState;
|
|
||||||
/**
|
|
||||||
* Operation edge positioning this node as its target. This may identify a
|
|
||||||
* connected resting edge that is intentionally absent from live-operation
|
|
||||||
* diagnostics.
|
|
||||||
*/
|
|
||||||
readonly incomingOperationId?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Read-only diagnostic representation of one live operation edge. */
|
|
||||||
export interface OriginOperation {
|
|
||||||
/** Unique, monotonically increasing operation identity within the scene. */
|
|
||||||
readonly id: number;
|
|
||||||
/** Key of the mounted component that originated the operation. */
|
|
||||||
readonly originKey: string;
|
|
||||||
/** Key of the newly created or retained target component. */
|
|
||||||
readonly targetKey: string;
|
|
||||||
/** Original history entry targeted before visual-edge rewrites. */
|
|
||||||
readonly entryTargetKey: string;
|
|
||||||
/** Choreography currently calculating this edge's effects. */
|
|
||||||
readonly choreography: OriginChoreography;
|
|
||||||
/** Target stacking relationship to the origin. */
|
|
||||||
readonly placement: OriginPlacement;
|
|
||||||
/** Retained-history behavior performed if this operation commits. */
|
|
||||||
readonly history: OriginHistoryMode;
|
|
||||||
/** Normalized progress in the inclusive range `0..1`. */
|
|
||||||
readonly progress: number;
|
|
||||||
/** Latest normalized velocity in progress units per second. */
|
|
||||||
readonly velocity: number;
|
|
||||||
/** Current operation lifecycle phase. */
|
|
||||||
readonly phase: OriginOperationPhase;
|
|
||||||
/** Selected operation outcome. */
|
|
||||||
readonly intent: OriginOperationIntent;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Node-local information supplied to component-owned action factories.
|
|
||||||
*
|
|
||||||
* There is deliberately no global `current` or `active` view. The component
|
|
||||||
* handling the event is the origin represented by this context.
|
|
||||||
*/
|
|
||||||
export interface OriginContext {
|
|
||||||
/** Unique key of the mounted node that owns the interaction. */
|
|
||||||
readonly nodeKey: string;
|
|
||||||
/** Recipe used to create the origin node. */
|
|
||||||
readonly view: OriginView;
|
|
||||||
/** Whether this retained history entry has a mounted previous instance. */
|
|
||||||
readonly canGoBack: boolean;
|
|
||||||
/** Recipe belonging to the retained previous instance, when available. */
|
|
||||||
readonly previous?: OriginView;
|
|
||||||
/** Recipes belonging to all retained instances preceding this node. */
|
|
||||||
readonly history: readonly OriginView[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Options controlling how a manually managed operation is resolved. */
|
|
||||||
export interface OriginFinishOptions {
|
|
||||||
/**
|
|
||||||
* Override the choreography's progress/velocity decision. Omit it to use the
|
|
||||||
* choreography's commit thresholds.
|
|
||||||
*/
|
|
||||||
commit?: boolean;
|
|
||||||
/**
|
|
||||||
* Whether to run the settling spring before finalizing the graph.
|
|
||||||
*
|
|
||||||
* @defaultValue `true`
|
|
||||||
*/
|
|
||||||
animate?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Imperative controller for one mounted, live operation edge. */
|
|
||||||
export interface OriginOperationHandle {
|
|
||||||
/** Identity of the live operation controlled by this handle. */
|
|
||||||
readonly id: number;
|
|
||||||
/** Key of the source node that created the operation. */
|
|
||||||
readonly originKey: string;
|
|
||||||
/** Key of the newly mounted or retained target node. */
|
|
||||||
readonly targetKey: string;
|
|
||||||
/**
|
|
||||||
* Set interactive progress and optional velocity.
|
|
||||||
*
|
|
||||||
* Progress is clamped to `0..1`. Velocity is normalized to progress units
|
|
||||||
* per second and is used by the choreography's flick threshold.
|
|
||||||
*/
|
|
||||||
update(progress: number, velocity?: number): void;
|
|
||||||
/**
|
|
||||||
* Select commit/cancel, run the settling spring, and finalize the scene.
|
|
||||||
*
|
|
||||||
* @returns `true` when the target was retained, otherwise `false`.
|
|
||||||
*/
|
|
||||||
finish(options?: OriginFinishOptions): Promise<boolean>;
|
|
||||||
/** Cancel the operation and remove its target branch. */
|
|
||||||
cancel(options?: Pick<OriginFinishOptions, "animate">): Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A self-contained scene graph, view-recipe history, and animation compositor.
|
|
||||||
*
|
|
||||||
* Multiple scenes may coexist and do not share nodes, history, or operations.
|
|
||||||
*/
|
|
||||||
export interface OriginScene {
|
|
||||||
/** Reactive snapshot of every currently mounted node. */
|
|
||||||
readonly nodes: ComputedRef<readonly OriginSceneNode[]>;
|
|
||||||
/** Reactive snapshot of every live animation/gesture operation. */
|
|
||||||
readonly operations: ComputedRef<readonly OriginOperation[]>;
|
|
||||||
/** Keys of visible operation-graph roots; parked history is excluded. */
|
|
||||||
readonly roots: Readonly<ShallowRef<readonly string[]>>;
|
|
||||||
/** Return the node-local action context for a mounted node key. */
|
|
||||||
contextFor(nodeKey: string): OriginContext;
|
|
||||||
/**
|
|
||||||
* Create a forward target or reveal a retained back target, then return
|
|
||||||
* manual control of its operation.
|
|
||||||
*
|
|
||||||
* @throws If the origin is missing, parked/exposed and inert, or already owns
|
|
||||||
* an outgoing operation.
|
|
||||||
*/
|
|
||||||
begin(
|
|
||||||
originKey: string,
|
|
||||||
action: OriginAction,
|
|
||||||
): Promise<OriginOperationHandle>;
|
|
||||||
/**
|
|
||||||
* Resolve a target and commit it programmatically using the settling spring.
|
|
||||||
*
|
|
||||||
* @returns `true` once the target has been committed.
|
|
||||||
*/
|
|
||||||
perform(originKey: string, action: OriginAction): Promise<boolean>;
|
|
||||||
/** @internal Register or unregister a scene node's host element. */
|
|
||||||
registerElement(nodeKey: string, element: HTMLElement | null): void;
|
|
||||||
/** @internal Register or unregister the scene container used for measurement. */
|
|
||||||
registerContainer(element: HTMLElement | null): void;
|
|
||||||
/** @internal Calculate the fully composited inline style for one node host. */
|
|
||||||
styleForNode(nodeKey: string): CSSProperties;
|
|
||||||
/** @internal Determine whether a node should currently receive pointer input. */
|
|
||||||
isNodeInteractive(nodeKey: string): boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Node-scoped controls returned by {@link useOrigin}. */
|
|
||||||
export interface UseOrigin {
|
|
||||||
/** Key of the mounted node containing the calling component. */
|
|
||||||
readonly nodeKey: string;
|
|
||||||
/** Scene containing the calling component. */
|
|
||||||
readonly scene: OriginScene;
|
|
||||||
/** Reactive context for the calling component's scene node. */
|
|
||||||
readonly context: ComputedRef<OriginContext>;
|
|
||||||
/** Reactive shorthand for `context.value.view`. */
|
|
||||||
readonly view: ComputedRef<OriginView>;
|
|
||||||
/** Reactive shorthand for `context.value.previous`. */
|
|
||||||
readonly previous: ComputedRef<OriginView | undefined>;
|
|
||||||
/** Reactive shorthand for `context.value.canGoBack`. */
|
|
||||||
readonly canGoBack: ComputedRef<boolean>;
|
|
||||||
/** Begin an interactively controlled operation from this component. */
|
|
||||||
begin(action: OriginAction): Promise<OriginOperationHandle>;
|
|
||||||
/** Programmatically create and commit a target from this component. */
|
|
||||||
perform(action: OriginAction): Promise<boolean>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Values accepted from gesture action factories.
|
|
||||||
*
|
|
||||||
* Returning `null` or `undefined` declines the recognized gesture. A promise
|
|
||||||
* allows lazy target resolution; stale results are discarded after release or
|
|
||||||
* cancellation.
|
|
||||||
*/
|
|
||||||
export type MaybeOriginAction =
|
|
||||||
OriginAction | null | undefined | Promise<OriginAction | null | undefined>;
|
|
||||||
|
|
||||||
/** Direction in which pointer movement advances operation progress. */
|
|
||||||
export type OriginGestureDirection = "left" | "right" | "up" | "down";
|
|
||||||
|
|
||||||
/** Physical side of the gesture host used to admit pointer-down. */
|
|
||||||
export type OriginGestureEdge = "left" | "right" | "top" | "bottom";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CSS length accepted by an edge-based gesture start rule.
|
|
||||||
*
|
|
||||||
* Numbers are interpreted as CSS pixels. Strings may use normal CSS lengths,
|
|
||||||
* percentages, `calc()`, or `clamp()`, and are resolved against the gesture
|
|
||||||
* host when pointer-down occurs.
|
|
||||||
*/
|
|
||||||
export type OriginGestureDistance = number | string;
|
|
||||||
|
|
||||||
/** A pointer position measured in viewport and gesture-host coordinates. */
|
|
||||||
export interface OriginGesturePoint {
|
|
||||||
/** Viewport-relative horizontal position. */
|
|
||||||
readonly clientX: number;
|
|
||||||
/** Viewport-relative vertical position. */
|
|
||||||
readonly clientY: number;
|
|
||||||
/** Horizontal position relative to the gesture host's left edge. */
|
|
||||||
readonly localX: number;
|
|
||||||
/** Vertical position relative to the gesture host's top edge. */
|
|
||||||
readonly localY: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Context supplied to a custom `.from.when()` start predicate. */
|
|
||||||
export interface OriginGestureStartContext {
|
|
||||||
/** Native pointer-down event being considered. */
|
|
||||||
readonly event: PointerEvent;
|
|
||||||
/** Component instance from which the gesture would originate. */
|
|
||||||
readonly origin: OriginContext;
|
|
||||||
/** Element carrying the gesture's pointer handlers. */
|
|
||||||
readonly host: HTMLElement;
|
|
||||||
/** Gesture-host bounds captured at pointer-down. */
|
|
||||||
readonly bounds: OriginRect;
|
|
||||||
/** Pointer position at pointer-down. */
|
|
||||||
readonly point: OriginGesturePoint;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Synchronous predicate deciding whether a pointer-down may become a gesture.
|
|
||||||
*
|
|
||||||
* Directional recognition still occurs later, after movement passes the
|
|
||||||
* configured intent threshold.
|
|
||||||
*/
|
|
||||||
export type OriginGestureStartPredicate = (
|
|
||||||
context: OriginGestureStartContext,
|
|
||||||
) => boolean;
|
|
||||||
|
|
||||||
/** Start policy stored in a completed gesture definition. */
|
|
||||||
export type OriginGestureStart =
|
|
||||||
| {
|
|
||||||
/** Recognize pointer-down anywhere on the host. */
|
|
||||||
readonly kind: "anywhere";
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
/** Recognize pointer-down within a CSS distance of one host side. */
|
|
||||||
readonly kind: "edge";
|
|
||||||
readonly edge: OriginGestureEdge;
|
|
||||||
readonly distance: OriginGestureDistance;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
/** Recognize pointer-down when application policy returns `true`. */
|
|
||||||
readonly kind: "when";
|
|
||||||
readonly predicate: OriginGestureStartPredicate;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Direction-recognition tuning accepted by `.to.left()` and its siblings. */
|
|
||||||
export interface OriginGestureDirectionOptions {
|
|
||||||
/**
|
|
||||||
* Minimum directed movement in CSS pixels before the gesture captures.
|
|
||||||
*
|
|
||||||
* @defaultValue `8`
|
|
||||||
*/
|
|
||||||
readonly threshold?: number;
|
|
||||||
/**
|
|
||||||
* Ratio by which directed movement must exceed cross-axis movement.
|
|
||||||
*
|
|
||||||
* @defaultValue `1.15`
|
|
||||||
*/
|
|
||||||
readonly axisDominance?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Values available when a custom `.complete()` policy runs on pointer-up. */
|
|
||||||
export interface OriginGestureCompletionContext {
|
|
||||||
/** Component instance that originated the gesture. */
|
|
||||||
readonly origin: OriginContext;
|
|
||||||
/** Recognized movement direction. */
|
|
||||||
readonly direction: OriginGestureDirection;
|
|
||||||
/** Normalized directed distance, clamped to `0..1`. */
|
|
||||||
readonly progress: number;
|
|
||||||
/** Latest normalized progress units per second. */
|
|
||||||
readonly velocity: number;
|
|
||||||
/** Directed movement from pointer-down in CSS pixels. */
|
|
||||||
readonly distance: number;
|
|
||||||
/** Absolute cross-axis movement from pointer-down in CSS pixels. */
|
|
||||||
readonly crossDistance: number;
|
|
||||||
/** Elapsed time since pointer-down in milliseconds. */
|
|
||||||
readonly duration: number;
|
|
||||||
/** Native pointer-up event that ended the interaction. */
|
|
||||||
readonly event: PointerEvent;
|
|
||||||
/** Element carrying the gesture's pointer handlers. */
|
|
||||||
readonly host: HTMLElement;
|
|
||||||
/** Gesture-host bounds captured at pointer-down. */
|
|
||||||
readonly bounds: OriginRect;
|
|
||||||
/** Pointer position captured at pointer-down. */
|
|
||||||
readonly start: OriginGesturePoint;
|
|
||||||
/** Pointer position at release. */
|
|
||||||
readonly current: OriginGesturePoint;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Synchronous commit/cancel policy installed by `.complete()`. */
|
|
||||||
export type OriginGestureCompletionPredicate = (
|
|
||||||
context: OriginGestureCompletionContext,
|
|
||||||
) => boolean;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Values accepted from a gesture builder's `.navigate()` factory.
|
|
||||||
*
|
|
||||||
* A promise supports lazy view selection. Stale resolutions are discarded if
|
|
||||||
* the pointer has already ended or been cancelled.
|
|
||||||
*/
|
|
||||||
export type MaybeOriginNavigationIntent =
|
|
||||||
| OriginNavigationIntent
|
|
||||||
| null
|
|
||||||
| undefined
|
|
||||||
| Promise<OriginNavigationIntent | null | undefined>;
|
|
||||||
|
|
||||||
/** Factory that resolves navigation after directional recognition succeeds. */
|
|
||||||
export type OriginGestureNavigationFactory = (
|
|
||||||
context: OriginContext,
|
|
||||||
) => MaybeOriginNavigationIntent;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Immutable, executable result of a complete gesture builder chain.
|
|
||||||
*
|
|
||||||
* Pass this object to {@link useOriginGesture}. An omitted `.from` step is
|
|
||||||
* represented as an `anywhere` start rule.
|
|
||||||
*/
|
|
||||||
export interface OriginGestureDefinition {
|
|
||||||
/** Discriminator used by the compatibility recognizer overload. */
|
|
||||||
readonly kind: "origin-gesture-definition";
|
|
||||||
/** Pointer-down eligibility policy. */
|
|
||||||
readonly start: OriginGestureStart;
|
|
||||||
/** Direction in which movement advances operation progress. */
|
|
||||||
readonly direction: OriginGestureDirection;
|
|
||||||
/** Directional intent recognition tuning. */
|
|
||||||
readonly recognition: Readonly<OriginGestureDirectionOptions>;
|
|
||||||
/**
|
|
||||||
* Optional release decision. When omitted, choreography thresholds decide.
|
|
||||||
*/
|
|
||||||
readonly completion?: OriginGestureCompletionPredicate;
|
|
||||||
/** Node-local destination/history resolver. */
|
|
||||||
readonly navigation: OriginGestureNavigationFactory;
|
|
||||||
/** Visual routine paired with the navigation intent. */
|
|
||||||
readonly choreography: OriginChoreography;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Direction-selection stage shared by `gesture.to` and `.from.*().to`. */
|
|
||||||
export interface OriginGestureToBuilder {
|
|
||||||
/** Recognize leftward pointer movement. */
|
|
||||||
left(options?: OriginGestureDirectionOptions): OriginGestureDirectedBuilder;
|
|
||||||
/** Recognize rightward pointer movement. */
|
|
||||||
right(options?: OriginGestureDirectionOptions): OriginGestureDirectedBuilder;
|
|
||||||
/** Recognize upward pointer movement. */
|
|
||||||
up(options?: OriginGestureDirectionOptions): OriginGestureDirectedBuilder;
|
|
||||||
/** Recognize downward pointer movement. */
|
|
||||||
down(options?: OriginGestureDirectionOptions): OriginGestureDirectedBuilder;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Stage produced after a `.from` policy has been selected. */
|
|
||||||
export interface OriginGestureFromSelection {
|
|
||||||
/** Select the direction that advances this gesture. */
|
|
||||||
readonly to: OriginGestureToBuilder;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Optional pointer-down policy exposed at the start of a gesture chain. */
|
|
||||||
export interface OriginGestureFromBuilder {
|
|
||||||
/** Admit pointer-down within `distance` of the host's left edge. */
|
|
||||||
left(distance: OriginGestureDistance): OriginGestureFromSelection;
|
|
||||||
/** Admit pointer-down within `distance` of the host's right edge. */
|
|
||||||
right(distance: OriginGestureDistance): OriginGestureFromSelection;
|
|
||||||
/** Admit pointer-down within `distance` of the host's top edge. */
|
|
||||||
top(distance: OriginGestureDistance): OriginGestureFromSelection;
|
|
||||||
/** Admit pointer-down within `distance` of the host's bottom edge. */
|
|
||||||
bottom(distance: OriginGestureDistance): OriginGestureFromSelection;
|
|
||||||
/** Admit pointer-down anywhere on the gesture host. */
|
|
||||||
anywhere(): OriginGestureFromSelection;
|
|
||||||
/** Admit pointer-down when a synchronous application predicate succeeds. */
|
|
||||||
when(predicate: OriginGestureStartPredicate): OriginGestureFromSelection;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Builder stage after direction is known and completion remains optional. */
|
|
||||||
export interface OriginGestureDirectedBuilder {
|
|
||||||
/** Override the choreography's default release decision. */
|
|
||||||
complete(
|
|
||||||
predicate: OriginGestureCompletionPredicate,
|
|
||||||
): OriginGestureCompletedBuilder;
|
|
||||||
/** Select the target/history mutation while retaining default completion. */
|
|
||||||
navigate(
|
|
||||||
factory: OriginGestureNavigationFactory,
|
|
||||||
): OriginGestureNavigationBuilder;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Builder stage after a custom completion policy has been selected. */
|
|
||||||
export interface OriginGestureCompletedBuilder {
|
|
||||||
/** Select the target/history mutation performed on commit. */
|
|
||||||
navigate(
|
|
||||||
factory: OriginGestureNavigationFactory,
|
|
||||||
): OriginGestureNavigationBuilder;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Final builder stage waiting for visual choreography. */
|
|
||||||
export interface OriginGestureNavigationBuilder {
|
|
||||||
/** Attach visual choreography and produce an executable definition. */
|
|
||||||
animate(choreography: OriginChoreography): OriginGestureDefinition;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Root of the immutable fluent gesture-definition API. */
|
|
||||||
export interface OriginGestureBuilder {
|
|
||||||
/** Optionally constrain where pointer-down may begin. */
|
|
||||||
readonly from: OriginGestureFromBuilder;
|
|
||||||
/**
|
|
||||||
* Select movement direction with an implicit `from.anywhere()` start.
|
|
||||||
*/
|
|
||||||
readonly to: OriginGestureToBuilder;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Configuration consumed by {@link useOriginGesture}. */
|
|
||||||
export interface OriginGestureOptions {
|
|
||||||
/**
|
|
||||||
* Direction in which the pointer moves to advance the operation.
|
|
||||||
*
|
|
||||||
* The starting edge is the opposite side: `right` begins at the left edge,
|
|
||||||
* `left` at the right edge, `down` at the top, and `up` at the bottom.
|
|
||||||
*/
|
|
||||||
direction: OriginGestureDirection;
|
|
||||||
/**
|
|
||||||
* Restrict pointer-down to this many CSS pixels from the gesture host's
|
|
||||||
* starting edge. Omit it to recognize across the entire host.
|
|
||||||
*
|
|
||||||
* This is relative to the bound element, not necessarily the browser
|
|
||||||
* viewport. Use a positive number such as `24` or `36`.
|
|
||||||
*/
|
|
||||||
edge?: number;
|
|
||||||
/**
|
|
||||||
* Minimum directed movement in CSS pixels before the gesture captures.
|
|
||||||
*
|
|
||||||
* Movement must also dominate the cross-axis by a factor of `1.15`.
|
|
||||||
*
|
|
||||||
* @defaultValue `8`
|
|
||||||
*/
|
|
||||||
threshold?: number;
|
|
||||||
/**
|
|
||||||
* Resolve the scene action after directional recognition succeeds.
|
|
||||||
*
|
|
||||||
* Returning no action abandons recognition without modifying the scene.
|
|
||||||
*/
|
|
||||||
action(context: OriginContext): MaybeOriginAction;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DOM bindings returned by {@link useOriginGesture}.
|
|
||||||
*
|
|
||||||
* Spread or attach all four handlers to the same `HTMLElement`. Apply
|
|
||||||
* {@link style} as well so native scrolling is preserved on the cross-axis.
|
|
||||||
*/
|
|
||||||
export interface OriginGestureBinding {
|
|
||||||
/** Required size and `touch-action` styles for the gesture host. */
|
|
||||||
readonly style: Readonly<CSSProperties>;
|
|
||||||
/** Pointer-down handler that records a potentially eligible gesture. */
|
|
||||||
readonly onPointerdown: (event: PointerEvent) => void;
|
|
||||||
/** Pointer-move handler that recognizes and updates the operation. */
|
|
||||||
readonly onPointermove: (event: PointerEvent) => void;
|
|
||||||
/** Pointer-up handler that commits or cancels using progress and velocity. */
|
|
||||||
readonly onPointerup: (event: PointerEvent) => void;
|
|
||||||
/** Pointer-cancel handler that abandons any captured operation. */
|
|
||||||
readonly onPointercancel: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Props shared by both `OriginGesture` declaration styles. */
|
|
||||||
export interface OriginGestureBaseProps {
|
|
||||||
/**
|
|
||||||
* Native HTML tag rendered as the gesture host.
|
|
||||||
*
|
|
||||||
* @defaultValue `"div"`
|
|
||||||
*/
|
|
||||||
as?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Builder-definition props accepted by the `OriginGesture` component. */
|
|
||||||
export interface OriginGestureDefinitionProps extends OriginGestureBaseProps {
|
|
||||||
/** Immutable definition produced by the {@link gesture} builder. */
|
|
||||||
gesture: OriginGestureDefinition;
|
|
||||||
/** Builder definitions already contain direction. */
|
|
||||||
direction?: never;
|
|
||||||
/** Builder definitions already contain their start policy. */
|
|
||||||
edge?: never;
|
|
||||||
/** Builder definitions already contain recognition tuning. */
|
|
||||||
threshold?: never;
|
|
||||||
/** Builder definitions already contain their navigation factory. */
|
|
||||||
action?: never;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Legacy option props accepted by the `OriginGesture` component. */
|
|
||||||
export interface OriginGestureLegacyProps extends OriginGestureBaseProps {
|
|
||||||
/** Legacy component declarations do not provide a builder definition. */
|
|
||||||
gesture?: never;
|
|
||||||
/** Direction in which pointer movement advances operation progress. */
|
|
||||||
direction: OriginGestureDirection;
|
|
||||||
/**
|
|
||||||
* Eligible pointer-down width in CSS pixels from the host's starting edge.
|
|
||||||
* Omit it to allow the full component surface.
|
|
||||||
*/
|
|
||||||
edge?: number;
|
|
||||||
/**
|
|
||||||
* Directed movement required before capture.
|
|
||||||
*
|
|
||||||
* @defaultValue `8`
|
|
||||||
*/
|
|
||||||
threshold?: number;
|
|
||||||
/** Node-local action factory invoked only after recognition succeeds. */
|
|
||||||
action(context: OriginContext): MaybeOriginAction;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Public props accepted by the `OriginGesture` convenience component.
|
|
||||||
*
|
|
||||||
* Prefer the builder-definition form. The legacy direction/action form remains
|
|
||||||
* available for compatibility.
|
|
||||||
*/
|
|
||||||
export type OriginGestureProps =
|
|
||||||
OriginGestureDefinitionProps | OriginGestureLegacyProps;
|
|
||||||
|
|
||||||
/** Public props accepted by the multi-definition gesture surface component. */
|
|
||||||
export interface OriginGestureSurfaceProps {
|
|
||||||
/**
|
|
||||||
* Native HTML tag rendered as the shared gesture host.
|
|
||||||
*
|
|
||||||
* @defaultValue `"div"`
|
|
||||||
*/
|
|
||||||
as?: string;
|
|
||||||
/**
|
|
||||||
* Complete immutable definitions installed on the shared host.
|
|
||||||
*
|
|
||||||
* The surface adds no start, direction, completion, navigation, or animation
|
|
||||||
* policy. Definitions should be created by the owning page component.
|
|
||||||
*/
|
|
||||||
gestures: readonly OriginGestureDefinition[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Public props accepted by the `OriginScene` renderer component. */
|
|
||||||
export interface OriginSceneProps {
|
|
||||||
/** Scene instance whose flat component nodes should be rendered. */
|
|
||||||
scene: OriginScene;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Injection payload provided by each stable scene-node host.
|
|
||||||
*
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
export interface OriginNodeScope {
|
|
||||||
/** Scene containing the node. */
|
|
||||||
readonly scene: OriginScene;
|
|
||||||
/** Unique key of the mounted node. */
|
|
||||||
readonly nodeKey: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mutable operation storage used by the scene implementation.
|
|
||||||
*
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
export interface MutableOriginOperation {
|
|
||||||
/** Unique operation identity. */
|
|
||||||
id: number;
|
|
||||||
/** Origin node key. */
|
|
||||||
originKey: string;
|
|
||||||
/** Newly created or retained target node key. */
|
|
||||||
targetKey: string;
|
|
||||||
/** Original history entry targeted before visual-edge rewrites. */
|
|
||||||
entryTargetKey: string;
|
|
||||||
/** Visual choreography for this edge. */
|
|
||||||
choreography: OriginChoreography;
|
|
||||||
/** Target stacking relationship. */
|
|
||||||
placement: OriginPlacement;
|
|
||||||
/** Retained-history behavior selected for this operation. */
|
|
||||||
history: OriginHistoryMode;
|
|
||||||
/** Normalized progress. */
|
|
||||||
progress: number;
|
|
||||||
/** Normalized velocity. */
|
|
||||||
velocity: number;
|
|
||||||
/** Current lifecycle phase. */
|
|
||||||
phase: OriginOperationPhase;
|
|
||||||
/** Selected outcome. */
|
|
||||||
intent: OriginOperationIntent;
|
|
||||||
/** Measured origin bounds, when an element was available. */
|
|
||||||
originRect?: OriginRect;
|
|
||||||
/** Measured target bounds, after the target mounted. */
|
|
||||||
targetRect?: OriginRect;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
.baseline/
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Differential verification
|
||||||
|
|
||||||
|
Checks a core-v2 implementation against an earlier one (by default `main`)
|
||||||
|
by driving both with identical randomized inputs and requiring identical
|
||||||
|
observable results after every step.
|
||||||
|
|
||||||
|
From the repository root:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
packages/core-v2/verify/setup-baseline.sh main # extract into verify/.baseline/
|
||||||
|
npx vitest run --config packages/core-v2/verify/vitest.config.ts
|
||||||
|
SEEDS=50 npx vitest run --config packages/core-v2/verify/vitest.config.ts # quicker
|
||||||
|
ONLY=rewrite npx vitest bench --run --config packages/core-v2/verify/vitest.config.ts
|
||||||
|
ONLY=baseline npx vitest bench --run --config packages/core-v2/verify/vitest.config.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
- `scene.diff.ts` runs random `begin`/`update`/`finish`/`cancel`/animation
|
||||||
|
frame sequences against both scenes and compares nodes, operations, roots,
|
||||||
|
every node's composited style, interactivity, contexts, and every promise
|
||||||
|
result. It also compares action-helper output and the runtime export list.
|
||||||
|
- `gesture.diff.ts` mounts both packages in Vue apps whose views install the
|
||||||
|
same gestures (builder definitions, legacy options, `OriginGesture`, and
|
||||||
|
`OriginGestureSurface`), dispatches identical random pointer streams, and
|
||||||
|
compares scene state and every navigation, start, and completion callback.
|
||||||
|
- `scene.bench.ts` measures compositing, push/back cycles, and rendered
|
||||||
|
frames. Run each implementation in its own process with `ONLY=`; running
|
||||||
|
both in one process biases whichever runs second.
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
/**
|
||||||
|
* Differential test for gesture recognition: the baseline and rewritten
|
||||||
|
* packages each render a scene whose views install equivalent gestures
|
||||||
|
* (builder, legacy options, OriginGesture, OriginGestureSurface). Identical
|
||||||
|
* random pointer streams must yield identical scenes and identical callbacks.
|
||||||
|
*/
|
||||||
|
import { createApp, defineComponent, h, nextTick, type App } from "vue";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import * as next from "../src";
|
||||||
|
import * as base from "./.baseline";
|
||||||
|
import { flush, installFrames, rng, snapshot } from "./harness";
|
||||||
|
|
||||||
|
type Impl = typeof next;
|
||||||
|
const impls = [base as unknown as Impl, next] as const;
|
||||||
|
const apps: App[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const app of apps.splice(0)) app.unmount();
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
const WIDTH = 300;
|
||||||
|
const HEIGHT = 600;
|
||||||
|
|
||||||
|
function stubBounds(element: HTMLElement) {
|
||||||
|
if ((element as { stubbed?: boolean }).stubbed) return;
|
||||||
|
(element as { stubbed?: boolean }).stubbed = true;
|
||||||
|
Object.defineProperties(element, {
|
||||||
|
clientWidth: { configurable: true, value: WIDTH },
|
||||||
|
clientHeight: { configurable: true, value: HEIGHT },
|
||||||
|
});
|
||||||
|
element.getBoundingClientRect = () =>
|
||||||
|
({
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: WIDTH,
|
||||||
|
bottom: HEIGHT,
|
||||||
|
width: WIDTH,
|
||||||
|
height: HEIGHT,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
toJSON: () => ({}),
|
||||||
|
}) as DOMRect;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointOf(point: next.OriginGesturePoint) {
|
||||||
|
return [point.clientX, point.clientY, point.localX, point.localY];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build one implementation's views, gestures, and callback recorder. */
|
||||||
|
function buildWorld(M: Impl, seed: number) {
|
||||||
|
const record: unknown[] = [];
|
||||||
|
const r = rng(seed);
|
||||||
|
const tick = async (count: number) => {
|
||||||
|
for (let i = 0; i < count; i += 1) await Promise.resolve();
|
||||||
|
};
|
||||||
|
const views: Record<string, next.OriginView> = {};
|
||||||
|
|
||||||
|
const slide = M.defineOriginChoreography({
|
||||||
|
name: "slide",
|
||||||
|
effects: ({ progress }) => ({
|
||||||
|
source: { transform: `translateX(${-progress * 100}px)` },
|
||||||
|
target: { transform: `translateX(${(1 - progress) * 100}px)` },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const drawer = M.defineOriginChoreography({
|
||||||
|
name: "drawer",
|
||||||
|
persistAtRest: true,
|
||||||
|
effects: ({ progress }) => ({
|
||||||
|
source: { transform: `translateY(${progress * 50}%)` },
|
||||||
|
target: { opacity: progress },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Asynchronous navigation delays are fixed per world so both match.
|
||||||
|
const delays = Array.from({ length: 64 }, () => r.int(4));
|
||||||
|
let delayIndex = 0;
|
||||||
|
const delay = () => tick(delays[delayIndex++ % delays.length]!);
|
||||||
|
|
||||||
|
const surfaceGestures = [
|
||||||
|
M.gesture.to
|
||||||
|
.left()
|
||||||
|
.navigate(async (context) => {
|
||||||
|
record.push(["left", context.nodeKey]);
|
||||||
|
await delay();
|
||||||
|
return M.forward(views.B!);
|
||||||
|
})
|
||||||
|
.animate(slide),
|
||||||
|
M.gesture.from
|
||||||
|
.left(40)
|
||||||
|
.to.right()
|
||||||
|
.navigate((context) => {
|
||||||
|
record.push(["right", context.nodeKey, context.canGoBack]);
|
||||||
|
return context.canGoBack ? M.back() : null;
|
||||||
|
})
|
||||||
|
.animate(M.slideRight),
|
||||||
|
M.gesture.from
|
||||||
|
.when(({ point, bounds, origin }) => {
|
||||||
|
record.push(["when", origin.nodeKey, pointOf(point), bounds.width]);
|
||||||
|
return point.localY < HEIGHT / 2;
|
||||||
|
})
|
||||||
|
.to.down({ threshold: 12, axisDominance: 1.5 })
|
||||||
|
.complete((context) => {
|
||||||
|
record.push([
|
||||||
|
"complete",
|
||||||
|
context.origin.nodeKey,
|
||||||
|
context.direction,
|
||||||
|
context.progress,
|
||||||
|
context.velocity,
|
||||||
|
context.distance,
|
||||||
|
context.crossDistance,
|
||||||
|
context.duration,
|
||||||
|
pointOf(context.start),
|
||||||
|
pointOf(context.current),
|
||||||
|
context.bounds,
|
||||||
|
]);
|
||||||
|
return context.progress > 0.2;
|
||||||
|
})
|
||||||
|
.navigate(() => M.forward(views.C!, { placement: "under" }))
|
||||||
|
.animate(drawer),
|
||||||
|
M.gesture.from
|
||||||
|
.bottom(120)
|
||||||
|
.to.up()
|
||||||
|
.navigate(async (context) => {
|
||||||
|
await delay();
|
||||||
|
if (delays[delayIndex % delays.length] === 0) throw new Error("nav");
|
||||||
|
return context.canGoBack ? M.back() : M.replace(views.A!);
|
||||||
|
})
|
||||||
|
.animate(M.fade),
|
||||||
|
];
|
||||||
|
|
||||||
|
const A = defineComponent({
|
||||||
|
name: "A",
|
||||||
|
setup: () => () =>
|
||||||
|
h(
|
||||||
|
M.OriginGestureSurface,
|
||||||
|
{ gestures: surfaceGestures, "data-gesture-host": "" },
|
||||||
|
() => [h("input", { "data-input": "" }), "A"],
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
const bGestures = [
|
||||||
|
M.gesture.to
|
||||||
|
.left({ threshold: 6 })
|
||||||
|
.navigate((context) => {
|
||||||
|
record.push(["b-left", context.nodeKey, context.history.length]);
|
||||||
|
return M.forward(views.C!);
|
||||||
|
})
|
||||||
|
.animate(slide),
|
||||||
|
M.gesture.to
|
||||||
|
.right({ threshold: 6 })
|
||||||
|
.navigate((context) => (context.canGoBack ? M.back() : null))
|
||||||
|
.animate(M.slideRight),
|
||||||
|
];
|
||||||
|
const B = defineComponent({
|
||||||
|
name: "B",
|
||||||
|
setup() {
|
||||||
|
// Bind each recognizer directly instead of through a component.
|
||||||
|
const bindings = bGestures.map((definition) =>
|
||||||
|
M.useOriginGesture(definition),
|
||||||
|
);
|
||||||
|
const each =
|
||||||
|
(name: "onPointerdown" | "onPointermove" | "onPointerup") =>
|
||||||
|
(event: PointerEvent) =>
|
||||||
|
bindings.forEach((binding) => binding[name](event));
|
||||||
|
return () =>
|
||||||
|
h(
|
||||||
|
"div",
|
||||||
|
{
|
||||||
|
"data-gesture-host": "",
|
||||||
|
style: bindings[0]!.style,
|
||||||
|
onPointerdown: each("onPointerdown"),
|
||||||
|
onPointermove: each("onPointermove"),
|
||||||
|
onPointerup: each("onPointerup"),
|
||||||
|
onPointercancel: () =>
|
||||||
|
bindings.forEach((binding) => binding.onPointercancel()),
|
||||||
|
},
|
||||||
|
[h("input", { "data-input": "" }), "B"],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const C = defineComponent({
|
||||||
|
name: "C",
|
||||||
|
setup: () => () =>
|
||||||
|
h(
|
||||||
|
M.OriginGesture,
|
||||||
|
{
|
||||||
|
"data-gesture-host": "",
|
||||||
|
direction: "up",
|
||||||
|
edge: 200,
|
||||||
|
threshold: 4,
|
||||||
|
action: (context: next.OriginContext) => {
|
||||||
|
record.push(["legacy", context.nodeKey]);
|
||||||
|
return context.canGoBack
|
||||||
|
? M.back(slide)
|
||||||
|
: M.forward(views.B!, slide);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
() => [h("input", { "data-input": "" }), "C"],
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
views.A = M.originView(A, undefined, { key: "a" });
|
||||||
|
views.B = M.originView(B, undefined, { key: "b" });
|
||||||
|
views.C = M.originView(C, undefined, { key: "c" });
|
||||||
|
|
||||||
|
const scene = M.createOriginScene({ initial: views.A });
|
||||||
|
const root = document.createElement("div");
|
||||||
|
document.body.append(root);
|
||||||
|
const app = createApp({ render: () => h(M.OriginScene, { scene }) });
|
||||||
|
apps.push(app);
|
||||||
|
app.mount(root);
|
||||||
|
return { scene, root, record };
|
||||||
|
}
|
||||||
|
|
||||||
|
function hostFor(root: HTMLElement, nodeKey: string) {
|
||||||
|
const host = root.querySelector(
|
||||||
|
`[data-origin-node="${CSS.escape(nodeKey)}"] [data-gesture-host]`,
|
||||||
|
) as HTMLElement | null;
|
||||||
|
if (host) stubBounds(host);
|
||||||
|
return host;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointerEvent(
|
||||||
|
type: string,
|
||||||
|
init: PointerEventInit,
|
||||||
|
timeStamp: number,
|
||||||
|
): PointerEvent {
|
||||||
|
const event = new PointerEvent(type, {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
...init,
|
||||||
|
});
|
||||||
|
Object.defineProperty(event, "timeStamp", { value: timeStamp });
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
maxNodes: 0,
|
||||||
|
maxOps: 0,
|
||||||
|
opSteps: 0,
|
||||||
|
records: {} as Record<string, number>,
|
||||||
|
finalNodes: {} as Record<number, number>,
|
||||||
|
};
|
||||||
|
|
||||||
|
async function runSequence(seed: number, steps: number) {
|
||||||
|
const r = rng(seed * 7919);
|
||||||
|
const frames = installFrames();
|
||||||
|
const worlds = impls.map((M) => buildWorld(M, seed));
|
||||||
|
await nextTick();
|
||||||
|
let time = 100;
|
||||||
|
let x = 0;
|
||||||
|
let y = 0;
|
||||||
|
let pointerId = 1;
|
||||||
|
let nodeKey = "";
|
||||||
|
let heading: next.OriginGestureDirection = "left";
|
||||||
|
let open = false;
|
||||||
|
const log: string[] = [];
|
||||||
|
|
||||||
|
const check = (label: string) => {
|
||||||
|
const message = `${label}\n${log.join("\n")}`;
|
||||||
|
expect(snapshot(worlds[1]!.scene), message).toEqual(
|
||||||
|
snapshot(worlds[0]!.scene),
|
||||||
|
);
|
||||||
|
expect(worlds[1]!.record, message).toEqual(worlds[0]!.record);
|
||||||
|
};
|
||||||
|
|
||||||
|
const dispatch = (type: string, init: PointerEventInit, onInput = false) => {
|
||||||
|
for (const world of worlds) {
|
||||||
|
const host = hostFor(world.root, nodeKey);
|
||||||
|
if (!host) continue;
|
||||||
|
const target = onInput
|
||||||
|
? (host.querySelector("[data-input]") as HTMLElement)
|
||||||
|
: host;
|
||||||
|
target.dispatchEvent(pointerEvent(type, init, time));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let step = 0; step < steps; step += 1) {
|
||||||
|
const roll = r.next();
|
||||||
|
time += r.int(40);
|
||||||
|
if (roll < 0.15) {
|
||||||
|
if (open && r.chance(0.8)) {
|
||||||
|
log.push(`up ${x},${y} t=${time}`);
|
||||||
|
dispatch("pointerup", { clientX: x, clientY: y, pointerId });
|
||||||
|
await flush(3);
|
||||||
|
}
|
||||||
|
open = true;
|
||||||
|
const nodes = worlds[0]!.scene.nodes.value;
|
||||||
|
const scene = worlds[0]!.scene;
|
||||||
|
const interactive = nodes.filter((node) =>
|
||||||
|
scene.isNodeInteractive(node.key),
|
||||||
|
);
|
||||||
|
nodeKey = (
|
||||||
|
interactive.length && r.chance(0.85)
|
||||||
|
? r.pick(interactive)
|
||||||
|
: r.pick(nodes)
|
||||||
|
).key;
|
||||||
|
x = r.chance(0.3) ? r.int(50) : r.int(WIDTH);
|
||||||
|
y = r.chance(0.3) ? HEIGHT - r.int(130) : r.int(HEIGHT);
|
||||||
|
pointerId = r.chance(0.9) ? 1 : 2;
|
||||||
|
const init = {
|
||||||
|
clientX: x,
|
||||||
|
clientY: y,
|
||||||
|
pointerId,
|
||||||
|
button: r.chance(0.95) ? 0 : 1,
|
||||||
|
isPrimary: r.chance(0.95),
|
||||||
|
};
|
||||||
|
heading = r.pick(["left", "right", "up", "down"] as const);
|
||||||
|
log.push(`down ${nodeKey} ${JSON.stringify(init)} t=${time}`);
|
||||||
|
dispatch("pointerdown", init, r.chance(0.08));
|
||||||
|
} else if (roll < 0.65) {
|
||||||
|
const jitter = r.int(9) - 4;
|
||||||
|
if (r.chance(0.75)) {
|
||||||
|
const amount = r.chance(0.1) ? 0 : 5 + r.int(55);
|
||||||
|
if (heading === "left") x -= amount;
|
||||||
|
if (heading === "right") x += amount;
|
||||||
|
if (heading === "up") y -= amount;
|
||||||
|
if (heading === "down") y += amount;
|
||||||
|
if (heading === "left" || heading === "right") y += jitter;
|
||||||
|
else x += jitter;
|
||||||
|
} else {
|
||||||
|
x += r.int(70) - 35;
|
||||||
|
y += r.int(70) - 35;
|
||||||
|
}
|
||||||
|
log.push(`move ${x},${y} t=${time}`);
|
||||||
|
dispatch("pointermove", { clientX: x, clientY: y, pointerId });
|
||||||
|
} else if (roll < 0.85) {
|
||||||
|
open = false;
|
||||||
|
log.push(`up ${x},${y} t=${time}`);
|
||||||
|
dispatch("pointerup", { clientX: x, clientY: y, pointerId });
|
||||||
|
} else if (roll < 0.9) {
|
||||||
|
open = false;
|
||||||
|
log.push(`cancel`);
|
||||||
|
dispatch("pointercancel", { pointerId });
|
||||||
|
} else {
|
||||||
|
const count = 1 + r.int(20);
|
||||||
|
log.push(`frames ${count}`);
|
||||||
|
for (let i = 0; i < count; i += 1) frames.advance(16);
|
||||||
|
}
|
||||||
|
if (r.chance(0.7)) {
|
||||||
|
frames.advance(16);
|
||||||
|
frames.advance(16);
|
||||||
|
}
|
||||||
|
if (!r.chance(0.2)) await flush(3);
|
||||||
|
check(`seed ${seed} step ${step}`);
|
||||||
|
stats.maxNodes = Math.max(
|
||||||
|
stats.maxNodes,
|
||||||
|
worlds[0]!.scene.nodes.value.length,
|
||||||
|
);
|
||||||
|
stats.maxOps = Math.max(
|
||||||
|
stats.maxOps,
|
||||||
|
worlds[0]!.scene.operations.value.length,
|
||||||
|
);
|
||||||
|
stats.opSteps += worlds[0]!.scene.operations.value.length ? 1 : 0;
|
||||||
|
}
|
||||||
|
for (const entry of worlds[0]!.record as unknown[][])
|
||||||
|
stats.records[entry[0] as string] =
|
||||||
|
(stats.records[entry[0] as string] ?? 0) + 1;
|
||||||
|
stats.finalNodes[worlds[0]!.scene.nodes.value.length] =
|
||||||
|
(stats.finalNodes[worlds[0]!.scene.nodes.value.length] ?? 0) + 1;
|
||||||
|
|
||||||
|
for (let i = 0; i < 400 && frames.pending(); i += 1) frames.advance(16);
|
||||||
|
await flush();
|
||||||
|
check(`seed ${seed} drained`);
|
||||||
|
return worlds[0]!.scene.nodes.value.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("gesture: baseline vs rewrite", () => {
|
||||||
|
const seeds = Number(process.env.SEEDS ?? 200);
|
||||||
|
for (let seed = 1; seed <= seeds; seed += 1) {
|
||||||
|
it(`random pointer stream ${seed}`, async () => {
|
||||||
|
await runSequence(seed, 120);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
it("reached multi-node scenes", () => {
|
||||||
|
console.log("STATS", JSON.stringify(stats));
|
||||||
|
expect(stats.maxNodes).toBeGreaterThan(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { defineComponent, h, type Component } from "vue";
|
||||||
|
import type { OriginScene } from "../src";
|
||||||
|
|
||||||
|
export function rng(seed: number) {
|
||||||
|
let a = seed >>> 0;
|
||||||
|
const next = () => {
|
||||||
|
a = (a + 0x6d2b79f5) >>> 0;
|
||||||
|
let t = a;
|
||||||
|
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||||
|
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
next,
|
||||||
|
int: (n: number) => Math.floor(next() * n),
|
||||||
|
pick: <T>(items: readonly T[]) => items[Math.floor(next() * items.length)]!,
|
||||||
|
chance: (p: number) => next() < p,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function component(name: string): Component {
|
||||||
|
return defineComponent({ name, render: () => h("div", name) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deterministic rAF + clock shared by both implementations. */
|
||||||
|
export function installFrames() {
|
||||||
|
let now = 1000;
|
||||||
|
let queue: FrameRequestCallback[] = [];
|
||||||
|
window.requestAnimationFrame = ((cb: FrameRequestCallback) => {
|
||||||
|
queue.push(cb);
|
||||||
|
return queue.length;
|
||||||
|
}) as typeof window.requestAnimationFrame;
|
||||||
|
globalThis.requestAnimationFrame = window.requestAnimationFrame;
|
||||||
|
const perf = performance;
|
||||||
|
perf.now = () => now;
|
||||||
|
return {
|
||||||
|
advance(ms = 16) {
|
||||||
|
now += ms;
|
||||||
|
const run = queue;
|
||||||
|
queue = [];
|
||||||
|
for (const cb of run) cb(now);
|
||||||
|
},
|
||||||
|
pending: () => queue.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function flush(rounds = 6) {
|
||||||
|
for (let i = 0; i < rounds; i += 1)
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Implementation-agnostic, comparable snapshot of a scene. */
|
||||||
|
export function snapshot(scene: OriginScene) {
|
||||||
|
const nodes = scene.nodes.value;
|
||||||
|
return {
|
||||||
|
nodes: nodes.map((node) => ({
|
||||||
|
key: node.key,
|
||||||
|
sequence: node.sequence,
|
||||||
|
view: node.view.key,
|
||||||
|
previousNodeKey: node.previousNodeKey,
|
||||||
|
state: node.state,
|
||||||
|
incomingOperationId: node.incomingOperationId,
|
||||||
|
history: node.history.map((view) => view.key),
|
||||||
|
})),
|
||||||
|
operations: scene.operations.value.map((operation) => ({
|
||||||
|
id: operation.id,
|
||||||
|
originKey: operation.originKey,
|
||||||
|
targetKey: operation.targetKey,
|
||||||
|
entryTargetKey: operation.entryTargetKey,
|
||||||
|
choreography: operation.choreography.name,
|
||||||
|
placement: operation.placement,
|
||||||
|
history: operation.history,
|
||||||
|
progress: operation.progress,
|
||||||
|
velocity: operation.velocity,
|
||||||
|
phase: operation.phase,
|
||||||
|
intent: operation.intent,
|
||||||
|
})),
|
||||||
|
roots: [...scene.roots.value],
|
||||||
|
styles: Object.fromEntries(
|
||||||
|
nodes.map((node) => [node.key, scene.styleForNode(node.key)]),
|
||||||
|
),
|
||||||
|
interactive: Object.fromEntries(
|
||||||
|
nodes.map((node) => [node.key, scene.isNodeInteractive(node.key)]),
|
||||||
|
),
|
||||||
|
contexts: Object.fromEntries(
|
||||||
|
nodes.map((node) => {
|
||||||
|
const context = scene.contextFor(node.key);
|
||||||
|
return [
|
||||||
|
node.key,
|
||||||
|
{
|
||||||
|
nodeKey: context.nodeKey,
|
||||||
|
view: context.view.key,
|
||||||
|
canGoBack: context.canGoBack,
|
||||||
|
previous: context.previous?.key,
|
||||||
|
history: context.history.map((view) => view.key),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
missingStyle: scene.styleForNode("missing::0"),
|
||||||
|
missingInteractive: scene.isNodeInteractive("missing::0"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function settle<T>(promise: Promise<T>) {
|
||||||
|
return promise.then(
|
||||||
|
(value) => ({ ok: true as const, value }),
|
||||||
|
(error: Error) => ({ ok: false as const, error: error.message }),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { createApp, h, nextTick } from "vue";
|
||||||
|
import { bench, describe } from "vitest";
|
||||||
|
import * as next from "../src";
|
||||||
|
import * as base from "./.baseline";
|
||||||
|
import { component } from "./harness";
|
||||||
|
|
||||||
|
type Impl = typeof next;
|
||||||
|
const all = { baseline: base as unknown as Impl, rewrite: next };
|
||||||
|
const impls = process.env.ONLY
|
||||||
|
? { [process.env.ONLY]: all[process.env.ONLY as keyof typeof all] }
|
||||||
|
: all;
|
||||||
|
|
||||||
|
const motion = next.defineOriginChoreography({
|
||||||
|
name: "bench",
|
||||||
|
effects: ({ progress }) => ({
|
||||||
|
frame: { transform: `translateY(${progress}px)` },
|
||||||
|
source: {
|
||||||
|
transform: `translateX(${-progress * 100}px)`,
|
||||||
|
opacity: 1 - progress,
|
||||||
|
},
|
||||||
|
target: {
|
||||||
|
transform: `translateX(${(1 - progress) * 100}px)`,
|
||||||
|
style: { filter: "none" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
/** Long enough for both implementations to reach steady-state JIT. */
|
||||||
|
const options = { time: 4000, warmupTime: 1500, warmupIterations: 2000 };
|
||||||
|
|
||||||
|
const views = Array.from({ length: 12 }, (_, i) =>
|
||||||
|
next.originView(component(`V${i}`), undefined, { key: `v${i}` }),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** A scene with `depth` simultaneously live, chained operations. */
|
||||||
|
async function liveChain(M: Impl, depth: number) {
|
||||||
|
const scene = M.createOriginScene({ initial: views[0]! });
|
||||||
|
const handles: next.OriginOperationHandle[] = [];
|
||||||
|
let origin = scene.nodes.value[0]!.key;
|
||||||
|
for (let i = 1; i <= depth; i += 1) {
|
||||||
|
const handle = await scene.begin(origin, M.forward(views[i]!, motion));
|
||||||
|
handle.update(0.5);
|
||||||
|
handles.push(handle);
|
||||||
|
origin = handle.targetKey;
|
||||||
|
}
|
||||||
|
return { scene, handles };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const depth of [3, 10]) {
|
||||||
|
describe(`styleForNode, every node, ${depth} live chained operations`, async () => {
|
||||||
|
for (const [name, M] of Object.entries(impls)) {
|
||||||
|
const { scene, handles } = await liveChain(M, depth);
|
||||||
|
let p = 0;
|
||||||
|
bench(
|
||||||
|
name,
|
||||||
|
() => {
|
||||||
|
p = (p + 0.013) % 1;
|
||||||
|
handles[0]!.update(p);
|
||||||
|
for (const node of scene.nodes.value) scene.styleForNode(node.key);
|
||||||
|
},
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("push → back cycle (begin + finish, no animation)", () => {
|
||||||
|
for (const [name, M] of Object.entries(impls)) {
|
||||||
|
const scene = M.createOriginScene({ initial: views[0]! });
|
||||||
|
const root = scene.nodes.value[0]!.key;
|
||||||
|
bench(
|
||||||
|
name,
|
||||||
|
async () => {
|
||||||
|
const push = await scene.begin(root, M.forward(views[1]!, motion));
|
||||||
|
await push.finish({ commit: true, animate: false });
|
||||||
|
const pop = await scene.begin(push.targetKey, M.back(motion));
|
||||||
|
await pop.finish({ commit: true, animate: false });
|
||||||
|
},
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("rendered frame: update progress + Vue re-render, 6 live operations", async () => {
|
||||||
|
for (const [name, M] of Object.entries(impls)) {
|
||||||
|
const { scene, handles } = await liveChain(M, 6);
|
||||||
|
const host = document.createElement("div");
|
||||||
|
document.body.append(host);
|
||||||
|
createApp({ render: () => h(M.OriginScene, { scene }) }).mount(host);
|
||||||
|
await nextTick();
|
||||||
|
let p = 0;
|
||||||
|
bench(
|
||||||
|
name,
|
||||||
|
async () => {
|
||||||
|
p = (p + 0.013) % 1;
|
||||||
|
for (const handle of handles) handle.update(p);
|
||||||
|
await nextTick();
|
||||||
|
},
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
/**
|
||||||
|
* Differential test: drives the baseline (main) and rewritten scenes with the
|
||||||
|
* same random operation sequences and requires identical observable state
|
||||||
|
* after every step.
|
||||||
|
*/
|
||||||
|
import { afterAll, describe, expect, it } from "vitest";
|
||||||
|
import * as next from "../src";
|
||||||
|
import * as base from "./.baseline";
|
||||||
|
import {
|
||||||
|
component,
|
||||||
|
flush,
|
||||||
|
installFrames,
|
||||||
|
rng,
|
||||||
|
settle,
|
||||||
|
snapshot,
|
||||||
|
} from "./harness";
|
||||||
|
|
||||||
|
type Impl = typeof next;
|
||||||
|
const impls = [base as unknown as Impl, next] as const;
|
||||||
|
|
||||||
|
const css = (style: Record<string, string>) => style as never;
|
||||||
|
|
||||||
|
const choreographies = [
|
||||||
|
next.defineOriginChoreography({
|
||||||
|
name: "layered",
|
||||||
|
effects: ({ progress }) => ({
|
||||||
|
source: { transform: `translateX(${-progress * 100}px)` },
|
||||||
|
target: { transform: `translateX(${(1 - progress) * 100}px)` },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
next.defineOriginChoreography({
|
||||||
|
name: "probe",
|
||||||
|
commitThreshold: 0.5,
|
||||||
|
effects: (c) => ({
|
||||||
|
frame: { style: css({ filter: `blur(${c.progress}px)` }), layer: 1 },
|
||||||
|
source: {
|
||||||
|
opacity: 1 - c.progress,
|
||||||
|
style: css({
|
||||||
|
opacity: "0.9",
|
||||||
|
transform: `rotate(${c.velocity}deg)`,
|
||||||
|
"--state": `${c.phase}/${c.intent}`,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
target: {
|
||||||
|
transform: c.progress > 0.5 ? "none" : `scale(${c.progress})`,
|
||||||
|
layer: 2,
|
||||||
|
style: css({
|
||||||
|
borderRadius: `${c.viewport.width}x${c.viewport.height}`,
|
||||||
|
"--rects": JSON.stringify([
|
||||||
|
c.originRect ?? null,
|
||||||
|
c.targetRect ?? null,
|
||||||
|
]),
|
||||||
|
position: "fixed",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
next.defineOriginChoreography({
|
||||||
|
name: "drawer-open",
|
||||||
|
persistAtRest: true,
|
||||||
|
effects: ({ progress }) => ({
|
||||||
|
source: { transform: `translateX(${progress * 66.6667}%)` },
|
||||||
|
target: { transform: `translateX(${(progress - 1) * 66.6667}%)` },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
next.defineOriginChoreography({
|
||||||
|
name: "drawer-close",
|
||||||
|
commitThreshold: 0.2,
|
||||||
|
effects: ({ progress }) => ({
|
||||||
|
source: { transform: `translateX(${-progress * 66.6667}%)` },
|
||||||
|
target: { transform: `translateX(${(1 - progress) * 66.6667}%)` },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
next.slideLeft,
|
||||||
|
next.slideRight,
|
||||||
|
next.fade,
|
||||||
|
];
|
||||||
|
|
||||||
|
const views = ["A", "B", "C", "D", "E"].map((name) =>
|
||||||
|
next.originView(component(name), undefined, { key: name.toLowerCase() }),
|
||||||
|
);
|
||||||
|
|
||||||
|
function randomAction(r: ReturnType<typeof rng>): next.OriginAction {
|
||||||
|
const choreography = r.pick(choreographies);
|
||||||
|
const target = r.pick(views);
|
||||||
|
const placement = r.pick(["above", "under", undefined] as const);
|
||||||
|
switch (r.int(6)) {
|
||||||
|
case 0:
|
||||||
|
return next.forward(target, choreography, { placement });
|
||||||
|
case 1:
|
||||||
|
return next.replace(target, choreography, { placement });
|
||||||
|
case 2:
|
||||||
|
case 3:
|
||||||
|
return next.back(choreography, { placement });
|
||||||
|
case 4:
|
||||||
|
return next.above(target, choreography, {
|
||||||
|
history: r.pick(["push", "replace", "back", undefined] as const),
|
||||||
|
});
|
||||||
|
default:
|
||||||
|
return next.under(target, choreography, {
|
||||||
|
history: r.pick(["push", "back"] as const),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Pair<T> = [T, T];
|
||||||
|
export const stats = {
|
||||||
|
undecidedFinishes: 0,
|
||||||
|
boundary: 0,
|
||||||
|
begins: 0,
|
||||||
|
beginOk: 0,
|
||||||
|
};
|
||||||
|
interface Slot {
|
||||||
|
done: [boolean, boolean];
|
||||||
|
value: [unknown, unknown];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runSequence(seed: number, steps: number) {
|
||||||
|
const r = rng(seed);
|
||||||
|
const frames = installFrames();
|
||||||
|
const scenes = impls.map((impl) =>
|
||||||
|
impl.createOriginScene({
|
||||||
|
initial: seed % 3 === 0 ? [views[0]!, views[1]!] : views[0]!,
|
||||||
|
}),
|
||||||
|
) as Pair<next.OriginScene>;
|
||||||
|
const handles: Pair<next.OriginOperationHandle>[] = [];
|
||||||
|
const slots: Slot[] = [];
|
||||||
|
const log: string[] = [];
|
||||||
|
|
||||||
|
const track = (promises: Pair<Promise<unknown>>) => {
|
||||||
|
const slot: Slot = { done: [false, false], value: [undefined, undefined] };
|
||||||
|
promises.forEach(
|
||||||
|
(promise, index) =>
|
||||||
|
void settle(promise).then((value) => {
|
||||||
|
slot.done[index] = true;
|
||||||
|
slot.value[index] = value;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
slots.push(slot);
|
||||||
|
return slot;
|
||||||
|
};
|
||||||
|
|
||||||
|
const check = (label: string) => {
|
||||||
|
expect(snapshot(scenes[1]), `${label}\n${log.join("\n")}`).toEqual(
|
||||||
|
snapshot(scenes[0]),
|
||||||
|
);
|
||||||
|
for (const slot of slots) {
|
||||||
|
expect(slot.done[1], label).toBe(slot.done[0]);
|
||||||
|
expect(slot.value[1], label).toEqual(slot.value[0]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickHandle = () => {
|
||||||
|
const liveIds = new Set(scenes[0].operations.value.map((o) => o.id));
|
||||||
|
const live = handles.filter(([handle]) => liveIds.has(handle.id));
|
||||||
|
return live.length && r.chance(0.85) ? r.pick(live) : r.pick(handles);
|
||||||
|
};
|
||||||
|
const boundaryUpdate = (pair: Pair<next.OriginOperationHandle>) => {
|
||||||
|
// Bias toward the commit-decision boundaries.
|
||||||
|
const progress = r.chance(0.4)
|
||||||
|
? r.pick([0, 0.2, 0.35999, 0.36, 0.49999, 0.5, 1])
|
||||||
|
: r.next() * 1.4 - 0.2;
|
||||||
|
const velocity = r.chance(0.2)
|
||||||
|
? undefined
|
||||||
|
: r.chance(0.4)
|
||||||
|
? r.pick([-0.3, 0, 0.29999, 0.3, 0.30001, 0.31])
|
||||||
|
: r.next() * 4 - 2;
|
||||||
|
log.push(`update ${pair[0].id} ${progress} ${velocity}`);
|
||||||
|
pair.forEach((handle) => handle.update(progress, velocity));
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let step = 0; step < steps; step += 1) {
|
||||||
|
const roll = r.next();
|
||||||
|
const nodeKeys = scenes[0].nodes.value.map((node) => node.key);
|
||||||
|
if (roll < 0.3 || handles.length === 0) {
|
||||||
|
const originKey = r.chance(0.03) ? "missing::0" : r.pick(nodeKeys);
|
||||||
|
const action = randomAction(r);
|
||||||
|
log.push(
|
||||||
|
`begin ${originKey} ${action.history} ${action.choreography.name}`,
|
||||||
|
);
|
||||||
|
const slot = track(
|
||||||
|
scenes.map((scene) => scene.begin(originKey, action)) as Pair<
|
||||||
|
Promise<next.OriginOperationHandle>
|
||||||
|
>,
|
||||||
|
);
|
||||||
|
await flush();
|
||||||
|
if (slot.done[0] && slot.done[1]) {
|
||||||
|
const [a, b] = slot.value as Pair<
|
||||||
|
{ ok: true; value: next.OriginOperationHandle } | { ok: false }
|
||||||
|
>;
|
||||||
|
stats.begins += 1;
|
||||||
|
if (a.ok && b.ok) {
|
||||||
|
stats.beginOk += 1;
|
||||||
|
handles.push([a.value, b.value]);
|
||||||
|
// Handles are compared by identity fields only.
|
||||||
|
slot.value = [
|
||||||
|
{ id: a.value.id, o: a.value.originKey, t: a.value.targetKey },
|
||||||
|
{ id: b.value.id, o: b.value.originKey, t: b.value.targetKey },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (roll < 0.55) {
|
||||||
|
boundaryUpdate(pickHandle());
|
||||||
|
} else if (roll < 0.75) {
|
||||||
|
const pair = pickHandle();
|
||||||
|
if (r.chance(0.5)) boundaryUpdate(pair);
|
||||||
|
const options = {
|
||||||
|
commit: r.pick([true, false, undefined]),
|
||||||
|
animate: r.chance(0.5),
|
||||||
|
};
|
||||||
|
log.push(`finish ${pair[0].id} ${JSON.stringify(options)}`);
|
||||||
|
const live = scenes[0].operations.value.find((o) => o.id === pair[0].id);
|
||||||
|
if (live && options.commit === undefined) {
|
||||||
|
stats.undecidedFinishes += 1;
|
||||||
|
if (live.velocity >= 0.3 && live.velocity < 0.31) stats.boundary += 1;
|
||||||
|
}
|
||||||
|
track(
|
||||||
|
pair.map((handle) => handle.finish(options)) as Pair<Promise<boolean>>,
|
||||||
|
);
|
||||||
|
} else if (roll < 0.82) {
|
||||||
|
const pair = pickHandle();
|
||||||
|
const animate = r.pick([true, false, undefined]);
|
||||||
|
log.push(`cancel ${pair[0].id} ${animate}`);
|
||||||
|
track(
|
||||||
|
pair.map((handle) => handle.cancel({ animate })) as Pair<Promise<void>>,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const count = 1 + r.int(12);
|
||||||
|
log.push(`frames ${count}`);
|
||||||
|
for (let i = 0; i < count; i += 1) frames.advance(4 + r.int(40));
|
||||||
|
}
|
||||||
|
if (!r.chance(0.15)) await flush();
|
||||||
|
check(`seed ${seed} step ${step}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drain every running spring and compare the resting state.
|
||||||
|
for (let i = 0; i < 400 && frames.pending(); i += 1) frames.advance(16);
|
||||||
|
await flush();
|
||||||
|
check(`seed ${seed} drained`);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("scene: baseline vs rewrite", () => {
|
||||||
|
it("produces identical action and intent objects", () => {
|
||||||
|
const target = views[1]!;
|
||||||
|
const c = choreographies[0]!;
|
||||||
|
const calls: Array<(m: Impl) => unknown> = [
|
||||||
|
(m) => m.originAction(target, c),
|
||||||
|
(m) => m.originAction(target, c, { placement: "under", history: "back" }),
|
||||||
|
(m) => m.forward(target),
|
||||||
|
(m) => m.forward(target, { placement: "under" }),
|
||||||
|
(m) => m.forward(target, c),
|
||||||
|
(m) => m.forward(target, c, { placement: "under" }),
|
||||||
|
(m) => m.replace(target),
|
||||||
|
(m) => m.replace(target, c, {}),
|
||||||
|
(m) => m.back(),
|
||||||
|
(m) => m.back({ placement: "above" }),
|
||||||
|
(m) => m.back(c),
|
||||||
|
(m) => m.back(c, { placement: "above" }),
|
||||||
|
(m) => m.above(target),
|
||||||
|
(m) => m.above(target, { history: "replace" }),
|
||||||
|
(m) => m.above(target, c),
|
||||||
|
(m) => m.above(target, c, { history: "back" }),
|
||||||
|
(m) => m.under(target),
|
||||||
|
(m) => m.under(target, c, { history: "back" }),
|
||||||
|
(m) => m.normalizedEffect(undefined),
|
||||||
|
(m) => m.normalizedEffect({ transform: "x", layer: 2 }, -1),
|
||||||
|
];
|
||||||
|
for (const call of calls) {
|
||||||
|
const [a, b] = impls.map(call);
|
||||||
|
expect(b).toStrictEqual(a);
|
||||||
|
expect(Object.keys(b as object)).toEqual(Object.keys(a as object));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => console.log("STATS", JSON.stringify(stats)));
|
||||||
|
|
||||||
|
it("exports the same runtime names", () => {
|
||||||
|
expect(Object.keys(next).sort()).toEqual(Object.keys(base).sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
const seeds = Number(process.env.SEEDS ?? 300);
|
||||||
|
for (let seed = 1; seed <= seeds; seed += 1) {
|
||||||
|
it(`random sequence ${seed}`, () => runSequence(seed, 60));
|
||||||
|
}
|
||||||
|
});
|
||||||
Executable
+13
@@ -0,0 +1,13 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
# Extract core-v2 sources from a git ref into ./.baseline for comparison.
|
||||||
|
set -eu
|
||||||
|
ref="${1:-main}"
|
||||||
|
here="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
root="$(git -C "$here" rev-parse --show-toplevel)"
|
||||||
|
tmp="$(mktemp -d)"
|
||||||
|
git -C "$root" archive "$ref" packages/core-v2/src | tar -x -C "$tmp"
|
||||||
|
rm -rf "$here/.baseline"
|
||||||
|
mv "$tmp/packages/core-v2/src" "$here/.baseline"
|
||||||
|
rm -f "$here/.baseline"/*.test.ts
|
||||||
|
rm -rf "$tmp"
|
||||||
|
echo "Baseline extracted from $ref into $here/.baseline"
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import vue from "@vitejs/plugin-vue";
|
||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Differential verification against a previous core-v2 implementation.
|
||||||
|
* Run `setup-baseline.sh <git-ref>` first; see README.md.
|
||||||
|
*/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue() as never],
|
||||||
|
test: {
|
||||||
|
root: __dirname,
|
||||||
|
environment: "happy-dom",
|
||||||
|
include: ["*.diff.ts"],
|
||||||
|
testTimeout: 120_000,
|
||||||
|
benchmark: { include: ["*.bench.ts"] },
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user