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">
  • - -
    - Yellow flower in tilt shift lens -
    -
    + ${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 ![Yellow flower in tilt shift lens](' + @@ -939,6 +943,9 @@ test.describe.parallel('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)![Yellow flower in tilt shift lens](' + @@ -1053,7 +1060,7 @@ test.describe.parallel('Markdown', () => { 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', () => {

- -

- Yellow flower in tilt shift lens -
- + ${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 - -

- Yellow flower in tilt shift lens -
- + ${getExpectedDateTimeHtml()} BC

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

A - -

- Yellow flower in tilt shift lens -
- + ${getExpectedDateTimeHtml()} @@ -1251,18 +1226,7 @@ test.describe.parallel('TextFormatting', () => { html`

A - -

- Yellow flower in tilt shift lens -
- + ${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();