]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/lexical/core/nodes/LexicalTextNode.ts
9a486749421612245f977da0e558efd6158d3e13
[bookstack] / resources / js / wysiwyg / lexical / core / nodes / LexicalTextNode.ts
1 /**
2  * Copyright (c) Meta Platforms, Inc. and affiliates.
3  *
4  * This source code is licensed under the MIT license found in the
5  * LICENSE file in the root directory of this source tree.
6  *
7  */
8
9 import type {
10   EditorConfig,
11   KlassConstructor,
12   LexicalEditor,
13   Spread,
14   TextNodeThemeClasses,
15 } from '../LexicalEditor';
16 import type {
17   DOMConversionMap,
18   DOMConversionOutput,
19   DOMExportOutput,
20   NodeKey,
21   SerializedLexicalNode,
22 } from '../LexicalNode';
23 import type {BaseSelection, RangeSelection} from '../LexicalSelection';
24 import type {ElementNode} from './LexicalElementNode';
25
26 import {IS_FIREFOX} from 'lexical/shared/environment';
27 import invariant from 'lexical/shared/invariant';
28
29 import {
30   COMPOSITION_SUFFIX,
31   DETAIL_TYPE_TO_DETAIL,
32   DOM_ELEMENT_TYPE,
33   DOM_TEXT_TYPE,
34   IS_BOLD,
35   IS_CODE,
36   IS_DIRECTIONLESS,
37   IS_HIGHLIGHT,
38   IS_ITALIC,
39   IS_SEGMENTED,
40   IS_STRIKETHROUGH,
41   IS_SUBSCRIPT,
42   IS_SUPERSCRIPT,
43   IS_TOKEN,
44   IS_UNDERLINE,
45   IS_UNMERGEABLE,
46   TEXT_MODE_TO_TYPE,
47   TEXT_TYPE_TO_FORMAT,
48   TEXT_TYPE_TO_MODE,
49 } from '../LexicalConstants';
50 import {LexicalNode} from '../LexicalNode';
51 import {
52   $getSelection,
53   $internalMakeRangeSelection,
54   $isRangeSelection,
55   $updateElementSelectionOnCreateDeleteNode,
56   adjustPointOffsetForMergedSibling,
57 } from '../LexicalSelection';
58 import {errorOnReadOnly} from '../LexicalUpdates';
59 import {
60   $applyNodeReplacement,
61   $getCompositionKey,
62   $setCompositionKey,
63   getCachedClassNameArray,
64   internalMarkSiblingsAsDirty,
65   isHTMLElement,
66   isInlineDomNode,
67   toggleTextFormatType,
68 } from '../LexicalUtils';
69 import {$createLineBreakNode} from './LexicalLineBreakNode';
70 import {$createTabNode} from './LexicalTabNode';
71
72 export type SerializedTextNode = Spread<
73   {
74     detail: number;
75     format: number;
76     mode: TextModeType;
77     style: string;
78     text: string;
79   },
80   SerializedLexicalNode
81 >;
82
83 export type TextDetailType = 'directionless' | 'unmergable';
84
85 export type TextFormatType =
86   | 'bold'
87   | 'underline'
88   | 'strikethrough'
89   | 'italic'
90   | 'highlight'
91   | 'code'
92   | 'subscript'
93   | 'superscript';
94
95 export type TextModeType = 'normal' | 'token' | 'segmented';
96
97 export type TextMark = {end: null | number; id: string; start: null | number};
98
99 export type TextMarks = Array<TextMark>;
100
101 function getElementOuterTag(node: TextNode, format: number): string | null {
102   if (format & IS_CODE) {
103     return 'code';
104   }
105   if (format & IS_HIGHLIGHT) {
106     return 'mark';
107   }
108   if (format & IS_SUBSCRIPT) {
109     return 'sub';
110   }
111   if (format & IS_SUPERSCRIPT) {
112     return 'sup';
113   }
114   return null;
115 }
116
117 function getElementInnerTag(node: TextNode, format: number): string {
118   if (format & IS_BOLD) {
119     return 'strong';
120   }
121   if (format & IS_ITALIC) {
122     return 'em';
123   }
124   return 'span';
125 }
126
127 function setTextThemeClassNames(
128   tag: string,
129   prevFormat: number,
130   nextFormat: number,
131   dom: HTMLElement,
132   textClassNames: TextNodeThemeClasses,
133 ): void {
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);
139   }
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(
146     textClassNames,
147     'underlineStrikethrough',
148   );
149   let hasUnderlineStrikethrough = false;
150   const prevUnderlineStrikethrough =
151     prevFormat & IS_UNDERLINE && prevFormat & IS_STRIKETHROUGH;
152   const nextUnderlineStrikethrough =
153     nextFormat & IS_UNDERLINE && nextFormat & IS_STRIKETHROUGH;
154
155   if (classNames !== undefined) {
156     if (nextUnderlineStrikethrough) {
157       hasUnderlineStrikethrough = true;
158       if (!prevUnderlineStrikethrough) {
159         domClassList.add(...classNames);
160       }
161     } else if (prevUnderlineStrikethrough) {
162       domClassList.remove(...classNames);
163     }
164   }
165
166   for (const key in TEXT_TYPE_TO_FORMAT) {
167     const format = key;
168     const flag = TEXT_TYPE_TO_FORMAT[format];
169     classNames = getCachedClassNameArray(textClassNames, key);
170     if (classNames !== undefined) {
171       if (nextFormat & flag) {
172         if (
173           hasUnderlineStrikethrough &&
174           (key === 'underline' || key === 'strikethrough')
175         ) {
176           if (prevFormat & flag) {
177             domClassList.remove(...classNames);
178           }
179           continue;
180         }
181         if (
182           (prevFormat & flag) === 0 ||
183           (prevUnderlineStrikethrough && key === 'underline') ||
184           key === 'strikethrough'
185         ) {
186           domClassList.add(...classNames);
187         }
188       } else if (prevFormat & flag) {
189         domClassList.remove(...classNames);
190       }
191     }
192   }
193 }
194
195 function diffComposedText(a: string, b: string): [number, number, string] {
196   const aLength = a.length;
197   const bLength = b.length;
198   let left = 0;
199   let right = 0;
200
201   while (left < aLength && left < bLength && a[left] === b[left]) {
202     left++;
203   }
204   while (
205     right + left < aLength &&
206     right + left < bLength &&
207     a[aLength - right - 1] === b[bLength - right - 1]
208   ) {
209     right++;
210   }
211
212   return [left, aLength - left - right, b.slice(left, bLength - right)];
213 }
214
215 function setTextContent(
216   nextText: string,
217   dom: HTMLElement,
218   node: TextNode,
219 ): void {
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;
225
226   if (firstChild == null) {
227     dom.textContent = text;
228   } else {
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(
235           nodeValue as string,
236           text,
237         );
238         if (remove !== 0) {
239           // @ts-expect-error
240           firstChild.deleteData(index, remove);
241         }
242         // @ts-expect-error
243         firstChild.insertData(index, insert);
244       } else {
245         firstChild.nodeValue = text;
246       }
247     }
248   }
249 }
250
251 function createTextInnerDOM(
252   innerDOM: HTMLElement,
253   node: TextNode,
254   innerTag: string,
255   format: number,
256   text: string,
257   config: EditorConfig,
258 ): void {
259   setTextContent(text, innerDOM, node);
260   const theme = config.theme;
261   // Apply theme class names
262   const textClassNames = theme.text;
263
264   if (textClassNames !== undefined) {
265     setTextThemeClassNames(innerTag, 0, format, innerDOM, textClassNames);
266   }
267 }
268
269 function wrapElementWith(
270   element: HTMLElement | Text,
271   tag: string,
272 ): HTMLElement {
273   const el = document.createElement(tag);
274   el.appendChild(element);
275   return el;
276 }
277
278 // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
279 export interface TextNode {
280   getTopLevelElement(): ElementNode | null;
281   getTopLevelElementOrThrow(): ElementNode;
282 }
283
284 /** @noInheritDoc */
285 // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
286 export class TextNode extends LexicalNode {
287   ['constructor']!: KlassConstructor<typeof TextNode>;
288   __text: string;
289   /** @internal */
290   __format: number;
291   /** @internal */
292   __style: string;
293   /** @internal */
294   __mode: 0 | 1 | 2 | 3;
295   /** @internal */
296   __detail: number;
297
298   static getType(): string {
299     return 'text';
300   }
301
302   static clone(node: TextNode): TextNode {
303     return new TextNode(node.__text, node.__key);
304   }
305
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;
312   }
313
314   constructor(text: string, key?: NodeKey) {
315     super(key);
316     this.__text = text;
317     this.__format = 0;
318     this.__style = '';
319     this.__mode = 0;
320     this.__detail = 0;
321   }
322
323   /**
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.
326    *
327    * @returns a number representing the format of the text node.
328    */
329   getFormat(): number {
330     const self = this.getLatest();
331     return self.__format;
332   }
333
334   /**
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.
338    *
339    * @returns a number representing the detail of the text node.
340    */
341   getDetail(): number {
342     const self = this.getLatest();
343     return self.__detail;
344   }
345
346   /**
347    * Returns the mode (TextModeType) of the TextNode, which may be "normal", "token", or "segmented"
348    *
349    * @returns TextModeType.
350    */
351   getMode(): TextModeType {
352     const self = this.getLatest();
353     return TEXT_TYPE_TO_MODE[self.__mode];
354   }
355
356   /**
357    * Returns the styles currently applied to the node. This is analogous to CSSText in the DOM.
358    *
359    * @returns CSSText-like string of styles applied to the underlying DOM node.
360    */
361   getStyle(): string {
362     const self = this.getLatest();
363     return self.__style;
364   }
365
366   /**
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).
369    *
370    * @returns true if the node is in token mode, false otherwise.
371    */
372   isToken(): boolean {
373     const self = this.getLatest();
374     return self.__mode === IS_TOKEN;
375   }
376
377   /**
378    *
379    * @returns true if Lexical detects that an IME or other 3rd-party script is attempting to
380    * mutate the TextNode, false otherwise.
381    */
382   isComposing(): boolean {
383     return this.__key === $getCompositionKey();
384   }
385
386   /**
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".
389    *
390    * @returns true if the node is in segmented mode, false otherwise.
391    */
392   isSegmented(): boolean {
393     const self = this.getLatest();
394     return self.__mode === IS_SEGMENTED;
395   }
396   /**
397    * Returns whether or not the node is "directionless". Directionless nodes don't respect changes between RTL and LTR modes.
398    *
399    * @returns true if the node is directionless, false otherwise.
400    */
401   isDirectionless(): boolean {
402     const self = this.getLatest();
403     return (self.__detail & IS_DIRECTIONLESS) !== 0;
404   }
405   /**
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.
408    *
409    * @returns true if the node is unmergeable, false otherwise.
410    */
411   isUnmergeable(): boolean {
412     const self = this.getLatest();
413     return (self.__detail & IS_UNMERGEABLE) !== 0;
414   }
415
416   /**
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.
419    *
420    * @param type - the TextFormatType to check for.
421    *
422    * @returns true if the node has the provided format, false otherwise.
423    */
424   hasFormat(type: TextFormatType): boolean {
425     const formatFlag = TEXT_TYPE_TO_FORMAT[type];
426     return (this.getFormat() & formatFlag) !== 0;
427   }
428
429   /**
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).
432    *
433    * @returns true if the node is simple text, false otherwise.
434    */
435   isSimpleText(): boolean {
436     return this.__type === 'text' && this.__mode === 0;
437   }
438
439   /**
440    * Returns the text content of the node as a string.
441    *
442    * @returns a string representing the text content of the node.
443    */
444   getTextContent(): string {
445     const self = this.getLatest();
446     return self.__text;
447   }
448
449   /**
450    * Returns the format flags applied to the node as a 32-bit integer.
451    *
452    * @returns a number representing the TextFormatTypes applied to the node.
453    */
454   getFormatFlags(type: TextFormatType, alignWithFormat: null | number): number {
455     const self = this.getLatest();
456     const format = self.__format;
457     return toggleTextFormatType(format, type, alignWithFormat);
458   }
459
460   /**
461    *
462    * @returns true if the text node supports font styling, false otherwise.
463    */
464   canHaveFormat(): boolean {
465     return true;
466   }
467
468   // View
469
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);
476     let innerDOM = dom;
477     if (this.hasFormat('code')) {
478       dom.setAttribute('spellcheck', 'false');
479     }
480     if (outerTag !== null) {
481       innerDOM = document.createElement(innerTag);
482       dom.appendChild(innerDOM);
483     }
484     const text = this.__text;
485     createTextInnerDOM(innerDOM, this, innerTag, format, text, config);
486     const style = this.__style;
487     if (style !== '') {
488       dom.style.cssText = style;
489     }
490     return dom;
491   }
492
493   updateDOM(
494     prevNode: TextNode,
495     dom: HTMLElement,
496     config: EditorConfig,
497   ): boolean {
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;
507
508     if (prevTag !== nextTag) {
509       return true;
510     }
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');
516       }
517       const nextInnerDOM = document.createElement(nextInnerTag);
518       createTextInnerDOM(
519         nextInnerDOM,
520         this,
521         nextInnerTag,
522         nextFormat,
523         nextText,
524         config,
525       );
526       dom.replaceChild(nextInnerDOM, prevInnerDOM);
527       return false;
528     }
529     let innerDOM = dom;
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');
535         }
536       }
537     }
538     setTextContent(nextText, innerDOM, this);
539     const theme = config.theme;
540     // Apply theme class names
541     const textClassNames = theme.text;
542
543     if (textClassNames !== undefined && prevFormat !== nextFormat) {
544       setTextThemeClassNames(
545         nextInnerTag,
546         prevFormat,
547         nextFormat,
548         innerDOM,
549         textClassNames,
550       );
551     }
552     const prevStyle = prevNode.__style;
553     const nextStyle = this.__style;
554     if (prevStyle !== nextStyle) {
555       dom.style.cssText = nextStyle;
556     }
557     return false;
558   }
559
560   static importDOM(): DOMConversionMap | null {
561     return {
562       '#text': () => ({
563         conversion: $convertTextDOMNode,
564         priority: 0,
565       }),
566       b: () => ({
567         conversion: convertBringAttentionToElement,
568         priority: 0,
569       }),
570       code: () => ({
571         conversion: convertTextFormatElement,
572         priority: 0,
573       }),
574       em: () => ({
575         conversion: convertTextFormatElement,
576         priority: 0,
577       }),
578       i: () => ({
579         conversion: convertTextFormatElement,
580         priority: 0,
581       }),
582       s: () => ({
583         conversion: convertTextFormatElement,
584         priority: 0,
585       }),
586       span: () => ({
587         conversion: convertSpanElement,
588         priority: 0,
589       }),
590       strong: () => ({
591         conversion: convertTextFormatElement,
592         priority: 0,
593       }),
594       sub: () => ({
595         conversion: convertTextFormatElement,
596         priority: 0,
597       }),
598       sup: () => ({
599         conversion: convertTextFormatElement,
600         priority: 0,
601       }),
602       u: () => ({
603         conversion: convertTextFormatElement,
604         priority: 0,
605       }),
606     };
607   }
608
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);
615     return node;
616   }
617
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);
623     const originalElementName = (element?.nodeName || '').toLowerCase()
624     invariant(
625       element !== null && isHTMLElement(element),
626       'Expected TextNode createDOM to always return a HTMLElement',
627     );
628
629     // Wrap up to retain space if head/tail whitespace exists
630     const text = this.getTextContent();
631     if (/^\s|\s$/.test(text)) {
632       element.style.whiteSpace = 'pre-wrap';
633     }
634
635     // Strip editor theme classes
636     for (const className of Array.from(element.classList.values())) {
637       if (className.startsWith('editor-theme-')) {
638         element.classList.remove(className);
639       }
640     }
641     if (element.classList.length === 0) {
642       element.removeAttribute('class');
643     }
644
645     // Remove placeholder tag if redundant
646     if (element.nodeName === 'SPAN' && !element.getAttribute('style')) {
647       element = document.createTextNode(text);
648     }
649
650     // This is the only way to properly add support for most clients,
651     // even if it's semantically incorrect to have to resort to using
652     // <b>, <u>, <s>, <i> elements.
653     if (this.hasFormat('bold') && originalElementName !== 'strong') {
654       element = wrapElementWith(element, 'strong');
655     }
656     if (this.hasFormat('italic')) {
657       element = wrapElementWith(element, 'em');
658     }
659     if (this.hasFormat('strikethrough')) {
660       element = wrapElementWith(element, 's');
661     }
662     if (this.hasFormat('underline')) {
663       element = wrapElementWith(element, 'u');
664     }
665
666     return {
667       element,
668     };
669   }
670
671   exportJSON(): SerializedTextNode {
672     return {
673       detail: this.getDetail(),
674       format: this.getFormat(),
675       mode: this.getMode(),
676       style: this.getStyle(),
677       text: this.getTextContent(),
678       type: 'text',
679       version: 1,
680     };
681   }
682
683   // Mutators
684   selectionTransform(
685     prevSelection: null | BaseSelection,
686     nextSelection: RangeSelection,
687   ): void {
688     return;
689   }
690
691   /**
692    * Sets the node format to the provided TextFormatType or 32-bit integer. Note that the TextFormatType
693    * version of the argument can only specify one format and doing so will remove all other formats that
694    * may be applied to the node. For toggling behavior, consider using {@link TextNode.toggleFormat}
695    *
696    * @param format - TextFormatType or 32-bit integer representing the node format.
697    *
698    * @returns this TextNode.
699    * // TODO 0.12 This should just be a `string`.
700    */
701   setFormat(format: TextFormatType | number): this {
702     const self = this.getWritable();
703     self.__format =
704       typeof format === 'string' ? TEXT_TYPE_TO_FORMAT[format] : format;
705     return self;
706   }
707
708   /**
709    * Sets the node detail to the provided TextDetailType or 32-bit integer. Note that the TextDetailType
710    * version of the argument can only specify one detail value and doing so will remove all other detail values that
711    * may be applied to the node. For toggling behavior, consider using {@link TextNode.toggleDirectionless}
712    * or {@link TextNode.toggleUnmergeable}
713    *
714    * @param detail - TextDetailType or 32-bit integer representing the node detail.
715    *
716    * @returns this TextNode.
717    * // TODO 0.12 This should just be a `string`.
718    */
719   setDetail(detail: TextDetailType | number): this {
720     const self = this.getWritable();
721     self.__detail =
722       typeof detail === 'string' ? DETAIL_TYPE_TO_DETAIL[detail] : detail;
723     return self;
724   }
725
726   /**
727    * Sets the node style to the provided CSSText-like string. Set this property as you
728    * would an HTMLElement style attribute to apply inline styles to the underlying DOM Element.
729    *
730    * @param style - CSSText to be applied to the underlying HTMLElement.
731    *
732    * @returns this TextNode.
733    */
734   setStyle(style: string): this {
735     const self = this.getWritable();
736     self.__style = style;
737     return self;
738   }
739
740   /**
741    * Applies the provided format to this TextNode if it's not present. Removes it if it's present.
742    * The subscript and superscript formats are mutually exclusive.
743    * Prefer using this method to turn specific formats on and off.
744    *
745    * @param type - TextFormatType to toggle.
746    *
747    * @returns this TextNode.
748    */
749   toggleFormat(type: TextFormatType): this {
750     const format = this.getFormat();
751     const newFormat = toggleTextFormatType(format, type, null);
752     return this.setFormat(newFormat);
753   }
754
755   /**
756    * Toggles the directionless detail value of the node. Prefer using this method over setDetail.
757    *
758    * @returns this TextNode.
759    */
760   toggleDirectionless(): this {
761     const self = this.getWritable();
762     self.__detail ^= IS_DIRECTIONLESS;
763     return self;
764   }
765
766   /**
767    * Toggles the unmergeable detail value of the node. Prefer using this method over setDetail.
768    *
769    * @returns this TextNode.
770    */
771   toggleUnmergeable(): this {
772     const self = this.getWritable();
773     self.__detail ^= IS_UNMERGEABLE;
774     return self;
775   }
776
777   /**
778    * Sets the mode of the node.
779    *
780    * @returns this TextNode.
781    */
782   setMode(type: TextModeType): this {
783     const mode = TEXT_MODE_TO_TYPE[type];
784     if (this.__mode === mode) {
785       return this;
786     }
787     const self = this.getWritable();
788     self.__mode = mode;
789     return self;
790   }
791
792   /**
793    * Sets the text content of the node.
794    *
795    * @param text - the string to set as the text value of the node.
796    *
797    * @returns this TextNode.
798    */
799   setTextContent(text: string): this {
800     if (this.__text === text) {
801       return this;
802     }
803     const self = this.getWritable();
804     self.__text = text;
805     return self;
806   }
807
808   /**
809    * Sets the current Lexical selection to be a RangeSelection with anchor and focus on this TextNode at the provided offsets.
810    *
811    * @param _anchorOffset - the offset at which the Selection anchor will be placed.
812    * @param _focusOffset - the offset at which the Selection focus will be placed.
813    *
814    * @returns the new RangeSelection.
815    */
816   select(_anchorOffset?: number, _focusOffset?: number): RangeSelection {
817     errorOnReadOnly();
818     let anchorOffset = _anchorOffset;
819     let focusOffset = _focusOffset;
820     const selection = $getSelection();
821     const text = this.getTextContent();
822     const key = this.__key;
823     if (typeof text === 'string') {
824       const lastOffset = text.length;
825       if (anchorOffset === undefined) {
826         anchorOffset = lastOffset;
827       }
828       if (focusOffset === undefined) {
829         focusOffset = lastOffset;
830       }
831     } else {
832       anchorOffset = 0;
833       focusOffset = 0;
834     }
835     if (!$isRangeSelection(selection)) {
836       return $internalMakeRangeSelection(
837         key,
838         anchorOffset,
839         key,
840         focusOffset,
841         'text',
842         'text',
843       );
844     } else {
845       const compositionKey = $getCompositionKey();
846       if (
847         compositionKey === selection.anchor.key ||
848         compositionKey === selection.focus.key
849       ) {
850         $setCompositionKey(key);
851       }
852       selection.setTextNodeRange(this, anchorOffset, this, focusOffset);
853     }
854     return selection;
855   }
856
857   selectStart(): RangeSelection {
858     return this.select(0, 0);
859   }
860
861   selectEnd(): RangeSelection {
862     const size = this.getTextContentSize();
863     return this.select(size, size);
864   }
865
866   /**
867    * Inserts the provided text into this TextNode at the provided offset, deleting the number of characters
868    * specified. Can optionally calculate a new selection after the operation is complete.
869    *
870    * @param offset - the offset at which the splice operation should begin.
871    * @param delCount - the number of characters to delete, starting from the offset.
872    * @param newText - the text to insert into the TextNode at the offset.
873    * @param moveSelection - optional, whether or not to move selection to the end of the inserted substring.
874    *
875    * @returns this TextNode.
876    */
877   spliceText(
878     offset: number,
879     delCount: number,
880     newText: string,
881     moveSelection?: boolean,
882   ): TextNode {
883     const writableSelf = this.getWritable();
884     const text = writableSelf.__text;
885     const handledTextLength = newText.length;
886     let index = offset;
887     if (index < 0) {
888       index = handledTextLength + index;
889       if (index < 0) {
890         index = 0;
891       }
892     }
893     const selection = $getSelection();
894     if (moveSelection && $isRangeSelection(selection)) {
895       const newOffset = offset + handledTextLength;
896       selection.setTextNodeRange(
897         writableSelf,
898         newOffset,
899         writableSelf,
900         newOffset,
901       );
902     }
903
904     const updatedText =
905       text.slice(0, index) + newText + text.slice(index + delCount);
906
907     writableSelf.__text = updatedText;
908     return writableSelf;
909   }
910
911   /**
912    * This method is meant to be overriden by TextNode subclasses to control the behavior of those nodes
913    * when a user event would cause text to be inserted before them in the editor. If true, Lexical will attempt
914    * to insert text into this node. If false, it will insert the text in a new sibling node.
915    *
916    * @returns true if text can be inserted before the node, false otherwise.
917    */
918   canInsertTextBefore(): boolean {
919     return true;
920   }
921
922   /**
923    * This method is meant to be overriden by TextNode subclasses to control the behavior of those nodes
924    * when a user event would cause text to be inserted after them in the editor. If true, Lexical will attempt
925    * to insert text into this node. If false, it will insert the text in a new sibling node.
926    *
927    * @returns true if text can be inserted after the node, false otherwise.
928    */
929   canInsertTextAfter(): boolean {
930     return true;
931   }
932
933   /**
934    * Splits this TextNode at the provided character offsets, forming new TextNodes from the substrings
935    * formed by the split, and inserting those new TextNodes into the editor, replacing the one that was split.
936    *
937    * @param splitOffsets - rest param of the text content character offsets at which this node should be split.
938    *
939    * @returns an Array containing the newly-created TextNodes.
940    */
941   splitText(...splitOffsets: Array<number>): Array<TextNode> {
942     errorOnReadOnly();
943     const self = this.getLatest();
944     const textContent = self.getTextContent();
945     const key = self.__key;
946     const compositionKey = $getCompositionKey();
947     const offsetsSet = new Set(splitOffsets);
948     const parts = [];
949     const textLength = textContent.length;
950     let string = '';
951     for (let i = 0; i < textLength; i++) {
952       if (string !== '' && offsetsSet.has(i)) {
953         parts.push(string);
954         string = '';
955       }
956       string += textContent[i];
957     }
958     if (string !== '') {
959       parts.push(string);
960     }
961     const partsLength = parts.length;
962     if (partsLength === 0) {
963       return [];
964     } else if (parts[0] === textContent) {
965       return [self];
966     }
967     const firstPart = parts[0];
968     const parent = self.getParent();
969     let writableNode;
970     const format = self.getFormat();
971     const style = self.getStyle();
972     const detail = self.__detail;
973     let hasReplacedSelf = false;
974
975     if (self.isSegmented()) {
976       // Create a new TextNode
977       writableNode = $createTextNode(firstPart);
978       writableNode.__format = format;
979       writableNode.__style = style;
980       writableNode.__detail = detail;
981       hasReplacedSelf = true;
982     } else {
983       // For the first part, update the existing node
984       writableNode = self.getWritable();
985       writableNode.__text = firstPart;
986     }
987
988     // Handle selection
989     const selection = $getSelection();
990
991     // Then handle all other parts
992     const splitNodes: TextNode[] = [writableNode];
993     let textSize = firstPart.length;
994
995     for (let i = 1; i < partsLength; i++) {
996       const part = parts[i];
997       const partSize = part.length;
998       const sibling = $createTextNode(part).getWritable();
999       sibling.__format = format;
1000       sibling.__style = style;
1001       sibling.__detail = detail;
1002       const siblingKey = sibling.__key;
1003       const nextTextSize = textSize + partSize;
1004
1005       if ($isRangeSelection(selection)) {
1006         const anchor = selection.anchor;
1007         const focus = selection.focus;
1008
1009         if (
1010           anchor.key === key &&
1011           anchor.type === 'text' &&
1012           anchor.offset > textSize &&
1013           anchor.offset <= nextTextSize
1014         ) {
1015           anchor.key = siblingKey;
1016           anchor.offset -= textSize;
1017           selection.dirty = true;
1018         }
1019         if (
1020           focus.key === key &&
1021           focus.type === 'text' &&
1022           focus.offset > textSize &&
1023           focus.offset <= nextTextSize
1024         ) {
1025           focus.key = siblingKey;
1026           focus.offset -= textSize;
1027           selection.dirty = true;
1028         }
1029       }
1030       if (compositionKey === key) {
1031         $setCompositionKey(siblingKey);
1032       }
1033       textSize = nextTextSize;
1034       splitNodes.push(sibling);
1035     }
1036
1037     // Insert the nodes into the parent's children
1038     if (parent !== null) {
1039       internalMarkSiblingsAsDirty(this);
1040       const writableParent = parent.getWritable();
1041       const insertionIndex = this.getIndexWithinParent();
1042       if (hasReplacedSelf) {
1043         writableParent.splice(insertionIndex, 0, splitNodes);
1044         this.remove();
1045       } else {
1046         writableParent.splice(insertionIndex, 1, splitNodes);
1047       }
1048
1049       if ($isRangeSelection(selection)) {
1050         $updateElementSelectionOnCreateDeleteNode(
1051           selection,
1052           parent,
1053           insertionIndex,
1054           partsLength - 1,
1055         );
1056       }
1057     }
1058
1059     return splitNodes;
1060   }
1061
1062   /**
1063    * Merges the target TextNode into this TextNode, removing the target node.
1064    *
1065    * @param target - the TextNode to merge into this one.
1066    *
1067    * @returns this TextNode.
1068    */
1069   mergeWithSibling(target: TextNode): TextNode {
1070     const isBefore = target === this.getPreviousSibling();
1071     if (!isBefore && target !== this.getNextSibling()) {
1072       invariant(
1073         false,
1074         'mergeWithSibling: sibling must be a previous or next sibling',
1075       );
1076     }
1077     const key = this.__key;
1078     const targetKey = target.__key;
1079     const text = this.__text;
1080     const textLength = text.length;
1081     const compositionKey = $getCompositionKey();
1082
1083     if (compositionKey === targetKey) {
1084       $setCompositionKey(key);
1085     }
1086     const selection = $getSelection();
1087     if ($isRangeSelection(selection)) {
1088       const anchor = selection.anchor;
1089       const focus = selection.focus;
1090       if (anchor !== null && anchor.key === targetKey) {
1091         adjustPointOffsetForMergedSibling(
1092           anchor,
1093           isBefore,
1094           key,
1095           target,
1096           textLength,
1097         );
1098         selection.dirty = true;
1099       }
1100       if (focus !== null && focus.key === targetKey) {
1101         adjustPointOffsetForMergedSibling(
1102           focus,
1103           isBefore,
1104           key,
1105           target,
1106           textLength,
1107         );
1108         selection.dirty = true;
1109       }
1110     }
1111     const targetText = target.__text;
1112     const newText = isBefore ? targetText + text : text + targetText;
1113     this.setTextContent(newText);
1114     const writableSelf = this.getWritable();
1115     target.remove();
1116     return writableSelf;
1117   }
1118
1119   /**
1120    * This method is meant to be overriden by TextNode subclasses to control the behavior of those nodes
1121    * when used with the registerLexicalTextEntity function. If you're using registerLexicalTextEntity, the
1122    * node class that you create and replace matched text with should return true from this method.
1123    *
1124    * @returns true if the node is to be treated as a "text entity", false otherwise.
1125    */
1126   isTextEntity(): boolean {
1127     return false;
1128   }
1129 }
1130
1131 function convertSpanElement(domNode: HTMLSpanElement): DOMConversionOutput {
1132   // domNode is a <span> since we matched it by nodeName
1133   const span = domNode;
1134   const style = span.style;
1135
1136   return {
1137     forChild: applyTextFormatFromStyle(style),
1138     node: null,
1139   };
1140 }
1141
1142 function convertBringAttentionToElement(
1143   domNode: HTMLElement,
1144 ): DOMConversionOutput {
1145   // domNode is a <b> since we matched it by nodeName
1146   const b = domNode;
1147   // Google Docs wraps all copied HTML in a <b> with font-weight normal
1148   const hasNormalFontWeight = b.style.fontWeight === 'normal';
1149
1150   return {
1151     forChild: applyTextFormatFromStyle(
1152       b.style,
1153       hasNormalFontWeight ? undefined : 'bold',
1154     ),
1155     node: null,
1156   };
1157 }
1158
1159 const preParentCache = new WeakMap<Node, null | Node>();
1160
1161 function isNodePre(node: Node): boolean {
1162   return (
1163     node.nodeName === 'PRE' ||
1164     (node.nodeType === DOM_ELEMENT_TYPE &&
1165       (node as HTMLElement).style !== undefined &&
1166       (node as HTMLElement).style.whiteSpace !== undefined &&
1167       (node as HTMLElement).style.whiteSpace.startsWith('pre'))
1168   );
1169 }
1170
1171 export function findParentPreDOMNode(node: Node) {
1172   let cached;
1173   let parent = node.parentNode;
1174   const visited = [node];
1175   while (
1176     parent !== null &&
1177     (cached = preParentCache.get(parent)) === undefined &&
1178     !isNodePre(parent)
1179   ) {
1180     visited.push(parent);
1181     parent = parent.parentNode;
1182   }
1183   const resultNode = cached === undefined ? parent : cached;
1184   for (let i = 0; i < visited.length; i++) {
1185     preParentCache.set(visited[i], resultNode);
1186   }
1187   return resultNode;
1188 }
1189
1190 function $convertTextDOMNode(domNode: Node): DOMConversionOutput {
1191   const domNode_ = domNode as Text;
1192   const parentDom = domNode.parentElement;
1193   invariant(
1194     parentDom !== null,
1195     'Expected parentElement of Text not to be null',
1196   );
1197   let textContent = domNode_.textContent || '';
1198   // No collapse and preserve segment break for pre, pre-wrap and pre-line
1199   if (findParentPreDOMNode(domNode_) !== null) {
1200     const parts = textContent.split(/(\r?\n|\t)/);
1201     const nodes: Array<LexicalNode> = [];
1202     const length = parts.length;
1203     for (let i = 0; i < length; i++) {
1204       const part = parts[i];
1205       if (part === '\n' || part === '\r\n') {
1206         nodes.push($createLineBreakNode());
1207       } else if (part === '\t') {
1208         nodes.push($createTabNode());
1209       } else if (part !== '') {
1210         nodes.push($createTextNode(part));
1211       }
1212     }
1213     return {node: nodes};
1214   }
1215   textContent = textContent.replace(/\r/g, '').replace(/[ \t\n]+/g, ' ');
1216   if (textContent === '') {
1217     return {node: null};
1218   }
1219   if (textContent[0] === ' ') {
1220     // Traverse backward while in the same line. If content contains new line or tab -> pontential
1221     // delete, other elements can borrow from this one. Deletion depends on whether it's also the
1222     // last space (see next condition: textContent[textContent.length - 1] === ' '))
1223     let previousText: null | Text = domNode_;
1224     let isStartOfLine = true;
1225     while (
1226       previousText !== null &&
1227       (previousText = findTextInLine(previousText, false)) !== null
1228     ) {
1229       const previousTextContent = previousText.textContent || '';
1230       if (previousTextContent.length > 0) {
1231         if (/[ \t\n]$/.test(previousTextContent)) {
1232           textContent = textContent.slice(1);
1233         }
1234         isStartOfLine = false;
1235         break;
1236       }
1237     }
1238     if (isStartOfLine) {
1239       textContent = textContent.slice(1);
1240     }
1241   }
1242   if (textContent[textContent.length - 1] === ' ') {
1243     // Traverse forward while in the same line, preserve if next inline will require a space
1244     let nextText: null | Text = domNode_;
1245     let isEndOfLine = true;
1246     while (
1247       nextText !== null &&
1248       (nextText = findTextInLine(nextText, true)) !== null
1249     ) {
1250       const nextTextContent = (nextText.textContent || '').replace(
1251         /^( |\t|\r?\n)+/,
1252         '',
1253       );
1254       if (nextTextContent.length > 0) {
1255         isEndOfLine = false;
1256         break;
1257       }
1258     }
1259     if (isEndOfLine) {
1260       textContent = textContent.slice(0, textContent.length - 1);
1261     }
1262   }
1263   if (textContent === '') {
1264     return {node: null};
1265   }
1266   return {node: $createTextNode(textContent)};
1267 }
1268
1269 function findTextInLine(text: Text, forward: boolean): null | Text {
1270   let node: Node = text;
1271   // eslint-disable-next-line no-constant-condition
1272   while (true) {
1273     let sibling: null | Node;
1274     while (
1275       (sibling = forward ? node.nextSibling : node.previousSibling) === null
1276     ) {
1277       const parentElement = node.parentElement;
1278       if (parentElement === null) {
1279         return null;
1280       }
1281       node = parentElement;
1282     }
1283     node = sibling;
1284     if (node.nodeType === DOM_ELEMENT_TYPE) {
1285       const display = (node as HTMLElement).style.display;
1286       if (
1287         (display === '' && !isInlineDomNode(node)) ||
1288         (display !== '' && !display.startsWith('inline'))
1289       ) {
1290         return null;
1291       }
1292     }
1293     let descendant: null | Node = node;
1294     while ((descendant = forward ? node.firstChild : node.lastChild) !== null) {
1295       node = descendant;
1296     }
1297     if (node.nodeType === DOM_TEXT_TYPE) {
1298       return node as Text;
1299     } else if (node.nodeName === 'BR') {
1300       return null;
1301     }
1302   }
1303 }
1304
1305 const nodeNameToTextFormat: Record<string, TextFormatType> = {
1306   code: 'code',
1307   em: 'italic',
1308   i: 'italic',
1309   s: 'strikethrough',
1310   strong: 'bold',
1311   sub: 'subscript',
1312   sup: 'superscript',
1313   u: 'underline',
1314 };
1315
1316 function convertTextFormatElement(domNode: HTMLElement): DOMConversionOutput {
1317   const format = nodeNameToTextFormat[domNode.nodeName.toLowerCase()];
1318
1319   if (format === 'code' && domNode.closest('pre')) {
1320     return {node: null};
1321   }
1322
1323   if (format === undefined) {
1324     return {node: null};
1325   }
1326   return {
1327     forChild: applyTextFormatFromStyle(domNode.style, format),
1328     node: null,
1329   };
1330 }
1331
1332 export function $createTextNode(text = ''): TextNode {
1333   return $applyNodeReplacement(new TextNode(text));
1334 }
1335
1336 export function $isTextNode(
1337   node: LexicalNode | null | undefined,
1338 ): node is TextNode {
1339   return node instanceof TextNode;
1340 }
1341
1342 function applyTextFormatFromStyle(
1343   style: CSSStyleDeclaration,
1344   shouldApply?: TextFormatType,
1345 ) {
1346   const fontWeight = style.fontWeight;
1347   const textDecoration = style.textDecoration.split(' ');
1348   // Google Docs uses span tags + font-weight for bold text
1349   const hasBoldFontWeight = fontWeight === '700' || fontWeight === 'bold';
1350   // Google Docs uses span tags + text-decoration: line-through for strikethrough text
1351   const hasLinethroughTextDecoration = textDecoration.includes('line-through');
1352   // Google Docs uses span tags + font-style for italic text
1353   const hasItalicFontStyle = style.fontStyle === 'italic';
1354   // Google Docs uses span tags + text-decoration: underline for underline text
1355   const hasUnderlineTextDecoration = textDecoration.includes('underline');
1356   // Google Docs uses span tags + vertical-align to specify subscript and superscript
1357   const verticalAlign = style.verticalAlign;
1358
1359   // Styles to copy to node
1360   const color = style.color;
1361   const backgroundColor = style.backgroundColor;
1362
1363   return (lexicalNode: LexicalNode) => {
1364     if (!$isTextNode(lexicalNode)) {
1365       return lexicalNode;
1366     }
1367     if (hasBoldFontWeight && !lexicalNode.hasFormat('bold')) {
1368       lexicalNode.toggleFormat('bold');
1369     }
1370     if (
1371       hasLinethroughTextDecoration &&
1372       !lexicalNode.hasFormat('strikethrough')
1373     ) {
1374       lexicalNode.toggleFormat('strikethrough');
1375     }
1376     if (hasItalicFontStyle && !lexicalNode.hasFormat('italic')) {
1377       lexicalNode.toggleFormat('italic');
1378     }
1379     if (hasUnderlineTextDecoration && !lexicalNode.hasFormat('underline')) {
1380       lexicalNode.toggleFormat('underline');
1381     }
1382     if (verticalAlign === 'sub' && !lexicalNode.hasFormat('subscript')) {
1383       lexicalNode.toggleFormat('subscript');
1384     }
1385     if (verticalAlign === 'super' && !lexicalNode.hasFormat('superscript')) {
1386       lexicalNode.toggleFormat('superscript');
1387     }
1388
1389     // Apply styles
1390     let style = lexicalNode.getStyle();
1391     if (color) {
1392       style += `color: ${color};`;
1393     }
1394     if (backgroundColor && backgroundColor !== 'transparent') {
1395       style += `background-color: ${backgroundColor};`;
1396     }
1397     if (style) {
1398       lexicalNode.setStyle(style);
1399     }
1400
1401     if (shouldApply && !lexicalNode.hasFormat(shouldApply)) {
1402       lexicalNode.toggleFormat(shouldApply);
1403     }
1404
1405     return lexicalNode;
1406   };
1407 }