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, YjsNode} from '.';
30 import invariant from 'lexical/shared/invariant';
31 import {Doc, Map as YMap, XmlElement, XmlText} from 'yjs';
34 $createCollabDecoratorNode,
36 } from './CollabDecoratorNode';
37 import {$createCollabElementNode, CollabElementNode} from './CollabElementNode';
39 $createCollabLineBreakNode,
41 } from './CollabLineBreakNode';
42 import {$createCollabTextNode, CollabTextNode} from './CollabTextNode';
44 const baseExcludedProperties = new Set<string>([
50 const elementExcludedProperties = new Set<string>([
55 const rootExcludedProperties = new Set<string>(['__cachedText']);
56 const textExcludedProperties = new Set<string>(['__text']);
58 function isExcludedProperty(
63 if (baseExcludedProperties.has(name)) {
67 if ($isTextNode(node)) {
68 if (textExcludedProperties.has(name)) {
71 } else if ($isElementNode(node)) {
73 elementExcludedProperties.has(name) ||
74 ($isRootNode(node) && rootExcludedProperties.has(name))
80 const nodeKlass = node.constructor;
81 const excludedProperties = binding.excludedProperties.get(nodeKlass);
82 return excludedProperties != null && excludedProperties.has(name);
85 export function getIndexOfYjsNode(
86 yjsParentNode: YjsNode,
89 let node = yjsParentNode.firstChild;
99 if (node === yjsNode) {
103 // @ts-expect-error Sibling exists but type is not available from YJS.
104 node = node.nextSibling;
109 } while (node !== null);
114 export function $getNodeByKeyOrThrow(key: NodeKey): LexicalNode {
115 const node = $getNodeByKey(key);
116 invariant(node !== null, 'could not find node by key');
120 export function $createCollabNodeFromLexicalNode(
122 lexicalNode: LexicalNode,
123 parent: CollabElementNode,
127 | CollabLineBreakNode
128 | CollabDecoratorNode {
129 const nodeType = lexicalNode.__type;
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(
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);
156 invariant(false, 'Expected text, element, decorator, or linebreak node');
159 collabNode._key = lexicalNode.__key;
163 function getNodeTypeFromSharedType(
164 sharedType: XmlText | YMap<unknown> | XmlElement,
167 sharedType instanceof YMap
168 ? sharedType.get('__type')
169 : sharedType.getAttribute('__type');
170 invariant(type != null, 'Expected shared type to include type attribute');
174 export function $getOrInitCollabNodeFromSharedType(
176 sharedType: XmlText | YMap<unknown> | XmlElement,
177 parent?: CollabElementNode,
181 | CollabLineBreakNode
182 | CollabDecoratorNode {
183 const collabNode = sharedType._collabNode;
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);
191 const sharedParent = sharedType.parent;
193 parent === undefined && sharedParent !== null
194 ? $getOrInitCollabNodeFromSharedType(
196 sharedParent as XmlText | YMap<unknown> | XmlElement,
201 targetParent instanceof CollabElementNode,
202 'Expected parent to be a collab element node',
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);
211 return $createCollabTextNode(sharedType, '', targetParent, type);
212 } else if (sharedType instanceof XmlElement) {
213 return $createCollabDecoratorNode(sharedType, targetParent, type);
220 export function createLexicalNodeFromCollabNode(
225 | CollabDecoratorNode
226 | CollabLineBreakNode,
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);
234 | DecoratorNode<unknown>
237 | LexicalNode = new nodeInfo.klass();
238 lexicalNode.__parent = parentKey;
239 collabNode._key = lexicalNode.__key;
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);
252 binding.collabNodeMap.set(lexicalNode.__key, collabNode);
256 export function syncPropertiesFromYjs(
258 sharedType: XmlText | YMap<unknown> | XmlElement,
259 lexicalNode: LexicalNode,
260 keysChanged: null | Set<string>,
264 ? sharedType instanceof YMap
265 ? Array.from(sharedType.keys())
266 : Object.keys(sharedType.getAttributes())
267 : Array.from(keysChanged);
270 for (let i = 0; i < properties.length; i++) {
271 const property = properties[i];
272 if (isExcludedProperty(property, lexicalNode, binding)) {
275 // eslint-disable-next-line @typescript-eslint/no-explicit-any
276 const prevValue = (lexicalNode as any)[property];
278 sharedType instanceof YMap
279 ? sharedType.get(property)
280 : sharedType.getAttribute(property);
282 if (prevValue !== nextValue) {
283 if (nextValue instanceof Doc) {
284 const yjsDocMap = binding.docMap;
286 if (prevValue instanceof Doc) {
287 yjsDocMap.delete(prevValue.guid);
290 const nestedEditor = createEditor();
291 const key = nextValue.guid;
292 nestedEditor._key = key;
293 yjsDocMap.set(key, nextValue);
295 nextValue = nestedEditor;
298 if (writableNode === undefined) {
299 writableNode = lexicalNode.getWritable();
302 writableNode[property as keyof typeof writableNode] = nextValue;
307 export function syncPropertiesFromLexical(
309 sharedType: XmlText | YMap<unknown> | XmlElement,
310 prevLexicalNode: null | LexicalNode,
311 nextLexicalNode: LexicalNode,
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);
320 nodeProperties.set(type, properties);
323 const EditorClass = binding.editor.constructor;
325 for (let i = 0; i < properties.length; i++) {
326 const property = properties[i];
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];
333 if (prevValue !== nextValue) {
334 if (nextValue instanceof EditorClass) {
335 const yjsDocMap = binding.docMap;
338 if (prevValue instanceof EditorClass) {
339 const prevKey = prevValue._key;
340 prevDoc = yjsDocMap.get(prevKey);
341 yjsDocMap.delete(prevKey);
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);
350 // Mark the node dirty as we've assigned a new key to it
351 binding.editor.update(() => {
352 nextLexicalNode.markDirty();
356 if (sharedType instanceof YMap) {
357 sharedType.set(property, nextValue);
359 sharedType.setAttribute(property, nextValue);
365 export function spliceString(
371 return str.slice(0, index) + newText + str.slice(index + delCount);
374 export function getPositionFromElementAndOffset(
375 node: CollabElementNode,
377 boundaryIsEdge: boolean,
383 | CollabDecoratorNode
384 | CollabLineBreakNode
391 const children = node._children;
392 const childrenLength = children.length;
394 for (; i < childrenLength; i++) {
395 const child = children[i];
396 const childOffset = index;
397 const size = child.getSize();
399 const exceedsBoundary = boundaryIsEdge ? index >= offset : index > offset;
401 if (exceedsBoundary && child instanceof CollabTextNode) {
402 let textOffset = offset - childOffset - 1;
404 if (textOffset < 0) {
408 const diffLength = index - offset;
417 if (index > offset) {
424 } else if (i === childrenLength - 1) {
429 offset: childOffset + 1,
442 export function doesSelectionNeedRecovering(
443 selection: RangeSelection,
445 const anchor = selection.anchor;
446 const focus = selection.focus;
447 let recoveryNeeded = false;
450 const anchorNode = anchor.getNode();
451 const focusNode = focus.getNode();
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())
462 recoveryNeeded = true;
465 // Sometimes checking nor a node via getNode might trigger
466 // an error, so we need recovery then too.
467 recoveryNeeded = true;
470 return recoveryNeeded;
473 export function syncWithTransaction(binding: Binding, fn: () => void): void {
474 binding.doc.transact(fn, binding);
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;
491 writableParent.__first = null;
494 const writablePrevSibling = prevSibling.getWritable();
495 if (nextSibling !== null) {
496 const writableNextSibling = nextSibling.getWritable();
497 writableNextSibling.__prev = writablePrevSibling.__key;
498 writablePrevSibling.__next = writableNextSibling.__key;
500 writablePrevSibling.__next = null;
502 writableNode.__prev = null;
504 if (nextSibling === null) {
505 if (prevSibling !== null) {
506 const writablePrevSibling = prevSibling.getWritable();
507 writableParent.__last = prevSibling.__key;
508 writablePrevSibling.__next = null;
510 writableParent.__last = null;
513 const writableNextSibling = nextSibling.getWritable();
514 if (prevSibling !== null) {
515 const writablePrevSibling = prevSibling.getWritable();
516 writablePrevSibling.__next = writableNextSibling.__key;
517 writableNextSibling.__prev = writablePrevSibling.__key;
519 writableNextSibling.__prev = null;
521 writableNode.__next = null;
523 writableParent.__size--;
524 writableNode.__parent = null;
528 export function $moveSelectionToPreviousNode(
529 anchorNodeKey: string,
530 currentEditorState: EditorState,
532 const anchorNode = currentEditorState._nodeMap.get(anchorNodeKey);
534 $getRoot().selectStart();
538 const prevNodeKey = anchorNode.__prev;
539 let prevNode: ElementNode | null = null;
541 prevNode = $getNodeByKey(prevNodeKey);
544 // If previous node not found, get parent node
545 if (prevNode === null && anchorNode.__parent !== null) {
546 prevNode = $getNodeByKey(anchorNode.__parent);
548 if (prevNode === null) {
549 $getRoot().selectStart();
553 if (prevNode !== null && prevNode.isAttached()) {
554 prevNode.selectEnd();
557 // If the found node is also deleted, select the next one
558 $moveSelectionToPreviousNode(prevNode.__key, currentEditorState);