512 lines
15 KiB
JavaScript
512 lines
15 KiB
JavaScript
|
|
// Taken from https://github.com/hamflx/prosemirror-diff/blob/master/src/diff.js
|
||
|
|
|
||
|
|
import { diff_match_patch } from 'diff-match-patch';
|
||
|
|
import { Fragment, Node } from 'prosemirror-model';
|
||
|
|
|
||
|
|
import { DiffType } from './DiffType.js';
|
||
|
|
|
||
|
|
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;
|
||
|
|
|
||
|
|
// console.log("==> searching same left");
|
||
|
|
for (; left < minChildLen; left++) {
|
||
|
|
const oldChild = oldChildren[left];
|
||
|
|
const newChild = newChildren[left];
|
||
|
|
if (!isNodeEqual(oldChild, newChild)) {
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
finalLeftChildren.push(...ensureArray(oldChild));
|
||
|
|
}
|
||
|
|
|
||
|
|
// console.log("==> searching same right");
|
||
|
|
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));
|
||
|
|
}
|
||
|
|
|
||
|
|
// console.log(
|
||
|
|
// `==> eq left:${left}, right:${right}`,
|
||
|
|
// [...finalLeftChildren],
|
||
|
|
// [...finalRightChildren],
|
||
|
|
// );
|
||
|
|
|
||
|
|
const diffOldChildren = oldChildren.slice(left, oldChildLen - right);
|
||
|
|
const diffNewChildren = newChildren.slice(left, newChildLen - right);
|
||
|
|
// console.log(
|
||
|
|
// "==> diff children",
|
||
|
|
// diffOldChildren.length,
|
||
|
|
// diffNewChildren.length,
|
||
|
|
// );
|
||
|
|
if (diffOldChildren.length && diffNewChildren.length) {
|
||
|
|
const matchedNodes = matchNodes(
|
||
|
|
schema,
|
||
|
|
diffOldChildren,
|
||
|
|
diffNewChildren
|
||
|
|
).sort((a, b) => b.count - a.count);
|
||
|
|
const bestMatch = matchedNodes[0];
|
||
|
|
if (bestMatch) {
|
||
|
|
// console.log("==> bestMatch", bestMatch);
|
||
|
|
const { oldStartIndex, newStartIndex, oldEndIndex, newEndIndex } =
|
||
|
|
bestMatch;
|
||
|
|
const oldBeforeMatchChildren = diffOldChildren.slice(0, oldStartIndex);
|
||
|
|
const newBeforeMatchChildren = diffNewChildren.slice(0, newStartIndex);
|
||
|
|
// console.log(
|
||
|
|
// "==> before match",
|
||
|
|
// oldBeforeMatchChildren.length,
|
||
|
|
// newBeforeMatchChildren.length,
|
||
|
|
// oldBeforeMatchChildren,
|
||
|
|
// newBeforeMatchChildren,
|
||
|
|
// );
|
||
|
|
finalLeftChildren.push(
|
||
|
|
...patchRemainNodes(
|
||
|
|
schema,
|
||
|
|
oldBeforeMatchChildren,
|
||
|
|
newBeforeMatchChildren
|
||
|
|
)
|
||
|
|
);
|
||
|
|
finalLeftChildren.push(
|
||
|
|
...diffOldChildren.slice(oldStartIndex, oldEndIndex)
|
||
|
|
);
|
||
|
|
// console.log("==> match", oldEndIndex - oldStartIndex);
|
||
|
|
|
||
|
|
const oldAfterMatchChildren = diffOldChildren.slice(oldEndIndex);
|
||
|
|
const newAfterMatchChildren = diffNewChildren.slice(newEndIndex);
|
||
|
|
// console.log(
|
||
|
|
// "==> after match",
|
||
|
|
// oldAfterMatchChildren.length,
|
||
|
|
// newAfterMatchChildren.length,
|
||
|
|
// );
|
||
|
|
finalRightChildren.unshift(
|
||
|
|
...patchRemainNodes(
|
||
|
|
schema,
|
||
|
|
oldAfterMatchChildren,
|
||
|
|
newAfterMatchChildren
|
||
|
|
)
|
||
|
|
);
|
||
|
|
} else {
|
||
|
|
// console.log("==> no best match found");
|
||
|
|
finalLeftChildren.push(
|
||
|
|
...patchRemainNodes(schema, diffOldChildren, diffNewChildren)
|
||
|
|
);
|
||
|
|
}
|
||
|
|
// console.log("==> matchedNodes", matchedNodes);
|
||
|
|
} else {
|
||
|
|
finalLeftChildren.push(
|
||
|
|
...patchRemainNodes(schema, diffOldChildren, diffNewChildren)
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
return createNewNode(oldNode, [...finalLeftChildren, ...finalRightChildren]);
|
||
|
|
};
|
||
|
|
|
||
|
|
const matchNodes = (schema, oldChildren, newChildren) => {
|
||
|
|
// console.log("==> matchNodes", 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;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
// console.log(
|
||
|
|
// "==> match",
|
||
|
|
// oldStartIndex,
|
||
|
|
// oldEndIndex,
|
||
|
|
// newStartIndex,
|
||
|
|
// newEndIndex,
|
||
|
|
// );
|
||
|
|
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 {
|
||
|
|
// todo
|
||
|
|
finalLeftChildren.push(
|
||
|
|
createDiffNode(schema, leftOldNode, DiffType.Deleted)
|
||
|
|
);
|
||
|
|
finalLeftChildren.push(
|
||
|
|
createDiffNode(schema, leftNewNode, DiffType.Inserted)
|
||
|
|
);
|
||
|
|
left += 1;
|
||
|
|
// delete and insert
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
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 diff = dmp.diff_main(oldText, newText);
|
||
|
|
let oldLen = 0;
|
||
|
|
let newLen = 0;
|
||
|
|
const res = diff
|
||
|
|
.map((d) => {
|
||
|
|
const [type, content] = [d[0], d[1]];
|
||
|
|
const node = createTextNode(
|
||
|
|
schema,
|
||
|
|
content,
|
||
|
|
type !== DiffType.Unchanged ? createDiffMark(schema, type) : []
|
||
|
|
);
|
||
|
|
const oldFrom = oldLen;
|
||
|
|
const oldTo = oldFrom + (type === DiffType.Inserted ? 0 : content.length);
|
||
|
|
const newFrom = newLen;
|
||
|
|
const newTo = newFrom + (type === DiffType.Deleted ? 0 : content.length);
|
||
|
|
oldLen = oldTo;
|
||
|
|
newLen = newTo;
|
||
|
|
return { node, type, oldFrom, oldTo, newFrom, newTo };
|
||
|
|
})
|
||
|
|
.map(({ node, type, oldFrom, oldTo, newFrom, newTo }) => {
|
||
|
|
if (type === DiffType.Deleted) {
|
||
|
|
const textItems = findTextNodes(oldNode, oldFrom, oldTo).filter(
|
||
|
|
(n) => Object.keys(n.node.attrs ?? {}).length || n.node.marks?.length
|
||
|
|
);
|
||
|
|
return applyTextNodeAttrsMarks(schema, node, oldFrom, textItems);
|
||
|
|
} else {
|
||
|
|
const textItems = findTextNodes(newNode, newFrom, newTo).filter(
|
||
|
|
(n) => Object.keys(n.node.attrs ?? {}).length || n.node.marks?.length
|
||
|
|
);
|
||
|
|
return applyTextNodeAttrsMarks(schema, node, newFrom, textItems);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
return res.flat(Infinity);
|
||
|
|
};
|
||
|
|
|
||
|
|
const findTextNodes = (textNodes, from, to) => {
|
||
|
|
const result = [];
|
||
|
|
let start = 0;
|
||
|
|
for (let i = 0; i < textNodes.length && start < to; i++) {
|
||
|
|
const node = textNodes[i];
|
||
|
|
const text = getNodeText(node);
|
||
|
|
const end = start + text.length;
|
||
|
|
const intersect =
|
||
|
|
(start >= from && start < to) ||
|
||
|
|
(end > from && end <= to) ||
|
||
|
|
(start <= from && end >= to);
|
||
|
|
if (intersect) {
|
||
|
|
result.push({ node, from: start, to: end });
|
||
|
|
}
|
||
|
|
start += text.length;
|
||
|
|
}
|
||
|
|
return result;
|
||
|
|
};
|
||
|
|
|
||
|
|
const applyTextNodeAttrsMarks = (schema, node, base, textItems) => {
|
||
|
|
if (!textItems.length) {
|
||
|
|
return node;
|
||
|
|
}
|
||
|
|
|
||
|
|
const baseMarks = node.marks ?? [];
|
||
|
|
const firstItem = textItems[0];
|
||
|
|
const nodeText = getNodeText(node);
|
||
|
|
const nodeEnd = base + nodeText.length;
|
||
|
|
const result = [];
|
||
|
|
if (firstItem.from - base > 0) {
|
||
|
|
result.push(
|
||
|
|
createTextNode(
|
||
|
|
schema,
|
||
|
|
nodeText.slice(0, firstItem.from - base),
|
||
|
|
baseMarks
|
||
|
|
)
|
||
|
|
);
|
||
|
|
}
|
||
|
|
for (let i = 0; i < textItems.length; i++) {
|
||
|
|
const { from, node: textNode, to } = textItems[i];
|
||
|
|
result.push(
|
||
|
|
createTextNode(
|
||
|
|
schema,
|
||
|
|
nodeText.slice(Math.max(from, base) - base, to - base),
|
||
|
|
[...baseMarks, ...(textNode.marks ?? [])]
|
||
|
|
)
|
||
|
|
);
|
||
|
|
const nextFrom = i + 1 < textItems.length ? textItems[i + 1].from : nodeEnd;
|
||
|
|
if (nextFrom > to) {
|
||
|
|
result.push(
|
||
|
|
createTextNode(
|
||
|
|
schema,
|
||
|
|
nodeText.slice(to - base, nextFrom - base),
|
||
|
|
baseMarks
|
||
|
|
)
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return result;
|
||
|
|
};
|
||
|
|
|
||
|
|
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 : undefined;
|
||
|
|
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);
|
||
|
|
};
|