2 * Copyright (c) Meta Platforms, Inc. and affiliates.
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
9 import type {Binding} from '.';
10 import type {ElementNode, NodeKey, NodeMap} from 'lexical';
11 import type {AbstractType, Map as YMap, XmlElement, XmlText} from 'yjs';
13 import {$createChildrenArray} from '@lexical/offset';
20 import invariant from 'lexical/shared/invariant';
22 import {CollabDecoratorNode} from './CollabDecoratorNode';
23 import {CollabLineBreakNode} from './CollabLineBreakNode';
24 import {CollabTextNode} from './CollabTextNode';
26 $createCollabNodeFromLexicalNode,
28 $getOrInitCollabNodeFromSharedType,
29 createLexicalNodeFromCollabNode,
30 getPositionFromElementAndOffset,
33 syncPropertiesFromLexical,
34 syncPropertiesFromYjs,
37 type IntentionallyMarkedAsDirtyElement = boolean;
39 export class CollabElementNode {
49 _parent: null | CollabElementNode;
53 parent: null | CollabElementNode,
58 this._xmlText = xmlText;
60 this._parent = parent;
63 getPrevNode(nodeMap: null | NodeMap): null | ElementNode {
64 if (nodeMap === null) {
68 const node = nodeMap.get(this._key);
69 return $isElementNode(node) ? node : null;
72 getNode(): null | ElementNode {
73 const node = $getNodeByKey(this._key);
74 return $isElementNode(node) ? node : null;
77 getSharedType(): XmlText {
90 return this._children.length === 0;
98 const collabElementNode = this._parent;
100 collabElementNode !== null,
101 'getOffset: could not find collab element node',
104 return collabElementNode.getChildOffset(this);
107 syncPropertiesFromYjs(
109 keysChanged: null | Set<string>,
111 const lexicalNode = this.getNode();
113 lexicalNode !== null,
114 'syncPropertiesFromYjs: could not find element node',
116 syncPropertiesFromYjs(binding, this._xmlText, lexicalNode, keysChanged);
119 applyChildrenYjsDelta(
122 insert?: string | object | AbstractType<unknown>;
126 [x: string]: unknown;
130 const children = this._children;
133 for (let i = 0; i < deltas.length; i++) {
134 const delta = deltas[i];
135 const insertDelta = delta.insert;
136 const deleteDelta = delta.delete;
138 if (delta.retain != null) {
139 currIndex += delta.retain;
140 } else if (typeof deleteDelta === 'number') {
141 let deletionSize = deleteDelta;
143 while (deletionSize > 0) {
144 const {node, nodeIndex, offset, length} =
145 getPositionFromElementAndOffset(this, currIndex, false);
148 node instanceof CollabElementNode ||
149 node instanceof CollabLineBreakNode ||
150 node instanceof CollabDecoratorNode
152 children.splice(nodeIndex, 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();
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
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);
176 node._text = spliceString(node._text, offset, delCount, '');
179 deletionSize -= delCount;
181 // Can occur due to the deletion from the dangling text heuristic below.
185 } else if (insertDelta != null) {
186 if (typeof insertDelta === 'string') {
187 const {node, offset} = getPositionFromElementAndOffset(
193 if (node instanceof CollabTextNode) {
194 node._text = spliceString(node._text, offset, 0, insertDelta);
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.
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
204 this._xmlText.delete(offset, insertDelta.length);
207 currIndex += insertDelta.length;
209 const sharedType = insertDelta;
210 const {nodeIndex} = getPositionFromElementAndOffset(
215 const collabNode = $getOrInitCollabNodeFromSharedType(
217 sharedType as XmlText | YMap<unknown> | XmlElement,
220 children.splice(nodeIndex, 0, collabNode);
224 throw new Error('Unexpected delta format');
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();
233 lexicalNode !== null,
234 'syncChildrenFromYjs: could not find element node',
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();
246 let writableLexicalNode;
248 let prevChildNode = null;
250 if (collabChildrenLength !== lexicalChildrenKeysLength) {
251 writableLexicalNode = lexicalNode.getWritable();
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;
260 if (collabLexicalChildNode !== null && lexicalChildKey === collabKey) {
261 const childNeedsUpdating = $isTextNode(collabLexicalChildNode);
263 visitedKeys.add(lexicalChildKey);
265 if (childNeedsUpdating) {
266 childCollabNode._key = lexicalChildKey;
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)) {
280 'syncChildrenFromYjs: expected text, element, decorator, or linebreak collab node',
285 nextLexicalChildrenKeys[i] = lexicalChildKey;
286 prevChildNode = collabLexicalChildNode;
289 if (collabKeys === undefined) {
290 collabKeys = new Set();
292 for (let s = 0; s < collabChildrenLength; s++) {
293 const child = collabChildren[s];
294 const childKey = child._key;
296 if (childKey !== '') {
297 collabKeys.add(childKey);
303 collabLexicalChildNode !== null &&
304 lexicalChildKey !== undefined &&
305 !collabKeys.has(lexicalChildKey)
307 const nodeToRemove = $getNodeByKeyOrThrow(lexicalChildKey);
308 removeFromParent(nodeToRemove);
314 writableLexicalNode = lexicalNode.getWritable();
316 const lexicalChildNode = createLexicalNodeFromCollabNode(
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;
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;
343 if (i === collabChildrenLength - 1) {
344 writableLexicalNode.__last = childKey;
346 writableLexicalNode.__size++;
347 prevChildNode = lexicalChildNode;
351 for (let i = 0; i < lexicalChildrenKeysLength; i++) {
352 const lexicalChildKey = prevLexicalChildrenKeys[i];
354 if (!visitedKeys.has(lexicalChildKey)) {
356 const lexicalChildNode = $getNodeByKeyOrThrow(lexicalChildKey);
357 const collabNode = binding.collabNodeMap.get(lexicalChildKey);
359 if (collabNode !== undefined) {
360 collabNode.destroy(binding);
362 removeFromParent(lexicalChildNode);
367 syncPropertiesFromLexical(
369 nextLexicalNode: ElementNode,
370 prevNodeMap: null | NodeMap,
372 syncPropertiesFromLexical(
375 this.getPrevNode(prevNodeMap),
380 _syncChildFromLexical(
384 prevNodeMap: null | NodeMap,
385 dirtyElements: null | Map<NodeKey, IntentionallyMarkedAsDirtyElement>,
386 dirtyLeaves: null | Set<NodeKey>,
388 const childCollabNode = this._children[index];
390 const nextChildNode = $getNodeByKeyOrThrow(key);
393 childCollabNode instanceof CollabElementNode &&
394 $isElementNode(nextChildNode)
396 childCollabNode.syncPropertiesFromLexical(
401 childCollabNode.syncChildrenFromLexical(
409 childCollabNode instanceof CollabTextNode &&
410 $isTextNode(nextChildNode)
412 childCollabNode.syncPropertiesAndTextFromLexical(
418 childCollabNode instanceof CollabDecoratorNode &&
419 $isDecoratorNode(nextChildNode)
421 childCollabNode.syncPropertiesFromLexical(
429 syncChildrenFromLexical(
431 nextLexicalNode: ElementNode,
432 prevNodeMap: null | NodeMap,
433 dirtyElements: null | Map<NodeKey, IntentionallyMarkedAsDirtyElement>,
434 dirtyLeaves: null | Set<NodeKey>,
436 const prevLexicalNode = this.getPrevNode(prevNodeMap);
438 prevLexicalNode === null
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;
450 while (prevIndex <= prevEndIndex && nextIndex <= nextEndIndex) {
451 const prevKey = prevChildren[prevIndex];
452 const nextKey = nextChildren[nextIndex];
454 if (prevKey === nextKey) {
455 // Nove move, create or remove
456 this._syncChildFromLexical(
468 if (prevChildrenSet === undefined) {
469 prevChildrenSet = new Set(prevChildren);
472 if (nextChildrenSet === undefined) {
473 nextChildrenSet = new Set(nextChildren);
476 const nextHasPrevKey = nextChildrenSet.has(prevKey);
477 const prevHasNextKey = prevChildrenSet.has(nextKey);
479 if (!nextHasPrevKey) {
481 this.splice(binding, nextIndex, 1);
485 const nextChildNode = $getNodeByKeyOrThrow(nextKey);
486 const collabNode = $createCollabNodeFromLexicalNode(
491 collabNodeMap.set(nextKey, collabNode);
493 if (prevHasNextKey) {
494 this.splice(binding, nextIndex, 1, collabNode);
498 this.splice(binding, nextIndex, 0, collabNode);
505 const appendNewChildren = prevIndex > prevEndIndex;
506 const removeOldChildren = nextIndex > nextEndIndex;
508 if (appendNewChildren && !removeOldChildren) {
509 for (; nextIndex <= nextEndIndex; ++nextIndex) {
510 const key = nextChildren[nextIndex];
511 const nextChildNode = $getNodeByKeyOrThrow(key);
512 const collabNode = $createCollabNodeFromLexicalNode(
517 this.append(collabNode);
518 collabNodeMap.set(key, collabNode);
520 } else if (removeOldChildren && !appendNewChildren) {
521 for (let i = this._children.length - 1; i >= nextIndex; i--) {
522 this.splice(binding, i, 1);
530 | CollabDecoratorNode
532 | CollabLineBreakNode,
534 const xmlText = this._xmlText;
535 const children = this._children;
536 const lastChild = children[children.length - 1];
538 lastChild !== undefined ? lastChild.getOffset() + lastChild.getSize() : 0;
540 if (collabNode instanceof CollabElementNode) {
541 xmlText.insertEmbed(offset, collabNode._xmlText);
542 } else if (collabNode instanceof CollabTextNode) {
543 const map = collabNode._map;
545 if (map.parent === null) {
546 xmlText.insertEmbed(offset, map);
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);
556 this._children.push(collabNode);
565 | CollabDecoratorNode
567 | CollabLineBreakNode,
569 const children = this._children;
570 const child = children[index];
572 if (child === undefined) {
574 collabNode !== undefined,
575 'splice: could not find collab element node',
577 this.append(collabNode);
581 const offset = child.getOffset();
582 invariant(offset !== -1, 'splice: expected offset to be greater than zero');
584 const xmlText = this._xmlText;
586 if (delCount !== 0) {
587 // What if we delete many nodes, don't we need to get all their
589 xmlText.delete(offset, child.getSize());
592 if (collabNode instanceof CollabElementNode) {
593 xmlText.insertEmbed(offset, collabNode._xmlText);
594 } else if (collabNode instanceof CollabTextNode) {
595 const map = collabNode._map;
597 if (map.parent === null) {
598 xmlText.insertEmbed(offset, map);
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);
608 if (delCount !== 0) {
609 const childrenToDelete = children.slice(index, index + delCount);
611 for (let i = 0; i < childrenToDelete.length; i++) {
612 childrenToDelete[i].destroy(binding);
616 if (collabNode !== undefined) {
617 children.splice(index, delCount, collabNode);
619 children.splice(index, delCount);
627 | CollabDecoratorNode
628 | CollabLineBreakNode,
631 const children = this._children;
633 for (let i = 0; i < children.length; i++) {
634 const child = children[i];
636 if (child === collabNode) {
640 offset += child.getSize();
646 destroy(binding: Binding): void {
647 const collabNodeMap = binding.collabNodeMap;
648 const children = this._children;
650 for (let i = 0; i < children.length; i++) {
651 children[i].destroy(binding);
654 collabNodeMap.delete(this._key);
658 export function $createCollabElementNode(
660 parent: null | CollabElementNode,
662 ): CollabElementNode {
663 const collabNode = new CollabElementNode(xmlText, parent, type);
664 xmlText._collabNode = collabNode;