/** * 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 { $createHorizontalRuleNode, HorizontalRuleNode, } from '@lexical/extension'; import { $createLineBreakNode, $createParagraphNode, $createTextNode, $generateNodesFromRawText, $getEditor, $isTextNode, $setDirectionFromDOM, $setFormatFromDOM, type ElementFormatType, IS_BOLD, IS_CODE, IS_HIGHLIGHT, IS_ITALIC, IS_STRIKETHROUGH, IS_SUBSCRIPT, IS_SUPERSCRIPT, IS_UNDERLINE, isBlockDomNode, isDOMTextNode, isLastChildInBlockNode, isOnlyChildInBlockNode, type LexicalNode, setNodeIndentFromDOM, TEXT_TYPE_TO_FORMAT, } from 'lexical'; import {contextValue} from '../ContextRecord'; import {defineImportRule} from './defineImportRule'; import { ImportTextFormat, ImportTextStyle, ImportWhitespaceConfig, type WhitespaceImportConfig, } from './ImportContext'; import {$propagateTextAlignToBlockChildren, BlockSchema} from './schemas'; import {selBase} from './sel'; const sel = selBase; const ALIGNMENT_VALUES: ReadonlySet = new Set([ 'center', 'end', 'justify', 'left', 'right', 'start', ]); /** * True if `value` is a non-empty {@link ElementFormatType} (matches one of * the supported `text-align` / legacy `align`-attribute values). * * @internal */ export function isAlignmentValue(value: string): value is ElementFormatType { return ALIGNMENT_VALUES.has(value); } /** * A pair of bitmasks describing which {@link TextFormatType} bits to set * and which to clear when descending into an element. The clear pass * matters for cases the legacy OR-merge mishandled, e.g. `` clearing an inherited bold, or `` / * `` clearing each other. */ interface FormatOverride { readonly set: number; readonly clear: number; } /** * The small subset of inline-style properties that affect text formatting * during import. Modeled as a plain object so tag-implicit defaults and * the element's own inline `style` can be merged with `{...defaults, * ...override-if-set}` semantics rather than relying on CSSStyleDeclaration. */ interface FormatStyle { fontWeight?: string; fontStyle?: string; textDecoration?: string; verticalAlign?: string; textTransform?: string; } /** * Default style implied by each inline format tag. ``/`` set * font-weight, `` sets vertical-align, etc. Any of these can be * overridden by the element's own inline `style` (so `` ends up with `fontWeight: 'normal'` in * the effective style). */ const TAG_DEFAULT_STYLE: Record = { B: {fontWeight: 'bold'}, EM: {fontStyle: 'italic'}, I: {fontStyle: 'italic'}, S: {textDecoration: 'line-through'}, STRONG: {fontWeight: 'bold'}, SUB: {verticalAlign: 'sub'}, SUP: {verticalAlign: 'super'}, U: {textDecoration: 'underline'}, }; /** * Tags whose effect on TextFormat has no CSS analog (so the style-merge * path can't reach them). Applied as a pure "set" override. */ const TAG_ONLY_SET: Record = { CODE: IS_CODE, MARK: IS_HIGHLIGHT, }; function readElementFormatStyle(el: HTMLElement): FormatStyle { return { fontStyle: el.style.fontStyle, fontWeight: el.style.fontWeight, textDecoration: el.style.textDecoration, textTransform: el.style.textTransform, verticalAlign: el.style.verticalAlign, }; } function mergeStyles( defaults: FormatStyle, override: FormatStyle, ): FormatStyle { return { fontStyle: override.fontStyle || defaults.fontStyle, fontWeight: override.fontWeight || defaults.fontWeight, textDecoration: override.textDecoration || defaults.textDecoration, textTransform: override.textTransform || defaults.textTransform, verticalAlign: override.verticalAlign || defaults.verticalAlign, }; } /** * The CSS property names {@link styleFormatOverride} reads — these are * "owned" by {@link ImportTextFormat} (the bit mask). When the * {@link ImportTextStyle} record is materialized onto a TextNode's * inline style by {@link styleObjectToCSS}, these are skipped so the * bit-mask side is the single source of truth and the same property * doesn't end up in both places (where the inline-style version would * shadow the format's themed CSS). */ const FORMAT_BIT_STYLE_PROPS: ReadonlySet = new Set([ 'font-weight', 'font-style', 'text-decoration', 'text-transform', 'vertical-align', ]); /** * Translate a {@link FormatStyle} into a {@link FormatOverride}. Explicit * "non-decorating" values (`font-weight: normal`, `text-decoration: none`, * `vertical-align: baseline`) produce `clear` bits, so an inner element * can remove a format inherited from its ancestors. */ function styleFormatOverride(style: FormatStyle): FormatOverride { let set = 0; let clear = 0; const {fontWeight, fontStyle, textDecoration, textTransform, verticalAlign} = style; if (fontWeight === '700' || fontWeight === 'bold') { set |= IS_BOLD; } else if (fontWeight === 'normal' || fontWeight === '400') { clear |= IS_BOLD; } if (fontStyle === 'italic') { set |= IS_ITALIC; } else if (fontStyle === 'normal') { clear |= IS_ITALIC; } if (textDecoration) { const parts = textDecoration.split(' '); if (parts.includes('underline')) { set |= IS_UNDERLINE; } if (parts.includes('line-through')) { set |= IS_STRIKETHROUGH; } if (parts.includes('none')) { clear |= IS_UNDERLINE | IS_STRIKETHROUGH; } } // TextNode.exportDOM writes exactly one of these three for the // capitalization formats, and they are mutually exclusive (#8915). if (textTransform === 'lowercase') { set |= TEXT_TYPE_TO_FORMAT.lowercase; clear |= TEXT_TYPE_TO_FORMAT.uppercase | TEXT_TYPE_TO_FORMAT.capitalize; } else if (textTransform === 'uppercase') { set |= TEXT_TYPE_TO_FORMAT.uppercase; clear |= TEXT_TYPE_TO_FORMAT.lowercase | TEXT_TYPE_TO_FORMAT.capitalize; } else if (textTransform === 'capitalize') { set |= TEXT_TYPE_TO_FORMAT.capitalize; clear |= TEXT_TYPE_TO_FORMAT.lowercase | TEXT_TYPE_TO_FORMAT.uppercase; } else if (textTransform === 'none') { clear |= TEXT_TYPE_TO_FORMAT.lowercase | TEXT_TYPE_TO_FORMAT.uppercase | TEXT_TYPE_TO_FORMAT.capitalize; } if (verticalAlign === 'sub') { set |= IS_SUBSCRIPT; clear |= IS_SUPERSCRIPT; } else if (verticalAlign === 'super') { set |= IS_SUPERSCRIPT; clear |= IS_SUBSCRIPT; } else if (verticalAlign === 'baseline') { clear |= IS_SUBSCRIPT | IS_SUPERSCRIPT; } return {clear, set}; } function applyFormatOverride(format: number, ov: FormatOverride): number { return (format & ~ov.clear) | ov.set; } /** * Unified rule for inline-format-bearing tags and ``. The element's * effective style is its tag's {@link TAG_DEFAULT_STYLE} merged with its * inline `style` (element's own style wins for any property it sets), and * the resulting style is translated into a {@link FormatOverride}. Tags * with no CSS analog (``, ``) contribute their bit as a pure * `set` override. * * This shape lets: * - `` clear an inherited IS_BOLD. * - `x` resolve to IS_SUPERSCRIPT only (sub/sup * mutex via the vertical-align clear logic). * - `` strip inherited underline / * line-through. */ const InlineFormatRule = defineImportRule({ $import: (ctx, el) => { const inherited = ctx.get(ImportTextFormat); const tagDefault = TAG_DEFAULT_STYLE[el.nodeName]; const elStyle = readElementFormatStyle(el); const effective = tagDefault ? mergeStyles(tagDefault, elStyle) : elStyle; let merged = applyFormatOverride(inherited, styleFormatOverride(effective)); const tagOnly = TAG_ONLY_SET[el.nodeName]; if (tagOnly) { merged |= tagOnly; } if (merged === inherited) { return ctx.$importChildren(el); } return ctx.$importChildren(el, { context: [contextValue(ImportTextFormat, merged)], }); }, match: sel.tag( 'b', 'strong', 'em', 'i', 'code', 'mark', 's', 'sub', 'sup', 'u', 'span', ), name: '@lexical/html/inline-format', }); /** * Walk up the DOM ancestor chain to determine whether `node` is inside an * element whose whitespace should be preserved, per the supplied * {@link WhitespaceImportConfig.preservesWhitespace} predicate. Pure * ancestor walk, no caching. */ function isInsidePreserveWhitespace( node: Node, wsConfig: WhitespaceImportConfig, ): boolean { let current: Node | null = node.parentNode; while (current !== null) { if (wsConfig.preservesWhitespace(current)) { return true; } current = current.parentNode; } return false; } function findAdjacentTextOnLine( text: Text, forward: boolean, wsConfig: WhitespaceImportConfig, ): Text | null { let node: Node = text; while (true) { let sibling: Node | null = null; while ( (sibling = forward ? node.nextSibling : node.previousSibling) === null ) { const parent: Node | null = node.parentNode; if (parent === null) { return null; } node = parent; } node = sibling; if (!wsConfig.isInline(node)) { return null; } let descendant: Node | null = node; while ((descendant = forward ? node.firstChild : node.lastChild) !== null) { node = descendant; } if (isDOMTextNode(node)) { return node; } if (node.nodeName === 'BR') { return null; } } } function collapseWhitespace( textNode: Text, wsConfig: WhitespaceImportConfig, ): string { let textContent = (textNode.textContent || '') .replace(/\r/g, '') .replace(/[ \t\n]+/g, ' '); if (textContent.length === 0) { return ''; } if (textContent[0] === ' ') { let neighbor: Text | null = textNode; let isStartOfLine = true; while ( neighbor !== null && (neighbor = findAdjacentTextOnLine(neighbor, false, wsConfig)) !== null ) { const neighborContent = neighbor.textContent || ''; if (neighborContent.length > 0) { if (/[ \t\n]$/.test(neighborContent)) { textContent = textContent.slice(1); } isStartOfLine = false; break; } } if (isStartOfLine) { textContent = textContent.slice(1); } } if (textContent.length > 0 && textContent[textContent.length - 1] === ' ') { let neighbor: Text | null = textNode; let isEndOfLine = true; while ( neighbor !== null && (neighbor = findAdjacentTextOnLine(neighbor, true, wsConfig)) !== null ) { const neighborContent = (neighbor.textContent || '').replace( /^( |\t|\r?\n)+/, '', ); if (neighborContent.length > 0) { isEndOfLine = false; break; } } if (isEndOfLine) { textContent = textContent.slice(0, -1); } } return textContent; } function $applyFormat(node: LexicalNode, format: number): LexicalNode { return format !== 0 && $isTextNode(node) ? node.setFormat(format) : node; } /** * Inverse of {@link getStyleObjectFromCSS}: serialize a parsed style * record back into a CSS declaration string suitable for * `TextNode.setStyle`. Returns the empty string for an empty record. */ function styleObjectToCSS(style: Readonly>): string { let css = ''; for (const prop in style) { if (FORMAT_BIT_STYLE_PROPS.has(prop)) { // Owned by ImportTextFormat (bit mask) — skip so the format-bit // CSS is the single source of truth on the rendered TextNode. continue; } css += `${prop}: ${style[prop]}; `; } return css.trimEnd(); } function $applyTextStyle( node: LexicalNode, style: Readonly>, ): LexicalNode { if ($isTextNode(node)) { const css = styleObjectToCSS(style); if (css !== '') { node.setStyle(css); } } return node; } /** * `#text` rule. Inside a `
` ancestor, preserve whitespace and split
 * on `\n` and `\t` into `LineBreakNode`/`TabNode` siblings. Otherwise
 * collapse whitespace using the same neighbor-aware rules as the legacy
 * `$convertTextDOMNode`.
 */
const TextRule = defineImportRule({
  $import: (ctx, el) => {
    const format = ctx.get(ImportTextFormat);
    const style = ctx.get(ImportTextStyle);
    const wsConfig = ctx.get(ImportWhitespaceConfig);
    if (isInsidePreserveWhitespace(el, wsConfig)) {
      const out = $generateNodesFromRawText(el.textContent || '');
      for (const node of out) {
        $applyFormat(node, format);
        $applyTextStyle(node, style);
      }
      return out;
    }
    const collapsed = collapseWhitespace(el, wsConfig);
    if (collapsed === '') {
      return [];
    }
    const text = $createTextNode(collapsed);
    $applyFormat(text, format);
    $applyTextStyle(text, style);
    return [text];
  },
  match: sel.text(),
  name: '@lexical/html/#text',
});

/**
 * Drop `