]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/lexical/yjs/CollabTextNode.ts
Images: Added testing to cover animated avif handling
[bookstack] / resources / js / wysiwyg / lexical / yjs / CollabTextNode.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 {CollabElementNode} from './CollabElementNode';
11 import type {NodeKey, NodeMap, TextNode} from 'lexical';
12 import type {Map as YMap} from 'yjs';
13
14 import {
15   $getNodeByKey,
16   $getSelection,
17   $isRangeSelection,
18   $isTextNode,
19 } from 'lexical';
20 import invariant from 'lexical/shared/invariant';
21 import simpleDiffWithCursor from 'lexical/shared/simpleDiffWithCursor';
22
23 import {syncPropertiesFromLexical, syncPropertiesFromYjs} from './Utils';
24
25 function $diffTextContentAndApplyDelta(
26   collabNode: CollabTextNode,
27   key: NodeKey,
28   prevText: string,
29   nextText: string,
30 ): void {
31   const selection = $getSelection();
32   let cursorOffset = nextText.length;
33
34   if ($isRangeSelection(selection) && selection.isCollapsed()) {
35     const anchor = selection.anchor;
36
37     if (anchor.key === key) {
38       cursorOffset = anchor.offset;
39     }
40   }
41
42   const diff = simpleDiffWithCursor(prevText, nextText, cursorOffset);
43   collabNode.spliceText(diff.index, diff.remove, diff.insert);
44 }
45
46 export class CollabTextNode {
47   _map: YMap<unknown>;
48   _key: NodeKey;
49   _parent: CollabElementNode;
50   _text: string;
51   _type: string;
52   _normalized: boolean;
53
54   constructor(
55     map: YMap<unknown>,
56     text: string,
57     parent: CollabElementNode,
58     type: string,
59   ) {
60     this._key = '';
61     this._map = map;
62     this._parent = parent;
63     this._text = text;
64     this._type = type;
65     this._normalized = false;
66   }
67
68   getPrevNode(nodeMap: null | NodeMap): null | TextNode {
69     if (nodeMap === null) {
70       return null;
71     }
72
73     const node = nodeMap.get(this._key);
74     return $isTextNode(node) ? node : null;
75   }
76
77   getNode(): null | TextNode {
78     const node = $getNodeByKey(this._key);
79     return $isTextNode(node) ? node : null;
80   }
81
82   getSharedType(): YMap<unknown> {
83     return this._map;
84   }
85
86   getType(): string {
87     return this._type;
88   }
89
90   getKey(): NodeKey {
91     return this._key;
92   }
93
94   getSize(): number {
95     return this._text.length + (this._normalized ? 0 : 1);
96   }
97
98   getOffset(): number {
99     const collabElementNode = this._parent;
100     return collabElementNode.getChildOffset(this);
101   }
102
103   spliceText(index: number, delCount: number, newText: string): void {
104     const collabElementNode = this._parent;
105     const xmlText = collabElementNode._xmlText;
106     const offset = this.getOffset() + 1 + index;
107
108     if (delCount !== 0) {
109       xmlText.delete(offset, delCount);
110     }
111
112     if (newText !== '') {
113       xmlText.insert(offset, newText);
114     }
115   }
116
117   syncPropertiesAndTextFromLexical(
118     binding: Binding,
119     nextLexicalNode: TextNode,
120     prevNodeMap: null | NodeMap,
121   ): void {
122     const prevLexicalNode = this.getPrevNode(prevNodeMap);
123     const nextText = nextLexicalNode.__text;
124
125     syncPropertiesFromLexical(
126       binding,
127       this._map,
128       prevLexicalNode,
129       nextLexicalNode,
130     );
131
132     if (prevLexicalNode !== null) {
133       const prevText = prevLexicalNode.__text;
134
135       if (prevText !== nextText) {
136         const key = nextLexicalNode.__key;
137         $diffTextContentAndApplyDelta(this, key, prevText, nextText);
138         this._text = nextText;
139       }
140     }
141   }
142
143   syncPropertiesAndTextFromYjs(
144     binding: Binding,
145     keysChanged: null | Set<string>,
146   ): void {
147     const lexicalNode = this.getNode();
148     invariant(
149       lexicalNode !== null,
150       'syncPropertiesAndTextFromYjs: could not find decorator node',
151     );
152
153     syncPropertiesFromYjs(binding, this._map, lexicalNode, keysChanged);
154
155     const collabText = this._text;
156
157     if (lexicalNode.__text !== collabText) {
158       const writable = lexicalNode.getWritable();
159       writable.__text = collabText;
160     }
161   }
162
163   destroy(binding: Binding): void {
164     const collabNodeMap = binding.collabNodeMap;
165     collabNodeMap.delete(this._key);
166   }
167 }
168
169 export function $createCollabTextNode(
170   map: YMap<unknown>,
171   text: string,
172   parent: CollabElementNode,
173   type: string,
174 ): CollabTextNode {
175   const collabNode = new CollabTextNode(map, text, parent, type);
176   map._collabNode = collabNode;
177   return collabNode;
178 }