Consolidate core-v2 modules and move scene logic onto classes
- Merge the small modules into 12 files: shared, choreography,
navigation, scene/{types,graph,scene}, gesture/{definition,recognizer}.
- SceneGraph now owns the begin/update/commit/cancel history rules as
methods, with the step helpers as private methods.
- createOriginScene returns a markRaw'd Scene class; operation handles
are an OperationHandle class instead of inline object literals.
- GestureRecognizer gains static create()/policyFor() factories, absorbing
the separate policy module.
- Pure helpers used by classes (matchesStart, pointOf, stepSpring,
composeNodeStyle, nodeEffectLayers, shouldCommit, ...) are exported
consts so they can be tested directly.
- Add haptics tests showing where navigator.vibrate can be triggered.
Public exports, types, and behavior are unchanged; the differential
suites against main still pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
This commit is contained in:
@@ -1,8 +1,14 @@
|
||||
import { markRaw, reactive, shallowReactive, shallowRef } from "vue";
|
||||
import type { OriginView } from "../view/types";
|
||||
import type {
|
||||
OriginAction,
|
||||
OriginHistoryMode,
|
||||
OriginView,
|
||||
} from "../navigation";
|
||||
import { clamp01, type OriginRect } from "../shared";
|
||||
import type {
|
||||
MutableOriginOperation,
|
||||
OriginContext,
|
||||
OriginFinishOptions,
|
||||
OriginSceneNodeState,
|
||||
} from "./types";
|
||||
|
||||
@@ -18,14 +24,34 @@ export interface SceneNodeRecord {
|
||||
incomingOperationId?: number;
|
||||
}
|
||||
|
||||
/** Fields supplied when a new operation edge is created. */
|
||||
export type OperationInit = Omit<
|
||||
MutableOriginOperation,
|
||||
"id" | "progress" | "velocity" | "phase" | "intent" | "targetRect"
|
||||
>;
|
||||
/**
|
||||
* Progress at which a release commits when the choreography does not supply
|
||||
* its own `commitThreshold`.
|
||||
*/
|
||||
export const DEFAULT_COMMIT_THRESHOLD = 0.36;
|
||||
|
||||
/**
|
||||
* Reactive storage for a scene's nodes and operation edges.
|
||||
* Release velocity (progress units per second) that commits a flick at any
|
||||
* progress. Choreographies' `commitVelocity` is not currently consulted.
|
||||
*/
|
||||
export const FLICK_COMMIT_VELOCITY = 0.3;
|
||||
|
||||
/** Decide a release outcome from explicit options or progress/velocity. */
|
||||
export const shouldCommit = (
|
||||
operation: MutableOriginOperation,
|
||||
options: OriginFinishOptions,
|
||||
): boolean => {
|
||||
if (options.commit !== undefined) return options.commit;
|
||||
const threshold =
|
||||
operation.choreography.commitThreshold ?? DEFAULT_COMMIT_THRESHOLD;
|
||||
return (
|
||||
operation.progress >= threshold ||
|
||||
(operation.progress >= 0 && operation.velocity >= FLICK_COMMIT_VELOCITY)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* The scene model: nodes, operation edges, and the rules that rewrite them.
|
||||
*
|
||||
* Two independent structures share these records:
|
||||
*
|
||||
@@ -36,8 +62,8 @@ export type OperationInit = Omit<
|
||||
* 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. This class owns structural primitives only;
|
||||
* navigation semantics live in `transitions.ts`.
|
||||
* 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>());
|
||||
@@ -49,8 +75,7 @@ export class SceneGraph {
|
||||
* Connected resting edges temporarily removed by a back operation, keyed by
|
||||
* that back operation's id. Cancelling the back operation restores the edge.
|
||||
*/
|
||||
readonly suspendedEdges = new Map<number, MutableOriginOperation>();
|
||||
|
||||
private readonly suspendedEdges = new Map<number, MutableOriginOperation>();
|
||||
private nodeSequence = 0;
|
||||
private operationSequence = 0;
|
||||
|
||||
@@ -75,6 +100,21 @@ export class SceneGraph {
|
||||
: 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 {
|
||||
for (const operation of this.operations.values())
|
||||
@@ -122,9 +162,266 @@ export class SceneGraph {
|
||||
};
|
||||
}
|
||||
|
||||
// Mutations ---------------------------------------------------------------
|
||||
/**
|
||||
* 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";
|
||||
}
|
||||
|
||||
createNode(view: OriginView, previousNodeKey?: string): SceneNodeRecord {
|
||||
// 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 Error(
|
||||
"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.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.velocity = 0;
|
||||
operation.intent = "commit";
|
||||
operation.phase = "finished";
|
||||
origin.state = "exposed";
|
||||
this.activate(target);
|
||||
return true;
|
||||
}
|
||||
|
||||
this.transferPosition(origin, target);
|
||||
operation.phase = "finished";
|
||||
this.operations.delete(operation.id);
|
||||
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.
|
||||
this.removeNode(origin.key);
|
||||
}
|
||||
|
||||
this.activate(target);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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 suspendedEdge = this.suspendedEdges.get(id);
|
||||
operation.phase = "finished";
|
||||
this.operations.delete(id);
|
||||
this.suspendedEdges.delete(id);
|
||||
|
||||
if (operation.history === "back") {
|
||||
if (suspendedEdge && origin && target) {
|
||||
// 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);
|
||||
origin.incomingOperationId = suspendedEdge.id;
|
||||
target.state = "exposed";
|
||||
} else if (target) {
|
||||
target.incomingOperationId = undefined;
|
||||
target.state = "parked";
|
||||
}
|
||||
} else {
|
||||
this.removeBranch(operation.entryTargetKey);
|
||||
}
|
||||
|
||||
if (origin) this.activate(origin);
|
||||
}
|
||||
|
||||
// Transition steps --------------------------------------------------------
|
||||
|
||||
private assertCanOriginate(originKey: string): SceneNodeRecord {
|
||||
const origin = this.nodes.get(originKey);
|
||||
if (!origin)
|
||||
throw new Error(`Cannot animate from missing origin "${originKey}".`);
|
||||
if (origin.state === "parked" || origin.state === "exposed")
|
||||
throw new Error(
|
||||
`Cannot animate from ${origin.state} origin "${originKey}".`,
|
||||
);
|
||||
if (this.liveOutgoingOf(originKey))
|
||||
throw new Error(
|
||||
`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 Error(
|
||||
`Cannot go back from origin "${origin.key}" without a previous entry.`,
|
||||
);
|
||||
const target = this.nodes.get(previousKey);
|
||||
if (!target)
|
||||
throw new Error(
|
||||
`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 Error(
|
||||
"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.operations.delete(resting.id);
|
||||
this.transferPosition(target, origin);
|
||||
target.incomingOperationId = undefined;
|
||||
suspendedEdge = resting;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.liveOutgoingOf(target.key))
|
||||
throw new Error(
|
||||
`Retained target "${target.key}" already has an outgoing operation.`,
|
||||
);
|
||||
if (target.incomingOperationId)
|
||||
throw new Error(
|
||||
`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 Error(
|
||||
`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);
|
||||
}
|
||||
|
||||
private activate(node: SceneNodeRecord): void {
|
||||
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}`,
|
||||
@@ -138,7 +435,18 @@ export class SceneGraph {
|
||||
return node;
|
||||
}
|
||||
|
||||
createOperation(init: OperationInit): MutableOriginOperation {
|
||||
private createOperation(
|
||||
init: Pick<
|
||||
MutableOriginOperation,
|
||||
| "originKey"
|
||||
| "targetKey"
|
||||
| "entryTargetKey"
|
||||
| "choreography"
|
||||
| "placement"
|
||||
| "history"
|
||||
| "originRect"
|
||||
>,
|
||||
): MutableOriginOperation {
|
||||
const operation = reactive<MutableOriginOperation>({
|
||||
id: ++this.operationSequence,
|
||||
originKey: init.originKey,
|
||||
@@ -157,7 +465,7 @@ export class SceneGraph {
|
||||
return operation;
|
||||
}
|
||||
|
||||
removeNode(nodeKey: string): void {
|
||||
private removeNode(nodeKey: string): void {
|
||||
this.nodes.delete(nodeKey);
|
||||
this.roots.value = this.roots.value.filter((key) => key !== nodeKey);
|
||||
}
|
||||
@@ -166,7 +474,7 @@ export class SceneGraph {
|
||||
* Remove a node, every node whose retained history passes through it, and
|
||||
* every operation touching any of them. Earlier entries are untouched.
|
||||
*/
|
||||
removeBranch(nodeKey: string, visited = new Set<string>()): void {
|
||||
private removeBranch(nodeKey: string, visited = new Set<string>()): void {
|
||||
if (visited.has(nodeKey)) return;
|
||||
visited.add(nodeKey);
|
||||
|
||||
@@ -188,7 +496,7 @@ export class SceneGraph {
|
||||
* as the target of `from`'s incoming edge, or as a root. Neither Vue VNode
|
||||
* moves; only edge bookkeeping changes.
|
||||
*/
|
||||
transferPosition(from: SceneNodeRecord, target: SceneNodeRecord): void {
|
||||
private transferPosition(from: SceneNodeRecord, target: SceneNodeRecord) {
|
||||
const parent = this.incomingOf(from);
|
||||
if (parent) {
|
||||
parent.targetKey = target.key;
|
||||
|
||||
Reference in New Issue
Block a user