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 }