resources/js/wysiwyg/lexical/yjs/SyncCursors.ts
/**
* 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 type {Binding} from './Bindings';
import type {BaseSelection, NodeKey, NodeMap, Point} from 'lexical';
import type {AbsolutePosition, RelativePosition} from 'yjs';
import {createDOMRange, createRectsFromDOMRange} from '@lexical/selection';
import {
$getNodeByKey,
$getSelection,
$isElementNode,
$isLineBreakNode,
$isRangeSelection,
$isTextNode,
} from 'lexical';
import invariant from 'lexical/shared/invariant';
import {
compareRelativePositions,
createAbsolutePositionFromRelativePosition,
createRelativePositionFromTypeIndex,
} from 'yjs';
import {Provider} from '.';
import {CollabDecoratorNode} from './CollabDecoratorNode';
import {CollabElementNode} from './CollabElementNode';
import {CollabLineBreakNode} from './CollabLineBreakNode';
import {CollabTextNode} from './CollabTextNode';
import {getPositionFromElementAndOffset} from './Utils';
export type CursorSelection = {
anchor: {
key: NodeKey;
offset: number;
};
caret: HTMLElement;
color: string;
focus: {
key: NodeKey;
offset: number;
};
name: HTMLSpanElement;
selections: Array<HTMLElement>;
};
export type Cursor = {
color: string;
name: string;
selection: null | CursorSelection;
};
function createRelativePosition(
point: Point,
binding: Binding,
): null | RelativePosition {
const collabNodeMap = binding.collabNodeMap;
const collabNode = collabNodeMap.get(point.key);
if (collabNode === undefined) {
return null;
}
let offset = point.offset;
let sharedType = collabNode.getSharedType();
if (collabNode instanceof CollabTextNode) {
sharedType = collabNode._parent._xmlText;
const currentOffset = collabNode.getOffset();
if (currentOffset === -1) {
return null;
}
offset = currentOffset + 1 + offset;
} else if (
collabNode instanceof CollabElementNode &&
point.type === 'element'
) {
const parent = point.getNode();
invariant($isElementNode(parent), 'Element point must be an element node');
let accumulatedOffset = 0;
let i = 0;
let node = parent.getFirstChild();
while (node !== null && i++ < offset) {
if ($isTextNode(node)) {
accumulatedOffset += node.getTextContentSize() + 1;
} else {
accumulatedOffset++;
}
node = node.getNextSibling();
}
offset = accumulatedOffset;
}
return createRelativePositionFromTypeIndex(sharedType, offset);
}
function createAbsolutePosition(
relativePosition: RelativePosition,
binding: Binding,
): AbsolutePosition | null {
return createAbsolutePositionFromRelativePosition(
relativePosition,
binding.doc,
);
}
function shouldUpdatePosition(
currentPos: RelativePosition | null | undefined,
pos: RelativePosition | null | undefined,
): boolean {
if (currentPos == null) {
if (pos != null) {
return true;
}
} else if (pos == null || !compareRelativePositions(currentPos, pos)) {
return true;
}
return false;
}
function createCursor(name: string, color: string): Cursor {
return {
color: color,
name: name,
selection: null,
};
}
function destroySelection(binding: Binding, selection: CursorSelection) {
const cursorsContainer = binding.cursorsContainer;
if (cursorsContainer !== null) {
const selections = selection.selections;
const selectionsLength = selections.length;
for (let i = 0; i < selectionsLength; i++) {
cursorsContainer.removeChild(selections[i]);
}
}
}
function destroyCursor(binding: Binding, cursor: Cursor) {
const selection = cursor.selection;
if (selection !== null) {
destroySelection(binding, selection);
}
}
function createCursorSelection(
cursor: Cursor,
anchorKey: NodeKey,
anchorOffset: number,
focusKey: NodeKey,
focusOffset: number,
): CursorSelection {
const color = cursor.color;
const caret = document.createElement('span');
caret.style.cssText = `position:absolute;top:0;bottom:0;right:-1px;width:1px;background-color:${color};z-index:10;`;
const name = document.createElement('span');
name.textContent = cursor.name;
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;`;
caret.appendChild(name);
return {
anchor: {
key: anchorKey,
offset: anchorOffset,
},
caret,
color,
focus: {
key: focusKey,
offset: focusOffset,
},
name,
selections: [],
};
}
function updateCursor(
binding: Binding,
cursor: Cursor,
nextSelection: null | CursorSelection,
nodeMap: NodeMap,
): void {
const editor = binding.editor;
const rootElement = editor.getRootElement();
const cursorsContainer = binding.cursorsContainer;
if (cursorsContainer === null || rootElement === null) {
return;
}
const cursorsContainerOffsetParent = cursorsContainer.offsetParent;
if (cursorsContainerOffsetParent === null) {
return;
}
const containerRect = cursorsContainerOffsetParent.getBoundingClientRect();
const prevSelection = cursor.selection;
if (nextSelection === null) {
if (prevSelection === null) {
return;
} else {
cursor.selection = null;
destroySelection(binding, prevSelection);
return;
}
} else {
cursor.selection = nextSelection;
}
const caret = nextSelection.caret;
const color = nextSelection.color;
const selections = nextSelection.selections;
const anchor = nextSelection.anchor;
const focus = nextSelection.focus;
const anchorKey = anchor.key;
const focusKey = focus.key;
const anchorNode = nodeMap.get(anchorKey);
const focusNode = nodeMap.get(focusKey);
if (anchorNode == null || focusNode == null) {
return;
}
let selectionRects: Array<DOMRect>;
// In the case of a collapsed selection on a linebreak, we need
// to improvise as the browser will return nothing here as <br>
// apparantly take up no visual space :/
// This won't work in all cases, but it's better than just showing
// nothing all the time.
if (anchorNode === focusNode && $isLineBreakNode(anchorNode)) {
const brRect = (
editor.getElementByKey(anchorKey) as HTMLElement
).getBoundingClientRect();
selectionRects = [brRect];
} else {
const range = createDOMRange(
editor,
anchorNode,
anchor.offset,
focusNode,
focus.offset,
);
if (range === null) {
return;
}
selectionRects = createRectsFromDOMRange(editor, range);
}
const selectionsLength = selections.length;
const selectionRectsLength = selectionRects.length;
for (let i = 0; i < selectionRectsLength; i++) {
const selectionRect = selectionRects[i];
let selection = selections[i];
if (selection === undefined) {
selection = document.createElement('span');
selections[i] = selection;
const selectionBg = document.createElement('span');
selection.appendChild(selectionBg);
cursorsContainer.appendChild(selection);
}
const top = selectionRect.top - containerRect.top;
const left = selectionRect.left - containerRect.left;
const style = `position:absolute;top:${top}px;left:${left}px;height:${selectionRect.height}px;width:${selectionRect.width}px;pointer-events:none;z-index:5;`;
selection.style.cssText = style;
(
selection.firstChild as HTMLSpanElement
).style.cssText = `${style}left:0;top:0;background-color:${color};opacity:0.3;`;
if (i === selectionRectsLength - 1) {
if (caret.parentNode !== selection) {
selection.appendChild(caret);
}
}
}
for (let i = selectionsLength - 1; i >= selectionRectsLength; i--) {
const selection = selections[i];
cursorsContainer.removeChild(selection);
selections.pop();
}
}
export function $syncLocalCursorPosition(
binding: Binding,
provider: Provider,
): void {
const awareness = provider.awareness;
const localState = awareness.getLocalState();
if (localState === null) {
return;
}
const anchorPos = localState.anchorPos;
const focusPos = localState.focusPos;
if (anchorPos !== null && focusPos !== null) {
const anchorAbsPos = createAbsolutePosition(anchorPos, binding);
const focusAbsPos = createAbsolutePosition(focusPos, binding);
if (anchorAbsPos !== null && focusAbsPos !== null) {
const [anchorCollabNode, anchorOffset] = getCollabNodeAndOffset(
anchorAbsPos.type,
anchorAbsPos.index,
);
const [focusCollabNode, focusOffset] = getCollabNodeAndOffset(
focusAbsPos.type,
focusAbsPos.index,
);
if (anchorCollabNode !== null && focusCollabNode !== null) {
const anchorKey = anchorCollabNode.getKey();
const focusKey = focusCollabNode.getKey();
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return;
}
const anchor = selection.anchor;
const focus = selection.focus;
$setPoint(anchor, anchorKey, anchorOffset);
$setPoint(focus, focusKey, focusOffset);
}
}
}
}
function $setPoint(point: Point, key: NodeKey, offset: number): void {
if (point.key !== key || point.offset !== offset) {
let anchorNode = $getNodeByKey(key);
if (
anchorNode !== null &&
!$isElementNode(anchorNode) &&
!$isTextNode(anchorNode)
) {
const parent = anchorNode.getParentOrThrow();
key = parent.getKey();
offset = anchorNode.getIndexWithinParent();
anchorNode = parent;
}
point.set(key, offset, $isElementNode(anchorNode) ? 'element' : 'text');
}
}
function getCollabNodeAndOffset(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
sharedType: any,
offset: number,
): [
(
| null
| CollabDecoratorNode
| CollabElementNode
| CollabTextNode
| CollabLineBreakNode
),
number,
] {
const collabNode = sharedType._collabNode;
if (collabNode === undefined) {
return [null, 0];
}
if (collabNode instanceof CollabElementNode) {
const {node, offset: collabNodeOffset} = getPositionFromElementAndOffset(
collabNode,
offset,
true,
);
if (node === null) {
return [collabNode, 0];
} else {
return [node, collabNodeOffset];
}
}
return [null, 0];
}
export function syncCursorPositions(
binding: Binding,
provider: Provider,
): void {
const awarenessStates = Array.from(provider.awareness.getStates());
const localClientID = binding.clientID;
const cursors = binding.cursors;
const editor = binding.editor;
const nodeMap = editor._editorState._nodeMap;
const visitedClientIDs = new Set();
for (let i = 0; i < awarenessStates.length; i++) {
const awarenessState = awarenessStates[i];
const [clientID, awareness] = awarenessState;
if (clientID !== localClientID) {
visitedClientIDs.add(clientID);
const {anchorPos, focusPos, name, color, focusing} = awareness;
let selection = null;
let cursor = cursors.get(clientID);
if (cursor === undefined) {
cursor = createCursor(name, color);
cursors.set(clientID, cursor);
}
if (anchorPos !== null && focusPos !== null && focusing) {
const anchorAbsPos = createAbsolutePosition(anchorPos, binding);
const focusAbsPos = createAbsolutePosition(focusPos, binding);
if (anchorAbsPos !== null && focusAbsPos !== null) {
const [anchorCollabNode, anchorOffset] = getCollabNodeAndOffset(
anchorAbsPos.type,
anchorAbsPos.index,
);
const [focusCollabNode, focusOffset] = getCollabNodeAndOffset(
focusAbsPos.type,
focusAbsPos.index,
);
if (anchorCollabNode !== null && focusCollabNode !== null) {
const anchorKey = anchorCollabNode.getKey();
const focusKey = focusCollabNode.getKey();
selection = cursor.selection;
if (selection === null) {
selection = createCursorSelection(
cursor,
anchorKey,
anchorOffset,
focusKey,
focusOffset,
);
} else {
const anchor = selection.anchor;
const focus = selection.focus;
anchor.key = anchorKey;
anchor.offset = anchorOffset;
focus.key = focusKey;
focus.offset = focusOffset;
}
}
}
}
updateCursor(binding, cursor, selection, nodeMap);
}
}
const allClientIDs = Array.from(cursors.keys());
for (let i = 0; i < allClientIDs.length; i++) {
const clientID = allClientIDs[i];
if (!visitedClientIDs.has(clientID)) {
const cursor = cursors.get(clientID);
if (cursor !== undefined) {
destroyCursor(binding, cursor);
cursors.delete(clientID);
}
}
}
}
export function syncLexicalSelectionToYjs(
binding: Binding,
provider: Provider,
prevSelection: null | BaseSelection,
nextSelection: null | BaseSelection,
): void {
const awareness = provider.awareness;
const localState = awareness.getLocalState();
if (localState === null) {
return;
}
const {
anchorPos: currentAnchorPos,
focusPos: currentFocusPos,
name,
color,
focusing,
awarenessData,
} = localState;
let anchorPos = null;
let focusPos = null;
if (
nextSelection === null ||
(currentAnchorPos !== null && !nextSelection.is(prevSelection))
) {
if (prevSelection === null) {
return;
}
}
if ($isRangeSelection(nextSelection)) {
anchorPos = createRelativePosition(nextSelection.anchor, binding);
focusPos = createRelativePosition(nextSelection.focus, binding);
}
if (
shouldUpdatePosition(currentAnchorPos, anchorPos) ||
shouldUpdatePosition(currentFocusPos, focusPos)
) {
awareness.setLocalState({
anchorPos,
awarenessData,
color,
focusPos,
focusing,
name,
});
}
}