/
history.ts
397 lines (347 loc) · 15.9 KB
/
history.ts
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
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
import {combineConfig, EditorState, Transaction, StateField, StateCommand, StateEffect,
Facet, Annotation, Extension, ChangeSet, ChangeDesc, EditorSelection} from "@codemirror/state"
import {KeyBinding, EditorView} from "@codemirror/view"
const enum BranchName { Done, Undone }
const fromHistory = Annotation.define<{side: BranchName, rest: Branch, selection: EditorSelection}>()
/// Transaction annotation that will prevent that transaction from
/// being combined with other transactions in the undo history. Given
/// `"before"`, it'll prevent merging with previous transactions. With
/// `"after"`, subsequent transactions won't be combined with this
/// one. With `"full"`, the transaction is isolated on both sides.
export const isolateHistory = Annotation.define<"before" | "after" | "full">()
/// This facet provides a way to register functions that, given a
/// transaction, provide a set of effects that the history should
/// store when inverting the transaction. This can be used to
/// integrate some kinds of effects in the history, so that they can
/// be undone (and redone again).
export const invertedEffects = Facet.define<(tr: Transaction) => readonly StateEffect<any>[]>()
interface HistoryConfig {
/// The minimum depth (amount of events) to store. Defaults to 100.
minDepth?: number
/// The maximum time (in milliseconds) that adjacent events can be
/// apart and still be grouped together. Defaults to 500.
newGroupDelay?: number
/// By default, when close enough together in time, changes are
/// joined into an existing undo event if they touch any of the
/// changed ranges from that event. You can pass a custom predicate
/// here to influence that logic.
joinToEvent?: (tr: Transaction, isAdjacent: boolean) => boolean
}
const historyConfig = Facet.define<HistoryConfig, Required<HistoryConfig>>({
combine(configs) {
return combineConfig(configs, {
minDepth: 100,
newGroupDelay: 500,
joinToEvent: (_t, isAdjacent) => isAdjacent,
}, {
minDepth: Math.max,
newGroupDelay: Math.min,
joinToEvent: (a, b) => (tr, adj) => a(tr, adj) || b(tr, adj)
})
}
})
const historyField_ = StateField.define({
create() {
return HistoryState.empty
},
update(state: HistoryState, tr: Transaction): HistoryState {
let config = tr.state.facet(historyConfig)
let fromHist = tr.annotation(fromHistory)
if (fromHist) {
let item = HistEvent.fromTransaction(tr, fromHist.selection), from = fromHist.side
let other = from == BranchName.Done ? state.undone : state.done
if (item) other = updateBranch(other, other.length, config.minDepth, item)
else other = addSelection(other, tr.startState.selection)
return new HistoryState(from == BranchName.Done ? fromHist.rest : other,
from == BranchName.Done ? other : fromHist.rest)
}
let isolate = tr.annotation(isolateHistory)
if (isolate == "full" || isolate == "before") state = state.isolate()
if (tr.annotation(Transaction.addToHistory) === false)
return !tr.changes.empty ? state.addMapping(tr.changes.desc) : state
let event = HistEvent.fromTransaction(tr)
let time = tr.annotation(Transaction.time)!, userEvent = tr.annotation(Transaction.userEvent)
if (event)
state = state.addChanges(event, time, userEvent, config, tr)
else if (tr.selection)
state = state.addSelection(tr.startState.selection, time, userEvent, config.newGroupDelay)
if (isolate == "full" || isolate == "after") state = state.isolate()
return state
},
toJSON(value) {
return {done: value.done.map(e => e.toJSON()), undone: value.undone.map(e => e.toJSON())}
},
fromJSON(json) {
return new HistoryState(json.done.map(HistEvent.fromJSON), json.undone.map(HistEvent.fromJSON))
}
})
/// Create a history extension with the given configuration.
export function history(config: HistoryConfig = {}): Extension {
return [
historyField_,
historyConfig.of(config),
EditorView.domEventHandlers({
beforeinput(e, view) {
let command = e.inputType == "historyUndo" ? undo : e.inputType == "historyRedo" ? redo : null
if (!command) return false
e.preventDefault()
return command(view)
}
})
]
}
/// The state field used to store the history data. Should probably
/// only be used when you want to
/// [serialize](#state.EditorState.toJSON) or
/// [deserialize](#state.EditorState^fromJSON) state objects in a way
/// that preserves history.
export const historyField = historyField_ as StateField<unknown>
function cmd(side: BranchName, selection: boolean): StateCommand {
return function({state, dispatch}: {state: EditorState, dispatch: (tr: Transaction) => void}) {
if (!selection && state.readOnly) return false
let historyState = state.field(historyField_, false)
if (!historyState) return false
let tr = historyState.pop(side, state, selection)
if (!tr) return false
dispatch(tr)
return true
}
}
/// Undo a single group of history events. Returns false if no group
/// was available.
export const undo = cmd(BranchName.Done, false)
/// Redo a group of history events. Returns false if no group was
/// available.
export const redo = cmd(BranchName.Undone, false)
/// Undo a change or selection change.
export const undoSelection = cmd(BranchName.Done, true)
/// Redo a change or selection change.
export const redoSelection = cmd(BranchName.Undone, true)
function depth(side: BranchName) {
return function(state: EditorState): number {
let histState = state.field(historyField_, false)
if (!histState) return 0
let branch = side == BranchName.Done ? histState.done : histState.undone
return branch.length - (branch.length && !branch[0].changes ? 1 : 0)
}
}
/// The amount of undoable change events available in a given state.
export const undoDepth = depth(BranchName.Done)
/// The amount of redoable change events available in a given state.
export const redoDepth = depth(BranchName.Undone)
// History events store groups of changes or effects that need to be
// undone/redone together.
class HistEvent {
constructor(
// The changes in this event. Normal events hold at least one
// change or effect. But it may be necessary to store selection
// events before the first change, in which case a special type of
// instance is created which doesn't hold any changes, with
// changes == startSelection == undefined
readonly changes: ChangeSet | undefined,
// The effects associated with this event
readonly effects: readonly StateEffect<any>[],
// Accumulated mapping (from addToHistory==false) that should be
// applied to events below this one.
readonly mapped: ChangeDesc | undefined,
// The selection before this event
readonly startSelection: EditorSelection | undefined,
// Stores selection changes after this event, to be used for
// selection undo/redo.
readonly selectionsAfter: readonly EditorSelection[]
) {}
setSelAfter(after: readonly EditorSelection[]) {
return new HistEvent(this.changes, this.effects, this.mapped, this.startSelection, after)
}
toJSON() {
return {
changes: this.changes?.toJSON(),
mapped: this.mapped?.toJSON(),
startSelection: this.startSelection?.toJSON(),
selectionsAfter: this.selectionsAfter.map(s => s.toJSON())
}
}
static fromJSON(json: any) {
return new HistEvent(
json.changes && ChangeSet.fromJSON(json.changes),
[],
json.mapped && ChangeDesc.fromJSON(json.mapped),
json.startSelection && EditorSelection.fromJSON(json.startSelection),
json.selectionsAfter.map(EditorSelection.fromJSON)
)
}
// This does not check `addToHistory` and such, it assumes the
// transaction needs to be converted to an item. Returns null when
// there are no changes or effects in the transaction.
static fromTransaction(tr: Transaction, selection?: EditorSelection) {
let effects: readonly StateEffect<any>[] = none
for (let invert of tr.startState.facet(invertedEffects)) {
let result = invert(tr)
if (result.length) effects = effects.concat(result)
}
if (!effects.length && tr.changes.empty) return null
return new HistEvent(tr.changes.invert(tr.startState.doc), effects, undefined, selection || tr.startState.selection, none)
}
static selection(selections: readonly EditorSelection[]) {
return new HistEvent(undefined, none, undefined, undefined, selections)
}
}
type Branch = readonly HistEvent[]
function updateBranch(branch: Branch, to: number, maxLen: number, newEvent: HistEvent) {
let start = to + 1 > maxLen + 20 ? to - maxLen - 1 : 0
let newBranch = branch.slice(start, to)
newBranch.push(newEvent)
return newBranch
}
function isAdjacent(a: ChangeDesc, b: ChangeDesc): boolean {
let ranges: number[] = [], isAdjacent = false
a.iterChangedRanges((f, t) => ranges.push(f, t))
b.iterChangedRanges((_f, _t, f, t) => {
for (let i = 0; i < ranges.length;) {
let from = ranges[i++], to = ranges[i++]
if (t >= from && f <= to) isAdjacent = true
}
})
return isAdjacent
}
function eqSelectionShape(a: EditorSelection, b: EditorSelection) {
return a.ranges.length == b.ranges.length &&
a.ranges.filter((r, i) => r.empty != b.ranges[i].empty).length === 0
}
function conc<T>(a: readonly T[], b: readonly T[]) {
return !a.length ? b : !b.length ? a : a.concat(b)
}
const none: readonly any[] = []
const MaxSelectionsPerEvent = 200
function addSelection(branch: Branch, selection: EditorSelection) {
if (!branch.length) {
return [HistEvent.selection([selection])]
} else {
let lastEvent = branch[branch.length - 1]
let sels = lastEvent.selectionsAfter.slice(Math.max(0, lastEvent.selectionsAfter.length - MaxSelectionsPerEvent))
if (sels.length && sels[sels.length - 1].eq(selection)) return branch
sels.push(selection)
return updateBranch(branch, branch.length - 1, 1e9, lastEvent.setSelAfter(sels))
}
}
// Assumes the top item has one or more selectionAfter values
function popSelection(branch: Branch): Branch {
let last = branch[branch.length - 1]
let newBranch = branch.slice()
newBranch[branch.length - 1] = last.setSelAfter(last.selectionsAfter.slice(0, last.selectionsAfter.length - 1))
return newBranch
}
// Add a mapping to the top event in the given branch. If this maps
// away all the changes and effects in that item, drop it and
// propagate the mapping to the next item.
function addMappingToBranch(branch: Branch, mapping: ChangeDesc) {
if (!branch.length) return branch
let length = branch.length, selections = none
while (length) {
let event = mapEvent(branch[length - 1], mapping, selections)
if (event.changes && !event.changes.empty || event.effects.length) { // Event survived mapping
let result = branch.slice(0, length)
result[length - 1] = event
return result
} else { // Drop this event, since there's no changes or effects left
mapping = event.mapped!
length--
selections = event.selectionsAfter
}
}
return selections.length ? [HistEvent.selection(selections)] : none
}
function mapEvent(event: HistEvent, mapping: ChangeDesc,
extraSelections: readonly EditorSelection[]) {
let selections = conc(event.selectionsAfter.length ? event.selectionsAfter.map(s => s.map(mapping)) : none,
extraSelections)
// Change-less events don't store mappings (they are always the last event in a branch)
if (!event.changes) return HistEvent.selection(selections)
let mappedChanges = event.changes.map(mapping), before = mapping.mapDesc(event.changes, true)
let fullMapping = event.mapped ? event.mapped.composeDesc(before) : before
return new HistEvent(mappedChanges, StateEffect.mapEffects(event.effects, mapping),
fullMapping, event.startSelection!.map(before), selections)
}
const joinableUserEvent = /^(input\.type|delete)($|\.)/
class HistoryState {
constructor(public readonly done: Branch,
public readonly undone: Branch,
private readonly prevTime: number = 0,
private readonly prevUserEvent: string | undefined = undefined) {}
isolate() {
return this.prevTime ? new HistoryState(this.done, this.undone) : this
}
addChanges(event: HistEvent, time: number, userEvent: string | undefined,
config: Required<HistoryConfig>, tr: Transaction): HistoryState {
let done = this.done, lastEvent = done[done.length - 1]
if (lastEvent && lastEvent.changes && !lastEvent.changes.empty && event.changes &&
(!userEvent || joinableUserEvent.test(userEvent)) &&
((!lastEvent.selectionsAfter.length &&
time - this.prevTime < config.newGroupDelay &&
config.joinToEvent(tr, isAdjacent(lastEvent.changes, event.changes))) ||
// For compose (but not compose.start) events, always join with previous event
userEvent == "input.type.compose")) {
done = updateBranch(done, done.length - 1, config.minDepth,
new HistEvent(event.changes.compose(lastEvent.changes), conc(event.effects, lastEvent.effects),
lastEvent.mapped, lastEvent.startSelection, none))
} else {
done = updateBranch(done, done.length, config.minDepth, event)
}
return new HistoryState(done, none, time, userEvent)
}
addSelection(selection: EditorSelection, time: number, userEvent: string | undefined, newGroupDelay: number) {
let last = this.done.length ? this.done[this.done.length - 1].selectionsAfter : none
if (last.length > 0 &&
time - this.prevTime < newGroupDelay &&
userEvent == this.prevUserEvent && userEvent && /^select($|\.)/.test(userEvent) &&
eqSelectionShape(last[last.length - 1], selection))
return this
return new HistoryState(addSelection(this.done, selection), this.undone, time, userEvent)
}
addMapping(mapping: ChangeDesc): HistoryState {
return new HistoryState(addMappingToBranch(this.done, mapping),
addMappingToBranch(this.undone, mapping),
this.prevTime, this.prevUserEvent)
}
pop(side: BranchName, state: EditorState, onlySelection: boolean): Transaction | null {
let branch = side == BranchName.Done ? this.done : this.undone
if (branch.length == 0) return null
let event = branch[branch.length - 1], selection = event.selectionsAfter[0] || state.selection
if (onlySelection && event.selectionsAfter.length) {
return state.update({
selection: event.selectionsAfter[event.selectionsAfter.length - 1],
annotations: fromHistory.of({side, rest: popSelection(branch), selection}),
userEvent: side == BranchName.Done ? "select.undo" : "select.redo",
scrollIntoView: true
})
} else if (!event.changes) {
return null
} else {
let rest = branch.length == 1 ? none : branch.slice(0, branch.length - 1)
if (event.mapped) rest = addMappingToBranch(rest, event.mapped!)
return state.update({
changes: event.changes,
selection: event.startSelection,
effects: event.effects,
annotations: fromHistory.of({side, rest, selection}),
filter: false,
userEvent: side == BranchName.Done ? "undo" : "redo",
scrollIntoView: true
})
}
}
static empty: HistoryState = new HistoryState(none, none)
}
/// Default key bindings for the undo history.
///
/// - Mod-z: [`undo`](#commands.undo).
/// - Mod-y (Mod-Shift-z on macOS) + Ctrl-Shift-z on Linux: [`redo`](#commands.redo).
/// - Mod-u: [`undoSelection`](#commands.undoSelection).
/// - Alt-u (Mod-Shift-u on macOS): [`redoSelection`](#commands.redoSelection).
export const historyKeymap: readonly KeyBinding[] = [
{key: "Mod-z", run: undo, preventDefault: true},
{key: "Mod-y", mac: "Mod-Shift-z", run: redo, preventDefault: true},
{linux: "Ctrl-Shift-z", run: redo, preventDefault: true},
{key: "Mod-u", run: undoSelection, preventDefault: true},
{key: "Alt-u", mac: "Mod-Shift-u", run: redoSelection, preventDefault: true}
]