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 }