Consolidate core-v2 modules and move scene logic onto classes

- Merge the small modules into 12 files: shared, choreography,
  navigation, scene/{types,graph,scene}, gesture/{definition,recognizer}.
- SceneGraph now owns the begin/update/commit/cancel history rules as
  methods, with the step helpers as private methods.
- createOriginScene returns a markRaw'd Scene class; operation handles
  are an OperationHandle class instead of inline object literals.
- GestureRecognizer gains static create()/policyFor() factories, absorbing
  the separate policy module.
- Pure helpers used by classes (matchesStart, pointOf, stepSpring,
  composeNodeStyle, nodeEffectLayers, shouldCommit, ...) are exported
  consts so they can be tested directly.
- Add haptics tests showing where navigator.vibrate can be triggered.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
This commit is contained in:
Claude
2026-10-16 02:49:56 +00:00
parent 329271691c
commit 511e58c198
35 changed files with 2177 additions and 1989 deletions
+14 -14
View File
@@ -294,23 +294,23 @@ component ancestry.
## Source layout ## Source layout
The package is layered; each layer depends only on the ones above it. Each module depends only on the ones listed above it.
| Directory | Responsibility | | File | Responsibility |
| --------------- | ------------------------------------------------------------------------------------------------------- | | ----------------------- | ------------------------------------------------------------------------------------------------- |
| `shared/` | Geometry and environment helpers | | `shared.ts` | Geometry and environment helpers |
| `view/` | `originView()` component recipes | | `choreography.ts` | Effect types, `defineOriginChoreography()`, presets, and `composeEffects()` style compositing |
| `choreography/` | Effect types, `defineOriginChoreography()`, presets, and `composeEffects()` style compositing | | `navigation.ts` | `originView()` recipes and `forward()`, `back()`, `replace()`, `above()`, `under()` actions |
| `actions/` | `forward()`, `back()`, `replace()`, `above()`, `under()`, `originAction()` | | `scene/types.ts` | Public scene types |
| `scene/` | `graph.ts` storage, `transitions.ts` history rules, `compositor.ts` styles, `spring.ts` settling motion | | `scene/graph.ts` | `SceneGraph`: node and edge storage plus the begin/commit/cancel history rules (all synchronous) |
| `composables/` | `useOrigin()` and the node-scope injection key | | `scene/scene.ts` | `Scene` and `OperationHandle`: mounting, measuring, settling spring, compositing, and `useOrigin` |
| `gesture/` | Builder, definition→policy normalization, start admission, pointer tracking, and the recognizer | | `gesture/definition.ts` | Gesture types, component props, and the immutable `gesture` builder |
| `gesture/recognizer.ts` | Pointer-down admission, `DirectionalTracker`, `GestureRecognizer`, and `useOriginGesture` |
| `components/` | `OriginScene`, `OriginGesture`, `OriginGestureSurface`, and the internal node host | | `components/` | `OriginScene`, `OriginGesture`, `OriginGestureSurface`, and the internal node host |
`createOriginScene()` wires the scene modules together: the graph holds nodes Pure helpers used by a class (for example `matchesStart`, `stepSpring`, or
and operation edges, transitions rewrite it synchronously when operations `composeNodeStyle`) are module-level `export const`s that the class calls, so
begin, commit, or cancel, and the scene adds the asynchronous parts (waiting they can be tested directly without widening the class's interface.
for Vue to mount a target, measuring, and running the settling spring).
## Current experimental boundaries ## Current experimental boundaries
-106
View File
@@ -1,106 +0,0 @@
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;
}
+296
View File
@@ -0,0 +1,296 @@
import type { CSSProperties } from "vue";
import { clamp01, type OriginRect } from "./shared";
/*
* Choreographies: the visual side of an operation. A choreography computes
* effects from operation progress; `composeEffects` collapses the effects
* applied to one node into its inline style. Nothing here knows about history.
*/
/**
* The lifecycle phase of an operation edge.
*
* - `preparing`: the target has been added and Vue is mounting it.
* - `interactive`: progress may be controlled by a gesture or application.
* - `settling`: the commit/cancel decision is fixed and the spring is running.
* - `finished`: the graph rewrite or cancellation cleanup has completed.
*/
export type OriginOperationPhase =
"preparing" | "interactive" | "settling" | "finished";
/**
* The outcome selected for an operation.
*
* Intent remains `undecided` until `finish()` is called. It becomes final
* before the settling animation completes, allowing the retained target to
* originate its own operation immediately.
*/
export type OriginOperationIntent = "undecided" | "commit" | "cancel";
/**
* One composable contribution to a scene node's final visual style.
*
* Transforms are concatenated in origin-to-descendant order and opacity values
* are multiplied. This lets X→Y and Y→Z affect Y simultaneously without either
* routine replacing the other routine's CSS transform.
*/
export interface OriginEffect {
/**
* A CSS transform contribution. Transform strings from inherited and local
* origin frames are concatenated rather than replacing one another.
*/
transform?: string;
/**
* An opacity contribution between `0` and `1`. Contributions from multiple
* frames are multiplied and the final value is clamped.
*/
opacity?: number;
/**
* Relative stacking contribution. `above()` defaults the target to +1 and
* `under()` defaults it to -1.
*/
layer?: number;
/**
* Escape hatch for non-geometric effects such as filter, clipPath, or
* borderRadius. Later/local effects override inherited properties.
*/
style?: CSSProperties;
}
/**
* Independent visual contributions calculated for one operation edge.
*
* Any omitted contribution is treated as an identity effect.
*/
export interface OriginEffectSet {
/**
* Applied to both source and target. This is useful for moving an entire
* origin-relative coordinate frame.
*/
frame?: OriginEffect;
/** Applied to the component that initiated this operation. */
source?: OriginEffect;
/** Applied to the component created by this operation and its descendants. */
target?: OriginEffect;
}
/** Values supplied whenever a choreography calculates its visual effects. */
export interface OriginChoreographyContext {
/** Normalized operation progress, clamped to the inclusive range `0..1`. */
progress: number;
/**
* Normalized progress units per second. Positive velocity moves toward
* commit; negative velocity moves back toward cancellation.
*/
velocity: number;
/** Current lifecycle phase of the operation. */
phase: OriginOperationPhase;
/** Commit/cancel outcome, if release has already selected one. */
intent: OriginOperationIntent;
/** Origin host bounds captured immediately before the target mounts. */
originRect?: OriginRect;
/** Target host bounds measured after Vue mounts the target. */
targetRect?: OriginRect;
/** Scene-container bounds, or the browser viewport when no container exists. */
viewport: OriginRect;
}
/**
* A choreography describes visual relationships only. Component creation,
* history, and cleanup are performed by the scene operation that uses it.
*/
export interface OriginChoreography {
/** Optional diagnostic name surfaced by scene inspectors and devtools. */
readonly name?: string;
/**
* Calculate source, target, and shared-frame contributions for the current
* operation state. This function should be deterministic and side-effect
* free because it can run many times per animation frame.
*/
effects(context: OriginChoreographyContext): OriginEffectSet;
/**
* Gesture progress required to retain the target after release.
*
* @defaultValue `0.36`
*/
readonly commitThreshold?: number;
/**
* Reserved: normalized positive release velocity intended to commit a
* deliberate flick.
*
* The scene currently commits any release whose velocity is at least `0.3`,
* regardless of this value. See `FLICK_COMMIT_VELOCITY` in the scene module.
*/
readonly commitVelocity?: number;
/**
* Keep this choreography's progress-`1` effects connected after a committed
* push instead of collapsing the edge and hiding its source.
*
* This is intended for partial presentations such as drawers and inspectors
* where part of the retained source remains visible beside the target. The
* source remains mounted, visible, and inert. A back operation temporarily
* suspends the resting edge; cancelling back restores it.
*
* Only retained-history `push` actions support connected resting effects.
*
* @defaultValue `false`
*/
readonly persistAtRest?: boolean;
}
/**
* Preserve type inference while declaring a custom choreography.
*
* The helper performs no runtime transformation. It gives custom routines a
* named, documented construction point and validates their shape in TypeScript.
*
* @param choreography - Side-effect-free visual effect calculator and optional
* release thresholds.
* @returns The same choreography object.
*/
export function defineOriginChoreography(
choreography: OriginChoreography,
): OriginChoreography {
return choreography;
}
/** Distinguish a choreography from a plain options object. */
export const isChoreography = (value: unknown): value is OriginChoreography => {
return (
typeof value === "object" &&
value !== null &&
"effects" in value &&
typeof value.effects === "function"
);
};
/**
* Normalize one contribution before the compositor combines it with effects
* inherited from earlier origin frames.
*
* Most applications should return plain effects from a choreography and let
* the scene call this function. It is exported for custom compositors and
* diagnostics.
*
* @param effect - Optional effect to normalize.
* @param fallbackLayer - Relative layer contribution added to `effect.layer`.
* @returns A defined effect with a numeric layer.
*/
export function normalizedEffect(
effect: OriginEffect | undefined,
fallbackLayer = 0,
): OriginEffect {
return {
...effect,
layer: (effect?.layer ?? 0) + fallbackLayer,
};
}
/** Whether a transform contributes anything to a composed transform list. */
function isComposable(transform: unknown): transform is string {
return (
typeof transform === "string" && transform !== "" && transform !== "none"
);
}
/** Result of collapsing an ordered list of effects into one style. */
export interface ComposedEffects {
/** Merged style with the composed `transform` and `opacity` applied. */
readonly style: CSSProperties;
/** Sum of every effect's relative layer contribution. */
readonly layer: number;
}
/**
* Collapse independent effect layers, ordered from the outermost inherited
* frame to the most local one, into a single style.
*
* Transforms are concatenated and opacities multiplied, including `transform`
* and numeric `opacity` supplied through `effect.style`. Every other `style`
* property uses local-last precedence over `base` and earlier effects.
*/
export const composeEffects = (
effects: readonly OriginEffect[],
base: CSSProperties = {},
): ComposedEffects => {
const style: CSSProperties = { ...base };
const transforms: string[] = [];
let opacity = 1;
let layer = 0;
for (const effect of effects) {
if (isComposable(effect.transform)) transforms.push(effect.transform);
if (effect.opacity !== undefined) opacity *= effect.opacity;
layer += effect.layer ?? 0;
if (effect.style) {
Object.assign(style, effect.style);
if (isComposable(effect.style.transform))
transforms.push(effect.style.transform);
if (effect.style.opacity !== undefined) {
const styleOpacity = Number(effect.style.opacity);
if (Number.isFinite(styleOpacity)) opacity *= styleOpacity;
}
}
}
// Assign the composed properties last so no single effect's `style` can
// replace transform work inherited from other frames.
style.transform = transforms.length ? transforms.join(" ") : "none";
style.opacity = String(clamp01(opacity));
return { style, layer };
};
const percent = (value: number) => `${(value * 100).toFixed(4)}%`;
/**
* Native-style forward motion: the target enters above the source while the
* source recedes slightly. These presets are examples; applications can
* replace them with arbitrary `defineOriginChoreography()` callbacks.
*/
export const slideLeft = defineOriginChoreography({
name: "slide-left",
commitThreshold: 0.36,
commitVelocity: 0.6,
effects: ({ progress }) => ({
source: {
transform: `translate3d(${percent(progress * -0.24)}, 0, 0) scale(${1 - progress * 0.025})`,
opacity: 1 - progress * 0.16,
},
target: {
transform: `translate3d(${percent(1 - progress)}, 0, 0)`,
},
}),
});
/**
* Back motion reveals the target underneath the source. This is merely a
* visual routine; the `back()` action selects retained-history behavior.
*/
export const slideRight = defineOriginChoreography({
name: "slide-right",
commitThreshold: 0.36,
commitVelocity: 0.6,
effects: ({ progress }) => ({
source: {
transform: `translate3d(${percent(progress)}, 0, 0)`,
},
target: {
transform: `translate3d(${percent(-0.24 + progress * 0.24)}, 0, 0) scale(${0.975 + progress * 0.025})`,
opacity: 0.84 + progress * 0.16,
},
}),
});
/**
* Cross-fade preset that fades the source out while fading the target in.
*
* The default release thresholds from the scene are used.
*/
export const fade = defineOriginChoreography({
name: "fade",
effects: ({ progress }) => ({
source: { opacity: 1 - progress },
target: { opacity: progress },
}),
});
@@ -1,80 +0,0 @@
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 };
}
@@ -1,27 +0,0 @@
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"
);
}
@@ -1,55 +0,0 @@
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
@@ -1,133 +0,0 @@
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,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { useOriginGesture } from "../gesture/useOriginGesture"; import { useOriginGesture } from "../gesture/recognizer";
import type { OriginGestureProps } from "./types"; import type { OriginGestureProps } from "../gesture/definition";
defineOptions({ name: "OriginGesture", inheritAttrs: false }); defineOptions({ name: "OriginGesture", inheritAttrs: false });
@@ -1,8 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from "vue"; import { computed } from "vue";
import { touchActionFor } from "../gesture/direction"; import type { OriginGestureSurfaceProps } from "../gesture/definition";
import { useOriginGesture } from "../gesture/useOriginGesture"; import { touchActionFor, useOriginGesture } from "../gesture/recognizer";
import type { OriginGestureSurfaceProps } from "./types";
defineOptions({ name: "OriginGestureSurface", inheritAttrs: false }); defineOptions({ name: "OriginGestureSurface", inheritAttrs: false });
@@ -1,6 +1,6 @@
<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 { originNodeScopeKey } from "../composables/useOrigin"; import { originNodeScopeKey } from "../scene/scene";
import type { OriginScene, OriginSceneNode } from "../scene/types"; import type { OriginScene, OriginSceneNode } from "../scene/types";
defineOptions({ name: "OriginNodeHost" }); defineOptions({ name: "OriginNodeHost" });
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { onBeforeUnmount, ref, watchEffect } from "vue"; import { onBeforeUnmount, ref, watchEffect } from "vue";
import type { OriginSceneProps } from "./types"; import type { OriginSceneProps } from "../scene/types";
import OriginNodeHost from "./OriginNodeHost.vue"; import OriginNodeHost from "./OriginNodeHost.vue";
defineOptions({ name: "OriginScene" }); defineOptions({ name: "OriginScene" });
-83
View File
@@ -1,83 +0,0 @@
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;
}
@@ -1,92 +0,0 @@
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),
};
}
-123
View File
@@ -1,123 +0,0 @@
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" })),
});
@@ -1,11 +1,14 @@
import type { CSSProperties } from "vue"; import type { CSSProperties } from "vue";
import type { import type { OriginChoreography } from "../choreography";
MaybeOriginAction, import type { MaybeOriginAction, OriginNavigationIntent } from "../navigation";
OriginNavigationIntent,
} from "../actions/types";
import type { OriginChoreography } from "../choreography/types";
import type { OriginContext } from "../scene/types"; import type { OriginContext } from "../scene/types";
import type { OriginRect } from "../shared/geometry"; import type { OriginRect } from "../shared";
/*
* What a gesture is: the public gesture types, component props, and the
* immutable builder that produces executable definitions. Running them is
* the recognizer's job.
*/
/** Direction in which pointer movement advances operation progress. */ /** Direction in which pointer movement advances operation progress. */
export type OriginGestureDirection = "left" | "right" | "up" | "down"; export type OriginGestureDirection = "left" | "right" | "up" | "down";
@@ -288,3 +291,183 @@ export interface OriginGestureBinding {
/** Pointer-cancel handler that abandons any captured operation. */ /** Pointer-cancel handler that abandons any captured operation. */
readonly onPointercancel: () => void; readonly onPointercancel: () => void;
} }
// Component props ----------------------------------------------------------
/** Props shared by both `OriginGesture` declaration styles. */
export interface OriginGestureBaseProps {
/**
* Native HTML tag rendered as the gesture host.
*
* @defaultValue `"div"`
*/
as?: string;
}
/** Builder-definition props accepted by the `OriginGesture` component. */
export interface OriginGestureDefinitionProps extends OriginGestureBaseProps {
/** Immutable definition produced by the {@link gesture} builder. */
gesture: OriginGestureDefinition;
/** Builder definitions already contain direction. */
direction?: never;
/** Builder definitions already contain their start policy. */
edge?: never;
/** Builder definitions already contain recognition tuning. */
threshold?: never;
/** Builder definitions already contain their navigation factory. */
action?: never;
}
/** Legacy option props accepted by the `OriginGesture` component. */
export interface OriginGestureLegacyProps extends OriginGestureBaseProps {
/** Legacy component declarations do not provide a builder definition. */
gesture?: never;
/** Direction in which pointer movement advances operation progress. */
direction: OriginGestureDirection;
/**
* Eligible pointer-down width in CSS pixels from the host's starting edge.
* Omit it to allow the full component surface.
*/
edge?: number;
/**
* Directed movement required before capture.
*
* @defaultValue `8`
*/
threshold?: number;
/** Node-local action factory invoked only after recognition succeeds. */
action(context: OriginContext): MaybeOriginAction;
}
/**
* Public props accepted by the `OriginGesture` convenience component.
*
* Prefer the builder-definition form. The legacy direction/action form remains
* available for compatibility.
*/
export type OriginGestureProps =
OriginGestureDefinitionProps | OriginGestureLegacyProps;
/** Public props accepted by the multi-definition gesture surface component. */
export interface OriginGestureSurfaceProps {
/**
* Native HTML tag rendered as the shared gesture host.
*
* @defaultValue `"div"`
*/
as?: string;
/**
* Complete immutable definitions installed on the shared host.
*
* The surface adds no start, direction, completion, navigation, or animation
* policy. Definitions should be created by the owning page component.
*/
gestures: readonly OriginGestureDefinition[];
}
// Builder ------------------------------------------------------------------
/*
* Immutable fluent builder. Each stage is a frozen object 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
@@ -1,51 +0,0 @@
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
@@ -1,87 +0,0 @@
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);
}
+413 -9
View File
@@ -1,12 +1,297 @@
import type { OriginAction } from "../actions/types"; import type { OriginAction, MaybeOriginAction } from "../navigation";
import type { UseOrigin } from "../composables/useOrigin"; import { useOrigin, type UseOrigin } from "../scene/scene";
import type { OriginContext, OriginOperationHandle } from "../scene/types"; import type { OriginContext, OriginOperationHandle } from "../scene/types";
import { measureRect, type OriginRect } from "../shared/geometry"; import { clamp01, measureRect, type OriginRect } from "../shared";
import { isHorizontal } from "./direction"; import type {
import type { RecognizerPolicy } from "./policy"; OriginGestureBinding,
import { isIgnoredTarget, matchesStart, pointOf } from "./start"; OriginGestureCompletionPredicate,
import { DirectionalTracker } from "./tracker"; OriginGestureDefinition,
import type { OriginGesturePoint } from "./types"; OriginGestureDirection,
OriginGestureDistance,
OriginGestureOptions,
OriginGesturePoint,
OriginGestureStart,
} from "./definition";
/*
* Running gestures: pointer-down admission, movement tracking, and the
* recognizer that turns one host's pointer events into a scene operation.
*/
// Direction ----------------------------------------------------------------
export const isHorizontal = (direction: OriginGestureDirection): boolean => {
return direction === "left" || direction === "right";
};
/** Component of a movement `(dx, dy)` along `direction`. */
export const directedDistance = (
direction: OriginGestureDirection,
dx: number,
dy: number,
): number => {
switch (direction) {
case "left":
return -dx;
case "right":
return dx;
case "up":
return -dy;
case "down":
return dy;
}
};
/** Absolute movement perpendicular to `direction`. */
export const crossDistance = (
direction: OriginGestureDirection,
dx: number,
dy: number,
): number => {
return isHorizontal(direction) ? Math.abs(dy) : Math.abs(dx);
};
/**
* CSS `touch-action` for a host recognizing the given directions: native
* scrolling is preserved on any axis no gesture claims.
*/
export const touchActionFor = (
directions: Iterable<OriginGestureDirection>,
): "none" | "pan-x" | "pan-y" | "auto" => {
let horizontal = false;
let vertical = false;
for (const direction of directions) {
if (isHorizontal(direction)) horizontal = true;
else vertical = true;
}
if (horizontal && vertical) return "none";
if (horizontal) return "pan-y";
if (vertical) return "pan-x";
return "auto";
};
// Pointer-down admission ----------------------------------------------------
/*
* Pointer-down admission: which events may begin a gesture at all.
*/
const IGNORED_TARGETS =
'[data-origin-gesture="ignore"], input, textarea, select, option, [contenteditable="true"]';
/** Form controls and opted-out subtrees never start gestures. */
export const isIgnoredTarget = (target: EventTarget | null): boolean => {
return (
!(target instanceof Element) || Boolean(target.closest(IGNORED_TARGETS))
);
};
export const pointOf = (
event: Pick<PointerEvent, "clientX" | "clientY">,
bounds: OriginRect,
): OriginGesturePoint => {
return {
clientX: event.clientX,
clientY: event.clientY,
localX: event.clientX - bounds.left,
localY: event.clientY - bounds.top,
};
};
const nonNegative = (value: number) =>
Number.isFinite(value) ? Math.max(0, value) : 0;
/**
* Resolve an arbitrary CSS length against a box with the gesture host's size.
*
* A short-lived off-screen box lets the browser handle `rem`, viewport units,
* percentages, `calc()`, and `clamp()` consistently. This runs only during
* pointer-down for edge-constrained definitions.
*/
export const resolveCssDistance = (
distance: OriginGestureDistance,
axis: "horizontal" | "vertical",
host: HTMLElement,
bounds: OriginRect,
): number => {
if (typeof distance === "number") return nonNegative(distance);
const document = host.ownerDocument;
if (!document.body) return Math.max(0, Number.parseFloat(distance) || 0);
const container = document.createElement("div");
const probe = document.createElement("div");
Object.assign(container.style, {
position: "fixed",
left: "-100000px",
top: "-100000px",
width: `${bounds.width}px`,
height: `${bounds.height}px`,
visibility: "hidden",
pointerEvents: "none",
contain: "strict",
});
Object.assign(probe.style, {
position: "absolute",
width: axis === "horizontal" ? distance : "0",
height: axis === "vertical" ? distance : "0",
});
container.append(probe);
document.body.append(container);
const rect = probe.getBoundingClientRect();
container.remove();
return nonNegative(axis === "horizontal" ? rect.width : rect.height);
};
/** Whether a pointer-down satisfies a definition's start policy. */
export const matchesStart = (
start: OriginGestureStart,
event: PointerEvent,
host: HTMLElement,
origin: OriginContext,
bounds: OriginRect,
): boolean => {
if (start.kind === "anywhere") return true;
const point = pointOf(event, bounds);
if (start.kind === "when")
return start.predicate({ event, origin, host, bounds, point });
const horizontal = start.edge === "left" || start.edge === "right";
const distance = resolveCssDistance(
start.distance,
horizontal ? "horizontal" : "vertical",
host,
bounds,
);
switch (start.edge) {
case "left":
return point.localX >= 0 && point.localX <= distance;
case "right":
return (
point.localX <= bounds.width && bounds.width - point.localX <= distance
);
case "top":
return point.localY >= 0 && point.localY <= distance;
case "bottom":
return (
point.localY <= bounds.height &&
bounds.height - point.localY <= distance
);
}
};
// Movement tracking ---------------------------------------------------------
type PointerSample = Pick<PointerEvent, "clientX" | "clientY" | "timeStamp">;
/** Shortest frame interval assumed when computing velocity, in ms. */
const MIN_SAMPLE_INTERVAL = 8;
/**
* A release without movement after this many ms is treated as a hold, so the
* last move's flick velocity no longer applies.
*/
const HOLD_RELEASE_INTERVAL = 80;
/**
* Measures one pointer sequence's movement along a gesture direction.
*
* Progress and velocity are normalized by the host's size along the gesture
* axis, so `1` means one full host width (or height).
*/
export class DirectionalTracker {
/** Directed distance from the start, in CSS pixels. */
distance = 0;
/** Absolute cross-axis distance from the start, in CSS pixels. */
crossDistance = 0;
/** Directed distance as a fraction of host size, clamped to `0..1`. */
progress = 0;
/** Latest directed velocity in progress units per second. */
velocity = 0;
private readonly horizontal: boolean;
/** Whether progress grows as the axis coordinate decreases. */
private readonly reversed: boolean;
private lastCoordinate: number;
private lastTime: number;
constructor(
private readonly direction: OriginGestureDirection,
private readonly start: PointerSample,
/** Host size along the gesture axis, read at every sample. */
private readonly axisSize: () => number,
) {
this.horizontal = isHorizontal(direction);
this.reversed = direction === "left" || direction === "up";
this.lastCoordinate = this.coordinateOf(start);
this.lastTime = start.timeStamp;
}
private coordinateOf(event: PointerSample) {
return this.horizontal ? event.clientX : event.clientY;
}
/** Movement from the start without recording a sample. */
displacement(event: PointerSample) {
const dx = event.clientX - this.start.clientX;
const dy = event.clientY - this.start.clientY;
return {
distance: directedDistance(this.direction, dx, dy),
crossDistance: crossDistance(this.direction, dx, dy),
};
}
/** Record a sample, updating distance, progress, and velocity. */
sample(event: PointerSample, release = false): void {
const { distance, crossDistance } = this.displacement(event);
this.distance = distance;
this.crossDistance = crossDistance;
const size = Math.max(1, this.axisSize());
const coordinate = this.coordinateOf(event);
const step = this.reversed
? this.lastCoordinate - coordinate
: coordinate - this.lastCoordinate;
const interval = event.timeStamp - this.lastTime;
this.progress = clamp01(distance / size);
// Pointer-up commonly repeats the final pointer-move coordinate. Preserve
// that move's flick velocity for a prompt release, but decay it when the
// pointer was held still long enough for the flick to have ended.
if (!release || step !== 0 || interval > HOLD_RELEASE_INTERVAL)
this.velocity =
(step * 1000) / (Math.max(MIN_SAMPLE_INTERVAL, interval) * size);
this.lastCoordinate = coordinate;
this.lastTime = event.timeStamp;
}
}
// Recognizer ----------------------------------------------------------------
/** Directed movement in CSS pixels required before a gesture captures. */
const DEFAULT_THRESHOLD = 8;
/** Ratio by which directed movement must exceed cross-axis movement. */
const DEFAULT_AXIS_DOMINANCE = 1.15;
/** Legacy `edge` start side for each movement direction. */
const LEGACY_START_EDGE = {
left: "right",
right: "left",
up: "bottom",
down: "top",
} as const;
/**
* The single shape the recognizer executes. Builder definitions and legacy
* options objects are both normalized into it.
*/
export interface RecognizerPolicy {
readonly direction: OriginGestureDirection;
readonly start: OriginGestureStart;
readonly threshold: number;
readonly axisDominance: number;
readonly completion?: OriginGestureCompletionPredicate;
readonly action: (context: OriginContext) => MaybeOriginAction;
}
/** The origin capabilities a recognizer needs. */ /** The origin capabilities a recognizer needs. */
export type RecognizerOrigin = Pick<UseOrigin, "context" | "begin">; export type RecognizerOrigin = Pick<UseOrigin, "context" | "begin">;
@@ -49,10 +334,77 @@ export class GestureRecognizer {
private session: PointerSession | null = null; private session: PointerSession | null = null;
constructor( constructor(
private readonly policy: RecognizerPolicy, readonly policy: RecognizerPolicy,
private readonly origin: RecognizerOrigin, private readonly origin: RecognizerOrigin,
) {} ) {}
/** Create a recognizer for a builder definition or legacy options. */
static create(
definition: OriginGestureDefinition | OriginGestureOptions,
origin: RecognizerOrigin,
): GestureRecognizer {
return new GestureRecognizer(
GestureRecognizer.policyFor(definition),
origin,
);
}
/**
* Normalize a builder definition or legacy options object into the single
* policy shape the recognizer executes.
*/
static policyFor(
definition: OriginGestureDefinition | OriginGestureOptions,
): RecognizerPolicy {
return "kind" in definition &&
definition.kind === "origin-gesture-definition"
? GestureRecognizer.policyFromDefinition(definition)
: GestureRecognizer.policyFromOptions(definition as OriginGestureOptions);
}
private static policyFromDefinition(
definition: OriginGestureDefinition,
): RecognizerPolicy {
return {
direction: definition.direction,
start: definition.start,
threshold: definition.recognition.threshold ?? DEFAULT_THRESHOLD,
axisDominance:
definition.recognition.axisDominance ?? DEFAULT_AXIS_DOMINANCE,
completion: definition.completion,
// Pair the resolved navigation intent with the definition's animation.
action: async (context) => {
const navigation = await definition.navigation(context);
if (!navigation) return navigation;
const action: OriginAction = {
...navigation,
choreography: definition.choreography,
};
return action;
},
};
}
/** Legacy `edge` is measured from the side opposite the movement. */
private static policyFromOptions(
options: OriginGestureOptions,
): RecognizerPolicy {
return {
direction: options.direction,
start:
options.edge === undefined
? { kind: "anywhere" }
: Object.freeze({
kind: "edge",
edge: LEGACY_START_EDGE[options.direction],
distance: options.edge,
}),
threshold: options.threshold ?? DEFAULT_THRESHOLD,
axisDominance: DEFAULT_AXIS_DOMINANCE,
action: options.action,
};
}
pointerDown(event: PointerEvent): void { pointerDown(event: PointerEvent): void {
const host = event.currentTarget; const host = event.currentTarget;
if ( if (
@@ -181,3 +533,55 @@ export class GestureRecognizer {
} }
} }
} }
// Composable ----------------------------------------------------------------
/**
* Install a component-owned pointer recognizer.
*
* Recognition is local to the element receiving these handlers. There is no
* application-wide gesture table and no lookup of a currently active view.
*
* Builder definitions may independently describe their pointer-down region
* and movement direction. Omitting `.from` recognizes pointer-down across the
* whole element. The legacy options object remains supported; its `edge` is
* inferred from the opposite side of its movement direction.
*
* Interactive controls and anything inside
* `[data-origin-gesture="ignore"]` are ignored automatically. The recognizer
* preserves native scrolling on the cross-axis through its returned style.
*
* @param definition - An immutable builder result or legacy recognizer options.
* @returns Pointer handlers and required host styles.
* @throws If called outside a component rendered by `OriginScene`.
*
* @example Builder-defined backward gesture
* ```ts
* const swipeBack = useOriginGesture(
* gesture
* .from.left("32px")
* .to.right()
* .navigate((context) => context.canGoBack ? back() : null)
* .animate(slideRight),
* );
* ```
*/
export function useOriginGesture(
definition: OriginGestureDefinition | OriginGestureOptions,
): OriginGestureBinding {
const recognizer = GestureRecognizer.create(definition, useOrigin());
return {
style: {
// Preserve native scrolling perpendicular to the declared gesture.
touchAction: touchActionFor([recognizer.policy.direction]),
// OriginGesture is commonly the root returned by a view component.
width: "100%",
height: "100%",
},
onPointerdown: (event) => recognizer.pointerDown(event),
onPointermove: (event) => void recognizer.pointerMove(event),
onPointerup: (event) => void recognizer.pointerUp(event),
onPointercancel: () => void recognizer.pointerCancel(),
};
}
-115
View File
@@ -1,115 +0,0 @@
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
@@ -1,86 +0,0 @@
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;
}
}
@@ -1,60 +0,0 @@
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(),
};
}
+177
View File
@@ -0,0 +1,177 @@
/**
* Where an application can trigger haptic feedback with the current API.
*/
import {
createApp,
defineComponent,
h,
nextTick,
watch,
type Component,
} from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
createOriginScene,
defineOriginChoreography,
forward,
gesture,
OriginScene,
originView,
useOriginGesture,
type OriginGestureDefinition,
} from ".";
const apps: Array<ReturnType<typeof createApp>> = [];
afterEach(() => {
for (const app of apps.splice(0)) app.unmount();
document.body.innerHTML = "";
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
const motion = defineOriginChoreography({
name: "haptics-test",
effects: ({ progress }) => ({ target: { opacity: progress } }),
});
const Target: Component = defineComponent({ render: () => h("div", "T") });
function pointer(type: string, clientX: number, timeStamp: number) {
const event = new PointerEvent(type, {
bubbles: true,
cancelable: true,
button: 0,
isPrimary: true,
pointerId: 1,
clientX,
clientY: 100,
});
Object.defineProperty(event, "timeStamp", { value: timeStamp });
return event;
}
async function flush() {
for (let i = 0; i < 4; i += 1) await new Promise((r) => setTimeout(r, 0));
}
/** Mount a scene whose root installs `definition`, then return its host. */
async function mount(definition: OriginGestureDefinition) {
// Reduced motion settles instantly, so release outcomes apply at once.
vi.stubGlobal("matchMedia", () => ({ matches: true }));
const Root = defineComponent({
setup() {
const binding = useOriginGesture(definition);
return () =>
h("div", {
id: "host",
style: binding.style,
onPointerdown: binding.onPointerdown,
onPointermove: binding.onPointermove,
onPointerup: binding.onPointerup,
});
},
});
const scene = createOriginScene({ initial: originView(Root) });
const root = document.createElement("div");
document.body.append(root);
const app = createApp({ render: () => h(OriginScene, { scene }) });
apps.push(app);
app.mount(root);
await nextTick();
const host = root.querySelector("#host") as HTMLElement;
Object.defineProperty(host, "clientWidth", { value: 200 });
host.getBoundingClientRect = () =>
({ top: 0, left: 0, width: 200, height: 400 }) as DOMRect;
return { scene, host };
}
/** Swipe left by `distance` px, slowly enough that velocity cannot commit. */
async function swipe(host: HTMLElement, distance: number, log: string[]) {
host.dispatchEvent(pointer("pointerdown", 190, 0));
host.dispatchEvent(pointer("pointermove", 180, 500));
await flush();
log.push("recognized");
host.dispatchEvent(pointer("pointermove", 190 - distance, 1000));
await flush();
host.dispatchEvent(pointer("pointerup", 190 - distance, 1000));
await flush();
log.push("released");
}
describe("haptic feedback", () => {
it("can vibrate when a gesture is recognized, from .navigate()", async () => {
const log: string[] = [];
const vibrate = vi.fn(() => log.push("vibrate") > 0);
vi.stubGlobal("navigator", { ...navigator, vibrate });
const { host } = await mount(
gesture.to
.left()
.navigate(() => {
navigator.vibrate?.(10);
return forward(originView(Target));
})
.animate(motion),
);
// A short swipe that is cancelled on release still vibrated, because
// .navigate() runs at recognition, before the outcome is known.
await swipe(host, 20, log);
expect(log).toEqual(["vibrate", "recognized", "released"]);
});
it("can vibrate only when a release commits, by watching operation intent", async () => {
const log: string[] = [];
const vibrate = vi.fn(() => log.push("vibrate") > 0);
vi.stubGlobal("navigator", { ...navigator, vibrate });
const { scene, host } = await mount(
gesture.to
.left()
.navigate(() => forward(originView(Target)))
.animate(motion),
);
// Intent becomes "commit" synchronously at release, before settling.
const seen = new Set<number>();
watch(
() => scene.operations.value.filter((o) => o.intent === "commit"),
(committed) => {
for (const operation of committed)
if (!seen.has(operation.id)) {
seen.add(operation.id);
navigator.vibrate?.(10);
}
},
{ flush: "sync" },
);
await swipe(host, 20, log); // 10% progress: cancelled
expect(vibrate).not.toHaveBeenCalled();
await swipe(host, 150, log); // 75% progress: committed
expect(log).toEqual([
"recognized",
"released",
"recognized",
"vibrate",
"released",
]);
});
it("can vibrate on release from a custom .complete() decision", async () => {
const vibrate = vi.fn(() => true);
vi.stubGlobal("navigator", { ...navigator, vibrate });
const { host } = await mount(
gesture.to
.left()
.complete(({ progress }) => {
const commit = progress > 0.5;
if (commit) navigator.vibrate?.(10);
return commit;
})
.navigate(() => forward(originView(Target)))
.animate(motion),
);
await swipe(host, 20, []);
expect(vibrate).not.toHaveBeenCalled();
await swipe(host, 150, []);
expect(vibrate).toHaveBeenCalledTimes(1);
});
});
+36 -26
View File
@@ -5,50 +5,60 @@
* origin-relative operation frames. It does not depend on Vue Router or choose * origin-relative operation frames. It does not depend on Vue Router or choose
* a globally active view. * a globally active view.
* *
* Layers, from the bottom up: * Modules, from the bottom up:
* *
* - `view/` component recipes (`originView`) * - `shared` geometry and environment helpers
* - `choreography/` visual effects, presets, and effect compositing * - `choreography` visual effects, presets, and effect compositing
* - `actions/` navigation intents and actions (`forward`, `back`, …) * - `navigation` view recipes, intents, and actions (`forward`, …)
* - `scene/` graph storage, transitions, compositor, settling spring * - `scene/graph` the scene model and its history rules
* - `composables/` node-scoped access to the owning scene (`useOrigin`) * - `scene/scene` timing, measuring, compositing, `useOrigin`
* - `gesture/` builder, pointer tracking, and recognition * - `gesture/definition` gesture types and the immutable builder
* - `gesture/recognizer` pointer admission, tracking, and recognition
* - `components/` Vue renderers and gesture hosts * - `components/` Vue renderers and gesture hosts
* *
* @packageDocumentation * @packageDocumentation
*/ */
export type { OriginRect } from "./shared/geometry"; export type { OriginRect } from "./shared";
export type * from "./view/types"; export type {
export { originView } from "./view/originView"; OriginChoreography,
OriginChoreographyContext,
OriginEffect,
OriginEffectSet,
OriginOperationIntent,
OriginOperationPhase,
} from "./choreography";
export {
defineOriginChoreography,
fade,
normalizedEffect,
slideLeft,
slideRight,
} from "./choreography";
export type * from "./choreography/types"; export type * from "./navigation";
export { defineOriginChoreography } from "./choreography/define";
export { normalizedEffect } from "./choreography/compose";
export { fade, slideLeft, slideRight } from "./choreography/presets";
export type * from "./actions/types";
export { export {
above, above,
back, back,
forward, forward,
originAction, originAction,
originView,
replace, replace,
under, under,
} from "./actions/actions"; } from "./navigation";
export type * from "./scene/types"; export type * from "./scene/types";
export { createOriginScene } from "./scene/createOriginScene"; export type { OriginNodeScope, UseOrigin } from "./scene/scene";
export {
createOriginScene,
originNodeScopeKey,
useOrigin,
} from "./scene/scene";
export type { OriginNodeScope, UseOrigin } from "./composables/useOrigin"; export type * from "./gesture/definition";
export { originNodeScopeKey, useOrigin } from "./composables/useOrigin"; export { gesture } from "./gesture/definition";
export { useOriginGesture } from "./gesture/recognizer";
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
@@ -1,15 +1,198 @@
import { isChoreography } from "../choreography/define"; import { markRaw, type Component } from "vue";
import type { OriginChoreography } from "../choreography/types"; import { isChoreography, type OriginChoreography } from "./choreography";
import type { OriginView } from "../view/types";
import type { /*
OriginAction, * Navigation vocabulary: view recipes (what to mount) and the intents and
OriginActionOptions, * actions (how history changes) that operations execute.
OriginHistoryMode, */
OriginNavigationActionOptions,
OriginNavigationIntent, /** Props accepted by a Vue component definition. */
OriginPlacement, export type PropsOf<T> = T extends abstract new (...args: any[]) => {
OriginPlacementActionOptions, $props: infer P;
} from "./types"; }
? P
: T extends (props: infer P, ...args: any[]) => any
? P
: never;
/**
* A view is a recipe for creating a Vue component, not a mounted instance.
*
* Forward navigation uses the recipe to create a mounted history entry. That
* instance remains mounted until a committed back operation pops it.
*
* @typeParam T - The component mounted by the recipe.
*/
export interface OriginView<T extends Component = Component> {
/** The Vue component definition that will be mounted for this recipe. */
readonly component: T;
/** Props passed to the component when the recipe is mounted. */
readonly props?: Readonly<PropsOf<T>>;
/**
* A developer-facing recipe identity used in diagnostics and as the prefix
* of generated scene-node keys. It does not make Vue reuse an instance.
*/
readonly key?: string;
/** A human-readable label used by diagnostics and DOM data attributes. */
readonly name?: string;
}
/** Optional developer-facing metadata for an {@link OriginView} recipe. */
export interface OriginViewOptions {
/**
* Stable recipe identity used in diagnostics and generated node-key prefixes.
* It does not preserve or reuse a mounted Vue component instance.
*/
key?: string;
/** Human-readable label used by inspectors and DOM data attributes. */
name?: string;
}
let viewSequence = 0;
function componentName(component: Component): string | undefined {
return typeof component === "object" && "name" in component
? String(component.name)
: undefined;
}
/**
* Turn a component and props into a lightweight, reusable view recipe.
*
* Components are marked raw so Vue never attempts to proxy their definitions
* when recipes are placed in reactive scene/history structures.
*
* @param component - Vue component definition to mount.
* @param props - Props captured by the recipe.
* @param options - Optional recipe identity and diagnostic name.
* @returns An immutable, reusable component recipe. It is not a Vue instance.
*
* @example
* ```ts
* const profile = originView(
* ProfileView,
* { userId: "42" },
* { key: "profile-42", name: "Profile" },
* );
* ```
*/
export function originView<T extends Component = Component>(
component: T,
props?: PropsOf<T>,
options: OriginViewOptions = {},
): OriginView<T> {
const name = options.name ?? componentName(component);
return markRaw({
component: markRaw(component),
props,
key: options.key ?? `${name ?? "view"}-${++viewSequence}`,
name,
});
}
/**
* Controls how an operation participates in retained instance history.
*
* - `push`: create a target whose previous entry is the mounted origin.
* - `replace`: create a target that inherits the origin's previous entry, then
* remove the origin on commit.
* - `back`: reuse the mounted previous entry and pop the origin on commit.
*/
export type OriginHistoryMode = "push" | "replace" | "back";
/**
* The target's stacking relationship to its origin while an operation exists.
*/
export type OriginPlacement = "above" | "under";
/**
* A scene mutation without visual choreography.
*
* Gesture builders keep navigation intent separate from animation so the same
* destination can be paired with different component-local interactions.
* Calling `.animate()` materializes this intent as an {@link OriginAction}.
*/
export interface OriginNavigationIntent {
/**
* Recipe for a newly created push or replacement target. Back navigation
* omits this because the scene resolves the retained previous instance.
*/
readonly target?: OriginView;
/** Target stacking relationship while the gesture operation is visible. */
readonly placement?: OriginPlacement;
/** Retained-history mutation performed if the gesture commits. */
readonly history: OriginHistoryMode;
}
/** A complete request to create and animate a target view from an origin. */
export interface OriginAction {
/**
* Recipe for a newly created push or replacement target. A back action omits
* this because the scene resolves its already-mounted previous entry.
*/
readonly target?: OriginView;
/** Visual relationship applied to the source, target, and shared frame. */
readonly choreography: OriginChoreography;
/**
* Target stacking relationship during the operation.
*
* @defaultValue `"above"`
*/
readonly placement?: OriginPlacement;
/**
* History mutation applied to the target recipe.
*
* @defaultValue `"push"`
*/
readonly history?: OriginHistoryMode;
}
/**
* Values accepted from gesture action factories.
*
* Returning `null` or `undefined` declines the recognized gesture. A promise
* allows lazy target resolution; stale results are discarded after release or
* cancellation.
*/
export type MaybeOriginAction =
OriginAction | null | undefined | Promise<OriginAction | null | undefined>;
/** Options accepted by {@link originAction}. */
export interface OriginActionOptions {
/**
* Target stacking relationship during the operation.
*
* @defaultValue `"above"`
*/
placement?: OriginPlacement;
/**
* History mutation assigned to the created target node.
*
* @defaultValue `"push"`
*/
history?: OriginHistoryMode;
}
/** History options shared by the {@link above} and {@link under} helpers. */
export interface OriginPlacementActionOptions {
/**
* History mutation assigned to the created target node.
*
* @defaultValue `"push"`
*/
history?: OriginHistoryMode;
}
/** Stacking options accepted by retained-history navigation helpers. */
export interface OriginNavigationActionOptions {
/**
* Target stacking relationship during the operation.
*
* @defaultValue `"above"` for {@link forward} and {@link replace}, `"under"`
* for {@link back}
*/
placement?: OriginPlacement;
}
/* /*
* Every helper accepts either `(…, options?)`, producing an animation-free * Every helper accepts either `(…, options?)`, producing an animation-free
-148
View File
@@ -1,148 +0,0 @@
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;
}
@@ -1,174 +0,0 @@
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),
};
}
+325 -17
View File
@@ -1,8 +1,14 @@
import { markRaw, reactive, shallowReactive, shallowRef } from "vue"; import { markRaw, reactive, shallowReactive, shallowRef } from "vue";
import type { OriginView } from "../view/types"; import type {
OriginAction,
OriginHistoryMode,
OriginView,
} from "../navigation";
import { clamp01, type OriginRect } from "../shared";
import type { import type {
MutableOriginOperation, MutableOriginOperation,
OriginContext, OriginContext,
OriginFinishOptions,
OriginSceneNodeState, OriginSceneNodeState,
} from "./types"; } from "./types";
@@ -18,14 +24,34 @@ export interface SceneNodeRecord {
incomingOperationId?: number; incomingOperationId?: number;
} }
/** Fields supplied when a new operation edge is created. */ /**
export type OperationInit = Omit< * Progress at which a release commits when the choreography does not supply
MutableOriginOperation, * its own `commitThreshold`.
"id" | "progress" | "velocity" | "phase" | "intent" | "targetRect" */
>; export const DEFAULT_COMMIT_THRESHOLD = 0.36;
/** /**
* Reactive storage for a scene's nodes and operation edges. * Release velocity (progress units per second) that commits a flick at any
* progress. Choreographies' `commitVelocity` is not currently consulted.
*/
export const FLICK_COMMIT_VELOCITY = 0.3;
/** Decide a release outcome from explicit options or progress/velocity. */
export const shouldCommit = (
operation: MutableOriginOperation,
options: OriginFinishOptions,
): boolean => {
if (options.commit !== undefined) return options.commit;
const threshold =
operation.choreography.commitThreshold ?? DEFAULT_COMMIT_THRESHOLD;
return (
operation.progress >= threshold ||
(operation.progress >= 0 && operation.velocity >= FLICK_COMMIT_VELOCITY)
);
};
/**
* The scene model: nodes, operation edges, and the rules that rewrite them.
* *
* Two independent structures share these records: * Two independent structures share these records:
* *
@@ -36,8 +62,8 @@ export type OperationInit = Omit<
* Nodes without an incoming edge are listed in `roots`. * Nodes without an incoming edge are listed in `roots`.
* *
* Nodes are stored flat so Vue never re-parents a component when edges are * Nodes are stored flat so Vue never re-parents a component when edges are
* added, spliced, or removed. This class owns structural primitives only; * added, spliced, or removed. Every method is synchronous; timing (mounting,
* navigation semantics live in `transitions.ts`. * measuring, settling) belongs to `Scene`.
*/ */
export class SceneGraph { export class SceneGraph {
readonly nodes = shallowReactive(new Map<string, SceneNodeRecord>()); readonly nodes = shallowReactive(new Map<string, SceneNodeRecord>());
@@ -49,8 +75,7 @@ export class SceneGraph {
* Connected resting edges temporarily removed by a back operation, keyed by * Connected resting edges temporarily removed by a back operation, keyed by
* that back operation's id. Cancelling the back operation restores the edge. * that back operation's id. Cancelling the back operation restores the edge.
*/ */
readonly suspendedEdges = new Map<number, MutableOriginOperation>(); private readonly suspendedEdges = new Map<number, MutableOriginOperation>();
private nodeSequence = 0; private nodeSequence = 0;
private operationSequence = 0; private operationSequence = 0;
@@ -75,6 +100,21 @@ export class SceneGraph {
: undefined; : undefined;
} }
/** Incoming edges positioning `node`, from the node outward to its root. */
incomingChain(node: SceneNodeRecord): MutableOriginOperation[] {
const chain: MutableOriginOperation[] = [];
const visited = new Set<string>();
let current: SceneNodeRecord | undefined = node;
while (current && !visited.has(current.key)) {
visited.add(current.key);
const incoming = this.incomingOf(current);
if (!incoming) break;
chain.push(incoming);
current = this.nodes.get(incoming.originKey);
}
return chain;
}
/** The unfinished operation originated by a node, if any. */ /** The unfinished operation originated by a node, if any. */
liveOutgoingOf(nodeKey: string): MutableOriginOperation | undefined { liveOutgoingOf(nodeKey: string): MutableOriginOperation | undefined {
for (const operation of this.operations.values()) for (const operation of this.operations.values())
@@ -122,9 +162,266 @@ export class SceneGraph {
}; };
} }
// Mutations --------------------------------------------------------------- /**
* Whether a node should currently receive input.
*
* Parked and exposed entries are inert. A target being cancelled is already
* scheduled to disappear, and a source settling toward commit has ceded new
* interactions to its target. This is node-local fate, not a global "active
* view".
*/
isInteractive(nodeKey: string): boolean {
const node = this.nodes.get(nodeKey);
if (!node || node.state === "parked" || node.state === "exposed")
return false;
if (this.incomingOf(node)?.intent === "cancel") return false;
return this.liveOutgoingOf(nodeKey)?.intent !== "commit";
}
createNode(view: OriginView, previousNodeKey?: string): SceneNodeRecord { // Transitions -------------------------------------------------------------
/**
* Validate an action and add its operation edge (and, for push/replace, its
* target node). The edge starts in the `preparing` phase.
*
* @throws If the origin cannot originate, or the action cannot be resolved.
*/
begin(
originKey: string,
action: OriginAction,
originRect: OriginRect | undefined,
): MutableOriginOperation {
const origin = this.assertCanOriginate(originKey);
const history = action.history ?? "push";
if (action.choreography.persistAtRest && history !== "push")
throw new Error(
"persistAtRest choreographies require retained-history push navigation.",
);
const { target, suspendedEdge } =
history === "back"
? this.resolveBackTarget(origin)
: { target: this.createForwardTarget(origin, action, history) };
const operation = this.createOperation({
originKey,
targetKey: target.key,
entryTargetKey: target.key,
choreography: action.choreography,
placement: action.placement ?? "above",
history,
originRect,
});
target.incomingOperationId = operation.id;
target.state = "transitioning";
origin.state = "transitioning";
if (suspendedEdge) this.suspendedEdges.set(operation.id, suspendedEdge);
return operation;
}
/** Apply interactive progress to an unfinished operation. */
update(id: number, progress: number, velocity: number): void {
const operation = this.operations.get(id);
if (!operation || operation.phase === "finished") return;
operation.progress = clamp01(progress);
operation.velocity = velocity;
if (operation.phase !== "preparing") operation.phase = "interactive";
}
/**
* Finalize a committed operation.
*
* A normal push parks its origin and collapses the temporary visual edge, so
* the target takes the origin's place in the visual graph. A connected
* (`persistAtRest`) push keeps the edge at progress 1 and leaves the origin
* exposed but inert. Replace and back remove the origin.
*
* @returns Whether the operation's nodes still existed to be committed.
*/
commit(id: number): boolean {
const operation = this.operations.get(id);
if (!operation) return false;
const origin = this.nodes.get(operation.originKey);
const target = this.nodes.get(operation.targetKey);
if (!origin || !target) return false;
if (operation.history === "push" && operation.choreography.persistAtRest) {
operation.progress = 1;
operation.velocity = 0;
operation.intent = "commit";
operation.phase = "finished";
origin.state = "exposed";
this.activate(target);
return true;
}
this.transferPosition(origin, target);
operation.phase = "finished";
this.operations.delete(operation.id);
this.suspendedEdges.delete(operation.id);
if (operation.history === "push") {
origin.incomingOperationId = undefined;
origin.state = "parked";
} else {
// Back pops the current entry; replace discards it in favor of the new
// target. Both preserve every earlier retained instance.
this.removeNode(origin.key);
}
this.activate(target);
return true;
}
/**
* Finalize a cancelled operation, restoring the origin.
*
* A cancelled back re-parks its retained target, or re-attaches the resting
* edge it suspended. A cancelled push/replace removes its new target branch.
*/
cancel(id: number): void {
const operation = this.operations.get(id);
if (!operation) return;
const origin = this.nodes.get(operation.originKey);
const target = this.nodes.get(operation.entryTargetKey);
const suspendedEdge = this.suspendedEdges.get(id);
operation.phase = "finished";
this.operations.delete(id);
this.suspendedEdges.delete(id);
if (operation.history === "back") {
if (suspendedEdge && origin && target) {
// Put the retained page back into its former position, then reconnect
// the resting page→drawer edge. Both nodes keep their VNodes.
this.transferPosition(origin, target);
this.operations.set(suspendedEdge.id, suspendedEdge);
origin.incomingOperationId = suspendedEdge.id;
target.state = "exposed";
} else if (target) {
target.incomingOperationId = undefined;
target.state = "parked";
}
} else {
this.removeBranch(operation.entryTargetKey);
}
if (origin) this.activate(origin);
}
// Transition steps --------------------------------------------------------
private assertCanOriginate(originKey: string): SceneNodeRecord {
const origin = this.nodes.get(originKey);
if (!origin)
throw new Error(`Cannot animate from missing origin "${originKey}".`);
if (origin.state === "parked" || origin.state === "exposed")
throw new Error(
`Cannot animate from ${origin.state} origin "${originKey}".`,
);
if (this.liveOutgoingOf(originKey))
throw new Error(
`Origin "${originKey}" already has an outgoing operation. ` +
"A descendant target may start its own operation instead.",
);
return origin;
}
/**
* Resolve the retained previous entry for a back operation.
*
* If the origin's incoming edge comes from that previous entry (a forward
* push still settling, or a connected resting edge), the edge is collapsed
* first. Creating a new edge back to its origin would otherwise form X→Y→X.
* Reciprocal choreographies begin at the same visual endpoints, so this
* handoff is continuous without remounting either node.
*/
private resolveBackTarget(origin: SceneNodeRecord): {
target: SceneNodeRecord;
suspendedEdge?: MutableOriginOperation;
} {
const previousKey = origin.previousNodeKey;
if (!previousKey)
throw new Error(
`Cannot go back from origin "${origin.key}" without a previous entry.`,
);
const target = this.nodes.get(previousKey);
if (!target)
throw new Error(
`Cannot go back to missing retained entry "${previousKey}".`,
);
let suspendedEdge: MutableOriginOperation | undefined;
const incoming = this.incomingOf(origin);
if (incoming?.originKey === target.key) {
if (incoming.intent !== "commit")
throw new Error(
"Cannot go back through an undecided forward operation.",
);
if (incoming.phase !== "finished") this.commit(incoming.id);
const resting = this.operations.get(incoming.id);
if (resting?.phase === "finished" && resting.choreography.persistAtRest) {
// Re-root A→page→drawer as A→drawer before adding drawer→page.
this.operations.delete(resting.id);
this.transferPosition(target, origin);
target.incomingOperationId = undefined;
suspendedEdge = resting;
}
}
if (this.liveOutgoingOf(target.key))
throw new Error(
`Retained target "${target.key}" already has an outgoing operation.`,
);
if (target.incomingOperationId)
throw new Error(
`Retained target "${target.key}" is already transitioning.`,
);
return { target, suspendedEdge };
}
/** Create the target node for a push or replace operation. */
private createForwardTarget(
origin: SceneNodeRecord,
action: OriginAction,
history: Exclude<OriginHistoryMode, "back">,
): SceneNodeRecord {
if (!action.target)
throw new Error(
`A "${history}" operation requires a target view recipe.`,
);
// A replacement skips the origin in the retained chain. The origin stays
// mounted until commit so an interactive replacement can still cancel.
const previousKey =
history === "push" ? origin.key : origin.previousNodeKey;
return this.createNode(action.target, previousKey);
}
private activate(node: SceneNodeRecord): void {
node.state = "active";
this.refreshPresentedState(node);
}
/**
* Recompute whether a presented node is participating in a live operation.
* Parked and exposed nodes keep their retained state.
*/
private refreshPresentedState(node: SceneNodeRecord): void {
if (node.state === "parked" || node.state === "exposed") return;
const incoming = this.incomingOf(node);
node.state =
(incoming && incoming.phase !== "finished") ||
this.liveOutgoingOf(node.key)
? "transitioning"
: "active";
}
// Structural primitives ---------------------------------------------------
private createNode(
view: OriginView,
previousNodeKey?: string,
): SceneNodeRecord {
const sequence = ++this.nodeSequence; const sequence = ++this.nodeSequence;
const node: SceneNodeRecord = shallowReactive({ const node: SceneNodeRecord = shallowReactive({
key: `${view.key ?? view.name ?? "view"}::${sequence}`, key: `${view.key ?? view.name ?? "view"}::${sequence}`,
@@ -138,7 +435,18 @@ export class SceneGraph {
return node; return node;
} }
createOperation(init: OperationInit): MutableOriginOperation { private createOperation(
init: Pick<
MutableOriginOperation,
| "originKey"
| "targetKey"
| "entryTargetKey"
| "choreography"
| "placement"
| "history"
| "originRect"
>,
): MutableOriginOperation {
const operation = reactive<MutableOriginOperation>({ const operation = reactive<MutableOriginOperation>({
id: ++this.operationSequence, id: ++this.operationSequence,
originKey: init.originKey, originKey: init.originKey,
@@ -157,7 +465,7 @@ export class SceneGraph {
return operation; return operation;
} }
removeNode(nodeKey: string): void { private removeNode(nodeKey: string): void {
this.nodes.delete(nodeKey); this.nodes.delete(nodeKey);
this.roots.value = this.roots.value.filter((key) => key !== nodeKey); this.roots.value = this.roots.value.filter((key) => key !== nodeKey);
} }
@@ -166,7 +474,7 @@ export class SceneGraph {
* Remove a node, every node whose retained history passes through it, and * Remove a node, every node whose retained history passes through it, and
* every operation touching any of them. Earlier entries are untouched. * every operation touching any of them. Earlier entries are untouched.
*/ */
removeBranch(nodeKey: string, visited = new Set<string>()): void { private removeBranch(nodeKey: string, visited = new Set<string>()): void {
if (visited.has(nodeKey)) return; if (visited.has(nodeKey)) return;
visited.add(nodeKey); visited.add(nodeKey);
@@ -188,7 +496,7 @@ export class SceneGraph {
* as the target of `from`'s incoming edge, or as a root. Neither Vue VNode * as the target of `from`'s incoming edge, or as a root. Neither Vue VNode
* moves; only edge bookkeeping changes. * moves; only edge bookkeeping changes.
*/ */
transferPosition(from: SceneNodeRecord, target: SceneNodeRecord): void { private transferPosition(from: SceneNodeRecord, target: SceneNodeRecord) {
const parent = this.incomingOf(from); const parent = this.incomingOf(from);
if (parent) { if (parent) {
parent.targetKey = target.key; parent.targetKey = target.key;
+496
View File
@@ -0,0 +1,496 @@
import {
computed,
inject,
markRaw,
nextTick,
type ComputedRef,
type CSSProperties,
type InjectionKey,
} from "vue";
import {
composeEffects,
normalizedEffect,
type OriginEffect,
type OriginEffectSet,
} from "../choreography";
import type { OriginAction, OriginView } from "../navigation";
import {
clamp,
clamp01,
measureRect,
prefersReducedMotion,
windowRect,
type OriginRect,
} from "../shared";
import { SceneGraph, shouldCommit, type SceneNodeRecord } from "./graph";
import type {
CreateOriginSceneOptions,
MutableOriginOperation,
OriginContext,
OriginFinishOptions,
OriginOperation,
OriginOperationHandle,
OriginScene,
OriginSceneNode,
} from "./types";
// Compositing ---------------------------------------------------------------
/**
* Compositor invariants rather than visual theming. They stay inline so a
* missing optional stylesheet can never place scene nodes back into normal
* block/flex flow and vertically stack views.
*
* Deliberately not frozen: it is spread once per node per frame, and V8
* spreads frozen objects markedly slower.
*/
const NODE_BASE_STYLE: Readonly<CSSProperties> = {
position: "absolute",
inset: "0",
width: "100%",
height: "100%",
overflow: "hidden",
};
/** Base z-index keeping every node above content outside the scene. */
const Z_INDEX_BASE = 1_000_000;
/** z-index distance between adjacent relative layers. */
const Z_INDEX_PER_LAYER = 10_000;
/**
* Effect layers applied to a node, ordered outermost frame first.
*
* A node inherits the frame and target effects of every edge on its incoming
* chain, then adds the frame and source effects of the edge it originates.
* Ancestors' source effects are intentionally excluded: only the initiating
* component receives the source side of an operation.
*
* @param viewport - Measures the scene bounds supplied to choreographies.
*/
export const nodeEffectLayers = (
graph: SceneGraph,
node: SceneNodeRecord,
viewport: () => OriginRect,
): OriginEffect[] => {
const effectsOf = (operation: MutableOriginOperation): OriginEffectSet =>
operation.choreography.effects({
progress: operation.progress,
velocity: operation.velocity,
phase: operation.phase,
intent: operation.intent,
originRect: operation.originRect,
targetRect: operation.targetRect,
viewport: viewport(),
});
const chain = graph.incomingChain(node);
// Evaluate choreographies node-first, then apply them outermost-first.
const effectSets = chain.map(effectsOf);
const layers: OriginEffect[] = [];
for (let index = chain.length - 1; index >= 0; index -= 1) {
const targetLayer = chain[index]!.placement === "above" ? 1 : -1;
layers.push(
normalizedEffect(effectSets[index]!.frame),
normalizedEffect(effectSets[index]!.target, targetLayer),
);
}
const outgoing = graph.presentedOutgoingOf(node.key);
if (outgoing) {
const effects = effectsOf(outgoing);
layers.push(
normalizedEffect(effects.frame),
normalizedEffect(effects.source),
);
}
return layers;
};
/** Collapse every effect layer applied to a node into its host's style. */
export const composeNodeStyle = (
graph: SceneGraph,
nodeKey: string,
viewport: () => OriginRect,
): CSSProperties => {
const node = graph.node(nodeKey);
if (!node) return {};
// Measure the scene at most once, and only when an edge needs it.
let measured: OriginRect | undefined;
const lazyViewport = () => (measured ??= viewport());
const { style, layer } = composeEffects(
nodeEffectLayers(graph, node, lazyViewport),
NODE_BASE_STYLE,
);
style.zIndex = Z_INDEX_BASE + layer * Z_INDEX_PER_LAYER + node.sequence;
style.pointerEvents = graph.isInteractive(nodeKey) ? "auto" : "none";
if (node.state === "parked") {
// Parked entries remain mounted in their original flat hosts. Keeping the
// DOM preserves local component state and nested scroll positions, while
// visibility/inert handling removes them from presentation.
style.visibility = "hidden";
style.contentVisibility = "hidden";
}
return style;
};
// Settling spring -----------------------------------------------------------
/*
* A damped spring settles an operation toward 0 or 1. It keeps release
* velocity continuous with pointer motion without imposing a fixed-duration
* easing on custom choreographies.
*/
const STIFFNESS = 280;
const DAMPING = 30;
/** Fixed integration step, in seconds. */
const STEP = 1 / 120;
/** Frame durations are clamped to this range, in seconds. */
const MIN_FRAME = 0.001;
const MAX_FRAME = 0.032;
/** Release velocities beyond this magnitude are clamped. */
const MAX_INITIAL_VELOCITY = 8;
const POSITION_TOLERANCE = 0.002;
const VELOCITY_TOLERANCE = 0.02;
/** Position and velocity of a spring, in progress units. */
export interface SpringState {
readonly position: number;
readonly velocity: number;
}
/**
* Advance a spring by one animation frame.
*
* @param elapsedMs - Time since the previous frame in milliseconds.
*/
export const stepSpring = (
state: SpringState,
target: number,
elapsedMs: number,
): SpringState & { readonly done: boolean } => {
const elapsed = clamp(elapsedMs / 1000, MIN_FRAME, MAX_FRAME);
const iterations = Math.max(1, Math.ceil(elapsed / STEP));
const dt = elapsed / iterations;
let { position, velocity } = state;
for (let index = 0; index < iterations; index += 1) {
const acceleration = (target - position) * STIFFNESS - velocity * DAMPING;
velocity += acceleration * dt;
position += velocity * dt;
}
const done =
Math.abs(target - position) < POSITION_TOLERANCE &&
Math.abs(velocity) < VELOCITY_TOLERANCE;
return { position, velocity, done };
};
/**
* Run a spring on `requestAnimationFrame` until it rests at `target` or
* `onFrame` returns `false` because its subject no longer exists.
*
* Every reported progress is clamped to `0..1`; the final frame reports
* exactly `target` with zero velocity.
*/
export const runSpring = (
from: SpringState,
target: number,
onFrame: (progress: number, velocity: number) => boolean,
): Promise<void> =>
new Promise<void>((resolve) => {
let state: SpringState = {
position: from.position,
velocity: clamp(
from.velocity,
-MAX_INITIAL_VELOCITY,
MAX_INITIAL_VELOCITY,
),
};
let previous = performance.now();
const frame = (time: number) => {
const next = stepSpring(state, target, time - previous);
previous = time;
state = next;
const alive = next.done
? onFrame(target, 0)
: onFrame(clamp01(next.position), next.velocity);
if (!alive || next.done) resolve();
else window.requestAnimationFrame(frame);
};
window.requestAnimationFrame(frame);
});
// Scene ---------------------------------------------------------------------
/** Imperative controller for one operation, returned by `Scene.begin()`. */
class OperationHandle implements OriginOperationHandle {
constructor(
private readonly scene: Scene,
readonly id: number,
readonly originKey: string,
readonly targetKey: string,
) {
markRaw(this);
}
update(progress: number, velocity = 0): void {
this.scene.graph.update(this.id, progress, velocity);
}
finish(options?: OriginFinishOptions): Promise<boolean> {
return this.scene.finish(this.id, options);
}
async cancel(options?: Pick<OriginFinishOptions, "animate">): Promise<void> {
await this.scene.finish(this.id, {
commit: false,
animate: options?.animate,
});
}
}
/**
* One independent scene: the {@link SceneGraph} model plus the timing around
* it — waiting for Vue to mount targets, measuring hosts, and settling
* released operations with a spring.
*/
class Scene implements OriginScene {
/** @internal */
readonly graph: SceneGraph;
readonly nodes: ComputedRef<readonly OriginSceneNode[]>;
readonly operations: ComputedRef<readonly OriginOperation[]>;
readonly roots: SceneGraph["roots"];
/** DOM registered by the renderer, used only for measurement. */
private readonly hosts = new Map<string, HTMLElement>();
private container: HTMLElement | null = null;
constructor(options: CreateOriginSceneOptions) {
// Never proxy the scene: its methods rely on raw internal state.
markRaw(this);
this.graph = new SceneGraph(
Array.isArray(options.initial) ? options.initial : [options.initial],
);
this.roots = this.graph.roots;
this.nodes = computed(() =>
[...this.graph.nodes.values()].map((node) => ({
...node,
history: this.graph.historyOf(node).map((entry) => entry.view),
})),
);
this.operations = computed(() =>
[...this.graph.operations.values()].filter(
(operation) => operation.phase !== "finished",
),
);
}
contextFor(nodeKey: string): OriginContext {
return this.graph.contextFor(nodeKey);
}
async begin(
originKey: string,
action: OriginAction,
): Promise<OriginOperationHandle> {
const operation = this.graph.begin(
originKey,
action,
this.measure(originKey),
);
const { 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 = this.measure(targetKey);
operation.phase = "interactive";
return new OperationHandle(this, id, originKey, targetKey);
}
async perform(originKey: string, action: OriginAction): Promise<boolean> {
const handle = await this.begin(originKey, action);
return handle.finish({ commit: true });
}
/**
* Fix an operation's outcome, settle it, and rewrite the graph.
*
* @internal Called through {@link OperationHandle}.
*/
async finish(id: number, options: OriginFinishOptions = {}) {
const operation = this.graph.operation(id);
if (!operation) return false;
if (operation.phase === "finished") return operation.intent === "commit";
// The outcome is fixed at release, so the target can originate another
// operation while this one is only visually settling.
const commit = shouldCommit(operation, options);
operation.intent = commit ? "commit" : "cancel";
operation.phase = "settling";
await this.settle(id, commit ? 1 : 0, options.animate ?? true);
// A later operation may already have finalized this edge.
if (!this.graph.operation(id)) return commit;
if (commit) return this.graph.commit(id);
this.graph.cancel(id);
return false;
}
registerElement(nodeKey: string, element: HTMLElement | null): void {
if (element) this.hosts.set(nodeKey, element);
else this.hosts.delete(nodeKey);
}
registerContainer(element: HTMLElement | null): void {
this.container = element;
}
styleForNode(nodeKey: string): CSSProperties {
return composeNodeStyle(this.graph, nodeKey, () => this.viewport());
}
isNodeInteractive(nodeKey: string): boolean {
return this.graph.isInteractive(nodeKey);
}
/** Move an operation to 0 or 1, animated unless motion is disabled. */
private async settle(id: number, target: 0 | 1, animate: boolean) {
const operation = this.graph.operation(id);
if (!operation) return;
if (!animate || prefersReducedMotion()) {
operation.progress = target;
operation.velocity = 0;
return;
}
const from = { position: operation.progress, velocity: operation.velocity };
await runSpring(from, target, (progress, velocity) => {
const live = this.graph.operation(id);
if (!live) return false;
live.progress = progress;
live.velocity = velocity;
return true;
});
}
private measure(nodeKey: string): OriginRect | undefined {
const host = this.hosts.get(nodeKey);
return host ? measureRect(host) : undefined;
}
/** Scene-container bounds, or the browser window before mounting. */
private viewport(): OriginRect {
return this.container ? measureRect(this.container) : windowRect();
}
}
/**
* Create one independent scene/history context.
*
* The implementation stores nodes flat. Operation edges describe how their
* visual effects compose, but a Vue component never moves to a different VNode
* parent as edges are added or removed. This is what preserves component state
* while X→Y and Y→Z overlap.
*
* @param options - Initial root view recipe or recipes.
* @returns A self-contained reactive scene. Render it through `OriginScene`.
*
* @example
* ```ts
* const scene = createOriginScene({
* initial: originView(HomeView, undefined, { key: "home" }),
* });
* ```
*/
export function createOriginScene(
options: CreateOriginSceneOptions,
): OriginScene {
return new Scene(options);
}
// Node-scoped access --------------------------------------------------------
/**
* Injection payload provided by each stable scene-node host.
*
* @internal
*/
export interface OriginNodeScope {
/** Scene containing the node. */
readonly scene: OriginScene;
/** Unique key of the mounted node. */
readonly nodeKey: string;
}
/** Node-scoped controls returned by {@link useOrigin}. */
export interface UseOrigin {
/** Key of the mounted node containing the calling component. */
readonly nodeKey: string;
/** Scene containing the calling component. */
readonly scene: OriginScene;
/** Reactive context for the calling component's scene node. */
readonly context: ComputedRef<OriginContext>;
/** Reactive shorthand for `context.value.view`. */
readonly view: ComputedRef<OriginView>;
/** Reactive shorthand for `context.value.previous`. */
readonly previous: ComputedRef<OriginView | undefined>;
/** Reactive shorthand for `context.value.canGoBack`. */
readonly canGoBack: ComputedRef<boolean>;
/** Begin an interactively controlled operation from this component. */
begin(action: OriginAction): Promise<OriginOperationHandle>;
/** Programmatically create and commit a target from this component. */
perform(action: OriginAction): Promise<boolean>;
}
/**
* Injection key used by the internal scene-node host to establish origin
* ownership for descendant components.
*
* Application code normally calls {@link useOrigin} instead of injecting this
* key directly.
*
* @internal
*/
export const originNodeScopeKey: InjectionKey<OriginNodeScope> =
Symbol("origin-node-scope");
/**
* Access the scene from the component that owns an interaction declaration.
*
* There is deliberately no `activeView`: the injected node is the origin
* because this component is where the event or application action occurred.
*
* @returns Node-scoped scene state and operation controls.
* @throws If called outside a component rendered by `OriginScene`.
*
* @example
* ```ts
* const origin = useOrigin();
*
* function openProfile() {
* return origin.perform(
* forward(originView(ProfileView), slideLeft),
* );
* }
* ```
*/
export function useOrigin(): UseOrigin {
const scope = inject(originNodeScopeKey);
if (!scope)
throw new Error("useOrigin() must be called inside an <OriginScene> view.");
const { scene, nodeKey } = scope;
const context = computed(() => scene.contextFor(nodeKey));
return {
nodeKey,
scene,
context,
view: computed(() => context.value.view),
previous: computed(() => context.value.previous),
canGoBack: computed(() => context.value.canGoBack),
begin: (action) => scene.begin(nodeKey, action),
perform: (action) => scene.perform(nodeKey, action),
};
}
-90
View File
@@ -1,90 +0,0 @@
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
@@ -1,278 +0,0 @@
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);
}
+14 -8
View File
@@ -1,16 +1,16 @@
import type { ComputedRef, CSSProperties, ShallowRef } from "vue"; import type { ComputedRef, CSSProperties, ShallowRef } from "vue";
import type {
OriginAction,
OriginHistoryMode,
OriginPlacement,
} from "../actions/types";
import type { import type {
OriginChoreography, OriginChoreography,
OriginOperationIntent, OriginOperationIntent,
OriginOperationPhase, OriginOperationPhase,
} from "../choreography/types"; } from "../choreography";
import type { OriginRect } from "../shared/geometry"; import type {
import type { OriginView } from "../view/types"; OriginAction,
OriginHistoryMode,
OriginPlacement,
OriginView,
} from "../navigation";
import type { OriginRect } from "../shared";
/** /**
* Visibility/lifecycle role of a mounted scene node. * Visibility/lifecycle role of a mounted scene node.
@@ -218,3 +218,9 @@ export interface MutableOriginOperation {
/** Measured target bounds, after the target mounted. */ /** Measured target bounds, after the target mounted. */
targetRect?: OriginRect; targetRect?: OriginRect;
} }
/** Public props accepted by the `OriginScene` renderer component. */
export interface OriginSceneProps {
/** Scene instance whose flat component nodes should be rendered. */
scene: OriginScene;
}
@@ -36,3 +36,17 @@ export function windowRect(): OriginRect {
height: browser ? window.innerHeight : 1, height: browser ? window.innerHeight : 1,
}; };
} }
/**
* Whether animation should resolve immediately.
*
* True outside a browser, when `requestAnimationFrame` is unavailable, or when
* the user prefers reduced motion.
*/
export function prefersReducedMotion(): boolean {
return (
typeof window === "undefined" ||
!window.requestAnimationFrame ||
Boolean(window.matchMedia?.("(prefers-reduced-motion: reduce)").matches)
);
}
@@ -1,13 +0,0 @@
/**
* 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)
);
}
-44
View File
@@ -1,44 +0,0 @@
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
@@ -1,43 +0,0 @@
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;
}