Files
Native-Router-Vue/packages/core-v2/src/scene/graph.ts
T
Claude 6889fc0112 Make gestures cooperate with scrolling and feel smoother
From an app built on core-v2:

Scrolling (#3, #4, #14)
- Each recognizer locks onto a touch within its first 6px: along its axis
  and direction it holds the touch, otherwise it lets go. A new
  onTouchmove binding cancels native scrolling only while a recognizer
  holds the touch, so hosts use touch-action: manipulation and one
  vertical gesture no longer disables vertical scrolling. Several
  gestures on one host share touches through the same rule.
- .from.scrollEdge(scroller, edge) admits drag-to-dismiss only while a
  scroller is at the edge (#5).

Feel
- recognition.distance sets what counts as full progress: pixels, a CSS
  length, or a measuring function (#6).
- Choreographies receive overshoot, the drag past either end of the
  clamped progress, and the settling spring starts from it (#7).
- Velocity is the least-squares trend of the last 100ms (#8).
- Defaults retuned to commitThreshold 0.25 and commitVelocity 0.2; the
  slide presets use them (#9).
- Choreographies may set spring stiffness and damping (#10).

Hooks and helpers
- .onArm()/.onDisarm() run during a drag when releasing would start or
  stop committing (#12).
- startedIn, nearEdge, atScrollEdge, anyOf, allOf start predicates (#15).
- Development builds warn when an operation's first frame moves a node
  that was visibly at rest, the one-frame "pop" of mismatched open and
  close choreographies (#16).

Docs: scrolling, ignored elements, animation performance, haptics, and
operating-system edge gestures including CloseWatcher/popstate for PWAs
(#11, #17, #18).

Verification: a Chromium harness with emulated touch (verify/browser)
checks swipes over scrollers, native scrolling beside gestures,
scroll-edge dismissal, onCommit inside pointerup, and the continuity
warning. Reverting the earlier capture fix makes it fail exactly as
reported ("none of the handles work").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
2026-10-31 08:22:11 +00:00

653 lines
23 KiB
TypeScript

import { markRaw, reactive, shallowReactive, shallowRef } from "vue";
import type {
OriginAction,
OriginHistoryMode,
OriginView,
} from "../navigation";
import { clamp01, type OriginRect } from "../shared";
import type {
MutableOriginOperation,
OriginContext,
OriginFinishOptions,
OriginSceneNodeState,
} from "./types";
/** Mutable storage for one mounted scene node. */
export interface SceneNodeRecord {
key: string;
sequence: number;
view: OriginView;
/** Retained-history link: the entry a committed back returns to. */
previousNodeKey?: string;
state: OriginSceneNodeState;
/** Visual link: the operation edge positioning this node as its target. */
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`.
*/
export const DEFAULT_COMMIT_THRESHOLD = 0.25;
/**
* Release velocity (progress units per second) that commits a flick at any
* progress when the choreography does not supply its own `commitVelocity`.
*/
export const DEFAULT_COMMIT_VELOCITY = 0.2;
/**
* Decide a release outcome from explicit options, or from velocity and
* progress: a flick at least `commitVelocity` fast decides by its direction,
* forward committing and backward cancelling, regardless of distance. Slower
* releases commit once progress reaches `commitThreshold`.
*/
export const shouldCommit = (
operation: MutableOriginOperation,
options: OriginFinishOptions,
): boolean => {
if (options.commit !== undefined) return options.commit;
const { commitThreshold, commitVelocity } = operation.choreography;
const flick = commitVelocity ?? DEFAULT_COMMIT_VELOCITY;
if (operation.velocity >= flick) return true;
if (operation.velocity <= -flick) return false;
return operation.progress >= (commitThreshold ?? DEFAULT_COMMIT_THRESHOLD);
};
/**
* The scene model: nodes, operation edges, and the rules that rewrite them.
*
* Two independent structures share these records:
*
* - **History** links each node to its retained previous entry through
* `previousNodeKey`. It changes only when operations commit or cancel.
* - **Visual graph** links each target to the operation that positions it
* through `incomingOperationId`; each operation links back to its origin.
* Nodes without an incoming edge are listed in `roots`.
*
* Nodes are stored flat so Vue never re-parents a component when edges are
* added, spliced, or removed. Every method is synchronous; timing (mounting,
* measuring, settling) belongs to `Scene`.
*/
export class SceneGraph {
readonly nodes = shallowReactive(new Map<string, SceneNodeRecord>());
readonly operations = shallowReactive(
new Map<number, MutableOriginOperation>(),
);
readonly roots = shallowRef<string[]>([]);
/**
* Connected resting edges temporarily removed by a back operation, keyed by
* 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;
constructor(initial: readonly OriginView[]) {
this.roots.value = initial.map((view) => this.createNode(view).key);
}
// Queries -----------------------------------------------------------------
node(key: string): SceneNodeRecord | undefined {
return this.nodes.get(key);
}
operation(id: number): MutableOriginOperation | undefined {
return this.operations.get(id);
}
/** The edge positioning `node` as its target, including resting edges. */
incomingOf(node: SceneNodeRecord): MutableOriginOperation | undefined {
return node.incomingOperationId
? this.operations.get(node.incomingOperationId)
: undefined;
}
/** Incoming edges positioning `node`, from the node outward to its root. */
incomingChain(node: SceneNodeRecord): MutableOriginOperation[] {
const chain: MutableOriginOperation[] = [];
const visited = new Set<string>();
let current: SceneNodeRecord | undefined = node;
while (current && !visited.has(current.key)) {
visited.add(current.key);
const incoming = this.incomingOf(current);
if (!incoming) break;
chain.push(incoming);
current = this.nodes.get(incoming.originKey);
}
return chain;
}
/** The unfinished operation originated by a node, if any. */
liveOutgoingOf(nodeKey: string): MutableOriginOperation | undefined {
return this.outgoing
.get(nodeKey)
?.find((operation) => operation.phase !== "finished");
}
/**
* The operation whose source effects apply to a node. Unlike
* {@link liveOutgoingOf}, this includes a finished, connected resting edge.
*/
presentedOutgoingOf(nodeKey: string): MutableOriginOperation | undefined {
return this.outgoing.get(nodeKey)?.[0];
}
/** Retained entries preceding `node`, oldest first. */
historyOf(node: SceneNodeRecord): SceneNodeRecord[] {
const history: SceneNodeRecord[] = [];
const visited = new Set<string>([node.key]);
let previousKey = node.previousNodeKey;
while (previousKey && !visited.has(previousKey)) {
visited.add(previousKey);
const previous = this.nodes.get(previousKey);
if (!previous) break;
history.push(previous);
previousKey = previous.previousNodeKey;
}
return history.reverse();
}
contextFor(nodeKey: string): OriginContext {
const node = this.nodes.get(nodeKey);
if (!node)
throw new OriginSceneError(
"missing-node",
`Origin scene node "${nodeKey}" no longer exists.`,
);
const history = this.historyOf(node).map((entry) => entry.view);
return {
nodeKey,
view: node.view,
canGoBack: Boolean(node.previousNodeKey),
previous: history.at(-1),
history,
};
}
/**
* Whether a node should currently receive input.
*
* Parked and exposed entries are inert. A target being cancelled is already
* scheduled to disappear, and a source settling toward commit has ceded new
* interactions to its target. This is node-local fate, not a global "active
* view".
*/
isInteractive(nodeKey: string): boolean {
const node = this.nodes.get(nodeKey);
if (!node || node.state === "parked" || node.state === "exposed")
return false;
if (this.incomingOf(node)?.intent === "cancel") return false;
return this.liveOutgoingOf(nodeKey)?.intent !== "commit";
}
// Transitions -------------------------------------------------------------
/**
* Validate an action and add its operation edge (and, for push/replace, its
* target node). The edge starts in the `preparing` phase.
*
* @throws If the origin cannot originate, or the action cannot be resolved.
*/
begin(
originKey: string,
action: OriginAction,
originRect: OriginRect | undefined,
): MutableOriginOperation {
const origin = this.assertCanOriginate(originKey);
const history = action.history ?? "push";
if (action.choreography.persistAtRest && history !== "push")
throw new OriginSceneError(
"invalid-action",
"persistAtRest choreographies require retained-history push navigation.",
);
const { target, suspendedEdge } =
history === "back"
? this.resolveBackTarget(origin)
: { target: this.createForwardTarget(origin, action, history) };
const operation = this.createOperation({
originKey,
targetKey: target.key,
entryTargetKey: target.key,
choreography: action.choreography,
placement: action.placement ?? "above",
history,
originRect,
});
target.incomingOperationId = operation.id;
target.state = "transitioning";
origin.state = "transitioning";
if (suspendedEdge) this.suspendedEdges.set(operation.id, suspendedEdge);
return operation;
}
/** Apply interactive progress to an unfinished operation. */
update(id: number, progress: number, velocity: number): void {
const operation = this.operations.get(id);
if (!operation || operation.phase === "finished") return;
operation.progress = clamp01(progress);
operation.overshoot = progress - operation.progress;
operation.velocity = velocity;
if (operation.phase !== "preparing") operation.phase = "interactive";
}
/**
* Finalize a committed operation.
*
* A normal push parks its origin and collapses the temporary visual edge, so
* the target takes the origin's place in the visual graph. A connected
* (`persistAtRest`) push keeps the edge at progress 1 and leaves the origin
* exposed but inert. Replace and back remove the origin.
*
* @returns Whether the operation's nodes still existed to be committed.
*/
commit(id: number): boolean {
const operation = this.operations.get(id);
if (!operation) return false;
const origin = this.nodes.get(operation.originKey);
const target = this.nodes.get(operation.targetKey);
if (!origin || !target) return false;
if (operation.history === "push" && operation.choreography.persistAtRest) {
operation.progress = 1;
operation.overshoot = 0;
operation.velocity = 0;
operation.intent = "commit";
operation.phase = "finished";
origin.state = "exposed";
this.activate(target);
return true;
}
this.transferPosition(origin, target);
operation.phase = "finished";
this.detach(operation);
this.suspendedEdges.delete(operation.id);
if (operation.history === "push") {
origin.incomingOperationId = undefined;
origin.state = "parked";
} else {
// Back pops the current entry; replace discards it in favor of the new
// 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);
}
this.activate(target);
return true;
}
/**
* Finalize a cancelled operation, restoring the origin.
*
* 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 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.detach(operation);
this.suspendedEdges.delete(id);
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.discardOutgoing(entry.key, new Set(retained));
this.transferPosition(origin, entry);
this.attach(suspendedEdge);
origin.incomingOperationId = suspendedEdge.id;
entry.state = "exposed";
} else {
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);
}
// Transition steps --------------------------------------------------------
private assertCanOriginate(originKey: string): SceneNodeRecord {
const origin = this.nodes.get(originKey);
if (!origin)
throw new OriginSceneError(
"missing-origin",
`Cannot animate from missing origin "${originKey}".`,
);
if (origin.state === "parked" || origin.state === "exposed")
throw new OriginSceneError(
"inert-origin",
`Cannot animate from ${origin.state} origin "${originKey}".`,
);
if (this.liveOutgoingOf(originKey))
throw new OriginSceneError(
"origin-busy",
`Origin "${originKey}" already has an outgoing operation. ` +
"A descendant target may start its own operation instead.",
);
return origin;
}
/**
* Resolve the retained previous entry for a back operation.
*
* If the origin's incoming edge comes from that previous entry (a forward
* push still settling, or a connected resting edge), the edge is collapsed
* first. Creating a new edge back to its origin would otherwise form X→Y→X.
* Reciprocal choreographies begin at the same visual endpoints, so this
* handoff is continuous without remounting either node.
*/
private resolveBackTarget(origin: SceneNodeRecord): {
target: SceneNodeRecord;
suspendedEdge?: MutableOriginOperation;
} {
const previousKey = origin.previousNodeKey;
if (!previousKey)
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 OriginSceneError(
"no-previous-entry",
`Cannot go back to missing retained entry "${previousKey}".`,
);
let suspendedEdge: MutableOriginOperation | undefined;
const incoming = this.incomingOf(origin);
if (incoming?.originKey === target.key) {
if (incoming.intent !== "commit")
throw new OriginSceneError(
"undecided-forward",
"Cannot go back through an undecided forward operation.",
);
if (incoming.phase !== "finished") this.commit(incoming.id);
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.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 OriginSceneError(
"target-busy",
`Retained target "${target.key}" already has an outgoing operation.`,
);
if (target.incomingOperationId)
throw new OriginSceneError(
"target-busy",
`Retained target "${target.key}" is already transitioning.`,
);
return { target, suspendedEdge };
}
/** Create the target node for a push or replace operation. */
private createForwardTarget(
origin: SceneNodeRecord,
action: OriginAction,
history: Exclude<OriginHistoryMode, "back">,
): SceneNodeRecord {
if (!action.target)
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
// mounted until commit so an interactive replacement can still cancel.
const previousKey =
history === "push" ? origin.key : origin.previousNodeKey;
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);
}
/**
* Recompute whether a presented node is participating in a live operation.
* Parked and exposed nodes keep their retained state.
*/
private refreshPresentedState(node: SceneNodeRecord): void {
if (node.state === "parked" || node.state === "exposed") return;
const incoming = this.incomingOf(node);
node.state =
(incoming && incoming.phase !== "finished") ||
this.liveOutgoingOf(node.key)
? "transitioning"
: "active";
}
// Structural primitives ---------------------------------------------------
private createNode(
view: OriginView,
previousNodeKey?: string,
): SceneNodeRecord {
const sequence = ++this.nodeSequence;
const node: SceneNodeRecord = shallowReactive({
key: `${view.key ?? view.name ?? "view"}::${sequence}`,
sequence,
view: markRaw(view),
previousNodeKey,
state: "active",
incomingOperationId: undefined,
});
this.nodes.set(node.key, node);
return node;
}
private createOperation(
init: Pick<
MutableOriginOperation,
| "originKey"
| "targetKey"
| "entryTargetKey"
| "choreography"
| "placement"
| "history"
| "originRect"
>,
): MutableOriginOperation {
const operation = reactive<MutableOriginOperation>({
id: ++this.operationSequence,
originKey: init.originKey,
targetKey: init.targetKey,
entryTargetKey: init.entryTargetKey,
choreography: markRaw(init.choreography),
placement: init.placement,
history: init.history,
progress: 0,
overshoot: 0,
velocity: 0,
phase: "preparing",
intent: "undecided",
originRect: init.originRect,
});
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 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) ?? [])]) {
// Includes finished resting edges: an open drawer closes with its page.
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;
visited.add(nodeKey);
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.detach(operation);
this.removeNode(nodeKey);
}
/**
* Give `target` the visual-graph position currently held by `from`: either
* as the target of `from`'s incoming edge, or as a root. Neither Vue VNode
* moves; only edge bookkeeping changes.
*/
private transferPosition(from: SceneNodeRecord, target: SceneNodeRecord) {
const parent = this.incomingOf(from);
if (parent) {
parent.targetKey = target.key;
target.incomingOperationId = parent.id;
} else {
this.roots.value = this.roots.value.map((key) =>
key === from.key ? target.key : key,
);
target.incomingOperationId = undefined;
}
}
}