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.
15 } from '../LexicalEditor';
21 SerializedLexicalNode,
22 } from '../LexicalNode';
23 import type {BaseSelection, RangeSelection} from '../LexicalSelection';
24 import type {ElementNode} from './LexicalElementNode';
26 import {IS_FIREFOX} from 'lexical/shared/environment';
27 import invariant from 'lexical/shared/invariant';
31 DETAIL_TYPE_TO_DETAIL,
49 } from '../LexicalConstants';
50 import {LexicalNode} from '../LexicalNode';
53 $internalMakeRangeSelection,
55 $updateElementSelectionOnCreateDeleteNode,
56 adjustPointOffsetForMergedSibling,
57 } from '../LexicalSelection';
58 import {errorOnReadOnly} from '../LexicalUpdates';
60 $applyNodeReplacement,
63 getCachedClassNameArray,
64 internalMarkSiblingsAsDirty,
68 } from '../LexicalUtils';
69 import {$createLineBreakNode} from './LexicalLineBreakNode';
70 import {$createTabNode} from './LexicalTabNode';
72 export type SerializedTextNode = Spread<
83 export type TextDetailType = 'directionless' | 'unmergable';
85 export type TextFormatType =
95 export type TextModeType = 'normal' | 'token' | 'segmented';
97 export type TextMark = {end: null | number; id: string; start: null | number};
99 export type TextMarks = Array<TextMark>;
101 function getElementOuterTag(node: TextNode, format: number): string | null {
102 if (format & IS_CODE) {
105 if (format & IS_HIGHLIGHT) {
108 if (format & IS_SUBSCRIPT) {
111 if (format & IS_SUPERSCRIPT) {
117 function getElementInnerTag(node: TextNode, format: number): string {
118 if (format & IS_BOLD) {
121 if (format & IS_ITALIC) {
127 function setTextThemeClassNames(
132 textClassNames: TextNodeThemeClasses,
134 const domClassList = dom.classList;
135 // Firstly we handle the base theme.
136 let classNames = getCachedClassNameArray(textClassNames, 'base');
137 if (classNames !== undefined) {
138 domClassList.add(...classNames);
140 // Secondly we handle the special case: underline + strikethrough.
141 // We have to do this as we need a way to compose the fact that
142 // the same CSS property will need to be used: text-decoration.
143 // In an ideal world we shouldn't have to do this, but there's no
144 // easy workaround for many atomic CSS systems today.
145 classNames = getCachedClassNameArray(
147 'underlineStrikethrough',
149 let hasUnderlineStrikethrough = false;
150 const prevUnderlineStrikethrough =
151 prevFormat & IS_UNDERLINE && prevFormat & IS_STRIKETHROUGH;
152 const nextUnderlineStrikethrough =
153 nextFormat & IS_UNDERLINE && nextFormat & IS_STRIKETHROUGH;
155 if (classNames !== undefined) {
156 if (nextUnderlineStrikethrough) {
157 hasUnderlineStrikethrough = true;
158 if (!prevUnderlineStrikethrough) {
159 domClassList.add(...classNames);
161 } else if (prevUnderlineStrikethrough) {
162 domClassList.remove(...classNames);
166 for (const key in TEXT_TYPE_TO_FORMAT) {
168 const flag = TEXT_TYPE_TO_FORMAT[format];
169 classNames = getCachedClassNameArray(textClassNames, key);
170 if (classNames !== undefined) {
171 if (nextFormat & flag) {
173 hasUnderlineStrikethrough &&
174 (key === 'underline' || key === 'strikethrough')
176 if (prevFormat & flag) {
177 domClassList.remove(...classNames);
182 (prevFormat & flag) === 0 ||
183 (prevUnderlineStrikethrough && key === 'underline') ||
184 key === 'strikethrough'
186 domClassList.add(...classNames);
188 } else if (prevFormat & flag) {
189 domClassList.remove(...classNames);
195 function diffComposedText(a: string, b: string): [number, number, string] {
196 const aLength = a.length;
197 const bLength = b.length;
201 while (left < aLength && left < bLength && a[left] === b[left]) {
205 right + left < aLength &&
206 right + left < bLength &&
207 a[aLength - right - 1] === b[bLength - right - 1]
212 return [left, aLength - left - right, b.slice(left, bLength - right)];
215 function setTextContent(
220 const firstChild = dom.firstChild;
221 const isComposing = node.isComposing();
222 // Always add a suffix if we're composing a node
223 const suffix = isComposing ? COMPOSITION_SUFFIX : '';
224 const text: string = nextText + suffix;
226 if (firstChild == null) {
227 dom.textContent = text;
229 const nodeValue = firstChild.nodeValue;
230 if (nodeValue !== text) {
231 if (isComposing || IS_FIREFOX) {
232 // We also use the diff composed text for general text in FF to avoid
233 // the spellcheck red line from flickering.
234 const [index, remove, insert] = diffComposedText(
240 firstChild.deleteData(index, remove);
243 firstChild.insertData(index, insert);
245 firstChild.nodeValue = text;
251 function createTextInnerDOM(
252 innerDOM: HTMLElement,
257 config: EditorConfig,
259 setTextContent(text, innerDOM, node);
260 const theme = config.theme;
261 // Apply theme class names
262 const textClassNames = theme.text;
264 if (textClassNames !== undefined) {
265 setTextThemeClassNames(innerTag, 0, format, innerDOM, textClassNames);
269 function wrapElementWith(
270 element: HTMLElement | Text,
273 const el = document.createElement(tag);
274 el.appendChild(element);
278 // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
279 export interface TextNode {
280 getTopLevelElement(): ElementNode | null;
281 getTopLevelElementOrThrow(): ElementNode;
285 // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
286 export class TextNode extends LexicalNode {
287 ['constructor']!: KlassConstructor<typeof TextNode>;
294 __mode: 0 | 1 | 2 | 3;
298 static getType(): string {
302 static clone(node: TextNode): TextNode {
303 return new TextNode(node.__text, node.__key);
306 afterCloneFrom(prevNode: this): void {
307 super.afterCloneFrom(prevNode);
308 this.__format = prevNode.__format;
309 this.__style = prevNode.__style;
310 this.__mode = prevNode.__mode;
311 this.__detail = prevNode.__detail;
314 constructor(text: string, key?: NodeKey) {
324 * Returns a 32-bit integer that represents the TextFormatTypes currently applied to the
325 * TextNode. You probably don't want to use this method directly - consider using TextNode.hasFormat instead.
327 * @returns a number representing the format of the text node.
329 getFormat(): number {
330 const self = this.getLatest();
331 return self.__format;
335 * Returns a 32-bit integer that represents the TextDetailTypes currently applied to the
336 * TextNode. You probably don't want to use this method directly - consider using TextNode.isDirectionless
337 * or TextNode.isUnmergeable instead.
339 * @returns a number representing the detail of the text node.
341 getDetail(): number {
342 const self = this.getLatest();
343 return self.__detail;
347 * Returns the mode (TextModeType) of the TextNode, which may be "normal", "token", or "segmented"
349 * @returns TextModeType.
351 getMode(): TextModeType {
352 const self = this.getLatest();
353 return TEXT_TYPE_TO_MODE[self.__mode];
357 * Returns the styles currently applied to the node. This is analogous to CSSText in the DOM.
359 * @returns CSSText-like string of styles applied to the underlying DOM node.
362 const self = this.getLatest();
367 * Returns whether or not the node is in "token" mode. TextNodes in token mode can be navigated through character-by-character
368 * with a RangeSelection, but are deleted as a single entity (not invdividually by character).
370 * @returns true if the node is in token mode, false otherwise.
373 const self = this.getLatest();
374 return self.__mode === IS_TOKEN;
379 * @returns true if Lexical detects that an IME or other 3rd-party script is attempting to
380 * mutate the TextNode, false otherwise.
382 isComposing(): boolean {
383 return this.__key === $getCompositionKey();
387 * Returns whether or not the node is in "segemented" mode. TextNodes in segemented mode can be navigated through character-by-character
388 * with a RangeSelection, but are deleted in space-delimited "segments".
390 * @returns true if the node is in segmented mode, false otherwise.
392 isSegmented(): boolean {
393 const self = this.getLatest();
394 return self.__mode === IS_SEGMENTED;
397 * Returns whether or not the node is "directionless". Directionless nodes don't respect changes between RTL and LTR modes.
399 * @returns true if the node is directionless, false otherwise.
401 isDirectionless(): boolean {
402 const self = this.getLatest();
403 return (self.__detail & IS_DIRECTIONLESS) !== 0;
406 * Returns whether or not the node is unmergeable. In some scenarios, Lexical tries to merge
407 * adjacent TextNodes into a single TextNode. If a TextNode is unmergeable, this won't happen.
409 * @returns true if the node is unmergeable, false otherwise.
411 isUnmergeable(): boolean {
412 const self = this.getLatest();
413 return (self.__detail & IS_UNMERGEABLE) !== 0;
417 * Returns whether or not the node has the provided format applied. Use this with the human-readable TextFormatType
418 * string values to get the format of a TextNode.
420 * @param type - the TextFormatType to check for.
422 * @returns true if the node has the provided format, false otherwise.
424 hasFormat(type: TextFormatType): boolean {
425 const formatFlag = TEXT_TYPE_TO_FORMAT[type];
426 return (this.getFormat() & formatFlag) !== 0;
430 * Returns whether or not the node is simple text. Simple text is defined as a TextNode that has the string type "text"
431 * (i.e., not a subclass) and has no mode applied to it (i.e., not segmented or token).
433 * @returns true if the node is simple text, false otherwise.
435 isSimpleText(): boolean {
436 return this.__type === 'text' && this.__mode === 0;
440 * Returns the text content of the node as a string.
442 * @returns a string representing the text content of the node.
444 getTextContent(): string {
445 const self = this.getLatest();
450 * Returns the format flags applied to the node as a 32-bit integer.
452 * @returns a number representing the TextFormatTypes applied to the node.
454 getFormatFlags(type: TextFormatType, alignWithFormat: null | number): number {
455 const self = this.getLatest();
456 const format = self.__format;
457 return toggleTextFormatType(format, type, alignWithFormat);
462 * @returns true if the text node supports font styling, false otherwise.
464 canHaveFormat(): boolean {
470 createDOM(config: EditorConfig, editor?: LexicalEditor): HTMLElement {
471 const format = this.__format;
472 const outerTag = getElementOuterTag(this, format);
473 const innerTag = getElementInnerTag(this, format);
474 const tag = outerTag === null ? innerTag : outerTag;
475 const dom = document.createElement(tag);
477 if (this.hasFormat('code')) {
478 dom.setAttribute('spellcheck', 'false');
480 if (outerTag !== null) {
481 innerDOM = document.createElement(innerTag);
482 dom.appendChild(innerDOM);
484 const text = this.__text;
485 createTextInnerDOM(innerDOM, this, innerTag, format, text, config);
486 const style = this.__style;
488 dom.style.cssText = style;
496 config: EditorConfig,
498 const nextText = this.__text;
499 const prevFormat = prevNode.__format;
500 const nextFormat = this.__format;
501 const prevOuterTag = getElementOuterTag(this, prevFormat);
502 const nextOuterTag = getElementOuterTag(this, nextFormat);
503 const prevInnerTag = getElementInnerTag(this, prevFormat);
504 const nextInnerTag = getElementInnerTag(this, nextFormat);
505 const prevTag = prevOuterTag === null ? prevInnerTag : prevOuterTag;
506 const nextTag = nextOuterTag === null ? nextInnerTag : nextOuterTag;
508 if (prevTag !== nextTag) {
511 if (prevOuterTag === nextOuterTag && prevInnerTag !== nextInnerTag) {
512 // should always be an element
513 const prevInnerDOM: HTMLElement = dom.firstChild as HTMLElement;
514 if (prevInnerDOM == null) {
515 invariant(false, 'updateDOM: prevInnerDOM is null or undefined');
517 const nextInnerDOM = document.createElement(nextInnerTag);
526 dom.replaceChild(nextInnerDOM, prevInnerDOM);
530 if (nextOuterTag !== null) {
531 if (prevOuterTag !== null) {
532 innerDOM = dom.firstChild as HTMLElement;
533 if (innerDOM == null) {
534 invariant(false, 'updateDOM: innerDOM is null or undefined');
538 setTextContent(nextText, innerDOM, this);
539 const theme = config.theme;
540 // Apply theme class names
541 const textClassNames = theme.text;
543 if (textClassNames !== undefined && prevFormat !== nextFormat) {
544 setTextThemeClassNames(
552 const prevStyle = prevNode.__style;
553 const nextStyle = this.__style;
554 if (prevStyle !== nextStyle) {
555 dom.style.cssText = nextStyle;
560 static importDOM(): DOMConversionMap | null {
563 conversion: $convertTextDOMNode,
567 conversion: convertBringAttentionToElement,
571 conversion: convertTextFormatElement,
575 conversion: convertTextFormatElement,
579 conversion: convertTextFormatElement,
583 conversion: convertTextFormatElement,
587 conversion: convertSpanElement,
591 conversion: convertTextFormatElement,
595 conversion: convertTextFormatElement,
599 conversion: convertTextFormatElement,
603 conversion: convertTextFormatElement,
609 static importJSON(serializedNode: SerializedTextNode): TextNode {
610 const node = $createTextNode(serializedNode.text);
611 node.setFormat(serializedNode.format);
612 node.setDetail(serializedNode.detail);
613 node.setMode(serializedNode.mode);
614 node.setStyle(serializedNode.style);
618 // This improves Lexical's basic text output in copy+paste plus
619 // for headless mode where people might use Lexical to generate
620 // HTML content and not have the ability to use CSS classes.
621 exportDOM(editor: LexicalEditor): DOMExportOutput {
622 let {element} = super.exportDOM(editor);
624 element !== null && isHTMLElement(element),
625 'Expected TextNode createDOM to always return a HTMLElement',
628 // Wrap up to retain space if head/tail whitespace exists
629 const text = this.getTextContent();
630 if (/^\s|\s$/.test(text)) {
631 element.style.whiteSpace = 'pre-wrap';
634 // Strip editor theme classes
635 for (const className of Array.from(element.classList.values())) {
636 if (className.startsWith('editor-theme-')) {
637 element.classList.remove(className);
640 if (element.classList.length === 0) {
641 element.removeAttribute('class');
644 // Remove placeholder tag if redundant
645 if (element.nodeName === 'SPAN' && !element.getAttribute('style')) {
646 element = document.createTextNode(text);
649 // This is the only way to properly add support for most clients,
650 // even if it's semantically incorrect to have to resort to using
651 // <b>, <u>, <s>, <i> elements.
652 if (this.hasFormat('bold')) {
653 element = wrapElementWith(element, 'b');
655 if (this.hasFormat('italic')) {
656 element = wrapElementWith(element, 'em');
658 if (this.hasFormat('strikethrough')) {
659 element = wrapElementWith(element, 's');
661 if (this.hasFormat('underline')) {
662 element = wrapElementWith(element, 'u');
670 exportJSON(): SerializedTextNode {
672 detail: this.getDetail(),
673 format: this.getFormat(),
674 mode: this.getMode(),
675 style: this.getStyle(),
676 text: this.getTextContent(),
684 prevSelection: null | BaseSelection,
685 nextSelection: RangeSelection,
691 * Sets the node format to the provided TextFormatType or 32-bit integer. Note that the TextFormatType
692 * version of the argument can only specify one format and doing so will remove all other formats that
693 * may be applied to the node. For toggling behavior, consider using {@link TextNode.toggleFormat}
695 * @param format - TextFormatType or 32-bit integer representing the node format.
697 * @returns this TextNode.
698 * // TODO 0.12 This should just be a `string`.
700 setFormat(format: TextFormatType | number): this {
701 const self = this.getWritable();
703 typeof format === 'string' ? TEXT_TYPE_TO_FORMAT[format] : format;
708 * Sets the node detail to the provided TextDetailType or 32-bit integer. Note that the TextDetailType
709 * version of the argument can only specify one detail value and doing so will remove all other detail values that
710 * may be applied to the node. For toggling behavior, consider using {@link TextNode.toggleDirectionless}
711 * or {@link TextNode.toggleUnmergeable}
713 * @param detail - TextDetailType or 32-bit integer representing the node detail.
715 * @returns this TextNode.
716 * // TODO 0.12 This should just be a `string`.
718 setDetail(detail: TextDetailType | number): this {
719 const self = this.getWritable();
721 typeof detail === 'string' ? DETAIL_TYPE_TO_DETAIL[detail] : detail;
726 * Sets the node style to the provided CSSText-like string. Set this property as you
727 * would an HTMLElement style attribute to apply inline styles to the underlying DOM Element.
729 * @param style - CSSText to be applied to the underlying HTMLElement.
731 * @returns this TextNode.
733 setStyle(style: string): this {
734 const self = this.getWritable();
735 self.__style = style;
740 * Applies the provided format to this TextNode if it's not present. Removes it if it's present.
741 * The subscript and superscript formats are mutually exclusive.
742 * Prefer using this method to turn specific formats on and off.
744 * @param type - TextFormatType to toggle.
746 * @returns this TextNode.
748 toggleFormat(type: TextFormatType): this {
749 const format = this.getFormat();
750 const newFormat = toggleTextFormatType(format, type, null);
751 return this.setFormat(newFormat);
755 * Toggles the directionless detail value of the node. Prefer using this method over setDetail.
757 * @returns this TextNode.
759 toggleDirectionless(): this {
760 const self = this.getWritable();
761 self.__detail ^= IS_DIRECTIONLESS;
766 * Toggles the unmergeable detail value of the node. Prefer using this method over setDetail.
768 * @returns this TextNode.
770 toggleUnmergeable(): this {
771 const self = this.getWritable();
772 self.__detail ^= IS_UNMERGEABLE;
777 * Sets the mode of the node.
779 * @returns this TextNode.
781 setMode(type: TextModeType): this {
782 const mode = TEXT_MODE_TO_TYPE[type];
783 if (this.__mode === mode) {
786 const self = this.getWritable();
792 * Sets the text content of the node.
794 * @param text - the string to set as the text value of the node.
796 * @returns this TextNode.
798 setTextContent(text: string): this {
799 if (this.__text === text) {
802 const self = this.getWritable();
808 * Sets the current Lexical selection to be a RangeSelection with anchor and focus on this TextNode at the provided offsets.
810 * @param _anchorOffset - the offset at which the Selection anchor will be placed.
811 * @param _focusOffset - the offset at which the Selection focus will be placed.
813 * @returns the new RangeSelection.
815 select(_anchorOffset?: number, _focusOffset?: number): RangeSelection {
817 let anchorOffset = _anchorOffset;
818 let focusOffset = _focusOffset;
819 const selection = $getSelection();
820 const text = this.getTextContent();
821 const key = this.__key;
822 if (typeof text === 'string') {
823 const lastOffset = text.length;
824 if (anchorOffset === undefined) {
825 anchorOffset = lastOffset;
827 if (focusOffset === undefined) {
828 focusOffset = lastOffset;
834 if (!$isRangeSelection(selection)) {
835 return $internalMakeRangeSelection(
844 const compositionKey = $getCompositionKey();
846 compositionKey === selection.anchor.key ||
847 compositionKey === selection.focus.key
849 $setCompositionKey(key);
851 selection.setTextNodeRange(this, anchorOffset, this, focusOffset);
856 selectStart(): RangeSelection {
857 return this.select(0, 0);
860 selectEnd(): RangeSelection {
861 const size = this.getTextContentSize();
862 return this.select(size, size);
866 * Inserts the provided text into this TextNode at the provided offset, deleting the number of characters
867 * specified. Can optionally calculate a new selection after the operation is complete.
869 * @param offset - the offset at which the splice operation should begin.
870 * @param delCount - the number of characters to delete, starting from the offset.
871 * @param newText - the text to insert into the TextNode at the offset.
872 * @param moveSelection - optional, whether or not to move selection to the end of the inserted substring.
874 * @returns this TextNode.
880 moveSelection?: boolean,
882 const writableSelf = this.getWritable();
883 const text = writableSelf.__text;
884 const handledTextLength = newText.length;
887 index = handledTextLength + index;
892 const selection = $getSelection();
893 if (moveSelection && $isRangeSelection(selection)) {
894 const newOffset = offset + handledTextLength;
895 selection.setTextNodeRange(
904 text.slice(0, index) + newText + text.slice(index + delCount);
906 writableSelf.__text = updatedText;
911 * This method is meant to be overriden by TextNode subclasses to control the behavior of those nodes
912 * when a user event would cause text to be inserted before them in the editor. If true, Lexical will attempt
913 * to insert text into this node. If false, it will insert the text in a new sibling node.
915 * @returns true if text can be inserted before the node, false otherwise.
917 canInsertTextBefore(): boolean {
922 * This method is meant to be overriden by TextNode subclasses to control the behavior of those nodes
923 * when a user event would cause text to be inserted after them in the editor. If true, Lexical will attempt
924 * to insert text into this node. If false, it will insert the text in a new sibling node.
926 * @returns true if text can be inserted after the node, false otherwise.
928 canInsertTextAfter(): boolean {
933 * Splits this TextNode at the provided character offsets, forming new TextNodes from the substrings
934 * formed by the split, and inserting those new TextNodes into the editor, replacing the one that was split.
936 * @param splitOffsets - rest param of the text content character offsets at which this node should be split.
938 * @returns an Array containing the newly-created TextNodes.
940 splitText(...splitOffsets: Array<number>): Array<TextNode> {
942 const self = this.getLatest();
943 const textContent = self.getTextContent();
944 const key = self.__key;
945 const compositionKey = $getCompositionKey();
946 const offsetsSet = new Set(splitOffsets);
948 const textLength = textContent.length;
950 for (let i = 0; i < textLength; i++) {
951 if (string !== '' && offsetsSet.has(i)) {
955 string += textContent[i];
960 const partsLength = parts.length;
961 if (partsLength === 0) {
963 } else if (parts[0] === textContent) {
966 const firstPart = parts[0];
967 const parent = self.getParent();
969 const format = self.getFormat();
970 const style = self.getStyle();
971 const detail = self.__detail;
972 let hasReplacedSelf = false;
974 if (self.isSegmented()) {
975 // Create a new TextNode
976 writableNode = $createTextNode(firstPart);
977 writableNode.__format = format;
978 writableNode.__style = style;
979 writableNode.__detail = detail;
980 hasReplacedSelf = true;
982 // For the first part, update the existing node
983 writableNode = self.getWritable();
984 writableNode.__text = firstPart;
988 const selection = $getSelection();
990 // Then handle all other parts
991 const splitNodes: TextNode[] = [writableNode];
992 let textSize = firstPart.length;
994 for (let i = 1; i < partsLength; i++) {
995 const part = parts[i];
996 const partSize = part.length;
997 const sibling = $createTextNode(part).getWritable();
998 sibling.__format = format;
999 sibling.__style = style;
1000 sibling.__detail = detail;
1001 const siblingKey = sibling.__key;
1002 const nextTextSize = textSize + partSize;
1004 if ($isRangeSelection(selection)) {
1005 const anchor = selection.anchor;
1006 const focus = selection.focus;
1009 anchor.key === key &&
1010 anchor.type === 'text' &&
1011 anchor.offset > textSize &&
1012 anchor.offset <= nextTextSize
1014 anchor.key = siblingKey;
1015 anchor.offset -= textSize;
1016 selection.dirty = true;
1019 focus.key === key &&
1020 focus.type === 'text' &&
1021 focus.offset > textSize &&
1022 focus.offset <= nextTextSize
1024 focus.key = siblingKey;
1025 focus.offset -= textSize;
1026 selection.dirty = true;
1029 if (compositionKey === key) {
1030 $setCompositionKey(siblingKey);
1032 textSize = nextTextSize;
1033 splitNodes.push(sibling);
1036 // Insert the nodes into the parent's children
1037 if (parent !== null) {
1038 internalMarkSiblingsAsDirty(this);
1039 const writableParent = parent.getWritable();
1040 const insertionIndex = this.getIndexWithinParent();
1041 if (hasReplacedSelf) {
1042 writableParent.splice(insertionIndex, 0, splitNodes);
1045 writableParent.splice(insertionIndex, 1, splitNodes);
1048 if ($isRangeSelection(selection)) {
1049 $updateElementSelectionOnCreateDeleteNode(
1062 * Merges the target TextNode into this TextNode, removing the target node.
1064 * @param target - the TextNode to merge into this one.
1066 * @returns this TextNode.
1068 mergeWithSibling(target: TextNode): TextNode {
1069 const isBefore = target === this.getPreviousSibling();
1070 if (!isBefore && target !== this.getNextSibling()) {
1073 'mergeWithSibling: sibling must be a previous or next sibling',
1076 const key = this.__key;
1077 const targetKey = target.__key;
1078 const text = this.__text;
1079 const textLength = text.length;
1080 const compositionKey = $getCompositionKey();
1082 if (compositionKey === targetKey) {
1083 $setCompositionKey(key);
1085 const selection = $getSelection();
1086 if ($isRangeSelection(selection)) {
1087 const anchor = selection.anchor;
1088 const focus = selection.focus;
1089 if (anchor !== null && anchor.key === targetKey) {
1090 adjustPointOffsetForMergedSibling(
1097 selection.dirty = true;
1099 if (focus !== null && focus.key === targetKey) {
1100 adjustPointOffsetForMergedSibling(
1107 selection.dirty = true;
1110 const targetText = target.__text;
1111 const newText = isBefore ? targetText + text : text + targetText;
1112 this.setTextContent(newText);
1113 const writableSelf = this.getWritable();
1115 return writableSelf;
1119 * This method is meant to be overriden by TextNode subclasses to control the behavior of those nodes
1120 * when used with the registerLexicalTextEntity function. If you're using registerLexicalTextEntity, the
1121 * node class that you create and replace matched text with should return true from this method.
1123 * @returns true if the node is to be treated as a "text entity", false otherwise.
1125 isTextEntity(): boolean {
1130 function convertSpanElement(domNode: HTMLSpanElement): DOMConversionOutput {
1131 // domNode is a <span> since we matched it by nodeName
1132 const span = domNode;
1133 const style = span.style;
1136 forChild: applyTextFormatFromStyle(style),
1141 function convertBringAttentionToElement(
1142 domNode: HTMLElement,
1143 ): DOMConversionOutput {
1144 // domNode is a <b> since we matched it by nodeName
1146 // Google Docs wraps all copied HTML in a <b> with font-weight normal
1147 const hasNormalFontWeight = b.style.fontWeight === 'normal';
1150 forChild: applyTextFormatFromStyle(
1152 hasNormalFontWeight ? undefined : 'bold',
1158 const preParentCache = new WeakMap<Node, null | Node>();
1160 function isNodePre(node: Node): boolean {
1162 node.nodeName === 'PRE' ||
1163 (node.nodeType === DOM_ELEMENT_TYPE &&
1164 (node as HTMLElement).style !== undefined &&
1165 (node as HTMLElement).style.whiteSpace !== undefined &&
1166 (node as HTMLElement).style.whiteSpace.startsWith('pre'))
1170 export function findParentPreDOMNode(node: Node) {
1172 let parent = node.parentNode;
1173 const visited = [node];
1176 (cached = preParentCache.get(parent)) === undefined &&
1179 visited.push(parent);
1180 parent = parent.parentNode;
1182 const resultNode = cached === undefined ? parent : cached;
1183 for (let i = 0; i < visited.length; i++) {
1184 preParentCache.set(visited[i], resultNode);
1189 function $convertTextDOMNode(domNode: Node): DOMConversionOutput {
1190 const domNode_ = domNode as Text;
1191 const parentDom = domNode.parentElement;
1194 'Expected parentElement of Text not to be null',
1196 let textContent = domNode_.textContent || '';
1197 // No collapse and preserve segment break for pre, pre-wrap and pre-line
1198 if (findParentPreDOMNode(domNode_) !== null) {
1199 const parts = textContent.split(/(\r?\n|\t)/);
1200 const nodes: Array<LexicalNode> = [];
1201 const length = parts.length;
1202 for (let i = 0; i < length; i++) {
1203 const part = parts[i];
1204 if (part === '\n' || part === '\r\n') {
1205 nodes.push($createLineBreakNode());
1206 } else if (part === '\t') {
1207 nodes.push($createTabNode());
1208 } else if (part !== '') {
1209 nodes.push($createTextNode(part));
1212 return {node: nodes};
1214 textContent = textContent.replace(/\r/g, '').replace(/[ \t\n]+/g, ' ');
1215 if (textContent === '') {
1216 return {node: null};
1218 if (textContent[0] === ' ') {
1219 // Traverse backward while in the same line. If content contains new line or tab -> pontential
1220 // delete, other elements can borrow from this one. Deletion depends on whether it's also the
1221 // last space (see next condition: textContent[textContent.length - 1] === ' '))
1222 let previousText: null | Text = domNode_;
1223 let isStartOfLine = true;
1225 previousText !== null &&
1226 (previousText = findTextInLine(previousText, false)) !== null
1228 const previousTextContent = previousText.textContent || '';
1229 if (previousTextContent.length > 0) {
1230 if (/[ \t\n]$/.test(previousTextContent)) {
1231 textContent = textContent.slice(1);
1233 isStartOfLine = false;
1237 if (isStartOfLine) {
1238 textContent = textContent.slice(1);
1241 if (textContent[textContent.length - 1] === ' ') {
1242 // Traverse forward while in the same line, preserve if next inline will require a space
1243 let nextText: null | Text = domNode_;
1244 let isEndOfLine = true;
1246 nextText !== null &&
1247 (nextText = findTextInLine(nextText, true)) !== null
1249 const nextTextContent = (nextText.textContent || '').replace(
1253 if (nextTextContent.length > 0) {
1254 isEndOfLine = false;
1259 textContent = textContent.slice(0, textContent.length - 1);
1262 if (textContent === '') {
1263 return {node: null};
1265 return {node: $createTextNode(textContent)};
1268 function findTextInLine(text: Text, forward: boolean): null | Text {
1269 let node: Node = text;
1270 // eslint-disable-next-line no-constant-condition
1272 let sibling: null | Node;
1274 (sibling = forward ? node.nextSibling : node.previousSibling) === null
1276 const parentElement = node.parentElement;
1277 if (parentElement === null) {
1280 node = parentElement;
1283 if (node.nodeType === DOM_ELEMENT_TYPE) {
1284 const display = (node as HTMLElement).style.display;
1286 (display === '' && !isInlineDomNode(node)) ||
1287 (display !== '' && !display.startsWith('inline'))
1292 let descendant: null | Node = node;
1293 while ((descendant = forward ? node.firstChild : node.lastChild) !== null) {
1296 if (node.nodeType === DOM_TEXT_TYPE) {
1297 return node as Text;
1298 } else if (node.nodeName === 'BR') {
1304 const nodeNameToTextFormat: Record<string, TextFormatType> = {
1315 function convertTextFormatElement(domNode: HTMLElement): DOMConversionOutput {
1316 const format = nodeNameToTextFormat[domNode.nodeName.toLowerCase()];
1317 if (format === undefined) {
1318 return {node: null};
1321 forChild: applyTextFormatFromStyle(domNode.style, format),
1326 export function $createTextNode(text = ''): TextNode {
1327 return $applyNodeReplacement(new TextNode(text));
1330 export function $isTextNode(
1331 node: LexicalNode | null | undefined,
1332 ): node is TextNode {
1333 return node instanceof TextNode;
1336 function applyTextFormatFromStyle(
1337 style: CSSStyleDeclaration,
1338 shouldApply?: TextFormatType,
1340 const fontWeight = style.fontWeight;
1341 const textDecoration = style.textDecoration.split(' ');
1342 // Google Docs uses span tags + font-weight for bold text
1343 const hasBoldFontWeight = fontWeight === '700' || fontWeight === 'bold';
1344 // Google Docs uses span tags + text-decoration: line-through for strikethrough text
1345 const hasLinethroughTextDecoration = textDecoration.includes('line-through');
1346 // Google Docs uses span tags + font-style for italic text
1347 const hasItalicFontStyle = style.fontStyle === 'italic';
1348 // Google Docs uses span tags + text-decoration: underline for underline text
1349 const hasUnderlineTextDecoration = textDecoration.includes('underline');
1350 // Google Docs uses span tags + vertical-align to specify subscript and superscript
1351 const verticalAlign = style.verticalAlign;
1353 // Styles to copy to node
1354 const color = style.color;
1355 const backgroundColor = style.backgroundColor;
1357 return (lexicalNode: LexicalNode) => {
1358 if (!$isTextNode(lexicalNode)) {
1361 if (hasBoldFontWeight && !lexicalNode.hasFormat('bold')) {
1362 lexicalNode.toggleFormat('bold');
1365 hasLinethroughTextDecoration &&
1366 !lexicalNode.hasFormat('strikethrough')
1368 lexicalNode.toggleFormat('strikethrough');
1370 if (hasItalicFontStyle && !lexicalNode.hasFormat('italic')) {
1371 lexicalNode.toggleFormat('italic');
1373 if (hasUnderlineTextDecoration && !lexicalNode.hasFormat('underline')) {
1374 lexicalNode.toggleFormat('underline');
1376 if (verticalAlign === 'sub' && !lexicalNode.hasFormat('subscript')) {
1377 lexicalNode.toggleFormat('subscript');
1379 if (verticalAlign === 'super' && !lexicalNode.hasFormat('superscript')) {
1380 lexicalNode.toggleFormat('superscript');
1384 let style = lexicalNode.getStyle();
1386 style += `color: ${color};`;
1388 if (backgroundColor && backgroundColor !== 'transparent') {
1389 style += `background-color: ${backgroundColor};`;
1392 lexicalNode.setStyle(style);
1395 if (shouldApply && !lexicalNode.hasFormat(shouldApply)) {
1396 lexicalNode.toggleFormat(shouldApply);