V2: Origin based animations, Gesture Builder, New Demo, non-url-based-routing. Massive improvements.

This commit is contained in:
2026-07-25 05:57:26 +00:00
parent 5a514906eb
commit 55dad11b25
49 changed files with 8885 additions and 1 deletions

View File

@@ -0,0 +1,41 @@
<script setup lang="ts">
import { computed } from "vue";
import { useOriginGesture } from "../gesture";
import type { OriginGestureProps } from "../types";
defineOptions({ name: "OriginGesture", inheritAttrs: false });
const props = withDefaults(defineProps<OriginGestureProps>(), {
as: "div",
});
/*
* This convenience component makes the declaration live exactly where the
* developer writes it. `useOriginGesture()` is also public for components that
* cannot accept an extra wrapper element.
*/
const gesture = useOriginGesture(
props.gesture ?? {
direction: props.direction,
edge: props.edge,
threshold: props.threshold,
action: (context) => props.action?.(context),
},
);
const touchStyle = computed(() => gesture.style);
</script>
<template>
<component
:is="as"
v-bind="$attrs"
class="nvo-gesture"
:style="touchStyle"
@pointerdown="gesture.onPointerdown"
@pointermove="gesture.onPointermove"
@pointerup="gesture.onPointerup"
@pointercancel="gesture.onPointercancel"
>
<slot />
</component>
</template>

View File

@@ -0,0 +1,73 @@
<script setup lang="ts">
import { computed } from "vue";
import { useOriginGesture } from "../gesture";
import type { OriginGestureSurfaceProps } from "../types";
defineOptions({ name: "OriginGestureSurface", inheritAttrs: false });
const props = withDefaults(defineProps<OriginGestureSurfaceProps>(), {
as: "div",
});
/*
* All interaction policy belongs to the component that built the definitions.
* This convenience host only installs them, combines their browser scrolling
* requirements, and forwards a pointer sequence to every recognizer.
*/
const bindings = props.gestures.map((definition) =>
useOriginGesture(definition),
);
const surfaceStyle = computed(() => {
const horizontal = props.gestures.some(
({ direction }) => direction === "left" || direction === "right",
);
const vertical = props.gestures.some(
({ direction }) => direction === "up" || direction === "down",
);
return {
width: "100%",
height: "100%",
touchAction:
horizontal && vertical
? "none"
: horizontal
? "pan-y"
: vertical
? "pan-x"
: "auto",
} as const;
});
function pointerDown(event: PointerEvent) {
for (const binding of bindings) binding.onPointerdown(event);
}
function pointerMove(event: PointerEvent) {
for (const binding of bindings) binding.onPointermove(event);
}
function pointerUp(event: PointerEvent) {
for (const binding of bindings) binding.onPointerup(event);
}
function pointerCancel() {
for (const binding of bindings) binding.onPointercancel();
}
</script>
<template>
<component
:is="as"
v-bind="$attrs"
class="nvo-gesture"
:style="[$attrs.style, surfaceStyle]"
@pointerdown="pointerDown"
@pointermove="pointerMove"
@pointerup="pointerUp"
@pointercancel="pointerCancel"
>
<slot />
</component>
</template>

View File

@@ -0,0 +1,52 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, provide, ref, watchEffect } from "vue";
import type { OriginScene, OriginSceneNode } from "../types";
import { originNodeScopeKey } from "../lifecycle";
defineOptions({ name: "OriginNodeHost" });
const props = defineProps<{
scene: OriginScene;
node: OriginSceneNode;
}>();
/*
* This host is the stable physical home of the view component. It is keyed by
* the scene node in OriginScene and never nested under another view. Only its
* composed CSS style changes while operation edges are created and collapsed.
*/
const host = ref<HTMLElement | null>(null);
provide(originNodeScopeKey, {
scene: props.scene,
nodeKey: props.node.key,
});
watchEffect(() => {
props.scene.registerElement(props.node.key, host.value);
});
onBeforeUnmount(() => props.scene.registerElement(props.node.key, null));
const style = computed(() => props.scene.styleForNode(props.node.key));
const interactive = computed(() =>
props.scene.isNodeInteractive(props.node.key),
);
</script>
<template>
<section
ref="host"
class="nvo-node"
:style="style"
:data-origin-node="node.key"
:data-origin-view="node.view.name ?? node.view.key"
:data-origin-state="node.state"
:aria-hidden="interactive ? undefined : 'true'"
:inert="interactive ? undefined : true"
>
<!--
Vue owns the component lifecycle normally. Adding another origin merely
adds effect layers to this host; it does not replace this component VNode.
-->
<component :is="node.view.component" v-bind="node.view.props" />
</section>
</template>

View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
import { onBeforeUnmount, ref, watchEffect } from "vue";
import type { OriginSceneProps } from "../types";
import OriginNodeHost from "./OriginNodeHost.vue";
defineOptions({ name: "OriginScene" });
const props = defineProps<OriginSceneProps>();
const root = ref<HTMLElement | null>(null);
watchEffect(() => props.scene.registerContainer(root.value));
onBeforeUnmount(() => props.scene.registerContainer(null));
</script>
<template>
<!--
All component hosts are siblings. The operation graph is intentionally not
mirrored as DOM ancestry because promoting Y after XY must not remount Y.
-->
<main
ref="root"
class="nvo-scene"
:style="{
position: 'relative',
width: '100%',
height: '100%',
overflow: 'hidden',
isolation: 'isolate',
}"
>
<OriginNodeHost
v-for="node in scene.nodes.value"
:key="node.key"
:scene="scene"
:node="node"
/>
</main>
</template>

View File

