tor-browser

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

domchange.ts (17012B)


      1 import {Fragment, DOMParser, TagParseRule, Node, Mark, ResolvedPos} from "prosemirror-model"
      2 import {TextSelection, Transaction} from "prosemirror-state"
      3 
      4 import {selectionBetween, selectionFromDOM, selectionToDOM} from "./selection"
      5 import {selectionCollapsed, keyEvent, DOMNode} from "./dom"
      6 import * as browser from "./browser"
      7 import {EditorView} from "./index"
      8 
      9 // Note that all referencing and parsing is done with the
     10 // start-of-operation selection and document, since that's the one
     11 // that the DOM represents. If any changes came in in the meantime,
     12 // the modification is mapped over those before it is applied, in
     13 // readDOMChange.
     14 
     15 function parseBetween(view: EditorView, from_: number, to_: number) {
     16  let {node: parent, fromOffset, toOffset, from, to} = view.docView.parseRange(from_, to_)
     17 
     18  let domSel = view.domSelectionRange()
     19  let find: {node: DOMNode, offset: number, pos?: number}[] | undefined
     20  let anchor = domSel.anchorNode
     21  if (anchor && view.dom.contains(anchor.nodeType == 1 ? anchor : anchor.parentNode)) {
     22    find = [{node: anchor, offset: domSel.anchorOffset}]
     23    if (!selectionCollapsed(domSel))
     24      find.push({node: domSel.focusNode!, offset: domSel.focusOffset})
     25  }
     26  // Work around issue in Chrome where backspacing sometimes replaces
     27  // the deleted content with a random BR node (issues #799, #831)
     28  if (browser.chrome && view.input.lastKeyCode === 8) {
     29    for (let off = toOffset; off > fromOffset; off--) {
     30      let node = parent.childNodes[off - 1], desc = node.pmViewDesc
     31      if (node.nodeName == "BR" && !desc) { toOffset = off; break }
     32      if (!desc || desc.size) break
     33    }
     34  }
     35  let startDoc = view.state.doc
     36  let parser = view.someProp("domParser") || DOMParser.fromSchema(view.state.schema)
     37  let $from = startDoc.resolve(from)
     38 
     39  let sel = null, doc = parser.parse(parent, {
     40    topNode: $from.parent,
     41    topMatch: $from.parent.contentMatchAt($from.index()),
     42    topOpen: true,
     43    from: fromOffset,
     44    to: toOffset,
     45    preserveWhitespace: $from.parent.type.whitespace == "pre" ? "full" : true,
     46    findPositions: find,
     47    ruleFromNode,
     48    context: $from
     49  })
     50  if (find && find[0].pos != null) {
     51    let anchor = find[0].pos, head = find[1] && find[1].pos
     52    if (head == null) head = anchor
     53    sel = {anchor: anchor + from, head: head + from}
     54  }
     55  return {doc, sel, from, to}
     56 }
     57 
     58 function ruleFromNode(dom: DOMNode): Omit<TagParseRule, "tag"> | null {
     59  let desc = dom.pmViewDesc
     60  if (desc) {
     61    return desc.parseRule()
     62  } else if (dom.nodeName == "BR" && dom.parentNode) {
     63    // Safari replaces the list item or table cell with a BR
     64    // directly in the list node (?!) if you delete the last
     65    // character in a list item or table cell (#708, #862)
     66    if (browser.safari && /^(ul|ol)$/i.test(dom.parentNode.nodeName)) {
     67      let skip = document.createElement("div")
     68      skip.appendChild(document.createElement("li"))
     69      return {skip} as any
     70    } else if (dom.parentNode.lastChild == dom || browser.safari && /^(tr|table)$/i.test(dom.parentNode.nodeName)) {
     71      return {ignore: true}
     72    }
     73  } else if (dom.nodeName == "IMG" && (dom as HTMLElement).getAttribute("mark-placeholder")) {
     74    return {ignore: true}
     75  }
     76  return null
     77 }
     78 
     79 const isInline = /^(a|abbr|acronym|b|bd[io]|big|br|button|cite|code|data(list)?|del|dfn|em|i|img|ins|kbd|label|map|mark|meter|output|q|ruby|s|samp|small|span|strong|su[bp]|time|u|tt|var)$/i
     80 
     81 export function readDOMChange(view: EditorView, from: number, to: number, typeOver: boolean, addedNodes: readonly DOMNode[]) {
     82  let compositionID = view.input.compositionPendingChanges || (view.composing ? view.input.compositionID : 0)
     83  view.input.compositionPendingChanges = 0
     84  
     85  if (from < 0) {
     86    let origin = view.input.lastSelectionTime > Date.now() - 50 ? view.input.lastSelectionOrigin : null
     87    let newSel = selectionFromDOM(view, origin)
     88    if (newSel && !view.state.selection.eq(newSel)) {
     89      if (browser.chrome && browser.android &&
     90          view.input.lastKeyCode === 13 && Date.now() - 100 < view.input.lastKeyCodeTime &&
     91          view.someProp("handleKeyDown", f => f(view, keyEvent(13, "Enter"))))
     92        return
     93      let tr = view.state.tr.setSelection(newSel)
     94      if (origin == "pointer") tr.setMeta("pointer", true)
     95      else if (origin == "key") tr.scrollIntoView()
     96      if (compositionID) tr.setMeta("composition", compositionID)
     97      view.dispatch(tr)
     98    }
     99    return
    100  }
    101 
    102  let $before = view.state.doc.resolve(from)
    103  let shared = $before.sharedDepth(to)
    104  from = $before.before(shared + 1)
    105  to = view.state.doc.resolve(to).after(shared + 1)
    106 
    107  let sel = view.state.selection
    108  let parse = parseBetween(view, from, to)
    109 
    110  let doc = view.state.doc, compare = doc.slice(parse.from, parse.to)
    111  let preferredPos, preferredSide: "start" | "end"
    112  // Prefer anchoring to end when Backspace is pressed
    113  if (view.input.lastKeyCode === 8 && Date.now() - 100 < view.input.lastKeyCodeTime) {
    114    preferredPos = view.state.selection.to
    115    preferredSide = "end"
    116  } else {
    117    preferredPos = view.state.selection.from
    118    preferredSide = "start"
    119  }
    120  view.input.lastKeyCode = null
    121 
    122  let change = findDiff(compare.content, parse.doc.content, parse.from, preferredPos, preferredSide)
    123  if (change) view.input.domChangeCount++
    124  if ((browser.ios && view.input.lastIOSEnter > Date.now() - 225 || browser.android) &&
    125      addedNodes.some(n => n.nodeType == 1 && !isInline.test(n.nodeName)) &&
    126      (!change || change.endA >= change.endB) &&
    127      view.someProp("handleKeyDown", f => f(view, keyEvent(13, "Enter")))) {
    128    view.input.lastIOSEnter = 0
    129    return
    130  }
    131  if (!change) {
    132    if (typeOver && sel instanceof TextSelection && !sel.empty && sel.$head.sameParent(sel.$anchor) &&
    133        !view.composing && !(parse.sel && parse.sel.anchor != parse.sel.head)) {
    134      change = {start: sel.from, endA: sel.to, endB: sel.to}
    135    } else {
    136      if (parse.sel) {
    137        let sel = resolveSelection(view, view.state.doc, parse.sel)
    138        if (sel && !sel.eq(view.state.selection)) {
    139          let tr = view.state.tr.setSelection(sel)
    140          if (compositionID) tr.setMeta("composition", compositionID)
    141          view.dispatch(tr)
    142        }
    143      }
    144      return
    145    }
    146  }
    147 
    148  // Handle the case where overwriting a selection by typing matches
    149  // the start or end of the selected content, creating a change
    150  // that's smaller than what was actually overwritten.
    151  if (view.state.selection.from < view.state.selection.to &&
    152      change.start == change.endB &&
    153      view.state.selection instanceof TextSelection) {
    154    if (change.start > view.state.selection.from && change.start <= view.state.selection.from + 2 &&
    155        view.state.selection.from >= parse.from) {
    156      change.start = view.state.selection.from
    157    } else if (change.endA < view.state.selection.to && change.endA >= view.state.selection.to - 2 &&
    158               view.state.selection.to <= parse.to) {
    159      change.endB += (view.state.selection.to - change.endA)
    160      change.endA = view.state.selection.to
    161    }
    162  }
    163 
    164  // IE11 will insert a non-breaking space _ahead_ of the space after
    165  // the cursor space when adding a space before another space. When
    166  // that happened, adjust the change to cover the space instead.
    167  if (browser.ie && browser.ie_version <= 11 && change.endB == change.start + 1 &&
    168      change.endA == change.start && change.start > parse.from &&
    169      parse.doc.textBetween(change.start - parse.from - 1, change.start - parse.from + 1) == " \u00a0") {
    170    change.start--
    171    change.endA--
    172    change.endB--
    173  }
    174 
    175  let $from = parse.doc.resolveNoCache(change.start - parse.from)
    176  let $to = parse.doc.resolveNoCache(change.endB - parse.from)
    177  let $fromA = doc.resolve(change.start)
    178  let inlineChange = $from.sameParent($to) && $from.parent.inlineContent && $fromA.end() >= change.endA
    179  // If this looks like the effect of pressing Enter (or was recorded
    180  // as being an iOS enter press), just dispatch an Enter key instead.
    181  if (((browser.ios && view.input.lastIOSEnter > Date.now() - 225 &&
    182        (!inlineChange || addedNodes.some(n => n.nodeName == "DIV" || n.nodeName == "P"))) ||
    183       (!inlineChange && $from.pos < parse.doc.content.size &&
    184        (!$from.sameParent($to) || !$from.parent.inlineContent) &&
    185        $from.pos < $to.pos && !/\S/.test(parse.doc.textBetween($from.pos, $to.pos, "", "")))) &&
    186      view.someProp("handleKeyDown", f => f(view, keyEvent(13, "Enter")))) {
    187    view.input.lastIOSEnter = 0
    188    return
    189  }
    190  // Same for backspace
    191  if (view.state.selection.anchor > change.start &&
    192      looksLikeBackspace(doc, change.start, change.endA, $from, $to) &&
    193      view.someProp("handleKeyDown", f => f(view, keyEvent(8, "Backspace")))) {
    194    if (browser.android && browser.chrome) view.domObserver.suppressSelectionUpdates() // #820
    195    return
    196  }
    197 
    198  // Chrome will occasionally, during composition, delete the
    199  // entire composition and then immediately insert it again. This is
    200  // used to detect that situation.
    201  if (browser.chrome && change.endB == change.start)
    202    view.input.lastChromeDelete = Date.now()
    203 
    204  // This tries to detect Android virtual keyboard
    205  // enter-and-pick-suggestion action. That sometimes (see issue
    206  // #1059) first fires a DOM mutation, before moving the selection to
    207  // the newly created block. And then, because ProseMirror cleans up
    208  // the DOM selection, it gives up moving the selection entirely,
    209  // leaving the cursor in the wrong place. When that happens, we drop
    210  // the new paragraph from the initial change, and fire a simulated
    211  // enter key afterwards.
    212  if (browser.android && !inlineChange && $from.start() != $to.start() && $to.parentOffset == 0 && $from.depth == $to.depth &&
    213      parse.sel && parse.sel.anchor == parse.sel.head && parse.sel.head == change.endA) {
    214    change.endB -= 2
    215    $to = parse.doc.resolveNoCache(change.endB - parse.from)
    216    setTimeout(() => {
    217      view.someProp("handleKeyDown", function (f) { return f(view, keyEvent(13, "Enter")); })
    218    }, 20)
    219  }
    220 
    221  let chFrom = change.start, chTo = change.endA
    222 
    223  let mkTr = (base?: Transaction) => {
    224    let tr = base || view.state.tr.replace(chFrom, chTo, parse.doc.slice(change!.start - parse.from,
    225                                                                         change!.endB - parse.from))
    226    if (parse.sel) {
    227      let sel = resolveSelection(view, tr.doc, parse.sel)
    228      // Chrome will sometimes, during composition, report the
    229      // selection in the wrong place. If it looks like that is
    230      // happening, don't update the selection.
    231      // Edge just doesn't move the cursor forward when you start typing
    232      // in an empty block or between br nodes.
    233      if (sel && !(browser.chrome && view.composing && sel.empty &&
    234        (change!.start != change!.endB || view.input.lastChromeDelete < Date.now() - 100) &&
    235        (sel.head == chFrom || sel.head == tr.mapping.map(chTo) - 1) ||
    236        browser.ie && sel.empty && sel.head == chFrom))
    237        tr.setSelection(sel)
    238    }
    239    if (compositionID) tr.setMeta("composition", compositionID)
    240    return tr.scrollIntoView()
    241  }
    242 
    243  let markChange
    244  if (inlineChange) {
    245    if ($from.pos == $to.pos) { // Deletion
    246      // IE11 sometimes weirdly moves the DOM selection around after
    247      // backspacing out the first element in a textblock
    248      if (browser.ie && browser.ie_version <= 11 && $from.parentOffset == 0) {
    249        view.domObserver.suppressSelectionUpdates()
    250        setTimeout(() => selectionToDOM(view), 20)
    251      }
    252      let tr = mkTr(view.state.tr.delete(chFrom, chTo))
    253      let marks = doc.resolve(change.start).marksAcross(doc.resolve(change.endA))
    254      if (marks) tr.ensureMarks(marks)
    255      view.dispatch(tr)
    256    } else if ( // Adding or removing a mark
    257      change.endA == change.endB &&
    258      (markChange = isMarkChange($from.parent.content.cut($from.parentOffset, $to.parentOffset),
    259                                 $fromA.parent.content.cut($fromA.parentOffset, change.endA - $fromA.start())))
    260    ) {
    261      let tr = mkTr(view.state.tr)
    262      if (markChange.type == "add") tr.addMark(chFrom, chTo, markChange.mark)
    263      else tr.removeMark(chFrom, chTo, markChange.mark)
    264      view.dispatch(tr)
    265    } else if ($from.parent.child($from.index()).isText && $from.index() == $to.index() - ($to.textOffset ? 0 : 1)) {
    266      // Both positions in the same text node -- simply insert text
    267      let text = $from.parent.textBetween($from.parentOffset, $to.parentOffset)
    268      let deflt = () => mkTr(view.state.tr.insertText(text, chFrom, chTo))
    269      if (!view.someProp("handleTextInput", f => f(view, chFrom, chTo, text, deflt)))
    270        view.dispatch(deflt())
    271    } else {
    272      view.dispatch(mkTr())
    273    }
    274  } else {
    275    view.dispatch(mkTr())
    276  }
    277 }
    278 
    279 function resolveSelection(view: EditorView, doc: Node, parsedSel: {anchor: number, head: number}) {
    280  if (Math.max(parsedSel.anchor, parsedSel.head) > doc.content.size) return null
    281  return selectionBetween(view, doc.resolve(parsedSel.anchor), doc.resolve(parsedSel.head))
    282 }
    283 
    284 // Given two same-length, non-empty fragments of inline content,
    285 // determine whether the first could be created from the second by
    286 // removing or adding a single mark type.
    287 function isMarkChange(cur: Fragment, prev: Fragment) {
    288  let curMarks = cur.firstChild!.marks, prevMarks = prev.firstChild!.marks
    289  let added = curMarks, removed = prevMarks, type, mark: Mark | undefined, update
    290  for (let i = 0; i < prevMarks.length; i++) added = prevMarks[i].removeFromSet(added)
    291  for (let i = 0; i < curMarks.length; i++) removed = curMarks[i].removeFromSet(removed)
    292  if (added.length == 1 && removed.length == 0) {
    293    mark = added[0]
    294    type = "add"
    295    update = (node: Node) => node.mark(mark!.addToSet(node.marks))
    296  } else if (added.length == 0 && removed.length == 1) {
    297    mark = removed[0]
    298    type = "remove"
    299    update = (node: Node) => node.mark(mark!.removeFromSet(node.marks))
    300  } else {
    301    return null
    302  }
    303  let updated = []
    304  for (let i = 0; i < prev.childCount; i++) updated.push(update(prev.child(i)))
    305  if (Fragment.from(updated).eq(cur)) return {mark, type}
    306 }
    307 
    308 function looksLikeBackspace(old: Node, start: number, end: number, $newStart: ResolvedPos, $newEnd: ResolvedPos) {
    309  if (// The content must have shrunk
    310      end - start <= $newEnd.pos - $newStart.pos ||
    311      // newEnd must point directly at or after the end of the block that newStart points into
    312      skipClosingAndOpening($newStart, true, false) < $newEnd.pos)
    313    return false
    314 
    315  let $start = old.resolve(start)
    316 
    317  // Handle the case where, rather than joining blocks, the change just removed an entire block
    318  if (!$newStart.parent.isTextblock) {
    319    let after = $start.nodeAfter
    320    return after != null && end == start + after.nodeSize
    321  }
    322 
    323  // Start must be at the end of a block
    324  if ($start.parentOffset < $start.parent.content.size || !$start.parent.isTextblock)
    325    return false
    326  let $next = old.resolve(skipClosingAndOpening($start, true, true))
    327  // The next textblock must start before end and end near it
    328  if (!$next.parent.isTextblock || $next.pos > end ||
    329      skipClosingAndOpening($next, true, false) < end)
    330    return false
    331 
    332  // The fragments after the join point must match
    333  return $newStart.parent.content.cut($newStart.parentOffset).eq($next.parent.content)
    334 }
    335 
    336 function skipClosingAndOpening($pos: ResolvedPos, fromEnd: boolean, mayOpen: boolean) {
    337  let depth = $pos.depth, end = fromEnd ? $pos.end() : $pos.pos
    338  while (depth > 0 && (fromEnd || $pos.indexAfter(depth) == $pos.node(depth).childCount)) {
    339    depth--
    340    end++
    341    fromEnd = false
    342  }
    343  if (mayOpen) {
    344    let next = $pos.node(depth).maybeChild($pos.indexAfter(depth))
    345    while (next && !next.isLeaf) {
    346      next = next.firstChild
    347      end++
    348    }
    349  }
    350  return end
    351 }
    352 
    353 function findDiff(a: Fragment, b: Fragment, pos: number, preferredPos: number, preferredSide: "start" | "end") {
    354  let start = a.findDiffStart(b, pos)
    355  if (start == null) return null
    356  let {a: endA, b: endB} = a.findDiffEnd(b, pos + a.size, pos + b.size)!
    357  if (preferredSide == "end") {
    358    let adjust = Math.max(0, start - Math.min(endA, endB))
    359    preferredPos -= endA + adjust - start
    360  }
    361  if (endA < start && a.size < b.size) {
    362    let move = preferredPos <= start && preferredPos >= endA ? start - preferredPos : 0
    363    start -= move
    364    if (start && start < b.size && isSurrogatePair(b.textBetween(start - 1, start + 1)))
    365      start += move ? 1 : -1
    366    endB = start + (endB - endA)
    367    endA = start
    368  } else if (endB < start) {
    369    let move = preferredPos <= start && preferredPos >= endB ? start - preferredPos : 0
    370    start -= move
    371    if (start && start < a.size && isSurrogatePair(a.textBetween(start - 1, start + 1)))
    372      start += move ? 1 : -1
    373    endA = start + (endA - endB)
    374    endB = start
    375  }
    376  return {start, endA, endB}
    377 }
    378 
    379 function isSurrogatePair(str: string) {
    380  if (str.length != 2) return false
    381  let a = str.charCodeAt(0), b = str.charCodeAt(1)
    382  return a >= 0xDC00 && a <= 0xDFFF && b >= 0xD800 && b <= 0xDBFF
    383 }