From 6a1cf185e3e9e17cc767fe3d28b14b705a196a92 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Sun, 1 Dec 2024 09:01:55 -0800 Subject: [PATCH] [lexical-table] Bug Fix: TableNode exportDOM fixes for partial table selection (#6889) --- .../lexical-table/src/LexicalTableCellNode.ts | 7 +- .../lexical-table/src/LexicalTableNode.ts | 75 +++++++++- .../lexical-table/src/LexicalTableRowNode.ts | 10 +- .../__tests__/unit/LexicalTableNode.test.tsx | 130 ++++++++++++++++++ 4 files changed, 214 insertions(+), 8 deletions(-) diff --git a/packages/lexical-table/src/LexicalTableCellNode.ts b/packages/lexical-table/src/LexicalTableCellNode.ts index c43e7fe1c9e..2b00b8dfb6e 100644 --- a/packages/lexical-table/src/LexicalTableCellNode.ts +++ b/packages/lexical-table/src/LexicalTableCellNode.ts @@ -26,6 +26,7 @@ import { $isLineBreakNode, $isTextNode, ElementNode, + isHTMLElement, } from 'lexical'; import {COLUMN_WIDTH, PIXEL_VALUE_REG_EXP} from './constants'; @@ -150,8 +151,12 @@ export class TableCellNode extends ElementNode { exportDOM(editor: LexicalEditor): DOMExportOutput { const output = super.exportDOM(editor); - if (output.element) { + if (output.element && isHTMLElement(output.element)) { const element = output.element as HTMLTableCellElement; + element.setAttribute( + 'data-temporary-table-cell-lexical-key', + this.getKey(), + ); element.style.border = '1px solid black'; if (this.__colSpan > 1) { element.colSpan = this.__colSpan; diff --git a/packages/lexical-table/src/LexicalTableNode.ts b/packages/lexical-table/src/LexicalTableNode.ts index ea14f9e5a9b..4a4a2c970fa 100644 --- a/packages/lexical-table/src/LexicalTableNode.ts +++ b/packages/lexical-table/src/LexicalTableNode.ts @@ -6,6 +6,8 @@ * */ +import type {TableRowNode} from './LexicalTableRowNode'; + import { addClassNamesToElement, isHTMLElement, @@ -15,6 +17,7 @@ import { $applyNodeReplacement, $getEditor, $getNearestNodeFromDOMNode, + BaseSelection, DOMConversionMap, DOMConversionOutput, DOMExportOutput, @@ -31,13 +34,13 @@ import { import invariant from 'shared/invariant'; import {PIXEL_VALUE_REG_EXP} from './constants'; -import {$isTableCellNode, TableCellNode} from './LexicalTableCellNode'; +import {$isTableCellNode, type TableCellNode} from './LexicalTableCellNode'; import {TableDOMCell, TableDOMTable} from './LexicalTableObserver'; -import {TableRowNode} from './LexicalTableRowNode'; import { $getNearestTableCellInTableFromDOMNode, getTable, } from './LexicalTableSelectionHelpers'; +import {$computeTableMapSkipCellCheck} from './LexicalTableUtils'; export type SerializedTableNode = Spread< { @@ -170,6 +173,14 @@ export class TableNode extends ElementNode { }; } + extractWithChild( + child: LexicalNode, + selection: BaseSelection | null, + destination: 'clone' | 'html', + ): boolean { + return destination === 'html'; + } + getDOMSlot(element: HTMLElement): ElementDOMSlot { const tableElement = (element.nodeName !== 'TABLE' && element.querySelector('table')) || @@ -227,11 +238,12 @@ export class TableNode extends ElementNode { } exportDOM(editor: LexicalEditor): DOMExportOutput { - const {element, after} = super.exportDOM(editor); + const superExport = super.exportDOM(editor); + const {element} = superExport; return { after: (tableElement) => { - if (after) { - tableElement = after(tableElement); + if (superExport.after) { + tableElement = superExport.after(tableElement); } if ( tableElement && @@ -243,11 +255,62 @@ export class TableNode extends ElementNode { if (!tableElement || !isHTMLElement(tableElement)) { return null; } + + // Scan the table map to build a map of table cell key to the columns it needs + const [tableMap] = $computeTableMapSkipCellCheck(this, null, null); + const cellValues = new Map< + NodeKey, + {startColumn: number; colSpan: number} + >(); + for (const mapRow of tableMap) { + for (const mapValue of mapRow) { + const key = mapValue.cell.getKey(); + if (!cellValues.has(key)) { + cellValues.set(key, { + colSpan: mapValue.cell.getColSpan(), + startColumn: mapValue.startColumn, + }); + } + } + } + + // scan the DOM to find the table cell keys that were used and mark those columns + const knownColumns = new Set(); + for (const cellDOM of tableElement.querySelectorAll( + ':scope > tr > [data-temporary-table-cell-lexical-key]', + )) { + const key = cellDOM.getAttribute( + 'data-temporary-table-cell-lexical-key', + ); + if (key) { + const cellSpan = cellValues.get(key); + cellDOM.removeAttribute('data-temporary-table-cell-lexical-key'); + if (cellSpan) { + cellValues.delete(key); + for (let i = 0; i < cellSpan.colSpan; i++) { + knownColumns.add(i + cellSpan.startColumn); + } + } + } + } + + // Compute the colgroup and columns in the export + const colGroup = tableElement.querySelector(':scope > colgroup'); + if (colGroup) { + // Only include the for rows that are in the output + const cols = Array.from( + tableElement.querySelectorAll(':scope > colgroup > col'), + ).filter((dom, i) => knownColumns.has(i)); + colGroup.replaceChildren(...cols); + } + // Wrap direct descendant rows in a tbody for export const rows = tableElement.querySelectorAll(':scope > tr'); if (rows.length > 0) { const tBody = document.createElement('tbody'); - tBody.append(...rows); + for (const row of rows) { + tBody.appendChild(row); + } tableElement.append(tBody); } return tableElement; diff --git a/packages/lexical-table/src/LexicalTableRowNode.ts b/packages/lexical-table/src/LexicalTableRowNode.ts index eddea69a27e..fd8bcb8fa0a 100644 --- a/packages/lexical-table/src/LexicalTableRowNode.ts +++ b/packages/lexical-table/src/LexicalTableRowNode.ts @@ -6,7 +6,7 @@ * */ -import type {Spread} from 'lexical'; +import type {BaseSelection, Spread} from 'lexical'; import {addClassNamesToElement} from '@lexical/utils'; import { @@ -81,6 +81,14 @@ export class TableRowNode extends ElementNode { return element; } + extractWithChild( + child: LexicalNode, + selection: BaseSelection | null, + destination: 'clone' | 'html', + ): boolean { + return destination === 'html'; + } + isShadowRoot(): boolean { return true; } diff --git a/packages/lexical-table/src/__tests__/unit/LexicalTableNode.test.tsx b/packages/lexical-table/src/__tests__/unit/LexicalTableNode.test.tsx index df23bdcf843..96ca3c7e426 100644 --- a/packages/lexical-table/src/__tests__/unit/LexicalTableNode.test.tsx +++ b/packages/lexical-table/src/__tests__/unit/LexicalTableNode.test.tsx @@ -7,13 +7,16 @@ */ import {$insertDataTransferForRichText} from '@lexical/clipboard'; +import {$generateHtmlFromNodes} from '@lexical/html'; import {TablePlugin} from '@lexical/react/LexicalTablePlugin'; import { $createTableNode, $createTableNodeWithDimensions, $createTableSelection, $insertTableColumn__EXPERIMENTAL, + $isTableCellNode, } from '@lexical/table'; +import {$dfs} from '@lexical/utils'; import { $createParagraphNode, $createTextNode, @@ -136,6 +139,133 @@ describe('LexicalTableNode tests', () => { }); }); + test('TableNode.exportDOM() with range selection', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const tableNode = $createTableNodeWithDimensions( + 2, + 2, + ).setColWidths([100, 200]); + tableNode + .getAllTextNodes() + .forEach((node, i) => node.setTextContent(String(i))); + $getRoot().clear().append(tableNode); + expectHtmlToBeEqual( + $generateHtmlFromNodes(editor, $getRoot().select(0)), + html` + + + + + + + + + + + + + + + +
+

0

+
+

1

+
+

2

+
+

3

+
+ `, + ); + }); + }); + + test('TableNode.exportDOM() with partial table selection', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const tableNode = $createTableNodeWithDimensions( + 2, + 2, + ).setColWidths([100, 200]); + tableNode + .getAllTextNodes() + .forEach((node, i) => node.setTextContent(String(i))); + $getRoot().append(tableNode); + const tableSelection = $createTableSelection(); + tableSelection.tableKey = tableNode.getKey(); + const cells = $dfs(tableNode).flatMap(({node}) => + $isTableCellNode(node) ? [node] : [], + ); + // second column + tableSelection.anchor.set(cells[1].getKey(), 0, 'element'); + tableSelection.focus.set(cells[3].getKey(), 0, 'element'); + expectHtmlToBeEqual( + $generateHtmlFromNodes(editor, tableSelection), + html` + + + + + + + + + + +
+

1

+
+

3

+
+ `, + ); + }); + }); + test('Copy table from an external source', async () => { const {editor} = testEnv;