Restructure core-v2 into layered modules

Rewrite the core-v2 internals without changing the public API or runtime
behavior. The four large files (types, scene, motion, gesture) become
layered modules with one responsibility each:

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
This commit is contained in:
Claude
2026-10-16 01:15:12 +00:00
parent d8303107fb
commit 329271691c
48 changed files with 4134 additions and 2681 deletions
+4 -1
View File
@@ -553,7 +553,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.
+20
View File
@@ -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
The package is layered; each layer depends only on the ones above it.
| Directory | Responsibility |
| --------------- | ------------------------------------------------------------------------------------------------------- |
| `shared/` | Geometry and environment helpers |
| `view/` | `originView()` component recipes |
| `choreography/` | Effect types, `defineOriginChoreography()`, presets, and `composeEffects()` style compositing |
| `actions/` | `forward()`, `back()`, `replace()`, `above()`, `under()`, `originAction()` |
| `scene/` | `graph.ts` storage, `transitions.ts` history rules, `compositor.ts` styles, `spring.ts` settling motion |
| `composables/` | `useOrigin()` and the node-scope injection key |
| `gesture/` | Builder, definition→policy normalization, start admission, pointer tracking, and the recognizer |
| `components/` | `OriginScene`, `OriginGesture`, `OriginGestureSurface`, and the internal node host |
`createOriginScene()` wires the scene modules together: the graph holds nodes
and operation edges, transitions rewrite it synchronously when operations
begin, commit, or cancel, and the scene adds the asynchronous parts (waiting
for Vue to mount a target, measuring, and running the settling spring).
## 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
+261
View File
@@ -0,0 +1,261 @@
import { isChoreography } from "../choreography/define";
import type { OriginChoreography } from "../choreography/types";
import type { OriginView } from "../view/types";
import type {
OriginAction,
OriginActionOptions,
OriginHistoryMode,
OriginNavigationActionOptions,
OriginNavigationIntent,
OriginPlacement,
OriginPlacementActionOptions,
} from "./types";
/*
* 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,
);
}
+106
View File
@@ -0,0 +1,106 @@
import type { OriginChoreography } from "../choreography/types";
import type { OriginView } from "../view/types";
/**
* 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;
}
@@ -0,0 +1,80 @@
import type { CSSProperties } from "vue";
import { clamp01 } from "../shared/geometry";
import type { OriginEffect } from "./types";
/**
* 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 function 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 };
}
@@ -0,0 +1,27 @@
import type { OriginChoreography } 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;
}
/** Distinguish a choreography from a plain options object. */
export function isChoreography(value: unknown): value is OriginChoreography {
return (
typeof value === "object" &&
value !== null &&
"effects" in value &&
typeof value.effects === "function"
);
}
@@ -0,0 +1,55 @@
import { defineOriginChoreography } from "./define";
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 },
}),
});
+133
View File
@@ -0,0 +1,133 @@
import type { CSSProperties } from "vue";
import type { OriginRect } from "../shared/geometry";
/**
* 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;
}
@@ -1,7 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from "vue"; import { useOriginGesture } from "../gesture/useOriginGesture";
import { useOriginGesture } from "../gesture"; import type { OriginGestureProps } from "./types";
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,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from "vue"; import { computed } from "vue";
import { useOriginGesture } from "../gesture"; import { touchActionFor } from "../gesture/direction";
import type { OriginGestureSurfaceProps } from "../types"; import { useOriginGesture } from "../gesture/useOriginGesture";
import type { OriginGestureSurfaceProps } from "./types";
defineOptions({ name: "OriginGestureSurface", inheritAttrs: false }); defineOptions({ name: "OriginGestureSurface", inheritAttrs: false });
@@ -18,43 +19,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", ({
); width: "100%",
const vertical = props.gestures.some( height: "100%",
({ direction }) => direction === "up" || direction === "down", touchAction: touchActionFor(
); props.gestures.map(({ direction }) => direction),
),
}) as const,
);
return { const onPointerdown = (event: PointerEvent) =>
width: "100%", bindings.forEach((binding) => binding.onPointerdown(event));
height: "100%", const onPointermove = (event: PointerEvent) =>
touchAction: bindings.forEach((binding) => binding.onPointermove(event));
horizontal && vertical const onPointerup = (event: PointerEvent) =>
? "none" bindings.forEach((binding) => binding.onPointerup(event));
: horizontal const onPointercancel = () =>
? "pan-y" bindings.forEach((binding) => binding.onPointercancel());
: vertical
? "pan-x"
: "auto",
} as const;
});
function pointerDown(event: PointerEvent) {
for (const binding of bindings) binding.onPointerdown(event);
}
function pointerMove(event: PointerEvent) {
for (const binding of bindings) binding.onPointermove(event);
}
function pointerUp(event: PointerEvent) {
for (const binding of bindings) binding.onPointerup(event);
}
function pointerCancel() {
for (const binding of bindings) binding.onPointercancel();
}
</script> </script>
<template> <template>
@@ -63,10 +46,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 "../composables/useOrigin";
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 "./types";
import OriginNodeHost from "./OriginNodeHost.vue"; import OriginNodeHost from "./OriginNodeHost.vue";
defineOptions({ name: "OriginScene" }); defineOptions({ name: "OriginScene" });
+83
View File
@@ -0,0 +1,83 @@
import type { MaybeOriginAction } from "../actions/types";
import type {
OriginGestureDefinition,
OriginGestureDirection,
} from "../gesture/types";
import type { OriginContext, OriginScene } from "../scene/types";
/** 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;
}
@@ -0,0 +1,92 @@
import { computed, inject, type ComputedRef, type InjectionKey } from "vue";
import type { OriginAction } from "../actions/types";
import type {
OriginContext,
OriginOperationHandle,
OriginScene,
} from "../scene/types";
import type { OriginView } from "../view/types";
/**
* 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),
};
}
+14 -9
View File
@@ -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>> = [];
-582
View File
@@ -1,582 +0,0 @@
import type {
MaybeOriginAction,
OriginAction,
OriginContext,
OriginGestureBinding,
OriginGestureBuilder,
OriginGestureCompletionContext,
OriginGestureCompletionPredicate,
OriginGestureDefinition,
OriginGestureDirection,
OriginGestureDirectionOptions,
OriginGestureDistance,
OriginGestureEdge,
OriginGestureFromBuilder,
OriginGestureFromSelection,
OriginGestureNavigationBuilder,
OriginGestureNavigationFactory,
OriginGestureOptions,
OriginGesturePoint,
OriginGestureStart,
OriginGestureStartContext,
OriginGestureStartPredicate,
OriginGestureToBuilder,
OriginOperationHandle,
OriginRect,
} from "./types";
import { useOrigin } from "./lifecycle";
function ignoreGestureTarget(target: EventTarget | null) {
return (
!(target instanceof Element) ||
Boolean(
target.closest(
'[data-origin-gesture="ignore"], input, textarea, select, option, [contenteditable="true"]',
),
)
);
}
function directedDistance(
direction: OriginGestureDirection,
dx: number,
dy: number,
) {
switch (direction) {
case "left":
return -dx;
case "right":
return dx;
case "up":
return -dy;
case "down":
return dy;
}
}
function rectOf(element: HTMLElement): OriginRect {
const rect = element.getBoundingClientRect();
return {
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
};
}
function pointOf(
event: Pick<PointerEvent, "clientX" | "clientY">,
bounds: OriginRect,
): OriginGesturePoint {
return {
clientX: event.clientX,
clientY: event.clientY,
localX: event.clientX - bounds.left,
localY: event.clientY - bounds.top,
};
}
/**
* Resolve an arbitrary CSS length against a box with the gesture host's size.
*
* A short-lived off-screen box lets the browser handle `rem`, viewport units,
* percentages, `calc()`, and `clamp()` consistently. This runs only during
* pointer-down for edge-constrained definitions.
*/
function resolveCssDistance(
distance: OriginGestureDistance,
axis: "horizontal" | "vertical",
host: HTMLElement,
bounds: OriginRect,
) {
if (typeof distance === "number")
return Number.isFinite(distance) ? Math.max(0, distance) : 0;
const document = host.ownerDocument;
if (!document.body) return Math.max(0, Number.parseFloat(distance) || 0);
const container = document.createElement("div");
const probe = document.createElement("div");
Object.assign(container.style, {
position: "fixed",
left: "-100000px",
top: "-100000px",
width: `${bounds.width}px`,
height: `${bounds.height}px`,
visibility: "hidden",
pointerEvents: "none",
contain: "strict",
});
Object.assign(probe.style, {
position: "absolute",
width: axis === "horizontal" ? distance : "0",
height: axis === "vertical" ? distance : "0",
});
container.append(probe);
document.body.append(container);
const resolved =
axis === "horizontal"
? probe.getBoundingClientRect().width
: probe.getBoundingClientRect().height;
container.remove();
return Number.isFinite(resolved) ? Math.max(0, resolved) : 0;
}
function matchesStart(
start: OriginGestureStart,
event: PointerEvent,
host: HTMLElement,
origin: OriginContext,
bounds: OriginRect,
) {
if (start.kind === "anywhere") return true;
const point = pointOf(event, bounds);
if (start.kind === "when") {
const context: OriginGestureStartContext = {
event,
origin,
host,
bounds,
point,
};
return start.predicate(context);
}
const horizontal = start.edge === "left" || start.edge === "right";
const distance = resolveCssDistance(
start.distance,
horizontal ? "horizontal" : "vertical",
host,
bounds,
);
switch (start.edge) {
case "left":
return point.localX >= 0 && point.localX <= distance;
case "right":
return (
point.localX <= bounds.width && bounds.width - point.localX <= distance
);
case "top":
return point.localY >= 0 && point.localY <= distance;
case "bottom":
return (
point.localY <= bounds.height &&
bounds.height - point.localY <= distance
);
}
}
function edgeStart(
edge: OriginGestureEdge,
distance: OriginGestureDistance,
): OriginGestureStart {
return Object.freeze({ kind: "edge", edge, distance });
}
function createNavigationBuilder(
start: OriginGestureStart,
direction: OriginGestureDirection,
recognition: Readonly<OriginGestureDirectionOptions>,
completion: OriginGestureCompletionPredicate | undefined,
navigation: OriginGestureNavigationFactory,
): OriginGestureNavigationBuilder {
return Object.freeze({
animate(choreography: OriginGestureDefinition["choreography"]) {
return Object.freeze({
kind: "origin-gesture-definition",
start,
direction,
recognition,
completion,
navigation,
choreography,
});
},
});
}
function createDirectedBuilder(
start: OriginGestureStart,
direction: OriginGestureDirection,
options: OriginGestureDirectionOptions = {},
) {
const recognition = Object.freeze({ ...options });
const navigate = (
navigation: OriginGestureNavigationFactory,
completion?: OriginGestureCompletionPredicate,
) =>
createNavigationBuilder(
start,
direction,
recognition,
completion,
navigation,
);
return Object.freeze({
complete(completion: OriginGestureCompletionPredicate) {
return Object.freeze({
navigate: (navigation: OriginGestureNavigationFactory) =>
navigate(navigation, completion),
});
},
navigate,
});
}
function createToBuilder(start: OriginGestureStart): OriginGestureToBuilder {
return Object.freeze({
left: (options?: OriginGestureDirectionOptions) =>
createDirectedBuilder(start, "left", options),
right: (options?: OriginGestureDirectionOptions) =>
createDirectedBuilder(start, "right", options),
up: (options?: OriginGestureDirectionOptions) =>
createDirectedBuilder(start, "up", options),
down: (options?: OriginGestureDirectionOptions) =>
createDirectedBuilder(start, "down", options),
});
}
function selectStart(start: OriginGestureStart): OriginGestureFromSelection {
return Object.freeze({ to: createToBuilder(Object.freeze(start)) });
}
const fromBuilder: OriginGestureFromBuilder = Object.freeze({
left: (distance: OriginGestureDistance) =>
selectStart(edgeStart("left", distance)),
right: (distance: OriginGestureDistance) =>
selectStart(edgeStart("right", distance)),
top: (distance: OriginGestureDistance) =>
selectStart(edgeStart("top", distance)),
bottom: (distance: OriginGestureDistance) =>
selectStart(edgeStart("bottom", distance)),
anywhere: () => selectStart({ kind: "anywhere" }),
when: (predicate: OriginGestureStartPredicate) =>
selectStart({ kind: "when", predicate }),
});
/**
* Root of the immutable gesture builder.
*
* `.from` is optional. Starting at `.to` admits pointer-down anywhere on the
* bound host, exactly like `.from.anywhere().to`.
*
* @example Anywhere-to-right back gesture
* ```ts
* const swipeBack = gesture
* .to.right()
* .navigate((context) => context.canGoBack ? back() : null)
* .animate(slideRight);
* ```
*
* @example Predicate-gated gesture with custom completion
* ```ts
* const openPanel = gesture
* .from.when(({ point, bounds }) => point.localX <= bounds.width * 0.08)
* .to.down()
* .complete(({ progress, velocity }) => progress > 0.5 || velocity > 1)
* .navigate(() => above(originView(PanelView)))
* .animate(dropPanel);
* ```
*/
export const gesture: OriginGestureBuilder = Object.freeze({
from: fromBuilder,
to: createToBuilder(Object.freeze({ kind: "anywhere" })),
});
function isDefinition(
value: OriginGestureOptions | OriginGestureDefinition,
): value is OriginGestureDefinition {
return "kind" in value && value.kind === "origin-gesture-definition";
}
interface RuntimeGesturePolicy {
readonly direction: OriginGestureDirection;
readonly start: OriginGestureStart;
readonly threshold: number;
readonly axisDominance: number;
readonly completion?: OriginGestureCompletionPredicate;
readonly action: (context: OriginContext) => MaybeOriginAction;
}
function legacyStart(options: OriginGestureOptions): OriginGestureStart {
if (options.edge === undefined) return { kind: "anywhere" };
switch (options.direction) {
case "left":
return edgeStart("right", options.edge);
case "right":
return edgeStart("left", options.edge);
case "up":
return edgeStart("bottom", options.edge);
case "down":
return edgeStart("top", options.edge);
}
}
function runtimePolicy(
options: OriginGestureOptions | OriginGestureDefinition,
): RuntimeGesturePolicy {
if (!isDefinition(options)) {
return {
direction: options.direction,
start: legacyStart(options),
threshold: options.threshold ?? 8,
axisDominance: 1.15,
action: options.action,
};
}
return {
direction: options.direction,
start: options.start,
threshold: options.recognition.threshold ?? 8,
axisDominance: options.recognition.axisDominance ?? 1.15,
completion: options.completion,
action: async (context) => {
const navigation = await options.navigation(context);
if (!navigation) return navigation;
const action: OriginAction = {
...navigation,
choreography: options.choreography,
};
return action;
},
};
}
/**
* Install a component-owned pointer recognizer.
*
* Recognition is local to the element receiving these handlers. There is no
* application-wide gesture table and no lookup of a currently active view.
*
* Builder definitions may independently describe their pointer-down region
* and movement direction. Omitting `.from` recognizes pointer-down across the
* whole element. The legacy options object remains supported; its `edge` is
* inferred from the opposite side of its movement direction.
*
* Interactive controls and anything inside
* `[data-origin-gesture="ignore"]` are ignored automatically. The recognizer
* preserves native scrolling on the cross-axis through its returned style.
*
* @param definition - An immutable builder result or legacy recognizer options.
* @returns Pointer handlers and required host styles.
* @throws If called outside a component rendered by `OriginScene`.
*
* @example Builder-defined backward gesture
* ```ts
* const swipeBack = useOriginGesture(
* gesture
* .from.left("32px")
* .to.right()
* .navigate((context) => context.canGoBack ? back() : null)
* .animate(slideRight),
* );
* ```
*/
export function useOriginGesture(
definition: OriginGestureDefinition | OriginGestureOptions,
): OriginGestureBinding {
const origin = useOrigin();
const policy = runtimePolicy(definition);
let pointerId = -1;
let element: HTMLElement | null = null;
let bounds: OriginRect | null = null;
let originContext: OriginContext | null = null;
let startPoint: OriginGesturePoint | null = null;
let startTime = 0;
let startX = 0;
let startY = 0;
let lastCoordinate = 0;
let lastTime = 0;
let captured = false;
let generation = 0;
let handlePromise: Promise<OriginOperationHandle | null> | null = null;
let bufferedProgress = 0;
let bufferedVelocity = 0;
let bufferedDistance = 0;
let bufferedCrossDistance = 0;
const horizontal =
policy.direction === "left" || policy.direction === "right";
function reset() {
pointerId = -1;
element = null;
bounds = null;
originContext = null;
startPoint = null;
captured = false;
handlePromise = null;
bufferedProgress = 0;
bufferedVelocity = 0;
bufferedDistance = 0;
bufferedCrossDistance = 0;
}
function onPointerdown(event: PointerEvent) {
const current = event.currentTarget;
if (
!event.isPrimary ||
event.button !== 0 ||
!(current instanceof HTMLElement) ||
ignoreGestureTarget(event.target)
)
return;
const nextBounds = rectOf(current);
const nextOriginContext = origin.context.value;
if (
!matchesStart(policy.start, event, current, nextOriginContext, nextBounds)
)
return;
// The component containing this declaration is the operation's origin.
event.stopPropagation();
generation += 1;
pointerId = event.pointerId;
element = current;
bounds = nextBounds;
originContext = nextOriginContext;
startPoint = pointOf(event, nextBounds);
startTime = event.timeStamp;
startX = event.clientX;
startY = event.clientY;
lastCoordinate = horizontal ? event.clientX : event.clientY;
lastTime = event.timeStamp;
captured = false;
handlePromise = null;
}
function updateMetrics(event: PointerEvent, release = false) {
if (!element) return;
const dx = event.clientX - startX;
const dy = event.clientY - startY;
bufferedDistance = directedDistance(policy.direction, dx, dy);
bufferedCrossDistance = horizontal ? Math.abs(dy) : Math.abs(dx);
const size = Math.max(
1,
horizontal ? element.clientWidth : element.clientHeight,
);
const coordinate = horizontal ? event.clientX : event.clientY;
const coordinateDelta =
policy.direction === "left" || policy.direction === "up"
? lastCoordinate - coordinate
: coordinate - lastCoordinate;
const rawElapsed = event.timeStamp - lastTime;
const elapsed = Math.max(8, rawElapsed);
bufferedProgress = Math.max(0, Math.min(1, bufferedDistance / size));
/*
* Pointer-up commonly repeats the final pointer-move coordinate. Preserve
* that move's flick velocity for a prompt release, but decay it when the
* pointer was held still long enough for the flick to have ended.
*/
if (!release || coordinateDelta !== 0 || rawElapsed > 80)
bufferedVelocity = (coordinateDelta * 1000) / (elapsed * size);
lastCoordinate = coordinate;
lastTime = event.timeStamp;
}
async function onPointermove(event: PointerEvent) {
if (event.pointerId !== pointerId || !element) return;
const dx = event.clientX - startX;
const dy = event.clientY - startY;
const distance = directedDistance(policy.direction, dx, dy);
const crossDistance = horizontal ? Math.abs(dy) : Math.abs(dx);
let metricsUpdated = false;
if (!captured) {
if (
distance < policy.threshold ||
distance < crossDistance * policy.axisDominance
)
return;
captured = true;
element.setPointerCapture?.(pointerId);
event.preventDefault();
updateMetrics(event);
metricsUpdated = true;
const recognitionGeneration = generation;
const action = await Promise.resolve(
policy.action(origin.context.value),
).catch(() => null);
// An asynchronous target resolver may finish after the pointer was
// released or cancelled. It must not create an orphan scene operation.
if (recognitionGeneration !== generation || event.pointerId !== pointerId)
return;
if (!action) return reset();
handlePromise = origin.begin(action).catch(() => null);
}
event.preventDefault();
if (!metricsUpdated) updateMetrics(event);
const pending = handlePromise;
const handle = pending ? await pending : null;
if (pending === handlePromise)
handle?.update(bufferedProgress, bufferedVelocity);
}
async function onPointerup(event: PointerEvent) {
if (event.pointerId !== pointerId) return;
if (captured) updateMetrics(event, true);
const pending = handlePromise;
const shouldFinish = captured;
const progress = bufferedProgress;
const velocity = bufferedVelocity;
const completion =
shouldFinish &&
policy.completion &&
element &&
bounds &&
originContext &&
startPoint
? policy.completion({
origin: originContext,
direction: policy.direction,
progress,
velocity,
distance: bufferedDistance,
crossDistance: bufferedCrossDistance,
duration: Math.max(0, event.timeStamp - startTime),
event,
host: element,
bounds,
start: startPoint,
current: pointOf(event, bounds),
} satisfies OriginGestureCompletionContext)
: undefined;
generation += 1;
reset();
const handle = pending ? await pending : null;
if (!shouldFinish || !handle) return;
handle.update(progress, velocity);
await handle.finish(
completion === undefined ? undefined : { commit: completion },
);
}
async function onPointercancel() {
generation += 1;
const pending = handlePromise;
const shouldCancel = captured;
reset();
const handle = pending ? await pending : null;
if (shouldCancel) await handle?.cancel();
}
return {
style: {
// Preserve native scrolling perpendicular to the declared gesture.
touchAction: horizontal ? "pan-y" : "pan-x",
// OriginGesture is commonly the root returned by a view component.
width: "100%",
height: "100%",
},
onPointerdown,
onPointermove: (event) => void onPointermove(event),
onPointerup: (event) => void onPointerup(event),
onPointercancel: () => void onPointercancel(),
};
}
+123
View File
@@ -0,0 +1,123 @@
import type { OriginChoreography } from "../choreography/types";
import type {
OriginGestureBuilder,
OriginGestureCompletionPredicate,
OriginGestureDefinition,
OriginGestureDirectedBuilder,
OriginGestureDirection,
OriginGestureDirectionOptions,
OriginGestureDistance,
OriginGestureEdge,
OriginGestureFromBuilder,
OriginGestureFromSelection,
OriginGestureNavigationBuilder,
OriginGestureNavigationFactory,
OriginGestureStart,
OriginGestureStartPredicate,
OriginGestureToBuilder,
} from "./types";
/*
* Immutable fluent builder. Each stage is a frozen object closing over the
* choices made so far; the final `.animate()` produces a frozen definition.
*
* gesture[.from.<start>].to.<direction>()[.complete()].navigate().animate()
*/
function navigationStage(
start: OriginGestureStart,
direction: OriginGestureDirection,
recognition: Readonly<OriginGestureDirectionOptions>,
completion: OriginGestureCompletionPredicate | undefined,
) {
return (
navigation: OriginGestureNavigationFactory,
): OriginGestureNavigationBuilder =>
Object.freeze({
animate: (choreography: OriginChoreography): OriginGestureDefinition =>
Object.freeze({
kind: "origin-gesture-definition",
start,
direction,
recognition,
completion,
navigation,
choreography,
}),
});
}
function directedStage(
start: OriginGestureStart,
direction: OriginGestureDirection,
options: OriginGestureDirectionOptions = {},
): OriginGestureDirectedBuilder {
const recognition = Object.freeze({ ...options });
return Object.freeze({
complete: (completion: OriginGestureCompletionPredicate) =>
Object.freeze({
navigate: navigationStage(start, direction, recognition, completion),
}),
navigate: navigationStage(start, direction, recognition, undefined),
});
}
function toStage(start: OriginGestureStart): OriginGestureToBuilder {
const direct =
(direction: OriginGestureDirection) =>
(options?: OriginGestureDirectionOptions) =>
directedStage(start, direction, options);
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" })),
});
+51
View File
@@ -0,0 +1,51 @@
import type { OriginGestureDirection } from "./types";
export function isHorizontal(direction: OriginGestureDirection): boolean {
return direction === "left" || direction === "right";
}
/** Component of a movement `(dx, dy)` along `direction`. */
export function 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 function 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 function 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";
}
+87
View File
@@ -0,0 +1,87 @@
import type { MaybeOriginAction, OriginAction } from "../actions/types";
import type { OriginContext } from "../scene/types";
import type {
OriginGestureCompletionPredicate,
OriginGestureDefinition,
OriginGestureDirection,
OriginGestureOptions,
OriginGestureStart,
} from "./types";
/** Directed movement in CSS pixels required before a gesture captures. */
export const DEFAULT_THRESHOLD = 8;
/** Ratio by which directed movement must exceed cross-axis movement. */
export const DEFAULT_AXIS_DOMINANCE = 1.15;
/**
* 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 action: (context: OriginContext) => MaybeOriginAction;
}
function isDefinition(
value: OriginGestureOptions | OriginGestureDefinition,
): value is OriginGestureDefinition {
return "kind" in value && value.kind === "origin-gesture-definition";
}
/** Legacy `edge` is measured from the side opposite the movement direction. */
const LEGACY_START_EDGE = {
left: "right",
right: "left",
up: "bottom",
down: "top",
} as const;
function fromLegacyOptions(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,
};
}
function fromDefinition(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,
// 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;
},
};
}
export function recognizerPolicy(
definition: OriginGestureDefinition | OriginGestureOptions,
): RecognizerPolicy {
return isDefinition(definition)
? fromDefinition(definition)
: fromLegacyOptions(definition);
}
+183
View File
@@ -0,0 +1,183 @@
import type { OriginAction } from "../actions/types";
import type { UseOrigin } from "../composables/useOrigin";
import type { OriginContext, OriginOperationHandle } from "../scene/types";
import { measureRect, type OriginRect } from "../shared/geometry";
import { isHorizontal } from "./direction";
import type { RecognizerPolicy } from "./policy";
import { isIgnoredTarget, matchesStart, pointOf } from "./start";
import { DirectionalTracker } from "./tracker";
import type { OriginGesturePoint } from "./types";
/** 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(
private readonly policy: RecognizerPolicy,
private readonly origin: RecognizerOrigin,
) {}
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 commit = this.policy.completion?.({
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 { progress, velocity } = tracker;
const handle = session.operation ? await session.operation : null;
if (!handle) return;
handle.update(progress, velocity);
await handle.finish(commit === undefined ? undefined : { commit });
}
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);
}
}
}
+115
View File
@@ -0,0 +1,115 @@
import type { OriginContext } from "../scene/types";
import type { OriginRect } from "../shared/geometry";
import type {
OriginGestureDistance,
OriginGesturePoint,
OriginGestureStart,
} from "./types";
/*
* 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 function isIgnoredTarget(target: EventTarget | null): boolean {
return (
!(target instanceof Element) || Boolean(target.closest(IGNORED_TARGETS))
);
}
export 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,
};
}
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.
*/
function 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 function 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
);
}
}
+86
View File
@@ -0,0 +1,86 @@
import { clamp01 } from "../shared/geometry";
import { crossDistance, directedDistance, isHorizontal } from "./direction";
import type { OriginGestureDirection } from "./types";
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;
}
}
+290
View File
@@ -0,0 +1,290 @@
import type { CSSProperties } from "vue";
import type {
MaybeOriginAction,
OriginNavigationIntent,
} from "../actions/types";
import type { OriginChoreography } from "../choreography/types";
import type { OriginContext } from "../scene/types";
import type { OriginRect } from "../shared/geometry";
/** 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;
}
@@ -0,0 +1,60 @@
import { useOrigin } from "../composables/useOrigin";
import { touchActionFor } from "./direction";
import { recognizerPolicy } from "./policy";
import { GestureRecognizer } from "./recognizer";
import type {
OriginGestureBinding,
OriginGestureDefinition,
OriginGestureOptions,
} from "./types";
/**
* 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 policy = recognizerPolicy(definition);
const recognizer = new GestureRecognizer(policy, useOrigin());
return {
style: {
// Preserve native scrolling perpendicular to the declared gesture.
touchAction: touchActionFor([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(),
};
}
+41 -5
View File
@@ -5,14 +5,50 @@
* 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.
* *
* Layers, from the bottom up:
*
* - `view/` component recipes (`originView`)
* - `choreography/` visual effects, presets, and effect compositing
* - `actions/` navigation intents and actions (`forward`, `back`, …)
* - `scene/` graph storage, transitions, compositor, settling spring
* - `composables/` node-scoped access to the owning scene (`useOrigin`)
* - `gesture/` builder, pointer tracking, and recognition
* - `components/` Vue renderers and gesture hosts
*
* @packageDocumentation * @packageDocumentation
*/ */
export * from "./types"; export type { OriginRect } from "./shared/geometry";
export * from "./scene";
export * from "./motion"; export type * from "./view/types";
export * from "./gesture"; export { originView } from "./view/originView";
export * from "./lifecycle";
export type * from "./choreography/types";
export { defineOriginChoreography } from "./choreography/define";
export { normalizedEffect } from "./choreography/compose";
export { fade, slideLeft, slideRight } from "./choreography/presets";
export type * from "./actions/types";
export {
above,
back,
forward,
originAction,
replace,
under,
} from "./actions/actions";
export type * from "./scene/types";
export { createOriginScene } from "./scene/createOriginScene";
export type { OriginNodeScope, UseOrigin } from "./composables/useOrigin";
export { originNodeScopeKey, useOrigin } from "./composables/useOrigin";
export type * from "./gesture/types";
export { gesture } from "./gesture/builder";
export { useOriginGesture } from "./gesture/useOriginGesture";
export type * from "./components/types";
/** /**
* Convenience component that binds one `useOriginGesture()` recognizer to a * Convenience component that binds one `useOriginGesture()` recognizer to a
-53
View File
@@ -1,53 +0,0 @@
import { computed, inject, type InjectionKey } from "vue";
import type { OriginNodeScope, UseOrigin } from "./types";
/**
* Injection key used by the internal scene-node host to establish origin
* ownership for descendant components.
*
* Application code normally calls {@link useOrigin} instead of injecting this
* key directly.
*
* @internal
*/
export const originNodeScopeKey: InjectionKey<OriginNodeScope> =
Symbol("origin-node-scope");
/**
* Access the scene from the component that owns an interaction declaration.
*
* There is deliberately no `activeView`: the injected node is the origin
* because this component is where the event or application action occurred.
*
* @returns Node-scoped scene state and operation controls.
* @throws If called outside a component rendered by `OriginScene`.
*
* @example
* ```ts
* const origin = useOrigin();
*
* function openProfile() {
* return origin.perform(
* forward(originView(ProfileView), slideLeft),
* );
* }
* ```
*/
export function useOrigin(): UseOrigin {
const scope = inject(originNodeScopeKey);
if (!scope)
throw new Error("useOrigin() must be called inside an <OriginScene> view.");
const context = computed(() => scope.scene.contextFor(scope.nodeKey));
return {
nodeKey: scope.nodeKey,
scene: scope.scene,
context,
view: computed(() => context.value.view),
previous: computed(() => context.value.previous),
canGoBack: computed(() => context.value.canGoBack),
begin: (action) => scope.scene.begin(scope.nodeKey, action),
perform: (action) => scope.scene.perform(scope.nodeKey, action),
};
}
-370
View File
@@ -1,370 +0,0 @@
import type {
OriginAction,
OriginChoreography,
OriginEffect,
OriginEffectSet,
OriginHistoryMode,
OriginNavigationIntent,
OriginPlacement,
OriginView,
} from "./types";
/**
* Preserve type inference while declaring a custom choreography.
*
* The helper performs no runtime transformation. It gives custom routines a
* named, documented construction point and validates their shape in TypeScript.
*
* @param choreography - Side-effect-free visual effect calculator and optional
* release thresholds.
* @returns The same choreography object.
*/
export function defineOriginChoreography(
choreography: OriginChoreography,
): OriginChoreography {
return choreography;
}
/** Options accepted by {@link originAction}. */
export interface OriginActionOptions {
/**
* Target stacking relationship during the operation.
*
* @defaultValue `"above"`
*/
placement?: OriginPlacement;
/**
* History mutation assigned to the created target node.
*
* @defaultValue `"push"`
*/
history?: OriginHistoryMode;
}
/** History options shared by the {@link above} and {@link under} helpers. */
export interface OriginPlacementActionOptions {
/**
* History mutation assigned to the created target node.
*
* @defaultValue `"push"`
*/
history?: OriginHistoryMode;
}
/** Stacking options accepted by retained-history navigation helpers. */
export interface OriginNavigationActionOptions {
/**
* Target stacking relationship during the operation.
*
* @defaultValue `"above"` for {@link forward} and {@link replace}, `"under"`
* for {@link back}
*/
placement?: OriginPlacement;
}
function isChoreography(
value: OriginChoreography | object | undefined,
): value is OriginChoreography {
return (
value !== undefined &&
"effects" in value &&
typeof value.effects === "function"
);
}
/**
* Create the scene mutation invoked by a click, gesture, hardware command, or
* any other application event. `above` and `under` affect stacking only; they
* do not imply a universal navigation direction.
*
* @param target - View recipe to create.
* @param choreography - Visual routine controlling the operation.
* @param options - Stacking and history behavior.
* @returns An action that can be passed to `begin()`, `perform()`, or returned
* from a gesture action factory.
*/
export function originAction(
target: OriginView,
choreography: OriginChoreography,
options: OriginActionOptions = {},
): OriginAction {
return {
target,
choreography,
placement: options.placement ?? "above",
history: options.history ?? "push",
};
}
/**
* Create a retained-history push intent or complete action.
*
* Committing the action parks its origin instance and leaves it mounted until
* a later committed {@link back} action pops the new entry. Omit choreography
* when declaring `.navigate()` inside a gesture builder; provide choreography
* when passing the result directly to `begin()` or `perform()`.
*
* @param target - View recipe created if the push commits.
* @param options - Stacking options for an animation-free navigation intent.
* @returns An animation-free intent for use with a gesture builder.
*/
export function forward(
target: OriginView,
options?: OriginNavigationActionOptions,
): OriginNavigationIntent;
export function forward(
target: OriginView,
choreography: OriginChoreography,
options?: OriginNavigationActionOptions,
): OriginAction;
export function forward(
target: OriginView,
choreographyOrOptions:
OriginChoreography | OriginNavigationActionOptions = {},
options: OriginNavigationActionOptions = {},
): OriginAction | OriginNavigationIntent {
if (isChoreography(choreographyOrOptions)) {
return originAction(target, choreographyOrOptions, {
placement: options.placement ?? "above",
history: "push",
});
}
return {
target,
placement: choreographyOrOptions.placement ?? "above",
history: "push",
};
}
/**
* Create a retained-history replacement intent or complete action.
*
* The target inherits the origin's previous mounted entry instead of retaining
* the origin itself. A commit unmounts the origin and activates the target, so
* a later {@link back} skips the replaced entry. A cancellation removes the
* proposed target and restores the origin unchanged.
*
* Omit choreography when declaring `.navigate()` inside a gesture builder;
* provide choreography when passing the result directly to `begin()` or
* `perform()`.
*
* @param target - View recipe created for the replacement.
* @param options - Stacking options for an animation-free navigation intent.
* @returns An animation-free replacement intent for use with a gesture builder.
*/
export function replace(
target: OriginView,
options?: OriginNavigationActionOptions,
): OriginNavigationIntent;
export function replace(
target: OriginView,
choreography: OriginChoreography,
options?: OriginNavigationActionOptions,
): OriginAction;
export function replace(
target: OriginView,
choreographyOrOptions:
OriginChoreography | OriginNavigationActionOptions = {},
options: OriginNavigationActionOptions = {},
): OriginAction | OriginNavigationIntent {
if (isChoreography(choreographyOrOptions)) {
return originAction(target, choreographyOrOptions, {
placement: options.placement ?? "above",
history: "replace",
});
}
return {
target,
placement: choreographyOrOptions.placement ?? "above",
history: "replace",
};
}
/**
* Create a retained-history back intent or complete action.
*
* The action intentionally has no target recipe. At execution time the scene
* resolves the origin's mounted `previousNodeKey`, reveals that exact instance,
* and removes the current instance only if the operation commits. Omit
* choreography inside gesture `.navigate()`; provide it for a programmatic
* action.
*
* @param options - Stacking options for an animation-free navigation intent.
* @returns An animation-free back intent for use with a gesture builder.
*/
export function back(
options?: OriginNavigationActionOptions,
): OriginNavigationIntent;
export function back(
choreography: OriginChoreography,
options?: OriginNavigationActionOptions,
): OriginAction;
export function back(
choreographyOrOptions:
OriginChoreography | OriginNavigationActionOptions = {},
options: OriginNavigationActionOptions = {},
): OriginAction | OriginNavigationIntent {
if (!isChoreography(choreographyOrOptions)) {
return {
placement: choreographyOrOptions.placement ?? "under",
history: "back",
};
}
return {
choreography: choreographyOrOptions,
placement: options.placement ?? "under",
history: "back",
};
}
/**
* Create an intent or action whose target is stacked above its origin.
*
* This helper controls stacking, not movement direction. The supplied
* choreography may move either component however the application chooses.
* Omitting choreography produces an intent for gesture `.navigate()`.
*
* @param target - View recipe to create.
* @param choreography - Visual routine controlling the operation.
* @param options - Optional history behavior.
*/
export function above(
target: OriginView,
options?: OriginPlacementActionOptions,
): OriginNavigationIntent;
export function above(
target: OriginView,
choreography: OriginChoreography,
options?: OriginPlacementActionOptions,
): OriginAction;
export function above(
target: OriginView,
choreographyOrOptions: OriginChoreography | OriginPlacementActionOptions = {},
options: OriginPlacementActionOptions = {},
): OriginAction | OriginNavigationIntent {
if (isChoreography(choreographyOrOptions)) {
return originAction(target, choreographyOrOptions, {
placement: "above",
history: options.history,
});
}
return {
target,
placement: "above",
history: choreographyOrOptions.history ?? "push",
};
}
/**
* Create an intent or action whose target is stacked underneath its origin.
*
* Commonly used for custom reveal effects, but it has no implicit history
* meaning. Prefer {@link back} for retained-history navigation. Omitting
* choreography produces an intent for gesture `.navigate()`.
*
* @param target - View recipe to create underneath the origin.
* @param choreography - Visual routine controlling the operation.
* @param options - Optional history behavior.
*/
export function under(
target: OriginView,
options?: OriginPlacementActionOptions,
): OriginNavigationIntent;
export function under(
target: OriginView,
choreography: OriginChoreography,
options?: OriginPlacementActionOptions,
): OriginAction;
export function under(
target: OriginView,
choreographyOrOptions: OriginChoreography | OriginPlacementActionOptions = {},
options: OriginPlacementActionOptions = {},
): OriginAction | OriginNavigationIntent {
if (isChoreography(choreographyOrOptions)) {
return originAction(target, choreographyOrOptions, {
placement: "under",
history: options.history,
});
}
return {
target,
placement: "under",
history: choreographyOrOptions.history ?? "push",
};
}
const percent = (value: number) => `${(value * 100).toFixed(4)}%`;
/**
* Native-style forward motion: the target enters above the source while the
* source recedes slightly. These presets are examples; applications can
* replace them with arbitrary `defineOriginChoreography()` callbacks.
*/
export const slideLeft = defineOriginChoreography({
name: "slide-left",
commitThreshold: 0.36,
commitVelocity: 0.6,
effects: ({ progress }): OriginEffectSet => ({
source: {
transform: `translate3d(${percent(progress * -0.24)}, 0, 0) scale(${1 - progress * 0.025})`,
opacity: 1 - progress * 0.16,
},
target: {
transform: `translate3d(${percent(1 - progress)}, 0, 0)`,
},
}),
});
/**
* Back motion reveals the target underneath the source. This is merely a
* visual routine; the {@link back} action selects retained-history behavior.
*/
export const slideRight = defineOriginChoreography({
name: "slide-right",
commitThreshold: 0.36,
commitVelocity: 0.6,
effects: ({ progress }): OriginEffectSet => ({
source: {
transform: `translate3d(${percent(progress)}, 0, 0)`,
},
target: {
transform: `translate3d(${percent(-0.24 + progress * 0.24)}, 0, 0) scale(${0.975 + progress * 0.025})`,
opacity: 0.84 + progress * 0.16,
},
}),
});
/**
* Cross-fade preset that fades the source out while fading the target in.
*
* The default release thresholds from the scene are used.
*/
export const fade = defineOriginChoreography({
name: "fade",
effects: ({ progress }): OriginEffectSet => ({
source: { opacity: 1 - progress },
target: { opacity: progress },
}),
});
/**
* Normalize one contribution before the compositor combines it with effects
* inherited from earlier origin frames.
*
* Most applications should return plain effects from a choreography and let
* the scene call this function. It is exported for custom compositors and
* diagnostics.
*
* @param effect - Optional effect to normalize.
* @param fallbackLayer - Relative layer contribution added to `effect.layer`.
* @returns A defined effect with a numeric layer.
*/
export function normalizedEffect(
effect: OriginEffect | undefined,
fallbackLayer = 0,
): OriginEffect {
return {
...effect,
layer: (effect?.layer ?? 0) + fallbackLayer,
};
}
+4 -6
View File
@@ -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",
-773
View File
@@ -1,773 +0,0 @@
import {
computed,
markRaw,
nextTick,
reactive,
shallowReactive,
shallowRef,
type Component,
type CSSProperties,
} from "vue";
import { normalizedEffect } from "./motion";
import type {
MutableOriginOperation,
OriginAction,
OriginChoreographyContext,
OriginContext,
OriginEffect,
OriginEffectSet,
OriginFinishOptions,
OriginOperation,
OriginOperationHandle,
OriginRect,
OriginScene,
OriginSceneNode,
OriginSceneNodeState,
OriginView,
PropsOf,
} from "./types";
interface SceneNodeState {
key: string;
sequence: number;
view: OriginView;
previousNodeKey?: string;
state: OriginSceneNodeState;
incomingOperationId?: number;
}
/** Options used to create an independent origin scene. */
export interface CreateOriginSceneOptions {
/**
* One initial root recipe, or several independent roots rendered in the same
* flat scene. Most applications begin with one root.
*/
initial: OriginView | readonly OriginView[];
}
/** Optional developer-facing metadata for an {@link OriginView} recipe. */
export interface OriginViewOptions {
/**
* Stable recipe identity used in diagnostics and generated node-key prefixes.
* It does not preserve or reuse a mounted Vue component instance.
*/
key?: string;
/** Human-readable label used by inspectors and DOM data attributes. */
name?: string;
}
let viewSequence = 0;
/**
* Turn a component and props into a lightweight, reusable view recipe.
*
* Components are marked raw so Vue never attempts to proxy their definitions
* when recipes are placed in reactive scene/history structures.
*
* @typeParam Props - Props passed to the component when the recipe is mounted.
* @param component - Vue component definition to mount.
* @param props - Props captured by the recipe.
* @param options - Optional recipe identity and diagnostic name.
* @returns An immutable, reusable component recipe. It is not a Vue instance.
*
* @example
* ```ts
* const profile = originView(
* ProfileView,
* { userId: "42" },
* { key: "profile-42", name: "Profile" },
* );
* ```
*/
export function originView<
T extends Component = Component,
>(
component: T,
props?: PropsOf<T>,
options: OriginViewOptions = {},
): OriginView<T> {
const inferredName =
options.name ??
(typeof component === "object" && "name" in component
? String(component.name)
: undefined);
return markRaw({
component: markRaw(component),
props,
key: options.key ?? `${inferredName ?? "view"}-${++viewSequence}`,
name: inferredName,
});
}
function clamp(value: number) {
return Math.max(0, Math.min(1, value));
}
function elementRect(element: HTMLElement | null | undefined) {
if (!element) return undefined;
const rect = element.getBoundingClientRect();
return {
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
};
}
function defaultViewport(element: HTMLElement | null): OriginRect {
return (
elementRect(element) ?? {
top: 0,
left: 0,
width: typeof window === "undefined" ? 1 : window.innerWidth,
height: typeof window === "undefined" ? 1 : window.innerHeight,
}
);
}
function prefersReducedMotion() {
return (
typeof window === "undefined" ||
!window.requestAnimationFrame ||
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
);
}
/**
* Create one independent scene/history context.
*
* The implementation stores nodes flat. Operation edges describe how their
* visual effects compose, but a Vue component never moves to a different VNode
* parent as edges are added or removed. This is what preserves component state
* while X→Y and Y→Z overlap.
*
* @param options - Initial root view recipe or recipes.
* @returns A self-contained reactive scene. Render it through `OriginScene`.
*
* @example
* ```ts
* const scene = createOriginScene({
* initial: originView(HomeView, undefined, { key: "home" }),
* });
* ```
*/
export function createOriginScene(
options: CreateOriginSceneOptions,
): OriginScene {
const nodes = shallowReactive(new Map<string, SceneNodeState>());
const operations = shallowReactive(new Map<number, MutableOriginOperation>());
/*
* A close gesture temporarily removes a connected resting edge to avoid a
* visual cycle (page→drawer→page). If that close is cancelled, this map lets
* us restore the exact same edge and component instances.
*/
const suspendedPresentations = new Map<number, MutableOriginOperation>();
const roots = shallowRef<string[]>([]);
const elements = new Map<string, HTMLElement>();
let container: HTMLElement | null = null;
let nodeSequence = 0;
let operationSequence = 0;
function uniqueNodeKey(view: OriginView) {
return `${view.key ?? view.name ?? "view"}::${++nodeSequence}`;
}
function addNode(
view: OriginView,
previousNodeKey?: string,
incomingOperationId?: number,
) {
const node: SceneNodeState = shallowReactive({
key: uniqueNodeKey(view),
sequence: nodeSequence,
view: markRaw(view),
previousNodeKey,
state: incomingOperationId ? "transitioning" : "active",
incomingOperationId,
});
nodes.set(node.key, node);
return node;
}
const initialViews = Array.isArray(options.initial)
? options.initial
: [options.initial];
for (const initial of initialViews) {
const node = addNode(initial);
roots.value = [...roots.value, node.key];
}
function historyNodesFor(node: SceneNodeState) {
const history: SceneNodeState[] = [];
const visited = new Set<string>([node.key]);
let previousKey = node.previousNodeKey;
while (previousKey && !visited.has(previousKey)) {
visited.add(previousKey);
const previous = nodes.get(previousKey);
if (!previous) break;
history.unshift(previous);
previousKey = previous.previousNodeKey;
}
return history;
}
function contextFor(nodeKey: string): OriginContext {
const node = nodes.get(nodeKey);
if (!node)
throw new Error(`Origin scene node "${nodeKey}" no longer exists.`);
const historyNodes = historyNodesFor(node);
return {
nodeKey,
view: node.view,
canGoBack: Boolean(node.previousNodeKey),
previous: historyNodes.at(-1)?.view,
history: historyNodes.map((entry) => entry.view),
};
}
function previousNodeForAction(origin: SceneNodeState, action: OriginAction) {
switch (action.history ?? "push") {
case "push":
return origin.key;
case "replace":
// Skip the origin in the retained chain. The old entry remains mounted
// until commit so an interactive replacement can still be cancelled.
return origin.previousNodeKey;
case "back":
return origin.previousNodeKey;
}
}
function operationContext(
operation: MutableOriginOperation,
): OriginChoreographyContext {
return {
progress: operation.progress,
velocity: operation.velocity,
phase: operation.phase,
intent: operation.intent,
originRect: operation.originRect,
targetRect: operation.targetRect,
viewport: defaultViewport(container),
};
}
function effectSetFor(operation: MutableOriginOperation): OriginEffectSet {
return operation.choreography.effects(operationContext(operation));
}
/**
* Return the origin-frame effects inherited by a node. The source effect of
* an ancestor is intentionally excluded: descendants inherit the target side
* of an operation, while only the initiating component receives its source
* side.
*/
function inheritedEffects(
nodeKey: string,
visited = new Set<string>(),
): OriginEffect[] {
if (visited.has(nodeKey)) return [];
visited.add(nodeKey);
const node = nodes.get(nodeKey);
const incoming = node?.incomingOperationId
? operations.get(node.incomingOperationId)
: undefined;
if (!node || !incoming) return [];
const effects = effectSetFor(incoming);
const targetLayer = incoming.placement === "above" ? 1 : -1;
return [
...inheritedEffects(incoming.originKey, visited),
normalizedEffect(effects.frame),
normalizedEffect(effects.target, targetLayer),
];
}
function visualEffects(nodeKey: string) {
const result = inheritedEffects(nodeKey);
const outgoing = visualOutgoingFor(nodeKey);
if (!outgoing) return result;
const effects = effectSetFor(outgoing);
return [
...result,
normalizedEffect(effects.frame),
normalizedEffect(effects.source),
];
}
function visualOutgoingFor(nodeKey: string) {
return [...operations.values()].find(
(operation) => operation.originKey === nodeKey,
);
}
/**
* Collapse independent effect layers into one host style. Geometry remains
* composable; arbitrary CSS properties use normal local-last precedence.
*/
function styleForNode(nodeKey: string): CSSProperties {
const node = nodes.get(nodeKey);
if (!node) return {};
const transforms: string[] = [];
let opacity = 1;
let layer = 0;
/*
* These rules are compositor invariants rather than visual theming. Keep
* them inline so a missing optional package stylesheet can never place
* scene nodes back into normal block/flex flow and vertically stack views.
*/
const style: CSSProperties = {
position: "absolute",
inset: "0",
width: "100%",
height: "100%",
overflow: "hidden",
};
for (const effect of visualEffects(nodeKey)) {
if (effect.transform && effect.transform !== "none")
transforms.push(effect.transform);
if (effect.opacity !== undefined) opacity *= effect.opacity;
layer += effect.layer ?? 0;
if (effect.style) {
const effectStyle = effect.style;
Object.assign(style, effectStyle);
if (
typeof effectStyle.transform === "string" &&
effectStyle.transform !== "none"
)
transforms.push(effectStyle.transform);
if (effectStyle.opacity !== undefined) {
const numericOpacity = Number(effectStyle.opacity);
if (Number.isFinite(numericOpacity)) opacity *= numericOpacity;
}
}
}
// Explicitly assign the composited properties after Object.assign so an
// individual effect cannot accidentally replace inherited transform work.
style.transform = transforms.length ? transforms.join(" ") : "none";
style.opacity = String(clamp(opacity));
style.zIndex = 1_000_000 + layer * 10_000 + node.sequence;
style.pointerEvents = isNodeInteractive(nodeKey) ? "auto" : "none";
if (node.state === "parked") {
// Parked entries remain mounted in their original flat hosts. Keeping
// the DOM preserves local component state and nested scroll positions,
// while visibility/inert handling removes them from presentation.
style.visibility = "hidden";
style.contentVisibility = "hidden";
}
return style;
}
function outgoingFor(nodeKey: string) {
return [...operations.values()].find(
(operation) =>
operation.originKey === nodeKey && operation.phase !== "finished",
);
}
function isNodeInteractive(nodeKey: string) {
const node = nodes.get(nodeKey);
if (!node || node.state === "parked" || node.state === "exposed")
return false;
// A target being cancelled is already scheduled to disappear. A source
// settling toward commit has ceded new interactions to the retained scene
// beneath/above it. This is node-local fate, not a global "active view".
const incoming = node.incomingOperationId
? operations.get(node.incomingOperationId)
: undefined;
if (incoming?.intent === "cancel") return false;
return outgoingFor(nodeKey)?.intent !== "commit";
}
function updateOperation(id: number, progress: number, velocity = 0) {
const operation = operations.get(id);
if (!operation || operation.phase === "finished") return;
operation.progress = clamp(progress);
operation.velocity = velocity;
if (operation.phase === "preparing") return;
operation.phase = "interactive";
}
function removeNode(nodeKey: string) {
elements.delete(nodeKey);
nodes.delete(nodeKey);
roots.value = roots.value.filter((key) => key !== nodeKey);
}
/**
* Remove a newly created forward branch while leaving the origin and all
* earlier retained entries untouched.
*/
function removeRetainedBranch(nodeKey: string, visited = new Set<string>()) {
if (visited.has(nodeKey)) return;
visited.add(nodeKey);
for (const node of [...nodes.values()]) {
if (node.previousNodeKey === nodeKey)
removeRetainedBranch(node.key, visited);
}
for (const operation of [...operations.values()]) {
if (
operation.originKey === nodeKey ||
operation.targetKey === nodeKey ||
operation.entryTargetKey === nodeKey
)
operations.delete(operation.id);
}
removeNode(nodeKey);
}
function refreshVisibleState(node: SceneNodeState) {
if (node.state === "parked" || node.state === "exposed") return;
const incoming = node.incomingOperationId
? operations.get(node.incomingOperationId)
: undefined;
node.state =
(incoming && incoming.phase !== "finished") || outgoingFor(node.key)
? "transitioning"
: "active";
}
/**
* Transfer an operation's visual graph position to its target without moving
* either Vue VNode. The history chain is intentionally independent from this
* temporary coordinate graph.
*/
function spliceVisualTarget(origin: SceneNodeState, target: SceneNodeState) {
const parentOperation = origin.incomingOperationId
? operations.get(origin.incomingOperationId)
: undefined;
if (parentOperation) {
parentOperation.targetKey = target.key;
target.incomingOperationId = parentOperation.id;
} else {
roots.value = roots.value.map((key) =>
key === origin.key ? target.key : key,
);
target.incomingOperationId = undefined;
}
}
/**
* Finalize a committed operation.
*
* A normal push parks its origin and collapses the temporary visual edge.
* A connected push keeps the edge at progress 1 and leaves the origin
* exposed but inert. Replace and back remove the origin.
*/
function commitOperation(id: number) {
const operation = operations.get(id);
if (!operation) return false;
const origin = nodes.get(operation.originKey);
const target = nodes.get(operation.targetKey);
if (!origin || !target) return false;
if (operation.history === "push" && operation.choreography.persistAtRest) {
operation.progress = 1;
operation.velocity = 0;
operation.intent = "commit";
operation.phase = "finished";
origin.state = "exposed";
target.state = "active";
refreshVisibleState(target);
return true;
}
spliceVisualTarget(origin, target);
operation.phase = "finished";
operations.delete(operation.id);
suspendedPresentations.delete(operation.id);
if (operation.history === "push") {
origin.incomingOperationId = undefined;
origin.state = "parked";
} else {
// Back pops the current entry; replace discards it in favor of the new
// target. Both preserve every earlier retained instance.
removeNode(origin.key);
}
target.state = "active";
refreshVisibleState(target);
return true;
}
function cancelOperation(id: number) {
const operation = operations.get(id);
if (!operation) return;
const origin = nodes.get(operation.originKey);
const target = nodes.get(operation.entryTargetKey);
const suspendedPresentation = suspendedPresentations.get(id);
operation.phase = "finished";
operations.delete(id);
suspendedPresentations.delete(id);
if (operation.history === "back") {
if (suspendedPresentation && origin && target) {
/*
* Put the retained page back into its former parent/root position, then
* reconnect the settled page→drawer edge. Both nodes keep their VNodes.
*/
spliceVisualTarget(origin, target);
operations.set(suspendedPresentation.id, suspendedPresentation);
origin.incomingOperationId = suspendedPresentation.id;
target.state = "exposed";
} else if (target) {
target.incomingOperationId = undefined;
target.state = "parked";
}
} else {
removeRetainedBranch(operation.entryTargetKey);
}
if (origin) {
origin.state = "active";
refreshVisibleState(origin);
}
}
async function settle(id: number, targetProgress: 0 | 1, animate: boolean) {
const operation = operations.get(id);
if (!operation) return;
if (!animate || prefersReducedMotion()) {
operation.progress = targetProgress;
operation.velocity = 0;
return;
}
await new Promise<void>((resolve) => {
let position = operation.progress;
let velocity = Math.max(-8, Math.min(8, operation.velocity));
let previous = performance.now();
const frame = (time: number) => {
const live = operations.get(id);
if (!live) return resolve();
// A damped spring makes release velocity continuous with pointer motion
// without imposing a fixed-duration easing on custom choreographies.
const elapsed = Math.min(
0.032,
Math.max(0.001, (time - previous) / 1000),
);
previous = time;
const iterations = Math.max(1, Math.ceil(elapsed / (1 / 120)));
const dt = elapsed / iterations;
for (let index = 0; index < iterations; index += 1) {
const acceleration =
(targetProgress - position) * 280 - velocity * 30;
velocity += acceleration * dt;
position += velocity * dt;
}
const done =
Math.abs(targetProgress - position) < 0.002 &&
Math.abs(velocity) < 0.02;
live.progress = done ? targetProgress : clamp(position);
live.velocity = done ? 0 : velocity;
if (done) resolve();
else window.requestAnimationFrame(frame);
};
window.requestAnimationFrame(frame);
});
}
async function finishOperation(
id: number,
options: OriginFinishOptions = {},
) {
const operation = operations.get(id);
if (!operation) return false;
if (operation.phase === "finished") return operation.intent === "commit";
const threshold = operation.choreography.commitThreshold ?? 0.36;
// const velocityThreshold = operation.choreography.commitVelocity ?? 0.9;
const shouldCommit =
options.commit ??
(operation.progress >= threshold ||
(operation.progress >= 0.0 && operation.velocity >= 0.3));
// operation.velocity >= velocityThreshold));
// The outcome is known synchronously at release. The target can therefore
// originate another gesture while this operation is only visually settling.
operation.intent = shouldCommit ? "commit" : "cancel";
operation.phase = "settling";
await settle(id, shouldCommit ? 1 : 0, options.animate ?? true);
if (!operations.has(id)) return shouldCommit;
if (shouldCommit) return commitOperation(id);
cancelOperation(id);
return false;
}
async function begin(
originKey: string,
action: OriginAction,
): Promise<OriginOperationHandle> {
const origin = nodes.get(originKey);
if (!origin)
throw new Error(`Cannot animate from missing origin "${originKey}".`);
if (origin.state === "parked" || origin.state === "exposed")
throw new Error(
`Cannot animate from ${origin.state} origin "${originKey}".`,
);
if (outgoingFor(originKey))
throw new Error(
`Origin "${originKey}" already has an outgoing operation. ` +
"A descendant target may start its own operation instead.",
);
const history = action.history ?? "push";
if (action.choreography.persistAtRest && history !== "push")
throw new Error(
"persistAtRest choreographies require retained-history push navigation.",
);
let target: SceneNodeState;
let suspendedPresentation: MutableOriginOperation | undefined;
if (history === "back") {
const previousKey = origin.previousNodeKey;
if (!previousKey)
throw new Error(
`Cannot go back from origin "${originKey}" without a previous entry.`,
);
const retainedTarget = nodes.get(previousKey);
if (!retainedTarget)
throw new Error(
`Cannot go back to missing retained entry "${previousKey}".`,
);
/*
* If back starts while the immediately preceding forward spring is still
* settling, collapse that already-committed edge first. Creating a new
* edge back to its origin would otherwise form X→Y→X. Reciprocal
* choreographies begin at the same visual endpoints, so this handoff is
* continuous without remounting either node.
*/
const incoming = origin.incomingOperationId
? operations.get(origin.incomingOperationId)
: undefined;
if (incoming?.originKey === retainedTarget.key) {
if (incoming.intent !== "commit")
throw new Error(
"Cannot go back through an undecided forward operation.",
);
if (incoming.phase !== "finished") commitOperation(incoming.id);
const connected = operations.get(incoming.id);
if (
connected?.phase === "finished" &&
connected.choreography.persistAtRest
) {
/*
* Re-root A→page→drawer as A→drawer before adding drawer→page.
* The reverse choreography starts at the same visual endpoints.
*/
operations.delete(connected.id);
spliceVisualTarget(retainedTarget, origin);
retainedTarget.incomingOperationId = undefined;
suspendedPresentation = connected;
}
}
if (outgoingFor(retainedTarget.key))
throw new Error(
`Retained target "${retainedTarget.key}" already has an outgoing operation.`,
);
if (retainedTarget.incomingOperationId)
throw new Error(
`Retained target "${retainedTarget.key}" is already transitioning.`,
);
target = retainedTarget;
} else {
if (!action.target)
throw new Error(
`A "${history}" operation requires a target view recipe.`,
);
target = addNode(action.target, previousNodeForAction(origin, action));
}
const operationId = ++operationSequence;
target.incomingOperationId = operationId;
target.state = "transitioning";
origin.state = "transitioning";
const operation = reactive<MutableOriginOperation>({
id: operationId,
originKey,
targetKey: target.key,
entryTargetKey: target.key,
choreography: markRaw(action.choreography),
placement: action.placement ?? "above",
history,
progress: 0,
velocity: 0,
phase: "preparing",
intent: "undecided",
originRect: elementRect(elements.get(originKey)),
});
operations.set(operation.id, operation);
if (suspendedPresentation)
suspendedPresentations.set(operation.id, suspendedPresentation);
// Let Vue mount the target before measuring it. Gesture composables buffer
// pointer progress while this short preparation step is pending.
await nextTick();
operation.targetRect = elementRect(elements.get(target.key));
operation.phase = "interactive";
return {
id: operation.id,
originKey,
targetKey: target.key,
update: (progress, velocity) =>
updateOperation(operation.id, progress, velocity),
finish: (finishOptions) => finishOperation(operation.id, finishOptions),
cancel: async (cancelOptions) => {
await finishOperation(operation.id, {
commit: false,
animate: cancelOptions?.animate,
});
},
};
}
async function perform(originKey: string, action: OriginAction) {
const operation = await begin(originKey, action);
return operation.finish({ commit: true });
}
return {
nodes: computed(
() =>
[...nodes.values()].map((node) => ({
...node,
history: historyNodesFor(node).map((entry) => entry.view),
})) as readonly OriginSceneNode[],
),
operations: computed(
() =>
[...operations.values()].filter(
(operation) => operation.phase !== "finished",
) as readonly OriginOperation[],
),
roots,
contextFor,
begin,
perform,
registerElement(nodeKey, element) {
if (element) elements.set(nodeKey, element);
else elements.delete(nodeKey);
},
registerContainer(element) {
container = element;
},
styleForNode,
isNodeInteractive,
};
}
+148
View File
@@ -0,0 +1,148 @@
import type { CSSProperties } from "vue";
import { composeEffects, normalizedEffect } from "../choreography/compose";
import type { OriginEffect, OriginEffectSet } from "../choreography/types";
import type { OriginRect } from "../shared/geometry";
import type { SceneGraph, SceneNodeRecord } from "./graph";
import type { MutableOriginOperation } from "./types";
/*
* Presentation of scene nodes: how operation edges compose into each node
* host's inline style, and which nodes accept input. Read-only over the graph.
*/
/**
* 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;
function effectSetFor(
operation: MutableOriginOperation,
viewport: OriginRect,
): OriginEffectSet {
return operation.choreography.effects({
progress: operation.progress,
velocity: operation.velocity,
phase: operation.phase,
intent: operation.intent,
originRect: operation.originRect,
targetRect: operation.targetRect,
viewport,
});
}
/** Incoming edges positioning `node`, from the node outward to its root. */
function incomingChain(graph: SceneGraph, node: SceneNodeRecord) {
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 = graph.incomingOf(current);
if (!incoming) break;
chain.push(incoming);
current = graph.node(incoming.originKey);
}
return chain;
}
/**
* 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.
*/
function effectLayers(
graph: SceneGraph,
node: SceneNodeRecord,
viewport: () => OriginRect,
): OriginEffect[] {
const chain = incomingChain(graph, node);
// Evaluate choreographies node-first, then apply them outermost-first.
const effectSets = chain.map((operation) =>
effectSetFor(operation, viewport()),
);
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 = effectSetFor(outgoing, viewport());
layers.push(
normalizedEffect(effects.frame),
normalizedEffect(effects.source),
);
}
return layers;
}
/**
* 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".
*/
export function isNodeInteractive(graph: SceneGraph, nodeKey: string): boolean {
const node = graph.node(nodeKey);
if (!node || node.state === "parked" || node.state === "exposed")
return false;
if (graph.incomingOf(node)?.intent === "cancel") return false;
return graph.liveOutgoingOf(nodeKey)?.intent !== "commit";
}
/**
* Collapse every effect layer applied to a node into its host's inline style.
*
* @param viewport - Measures the scene bounds supplied to choreographies.
*/
export function styleForNode(
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(
effectLayers(graph, node, lazyViewport),
NODE_BASE_STYLE,
);
style.zIndex = Z_INDEX_BASE + layer * Z_INDEX_PER_LAYER + node.sequence;
style.pointerEvents = isNodeInteractive(graph, 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;
}
@@ -0,0 +1,174 @@
import { computed, nextTick } from "vue";
import type { OriginAction } from "../actions/types";
import { prefersReducedMotion } from "../shared/environment";
import { measureRect, windowRect, type OriginRect } from "../shared/geometry";
import * as compositor from "./compositor";
import { SceneGraph } from "./graph";
import { runSpring } from "./spring";
import {
beginTransition,
cancelTransition,
commitTransition,
shouldCommit,
updateTransition,
} from "./transitions";
import type {
CreateOriginSceneOptions,
OriginFinishOptions,
OriginOperation,
OriginOperationHandle,
OriginScene,
OriginSceneNode,
} from "./types";
/** DOM elements registered by the renderer, used only for measurement. */
class SceneElements {
private readonly hosts = new Map<string, HTMLElement>();
private container: HTMLElement | null = null;
registerHost(nodeKey: string, element: HTMLElement | null) {
if (element) this.hosts.set(nodeKey, element);
else this.hosts.delete(nodeKey);
}
registerContainer(element: HTMLElement | null) {
this.container = element;
}
rectOf(nodeKey: string): OriginRect | undefined {
const host = this.hosts.get(nodeKey);
return host ? measureRect(host) : undefined;
}
/** Scene-container bounds, or the browser window before mounting. */
viewport = (): OriginRect =>
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 {
const graph = new SceneGraph(
Array.isArray(options.initial) ? options.initial : [options.initial],
);
const elements = new SceneElements();
/** Move an operation to 0 or 1, animated unless motion is disabled. */
async function settle(id: number, target: 0 | 1, animate: boolean) {
const operation = 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 = graph.operation(id);
if (!live) return false;
live.progress = progress;
live.velocity = velocity;
return true;
});
}
async function finish(id: number, options: OriginFinishOptions = {}) {
const operation = 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 settle(id, commit ? 1 : 0, options.animate ?? true);
// A later operation may already have finalized this edge.
if (!graph.operation(id)) return commit;
if (commit) return commitTransition(graph, id);
cancelTransition(graph, id);
return false;
}
async function begin(
originKey: string,
action: OriginAction,
): Promise<OriginOperationHandle> {
const operation = beginTransition(
graph,
originKey,
action,
elements.rectOf(originKey),
);
const { id, 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 = elements.rectOf(targetKey);
operation.phase = "interactive";
return {
id,
originKey,
targetKey,
update: (progress, velocity = 0) =>
updateTransition(graph, id, progress, velocity),
finish: (finishOptions) => finish(id, finishOptions),
cancel: async (cancelOptions) => {
await finish(id, { commit: false, animate: cancelOptions?.animate });
},
};
}
async function perform(originKey: string, action: OriginAction) {
const handle = await begin(originKey, action);
return handle.finish({ commit: true });
}
return {
nodes: computed(
() =>
[...graph.nodes.values()].map((node) => ({
...node,
history: graph.historyOf(node).map((entry) => entry.view),
})) as readonly OriginSceneNode[],
),
operations: computed(
() =>
[...graph.operations.values()].filter(
(operation) => operation.phase !== "finished",
) as readonly OriginOperation[],
),
roots: graph.roots,
contextFor: (nodeKey) => graph.contextFor(nodeKey),
begin,
perform,
registerElement: (nodeKey, element) =>
elements.registerHost(nodeKey, element),
registerContainer: (element) => elements.registerContainer(element),
styleForNode: (nodeKey) =>
compositor.styleForNode(graph, nodeKey, elements.viewport),
isNodeInteractive: (nodeKey) =>
compositor.isNodeInteractive(graph, nodeKey),
};
}
+203
View File
@@ -0,0 +1,203 @@
import { markRaw, reactive, shallowReactive, shallowRef } from "vue";
import type { OriginView } from "../view/types";
import type {
MutableOriginOperation,
OriginContext,
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;
}
/** Fields supplied when a new operation edge is created. */
export type OperationInit = Omit<
MutableOriginOperation,
"id" | "progress" | "velocity" | "phase" | "intent" | "targetRect"
>;
/**
* Reactive storage for a scene's nodes and operation edges.
*
* 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. This class owns structural primitives only;
* navigation semantics live in `transitions.ts`.
*/
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.
*/
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;
}
/** 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,
};
}
// Mutations ---------------------------------------------------------------
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;
}
createOperation(init: OperationInit): 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;
}
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.
*/
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.
*/
transferPosition(from: SceneNodeRecord, target: SceneNodeRecord): void {
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;
}
}
}
+90
View File
@@ -0,0 +1,90 @@
import { clamp, clamp01 } from "../shared/geometry";
/*
* 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 function 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 };
}
/** Receives each frame's progress; returns `false` to stop the spring. */
export type SpringFrame = (progress: number, velocity: number) => boolean;
/**
* Run a spring on `requestAnimationFrame` until it rests at `target` or
* `onFrame` reports that its subject no longer exists.
*
* Every reported progress is clamped to `0..1`; the final frame reports
* exactly `target` with zero velocity.
*/
export function runSpring(
from: SpringState,
target: number,
onFrame: SpringFrame,
): Promise<void> {
return 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);
});
}
+278
View File
@@ -0,0 +1,278 @@
import type { OriginAction, OriginHistoryMode } from "../actions/types";
import { clamp01, type OriginRect } from "../shared/geometry";
import type { SceneGraph, SceneNodeRecord } from "./graph";
import type { MutableOriginOperation, OriginFinishOptions } from "./types";
/*
* Navigation semantics: how operations are created, and how the history and
* visual graph are rewritten when they commit or cancel. Every function here
* is synchronous; timing (mounting, measuring, settling) belongs to the scene.
*/
/**
* 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;
/**
* Recompute whether a presented node is participating in a live operation.
* Parked and exposed nodes keep their retained state.
*/
function refreshPresentedState(graph: SceneGraph, node: SceneNodeRecord) {
if (node.state === "parked" || node.state === "exposed") return;
const incoming = graph.incomingOf(node);
node.state =
(incoming && incoming.phase !== "finished") ||
graph.liveOutgoingOf(node.key)
? "transitioning"
: "active";
}
function activate(graph: SceneGraph, node: SceneNodeRecord) {
node.state = "active";
refreshPresentedState(graph, node);
}
function assertCanOriginate(graph: SceneGraph, originKey: string) {
const origin = graph.node(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 (graph.liveOutgoingOf(originKey))
throw new Error(
`Origin "${originKey}" already has an outgoing operation. ` +
"A descendant target may start its own operation instead.",
);
return origin;
}
/** A retained back target plus a resting edge suspended to reach it. */
interface BackTarget {
readonly target: SceneNodeRecord;
readonly suspendedEdge?: MutableOriginOperation;
}
/**
* 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.
*/
function resolveBackTarget(
graph: SceneGraph,
origin: SceneNodeRecord,
): BackTarget {
const previousKey = origin.previousNodeKey;
if (!previousKey)
throw new Error(
`Cannot go back from origin "${origin.key}" without a previous entry.`,
);
const target = graph.node(previousKey);
if (!target)
throw new Error(
`Cannot go back to missing retained entry "${previousKey}".`,
);
let suspendedEdge: MutableOriginOperation | undefined;
const incoming = graph.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") commitTransition(graph, incoming.id);
const resting = graph.operation(incoming.id);
if (resting?.phase === "finished" && resting.choreography.persistAtRest) {
// Re-root A→page→drawer as A→drawer before adding drawer→page.
graph.operations.delete(resting.id);
graph.transferPosition(target, origin);
target.incomingOperationId = undefined;
suspendedEdge = resting;
}
}
if (graph.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. */
function createForwardTarget(
graph: SceneGraph,
origin: SceneNodeRecord,
action: OriginAction,
history: Exclude<OriginHistoryMode, "back">,
) {
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 graph.createNode(action.target, previousKey);
}
/**
* Validate an action and add its operation edge (and, for push/replace, its
* target node) to the graph. The edge starts in the `preparing` phase.
*
* @throws If the origin cannot originate, or the action cannot be resolved.
*/
export function beginTransition(
graph: SceneGraph,
originKey: string,
action: OriginAction,
originRect: OriginRect | undefined,
): MutableOriginOperation {
const origin = assertCanOriginate(graph, 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"
? resolveBackTarget(graph, origin)
: { target: createForwardTarget(graph, origin, action, history) };
const operation = graph.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) graph.suspendedEdges.set(operation.id, suspendedEdge);
return operation;
}
/** Apply interactive progress to an unfinished operation. */
export function updateTransition(
graph: SceneGraph,
id: number,
progress: number,
velocity: number,
): void {
const operation = graph.operation(id);
if (!operation || operation.phase === "finished") return;
operation.progress = clamp01(progress);
operation.velocity = velocity;
if (operation.phase !== "preparing") operation.phase = "interactive";
}
/** Decide a release outcome from explicit options or progress/velocity. */
export function 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)
);
}
/**
* 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.
*/
export function commitTransition(graph: SceneGraph, id: number): boolean {
const operation = graph.operation(id);
if (!operation) return false;
const origin = graph.node(operation.originKey);
const target = graph.node(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";
activate(graph, target);
return true;
}
graph.transferPosition(origin, target);
operation.phase = "finished";
graph.operations.delete(operation.id);
graph.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.
graph.removeNode(origin.key);
}
activate(graph, 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.
*/
export function cancelTransition(graph: SceneGraph, id: number): void {
const operation = graph.operation(id);
if (!operation) return;
const origin = graph.node(operation.originKey);
const target = graph.node(operation.entryTargetKey);
const suspendedEdge = graph.suspendedEdges.get(id);
operation.phase = "finished";
graph.operations.delete(id);
graph.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.
graph.transferPosition(origin, target);
graph.operations.set(suspendedEdge.id, suspendedEdge);
origin.incomingOperationId = suspendedEdge.id;
target.state = "exposed";
} else if (target) {
target.incomingOperationId = undefined;
target.state = "parked";
}
} else {
graph.removeBranch(operation.entryTargetKey);
}
if (origin) activate(graph, origin);
}
+220
View File
@@ -0,0 +1,220 @@
import type { ComputedRef, CSSProperties, ShallowRef } from "vue";
import type {
OriginAction,
OriginHistoryMode,
OriginPlacement,
} from "../actions/types";
import type {
OriginChoreography,
OriginOperationIntent,
OriginOperationPhase,
} from "../choreography/types";
import type { OriginRect } from "../shared/geometry";
import type { OriginView } from "../view/types";
/**
* 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;
/**
* 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;
}
@@ -0,0 +1,13 @@
/**
* 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)
);
}
+38
View File
@@ -0,0 +1,38 @@
/** 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,
};
}
-831
View File
@@ -1,831 +0,0 @@
import type { Component, ComputedRef, CSSProperties, ShallowRef } from "vue";
export type PropsOf<T> =
T extends abstract new (...args: any[]) => { $props: infer P }
? P
: T extends (props: infer P, ...args: any[]) => any
? P
: never;
/**
* A view is a recipe for creating a Vue component, not a mounted instance.
*
* Forward navigation uses the recipe to create a mounted history entry. That
* instance remains mounted until a committed back operation pops it.
*
* @typeParam Props - The props accepted by the component recipe.
*/
export interface OriginView<
T extends Component = Component,
// Props extends Record<string, unknown> = Record<string, unknown>,
> {
/** The Vue component definition that will be mounted for this recipe. */
readonly component: T;
/** Props passed to the component when the recipe is mounted. */
readonly props?: Readonly<PropsOf<T>>;
/**
* A developer-facing recipe identity used in diagnostics and as the prefix
* of generated scene-node keys. It does not make Vue reuse an instance.
*/
readonly key?: string;
/** A human-readable label used by diagnostics and DOM data attributes. */
readonly name?: string;
}
/**
* The lifecycle phase of an operation edge.
*
* - `preparing`: the target has been added and Vue is mounting it.
* - `interactive`: progress may be controlled by a gesture or application.
* - `settling`: the commit/cancel decision is fixed and the spring is running.
* - `finished`: the graph rewrite or cancellation cleanup has completed.
*/
export type OriginOperationPhase =
"preparing" | "interactive" | "settling" | "finished";
/**
* The outcome selected for an operation.
*
* Intent remains `undecided` until `finish()` is called. It becomes final
* before the settling animation completes, allowing the retained target to
* originate its own operation immediately.
*/
export type OriginOperationIntent = "undecided" | "commit" | "cancel";
/**
* Controls how an operation participates in retained instance history.
*
* - `push`: create a target whose previous entry is the mounted origin.
* - `replace`: create a target that inherits the origin's previous entry, then
* remove the origin on commit.
* - `back`: reuse the mounted previous entry and pop the origin on commit.
*/
export type OriginHistoryMode = "push" | "replace" | "back";
/**
* The target's stacking relationship to its origin while an operation exists.
*/
export type OriginPlacement = "above" | "under";
/**
* Visibility/lifecycle role of a mounted scene node.
*
* - `active`: currently exposed for normal interaction.
* - `transitioning`: participating in at least one live operation edge.
* - `exposed`: retained and visually presented behind/beside the active entry,
* but inert.
* - `parked`: retained in history but visually hidden and inert.
*/
export type OriginSceneNodeState =
"active" | "transitioning" | "exposed" | "parked";
/** A rectangle measured in viewport CSS pixels. */
export interface OriginRect {
/** Distance from the viewport's top edge in CSS pixels. */
top: number;
/** Distance from the viewport's left edge in CSS pixels. */
left: number;
/** Rectangle width in CSS pixels. */
width: number;
/** Rectangle height in CSS pixels. */
height: number;
}
/**
* One composable contribution to a scene node's final visual style.
*
* Transforms are concatenated in origin-to-descendant order and opacity values
* are multiplied. This lets X→Y and Y→Z affect Y simultaneously without either
* routine replacing the other routine's CSS transform.
*/
export interface OriginEffect {
/**
* A CSS transform contribution. Transform strings from inherited and local
* origin frames are concatenated rather than replacing one another.
*/
transform?: string;
/**
* An opacity contribution between `0` and `1`. Contributions from multiple
* frames are multiplied and the final value is clamped.
*/
opacity?: number;
/**
* Relative stacking contribution. `above()` defaults the target to +1 and
* `under()` defaults it to -1.
*/
layer?: number;
/**
* Escape hatch for non-geometric effects such as filter, clipPath, or
* borderRadius. Later/local effects override inherited properties.
*/
style?: CSSProperties;
}
/**
* Independent visual contributions calculated for one operation edge.
*
* Any omitted contribution is treated as an identity effect.
*/
export interface OriginEffectSet {
/**
* Applied to both source and target. This is useful for moving an entire
* origin-relative coordinate frame.
*/
frame?: OriginEffect;
/** Applied to the component that initiated this operation. */
source?: OriginEffect;
/** Applied to the component created by this operation and its descendants. */
target?: OriginEffect;
}
/** Values supplied whenever a choreography calculates its visual effects. */
export interface OriginChoreographyContext {
/** Normalized operation progress, clamped to the inclusive range `0..1`. */
progress: number;
/**
* Normalized progress units per second. Positive velocity moves toward
* commit; negative velocity moves back toward cancellation.
*/
velocity: number;
/** Current lifecycle phase of the operation. */
phase: OriginOperationPhase;
/** Commit/cancel outcome, if release has already selected one. */
intent: OriginOperationIntent;
/** Origin host bounds captured immediately before the target mounts. */
originRect?: OriginRect;
/** Target host bounds measured after Vue mounts the target. */
targetRect?: OriginRect;
/** Scene-container bounds, or the browser viewport when no container exists. */
viewport: OriginRect;
}
/**
* A choreography describes visual relationships only. Component creation,
* history, and cleanup are performed by the scene operation that uses it.
*/
export interface OriginChoreography {
/** Optional diagnostic name surfaced by scene inspectors and devtools. */
readonly name?: string;
/**
* Calculate source, target, and shared-frame contributions for the current
* operation state. This function should be deterministic and side-effect
* free because it can run many times per animation frame.
*/
effects(context: OriginChoreographyContext): OriginEffectSet;
/**
* Gesture progress required to retain the target after release.
*
* @defaultValue `0.36`
*/
readonly commitThreshold?: number;
/**
* Normalized positive release velocity that can commit a deliberate flick
* once progress has reached at least `0.06`.
*
* @defaultValue `0.9`
*/
readonly commitVelocity?: number;
/**
* Keep this choreography's progress-`1` effects connected after a committed
* push instead of collapsing the edge and hiding its source.
*
* This is intended for partial presentations such as drawers and inspectors
* where part of the retained source remains visible beside the target. The
* source remains mounted, visible, and inert. A back operation temporarily
* suspends the resting edge; cancelling back restores it.
*
* Only retained-history `push` actions support connected resting effects.
*
* @defaultValue `false`
*/
readonly persistAtRest?: boolean;
}
/** A complete request to create and animate a target view from an origin. */
export interface OriginAction {
/**
* Recipe for a newly created push or replacement target. A back action omits
* this because the scene resolves its already-mounted previous entry.
*/
readonly target?: OriginView;
/** Visual relationship applied to the source, target, and shared frame. */
readonly choreography: OriginChoreography;
/**
* Target stacking relationship during the operation.
*
* @defaultValue `"above"`
*/
readonly placement?: OriginPlacement;
/**
* History mutation applied to the target recipe.
*
* @defaultValue `"push"`
*/
readonly history?: OriginHistoryMode;
}
/**
* A scene mutation without visual choreography.
*
* Gesture builders keep navigation intent separate from animation so the same
* destination can be paired with different component-local interactions.
* Calling `.animate()` materializes this intent as an {@link OriginAction}.
*/
export interface OriginNavigationIntent {
/**
* Recipe for a newly created push or replacement target. Back navigation
* omits this because the scene resolves the retained previous instance.
*/
readonly target?: OriginView;
/** Target stacking relationship while the gesture operation is visible. */
readonly placement?: OriginPlacement;
/** Retained-history mutation performed if the gesture commits. */
readonly history: OriginHistoryMode;
}
/**
* Read-only diagnostic representation of one mounted scene node.
*
* A node corresponds to one currently mounted Vue component instance.
*/
export interface OriginSceneNode {
/** Unique identity for this particular mounted scene node. */
readonly key: string;
/** Monotonically increasing creation order within the scene. */
readonly sequence: number;
/** Recipe used to create the node's component. */
readonly view: OriginView;
/** Recipes for the retained instance chain preceding this node. */
readonly history: readonly OriginView[];
/** Key of the retained mounted entry immediately before this node. */
readonly previousNodeKey?: string;
/** Current visibility/lifecycle role of this mounted instance. */
readonly state: OriginSceneNodeState;
/**
* Operation edge positioning this node as its target. This may identify a
* connected resting edge that is intentionally absent from live-operation
* diagnostics.
*/
readonly incomingOperationId?: number;
}
/** Read-only diagnostic representation of one live operation edge. */
export interface OriginOperation {
/** Unique, monotonically increasing operation identity within the scene. */
readonly id: number;
/** Key of the mounted component that originated the operation. */
readonly originKey: string;
/** Key of the newly created or retained target component. */
readonly targetKey: string;
/** Original history entry targeted before visual-edge rewrites. */
readonly entryTargetKey: string;
/** Choreography currently calculating this edge's effects. */
readonly choreography: OriginChoreography;
/** Target stacking relationship to the origin. */
readonly placement: OriginPlacement;
/** Retained-history behavior performed if this operation commits. */
readonly history: OriginHistoryMode;
/** Normalized progress in the inclusive range `0..1`. */
readonly progress: number;
/** Latest normalized velocity in progress units per second. */
readonly velocity: number;
/** Current operation lifecycle phase. */
readonly phase: OriginOperationPhase;
/** Selected operation outcome. */
readonly intent: OriginOperationIntent;
}
/**
* Node-local information supplied to component-owned action factories.
*
* There is deliberately no global `current` or `active` view. The component
* handling the event is the origin represented by this context.
*/
export interface OriginContext {
/** Unique key of the mounted node that owns the interaction. */
readonly nodeKey: string;
/** Recipe used to create the origin node. */
readonly view: OriginView;
/** Whether this retained history entry has a mounted previous instance. */
readonly canGoBack: boolean;
/** Recipe belonging to the retained previous instance, when available. */
readonly previous?: OriginView;
/** Recipes belonging to all retained instances preceding this node. */
readonly history: readonly OriginView[];
}
/** Options controlling how a manually managed operation is resolved. */
export interface OriginFinishOptions {
/**
* Override the choreography's progress/velocity decision. Omit it to use the
* choreography's commit thresholds.
*/
commit?: boolean;
/**
* Whether to run the settling spring before finalizing the graph.
*
* @defaultValue `true`
*/
animate?: boolean;
}
/** Imperative controller for one mounted, live operation edge. */
export interface OriginOperationHandle {
/** Identity of the live operation controlled by this handle. */
readonly id: number;
/** Key of the source node that created the operation. */
readonly originKey: string;
/** Key of the newly mounted or retained target node. */
readonly targetKey: string;
/**
* Set interactive progress and optional velocity.
*
* Progress is clamped to `0..1`. Velocity is normalized to progress units
* per second and is used by the choreography's flick threshold.
*/
update(progress: number, velocity?: number): void;
/**
* Select commit/cancel, run the settling spring, and finalize the scene.
*
* @returns `true` when the target was retained, otherwise `false`.
*/
finish(options?: OriginFinishOptions): Promise<boolean>;
/** Cancel the operation and remove its target branch. */
cancel(options?: Pick<OriginFinishOptions, "animate">): Promise<void>;
}
/**
* A self-contained scene graph, view-recipe history, and animation compositor.
*
* Multiple scenes may coexist and do not share nodes, history, or operations.
*/
export interface OriginScene {
/** Reactive snapshot of every currently mounted node. */
readonly nodes: ComputedRef<readonly OriginSceneNode[]>;
/** Reactive snapshot of every live animation/gesture operation. */
readonly operations: ComputedRef<readonly OriginOperation[]>;
/** Keys of visible operation-graph roots; parked history is excluded. */
readonly roots: Readonly<ShallowRef<readonly string[]>>;
/** Return the node-local action context for a mounted node key. */
contextFor(nodeKey: string): OriginContext;
/**
* Create a forward target or reveal a retained back target, then return
* manual control of its operation.
*
* @throws If the origin is missing, parked/exposed and inert, or already owns
* an outgoing operation.
*/
begin(
originKey: string,
action: OriginAction,
): Promise<OriginOperationHandle>;
/**
* Resolve a target and commit it programmatically using the settling spring.
*
* @returns `true` once the target has been committed.
*/
perform(originKey: string, action: OriginAction): Promise<boolean>;
/** @internal Register or unregister a scene node's host element. */
registerElement(nodeKey: string, element: HTMLElement | null): void;
/** @internal Register or unregister the scene container used for measurement. */
registerContainer(element: HTMLElement | null): void;
/** @internal Calculate the fully composited inline style for one node host. */
styleForNode(nodeKey: string): CSSProperties;
/** @internal Determine whether a node should currently receive pointer input. */
isNodeInteractive(nodeKey: string): boolean;
}
/** Node-scoped controls returned by {@link useOrigin}. */
export interface UseOrigin {
/** Key of the mounted node containing the calling component. */
readonly nodeKey: string;
/** Scene containing the calling component. */
readonly scene: OriginScene;
/** Reactive context for the calling component's scene node. */
readonly context: ComputedRef<OriginContext>;
/** Reactive shorthand for `context.value.view`. */
readonly view: ComputedRef<OriginView>;
/** Reactive shorthand for `context.value.previous`. */
readonly previous: ComputedRef<OriginView | undefined>;
/** Reactive shorthand for `context.value.canGoBack`. */
readonly canGoBack: ComputedRef<boolean>;
/** Begin an interactively controlled operation from this component. */
begin(action: OriginAction): Promise<OriginOperationHandle>;
/** Programmatically create and commit a target from this component. */
perform(action: OriginAction): Promise<boolean>;
}
/**
* Values accepted from gesture action factories.
*
* Returning `null` or `undefined` declines the recognized gesture. A promise
* allows lazy target resolution; stale results are discarded after release or
* cancellation.
*/
export type MaybeOriginAction =
OriginAction | null | undefined | Promise<OriginAction | null | undefined>;
/** Direction in which pointer movement advances operation progress. */
export type OriginGestureDirection = "left" | "right" | "up" | "down";
/** Physical side of the gesture host used to admit pointer-down. */
export type OriginGestureEdge = "left" | "right" | "top" | "bottom";
/**
* CSS length accepted by an edge-based gesture start rule.
*
* Numbers are interpreted as CSS pixels. Strings may use normal CSS lengths,
* percentages, `calc()`, or `clamp()`, and are resolved against the gesture
* host when pointer-down occurs.
*/
export type OriginGestureDistance = number | string;
/** A pointer position measured in viewport and gesture-host coordinates. */
export interface OriginGesturePoint {
/** Viewport-relative horizontal position. */
readonly clientX: number;
/** Viewport-relative vertical position. */
readonly clientY: number;
/** Horizontal position relative to the gesture host's left edge. */
readonly localX: number;
/** Vertical position relative to the gesture host's top edge. */
readonly localY: number;
}
/** Context supplied to a custom `.from.when()` start predicate. */
export interface OriginGestureStartContext {
/** Native pointer-down event being considered. */
readonly event: PointerEvent;
/** Component instance from which the gesture would originate. */
readonly origin: OriginContext;
/** Element carrying the gesture's pointer handlers. */
readonly host: HTMLElement;
/** Gesture-host bounds captured at pointer-down. */
readonly bounds: OriginRect;
/** Pointer position at pointer-down. */
readonly point: OriginGesturePoint;
}
/**
* Synchronous predicate deciding whether a pointer-down may become a gesture.
*
* Directional recognition still occurs later, after movement passes the
* configured intent threshold.
*/
export type OriginGestureStartPredicate = (
context: OriginGestureStartContext,
) => boolean;
/** Start policy stored in a completed gesture definition. */
export type OriginGestureStart =
| {
/** Recognize pointer-down anywhere on the host. */
readonly kind: "anywhere";
}
| {
/** Recognize pointer-down within a CSS distance of one host side. */
readonly kind: "edge";
readonly edge: OriginGestureEdge;
readonly distance: OriginGestureDistance;
}
| {
/** Recognize pointer-down when application policy returns `true`. */
readonly kind: "when";
readonly predicate: OriginGestureStartPredicate;
};
/** Direction-recognition tuning accepted by `.to.left()` and its siblings. */
export interface OriginGestureDirectionOptions {
/**
* Minimum directed movement in CSS pixels before the gesture captures.
*
* @defaultValue `8`
*/
readonly threshold?: number;
/**
* Ratio by which directed movement must exceed cross-axis movement.
*
* @defaultValue `1.15`
*/
readonly axisDominance?: number;
}
/** Values available when a custom `.complete()` policy runs on pointer-up. */
export interface OriginGestureCompletionContext {
/** Component instance that originated the gesture. */
readonly origin: OriginContext;
/** Recognized movement direction. */
readonly direction: OriginGestureDirection;
/** Normalized directed distance, clamped to `0..1`. */
readonly progress: number;
/** Latest normalized progress units per second. */
readonly velocity: number;
/** Directed movement from pointer-down in CSS pixels. */
readonly distance: number;
/** Absolute cross-axis movement from pointer-down in CSS pixels. */
readonly crossDistance: number;
/** Elapsed time since pointer-down in milliseconds. */
readonly duration: number;
/** Native pointer-up event that ended the interaction. */
readonly event: PointerEvent;
/** Element carrying the gesture's pointer handlers. */
readonly host: HTMLElement;
/** Gesture-host bounds captured at pointer-down. */
readonly bounds: OriginRect;
/** Pointer position captured at pointer-down. */
readonly start: OriginGesturePoint;
/** Pointer position at release. */
readonly current: OriginGesturePoint;
}
/** Synchronous commit/cancel policy installed by `.complete()`. */
export type OriginGestureCompletionPredicate = (
context: OriginGestureCompletionContext,
) => boolean;
/**
* Values accepted from a gesture builder's `.navigate()` factory.
*
* A promise supports lazy view selection. Stale resolutions are discarded if
* the pointer has already ended or been cancelled.
*/
export type MaybeOriginNavigationIntent =
| OriginNavigationIntent
| null
| undefined
| Promise<OriginNavigationIntent | null | undefined>;
/** Factory that resolves navigation after directional recognition succeeds. */
export type OriginGestureNavigationFactory = (
context: OriginContext,
) => MaybeOriginNavigationIntent;
/**
* Immutable, executable result of a complete gesture builder chain.
*
* Pass this object to {@link useOriginGesture}. An omitted `.from` step is
* represented as an `anywhere` start rule.
*/
export interface OriginGestureDefinition {
/** Discriminator used by the compatibility recognizer overload. */
readonly kind: "origin-gesture-definition";
/** Pointer-down eligibility policy. */
readonly start: OriginGestureStart;
/** Direction in which movement advances operation progress. */
readonly direction: OriginGestureDirection;
/** Directional intent recognition tuning. */
readonly recognition: Readonly<OriginGestureDirectionOptions>;
/**
* Optional release decision. When omitted, choreography thresholds decide.
*/
readonly completion?: OriginGestureCompletionPredicate;
/** Node-local destination/history resolver. */
readonly navigation: OriginGestureNavigationFactory;
/** Visual routine paired with the navigation intent. */
readonly choreography: OriginChoreography;
}
/** Direction-selection stage shared by `gesture.to` and `.from.*().to`. */
export interface OriginGestureToBuilder {
/** Recognize leftward pointer movement. */
left(options?: OriginGestureDirectionOptions): OriginGestureDirectedBuilder;
/** Recognize rightward pointer movement. */
right(options?: OriginGestureDirectionOptions): OriginGestureDirectedBuilder;
/** Recognize upward pointer movement. */
up(options?: OriginGestureDirectionOptions): OriginGestureDirectedBuilder;
/** Recognize downward pointer movement. */
down(options?: OriginGestureDirectionOptions): OriginGestureDirectedBuilder;
}
/** Stage produced after a `.from` policy has been selected. */
export interface OriginGestureFromSelection {
/** Select the direction that advances this gesture. */
readonly to: OriginGestureToBuilder;
}
/** Optional pointer-down policy exposed at the start of a gesture chain. */
export interface OriginGestureFromBuilder {
/** Admit pointer-down within `distance` of the host's left edge. */
left(distance: OriginGestureDistance): OriginGestureFromSelection;
/** Admit pointer-down within `distance` of the host's right edge. */
right(distance: OriginGestureDistance): OriginGestureFromSelection;
/** Admit pointer-down within `distance` of the host's top edge. */
top(distance: OriginGestureDistance): OriginGestureFromSelection;
/** Admit pointer-down within `distance` of the host's bottom edge. */
bottom(distance: OriginGestureDistance): OriginGestureFromSelection;
/** Admit pointer-down anywhere on the gesture host. */
anywhere(): OriginGestureFromSelection;
/** Admit pointer-down when a synchronous application predicate succeeds. */
when(predicate: OriginGestureStartPredicate): OriginGestureFromSelection;
}
/** Builder stage after direction is known and completion remains optional. */
export interface OriginGestureDirectedBuilder {
/** Override the choreography's default release decision. */
complete(
predicate: OriginGestureCompletionPredicate,
): OriginGestureCompletedBuilder;
/** Select the target/history mutation while retaining default completion. */
navigate(
factory: OriginGestureNavigationFactory,
): OriginGestureNavigationBuilder;
}
/** Builder stage after a custom completion policy has been selected. */
export interface OriginGestureCompletedBuilder {
/** Select the target/history mutation performed on commit. */
navigate(
factory: OriginGestureNavigationFactory,
): OriginGestureNavigationBuilder;
}
/** Final builder stage waiting for visual choreography. */
export interface OriginGestureNavigationBuilder {
/** Attach visual choreography and produce an executable definition. */
animate(choreography: OriginChoreography): OriginGestureDefinition;
}
/** Root of the immutable fluent gesture-definition API. */
export interface OriginGestureBuilder {
/** Optionally constrain where pointer-down may begin. */
readonly from: OriginGestureFromBuilder;
/**
* Select movement direction with an implicit `from.anywhere()` start.
*/
readonly to: OriginGestureToBuilder;
}
/** Configuration consumed by {@link useOriginGesture}. */
export interface OriginGestureOptions {
/**
* Direction in which the pointer moves to advance the operation.
*
* The starting edge is the opposite side: `right` begins at the left edge,
* `left` at the right edge, `down` at the top, and `up` at the bottom.
*/
direction: OriginGestureDirection;
/**
* Restrict pointer-down to this many CSS pixels from the gesture host's
* starting edge. Omit it to recognize across the entire host.
*
* This is relative to the bound element, not necessarily the browser
* viewport. Use a positive number such as `24` or `36`.
*/
edge?: number;
/**
* Minimum directed movement in CSS pixels before the gesture captures.
*
* Movement must also dominate the cross-axis by a factor of `1.15`.
*
* @defaultValue `8`
*/
threshold?: number;
/**
* Resolve the scene action after directional recognition succeeds.
*
* Returning no action abandons recognition without modifying the scene.
*/
action(context: OriginContext): MaybeOriginAction;
}
/**
* DOM bindings returned by {@link useOriginGesture}.
*
* Spread or attach all four handlers to the same `HTMLElement`. Apply
* {@link style} as well so native scrolling is preserved on the cross-axis.
*/
export interface OriginGestureBinding {
/** Required size and `touch-action` styles for the gesture host. */
readonly style: Readonly<CSSProperties>;
/** Pointer-down handler that records a potentially eligible gesture. */
readonly onPointerdown: (event: PointerEvent) => void;
/** Pointer-move handler that recognizes and updates the operation. */
readonly onPointermove: (event: PointerEvent) => void;
/** Pointer-up handler that commits or cancels using progress and velocity. */
readonly onPointerup: (event: PointerEvent) => void;
/** Pointer-cancel handler that abandons any captured operation. */
readonly onPointercancel: () => void;
}
/** Props shared by both `OriginGesture` declaration styles. */
export interface OriginGestureBaseProps {
/**
* Native HTML tag rendered as the gesture host.
*
* @defaultValue `"div"`
*/
as?: string;
}
/** Builder-definition props accepted by the `OriginGesture` component. */
export interface OriginGestureDefinitionProps extends OriginGestureBaseProps {
/** Immutable definition produced by the {@link gesture} builder. */
gesture: OriginGestureDefinition;
/** Builder definitions already contain direction. */
direction?: never;
/** Builder definitions already contain their start policy. */
edge?: never;
/** Builder definitions already contain recognition tuning. */
threshold?: never;
/** Builder definitions already contain their navigation factory. */
action?: never;
}
/** Legacy option props accepted by the `OriginGesture` component. */
export interface OriginGestureLegacyProps extends OriginGestureBaseProps {
/** Legacy component declarations do not provide a builder definition. */
gesture?: never;
/** Direction in which pointer movement advances operation progress. */
direction: OriginGestureDirection;
/**
* Eligible pointer-down width in CSS pixels from the host's starting edge.
* Omit it to allow the full component surface.
*/
edge?: number;
/**
* Directed movement required before capture.
*
* @defaultValue `8`
*/
threshold?: number;
/** Node-local action factory invoked only after recognition succeeds. */
action(context: OriginContext): MaybeOriginAction;
}
/**
* Public props accepted by the `OriginGesture` convenience component.
*
* Prefer the builder-definition form. The legacy direction/action form remains
* available for compatibility.
*/
export type OriginGestureProps =
OriginGestureDefinitionProps | OriginGestureLegacyProps;
/** Public props accepted by the multi-definition gesture surface component. */
export interface OriginGestureSurfaceProps {
/**
* Native HTML tag rendered as the shared gesture host.
*
* @defaultValue `"div"`
*/
as?: string;
/**
* Complete immutable definitions installed on the shared host.
*
* The surface adds no start, direction, completion, navigation, or animation
* policy. Definitions should be created by the owning page component.
*/
gestures: readonly OriginGestureDefinition[];
}
/** Public props accepted by the `OriginScene` renderer component. */
export interface OriginSceneProps {
/** Scene instance whose flat component nodes should be rendered. */
scene: OriginScene;
}
/**
* Injection payload provided by each stable scene-node host.
*
* @internal
*/
export interface OriginNodeScope {
/** Scene containing the node. */
readonly scene: OriginScene;
/** Unique key of the mounted node. */
readonly nodeKey: string;
}
/**
* Mutable operation storage used by the scene implementation.
*
* @internal
*/
export interface MutableOriginOperation {
/** Unique operation identity. */
id: number;
/** Origin node key. */
originKey: string;
/** Newly created or retained target node key. */
targetKey: string;
/** Original history entry targeted before visual-edge rewrites. */
entryTargetKey: string;
/** Visual choreography for this edge. */
choreography: OriginChoreography;
/** Target stacking relationship. */
placement: OriginPlacement;
/** Retained-history behavior selected for this operation. */
history: OriginHistoryMode;
/** Normalized progress. */
progress: number;
/** Normalized velocity. */
velocity: number;
/** Current lifecycle phase. */
phase: OriginOperationPhase;
/** Selected outcome. */
intent: OriginOperationIntent;
/** Measured origin bounds, when an element was available. */
originRect?: OriginRect;
/** Measured target bounds, after the target mounted. */
targetRect?: OriginRect;
}
+44
View File
@@ -0,0 +1,44 @@
import { markRaw, type Component } from "vue";
import type { OriginView, OriginViewOptions, PropsOf } from "./types";
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,
});
}
+43
View File
@@ -0,0 +1,43 @@
import type { Component } from "vue";
/** 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;
}
+1
View File
@@ -0,0 +1 @@
.baseline/
+27
View File
@@ -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.
+381
View File
@@ -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);
});
});
+110
View File
@@ -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 }),
);
}
+101
View File
@@ -0,0 +1,101 @@
import { createApp, h, nextTick } from "vue";
import { bench, describe } from "vitest";
import * as next from "../src";
import * as base from "./.baseline";
import { component } from "./harness";
type Impl = typeof next;
const all = { baseline: base as unknown as Impl, rewrite: next };
const impls = process.env.ONLY
? { [process.env.ONLY]: all[process.env.ONLY as keyof typeof all] }
: all;
const motion = next.defineOriginChoreography({
name: "bench",
effects: ({ progress }) => ({
frame: { transform: `translateY(${progress}px)` },
source: {
transform: `translateX(${-progress * 100}px)`,
opacity: 1 - progress,
},
target: {
transform: `translateX(${(1 - progress) * 100}px)`,
style: { filter: "none" },
},
}),
});
/** Long enough for both implementations to reach steady-state JIT. */
const options = { time: 4000, warmupTime: 1500, warmupIterations: 2000 };
const views = Array.from({ length: 12 }, (_, i) =>
next.originView(component(`V${i}`), undefined, { key: `v${i}` }),
);
/** A scene with `depth` simultaneously live, chained operations. */
async function liveChain(M: Impl, depth: number) {
const scene = M.createOriginScene({ initial: views[0]! });
const handles: next.OriginOperationHandle[] = [];
let origin = scene.nodes.value[0]!.key;
for (let i = 1; i <= depth; i += 1) {
const handle = await scene.begin(origin, M.forward(views[i]!, motion));
handle.update(0.5);
handles.push(handle);
origin = handle.targetKey;
}
return { scene, handles };
}
for (const depth of [3, 10]) {
describe(`styleForNode, every node, ${depth} live chained operations`, async () => {
for (const [name, M] of Object.entries(impls)) {
const { scene, handles } = await liveChain(M, depth);
let p = 0;
bench(
name,
() => {
p = (p + 0.013) % 1;
handles[0]!.update(p);
for (const node of scene.nodes.value) scene.styleForNode(node.key);
},
options,
);
}
});
}
describe("push → back cycle (begin + finish, no animation)", () => {
for (const [name, M] of Object.entries(impls)) {
const scene = M.createOriginScene({ initial: views[0]! });
const root = scene.nodes.value[0]!.key;
bench(
name,
async () => {
const push = await scene.begin(root, M.forward(views[1]!, motion));
await push.finish({ commit: true, animate: false });
const pop = await scene.begin(push.targetKey, M.back(motion));
await pop.finish({ commit: true, animate: false });
},
options,
);
}
});
describe("rendered frame: update progress + Vue re-render, 6 live operations", async () => {
for (const [name, M] of Object.entries(impls)) {
const { scene, handles } = await liveChain(M, 6);
const host = document.createElement("div");
document.body.append(host);
createApp({ render: () => h(M.OriginScene, { scene }) }).mount(host);
await nextTick();
let p = 0;
bench(
name,
async () => {
p = (p + 0.013) % 1;
for (const handle of handles) handle.update(p);
await nextTick();
},
options,
);
}
});
+285
View File
@@ -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));
}
});
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env sh
# Extract core-v2 sources from a git ref into ./.baseline for comparison.
set -eu
ref="${1:-main}"
here="$(cd "$(dirname "$0")" && pwd)"
root="$(git -C "$here" rev-parse --show-toplevel)"
tmp="$(mktemp -d)"
git -C "$root" archive "$ref" packages/core-v2/src | tar -x -C "$tmp"
rm -rf "$here/.baseline"
mv "$tmp/packages/core-v2/src" "$here/.baseline"
rm -f "$here/.baseline"/*.test.ts
rm -rf "$tmp"
echo "Baseline extracted from $ref into $here/.baseline"
+17
View File
@@ -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"] },
},
});