Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 15 additions & 6 deletions packages/lexical-yjs/src/SyncCursors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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 => {

Check failure on line 71 in packages/lexical-yjs/src/__tests__/unit/SyncCursorsV2ElementPoint.test.ts

View workflow job for this annotation

GitHub Actions / core-tests / integrity (24.x)

Parameter 'state' implicitly has an 'any' type.
localState = state;
},
setLocalStateField: (field, value) => {

Check failure on line 74 in packages/lexical-yjs/src/__tests__/unit/SyncCursorsV2ElementPoint.test.ts

View workflow job for this annotation

GitHub Actions / core-tests / integrity (24.x)

Parameter 'value' implicitly has an 'any' type.

Check failure on line 74 in packages/lexical-yjs/src/__tests__/unit/SyncCursorsV2ElementPoint.test.ts

View workflow job for this annotation

GitHub Actions / core-tests / integrity (24.x)

Parameter 'field' implicitly has an 'any' type.
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<string, Doc>([['v2-element-point', doc]]);
const binding = createBindingV2__EXPERIMENTAL(
editor,
'v2-element-point',
doc,
docMap,
);
return {binding, doc, editor};
}

function serialize(
editor: LexicalEditor,
binding: ReturnType<typeof createBindingV2__EXPERIMENTAL>,
) {
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);
});
});
Loading