@@ -0,0 +1,248 @@
import { createApp, defineComponent, h, nextTick, type Component } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import OriginGestureSurface from "./components/OriginGestureSurface.vue";
import OriginScene from "./components/OriginScene.vue";
import { gesture, useOriginGesture } from "./gesture";
import { back, defineOriginChoreography, forward } from "./motion";
import { createOriginScene, originView } from "./scene";
import type {
OriginGestureBinding,
OriginGestureCompletionContext,
} from "./types";
const mountedApps: Array<ReturnType<typeof createApp>> = [];
afterEach(() => {
for (const app of mountedApps.splice(0)) app.unmount();
document.body.innerHTML = "";
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
const testMotion = defineOriginChoreography({
name: "gesture-test",
effects: ({ progress }) => ({
source: { opacity: 1 - progress },
target: { opacity: progress },
}),
});
function component(name: string): Component {
return defineComponent({
name,
render: () => h("div", name),
});
}
function pointer(
type: string,
init: Pick<PointerEventInit, "clientX" | "clientY">,
) {
return new PointerEvent(type, {
bubbles: true,
cancelable: true,
button: 0,
isPrimary: true,
pointerId: 7,
...init,
});
}
async function flushAsyncHandlers() {
await Promise.resolve();
await nextTick();
await Promise.resolve();
await nextTick();
}
describe("gesture builder", () => {
it("installs multiple page-owned definitions on a policy-neutral surface", async () => {
const horizontal = gesture.to
.left()
.navigate(() => null)
.animate(testMotion);
const vertical = gesture.from
.top("12%")
.to.down()
.navigate(() => null)
.animate(testMotion);
const Initial = defineComponent({
name: "SurfaceInitial",
render: () =>
h(
OriginGestureSurface,
{
as: "section",
id: "multi-gesture-surface",
gestures: [horizontal, vertical],
},
() => "surface",
),
});
const scene = createOriginScene({
initial: originView(Initial, undefined, { key: "surface-initial" }),
});
const root = document.createElement("div");
document.body.append(root);
const app = createApp({ render: () => h(OriginScene, { scene }) });
mountedApps.push(app);
app.mount(root);
await nextTick();
const surface = root.querySelector("#multi-gesture-surface") as HTMLElement;
expect(surface.tagName).toBe("SECTION");
expect(surface.classList.contains("nvo-gesture")).toBe(true);
expect(surface.style.touchAction).toBe("none");
expect(surface.style.width).toBe("100%");
expect(surface.style.height).toBe("100%");
});
it("keeps builder navigation intents separate from complete actions", () => {
const target = originView(component("Target"));
expect(forward(target)).toEqual({
target,
placement: "above",
history: "push",
});
expect(back()).toEqual({
placement: "under",
history: "back",
});
expect(forward(target, testMotion)).toMatchObject({
target,
choreography: testMotion,
history: "push",
});
expect(back(testMotion)).toMatchObject({
choreography: testMotion,
history: "back",
});
});
it("treats a chain beginning at .to as an immutable anywhere gesture", () => {
const definition = gesture.to
.right({ threshold: 12 })
.navigate(() => back())
.animate(testMotion);
expect(definition).toMatchObject({
kind: "origin-gesture-definition",
start: { kind: "anywhere" },
direction: "right",
recognition: { threshold: 12 },
choreography: testMotion,
});
expect(Object.isFrozen(definition)).toBe(true);
expect(Object.isFrozen(definition.recognition)).toBe(true);
});
it("keeps start predicates independent from movement direction", () => {
const predicate = vi.fn(() => true);
const complete = vi.fn(() => true);
const definition = gesture.from
.when(predicate)
.to.down({ axisDominance: 1.4 })
.complete(complete)
.navigate(() => forward(originView(component("Dialog"))))
.animate(testMotion);
expect(definition.start).toEqual({ kind: "when", predicate });
expect(definition.direction).toBe("down");
expect(definition.recognition.axisDominance).toBe(1.4);
expect(definition.completion).toBe(complete);
});
it("recognizes .to.right anywhere and lets .complete override release", async () => {
vi.stubGlobal(
"matchMedia",
vi.fn(() => ({ matches: true }) as MediaQueryList),
);
const Target = component("Target");
let binding: OriginGestureBinding | undefined;
let completion: OriginGestureCompletionContext | undefined;
const definition = gesture.to
.right()
.complete((context) => {
completion = context;
return false;
})
.navigate(() => forward(originView(Target, undefined, { key: "target" })))
.animate(testMotion);
const Initial = defineComponent({
name: "Initial",
setup() {
binding = useOriginGesture(definition);
return () =>
h(
"div",
{
id: "gesture-host",
style: binding!.style,
onPointerdown: binding!.onPointerdown,
onPointermove: binding!.onPointermove,
onPointerup: binding!.onPointerup,
onPointercancel: binding!.onPointercancel,
},
"Initial",
);
},
});
const scene = createOriginScene({
initial: originView(Initial, undefined, { key: "initial" }),
});
const root = document.createElement("div");
document.body.append(root);
const app = createApp({ render: () => h(OriginScene, { scene }) });
mountedApps.push(app);
app.mount(root);
await nextTick();
const host = root.querySelector("#gesture-host") as HTMLElement;
Object.defineProperties(host, {
clientWidth: { configurable: true, value: 200 },
clientHeight: { configurable: true, value: 400 },
});
host.getBoundingClientRect = () =>
({
top: 20,
left: 100,
right: 300,
bottom: 420,
width: 200,
height: 400,
x: 100,
y: 20,
toJSON: () => ({}),
}) as DOMRect;
// x=250 is nowhere near the left edge. With no `.from`, it is eligible.
host.dispatchEvent(pointer("pointerdown", { clientX: 250, clientY: 100 }));
host.dispatchEvent(pointer("pointermove", { clientX: 330, clientY: 102 }));
await flushAsyncHandlers();
expect(scene.operations.value).toHaveLength(1);
host.dispatchEvent(pointer("pointerup", { clientX: 350, clientY: 102 }));
await flushAsyncHandlers();
expect(completion).toMatchObject({
direction: "right",
progress: 0.5,
distance: 100,
crossDistance: 2,
});
expect(completion?.start).toMatchObject({
clientX: 250,
localX: 150,
});
expect(completion?.current).toMatchObject({
clientX: 350,
localX: 250,
});
expect(scene.operations.value).toHaveLength(0);
expect(scene.nodes.value.map((node) => node.view.name)).toEqual([
"Initial",
]);
});
});

View File

@@ -0,0 +1,582 @@
import type {
MaybeOriginAction,
OriginAction,
OriginContext,
OriginGestureBinding,
OriginGestureBuilder,
OriginGestureCompletionContext,
OriginGestureCompletionPredicate,
OriginGestureDefinition,
OriginGestureDirection,
OriginGestureDirectionOptions,
OriginGestureDistance,
OriginGestureEdge,
OriginGestureFromBuilder,
OriginGestureFromSelection,
OriginGestureNavigationBuilder,
OriginGestureNavigationFactory,
OriginGestureOptions,
OriginGesturePoint,
OriginGestureStart,
OriginGestureStartContext,
OriginGestureStartPredicate,
OriginGestureToBuilder,
OriginOperationHandle,
OriginRect,
} from "./types";
import { useOrigin } from "./lifecycle";
function ignoreGestureTarget(target: EventTarget | null) {
return (
!(target instanceof Element) ||
Boolean(
target.closest(
'[data-origin-gesture="ignore"], input, textarea, select, option, [contenteditable="true"]',
),
)
);
}
function directedDistance(
direction: OriginGestureDirection,
dx: number,
dy: number,
) {
switch (direction) {
case "left":
return -dx;
case "right":
return dx;
case "up":
return -dy;
case "down":
return dy;
}
}
function rectOf(element: HTMLElement): OriginRect {
const rect = element.getBoundingClientRect();
return {
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
};
}
function pointOf(
event: Pick<PointerEvent, "clientX" | "clientY">,
bounds: OriginRect,
): OriginGesturePoint {
return {
clientX: event.clientX,
clientY: event.clientY,
localX: event.clientX - bounds.left,
localY: event.clientY - bounds.top,
};
}
/**
* Resolve an arbitrary CSS length against a box with the gesture host's size.
*
* A short-lived off-screen box lets the browser handle `rem`, viewport units,
* percentages, `calc()`, and `clamp()` consistently. This runs only during
* pointer-down for edge-constrained definitions.
*/
function resolveCssDistance(
distance: OriginGestureDistance,
axis: "horizontal" | "vertical",
host: HTMLElement,
bounds: OriginRect,
) {
if (typeof distance === "number")
return Number.isFinite(distance) ? Math.max(0, distance) : 0;
const document = host.ownerDocument;
if (!document.body) return Math.max(0, Number.parseFloat(distance) || 0);
const container = document.createElement("div");
const probe = document.createElement("div");
Object.assign(container.style, {
position: "fixed",
left: "-100000px",
top: "-100000px",
width: `${bounds.width}px`,
height: `${bounds.height}px`,
visibility: "hidden",
pointerEvents: "none",
contain: "strict",
});
Object.assign(probe.style, {
position: "absolute",
width: axis === "horizontal" ? distance : "0",
height: axis === "vertical" ? distance : "0",
});
container.append(probe);
document.body.append(container);
const resolved =
axis === "horizontal"
? probe.getBoundingClientRect().width
: probe.getBoundingClientRect().height;
container.remove();
return Number.isFinite(resolved) ? Math.max(0, resolved) : 0;
}
function matchesStart(
start: OriginGestureStart,
event: PointerEvent,
host: HTMLElement,
origin: OriginContext,
bounds: OriginRect,
) {
if (start.kind === "anywhere") return true;
const point = pointOf(event, bounds);
if (start.kind === "when") {
const context: OriginGestureStartContext = {
event,
origin,
host,
bounds,
point,
};
return start.predicate(context);
}
const horizontal = start.edge === "left" || start.edge === "right";
const distance = resolveCssDistance(
start.distance,
horizontal ? "horizontal" : "vertical",
host,
bounds,
);
switch (start.edge) {
case "left":
return point.localX >= 0 && point.localX <= distance;
case "right":
return (
point.localX <= bounds.width && bounds.width - point.localX <= distance
);
case "top":
return point.localY >= 0 && point.localY <= distance;
case "bottom":
return (
point.localY <= bounds.height &&
bounds.height - point.localY <= distance
);
}
}
function edgeStart(
edge: OriginGestureEdge,
distance: OriginGestureDistance,
): OriginGestureStart {
return Object.freeze({ kind: "edge", edge, distance });
}
function createNavigationBuilder(
start: OriginGestureStart,
direction: OriginGestureDirection,
recognition: Readonly<OriginGestureDirectionOptions>,
completion: OriginGestureCompletionPredicate | undefined,
navigation: OriginGestureNavigationFactory,
): OriginGestureNavigationBuilder {
return Object.freeze({
animate(choreography: OriginGestureDefinition["choreography"]) {
return Object.freeze({
kind: "origin-gesture-definition",
start,
direction,
recognition,
completion,
navigation,
choreography,
});
},
});
}
function createDirectedBuilder(
start: OriginGestureStart,
direction: OriginGestureDirection,
options: OriginGestureDirectionOptions = {},
) {
const recognition = Object.freeze({ ...options });
const navigate = (
navigation: OriginGestureNavigationFactory,
completion?: OriginGestureCompletionPredicate,
) =>
createNavigationBuilder(
start,
direction,
recognition,
completion,
navigation,
);
return Object.freeze({
complete(completion: OriginGestureCompletionPredicate) {
return Object.freeze({
navigate: (navigation: OriginGestureNavigationFactory) =>
navigate(navigation, completion),
});
},
navigate,
});
}
function createToBuilder(start: OriginGestureStart): OriginGestureToBuilder {
return Object.freeze({
left: (options?: OriginGestureDirectionOptions) =>
createDirectedBuilder(start, "left", options),
right: (options?: OriginGestureDirectionOptions) =>
createDirectedBuilder(start, "right", options),
up: (options?: OriginGestureDirectionOptions) =>
createDirectedBuilder(start, "up", options),
down: (options?: OriginGestureDirectionOptions) =>
createDirectedBuilder(start, "down", options),
});
}
function selectStart(start: OriginGestureStart): OriginGestureFromSelection {
return Object.freeze({ to: createToBuilder(Object.freeze(start)) });
}
const fromBuilder: OriginGestureFromBuilder = Object.freeze({
left: (distance: OriginGestureDistance) =>
selectStart(edgeStart("left", distance)),
right: (distance: OriginGestureDistance) =>
selectStart(edgeStart("right", distance)),
top: (distance: OriginGestureDistance) =>
selectStart(edgeStart("top", distance)),
bottom: (distance: OriginGestureDistance) =>
selectStart(edgeStart("bottom", distance)),
anywhere: () => selectStart({ kind: "anywhere" }),
when: (predicate: OriginGestureStartPredicate) =>
selectStart({ kind: "when", predicate }),
});
/**
* Root of the immutable gesture builder.
*
* `.from` is optional. Starting at `.to` admits pointer-down anywhere on the
* bound host, exactly like `.from.anywhere().to`.
*
* @example Anywhere-to-right back gesture
* ```ts
* const swipeBack = gesture
* .to.right()
* .navigate((context) => context.canGoBack ? back() : null)
* .animate(slideRight);
* ```
*
* @example Predicate-gated gesture with custom completion
* ```ts
* const openPanel = gesture
* .from.when(({ point, bounds }) => point.localX <= bounds.width * 0.08)
* .to.down()
* .complete(({ progress, velocity }) => progress > 0.5 || velocity > 1)
* .navigate(() => above(originView(PanelView)))
* .animate(dropPanel);
* ```
*/
export const gesture: OriginGestureBuilder = Object.freeze({
from: fromBuilder,
to: createToBuilder(Object.freeze({ kind: "anywhere" })),
});
function isDefinition(
value: OriginGestureOptions | OriginGestureDefinition,
): value is OriginGestureDefinition {
return "kind" in value && value.kind === "origin-gesture-definition";
}
interface RuntimeGesturePolicy {
readonly direction: OriginGestureDirection;
readonly start: OriginGestureStart;
readonly threshold: number;
readonly axisDominance: number;
readonly completion?: OriginGestureCompletionPredicate;
readonly action: (context: OriginContext) => MaybeOriginAction;
}
function legacyStart(options: OriginGestureOptions): OriginGestureStart {
if (options.edge === undefined) return { kind: "anywhere" };
switch (options.direction) {
case "left":
return edgeStart("right", options.edge);
case "right":
return edgeStart("left", options.edge);
case "up":
return edgeStart("bottom", options.edge);
case "down":
return edgeStart("top", options.edge);
}
}
function runtimePolicy(
options: OriginGestureOptions | OriginGestureDefinition,
): RuntimeGesturePolicy {
if (!isDefinition(options)) {
return {
direction: options.direction,
start: legacyStart(options),
threshold: options.threshold ?? 8,
axisDominance: 1.15,
action: options.action,
};
}
return {
direction: options.direction,
start: options.start,
threshold: options.recognition.threshold ?? 8,
axisDominance: options.recognition.axisDominance ?? 1.15,
completion: options.completion,
action: async (context) => {
const navigation = await options.navigation(context);
if (!navigation) return navigation;
const action: OriginAction = {
...navigation,
choreography: options.choreography,
};
return action;
},
};
}
/**
* Install a component-owned pointer recognizer.
*
* Recognition is local to the element receiving these handlers. There is no
* application-wide gesture table and no lookup of a currently active view.
*
* Builder definitions may independently describe their pointer-down region
* and movement direction. Omitting `.from` recognizes pointer-down across the
* whole element. The legacy options object remains supported; its `edge` is
* inferred from the opposite side of its movement direction.
*
* Interactive controls and anything inside
* `[data-origin-gesture="ignore"]` are ignored automatically. The recognizer
* preserves native scrolling on the cross-axis through its returned style.
*
* @param definition - An immutable builder result or legacy recognizer options.
* @returns Pointer handlers and required host styles.
* @throws If called outside a component rendered by `OriginScene`.
*
* @example Builder-defined backward gesture
* ```ts
* const swipeBack = useOriginGesture(
* gesture
* .from.left("32px")
* .to.right()
* .navigate((context) => context.canGoBack ? back() : null)
* .animate(slideRight),
* );
* ```
*/
export function useOriginGesture(
definition: OriginGestureDefinition | OriginGestureOptions,
): OriginGestureBinding {
const origin = useOrigin();
const policy = runtimePolicy(definition);
let pointerId = -1;
let element: HTMLElement | null = null;
let bounds: OriginRect | null = null;
let originContext: OriginContext | null = null;
let startPoint: OriginGesturePoint | null = null;
let startTime = 0;
let startX = 0;
let startY = 0;
let lastCoordinate = 0;
let lastTime = 0;
let captured = false;
let generation = 0;
let handlePromise: Promise<OriginOperationHandle | null> | null = null;
let bufferedProgress = 0;
let bufferedVelocity = 0;
let bufferedDistance = 0;
let bufferedCrossDistance = 0;
const horizontal =
policy.direction === "left" || policy.direction === "right";
function reset() {
pointerId = -1;
element = null;
bounds = null;
originContext = null;
startPoint = null;
captured = false;
handlePromise = null;
bufferedProgress = 0;
bufferedVelocity = 0;
bufferedDistance = 0;
bufferedCrossDistance = 0;
}
function onPointerdown(event: PointerEvent) {
const current = event.currentTarget;
if (
!event.isPrimary ||
event.button !== 0 ||
!(current instanceof HTMLElement) ||
ignoreGestureTarget(event.target)
)
return;
const nextBounds = rectOf(current);
const nextOriginContext = origin.context.value;
if (
!matchesStart(policy.start, event, current, nextOriginContext, nextBounds)
)
return;
// The component containing this declaration is the operation's origin.
event.stopPropagation();
generation += 1;
pointerId = event.pointerId;
element = current;
bounds = nextBounds;
originContext = nextOriginContext;
startPoint = pointOf(event, nextBounds);
startTime = event.timeStamp;
startX = event.clientX;
startY = event.clientY;
lastCoordinate = horizontal ? event.clientX : event.clientY;
lastTime = event.timeStamp;
captured = false;
handlePromise = null;
}
function updateMetrics(event: PointerEvent, release = false) {
if (!element) return;
const dx = event.clientX - startX;
const dy = event.clientY - startY;
bufferedDistance = directedDistance(policy.direction, dx, dy);
bufferedCrossDistance = horizontal ? Math.abs(dy) : Math.abs(dx);
const size = Math.max(
1,
horizontal ? element.clientWidth : element.clientHeight,
);
const coordinate = horizontal ? event.clientX : event.clientY;
const coordinateDelta =
policy.direction === "left" || policy.direction === "up"
? lastCoordinate - coordinate
: coordinate - lastCoordinate;
const rawElapsed = event.timeStamp - lastTime;
const elapsed = Math.max(8, rawElapsed);
bufferedProgress = Math.max(0, Math.min(1, bufferedDistance / size));
/*
* Pointer-up commonly repeats the final pointer-move coordinate. Preserve
* that move's flick velocity for a prompt release, but decay it when the
* pointer was held still long enough for the flick to have ended.
*/
if (!release || coordinateDelta !== 0 || rawElapsed > 80)
bufferedVelocity = (coordinateDelta * 1000) / (elapsed * size);
lastCoordinate = coordinate;
lastTime = event.timeStamp;
}
async function onPointermove(event: PointerEvent) {
if (event.pointerId !== pointerId || !element) return;
const dx = event.clientX - startX;
const dy = event.clientY - startY;
const distance = directedDistance(policy.direction, dx, dy);
const crossDistance = horizontal ? Math.abs(dy) : Math.abs(dx);
let metricsUpdated = false;
if (!captured) {
if (
distance < policy.threshold ||
distance < crossDistance * policy.axisDominance
)
return;
captured = true;
element.setPointerCapture?.(pointerId);
event.preventDefault();
updateMetrics(event);
metricsUpdated = true;
const recognitionGeneration = generation;
const action = await Promise.resolve(
policy.action(origin.context.value),
).catch(() => null);
// An asynchronous target resolver may finish after the pointer was
// released or cancelled. It must not create an orphan scene operation.
if (recognitionGeneration !== generation || event.pointerId !== pointerId)
return;
if (!action) return reset();
handlePromise = origin.begin(action).catch(() => null);
}
event.preventDefault();
if (!metricsUpdated) updateMetrics(event);
const pending = handlePromise;
const handle = pending ? await pending : null;
if (pending === handlePromise)
handle?.update(bufferedProgress, bufferedVelocity);
}
async function onPointerup(event: PointerEvent) {
if (event.pointerId !== pointerId) return;
if (captured) updateMetrics(event, true);
const pending = handlePromise;
const shouldFinish = captured;
const progress = bufferedProgress;
const velocity = bufferedVelocity;
const completion =
shouldFinish &&
policy.completion &&
element &&
bounds &&
originContext &&
startPoint
? policy.completion({
origin: originContext,
direction: policy.direction,
progress,
velocity,
distance: bufferedDistance,
crossDistance: bufferedCrossDistance,
duration: Math.max(0, event.timeStamp - startTime),
event,
host: element,
bounds,
start: startPoint,
current: pointOf(event, bounds),
} satisfies OriginGestureCompletionContext)
: undefined;
generation += 1;
reset();
const handle = pending ? await pending : null;
if (!shouldFinish || !handle) return;
handle.update(progress, velocity);
await handle.finish(
completion === undefined ? undefined : { commit: completion },
);
}
async function onPointercancel() {
generation += 1;
const pending = handlePromise;
const shouldCancel = captured;
reset();
const handle = pending ? await pending : null;
if (shouldCancel) await handle?.cancel();
}
return {
style: {
// Preserve native scrolling perpendicular to the declared gesture.
touchAction: horizontal ? "pan-y" : "pan-x",
// OriginGesture is commonly the root returned by a view component.
width: "100%",
height: "100%",
},
onPointerdown,
onPointermove: (event) => void onPointermove(event),
onPointerup: (event) => void onPointerup(event),
onPointercancel: () => void onPointercancel(),
};
}

View File

@@ -0,0 +1,38 @@
/**
* Routeless, component-owned scene transitions and gesture recognition for Vue.
*
* The package renders flat, stable Vue component hosts and composes temporary
* origin-relative operation frames. It does not depend on Vue Router or choose
* a globally active view.
*
* @packageDocumentation
*/
export * from "./types";
export * from "./scene";
export * from "./motion";
export * from "./gesture";
export * from "./lifecycle";
/**
* Convenience component that binds one `useOriginGesture()` recognizer to a
* rendered HTML element. See `OriginGestureProps` for its public props.
*/
export { default as OriginGesture } from "./components/OriginGesture.vue";
/**
* Policy-neutral host for multiple completed gesture definitions. The owning
* page builds each definition; this component only installs their recognizers
* and forwards pointer events across the shared surface.
*/
export { default as OriginGestureSurface } from "./components/OriginGestureSurface.vue";
/**
* Renderer for an `OriginScene`. Every live view is mounted as a stable,
* absolutely positioned sibling beneath this component.
*/
export { default as OriginScene } from "./components/OriginScene.vue";
// Makes the library build emit dist/style.css. Applications should import the
// explicit `@native-vue-router/core-v2/style.css` export as shown in the README.
import "./style.css";

View File

@@ -0,0 +1,53 @@
import { computed, inject, type InjectionKey } from "vue";
import type { OriginNodeScope, UseOrigin } from "./types";
/**
* Injection key used by the internal scene-node host to establish origin
* ownership for descendant components.
*
* Application code normally calls {@link useOrigin} instead of injecting this
* key directly.
*
* @internal
*/
export const originNodeScopeKey: InjectionKey<OriginNodeScope> =
Symbol("origin-node-scope");
/**
* Access the scene from the component that owns an interaction declaration.
*
* There is deliberately no `activeView`: the injected node is the origin
* because this component is where the event or application action occurred.
*
* @returns Node-scoped scene state and operation controls.
* @throws If called outside a component rendered by `OriginScene`.
*
* @example
* ```ts
* const origin = useOrigin();
*
* function openProfile() {
* return origin.perform(
* forward(originView(ProfileView), slideLeft),
* );
* }
* ```
*/
export function useOrigin(): UseOrigin {
const scope = inject(originNodeScopeKey);
if (!scope)
throw new Error("useOrigin() must be called inside an <OriginScene> view.");
const context = computed(() => scope.scene.contextFor(scope.nodeKey));
return {
nodeKey: scope.nodeKey,
scene: scope.scene,
context,
view: computed(() => context.value.view),
previous: computed(() => context.value.previous),
canGoBack: computed(() => context.value.canGoBack),
begin: (action) => scope.scene.begin(scope.nodeKey, action),
perform: (action) => scope.scene.perform(scope.nodeKey, action),
};
}

View File

@@ -0,0 +1,325 @@
import type {
OriginAction,
OriginChoreography,
OriginEffect,
OriginEffectSet,
OriginHistoryMode,
OriginNavigationIntent,
OriginPlacement,
OriginView,
} from "./types";
/**
* Preserve type inference while declaring a custom choreography.
*
* The helper performs no runtime transformation. It gives custom routines a
* named, documented construction point and validates their shape in TypeScript.
*
* @param choreography - Side-effect-free visual effect calculator and optional
* release thresholds.
* @returns The same choreography object.
*/
export function defineOriginChoreography(
choreography: OriginChoreography,
): OriginChoreography {
return choreography;
}
/** Options accepted by {@link originAction}. */
export interface OriginActionOptions {
/**
* Target stacking relationship during the operation.
*
* @defaultValue `"above"`
*/
placement?: OriginPlacement;
/**
* History mutation assigned to the created target node.
*
* @defaultValue `"push"`
*/
history?: OriginHistoryMode;
}
/** History options shared by the {@link above} and {@link under} helpers. */
export interface OriginPlacementActionOptions {
/**
* History mutation assigned to the created target node.
*
* @defaultValue `"push"`
*/
history?: OriginHistoryMode;
}
/** Stacking options accepted by retained-history navigation helpers. */
export interface OriginNavigationActionOptions {
/**
* Target stacking relationship during the operation.
*
* @defaultValue `"above"` for {@link forward}, `"under"` for {@link back}
*/
placement?: OriginPlacement;
}
function isChoreography(
value: OriginChoreography | object | undefined,
): value is OriginChoreography {
return (
value !== undefined &&
"effects" in value &&
typeof value.effects === "function"
);
}
/**
* Create the scene mutation invoked by a click, gesture, hardware command, or
* any other application event. `above` and `under` affect stacking only; they
* do not imply a universal navigation direction.
*
* @param target - View recipe to create.
* @param choreography - Visual routine controlling the operation.
* @param options - Stacking and history behavior.
* @returns An action that can be passed to `begin()`, `perform()`, or returned
* from a gesture action factory.
*/
export function originAction(
target: OriginView,
choreography: OriginChoreography,
options: OriginActionOptions = {},
): OriginAction {
return {
target,
choreography,
placement: options.placement ?? "above",
history: options.history ?? "push",
};
}
/**
* Create a retained-history push intent or complete action.
*
* Committing the action parks its origin instance and leaves it mounted until
* a later committed {@link back} action pops the new entry. Omit choreography
* when declaring `.navigate()` inside a gesture builder; provide choreography
* when passing the result directly to `begin()` or `perform()`.
*
* @param target - View recipe created if the push commits.
* @param options - Stacking options for an animation-free navigation intent.
* @returns An animation-free intent for use with a gesture builder.
*/
export function forward(
target: OriginView,
options?: OriginNavigationActionOptions,
): OriginNavigationIntent;
export function forward(
target: OriginView,
choreography: OriginChoreography,
options?: OriginNavigationActionOptions,
): OriginAction;
export function forward(
target: OriginView,
choreographyOrOptions:
OriginChoreography | OriginNavigationActionOptions = {},
options: OriginNavigationActionOptions = {},
): OriginAction | OriginNavigationIntent {
if (isChoreography(choreographyOrOptions)) {
return originAction(target, choreographyOrOptions, {
placement: options.placement ?? "above",
history: "push",
});
}
return {
target,
placement: choreographyOrOptions.placement ?? "above",
history: "push",
};
}
/**
* Create a retained-history back intent or complete action.
*
* The action intentionally has no target recipe. At execution time the scene
* resolves the origin's mounted `previousNodeKey`, reveals that exact instance,
* and removes the current instance only if the operation commits. Omit
* choreography inside gesture `.navigate()`; provide it for a programmatic
* action.
*
* @param options - Stacking options for an animation-free navigation intent.
* @returns An animation-free back intent for use with a gesture builder.
*/
export function back(
options?: OriginNavigationActionOptions,
): OriginNavigationIntent;
export function back(
choreography: OriginChoreography,
options?: OriginNavigationActionOptions,
): OriginAction;
export function back(
choreographyOrOptions:
OriginChoreography | OriginNavigationActionOptions = {},
options: OriginNavigationActionOptions = {},
): OriginAction | OriginNavigationIntent {
if (!isChoreography(choreographyOrOptions)) {
return {
placement: choreographyOrOptions.placement ?? "under",
history: "back",
};
}
return {
choreography: choreographyOrOptions,
placement: options.placement ?? "under",
history: "back",
};
}
/**
* Create an intent or action whose target is stacked above its origin.
*
* This helper controls stacking, not movement direction. The supplied
* choreography may move either component however the application chooses.
* Omitting choreography produces an intent for gesture `.navigate()`.
*
* @param target - View recipe to create.
* @param choreography - Visual routine controlling the operation.
* @param options - Optional history behavior.
*/
export function above(
target: OriginView,
options?: OriginPlacementActionOptions,
): OriginNavigationIntent;
export function above(
target: OriginView,
choreography: OriginChoreography,
options?: OriginPlacementActionOptions,
): OriginAction;
export function above(
target: OriginView,
choreographyOrOptions: OriginChoreography | OriginPlacementActionOptions = {},
options: OriginPlacementActionOptions = {},
): OriginAction | OriginNavigationIntent {
if (isChoreography(choreographyOrOptions)) {
return originAction(target, choreographyOrOptions, {
placement: "above",
history: options.history,
});
}
return {
target,
placement: "above",
history: choreographyOrOptions.history ?? "push",
};
}
/**
* Create an intent or action whose target is stacked underneath its origin.
*
* Commonly used for custom reveal effects, but it has no implicit history
* meaning. Prefer {@link back} for retained-history navigation. Omitting
* choreography produces an intent for gesture `.navigate()`.
*
* @param target - View recipe to create underneath the origin.
* @param choreography - Visual routine controlling the operation.
* @param options - Optional history behavior.
*/
export function under(
target: OriginView,
options?: OriginPlacementActionOptions,
): OriginNavigationIntent;
export function under(
target: OriginView,
choreography: OriginChoreography,
options?: OriginPlacementActionOptions,
): OriginAction;
export function under(
target: OriginView,
choreographyOrOptions: OriginChoreography | OriginPlacementActionOptions = {},
options: OriginPlacementActionOptions = {},
): OriginAction | OriginNavigationIntent {
if (isChoreography(choreographyOrOptions)) {
return originAction(target, choreographyOrOptions, {
placement: "under",
history: options.history,
});
}
return {
target,
placement: "under",
history: choreographyOrOptions.history ?? "push",
};
}
const percent = (value: number) => `${(value * 100).toFixed(4)}%`;
/**
* Native-style forward motion: the target enters above the source while the
* source recedes slightly. These presets are examples; applications can
* replace them with arbitrary `defineOriginChoreography()` callbacks.
*/
export const slideLeft = defineOriginChoreography({
name: "slide-left",
commitThreshold: 0.36,
commitVelocity: 0.6,
effects: ({ progress }): OriginEffectSet => ({
source: {
transform: `translate3d(${percent(progress * -0.24)}, 0, 0) scale(${1 - progress * 0.025})`,
opacity: 1 - progress * 0.16,
},
target: {
transform: `translate3d(${percent(1 - progress)}, 0, 0)`,
},
}),
});
/**
* Back motion reveals the target underneath the source. This is merely a
* visual routine; the {@link back} action selects retained-history behavior.
*/
export const slideRight = defineOriginChoreography({
name: "slide-right",
commitThreshold: 0.36,
commitVelocity: 0.6,
effects: ({ progress }): OriginEffectSet => ({
source: {
transform: `translate3d(${percent(progress)}, 0, 0)`,
},
target: {
transform: `translate3d(${percent(-0.24 + progress * 0.24)}, 0, 0) scale(${0.975 + progress * 0.025})`,
opacity: 0.84 + progress * 0.16,
},
}),
});
/**
* Cross-fade preset that fades the source out while fading the target in.
*
* The default release thresholds from the scene are used.
*/
export const fade = defineOriginChoreography({
name: "fade",
effects: ({ progress }): OriginEffectSet => ({
source: { opacity: 1 - progress },
target: { opacity: progress },
}),
});
/**
* Normalize one contribution before the compositor combines it with effects
* inherited from earlier origin frames.
*
* Most applications should return plain effects from a choreography and let
* the scene call this function. It is exported for custom compositors and
* diagnostics.
*
* @param effect - Optional effect to normalize.
* @param fallbackLayer - Relative layer contribution added to `effect.layer`.
* @returns A defined effect with a numeric layer.
*/
export function normalizedEffect(
effect: OriginEffect | undefined,
fallbackLayer = 0,
): OriginEffect {
return {
...effect,
layer: (effect?.layer ?? 0) + fallbackLayer,
};
}

View File

@@ -0,0 +1,315 @@
import {
createApp,
defineComponent,
h,
nextTick,
onMounted,
onUnmounted,
type Component,
} from "vue";
import { afterEach, describe, expect, it } from "vitest";
import OriginScene from "./components/OriginScene.vue";
import {
above,
back,
defineOriginChoreography,
forward,
under,
} from "./motion";
import { createOriginScene, originView } from "./scene";
const mountedApps: Array<ReturnType<typeof createApp>> = [];
afterEach(() => {
for (const app of mountedApps.splice(0)) app.unmount();
document.body.innerHTML = "";
});
function component(name: string): Component {
return defineComponent({
name,
render: () => h("div", { "data-test-view": name }, name),
});
}
const layeredMotion = defineOriginChoreography({
name: "test-layered-motion",
effects: ({ progress }) => ({
source: { transform: `translateX(${-progress * 100}px)` },
target: { transform: `translateX(${(1 - progress) * 100}px)` },
}),
});
describe("origin-relative scene graph", () => {
it("composes X→Y and Y→Z as independent transform layers", async () => {
const x = originView(component("X"), undefined, { key: "x" });
const y = originView(component("Y"), undefined, { key: "y" });
const z = originView(component("Z"), undefined, { key: "z" });
const scene = createOriginScene({ initial: x });
const xKey = scene.nodes.value[0]!.key;
const xy = await scene.begin(xKey, above(y, layeredMotion));
xy.update(0.5);
const yz = await scene.begin(xy.targetKey, above(z, layeredMotion));
yz.update(0.25);
/*
* Y inherits the target half of X→Y, then adds its own source half of
* Y→Z. Z inherits X→Y as well, but receives Y→Z's target half.
*/
expect(scene.styleForNode(xy.targetKey).transform).toBe(
"translateX(50px) translateX(-25px)",
);
expect(scene.styleForNode(xy.targetKey)).toMatchObject({
position: "absolute",
inset: "0",
width: "100%",
height: "100%",
});
expect(scene.styleForNode(yz.targetKey).transform).toBe(
"translateX(50px) translateX(75px)",
);
expect(scene.nodes.value.map((node) => node.view.name)).toEqual([
"X",
"Y",
"Z",
]);
});
it("splices completed operations in either order without losing descendants", async () => {
const scene = createOriginScene({
initial: originView(component("X"), undefined, { key: "x" }),
});
const xKey = scene.nodes.value[0]!.key;
const xy = await scene.begin(
xKey,
above(originView(component("Y"), undefined, { key: "y" }), layeredMotion),
);
xy.update(0.8);
const yz = await scene.begin(
xy.targetKey,
above(originView(component("Z"), undefined, { key: "z" }), layeredMotion),
);
yz.update(0.6);
// Completing the newer edge first parks Y while Z replaces it as the
// visual target. Y remains mounted as Z's retained previous entry.
await yz.finish({ commit: true, animate: false });
expect(scene.nodes.value.map((node) => node.view.name)).toEqual([
"X",
"Y",
"Z",
]);
expect(
scene.nodes.value.find((node) => node.view.name === "Y")?.state,
).toBe("parked");
expect(scene.operations.value).toHaveLength(1);
expect(scene.operations.value[0]?.targetKey).toBe(yz.targetKey);
expect(scene.styleForNode(yz.targetKey).transform).toMatch(
/^translateX\(19\.9.+px\)$/,
);
await xy.finish({ commit: true, animate: false });
expect(
scene.nodes.value.map((node) => [node.view.name, node.state]),
).toEqual([
["X", "parked"],
["Y", "parked"],
["Z", "active"],
]);
expect(scene.operations.value).toHaveLength(0);
expect(scene.styleForNode(yz.targetKey).transform).toBe("none");
expect(scene.styleForNode(xKey).visibility).toBe("hidden");
});
it("does not remount a target when its incoming operation is collapsed", async () => {
let yMounts = 0;
const X = component("X");
const Y = defineComponent({
name: "Y",
setup() {
onMounted(() => {
yMounts += 1;
});
return () => h("div", "Y");
},
});
const Z = component("Z");
const scene = createOriginScene({
initial: originView(X, undefined, { key: "x" }),
});
const host = document.createElement("div");
document.body.append(host);
const app = createApp({
render: () => h(OriginScene, { scene }),
});
mountedApps.push(app);
app.mount(host);
expect(
(host.querySelector(".nvo-scene") as HTMLElement | null)?.style.position,
).toBe("relative");
const xy = await scene.begin(
scene.nodes.value[0]!.key,
above(originView(Y, undefined, { key: "y" }), layeredMotion),
);
const yz = await scene.begin(
xy.targetKey,
above(originView(Z, undefined, { key: "z" }), layeredMotion),
);
await nextTick();
expect(yMounts).toBe(1);
expect(
(host.querySelector('[data-origin-view="Y"]') as HTMLElement | null)
?.style.position,
).toBe("absolute");
// Y remains the same flat, keyed host while X→Y disappears around it.
await xy.finish({ commit: true, animate: false });
await nextTick();
expect(yMounts).toBe(1);
expect(scene.nodes.value.map((node) => node.view.name)).toEqual([
"X",
"Y",
"Z",
]);
await yz.cancel({ animate: false });
expect(
scene.nodes.value.map((node) => [node.view.name, node.state]),
).toEqual([
["X", "parked"],
["Y", "active"],
]);
});
it("reuses the retained previous node and pops only the current entry on back", async () => {
let xMounts = 0;
let xUnmounts = 0;
let yUnmounts = 0;
const X = defineComponent({
name: "X",
setup() {
onMounted(() => {
xMounts += 1;
});
onUnmounted(() => {
xUnmounts += 1;
});
return () =>
h(
"div",
{
"data-scroll": "feed",
style: { height: "100px", overflow: "auto" },
},
h("div", { style: { height: "2000px" } }, "Feed"),
);
},
});
const Y = defineComponent({
name: "Y",
setup() {
onUnmounted(() => {
yUnmounts += 1;
});
return () => h("div", "Y");
},
});
const x = originView(X, { message: "original" }, { key: "x" });
const y = originView(Y, undefined, { key: "y" });
const scene = createOriginScene({ initial: x });
const host = document.createElement("div");
document.body.append(host);
const app = createApp({
render: () => h(OriginScene, { scene }),
});
mountedApps.push(app);
app.mount(host);
await nextTick();
const xKey = scene.nodes.value[0]!.key;
const originalScroller = host.querySelector(
'[data-scroll="feed"]',
) as HTMLElement;
originalScroller.scrollTop = 842;
const xy = await scene.begin(xKey, forward(y, layeredMotion));
await xy.finish({ commit: true, animate: false });
await nextTick();
const yContext = scene.contextFor(xy.targetKey);
expect(yContext.previous).toBe(x);
expect(yContext.history).toEqual([x]);
expect(xMounts).toBe(1);
expect(xUnmounts).toBe(0);
expect(scene.nodes.value.find((node) => node.key === xKey)?.state).toBe(
"parked",
);
expect(host.querySelector('[data-scroll="feed"]')).toBe(originalScroller);
expect(originalScroller.scrollTop).toBe(842);
const yx = await scene.begin(xy.targetKey, back(layeredMotion));
expect(yx.targetKey).toBe(xKey);
await nextTick();
expect(host.querySelector('[data-scroll="feed"]')).toBe(originalScroller);
expect(originalScroller.scrollTop).toBe(842);
await yx.finish({ commit: true, animate: false });
await nextTick();
expect(scene.nodes.value.map((node) => node.key)).toEqual([xKey]);
expect(scene.contextFor(xKey).history).toEqual([]);
expect(scene.nodes.value[0]?.state).toBe("active");
expect(xMounts).toBe(1);
expect(xUnmounts).toBe(0);
expect(yUnmounts).toBe(1);
expect(originalScroller.scrollTop).toBe(842);
});
it("re-parks the retained target when a back operation is cancelled", async () => {
const x = originView(component("X"), undefined, { key: "x" });
const y = originView(component("Y"), undefined, { key: "y" });
const scene = createOriginScene({ initial: x });
const xKey = scene.nodes.value[0]!.key;
const xy = await scene.begin(xKey, above(y, layeredMotion));
await xy.finish({ commit: true, animate: false });
const yx = await scene.begin(
xy.targetKey,
under(x, layeredMotion, { history: "back" }),
);
expect(yx.targetKey).toBe(xKey);
await yx.cancel({ animate: false });
expect(scene.operations.value).toHaveLength(0);
expect(
scene.nodes.value.map((node) => [node.view.name, node.state]),
).toEqual([
["X", "parked"],
["Y", "active"],
]);
});
it("hands an immediate back gesture from a settling push to the same instances", async () => {
const x = originView(component("X"), undefined, { key: "x" });
const y = originView(component("Y"), undefined, { key: "y" });
const scene = createOriginScene({ initial: x });
const xKey = scene.nodes.value[0]!.key;
const xy = await scene.begin(xKey, above(y, layeredMotion));
xy.update(0.8, 1);
const forwardSettlement = xy.finish({ commit: true });
const yx = await scene.begin(
xy.targetKey,
under(x, layeredMotion, { history: "back" }),
);
expect(yx.targetKey).toBe(xKey);
await yx.finish({ commit: true, animate: false });
await forwardSettlement;
expect(scene.operations.value).toHaveLength(0);
expect(scene.nodes.value.map((node) => [node.key, node.state])).toEqual([
[xKey, "active"],
]);
});
});

View File

@@ -0,0 +1,701 @@
import {
computed,
markRaw,
nextTick,
reactive,
shallowReactive,
shallowRef,
type Component,
type CSSProperties,
} from "vue";
import { normalizedEffect } from "./motion";
import type {
MutableOriginOperation,
OriginAction,
OriginChoreographyContext,
OriginContext,
OriginEffect,
OriginEffectSet,
OriginFinishOptions,
OriginOperation,
OriginOperationHandle,
OriginRect,
OriginScene,
OriginSceneNode,
OriginSceneNodeState,
OriginView,
} from "./types";
interface SceneNodeState {
key: string;
sequence: number;
view: OriginView;
previousNodeKey?: string;
state: OriginSceneNodeState;
incomingOperationId?: number;
}
/** Options used to create an independent origin scene. */
export interface CreateOriginSceneOptions {
/**
* One initial root recipe, or several independent roots rendered in the same
* flat scene. Most applications begin with one root.
*/
initial: OriginView | readonly OriginView[];
}
/** Optional developer-facing metadata for an {@link OriginView} recipe. */
export interface OriginViewOptions {
/**
* Stable recipe identity used in diagnostics and generated node-key prefixes.
* It does not preserve or reuse a mounted Vue component instance.
*/
key?: string;
/** Human-readable label used by inspectors and DOM data attributes. */
name?: string;
}
let viewSequence = 0;
/**
* Turn a component and props into a lightweight, reusable view recipe.
*
* Components are marked raw so Vue never attempts to proxy their definitions
* when recipes are placed in reactive scene/history structures.
*
* @typeParam Props - Props passed to the component when the recipe is mounted.
* @param component - Vue component definition to mount.
* @param props - Props captured by the recipe.
* @param options - Optional recipe identity and diagnostic name.
* @returns An immutable, reusable component recipe. It is not a Vue instance.
*
* @example
* ```ts
* const profile = originView(
* ProfileView,
* { userId: "42" },
* { key: "profile-42", name: "Profile" },
* );
* ```
*/
export function originView<
Props extends Record<string, unknown> = Record<string, unknown>,
>(
component: Component,
props?: Props,
options: OriginViewOptions = {},
): OriginView<Props> {
const inferredName =
options.name ??
(typeof component === "object" && "name" in component
? String(component.name)
: undefined);
return markRaw({
component: markRaw(component),
props,
key: options.key ?? `${inferredName ?? "view"}-${++viewSequence}`,
name: inferredName,
});
}
function clamp(value: number) {
return Math.max(0, Math.min(1, value));
}
function elementRect(element: HTMLElement | null | undefined) {
if (!element) return undefined;
const rect = element.getBoundingClientRect();
return {
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
};
}
function defaultViewport(element: HTMLElement | null): OriginRect {
return (
elementRect(element) ?? {
top: 0,
left: 0,
width: typeof window === "undefined" ? 1 : window.innerWidth,
height: typeof window === "undefined" ? 1 : window.innerHeight,
}
);
}
function prefersReducedMotion() {
return (
typeof window === "undefined" ||
!window.requestAnimationFrame ||
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
);
}
/**
* Create one independent scene/history context.
*
* The implementation stores nodes flat. Operation edges describe how their
* visual effects compose, but a Vue component never moves to a different VNode
* parent as edges are added or removed. This is what preserves component state
* while X→Y and Y→Z overlap.
*
* @param options - Initial root view recipe or recipes.
* @returns A self-contained reactive scene. Render it through `OriginScene`.
*
* @example
* ```ts
* const scene = createOriginScene({
* initial: originView(HomeView, undefined, { key: "home" }),
* });
* ```
*/
export function createOriginScene(
options: CreateOriginSceneOptions,
): OriginScene {
const nodes = shallowReactive(new Map<string, SceneNodeState>());
const operations = shallowReactive(new Map<number, MutableOriginOperation>());
const roots = shallowRef<string[]>([]);
const elements = new Map<string, HTMLElement>();
let container: HTMLElement | null = null;
let nodeSequence = 0;
let operationSequence = 0;
function uniqueNodeKey(view: OriginView) {
return `${view.key ?? view.name ?? "view"}::${++nodeSequence}`;
}
function addNode(
view: OriginView,
previousNodeKey?: string,
incomingOperationId?: number,
) {
const node: SceneNodeState = shallowReactive({
key: uniqueNodeKey(view),
sequence: nodeSequence,
view: markRaw(view),
previousNodeKey,
state: incomingOperationId ? "transitioning" : "active",
incomingOperationId,
});
nodes.set(node.key, node);
return node;
}
const initialViews = Array.isArray(options.initial)
? options.initial
: [options.initial];
for (const initial of initialViews) {
const node = addNode(initial);
roots.value = [...roots.value, node.key];
}
function historyNodesFor(node: SceneNodeState) {
const history: SceneNodeState[] = [];
const visited = new Set<string>([node.key]);
let previousKey = node.previousNodeKey;
while (previousKey && !visited.has(previousKey)) {
visited.add(previousKey);
const previous = nodes.get(previousKey);
if (!previous) break;
history.unshift(previous);
previousKey = previous.previousNodeKey;
}
return history;
}
function contextFor(nodeKey: string): OriginContext {
const node = nodes.get(nodeKey);
if (!node)
throw new Error(`Origin scene node "${nodeKey}" no longer exists.`);
const historyNodes = historyNodesFor(node);
return {
nodeKey,
view: node.view,
canGoBack: Boolean(node.previousNodeKey),
previous: historyNodes.at(-1)?.view,
history: historyNodes.map((entry) => entry.view),
};
}
function previousNodeForAction(origin: SceneNodeState, action: OriginAction) {
switch (action.history ?? "push") {
case "push":
return origin.key;
case "back":
return origin.previousNodeKey;
}
}
function operationContext(
operation: MutableOriginOperation,
): OriginChoreographyContext {
return {
progress: operation.progress,
velocity: operation.velocity,
phase: operation.phase,
intent: operation.intent,
originRect: operation.originRect,
targetRect: operation.targetRect,
viewport: defaultViewport(container),
};
}
function effectSetFor(operation: MutableOriginOperation): OriginEffectSet {
return operation.choreography.effects(operationContext(operation));
}
/**
* Return the origin-frame effects inherited by a node. The source effect of
* an ancestor is intentionally excluded: descendants inherit the target side
* of an operation, while only the initiating component receives its source
* side.
*/
function inheritedEffects(
nodeKey: string,
visited = new Set<string>(),
): OriginEffect[] {
if (visited.has(nodeKey)) return [];
visited.add(nodeKey);
const node = nodes.get(nodeKey);
const incoming = node?.incomingOperationId
? operations.get(node.incomingOperationId)
: undefined;
if (!node || !incoming) return [];
const effects = effectSetFor(incoming);
const targetLayer = incoming.placement === "above" ? 1 : -1;
return [
...inheritedEffects(incoming.originKey, visited),
normalizedEffect(effects.frame),
normalizedEffect(effects.target, targetLayer),
];
}
function visualEffects(nodeKey: string) {
const result = inheritedEffects(nodeKey);
const outgoing = [...operations.values()].find(
(operation) => operation.originKey === nodeKey,
);
if (!outgoing) return result;
const effects = effectSetFor(outgoing);
return [
...result,
normalizedEffect(effects.frame),
normalizedEffect(effects.source),
];
}
/**
* Collapse independent effect layers into one host style. Geometry remains
* composable; arbitrary CSS properties use normal local-last precedence.
*/
function styleForNode(nodeKey: string): CSSProperties {
const node = nodes.get(nodeKey);
if (!node) return {};
const transforms: string[] = [];
let opacity = 1;
let layer = 0;
/*
* These rules are compositor invariants rather than visual theming. Keep
* them inline so a missing optional package stylesheet can never place
* scene nodes back into normal block/flex flow and vertically stack views.
*/
const style: CSSProperties = {
position: "absolute",
inset: "0",
width: "100%",
height: "100%",
overflow: "hidden",
};
for (const effect of visualEffects(nodeKey)) {
if (effect.transform && effect.transform !== "none")
transforms.push(effect.transform);
if (effect.opacity !== undefined) opacity *= effect.opacity;
layer += effect.layer ?? 0;
if (effect.style) {
const effectStyle = effect.style;
Object.assign(style, effectStyle);
if (
typeof effectStyle.transform === "string" &&
effectStyle.transform !== "none"
)
transforms.push(effectStyle.transform);
if (effectStyle.opacity !== undefined) {
const numericOpacity = Number(effectStyle.opacity);
if (Number.isFinite(numericOpacity)) opacity *= numericOpacity;
}
}
}
// Explicitly assign the composited properties after Object.assign so an
// individual effect cannot accidentally replace inherited transform work.
style.transform = transforms.length ? transforms.join(" ") : "none";
style.opacity = String(clamp(opacity));
style.zIndex = 1_000_000 + layer * 10_000 + node.sequence;
style.pointerEvents = isNodeInteractive(nodeKey) ? "auto" : "none";
if (node.state === "parked") {
// Parked entries remain mounted in their original flat hosts. Keeping
// the DOM preserves local component state and nested scroll positions,
// while visibility/inert handling removes them from presentation.
style.visibility = "hidden";
style.contentVisibility = "hidden";
}
return style;
}
function outgoingFor(nodeKey: string) {
return [...operations.values()].find(
(operation) => operation.originKey === nodeKey,
);
}
function isNodeInteractive(nodeKey: string) {
const node = nodes.get(nodeKey);
if (!node || node.state === "parked") return false;
// A target being cancelled is already scheduled to disappear. A source
// settling toward commit has ceded new interactions to the retained scene
// beneath/above it. This is node-local fate, not a global "active view".
const incoming = node.incomingOperationId
? operations.get(node.incomingOperationId)
: undefined;
if (incoming?.intent === "cancel") return false;
return outgoingFor(nodeKey)?.intent !== "commit";
}
function updateOperation(id: number, progress: number, velocity = 0) {
const operation = operations.get(id);
if (!operation || operation.phase === "finished") return;
operation.progress = clamp(progress);
operation.velocity = velocity;
if (operation.phase === "preparing") return;
operation.phase = "interactive";
}
function removeNode(nodeKey: string) {
elements.delete(nodeKey);
nodes.delete(nodeKey);
roots.value = roots.value.filter((key) => key !== nodeKey);
}
/**
* Remove a newly created forward branch while leaving the origin and all
* earlier retained entries untouched.
*/
function removeRetainedBranch(nodeKey: string, visited = new Set<string>()) {
if (visited.has(nodeKey)) return;
visited.add(nodeKey);
for (const node of [...nodes.values()]) {
if (node.previousNodeKey === nodeKey)
removeRetainedBranch(node.key, visited);
}
for (const operation of [...operations.values()]) {
if (
operation.originKey === nodeKey ||
operation.targetKey === nodeKey ||
operation.entryTargetKey === nodeKey
)
operations.delete(operation.id);
}
removeNode(nodeKey);
}
function refreshVisibleState(node: SceneNodeState) {
if (node.state === "parked") return;
node.state =
node.incomingOperationId || outgoingFor(node.key)
? "transitioning"
: "active";
}
/**
* Transfer an operation's visual graph position to its target without moving
* either Vue VNode. The history chain is intentionally independent from this
* temporary coordinate graph.
*/
function spliceVisualTarget(origin: SceneNodeState, target: SceneNodeState) {
const parentOperation = origin.incomingOperationId
? operations.get(origin.incomingOperationId)
: undefined;
if (parentOperation) {
parentOperation.targetKey = target.key;
target.incomingOperationId = parentOperation.id;
} else {
roots.value = roots.value.map((key) =>
key === origin.key ? target.key : key,
);
target.incomingOperationId = undefined;
}
}
/**
* Finalize a committed operation.
*
* Push keeps the origin mounted and parks it. Back reuses the existing
* previous node and removes only the entry being popped.
*/
function commitOperation(id: number) {
const operation = operations.get(id);
if (!operation) return false;
const origin = nodes.get(operation.originKey);
const target = nodes.get(operation.targetKey);
if (!origin || !target) return false;
spliceVisualTarget(origin, target);
operation.phase = "finished";
operations.delete(operation.id);
if (operation.history === "push") {
origin.incomingOperationId = undefined;
origin.state = "parked";
} else {
// A committed back pops only the current retained history entry.
removeNode(origin.key);
}
target.state = "active";
refreshVisibleState(target);
return true;
}
function cancelOperation(id: number) {
const operation = operations.get(id);
if (!operation) return;
const origin = nodes.get(operation.originKey);
const target = nodes.get(operation.entryTargetKey);
operation.phase = "finished";
operations.delete(id);
if (operation.history === "back") {
if (target) {
target.incomingOperationId = undefined;
target.state = "parked";
}
} else {
removeRetainedBranch(operation.entryTargetKey);
}
if (origin) {
origin.state = "active";
refreshVisibleState(origin);
}
}
async function settle(id: number, targetProgress: 0 | 1, animate: boolean) {
const operation = operations.get(id);
if (!operation) return;
if (!animate || prefersReducedMotion()) {
operation.progress = targetProgress;
operation.velocity = 0;
return;
}
await new Promise<void>((resolve) => {
let position = operation.progress;
let velocity = Math.max(-8, Math.min(8, operation.velocity));
let previous = performance.now();
const frame = (time: number) => {
const live = operations.get(id);
if (!live) return resolve();
// A damped spring makes release velocity continuous with pointer motion
// without imposing a fixed-duration easing on custom choreographies.
const elapsed = Math.min(
0.032,
Math.max(0.001, (time - previous) / 1000),
);
previous = time;
const iterations = Math.max(1, Math.ceil(elapsed / (1 / 120)));
const dt = elapsed / iterations;
for (let index = 0; index < iterations; index += 1) {
const acceleration =
(targetProgress - position) * 280 - velocity * 30;
velocity += acceleration * dt;
position += velocity * dt;
}
const done =
Math.abs(targetProgress - position) < 0.002 &&
Math.abs(velocity) < 0.02;
live.progress = done ? targetProgress : clamp(position);
live.velocity = done ? 0 : velocity;
if (done) resolve();
else window.requestAnimationFrame(frame);
};
window.requestAnimationFrame(frame);
});
}
async function finishOperation(
id: number,
options: OriginFinishOptions = {},
) {
const operation = operations.get(id);
if (!operation) return false;
const threshold = operation.choreography.commitThreshold ?? 0.36;
// const velocityThreshold = operation.choreography.commitVelocity ?? 0.9;
const shouldCommit =
options.commit ??
(operation.progress >= threshold ||
(operation.progress >= 0.00 &&
operation.velocity >= 0.3));
// operation.velocity >= velocityThreshold));
// The outcome is known synchronously at release. The target can therefore
// originate another gesture while this operation is only visually settling.
operation.intent = shouldCommit ? "commit" : "cancel";
operation.phase = "settling";
await settle(id, shouldCommit ? 1 : 0, options.animate ?? true);
if (!operations.has(id)) return shouldCommit;
if (shouldCommit) return commitOperation(id);
cancelOperation(id);
return false;
}
async function begin(
originKey: string,
action: OriginAction,
): Promise<OriginOperationHandle> {
const origin = nodes.get(originKey);
if (!origin)
throw new Error(`Cannot animate from missing origin "${originKey}".`);
if (origin.state === "parked")
throw new Error(`Cannot animate from parked origin "${originKey}".`);
if (outgoingFor(originKey))
throw new Error(
`Origin "${originKey}" already has an outgoing operation. ` +
"A descendant target may start its own operation instead.",
);
const history = action.history ?? "push";
let target: SceneNodeState;
if (history === "back") {
const previousKey = origin.previousNodeKey;
if (!previousKey)
throw new Error(
`Cannot go back from origin "${originKey}" without a previous entry.`,
);
const retainedTarget = nodes.get(previousKey);
if (!retainedTarget)
throw new Error(
`Cannot go back to missing retained entry "${previousKey}".`,
);
/*
* If back starts while the immediately preceding forward spring is still
* settling, collapse that already-committed edge first. Creating a new
* edge back to its origin would otherwise form X→Y→X. Reciprocal
* choreographies begin at the same visual endpoints, so this handoff is
* continuous without remounting either node.
*/
const incoming = origin.incomingOperationId
? operations.get(origin.incomingOperationId)
: undefined;
if (incoming?.originKey === retainedTarget.key) {
if (incoming.intent !== "commit")
throw new Error(
"Cannot go back through an undecided forward operation.",
);
commitOperation(incoming.id);
}
if (outgoingFor(retainedTarget.key))
throw new Error(
`Retained target "${retainedTarget.key}" already has an outgoing operation.`,
);
if (retainedTarget.incomingOperationId)
throw new Error(
`Retained target "${retainedTarget.key}" is already transitioning.`,
);
target = retainedTarget;
} else {
if (!action.target)
throw new Error(
`A "${history}" operation requires a target view recipe.`,
);
target = addNode(action.target, previousNodeForAction(origin, action));
}
const operationId = ++operationSequence;
target.incomingOperationId = operationId;
target.state = "transitioning";
origin.state = "transitioning";
const operation = reactive<MutableOriginOperation>({
id: operationId,
originKey,
targetKey: target.key,
entryTargetKey: target.key,
choreography: markRaw(action.choreography),
placement: action.placement ?? "above",
history,
progress: 0,
velocity: 0,
phase: "preparing",
intent: "undecided",
originRect: elementRect(elements.get(originKey)),
});
operations.set(operation.id, operation);
// Let Vue mount the target before measuring it. Gesture composables buffer
// pointer progress while this short preparation step is pending.
await nextTick();
operation.targetRect = elementRect(elements.get(target.key));
operation.phase = "interactive";
return {
id: operation.id,
originKey,
targetKey: target.key,
update: (progress, velocity) =>
updateOperation(operation.id, progress, velocity),
finish: (finishOptions) => finishOperation(operation.id, finishOptions),
cancel: async (cancelOptions) => {
await finishOperation(operation.id, {
commit: false,
animate: cancelOptions?.animate,
});
},
};
}
async function perform(originKey: string, action: OriginAction) {
const operation = await begin(originKey, action);
return operation.finish({ commit: true });
}
return {
nodes: computed(
() =>
[...nodes.values()].map((node) => ({
...node,
history: historyNodesFor(node).map((entry) => entry.view),
})) as readonly OriginSceneNode[],
),
operations: computed(
() => [...operations.values()] as readonly OriginOperation[],
),
roots,
contextFor,
begin,
perform,
registerElement(nodeKey, element) {
if (element) elements.set(nodeKey, element);
else elements.delete(nodeKey);
},
registerContainer(element) {
container = element;
},
styleForNode,
isNodeInteractive,
};
}

View File

@@ -0,0 +1,37 @@
.nvo-scene {
position: relative;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
overflow: hidden;
isolation: isolate;
contain: layout paint;
background: #000;
}
.nvo-node {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: hidden;
will-change: transform, opacity;
backface-visibility: hidden;
}
.nvo-node > * {
width: 100%;
height: 100%;
}
.nvo-gesture {
-webkit-user-select: none;
user-select: none;
}
@media (prefers-reduced-motion: reduce) {
.nvo-node {
will-change: auto;
}
}

View File

@@ -0,0 +1,800 @@
import type { Component, ComputedRef, CSSProperties, ShallowRef } from "vue";
/**
* A view is a recipe for creating a Vue component, not a mounted instance.
*
* Forward navigation uses the recipe to create a mounted history entry. That
* instance remains mounted until a committed back operation pops it.
*
* @typeParam Props - The props accepted by the component recipe.
*/
export interface OriginView<
Props extends Record<string, unknown> = Record<string, unknown>,
> {
/** The Vue component definition that will be mounted for this recipe. */
readonly component: Component;
/** Props passed to the component when the recipe is mounted. */
readonly props?: Readonly<Props>;
/**
* A developer-facing recipe identity used in diagnostics and as the prefix
* of generated scene-node keys. It does not make Vue reuse an instance.
*/
readonly key?: string;
/** A human-readable label used by diagnostics and DOM data attributes. */
readonly name?: string;
}
/**
* The lifecycle phase of an operation edge.
*
* - `preparing`: the target has been added and Vue is mounting it.
* - `interactive`: progress may be controlled by a gesture or application.
* - `settling`: the commit/cancel decision is fixed and the spring is running.
* - `finished`: the graph rewrite or cancellation cleanup has completed.
*/
export type OriginOperationPhase =
"preparing" | "interactive" | "settling" | "finished";
/**
* The outcome selected for an operation.
*
* Intent remains `undecided` until `finish()` is called. It becomes final
* before the settling animation completes, allowing the retained target to
* originate its own operation immediately.
*/
export type OriginOperationIntent = "undecided" | "commit" | "cancel";
/**
* Controls how an operation participates in retained instance history.
*
* - `push`: create a target whose previous entry is the mounted origin.
* - `back`: reuse the mounted previous entry and pop the origin on commit.
*/
export type OriginHistoryMode = "push" | "back";
/**
* The target's stacking relationship to its origin while an operation exists.
*/
export type OriginPlacement = "above" | "under";
/**
* Visibility/lifecycle role of a mounted scene node.
*
* - `active`: currently exposed for normal interaction.
* - `transitioning`: participating in at least one live operation edge.
* - `parked`: retained in history but visually hidden and inert.
*/
export type OriginSceneNodeState = "active" | "transitioning" | "parked";
/** A rectangle measured in viewport CSS pixels. */
export interface OriginRect {
/** Distance from the viewport's top edge in CSS pixels. */
top: number;
/** Distance from the viewport's left edge in CSS pixels. */
left: number;
/** Rectangle width in CSS pixels. */
width: number;
/** Rectangle height in CSS pixels. */
height: number;
}
/**
* One composable contribution to a scene node's final visual style.
*
* Transforms are concatenated in origin-to-descendant order and opacity values
* are multiplied. This lets X→Y and Y→Z affect Y simultaneously without either
* routine replacing the other routine's CSS transform.
*/
export interface OriginEffect {
/**
* A CSS transform contribution. Transform strings from inherited and local
* origin frames are concatenated rather than replacing one another.
*/
transform?: string;
/**
* An opacity contribution between `0` and `1`. Contributions from multiple
* frames are multiplied and the final value is clamped.
*/
opacity?: number;
/**
* Relative stacking contribution. `above()` defaults the target to +1 and
* `under()` defaults it to -1.
*/
layer?: number;
/**
* Escape hatch for non-geometric effects such as filter, clipPath, or
* borderRadius. Later/local effects override inherited properties.
*/
style?: CSSProperties;
}
/**
* Independent visual contributions calculated for one operation edge.
*
* Any omitted contribution is treated as an identity effect.
*/
export interface OriginEffectSet {
/**
* Applied to both source and target. This is useful for moving an entire
* origin-relative coordinate frame.
*/
frame?: OriginEffect;
/** Applied to the component that initiated this operation. */
source?: OriginEffect;
/** Applied to the component created by this operation and its descendants. */
target?: OriginEffect;
}
/** Values supplied whenever a choreography calculates its visual effects. */
export interface OriginChoreographyContext {
/** Normalized operation progress, clamped to the inclusive range `0..1`. */
progress: number;
/**
* Normalized progress units per second. Positive velocity moves toward
* commit; negative velocity moves back toward cancellation.
*/
velocity: number;
/** Current lifecycle phase of the operation. */
phase: OriginOperationPhase;
/** Commit/cancel outcome, if release has already selected one. */
intent: OriginOperationIntent;
/** Origin host bounds captured immediately before the target mounts. */
originRect?: OriginRect;
/** Target host bounds measured after Vue mounts the target. */
targetRect?: OriginRect;
/** Scene-container bounds, or the browser viewport when no container exists. */
viewport: OriginRect;
}
/**
* A choreography describes visual relationships only. Component creation,
* history, and cleanup are performed by the scene operation that uses it.
*/
export interface OriginChoreography {
/** Optional diagnostic name surfaced by scene inspectors and devtools. */
readonly name?: string;
/**
* Calculate source, target, and shared-frame contributions for the current
* operation state. This function should be deterministic and side-effect
* free because it can run many times per animation frame.
*/
effects(context: OriginChoreographyContext): OriginEffectSet;
/**
* Gesture progress required to retain the target after release.
*
* @defaultValue `0.36`
*/
readonly commitThreshold?: number;
/**
* Normalized positive release velocity that can commit a deliberate flick
* once progress has reached at least `0.06`.
*
* @defaultValue `0.9`
*/
readonly commitVelocity?: number;
}
/** A complete request to create and animate a target view from an origin. */
export interface OriginAction {
/**
* Recipe for a newly created target. A back action omits this because the
* scene resolves its already-mounted previous entry.
*/
readonly target?: OriginView;
/** Visual relationship applied to the source, target, and shared frame. */
readonly choreography: OriginChoreography;
/**
* Target stacking relationship during the operation.
*
* @defaultValue `"above"`
*/
readonly placement?: OriginPlacement;
/**
* History mutation applied to the target recipe.
*
* @defaultValue `"push"`
*/
readonly history?: OriginHistoryMode;
}
/**
* A scene mutation without visual choreography.
*
* Gesture builders keep navigation intent separate from animation so the same
* destination can be paired with different component-local interactions.
* Calling `.animate()` materializes this intent as an {@link OriginAction}.
*/
export interface OriginNavigationIntent {
/**
* Recipe for a newly created target. Back navigation omits this because the
* scene resolves the retained previous instance.
*/
readonly target?: OriginView;
/** Target stacking relationship while the gesture operation is visible. */
readonly placement?: OriginPlacement;
/** Retained-history mutation performed if the gesture commits. */
readonly history: OriginHistoryMode;
}
/**
* Read-only diagnostic representation of one mounted scene node.
*
* A node corresponds to one currently mounted Vue component instance.
*/
export interface OriginSceneNode {
/** Unique identity for this particular mounted scene node. */
readonly key: string;
/** Monotonically increasing creation order within the scene. */
readonly sequence: number;
/** Recipe used to create the node's component. */
readonly view: OriginView;
/** Recipes for the retained instance chain preceding this node. */
readonly history: readonly OriginView[];
/** Key of the retained mounted entry immediately before this node. */
readonly previousNodeKey?: string;
/** Current visibility/lifecycle role of this mounted instance. */
readonly state: OriginSceneNodeState;
/** Live operation currently positioning this node as its target. */
readonly incomingOperationId?: number;
}
/** Read-only diagnostic representation of one live operation edge. */
export interface OriginOperation {
/** Unique, monotonically increasing operation identity within the scene. */
readonly id: number;
/** Key of the mounted component that originated the operation. */
readonly originKey: string;
/** Key of the newly created or retained target component. */
readonly targetKey: string;
/** Original history entry targeted before visual-edge rewrites. */
readonly entryTargetKey: string;
/** Choreography currently calculating this edge's effects. */
readonly choreography: OriginChoreography;
/** Target stacking relationship to the origin. */
readonly placement: OriginPlacement;
/** Retained-history behavior performed if this operation commits. */
readonly history: OriginHistoryMode;
/** Normalized progress in the inclusive range `0..1`. */
readonly progress: number;
/** Latest normalized velocity in progress units per second. */
readonly velocity: number;
/** Current operation lifecycle phase. */
readonly phase: OriginOperationPhase;
/** Selected operation outcome. */
readonly intent: OriginOperationIntent;
}
/**
* Node-local information supplied to component-owned action factories.
*
* There is deliberately no global `current` or `active` view. The component
* handling the event is the origin represented by this context.
*/
export interface OriginContext {
/** Unique key of the mounted node that owns the interaction. */
readonly nodeKey: string;
/** Recipe used to create the origin node. */
readonly view: OriginView;
/** Whether this retained history entry has a mounted previous instance. */
readonly canGoBack: boolean;
/** Recipe belonging to the retained previous instance, when available. */
readonly previous?: OriginView;
/** Recipes belonging to all retained instances preceding this node. */
readonly history: readonly OriginView[];
}
/** Options controlling how a manually managed operation is resolved. */
export interface OriginFinishOptions {
/**
* Override the choreography's progress/velocity decision. Omit it to use the
* choreography's commit thresholds.
*/
commit?: boolean;
/**
* Whether to run the settling spring before finalizing the graph.
*
* @defaultValue `true`
*/
animate?: boolean;
}
/** Imperative controller for one mounted, live operation edge. */
export interface OriginOperationHandle {
/** Identity of the live operation controlled by this handle. */
readonly id: number;
/** Key of the source node that created the operation. */
readonly originKey: string;
/** Key of the newly mounted or retained target node. */
readonly targetKey: string;
/**
* Set interactive progress and optional velocity.
*
* Progress is clamped to `0..1`. Velocity is normalized to progress units
* per second and is used by the choreography's flick threshold.
*/
update(progress: number, velocity?: number): void;
/**
* Select commit/cancel, run the settling spring, and finalize the scene.
*
* @returns `true` when the target was retained, otherwise `false`.
*/
finish(options?: OriginFinishOptions): Promise<boolean>;
/** Cancel the operation and remove its target branch. */
cancel(options?: Pick<OriginFinishOptions, "animate">): Promise<void>;
}
/**
* A self-contained scene graph, view-recipe history, and animation compositor.
*
* Multiple scenes may coexist and do not share nodes, history, or operations.
*/
export interface OriginScene {
/** Reactive snapshot of every currently mounted node. */
readonly nodes: ComputedRef<readonly OriginSceneNode[]>;
/** Reactive snapshot of every live animation/gesture operation. */
readonly operations: ComputedRef<readonly OriginOperation[]>;
/** Keys of visible operation-graph roots; parked history is excluded. */
readonly roots: Readonly<ShallowRef<readonly string[]>>;
/** Return the node-local action context for a mounted node key. */
contextFor(nodeKey: string): OriginContext;
/**
* Create a forward target or reveal a retained back target, then return
* manual control of its operation.
*
* @throws If the origin is missing, parked, or already owns an outgoing
* operation.
*/
begin(
originKey: string,
action: OriginAction,
): Promise<OriginOperationHandle>;
/**
* Resolve a target and commit it programmatically using the settling spring.
*
* @returns `true` once the target has been committed.
*/
perform(originKey: string, action: OriginAction): Promise<boolean>;
/** @internal Register or unregister a scene node's host element. */
registerElement(nodeKey: string, element: HTMLElement | null): void;
/** @internal Register or unregister the scene container used for measurement. */
registerContainer(element: HTMLElement | null): void;
/** @internal Calculate the fully composited inline style for one node host. */
styleForNode(nodeKey: string): CSSProperties;
/** @internal Determine whether a node should currently receive pointer input. */
isNodeInteractive(nodeKey: string): boolean;
}
/** Node-scoped controls returned by {@link useOrigin}. */
export interface UseOrigin {
/** Key of the mounted node containing the calling component. */
readonly nodeKey: string;
/** Scene containing the calling component. */
readonly scene: OriginScene;
/** Reactive context for the calling component's scene node. */
readonly context: ComputedRef<OriginContext>;
/** Reactive shorthand for `context.value.view`. */
readonly view: ComputedRef<OriginView>;
/** Reactive shorthand for `context.value.previous`. */
readonly previous: ComputedRef<OriginView | undefined>;
/** Reactive shorthand for `context.value.canGoBack`. */
readonly canGoBack: ComputedRef<boolean>;
/** Begin an interactively controlled operation from this component. */
begin(action: OriginAction): Promise<OriginOperationHandle>;
/** Programmatically create and commit a target from this component. */
perform(action: OriginAction): Promise<boolean>;
}
/**
* Values accepted from gesture action factories.
*
* Returning `null` or `undefined` declines the recognized gesture. A promise
* allows lazy target resolution; stale results are discarded after release or
* cancellation.
*/
export type MaybeOriginAction =
OriginAction | null | undefined | Promise<OriginAction | null | undefined>;
/** Direction in which pointer movement advances operation progress. */
export type OriginGestureDirection = "left" | "right" | "up" | "down";
/** Physical side of the gesture host used to admit pointer-down. */
export type OriginGestureEdge = "left" | "right" | "top" | "bottom";
/**
* CSS length accepted by an edge-based gesture start rule.
*
* Numbers are interpreted as CSS pixels. Strings may use normal CSS lengths,
* percentages, `calc()`, or `clamp()`, and are resolved against the gesture
* host when pointer-down occurs.
*/
export type OriginGestureDistance = number | string;
/** A pointer position measured in viewport and gesture-host coordinates. */
export interface OriginGesturePoint {
/** Viewport-relative horizontal position. */
readonly clientX: number;
/** Viewport-relative vertical position. */
readonly clientY: number;
/** Horizontal position relative to the gesture host's left edge. */
readonly localX: number;
/** Vertical position relative to the gesture host's top edge. */
readonly localY: number;
}
/** Context supplied to a custom `.from.when()` start predicate. */
export interface OriginGestureStartContext {
/** Native pointer-down event being considered. */
readonly event: PointerEvent;
/** Component instance from which the gesture would originate. */
readonly origin: OriginContext;
/** Element carrying the gesture's pointer handlers. */
readonly host: HTMLElement;
/** Gesture-host bounds captured at pointer-down. */
readonly bounds: OriginRect;
/** Pointer position at pointer-down. */
readonly point: OriginGesturePoint;
}
/**
* Synchronous predicate deciding whether a pointer-down may become a gesture.
*
* Directional recognition still occurs later, after movement passes the
* configured intent threshold.
*/
export type OriginGestureStartPredicate = (
context: OriginGestureStartContext,
) => boolean;
/** Start policy stored in a completed gesture definition. */
export type OriginGestureStart =
| {
/** Recognize pointer-down anywhere on the host. */
readonly kind: "anywhere";
}
| {
/** Recognize pointer-down within a CSS distance of one host side. */
readonly kind: "edge";
readonly edge: OriginGestureEdge;
readonly distance: OriginGestureDistance;
}
| {
/** Recognize pointer-down when application policy returns `true`. */
readonly kind: "when";
readonly predicate: OriginGestureStartPredicate;
};
/** Direction-recognition tuning accepted by `.to.left()` and its siblings. */
export interface OriginGestureDirectionOptions {
/**
* Minimum directed movement in CSS pixels before the gesture captures.
*
* @defaultValue `8`
*/
readonly threshold?: number;
/**
* Ratio by which directed movement must exceed cross-axis movement.
*
* @defaultValue `1.15`
*/
readonly axisDominance?: number;
}
/** Values available when a custom `.complete()` policy runs on pointer-up. */
export interface OriginGestureCompletionContext {
/** Component instance that originated the gesture. */
readonly origin: OriginContext;
/** Recognized movement direction. */
readonly direction: OriginGestureDirection;
/** Normalized directed distance, clamped to `0..1`. */
readonly progress: number;
/** Latest normalized progress units per second. */
readonly velocity: number;
/** Directed movement from pointer-down in CSS pixels. */
readonly distance: number;
/** Absolute cross-axis movement from pointer-down in CSS pixels. */
readonly crossDistance: number;
/** Elapsed time since pointer-down in milliseconds. */
readonly duration: number;
/** Native pointer-up event that ended the interaction. */
readonly event: PointerEvent;
/** Element carrying the gesture's pointer handlers. */
readonly host: HTMLElement;
/** Gesture-host bounds captured at pointer-down. */
readonly bounds: OriginRect;
/** Pointer position captured at pointer-down. */
readonly start: OriginGesturePoint;
/** Pointer position at release. */
readonly current: OriginGesturePoint;
}
/** Synchronous commit/cancel policy installed by `.complete()`. */
export type OriginGestureCompletionPredicate = (
context: OriginGestureCompletionContext,
) => boolean;
/**
* Values accepted from a gesture builder's `.navigate()` factory.
*
* A promise supports lazy view selection. Stale resolutions are discarded if
* the pointer has already ended or been cancelled.
*/
export type MaybeOriginNavigationIntent =
| OriginNavigationIntent
| null
| undefined
| Promise<OriginNavigationIntent | null | undefined>;
/** Factory that resolves navigation after directional recognition succeeds. */
export type OriginGestureNavigationFactory = (
context: OriginContext,
) => MaybeOriginNavigationIntent;
/**
* Immutable, executable result of a complete gesture builder chain.
*
* Pass this object to {@link useOriginGesture}. An omitted `.from` step is
* represented as an `anywhere` start rule.
*/
export interface OriginGestureDefinition {
/** Discriminator used by the compatibility recognizer overload. */
readonly kind: "origin-gesture-definition";
/** Pointer-down eligibility policy. */
readonly start: OriginGestureStart;
/** Direction in which movement advances operation progress. */
readonly direction: OriginGestureDirection;
/** Directional intent recognition tuning. */
readonly recognition: Readonly<OriginGestureDirectionOptions>;
/**
* Optional release decision. When omitted, choreography thresholds decide.
*/
readonly completion?: OriginGestureCompletionPredicate;
/** Node-local destination/history resolver. */
readonly navigation: OriginGestureNavigationFactory;
/** Visual routine paired with the navigation intent. */
readonly choreography: OriginChoreography;
}
/** Direction-selection stage shared by `gesture.to` and `.from.*().to`. */
export interface OriginGestureToBuilder {
/** Recognize leftward pointer movement. */
left(options?: OriginGestureDirectionOptions): OriginGestureDirectedBuilder;
/** Recognize rightward pointer movement. */
right(options?: OriginGestureDirectionOptions): OriginGestureDirectedBuilder;
/** Recognize upward pointer movement. */
up(options?: OriginGestureDirectionOptions): OriginGestureDirectedBuilder;
/** Recognize downward pointer movement. */
down(options?: OriginGestureDirectionOptions): OriginGestureDirectedBuilder;
}
/** Stage produced after a `.from` policy has been selected. */
export interface OriginGestureFromSelection {
/** Select the direction that advances this gesture. */
readonly to: OriginGestureToBuilder;
}
/** Optional pointer-down policy exposed at the start of a gesture chain. */
export interface OriginGestureFromBuilder {
/** Admit pointer-down within `distance` of the host's left edge. */
left(distance: OriginGestureDistance): OriginGestureFromSelection;
/** Admit pointer-down within `distance` of the host's right edge. */
right(distance: OriginGestureDistance): OriginGestureFromSelection;
/** Admit pointer-down within `distance` of the host's top edge. */
top(distance: OriginGestureDistance): OriginGestureFromSelection;
/** Admit pointer-down within `distance` of the host's bottom edge. */
bottom(distance: OriginGestureDistance): OriginGestureFromSelection;
/** Admit pointer-down anywhere on the gesture host. */
anywhere(): OriginGestureFromSelection;
/** Admit pointer-down when a synchronous application predicate succeeds. */
when(predicate: OriginGestureStartPredicate): OriginGestureFromSelection;
}
/** Builder stage after direction is known and completion remains optional. */
export interface OriginGestureDirectedBuilder {
/** Override the choreography's default release decision. */
complete(
predicate: OriginGestureCompletionPredicate,
): OriginGestureCompletedBuilder;
/** Select the target/history mutation while retaining default completion. */
navigate(
factory: OriginGestureNavigationFactory,
): OriginGestureNavigationBuilder;
}
/** Builder stage after a custom completion policy has been selected. */
export interface OriginGestureCompletedBuilder {
/** Select the target/history mutation performed on commit. */
navigate(
factory: OriginGestureNavigationFactory,
): OriginGestureNavigationBuilder;
}
/** Final builder stage waiting for visual choreography. */
export interface OriginGestureNavigationBuilder {
/** Attach visual choreography and produce an executable definition. */
animate(choreography: OriginChoreography): OriginGestureDefinition;
}
/** Root of the immutable fluent gesture-definition API. */
export interface OriginGestureBuilder {
/** Optionally constrain where pointer-down may begin. */
readonly from: OriginGestureFromBuilder;
/**
* Select movement direction with an implicit `from.anywhere()` start.
*/
readonly to: OriginGestureToBuilder;
}
/** Configuration consumed by {@link useOriginGesture}. */
export interface OriginGestureOptions {
/**
* Direction in which the pointer moves to advance the operation.
*
* The starting edge is the opposite side: `right` begins at the left edge,
* `left` at the right edge, `down` at the top, and `up` at the bottom.
*/
direction: OriginGestureDirection;
/**
* Restrict pointer-down to this many CSS pixels from the gesture host's
* starting edge. Omit it to recognize across the entire host.
*
* This is relative to the bound element, not necessarily the browser
* viewport. Use a positive number such as `24` or `36`.
*/
edge?: number;
/**
* Minimum directed movement in CSS pixels before the gesture captures.
*
* Movement must also dominate the cross-axis by a factor of `1.15`.
*
* @defaultValue `8`
*/
threshold?: number;
/**
* Resolve the scene action after directional recognition succeeds.
*
* Returning no action abandons recognition without modifying the scene.
*/
action(context: OriginContext): MaybeOriginAction;
}
/**
* DOM bindings returned by {@link useOriginGesture}.
*
* Spread or attach all four handlers to the same `HTMLElement`. Apply
* {@link style} as well so native scrolling is preserved on the cross-axis.
*/
export interface OriginGestureBinding {
/** Required size and `touch-action` styles for the gesture host. */
readonly style: Readonly<CSSProperties>;
/** Pointer-down handler that records a potentially eligible gesture. */
readonly onPointerdown: (event: PointerEvent) => void;
/** Pointer-move handler that recognizes and updates the operation. */
readonly onPointermove: (event: PointerEvent) => void;
/** Pointer-up handler that commits or cancels using progress and velocity. */
readonly onPointerup: (event: PointerEvent) => void;
/** Pointer-cancel handler that abandons any captured operation. */
readonly onPointercancel: () => void;
}
/** Props shared by both `OriginGesture` declaration styles. */
export interface OriginGestureBaseProps {
/**
* Native HTML tag rendered as the gesture host.
*
* @defaultValue `"div"`
*/
as?: string;
}
/** Builder-definition props accepted by the `OriginGesture` component. */
export interface OriginGestureDefinitionProps extends OriginGestureBaseProps {
/** Immutable definition produced by the {@link gesture} builder. */
gesture: OriginGestureDefinition;
/** Builder definitions already contain direction. */
direction?: never;
/** Builder definitions already contain their start policy. */
edge?: never;
/** Builder definitions already contain recognition tuning. */
threshold?: never;
/** Builder definitions already contain their navigation factory. */
action?: never;
}
/** Legacy option props accepted by the `OriginGesture` component. */
export interface OriginGestureLegacyProps extends OriginGestureBaseProps {
/** Legacy component declarations do not provide a builder definition. */
gesture?: never;
/** Direction in which pointer movement advances operation progress. */
direction: OriginGestureDirection;
/**
* Eligible pointer-down width in CSS pixels from the host's starting edge.
* Omit it to allow the full component surface.
*/
edge?: number;
/**
* Directed movement required before capture.
*
* @defaultValue `8`
*/
threshold?: number;
/** Node-local action factory invoked only after recognition succeeds. */
action(context: OriginContext): MaybeOriginAction;
}
/**
* Public props accepted by the `OriginGesture` convenience component.
*
* Prefer the builder-definition form. The legacy direction/action form remains
* available for compatibility.
*/
export type OriginGestureProps =
OriginGestureDefinitionProps | OriginGestureLegacyProps;
/** Public props accepted by the multi-definition gesture surface component. */
export interface OriginGestureSurfaceProps {
/**
* Native HTML tag rendered as the shared gesture host.
*
* @defaultValue `"div"`
*/
as?: string;
/**
* Complete immutable definitions installed on the shared host.
*
* The surface adds no start, direction, completion, navigation, or animation
* policy. Definitions should be created by the owning page component.
*/
gestures: readonly OriginGestureDefinition[];
}
/** Public props accepted by the `OriginScene` renderer component. */
export interface OriginSceneProps {
/** Scene instance whose flat component nodes should be rendered. */
scene: OriginScene;
}
/**
* Injection payload provided by each stable scene-node host.
*
* @internal
*/
export interface OriginNodeScope {
/** Scene containing the node. */
readonly scene: OriginScene;
/** Unique key of the mounted node. */
readonly nodeKey: string;
}
/**
* Mutable operation storage used by the scene implementation.
*
* @internal
*/
export interface MutableOriginOperation {
/** Unique operation identity. */
id: number;
/** Origin node key. */
originKey: string;
/** Newly created or retained target node key. */
targetKey: string;
/** Original history entry targeted before visual-edge rewrites. */
entryTargetKey: string;
/** Visual choreography for this edge. */
choreography: OriginChoreography;
/** Target stacking relationship. */
placement: OriginPlacement;
/** Retained-history behavior selected for this operation. */
history: OriginHistoryMode;
/** Normalized progress. */
progress: number;
/** Normalized velocity. */
velocity: number;
/** Current lifecycle phase. */
phase: OriginOperationPhase;
/** Selected outcome. */
intent: OriginOperationIntent;
/** Measured origin bounds, when an element was available. */
originRect?: OriginRect;
/** Measured target bounds, after the target mounted. */
targetRect?: OriginRect;
}