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, ): void { @@ -244,7 +212,7 @@ export function syncLexicalUpdateToYjs( // when we need to handle normalization merge conflicts. if (tags.has('collaboration') || tags.has('historic')) { if (normalizedNodes.size > 0) { - handleNormalizationMergeConflicts(binding, normalizedNodes); + $handleNormalizationMergeConflicts(binding, normalizedNodes); } return; diff --git a/packages/lexical-yjs/src/Utils.ts b/packages/lexical-yjs/src/Utils.ts index 677905788f8..3708b9ce6f7 100644 --- a/packages/lexical-yjs/src/Utils.ts +++ b/packages/lexical-yjs/src/Utils.ts @@ -9,6 +9,7 @@ import type {Binding, YjsNode} from '.'; import type { DecoratorNode, + EditorState, ElementNode, LexicalNode, NodeMap, @@ -18,6 +19,7 @@ import type { import { $getNodeByKey, + $getRoot, $isDecoratorNode, $isElementNode, $isLineBreakNode, @@ -170,7 +172,7 @@ function getNodeTypeFromSharedType( return type; } -export function getOrInitCollabNodeFromSharedType( +export function $getOrInitCollabNodeFromSharedType( binding: Binding, sharedType: XmlText | YMap | XmlElement, parent?: CollabElementNode, @@ -190,7 +192,7 @@ export function getOrInitCollabNodeFromSharedType( const sharedParent = sharedType.parent; const targetParent = parent === undefined && sharedParent !== null - ? getOrInitCollabNodeFromSharedType( + ? $getOrInitCollabNodeFromSharedType( binding, sharedParent as XmlText | YMap | XmlElement, ) @@ -215,6 +217,9 @@ export function getOrInitCollabNodeFromSharedType( return collabNode; } +/** @deprecated renamed to $getOrInitCollabNodeFromSharedType by @lexical/eslint-plugin rules-of-lexical */ +export const getOrInitCollabNodeFromSharedType = + $getOrInitCollabNodeFromSharedType; export function createLexicalNodeFromCollabNode( binding: Binding, @@ -473,7 +478,7 @@ export function syncWithTransaction(binding: Binding, fn: () => void): void { binding.doc.transact(fn, binding); } -export function createChildrenArray( +export function $createChildrenArray( element: ElementNode, nodeMap: null | NodeMap, ): Array { @@ -490,6 +495,8 @@ export function createChildrenArray( } return children; } +/** @deprecated renamed to $createChildrenArray by @lexical/eslint-plugin rules-of-lexical */ +export const createChildrenArray = $createChildrenArray; export function removeFromParent(node: LexicalNode): void { const oldParent = node.getParent(); @@ -541,3 +548,37 @@ export function removeFromParent(node: LexicalNode): void { writableNode.__parent = null; } } + +export function $moveSelectionToPreviousNode( + anchorNodeKey: string, + currentEditorState: EditorState, +) { + const anchorNode = currentEditorState._nodeMap.get(anchorNodeKey); + if (!anchorNode) { + $getRoot().selectStart(); + return; + } + // Get previous node + const prevNodeKey = anchorNode.__prev; + let prevNode: ElementNode | null = null; + if (prevNodeKey) { + prevNode = $getNodeByKey(prevNodeKey); + } + + // If previous node not found, get parent node + if (prevNode === null && anchorNode.__parent !== null) { + prevNode = $getNodeByKey(anchorNode.__parent); + } + if (prevNode === null) { + $getRoot().selectStart(); + return; + } + + if (prevNode !== null && prevNode.isAttached()) { + prevNode.selectEnd(); + return; + } else { + // If the found node is also deleted, select the next one + $moveSelectionToPreviousNode(prevNode.__key, currentEditorState); + } +}