diff --git a/packages/core-v2/API.md b/packages/core-v2/API.md index 4533a21..903b1cf 100644 --- a/packages/core-v2/API.md +++ b/packages/core-v2/API.md @@ -553,7 +553,10 @@ When `finish()` does not explicitly override the decision, a target commits when either: - `progress >= commitThreshold`, default `0.36`; or -- `progress >= 0.06` and `velocity >= commitVelocity`, default `0.9`. +- 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. The operation's intent becomes final before its spring settles. diff --git a/packages/core-v2/README.md b/packages/core-v2/README.md index 9bdb54a..db77f5d 100644 --- a/packages/core-v2/README.md +++ b/packages/core-v2/README.md @@ -292,6 +292,26 @@ flat host. “Y is Z's origin” is a coordinate and retained-history relationship, not Vue component ancestry. +## Source layout + +The package is layered; each layer depends only on the ones above it. + +| Directory | Responsibility | +| --------------- | ------------------------------------------------------------------------------------------------------- | +| `shared/` | Geometry and environment helpers | +| `view/` | `originView()` component recipes | +| `choreography/` | Effect types, `defineOriginChoreography()`, presets, and `composeEffects()` style compositing | +| `actions/` | `forward()`, `back()`, `replace()`, `above()`, `under()`, `originAction()` | +| `scene/` | `graph.ts` storage, `transitions.ts` history rules, `compositor.ts` styles, `spring.ts` settling motion | +| `composables/` | `useOrigin()` and the node-scope injection key | +| `gesture/` | Builder, definition→policy normalization, start admission, pointer tracking, and the recognizer | +| `components/` | `OriginScene`, `OriginGesture`, `OriginGestureSurface`, and the internal node host | + +`createOriginScene()` wires the scene modules together: the graph holds nodes +and operation edges, transitions rewrite it synchronously when operations +begin, commit, or cancel, and the scene adds the asynchronous parts (waiting +for Vue to mount a target, measuring, and running the settling spring). + ## Current experimental boundaries - One operation creates one target. Chaining operations already permits any diff --git a/packages/core-v2/src/actions/actions.ts b/packages/core-v2/src/actions/actions.ts new file mode 100644 index 0000000..3537263 --- /dev/null +++ b/packages/core-v2/src/actions/actions.ts @@ -0,0 +1,261 @@ +import { isChoreography } from "../choreography/define"; +import type { OriginChoreography } from "../choreography/types"; +import type { OriginView } from "../view/types"; +import type { + OriginAction, + OriginActionOptions, + OriginHistoryMode, + OriginNavigationActionOptions, + OriginNavigationIntent, + OriginPlacement, + OriginPlacementActionOptions, +} from "./types"; + +/* + * Every helper accepts either `(…, options?)`, producing an animation-free + * navigation intent for gesture `.navigate()`, or `(…, choreography, + * options?)`, producing a complete action for `begin()`/`perform()`. + */ + +interface HelperArguments { + readonly choreography?: OriginChoreography; + readonly options: Options; +} + +function helperArguments( + choreographyOrOptions: OriginChoreography | Options | undefined, + options: Options | undefined, +): HelperArguments> { + return isChoreography(choreographyOrOptions) + ? { choreography: choreographyOrOptions, options: options ?? {} } + : { options: choreographyOrOptions ?? {} }; +} + +function navigation( + target: OriginView | undefined, + placement: OriginPlacement, + history: OriginHistoryMode, +): OriginNavigationIntent { + return { target, placement, history }; +} + +/** Back navigation resolves its target at execution time, so it has none. */ +function targetlessNavigation( + placement: OriginPlacement, + history: OriginHistoryMode, +): OriginNavigationIntent { + return { placement, history }; +} + +/** Pair a navigation intent with choreography, or return it unchanged. */ +function materialize( + intent: OriginNavigationIntent, + choreography: OriginChoreography | undefined, +): OriginNavigationIntent | OriginAction { + if (!choreography) return intent; + const { placement, history } = intent; + return "target" in intent + ? { target: intent.target, choreography, placement, history } + : { choreography, placement, history }; +} + +/** + * Create the scene mutation invoked by a click, gesture, hardware command, or + * any other application event. `above` and `under` affect stacking only; they + * do not imply a universal navigation direction. + * + * @param target - View recipe to create. + * @param choreography - Visual routine controlling the operation. + * @param options - Stacking and history behavior. + * @returns An action that can be passed to `begin()`, `perform()`, or returned + * from a gesture action factory. + */ +export function originAction( + target: OriginView, + choreography: OriginChoreography, + options: OriginActionOptions = {}, +): OriginAction { + return materialize( + navigation(target, options.placement ?? "above", options.history ?? "push"), + choreography, + ) as OriginAction; +} + +/** + * Create a retained-history push intent or complete action. + * + * Committing the action parks its origin instance and leaves it mounted until + * a later committed {@link back} action pops the new entry. Omit choreography + * when declaring `.navigate()` inside a gesture builder; provide choreography + * when passing the result directly to `begin()` or `perform()`. + * + * @param target - View recipe created if the push commits. + * @param options - Stacking options for an animation-free navigation intent. + * @returns An animation-free intent for use with a gesture builder. + */ +export function forward( + target: OriginView, + options?: OriginNavigationActionOptions, +): OriginNavigationIntent; +export function forward( + target: OriginView, + choreography: OriginChoreography, + options?: OriginNavigationActionOptions, +): OriginAction; +export function forward( + target: OriginView, + choreographyOrOptions?: OriginChoreography | OriginNavigationActionOptions, + maybeOptions?: OriginNavigationActionOptions, +): OriginAction | OriginNavigationIntent { + const { choreography, options } = helperArguments( + choreographyOrOptions, + maybeOptions, + ); + return materialize( + navigation(target, options.placement ?? "above", "push"), + choreography, + ); +} + +/** + * Create a retained-history replacement intent or complete action. + * + * The target inherits the origin's previous mounted entry instead of retaining + * the origin itself. A commit unmounts the origin and activates the target, so + * a later {@link back} skips the replaced entry. A cancellation removes the + * proposed target and restores the origin unchanged. + * + * Omit choreography when declaring `.navigate()` inside a gesture builder; + * provide choreography when passing the result directly to `begin()` or + * `perform()`. + * + * @param target - View recipe created for the replacement. + * @param options - Stacking options for an animation-free navigation intent. + * @returns An animation-free replacement intent for use with a gesture builder. + */ +export function replace( + target: OriginView, + options?: OriginNavigationActionOptions, +): OriginNavigationIntent; +export function replace( + target: OriginView, + choreography: OriginChoreography, + options?: OriginNavigationActionOptions, +): OriginAction; +export function replace( + target: OriginView, + choreographyOrOptions?: OriginChoreography | OriginNavigationActionOptions, + maybeOptions?: OriginNavigationActionOptions, +): OriginAction | OriginNavigationIntent { + const { choreography, options } = helperArguments( + choreographyOrOptions, + maybeOptions, + ); + return materialize( + navigation(target, options.placement ?? "above", "replace"), + choreography, + ); +} + +/** + * Create a retained-history back intent or complete action. + * + * The action intentionally has no target recipe. At execution time the scene + * resolves the origin's mounted `previousNodeKey`, reveals that exact instance, + * and removes the current instance only if the operation commits. Omit + * choreography inside gesture `.navigate()`; provide it for a programmatic + * action. + * + * @param options - Stacking options for an animation-free navigation intent. + * @returns An animation-free back intent for use with a gesture builder. + */ +export function back( + options?: OriginNavigationActionOptions, +): OriginNavigationIntent; +export function back( + choreography: OriginChoreography, + options?: OriginNavigationActionOptions, +): OriginAction; +export function back( + choreographyOrOptions?: OriginChoreography | OriginNavigationActionOptions, + maybeOptions?: OriginNavigationActionOptions, +): OriginAction | OriginNavigationIntent { + const { choreography, options } = helperArguments( + choreographyOrOptions, + maybeOptions, + ); + return materialize( + targetlessNavigation(options.placement ?? "under", "back"), + choreography, + ); +} + +/** + * Create an intent or action whose target is stacked above its origin. + * + * This helper controls stacking, not movement direction. The supplied + * choreography may move either component however the application chooses. + * Omitting choreography produces an intent for gesture `.navigate()`. + * + * @param target - View recipe to create. + * @param choreography - Visual routine controlling the operation. + * @param options - Optional history behavior. + */ +export function above( + target: OriginView, + options?: OriginPlacementActionOptions, +): OriginNavigationIntent; +export function above( + target: OriginView, + choreography: OriginChoreography, + options?: OriginPlacementActionOptions, +): OriginAction; +export function above( + target: OriginView, + choreographyOrOptions?: OriginChoreography | OriginPlacementActionOptions, + maybeOptions?: OriginPlacementActionOptions, +): OriginAction | OriginNavigationIntent { + const { choreography, options } = helperArguments( + choreographyOrOptions, + maybeOptions, + ); + return materialize( + navigation(target, "above", options.history ?? "push"), + choreography, + ); +} + +/** + * Create an intent or action whose target is stacked underneath its origin. + * + * Commonly used for custom reveal effects, but it has no implicit history + * meaning. Prefer {@link back} for retained-history navigation. Omitting + * choreography produces an intent for gesture `.navigate()`. + * + * @param target - View recipe to create underneath the origin. + * @param choreography - Visual routine controlling the operation. + * @param options - Optional history behavior. + */ +export function under( + target: OriginView, + options?: OriginPlacementActionOptions, +): OriginNavigationIntent; +export function under( + target: OriginView, + choreography: OriginChoreography, + options?: OriginPlacementActionOptions, +): OriginAction; +export function under( + target: OriginView, + choreographyOrOptions?: OriginChoreography | OriginPlacementActionOptions, + maybeOptions?: OriginPlacementActionOptions, +): OriginAction | OriginNavigationIntent { + const { choreography, options } = helperArguments( + choreographyOrOptions, + maybeOptions, + ); + return materialize( + navigation(target, "under", options.history ?? "push"), + choreography, + ); +} diff --git a/packages/core-v2/src/actions/types.ts b/packages/core-v2/src/actions/types.ts new file mode 100644 index 0000000..0c6f7b7 --- /dev/null +++ b/packages/core-v2/src/actions/types.ts @@ -0,0 +1,106 @@ +import type { OriginChoreography } from "../choreography/types"; +import type { OriginView } from "../view/types"; + +/** + * Controls how an operation participates in retained instance history. + * + * - `push`: create a target whose previous entry is the mounted origin. + * - `replace`: create a target that inherits the origin's previous entry, then + * remove the origin on commit. + * - `back`: reuse the mounted previous entry and pop the origin on commit. + */ +export type OriginHistoryMode = "push" | "replace" | "back"; + +/** + * The target's stacking relationship to its origin while an operation exists. + */ +export type OriginPlacement = "above" | "under"; + +/** + * A scene mutation without visual choreography. + * + * Gesture builders keep navigation intent separate from animation so the same + * destination can be paired with different component-local interactions. + * Calling `.animate()` materializes this intent as an {@link OriginAction}. + */ +export interface OriginNavigationIntent { + /** + * Recipe for a newly created push or replacement target. Back navigation + * omits this because the scene resolves the retained previous instance. + */ + readonly target?: OriginView; + /** Target stacking relationship while the gesture operation is visible. */ + readonly placement?: OriginPlacement; + /** Retained-history mutation performed if the gesture commits. */ + readonly history: OriginHistoryMode; +} + +/** A complete request to create and animate a target view from an origin. */ +export interface OriginAction { + /** + * Recipe for a newly created push or replacement target. A back action omits + * this because the scene resolves its already-mounted previous entry. + */ + readonly target?: OriginView; + /** Visual relationship applied to the source, target, and shared frame. */ + readonly choreography: OriginChoreography; + /** + * Target stacking relationship during the operation. + * + * @defaultValue `"above"` + */ + readonly placement?: OriginPlacement; + /** + * History mutation applied to the target recipe. + * + * @defaultValue `"push"` + */ + readonly history?: OriginHistoryMode; +} + +/** + * Values accepted from gesture action factories. + * + * Returning `null` or `undefined` declines the recognized gesture. A promise + * allows lazy target resolution; stale results are discarded after release or + * cancellation. + */ +export type MaybeOriginAction = + OriginAction | null | undefined | Promise; + +/** Options accepted by {@link originAction}. */ +export interface OriginActionOptions { + /** + * Target stacking relationship during the operation. + * + * @defaultValue `"above"` + */ + placement?: OriginPlacement; + /** + * History mutation assigned to the created target node. + * + * @defaultValue `"push"` + */ + history?: OriginHistoryMode; +} + +/** History options shared by the {@link above} and {@link under} helpers. */ +export interface OriginPlacementActionOptions { + /** + * History mutation assigned to the created target node. + * + * @defaultValue `"push"` + */ + history?: OriginHistoryMode; +} + +/** Stacking options accepted by retained-history navigation helpers. */ +export interface OriginNavigationActionOptions { + /** + * Target stacking relationship during the operation. + * + * @defaultValue `"above"` for {@link forward} and {@link replace}, `"under"` + * for {@link back} + */ + placement?: OriginPlacement; +} diff --git a/packages/core-v2/src/choreography/compose.ts b/packages/core-v2/src/choreography/compose.ts new file mode 100644 index 0000000..6df33b4 --- /dev/null +++ b/packages/core-v2/src/choreography/compose.ts @@ -0,0 +1,80 @@ +import type { CSSProperties } from "vue"; +import { clamp01 } from "../shared/geometry"; +import type { OriginEffect } from "./types"; + +/** + * Normalize one contribution before the compositor combines it with effects + * inherited from earlier origin frames. + * + * Most applications should return plain effects from a choreography and let + * the scene call this function. It is exported for custom compositors and + * diagnostics. + * + * @param effect - Optional effect to normalize. + * @param fallbackLayer - Relative layer contribution added to `effect.layer`. + * @returns A defined effect with a numeric layer. + */ +export function normalizedEffect( + effect: OriginEffect | undefined, + fallbackLayer = 0, +): OriginEffect { + return { + ...effect, + layer: (effect?.layer ?? 0) + fallbackLayer, + }; +} + +/** Whether a transform contributes anything to a composed transform list. */ +function isComposable(transform: unknown): transform is string { + return ( + typeof transform === "string" && transform !== "" && transform !== "none" + ); +} + +/** Result of collapsing an ordered list of effects into one style. */ +export interface ComposedEffects { + /** Merged style with the composed `transform` and `opacity` applied. */ + readonly style: CSSProperties; + /** Sum of every effect's relative layer contribution. */ + readonly layer: number; +} + +/** + * Collapse independent effect layers, ordered from the outermost inherited + * frame to the most local one, into a single style. + * + * Transforms are concatenated and opacities multiplied, including `transform` + * and numeric `opacity` supplied through `effect.style`. Every other `style` + * property uses local-last precedence over `base` and earlier effects. + */ +export function composeEffects( + effects: readonly OriginEffect[], + base: CSSProperties = {}, +): ComposedEffects { + const style: CSSProperties = { ...base }; + const transforms: string[] = []; + let opacity = 1; + let layer = 0; + + for (const effect of effects) { + if (isComposable(effect.transform)) transforms.push(effect.transform); + if (effect.opacity !== undefined) opacity *= effect.opacity; + layer += effect.layer ?? 0; + + if (effect.style) { + Object.assign(style, effect.style); + if (isComposable(effect.style.transform)) + transforms.push(effect.style.transform); + if (effect.style.opacity !== undefined) { + const styleOpacity = Number(effect.style.opacity); + if (Number.isFinite(styleOpacity)) opacity *= styleOpacity; + } + } + } + + // Assign the composed properties last so no single effect's `style` can + // replace transform work inherited from other frames. + style.transform = transforms.length ? transforms.join(" ") : "none"; + style.opacity = String(clamp01(opacity)); + return { style, layer }; +} diff --git a/packages/core-v2/src/choreography/define.ts b/packages/core-v2/src/choreography/define.ts new file mode 100644 index 0000000..2d8c014 --- /dev/null +++ b/packages/core-v2/src/choreography/define.ts @@ -0,0 +1,27 @@ +import type { OriginChoreography } from "./types"; + +/** + * Preserve type inference while declaring a custom choreography. + * + * The helper performs no runtime transformation. It gives custom routines a + * named, documented construction point and validates their shape in TypeScript. + * + * @param choreography - Side-effect-free visual effect calculator and optional + * release thresholds. + * @returns The same choreography object. + */ +export function defineOriginChoreography( + choreography: OriginChoreography, +): OriginChoreography { + return choreography; +} + +/** Distinguish a choreography from a plain options object. */ +export function isChoreography(value: unknown): value is OriginChoreography { + return ( + typeof value === "object" && + value !== null && + "effects" in value && + typeof value.effects === "function" + ); +} diff --git a/packages/core-v2/src/choreography/presets.ts b/packages/core-v2/src/choreography/presets.ts new file mode 100644 index 0000000..0cab8b4 --- /dev/null +++ b/packages/core-v2/src/choreography/presets.ts @@ -0,0 +1,55 @@ +import { defineOriginChoreography } from "./define"; + +const percent = (value: number) => `${(value * 100).toFixed(4)}%`; + +/** + * Native-style forward motion: the target enters above the source while the + * source recedes slightly. These presets are examples; applications can + * replace them with arbitrary `defineOriginChoreography()` callbacks. + */ +export const slideLeft = defineOriginChoreography({ + name: "slide-left", + commitThreshold: 0.36, + commitVelocity: 0.6, + effects: ({ progress }) => ({ + source: { + transform: `translate3d(${percent(progress * -0.24)}, 0, 0) scale(${1 - progress * 0.025})`, + opacity: 1 - progress * 0.16, + }, + target: { + transform: `translate3d(${percent(1 - progress)}, 0, 0)`, + }, + }), +}); + +/** + * Back motion reveals the target underneath the source. This is merely a + * visual routine; the `back()` action selects retained-history behavior. + */ +export const slideRight = defineOriginChoreography({ + name: "slide-right", + commitThreshold: 0.36, + commitVelocity: 0.6, + effects: ({ progress }) => ({ + source: { + transform: `translate3d(${percent(progress)}, 0, 0)`, + }, + target: { + transform: `translate3d(${percent(-0.24 + progress * 0.24)}, 0, 0) scale(${0.975 + progress * 0.025})`, + opacity: 0.84 + progress * 0.16, + }, + }), +}); + +/** + * Cross-fade preset that fades the source out while fading the target in. + * + * The default release thresholds from the scene are used. + */ +export const fade = defineOriginChoreography({ + name: "fade", + effects: ({ progress }) => ({ + source: { opacity: 1 - progress }, + target: { opacity: progress }, + }), +}); diff --git a/packages/core-v2/src/choreography/types.ts b/packages/core-v2/src/choreography/types.ts new file mode 100644 index 0000000..30be007 --- /dev/null +++ b/packages/core-v2/src/choreography/types.ts @@ -0,0 +1,133 @@ +import type { CSSProperties } from "vue"; +import type { OriginRect } from "../shared/geometry"; + +/** + * The lifecycle phase of an operation edge. + * + * - `preparing`: the target has been added and Vue is mounting it. + * - `interactive`: progress may be controlled by a gesture or application. + * - `settling`: the commit/cancel decision is fixed and the spring is running. + * - `finished`: the graph rewrite or cancellation cleanup has completed. + */ +export type OriginOperationPhase = + "preparing" | "interactive" | "settling" | "finished"; + +/** + * The outcome selected for an operation. + * + * Intent remains `undecided` until `finish()` is called. It becomes final + * before the settling animation completes, allowing the retained target to + * originate its own operation immediately. + */ +export type OriginOperationIntent = "undecided" | "commit" | "cancel"; + +/** + * One composable contribution to a scene node's final visual style. + * + * Transforms are concatenated in origin-to-descendant order and opacity values + * are multiplied. This lets X→Y and Y→Z affect Y simultaneously without either + * routine replacing the other routine's CSS transform. + */ +export interface OriginEffect { + /** + * A CSS transform contribution. Transform strings from inherited and local + * origin frames are concatenated rather than replacing one another. + */ + transform?: string; + /** + * An opacity contribution between `0` and `1`. Contributions from multiple + * frames are multiplied and the final value is clamped. + */ + opacity?: number; + /** + * Relative stacking contribution. `above()` defaults the target to +1 and + * `under()` defaults it to -1. + */ + layer?: number; + /** + * Escape hatch for non-geometric effects such as filter, clipPath, or + * borderRadius. Later/local effects override inherited properties. + */ + style?: CSSProperties; +} + +/** + * Independent visual contributions calculated for one operation edge. + * + * Any omitted contribution is treated as an identity effect. + */ +export interface OriginEffectSet { + /** + * Applied to both source and target. This is useful for moving an entire + * origin-relative coordinate frame. + */ + frame?: OriginEffect; + /** Applied to the component that initiated this operation. */ + source?: OriginEffect; + /** Applied to the component created by this operation and its descendants. */ + target?: OriginEffect; +} + +/** Values supplied whenever a choreography calculates its visual effects. */ +export interface OriginChoreographyContext { + /** Normalized operation progress, clamped to the inclusive range `0..1`. */ + progress: number; + /** + * Normalized progress units per second. Positive velocity moves toward + * commit; negative velocity moves back toward cancellation. + */ + velocity: number; + /** Current lifecycle phase of the operation. */ + phase: OriginOperationPhase; + /** Commit/cancel outcome, if release has already selected one. */ + intent: OriginOperationIntent; + /** Origin host bounds captured immediately before the target mounts. */ + originRect?: OriginRect; + /** Target host bounds measured after Vue mounts the target. */ + targetRect?: OriginRect; + /** Scene-container bounds, or the browser viewport when no container exists. */ + viewport: OriginRect; +} + +/** + * A choreography describes visual relationships only. Component creation, + * history, and cleanup are performed by the scene operation that uses it. + */ +export interface OriginChoreography { + /** Optional diagnostic name surfaced by scene inspectors and devtools. */ + readonly name?: string; + /** + * Calculate source, target, and shared-frame contributions for the current + * operation state. This function should be deterministic and side-effect + * free because it can run many times per animation frame. + */ + effects(context: OriginChoreographyContext): OriginEffectSet; + /** + * Gesture progress required to retain the target after release. + * + * @defaultValue `0.36` + */ + readonly commitThreshold?: number; + /** + * Reserved: normalized positive release velocity intended to commit a + * deliberate flick. + * + * The scene currently commits any release whose velocity is at least `0.3`, + * regardless of this value. See `FLICK_COMMIT_VELOCITY` in the scene module. + */ + readonly commitVelocity?: number; + /** + * Keep this choreography's progress-`1` effects connected after a committed + * push instead of collapsing the edge and hiding its source. + * + * This is intended for partial presentations such as drawers and inspectors + * where part of the retained source remains visible beside the target. The + * source remains mounted, visible, and inert. A back operation temporarily + * suspends the resting edge; cancelling back restores it. + * + * Only retained-history `push` actions support connected resting effects. + * + * @defaultValue `false` + */ + readonly persistAtRest?: boolean; +} diff --git a/packages/core-v2/src/components/OriginGesture.vue b/packages/core-v2/src/components/OriginGesture.vue index 9f5ce3d..e716ba6 100644 --- a/packages/core-v2/src/components/OriginGesture.vue +++ b/packages/core-v2/src/components/OriginGesture.vue @@ -1,7 +1,6 @@