|
| 1 | +import { |
| 2 | + history, |
| 3 | + invertedEffects, |
| 4 | + isolateHistory, |
| 5 | + redo, |
| 6 | + undo, |
| 7 | +} from '@codemirror/commands' |
| 8 | +import { |
| 9 | + Compartment, |
| 10 | + StateEffect, |
| 11 | + Transaction, |
| 12 | + type Extension, |
| 13 | + type TransactionSpec, |
| 14 | +} from '@codemirror/state' |
| 15 | +import { EditorView } from 'codemirror' |
| 16 | + |
| 17 | +/** Any EditorView that wants to be a local history target must use this extension compartment */ |
| 18 | +export const localHistoryTarget = new Compartment() |
| 19 | +/** |
| 20 | + * Extensions which want to dispatch global history transactions |
| 21 | + * must use this limited transaction type. We don't want any developers |
| 22 | + * mistaking the globalHistoryView for a normal document-oriented EditorView. |
| 23 | + */ |
| 24 | +export type TransactionSpecNoChanges = TransactionSpec & { |
| 25 | + changes?: undefined |
| 26 | + selection?: undefined |
| 27 | +} |
| 28 | + |
| 29 | +const globalHistory = new Compartment() |
| 30 | +type HistoryRequest = { |
| 31 | + historySource: EditorView |
| 32 | + request: 'new' | 'undo' | 'redo' |
| 33 | +} |
| 34 | +const globalHistoryRequest = StateEffect.define<HistoryRequest>() |
| 35 | + |
| 36 | +/** |
| 37 | + * A locked-down CodeMirror EditorView that is meant for |
| 38 | + * global application effects that we want to be un/redoable. |
| 39 | + * |
| 40 | + * It can be created without a "local target history" EditorView registered, |
| 41 | + * and receive transactions that are un/redoable. If it has a registered "local target history", |
| 42 | + * any transactions the HistoryView receives will be interleaved into the local target's history |
| 43 | + * as "global history request" StateEffects. |
| 44 | + * |
| 45 | + * The HistoryView is then intended to be used as a fallback for "local history" un/redo commands: |
| 46 | + * - If there is un/redo available, the local history only fires |
| 47 | + * - If this is a forwarded "global history request", then yay we un/redo the global history. |
| 48 | + * - If there is no local history, we try out the global history. |
| 49 | + * |
| 50 | + * This allows us to do keep around this global history for longer than the life of any given |
| 51 | + * "local history" EditorView, but still get to interleave them with normal local history! |
| 52 | + */ |
| 53 | +export class HistoryView { |
| 54 | + private editorView: EditorView |
| 55 | + |
| 56 | + constructor(extensions: Extension[]) { |
| 57 | + this.editorView = new EditorView({ |
| 58 | + extensions: [history(), globalHistory.of([]), ...extensions], |
| 59 | + }) |
| 60 | + } |
| 61 | + |
| 62 | + registerLocalHistoryTarget(target: EditorView) { |
| 63 | + this.editorView.dispatch({ |
| 64 | + effects: [ |
| 65 | + globalHistory.reconfigure([this.buildForwardingExtension(target)]), |
| 66 | + ], |
| 67 | + }) |
| 68 | + target.dispatch({ |
| 69 | + effects: [localHistoryTarget.reconfigure(this.localHistoryExtension())], |
| 70 | + annotations: [Transaction.addToHistory.of(false)], |
| 71 | + }) |
| 72 | + } |
| 73 | + |
| 74 | + // Used by `undo()` function |
| 75 | + get state() { |
| 76 | + return this.editorView.state |
| 77 | + } |
| 78 | + // Used by `undo()` function |
| 79 | + dispatch = (tr: Transaction | TransactionSpec) => this.editorView.dispatch(tr) |
| 80 | + |
| 81 | + /** |
| 82 | + * Type errors will occur if a developer tries to include `changes` or `selection`. |
| 83 | + * The HistoryView should really only contain effectful, reversible transactions |
| 84 | + */ |
| 85 | + limitedDispatch = (spec: TransactionSpecNoChanges) => { |
| 86 | + this.editorView.dispatch(spec) |
| 87 | + } |
| 88 | + |
| 89 | + /** Extensions attached to a local history target */ |
| 90 | + private localHistoryExtension(): Extension { |
| 91 | + /** |
| 92 | + * Extension attached to a local history source to turn effects pointing |
| 93 | + * to "global history requests" into actual history commands on the global history. |
| 94 | + */ |
| 95 | + const localHistoryEffect = EditorView.updateListener.of((vu) => { |
| 96 | + for (const tr of vu.transactions) { |
| 97 | + for (const e of tr.effects) { |
| 98 | + if (e.is(globalHistoryRequest)) { |
| 99 | + e.value.request === 'undo' |
| 100 | + ? undo(e.value.historySource) |
| 101 | + : redo(e.value.historySource) |
| 102 | + } |
| 103 | + } |
| 104 | + } |
| 105 | + }) |
| 106 | + |
| 107 | + /** |
| 108 | + * Extension attached to local history source to make "global history request" |
| 109 | + * effects undoable. The value simply changes whether an undo or redo is called |
| 110 | + * on the global history source. |
| 111 | + */ |
| 112 | + const localHistoryInvertedEffect = invertedEffects.of((tr) => { |
| 113 | + const found: StateEffect<unknown>[] = [] |
| 114 | + for (const e of tr.effects) { |
| 115 | + if (e.is(globalHistoryRequest)) { |
| 116 | + found.push( |
| 117 | + globalHistoryRequest.of({ |
| 118 | + historySource: e.value.historySource, |
| 119 | + request: e.value.request === 'undo' ? 'redo' : 'undo', |
| 120 | + }) |
| 121 | + ) |
| 122 | + } |
| 123 | + } |
| 124 | + return found |
| 125 | + }) |
| 126 | + |
| 127 | + return [localHistoryEffect, localHistoryInvertedEffect] |
| 128 | + } |
| 129 | + |
| 130 | + /** |
| 131 | + * Extension attached to global history source that will |
| 132 | + * forward any updates it receives to a `targetView` with local history. |
| 133 | + */ |
| 134 | + private buildForwardingExtension(targetView: EditorView): Extension { |
| 135 | + return EditorView.updateListener.of((vu) => { |
| 136 | + for (const tr of vu.transactions) { |
| 137 | + // We don't want to forward undo and redo events because then |
| 138 | + // we'll just be in a loop with our target local history. |
| 139 | + if (tr.isUserEvent('undo') || tr.isUserEvent('redo')) { |
| 140 | + continue |
| 141 | + } |
| 142 | + |
| 143 | + targetView.dispatch({ |
| 144 | + effects: [ |
| 145 | + globalHistoryRequest.of({ |
| 146 | + historySource: this.editorView, |
| 147 | + request: 'redo', |
| 148 | + }), |
| 149 | + ], |
| 150 | + annotations: [ |
| 151 | + // These "global history request" transactions should never be grouped. |
| 152 | + isolateHistory.of('full'), |
| 153 | + ], |
| 154 | + }) |
| 155 | + } |
| 156 | + }) |
| 157 | + } |
| 158 | +} |
0 commit comments