From f0a01f07155d9e654704b78617ea50b53ab64b60 Mon Sep 17 00:00:00 2001
From: James Fitzsimmons
Date: Wed, 11 Jun 2025 11:34:30 +1000
Subject: [PATCH 01/21] [lexical-yjs][lexical-react] Refactor: split out
useYjsCollaborationInternal with shared v1/v2 logic
---
.../src/shared/useYjsCollaboration.tsx | 204 ++++++++++++------
packages/lexical-yjs/src/Bindings.ts | 27 ++-
packages/lexical-yjs/src/index.ts | 8 +-
3 files changed, 163 insertions(+), 76 deletions(-)
diff --git a/packages/lexical-react/src/shared/useYjsCollaboration.tsx b/packages/lexical-react/src/shared/useYjsCollaboration.tsx
index cd724dfe786..3690eb6dc0a 100644
--- a/packages/lexical-react/src/shared/useYjsCollaboration.tsx
+++ b/packages/lexical-react/src/shared/useYjsCollaboration.tsx
@@ -6,7 +6,13 @@
*
*/
-import type {Binding, Provider, SyncCursorPositionsFn} from '@lexical/yjs';
+import type {
+ BaseBinding,
+ Binding,
+ BindingV2,
+ Provider,
+ SyncCursorPositionsFn,
+} from '@lexical/yjs';
import type {LexicalEditor} from 'lexical';
import type {JSX} from 'react';
@@ -44,6 +50,13 @@ import {InitialEditorStateType} from '../LexicalComposer';
export type CursorsContainerRef = React.MutableRefObject;
+type OnYjsTreeChanges = (
+ // The below `any` type is taken directly from the vendor types for YJS.
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ events: Array>,
+ transaction: Transaction,
+) => void;
+
export function useYjsCollaboration(
editor: LexicalEditor,
id: string,
@@ -59,50 +72,18 @@ export function useYjsCollaboration(
awarenessData?: object,
syncCursorPositionsFn: SyncCursorPositionsFn = syncCursorPositions,
): JSX.Element {
- const isReloadingDoc = useRef(false);
-
- const connect = useCallback(() => provider.connect(), [provider]);
-
- const disconnect = useCallback(() => {
- try {
- provider.disconnect();
- } catch (e) {
- // Do nothing
+ const onBootstrap = useCallback(() => {
+ const {root} = binding;
+ if (shouldBootstrap && root.isEmpty() && root._xmlText._length === 0) {
+ initializeEditor(editor, initialEditorState);
}
- }, [provider]);
+ }, [binding, editor, initialEditorState, shouldBootstrap]);
useEffect(() => {
const {root} = binding;
const {awareness} = provider;
- const onStatus = ({status}: {status: string}) => {
- editor.dispatchCommand(CONNECTED_COMMAND, status === 'connected');
- };
-
- const onSync = (isSynced: boolean) => {
- if (
- shouldBootstrap &&
- isSynced &&
- root.isEmpty() &&
- root._xmlText._length === 0 &&
- isReloadingDoc.current === false
- ) {
- initializeEditor(editor, initialEditorState);
- }
-
- isReloadingDoc.current = false;
- };
-
- const onAwarenessUpdate = () => {
- syncCursorPositionsFn(binding, provider);
- };
-
- const onYjsTreeChanges = (
- // The below `any` type is taken directly from the vendor types for YJS.
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- events: Array>,
- transaction: Transaction,
- ) => {
+ const onYjsTreeChanges: OnYjsTreeChanges = (events, transaction) => {
const origin = transaction.origin;
if (origin !== binding) {
const isFromUndoManger = origin instanceof UndoManager;
@@ -116,25 +97,6 @@ export function useYjsCollaboration(
}
};
- initLocalState(
- provider,
- name,
- color,
- document.activeElement === editor.getRootElement(),
- awarenessData || {},
- );
-
- const onProviderDocReload = (ydoc: Doc) => {
- clearEditorSkipCollab(editor, binding);
- setDoc(ydoc);
- docMap.set(id, ydoc);
- isReloadingDoc.current = true;
- };
-
- provider.on('reload', onProviderDocReload);
- provider.on('status', onStatus);
- provider.on('sync', onSync);
- awareness.on('update', onAwarenessUpdate);
// This updates the local editor state when we receive updates from other clients
root.getSharedType().observeDeep(onYjsTreeChanges);
const removeListener = editor.registerUpdateListener(
@@ -161,6 +123,119 @@ export function useYjsCollaboration(
},
);
+ const onAwarenessUpdate = () => {
+ syncCursorPositionsFn(binding, provider);
+ };
+ awareness.on('update', onAwarenessUpdate);
+
+ return () => {
+ binding.root.getSharedType().unobserveDeep(onYjsTreeChanges);
+ removeListener();
+ awareness.off('update', onAwarenessUpdate);
+ };
+ }, [binding, provider, editor, syncCursorPositionsFn]);
+
+ return useYjsCollaborationInternal(
+ editor,
+ id,
+ provider,
+ docMap,
+ name,
+ color,
+ binding,
+ setDoc,
+ cursorsContainerRef,
+ awarenessData,
+ onBootstrap,
+ );
+}
+
+export function useYjsCollaborationV2__EXPERIMENTAL(
+ editor: LexicalEditor,
+ id: string,
+ provider: Provider,
+ docMap: Map,
+ name: string,
+ color: string,
+ binding: BindingV2,
+ setDoc: React.Dispatch>,
+ cursorsContainerRef?: CursorsContainerRef,
+ awarenessData?: object,
+) {
+ // TODO: configure observer/listener.
+ // TODO: sync cursor positions.
+
+ return useYjsCollaborationInternal(
+ editor,
+ id,
+ provider,
+ docMap,
+ name,
+ color,
+ binding,
+ setDoc,
+ cursorsContainerRef,
+ awarenessData,
+ );
+}
+
+function useYjsCollaborationInternal(
+ editor: LexicalEditor,
+ id: string,
+ provider: Provider,
+ docMap: Map,
+ name: string,
+ color: string,
+ binding: T,
+ setDoc: React.Dispatch>,
+ cursorsContainerRef?: CursorsContainerRef,
+ awarenessData?: object,
+ onBootstrap?: () => void,
+): JSX.Element {
+ const isReloadingDoc = useRef(false);
+
+ const connect = useCallback(() => provider.connect(), [provider]);
+
+ const disconnect = useCallback(() => {
+ try {
+ provider.disconnect();
+ } catch (e) {
+ // Do nothing
+ }
+ }, [provider]);
+
+ useEffect(() => {
+ const onStatus = ({status}: {status: string}) => {
+ editor.dispatchCommand(CONNECTED_COMMAND, status === 'connected');
+ };
+
+ const onSync = (isSynced: boolean) => {
+ if (isSynced && isReloadingDoc.current === false && onBootstrap) {
+ onBootstrap();
+ }
+
+ isReloadingDoc.current = false;
+ };
+
+ initLocalState(
+ provider,
+ name,
+ color,
+ document.activeElement === editor.getRootElement(),
+ awarenessData || {},
+ );
+
+ const onProviderDocReload = (ydoc: Doc) => {
+ clearEditorSkipCollab(editor, binding);
+ setDoc(ydoc);
+ docMap.set(id, ydoc);
+ isReloadingDoc.current = true;
+ };
+
+ provider.on('reload', onProviderDocReload);
+ provider.on('status', onStatus);
+ provider.on('sync', onSync);
+
const connectionPromise = connect();
return () => {
@@ -182,10 +257,7 @@ export function useYjsCollaboration(
provider.off('sync', onSync);
provider.off('status', onStatus);
provider.off('reload', onProviderDocReload);
- awareness.off('update', onAwarenessUpdate);
- root.getSharedType().unobserveDeep(onYjsTreeChanges);
docMap.delete(id);
- removeListener();
};
}, [
binding,
@@ -195,13 +267,11 @@ export function useYjsCollaboration(
docMap,
editor,
id,
- initialEditorState,
name,
provider,
- shouldBootstrap,
+ onBootstrap,
awarenessData,
setDoc,
- syncCursorPositionsFn,
]);
const cursorsContainer = useMemo(() => {
const ref = (element: null | HTMLElement) => {
@@ -277,6 +347,10 @@ export function useYjsHistory(
[binding],
);
+ return useYjsUndoManager(editor, undoManager);
+}
+
+function useYjsUndoManager(editor: LexicalEditor, undoManager: UndoManager) {
useEffect(() => {
const undo = () => {
undoManager.undo();
@@ -393,7 +467,7 @@ function initializeEditor(
);
}
-function clearEditorSkipCollab(editor: LexicalEditor, binding: Binding) {
+function clearEditorSkipCollab(editor: LexicalEditor, binding: BaseBinding) {
// reset editor state
editor.update(
() => {
diff --git a/packages/lexical-yjs/src/Bindings.ts b/packages/lexical-yjs/src/Bindings.ts
index 5c9bdb8f9e0..776770493b8 100644
--- a/packages/lexical-yjs/src/Bindings.ts
+++ b/packages/lexical-yjs/src/Bindings.ts
@@ -12,7 +12,7 @@ import type {CollabLineBreakNode} from './CollabLineBreakNode';
import type {CollabTextNode} from './CollabTextNode';
import type {Cursor} from './SyncCursors';
import type {LexicalEditor, NodeKey} from 'lexical';
-import type {Doc} from 'yjs';
+import type {Doc, XmlElement} from 'yjs';
import {Klass, LexicalNode} from 'lexical';
import invariant from 'shared/invariant';
@@ -22,15 +22,8 @@ import {Provider} from '.';
import {$createCollabElementNode} from './CollabElementNode';
export type ClientID = number;
-export type Binding = {
+export type BaseBinding = {
clientID: number;
- collabNodeMap: Map<
- NodeKey,
- | CollabElementNode
- | CollabTextNode
- | CollabDecoratorNode
- | CollabLineBreakNode
- >;
cursors: Map;
cursorsContainer: null | HTMLElement;
doc: Doc;
@@ -38,11 +31,25 @@ export type Binding = {
editor: LexicalEditor;
id: string;
nodeProperties: Map>;
- root: CollabElementNode;
excludedProperties: ExcludedProperties;
};
export type ExcludedProperties = Map, Set>;
+export type Binding = BaseBinding & {
+ collabNodeMap: Map<
+ NodeKey,
+ | CollabElementNode
+ | CollabTextNode
+ | CollabDecoratorNode
+ | CollabLineBreakNode
+ >;
+ root: CollabElementNode;
+};
+
+export type BindingV2 = BaseBinding & {
+ root: XmlElement;
+};
+
export function createBinding(
editor: LexicalEditor,
provider: Provider,
diff --git a/packages/lexical-yjs/src/index.ts b/packages/lexical-yjs/src/index.ts
index 21ace2cb1bb..ce6b004046f 100644
--- a/packages/lexical-yjs/src/index.ts
+++ b/packages/lexical-yjs/src/index.ts
@@ -58,7 +58,13 @@ export type Delta = Array;
export type YjsNode = Record;
export type YjsEvent = Record;
export type {Provider};
-export type {Binding, ClientID, ExcludedProperties} from './Bindings';
+export type {
+ BaseBinding,
+ Binding,
+ BindingV2,
+ ClientID,
+ ExcludedProperties,
+} from './Bindings';
export {createBinding} from './Bindings';
export function createUndoManager(
From 8d91369a647943af43bd8ce30da46183e7f95dde Mon Sep 17 00:00:00 2001
From: James Fitzsimmons
Date: Thu, 12 Jun 2025 16:20:15 +1000
Subject: [PATCH 02/21] [lexical-yjs][lexical-react] Feature: initial
implementation of collab v2
---
.../__tests__/e2e/Collaboration.spec.mjs | 3 +-
packages/lexical-playground/src/Editor.tsx | 23 +-
.../lexical-playground/src/appSettings.ts | 1 +
.../src/LexicalCollaborationPlugin.tsx | 216 +++++-
.../src/__tests__/unit/Collaboration.test.ts | 705 +++++++++---------
.../unit/CollaborationWithCollisions.test.ts | 79 +-
.../src/__tests__/unit/utils.tsx | 35 +-
.../src/shared/useYjsCollaboration.tsx | 54 +-
packages/lexical-yjs/src/Bindings.ts | 66 +-
packages/lexical-yjs/src/SyncEditorStates.ts | 103 ++-
packages/lexical-yjs/src/SyncV2.ts | 647 ++++++++++++++++
packages/lexical-yjs/src/Utils.ts | 58 +-
packages/lexical-yjs/src/index.ts | 18 +-
13 files changed, 1519 insertions(+), 489 deletions(-)
create mode 100644 packages/lexical-yjs/src/SyncV2.ts
diff --git a/packages/lexical-playground/__tests__/e2e/Collaboration.spec.mjs b/packages/lexical-playground/__tests__/e2e/Collaboration.spec.mjs
index 6eac05dd9a2..4b32f26946c 100644
--- a/packages/lexical-playground/__tests__/e2e/Collaboration.spec.mjs
+++ b/packages/lexical-playground/__tests__/e2e/Collaboration.spec.mjs
@@ -19,7 +19,6 @@ import {
focusEditor,
html,
initialize,
- IS_MAC,
sleep,
test,
} from '../utils/index.mjs';
@@ -38,7 +37,7 @@ test.describe('Collaboration', () => {
isCollab,
browserName,
}) => {
- test.skip(!isCollab || IS_MAC);
+ test.skip(!isCollab);
await focusEditor(page);
await page.keyboard.type('hello');
diff --git a/packages/lexical-playground/src/Editor.tsx b/packages/lexical-playground/src/Editor.tsx
index 5c4825afe9a..74570983d74 100644
--- a/packages/lexical-playground/src/Editor.tsx
+++ b/packages/lexical-playground/src/Editor.tsx
@@ -13,7 +13,10 @@ import {CharacterLimitPlugin} from '@lexical/react/LexicalCharacterLimitPlugin';
import {CheckListPlugin} from '@lexical/react/LexicalCheckListPlugin';
import {ClearEditorPlugin} from '@lexical/react/LexicalClearEditorPlugin';
import {ClickableLinkPlugin} from '@lexical/react/LexicalClickableLinkPlugin';
-import {CollaborationPlugin} from '@lexical/react/LexicalCollaborationPlugin';
+import {
+ CollaborationPlugin,
+ CollaborationPluginV2__EXPERIMENTAL,
+} from '@lexical/react/LexicalCollaborationPlugin';
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {LexicalErrorBoundary} from '@lexical/react/LexicalErrorBoundary';
import {HashtagPlugin} from '@lexical/react/LexicalHashtagPlugin';
@@ -85,6 +88,7 @@ export default function Editor(): JSX.Element {
const {
settings: {
isCollab,
+ useCollabV2,
isAutocomplete,
isMaxLength,
isCharLimit,
@@ -180,11 +184,18 @@ export default function Editor(): JSX.Element {
{isRichText ? (
<>
{isCollab ? (
-
+ useCollabV2 ? (
+
+ ) : (
+
+ )
) : (
)}
diff --git a/packages/lexical-playground/src/appSettings.ts b/packages/lexical-playground/src/appSettings.ts
index f5352f1cf8f..bb44dc5f720 100644
--- a/packages/lexical-playground/src/appSettings.ts
+++ b/packages/lexical-playground/src/appSettings.ts
@@ -33,6 +33,7 @@ export const DEFAULT_SETTINGS = {
tableCellBackgroundColor: true,
tableCellMerge: true,
tableHorizontalScroll: true,
+ useCollabV2: false,
} as const;
// These are mutated in setupEnv
diff --git a/packages/lexical-react/src/LexicalCollaborationPlugin.tsx b/packages/lexical-react/src/LexicalCollaborationPlugin.tsx
index f5ef1340616..7c0f031ba8c 100644
--- a/packages/lexical-react/src/LexicalCollaborationPlugin.tsx
+++ b/packages/lexical-react/src/LexicalCollaborationPlugin.tsx
@@ -16,7 +16,9 @@ import {
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {
Binding,
+ BindingV2,
createBinding,
+ createBindingV2__EXPERIMENTAL,
ExcludedProperties,
Provider,
SyncCursorPositionsFn,
@@ -28,17 +30,17 @@ import {InitialEditorStateType} from './LexicalComposer';
import {
CursorsContainerRef,
useYjsCollaboration,
+ useYjsCollaborationV2__EXPERIMENTAL,
useYjsFocusTracking,
useYjsHistory,
+ useYjsHistoryV2,
} from './shared/useYjsCollaboration';
-type Props = {
+type ProviderFactory = (id: string, yjsDocMap: Map) => Provider;
+
+type CollaborationPluginProps = {
id: string;
- providerFactory: (
- // eslint-disable-next-line no-shadow
- id: string,
- yjsDocMap: Map,
- ) => Provider;
+ providerFactory: ProviderFactory;
shouldBootstrap: boolean;
username?: string;
cursorColor?: string;
@@ -61,44 +63,17 @@ export function CollaborationPlugin({
excludedProperties,
awarenessData,
syncCursorPositionsFn,
-}: Props): JSX.Element {
+}: CollaborationPluginProps): JSX.Element {
const isBindingInitialized = useRef(false);
- const isProviderInitialized = useRef(false);
const collabContext = useCollaborationContext(username, cursorColor);
const {yjsDocMap, name, color} = collabContext;
const [editor] = useLexicalComposerContext();
+ useCollabActive(collabContext, editor);
- useEffect(() => {
- collabContext.isCollabActive = true;
-
- return () => {
- // Resetting flag only when unmount top level editor collab plugin. Nested
- // editors (e.g. image caption) should unmount without affecting it
- if (editor._parentEditor == null) {
- collabContext.isCollabActive = false;
- }
- };
- }, [collabContext, editor]);
-
- const [provider, setProvider] = useState();
-
- useEffect(() => {
- if (isProviderInitialized.current) {
- return;
- }
-
- isProviderInitialized.current = true;
-
- const newProvider = providerFactory(id, yjsDocMap);
- setProvider(newProvider);
-
- return () => {
- newProvider.disconnect();
- };
- }, [id, providerFactory, yjsDocMap]);
+ const provider = useProvider(id, yjsDocMap, providerFactory);
const [doc, setDoc] = useState(yjsDocMap.get(id));
const [binding, setBinding] = useState();
@@ -207,3 +182,172 @@ function YjsCollaborationCursors({
return cursors;
}
+
+type CollaborationPluginV2Props = {
+ id: string;
+ providerFactory: ProviderFactory;
+ username?: string;
+ cursorColor?: string;
+ cursorsContainerRef?: CursorsContainerRef;
+ excludedProperties?: ExcludedProperties;
+ // `awarenessData` parameter allows arbitrary data to be added to the awareness.
+ awarenessData?: object;
+};
+
+export function CollaborationPluginV2__EXPERIMENTAL({
+ id,
+ providerFactory,
+ username,
+ cursorColor,
+ cursorsContainerRef,
+ excludedProperties,
+ awarenessData,
+}: CollaborationPluginV2Props): JSX.Element {
+ const isBindingInitialized = useRef(false);
+
+ const collabContext = useCollaborationContext(username, cursorColor);
+
+ const {yjsDocMap, name, color} = collabContext;
+
+ const [editor] = useLexicalComposerContext();
+ useCollabActive(collabContext, editor);
+
+ const provider = useProvider(id, yjsDocMap, providerFactory);
+
+ const [doc, setDoc] = useState(yjsDocMap.get(id));
+ const [binding, setBinding] = useState();
+
+ useEffect(() => {
+ if (!provider) {
+ return;
+ }
+
+ if (isBindingInitialized.current) {
+ return;
+ }
+
+ isBindingInitialized.current = true;
+
+ const newBinding = createBindingV2__EXPERIMENTAL(
+ editor,
+ id,
+ doc || yjsDocMap.get(id),
+ yjsDocMap,
+ excludedProperties,
+ );
+ setBinding(newBinding);
+ }, [editor, provider, id, yjsDocMap, doc, excludedProperties]);
+
+ if (!provider || !binding) {
+ return <>>;
+ }
+
+ return (
+
+ );
+}
+
+function YjsCollaborationCursorsV2__EXPERIMENTAL({
+ editor,
+ id,
+ provider,
+ yjsDocMap,
+ name,
+ color,
+ cursorsContainerRef,
+ awarenessData,
+ collabContext,
+ binding,
+ setDoc,
+}: {
+ editor: LexicalEditor;
+ id: string;
+ provider: Provider;
+ yjsDocMap: Map;
+ name: string;
+ color: string;
+ shouldBootstrap: boolean;
+ binding: BindingV2;
+ setDoc: React.Dispatch>;
+ cursorsContainerRef?: CursorsContainerRef | undefined;
+ initialEditorState?: InitialEditorStateType | undefined;
+ awarenessData?: object;
+ collabContext: CollaborationContextType;
+ syncCursorPositionsFn?: SyncCursorPositionsFn;
+}) {
+ const cursors = useYjsCollaborationV2__EXPERIMENTAL(
+ editor,
+ id,
+ provider,
+ yjsDocMap,
+ name,
+ color,
+ binding,
+ setDoc,
+ cursorsContainerRef,
+ awarenessData,
+ );
+
+ collabContext.clientID = binding.clientID;
+
+ useYjsHistoryV2(editor, binding);
+ useYjsFocusTracking(editor, provider, name, color, awarenessData);
+
+ return cursors;
+}
+
+const useCollabActive = (
+ collabContext: CollaborationContextType,
+ editor: LexicalEditor,
+) => {
+ useEffect(() => {
+ collabContext.isCollabActive = true;
+
+ return () => {
+ // Resetting flag only when unmount top level editor collab plugin. Nested
+ // editors (e.g. image caption) should unmount without affecting it
+ if (editor._parentEditor == null) {
+ collabContext.isCollabActive = false;
+ }
+ };
+ }, [collabContext, editor]);
+};
+
+const useProvider = (
+ id: string,
+ yjsDocMap: Map,
+ providerFactory: ProviderFactory,
+) => {
+ const isProviderInitialized = useRef(false);
+ const [provider, setProvider] = useState();
+
+ useEffect(() => {
+ if (isProviderInitialized.current) {
+ return;
+ }
+
+ isProviderInitialized.current = true;
+
+ const newProvider = providerFactory(id, yjsDocMap);
+ setProvider(newProvider);
+
+ return () => {
+ newProvider.disconnect();
+ };
+ }, [id, providerFactory, yjsDocMap]);
+
+ return provider;
+};
diff --git a/packages/lexical-react/src/__tests__/unit/Collaboration.test.ts b/packages/lexical-react/src/__tests__/unit/Collaboration.test.ts
index 7e5b7dd2e67..48ce2d276c5 100644
--- a/packages/lexical-react/src/__tests__/unit/Collaboration.test.ts
+++ b/packages/lexical-react/src/__tests__/unit/Collaboration.test.ts
@@ -32,7 +32,11 @@ describe('Collaboration', () => {
container = null;
});
- async function expectCorrectInitialContent(client1: Client, client2: Client) {
+ async function expectCorrectInitialContent(
+ client1: Client,
+ client2: Client,
+ useCollabV2: boolean,
+ ) {
// Should be empty, as client has not yet updated
expect(client1.getHTML()).toEqual('');
expect(client1.getHTML()).toEqual(client2.getHTML());
@@ -40,435 +44,429 @@ describe('Collaboration', () => {
// Wait for clients to render the initial content
await Promise.resolve().then();
- expect(client1.getHTML()).toEqual('
');
- expect(client1.getHTML()).toEqual(client2.getHTML());
- expect(client1.getDocJSON()).toEqual(client2.getDocJSON());
- }
-
- it('Should collaborate basic text insertion between two clients', async () => {
- const connector = createTestConnection();
-
- const client1 = connector.createClient('1');
- const client2 = connector.createClient('2');
-
- client1.start(container!);
- client2.start(container!);
-
- await expectCorrectInitialContent(client1, client2);
-
- // Insert a text node on client 1
- await waitForReact(() => {
- client1.update(() => {
- const root = $getRoot();
-
- const paragraph = root.getFirstChild();
-
- const text = $createTextNode('Hello world');
-
- paragraph!.append(text);
+ if (useCollabV2) {
+ // Manually bootstrap editor state.
+ await waitForReact(() => {
+ client1.update(() => $getRoot().append($createParagraphNode()));
});
- });
+ }
- expect(client1.getHTML()).toEqual(
- 'Hello world
',
- );
+ expect(client1.getHTML()).toEqual('
');
expect(client1.getHTML()).toEqual(client2.getHTML());
expect(client1.getDocJSON()).toEqual(client2.getDocJSON());
+ }
- // Insert some text on client 2
- await waitForReact(() => {
- client2.update(() => {
- const root = $getRoot();
-
- const paragraph = root.getFirstChild()!;
- const text = paragraph.getFirstChild()!;
-
- text.spliceText(6, 5, 'metaverse');
- });
- });
+ describe.each([[false], [true]])(
+ 'useCollabV2: %s',
+ (useCollabV2: boolean) => {
+ it('Should collaborate basic text insertion between two clients', async () => {
+ const connector = createTestConnection(useCollabV2);
- expect(client2.getHTML()).toEqual(
- 'Hello metaverse
',
- );
- expect(client1.getHTML()).toEqual(client2.getHTML());
- expect(client1.getDocJSON()).toEqual({
- root: '[object Object]Hello metaverse',
- });
- expect(client1.getDocJSON()).toEqual(client2.getDocJSON());
+ const client1 = connector.createClient('1');
+ const client2 = connector.createClient('2');
- client1.stop();
- client2.stop();
- });
+ client1.start(container!);
+ client2.start(container!);
- it('Should collaborate basic text insertion conflicts between two clients', async () => {
- const connector = createTestConnection();
+ await expectCorrectInitialContent(client1, client2, useCollabV2);
- const client1 = connector.createClient('1');
- const client2 = connector.createClient('2');
+ // Insert a text node on client 1
+ await waitForReact(() => {
+ client1.update(() => {
+ const root = $getRoot();
- client1.start(container!);
- client2.start(container!);
+ const paragraph = root.getFirstChild();
- await expectCorrectInitialContent(client1, client2);
+ const text = $createTextNode('Hello world');
- client1.disconnect();
+ paragraph!.append(text);
+ });
+ });
- // Insert some a text node on client 1
- await waitForReact(() => {
- client1.update(() => {
- const root = $getRoot();
+ expect(client1.getHTML()).toEqual(
+ 'Hello world
',
+ );
+ expect(client1.getHTML()).toEqual(client2.getHTML());
+ expect(client1.getDocJSON()).toEqual(client2.getDocJSON());
- const paragraph = root.getFirstChild()!;
- const text = $createTextNode('Hello world');
+ // Insert some text on client 2
+ await waitForReact(() => {
+ client2.update(() => {
+ const root = $getRoot();
- paragraph.append(text);
- });
- });
- expect(client1.getHTML()).toEqual(
- 'Hello world
',
- );
- expect(client2.getHTML()).toEqual('
');
+ const paragraph = root.getFirstChild()!;
+ const text = paragraph.getFirstChild()!;
- // Insert some a text node on client 1
- await waitForReact(() => {
- client2.update(() => {
- const root = $getRoot();
+ text.spliceText(6, 5, 'metaverse');
+ });
+ });
- const paragraph = root.getFirstChild()!;
- const text = $createTextNode('Hello world');
+ expect(client2.getHTML()).toEqual(
+ 'Hello metaverse
',
+ );
+ expect(client1.getHTML()).toEqual(client2.getHTML());
+ expect(client1.getDocJSON()).toEqual(client2.getDocJSON());
- paragraph.append(text);
+ client1.stop();
+ client2.stop();
});
- });
- expect(client2.getHTML()).toEqual(
- 'Hello world
',
- );
- expect(client1.getHTML()).toEqual(client2.getHTML());
+ it('Should collaborate basic text insertion conflicts between two clients', async () => {
+ const connector = createTestConnection(useCollabV2);
- await waitForReact(() => {
- client1.connect();
- });
+ const client1 = connector.createClient('1');
+ const client2 = connector.createClient('2');
- // Text content should be repeated, but there should only be a single node
- expect(client1.getHTML()).toEqual(
- 'Hello worldHello world
',
- );
- expect(client1.getHTML()).toEqual(client2.getHTML());
- expect(client1.getDocJSON()).toEqual({
- root: '[object Object]Hello worldHello world',
- });
- expect(client1.getDocJSON()).toEqual(client2.getDocJSON());
+ client1.start(container!);
+ client2.start(container!);
- client2.disconnect();
+ await expectCorrectInitialContent(client1, client2, useCollabV2);
- await waitForReact(() => {
- client1.update(() => {
- const root = $getRoot();
+ client1.disconnect();
- const paragraph = root.getFirstChild()!;
- const text = paragraph.getFirstChild()!;
+ // Insert some a text node on client 1
+ await waitForReact(() => {
+ client1.update(() => {
+ const root = $getRoot();
- text.spliceText(11, 11, '');
- });
- });
+ const paragraph = root.getFirstChild()!;
+ const text = $createTextNode('Hello world');
- expect(client1.getHTML()).toEqual(
- 'Hello world
',
- );
- expect(client2.getHTML()).toEqual(
- 'Hello worldHello world
',
- );
+ paragraph.append(text);
+ });
+ });
+ expect(client1.getHTML()).toEqual(
+ 'Hello world
',
+ );
+ expect(client2.getHTML()).toEqual('
');
- await waitForReact(() => {
- client2.update(() => {
- const root = $getRoot();
+ // Insert some a text node on client 1
+ await waitForReact(() => {
+ client2.update(() => {
+ const root = $getRoot();
- const paragraph = root.getFirstChild()!;
- const text = paragraph.getFirstChild()!;
+ const paragraph = root.getFirstChild()!;
+ const text = $createTextNode('Hello world');
- text.spliceText(11, 11, '!');
- });
- });
+ paragraph.append(text);
+ });
+ });
- await waitForReact(() => {
- client2.connect();
- });
-
- expect(client1.getHTML()).toEqual(
- 'Hello world!
',
- );
- expect(client1.getHTML()).toEqual(client2.getHTML());
- expect(client1.getDocJSON()).toEqual({
- root: '[object Object]Hello world!',
- });
- expect(client1.getDocJSON()).toEqual(client2.getDocJSON());
+ expect(client2.getHTML()).toEqual(
+ 'Hello world
',
+ );
+ expect(client1.getHTML()).toEqual(client2.getHTML());
- client1.stop();
- client2.stop();
- });
+ await waitForReact(() => {
+ client1.connect();
+ });
- it('Should collaborate basic text deletion conflicts between two clients', async () => {
- const connector = createTestConnection();
- const client1 = connector.createClient('1');
- const client2 = connector.createClient('2');
- client1.start(container!);
- client2.start(container!);
+ // Text content should be repeated, but there should only be a single node
+ expect(client1.getHTML()).toEqual(
+ 'Hello worldHello world
',
+ );
+ expect(client1.getHTML()).toEqual(client2.getHTML());
+ expect(client1.getDocJSON()).toEqual(client2.getDocJSON());
- await expectCorrectInitialContent(client1, client2);
+ client2.disconnect();
- // Insert some a text node on client 1
- await waitForReact(() => {
- client1.update(() => {
- const root = $getRoot();
+ await waitForReact(() => {
+ client1.update(() => {
+ const root = $getRoot();
- const paragraph = root.getFirstChild()!;
- const text = $createTextNode('Hello world');
- paragraph.append(text);
- });
- });
+ const paragraph = root.getFirstChild()!;
+ const text = paragraph.getFirstChild()!;
- expect(client1.getHTML()).toEqual(
- 'Hello world
',
- );
- expect(client1.getHTML()).toEqual(client2.getHTML());
- expect(client1.getDocJSON()).toEqual({
- root: '[object Object]Hello world',
- });
- expect(client1.getDocJSON()).toEqual(client2.getDocJSON());
+ text.spliceText(11, 11, '');
+ });
+ });
- client1.disconnect();
+ expect(client1.getHTML()).toEqual(
+ 'Hello world
',
+ );
+ expect(client2.getHTML()).toEqual(
+ 'Hello worldHello world
',
+ );
- // Delete the text on client 1
- await waitForReact(() => {
- client1.update(() => {
- const root = $getRoot();
+ await waitForReact(() => {
+ client2.update(() => {
+ const root = $getRoot();
- const paragraph = root.getFirstChild()!;
- paragraph.getFirstChild()!.remove();
- });
- });
+ const paragraph = root.getFirstChild()!;
+ const text = paragraph.getFirstChild()!;
- expect(client1.getHTML()).toEqual('
');
- expect(client2.getHTML()).toEqual(
- 'Hello world
',
- );
+ text.spliceText(11, 11, '!');
+ });
+ });
- // Insert some text on client 2
- await waitForReact(() => {
- client2.update(() => {
- const root = $getRoot();
+ await waitForReact(() => {
+ client2.connect();
+ });
- const paragraph = root.getFirstChild()!;
+ expect(client1.getHTML()).toEqual(
+ 'Hello world!
',
+ );
+ expect(client1.getHTML()).toEqual(client2.getHTML());
+ expect(client1.getDocJSON()).toEqual(client2.getDocJSON());
- paragraph.getFirstChild()!.spliceText(11, 0, 'Hello world');
+ client1.stop();
+ client2.stop();
});
- });
- expect(client1.getHTML()).toEqual('
');
- expect(client2.getHTML()).toEqual(
- 'Hello worldHello world
',
- );
+ it('Should collaborate basic text deletion conflicts between two clients', async () => {
+ const connector = createTestConnection(useCollabV2);
+ const client1 = connector.createClient('1');
+ const client2 = connector.createClient('2');
+ client1.start(container!);
+ client2.start(container!);
- await waitForReact(() => {
- client1.connect();
- });
+ await expectCorrectInitialContent(client1, client2, useCollabV2);
- // TODO we can probably handle these conflicts better. We could keep around
- // a "fallback" {Map} when we remove text without any adjacent text nodes. This
- // would require big changes in `CollabElementNode.splice` and also need adjustments
- // in `CollabElementNode.applyChildrenYjsDelta` to handle the existence of these
- // fallback maps. For now though, if a user clears all text nodes from an element
- // and another user inserts some text into the same element at the same time, the
- // deletion will take precedence on conflicts.
- expect(client1.getHTML()).toEqual('
');
- expect(client1.getHTML()).toEqual(client2.getHTML());
- expect(client1.getDocJSON()).toEqual({
- root: '',
- });
- expect(client1.getDocJSON()).toEqual(client2.getDocJSON());
- client1.stop();
- client2.stop();
- });
+ // Insert some a text node on client 1
+ await waitForReact(() => {
+ client1.update(() => {
+ const root = $getRoot();
- it('Should not sync direction of element node', async () => {
- const connector = createTestConnection();
- const client1 = connector.createClient('1');
- const client2 = connector.createClient('2');
- client1.start(container!);
- client2.start(container!);
+ const paragraph = root.getFirstChild()!;
+ const text = $createTextNode('Hello world');
+ paragraph.append(text);
+ });
+ });
- await expectCorrectInitialContent(client1, client2);
-
- // Add paragraph with RTL text, then another with a non-TextNode child
- await waitForReact(() => {
- client1.update(() => {
- const root = $getRoot().clear();
- root.append(
- $createParagraphNode().append($createTextNode('فرعي')),
- $createParagraphNode().append($createLineBreakNode()),
+ expect(client1.getHTML()).toEqual(
+ 'Hello world
',
);
- });
- });
-
- // Check that the second paragraph has RTL direction
- expect(client1.getHTML()).toEqual(
- 'فرعي
',
- );
- expect(client2.getHTML()).toEqual(client1.getHTML());
-
- // Mark the second paragraph's child as dirty to force the reconciler to run.
- await waitForReact(() => {
- client1.update(() => {
- const pargraph = $getRoot().getChildAtIndex(1)!;
- const lineBreak = pargraph.getFirstChildOrThrow();
- lineBreak.markDirty();
- });
- });
+ expect(client1.getHTML()).toEqual(client2.getHTML());
+ expect(client1.getDocJSON()).toEqual(client2.getDocJSON());
- // There was no activeEditorDirection when processing this node, so direction should be set back to null.
- expect(client1.getHTML()).toEqual(
- 'فرعي
',
- );
+ client1.disconnect();
- // Check that the second paragraph still has RTL direction on client 2, as __dir is not synced.
- expect(client2.getHTML()).toEqual(
- 'فرعي
',
- );
+ // Delete the text on client 1
+ await waitForReact(() => {
+ client1.update(() => {
+ const root = $getRoot();
- client1.stop();
- client2.stop();
- });
+ const paragraph = root.getFirstChild()!;
+ paragraph.getFirstChild()!.remove();
+ });
+ });
- it('Should allow the passing of arbitrary awareness data', async () => {
- const connector = createTestConnection();
+ expect(client1.getHTML()).toEqual('
');
+ expect(client2.getHTML()).toEqual(
+ 'Hello world
',
+ );
- const client1 = connector.createClient('1');
- const client2 = connector.createClient('2');
+ // Insert some text on client 2
+ await waitForReact(() => {
+ client2.update(() => {
+ const root = $getRoot();
- const awarenessData1 = {
- foo: 'foo',
- uuid: Math.floor(Math.random() * 10000),
- };
- const awarenessData2 = {
- bar: 'bar',
- uuid: Math.floor(Math.random() * 10000),
- };
+ const paragraph = root.getFirstChild()!;
- client1.start(container!, awarenessData1);
- client2.start(container!, awarenessData2);
+ paragraph
+ .getFirstChild()!
+ .spliceText(11, 0, 'Hello world');
+ });
+ });
- await expectCorrectInitialContent(client1, client2);
+ expect(client1.getHTML()).toEqual('
');
+ expect(client2.getHTML()).toEqual(
+ 'Hello worldHello world
',
+ );
- expect(client1.awareness.getLocalState()!.awarenessData).toEqual(
- awarenessData1,
- );
- expect(client2.awareness.getLocalState()!.awarenessData).toEqual(
- awarenessData2,
- );
+ await waitForReact(() => {
+ client1.connect();
+ });
+
+ // TODO we can probably handle these conflicts better. We could keep around
+ // a "fallback" {Map} when we remove text without any adjacent text nodes. This
+ // would require big changes in `CollabElementNode.splice` and also need adjustments
+ // in `CollabElementNode.applyChildrenYjsDelta` to handle the existence of these
+ // fallback maps. For now though, if a user clears all text nodes from an element
+ // and another user inserts some text into the same element at the same time, the
+ // deletion will take precedence on conflicts.
+ expect(client1.getHTML()).toEqual('
');
+ expect(client1.getHTML()).toEqual(client2.getHTML());
+ expect(client1.getDocJSON()).toEqual(client2.getDocJSON());
+ client1.stop();
+ client2.stop();
+ });
- client1.stop();
- client2.stop();
- });
+ it('Should not sync direction of element node', async () => {
+ const connector = createTestConnection(useCollabV2);
+ const client1 = connector.createClient('1');
+ const client2 = connector.createClient('2');
+ client1.start(container!);
+ client2.start(container!);
+
+ await expectCorrectInitialContent(client1, client2, useCollabV2);
+
+ // Add paragraph with RTL text, then another with a non-TextNode child
+ await waitForReact(() => {
+ client1.update(() => {
+ const root = $getRoot().clear();
+ root.append(
+ $createParagraphNode().append($createTextNode('فرعي')),
+ $createParagraphNode().append($createLineBreakNode()),
+ );
+ });
+ });
+
+ // Check that the second paragraph has RTL direction
+ expect(client1.getHTML()).toEqual(
+ 'فرعي
',
+ );
+ expect(client2.getHTML()).toEqual(client1.getHTML());
+
+ // Mark the second paragraph's child as dirty to force the reconciler to run.
+ await waitForReact(() => {
+ client1.update(() => {
+ const pargraph = $getRoot().getChildAtIndex(1)!;
+ const lineBreak = pargraph.getFirstChildOrThrow();
+ lineBreak.markDirty();
+ });
+ });
+
+ // There was no activeEditorDirection when processing this node, so direction should be set back to null.
+ expect(client1.getHTML()).toEqual(
+ 'فرعي
',
+ );
- /**
- * When a document is not bootstrapped (via `shouldBootstrap`), the document only initializes the initial paragraph
- * node upon the first user interaction. Then, both a new paragraph as well as the user character are inserted as a
- * single Yjs change. However, when the user undos this initial change, the document now has no initial paragraph
- * node. syncYjsChangesToLexical addresses this by doing a check: `$getRoot().getChildrenSize() === 0)` and if true,
- * inserts the paragraph node. However, this insertion was previously being done in an editor.update block that had
- * either the tag 'collaboration' or 'historic'. Then, when `syncLexicalUpdateToYjs` was called, because one of these
- * tags were present, the function would early-return, and this change would not be synced to other clients, causing
- * permanent desync and corruption of the doc for both users. Not only was the change not syncing to other clients,
- * but even the initiating client was not notified via the proper callbacks, and the change would fall through from
- * persistence, causing permanent desync. The fix was to move the insertion of the paragraph node outside of the
- * editor.update block that included the 'collaboration' or 'historic' tag, and instead insert it in a separate
- * editor.update block that did not have these tags.
- */
- it('Should sync to other clients when inserting a new paragraph node when document is emptied via undo', async () => {
- const connector = createTestConnection();
+ // Check that the second paragraph still has RTL direction on client 2, as __dir is not synced.
+ expect(client2.getHTML()).toEqual(
+ 'فرعي
',
+ );
- const client1 = connector.createClient('1');
- const client2 = connector.createClient('2');
+ client1.stop();
+ client2.stop();
+ });
- client1.start(container!, undefined, {shouldBootstrapEditor: false});
- client2.start(container!, undefined, {shouldBootstrapEditor: false});
+ it('Should allow the passing of arbitrary awareness data', async () => {
+ const connector = createTestConnection(useCollabV2);
- expect(client1.getHTML()).toEqual('');
- expect(client1.getHTML()).toEqual(client2.getHTML());
+ const client1 = connector.createClient('1');
+ const client2 = connector.createClient('2');
- // Wait for clients to render the initial content
- await Promise.resolve().then();
+ const awarenessData1 = {
+ foo: 'foo',
+ uuid: Math.floor(Math.random() * 10000),
+ };
+ const awarenessData2 = {
+ bar: 'bar',
+ uuid: Math.floor(Math.random() * 10000),
+ };
- expect(client1.getHTML()).toEqual('');
- expect(client1.getHTML()).toEqual(client2.getHTML());
+ client1.start(container!, awarenessData1);
+ client2.start(container!, awarenessData2);
- await waitForReact(() => {
- client1.update(() => {
- const root = $getRoot();
+ await expectCorrectInitialContent(client1, client2, useCollabV2);
- // Since bootstrap is false, we create our own paragraph node
- const paragraph = $createParagraphNode();
- const text = $createTextNode('Hello');
- paragraph.append(text);
+ expect(client1.awareness.getLocalState()!.awarenessData).toEqual(
+ awarenessData1,
+ );
+ expect(client2.awareness.getLocalState()!.awarenessData).toEqual(
+ awarenessData2,
+ );
- root.append(paragraph);
+ client1.stop();
+ client2.stop();
});
- });
-
- expect(client1.getHTML()).toEqual(
- 'Hello
',
- );
- expect(client1.getHTML()).toEqual(client2.getHTML());
- expect(client1.getDocJSON()).toEqual({
- root: '[object Object]Hello',
- });
- expect(client1.getDocJSON()).toEqual(client2.getDocJSON());
- await waitForReact(() => {
- // Undo the insertion of the initial paragraph and text node
- client1.getEditor().dispatchCommand(UNDO_COMMAND, undefined);
- });
-
- // We expect the safety check in syncYjsChangesToLexical to
- // insert a new paragraph node and prevent the document from being empty
- expect(client1.getHTML()).toEqual('
');
- expect(client1.getHTML()).toEqual(client2.getHTML());
- expect(client1.getDocJSON()).toEqual(client2.getDocJSON());
-
- await waitForReact(() => {
- client1.update(() => {
- const root = $getRoot();
-
- const paragraph = $createParagraphNode();
- const text = $createTextNode('Hello world');
- paragraph.append(text);
+ /**
+ * When a document is not bootstrapped (via `shouldBootstrap`), the document only initializes the initial paragraph
+ * node upon the first user interaction. Then, both a new paragraph as well as the user character are inserted as a
+ * single Yjs change. However, when the user undos this initial change, the document now has no initial paragraph
+ * node. syncYjsChangesToLexical addresses this by doing a check: `$getRoot().getChildrenSize() === 0)` and if true,
+ * inserts the paragraph node. However, this insertion was previously being done in an editor.update block that had
+ * either the tag 'collaboration' or 'historic'. Then, when `syncLexicalUpdateToYjs` was called, because one of these
+ * tags were present, the function would early-return, and this change would not be synced to other clients, causing
+ * permanent desync and corruption of the doc for both users. Not only was the change not syncing to other clients,
+ * but even the initiating client was not notified via the proper callbacks, and the change would fall through from
+ * persistence, causing permanent desync. The fix was to move the insertion of the paragraph node outside of the
+ * editor.update block that included the 'collaboration' or 'historic' tag, and instead insert it in a separate
+ * editor.update block that did not have these tags.
+ */
+ it('Should sync to other clients when inserting a new paragraph node when document is emptied via undo', async () => {
+ const connector = createTestConnection(useCollabV2);
+
+ const client1 = connector.createClient('1');
+ const client2 = connector.createClient('2');
+
+ client1.start(container!, undefined, {shouldBootstrapEditor: false});
+ client2.start(container!, undefined, {shouldBootstrapEditor: false});
+
+ expect(client1.getHTML()).toEqual('');
+ expect(client1.getHTML()).toEqual(client2.getHTML());
+
+ // Wait for clients to render the initial content
+ await Promise.resolve().then();
+
+ expect(client1.getHTML()).toEqual('');
+ expect(client1.getHTML()).toEqual(client2.getHTML());
+
+ await waitForReact(() => {
+ client1.update(() => {
+ const root = $getRoot();
+
+ // Since bootstrap is false, we create our own paragraph node
+ const paragraph = $createParagraphNode();
+ const text = $createTextNode('Hello');
+ paragraph.append(text);
+
+ root.append(paragraph);
+ });
+ });
+
+ expect(client1.getHTML()).toEqual(
+ 'Hello
',
+ );
+ expect(client1.getHTML()).toEqual(client2.getHTML());
+ expect(client1.getDocJSON()).toEqual(client2.getDocJSON());
+
+ await waitForReact(() => {
+ // Undo the insertion of the initial paragraph and text node
+ client1.getEditor().dispatchCommand(UNDO_COMMAND, undefined);
+ });
+
+ // We expect the safety check in syncYjsChangesToLexical to
+ // insert a new paragraph node and prevent the document from being empty
+ expect(client1.getHTML()).toEqual('
');
+ expect(client1.getHTML()).toEqual(client2.getHTML());
+ expect(client1.getDocJSON()).toEqual(client2.getDocJSON());
+
+ await waitForReact(() => {
+ client1.update(() => {
+ const root = $getRoot();
+
+ const paragraph = $createParagraphNode();
+ const text = $createTextNode('Hello world');
+ paragraph.append(text);
+
+ root.append(paragraph);
+ });
+ });
+
+ expect(client1.getHTML()).toEqual(
+ '
Hello world
',
+ );
+ expect(client1.getHTML()).toEqual(client2.getHTML());
+ expect(client1.getDocJSON()).toEqual(client2.getDocJSON());
- root.append(paragraph);
+ client1.stop();
+ client2.stop();
});
- });
-
- expect(client1.getHTML()).toEqual(
- '
Hello world
',
- );
- expect(client1.getHTML()).toEqual(client2.getHTML());
- expect(client1.getDocJSON()).toEqual({
- root: '[object Object]Hello world',
- });
- expect(client1.getDocJSON()).toEqual(client2.getDocJSON());
-
- client1.stop();
- client2.stop();
- });
+ },
+ );
it('Should handle multiple text nodes being normalized due to merge conflict', async () => {
- const connector = createTestConnection();
+ // Only applicable to Collab v1.
+ const connector = createTestConnection(false);
const client1 = connector.createClient('1');
const client2 = connector.createClient('2');
client1.start(container!);
client2.start(container!);
- await expectCorrectInitialContent(client1, client2);
+ await expectCorrectInitialContent(client1, client2, false);
client2.disconnect();
@@ -485,9 +483,6 @@ describe('Collaboration', () => {
expect(client1.getHTML()).toEqual(
'1
',
);
- expect(client1.getDocJSON()).toEqual({
- root: '[object Object]1',
- });
// Simulate normalization merge conflicts by inserting YMap+strings directly into Yjs.
const yDoc = client1.getDoc();
@@ -505,9 +500,6 @@ describe('Collaboration', () => {
expect(client1.getHTML()).toEqual(
'1
',
);
- expect(client1.getDocJSON()).toEqual({
- root: '[object Object]1[object Object]2[object Object]3',
- });
// When client2 reconnects, it will normalize the three text nodes, which syncs back to client1.
await waitForReact(() => {
@@ -518,9 +510,6 @@ describe('Collaboration', () => {
'123
',
);
expect(client1.getHTML()).toEqual(client2.getHTML());
- expect(client1.getDocJSON()).toEqual({
- root: '[object Object]123',
- });
expect(client1.getDocJSON()).toEqual(client2.getDocJSON());
client1.stop();
diff --git a/packages/lexical-react/src/__tests__/unit/CollaborationWithCollisions.test.ts b/packages/lexical-react/src/__tests__/unit/CollaborationWithCollisions.test.ts
index 6ffb1895089..4ce4cbadf49 100644
--- a/packages/lexical-react/src/__tests__/unit/CollaborationWithCollisions.test.ts
+++ b/packages/lexical-react/src/__tests__/unit/CollaborationWithCollisions.test.ts
@@ -194,47 +194,52 @@ describe('CollaborationWithCollisions', () => {
},
];
- SIMPLE_TEXT_COLLISION_TESTS.forEach((testCase) => {
- it(testCase.name, async () => {
- const connection = createTestConnection();
- const clients = createAndStartClients(
- connection,
- container!,
- testCase.clients.length,
- );
-
- // Set initial content (into first editor only, the rest will be sync'd)
- const clientA = clients[0];
-
- await waitForReact(() => {
- clientA.update(() => {
- $getRoot().clear();
- testCase.init();
- });
- });
+ describe.each([[false], [true]])(
+ 'useCollabV2: %s',
+ (useCollabV2: boolean) => {
+ SIMPLE_TEXT_COLLISION_TESTS.forEach((testCase) => {
+ it(testCase.name, async () => {
+ const connection = createTestConnection(useCollabV2);
+ const clients = createAndStartClients(
+ connection,
+ container!,
+ testCase.clients.length,
+ );
+
+ // Set initial content (into first editor only, the rest will be sync'd)
+ const clientA = clients[0];
+
+ await waitForReact(() => {
+ clientA.update(() => {
+ $getRoot().clear();
+ testCase.init();
+ });
+ });
- testClientsForEquality(clients);
+ testClientsForEquality(clients);
- // Disconnect clients and apply client-specific actions, reconnect them back and
- // verify that they're sync'd and have the same content
- disconnectClients(clients);
+ // Disconnect clients and apply client-specific actions, reconnect them back and
+ // verify that they're sync'd and have the same content
+ disconnectClients(clients);
- for (let i = 0; i < clients.length; i++) {
- await waitForReact(() => {
- clients[i].update(testCase.clients[i]);
- });
- }
+ for (let i = 0; i < clients.length; i++) {
+ await waitForReact(() => {
+ clients[i].update(testCase.clients[i]);
+ });
+ }
- await waitForReact(() => {
- connectClients(clients);
- });
+ await waitForReact(() => {
+ connectClients(clients);
+ });
- if (testCase.expectedHTML) {
- expect(clientA.getHTML()).toEqual(testCase.expectedHTML);
- }
+ if (testCase.expectedHTML) {
+ expect(clientA.getHTML()).toEqual(testCase.expectedHTML);
+ }
- testClientsForEquality(clients);
- stopClients(clients);
- });
- });
+ testClientsForEquality(clients);
+ stopClients(clients);
+ });
+ });
+ },
+ );
});
diff --git a/packages/lexical-react/src/__tests__/unit/utils.tsx b/packages/lexical-react/src/__tests__/unit/utils.tsx
index 7f04610537a..8c16b6b6ca9 100644
--- a/packages/lexical-react/src/__tests__/unit/utils.tsx
+++ b/packages/lexical-react/src/__tests__/unit/utils.tsx
@@ -7,7 +7,10 @@
*/
import {useCollaborationContext} from '@lexical/react/LexicalCollaborationContext';
-import {CollaborationPlugin} from '@lexical/react/LexicalCollaborationPlugin';
+import {
+ CollaborationPlugin,
+ CollaborationPluginV2__EXPERIMENTAL,
+} from '@lexical/react/LexicalCollaborationPlugin';
import {LexicalComposer} from '@lexical/react/LexicalComposer';
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {ContentEditable} from '@lexical/react/LexicalContentEditable';
@@ -27,12 +30,14 @@ function Editor({
setEditor,
awarenessData,
shouldBootstrapEditor = true,
+ useCollabV2 = true,
}: {
doc: Y.Doc;
provider: Provider;
setEditor: (editor: LexicalEditor) => void;
awarenessData?: object | undefined;
shouldBootstrapEditor?: boolean;
+ useCollabV2?: boolean;
}) {
const context = useCollaborationContext();
@@ -46,12 +51,20 @@ function Editor({
return (
<>
- provider}
- shouldBootstrap={shouldBootstrapEditor}
- awarenessData={awarenessData}
- />
+ {useCollabV2 ? (
+ provider}
+ awarenessData={awarenessData}
+ />
+ ) : (
+ provider}
+ shouldBootstrap={shouldBootstrapEditor}
+ awarenessData={awarenessData}
+ />
+ )}
}
placeholder={<>>}
@@ -68,6 +81,7 @@ export class Client implements Provider {
_editor: LexicalEditor | null = null;
_connection: {
_clients: Map;
+ _useCollabV2: boolean;
};
_connected: boolean = false;
_doc: Y.Doc = new Y.Doc();
@@ -185,6 +199,7 @@ export class Client implements Provider {
setEditor={(editor) => (this._editor = editor)}
awarenessData={awarenessData}
shouldBootstrapEditor={options.shouldBootstrapEditor}
+ useCollabV2={this._connection._useCollabV2}
/>
,
);
@@ -269,6 +284,8 @@ export class Client implements Provider {
class TestConnection {
_clients = new Map();
+ constructor(readonly _useCollabV2: boolean) {}
+
createClient(id: string) {
const client = new Client(id, this);
@@ -278,8 +295,8 @@ class TestConnection {
}
}
-export function createTestConnection() {
- return new TestConnection();
+export function createTestConnection(useCollabV2: boolean) {
+ return new TestConnection(useCollabV2);
}
export async function waitForReact(cb: () => void) {
diff --git a/packages/lexical-react/src/shared/useYjsCollaboration.tsx b/packages/lexical-react/src/shared/useYjsCollaboration.tsx
index 3690eb6dc0a..002d91aaf6f 100644
--- a/packages/lexical-react/src/shared/useYjsCollaboration.tsx
+++ b/packages/lexical-react/src/shared/useYjsCollaboration.tsx
@@ -24,7 +24,9 @@ import {
setLocalStateFocus,
syncCursorPositions,
syncLexicalUpdateToYjs,
+ syncLexicalUpdateToYjsV2__EXPERIMENTAL,
syncYjsChangesToLexical,
+ syncYjsChangesToLexicalV2__EXPERIMENTAL,
TOGGLE_CONNECT_COMMAND,
} from '@lexical/yjs';
import {
@@ -162,8 +164,44 @@ export function useYjsCollaborationV2__EXPERIMENTAL(
cursorsContainerRef?: CursorsContainerRef,
awarenessData?: object,
) {
- // TODO: configure observer/listener.
- // TODO: sync cursor positions.
+ // TODO(collab-v2): sync cursor positions.
+
+ useEffect(() => {
+ const {root} = binding;
+
+ const onYjsTreeChanges: OnYjsTreeChanges = (_events, transaction) => {
+ const origin = transaction.origin;
+ if (origin !== binding) {
+ const isFromUndoManger = origin instanceof UndoManager;
+ syncYjsChangesToLexicalV2__EXPERIMENTAL(
+ binding,
+ transaction,
+ isFromUndoManger,
+ );
+ }
+ };
+
+ // This updates the local editor state when we receive updates from other clients
+ root.observeDeep(onYjsTreeChanges);
+ const removeListener = editor.registerUpdateListener(
+ ({editorState, dirtyElements, normalizedNodes, tags}) => {
+ if (tags.has(SKIP_COLLAB_TAG) === false) {
+ syncLexicalUpdateToYjsV2__EXPERIMENTAL(
+ binding,
+ editorState,
+ dirtyElements,
+ normalizedNodes,
+ tags,
+ );
+ }
+ },
+ );
+
+ return () => {
+ root.unobserveDeep(onYjsTreeChanges);
+ removeListener();
+ };
+ }, [binding, editor]);
return useYjsCollaborationInternal(
editor,
@@ -350,6 +388,18 @@ export function useYjsHistory(
return useYjsUndoManager(editor, undoManager);
}
+export function useYjsHistoryV2(
+ editor: LexicalEditor,
+ binding: BindingV2,
+): () => void {
+ const undoManager = useMemo(
+ () => createUndoManager(binding, binding.root),
+ [binding],
+ );
+
+ return useYjsUndoManager(editor, undoManager);
+}
+
function useYjsUndoManager(editor: LexicalEditor, undoManager: UndoManager) {
useEffect(() => {
const undo = () => {
diff --git a/packages/lexical-yjs/src/Bindings.ts b/packages/lexical-yjs/src/Bindings.ts
index 776770493b8..ac5fa54f3b1 100644
--- a/packages/lexical-yjs/src/Bindings.ts
+++ b/packages/lexical-yjs/src/Bindings.ts
@@ -11,12 +11,12 @@ import type {CollabElementNode} from './CollabElementNode';
import type {CollabLineBreakNode} from './CollabLineBreakNode';
import type {CollabTextNode} from './CollabTextNode';
import type {Cursor} from './SyncCursors';
-import type {LexicalEditor, NodeKey} from 'lexical';
-import type {Doc, XmlElement} from 'yjs';
+import type {LexicalEditor, NodeKey, TextNode} from 'lexical';
+import type {AbstractType as YAbstractType} from 'yjs';
import {Klass, LexicalNode} from 'lexical';
import invariant from 'shared/invariant';
-import {XmlText} from 'yjs';
+import {Doc, XmlElement, XmlText} from 'yjs';
import {Provider} from '.';
import {$createCollabElementNode} from './CollabElementNode';
@@ -46,10 +46,42 @@ export type Binding = BaseBinding & {
root: CollabElementNode;
};
+export type LexicalMapping = Map<
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ YAbstractType,
+ // Either a node if type is YXmlElement or an Array of text nodes if YXmlText
+ LexicalNode | Array
+>;
+
export type BindingV2 = BaseBinding & {
+ mapping: LexicalMapping;
root: XmlElement;
};
+function createBaseBinding(
+ editor: LexicalEditor,
+ id: string,
+ doc: Doc | null | undefined,
+ docMap: Map,
+ excludedProperties?: ExcludedProperties,
+): BaseBinding {
+ invariant(
+ doc !== undefined && doc !== null,
+ 'createBinding: doc is null or undefined',
+ );
+ return {
+ clientID: doc.clientID,
+ cursors: new Map(),
+ cursorsContainer: null,
+ doc,
+ docMap,
+ editor,
+ excludedProperties: excludedProperties || new Map(),
+ id,
+ nodeProperties: new Map(),
+ };
+}
+
export function createBinding(
editor: LexicalEditor,
provider: Provider,
@@ -70,16 +102,26 @@ export function createBinding(
);
root._key = 'root';
return {
- clientID: doc.clientID,
+ ...createBaseBinding(editor, id, doc, docMap, excludedProperties),
collabNodeMap: new Map(),
- cursors: new Map(),
- cursorsContainer: null,
- doc,
- docMap,
- editor,
- excludedProperties: excludedProperties || new Map(),
- id,
- nodeProperties: new Map(),
root,
};
}
+
+export function createBindingV2__EXPERIMENTAL(
+ editor: LexicalEditor,
+ id: string,
+ doc: Doc | null | undefined,
+ docMap: Map,
+ excludedProperties?: ExcludedProperties,
+): BindingV2 {
+ invariant(
+ doc !== undefined && doc !== null,
+ 'createBinding: doc is null or undefined',
+ );
+ return {
+ ...createBaseBinding(editor, id, doc, docMap, excludedProperties),
+ mapping: new Map(),
+ root: doc.get('root-v2', XmlElement) as XmlElement,
+ };
+}
diff --git a/packages/lexical-yjs/src/SyncEditorStates.ts b/packages/lexical-yjs/src/SyncEditorStates.ts
index f52f7ca7fd7..11a492a4091 100644
--- a/packages/lexical-yjs/src/SyncEditorStates.ts
+++ b/packages/lexical-yjs/src/SyncEditorStates.ts
@@ -6,7 +6,11 @@
*
*/
-import type {EditorState, NodeKey} from 'lexical';
+import type {EditorState, LexicalNode, NodeKey} from 'lexical';
+import type {
+ AbstractType as YAbstractType,
+ Transaction as YTransaction,
+} from 'yjs';
import {
$addUpdateTag,
@@ -33,7 +37,7 @@ import {
YXmlEvent,
} from 'yjs';
-import {Binding, Provider} from '.';
+import {Binding, BindingV2, Provider} from '.';
import {CollabDecoratorNode} from './CollabDecoratorNode';
import {CollabElementNode} from './CollabElementNode';
import {CollabTextNode} from './CollabTextNode';
@@ -43,6 +47,7 @@ import {
SyncCursorPositionsFn,
syncLexicalSelectionToYjs,
} from './SyncCursors';
+import {$createOrUpdateNodeFromYElement, updateYFragment} from './SyncV2';
import {
$getOrInitCollabNodeFromSharedType,
$moveSelectionToPreviousNode,
@@ -304,3 +309,97 @@ export function syncLexicalUpdateToYjs(
});
});
}
+
+function $syncV2XmlElement(
+ binding: BindingV2,
+ transaction: YTransaction,
+): void {
+ const dirtyElements = new Set();
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const collectDirty = (_value: any, type: YAbstractType) => {
+ if (binding.mapping.has(type)) {
+ const node = binding.mapping.get(type)!;
+ if (!(node instanceof Array)) {
+ dirtyElements.add(node.getKey());
+ }
+ }
+ };
+ transaction.changed.forEach(collectDirty);
+ transaction.changedParentTypes.forEach(collectDirty);
+ const fragmentContent = binding.root
+ .toArray()
+ .map(
+ (t) =>
+ $createOrUpdateNodeFromYElement(
+ t as XmlElement,
+ binding,
+ dirtyElements,
+ ) as LexicalNode,
+ )
+ .filter((n) => n !== null);
+
+ // TODO(collab-v2): be more targeted with splicing, similar to CollabElementNode's syncChildrenFromLexical
+ $getRoot().splice(0, $getRoot().getChildrenSize(), fragmentContent);
+}
+
+export function syncYjsChangesToLexicalV2__EXPERIMENTAL(
+ binding: BindingV2,
+ transaction: YTransaction,
+ isFromUndoManger: boolean,
+): void {
+ const editor = binding.editor;
+
+ editor.update(
+ () => {
+ $syncV2XmlElement(binding, transaction);
+
+ if (!isFromUndoManger) {
+ // If it is an external change, we don't want the current scroll position to get changed
+ // since the user might've intentionally scrolled somewhere else in the document.
+ $addUpdateTag(SKIP_SCROLL_INTO_VIEW_TAG);
+ }
+ },
+ {
+ onUpdate: () => {
+ // If there was a collision on the top level paragraph
+ // we need to re-add a paragraph. To ensure this insertion properly syncs with other clients,
+ // it must be placed outside of the update block above that has tags 'collaboration' or 'historic'.
+ editor.update(() => {
+ if ($getRoot().getChildrenSize() === 0) {
+ $getRoot().append($createParagraphNode());
+ }
+ });
+ },
+ skipTransforms: true,
+ tag: isFromUndoManger ? HISTORIC_TAG : COLLABORATION_TAG,
+ },
+ );
+}
+
+export function syncLexicalUpdateToYjsV2__EXPERIMENTAL(
+ binding: BindingV2,
+ editorState: EditorState,
+ dirtyElements: Map,
+ normalizedNodes: Set,
+ tags: Set,
+): void {
+ syncWithTransaction(binding, () => {
+ editorState.read(() => {
+ // TODO(collab-v2): what sort of normalization handling do we need for clients that concurrently create YText?
+
+ if (dirtyElements.has('root')) {
+ updateYFragment(
+ binding.doc,
+ binding.root,
+ $getRoot(),
+ binding,
+ new Set(dirtyElements.keys()),
+ );
+ }
+
+ // const selection = $getSelection();
+ // const prevSelection = prevEditorState._selection;
+ // syncLexicalSelectionToYjs(binding, provider, prevSelection, selection);
+ });
+ });
+}
diff --git a/packages/lexical-yjs/src/SyncV2.ts b/packages/lexical-yjs/src/SyncV2.ts
new file mode 100644
index 00000000000..8f49216b0c0
--- /dev/null
+++ b/packages/lexical-yjs/src/SyncV2.ts
@@ -0,0 +1,647 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import {
+ $createTextNode,
+ ElementNode,
+ LexicalNode,
+ NodeKey,
+ TextNode,
+} from 'lexical';
+// TODO(collab-v2): use internal implementation
+import {simpleDiff} from 'lib0/diff';
+import invariant from 'shared/invariant';
+// TODO(collab-v2): import specific types
+import * as Y from 'yjs';
+
+import {BindingV2} from './Bindings';
+import {$syncPropertiesFromYjs, getNodeProperties} from './Utils';
+
+type ComputeYChange = (
+ event: 'removed' | 'added',
+ id: Y.ID,
+) => Record;
+
+const isVisible = (item: Y.Item, snapshot?: Y.Snapshot): boolean =>
+ snapshot === undefined
+ ? !item.deleted
+ : snapshot.sv.has(item.id.client) &&
+ snapshot.sv.get(item.id.client)! > item.id.clock &&
+ !Y.isDeleted(snapshot.ds, item.id);
+
+/**
+ * @return Returns node if node could be created. Otherwise it deletes the yjs type and returns null
+ */
+export const $createOrUpdateNodeFromYElement = (
+ el: Y.XmlElement,
+ meta: BindingV2,
+ dirtyElements: Set,
+ snapshot?: Y.Snapshot,
+ prevSnapshot?: Y.Snapshot,
+ computeYChange?: ComputeYChange,
+): LexicalNode | null => {
+ let node = meta.mapping.get(el) as LexicalNode | undefined;
+ if (node && !dirtyElements.has(node.getKey())) {
+ return node;
+ }
+
+ const children: LexicalNode[] = [];
+ const $createChildren = (type: Y.XmlElement | Y.XmlText | Y.XmlHook) => {
+ if (type instanceof Y.XmlElement) {
+ const n = $createOrUpdateNodeFromYElement(
+ type,
+ meta,
+ dirtyElements,
+ snapshot,
+ prevSnapshot,
+ computeYChange,
+ );
+ if (n !== null) {
+ children.push(n);
+ }
+ } else if (type instanceof Y.XmlText) {
+ // If the next ytext exists and was created by us, move the content to the current ytext.
+ // This is a fix for #160 -- duplication of characters when two Y.Text exist next to each
+ // other.
+ // eslint-disable-next-line lexical/no-optional-chaining
+ const content = type._item!.right?.content as Y.ContentType | undefined;
+ // eslint-disable-next-line lexical/no-optional-chaining
+ const nextytext = content?.type;
+ if (
+ nextytext instanceof Y.Text &&
+ !nextytext._item!.deleted &&
+ nextytext._item!.id.client === nextytext.doc!.clientID
+ ) {
+ type.applyDelta([{retain: type.length}, ...nextytext.toDelta()]);
+ nextytext.doc!.transact((tr) => {
+ nextytext._item!.delete(tr);
+ });
+ }
+ // now create the prosemirror text nodes
+ const ns = $createTextNodesFromYText(
+ type,
+ meta,
+ snapshot,
+ prevSnapshot,
+ computeYChange,
+ );
+ if (ns !== null) {
+ ns.forEach((textchild) => {
+ if (textchild !== null) {
+ children.push(textchild);
+ }
+ });
+ }
+ } else {
+ invariant(false, 'XmlHook is not supported');
+ }
+ };
+ if (snapshot === undefined || prevSnapshot === undefined) {
+ el.toArray().forEach($createChildren);
+ } else {
+ Y.typeListToArraySnapshot(
+ el,
+ new Y.Snapshot(prevSnapshot.ds, snapshot.sv),
+ ).forEach($createChildren);
+ }
+ try {
+ const attrs = el.getAttributes(snapshot);
+ if (snapshot !== undefined) {
+ if (!isVisible(el._item!, snapshot)) {
+ // TODO(collab-v2): add type for ychange, store in node state?
+ attrs.ychange = computeYChange
+ ? computeYChange('removed', el._item!.id)
+ : {type: 'removed'};
+ } else if (!isVisible(el._item!, prevSnapshot)) {
+ attrs.ychange = computeYChange
+ ? computeYChange('added', el._item!.id)
+ : {type: 'added'};
+ }
+ }
+ const type = attrs.__type;
+ const registeredNodes = meta.editor._nodes;
+ const nodeInfo = registeredNodes.get(type);
+ if (nodeInfo === undefined) {
+ throw new Error(`Node ${type} is not registered`);
+ }
+ node = node || new nodeInfo.klass();
+ $syncPropertiesFromYjs(meta, attrs, node, null);
+ // TODO(collab-v2): be more targeted with splicing, similar to CollabElementNode's syncChildrenFromLexical
+ if (node instanceof ElementNode) {
+ node.splice(0, node.getChildrenSize(), children);
+ }
+ meta.mapping.set(el, node.getLatest());
+ return node;
+ } catch (e) {
+ // an error occured while creating the node. This is probably a result of a concurrent action.
+ // TODO(collab-v2): also delete the mapped node from editor state.
+ el.doc!.transact((transaction) => {
+ el._item!.delete(transaction);
+ }, meta);
+ meta.mapping.delete(el);
+ return null;
+ }
+};
+
+const $createTextNodesFromYText = (
+ text: Y.XmlText,
+ meta: BindingV2,
+ snapshot?: Y.Snapshot,
+ prevSnapshot?: Y.Snapshot,
+ computeYChange?: ComputeYChange,
+): Array | null => {
+ const deltas = text.toDelta(snapshot, prevSnapshot, computeYChange);
+ const nodes: TextNode[] = (meta.mapping.get(text) as TextNode[]) ?? [];
+ while (nodes.length < deltas.length) {
+ nodes.push($createTextNode());
+ }
+ try {
+ for (let i = 0; i < deltas.length; i++) {
+ const node = nodes[i];
+ const delta = deltas[i];
+ const {attributes, insert} = delta;
+ node.setTextContent(insert);
+ const properties = {
+ ...attributes.__properties,
+ ...attributes.ychange,
+ };
+ $syncPropertiesFromYjs(meta, properties, node, null);
+ }
+ while (nodes.length > deltas.length) {
+ nodes.pop()!.remove();
+ }
+ } catch (e) {
+ // an error occured while creating the node. This is probably a result of a concurrent action.
+ // TODO(collab-v2): also delete the mapped text nodes from editor state.
+ text.doc!.transact((transaction) => {
+ text._item!.delete(transaction);
+ });
+ return null;
+ }
+ meta.mapping.set(
+ text,
+ nodes.map((node) => node.getLatest()),
+ );
+ return nodes;
+};
+
+const createTypeFromTextNodes = (
+ nodes: TextNode[],
+ meta: BindingV2,
+): Y.XmlText => {
+ const type = new Y.XmlText();
+ const delta = nodes.map((node) => ({
+ // TODO(collab-v2): exclude ychange, handle node state
+ attributes: {__properties: propertiesToAttributes(node, meta)},
+ insert: node.getTextContent(),
+ }));
+ type.applyDelta(delta);
+ meta.mapping.set(type, nodes);
+ return type;
+};
+
+const createTypeFromElementNode = (
+ node: LexicalNode,
+ meta: BindingV2,
+): Y.XmlElement => {
+ const type = new Y.XmlElement(node.getType());
+ // TODO(collab-v2): exclude ychange, handle node state
+ const attrs = propertiesToAttributes(node, meta);
+ for (const key in attrs) {
+ const val = attrs[key];
+ if (val !== null) {
+ type.setAttribute(key, val);
+ }
+ }
+ if (!(node instanceof ElementNode)) {
+ return type;
+ }
+ type.insert(
+ 0,
+ normalizePNodeContent(node).map((n) =>
+ createTypeFromTextOrElementNode(n, meta),
+ ),
+ );
+ meta.mapping.set(type, node);
+ return type;
+};
+
+const createTypeFromTextOrElementNode = (
+ node: LexicalNode | TextNode[],
+ meta: BindingV2,
+): Y.XmlElement | Y.XmlText =>
+ node instanceof Array
+ ? createTypeFromTextNodes(node, meta)
+ : createTypeFromElementNode(node, meta);
+
+const isObject = (val: unknown) => typeof val === 'object' && val !== null;
+
+const equalAttrs = (
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ pattrs: Record,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ yattrs: Record | null,
+) => {
+ const keys = Object.keys(pattrs).filter((key) => pattrs[key] !== null);
+ if (yattrs == null) {
+ return keys.length === 0;
+ }
+ let eq =
+ keys.length ===
+ Object.keys(yattrs).filter((key) => yattrs[key] !== null).length;
+ for (let i = 0; i < keys.length && eq; i++) {
+ const key = keys[i];
+ const l = pattrs[key];
+ const r = yattrs[key];
+ eq =
+ key === 'ychange' ||
+ l === r ||
+ (isObject(l) && isObject(r) && equalAttrs(l, r));
+ }
+ return eq;
+};
+
+type NormalizedPNodeContent = Array | LexicalNode>;
+
+const normalizePNodeContent = (pnode: LexicalNode): NormalizedPNodeContent => {
+ if (!(pnode instanceof ElementNode)) {
+ return [];
+ }
+ const c = pnode.getChildren();
+ const res: NormalizedPNodeContent = [];
+ for (let i = 0; i < c.length; i++) {
+ const n = c[i];
+ if (n instanceof TextNode) {
+ const textNodes: TextNode[] = [];
+ for (
+ let tnode = c[i];
+ i < c.length && tnode instanceof TextNode;
+ tnode = c[++i]
+ ) {
+ textNodes.push(tnode);
+ }
+ i--;
+ res.push(textNodes);
+ } else {
+ res.push(n);
+ }
+ }
+ return res;
+};
+
+const equalYTextLText = (
+ ytext: Y.XmlText,
+ ltexts: TextNode[],
+ meta: BindingV2,
+) => {
+ const delta = ytext.toDelta();
+ return (
+ delta.length === ltexts.length &&
+ delta.every(
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (d: any, i: number) =>
+ d.insert === ltexts[i].getTextContent() &&
+ equalAttrs(
+ d.attributes.__properties,
+ propertiesToAttributes(ltexts[i], meta),
+ ),
+ )
+ );
+};
+
+const equalYTypePNode = (
+ ytype: Y.XmlElement | Y.XmlText | Y.XmlHook,
+ pnode: LexicalNode | TextNode[],
+ meta: BindingV2,
+): boolean => {
+ if (
+ ytype instanceof Y.XmlElement &&
+ !(pnode instanceof Array) &&
+ matchNodeName(ytype, pnode)
+ ) {
+ const normalizedContent = normalizePNodeContent(pnode);
+ return (
+ ytype._length === normalizedContent.length &&
+ equalAttrs(ytype.getAttributes(), propertiesToAttributes(pnode, meta)) &&
+ ytype
+ .toArray()
+ .every((ychild, i) =>
+ equalYTypePNode(ychild, normalizedContent[i], meta),
+ )
+ );
+ }
+ return (
+ ytype instanceof Y.XmlText &&
+ pnode instanceof Array &&
+ equalYTextLText(ytype, pnode, meta)
+ );
+};
+
+const mappedIdentity = (
+ mapped: LexicalNode | TextNode[] | undefined,
+ pcontent: LexicalNode | TextNode[],
+) =>
+ mapped === pcontent ||
+ (mapped instanceof Array &&
+ pcontent instanceof Array &&
+ mapped.length === pcontent.length &&
+ mapped.every((a, i) => pcontent[i] === a));
+
+type EqualityFactor = {
+ foundMappedChild: boolean;
+ equalityFactor: number;
+};
+
+const computeChildEqualityFactor = (
+ ytype: Y.XmlElement,
+ pnode: LexicalNode,
+ meta: BindingV2,
+): EqualityFactor => {
+ const yChildren = ytype.toArray();
+ const pChildren = normalizePNodeContent(pnode);
+ const pChildCnt = pChildren.length;
+ const yChildCnt = yChildren.length;
+ const minCnt = Math.min(yChildCnt, pChildCnt);
+ let left = 0;
+ let right = 0;
+ let foundMappedChild = false;
+ for (; left < minCnt; left++) {
+ const leftY = yChildren[left];
+ const leftP = pChildren[left];
+ if (mappedIdentity(meta.mapping.get(leftY), leftP)) {
+ foundMappedChild = true; // definite (good) match!
+ } else if (!equalYTypePNode(leftY, leftP, meta)) {
+ break;
+ }
+ }
+ for (; left + right < minCnt; right++) {
+ const rightY = yChildren[yChildCnt - right - 1];
+ const rightP = pChildren[pChildCnt - right - 1];
+ if (mappedIdentity(meta.mapping.get(rightY), rightP)) {
+ foundMappedChild = true;
+ } else if (!equalYTypePNode(rightY, rightP, meta)) {
+ break;
+ }
+ }
+ return {
+ equalityFactor: left + right,
+ foundMappedChild,
+ };
+};
+
+const ytextTrans = (
+ ytext: Y.Text,
+): {nAttrs: Record; str: string} => {
+ let str = '';
+ let n = ytext._start;
+ const nAttrs: Record = {};
+ while (n !== null) {
+ if (!n.deleted) {
+ if (n.countable && n.content instanceof Y.ContentString) {
+ str += n.content.str;
+ } else if (n.content instanceof Y.ContentFormat) {
+ nAttrs[n.content.key] = null;
+ }
+ }
+ n = n.right;
+ }
+ return {
+ nAttrs,
+ str,
+ };
+};
+
+const updateYText = (ytext: Y.Text, ltexts: TextNode[], meta: BindingV2) => {
+ meta.mapping.set(ytext, ltexts);
+ const {nAttrs, str} = ytextTrans(ytext);
+ const content = ltexts.map((l) => ({
+ attributes: Object.assign({}, nAttrs, {
+ __properties: propertiesToAttributes(l, meta),
+ }),
+ insert: l.getTextContent(),
+ }));
+ const {insert, remove, index} = simpleDiff(
+ str,
+ content.map((c) => c.insert).join(''),
+ );
+ ytext.delete(index, remove);
+ ytext.insert(index, insert);
+ ytext.applyDelta(
+ content.map((c) => ({attributes: c.attributes, retain: c.insert.length})),
+ );
+};
+
+const propertiesToAttributes = (node: LexicalNode, meta: BindingV2) => {
+ const properties = getNodeProperties(node, meta);
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const attrs: Record = {};
+ properties.forEach((property) => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ attrs[property] = (node as any)[property];
+ });
+ return attrs;
+};
+
+/**
+ * Update a yDom node by syncing the current content of the prosemirror node.e
+ */
+export const updateYFragment = (
+ y: Y.Doc,
+ yDomFragment: Y.XmlElement,
+ pNode: LexicalNode,
+ meta: BindingV2,
+ dirtyElements: Set,
+) => {
+ if (
+ yDomFragment instanceof Y.XmlElement &&
+ yDomFragment.nodeName !== pNode.getType() &&
+ // TODO(collab-v2): the root XmlElement should have a valid node name
+ !(yDomFragment.nodeName === 'UNDEFINED' && pNode.getType() === 'root')
+ ) {
+ throw new Error('node name mismatch!');
+ }
+ meta.mapping.set(yDomFragment, pNode);
+ // update attributes
+ if (yDomFragment instanceof Y.XmlElement) {
+ const yDomAttrs = yDomFragment.getAttributes();
+ const pAttrs = propertiesToAttributes(pNode, meta);
+ for (const key in pAttrs) {
+ if (pAttrs[key] !== null) {
+ if (yDomAttrs[key] !== pAttrs[key] && key !== 'ychange') {
+ yDomFragment.setAttribute(key, pAttrs[key]);
+ }
+ } else {
+ yDomFragment.removeAttribute(key);
+ }
+ }
+ // remove all keys that are no longer in pAttrs
+ for (const key in yDomAttrs) {
+ if (pAttrs[key] === undefined) {
+ yDomFragment.removeAttribute(key);
+ }
+ }
+ }
+ // update children
+ const pChildren = normalizePNodeContent(pNode);
+ const pChildCnt = pChildren.length;
+ const yChildren = yDomFragment.toArray();
+ const yChildCnt = yChildren.length;
+ const minCnt = Math.min(pChildCnt, yChildCnt);
+ let left = 0;
+ let right = 0;
+ // find number of matching elements from left
+ for (; left < minCnt; left++) {
+ const leftY = yChildren[left];
+ const leftP = pChildren[left];
+ if (mappedIdentity(meta.mapping.get(leftY), leftP)) {
+ if (leftP instanceof ElementNode && dirtyElements.has(leftP.getKey())) {
+ updateYFragment(
+ y,
+ leftY as Y.XmlElement,
+ leftP as LexicalNode,
+ meta,
+ dirtyElements,
+ );
+ }
+ } else {
+ if (equalYTypePNode(leftY, leftP, meta)) {
+ // update mapping
+ meta.mapping.set(leftY, leftP);
+ } else {
+ break;
+ }
+ }
+ }
+ // find number of matching elements from right
+ for (; right + left + 1 < minCnt; right++) {
+ const rightY = yChildren[yChildCnt - right - 1];
+ const rightP = pChildren[pChildCnt - right - 1];
+ if (mappedIdentity(meta.mapping.get(rightY), rightP)) {
+ if (rightP instanceof ElementNode && dirtyElements.has(rightP.getKey())) {
+ updateYFragment(
+ y,
+ rightY as Y.XmlElement,
+ rightP as LexicalNode,
+ meta,
+ dirtyElements,
+ );
+ }
+ } else {
+ if (equalYTypePNode(rightY, rightP, meta)) {
+ // update mapping
+ meta.mapping.set(rightY, rightP);
+ } else {
+ break;
+ }
+ }
+ }
+ y.transact(() => {
+ // try to compare and update
+ while (yChildCnt - left - right > 0 && pChildCnt - left - right > 0) {
+ const leftY = yChildren[left];
+ const leftP = pChildren[left];
+ const rightY = yChildren[yChildCnt - right - 1];
+ const rightP = pChildren[pChildCnt - right - 1];
+ if (leftY instanceof Y.XmlText && leftP instanceof Array) {
+ if (!equalYTextLText(leftY, leftP, meta)) {
+ updateYText(leftY, leftP, meta);
+ }
+ left += 1;
+ } else {
+ let updateLeft =
+ leftY instanceof Y.XmlElement && matchNodeName(leftY, leftP);
+ let updateRight =
+ rightY instanceof Y.XmlElement && matchNodeName(rightY, rightP);
+ if (updateLeft && updateRight) {
+ // decide which which element to update
+ const equalityLeft = computeChildEqualityFactor(
+ leftY as Y.XmlElement,
+ leftP as LexicalNode,
+ meta,
+ );
+ const equalityRight = computeChildEqualityFactor(
+ rightY as Y.XmlElement,
+ rightP as LexicalNode,
+ meta,
+ );
+ if (
+ equalityLeft.foundMappedChild &&
+ !equalityRight.foundMappedChild
+ ) {
+ updateRight = false;
+ } else if (
+ !equalityLeft.foundMappedChild &&
+ equalityRight.foundMappedChild
+ ) {
+ updateLeft = false;
+ } else if (
+ equalityLeft.equalityFactor < equalityRight.equalityFactor
+ ) {
+ updateLeft = false;
+ } else {
+ updateRight = false;
+ }
+ }
+ if (updateLeft) {
+ updateYFragment(
+ y,
+ leftY as Y.XmlElement,
+ leftP as LexicalNode,
+ meta,
+ dirtyElements,
+ );
+ left += 1;
+ } else if (updateRight) {
+ updateYFragment(
+ y,
+ rightY as Y.XmlElement,
+ rightP as LexicalNode,
+ meta,
+ dirtyElements,
+ );
+ right += 1;
+ } else {
+ meta.mapping.delete(yDomFragment.get(left));
+ yDomFragment.delete(left, 1);
+ yDomFragment.insert(left, [
+ createTypeFromTextOrElementNode(leftP, meta),
+ ]);
+ left += 1;
+ }
+ }
+ }
+ const yDelLen = yChildCnt - left - right;
+ if (
+ yChildCnt === 1 &&
+ pChildCnt === 0 &&
+ yChildren[0] instanceof Y.XmlText
+ ) {
+ meta.mapping.delete(yChildren[0]);
+ // Edge case handling https://github.com/yjs/y-prosemirror/issues/108
+ // Only delete the content of the Y.Text to retain remote changes on the same Y.Text object
+ yChildren[0].delete(0, yChildren[0].length);
+ } else if (yDelLen > 0) {
+ yDomFragment
+ .slice(left, left + yDelLen)
+ .forEach((type) => meta.mapping.delete(type));
+ yDomFragment.delete(left, yDelLen);
+ }
+ if (left + right < pChildCnt) {
+ const ins = [];
+ for (let i = left; i < pChildCnt - right; i++) {
+ ins.push(createTypeFromTextOrElementNode(pChildren[i], meta));
+ }
+ yDomFragment.insert(left, ins);
+ }
+ }, meta);
+};
+
+const matchNodeName = (
+ yElement: Y.XmlElement,
+ pNode: LexicalNode | TextNode[],
+) => !(pNode instanceof Array) && yElement.nodeName === pNode.getType();
diff --git a/packages/lexical-yjs/src/Utils.ts b/packages/lexical-yjs/src/Utils.ts
index 87a35d5f2fd..3129c9b40bd 100644
--- a/packages/lexical-yjs/src/Utils.ts
+++ b/packages/lexical-yjs/src/Utils.ts
@@ -6,7 +6,7 @@
*
*/
-import type {Binding, YjsNode} from '.';
+import type {BaseBinding, Binding, YjsNode} from '.';
import {
$getNodeByKey,
@@ -59,7 +59,7 @@ const textExcludedProperties = new Set(['__text']);
function isExcludedProperty(
name: string,
node: LexicalNode,
- binding: Binding,
+ binding: BaseBinding,
): boolean {
if (
baseExcludedProperties.has(name) ||
@@ -86,6 +86,22 @@ function isExcludedProperty(
return excludedProperties != null && excludedProperties.has(name);
}
+export function getNodeProperties(
+ node: LexicalNode,
+ binding: BaseBinding,
+): string[] {
+ const type = node.__type;
+ const {nodeProperties} = binding;
+ if (nodeProperties.has(type)) {
+ return nodeProperties.get(type)!;
+ }
+ const properties = Object.keys(node).filter((property) => {
+ return !isExcludedProperty(property, node, binding);
+ });
+ nodeProperties.set(type, properties);
+ return properties;
+}
+
export function getIndexOfYjsNode(
yjsParentNode: YjsNode,
yjsNode: YjsNode,
@@ -256,8 +272,8 @@ export function createLexicalNodeFromCollabNode(
}
export function $syncPropertiesFromYjs(
- binding: Binding,
- sharedType: XmlText | YMap | XmlElement,
+ binding: BaseBinding,
+ sharedType: XmlText | YMap | XmlElement | Record,
lexicalNode: LexicalNode,
keysChanged: null | Set,
): void {
@@ -265,7 +281,9 @@ export function $syncPropertiesFromYjs(
keysChanged === null
? sharedType instanceof YMap
? Array.from(sharedType.keys())
- : Object.keys(sharedType.getAttributes())
+ : sharedType instanceof XmlText || sharedType instanceof XmlElement
+ ? Object.keys(sharedType.getAttributes())
+ : Object.keys(sharedType)
: Array.from(keysChanged);
let writableNode: LexicalNode | undefined;
@@ -276,7 +294,7 @@ export function $syncPropertiesFromYjs(
if (!writableNode) {
writableNode = lexicalNode.getWritable();
}
- $syncNodeStateToLexical(binding, sharedType, writableNode);
+ $syncNodeStateToLexical(sharedType, writableNode);
}
continue;
}
@@ -311,13 +329,18 @@ export function $syncPropertiesFromYjs(
}
function sharedTypeGet(
- sharedType: XmlText | YMap | XmlElement,
+ sharedType: XmlText | YMap | XmlElement | Record,
property: string,
): unknown {
if (sharedType instanceof YMap) {
return sharedType.get(property);
- } else {
+ } else if (
+ sharedType instanceof XmlText ||
+ sharedType instanceof XmlElement
+ ) {
return sharedType.getAttribute(property);
+ } else {
+ return sharedType[property];
}
}
@@ -334,11 +357,11 @@ function sharedTypeSet(
}
function $syncNodeStateToLexical(
- binding: Binding,
- sharedType: XmlText | YMap | XmlElement,
+ sharedType: XmlText | YMap | XmlElement | Record,
lexicalNode: LexicalNode,
): void {
const existingState = sharedTypeGet(sharedType, '__state');
+ // TODO(collab-v2): handle v2 where the sharedType is a Record
if (!(existingState instanceof YMap)) {
return;
}
@@ -393,15 +416,7 @@ export function syncPropertiesFromLexical(
prevLexicalNode: null | LexicalNode,
nextLexicalNode: LexicalNode,
): void {
- const type = nextLexicalNode.__type;
- const nodeProperties = binding.nodeProperties;
- let properties = nodeProperties.get(type);
- if (properties === undefined) {
- properties = Object.keys(nextLexicalNode).filter((property) => {
- return !isExcludedProperty(property, nextLexicalNode, binding);
- });
- nodeProperties.set(type, properties);
- }
+ const properties = getNodeProperties(nextLexicalNode, binding);
const EditorClass = binding.editor.constructor;
@@ -555,7 +570,10 @@ export function doesSelectionNeedRecovering(
return recoveryNeeded;
}
-export function syncWithTransaction(binding: Binding, fn: () => void): void {
+export function syncWithTransaction(
+ binding: BaseBinding,
+ fn: () => void,
+): void {
binding.doc.transact(fn, binding);
}
diff --git a/packages/lexical-yjs/src/index.ts b/packages/lexical-yjs/src/index.ts
index ce6b004046f..f6a2e630ba5 100644
--- a/packages/lexical-yjs/src/index.ts
+++ b/packages/lexical-yjs/src/index.ts
@@ -6,9 +6,15 @@
*
*/
-import type {Binding} from './Bindings';
+import type {BaseBinding} from './Bindings';
import type {LexicalCommand} from 'lexical';
-import type {Doc, RelativePosition, UndoManager, XmlText} from 'yjs';
+import type {
+ Doc,
+ RelativePosition,
+ UndoManager,
+ XmlElement,
+ XmlText,
+} from 'yjs';
import {createCommand} from 'lexical';
import {UndoManager as YjsUndoManager} from 'yjs';
@@ -65,11 +71,11 @@ export type {
ClientID,
ExcludedProperties,
} from './Bindings';
-export {createBinding} from './Bindings';
+export {createBinding, createBindingV2__EXPERIMENTAL} from './Bindings';
export function createUndoManager(
- binding: Binding,
- root: XmlText,
+ binding: BaseBinding,
+ root: XmlText | XmlElement,
): UndoManager {
return new YjsUndoManager(root, {
trackedOrigins: new Set([binding, null]),
@@ -124,5 +130,7 @@ export {
} from './SyncCursors';
export {
syncLexicalUpdateToYjs,
+ syncLexicalUpdateToYjsV2__EXPERIMENTAL,
syncYjsChangesToLexical,
+ syncYjsChangesToLexicalV2__EXPERIMENTAL,
} from './SyncEditorStates';
From e59f5600d0e600a510b9097b3761e643d0898d72 Mon Sep 17 00:00:00 2001
From: James Fitzsimmons
Date: Tue, 17 Jun 2025 15:02:12 +1000
Subject: [PATCH 03/21] [lexical-yjs] Feature: sync cursors in collab v2
---
.../src/shared/useYjsCollaboration.tsx | 21 +-
packages/lexical-yjs/src/BiMultiMap.ts | 175 ++++++++++++++
packages/lexical-yjs/src/Bindings.ts | 21 +-
packages/lexical-yjs/src/LexicalMapping.ts | 74 ++++++
packages/lexical-yjs/src/SyncCursors.ts | 213 +++++++++++++++---
packages/lexical-yjs/src/SyncEditorStates.ts | 97 +++++---
packages/lexical-yjs/src/SyncV2.ts | 2 +-
7 files changed, 526 insertions(+), 77 deletions(-)
create mode 100644 packages/lexical-yjs/src/BiMultiMap.ts
create mode 100644 packages/lexical-yjs/src/LexicalMapping.ts
diff --git a/packages/lexical-react/src/shared/useYjsCollaboration.tsx b/packages/lexical-react/src/shared/useYjsCollaboration.tsx
index 002d91aaf6f..8eafd2e6ea3 100644
--- a/packages/lexical-react/src/shared/useYjsCollaboration.tsx
+++ b/packages/lexical-react/src/shared/useYjsCollaboration.tsx
@@ -164,10 +164,9 @@ export function useYjsCollaborationV2__EXPERIMENTAL(
cursorsContainerRef?: CursorsContainerRef,
awarenessData?: object,
) {
- // TODO(collab-v2): sync cursor positions.
-
useEffect(() => {
const {root} = binding;
+ const {awareness} = provider;
const onYjsTreeChanges: OnYjsTreeChanges = (_events, transaction) => {
const origin = transaction.origin;
@@ -175,6 +174,7 @@ export function useYjsCollaborationV2__EXPERIMENTAL(
const isFromUndoManger = origin instanceof UndoManager;
syncYjsChangesToLexicalV2__EXPERIMENTAL(
binding,
+ provider,
transaction,
isFromUndoManger,
);
@@ -184,10 +184,18 @@ export function useYjsCollaborationV2__EXPERIMENTAL(
// This updates the local editor state when we receive updates from other clients
root.observeDeep(onYjsTreeChanges);
const removeListener = editor.registerUpdateListener(
- ({editorState, dirtyElements, normalizedNodes, tags}) => {
+ ({
+ prevEditorState,
+ editorState,
+ dirtyElements,
+ normalizedNodes,
+ tags,
+ }) => {
if (tags.has(SKIP_COLLAB_TAG) === false) {
syncLexicalUpdateToYjsV2__EXPERIMENTAL(
binding,
+ provider,
+ prevEditorState,
editorState,
dirtyElements,
normalizedNodes,
@@ -197,11 +205,16 @@ export function useYjsCollaborationV2__EXPERIMENTAL(
},
);
+ const onAwarenessUpdate = () => {
+ syncCursorPositions(binding, provider);
+ };
+ awareness.on('update', onAwarenessUpdate);
+
return () => {
root.unobserveDeep(onYjsTreeChanges);
removeListener();
};
- }, [binding, editor]);
+ }, [binding, provider, editor]);
return useYjsCollaborationInternal(
editor,
diff --git a/packages/lexical-yjs/src/BiMultiMap.ts b/packages/lexical-yjs/src/BiMultiMap.ts
new file mode 100644
index 00000000000..9ff25b66203
--- /dev/null
+++ b/packages/lexical-yjs/src/BiMultiMap.ts
@@ -0,0 +1,175 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+export class BiMultiMap {
+ private keyToValues = new Map>();
+ private valueToKey = new Map();
+
+ /**
+ * Associates a value with a key. If the value was previously associated
+ * with a different key, it will be removed from that key first.
+ */
+ put(key: K, value: V): void {
+ // If this value is already associated with a different key, remove it
+ const existingKey = this.valueToKey.get(value);
+ if (existingKey !== undefined && existingKey !== key) {
+ this.removeValue(existingKey, value);
+ }
+
+ // Add the key-value association
+ if (!this.keyToValues.has(key)) {
+ this.keyToValues.set(key, new Set());
+ }
+ this.keyToValues.get(key)!.add(value);
+ this.valueToKey.set(value, key);
+ }
+
+ putAll(key: K, values: Array): void {
+ // Remove all existing associations for this key so that insertion order is respected.
+ this.removeKey(key);
+ for (const value of values) {
+ this.put(key, value);
+ }
+ }
+
+ /**
+ * Gets all values associated with a key.
+ * Returns an empty Set if the key doesn't exist.
+ */
+ get(key: K): Set {
+ const values = this.keyToValues.get(key);
+ return values ? new Set(values) : new Set();
+ }
+
+ /**
+ * Gets the key associated with a value.
+ * Returns undefined if the value doesn't exist.
+ */
+ getKey(value: V): K | undefined {
+ return this.valueToKey.get(value);
+ }
+
+ /**
+ * Checks if a key exists in the map.
+ */
+ hasKey(key: K): boolean {
+ return this.keyToValues.has(key);
+ }
+
+ /**
+ * Checks if a value exists in the map.
+ */
+ hasValue(value: V): boolean {
+ return this.valueToKey.has(value);
+ }
+
+ /**
+ * Checks if a specific key-value pair exists.
+ */
+ has(key: K, value: V): boolean {
+ const values = this.keyToValues.get(key);
+ return values ? values.has(value) : false;
+ }
+
+ /**
+ * Removes all values associated with a key.
+ * Returns true if the key existed and was removed.
+ */
+ removeKey(key: K): boolean {
+ const values = this.keyToValues.get(key);
+ if (!values) {
+ return false;
+ }
+
+ // Remove all value-to-key mappings
+ for (const value of values) {
+ this.valueToKey.delete(value);
+ }
+
+ // Remove the key
+ this.keyToValues.delete(key);
+ return true;
+ }
+
+ /**
+ * Removes a value from wherever it exists.
+ * Returns true if the value existed and was removed.
+ */
+ removeValue(key: K, value: V): boolean {
+ const values = this.keyToValues.get(key);
+ if (!values || !values.has(value)) {
+ return false;
+ }
+
+ values.delete(value);
+ this.valueToKey.delete(value);
+
+ // If this was the last value for the key, remove the key entirely
+ if (values.size === 0) {
+ this.keyToValues.delete(key);
+ }
+
+ return true;
+ }
+
+ /**
+ * Gets all keys in the map.
+ */
+ keys(): Set {
+ return new Set(this.keyToValues.keys());
+ }
+
+ /**
+ * Gets all values in the map.
+ */
+ values(): Set {
+ return new Set(this.valueToKey.keys());
+ }
+
+ /**
+ * Gets all key-value pairs as an array of [key, value] tuples.
+ */
+ entries(): [K, V][] {
+ const result: [K, V][] = [];
+ for (const [key, values] of this.keyToValues) {
+ for (const value of values) {
+ result.push([key, value]);
+ }
+ }
+ return result;
+ }
+
+ /**
+ * Returns the number of key-value pairs in the map.
+ */
+ get size(): number {
+ return this.valueToKey.size;
+ }
+
+ /**
+ * Returns the number of unique keys in the map.
+ */
+ get keyCount(): number {
+ return this.keyToValues.size;
+ }
+
+ /**
+ * Removes all entries from the map.
+ */
+ clear(): void {
+ this.keyToValues.clear();
+ this.valueToKey.clear();
+ }
+
+ /**
+ * Returns true if the map is empty.
+ */
+ get isEmpty(): boolean {
+ return this.size === 0;
+ }
+}
diff --git a/packages/lexical-yjs/src/Bindings.ts b/packages/lexical-yjs/src/Bindings.ts
index ac5fa54f3b1..9e32cf6cf31 100644
--- a/packages/lexical-yjs/src/Bindings.ts
+++ b/packages/lexical-yjs/src/Bindings.ts
@@ -11,8 +11,7 @@ import type {CollabElementNode} from './CollabElementNode';
import type {CollabLineBreakNode} from './CollabLineBreakNode';
import type {CollabTextNode} from './CollabTextNode';
import type {Cursor} from './SyncCursors';
-import type {LexicalEditor, NodeKey, TextNode} from 'lexical';
-import type {AbstractType as YAbstractType} from 'yjs';
+import type {LexicalEditor, NodeKey} from 'lexical';
import {Klass, LexicalNode} from 'lexical';
import invariant from 'shared/invariant';
@@ -20,6 +19,7 @@ import {Doc, XmlElement, XmlText} from 'yjs';
import {Provider} from '.';
import {$createCollabElementNode} from './CollabElementNode';
+import {LexicalMapping} from './LexicalMapping';
export type ClientID = number;
export type BaseBinding = {
@@ -46,13 +46,6 @@ export type Binding = BaseBinding & {
root: CollabElementNode;
};
-export type LexicalMapping = Map<
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- YAbstractType,
- // Either a node if type is YXmlElement or an Array of text nodes if YXmlText
- LexicalNode | Array
->;
-
export type BindingV2 = BaseBinding & {
mapping: LexicalMapping;
root: XmlElement;
@@ -121,7 +114,15 @@ export function createBindingV2__EXPERIMENTAL(
);
return {
...createBaseBinding(editor, id, doc, docMap, excludedProperties),
- mapping: new Map(),
+ mapping: new LexicalMapping(),
root: doc.get('root-v2', XmlElement) as XmlElement,
};
}
+
+export function isBindingV1(binding: BaseBinding): binding is Binding {
+ return Object.hasOwn(binding, 'collabNodeMap');
+}
+
+export function isBindingV2(binding: BaseBinding): binding is BindingV2 {
+ return Object.hasOwn(binding, 'mapping');
+}
diff --git a/packages/lexical-yjs/src/LexicalMapping.ts b/packages/lexical-yjs/src/LexicalMapping.ts
new file mode 100644
index 00000000000..1321941aca8
--- /dev/null
+++ b/packages/lexical-yjs/src/LexicalMapping.ts
@@ -0,0 +1,74 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import {$isTextNode, type LexicalNode, type TextNode} from 'lexical';
+import invariant from 'shared/invariant';
+import {XmlElement, XmlHook, XmlText} from 'yjs';
+
+import {BiMultiMap} from './BiMultiMap';
+
+export class LexicalMapping {
+ private _map: BiMultiMap;
+
+ constructor() {
+ this._map = new BiMultiMap();
+ }
+
+ set(
+ sharedType: XmlElement | XmlText | XmlHook,
+ node: LexicalNode | TextNode[],
+ ) {
+ invariant(!(sharedType instanceof XmlHook), 'XmlHook is not supported');
+ const isArray = node instanceof Array;
+ if (sharedType instanceof XmlText) {
+ invariant(isArray, 'Text nodes must be mapped as an array');
+ this._map.putAll(sharedType, node);
+ } else {
+ invariant(!isArray, 'Element nodes must be mapped as a single node');
+ invariant(!$isTextNode(node), 'Text nodes must be mapped to XmlText');
+ this._map.put(sharedType, node);
+ }
+ }
+
+ get(
+ sharedType: XmlElement | XmlText | XmlHook,
+ ): LexicalNode | Array | undefined {
+ if (sharedType instanceof XmlHook) {
+ return undefined;
+ }
+ const nodes = this._map.get(sharedType);
+ if (nodes === undefined) {
+ return undefined;
+ }
+ if (sharedType instanceof XmlText) {
+ const arr = Array.from(nodes) as Array;
+ return arr.length > 0 ? arr : undefined;
+ }
+ return nodes.values().next().value;
+ }
+
+ getSharedType(node: LexicalNode): XmlElement | XmlText | undefined {
+ return this._map.getKey(node);
+ }
+
+ clear(): void {
+ this._map.clear();
+ }
+
+ delete(sharedType: XmlElement | XmlText): boolean {
+ return this._map.removeKey(sharedType);
+ }
+
+ has(sharedType: XmlElement | XmlText): boolean {
+ return this._map.hasKey(sharedType);
+ }
+
+ hasNode(node: LexicalNode): boolean {
+ return this._map.hasValue(node);
+ }
+}
diff --git a/packages/lexical-yjs/src/SyncCursors.ts b/packages/lexical-yjs/src/SyncCursors.ts
index 8827dc299e3..4f08d76d6e9 100644
--- a/packages/lexical-yjs/src/SyncCursors.ts
+++ b/packages/lexical-yjs/src/SyncCursors.ts
@@ -6,9 +6,14 @@
*
*/
-import type {Binding} from './Bindings';
-import type {BaseSelection, NodeKey, NodeMap, Point} from 'lexical';
-import type {AbsolutePosition, RelativePosition} from 'yjs';
+import type {
+ BaseSelection,
+ LexicalNode,
+ NodeKey,
+ NodeMap,
+ Point,
+ TextNode,
+} from 'lexical';
import {createDOMRange, createRectsFromDOMRange} from '@lexical/selection';
import {
@@ -21,16 +26,28 @@ import {
} from 'lexical';
import invariant from 'shared/invariant';
import {
+ AbsolutePosition,
compareRelativePositions,
createAbsolutePositionFromRelativePosition,
createRelativePositionFromTypeIndex,
+ RelativePosition,
+ XmlElement,
+ XmlText,
} from 'yjs';
import {Provider, UserState} from '.';
+import {
+ type BaseBinding,
+ type Binding,
+ type BindingV2,
+ isBindingV1,
+ isBindingV2,
+} from './Bindings';
import {CollabDecoratorNode} from './CollabDecoratorNode';
import {CollabElementNode} from './CollabElementNode';
import {CollabLineBreakNode} from './CollabLineBreakNode';
import {CollabTextNode} from './CollabTextNode';
+import {LexicalMapping} from './LexicalMapping';
import {getPositionFromElementAndOffset} from './Utils';
export type CursorSelection = {
@@ -99,9 +116,49 @@ function createRelativePosition(
return createRelativePositionFromTypeIndex(sharedType, offset);
}
+function createRelativePositionV2(
+ point: Point,
+ binding: BindingV2,
+): null | RelativePosition {
+ const {mapping} = binding;
+ const {offset} = point;
+ const node = point.getNode();
+ const yType = mapping.getSharedType(node);
+ if (yType === undefined) {
+ return null;
+ }
+ if (point.type === 'text') {
+ invariant($isTextNode(node), 'Text point must be a text node');
+ let prevSibling = node.getPreviousSibling();
+ let adjustedOffset = offset;
+ while ($isTextNode(prevSibling)) {
+ adjustedOffset += prevSibling.getTextContentSize();
+ prevSibling = prevSibling.getPreviousSibling();
+ }
+ return createRelativePositionFromTypeIndex(yType, adjustedOffset);
+ } else if (point.type === 'element') {
+ invariant($isElementNode(node), 'Element point must be an element node');
+ let i = 0;
+ let child = node.getFirstChild();
+ while (child !== null && i < offset) {
+ if ($isTextNode(child)) {
+ // Multiple text nodes are collapsed into a single YText.
+ let nextSiblind = child.getNextSibling();
+ while ($isTextNode(nextSiblind)) {
+ nextSiblind = nextSiblind.getNextSibling();
+ }
+ }
+ i++;
+ child = child.getNextSibling();
+ }
+ return createRelativePositionFromTypeIndex(yType, i);
+ }
+ return null;
+}
+
function createAbsolutePosition(
relativePosition: RelativePosition,
- binding: Binding,
+ binding: BaseBinding,
): AbsolutePosition | null {
return createAbsolutePositionFromRelativePosition(
relativePosition,
@@ -132,7 +189,7 @@ function createCursor(name: string, color: string): Cursor {
};
}
-function destroySelection(binding: Binding, selection: CursorSelection) {
+function destroySelection(binding: BaseBinding, selection: CursorSelection) {
const cursorsContainer = binding.cursorsContainer;
if (cursorsContainer !== null) {
@@ -145,7 +202,7 @@ function destroySelection(binding: Binding, selection: CursorSelection) {
}
}
-function destroyCursor(binding: Binding, cursor: Cursor) {
+function destroyCursor(binding: BaseBinding, cursor: Cursor) {
const selection = cursor.selection;
if (selection !== null) {
@@ -184,7 +241,7 @@ function createCursorSelection(
}
function updateCursor(
- binding: Binding,
+ binding: BaseBinding,
cursor: Cursor,
nextSelection: null | CursorSelection,
nodeMap: NodeMap,
@@ -294,19 +351,20 @@ function updateCursor(
selections.pop();
}
}
-
type AnyCollabNode =
| CollabDecoratorNode
| CollabElementNode
| CollabTextNode
| CollabLineBreakNode;
+/**
+ * @deprecated Use `$getAnchorAndFocusForUserState` instead.
+ */
export function getAnchorAndFocusCollabNodesForUserState(
binding: Binding,
userState: UserState,
) {
const {anchorPos, focusPos} = userState;
-
let anchorCollabNode: AnyCollabNode | null = null;
let anchorOffset = 0;
let focusCollabNode: AnyCollabNode | null = null;
@@ -336,8 +394,69 @@ export function getAnchorAndFocusCollabNodesForUserState(
};
}
+export function $getAnchorAndFocusForUserState(
+ binding: BaseBinding,
+ userState: UserState,
+): {
+ anchorKey: NodeKey | null;
+ anchorOffset: number;
+ focusKey: NodeKey | null;
+ focusOffset: number;
+} {
+ const {anchorPos, focusPos} = userState;
+ const anchorAbsPos = anchorPos
+ ? createAbsolutePosition(anchorPos, binding)
+ : null;
+ const focusAbsPos = focusPos
+ ? createAbsolutePosition(focusPos, binding)
+ : null;
+
+ if (anchorAbsPos === null || focusAbsPos === null) {
+ return {
+ anchorKey: null,
+ anchorOffset: 0,
+ focusKey: null,
+ focusOffset: 0,
+ };
+ }
+
+ if (isBindingV1(binding)) {
+ const [anchorCollabNode, anchorOffset] = getCollabNodeAndOffset(
+ anchorAbsPos.type,
+ anchorAbsPos.index,
+ );
+ const [focusCollabNode, focusOffset] = getCollabNodeAndOffset(
+ focusAbsPos.type,
+ focusAbsPos.index,
+ );
+ return {
+ anchorKey: anchorCollabNode !== null ? anchorCollabNode.getKey() : null,
+ anchorOffset,
+ focusKey: focusCollabNode !== null ? focusCollabNode.getKey() : null,
+ focusOffset,
+ };
+ } else if (isBindingV2(binding)) {
+ const [anchorKey, anchorOffset] = $getNodeAndOffsetV2(
+ binding.mapping,
+ anchorAbsPos,
+ );
+ const [focusKey, focusOffset] = $getNodeAndOffsetV2(
+ binding.mapping,
+ focusAbsPos,
+ );
+ return {
+ anchorKey,
+ anchorOffset,
+ focusKey,
+ focusOffset,
+ };
+ } else {
+ invariant(false, 'getAnchorAndFocusForUserState: unknown binding type');
+ }
+}
+
export function $syncLocalCursorPosition(
- binding: Binding,
+ binding: BaseBinding,
provider: Provider,
): void {
const awareness = provider.awareness;
@@ -347,13 +466,10 @@ export function $syncLocalCursorPosition(
return;
}
- const {anchorCollabNode, anchorOffset, focusCollabNode, focusOffset} =
- getAnchorAndFocusCollabNodesForUserState(binding, localState);
-
- if (anchorCollabNode !== null && focusCollabNode !== null) {
- const anchorKey = anchorCollabNode.getKey();
- const focusKey = focusCollabNode.getKey();
+ const {anchorKey, anchorOffset, focusKey, focusOffset} =
+ $getAnchorAndFocusForUserState(binding, localState);
+ if (anchorKey !== null && focusKey !== null) {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
@@ -410,28 +526,63 @@ function getCollabNodeAndOffset(
return [null, 0];
}
+function $getNodeAndOffsetV2(
+ mapping: LexicalMapping,
+ absolutePosition: AbsolutePosition,
+): [null | NodeKey, number] {
+ const yType = absolutePosition.type as XmlElement | XmlText;
+ const offset = absolutePosition.index;
+ if (yType instanceof XmlElement) {
+ const node = mapping.get(yType) as LexicalNode;
+ if (node === undefined) {
+ return [null, 0];
+ }
+ const maxOffset = $isElementNode(node) ? node.getChildrenSize() : 0;
+ return [node.getKey(), Math.min(offset, maxOffset)];
+ } else {
+ const nodes = mapping.get(yType) as TextNode[];
+ if (nodes === undefined) {
+ return [null, 0];
+ }
+ let i = 0;
+ let adjustedOffset = offset;
+ while (
+ adjustedOffset > nodes[i].getTextContentSize() &&
+ i + 1 < nodes.length
+ ) {
+ adjustedOffset -= nodes[i].getTextContentSize();
+ i++;
+ }
+ const textNode = nodes[i];
+ return [
+ textNode.getKey(),
+ Math.min(adjustedOffset, textNode.getTextContentSize()),
+ ];
+ }
+}
+
export type SyncCursorPositionsFn = (
- binding: Binding,
+ binding: BaseBinding,
provider: Provider,
options?: SyncCursorPositionsOptions,
) => void;
export type SyncCursorPositionsOptions = {
getAwarenessStates?: (
- binding: Binding,
+ binding: BaseBinding,
provider: Provider,
) => Map;
};
function getAwarenessStatesDefault(
- _binding: Binding,
+ _binding: BaseBinding,
provider: Provider,
): Map {
return provider.awareness.getStates();
}
export function syncCursorPositions(
- binding: Binding,
+ binding: BaseBinding,
provider: Provider,
options?: SyncCursorPositionsOptions,
): void {
@@ -460,12 +611,11 @@ export function syncCursorPositions(
}
if (focusing) {
- const {anchorCollabNode, anchorOffset, focusCollabNode, focusOffset} =
- getAnchorAndFocusCollabNodesForUserState(binding, awareness);
+ const {anchorKey, anchorOffset, focusKey, focusOffset} = editor.read(
+ () => $getAnchorAndFocusForUserState(binding, awareness),
+ );
- if (anchorCollabNode !== null && focusCollabNode !== null) {
- const anchorKey = anchorCollabNode.getKey();
- const focusKey = focusCollabNode.getKey();
+ if (anchorKey !== null && focusKey !== null) {
selection = cursor.selection;
if (selection === null) {
@@ -508,7 +658,7 @@ export function syncCursorPositions(
}
export function syncLexicalSelectionToYjs(
- binding: Binding,
+ binding: BaseBinding,
provider: Provider,
prevSelection: null | BaseSelection,
nextSelection: null | BaseSelection,
@@ -541,8 +691,15 @@ export function syncLexicalSelectionToYjs(
}
if ($isRangeSelection(nextSelection)) {
- anchorPos = createRelativePosition(nextSelection.anchor, binding);
- focusPos = createRelativePosition(nextSelection.focus, binding);
+ if (isBindingV1(binding)) {
+ anchorPos = createRelativePosition(nextSelection.anchor, binding);
+ focusPos = createRelativePosition(nextSelection.focus, binding);
+ } else if (isBindingV2(binding)) {
+ anchorPos = createRelativePositionV2(nextSelection.anchor, binding);
+ focusPos = createRelativePositionV2(nextSelection.focus, binding);
+ } else {
+ invariant(false, 'syncLexicalSelectionToYjs: unknown binding type');
+ }
}
if (
diff --git a/packages/lexical-yjs/src/SyncEditorStates.ts b/packages/lexical-yjs/src/SyncEditorStates.ts
index 11a492a4091..e57b1bb5e9b 100644
--- a/packages/lexical-yjs/src/SyncEditorStates.ts
+++ b/packages/lexical-yjs/src/SyncEditorStates.ts
@@ -9,6 +9,7 @@
import type {EditorState, LexicalNode, NodeKey} from 'lexical';
import type {
AbstractType as YAbstractType,
+ ContentType,
Transaction as YTransaction,
} from 'yjs';
@@ -27,6 +28,8 @@ import {
} from 'lexical';
import invariant from 'shared/invariant';
import {
+ Item,
+ iterateDeletedStructs,
Map as YMap,
Text as YText,
XmlElement,
@@ -37,7 +40,7 @@ import {
YXmlEvent,
} from 'yjs';
-import {Binding, BindingV2, Provider} from '.';
+import {BaseBinding, Binding, BindingV2, Provider} from '.';
import {CollabDecoratorNode} from './CollabDecoratorNode';
import {CollabElementNode} from './CollabElementNode';
import {CollabTextNode} from './CollabTextNode';
@@ -155,31 +158,7 @@ export function syncYjsChangesToLexical(
$syncEvent(binding, event);
}
- const selection = $getSelection();
-
- if ($isRangeSelection(selection)) {
- if (doesSelectionNeedRecovering(selection)) {
- const prevSelection = currentEditorState._selection;
-
- if ($isRangeSelection(prevSelection)) {
- $syncLocalCursorPosition(binding, provider);
- if (doesSelectionNeedRecovering(selection)) {
- // If the selected node is deleted, move the selection to the previous or parent node.
- const anchorNodeKey = selection.anchor.key;
- $moveSelectionToPreviousNode(anchorNodeKey, currentEditorState);
- }
- }
-
- syncLexicalSelectionToYjs(
- binding,
- provider,
- prevSelection,
- $getSelection(),
- );
- } else {
- $syncLocalCursorPosition(binding, provider);
- }
- }
+ $syncCursorFromYjs(currentEditorState, binding, provider);
if (!isFromUndoManger) {
// If it is an external change, we don't want the current scroll position to get changed
@@ -189,10 +168,10 @@ export function syncYjsChangesToLexical(
},
{
onUpdate: () => {
- syncCursorPositionsFn(binding, provider);
// If there was a collision on the top level paragraph
// we need to re-add a paragraph. To ensure this insertion properly syncs with other clients,
// it must be placed outside of the update block above that has tags 'collaboration' or 'historic'.
+ syncCursorPositionsFn(binding, provider);
editor.update(() => {
if ($getRoot().getChildrenSize() === 0) {
$getRoot().append($createParagraphNode());
@@ -205,6 +184,38 @@ export function syncYjsChangesToLexical(
);
}
+function $syncCursorFromYjs(
+ editorState: EditorState,
+ binding: BaseBinding,
+ provider: Provider,
+) {
+ const selection = $getSelection();
+
+ if ($isRangeSelection(selection)) {
+ if (doesSelectionNeedRecovering(selection)) {
+ const prevSelection = editorState._selection;
+
+ if ($isRangeSelection(prevSelection)) {
+ $syncLocalCursorPosition(binding, provider);
+ if (doesSelectionNeedRecovering(selection)) {
+ // If the selected node is deleted, move the selection to the previous or parent node.
+ const anchorNodeKey = selection.anchor.key;
+ $moveSelectionToPreviousNode(anchorNodeKey, editorState);
+ }
+ }
+
+ syncLexicalSelectionToYjs(
+ binding,
+ provider,
+ prevSelection,
+ $getSelection(),
+ );
+ } else {
+ $syncLocalCursorPosition(binding, provider);
+ }
+ }
+}
+
function $handleNormalizationMergeConflicts(
binding: Binding,
normalizedNodes: Set,
@@ -314,10 +325,21 @@ function $syncV2XmlElement(
binding: BindingV2,
transaction: YTransaction,
): void {
+ iterateDeletedStructs(transaction, transaction.deleteSet, (struct) => {
+ if (struct.constructor === Item) {
+ const content = struct.content as ContentType;
+ const type = content.type;
+ if (type) {
+ binding.mapping.delete(type as XmlElement | XmlText);
+ }
+ }
+ });
+
const dirtyElements = new Set();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
- const collectDirty = (_value: any, type: YAbstractType) => {
- if (binding.mapping.has(type)) {
+ const collectDirty = (_value: unknown, type: YAbstractType) => {
+ const knownType = type instanceof XmlElement || type instanceof XmlText;
+ if (knownType && binding.mapping.has(type)) {
const node = binding.mapping.get(type)!;
if (!(node instanceof Array)) {
dirtyElements.add(node.getKey());
@@ -326,6 +348,7 @@ function $syncV2XmlElement(
};
transaction.changed.forEach(collectDirty);
transaction.changedParentTypes.forEach(collectDirty);
+
const fragmentContent = binding.root
.toArray()
.map(
@@ -344,14 +367,17 @@ function $syncV2XmlElement(
export function syncYjsChangesToLexicalV2__EXPERIMENTAL(
binding: BindingV2,
+ provider: Provider,
transaction: YTransaction,
isFromUndoManger: boolean,
): void {
const editor = binding.editor;
+ const editorState = editor._editorState;
editor.update(
() => {
$syncV2XmlElement(binding, transaction);
+ $syncCursorFromYjs(editorState, binding, provider);
if (!isFromUndoManger) {
// If it is an external change, we don't want the current scroll position to get changed
@@ -364,6 +390,7 @@ export function syncYjsChangesToLexicalV2__EXPERIMENTAL(
// If there was a collision on the top level paragraph
// we need to re-add a paragraph. To ensure this insertion properly syncs with other clients,
// it must be placed outside of the update block above that has tags 'collaboration' or 'historic'.
+ syncCursorPositions(binding, provider);
editor.update(() => {
if ($getRoot().getChildrenSize() === 0) {
$getRoot().append($createParagraphNode());
@@ -378,13 +405,15 @@ export function syncYjsChangesToLexicalV2__EXPERIMENTAL(
export function syncLexicalUpdateToYjsV2__EXPERIMENTAL(
binding: BindingV2,
- editorState: EditorState,
+ provider: Provider,
+ prevEditorState: EditorState,
+ currEditorState: EditorState,
dirtyElements: Map,
normalizedNodes: Set,
tags: Set,
): void {
syncWithTransaction(binding, () => {
- editorState.read(() => {
+ currEditorState.read(() => {
// TODO(collab-v2): what sort of normalization handling do we need for clients that concurrently create YText?
if (dirtyElements.has('root')) {
@@ -397,9 +426,9 @@ export function syncLexicalUpdateToYjsV2__EXPERIMENTAL(
);
}
- // const selection = $getSelection();
- // const prevSelection = prevEditorState._selection;
- // syncLexicalSelectionToYjs(binding, provider, prevSelection, selection);
+ const selection = $getSelection();
+ const prevSelection = prevEditorState._selection;
+ syncLexicalSelectionToYjs(binding, provider, prevSelection, selection);
});
});
}
diff --git a/packages/lexical-yjs/src/SyncV2.ts b/packages/lexical-yjs/src/SyncV2.ts
index 8f49216b0c0..3858c9e1869 100644
--- a/packages/lexical-yjs/src/SyncV2.ts
+++ b/packages/lexical-yjs/src/SyncV2.ts
@@ -416,7 +416,7 @@ const ytextTrans = (
};
};
-const updateYText = (ytext: Y.Text, ltexts: TextNode[], meta: BindingV2) => {
+const updateYText = (ytext: Y.XmlText, ltexts: TextNode[], meta: BindingV2) => {
meta.mapping.set(ytext, ltexts);
const {nAttrs, str} = ytextTrans(ytext);
const content = ltexts.map((l) => ({
From 7bcf36a422492a24c0deda77d8a6c55e54fda30d Mon Sep 17 00:00:00 2001
From: James Fitzsimmons
Date: Wed, 10 Sep 2025 10:36:27 +1000
Subject: [PATCH 04/21] [*] Feature: more v2 sync code, all e2e and unit tests
passing
---
package.json | 3 +
.../src/CodeHighlighterShiki.ts | 2 +-
.../lexical-code/src/CodeHighlighterPrism.ts | 2 +-
.../__tests__/e2e/Collaboration.spec.mjs | 84 ++-
.../__tests__/e2e/File.spec.mjs | 4 +-
.../__tests__/e2e/Images.spec.mjs | 4 +
.../__tests__/e2e/List.spec.mjs | 19 +-
.../__tests__/e2e/Markdown.spec.mjs | 11 +-
.../__tests__/e2e/Selection.spec.mjs | 4 +-
.../__tests__/e2e/Tables.spec.mjs | 28 +-
.../__tests__/e2e/TextFormatting.spec.mjs | 48 +-
.../__tests__/e2e/Toolbar.spec.mjs | 4 +-
.../__tests__/utils/index.mjs | 27 +-
packages/lexical-playground/src/Editor.tsx | 1 +
.../src/themes/PlaygroundEditorTheme.ts | 2 -
.../src/LexicalCollaborationPlugin.tsx | 6 +-
.../src/__tests__/unit/Collaboration.test.ts | 53 +-
.../src/__tests__/unit/utils.tsx | 1 +
.../src/shared/useYjsCollaboration.tsx | 9 +
.../lexical-table/src/LexicalTableNode.ts | 1 +
packages/lexical-yjs/flow/LexicalYjs.js.flow | 2 +-
packages/lexical-yjs/src/BiMultiMap.ts | 2 -
packages/lexical-yjs/src/Bindings.ts | 7 +-
packages/lexical-yjs/src/LexicalMapping.ts | 6 +-
packages/lexical-yjs/src/SyncCursors.ts | 72 ++-
packages/lexical-yjs/src/SyncEditorStates.ts | 54 +-
packages/lexical-yjs/src/SyncV2.ts | 518 +++++++++++-------
packages/lexical-yjs/src/Utils.ts | 37 +-
28 files changed, 611 insertions(+), 400 deletions(-)
diff --git a/package.json b/package.json
index 39661d8024b..aa655015f42 100644
--- a/package.json
+++ b/package.json
@@ -51,6 +51,9 @@
"test-e2e-collab-chromium": "cross-env E2E_BROWSER=chromium E2E_EDITOR_MODE=rich-text-with-collab playwright test --project=\"chromium\"",
"test-e2e-collab-firefox": "cross-env E2E_BROWSER=firefox E2E_EDITOR_MODE=rich-text-with-collab playwright test --project=\"firefox\"",
"test-e2e-collab-webkit": "cross-env E2E_BROWSER=webkit E2E_EDITOR_MODE=rich-text-with-collab playwright test --project=\"webkit\"",
+ "test-e2e-collab-v2-chromium": "cross-env E2E_BROWSER=chromium E2E_EDITOR_MODE=rich-text-with-collab-v2 playwright test --project=\"chromium\"",
+ "test-e2e-collab-v2-firefox": "cross-env E2E_BROWSER=firefox E2E_EDITOR_MODE=rich-text-with-collab-v2 playwright test --project=\"firefox\"",
+ "test-e2e-collab-v2-webkit": "cross-env E2E_BROWSER=webkit E2E_EDITOR_MODE=rich-text-with-collab-v2 playwright test --project=\"webkit\"",
"test-e2e-prod-chromium": "cross-env E2E_BROWSER=chromium E2E_PORT=4000 playwright test --project=\"chromium\"",
"test-e2e-collab-prod-chromium": "cross-env E2E_BROWSER=chromium E2E_PORT=4000 E2E_EDITOR_MODE=rich-text-with-collab playwright test --project=\"chromium\"",
"test-e2e-ci-chromium": "npm run prepare-ci && cross-env E2E_PORT=4000 npm run test-e2e-chromium",
diff --git a/packages/lexical-code-shiki/src/CodeHighlighterShiki.ts b/packages/lexical-code-shiki/src/CodeHighlighterShiki.ts
index 2c1c3447fec..d6f1ff450c0 100644
--- a/packages/lexical-code-shiki/src/CodeHighlighterShiki.ts
+++ b/packages/lexical-code-shiki/src/CodeHighlighterShiki.ts
@@ -777,7 +777,7 @@ export function registerCodeHighlighting(
editor.registerMutationListener(
CodeNode,
(mutations) => {
- editor.update(() => {
+ editor.read(() => {
for (const [key, type] of mutations) {
if (type !== 'destroyed') {
const node = $getNodeByKey(key);
diff --git a/packages/lexical-code/src/CodeHighlighterPrism.ts b/packages/lexical-code/src/CodeHighlighterPrism.ts
index 40cfe4d354e..9fbd5c6a910 100644
--- a/packages/lexical-code/src/CodeHighlighterPrism.ts
+++ b/packages/lexical-code/src/CodeHighlighterPrism.ts
@@ -771,7 +771,7 @@ export function registerCodeHighlighting(
editor.registerMutationListener(
CodeNode,
(mutations) => {
- editor.update(() => {
+ editor.read(() => {
for (const [key, type] of mutations) {
if (type !== 'destroyed') {
const node = $getNodeByKey(key);
diff --git a/packages/lexical-playground/__tests__/e2e/Collaboration.spec.mjs b/packages/lexical-playground/__tests__/e2e/Collaboration.spec.mjs
index 2ddfb79dff3..fe3e34f2aea 100644
--- a/packages/lexical-playground/__tests__/e2e/Collaboration.spec.mjs
+++ b/packages/lexical-playground/__tests__/e2e/Collaboration.spec.mjs
@@ -160,12 +160,21 @@ test.describe('Collaboration', () => {
`,
);
- await assertSelection(page, {
- anchorOffset: 5,
- anchorPath: [0, 0, 0],
- focusOffset: 5,
- focusPath: [0, 0, 0],
- });
+ if (isCollab === 1) {
+ await assertSelection(page, {
+ anchorOffset: 5,
+ anchorPath: [0, 0, 0],
+ focusOffset: 5,
+ focusPath: [0, 0, 0],
+ });
+ } else {
+ await assertSelection(page, {
+ anchorOffset: 1,
+ anchorPath: [0],
+ focusOffset: 1,
+ focusPath: [0],
+ });
+ }
await page.keyboard.press('ArrowDown');
await page.keyboard.type('Some bold text');
@@ -314,7 +323,6 @@ test.describe('Collaboration', () => {
`,
);
- const boldSleep = sleep(1050);
// Right collaborator types at the end of the paragraph.
await page
@@ -339,7 +347,7 @@ test.describe('Collaboration', () => {
);
// Left collaborator undoes their bold text.
- await boldSleep;
+ await sleep(1050);
await page.frameLocator('iframe[name="left"]').getByLabel('Undo').click();
// The undo also removed bold the text node from YJS.
@@ -428,15 +436,27 @@ test.describe('Collaboration', () => {
// Left collaborator undoes their bold text.
await page.frameLocator('iframe[name="left"]').getByLabel('Undo').click();
- // The undo causes the text to be appended to the original string, like in the above test.
- await assertHTML(
- page,
- html`
-
- normal boldBOLD
-
- `,
- );
+ if (isCollab === 1) {
+ // The undo causes the text to be appended to the original string, like in the above test.
+ await assertHTML(
+ page,
+ html`
+
+ normal boldBOLD
+
+ `,
+ );
+ } else {
+ // In v2, the text is not moved.
+ await assertHTML(
+ page,
+ html`
+
+ normal boBOLDld
+
+ `,
+ );
+ }
// Left collaborator redoes the bold text.
await page.frameLocator('iframe[name="left"]').getByLabel('Redo').click();
@@ -549,15 +569,27 @@ test.describe('Collaboration', () => {
// Left collaborator undoes the link.
await page.frameLocator('iframe[name="left"]').getByLabel('Undo').click();
- // The undo causes the text to be appended to the original string, like in the above test.
- await assertHTML(
- page,
- html`
-
- Check out the website! now
-
- `,
- );
+ if (isCollab === 1) {
+ // The undo causes the text to be appended to the original string, like in the above test.
+ await assertHTML(
+ page,
+ html`
+
+ Check out the website! now
+
+ `,
+ );
+ } else {
+ // The undo causes the YText node to be removed.
+ await assertHTML(
+ page,
+ html`
+
+ Check out the website!
+
+ `,
+ );
+ }
// Left collaborator redoes the link.
await page.frameLocator('iframe[name="left"]').getByLabel('Redo').click();
diff --git a/packages/lexical-playground/__tests__/e2e/File.spec.mjs b/packages/lexical-playground/__tests__/e2e/File.spec.mjs
index bffe6b99431..f3f43907f3d 100644
--- a/packages/lexical-playground/__tests__/e2e/File.spec.mjs
+++ b/packages/lexical-playground/__tests__/e2e/File.spec.mjs
@@ -14,6 +14,7 @@ import {
html,
initialize,
insertUploadImage,
+ IS_COLLAB_V2,
sleep,
test,
waitForSelector,
@@ -24,7 +25,8 @@ test.describe('File', () => {
test.beforeEach(({isCollab, page}) => initialize({isCollab, page}));
test(`Can import/export`, async ({page, isPlainText}) => {
- test.skip(isPlainText);
+ // TODO(collab-v2): nested editors are not supported yet
+ test.skip(isPlainText || IS_COLLAB_V2);
await focusEditor(page);
await toggleBold(page);
await page.keyboard.type('Hello');
diff --git a/packages/lexical-playground/__tests__/e2e/Images.spec.mjs b/packages/lexical-playground/__tests__/e2e/Images.spec.mjs
index c4b311450db..fa98a5dec20 100644
--- a/packages/lexical-playground/__tests__/e2e/Images.spec.mjs
+++ b/packages/lexical-playground/__tests__/e2e/Images.spec.mjs
@@ -22,6 +22,7 @@ import {
insertSampleImage,
insertUploadImage,
insertUrlImage,
+ IS_COLLAB_V2,
IS_WINDOWS,
LEGACY_EVENTS,
SAMPLE_IMAGE_URL,
@@ -33,6 +34,9 @@ import {
} from '../utils/index.mjs';
test.describe('Images', () => {
+ // TODO(collab-v2): nested editors are not supported yet
+ test.skip(IS_COLLAB_V2);
+
test.beforeEach(({isCollab, page}) => initialize({isCollab, page}));
test(`Can create a decorator and move selection around it`, async ({
page,
diff --git a/packages/lexical-playground/__tests__/e2e/List.spec.mjs b/packages/lexical-playground/__tests__/e2e/List.spec.mjs
index 811f0f6f084..b5873c3b83b 100644
--- a/packages/lexical-playground/__tests__/e2e/List.spec.mjs
+++ b/packages/lexical-playground/__tests__/e2e/List.spec.mjs
@@ -30,12 +30,12 @@ import {
click,
copyToClipboard,
focusEditor,
+ getExpectedDateTimeHtml,
html,
initialize,
- insertSampleImage,
+ insertDateTime,
pasteFromClipboard,
repeat,
- SAMPLE_IMAGE_URL,
selectFromAlignDropdown,
selectFromColorPicker,
selectFromFormatDropdown,
@@ -229,7 +229,7 @@ test.describe.parallel('Nested List', () => {
await focusEditor(page);
await toggleBulletList(page);
- await insertSampleImage(page);
+ await insertDateTime(page);
await page.keyboard.type('x');
await moveLeft(page, 1);
@@ -244,18 +244,7 @@ test.describe.parallel('Nested List', () => {
value="1">
-
-
-
-

-
-
+ ${getExpectedDateTimeHtml()}
x
diff --git a/packages/lexical-playground/__tests__/e2e/Markdown.spec.mjs b/packages/lexical-playground/__tests__/e2e/Markdown.spec.mjs
index f95f06a79f3..1319d4eb730 100644
--- a/packages/lexical-playground/__tests__/e2e/Markdown.spec.mjs
+++ b/packages/lexical-playground/__tests__/e2e/Markdown.spec.mjs
@@ -23,6 +23,7 @@ import {
getHTML,
html,
initialize,
+ IS_COLLAB_V2,
LEGACY_EVENTS,
pasteFromClipboard,
pressToggleBold,
@@ -906,6 +907,9 @@ test.describe.parallel('Markdown', () => {
});
test('can import single decorator node (#2604)', async ({page}) => {
+ // TODO(collab-v2): nested editors are not supported yet
+ test.skip(IS_COLLAB_V2);
+
await focusEditor(page);
await page.keyboard.type(
'```markdown  => {
test('can import several text match transformers in a same line (#5385)', async ({
page,
}) => {
+ // TODO(collab-v2): nested editors are not supported yet
+ test.skip(IS_COLLAB_V2);
+
await focusEditor(page);
await page.keyboard.type(
'```markdown [link](https://lexical.dev)[link](https://lexical.dev) => {
const TYPED_MARKDOWN = `# Markdown Shortcuts
This is *italic*, _italic_, **bold**, __bold__, ~~strikethrough~~ text
-This is *__~~bold italic strikethrough~~__* text, ___~~this one too~~___
+This is ~~*__bold italic strikethrough__*~~ text, ___~~this one too~~___
It ~~___works [with links](https://lexical.io) too___~~
*Nested **stars tags** are handled too*
# Title
@@ -1101,7 +1108,7 @@ const TYPED_MARKDOWN_HTML = html`
This is
bold italic strikethrough
diff --git a/packages/lexical-playground/__tests__/e2e/Selection.spec.mjs b/packages/lexical-playground/__tests__/e2e/Selection.spec.mjs
index 9c9d6795182..4e018f2bd9c 100644
--- a/packages/lexical-playground/__tests__/e2e/Selection.spec.mjs
+++ b/packages/lexical-playground/__tests__/e2e/Selection.spec.mjs
@@ -42,6 +42,7 @@ import {
insertSampleImage,
insertTable,
insertYouTubeEmbed,
+ IS_COLLAB_V2,
IS_LINUX,
IS_MAC,
IS_WINDOWS,
@@ -86,7 +87,8 @@ test.describe.parallel('Selection', () => {
isPlainText,
browserName,
}) => {
- test.skip(isPlainText);
+ // TODO(collab-v2): nested editors are not supported yet
+ test.skip(isPlainText || IS_COLLAB_V2);
const hasSelection = async (parentSelector) =>
await evaluate(
page,
diff --git a/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs b/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs
index 8a3a27fc620..12f7a9b747e 100644
--- a/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs
+++ b/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs
@@ -29,12 +29,13 @@ import {
dragMouse,
expect,
focusEditor,
+ getExpectedDateTimeHtml,
getPageOrFrame,
html,
initialize,
insertCollapsible,
+ insertDateTime,
insertHorizontalRule,
- insertSampleImage,
insertTable,
insertTableColumnBefore,
insertTableRowAbove,
@@ -46,13 +47,13 @@ import {
LEGACY_EVENTS,
mergeTableCells,
pasteFromClipboard,
- SAMPLE_IMAGE_URL,
selectCellFromTableCoord,
selectCellsFromTableCords,
selectFromAdditionalStylesDropdown,
selectFromAlignDropdown,
selectorBoundingBox,
setBackgroundColor,
+ sleep,
test,
toggleColumnHeader,
unmergeTableCell,
@@ -1355,7 +1356,7 @@ test.describe.parallel('Tables', () => {
await focusEditor(page);
await page.keyboard.type('Text before');
await page.keyboard.press('Enter');
- await insertSampleImage(page);
+ await insertDateTime(page);
await page.keyboard.press('Enter');
await page.keyboard.type('Text after');
await insertTable(page, 2, 3);
@@ -1447,7 +1448,7 @@ test.describe.parallel('Tables', () => {
});
test(
- 'Table selection: can select multiple cells and insert an image',
+ 'Table selection: can select multiple cells and insert a decorator',
{
tag: '@flaky',
},
@@ -1467,11 +1468,9 @@ test.describe.parallel('Tables', () => {
await page.keyboard.press('ArrowDown');
await page.keyboard.up('Shift');
- await insertSampleImage(page);
+ await insertDateTime(page);
await page.keyboard.type(' <- it works!');
- await waitForSelector(page, '.editor-image img');
-
await assertHTML(
page,
html`
@@ -1500,18 +1499,7 @@ test.describe.parallel('Tables', () => {
-
-
- 
-
-
+ ${getExpectedDateTimeHtml()}
<- it works!
|
@@ -6700,6 +6688,8 @@ test.describe.parallel('Tables', () => {
false,
);
+ await sleep(1050);
+
await withExclusiveClipboardAccess(async () => {
const clipboard = await copyToClipboard(page);
diff --git a/packages/lexical-playground/__tests__/e2e/TextFormatting.spec.mjs b/packages/lexical-playground/__tests__/e2e/TextFormatting.spec.mjs
index 14cb199224b..062309ad457 100644
--- a/packages/lexical-playground/__tests__/e2e/TextFormatting.spec.mjs
+++ b/packages/lexical-playground/__tests__/e2e/TextFormatting.spec.mjs
@@ -27,12 +27,11 @@ import {
expect,
fill,
focusEditor,
+ getExpectedDateTimeHtml,
html,
initialize,
- insertSampleImage,
- SAMPLE_IMAGE_URL,
+ insertDateTime,
test,
- waitForSelector,
} from '../utils/index.mjs';
test.describe.parallel('TextFormatting', () => {
@@ -1187,32 +1186,19 @@ test.describe.parallel('TextFormatting', () => {
await focusEditor(page);
await page.keyboard.type('A');
- await insertSampleImage(page);
+ await insertDateTime(page);
await page.keyboard.type('BC');
await moveLeft(page, 1);
await selectCharacters(page, 'left', 2);
if (!isCollab) {
- await waitForSelector(page, '.editor-image img');
await assertHTML(
page,
html`
A
-
-
-

-
-
+ ${getExpectedDateTimeHtml()}
BC
`,
@@ -1224,18 +1210,7 @@ test.describe.parallel('TextFormatting', () => {
html`
A
-
-
-

-
-
+ ${getExpectedDateTimeHtml()}
@@ -1251,18 +1226,7 @@ test.describe.parallel('TextFormatting', () => {
html`
A
-
-
-

-
-
+ ${getExpectedDateTimeHtml()}
BC
`,
diff --git a/packages/lexical-playground/__tests__/e2e/Toolbar.spec.mjs b/packages/lexical-playground/__tests__/e2e/Toolbar.spec.mjs
index 0cc194dfaa9..dab2e8118c3 100644
--- a/packages/lexical-playground/__tests__/e2e/Toolbar.spec.mjs
+++ b/packages/lexical-playground/__tests__/e2e/Toolbar.spec.mjs
@@ -23,6 +23,7 @@ import {
html,
initialize,
insertSampleImage,
+ IS_COLLAB_V2,
SAMPLE_IMAGE_URL,
selectFromAlignDropdown,
selectFromInsertDropdown,
@@ -45,7 +46,8 @@ test.describe('Toolbar', () => {
tag: '@flaky',
},
async ({page, isPlainText}) => {
- test.skip(isPlainText);
+ // TODO(collab-v2): nested editors are not supported yet
+ test.skip(isPlainText || IS_COLLAB_V2);
await focusEditor(page);
// Add caption
diff --git a/packages/lexical-playground/__tests__/utils/index.mjs b/packages/lexical-playground/__tests__/utils/index.mjs
index 8308ac41985..cfb3258f22c 100644
--- a/packages/lexical-playground/__tests__/utils/index.mjs
+++ b/packages/lexical-playground/__tests__/utils/index.mjs
@@ -29,8 +29,11 @@ export const E2E_BROWSER = process.env.E2E_BROWSER;
export const IS_MAC = process.platform === 'darwin';
export const IS_WINDOWS = process.platform === 'win32';
export const IS_LINUX = !IS_MAC && !IS_WINDOWS;
-export const IS_COLLAB =
+export const IS_COLLAB_V1 =
process.env.E2E_EDITOR_MODE === 'rich-text-with-collab';
+export const IS_COLLAB_V2 =
+ process.env.E2E_EDITOR_MODE === 'rich-text-with-collab-v2';
+export const IS_COLLAB = IS_COLLAB_V1 || IS_COLLAB_V2;
const IS_RICH_TEXT = process.env.E2E_EDITOR_MODE !== 'plain-text';
const IS_PLAIN_TEXT = process.env.E2E_EDITOR_MODE === 'plain-text';
export const LEGACY_EVENTS = process.env.E2E_EVENTS_MODE === 'legacy-events';
@@ -100,7 +103,8 @@ export async function initialize({
appSettings.tableHorizontalScroll =
tableHorizontalScroll ?? IS_TABLE_HORIZONTAL_SCROLL;
if (isCollab) {
- appSettings.isCollab = isCollab;
+ appSettings.isCollab = !!isCollab;
+ appSettings.useCollabV2 = isCollab === 2;
appSettings.collabId = randomUUID();
}
if (showNestedEditorTreeView === undefined) {
@@ -174,7 +178,8 @@ export const test = base.extend({
hasLinkAttributes: false,
isCharLimit: false,
isCharLimitUtf8: false,
- isCollab: IS_COLLAB,
+ /** @type {number | false} */
+ isCollab: IS_COLLAB_V1 ? 1 : IS_COLLAB_V2 ? 2 : false,
isMaxLength: false,
isPlainText: IS_PLAIN_TEXT,
isRichText: IS_RICH_TEXT,
@@ -753,6 +758,22 @@ export async function insertDateTime(page) {
await sleep(500);
}
+export function getExpectedDateTimeHtml() {
+ const now = new Date();
+ const date = new Date(now.getFullYear(), now.getMonth(), now.getDate());
+ return html`
+
+
+ ${date.toDateString()}
+
+
+ `;
+}
+
export async function insertImageCaption(page, caption) {
await click(page, '.editor-image img');
await click(page, '.image-caption-button');
diff --git a/packages/lexical-playground/src/Editor.tsx b/packages/lexical-playground/src/Editor.tsx
index e82ee9d7556..55ccc45f6f2 100644
--- a/packages/lexical-playground/src/Editor.tsx
+++ b/packages/lexical-playground/src/Editor.tsx
@@ -193,6 +193,7 @@ export default function Editor(): JSX.Element {
) : (
);
@@ -265,6 +267,7 @@ function YjsCollaborationCursorsV2__EXPERIMENTAL({
yjsDocMap,
name,
color,
+ shouldBootstrap,
cursorsContainerRef,
awarenessData,
collabContext,
@@ -293,6 +296,7 @@ function YjsCollaborationCursorsV2__EXPERIMENTAL({
yjsDocMap,
name,
color,
+ shouldBootstrap,
binding,
setDoc,
cursorsContainerRef,
diff --git a/packages/lexical-react/src/__tests__/unit/Collaboration.test.ts b/packages/lexical-react/src/__tests__/unit/Collaboration.test.ts
index 73abcdcfdc6..dbced8728d5 100644
--- a/packages/lexical-react/src/__tests__/unit/Collaboration.test.ts
+++ b/packages/lexical-react/src/__tests__/unit/Collaboration.test.ts
@@ -32,11 +32,7 @@ describe('Collaboration', () => {
container = null;
});
- async function expectCorrectInitialContent(
- client1: Client,
- client2: Client,
- useCollabV2: boolean,
- ) {
+ async function expectCorrectInitialContent(client1: Client, client2: Client) {
// Should be empty, as client has not yet updated
expect(client1.getHTML()).toEqual('');
expect(client1.getHTML()).toEqual(client2.getHTML());
@@ -44,13 +40,6 @@ describe('Collaboration', () => {
// Wait for clients to render the initial content
await Promise.resolve().then();
- if (useCollabV2) {
- // Manually bootstrap editor state.
- await waitForReact(() => {
- client1.update(() => $getRoot().append($createParagraphNode()));
- });
- }
-
expect(client1.getHTML()).toEqual('
');
expect(client1.getHTML()).toEqual(client2.getHTML());
expect(client1.getDocJSON()).toEqual(client2.getDocJSON());
@@ -68,7 +57,7 @@ describe('Collaboration', () => {
client1.start(container!);
client2.start(container!);
- await expectCorrectInitialContent(client1, client2, useCollabV2);
+ await expectCorrectInitialContent(client1, client2);
// Insert a text node on client 1
await waitForReact(() => {
@@ -120,7 +109,7 @@ describe('Collaboration', () => {
client1.start(container!);
client2.start(container!);
- await expectCorrectInitialContent(client1, client2, useCollabV2);
+ await expectCorrectInitialContent(client1, client2);
client1.disconnect();
@@ -220,7 +209,7 @@ describe('Collaboration', () => {
client1.start(container!);
client2.start(container!);
- await expectCorrectInitialContent(client1, client2, useCollabV2);
+ await expectCorrectInitialContent(client1, client2);
// Insert some a text node on client 1
await waitForReact(() => {
@@ -278,14 +267,20 @@ describe('Collaboration', () => {
client1.connect();
});
- // TODO we can probably handle these conflicts better. We could keep around
- // a "fallback" {Map} when we remove text without any adjacent text nodes. This
- // would require big changes in `CollabElementNode.splice` and also need adjustments
- // in `CollabElementNode.applyChildrenYjsDelta` to handle the existence of these
- // fallback maps. For now though, if a user clears all text nodes from an element
- // and another user inserts some text into the same element at the same time, the
- // deletion will take precedence on conflicts.
- expect(client1.getHTML()).toEqual('
');
+ if (useCollabV2) {
+ expect(client1.getHTML()).toEqual(
+ 'Hello world
',
+ );
+ } else {
+ // TODO we can probably handle these conflicts better. We could keep around
+ // a "fallback" {Map} when we remove text without any adjacent text nodes. This
+ // would require big changes in `CollabElementNode.splice` and also need adjustments
+ // in `CollabElementNode.applyChildrenYjsDelta` to handle the existence of these
+ // fallback maps. For now though, if a user clears all text nodes from an element
+ // and another user inserts some text into the same element at the same time, the
+ // deletion will take precedence on conflicts.
+ expect(client1.getHTML()).toEqual('
');
+ }
expect(client1.getHTML()).toEqual(client2.getHTML());
expect(client1.getDocJSON()).toEqual(client2.getDocJSON());
client1.stop();
@@ -299,7 +294,7 @@ describe('Collaboration', () => {
client1.start(container!);
client2.start(container!);
- await expectCorrectInitialContent(client1, client2, useCollabV2);
+ await expectCorrectInitialContent(client1, client2);
await waitForReact(() => {
client1.update(() => {
@@ -350,7 +345,7 @@ describe('Collaboration', () => {
client1.start(container!, awarenessData1);
client2.start(container!, awarenessData2);
- await expectCorrectInitialContent(client1, client2, useCollabV2);
+ await expectCorrectInitialContent(client1, client2);
expect(client1.awareness.getLocalState()!.awarenessData).toEqual(
awarenessData1,
@@ -457,7 +452,7 @@ describe('Collaboration', () => {
client1.start(container!);
client2.start(container!);
- await expectCorrectInitialContent(client1, client2, false);
+ await expectCorrectInitialContent(client1, client2);
client2.disconnect();
@@ -472,7 +467,7 @@ describe('Collaboration', () => {
});
expect(client1.getHTML()).toEqual(
- '1
',
+ '1
',
);
// Simulate normalization merge conflicts by inserting YMap+strings directly into Yjs.
@@ -489,7 +484,7 @@ describe('Collaboration', () => {
// Note: client1 HTML won't have been updated yet here because we edited its Yjs doc directly.
expect(client1.getHTML()).toEqual(
- '1
',
+ '1
',
);
// When client2 reconnects, it will normalize the three text nodes, which syncs back to client1.
@@ -498,7 +493,7 @@ describe('Collaboration', () => {
});
expect(client1.getHTML()).toEqual(
- '123
',
+ '123
',
);
expect(client1.getHTML()).toEqual(client2.getHTML());
expect(client1.getDocJSON()).toEqual(client2.getDocJSON());
diff --git a/packages/lexical-react/src/__tests__/unit/utils.tsx b/packages/lexical-react/src/__tests__/unit/utils.tsx
index 0b156e41a4b..85a15c2522a 100644
--- a/packages/lexical-react/src/__tests__/unit/utils.tsx
+++ b/packages/lexical-react/src/__tests__/unit/utils.tsx
@@ -56,6 +56,7 @@ function Editor({
provider}
+ shouldBootstrap={shouldBootstrapEditor}
awarenessData={awarenessData}
/>
) : (
diff --git a/packages/lexical-react/src/shared/useYjsCollaboration.tsx b/packages/lexical-react/src/shared/useYjsCollaboration.tsx
index bfb00dd7286..281e754c6c4 100644
--- a/packages/lexical-react/src/shared/useYjsCollaboration.tsx
+++ b/packages/lexical-react/src/shared/useYjsCollaboration.tsx
@@ -159,11 +159,19 @@ export function useYjsCollaborationV2__EXPERIMENTAL(
docMap: Map,
name: string,
color: string,
+ shouldBootstrap: boolean,
binding: BindingV2,
setDoc: React.Dispatch>,
cursorsContainerRef?: CursorsContainerRef,
awarenessData?: object,
) {
+ const onBootstrap = useCallback(() => {
+ const {root} = binding;
+ if (shouldBootstrap && root._length === 0) {
+ initializeEditor(editor);
+ }
+ }, [binding, editor, shouldBootstrap]);
+
useEffect(() => {
const {root} = binding;
const {awareness} = provider;
@@ -227,6 +235,7 @@ export function useYjsCollaborationV2__EXPERIMENTAL(
setDoc,
cursorsContainerRef,
awarenessData,
+ onBootstrap,
);
}
diff --git a/packages/lexical-table/src/LexicalTableNode.ts b/packages/lexical-table/src/LexicalTableNode.ts
index fb802ec6e44..41b4d23c915 100644
--- a/packages/lexical-table/src/LexicalTableNode.ts
+++ b/packages/lexical-table/src/LexicalTableNode.ts
@@ -234,6 +234,7 @@ export class TableNode extends ElementNode {
this.__rowStriping = false;
this.__frozenColumnCount = 0;
this.__frozenRowCount = 0;
+ this.__colWidths = undefined;
}
exportJSON(): SerializedTableNode {
diff --git a/packages/lexical-yjs/flow/LexicalYjs.js.flow b/packages/lexical-yjs/flow/LexicalYjs.js.flow
index dbef60290b5..907d982ca9f 100644
--- a/packages/lexical-yjs/flow/LexicalYjs.js.flow
+++ b/packages/lexical-yjs/flow/LexicalYjs.js.flow
@@ -106,7 +106,7 @@ export type Binding = {
docMap: Map,
editor: LexicalEditor,
id: string,
- nodeProperties: Map>,
+ nodeProperties: Map,
root: CollabElementNode,
};
diff --git a/packages/lexical-yjs/src/BiMultiMap.ts b/packages/lexical-yjs/src/BiMultiMap.ts
index 9ff25b66203..54baa53c281 100644
--- a/packages/lexical-yjs/src/BiMultiMap.ts
+++ b/packages/lexical-yjs/src/BiMultiMap.ts
@@ -30,8 +30,6 @@ export class BiMultiMap {
}
putAll(key: K, values: Array): void {
- // Remove all existing associations for this key so that insertion order is respected.
- this.removeKey(key);
for (const value of values) {
this.put(key, value);
}
diff --git a/packages/lexical-yjs/src/Bindings.ts b/packages/lexical-yjs/src/Bindings.ts
index 9e32cf6cf31..7498d099a61 100644
--- a/packages/lexical-yjs/src/Bindings.ts
+++ b/packages/lexical-yjs/src/Bindings.ts
@@ -20,6 +20,7 @@ import {Doc, XmlElement, XmlText} from 'yjs';
import {Provider} from '.';
import {$createCollabElementNode} from './CollabElementNode';
import {LexicalMapping} from './LexicalMapping';
+import {initializeNodeProperties} from './Utils';
export type ClientID = number;
export type BaseBinding = {
@@ -30,7 +31,7 @@ export type BaseBinding = {
docMap: Map;
editor: LexicalEditor;
id: string;
- nodeProperties: Map>;
+ nodeProperties: Map; // node type to property to default value
excludedProperties: ExcludedProperties;
};
export type ExcludedProperties = Map, Set>;
@@ -62,7 +63,7 @@ function createBaseBinding(
doc !== undefined && doc !== null,
'createBinding: doc is null or undefined',
);
- return {
+ const binding = {
clientID: doc.clientID,
cursors: new Map(),
cursorsContainer: null,
@@ -73,6 +74,8 @@ function createBaseBinding(
id,
nodeProperties: new Map(),
};
+ initializeNodeProperties(binding);
+ return binding;
}
export function createBinding(
diff --git a/packages/lexical-yjs/src/LexicalMapping.ts b/packages/lexical-yjs/src/LexicalMapping.ts
index 1321941aca8..8fe3e851a01 100644
--- a/packages/lexical-yjs/src/LexicalMapping.ts
+++ b/packages/lexical-yjs/src/LexicalMapping.ts
@@ -24,6 +24,8 @@ export class LexicalMapping {
node: LexicalNode | TextNode[],
) {
invariant(!(sharedType instanceof XmlHook), 'XmlHook is not supported');
+ // Remove all existing associations for this key so that insertion order is respected.
+ this._map.removeKey(sharedType);
const isArray = node instanceof Array;
if (sharedType instanceof XmlText) {
invariant(isArray, 'Text nodes must be mapped as an array');
@@ -67,8 +69,4 @@ export class LexicalMapping {
has(sharedType: XmlElement | XmlText): boolean {
return this._map.hasKey(sharedType);
}
-
- hasNode(node: LexicalNode): boolean {
- return this._map.hasValue(node);
- }
}
diff --git a/packages/lexical-yjs/src/SyncCursors.ts b/packages/lexical-yjs/src/SyncCursors.ts
index 8b1b20f8bfe..77ef212e10d 100644
--- a/packages/lexical-yjs/src/SyncCursors.ts
+++ b/packages/lexical-yjs/src/SyncCursors.ts
@@ -143,9 +143,9 @@ function createRelativePositionV2(
while (child !== null && i < offset) {
if ($isTextNode(child)) {
// Multiple text nodes are collapsed into a single YText.
- let nextSiblind = child.getNextSibling();
- while ($isTextNode(nextSiblind)) {
- nextSiblind = nextSiblind.getNextSibling();
+ let nextSibling = child.getNextSibling();
+ while ($isTextNode(nextSibling)) {
+ nextSibling = nextSibling.getNextSibling();
}
}
i++;
@@ -435,18 +435,42 @@ export function $getAnchorAndFocusForUserState(
focusOffset,
};
} else if (isBindingV2(binding)) {
- const [anchorKey, anchorOffset] = $getNodeAndOffsetV2(
+ let [anchorNode, anchorOffset] = $getNodeAndOffsetV2(
binding.mapping,
anchorAbsPos,
);
- const [focusKey, focusOffset] = $getNodeAndOffsetV2(
+ let [focusNode, focusOffset] = $getNodeAndOffsetV2(
binding.mapping,
focusAbsPos,
);
+ // For a non-collapsed selection, if the start of the selection is as the end of a text node,
+ // move it to the beginning of the next text node (if one exists).
+ if (
+ focusNode &&
+ anchorNode &&
+ (focusNode !== anchorNode || focusOffset !== anchorOffset)
+ ) {
+ const isBackwards = focusNode.isBefore(anchorNode);
+ const startNode = isBackwards ? focusNode : anchorNode;
+ const startOffset = isBackwards ? focusOffset : anchorOffset;
+ if (
+ $isTextNode(startNode) &&
+ $isTextNode(startNode.getNextSibling()) &&
+ startOffset === startNode.getTextContentSize()
+ ) {
+ if (isBackwards) {
+ focusNode = startNode.getNextSibling();
+ focusOffset = 0;
+ } else {
+ anchorNode = startNode.getNextSibling();
+ anchorOffset = 0;
+ }
+ }
+ }
return {
- anchorKey,
+ anchorKey: anchorNode !== null ? anchorNode.getKey() : null,
anchorOffset,
- focusKey,
+ focusKey: focusNode !== null ? focusNode.getKey() : null,
focusOffset,
};
} else {
@@ -528,23 +552,42 @@ function getCollabNodeAndOffset(
function $getNodeAndOffsetV2(
mapping: LexicalMapping,
absolutePosition: AbsolutePosition,
-): [null | NodeKey, number] {
+): [null | LexicalNode, number] {
const yType = absolutePosition.type as XmlElement | XmlText;
- const offset = absolutePosition.index;
+ const yOffset = absolutePosition.index;
if (yType instanceof XmlElement) {
const node = mapping.get(yType) as LexicalNode;
if (node === undefined) {
return [null, 0];
}
- const maxOffset = $isElementNode(node) ? node.getChildrenSize() : 0;
- return [node.getKey(), Math.min(offset, maxOffset)];
+ if (!$isElementNode(node)) {
+ return [node, yOffset];
+ }
+ let remainingYOffset = yOffset;
+ let lexicalOffset = 0;
+ const children = node.getChildren();
+ while (remainingYOffset > 0 && lexicalOffset < children.length) {
+ const child = children[lexicalOffset];
+ remainingYOffset -= 1;
+ lexicalOffset += 1;
+ if ($isTextNode(child)) {
+ // Multiple text nodes (lexicalOffset) are collapsed into a single YText (remainingYOffset).
+ while (
+ lexicalOffset < children.length &&
+ $isTextNode(children[lexicalOffset])
+ ) {
+ lexicalOffset += 1;
+ }
+ }
+ }
+ return [node, lexicalOffset];
} else {
const nodes = mapping.get(yType) as TextNode[];
if (nodes === undefined) {
return [null, 0];
}
let i = 0;
- let adjustedOffset = offset;
+ let adjustedOffset = yOffset;
while (
adjustedOffset > nodes[i].getTextContentSize() &&
i + 1 < nodes.length
@@ -553,10 +596,7 @@ function $getNodeAndOffsetV2(
i++;
}
const textNode = nodes[i];
- return [
- textNode.getKey(),
- Math.min(adjustedOffset, textNode.getTextContentSize()),
- ];
+ return [textNode, Math.min(adjustedOffset, textNode.getTextContentSize())];
}
}
diff --git a/packages/lexical-yjs/src/SyncEditorStates.ts b/packages/lexical-yjs/src/SyncEditorStates.ts
index e57b1bb5e9b..13322c7775f 100644
--- a/packages/lexical-yjs/src/SyncEditorStates.ts
+++ b/packages/lexical-yjs/src/SyncEditorStates.ts
@@ -6,7 +6,7 @@
*
*/
-import type {EditorState, LexicalNode, NodeKey} from 'lexical';
+import type {EditorState, NodeKey} from 'lexical';
import type {
AbstractType as YAbstractType,
ContentType,
@@ -168,15 +168,8 @@ export function syncYjsChangesToLexical(
},
{
onUpdate: () => {
- // If there was a collision on the top level paragraph
- // we need to re-add a paragraph. To ensure this insertion properly syncs with other clients,
- // it must be placed outside of the update block above that has tags 'collaboration' or 'historic'.
syncCursorPositionsFn(binding, provider);
- editor.update(() => {
- if ($getRoot().getChildrenSize() === 0) {
- $getRoot().append($createParagraphNode());
- }
- });
+ editor.update(() => $ensureEditorNotEmpty());
},
skipTransforms: true,
tag: isFromUndoManger ? HISTORIC_TAG : COLLABORATION_TAG,
@@ -267,6 +260,15 @@ function $handleNormalizationMergeConflicts(
}
}
+// If there was a collision on the top level paragraph
+// we need to re-add a paragraph. To ensure this insertion properly syncs with other clients,
+// it must be placed outside of the update block above that has tags 'collaboration' or 'historic'.
+function $ensureEditorNotEmpty() {
+ if ($getRoot().getChildrenSize() === 0) {
+ $getRoot().append($createParagraphNode());
+ }
+}
+
type IntentionallyMarkedAsDirtyElement = boolean;
export function syncLexicalUpdateToYjs(
@@ -349,20 +351,7 @@ function $syncV2XmlElement(
transaction.changed.forEach(collectDirty);
transaction.changedParentTypes.forEach(collectDirty);
- const fragmentContent = binding.root
- .toArray()
- .map(
- (t) =>
- $createOrUpdateNodeFromYElement(
- t as XmlElement,
- binding,
- dirtyElements,
- ) as LexicalNode,
- )
- .filter((n) => n !== null);
-
- // TODO(collab-v2): be more targeted with splicing, similar to CollabElementNode's syncChildrenFromLexical
- $getRoot().splice(0, $getRoot().getChildrenSize(), fragmentContent);
+ $createOrUpdateNodeFromYElement(binding.root, binding, dirtyElements);
}
export function syncYjsChangesToLexicalV2__EXPERIMENTAL(
@@ -386,16 +375,12 @@ export function syncYjsChangesToLexicalV2__EXPERIMENTAL(
}
},
{
+ // Need any text node normalisation to be synchronously updated back to Yjs, otherwise the
+ // binding.mapping will get out of sync.
+ discrete: true,
onUpdate: () => {
- // If there was a collision on the top level paragraph
- // we need to re-add a paragraph. To ensure this insertion properly syncs with other clients,
- // it must be placed outside of the update block above that has tags 'collaboration' or 'historic'.
syncCursorPositions(binding, provider);
- editor.update(() => {
- if ($getRoot().getChildrenSize() === 0) {
- $getRoot().append($createParagraphNode());
- }
- });
+ editor.update(() => $ensureEditorNotEmpty());
},
skipTransforms: true,
tag: isFromUndoManger ? HISTORIC_TAG : COLLABORATION_TAG,
@@ -412,10 +397,13 @@ export function syncLexicalUpdateToYjsV2__EXPERIMENTAL(
normalizedNodes: Set,
tags: Set,
): void {
+ const isFromYjs = tags.has(COLLABORATION_TAG) || tags.has(HISTORIC_TAG);
+ if (isFromYjs && normalizedNodes.size === 0) {
+ return;
+ }
+
syncWithTransaction(binding, () => {
currEditorState.read(() => {
- // TODO(collab-v2): what sort of normalization handling do we need for clients that concurrently create YText?
-
if (dirtyElements.has('root')) {
updateYFragment(
binding.doc,
diff --git a/packages/lexical-yjs/src/SyncV2.ts b/packages/lexical-yjs/src/SyncV2.ts
index 3858c9e1869..b68e60d4ed5 100644
--- a/packages/lexical-yjs/src/SyncV2.ts
+++ b/packages/lexical-yjs/src/SyncV2.ts
@@ -7,7 +7,8 @@
*/
import {
- $createTextNode,
+ $getWritableNodeState,
+ $isTextNode,
ElementNode,
LexicalNode,
NodeKey,
@@ -20,13 +21,28 @@ import invariant from 'shared/invariant';
import * as Y from 'yjs';
import {BindingV2} from './Bindings';
-import {$syncPropertiesFromYjs, getNodeProperties} from './Utils';
+import {$syncPropertiesFromYjs, getDefaultNodeProperties} from './Utils';
type ComputeYChange = (
event: 'removed' | 'added',
id: Y.ID,
) => Record;
+type TextAttributes = {
+ t?: string; // type if not TextNode
+ p?: Record; // properties
+ [key: `s_${string}`]: unknown; // state
+ y: {idx: number};
+ ychange?: Record;
+};
+
+// Used for resolving concurrent deletes.
+const DEFAULT_TEXT_ATTRIBUTES: TextAttributes = {
+ p: {},
+ t: TextNode.getType(),
+ y: {idx: 0},
+};
+
const isVisible = (item: Y.Item, snapshot?: Y.Snapshot): boolean =>
snapshot === undefined
? !item.deleted
@@ -34,6 +50,9 @@ const isVisible = (item: Y.Item, snapshot?: Y.Snapshot): boolean =>
snapshot.sv.get(item.id.client)! > item.id.clock &&
!Y.isDeleted(snapshot.ds, item.id);
+const isRootElement = (el: Y.XmlElement): boolean =>
+ el.nodeName === 'UNDEFINED';
+
/**
* @return Returns node if node could be created. Otherwise it deletes the yjs type and returns null
*/
@@ -109,42 +128,107 @@ export const $createOrUpdateNodeFromYElement = (
new Y.Snapshot(prevSnapshot.ds, snapshot.sv),
).forEach($createChildren);
}
- try {
- const attrs = el.getAttributes(snapshot);
- if (snapshot !== undefined) {
- if (!isVisible(el._item!, snapshot)) {
- // TODO(collab-v2): add type for ychange, store in node state?
- attrs.ychange = computeYChange
- ? computeYChange('removed', el._item!.id)
- : {type: 'removed'};
- } else if (!isVisible(el._item!, prevSnapshot)) {
- attrs.ychange = computeYChange
- ? computeYChange('added', el._item!.id)
- : {type: 'added'};
- }
+ const type = isRootElement(el) ? 'root' : el.nodeName;
+ const registeredNodes = meta.editor._nodes;
+ const nodeInfo = registeredNodes.get(type);
+ if (nodeInfo === undefined) {
+ throw new Error(
+ `$createOrUpdateNodeFromYElement: Node ${type} is not registered`,
+ );
+ }
+ node = node || new nodeInfo.klass();
+ const attrs = {
+ ...getDefaultNodeProperties(node, meta),
+ ...el.getAttributes(snapshot),
+ };
+ if (snapshot !== undefined) {
+ if (!isVisible(el._item!, snapshot)) {
+ // TODO(collab-v2): add type for ychange, store in node state?
+ attrs.ychange = computeYChange
+ ? computeYChange('removed', el._item!.id)
+ : {type: 'removed'};
+ } else if (!isVisible(el._item!, prevSnapshot)) {
+ attrs.ychange = computeYChange
+ ? computeYChange('added', el._item!.id)
+ : {type: 'added'};
}
- const type = attrs.__type;
- const registeredNodes = meta.editor._nodes;
- const nodeInfo = registeredNodes.get(type);
- if (nodeInfo === undefined) {
- throw new Error(`Node ${type} is not registered`);
+ }
+ const properties: Record = {};
+ const state: Record = {};
+ for (const k in attrs) {
+ if (k.startsWith(STATE_KEY_PREFIX)) {
+ state[attrKeyToStateKey(k)] = attrs[k];
+ } else {
+ properties[k] = attrs[k];
+ }
+ }
+ $syncPropertiesFromYjs(meta, properties, node, null);
+ $getWritableNodeState(node).updateFromJSON(state);
+ if (node instanceof ElementNode) {
+ $spliceChildren(node, children);
+ }
+ const latestNode = node.getLatest();
+ meta.mapping.set(el, latestNode);
+ return latestNode;
+};
+
+const $spliceChildren = (node: ElementNode, nextChildren: LexicalNode[]) => {
+ const prevChildren = node.getChildren();
+ const prevChildrenKeySet = new Set(
+ prevChildren.map((child) => child.getKey()),
+ );
+ const nextChildrenKeySet = new Set(
+ nextChildren.map((child) => child.getKey()),
+ );
+
+ const prevEndIndex = prevChildren.length - 1;
+ const nextEndIndex = nextChildren.length - 1;
+ let prevIndex = 0;
+ let nextIndex = 0;
+
+ while (prevIndex <= prevEndIndex && nextIndex <= nextEndIndex) {
+ const prevKey = prevChildren[prevIndex].getKey();
+ const nextKey = nextChildren[nextIndex].getKey();
+
+ if (prevKey === nextKey) {
+ prevIndex++;
+ nextIndex++;
+ continue;
}
- node = node || new nodeInfo.klass();
- $syncPropertiesFromYjs(meta, attrs, node, null);
- // TODO(collab-v2): be more targeted with splicing, similar to CollabElementNode's syncChildrenFromLexical
- if (node instanceof ElementNode) {
- node.splice(0, node.getChildrenSize(), children);
+
+ const nextHasPrevKey = nextChildrenKeySet.has(prevKey);
+ const prevHasNextKey = prevChildrenKeySet.has(nextKey);
+
+ if (!nextHasPrevKey) {
+ // Remove
+ node.splice(nextIndex, 1, []);
+ prevIndex++;
+ continue;
+ }
+
+ // Create or replace
+ const nextChildNode = nextChildren[nextIndex];
+ if (prevHasNextKey) {
+ node.splice(nextIndex, 1, [nextChildNode]);
+ prevIndex++;
+ nextIndex++;
+ } else {
+ node.splice(nextIndex, 0, [nextChildNode]);
+ nextIndex++;
}
- meta.mapping.set(el, node.getLatest());
- return node;
- } catch (e) {
- // an error occured while creating the node. This is probably a result of a concurrent action.
- // TODO(collab-v2): also delete the mapped node from editor state.
- el.doc!.transact((transaction) => {
- el._item!.delete(transaction);
- }, meta);
- meta.mapping.delete(el);
- return null;
+ }
+
+ const appendNewChildren = prevIndex > prevEndIndex;
+ const removeOldChildren = nextIndex > nextEndIndex;
+
+ if (appendNewChildren && !removeOldChildren) {
+ node.append(...nextChildren.slice(nextIndex));
+ } else if (removeOldChildren && !appendNewChildren) {
+ node.splice(
+ nextChildren.length,
+ node.getChildrenSize() - nextChildren.length,
+ [],
+ );
}
};
@@ -155,39 +239,54 @@ const $createTextNodesFromYText = (
prevSnapshot?: Y.Snapshot,
computeYChange?: ComputeYChange,
): Array | null => {
- const deltas = text.toDelta(snapshot, prevSnapshot, computeYChange);
- const nodes: TextNode[] = (meta.mapping.get(text) as TextNode[]) ?? [];
- while (nodes.length < deltas.length) {
- nodes.push($createTextNode());
+ const deltas: {insert: string; attributes: TextAttributes}[] = text
+ .toDelta(snapshot, prevSnapshot, computeYChange)
+ .map((delta: {insert: string; attributes?: TextAttributes}) => ({
+ ...delta,
+ attributes: delta.attributes ?? DEFAULT_TEXT_ATTRIBUTES,
+ }));
+ const nodeTypes: string[] = deltas.map(
+ (delta) => delta.attributes!.t ?? TextNode.getType(),
+ );
+ let nodes: TextNode[] = (meta.mapping.get(text) as TextNode[]) ?? [];
+ if (
+ nodes.length !== nodeTypes.length ||
+ nodes.some((node, i) => node.getType() !== nodeTypes[i])
+ ) {
+ const registeredNodes = meta.editor._nodes;
+ nodes = nodeTypes.map((type) => {
+ const nodeInfo = registeredNodes.get(type);
+ if (nodeInfo === undefined) {
+ throw new Error(
+ `$createTextNodesFromYText: Node ${type} is not registered`,
+ );
+ }
+ const node = new nodeInfo.klass();
+ if (!$isTextNode(node)) {
+ throw new Error(
+ `$createTextNodesFromYText: Node ${type} is not a TextNode`,
+ );
+ }
+ return node;
+ });
}
- try {
- for (let i = 0; i < deltas.length; i++) {
- const node = nodes[i];
- const delta = deltas[i];
- const {attributes, insert} = delta;
+ for (let i = 0; i < deltas.length; i++) {
+ const node = nodes[i];
+ const delta = deltas[i];
+ const {attributes, insert} = delta;
+ if (node.__text !== insert) {
node.setTextContent(insert);
- const properties = {
- ...attributes.__properties,
- ...attributes.ychange,
- };
- $syncPropertiesFromYjs(meta, properties, node, null);
}
- while (nodes.length > deltas.length) {
- nodes.pop()!.remove();
- }
- } catch (e) {
- // an error occured while creating the node. This is probably a result of a concurrent action.
- // TODO(collab-v2): also delete the mapped text nodes from editor state.
- text.doc!.transact((transaction) => {
- text._item!.delete(transaction);
- });
- return null;
+ const properties = {
+ ...getDefaultNodeProperties(node, meta),
+ ...attributes.p,
+ ...attributes.ychange,
+ };
+ $syncPropertiesFromYjs(meta, properties, node, null);
}
- meta.mapping.set(
- text,
- nodes.map((node) => node.getLatest()),
- );
- return nodes;
+ const latestNodes = nodes.map((node) => node.getLatest());
+ meta.mapping.set(text, latestNodes);
+ return latestNodes;
};
const createTypeFromTextNodes = (
@@ -195,13 +294,7 @@ const createTypeFromTextNodes = (
meta: BindingV2,
): Y.XmlText => {
const type = new Y.XmlText();
- const delta = nodes.map((node) => ({
- // TODO(collab-v2): exclude ychange, handle node state
- attributes: {__properties: propertiesToAttributes(node, meta)},
- insert: node.getTextContent(),
- }));
- type.applyDelta(delta);
- meta.mapping.set(type, nodes);
+ updateYText(type, nodes, meta);
return type;
};
@@ -210,8 +303,11 @@ const createTypeFromElementNode = (
meta: BindingV2,
): Y.XmlElement => {
const type = new Y.XmlElement(node.getType());
- // TODO(collab-v2): exclude ychange, handle node state
- const attrs = propertiesToAttributes(node, meta);
+ // TODO(collab-v2): exclude ychange
+ const attrs = {
+ ...propertiesToAttributes(node, meta),
+ ...stateToAttributes(node),
+ };
for (const key in attrs) {
const val = attrs[key];
if (val !== null) {
@@ -306,9 +402,18 @@ const equalYTextLText = (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(d: any, i: number) =>
d.insert === ltexts[i].getTextContent() &&
+ d.attributes.t === ltexts[i].getType() &&
equalAttrs(
- d.attributes.__properties,
+ d.attributes.p ?? {},
propertiesToAttributes(ltexts[i], meta),
+ ) &&
+ equalAttrs(
+ Object.fromEntries(
+ Object.entries(d.attributes)
+ .filter(([k]) => k.startsWith(STATE_KEY_PREFIX))
+ .map(([k, v]) => [attrKeyToStateKey(k), v]),
+ ),
+ stateToAttributes(ltexts[i]),
),
)
);
@@ -327,7 +432,10 @@ const equalYTypePNode = (
const normalizedContent = normalizePNodeContent(pnode);
return (
ytype._length === normalizedContent.length &&
- equalAttrs(ytype.getAttributes(), propertiesToAttributes(pnode, meta)) &&
+ equalAttrs(ytype.getAttributes(), {
+ ...propertiesToAttributes(pnode, meta),
+ ...stateToAttributes(pnode),
+ }) &&
ytype
.toArray()
.every((ychild, i) =>
@@ -419,12 +527,26 @@ const ytextTrans = (
const updateYText = (ytext: Y.XmlText, ltexts: TextNode[], meta: BindingV2) => {
meta.mapping.set(ytext, ltexts);
const {nAttrs, str} = ytextTrans(ytext);
- const content = ltexts.map((l) => ({
- attributes: Object.assign({}, nAttrs, {
- __properties: propertiesToAttributes(l, meta),
- }),
- insert: l.getTextContent(),
- }));
+ const content = ltexts.map((node, idx) => {
+ const nodeType = node.getType();
+ let properties: TextAttributes['p'] | null = propertiesToAttributes(
+ node,
+ meta,
+ );
+ if (Object.keys(properties).length === 0) {
+ properties = null;
+ }
+ return {
+ attributes: Object.assign({}, nAttrs, {
+ ...(nodeType !== TextNode.getType() && {t: nodeType}),
+ p: properties,
+ ...stateToAttributes(node),
+ // TODO(collab-v2): can probably be more targeted here
+ y: {idx}, // Prevent Yjs from merging text nodes itself.
+ }),
+ insert: node.getTextContent(),
+ };
+ });
const {insert, remove, index} = simpleDiff(
str,
content.map((c) => c.insert).join(''),
@@ -437,18 +559,46 @@ const updateYText = (ytext: Y.XmlText, ltexts: TextNode[], meta: BindingV2) => {
};
const propertiesToAttributes = (node: LexicalNode, meta: BindingV2) => {
- const properties = getNodeProperties(node, meta);
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const attrs: Record = {};
- properties.forEach((property) => {
+ const defaultProperties = getDefaultNodeProperties(node, meta);
+ const attrs: Record = {};
+ Object.entries(defaultProperties).forEach(([property, defaultValue]) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
- attrs[property] = (node as any)[property];
+ const value = (node as any)[property];
+ if (value !== defaultValue) {
+ attrs[property] = value;
+ }
});
return attrs;
};
+const STATE_KEY_PREFIX = 's_';
+const stateKeyToAttrKey = (key: string) => STATE_KEY_PREFIX + key;
+const attrKeyToStateKey = (key: string) => {
+ if (!key.startsWith(STATE_KEY_PREFIX)) {
+ throw new Error(`Invalid state key: ${key}`);
+ }
+ return key.slice(STATE_KEY_PREFIX.length);
+};
+
+const stateToAttributes = (node: LexicalNode) => {
+ const state = node.__state;
+ if (!state) {
+ return {};
+ }
+ const [unknown = {}, known] = state.getInternalState();
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const attrs: Record = {};
+ for (const [k, v] of Object.entries(unknown)) {
+ attrs[stateKeyToAttrKey(k)] = v;
+ }
+ for (const [stateConfig, v] of known) {
+ attrs[stateKeyToAttrKey(stateConfig.key)] = stateConfig.unparse(v);
+ }
+ return attrs;
+};
+
/**
- * Update a yDom node by syncing the current content of the prosemirror node.e
+ * Update a yDom node by syncing the current content of the prosemirror node.
*/
export const updateYFragment = (
y: Y.Doc,
@@ -461,7 +611,7 @@ export const updateYFragment = (
yDomFragment instanceof Y.XmlElement &&
yDomFragment.nodeName !== pNode.getType() &&
// TODO(collab-v2): the root XmlElement should have a valid node name
- !(yDomFragment.nodeName === 'UNDEFINED' && pNode.getType() === 'root')
+ !(isRootElement(yDomFragment) && pNode.getType() === 'root')
) {
throw new Error('node name mismatch!');
}
@@ -469,11 +619,14 @@ export const updateYFragment = (
// update attributes
if (yDomFragment instanceof Y.XmlElement) {
const yDomAttrs = yDomFragment.getAttributes();
- const pAttrs = propertiesToAttributes(pNode, meta);
- for (const key in pAttrs) {
- if (pAttrs[key] !== null) {
- if (yDomAttrs[key] !== pAttrs[key] && key !== 'ychange') {
- yDomFragment.setAttribute(key, pAttrs[key]);
+ const lexicalAttrs = {
+ ...propertiesToAttributes(pNode, meta),
+ ...stateToAttributes(pNode),
+ };
+ for (const key in lexicalAttrs) {
+ if (lexicalAttrs[key] !== null) {
+ if (yDomAttrs[key] !== lexicalAttrs[key] && key !== 'ychange') {
+ yDomFragment.setAttribute(key, lexicalAttrs[key]);
}
} else {
yDomFragment.removeAttribute(key);
@@ -481,7 +634,7 @@ export const updateYFragment = (
}
// remove all keys that are no longer in pAttrs
for (const key in yDomAttrs) {
- if (pAttrs[key] === undefined) {
+ if (lexicalAttrs[key] === undefined) {
yDomFragment.removeAttribute(key);
}
}
@@ -518,7 +671,7 @@ export const updateYFragment = (
}
}
// find number of matching elements from right
- for (; right + left + 1 < minCnt; right++) {
+ for (; right + left < minCnt; right++) {
const rightY = yChildren[yChildCnt - right - 1];
const rightP = pChildren[pChildCnt - right - 1];
if (mappedIdentity(meta.mapping.get(rightY), rightP)) {
@@ -540,105 +693,94 @@ export const updateYFragment = (
}
}
}
- y.transact(() => {
- // try to compare and update
- while (yChildCnt - left - right > 0 && pChildCnt - left - right > 0) {
- const leftY = yChildren[left];
- const leftP = pChildren[left];
- const rightY = yChildren[yChildCnt - right - 1];
- const rightP = pChildren[pChildCnt - right - 1];
- if (leftY instanceof Y.XmlText && leftP instanceof Array) {
- if (!equalYTextLText(leftY, leftP, meta)) {
- updateYText(leftY, leftP, meta);
+ // try to compare and update
+ while (yChildCnt - left - right > 0 && pChildCnt - left - right > 0) {
+ const leftY = yChildren[left];
+ const leftP = pChildren[left];
+ const rightY = yChildren[yChildCnt - right - 1];
+ const rightP = pChildren[pChildCnt - right - 1];
+ if (leftY instanceof Y.XmlText && leftP instanceof Array) {
+ if (!equalYTextLText(leftY, leftP, meta)) {
+ updateYText(leftY, leftP, meta);
+ }
+ left += 1;
+ } else {
+ let updateLeft =
+ leftY instanceof Y.XmlElement && matchNodeName(leftY, leftP);
+ let updateRight =
+ rightY instanceof Y.XmlElement && matchNodeName(rightY, rightP);
+ if (updateLeft && updateRight) {
+ // decide which which element to update
+ const equalityLeft = computeChildEqualityFactor(
+ leftY as Y.XmlElement,
+ leftP as LexicalNode,
+ meta,
+ );
+ const equalityRight = computeChildEqualityFactor(
+ rightY as Y.XmlElement,
+ rightP as LexicalNode,
+ meta,
+ );
+ if (equalityLeft.foundMappedChild && !equalityRight.foundMappedChild) {
+ updateRight = false;
+ } else if (
+ !equalityLeft.foundMappedChild &&
+ equalityRight.foundMappedChild
+ ) {
+ updateLeft = false;
+ } else if (equalityLeft.equalityFactor < equalityRight.equalityFactor) {
+ updateLeft = false;
+ } else {
+ updateRight = false;
}
+ }
+ if (updateLeft) {
+ updateYFragment(
+ y,
+ leftY as Y.XmlElement,
+ leftP as LexicalNode,
+ meta,
+ dirtyElements,
+ );
left += 1;
+ } else if (updateRight) {
+ updateYFragment(
+ y,
+ rightY as Y.XmlElement,
+ rightP as LexicalNode,
+ meta,
+ dirtyElements,
+ );
+ right += 1;
} else {
- let updateLeft =
- leftY instanceof Y.XmlElement && matchNodeName(leftY, leftP);
- let updateRight =
- rightY instanceof Y.XmlElement && matchNodeName(rightY, rightP);
- if (updateLeft && updateRight) {
- // decide which which element to update
- const equalityLeft = computeChildEqualityFactor(
- leftY as Y.XmlElement,
- leftP as LexicalNode,
- meta,
- );
- const equalityRight = computeChildEqualityFactor(
- rightY as Y.XmlElement,
- rightP as LexicalNode,
- meta,
- );
- if (
- equalityLeft.foundMappedChild &&
- !equalityRight.foundMappedChild
- ) {
- updateRight = false;
- } else if (
- !equalityLeft.foundMappedChild &&
- equalityRight.foundMappedChild
- ) {
- updateLeft = false;
- } else if (
- equalityLeft.equalityFactor < equalityRight.equalityFactor
- ) {
- updateLeft = false;
- } else {
- updateRight = false;
- }
- }
- if (updateLeft) {
- updateYFragment(
- y,
- leftY as Y.XmlElement,
- leftP as LexicalNode,
- meta,
- dirtyElements,
- );
- left += 1;
- } else if (updateRight) {
- updateYFragment(
- y,
- rightY as Y.XmlElement,
- rightP as LexicalNode,
- meta,
- dirtyElements,
- );
- right += 1;
- } else {
- meta.mapping.delete(yDomFragment.get(left));
- yDomFragment.delete(left, 1);
- yDomFragment.insert(left, [
- createTypeFromTextOrElementNode(leftP, meta),
- ]);
- left += 1;
- }
+ meta.mapping.delete(yDomFragment.get(left));
+ yDomFragment.delete(left, 1);
+ yDomFragment.insert(left, [
+ createTypeFromTextOrElementNode(leftP, meta),
+ ]);
+ left += 1;
}
}
- const yDelLen = yChildCnt - left - right;
- if (
- yChildCnt === 1 &&
- pChildCnt === 0 &&
- yChildren[0] instanceof Y.XmlText
- ) {
- meta.mapping.delete(yChildren[0]);
- // Edge case handling https://github.com/yjs/y-prosemirror/issues/108
- // Only delete the content of the Y.Text to retain remote changes on the same Y.Text object
- yChildren[0].delete(0, yChildren[0].length);
- } else if (yDelLen > 0) {
- yDomFragment
- .slice(left, left + yDelLen)
- .forEach((type) => meta.mapping.delete(type));
- yDomFragment.delete(left, yDelLen);
- }
- if (left + right < pChildCnt) {
- const ins = [];
- for (let i = left; i < pChildCnt - right; i++) {
- ins.push(createTypeFromTextOrElementNode(pChildren[i], meta));
- }
- yDomFragment.insert(left, ins);
+ }
+ const yDelLen = yChildCnt - left - right;
+ if (yChildCnt === 1 && pChildCnt === 0 && yChildren[0] instanceof Y.XmlText) {
+ meta.mapping.delete(yChildren[0]);
+ // Edge case handling https://github.com/yjs/y-prosemirror/issues/108
+ // Only delete the content of the Y.Text to retain remote changes on the same Y.Text object
+ yChildren[0].delete(0, yChildren[0].length);
+ } else if (yDelLen > 0) {
+ yDomFragment
+ .slice(left, left + yDelLen)
+ .forEach((type) => meta.mapping.delete(type));
+ yDomFragment.delete(left, yDelLen);
+ }
+ if (left + right < pChildCnt) {
+ const ins = [];
+ for (let i = left; i < pChildCnt - right; i++) {
+ ins.push(createTypeFromTextOrElementNode(pChildren[i], meta));
}
- }, meta);
+ yDomFragment.insert(left, ins);
+ }
};
const matchNodeName = (
diff --git a/packages/lexical-yjs/src/Utils.ts b/packages/lexical-yjs/src/Utils.ts
index ce898d7bba2..c6ddc940efb 100644
--- a/packages/lexical-yjs/src/Utils.ts
+++ b/packages/lexical-yjs/src/Utils.ts
@@ -85,19 +85,34 @@ function isExcludedProperty(
return excludedProperties != null && excludedProperties.has(name);
}
-export function getNodeProperties(
+export function initializeNodeProperties(binding: BaseBinding): void {
+ const {editor, nodeProperties} = binding;
+ editor.update(() => {
+ editor._nodes.forEach((nodeInfo) => {
+ const node = new nodeInfo.klass();
+ const defaultProperties: {[property: string]: unknown} = {};
+ for (const [property, value] of Object.entries(node)) {
+ if (!isExcludedProperty(property, node, binding)) {
+ defaultProperties[property] = value;
+ }
+ }
+ nodeProperties.set(node.__type, defaultProperties);
+ });
+ });
+}
+
+export function getDefaultNodeProperties(
node: LexicalNode,
binding: BaseBinding,
-): string[] {
+): {[property: string]: unknown} {
const type = node.__type;
const {nodeProperties} = binding;
- if (nodeProperties.has(type)) {
- return nodeProperties.get(type)!;
- }
- const properties = Object.keys(node).filter((property) => {
- return !isExcludedProperty(property, node, binding);
- });
- nodeProperties.set(type, properties);
+ const properties = nodeProperties.get(type);
+ invariant(
+ properties !== undefined,
+ 'Node properties for %s not initialized for sync',
+ type,
+ );
return properties;
}
@@ -415,7 +430,9 @@ export function syncPropertiesFromLexical(
prevLexicalNode: null | LexicalNode,
nextLexicalNode: LexicalNode,
): void {
- const properties = getNodeProperties(nextLexicalNode, binding);
+ const properties = Object.keys(
+ getDefaultNodeProperties(nextLexicalNode, binding),
+ );
const EditorClass = binding.editor.constructor;
From 21aa07e3683efcde42ef0b9aad1bb388bffa609c Mon Sep 17 00:00:00 2001
From: James Fitzsimmons
Date: Wed, 10 Sep 2025 14:38:11 +1000
Subject: [PATCH 05/21] rewrite lexicalmapping to collabv2mapping without
bimultimap
---
packages/lexical-yjs/src/BiMultiMap.ts | 173 --------------------
packages/lexical-yjs/src/Bindings.ts | 6 +-
packages/lexical-yjs/src/CollabV2Mapping.ts | 103 ++++++++++++
packages/lexical-yjs/src/LexicalMapping.ts | 72 --------
packages/lexical-yjs/src/SyncCursors.ts | 9 +-
packages/lexical-yjs/src/SyncV2.ts | 40 +++--
6 files changed, 132 insertions(+), 271 deletions(-)
delete mode 100644 packages/lexical-yjs/src/BiMultiMap.ts
create mode 100644 packages/lexical-yjs/src/CollabV2Mapping.ts
delete mode 100644 packages/lexical-yjs/src/LexicalMapping.ts
diff --git a/packages/lexical-yjs/src/BiMultiMap.ts b/packages/lexical-yjs/src/BiMultiMap.ts
deleted file mode 100644
index 54baa53c281..00000000000
--- a/packages/lexical-yjs/src/BiMultiMap.ts
+++ /dev/null
@@ -1,173 +0,0 @@
-/**
- * Copyright (c) Meta Platforms, Inc. and affiliates.
- *
- * This source code is licensed under the MIT license found in the
- * LICENSE file in the root directory of this source tree.
- *
- */
-
-export class BiMultiMap {
- private keyToValues = new Map>();
- private valueToKey = new Map();
-
- /**
- * Associates a value with a key. If the value was previously associated
- * with a different key, it will be removed from that key first.
- */
- put(key: K, value: V): void {
- // If this value is already associated with a different key, remove it
- const existingKey = this.valueToKey.get(value);
- if (existingKey !== undefined && existingKey !== key) {
- this.removeValue(existingKey, value);
- }
-
- // Add the key-value association
- if (!this.keyToValues.has(key)) {
- this.keyToValues.set(key, new Set());
- }
- this.keyToValues.get(key)!.add(value);
- this.valueToKey.set(value, key);
- }
-
- putAll(key: K, values: Array): void {
- for (const value of values) {
- this.put(key, value);
- }
- }
-
- /**
- * Gets all values associated with a key.
- * Returns an empty Set if the key doesn't exist.
- */
- get(key: K): Set {
- const values = this.keyToValues.get(key);
- return values ? new Set(values) : new Set();
- }
-
- /**
- * Gets the key associated with a value.
- * Returns undefined if the value doesn't exist.
- */
- getKey(value: V): K | undefined {
- return this.valueToKey.get(value);
- }
-
- /**
- * Checks if a key exists in the map.
- */
- hasKey(key: K): boolean {
- return this.keyToValues.has(key);
- }
-
- /**
- * Checks if a value exists in the map.
- */
- hasValue(value: V): boolean {
- return this.valueToKey.has(value);
- }
-
- /**
- * Checks if a specific key-value pair exists.
- */
- has(key: K, value: V): boolean {
- const values = this.keyToValues.get(key);
- return values ? values.has(value) : false;
- }
-
- /**
- * Removes all values associated with a key.
- * Returns true if the key existed and was removed.
- */
- removeKey(key: K): boolean {
- const values = this.keyToValues.get(key);
- if (!values) {
- return false;
- }
-
- // Remove all value-to-key mappings
- for (const value of values) {
- this.valueToKey.delete(value);
- }
-
- // Remove the key
- this.keyToValues.delete(key);
- return true;
- }
-
- /**
- * Removes a value from wherever it exists.
- * Returns true if the value existed and was removed.
- */
- removeValue(key: K, value: V): boolean {
- const values = this.keyToValues.get(key);
- if (!values || !values.has(value)) {
- return false;
- }
-
- values.delete(value);
- this.valueToKey.delete(value);
-
- // If this was the last value for the key, remove the key entirely
- if (values.size === 0) {
- this.keyToValues.delete(key);
- }
-
- return true;
- }
-
- /**
- * Gets all keys in the map.
- */
- keys(): Set {
- return new Set(this.keyToValues.keys());
- }
-
- /**
- * Gets all values in the map.
- */
- values(): Set {
- return new Set(this.valueToKey.keys());
- }
-
- /**
- * Gets all key-value pairs as an array of [key, value] tuples.
- */
- entries(): [K, V][] {
- const result: [K, V][] = [];
- for (const [key, values] of this.keyToValues) {
- for (const value of values) {
- result.push([key, value]);
- }
- }
- return result;
- }
-
- /**
- * Returns the number of key-value pairs in the map.
- */
- get size(): number {
- return this.valueToKey.size;
- }
-
- /**
- * Returns the number of unique keys in the map.
- */
- get keyCount(): number {
- return this.keyToValues.size;
- }
-
- /**
- * Removes all entries from the map.
- */
- clear(): void {
- this.keyToValues.clear();
- this.valueToKey.clear();
- }
-
- /**
- * Returns true if the map is empty.
- */
- get isEmpty(): boolean {
- return this.size === 0;
- }
-}
diff --git a/packages/lexical-yjs/src/Bindings.ts b/packages/lexical-yjs/src/Bindings.ts
index 7498d099a61..1c54bfd96a2 100644
--- a/packages/lexical-yjs/src/Bindings.ts
+++ b/packages/lexical-yjs/src/Bindings.ts
@@ -19,7 +19,7 @@ import {Doc, XmlElement, XmlText} from 'yjs';
import {Provider} from '.';
import {$createCollabElementNode} from './CollabElementNode';
-import {LexicalMapping} from './LexicalMapping';
+import {CollabV2Mapping} from './CollabV2Mapping';
import {initializeNodeProperties} from './Utils';
export type ClientID = number;
@@ -48,7 +48,7 @@ export type Binding = BaseBinding & {
};
export type BindingV2 = BaseBinding & {
- mapping: LexicalMapping;
+ mapping: CollabV2Mapping;
root: XmlElement;
};
@@ -117,7 +117,7 @@ export function createBindingV2__EXPERIMENTAL(
);
return {
...createBaseBinding(editor, id, doc, docMap, excludedProperties),
- mapping: new LexicalMapping(),
+ mapping: new CollabV2Mapping(),
root: doc.get('root-v2', XmlElement) as XmlElement,
};
}
diff --git a/packages/lexical-yjs/src/CollabV2Mapping.ts b/packages/lexical-yjs/src/CollabV2Mapping.ts
new file mode 100644
index 00000000000..e4f20d1b9a3
--- /dev/null
+++ b/packages/lexical-yjs/src/CollabV2Mapping.ts
@@ -0,0 +1,103 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import {$isTextNode, type LexicalNode, NodeKey, type TextNode} from 'lexical';
+import invariant from 'shared/invariant';
+import {XmlElement, XmlText} from 'yjs';
+
+type SharedType = XmlElement | XmlText;
+
+// Stores mappings between Yjs shared types and the Lexical nodes they were last associated with.
+export class CollabV2Mapping {
+ private _nodeMap: Map = new Map();
+
+ private _sharedTypeToNodeKeys: Map = new Map();
+ private _nodeKeyToSharedType: Map = new Map();
+
+ set(sharedType: SharedType, node: LexicalNode | TextNode[]) {
+ const isArray = node instanceof Array;
+
+ // Clear all existing associations for this key.
+ this.delete(sharedType);
+
+ // If nodes were associated with other shared types, remove those associations.
+ const nodes = isArray ? node : [node];
+ for (const n of nodes) {
+ const key = n.getKey();
+ if (this._nodeKeyToSharedType.has(key)) {
+ const otherSharedType = this._nodeKeyToSharedType.get(key)!;
+ const keyIndex = this._sharedTypeToNodeKeys
+ .get(otherSharedType)!
+ .indexOf(key);
+ if (keyIndex !== -1) {
+ this._sharedTypeToNodeKeys.get(otherSharedType)!.splice(keyIndex, 1);
+ }
+ this._nodeKeyToSharedType.delete(key);
+ this._nodeMap.delete(key);
+ }
+ }
+
+ if (sharedType instanceof XmlText) {
+ invariant(isArray, 'Text nodes must be mapped as an array');
+ if (node.length === 0) {
+ return;
+ }
+ this._sharedTypeToNodeKeys.set(
+ sharedType,
+ node.map((n) => n.getKey()),
+ );
+ for (const n of node) {
+ this._nodeMap.set(n.getKey(), n);
+ this._nodeKeyToSharedType.set(n.getKey(), sharedType);
+ }
+ } else {
+ invariant(!isArray, 'Element nodes must be mapped as a single node');
+ invariant(!$isTextNode(node), 'Text nodes must be mapped to XmlText');
+ this._sharedTypeToNodeKeys.set(sharedType, [node.getKey()]);
+ this._nodeMap.set(node.getKey(), node);
+ this._nodeKeyToSharedType.set(node.getKey(), sharedType);
+ }
+ }
+
+ get(sharedType: XmlElement): LexicalNode | undefined;
+ get(sharedType: XmlText): TextNode[] | undefined;
+ get(sharedType: SharedType): LexicalNode | Array | undefined;
+ get(sharedType: SharedType): LexicalNode | Array | undefined {
+ const nodes = this._sharedTypeToNodeKeys.get(sharedType);
+ if (nodes === undefined) {
+ return undefined;
+ }
+ if (sharedType instanceof XmlText) {
+ const arr = Array.from(
+ nodes.map((nodeKey) => this._nodeMap.get(nodeKey)!),
+ ) as Array;
+ return arr.length > 0 ? arr : undefined;
+ }
+ return this._nodeMap.get(nodes[0])!;
+ }
+
+ getSharedType(node: LexicalNode): SharedType | undefined {
+ return this._nodeKeyToSharedType.get(node.getKey());
+ }
+
+ delete(sharedType: SharedType): void {
+ const nodeKeys = this._sharedTypeToNodeKeys.get(sharedType);
+ if (nodeKeys === undefined) {
+ return;
+ }
+ for (const nodeKey of nodeKeys) {
+ this._nodeMap.delete(nodeKey);
+ this._nodeKeyToSharedType.delete(nodeKey);
+ }
+ this._sharedTypeToNodeKeys.delete(sharedType);
+ }
+
+ has(sharedType: SharedType): boolean {
+ return this._sharedTypeToNodeKeys.has(sharedType);
+ }
+}
diff --git a/packages/lexical-yjs/src/LexicalMapping.ts b/packages/lexical-yjs/src/LexicalMapping.ts
deleted file mode 100644
index 8fe3e851a01..00000000000
--- a/packages/lexical-yjs/src/LexicalMapping.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-/**
- * Copyright (c) Meta Platforms, Inc. and affiliates.
- *
- * This source code is licensed under the MIT license found in the
- * LICENSE file in the root directory of this source tree.
- *
- */
-
-import {$isTextNode, type LexicalNode, type TextNode} from 'lexical';
-import invariant from 'shared/invariant';
-import {XmlElement, XmlHook, XmlText} from 'yjs';
-
-import {BiMultiMap} from './BiMultiMap';
-
-export class LexicalMapping {
- private _map: BiMultiMap;
-
- constructor() {
- this._map = new BiMultiMap();
- }
-
- set(
- sharedType: XmlElement | XmlText | XmlHook,
- node: LexicalNode | TextNode[],
- ) {
- invariant(!(sharedType instanceof XmlHook), 'XmlHook is not supported');
- // Remove all existing associations for this key so that insertion order is respected.
- this._map.removeKey(sharedType);
- const isArray = node instanceof Array;
- if (sharedType instanceof XmlText) {
- invariant(isArray, 'Text nodes must be mapped as an array');
- this._map.putAll(sharedType, node);
- } else {
- invariant(!isArray, 'Element nodes must be mapped as a single node');
- invariant(!$isTextNode(node), 'Text nodes must be mapped to XmlText');
- this._map.put(sharedType, node);
- }
- }
-
- get(
- sharedType: XmlElement | XmlText | XmlHook,
- ): LexicalNode | Array | undefined {
- if (sharedType instanceof XmlHook) {
- return undefined;
- }
- const nodes = this._map.get(sharedType);
- if (nodes === undefined) {
- return undefined;
- }
- if (sharedType instanceof XmlText) {
- const arr = Array.from(nodes) as Array;
- return arr.length > 0 ? arr : undefined;
- }
- return nodes.values().next().value;
- }
-
- getSharedType(node: LexicalNode): XmlElement | XmlText | undefined {
- return this._map.getKey(node);
- }
-
- clear(): void {
- this._map.clear();
- }
-
- delete(sharedType: XmlElement | XmlText): boolean {
- return this._map.removeKey(sharedType);
- }
-
- has(sharedType: XmlElement | XmlText): boolean {
- return this._map.hasKey(sharedType);
- }
-}
diff --git a/packages/lexical-yjs/src/SyncCursors.ts b/packages/lexical-yjs/src/SyncCursors.ts
index 77ef212e10d..c285eea19f7 100644
--- a/packages/lexical-yjs/src/SyncCursors.ts
+++ b/packages/lexical-yjs/src/SyncCursors.ts
@@ -12,7 +12,6 @@ import type {
NodeKey,
NodeMap,
Point,
- TextNode,
} from 'lexical';
import {createDOMRange, createRectsFromDOMRange} from '@lexical/selection';
@@ -47,7 +46,7 @@ import {CollabDecoratorNode} from './CollabDecoratorNode';
import {CollabElementNode} from './CollabElementNode';
import {CollabLineBreakNode} from './CollabLineBreakNode';
import {CollabTextNode} from './CollabTextNode';
-import {LexicalMapping} from './LexicalMapping';
+import {CollabV2Mapping} from './CollabV2Mapping';
import {getPositionFromElementAndOffset} from './Utils';
export type CursorSelection = {
@@ -550,13 +549,13 @@ function getCollabNodeAndOffset(
}
function $getNodeAndOffsetV2(
- mapping: LexicalMapping,
+ mapping: CollabV2Mapping,
absolutePosition: AbsolutePosition,
): [null | LexicalNode, number] {
const yType = absolutePosition.type as XmlElement | XmlText;
const yOffset = absolutePosition.index;
if (yType instanceof XmlElement) {
- const node = mapping.get(yType) as LexicalNode;
+ const node = mapping.get(yType);
if (node === undefined) {
return [null, 0];
}
@@ -582,7 +581,7 @@ function $getNodeAndOffsetV2(
}
return [node, lexicalOffset];
} else {
- const nodes = mapping.get(yType) as TextNode[];
+ const nodes = mapping.get(yType);
if (nodes === undefined) {
return [null, 0];
}
diff --git a/packages/lexical-yjs/src/SyncV2.ts b/packages/lexical-yjs/src/SyncV2.ts
index b68e60d4ed5..15ee8fabe79 100644
--- a/packages/lexical-yjs/src/SyncV2.ts
+++ b/packages/lexical-yjs/src/SyncV2.ts
@@ -64,7 +64,7 @@ export const $createOrUpdateNodeFromYElement = (
prevSnapshot?: Y.Snapshot,
computeYChange?: ComputeYChange,
): LexicalNode | null => {
- let node = meta.mapping.get(el) as LexicalNode | undefined;
+ let node = meta.mapping.get(el);
if (node && !dirtyElements.has(node.getKey())) {
return node;
}
@@ -248,7 +248,7 @@ const $createTextNodesFromYText = (
const nodeTypes: string[] = deltas.map(
(delta) => delta.attributes!.t ?? TextNode.getType(),
);
- let nodes: TextNode[] = (meta.mapping.get(text) as TextNode[]) ?? [];
+ let nodes: TextNode[] = meta.mapping.get(text) ?? [];
if (
nodes.length !== nodeTypes.length ||
nodes.some((node, i) => node.getType() !== nodeTypes[i])
@@ -481,7 +481,9 @@ const computeChildEqualityFactor = (
for (; left < minCnt; left++) {
const leftY = yChildren[left];
const leftP = pChildren[left];
- if (mappedIdentity(meta.mapping.get(leftY), leftP)) {
+ if (leftY instanceof Y.XmlHook) {
+ break;
+ } else if (mappedIdentity(meta.mapping.get(leftY), leftP)) {
foundMappedChild = true; // definite (good) match!
} else if (!equalYTypePNode(leftY, leftP, meta)) {
break;
@@ -490,7 +492,9 @@ const computeChildEqualityFactor = (
for (; left + right < minCnt; right++) {
const rightY = yChildren[yChildCnt - right - 1];
const rightP = pChildren[pChildCnt - right - 1];
- if (mappedIdentity(meta.mapping.get(rightY), rightP)) {
+ if (rightY instanceof Y.XmlHook) {
+ break;
+ } else if (mappedIdentity(meta.mapping.get(rightY), rightP)) {
foundMappedChild = true;
} else if (!equalYTypePNode(rightY, rightP, meta)) {
break;
@@ -651,7 +655,9 @@ export const updateYFragment = (
for (; left < minCnt; left++) {
const leftY = yChildren[left];
const leftP = pChildren[left];
- if (mappedIdentity(meta.mapping.get(leftY), leftP)) {
+ if (leftY instanceof Y.XmlHook) {
+ break;
+ } else if (mappedIdentity(meta.mapping.get(leftY), leftP)) {
if (leftP instanceof ElementNode && dirtyElements.has(leftP.getKey())) {
updateYFragment(
y,
@@ -661,20 +667,20 @@ export const updateYFragment = (
dirtyElements,
);
}
+ } else if (equalYTypePNode(leftY, leftP, meta)) {
+ // update mapping
+ meta.mapping.set(leftY, leftP);
} else {
- if (equalYTypePNode(leftY, leftP, meta)) {
- // update mapping
- meta.mapping.set(leftY, leftP);
- } else {
- break;
- }
+ break;
}
}
// find number of matching elements from right
for (; right + left < minCnt; right++) {
const rightY = yChildren[yChildCnt - right - 1];
const rightP = pChildren[pChildCnt - right - 1];
- if (mappedIdentity(meta.mapping.get(rightY), rightP)) {
+ if (rightY instanceof Y.XmlHook) {
+ break;
+ } else if (mappedIdentity(meta.mapping.get(rightY), rightP)) {
if (rightP instanceof ElementNode && dirtyElements.has(rightP.getKey())) {
updateYFragment(
y,
@@ -684,13 +690,11 @@ export const updateYFragment = (
dirtyElements,
);
}
+ } else if (equalYTypePNode(rightY, rightP, meta)) {
+ // update mapping
+ meta.mapping.set(rightY, rightP);
} else {
- if (equalYTypePNode(rightY, rightP, meta)) {
- // update mapping
- meta.mapping.set(rightY, rightP);
- } else {
- break;
- }
+ break;
}
}
// try to compare and update
From 4e7a3ac853f524513833878b55d2253214987088 Mon Sep 17 00:00:00 2001
From: James Fitzsimmons
Date: Thu, 11 Sep 2025 10:58:14 +1000
Subject: [PATCH 06/21] self-review
---
.../__tests__/e2e/Collaboration.spec.mjs | 2 +-
.../src/LexicalCollaborationPlugin.tsx | 14 +++++++-------
.../lexical-react/src/__tests__/unit/utils.tsx | 2 +-
.../src/shared/useYjsCollaboration.tsx | 1 +
packages/lexical-yjs/src/Bindings.ts | 5 +++--
packages/lexical-yjs/src/CollabV2Mapping.ts | 2 +-
packages/lexical-yjs/src/SyncCursors.ts | 3 +--
packages/lexical-yjs/src/SyncEditorStates.ts | 14 ++++++--------
packages/lexical-yjs/src/Utils.ts | 13 +++++++++----
9 files changed, 30 insertions(+), 26 deletions(-)
diff --git a/packages/lexical-playground/__tests__/e2e/Collaboration.spec.mjs b/packages/lexical-playground/__tests__/e2e/Collaboration.spec.mjs
index fe3e34f2aea..ea5080da4ff 100644
--- a/packages/lexical-playground/__tests__/e2e/Collaboration.spec.mjs
+++ b/packages/lexical-playground/__tests__/e2e/Collaboration.spec.mjs
@@ -668,7 +668,7 @@ test.describe('Collaboration', () => {
`,
);
- // Right collaborator deletes A, left deletes B.
+ // Left collaborator deletes A, right deletes B.
await sleep(1050);
await page.keyboard.press('Delete');
await sleep(50);
diff --git a/packages/lexical-react/src/LexicalCollaborationPlugin.tsx b/packages/lexical-react/src/LexicalCollaborationPlugin.tsx
index 2a83df6e482..c3b7c468083 100644
--- a/packages/lexical-react/src/LexicalCollaborationPlugin.tsx
+++ b/packages/lexical-react/src/LexicalCollaborationPlugin.tsx
@@ -25,6 +25,7 @@ import {
} from '@lexical/yjs';
import {LexicalEditor} from 'lexical';
import {useEffect, useRef, useState} from 'react';
+import invariant from 'shared/invariant';
import {InitialEditorStateType} from './LexicalComposer';
import {
@@ -211,6 +212,10 @@ export function CollaborationPluginV2__EXPERIMENTAL({
const {yjsDocMap, name, color} = collabContext;
const [editor] = useLexicalComposerContext();
+ if (editor._parentEditor !== null) {
+ invariant(false, 'Collaboration V2 cannot be used with a nested editor');
+ }
+
useCollabActive(collabContext, editor);
const [doc, setDoc] = useState();
@@ -218,22 +223,17 @@ export function CollaborationPluginV2__EXPERIMENTAL({
const [binding, setBinding] = useState();
useEffect(() => {
- if (!provider) {
- return;
- }
-
- if (isBindingInitialized.current) {
+ if (!provider || isBindingInitialized.current) {
return;
}
isBindingInitialized.current = true;
-
const newBinding = createBindingV2__EXPERIMENTAL(
editor,
id,
doc || yjsDocMap.get(id),
yjsDocMap,
- excludedProperties,
+ {excludedProperties},
);
setBinding(newBinding);
}, [editor, provider, id, yjsDocMap, doc, excludedProperties]);
diff --git a/packages/lexical-react/src/__tests__/unit/utils.tsx b/packages/lexical-react/src/__tests__/unit/utils.tsx
index 85a15c2522a..f5f2f48c2f8 100644
--- a/packages/lexical-react/src/__tests__/unit/utils.tsx
+++ b/packages/lexical-react/src/__tests__/unit/utils.tsx
@@ -31,7 +31,7 @@ function Editor({
setEditor,
awarenessData,
shouldBootstrapEditor = true,
- useCollabV2 = true,
+ useCollabV2 = false,
}: {
doc: Y.Doc;
provider: Provider;
diff --git a/packages/lexical-react/src/shared/useYjsCollaboration.tsx b/packages/lexical-react/src/shared/useYjsCollaboration.tsx
index 281e754c6c4..6a7b53352e6 100644
--- a/packages/lexical-react/src/shared/useYjsCollaboration.tsx
+++ b/packages/lexical-react/src/shared/useYjsCollaboration.tsx
@@ -221,6 +221,7 @@ export function useYjsCollaborationV2__EXPERIMENTAL(
return () => {
root.unobserveDeep(onYjsTreeChanges);
removeListener();
+ awareness.off('update', onAwarenessUpdate);
};
}, [binding, provider, editor]);
diff --git a/packages/lexical-yjs/src/Bindings.ts b/packages/lexical-yjs/src/Bindings.ts
index 1c54bfd96a2..516aa39f25a 100644
--- a/packages/lexical-yjs/src/Bindings.ts
+++ b/packages/lexical-yjs/src/Bindings.ts
@@ -109,16 +109,17 @@ export function createBindingV2__EXPERIMENTAL(
id: string,
doc: Doc | null | undefined,
docMap: Map,
- excludedProperties?: ExcludedProperties,
+ options: {excludedProperties?: ExcludedProperties; rootName?: string} = {},
): BindingV2 {
invariant(
doc !== undefined && doc !== null,
'createBinding: doc is null or undefined',
);
+ const {excludedProperties, rootName = 'root-v2'} = options;
return {
...createBaseBinding(editor, id, doc, docMap, excludedProperties),
mapping: new CollabV2Mapping(),
- root: doc.get('root-v2', XmlElement) as XmlElement,
+ root: doc.get(rootName, XmlElement) as XmlElement,
};
}
diff --git a/packages/lexical-yjs/src/CollabV2Mapping.ts b/packages/lexical-yjs/src/CollabV2Mapping.ts
index e4f20d1b9a3..6a67ed53e54 100644
--- a/packages/lexical-yjs/src/CollabV2Mapping.ts
+++ b/packages/lexical-yjs/src/CollabV2Mapping.ts
@@ -19,7 +19,7 @@ export class CollabV2Mapping {
private _sharedTypeToNodeKeys: Map = new Map();
private _nodeKeyToSharedType: Map = new Map();
- set(sharedType: SharedType, node: LexicalNode | TextNode[]) {
+ set(sharedType: SharedType, node: LexicalNode | TextNode[]): void {
const isArray = node instanceof Array;
// Clear all existing associations for this key.
diff --git a/packages/lexical-yjs/src/SyncCursors.ts b/packages/lexical-yjs/src/SyncCursors.ts
index c285eea19f7..3e35dde22ed 100644
--- a/packages/lexical-yjs/src/SyncCursors.ts
+++ b/packages/lexical-yjs/src/SyncCursors.ts
@@ -141,7 +141,6 @@ function createRelativePositionV2(
let child = node.getFirstChild();
while (child !== null && i < offset) {
if ($isTextNode(child)) {
- // Multiple text nodes are collapsed into a single YText.
let nextSibling = child.getNextSibling();
while ($isTextNode(nextSibling)) {
nextSibling = nextSibling.getNextSibling();
@@ -349,6 +348,7 @@ function updateCursor(
selections.pop();
}
}
+
type AnyCollabNode =
| CollabDecoratorNode
| CollabElementNode
@@ -570,7 +570,6 @@ function $getNodeAndOffsetV2(
remainingYOffset -= 1;
lexicalOffset += 1;
if ($isTextNode(child)) {
- // Multiple text nodes (lexicalOffset) are collapsed into a single YText (remainingYOffset).
while (
lexicalOffset < children.length &&
$isTextNode(children[lexicalOffset])
diff --git a/packages/lexical-yjs/src/SyncEditorStates.ts b/packages/lexical-yjs/src/SyncEditorStates.ts
index 13322c7775f..92c247c8fc7 100644
--- a/packages/lexical-yjs/src/SyncEditorStates.ts
+++ b/packages/lexical-yjs/src/SyncEditorStates.ts
@@ -339,17 +339,15 @@ function $syncV2XmlElement(
const dirtyElements = new Set();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
- const collectDirty = (_value: unknown, type: YAbstractType) => {
- const knownType = type instanceof XmlElement || type instanceof XmlText;
- if (knownType && binding.mapping.has(type)) {
+ const collectDirtyElements = (_value: unknown, type: YAbstractType) => {
+ const elementType = type instanceof XmlElement;
+ if (elementType && binding.mapping.has(type)) {
const node = binding.mapping.get(type)!;
- if (!(node instanceof Array)) {
- dirtyElements.add(node.getKey());
- }
+ dirtyElements.add(node.getKey());
}
};
- transaction.changed.forEach(collectDirty);
- transaction.changedParentTypes.forEach(collectDirty);
+ transaction.changed.forEach(collectDirtyElements);
+ transaction.changedParentTypes.forEach(collectDirtyElements);
$createOrUpdateNodeFromYElement(binding.root, binding, dirtyElements);
}
diff --git a/packages/lexical-yjs/src/Utils.ts b/packages/lexical-yjs/src/Utils.ts
index c6ddc940efb..c5a9a4939f8 100644
--- a/packages/lexical-yjs/src/Utils.ts
+++ b/packages/lexical-yjs/src/Utils.ts
@@ -29,6 +29,7 @@ import {
import invariant from 'shared/invariant';
import {Doc, Map as YMap, XmlElement, XmlText} from 'yjs';
+import {isBindingV1} from './Bindings';
import {
$createCollabDecoratorNode,
CollabDecoratorNode,
@@ -96,7 +97,7 @@ export function initializeNodeProperties(binding: BaseBinding): void {
defaultProperties[property] = value;
}
}
- nodeProperties.set(node.__type, defaultProperties);
+ nodeProperties.set(node.__type, Object.freeze(defaultProperties));
});
});
}
@@ -287,7 +288,12 @@ export function createLexicalNodeFromCollabNode(
export function $syncPropertiesFromYjs(
binding: BaseBinding,
- sharedType: XmlText | YMap | XmlElement | Record,
+ sharedType:
+ | XmlText
+ | YMap
+ | XmlElement
+ // v2
+ | Record,
lexicalNode: LexicalNode,
keysChanged: null | Set,
): void {
@@ -304,7 +310,7 @@ export function $syncPropertiesFromYjs(
for (let i = 0; i < properties.length; i++) {
const property = properties[i];
if (isExcludedProperty(property, lexicalNode, binding)) {
- if (property === '__state') {
+ if (property === '__state' && isBindingV1(binding)) {
if (!writableNode) {
writableNode = lexicalNode.getWritable();
}
@@ -375,7 +381,6 @@ function $syncNodeStateToLexical(
lexicalNode: LexicalNode,
): void {
const existingState = sharedTypeGet(sharedType, '__state');
- // TODO(collab-v2): handle v2 where the sharedType is a Record
if (!(existingState instanceof YMap)) {
return;
}
From 2208ab09ed976b14162583cbb1f39a9a25e49988 Mon Sep 17 00:00:00 2001
From: James Fitzsimmons
Date: Thu, 11 Sep 2025 11:14:43 +1000
Subject: [PATCH 07/21] self-review pt2
---
packages/lexical-yjs/src/SyncEditorStates.ts | 4 +-
packages/lexical-yjs/src/SyncV2.ts | 414 ++++++++++---------
2 files changed, 225 insertions(+), 193 deletions(-)
diff --git a/packages/lexical-yjs/src/SyncEditorStates.ts b/packages/lexical-yjs/src/SyncEditorStates.ts
index 92c247c8fc7..c9a5d830c71 100644
--- a/packages/lexical-yjs/src/SyncEditorStates.ts
+++ b/packages/lexical-yjs/src/SyncEditorStates.ts
@@ -50,7 +50,7 @@ import {
SyncCursorPositionsFn,
syncLexicalSelectionToYjs,
} from './SyncCursors';
-import {$createOrUpdateNodeFromYElement, updateYFragment} from './SyncV2';
+import {$createOrUpdateNodeFromYElement, $updateYFragment} from './SyncV2';
import {
$getOrInitCollabNodeFromSharedType,
$moveSelectionToPreviousNode,
@@ -403,7 +403,7 @@ export function syncLexicalUpdateToYjsV2__EXPERIMENTAL(
syncWithTransaction(binding, () => {
currEditorState.read(() => {
if (dirtyElements.has('root')) {
- updateYFragment(
+ $updateYFragment(
binding.doc,
binding.root,
$getRoot(),
diff --git a/packages/lexical-yjs/src/SyncV2.ts b/packages/lexical-yjs/src/SyncV2.ts
index 15ee8fabe79..f8df618d0c4 100644
--- a/packages/lexical-yjs/src/SyncV2.ts
+++ b/packages/lexical-yjs/src/SyncV2.ts
@@ -6,26 +6,56 @@
*
*/
+/*
+ * Implementation notes, in no particular order.
+ *
+ * Sibling text nodes are synced to a single XmlText type. All non-text nodes are synced one-to-one
+ * with an XmlElement.
+ *
+ * To be space-efficient, only property values that differ from their defaults are synced. Default
+ * values are determined by creating an instance of the node with no constructor arguments and
+ * enumerating over its properties.
+ *
+ * For a given text node, we make use of XmlText.applyDelta() to sync properties and state to
+ * specific ranges of text. Refer to TextAttributes below for the structure of the attributes.
+ *
+ * For non-text nodes, we use the XmlElement's attributes (YMap under the hood) to sync properties
+ * and state. The former are stored using their names as keys, and the latter are stored with a
+ * prefix. (NB: '$' couldn't be used as the prefix because it breaks XmlElement.toDOM().)
+ */
+
import {
$getWritableNodeState,
$isTextNode,
ElementNode,
LexicalNode,
NodeKey,
+ RootNode,
TextNode,
} from 'lexical';
// TODO(collab-v2): use internal implementation
import {simpleDiff} from 'lib0/diff';
import invariant from 'shared/invariant';
-// TODO(collab-v2): import specific types
-import * as Y from 'yjs';
+import {
+ ContentFormat,
+ ContentString,
+ ContentType,
+ Doc as YDoc,
+ ID,
+ Snapshot,
+ Text as YText,
+ typeListToArraySnapshot,
+ XmlElement,
+ XmlHook,
+ XmlText,
+} from 'yjs';
import {BindingV2} from './Bindings';
import {$syncPropertiesFromYjs, getDefaultNodeProperties} from './Utils';
type ComputeYChange = (
event: 'removed' | 'added',
- id: Y.ID,
+ id: ID,
) => Record;
type TextAttributes = {
@@ -43,38 +73,38 @@ const DEFAULT_TEXT_ATTRIBUTES: TextAttributes = {
y: {idx: 0},
};
-const isVisible = (item: Y.Item, snapshot?: Y.Snapshot): boolean =>
+/*
+const isVisible = (item: Item, snapshot?: Snapshot): boolean =>
snapshot === undefined
? !item.deleted
: snapshot.sv.has(item.id.client) &&
snapshot.sv.get(item.id.client)! > item.id.clock &&
- !Y.isDeleted(snapshot.ds, item.id);
+ !isDeleted(snapshot.ds, item.id);
+*/
-const isRootElement = (el: Y.XmlElement): boolean =>
- el.nodeName === 'UNDEFINED';
+// https://docs.yjs.dev/api/shared-types/y.xmlelement
+// "Define a top-level type; Note that the nodeName is always "undefined""
+const isRootElement = (el: XmlElement): boolean => el.nodeName === 'UNDEFINED';
-/**
- * @return Returns node if node could be created. Otherwise it deletes the yjs type and returns null
- */
export const $createOrUpdateNodeFromYElement = (
- el: Y.XmlElement,
- meta: BindingV2,
+ el: XmlElement,
+ binding: BindingV2,
dirtyElements: Set,
- snapshot?: Y.Snapshot,
- prevSnapshot?: Y.Snapshot,
+ snapshot?: Snapshot,
+ prevSnapshot?: Snapshot,
computeYChange?: ComputeYChange,
): LexicalNode | null => {
- let node = meta.mapping.get(el);
+ let node = binding.mapping.get(el);
if (node && !dirtyElements.has(node.getKey())) {
return node;
}
const children: LexicalNode[] = [];
- const $createChildren = (type: Y.XmlElement | Y.XmlText | Y.XmlHook) => {
- if (type instanceof Y.XmlElement) {
+ const $createChildren = (type: XmlElement | XmlText | XmlHook) => {
+ if (type instanceof XmlElement) {
const n = $createOrUpdateNodeFromYElement(
type,
- meta,
+ binding,
dirtyElements,
snapshot,
prevSnapshot,
@@ -83,16 +113,16 @@ export const $createOrUpdateNodeFromYElement = (
if (n !== null) {
children.push(n);
}
- } else if (type instanceof Y.XmlText) {
+ } else if (type instanceof XmlText) {
// If the next ytext exists and was created by us, move the content to the current ytext.
- // This is a fix for #160 -- duplication of characters when two Y.Text exist next to each
- // other.
+ // This is a fix for y-prosemirror #160 -- duplication of characters when two YText exist
+ // next to each other.
// eslint-disable-next-line lexical/no-optional-chaining
- const content = type._item!.right?.content as Y.ContentType | undefined;
+ const content = type._item!.right?.content as ContentType | undefined;
// eslint-disable-next-line lexical/no-optional-chaining
const nextytext = content?.type;
if (
- nextytext instanceof Y.Text &&
+ nextytext instanceof YText &&
!nextytext._item!.deleted &&
nextytext._item!.id.client === nextytext.doc!.clientID
) {
@@ -101,10 +131,10 @@ export const $createOrUpdateNodeFromYElement = (
nextytext._item!.delete(tr);
});
}
- // now create the prosemirror text nodes
+ // now create the text nodes
const ns = $createTextNodesFromYText(
type,
- meta,
+ binding,
snapshot,
prevSnapshot,
computeYChange,
@@ -120,16 +150,18 @@ export const $createOrUpdateNodeFromYElement = (
invariant(false, 'XmlHook is not supported');
}
};
+
if (snapshot === undefined || prevSnapshot === undefined) {
el.toArray().forEach($createChildren);
} else {
- Y.typeListToArraySnapshot(
+ typeListToArraySnapshot(
el,
- new Y.Snapshot(prevSnapshot.ds, snapshot.sv),
+ new Snapshot(prevSnapshot.ds, snapshot.sv),
).forEach($createChildren);
}
- const type = isRootElement(el) ? 'root' : el.nodeName;
- const registeredNodes = meta.editor._nodes;
+
+ const type = isRootElement(el) ? RootNode.getType() : el.nodeName;
+ const registeredNodes = binding.editor._nodes;
const nodeInfo = registeredNodes.get(type);
if (nodeInfo === undefined) {
throw new Error(
@@ -138,9 +170,10 @@ export const $createOrUpdateNodeFromYElement = (
}
node = node || new nodeInfo.klass();
const attrs = {
- ...getDefaultNodeProperties(node, meta),
+ ...getDefaultNodeProperties(node, binding),
...el.getAttributes(snapshot),
};
+ /*
if (snapshot !== undefined) {
if (!isVisible(el._item!, snapshot)) {
// TODO(collab-v2): add type for ychange, store in node state?
@@ -153,6 +186,7 @@ export const $createOrUpdateNodeFromYElement = (
: {type: 'added'};
}
}
+ */
const properties: Record = {};
const state: Record = {};
for (const k in attrs) {
@@ -162,13 +196,14 @@ export const $createOrUpdateNodeFromYElement = (
properties[k] = attrs[k];
}
}
- $syncPropertiesFromYjs(meta, properties, node, null);
+ $syncPropertiesFromYjs(binding, properties, node, null);
$getWritableNodeState(node).updateFromJSON(state);
if (node instanceof ElementNode) {
$spliceChildren(node, children);
}
+
const latestNode = node.getLatest();
- meta.mapping.set(el, latestNode);
+ binding.mapping.set(el, latestNode);
return latestNode;
};
@@ -233,10 +268,10 @@ const $spliceChildren = (node: ElementNode, nextChildren: LexicalNode[]) => {
};
const $createTextNodesFromYText = (
- text: Y.XmlText,
- meta: BindingV2,
- snapshot?: Y.Snapshot,
- prevSnapshot?: Y.Snapshot,
+ text: XmlText,
+ binding: BindingV2,
+ snapshot?: Snapshot,
+ prevSnapshot?: Snapshot,
computeYChange?: ComputeYChange,
): Array | null => {
const deltas: {insert: string; attributes: TextAttributes}[] = text
@@ -248,12 +283,12 @@ const $createTextNodesFromYText = (
const nodeTypes: string[] = deltas.map(
(delta) => delta.attributes!.t ?? TextNode.getType(),
);
- let nodes: TextNode[] = meta.mapping.get(text) ?? [];
+ let nodes: TextNode[] = binding.mapping.get(text) ?? [];
if (
nodes.length !== nodeTypes.length ||
nodes.some((node, i) => node.getType() !== nodeTypes[i])
) {
- const registeredNodes = meta.editor._nodes;
+ const registeredNodes = binding.editor._nodes;
nodes = nodeTypes.map((type) => {
const nodeInfo = registeredNodes.get(type);
if (nodeInfo === undefined) {
@@ -270,6 +305,8 @@ const $createTextNodesFromYText = (
return node;
});
}
+
+ // Sync text, properties and state to the text nodes.
for (let i = 0; i < deltas.length; i++) {
const node = nodes[i];
const delta = deltas[i];
@@ -278,40 +315,40 @@ const $createTextNodesFromYText = (
node.setTextContent(insert);
}
const properties = {
- ...getDefaultNodeProperties(node, meta),
+ ...getDefaultNodeProperties(node, binding),
...attributes.p,
- ...attributes.ychange,
};
- $syncPropertiesFromYjs(meta, properties, node, null);
+ $syncPropertiesFromYjs(binding, properties, node, null);
}
const latestNodes = nodes.map((node) => node.getLatest());
- meta.mapping.set(text, latestNodes);
+ binding.mapping.set(text, latestNodes);
return latestNodes;
};
-const createTypeFromTextNodes = (
+const $createTypeFromTextNodes = (
nodes: TextNode[],
- meta: BindingV2,
-): Y.XmlText => {
- const type = new Y.XmlText();
- updateYText(type, nodes, meta);
+ binding: BindingV2,
+): XmlText => {
+ const type = new XmlText();
+ $updateYText(type, nodes, binding);
return type;
};
const createTypeFromElementNode = (
node: LexicalNode,
- meta: BindingV2,
-): Y.XmlElement => {
- const type = new Y.XmlElement(node.getType());
- // TODO(collab-v2): exclude ychange
+ binding: BindingV2,
+): XmlElement => {
+ const type = new XmlElement(node.getType());
const attrs = {
- ...propertiesToAttributes(node, meta),
+ ...propertiesToAttributes(node, binding),
...stateToAttributes(node),
};
for (const key in attrs) {
const val = attrs[key];
if (val !== null) {
- type.setAttribute(key, val);
+ // TODO(collab-v2): typing for XmlElement generic
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ type.setAttribute(key, val as any);
}
}
if (!(node instanceof ElementNode)) {
@@ -319,29 +356,28 @@ const createTypeFromElementNode = (
}
type.insert(
0,
- normalizePNodeContent(node).map((n) =>
- createTypeFromTextOrElementNode(n, meta),
+ normalizeNodeContent(node).map((n) =>
+ $createTypeFromTextOrElementNode(n, binding),
),
);
- meta.mapping.set(type, node);
+ binding.mapping.set(type, node);
return type;
};
-const createTypeFromTextOrElementNode = (
+const $createTypeFromTextOrElementNode = (
node: LexicalNode | TextNode[],
meta: BindingV2,
-): Y.XmlElement | Y.XmlText =>
+): XmlElement | XmlText =>
node instanceof Array
- ? createTypeFromTextNodes(node, meta)
+ ? $createTypeFromTextNodes(node, meta)
: createTypeFromElementNode(node, meta);
-const isObject = (val: unknown) => typeof val === 'object' && val !== null;
+const isObject = (val: unknown): val is Record =>
+ typeof val === 'object' && val != null;
const equalAttrs = (
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- pattrs: Record,
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- yattrs: Record | null,
+ pattrs: Record,
+ yattrs: Record | null,
) => {
const keys = Object.keys(pattrs).filter((key) => pattrs[key] !== null);
if (yattrs == null) {
@@ -364,22 +400,22 @@ const equalAttrs = (
type NormalizedPNodeContent = Array | LexicalNode>;
-const normalizePNodeContent = (pnode: LexicalNode): NormalizedPNodeContent => {
- if (!(pnode instanceof ElementNode)) {
+const normalizeNodeContent = (node: LexicalNode): NormalizedPNodeContent => {
+ if (!(node instanceof ElementNode)) {
return [];
}
- const c = pnode.getChildren();
+ const c = node.getChildren();
const res: NormalizedPNodeContent = [];
for (let i = 0; i < c.length; i++) {
const n = c[i];
if (n instanceof TextNode) {
const textNodes: TextNode[] = [];
for (
- let tnode = c[i];
- i < c.length && tnode instanceof TextNode;
- tnode = c[++i]
+ let maybeTextNode = c[i];
+ i < c.length && maybeTextNode instanceof TextNode;
+ maybeTextNode = c[++i]
) {
- textNodes.push(tnode);
+ textNodes.push(maybeTextNode);
}
i--;
res.push(textNodes);
@@ -391,9 +427,9 @@ const normalizePNodeContent = (pnode: LexicalNode): NormalizedPNodeContent => {
};
const equalYTextLText = (
- ytext: Y.XmlText,
+ ytext: XmlText,
ltexts: TextNode[],
- meta: BindingV2,
+ binding: BindingV2,
) => {
const delta = ytext.toDelta();
return (
@@ -405,7 +441,7 @@ const equalYTextLText = (
d.attributes.t === ltexts[i].getType() &&
equalAttrs(
d.attributes.p ?? {},
- propertiesToAttributes(ltexts[i], meta),
+ propertiesToAttributes(ltexts[i], binding),
) &&
equalAttrs(
Object.fromEntries(
@@ -420,45 +456,45 @@ const equalYTextLText = (
};
const equalYTypePNode = (
- ytype: Y.XmlElement | Y.XmlText | Y.XmlHook,
- pnode: LexicalNode | TextNode[],
- meta: BindingV2,
-): boolean => {
+ ytype: XmlElement | XmlText | XmlHook,
+ lnode: LexicalNode | TextNode[],
+ binding: BindingV2,
+): ytype is XmlElement | XmlText => {
if (
- ytype instanceof Y.XmlElement &&
- !(pnode instanceof Array) &&
- matchNodeName(ytype, pnode)
+ ytype instanceof XmlElement &&
+ !(lnode instanceof Array) &&
+ matchNodeName(ytype, lnode)
) {
- const normalizedContent = normalizePNodeContent(pnode);
+ const normalizedContent = normalizeNodeContent(lnode);
return (
ytype._length === normalizedContent.length &&
equalAttrs(ytype.getAttributes(), {
- ...propertiesToAttributes(pnode, meta),
- ...stateToAttributes(pnode),
+ ...propertiesToAttributes(lnode, binding),
+ ...stateToAttributes(lnode),
}) &&
ytype
.toArray()
.every((ychild, i) =>
- equalYTypePNode(ychild, normalizedContent[i], meta),
+ equalYTypePNode(ychild, normalizedContent[i], binding),
)
);
}
return (
- ytype instanceof Y.XmlText &&
- pnode instanceof Array &&
- equalYTextLText(ytype, pnode, meta)
+ ytype instanceof XmlText &&
+ lnode instanceof Array &&
+ equalYTextLText(ytype, lnode, binding)
);
};
const mappedIdentity = (
mapped: LexicalNode | TextNode[] | undefined,
- pcontent: LexicalNode | TextNode[],
+ lcontent: LexicalNode | TextNode[],
) =>
- mapped === pcontent ||
+ mapped === lcontent ||
(mapped instanceof Array &&
- pcontent instanceof Array &&
- mapped.length === pcontent.length &&
- mapped.every((a, i) => pcontent[i] === a));
+ lcontent instanceof Array &&
+ mapped.length === lcontent.length &&
+ mapped.every((a, i) => lcontent[i] === a));
type EqualityFactor = {
foundMappedChild: boolean;
@@ -466,12 +502,12 @@ type EqualityFactor = {
};
const computeChildEqualityFactor = (
- ytype: Y.XmlElement,
- pnode: LexicalNode,
- meta: BindingV2,
+ ytype: XmlElement,
+ lnode: LexicalNode,
+ binding: BindingV2,
): EqualityFactor => {
const yChildren = ytype.toArray();
- const pChildren = normalizePNodeContent(pnode);
+ const pChildren = normalizeNodeContent(lnode);
const pChildCnt = pChildren.length;
const yChildCnt = yChildren.length;
const minCnt = Math.min(yChildCnt, pChildCnt);
@@ -481,22 +517,22 @@ const computeChildEqualityFactor = (
for (; left < minCnt; left++) {
const leftY = yChildren[left];
const leftP = pChildren[left];
- if (leftY instanceof Y.XmlHook) {
+ if (leftY instanceof XmlHook) {
break;
- } else if (mappedIdentity(meta.mapping.get(leftY), leftP)) {
+ } else if (mappedIdentity(binding.mapping.get(leftY), leftP)) {
foundMappedChild = true; // definite (good) match!
- } else if (!equalYTypePNode(leftY, leftP, meta)) {
+ } else if (!equalYTypePNode(leftY, leftP, binding)) {
break;
}
}
for (; left + right < minCnt; right++) {
const rightY = yChildren[yChildCnt - right - 1];
const rightP = pChildren[pChildCnt - right - 1];
- if (rightY instanceof Y.XmlHook) {
+ if (rightY instanceof XmlHook) {
break;
- } else if (mappedIdentity(meta.mapping.get(rightY), rightP)) {
+ } else if (mappedIdentity(binding.mapping.get(rightY), rightP)) {
foundMappedChild = true;
- } else if (!equalYTypePNode(rightY, rightP, meta)) {
+ } else if (!equalYTypePNode(rightY, rightP, binding)) {
break;
}
}
@@ -507,16 +543,16 @@ const computeChildEqualityFactor = (
};
const ytextTrans = (
- ytext: Y.Text,
+ ytext: YText,
): {nAttrs: Record; str: string} => {
let str = '';
let n = ytext._start;
const nAttrs: Record = {};
while (n !== null) {
if (!n.deleted) {
- if (n.countable && n.content instanceof Y.ContentString) {
+ if (n.countable && n.content instanceof ContentString) {
str += n.content.str;
- } else if (n.content instanceof Y.ContentFormat) {
+ } else if (n.content instanceof ContentFormat) {
nAttrs[n.content.key] = null;
}
}
@@ -528,22 +564,23 @@ const ytextTrans = (
};
};
-const updateYText = (ytext: Y.XmlText, ltexts: TextNode[], meta: BindingV2) => {
- meta.mapping.set(ytext, ltexts);
+const $updateYText = (
+ ytext: XmlText,
+ ltexts: TextNode[],
+ binding: BindingV2,
+) => {
+ binding.mapping.set(ytext, ltexts);
const {nAttrs, str} = ytextTrans(ytext);
const content = ltexts.map((node, idx) => {
const nodeType = node.getType();
- let properties: TextAttributes['p'] | null = propertiesToAttributes(
- node,
- meta,
- );
- if (Object.keys(properties).length === 0) {
- properties = null;
+ let p: TextAttributes['p'] | null = propertiesToAttributes(node, binding);
+ if (Object.keys(p).length === 0) {
+ p = null;
}
return {
attributes: Object.assign({}, nAttrs, {
...(nodeType !== TextNode.getType() && {t: nodeType}),
- p: properties,
+ p,
...stateToAttributes(node),
// TODO(collab-v2): can probably be more targeted here
y: {idx}, // Prevent Yjs from merging text nodes itself.
@@ -590,8 +627,7 @@ const stateToAttributes = (node: LexicalNode) => {
return {};
}
const [unknown = {}, known] = state.getInternalState();
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const attrs: Record = {};
+ const attrs: Record = {};
for (const [k, v] of Object.entries(unknown)) {
attrs[stateKeyToAttrKey(k)] = v;
}
@@ -601,36 +637,34 @@ const stateToAttributes = (node: LexicalNode) => {
return attrs;
};
-/**
- * Update a yDom node by syncing the current content of the prosemirror node.
- */
-export const updateYFragment = (
- y: Y.Doc,
- yDomFragment: Y.XmlElement,
- pNode: LexicalNode,
- meta: BindingV2,
+export const $updateYFragment = (
+ y: YDoc,
+ yDomFragment: XmlElement,
+ node: LexicalNode,
+ binding: BindingV2,
dirtyElements: Set,
) => {
if (
- yDomFragment instanceof Y.XmlElement &&
- yDomFragment.nodeName !== pNode.getType() &&
- // TODO(collab-v2): the root XmlElement should have a valid node name
- !(isRootElement(yDomFragment) && pNode.getType() === 'root')
+ yDomFragment instanceof XmlElement &&
+ yDomFragment.nodeName !== node.getType() &&
+ !(isRootElement(yDomFragment) && node.getType() === RootNode.getType())
) {
throw new Error('node name mismatch!');
}
- meta.mapping.set(yDomFragment, pNode);
+ binding.mapping.set(yDomFragment, node);
// update attributes
- if (yDomFragment instanceof Y.XmlElement) {
+ if (yDomFragment instanceof XmlElement) {
const yDomAttrs = yDomFragment.getAttributes();
const lexicalAttrs = {
- ...propertiesToAttributes(pNode, meta),
- ...stateToAttributes(pNode),
+ ...propertiesToAttributes(node, binding),
+ ...stateToAttributes(node),
};
for (const key in lexicalAttrs) {
if (lexicalAttrs[key] !== null) {
if (yDomAttrs[key] !== lexicalAttrs[key] && key !== 'ychange') {
- yDomFragment.setAttribute(key, lexicalAttrs[key]);
+ // TODO(collab-v2): typing for XmlElement generic
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ yDomFragment.setAttribute(key, lexicalAttrs[key] as any);
}
} else {
yDomFragment.removeAttribute(key);
@@ -644,32 +678,32 @@ export const updateYFragment = (
}
}
// update children
- const pChildren = normalizePNodeContent(pNode);
- const pChildCnt = pChildren.length;
+ const lChildren = normalizeNodeContent(node);
+ const lChildCnt = lChildren.length;
const yChildren = yDomFragment.toArray();
const yChildCnt = yChildren.length;
- const minCnt = Math.min(pChildCnt, yChildCnt);
+ const minCnt = Math.min(lChildCnt, yChildCnt);
let left = 0;
let right = 0;
// find number of matching elements from left
for (; left < minCnt; left++) {
const leftY = yChildren[left];
- const leftP = pChildren[left];
- if (leftY instanceof Y.XmlHook) {
+ const leftL = lChildren[left];
+ if (leftY instanceof XmlHook) {
break;
- } else if (mappedIdentity(meta.mapping.get(leftY), leftP)) {
- if (leftP instanceof ElementNode && dirtyElements.has(leftP.getKey())) {
- updateYFragment(
+ } else if (mappedIdentity(binding.mapping.get(leftY), leftL)) {
+ if (leftL instanceof ElementNode && dirtyElements.has(leftL.getKey())) {
+ $updateYFragment(
y,
- leftY as Y.XmlElement,
- leftP as LexicalNode,
- meta,
+ leftY as XmlElement,
+ leftL as LexicalNode,
+ binding,
dirtyElements,
);
}
- } else if (equalYTypePNode(leftY, leftP, meta)) {
+ } else if (equalYTypePNode(leftY, leftL, binding)) {
// update mapping
- meta.mapping.set(leftY, leftP);
+ binding.mapping.set(leftY, leftL);
} else {
break;
}
@@ -677,53 +711,53 @@ export const updateYFragment = (
// find number of matching elements from right
for (; right + left < minCnt; right++) {
const rightY = yChildren[yChildCnt - right - 1];
- const rightP = pChildren[pChildCnt - right - 1];
- if (rightY instanceof Y.XmlHook) {
+ const rightL = lChildren[lChildCnt - right - 1];
+ if (rightY instanceof XmlHook) {
break;
- } else if (mappedIdentity(meta.mapping.get(rightY), rightP)) {
- if (rightP instanceof ElementNode && dirtyElements.has(rightP.getKey())) {
- updateYFragment(
+ } else if (mappedIdentity(binding.mapping.get(rightY), rightL)) {
+ if (rightL instanceof ElementNode && dirtyElements.has(rightL.getKey())) {
+ $updateYFragment(
y,
- rightY as Y.XmlElement,
- rightP as LexicalNode,
- meta,
+ rightY as XmlElement,
+ rightL as LexicalNode,
+ binding,
dirtyElements,
);
}
- } else if (equalYTypePNode(rightY, rightP, meta)) {
+ } else if (equalYTypePNode(rightY, rightL, binding)) {
// update mapping
- meta.mapping.set(rightY, rightP);
+ binding.mapping.set(rightY, rightL);
} else {
break;
}
}
// try to compare and update
- while (yChildCnt - left - right > 0 && pChildCnt - left - right > 0) {
+ while (yChildCnt - left - right > 0 && lChildCnt - left - right > 0) {
const leftY = yChildren[left];
- const leftP = pChildren[left];
+ const leftL = lChildren[left];
const rightY = yChildren[yChildCnt - right - 1];
- const rightP = pChildren[pChildCnt - right - 1];
- if (leftY instanceof Y.XmlText && leftP instanceof Array) {
- if (!equalYTextLText(leftY, leftP, meta)) {
- updateYText(leftY, leftP, meta);
+ const rightL = lChildren[lChildCnt - right - 1];
+ if (leftY instanceof XmlText && leftL instanceof Array) {
+ if (!equalYTextLText(leftY, leftL, binding)) {
+ $updateYText(leftY, leftL, binding);
}
left += 1;
} else {
let updateLeft =
- leftY instanceof Y.XmlElement && matchNodeName(leftY, leftP);
+ leftY instanceof XmlElement && matchNodeName(leftY, leftL);
let updateRight =
- rightY instanceof Y.XmlElement && matchNodeName(rightY, rightP);
+ rightY instanceof XmlElement && matchNodeName(rightY, rightL);
if (updateLeft && updateRight) {
// decide which which element to update
const equalityLeft = computeChildEqualityFactor(
- leftY as Y.XmlElement,
- leftP as LexicalNode,
- meta,
+ leftY as XmlElement,
+ leftL as LexicalNode,
+ binding,
);
const equalityRight = computeChildEqualityFactor(
- rightY as Y.XmlElement,
- rightP as LexicalNode,
- meta,
+ rightY as XmlElement,
+ rightL as LexicalNode,
+ binding,
);
if (equalityLeft.foundMappedChild && !equalityRight.foundMappedChild) {
updateRight = false;
@@ -739,55 +773,53 @@ export const updateYFragment = (
}
}
if (updateLeft) {
- updateYFragment(
+ $updateYFragment(
y,
- leftY as Y.XmlElement,
- leftP as LexicalNode,
- meta,
+ leftY as XmlElement,
+ leftL as LexicalNode,
+ binding,
dirtyElements,
);
left += 1;
} else if (updateRight) {
- updateYFragment(
+ $updateYFragment(
y,
- rightY as Y.XmlElement,
- rightP as LexicalNode,
- meta,
+ rightY as XmlElement,
+ rightL as LexicalNode,
+ binding,
dirtyElements,
);
right += 1;
} else {
- meta.mapping.delete(yDomFragment.get(left));
+ binding.mapping.delete(yDomFragment.get(left));
yDomFragment.delete(left, 1);
yDomFragment.insert(left, [
- createTypeFromTextOrElementNode(leftP, meta),
+ $createTypeFromTextOrElementNode(leftL, binding),
]);
left += 1;
}
}
}
const yDelLen = yChildCnt - left - right;
- if (yChildCnt === 1 && pChildCnt === 0 && yChildren[0] instanceof Y.XmlText) {
- meta.mapping.delete(yChildren[0]);
+ if (yChildCnt === 1 && lChildCnt === 0 && yChildren[0] instanceof XmlText) {
+ binding.mapping.delete(yChildren[0]);
// Edge case handling https://github.com/yjs/y-prosemirror/issues/108
// Only delete the content of the Y.Text to retain remote changes on the same Y.Text object
yChildren[0].delete(0, yChildren[0].length);
} else if (yDelLen > 0) {
yDomFragment
.slice(left, left + yDelLen)
- .forEach((type) => meta.mapping.delete(type));
+ .forEach((type) => binding.mapping.delete(type));
yDomFragment.delete(left, yDelLen);
}
- if (left + right < pChildCnt) {
+ if (left + right < lChildCnt) {
const ins = [];
- for (let i = left; i < pChildCnt - right; i++) {
- ins.push(createTypeFromTextOrElementNode(pChildren[i], meta));
+ for (let i = left; i < lChildCnt - right; i++) {
+ ins.push($createTypeFromTextOrElementNode(lChildren[i], binding));
}
yDomFragment.insert(left, ins);
}
};
-const matchNodeName = (
- yElement: Y.XmlElement,
- pNode: LexicalNode | TextNode[],
-) => !(pNode instanceof Array) && yElement.nodeName === pNode.getType();
+const matchNodeName = (yElement: XmlElement, lnode: LexicalNode | TextNode[]) =>
+ !(lnode instanceof Array) && yElement.nodeName === lnode.getType();
From faee909e81870d5244f29c19ef9f9aed68f48622 Mon Sep 17 00:00:00 2001
From: James Fitzsimmons
Date: Thu, 11 Sep 2025 11:24:05 +1000
Subject: [PATCH 08/21] simpleDiff
---
packages/lexical-yjs/src/SyncV2.ts | 28 ++++++++++++++++++++++++----
1 file changed, 24 insertions(+), 4 deletions(-)
diff --git a/packages/lexical-yjs/src/SyncV2.ts b/packages/lexical-yjs/src/SyncV2.ts
index f8df618d0c4..bb2f0300ba3 100644
--- a/packages/lexical-yjs/src/SyncV2.ts
+++ b/packages/lexical-yjs/src/SyncV2.ts
@@ -25,7 +25,9 @@
*/
import {
+ $getSelection,
$getWritableNodeState,
+ $isRangeSelection,
$isTextNode,
ElementNode,
LexicalNode,
@@ -33,9 +35,8 @@ import {
RootNode,
TextNode,
} from 'lexical';
-// TODO(collab-v2): use internal implementation
-import {simpleDiff} from 'lib0/diff';
import invariant from 'shared/invariant';
+import simpleDiffWithCursor from 'shared/simpleDiffWithCursor';
import {
ContentFormat,
ContentString,
@@ -586,11 +587,30 @@ const $updateYText = (
y: {idx}, // Prevent Yjs from merging text nodes itself.
}),
insert: node.getTextContent(),
+ nodeKey: node.getKey(),
};
});
- const {insert, remove, index} = simpleDiff(
+
+ const nextText = content.map((c) => c.insert).join('');
+ const selection = $getSelection();
+ let cursorOffset: number;
+ if ($isRangeSelection(selection) && selection.isCollapsed()) {
+ cursorOffset = 0;
+ for (const c of content) {
+ if (c.nodeKey === selection.anchor.key) {
+ cursorOffset += selection.anchor.offset;
+ break;
+ }
+ cursorOffset += c.insert.length;
+ }
+ } else {
+ cursorOffset = nextText.length;
+ }
+
+ const {insert, remove, index} = simpleDiffWithCursor(
str,
- content.map((c) => c.insert).join(''),
+ nextText,
+ cursorOffset,
);
ytext.delete(index, remove);
ytext.insert(index, insert);
From b60c886d8ad00a230611a8793e3175605cb29bb5 Mon Sep 17 00:00:00 2001
From: James Fitzsimmons
Date: Thu, 11 Sep 2025 11:29:30 +1000
Subject: [PATCH 09/21] attrs
---
packages/lexical-yjs/src/SyncV2.ts | 13 ++++++-------
1 file changed, 6 insertions(+), 7 deletions(-)
diff --git a/packages/lexical-yjs/src/SyncV2.ts b/packages/lexical-yjs/src/SyncV2.ts
index bb2f0300ba3..b347df91fd8 100644
--- a/packages/lexical-yjs/src/SyncV2.ts
+++ b/packages/lexical-yjs/src/SyncV2.ts
@@ -170,14 +170,11 @@ export const $createOrUpdateNodeFromYElement = (
);
}
node = node || new nodeInfo.klass();
- const attrs = {
- ...getDefaultNodeProperties(node, binding),
- ...el.getAttributes(snapshot),
- };
+ const attrs = el.getAttributes(snapshot);
+ // TODO(collab-v2): support for ychange
/*
if (snapshot !== undefined) {
if (!isVisible(el._item!, snapshot)) {
- // TODO(collab-v2): add type for ychange, store in node state?
attrs.ychange = computeYChange
? computeYChange('removed', el._item!.id)
: {type: 'removed'};
@@ -188,7 +185,9 @@ export const $createOrUpdateNodeFromYElement = (
}
}
*/
- const properties: Record = {};
+ const properties: Record = {
+ ...getDefaultNodeProperties(node, binding),
+ };
const state: Record = {};
for (const k in attrs) {
if (k.startsWith(STATE_KEY_PREFIX)) {
@@ -680,7 +679,7 @@ export const $updateYFragment = (
...stateToAttributes(node),
};
for (const key in lexicalAttrs) {
- if (lexicalAttrs[key] !== null) {
+ if (lexicalAttrs[key] != null) {
if (yDomAttrs[key] !== lexicalAttrs[key] && key !== 'ychange') {
// TODO(collab-v2): typing for XmlElement generic
// eslint-disable-next-line @typescript-eslint/no-explicit-any
From 1080cf032261aa8f54551fd694033ef3ca0f7ddb Mon Sep 17 00:00:00 2001
From: James Fitzsimmons
Date: Thu, 11 Sep 2025 11:30:39 +1000
Subject: [PATCH 10/21] node state
---
packages/lexical-yjs/src/SyncV2.ts | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/packages/lexical-yjs/src/SyncV2.ts b/packages/lexical-yjs/src/SyncV2.ts
index b347df91fd8..3a55e6b195b 100644
--- a/packages/lexical-yjs/src/SyncV2.ts
+++ b/packages/lexical-yjs/src/SyncV2.ts
@@ -318,8 +318,15 @@ const $createTextNodesFromYText = (
...getDefaultNodeProperties(node, binding),
...attributes.p,
};
+ const state = Object.fromEntries(
+ Object.entries(attributes)
+ .filter(([k]) => k.startsWith(STATE_KEY_PREFIX))
+ .map(([k, v]) => [attrKeyToStateKey(k), v]),
+ );
$syncPropertiesFromYjs(binding, properties, node, null);
+ $getWritableNodeState(node).updateFromJSON(state);
}
+
const latestNodes = nodes.map((node) => node.getLatest());
binding.mapping.set(text, latestNodes);
return latestNodes;
From 586b6af286db87322b5677a4e48b0ad617ef9390 Mon Sep 17 00:00:00 2001
From: James Fitzsimmons
Date: Thu, 11 Sep 2025 16:27:15 +1000
Subject: [PATCH 11/21] more targeted updates of properties/state
---
.../src/shared/useYjsCollaboration.tsx | 3 +-
packages/lexical-yjs/src/CollabV2Mapping.ts | 8 +
packages/lexical-yjs/src/SyncEditorStates.ts | 85 +++++++----
packages/lexical-yjs/src/SyncV2.ts | 142 +++++++++---------
4 files changed, 136 insertions(+), 102 deletions(-)
diff --git a/packages/lexical-react/src/shared/useYjsCollaboration.tsx b/packages/lexical-react/src/shared/useYjsCollaboration.tsx
index 6a7b53352e6..807610c779f 100644
--- a/packages/lexical-react/src/shared/useYjsCollaboration.tsx
+++ b/packages/lexical-react/src/shared/useYjsCollaboration.tsx
@@ -176,13 +176,14 @@ export function useYjsCollaborationV2__EXPERIMENTAL(
const {root} = binding;
const {awareness} = provider;
- const onYjsTreeChanges: OnYjsTreeChanges = (_events, transaction) => {
+ const onYjsTreeChanges: OnYjsTreeChanges = (events, transaction) => {
const origin = transaction.origin;
if (origin !== binding) {
const isFromUndoManger = origin instanceof UndoManager;
syncYjsChangesToLexicalV2__EXPERIMENTAL(
binding,
provider,
+ events,
transaction,
isFromUndoManger,
);
diff --git a/packages/lexical-yjs/src/CollabV2Mapping.ts b/packages/lexical-yjs/src/CollabV2Mapping.ts
index 6a67ed53e54..d9e854438cb 100644
--- a/packages/lexical-yjs/src/CollabV2Mapping.ts
+++ b/packages/lexical-yjs/src/CollabV2Mapping.ts
@@ -97,6 +97,14 @@ export class CollabV2Mapping {
this._sharedTypeToNodeKeys.delete(sharedType);
}
+ deleteNode(nodeKey: NodeKey): void {
+ const sharedType = this._nodeKeyToSharedType.get(nodeKey);
+ if (sharedType) {
+ this.delete(sharedType);
+ }
+ this._nodeMap.delete(nodeKey);
+ }
+
has(sharedType: SharedType): boolean {
return this._sharedTypeToNodeKeys.has(sharedType);
}
diff --git a/packages/lexical-yjs/src/SyncEditorStates.ts b/packages/lexical-yjs/src/SyncEditorStates.ts
index c9a5d830c71..ce5243a2cf4 100644
--- a/packages/lexical-yjs/src/SyncEditorStates.ts
+++ b/packages/lexical-yjs/src/SyncEditorStates.ts
@@ -7,11 +7,7 @@
*/
import type {EditorState, NodeKey} from 'lexical';
-import type {
- AbstractType as YAbstractType,
- ContentType,
- Transaction as YTransaction,
-} from 'yjs';
+import type {ContentType, Transaction as YTransaction} from 'yjs';
import {
$addUpdateTag,
@@ -26,6 +22,7 @@ import {
HISTORIC_TAG,
SKIP_SCROLL_INTO_VIEW_TAG,
} from 'lexical';
+import {YXmlElement, YXmlText} from 'node_modules/yjs/dist/src/internals';
import invariant from 'shared/invariant';
import {
Item,
@@ -323,47 +320,67 @@ export function syncLexicalUpdateToYjs(
});
}
-function $syncV2XmlElement(
+function $syncEventV2(
binding: BindingV2,
- transaction: YTransaction,
+ event: YEvent,
): void {
- iterateDeletedStructs(transaction, transaction.deleteSet, (struct) => {
- if (struct.constructor === Item) {
- const content = struct.content as ContentType;
- const type = content.type;
- if (type) {
- binding.mapping.delete(type as XmlElement | XmlText);
- }
- }
- });
-
- const dirtyElements = new Set();
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const collectDirtyElements = (_value: unknown, type: YAbstractType) => {
- const elementType = type instanceof XmlElement;
- if (elementType && binding.mapping.has(type)) {
- const node = binding.mapping.get(type)!;
- dirtyElements.add(node.getKey());
+ const {target} = event;
+ if (target instanceof XmlElement && event instanceof YXmlEvent) {
+ $createOrUpdateNodeFromYElement(
+ target,
+ binding,
+ event.attributesChanged,
+ // @ts-expect-error childListChanged is private
+ event.childListChanged,
+ );
+ } else if (target instanceof XmlText && event instanceof YTextEvent) {
+ const parent = target.parent;
+ if (parent instanceof XmlElement) {
+ // Need to sync via parent element in order to attach new next nodes.
+ $createOrUpdateNodeFromYElement(parent, binding, new Set(), true);
+ } else {
+ invariant(false, 'Expected XmlElement parent for XmlText');
}
- };
- transaction.changed.forEach(collectDirtyElements);
- transaction.changedParentTypes.forEach(collectDirtyElements);
-
- $createOrUpdateNodeFromYElement(binding.root, binding, dirtyElements);
+ } else {
+ invariant(false, 'Expected xml or text event');
+ }
}
export function syncYjsChangesToLexicalV2__EXPERIMENTAL(
binding: BindingV2,
provider: Provider,
+ events: Array>,
transaction: YTransaction,
isFromUndoManger: boolean,
): void {
const editor = binding.editor;
const editorState = editor._editorState;
+ // Remove deleted nodes from the mapping
+ iterateDeletedStructs(transaction, transaction.deleteSet, (struct) => {
+ if (struct.constructor === Item) {
+ const content = struct.content as ContentType;
+ const type = content.type;
+ if (type) {
+ binding.mapping.delete(type as XmlElement | XmlText);
+ }
+ }
+ });
+
+ // This line precompute the delta before editor update. The reason is
+ // delta is computed when it is accessed. Note that this can only be
+ // safely computed during the event call. If it is accessed after event
+ // call it might result in unexpected behavior.
+ // https://github.com/yjs/yjs/blob/00ef472d68545cb260abd35c2de4b3b78719c9e4/src/utils/YEvent.js#L132
+ events.forEach((event) => event.delta);
+
editor.update(
() => {
- $syncV2XmlElement(binding, transaction);
+ for (let i = 0; i < events.length; i++) {
+ const event = events[i];
+ $syncEventV2(binding, event);
+ }
+
$syncCursorFromYjs(editorState, binding, provider);
if (!isFromUndoManger) {
@@ -373,7 +390,7 @@ export function syncYjsChangesToLexicalV2__EXPERIMENTAL(
}
},
{
- // Need any text node normalisation to be synchronously updated back to Yjs, otherwise the
+ // Need any text node normalization to be synchronously updated back to Yjs, otherwise the
// binding.mapping will get out of sync.
discrete: true,
onUpdate: () => {
@@ -400,6 +417,12 @@ export function syncLexicalUpdateToYjsV2__EXPERIMENTAL(
return;
}
+ // Nodes are normalized synchronously (`discrete: true` above), so the mapping may now be
+ // incorrect for these nodes, as they point to `getLatest` which is mutable within an update.
+ normalizedNodes.forEach((nodeKey) => {
+ binding.mapping.deleteNode(nodeKey);
+ });
+
syncWithTransaction(binding, () => {
currEditorState.read(() => {
if (dirtyElements.has('root')) {
diff --git a/packages/lexical-yjs/src/SyncV2.ts b/packages/lexical-yjs/src/SyncV2.ts
index 3a55e6b195b..743014fbb68 100644
--- a/packages/lexical-yjs/src/SyncV2.ts
+++ b/packages/lexical-yjs/src/SyncV2.ts
@@ -40,7 +40,6 @@ import simpleDiffWithCursor from 'shared/simpleDiffWithCursor';
import {
ContentFormat,
ContentString,
- ContentType,
Doc as YDoc,
ID,
Snapshot,
@@ -90,77 +89,17 @@ const isRootElement = (el: XmlElement): boolean => el.nodeName === 'UNDEFINED';
export const $createOrUpdateNodeFromYElement = (
el: XmlElement,
binding: BindingV2,
- dirtyElements: Set,
+ keysChanged: Set | null,
+ childListChanged: boolean,
snapshot?: Snapshot,
prevSnapshot?: Snapshot,
computeYChange?: ComputeYChange,
): LexicalNode | null => {
let node = binding.mapping.get(el);
- if (node && !dirtyElements.has(node.getKey())) {
+ if (node && keysChanged && keysChanged.size === 0 && !childListChanged) {
return node;
}
- const children: LexicalNode[] = [];
- const $createChildren = (type: XmlElement | XmlText | XmlHook) => {
- if (type instanceof XmlElement) {
- const n = $createOrUpdateNodeFromYElement(
- type,
- binding,
- dirtyElements,
- snapshot,
- prevSnapshot,
- computeYChange,
- );
- if (n !== null) {
- children.push(n);
- }
- } else if (type instanceof XmlText) {
- // If the next ytext exists and was created by us, move the content to the current ytext.
- // This is a fix for y-prosemirror #160 -- duplication of characters when two YText exist
- // next to each other.
- // eslint-disable-next-line lexical/no-optional-chaining
- const content = type._item!.right?.content as ContentType | undefined;
- // eslint-disable-next-line lexical/no-optional-chaining
- const nextytext = content?.type;
- if (
- nextytext instanceof YText &&
- !nextytext._item!.deleted &&
- nextytext._item!.id.client === nextytext.doc!.clientID
- ) {
- type.applyDelta([{retain: type.length}, ...nextytext.toDelta()]);
- nextytext.doc!.transact((tr) => {
- nextytext._item!.delete(tr);
- });
- }
- // now create the text nodes
- const ns = $createTextNodesFromYText(
- type,
- binding,
- snapshot,
- prevSnapshot,
- computeYChange,
- );
- if (ns !== null) {
- ns.forEach((textchild) => {
- if (textchild !== null) {
- children.push(textchild);
- }
- });
- }
- } else {
- invariant(false, 'XmlHook is not supported');
- }
- };
-
- if (snapshot === undefined || prevSnapshot === undefined) {
- el.toArray().forEach($createChildren);
- } else {
- typeListToArraySnapshot(
- el,
- new Snapshot(prevSnapshot.ds, snapshot.sv),
- ).forEach($createChildren);
- }
-
const type = isRootElement(el) ? RootNode.getType() : el.nodeName;
const registeredNodes = binding.editor._nodes;
const nodeInfo = registeredNodes.get(type);
@@ -169,7 +108,61 @@ export const $createOrUpdateNodeFromYElement = (
`$createOrUpdateNodeFromYElement: Node ${type} is not registered`,
);
}
- node = node || new nodeInfo.klass();
+
+ if (!node) {
+ node = new nodeInfo.klass();
+ keysChanged = null;
+ childListChanged = true;
+ }
+
+ if (childListChanged && node instanceof ElementNode) {
+ const children: LexicalNode[] = [];
+ const $createChildren = (childType: XmlElement | XmlText | XmlHook) => {
+ if (childType instanceof XmlElement) {
+ const n = $createOrUpdateNodeFromYElement(
+ childType,
+ binding,
+ new Set(),
+ false,
+ snapshot,
+ prevSnapshot,
+ computeYChange,
+ );
+ if (n !== null) {
+ children.push(n);
+ }
+ } else if (childType instanceof XmlText) {
+ const ns = $createOrUpdateTextNodesFromYText(
+ childType,
+ binding,
+ snapshot,
+ prevSnapshot,
+ computeYChange,
+ );
+ if (ns !== null) {
+ ns.forEach((textchild) => {
+ if (textchild !== null) {
+ children.push(textchild);
+ }
+ });
+ }
+ } else {
+ invariant(false, 'XmlHook is not supported');
+ }
+ };
+
+ if (snapshot === undefined || prevSnapshot === undefined) {
+ el.toArray().forEach($createChildren);
+ } else {
+ typeListToArraySnapshot(
+ el,
+ new Snapshot(prevSnapshot.ds, snapshot.sv),
+ ).forEach($createChildren);
+ }
+
+ $spliceChildren(node, children);
+ }
+
const attrs = el.getAttributes(snapshot);
// TODO(collab-v2): support for ychange
/*
@@ -196,10 +189,13 @@ export const $createOrUpdateNodeFromYElement = (
properties[k] = attrs[k];
}
}
- $syncPropertiesFromYjs(binding, properties, node, null);
- $getWritableNodeState(node).updateFromJSON(state);
- if (node instanceof ElementNode) {
- $spliceChildren(node, children);
+
+ $syncPropertiesFromYjs(binding, properties, node, keysChanged);
+ const updateState =
+ !keysChanged ||
+ !Array.from(keysChanged).some((k) => k.startsWith(STATE_KEY_PREFIX));
+ if (updateState) {
+ $getWritableNodeState(node).updateFromJSON(state);
}
const latestNode = node.getLatest();
@@ -235,6 +231,12 @@ const $spliceChildren = (node: ElementNode, nextChildren: LexicalNode[]) => {
const prevHasNextKey = prevChildrenKeySet.has(nextKey);
if (!nextHasPrevKey) {
+ // If removing the last node, insert remaining new nodes immediately, otherwise if the node
+ // cannot be empty, it will remove itself from its parent.
+ if (nextIndex === 0 && node.getChildrenSize() === 1) {
+ node.splice(nextIndex, 1, nextChildren.slice(nextIndex));
+ return;
+ }
// Remove
node.splice(nextIndex, 1, []);
prevIndex++;
@@ -267,7 +269,7 @@ const $spliceChildren = (node: ElementNode, nextChildren: LexicalNode[]) => {
}
};
-const $createTextNodesFromYText = (
+const $createOrUpdateTextNodesFromYText = (
text: XmlText,
binding: BindingV2,
snapshot?: Snapshot,
From d7b6d2d228402f4413ba9af0ede54dde6d63697c Mon Sep 17 00:00:00 2001
From: James Fitzsimmons
Date: Fri, 12 Sep 2025 13:29:28 +1000
Subject: [PATCH 12/21] pull out toDelta with default attributes
---
packages/lexical-yjs/src/SyncV2.ts | 46 ++++++++++++++++--------------
1 file changed, 24 insertions(+), 22 deletions(-)
diff --git a/packages/lexical-yjs/src/SyncV2.ts b/packages/lexical-yjs/src/SyncV2.ts
index 743014fbb68..f90254babe3 100644
--- a/packages/lexical-yjs/src/SyncV2.ts
+++ b/packages/lexical-yjs/src/SyncV2.ts
@@ -62,15 +62,8 @@ type TextAttributes = {
t?: string; // type if not TextNode
p?: Record; // properties
[key: `s_${string}`]: unknown; // state
- y: {idx: number};
- ychange?: Record;
-};
-
-// Used for resolving concurrent deletes.
-const DEFAULT_TEXT_ATTRIBUTES: TextAttributes = {
- p: {},
- t: TextNode.getType(),
- y: {idx: 0},
+ y?: {i: number};
+ // ychange?: Record;
};
/*
@@ -276,12 +269,7 @@ const $createOrUpdateTextNodesFromYText = (
prevSnapshot?: Snapshot,
computeYChange?: ComputeYChange,
): Array | null => {
- const deltas: {insert: string; attributes: TextAttributes}[] = text
- .toDelta(snapshot, prevSnapshot, computeYChange)
- .map((delta: {insert: string; attributes?: TextAttributes}) => ({
- ...delta,
- attributes: delta.attributes ?? DEFAULT_TEXT_ATTRIBUTES,
- }));
+ const deltas = toDelta(text, snapshot, prevSnapshot, computeYChange);
const nodeTypes: string[] = deltas.map(
(delta) => delta.attributes!.t ?? TextNode.getType(),
);
@@ -417,11 +405,11 @@ const normalizeNodeContent = (node: LexicalNode): NormalizedPNodeContent => {
const res: NormalizedPNodeContent = [];
for (let i = 0; i < c.length; i++) {
const n = c[i];
- if (n instanceof TextNode) {
+ if ($isTextNode(n)) {
const textNodes: TextNode[] = [];
for (
let maybeTextNode = c[i];
- i < c.length && maybeTextNode instanceof TextNode;
+ i < c.length && $isTextNode(maybeTextNode);
maybeTextNode = c[++i]
) {
textNodes.push(maybeTextNode);
@@ -440,10 +428,10 @@ const equalYTextLText = (
ltexts: TextNode[],
binding: BindingV2,
) => {
- const delta = ytext.toDelta();
+ const deltas = toDelta(ytext);
return (
- delta.length === ltexts.length &&
- delta.every(
+ deltas.length === ltexts.length &&
+ deltas.every(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(d: any, i: number) =>
d.insert === ltexts[i].getTextContent() &&
@@ -580,7 +568,7 @@ const $updateYText = (
) => {
binding.mapping.set(ytext, ltexts);
const {nAttrs, str} = ytextTrans(ytext);
- const content = ltexts.map((node, idx) => {
+ const content = ltexts.map((node, i) => {
const nodeType = node.getType();
let p: TextAttributes['p'] | null = propertiesToAttributes(node, binding);
if (Object.keys(p).length === 0) {
@@ -592,7 +580,7 @@ const $updateYText = (
p,
...stateToAttributes(node),
// TODO(collab-v2): can probably be more targeted here
- y: {idx}, // Prevent Yjs from merging text nodes itself.
+ ...(ltexts.length > 1 && {y: {i}}), // Prevent Yjs from merging text nodes itself.
}),
insert: node.getTextContent(),
nodeKey: node.getKey(),
@@ -627,6 +615,20 @@ const $updateYText = (
);
};
+const toDelta = (
+ ytext: YText,
+ snapshot?: Snapshot,
+ prevSnapshot?: Snapshot,
+ computeYChange?: ComputeYChange,
+): Array<{insert: string; attributes: TextAttributes}> => {
+ return ytext
+ .toDelta(snapshot, prevSnapshot, computeYChange)
+ .map((delta: {insert: string; attributes?: TextAttributes}) => ({
+ ...delta,
+ attributes: delta.attributes ?? {},
+ }));
+};
+
const propertiesToAttributes = (node: LexicalNode, meta: BindingV2) => {
const defaultProperties = getDefaultNodeProperties(node, meta);
const attrs: Record = {};
From 9fffb0c30c1885ba48832dc77ac4adee9ee17e3d Mon Sep 17 00:00:00 2001
From: James Fitzsimmons
Date: Fri, 12 Sep 2025 14:22:26 +1000
Subject: [PATCH 13/21] little bit more cleanup
---
packages/lexical-yjs/src/SyncV2.ts | 53 +++++++++++++++---------------
1 file changed, 27 insertions(+), 26 deletions(-)
diff --git a/packages/lexical-yjs/src/SyncV2.ts b/packages/lexical-yjs/src/SyncV2.ts
index f90254babe3..01ca1db666b 100644
--- a/packages/lexical-yjs/src/SyncV2.ts
+++ b/packages/lexical-yjs/src/SyncV2.ts
@@ -62,7 +62,7 @@ type TextAttributes = {
t?: string; // type if not TextNode
p?: Record; // properties
[key: `s_${string}`]: unknown; // state
- y?: {i: number};
+ i?: number; // used to prevent Yjs from merging text nodes itself
// ychange?: Record;
};
@@ -270,14 +270,18 @@ const $createOrUpdateTextNodesFromYText = (
computeYChange?: ComputeYChange,
): Array | null => {
const deltas = toDelta(text, snapshot, prevSnapshot, computeYChange);
+
+ // Use existing text nodes if the count and types all align, otherwise throw out the existing
+ // nodes and create new ones.
+ let nodes: TextNode[] = binding.mapping.get(text) ?? [];
+
const nodeTypes: string[] = deltas.map(
- (delta) => delta.attributes!.t ?? TextNode.getType(),
+ (delta) => delta.attributes.t ?? TextNode.getType(),
);
- let nodes: TextNode[] = binding.mapping.get(text) ?? [];
- if (
- nodes.length !== nodeTypes.length ||
- nodes.some((node, i) => node.getType() !== nodeTypes[i])
- ) {
+ const canReuseNodes =
+ nodes.length === nodeTypes.length &&
+ nodes.every((node, i) => node.getType() === nodeTypes[i]);
+ if (!canReuseNodes) {
const registeredNodes = binding.editor._nodes;
nodes = nodeTypes.map((type) => {
const nodeInfo = registeredNodes.get(type);
@@ -431,24 +435,22 @@ const equalYTextLText = (
const deltas = toDelta(ytext);
return (
deltas.length === ltexts.length &&
- deltas.every(
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- (d: any, i: number) =>
- d.insert === ltexts[i].getTextContent() &&
- d.attributes.t === ltexts[i].getType() &&
- equalAttrs(
- d.attributes.p ?? {},
- propertiesToAttributes(ltexts[i], binding),
- ) &&
- equalAttrs(
- Object.fromEntries(
- Object.entries(d.attributes)
- .filter(([k]) => k.startsWith(STATE_KEY_PREFIX))
- .map(([k, v]) => [attrKeyToStateKey(k), v]),
- ),
- stateToAttributes(ltexts[i]),
+ deltas.every((d, i) => {
+ const ltext = ltexts[i];
+ const type = d.attributes.t ?? TextNode.getType();
+ const propertyAttrs = d.attributes.p ?? {};
+ const stateAttrs = Object.fromEntries(
+ Object.entries(d.attributes).filter(([k]) =>
+ k.startsWith(STATE_KEY_PREFIX),
),
- )
+ );
+ return (
+ d.insert === ltext.getTextContent() &&
+ type === ltext.getType() &&
+ equalAttrs(propertyAttrs, propertiesToAttributes(ltext, binding)) &&
+ equalAttrs(stateAttrs, stateToAttributes(ltext))
+ );
+ })
);
};
@@ -579,8 +581,7 @@ const $updateYText = (
...(nodeType !== TextNode.getType() && {t: nodeType}),
p,
...stateToAttributes(node),
- // TODO(collab-v2): can probably be more targeted here
- ...(ltexts.length > 1 && {y: {i}}), // Prevent Yjs from merging text nodes itself.
+ ...(i > 0 && {i}), // Prevent Yjs from merging text nodes itself.
}),
insert: node.getTextContent(),
nodeKey: node.getKey(),
From 2892aed8152e127105a7d91033f927aa00d5a318 Mon Sep 17 00:00:00 2001
From: James Fitzsimmons
Date: Tue, 16 Sep 2025 13:34:36 +1000
Subject: [PATCH 14/21] pass in doc+provider in v2 plugin
---
packages/lexical-playground/src/Editor.tsx | 38 ++++-
.../lexical-playground/src/collaboration.ts | 4 +
.../src/LexicalCollaborationPlugin.tsx | 158 ++++--------------
.../src/__tests__/unit/utils.tsx | 4 +-
.../src/shared/useYjsCollaboration.tsx | 157 +++++++++--------
5 files changed, 160 insertions(+), 201 deletions(-)
diff --git a/packages/lexical-playground/src/Editor.tsx b/packages/lexical-playground/src/Editor.tsx
index 55ccc45f6f2..c636ac0ba13 100644
--- a/packages/lexical-playground/src/Editor.tsx
+++ b/packages/lexical-playground/src/Editor.tsx
@@ -30,10 +30,13 @@ import {TabIndentationPlugin} from '@lexical/react/LexicalTabIndentationPlugin';
import {TablePlugin} from '@lexical/react/LexicalTablePlugin';
import {useLexicalEditable} from '@lexical/react/useLexicalEditable';
import {CAN_USE_DOM} from '@lexical/utils';
-import * as React from 'react';
-import {useEffect, useState} from 'react';
+import {useEffect, useMemo, useState} from 'react';
+import {Doc} from 'yjs';
-import {createWebsocketProvider} from './collaboration';
+import {
+ createWebsocketProvider,
+ createWebsocketProviderWithDoc,
+} from './collaboration';
import {useSettings} from './context/SettingsContext';
import {useSharedHistoryContext} from './context/SharedHistoryContext';
import ActionsPlugin from './plugins/ActionsPlugin';
@@ -190,11 +193,7 @@ export default function Editor(): JSX.Element {
<>
{isCollab ? (
useCollabV2 ? (
-
+
) : (
);
}
+
+function CollabV2({
+ id,
+ shouldBootstrap,
+}: {
+ id: string;
+ shouldBootstrap: boolean;
+}) {
+ const doc = useMemo(() => new Doc(), []);
+
+ const provider = useMemo(() => {
+ return createWebsocketProviderWithDoc('main', doc);
+ }, [doc]);
+
+ return (
+
+ );
+}
diff --git a/packages/lexical-playground/src/collaboration.ts b/packages/lexical-playground/src/collaboration.ts
index ca22311cdef..e1d41968e24 100644
--- a/packages/lexical-playground/src/collaboration.ts
+++ b/packages/lexical-playground/src/collaboration.ts
@@ -31,6 +31,10 @@ export function createWebsocketProvider(
doc.load();
}
+ return createWebsocketProviderWithDoc(id, doc);
+}
+
+export function createWebsocketProviderWithDoc(id: string, doc: Doc): Provider {
// @ts-expect-error
return new WebsocketProvider(
WEBSOCKET_ENDPOINT,
diff --git a/packages/lexical-react/src/LexicalCollaborationPlugin.tsx b/packages/lexical-react/src/LexicalCollaborationPlugin.tsx
index c3b7c468083..113a2f36051 100644
--- a/packages/lexical-react/src/LexicalCollaborationPlugin.tsx
+++ b/packages/lexical-react/src/LexicalCollaborationPlugin.tsx
@@ -7,7 +7,6 @@
*/
import type {JSX} from 'react';
-import type {Doc} from 'yjs';
import {
type CollaborationContextType,
@@ -16,22 +15,21 @@ import {
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {
Binding,
- BindingV2,
createBinding,
- createBindingV2__EXPERIMENTAL,
ExcludedProperties,
Provider,
SyncCursorPositionsFn,
} from '@lexical/yjs';
import {LexicalEditor} from 'lexical';
import {useEffect, useRef, useState} from 'react';
-import invariant from 'shared/invariant';
+import {Doc} from 'yjs';
import {InitialEditorStateType} from './LexicalComposer';
import {
CursorsContainerRef,
useYjsCollaboration,
useYjsCollaborationV2__EXPERIMENTAL,
+ useYjsCursors,
useYjsFocusTracking,
useYjsHistory,
useYjsHistoryV2,
@@ -66,16 +64,34 @@ export function CollaborationPlugin({
syncCursorPositionsFn,
}: CollaborationPluginProps): JSX.Element {
const isBindingInitialized = useRef(false);
+ const isProviderInitialized = useRef(false);
const collabContext = useCollaborationContext(username, cursorColor);
-
const {yjsDocMap, name, color} = collabContext;
const [editor] = useLexicalComposerContext();
+
useCollabActive(collabContext, editor);
+ const [provider, setProvider] = useState();
const [doc, setDoc] = useState();
- const provider = useProvider(id, yjsDocMap, setDoc, providerFactory);
+
+ useEffect(() => {
+ if (isProviderInitialized.current) {
+ return;
+ }
+
+ isProviderInitialized.current = true;
+
+ const newProvider = providerFactory(id, yjsDocMap);
+ setProvider(newProvider);
+ setDoc(yjsDocMap.get(id));
+
+ return () => {
+ newProvider.disconnect();
+ };
+ }, [id, providerFactory, yjsDocMap]);
+
const [binding, setBinding] = useState();
useEffect(() => {
@@ -185,7 +201,8 @@ function YjsCollaborationCursors({
type CollaborationPluginV2Props = {
id: string;
- providerFactory: ProviderFactory;
+ doc: Doc;
+ provider: Provider;
shouldBootstrap: boolean;
username?: string;
cursorColor?: string;
@@ -197,7 +214,8 @@ type CollaborationPluginV2Props = {
export function CollaborationPluginV2__EXPERIMENTAL({
id,
- providerFactory,
+ doc,
+ provider,
shouldBootstrap,
username,
cursorColor,
@@ -205,110 +223,30 @@ export function CollaborationPluginV2__EXPERIMENTAL({
excludedProperties,
awarenessData,
}: CollaborationPluginV2Props): JSX.Element {
- const isBindingInitialized = useRef(false);
-
const collabContext = useCollaborationContext(username, cursorColor);
-
const {yjsDocMap, name, color} = collabContext;
const [editor] = useLexicalComposerContext();
- if (editor._parentEditor !== null) {
- invariant(false, 'Collaboration V2 cannot be used with a nested editor');
- }
-
useCollabActive(collabContext, editor);
- const [doc, setDoc] = useState();
- const provider = useProvider(id, yjsDocMap, setDoc, providerFactory);
- const [binding, setBinding] = useState();
-
- useEffect(() => {
- if (!provider || isBindingInitialized.current) {
- return;
- }
-
- isBindingInitialized.current = true;
- const newBinding = createBindingV2__EXPERIMENTAL(
- editor,
- id,
- doc || yjsDocMap.get(id),
- yjsDocMap,
- {excludedProperties},
- );
- setBinding(newBinding);
- }, [editor, provider, id, yjsDocMap, doc, excludedProperties]);
-
- if (!provider || !binding) {
- return <>>;
- }
-
- return (
-
- );
-}
-
-function YjsCollaborationCursorsV2__EXPERIMENTAL({
- editor,
- id,
- provider,
- yjsDocMap,
- name,
- color,
- shouldBootstrap,
- cursorsContainerRef,
- awarenessData,
- collabContext,
- binding,
- setDoc,
-}: {
- editor: LexicalEditor;
- id: string;
- provider: Provider;
- yjsDocMap: Map;
- name: string;
- color: string;
- shouldBootstrap: boolean;
- binding: BindingV2;
- setDoc: React.Dispatch>;
- cursorsContainerRef?: CursorsContainerRef | undefined;
- initialEditorState?: InitialEditorStateType | undefined;
- awarenessData?: object;
- collabContext: CollaborationContextType;
- syncCursorPositionsFn?: SyncCursorPositionsFn;
-}) {
- const cursors = useYjsCollaborationV2__EXPERIMENTAL(
+ const binding = useYjsCollaborationV2__EXPERIMENTAL(
editor,
id,
+ doc,
provider,
yjsDocMap,
name,
color,
- shouldBootstrap,
- binding,
- setDoc,
- cursorsContainerRef,
- awarenessData,
+ {
+ awarenessData,
+ excludedProperties,
+ shouldBootstrap,
+ },
);
- collabContext.clientID = binding.clientID;
-
useYjsHistoryV2(editor, binding);
useYjsFocusTracking(editor, provider, name, color, awarenessData);
-
- return cursors;
+ return useYjsCursors(binding, cursorsContainerRef);
}
const useCollabActive = (
@@ -327,31 +265,3 @@ const useCollabActive = (
};
}, [collabContext, editor]);
};
-
-const useProvider = (
- id: string,
- yjsDocMap: Map,
- setDoc: React.Dispatch>,
- providerFactory: ProviderFactory,
-) => {
- const isProviderInitialized = useRef(false);
- const [provider, setProvider] = useState();
-
- useEffect(() => {
- if (isProviderInitialized.current) {
- return;
- }
-
- isProviderInitialized.current = true;
-
- const newProvider = providerFactory(id, yjsDocMap);
- setProvider(newProvider);
- setDoc(yjsDocMap.get(id));
-
- return () => {
- newProvider.disconnect();
- };
- }, [id, providerFactory, yjsDocMap, setDoc]);
-
- return provider;
-};
diff --git a/packages/lexical-react/src/__tests__/unit/utils.tsx b/packages/lexical-react/src/__tests__/unit/utils.tsx
index f5f2f48c2f8..9ae94dc8da6 100644
--- a/packages/lexical-react/src/__tests__/unit/utils.tsx
+++ b/packages/lexical-react/src/__tests__/unit/utils.tsx
@@ -18,7 +18,6 @@ import {LexicalErrorBoundary} from '@lexical/react/LexicalErrorBoundary';
import {RichTextPlugin} from '@lexical/react/LexicalRichTextPlugin';
import {Provider, UserState} from '@lexical/yjs';
import {LexicalEditor} from 'lexical';
-import * as React from 'react';
import {Container} from 'react-dom';
import {createRoot, Root} from 'react-dom/client';
import * as ReactTestUtils from 'shared/react-test-utils';
@@ -55,7 +54,8 @@ function Editor({
{useCollabV2 ? (
provider}
+ doc={doc}
+ provider={provider}
shouldBootstrap={shouldBootstrapEditor}
awarenessData={awarenessData}
/>
diff --git a/packages/lexical-react/src/shared/useYjsCollaboration.tsx b/packages/lexical-react/src/shared/useYjsCollaboration.tsx
index 807610c779f..ece17082af8 100644
--- a/packages/lexical-react/src/shared/useYjsCollaboration.tsx
+++ b/packages/lexical-react/src/shared/useYjsCollaboration.tsx
@@ -10,6 +10,7 @@ import type {
BaseBinding,
Binding,
BindingV2,
+ ExcludedProperties,
Provider,
SyncCursorPositionsFn,
} from '@lexical/yjs';
@@ -19,6 +20,7 @@ import type {JSX} from 'react';
import {mergeRegister} from '@lexical/utils';
import {
CONNECTED_COMMAND,
+ createBindingV2__EXPERIMENTAL,
createUndoManager,
initLocalState,
setLocalStateFocus,
@@ -74,6 +76,8 @@ export function useYjsCollaboration(
awarenessData?: object,
syncCursorPositionsFn: SyncCursorPositionsFn = syncCursorPositions,
): JSX.Element {
+ const isReloadingDoc = useRef(false);
+
const onBootstrap = useCallback(() => {
const {root} = binding;
if (shouldBootstrap && root.isEmpty() && root._xmlText._length === 0) {
@@ -83,7 +87,6 @@ export function useYjsCollaboration(
useEffect(() => {
const {root} = binding;
- const {awareness} = provider;
const onYjsTreeChanges: OnYjsTreeChanges = (events, transaction) => {
const origin = transaction.origin;
@@ -125,46 +128,84 @@ export function useYjsCollaboration(
},
);
- const onAwarenessUpdate = () => {
- syncCursorPositionsFn(binding, provider);
+ return () => {
+ root.getSharedType().unobserveDeep(onYjsTreeChanges);
+ removeListener();
};
- awareness.on('update', onAwarenessUpdate);
+ }, [binding, provider, editor, setDoc, docMap, id, syncCursorPositionsFn]);
+
+ // Note: 'reload' is not an actual Yjs event type. Included here for legacy support (#1409).
+ useEffect(() => {
+ const onProviderDocReload = (ydoc: Doc) => {
+ clearEditorSkipCollab(editor, binding);
+ setDoc(ydoc);
+ docMap.set(id, ydoc);
+ isReloadingDoc.current = true;
+ };
+
+ const onSync = () => {
+ isReloadingDoc.current = false;
+ };
+
+ provider.on('reload', onProviderDocReload);
+ provider.on('sync', onSync);
return () => {
- binding.root.getSharedType().unobserveDeep(onYjsTreeChanges);
- removeListener();
- awareness.off('update', onAwarenessUpdate);
+ provider.off('reload', onProviderDocReload);
+ provider.off('sync', onSync);
};
- }, [binding, provider, editor, syncCursorPositionsFn]);
+ }, [binding, provider, editor, setDoc, docMap, id]);
- return useYjsCollaborationInternal(
+ useProvider(
editor,
- id,
provider,
- docMap,
name,
color,
- binding,
- setDoc,
- cursorsContainerRef,
+ isReloadingDoc,
awarenessData,
onBootstrap,
);
+
+ return useYjsCursors(binding, cursorsContainerRef);
}
export function useYjsCollaborationV2__EXPERIMENTAL(
editor: LexicalEditor,
id: string,
+ doc: Doc,
provider: Provider,
docMap: Map,
name: string,
color: string,
- shouldBootstrap: boolean,
- binding: BindingV2,
- setDoc: React.Dispatch>,
- cursorsContainerRef?: CursorsContainerRef,
- awarenessData?: object,
-) {
+ options: {
+ awarenessData?: object;
+ excludedProperties?: ExcludedProperties;
+ rootName?: string;
+ shouldBootstrap?: boolean;
+ } = {},
+): BindingV2 {
+ const {awarenessData, excludedProperties, rootName, shouldBootstrap} =
+ options;
+
+ // Note: v2 does not support 'reload' event, which is not an actual Yjs event type.
+ const isReloadingDoc = useMemo(() => ({current: false}), []);
+
+ const binding = useMemo(
+ () =>
+ createBindingV2__EXPERIMENTAL(editor, id, doc, docMap, {
+ excludedProperties,
+ rootName,
+ }),
+ [editor, id, doc, docMap, excludedProperties, rootName],
+ );
+
+ useEffect(() => {
+ docMap.set(id, doc);
+ return () => {
+ docMap.delete(id);
+ };
+ }, [doc, docMap, id]);
+
const onBootstrap = useCallback(() => {
const {root} = binding;
if (shouldBootstrap && root._length === 0) {
@@ -226,36 +267,28 @@ export function useYjsCollaborationV2__EXPERIMENTAL(
};
}, [binding, provider, editor]);
- return useYjsCollaborationInternal(
+ useProvider(
editor,
- id,
provider,
- docMap,
name,
color,
- binding,
- setDoc,
- cursorsContainerRef,
+ isReloadingDoc,
awarenessData,
onBootstrap,
);
+
+ return binding;
}
-function useYjsCollaborationInternal(
+function useProvider(
editor: LexicalEditor,
- id: string,
provider: Provider,
- docMap: Map,
name: string,
color: string,
- binding: T,
- setDoc: React.Dispatch>,
- cursorsContainerRef?: CursorsContainerRef,
+ isReloadingDoc: React.RefObject,
awarenessData?: object,
onBootstrap?: () => void,
-): JSX.Element {
- const isReloadingDoc = useRef(false);
-
+): void {
const connect = useCallback(() => provider.connect(), [provider]);
const disconnect = useCallback(() => {
@@ -275,8 +308,6 @@ function useYjsCollaborationInternal(
if (isSynced && isReloadingDoc.current === false && onBootstrap) {
onBootstrap();
}
-
- isReloadingDoc.current = false;
};
initLocalState(
@@ -287,20 +318,13 @@ function useYjsCollaborationInternal(
awarenessData || {},
);
- const onProviderDocReload = (ydoc: Doc) => {
- clearEditorSkipCollab(editor, binding);
- setDoc(ydoc);
- docMap.set(id, ydoc);
- isReloadingDoc.current = true;
- };
-
- provider.on('reload', onProviderDocReload);
provider.on('status', onStatus);
provider.on('sync', onSync);
const connectionPromise = connect();
return () => {
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- expected that isReloadingDoc.current may change
if (isReloadingDoc.current === false) {
if (connectionPromise) {
connectionPromise.then(disconnect);
@@ -318,33 +342,18 @@ function useYjsCollaborationInternal(
provider.off('sync', onSync);
provider.off('status', onStatus);
- provider.off('reload', onProviderDocReload);
- docMap.delete(id);
};
}, [
- binding,
- color,
- connect,
- disconnect,
- docMap,
editor,
- id,
- name,
provider,
- onBootstrap,
+ name,
+ color,
+ isReloadingDoc,
awarenessData,
- setDoc,
+ onBootstrap,
+ connect,
+ disconnect,
]);
- const cursorsContainer = useMemo(() => {
- const ref = (element: null | HTMLElement) => {
- binding.cursorsContainer = element;
- };
-
- return createPortal(
- ,
- (cursorsContainerRef && cursorsContainerRef.current) || document.body,
- );
- }, [binding, cursorsContainerRef]);
useEffect(() => {
return editor.registerCommand(
@@ -367,8 +376,22 @@ function useYjsCollaborationInternal(
COMMAND_PRIORITY_EDITOR,
);
}, [connect, disconnect, editor]);
+}
- return cursorsContainer;
+export function useYjsCursors(
+ binding: BaseBinding,
+ cursorsContainerRef?: CursorsContainerRef,
+): JSX.Element {
+ return useMemo(() => {
+ const ref = (element: null | HTMLElement) => {
+ binding.cursorsContainer = element;
+ };
+
+ return createPortal(
+ ,
+ (cursorsContainerRef && cursorsContainerRef.current) || document.body,
+ );
+ }, [binding, cursorsContainerRef]);
}
export function useYjsFocusTracking(
From 13a41c3ed34f41969f06e39bc52f4f704aa93b62 Mon Sep 17 00:00:00 2001
From: James Fitzsimmons
Date: Fri, 19 Sep 2025 12:21:50 +1000
Subject: [PATCH 15/21] __shouldBootstrapUnsafe, getEditorState(), remove ===
false
---
.../lexical-code-shiki/src/CodeHighlighterShiki.ts | 2 +-
packages/lexical-code/src/CodeHighlighterPrism.ts | 2 +-
packages/lexical-playground/src/Editor.tsx | 2 +-
.../src/LexicalCollaborationPlugin.tsx | 6 +++---
.../lexical-react/src/__tests__/unit/utils.tsx | 2 +-
.../src/shared/useYjsCollaboration.tsx | 14 +++++++++-----
6 files changed, 16 insertions(+), 12 deletions(-)
diff --git a/packages/lexical-code-shiki/src/CodeHighlighterShiki.ts b/packages/lexical-code-shiki/src/CodeHighlighterShiki.ts
index d6f1ff450c0..3d847d735f8 100644
--- a/packages/lexical-code-shiki/src/CodeHighlighterShiki.ts
+++ b/packages/lexical-code-shiki/src/CodeHighlighterShiki.ts
@@ -777,7 +777,7 @@ export function registerCodeHighlighting(
editor.registerMutationListener(
CodeNode,
(mutations) => {
- editor.read(() => {
+ editor.getEditorState().read(() => {
for (const [key, type] of mutations) {
if (type !== 'destroyed') {
const node = $getNodeByKey(key);
diff --git a/packages/lexical-code/src/CodeHighlighterPrism.ts b/packages/lexical-code/src/CodeHighlighterPrism.ts
index 9fbd5c6a910..ab487637be8 100644
--- a/packages/lexical-code/src/CodeHighlighterPrism.ts
+++ b/packages/lexical-code/src/CodeHighlighterPrism.ts
@@ -771,7 +771,7 @@ export function registerCodeHighlighting(
editor.registerMutationListener(
CodeNode,
(mutations) => {
- editor.read(() => {
+ editor.getEditorState().read(() => {
for (const [key, type] of mutations) {
if (type !== 'destroyed') {
const node = $getNodeByKey(key);
diff --git a/packages/lexical-playground/src/Editor.tsx b/packages/lexical-playground/src/Editor.tsx
index c636ac0ba13..972d123ed9f 100644
--- a/packages/lexical-playground/src/Editor.tsx
+++ b/packages/lexical-playground/src/Editor.tsx
@@ -317,7 +317,7 @@ function CollabV2({
id={id}
doc={doc}
provider={provider}
- shouldBootstrap={shouldBootstrap}
+ __shouldBootstrapUnsafe={shouldBootstrap}
/>
);
}
diff --git a/packages/lexical-react/src/LexicalCollaborationPlugin.tsx b/packages/lexical-react/src/LexicalCollaborationPlugin.tsx
index 113a2f36051..e0335deaa68 100644
--- a/packages/lexical-react/src/LexicalCollaborationPlugin.tsx
+++ b/packages/lexical-react/src/LexicalCollaborationPlugin.tsx
@@ -203,7 +203,7 @@ type CollaborationPluginV2Props = {
id: string;
doc: Doc;
provider: Provider;
- shouldBootstrap: boolean;
+ __shouldBootstrapUnsafe: boolean;
username?: string;
cursorColor?: string;
cursorsContainerRef?: CursorsContainerRef;
@@ -216,7 +216,7 @@ export function CollaborationPluginV2__EXPERIMENTAL({
id,
doc,
provider,
- shouldBootstrap,
+ __shouldBootstrapUnsafe,
username,
cursorColor,
cursorsContainerRef,
@@ -238,9 +238,9 @@ export function CollaborationPluginV2__EXPERIMENTAL({
name,
color,
{
+ __shouldBootstrapUnsafe,
awarenessData,
excludedProperties,
- shouldBootstrap,
},
);
diff --git a/packages/lexical-react/src/__tests__/unit/utils.tsx b/packages/lexical-react/src/__tests__/unit/utils.tsx
index 9ae94dc8da6..28e477c2280 100644
--- a/packages/lexical-react/src/__tests__/unit/utils.tsx
+++ b/packages/lexical-react/src/__tests__/unit/utils.tsx
@@ -56,8 +56,8 @@ function Editor({
id="main"
doc={doc}
provider={provider}
- shouldBootstrap={shouldBootstrapEditor}
awarenessData={awarenessData}
+ __shouldBootstrapUnsafe={shouldBootstrapEditor}
/>
) : (
{
- if (tags.has(SKIP_COLLAB_TAG) === false) {
+ if (!tags.has(SKIP_COLLAB_TAG)) {
syncLexicalUpdateToYjs(
binding,
provider,
@@ -181,11 +181,15 @@ export function useYjsCollaborationV2__EXPERIMENTAL(
awarenessData?: object;
excludedProperties?: ExcludedProperties;
rootName?: string;
- shouldBootstrap?: boolean;
+ __shouldBootstrapUnsafe?: boolean;
} = {},
): BindingV2 {
- const {awarenessData, excludedProperties, rootName, shouldBootstrap} =
- options;
+ const {
+ awarenessData,
+ excludedProperties,
+ rootName,
+ __shouldBootstrapUnsafe: shouldBootstrap,
+ } = options;
// Note: v2 does not support 'reload' event, which is not an actual Yjs event type.
const isReloadingDoc = useMemo(() => ({current: false}), []);
@@ -241,7 +245,7 @@ export function useYjsCollaborationV2__EXPERIMENTAL(
normalizedNodes,
tags,
}) => {
- if (tags.has(SKIP_COLLAB_TAG) === false) {
+ if (!tags.has(SKIP_COLLAB_TAG)) {
syncLexicalUpdateToYjsV2__EXPERIMENTAL(
binding,
provider,
From 1c5f8bd0006796ac4c1bfa7036b7e56555ffe2d8 Mon Sep 17 00:00:00 2001
From: James Fitzsimmons
Date: Fri, 19 Sep 2025 12:22:08 +1000
Subject: [PATCH 16/21] switch to interface, introduce AnyBinding
---
packages/lexical-yjs/src/Bindings.ts | 17 +++--
packages/lexical-yjs/src/SyncCursors.ts | 86 ++++++++++++-------------
2 files changed, 51 insertions(+), 52 deletions(-)
diff --git a/packages/lexical-yjs/src/Bindings.ts b/packages/lexical-yjs/src/Bindings.ts
index 516aa39f25a..f29bbdbd591 100644
--- a/packages/lexical-yjs/src/Bindings.ts
+++ b/packages/lexical-yjs/src/Bindings.ts
@@ -23,7 +23,7 @@ import {CollabV2Mapping} from './CollabV2Mapping';
import {initializeNodeProperties} from './Utils';
export type ClientID = number;
-export type BaseBinding = {
+export interface BaseBinding {
clientID: number;
cursors: Map;
cursorsContainer: null | HTMLElement;
@@ -33,10 +33,9 @@ export type BaseBinding = {
id: string;
nodeProperties: Map; // node type to property to default value
excludedProperties: ExcludedProperties;
-};
-export type ExcludedProperties = Map, Set>;
+}
-export type Binding = BaseBinding & {
+export interface Binding extends BaseBinding {
collabNodeMap: Map<
NodeKey,
| CollabElementNode
@@ -45,12 +44,16 @@ export type Binding = BaseBinding & {
| CollabLineBreakNode
>;
root: CollabElementNode;
-};
+}
-export type BindingV2 = BaseBinding & {
+export interface BindingV2 extends BaseBinding {
mapping: CollabV2Mapping;
root: XmlElement;
-};
+}
+
+export type AnyBinding = Binding | BindingV2;
+
+export type ExcludedProperties = Map, Set>;
function createBaseBinding(
editor: LexicalEditor,
diff --git a/packages/lexical-yjs/src/SyncCursors.ts b/packages/lexical-yjs/src/SyncCursors.ts
index 3e35dde22ed..640b64829cd 100644
--- a/packages/lexical-yjs/src/SyncCursors.ts
+++ b/packages/lexical-yjs/src/SyncCursors.ts
@@ -36,11 +36,11 @@ import {
import {Provider, UserState} from '.';
import {
+ AnyBinding,
type BaseBinding,
type Binding,
type BindingV2,
isBindingV1,
- isBindingV2,
} from './Bindings';
import {CollabDecoratorNode} from './CollabDecoratorNode';
import {CollabElementNode} from './CollabElementNode';
@@ -393,7 +393,7 @@ export function getAnchorAndFocusCollabNodesForUserState(
}
export function $getAnchorAndFocusForUserState(
- binding: BaseBinding,
+ binding: AnyBinding,
userState: UserState,
): {
anchorKey: NodeKey | null;
@@ -433,52 +433,50 @@ export function $getAnchorAndFocusForUserState(
focusKey: focusCollabNode !== null ? focusCollabNode.getKey() : null,
focusOffset,
};
- } else if (isBindingV2(binding)) {
- let [anchorNode, anchorOffset] = $getNodeAndOffsetV2(
- binding.mapping,
- anchorAbsPos,
- );
- let [focusNode, focusOffset] = $getNodeAndOffsetV2(
- binding.mapping,
- focusAbsPos,
- );
- // For a non-collapsed selection, if the start of the selection is as the end of a text node,
- // move it to the beginning of the next text node (if one exists).
+ }
+
+ let [anchorNode, anchorOffset] = $getNodeAndOffsetV2(
+ binding.mapping,
+ anchorAbsPos,
+ );
+ let [focusNode, focusOffset] = $getNodeAndOffsetV2(
+ binding.mapping,
+ focusAbsPos,
+ );
+ // For a non-collapsed selection, if the start of the selection is as the end of a text node,
+ // move it to the beginning of the next text node (if one exists).
+ if (
+ focusNode &&
+ anchorNode &&
+ (focusNode !== anchorNode || focusOffset !== anchorOffset)
+ ) {
+ const isBackwards = focusNode.isBefore(anchorNode);
+ const startNode = isBackwards ? focusNode : anchorNode;
+ const startOffset = isBackwards ? focusOffset : anchorOffset;
if (
- focusNode &&
- anchorNode &&
- (focusNode !== anchorNode || focusOffset !== anchorOffset)
+ $isTextNode(startNode) &&
+ $isTextNode(startNode.getNextSibling()) &&
+ startOffset === startNode.getTextContentSize()
) {
- const isBackwards = focusNode.isBefore(anchorNode);
- const startNode = isBackwards ? focusNode : anchorNode;
- const startOffset = isBackwards ? focusOffset : anchorOffset;
- if (
- $isTextNode(startNode) &&
- $isTextNode(startNode.getNextSibling()) &&
- startOffset === startNode.getTextContentSize()
- ) {
- if (isBackwards) {
- focusNode = startNode.getNextSibling();
- focusOffset = 0;
- } else {
- anchorNode = startNode.getNextSibling();
- anchorOffset = 0;
- }
+ if (isBackwards) {
+ focusNode = startNode.getNextSibling();
+ focusOffset = 0;
+ } else {
+ anchorNode = startNode.getNextSibling();
+ anchorOffset = 0;
}
}
- return {
- anchorKey: anchorNode !== null ? anchorNode.getKey() : null,
- anchorOffset,
- focusKey: focusNode !== null ? focusNode.getKey() : null,
- focusOffset,
- };
- } else {
- invariant(false, 'getAnchorAndFocusForUserState: unknown binding type');
}
+ return {
+ anchorKey: anchorNode !== null ? anchorNode.getKey() : null,
+ anchorOffset,
+ focusKey: focusNode !== null ? focusNode.getKey() : null,
+ focusOffset,
+ };
}
export function $syncLocalCursorPosition(
- binding: BaseBinding,
+ binding: AnyBinding,
provider: Provider,
): void {
const awareness = provider.awareness;
@@ -619,7 +617,7 @@ function getAwarenessStatesDefault(
}
export function syncCursorPositions(
- binding: BaseBinding,
+ binding: AnyBinding,
provider: Provider,
options?: SyncCursorPositionsOptions,
): void {
@@ -695,7 +693,7 @@ export function syncCursorPositions(
}
export function syncLexicalSelectionToYjs(
- binding: BaseBinding,
+ binding: AnyBinding,
provider: Provider,
prevSelection: null | BaseSelection,
nextSelection: null | BaseSelection,
@@ -731,11 +729,9 @@ export function syncLexicalSelectionToYjs(
if (isBindingV1(binding)) {
anchorPos = createRelativePosition(nextSelection.anchor, binding);
focusPos = createRelativePosition(nextSelection.focus, binding);
- } else if (isBindingV2(binding)) {
+ } else {
anchorPos = createRelativePositionV2(nextSelection.anchor, binding);
focusPos = createRelativePositionV2(nextSelection.focus, binding);
- } else {
- invariant(false, 'syncLexicalSelectionToYjs: unknown binding type');
}
}
From 2b951ea0e8315e13c2dcd6705004535c23b96a5f Mon Sep 17 00:00:00 2001
From: James Fitzsimmons
Date: Fri, 19 Sep 2025 13:45:20 +1000
Subject: [PATCH 17/21] AnyBinding for cursor fn
---
packages/lexical-yjs/src/SyncCursors.ts | 2 +-
packages/lexical-yjs/src/SyncEditorStates.ts | 5 +++--
2 files changed, 4 insertions(+), 3 deletions(-)
diff --git a/packages/lexical-yjs/src/SyncCursors.ts b/packages/lexical-yjs/src/SyncCursors.ts
index 640b64829cd..8c65a65109c 100644
--- a/packages/lexical-yjs/src/SyncCursors.ts
+++ b/packages/lexical-yjs/src/SyncCursors.ts
@@ -597,7 +597,7 @@ function $getNodeAndOffsetV2(
}
export type SyncCursorPositionsFn = (
- binding: BaseBinding,
+ binding: AnyBinding,
provider: Provider,
options?: SyncCursorPositionsOptions,
) => void;
diff --git a/packages/lexical-yjs/src/SyncEditorStates.ts b/packages/lexical-yjs/src/SyncEditorStates.ts
index ce5243a2cf4..b85ffcd3a8c 100644
--- a/packages/lexical-yjs/src/SyncEditorStates.ts
+++ b/packages/lexical-yjs/src/SyncEditorStates.ts
@@ -37,7 +37,8 @@ import {
YXmlEvent,
} from 'yjs';
-import {BaseBinding, Binding, BindingV2, Provider} from '.';
+import {Binding, BindingV2, Provider} from '.';
+import {AnyBinding} from './Bindings';
import {CollabDecoratorNode} from './CollabDecoratorNode';
import {CollabElementNode} from './CollabElementNode';
import {CollabTextNode} from './CollabTextNode';
@@ -176,7 +177,7 @@ export function syncYjsChangesToLexical(
function $syncCursorFromYjs(
editorState: EditorState,
- binding: BaseBinding,
+ binding: AnyBinding,
provider: Provider,
) {
const selection = $getSelection();
From 5d8b4ec39bc5723a08a925d24a296c6624d2e8a6 Mon Sep 17 00:00:00 2001
From: James Fitzsimmons
Date: Tue, 30 Sep 2025 11:23:47 +1000
Subject: [PATCH 18/21] allow for selected class on datetime component
---
.../__tests__/e2e/TextFormatting.spec.mjs | 3 ++-
packages/lexical-playground/__tests__/utils/index.mjs | 6 ++++--
2 files changed, 6 insertions(+), 3 deletions(-)
diff --git a/packages/lexical-playground/__tests__/e2e/TextFormatting.spec.mjs b/packages/lexical-playground/__tests__/e2e/TextFormatting.spec.mjs
index 062309ad457..5da1659a46b 100644
--- a/packages/lexical-playground/__tests__/e2e/TextFormatting.spec.mjs
+++ b/packages/lexical-playground/__tests__/e2e/TextFormatting.spec.mjs
@@ -31,6 +31,7 @@ import {
html,
initialize,
insertDateTime,
+ sleep,
test,
} from '../utils/index.mjs';
@@ -1198,7 +1199,7 @@ test.describe.parallel('TextFormatting', () => {
html`
A
- ${getExpectedDateTimeHtml()}
+ ${getExpectedDateTimeHtml({selected: true})}
BC
`,
diff --git a/packages/lexical-playground/__tests__/utils/index.mjs b/packages/lexical-playground/__tests__/utils/index.mjs
index cfb3258f22c..014792dabcf 100644
--- a/packages/lexical-playground/__tests__/utils/index.mjs
+++ b/packages/lexical-playground/__tests__/utils/index.mjs
@@ -758,7 +758,7 @@ export async function insertDateTime(page) {
await sleep(500);
}
-export function getExpectedDateTimeHtml() {
+export function getExpectedDateTimeHtml({selected = false} = {}) {
const now = new Date();
const date = new Date(now.getFullYear(), now.getMonth(), now.getDate());
return html`
@@ -767,7 +767,9 @@ export function getExpectedDateTimeHtml() {
style="display: inline-block;"
data-lexical-datetime="${date.toString()}"
data-lexical-decorator="true">
-
+
${date.toDateString()}
From edd71291488eb6d5ae34d15894b8eaf3a951bae9 Mon Sep 17 00:00:00 2001
From: James Fitzsimmons
Date: Tue, 30 Sep 2025 11:33:43 +1000
Subject: [PATCH 19/21] fixup! Merge remote-tracking branch 'origin/main' into
yjs-v2-boilerplate
---
packages/lexical-react/src/__tests__/unit/utils.tsx | 2 --
1 file changed, 2 deletions(-)
diff --git a/packages/lexical-react/src/__tests__/unit/utils.tsx b/packages/lexical-react/src/__tests__/unit/utils.tsx
index fc5242eaf51..79e265eee0b 100644
--- a/packages/lexical-react/src/__tests__/unit/utils.tsx
+++ b/packages/lexical-react/src/__tests__/unit/utils.tsx
@@ -22,8 +22,6 @@ import {RichTextPlugin} from '@lexical/react/LexicalRichTextPlugin';
import {Provider, UserState} from '@lexical/yjs';
import {LexicalEditor} from 'lexical';
import * as React from 'react';
-import {Container} from 'react-dom';
-import {createRoot, Root} from 'react-dom/client';
import {type Container, createRoot, type Root} from 'react-dom/client';
import * as ReactTestUtils from 'shared/react-test-utils';
import {expect} from 'vitest';
From e13bd4bf70233248d7f24dd9b4b0ba007ca663a6 Mon Sep 17 00:00:00 2001
From: James Fitzsimmons
Date: Tue, 30 Sep 2025 13:13:22 +1000
Subject: [PATCH 20/21] remove unused import
---
.../lexical-playground/__tests__/e2e/TextFormatting.spec.mjs | 1 -
1 file changed, 1 deletion(-)
diff --git a/packages/lexical-playground/__tests__/e2e/TextFormatting.spec.mjs b/packages/lexical-playground/__tests__/e2e/TextFormatting.spec.mjs
index 5da1659a46b..fa4ccea7879 100644
--- a/packages/lexical-playground/__tests__/e2e/TextFormatting.spec.mjs
+++ b/packages/lexical-playground/__tests__/e2e/TextFormatting.spec.mjs
@@ -31,7 +31,6 @@ import {
html,
initialize,
insertDateTime,
- sleep,
test,
} from '../utils/index.mjs';
From a9d7f44cf5ad2617632f53824e9d2a3a8fe2ad2f Mon Sep 17 00:00:00 2001
From: James Fitzsimmons
Date: Tue, 30 Sep 2025 14:42:47 +1000
Subject: [PATCH 21/21] more targeted state update when syncing from yjs
---
packages/lexical-yjs/src/SyncV2.ts | 15 +++++++++++----
1 file changed, 11 insertions(+), 4 deletions(-)
diff --git a/packages/lexical-yjs/src/SyncV2.ts b/packages/lexical-yjs/src/SyncV2.ts
index 01ca1db666b..c13815c9933 100644
--- a/packages/lexical-yjs/src/SyncV2.ts
+++ b/packages/lexical-yjs/src/SyncV2.ts
@@ -184,11 +184,18 @@ export const $createOrUpdateNodeFromYElement = (
}
$syncPropertiesFromYjs(binding, properties, node, keysChanged);
- const updateState =
- !keysChanged ||
- !Array.from(keysChanged).some((k) => k.startsWith(STATE_KEY_PREFIX));
- if (updateState) {
+ if (!keysChanged) {
$getWritableNodeState(node).updateFromJSON(state);
+ } else {
+ const stateKeysChanged = Object.keys(state).filter((k) =>
+ keysChanged.has(stateKeyToAttrKey(k)),
+ );
+ if (stateKeysChanged.length > 0) {
+ const writableState = $getWritableNodeState(node);
+ for (const k of stateKeysChanged) {
+ writableState.updateFromUnknown(k, state[k]);
+ }
+ }
}
const latestNode = node.getLatest();