import {Fragment, Node} from "prosemirror-model" import {Span, Change} from "./change" let letter: RegExp | undefined // If the runtime support unicode properties in regexps, that's a good // source of info on whether something is a letter. try { letter = new RegExp("[\\p{Alphabetic}_]", "u") } catch(_) {} // Otherwise, we see if the character changes when upper/lowercased, // or if it is part of these common single-case scripts. const nonASCIISingleCaseWordChar = /[\u00df\u0587\u0590-\u05f4\u0600-\u06ff\u3040-\u309f\u30a0-\u30ff\u3400-\u4db5\u4e00-\u9fcc\uac00-\ud7af]/ function isLetter(code: number) { if (code < 128) return code >= 48 && code <= 57 || code >= 65 && code <= 90 || code >= 79 && code <= 122 let ch = String.fromCharCode(code) if (letter) return letter.test(ch) return ch.toUpperCase() != ch.toLowerCase() || nonASCIISingleCaseWordChar.test(ch) } // Convert a range of document into a string, so that we can easily // access characters at a given position. Treat non-text tokens as // spaces so that they aren't considered part of a word. function getText(frag: Fragment, start: number, end: number) { let out = "" function convert(frag: Fragment, start: number, end: number) { for (let i = 0, off = 0; i < frag.childCount; i++) { let child = frag.child(i), endOff = off + child.nodeSize let from = Math.max(off, start), to = Math.min(endOff, end) if (from < to) { if (child.isText) { out += child.text!.slice(Math.max(0, start - off), Math.min(child.text!.length, end - off)) } else if (child.isLeaf) { out += " " } else { if (from == off) out += " " convert(child.content, Math.max(0, from - off - 1), Math.min(child.content.size, end - off)) if (to == endOff) out += " " } } off = endOff } } convert(frag, start, end) return out } // The distance changes have to be apart for us to not consider them // candidates for merging. const MAX_SIMPLIFY_DISTANCE = 30 /// Simplifies a set of changes for presentation. This makes the /// assumption that having both insertions and deletions within a word /// is confusing, and, when such changes occur without a word boundary /// between them, they should be expanded to cover the entire set of /// words (in the new document) they touch. An exception is made for /// single-character replacements. export function simplifyChanges(changes: readonly Change[], doc: Node) { let result: Change[] = [] for (let i = 0; i < changes.length; i++) { let end = changes[i].toB, start = i while (i < changes.length - 1 && changes[i + 1].fromB <= end + MAX_SIMPLIFY_DISTANCE) end = changes[++i].toB simplifyAdjacentChanges(changes, start, i + 1, doc, result) } return result } function simplifyAdjacentChanges(changes: readonly Change[], from: number, to: number, doc: Node, target: Change[]) { let start = Math.max(0, changes[from].fromB - MAX_SIMPLIFY_DISTANCE) let end = Math.min(doc.content.size, changes[to - 1].toB + MAX_SIMPLIFY_DISTANCE) let text = getText(doc.content, start, end) for (let i = from; i < to; i++) { let startI = i, last = changes[i], deleted = last.lenA, inserted = last.lenB while (i < to - 1) { let next = changes[i + 1], boundary = false let prevLetter = last.toB == end ? false : isLetter(text.charCodeAt(last.toB - 1 - start)) for (let pos = last.toB; !boundary && pos < next.fromB; pos++) { let nextLetter = pos == end ? false : isLetter(text.charCodeAt(pos - start)) if ((!prevLetter || !nextLetter) && pos != changes[startI].fromB) boundary = true prevLetter = nextLetter } if (boundary) break deleted += next.lenA; inserted += next.lenB last = next i++ } if (inserted > 0 && deleted > 0 && !(inserted == 1 && deleted == 1)) { let from = changes[startI].fromB, to = changes[i].toB if (from < end && isLetter(text.charCodeAt(from - start))) while (from > start && isLetter(text.charCodeAt(from - 1 - start))) from-- if (to > start && isLetter(text.charCodeAt(to - 1 - start))) while (to < end && isLetter(text.charCodeAt(to - start))) to++ let joined = fillChange(changes.slice(startI, i + 1), from, to) let last = target.length ? target[target.length - 1] : null if (last && last.toA == joined.fromA) target[target.length - 1] = new Change(last.fromA, joined.toA, last.fromB, joined.toB, last.deleted.concat(joined.deleted), last.inserted.concat(joined.inserted)) else target.push(joined) } else { for (let j = startI; j <= i; j++) target.push(changes[j]) } } return changes } function combine(a: T, b: T): T { return a === b ? a : null as any } function fillChange(changes: readonly Change[], fromB: number, toB: number) { let fromA = changes[0].fromA - (changes[0].fromB - fromB) let last = changes[changes.length - 1] let toA = last.toA + (toB - last.toB) let deleted = Span.none, inserted = Span.none let delData = (changes[0].deleted.length ? changes[0].deleted : changes[0].inserted)[0].data let insData = (changes[0].inserted.length ? changes[0].inserted : changes[0].deleted)[0].data for (let posA = fromA, posB = fromB, i = 0;; i++) { let next = i == changes.length ? null : changes[i] let endA = next ? next.fromA : toA, endB = next ? next.fromB : toB if (endA > posA) deleted = Span.join(deleted, [new Span(endA - posA, delData)], combine) if (endB > posB) inserted = Span.join(inserted, [new Span(endB - posB, insData)], combine) if (!next) break deleted = Span.join(deleted, next.deleted, combine) inserted = Span.join(inserted, next.inserted, combine) if (deleted.length) delData = deleted[deleted.length - 1].data if (inserted.length) insData = inserted[inserted.length - 1].data posA = next.toA; posB = next.toB } return new Change(fromA, toA, fromB, toB, deleted, inserted) }