diff --git a/packages/lexical-playground/__tests__/e2e/Collaboration.spec.mjs b/packages/lexical-playground/__tests__/e2e/Collaboration.spec.mjs index 296d9ac21a0..90d4edeefca 100644 --- a/packages/lexical-playground/__tests__/e2e/Collaboration.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/Collaboration.spec.mjs @@ -413,4 +413,256 @@ test.describe('Collaboration', () => { `, ); }); + + test('Undo/redo where text node is split by formatting change', async ({ + isRichText, + page, + isCollab, + browserName, + }) => { + test.skip(!isCollab); + + // Left collaborator types two words, sets the second one to bold. + await focusEditor(page); + await page.keyboard.type('normal bold'); + + await sleep(1050); + await selectCharacters(page, 'left', 'bold'.length); + await toggleBold(page); + + await assertHTML( + page, + html` +

+ normal + + bold + +

+ `, + ); + + // Right collaborator types in the middle of the bold word. + await page + .frameLocator('iframe[name="right"]') + .locator('[data-lexical-editor="true"]') + .focus(); + await page.keyboard.press('ArrowDown', {delay: 50}); + await page.keyboard.press('ArrowLeft'); + await page.keyboard.press('ArrowLeft'); + await page.keyboard.type('BOLD'); + + await assertHTML( + page, + html` +

+ normal + + boBOLDld + +

+ `, + ); + + // 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 +

+ `, + ); + + // Left collaborator redoes the bold text. + await page.frameLocator('iframe[name="left"]').getByLabel('Redo').click(); + + // The text should be back as it was prior to the undo. + await assertHTML( + page, + html` +

+ normal + + boBOLDld + +

+ `, + ); + + // Collaboration should still work. + await page + .frameLocator('iframe[name="right"]') + .locator('[data-lexical-editor="true"]') + .focus(); + await page.keyboard.press('ArrowDown', {delay: 50}); + await page.keyboard.type(' text'); + + await assertHTML( + page, + html` +

+ normal + + boBOLDld text + +

+ `, + ); + }); + + test('Undo/redo where text node is split by inline element node', async ({ + isRichText, + page, + isCollab, + browserName, + }) => { + test.skip(!isCollab); + + // Left collaborator types some text, then splits the text nodes with an element node. + await focusEditor(page); + await page.keyboard.type('Check out the website!'); + + await sleep(1050); + await page.keyboard.press('ArrowLeft'); + await selectCharacters(page, 'left', 'website'.length); + await page + .frameLocator('iframe[name="left"]') + .getByLabel('Insert link') + .first() + .click(); + + await assertHTML( + page, + html` +

+ Check out the + + website + + ! +

+ `, + ); + + // Right collaborator adds some text. + await page + .frameLocator('iframe[name="right"]') + .locator('[data-lexical-editor="true"]') + .focus(); + await page.keyboard.press('ArrowDown', {delay: 50}); + await page.keyboard.press('ArrowLeft'); + await page.keyboard.type(' now'); + + await assertHTML( + page, + html` +

+ Check out the + + website + + now! +

+ `, + ); + + // 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 +

+ `, + ); + + // Left collaborator redoes the link. + await page.frameLocator('iframe[name="left"]').getByLabel('Redo').click(); + + // The text should be back as it was prior to the undo. + await assertHTML( + page, + html` +

+ Check out the + + website + + now! +

+ `, + ); + + // Collaboration should still work. + await page + .frameLocator('iframe[name="right"]') + .locator('[data-lexical-editor="true"]') + .focus(); + await page.keyboard.press('ArrowDown', {delay: 50}); + await page.keyboard.type(' Check it out.'); + + await assertHTML( + page, + html` +

+ Check out the + + website + + now! Check it out. +

+ `, + ); + }); }); diff --git a/packages/lexical-yjs/src/CollabElementNode.ts b/packages/lexical-yjs/src/CollabElementNode.ts index c38171af0e8..14fd1fbf0c8 100644 --- a/packages/lexical-yjs/src/CollabElementNode.ts +++ b/packages/lexical-yjs/src/CollabElementNode.ts @@ -129,6 +129,7 @@ export class CollabElementNode { ): void { const children = this._children; let currIndex = 0; + let pendingSplitText = null; for (let i = 0; i < deltas.length; i++) { const delta = deltas[i]; @@ -211,7 +212,7 @@ export class CollabElementNode { currIndex += insertDelta.length; } else { const sharedType = insertDelta; - const {nodeIndex} = getPositionFromElementAndOffset( + const {node, nodeIndex, length} = getPositionFromElementAndOffset( this, currIndex, false, @@ -221,7 +222,30 @@ export class CollabElementNode { sharedType as XmlText | YMap | XmlElement, this, ); - children.splice(nodeIndex, 0, collabNode); + if ( + node instanceof CollabTextNode && + length > 0 && + length < node._text.length + ) { + // Trying to insert in the middle of a text node; split the text. + const text = node._text; + const splitIdx = text.length - length; + node._text = spliceString(text, splitIdx, length, ''); + children.splice(nodeIndex + 1, 0, collabNode); + // The insert that triggers the text split might not be a text node. Need to keep a + // reference to the remaining text so that it can be added when we do create one. + pendingSplitText = spliceString(text, 0, splitIdx, ''); + } else { + children.splice(nodeIndex, 0, collabNode); + } + if ( + pendingSplitText !== null && + collabNode instanceof CollabTextNode + ) { + // Found a text node to insert the pending text into. + collabNode._text = pendingSplitText + collabNode._text; + pendingSplitText = null; + } currIndex += 1; } } else {