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:
+15
-15
@@ -294,23 +294,23 @@ component ancestry.
|
||||
|
||||
## 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 |
|
||||
| --------------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| `shared/` | Geometry and environment helpers |
|
||||
| `view/` | `originView()` component recipes |
|
||||
| `choreography/` | Effect types, `defineOriginChoreography()`, presets, and `composeEffects()` style compositing |
|
||||
| `actions/` | `forward()`, `back()`, `replace()`, `above()`, `under()`, `originAction()` |
|
||||
| `scene/` | `graph.ts` storage, `transitions.ts` history rules, `compositor.ts` styles, `spring.ts` settling motion |
|
||||
| `composables/` | `useOrigin()` and the node-scope injection key |
|
||||
| `gesture/` | Builder, definition→policy normalization, start admission, pointer tracking, and the recognizer |
|
||||
| `components/` | `OriginScene`, `OriginGesture`, `OriginGestureSurface`, and the internal node host |
|
||||
| File | Responsibility |
|
||||
| ----------------------- | ------------------------------------------------------------------------------------------------- |
|
||||
| `shared.ts` | Geometry and environment helpers |
|
||||
| `choreography.ts` | Effect types, `defineOriginChoreography()`, presets, and `composeEffects()` style compositing |
|
||||
| `navigation.ts` | `originView()` recipes and `forward()`, `back()`, `replace()`, `above()`, `under()` actions |
|
||||
| `scene/types.ts` | Public scene types |
|
||||
| `scene/graph.ts` | `SceneGraph`: node and edge storage plus the begin/commit/cancel history rules (all synchronous) |
|
||||
| `scene/scene.ts` | `Scene` and `OperationHandle`: mounting, measuring, settling spring, compositing, and `useOrigin` |
|
||||
| `gesture/definition.ts` | Gesture types, component props, and the immutable `gesture` builder |
|
||||
| `gesture/recognizer.ts` | Pointer-down admission, `DirectionalTracker`, `GestureRecognizer`, and `useOriginGesture` |
|
||||
| `components/` | `OriginScene`, `OriginGesture`, `OriginGestureSurface`, and the internal node host |
|
||||
|
||||
`createOriginScene()` wires the scene modules together: the graph holds nodes
|
||||
and operation edges, transitions rewrite it synchronously when operations
|
||||
begin, commit, or cancel, and the scene adds the asynchronous parts (waiting
|
||||
for Vue to mount a target, measuring, and running the settling spring).
|
||||
Pure helpers used by a class (for example `matchesStart`, `stepSpring`, or
|
||||
`composeNodeStyle`) are module-level `export const`s that the class calls, so
|
||||
they can be tested directly without widening the class's interface.
|
||||
|
||||
## Current experimental boundaries
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 },
|
||||
}),
|
||||
});
|
||||
@@ -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">
|
||||
import { useOriginGesture } from "../gesture/useOriginGesture";
|
||||
import type { OriginGestureProps } from "./types";
|
||||
import { useOriginGesture } from "../gesture/recognizer";
|
||||
import type { OriginGestureProps } from "../gesture/definition";
|
||||
|
||||
defineOptions({ name: "OriginGesture", inheritAttrs: false });
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { touchActionFor } from "../gesture/direction";
|
||||
import { useOriginGesture } from "../gesture/useOriginGesture";
|
||||
import type { OriginGestureSurfaceProps } from "./types";
|
||||
import type { OriginGestureSurfaceProps } from "../gesture/definition";
|
||||
import { touchActionFor, useOriginGesture } from "../gesture/recognizer";
|
||||
|
||||
defineOptions({ name: "OriginGestureSurface", inheritAttrs: false });
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
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";
|
||||
|
||||
defineOptions({ name: "OriginNodeHost" });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, ref, watchEffect } from "vue";
|
||||
import type { OriginSceneProps } from "./types";
|
||||
import type { OriginSceneProps } from "../scene/types";
|
||||
import OriginNodeHost from "./OriginNodeHost.vue";
|
||||
|
||||
defineOptions({ name: "OriginScene" });
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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" })),
|
||||
});
|
||||
+189
-6
@@ -1,11 +1,14 @@
|
||||
import type { CSSProperties } from "vue";
|
||||
import type {
|
||||
MaybeOriginAction,
|
||||
OriginNavigationIntent,
|
||||
} from "../actions/types";
|
||||
import type { OriginChoreography } from "../choreography/types";
|
||||
import type { OriginChoreography } from "../choreography";
|
||||
import type { MaybeOriginAction, OriginNavigationIntent } from "../navigation";
|
||||
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. */
|
||||
export type OriginGestureDirection = "left" | "right" | "up" | "down";
|
||||
@@ -288,3 +291,183 @@ export interface OriginGestureBinding {
|
||||
/** Pointer-cancel handler that abandons any captured operation. */
|
||||
readonly onPointercancel: () => void;
|
||||
}
|
||||
|
||||
// Component props ----------------------------------------------------------
|
||||
|
||||
/** Props shared by both `OriginGesture` declaration styles. */
|
||||
export interface OriginGestureBaseProps {
|
||||
/**
|
||||
* Native HTML tag rendered as the gesture host.
|
||||
*
|
||||
* @defaultValue `"div"`
|
||||
*/
|
||||
as?: string;
|
||||
}
|
||||
|
||||
/** Builder-definition props accepted by the `OriginGesture` component. */
|
||||
export interface OriginGestureDefinitionProps extends OriginGestureBaseProps {
|
||||
/** Immutable definition produced by the {@link gesture} builder. */
|
||||
gesture: OriginGestureDefinition;
|
||||
/** Builder definitions already contain direction. */
|
||||
direction?: never;
|
||||
/** Builder definitions already contain their start policy. */
|
||||
edge?: never;
|
||||
/** Builder definitions already contain recognition tuning. */
|
||||
threshold?: never;
|
||||
/** Builder definitions already contain their navigation factory. */
|
||||
action?: never;
|
||||
}
|
||||
|
||||
/** Legacy option props accepted by the `OriginGesture` component. */
|
||||
export interface OriginGestureLegacyProps extends OriginGestureBaseProps {
|
||||
/** Legacy component declarations do not provide a builder definition. */
|
||||
gesture?: never;
|
||||
/** Direction in which pointer movement advances operation progress. */
|
||||
direction: OriginGestureDirection;
|
||||
/**
|
||||
* Eligible pointer-down width in CSS pixels from the host's starting edge.
|
||||
* Omit it to allow the full component surface.
|
||||
*/
|
||||
edge?: number;
|
||||
/**
|
||||
* Directed movement required before capture.
|
||||
*
|
||||
* @defaultValue `8`
|
||||
*/
|
||||
threshold?: number;
|
||||
/** Node-local action factory invoked only after recognition succeeds. */
|
||||
action(context: OriginContext): MaybeOriginAction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Public props accepted by the `OriginGesture` convenience component.
|
||||
*
|
||||
* Prefer the builder-definition form. The legacy direction/action form remains
|
||||
* available for compatibility.
|
||||
*/
|
||||
export type OriginGestureProps =
|
||||
OriginGestureDefinitionProps | OriginGestureLegacyProps;
|
||||
|
||||
/** Public props accepted by the multi-definition gesture surface component. */
|
||||
export interface OriginGestureSurfaceProps {
|
||||
/**
|
||||
* Native HTML tag rendered as the shared gesture host.
|
||||
*
|
||||
* @defaultValue `"div"`
|
||||
*/
|
||||
as?: string;
|
||||
/**
|
||||
* Complete immutable definitions installed on the shared host.
|
||||
*
|
||||
* The surface adds no start, direction, completion, navigation, or animation
|
||||
* policy. Definitions should be created by the owning page component.
|
||||
*/
|
||||
gestures: readonly OriginGestureDefinition[];
|
||||
}
|
||||
|
||||
// Builder ------------------------------------------------------------------
|
||||
|
||||
/*
|
||||
* Immutable fluent builder. Each stage is a frozen object 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,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";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,12 +1,297 @@
|
||||
import type { OriginAction } from "../actions/types";
|
||||
import type { UseOrigin } from "../composables/useOrigin";
|
||||
import type { OriginAction, MaybeOriginAction } from "../navigation";
|
||||
import { useOrigin, type UseOrigin } from "../scene/scene";
|
||||
import type { OriginContext, OriginOperationHandle } from "../scene/types";
|
||||
import { measureRect, type OriginRect } from "../shared/geometry";
|
||||
import { isHorizontal } from "./direction";
|
||||
import type { RecognizerPolicy } from "./policy";
|
||||
import { isIgnoredTarget, matchesStart, pointOf } from "./start";
|
||||
import { DirectionalTracker } from "./tracker";
|
||||
import type { OriginGesturePoint } from "./types";
|
||||
import { clamp01, measureRect, type OriginRect } from "../shared";
|
||||
import type {
|
||||
OriginGestureBinding,
|
||||
OriginGestureCompletionPredicate,
|
||||
OriginGestureDefinition,
|
||||
OriginGestureDirection,
|
||||
OriginGestureDistance,
|
||||
OriginGestureOptions,
|
||||
OriginGesturePoint,
|
||||
OriginGestureStart,
|
||||
} from "./definition";
|
||||
|
||||
/*
|
||||
* Running gestures: pointer-down admission, movement tracking, and the
|
||||
* recognizer that turns one host's pointer events into a scene operation.
|
||||
*/
|
||||
|
||||
// Direction ----------------------------------------------------------------
|
||||
|
||||
export const isHorizontal = (direction: OriginGestureDirection): boolean => {
|
||||
return direction === "left" || direction === "right";
|
||||
};
|
||||
|
||||
/** Component of a movement `(dx, dy)` along `direction`. */
|
||||
export const directedDistance = (
|
||||
direction: OriginGestureDirection,
|
||||
dx: number,
|
||||
dy: number,
|
||||
): number => {
|
||||
switch (direction) {
|
||||
case "left":
|
||||
return -dx;
|
||||
case "right":
|
||||
return dx;
|
||||
case "up":
|
||||
return -dy;
|
||||
case "down":
|
||||
return dy;
|
||||
}
|
||||
};
|
||||
|
||||
/** Absolute movement perpendicular to `direction`. */
|
||||
export const crossDistance = (
|
||||
direction: OriginGestureDirection,
|
||||
dx: number,
|
||||
dy: number,
|
||||
): number => {
|
||||
return isHorizontal(direction) ? Math.abs(dy) : Math.abs(dx);
|
||||
};
|
||||
|
||||
/**
|
||||
* CSS `touch-action` for a host recognizing the given directions: native
|
||||
* scrolling is preserved on any axis no gesture claims.
|
||||
*/
|
||||
export const touchActionFor = (
|
||||
directions: Iterable<OriginGestureDirection>,
|
||||
): "none" | "pan-x" | "pan-y" | "auto" => {
|
||||
let horizontal = false;
|
||||
let vertical = false;
|
||||
for (const direction of directions) {
|
||||
if (isHorizontal(direction)) horizontal = true;
|
||||
else vertical = true;
|
||||
}
|
||||
if (horizontal && vertical) return "none";
|
||||
if (horizontal) return "pan-y";
|
||||
if (vertical) return "pan-x";
|
||||
return "auto";
|
||||
};
|
||||
|
||||
// Pointer-down admission ----------------------------------------------------
|
||||
|
||||
/*
|
||||
* Pointer-down admission: which events may begin a gesture at all.
|
||||
*/
|
||||
|
||||
const IGNORED_TARGETS =
|
||||
'[data-origin-gesture="ignore"], input, textarea, select, option, [contenteditable="true"]';
|
||||
|
||||
/** Form controls and opted-out subtrees never start gestures. */
|
||||
export const isIgnoredTarget = (target: EventTarget | null): boolean => {
|
||||
return (
|
||||
!(target instanceof Element) || Boolean(target.closest(IGNORED_TARGETS))
|
||||
);
|
||||
};
|
||||
|
||||
export const pointOf = (
|
||||
event: Pick<PointerEvent, "clientX" | "clientY">,
|
||||
bounds: OriginRect,
|
||||
): OriginGesturePoint => {
|
||||
return {
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
localX: event.clientX - bounds.left,
|
||||
localY: event.clientY - bounds.top,
|
||||
};
|
||||
};
|
||||
|
||||
const nonNegative = (value: number) =>
|
||||
Number.isFinite(value) ? Math.max(0, value) : 0;
|
||||
|
||||
/**
|
||||
* Resolve an arbitrary CSS length against a box with the gesture host's size.
|
||||
*
|
||||
* A short-lived off-screen box lets the browser handle `rem`, viewport units,
|
||||
* percentages, `calc()`, and `clamp()` consistently. This runs only during
|
||||
* pointer-down for edge-constrained definitions.
|
||||
*/
|
||||
export const resolveCssDistance = (
|
||||
distance: OriginGestureDistance,
|
||||
axis: "horizontal" | "vertical",
|
||||
host: HTMLElement,
|
||||
bounds: OriginRect,
|
||||
): number => {
|
||||
if (typeof distance === "number") return nonNegative(distance);
|
||||
|
||||
const document = host.ownerDocument;
|
||||
if (!document.body) return Math.max(0, Number.parseFloat(distance) || 0);
|
||||
|
||||
const container = document.createElement("div");
|
||||
const probe = document.createElement("div");
|
||||
Object.assign(container.style, {
|
||||
position: "fixed",
|
||||
left: "-100000px",
|
||||
top: "-100000px",
|
||||
width: `${bounds.width}px`,
|
||||
height: `${bounds.height}px`,
|
||||
visibility: "hidden",
|
||||
pointerEvents: "none",
|
||||
contain: "strict",
|
||||
});
|
||||
Object.assign(probe.style, {
|
||||
position: "absolute",
|
||||
width: axis === "horizontal" ? distance : "0",
|
||||
height: axis === "vertical" ? distance : "0",
|
||||
});
|
||||
container.append(probe);
|
||||
document.body.append(container);
|
||||
const rect = probe.getBoundingClientRect();
|
||||
container.remove();
|
||||
return nonNegative(axis === "horizontal" ? rect.width : rect.height);
|
||||
};
|
||||
|
||||
/** Whether a pointer-down satisfies a definition's start policy. */
|
||||
export const matchesStart = (
|
||||
start: OriginGestureStart,
|
||||
event: PointerEvent,
|
||||
host: HTMLElement,
|
||||
origin: OriginContext,
|
||||
bounds: OriginRect,
|
||||
): boolean => {
|
||||
if (start.kind === "anywhere") return true;
|
||||
const point = pointOf(event, bounds);
|
||||
if (start.kind === "when")
|
||||
return start.predicate({ event, origin, host, bounds, point });
|
||||
|
||||
const horizontal = start.edge === "left" || start.edge === "right";
|
||||
const distance = resolveCssDistance(
|
||||
start.distance,
|
||||
horizontal ? "horizontal" : "vertical",
|
||||
host,
|
||||
bounds,
|
||||
);
|
||||
switch (start.edge) {
|
||||
case "left":
|
||||
return point.localX >= 0 && point.localX <= distance;
|
||||
case "right":
|
||||
return (
|
||||
point.localX <= bounds.width && bounds.width - point.localX <= distance
|
||||
);
|
||||
case "top":
|
||||
return point.localY >= 0 && point.localY <= distance;
|
||||
case "bottom":
|
||||
return (
|
||||
point.localY <= bounds.height &&
|
||||
bounds.height - point.localY <= distance
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Movement tracking ---------------------------------------------------------
|
||||
|
||||
type PointerSample = Pick<PointerEvent, "clientX" | "clientY" | "timeStamp">;
|
||||
|
||||
/** Shortest frame interval assumed when computing velocity, in ms. */
|
||||
const MIN_SAMPLE_INTERVAL = 8;
|
||||
/**
|
||||
* A release without movement after this many ms is treated as a hold, so the
|
||||
* last move's flick velocity no longer applies.
|
||||
*/
|
||||
const HOLD_RELEASE_INTERVAL = 80;
|
||||
|
||||
/**
|
||||
* Measures one pointer sequence's movement along a gesture direction.
|
||||
*
|
||||
* Progress and velocity are normalized by the host's size along the gesture
|
||||
* axis, so `1` means one full host width (or height).
|
||||
*/
|
||||
export class DirectionalTracker {
|
||||
/** Directed distance from the start, in CSS pixels. */
|
||||
distance = 0;
|
||||
/** Absolute cross-axis distance from the start, in CSS pixels. */
|
||||
crossDistance = 0;
|
||||
/** Directed distance as a fraction of host size, clamped to `0..1`. */
|
||||
progress = 0;
|
||||
/** Latest directed velocity in progress units per second. */
|
||||
velocity = 0;
|
||||
|
||||
private readonly horizontal: boolean;
|
||||
/** Whether progress grows as the axis coordinate decreases. */
|
||||
private readonly reversed: boolean;
|
||||
private lastCoordinate: number;
|
||||
private lastTime: number;
|
||||
|
||||
constructor(
|
||||
private readonly direction: OriginGestureDirection,
|
||||
private readonly start: PointerSample,
|
||||
/** Host size along the gesture axis, read at every sample. */
|
||||
private readonly axisSize: () => number,
|
||||
) {
|
||||
this.horizontal = isHorizontal(direction);
|
||||
this.reversed = direction === "left" || direction === "up";
|
||||
this.lastCoordinate = this.coordinateOf(start);
|
||||
this.lastTime = start.timeStamp;
|
||||
}
|
||||
|
||||
private coordinateOf(event: PointerSample) {
|
||||
return this.horizontal ? event.clientX : event.clientY;
|
||||
}
|
||||
|
||||
/** Movement from the start without recording a sample. */
|
||||
displacement(event: PointerSample) {
|
||||
const dx = event.clientX - this.start.clientX;
|
||||
const dy = event.clientY - this.start.clientY;
|
||||
return {
|
||||
distance: directedDistance(this.direction, dx, dy),
|
||||
crossDistance: crossDistance(this.direction, dx, dy),
|
||||
};
|
||||
}
|
||||
|
||||
/** Record a sample, updating distance, progress, and velocity. */
|
||||
sample(event: PointerSample, release = false): void {
|
||||
const { distance, crossDistance } = this.displacement(event);
|
||||
this.distance = distance;
|
||||
this.crossDistance = crossDistance;
|
||||
|
||||
const size = Math.max(1, this.axisSize());
|
||||
const coordinate = this.coordinateOf(event);
|
||||
const step = this.reversed
|
||||
? this.lastCoordinate - coordinate
|
||||
: coordinate - this.lastCoordinate;
|
||||
const interval = event.timeStamp - this.lastTime;
|
||||
this.progress = clamp01(distance / size);
|
||||
// Pointer-up commonly repeats the final pointer-move coordinate. Preserve
|
||||
// that move's flick velocity for a prompt release, but decay it when the
|
||||
// pointer was held still long enough for the flick to have ended.
|
||||
if (!release || step !== 0 || interval > HOLD_RELEASE_INTERVAL)
|
||||
this.velocity =
|
||||
(step * 1000) / (Math.max(MIN_SAMPLE_INTERVAL, interval) * size);
|
||||
|
||||
this.lastCoordinate = coordinate;
|
||||
this.lastTime = event.timeStamp;
|
||||
}
|
||||
}
|
||||
|
||||
// Recognizer ----------------------------------------------------------------
|
||||
|
||||
/** Directed movement in CSS pixels required before a gesture captures. */
|
||||
const DEFAULT_THRESHOLD = 8;
|
||||
/** Ratio by which directed movement must exceed cross-axis movement. */
|
||||
const DEFAULT_AXIS_DOMINANCE = 1.15;
|
||||
|
||||
/** Legacy `edge` start side for each movement direction. */
|
||||
const LEGACY_START_EDGE = {
|
||||
left: "right",
|
||||
right: "left",
|
||||
up: "bottom",
|
||||
down: "top",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* The single shape the recognizer executes. Builder definitions and legacy
|
||||
* options objects are both normalized into it.
|
||||
*/
|
||||
export interface RecognizerPolicy {
|
||||
readonly direction: OriginGestureDirection;
|
||||
readonly start: OriginGestureStart;
|
||||
readonly threshold: number;
|
||||
readonly axisDominance: number;
|
||||
readonly completion?: OriginGestureCompletionPredicate;
|
||||
readonly action: (context: OriginContext) => MaybeOriginAction;
|
||||
}
|
||||
|
||||
/** The origin capabilities a recognizer needs. */
|
||||
export type RecognizerOrigin = Pick<UseOrigin, "context" | "begin">;
|
||||
@@ -49,10 +334,77 @@ export class GestureRecognizer {
|
||||
private session: PointerSession | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly policy: RecognizerPolicy,
|
||||
readonly policy: RecognizerPolicy,
|
||||
private readonly origin: RecognizerOrigin,
|
||||
) {}
|
||||
|
||||
/** Create a recognizer for a builder definition or legacy options. */
|
||||
static create(
|
||||
definition: OriginGestureDefinition | OriginGestureOptions,
|
||||
origin: RecognizerOrigin,
|
||||
): GestureRecognizer {
|
||||
return new GestureRecognizer(
|
||||
GestureRecognizer.policyFor(definition),
|
||||
origin,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a builder definition or legacy options object into the single
|
||||
* policy shape the recognizer executes.
|
||||
*/
|
||||
static policyFor(
|
||||
definition: OriginGestureDefinition | OriginGestureOptions,
|
||||
): RecognizerPolicy {
|
||||
return "kind" in definition &&
|
||||
definition.kind === "origin-gesture-definition"
|
||||
? GestureRecognizer.policyFromDefinition(definition)
|
||||
: GestureRecognizer.policyFromOptions(definition as OriginGestureOptions);
|
||||
}
|
||||
|
||||
private static policyFromDefinition(
|
||||
definition: OriginGestureDefinition,
|
||||
): RecognizerPolicy {
|
||||
return {
|
||||
direction: definition.direction,
|
||||
start: definition.start,
|
||||
threshold: definition.recognition.threshold ?? DEFAULT_THRESHOLD,
|
||||
axisDominance:
|
||||
definition.recognition.axisDominance ?? DEFAULT_AXIS_DOMINANCE,
|
||||
completion: definition.completion,
|
||||
// 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 {
|
||||
const host = event.currentTarget;
|
||||
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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -5,50 +5,60 @@
|
||||
* origin-relative operation frames. It does not depend on Vue Router or choose
|
||||
* a globally active view.
|
||||
*
|
||||
* Layers, from the bottom up:
|
||||
* Modules, from the bottom up:
|
||||
*
|
||||
* - `view/` component recipes (`originView`)
|
||||
* - `choreography/` visual effects, presets, and effect compositing
|
||||
* - `actions/` navigation intents and actions (`forward`, `back`, …)
|
||||
* - `scene/` graph storage, transitions, compositor, settling spring
|
||||
* - `composables/` node-scoped access to the owning scene (`useOrigin`)
|
||||
* - `gesture/` builder, pointer tracking, and recognition
|
||||
* - `components/` Vue renderers and gesture hosts
|
||||
* - `shared` geometry and environment helpers
|
||||
* - `choreography` visual effects, presets, and effect compositing
|
||||
* - `navigation` view recipes, intents, and actions (`forward`, …)
|
||||
* - `scene/graph` the scene model and its history rules
|
||||
* - `scene/scene` timing, measuring, compositing, `useOrigin`
|
||||
* - `gesture/definition` gesture types and the immutable builder
|
||||
* - `gesture/recognizer` pointer admission, tracking, and recognition
|
||||
* - `components/` Vue renderers and gesture hosts
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
export type { OriginRect } from "./shared/geometry";
|
||||
export type { OriginRect } from "./shared";
|
||||
|
||||
export type * from "./view/types";
|
||||
export { originView } from "./view/originView";
|
||||
export type {
|
||||
OriginChoreography,
|
||||
OriginChoreographyContext,
|
||||
OriginEffect,
|
||||
OriginEffectSet,
|
||||
OriginOperationIntent,
|
||||
OriginOperationPhase,
|
||||
} from "./choreography";
|
||||
export {
|
||||
defineOriginChoreography,
|
||||
fade,
|
||||
normalizedEffect,
|
||||
slideLeft,
|
||||
slideRight,
|
||||
} from "./choreography";
|
||||
|
||||
export type * from "./choreography/types";
|
||||
export { defineOriginChoreography } from "./choreography/define";
|
||||
export { normalizedEffect } from "./choreography/compose";
|
||||
export { fade, slideLeft, slideRight } from "./choreography/presets";
|
||||
|
||||
export type * from "./actions/types";
|
||||
export type * from "./navigation";
|
||||
export {
|
||||
above,
|
||||
back,
|
||||
forward,
|
||||
originAction,
|
||||
originView,
|
||||
replace,
|
||||
under,
|
||||
} from "./actions/actions";
|
||||
} from "./navigation";
|
||||
|
||||
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 { originNodeScopeKey, useOrigin } from "./composables/useOrigin";
|
||||
|
||||
export type * from "./gesture/types";
|
||||
export { gesture } from "./gesture/builder";
|
||||
export { useOriginGesture } from "./gesture/useOriginGesture";
|
||||
|
||||
export type * from "./components/types";
|
||||
export type * from "./gesture/definition";
|
||||
export { gesture } from "./gesture/definition";
|
||||
export { useOriginGesture } from "./gesture/recognizer";
|
||||
|
||||
/**
|
||||
* Convenience component that binds one `useOriginGesture()` recognizer to a
|
||||
|
||||
@@ -1,15 +1,198 @@
|
||||
import { isChoreography } from "../choreography/define";
|
||||
import type { OriginChoreography } from "../choreography/types";
|
||||
import type { OriginView } from "../view/types";
|
||||
import type {
|
||||
OriginAction,
|
||||
OriginActionOptions,
|
||||
OriginHistoryMode,
|
||||
OriginNavigationActionOptions,
|
||||
OriginNavigationIntent,
|
||||
OriginPlacement,
|
||||
OriginPlacementActionOptions,
|
||||
} from "./types";
|
||||
import { markRaw, type Component } from "vue";
|
||||
import { isChoreography, type OriginChoreography } from "./choreography";
|
||||
|
||||
/*
|
||||
* Navigation vocabulary: view recipes (what to mount) and the intents and
|
||||
* actions (how history changes) that operations execute.
|
||||
*/
|
||||
|
||||
/** Props accepted by a Vue component definition. */
|
||||
export type PropsOf<T> = T extends abstract new (...args: any[]) => {
|
||||
$props: infer P;
|
||||
}
|
||||
? P
|
||||
: T extends (props: infer P, ...args: any[]) => any
|
||||
? P
|
||||
: never;
|
||||
|
||||
/**
|
||||
* A view is a recipe for creating a Vue component, not a mounted instance.
|
||||
*
|
||||
* Forward navigation uses the recipe to create a mounted history entry. That
|
||||
* instance remains mounted until a committed back operation pops it.
|
||||
*
|
||||
* @typeParam T - The component mounted by the recipe.
|
||||
*/
|
||||
export interface OriginView<T extends Component = Component> {
|
||||
/** The Vue component definition that will be mounted for this recipe. */
|
||||
readonly component: T;
|
||||
/** Props passed to the component when the recipe is mounted. */
|
||||
readonly props?: Readonly<PropsOf<T>>;
|
||||
/**
|
||||
* A developer-facing recipe identity used in diagnostics and as the prefix
|
||||
* of generated scene-node keys. It does not make Vue reuse an instance.
|
||||
*/
|
||||
readonly key?: string;
|
||||
/** A human-readable label used by diagnostics and DOM data attributes. */
|
||||
readonly name?: string;
|
||||
}
|
||||
|
||||
/** Optional developer-facing metadata for an {@link OriginView} recipe. */
|
||||
export interface OriginViewOptions {
|
||||
/**
|
||||
* Stable recipe identity used in diagnostics and generated node-key prefixes.
|
||||
* It does not preserve or reuse a mounted Vue component instance.
|
||||
*/
|
||||
key?: string;
|
||||
/** Human-readable label used by inspectors and DOM data attributes. */
|
||||
name?: string;
|
||||
}
|
||||
|
||||
let viewSequence = 0;
|
||||
|
||||
function componentName(component: Component): string | undefined {
|
||||
return typeof component === "object" && "name" in component
|
||||
? String(component.name)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a component and props into a lightweight, reusable view recipe.
|
||||
*
|
||||
* Components are marked raw so Vue never attempts to proxy their definitions
|
||||
* when recipes are placed in reactive scene/history structures.
|
||||
*
|
||||
* @param component - Vue component definition to mount.
|
||||
* @param props - Props captured by the recipe.
|
||||
* @param options - Optional recipe identity and diagnostic name.
|
||||
* @returns An immutable, reusable component recipe. It is not a Vue instance.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const profile = originView(
|
||||
* ProfileView,
|
||||
* { userId: "42" },
|
||||
* { key: "profile-42", name: "Profile" },
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export function originView<T extends Component = Component>(
|
||||
component: T,
|
||||
props?: PropsOf<T>,
|
||||
options: OriginViewOptions = {},
|
||||
): OriginView<T> {
|
||||
const name = options.name ?? componentName(component);
|
||||
return markRaw({
|
||||
component: markRaw(component),
|
||||
props,
|
||||
key: options.key ?? `${name ?? "view"}-${++viewSequence}`,
|
||||
name,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Controls how an operation participates in retained instance history.
|
||||
*
|
||||
* - `push`: create a target whose previous entry is the mounted origin.
|
||||
* - `replace`: create a target that inherits the origin's previous entry, then
|
||||
* remove the origin on commit.
|
||||
* - `back`: reuse the mounted previous entry and pop the origin on commit.
|
||||
*/
|
||||
export type OriginHistoryMode = "push" | "replace" | "back";
|
||||
|
||||
/**
|
||||
* The target's stacking relationship to its origin while an operation exists.
|
||||
*/
|
||||
export type OriginPlacement = "above" | "under";
|
||||
|
||||
/**
|
||||
* A scene mutation without visual choreography.
|
||||
*
|
||||
* Gesture builders keep navigation intent separate from animation so the same
|
||||
* destination can be paired with different component-local interactions.
|
||||
* Calling `.animate()` materializes this intent as an {@link OriginAction}.
|
||||
*/
|
||||
export interface OriginNavigationIntent {
|
||||
/**
|
||||
* Recipe for a newly created push or replacement target. Back navigation
|
||||
* omits this because the scene resolves the retained previous instance.
|
||||
*/
|
||||
readonly target?: OriginView;
|
||||
/** Target stacking relationship while the gesture operation is visible. */
|
||||
readonly placement?: OriginPlacement;
|
||||
/** Retained-history mutation performed if the gesture commits. */
|
||||
readonly history: OriginHistoryMode;
|
||||
}
|
||||
|
||||
/** A complete request to create and animate a target view from an origin. */
|
||||
export interface OriginAction {
|
||||
/**
|
||||
* Recipe for a newly created push or replacement target. A back action omits
|
||||
* this because the scene resolves its already-mounted previous entry.
|
||||
*/
|
||||
readonly target?: OriginView;
|
||||
/** Visual relationship applied to the source, target, and shared frame. */
|
||||
readonly choreography: OriginChoreography;
|
||||
/**
|
||||
* Target stacking relationship during the operation.
|
||||
*
|
||||
* @defaultValue `"above"`
|
||||
*/
|
||||
readonly placement?: OriginPlacement;
|
||||
/**
|
||||
* History mutation applied to the target recipe.
|
||||
*
|
||||
* @defaultValue `"push"`
|
||||
*/
|
||||
readonly history?: OriginHistoryMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Values accepted from gesture action factories.
|
||||
*
|
||||
* Returning `null` or `undefined` declines the recognized gesture. A promise
|
||||
* allows lazy target resolution; stale results are discarded after release or
|
||||
* cancellation.
|
||||
*/
|
||||
export type MaybeOriginAction =
|
||||
OriginAction | null | undefined | Promise<OriginAction | null | undefined>;
|
||||
|
||||
/** Options accepted by {@link originAction}. */
|
||||
export interface OriginActionOptions {
|
||||
/**
|
||||
* Target stacking relationship during the operation.
|
||||
*
|
||||
* @defaultValue `"above"`
|
||||
*/
|
||||
placement?: OriginPlacement;
|
||||
/**
|
||||
* History mutation assigned to the created target node.
|
||||
*
|
||||
* @defaultValue `"push"`
|
||||
*/
|
||||
history?: OriginHistoryMode;
|
||||
}
|
||||
|
||||
/** History options shared by the {@link above} and {@link under} helpers. */
|
||||
export interface OriginPlacementActionOptions {
|
||||
/**
|
||||
* History mutation assigned to the created target node.
|
||||
*
|
||||
* @defaultValue `"push"`
|
||||
*/
|
||||
history?: OriginHistoryMode;
|
||||
}
|
||||
|
||||
/** Stacking options accepted by retained-history navigation helpers. */
|
||||
export interface OriginNavigationActionOptions {
|
||||
/**
|
||||
* Target stacking relationship during the operation.
|
||||
*
|
||||
* @defaultValue `"above"` for {@link forward} and {@link replace}, `"under"`
|
||||
* for {@link back}
|
||||
*/
|
||||
placement?: OriginPlacement;
|
||||
}
|
||||
|
||||
/*
|
||||
* Every helper accepts either `(…, options?)`, producing an animation-free
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -1,8 +1,14 @@
|
||||
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 {
|
||||
MutableOriginOperation,
|
||||
OriginContext,
|
||||
OriginFinishOptions,
|
||||
OriginSceneNodeState,
|
||||
} from "./types";
|
||||
|
||||
@@ -18,14 +24,34 @@ export interface SceneNodeRecord {
|
||||
incomingOperationId?: number;
|
||||
}
|
||||
|
||||
/** Fields supplied when a new operation edge is created. */
|
||||
export type OperationInit = Omit<
|
||||
MutableOriginOperation,
|
||||
"id" | "progress" | "velocity" | "phase" | "intent" | "targetRect"
|
||||
>;
|
||||
/**
|
||||
* Progress at which a release commits when the choreography does not supply
|
||||
* its own `commitThreshold`.
|
||||
*/
|
||||
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:
|
||||
*
|
||||
@@ -36,8 +62,8 @@ export type OperationInit = Omit<
|
||||
* Nodes without an incoming edge are listed in `roots`.
|
||||
*
|
||||
* Nodes are stored flat so Vue never re-parents a component when edges are
|
||||
* added, spliced, or removed. This class owns structural primitives only;
|
||||
* navigation semantics live in `transitions.ts`.
|
||||
* added, spliced, or removed. Every method is synchronous; timing (mounting,
|
||||
* measuring, settling) belongs to `Scene`.
|
||||
*/
|
||||
export class SceneGraph {
|
||||
readonly nodes = shallowReactive(new Map<string, SceneNodeRecord>());
|
||||
@@ -49,8 +75,7 @@ export class SceneGraph {
|
||||
* Connected resting edges temporarily removed by a back operation, keyed by
|
||||
* that back operation's id. Cancelling the back operation restores the edge.
|
||||
*/
|
||||
readonly suspendedEdges = new Map<number, MutableOriginOperation>();
|
||||
|
||||
private readonly suspendedEdges = new Map<number, MutableOriginOperation>();
|
||||
private nodeSequence = 0;
|
||||
private operationSequence = 0;
|
||||
|
||||
@@ -75,6 +100,21 @@ export class SceneGraph {
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** Incoming edges positioning `node`, from the node outward to its root. */
|
||||
incomingChain(node: SceneNodeRecord): MutableOriginOperation[] {
|
||||
const chain: MutableOriginOperation[] = [];
|
||||
const visited = new Set<string>();
|
||||
let current: SceneNodeRecord | undefined = node;
|
||||
while (current && !visited.has(current.key)) {
|
||||
visited.add(current.key);
|
||||
const incoming = this.incomingOf(current);
|
||||
if (!incoming) break;
|
||||
chain.push(incoming);
|
||||
current = this.nodes.get(incoming.originKey);
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
|
||||
/** The unfinished operation originated by a node, if any. */
|
||||
liveOutgoingOf(nodeKey: string): MutableOriginOperation | undefined {
|
||||
for (const operation of this.operations.values())
|
||||
@@ -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 node: SceneNodeRecord = shallowReactive({
|
||||
key: `${view.key ?? view.name ?? "view"}::${sequence}`,
|
||||
@@ -138,7 +435,18 @@ export class SceneGraph {
|
||||
return node;
|
||||
}
|
||||
|
||||
createOperation(init: OperationInit): MutableOriginOperation {
|
||||
private createOperation(
|
||||
init: Pick<
|
||||
MutableOriginOperation,
|
||||
| "originKey"
|
||||
| "targetKey"
|
||||
| "entryTargetKey"
|
||||
| "choreography"
|
||||
| "placement"
|
||||
| "history"
|
||||
| "originRect"
|
||||
>,
|
||||
): MutableOriginOperation {
|
||||
const operation = reactive<MutableOriginOperation>({
|
||||
id: ++this.operationSequence,
|
||||
originKey: init.originKey,
|
||||
@@ -157,7 +465,7 @@ export class SceneGraph {
|
||||
return operation;
|
||||
}
|
||||
|
||||
removeNode(nodeKey: string): void {
|
||||
private removeNode(nodeKey: string): void {
|
||||
this.nodes.delete(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
|
||||
* 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;
|
||||
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
|
||||
* moves; only edge bookkeeping changes.
|
||||
*/
|
||||
transferPosition(from: SceneNodeRecord, target: SceneNodeRecord): void {
|
||||
private transferPosition(from: SceneNodeRecord, target: SceneNodeRecord) {
|
||||
const parent = this.incomingOf(from);
|
||||
if (parent) {
|
||||
parent.targetKey = target.key;
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
import type { ComputedRef, CSSProperties, ShallowRef } from "vue";
|
||||
import type {
|
||||
OriginAction,
|
||||
OriginHistoryMode,
|
||||
OriginPlacement,
|
||||
} from "../actions/types";
|
||||
import type {
|
||||
OriginChoreography,
|
||||
OriginOperationIntent,
|
||||
OriginOperationPhase,
|
||||
} from "../choreography/types";
|
||||
import type { OriginRect } from "../shared/geometry";
|
||||
import type { OriginView } from "../view/types";
|
||||
} from "../choreography";
|
||||
import type {
|
||||
OriginAction,
|
||||
OriginHistoryMode,
|
||||
OriginPlacement,
|
||||
OriginView,
|
||||
} from "../navigation";
|
||||
import type { OriginRect } from "../shared";
|
||||
|
||||
/**
|
||||
* Visibility/lifecycle role of a mounted scene node.
|
||||
@@ -218,3 +218,9 @@ export interface MutableOriginOperation {
|
||||
/** Measured target bounds, after the target mounted. */
|
||||
targetRect?: OriginRect;
|
||||
}
|
||||
|
||||
/** Public props accepted by the `OriginScene` renderer component. */
|
||||
export interface OriginSceneProps {
|
||||
/** Scene instance whose flat component nodes should be rendered. */
|
||||
scene: OriginScene;
|
||||
}
|
||||
|
||||
@@ -36,3 +36,17 @@ export function windowRect(): OriginRect {
|
||||
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)
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user