tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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 }