133 lines
5.9 KiB
TypeScript
133 lines
5.9 KiB
TypeScript
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 >= 97 && 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<T>(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)
|
|
}
|