| | |
| |
|
| | import { diff_match_patch } from 'diff-match-patch'; |
| | import { Fragment, Node } from 'prosemirror-model'; |
| |
|
| | export const DiffType = { |
| | Unchanged: 0, |
| | Deleted: -1, |
| | Inserted: 1, |
| | }; |
| |
|
| | export const patchDocumentNode = (schema, oldNode, newNode) => { |
| | assertNodeTypeEqual(oldNode, newNode); |
| |
|
| | const finalLeftChildren = []; |
| | const finalRightChildren = []; |
| |
|
| | const oldChildren = normalizeNodeContent(oldNode); |
| | const newChildren = normalizeNodeContent(newNode); |
| | const oldChildLen = oldChildren.length; |
| | const newChildLen = newChildren.length; |
| | const minChildLen = Math.min(oldChildLen, newChildLen); |
| |
|
| | let left = 0; |
| | let right = 0; |
| |
|
| | for (; left < minChildLen; left++) { |
| | const oldChild = oldChildren[left]; |
| | const newChild = newChildren[left]; |
| | if (!isNodeEqual(oldChild, newChild)) { |
| | break; |
| | } |
| | finalLeftChildren.push(...ensureArray(oldChild)); |
| | } |
| |
|
| | for (; right + left + 1 < minChildLen; right++) { |
| | const oldChild = oldChildren[oldChildLen - right - 1]; |
| | const newChild = newChildren[newChildLen - right - 1]; |
| | if (!isNodeEqual(oldChild, newChild)) { |
| | break; |
| | } |
| | finalRightChildren.unshift(...ensureArray(oldChild)); |
| | } |
| |
|
| | const diffOldChildren = oldChildren.slice(left, oldChildLen - right); |
| | const diffNewChildren = newChildren.slice(left, newChildLen - right); |
| |
|
| | if (diffOldChildren.length && diffNewChildren.length) { |
| | const matchedNodes = matchNodes( |
| | schema, |
| | diffOldChildren, |
| | diffNewChildren, |
| | ).sort((a, b) => b.count - a.count); |
| | const bestMatch = matchedNodes[0]; |
| | if (bestMatch) { |
| | const { oldStartIndex, newStartIndex, oldEndIndex, newEndIndex } = |
| | bestMatch; |
| | const oldBeforeMatchChildren = diffOldChildren.slice(0, oldStartIndex); |
| | const newBeforeMatchChildren = diffNewChildren.slice(0, newStartIndex); |
| |
|
| | finalLeftChildren.push( |
| | ...patchRemainNodes( |
| | schema, |
| | oldBeforeMatchChildren, |
| | newBeforeMatchChildren, |
| | ), |
| | ); |
| | finalLeftChildren.push( |
| | ...diffOldChildren.slice(oldStartIndex, oldEndIndex), |
| | ); |
| |
|
| | const oldAfterMatchChildren = diffOldChildren.slice(oldEndIndex); |
| | const newAfterMatchChildren = diffNewChildren.slice(newEndIndex); |
| |
|
| | finalRightChildren.unshift( |
| | ...patchRemainNodes( |
| | schema, |
| | oldAfterMatchChildren, |
| | newAfterMatchChildren, |
| | ), |
| | ); |
| | } else { |
| | finalLeftChildren.push( |
| | ...patchRemainNodes(schema, diffOldChildren, diffNewChildren), |
| | ); |
| | } |
| | } else { |
| | finalLeftChildren.push( |
| | ...patchRemainNodes(schema, diffOldChildren, diffNewChildren), |
| | ); |
| | } |
| |
|
| | return createNewNode(oldNode, [...finalLeftChildren, ...finalRightChildren]); |
| | }; |
| |
|
| | const matchNodes = (schema, oldChildren, newChildren) => { |
| | const matches = []; |
| | for ( |
| | let oldStartIndex = 0; |
| | oldStartIndex < oldChildren.length; |
| | oldStartIndex++ |
| | ) { |
| | const oldStartNode = oldChildren[oldStartIndex]; |
| | const newStartIndex = findMatchNode(newChildren, oldStartNode); |
| |
|
| | if (newStartIndex !== -1) { |
| | let oldEndIndex = oldStartIndex + 1; |
| | let newEndIndex = newStartIndex + 1; |
| | for ( |
| | ; |
| | oldEndIndex < oldChildren.length && newEndIndex < newChildren.length; |
| | oldEndIndex++, newEndIndex++ |
| | ) { |
| | const oldEndNode = oldChildren[oldEndIndex]; |
| | if (!isNodeEqual(newChildren[newEndIndex], oldEndNode)) { |
| | break; |
| | } |
| | } |
| | matches.push({ |
| | oldStartIndex, |
| | newStartIndex, |
| | oldEndIndex, |
| | newEndIndex, |
| | count: newEndIndex - newStartIndex, |
| | }); |
| | } |
| | } |
| | return matches; |
| | }; |
| |
|
| | const findMatchNode = (children, node, startIndex = 0) => { |
| | for (let i = startIndex; i < children.length; i++) { |
| | if (isNodeEqual(children[i], node)) { |
| | return i; |
| | } |
| | } |
| | return -1; |
| | }; |
| |
|
| | const patchRemainNodes = (schema, oldChildren, newChildren) => { |
| | const finalLeftChildren = []; |
| | const finalRightChildren = []; |
| | const oldChildLen = oldChildren.length; |
| | const newChildLen = newChildren.length; |
| | let left = 0; |
| | let right = 0; |
| | while (oldChildLen - left - right > 0 && newChildLen - left - right > 0) { |
| | const leftOldNode = oldChildren[left]; |
| | const leftNewNode = newChildren[left]; |
| | const rightOldNode = oldChildren[oldChildLen - right - 1]; |
| | const rightNewNode = newChildren[newChildLen - right - 1]; |
| | let updateLeft = |
| | !isTextNode(leftOldNode) && matchNodeType(leftOldNode, leftNewNode); |
| | let updateRight = |
| | !isTextNode(rightOldNode) && matchNodeType(rightOldNode, rightNewNode); |
| | if (Array.isArray(leftOldNode) && Array.isArray(leftNewNode)) { |
| | finalLeftChildren.push( |
| | ...patchTextNodes(schema, leftOldNode, leftNewNode), |
| | ); |
| | left += 1; |
| | continue; |
| | } |
| |
|
| | if (updateLeft && updateRight) { |
| | const equalityLeft = computeChildEqualityFactor(leftOldNode, leftNewNode); |
| | const equalityRight = computeChildEqualityFactor( |
| | rightOldNode, |
| | rightNewNode, |
| | ); |
| | if (equalityLeft < equalityRight) { |
| | updateLeft = false; |
| | } else { |
| | updateRight = false; |
| | } |
| | } |
| | if (updateLeft) { |
| | finalLeftChildren.push( |
| | patchDocumentNode(schema, leftOldNode, leftNewNode), |
| | ); |
| | left += 1; |
| | } else if (updateRight) { |
| | finalRightChildren.unshift( |
| | patchDocumentNode(schema, rightOldNode, rightNewNode), |
| | ); |
| | right += 1; |
| | } else { |
| | |
| | finalLeftChildren.push( |
| | createDiffNode(schema, leftOldNode, DiffType.Deleted), |
| | ); |
| | finalLeftChildren.push( |
| | createDiffNode(schema, leftNewNode, DiffType.Inserted), |
| | ); |
| | left += 1; |
| | } |
| | } |
| |
|
| | const deleteNodeLen = oldChildLen - left - right; |
| | const insertNodeLen = newChildLen - left - right; |
| | if (deleteNodeLen) { |
| | finalLeftChildren.push( |
| | ...oldChildren |
| | .slice(left, left + deleteNodeLen) |
| | .flat() |
| | .map((node) => createDiffNode(schema, node, DiffType.Deleted)), |
| | ); |
| | } |
| |
|
| | if (insertNodeLen) { |
| | finalRightChildren.unshift( |
| | ...newChildren |
| | .slice(left, left + insertNodeLen) |
| | .flat() |
| | .map((node) => createDiffNode(schema, node, DiffType.Inserted)), |
| | ); |
| | } |
| |
|
| | return [...finalLeftChildren, ...finalRightChildren]; |
| | }; |
| |
|
| | |
| | export const patchTextNodes = (schema, oldNode, newNode) => { |
| | const dmp = new diff_match_patch(); |
| |
|
| | |
| | const oldText = oldNode.map((n) => getNodeText(n)).join(''); |
| | const newText = newNode.map((n) => getNodeText(n)).join(''); |
| |
|
| | |
| | const oldSentences = tokenizeSentences(oldText); |
| | const newSentences = tokenizeSentences(newText); |
| |
|
| | |
| | const { chars1, chars2, lineArray } = sentencesToChars( |
| | oldSentences, |
| | newSentences, |
| | ); |
| |
|
| | |
| | let diffs = dmp.diff_main(chars1, chars2, false); |
| |
|
| | |
| | diffs = diffs.map(([type, text]) => { |
| | const sentences = text |
| | .split('') |
| | .map((char) => lineArray[char.charCodeAt(0)]); |
| | return [type, sentences]; |
| | }); |
| |
|
| | |
| | const res = diffs.flatMap(([type, sentences]) => { |
| | return sentences.map((sentence) => { |
| | const node = createTextNode( |
| | schema, |
| | sentence, |
| | type !== DiffType.Unchanged ? [createDiffMark(schema, type)] : [], |
| | ); |
| | return node; |
| | }); |
| | }); |
| |
|
| | return res; |
| | }; |
| |
|
| | |
| | const tokenizeSentences = (text) => { |
| | return text.match(/[^.!?]+[.!?]*\s*/g) || []; |
| | }; |
| |
|
| | |
| | const sentencesToChars = (oldSentences, newSentences) => { |
| | const lineArray = []; |
| | const lineHash = {}; |
| | let lineStart = 0; |
| |
|
| | const chars1 = oldSentences |
| | .map((sentence) => { |
| | const line = sentence; |
| | if (line in lineHash) { |
| | return String.fromCharCode(lineHash[line]); |
| | } |
| | lineHash[line] = lineStart; |
| | lineArray[lineStart] = line; |
| | lineStart++; |
| | return String.fromCharCode(lineHash[line]); |
| | }) |
| | .join(''); |
| |
|
| | const chars2 = newSentences |
| | .map((sentence) => { |
| | const line = sentence; |
| | if (line in lineHash) { |
| | return String.fromCharCode(lineHash[line]); |
| | } |
| | lineHash[line] = lineStart; |
| | lineArray[lineStart] = line; |
| | lineStart++; |
| | return String.fromCharCode(lineHash[line]); |
| | }) |
| | .join(''); |
| |
|
| | return { chars1, chars2, lineArray }; |
| | }; |
| |
|
| | export const computeChildEqualityFactor = (node1, node2) => { |
| | return 0; |
| | }; |
| |
|
| | export const assertNodeTypeEqual = (node1, node2) => { |
| | if (getNodeProperty(node1, 'type') !== getNodeProperty(node2, 'type')) { |
| | throw new Error(`node type not equal: ${node1.type} !== ${node2.type}`); |
| | } |
| | }; |
| |
|
| | export const ensureArray = (value) => { |
| | return Array.isArray(value) ? value : [value]; |
| | }; |
| |
|
| | export const isNodeEqual = (node1, node2) => { |
| | const isNode1Array = Array.isArray(node1); |
| | const isNode2Array = Array.isArray(node2); |
| | if (isNode1Array !== isNode2Array) { |
| | return false; |
| | } |
| | if (isNode1Array) { |
| | return ( |
| | node1.length === node2.length && |
| | node1.every((node, index) => isNodeEqual(node, node2[index])) |
| | ); |
| | } |
| |
|
| | const type1 = getNodeProperty(node1, 'type'); |
| | const type2 = getNodeProperty(node2, 'type'); |
| | if (type1 !== type2) { |
| | return false; |
| | } |
| | if (isTextNode(node1)) { |
| | const text1 = getNodeProperty(node1, 'text'); |
| | const text2 = getNodeProperty(node2, 'text'); |
| | if (text1 !== text2) { |
| | return false; |
| | } |
| | } |
| | const attrs1 = getNodeAttributes(node1); |
| | const attrs2 = getNodeAttributes(node2); |
| | const attrs = [...new Set([...Object.keys(attrs1), ...Object.keys(attrs2)])]; |
| | for (const attr of attrs) { |
| | if (attrs1[attr] !== attrs2[attr]) { |
| | return false; |
| | } |
| | } |
| | const marks1 = getNodeMarks(node1); |
| | const marks2 = getNodeMarks(node2); |
| | if (marks1.length !== marks2.length) { |
| | return false; |
| | } |
| | for (let i = 0; i < marks1.length; i++) { |
| | if (!isNodeEqual(marks1[i], marks2[i])) { |
| | return false; |
| | } |
| | } |
| | const children1 = getNodeChildren(node1); |
| | const children2 = getNodeChildren(node2); |
| | if (children1.length !== children2.length) { |
| | return false; |
| | } |
| | for (let i = 0; i < children1.length; i++) { |
| | if (!isNodeEqual(children1[i], children2[i])) { |
| | return false; |
| | } |
| | } |
| | return true; |
| | }; |
| |
|
| | export const normalizeNodeContent = (node) => { |
| | const content = getNodeChildren(node) ?? []; |
| | const res = []; |
| | for (let i = 0; i < content.length; i++) { |
| | const child = content[i]; |
| | if (isTextNode(child)) { |
| | const textNodes = []; |
| | for ( |
| | let textNode = content[i]; |
| | i < content.length && isTextNode(textNode); |
| | textNode = content[++i] |
| | ) { |
| | textNodes.push(textNode); |
| | } |
| | i--; |
| | res.push(textNodes); |
| | } else { |
| | res.push(child); |
| | } |
| | } |
| | return res; |
| | }; |
| |
|
| | export const getNodeProperty = (node, property) => { |
| | if (property === 'type') { |
| | return node.type?.name; |
| | } |
| | return node[property]; |
| | }; |
| |
|
| | export const getNodeAttribute = (node, attribute) => |
| | node.attrs ? node.attrs[attribute] : undefined; |
| |
|
| | export const getNodeAttributes = (node) => (node.attrs ? node.attrs : {}); |
| |
|
| | export const getNodeMarks = (node) => node.marks ?? []; |
| |
|
| | export const getNodeChildren = (node) => node.content?.content ?? []; |
| |
|
| | export const getNodeText = (node) => node.text; |
| |
|
| | export const isTextNode = (node) => node.type?.name === 'text'; |
| |
|
| | export const matchNodeType = (node1, node2) => |
| | node1.type?.name === node2.type?.name || |
| | (Array.isArray(node1) && Array.isArray(node2)); |
| |
|
| | export const createNewNode = (oldNode, children) => { |
| | if (!oldNode.type) { |
| | throw new Error('oldNode.type is undefined'); |
| | } |
| | return new Node( |
| | oldNode.type, |
| | oldNode.attrs, |
| | Fragment.fromArray(children), |
| | oldNode.marks, |
| | ); |
| | }; |
| |
|
| | export const createDiffNode = (schema, node, type) => { |
| | return mapDocumentNode(node, (node) => { |
| | if (isTextNode(node)) { |
| | return createTextNode(schema, getNodeText(node), [ |
| | ...(node.marks || []), |
| | createDiffMark(schema, type), |
| | ]); |
| | } |
| | return node; |
| | }); |
| | }; |
| |
|
| | function mapDocumentNode(node, mapper) { |
| | const copy = node.copy( |
| | Fragment.from( |
| | node.content.content |
| | .map((node) => mapDocumentNode(node, mapper)) |
| | .filter((n) => n), |
| | ), |
| | ); |
| | return mapper(copy) || copy; |
| | } |
| |
|
| | export const createDiffMark = (schema, type) => { |
| | if (type === DiffType.Inserted) { |
| | return schema.mark('diffMark', { type }); |
| | } |
| | if (type === DiffType.Deleted) { |
| | return schema.mark('diffMark', { type }); |
| | } |
| | throw new Error('type is not valid'); |
| | }; |
| |
|
| | export const createTextNode = (schema, content, marks = []) => { |
| | return schema.text(content, marks); |
| | }; |
| |
|
| | export const diffEditor = (schema, oldDoc, newDoc) => { |
| | const oldNode = Node.fromJSON(schema, oldDoc); |
| | const newNode = Node.fromJSON(schema, newDoc); |
| | return patchDocumentNode(schema, oldNode, newNode); |
| | }; |
| |
|