Add canvas interface (#461)

This commit is contained in:
Jeremy 2024-10-30 16:01:24 +05:30 committed by GitHub
parent 1a74a5ca9a
commit b3cb0ea755
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 7454 additions and 4691 deletions

7
lib/editor/DiffType.js Normal file
View file

@ -0,0 +1,7 @@
// Taken from https://github.com/hamflx/prosemirror-diff/blob/master/src/DiffType.js
export const DiffType = {
Unchanged: 0,
Deleted: -1,
Inserted: 1,
};

511
lib/editor/diff.js Normal file
View file

@ -0,0 +1,511 @@
// 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);
};

2
lib/editor/index.js Normal file
View file

@ -0,0 +1,2 @@
export * from "./diff.js";
export * from "./DiffType";

View file

@ -0,0 +1,11 @@
import { createRoot } from "react-dom/client";
export class ReactRenderer {
static render(component: React.ReactElement, dom: HTMLElement) {
const root = createRoot(dom);
root.render(component);
return {
destroy: () => root.unmount(),
};
}
}

118
lib/editor/suggestions.tsx Normal file
View file

@ -0,0 +1,118 @@
import { Node } from 'prosemirror-model';
import { PluginKey, Plugin } from 'prosemirror-state';
import { DecorationSet, EditorView } from 'prosemirror-view';
import { createRoot } from 'react-dom/client';
import { Suggestion as PreviewSuggestion } from '@/components/custom/suggestion';
import { Suggestion } from '@/db/schema';
export interface UISuggestion extends Suggestion {
selectionStart: number;
selectionEnd: number;
}
interface Position {
start: number;
end: number;
}
function findPositionsInDoc(doc: Node, searchText: string): Position | null {
let positions: { start: number; end: number } | null = null;
doc.nodesBetween(0, doc.content.size, (node, pos) => {
if (node.isText && node.text) {
const index = node.text.indexOf(searchText);
if (index !== -1) {
positions = {
start: pos + index,
end: pos + index + searchText.length,
};
return false;
}
}
return true;
});
return positions;
}
export function projectWithHighlights(
doc: Node,
suggestions: Array<Suggestion>
): Array<UISuggestion> {
return suggestions.map((suggestion) => {
const positions = findPositionsInDoc(doc, suggestion.originalText);
if (!positions) {
return {
...suggestion,
selectionStart: 0,
selectionEnd: 0,
};
}
return {
...suggestion,
selectionStart: positions.start,
selectionEnd: positions.end,
};
});
}
export function createSuggestionWidget(
suggestion: UISuggestion,
view: EditorView
): { dom: HTMLElement; destroy: () => void } {
const dom = document.createElement('span');
const root = createRoot(dom);
const onApply = () => {
const { state, dispatch } = view;
const tr = state.tr.replaceWith(
suggestion.selectionStart,
suggestion.selectionEnd,
state.schema.text(suggestion.suggestedText)
);
dispatch(tr);
};
root.render(<PreviewSuggestion suggestion={suggestion} onApply={onApply} />);
return {
dom,
destroy: () => {
// Wrapping unmount in setTimeout to avoid synchronous unmounting during render
setTimeout(() => {
root.unmount();
}, 0);
},
};
}
export const suggestionsPluginKey = new PluginKey('suggestions');
export const suggestionsPlugin = new Plugin({
key: suggestionsPluginKey,
state: {
init() {
return { decorations: DecorationSet.empty, selected: null };
},
apply(tr, state) {
const newDecorations = tr.getMeta(suggestionsPluginKey);
if (newDecorations) return newDecorations;
return {
decorations: state.decorations.map(tr.mapping, tr.doc),
selected: state.selected,
};
},
},
props: {
decorations(state) {
return this.getState(state)?.decorations ?? DecorationSet.empty;
},
},
});