Skip to content

Commit

Permalink
[lexical-yjs] Bug fix: handle text node being split by Yjs redo
Browse files Browse the repository at this point in the history
  • Loading branch information
james-atticus committed Jan 26, 2025
1 parent 6a98a47 commit 3ffbfcc
Show file tree
Hide file tree
Showing 2 changed files with 278 additions and 2 deletions.
252 changes: 252 additions & 0 deletions packages/lexical-playground/__tests__/e2e/Collaboration.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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`
<p
class="PlaygroundEditorTheme__paragraph PlaygroundEditorTheme__ltr"
dir="ltr">
<span data-lexical-text="true">normal</span>
<strong
class="PlaygroundEditorTheme__textBold"
data-lexical-text="true">
bold
</strong>
</p>
`,
);

// 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`
<p
class="PlaygroundEditorTheme__paragraph PlaygroundEditorTheme__ltr"
dir="ltr">
<span data-lexical-text="true">normal</span>
<strong
class="PlaygroundEditorTheme__textBold"
data-lexical-text="true">
boBOLDld
</strong>
</p>
`,
);

// 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`
<p
class="PlaygroundEditorTheme__paragraph PlaygroundEditorTheme__ltr"
dir="ltr">
<span data-lexical-text="true">normal boldBOLD</span>
</p>
`,
);

// 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`
<p
class="PlaygroundEditorTheme__paragraph PlaygroundEditorTheme__ltr"
dir="ltr">
<span data-lexical-text="true">normal</span>
<strong
class="PlaygroundEditorTheme__textBold"
data-lexical-text="true">
boBOLDld
</strong>
</p>
`,
);

// 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`
<p
class="PlaygroundEditorTheme__paragraph PlaygroundEditorTheme__ltr"
dir="ltr">
<span data-lexical-text="true">normal</span>
<strong
class="PlaygroundEditorTheme__textBold"
data-lexical-text="true">
boBOLDld text
</strong>
</p>
`,
);
});

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`
<p
class="PlaygroundEditorTheme__paragraph PlaygroundEditorTheme__ltr"
dir="ltr">
<span data-lexical-text="true">Check out the</span>
<a
class="PlaygroundEditorTheme__link PlaygroundEditorTheme__ltr"
dir="ltr"
href="https://"
rel="noreferrer">
<span data-lexical-text="true">website</span>
</a>
<span data-lexical-text="true">!</span>
</p>
`,
);

// 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`
<p
class="PlaygroundEditorTheme__paragraph PlaygroundEditorTheme__ltr"
dir="ltr">
<span data-lexical-text="true">Check out the</span>
<a
class="PlaygroundEditorTheme__link PlaygroundEditorTheme__ltr"
dir="ltr"
href="https://"
rel="noreferrer">
<span data-lexical-text="true">website</span>
</a>
<span data-lexical-text="true">now!</span>
</p>
`,
);

// 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`
<p
class="PlaygroundEditorTheme__paragraph PlaygroundEditorTheme__ltr"
dir="ltr">
<span data-lexical-text="true">Check out the website! now</span>
</p>
`,
);

// 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`
<p
class="PlaygroundEditorTheme__paragraph PlaygroundEditorTheme__ltr"
dir="ltr">
<span data-lexical-text="true">Check out the</span>
<a
class="PlaygroundEditorTheme__link PlaygroundEditorTheme__ltr"
dir="ltr"
href="https://"
rel="noreferrer">
<span data-lexical-text="true">website</span>
</a>
<span data-lexical-text="true">now!</span>
</p>
`,
);

// 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`
<p
class="PlaygroundEditorTheme__paragraph PlaygroundEditorTheme__ltr"
dir="ltr">
<span data-lexical-text="true">Check out the</span>
<a
class="PlaygroundEditorTheme__link PlaygroundEditorTheme__ltr"
dir="ltr"
href="https://"
rel="noreferrer">
<span data-lexical-text="true">website</span>
</a>
<span data-lexical-text="true">now! Check it out.</span>
</p>
`,
);
});
});
28 changes: 26 additions & 2 deletions packages/lexical-yjs/src/CollabElementNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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,
Expand All @@ -221,7 +222,30 @@ export class CollabElementNode {
sharedType as XmlText | YMap<unknown> | 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 {
Expand Down

0 comments on commit 3ffbfcc

Please sign in to comment.