fix: supported incremental updates (#6531)

This commit is contained in:
Kilu.He 2024-10-11 11:29:29 +08:00 committed by GitHub
parent ea61c81cce
commit b5936cec54
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 343 additions and 48 deletions

View File

@ -143,6 +143,7 @@ export const CustomEditor = {
handleMergeBlockBackwardWithTxn(editor, node, point);
} else {
Transforms.collapse(editor, { edge: 'start' });
removeRangeWithTxn(editor, sharedRoot, newAt);
}

View File

@ -1,3 +1,4 @@
import { translateYEvents } from '@/application/slate-yjs/utils/applyToSlate';
import { CollabOrigin, YjsEditorKey, YSharedRoot } from '@/application/types';
import { applyToYjs } from '@/application/slate-yjs/utils/applyToYjs';
import { Editor, Operation, Descendant, Transforms } from 'slate';
@ -125,7 +126,7 @@ export function withYjs<T extends Editor> (
apply(op);
};
e.applyRemoteEvents = (_events: Array<YEvent>, _transaction: Transaction) => {
e.applyRemoteEvents = (events: Array<YEvent>, transaction: Transaction) => {
console.time('applyRemoteEvents');
// Flush local changes to ensure all local changes are applied before processing remote events
YjsEditor.flushLocalChanges(e);
@ -133,7 +134,28 @@ export function withYjs<T extends Editor> (
e.interceptLocalChange = true;
// Initialize or update the document content to ensure it is in the correct state before applying remote events
initializeDocumentContent();
if (transaction.origin === CollabOrigin.Remote) {
initializeDocumentContent();
} else {
const selection = editor.selection;
Editor.withoutNormalizing(e, () => {
translateYEvents(e, events);
});
if (selection) {
if (!ReactEditor.hasRange(editor, selection)) {
try {
Transforms.select(e, Editor.start(editor, [0]));
} catch (e) {
console.error(e);
editor.deselect();
}
} else {
e.select(selection);
}
}
}
// Restore the apply function to store local changes after applying remote changes
e.interceptLocalChange = false;
@ -141,9 +163,8 @@ export function withYjs<T extends Editor> (
};
const handleYEvents = (events: Array<YEvent>, transaction: Transaction) => {
if (transaction.origin !== CollabOrigin.Local) {
YjsEditor.applyRemoteEvents(e, events, transaction);
}
if (transaction.origin === CollabOrigin.Local) return;
YjsEditor.applyRemoteEvents(e, events, transaction);
};

View File

@ -0,0 +1,208 @@
import { YjsEditor } from '@/application/slate-yjs';
import { BlockJson } from '@/application/slate-yjs/types';
import { blockToSlateNode, deltaInsertToSlateNode } from '@/application/slate-yjs/utils/convert';
import {
dataStringTOJson,
getBlock,
getChildrenArray,
getPageId,
getText,
} from '@/application/slate-yjs/utils/yjsOperations';
import { YBlock, YjsEditorKey } from '@/application/types';
import isEqual from 'lodash-es/isEqual';
import { Editor, Element, NodeEntry } from 'slate';
import { YEvent, YMapEvent, YTextEvent } from 'yjs';
import { YText } from 'yjs/dist/src/types/YText';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type BlockMapEvent = YMapEvent<any>
export function translateYEvents (editor: YjsEditor, events: Array<YEvent>) {
console.log('=== Translating Yjs events ===', events);
events.forEach((event) => {
console.log(event.path);
if (isEqual(event.path, ['document', 'blocks'])) {
applyBlocksYEvent(editor, event as BlockMapEvent);
}
if (isEqual((event.path), ['document', 'blocks', event.path[2]])) {
const blockId = event.path[2] as string;
applyUpdateBlockYEvent(editor, blockId, event as YMapEvent<unknown>);
}
if (isEqual(event.path, ['document', 'meta', 'text_map', event.path[3]])) {
const textId = event.path[3] as string;
applyTextYEvent(editor, textId, event as YTextEvent);
}
});
}
function applyUpdateBlockYEvent (editor: YjsEditor, blockId: string, event: YMapEvent<unknown>) {
const { target } = event;
const block = target as YBlock;
const newData = dataStringTOJson(block.get(YjsEditorKey.block_data));
const [entry] = editor.nodes({
match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.blockId === blockId,
mode: 'all',
});
if (!entry) {
console.error('Block node not found', blockId);
return [];
}
const [node, path] = entry as NodeEntry<Element>;
const oldData = node.data as Record<string, unknown>;
editor.apply({
type: 'set_node',
path,
newProperties: {
data: newData,
},
properties: {
data: oldData,
},
});
}
function applyTextYEvent (editor: YjsEditor, textId: string, event: YTextEvent) {
const { target } = event;
const yText = target as YText;
const delta = yText.toDelta();
const slateDelta = delta.flatMap(deltaInsertToSlateNode);
const [entry] = editor.nodes({
match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.textId === textId,
mode: 'all',
});
if (!entry) {
console.error('Text node not found', textId);
return [];
}
editor.apply({
type: 'remove_node',
path: entry[1],
node: entry[0],
});
editor.apply({
type: 'insert_node',
path: entry[1],
node: {
textId,
type: YjsEditorKey.text,
children: slateDelta,
},
});
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function applyBlocksYEvent (editor: YjsEditor, event: BlockMapEvent) {
const { changes, keysChanged } = event;
const { keys } = changes;
const keyPath: Record<string, number[]> = {};
keysChanged.forEach((key: string) => {
const value = keys.get(key);
if (!value) return;
if (value.action === 'add') {
handleNewBlock(editor, key, keyPath);
} else if (value.action === 'delete') {
handleDeleteNode(editor, key);
} else if (value.action === 'update') {
console.log('=== Applying block update Yjs event ===', key);
}
});
}
function handleNewBlock (editor: YjsEditor, key: string, keyPath: Record<string, number[]>) {
const block = getBlock(key, editor.sharedRoot);
const parentId = block.get(YjsEditorKey.block_parent);
const pageId = getPageId(editor.sharedRoot);
const parent = getBlock(parentId, editor.sharedRoot);
const parentChildren = getChildrenArray(parent.get(YjsEditorKey.block_children), editor.sharedRoot);
const index = parentChildren.toArray().findIndex((child) => child === key);
const slateNode = blockToSlateNode(block.toJSON() as BlockJson);
const textId = block.get(YjsEditorKey.block_external_id);
const yText = getText(textId, editor.sharedRoot);
const delta = yText.toDelta();
const slateDelta = delta.flatMap(deltaInsertToSlateNode);
if (slateDelta.length === 0) {
slateDelta.push({
text: '',
});
}
const textNode: Element = {
textId,
type: YjsEditorKey.text,
children: slateDelta,
};
let path = [index];
if (parentId !== pageId) {
const [parentEntry] = editor.nodes({
match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.blockId === parentId,
mode: 'all',
});
if (!parentEntry) {
if (keyPath[parentId]) {
path = [...keyPath[parentId], index + 1];
} else {
console.error('Parent block not found', parentId);
return [];
}
} else {
const childrenLength = (parentEntry[0] as Element).children.length;
path = [...parentEntry[1], Math.min(index + 1, childrenLength)];
}
}
editor.apply({
type: 'insert_node',
path,
node: {
...slateNode,
children: [textNode],
},
});
keyPath[key] = path;
}
function handleDeleteNode (editor: YjsEditor, key: string) {
const [entry] = editor.nodes({
at: [],
match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.blockId === key,
});
if (!entry) {
console.error('Block not found');
return [];
}
const [node, path] = entry;
editor.apply({
type: 'remove_node',
path,
node,
});
}

View File

@ -123,18 +123,26 @@ function applyRemoveText (ydoc: Y.Doc, editor: Editor, op: RemoveTextOperation,
const textId = node.textId;
if (!textId) return;
if (!textId) {
console.error('textId not found', node);
return;
}
const sharedRoot = ydoc.getMap(YjsEditorKey.data_section) as YSharedRoot;
const yText = getText(textId, sharedRoot);
if (!yText) return;
if (!yText) {
console.error('yText not found', textId, sharedRoot.toJSON());
return;
}
const point = { path, offset };
const relativeOffset = Math.min(calculateOffsetRelativeToParent(node, point), yText.toJSON().length);
yText.delete(relativeOffset, text.length);
console.log('applyRemoveText', op, yText.toDelta());
}
function applySetNode (ydoc: Y.Doc, editor: Editor, op: SetNodeOperation, slateContent: Descendant[]) {

View File

@ -64,6 +64,7 @@ export function yDataToSlateContent ({
const yText = textId ? textMap.get(textId) : undefined;
if (!yText) {
if (children.length === 0) {
children.push({
text: '',
@ -185,7 +186,7 @@ function dealWithEmptyAttribute (attributes: Record<string, any>) {
}
// Helper function to convert Slate text node to Delta insert
function slateNodeToDeltaInsert (node: Text): YDelta {
export function slateNodeToDeltaInsert (node: Text): YDelta {
const { text, ...attributes } = node;
return {

View File

@ -1,4 +1,5 @@
import { getText } from '@/application/slate-yjs/utils/yjsOperations';
import { slateNodeToDeltaInsert } from '@/application/slate-yjs/utils/convert';
import { getText, getTextMap } from '@/application/slate-yjs/utils/yjsOperations';
import { YSharedRoot } from '@/application/types';
import { BasePoint, BaseRange, Node, Element, Editor, NodeEntry, Text } from 'slate';
import { RelativeRange } from '../types';
@ -55,10 +56,16 @@ export function slatePointToRelativePosition (
}
const textId = node.textId as string;
const ytext = getText(textId, sharedRoot);
let ytext = getText(textId, sharedRoot);
if (!ytext) {
throw new Error('YText not found');
const newYText = new Y.Text();
const textMap = getTextMap(sharedRoot);
const ops = (node.children as Text[]).map(slateNodeToDeltaInsert);
newYText.applyDelta(ops);
textMap.set(textId, newYText);
ytext = newYText;
}
const offset = Math.min(calculateOffsetRelativeToParent(node, point), ytext.length);

View File

@ -69,11 +69,16 @@ export function createEmptyDocument () {
return doc;
}
export function getText (textId: string, sharedRoot: YSharedRoot) {
export function getTextMap (sharedRoot: YSharedRoot) {
const document = sharedRoot.get(YjsEditorKey.document);
const meta = document.get(YjsEditorKey.meta) as YMeta;
const textMap = meta.get(YjsEditorKey.text_map) as YTextMap;
return meta.get(YjsEditorKey.text_map) as YTextMap;
}
export function getText (textId: string, sharedRoot: YSharedRoot) {
const textMap = getTextMap(sharedRoot);
return textMap.get(textId);
}
@ -191,6 +196,8 @@ export function handleCollapsedBreakWithTxn (editor: YjsEditor, sharedRoot: YSha
} else {
Transforms.select(editor, Editor.start(editor, at));
}
console.log('handleCollapsedBreakWithTxn', editor.selection);
}
export function removeRangeWithTxn (editor: YjsEditor, sharedRoot: YSharedRoot, range: Range) {

View File

@ -84,7 +84,7 @@ function MoreActions () {
];
const importShow = false;
if (importShow) {
items.unshift({
Icon: ImportIcon,

View File

@ -40,6 +40,8 @@ describe('Markdown editing', () => {
// Test 1: heading
cy.get('@editor').type('##');
cy.get('@editor').realPress('Space');
cy.wait(50);
cy.get('@editor').type('Heading 2');
expectedJson = [...expectedJson, {
type: 'heading',

View File

@ -90,5 +90,5 @@ export const LinkPreview = memo(
</div>
);
}),
);
(prev, next) => prev.node.data.url === next.node.data.url);
export default LinkPreview;

View File

@ -34,25 +34,29 @@ export function NumberListIcon ({ block, className }: { block: NumberedListNode;
return index;
}
let prevPath = Path.previous(path);
try {
let prevPath = Path.previous(path);
while (prevPath) {
const prev = editor.node(prevPath);
while (prevPath) {
const prev = editor.node(prevPath);
const prevNode = prev[0] as Element;
const prevNode = prev[0] as Element;
if (prevNode.type === block.type) {
index += 1;
topNode = prevNode;
} else {
break;
if (prevNode.type === block.type) {
index += 1;
topNode = prevNode;
} else {
break;
}
if (prevPath.length === 1 && prevPath[0] === 0) {
return index;
}
prevPath = Path.previous(prevPath);
}
if (prevPath.length === 1 && prevPath[0] === 0) {
return index;
}
prevPath = Path.previous(prevPath);
} catch (e) {
// do nothing
}
if (!topNode) {

View File

@ -4,11 +4,13 @@ import React, { forwardRef, memo, useMemo } from 'react';
export const Quote = memo(
forwardRef<HTMLDivElement, EditorElementProps<QuoteNode>>(({ node: _, children, ...attributes }, ref) => {
const className = useMemo(() => {
return `my-1 ${attributes.className ?? ''} pl-3`;
return `my-1 ${attributes.className ?? ''} pl-3 quote-block`;
}, [attributes.className]);
return (
<div {...attributes} ref={ref} className={className}>
<div {...attributes} ref={ref}
className={className}
>
<div className={'border-l-4 border-fill-default pl-2'}>
{children}
</div>

View File

@ -65,7 +65,7 @@ function FormulaLeaf ({ formula, text }: {
CustomEditor.removeMark(editor, EditorMarkFormat.Formula);
editor.deleteBackward('character');
editor.delete();
editor.insertText(formula);
}, [editor, formula, handleClose, text]);

View File

@ -25,9 +25,17 @@ function Formula () {
editor.delete();
editor.insertText('$');
const newSelection = editor.selection;
if (!newSelection) {
console.error('newSelection is undefined');
return;
}
Transforms.select(editor, {
anchor: start,
focus: { path: start.path, offset: start.offset + 1 },
focus: newSelection.focus,
});
CustomEditor.addMark(editor, {
key: EditorMarkFormat.Formula,
@ -46,10 +54,7 @@ function Formula () {
CustomEditor.removeMark(editor, EditorMarkFormat.Formula);
editor.collapse({
edge: 'end',
});
editor.deleteBackward('character');
editor.delete();
editor.insertText(formula);
}

View File

@ -30,6 +30,10 @@
text-align: left;
justify-content: flex-start;
}
.quote-block {
@apply items-start;
}
}
.block-element.block-align-right {
@ -37,6 +41,10 @@
text-align: right;
justify-content: flex-end;
}
.quote-block {
@apply items-end;
}
}
.block-element.block-align-center {
@ -45,6 +53,9 @@
justify-content: center;
}
.quote-block {
@apply items-center;
}
}

View File

@ -1,6 +1,11 @@
import { YjsEditor } from '@/application/slate-yjs';
import { CustomEditor } from '@/application/slate-yjs/command';
import { isAtBlockStart, isAtBlockEnd, isEntireDocumentSelected } from '@/application/slate-yjs/utils/yjsOperations';
import {
isAtBlockStart,
isAtBlockEnd,
isEntireDocumentSelected,
getBlockEntry,
} from '@/application/slate-yjs/utils/yjsOperations';
import { TextUnit, Range, EditorFragmentDeletionOptions } from 'slate';
import { ReactEditor } from 'slate-react';
import { TextDeleteOptions } from 'slate/dist/interfaces/transforms/text';
@ -18,6 +23,15 @@ export function withDelete (editor: ReactEditor) {
return;
}
const [start, end] = Range.edges(selection);
const startBlock = getBlockEntry(editor as YjsEditor, start)[0];
const endBlock = getBlockEntry(editor as YjsEditor, end)[0];
if (startBlock.blockId === endBlock.blockId) {
deleteText(options);
return;
}
CustomEditor.deleteBlockBackward(editor as YjsEditor, selection);
};

View File

@ -95,8 +95,8 @@ const rules: Rule[] = [
const level = match[1].length;
const [node] = getBlockEntry(editor);
deletePrefix(editor, level);
CustomEditor.turnToBlock<HeadingBlockData>(editor, node.blockId as string, BlockType.HeadingBlock, { level });
deletePrefix(editor, level);
},
},
{
@ -107,8 +107,8 @@ const rules: Rule[] = [
return getNodeType(editor) === BlockType.QuoteBlock;
},
transform: (editor) => {
deletePrefix(editor, 1);
CustomEditor.turnToBlock(editor, getBlockEntry(editor)[0].blockId as string, BlockType.QuoteBlock, {});
deletePrefix(editor, 1);
},
},
{
@ -123,10 +123,11 @@ const rules: Rule[] = [
return blockType === BlockType.TodoListBlock && (blockData as TodoListBlockData).checked === checked;
},
transform: (editor, match) => {
deletePrefix(editor, match[0].length - 1);
const checked = match[2] === 'x';
CustomEditor.turnToBlock<TodoListBlockData>(editor, getBlockEntry(editor)[0].blockId as string, BlockType.TodoListBlock, { checked });
deletePrefix(editor, match[0].length - 1);
},
},
{
@ -137,8 +138,9 @@ const rules: Rule[] = [
return getNodeType(editor) === BlockType.ToggleListBlock;
},
transform: (editor) => {
deletePrefix(editor, 1);
CustomEditor.turnToBlock<ToggleListBlockData>(editor, getBlockEntry(editor)[0].blockId as string, BlockType.ToggleListBlock, { collapsed: false });
deletePrefix(editor, 1);
},
},
{
@ -149,9 +151,9 @@ const rules: Rule[] = [
return !isEmptyLine(editor, 2) || getNodeType(editor) === BlockType.CodeBlock;
},
transform: (editor) => {
deletePrefix(editor, 2);
CustomEditor.turnToBlock(editor, getBlockEntry(editor)[0].blockId as string, BlockType.CodeBlock, {});
deletePrefix(editor, 2);
},
},
{
@ -162,8 +164,9 @@ const rules: Rule[] = [
return getNodeType(editor) === BlockType.BulletedListBlock;
},
transform: (editor) => {
deletePrefix(editor, 1);
CustomEditor.turnToBlock(editor, getBlockEntry(editor)[0].blockId as string, BlockType.BulletedListBlock, {});
deletePrefix(editor, 1);
},
},
{
@ -180,8 +183,8 @@ const rules: Rule[] = [
transform: (editor, match) => {
const start = parseInt(match[1]);
deletePrefix(editor, String(start).length + 1);
CustomEditor.turnToBlock<NumberedListBlockData>(editor, getBlockEntry(editor)[0].blockId as string, BlockType.NumberedListBlock, { number: start });
deletePrefix(editor, String(start).length + 1);
},
},
@ -193,8 +196,9 @@ const rules: Rule[] = [
return !isEmptyLine(editor, 2) || getNodeType(editor) === BlockType.DividerBlock;
},
transform: (editor) => {
deletePrefix(editor, 2);
CustomEditor.turnToBlock(editor, getBlockEntry(editor)[0].blockId as string, BlockType.DividerBlock, {});
deletePrefix(editor, 2);
},
},