resources/js/wysiwyg/lexical/core/nodes/LexicalTextNode.ts
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import type {
EditorConfig,
KlassConstructor,
LexicalEditor,
Spread,
TextNodeThemeClasses,
} from '../LexicalEditor';
import type {
DOMConversionMap,
DOMConversionOutput,
DOMExportOutput,
NodeKey,
SerializedLexicalNode,
} from '../LexicalNode';
import type {BaseSelection, RangeSelection} from '../LexicalSelection';
import type {ElementNode} from './LexicalElementNode';
import {IS_FIREFOX} from 'lexical/shared/environment';
import invariant from 'lexical/shared/invariant';
import {
COMPOSITION_SUFFIX,
DETAIL_TYPE_TO_DETAIL,
DOM_ELEMENT_TYPE,
DOM_TEXT_TYPE,
IS_BOLD,
IS_CODE,
IS_DIRECTIONLESS,
IS_HIGHLIGHT,
IS_ITALIC,
IS_SEGMENTED,
IS_STRIKETHROUGH,
IS_SUBSCRIPT,
IS_SUPERSCRIPT,
IS_TOKEN,
IS_UNDERLINE,
IS_UNMERGEABLE,
TEXT_MODE_TO_TYPE,
TEXT_TYPE_TO_FORMAT,
TEXT_TYPE_TO_MODE,
} from '../LexicalConstants';
import {LexicalNode} from '../LexicalNode';
import {
$getSelection,
$internalMakeRangeSelection,
$isRangeSelection,
$updateElementSelectionOnCreateDeleteNode,
adjustPointOffsetForMergedSibling,
} from '../LexicalSelection';
import {errorOnReadOnly} from '../LexicalUpdates';
import {
$applyNodeReplacement,
$getCompositionKey,
$setCompositionKey,
getCachedClassNameArray,
internalMarkSiblingsAsDirty,
isHTMLElement,
isInlineDomNode,
toggleTextFormatType,
} from '../LexicalUtils';
import {$createLineBreakNode} from './LexicalLineBreakNode';
import {$createTabNode} from './LexicalTabNode';
export type SerializedTextNode = Spread<
{
detail: number;
format: number;
mode: TextModeType;
style: string;
text: string;
},
SerializedLexicalNode
>;
export type TextDetailType = 'directionless' | 'unmergable';
export type TextFormatType =
| 'bold'
| 'underline'
| 'strikethrough'
| 'italic'
| 'highlight'
| 'code'
| 'subscript'
| 'superscript';
export type TextModeType = 'normal' | 'token' | 'segmented';
export type TextMark = {end: null | number; id: string; start: null | number};
export type TextMarks = Array<TextMark>;
function getElementOuterTag(node: TextNode, format: number): string | null {
if (format & IS_CODE) {
return 'code';
}
if (format & IS_HIGHLIGHT) {
return 'mark';
}
if (format & IS_SUBSCRIPT) {
return 'sub';
}
if (format & IS_SUPERSCRIPT) {
return 'sup';
}
return null;
}
function getElementInnerTag(node: TextNode, format: number): string {
if (format & IS_BOLD) {
return 'strong';
}
if (format & IS_ITALIC) {
return 'em';
}
return 'span';
}
function setTextThemeClassNames(
tag: string,
prevFormat: number,
nextFormat: number,
dom: HTMLElement,
textClassNames: TextNodeThemeClasses,
): void {
const domClassList = dom.classList;
// Firstly we handle the base theme.
let classNames = getCachedClassNameArray(textClassNames, 'base');
if (classNames !== undefined) {
domClassList.add(...classNames);
}
// Secondly we handle the special case: underline + strikethrough.
// We have to do this as we need a way to compose the fact that
// the same CSS property will need to be used: text-decoration.
// In an ideal world we shouldn't have to do this, but there's no
// easy workaround for many atomic CSS systems today.
classNames = getCachedClassNameArray(
textClassNames,
'underlineStrikethrough',
);
let hasUnderlineStrikethrough = false;
const prevUnderlineStrikethrough =
prevFormat & IS_UNDERLINE && prevFormat & IS_STRIKETHROUGH;
const nextUnderlineStrikethrough =
nextFormat & IS_UNDERLINE && nextFormat & IS_STRIKETHROUGH;
if (classNames !== undefined) {
if (nextUnderlineStrikethrough) {
hasUnderlineStrikethrough = true;
if (!prevUnderlineStrikethrough) {
domClassList.add(...classNames);
}
} else if (prevUnderlineStrikethrough) {
domClassList.remove(...classNames);
}
}
for (const key in TEXT_TYPE_TO_FORMAT) {
const format = key;
const flag = TEXT_TYPE_TO_FORMAT[format];
classNames = getCachedClassNameArray(textClassNames, key);
if (classNames !== undefined) {
if (nextFormat & flag) {
if (
hasUnderlineStrikethrough &&
(key === 'underline' || key === 'strikethrough')
) {
if (prevFormat & flag) {
domClassList.remove(...classNames);
}
continue;
}
if (
(prevFormat & flag) === 0 ||
(prevUnderlineStrikethrough && key === 'underline') ||
key === 'strikethrough'
) {
domClassList.add(...classNames);
}
} else if (prevFormat & flag) {
domClassList.remove(...classNames);
}
}
}
}
function diffComposedText(a: string, b: string): [number, number, string] {
const aLength = a.length;
const bLength = b.length;
let left = 0;
let right = 0;
while (left < aLength && left < bLength && a[left] === b[left]) {
left++;
}
while (
right + left < aLength &&
right + left < bLength &&
a[aLength - right - 1] === b[bLength - right - 1]
) {
right++;
}
return [left, aLength - left - right, b.slice(left, bLength - right)];
}
function setTextContent(
nextText: string,
dom: HTMLElement,
node: TextNode,
): void {
const firstChild = dom.firstChild;
const isComposing = node.isComposing();
// Always add a suffix if we're composing a node
const suffix = isComposing ? COMPOSITION_SUFFIX : '';
const text: string = nextText + suffix;
if (firstChild == null) {
dom.textContent = text;
} else {
const nodeValue = firstChild.nodeValue;
if (nodeValue !== text) {
if (isComposing || IS_FIREFOX) {
// We also use the diff composed text for general text in FF to avoid
// the spellcheck red line from flickering.
const [index, remove, insert] = diffComposedText(
nodeValue as string,
text,
);
if (remove !== 0) {
// @ts-expect-error
firstChild.deleteData(index, remove);
}
// @ts-expect-error
firstChild.insertData(index, insert);
} else {
firstChild.nodeValue = text;
}
}
}
}
function createTextInnerDOM(
innerDOM: HTMLElement,
node: TextNode,
innerTag: string,
format: number,
text: string,
config: EditorConfig,
): void {
setTextContent(text, innerDOM, node);
const theme = config.theme;
// Apply theme class names
const textClassNames = theme.text;
if (textClassNames !== undefined) {
setTextThemeClassNames(innerTag, 0, format, innerDOM, textClassNames);
}
}
function wrapElementWith(
element: HTMLElement | Text,
tag: string,
): HTMLElement {
const el = document.createElement(tag);
el.appendChild(element);
return el;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
export interface TextNode {
getTopLevelElement(): ElementNode | null;
getTopLevelElementOrThrow(): ElementNode;
}
/** @noInheritDoc */
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
export class TextNode extends LexicalNode {
['constructor']!: KlassConstructor<typeof TextNode>;
__text: string;
/** @internal */
__format: number;
/** @internal */
__style: string;
/** @internal */
__mode: 0 | 1 | 2 | 3;
/** @internal */
__detail: number;
static getType(): string {
return 'text';
}
static clone(node: TextNode): TextNode {
return new TextNode(node.__text, node.__key);
}
afterCloneFrom(prevNode: this): void {
super.afterCloneFrom(prevNode);
this.__format = prevNode.__format;
this.__style = prevNode.__style;
this.__mode = prevNode.__mode;
this.__detail = prevNode.__detail;
}
constructor(text: string, key?: NodeKey) {
super(key);
this.__text = text;
this.__format = 0;
this.__style = '';
this.__mode = 0;
this.__detail = 0;
}
/**
* Returns a 32-bit integer that represents the TextFormatTypes currently applied to the
* TextNode. You probably don't want to use this method directly - consider using TextNode.hasFormat instead.
*
* @returns a number representing the format of the text node.
*/
getFormat(): number {
const self = this.getLatest();
return self.__format;
}
/**
* Returns a 32-bit integer that represents the TextDetailTypes currently applied to the
* TextNode. You probably don't want to use this method directly - consider using TextNode.isDirectionless
* or TextNode.isUnmergeable instead.
*
* @returns a number representing the detail of the text node.
*/
getDetail(): number {
const self = this.getLatest();
return self.__detail;
}
/**
* Returns the mode (TextModeType) of the TextNode, which may be "normal", "token", or "segmented"
*
* @returns TextModeType.
*/
getMode(): TextModeType {
const self = this.getLatest();
return TEXT_TYPE_TO_MODE[self.__mode];
}
/**
* Returns the styles currently applied to the node. This is analogous to CSSText in the DOM.
*
* @returns CSSText-like string of styles applied to the underlying DOM node.
*/
getStyle(): string {
const self = this.getLatest();
return self.__style;
}
/**
* Returns whether or not the node is in "token" mode. TextNodes in token mode can be navigated through character-by-character
* with a RangeSelection, but are deleted as a single entity (not invdividually by character).
*
* @returns true if the node is in token mode, false otherwise.
*/
isToken(): boolean {
const self = this.getLatest();
return self.__mode === IS_TOKEN;
}
/**
*
* @returns true if Lexical detects that an IME or other 3rd-party script is attempting to
* mutate the TextNode, false otherwise.
*/
isComposing(): boolean {
return this.__key === $getCompositionKey();
}
/**
* Returns whether or not the node is in "segemented" mode. TextNodes in segemented mode can be navigated through character-by-character
* with a RangeSelection, but are deleted in space-delimited "segments".
*
* @returns true if the node is in segmented mode, false otherwise.
*/
isSegmented(): boolean {
const self = this.getLatest();
return self.__mode === IS_SEGMENTED;
}
/**
* Returns whether or not the node is "directionless". Directionless nodes don't respect changes between RTL and LTR modes.
*
* @returns true if the node is directionless, false otherwise.
*/
isDirectionless(): boolean {
const self = this.getLatest();
return (self.__detail & IS_DIRECTIONLESS) !== 0;
}
/**
* Returns whether or not the node is unmergeable. In some scenarios, Lexical tries to merge
* adjacent TextNodes into a single TextNode. If a TextNode is unmergeable, this won't happen.
*
* @returns true if the node is unmergeable, false otherwise.
*/
isUnmergeable(): boolean {
const self = this.getLatest();
return (self.__detail & IS_UNMERGEABLE) !== 0;
}
/**
* Returns whether or not the node has the provided format applied. Use this with the human-readable TextFormatType
* string values to get the format of a TextNode.
*
* @param type - the TextFormatType to check for.
*
* @returns true if the node has the provided format, false otherwise.
*/
hasFormat(type: TextFormatType): boolean {
const formatFlag = TEXT_TYPE_TO_FORMAT[type];
return (this.getFormat() & formatFlag) !== 0;
}
/**
* Returns whether or not the node is simple text. Simple text is defined as a TextNode that has the string type "text"
* (i.e., not a subclass) and has no mode applied to it (i.e., not segmented or token).
*
* @returns true if the node is simple text, false otherwise.
*/
isSimpleText(): boolean {
return this.__type === 'text' && this.__mode === 0;
}
/**
* Returns the text content of the node as a string.
*
* @returns a string representing the text content of the node.
*/
getTextContent(): string {
const self = this.getLatest();
return self.__text;
}
/**
* Returns the format flags applied to the node as a 32-bit integer.
*
* @returns a number representing the TextFormatTypes applied to the node.
*/
getFormatFlags(type: TextFormatType, alignWithFormat: null | number): number {
const self = this.getLatest();
const format = self.__format;
return toggleTextFormatType(format, type, alignWithFormat);
}
/**
*
* @returns true if the text node supports font styling, false otherwise.
*/
canHaveFormat(): boolean {
return true;
}
// View
createDOM(config: EditorConfig, editor?: LexicalEditor): HTMLElement {
const format = this.__format;
const outerTag = getElementOuterTag(this, format);
const innerTag = getElementInnerTag(this, format);
const tag = outerTag === null ? innerTag : outerTag;
const dom = document.createElement(tag);
let innerDOM = dom;
if (this.hasFormat('code')) {
dom.setAttribute('spellcheck', 'false');
}
if (outerTag !== null) {
innerDOM = document.createElement(innerTag);
dom.appendChild(innerDOM);
}
const text = this.__text;
createTextInnerDOM(innerDOM, this, innerTag, format, text, config);
const style = this.__style;
if (style !== '') {
dom.style.cssText = style;
}
return dom;
}
updateDOM(
prevNode: TextNode,
dom: HTMLElement,
config: EditorConfig,
): boolean {
const nextText = this.__text;
const prevFormat = prevNode.__format;
const nextFormat = this.__format;
const prevOuterTag = getElementOuterTag(this, prevFormat);
const nextOuterTag = getElementOuterTag(this, nextFormat);
const prevInnerTag = getElementInnerTag(this, prevFormat);
const nextInnerTag = getElementInnerTag(this, nextFormat);
const prevTag = prevOuterTag === null ? prevInnerTag : prevOuterTag;
const nextTag = nextOuterTag === null ? nextInnerTag : nextOuterTag;
if (prevTag !== nextTag) {
return true;
}
if (prevOuterTag === nextOuterTag && prevInnerTag !== nextInnerTag) {
// should always be an element
const prevInnerDOM: HTMLElement = dom.firstChild as HTMLElement;
if (prevInnerDOM == null) {
invariant(false, 'updateDOM: prevInnerDOM is null or undefined');
}
const nextInnerDOM = document.createElement(nextInnerTag);
createTextInnerDOM(
nextInnerDOM,
this,
nextInnerTag,
nextFormat,
nextText,
config,
);
dom.replaceChild(nextInnerDOM, prevInnerDOM);
return false;
}
let innerDOM = dom;
if (nextOuterTag !== null) {
if (prevOuterTag !== null) {
innerDOM = dom.firstChild as HTMLElement;
if (innerDOM == null) {
invariant(false, 'updateDOM: innerDOM is null or undefined');
}
}
}
setTextContent(nextText, innerDOM, this);
const theme = config.theme;
// Apply theme class names
const textClassNames = theme.text;
if (textClassNames !== undefined && prevFormat !== nextFormat) {
setTextThemeClassNames(
nextInnerTag,
prevFormat,
nextFormat,
innerDOM,
textClassNames,
);
}
const prevStyle = prevNode.__style;
const nextStyle = this.__style;
if (prevStyle !== nextStyle) {
dom.style.cssText = nextStyle;
}
return false;
}
static importDOM(): DOMConversionMap | null {
return {
'#text': () => ({
conversion: $convertTextDOMNode,
priority: 0,
}),
b: () => ({
conversion: convertBringAttentionToElement,
priority: 0,
}),
code: () => ({
conversion: convertTextFormatElement,
priority: 0,
}),
em: () => ({
conversion: convertTextFormatElement,
priority: 0,
}),
i: () => ({
conversion: convertTextFormatElement,
priority: 0,
}),
s: () => ({
conversion: convertTextFormatElement,
priority: 0,
}),
span: () => ({
conversion: convertSpanElement,
priority: 0,
}),
strong: () => ({
conversion: convertTextFormatElement,
priority: 0,
}),
sub: () => ({
conversion: convertTextFormatElement,
priority: 0,
}),
sup: () => ({
conversion: convertTextFormatElement,
priority: 0,
}),
u: () => ({
conversion: convertTextFormatElement,
priority: 0,
}),
};
}
static importJSON(serializedNode: SerializedTextNode): TextNode {
const node = $createTextNode(serializedNode.text);
node.setFormat(serializedNode.format);
node.setDetail(serializedNode.detail);
node.setMode(serializedNode.mode);
node.setStyle(serializedNode.style);
return node;
}
// This improves Lexical's basic text output in copy+paste plus
// for headless mode where people might use Lexical to generate
// HTML content and not have the ability to use CSS classes.
exportDOM(editor: LexicalEditor): DOMExportOutput {
let {element} = super.exportDOM(editor);
invariant(
element !== null && isHTMLElement(element),
'Expected TextNode createDOM to always return a HTMLElement',
);
// Wrap up to retain space if head/tail whitespace exists
const text = this.getTextContent();
if (/^\s|\s$/.test(text)) {
element.style.whiteSpace = 'pre-wrap';
}
// Strip editor theme classes
for (const className of Array.from(element.classList.values())) {
if (className.startsWith('editor-theme-')) {
element.classList.remove(className);
}
}
if (element.classList.length === 0) {
element.removeAttribute('class');
}
// Remove placeholder tag if redundant
if (element.nodeName === 'SPAN' && !element.getAttribute('style')) {
element = document.createTextNode(text);
}
// This is the only way to properly add support for most clients,
// even if it's semantically incorrect to have to resort to using
// <b>, <u>, <s>, <i> elements.
if (this.hasFormat('bold')) {
element = wrapElementWith(element, 'b');
}
if (this.hasFormat('italic')) {
element = wrapElementWith(element, 'em');
}
if (this.hasFormat('strikethrough')) {
element = wrapElementWith(element, 's');
}
if (this.hasFormat('underline')) {
element = wrapElementWith(element, 'u');
}
return {
element,
};
}
exportJSON(): SerializedTextNode {
return {
detail: this.getDetail(),
format: this.getFormat(),
mode: this.getMode(),
style: this.getStyle(),
text: this.getTextContent(),
type: 'text',
version: 1,
};
}
// Mutators
selectionTransform(
prevSelection: null | BaseSelection,
nextSelection: RangeSelection,
): void {
return;
}
/**
* Sets the node format to the provided TextFormatType or 32-bit integer. Note that the TextFormatType
* version of the argument can only specify one format and doing so will remove all other formats that
* may be applied to the node. For toggling behavior, consider using {@link TextNode.toggleFormat}
*
* @param format - TextFormatType or 32-bit integer representing the node format.
*
* @returns this TextNode.
* // TODO 0.12 This should just be a `string`.
*/
setFormat(format: TextFormatType | number): this {
const self = this.getWritable();
self.__format =
typeof format === 'string' ? TEXT_TYPE_TO_FORMAT[format] : format;
return self;
}
/**
* Sets the node detail to the provided TextDetailType or 32-bit integer. Note that the TextDetailType
* version of the argument can only specify one detail value and doing so will remove all other detail values that
* may be applied to the node. For toggling behavior, consider using {@link TextNode.toggleDirectionless}
* or {@link TextNode.toggleUnmergeable}
*
* @param detail - TextDetailType or 32-bit integer representing the node detail.
*
* @returns this TextNode.
* // TODO 0.12 This should just be a `string`.
*/
setDetail(detail: TextDetailType | number): this {
const self = this.getWritable();
self.__detail =
typeof detail === 'string' ? DETAIL_TYPE_TO_DETAIL[detail] : detail;
return self;
}
/**
* Sets the node style to the provided CSSText-like string. Set this property as you
* would an HTMLElement style attribute to apply inline styles to the underlying DOM Element.
*
* @param style - CSSText to be applied to the underlying HTMLElement.
*
* @returns this TextNode.
*/
setStyle(style: string): this {
const self = this.getWritable();
self.__style = style;
return self;
}
/**
* Applies the provided format to this TextNode if it's not present. Removes it if it's present.
* The subscript and superscript formats are mutually exclusive.
* Prefer using this method to turn specific formats on and off.
*
* @param type - TextFormatType to toggle.
*
* @returns this TextNode.
*/
toggleFormat(type: TextFormatType): this {
const format = this.getFormat();
const newFormat = toggleTextFormatType(format, type, null);
return this.setFormat(newFormat);
}
/**
* Toggles the directionless detail value of the node. Prefer using this method over setDetail.
*
* @returns this TextNode.
*/
toggleDirectionless(): this {
const self = this.getWritable();
self.__detail ^= IS_DIRECTIONLESS;
return self;
}
/**
* Toggles the unmergeable detail value of the node. Prefer using this method over setDetail.
*
* @returns this TextNode.
*/
toggleUnmergeable(): this {
const self = this.getWritable();
self.__detail ^= IS_UNMERGEABLE;
return self;
}
/**
* Sets the mode of the node.
*
* @returns this TextNode.
*/
setMode(type: TextModeType): this {
const mode = TEXT_MODE_TO_TYPE[type];
if (this.__mode === mode) {
return this;
}
const self = this.getWritable();
self.__mode = mode;
return self;
}
/**
* Sets the text content of the node.
*
* @param text - the string to set as the text value of the node.
*
* @returns this TextNode.
*/
setTextContent(text: string): this {
if (this.__text === text) {
return this;
}
const self = this.getWritable();
self.__text = text;
return self;
}
/**
* Sets the current Lexical selection to be a RangeSelection with anchor and focus on this TextNode at the provided offsets.
*
* @param _anchorOffset - the offset at which the Selection anchor will be placed.
* @param _focusOffset - the offset at which the Selection focus will be placed.
*
* @returns the new RangeSelection.
*/
select(_anchorOffset?: number, _focusOffset?: number): RangeSelection {
errorOnReadOnly();
let anchorOffset = _anchorOffset;
let focusOffset = _focusOffset;
const selection = $getSelection();
const text = this.getTextContent();
const key = this.__key;
if (typeof text === 'string') {
const lastOffset = text.length;
if (anchorOffset === undefined) {
anchorOffset = lastOffset;
}
if (focusOffset === undefined) {
focusOffset = lastOffset;
}
} else {
anchorOffset = 0;
focusOffset = 0;
}
if (!$isRangeSelection(selection)) {
return $internalMakeRangeSelection(
key,
anchorOffset,
key,
focusOffset,
'text',
'text',
);
} else {
const compositionKey = $getCompositionKey();
if (
compositionKey === selection.anchor.key ||
compositionKey === selection.focus.key
) {
$setCompositionKey(key);
}
selection.setTextNodeRange(this, anchorOffset, this, focusOffset);
}
return selection;
}
selectStart(): RangeSelection {
return this.select(0, 0);
}
selectEnd(): RangeSelection {
const size = this.getTextContentSize();
return this.select(size, size);
}
/**
* Inserts the provided text into this TextNode at the provided offset, deleting the number of characters
* specified. Can optionally calculate a new selection after the operation is complete.
*
* @param offset - the offset at which the splice operation should begin.
* @param delCount - the number of characters to delete, starting from the offset.
* @param newText - the text to insert into the TextNode at the offset.
* @param moveSelection - optional, whether or not to move selection to the end of the inserted substring.
*
* @returns this TextNode.
*/
spliceText(
offset: number,
delCount: number,
newText: string,
moveSelection?: boolean,
): TextNode {
const writableSelf = this.getWritable();
const text = writableSelf.__text;
const handledTextLength = newText.length;
let index = offset;
if (index < 0) {
index = handledTextLength + index;
if (index < 0) {
index = 0;
}
}
const selection = $getSelection();
if (moveSelection && $isRangeSelection(selection)) {
const newOffset = offset + handledTextLength;
selection.setTextNodeRange(
writableSelf,
newOffset,
writableSelf,
newOffset,
);
}
const updatedText =
text.slice(0, index) + newText + text.slice(index + delCount);
writableSelf.__text = updatedText;
return writableSelf;
}
/**
* This method is meant to be overriden by TextNode subclasses to control the behavior of those nodes
* when a user event would cause text to be inserted before them in the editor. If true, Lexical will attempt
* to insert text into this node. If false, it will insert the text in a new sibling node.
*
* @returns true if text can be inserted before the node, false otherwise.
*/
canInsertTextBefore(): boolean {
return true;
}
/**
* This method is meant to be overriden by TextNode subclasses to control the behavior of those nodes
* when a user event would cause text to be inserted after them in the editor. If true, Lexical will attempt
* to insert text into this node. If false, it will insert the text in a new sibling node.
*
* @returns true if text can be inserted after the node, false otherwise.
*/
canInsertTextAfter(): boolean {
return true;
}
/**
* Splits this TextNode at the provided character offsets, forming new TextNodes from the substrings
* formed by the split, and inserting those new TextNodes into the editor, replacing the one that was split.
*
* @param splitOffsets - rest param of the text content character offsets at which this node should be split.
*
* @returns an Array containing the newly-created TextNodes.
*/
splitText(...splitOffsets: Array<number>): Array<TextNode> {
errorOnReadOnly();
const self = this.getLatest();
const textContent = self.getTextContent();
const key = self.__key;
const compositionKey = $getCompositionKey();
const offsetsSet = new Set(splitOffsets);
const parts = [];
const textLength = textContent.length;
let string = '';
for (let i = 0; i < textLength; i++) {
if (string !== '' && offsetsSet.has(i)) {
parts.push(string);
string = '';
}
string += textContent[i];
}
if (string !== '') {
parts.push(string);
}
const partsLength = parts.length;
if (partsLength === 0) {
return [];
} else if (parts[0] === textContent) {
return [self];
}
const firstPart = parts[0];
const parent = self.getParent();
let writableNode;
const format = self.getFormat();
const style = self.getStyle();
const detail = self.__detail;
let hasReplacedSelf = false;
if (self.isSegmented()) {
// Create a new TextNode
writableNode = $createTextNode(firstPart);
writableNode.__format = format;
writableNode.__style = style;
writableNode.__detail = detail;
hasReplacedSelf = true;
} else {
// For the first part, update the existing node
writableNode = self.getWritable();
writableNode.__text = firstPart;
}
// Handle selection
const selection = $getSelection();
// Then handle all other parts
const splitNodes: TextNode[] = [writableNode];
let textSize = firstPart.length;
for (let i = 1; i < partsLength; i++) {
const part = parts[i];
const partSize = part.length;
const sibling = $createTextNode(part).getWritable();
sibling.__format = format;
sibling.__style = style;
sibling.__detail = detail;
const siblingKey = sibling.__key;
const nextTextSize = textSize + partSize;
if ($isRangeSelection(selection)) {
const anchor = selection.anchor;
const focus = selection.focus;
if (
anchor.key === key &&
anchor.type === 'text' &&
anchor.offset > textSize &&
anchor.offset <= nextTextSize
) {
anchor.key = siblingKey;
anchor.offset -= textSize;
selection.dirty = true;
}
if (
focus.key === key &&
focus.type === 'text' &&
focus.offset > textSize &&
focus.offset <= nextTextSize
) {
focus.key = siblingKey;
focus.offset -= textSize;
selection.dirty = true;
}
}
if (compositionKey === key) {
$setCompositionKey(siblingKey);
}
textSize = nextTextSize;
splitNodes.push(sibling);
}
// Insert the nodes into the parent's children
if (parent !== null) {
internalMarkSiblingsAsDirty(this);
const writableParent = parent.getWritable();
const insertionIndex = this.getIndexWithinParent();
if (hasReplacedSelf) {
writableParent.splice(insertionIndex, 0, splitNodes);
this.remove();
} else {
writableParent.splice(insertionIndex, 1, splitNodes);
}
if ($isRangeSelection(selection)) {
$updateElementSelectionOnCreateDeleteNode(
selection,
parent,
insertionIndex,
partsLength - 1,
);
}
}
return splitNodes;
}
/**
* Merges the target TextNode into this TextNode, removing the target node.
*
* @param target - the TextNode to merge into this one.
*
* @returns this TextNode.
*/
mergeWithSibling(target: TextNode): TextNode {
const isBefore = target === this.getPreviousSibling();
if (!isBefore && target !== this.getNextSibling()) {
invariant(
false,
'mergeWithSibling: sibling must be a previous or next sibling',
);
}
const key = this.__key;
const targetKey = target.__key;
const text = this.__text;
const textLength = text.length;
const compositionKey = $getCompositionKey();
if (compositionKey === targetKey) {
$setCompositionKey(key);
}
const selection = $getSelection();
if ($isRangeSelection(selection)) {
const anchor = selection.anchor;
const focus = selection.focus;
if (anchor !== null && anchor.key === targetKey) {
adjustPointOffsetForMergedSibling(
anchor,
isBefore,
key,
target,
textLength,
);
selection.dirty = true;
}
if (focus !== null && focus.key === targetKey) {
adjustPointOffsetForMergedSibling(
focus,
isBefore,
key,
target,
textLength,
);
selection.dirty = true;
}
}
const targetText = target.__text;
const newText = isBefore ? targetText + text : text + targetText;
this.setTextContent(newText);
const writableSelf = this.getWritable();
target.remove();
return writableSelf;
}
/**
* This method is meant to be overriden by TextNode subclasses to control the behavior of those nodes
* when used with the registerLexicalTextEntity function. If you're using registerLexicalTextEntity, the
* node class that you create and replace matched text with should return true from this method.
*
* @returns true if the node is to be treated as a "text entity", false otherwise.
*/
isTextEntity(): boolean {
return false;
}
}
function convertSpanElement(domNode: HTMLSpanElement): DOMConversionOutput {
// domNode is a <span> since we matched it by nodeName
const span = domNode;
const style = span.style;
return {
forChild: applyTextFormatFromStyle(style),
node: null,
};
}
function convertBringAttentionToElement(
domNode: HTMLElement,
): DOMConversionOutput {
// domNode is a <b> since we matched it by nodeName
const b = domNode;
// Google Docs wraps all copied HTML in a <b> with font-weight normal
const hasNormalFontWeight = b.style.fontWeight === 'normal';
return {
forChild: applyTextFormatFromStyle(
b.style,
hasNormalFontWeight ? undefined : 'bold',
),
node: null,
};
}
const preParentCache = new WeakMap<Node, null | Node>();
function isNodePre(node: Node): boolean {
return (
node.nodeName === 'PRE' ||
(node.nodeType === DOM_ELEMENT_TYPE &&
(node as HTMLElement).style !== undefined &&
(node as HTMLElement).style.whiteSpace !== undefined &&
(node as HTMLElement).style.whiteSpace.startsWith('pre'))
);
}
export function findParentPreDOMNode(node: Node) {
let cached;
let parent = node.parentNode;
const visited = [node];
while (
parent !== null &&
(cached = preParentCache.get(parent)) === undefined &&
!isNodePre(parent)
) {
visited.push(parent);
parent = parent.parentNode;
}
const resultNode = cached === undefined ? parent : cached;
for (let i = 0; i < visited.length; i++) {
preParentCache.set(visited[i], resultNode);
}
return resultNode;
}
function $convertTextDOMNode(domNode: Node): DOMConversionOutput {
const domNode_ = domNode as Text;
const parentDom = domNode.parentElement;
invariant(
parentDom !== null,
'Expected parentElement of Text not to be null',
);
let textContent = domNode_.textContent || '';
// No collapse and preserve segment break for pre, pre-wrap and pre-line
if (findParentPreDOMNode(domNode_) !== null) {
const parts = textContent.split(/(\r?\n|\t)/);
const nodes: Array<LexicalNode> = [];
const length = parts.length;
for (let i = 0; i < length; i++) {
const part = parts[i];
if (part === '\n' || part === '\r\n') {
nodes.push($createLineBreakNode());
} else if (part === '\t') {
nodes.push($createTabNode());
} else if (part !== '') {
nodes.push($createTextNode(part));
}
}
return {node: nodes};
}
textContent = textContent.replace(/\r/g, '').replace(/[ \t\n]+/g, ' ');
if (textContent === '') {
return {node: null};
}
if (textContent[0] === ' ') {
// Traverse backward while in the same line. If content contains new line or tab -> pontential
// delete, other elements can borrow from this one. Deletion depends on whether it's also the
// last space (see next condition: textContent[textContent.length - 1] === ' '))
let previousText: null | Text = domNode_;
let isStartOfLine = true;
while (
previousText !== null &&
(previousText = findTextInLine(previousText, false)) !== null
) {
const previousTextContent = previousText.textContent || '';
if (previousTextContent.length > 0) {
if (/[ \t\n]$/.test(previousTextContent)) {
textContent = textContent.slice(1);
}
isStartOfLine = false;
break;
}
}
if (isStartOfLine) {
textContent = textContent.slice(1);
}
}
if (textContent[textContent.length - 1] === ' ') {
// Traverse forward while in the same line, preserve if next inline will require a space
let nextText: null | Text = domNode_;
let isEndOfLine = true;
while (
nextText !== null &&
(nextText = findTextInLine(nextText, true)) !== null
) {
const nextTextContent = (nextText.textContent || '').replace(
/^( |\t|\r?\n)+/,
'',
);
if (nextTextContent.length > 0) {
isEndOfLine = false;
break;
}
}
if (isEndOfLine) {
textContent = textContent.slice(0, textContent.length - 1);
}
}
if (textContent === '') {
return {node: null};
}
return {node: $createTextNode(textContent)};
}
function findTextInLine(text: Text, forward: boolean): null | Text {
let node: Node = text;
// eslint-disable-next-line no-constant-condition
while (true) {
let sibling: null | Node;
while (
(sibling = forward ? node.nextSibling : node.previousSibling) === null
) {
const parentElement = node.parentElement;
if (parentElement === null) {
return null;
}
node = parentElement;
}
node = sibling;
if (node.nodeType === DOM_ELEMENT_TYPE) {
const display = (node as HTMLElement).style.display;
if (
(display === '' && !isInlineDomNode(node)) ||
(display !== '' && !display.startsWith('inline'))
) {
return null;
}
}
let descendant: null | Node = node;
while ((descendant = forward ? node.firstChild : node.lastChild) !== null) {
node = descendant;
}
if (node.nodeType === DOM_TEXT_TYPE) {
return node as Text;
} else if (node.nodeName === 'BR') {
return null;
}
}
}
const nodeNameToTextFormat: Record<string, TextFormatType> = {
code: 'code',
em: 'italic',
i: 'italic',
s: 'strikethrough',
strong: 'bold',
sub: 'subscript',
sup: 'superscript',
u: 'underline',
};
function convertTextFormatElement(domNode: HTMLElement): DOMConversionOutput {
const format = nodeNameToTextFormat[domNode.nodeName.toLowerCase()];
if (format === undefined) {
return {node: null};
}
return {
forChild: applyTextFormatFromStyle(domNode.style, format),
node: null,
};
}
export function $createTextNode(text = ''): TextNode {
return $applyNodeReplacement(new TextNode(text));
}
export function $isTextNode(
node: LexicalNode | null | undefined,
): node is TextNode {
return node instanceof TextNode;
}
function applyTextFormatFromStyle(
style: CSSStyleDeclaration,
shouldApply?: TextFormatType,
) {
const fontWeight = style.fontWeight;
const textDecoration = style.textDecoration.split(' ');
// Google Docs uses span tags + font-weight for bold text
const hasBoldFontWeight = fontWeight === '700' || fontWeight === 'bold';
// Google Docs uses span tags + text-decoration: line-through for strikethrough text
const hasLinethroughTextDecoration = textDecoration.includes('line-through');
// Google Docs uses span tags + font-style for italic text
const hasItalicFontStyle = style.fontStyle === 'italic';
// Google Docs uses span tags + text-decoration: underline for underline text
const hasUnderlineTextDecoration = textDecoration.includes('underline');
// Google Docs uses span tags + vertical-align to specify subscript and superscript
const verticalAlign = style.verticalAlign;
// Styles to copy to node
const color = style.color;
const backgroundColor = style.backgroundColor;
return (lexicalNode: LexicalNode) => {
if (!$isTextNode(lexicalNode)) {
return lexicalNode;
}
if (hasBoldFontWeight && !lexicalNode.hasFormat('bold')) {
lexicalNode.toggleFormat('bold');
}
if (
hasLinethroughTextDecoration &&
!lexicalNode.hasFormat('strikethrough')
) {
lexicalNode.toggleFormat('strikethrough');
}
if (hasItalicFontStyle && !lexicalNode.hasFormat('italic')) {
lexicalNode.toggleFormat('italic');
}
if (hasUnderlineTextDecoration && !lexicalNode.hasFormat('underline')) {
lexicalNode.toggleFormat('underline');
}
if (verticalAlign === 'sub' && !lexicalNode.hasFormat('subscript')) {
lexicalNode.toggleFormat('subscript');
}
if (verticalAlign === 'super' && !lexicalNode.hasFormat('superscript')) {
lexicalNode.toggleFormat('superscript');
}
// Apply styles
let style = lexicalNode.getStyle();
if (color) {
style += `color: ${color};`;
}
if (backgroundColor && backgroundColor !== 'transparent') {
style += `background-color: ${backgroundColor};`;
}
if (style) {
lexicalNode.setStyle(style);
}
if (shouldApply && !lexicalNode.hasFormat(shouldApply)) {
lexicalNode.toggleFormat(shouldApply);
}
return lexicalNode;
};
}