tor-browser

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

domobserver.ts (13828B)


      1 import {Selection} from "prosemirror-state"
      2 import * as browser from "./browser"
      3 import {domIndex, isEquivalentPosition, selectionCollapsed, parentNode, DOMSelectionRange, DOMNode, DOMSelection} from "./dom"
      4 import {hasFocusAndSelection, selectionToDOM, selectionFromDOM} from "./selection"
      5 import {EditorView} from "./index"
      6 
      7 const observeOptions = {
      8  childList: true,
      9  characterData: true,
     10  characterDataOldValue: true,
     11  attributes: true,
     12  attributeOldValue: true,
     13  subtree: true
     14 }
     15 // IE11 has very broken mutation observers, so we also listen to DOMCharacterDataModified
     16 const useCharData = browser.ie && browser.ie_version <= 11
     17 
     18 class SelectionState {
     19  anchorNode: Node | null = null
     20  anchorOffset: number = 0
     21  focusNode: Node | null = null
     22  focusOffset: number = 0
     23 
     24  set(sel: DOMSelectionRange) {
     25    this.anchorNode = sel.anchorNode; this.anchorOffset = sel.anchorOffset
     26    this.focusNode = sel.focusNode; this.focusOffset = sel.focusOffset
     27  }
     28 
     29  clear() {
     30    this.anchorNode = this.focusNode = null
     31  }
     32 
     33  eq(sel: DOMSelectionRange) {
     34    return sel.anchorNode == this.anchorNode && sel.anchorOffset == this.anchorOffset &&
     35      sel.focusNode == this.focusNode && sel.focusOffset == this.focusOffset
     36  }
     37 }
     38 
     39 export class DOMObserver {
     40  queue: MutationRecord[] = []
     41  flushingSoon = -1
     42  observer: MutationObserver | null = null
     43  currentSelection = new SelectionState
     44  onCharData: ((e: Event) => void) | null = null
     45  suppressingSelectionUpdates = false
     46  lastChangedTextNode: Text | null = null
     47 
     48  constructor(
     49    readonly view: EditorView,
     50    readonly handleDOMChange: (from: number, to: number, typeOver: boolean, added: Node[]) => void
     51  ) {
     52    this.observer = window.MutationObserver &&
     53      new window.MutationObserver(mutations => {
     54        for (let i = 0; i < mutations.length; i++) this.queue.push(mutations[i])
     55        // IE11 will sometimes (on backspacing out a single character
     56        // text node after a BR node) call the observer callback
     57        // before actually updating the DOM, which will cause
     58        // ProseMirror to miss the change (see #930)
     59        if (browser.ie && browser.ie_version <= 11 && mutations.some(
     60          m => m.type == "childList" && m.removedNodes.length ||
     61               m.type == "characterData" && m.oldValue!.length > m.target.nodeValue!.length))
     62          this.flushSoon()
     63        else
     64          this.flush()
     65      })
     66    if (useCharData) {
     67      this.onCharData = e => {
     68        this.queue.push({target: e.target as Node, type: "characterData", oldValue: (e as any).prevValue} as MutationRecord)
     69        this.flushSoon()
     70      }
     71    }
     72    this.onSelectionChange = this.onSelectionChange.bind(this)
     73  }
     74 
     75  flushSoon() {
     76    if (this.flushingSoon < 0)
     77      this.flushingSoon = window.setTimeout(() => { this.flushingSoon = -1; this.flush() }, 20)
     78  }
     79 
     80  forceFlush() {
     81    if (this.flushingSoon > -1) {
     82      window.clearTimeout(this.flushingSoon)
     83      this.flushingSoon = -1
     84      this.flush()
     85    }
     86  }
     87 
     88  start() {
     89    if (this.observer) {
     90      this.observer.takeRecords()
     91      this.observer.observe(this.view.dom, observeOptions)
     92    }
     93    if (this.onCharData)
     94      this.view.dom.addEventListener("DOMCharacterDataModified", this.onCharData)
     95    this.connectSelection()
     96  }
     97 
     98  stop() {
     99    if (this.observer) {
    100      let take = this.observer.takeRecords()
    101      if (take.length) {
    102        for (let i = 0; i < take.length; i++) this.queue.push(take[i])
    103        window.setTimeout(() => this.flush(), 20)
    104      }
    105      this.observer.disconnect()
    106    }
    107    if (this.onCharData) this.view.dom.removeEventListener("DOMCharacterDataModified", this.onCharData)
    108    this.disconnectSelection()
    109  }
    110 
    111  connectSelection() {
    112    this.view.dom.ownerDocument.addEventListener("selectionchange", this.onSelectionChange)
    113  }
    114 
    115  disconnectSelection() {
    116    this.view.dom.ownerDocument.removeEventListener("selectionchange", this.onSelectionChange)
    117  }
    118 
    119  suppressSelectionUpdates() {
    120    this.suppressingSelectionUpdates = true
    121    setTimeout(() => this.suppressingSelectionUpdates = false, 50)
    122  }
    123 
    124  onSelectionChange() {
    125    if (!hasFocusAndSelection(this.view)) return
    126    if (this.suppressingSelectionUpdates) return selectionToDOM(this.view)
    127    // Deletions on IE11 fire their events in the wrong order, giving
    128    // us a selection change event before the DOM changes are
    129    // reported.
    130    if (browser.ie && browser.ie_version <= 11 && !this.view.state.selection.empty) {
    131      let sel = this.view.domSelectionRange()
    132      // Selection.isCollapsed isn't reliable on IE
    133      if (sel.focusNode && isEquivalentPosition(sel.focusNode, sel.focusOffset, sel.anchorNode!, sel.anchorOffset))
    134        return this.flushSoon()
    135    }
    136    this.flush()
    137  }
    138 
    139  setCurSelection() {
    140    this.currentSelection.set(this.view.domSelectionRange())
    141  }
    142 
    143  ignoreSelectionChange(sel: DOMSelectionRange) {
    144    if (!sel.focusNode) return true
    145    let ancestors: Set<Node> = new Set, container: DOMNode | undefined
    146    for (let scan: DOMNode | null = sel.focusNode; scan; scan = parentNode(scan)) ancestors.add(scan)
    147    for (let scan = sel.anchorNode; scan; scan = parentNode(scan)) if (ancestors.has(scan)) {
    148      container = scan
    149      break
    150    }
    151    let desc = container && this.view.docView.nearestDesc(container)
    152    if (desc && desc.ignoreMutation({
    153      type: "selection",
    154      target: container!.nodeType == 3 ? container!.parentNode! : container!
    155    })) {
    156      this.setCurSelection()
    157      return true
    158    }
    159  }
    160 
    161  pendingRecords() {
    162    if (this.observer) for (let mut of this.observer.takeRecords()) this.queue.push(mut)
    163    return this.queue
    164  }
    165 
    166  flush() {
    167    let {view} = this
    168    if (!view.docView || this.flushingSoon > -1) return
    169    let mutations = this.pendingRecords()
    170    if (mutations.length) this.queue = []
    171 
    172    let sel = view.domSelectionRange()
    173    let newSel = !this.suppressingSelectionUpdates && !this.currentSelection.eq(sel) && hasFocusAndSelection(view) && !this.ignoreSelectionChange(sel)
    174 
    175    let from = -1, to = -1, typeOver = false, added: Node[] = []
    176    if (view.editable) {
    177      for (let i = 0; i < mutations.length; i++) {
    178        let result = this.registerMutation(mutations[i], added)
    179        if (result) {
    180          from = from < 0 ? result.from : Math.min(result.from, from)
    181          to = to < 0 ? result.to : Math.max(result.to, to)
    182          if (result.typeOver) typeOver = true
    183        }
    184      }
    185    }
    186 
    187    if (browser.gecko && added.length) {
    188      let brs = added.filter(n => n.nodeName == "BR") as HTMLElement[]
    189      if (brs.length == 2) {
    190        let [a, b] = brs
    191        if (a.parentNode && a.parentNode.parentNode == b.parentNode) b.remove()
    192        else a.remove()
    193      } else {
    194        let {focusNode} = this.currentSelection
    195        for (let br of brs) {
    196          let parent = br.parentNode
    197          if (parent && parent.nodeName == "LI" && (!focusNode || blockParent(view, focusNode) != parent))
    198            br.remove()
    199        }
    200      }
    201    } else if ((browser.chrome || browser.safari) && added.some(n => n.nodeName == "BR") &&
    202               (view.input.lastKeyCode == 8 || view.input.lastKeyCode == 46)) {
    203      // Chrome/Safari sometimes insert a bogus break node if you
    204      // backspace out the last bit of text before an inline-flex node (#1552)
    205      for (let node of added) if (node.nodeName == "BR" && node.parentNode) {
    206        let after = node.nextSibling
    207        if (after && after.nodeType == 1 && (after as HTMLElement).contentEditable == "false")
    208          node.parentNode.removeChild(node)
    209      }
    210    }
    211 
    212    let readSel: Selection | null = null
    213    // If it looks like the browser has reset the selection to the
    214    // start of the document after focus, restore the selection from
    215    // the state
    216    if (from < 0 && newSel && view.input.lastFocus > Date.now() - 200 &&
    217        Math.max(view.input.lastTouch, view.input.lastClick.time) < Date.now() - 300 &&
    218        selectionCollapsed(sel) && (readSel = selectionFromDOM(view)) &&
    219        readSel.eq(Selection.near(view.state.doc.resolve(0), 1))) {
    220      view.input.lastFocus = 0
    221      selectionToDOM(view)
    222      this.currentSelection.set(sel)
    223      view.scrollToSelection()
    224    } else if (from > -1 || newSel) {
    225      if (from > -1) {
    226        view.docView.markDirty(from, to)
    227        checkCSS(view)
    228      }
    229      this.handleDOMChange(from, to, typeOver, added)
    230      if (view.docView && view.docView.dirty) view.updateState(view.state)
    231      else if (!this.currentSelection.eq(sel)) selectionToDOM(view)
    232      this.currentSelection.set(sel)
    233    }
    234  }
    235 
    236  registerMutation(mut: MutationRecord, added: Node[]) {
    237    // Ignore mutations inside nodes that were already noted as inserted
    238    if (added.indexOf(mut.target) > -1) return null
    239    let desc = this.view.docView.nearestDesc(mut.target)
    240    if (mut.type == "attributes" &&
    241        (desc == this.view.docView || mut.attributeName == "contenteditable" ||
    242         // Firefox sometimes fires spurious events for null/empty styles
    243         (mut.attributeName == "style" && !mut.oldValue && !(mut.target as HTMLElement).getAttribute("style"))))
    244      return null
    245    if (!desc || desc.ignoreMutation(mut)) return null
    246 
    247    if (mut.type == "childList") {
    248      for (let i = 0; i < mut.addedNodes.length; i++) {
    249        let node = mut.addedNodes[i]
    250        added.push(node)
    251        if (node.nodeType == 3) this.lastChangedTextNode = node as Text
    252      }
    253      if (desc.contentDOM && desc.contentDOM != desc.dom && !desc.contentDOM.contains(mut.target))
    254        return {from: desc.posBefore, to: desc.posAfter}
    255      let prev = mut.previousSibling, next = mut.nextSibling
    256      if (browser.ie && browser.ie_version <= 11 && mut.addedNodes.length) {
    257        // IE11 gives us incorrect next/prev siblings for some
    258        // insertions, so if there are added nodes, recompute those
    259        for (let i = 0; i < mut.addedNodes.length; i++) {
    260          let {previousSibling, nextSibling} = mut.addedNodes[i]
    261          if (!previousSibling || Array.prototype.indexOf.call(mut.addedNodes, previousSibling) < 0) prev = previousSibling
    262          if (!nextSibling || Array.prototype.indexOf.call(mut.addedNodes, nextSibling) < 0) next = nextSibling
    263        }
    264      }
    265      let fromOffset = prev && prev.parentNode == mut.target
    266          ? domIndex(prev) + 1 : 0
    267      let from = desc.localPosFromDOM(mut.target, fromOffset, -1)
    268      let toOffset = next && next.parentNode == mut.target
    269          ? domIndex(next) : mut.target.childNodes.length
    270      let to = desc.localPosFromDOM(mut.target, toOffset, 1)
    271      return {from, to}
    272    } else if (mut.type == "attributes") {
    273      return {from: desc.posAtStart - desc.border, to: desc.posAtEnd + desc.border}
    274    } else { // "characterData"
    275      this.lastChangedTextNode = mut.target as Text
    276      return {
    277        from: desc.posAtStart,
    278        to: desc.posAtEnd,
    279        // An event was generated for a text change that didn't change
    280        // any text. Mark the dom change to fall back to assuming the
    281        // selection was typed over with an identical value if it can't
    282        // find another change.
    283        typeOver: mut.target.nodeValue == mut.oldValue
    284      }
    285    }
    286  }
    287 }
    288 
    289 let cssChecked: WeakMap<EditorView, null> = new WeakMap()
    290 let cssCheckWarned: boolean = false
    291 
    292 function checkCSS(view: EditorView) {
    293  if (cssChecked.has(view)) return
    294  cssChecked.set(view, null)
    295  if (['normal', 'nowrap', 'pre-line'].indexOf(getComputedStyle(view.dom).whiteSpace) !== -1) {
    296    view.requiresGeckoHackNode = browser.gecko
    297    if (cssCheckWarned) return
    298    console["warn"]("ProseMirror expects the CSS white-space property to be set, preferably to 'pre-wrap'. It is recommended to load style/prosemirror.css from the prosemirror-view package.")
    299    cssCheckWarned = true
    300  }
    301 }
    302 
    303 function rangeToSelectionRange(view: EditorView, range: StaticRange) {
    304  let anchorNode = range.startContainer, anchorOffset = range.startOffset
    305  let focusNode = range.endContainer, focusOffset = range.endOffset
    306 
    307  let currentAnchor = view.domAtPos(view.state.selection.anchor)
    308  // Since such a range doesn't distinguish between anchor and head,
    309  // use a heuristic that flips it around if its end matches the
    310  // current anchor.
    311  if (isEquivalentPosition(currentAnchor.node, currentAnchor.offset, focusNode, focusOffset))
    312    [anchorNode, anchorOffset, focusNode, focusOffset] = [focusNode, focusOffset, anchorNode, anchorOffset]
    313  return {anchorNode, anchorOffset, focusNode, focusOffset}
    314 }
    315 
    316 // Used to work around a Safari Selection/shadow DOM bug
    317 // Based on https://github.com/codemirror/dev/issues/414 fix
    318 export function safariShadowSelectionRange(view: EditorView, selection: DOMSelection): DOMSelectionRange | null {
    319  if ((selection as any).getComposedRanges) {
    320    let range = (selection as any).getComposedRanges(view.root)[0] as StaticRange
    321    if (range) return rangeToSelectionRange(view, range)
    322  }
    323 
    324  let found: StaticRange | undefined
    325  function read(event: InputEvent) {
    326    event.preventDefault()
    327    event.stopImmediatePropagation()
    328    found = event.getTargetRanges()[0]
    329  }
    330 
    331  // Because Safari (at least in 2018-2022) doesn't provide regular
    332  // access to the selection inside a shadowRoot, we have to perform a
    333  // ridiculous hack to get at it—using `execCommand` to trigger a
    334  // `beforeInput` event so that we can read the target range from the
    335  // event.
    336  view.dom.addEventListener("beforeinput", read, true)
    337  document.execCommand("indent")
    338  view.dom.removeEventListener("beforeinput", read, true)
    339 
    340  return found ? rangeToSelectionRange(view, found) : null
    341 }
    342 
    343 function blockParent(view: EditorView, node: DOMNode): Node | null {
    344  for (let p = node.parentNode; p && p != view.dom; p = p.parentNode) {
    345    let desc = view.docView.nearestDesc(p, true)
    346    if (desc && desc.node.isBlock) return p
    347  }
    348  return null
    349 }