tor-browser

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

replace.ts (20249B)


      1 import {Fragment, Slice, Node, ResolvedPos, NodeType, ContentMatch, Attrs} from "prosemirror-model"
      2 
      3 import {Step} from "./step"
      4 import {ReplaceStep, ReplaceAroundStep} from "./replace_step"
      5 import {Transform} from "./transform"
      6 import {insertPoint} from "./structure"
      7 
      8 /// ‘Fit’ a slice into a given position in the document, producing a
      9 /// [step](#transform.Step) that inserts it. Will return null if
     10 /// there's no meaningful way to insert the slice here, or inserting it
     11 /// would be a no-op (an empty slice over an empty range).
     12 export function replaceStep(doc: Node, from: number, to = from, slice = Slice.empty): Step | null {
     13  if (from == to && !slice.size) return null
     14 
     15  let $from = doc.resolve(from), $to = doc.resolve(to)
     16  // Optimization -- avoid work if it's obvious that it's not needed.
     17  if (fitsTrivially($from, $to, slice)) return new ReplaceStep(from, to, slice)
     18  return new Fitter($from, $to, slice).fit()
     19 }
     20 
     21 function fitsTrivially($from: ResolvedPos, $to: ResolvedPos, slice: Slice) {
     22  return !slice.openStart && !slice.openEnd && $from.start() == $to.start() &&
     23    $from.parent.canReplace($from.index(), $to.index(), slice.content)
     24 }
     25 
     26 interface Fittable {
     27  sliceDepth: number
     28  frontierDepth: number
     29  parent: Node | null
     30  inject?: Fragment | null
     31  wrap?: readonly NodeType[]
     32 }
     33 
     34 // Algorithm for 'placing' the elements of a slice into a gap:
     35 //
     36 // We consider the content of each node that is open to the left to be
     37 // independently placeable. I.e. in <p("foo"), p("bar")>, when the
     38 // paragraph on the left is open, "foo" can be placed (somewhere on
     39 // the left side of the replacement gap) independently from p("bar").
     40 //
     41 // This class tracks the state of the placement progress in the
     42 // following properties:
     43 //
     44 //  - `frontier` holds a stack of `{type, match}` objects that
     45 //    represent the open side of the replacement. It starts at
     46 //    `$from`, then moves forward as content is placed, and is finally
     47 //    reconciled with `$to`.
     48 //
     49 //  - `unplaced` is a slice that represents the content that hasn't
     50 //    been placed yet.
     51 //
     52 //  - `placed` is a fragment of placed content. Its open-start value
     53 //    is implicit in `$from`, and its open-end value in `frontier`.
     54 class Fitter {
     55  frontier: {type: NodeType, match: ContentMatch}[] = []
     56  placed: Fragment = Fragment.empty
     57 
     58  constructor(
     59    readonly $from: ResolvedPos,
     60    readonly $to: ResolvedPos,
     61    public unplaced: Slice
     62  ) {
     63    for (let i = 0; i <= $from.depth; i++) {
     64      let node = $from.node(i)
     65      this.frontier.push({
     66        type: node.type,
     67        match: node.contentMatchAt($from.indexAfter(i))
     68      })
     69    }
     70 
     71    for (let i = $from.depth; i > 0; i--)
     72      this.placed = Fragment.from($from.node(i).copy(this.placed))
     73  }
     74 
     75  get depth() { return this.frontier.length - 1 }
     76 
     77  fit() {
     78    // As long as there's unplaced content, try to place some of it.
     79    // If that fails, either increase the open score of the unplaced
     80    // slice, or drop nodes from it, and then try again.
     81    while (this.unplaced.size) {
     82      let fit = this.findFittable()
     83      if (fit) this.placeNodes(fit)
     84      else this.openMore() || this.dropNode()
     85    }
     86    // When there's inline content directly after the frontier _and_
     87    // directly after `this.$to`, we must generate a `ReplaceAround`
     88    // step that pulls that content into the node after the frontier.
     89    // That means the fitting must be done to the end of the textblock
     90    // node after `this.$to`, not `this.$to` itself.
     91    let moveInline = this.mustMoveInline(), placedSize = this.placed.size - this.depth - this.$from.depth
     92    let $from = this.$from, $to = this.close(moveInline < 0 ? this.$to : $from.doc.resolve(moveInline))
     93    if (!$to) return null
     94 
     95    // If closing to `$to` succeeded, create a step
     96    let content = this.placed, openStart = $from.depth, openEnd = $to.depth
     97    while (openStart && openEnd && content.childCount == 1) { // Normalize by dropping open parent nodes
     98      content = content.firstChild!.content
     99      openStart--; openEnd--
    100    }
    101    let slice = new Slice(content, openStart, openEnd)
    102    if (moveInline > -1)
    103      return new ReplaceAroundStep($from.pos, moveInline, this.$to.pos, this.$to.end(), slice, placedSize)
    104    if (slice.size || $from.pos != this.$to.pos) // Don't generate no-op steps
    105      return new ReplaceStep($from.pos, $to.pos, slice)
    106    return null
    107  }
    108 
    109  // Find a position on the start spine of `this.unplaced` that has
    110  // content that can be moved somewhere on the frontier. Returns two
    111  // depths, one for the slice and one for the frontier.
    112  findFittable(): Fittable | undefined {
    113    let startDepth = this.unplaced.openStart
    114    for (let cur = this.unplaced.content, d = 0, openEnd = this.unplaced.openEnd; d < startDepth; d++) {
    115      let node = cur.firstChild!
    116      if (cur.childCount > 1) openEnd = 0
    117      if (node.type.spec.isolating && openEnd <= d) {
    118        startDepth = d
    119        break
    120      }
    121      cur = node.content
    122    }
    123 
    124    // Only try wrapping nodes (pass 2) after finding a place without
    125    // wrapping failed.
    126    for (let pass = 1; pass <= 2; pass++) {
    127      for (let sliceDepth = pass == 1 ? startDepth : this.unplaced.openStart; sliceDepth >= 0; sliceDepth--) {
    128        let fragment, parent = null
    129        if (sliceDepth) {
    130          parent = contentAt(this.unplaced.content, sliceDepth - 1).firstChild
    131          fragment = parent!.content
    132        } else {
    133          fragment = this.unplaced.content
    134        }
    135        let first = fragment.firstChild
    136        for (let frontierDepth = this.depth; frontierDepth >= 0; frontierDepth--) {
    137          let {type, match} = this.frontier[frontierDepth], wrap, inject: Fragment | null = null
    138          // In pass 1, if the next node matches, or there is no next
    139          // node but the parents look compatible, we've found a
    140          // place.
    141          if (pass == 1 && (first ? match.matchType(first.type) || (inject = match.fillBefore(Fragment.from(first), false))
    142                            : parent && type.compatibleContent(parent.type)))
    143            return {sliceDepth, frontierDepth, parent, inject}
    144          // In pass 2, look for a set of wrapping nodes that make
    145          // `first` fit here.
    146          else if (pass == 2 && first && (wrap = match.findWrapping(first.type)))
    147            return {sliceDepth, frontierDepth, parent, wrap}
    148          // Don't continue looking further up if the parent node
    149          // would fit here.
    150          if (parent && match.matchType(parent.type)) break
    151        }
    152      }
    153    }
    154  }
    155 
    156  openMore() {
    157    let {content, openStart, openEnd} = this.unplaced
    158    let inner = contentAt(content, openStart)
    159    if (!inner.childCount || inner.firstChild!.isLeaf) return false
    160    this.unplaced = new Slice(content, openStart + 1,
    161                              Math.max(openEnd, inner.size + openStart >= content.size - openEnd ? openStart + 1 : 0))
    162    return true
    163  }
    164 
    165  dropNode() {
    166    let {content, openStart, openEnd} = this.unplaced
    167    let inner = contentAt(content, openStart)
    168    if (inner.childCount <= 1 && openStart > 0) {
    169      let openAtEnd = content.size - openStart <= openStart + inner.size
    170      this.unplaced = new Slice(dropFromFragment(content, openStart - 1, 1), openStart - 1,
    171                                openAtEnd ? openStart - 1 : openEnd)
    172    } else {
    173      this.unplaced = new Slice(dropFromFragment(content, openStart, 1), openStart, openEnd)
    174    }
    175  }
    176 
    177  // Move content from the unplaced slice at `sliceDepth` to the
    178  // frontier node at `frontierDepth`. Close that frontier node when
    179  // applicable.
    180  placeNodes({sliceDepth, frontierDepth, parent, inject, wrap}: Fittable) {
    181    while (this.depth > frontierDepth) this.closeFrontierNode()
    182    if (wrap) for (let i = 0; i < wrap.length; i++) this.openFrontierNode(wrap[i])
    183 
    184    let slice = this.unplaced, fragment = parent ? parent.content : slice.content
    185    let openStart = slice.openStart - sliceDepth
    186    let taken = 0, add = []
    187    let {match, type} = this.frontier[frontierDepth]
    188    if (inject) {
    189      for (let i = 0; i < inject.childCount; i++) add.push(inject.child(i))
    190      match = match.matchFragment(inject)!
    191    }
    192    // Computes the amount of (end) open nodes at the end of the
    193    // fragment. When 0, the parent is open, but no more. When
    194    // negative, nothing is open.
    195    let openEndCount = (fragment.size + sliceDepth) - (slice.content.size - slice.openEnd)
    196    // Scan over the fragment, fitting as many child nodes as
    197    // possible.
    198    while (taken < fragment.childCount) {
    199      let next = fragment.child(taken), matches = match.matchType(next.type)
    200      if (!matches) break
    201      taken++
    202      if (taken > 1 || openStart == 0 || next.content.size) { // Drop empty open nodes
    203        match = matches
    204        add.push(closeNodeStart(next.mark(type.allowedMarks(next.marks)), taken == 1 ? openStart : 0,
    205                                taken == fragment.childCount ? openEndCount : -1))
    206      }
    207    }
    208    let toEnd = taken == fragment.childCount
    209    if (!toEnd) openEndCount = -1
    210 
    211    this.placed = addToFragment(this.placed, frontierDepth, Fragment.from(add))
    212    this.frontier[frontierDepth].match = match
    213 
    214    // If the parent types match, and the entire node was moved, and
    215    // it's not open, close this frontier node right away.
    216    if (toEnd && openEndCount < 0 && parent && parent.type == this.frontier[this.depth].type && this.frontier.length > 1)
    217      this.closeFrontierNode()
    218 
    219    // Add new frontier nodes for any open nodes at the end.
    220    for (let i = 0, cur = fragment; i < openEndCount; i++) {
    221      let node = cur.lastChild!
    222      this.frontier.push({type: node.type, match: node.contentMatchAt(node.childCount)})
    223      cur = node.content
    224    }
    225 
    226    // Update `this.unplaced`. Drop the entire node from which we
    227    // placed it we got to its end, otherwise just drop the placed
    228    // nodes.
    229    this.unplaced = !toEnd ? new Slice(dropFromFragment(slice.content, sliceDepth, taken), slice.openStart, slice.openEnd)
    230      : sliceDepth == 0 ? Slice.empty
    231      : new Slice(dropFromFragment(slice.content, sliceDepth - 1, 1),
    232                  sliceDepth - 1, openEndCount < 0 ? slice.openEnd : sliceDepth - 1)
    233  }
    234 
    235  mustMoveInline() {
    236    if (!this.$to.parent.isTextblock) return -1
    237    let top = this.frontier[this.depth], level
    238    if (!top.type.isTextblock || !contentAfterFits(this.$to, this.$to.depth, top.type, top.match, false) ||
    239        (this.$to.depth == this.depth && (level = this.findCloseLevel(this.$to)) && level.depth == this.depth)) return -1
    240 
    241    let {depth} = this.$to, after = this.$to.after(depth)
    242    while (depth > 1 && after == this.$to.end(--depth)) ++after
    243    return after
    244  }
    245 
    246  findCloseLevel($to: ResolvedPos) {
    247    scan: for (let i = Math.min(this.depth, $to.depth); i >= 0; i--) {
    248      let {match, type} = this.frontier[i]
    249      let dropInner = i < $to.depth && $to.end(i + 1) == $to.pos + ($to.depth - (i + 1))
    250      let fit = contentAfterFits($to, i, type, match, dropInner)
    251      if (!fit) continue
    252      for (let d = i - 1; d >= 0; d--) {
    253        let {match, type} = this.frontier[d]
    254        let matches = contentAfterFits($to, d, type, match, true)
    255        if (!matches || matches.childCount) continue scan
    256      }
    257      return {depth: i, fit, move: dropInner ? $to.doc.resolve($to.after(i + 1)) : $to}
    258    }
    259  }
    260 
    261  close($to: ResolvedPos) {
    262    let close = this.findCloseLevel($to)
    263    if (!close) return null
    264 
    265    while (this.depth > close.depth) this.closeFrontierNode()
    266    if (close.fit.childCount) this.placed = addToFragment(this.placed, close.depth, close.fit)
    267    $to = close.move
    268    for (let d = close.depth + 1; d <= $to.depth; d++) {
    269      let node = $to.node(d), add = node.type.contentMatch.fillBefore(node.content, true, $to.index(d))!
    270      this.openFrontierNode(node.type, node.attrs, add)
    271    }
    272    return $to
    273  }
    274 
    275  openFrontierNode(type: NodeType, attrs: Attrs | null = null, content?: Fragment) {
    276    let top = this.frontier[this.depth]
    277    top.match = top.match.matchType(type)!
    278    this.placed = addToFragment(this.placed, this.depth, Fragment.from(type.create(attrs, content)))
    279    this.frontier.push({type, match: type.contentMatch})
    280  }
    281 
    282  closeFrontierNode() {
    283    let open = this.frontier.pop()!
    284    let add = open.match.fillBefore(Fragment.empty, true)!
    285    if (add.childCount) this.placed = addToFragment(this.placed, this.frontier.length, add)
    286  }
    287 }
    288 
    289 function dropFromFragment(fragment: Fragment, depth: number, count: number): Fragment {
    290  if (depth == 0) return fragment.cutByIndex(count, fragment.childCount)
    291  return fragment.replaceChild(0, fragment.firstChild!.copy(dropFromFragment(fragment.firstChild!.content, depth - 1, count)))
    292 }
    293 
    294 function addToFragment(fragment: Fragment, depth: number, content: Fragment): Fragment {
    295  if (depth == 0) return fragment.append(content)
    296  return fragment.replaceChild(fragment.childCount - 1,
    297                               fragment.lastChild!.copy(addToFragment(fragment.lastChild!.content, depth - 1, content)))
    298 }
    299 
    300 function contentAt(fragment: Fragment, depth: number) {
    301  for (let i = 0; i < depth; i++) fragment = fragment.firstChild!.content
    302  return fragment
    303 }
    304 
    305 function closeNodeStart(node: Node, openStart: number, openEnd: number) {
    306  if (openStart <= 0) return node
    307  let frag = node.content
    308  if (openStart > 1)
    309    frag = frag.replaceChild(0, closeNodeStart(frag.firstChild!, openStart - 1, frag.childCount == 1 ? openEnd - 1 : 0))
    310  if (openStart > 0) {
    311    frag = node.type.contentMatch.fillBefore(frag)!.append(frag)
    312    if (openEnd <= 0) frag = frag.append(node.type.contentMatch.matchFragment(frag)!.fillBefore(Fragment.empty, true)!)
    313  }
    314  return node.copy(frag)
    315 }
    316 
    317 function contentAfterFits($to: ResolvedPos, depth: number, type: NodeType, match: ContentMatch, open: boolean) {
    318  let node = $to.node(depth), index = open ? $to.indexAfter(depth) : $to.index(depth)
    319  if (index == node.childCount && !type.compatibleContent(node.type)) return null
    320  let fit = match.fillBefore(node.content, true, index)
    321  return fit && !invalidMarks(type, node.content, index) ? fit : null
    322 }
    323 
    324 function invalidMarks(type: NodeType, fragment: Fragment, start: number) {
    325  for (let i = start; i < fragment.childCount; i++)
    326    if (!type.allowsMarks(fragment.child(i).marks)) return true
    327  return false
    328 }
    329 
    330 function definesContent(type: NodeType) {
    331  return type.spec.defining || type.spec.definingForContent
    332 }
    333 
    334 export function replaceRange(tr: Transform, from: number, to: number, slice: Slice) {
    335  if (!slice.size) return tr.deleteRange(from, to)
    336 
    337  let $from = tr.doc.resolve(from), $to = tr.doc.resolve(to)
    338  if (fitsTrivially($from, $to, slice))
    339    return tr.step(new ReplaceStep(from, to, slice))
    340 
    341  let targetDepths = coveredDepths($from, $to)
    342  // Can't replace the whole document, so remove 0 if it's present
    343  if (targetDepths[targetDepths.length - 1] == 0) targetDepths.pop()
    344  // Negative numbers represent not expansion over the whole node at
    345  // that depth, but replacing from $from.before(-D) to $to.pos.
    346  let preferredTarget = -($from.depth + 1)
    347  targetDepths.unshift(preferredTarget)
    348  // This loop picks a preferred target depth, if one of the covering
    349  // depths is not outside of a defining node, and adds negative
    350  // depths for any depth that has $from at its start and does not
    351  // cross a defining node.
    352  for (let d = $from.depth, pos = $from.pos - 1; d > 0; d--, pos--) {
    353    let spec = $from.node(d).type.spec
    354    if (spec.defining || spec.definingAsContext || spec.isolating) break
    355    if (targetDepths.indexOf(d) > -1) preferredTarget = d
    356    else if ($from.before(d) == pos) targetDepths.splice(1, 0, -d)
    357  }
    358  // Try to fit each possible depth of the slice into each possible
    359  // target depth, starting with the preferred depths.
    360  let preferredTargetIndex = targetDepths.indexOf(preferredTarget)
    361 
    362  let leftNodes: Node[] = [], preferredDepth = slice.openStart
    363  for (let content = slice.content, i = 0;; i++) {
    364    let node = content.firstChild!
    365    leftNodes.push(node)
    366    if (i == slice.openStart) break
    367    content = node.content
    368  }
    369 
    370  // Back up preferredDepth to cover defining textblocks directly
    371  // above it, possibly skipping a non-defining textblock.
    372  for (let d = preferredDepth - 1; d >= 0; d--) {
    373    let leftNode = leftNodes[d], def = definesContent(leftNode.type)
    374    if (def && !leftNode.sameMarkup($from.node(Math.abs(preferredTarget) - 1))) preferredDepth = d
    375    else if (def || !leftNode.type.isTextblock) break
    376  }
    377 
    378  for (let j = slice.openStart; j >= 0; j--) {
    379    let openDepth = (j + preferredDepth + 1) % (slice.openStart + 1)
    380    let insert = leftNodes[openDepth]
    381    if (!insert) continue
    382    for (let i = 0; i < targetDepths.length; i++) {
    383      // Loop over possible expansion levels, starting with the
    384      // preferred one
    385      let targetDepth = targetDepths[(i + preferredTargetIndex) % targetDepths.length], expand = true
    386      if (targetDepth < 0) { expand = false; targetDepth = -targetDepth }
    387      let parent = $from.node(targetDepth - 1), index = $from.index(targetDepth - 1)
    388      if (parent.canReplaceWith(index, index, insert.type, insert.marks))
    389        return tr.replace($from.before(targetDepth), expand ? $to.after(targetDepth) : to,
    390                            new Slice(closeFragment(slice.content, 0, slice.openStart, openDepth),
    391                                      openDepth, slice.openEnd))
    392    }
    393  }
    394 
    395  let startSteps = tr.steps.length
    396  for (let i = targetDepths.length - 1; i >= 0; i--) {
    397    tr.replace(from, to, slice)
    398    if (tr.steps.length > startSteps) break
    399    let depth = targetDepths[i]
    400    if (depth < 0) continue
    401    from = $from.before(depth); to = $to.after(depth)
    402  }
    403 }
    404 
    405 function closeFragment(fragment: Fragment, depth: number, oldOpen: number, newOpen: number, parent?: Node) {
    406  if (depth < oldOpen) {
    407    let first = fragment.firstChild!
    408    fragment = fragment.replaceChild(0, first.copy(closeFragment(first.content, depth + 1, oldOpen, newOpen, first)))
    409  }
    410  if (depth > newOpen) {
    411    let match = parent!.contentMatchAt(0)!
    412    let start = match.fillBefore(fragment)!.append(fragment)
    413    fragment = start.append(match.matchFragment(start)!.fillBefore(Fragment.empty, true)!)
    414  }
    415  return fragment
    416 }
    417 
    418 export function replaceRangeWith(tr: Transform, from: number, to: number, node: Node) {
    419  if (!node.isInline && from == to && tr.doc.resolve(from).parent.content.size) {
    420    let point = insertPoint(tr.doc, from, node.type)
    421    if (point != null) from = to = point
    422  }
    423  tr.replaceRange(from, to, new Slice(Fragment.from(node), 0, 0))
    424 }
    425 
    426 export function deleteRange(tr: Transform, from: number, to: number) {
    427  let $from = tr.doc.resolve(from), $to = tr.doc.resolve(to)
    428  let covered = coveredDepths($from, $to)
    429  for (let i = 0; i < covered.length; i++) {
    430    let depth = covered[i], last = i == covered.length - 1
    431    if ((last && depth == 0) || $from.node(depth).type.contentMatch.validEnd)
    432      return tr.delete($from.start(depth), $to.end(depth))
    433    if (depth > 0 && (last || $from.node(depth - 1).canReplace($from.index(depth - 1), $to.indexAfter(depth - 1))))
    434      return tr.delete($from.before(depth), $to.after(depth))
    435  }
    436  for (let d = 1; d <= $from.depth && d <= $to.depth; d++) {
    437    if (from - $from.start(d) == $from.depth - d && to > $from.end(d) && $to.end(d) - to != $to.depth - d &&
    438        $from.start(d - 1) == $to.start(d - 1) && $from.node(d - 1).canReplace($from.index(d - 1), $to.index(d - 1)))
    439      return tr.delete($from.before(d), to)
    440  }
    441  tr.delete(from, to)
    442 }
    443 
    444 // Returns an array of all depths for which $from - $to spans the
    445 // whole content of the nodes at that depth.
    446 function coveredDepths($from: ResolvedPos, $to: ResolvedPos) {
    447  let result: number[] = [], minDepth = Math.min($from.depth, $to.depth)
    448  for (let d = minDepth; d >= 0; d--) {
    449    let start = $from.start(d)
    450    if (start < $from.pos - ($from.depth - d) ||
    451        $to.end(d) > $to.pos + ($to.depth - d) ||
    452        $from.node(d).type.spec.isolating ||
    453        $to.node(d).type.spec.isolating) break
    454    if (start == $to.start(d) ||
    455        (d == $from.depth && d == $to.depth && $from.parent.inlineContent && $to.parent.inlineContent &&
    456         d && $to.start(d - 1) == start - 1))
    457      result.push(d)
    458  }
    459  return result
    460 }