diff --git a/package.json b/package.json index bd7dc67cda7..2e338a295f9 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,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 cb1016263d9..d6917b0c410 100644 --- a/packages/lexical-code-shiki/src/CodeHighlighterShiki.ts +++ b/packages/lexical-code-shiki/src/CodeHighlighterShiki.ts @@ -779,7 +779,7 @@ export function registerCodeHighlighting( editor.registerMutationListener( CodeNode, (mutations) => { - editor.update(() => { + 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 40cfe4d354e..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.update(() => { + editor.getEditorState().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..ea5080da4ff 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(); @@ -636,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-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"> 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 ab0aa46a47c..24f5f61e02d 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 860ec013c75..f75a6d74ac3 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,7 +47,6 @@ import { LEGACY_EVENTS, mergeTableCells, pasteFromClipboard, - SAMPLE_IMAGE_URL, selectCellFromTableCoord, selectCellsFromTableCords, selectFromAdditionalStylesDropdown, @@ -1356,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); @@ -1448,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', }, @@ -1468,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` @@ -1501,18 +1499,7 @@ test.describe.parallel('Tables', () => {

- -

- Yellow flower in tilt shift lens -
- + ${getExpectedDateTimeHtml()} <- it works!

@@ -6703,6 +6690,8 @@ test.describe.parallel('Tables', () => { // undo is used so we need to wait for history await sleep(1050); + 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 c1d77669e7c..2d9c13876ba 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', () => { @@ -1188,32 +1187,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({selected: true})} BC

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

A - -

- Yellow flower in tilt shift lens -
- + ${getExpectedDateTimeHtml()} @@ -1252,18 +1227,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..014792dabcf 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,24 @@ export async function insertDateTime(page) { await sleep(500); } +export function getExpectedDateTimeHtml({selected = false} = {}) { + 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 8f33f071325..b7699aca9b5 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'; @@ -27,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'; @@ -88,6 +94,7 @@ export default function Editor(): JSX.Element { isCodeHighlighted, isCodeShiki, isCollab, + useCollabV2, isAutocomplete, isMaxLength, isCharLimit, @@ -184,11 +191,15 @@ export default function Editor(): JSX.Element { {isRichText ? ( <> {isCollab ? ( - + useCollabV2 ? ( + + ) : ( + + ) ) : ( )} @@ -285,3 +296,26 @@ export default function Editor(): JSX.Element { ); } + +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/appSettings.ts b/packages/lexical-playground/src/appSettings.ts index 5953edb240d..3235c1a1a06 100644 --- a/packages/lexical-playground/src/appSettings.ts +++ b/packages/lexical-playground/src/appSettings.ts @@ -35,6 +35,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-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-playground/src/themes/PlaygroundEditorTheme.ts b/packages/lexical-playground/src/themes/PlaygroundEditorTheme.ts index ba9d9c8d209..7e0fa2ae86f 100644 --- a/packages/lexical-playground/src/themes/PlaygroundEditorTheme.ts +++ b/packages/lexical-playground/src/themes/PlaygroundEditorTheme.ts @@ -85,12 +85,10 @@ const theme: EditorThemeClasses = { ], ul: 'PlaygroundEditorTheme__ul', }, - ltr: 'PlaygroundEditorTheme__ltr', mark: 'PlaygroundEditorTheme__mark', markOverlap: 'PlaygroundEditorTheme__markOverlap', paragraph: 'PlaygroundEditorTheme__paragraph', quote: 'PlaygroundEditorTheme__quote', - rtl: 'PlaygroundEditorTheme__rtl', specialText: 'PlaygroundEditorTheme__specialText', tab: 'PlaygroundEditorTheme__tabNode', table: 'PlaygroundEditorTheme__table', diff --git a/packages/lexical-react/src/LexicalCollaborationPlugin.tsx b/packages/lexical-react/src/LexicalCollaborationPlugin.tsx index f5bfcdfee86..e47762f9d10 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, @@ -23,22 +22,24 @@ import { } from '@lexical/yjs'; import {LexicalEditor} from 'lexical'; import {useEffect, useRef, useState} from 'react'; +import {Doc} from 'yjs'; import {InitialEditorStateType} from './LexicalComposer'; import { CursorsContainerRef, useYjsCollaboration, + useYjsCollaborationV2__EXPERIMENTAL, + useYjsCursors, 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,27 +62,16 @@ 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(); - 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]); + useCollabActive(collabContext, editor); const [provider, setProvider] = useState(); const [doc, setDoc] = useState(); @@ -206,3 +196,70 @@ function YjsCollaborationCursors({ return cursors; } + +type CollaborationPluginV2Props = { + id: string; + doc: Doc; + provider: Provider; + __shouldBootstrapUnsafe: boolean; + 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, + doc, + provider, + __shouldBootstrapUnsafe, + username, + cursorColor, + cursorsContainerRef, + excludedProperties, + awarenessData, +}: CollaborationPluginV2Props): JSX.Element { + const collabContext = useCollaborationContext(username, cursorColor); + const {yjsDocMap, name, color} = collabContext; + + const [editor] = useLexicalComposerContext(); + useCollabActive(collabContext, editor); + + const binding = useYjsCollaborationV2__EXPERIMENTAL( + editor, + id, + doc, + provider, + yjsDocMap, + name, + color, + { + __shouldBootstrapUnsafe, + awarenessData, + excludedProperties, + }, + ); + + useYjsHistoryV2(editor, binding); + useYjsFocusTracking(editor, provider, name, color, awarenessData); + return useYjsCursors(binding, cursorsContainerRef); +} + +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]); +}; diff --git a/packages/lexical-react/src/__tests__/unit/Collaboration.test.ts b/packages/lexical-react/src/__tests__/unit/Collaboration.test.ts index 9eee6a38c23..dbced8728d5 100644 --- a/packages/lexical-react/src/__tests__/unit/Collaboration.test.ts +++ b/packages/lexical-react/src/__tests__/unit/Collaboration.test.ts @@ -45,413 +45,408 @@ describe('Collaboration', () => { expect(client1.getDocJSON()).toEqual(client2.getDocJSON()); } - it('Should collaborate basic text insertion between two clients', async () => { - const connector = createTestConnection(); + describe.each([[false], [true]])( + 'useCollabV2: %s', + (useCollabV2: boolean) => { + it('Should collaborate basic text insertion 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 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); - }); - }); + const client1 = connector.createClient('1'); + const client2 = connector.createClient('2'); - expect(client1.getHTML()).toEqual( - '

Hello world

', - ); - expect(client1.getHTML()).toEqual(client2.getHTML()); - expect(client1.getDocJSON()).toEqual(client2.getDocJSON()); + client1.start(container!); + client2.start(container!); - // 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'); - }); - }); + await expectCorrectInitialContent(client1, client2); - 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()); + // Insert a text node on client 1 + await waitForReact(() => { + client1.update(() => { + const root = $getRoot(); - client1.stop(); - client2.stop(); - }); + const paragraph = root.getFirstChild(); - it('Should collaborate basic text insertion conflicts between two clients', async () => { - const connector = createTestConnection(); + const text = $createTextNode('Hello world'); - const client1 = connector.createClient('1'); - const client2 = connector.createClient('2'); + paragraph!.append(text); + }); + }); - client1.start(container!); - client2.start(container!); + expect(client1.getHTML()).toEqual( + '

Hello world

', + ); + expect(client1.getHTML()).toEqual(client2.getHTML()); + expect(client1.getDocJSON()).toEqual(client2.getDocJSON()); - await expectCorrectInitialContent(client1, client2); + // Insert some text on client 2 + await waitForReact(() => { + client2.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(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(client1.getHTML()).toEqual( - '

Hello world

', - ); - expect(client2.getHTML()).toEqual('


'); - // Insert some a text node on client 1 - await waitForReact(() => { - client2.update(() => { - const root = $getRoot(); + it('Should collaborate basic text insertion conflicts between two clients', async () => { + const connector = createTestConnection(useCollabV2); - const paragraph = root.getFirstChild()!; - const text = $createTextNode('Hello world'); + const client1 = connector.createClient('1'); + const client2 = connector.createClient('2'); - paragraph.append(text); - }); - }); + client1.start(container!); + client2.start(container!); - expect(client2.getHTML()).toEqual( - '

Hello world

', - ); - expect(client1.getHTML()).toEqual(client2.getHTML()); + await expectCorrectInitialContent(client1, client2); - await waitForReact(() => { - client1.connect(); - }); + client1.disconnect(); - // 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()); + // Insert some a text node on client 1 + await waitForReact(() => { + client1.update(() => { + const root = $getRoot(); - client2.disconnect(); + const paragraph = root.getFirstChild()!; + const text = $createTextNode('Hello world'); - await waitForReact(() => { - client1.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(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); + }); + }); - await waitForReact(() => { - client2.update(() => { - const root = $getRoot(); + expect(client2.getHTML()).toEqual( + '

Hello world

', + ); + expect(client1.getHTML()).toEqual(client2.getHTML()); - const paragraph = root.getFirstChild()!; - const text = paragraph.getFirstChild()!; + await waitForReact(() => { + client1.connect(); + }); - text.spliceText(11, 11, '!'); - }); - }); + // 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 waitForReact(() => { - client2.connect(); - }); + client2.disconnect(); - 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()); + await waitForReact(() => { + client1.update(() => { + const root = $getRoot(); - client1.stop(); - client2.stop(); - }); + const paragraph = root.getFirstChild()!; + const text = paragraph.getFirstChild()!; - 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.spliceText(11, 11, ''); + }); + }); - await expectCorrectInitialContent(client1, client2); + expect(client1.getHTML()).toEqual( + '

Hello world

', + ); + expect(client2.getHTML()).toEqual( + '

Hello worldHello world

', + ); - // Insert some a text node on client 1 - await waitForReact(() => { - client1.update(() => { - const root = $getRoot(); + await waitForReact(() => { + client2.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(); + await waitForReact(() => { + client2.connect(); + }); - // Delete the text 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()!; - paragraph.getFirstChild()!.remove(); + client1.stop(); + client2.stop(); }); - }); - expect(client1.getHTML()).toEqual('


'); - expect(client2.getHTML()).toEqual( - '

Hello world

', - ); - - // Insert some text on client 2 - await waitForReact(() => { - client2.update(() => { - const root = $getRoot(); - - const paragraph = root.getFirstChild()!; - - paragraph.getFirstChild()!.spliceText(11, 0, 'Hello world'); - }); - }); - - expect(client1.getHTML()).toEqual('


'); - expect(client2.getHTML()).toEqual( - '

Hello worldHello world

', - ); - - 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({ - root: '', - }); - expect(client1.getDocJSON()).toEqual(client2.getDocJSON()); - client1.stop(); - client2.stop(); - }); - - it('Should 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!); - - await expectCorrectInitialContent(client1, client2); - - await waitForReact(() => { - client1.update(() => { - const root = $getRoot().clear(); - root.append($createParagraphNode().append($createTextNode('hello'))); + 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 expectCorrectInitialContent(client1, client2); + + // Insert some 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); + }); + }); + + expect(client1.getHTML()).toEqual( + '

Hello world

', + ); + expect(client1.getHTML()).toEqual(client2.getHTML()); + expect(client1.getDocJSON()).toEqual(client2.getDocJSON()); + + client1.disconnect(); + + // Delete the text on client 1 + await waitForReact(() => { + client1.update(() => { + const root = $getRoot(); + + const paragraph = root.getFirstChild()!; + paragraph.getFirstChild()!.remove(); + }); + }); + + expect(client1.getHTML()).toEqual('


'); + expect(client2.getHTML()).toEqual( + '

Hello world

', + ); + + // Insert some text on client 2 + await waitForReact(() => { + client2.update(() => { + const root = $getRoot(); + + const paragraph = root.getFirstChild()!; + + paragraph + .getFirstChild()! + .spliceText(11, 0, 'Hello world'); + }); + }); + + expect(client1.getHTML()).toEqual('


'); + expect(client2.getHTML()).toEqual( + '

Hello worldHello world

', + ); + + await waitForReact(() => { + client1.connect(); + }); + + 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(); + client2.stop(); }); - }); - expect(client1.getHTML()).toEqual( - '

hello

', - ); - expect(client2.getHTML()).toEqual(client1.getHTML()); - - // Override direction - await waitForReact(() => { - client1.update(() => { - const paragraph = $getRoot().getFirstChild()!; - paragraph.setDirection('rtl'); + it('Should 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); + + await waitForReact(() => { + client1.update(() => { + const root = $getRoot().clear(); + root.append( + $createParagraphNode().append($createTextNode('hello')), + ); + }); + }); + + expect(client1.getHTML()).toEqual( + '

hello

', + ); + expect(client2.getHTML()).toEqual(client1.getHTML()); + + // Override direction + await waitForReact(() => { + client1.update(() => { + const paragraph = $getRoot().getFirstChild()!; + paragraph.setDirection('rtl'); + }); + }); + + expect(client1.getHTML()).toEqual( + '

hello

', + ); + expect(client2.getHTML()).toEqual(client1.getHTML()); + + client1.stop(); + client2.stop(); }); - }); - - expect(client1.getHTML()).toEqual( - '

hello

', - ); - expect(client2.getHTML()).toEqual(client1.getHTML()); - client1.stop(); - client2.stop(); - }); + it('Should allow the passing of arbitrary awareness data', async () => { + const connector = createTestConnection(useCollabV2); - it('Should allow the passing of arbitrary awareness data', async () => { - const connector = createTestConnection(); + const client1 = connector.createClient('1'); + const client2 = connector.createClient('2'); - const client1 = connector.createClient('1'); - const client2 = connector.createClient('2'); + const awarenessData1 = { + foo: 'foo', + uuid: Math.floor(Math.random() * 10000), + }; + const awarenessData2 = { + bar: 'bar', + uuid: Math.floor(Math.random() * 10000), + }; - const awarenessData1 = { - foo: 'foo', - uuid: Math.floor(Math.random() * 10000), - }; - const awarenessData2 = { - bar: 'bar', - uuid: Math.floor(Math.random() * 10000), - }; + client1.start(container!, awarenessData1); + client2.start(container!, awarenessData2); - client1.start(container!, awarenessData1); - client2.start(container!, awarenessData2); + await expectCorrectInitialContent(client1, client2); - await expectCorrectInitialContent(client1, client2); + expect(client1.awareness.getLocalState()!.awarenessData).toEqual( + awarenessData1, + ); + expect(client2.awareness.getLocalState()!.awarenessData).toEqual( + awarenessData2, + ); - expect(client1.awareness.getLocalState()!.awarenessData).toEqual( - awarenessData1, - ); - expect(client2.awareness.getLocalState()!.awarenessData).toEqual( - awarenessData2, - ); - - client1.stop(); - client2.stop(); - }); - - /** - * 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(); - - 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); + 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); - - root.append(paragraph); + /** + * 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()); + + 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!); @@ -474,9 +469,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(); @@ -494,9 +486,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(() => { @@ -507,9 +496,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 b0941d88e20..5934301dd41 100644 --- a/packages/lexical-react/src/__tests__/unit/CollaborationWithCollisions.test.ts +++ b/packages/lexical-react/src/__tests__/unit/CollaborationWithCollisions.test.ts @@ -195,47 +195,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 9ac6e6818c6..79e265eee0b 100644 --- a/packages/lexical-react/src/__tests__/unit/utils.tsx +++ b/packages/lexical-react/src/__tests__/unit/utils.tsx @@ -10,7 +10,10 @@ import { LexicalCollaboration, 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'; @@ -30,12 +33,14 @@ function Editor({ setEditor, awarenessData, shouldBootstrapEditor = true, + useCollabV2 = false, }: { doc: Y.Doc; provider: Provider; setEditor: (editor: LexicalEditor) => void; awarenessData?: object | undefined; shouldBootstrapEditor?: boolean; + useCollabV2?: boolean; }) { const context = useCollaborationContext(); @@ -49,12 +54,22 @@ function Editor({ return ( <> - provider} - shouldBootstrap={shouldBootstrapEditor} - awarenessData={awarenessData} - /> + {useCollabV2 ? ( + + ) : ( + provider} + shouldBootstrap={shouldBootstrapEditor} + awarenessData={awarenessData} + /> + )} } placeholder={<>} @@ -71,6 +86,7 @@ export class Client implements Provider { _editor: LexicalEditor | null = null; _connection: { _clients: Map; + _useCollabV2: boolean; }; _connected: boolean = false; _doc: Y.Doc = new Y.Doc(); @@ -189,6 +205,7 @@ export class Client implements Provider { setEditor={(editor) => (this._editor = editor)} awarenessData={awarenessData} shouldBootstrapEditor={options.shouldBootstrapEditor} + useCollabV2={this._connection._useCollabV2} /> , @@ -274,6 +291,8 @@ export class Client implements Provider { class TestConnection { _clients = new Map(); + constructor(readonly _useCollabV2: boolean) {} + createClient(id: string) { const client = new Client(id, this); @@ -283,8 +302,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 86fac79e1a5..e2c2f8e990f 100644 --- a/packages/lexical-react/src/shared/useYjsCollaboration.tsx +++ b/packages/lexical-react/src/shared/useYjsCollaboration.tsx @@ -6,19 +6,29 @@ * */ -import type {Binding, Provider, SyncCursorPositionsFn} from '@lexical/yjs'; +import type { + BaseBinding, + Binding, + BindingV2, + ExcludedProperties, + Provider, + SyncCursorPositionsFn, +} from '@lexical/yjs'; import type {LexicalEditor} from 'lexical'; import type {JSX} from 'react'; import {mergeRegister} from '@lexical/utils'; import { CONNECTED_COMMAND, + createBindingV2__EXPERIMENTAL, createUndoManager, initLocalState, setLocalStateFocus, syncCursorPositions, syncLexicalUpdateToYjs, + syncLexicalUpdateToYjsV2__EXPERIMENTAL, syncYjsChangesToLexical, + syncYjsChangesToLexicalV2__EXPERIMENTAL, TOGGLE_CONNECT_COMMAND, } from '@lexical/yjs'; import { @@ -44,6 +54,13 @@ import {InitialEditorStateType} from '../LexicalComposer'; export type CursorsContainerRef = React.RefObject; +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, @@ -61,48 +78,17 @@ export function useYjsCollaboration( ): 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,14 +102,40 @@ export function useYjsCollaboration( } }; - initLocalState( - provider, - name, - color, - document.activeElement === editor.getRootElement(), - awarenessData || {}, + // This updates the local editor state when we receive updates from other clients + root.getSharedType().observeDeep(onYjsTreeChanges); + const removeListener = editor.registerUpdateListener( + ({ + prevEditorState, + editorState, + dirtyLeaves, + dirtyElements, + normalizedNodes, + tags, + }) => { + if (!tags.has(SKIP_COLLAB_TAG)) { + syncLexicalUpdateToYjs( + binding, + provider, + prevEditorState, + editorState, + dirtyElements, + dirtyLeaves, + normalizedNodes, + tags, + ); + } + }, ); + return () => { + root.getSharedType().unobserveDeep(onYjsTreeChanges); + removeListener(); + }; + }, [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); @@ -131,29 +143,115 @@ export function useYjsCollaboration( isReloadingDoc.current = true; }; + const onSync = () => { + isReloadingDoc.current = false; + }; + provider.on('reload', onProviderDocReload); - provider.on('status', onStatus); provider.on('sync', onSync); - awareness.on('update', onAwarenessUpdate); + + return () => { + provider.off('reload', onProviderDocReload); + provider.off('sync', onSync); + }; + }, [binding, provider, editor, setDoc, docMap, id]); + + useProvider( + editor, + provider, + name, + color, + 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, + options: { + awarenessData?: object; + excludedProperties?: ExcludedProperties; + rootName?: string; + __shouldBootstrapUnsafe?: boolean; + } = {}, +): BindingV2 { + 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}), []); + + 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) { + initializeEditor(editor); + } + }, [binding, editor, shouldBootstrap]); + + useEffect(() => { + const {root} = binding; + const {awareness} = provider; + + const onYjsTreeChanges: OnYjsTreeChanges = (events, transaction) => { + const origin = transaction.origin; + if (origin !== binding) { + const isFromUndoManger = origin instanceof UndoManager; + syncYjsChangesToLexicalV2__EXPERIMENTAL( + binding, + provider, + events, + transaction, + isFromUndoManger, + ); + } + }; + // This updates the local editor state when we receive updates from other clients - root.getSharedType().observeDeep(onYjsTreeChanges); + root.observeDeep(onYjsTreeChanges); const removeListener = editor.registerUpdateListener( ({ prevEditorState, editorState, - dirtyLeaves, dirtyElements, normalizedNodes, tags, }) => { - if (tags.has(SKIP_COLLAB_TAG) === false) { - syncLexicalUpdateToYjs( + if (!tags.has(SKIP_COLLAB_TAG)) { + syncLexicalUpdateToYjsV2__EXPERIMENTAL( binding, provider, prevEditorState, editorState, dirtyElements, - dirtyLeaves, normalizedNodes, tags, ); @@ -161,9 +259,76 @@ export function useYjsCollaboration( }, ); + const onAwarenessUpdate = () => { + syncCursorPositions(binding, provider); + }; + awareness.on('update', onAwarenessUpdate); + + return () => { + root.unobserveDeep(onYjsTreeChanges); + removeListener(); + awareness.off('update', onAwarenessUpdate); + }; + }, [binding, provider, editor]); + + useProvider( + editor, + provider, + name, + color, + isReloadingDoc, + awarenessData, + onBootstrap, + ); + + return binding; +} + +function useProvider( + editor: LexicalEditor, + provider: Provider, + name: string, + color: string, + isReloadingDoc: React.RefObject, + awarenessData?: object, + onBootstrap?: () => void, +): void { + 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(); + } + }; + + initLocalState( + provider, + name, + color, + document.activeElement === editor.getRootElement(), + awarenessData || {}, + ); + + 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); @@ -181,38 +346,18 @@ 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, - color, - connect, - disconnect, - docMap, editor, - id, - initialEditorState, - name, provider, - shouldBootstrap, + name, + color, + isReloadingDoc, awarenessData, - setDoc, - syncCursorPositionsFn, + 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( @@ -235,8 +380,22 @@ export function useYjsCollaboration( 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( @@ -277,6 +436,22 @@ export function useYjsHistory( [binding], ); + 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 = () => { undoManager.undo(); @@ -393,7 +568,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-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/Bindings.ts b/packages/lexical-yjs/src/Bindings.ts index 5c9bdb8f9e0..f29bbdbd591 100644 --- a/packages/lexical-yjs/src/Bindings.ts +++ b/packages/lexical-yjs/src/Bindings.ts @@ -12,18 +12,30 @@ 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 {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'; +import {CollabV2Mapping} from './CollabV2Mapping'; +import {initializeNodeProperties} from './Utils'; export type ClientID = number; -export type Binding = { +export interface BaseBinding { clientID: number; + cursors: Map; + cursorsContainer: null | HTMLElement; + doc: Doc; + docMap: Map; + editor: LexicalEditor; + id: string; + nodeProperties: Map; // node type to property to default value + excludedProperties: ExcludedProperties; +} + +export interface Binding extends BaseBinding { collabNodeMap: Map< NodeKey, | CollabElementNode @@ -31,18 +43,44 @@ export type Binding = { | CollabDecoratorNode | CollabLineBreakNode >; - cursors: Map; - cursorsContainer: null | HTMLElement; - doc: Doc; - docMap: Map; - editor: LexicalEditor; - id: string; - nodeProperties: Map>; root: CollabElementNode; - excludedProperties: ExcludedProperties; -}; +} + +export interface BindingV2 extends BaseBinding { + mapping: CollabV2Mapping; + root: XmlElement; +} + +export type AnyBinding = Binding | BindingV2; + export type ExcludedProperties = Map, Set>; +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', + ); + const binding = { + clientID: doc.clientID, + cursors: new Map(), + cursorsContainer: null, + doc, + docMap, + editor, + excludedProperties: excludedProperties || new Map(), + id, + nodeProperties: new Map(), + }; + initializeNodeProperties(binding); + return binding; +} + export function createBinding( editor: LexicalEditor, provider: Provider, @@ -63,16 +101,35 @@ 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, + 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(rootName, 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/CollabV2Mapping.ts b/packages/lexical-yjs/src/CollabV2Mapping.ts new file mode 100644 index 00000000000..d9e854438cb --- /dev/null +++ b/packages/lexical-yjs/src/CollabV2Mapping.ts @@ -0,0 +1,111 @@ +/** + * 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[]): void { + 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); + } + + 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/SyncCursors.ts b/packages/lexical-yjs/src/SyncCursors.ts index c64451acbd8..8c65a65109c 100644 --- a/packages/lexical-yjs/src/SyncCursors.ts +++ b/packages/lexical-yjs/src/SyncCursors.ts @@ -6,9 +6,13 @@ * */ -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, +} from 'lexical'; import {createDOMRange, createRectsFromDOMRange} from '@lexical/selection'; import { @@ -21,16 +25,28 @@ import { } from 'lexical'; import invariant from 'shared/invariant'; import { + AbsolutePosition, compareRelativePositions, createAbsolutePositionFromRelativePosition, createRelativePositionFromTypeIndex, + RelativePosition, + XmlElement, + XmlText, } from 'yjs'; import {Provider, UserState} from '.'; +import { + AnyBinding, + type BaseBinding, + type Binding, + type BindingV2, + isBindingV1, +} from './Bindings'; import {CollabDecoratorNode} from './CollabDecoratorNode'; import {CollabElementNode} from './CollabElementNode'; import {CollabLineBreakNode} from './CollabLineBreakNode'; import {CollabTextNode} from './CollabTextNode'; +import {CollabV2Mapping} from './CollabV2Mapping'; import {getPositionFromElementAndOffset} from './Utils'; export type CursorSelection = { @@ -99,9 +115,48 @@ 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)) { + let nextSibling = child.getNextSibling(); + while ($isTextNode(nextSibling)) { + nextSibling = nextSibling.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 +187,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 +200,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 +239,7 @@ function createCursorSelection( } function updateCursor( - binding: Binding, + binding: BaseBinding, cursor: Cursor, nextSelection: null | CursorSelection, nodeMap: NodeMap, @@ -300,12 +355,14 @@ type AnyCollabNode = | 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; @@ -335,8 +392,91 @@ export function getAnchorAndFocusCollabNodesForUserState( }; } +export function $getAnchorAndFocusForUserState( + binding: AnyBinding, + 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, + }; + } + + 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 ( + $isTextNode(startNode) && + $isTextNode(startNode.getNextSibling()) && + startOffset === startNode.getTextContentSize() + ) { + 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, + }; +} + export function $syncLocalCursorPosition( - binding: Binding, + binding: AnyBinding, provider: Provider, ): void { const awareness = provider.awareness; @@ -346,13 +486,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)) { @@ -409,28 +546,78 @@ function getCollabNodeAndOffset( return [null, 0]; } +function $getNodeAndOffsetV2( + 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); + if (node === undefined) { + return [null, 0]; + } + 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)) { + while ( + lexicalOffset < children.length && + $isTextNode(children[lexicalOffset]) + ) { + lexicalOffset += 1; + } + } + } + return [node, lexicalOffset]; + } else { + const nodes = mapping.get(yType); + if (nodes === undefined) { + return [null, 0]; + } + let i = 0; + let adjustedOffset = yOffset; + while ( + adjustedOffset > nodes[i].getTextContentSize() && + i + 1 < nodes.length + ) { + adjustedOffset -= nodes[i].getTextContentSize(); + i++; + } + const textNode = nodes[i]; + return [textNode, Math.min(adjustedOffset, textNode.getTextContentSize())]; + } +} + export type SyncCursorPositionsFn = ( - binding: Binding, + binding: AnyBinding, 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: AnyBinding, provider: Provider, options?: SyncCursorPositionsOptions, ): void { @@ -459,12 +646,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) { @@ -507,7 +693,7 @@ export function syncCursorPositions( } export function syncLexicalSelectionToYjs( - binding: Binding, + binding: AnyBinding, provider: Provider, prevSelection: null | BaseSelection, nextSelection: null | BaseSelection, @@ -540,8 +726,13 @@ 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 { + anchorPos = createRelativePositionV2(nextSelection.anchor, binding); + focusPos = createRelativePositionV2(nextSelection.focus, binding); + } } if ( diff --git a/packages/lexical-yjs/src/SyncEditorStates.ts b/packages/lexical-yjs/src/SyncEditorStates.ts index f52f7ca7fd7..b85ffcd3a8c 100644 --- a/packages/lexical-yjs/src/SyncEditorStates.ts +++ b/packages/lexical-yjs/src/SyncEditorStates.ts @@ -7,6 +7,7 @@ */ import type {EditorState, NodeKey} from 'lexical'; +import type {ContentType, Transaction as YTransaction} from 'yjs'; import { $addUpdateTag, @@ -21,8 +22,11 @@ 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, + iterateDeletedStructs, Map as YMap, Text as YText, XmlElement, @@ -33,7 +37,8 @@ import { YXmlEvent, } from 'yjs'; -import {Binding, Provider} from '.'; +import {Binding, BindingV2, Provider} from '.'; +import {AnyBinding} from './Bindings'; import {CollabDecoratorNode} from './CollabDecoratorNode'; import {CollabElementNode} from './CollabElementNode'; import {CollabTextNode} from './CollabTextNode'; @@ -43,6 +48,7 @@ import { SyncCursorPositionsFn, syncLexicalSelectionToYjs, } from './SyncCursors'; +import {$createOrUpdateNodeFromYElement, $updateYFragment} from './SyncV2'; import { $getOrInitCollabNodeFromSharedType, $moveSelectionToPreviousNode, @@ -150,31 +156,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 @@ -185,14 +167,7 @@ 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'. - editor.update(() => { - if ($getRoot().getChildrenSize() === 0) { - $getRoot().append($createParagraphNode()); - } - }); + editor.update(() => $ensureEditorNotEmpty()); }, skipTransforms: true, tag: isFromUndoManger ? HISTORIC_TAG : COLLABORATION_TAG, @@ -200,6 +175,38 @@ export function syncYjsChangesToLexical( ); } +function $syncCursorFromYjs( + editorState: EditorState, + binding: AnyBinding, + 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, @@ -251,6 +258,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( @@ -304,3 +320,125 @@ export function syncLexicalUpdateToYjs( }); }); } + +function $syncEventV2( + binding: BindingV2, + event: YEvent, +): void { + 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'); + } + } 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( + () => { + for (let i = 0; i < events.length; i++) { + const event = events[i]; + $syncEventV2(binding, event); + } + + $syncCursorFromYjs(editorState, binding, provider); + + 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); + } + }, + { + // Need any text node normalization to be synchronously updated back to Yjs, otherwise the + // binding.mapping will get out of sync. + discrete: true, + onUpdate: () => { + syncCursorPositions(binding, provider); + editor.update(() => $ensureEditorNotEmpty()); + }, + skipTransforms: true, + tag: isFromUndoManger ? HISTORIC_TAG : COLLABORATION_TAG, + }, + ); +} + +export function syncLexicalUpdateToYjsV2__EXPERIMENTAL( + binding: BindingV2, + provider: Provider, + prevEditorState: EditorState, + currEditorState: EditorState, + dirtyElements: Map, + normalizedNodes: Set, + tags: Set, +): void { + const isFromYjs = tags.has(COLLABORATION_TAG) || tags.has(HISTORIC_TAG); + if (isFromYjs && normalizedNodes.size === 0) { + 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')) { + $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..c13815c9933 --- /dev/null +++ b/packages/lexical-yjs/src/SyncV2.ts @@ -0,0 +1,863 @@ +/** + * 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. + * + */ + +/* + * 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 { + $getSelection, + $getWritableNodeState, + $isRangeSelection, + $isTextNode, + ElementNode, + LexicalNode, + NodeKey, + RootNode, + TextNode, +} from 'lexical'; +import invariant from 'shared/invariant'; +import simpleDiffWithCursor from 'shared/simpleDiffWithCursor'; +import { + ContentFormat, + ContentString, + 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: ID, +) => Record; + +type TextAttributes = { + t?: string; // type if not TextNode + p?: Record; // properties + [key: `s_${string}`]: unknown; // state + i?: number; // used to prevent Yjs from merging text nodes itself + // ychange?: Record; +}; + +/* +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 && + !isDeleted(snapshot.ds, item.id); +*/ + +// 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'; + +export const $createOrUpdateNodeFromYElement = ( + el: XmlElement, + binding: BindingV2, + keysChanged: Set | null, + childListChanged: boolean, + snapshot?: Snapshot, + prevSnapshot?: Snapshot, + computeYChange?: ComputeYChange, +): LexicalNode | null => { + let node = binding.mapping.get(el); + if (node && keysChanged && keysChanged.size === 0 && !childListChanged) { + return node; + } + + const type = isRootElement(el) ? RootNode.getType() : el.nodeName; + const registeredNodes = binding.editor._nodes; + const nodeInfo = registeredNodes.get(type); + if (nodeInfo === undefined) { + throw new Error( + `$createOrUpdateNodeFromYElement: Node ${type} is not registered`, + ); + } + + 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 + /* + if (snapshot !== undefined) { + if (!isVisible(el._item!, snapshot)) { + 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 properties: Record = { + ...getDefaultNodeProperties(node, binding), + }; + 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(binding, properties, node, keysChanged); + 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(); + binding.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; + } + + const nextHasPrevKey = nextChildrenKeySet.has(prevKey); + 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++; + continue; + } + + // Create or replace + const nextChildNode = nextChildren[nextIndex]; + if (prevHasNextKey) { + node.splice(nextIndex, 1, [nextChildNode]); + prevIndex++; + nextIndex++; + } else { + node.splice(nextIndex, 0, [nextChildNode]); + nextIndex++; + } + } + + 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, + [], + ); + } +}; + +const $createOrUpdateTextNodesFromYText = ( + text: XmlText, + binding: BindingV2, + snapshot?: Snapshot, + prevSnapshot?: Snapshot, + 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(), + ); + 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); + 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; + }); + } + + // 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]; + const {attributes, insert} = delta; + if (node.__text !== insert) { + node.setTextContent(insert); + } + const properties = { + ...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; +}; + +const $createTypeFromTextNodes = ( + nodes: TextNode[], + binding: BindingV2, +): XmlText => { + const type = new XmlText(); + $updateYText(type, nodes, binding); + return type; +}; + +const createTypeFromElementNode = ( + node: LexicalNode, + binding: BindingV2, +): XmlElement => { + const type = new XmlElement(node.getType()); + const attrs = { + ...propertiesToAttributes(node, binding), + ...stateToAttributes(node), + }; + for (const key in attrs) { + const val = attrs[key]; + if (val !== null) { + // 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)) { + return type; + } + type.insert( + 0, + normalizeNodeContent(node).map((n) => + $createTypeFromTextOrElementNode(n, binding), + ), + ); + binding.mapping.set(type, node); + return type; +}; + +const $createTypeFromTextOrElementNode = ( + node: LexicalNode | TextNode[], + meta: BindingV2, +): XmlElement | XmlText => + node instanceof Array + ? $createTypeFromTextNodes(node, meta) + : createTypeFromElementNode(node, meta); + +const isObject = (val: unknown): val is Record => + typeof val === 'object' && val != null; + +const equalAttrs = ( + pattrs: Record, + 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 normalizeNodeContent = (node: LexicalNode): NormalizedPNodeContent => { + if (!(node instanceof ElementNode)) { + return []; + } + const c = node.getChildren(); + const res: NormalizedPNodeContent = []; + for (let i = 0; i < c.length; i++) { + const n = c[i]; + if ($isTextNode(n)) { + const textNodes: TextNode[] = []; + for ( + let maybeTextNode = c[i]; + i < c.length && $isTextNode(maybeTextNode); + maybeTextNode = c[++i] + ) { + textNodes.push(maybeTextNode); + } + i--; + res.push(textNodes); + } else { + res.push(n); + } + } + return res; +}; + +const equalYTextLText = ( + ytext: XmlText, + ltexts: TextNode[], + binding: BindingV2, +) => { + const deltas = toDelta(ytext); + return ( + deltas.length === ltexts.length && + 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)) + ); + }) + ); +}; + +const equalYTypePNode = ( + ytype: XmlElement | XmlText | XmlHook, + lnode: LexicalNode | TextNode[], + binding: BindingV2, +): ytype is XmlElement | XmlText => { + if ( + ytype instanceof XmlElement && + !(lnode instanceof Array) && + matchNodeName(ytype, lnode) + ) { + const normalizedContent = normalizeNodeContent(lnode); + return ( + ytype._length === normalizedContent.length && + equalAttrs(ytype.getAttributes(), { + ...propertiesToAttributes(lnode, binding), + ...stateToAttributes(lnode), + }) && + ytype + .toArray() + .every((ychild, i) => + equalYTypePNode(ychild, normalizedContent[i], binding), + ) + ); + } + return ( + ytype instanceof XmlText && + lnode instanceof Array && + equalYTextLText(ytype, lnode, binding) + ); +}; + +const mappedIdentity = ( + mapped: LexicalNode | TextNode[] | undefined, + lcontent: LexicalNode | TextNode[], +) => + mapped === lcontent || + (mapped instanceof Array && + lcontent instanceof Array && + mapped.length === lcontent.length && + mapped.every((a, i) => lcontent[i] === a)); + +type EqualityFactor = { + foundMappedChild: boolean; + equalityFactor: number; +}; + +const computeChildEqualityFactor = ( + ytype: XmlElement, + lnode: LexicalNode, + binding: BindingV2, +): EqualityFactor => { + const yChildren = ytype.toArray(); + const pChildren = normalizeNodeContent(lnode); + 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 (leftY instanceof XmlHook) { + break; + } else if (mappedIdentity(binding.mapping.get(leftY), leftP)) { + foundMappedChild = true; // definite (good) match! + } 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 XmlHook) { + break; + } else if (mappedIdentity(binding.mapping.get(rightY), rightP)) { + foundMappedChild = true; + } else if (!equalYTypePNode(rightY, rightP, binding)) { + break; + } + } + return { + equalityFactor: left + right, + foundMappedChild, + }; +}; + +const ytextTrans = ( + 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 ContentString) { + str += n.content.str; + } else if (n.content instanceof ContentFormat) { + nAttrs[n.content.key] = null; + } + } + n = n.right; + } + return { + nAttrs, + str, + }; +}; + +const $updateYText = ( + ytext: XmlText, + ltexts: TextNode[], + binding: BindingV2, +) => { + binding.mapping.set(ytext, ltexts); + const {nAttrs, str} = ytextTrans(ytext); + const content = ltexts.map((node, i) => { + const nodeType = node.getType(); + 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, + ...stateToAttributes(node), + ...(i > 0 && {i}), // Prevent Yjs from merging text nodes itself. + }), + insert: node.getTextContent(), + nodeKey: node.getKey(), + }; + }); + + 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, + nextText, + cursorOffset, + ); + ytext.delete(index, remove); + ytext.insert(index, insert); + ytext.applyDelta( + content.map((c) => ({attributes: c.attributes, retain: c.insert.length})), + ); +}; + +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 = {}; + Object.entries(defaultProperties).forEach(([property, defaultValue]) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + 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(); + 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; +}; + +export const $updateYFragment = ( + y: YDoc, + yDomFragment: XmlElement, + node: LexicalNode, + binding: BindingV2, + dirtyElements: Set, +) => { + if ( + yDomFragment instanceof XmlElement && + yDomFragment.nodeName !== node.getType() && + !(isRootElement(yDomFragment) && node.getType() === RootNode.getType()) + ) { + throw new Error('node name mismatch!'); + } + binding.mapping.set(yDomFragment, node); + // update attributes + if (yDomFragment instanceof XmlElement) { + const yDomAttrs = yDomFragment.getAttributes(); + const lexicalAttrs = { + ...propertiesToAttributes(node, binding), + ...stateToAttributes(node), + }; + for (const key in lexicalAttrs) { + 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 + yDomFragment.setAttribute(key, lexicalAttrs[key] as any); + } + } else { + yDomFragment.removeAttribute(key); + } + } + // remove all keys that are no longer in pAttrs + for (const key in yDomAttrs) { + if (lexicalAttrs[key] === undefined) { + yDomFragment.removeAttribute(key); + } + } + } + // update children + const lChildren = normalizeNodeContent(node); + const lChildCnt = lChildren.length; + const yChildren = yDomFragment.toArray(); + const yChildCnt = yChildren.length; + 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 leftL = lChildren[left]; + if (leftY instanceof XmlHook) { + break; + } else if (mappedIdentity(binding.mapping.get(leftY), leftL)) { + if (leftL instanceof ElementNode && dirtyElements.has(leftL.getKey())) { + $updateYFragment( + y, + leftY as XmlElement, + leftL as LexicalNode, + binding, + dirtyElements, + ); + } + } else if (equalYTypePNode(leftY, leftL, binding)) { + // update mapping + binding.mapping.set(leftY, leftL); + } else { + break; + } + } + // find number of matching elements from right + for (; right + left < minCnt; right++) { + const rightY = yChildren[yChildCnt - right - 1]; + const rightL = lChildren[lChildCnt - right - 1]; + if (rightY instanceof XmlHook) { + break; + } else if (mappedIdentity(binding.mapping.get(rightY), rightL)) { + if (rightL instanceof ElementNode && dirtyElements.has(rightL.getKey())) { + $updateYFragment( + y, + rightY as XmlElement, + rightL as LexicalNode, + binding, + dirtyElements, + ); + } + } else if (equalYTypePNode(rightY, rightL, binding)) { + // update mapping + binding.mapping.set(rightY, rightL); + } else { + break; + } + } + // try to compare and update + while (yChildCnt - left - right > 0 && lChildCnt - left - right > 0) { + const leftY = yChildren[left]; + const leftL = lChildren[left]; + const rightY = yChildren[yChildCnt - right - 1]; + 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 XmlElement && matchNodeName(leftY, leftL); + let updateRight = + rightY instanceof XmlElement && matchNodeName(rightY, rightL); + if (updateLeft && updateRight) { + // decide which which element to update + const equalityLeft = computeChildEqualityFactor( + leftY as XmlElement, + leftL as LexicalNode, + binding, + ); + const equalityRight = computeChildEqualityFactor( + rightY as XmlElement, + rightL as LexicalNode, + binding, + ); + 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 XmlElement, + leftL as LexicalNode, + binding, + dirtyElements, + ); + left += 1; + } else if (updateRight) { + $updateYFragment( + y, + rightY as XmlElement, + rightL as LexicalNode, + binding, + dirtyElements, + ); + right += 1; + } else { + binding.mapping.delete(yDomFragment.get(left)); + yDomFragment.delete(left, 1); + yDomFragment.insert(left, [ + $createTypeFromTextOrElementNode(leftL, binding), + ]); + left += 1; + } + } + } + const yDelLen = yChildCnt - left - right; + 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) => binding.mapping.delete(type)); + yDomFragment.delete(left, yDelLen); + } + if (left + right < lChildCnt) { + const ins = []; + for (let i = left; i < lChildCnt - right; i++) { + ins.push($createTypeFromTextOrElementNode(lChildren[i], binding)); + } + yDomFragment.insert(left, ins); + } +}; + +const matchNodeName = (yElement: XmlElement, lnode: LexicalNode | TextNode[]) => + !(lnode instanceof Array) && yElement.nodeName === lnode.getType(); diff --git a/packages/lexical-yjs/src/Utils.ts b/packages/lexical-yjs/src/Utils.ts index 8172836bf4b..c5a9a4939f8 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, @@ -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, @@ -58,7 +59,7 @@ const textExcludedProperties = new Set(['__text']); function isExcludedProperty( name: string, node: LexicalNode, - binding: Binding, + binding: BaseBinding, ): boolean { if ( baseExcludedProperties.has(name) || @@ -85,6 +86,37 @@ function isExcludedProperty( return excludedProperties != null && excludedProperties.has(name); } +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, Object.freeze(defaultProperties)); + }); + }); +} + +export function getDefaultNodeProperties( + node: LexicalNode, + binding: BaseBinding, +): {[property: string]: unknown} { + const type = node.__type; + const {nodeProperties} = binding; + const properties = nodeProperties.get(type); + invariant( + properties !== undefined, + 'Node properties for %s not initialized for sync', + type, + ); + return properties; +} + export function getIndexOfYjsNode( yjsParentNode: YjsNode, yjsNode: YjsNode, @@ -255,8 +287,13 @@ export function createLexicalNodeFromCollabNode( } export function $syncPropertiesFromYjs( - binding: Binding, - sharedType: XmlText | YMap | XmlElement, + binding: BaseBinding, + sharedType: + | XmlText + | YMap + | XmlElement + // v2 + | Record, lexicalNode: LexicalNode, keysChanged: null | Set, ): void { @@ -264,18 +301,20 @@ 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; 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(); } - $syncNodeStateToLexical(binding, sharedType, writableNode); + $syncNodeStateToLexical(sharedType, writableNode); } continue; } @@ -310,13 +349,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]; } } @@ -333,8 +377,7 @@ function sharedTypeSet( } function $syncNodeStateToLexical( - binding: Binding, - sharedType: XmlText | YMap | XmlElement, + sharedType: XmlText | YMap | XmlElement | Record, lexicalNode: LexicalNode, ): void { const existingState = sharedTypeGet(sharedType, '__state'); @@ -392,15 +435,9 @@ 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 = Object.keys( + getDefaultNodeProperties(nextLexicalNode, binding), + ); const EditorClass = binding.editor.constructor; @@ -554,7 +591,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 6a555099398..d32b9b06499 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 './types'; @@ -60,12 +66,18 @@ export type Delta = Array; export type YjsNode = Record; export type YjsEvent = Record; export type {Provider}; -export type {Binding, ClientID, ExcludedProperties} from './Bindings'; -export {createBinding} from './Bindings'; +export type { + BaseBinding, + Binding, + BindingV2, + ClientID, + ExcludedProperties, +} 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]), @@ -120,5 +132,7 @@ export { } from './SyncCursors'; export { syncLexicalUpdateToYjs, + syncLexicalUpdateToYjsV2__EXPERIMENTAL, syncYjsChangesToLexical, + syncYjsChangesToLexicalV2__EXPERIMENTAL, } from './SyncEditorStates';