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">
-
-
-
-

-
-
+ ${getExpectedDateTimeHtml()}
x
diff --git a/packages/lexical-playground/__tests__/e2e/Markdown.spec.mjs b/packages/lexical-playground/__tests__/e2e/Markdown.spec.mjs
index f95f06a79f3..1319d4eb730 100644
--- a/packages/lexical-playground/__tests__/e2e/Markdown.spec.mjs
+++ b/packages/lexical-playground/__tests__/e2e/Markdown.spec.mjs
@@ -23,6 +23,7 @@ import {
getHTML,
html,
initialize,
+ IS_COLLAB_V2,
LEGACY_EVENTS,
pasteFromClipboard,
pressToggleBold,
@@ -906,6 +907,9 @@ test.describe.parallel('Markdown', () => {
});
test('can import single decorator node (#2604)', async ({page}) => {
+ // TODO(collab-v2): nested editors are not supported yet
+ test.skip(IS_COLLAB_V2);
+
await focusEditor(page);
await page.keyboard.type(
'```markdown  => {
test('can import several text match transformers in a same line (#5385)', async ({
page,
}) => {
+ // TODO(collab-v2): nested editors are not supported yet
+ test.skip(IS_COLLAB_V2);
+
await focusEditor(page);
await page.keyboard.type(
'```markdown [link](https://lexical.dev)[link](https://lexical.dev) => {
const TYPED_MARKDOWN = `# Markdown Shortcuts
This is *italic*, _italic_, **bold**, __bold__, ~~strikethrough~~ text
-This is *__~~bold italic strikethrough~~__* text, ___~~this one too~~___
+This is ~~*__bold italic strikethrough__*~~ text, ___~~this one too~~___
It ~~___works [with links](https://lexical.io) too___~~
*Nested **stars tags** are handled too*
# Title
@@ -1101,7 +1108,7 @@ const TYPED_MARKDOWN_HTML = html`
This is
bold italic strikethrough
diff --git a/packages/lexical-playground/__tests__/e2e/Selection.spec.mjs b/packages/lexical-playground/__tests__/e2e/Selection.spec.mjs
index 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', () => {
-
-
- 
-
-
+ ${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
-
-
-

-
-
+ ${getExpectedDateTimeHtml({selected: true})}
BC
`,
@@ -1225,18 +1211,7 @@ test.describe.parallel('TextFormatting', () => {
html`
A
-
-
-

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

-
-
+ ${getExpectedDateTimeHtml()}
BC
`,
diff --git a/packages/lexical-playground/__tests__/e2e/Toolbar.spec.mjs b/packages/lexical-playground/__tests__/e2e/Toolbar.spec.mjs
index 0cc194dfaa9..dab2e8118c3 100644
--- a/packages/lexical-playground/__tests__/e2e/Toolbar.spec.mjs
+++ b/packages/lexical-playground/__tests__/e2e/Toolbar.spec.mjs
@@ -23,6 +23,7 @@ import {
html,
initialize,
insertSampleImage,
+ IS_COLLAB_V2,
SAMPLE_IMAGE_URL,
selectFromAlignDropdown,
selectFromInsertDropdown,
@@ -45,7 +46,8 @@ test.describe('Toolbar', () => {
tag: '@flaky',
},
async ({page, isPlainText}) => {
- test.skip(isPlainText);
+ // TODO(collab-v2): nested editors are not supported yet
+ test.skip(isPlainText || IS_COLLAB_V2);
await focusEditor(page);
// Add caption
diff --git a/packages/lexical-playground/__tests__/utils/index.mjs b/packages/lexical-playground/__tests__/utils/index.mjs
index 8308ac41985..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';