1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
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(' | ');
}
}
|