V2: Origin based animations, Gesture Builder, New Demo, non-url-based-routing. Massive improvements.
This commit is contained in:
1
apps/origins-demo/.gitignore
vendored
Normal file
1
apps/origins-demo/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
dev-dist
|
||||
25
apps/origins-demo/index.html
Normal file
25
apps/origins-demo/index.html
Normal file
@@ -0,0 +1,25 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
|
||||
/>
|
||||
<meta name="theme-color" content="#080b12" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta
|
||||
name="apple-mobile-web-app-status-bar-style"
|
||||
content="black-translucent"
|
||||
/>
|
||||
<meta name="apple-mobile-web-app-title" content="Origins" />
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="icon" href="/favicon.svg" />
|
||||
<title>Routeless Origins Lab</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
22
apps/origins-demo/package.json
Normal file
22
apps/origins-demo/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@native-vue-router/origins-demo",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --config vite.config.ts --host",
|
||||
"build": "vue-tsc -p tsconfig.json --noEmit && vite build --config vite.config.ts",
|
||||
"preview": "vite preview --config vite.config.ts --host 0.0.0.0 --port 5173"
|
||||
},
|
||||
"dependencies": {
|
||||
"@native-vue-router/core-v2": "0.1.0-experimental.0",
|
||||
"vue": "^3.5.39"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.7",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.1.1",
|
||||
"vite-plugin-pwa": "^1.1.0",
|
||||
"vue-tsc": "^3.3.5"
|
||||
}
|
||||
}
|
||||
78
apps/origins-demo/src/App.vue
Normal file
78
apps/origins-demo/src/App.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
OriginScene,
|
||||
createOriginScene,
|
||||
originView,
|
||||
} from "@native-vue-router/core-v2";
|
||||
import { labEvents } from "./lab-state";
|
||||
import HubView from "./views/HubView.vue";
|
||||
|
||||
/*
|
||||
* This scene is the entire application state for the experiment. There is no
|
||||
* Vue Router installation, route table, URL, or hidden RouterView.
|
||||
*/
|
||||
const scene = createOriginScene({
|
||||
initial: originView(HubView, undefined, {
|
||||
key: "origins-lab",
|
||||
name: "Origins Lab",
|
||||
}),
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="demo-shell">
|
||||
<OriginScene :scene="scene" />
|
||||
|
||||
<details class="scene-debug">
|
||||
<summary>
|
||||
<span
|
||||
class="debug-pulse"
|
||||
:class="{ live: scene.operations.value.length }"
|
||||
/>
|
||||
{{ scene.nodes.value.length }} node{{
|
||||
scene.nodes.value.length === 1 ? "" : "s"
|
||||
}}
|
||||
</summary>
|
||||
<div class="debug-content" aria-live="polite">
|
||||
<section>
|
||||
<strong>Scene graph</strong>
|
||||
<ol>
|
||||
<li v-for="node in scene.nodes.value" :key="node.key">
|
||||
<span>
|
||||
{{ node.view.name }}
|
||||
<em :class="`node-state node-state--${node.state}`">
|
||||
{{ node.state }}
|
||||
</em>
|
||||
</span>
|
||||
<code>{{ node.key.split("::").at(-1) }}</code>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
<section v-if="scene.operations.value.length">
|
||||
<strong>Animation edges</strong>
|
||||
<div
|
||||
v-for="operation in scene.operations.value"
|
||||
:key="operation.id"
|
||||
class="debug-operation"
|
||||
>
|
||||
<span>{{ operation.choreography.name }}</span>
|
||||
<progress :value="operation.progress" max="1" />
|
||||
<small>
|
||||
{{ operation.phase }} ·
|
||||
{{ Math.round(operation.progress * 100) }}%
|
||||
</small>
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<strong>Recent Vue lifecycle</strong>
|
||||
<ul class="event-log">
|
||||
<li v-for="event in labEvents.slice(0, 6)" :key="event.id">
|
||||
<time>{{ event.time }}</time>
|
||||
{{ event.message }}
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</template>
|
||||
15
apps/origins-demo/src/components/InstanceCard.vue
Normal file
15
apps/origins-demo/src/components/InstanceCard.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
label: string;
|
||||
instance: string;
|
||||
status: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="instance-card">
|
||||
<span>{{ label }}</span>
|
||||
<strong>{{ instance }}</strong>
|
||||
<small>{{ status }}</small>
|
||||
</div>
|
||||
</template>
|
||||
37
apps/origins-demo/src/gallery-data.ts
Normal file
37
apps/origins-demo/src/gallery-data.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export interface DemoPhoto {
|
||||
id: string;
|
||||
title: string;
|
||||
location: string;
|
||||
gradient: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const demoPhotos: readonly DemoPhoto[] = [
|
||||
{
|
||||
id: "ember",
|
||||
title: "Afterglow",
|
||||
location: "Reykjanes · 23:14",
|
||||
gradient:
|
||||
"radial-gradient(circle at 72% 25%, #ffcc8a 0, #ff774d 18%, transparent 43%), linear-gradient(145deg, #622c54, #171526 72%)",
|
||||
description:
|
||||
"A warm focus portal demonstrates scale, blur, opacity, and rounded-corner effects.",
|
||||
},
|
||||
{
|
||||
id: "tide",
|
||||
title: "Low tide",
|
||||
location: "Lofoten · 05:42",
|
||||
gradient:
|
||||
"radial-gradient(circle at 25% 25%, #b8f2ff 0, #3f9eb8 24%, transparent 48%), linear-gradient(160deg, #123b50, #07131f 72%)",
|
||||
description:
|
||||
"Swipe left while this view is present to create the next photo as another origin.",
|
||||
},
|
||||
{
|
||||
id: "moss",
|
||||
title: "Quiet giant",
|
||||
location: "Yakushima · 16:08",
|
||||
gradient:
|
||||
"radial-gradient(circle at 64% 30%, #d9ff9f 0, #608b52 22%, transparent 45%), linear-gradient(145deg, #294235, #091812 74%)",
|
||||
description:
|
||||
"The like button is component-local state. Move forward, then return to see the same retained instance and state.",
|
||||
},
|
||||
];
|
||||
49
apps/origins-demo/src/lab-state.ts
Normal file
49
apps/origins-demo/src/lab-state.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { onBeforeUnmount, onMounted, reactive, ref } from "vue";
|
||||
|
||||
export interface LabEvent {
|
||||
id: number;
|
||||
message: string;
|
||||
time: string;
|
||||
}
|
||||
|
||||
let instanceSequence = 0;
|
||||
let eventSequence = 0;
|
||||
|
||||
export const labEvents = reactive<LabEvent[]>([]);
|
||||
|
||||
export function recordLabEvent(message: string) {
|
||||
labEvents.unshift({
|
||||
id: ++eventSequence,
|
||||
message,
|
||||
time: new Date().toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
}),
|
||||
});
|
||||
if (labEvents.length > 18) labEvents.length = 18;
|
||||
}
|
||||
|
||||
/**
|
||||
* Give every Vue setup execution a visible identity. A scene-edge collapse
|
||||
* and a forward push both retain the ID. A committed back only unmounts the
|
||||
* entry being popped, then reveals the exact previous instance.
|
||||
*/
|
||||
export function useDemoInstance(label: string) {
|
||||
const instance = `${label.slice(0, 2).toUpperCase()}-${String(
|
||||
++instanceSequence,
|
||||
).padStart(2, "0")}`;
|
||||
const status = ref("created");
|
||||
recordLabEvent(`${instance} created`);
|
||||
|
||||
onMounted(() => {
|
||||
status.value = "mounted";
|
||||
recordLabEvent(`${instance} mounted`);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
status.value = "unmounting";
|
||||
recordLabEvent(`${instance} unmounted`);
|
||||
});
|
||||
|
||||
return { instance, status };
|
||||
}
|
||||
13
apps/origins-demo/src/main.ts
Normal file
13
apps/origins-demo/src/main.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { createApp } from "vue";
|
||||
import { registerSW } from "virtual:pwa-register";
|
||||
import App from "./App.vue";
|
||||
import "./style.css";
|
||||
|
||||
/*
|
||||
* Registration is deliberately immediate so an installed app becomes
|
||||
* offline-capable during its first session. `autoUpdate` in the Vite plugin
|
||||
* activates a fresh build without requiring a custom update prompt yet.
|
||||
*/
|
||||
registerSW({ immediate: true });
|
||||
|
||||
createApp(App).mount("#app");
|
||||
325
apps/origins-demo/src/motions.ts
Normal file
325
apps/origins-demo/src/motions.ts
Normal file
@@ -0,0 +1,325 @@
|
||||
import { defineOriginChoreography } from "@native-vue-router/core-v2";
|
||||
|
||||
const percent = (value: number) => `${(value * 100).toFixed(3)}%`;
|
||||
|
||||
/** A bottom-up cover similar to presenting a native media player. */
|
||||
export const coverUp = defineOriginChoreography({
|
||||
name: "cover-up",
|
||||
commitThreshold: 0.3,
|
||||
commitVelocity: 0.75,
|
||||
effects: ({ progress }) => ({
|
||||
source: {
|
||||
transform: `translate3d(0, ${percent(progress * -0.035)}, 0) scale(${1 - progress * 0.055})`,
|
||||
opacity: 1 - progress * 0.22,
|
||||
style: { filter: `saturate(${1 - progress * 0.3})` },
|
||||
},
|
||||
target: {
|
||||
transform: `translate3d(0, ${percent(1 - progress)}, 0)`,
|
||||
style: {
|
||||
borderRadius: `${(1 - progress) * 30}px`,
|
||||
boxShadow: `0 -24px 80px rgb(0 0 0 / ${progress * 0.38})`,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
/** Reverse of coverUp. The retained origin instance is revealed underneath. */
|
||||
export const coverDown = defineOriginChoreography({
|
||||
name: "cover-down",
|
||||
commitThreshold: 0.28,
|
||||
commitVelocity: 0.72,
|
||||
effects: ({ progress }) => ({
|
||||
source: {
|
||||
transform: `translate3d(0, ${percent(progress)}, 0)`,
|
||||
style: { borderRadius: `${progress * 30}px` },
|
||||
},
|
||||
target: {
|
||||
transform: `translate3d(0, ${percent(-0.035 + progress * 0.035)}, 0) scale(${0.945 + progress * 0.055})`,
|
||||
opacity: 0.78 + progress * 0.22,
|
||||
style: { filter: `saturate(${0.7 + progress * 0.3})` },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* A scale-and-focus transition. Filters use local-last CSS composition while
|
||||
* geometry remains independently composable with any ancestor operation.
|
||||
*/
|
||||
export const focusPortal = defineOriginChoreography({
|
||||
name: "focus-portal",
|
||||
commitThreshold: 0.32,
|
||||
effects: ({ progress }) => ({
|
||||
source: {
|
||||
transform: `scale(${1 - progress * 0.12})`,
|
||||
opacity: 1 - progress * 0.55,
|
||||
style: { filter: `blur(${progress * 8}px)` },
|
||||
},
|
||||
target: {
|
||||
transform: `translate3d(0, ${percent((1 - progress) * 0.08)}, 0) scale(${0.72 + progress * 0.28})`,
|
||||
opacity: progress,
|
||||
style: {
|
||||
borderRadius: `${(1 - progress) * 42}px`,
|
||||
filter: `blur(${(1 - progress) * 3}px)`,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
export const unfocusPortal = defineOriginChoreography({
|
||||
name: "unfocus-portal",
|
||||
commitThreshold: 0.3,
|
||||
effects: ({ progress }) => ({
|
||||
source: {
|
||||
transform: `translate3d(0, ${percent(progress * 0.08)}, 0) scale(${1 - progress * 0.28})`,
|
||||
opacity: 1 - progress,
|
||||
style: {
|
||||
borderRadius: `${progress * 42}px`,
|
||||
filter: `blur(${progress * 3}px)`,
|
||||
},
|
||||
},
|
||||
target: {
|
||||
transform: `scale(${0.88 + progress * 0.12})`,
|
||||
opacity: 0.45 + progress * 0.55,
|
||||
style: { filter: `blur(${(1 - progress) * 8}px)` },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
/** A deliberately expressive transition for the four-direction gesture lab. */
|
||||
export const cardFlip = defineOriginChoreography({
|
||||
name: "card-flip",
|
||||
commitThreshold: 0.42,
|
||||
effects: ({ progress }) => ({
|
||||
source: {
|
||||
transform: `perspective(900px) rotateY(${-progress * 86}deg) scale(${1 - progress * 0.08})`,
|
||||
opacity: 1 - progress * 0.68,
|
||||
style: { transformOrigin: "left center" },
|
||||
},
|
||||
target: {
|
||||
transform: `perspective(900px) rotateY(${(1 - progress) * 86}deg) scale(${0.92 + progress * 0.08})`,
|
||||
opacity: 0.3 + progress * 0.7,
|
||||
style: { transformOrigin: "right center" },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* The frame contribution affects both sides of the edge. If this operation is
|
||||
* chained, descendants inherit the same small vertical arc.
|
||||
*/
|
||||
export const arcSlide = defineOriginChoreography({
|
||||
name: "arc-slide",
|
||||
commitThreshold: 0.34,
|
||||
commitVelocity: 0.8,
|
||||
effects: ({ progress }) => ({
|
||||
frame: {
|
||||
transform: `translate3d(0, ${-Math.sin(progress * Math.PI) * 9}px, 0)`,
|
||||
},
|
||||
source: {
|
||||
transform: `translate3d(${percent(progress * -0.28)}, 0, 0) rotate(${-progress * 1.5}deg) scale(${1 - progress * 0.035})`,
|
||||
opacity: 1 - progress * 0.22,
|
||||
},
|
||||
target: {
|
||||
transform: `translate3d(${percent(1 - progress)}, 0, 0) rotate(${(1 - progress) * 2.5}deg)`,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
export const rise = defineOriginChoreography({
|
||||
name: "rise",
|
||||
commitThreshold: 0.33,
|
||||
effects: ({ progress }) => ({
|
||||
source: {
|
||||
transform: `translate3d(0, ${percent(progress * -0.18)}, 0) scale(${1 - progress * 0.04})`,
|
||||
opacity: 1 - progress * 0.3,
|
||||
},
|
||||
target: {
|
||||
transform: `translate3d(0, ${percent(1 - progress)}, 0)`,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
export const fall = defineOriginChoreography({
|
||||
name: "fall",
|
||||
commitThreshold: 0.33,
|
||||
effects: ({ progress }) => ({
|
||||
source: {
|
||||
transform: `translate3d(0, ${percent(progress)}, 0)`,
|
||||
},
|
||||
target: {
|
||||
transform: `translate3d(0, ${percent(-0.18 + progress * 0.18)}, 0) scale(${0.96 + progress * 0.04})`,
|
||||
opacity: 0.7 + progress * 0.3,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Reveal a full-screen overlay from above while its actual dialog remains
|
||||
* anchored to the left. The gesture's custom start predicate lives in the
|
||||
* originating view; choreography remains concerned only with movement.
|
||||
*/
|
||||
export const dropEdgeDialog = defineOriginChoreography({
|
||||
name: "drop-edge-dialog",
|
||||
commitThreshold: 0.24,
|
||||
commitVelocity: 0.62,
|
||||
effects: ({ progress }) => ({
|
||||
source: {
|
||||
transform: `scale(${1 - progress * 0.025})`,
|
||||
opacity: 1 - progress * 0.38,
|
||||
style: {
|
||||
filter: `saturate(${1 - progress * 0.35})`,
|
||||
},
|
||||
},
|
||||
target: {
|
||||
transform: `translate3d(0, ${percent((1 - progress) * -0.018)}, 0)`,
|
||||
opacity: 1,
|
||||
style: {
|
||||
clipPath: `inset(0 0 ${percent(1 - progress)} 0)`,
|
||||
filter: `drop-shadow(0 28px 70px rgb(0 0 0 / ${progress * 0.55}))`,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
/** Reverse the edge dialog upward and reveal its retained origin instance. */
|
||||
export const liftEdgeDialog = defineOriginChoreography({
|
||||
name: "lift-edge-dialog",
|
||||
commitThreshold: 0.24,
|
||||
commitVelocity: 0.62,
|
||||
effects: ({ progress }) => ({
|
||||
source: {
|
||||
transform: `translate3d(0, ${percent(progress * -0.018)}, 0)`,
|
||||
opacity: 1,
|
||||
style: {
|
||||
clipPath: `inset(0 0 ${percent(progress)} 0)`,
|
||||
},
|
||||
},
|
||||
target: {
|
||||
transform: `scale(${0.975 + progress * 0.025})`,
|
||||
opacity: 0.62 + progress * 0.38,
|
||||
style: {
|
||||
filter: `saturate(${0.65 + progress * 0.35})`,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Compact horizontal motion for a nested carousel. Its smaller source travel
|
||||
* keeps the retained slide visible behind the incoming card.
|
||||
*/
|
||||
export const nestedCarouselForward = defineOriginChoreography({
|
||||
name: "nested-carousel-forward",
|
||||
commitThreshold: 0.3,
|
||||
commitVelocity: 0.7,
|
||||
effects: ({ progress }) => ({
|
||||
source: {
|
||||
transform: `translate3d(${percent(progress * -0.18)}, 0, 0) scale(${1 - progress * 0.045})`,
|
||||
opacity: 1 - progress * 0.35,
|
||||
style: { filter: `saturate(${1 - progress * 0.25})` },
|
||||
},
|
||||
target: {
|
||||
transform: `translate3d(${percent(1 - progress)}, 0, 0)`,
|
||||
style: {
|
||||
borderRadius: `${(1 - progress) * 28}px`,
|
||||
boxShadow: `-24px 0 60px rgb(0 0 0 / ${progress * 0.3})`,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
/** Reverse reveal for the cooperative nested carousel. */
|
||||
export const nestedCarouselBack = defineOriginChoreography({
|
||||
name: "nested-carousel-back",
|
||||
commitThreshold: 0.3,
|
||||
commitVelocity: 0.7,
|
||||
effects: ({ progress }) => ({
|
||||
source: {
|
||||
transform: `translate3d(${percent(progress)}, 0, 0)`,
|
||||
style: { borderRadius: `${progress * 28}px` },
|
||||
},
|
||||
target: {
|
||||
transform: `translate3d(${percent(-0.18 + progress * 0.18)}, 0, 0) scale(${0.955 + progress * 0.045})`,
|
||||
opacity: 0.65 + progress * 0.35,
|
||||
style: { filter: `saturate(${0.75 + progress * 0.25})` },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Vertical nested-deck motion with a shared sideways arc. The frame effect
|
||||
* demonstrates that nested scenes retain the same composition semantics.
|
||||
*/
|
||||
export const nestedDeckForward = defineOriginChoreography({
|
||||
name: "nested-deck-forward",
|
||||
commitThreshold: 0.32,
|
||||
effects: ({ progress }) => ({
|
||||
frame: {
|
||||
transform: `translate3d(${Math.sin(progress * Math.PI) * 10}px, 0, 0)`,
|
||||
},
|
||||
source: {
|
||||
transform: `translate3d(0, ${percent(progress * -0.12)}, 0) rotate(${-progress * 2}deg) scale(${1 - progress * 0.06})`,
|
||||
opacity: 1 - progress * 0.4,
|
||||
},
|
||||
target: {
|
||||
transform: `translate3d(0, ${percent(1 - progress)}, 0) rotate(${(1 - progress) * 3}deg)`,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
/** Downward reverse of the nested vertical deck. */
|
||||
export const nestedDeckBack = defineOriginChoreography({
|
||||
name: "nested-deck-back",
|
||||
commitThreshold: 0.32,
|
||||
effects: ({ progress }) => ({
|
||||
frame: {
|
||||
transform: `translate3d(${-Math.sin(progress * Math.PI) * 10}px, 0, 0)`,
|
||||
},
|
||||
source: {
|
||||
transform: `translate3d(0, ${percent(progress)}, 0) rotate(${progress * 3}deg)`,
|
||||
},
|
||||
target: {
|
||||
transform: `translate3d(0, ${percent(-0.12 + progress * 0.12)}, 0) rotate(${(1 - progress) * -2}deg) scale(${0.94 + progress * 0.06})`,
|
||||
opacity: 0.6 + progress * 0.4,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Intentionally dramatic cube turn used by the conflict case so it is obvious
|
||||
* when the child scene, rather than the page scene, owns the gesture.
|
||||
*/
|
||||
export const nestedCubeForward = defineOriginChoreography({
|
||||
name: "nested-cube-forward",
|
||||
commitThreshold: 0.38,
|
||||
effects: ({ progress }) => ({
|
||||
source: {
|
||||
transform: `perspective(720px) translate3d(${percent(progress * -0.5)}, 0, 0) rotateY(${progress * 72}deg)`,
|
||||
opacity: 1 - progress * 0.55,
|
||||
style: { transformOrigin: "right center" },
|
||||
},
|
||||
target: {
|
||||
transform: `perspective(720px) translate3d(${percent((1 - progress) * 0.5)}, 0, 0) rotateY(${(progress - 1) * 72}deg)`,
|
||||
opacity: 0.45 + progress * 0.55,
|
||||
style: { transformOrigin: "left center" },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
/** Reverse cube turn for retained slides in the conflict case. */
|
||||
export const nestedCubeBack = defineOriginChoreography({
|
||||
name: "nested-cube-back",
|
||||
commitThreshold: 0.38,
|
||||
effects: ({ progress }) => ({
|
||||
source: {
|
||||
transform: `perspective(720px) translate3d(${percent(progress * 0.5)}, 0, 0) rotateY(${-progress * 72}deg)`,
|
||||
opacity: 1 - progress * 0.55,
|
||||
style: { transformOrigin: "left center" },
|
||||
},
|
||||
target: {
|
||||
transform: `perspective(720px) translate3d(${percent((progress - 1) * 0.5)}, 0, 0) rotateY(${(1 - progress) * 72}deg)`,
|
||||
opacity: 0.45 + progress * 0.55,
|
||||
style: { transformOrigin: "right center" },
|
||||
},
|
||||
}),
|
||||
});
|
||||
1963
apps/origins-demo/src/style.css
Normal file
1963
apps/origins-demo/src/style.css
Normal file
File diff suppressed because it is too large
Load Diff
65
apps/origins-demo/src/views/ChatView.vue
Normal file
65
apps/origins-demo/src/views/ChatView.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref } from "vue";
|
||||
import { back, slideRight, useOrigin } from "@native-vue-router/core-v2";
|
||||
import InstanceCard from "../components/InstanceCard.vue";
|
||||
import { useDemoInstance } from "../lab-state";
|
||||
|
||||
const origin = useOrigin();
|
||||
const identity = useDemoInstance("Chat");
|
||||
const draft = ref("");
|
||||
const messages = ref([
|
||||
"This page deliberately owns no edge recognizer.",
|
||||
"Try swiping right. Nothing should happen.",
|
||||
]);
|
||||
|
||||
function goBack() {
|
||||
if (origin.context.value.canGoBack) void origin.perform(back(slideRight));
|
||||
}
|
||||
|
||||
async function send() {
|
||||
const message = draft.value.trim();
|
||||
if (!message) return;
|
||||
messages.value.push(message);
|
||||
draft.value = "";
|
||||
await nextTick();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!--
|
||||
No gesture binding is used here. Gesture absence is the policy, rather
|
||||
than a global back gesture followed by an exception.
|
||||
-->
|
||||
<div class="chat-view">
|
||||
<header class="chat-header">
|
||||
<button type="button" @click="goBack">←</button>
|
||||
<div class="chat-avatar">M</div>
|
||||
<div>
|
||||
<strong>Maya</strong>
|
||||
<span>online</span>
|
||||
</div>
|
||||
<span class="chat-lock">edge locked</span>
|
||||
</header>
|
||||
|
||||
<main class="messages">
|
||||
<div
|
||||
v-for="(message, index) in messages"
|
||||
:key="`${index}-${message}`"
|
||||
class="message"
|
||||
:class="{ 'message--mine': index > 1 }"
|
||||
>
|
||||
{{ message }}
|
||||
</div>
|
||||
<InstanceCard
|
||||
label="Chat instance"
|
||||
:instance="identity.instance"
|
||||
:status="identity.status.value"
|
||||
/>
|
||||
</main>
|
||||
|
||||
<form class="composer" @submit.prevent="send">
|
||||
<input v-model="draft" placeholder="Write something…" />
|
||||
<button type="submit">Send</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
116
apps/origins-demo/src/views/DirectionLabView.vue
Normal file
116
apps/origins-demo/src/views/DirectionLabView.vue
Normal file
@@ -0,0 +1,116 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
above,
|
||||
back,
|
||||
gesture,
|
||||
OriginGestureSurface,
|
||||
originView,
|
||||
useOrigin,
|
||||
} from "@native-vue-router/core-v2";
|
||||
import InstanceCard from "../components/InstanceCard.vue";
|
||||
import { useDemoInstance } from "../lab-state";
|
||||
import { cardFlip, rise, unfocusPortal } from "../motions";
|
||||
import DirectionResultView from "./DirectionResultView.vue";
|
||||
|
||||
const origin = useOrigin();
|
||||
const identity = useDemoInstance("Direction");
|
||||
|
||||
const eastView = () =>
|
||||
originView(
|
||||
DirectionResultView,
|
||||
{
|
||||
direction: "east",
|
||||
heading: "Horizontal declarations can own a full-page flip.",
|
||||
accent: "#38d8ff",
|
||||
createdBy: identity.instance,
|
||||
},
|
||||
{ key: "direction-east", name: "East Result" },
|
||||
);
|
||||
const openEast = () => above(eastView(), cardFlip);
|
||||
|
||||
const northView = () =>
|
||||
originView(
|
||||
DirectionResultView,
|
||||
{
|
||||
direction: "north",
|
||||
heading: "The same origin can create a completely different view upward.",
|
||||
accent: "#b9ff66",
|
||||
createdBy: identity.instance,
|
||||
},
|
||||
{ key: "direction-north", name: "North Result" },
|
||||
);
|
||||
const openNorth = () => above(northView(), rise);
|
||||
|
||||
const flipGesture = gesture.to
|
||||
.left()
|
||||
.navigate(() => above(eastView()))
|
||||
.animate(cardFlip);
|
||||
const riseGesture = gesture.to
|
||||
.up()
|
||||
.navigate(() => above(northView()))
|
||||
.animate(rise);
|
||||
const backGesture = gesture.from
|
||||
.left("clamp(24px, 8%, 64px)")
|
||||
.to.right()
|
||||
.navigate((context) => (context.canGoBack ? back() : null))
|
||||
.animate(unfocusPortal);
|
||||
const gestures = [flipGesture, riseGesture, backGesture] as const;
|
||||
|
||||
function perform(action: ReturnType<typeof openEast>) {
|
||||
void origin.perform(action);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OriginGestureSurface
|
||||
as="main"
|
||||
class="lab-page direction-view"
|
||||
:gestures="gestures"
|
||||
>
|
||||
<div class="eyebrow">Lab 02 · Direction matrix</div>
|
||||
<h1>One component. Three explicit choices.</h1>
|
||||
<p>
|
||||
Swipe left for a perspective flip, swipe up for a rising cover, or use the
|
||||
left edge to go back. None of these gestures exist outside this view.
|
||||
</p>
|
||||
|
||||
<div class="direction-compass">
|
||||
<button
|
||||
type="button"
|
||||
data-origin-gesture="ignore"
|
||||
class="compass-action compass-action--up"
|
||||
@click="perform(openNorth())"
|
||||
>
|
||||
<span>↑</span>
|
||||
<strong>Rise</strong>
|
||||
<small>target above</small>
|
||||
</button>
|
||||
<div class="compass-core">
|
||||
<span>origin</span>
|
||||
<strong>{{ identity.instance }}</strong>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-origin-gesture="ignore"
|
||||
class="compass-action compass-action--left"
|
||||
@click="perform(openEast())"
|
||||
>
|
||||
<span>←</span>
|
||||
<strong>Flip left</strong>
|
||||
<small>custom perspective</small>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<InstanceCard
|
||||
label="Direction view"
|
||||
:instance="identity.instance"
|
||||
:status="identity.status.value"
|
||||
/>
|
||||
<div class="gesture-map">
|
||||
<span>↑ full-surface target</span>
|
||||
<span>← full-surface target</span>
|
||||
<span>left edge → history back</span>
|
||||
</div>
|
||||
<div class="edge-marker edge-marker--left">back edge</div>
|
||||
</OriginGestureSurface>
|
||||
</template>
|
||||
87
apps/origins-demo/src/views/DirectionResultView.vue
Normal file
87
apps/origins-demo/src/views/DirectionResultView.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
OriginGesture,
|
||||
back,
|
||||
gesture,
|
||||
slideRight,
|
||||
useOrigin,
|
||||
type OriginContext,
|
||||
} from "@native-vue-router/core-v2";
|
||||
import InstanceCard from "../components/InstanceCard.vue";
|
||||
import { useDemoInstance } from "../lab-state";
|
||||
import { fall } from "../motions";
|
||||
|
||||
const props = defineProps<{
|
||||
direction: "east" | "north";
|
||||
heading: string;
|
||||
accent: string;
|
||||
createdBy?: string;
|
||||
}>();
|
||||
|
||||
const origin = useOrigin();
|
||||
const identity = useDemoInstance(props.direction === "east" ? "East" : "North");
|
||||
const goBack = (context: OriginContext) => {
|
||||
if (!context.canGoBack) return null;
|
||||
return back(props.direction === "north" ? fall : slideRight);
|
||||
};
|
||||
|
||||
const returnGesture =
|
||||
props.direction === "east"
|
||||
? gesture.from
|
||||
.left("clamp(24px, 8%, 64px)")
|
||||
.to.right()
|
||||
.navigate((context) => (context.canGoBack ? back() : null))
|
||||
.animate(slideRight)
|
||||
: gesture.to
|
||||
.down()
|
||||
.navigate((context) => (context.canGoBack ? back() : null))
|
||||
.animate(fall);
|
||||
|
||||
function backWithButton() {
|
||||
const action = goBack(origin.context.value);
|
||||
if (action) void origin.perform(action);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OriginGesture
|
||||
class="lab-page direction-result"
|
||||
:class="`direction-result--${direction}`"
|
||||
:style="{ '--result-accent': accent }"
|
||||
:gesture="returnGesture"
|
||||
>
|
||||
<div class="result-orbit" />
|
||||
<div class="eyebrow">Resolved from {{ direction }}</div>
|
||||
<h1>{{ heading }}</h1>
|
||||
<p>
|
||||
This component was created by <strong>{{ createdBy }}</strong
|
||||
>. Its own setup has now produced <strong>{{ identity.instance }}</strong
|
||||
>.
|
||||
</p>
|
||||
<InstanceCard
|
||||
label="Result instance"
|
||||
:instance="identity.instance"
|
||||
:status="identity.status.value"
|
||||
/>
|
||||
<button
|
||||
class="primary-action"
|
||||
type="button"
|
||||
data-origin-gesture="ignore"
|
||||
@click="backWithButton"
|
||||
>
|
||||
Return to the retained direction instance
|
||||
</button>
|
||||
<div
|
||||
class="gesture-hint"
|
||||
:class="{ 'gesture-hint--back': direction === 'east' }"
|
||||
>
|
||||
<template v-if="direction === 'north'"
|
||||
>Swipe down to return <span>↓</span></template
|
||||
>
|
||||
<template v-else><span>←</span> Swipe from the left edge</template>
|
||||
</div>
|
||||
<div v-if="direction === 'east'" class="edge-marker edge-marker--left">
|
||||
back edge
|
||||
</div>
|
||||
</OriginGesture>
|
||||
</template>
|
||||
116
apps/origins-demo/src/views/EdgeDialogView.vue
Normal file
116
apps/origins-demo/src/views/EdgeDialogView.vue
Normal file
@@ -0,0 +1,116 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import {
|
||||
OriginGesture,
|
||||
back,
|
||||
gesture,
|
||||
useOrigin,
|
||||
type OriginContext,
|
||||
} from "@native-vue-router/core-v2";
|
||||
import InstanceCard from "../components/InstanceCard.vue";
|
||||
import { useDemoInstance } from "../lab-state";
|
||||
import { liftEdgeDialog } from "../motions";
|
||||
|
||||
defineProps<{
|
||||
openedBy?: string;
|
||||
predicate?: string;
|
||||
}>();
|
||||
|
||||
const origin = useOrigin();
|
||||
const identity = useDemoInstance("Dialog");
|
||||
const selected = ref("Motion");
|
||||
|
||||
const dismiss = (context: OriginContext) =>
|
||||
context.canGoBack ? back(liftEdgeDialog) : null;
|
||||
|
||||
const dismissGesture = gesture.to
|
||||
.up()
|
||||
.navigate((context) => (context.canGoBack ? back() : null))
|
||||
.animate(liftEdgeDialog);
|
||||
|
||||
function close() {
|
||||
const action = dismiss(origin.context.value);
|
||||
if (action) void origin.perform(action);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OriginGesture class="edge-dialog-overlay" :gesture="dismissGesture">
|
||||
<button
|
||||
class="edge-dialog-backdrop"
|
||||
type="button"
|
||||
data-origin-gesture="ignore"
|
||||
aria-label="Close edge dialog"
|
||||
@click="close"
|
||||
/>
|
||||
|
||||
<section
|
||||
class="edge-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="edge-dialog-title"
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<div class="eyebrow">Custom-origin dialog</div>
|
||||
<h1 id="edge-dialog-title">Dropped from the top.</h1>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-origin-gesture="ignore"
|
||||
aria-label="Close dialog"
|
||||
@click="close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<p>
|
||||
The target is a normal scene component. Its content is anchored left,
|
||||
while the operation moves the target frame from top to bottom.
|
||||
</p>
|
||||
|
||||
<dl class="dialog-facts">
|
||||
<div>
|
||||
<dt>Admitted by</dt>
|
||||
<dd>{{ predicate }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Origin instance</dt>
|
||||
<dd>{{ openedBy }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Placement</dt>
|
||||
<dd>above</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<fieldset>
|
||||
<legend>Dialog-local state</legend>
|
||||
<button
|
||||
v-for="option in ['Motion', 'Gesture', 'Scene']"
|
||||
:key="option"
|
||||
type="button"
|
||||
data-origin-gesture="ignore"
|
||||
:class="{ selected: selected === option }"
|
||||
@click="selected = option"
|
||||
>
|
||||
{{ option }}
|
||||
</button>
|
||||
</fieldset>
|
||||
|
||||
<InstanceCard
|
||||
label="Dialog instance"
|
||||
:instance="identity.instance"
|
||||
:status="identity.status.value"
|
||||
/>
|
||||
|
||||
<footer>
|
||||
<strong>Swipe up anywhere on the panel to dismiss.</strong>
|
||||
<small
|
||||
>The reverse operation reveals the retained origin instance.</small
|
||||
>
|
||||
</footer>
|
||||
</section>
|
||||
</OriginGesture>
|
||||
</template>
|
||||
125
apps/origins-demo/src/views/EdgePredicateView.vue
Normal file
125
apps/origins-demo/src/views/EdgePredicateView.vue
Normal file
@@ -0,0 +1,125 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
above,
|
||||
back,
|
||||
gesture,
|
||||
OriginGestureSurface,
|
||||
originView,
|
||||
useOrigin,
|
||||
type OriginContext,
|
||||
} from "@native-vue-router/core-v2";
|
||||
import InstanceCard from "../components/InstanceCard.vue";
|
||||
import { useDemoInstance } from "../lab-state";
|
||||
import { dropEdgeDialog, unfocusPortal } from "../motions";
|
||||
import EdgeDialogView from "./EdgeDialogView.vue";
|
||||
|
||||
const origin = useOrigin();
|
||||
const identity = useDemoInstance("Predicate");
|
||||
|
||||
/*
|
||||
* This is deliberately not the built-in `edge` option. A down gesture's
|
||||
* built-in edge would be the top edge, while this lab wants down movement to
|
||||
* begin at the left edge. The predicate can describe that cross-axis rule—or
|
||||
* any safe area, shape, percentage, or exclusion zone the application needs.
|
||||
*/
|
||||
function dialogEdgeWidth(host: HTMLElement) {
|
||||
return Math.min(72, Math.max(36, host.clientWidth * 0.08));
|
||||
}
|
||||
|
||||
const dialogView = () =>
|
||||
originView(
|
||||
EdgeDialogView,
|
||||
{
|
||||
openedBy: identity.instance,
|
||||
predicate: "x ≤ clamp(36px, 8%, 72px)",
|
||||
},
|
||||
{ key: "edge-dialog", name: "Edge Dialog" },
|
||||
);
|
||||
|
||||
const openDialog = () => above(dialogView(), dropEdgeDialog);
|
||||
|
||||
const goBack = (context: OriginContext) =>
|
||||
context.canGoBack ? back(unfocusPortal) : null;
|
||||
|
||||
const dialogGesture = gesture.from
|
||||
.when(({ point, host }) => point.localX <= dialogEdgeWidth(host))
|
||||
.to.down()
|
||||
.navigate(() => above(dialogView()))
|
||||
.animate(dropEdgeDialog);
|
||||
|
||||
/*
|
||||
* The same physical left edge also supports a conventional rightward back
|
||||
* gesture. Both recognizers see pointer-down; their axis checks decide which
|
||||
* one captures once movement becomes unambiguous.
|
||||
*/
|
||||
const backGesture = gesture.from
|
||||
.left("clamp(36px, 8%, 72px)")
|
||||
.to.right()
|
||||
.navigate((context) => (context.canGoBack ? back() : null))
|
||||
.animate(unfocusPortal);
|
||||
|
||||
const gestures = [dialogGesture, backGesture] as const;
|
||||
|
||||
function backWithButton() {
|
||||
const action = goBack(origin.context.value);
|
||||
if (action) void origin.perform(action);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OriginGestureSurface
|
||||
as="main"
|
||||
class="lab-page predicate-view"
|
||||
:gestures="gestures"
|
||||
>
|
||||
<button
|
||||
class="view-back"
|
||||
type="button"
|
||||
data-origin-gesture="ignore"
|
||||
@click="backWithButton"
|
||||
>
|
||||
← Labs
|
||||
</button>
|
||||
|
||||
<div class="predicate-copy">
|
||||
<div class="eyebrow">Lab 06 · Predicate edge</div>
|
||||
<h1>Direction and starting edge are independent.</h1>
|
||||
<p>
|
||||
Begin inside the illuminated left rail and drag <strong>down</strong>. A
|
||||
custom predicate admits the pointer; the normal direction recognizer
|
||||
still handles axis capture, progress, velocity, and release.
|
||||
</p>
|
||||
|
||||
<div class="predicate-code">
|
||||
<span>start predicate</span>
|
||||
<code>x ≤ clamp(36px, 8%, 72px)</code>
|
||||
<small>movement: down · built-in edge: intentionally omitted</small>
|
||||
</div>
|
||||
|
||||
<InstanceCard
|
||||
label="Predicate origin"
|
||||
:instance="identity.instance"
|
||||
:status="identity.status.value"
|
||||
/>
|
||||
|
||||
<button
|
||||
class="primary-action"
|
||||
type="button"
|
||||
data-origin-gesture="ignore"
|
||||
@click="origin.perform(openDialog())"
|
||||
>
|
||||
Open accessibly without dragging
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="predicate-edge" aria-hidden="true">
|
||||
<span>custom left-edge predicate</span>
|
||||
<div class="predicate-arrow">↓</div>
|
||||
<small>start here · pull down</small>
|
||||
</div>
|
||||
|
||||
<div class="predicate-axis-note">
|
||||
A rightward drag on this same rail still performs edge-back.
|
||||
</div>
|
||||
</OriginGestureSurface>
|
||||
</template>
|
||||
89
apps/origins-demo/src/views/FirstView.vue
Normal file
89
apps/origins-demo/src/views/FirstView.vue
Normal file
@@ -0,0 +1,89 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
above,
|
||||
back,
|
||||
gesture,
|
||||
OriginGestureSurface,
|
||||
originView,
|
||||
slideLeft,
|
||||
slideRight,
|
||||
useOrigin,
|
||||
type OriginContext,
|
||||
} from "@native-vue-router/core-v2";
|
||||
import InstanceCard from "../components/InstanceCard.vue";
|
||||
import { useDemoInstance } from "../lab-state";
|
||||
import SecondView from "./SecondView.vue";
|
||||
|
||||
const origin = useOrigin();
|
||||
const identity = useDemoInstance("X");
|
||||
|
||||
const secondView = () =>
|
||||
originView(
|
||||
SecondView,
|
||||
{
|
||||
openedAt: new Date().toLocaleTimeString(),
|
||||
createdBy: identity.instance,
|
||||
},
|
||||
{ key: "second", name: "Second" },
|
||||
);
|
||||
const createSecond = () => above(secondView(), slideLeft);
|
||||
|
||||
const goBack = (context: OriginContext) =>
|
||||
context.canGoBack ? back(slideRight) : null;
|
||||
|
||||
const forwardGesture = gesture.to
|
||||
.left()
|
||||
.navigate(() => above(secondView()))
|
||||
.animate(slideLeft);
|
||||
const backGesture = gesture
|
||||
// .from.left("clamp(24px, 8%, 64px)")
|
||||
// .from.left("20%")
|
||||
.to.right()
|
||||
.navigate((context) => (context.canGoBack ? back() : null))
|
||||
.animate(slideRight);
|
||||
const gestures = [forwardGesture, backGesture] as const;
|
||||
|
||||
function backWithButton() {
|
||||
const action = goBack(origin.context.value);
|
||||
if (action) void origin.perform(action);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OriginGestureSurface
|
||||
as="main"
|
||||
class="lab-page chain-view view-one"
|
||||
:gestures="gestures"
|
||||
>
|
||||
<button
|
||||
class="view-back"
|
||||
type="button"
|
||||
data-origin-gesture="ignore"
|
||||
@click="backWithButton"
|
||||
>
|
||||
← Labs
|
||||
</button>
|
||||
<div class="eyebrow">Chain stress test · Origin X</div>
|
||||
<h1>Every component starts its own movement.</h1>
|
||||
<p>
|
||||
Drag this screen left. Release it, then immediately drag the blue screen
|
||||
while this spring is still settling. Keep going to build a four-node
|
||||
scene.
|
||||
</p>
|
||||
<InstanceCard
|
||||
label="X Vue instance"
|
||||
:instance="identity.instance"
|
||||
:status="identity.status.value"
|
||||
/>
|
||||
<button
|
||||
class="primary-action"
|
||||
data-origin-gesture="ignore"
|
||||
type="button"
|
||||
@click="origin.perform(createSecond())"
|
||||
>
|
||||
Open Y without a gesture
|
||||
</button>
|
||||
<div class="gesture-hint">Swipe left <span>→</span></div>
|
||||
<div class="edge-marker edge-marker--left">back edge</div>
|
||||
</OriginGestureSurface>
|
||||
</template>
|
||||
63
apps/origins-demo/src/views/FourthView.vue
Normal file
63
apps/origins-demo/src/views/FourthView.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
OriginGesture,
|
||||
back,
|
||||
gesture,
|
||||
slideRight,
|
||||
useOrigin,
|
||||
type OriginContext,
|
||||
} from "@native-vue-router/core-v2";
|
||||
import InstanceCard from "../components/InstanceCard.vue";
|
||||
import { useDemoInstance } from "../lab-state";
|
||||
|
||||
defineProps<{ chain?: string }>();
|
||||
|
||||
const origin = useOrigin();
|
||||
const identity = useDemoInstance("Omega");
|
||||
const goBack = (context: OriginContext) =>
|
||||
context.canGoBack ? back(slideRight) : null;
|
||||
|
||||
const backGesture = gesture
|
||||
// .from.left("clamp(24px, 8%, 64px)")
|
||||
// .from.left("20%")
|
||||
.to.right()
|
||||
.navigate((context) => (context.canGoBack ? back() : null))
|
||||
.animate(slideRight);
|
||||
|
||||
function backWithButton() {
|
||||
const action = goBack(origin.context.value);
|
||||
if (action) void origin.perform(action);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OriginGesture class="lab-page chain-view view-four" :gesture="backGesture">
|
||||
<div class="eyebrow">Chain stress test · Origin Ω</div>
|
||||
<h1>Four independently mounted components. Three live edges.</h1>
|
||||
<p>
|
||||
If you moved quickly enough, the inspector showed X, Y, Z, and Ω at once.
|
||||
Each component stayed a flat sibling while inherited transforms composed
|
||||
mathematically.
|
||||
</p>
|
||||
<div class="proof">
|
||||
Reported ancestry <strong>{{ chain }}</strong>
|
||||
</div>
|
||||
<InstanceCard
|
||||
label="Ω Vue instance"
|
||||
:instance="identity.instance"
|
||||
:status="identity.status.value"
|
||||
/>
|
||||
<button
|
||||
class="primary-action"
|
||||
type="button"
|
||||
data-origin-gesture="ignore"
|
||||
@click="backWithButton"
|
||||
>
|
||||
Return to the retained Z instance
|
||||
</button>
|
||||
<div class="gesture-hint gesture-hint--back">
|
||||
<span>←</span> Or swipe from the left edge
|
||||
</div>
|
||||
<div class="edge-marker edge-marker--left">back edge</div>
|
||||
</OriginGesture>
|
||||
</template>
|
||||
88
apps/origins-demo/src/views/GalleryView.vue
Normal file
88
apps/origins-demo/src/views/GalleryView.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
OriginGesture,
|
||||
above,
|
||||
back,
|
||||
gesture,
|
||||
originView,
|
||||
useOrigin,
|
||||
type OriginContext,
|
||||
} from "@native-vue-router/core-v2";
|
||||
import InstanceCard from "../components/InstanceCard.vue";
|
||||
import { demoPhotos, type DemoPhoto } from "../gallery-data";
|
||||
import { useDemoInstance } from "../lab-state";
|
||||
import { focusPortal, unfocusPortal } from "../motions";
|
||||
import PhotoDetailView from "./PhotoDetailView.vue";
|
||||
|
||||
const origin = useOrigin();
|
||||
const identity = useDemoInstance("Gallery");
|
||||
|
||||
const photoAction = (photo: DemoPhoto, index: number) =>
|
||||
above(
|
||||
originView(
|
||||
PhotoDetailView,
|
||||
{ photo, index },
|
||||
{ key: `photo-${photo.id}`, name: photo.title },
|
||||
),
|
||||
focusPortal,
|
||||
);
|
||||
|
||||
const goBack = (context: OriginContext) =>
|
||||
context.canGoBack ? back(unfocusPortal) : null;
|
||||
|
||||
const backGesture = gesture.from
|
||||
.left("clamp(24px, 8%, 64px)")
|
||||
.to.right()
|
||||
.navigate((context) => (context.canGoBack ? back() : null))
|
||||
.animate(unfocusPortal);
|
||||
|
||||
function backWithButton() {
|
||||
const action = goBack(origin.context.value);
|
||||
if (action) void origin.perform(action);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OriginGesture class="lab-page gallery-view" :gesture="backGesture">
|
||||
<header class="section-header">
|
||||
<div>
|
||||
<button
|
||||
class="view-back"
|
||||
type="button"
|
||||
data-origin-gesture="ignore"
|
||||
@click="backWithButton"
|
||||
>
|
||||
← Labs
|
||||
</button>
|
||||
<div class="eyebrow">Lab 03 · Focus gallery</div>
|
||||
<h1>Events can originate motion without being gestures.</h1>
|
||||
<p>
|
||||
Select a card to run the same scene primitive programmatically. Once
|
||||
inside, the destination declares its own traversal gestures.
|
||||
</p>
|
||||
</div>
|
||||
<InstanceCard
|
||||
label="Gallery instance"
|
||||
:instance="identity.instance"
|
||||
:status="identity.status.value"
|
||||
/>
|
||||
</header>
|
||||
|
||||
<div class="photo-grid">
|
||||
<button
|
||||
v-for="(photo, index) in demoPhotos"
|
||||
:key="photo.id"
|
||||
type="button"
|
||||
data-origin-gesture="ignore"
|
||||
class="photo-card"
|
||||
:style="{ background: photo.gradient }"
|
||||
@click="origin.perform(photoAction(photo, index))"
|
||||
>
|
||||
<span>{{ String(index + 1).padStart(2, "0") }}</span>
|
||||
<strong>{{ photo.title }}</strong>
|
||||
<small>{{ photo.location }}</small>
|
||||
</button>
|
||||
</div>
|
||||
<div class="edge-marker edge-marker--left">back edge</div>
|
||||
</OriginGesture>
|
||||
</template>
|
||||
177
apps/origins-demo/src/views/HubView.vue
Normal file
177
apps/origins-demo/src/views/HubView.vue
Normal file
@@ -0,0 +1,177 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
OriginGesture,
|
||||
above,
|
||||
gesture,
|
||||
originView,
|
||||
slideLeft,
|
||||
useOrigin,
|
||||
type OriginChoreography,
|
||||
} from "@native-vue-router/core-v2";
|
||||
import InstanceCard from "../components/InstanceCard.vue";
|
||||
import { useDemoInstance } from "../lab-state";
|
||||
import { coverUp, focusPortal } from "../motions";
|
||||
import ChatView from "./ChatView.vue";
|
||||
import DirectionLabView from "./DirectionLabView.vue";
|
||||
import EdgePredicateView from "./EdgePredicateView.vue";
|
||||
import FirstView from "./FirstView.vue";
|
||||
import GalleryView from "./GalleryView.vue";
|
||||
import NestedScenesView from "./NestedScenesView.vue";
|
||||
import PlayerView from "./PlayerView.vue";
|
||||
|
||||
const origin = useOrigin();
|
||||
const identity = useDemoInstance("Hub");
|
||||
|
||||
function viewFor(component: Parameters<typeof originView>[0], name: string) {
|
||||
return originView(component, undefined, {
|
||||
key: name.toLowerCase().replaceAll(" ", "-"),
|
||||
name,
|
||||
});
|
||||
}
|
||||
|
||||
function actionFor(
|
||||
component: Parameters<typeof originView>[0],
|
||||
name: string,
|
||||
choreography: OriginChoreography,
|
||||
) {
|
||||
return above(viewFor(component, name), choreography);
|
||||
}
|
||||
|
||||
/*
|
||||
* No `.from` is declared: every eligible pointer-down on the Hub can become
|
||||
* this leftward gesture once movement passes the direction lock.
|
||||
*/
|
||||
const enterChainGesture = gesture.to
|
||||
.left()
|
||||
// .complete(
|
||||
// ({ progress, velocity }) =>
|
||||
// progress >= 0.3 || (progress >= 0.05 && velocity >= 0.8),
|
||||
// )
|
||||
.navigate(() => above(viewFor(FirstView, "Chain X")))
|
||||
.animate(slideLeft);
|
||||
|
||||
const openChain = () => actionFor(FirstView, "Chain X", slideLeft);
|
||||
const openDirections = () =>
|
||||
actionFor(DirectionLabView, "Direction Lab", focusPortal);
|
||||
const openGallery = () => actionFor(GalleryView, "Gallery", focusPortal);
|
||||
const openPlayer = () => actionFor(PlayerView, "Player", coverUp);
|
||||
const openChat = () => actionFor(ChatView, "Locked Chat", slideLeft);
|
||||
const openPredicate = () =>
|
||||
actionFor(EdgePredicateView, "Predicate Edge", focusPortal);
|
||||
const openNestedScenes = () =>
|
||||
actionFor(NestedScenesView, "Nested Scenes", focusPortal);
|
||||
|
||||
const labs = [
|
||||
{
|
||||
number: "01",
|
||||
title: "Concurrent chain",
|
||||
description: "Swipe through X→Y→Z→Ω before the preceding springs settle.",
|
||||
tags: ["4 live nodes", "frame composition"],
|
||||
action: openChain,
|
||||
accent: "violet",
|
||||
},
|
||||
{
|
||||
number: "02",
|
||||
title: "Direction matrix",
|
||||
description:
|
||||
"One component declares horizontal, vertical, and edge-only behavior.",
|
||||
tags: ["multi-axis", "custom flip"],
|
||||
action: openDirections,
|
||||
accent: "cyan",
|
||||
},
|
||||
{
|
||||
number: "03",
|
||||
title: "Focus gallery",
|
||||
description:
|
||||
"Programmatic card selection and gesture-driven photo traversal.",
|
||||
tags: ["portal", "local state"],
|
||||
action: openGallery,
|
||||
accent: "amber",
|
||||
},
|
||||
{
|
||||
number: "04",
|
||||
title: "Native player",
|
||||
description:
|
||||
"A vertical cover with media state and intentionally no back edge.",
|
||||
tags: ["vertical dismiss", "gesture policy"],
|
||||
action: openPlayer,
|
||||
accent: "green",
|
||||
},
|
||||
{
|
||||
number: "05",
|
||||
title: "Locked chat",
|
||||
description:
|
||||
"A Snapchat-style page that exposes only an explicit back button.",
|
||||
tags: ["blocked edge", "form state"],
|
||||
action: openChat,
|
||||
accent: "rose",
|
||||
},
|
||||
{
|
||||
number: "06",
|
||||
title: "Predicate edge",
|
||||
description: "Swipe down from a dynamic left-edge region to drop a dialog.",
|
||||
tags: ["custom hit test", "axis arbitration"],
|
||||
action: openPredicate,
|
||||
accent: "lime",
|
||||
},
|
||||
{
|
||||
number: "07",
|
||||
title: "Nested scenes",
|
||||
description:
|
||||
"Carousels and decks with local history—including deliberate gesture conflicts.",
|
||||
tags: ["carousel", "nested ownership"],
|
||||
action: openNestedScenes,
|
||||
accent: "blue",
|
||||
},
|
||||
] as const;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OriginGesture class="lab-page hub-view" :gesture="enterChainGesture">
|
||||
<keep-alive>
|
||||
<header class="hub-header">
|
||||
<div>
|
||||
<div class="eyebrow">Routeless origins · interactive field guide</div>
|
||||
<h1>A navigation engine made of component-owned movement.</h1>
|
||||
<p>
|
||||
No route table decides what this screen can do. Every lab below
|
||||
creates its target, chooses its choreography, and declares its own
|
||||
gesture policy.
|
||||
</p>
|
||||
</div>
|
||||
<InstanceCard
|
||||
label="Hub instance"
|
||||
:instance="identity.instance"
|
||||
:status="identity.status.value"
|
||||
/>
|
||||
</header>
|
||||
|
||||
<main class="lab-grid">
|
||||
<button
|
||||
v-for="lab in labs"
|
||||
:key="lab.number"
|
||||
type="button"
|
||||
data-origin-gesture="ignore"
|
||||
class="lab-card"
|
||||
:class="`lab-card--${lab.accent}`"
|
||||
@click="origin.perform(lab.action())"
|
||||
>
|
||||
<span class="lab-number">{{ lab.number }}</span>
|
||||
<span class="lab-copy">
|
||||
<strong>{{ lab.title }}</strong>
|
||||
<small>{{ lab.description }}</small>
|
||||
</span>
|
||||
<span class="tag-row">
|
||||
<em v-for="tag in lab.tags" :key="tag">{{ tag }}</em>
|
||||
</span>
|
||||
<span class="lab-arrow">↗</span>
|
||||
</button>
|
||||
</main>
|
||||
|
||||
<footer class="hub-footer">
|
||||
<span>Tip: swipe anywhere left to enter the chain stress test.</span>
|
||||
<span>The inspector in the corner exposes live nodes and edges.</span>
|
||||
</footer>
|
||||
</keep-alive>
|
||||
</OriginGesture>
|
||||
</template>
|
||||
166
apps/origins-demo/src/views/NestedSceneSlide.vue
Normal file
166
apps/origins-demo/src/views/NestedSceneSlide.vue
Normal file
@@ -0,0 +1,166 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import {
|
||||
back,
|
||||
forward,
|
||||
gesture,
|
||||
OriginGestureSurface,
|
||||
useOrigin,
|
||||
type OriginChoreography,
|
||||
type OriginGestureDirectedBuilder,
|
||||
type OriginView,
|
||||
} from "@native-vue-router/core-v2";
|
||||
import InstanceCard from "../components/InstanceCard.vue";
|
||||
import { useDemoInstance } from "../lab-state";
|
||||
import {
|
||||
nestedCarouselBack,
|
||||
nestedCarouselForward,
|
||||
nestedCubeBack,
|
||||
nestedCubeForward,
|
||||
nestedDeckBack,
|
||||
nestedDeckForward,
|
||||
} from "../motions";
|
||||
|
||||
type NestedSceneKind = "cooperative" | "vertical" | "conflict";
|
||||
|
||||
const props = defineProps<{
|
||||
kind: NestedSceneKind;
|
||||
index: number;
|
||||
total: number;
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description: string;
|
||||
accent: string;
|
||||
next?: () => OriginView;
|
||||
}>();
|
||||
|
||||
const origin = useOrigin();
|
||||
const identity = useDemoInstance(`Nested ${props.kind} ${props.index + 1}`);
|
||||
const localLikes = ref(0);
|
||||
|
||||
const vertical = props.kind === "vertical";
|
||||
const forwardMotion: OriginChoreography = vertical
|
||||
? nestedDeckForward
|
||||
: props.kind === "conflict"
|
||||
? nestedCubeForward
|
||||
: nestedCarouselForward;
|
||||
const backMotion: OriginChoreography = vertical
|
||||
? nestedDeckBack
|
||||
: props.kind === "conflict"
|
||||
? nestedCubeBack
|
||||
: nestedCarouselBack;
|
||||
|
||||
/*
|
||||
* Cooperative cases deliberately leave the outer page's first 44 pixels
|
||||
* untouched. The conflict case starts anywhere, so its pointer-down handler
|
||||
* stops propagation before it knows whether `back()` is available.
|
||||
*/
|
||||
function movementBuilder(
|
||||
direction: "forward" | "back",
|
||||
): OriginGestureDirectedBuilder {
|
||||
const to = vertical
|
||||
? direction === "forward"
|
||||
? "up"
|
||||
: "down"
|
||||
: direction === "forward"
|
||||
? "left"
|
||||
: "right";
|
||||
const start =
|
||||
props.kind === "conflict"
|
||||
? gesture.to
|
||||
: gesture.from.when(({ point, bounds }) => {
|
||||
const parentRail = Math.min(52, Math.max(22, bounds.width * 0.12));
|
||||
return point.localX > parentRail;
|
||||
}).to;
|
||||
return start[to]();
|
||||
}
|
||||
|
||||
const forwardGesture = movementBuilder("forward")
|
||||
.navigate(() => {
|
||||
const target = props.next?.();
|
||||
return target ? forward(target) : null;
|
||||
})
|
||||
.animate(forwardMotion);
|
||||
|
||||
const backGesture = movementBuilder("back")
|
||||
.navigate((context) => (context.canGoBack ? back() : null))
|
||||
.animate(backMotion);
|
||||
|
||||
const gestures = [forwardGesture, backGesture] as const;
|
||||
|
||||
function nextWithButton() {
|
||||
const target = props.next?.();
|
||||
if (target) void origin.perform(forward(target, forwardMotion));
|
||||
}
|
||||
|
||||
function backWithButton() {
|
||||
if (origin.context.value.canGoBack) void origin.perform(back(backMotion));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OriginGestureSurface
|
||||
as="article"
|
||||
class="nested-slide"
|
||||
:class="`nested-slide--${kind}`"
|
||||
:data-nested-kind="kind"
|
||||
:data-nested-index="index"
|
||||
:style="{ '--nested-accent': accent }"
|
||||
:gestures="gestures"
|
||||
>
|
||||
<div class="nested-slide__orb" aria-hidden="true" />
|
||||
|
||||
<header>
|
||||
<span>{{ eyebrow }}</span>
|
||||
<strong>{{ index + 1 }} / {{ total }}</strong>
|
||||
</header>
|
||||
|
||||
<div class="nested-slide__copy">
|
||||
<h3>{{ title }}</h3>
|
||||
<p>{{ description }}</p>
|
||||
</div>
|
||||
|
||||
<div class="nested-slide__state">
|
||||
<InstanceCard
|
||||
label="Nested instance"
|
||||
:instance="identity.instance"
|
||||
:status="identity.status.value"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
data-origin-gesture="ignore"
|
||||
@click="localLikes += 1"
|
||||
>
|
||||
Local state · {{ localLikes }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<button
|
||||
type="button"
|
||||
data-origin-gesture="ignore"
|
||||
:disabled="!origin.canGoBack.value"
|
||||
@click="backWithButton"
|
||||
>
|
||||
{{ vertical ? "↓ Previous" : "← Previous" }}
|
||||
</button>
|
||||
|
||||
<div class="nested-slide__dots" aria-label="Slide position">
|
||||
<span
|
||||
v-for="dot in total"
|
||||
:key="dot"
|
||||
:class="{ active: dot === index + 1 }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-origin-gesture="ignore"
|
||||
:disabled="!next"
|
||||
@click="nextWithButton"
|
||||
>
|
||||
{{ vertical ? "Next ↑" : "Next →" }}
|
||||
</button>
|
||||
</footer>
|
||||
</OriginGestureSurface>
|
||||
</template>
|
||||
275
apps/origins-demo/src/views/NestedScenesView.vue
Normal file
275
apps/origins-demo/src/views/NestedScenesView.vue
Normal file
@@ -0,0 +1,275 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
OriginGesture,
|
||||
OriginScene,
|
||||
back,
|
||||
createOriginScene,
|
||||
gesture,
|
||||
originView,
|
||||
useOrigin,
|
||||
} from "@native-vue-router/core-v2";
|
||||
import { useDemoInstance } from "../lab-state";
|
||||
import { unfocusPortal } from "../motions";
|
||||
import NestedSceneSlide from "./NestedSceneSlide.vue";
|
||||
|
||||
type NestedSceneKind = "cooperative" | "vertical" | "conflict";
|
||||
|
||||
interface SlideCopy {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description: string;
|
||||
accent: string;
|
||||
}
|
||||
|
||||
const slideSets: Record<NestedSceneKind, readonly SlideCopy[]> = {
|
||||
cooperative: [
|
||||
{
|
||||
eyebrow: "Cooperative carousel",
|
||||
title: "A scene inside a page",
|
||||
description:
|
||||
"Swipe left away from the glowing pass-through rail. The target mounts only inside this clipped carousel.",
|
||||
accent: "#76dcff",
|
||||
},
|
||||
{
|
||||
eyebrow: "Retained nested history",
|
||||
title: "Slide one is still mounted",
|
||||
description:
|
||||
"Increment local state, move again, then return. The same nested Vue instances are revealed.",
|
||||
accent: "#a78bfa",
|
||||
},
|
||||
{
|
||||
eyebrow: "Independent operation graph",
|
||||
title: "Three carousel instances",
|
||||
description:
|
||||
"This history belongs to the carousel scene. The outer page remains one mounted application entry.",
|
||||
accent: "#65f2b1",
|
||||
},
|
||||
],
|
||||
vertical: [
|
||||
{
|
||||
eyebrow: "Vertical nested deck",
|
||||
title: "Pull upward inside this card",
|
||||
description:
|
||||
"The deck uses vertical progress, rotation, and a shared sideways frame arc.",
|
||||
accent: "#ffbd70",
|
||||
},
|
||||
{
|
||||
eyebrow: "Orthogonal choreography",
|
||||
title: "The page still owns its left rail",
|
||||
description:
|
||||
"Pull down to reveal the retained card, or begin on its pass-through rail and swipe right to leave the lab.",
|
||||
accent: "#ff7f9f",
|
||||
},
|
||||
{
|
||||
eyebrow: "Nested composition",
|
||||
title: "Frames remain local",
|
||||
description:
|
||||
"Transforms compose exactly like the full-screen scene, but measurements come from this smaller viewport.",
|
||||
accent: "#ffe079",
|
||||
},
|
||||
],
|
||||
conflict: [
|
||||
{
|
||||
eyebrow: "Intentional conflict",
|
||||
title: "This child claims every pointer-down",
|
||||
description:
|
||||
"Try the page's rightward back gesture over this card. The child sees it first, even though nested back is unavailable.",
|
||||
accent: "#ff5f78",
|
||||
},
|
||||
{
|
||||
eyebrow: "Child-owned back",
|
||||
title: "Now rightward swipe works here",
|
||||
description:
|
||||
"After moving forward, the exact same gesture can resolve nested back, producing the cube reversal.",
|
||||
accent: "#ff8d4d",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function nestedView(kind: NestedSceneKind, index: number) {
|
||||
const slides = slideSets[kind];
|
||||
const copy = slides[index]!;
|
||||
return originView(
|
||||
NestedSceneSlide,
|
||||
{
|
||||
kind,
|
||||
index,
|
||||
total: slides.length,
|
||||
...copy,
|
||||
next:
|
||||
index + 1 < slides.length
|
||||
? () => nestedView(kind, index + 1)
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
key: `${kind}-slide-${index + 1}`,
|
||||
name: `${kind} slide ${index + 1}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function nestedScene(kind: NestedSceneKind) {
|
||||
return createOriginScene({ initial: nestedView(kind, 0) });
|
||||
}
|
||||
|
||||
interface NestedCase {
|
||||
kind: NestedSceneKind;
|
||||
status: "supported" | "supported-with-policy" | "known-conflict";
|
||||
title: string;
|
||||
summary: string;
|
||||
expected: string;
|
||||
issue: string;
|
||||
scene: ReturnType<typeof createOriginScene>;
|
||||
}
|
||||
|
||||
const cases: readonly NestedCase[] = [
|
||||
{
|
||||
kind: "cooperative",
|
||||
status: "supported-with-policy",
|
||||
title: "Reserved-edge carousel",
|
||||
summary:
|
||||
"Horizontal nested history with a start predicate that leaves a clamp(22px, 12%, 52px) rail for its parent.",
|
||||
expected:
|
||||
"Swipe left or right beyond the rail; swipe right from the card's rail to trigger parent back.",
|
||||
issue:
|
||||
"The parent/child agreement is spatial policy written by the developer, not automatic arbitration.",
|
||||
scene: nestedScene("cooperative"),
|
||||
},
|
||||
{
|
||||
kind: "vertical",
|
||||
status: "supported",
|
||||
title: "Vertical component deck",
|
||||
summary:
|
||||
"A second nested scene uses upward/downward gestures and an entirely different frame animation.",
|
||||
expected:
|
||||
"Swipe up for the next card, down for retained back, and swipe right from its rail for parent back.",
|
||||
issue:
|
||||
"Its touch-action prevents page scrolling while a touch begins over the deck, which is appropriate here but must be intentional.",
|
||||
scene: nestedScene("vertical"),
|
||||
},
|
||||
{
|
||||
kind: "conflict",
|
||||
status: "known-conflict",
|
||||
title: "Greedy child recognizer",
|
||||
summary:
|
||||
"The cube carousel deliberately uses `.to.left()` and `.to.right()` without a `.from` constraint.",
|
||||
expected: "Nested forward/back works and remains clipped to the card.",
|
||||
issue:
|
||||
"On slide one, parent back fails everywhere over this card: the child stops pointer-down propagation before `.navigate()` returns null.",
|
||||
scene: nestedScene("conflict"),
|
||||
},
|
||||
];
|
||||
|
||||
const origin = useOrigin();
|
||||
const identity = useDemoInstance("Nested scenes");
|
||||
|
||||
const pageBackGesture = gesture.to
|
||||
.right()
|
||||
.complete(
|
||||
({ distance, bounds, progress, velocity }) =>
|
||||
distance >= bounds.width * 0.08 || progress >= 0.22 || velocity >= 0.65,
|
||||
)
|
||||
.navigate((context) => (context.canGoBack ? back() : null))
|
||||
.animate(unfocusPortal);
|
||||
|
||||
function backWithButton() {
|
||||
if (origin.context.value.canGoBack) void origin.perform(back(unfocusPortal));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OriginGesture class="lab-page nested-scenes-view" :gesture="pageBackGesture">
|
||||
<button
|
||||
class="view-back"
|
||||
type="button"
|
||||
data-origin-gesture="ignore"
|
||||
@click="backWithButton"
|
||||
>
|
||||
← Labs
|
||||
</button>
|
||||
|
||||
<header class="nested-scenes-header">
|
||||
<div>
|
||||
<div class="eyebrow">Lab 07 · Nested origin scenes</div>
|
||||
<h1>Components can own smaller worlds.</h1>
|
||||
<p>
|
||||
Each card below contains a completely independent
|
||||
<code>OriginScene</code>. Its views, history, gestures, measurements,
|
||||
and animations stay inside that card while this page remains the outer
|
||||
origin.
|
||||
</p>
|
||||
</div>
|
||||
<div class="nested-page-instance">
|
||||
<span>Outer page instance</span>
|
||||
<strong>{{ identity.instance }}</strong>
|
||||
<small>rightward 8% → page back</small>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="nested-case-grid">
|
||||
<section
|
||||
v-for="item in cases"
|
||||
:key="item.kind"
|
||||
class="nested-case"
|
||||
:class="`nested-case--${item.kind}`"
|
||||
>
|
||||
<header>
|
||||
<span class="nested-case__status" :class="item.status">
|
||||
{{ item.status.replaceAll("-", " ") }}
|
||||
</span>
|
||||
<h2>{{ item.title }}</h2>
|
||||
<p>{{ item.summary }}</p>
|
||||
</header>
|
||||
|
||||
<div class="nested-stage-shell">
|
||||
<OriginScene class="nested-stage" :scene="item.scene" />
|
||||
<div class="nested-stage-edge" aria-hidden="true">
|
||||
{{
|
||||
item.kind === "conflict"
|
||||
? "parent blocked"
|
||||
: "parent pass-through"
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nested-case__diagnostics">
|
||||
<span>
|
||||
{{ item.scene.nodes.value.length }} nested node{{
|
||||
item.scene.nodes.value.length === 1 ? "" : "s"
|
||||
}}
|
||||
</span>
|
||||
<span>
|
||||
{{ item.scene.operations.value.length }} live edge{{
|
||||
item.scene.operations.value.length === 1 ? "" : "s"
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Expected</dt>
|
||||
<dd>{{ item.expected }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>
|
||||
{{ item.kind === "conflict" ? "Predicted failure" : "Caveat" }}
|
||||
</dt>
|
||||
<dd>{{ item.issue }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="nested-scenes-footer">
|
||||
<strong>Ownership today is decided at pointer-down.</strong>
|
||||
<span>
|
||||
A future gesture arena could delay that decision and let a parent claim
|
||||
the sequence when a child declines navigation.
|
||||
</span>
|
||||
</footer>
|
||||
|
||||
<div class="nested-page-gesture">
|
||||
Parent gesture: swipe right outside a child, or from a cooperative rail
|
||||
</div>
|
||||
</OriginGesture>
|
||||
</template>
|
||||
114
apps/origins-demo/src/views/PhotoDetailView.vue
Normal file
114
apps/origins-demo/src/views/PhotoDetailView.vue
Normal file
@@ -0,0 +1,114 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, getCurrentInstance, ref, type Component } from "vue";
|
||||
import {
|
||||
above,
|
||||
back,
|
||||
gesture,
|
||||
OriginGestureSurface,
|
||||
originView,
|
||||
slideRight,
|
||||
useOrigin,
|
||||
type OriginContext,
|
||||
} from "@native-vue-router/core-v2";
|
||||
import InstanceCard from "../components/InstanceCard.vue";
|
||||
import { demoPhotos, type DemoPhoto } from "../gallery-data";
|
||||
import { useDemoInstance } from "../lab-state";
|
||||
import { arcSlide, coverDown } from "../motions";
|
||||
|
||||
const props = defineProps<{ photo: DemoPhoto; index: number }>();
|
||||
|
||||
const origin = useOrigin();
|
||||
const identity = useDemoInstance(`Photo ${props.index + 1}`);
|
||||
const liked = ref(false);
|
||||
const ownComponent = getCurrentInstance()?.type as Component;
|
||||
const nextPhoto = computed(
|
||||
() => demoPhotos[(props.index + 1) % demoPhotos.length]!,
|
||||
);
|
||||
|
||||
/*
|
||||
* The target uses the same SFC definition with different props. It is still a
|
||||
* fresh scene node and Vue instance, which makes recipe identity visible.
|
||||
*/
|
||||
const nextPhotoView = () =>
|
||||
originView(
|
||||
ownComponent,
|
||||
{
|
||||
photo: nextPhoto.value,
|
||||
index: (props.index + 1) % demoPhotos.length,
|
||||
},
|
||||
{ key: `photo-${nextPhoto.value.id}`, name: nextPhoto.value.title },
|
||||
);
|
||||
const goNext = () => above(nextPhotoView(), arcSlide);
|
||||
|
||||
const goBack = (context: OriginContext, vertical = false) =>
|
||||
context.canGoBack ? back(vertical ? coverDown : slideRight) : null;
|
||||
|
||||
const nextGesture = gesture.to
|
||||
.left()
|
||||
.navigate(() => above(nextPhotoView()))
|
||||
.animate(arcSlide);
|
||||
const dismissGesture = gesture.to
|
||||
.down()
|
||||
.navigate((context) => (context.canGoBack ? back() : null))
|
||||
.animate(coverDown);
|
||||
const edgeBackGesture = gesture.from
|
||||
.left("clamp(24px, 8%, 64px)")
|
||||
.to.right()
|
||||
.navigate((context) => (context.canGoBack ? back() : null))
|
||||
.animate(slideRight);
|
||||
const gestures = [nextGesture, dismissGesture, edgeBackGesture] as const;
|
||||
|
||||
function close() {
|
||||
const action = goBack(origin.context.value, true);
|
||||
if (action) void origin.perform(action);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OriginGestureSurface
|
||||
as="article"
|
||||
class="photo-detail"
|
||||
:style="{ background: photo.gradient }"
|
||||
:gestures="gestures"
|
||||
>
|
||||
<div class="photo-vignette" />
|
||||
<header class="photo-toolbar">
|
||||
<button type="button" data-origin-gesture="ignore" @click="close">
|
||||
↓ Close
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-origin-gesture="ignore"
|
||||
:aria-pressed="liked"
|
||||
@click="liked = !liked"
|
||||
>
|
||||
{{ liked ? "♥ Liked" : "♡ Like" }}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<main class="photo-copy">
|
||||
<div class="eyebrow">{{ photo.location }}</div>
|
||||
<h1>{{ photo.title }}</h1>
|
||||
<p>{{ photo.description }}</p>
|
||||
<InstanceCard
|
||||
label="Photo instance"
|
||||
:instance="identity.instance"
|
||||
:status="identity.status.value"
|
||||
/>
|
||||
<button
|
||||
class="primary-action"
|
||||
type="button"
|
||||
data-origin-gesture="ignore"
|
||||
@click="origin.perform(goNext())"
|
||||
>
|
||||
Next: {{ nextPhoto.title }}
|
||||
</button>
|
||||
<div class="gesture-map">
|
||||
<span>← next photo</span>
|
||||
<span>↓ retained previous instance</span>
|
||||
<span>left edge → retained previous instance</span>
|
||||
</div>
|
||||
</main>
|
||||
<div class="edge-marker edge-marker--left">back edge</div>
|
||||
</OriginGestureSurface>
|
||||
</template>
|
||||
105
apps/origins-demo/src/views/PlayerView.vue
Normal file
105
apps/origins-demo/src/views/PlayerView.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref } from "vue";
|
||||
import {
|
||||
OriginGesture,
|
||||
back,
|
||||
gesture,
|
||||
useOrigin,
|
||||
type OriginContext,
|
||||
} from "@native-vue-router/core-v2";
|
||||
import InstanceCard from "../components/InstanceCard.vue";
|
||||
import { useDemoInstance } from "../lab-state";
|
||||
import { coverDown } from "../motions";
|
||||
|
||||
const origin = useOrigin();
|
||||
const identity = useDemoInstance("Player");
|
||||
const playing = ref(false);
|
||||
const position = ref(38);
|
||||
const elapsed = computed(() => `1:${String(position.value).padStart(2, "0")}`);
|
||||
const timer = window.setInterval(() => {
|
||||
if (playing.value) position.value = (position.value + 1) % 60;
|
||||
}, 1000);
|
||||
onBeforeUnmount(() => window.clearInterval(timer));
|
||||
|
||||
const dismiss = (context: OriginContext) =>
|
||||
context.canGoBack ? back(coverDown) : null;
|
||||
|
||||
const dismissGesture = gesture.to
|
||||
.down()
|
||||
.navigate((context) => (context.canGoBack ? back() : null))
|
||||
.animate(coverDown);
|
||||
|
||||
function close() {
|
||||
const action = dismiss(origin.context.value);
|
||||
if (action) void origin.perform(action);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OriginGesture class="player-view" :gesture="dismissGesture">
|
||||
<header class="player-header">
|
||||
<button type="button" data-origin-gesture="ignore" @click="close">
|
||||
⌄
|
||||
</button>
|
||||
<span>Now playing</span>
|
||||
<button type="button" data-origin-gesture="ignore">•••</button>
|
||||
</header>
|
||||
|
||||
<div class="album-art">
|
||||
<span class="album-ring album-ring--one" />
|
||||
<span class="album-ring album-ring--two" />
|
||||
<span class="album-center">O</span>
|
||||
</div>
|
||||
|
||||
<section class="track-copy">
|
||||
<div>
|
||||
<h1>Independent Frames</h1>
|
||||
<p>The Scene Graph</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-origin-gesture="ignore"
|
||||
:aria-pressed="playing"
|
||||
@click="playing = !playing"
|
||||
>
|
||||
{{ playing ? "♥" : "♡" }}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<label class="timeline">
|
||||
<input
|
||||
v-model="position"
|
||||
data-origin-gesture="ignore"
|
||||
type="range"
|
||||
min="0"
|
||||
max="59"
|
||||
/>
|
||||
<span>{{ elapsed }}</span>
|
||||
<span>3:42</span>
|
||||
</label>
|
||||
|
||||
<div class="player-controls">
|
||||
<button type="button" data-origin-gesture="ignore">↶</button>
|
||||
<button
|
||||
class="play-button"
|
||||
type="button"
|
||||
data-origin-gesture="ignore"
|
||||
@click="playing = !playing"
|
||||
>
|
||||
{{ playing ? "Ⅱ" : "▶" }}
|
||||
</button>
|
||||
<button type="button" data-origin-gesture="ignore">↷</button>
|
||||
</div>
|
||||
|
||||
<InstanceCard
|
||||
label="Player instance"
|
||||
:instance="identity.instance"
|
||||
:status="identity.status.value"
|
||||
/>
|
||||
<p class="policy-note">
|
||||
Swipe down anywhere to dismiss. Horizontal back is intentionally absent,
|
||||
like a native full-screen player.
|
||||
</p>
|
||||
<div class="drag-handle" />
|
||||
</OriginGesture>
|
||||
</template>
|
||||
67
apps/origins-demo/src/views/SecondView.vue
Normal file
67
apps/origins-demo/src/views/SecondView.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
above,
|
||||
back,
|
||||
gesture,
|
||||
OriginGestureSurface,
|
||||
originView,
|
||||
slideLeft,
|
||||
slideRight,
|
||||
} from "@native-vue-router/core-v2";
|
||||
import InstanceCard from "../components/InstanceCard.vue";
|
||||
import { useDemoInstance } from "../lab-state";
|
||||
import ThirdView from "./ThirdView.vue";
|
||||
|
||||
defineProps<{ openedAt?: string; createdBy?: string }>();
|
||||
|
||||
const identity = useDemoInstance("Y");
|
||||
const thirdView = () =>
|
||||
originView(
|
||||
ThirdView,
|
||||
{ createdBy: identity.instance },
|
||||
{ key: "third", name: "Third" },
|
||||
);
|
||||
const forwardGesture = gesture.to
|
||||
.left()
|
||||
.navigate(() => above(thirdView()))
|
||||
.animate(slideLeft);
|
||||
const backGesture = gesture
|
||||
// .from.left("clamp(24px, 8%, 64px)")
|
||||
// .from.left("20%")
|
||||
.to.right()
|
||||
.navigate((context) => (context.canGoBack ? back() : null))
|
||||
.animate(slideRight);
|
||||
const gestures = [forwardGesture, backGesture] as const;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OriginGestureSurface
|
||||
as="main"
|
||||
class="lab-page chain-view view-two"
|
||||
:gestures="gestures"
|
||||
>
|
||||
<div class="eyebrow">Chain stress test · Origin Y</div>
|
||||
<h1>Y can move while X is still moving it.</h1>
|
||||
<p>
|
||||
X→Y supplies Y's inherited coordinate frame. This Y→Z gesture adds a
|
||||
second transform layer inside that frame.
|
||||
</p>
|
||||
<div class="metric-grid">
|
||||
<div>
|
||||
<span>Created by X</span>
|
||||
<strong>{{ createdBy }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Created at</span>
|
||||
<strong>{{ openedAt ?? "unknown" }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<InstanceCard
|
||||
label="Y Vue instance"
|
||||
:instance="identity.instance"
|
||||
:status="identity.status.value"
|
||||
/>
|
||||
<div class="gesture-hint">Swipe left again <span>→</span></div>
|
||||
<div class="edge-marker edge-marker--left">back edge</div>
|
||||
</OriginGestureSurface>
|
||||
</template>
|
||||
60
apps/origins-demo/src/views/ThirdView.vue
Normal file
60
apps/origins-demo/src/views/ThirdView.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
above,
|
||||
back,
|
||||
gesture,
|
||||
OriginGestureSurface,
|
||||
originView,
|
||||
slideRight,
|
||||
} from "@native-vue-router/core-v2";
|
||||
import InstanceCard from "../components/InstanceCard.vue";
|
||||
import { useDemoInstance } from "../lab-state";
|
||||
import { arcSlide } from "../motions";
|
||||
import FourthView from "./FourthView.vue";
|
||||
|
||||
defineProps<{ createdBy?: string }>();
|
||||
|
||||
const identity = useDemoInstance("Z");
|
||||
const fourthView = () =>
|
||||
originView(
|
||||
FourthView,
|
||||
{ chain: `X → Y → ${identity.instance}` },
|
||||
{ key: "fourth", name: "Omega" },
|
||||
);
|
||||
const forwardGesture = gesture.to
|
||||
.left()
|
||||
.navigate(() => above(fourthView()))
|
||||
.animate(arcSlide);
|
||||
const backGesture = gesture
|
||||
// .from.left("clamp(24px, 8%, 64px)")
|
||||
// .from.left("20%")
|
||||
.to.right()
|
||||
.navigate((context) => (context.canGoBack ? back() : null))
|
||||
.animate(slideRight);
|
||||
const gestures = [forwardGesture, backGesture] as const;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OriginGestureSurface
|
||||
as="main"
|
||||
class="lab-page chain-view view-three"
|
||||
:gestures="gestures"
|
||||
>
|
||||
<div class="eyebrow">Chain stress test · Origin Z</div>
|
||||
<h1>Now add an arc while two frames may still be moving.</h1>
|
||||
<p>
|
||||
Z inherits X→Y and Y→Z motion, then originates its own frame contribution.
|
||||
The shared frame lifts both Z and Ω through a small arc.
|
||||
</p>
|
||||
<div class="proof">
|
||||
Created by Y instance <strong>{{ createdBy }}</strong>
|
||||
</div>
|
||||
<InstanceCard
|
||||
label="Z Vue instance"
|
||||
:instance="identity.instance"
|
||||
:status="identity.status.value"
|
||||
/>
|
||||
<div class="gesture-hint">Swipe left for Ω <span>→</span></div>
|
||||
<div class="edge-marker edge-marker--left">back edge</div>
|
||||
</OriginGestureSurface>
|
||||
</template>
|
||||
11
apps/origins-demo/tsconfig.json
Normal file
11
apps/origins-demo/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.app.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "../../node_modules/.tmp/origins-demo.tsbuildinfo",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@native-vue-router/core-v2": ["../../packages/core-v2/src/index.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue"]
|
||||
}
|
||||
174
apps/origins-demo/vite.config.ts
Normal file
174
apps/origins-demo/vite.config.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import path from "node:path";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import { defineConfig, type Plugin } from "vite";
|
||||
import { VitePWA } from "vite-plugin-pwa";
|
||||
|
||||
/**
|
||||
* Recover browsers that visited this hostname while Docker still served Vite's
|
||||
* development mode. Its development worker can retain HTML that imports
|
||||
* `/@vite/client` and `/src/main.ts`; a normal static fallback would return
|
||||
* HTML for those module URLs and leave the visitor on a blank screen.
|
||||
*
|
||||
* This preview-only compatibility endpoint is requested only by that obsolete
|
||||
* shell. It clears the old origin-local worker/cache state and writes the
|
||||
* current production HTML into the document. The production application then
|
||||
* registers the real `/sw.js` worker normally.
|
||||
*/
|
||||
function legacyDevelopmentWorkerMigration(): Plugin {
|
||||
const migrationSource = `
|
||||
void (async () => {
|
||||
if ("serviceWorker" in navigator) {
|
||||
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||
await Promise.all(registrations.map((registration) => registration.unregister()));
|
||||
}
|
||||
if ("caches" in window) {
|
||||
const names = await caches.keys();
|
||||
await Promise.all(names.map((name) => caches.delete(name)));
|
||||
}
|
||||
|
||||
const productionDocument = await fetch(
|
||||
"/index.html?origin-pwa-migration=" + Date.now(),
|
||||
{ cache: "no-store" },
|
||||
);
|
||||
const html = await productionDocument.text();
|
||||
document.open();
|
||||
document.write(html);
|
||||
document.close();
|
||||
})();
|
||||
`;
|
||||
|
||||
return {
|
||||
name: "origins-legacy-development-worker-migration",
|
||||
configurePreviewServer(server) {
|
||||
server.middlewares.use((request, response, next) => {
|
||||
const pathname = new URL(request.url ?? "/", "http://origins.local")
|
||||
.pathname;
|
||||
|
||||
if (pathname === "/@vite/client") {
|
||||
response.statusCode = 200;
|
||||
response.setHeader("Content-Type", "text/javascript; charset=utf-8");
|
||||
response.setHeader("Cache-Control", "no-store");
|
||||
response.end(
|
||||
"// Compatibility stub for the retired Vite dev client.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === "/src/main.ts") {
|
||||
response.statusCode = 200;
|
||||
response.setHeader("Content-Type", "text/javascript; charset=utf-8");
|
||||
response.setHeader("Cache-Control", "no-store");
|
||||
response.end(migrationSource);
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
base: "./",
|
||||
// Reuse the workspace's existing install icons rather than maintaining a
|
||||
// second set that can silently drift from the main demo.
|
||||
publicDir: path.resolve(__dirname, "../../public"),
|
||||
plugins: [
|
||||
vue(),
|
||||
legacyDevelopmentWorkerMigration(),
|
||||
VitePWA({
|
||||
registerType: "autoUpdate",
|
||||
devOptions: {
|
||||
// The hosted Docker service runs Vite's development server, so its
|
||||
// public HTTPS URL also needs a real manifest and service worker.
|
||||
enabled: true,
|
||||
navigateFallback: "index.html",
|
||||
suppressWarnings: true,
|
||||
},
|
||||
includeAssets: [
|
||||
"favicon.svg",
|
||||
"app-icon.svg",
|
||||
"apple-touch-icon.png",
|
||||
"pwa-192.png",
|
||||
"pwa-512.png",
|
||||
],
|
||||
manifest: {
|
||||
id: "/",
|
||||
name: "Routeless Origins Lab",
|
||||
short_name: "Origins Lab",
|
||||
description:
|
||||
"Seven interactive labs for a routeless Vue animation and gesture engine.",
|
||||
theme_color: "#080b12",
|
||||
background_color: "#080b12",
|
||||
display: "standalone",
|
||||
scope: "/",
|
||||
start_url: "/",
|
||||
orientation: "any",
|
||||
icons: [
|
||||
{
|
||||
src: "/pwa-192.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
purpose: "any",
|
||||
},
|
||||
{
|
||||
src: "/pwa-512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "any",
|
||||
},
|
||||
{
|
||||
src: "/pwa-512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "maskable",
|
||||
},
|
||||
{
|
||||
src: "/app-icon.svg",
|
||||
sizes: "any",
|
||||
type: "image/svg+xml",
|
||||
purpose: "any",
|
||||
},
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
cleanupOutdatedCaches: true,
|
||||
clientsClaim: true,
|
||||
skipWaiting: true,
|
||||
navigateFallback: "/index.html",
|
||||
globPatterns: ["**/*.{js,css,html,svg,png,woff2}"],
|
||||
},
|
||||
}),
|
||||
],
|
||||
server: {
|
||||
// The development container is reached through this Traefik hostname.
|
||||
// Keeping the allow-list explicit preserves Vite's DNS-rebinding defense.
|
||||
allowedHosts: ["v2.demo.native-router.harvmaster.com"],
|
||||
},
|
||||
preview: {
|
||||
allowedHosts: ["v2.demo.native-router.harvmaster.com"],
|
||||
},
|
||||
resolve: {
|
||||
alias: [
|
||||
{
|
||||
find: /^@native-vue-router\/core-v2\/style\.css$/,
|
||||
replacement: path.resolve(
|
||||
__dirname,
|
||||
"../../packages/core-v2/src/style.css",
|
||||
),
|
||||
},
|
||||
{
|
||||
find: /^@native-vue-router\/core-v2$/,
|
||||
replacement: path.resolve(
|
||||
__dirname,
|
||||
"../../packages/core-v2/src/index.ts",
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
build: {
|
||||
outDir: path.resolve(__dirname, "dist"),
|
||||
emptyOutDir: true,
|
||||
},
|
||||
});
|
||||
105
docs/routeless-origins.md
Normal file
105
docs/routeless-origins.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# Routeless origins architecture
|
||||
|
||||
The v2 experiment lives in `packages/core-v2` and its physical test application
|
||||
lives in `apps/origins-demo`.
|
||||
|
||||
## State model
|
||||
|
||||
The scene contains:
|
||||
|
||||
- stable, flat Vue component nodes;
|
||||
- linked mounted-instance history entries;
|
||||
- temporary directed operation edges.
|
||||
|
||||
It does not contain an active route or current view.
|
||||
|
||||
For overlapping operations:
|
||||
|
||||
```text
|
||||
Nodes: X, Y, Z
|
||||
Edges: X → Y
|
||||
Y → Z
|
||||
```
|
||||
|
||||
Each edge records its own progress, velocity, outcome, choreography, and
|
||||
source/target keys.
|
||||
|
||||
## Coordinate composition
|
||||
|
||||
Suppose A is X→Y and B is Y→Z:
|
||||
|
||||
```text
|
||||
visual(X) = A.source
|
||||
visual(Y) = A.target × B.source
|
||||
visual(Z) = A.target × B.target
|
||||
```
|
||||
|
||||
An optional frame effect is included on both sides of an edge:
|
||||
|
||||
```text
|
||||
visual(Y) = A.frame × A.target × B.frame × B.source
|
||||
visual(Z) = A.frame × A.target × B.frame × B.target
|
||||
```
|
||||
|
||||
The implementation emits these operations as one combined transform on each
|
||||
flat host. This has the visual semantics of nested coordinate frames without
|
||||
reparenting Vue component VNodes.
|
||||
|
||||
## Completion
|
||||
|
||||
Committing a forward X→Y:
|
||||
|
||||
1. Keeps X mounted but marks it parked, inert, and visually hidden.
|
||||
2. Places Y in X's former visual graph position.
|
||||
3. Removes the X→Y edge.
|
||||
4. Leaves any Y→Z edge and all mounted descendants intact.
|
||||
|
||||
Committing Y→Z before X→Y is also valid. Z replaces Y as the target of the
|
||||
still-running X edge, after which X→Y effectively becomes X→Z. The unit suite
|
||||
covers both completion orders.
|
||||
|
||||
Committing back from Y to X reveals the existing X node and removes Y. It does
|
||||
not construct X again from its recipe. Cancelling a forward edge restores its
|
||||
source and removes the newly created target branch; cancelling a back edge
|
||||
re-parks its retained target.
|
||||
|
||||
## Interaction
|
||||
|
||||
Gesture recognition is declared within each component through
|
||||
`OriginGesture` or `useOriginGesture()`. The injected scene-node key determines
|
||||
the origin. Recognition never asks a coordinator which view is active.
|
||||
|
||||
The immutable `gesture` builder separates optional pointer-down policy
|
||||
(`.from`), movement recognition (`.to`), release policy (`.complete`),
|
||||
navigation intent (`.navigate`), and visual choreography (`.animate`). A chain
|
||||
that begins at `.to` is valid and admits pointer-down anywhere on its host.
|
||||
|
||||
At pointer release, the operation decides synchronously whether it will commit
|
||||
or cancel. Its spring may continue afterward. A retained target can therefore
|
||||
originate another routine while the preceding spring is still visible.
|
||||
|
||||
### Nested scenes
|
||||
|
||||
An `OriginScene` may be rendered inside a view owned by another scene. The
|
||||
nearest injected node scope makes carousel or deck gestures operate on the
|
||||
nested scene, with local measurements and retained history.
|
||||
|
||||
Gesture ownership between nested scenes is currently selected at pointer-down.
|
||||
An eligible child stops propagation even if its later navigation factory
|
||||
declines. Parent fallback therefore requires the child to reserve a
|
||||
non-matching `.from` region; automatic delayed arbitration remains future
|
||||
gesture-arena work.
|
||||
|
||||
## Retained history
|
||||
|
||||
Each pushed node stores the key of its mounted previous entry. The chain is
|
||||
local to that origin context rather than a URL:
|
||||
|
||||
```text
|
||||
X (parked) ← Y (parked) ← Z (visible)
|
||||
```
|
||||
|
||||
Back targets the previous node key directly. A node is unmounted only when a
|
||||
committed back operation pops it, a forward operation is cancelled, or the
|
||||
whole scene is destroyed. Because the same DOM survives parking, nested scroll
|
||||
positions and component-local state survive without `<KeepAlive>`.
|
||||
31
package-lock.json
generated
31
package-lock.json
generated
@@ -70,6 +70,21 @@
|
||||
"name": "@native-vue-router/demo-electron",
|
||||
"version": "0.1.0"
|
||||
},
|
||||
"apps/origins-demo": {
|
||||
"name": "@native-vue-router/origins-demo",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@native-vue-router/core-v2": "0.1.0-experimental.0",
|
||||
"vue": "^3.5.39"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.7",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.1.1",
|
||||
"vite-plugin-pwa": "^1.1.0",
|
||||
"vue-tsc": "^3.3.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@apideck/better-ajv-errors": {
|
||||
"version": "0.3.7",
|
||||
"resolved": "https://registry.npmjs.org/@apideck/better-ajv-errors/-/better-ajv-errors-0.3.7.tgz",
|
||||
@@ -2547,6 +2562,10 @@
|
||||
"resolved": "packages/core",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@native-vue-router/core-v2": {
|
||||
"resolved": "packages/core-v2",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@native-vue-router/demo": {
|
||||
"resolved": "apps/demo",
|
||||
"link": true
|
||||
@@ -2563,6 +2582,10 @@
|
||||
"resolved": "packages/electron",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@native-vue-router/origins-demo": {
|
||||
"resolved": "apps/origins-demo",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@native-vue-router/preset-native": {
|
||||
"resolved": "packages/preset-native",
|
||||
"link": true
|
||||
@@ -9297,6 +9320,14 @@
|
||||
"vue-router": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"packages/core-v2": {
|
||||
"name": "@native-vue-router/core-v2",
|
||||
"version": "0.1.0-experimental.0",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.0"
|
||||
}
|
||||
},
|
||||
"packages/electron": {
|
||||
"name": "@native-vue-router/electron",
|
||||
"version": "0.1.0",
|
||||
|
||||
@@ -10,7 +10,10 @@
|
||||
"scripts": {
|
||||
"dev": "vite --host",
|
||||
"build": "npm run build:packages && vue-tsc -b && vite build",
|
||||
"build:packages": "npm run build --workspace @native-vue-router/core --workspace @native-vue-router/preset-native --workspace @native-vue-router/capacitor --workspace @native-vue-router/electron --if-present",
|
||||
"build:packages": "npm run build --workspace @native-vue-router/core --workspace @native-vue-router/core-v2 --workspace @native-vue-router/preset-native --workspace @native-vue-router/capacitor --workspace @native-vue-router/electron --if-present",
|
||||
"build:v2-demo": "npm run build --workspace @native-vue-router/origins-demo",
|
||||
"dev:v2": "npm run dev --workspace @native-vue-router/origins-demo",
|
||||
"serve:v2": "npm run build:v2-demo && npm run preview --workspace @native-vue-router/origins-demo",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
|
||||
720
packages/core-v2/API.md
Normal file
720
packages/core-v2/API.md
Normal file
@@ -0,0 +1,720 @@
|
||||
# Core v2 API reference
|
||||
|
||||
`@native-vue-router/core-v2` is a routeless Vue scene compositor. A mounted
|
||||
component can create another component, animate both through a local operation
|
||||
frame, and retain either side when the operation resolves.
|
||||
|
||||
This document describes the experimental `0.1.0-experimental.0` API.
|
||||
|
||||
## Contents
|
||||
|
||||
- [Gesture start recognition](#gesture-start-recognition)
|
||||
- [Minimum setup](#minimum-setup)
|
||||
- [Components](#components)
|
||||
- [Nested scenes](#nested-scenes)
|
||||
- [Scene and view functions](#scene-and-view-functions)
|
||||
- [Actions and history](#actions-and-history)
|
||||
- [Gestures](#gestures)
|
||||
- [Choreographies and effects](#choreographies-and-effects)
|
||||
- [Node-scoped controls](#node-scoped-controls)
|
||||
- [Scene diagnostics and manual operations](#scene-diagnostics-and-manual-operations)
|
||||
- [Type reference](#type-reference)
|
||||
- [Errors and constraints](#errors-and-constraints)
|
||||
|
||||
## Gesture start recognition
|
||||
|
||||
The builder separates where a gesture begins from the direction it moves:
|
||||
|
||||
```ts
|
||||
const edgeBack = gesture.from
|
||||
.left("clamp(24px, 7vw, 48px)")
|
||||
.to.right()
|
||||
.navigate((context) => (context.canGoBack ? back() : null))
|
||||
.animate(slideRight);
|
||||
```
|
||||
|
||||
Edges are measured from the **gesture host element**, not unconditionally from
|
||||
the browser viewport. Supported start rules are:
|
||||
|
||||
```ts
|
||||
gesture.from.left(distance);
|
||||
gesture.from.right(distance);
|
||||
gesture.from.top(distance);
|
||||
gesture.from.bottom(distance);
|
||||
gesture.from.anywhere();
|
||||
gesture.from.when(predicate);
|
||||
```
|
||||
|
||||
`distance` accepts a number in CSS pixels or a CSS length string, including
|
||||
percentages, `calc()`, and `clamp()`. It is resolved against the current host
|
||||
size at pointer-down.
|
||||
|
||||
`.from` is optional. A chain beginning at `.to` admits pointer-down anywhere:
|
||||
|
||||
```ts
|
||||
gesture.to.right();
|
||||
```
|
||||
|
||||
This is semantically equivalent to:
|
||||
|
||||
```ts
|
||||
gesture.from.anywhere().to.right();
|
||||
```
|
||||
|
||||
It does not capture on the first positive pixel. The recognizer waits until
|
||||
directed movement crosses the intent threshold and dominates the cross-axis.
|
||||
|
||||
Custom shapes, safe-area rules, and exclusion zones belong in `.from.when()`:
|
||||
|
||||
```ts
|
||||
const dropDialog = gesture.from
|
||||
.when(({ point, bounds, event }) => {
|
||||
const rail = Math.max(36, bounds.width * 0.08);
|
||||
const outsideExcludedBand =
|
||||
event.clientY < bounds.top + bounds.height * 0.35 ||
|
||||
event.clientY > bounds.top + bounds.height * 0.65;
|
||||
return point.localX <= rail && outsideExcludedBand;
|
||||
})
|
||||
.to.down()
|
||||
.navigate(() => above(originView(DialogView)))
|
||||
.animate(dropAnimation);
|
||||
```
|
||||
|
||||
Interactive form controls are ignored automatically. Add
|
||||
`data-origin-gesture="ignore"` to any other element or ancestor that should not
|
||||
begin a gesture.
|
||||
|
||||
## Minimum setup
|
||||
|
||||
Import the required compositor stylesheet once:
|
||||
|
||||
```ts
|
||||
import "@native-vue-router/core-v2/style.css";
|
||||
```
|
||||
|
||||
Create a scene:
|
||||
|
||||
```ts
|
||||
import { createOriginScene, originView } from "@native-vue-router/core-v2";
|
||||
import HomeView from "./HomeView.vue";
|
||||
|
||||
export const scene = createOriginScene({
|
||||
initial: originView(HomeView, undefined, {
|
||||
key: "home",
|
||||
name: "Home",
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
Render it:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { OriginScene } from "@native-vue-router/core-v2";
|
||||
import { scene } from "./scene";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OriginScene :scene="scene" />
|
||||
</template>
|
||||
```
|
||||
|
||||
`OriginScene` must have a non-zero width and height through its parent layout.
|
||||
|
||||
## Components
|
||||
|
||||
### `OriginScene`
|
||||
|
||||
Renders every currently mounted scene node as a stable, absolutely positioned
|
||||
sibling.
|
||||
|
||||
| Prop | Type | Required | Description |
|
||||
| ------- | ------------- | -------- | -------------------------------------- |
|
||||
| `scene` | `OriginScene` | yes | Scene created by `createOriginScene()` |
|
||||
|
||||
The component provides node ownership to descendants, registers host elements
|
||||
for measurement, and applies the scene's composited styles. Application views
|
||||
must be rendered through this component before calling `useOrigin()` or
|
||||
`useOriginGesture()`.
|
||||
|
||||
### `OriginGesture`
|
||||
|
||||
Convenience component that renders one HTML element and binds one gesture
|
||||
recognizer to it.
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| --------- | ------------------------- | ------- | ---------------------------------- |
|
||||
| `as` | `string` | `"div"` | HTML tag used for the gesture host |
|
||||
| `gesture` | `OriginGestureDefinition` | — | Preferred builder definition |
|
||||
|
||||
Attributes, classes, and listeners not consumed as props are forwarded to the
|
||||
rendered host.
|
||||
|
||||
```vue
|
||||
<OriginGesture as="main" class="profile" :gesture="openDetailsGesture">
|
||||
...
|
||||
</OriginGesture>
|
||||
```
|
||||
|
||||
For compatibility, the component also accepts the legacy mutually exclusive
|
||||
set of `direction`, `edge`, `threshold`, and `action` props.
|
||||
|
||||
Use `useOriginGesture()` instead when an extra wrapper is undesirable.
|
||||
|
||||
### `OriginGestureSurface`
|
||||
|
||||
Policy-neutral host for multiple completed builder definitions:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const gestures = [forwardGesture, backGesture] as const;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OriginGestureSurface as="main" :gestures="gestures">
|
||||
...
|
||||
</OriginGestureSurface>
|
||||
</template>
|
||||
```
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ---------- | ------------------------------------ | -------- | ------------------------------ |
|
||||
| `as` | `string` | `"div"` | Shared native gesture host |
|
||||
| `gestures` | `readonly OriginGestureDefinition[]` | required | Fully defined page-owned rules |
|
||||
|
||||
The component adds no recognition or navigation policy. It installs each
|
||||
definition with `useOriginGesture()`, forwards every pointer event to every
|
||||
binding, and derives the least-permissive shared `touch-action`:
|
||||
|
||||
- horizontal only: `pan-y`;
|
||||
- vertical only: `pan-x`;
|
||||
- both axes: `none`.
|
||||
|
||||
Definitions should be immutable and stable for the lifetime of the rendered
|
||||
surface. The owning page remains the visible declaration point for every
|
||||
`.from`, `.to`, `.complete`, `.navigate`, and `.animate` choice.
|
||||
|
||||
## Nested scenes
|
||||
|
||||
`OriginScene` is a reusable compositor, not an application-only singleton. A
|
||||
component may render another scene inside its own layout:
|
||||
|
||||
```vue
|
||||
<section class="carousel">
|
||||
<OriginScene :scene="carouselScene" />
|
||||
</section>
|
||||
```
|
||||
|
||||
The child scene gets independent mounted nodes, retained history, measurements,
|
||||
operations, and clipping. `useOrigin()` and `useOriginGesture()` resolve the
|
||||
nearest scene-node provider, so declarations inside a carousel slide operate
|
||||
on carousel components rather than the outer page.
|
||||
|
||||
Parent/child gesture arbitration is currently pointer-down based. An eligible
|
||||
child recognizer stops propagation immediately. If its later `.navigate()`
|
||||
factory returns `null`, that same pointer sequence is not offered to the
|
||||
parent. Cooperative nested components should reserve a start region with
|
||||
`.from.when()` or `.from.left()` that allows the parent handler to receive
|
||||
pointer-down.
|
||||
|
||||
The nested-scenes demo contains both this cooperative policy and an intentional
|
||||
greedy-child conflict. A future gesture arena could delay ownership until
|
||||
direction and navigation availability are known.
|
||||
|
||||
## Scene and view functions
|
||||
|
||||
### `originView(component, props?, options?)`
|
||||
|
||||
Creates a lightweight recipe for mounting a Vue component.
|
||||
|
||||
```ts
|
||||
const profile = originView(
|
||||
ProfileView,
|
||||
{ userId: "42" },
|
||||
{ key: "profile-42", name: "Profile" },
|
||||
);
|
||||
```
|
||||
|
||||
The recipe is not itself a mounted instance. A forward action creates an
|
||||
instance from it, then retains that exact instance while its entry remains in
|
||||
history. Back does not call the recipe again. Component definitions are marked
|
||||
raw so Vue does not proxy them inside reactive scene structures.
|
||||
|
||||
`OriginViewOptions`:
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------ | -------- | --------------------------------------------- |
|
||||
| `key` | `string` | Recipe identity and generated node-key prefix |
|
||||
| `name` | `string` | Human-readable diagnostic label |
|
||||
|
||||
When no key is supplied, one is generated from the component/name and a
|
||||
sequence number.
|
||||
|
||||
### `createOriginScene(options)`
|
||||
|
||||
Creates one independent scene graph, history context, and compositor.
|
||||
|
||||
```ts
|
||||
const scene = createOriginScene({
|
||||
initial: originView(HomeView),
|
||||
});
|
||||
```
|
||||
|
||||
`options.initial` accepts one `OriginView` or an array of independent root
|
||||
views. Scenes do not share nodes, history, operation IDs, or measurements.
|
||||
|
||||
## Actions and history
|
||||
|
||||
### `forward(target, choreography?, options?)`
|
||||
|
||||
With choreography, creates a complete retained-history push action:
|
||||
|
||||
```ts
|
||||
const openProfile = () =>
|
||||
forward(originView(ProfileView, { userId: "42" }), slideLeft);
|
||||
```
|
||||
|
||||
Without choreography, it creates an `OriginNavigationIntent` for a gesture
|
||||
builder:
|
||||
|
||||
```ts
|
||||
.navigate(() => forward(originView(ProfileView, { userId: "42" })))
|
||||
```
|
||||
|
||||
When forward commits, the origin remains mounted but becomes parked. Its DOM,
|
||||
component-local state, and nested scroll positions remain intact.
|
||||
|
||||
`OriginNavigationActionOptions.placement` defaults to `"above"`.
|
||||
|
||||
### `back(choreography?, options?)`
|
||||
|
||||
Creates a retained-history pop action:
|
||||
|
||||
```ts
|
||||
const goBack = (context: OriginContext) =>
|
||||
context.canGoBack ? back(slideRight) : null;
|
||||
```
|
||||
|
||||
Back has no target recipe. When it begins, the scene resolves the origin's
|
||||
`previousNodeKey` and reveals that exact mounted instance. A committed back
|
||||
unmounts only the current entry. A cancelled back hides the previous entry
|
||||
again and leaves the current entry active.
|
||||
|
||||
`back()` without choreography returns an animation-free navigation intent for
|
||||
`.navigate()`. `back(slideRight)` returns a complete programmatic action.
|
||||
|
||||
`OriginNavigationActionOptions.placement` defaults to `"under"`.
|
||||
|
||||
### `originAction(target, choreography, options?)`
|
||||
|
||||
Constructs a complete low-level `OriginAction`. Prefer `forward()` and `back()`
|
||||
when expressing retained navigation.
|
||||
|
||||
```ts
|
||||
const action = originAction(profile, slideLeft, {
|
||||
placement: "above",
|
||||
history: "push",
|
||||
});
|
||||
```
|
||||
|
||||
`OriginActionOptions`:
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
| ----------- | -------------------- | --------- | ---------------------------- |
|
||||
| `placement` | `"above" \| "under"` | `"above"` | Target stacking relationship |
|
||||
| `history` | `OriginHistoryMode` | `"push"` | Target history mutation |
|
||||
|
||||
### `above(target, choreography?, options?)`
|
||||
|
||||
Shorthand for `originAction()` with `placement: "above"`.
|
||||
|
||||
```ts
|
||||
const openProfile = () => above(originView(ProfileView), slideLeft);
|
||||
```
|
||||
|
||||
Placement controls stacking only. It does not imply a movement direction.
|
||||
Omitting choreography returns a navigation intent for a gesture builder.
|
||||
|
||||
### `under(target, choreography?, options?)`
|
||||
|
||||
Shorthand for `originAction()` with `placement: "under"`.
|
||||
|
||||
```ts
|
||||
const goBack = (context: OriginContext) =>
|
||||
context.previous ? back(slideRight) : null;
|
||||
```
|
||||
|
||||
`under()` does not automatically mean history back. It remains available for
|
||||
custom stacking actions; `back()` is the clearer retained-history primitive.
|
||||
Omitting choreography returns a navigation intent for a gesture builder.
|
||||
|
||||
### History modes
|
||||
|
||||
History is a linked chain of mounted scene nodes.
|
||||
|
||||
| Mode | Commit behavior |
|
||||
| -------- | -------------------------------------------------------------- |
|
||||
| `"push"` | Park and retain the origin; activate the new target |
|
||||
| `"back"` | Reuse the retained previous target; pop and unmount the origin |
|
||||
|
||||
Parked entries are `inert`, `aria-hidden`, invisible, and excluded from pointer
|
||||
input. They remain mounted until back pops them or the scene is destroyed.
|
||||
|
||||
## Gestures
|
||||
|
||||
### `gesture`
|
||||
|
||||
Immutable fluent builder for component-owned gesture policy:
|
||||
|
||||
```ts
|
||||
const swipeBack = gesture.from
|
||||
.left(32)
|
||||
.to.right({ threshold: 10 })
|
||||
.complete(({ progress, velocity }) => progress >= 0.4 || velocity >= 0.9)
|
||||
.navigate((context) => (context.canGoBack ? back() : null))
|
||||
.animate(slideRight);
|
||||
```
|
||||
|
||||
The stages have distinct responsibilities:
|
||||
|
||||
| Stage | Responsibility |
|
||||
| ---------------------- | ---------------------------------------------------------- |
|
||||
| `.from.*` | Optional pointer-down eligibility |
|
||||
| `.to.*` | Required movement direction and intent-recognition options |
|
||||
| `.complete(predicate)` | Optional release commit/cancel decision |
|
||||
| `.navigate(factory)` | Required target and retained-history intent |
|
||||
| `.animate(routine)` | Required source/target/frame choreography |
|
||||
|
||||
The builder is persistent and immutable. Reusing an earlier stage cannot
|
||||
change a definition already produced from it.
|
||||
|
||||
`.to.left()`, `.to.right()`, `.to.up()`, and `.to.down()` accept optional
|
||||
`OriginGestureDirectionOptions`:
|
||||
|
||||
| Field | Default | Description |
|
||||
| --------------- | ------- | ------------------------------------------------- |
|
||||
| `threshold` | `8` | Directed CSS pixels required before capture |
|
||||
| `axisDominance` | `1.15` | Directed/cross-axis ratio required before capture |
|
||||
|
||||
If `.complete()` is omitted, the choreography's `commitThreshold` and
|
||||
`commitVelocity` decide release normally.
|
||||
|
||||
The completion context contains the origin, direction, normalized progress and
|
||||
velocity, directed pixel distance, cross-axis distance, duration, pointer-up
|
||||
event, host, bounds, and start/current points. Completion predicates are
|
||||
synchronous because they select operation intent at release.
|
||||
|
||||
### `useOriginGesture(definition)`
|
||||
|
||||
Creates one primary-pointer, single-axis recognizer owned by the component that
|
||||
calls it.
|
||||
|
||||
```ts
|
||||
const open = useOriginGesture(
|
||||
gesture.to
|
||||
.left({ threshold: 10 })
|
||||
.navigate(() => forward(originView(DetailsView)))
|
||||
.animate(slideLeft),
|
||||
);
|
||||
```
|
||||
|
||||
The return value contains:
|
||||
|
||||
```ts
|
||||
interface OriginGestureBinding {
|
||||
readonly style: Readonly<CSSProperties>;
|
||||
readonly onPointerdown: (event: PointerEvent) => void;
|
||||
readonly onPointermove: (event: PointerEvent) => void;
|
||||
readonly onPointerup: (event: PointerEvent) => void;
|
||||
readonly onPointercancel: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
Apply all handlers to the same element. The returned style sets dimensions and
|
||||
`touch-action` so native scrolling remains available on the cross-axis.
|
||||
|
||||
Recognition requires:
|
||||
|
||||
1. A primary, left-button pointer satisfies the optional start policy.
|
||||
2. The target is not an ignored interactive element.
|
||||
3. Directed movement reaches `threshold`.
|
||||
4. Directed movement exceeds cross-axis movement by `axisDominance`.
|
||||
5. The navigation factory returns an intent.
|
||||
|
||||
Progress is directed distance divided by host width or height. Release velocity
|
||||
is normalized by the same dimension.
|
||||
|
||||
An asynchronous navigation factory is supported. If it resolves after the
|
||||
pointer was released or cancelled, the stale result is discarded.
|
||||
|
||||
The legacy `OriginGestureOptions` object remains accepted. Its `edge` is a
|
||||
number inferred from the side opposite `direction`, matching the previous API.
|
||||
|
||||
## Choreographies and effects
|
||||
|
||||
### `defineOriginChoreography(choreography)`
|
||||
|
||||
Type-safe identity helper for declaring custom visual routines.
|
||||
|
||||
```ts
|
||||
const scaleIn = defineOriginChoreography({
|
||||
name: "scale-in",
|
||||
commitThreshold: 0.4,
|
||||
commitVelocity: 0.8,
|
||||
effects: ({ progress, viewport }) => ({
|
||||
source: {
|
||||
transform: `scale(${1 - progress * 0.08})`,
|
||||
opacity: 1 - progress * 0.3,
|
||||
},
|
||||
target: {
|
||||
transform: `translateY(${(1 - progress) * viewport.height}px)`,
|
||||
},
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
The function returns the same object. Its value is type checking and a clear
|
||||
construction point.
|
||||
|
||||
`effects()` may return:
|
||||
|
||||
| Effect | Applied to |
|
||||
| -------- | ---------------------------------------------------------- |
|
||||
| `frame` | Source, target, and descendants on both sides of this edge |
|
||||
| `source` | Component that originated this operation |
|
||||
| `target` | Component created by this operation and its descendants |
|
||||
|
||||
Transforms are concatenated from inherited frames to local frames. Opacity is
|
||||
multiplied. Properties inside `style` use local-last precedence, except
|
||||
`transform` and numeric `opacity`, which are also composed.
|
||||
|
||||
Choreography callbacks should be deterministic and free of side effects. They
|
||||
can run repeatedly during rendering and animation.
|
||||
|
||||
### Commit thresholds
|
||||
|
||||
When `finish()` does not explicitly override the decision, a target commits
|
||||
when either:
|
||||
|
||||
- `progress >= commitThreshold`, default `0.36`; or
|
||||
- `progress >= 0.06` and `velocity >= commitVelocity`, default `0.9`.
|
||||
|
||||
The operation's intent becomes final before its spring settles.
|
||||
|
||||
### Included presets
|
||||
|
||||
| Export | Behavior |
|
||||
| ------------ | ------------------------------------------------------------- |
|
||||
| `slideLeft` | Target enters from the right above a slightly receding source |
|
||||
| `slideRight` | Source exits right and reveals a target underneath |
|
||||
| `fade` | Source fades out as target fades in |
|
||||
|
||||
These are ordinary `OriginChoreography` objects and can be replaced entirely.
|
||||
|
||||
### `normalizedEffect(effect, fallbackLayer?)`
|
||||
|
||||
Internal compositor helper exposed for custom diagnostics or compositors. It
|
||||
returns a defined effect and adds `fallbackLayer` to the effect's own layer.
|
||||
Applications normally return plain effects and let the scene normalize them.
|
||||
|
||||
## Node-scoped controls
|
||||
|
||||
### `useOrigin()`
|
||||
|
||||
Returns controls scoped to the scene node containing the calling component.
|
||||
|
||||
```ts
|
||||
const origin = useOrigin();
|
||||
|
||||
await origin.perform(forward(originView(SettingsView), fade));
|
||||
```
|
||||
|
||||
Return value:
|
||||
|
||||
| Field | Description |
|
||||
| ----------------- | --------------------------------------------------- |
|
||||
| `nodeKey` | Unique key of this mounted node |
|
||||
| `scene` | Containing `OriginScene` |
|
||||
| `context` | Reactive node-local `OriginContext` |
|
||||
| `view` | Reactive shorthand for the current recipe |
|
||||
| `previous` | Recipe belonging to the retained previous instance |
|
||||
| `canGoBack` | Whether a retained previous instance exists |
|
||||
| `begin(action)` | Create a target and return manual operation control |
|
||||
| `perform(action)` | Create and programmatically commit a target |
|
||||
|
||||
The composable throws when called outside a view mounted by `OriginScene`.
|
||||
There is no global `activeView`; the injected node containing the event is the
|
||||
origin.
|
||||
|
||||
## Scene diagnostics and manual operations
|
||||
|
||||
### `OriginScene` fields
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------ | ----------------------------------------- | ------------------------------------- |
|
||||
| `nodes` | `ComputedRef<readonly OriginSceneNode[]>` | Currently mounted Vue component nodes |
|
||||
| `operations` | `ComputedRef<readonly OriginOperation[]>` | Live operation edges |
|
||||
| `roots` | `ShallowRef<readonly string[]>` | Visible operation-graph roots |
|
||||
|
||||
These fields are suitable for inspectors and diagnostics. Do not mutate their
|
||||
contents.
|
||||
|
||||
### `scene.contextFor(nodeKey)`
|
||||
|
||||
Returns the `OriginContext` for a mounted node. Throws if the key no longer
|
||||
exists.
|
||||
|
||||
### `scene.begin(originKey, action)`
|
||||
|
||||
For forward, mounts a new target and waits one Vue tick for measurement. For
|
||||
back, reveals and measures the retained previous node. It then returns an
|
||||
`OriginOperationHandle`.
|
||||
|
||||
```ts
|
||||
const handle = await scene.begin(nodeKey, action);
|
||||
handle.update(0.25, 0.4);
|
||||
const committed = await handle.finish();
|
||||
```
|
||||
|
||||
Only one outgoing operation may exist for a given origin. Its created target
|
||||
can immediately begin its own outgoing operation, enabling X → Y → Z chains.
|
||||
|
||||
### `OriginOperationHandle`
|
||||
|
||||
| Member | Description |
|
||||
| ----------------------------- | --------------------------------------------------- |
|
||||
| `id` | Unique operation ID |
|
||||
| `originKey` | Source node key |
|
||||
| `targetKey` | Created or retained target node key |
|
||||
| `update(progress, velocity?)` | Update normalized interactive state |
|
||||
| `finish(options?)` | Decide, settle, and return whether target committed |
|
||||
| `cancel(options?)` | Force cancellation and remove the target branch |
|
||||
|
||||
`OriginFinishOptions`:
|
||||
|
||||
| Field | Default | Description |
|
||||
| --------- | ------------------ | ---------------------------- |
|
||||
| `commit` | threshold decision | Force commit or cancellation |
|
||||
| `animate` | `true` | Run the settling spring |
|
||||
|
||||
### `scene.perform(originKey, action)`
|
||||
|
||||
Equivalent to beginning an operation and immediately finishing it with
|
||||
`commit: true`. The target still uses the settling spring unless reduced motion
|
||||
is active.
|
||||
|
||||
### Renderer integration methods
|
||||
|
||||
`registerElement()`, `registerContainer()`, `styleForNode()`, and
|
||||
`isNodeInteractive()` are public at the TypeScript boundary because the Vue
|
||||
renderer components consume them. They are internal integration APIs and may
|
||||
change during the experimental series.
|
||||
|
||||
## Type reference
|
||||
|
||||
### `OriginView<Props>`
|
||||
|
||||
A component recipe containing `component`, optional `props`, optional `key`,
|
||||
and optional diagnostic `name`.
|
||||
|
||||
### `OriginAction`
|
||||
|
||||
A choreography, placement, history mode, and—except for back—target recipe.
|
||||
|
||||
### `OriginNavigationIntent`
|
||||
|
||||
An animation-free target, placement, and history mutation returned by
|
||||
`forward()`, `back()`, `above()`, or `under()` when choreography is omitted.
|
||||
Gesture `.animate()` combines it with choreography to create the internal
|
||||
action.
|
||||
|
||||
### Gesture definition types
|
||||
|
||||
- `OriginGestureDefinition`: immutable executable result passed to
|
||||
`useOriginGesture()` or the `OriginGesture` component.
|
||||
- `OriginGestureStart`: anywhere, edge, or predicate start policy.
|
||||
- `OriginGestureDistance`: numeric CSS pixels or a CSS length string.
|
||||
- `OriginGestureStartContext`: pointer-down event, origin, host, bounds, and
|
||||
local/client point.
|
||||
- `OriginGestureCompletionContext`: release metrics and origin/DOM context.
|
||||
- `OriginGestureDirectionOptions`: `threshold` and `axisDominance`.
|
||||
- `OriginGestureBinding`: host style and four pointer handlers.
|
||||
- `OriginGestureSurfaceProps`: shared host tag and completed definition list.
|
||||
- `MaybeOriginNavigationIntent`: synchronous or asynchronous nullable
|
||||
navigation-factory result.
|
||||
|
||||
### `OriginContext`
|
||||
|
||||
Node-local action context:
|
||||
|
||||
- `nodeKey`: mounted origin identity.
|
||||
- `view`: origin recipe.
|
||||
- `canGoBack`: whether a retained previous instance exists.
|
||||
- `previous`: recipe belonging to the mounted previous entry.
|
||||
- `history`: recipes belonging to all retained previous entries.
|
||||
|
||||
### `MaybeOriginAction`
|
||||
|
||||
```ts
|
||||
OriginAction | null | undefined | Promise<OriginAction | null | undefined>;
|
||||
```
|
||||
|
||||
### `OriginEffect`
|
||||
|
||||
| Field | Description |
|
||||
| ----------- | ----------------------------------------------- |
|
||||
| `transform` | Composable CSS transform contribution |
|
||||
| `opacity` | Multiplicative opacity contribution |
|
||||
| `layer` | Relative stacking contribution |
|
||||
| `style` | Other CSS properties with local-last precedence |
|
||||
|
||||
`above()` adds a default target layer of `+1`; `under()` adds `-1`.
|
||||
|
||||
### `OriginChoreographyContext`
|
||||
|
||||
| Field | Description |
|
||||
| ------------ | ------------------------------------------------------ |
|
||||
| `progress` | Normalized `0..1` progress |
|
||||
| `velocity` | Normalized progress units per second |
|
||||
| `phase` | `preparing`, `interactive`, `settling`, or `finished` |
|
||||
| `intent` | `undecided`, `commit`, or `cancel` |
|
||||
| `originRect` | Origin bounds captured before target mounting |
|
||||
| `targetRect` | Target bounds measured after mounting |
|
||||
| `viewport` | Scene-container bounds, with browser viewport fallback |
|
||||
|
||||
Rect values are viewport CSS pixels.
|
||||
|
||||
### Diagnostic types
|
||||
|
||||
- `OriginSceneNode`: mounted identity, retained previous key, state, recipe,
|
||||
history, and incoming edge.
|
||||
- `OriginSceneNodeState`: `active`, `transitioning`, or `parked`.
|
||||
- `OriginOperation`: read-only live edge state.
|
||||
- `OriginOperationPhase`: operation lifecycle phase.
|
||||
- `OriginOperationIntent`: selected operation outcome.
|
||||
- `OriginRect`: top, left, width, and height.
|
||||
|
||||
### Internal types
|
||||
|
||||
`OriginNodeScope` and `MutableOriginOperation` are renderer/runtime
|
||||
implementation types. They are exported by the current barrel but marked
|
||||
`@internal` and should not be application dependencies.
|
||||
|
||||
## Errors and constraints
|
||||
|
||||
- `useOrigin()` and `useOriginGesture()` must run inside a component mounted by
|
||||
`OriginScene`.
|
||||
- An origin can own only one outgoing operation at a time.
|
||||
- Parked history entries cannot originate operations until back reveals them.
|
||||
- A target can originate its own operation as soon as its incoming operation's
|
||||
intent becomes commit.
|
||||
- The included recognizer follows one primary pointer and one axis.
|
||||
- Builder edges accept CSS lengths; arbitrary start policy belongs in
|
||||
`.from.when()`.
|
||||
- Every pushed history entry retains its Vue instance and DOM until a committed
|
||||
back operation pops it. There is no eviction policy yet.
|
||||
- Parked instances remain mounted, so their ordinary Vue effects and timers
|
||||
continue running.
|
||||
- A choreography creates one target. Chaining supports any number of
|
||||
simultaneously mounted targets.
|
||||
- Reduced-motion preference resolves settling immediately.
|
||||
234
packages/core-v2/README.md
Normal file
234
packages/core-v2/README.md
Normal file
@@ -0,0 +1,234 @@
|
||||
# Core v2: routeless origins
|
||||
|
||||
`@native-vue-router/core-v2` is an experimental, Vue-only scene compositor. It
|
||||
does not install Vue Router, resolve URLs, select a globally active route, or
|
||||
render through `RouterView`.
|
||||
|
||||
The complete function, component, option, type, gesture-edge, and choreography
|
||||
reference is in [API.md](./API.md).
|
||||
|
||||
The primitive is:
|
||||
|
||||
> A mounted component can originate a routine that creates another component,
|
||||
> moves both components relative to the origin's coordinate frame, and retains
|
||||
> the previous instance until a committed back operation pops the newer entry.
|
||||
|
||||
## Run the experiment
|
||||
|
||||
From the workspace root:
|
||||
|
||||
```sh
|
||||
npm run dev:v2
|
||||
```
|
||||
|
||||
Open the printed URL to explore seven physical labs:
|
||||
|
||||
- a four-view chain that can keep four nodes and three edges live at once;
|
||||
- one view with horizontal, vertical, and edge-only declarations;
|
||||
- programmatic gallery navigation followed by gesture-owned traversal;
|
||||
- a vertically presented media player with local interactive state;
|
||||
- a chat that intentionally declares no back gesture;
|
||||
- a predicate-gated downward gesture that drops a left-edge dialog.
|
||||
- three nested scenes demonstrating cooperative carousels, vertical decks, and
|
||||
an intentional parent/child gesture conflict.
|
||||
|
||||
The expandable inspector reports mounted Vue instances, active operation
|
||||
edges, animation progress, and recent lifecycle events. In the chain lab,
|
||||
swipe rapidly through X → Y → Z → Ω to see all four components mounted while
|
||||
their independent frames are still moving.
|
||||
|
||||
An installable, offline-capable PWA build of the same experiment is hosted at
|
||||
<https://v2.demo.native-router.harvmaster.com/>.
|
||||
|
||||
## Basic usage
|
||||
|
||||
Create a scene with a component recipe:
|
||||
|
||||
```ts
|
||||
import { createOriginScene, originView } from "@native-vue-router/core-v2";
|
||||
import "@native-vue-router/core-v2/style.css";
|
||||
import HomeView from "./HomeView.vue";
|
||||
|
||||
export const scene = createOriginScene({
|
||||
initial: originView(HomeView, { accountId: "42" }, { key: "home" }),
|
||||
});
|
||||
```
|
||||
|
||||
Render it:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { OriginScene } from "@native-vue-router/core-v2";
|
||||
import { scene } from "./scene";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OriginScene :scene="scene" />
|
||||
</template>
|
||||
```
|
||||
|
||||
Declare an interaction inside the component that should originate it:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
OriginGesture,
|
||||
forward,
|
||||
gesture,
|
||||
originView,
|
||||
slideLeft,
|
||||
} from "@native-vue-router/core-v2";
|
||||
import ProfileView from "./ProfileView.vue";
|
||||
|
||||
const openProfile = gesture.to
|
||||
.left()
|
||||
.navigate(() =>
|
||||
forward(originView(ProfileView, { userId: "7" }, { key: "profile-7" })),
|
||||
)
|
||||
.animate(slideLeft);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OriginGesture :gesture="openProfile">
|
||||
<main>Swipe this component left</main>
|
||||
</OriginGesture>
|
||||
</template>
|
||||
```
|
||||
|
||||
Starting directly at `.to.left()` means pointer-down may occur anywhere on the
|
||||
host. Add `.from.left("clamp(24px, 8%, 64px)")` before `.to.right()` for a
|
||||
conventional proportional back edge, or `.from.when(context => ...)` for
|
||||
arbitrary start policy. `.complete()` can override the choreography's release
|
||||
thresholds.
|
||||
|
||||
There is no global navigation declaration. If this component should not
|
||||
support that gesture, it simply does not render `OriginGesture`.
|
||||
|
||||
For an existing element where an additional wrapper is undesirable, use
|
||||
`useOriginGesture()` and attach its four pointer handlers directly.
|
||||
|
||||
For several gestures on one page surface, keep the definitions in the page and
|
||||
pass them to the policy-neutral host:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { OriginGestureSurface } from "@native-vue-router/core-v2";
|
||||
|
||||
const gestures = [forwardGesture, backGesture] as const;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OriginGestureSurface as="main" :gestures="gestures">
|
||||
...
|
||||
</OriginGestureSurface>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Going backward
|
||||
|
||||
Every pushed history entry remains mounted. Back resolves the already-mounted
|
||||
previous node and pops only the current entry after the operation commits:
|
||||
|
||||
```ts
|
||||
import {
|
||||
back,
|
||||
gesture,
|
||||
slideRight,
|
||||
useOriginGesture,
|
||||
} from "@native-vue-router/core-v2";
|
||||
|
||||
const goBack = useOriginGesture(
|
||||
gesture.from
|
||||
.left("max(24px, 6%)")
|
||||
.to.right()
|
||||
.navigate((context) => (context.canGoBack ? back() : null))
|
||||
.animate(slideRight),
|
||||
);
|
||||
```
|
||||
|
||||
Parked entries are visually hidden, inert, and removed from pointer and
|
||||
accessibility interaction. Their Vue instances and DOM remain mounted, so
|
||||
component-local state and nested element scroll positions are preserved
|
||||
naturally. A cancelled back re-parks the previous target; a committed back
|
||||
unmounts the entry being left.
|
||||
|
||||
The application chooses whether this is exposed as a left-edge gesture,
|
||||
toolbar button, keyboard shortcut, Android hardware-back action, or not exposed
|
||||
at all.
|
||||
|
||||
## Custom choreography
|
||||
|
||||
A choreography returns independent effects for its source, target, and their
|
||||
shared frame:
|
||||
|
||||
```ts
|
||||
import { defineOriginChoreography } from "@native-vue-router/core-v2";
|
||||
|
||||
export const zoomFromCard = defineOriginChoreography({
|
||||
name: "zoom-from-card",
|
||||
commitThreshold: 0.42,
|
||||
effects: ({ progress, originRect, viewport }) => ({
|
||||
source: {
|
||||
transform: `scale(${1 - progress * 0.08})`,
|
||||
opacity: 1 - progress * 0.4,
|
||||
},
|
||||
target: {
|
||||
transform: `translateY(${(1 - progress) * viewport.height}px)`,
|
||||
style: {
|
||||
borderRadius: `${(1 - progress) * 24}px`,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
`originRect`, `targetRect`, and the scene viewport are measured after the
|
||||
target mounts. The gesture may update progress interactively or a normal click
|
||||
can call `useOrigin().perform(action)`.
|
||||
|
||||
Transforms are concatenated from the oldest origin frame to the newest local
|
||||
effect. Opacity is multiplied. Other properties in `style` use local-last
|
||||
precedence. Consequently, if X→Y and Y→Z overlap:
|
||||
|
||||
```text
|
||||
Y transform = (X→Y target) × (Y→Z source)
|
||||
Z transform = (X→Y target) × (Y→Z target)
|
||||
```
|
||||
|
||||
## Why scene nodes are flat
|
||||
|
||||
The operation graph is not represented as Vue component ancestry. Every
|
||||
component has one stable, keyed host directly under `OriginScene`.
|
||||
|
||||
If Y were physically moved from an X→Y wrapper to the scene root when an edge
|
||||
collapsed, Vue would unmount and recreate Y. Instead, v2 rewrites graph edges
|
||||
and recalculates Y's effect layers while every retained VNode stays in the same
|
||||
flat host.
|
||||
|
||||
“Y is Z's origin” is a coordinate and retained-history relationship, not Vue
|
||||
component ancestry.
|
||||
|
||||
## Current experimental boundaries
|
||||
|
||||
- One operation creates one target. Chaining operations already permits any
|
||||
number of simultaneous scene nodes; multi-target routines are not yet
|
||||
exposed as a public builder.
|
||||
- A node can originate one outgoing operation at a time. Its created target
|
||||
may immediately originate the next operation.
|
||||
- The included pointer recognizer handles one primary pointer and one axis.
|
||||
Choreographies and scene operations are independent of it.
|
||||
- Multiple recognizers can share a host and arbitrate by start policy and
|
||||
directional intent. A dedicated multi-pointer gesture arena is not exposed.
|
||||
- Nested `OriginScene` components have independent history and measurements.
|
||||
Child recognizers currently claim propagation at pointer-down, so yielding a
|
||||
region to a parent requires an explicit `.from` policy.
|
||||
- Every pushed history entry remains mounted until back pops it. There is not
|
||||
yet an eviction policy, so applications should deliberately reset long-lived
|
||||
navigation contexts when that API is introduced.
|
||||
- Parked instances remain mounted and ordinary Vue timers/watchers continue to
|
||||
run. Engine-specific park/resume lifecycle hooks are not exposed yet.
|
||||
- Arbitrary CSS properties can be used, but only transforms and opacity have
|
||||
defined multi-operation composition rules at present.
|
||||
|
||||
These boundaries are explicit so the experiment can validate the origin
|
||||
primitive before compatibility conveniences become permanent architecture.
|
||||
27
packages/core-v2/package.json
Normal file
27
packages/core-v2/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@native-vue-router/core-v2",
|
||||
"version": "0.1.0-experimental.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"files": [
|
||||
"dist",
|
||||
"API.md",
|
||||
"README.md"
|
||||
],
|
||||
"sideEffects": [
|
||||
"./dist/style.css"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "vite build --config vite.config.ts && vue-tsc -p tsconfig.json --emitDeclarationOnly"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.0"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./style.css": "./dist/style.css"
|
||||
}
|
||||
}
|
||||
41
packages/core-v2/src/components/OriginGesture.vue
Normal file
41
packages/core-v2/src/components/OriginGesture.vue
Normal 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>
|
||||
73
packages/core-v2/src/components/OriginGestureSurface.vue
Normal file
73
packages/core-v2/src/components/OriginGestureSurface.vue
Normal 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>
|
||||
52
packages/core-v2/src/components/OriginNodeHost.vue
Normal file
52
packages/core-v2/src/components/OriginNodeHost.vue
Normal 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>
|
||||
38
packages/core-v2/src/components/OriginScene.vue
Normal file
38
packages/core-v2/src/components/OriginScene.vue
Normal 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 X→Y 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>
|
||||
248
packages/core-v2/src/gesture.test.ts
Normal file
248
packages/core-v2/src/gesture.test.ts
Normal 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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
582
packages/core-v2/src/gesture.ts
Normal file
582
packages/core-v2/src/gesture.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
38
packages/core-v2/src/index.ts
Normal file
38
packages/core-v2/src/index.ts
Normal 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";
|
||||
53
packages/core-v2/src/lifecycle.ts
Normal file
53
packages/core-v2/src/lifecycle.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
325
packages/core-v2/src/motion.ts
Normal file
325
packages/core-v2/src/motion.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
315
packages/core-v2/src/scene.test.ts
Normal file
315
packages/core-v2/src/scene.test.ts
Normal 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"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
701
packages/core-v2/src/scene.ts
Normal file
701
packages/core-v2/src/scene.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
37
packages/core-v2/src/style.css
Normal file
37
packages/core-v2/src/style.css
Normal 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;
|
||||
}
|
||||
}
|
||||
800
packages/core-v2/src/types.ts
Normal file
800
packages/core-v2/src/types.ts
Normal 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;
|
||||
}
|
||||
14
packages/core-v2/tsconfig.json
Normal file
14
packages/core-v2/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "../../tsconfig.app.json",
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"noEmit": false,
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"tsBuildInfoFile": "../../node_modules/.tmp/core-v2.tsbuildinfo"
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
20
packages/core-v2/vite.config.ts
Normal file
20
packages/core-v2/vite.config.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { resolve } from "node:path";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
build: {
|
||||
outDir: "dist",
|
||||
emptyOutDir: true,
|
||||
lib: {
|
||||
entry: resolve(__dirname, "src/index.ts"),
|
||||
formats: ["es"],
|
||||
fileName: "index",
|
||||
cssFileName: "style",
|
||||
},
|
||||
// V2 intentionally has no vue-router dependency. Vue is supplied by the
|
||||
// consuming application so every scene shares the application's renderer.
|
||||
rollupOptions: { external: ["vue"] },
|
||||
},
|
||||
});
|
||||
@@ -12,6 +12,7 @@
|
||||
"paths": {
|
||||
"@/*": ["./apps/demo/src/*"],
|
||||
"@native-vue-router/core": ["./packages/core/src/index.ts"],
|
||||
"@native-vue-router/core-v2": ["./packages/core-v2/src/index.ts"],
|
||||
"@native-vue-router/preset-native": [
|
||||
"./packages/preset-native/src/index.ts"
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user