]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/lexical/yjs/Utils.ts
Add optional OIDC avatar fetching from the “picture” claim
[bookstack] / resources / js / wysiwyg / lexical / yjs / Utils.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, YjsNode} from '.';
10 import type {
11   DecoratorNode,
12   EditorState,
13   ElementNode,
14   LexicalNode,
15   RangeSelection,
16   TextNode,
17 } from 'lexical';
18
19 import {
20   $getNodeByKey,
21   $getRoot,
22   $isDecoratorNode,
23   $isElementNode,
24   $isLineBreakNode,
25   $isRootNode,
26   $isTextNode,
27   createEditor,
28   NodeKey,
29 } from 'lexical';
30 import invariant from 'lexical/shared/invariant';
31 import {Doc, Map as YMap, XmlElement, XmlText} from 'yjs';
32
33 import {
34   $createCollabDecoratorNode,
35   CollabDecoratorNode,
36 } from './CollabDecoratorNode';
37 import {$createCollabElementNode, CollabElementNode} from './CollabElementNode';
38 import {
39   $createCollabLineBreakNode,
40   CollabLineBreakNode,
41 } from './CollabLineBreakNode';
42 import {$createCollabTextNode, CollabTextNode} from './CollabTextNode';
43
44 const baseExcludedProperties = new Set<string>([
45   '__key',
46   '__parent',
47   '__next',
48   '__prev',
49 ]);
50 const elementExcludedProperties = new Set<string>([
51   '__first',
52   '__last',
53   '__size',
54 ]);
55 const rootExcludedProperties = new Set<string>(['__cachedText']);
56 const textExcludedProperties = new Set<string>(['__text']);
57
58 function isExcludedProperty(
59   name: string,
60   node: LexicalNode,
61   binding: Binding,
62 ): boolean {
63   if (baseExcludedProperties.has(name)) {
64     return true;
65   }
66
67   if ($isTextNode(node)) {
68     if (textExcludedProperties.has(name)) {
69       return true;
70     }
71   } else if ($isElementNode(node)) {
72     if (
73       elementExcludedProperties.has(name) ||
74       ($isRootNode(node) && rootExcludedProperties.has(name))
75     ) {
76       return true;
77     }
78   }
79
80   const nodeKlass = node.constructor;
81   const excludedProperties = binding.excludedProperties.get(nodeKlass);
82   return excludedProperties != null && excludedProperties.has(name);
83 }
84
85 export function getIndexOfYjsNode(
86   yjsParentNode: YjsNode,
87   yjsNode: YjsNode,
88 ): number {
89   let node = yjsParentNode.firstChild;
90   let i = -1;
91
92   if (node === null) {
93     return -1;
94   }
95
96   do {
97     i++;
98
99     if (node === yjsNode) {
100       return i;
101     }
102
103     // @ts-expect-error Sibling exists but type is not available from YJS.
104     node = node.nextSibling;
105
106     if (node === null) {
107       return -1;
108     }
109   } while (node !== null);
110
111   return i;
112 }
113
114 export function $getNodeByKeyOrThrow(key: NodeKey): LexicalNode {
115   const node = $getNodeByKey(key);
116   invariant(node !== null, 'could not find node by key');
117   return node;
118 }
119
120 export function $createCollabNodeFromLexicalNode(
121   binding: Binding,
122   lexicalNode: LexicalNode,
123   parent: CollabElementNode,
124 ):
125   | CollabElementNode
126   | CollabTextNode
127   | CollabLineBreakNode
128   | CollabDecoratorNode {
129   const nodeType = lexicalNode.__type;
130   let collabNode;
131
132   if ($isElementNode(lexicalNode)) {
133     const xmlText = new XmlText();
134     collabNode = $createCollabElementNode(xmlText, parent, nodeType);
135     collabNode.syncPropertiesFromLexical(binding, lexicalNode, null);
136     collabNode.syncChildrenFromLexical(binding, lexicalNode, null, null, null);
137   } else if ($isTextNode(lexicalNode)) {
138     // TODO create a token text node for token, segmented nodes.
139     const map = new YMap();
140     collabNode = $createCollabTextNode(
141       map,
142       lexicalNode.__text,
143       parent,
144       nodeType,
145     );
146     collabNode.syncPropertiesAndTextFromLexical(binding, lexicalNode, null);
147   } else if ($isLineBreakNode(lexicalNode)) {
148     const map = new YMap();
149     map.set('__type', 'linebreak');
150     collabNode = $createCollabLineBreakNode(map, parent);
151   } else if ($isDecoratorNode(lexicalNode)) {
152     const xmlElem = new XmlElement();
153     collabNode = $createCollabDecoratorNode(xmlElem, parent, nodeType);
154     collabNode.syncPropertiesFromLexical(binding, lexicalNode, null);
155   } else {
156     invariant(false, 'Expected text, element, decorator, or linebreak node');
157   }
158
159   collabNode._key = lexicalNode.__key;
160   return collabNode;
161 }
162
163 function getNodeTypeFromSharedType(
164   sharedType: XmlText | YMap<unknown> | XmlElement,
165 ): string {
166   const type =
167     sharedType instanceof YMap
168       ? sharedType.get('__type')
169       : sharedType.getAttribute('__type');
170   invariant(type != null, 'Expected shared type to include type attribute');
171   return type;
172 }
173
174 export function $getOrInitCollabNodeFromSharedType(
175   binding: Binding,
176   sharedType: XmlText | YMap<unknown> | XmlElement,
177   parent?: CollabElementNode,
178 ):
179   | CollabElementNode
180   | CollabTextNode
181   | CollabLineBreakNode
182   | CollabDecoratorNode {
183   const collabNode = sharedType._collabNode;
184
185   if (collabNode === undefined) {
186     const registeredNodes = binding.editor._nodes;
187     const type = getNodeTypeFromSharedType(sharedType);
188     const nodeInfo = registeredNodes.get(type);
189     invariant(nodeInfo !== undefined, 'Node %s is not registered', type);
190
191     const sharedParent = sharedType.parent;
192     const targetParent =
193       parent === undefined && sharedParent !== null
194         ? $getOrInitCollabNodeFromSharedType(
195             binding,
196             sharedParent as XmlText | YMap<unknown> | XmlElement,
197           )
198         : parent || null;
199
200     invariant(
201       targetParent instanceof CollabElementNode,
202       'Expected parent to be a collab element node',
203     );
204
205     if (sharedType instanceof XmlText) {
206       return $createCollabElementNode(sharedType, targetParent, type);
207     } else if (sharedType instanceof YMap) {
208       if (type === 'linebreak') {
209         return $createCollabLineBreakNode(sharedType, targetParent);
210       }
211       return $createCollabTextNode(sharedType, '', targetParent, type);
212     } else if (sharedType instanceof XmlElement) {
213       return $createCollabDecoratorNode(sharedType, targetParent, type);
214     }
215   }
216
217   return collabNode;
218 }
219
220 export function createLexicalNodeFromCollabNode(
221   binding: Binding,
222   collabNode:
223     | CollabElementNode
224     | CollabTextNode
225     | CollabDecoratorNode
226     | CollabLineBreakNode,
227   parentKey: NodeKey,
228 ): LexicalNode {
229   const type = collabNode.getType();
230   const registeredNodes = binding.editor._nodes;
231   const nodeInfo = registeredNodes.get(type);
232   invariant(nodeInfo !== undefined, 'Node %s is not registered', type);
233   const lexicalNode:
234     | DecoratorNode<unknown>
235     | TextNode
236     | ElementNode
237     | LexicalNode = new nodeInfo.klass();
238   lexicalNode.__parent = parentKey;
239   collabNode._key = lexicalNode.__key;
240
241   if (collabNode instanceof CollabElementNode) {
242     const xmlText = collabNode._xmlText;
243     collabNode.syncPropertiesFromYjs(binding, null);
244     collabNode.applyChildrenYjsDelta(binding, xmlText.toDelta());
245     collabNode.syncChildrenFromYjs(binding);
246   } else if (collabNode instanceof CollabTextNode) {
247     collabNode.syncPropertiesAndTextFromYjs(binding, null);
248   } else if (collabNode instanceof CollabDecoratorNode) {
249     collabNode.syncPropertiesFromYjs(binding, null);
250   }
251
252   binding.collabNodeMap.set(lexicalNode.__key, collabNode);
253   return lexicalNode;
254 }
255
256 export function syncPropertiesFromYjs(
257   binding: Binding,
258   sharedType: XmlText | YMap<unknown> | XmlElement,
259   lexicalNode: LexicalNode,
260   keysChanged: null | Set<string>,
261 ): void {
262   const properties =
263     keysChanged === null
264       ? sharedType instanceof YMap
265         ? Array.from(sharedType.keys())
266         : Object.keys(sharedType.getAttributes())
267       : Array.from(keysChanged);
268   let writableNode;
269
270   for (let i = 0; i < properties.length; i++) {
271     const property = properties[i];
272     if (isExcludedProperty(property, lexicalNode, binding)) {
273       continue;
274     }
275     // eslint-disable-next-line @typescript-eslint/no-explicit-any
276     const prevValue = (lexicalNode as any)[property];
277     let nextValue =
278       sharedType instanceof YMap
279         ? sharedType.get(property)
280         : sharedType.getAttribute(property);
281
282     if (prevValue !== nextValue) {
283       if (nextValue instanceof Doc) {
284         const yjsDocMap = binding.docMap;
285
286         if (prevValue instanceof Doc) {
287           yjsDocMap.delete(prevValue.guid);
288         }
289
290         const nestedEditor = createEditor();
291         const key = nextValue.guid;
292         nestedEditor._key = key;
293         yjsDocMap.set(key, nextValue);
294
295         nextValue = nestedEditor;
296       }
297
298       if (writableNode === undefined) {
299         writableNode = lexicalNode.getWritable();
300       }
301
302       writableNode[property as keyof typeof writableNode] = nextValue;
303     }
304   }
305 }
306
307 export function syncPropertiesFromLexical(
308   binding: Binding,
309   sharedType: XmlText | YMap<unknown> | XmlElement,
310   prevLexicalNode: null | LexicalNode,
311   nextLexicalNode: LexicalNode,
312 ): void {
313   const type = nextLexicalNode.__type;
314   const nodeProperties = binding.nodeProperties;
315   let properties = nodeProperties.get(type);
316   if (properties === undefined) {
317     properties = Object.keys(nextLexicalNode).filter((property) => {
318       return !isExcludedProperty(property, nextLexicalNode, binding);
319     });
320     nodeProperties.set(type, properties);
321   }
322
323   const EditorClass = binding.editor.constructor;
324
325   for (let i = 0; i < properties.length; i++) {
326     const property = properties[i];
327     const prevValue =
328       // eslint-disable-next-line @typescript-eslint/no-explicit-any
329       prevLexicalNode === null ? undefined : (prevLexicalNode as any)[property];
330     // eslint-disable-next-line @typescript-eslint/no-explicit-any
331     let nextValue = (nextLexicalNode as any)[property];
332
333     if (prevValue !== nextValue) {
334       if (nextValue instanceof EditorClass) {
335         const yjsDocMap = binding.docMap;
336         let prevDoc;
337
338         if (prevValue instanceof EditorClass) {
339           const prevKey = prevValue._key;
340           prevDoc = yjsDocMap.get(prevKey);
341           yjsDocMap.delete(prevKey);
342         }
343
344         // If we already have a document, use it.
345         const doc = prevDoc || new Doc();
346         const key = doc.guid;
347         nextValue._key = key;
348         yjsDocMap.set(key, doc);
349         nextValue = doc;
350         // Mark the node dirty as we've assigned a new key to it
351         binding.editor.update(() => {
352           nextLexicalNode.markDirty();
353         });
354       }
355
356       if (sharedType instanceof YMap) {
357         sharedType.set(property, nextValue);
358       } else {
359         sharedType.setAttribute(property, nextValue);
360       }
361     }
362   }
363 }
364
365 export function spliceString(
366   str: string,
367   index: number,
368   delCount: number,
369   newText: string,
370 ): string {
371   return str.slice(0, index) + newText + str.slice(index + delCount);
372 }
373
374 export function getPositionFromElementAndOffset(
375   node: CollabElementNode,
376   offset: number,
377   boundaryIsEdge: boolean,
378 ): {
379   length: number;
380   node:
381     | CollabElementNode
382     | CollabTextNode
383     | CollabDecoratorNode
384     | CollabLineBreakNode
385     | null;
386   nodeIndex: number;
387   offset: number;
388 } {
389   let index = 0;
390   let i = 0;
391   const children = node._children;
392   const childrenLength = children.length;
393
394   for (; i < childrenLength; i++) {
395     const child = children[i];
396     const childOffset = index;
397     const size = child.getSize();
398     index += size;
399     const exceedsBoundary = boundaryIsEdge ? index >= offset : index > offset;
400
401     if (exceedsBoundary && child instanceof CollabTextNode) {
402       let textOffset = offset - childOffset - 1;
403
404       if (textOffset < 0) {
405         textOffset = 0;
406       }
407
408       const diffLength = index - offset;
409       return {
410         length: diffLength,
411         node: child,
412         nodeIndex: i,
413         offset: textOffset,
414       };
415     }
416
417     if (index > offset) {
418       return {
419         length: 0,
420         node: child,
421         nodeIndex: i,
422         offset: childOffset,
423       };
424     } else if (i === childrenLength - 1) {
425       return {
426         length: 0,
427         node: null,
428         nodeIndex: i + 1,
429         offset: childOffset + 1,
430       };
431     }
432   }
433
434   return {
435     length: 0,
436     node: null,
437     nodeIndex: 0,
438     offset: 0,
439   };
440 }
441
442 export function doesSelectionNeedRecovering(
443   selection: RangeSelection,
444 ): boolean {
445   const anchor = selection.anchor;
446   const focus = selection.focus;
447   let recoveryNeeded = false;
448
449   try {
450     const anchorNode = anchor.getNode();
451     const focusNode = focus.getNode();
452
453     if (
454       // We might have removed a node that no longer exists
455       !anchorNode.isAttached() ||
456       !focusNode.isAttached() ||
457       // If we've split a node, then the offset might not be right
458       ($isTextNode(anchorNode) &&
459         anchor.offset > anchorNode.getTextContentSize()) ||
460       ($isTextNode(focusNode) && focus.offset > focusNode.getTextContentSize())
461     ) {
462       recoveryNeeded = true;
463     }
464   } catch (e) {
465     // Sometimes checking nor a node via getNode might trigger
466     // an error, so we need recovery then too.
467     recoveryNeeded = true;
468   }
469
470   return recoveryNeeded;
471 }
472
473 export function syncWithTransaction(binding: Binding, fn: () => void): void {
474   binding.doc.transact(fn, binding);
475 }
476
477 export function removeFromParent(node: LexicalNode): void {
478   const oldParent = node.getParent();
479   if (oldParent !== null) {
480     const writableNode = node.getWritable();
481     const writableParent = oldParent.getWritable();
482     const prevSibling = node.getPreviousSibling();
483     const nextSibling = node.getNextSibling();
484     // TODO: this function duplicates a bunch of operations, can be simplified.
485     if (prevSibling === null) {
486       if (nextSibling !== null) {
487         const writableNextSibling = nextSibling.getWritable();
488         writableParent.__first = nextSibling.__key;
489         writableNextSibling.__prev = null;
490       } else {
491         writableParent.__first = null;
492       }
493     } else {
494       const writablePrevSibling = prevSibling.getWritable();
495       if (nextSibling !== null) {
496         const writableNextSibling = nextSibling.getWritable();
497         writableNextSibling.__prev = writablePrevSibling.__key;
498         writablePrevSibling.__next = writableNextSibling.__key;
499       } else {
500         writablePrevSibling.__next = null;
501       }
502       writableNode.__prev = null;
503     }
504     if (nextSibling === null) {
505       if (prevSibling !== null) {
506         const writablePrevSibling = prevSibling.getWritable();
507         writableParent.__last = prevSibling.__key;
508         writablePrevSibling.__next = null;
509       } else {
510         writableParent.__last = null;
511       }
512     } else {
513       const writableNextSibling = nextSibling.getWritable();
514       if (prevSibling !== null) {
515         const writablePrevSibling = prevSibling.getWritable();
516         writablePrevSibling.__next = writableNextSibling.__key;
517         writableNextSibling.__prev = writablePrevSibling.__key;
518       } else {
519         writableNextSibling.__prev = null;
520       }
521       writableNode.__next = null;
522     }
523     writableParent.__size--;
524     writableNode.__parent = null;
525   }
526 }
527
528 export function $moveSelectionToPreviousNode(
529   anchorNodeKey: string,
530   currentEditorState: EditorState,
531 ) {
532   const anchorNode = currentEditorState._nodeMap.get(anchorNodeKey);
533   if (!anchorNode) {
534     $getRoot().selectStart();
535     return;
536   }
537   // Get previous node
538   const prevNodeKey = anchorNode.__prev;
539   let prevNode: ElementNode | null = null;
540   if (prevNodeKey) {
541     prevNode = $getNodeByKey(prevNodeKey);
542   }
543
544   // If previous node not found, get parent node
545   if (prevNode === null && anchorNode.__parent !== null) {
546     prevNode = $getNodeByKey(anchorNode.__parent);
547   }
548   if (prevNode === null) {
549     $getRoot().selectStart();
550     return;
551   }
552
553   if (prevNode !== null && prevNode.isAttached()) {
554     prevNode.selectEnd();
555     return;
556   } else {
557     // If the found node is also deleted, select the next one
558     $moveSelectionToPreviousNode(prevNode.__key, currentEditorState);
559   }
560 }