Compare commits
2
Commits
8448e910ed
...
b6f782d8af
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6f782d8af | ||
|
|
46f030630b |
@@ -7,6 +7,7 @@ import {
|
||||
gesture,
|
||||
originView,
|
||||
useOrigin,
|
||||
type OriginView,
|
||||
} from "@native-vue-router/core-v2";
|
||||
import { useDemoInstance } from "../lab-state";
|
||||
import { unfocusPortal } from "../motions";
|
||||
@@ -86,7 +87,7 @@ const slideSets: Record<NestedSceneKind, readonly SlideCopy[]> = {
|
||||
],
|
||||
};
|
||||
|
||||
function nestedView(kind: NestedSceneKind, index: number) {
|
||||
function nestedView(kind: NestedSceneKind, index: number): OriginView {
|
||||
const slides = slideSets[kind];
|
||||
const copy = slides[index]!;
|
||||
return originView(
|
||||
@@ -98,7 +99,7 @@ function nestedView(kind: NestedSceneKind, index: number) {
|
||||
...copy,
|
||||
next:
|
||||
index + 1 < slides.length
|
||||
? () => nestedView(kind, index + 1)
|
||||
? (): OriginView => nestedView(kind, index + 1)
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
|
||||
+46
-21
@@ -145,7 +145,7 @@ recognizer to it.
|
||||
| Prop | Type | Default | Description |
|
||||
| --------- | ------------------------- | ------- | ---------------------------------- |
|
||||
| `as` | `string` | `"div"` | HTML tag used for the gesture host |
|
||||
| `gesture` | `OriginGestureDefinition` | — | Preferred builder definition |
|
||||
| `gesture` | `OriginGestureDefinition` | — | Builder definition |
|
||||
|
||||
Attributes, classes, and listeners not consumed as props are forwarded to the
|
||||
rendered host.
|
||||
@@ -156,8 +156,9 @@ rendered host.
|
||||
</OriginGesture>
|
||||
```
|
||||
|
||||
For compatibility, the component also accepts the legacy mutually exclusive
|
||||
set of `direction`, `edge`, `threshold`, and `action` props.
|
||||
The `gesture` prop is reactive. A new definition applies from the next
|
||||
pointer-down; a gesture already in progress keeps the definition it started
|
||||
with, so a parent re-render never interrupts a drag.
|
||||
|
||||
Use `useOriginGesture()` instead when an extra wrapper is undesirable.
|
||||
|
||||
@@ -190,9 +191,11 @@ binding, and derives the least-permissive shared `touch-action`:
|
||||
- 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.
|
||||
The `gestures` list is reactive, including its length and the derived
|
||||
`touch-action`. Recognizers are kept per list position, so a re-render with new
|
||||
definition objects never interrupts a gesture in progress; each change applies
|
||||
from the next pointer-down. The owning page remains the visible declaration
|
||||
point for every `.from`, `.to`, `.complete`, `.navigate`, and `.animate` choice.
|
||||
|
||||
## Nested scenes
|
||||
|
||||
@@ -457,7 +460,8 @@ const openProfile = gesture.to
|
||||
### `useOriginGesture(definition)`
|
||||
|
||||
Creates one primary-pointer, single-axis recognizer owned by the component that
|
||||
calls it.
|
||||
calls it. `definition` may also be a ref or getter; a changed definition
|
||||
applies from the next pointer-down.
|
||||
|
||||
```ts
|
||||
const open = useOriginGesture(
|
||||
@@ -477,6 +481,7 @@ interface OriginGestureBinding {
|
||||
readonly onPointermove: (event: PointerEvent) => void;
|
||||
readonly onPointerup: (event: PointerEvent) => void;
|
||||
readonly onPointercancel: () => void;
|
||||
readonly onLostpointercapture: (event: PointerEvent) => void;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -497,8 +502,14 @@ 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.
|
||||
A captured gesture is cancelled, never left mid-transition, when its pointer
|
||||
ends without a pointer-up: on `pointercancel`, on `lostpointercapture`, when a
|
||||
new primary pointer goes down, or when the component unmounts.
|
||||
|
||||
Failures are reported with `console.warn` and the gesture is abandoned: a
|
||||
`navigate()` factory that throws or rejects, or a scene that rejects the
|
||||
operation (see `OriginSceneError`). Returning `null` declines silently, as
|
||||
does losing a race to another gesture on the same origin.
|
||||
|
||||
## Choreographies and effects
|
||||
|
||||
@@ -575,10 +586,9 @@ When `finish()` does not explicitly override the decision, a target commits
|
||||
when either:
|
||||
|
||||
- `progress >= commitThreshold`, default `0.36`; or
|
||||
- release `velocity >= 0.3` progress units per second, at any progress.
|
||||
|
||||
`commitVelocity` is accepted on choreographies but is not currently consulted;
|
||||
the flick threshold is the fixed `0.3` above.
|
||||
- release `velocity >= commitVelocity`, default `0.3` progress units per
|
||||
second, at any progress. Velocity is normalized by the gesture host's size,
|
||||
so `1` is one full width (or height) per second.
|
||||
|
||||
The operation's intent becomes final before its spring settles.
|
||||
|
||||
@@ -662,8 +672,13 @@ can immediately begin its own outgoing operation, enabling X → Y → Z chains.
|
||||
|
||||
### `OriginOperationHandle`
|
||||
|
||||
`intent` reports the selected outcome (`undecided`, `commit`, or `cancel`). It
|
||||
changes synchronously when `finish()` or `cancel()` is called.
|
||||
`intent` changes synchronously when `finish()` or `cancel()` is called, before
|
||||
the settling spring runs.
|
||||
|
||||
Calling `update()` while an operation settles catches it: the spring stops and
|
||||
progress is under control again, for example to grab a transition that is
|
||||
still animating. A later `finish()` decides the outcome, and any earlier
|
||||
`finish()` promise resolves with that final outcome.
|
||||
|
||||
| Member | Description |
|
||||
| ----------------------------- | --------------------------------------------------- |
|
||||
@@ -672,6 +687,7 @@ changes synchronously when `finish()` or `cancel()` is called.
|
||||
| `targetKey` | Created or retained target node key |
|
||||
| `update(progress, velocity?)` | Update normalized interactive state |
|
||||
| `finish(options?)` | Decide, settle, and return whether target committed |
|
||||
| `intent` | Selected outcome: `undecided`, `commit`, `cancel` |
|
||||
| `cancel(options?)` | Force cancellation and remove the target branch |
|
||||
|
||||
`OriginFinishOptions`:
|
||||
@@ -737,12 +753,6 @@ Node-local action context:
|
||||
- `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 |
|
||||
@@ -786,6 +796,21 @@ implementation types. They are exported by the current barrel but marked
|
||||
|
||||
## Errors and constraints
|
||||
|
||||
Scene operations reject with `OriginSceneError`, whose `code` identifies the
|
||||
cause:
|
||||
|
||||
| Code | Cause |
|
||||
| ------------------- | --------------------------------------------------- |
|
||||
| `missing-node` | The node no longer exists |
|
||||
| `missing-origin` | The origin node does not exist |
|
||||
| `inert-origin` | The origin is parked or exposed |
|
||||
| `origin-busy` | The origin already has a live outgoing operation |
|
||||
| `invalid-action` | The action cannot be executed as written |
|
||||
| `no-previous-entry` | Back was requested without a retained entry |
|
||||
| `undecided-forward` | Back was requested through an unreleased forward |
|
||||
| `target-busy` | The back target is transitioning or still presented |
|
||||
| `outside-scene` | `useOrigin()` was called outside an `<OriginScene>` |
|
||||
|
||||
- `useOrigin()` and `useOriginGesture()` must run inside a component mounted by
|
||||
`OriginScene`.
|
||||
- An origin can own only one outgoing operation at a time.
|
||||
|
||||
@@ -115,11 +115,11 @@ export interface OriginChoreography {
|
||||
*/
|
||||
readonly commitThreshold?: number;
|
||||
/**
|
||||
* Reserved: normalized positive release velocity intended to commit a
|
||||
* deliberate flick.
|
||||
* Release velocity, in progress units per second, that commits a flick
|
||||
* regardless of progress. Velocity is normalized by the gesture host's
|
||||
* size, so `1` means one full host width (or height) per second.
|
||||
*
|
||||
* The scene currently commits any release whose velocity is at least `0.3`,
|
||||
* regardless of this value. See `FLICK_COMMIT_VELOCITY` in the scene module.
|
||||
* @defaultValue `0.3`
|
||||
*/
|
||||
readonly commitVelocity?: number;
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useOriginGesture } from "../gesture/recognizer";
|
||||
import type { OriginGestureProps } from "../gesture/definition";
|
||||
import { useOriginGesture } from "../gesture/recognizer";
|
||||
|
||||
defineOptions({ name: "OriginGesture", inheritAttrs: false });
|
||||
|
||||
@@ -11,17 +11,10 @@ const props = withDefaults(defineProps<OriginGestureProps>(), {
|
||||
/*
|
||||
* 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. Props are read once, at setup; the
|
||||
* legacy `action` prop is read lazily so it may change.
|
||||
* cannot accept an extra wrapper element. A new `gesture` prop applies from
|
||||
* the next pointer-down.
|
||||
*/
|
||||
const gesture = useOriginGesture(
|
||||
props.gesture ?? {
|
||||
direction: props.direction,
|
||||
edge: props.edge,
|
||||
threshold: props.threshold,
|
||||
action: (context) => props.action?.(context),
|
||||
},
|
||||
);
|
||||
const gesture = useOriginGesture(() => props.gesture);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -34,6 +27,7 @@ const gesture = useOriginGesture(
|
||||
@pointermove="gesture.onPointermove"
|
||||
@pointerup="gesture.onPointerup"
|
||||
@pointercancel="gesture.onPointercancel"
|
||||
@lostpointercapture="gesture.onLostpointercapture"
|
||||
>
|
||||
<slot />
|
||||
</component>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import type { OriginGestureSurfaceProps } from "../gesture/definition";
|
||||
import { touchActionFor, useOriginGesture } from "../gesture/recognizer";
|
||||
import { useOriginGestures } from "../gesture/recognizer";
|
||||
|
||||
defineOptions({ name: "OriginGestureSurface", inheritAttrs: false });
|
||||
|
||||
@@ -14,29 +13,7 @@ const props = withDefaults(defineProps<OriginGestureSurfaceProps>(), {
|
||||
* 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(
|
||||
() =>
|
||||
({
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
touchAction: touchActionFor(
|
||||
props.gestures.map(({ direction }) => direction),
|
||||
),
|
||||
}) as const,
|
||||
);
|
||||
|
||||
const onPointerdown = (event: PointerEvent) =>
|
||||
bindings.forEach((binding) => binding.onPointerdown(event));
|
||||
const onPointermove = (event: PointerEvent) =>
|
||||
bindings.forEach((binding) => binding.onPointermove(event));
|
||||
const onPointerup = (event: PointerEvent) =>
|
||||
bindings.forEach((binding) => binding.onPointerup(event));
|
||||
const onPointercancel = () =>
|
||||
bindings.forEach((binding) => binding.onPointercancel());
|
||||
const surface = useOriginGestures(() => props.gestures);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -44,11 +21,12 @@ const onPointercancel = () =>
|
||||
:is="as"
|
||||
v-bind="$attrs"
|
||||
class="nvo-gesture"
|
||||
:style="[$attrs.style, surfaceStyle]"
|
||||
@pointerdown="onPointerdown"
|
||||
@pointermove="onPointermove"
|
||||
@pointerup="onPointerup"
|
||||
@pointercancel="onPointercancel"
|
||||
:style="[$attrs.style, surface.style]"
|
||||
@pointerdown="surface.onPointerdown"
|
||||
@pointermove="surface.onPointermove"
|
||||
@pointerup="surface.onPointerup"
|
||||
@pointercancel="surface.onPointercancel"
|
||||
@lostpointercapture="surface.onLostpointercapture"
|
||||
>
|
||||
<slot />
|
||||
</component>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { CSSProperties } from "vue";
|
||||
import type { OriginChoreography } from "../choreography";
|
||||
import type { MaybeOriginAction, OriginNavigationIntent } from "../navigation";
|
||||
import type { OriginNavigationIntent } from "../navigation";
|
||||
import type { OriginContext } from "../scene/types";
|
||||
import type { OriginRect } from "../shared";
|
||||
|
||||
@@ -152,7 +152,7 @@ export type OriginGestureNavigationFactory = (
|
||||
* represented as an `anywhere` start rule.
|
||||
*/
|
||||
export interface OriginGestureDefinition {
|
||||
/** Discriminator used by the compatibility recognizer overload. */
|
||||
/** Identifies a definition produced by the {@link gesture} builder. */
|
||||
readonly kind: "origin-gesture-definition";
|
||||
/** Pointer-down eligibility policy. */
|
||||
readonly start: OriginGestureStart;
|
||||
@@ -260,47 +260,17 @@ export interface OriginGestureBuilder {
|
||||
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.
|
||||
* Attach all five 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. */
|
||||
/**
|
||||
* Required size and `touch-action` styles for the gesture host. Reading it
|
||||
* inside a render function tracks changes to the gesture definitions.
|
||||
*/
|
||||
readonly style: Readonly<CSSProperties>;
|
||||
/** Pointer-down handler that records a potentially eligible gesture. */
|
||||
readonly onPointerdown: (event: PointerEvent) => void;
|
||||
@@ -310,64 +280,31 @@ export interface OriginGestureBinding {
|
||||
readonly onPointerup: (event: PointerEvent) => void;
|
||||
/** Pointer-cancel handler that abandons any captured operation. */
|
||||
readonly onPointercancel: () => void;
|
||||
/**
|
||||
* Lost-capture handler. Cancels a captured gesture whose pointer capture
|
||||
* ends without a pointer-up, so the operation never stays stuck mid-way.
|
||||
*/
|
||||
readonly onLostpointercapture: (event: PointerEvent) => void;
|
||||
}
|
||||
|
||||
// Component props ----------------------------------------------------------
|
||||
|
||||
/** Props shared by both `OriginGesture` declaration styles. */
|
||||
export interface OriginGestureBaseProps {
|
||||
/** Public props accepted by the `OriginGesture` convenience component. */
|
||||
export interface OriginGestureProps {
|
||||
/**
|
||||
* 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. */
|
||||
/**
|
||||
* Immutable definition produced by the {@link gesture} builder. Replacing
|
||||
* it takes effect from the next pointer-down; a gesture in progress keeps
|
||||
* the definition it started with.
|
||||
*/
|
||||
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 {
|
||||
/**
|
||||
@@ -380,7 +317,9 @@ export interface OriginGestureSurfaceProps {
|
||||
* 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.
|
||||
* policy. Definitions should be created by the owning page component. The
|
||||
* list may change; a gesture in progress keeps the definition it started
|
||||
* with.
|
||||
*/
|
||||
gestures: readonly OriginGestureDefinition[];
|
||||
}
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import type { OriginAction, MaybeOriginAction } from "../navigation";
|
||||
import { computed, onScopeDispose, toValue, type MaybeRefOrGetter } from "vue";
|
||||
import type { OriginAction } from "../navigation";
|
||||
import { OriginSceneError } from "../scene/graph";
|
||||
import { useOrigin, type UseOrigin } from "../scene/scene";
|
||||
import type { OriginContext, OriginOperationHandle } from "../scene/types";
|
||||
import { clamp01, measureRect, type OriginRect } from "../shared";
|
||||
import { clamp01, measureRect, warn, type OriginRect } from "../shared";
|
||||
import type {
|
||||
OriginGestureBinding,
|
||||
OriginGestureCommitHandler,
|
||||
OriginGestureCompletionContext,
|
||||
OriginGestureCompletionPredicate,
|
||||
OriginGestureDefinition,
|
||||
OriginGestureCompletionContext,
|
||||
OriginGestureDirection,
|
||||
OriginGestureDistance,
|
||||
OriginGestureOptions,
|
||||
OriginGesturePoint,
|
||||
OriginGestureStart,
|
||||
} from "./definition";
|
||||
@@ -103,12 +102,16 @@ export const pointOf = (
|
||||
const nonNegative = (value: number) =>
|
||||
Number.isFinite(value) ? Math.max(0, value) : 0;
|
||||
|
||||
const PIXELS = /^\s*(-?(?:\d+\.?\d*|\.\d+))px\s*$/;
|
||||
const PERCENT = /^\s*(-?(?:\d+\.?\d*|\.\d+))%\s*$/;
|
||||
|
||||
/**
|
||||
* Resolve an arbitrary CSS length against a box with the gesture host's size.
|
||||
* Resolve a 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.
|
||||
* Numbers, pixel lengths, and percentages are computed directly. Anything
|
||||
* else (`rem`, viewport units, `calc()`, `clamp()`) is measured with a
|
||||
* short-lived off-screen box so the browser resolves it exactly. This runs
|
||||
* only during pointer-down for edge-constrained definitions.
|
||||
*/
|
||||
export const resolveCssDistance = (
|
||||
distance: OriginGestureDistance,
|
||||
@@ -117,6 +120,13 @@ export const resolveCssDistance = (
|
||||
bounds: OriginRect,
|
||||
): number => {
|
||||
if (typeof distance === "number") return nonNegative(distance);
|
||||
const pixels = PIXELS.exec(distance);
|
||||
if (pixels) return nonNegative(Number(pixels[1]));
|
||||
const percent = PERCENT.exec(distance);
|
||||
if (percent) {
|
||||
const size = axis === "horizontal" ? bounds.width : bounds.height;
|
||||
return nonNegative((Number(percent[1]) / 100) * size);
|
||||
}
|
||||
|
||||
const document = host.ownerDocument;
|
||||
if (!document.body) return Math.max(0, Number.parseFloat(distance) || 0);
|
||||
@@ -274,33 +284,13 @@ const DEFAULT_THRESHOLD = 8;
|
||||
/** Ratio by which directed movement must exceed cross-axis movement. */
|
||||
const DEFAULT_AXIS_DOMINANCE = 1.15;
|
||||
|
||||
/** Legacy `edge` start side for each movement direction. */
|
||||
const LEGACY_START_EDGE = {
|
||||
left: "right",
|
||||
right: "left",
|
||||
up: "bottom",
|
||||
down: "top",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* The single shape the recognizer executes. Builder definitions and legacy
|
||||
* options objects are both normalized into it.
|
||||
*/
|
||||
export interface RecognizerPolicy {
|
||||
readonly direction: OriginGestureDirection;
|
||||
readonly start: OriginGestureStart;
|
||||
readonly threshold: number;
|
||||
readonly axisDominance: number;
|
||||
readonly completion?: OriginGestureCompletionPredicate;
|
||||
readonly commitHandlers: readonly OriginGestureCommitHandler[];
|
||||
readonly action: (context: OriginContext) => MaybeOriginAction;
|
||||
}
|
||||
|
||||
/** The origin capabilities a recognizer needs. */
|
||||
export type RecognizerOrigin = Pick<UseOrigin, "context" | "begin">;
|
||||
|
||||
/** State of one admitted pointer sequence, from pointer-down to release. */
|
||||
interface PointerSession {
|
||||
/** Definition current at pointer-down; later changes do not affect it. */
|
||||
readonly definition: OriginGestureDefinition;
|
||||
readonly pointerId: number;
|
||||
readonly host: HTMLElement;
|
||||
readonly bounds: OriginRect;
|
||||
@@ -311,7 +301,7 @@ interface PointerSession {
|
||||
readonly tracker: DirectionalTracker;
|
||||
/** Movement passed the recognition threshold and the pointer is captured. */
|
||||
captured: boolean;
|
||||
/** The scene operation, once the action resolved and `begin()` was called. */
|
||||
/** The scene operation, once navigation resolved and `begin()` was called. */
|
||||
operation: Promise<OriginOperationHandle | null> | null;
|
||||
}
|
||||
|
||||
@@ -322,119 +312,58 @@ interface PointerSession {
|
||||
*
|
||||
* 1. **down**: an eligible primary pointer opens a session.
|
||||
* 2. **move**: once directed movement passes the threshold, the pointer is
|
||||
* captured and the policy's action is resolved; a non-null action begins
|
||||
* an operation, while a declined one closes the session. Every later move
|
||||
* captured and `.navigate()` is resolved; a non-null intent begins an
|
||||
* operation, while a declined one closes the session. Every later move
|
||||
* updates the operation's progress.
|
||||
* 3. **up**: the session closes and the operation finishes, using the
|
||||
* policy's completion predicate when present.
|
||||
* 4. **cancel**: the session closes and the operation is cancelled.
|
||||
* definition's `.complete()` predicate when present.
|
||||
* 4. **cancel**: pointer-cancel, lost pointer capture, a new primary
|
||||
* pointer-down, or disposal closes the session and cancels its operation.
|
||||
*
|
||||
* Action resolution and `begin()` are asynchronous. Each continuation checks
|
||||
* that its session is still current, so a stale resolution never creates or
|
||||
* Navigation and `begin()` are asynchronous. Each continuation checks that
|
||||
* its session is still current, so a stale resolution never creates or
|
||||
* updates an orphan operation.
|
||||
*/
|
||||
export class GestureRecognizer {
|
||||
private session: PointerSession | null = null;
|
||||
|
||||
constructor(
|
||||
readonly policy: RecognizerPolicy,
|
||||
/** Reads the current definition; it may change between sessions. */
|
||||
private readonly definitionOf: () => OriginGestureDefinition,
|
||||
private readonly origin: RecognizerOrigin,
|
||||
) {}
|
||||
|
||||
/** Create a recognizer for a builder definition or legacy options. */
|
||||
static create(
|
||||
definition: OriginGestureDefinition | OriginGestureOptions,
|
||||
origin: RecognizerOrigin,
|
||||
): GestureRecognizer {
|
||||
return new GestureRecognizer(
|
||||
GestureRecognizer.policyFor(definition),
|
||||
origin,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a builder definition or legacy options object into the single
|
||||
* policy shape the recognizer executes.
|
||||
*/
|
||||
static policyFor(
|
||||
definition: OriginGestureDefinition | OriginGestureOptions,
|
||||
): RecognizerPolicy {
|
||||
return "kind" in definition &&
|
||||
definition.kind === "origin-gesture-definition"
|
||||
? GestureRecognizer.policyFromDefinition(definition)
|
||||
: GestureRecognizer.policyFromOptions(definition as OriginGestureOptions);
|
||||
}
|
||||
|
||||
private static policyFromDefinition(
|
||||
definition: OriginGestureDefinition,
|
||||
): RecognizerPolicy {
|
||||
return {
|
||||
direction: definition.direction,
|
||||
start: definition.start,
|
||||
threshold: definition.recognition.threshold ?? DEFAULT_THRESHOLD,
|
||||
axisDominance:
|
||||
definition.recognition.axisDominance ?? DEFAULT_AXIS_DOMINANCE,
|
||||
completion: definition.completion,
|
||||
commitHandlers: definition.commitHandlers ?? [],
|
||||
// Pair the resolved navigation intent with the definition's animation.
|
||||
action: async (context) => {
|
||||
const navigation = await definition.navigation(context);
|
||||
if (!navigation) return navigation;
|
||||
const action: OriginAction = {
|
||||
...navigation,
|
||||
choreography: definition.choreography,
|
||||
};
|
||||
return action;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Legacy `edge` is measured from the side opposite the movement. */
|
||||
private static policyFromOptions(
|
||||
options: OriginGestureOptions,
|
||||
): RecognizerPolicy {
|
||||
return {
|
||||
direction: options.direction,
|
||||
start:
|
||||
options.edge === undefined
|
||||
? { kind: "anywhere" }
|
||||
: Object.freeze({
|
||||
kind: "edge",
|
||||
edge: LEGACY_START_EDGE[options.direction],
|
||||
distance: options.edge,
|
||||
}),
|
||||
threshold: options.threshold ?? DEFAULT_THRESHOLD,
|
||||
axisDominance: DEFAULT_AXIS_DOMINANCE,
|
||||
action: options.action,
|
||||
commitHandlers: [],
|
||||
};
|
||||
}
|
||||
|
||||
pointerDown(event: PointerEvent): void {
|
||||
if (!event.isPrimary) return;
|
||||
// Only one primary pointer exists at a time, so any session still open
|
||||
// lost its pointer-up (for example, released outside the window).
|
||||
void this.abandon();
|
||||
|
||||
const host = event.currentTarget;
|
||||
if (
|
||||
!event.isPrimary ||
|
||||
event.button !== 0 ||
|
||||
!(host instanceof HTMLElement) ||
|
||||
isIgnoredTarget(event.target)
|
||||
)
|
||||
return;
|
||||
|
||||
const definition = this.definitionOf();
|
||||
const bounds = measureRect(host);
|
||||
const origin = this.origin.context.value;
|
||||
if (!matchesStart(this.policy.start, event, host, origin, bounds)) return;
|
||||
if (!matchesStart(definition.start, event, host, origin, bounds)) return;
|
||||
|
||||
// The component containing this declaration is the operation's origin.
|
||||
event.stopPropagation();
|
||||
const horizontal = isHorizontal(this.policy.direction);
|
||||
const horizontal = isHorizontal(definition.direction);
|
||||
this.session = {
|
||||
definition,
|
||||
pointerId: event.pointerId,
|
||||
host,
|
||||
bounds,
|
||||
origin,
|
||||
start: pointOf(event, bounds),
|
||||
startTime: event.timeStamp,
|
||||
tracker: new DirectionalTracker(this.policy.direction, event, () =>
|
||||
tracker: new DirectionalTracker(definition.direction, event, () =>
|
||||
horizontal ? host.clientWidth : host.clientHeight,
|
||||
),
|
||||
captured: false,
|
||||
@@ -447,19 +376,26 @@ export class GestureRecognizer {
|
||||
if (!session) return;
|
||||
|
||||
if (!session.captured) {
|
||||
if (!this.recognizes(session, event)) return;
|
||||
if (!GestureRecognizer.recognizes(session, event)) return;
|
||||
session.captured = true;
|
||||
session.host.setPointerCapture?.(session.pointerId);
|
||||
event.preventDefault();
|
||||
session.tracker.sample(event);
|
||||
const action = await this.resolveAction();
|
||||
const action = await this.resolveAction(session.definition);
|
||||
// The pointer may have been released or cancelled meanwhile.
|
||||
if (this.session !== session) return;
|
||||
if (!action) {
|
||||
this.session = null;
|
||||
return;
|
||||
}
|
||||
session.operation = this.origin.begin(action).catch(() => null);
|
||||
session.operation = this.origin.begin(action).catch((error) => {
|
||||
// Another recognizer on this origin won the race; that is expected.
|
||||
if (!(
|
||||
error instanceof OriginSceneError && error.code === "origin-busy"
|
||||
))
|
||||
warn("A gesture could not begin its operation.", error);
|
||||
return null;
|
||||
});
|
||||
} else {
|
||||
event.preventDefault();
|
||||
session.tracker.sample(event);
|
||||
@@ -478,11 +414,11 @@ export class GestureRecognizer {
|
||||
this.session = null;
|
||||
if (!session.captured) return;
|
||||
|
||||
const { tracker } = session;
|
||||
const { tracker, definition } = session;
|
||||
tracker.sample(event, true);
|
||||
const context: OriginGestureCompletionContext = {
|
||||
origin: session.origin,
|
||||
direction: this.policy.direction,
|
||||
direction: definition.direction,
|
||||
progress: tracker.progress,
|
||||
velocity: tracker.velocity,
|
||||
distance: tracker.distance,
|
||||
@@ -494,7 +430,7 @@ export class GestureRecognizer {
|
||||
start: session.start,
|
||||
current: pointOf(event, session.bounds),
|
||||
};
|
||||
const commit = this.policy.completion?.(context);
|
||||
const commit = definition.completion?.(context);
|
||||
const { progress, velocity } = tracker;
|
||||
|
||||
const handle = session.operation ? await session.operation : null;
|
||||
@@ -504,24 +440,22 @@ export class GestureRecognizer {
|
||||
const settled = handle.finish(
|
||||
commit === undefined ? undefined : { commit },
|
||||
);
|
||||
if (handle.intent === "commit") this.notifyCommit(context);
|
||||
if (handle.intent === "commit")
|
||||
GestureRecognizer.notifyCommit(definition, context);
|
||||
await settled;
|
||||
}
|
||||
|
||||
/** Run commit handlers; a failing handler never interrupts the others. */
|
||||
private notifyCommit(context: OriginGestureCompletionContext) {
|
||||
for (const handler of this.policy.commitHandlers) {
|
||||
try {
|
||||
handler(context);
|
||||
} catch (error) {
|
||||
queueMicrotask(() => {
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
}
|
||||
/** Pointer capture ended without a pointer-up; cancel like pointer-cancel. */
|
||||
pointerCaptureLost(event: PointerEvent): Promise<void> {
|
||||
return this.sessionFor(event) ? this.abandon() : Promise.resolve();
|
||||
}
|
||||
|
||||
async pointerCancel(): Promise<void> {
|
||||
pointerCancel(): Promise<void> {
|
||||
return this.abandon();
|
||||
}
|
||||
|
||||
/** Close the current session, cancelling its operation if captured. */
|
||||
async abandon(): Promise<void> {
|
||||
const session = this.session;
|
||||
this.session = null;
|
||||
if (!session?.captured) return;
|
||||
@@ -535,30 +469,122 @@ export class GestureRecognizer {
|
||||
}
|
||||
|
||||
/** Directed movement passed the threshold and dominates the cross axis. */
|
||||
private recognizes(session: PointerSession, event: PointerEvent) {
|
||||
private static recognizes(session: PointerSession, event: PointerEvent) {
|
||||
const { recognition } = session.definition;
|
||||
const { distance, crossDistance } = session.tracker.displacement(event);
|
||||
return (
|
||||
distance >= this.policy.threshold &&
|
||||
distance >= crossDistance * this.policy.axisDominance
|
||||
distance >= (recognition.threshold ?? DEFAULT_THRESHOLD) &&
|
||||
distance >=
|
||||
crossDistance * (recognition.axisDominance ?? DEFAULT_AXIS_DOMINANCE)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the policy's action against the origin's current context. A
|
||||
* declined or failed action resolves to `null`.
|
||||
* Resolve the definition's navigation against the origin's current context
|
||||
* and pair it with its choreography. A declined navigation resolves to
|
||||
* `null`; a failing one also warns.
|
||||
*/
|
||||
private resolveAction(): Promise<OriginAction | null | undefined> {
|
||||
private resolveAction(
|
||||
definition: OriginGestureDefinition,
|
||||
): Promise<OriginAction | null> {
|
||||
const fail = (error: unknown) => {
|
||||
warn("A gesture's navigate() factory failed.", error);
|
||||
return null;
|
||||
};
|
||||
try {
|
||||
return Promise.resolve(
|
||||
this.policy.action(this.origin.context.value),
|
||||
).catch(() => null);
|
||||
} catch {
|
||||
return Promise.resolve(null);
|
||||
definition.navigation(this.origin.context.value),
|
||||
).then(
|
||||
(navigation) =>
|
||||
navigation
|
||||
? { ...navigation, choreography: definition.choreography }
|
||||
: null,
|
||||
fail,
|
||||
);
|
||||
} catch (error) {
|
||||
return Promise.resolve(fail(error));
|
||||
}
|
||||
}
|
||||
|
||||
/** Run commit handlers; a failing handler never interrupts the others. */
|
||||
private static notifyCommit(
|
||||
definition: OriginGestureDefinition,
|
||||
context: OriginGestureCompletionContext,
|
||||
) {
|
||||
for (const handler of definition.commitHandlers ?? []) {
|
||||
try {
|
||||
handler(context);
|
||||
} catch (error) {
|
||||
queueMicrotask(() => {
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Composable ----------------------------------------------------------------
|
||||
// Composables ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Bind any number of definitions to one host. Recognizers are kept per list
|
||||
* position, so re-rendering with new definition objects never interrupts a
|
||||
* gesture in progress; each change applies from the next pointer-down.
|
||||
*/
|
||||
export const useOriginGestures = (
|
||||
definitions: MaybeRefOrGetter<readonly OriginGestureDefinition[]>,
|
||||
): OriginGestureBinding => {
|
||||
const origin = useOrigin();
|
||||
const recognizers: GestureRecognizer[] = [];
|
||||
|
||||
/** Grow or shrink the recognizer list to match the current definitions. */
|
||||
const current = () => {
|
||||
const count = toValue(definitions).length;
|
||||
while (recognizers.length < count) {
|
||||
const index = recognizers.length;
|
||||
recognizers.push(
|
||||
new GestureRecognizer(() => toValue(definitions)[index]!, origin),
|
||||
);
|
||||
}
|
||||
for (const removed of recognizers.splice(count)) void removed.abandon();
|
||||
return recognizers;
|
||||
};
|
||||
|
||||
const style = computed(() => ({
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
// Preserve native scrolling on any axis no gesture claims.
|
||||
touchAction: touchActionFor(
|
||||
toValue(definitions).map(({ direction }) => direction),
|
||||
),
|
||||
}));
|
||||
|
||||
// A recognizer whose component unmounts mid-gesture cancels its operation.
|
||||
onScopeDispose(() => {
|
||||
for (const recognizer of recognizers.splice(0)) void recognizer.abandon();
|
||||
});
|
||||
|
||||
return {
|
||||
get style() {
|
||||
return style.value;
|
||||
},
|
||||
onPointerdown: (event) => {
|
||||
for (const recognizer of current()) recognizer.pointerDown(event);
|
||||
},
|
||||
onPointermove: (event) => {
|
||||
for (const recognizer of current()) void recognizer.pointerMove(event);
|
||||
},
|
||||
onPointerup: (event) => {
|
||||
for (const recognizer of current()) void recognizer.pointerUp(event);
|
||||
},
|
||||
onPointercancel: () => {
|
||||
for (const recognizer of current()) void recognizer.pointerCancel();
|
||||
},
|
||||
onLostpointercapture: (event) => {
|
||||
for (const recognizer of current())
|
||||
void recognizer.pointerCaptureLost(event);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Install a component-owned pointer recognizer.
|
||||
@@ -568,14 +594,14 @@ export class GestureRecognizer {
|
||||
*
|
||||
* 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.
|
||||
* whole element.
|
||||
*
|
||||
* 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.
|
||||
* @param definition - An immutable builder result, or a ref or getter
|
||||
* returning one. A changed definition applies from the next pointer-down.
|
||||
* @returns Pointer handlers and required host styles.
|
||||
* @throws If called outside a component rendered by `OriginScene`.
|
||||
*
|
||||
@@ -591,21 +617,7 @@ export class GestureRecognizer {
|
||||
* ```
|
||||
*/
|
||||
export function useOriginGesture(
|
||||
definition: OriginGestureDefinition | OriginGestureOptions,
|
||||
definition: MaybeRefOrGetter<OriginGestureDefinition>,
|
||||
): OriginGestureBinding {
|
||||
const recognizer = GestureRecognizer.create(definition, useOrigin());
|
||||
|
||||
return {
|
||||
style: {
|
||||
// Preserve native scrolling perpendicular to the declared gesture.
|
||||
touchAction: touchActionFor([recognizer.policy.direction]),
|
||||
// OriginGesture is commonly the root returned by a view component.
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
},
|
||||
onPointerdown: (event) => recognizer.pointerDown(event),
|
||||
onPointermove: (event) => void recognizer.pointerMove(event),
|
||||
onPointerup: (event) => void recognizer.pointerUp(event),
|
||||
onPointercancel: () => void recognizer.pointerCancel(),
|
||||
};
|
||||
return useOriginGestures(() => [toValue(definition)]);
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ export {
|
||||
|
||||
export type * from "./scene/types";
|
||||
export type { OriginNodeScope, UseOrigin } from "./scene/scene";
|
||||
export { OriginSceneError, type OriginSceneErrorCode } from "./scene/graph";
|
||||
export {
|
||||
createOriginScene,
|
||||
originNodeScopeKey,
|
||||
|
||||
@@ -147,16 +147,6 @@ export interface OriginAction {
|
||||
readonly history?: OriginHistoryMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Values accepted from gesture action factories.
|
||||
*
|
||||
* Returning `null` or `undefined` declines the recognized gesture. A promise
|
||||
* allows lazy target resolution; stale results are discarded after release or
|
||||
* cancellation.
|
||||
*/
|
||||
export type MaybeOriginAction =
|
||||
OriginAction | null | undefined | Promise<OriginAction | null | undefined>;
|
||||
|
||||
/** Options accepted by {@link originAction}. */
|
||||
export interface OriginActionOptions {
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,555 @@
|
||||
/**
|
||||
* Property tests: random operation sequences and pointer streams must keep
|
||||
* the scene's structural invariants after every step, and must never leave
|
||||
* an operation stuck once all pointers are released.
|
||||
*/
|
||||
import { createApp, defineComponent, h, nextTick, type Component } from "vue";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
above,
|
||||
back,
|
||||
createOriginScene,
|
||||
defineOriginChoreography,
|
||||
fade,
|
||||
forward,
|
||||
gesture,
|
||||
OriginGestureSurface,
|
||||
OriginScene,
|
||||
OriginSceneError,
|
||||
originView,
|
||||
replace,
|
||||
slideLeft,
|
||||
slideRight,
|
||||
under,
|
||||
type OriginAction,
|
||||
type OriginOperationHandle,
|
||||
} from ".";
|
||||
import type { SceneGraph } from "./scene/graph";
|
||||
import type { OriginScene as Scene } from "./scene/types";
|
||||
|
||||
const SEEDS = Number(process.env.PROPERTY_SEEDS ?? 60);
|
||||
|
||||
// Support -------------------------------------------------------------------
|
||||
|
||||
function rng(seed: number) {
|
||||
let a = seed >>> 0;
|
||||
const next = () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0;
|
||||
let t = a;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
return {
|
||||
next,
|
||||
int: (n: number) => Math.floor(next() * n),
|
||||
pick: <T>(items: readonly T[]) => items[Math.floor(next() * items.length)]!,
|
||||
chance: (p: number) => next() < p,
|
||||
};
|
||||
}
|
||||
|
||||
/** Deterministic `requestAnimationFrame` and clock. */
|
||||
function installFrames() {
|
||||
let now = 1000;
|
||||
let queue: FrameRequestCallback[] = [];
|
||||
window.requestAnimationFrame = ((cb: FrameRequestCallback) =>
|
||||
queue.push(cb)) as typeof window.requestAnimationFrame;
|
||||
performance.now = () => now;
|
||||
return {
|
||||
advance(ms = 16) {
|
||||
now += ms;
|
||||
const run = queue;
|
||||
queue = [];
|
||||
for (const cb of run) cb(now);
|
||||
},
|
||||
drain() {
|
||||
for (let i = 0; i < 1000 && queue.length; i += 1) this.advance();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function flush(rounds = 4) {
|
||||
for (let i = 0; i < rounds; i += 1)
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
const component = (name: string): Component =>
|
||||
defineComponent({ name, render: () => h("div", name) });
|
||||
|
||||
/** One-line summary of a scene, for failure traces. */
|
||||
const describeScene = (scene: Scene) => {
|
||||
const nodes = scene.nodes.value
|
||||
.map(
|
||||
(n) =>
|
||||
`${n.key}:${n.state}:in=${n.incomingOperationId ?? "-"}:prev=${n.previousNodeKey ?? "-"}`,
|
||||
)
|
||||
.join(" ");
|
||||
const operations = scene.operations.value
|
||||
.map(
|
||||
(o) =>
|
||||
`#${o.id} ${o.originKey}→${o.targetKey} ${o.history} ${o.phase}/${o.intent}`,
|
||||
)
|
||||
.join(", ");
|
||||
return ` roots=[${scene.roots.value}] nodes=[${nodes}] ops=[${operations}]`;
|
||||
};
|
||||
|
||||
const graphOf = (scene: Scene) =>
|
||||
(scene as unknown as { graph: SceneGraph }).graph;
|
||||
|
||||
/** Structural invariants that must hold after every step. */
|
||||
function checkInvariants(scene: Scene, label: string, rootCount: number) {
|
||||
const graph = graphOf(scene);
|
||||
const nodes = [...graph.nodes.values()];
|
||||
const roots = scene.roots.value;
|
||||
const fail = (message: string) => {
|
||||
throw new Error(`${label}\n${describeScene(scene)}\n${message}`);
|
||||
};
|
||||
|
||||
if (roots.length !== rootCount)
|
||||
fail(`expected ${rootCount} roots, found ${roots.length}`);
|
||||
if (new Set(roots).size !== roots.length) fail("duplicate roots");
|
||||
for (const key of roots) {
|
||||
const node = graph.node(key);
|
||||
if (!node) fail(`root ${key} does not exist`);
|
||||
else if (graph.incomingOf(node)) fail(`root ${key} has an incoming edge`);
|
||||
}
|
||||
|
||||
for (const node of nodes) {
|
||||
// Incoming edges point back at their node.
|
||||
if (node.incomingOperationId !== undefined) {
|
||||
const incoming = graph.operation(node.incomingOperationId);
|
||||
if (!incoming) fail(`${node.key} has a dangling incoming edge`);
|
||||
else if (incoming.targetKey !== node.key)
|
||||
fail(`${node.key}'s incoming edge targets ${incoming.targetKey}`);
|
||||
}
|
||||
|
||||
// History links exist and are acyclic.
|
||||
const seen = new Set([node.key]);
|
||||
for (let key = node.previousNodeKey; key;) {
|
||||
const previous = graph.node(key);
|
||||
if (!previous) fail(`${node.key} has a missing previous entry ${key}`);
|
||||
if (seen.has(key)) fail(`${node.key} has cyclic history`);
|
||||
seen.add(key);
|
||||
key = previous?.previousNodeKey;
|
||||
}
|
||||
|
||||
if (node.state === "parked") {
|
||||
if (roots.includes(node.key)) fail(`parked ${node.key} is a root`);
|
||||
if (node.incomingOperationId !== undefined)
|
||||
fail(`parked ${node.key} is positioned by an edge`);
|
||||
if (scene.isNodeInteractive(node.key)) fail(`parked ${node.key} is live`);
|
||||
if (scene.styleForNode(node.key).visibility !== "hidden")
|
||||
fail(`parked ${node.key} is visible`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Every presented node reaches a root through acyclic incoming edges.
|
||||
const visited = new Set<string>();
|
||||
let current = node;
|
||||
for (;;) {
|
||||
if (visited.has(current.key)) fail(`${node.key} is in a visual cycle`);
|
||||
visited.add(current.key);
|
||||
const incoming = graph.incomingOf(current);
|
||||
if (!incoming) break;
|
||||
const origin = graph.node(incoming.originKey);
|
||||
if (!origin) fail(`${current.key}'s edge comes from a missing origin`);
|
||||
current = origin!;
|
||||
}
|
||||
if (!roots.includes(current.key))
|
||||
fail(`${node.key} is detached from every root`);
|
||||
if (node.state === "exposed" && scene.isNodeInteractive(node.key))
|
||||
fail(`exposed ${node.key} is live`);
|
||||
}
|
||||
|
||||
// Every live operation joins existing nodes; one per origin.
|
||||
const origins = new Set<string>();
|
||||
for (const operation of scene.operations.value) {
|
||||
if (!graph.node(operation.originKey))
|
||||
fail(`operation ${operation.id} has a missing origin`);
|
||||
if (!graph.node(operation.targetKey))
|
||||
fail(`operation ${operation.id} has a missing target`);
|
||||
if (origins.has(operation.originKey))
|
||||
fail(`${operation.originKey} has two live operations`);
|
||||
origins.add(operation.originKey);
|
||||
}
|
||||
}
|
||||
|
||||
/** Additional invariants once every operation has finished. */
|
||||
function checkResting(scene: Scene, rootCount: number, label: string) {
|
||||
checkInvariants(scene, label, rootCount);
|
||||
const states = scene.nodes.value.map((node) => node.state);
|
||||
expect(scene.operations.value, `${label}: live operations`).toEqual([]);
|
||||
expect(states, `${label}: states`).not.toContain("transitioning");
|
||||
expect(
|
||||
states.filter((state) => state === "active"),
|
||||
`${label}: one active entry per root`,
|
||||
).toHaveLength(rootCount);
|
||||
}
|
||||
|
||||
// Scene operations ----------------------------------------------------------
|
||||
|
||||
const drawerOpen = defineOriginChoreography({
|
||||
name: "drawer-open",
|
||||
persistAtRest: true,
|
||||
effects: ({ progress }) => ({
|
||||
source: { transform: `translateX(${progress * 66}%)` },
|
||||
target: { transform: `translateX(${(progress - 1) * 66}%)` },
|
||||
}),
|
||||
});
|
||||
const drawerClose = defineOriginChoreography({
|
||||
name: "drawer-close",
|
||||
commitThreshold: 0.2,
|
||||
commitVelocity: 0.5,
|
||||
effects: ({ progress }) => ({
|
||||
source: { transform: `translateX(${-progress * 66}%)` },
|
||||
}),
|
||||
});
|
||||
const choreographies = [slideLeft, slideRight, fade, drawerOpen, drawerClose];
|
||||
const views = ["A", "B", "C", "D"].map((name) =>
|
||||
originView(component(name), undefined, { key: name.toLowerCase() }),
|
||||
);
|
||||
|
||||
function randomAction(r: ReturnType<typeof rng>): OriginAction {
|
||||
const choreography = r.pick(choreographies);
|
||||
const target = r.pick(views);
|
||||
const placement = r.pick(["above", "under", undefined] as const);
|
||||
switch (r.int(6)) {
|
||||
case 0:
|
||||
return forward(target, choreography, { placement });
|
||||
case 1:
|
||||
return replace(target, choreography, { placement });
|
||||
case 2:
|
||||
case 3:
|
||||
return back(choreography, { placement });
|
||||
case 4:
|
||||
return above(target, choreography, {
|
||||
history: r.pick(["push", "replace", "back"] as const),
|
||||
});
|
||||
default:
|
||||
return under(target, choreography);
|
||||
}
|
||||
}
|
||||
|
||||
async function runOperations(seed: number) {
|
||||
const r = rng(seed);
|
||||
const frames = installFrames();
|
||||
const initial = seed % 3 === 0 ? [views[0]!, views[1]!] : [views[0]!];
|
||||
const scene = createOriginScene({ initial });
|
||||
const handles: OriginOperationHandle[] = [];
|
||||
const unexpected: unknown[] = [];
|
||||
const expectSceneError = (error: unknown) => {
|
||||
if (!(error instanceof OriginSceneError)) unexpected.push(error);
|
||||
};
|
||||
|
||||
const trace: string[] = [];
|
||||
for (let step = 0; step < 80; step += 1) {
|
||||
const label = () =>
|
||||
`seed ${seed} step ${step}\n${trace.slice(-60).join("\n")}`;
|
||||
const roll = r.next();
|
||||
const liveIds = new Set(scene.operations.value.map(({ id }) => id));
|
||||
const live = handles.filter(({ id }) => liveIds.has(id));
|
||||
const pick = () =>
|
||||
live.length && r.chance(0.85) ? r.pick(live) : r.pick(handles);
|
||||
|
||||
if (roll < 0.3 || handles.length === 0) {
|
||||
const originKey = r.pick(scene.nodes.value).key;
|
||||
const action = randomAction(r);
|
||||
scene.begin(originKey, action).then(
|
||||
(handle) => {
|
||||
handles.push(handle);
|
||||
trace.push(
|
||||
` begin #${handle.id} ${originKey}→${handle.targetKey} ${action.history} ${action.choreography.name}`,
|
||||
);
|
||||
},
|
||||
(error) => {
|
||||
trace.push(
|
||||
` begin ${originKey} ${action.history} rejected: ${error.code}`,
|
||||
);
|
||||
expectSceneError(error);
|
||||
},
|
||||
);
|
||||
} else if (roll < 0.55) {
|
||||
const handle = pick();
|
||||
const p = r.next() * 1.4 - 0.2;
|
||||
const v = r.next() * 3 - 1;
|
||||
trace.push(`update #${handle.id} ${p.toFixed(2)} ${v.toFixed(2)}`);
|
||||
handle.update(p, v);
|
||||
} else if (roll < 0.75) {
|
||||
const handle = pick();
|
||||
const options = {
|
||||
commit: r.pick([true, false, undefined]),
|
||||
animate: r.chance(0.5),
|
||||
};
|
||||
trace.push(`finish #${handle.id} ${JSON.stringify(options)}`);
|
||||
void handle
|
||||
.finish(options)
|
||||
.then((v) => trace.push(` finished #${handle.id} → ${v}`));
|
||||
} else if (roll < 0.82) {
|
||||
const handle = pick();
|
||||
trace.push(`cancel #${handle.id}`);
|
||||
void handle
|
||||
.cancel({ animate: r.chance(0.5) })
|
||||
.then(() => trace.push(` cancelled #${handle.id}`));
|
||||
} else {
|
||||
trace.push("frames");
|
||||
for (let i = 1 + r.int(12); i > 0; i -= 1) frames.advance(4 + r.int(40));
|
||||
}
|
||||
if (!r.chance(0.15)) await flush();
|
||||
trace.push(describeScene(scene));
|
||||
checkInvariants(scene, label(), initial.length);
|
||||
}
|
||||
|
||||
// Release everything: every live operation finishes, then springs drain.
|
||||
await flush();
|
||||
for (const handle of handles) void handle.finish({ animate: r.chance(0.5) });
|
||||
await flush();
|
||||
frames.drain();
|
||||
await flush();
|
||||
checkResting(
|
||||
scene,
|
||||
initial.length,
|
||||
`seed ${seed} at rest\n${trace.slice(-50).join("\n")}`,
|
||||
);
|
||||
expect(unexpected, `seed ${seed}: non-scene errors`).toEqual([]);
|
||||
}
|
||||
|
||||
describe("scene properties", () => {
|
||||
for (let seed = 1; seed <= SEEDS; seed += 1)
|
||||
it(`random operations ${seed}`, () => runOperations(seed));
|
||||
});
|
||||
|
||||
// Gestures ------------------------------------------------------------------
|
||||
|
||||
const apps: Array<ReturnType<typeof createApp>> = [];
|
||||
afterEach(() => {
|
||||
for (const app of apps.splice(0)) app.unmount();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
const WIDTH = 300;
|
||||
const HEIGHT = 600;
|
||||
|
||||
function stubBounds(element: HTMLElement) {
|
||||
if (element.dataset.stubbed) return;
|
||||
element.dataset.stubbed = "1";
|
||||
Object.defineProperties(element, {
|
||||
clientWidth: { configurable: true, value: WIDTH },
|
||||
clientHeight: { configurable: true, value: HEIGHT },
|
||||
});
|
||||
element.getBoundingClientRect = () =>
|
||||
({ top: 0, left: 0, width: WIDTH, height: HEIGHT }) as DOMRect;
|
||||
}
|
||||
|
||||
/** Views whose gestures can grow, collapse, and race chains of operations. */
|
||||
function gestureWorld() {
|
||||
const viewsByName: Record<string, ReturnType<typeof originView>> = {};
|
||||
const slide = defineOriginChoreography({
|
||||
name: "slide",
|
||||
commitVelocity: 0.6,
|
||||
effects: ({ progress }) => ({
|
||||
source: { transform: `translateX(${-progress * 100}px)` },
|
||||
target: { transform: `translateX(${(1 - progress) * 100}px)` },
|
||||
}),
|
||||
});
|
||||
const surface = (name: string, gestures: () => unknown[]) =>
|
||||
defineComponent({
|
||||
name,
|
||||
setup: () => () =>
|
||||
h(
|
||||
OriginGestureSurface,
|
||||
{ gestures: gestures() as never, "data-gesture-host": "" },
|
||||
() => [h("input", { "data-input": "" }), name],
|
||||
),
|
||||
});
|
||||
|
||||
const A = surface("A", () => [
|
||||
gesture.to
|
||||
.left()
|
||||
.navigate(async () => {
|
||||
await Promise.resolve();
|
||||
return forward(viewsByName.B!);
|
||||
})
|
||||
.animate(slide),
|
||||
gesture.from
|
||||
.left(40)
|
||||
.to.right()
|
||||
.navigate((context) => (context.canGoBack ? back() : null))
|
||||
.animate(slideRight),
|
||||
gesture.from
|
||||
.when(({ point }) => point.localY < HEIGHT / 2)
|
||||
.to.down({ threshold: 12 })
|
||||
.complete(({ progress }) => progress > 0.2)
|
||||
.navigate(() => forward(viewsByName.C!, { placement: "under" }))
|
||||
.animate(drawerOpen),
|
||||
gesture.from
|
||||
.bottom("20%")
|
||||
.to.up()
|
||||
.navigate((context) =>
|
||||
context.canGoBack ? back() : replace(viewsByName.A!),
|
||||
)
|
||||
.animate(fade),
|
||||
]);
|
||||
const B = surface("B", () => [
|
||||
gesture.to
|
||||
.left({ threshold: 6 })
|
||||
.navigate(() => forward(viewsByName.C!))
|
||||
.animate(slide),
|
||||
gesture.to
|
||||
.right({ threshold: 6 })
|
||||
.navigate((context) => (context.canGoBack ? back() : null))
|
||||
.animate(slideRight),
|
||||
]);
|
||||
const C = surface("C", () => [
|
||||
gesture.to
|
||||
.up({ threshold: 4 })
|
||||
.navigate((context) =>
|
||||
context.canGoBack ? back() : forward(viewsByName.B!),
|
||||
)
|
||||
.animate(drawerClose),
|
||||
gesture.to
|
||||
.left()
|
||||
.navigate(() => {
|
||||
throw new Error("intentional navigate failure");
|
||||
})
|
||||
.animate(slide),
|
||||
]);
|
||||
viewsByName.A = originView(A, undefined, { key: "a" });
|
||||
viewsByName.B = originView(B, undefined, { key: "b" });
|
||||
viewsByName.C = originView(C, undefined, { key: "c" });
|
||||
|
||||
const scene = createOriginScene({ initial: viewsByName.A });
|
||||
const root = document.createElement("div");
|
||||
document.body.append(root);
|
||||
const app = createApp({ render: () => h(OriginScene, { scene }) });
|
||||
apps.push(app);
|
||||
app.mount(root);
|
||||
return { scene, root };
|
||||
}
|
||||
|
||||
function pointerEvent(type: string, init: PointerEventInit, timeStamp: number) {
|
||||
const event = new PointerEvent(type, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
...init,
|
||||
});
|
||||
Object.defineProperty(event, "timeStamp", { value: timeStamp });
|
||||
return event;
|
||||
}
|
||||
|
||||
async function runPointers(seed: number) {
|
||||
const r = rng(seed * 7919);
|
||||
const frames = installFrames();
|
||||
const warn = console.warn;
|
||||
console.warn = () => {};
|
||||
try {
|
||||
const { scene, root } = gestureWorld();
|
||||
await nextTick();
|
||||
let time = 100;
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
let pointerId = 1;
|
||||
let host: HTMLElement | null = null;
|
||||
let heading: "left" | "right" | "up" | "down" = "left";
|
||||
const trace: string[] = [];
|
||||
|
||||
const dispatch = (
|
||||
type: string,
|
||||
init: PointerEventInit,
|
||||
onInput = false,
|
||||
) => {
|
||||
trace.push(
|
||||
`${type} ${host?.closest("[data-origin-node]")?.getAttribute("data-origin-node")} ${JSON.stringify(init)} t=${time}${host?.isConnected ? "" : " (detached)"}`,
|
||||
);
|
||||
if (!host?.isConnected) return;
|
||||
const target = onInput
|
||||
? (host.querySelector("[data-input]") as HTMLElement)
|
||||
: host;
|
||||
target.dispatchEvent(pointerEvent(type, init, time));
|
||||
};
|
||||
|
||||
for (let step = 0; step < 120; step += 1) {
|
||||
time += r.int(40);
|
||||
const roll = r.next();
|
||||
if (roll < 0.15) {
|
||||
// A new primary pointer means the previous one ended: a browser
|
||||
// delivers its pointer-up to the captured host, or, if that was
|
||||
// lost, fires lostpointercapture there.
|
||||
dispatch(r.chance(0.8) ? "pointerup" : "lostpointercapture", {
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
pointerId,
|
||||
});
|
||||
if (r.chance(0.8)) await flush(3);
|
||||
const nodes = scene.nodes.value.filter((node) =>
|
||||
scene.isNodeInteractive(node.key),
|
||||
);
|
||||
const node = r.pick(nodes.length ? nodes : scene.nodes.value);
|
||||
host = root.querySelector(
|
||||
`[data-origin-node="${CSS.escape(node.key)}"] [data-gesture-host]`,
|
||||
);
|
||||
if (host) stubBounds(host);
|
||||
x = r.chance(0.3) ? r.int(50) : r.int(WIDTH);
|
||||
y = r.chance(0.3) ? HEIGHT - r.int(130) : r.int(HEIGHT);
|
||||
pointerId = r.chance(0.8) ? 1 : 2;
|
||||
heading = r.pick(["left", "right", "up", "down"] as const);
|
||||
dispatch(
|
||||
"pointerdown",
|
||||
{
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
pointerId,
|
||||
button: r.chance(0.95) ? 0 : 1,
|
||||
isPrimary: r.chance(0.95),
|
||||
},
|
||||
r.chance(0.08),
|
||||
);
|
||||
} else if (roll < 0.65) {
|
||||
const amount = 5 + r.int(55);
|
||||
if (heading === "left") x -= amount;
|
||||
if (heading === "right") x += amount;
|
||||
if (heading === "up") y -= amount;
|
||||
if (heading === "down") y += amount;
|
||||
dispatch("pointermove", { clientX: x, clientY: y, pointerId });
|
||||
} else if (roll < 0.8) {
|
||||
dispatch("pointerup", { clientX: x, clientY: y, pointerId });
|
||||
} else if (roll < 0.85) {
|
||||
dispatch(r.pick(["pointercancel", "lostpointercapture"]), {
|
||||
pointerId,
|
||||
});
|
||||
} else {
|
||||
trace.push("frames");
|
||||
for (let i = 1 + r.int(20); i > 0; i -= 1) frames.advance(16);
|
||||
}
|
||||
if (r.chance(0.7)) {
|
||||
frames.advance(16);
|
||||
frames.advance(16);
|
||||
}
|
||||
if (!r.chance(0.2)) await flush(3);
|
||||
trace.push(describeScene(scene));
|
||||
checkInvariants(
|
||||
scene,
|
||||
`seed ${seed} step ${step}\n${trace.slice(-40).join("\n")}`,
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
// Lift the pointer; nothing may remain stuck mid-transition.
|
||||
dispatch("pointerup", { clientX: x, clientY: y, pointerId });
|
||||
await flush();
|
||||
frames.drain();
|
||||
await flush();
|
||||
trace.push(describeScene(scene));
|
||||
checkResting(
|
||||
scene,
|
||||
1,
|
||||
`seed ${seed} after release\n${trace.slice(-40).join("\n")}`,
|
||||
);
|
||||
} finally {
|
||||
console.warn = warn;
|
||||
}
|
||||
}
|
||||
|
||||
describe("gesture properties", () => {
|
||||
for (let seed = 1; seed <= SEEDS; seed += 1)
|
||||
it(`random pointer stream ${seed}`, () => runPointers(seed));
|
||||
});
|
||||
@@ -0,0 +1,324 @@
|
||||
import {
|
||||
createApp,
|
||||
defineComponent,
|
||||
h,
|
||||
nextTick,
|
||||
shallowRef,
|
||||
type Component,
|
||||
} from "vue";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
back,
|
||||
createOriginScene,
|
||||
defineOriginChoreography,
|
||||
forward,
|
||||
gesture,
|
||||
OriginGesture,
|
||||
OriginGestureSurface,
|
||||
OriginScene,
|
||||
OriginSceneError,
|
||||
originView,
|
||||
type OriginGestureDefinition,
|
||||
} from ".";
|
||||
import { shouldCommit } from "./scene/graph";
|
||||
|
||||
const apps: Array<ReturnType<typeof createApp>> = [];
|
||||
afterEach(() => {
|
||||
for (const app of apps.splice(0)) app.unmount();
|
||||
document.body.innerHTML = "";
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const motion = defineOriginChoreography({
|
||||
name: "recognizer-test",
|
||||
effects: ({ progress }) => ({ target: { opacity: progress } }),
|
||||
});
|
||||
const Target: Component = defineComponent({ render: () => h("div", "T") });
|
||||
const toTarget = () => forward(originView(Target));
|
||||
|
||||
function pointer(
|
||||
type: string,
|
||||
clientX: number,
|
||||
timeStamp: number,
|
||||
pointerId = 1,
|
||||
) {
|
||||
const event = new PointerEvent(type, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button: 0,
|
||||
isPrimary: true,
|
||||
pointerId,
|
||||
clientX,
|
||||
clientY: 100,
|
||||
});
|
||||
Object.defineProperty(event, "timeStamp", { value: timeStamp });
|
||||
return event;
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
for (let i = 0; i < 4; i += 1) await new Promise((r) => setTimeout(r, 0));
|
||||
}
|
||||
|
||||
function stubBounds(host: HTMLElement) {
|
||||
Object.defineProperty(host, "clientWidth", { value: 200 });
|
||||
host.getBoundingClientRect = () =>
|
||||
({ top: 0, left: 0, width: 200, height: 400 }) as DOMRect;
|
||||
}
|
||||
|
||||
/** Render `root` as the scene's initial view; return the scene and host. */
|
||||
async function mount(root: Component) {
|
||||
vi.stubGlobal("matchMedia", () => ({ matches: true }));
|
||||
const scene = createOriginScene({ initial: originView(root) });
|
||||
const element = document.createElement("div");
|
||||
document.body.append(element);
|
||||
const app = createApp({ render: () => h(OriginScene, { scene }) });
|
||||
apps.push(app);
|
||||
app.mount(element);
|
||||
await nextTick();
|
||||
const host = element.querySelector("#host") as HTMLElement;
|
||||
stubBounds(host);
|
||||
return { scene, host, app };
|
||||
}
|
||||
|
||||
const gestureHost = (definition: () => OriginGestureDefinition) =>
|
||||
defineComponent({
|
||||
setup: () => () =>
|
||||
h(OriginGesture, { id: "host", gesture: definition() }, () => "host"),
|
||||
});
|
||||
|
||||
/** Press, then drag left far enough to capture and begin an operation. */
|
||||
async function dragLeft(host: HTMLElement, pointerId = 1, x = 180) {
|
||||
host.dispatchEvent(pointer("pointerdown", x, 0, pointerId));
|
||||
host.dispatchEvent(pointer("pointermove", x - 60, 100, pointerId));
|
||||
await flush();
|
||||
}
|
||||
|
||||
describe("stuck gestures", () => {
|
||||
const definition = gesture.to.left().navigate(toTarget).animate(motion);
|
||||
|
||||
it("cancels a captured gesture when pointer capture is lost", async () => {
|
||||
const { scene, host } = await mount(gestureHost(() => definition));
|
||||
await dragLeft(host);
|
||||
expect(scene.operations.value).toHaveLength(1);
|
||||
|
||||
host.dispatchEvent(pointer("lostpointercapture", 120, 120));
|
||||
await flush();
|
||||
expect(scene.operations.value).toHaveLength(0);
|
||||
expect(scene.nodes.value).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("ignores lostpointercapture after a normal release", async () => {
|
||||
const { scene, host } = await mount(gestureHost(() => definition));
|
||||
await dragLeft(host);
|
||||
host.dispatchEvent(pointer("pointermove", 20, 200));
|
||||
host.dispatchEvent(pointer("pointerup", 20, 200));
|
||||
host.dispatchEvent(pointer("lostpointercapture", 20, 200));
|
||||
await flush();
|
||||
// Committed, not cancelled.
|
||||
expect(scene.nodes.value.map((node) => node.state)).toEqual([
|
||||
"parked",
|
||||
"active",
|
||||
]);
|
||||
});
|
||||
|
||||
it("cancels a stale gesture when a new primary pointer goes down", async () => {
|
||||
const { scene, host } = await mount(gestureHost(() => definition));
|
||||
await dragLeft(host, 1);
|
||||
expect(scene.operations.value).toHaveLength(1);
|
||||
|
||||
// pointer 1's pointer-up was lost; a new touch starts.
|
||||
host.dispatchEvent(pointer("pointerdown", 150, 300, 2));
|
||||
await flush();
|
||||
expect(scene.operations.value).toHaveLength(0);
|
||||
expect(scene.nodes.value).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("cancels a captured gesture when its component unmounts", async () => {
|
||||
const show = shallowRef(true);
|
||||
const Root = defineComponent({
|
||||
setup: () => () =>
|
||||
h("div", [
|
||||
show.value
|
||||
? h(OriginGesture, { id: "host", gesture: definition }, () => "x")
|
||||
: null,
|
||||
]),
|
||||
});
|
||||
const { scene, host } = await mount(Root);
|
||||
await dragLeft(host);
|
||||
expect(scene.operations.value).toHaveLength(1);
|
||||
|
||||
show.value = false;
|
||||
await flush();
|
||||
expect(scene.operations.value).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("failure warnings", () => {
|
||||
it("warns when navigate() throws, without touching the scene", async () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const failing = gesture.to
|
||||
.left()
|
||||
.navigate(() => {
|
||||
throw new Error("no route");
|
||||
})
|
||||
.animate(motion);
|
||||
const { scene, host } = await mount(gestureHost(() => failing));
|
||||
await dragLeft(host);
|
||||
expect(scene.operations.value).toHaveLength(0);
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
"[native-vue-router] A gesture's navigate() factory failed.",
|
||||
new Error("no route"),
|
||||
);
|
||||
});
|
||||
|
||||
it("warns when the scene rejects the operation", async () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
// The initial view has no previous entry, so back() is rejected.
|
||||
const invalid = gesture.to
|
||||
.left()
|
||||
.navigate(() => back())
|
||||
.animate(motion);
|
||||
const { host } = await mount(gestureHost(() => invalid));
|
||||
await dragLeft(host);
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
const error = warn.mock.calls[0]![1] as OriginSceneError;
|
||||
expect(error).toBeInstanceOf(OriginSceneError);
|
||||
expect(error.code).toBe("no-previous-entry");
|
||||
});
|
||||
|
||||
it("stays quiet when two gestures race for the same origin", async () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const both = [
|
||||
gesture.to.left({ threshold: 4 }).navigate(toTarget).animate(motion),
|
||||
gesture.to.left({ threshold: 6 }).navigate(toTarget).animate(motion),
|
||||
];
|
||||
const Root = defineComponent({
|
||||
setup: () => () =>
|
||||
h(OriginGestureSurface, { id: "host", gestures: both }, () => "x"),
|
||||
});
|
||||
const { scene, host } = await mount(Root);
|
||||
await dragLeft(host);
|
||||
expect(scene.operations.value).toHaveLength(1);
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("declining with null does not warn", async () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const declined = gesture.to
|
||||
.left()
|
||||
.navigate(() => null)
|
||||
.animate(motion);
|
||||
const { host } = await mount(gestureHost(() => declined));
|
||||
await dragLeft(host);
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("reactive gesture props", () => {
|
||||
it("applies a new definition from the next pointer-down only", async () => {
|
||||
const leftward = gesture.to.left().navigate(toTarget).animate(motion);
|
||||
const rightward = gesture.to.right().navigate(toTarget).animate(motion);
|
||||
const current = shallowRef(leftward);
|
||||
const { scene, host } = await mount(gestureHost(() => current.value));
|
||||
expect(host.style.touchAction).toBe("pan-y");
|
||||
|
||||
// Swap mid-gesture: the drag in progress keeps the leftward definition.
|
||||
host.dispatchEvent(pointer("pointerdown", 180, 0));
|
||||
current.value = rightward;
|
||||
await nextTick();
|
||||
host.dispatchEvent(pointer("pointermove", 120, 100));
|
||||
await flush();
|
||||
expect(scene.operations.value).toHaveLength(1);
|
||||
host.dispatchEvent(pointer("pointerup", 120, 1000));
|
||||
await flush();
|
||||
|
||||
// The next touch uses the rightward definition.
|
||||
host.dispatchEvent(pointer("pointerdown", 20, 2000));
|
||||
host.dispatchEvent(pointer("pointermove", 80, 2100));
|
||||
await flush();
|
||||
expect(scene.operations.value).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps a gesture in progress across a parent re-render", async () => {
|
||||
const tick = shallowRef(0);
|
||||
// A new but equivalent definition object on every render.
|
||||
const { scene, host } = await mount(
|
||||
gestureHost(() => {
|
||||
void tick.value;
|
||||
return gesture.to.left().navigate(toTarget).animate(motion);
|
||||
}),
|
||||
);
|
||||
await dragLeft(host);
|
||||
tick.value += 1;
|
||||
await nextTick();
|
||||
host.dispatchEvent(pointer("pointermove", 20, 200));
|
||||
host.dispatchEvent(pointer("pointerup", 20, 200));
|
||||
await flush();
|
||||
expect(scene.nodes.value.map((node) => node.state)).toEqual([
|
||||
"parked",
|
||||
"active",
|
||||
]);
|
||||
});
|
||||
|
||||
it("tracks a surface's changing gesture list and touch-action", async () => {
|
||||
const list = shallowRef<OriginGestureDefinition[]>([
|
||||
gesture.to.left().navigate(toTarget).animate(motion),
|
||||
]);
|
||||
const Root = defineComponent({
|
||||
setup: () => () =>
|
||||
h(
|
||||
OriginGestureSurface,
|
||||
{ id: "host", gestures: list.value },
|
||||
() => "x",
|
||||
),
|
||||
});
|
||||
const { scene, host } = await mount(Root);
|
||||
expect(host.style.touchAction).toBe("pan-y");
|
||||
|
||||
list.value = [
|
||||
...list.value,
|
||||
gesture.to.down().navigate(toTarget).animate(motion),
|
||||
];
|
||||
await nextTick();
|
||||
expect(host.style.touchAction).toBe("none");
|
||||
|
||||
host.dispatchEvent(
|
||||
Object.assign(pointer("pointerdown", 100, 0), { clientY: 0 }),
|
||||
);
|
||||
const down = new PointerEvent("pointermove", {
|
||||
bubbles: true,
|
||||
isPrimary: true,
|
||||
pointerId: 1,
|
||||
clientX: 100,
|
||||
clientY: 80,
|
||||
});
|
||||
host.dispatchEvent(down);
|
||||
await flush();
|
||||
expect(scene.operations.value).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("commitVelocity", () => {
|
||||
const operation = (commitVelocity: number | undefined, velocity: number) =>
|
||||
({
|
||||
progress: 0.1,
|
||||
velocity,
|
||||
choreography: { effects: () => ({}), commitVelocity },
|
||||
}) as unknown as Parameters<typeof shouldCommit>[0];
|
||||
|
||||
it("defaults to 0.3", () => {
|
||||
expect(shouldCommit(operation(undefined, 0.29), {})).toBe(false);
|
||||
expect(shouldCommit(operation(undefined, 0.3), {})).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the choreography's value when supplied", () => {
|
||||
expect(shouldCommit(operation(0.6, 0.5), {})).toBe(false);
|
||||
expect(shouldCommit(operation(0.6, 0.6), {})).toBe(true);
|
||||
expect(shouldCommit(operation(0.1, 0.15), {})).toBe(true);
|
||||
});
|
||||
|
||||
it("still yields to an explicit decision", () => {
|
||||
expect(shouldCommit(operation(0.1, 5), { commit: false })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,43 @@ export interface SceneNodeRecord {
|
||||
incomingOperationId?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a scene operation was rejected.
|
||||
*
|
||||
* - `missing-node`: the node no longer exists.
|
||||
* - `missing-origin`: the origin node does not exist.
|
||||
* - `inert-origin`: the origin is parked or exposed and cannot originate.
|
||||
* - `origin-busy`: the origin already has a live outgoing operation.
|
||||
* - `invalid-action`: the action cannot be executed as written.
|
||||
* - `no-previous-entry`: back was requested without a retained entry.
|
||||
* - `undecided-forward`: back was requested through an unreleased forward.
|
||||
* - `target-busy`: the retained back target is transitioning, or still
|
||||
* positions the origin.
|
||||
* - `outside-scene`: `useOrigin()` was called outside an `<OriginScene>` view.
|
||||
*/
|
||||
export type OriginSceneErrorCode =
|
||||
| "missing-node"
|
||||
| "missing-origin"
|
||||
| "inert-origin"
|
||||
| "origin-busy"
|
||||
| "invalid-action"
|
||||
| "no-previous-entry"
|
||||
| "undecided-forward"
|
||||
| "target-busy"
|
||||
| "outside-scene";
|
||||
|
||||
/** Error thrown or rejected by scene operations, with a stable `code`. */
|
||||
export class OriginSceneError extends Error {
|
||||
override readonly name = "OriginSceneError";
|
||||
|
||||
constructor(
|
||||
readonly code: OriginSceneErrorCode,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Progress at which a release commits when the choreography does not supply
|
||||
* its own `commitThreshold`.
|
||||
@@ -32,9 +69,9 @@ export const DEFAULT_COMMIT_THRESHOLD = 0.36;
|
||||
|
||||
/**
|
||||
* Release velocity (progress units per second) that commits a flick at any
|
||||
* progress. Choreographies' `commitVelocity` is not currently consulted.
|
||||
* progress when the choreography does not supply its own `commitVelocity`.
|
||||
*/
|
||||
export const FLICK_COMMIT_VELOCITY = 0.3;
|
||||
export const DEFAULT_COMMIT_VELOCITY = 0.3;
|
||||
|
||||
/** Decide a release outcome from explicit options or progress/velocity. */
|
||||
export const shouldCommit = (
|
||||
@@ -42,11 +79,10 @@ export const shouldCommit = (
|
||||
options: OriginFinishOptions,
|
||||
): boolean => {
|
||||
if (options.commit !== undefined) return options.commit;
|
||||
const threshold =
|
||||
operation.choreography.commitThreshold ?? DEFAULT_COMMIT_THRESHOLD;
|
||||
const { commitThreshold, commitVelocity } = operation.choreography;
|
||||
return (
|
||||
operation.progress >= threshold ||
|
||||
(operation.progress >= 0 && operation.velocity >= FLICK_COMMIT_VELOCITY)
|
||||
operation.progress >= (commitThreshold ?? DEFAULT_COMMIT_THRESHOLD) ||
|
||||
operation.velocity >= (commitVelocity ?? DEFAULT_COMMIT_VELOCITY)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -76,6 +112,14 @@ export class SceneGraph {
|
||||
* that back operation's id. Cancelling the back operation restores the edge.
|
||||
*/
|
||||
private readonly suspendedEdges = new Map<number, MutableOriginOperation>();
|
||||
/**
|
||||
* Operations indexed by origin key, in insertion order. Every addition and
|
||||
* removal goes through {@link attach} and {@link detach} to keep it in sync
|
||||
* with `operations`.
|
||||
*/
|
||||
private readonly outgoing = shallowReactive(
|
||||
new Map<string, readonly MutableOriginOperation[]>(),
|
||||
);
|
||||
private nodeSequence = 0;
|
||||
private operationSequence = 0;
|
||||
|
||||
@@ -117,10 +161,9 @@ export class SceneGraph {
|
||||
|
||||
/** The unfinished operation originated by a node, if any. */
|
||||
liveOutgoingOf(nodeKey: string): MutableOriginOperation | undefined {
|
||||
for (const operation of this.operations.values())
|
||||
if (operation.originKey === nodeKey && operation.phase !== "finished")
|
||||
return operation;
|
||||
return undefined;
|
||||
return this.outgoing
|
||||
.get(nodeKey)
|
||||
?.find((operation) => operation.phase !== "finished");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -128,9 +171,7 @@ export class SceneGraph {
|
||||
* {@link liveOutgoingOf}, this includes a finished, connected resting edge.
|
||||
*/
|
||||
presentedOutgoingOf(nodeKey: string): MutableOriginOperation | undefined {
|
||||
for (const operation of this.operations.values())
|
||||
if (operation.originKey === nodeKey) return operation;
|
||||
return undefined;
|
||||
return this.outgoing.get(nodeKey)?.[0];
|
||||
}
|
||||
|
||||
/** Retained entries preceding `node`, oldest first. */
|
||||
@@ -151,7 +192,10 @@ export class SceneGraph {
|
||||
contextFor(nodeKey: string): OriginContext {
|
||||
const node = this.nodes.get(nodeKey);
|
||||
if (!node)
|
||||
throw new Error(`Origin scene node "${nodeKey}" no longer exists.`);
|
||||
throw new OriginSceneError(
|
||||
"missing-node",
|
||||
`Origin scene node "${nodeKey}" no longer exists.`,
|
||||
);
|
||||
const history = this.historyOf(node).map((entry) => entry.view);
|
||||
return {
|
||||
nodeKey,
|
||||
@@ -194,7 +238,8 @@ export class SceneGraph {
|
||||
const origin = this.assertCanOriginate(originKey);
|
||||
const history = action.history ?? "push";
|
||||
if (action.choreography.persistAtRest && history !== "push")
|
||||
throw new Error(
|
||||
throw new OriginSceneError(
|
||||
"invalid-action",
|
||||
"persistAtRest choreographies require retained-history push navigation.",
|
||||
);
|
||||
|
||||
@@ -257,7 +302,7 @@ export class SceneGraph {
|
||||
|
||||
this.transferPosition(origin, target);
|
||||
operation.phase = "finished";
|
||||
this.operations.delete(operation.id);
|
||||
this.detach(operation);
|
||||
this.suspendedEdges.delete(operation.id);
|
||||
|
||||
if (operation.history === "push") {
|
||||
@@ -265,7 +310,11 @@ export class SceneGraph {
|
||||
origin.state = "parked";
|
||||
} else {
|
||||
// Back pops the current entry; replace discards it in favor of the new
|
||||
// target. Both preserve every earlier retained instance.
|
||||
// target. Both preserve every earlier retained instance, and any entry
|
||||
// still retained above the origin now sits on the target instead.
|
||||
for (const node of this.nodes.values())
|
||||
if (node.previousNodeKey === origin.key && node !== target)
|
||||
node.previousNodeKey = target.key;
|
||||
this.removeNode(origin.key);
|
||||
}
|
||||
|
||||
@@ -276,33 +325,37 @@ export class SceneGraph {
|
||||
/**
|
||||
* Finalize a cancelled operation, restoring the origin.
|
||||
*
|
||||
* A cancelled back re-parks its retained target, or re-attaches the resting
|
||||
* edge it suspended. A cancelled push/replace removes its new target branch.
|
||||
* Everything the edge created or now positions is undone. Entries from the
|
||||
* origin's retained history (a back target, or a replacement of one) return
|
||||
* to history; anything newer is discarded. A cancelled back that suspended
|
||||
* a connected resting edge reconnects it instead.
|
||||
*/
|
||||
cancel(id: number): void {
|
||||
const operation = this.operations.get(id);
|
||||
if (!operation) return;
|
||||
const origin = this.nodes.get(operation.originKey);
|
||||
const target = this.nodes.get(operation.entryTargetKey);
|
||||
const entry = this.nodes.get(operation.entryTargetKey);
|
||||
const current = this.nodes.get(operation.targetKey);
|
||||
const suspendedEdge = this.suspendedEdges.get(id);
|
||||
const retained = origin ? this.retainedKeys(origin) : new Set<string>();
|
||||
operation.phase = "finished";
|
||||
this.operations.delete(id);
|
||||
this.detach(operation);
|
||||
this.suspendedEdges.delete(id);
|
||||
|
||||
if (operation.history === "back") {
|
||||
if (suspendedEdge && origin && target) {
|
||||
if (suspendedEdge && origin && entry && current === entry) {
|
||||
// Put the retained page back into its former position, then reconnect
|
||||
// the resting page→drawer edge. Both nodes keep their VNodes.
|
||||
this.transferPosition(origin, target);
|
||||
this.operations.set(suspendedEdge.id, suspendedEdge);
|
||||
this.discardOutgoing(entry.key, new Set(retained));
|
||||
this.transferPosition(origin, entry);
|
||||
this.attach(suspendedEdge);
|
||||
origin.incomingOperationId = suspendedEdge.id;
|
||||
target.state = "exposed";
|
||||
} else if (target) {
|
||||
target.incomingOperationId = undefined;
|
||||
target.state = "parked";
|
||||
}
|
||||
entry.state = "exposed";
|
||||
} else {
|
||||
this.removeBranch(operation.entryTargetKey);
|
||||
for (const node of new Set([entry, current])) {
|
||||
if (!node || node === origin) continue;
|
||||
if (retained.has(node.key)) this.returnToHistory(node, retained);
|
||||
else this.removeBranch(node.key, new Set(retained));
|
||||
}
|
||||
}
|
||||
|
||||
if (origin) this.activate(origin);
|
||||
@@ -313,13 +366,18 @@ export class SceneGraph {
|
||||
private assertCanOriginate(originKey: string): SceneNodeRecord {
|
||||
const origin = this.nodes.get(originKey);
|
||||
if (!origin)
|
||||
throw new Error(`Cannot animate from missing origin "${originKey}".`);
|
||||
throw new OriginSceneError(
|
||||
"missing-origin",
|
||||
`Cannot animate from missing origin "${originKey}".`,
|
||||
);
|
||||
if (origin.state === "parked" || origin.state === "exposed")
|
||||
throw new Error(
|
||||
throw new OriginSceneError(
|
||||
"inert-origin",
|
||||
`Cannot animate from ${origin.state} origin "${originKey}".`,
|
||||
);
|
||||
if (this.liveOutgoingOf(originKey))
|
||||
throw new Error(
|
||||
throw new OriginSceneError(
|
||||
"origin-busy",
|
||||
`Origin "${originKey}" already has an outgoing operation. ` +
|
||||
"A descendant target may start its own operation instead.",
|
||||
);
|
||||
@@ -341,12 +399,14 @@ export class SceneGraph {
|
||||
} {
|
||||
const previousKey = origin.previousNodeKey;
|
||||
if (!previousKey)
|
||||
throw new Error(
|
||||
throw new OriginSceneError(
|
||||
"no-previous-entry",
|
||||
`Cannot go back from origin "${origin.key}" without a previous entry.`,
|
||||
);
|
||||
const target = this.nodes.get(previousKey);
|
||||
if (!target)
|
||||
throw new Error(
|
||||
throw new OriginSceneError(
|
||||
"no-previous-entry",
|
||||
`Cannot go back to missing retained entry "${previousKey}".`,
|
||||
);
|
||||
|
||||
@@ -354,7 +414,8 @@ export class SceneGraph {
|
||||
const incoming = this.incomingOf(origin);
|
||||
if (incoming?.originKey === target.key) {
|
||||
if (incoming.intent !== "commit")
|
||||
throw new Error(
|
||||
throw new OriginSceneError(
|
||||
"undecided-forward",
|
||||
"Cannot go back through an undecided forward operation.",
|
||||
);
|
||||
if (incoming.phase !== "finished") this.commit(incoming.id);
|
||||
@@ -362,19 +423,32 @@ export class SceneGraph {
|
||||
const resting = this.operations.get(incoming.id);
|
||||
if (resting?.phase === "finished" && resting.choreography.persistAtRest) {
|
||||
// Re-root A→page→drawer as A→drawer before adding drawer→page.
|
||||
this.operations.delete(resting.id);
|
||||
this.detach(resting);
|
||||
this.transferPosition(target, origin);
|
||||
target.incomingOperationId = undefined;
|
||||
suspendedEdge = resting;
|
||||
}
|
||||
}
|
||||
|
||||
// A target still positioning the origin (through edges other than the
|
||||
// one collapsed above) would close a visual cycle.
|
||||
if (
|
||||
this.incomingChain(origin).some(
|
||||
(operation) => operation.originKey === target.key,
|
||||
)
|
||||
)
|
||||
throw new OriginSceneError(
|
||||
"target-busy",
|
||||
`Retained target "${target.key}" still positions origin "${origin.key}".`,
|
||||
);
|
||||
if (this.liveOutgoingOf(target.key))
|
||||
throw new Error(
|
||||
throw new OriginSceneError(
|
||||
"target-busy",
|
||||
`Retained target "${target.key}" already has an outgoing operation.`,
|
||||
);
|
||||
if (target.incomingOperationId)
|
||||
throw new Error(
|
||||
throw new OriginSceneError(
|
||||
"target-busy",
|
||||
`Retained target "${target.key}" is already transitioning.`,
|
||||
);
|
||||
return { target, suspendedEdge };
|
||||
@@ -387,7 +461,8 @@ export class SceneGraph {
|
||||
history: Exclude<OriginHistoryMode, "back">,
|
||||
): SceneNodeRecord {
|
||||
if (!action.target)
|
||||
throw new Error(
|
||||
throw new OriginSceneError(
|
||||
"invalid-action",
|
||||
`A "${history}" operation requires a target view recipe.`,
|
||||
);
|
||||
// A replacement skips the origin in the retained chain. The origin stays
|
||||
@@ -397,7 +472,16 @@ export class SceneGraph {
|
||||
return this.createNode(action.target, previousKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a node presentable again. A node still holding a connected resting
|
||||
* edge (an open drawer, say) stays exposed rather than becoming active.
|
||||
*/
|
||||
private activate(node: SceneNodeRecord): void {
|
||||
const resting = this.presentedOutgoingOf(node.key);
|
||||
if (resting?.phase === "finished") {
|
||||
node.state = "exposed";
|
||||
return;
|
||||
}
|
||||
node.state = "active";
|
||||
this.refreshPresentedState(node);
|
||||
}
|
||||
@@ -461,18 +545,66 @@ export class SceneGraph {
|
||||
intent: "undecided",
|
||||
originRect: init.originRect,
|
||||
});
|
||||
this.operations.set(operation.id, operation);
|
||||
this.attach(operation);
|
||||
return operation;
|
||||
}
|
||||
|
||||
private attach(operation: MutableOriginOperation): void {
|
||||
this.operations.set(operation.id, operation);
|
||||
const siblings = this.outgoing.get(operation.originKey) ?? [];
|
||||
this.outgoing.set(operation.originKey, [...siblings, operation]);
|
||||
}
|
||||
|
||||
private detach(operation: MutableOriginOperation): void {
|
||||
this.operations.delete(operation.id);
|
||||
const siblings = (this.outgoing.get(operation.originKey) ?? []).filter(
|
||||
(sibling) => sibling.id !== operation.id,
|
||||
);
|
||||
if (siblings.length) this.outgoing.set(operation.originKey, siblings);
|
||||
else this.outgoing.delete(operation.originKey);
|
||||
}
|
||||
|
||||
private removeNode(nodeKey: string): void {
|
||||
this.nodes.delete(nodeKey);
|
||||
this.roots.value = this.roots.value.filter((key) => key !== nodeKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the live operations a node started, because the node is leaving
|
||||
* the visible graph: nodes they created (push/replace targets, including
|
||||
* replacements whose history skips this node) are removed, and retained
|
||||
* entries they revealed (back targets) are restored as if that back
|
||||
* operation were cancelled.
|
||||
*/
|
||||
/** Park a retained entry again, discarding what it started meanwhile. */
|
||||
private returnToHistory(node: SceneNodeRecord, retained: Set<string>): void {
|
||||
this.discardOutgoing(node.key, new Set(retained));
|
||||
node.incomingOperationId = undefined;
|
||||
node.state = "parked";
|
||||
}
|
||||
|
||||
/** A node's key plus its retained history, which a cancel must keep. */
|
||||
private retainedKeys(node: SceneNodeRecord): Set<string> {
|
||||
return new Set([node.key, ...this.historyOf(node).map(({ key }) => key)]);
|
||||
}
|
||||
|
||||
private discardOutgoing(nodeKey: string, visited = new Set<string>()): void {
|
||||
for (const operation of [...(this.outgoing.get(nodeKey) ?? [])]) {
|
||||
if (operation.phase === "finished") continue;
|
||||
if (operation.history === "back") {
|
||||
this.cancel(operation.id);
|
||||
} else {
|
||||
this.detach(operation);
|
||||
this.removeBranch(operation.entryTargetKey, visited);
|
||||
this.removeBranch(operation.targetKey, visited);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a node, every node whose retained history passes through it, and
|
||||
* every operation touching any of them. Earlier entries are untouched.
|
||||
* Operations the node itself started are discarded first.
|
||||
*/
|
||||
private removeBranch(nodeKey: string, visited = new Set<string>()): void {
|
||||
if (visited.has(nodeKey)) return;
|
||||
@@ -481,13 +613,14 @@ export class SceneGraph {
|
||||
for (const node of [...this.nodes.values()])
|
||||
if (node.previousNodeKey === nodeKey)
|
||||
this.removeBranch(node.key, visited);
|
||||
this.discardOutgoing(nodeKey, visited);
|
||||
for (const operation of [...this.operations.values()])
|
||||
if (
|
||||
operation.originKey === nodeKey ||
|
||||
operation.targetKey === nodeKey ||
|
||||
operation.entryTargetKey === nodeKey
|
||||
)
|
||||
this.operations.delete(operation.id);
|
||||
this.detach(operation);
|
||||
this.removeNode(nodeKey);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
inject,
|
||||
markRaw,
|
||||
nextTick,
|
||||
shallowRef,
|
||||
type ComputedRef,
|
||||
type CSSProperties,
|
||||
type InjectionKey,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
type OriginEffect,
|
||||
type OriginEffectSet,
|
||||
type OriginOperationIntent,
|
||||
type OriginOperationPhase,
|
||||
} from "../choreography";
|
||||
import type { OriginAction, OriginView } from "../navigation";
|
||||
import {
|
||||
@@ -23,7 +25,12 @@ import {
|
||||
windowRect,
|
||||
type OriginRect,
|
||||
} from "../shared";
|
||||
import { SceneGraph, shouldCommit, type SceneNodeRecord } from "./graph";
|
||||
import {
|
||||
OriginSceneError,
|
||||
SceneGraph,
|
||||
shouldCommit,
|
||||
type SceneNodeRecord,
|
||||
} from "./graph";
|
||||
import type {
|
||||
CreateOriginSceneOptions,
|
||||
MutableOriginOperation,
|
||||
@@ -66,24 +73,13 @@ const Z_INDEX_PER_LAYER = 10_000;
|
||||
* Ancestors' source effects are intentionally excluded: only the initiating
|
||||
* component receives the source side of an operation.
|
||||
*
|
||||
* @param viewport - Measures the scene bounds supplied to choreographies.
|
||||
* @param effectsOf - The current effect set of an operation's choreography.
|
||||
*/
|
||||
export const nodeEffectLayers = (
|
||||
graph: SceneGraph,
|
||||
node: SceneNodeRecord,
|
||||
viewport: () => OriginRect,
|
||||
effectsOf: (operation: MutableOriginOperation) => OriginEffectSet,
|
||||
): OriginEffect[] => {
|
||||
const effectsOf = (operation: MutableOriginOperation): OriginEffectSet =>
|
||||
operation.choreography.effects({
|
||||
progress: operation.progress,
|
||||
velocity: operation.velocity,
|
||||
phase: operation.phase,
|
||||
intent: operation.intent,
|
||||
originRect: operation.originRect,
|
||||
targetRect: operation.targetRect,
|
||||
viewport: viewport(),
|
||||
});
|
||||
|
||||
const chain = graph.incomingChain(node);
|
||||
// Evaluate choreographies node-first, then apply them outermost-first.
|
||||
const effectSets = chain.map(effectsOf);
|
||||
@@ -111,16 +107,13 @@ export const nodeEffectLayers = (
|
||||
export const composeNodeStyle = (
|
||||
graph: SceneGraph,
|
||||
nodeKey: string,
|
||||
viewport: () => OriginRect,
|
||||
effectsOf: (operation: MutableOriginOperation) => OriginEffectSet,
|
||||
): CSSProperties => {
|
||||
const node = graph.node(nodeKey);
|
||||
if (!node) return {};
|
||||
|
||||
// Measure the scene at most once, and only when an edge needs it.
|
||||
let measured: OriginRect | undefined;
|
||||
const lazyViewport = () => (measured ??= viewport());
|
||||
const { style, layer } = composeEffects(
|
||||
nodeEffectLayers(graph, node, lazyViewport),
|
||||
nodeEffectLayers(graph, node, effectsOf),
|
||||
NODE_BASE_STYLE,
|
||||
);
|
||||
style.zIndex = Z_INDEX_BASE + layer * Z_INDEX_PER_LAYER + node.sequence;
|
||||
@@ -278,6 +271,28 @@ class Scene implements OriginScene {
|
||||
/** DOM registered by the renderer, used only for measurement. */
|
||||
private readonly hosts = new Map<string, HTMLElement>();
|
||||
private container: HTMLElement | null = null;
|
||||
private resizeObserver: ResizeObserver | null = null;
|
||||
/**
|
||||
* Scene-container bounds, or the browser window before mounting. Cached and
|
||||
* refreshed on resize and whenever an operation begins, so compositing
|
||||
* never forces layout.
|
||||
*/
|
||||
private readonly viewport = shallowRef<OriginRect>(windowRect());
|
||||
/**
|
||||
* One memoized effect set per operation. It recomputes only when that
|
||||
* operation's progress, phase, intent, rects, or the viewport change, so
|
||||
* nodes sharing an edge do not evaluate its choreography again.
|
||||
*/
|
||||
/** The latest release (finish call) of each operation. */
|
||||
private readonly releases = new WeakMap<MutableOriginOperation, object>();
|
||||
private readonly outcomes = new WeakMap<
|
||||
MutableOriginOperation,
|
||||
{ promise: Promise<boolean>; resolve(committed: boolean): void }
|
||||
>();
|
||||
private readonly effectSets = new WeakMap<
|
||||
MutableOriginOperation,
|
||||
ComputedRef<OriginEffectSet>
|
||||
>();
|
||||
|
||||
constructor(options: CreateOriginSceneOptions) {
|
||||
// Never proxy the scene: its methods rely on raw internal state.
|
||||
@@ -307,6 +322,7 @@ class Scene implements OriginScene {
|
||||
originKey: string,
|
||||
action: OriginAction,
|
||||
): Promise<OriginOperationHandle> {
|
||||
this.refreshViewport();
|
||||
const operation = this.graph.begin(
|
||||
originKey,
|
||||
action,
|
||||
@@ -330,25 +346,73 @@ class Scene implements OriginScene {
|
||||
/**
|
||||
* Fix an operation's outcome, settle it, and rewrite the graph.
|
||||
*
|
||||
* Calling `update()` while an operation settles "catches" it: the spring
|
||||
* stops and progress is under control again. A later `finish()` then
|
||||
* decides the outcome; every earlier, interrupted `finish()` resolves with
|
||||
* that final outcome instead of applying its own.
|
||||
*
|
||||
* @internal Called through {@link OperationHandle}.
|
||||
*/
|
||||
async finish(id: number, options: OriginFinishOptions = {}) {
|
||||
finish(id: number, options: OriginFinishOptions = {}): Promise<boolean> {
|
||||
const operation = this.graph.operation(id);
|
||||
if (!operation) return false;
|
||||
if (operation.phase === "finished") return operation.intent === "commit";
|
||||
if (!operation) return Promise.resolve(false);
|
||||
if (operation.phase === "finished")
|
||||
return Promise.resolve(operation.intent === "commit");
|
||||
const release = {};
|
||||
this.releases.set(operation, release);
|
||||
return this.release(operation, release, options);
|
||||
}
|
||||
|
||||
private async release(
|
||||
operation: MutableOriginOperation,
|
||||
release: object,
|
||||
options: OriginFinishOptions,
|
||||
): Promise<boolean> {
|
||||
// The outcome is fixed at release, so the target can originate another
|
||||
// operation while this one is only visually settling.
|
||||
const commit = shouldCommit(operation, options);
|
||||
operation.intent = commit ? "commit" : "cancel";
|
||||
operation.phase = "settling";
|
||||
await this.settle(id, commit ? 1 : 0, options.animate ?? true);
|
||||
const current = () =>
|
||||
this.releases.get(operation) === release &&
|
||||
operation.phase === "settling";
|
||||
await this.settle(
|
||||
operation,
|
||||
commit ? 1 : 0,
|
||||
options.animate ?? true,
|
||||
current,
|
||||
);
|
||||
|
||||
const outcome = this.outcomeOf(operation);
|
||||
// A later operation may already have finalized this edge.
|
||||
if (!this.graph.operation(id)) return commit;
|
||||
if (commit) return this.graph.commit(id);
|
||||
this.graph.cancel(id);
|
||||
return false;
|
||||
const finished = (operation.phase as OriginOperationPhase) === "finished";
|
||||
if (!this.graph.operation(operation.id) || finished) {
|
||||
const committed = this.graph.operation(operation.id)
|
||||
? operation.intent === "commit"
|
||||
: commit;
|
||||
outcome.resolve(committed);
|
||||
return committed;
|
||||
}
|
||||
// Caught by update() or superseded by a later finish().
|
||||
if (!current()) return outcome.promise;
|
||||
|
||||
const committed = commit
|
||||
? this.graph.commit(operation.id)
|
||||
: (this.graph.cancel(operation.id), false);
|
||||
outcome.resolve(committed);
|
||||
return committed;
|
||||
}
|
||||
|
||||
/** Resolves with an operation's final outcome, whichever release decides. */
|
||||
private outcomeOf(operation: MutableOriginOperation) {
|
||||
let outcome = this.outcomes.get(operation);
|
||||
if (!outcome) {
|
||||
let resolve!: (committed: boolean) => void;
|
||||
const promise = new Promise<boolean>((done) => (resolve = done));
|
||||
outcome = { promise, resolve };
|
||||
this.outcomes.set(operation, outcome);
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
|
||||
registerElement(nodeKey: string, element: HTMLElement | null): void {
|
||||
@@ -357,21 +421,37 @@ class Scene implements OriginScene {
|
||||
}
|
||||
|
||||
registerContainer(element: HTMLElement | null): void {
|
||||
if (element === this.container) return;
|
||||
this.resizeObserver?.disconnect();
|
||||
this.resizeObserver = null;
|
||||
this.container = element;
|
||||
if (!element) return;
|
||||
this.refreshViewport();
|
||||
if (typeof ResizeObserver !== "undefined") {
|
||||
this.resizeObserver = new ResizeObserver(() => this.refreshViewport());
|
||||
this.resizeObserver.observe(element);
|
||||
}
|
||||
}
|
||||
|
||||
styleForNode(nodeKey: string): CSSProperties {
|
||||
return composeNodeStyle(this.graph, nodeKey, () => this.viewport());
|
||||
return composeNodeStyle(this.graph, nodeKey, this.effectsOf);
|
||||
}
|
||||
|
||||
isNodeInteractive(nodeKey: string): boolean {
|
||||
return this.graph.isInteractive(nodeKey);
|
||||
}
|
||||
|
||||
/** Move an operation to 0 or 1, animated unless motion is disabled. */
|
||||
private async settle(id: number, target: 0 | 1, animate: boolean) {
|
||||
const operation = this.graph.operation(id);
|
||||
if (!operation) return;
|
||||
/**
|
||||
* Move an operation to 0 or 1, animated unless motion is disabled. The
|
||||
* spring stops early once `current()` reports the release was caught or
|
||||
* superseded.
|
||||
*/
|
||||
private async settle(
|
||||
operation: MutableOriginOperation,
|
||||
target: 0 | 1,
|
||||
animate: boolean,
|
||||
current: () => boolean,
|
||||
) {
|
||||
if (!animate || prefersReducedMotion()) {
|
||||
operation.progress = target;
|
||||
operation.velocity = 0;
|
||||
@@ -379,10 +459,9 @@ class Scene implements OriginScene {
|
||||
}
|
||||
const from = { position: operation.progress, velocity: operation.velocity };
|
||||
await runSpring(from, target, (progress, velocity) => {
|
||||
const live = this.graph.operation(id);
|
||||
if (!live) return false;
|
||||
live.progress = progress;
|
||||
live.velocity = velocity;
|
||||
if (!this.graph.operation(operation.id) || !current()) return false;
|
||||
operation.progress = progress;
|
||||
operation.velocity = velocity;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -392,10 +471,39 @@ class Scene implements OriginScene {
|
||||
return host ? measureRect(host) : undefined;
|
||||
}
|
||||
|
||||
/** Scene-container bounds, or the browser window before mounting. */
|
||||
private viewport(): OriginRect {
|
||||
return this.container ? measureRect(this.container) : windowRect();
|
||||
/** Re-measure the container, replacing the cached rect only on change. */
|
||||
private refreshViewport(): void {
|
||||
const next = this.container ? measureRect(this.container) : windowRect();
|
||||
const current = this.viewport.value;
|
||||
if (
|
||||
next.top !== current.top ||
|
||||
next.left !== current.left ||
|
||||
next.width !== current.width ||
|
||||
next.height !== current.height
|
||||
)
|
||||
this.viewport.value = next;
|
||||
}
|
||||
|
||||
private readonly effectsOf = (
|
||||
operation: MutableOriginOperation,
|
||||
): OriginEffectSet => {
|
||||
let effects = this.effectSets.get(operation);
|
||||
if (!effects) {
|
||||
effects = computed(() =>
|
||||
operation.choreography.effects({
|
||||
progress: operation.progress,
|
||||
velocity: operation.velocity,
|
||||
phase: operation.phase,
|
||||
intent: operation.intent,
|
||||
originRect: operation.originRect,
|
||||
targetRect: operation.targetRect,
|
||||
viewport: this.viewport.value,
|
||||
}),
|
||||
);
|
||||
this.effectSets.set(operation, effects);
|
||||
}
|
||||
return effects.value;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -491,7 +599,10 @@ export const originNodeScopeKey: InjectionKey<OriginNodeScope> =
|
||||
export function useOrigin(): UseOrigin {
|
||||
const scope = inject(originNodeScopeKey);
|
||||
if (!scope)
|
||||
throw new Error("useOrigin() must be called inside an <OriginScene> view.");
|
||||
throw new OriginSceneError(
|
||||
"outside-scene",
|
||||
"useOrigin() must be called inside an <OriginScene> view.",
|
||||
);
|
||||
|
||||
const { scene, nodeKey } = scope;
|
||||
const context = computed(() => scene.contextFor(nodeKey));
|
||||
|
||||
@@ -50,3 +50,9 @@ export function prefersReducedMotion(): boolean {
|
||||
Boolean(window.matchMedia?.("(prefers-reduced-motion: reduce)").matches)
|
||||
);
|
||||
}
|
||||
|
||||
/** Report a recoverable problem to developers without interrupting the UI. */
|
||||
export function warn(message: string, error?: unknown): void {
|
||||
if (typeof console !== "undefined")
|
||||
console.warn(`[native-vue-router] ${message}`, ...(error ? [error] : []));
|
||||
}
|
||||
|
||||
@@ -1,27 +1,20 @@
|
||||
# Differential verification
|
||||
# Benchmarks
|
||||
|
||||
Checks a core-v2 implementation against an earlier one (by default `main`)
|
||||
by driving both with identical randomized inputs and requiring identical
|
||||
observable results after every step.
|
||||
Compares core-v2's performance against an earlier implementation (by default
|
||||
`main`).
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
packages/core-v2/verify/setup-baseline.sh main # extract into verify/.baseline/
|
||||
npx vitest run --config packages/core-v2/verify/vitest.config.ts
|
||||
SEEDS=50 npx vitest run --config packages/core-v2/verify/vitest.config.ts # quicker
|
||||
ONLY=rewrite npx vitest bench --run --config packages/core-v2/verify/vitest.config.ts
|
||||
ONLY=baseline npx vitest bench --run --config packages/core-v2/verify/vitest.config.ts
|
||||
ONLY=rewrite npx vitest bench --run --config packages/core-v2/verify/vitest.config.ts
|
||||
```
|
||||
|
||||
- `scene.diff.ts` runs random `begin`/`update`/`finish`/`cancel`/animation
|
||||
frame sequences against both scenes and compares nodes, operations, roots,
|
||||
every node's composited style, interactivity, contexts, and every promise
|
||||
result. It also compares action-helper output and the runtime export list.
|
||||
- `gesture.diff.ts` mounts both packages in Vue apps whose views install the
|
||||
same gestures (builder definitions, legacy options, `OriginGesture`, and
|
||||
`OriginGestureSurface`), dispatches identical random pointer streams, and
|
||||
compares scene state and every navigation, start, and completion callback.
|
||||
- `scene.bench.ts` measures compositing, push/back cycles, and rendered
|
||||
frames. Run each implementation in its own process with `ONLY=`; running
|
||||
both in one process biases whichever runs second.
|
||||
`scene.bench.ts` measures compositing, push/back cycles, and rendered frames.
|
||||
Run each implementation in its own process with `ONLY=`; running both in one
|
||||
process biases whichever runs second.
|
||||
|
||||
Behavioural verification lives with the unit tests: `src/property.test.ts`
|
||||
drives random operation sequences and pointer streams and checks the scene's
|
||||
structural invariants after every step. Set `PROPERTY_SEEDS` to run more.
|
||||
|
||||
@@ -1,381 +0,0 @@
|
||||
/**
|
||||
* Differential test for gesture recognition: the baseline and rewritten
|
||||
* packages each render a scene whose views install equivalent gestures
|
||||
* (builder, legacy options, OriginGesture, OriginGestureSurface). Identical
|
||||
* random pointer streams must yield identical scenes and identical callbacks.
|
||||
*/
|
||||
import { createApp, defineComponent, h, nextTick, type App } from "vue";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import * as next from "../src";
|
||||
import * as base from "./.baseline";
|
||||
import { flush, installFrames, rng, snapshot } from "./harness";
|
||||
|
||||
type Impl = typeof next;
|
||||
const impls = [base as unknown as Impl, next] as const;
|
||||
const apps: App[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const app of apps.splice(0)) app.unmount();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
const WIDTH = 300;
|
||||
const HEIGHT = 600;
|
||||
|
||||
function stubBounds(element: HTMLElement) {
|
||||
if ((element as { stubbed?: boolean }).stubbed) return;
|
||||
(element as { stubbed?: boolean }).stubbed = true;
|
||||
Object.defineProperties(element, {
|
||||
clientWidth: { configurable: true, value: WIDTH },
|
||||
clientHeight: { configurable: true, value: HEIGHT },
|
||||
});
|
||||
element.getBoundingClientRect = () =>
|
||||
({
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: WIDTH,
|
||||
bottom: HEIGHT,
|
||||
width: WIDTH,
|
||||
height: HEIGHT,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
}) as DOMRect;
|
||||
}
|
||||
|
||||
function pointOf(point: next.OriginGesturePoint) {
|
||||
return [point.clientX, point.clientY, point.localX, point.localY];
|
||||
}
|
||||
|
||||
/** Build one implementation's views, gestures, and callback recorder. */
|
||||
function buildWorld(M: Impl, seed: number) {
|
||||
const record: unknown[] = [];
|
||||
const r = rng(seed);
|
||||
const tick = async (count: number) => {
|
||||
for (let i = 0; i < count; i += 1) await Promise.resolve();
|
||||
};
|
||||
const views: Record<string, next.OriginView> = {};
|
||||
|
||||
const slide = M.defineOriginChoreography({
|
||||
name: "slide",
|
||||
effects: ({ progress }) => ({
|
||||
source: { transform: `translateX(${-progress * 100}px)` },
|
||||
target: { transform: `translateX(${(1 - progress) * 100}px)` },
|
||||
}),
|
||||
});
|
||||
const drawer = M.defineOriginChoreography({
|
||||
name: "drawer",
|
||||
persistAtRest: true,
|
||||
effects: ({ progress }) => ({
|
||||
source: { transform: `translateY(${progress * 50}%)` },
|
||||
target: { opacity: progress },
|
||||
}),
|
||||
});
|
||||
|
||||
// Asynchronous navigation delays are fixed per world so both match.
|
||||
const delays = Array.from({ length: 64 }, () => r.int(4));
|
||||
let delayIndex = 0;
|
||||
const delay = () => tick(delays[delayIndex++ % delays.length]!);
|
||||
|
||||
const surfaceGestures = [
|
||||
M.gesture.to
|
||||
.left()
|
||||
.navigate(async (context) => {
|
||||
record.push(["left", context.nodeKey]);
|
||||
await delay();
|
||||
return M.forward(views.B!);
|
||||
})
|
||||
.animate(slide),
|
||||
M.gesture.from
|
||||
.left(40)
|
||||
.to.right()
|
||||
.navigate((context) => {
|
||||
record.push(["right", context.nodeKey, context.canGoBack]);
|
||||
return context.canGoBack ? M.back() : null;
|
||||
})
|
||||
.animate(M.slideRight),
|
||||
M.gesture.from
|
||||
.when(({ point, bounds, origin }) => {
|
||||
record.push(["when", origin.nodeKey, pointOf(point), bounds.width]);
|
||||
return point.localY < HEIGHT / 2;
|
||||
})
|
||||
.to.down({ threshold: 12, axisDominance: 1.5 })
|
||||
.complete((context) => {
|
||||
record.push([
|
||||
"complete",
|
||||
context.origin.nodeKey,
|
||||
context.direction,
|
||||
context.progress,
|
||||
context.velocity,
|
||||
context.distance,
|
||||
context.crossDistance,
|
||||
context.duration,
|
||||
pointOf(context.start),
|
||||
pointOf(context.current),
|
||||
context.bounds,
|
||||
]);
|
||||
return context.progress > 0.2;
|
||||
})
|
||||
.navigate(() => M.forward(views.C!, { placement: "under" }))
|
||||
.animate(drawer),
|
||||
M.gesture.from
|
||||
.bottom(120)
|
||||
.to.up()
|
||||
.navigate(async (context) => {
|
||||
await delay();
|
||||
if (delays[delayIndex % delays.length] === 0) throw new Error("nav");
|
||||
return context.canGoBack ? M.back() : M.replace(views.A!);
|
||||
})
|
||||
.animate(M.fade),
|
||||
];
|
||||
|
||||
const A = defineComponent({
|
||||
name: "A",
|
||||
setup: () => () =>
|
||||
h(
|
||||
M.OriginGestureSurface,
|
||||
{ gestures: surfaceGestures, "data-gesture-host": "" },
|
||||
() => [h("input", { "data-input": "" }), "A"],
|
||||
),
|
||||
});
|
||||
|
||||
const bGestures = [
|
||||
M.gesture.to
|
||||
.left({ threshold: 6 })
|
||||
.navigate((context) => {
|
||||
record.push(["b-left", context.nodeKey, context.history.length]);
|
||||
return M.forward(views.C!);
|
||||
})
|
||||
.animate(slide),
|
||||
M.gesture.to
|
||||
.right({ threshold: 6 })
|
||||
.navigate((context) => (context.canGoBack ? M.back() : null))
|
||||
.animate(M.slideRight),
|
||||
];
|
||||
const B = defineComponent({
|
||||
name: "B",
|
||||
setup() {
|
||||
// Bind each recognizer directly instead of through a component.
|
||||
const bindings = bGestures.map((definition) =>
|
||||
M.useOriginGesture(definition),
|
||||
);
|
||||
const each =
|
||||
(name: "onPointerdown" | "onPointermove" | "onPointerup") =>
|
||||
(event: PointerEvent) =>
|
||||
bindings.forEach((binding) => binding[name](event));
|
||||
return () =>
|
||||
h(
|
||||
"div",
|
||||
{
|
||||
"data-gesture-host": "",
|
||||
style: bindings[0]!.style,
|
||||
onPointerdown: each("onPointerdown"),
|
||||
onPointermove: each("onPointermove"),
|
||||
onPointerup: each("onPointerup"),
|
||||
onPointercancel: () =>
|
||||
bindings.forEach((binding) => binding.onPointercancel()),
|
||||
},
|
||||
[h("input", { "data-input": "" }), "B"],
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const C = defineComponent({
|
||||
name: "C",
|
||||
setup: () => () =>
|
||||
h(
|
||||
M.OriginGesture,
|
||||
{
|
||||
"data-gesture-host": "",
|
||||
direction: "up",
|
||||
edge: 200,
|
||||
threshold: 4,
|
||||
action: (context: next.OriginContext) => {
|
||||
record.push(["legacy", context.nodeKey]);
|
||||
return context.canGoBack
|
||||
? M.back(slide)
|
||||
: M.forward(views.B!, slide);
|
||||
},
|
||||
},
|
||||
() => [h("input", { "data-input": "" }), "C"],
|
||||
),
|
||||
});
|
||||
|
||||
views.A = M.originView(A, undefined, { key: "a" });
|
||||
views.B = M.originView(B, undefined, { key: "b" });
|
||||
views.C = M.originView(C, undefined, { key: "c" });
|
||||
|
||||
const scene = M.createOriginScene({ initial: views.A });
|
||||
const root = document.createElement("div");
|
||||
document.body.append(root);
|
||||
const app = createApp({ render: () => h(M.OriginScene, { scene }) });
|
||||
apps.push(app);
|
||||
app.mount(root);
|
||||
return { scene, root, record };
|
||||
}
|
||||
|
||||
function hostFor(root: HTMLElement, nodeKey: string) {
|
||||
const host = root.querySelector(
|
||||
`[data-origin-node="${CSS.escape(nodeKey)}"] [data-gesture-host]`,
|
||||
) as HTMLElement | null;
|
||||
if (host) stubBounds(host);
|
||||
return host;
|
||||
}
|
||||
|
||||
function pointerEvent(
|
||||
type: string,
|
||||
init: PointerEventInit,
|
||||
timeStamp: number,
|
||||
): PointerEvent {
|
||||
const event = new PointerEvent(type, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
...init,
|
||||
});
|
||||
Object.defineProperty(event, "timeStamp", { value: timeStamp });
|
||||
return event;
|
||||
}
|
||||
|
||||
const stats = {
|
||||
maxNodes: 0,
|
||||
maxOps: 0,
|
||||
opSteps: 0,
|
||||
records: {} as Record<string, number>,
|
||||
finalNodes: {} as Record<number, number>,
|
||||
};
|
||||
|
||||
async function runSequence(seed: number, steps: number) {
|
||||
const r = rng(seed * 7919);
|
||||
const frames = installFrames();
|
||||
const worlds = impls.map((M) => buildWorld(M, seed));
|
||||
await nextTick();
|
||||
let time = 100;
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
let pointerId = 1;
|
||||
let nodeKey = "";
|
||||
let heading: next.OriginGestureDirection = "left";
|
||||
let open = false;
|
||||
const log: string[] = [];
|
||||
|
||||
const check = (label: string) => {
|
||||
const message = `${label}\n${log.join("\n")}`;
|
||||
expect(snapshot(worlds[1]!.scene), message).toEqual(
|
||||
snapshot(worlds[0]!.scene),
|
||||
);
|
||||
expect(worlds[1]!.record, message).toEqual(worlds[0]!.record);
|
||||
};
|
||||
|
||||
const dispatch = (type: string, init: PointerEventInit, onInput = false) => {
|
||||
for (const world of worlds) {
|
||||
const host = hostFor(world.root, nodeKey);
|
||||
if (!host) continue;
|
||||
const target = onInput
|
||||
? (host.querySelector("[data-input]") as HTMLElement)
|
||||
: host;
|
||||
target.dispatchEvent(pointerEvent(type, init, time));
|
||||
}
|
||||
};
|
||||
|
||||
for (let step = 0; step < steps; step += 1) {
|
||||
const roll = r.next();
|
||||
time += r.int(40);
|
||||
if (roll < 0.15) {
|
||||
if (open && r.chance(0.8)) {
|
||||
log.push(`up ${x},${y} t=${time}`);
|
||||
dispatch("pointerup", { clientX: x, clientY: y, pointerId });
|
||||
await flush(3);
|
||||
}
|
||||
open = true;
|
||||
const nodes = worlds[0]!.scene.nodes.value;
|
||||
const scene = worlds[0]!.scene;
|
||||
const interactive = nodes.filter((node) =>
|
||||
scene.isNodeInteractive(node.key),
|
||||
);
|
||||
nodeKey = (
|
||||
interactive.length && r.chance(0.85)
|
||||
? r.pick(interactive)
|
||||
: r.pick(nodes)
|
||||
).key;
|
||||
x = r.chance(0.3) ? r.int(50) : r.int(WIDTH);
|
||||
y = r.chance(0.3) ? HEIGHT - r.int(130) : r.int(HEIGHT);
|
||||
pointerId = r.chance(0.9) ? 1 : 2;
|
||||
const init = {
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
pointerId,
|
||||
button: r.chance(0.95) ? 0 : 1,
|
||||
isPrimary: r.chance(0.95),
|
||||
};
|
||||
heading = r.pick(["left", "right", "up", "down"] as const);
|
||||
log.push(`down ${nodeKey} ${JSON.stringify(init)} t=${time}`);
|
||||
dispatch("pointerdown", init, r.chance(0.08));
|
||||
} else if (roll < 0.65) {
|
||||
const jitter = r.int(9) - 4;
|
||||
if (r.chance(0.75)) {
|
||||
const amount = r.chance(0.1) ? 0 : 5 + r.int(55);
|
||||
if (heading === "left") x -= amount;
|
||||
if (heading === "right") x += amount;
|
||||
if (heading === "up") y -= amount;
|
||||
if (heading === "down") y += amount;
|
||||
if (heading === "left" || heading === "right") y += jitter;
|
||||
else x += jitter;
|
||||
} else {
|
||||
x += r.int(70) - 35;
|
||||
y += r.int(70) - 35;
|
||||
}
|
||||
log.push(`move ${x},${y} t=${time}`);
|
||||
dispatch("pointermove", { clientX: x, clientY: y, pointerId });
|
||||
} else if (roll < 0.85) {
|
||||
open = false;
|
||||
log.push(`up ${x},${y} t=${time}`);
|
||||
dispatch("pointerup", { clientX: x, clientY: y, pointerId });
|
||||
} else if (roll < 0.9) {
|
||||
open = false;
|
||||
log.push(`cancel`);
|
||||
dispatch("pointercancel", { pointerId });
|
||||
} else {
|
||||
const count = 1 + r.int(20);
|
||||
log.push(`frames ${count}`);
|
||||
for (let i = 0; i < count; i += 1) frames.advance(16);
|
||||
}
|
||||
if (r.chance(0.7)) {
|
||||
frames.advance(16);
|
||||
frames.advance(16);
|
||||
}
|
||||
if (!r.chance(0.2)) await flush(3);
|
||||
check(`seed ${seed} step ${step}`);
|
||||
stats.maxNodes = Math.max(
|
||||
stats.maxNodes,
|
||||
worlds[0]!.scene.nodes.value.length,
|
||||
);
|
||||
stats.maxOps = Math.max(
|
||||
stats.maxOps,
|
||||
worlds[0]!.scene.operations.value.length,
|
||||
);
|
||||
stats.opSteps += worlds[0]!.scene.operations.value.length ? 1 : 0;
|
||||
}
|
||||
for (const entry of worlds[0]!.record as unknown[][])
|
||||
stats.records[entry[0] as string] =
|
||||
(stats.records[entry[0] as string] ?? 0) + 1;
|
||||
stats.finalNodes[worlds[0]!.scene.nodes.value.length] =
|
||||
(stats.finalNodes[worlds[0]!.scene.nodes.value.length] ?? 0) + 1;
|
||||
|
||||
for (let i = 0; i < 400 && frames.pending(); i += 1) frames.advance(16);
|
||||
await flush();
|
||||
check(`seed ${seed} drained`);
|
||||
return worlds[0]!.scene.nodes.value.length;
|
||||
}
|
||||
|
||||
describe("gesture: baseline vs rewrite", () => {
|
||||
const seeds = Number(process.env.SEEDS ?? 200);
|
||||
for (let seed = 1; seed <= seeds; seed += 1) {
|
||||
it(`random pointer stream ${seed}`, async () => {
|
||||
await runSequence(seed, 120);
|
||||
});
|
||||
}
|
||||
it("reached multi-node scenes", () => {
|
||||
console.log("STATS", JSON.stringify(stats));
|
||||
expect(stats.maxNodes).toBeGreaterThan(2);
|
||||
});
|
||||
});
|
||||
@@ -1,110 +1,5 @@
|
||||
import { defineComponent, h, type Component } from "vue";
|
||||
import type { OriginScene } from "../src";
|
||||
|
||||
export function rng(seed: number) {
|
||||
let a = seed >>> 0;
|
||||
const next = () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0;
|
||||
let t = a;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
return {
|
||||
next,
|
||||
int: (n: number) => Math.floor(next() * n),
|
||||
pick: <T>(items: readonly T[]) => items[Math.floor(next() * items.length)]!,
|
||||
chance: (p: number) => next() < p,
|
||||
};
|
||||
}
|
||||
|
||||
export function component(name: string): Component {
|
||||
return defineComponent({ name, render: () => h("div", name) });
|
||||
}
|
||||
|
||||
/** Deterministic rAF + clock shared by both implementations. */
|
||||
export function installFrames() {
|
||||
let now = 1000;
|
||||
let queue: FrameRequestCallback[] = [];
|
||||
window.requestAnimationFrame = ((cb: FrameRequestCallback) => {
|
||||
queue.push(cb);
|
||||
return queue.length;
|
||||
}) as typeof window.requestAnimationFrame;
|
||||
globalThis.requestAnimationFrame = window.requestAnimationFrame;
|
||||
const perf = performance;
|
||||
perf.now = () => now;
|
||||
return {
|
||||
advance(ms = 16) {
|
||||
now += ms;
|
||||
const run = queue;
|
||||
queue = [];
|
||||
for (const cb of run) cb(now);
|
||||
},
|
||||
pending: () => queue.length,
|
||||
};
|
||||
}
|
||||
|
||||
export async function flush(rounds = 6) {
|
||||
for (let i = 0; i < rounds; i += 1)
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
/** Implementation-agnostic, comparable snapshot of a scene. */
|
||||
export function snapshot(scene: OriginScene) {
|
||||
const nodes = scene.nodes.value;
|
||||
return {
|
||||
nodes: nodes.map((node) => ({
|
||||
key: node.key,
|
||||
sequence: node.sequence,
|
||||
view: node.view.key,
|
||||
previousNodeKey: node.previousNodeKey,
|
||||
state: node.state,
|
||||
incomingOperationId: node.incomingOperationId,
|
||||
history: node.history.map((view) => view.key),
|
||||
})),
|
||||
operations: scene.operations.value.map((operation) => ({
|
||||
id: operation.id,
|
||||
originKey: operation.originKey,
|
||||
targetKey: operation.targetKey,
|
||||
entryTargetKey: operation.entryTargetKey,
|
||||
choreography: operation.choreography.name,
|
||||
placement: operation.placement,
|
||||
history: operation.history,
|
||||
progress: operation.progress,
|
||||
velocity: operation.velocity,
|
||||
phase: operation.phase,
|
||||
intent: operation.intent,
|
||||
})),
|
||||
roots: [...scene.roots.value],
|
||||
styles: Object.fromEntries(
|
||||
nodes.map((node) => [node.key, scene.styleForNode(node.key)]),
|
||||
),
|
||||
interactive: Object.fromEntries(
|
||||
nodes.map((node) => [node.key, scene.isNodeInteractive(node.key)]),
|
||||
),
|
||||
contexts: Object.fromEntries(
|
||||
nodes.map((node) => {
|
||||
const context = scene.contextFor(node.key);
|
||||
return [
|
||||
node.key,
|
||||
{
|
||||
nodeKey: context.nodeKey,
|
||||
view: context.view.key,
|
||||
canGoBack: context.canGoBack,
|
||||
previous: context.previous?.key,
|
||||
history: context.history.map((view) => view.key),
|
||||
},
|
||||
];
|
||||
}),
|
||||
),
|
||||
missingStyle: scene.styleForNode("missing::0"),
|
||||
missingInteractive: scene.isNodeInteractive("missing::0"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function settle<T>(promise: Promise<T>) {
|
||||
return promise.then(
|
||||
(value) => ({ ok: true as const, value }),
|
||||
(error: Error) => ({ ok: false as const, error: error.message }),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,285 +0,0 @@
|
||||
/**
|
||||
* Differential test: drives the baseline (main) and rewritten scenes with the
|
||||
* same random operation sequences and requires identical observable state
|
||||
* after every step.
|
||||
*/
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
import * as next from "../src";
|
||||
import * as base from "./.baseline";
|
||||
import {
|
||||
component,
|
||||
flush,
|
||||
installFrames,
|
||||
rng,
|
||||
settle,
|
||||
snapshot,
|
||||
} from "./harness";
|
||||
|
||||
type Impl = typeof next;
|
||||
const impls = [base as unknown as Impl, next] as const;
|
||||
|
||||
const css = (style: Record<string, string>) => style as never;
|
||||
|
||||
const choreographies = [
|
||||
next.defineOriginChoreography({
|
||||
name: "layered",
|
||||
effects: ({ progress }) => ({
|
||||
source: { transform: `translateX(${-progress * 100}px)` },
|
||||
target: { transform: `translateX(${(1 - progress) * 100}px)` },
|
||||
}),
|
||||
}),
|
||||
next.defineOriginChoreography({
|
||||
name: "probe",
|
||||
commitThreshold: 0.5,
|
||||
effects: (c) => ({
|
||||
frame: { style: css({ filter: `blur(${c.progress}px)` }), layer: 1 },
|
||||
source: {
|
||||
opacity: 1 - c.progress,
|
||||
style: css({
|
||||
opacity: "0.9",
|
||||
transform: `rotate(${c.velocity}deg)`,
|
||||
"--state": `${c.phase}/${c.intent}`,
|
||||
}),
|
||||
},
|
||||
target: {
|
||||
transform: c.progress > 0.5 ? "none" : `scale(${c.progress})`,
|
||||
layer: 2,
|
||||
style: css({
|
||||
borderRadius: `${c.viewport.width}x${c.viewport.height}`,
|
||||
"--rects": JSON.stringify([
|
||||
c.originRect ?? null,
|
||||
c.targetRect ?? null,
|
||||
]),
|
||||
position: "fixed",
|
||||
}),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
next.defineOriginChoreography({
|
||||
name: "drawer-open",
|
||||
persistAtRest: true,
|
||||
effects: ({ progress }) => ({
|
||||
source: { transform: `translateX(${progress * 66.6667}%)` },
|
||||
target: { transform: `translateX(${(progress - 1) * 66.6667}%)` },
|
||||
}),
|
||||
}),
|
||||
next.defineOriginChoreography({
|
||||
name: "drawer-close",
|
||||
commitThreshold: 0.2,
|
||||
effects: ({ progress }) => ({
|
||||
source: { transform: `translateX(${-progress * 66.6667}%)` },
|
||||
target: { transform: `translateX(${(1 - progress) * 66.6667}%)` },
|
||||
}),
|
||||
}),
|
||||
next.slideLeft,
|
||||
next.slideRight,
|
||||
next.fade,
|
||||
];
|
||||
|
||||
const views = ["A", "B", "C", "D", "E"].map((name) =>
|
||||
next.originView(component(name), undefined, { key: name.toLowerCase() }),
|
||||
);
|
||||
|
||||
function randomAction(r: ReturnType<typeof rng>): next.OriginAction {
|
||||
const choreography = r.pick(choreographies);
|
||||
const target = r.pick(views);
|
||||
const placement = r.pick(["above", "under", undefined] as const);
|
||||
switch (r.int(6)) {
|
||||
case 0:
|
||||
return next.forward(target, choreography, { placement });
|
||||
case 1:
|
||||
return next.replace(target, choreography, { placement });
|
||||
case 2:
|
||||
case 3:
|
||||
return next.back(choreography, { placement });
|
||||
case 4:
|
||||
return next.above(target, choreography, {
|
||||
history: r.pick(["push", "replace", "back", undefined] as const),
|
||||
});
|
||||
default:
|
||||
return next.under(target, choreography, {
|
||||
history: r.pick(["push", "back"] as const),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
type Pair<T> = [T, T];
|
||||
export const stats = {
|
||||
undecidedFinishes: 0,
|
||||
boundary: 0,
|
||||
begins: 0,
|
||||
beginOk: 0,
|
||||
};
|
||||
interface Slot {
|
||||
done: [boolean, boolean];
|
||||
value: [unknown, unknown];
|
||||
}
|
||||
|
||||
async function runSequence(seed: number, steps: number) {
|
||||
const r = rng(seed);
|
||||
const frames = installFrames();
|
||||
const scenes = impls.map((impl) =>
|
||||
impl.createOriginScene({
|
||||
initial: seed % 3 === 0 ? [views[0]!, views[1]!] : views[0]!,
|
||||
}),
|
||||
) as Pair<next.OriginScene>;
|
||||
const handles: Pair<next.OriginOperationHandle>[] = [];
|
||||
const slots: Slot[] = [];
|
||||
const log: string[] = [];
|
||||
|
||||
const track = (promises: Pair<Promise<unknown>>) => {
|
||||
const slot: Slot = { done: [false, false], value: [undefined, undefined] };
|
||||
promises.forEach(
|
||||
(promise, index) =>
|
||||
void settle(promise).then((value) => {
|
||||
slot.done[index] = true;
|
||||
slot.value[index] = value;
|
||||
}),
|
||||
);
|
||||
slots.push(slot);
|
||||
return slot;
|
||||
};
|
||||
|
||||
const check = (label: string) => {
|
||||
expect(snapshot(scenes[1]), `${label}\n${log.join("\n")}`).toEqual(
|
||||
snapshot(scenes[0]),
|
||||
);
|
||||
for (const slot of slots) {
|
||||
expect(slot.done[1], label).toBe(slot.done[0]);
|
||||
expect(slot.value[1], label).toEqual(slot.value[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const pickHandle = () => {
|
||||
const liveIds = new Set(scenes[0].operations.value.map((o) => o.id));
|
||||
const live = handles.filter(([handle]) => liveIds.has(handle.id));
|
||||
return live.length && r.chance(0.85) ? r.pick(live) : r.pick(handles);
|
||||
};
|
||||
const boundaryUpdate = (pair: Pair<next.OriginOperationHandle>) => {
|
||||
// Bias toward the commit-decision boundaries.
|
||||
const progress = r.chance(0.4)
|
||||
? r.pick([0, 0.2, 0.35999, 0.36, 0.49999, 0.5, 1])
|
||||
: r.next() * 1.4 - 0.2;
|
||||
const velocity = r.chance(0.2)
|
||||
? undefined
|
||||
: r.chance(0.4)
|
||||
? r.pick([-0.3, 0, 0.29999, 0.3, 0.30001, 0.31])
|
||||
: r.next() * 4 - 2;
|
||||
log.push(`update ${pair[0].id} ${progress} ${velocity}`);
|
||||
pair.forEach((handle) => handle.update(progress, velocity));
|
||||
};
|
||||
|
||||
for (let step = 0; step < steps; step += 1) {
|
||||
const roll = r.next();
|
||||
const nodeKeys = scenes[0].nodes.value.map((node) => node.key);
|
||||
if (roll < 0.3 || handles.length === 0) {
|
||||
const originKey = r.chance(0.03) ? "missing::0" : r.pick(nodeKeys);
|
||||
const action = randomAction(r);
|
||||
log.push(
|
||||
`begin ${originKey} ${action.history} ${action.choreography.name}`,
|
||||
);
|
||||
const slot = track(
|
||||
scenes.map((scene) => scene.begin(originKey, action)) as Pair<
|
||||
Promise<next.OriginOperationHandle>
|
||||
>,
|
||||
);
|
||||
await flush();
|
||||
if (slot.done[0] && slot.done[1]) {
|
||||
const [a, b] = slot.value as Pair<
|
||||
{ ok: true; value: next.OriginOperationHandle } | { ok: false }
|
||||
>;
|
||||
stats.begins += 1;
|
||||
if (a.ok && b.ok) {
|
||||
stats.beginOk += 1;
|
||||
handles.push([a.value, b.value]);
|
||||
// Handles are compared by identity fields only.
|
||||
slot.value = [
|
||||
{ id: a.value.id, o: a.value.originKey, t: a.value.targetKey },
|
||||
{ id: b.value.id, o: b.value.originKey, t: b.value.targetKey },
|
||||
];
|
||||
}
|
||||
}
|
||||
} else if (roll < 0.55) {
|
||||
boundaryUpdate(pickHandle());
|
||||
} else if (roll < 0.75) {
|
||||
const pair = pickHandle();
|
||||
if (r.chance(0.5)) boundaryUpdate(pair);
|
||||
const options = {
|
||||
commit: r.pick([true, false, undefined]),
|
||||
animate: r.chance(0.5),
|
||||
};
|
||||
log.push(`finish ${pair[0].id} ${JSON.stringify(options)}`);
|
||||
const live = scenes[0].operations.value.find((o) => o.id === pair[0].id);
|
||||
if (live && options.commit === undefined) {
|
||||
stats.undecidedFinishes += 1;
|
||||
if (live.velocity >= 0.3 && live.velocity < 0.31) stats.boundary += 1;
|
||||
}
|
||||
track(
|
||||
pair.map((handle) => handle.finish(options)) as Pair<Promise<boolean>>,
|
||||
);
|
||||
} else if (roll < 0.82) {
|
||||
const pair = pickHandle();
|
||||
const animate = r.pick([true, false, undefined]);
|
||||
log.push(`cancel ${pair[0].id} ${animate}`);
|
||||
track(
|
||||
pair.map((handle) => handle.cancel({ animate })) as Pair<Promise<void>>,
|
||||
);
|
||||
} else {
|
||||
const count = 1 + r.int(12);
|
||||
log.push(`frames ${count}`);
|
||||
for (let i = 0; i < count; i += 1) frames.advance(4 + r.int(40));
|
||||
}
|
||||
if (!r.chance(0.15)) await flush();
|
||||
check(`seed ${seed} step ${step}`);
|
||||
}
|
||||
|
||||
// Drain every running spring and compare the resting state.
|
||||
for (let i = 0; i < 400 && frames.pending(); i += 1) frames.advance(16);
|
||||
await flush();
|
||||
check(`seed ${seed} drained`);
|
||||
}
|
||||
|
||||
describe("scene: baseline vs rewrite", () => {
|
||||
it("produces identical action and intent objects", () => {
|
||||
const target = views[1]!;
|
||||
const c = choreographies[0]!;
|
||||
const calls: Array<(m: Impl) => unknown> = [
|
||||
(m) => m.originAction(target, c),
|
||||
(m) => m.originAction(target, c, { placement: "under", history: "back" }),
|
||||
(m) => m.forward(target),
|
||||
(m) => m.forward(target, { placement: "under" }),
|
||||
(m) => m.forward(target, c),
|
||||
(m) => m.forward(target, c, { placement: "under" }),
|
||||
(m) => m.replace(target),
|
||||
(m) => m.replace(target, c, {}),
|
||||
(m) => m.back(),
|
||||
(m) => m.back({ placement: "above" }),
|
||||
(m) => m.back(c),
|
||||
(m) => m.back(c, { placement: "above" }),
|
||||
(m) => m.above(target),
|
||||
(m) => m.above(target, { history: "replace" }),
|
||||
(m) => m.above(target, c),
|
||||
(m) => m.above(target, c, { history: "back" }),
|
||||
(m) => m.under(target),
|
||||
(m) => m.under(target, c, { history: "back" }),
|
||||
(m) => m.normalizedEffect(undefined),
|
||||
(m) => m.normalizedEffect({ transform: "x", layer: 2 }, -1),
|
||||
];
|
||||
for (const call of calls) {
|
||||
const [a, b] = impls.map(call);
|
||||
expect(b).toStrictEqual(a);
|
||||
expect(Object.keys(b as object)).toEqual(Object.keys(a as object));
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(() => console.log("STATS", JSON.stringify(stats)));
|
||||
|
||||
it("exports the same runtime names", () => {
|
||||
expect(Object.keys(next).sort()).toEqual(Object.keys(base).sort());
|
||||
});
|
||||
|
||||
const seeds = Number(process.env.SEEDS ?? 300);
|
||||
for (let seed = 1; seed <= seeds; seed += 1) {
|
||||
it(`random sequence ${seed}`, () => runSequence(seed, 60));
|
||||
}
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import vue from "@vitejs/plugin-vue";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
/*
|
||||
* Differential verification against a previous core-v2 implementation.
|
||||
* Benchmarks against a previous core-v2 implementation.
|
||||
* Run `setup-baseline.sh <git-ref>` first; see README.md.
|
||||
*/
|
||||
export default defineConfig({
|
||||
@@ -10,7 +10,6 @@ export default defineConfig({
|
||||
test: {
|
||||
root: __dirname,
|
||||
environment: "happy-dom",
|
||||
include: ["*.diff.ts"],
|
||||
testTimeout: 120_000,
|
||||
benchmark: { include: ["*.bench.ts"] },
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user