diff --git a/packages/lexical-playground/__tests__/e2e/Collaboration.spec.mjs b/packages/lexical-playground/__tests__/e2e/Collaboration.spec.mjs new file mode 100644 index 00000000000..ffb7e3877e1 --- /dev/null +++ b/packages/lexical-playground/__tests__/e2e/Collaboration.spec.mjs @@ -0,0 +1,233 @@ +/** + * 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 { + moveLeft, + selectCharacters, + toggleBold, + undo, +} from '../keyboardShortcuts/index.mjs'; +import { + assertHTML, + assertSelection, + click, + focusEditor, + html, + initialize, + IS_MAC, + sleep, + test, +} from '../utils/index.mjs'; + +async function toggleCheckList(page) { + await click(page, '.block-controls'); + await click(page, '.dropdown .icon.check-list'); +} + +test.describe('Collaboration', () => { + test.beforeEach(({isCollab, page}) => initialize({isCollab, page})); + + test('Undo with collaboration on', async ({ + isRichText, + page, + isCollab, + browserName, + }) => { + test.skip(!isCollab || IS_MAC); + + await focusEditor(page); + await page.keyboard.type('hello'); + await page.keyboard.press('Enter'); + await page.keyboard.press('Enter'); + await page.keyboard.type('world'); + await sleep(1050); // default merge interval is 1000, add 50ms as overhead due to CI latency. + await page.keyboard.press('ArrowUp'); + await page.keyboard.type('hello world again'); + + await assertHTML( + page, + html` +
+ hello +
++ hello world again +
++ world +
+ `, + ); + await assertSelection(page, { + anchorOffset: 17, + anchorPath: [1, 0, 0], + focusOffset: 17, + focusPath: [1, 0, 0], + }); + + await undo(page); + + await assertHTML( + page, + html` ++ hello +
+
+
+
+ world +
+ `, + ); + await assertSelection(page, { + anchorOffset: 0, + anchorPath: [1], + focusOffset: 0, + focusPath: [1], + }); + + await toggleCheckList(page); + await page.keyboard.type('a'); + await page.keyboard.press('Enter'); + await page.keyboard.type('b'); + await page.keyboard.press('Enter'); + + await assertHTML( + page, + html` ++ hello +
++ world +
+ `, + ); + await assertSelection(page, { + anchorOffset: 0, + anchorPath: [1, 2], + focusOffset: 0, + focusPath: [1, 2], + }); + + await undo(page); + + await assertHTML( + page, + html` ++ hello +
+
+
+
+ world +
+ `, + ); + await assertSelection(page, { + anchorOffset: 5, + anchorPath: [0, 0, 0], + focusOffset: 5, + focusPath: [0, 0, 0], + }); + + await page.keyboard.press('ArrowDown'); + await page.keyboard.type('Some bold text'); + + // Move caret to end of 'bold' + await moveLeft(page, ' text'.length); + + // Select the word 'bold' + await selectCharacters(page, 'left', 'bold'.length); + + await toggleBold(page); + + await assertHTML( + page, + html` ++ hello +
++ Some + + bold + + text +
++ world +
+ `, + ); + await assertSelection(page, { + anchorOffset: 4, + anchorPath: [1, 1, 0], + focusOffset: 0, + focusPath: [1, 1, 0], + }); + }); +}); diff --git a/packages/lexical-yjs/src/SyncEditorStates.ts b/packages/lexical-yjs/src/SyncEditorStates.ts index cb6bde82454..f2f272f3bee 100644 --- a/packages/lexical-yjs/src/SyncEditorStates.ts +++ b/packages/lexical-yjs/src/SyncEditorStates.ts @@ -8,7 +8,6 @@ import type {EditorState, NodeKey} from 'lexical'; -import {$createOffsetView} from '@lexical/offset'; import { $createParagraphNode, $getNodeByKey, @@ -16,7 +15,6 @@ import { $getSelection, $isRangeSelection, $isTextNode, - $setSelection, } from 'lexical'; import invariant from 'shared/invariant'; import {Text as YText, YEvent, YMapEvent, YTextEvent, YXmlEvent} from 'yjs'; @@ -31,6 +29,7 @@ import { syncLocalCursorPosition, } from './SyncCursors'; import { + $moveSelectionToPreviousNode, doesSelectionNeedRecovering, getOrInitCollabNodeFromSharedType, syncWithTransaction, @@ -97,8 +96,6 @@ export function syncYjsChangesToLexical( editor.update( () => { - const pendingEditorState: EditorState | null = editor._pendingEditorState; - for (let i = 0; i < events.length; i++) { const event = events[i]; syncEvent(binding, event); @@ -112,44 +109,15 @@ export function syncYjsChangesToLexical( const selection = $getSelection(); if ($isRangeSelection(selection)) { - // We can't use Yjs's cursor position here, as it doesn't always - // handle selection recovery correctly – especially on elements that - // get moved around or split. So instead, we roll our own solution. if (doesSelectionNeedRecovering(selection)) { const prevSelection = currentEditorState._selection; if ($isRangeSelection(prevSelection)) { - const prevOffsetView = $createOffsetView( - editor, - 0, - currentEditorState, - ); - const nextOffsetView = $createOffsetView( - editor, - 0, - pendingEditorState, - ); - const [start, end] = - prevOffsetView.getOffsetsFromSelection(prevSelection); - const nextSelection = - start >= 0 && end >= 0 - ? nextOffsetView.createSelectionFromOffsets( - start, - end, - prevOffsetView, - ) - : null; - - if (nextSelection !== null) { - $setSelection(nextSelection); - } else { - // Fallback is to use the Yjs cursor position - syncLocalCursorPosition(binding, provider); - - if (doesSelectionNeedRecovering(selection)) { - // Fallback - $getRoot().selectEnd(); - } + 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); } } @@ -174,7 +142,7 @@ export function syncYjsChangesToLexical( ); } -function handleNormalizationMergeConflicts( +function $handleNormalizationMergeConflicts( binding: Binding, normalizedNodes: Set