Files
picoGraph/scripts/ui/workspace/WorkspaceHistoryManager.js

310 lines
8.0 KiB
JavaScript

/**
* @typedef {import('../BlueprintWorkspace.js').BlueprintWorkspace} BlueprintWorkspace
* @typedef {ReturnType<import('../../core/NodeGraph.js').NodeGraph['toJSON']>} GraphSnapshot
*/
/**
* @typedef {Object} WorkspaceSnapshotSelection
* @property {Array<string>} 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<WorkspaceSnapshot>} */
this.undoStack = [];
/** @type {Array<WorkspaceSnapshot>} */
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;
}
}