summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/lib/utils/undo_stack.js
blob: 6cfdc2a0a0fd342388bff072f607b69ad2c79a8e (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
/**
 * UndoStack provides a custom implementation of an undo/redo engine. It was originally written for GitLab's Markdown editor (`gl_form.js`), whose rich text editing capabilities broke native browser undo/redo behaviour.
 *
 * UndoStack supports predictable undos/redos, debounced saves, maximum history length, and duplicate detection.
 *
 * Usage:
 *   - `stack = new UndoStack();`
 *   - Saves a state to the stack with `stack.save(state)`.
 *   - Get the current state with `stack.current()`.
 *   - Revert to the previous state with `stack.undo()`.
 *   - Redo a previous undo with `stack.redo()`;
 *   - Queue a future save with `stack.scheduleSave(state, delay)`. Useful for text editors.
 *   - See the full undo history in `stack.history`.
 */
export default class UndoStack {
  constructor(maxLength = 1000) {
    this.clear();
    this.maxLength = maxLength;

    // If you're storing reference-types in the undo stack, you might want to
    // reassign this property to some deep-equals function.
    this.comparator = (a, b) => a === b;
  }

  current() {
    if (this.cursor === -1) {
      return undefined;
    }
    return this.history[this.cursor];
  }

  isEmpty() {
    return this.history.length === 0;
  }

  clear() {
    this.clearPending();
    this.history = [];
    this.cursor = -1;
  }

  save(state) {
    this.clearPending();
    if (this.comparator(state, this.current())) {
      // Don't save state if it's the same as the current state
      return;
    }

    this.history.length = this.cursor + 1;
    this.history.push(state);
    this.cursor += 1;

    if (this.history.length > this.maxLength) {
      this.history.shift();
      this.cursor -= 1;
    }
  }

  scheduleSave(state, delay = 1000) {
    this.clearPending();
    this.pendingState = state;
    this.timeout = setTimeout(this.saveNow.bind(this), delay);
  }

  saveNow() {
    // Persists scheduled saves immediately
    this.save(this.pendingState);
    this.clearPending();
  }

  clearPending() {
    // Cancels any scheduled saves
    if (this.timeout) {
      clearTimeout(this.timeout);
      delete this.timeout;
      delete this.pendingState;
    }
  }

  canUndo() {
    return this.cursor > 0;
  }

  undo() {
    this.clearPending();
    if (!this.canUndo()) {
      return undefined;
    }
    this.cursor -= 1;
    return this.history[this.cursor];
  }

  canRedo() {
    return this.cursor >= 0 && this.cursor < this.history.length - 1;
  }

  redo() {
    this.clearPending();
    if (!this.canRedo()) {
      return undefined;
    }
    this.cursor += 1;
    return this.history[this.cursor];
  }
}