]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/lexical/yjs/SyncCursors.ts
Lexical: Imported core lexical libs
[bookstack] / resources / js / wysiwyg / lexical / yjs / SyncCursors.ts
1 /**
2  * Copyright (c) Meta Platforms, Inc. and affiliates.
3  *
4  * This source code is licensed under the MIT license found in the
5  * LICENSE file in the root directory of this source tree.
6  *
7  */
8
9 import type {Binding} from './Bindings';
10 import type {BaseSelection, NodeKey, NodeMap, Point} from 'lexical';
11 import type {AbsolutePosition, RelativePosition} from 'yjs';
12
13 import {createDOMRange, createRectsFromDOMRange} from '@lexical/selection';
14 import {
15   $getNodeByKey,
16   $getSelection,
17   $isElementNode,
18   $isLineBreakNode,
19   $isRangeSelection,
20   $isTextNode,
21 } from 'lexical';
22 import invariant from 'lexical/shared/invariant';
23 import {
24   compareRelativePositions,
25   createAbsolutePositionFromRelativePosition,
26   createRelativePositionFromTypeIndex,
27 } from 'yjs';
28
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';
35
36 export type CursorSelection = {
37   anchor: {
38     key: NodeKey;
39     offset: number;
40   };
41   caret: HTMLElement;
42   color: string;
43   focus: {
44     key: NodeKey;
45     offset: number;
46   };
47   name: HTMLSpanElement;
48   selections: Array<HTMLElement>;
49 };
50 export type Cursor = {
51   color: string;
52   name: string;
53   selection: null | CursorSelection;
54 };
55
56 function createRelativePosition(
57   point: Point,
58   binding: Binding,
59 ): null | RelativePosition {
60   const collabNodeMap = binding.collabNodeMap;
61   const collabNode = collabNodeMap.get(point.key);
62
63   if (collabNode === undefined) {
64     return null;
65   }
66
67   let offset = point.offset;
68   let sharedType = collabNode.getSharedType();
69
70   if (collabNode instanceof CollabTextNode) {
71     sharedType = collabNode._parent._xmlText;
72     const currentOffset = collabNode.getOffset();
73
74     if (currentOffset === -1) {
75       return null;
76     }
77
78     offset = currentOffset + 1 + offset;
79   } else if (
80     collabNode instanceof CollabElementNode &&
81     point.type === 'element'
82   ) {
83     const parent = point.getNode();
84     invariant($isElementNode(parent), 'Element point must be an element node');
85     let accumulatedOffset = 0;
86     let i = 0;
87     let node = parent.getFirstChild();
88     while (node !== null && i++ < offset) {
89       if ($isTextNode(node)) {
90         accumulatedOffset += node.getTextContentSize() + 1;
91       } else {
92         accumulatedOffset++;
93       }
94       node = node.getNextSibling();
95     }
96     offset = accumulatedOffset;
97   }
98
99   return createRelativePositionFromTypeIndex(sharedType, offset);
100 }
101
102 function createAbsolutePosition(
103   relativePosition: RelativePosition,
104   binding: Binding,
105 ): AbsolutePosition | null {
106   return createAbsolutePositionFromRelativePosition(
107     relativePosition,
108     binding.doc,
109   );
110 }
111
112 function shouldUpdatePosition(
113   currentPos: RelativePosition | null | undefined,
114   pos: RelativePosition | null | undefined,
115 ): boolean {
116   if (currentPos == null) {
117     if (pos != null) {
118       return true;
119     }
120   } else if (pos == null || !compareRelativePositions(currentPos, pos)) {
121     return true;
122   }
123
124   return false;
125 }
126
127 function createCursor(name: string, color: string): Cursor {
128   return {
129     color: color,
130     name: name,
131     selection: null,
132   };
133 }
134
135 function destroySelection(binding: Binding, selection: CursorSelection) {
136   const cursorsContainer = binding.cursorsContainer;
137
138   if (cursorsContainer !== null) {
139     const selections = selection.selections;
140     const selectionsLength = selections.length;
141
142     for (let i = 0; i < selectionsLength; i++) {
143       cursorsContainer.removeChild(selections[i]);
144     }
145   }
146 }
147
148 function destroyCursor(binding: Binding, cursor: Cursor) {
149   const selection = cursor.selection;
150
151   if (selection !== null) {
152     destroySelection(binding, selection);
153   }
154 }
155
156 function createCursorSelection(
157   cursor: Cursor,
158   anchorKey: NodeKey,
159   anchorOffset: number,
160   focusKey: NodeKey,
161   focusOffset: number,
162 ): CursorSelection {
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);
170   return {
171     anchor: {
172       key: anchorKey,
173       offset: anchorOffset,
174     },
175     caret,
176     color,
177     focus: {
178       key: focusKey,
179       offset: focusOffset,
180     },
181     name,
182     selections: [],
183   };
184 }
185
186 function updateCursor(
187   binding: Binding,
188   cursor: Cursor,
189   nextSelection: null | CursorSelection,
190   nodeMap: NodeMap,
191 ): void {
192   const editor = binding.editor;
193   const rootElement = editor.getRootElement();
194   const cursorsContainer = binding.cursorsContainer;
195
196   if (cursorsContainer === null || rootElement === null) {
197     return;
198   }
199
200   const cursorsContainerOffsetParent = cursorsContainer.offsetParent;
201   if (cursorsContainerOffsetParent === null) {
202     return;
203   }
204
205   const containerRect = cursorsContainerOffsetParent.getBoundingClientRect();
206   const prevSelection = cursor.selection;
207
208   if (nextSelection === null) {
209     if (prevSelection === null) {
210       return;
211     } else {
212       cursor.selection = null;
213       destroySelection(binding, prevSelection);
214       return;
215     }
216   } else {
217     cursor.selection = nextSelection;
218   }
219
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);
229
230   if (anchorNode == null || focusNode == null) {
231     return;
232   }
233   let selectionRects: Array<DOMRect>;
234
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)) {
241     const brRect = (
242       editor.getElementByKey(anchorKey) as HTMLElement
243     ).getBoundingClientRect();
244     selectionRects = [brRect];
245   } else {
246     const range = createDOMRange(
247       editor,
248       anchorNode,
249       anchor.offset,
250       focusNode,
251       focus.offset,
252     );
253
254     if (range === null) {
255       return;
256     }
257     selectionRects = createRectsFromDOMRange(editor, range);
258   }
259
260   const selectionsLength = selections.length;
261   const selectionRectsLength = selectionRects.length;
262
263   for (let i = 0; i < selectionRectsLength; i++) {
264     const selectionRect = selectionRects[i];
265     let selection = selections[i];
266
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);
273     }
274
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;
279
280     (
281       selection.firstChild as HTMLSpanElement
282     ).style.cssText = `${style}left:0;top:0;background-color:${color};opacity:0.3;`;
283
284     if (i === selectionRectsLength - 1) {
285       if (caret.parentNode !== selection) {
286         selection.appendChild(caret);
287       }
288     }
289   }
290
291   for (let i = selectionsLength - 1; i >= selectionRectsLength; i--) {
292     const selection = selections[i];
293     cursorsContainer.removeChild(selection);
294     selections.pop();
295   }
296 }
297
298 export function $syncLocalCursorPosition(
299   binding: Binding,
300   provider: Provider,
301 ): void {
302   const awareness = provider.awareness;
303   const localState = awareness.getLocalState();
304
305   if (localState === null) {
306     return;
307   }
308
309   const anchorPos = localState.anchorPos;
310   const focusPos = localState.focusPos;
311
312   if (anchorPos !== null && focusPos !== null) {
313     const anchorAbsPos = createAbsolutePosition(anchorPos, binding);
314     const focusAbsPos = createAbsolutePosition(focusPos, binding);
315
316     if (anchorAbsPos !== null && focusAbsPos !== null) {
317       const [anchorCollabNode, anchorOffset] = getCollabNodeAndOffset(
318         anchorAbsPos.type,
319         anchorAbsPos.index,
320       );
321       const [focusCollabNode, focusOffset] = getCollabNodeAndOffset(
322         focusAbsPos.type,
323         focusAbsPos.index,
324       );
325
326       if (anchorCollabNode !== null && focusCollabNode !== null) {
327         const anchorKey = anchorCollabNode.getKey();
328         const focusKey = focusCollabNode.getKey();
329
330         const selection = $getSelection();
331
332         if (!$isRangeSelection(selection)) {
333           return;
334         }
335         const anchor = selection.anchor;
336         const focus = selection.focus;
337
338         $setPoint(anchor, anchorKey, anchorOffset);
339         $setPoint(focus, focusKey, focusOffset);
340       }
341     }
342   }
343 }
344
345 function $setPoint(point: Point, key: NodeKey, offset: number): void {
346   if (point.key !== key || point.offset !== offset) {
347     let anchorNode = $getNodeByKey(key);
348     if (
349       anchorNode !== null &&
350       !$isElementNode(anchorNode) &&
351       !$isTextNode(anchorNode)
352     ) {
353       const parent = anchorNode.getParentOrThrow();
354       key = parent.getKey();
355       offset = anchorNode.getIndexWithinParent();
356       anchorNode = parent;
357     }
358     point.set(key, offset, $isElementNode(anchorNode) ? 'element' : 'text');
359   }
360 }
361
362 function getCollabNodeAndOffset(
363   // eslint-disable-next-line @typescript-eslint/no-explicit-any
364   sharedType: any,
365   offset: number,
366 ): [
367   (
368     | null
369     | CollabDecoratorNode
370     | CollabElementNode
371     | CollabTextNode
372     | CollabLineBreakNode
373   ),
374   number,
375 ] {
376   const collabNode = sharedType._collabNode;
377
378   if (collabNode === undefined) {
379     return [null, 0];
380   }
381
382   if (collabNode instanceof CollabElementNode) {
383     const {node, offset: collabNodeOffset} = getPositionFromElementAndOffset(
384       collabNode,
385       offset,
386       true,
387     );
388
389     if (node === null) {
390       return [collabNode, 0];
391     } else {
392       return [node, collabNodeOffset];
393     }
394   }
395
396   return [null, 0];
397 }
398
399 export function syncCursorPositions(
400   binding: Binding,
401   provider: Provider,
402 ): void {
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();
409
410   for (let i = 0; i < awarenessStates.length; i++) {
411     const awarenessState = awarenessStates[i];
412     const [clientID, awareness] = awarenessState;
413
414     if (clientID !== localClientID) {
415       visitedClientIDs.add(clientID);
416       const {anchorPos, focusPos, name, color, focusing} = awareness;
417       let selection = null;
418
419       let cursor = cursors.get(clientID);
420
421       if (cursor === undefined) {
422         cursor = createCursor(name, color);
423         cursors.set(clientID, cursor);
424       }
425
426       if (anchorPos !== null && focusPos !== null && focusing) {
427         const anchorAbsPos = createAbsolutePosition(anchorPos, binding);
428         const focusAbsPos = createAbsolutePosition(focusPos, binding);
429
430         if (anchorAbsPos !== null && focusAbsPos !== null) {
431           const [anchorCollabNode, anchorOffset] = getCollabNodeAndOffset(
432             anchorAbsPos.type,
433             anchorAbsPos.index,
434           );
435           const [focusCollabNode, focusOffset] = getCollabNodeAndOffset(
436             focusAbsPos.type,
437             focusAbsPos.index,
438           );
439
440           if (anchorCollabNode !== null && focusCollabNode !== null) {
441             const anchorKey = anchorCollabNode.getKey();
442             const focusKey = focusCollabNode.getKey();
443             selection = cursor.selection;
444
445             if (selection === null) {
446               selection = createCursorSelection(
447                 cursor,
448                 anchorKey,
449                 anchorOffset,
450                 focusKey,
451                 focusOffset,
452               );
453             } else {
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;
460             }
461           }
462         }
463       }
464
465       updateCursor(binding, cursor, selection, nodeMap);
466     }
467   }
468
469   const allClientIDs = Array.from(cursors.keys());
470
471   for (let i = 0; i < allClientIDs.length; i++) {
472     const clientID = allClientIDs[i];
473
474     if (!visitedClientIDs.has(clientID)) {
475       const cursor = cursors.get(clientID);
476
477       if (cursor !== undefined) {
478         destroyCursor(binding, cursor);
479         cursors.delete(clientID);
480       }
481     }
482   }
483 }
484
485 export function syncLexicalSelectionToYjs(
486   binding: Binding,
487   provider: Provider,
488   prevSelection: null | BaseSelection,
489   nextSelection: null | BaseSelection,
490 ): void {
491   const awareness = provider.awareness;
492   const localState = awareness.getLocalState();
493
494   if (localState === null) {
495     return;
496   }
497
498   const {
499     anchorPos: currentAnchorPos,
500     focusPos: currentFocusPos,
501     name,
502     color,
503     focusing,
504     awarenessData,
505   } = localState;
506   let anchorPos = null;
507   let focusPos = null;
508
509   if (
510     nextSelection === null ||
511     (currentAnchorPos !== null && !nextSelection.is(prevSelection))
512   ) {
513     if (prevSelection === null) {
514       return;
515     }
516   }
517
518   if ($isRangeSelection(nextSelection)) {
519     anchorPos = createRelativePosition(nextSelection.anchor, binding);
520     focusPos = createRelativePosition(nextSelection.focus, binding);
521   }
522
523   if (
524     shouldUpdatePosition(currentAnchorPos, anchorPos) ||
525     shouldUpdatePosition(currentFocusPos, focusPos)
526   ) {
527     awareness.setLocalState({
528       anchorPos,
529       awarenessData,
530       color,
531       focusPos,
532       focusing,
533       name,
534     });
535   }
536 }