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, | ||
|  |     }); | ||
|  |   } | ||
|  | } |