/** * Undo / redo for the whole studio state. * * The studio is a big pile of sliders, and before this every experiment was * one-way: nudging the wrong slider meant dialling the old value back by hand. * A history of whole-state snapshots is the simplest thing that can possibly * work here, because the state is already the single source of truth and every * control funnels through `applyState`. * * Two details matter for it to feel right rather than merely correct: * * - Dragging a slider fires an event per pixel, which would bury the history in * hundreds of near-identical entries. Entries therefore carry a label, and a * new entry with the *same* label within `coalesceMs` REPLACES the previous * one instead of stacking on top of it. So a whole drag becomes one step. * * - `view.backgroundImage` can be a multi-megabyte data URL, and `state` also * holds the caption text. The snapshots copy objects by hand rather than via * `JSON.parse(JSON.stringify(...))`, because assigning a string in JavaScript * shares it instead of duplicating it - so a hundred snapshots of a heavy * state stay cheap. */ const SHALLOW_TYPES = new Set(['string', 'number', 'boolean', 'undefined']); /** Deep copy that shares string data (and handles the odd null/array). */ function copy(value) { if (value === null || SHALLOW_TYPES.has(typeof value)) return value; if (Array.isArray(value)) return value.map(copy); if (typeof value === 'object') { const out = {}; for (const [key, inner] of Object.entries(value)) out[key] = copy(inner); return out; } return value; // functions, symbols: not part of the saved state } export class History { constructor({ limit = 120, coalesceMs = 700, onChange = null } = {}) { this.limit = Math.max(2, limit); this.coalesceMs = coalesceMs; this.onChange = onChange; /** @type {{ state: object, label: string, at: number }[]} */ this.entries = []; this.index = -1; } /** Forget everything and start from `state` (call after load/reset). */ reset(state, label = 'start') { this.entries = [{ state: copy(state), label, at: Date.now() }]; this.index = 0; this.onChange?.(this); } get canUndo() { return this.index > 0; } get canRedo() { return this.index >= 0 && this.index < this.entries.length - 1; } /** Label of the step undo would jump to, for the button tooltip. */ get undoLabel() { return this.canUndo ? this.entries[this.index].label : null; } get redoLabel() { return this.canRedo ? this.entries[this.index + 1].label : null; } /** * Record the state *after* a change. Repeating the same label in quick * succession (a slider drag) keeps a single entry that follows the value. */ push(state, label = '変更') { const now = Date.now(); const top = this.entries[this.index]; const sameDrag = top && top.label === label && now - top.at <= this.coalesceMs && this.index === this.entries.length - 1; if (sameDrag) { this.entries[this.index] = { state: copy(state), label, at: now }; } else { this.entries.length = this.index + 1; this.entries.push({ state: copy(state), label, at: now }); if (this.entries.length > this.limit) this.entries.shift(); this.index = this.entries.length - 1; } this.onChange?.(this); return this; } /** The previous snapshot, or `null` when there is nothing to go back to. */ undo() { if (!this.canUndo) return null; this.index -= 1; this.onChange?.(this); return copy(this.entries[this.index].state); } redo() { if (!this.canRedo) return null; this.index += 1; this.onChange?.(this); return copy(this.entries[this.index].state); } /** A plain description of where we are, for tests and debug output. */ describe() { return this.entries.map((entry, i) => `${i === this.index ? '*' : ' '}${entry.label}`).join(' | '); } }