]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/lexical/yjs/CollabElementNode.ts
Add optional OIDC avatar fetching from the “picture” claim
[bookstack] / resources / js / wysiwyg / lexical / yjs / CollabElementNode.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 '.';
10 import type {ElementNode, NodeKey, NodeMap} from 'lexical';
11 import type {AbstractType, Map as YMap, XmlElement, XmlText} from 'yjs';
12
13 import {$createChildrenArray} from '@lexical/offset';
14 import {
15   $getNodeByKey,
16   $isDecoratorNode,
17   $isElementNode,
18   $isTextNode,
19 } from 'lexical';
20 import invariant from 'lexical/shared/invariant';
21
22 import {CollabDecoratorNode} from './CollabDecoratorNode';
23 import {CollabLineBreakNode} from './CollabLineBreakNode';
24 import {CollabTextNode} from './CollabTextNode';
25 import {
26   $createCollabNodeFromLexicalNode,
27   $getNodeByKeyOrThrow,
28   $getOrInitCollabNodeFromSharedType,
29   createLexicalNodeFromCollabNode,
30   getPositionFromElementAndOffset,
31   removeFromParent,
32   spliceString,
33   syncPropertiesFromLexical,
34   syncPropertiesFromYjs,
35 } from './Utils';
36
37 type IntentionallyMarkedAsDirtyElement = boolean;
38
39 export class CollabElementNode {
40   _key: NodeKey;
41   _children: Array<
42     | CollabElementNode
43     | CollabTextNode
44     | CollabDecoratorNode
45     | CollabLineBreakNode
46   >;
47   _xmlText: XmlText;
48   _type: string;
49   _parent: null | CollabElementNode;
50
51   constructor(
52     xmlText: XmlText,
53     parent: null | CollabElementNode,
54     type: string,
55   ) {
56     this._key = '';
57     this._children = [];
58     this._xmlText = xmlText;
59     this._type = type;
60     this._parent = parent;
61   }
62
63   getPrevNode(nodeMap: null | NodeMap): null | ElementNode {
64     if (nodeMap === null) {
65       return null;
66     }
67
68     const node = nodeMap.get(this._key);
69     return $isElementNode(node) ? node : null;
70   }
71
72   getNode(): null | ElementNode {
73     const node = $getNodeByKey(this._key);
74     return $isElementNode(node) ? node : null;
75   }
76
77   getSharedType(): XmlText {
78     return this._xmlText;
79   }
80
81   getType(): string {
82     return this._type;
83   }
84
85   getKey(): NodeKey {
86     return this._key;
87   }
88
89   isEmpty(): boolean {
90     return this._children.length === 0;
91   }
92
93   getSize(): number {
94     return 1;
95   }
96
97   getOffset(): number {
98     const collabElementNode = this._parent;
99     invariant(
100       collabElementNode !== null,
101       'getOffset: could not find collab element node',
102     );
103
104     return collabElementNode.getChildOffset(this);
105   }
106
107   syncPropertiesFromYjs(
108     binding: Binding,
109     keysChanged: null | Set<string>,
110   ): void {
111     const lexicalNode = this.getNode();
112     invariant(
113       lexicalNode !== null,
114       'syncPropertiesFromYjs: could not find element node',
115     );
116     syncPropertiesFromYjs(binding, this._xmlText, lexicalNode, keysChanged);
117   }
118
119   applyChildrenYjsDelta(
120     binding: Binding,
121     deltas: Array<{
122       insert?: string | object | AbstractType<unknown>;
123       delete?: number;
124       retain?: number;
125       attributes?: {
126         [x: string]: unknown;
127       };
128     }>,
129   ): void {
130     const children = this._children;
131     let currIndex = 0;
132
133     for (let i = 0; i < deltas.length; i++) {
134       const delta = deltas[i];
135       const insertDelta = delta.insert;
136       const deleteDelta = delta.delete;
137
138       if (delta.retain != null) {
139         currIndex += delta.retain;
140       } else if (typeof deleteDelta === 'number') {
141         let deletionSize = deleteDelta;
142
143         while (deletionSize > 0) {
144           const {node, nodeIndex, offset, length} =
145             getPositionFromElementAndOffset(this, currIndex, false);
146
147           if (
148             node instanceof CollabElementNode ||
149             node instanceof CollabLineBreakNode ||
150             node instanceof CollabDecoratorNode
151           ) {
152             children.splice(nodeIndex, 1);
153             deletionSize -= 1;
154           } else if (node instanceof CollabTextNode) {
155             const delCount = Math.min(deletionSize, length);
156             const prevCollabNode =
157               nodeIndex !== 0 ? children[nodeIndex - 1] : null;
158             const nodeSize = node.getSize();
159
160             if (
161               offset === 0 &&
162               delCount === 1 &&
163               nodeIndex > 0 &&
164               prevCollabNode instanceof CollabTextNode &&
165               length === nodeSize &&
166               // If the node has no keys, it's been deleted
167               Array.from(node._map.keys()).length === 0
168             ) {
169               // Merge the text node with previous.
170               prevCollabNode._text += node._text;
171               children.splice(nodeIndex, 1);
172             } else if (offset === 0 && delCount === nodeSize) {
173               // The entire thing needs removing
174               children.splice(nodeIndex, 1);
175             } else {
176               node._text = spliceString(node._text, offset, delCount, '');
177             }
178
179             deletionSize -= delCount;
180           } else {
181             // Can occur due to the deletion from the dangling text heuristic below.
182             break;
183           }
184         }
185       } else if (insertDelta != null) {
186         if (typeof insertDelta === 'string') {
187           const {node, offset} = getPositionFromElementAndOffset(
188             this,
189             currIndex,
190             true,
191           );
192
193           if (node instanceof CollabTextNode) {
194             node._text = spliceString(node._text, offset, 0, insertDelta);
195           } else {
196             // TODO: maybe we can improve this by keeping around a redundant
197             // text node map, rather than removing all the text nodes, so there
198             // never can be dangling text.
199
200             // We have a conflict where there was likely a CollabTextNode and
201             // an Lexical TextNode too, but they were removed in a merge. So
202             // let's just ignore the text and trigger a removal for it from our
203             // shared type.
204             this._xmlText.delete(offset, insertDelta.length);
205           }
206
207           currIndex += insertDelta.length;
208         } else {
209           const sharedType = insertDelta;
210           const {nodeIndex} = getPositionFromElementAndOffset(
211             this,
212             currIndex,
213             false,
214           );
215           const collabNode = $getOrInitCollabNodeFromSharedType(
216             binding,
217             sharedType as XmlText | YMap<unknown> | XmlElement,
218             this,
219           );
220           children.splice(nodeIndex, 0, collabNode);
221           currIndex += 1;
222         }
223       } else {
224         throw new Error('Unexpected delta format');
225       }
226     }
227   }
228
229   syncChildrenFromYjs(binding: Binding): void {
230     // Now diff the children of the collab node with that of our existing Lexical node.
231     const lexicalNode = this.getNode();
232     invariant(
233       lexicalNode !== null,
234       'syncChildrenFromYjs: could not find element node',
235     );
236
237     const key = lexicalNode.__key;
238     const prevLexicalChildrenKeys = $createChildrenArray(lexicalNode, null);
239     const nextLexicalChildrenKeys: Array<NodeKey> = [];
240     const lexicalChildrenKeysLength = prevLexicalChildrenKeys.length;
241     const collabChildren = this._children;
242     const collabChildrenLength = collabChildren.length;
243     const collabNodeMap = binding.collabNodeMap;
244     const visitedKeys = new Set();
245     let collabKeys;
246     let writableLexicalNode;
247     let prevIndex = 0;
248     let prevChildNode = null;
249
250     if (collabChildrenLength !== lexicalChildrenKeysLength) {
251       writableLexicalNode = lexicalNode.getWritable();
252     }
253
254     for (let i = 0; i < collabChildrenLength; i++) {
255       const lexicalChildKey = prevLexicalChildrenKeys[prevIndex];
256       const childCollabNode = collabChildren[i];
257       const collabLexicalChildNode = childCollabNode.getNode();
258       const collabKey = childCollabNode._key;
259
260       if (collabLexicalChildNode !== null && lexicalChildKey === collabKey) {
261         const childNeedsUpdating = $isTextNode(collabLexicalChildNode);
262         // Update
263         visitedKeys.add(lexicalChildKey);
264
265         if (childNeedsUpdating) {
266           childCollabNode._key = lexicalChildKey;
267
268           if (childCollabNode instanceof CollabElementNode) {
269             const xmlText = childCollabNode._xmlText;
270             childCollabNode.syncPropertiesFromYjs(binding, null);
271             childCollabNode.applyChildrenYjsDelta(binding, xmlText.toDelta());
272             childCollabNode.syncChildrenFromYjs(binding);
273           } else if (childCollabNode instanceof CollabTextNode) {
274             childCollabNode.syncPropertiesAndTextFromYjs(binding, null);
275           } else if (childCollabNode instanceof CollabDecoratorNode) {
276             childCollabNode.syncPropertiesFromYjs(binding, null);
277           } else if (!(childCollabNode instanceof CollabLineBreakNode)) {
278             invariant(
279               false,
280               'syncChildrenFromYjs: expected text, element, decorator, or linebreak collab node',
281             );
282           }
283         }
284
285         nextLexicalChildrenKeys[i] = lexicalChildKey;
286         prevChildNode = collabLexicalChildNode;
287         prevIndex++;
288       } else {
289         if (collabKeys === undefined) {
290           collabKeys = new Set();
291
292           for (let s = 0; s < collabChildrenLength; s++) {
293             const child = collabChildren[s];
294             const childKey = child._key;
295
296             if (childKey !== '') {
297               collabKeys.add(childKey);
298             }
299           }
300         }
301
302         if (
303           collabLexicalChildNode !== null &&
304           lexicalChildKey !== undefined &&
305           !collabKeys.has(lexicalChildKey)
306         ) {
307           const nodeToRemove = $getNodeByKeyOrThrow(lexicalChildKey);
308           removeFromParent(nodeToRemove);
309           i--;
310           prevIndex++;
311           continue;
312         }
313
314         writableLexicalNode = lexicalNode.getWritable();
315         // Create/Replace
316         const lexicalChildNode = createLexicalNodeFromCollabNode(
317           binding,
318           childCollabNode,
319           key,
320         );
321         const childKey = lexicalChildNode.__key;
322         collabNodeMap.set(childKey, childCollabNode);
323         nextLexicalChildrenKeys[i] = childKey;
324         if (prevChildNode === null) {
325           const nextSibling = writableLexicalNode.getFirstChild();
326           writableLexicalNode.__first = childKey;
327           if (nextSibling !== null) {
328             const writableNextSibling = nextSibling.getWritable();
329             writableNextSibling.__prev = childKey;
330             lexicalChildNode.__next = writableNextSibling.__key;
331           }
332         } else {
333           const writablePrevChildNode = prevChildNode.getWritable();
334           const nextSibling = prevChildNode.getNextSibling();
335           writablePrevChildNode.__next = childKey;
336           lexicalChildNode.__prev = prevChildNode.__key;
337           if (nextSibling !== null) {
338             const writableNextSibling = nextSibling.getWritable();
339             writableNextSibling.__prev = childKey;
340             lexicalChildNode.__next = writableNextSibling.__key;
341           }
342         }
343         if (i === collabChildrenLength - 1) {
344           writableLexicalNode.__last = childKey;
345         }
346         writableLexicalNode.__size++;
347         prevChildNode = lexicalChildNode;
348       }
349     }
350
351     for (let i = 0; i < lexicalChildrenKeysLength; i++) {
352       const lexicalChildKey = prevLexicalChildrenKeys[i];
353
354       if (!visitedKeys.has(lexicalChildKey)) {
355         // Remove
356         const lexicalChildNode = $getNodeByKeyOrThrow(lexicalChildKey);
357         const collabNode = binding.collabNodeMap.get(lexicalChildKey);
358
359         if (collabNode !== undefined) {
360           collabNode.destroy(binding);
361         }
362         removeFromParent(lexicalChildNode);
363       }
364     }
365   }
366
367   syncPropertiesFromLexical(
368     binding: Binding,
369     nextLexicalNode: ElementNode,
370     prevNodeMap: null | NodeMap,
371   ): void {
372     syncPropertiesFromLexical(
373       binding,
374       this._xmlText,
375       this.getPrevNode(prevNodeMap),
376       nextLexicalNode,
377     );
378   }
379
380   _syncChildFromLexical(
381     binding: Binding,
382     index: number,
383     key: NodeKey,
384     prevNodeMap: null | NodeMap,
385     dirtyElements: null | Map<NodeKey, IntentionallyMarkedAsDirtyElement>,
386     dirtyLeaves: null | Set<NodeKey>,
387   ): void {
388     const childCollabNode = this._children[index];
389     // Update
390     const nextChildNode = $getNodeByKeyOrThrow(key);
391
392     if (
393       childCollabNode instanceof CollabElementNode &&
394       $isElementNode(nextChildNode)
395     ) {
396       childCollabNode.syncPropertiesFromLexical(
397         binding,
398         nextChildNode,
399         prevNodeMap,
400       );
401       childCollabNode.syncChildrenFromLexical(
402         binding,
403         nextChildNode,
404         prevNodeMap,
405         dirtyElements,
406         dirtyLeaves,
407       );
408     } else if (
409       childCollabNode instanceof CollabTextNode &&
410       $isTextNode(nextChildNode)
411     ) {
412       childCollabNode.syncPropertiesAndTextFromLexical(
413         binding,
414         nextChildNode,
415         prevNodeMap,
416       );
417     } else if (
418       childCollabNode instanceof CollabDecoratorNode &&
419       $isDecoratorNode(nextChildNode)
420     ) {
421       childCollabNode.syncPropertiesFromLexical(
422         binding,
423         nextChildNode,
424         prevNodeMap,
425       );
426     }
427   }
428
429   syncChildrenFromLexical(
430     binding: Binding,
431     nextLexicalNode: ElementNode,
432     prevNodeMap: null | NodeMap,
433     dirtyElements: null | Map<NodeKey, IntentionallyMarkedAsDirtyElement>,
434     dirtyLeaves: null | Set<NodeKey>,
435   ): void {
436     const prevLexicalNode = this.getPrevNode(prevNodeMap);
437     const prevChildren =
438       prevLexicalNode === null
439         ? []
440         : $createChildrenArray(prevLexicalNode, prevNodeMap);
441     const nextChildren = $createChildrenArray(nextLexicalNode, null);
442     const prevEndIndex = prevChildren.length - 1;
443     const nextEndIndex = nextChildren.length - 1;
444     const collabNodeMap = binding.collabNodeMap;
445     let prevChildrenSet: Set<NodeKey> | undefined;
446     let nextChildrenSet: Set<NodeKey> | undefined;
447     let prevIndex = 0;
448     let nextIndex = 0;
449
450     while (prevIndex <= prevEndIndex && nextIndex <= nextEndIndex) {
451       const prevKey = prevChildren[prevIndex];
452       const nextKey = nextChildren[nextIndex];
453
454       if (prevKey === nextKey) {
455         // Nove move, create or remove
456         this._syncChildFromLexical(
457           binding,
458           nextIndex,
459           nextKey,
460           prevNodeMap,
461           dirtyElements,
462           dirtyLeaves,
463         );
464
465         prevIndex++;
466         nextIndex++;
467       } else {
468         if (prevChildrenSet === undefined) {
469           prevChildrenSet = new Set(prevChildren);
470         }
471
472         if (nextChildrenSet === undefined) {
473           nextChildrenSet = new Set(nextChildren);
474         }
475
476         const nextHasPrevKey = nextChildrenSet.has(prevKey);
477         const prevHasNextKey = prevChildrenSet.has(nextKey);
478
479         if (!nextHasPrevKey) {
480           // Remove
481           this.splice(binding, nextIndex, 1);
482           prevIndex++;
483         } else {
484           // Create or replace
485           const nextChildNode = $getNodeByKeyOrThrow(nextKey);
486           const collabNode = $createCollabNodeFromLexicalNode(
487             binding,
488             nextChildNode,
489             this,
490           );
491           collabNodeMap.set(nextKey, collabNode);
492
493           if (prevHasNextKey) {
494             this.splice(binding, nextIndex, 1, collabNode);
495             prevIndex++;
496             nextIndex++;
497           } else {
498             this.splice(binding, nextIndex, 0, collabNode);
499             nextIndex++;
500           }
501         }
502       }
503     }
504
505     const appendNewChildren = prevIndex > prevEndIndex;
506     const removeOldChildren = nextIndex > nextEndIndex;
507
508     if (appendNewChildren && !removeOldChildren) {
509       for (; nextIndex <= nextEndIndex; ++nextIndex) {
510         const key = nextChildren[nextIndex];
511         const nextChildNode = $getNodeByKeyOrThrow(key);
512         const collabNode = $createCollabNodeFromLexicalNode(
513           binding,
514           nextChildNode,
515           this,
516         );
517         this.append(collabNode);
518         collabNodeMap.set(key, collabNode);
519       }
520     } else if (removeOldChildren && !appendNewChildren) {
521       for (let i = this._children.length - 1; i >= nextIndex; i--) {
522         this.splice(binding, i, 1);
523       }
524     }
525   }
526
527   append(
528     collabNode:
529       | CollabElementNode
530       | CollabDecoratorNode
531       | CollabTextNode
532       | CollabLineBreakNode,
533   ): void {
534     const xmlText = this._xmlText;
535     const children = this._children;
536     const lastChild = children[children.length - 1];
537     const offset =
538       lastChild !== undefined ? lastChild.getOffset() + lastChild.getSize() : 0;
539
540     if (collabNode instanceof CollabElementNode) {
541       xmlText.insertEmbed(offset, collabNode._xmlText);
542     } else if (collabNode instanceof CollabTextNode) {
543       const map = collabNode._map;
544
545       if (map.parent === null) {
546         xmlText.insertEmbed(offset, map);
547       }
548
549       xmlText.insert(offset + 1, collabNode._text);
550     } else if (collabNode instanceof CollabLineBreakNode) {
551       xmlText.insertEmbed(offset, collabNode._map);
552     } else if (collabNode instanceof CollabDecoratorNode) {
553       xmlText.insertEmbed(offset, collabNode._xmlElem);
554     }
555
556     this._children.push(collabNode);
557   }
558
559   splice(
560     binding: Binding,
561     index: number,
562     delCount: number,
563     collabNode?:
564       | CollabElementNode
565       | CollabDecoratorNode
566       | CollabTextNode
567       | CollabLineBreakNode,
568   ): void {
569     const children = this._children;
570     const child = children[index];
571
572     if (child === undefined) {
573       invariant(
574         collabNode !== undefined,
575         'splice: could not find collab element node',
576       );
577       this.append(collabNode);
578       return;
579     }
580
581     const offset = child.getOffset();
582     invariant(offset !== -1, 'splice: expected offset to be greater than zero');
583
584     const xmlText = this._xmlText;
585
586     if (delCount !== 0) {
587       // What if we delete many nodes, don't we need to get all their
588       // sizes?
589       xmlText.delete(offset, child.getSize());
590     }
591
592     if (collabNode instanceof CollabElementNode) {
593       xmlText.insertEmbed(offset, collabNode._xmlText);
594     } else if (collabNode instanceof CollabTextNode) {
595       const map = collabNode._map;
596
597       if (map.parent === null) {
598         xmlText.insertEmbed(offset, map);
599       }
600
601       xmlText.insert(offset + 1, collabNode._text);
602     } else if (collabNode instanceof CollabLineBreakNode) {
603       xmlText.insertEmbed(offset, collabNode._map);
604     } else if (collabNode instanceof CollabDecoratorNode) {
605       xmlText.insertEmbed(offset, collabNode._xmlElem);
606     }
607
608     if (delCount !== 0) {
609       const childrenToDelete = children.slice(index, index + delCount);
610
611       for (let i = 0; i < childrenToDelete.length; i++) {
612         childrenToDelete[i].destroy(binding);
613       }
614     }
615
616     if (collabNode !== undefined) {
617       children.splice(index, delCount, collabNode);
618     } else {
619       children.splice(index, delCount);
620     }
621   }
622
623   getChildOffset(
624     collabNode:
625       | CollabElementNode
626       | CollabTextNode
627       | CollabDecoratorNode
628       | CollabLineBreakNode,
629   ): number {
630     let offset = 0;
631     const children = this._children;
632
633     for (let i = 0; i < children.length; i++) {
634       const child = children[i];
635
636       if (child === collabNode) {
637         return offset;
638       }
639
640       offset += child.getSize();
641     }
642
643     return -1;
644   }
645
646   destroy(binding: Binding): void {
647     const collabNodeMap = binding.collabNodeMap;
648     const children = this._children;
649
650     for (let i = 0; i < children.length; i++) {
651       children[i].destroy(binding);
652     }
653
654     collabNodeMap.delete(this._key);
655   }
656 }
657
658 export function $createCollabElementNode(
659   xmlText: XmlText,
660   parent: null | CollabElementNode,
661   type: string,
662 ): CollabElementNode {
663   const collabNode = new CollabElementNode(xmlText, parent, type);
664   xmlText._collabNode = collabNode;
665   return collabNode;
666 }