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