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
11 changes: 11 additions & 0 deletions packages/lexical-yjs/src/SyncCursors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -912,6 +912,17 @@ export function syncCursorPositions(
if (cursor === undefined) {
cursor = createCursor(name, color);
cursors.set(clientID, cursor);
} else if (cursor.name !== name || cursor.color !== color) {
// Awareness is mutable: a peer can rename itself or change colour at
// any time (the React plugin republishes local state whenever its
// `username` / `cursorColor` props change). The name and colour are
// baked into the caret DOM and the ::highlight() rule when the
// selection is built, so drop the stale selection here and let the
// code below rebuild it from the new values.
destroyCursor(binding, cursor);
cursor.name = name;
cursor.color = color;
cursor.selection = null;
}

if (focusing) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/**
* 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 {
createBinding,
type Provider,
syncCursorPositions,
type UserState,
} from '@lexical/yjs';
import {defineExtension} from 'lexical';
import {afterEach, assert, describe, expect, test} from 'vitest';
import {Doc} from 'yjs';

const REMOTE_CLIENT_ID = 4242;

function userState(name: string, color: string): UserState {
return {
anchorPos: null,
awarenessData: {},
color,
focusPos: null,
focusing: false,
name,
};
}

describe('syncCursorPositions awareness refresh', () => {
const editors: LexicalEditorWithDispose[] = [];
afterEach(() => {
for (const editor of editors) {
editor.dispose();
}
editors.length = 0;
});

function buildBinding() {
const editor = buildEditorFromExtensions(
defineExtension({
$initialEditorState: null,
name: '[cursor-awareness]',
}),
);
editors.push(editor);
const doc = new Doc();
const docMap = new Map<string, Doc>([['cursor-awareness', doc]]);
const binding = createBinding(
editor,
null as unknown as Provider,
'cursor-awareness',
doc,
docMap,
);
return {binding, editor};
}

function sync(
binding: ReturnType<typeof createBinding>,
state: UserState,
): void {
syncCursorPositions(binding, null as unknown as Provider, {
getAwarenessStates: () =>
new Map<number, UserState>([[REMOTE_CLIENT_ID, state]]),
});
}

test('a peer that renames itself updates its cursor name', () => {
const {binding} = buildBinding();

sync(binding, userState('Bob', '#ff0000'));
const cursor = binding.cursors.get(REMOTE_CLIENT_ID);
assert(cursor !== undefined);
expect(cursor.name).toBe('Bob');

sync(binding, userState('Robert', '#ff0000'));
expect(binding.cursors.get(REMOTE_CLIENT_ID)?.name).toBe('Robert');
});

test('a peer that changes colour updates its cursor colour', () => {
const {binding} = buildBinding();

sync(binding, userState('Bob', '#ff0000'));
expect(binding.cursors.get(REMOTE_CLIENT_ID)?.color).toBe('#ff0000');

sync(binding, userState('Bob', '#0000ff'));
expect(binding.cursors.get(REMOTE_CLIENT_ID)?.color).toBe('#0000ff');
});

test('an unchanged peer keeps the same cursor object', () => {
const {binding} = buildBinding();

sync(binding, userState('Bob', '#ff0000'));
const first = binding.cursors.get(REMOTE_CLIENT_ID);
sync(binding, userState('Bob', '#ff0000'));
expect(binding.cursors.get(REMOTE_CLIENT_ID)).toBe(first);
});
});
Loading