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

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

View File

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

View File

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

View File

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

View File

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