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
653 lines
23 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|