diff --git a/packages/lexical-yjs/src/SyncCursors.ts b/packages/lexical-yjs/src/SyncCursors.ts index a55500e930e..ba519b4c475 100644 --- a/packages/lexical-yjs/src/SyncCursors.ts +++ b/packages/lexical-yjs/src/SyncCursors.ts @@ -242,19 +242,28 @@ function createRelativePositionV2( return createRelativePositionFromTypeIndex(yType, adjustedOffset, assoc); } else if (point.type === 'element') { invariant($isElementNode(node), 'Element point must be an element node'); - let i = 0; + // `offset` counts lexical children, but the index handed to yjs counts + // yjs children, and normalizeNodeContent collapses a run of adjacent + // TextNodes into a single XmlText child. Advance a lexical cursor to + // `offset` while counting each text run as one yjs child, mirroring + // $getNodeAndOffsetV2, which consumes one yjs offset per child and then + // skips the remainder of a text run. + let yIndex = 0; + let lexicalIndex = 0; let child = node.getFirstChild(); - while (child !== null && i < offset) { + while (child !== null && lexicalIndex < offset) { + let nextSibling = child.getNextSibling(); + lexicalIndex++; if ($isTextNode(child)) { - let nextSibling = child.getNextSibling(); while ($isTextNode(nextSibling)) { nextSibling = nextSibling.getNextSibling(); + lexicalIndex++; } } - i++; - child = child.getNextSibling(); + yIndex++; + child = nextSibling; } - return createRelativePositionFromTypeIndex(yType, i, assoc); + return createRelativePositionFromTypeIndex(yType, yIndex, assoc); } return null; } diff --git a/packages/lexical-yjs/src/__tests__/unit/SyncCursorsV2ElementPoint.test.ts b/packages/lexical-yjs/src/__tests__/unit/SyncCursorsV2ElementPoint.test.ts new file mode 100644 index 00000000000..cc8316de152 --- /dev/null +++ b/packages/lexical-yjs/src/__tests__/unit/SyncCursorsV2ElementPoint.test.ts @@ -0,0 +1,199 @@ +/** + * 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 { + buildEditorFromExtensions, + type LexicalEditorWithDispose, +} from '@lexical/extension'; +import { + $getAnchorAndFocusForUserState, + createBindingV2__EXPERIMENTAL, + type Provider, + type ProviderAwareness, + type UserState, +} from '@lexical/yjs'; +import { + $createParagraphNode, + $createRangeSelection, + $createTextNode, + $getRoot, + $getSelection, + $setSelection, + defineExtension, + type LexicalEditor, +} from 'lexical'; +import { + $createTestDecoratorNode, + TestDecoratorNode, +} from 'lexical/src/__tests__/utils'; +import {afterEach, assert, describe, expect, test} from 'vitest'; +import {Doc} from 'yjs'; + +import {syncLexicalSelectionToYjs} from '../../SyncCursors'; +import {$updateYFragment} from '../../SyncV2'; + +// In collab-v2 a run of adjacent TextNodes is serialized as a single XmlText +// child (see normalizeNodeContent in SyncV2), so a paragraph whose lexical +// children are [Text, Text, Decorator] has only two yjs children: +// [XmlText, XmlElement]. An element-type selection point therefore has to be +// converted from a lexical child offset into a yjs child index. +describe('collab-v2 element selection points', () => { + const editors: LexicalEditorWithDispose[] = []; + afterEach(() => { + for (const editor of editors) { + editor.dispose(); + } + editors.length = 0; + }); + + function createAwareness(): { + awareness: ProviderAwareness; + getState: () => UserState | null; + } { + let localState: UserState | null = { + anchorPos: null, + awarenessData: {}, + color: '#000000', + focusPos: null, + focusing: true, + name: 'test', + }; + return { + awareness: { + getLocalState: () => localState, + getStates: () => new Map(), + off: () => {}, + on: () => {}, + setLocalState: state => { + localState = state; + }, + setLocalStateField: (field, value) => { + if (localState !== null) { + localState = {...localState, [field]: value}; + } + }, + } as unknown as ProviderAwareness, + getState: () => localState, + }; + } + + function buildBinding() { + const editor = buildEditorFromExtensions( + defineExtension({ + $initialEditorState: null, + name: '[v2-element-point]', + nodes: [TestDecoratorNode], + }), + ); + editors.push(editor); + const doc = new Doc(); + const docMap = new Map([['v2-element-point', doc]]); + const binding = createBindingV2__EXPERIMENTAL( + editor, + 'v2-element-point', + doc, + docMap, + ); + return {binding, doc, editor}; + } + + function serialize( + editor: LexicalEditor, + binding: ReturnType, + ) { + editor.read(() => { + binding.doc.transact(() => { + $updateYFragment( + binding.doc, + binding.root, + $getRoot(), + binding, + new Set(['root']), + ); + }); + }); + } + + /** + * Put a collapsed element-type selection at `offset` inside the paragraph, + * push it through the awareness encoder, and decode it back. The encoded + * form is what remote peers receive, so a mismatch here is a remote cursor + * rendered at the wrong place. + */ + function roundTripElementOffset(offset: number): { + key: null | string; + offset: number; + paragraphKey: string; + } { + const {binding, editor} = buildBinding(); + const {awareness, getState} = createAwareness(); + const provider = {awareness} as unknown as Provider; + + let paragraphKey = ''; + editor.update( + () => { + const paragraph = $createParagraphNode(); + paragraph.append( + $createTextNode('a'), + $createTextNode('b').setFormat('bold'), + $createTestDecoratorNode(), + ); + $getRoot().clear().append(paragraph); + paragraphKey = paragraph.getKey(); + }, + {discrete: true}, + ); + + serialize(editor, binding); + + editor.update( + () => { + const selection = $createRangeSelection(); + selection.anchor.set(paragraphKey, offset, 'element'); + selection.focus.set(paragraphKey, offset, 'element'); + $setSelection(selection); + }, + {discrete: true}, + ); + + editor.read(() => { + syncLexicalSelectionToYjs(binding, provider, null, $getSelection()); + }); + + const state = getState(); + assert(state !== null); + + const decoded = editor.read(() => + $getAnchorAndFocusForUserState(binding, state), + ); + return { + key: decoded.anchorKey, + offset: decoded.anchorOffset, + paragraphKey, + }; + } + + test('an element point before a decorator that follows a text run round trips', () => { + // lexical children: [Text 'a', Text 'b', Decorator]; offset 2 is the + // caret just before the decorator. + const result = roundTripElementOffset(2); + expect(result.key).toBe(result.paragraphKey); + expect(result.offset).toBe(2); + }); + + test('an element point at the start round trips', () => { + const result = roundTripElementOffset(0); + expect(result.key).toBe(result.paragraphKey); + expect(result.offset).toBe(0); + }); + + test('an element point at the end round trips', () => { + const result = roundTripElementOffset(3); + expect(result.key).toBe(result.paragraphKey); + expect(result.offset).toBe(3); + }); +});