aboutsummaryrefslogtreecommitdiffhomepage
path: root/bluebey-studio/src/history.js
diff options
context:
space:
mode:
authorYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-07 23:09:40 +0900
committerYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-07 23:09:40 +0900
commitbc2821adb11a30244fc4663f4fafb756877d9508 (patch)
treefea8d30d2eb1e67f895097dbf7ec4d152361243e /bluebey-studio/src/history.js
parent0d50c5ede0812ba7b67b775c9cd0bf52d7b65e02 (diff)
bluebey-studio: public/ の外へ移動し非公開化
Diffstat (limited to 'bluebey-studio/src/history.js')
-rw-r--r--bluebey-studio/src/history.js105
1 files changed, 105 insertions, 0 deletions
diff --git a/bluebey-studio/src/history.js b/bluebey-studio/src/history.js
new file mode 100644
index 0000000..d988b50
--- /dev/null
+++ b/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(' | ');
+ }
+}