UNPKG

@lexical/table

Version:

This package provides the Table feature for Lexical.

309 lines (295 loc) 9.9 kB
/** * 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 { $propagateTextAlignToBlockChildren, type ChildSchema, contextValue, defineImportRule, type ImportContextPairOrUpdater, ImportTextFormat, ImportTextStyle, sel, } from '@lexical/html'; import {$descendantsMatching} from '@lexical/utils'; import { $createParagraphNode, $isInlineElementOrDecoratorNode, $isLineBreakNode, $isTextNode, 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 {$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 `<th>` / `<td>`'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 inside a `<td>`/`<th>` into their own * `ParagraphNode`s, leaving any pre-existing `ParagraphNode` children * (real `<p>` elements, or the `ParagraphNode` stand-ins that * {@link TransparentBlockRule} lowers `<div>`/`<section>`/… to) in * place as their own paragraph siblings. Mirrors the legacy `<td>` * importer where a bare `<td>789<div>000</div></td>` ended up as two * paragraphs (`<p>789</p><p>000</p>`), and also drops a sole leading * `<br>` that the legacy `removeSingleLineBreakNode` cleanup would have * removed. */ 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 { // Block children (paragraphs, nested tables, decorator blocks, …) // start their own sibling — any inline run that was being // accumulated into `paragraph` is closed off here. flushSingleLineBreak(); paragraph = null; result.push(child); } } flushSingleLineBreak(); if (result.length === 0) { result.push($createParagraphNode()); } return result; } const TableRule = /* @__PURE__ */ 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<HTMLTableColElement>( ':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 = /* @__PURE__ */ 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 = /* @__PURE__ */ 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<Record<string, string>> = color ? {...inheritedStyle, color} : inheritedStyle; const branchContext: ImportContextPairOrUpdater[] = []; if (cellFormat !== inheritedFormat) { branchContext.push(contextValue(ImportTextFormat, cellFormat)); } if (cellStyle !== inheritedStyle) { branchContext.push(contextValue(ImportTextStyle, cellStyle)); } // {@link $packageCellChildren} keeps each `ParagraphNode` child // (from a real `<p>`, or from {@link TransparentBlockRule}'s // lowering of `<div>`/`<section>`/…) as its own sibling paragraph // — matching legacy `<td>`'s `<td>789<div>000</div></td>` → // `<p>789</p><p>000</p>` shape. const packaged = $packageCellChildren( ctx.$importChildren(el, {context: branchContext}), ); // Only `<td>` propagates `text-align` onto its block children — // mirroring legacy `wrapContinuousInlines`, which runs only for // `isBlockDomNode` elements. `<th>` is intentionally absent from // the block-tag set (see `BLOCK_TAG_RE` in `LexicalUtils.ts`), so a // `<th style="text-align: start">` wrapping a bare `<p>` leaves the // paragraph format empty. const children = isHeader ? packaged : $propagateTextAlignToBlockChildren(packaged, el); return [cell.splice(0, 0, children)]; }, 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}. * * Registered by {@link TableExtension} itself (together with * `CoreImportExtension`), so any editor that uses the table extension can * import these tags through the `DOMImportExtension` pipeline without * further configuration. * * @experimental */ export const TableImportRules = [TableRule, TableRowRule, TableCellRule];