aboutsummaryrefslogtreecommitdiffhomepage
path: root/bluebey-studio/src/history.js
blob: d988b50fc42c7b2d53a861e2eb63fef18f1b48a9 (plain)
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(' | ');
  }
}