537 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
			
		
		
	
	
			537 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
| /**
 | |
|  * 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,
 | |
|     });
 | |
|   }
 | |
| }
 |