/** * @typedef {import('../BlueprintWorkspace.js').BlueprintWorkspace} BlueprintWorkspace * @typedef {ReturnType} GraphSnapshot */ /** * @typedef {Object} WorkspaceSnapshotSelection * @property {Array} nodes * @property {string | null} primary * @property {string | null} variable */ /** * @typedef {Object} WorkspaceSnapshotView * @property {{ x: number, y: number }} backgroundOffset * @property {number} zoom */ /** * @typedef {Object} SerializedWorkspace * @property {number} version * @property {{ use60Fps: boolean }} projectSettings * @property {GraphSnapshot} graph * @property {{ entries: Array<{ id: string, name: string, type: import('../BlueprintWorkspace.js').VariableType, defaultValue: import('../BlueprintWorkspace.js').VariableDefaultValue | null }>, counter: number }} variables * @property {number} spawnIndex * @property {{ paletteVisible: boolean }} [ui] */ /** * @typedef {Object} WorkspaceSnapshot * @property {SerializedWorkspace} workspace * @property {WorkspaceSnapshotSelection} selection * @property {WorkspaceSnapshotView} view */ const HISTORY_MAX_ENTRIES = 200; const HISTORY_DEBOUNCE_MS = 200; /** * Coordinates undo/redo history for the blueprint workspace. */ export class WorkspaceHistoryManager { #workspace; #timerId; #lastSerialized; #pendingDirty; #pendingReason; #suspendDepth; /** * @param {BlueprintWorkspace} workspace Owning workspace instance. */ constructor(workspace) { this.#workspace = workspace; /** @type {Array} */ this.undoStack = []; /** @type {Array} */ this.redoStack = []; this.isReady = false; this.#timerId = null; this.#lastSerialized = null; this.#pendingDirty = false; this.#pendingReason = null; this.#suspendDepth = 0; } /** * Resets undo/redo buffers and records the current workspace state. */ initialize() { this.#clearTimer(); this.undoStack = []; this.redoStack = []; this.#pendingDirty = false; this.#pendingReason = null; this.#suspendDepth = 0; const snapshot = this.#captureSnapshot(); this.undoStack.push(snapshot); this.#lastSerialized = JSON.stringify(snapshot); this.isReady = true; } /** * Clears undo/redo buffers without recording a snapshot. Used when the workspace is about to be rebuilt entirely. */ handleReset() { this.isReady = false; this.#clearTimer(); this.undoStack = []; this.redoStack = []; this.#pendingDirty = false; this.#pendingReason = null; this.#lastSerialized = null; this.#suspendDepth = 0; } /** * Records a mutation for undo/redo bookkeeping. * * @param {'nodeschanged'|'nodepropertychanged'|'connectionschanged'|'nodepositionchanged'} kind Mutation descriptor. */ registerMutation(kind) { if (!this.isReady || this.#workspace.isRestoring) { return; } if (kind === "nodepositionchanged") { this.markDirty(kind, HISTORY_DEBOUNCE_MS); return; } if (kind === "nodeschanged") { this.markDirty(kind, 0); return; } this.commitCheckpoint(kind); } /** * Schedules a deferred history checkpoint to coalesce rapid mutations. * * @param {string} reason Descriptive label for the mutation. * @param {number} [delay=HISTORY_DEBOUNCE_MS] Debounce duration. */ markDirty(reason, delay = HISTORY_DEBOUNCE_MS) { if (!this.isReady || this.#workspace.isRestoring) { return; } if (this.#suspendDepth > 0) { this.#pendingDirty = true; this.#pendingReason = reason; return; } if (delay <= 0 || typeof window === "undefined") { this.commitCheckpoint(reason); return; } this.#clearTimer(); this.#pendingDirty = true; this.#pendingReason = reason; this.#timerId = window.setTimeout(() => { this.#timerId = null; const pendingReason = this.#pendingReason ?? reason; this.#pendingDirty = false; this.#pendingReason = null; this.commitCheckpoint(pendingReason); }, delay); } /** * Captures and stores a new undo checkpoint immediately. * * @param {string} reason Descriptive label for the mutation. */ commitCheckpoint(reason) { if (!this.isReady || this.#workspace.isRestoring) { return; } if (this.#suspendDepth > 0) { this.#pendingDirty = true; this.#pendingReason = reason; return; } this.#clearTimer(); const snapshot = this.#captureSnapshot(); const serialized = JSON.stringify(snapshot); if (serialized === this.#lastSerialized) { return; } this.undoStack.push(snapshot); if (this.undoStack.length > HISTORY_MAX_ENTRIES + 1) { this.undoStack.shift(); } this.#lastSerialized = serialized; this.redoStack = []; this.#pendingDirty = false; this.#pendingReason = null; } /** * Executes a callback while temporarily suppressing automatic history commits. * * @template T * @param {() => T} callback Callback executed while suspended. * @param {{ suppressAutoCommit?: boolean }} [options] Behaviour overrides. * @returns {T} */ withSuspended(callback, options = {}) { const suppressAutoCommit = Boolean(options.suppressAutoCommit); this.#suspendDepth += 1; try { return callback(); } finally { this.#suspendDepth = Math.max(0, this.#suspendDepth - 1); if (this.#suspendDepth === 0 && this.#pendingDirty) { if (suppressAutoCommit) { this.#pendingDirty = false; this.#pendingReason = null; } else { const reason = this.#pendingReason ?? "batched"; this.#pendingDirty = false; this.#pendingReason = null; this.markDirty(reason, 0); } } } } /** * Reverts the workspace to the previous history entry, if available. */ undo() { if (!this.isReady || this.undoStack.length <= 1) { return; } this.#clearTimer(); const current = this.undoStack.pop(); if (!current) { return; } this.redoStack.push(current); const previous = this.undoStack[this.undoStack.length - 1]; if (!previous) { return; } this.#restoreSnapshot(previous); this.#lastSerialized = JSON.stringify(previous); } /** * Re-applies the most recently undone history entry, if available. */ redo() { if (!this.isReady || !this.redoStack.length) { return; } this.#clearTimer(); const snapshot = this.redoStack.pop(); if (!snapshot) { return; } this.undoStack.push(snapshot); this.#restoreSnapshot(snapshot); this.#lastSerialized = JSON.stringify(snapshot); } /** * Cancels any pending history timers. */ #clearTimer() { if (this.#timerId !== null && typeof window !== "undefined") { window.clearTimeout(this.#timerId); this.#timerId = null; } } /** * Captures a complete workspace snapshot, including selection and view state. * * @returns {WorkspaceSnapshot} */ #captureSnapshot() { const workspacePayload = /** @type {SerializedWorkspace} */ ( this.#workspace.serializeWorkspace() ); const selection = { nodes: [...this.#workspace.selectedNodeIds], primary: this.#workspace.selectedNodeId ?? null, variable: this.#workspace.selectedVariableId ?? null, }; const view = { backgroundOffset: { ...this.#workspace.workspaceBackgroundOffset }, zoom: this.#workspace.zoomLevel, }; return { workspace: workspacePayload, selection, view }; } /** * Restores the workspace to a previously captured snapshot. * * @param {WorkspaceSnapshot} snapshot Snapshot to restore. */ #restoreSnapshot(snapshot) { this.#clearTimer(); const wasReady = this.isReady; this.isReady = false; this.#pendingDirty = false; this.#pendingReason = null; this.#workspace.applySerializedWorkspace(snapshot.workspace); this.#workspace.applyHistorySelection(snapshot.selection); this.isReady = wasReady; } }