/** * 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 {ChildSchema, ImportContextPairOrUpdater} from '@lexical/html'; import { contextValue, CoreImportExtension, defineImportRule, DOMImportExtension, ImportTextFormat, ImportTextStyle, sel, } from '@lexical/html'; import {$descendantsMatching} from '@lexical/utils'; import { $createParagraphNode, $isInlineElementOrDecoratorNode, $isLineBreakNode, $isTextNode, configExtension, defineExtension, IS_BOLD, IS_ITALIC, IS_STRIKETHROUGH, IS_UNDERLINE, isHTMLTableRowElement, type LexicalNode, type ParagraphNode, } from 'lexical'; import {PIXEL_VALUE_REG_EXP} from './constants'; import { $createTableCellNode, $isTableCellNode, TableCellHeaderStates, } from './LexicalTableCellNode'; import {TableExtension} from './LexicalTableExtension'; import {$createTableNode} from './LexicalTableNode'; import {$createTableRowNode, $isTableRowNode} from './LexicalTableRowNode'; function isValidVerticalAlign( verticalAlign?: null | string, ): verticalAlign is 'middle' | 'bottom' { return verticalAlign === 'middle' || verticalAlign === 'bottom'; } /** * Bitmask of TextNode format bits implied by a `` / ``'s * inline styles (`font-weight: bold`, `font-style: italic`, and * `underline` / `line-through` in `text-decoration`). */ function cellTextFormatMask(style: CSSStyleDeclaration): number { let mask = 0; const fontWeight = style.fontWeight; if (fontWeight === '700' || fontWeight === 'bold') { mask |= IS_BOLD; } if (style.fontStyle === 'italic') { mask |= IS_ITALIC; } const decoration = (style.textDecoration || '').split(' '); if (decoration.includes('underline')) { mask |= IS_UNDERLINE; } if (decoration.includes('line-through')) { mask |= IS_STRIKETHROUGH; } return mask; } /** * Coalesce inline + line-break runs in a cell into paragraphs. Mirrors * the legacy `removeSingleLineBreakNode` cleanup so a sole `
` doesn't * survive as a paragraph's only child. */ function $packageCellChildren(children: LexicalNode[]): LexicalNode[] { const result: LexicalNode[] = []; let paragraph: ParagraphNode | null = null; const flushSingleLineBreak = () => { if (paragraph !== null) { const first = paragraph.getFirstChild(); if ($isLineBreakNode(first) && paragraph.getChildrenSize() === 1) { first.remove(); } } }; for (const child of children) { if ( $isInlineElementOrDecoratorNode(child) || $isTextNode(child) || $isLineBreakNode(child) ) { if (paragraph !== null) { paragraph.append(child); } else { paragraph = $createParagraphNode().append(child); result.push(paragraph); } } else { flushSingleLineBreak(); result.push(child); paragraph = null; } } flushSingleLineBreak(); if (result.length === 0) { result.push($createParagraphNode()); } return result; } const TableRule = defineImportRule({ $import: (ctx, el) => { const node = $createTableNode(); if (el.hasAttribute('data-lexical-row-striping')) { node.setRowStriping(true); } if (el.hasAttribute('data-lexical-frozen-column')) { node.setFrozenColumns(1); } if (el.hasAttribute('data-lexical-frozen-row')) { node.setFrozenRows(1); } const colGroup = el.querySelector(':scope > colgroup'); if (colGroup) { let columns: number[] | undefined = []; for (const col of colGroup.querySelectorAll( ':scope > col', )) { let width = col.style.width || ''; if (!PIXEL_VALUE_REG_EXP.test(width)) { width = col.getAttribute('width') || ''; if (!/^\d+$/.test(width)) { columns = undefined; break; } } columns.push(parseFloat(width)); } if (columns) { node.setColWidths(columns); } } return [ node.splice( 0, 0, $descendantsMatching(ctx.$importChildren(el), $isTableRowNode), ), ]; }, match: sel.tag('table'), name: '@lexical/table/table', }); const TableRowRule = defineImportRule({ $import: (ctx, el) => { const height = PIXEL_VALUE_REG_EXP.test(el.style.height) ? parseFloat(el.style.height) : undefined; return [ $createTableRowNode(height).splice( 0, 0, $descendantsMatching(ctx.$importChildren(el), $isTableCellNode), ), ]; }, match: sel.tag('tr'), name: '@lexical/table/tr', }); const TableCellRule = defineImportRule({ $import: (ctx, el) => { const isHeader = el.nodeName === 'TH'; const width = PIXEL_VALUE_REG_EXP.test(el.style.width) ? parseFloat(el.style.width) : undefined; let headerState = TableCellHeaderStates.NO_STATUS; if (isHeader) { const scope = el.getAttribute('scope'); if (scope === 'col') { headerState = TableCellHeaderStates.COLUMN; } else if (scope === 'row') { headerState = TableCellHeaderStates.ROW; } else { const parentRow = el.parentElement; const isInHeaderRow = isHTMLTableRowElement(parentRow) && ((parentRow.parentElement && parentRow.parentElement.nodeName === 'THEAD') || parentRow.rowIndex === 0); const isFirstColumn = el.cellIndex === 0; if (isInHeaderRow) { headerState |= TableCellHeaderStates.ROW; } if (isFirstColumn) { headerState |= TableCellHeaderStates.COLUMN; } if (headerState === TableCellHeaderStates.NO_STATUS) { headerState = TableCellHeaderStates.ROW; } } } const cell = $createTableCellNode(headerState, el.colSpan, width); cell.__rowSpan = el.rowSpan; const backgroundColor = el.style.backgroundColor; if (backgroundColor !== '') { cell.__backgroundColor = backgroundColor; } const verticalAlign = el.style.verticalAlign; if (isValidVerticalAlign(verticalAlign)) { cell.__verticalAlign = verticalAlign; } // Propagate the cell's bold/italic/underline/strikethrough as // format bits, and the cell's `color` as a parsed-style record. // The core `#text` rule reads both at construction time and // applies them to each TextNode — no post-walk needed. const inheritedFormat = ctx.get(ImportTextFormat); const cellFormat = inheritedFormat | cellTextFormatMask(el.style); const inheritedStyle = ctx.get(ImportTextStyle); const color = el.style.color; const cellStyle: Readonly> = color ? {...inheritedStyle, color} : inheritedStyle; const branchContext: ImportContextPairOrUpdater[] = []; if (cellFormat !== inheritedFormat) { branchContext.push(contextValue(ImportTextFormat, cellFormat)); } if (cellStyle !== inheritedStyle) { branchContext.push(contextValue(ImportTextStyle, cellStyle)); } return [ cell.splice( 0, 0, $packageCellChildren(ctx.$importChildren(el, {context: branchContext})), ), ]; }, match: sel.tag('td', 'th'), name: '@lexical/table/cell', }); /** * A {@link ChildSchema} that enforces TableNode invariants: only * `TableRowNode` children are accepted; orphan `TableCellNode` runs are * wrapped in a synthesized row. * * @experimental */ export const TableSchema: ChildSchema = { $accepts: $isTableRowNode, $packageRun: run => run.every($isTableCellNode) ? [$createTableRowNode().splice(0, 0, run)] : [], name: 'TableSchema', }; /** * A {@link ChildSchema} that enforces TableRowNode invariants: only * `TableCellNode` children are accepted; non-cell children are dropped * (the legacy converter does the same via `$descendantsMatching`). * * @experimental */ export const TableRowSchema: ChildSchema = { $accepts: $isTableCellNode, name: 'TableRowSchema', }; /** * Import rules for {@link TableNode}, {@link TableRowNode}, and * {@link TableCellNode}. * * @experimental */ export const TableImportRules = [TableRule, TableRowRule, TableCellRule]; /** * Bundles {@link TableImportRules} (plus {@link CoreImportExtension}) * into a single dependency. * * @experimental */ export const TableImportExtension = defineExtension({ dependencies: [ CoreImportExtension, TableExtension, configExtension(DOMImportExtension, {rules: TableImportRules}), ], name: '@lexical/table/Import', });