Skip to content

Commit

Permalink
fix: supported incremental updates (AppFlowy-IO#6531)
Browse files Browse the repository at this point in the history
  • Loading branch information
qinluhe authored Oct 11, 2024
1 parent ea61c81 commit b5936ce
Show file tree
Hide file tree
Showing 17 changed files with 342 additions and 47 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ export const CustomEditor = {

handleMergeBlockBackwardWithTxn(editor, node, point);
} else {

Transforms.collapse(editor, { edge: 'start' });
removeRangeWithTxn(editor, sharedRoot, newAt);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -125,25 +126,45 @@ 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);
// Replace the apply function to avoid storing remote changes as local changes
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;
console.timeEnd('applyRemoteEvents');
};

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);

};

Expand Down
Original file line number Diff line number Diff line change
@@ -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,
});

}
Original file line number Diff line number Diff line change
Expand Up @@ -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[]) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export function yDataToSlateContent ({
const yText = textId ? textMap.get(textId) : undefined;

if (!yText) {

if (children.length === 0) {
children.push({
text: '',
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ function MoreActions () {
];

const importShow = false;

if (importShow) {
items.unshift({
Icon: ImportIcon,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,5 +90,5 @@ export const LinkPreview = memo(
</div>
);
}),
);
(prev, next) => prev.node.data.url === next.node.data.url);
export default LinkPreview;
Loading

0 comments on commit b5936ce

Please sign in to comment.