diff --git a/packages/lexical-playground/__tests__/e2e/Collaboration.spec.mjs b/packages/lexical-playground/__tests__/e2e/Collaboration.spec.mjs index 762ee82e94e..b47b3c04f6a 100644 --- a/packages/lexical-playground/__tests__/e2e/Collaboration.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/Collaboration.spec.mjs @@ -230,4 +230,161 @@ test.describe('Collaboration', () => { focusPath: [1, 1, 0], }); }); + + test('Remove dangling text from YJS when there is no preceding text node', async ({ + isRichText, + page, + isCollab, + browserName, + }) => { + test.skip(!isCollab); + + // Left collaborator types two paragraphs of text + await focusEditor(page); + await page.keyboard.type('Line 1'); + await page.keyboard.press('Enter'); + await sleep(1050); // default merge interval is 1000, add 50ms as overhead due to CI latency. + await page.keyboard.type('This is a test. '); + + // Right collaborator types at the end of paragraph 2 + await sleep(1050); + await page + .frameLocator('iframe[name="right"]') + .locator('[data-lexical-editor="true"]') + .focus(); + await page.keyboard.press('ArrowDown'); // Move caret to end of paragraph 2 + await page.keyboard.press('ArrowDown'); + await page.keyboard.type('Word'); + + await assertHTML( + page, + html` +
+ Line 1 +
++ This is a test. Word +
+ `, + ); + + // Left collaborator undoes their text in the second paragraph. + await sleep(50); + await page.frameLocator('iframe[name="left"]').getByLabel('Undo').click(); + + // The undo also removed the text node from YJS. + // Check that the dangling text from right user was also removed. + await assertHTML( + page, + html` ++ Line 1 +
++ Line 1 +
++ normal + + boldBOLD + +
+ `, + ); + + // Left collaborator undoes their bold text. + await sleep(50); + await page.frameLocator('iframe[name="left"]').getByLabel('Undo').click(); + + // The undo also removed bold the text node from YJS. + // Check that the dangling text from right user was merged into the preceding text node. + await assertHTML( + page, + html` ++ normalBOLD +
+ `, + ); + + // Left collaborator refreshes their page + await page.evaluate(() => { + document + .querySelector('iframe[name="left"]') + .contentDocument.location.reload(); + }); + + // Page content should be the same as before the refresh + await assertHTML( + page, + html` ++ normalBOLD +
+ `, + ); + }); }); diff --git a/packages/lexical-yjs/src/CollabElementNode.ts b/packages/lexical-yjs/src/CollabElementNode.ts index f4c3f124c55..c38171af0e8 100644 --- a/packages/lexical-yjs/src/CollabElementNode.ts +++ b/packages/lexical-yjs/src/CollabElementNode.ts @@ -157,21 +157,25 @@ export class CollabElementNode { nodeIndex !== 0 ? children[nodeIndex - 1] : null; const nodeSize = node.getSize(); - if ( - offset === 0 && - delCount === 1 && - nodeIndex > 0 && - prevCollabNode instanceof CollabTextNode && - length === nodeSize && - // If the node has no keys, it's been deleted - Array.from(node._map.keys()).length === 0 - ) { - // Merge the text node with previous. - prevCollabNode._text += node._text; - children.splice(nodeIndex, 1); - } else if (offset === 0 && delCount === nodeSize) { - // The entire thing needs removing + if (offset === 0 && length === nodeSize) { + // Text node has been deleted. children.splice(nodeIndex, 1); + // If this was caused by an undo from YJS, there could be dangling text. + const danglingText = spliceString( + node._text, + offset, + delCount - 1, + '', + ); + if (danglingText.length > 0) { + if (prevCollabNode instanceof CollabTextNode) { + // Merge the text node with previous. + prevCollabNode._text += danglingText; + } else { + // No previous text node to merge into, just delete the text. + this._xmlText.delete(offset, danglingText.length); + } + } } else { node._text = spliceString(node._text, offset, delCount, ''); }