history.ts (17869B)
1 import RopeSequence from "rope-sequence" 2 import {Mapping, Step, StepMap, Transform} from "prosemirror-transform" 3 import {Plugin, Command, PluginKey, EditorState, Transaction, SelectionBookmark} from "prosemirror-state" 4 5 // ProseMirror's history isn't simply a way to roll back to a previous 6 // state, because ProseMirror supports applying changes without adding 7 // them to the history (for example during collaboration). 8 // 9 // To this end, each 'Branch' (one for the undo history and one for 10 // the redo history) keeps an array of 'Items', which can optionally 11 // hold a step (an actual undoable change), and always hold a position 12 // map (which is needed to move changes below them to apply to the 13 // current document). 14 // 15 // An item that has both a step and a selection bookmark is the start 16 // of an 'event' — a group of changes that will be undone or redone at 17 // once. (It stores only the bookmark, since that way we don't have to 18 // provide a document until the selection is actually applied, which 19 // is useful when compressing.) 20 21 // Used to schedule history compression 22 const max_empty_items = 500 23 24 class Branch { 25 constructor(readonly items: RopeSequence<Item>, readonly eventCount: number) {} 26 27 // Pop the latest event off the branch's history and apply it 28 // to a document transform. 29 popEvent(state: EditorState, preserveItems: boolean) { 30 if (this.eventCount == 0) return null 31 32 let end = this.items.length 33 for (;; end--) { 34 let next = this.items.get(end - 1) 35 if (next.selection) { --end; break } 36 } 37 38 let remap: Mapping | undefined, mapFrom: number | undefined 39 if (preserveItems) { 40 remap = this.remapping(end, this.items.length) 41 mapFrom = remap.maps.length 42 } 43 let transform = state.tr 44 let selection: SelectionBookmark | undefined, remaining: Branch | undefined 45 let addAfter: Item[] = [], addBefore: Item[] = [] 46 47 this.items.forEach((item, i) => { 48 if (!item.step) { 49 if (!remap) { 50 remap = this.remapping(end, i + 1) 51 mapFrom = remap.maps.length 52 } 53 mapFrom!-- 54 addBefore.push(item) 55 return 56 } 57 58 if (remap) { 59 addBefore.push(new Item(item.map)) 60 let step = item.step.map(remap.slice(mapFrom)), map 61 62 if (step && transform.maybeStep(step).doc) { 63 map = transform.mapping.maps[transform.mapping.maps.length - 1] 64 addAfter.push(new Item(map, undefined, undefined, addAfter.length + addBefore.length)) 65 } 66 mapFrom!-- 67 if (map) remap.appendMap(map, mapFrom) 68 } else { 69 transform.maybeStep(item.step) 70 } 71 72 if (item.selection) { 73 selection = remap ? item.selection.map(remap.slice(mapFrom)) : item.selection 74 remaining = new Branch(this.items.slice(0, end).append(addBefore.reverse().concat(addAfter)), this.eventCount - 1) 75 return false 76 } 77 }, this.items.length, 0) 78 79 return {remaining: remaining!, transform, selection: selection!} 80 } 81 82 // Create a new branch with the given transform added. 83 addTransform(transform: Transform, selection: SelectionBookmark | undefined, 84 histOptions: Required<HistoryOptions>, preserveItems: boolean) { 85 let newItems = [], eventCount = this.eventCount 86 let oldItems = this.items, lastItem = !preserveItems && oldItems.length ? oldItems.get(oldItems.length - 1) : null 87 88 for (let i = 0; i < transform.steps.length; i++) { 89 let step = transform.steps[i].invert(transform.docs[i]) 90 let item = new Item(transform.mapping.maps[i], step, selection), merged 91 if (merged = lastItem && lastItem.merge(item)) { 92 item = merged 93 if (i) newItems.pop() 94 else oldItems = oldItems.slice(0, oldItems.length - 1) 95 } 96 newItems.push(item) 97 if (selection) { 98 eventCount++ 99 selection = undefined 100 } 101 if (!preserveItems) lastItem = item 102 } 103 let overflow = eventCount - histOptions.depth 104 if (overflow > DEPTH_OVERFLOW) { 105 oldItems = cutOffEvents(oldItems, overflow) 106 eventCount -= overflow 107 } 108 return new Branch(oldItems.append(newItems), eventCount) 109 } 110 111 remapping(from: number, to: number): Mapping { 112 let maps = new Mapping 113 this.items.forEach((item, i) => { 114 let mirrorPos = item.mirrorOffset != null && i - item.mirrorOffset >= from 115 ? maps.maps.length - item.mirrorOffset : undefined 116 maps.appendMap(item.map, mirrorPos) 117 }, from, to) 118 return maps 119 } 120 121 addMaps(array: readonly StepMap[]) { 122 if (this.eventCount == 0) return this 123 return new Branch(this.items.append(array.map(map => new Item(map))), this.eventCount) 124 } 125 126 // When the collab module receives remote changes, the history has 127 // to know about those, so that it can adjust the steps that were 128 // rebased on top of the remote changes, and include the position 129 // maps for the remote changes in its array of items. 130 rebased(rebasedTransform: Transform, rebasedCount: number) { 131 if (!this.eventCount) return this 132 133 let rebasedItems: Item[] = [], start = Math.max(0, this.items.length - rebasedCount) 134 135 let mapping = rebasedTransform.mapping 136 let newUntil = rebasedTransform.steps.length 137 let eventCount = this.eventCount 138 this.items.forEach(item => { if (item.selection) eventCount-- }, start) 139 140 let iRebased = rebasedCount 141 this.items.forEach(item => { 142 let pos = mapping.getMirror(--iRebased) 143 if (pos == null) return 144 newUntil = Math.min(newUntil, pos) 145 let map = mapping.maps[pos] 146 if (item.step) { 147 let step = rebasedTransform.steps[pos].invert(rebasedTransform.docs[pos]) 148 let selection = item.selection && item.selection.map(mapping.slice(iRebased + 1, pos)) 149 if (selection) eventCount++ 150 rebasedItems.push(new Item(map, step, selection)) 151 } else { 152 rebasedItems.push(new Item(map)) 153 } 154 }, start) 155 156 let newMaps = [] 157 for (let i = rebasedCount; i < newUntil; i++) 158 newMaps.push(new Item(mapping.maps[i])) 159 let items = this.items.slice(0, start).append(newMaps).append(rebasedItems) 160 let branch = new Branch(items, eventCount) 161 162 if (branch.emptyItemCount() > max_empty_items) 163 branch = branch.compress(this.items.length - rebasedItems.length) 164 return branch 165 } 166 167 emptyItemCount() { 168 let count = 0 169 this.items.forEach(item => { if (!item.step) count++ }) 170 return count 171 } 172 173 // Compressing a branch means rewriting it to push the air (map-only 174 // items) out. During collaboration, these naturally accumulate 175 // because each remote change adds one. The `upto` argument is used 176 // to ensure that only the items below a given level are compressed, 177 // because `rebased` relies on a clean, untouched set of items in 178 // order to associate old items with rebased steps. 179 compress(upto = this.items.length) { 180 let remap = this.remapping(0, upto), mapFrom = remap.maps.length 181 let items: Item[] = [], events = 0 182 this.items.forEach((item, i) => { 183 if (i >= upto) { 184 items.push(item) 185 if (item.selection) events++ 186 } else if (item.step) { 187 let step = item.step.map(remap.slice(mapFrom)), map = step && step.getMap() 188 mapFrom-- 189 if (map) remap.appendMap(map, mapFrom) 190 if (step) { 191 let selection = item.selection && item.selection.map(remap.slice(mapFrom)) 192 if (selection) events++ 193 let newItem = new Item(map!.invert(), step, selection), merged, last = items.length - 1 194 if (merged = items.length && items[last].merge(newItem)) 195 items[last] = merged 196 else 197 items.push(newItem) 198 } 199 } else if (item.map) { 200 mapFrom-- 201 } 202 }, this.items.length, 0) 203 return new Branch(RopeSequence.from(items.reverse()), events) 204 } 205 206 static empty = new Branch(RopeSequence.empty, 0) 207 } 208 209 function cutOffEvents(items: RopeSequence<Item>, n: number) { 210 let cutPoint: number | undefined 211 items.forEach((item, i) => { 212 if (item.selection && (n-- == 0)) { 213 cutPoint = i 214 return false 215 } 216 }) 217 return items.slice(cutPoint!) 218 } 219 220 class Item { 221 constructor( 222 // The (forward) step map for this item. 223 readonly map: StepMap, 224 // The inverted step 225 readonly step?: Step, 226 // If this is non-null, this item is the start of a group, and 227 // this selection is the starting selection for the group (the one 228 // that was active before the first step was applied) 229 readonly selection?: SelectionBookmark, 230 // If this item is the inverse of a previous mapping on the stack, 231 // this points at the inverse's offset 232 readonly mirrorOffset?: number 233 ) {} 234 235 merge(other: Item) { 236 if (this.step && other.step && !other.selection) { 237 let step = other.step.merge(this.step) 238 if (step) return new Item(step.getMap().invert(), step, this.selection) 239 } 240 } 241 } 242 243 // The value of the state field that tracks undo/redo history for that 244 // state. Will be stored in the plugin state when the history plugin 245 // is active. 246 class HistoryState { 247 constructor( 248 readonly done: Branch, 249 readonly undone: Branch, 250 readonly prevRanges: readonly number[] | null, 251 readonly prevTime: number, 252 readonly prevComposition: number 253 ) {} 254 } 255 256 const DEPTH_OVERFLOW = 20 257 258 // Record a transformation in undo history. 259 function applyTransaction(history: HistoryState, state: EditorState, tr: Transaction, options: Required<HistoryOptions>) { 260 let historyTr = tr.getMeta(historyKey), rebased 261 if (historyTr) return historyTr.historyState 262 263 if (tr.getMeta(closeHistoryKey)) history = new HistoryState(history.done, history.undone, null, 0, -1) 264 265 let appended = tr.getMeta("appendedTransaction") 266 267 if (tr.steps.length == 0) { 268 return history 269 } else if (appended && appended.getMeta(historyKey)) { 270 if (appended.getMeta(historyKey).redo) 271 return new HistoryState(history.done.addTransform(tr, undefined, options, mustPreserveItems(state)), 272 history.undone, rangesFor(tr.mapping.maps), 273 history.prevTime, history.prevComposition) 274 else 275 return new HistoryState(history.done, history.undone.addTransform(tr, undefined, options, mustPreserveItems(state)), 276 null, history.prevTime, history.prevComposition) 277 } else if (tr.getMeta("addToHistory") !== false && !(appended && appended.getMeta("addToHistory") === false)) { 278 // Group transforms that occur in quick succession into one event. 279 let composition = tr.getMeta("composition") 280 let newGroup = history.prevTime == 0 || 281 (!appended && history.prevComposition != composition && 282 (history.prevTime < (tr.time || 0) - options.newGroupDelay || !isAdjacentTo(tr, history.prevRanges!))) 283 let prevRanges = appended ? mapRanges(history.prevRanges!, tr.mapping) : rangesFor(tr.mapping.maps) 284 return new HistoryState(history.done.addTransform(tr, newGroup ? state.selection.getBookmark() : undefined, 285 options, mustPreserveItems(state)), 286 Branch.empty, prevRanges, tr.time, composition == null ? history.prevComposition : composition) 287 } else if (rebased = tr.getMeta("rebased")) { 288 // Used by the collab module to tell the history that some of its 289 // content has been rebased. 290 return new HistoryState(history.done.rebased(tr, rebased), 291 history.undone.rebased(tr, rebased), 292 mapRanges(history.prevRanges!, tr.mapping), history.prevTime, history.prevComposition) 293 } else { 294 return new HistoryState(history.done.addMaps(tr.mapping.maps), 295 history.undone.addMaps(tr.mapping.maps), 296 mapRanges(history.prevRanges!, tr.mapping), history.prevTime, history.prevComposition) 297 } 298 } 299 300 function isAdjacentTo(transform: Transform, prevRanges: readonly number[]) { 301 if (!prevRanges) return false 302 if (!transform.docChanged) return true 303 let adjacent = false 304 transform.mapping.maps[0].forEach((start, end) => { 305 for (let i = 0; i < prevRanges.length; i += 2) 306 if (start <= prevRanges[i + 1] && end >= prevRanges[i]) 307 adjacent = true 308 }) 309 return adjacent 310 } 311 312 function rangesFor(maps: readonly StepMap[]) { 313 let result: number[] = [] 314 for (let i = maps.length - 1; i >= 0 && result.length == 0; i--) 315 maps[i].forEach((_from, _to, from, to) => result.push(from, to)) 316 return result 317 } 318 319 function mapRanges(ranges: readonly number[], mapping: Mapping) { 320 if (!ranges) return null 321 let result = [] 322 for (let i = 0; i < ranges.length; i += 2) { 323 let from = mapping.map(ranges[i], 1), to = mapping.map(ranges[i + 1], -1) 324 if (from <= to) result.push(from, to) 325 } 326 return result 327 } 328 329 // Apply the latest event from one branch to the document and shift the event 330 // onto the other branch. 331 function histTransaction(history: HistoryState, state: EditorState, redo: boolean): Transaction | null { 332 let preserveItems = mustPreserveItems(state) 333 let histOptions = (historyKey.get(state)!.spec as any).config as Required<HistoryOptions> 334 let pop = (redo ? history.undone : history.done).popEvent(state, preserveItems) 335 if (!pop) return null 336 337 let selection = pop.selection!.resolve(pop.transform.doc) 338 let added = (redo ? history.done : history.undone).addTransform(pop.transform, state.selection.getBookmark(), 339 histOptions, preserveItems) 340 341 let newHist = new HistoryState(redo ? added : pop.remaining, redo ? pop.remaining : added, null, 0, -1) 342 return pop.transform.setSelection(selection).setMeta(historyKey, {redo, historyState: newHist}) 343 } 344 345 let cachedPreserveItems = false, cachedPreserveItemsPlugins: readonly Plugin[] | null = null 346 // Check whether any plugin in the given state has a 347 // `historyPreserveItems` property in its spec, in which case we must 348 // preserve steps exactly as they came in, so that they can be 349 // rebased. 350 function mustPreserveItems(state: EditorState) { 351 let plugins = state.plugins 352 if (cachedPreserveItemsPlugins != plugins) { 353 cachedPreserveItems = false 354 cachedPreserveItemsPlugins = plugins 355 for (let i = 0; i < plugins.length; i++) if ((plugins[i].spec as any).historyPreserveItems) { 356 cachedPreserveItems = true 357 break 358 } 359 } 360 return cachedPreserveItems 361 } 362 363 /// Set a flag on the given transaction that will prevent further steps 364 /// from being appended to an existing history event (so that they 365 /// require a separate undo command to undo). 366 export function closeHistory(tr: Transaction) { 367 return tr.setMeta(closeHistoryKey, true) 368 } 369 370 const historyKey = new PluginKey("history") 371 const closeHistoryKey = new PluginKey("closeHistory") 372 373 interface HistoryOptions { 374 /// The amount of history events that are collected before the 375 /// oldest events are discarded. Defaults to 100. 376 depth?: number 377 378 /// The delay between changes after which a new group should be 379 /// started. Defaults to 500 (milliseconds). Note that when changes 380 /// aren't adjacent, a new group is always started. 381 newGroupDelay?: number 382 } 383 384 /// Returns a plugin that enables the undo history for an editor. The 385 /// plugin will track undo and redo stacks, which can be used with the 386 /// [`undo`](#history.undo) and [`redo`](#history.redo) commands. 387 /// 388 /// You can set an `"addToHistory"` [metadata 389 /// property](#state.Transaction.setMeta) of `false` on a transaction 390 /// to prevent it from being rolled back by undo. 391 export function history(config: HistoryOptions = {}): Plugin { 392 config = {depth: config.depth || 100, 393 newGroupDelay: config.newGroupDelay || 500} 394 395 return new Plugin({ 396 key: historyKey, 397 398 state: { 399 init() { 400 return new HistoryState(Branch.empty, Branch.empty, null, 0, -1) 401 }, 402 apply(tr, hist, state) { 403 return applyTransaction(hist, state, tr, config as Required<HistoryOptions>) 404 } 405 }, 406 407 config, 408 409 props: { 410 handleDOMEvents: { 411 beforeinput(view, e: Event) { 412 let inputType = (e as InputEvent).inputType 413 let command = inputType == "historyUndo" ? undo : inputType == "historyRedo" ? redo : null 414 if (!command || !view.editable) return false 415 e.preventDefault() 416 return command(view.state, view.dispatch) 417 } 418 } 419 } 420 }) 421 } 422 423 function buildCommand(redo: boolean, scroll: boolean): Command { 424 return (state, dispatch) => { 425 let hist = historyKey.getState(state) 426 if (!hist || (redo ? hist.undone : hist.done).eventCount == 0) return false 427 if (dispatch) { 428 let tr = histTransaction(hist, state, redo) 429 if (tr) dispatch(scroll ? tr.scrollIntoView() : tr) 430 } 431 return true 432 } 433 } 434 435 /// A command function that undoes the last change, if any. 436 export const undo = buildCommand(false, true) 437 438 /// A command function that redoes the last undone change, if any. 439 export const redo = buildCommand(true, true) 440 441 /// A command function that undoes the last change. Don't scroll the 442 /// selection into view. 443 export const undoNoScroll = buildCommand(false, false) 444 445 /// A command function that redoes the last undone change. Don't 446 /// scroll the selection into view. 447 export const redoNoScroll = buildCommand(true, false) 448 449 /// The amount of undoable events available in a given state. 450 export function undoDepth(state: EditorState) { 451 let hist = historyKey.getState(state) 452 return hist ? hist.done.eventCount : 0 453 } 454 455 /// The amount of redoable events available in a given editor state. 456 export function redoDepth(state: EditorState) { 457 let hist = historyKey.getState(state) 458 return hist ? hist.undone.eventCount : 0 459 } 460 461 /// Returns true if the given transaction was generated by the history 462 /// plugin. 463 export function isHistoryTransaction(tr: Transaction) { 464 return tr.getMeta(historyKey) != null 465 }