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.
21 } from './LexicalEditor';
22 import type {EditorState} from './LexicalEditorState';
23 import type {LexicalNode, NodeKey, NodeMap} from './LexicalNode';
28 } from './LexicalSelection';
29 import type {RootNode} from './nodes/LexicalRootNode';
30 import type {TextFormatType, TextNode} from './nodes/LexicalTextNode';
32 import {CAN_USE_DOM} from 'lexical/shared/canUseDOM';
33 import {IS_APPLE, IS_APPLE_WEBKIT, IS_IOS, IS_SAFARI} from 'lexical/shared/environment';
34 import invariant from 'lexical/shared/invariant';
35 import normalizeClassNames from 'lexical/shared/normalizeClassNames';
39 $getPreviousSelection,
58 } from './LexicalConstants';
59 import {LexicalEditor} from './LexicalEditor';
60 import {$flushRootMutations} from './LexicalMutations';
61 import {$normalizeSelection} from './LexicalNormalization';
63 errorOnInfiniteTransforms,
67 internalGetActiveEditorState,
68 isCurrentlyReadOnlyMode,
69 triggerCommandListeners,
71 } from './LexicalUpdates';
73 export const emptyFunction = () => {
79 export function resetRandomKey(): void {
83 export function generateRandomKey(): string {
84 return '' + keyCounter++;
87 export function getRegisteredNodeOrThrow(
88 editor: LexicalEditor,
91 const registeredNode = editor._nodes.get(nodeType);
92 if (registeredNode === undefined) {
93 invariant(false, 'registeredNode: Type %s not found', nodeType);
95 return registeredNode;
98 export const isArray = Array.isArray;
100 export const scheduleMicroTask: (fn: () => void) => void =
101 typeof queueMicrotask === 'function'
104 // No window prefix intended (#1400)
105 Promise.resolve().then(fn);
108 export function $isSelectionCapturedInDecorator(node: Node): boolean {
109 return $isDecoratorNode($getNearestNodeFromDOMNode(node));
112 export function isSelectionCapturedInDecoratorInput(anchorDOM: Node): boolean {
113 const activeElement = document.activeElement as HTMLElement;
115 if (activeElement === null) {
118 const nodeName = activeElement.nodeName;
121 $isDecoratorNode($getNearestNodeFromDOMNode(anchorDOM)) &&
122 (nodeName === 'INPUT' ||
123 nodeName === 'TEXTAREA' ||
124 (activeElement.contentEditable === 'true' &&
125 getEditorPropertyFromDOMNode(activeElement) == null))
129 export function isSelectionWithinEditor(
130 editor: LexicalEditor,
131 anchorDOM: null | Node,
132 focusDOM: null | Node,
134 const rootElement = editor.getRootElement();
137 rootElement !== null &&
138 rootElement.contains(anchorDOM) &&
139 rootElement.contains(focusDOM) &&
140 // Ignore if selection is within nested editor
141 anchorDOM !== null &&
142 !isSelectionCapturedInDecoratorInput(anchorDOM as Node) &&
143 getNearestEditorFromDOMNode(anchorDOM) === editor
151 * @returns true if the given argument is a LexicalEditor instance from this build of Lexical
153 export function isLexicalEditor(editor: unknown): editor is LexicalEditor {
154 // Check instanceof to prevent issues with multiple embedded Lexical installations
155 return editor instanceof LexicalEditor;
158 export function getNearestEditorFromDOMNode(
160 ): LexicalEditor | null {
161 let currentNode = node;
162 while (currentNode != null) {
163 const editor = getEditorPropertyFromDOMNode(currentNode);
164 if (isLexicalEditor(editor)) {
167 currentNode = getParentElement(currentNode);
173 export function getEditorPropertyFromDOMNode(node: Node | null): unknown {
174 // @ts-expect-error: internal field
175 return node ? node.__lexicalEditor : null;
178 export function getTextDirection(text: string): 'ltr' | 'rtl' | null {
179 if (RTL_REGEX.test(text)) {
182 if (LTR_REGEX.test(text)) {
188 export function $isTokenOrSegmented(node: TextNode): boolean {
189 return node.isToken() || node.isSegmented();
192 function isDOMNodeLexicalTextNode(node: Node): node is Text {
193 return node.nodeType === DOM_TEXT_TYPE;
196 export function getDOMTextNode(element: Node | null): Text | null {
198 while (node != null) {
199 if (isDOMNodeLexicalTextNode(node)) {
202 node = node.firstChild;
207 export function toggleTextFormatType(
209 type: TextFormatType,
210 alignWithFormat: null | number,
212 const activeFormat = TEXT_TYPE_TO_FORMAT[type];
214 alignWithFormat !== null &&
215 (format & activeFormat) === (alignWithFormat & activeFormat)
219 let newFormat = format ^ activeFormat;
220 if (type === 'subscript') {
221 newFormat &= ~TEXT_TYPE_TO_FORMAT.superscript;
222 } else if (type === 'superscript') {
223 newFormat &= ~TEXT_TYPE_TO_FORMAT.subscript;
228 export function $isLeafNode(
229 node: LexicalNode | null | undefined,
230 ): node is TextNode | LineBreakNode | DecoratorNode<unknown> {
231 return $isTextNode(node) || $isLineBreakNode(node) || $isDecoratorNode(node);
234 export function $setNodeKey(
236 existingKey: NodeKey | null | undefined,
238 if (existingKey != null) {
240 errorOnNodeKeyConstructorMismatch(node, existingKey);
242 node.__key = existingKey;
246 errorOnInfiniteTransforms();
247 const editor = getActiveEditor();
248 const editorState = getActiveEditorState();
249 const key = generateRandomKey();
250 editorState._nodeMap.set(key, node);
251 // TODO Split this function into leaf/element
252 if ($isElementNode(node)) {
253 editor._dirtyElements.set(key, true);
255 editor._dirtyLeaves.add(key);
257 editor._cloneNotNeeded.add(key);
258 editor._dirtyType = HAS_DIRTY_NODES;
262 function errorOnNodeKeyConstructorMismatch(
264 existingKey: NodeKey,
266 const editorState = internalGetActiveEditorState();
268 // tests expect to be able to do this kind of clone without an active editor state
271 const existingNode = editorState._nodeMap.get(existingKey);
272 if (existingNode && existingNode.constructor !== node.constructor) {
273 // Lifted condition to if statement because the inverted logic is a bit confusing
274 if (node.constructor.name !== existingNode.constructor.name) {
277 'Lexical node with constructor %s attempted to re-use key from node in active editor state with constructor %s. Keys must not be re-used when the type is changed.',
278 node.constructor.name,
279 existingNode.constructor.name,
284 'Lexical node with constructor %s attempted to re-use key from node in active editor state with different constructor with the same name (possibly due to invalid Hot Module Replacement). Keys must not be re-used when the type is changed.',
285 node.constructor.name,
291 type IntentionallyMarkedAsDirtyElement = boolean;
293 function internalMarkParentElementsAsDirty(
296 dirtyElements: Map<NodeKey, IntentionallyMarkedAsDirtyElement>,
298 let nextParentKey: string | null = parentKey;
299 while (nextParentKey !== null) {
300 if (dirtyElements.has(nextParentKey)) {
303 const node = nodeMap.get(nextParentKey);
304 if (node === undefined) {
307 dirtyElements.set(nextParentKey, false);
308 nextParentKey = node.__parent;
312 // TODO #6031 this function or their callers have to adjust selection (i.e. insertBefore)
313 export function removeFromParent(node: LexicalNode): void {
314 const oldParent = node.getParent();
315 if (oldParent !== null) {
316 const writableNode = node.getWritable();
317 const writableParent = oldParent.getWritable();
318 const prevSibling = node.getPreviousSibling();
319 const nextSibling = node.getNextSibling();
320 // TODO: this function duplicates a bunch of operations, can be simplified.
321 if (prevSibling === null) {
322 if (nextSibling !== null) {
323 const writableNextSibling = nextSibling.getWritable();
324 writableParent.__first = nextSibling.__key;
325 writableNextSibling.__prev = null;
327 writableParent.__first = null;
330 const writablePrevSibling = prevSibling.getWritable();
331 if (nextSibling !== null) {
332 const writableNextSibling = nextSibling.getWritable();
333 writableNextSibling.__prev = writablePrevSibling.__key;
334 writablePrevSibling.__next = writableNextSibling.__key;
336 writablePrevSibling.__next = null;
338 writableNode.__prev = null;
340 if (nextSibling === null) {
341 if (prevSibling !== null) {
342 const writablePrevSibling = prevSibling.getWritable();
343 writableParent.__last = prevSibling.__key;
344 writablePrevSibling.__next = null;
346 writableParent.__last = null;
349 const writableNextSibling = nextSibling.getWritable();
350 if (prevSibling !== null) {
351 const writablePrevSibling = prevSibling.getWritable();
352 writablePrevSibling.__next = writableNextSibling.__key;
353 writableNextSibling.__prev = writablePrevSibling.__key;
355 writableNextSibling.__prev = null;
357 writableNode.__next = null;
359 writableParent.__size--;
360 writableNode.__parent = null;
364 // Never use this function directly! It will break
365 // the cloning heuristic. Instead use node.getWritable().
366 export function internalMarkNodeAsDirty(node: LexicalNode): void {
367 errorOnInfiniteTransforms();
368 const latest = node.getLatest();
369 const parent = latest.__parent;
370 const editorState = getActiveEditorState();
371 const editor = getActiveEditor();
372 const nodeMap = editorState._nodeMap;
373 const dirtyElements = editor._dirtyElements;
374 if (parent !== null) {
375 internalMarkParentElementsAsDirty(parent, nodeMap, dirtyElements);
377 const key = latest.__key;
378 editor._dirtyType = HAS_DIRTY_NODES;
379 if ($isElementNode(node)) {
380 dirtyElements.set(key, true);
382 // TODO split internally MarkNodeAsDirty into two dedicated Element/leave functions
383 editor._dirtyLeaves.add(key);
387 export function internalMarkSiblingsAsDirty(node: LexicalNode) {
388 const previousNode = node.getPreviousSibling();
389 const nextNode = node.getNextSibling();
390 if (previousNode !== null) {
391 internalMarkNodeAsDirty(previousNode);
393 if (nextNode !== null) {
394 internalMarkNodeAsDirty(nextNode);
398 export function $setCompositionKey(compositionKey: null | NodeKey): void {
400 const editor = getActiveEditor();
401 const previousCompositionKey = editor._compositionKey;
402 if (compositionKey !== previousCompositionKey) {
403 editor._compositionKey = compositionKey;
404 if (previousCompositionKey !== null) {
405 const node = $getNodeByKey(previousCompositionKey);
410 if (compositionKey !== null) {
411 const node = $getNodeByKey(compositionKey);
419 export function $getCompositionKey(): null | NodeKey {
420 if (isCurrentlyReadOnlyMode()) {
423 const editor = getActiveEditor();
424 return editor._compositionKey;
427 export function $getNodeByKey<T extends LexicalNode>(
429 _editorState?: EditorState,
431 const editorState = _editorState || getActiveEditorState();
432 const node = editorState._nodeMap.get(key) as T;
433 if (node === undefined) {
439 export function $getNodeFromDOMNode(
441 editorState?: EditorState,
442 ): LexicalNode | null {
443 const editor = getActiveEditor();
444 // @ts-ignore We intentionally add this to the Node.
445 const key = dom[`__lexicalKey_${editor._key}`];
446 if (key !== undefined) {
447 return $getNodeByKey(key, editorState);
452 export function $getNearestNodeFromDOMNode(
454 editorState?: EditorState,
455 ): LexicalNode | null {
456 let dom: Node | null = startingDOM;
457 while (dom != null) {
458 const node = $getNodeFromDOMNode(dom, editorState);
462 dom = getParentElement(dom);
467 export function cloneDecorators(
468 editor: LexicalEditor,
469 ): Record<NodeKey, unknown> {
470 const currentDecorators = editor._decorators;
471 const pendingDecorators = Object.assign({}, currentDecorators);
472 editor._pendingDecorators = pendingDecorators;
473 return pendingDecorators;
476 export function getEditorStateTextContent(editorState: EditorState): string {
477 return editorState.read(() => $getRoot().getTextContent());
480 export function markAllNodesAsDirty(editor: LexicalEditor, type: string): void {
481 // Mark all existing text nodes as dirty
485 const editorState = getActiveEditorState();
486 if (editorState.isEmpty()) {
489 if (type === 'root') {
490 $getRoot().markDirty();
493 const nodeMap = editorState._nodeMap;
494 for (const [, node] of nodeMap) {
498 editor._pendingEditorState === null
500 tag: 'history-merge',
506 export function $getRoot(): RootNode {
507 return internalGetRoot(getActiveEditorState());
510 export function internalGetRoot(editorState: EditorState): RootNode {
511 return editorState._nodeMap.get('root') as RootNode;
514 export function $setSelection(selection: null | BaseSelection): void {
516 const editorState = getActiveEditorState();
517 if (selection !== null) {
519 if (Object.isFrozen(selection)) {
522 '$setSelection called on frozen selection object. Ensure selection is cloned before passing in.',
526 selection.dirty = true;
527 selection.setCachedNodes(null);
529 editorState._selection = selection;
532 export function $flushMutations(): void {
534 const editor = getActiveEditor();
535 $flushRootMutations(editor);
538 export function $getNodeFromDOM(dom: Node): null | LexicalNode {
539 const editor = getActiveEditor();
540 const nodeKey = getNodeKeyFromDOM(dom, editor);
541 if (nodeKey === null) {
542 const rootElement = editor.getRootElement();
543 if (dom === rootElement) {
544 return $getNodeByKey('root');
548 return $getNodeByKey(nodeKey);
551 export function getTextNodeOffset(
553 moveSelectionToEnd: boolean,
555 return moveSelectionToEnd ? node.getTextContentSize() : 0;
558 function getNodeKeyFromDOM(
559 // Note that node here refers to a DOM Node, not an Lexical Node
561 editor: LexicalEditor,
563 let node: Node | null = dom;
564 while (node != null) {
565 // @ts-ignore We intentionally add this to the Node.
566 const key: NodeKey = node[`__lexicalKey_${editor._key}`];
567 if (key !== undefined) {
570 node = getParentElement(node);
575 export function doesContainGrapheme(str: string): boolean {
576 return /[\uD800-\uDBFF][\uDC00-\uDFFF]/g.test(str);
579 export function getEditorsToPropagate(
580 editor: LexicalEditor,
581 ): Array<LexicalEditor> {
582 const editorsToPropagate = [];
583 let currentEditor: LexicalEditor | null = editor;
584 while (currentEditor !== null) {
585 editorsToPropagate.push(currentEditor);
586 currentEditor = currentEditor._parentEditor;
588 return editorsToPropagate;
591 export function createUID(): string {
594 .replace(/[^a-z]+/g, '')
598 export function getAnchorTextFromDOM(anchorNode: Node): null | string {
599 if (anchorNode.nodeType === DOM_TEXT_TYPE) {
600 return anchorNode.nodeValue;
605 export function $updateSelectedTextFromDOM(
606 isCompositionEnd: boolean,
607 editor: LexicalEditor,
610 // Update the text content with the latest composition text
611 const domSelection = getDOMSelection(editor._window);
612 if (domSelection === null) {
615 const anchorNode = domSelection.anchorNode;
616 let {anchorOffset, focusOffset} = domSelection;
617 if (anchorNode !== null) {
618 let textContent = getAnchorTextFromDOM(anchorNode);
619 const node = $getNearestNodeFromDOMNode(anchorNode);
620 if (textContent !== null && $isTextNode(node)) {
621 // Data is intentionally truthy, as we check for boolean, null and empty string.
622 if (textContent === COMPOSITION_SUFFIX && data) {
623 const offset = data.length;
625 anchorOffset = offset;
626 focusOffset = offset;
629 if (textContent !== null) {
630 $updateTextNodeFromDOMContent(
642 export function $updateTextNodeFromDOMContent(
645 anchorOffset: null | number,
646 focusOffset: null | number,
647 compositionEnd: boolean,
651 if (node.isAttached() && (compositionEnd || !node.isDirty())) {
652 const isComposing = node.isComposing();
653 let normalizedTextContent = textContent;
656 (isComposing || compositionEnd) &&
657 textContent[textContent.length - 1] === COMPOSITION_SUFFIX
659 normalizedTextContent = textContent.slice(0, -1);
661 const prevTextContent = node.getTextContent();
663 if (compositionEnd || normalizedTextContent !== prevTextContent) {
664 if (normalizedTextContent === '') {
665 $setCompositionKey(null);
666 if (!IS_SAFARI && !IS_IOS && !IS_APPLE_WEBKIT) {
667 // For composition (mainly Android), we have to remove the node on a later update
668 const editor = getActiveEditor();
670 editor.update(() => {
671 if (node.isAttached()) {
681 const parent = node.getParent();
682 const prevSelection = $getPreviousSelection();
683 const prevTextContentSize = node.getTextContentSize();
684 const compositionKey = $getCompositionKey();
685 const nodeKey = node.getKey();
689 (compositionKey !== null &&
690 nodeKey === compositionKey &&
692 // Check if character was added at the start or boundaries when not insertable, and we need
693 // to clear this input from occurring as that action wasn't permitted.
694 ($isRangeSelection(prevSelection) &&
696 !parent.canInsertTextBefore() &&
697 prevSelection.anchor.offset === 0) ||
698 (prevSelection.anchor.key === textNode.__key &&
699 prevSelection.anchor.offset === 0 &&
700 !node.canInsertTextBefore() &&
702 (prevSelection.focus.key === textNode.__key &&
703 prevSelection.focus.offset === prevTextContentSize &&
704 !node.canInsertTextAfter() &&
710 const selection = $getSelection();
713 !$isRangeSelection(selection) ||
714 anchorOffset === null ||
717 node.setTextContent(normalizedTextContent);
720 selection.setTextNodeRange(node, anchorOffset, node, focusOffset);
722 if (node.isSegmented()) {
723 const originalTextContent = node.getTextContent();
724 const replacement = $createTextNode(originalTextContent);
725 node.replace(replacement);
728 node.setTextContent(normalizedTextContent);
733 function $previousSiblingDoesNotAcceptText(node: TextNode): boolean {
734 const previousSibling = node.getPreviousSibling();
737 ($isTextNode(previousSibling) ||
738 ($isElementNode(previousSibling) && previousSibling.isInline())) &&
739 !previousSibling.canInsertTextAfter()
743 // This function is connected to $shouldPreventDefaultAndInsertText and determines whether the
744 // TextNode boundaries are writable or we should use the previous/next sibling instead. For example,
745 // in the case of a LinkNode, boundaries are not writable.
746 export function $shouldInsertTextAfterOrBeforeTextNode(
747 selection: RangeSelection,
750 if (node.isSegmented()) {
753 if (!selection.isCollapsed()) {
756 const offset = selection.anchor.offset;
757 const parent = node.getParentOrThrow();
758 const isToken = node.isToken();
761 !node.canInsertTextBefore() ||
762 (!parent.canInsertTextBefore() && !node.isComposing()) ||
764 $previousSiblingDoesNotAcceptText(node)
766 } else if (offset === node.getTextContentSize()) {
768 !node.canInsertTextAfter() ||
769 (!parent.canInsertTextAfter() && !node.isComposing()) ||
777 export function isTab(
783 return key === 'Tab' && !altKey && !ctrlKey && !metaKey;
786 export function isBold(
793 key.toLowerCase() === 'b' && !altKey && controlOrMeta(metaKey, ctrlKey)
797 export function isItalic(
804 key.toLowerCase() === 'i' && !altKey && controlOrMeta(metaKey, ctrlKey)
808 export function isUnderline(
815 key.toLowerCase() === 'u' && !altKey && controlOrMeta(metaKey, ctrlKey)
819 export function isParagraph(key: string, shiftKey: boolean): boolean {
820 return isReturn(key) && !shiftKey;
823 export function isLineBreak(key: string, shiftKey: boolean): boolean {
824 return isReturn(key) && shiftKey;
827 // Inserts a new line after the selection
829 export function isOpenLineBreak(key: string, ctrlKey: boolean): boolean {
831 return IS_APPLE && ctrlKey && key.toLowerCase() === 'o';
834 export function isDeleteWordBackward(
839 return isBackspace(key) && (IS_APPLE ? altKey : ctrlKey);
842 export function isDeleteWordForward(
847 return isDelete(key) && (IS_APPLE ? altKey : ctrlKey);
850 export function isDeleteLineBackward(key: string, metaKey: boolean): boolean {
851 return IS_APPLE && metaKey && isBackspace(key);
854 export function isDeleteLineForward(key: string, metaKey: boolean): boolean {
855 return IS_APPLE && metaKey && isDelete(key);
858 export function isDeleteBackward(
865 if (altKey || metaKey) {
868 return isBackspace(key) || (key.toLowerCase() === 'h' && ctrlKey);
870 if (ctrlKey || altKey || metaKey) {
873 return isBackspace(key);
876 export function isDeleteForward(
884 if (shiftKey || altKey || metaKey) {
887 return isDelete(key) || (key.toLowerCase() === 'd' && ctrlKey);
889 if (ctrlKey || altKey || metaKey) {
892 return isDelete(key);
895 export function isUndo(
902 key.toLowerCase() === 'z' && !shiftKey && controlOrMeta(metaKey, ctrlKey)
906 export function isRedo(
913 return key.toLowerCase() === 'z' && metaKey && shiftKey;
916 (key.toLowerCase() === 'y' && ctrlKey) ||
917 (key.toLowerCase() === 'z' && ctrlKey && shiftKey)
921 export function isCopy(
930 if (key.toLowerCase() === 'c') {
931 return IS_APPLE ? metaKey : ctrlKey;
937 export function isCut(
946 if (key.toLowerCase() === 'x') {
947 return IS_APPLE ? metaKey : ctrlKey;
953 function isArrowLeft(key: string): boolean {
954 return key === 'ArrowLeft';
957 function isArrowRight(key: string): boolean {
958 return key === 'ArrowRight';
961 function isArrowUp(key: string): boolean {
962 return key === 'ArrowUp';
965 function isArrowDown(key: string): boolean {
966 return key === 'ArrowDown';
969 export function isMoveBackward(
975 return isArrowLeft(key) && !ctrlKey && !metaKey && !altKey;
978 export function isMoveToStart(
985 return isArrowLeft(key) && !altKey && !shiftKey && (ctrlKey || metaKey);
988 export function isMoveForward(
994 return isArrowRight(key) && !ctrlKey && !metaKey && !altKey;
997 export function isMoveToEnd(
1004 return isArrowRight(key) && !altKey && !shiftKey && (ctrlKey || metaKey);
1007 export function isMoveUp(
1012 return isArrowUp(key) && !ctrlKey && !metaKey;
1015 export function isMoveDown(
1020 return isArrowDown(key) && !ctrlKey && !metaKey;
1023 export function isModifier(
1029 return ctrlKey || shiftKey || altKey || metaKey;
1032 export function isSpace(key: string): boolean {
1036 export function controlOrMeta(metaKey: boolean, ctrlKey: boolean): boolean {
1043 export function isReturn(key: string): boolean {
1044 return key === 'Enter';
1047 export function isBackspace(key: string): boolean {
1048 return key === 'Backspace';
1051 export function isEscape(key: string): boolean {
1052 return key === 'Escape';
1055 export function isDelete(key: string): boolean {
1056 return key === 'Delete';
1059 export function isSelectAll(
1064 return key.toLowerCase() === 'a' && controlOrMeta(metaKey, ctrlKey);
1067 export function $selectAll(): void {
1068 const root = $getRoot();
1069 const selection = root.select(0, root.getChildrenSize());
1070 $setSelection($normalizeSelection(selection));
1073 export function getCachedClassNameArray(
1074 classNamesTheme: EditorThemeClasses,
1075 classNameThemeType: string,
1077 if (classNamesTheme.__lexicalClassNameCache === undefined) {
1078 classNamesTheme.__lexicalClassNameCache = {};
1080 const classNamesCache = classNamesTheme.__lexicalClassNameCache;
1081 const cachedClassNames = classNamesCache[classNameThemeType];
1082 if (cachedClassNames !== undefined) {
1083 return cachedClassNames;
1085 const classNames = classNamesTheme[classNameThemeType];
1086 // As we're using classList, we need
1087 // to handle className tokens that have spaces.
1088 // The easiest way to do this to convert the
1089 // className tokens to an array that can be
1090 // applied to classList.add()/remove().
1091 if (typeof classNames === 'string') {
1092 const classNamesArr = normalizeClassNames(classNames);
1093 classNamesCache[classNameThemeType] = classNamesArr;
1094 return classNamesArr;
1099 export function setMutatedNode(
1100 mutatedNodes: MutatedNodes,
1101 registeredNodes: RegisteredNodes,
1102 mutationListeners: MutationListeners,
1104 mutation: NodeMutation,
1106 if (mutationListeners.size === 0) {
1109 const nodeType = node.__type;
1110 const nodeKey = node.__key;
1111 const registeredNode = registeredNodes.get(nodeType);
1112 if (registeredNode === undefined) {
1113 invariant(false, 'Type %s not in registeredNodes', nodeType);
1115 const klass = registeredNode.klass;
1116 let mutatedNodesByType = mutatedNodes.get(klass);
1117 if (mutatedNodesByType === undefined) {
1118 mutatedNodesByType = new Map();
1119 mutatedNodes.set(klass, mutatedNodesByType);
1121 const prevMutation = mutatedNodesByType.get(nodeKey);
1122 // If the node has already been "destroyed", yet we are
1123 // re-making it, then this means a move likely happened.
1124 // We should change the mutation to be that of "updated"
1126 const isMove = prevMutation === 'destroyed' && mutation === 'created';
1127 if (prevMutation === undefined || isMove) {
1128 mutatedNodesByType.set(nodeKey, isMove ? 'updated' : mutation);
1132 export function $nodesOfType<T extends LexicalNode>(klass: Klass<T>): Array<T> {
1133 const klassType = klass.getType();
1134 const editorState = getActiveEditorState();
1135 if (editorState._readOnly) {
1136 const nodes = getCachedTypeToNodeMap(editorState).get(klassType) as
1139 return nodes ? Array.from(nodes.values()) : [];
1141 const nodes = editorState._nodeMap;
1142 const nodesOfType: Array<T> = [];
1143 for (const [, node] of nodes) {
1145 node instanceof klass &&
1146 node.__type === klassType &&
1149 nodesOfType.push(node as T);
1155 function resolveElement(
1156 element: ElementNode,
1157 isBackward: boolean,
1158 focusOffset: number,
1159 ): LexicalNode | null {
1160 const parent = element.getParent();
1161 let offset = focusOffset;
1162 let block = element;
1163 if (parent !== null) {
1164 if (isBackward && focusOffset === 0) {
1165 offset = block.getIndexWithinParent();
1167 } else if (!isBackward && focusOffset === block.getChildrenSize()) {
1168 offset = block.getIndexWithinParent() + 1;
1172 return block.getChildAtIndex(isBackward ? offset - 1 : offset);
1175 export function $getAdjacentNode(
1177 isBackward: boolean,
1178 ): null | LexicalNode {
1179 const focusOffset = focus.offset;
1180 if (focus.type === 'element') {
1181 const block = focus.getNode();
1182 return resolveElement(block, isBackward, focusOffset);
1184 const focusNode = focus.getNode();
1186 (isBackward && focusOffset === 0) ||
1187 (!isBackward && focusOffset === focusNode.getTextContentSize())
1189 const possibleNode = isBackward
1190 ? focusNode.getPreviousSibling()
1191 : focusNode.getNextSibling();
1192 if (possibleNode === null) {
1193 return resolveElement(
1194 focusNode.getParentOrThrow(),
1196 focusNode.getIndexWithinParent() + (isBackward ? 0 : 1),
1199 return possibleNode;
1205 export function isFirefoxClipboardEvents(editor: LexicalEditor): boolean {
1206 const event = getWindow(editor).event;
1207 const inputType = event && (event as InputEvent).inputType;
1209 inputType === 'insertFromPaste' ||
1210 inputType === 'insertFromPasteAsQuotation'
1214 export function dispatchCommand<TCommand extends LexicalCommand<unknown>>(
1215 editor: LexicalEditor,
1217 payload: CommandPayloadType<TCommand>,
1219 return triggerCommandListeners(editor, command, payload);
1222 export function $textContentRequiresDoubleLinebreakAtEnd(
1225 return !$isRootNode(node) && !node.isLastChild() && !node.isInline();
1228 export function getElementByKeyOrThrow(
1229 editor: LexicalEditor,
1232 const element = editor._keyToDOMMap.get(key);
1234 if (element === undefined) {
1237 'Reconciliation: could not find DOM element for node key %s',
1245 export function getParentElement(node: Node): HTMLElement | null {
1246 const parentElement =
1247 (node as HTMLSlotElement).assignedSlot || node.parentElement;
1248 return parentElement !== null && parentElement.nodeType === 11
1249 ? ((parentElement as unknown as ShadowRoot).host as HTMLElement)
1253 export function scrollIntoViewIfNeeded(
1254 editor: LexicalEditor,
1255 selectionRect: DOMRect,
1256 rootElement: HTMLElement,
1258 const doc = rootElement.ownerDocument;
1259 const defaultView = doc.defaultView;
1261 if (defaultView === null) {
1264 let {top: currentTop, bottom: currentBottom} = selectionRect;
1266 let targetBottom = 0;
1267 let element: HTMLElement | null = rootElement;
1269 while (element !== null) {
1270 const isBodyElement = element === doc.body;
1271 if (isBodyElement) {
1273 targetBottom = getWindow(editor).innerHeight;
1275 const targetRect = element.getBoundingClientRect();
1276 targetTop = targetRect.top;
1277 targetBottom = targetRect.bottom;
1281 if (currentTop < targetTop) {
1282 diff = -(targetTop - currentTop);
1283 } else if (currentBottom > targetBottom) {
1284 diff = currentBottom - targetBottom;
1288 if (isBodyElement) {
1289 // Only handles scrolling of Y axis
1290 defaultView.scrollBy(0, diff);
1292 const scrollTop = element.scrollTop;
1293 element.scrollTop += diff;
1294 const yOffset = element.scrollTop - scrollTop;
1295 currentTop -= yOffset;
1296 currentBottom -= yOffset;
1299 if (isBodyElement) {
1302 element = getParentElement(element);
1306 export function $hasUpdateTag(tag: string): boolean {
1307 const editor = getActiveEditor();
1308 return editor._updateTags.has(tag);
1311 export function $addUpdateTag(tag: string): void {
1313 const editor = getActiveEditor();
1314 editor._updateTags.add(tag);
1317 export function $maybeMoveChildrenSelectionToParent(
1318 parentNode: LexicalNode,
1319 ): BaseSelection | null {
1320 const selection = $getSelection();
1321 if (!$isRangeSelection(selection) || !$isElementNode(parentNode)) {
1324 const {anchor, focus} = selection;
1325 const anchorNode = anchor.getNode();
1326 const focusNode = focus.getNode();
1327 if ($hasAncestor(anchorNode, parentNode)) {
1328 anchor.set(parentNode.__key, 0, 'element');
1330 if ($hasAncestor(focusNode, parentNode)) {
1331 focus.set(parentNode.__key, 0, 'element');
1336 export function $hasAncestor(
1338 targetNode: LexicalNode,
1340 let parent = child.getParent();
1341 while (parent !== null) {
1342 if (parent.is(targetNode)) {
1345 parent = parent.getParent();
1350 export function getDefaultView(domElem: HTMLElement): Window | null {
1351 const ownerDoc = domElem.ownerDocument;
1352 return (ownerDoc && ownerDoc.defaultView) || null;
1355 export function getWindow(editor: LexicalEditor): Window {
1356 const windowObj = editor._window;
1357 if (windowObj === null) {
1358 invariant(false, 'window object not found');
1363 export function $isInlineElementOrDecoratorNode(node: LexicalNode): boolean {
1365 ($isElementNode(node) && node.isInline()) ||
1366 ($isDecoratorNode(node) && node.isInline())
1370 export function $getNearestRootOrShadowRoot(
1372 ): RootNode | ElementNode {
1373 let parent = node.getParentOrThrow();
1374 while (parent !== null) {
1375 if ($isRootOrShadowRoot(parent)) {
1378 parent = parent.getParentOrThrow();
1383 const ShadowRootNodeBrand: unique symbol = Symbol.for(
1384 '@lexical/ShadowRootNodeBrand',
1386 type ShadowRootNode = Spread<
1387 {isShadowRoot(): true; [ShadowRootNodeBrand]: never},
1390 export function $isRootOrShadowRoot(
1391 node: null | LexicalNode,
1392 ): node is RootNode | ShadowRootNode {
1393 return $isRootNode(node) || ($isElementNode(node) && node.isShadowRoot());
1397 * Returns a shallow clone of node with a new key
1399 * @param node - The node to be copied.
1400 * @returns The copy of the node.
1402 export function $copyNode<T extends LexicalNode>(node: T): T {
1403 const copy = node.constructor.clone(node) as T;
1404 $setNodeKey(copy, null);
1408 export function $applyNodeReplacement<N extends LexicalNode>(
1411 const editor = getActiveEditor();
1412 const nodeType = node.constructor.getType();
1413 const registeredNode = editor._nodes.get(nodeType);
1414 if (registeredNode === undefined) {
1417 '$initializeNode failed. Ensure node has been registered to the editor. You can do this by passing the node class via the "nodes" array in the editor config.',
1420 const replaceFunc = registeredNode.replace;
1421 if (replaceFunc !== null) {
1422 const replacementNode = replaceFunc(node) as N;
1423 if (!(replacementNode instanceof node.constructor)) {
1426 '$initializeNode failed. Ensure replacement node is a subclass of the original node.',
1429 return replacementNode;
1434 export function errorOnInsertTextNodeOnRoot(
1436 insertNode: LexicalNode,
1438 const parentNode = node.getParent();
1440 $isRootNode(parentNode) &&
1441 !$isElementNode(insertNode) &&
1442 !$isDecoratorNode(insertNode)
1446 'Only element or decorator nodes can be inserted in to the root node',
1451 export function $getNodeByKeyOrThrow<N extends LexicalNode>(key: NodeKey): N {
1452 const node = $getNodeByKey<N>(key);
1453 if (node === null) {
1456 "Expected node with key %s to exist but it's not in the nodeMap.",
1463 function createBlockCursorElement(editorConfig: EditorConfig): HTMLDivElement {
1464 const theme = editorConfig.theme;
1465 const element = document.createElement('div');
1466 element.contentEditable = 'false';
1467 element.setAttribute('data-lexical-cursor', 'true');
1468 let blockCursorTheme = theme.blockCursor;
1469 if (blockCursorTheme !== undefined) {
1470 if (typeof blockCursorTheme === 'string') {
1471 const classNamesArr = normalizeClassNames(blockCursorTheme);
1472 // @ts-expect-error: intentional
1473 blockCursorTheme = theme.blockCursor = classNamesArr;
1475 if (blockCursorTheme !== undefined) {
1476 element.classList.add(...blockCursorTheme);
1482 function needsBlockCursor(node: null | LexicalNode): boolean {
1484 ($isDecoratorNode(node) || ($isElementNode(node) && !node.canBeEmpty())) &&
1489 export function removeDOMBlockCursorElement(
1490 blockCursorElement: HTMLElement,
1491 editor: LexicalEditor,
1492 rootElement: HTMLElement,
1494 rootElement.style.removeProperty('caret-color');
1495 editor._blockCursorElement = null;
1496 const parentElement = blockCursorElement.parentElement;
1497 if (parentElement !== null) {
1498 parentElement.removeChild(blockCursorElement);
1502 export function updateDOMBlockCursorElement(
1503 editor: LexicalEditor,
1504 rootElement: HTMLElement,
1505 nextSelection: null | BaseSelection,
1507 let blockCursorElement = editor._blockCursorElement;
1510 $isRangeSelection(nextSelection) &&
1511 nextSelection.isCollapsed() &&
1512 nextSelection.anchor.type === 'element' &&
1513 rootElement.contains(document.activeElement)
1515 const anchor = nextSelection.anchor;
1516 const elementNode = anchor.getNode();
1517 const offset = anchor.offset;
1518 const elementNodeSize = elementNode.getChildrenSize();
1519 let isBlockCursor = false;
1520 let insertBeforeElement: null | HTMLElement = null;
1522 if (offset === elementNodeSize) {
1523 const child = elementNode.getChildAtIndex(offset - 1);
1524 if (needsBlockCursor(child)) {
1525 isBlockCursor = true;
1528 const child = elementNode.getChildAtIndex(offset);
1529 if (needsBlockCursor(child)) {
1530 const sibling = (child as LexicalNode).getPreviousSibling();
1531 if (sibling === null || needsBlockCursor(sibling)) {
1532 isBlockCursor = true;
1533 insertBeforeElement = editor.getElementByKey(
1534 (child as LexicalNode).__key,
1539 if (isBlockCursor) {
1540 const elementDOM = editor.getElementByKey(
1543 if (blockCursorElement === null) {
1544 editor._blockCursorElement = blockCursorElement =
1545 createBlockCursorElement(editor._config);
1547 rootElement.style.caretColor = 'transparent';
1548 if (insertBeforeElement === null) {
1549 elementDOM.appendChild(blockCursorElement);
1551 elementDOM.insertBefore(blockCursorElement, insertBeforeElement);
1557 if (blockCursorElement !== null) {
1558 removeDOMBlockCursorElement(blockCursorElement, editor, rootElement);
1562 export function getDOMSelection(targetWindow: null | Window): null | Selection {
1563 return !CAN_USE_DOM ? null : (targetWindow || window).getSelection();
1566 export function $splitNode(
1569 ): [ElementNode | null, ElementNode] {
1570 let startNode = node.getChildAtIndex(offset);
1571 if (startNode == null) {
1576 !$isRootOrShadowRoot(node),
1577 'Can not call $splitNode() on root element',
1580 const recurse = <T extends LexicalNode>(
1582 ): [ElementNode, ElementNode, T] => {
1583 const parent = currentNode.getParentOrThrow();
1584 const isParentRoot = $isRootOrShadowRoot(parent);
1585 // The node we start split from (leaf) is moved, but its recursive
1586 // parents are copied to create separate tree
1588 currentNode === startNode && !isParentRoot
1590 : $copyNode(currentNode);
1594 $isElementNode(currentNode) && $isElementNode(nodeToMove),
1595 'Children of a root must be ElementNode',
1598 currentNode.insertAfter(nodeToMove);
1599 return [currentNode, nodeToMove, nodeToMove];
1601 const [leftTree, rightTree, newParent] = recurse(parent);
1602 const nextSiblings = currentNode.getNextSiblings();
1604 newParent.append(nodeToMove, ...nextSiblings);
1605 return [leftTree, rightTree, nodeToMove];
1609 const [leftTree, rightTree] = recurse(startNode);
1611 return [leftTree, rightTree];
1614 export function $findMatchingParent(
1615 startingNode: LexicalNode,
1616 findFn: (node: LexicalNode) => boolean,
1617 ): LexicalNode | null {
1618 let curr: ElementNode | LexicalNode | null = startingNode;
1620 while (curr !== $getRoot() && curr != null) {
1625 curr = curr.getParent();
1632 * @param x - The element being tested
1633 * @returns Returns true if x is an HTML anchor tag, false otherwise
1635 export function isHTMLAnchorElement(x: Node): x is HTMLAnchorElement {
1636 return isHTMLElement(x) && x.tagName === 'A';
1640 * @param x - The element being testing
1641 * @returns Returns true if x is an HTML element, false otherwise.
1643 export function isHTMLElement(x: Node | EventTarget): x is HTMLElement {
1644 // @ts-ignore-next-line - strict check on nodeType here should filter out non-Element EventTarget implementors
1645 return x.nodeType === 1;
1650 * @param node - the Dom Node to check
1651 * @returns if the Dom Node is an inline node
1653 export function isInlineDomNode(node: Node) {
1654 const inlineNodes = new RegExp(
1655 /^(a|abbr|acronym|b|cite|code|del|em|i|ins|kbd|label|output|q|ruby|s|samp|span|strong|sub|sup|time|u|tt|var|#text)$/,
1658 return node.nodeName.match(inlineNodes) !== null;
1663 * @param node - the Dom Node to check
1664 * @returns if the Dom Node is a block node
1666 export function isBlockDomNode(node: Node) {
1667 const blockNodes = new RegExp(
1668 /^(address|article|aside|blockquote|canvas|dd|div|dl|dt|fieldset|figcaption|figure|footer|form|h1|h2|h3|h4|h5|h6|header|hr|li|main|nav|noscript|ol|p|pre|section|table|td|tfoot|ul|video)$/,
1671 return node.nodeName.match(blockNodes) !== null;
1675 * This function is for internal use of the library.
1676 * Please do not use it as it may change in the future.
1678 export function INTERNAL_$isBlock(
1680 ): node is ElementNode | DecoratorNode<unknown> {
1681 if ($isRootNode(node) || ($isDecoratorNode(node) && !node.isInline())) {
1684 if (!$isElementNode(node) || $isRootOrShadowRoot(node)) {
1688 const firstChild = node.getFirstChild();
1689 const isLeafElement =
1690 firstChild === null ||
1691 $isLineBreakNode(firstChild) ||
1692 $isTextNode(firstChild) ||
1693 firstChild.isInline();
1695 return !node.isInline() && node.canBeEmpty() !== false && isLeafElement;
1698 export function $getAncestor<NodeType extends LexicalNode = LexicalNode>(
1700 predicate: (ancestor: LexicalNode) => ancestor is NodeType,
1703 while (parent !== null && parent.getParent() !== null && !predicate(parent)) {
1704 parent = parent.getParentOrThrow();
1706 return predicate(parent) ? parent : null;
1710 * Utility function for accessing current active editor instance.
1711 * @returns Current active editor
1713 export function $getEditor(): LexicalEditor {
1714 return getActiveEditor();
1718 export type TypeToNodeMap = Map<string, NodeMap>;
1721 * Compute a cached Map of node type to nodes for a frozen EditorState
1723 const cachedNodeMaps = new WeakMap<EditorState, TypeToNodeMap>();
1724 const EMPTY_TYPE_TO_NODE_MAP: TypeToNodeMap = new Map();
1725 export function getCachedTypeToNodeMap(
1726 editorState: EditorState,
1728 // If this is a new Editor it may have a writable this._editorState
1729 // with only a 'root' entry.
1730 if (!editorState._readOnly && editorState.isEmpty()) {
1731 return EMPTY_TYPE_TO_NODE_MAP;
1734 editorState._readOnly,
1735 'getCachedTypeToNodeMap called with a writable EditorState',
1737 let typeToNodeMap = cachedNodeMaps.get(editorState);
1738 if (!typeToNodeMap) {
1739 typeToNodeMap = new Map();
1740 cachedNodeMaps.set(editorState, typeToNodeMap);
1741 for (const [nodeKey, node] of editorState._nodeMap) {
1742 const nodeType = node.__type;
1743 let nodeMap = typeToNodeMap.get(nodeType);
1745 nodeMap = new Map();
1746 typeToNodeMap.set(nodeType, nodeMap);
1748 nodeMap.set(nodeKey, node);
1751 return typeToNodeMap;
1755 * Returns a clone of a node using `node.constructor.clone()` followed by
1756 * `clone.afterCloneFrom(node)`. The resulting clone must have the same key,
1757 * parent/next/prev pointers, and other properties that are not set by
1758 * `node.constructor.clone` (format, style, etc.). This is primarily used by
1759 * {@link LexicalNode.getWritable} to create a writable version of an
1760 * existing node. The clone is the same logical node as the original node,
1761 * do not try and use this function to duplicate or copy an existing node.
1763 * Does not mutate the EditorState.
1764 * @param node - The node to be cloned.
1765 * @returns The clone of the node.
1767 export function $cloneWithProperties<T extends LexicalNode>(latestNode: T): T {
1768 const constructor = latestNode.constructor;
1769 const mutableNode = constructor.clone(latestNode) as T;
1770 mutableNode.afterCloneFrom(latestNode);
1773 mutableNode.__key === latestNode.__key,
1774 "$cloneWithProperties: %s.clone(node) (with type '%s') did not return a node with the same key, make sure to specify node.__key as the last argument to the constructor",
1776 constructor.getType(),
1779 mutableNode.__parent === latestNode.__parent &&
1780 mutableNode.__next === latestNode.__next &&
1781 mutableNode.__prev === latestNode.__prev,
1782 "$cloneWithProperties: %s.clone(node) (with type '%s') overrided afterCloneFrom but did not call super.afterCloneFrom(prevNode)",
1784 constructor.getType(),