diff options
| author | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-02 23:51:34 +0900 |
|---|---|---|
| committer | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-02 23:51:34 +0900 |
| commit | e332019acb312ec64893c26cf3d797d5ce472f26 (patch) | |
| tree | d97eaca75ad6d1d41658854d6b80519f6cd1ab79 /public/bluebey-studio/src/history.js | |
| parent | 99204ebe327657ed4aaaa92d7f2d6e0cb04a6b3b (diff) | |
bluebey: ぶるべー スタジオのページを公開
Diffstat (limited to 'public/bluebey-studio/src/history.js')
| -rw-r--r-- | public/bluebey-studio/src/history.js | 105 |
1 files changed, 105 insertions, 0 deletions
diff --git a/public/bluebey-studio/src/history.js b/public/bluebey-studio/src/history.js new file mode 100644 index 0000000..d988b50 --- /dev/null +++ b/public/bluebey-studio/src/history.js @@ -0,0 +1,105 @@ +/** + * 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(' | '); + } +} |
