2 * Copyright (c) Meta Platforms, Inc. and affiliates.
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
9 import type {Binding} from './Bindings';
10 import type {BaseSelection, NodeKey, NodeMap, Point} from 'lexical';
11 import type {AbsolutePosition, RelativePosition} from 'yjs';
13 import {createDOMRange, createRectsFromDOMRange} from '@lexical/selection';
22 import invariant from 'lexical/shared/invariant';
24 compareRelativePositions,
25 createAbsolutePositionFromRelativePosition,
26 createRelativePositionFromTypeIndex,
29 import {Provider} from '.';
30 import {CollabDecoratorNode} from './CollabDecoratorNode';
31 import {CollabElementNode} from './CollabElementNode';
32 import {CollabLineBreakNode} from './CollabLineBreakNode';
33 import {CollabTextNode} from './CollabTextNode';
34 import {getPositionFromElementAndOffset} from './Utils';
36 export type CursorSelection = {
47 name: HTMLSpanElement;
48 selections: Array<HTMLElement>;
50 export type Cursor = {
53 selection: null | CursorSelection;
56 function createRelativePosition(
59 ): null | RelativePosition {
60 const collabNodeMap = binding.collabNodeMap;
61 const collabNode = collabNodeMap.get(point.key);
63 if (collabNode === undefined) {
67 let offset = point.offset;
68 let sharedType = collabNode.getSharedType();
70 if (collabNode instanceof CollabTextNode) {
71 sharedType = collabNode._parent._xmlText;
72 const currentOffset = collabNode.getOffset();
74 if (currentOffset === -1) {
78 offset = currentOffset + 1 + offset;
80 collabNode instanceof CollabElementNode &&
81 point.type === 'element'
83 const parent = point.getNode();
84 invariant($isElementNode(parent), 'Element point must be an element node');
85 let accumulatedOffset = 0;
87 let node = parent.getFirstChild();
88 while (node !== null && i++ < offset) {
89 if ($isTextNode(node)) {
90 accumulatedOffset += node.getTextContentSize() + 1;
94 node = node.getNextSibling();
96 offset = accumulatedOffset;
99 return createRelativePositionFromTypeIndex(sharedType, offset);
102 function createAbsolutePosition(
103 relativePosition: RelativePosition,
105 ): AbsolutePosition | null {
106 return createAbsolutePositionFromRelativePosition(
112 function shouldUpdatePosition(
113 currentPos: RelativePosition | null | undefined,
114 pos: RelativePosition | null | undefined,
116 if (currentPos == null) {
120 } else if (pos == null || !compareRelativePositions(currentPos, pos)) {
127 function createCursor(name: string, color: string): Cursor {
135 function destroySelection(binding: Binding, selection: CursorSelection) {
136 const cursorsContainer = binding.cursorsContainer;
138 if (cursorsContainer !== null) {
139 const selections = selection.selections;
140 const selectionsLength = selections.length;
142 for (let i = 0; i < selectionsLength; i++) {
143 cursorsContainer.removeChild(selections[i]);
148 function destroyCursor(binding: Binding, cursor: Cursor) {
149 const selection = cursor.selection;
151 if (selection !== null) {
152 destroySelection(binding, selection);
156 function createCursorSelection(
159 anchorOffset: number,
163 const color = cursor.color;
164 const caret = document.createElement('span');
165 caret.style.cssText = `position:absolute;top:0;bottom:0;right:-1px;width:1px;background-color:${color};z-index:10;`;
166 const name = document.createElement('span');
167 name.textContent = cursor.name;
168 name.style.cssText = `position:absolute;left:-2px;top:-16px;background-color:${color};color:#fff;line-height:12px;font-size:12px;padding:2px;font-family:Arial;font-weight:bold;white-space:nowrap;`;
169 caret.appendChild(name);
173 offset: anchorOffset,
186 function updateCursor(
189 nextSelection: null | CursorSelection,
192 const editor = binding.editor;
193 const rootElement = editor.getRootElement();
194 const cursorsContainer = binding.cursorsContainer;
196 if (cursorsContainer === null || rootElement === null) {
200 const cursorsContainerOffsetParent = cursorsContainer.offsetParent;
201 if (cursorsContainerOffsetParent === null) {
205 const containerRect = cursorsContainerOffsetParent.getBoundingClientRect();
206 const prevSelection = cursor.selection;
208 if (nextSelection === null) {
209 if (prevSelection === null) {
212 cursor.selection = null;
213 destroySelection(binding, prevSelection);
217 cursor.selection = nextSelection;
220 const caret = nextSelection.caret;
221 const color = nextSelection.color;
222 const selections = nextSelection.selections;
223 const anchor = nextSelection.anchor;
224 const focus = nextSelection.focus;
225 const anchorKey = anchor.key;
226 const focusKey = focus.key;
227 const anchorNode = nodeMap.get(anchorKey);
228 const focusNode = nodeMap.get(focusKey);
230 if (anchorNode == null || focusNode == null) {
233 let selectionRects: Array<DOMRect>;
235 // In the case of a collapsed selection on a linebreak, we need
236 // to improvise as the browser will return nothing here as <br>
237 // apparantly take up no visual space :/
238 // This won't work in all cases, but it's better than just showing
239 // nothing all the time.
240 if (anchorNode === focusNode && $isLineBreakNode(anchorNode)) {
242 editor.getElementByKey(anchorKey) as HTMLElement
243 ).getBoundingClientRect();
244 selectionRects = [brRect];
246 const range = createDOMRange(
254 if (range === null) {
257 selectionRects = createRectsFromDOMRange(editor, range);
260 const selectionsLength = selections.length;
261 const selectionRectsLength = selectionRects.length;
263 for (let i = 0; i < selectionRectsLength; i++) {
264 const selectionRect = selectionRects[i];
265 let selection = selections[i];
267 if (selection === undefined) {
268 selection = document.createElement('span');
269 selections[i] = selection;
270 const selectionBg = document.createElement('span');
271 selection.appendChild(selectionBg);
272 cursorsContainer.appendChild(selection);
275 const top = selectionRect.top - containerRect.top;
276 const left = selectionRect.left - containerRect.left;
277 const style = `position:absolute;top:${top}px;left:${left}px;height:${selectionRect.height}px;width:${selectionRect.width}px;pointer-events:none;z-index:5;`;
278 selection.style.cssText = style;
281 selection.firstChild as HTMLSpanElement
282 ).style.cssText = `${style}left:0;top:0;background-color:${color};opacity:0.3;`;
284 if (i === selectionRectsLength - 1) {
285 if (caret.parentNode !== selection) {
286 selection.appendChild(caret);
291 for (let i = selectionsLength - 1; i >= selectionRectsLength; i--) {
292 const selection = selections[i];
293 cursorsContainer.removeChild(selection);
298 export function $syncLocalCursorPosition(
302 const awareness = provider.awareness;
303 const localState = awareness.getLocalState();
305 if (localState === null) {
309 const anchorPos = localState.anchorPos;
310 const focusPos = localState.focusPos;
312 if (anchorPos !== null && focusPos !== null) {
313 const anchorAbsPos = createAbsolutePosition(anchorPos, binding);
314 const focusAbsPos = createAbsolutePosition(focusPos, binding);
316 if (anchorAbsPos !== null && focusAbsPos !== null) {
317 const [anchorCollabNode, anchorOffset] = getCollabNodeAndOffset(
321 const [focusCollabNode, focusOffset] = getCollabNodeAndOffset(
326 if (anchorCollabNode !== null && focusCollabNode !== null) {
327 const anchorKey = anchorCollabNode.getKey();
328 const focusKey = focusCollabNode.getKey();
330 const selection = $getSelection();
332 if (!$isRangeSelection(selection)) {
335 const anchor = selection.anchor;
336 const focus = selection.focus;
338 $setPoint(anchor, anchorKey, anchorOffset);
339 $setPoint(focus, focusKey, focusOffset);
345 function $setPoint(point: Point, key: NodeKey, offset: number): void {
346 if (point.key !== key || point.offset !== offset) {
347 let anchorNode = $getNodeByKey(key);
349 anchorNode !== null &&
350 !$isElementNode(anchorNode) &&
351 !$isTextNode(anchorNode)
353 const parent = anchorNode.getParentOrThrow();
354 key = parent.getKey();
355 offset = anchorNode.getIndexWithinParent();
358 point.set(key, offset, $isElementNode(anchorNode) ? 'element' : 'text');
362 function getCollabNodeAndOffset(
363 // eslint-disable-next-line @typescript-eslint/no-explicit-any
369 | CollabDecoratorNode
372 | CollabLineBreakNode
376 const collabNode = sharedType._collabNode;
378 if (collabNode === undefined) {
382 if (collabNode instanceof CollabElementNode) {
383 const {node, offset: collabNodeOffset} = getPositionFromElementAndOffset(
390 return [collabNode, 0];
392 return [node, collabNodeOffset];
399 export function syncCursorPositions(
403 const awarenessStates = Array.from(provider.awareness.getStates());
404 const localClientID = binding.clientID;
405 const cursors = binding.cursors;
406 const editor = binding.editor;
407 const nodeMap = editor._editorState._nodeMap;
408 const visitedClientIDs = new Set();
410 for (let i = 0; i < awarenessStates.length; i++) {
411 const awarenessState = awarenessStates[i];
412 const [clientID, awareness] = awarenessState;
414 if (clientID !== localClientID) {
415 visitedClientIDs.add(clientID);
416 const {anchorPos, focusPos, name, color, focusing} = awareness;
417 let selection = null;
419 let cursor = cursors.get(clientID);
421 if (cursor === undefined) {
422 cursor = createCursor(name, color);
423 cursors.set(clientID, cursor);
426 if (anchorPos !== null && focusPos !== null && focusing) {
427 const anchorAbsPos = createAbsolutePosition(anchorPos, binding);
428 const focusAbsPos = createAbsolutePosition(focusPos, binding);
430 if (anchorAbsPos !== null && focusAbsPos !== null) {
431 const [anchorCollabNode, anchorOffset] = getCollabNodeAndOffset(
435 const [focusCollabNode, focusOffset] = getCollabNodeAndOffset(
440 if (anchorCollabNode !== null && focusCollabNode !== null) {
441 const anchorKey = anchorCollabNode.getKey();
442 const focusKey = focusCollabNode.getKey();
443 selection = cursor.selection;
445 if (selection === null) {
446 selection = createCursorSelection(
454 const anchor = selection.anchor;
455 const focus = selection.focus;
456 anchor.key = anchorKey;
457 anchor.offset = anchorOffset;
458 focus.key = focusKey;
459 focus.offset = focusOffset;
465 updateCursor(binding, cursor, selection, nodeMap);
469 const allClientIDs = Array.from(cursors.keys());
471 for (let i = 0; i < allClientIDs.length; i++) {
472 const clientID = allClientIDs[i];
474 if (!visitedClientIDs.has(clientID)) {
475 const cursor = cursors.get(clientID);
477 if (cursor !== undefined) {
478 destroyCursor(binding, cursor);
479 cursors.delete(clientID);
485 export function syncLexicalSelectionToYjs(
488 prevSelection: null | BaseSelection,
489 nextSelection: null | BaseSelection,
491 const awareness = provider.awareness;
492 const localState = awareness.getLocalState();
494 if (localState === null) {
499 anchorPos: currentAnchorPos,
500 focusPos: currentFocusPos,
506 let anchorPos = null;
510 nextSelection === null ||
511 (currentAnchorPos !== null && !nextSelection.is(prevSelection))
513 if (prevSelection === null) {
518 if ($isRangeSelection(nextSelection)) {
519 anchorPos = createRelativePosition(nextSelection.anchor, binding);
520 focusPos = createRelativePosition(nextSelection.focus, binding);
524 shouldUpdatePosition(currentAnchorPos, anchorPos) ||
525 shouldUpdatePosition(currentFocusPos, focusPos)
527 awareness.setLocalState({