Skip to content

Commit

Permalink
[lexical-yjs] Bug fix: Fix cursor position after undo in collab mode (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
KatsiarynaDzibrova authored May 10, 2024
1 parent 3fc9fb6 commit b5fa87d
Show file tree
Hide file tree
Showing 3 changed files with 285 additions and 43 deletions.
233 changes: 233 additions & 0 deletions packages/lexical-playground/__tests__/e2e/Collaboration.spec.mjs
Original file line number Diff line number Diff line change
@@ -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`
<p
class="PlaygroundEditorTheme__paragraph PlaygroundEditorTheme__ltr"
dir="ltr">
<span data-lexical-text="true">hello</span>
</p>
<p
class="PlaygroundEditorTheme__paragraph PlaygroundEditorTheme__ltr"
dir="ltr">
<span data-lexical-text="true">hello world again</span>
</p>
<p
class="PlaygroundEditorTheme__paragraph PlaygroundEditorTheme__ltr"
dir="ltr">
<span data-lexical-text="true">world</span>
</p>
`,
);
await assertSelection(page, {
anchorOffset: 17,
anchorPath: [1, 0, 0],
focusOffset: 17,
focusPath: [1, 0, 0],
});

await undo(page);

await assertHTML(
page,
html`
<p
class="PlaygroundEditorTheme__paragraph PlaygroundEditorTheme__ltr"
dir="ltr">
<span data-lexical-text="true">hello</span>
</p>
<p class="PlaygroundEditorTheme__paragraph">
<br />
</p>
<p
class="PlaygroundEditorTheme__paragraph PlaygroundEditorTheme__ltr"
dir="ltr">
<span data-lexical-text="true">world</span>
</p>
`,
);
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`
<p
class="PlaygroundEditorTheme__paragraph PlaygroundEditorTheme__ltr"
dir="ltr">
<span data-lexical-text="true">hello</span>
</p>
<ul class="PlaygroundEditorTheme__ul PlaygroundEditorTheme__checklist">
<li
aria-checked="false"
role="checkbox"
tabindex="-1"
class="PlaygroundEditorTheme__listItem PlaygroundEditorTheme__listItemUnchecked PlaygroundEditorTheme__ltr"
dir="ltr"
value="1">
<span data-lexical-text="true">a</span>
</li>
<li
aria-checked="false"
role="checkbox"
tabindex="-1"
class="PlaygroundEditorTheme__listItem PlaygroundEditorTheme__listItemUnchecked PlaygroundEditorTheme__ltr"
dir="ltr"
value="2">
<span data-lexical-text="true">b</span>
</li>
<li
aria-checked="false"
class="PlaygroundEditorTheme__listItem PlaygroundEditorTheme__listItemUnchecked"
role="checkbox"
tabindex="-1"
value="3">
<br />
</li>
</ul>
<p
class="PlaygroundEditorTheme__paragraph PlaygroundEditorTheme__ltr"
dir="ltr">
<span data-lexical-text="true">world</span>
</p>
`,
);
await assertSelection(page, {
anchorOffset: 0,
anchorPath: [1, 2],
focusOffset: 0,
focusPath: [1, 2],
});

await undo(page);

await assertHTML(
page,
html`
<p
class="PlaygroundEditorTheme__paragraph PlaygroundEditorTheme__ltr"
dir="ltr">
<span data-lexical-text="true">hello</span>
</p>
<p class="PlaygroundEditorTheme__paragraph">
<br />
</p>
<p
class="PlaygroundEditorTheme__paragraph PlaygroundEditorTheme__ltr"
dir="ltr">
<span data-lexical-text="true">world</span>
</p>
`,
);
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`
<p
class="PlaygroundEditorTheme__paragraph PlaygroundEditorTheme__ltr"
dir="ltr">
<span data-lexical-text="true">hello</span>
</p>
<p
class="PlaygroundEditorTheme__paragraph PlaygroundEditorTheme__ltr"
dir="ltr">
<span data-lexical-text="true">Some</span>
<strong
class="PlaygroundEditorTheme__textBold"
data-lexical-text="true">
bold
</strong>
<span data-lexical-text="true">text</span>
</p>
<p
class="PlaygroundEditorTheme__paragraph PlaygroundEditorTheme__ltr"
dir="ltr">
<span data-lexical-text="true">world</span>
</p>
`,
);
await assertSelection(page, {
anchorOffset: 4,
anchorPath: [1, 1, 0],
focusOffset: 0,
focusPath: [1, 1, 0],
});
});
});
48 changes: 8 additions & 40 deletions packages/lexical-yjs/src/SyncEditorStates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,13 @@

import type {EditorState, NodeKey} from 'lexical';

import {$createOffsetView} from '@lexical/offset';
import {
$createParagraphNode,
$getNodeByKey,
$getRoot,
$getSelection,
$isRangeSelection,
$isTextNode,
$setSelection,
} from 'lexical';
import invariant from 'shared/invariant';
import {Text as YText, YEvent, YMapEvent, YTextEvent, YXmlEvent} from 'yjs';
Expand All @@ -31,6 +29,7 @@ import {
syncLocalCursorPosition,
} from './SyncCursors';
import {
$moveSelectionToPreviousNode,
doesSelectionNeedRecovering,
getOrInitCollabNodeFromSharedType,
syncWithTransaction,
Expand Down Expand Up @@ -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);
Expand All @@ -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);
}
}

Expand All @@ -174,7 +142,7 @@ export function syncYjsChangesToLexical(
);
}

function handleNormalizationMergeConflicts(
function $handleNormalizationMergeConflicts(
binding: Binding,
normalizedNodes: Set<NodeKey>,
): void {
Expand Down Expand Up @@ -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;
Expand Down
Loading

0 comments on commit b5fa87d

Please sign in to comment.