diff --git a/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs b/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs index 105ed967e1e..af4f7c94832 100644 --- a/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs @@ -41,6 +41,7 @@ import { selectCellsFromTableCords, selectFromAdditionalStylesDropdown, selectFromAlignDropdown, + selectorBoundingBox, setBackgroundColor, test, toggleColumnHeader, @@ -1521,6 +1522,243 @@ test.describe.parallel('Tables', () => { ); }); + test('Resize merged cells width (1)', async ({ + page, + isPlainText, + isCollab, + }) => { + await initialize({isCollab, page}); + test.skip(isPlainText); + if (IS_COLLAB) { + // The contextual menu positioning needs fixing (it's hardcoded to show on the right side) + page.setViewportSize({height: 1000, width: 3000}); + } + + await focusEditor(page); + + await insertTable(page, 3, 3); + await click(page, '.PlaygroundEditorTheme__tableCell'); + await selectCellsFromTableCords( + page, + {x: 0, y: 0}, + {x: 1, y: 1}, + true, + false, + ); + await mergeTableCells(page); + await click(page, 'td:nth-child(3) > .PlaygroundEditorTheme__paragraph'); + const resizerBoundingBox = await selectorBoundingBox( + page, + '.TableCellResizer__resizer:first-child', + ); + const x = resizerBoundingBox.x + resizerBoundingBox.width / 2; + const y = resizerBoundingBox.y + resizerBoundingBox.height / 2; + await page.mouse.move(x, y); + await page.mouse.down(); + await page.mouse.move(x + 50, y); + await page.mouse.up(); + + await assertHTML( + page, + html` +


+ + + + + + + + + + + + + +
+


+
+


+
+


+
+


+
+


+
+


+
+


+ `, + ); + }); + + test('Resize merged cells width (2)', async ({ + page, + isPlainText, + isCollab, + }) => { + await initialize({isCollab, page}); + test.skip(isPlainText); + if (IS_COLLAB) { + // The contextual menu positioning needs fixing (it's hardcoded to show on the right side) + page.setViewportSize({height: 1000, width: 3000}); + } + + await focusEditor(page); + + await insertTable(page, 3, 3); + await click(page, '.PlaygroundEditorTheme__tableCell'); + await selectCellsFromTableCords( + page, + {x: 0, y: 0}, + {x: 1, y: 1}, + true, + false, + ); + await mergeTableCells(page); + await click(page, 'th'); + const resizerBoundingBox = await selectorBoundingBox( + page, + '.TableCellResizer__resizer:first-child', + ); + const x = resizerBoundingBox.x + resizerBoundingBox.width / 2; + const y = resizerBoundingBox.y + resizerBoundingBox.height / 2; + await page.mouse.move(x, y); + await page.mouse.down(); + await page.mouse.move(x + 50, y); + await page.mouse.up(); + + await assertHTML( + page, + html` +


+ + + + + + + + + + + + + +
+


+
+


+
+


+
+


+
+


+
+


+
+


+ `, + ); + }); + + test('Resize merged cells height', async ({page, isPlainText, isCollab}) => { + await initialize({isCollab, page}); + test.skip(isPlainText); + if (IS_COLLAB) { + // The contextual menu positioning needs fixing (it's hardcoded to show on the right side) + page.setViewportSize({height: 1000, width: 3000}); + } + + await focusEditor(page); + + await insertTable(page, 3, 3); + await click(page, '.PlaygroundEditorTheme__tableCell'); + await selectCellsFromTableCords( + page, + {x: 0, y: 0}, + {x: 1, y: 1}, + true, + false, + ); + await mergeTableCells(page); + await click(page, 'th'); + const resizerBoundingBox = await selectorBoundingBox( + page, + '.TableCellResizer__resizer:nth-child(2)', + ); + const x = resizerBoundingBox.x + resizerBoundingBox.width / 2; + const y = resizerBoundingBox.y + resizerBoundingBox.height / 2; + await page.mouse.move(x, y); + await page.mouse.down(); + await page.mouse.move(x, y + 50); + await page.mouse.up(); + + await assertHTML( + page, + html` +


+ + + + + + + + + + + + + +
+


+
+


+
+


+
+


+
+


+
+


+
+


+ `, + undefined, + { + ignoreClasses: false, + ignoreInlineStyles: false, + }, + (actualHtml) => + // flaky fix: +- 1px for the height assertion + actualHtml.replace( + '', + '', + ), + ); + }); + test('Merge/unmerge cells (1)', async ({page, isPlainText, isCollab}) => { await initialize({isCollab, page}); test.skip(isPlainText); diff --git a/packages/lexical-playground/__tests__/utils/index.mjs b/packages/lexical-playground/__tests__/utils/index.mjs index c9097052429..990e1e99799 100644 --- a/packages/lexical-playground/__tests__/utils/index.mjs +++ b/packages/lexical-playground/__tests__/utils/index.mjs @@ -241,6 +241,7 @@ export async function assertHTML( ignoreClasses, ignoreInlineStyles, 'page', + actualHtmlModificationsCallback, ); } } diff --git a/packages/lexical-playground/src/plugins/TableCellResizer/index.tsx b/packages/lexical-playground/src/plugins/TableCellResizer/index.tsx index bc96ffe81e0..43290761f08 100644 --- a/packages/lexical-playground/src/plugins/TableCellResizer/index.tsx +++ b/packages/lexical-playground/src/plugins/TableCellResizer/index.tsx @@ -5,7 +5,12 @@ * LICENSE file in the root directory of this source tree. * */ -import type {TableDOMCell} from '@lexical/table'; +import type { + TableCellNode, + TableDOMCell, + TableMapType, + TableMapValueType, +} from '@lexical/table'; import type {LexicalEditor} from 'lexical'; import './index.css'; @@ -13,13 +18,12 @@ import './index.css'; import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; import {useLexicalEditable} from '@lexical/react/useLexicalEditable'; import { - $getTableColumnIndexFromTableCellNode, + $computeTableMapSkipCellCheck, $getTableNodeFromLexicalNodeOrThrow, $getTableRowIndexFromTableCellNode, $isTableCellNode, $isTableRowNode, getDOMCellFromTarget, - TableCellNode, } from '@lexical/table'; import {calculateZoomLevel} from '@lexical/utils'; import {$getNearestNodeFromDOMNode} from 'lexical'; @@ -149,7 +153,7 @@ function TableCellResizer({editor}: {editor: LexicalEditor}): JSX.Element { }; const updateRowHeight = useCallback( - (newHeight: number) => { + (heightChange: number) => { if (!activeCell) { throw new Error('TableCellResizer: Expected active cell.'); } @@ -178,6 +182,17 @@ function TableCellResizer({editor}: {editor: LexicalEditor}): JSX.Element { throw new Error('Expected table row'); } + let height = tableRow.getHeight(); + if (height === undefined) { + const rowCells = tableRow.getChildren(); + height = Math.min( + ...rowCells.map( + (cell) => getCellNodeHeight(cell, editor) ?? Infinity, + ), + ); + } + + const newHeight = Math.max(height + heightChange, MIN_ROW_HEIGHT); tableRow.setHeight(newHeight); }, {tag: 'skip-scroll-into-view'}, @@ -186,8 +201,50 @@ function TableCellResizer({editor}: {editor: LexicalEditor}): JSX.Element { [activeCell, editor], ); + const getCellNodeWidth = ( + cell: TableCellNode, + activeEditor: LexicalEditor, + ): number | undefined => { + const width = cell.getWidth(); + if (width !== undefined) { + return width; + } + + const domCellNode = activeEditor.getElementByKey(cell.getKey()); + if (domCellNode == null) { + return undefined; + } + const computedStyle = getComputedStyle(domCellNode); + return ( + domCellNode.clientWidth - + parseFloat(computedStyle.paddingLeft) - + parseFloat(computedStyle.paddingRight) + ); + }; + + const getCellNodeHeight = ( + cell: TableCellNode, + activeEditor: LexicalEditor, + ): number | undefined => { + const domCellNode = activeEditor.getElementByKey(cell.getKey()); + return domCellNode?.clientHeight; + }; + + const getCellColumnIndex = ( + tableCellNode: TableCellNode, + tableMap: TableMapType, + ) => { + for (let row = 0; row < tableMap.length; row++) { + for (let column = 0; column < tableMap[row].length; column++) { + if (tableMap[row][column].cell === tableCellNode) { + return column; + } + } + } + }; + const updateColumnWidth = useCallback( - (newWidth: number) => { + (widthChange: number) => { if (!activeCell) { throw new Error('TableCellResizer: Expected active cell.'); } @@ -199,48 +256,31 @@ function TableCellResizer({editor}: {editor: LexicalEditor}): JSX.Element { } const tableNode = $getTableNodeFromLexicalNodeOrThrow(tableCellNode); + const [tableMap] = $computeTableMapSkipCellCheck( + tableNode, + null, + null, + ); + const columnIndex = getCellColumnIndex(tableCellNode, tableMap); + if (columnIndex === undefined) { + throw new Error('TableCellResizer: Table column not found.'); + } - const tableColumnIndex = - $getTableColumnIndexFromTableCellNode(tableCellNode); - - const tableRows = tableNode.getChildren(); - - for (let r = 0; r < tableRows.length; r++) { - const tableRow = tableRows[r]; - - if (!$isTableRowNode(tableRow)) { - throw new Error('Expected table row'); - } - - const rowCells = tableRow.getChildren(); - const rowCellsSpan = rowCells.map((cell) => cell.getColSpan()); - - const aggregatedRowSpans = rowCellsSpan.reduce( - (rowSpans: number[], cellSpan) => { - const previousCell = rowSpans[rowSpans.length - 1] ?? 0; - rowSpans.push(previousCell + cellSpan); - return rowSpans; - }, - [], - ); - const rowColumnIndexWithSpan = aggregatedRowSpans.findIndex( - (cellSpan: number) => cellSpan > tableColumnIndex, - ); - + for (let row = 0; row < tableMap.length; row++) { + const cell: TableMapValueType = tableMap[row][columnIndex]; if ( - rowColumnIndexWithSpan >= rowCells.length || - rowColumnIndexWithSpan < 0 + cell.startRow === row && + (columnIndex === tableMap[row].length - 1 || + tableMap[row][columnIndex].cell !== + tableMap[row][columnIndex + 1].cell) ) { - throw new Error('Expected table cell to be inside of table row.'); - } - - const tableCell = rowCells[rowColumnIndexWithSpan]; - - if (!$isTableCellNode(tableCell)) { - throw new Error('Expected table cell'); + const width = getCellNodeWidth(cell.cell, editor); + if (width === undefined) { + continue; + } + const newWidth = Math.max(width + widthChange, MIN_COLUMN_WIDTH); + cell.cell.setWidth(newWidth); } - - tableCell.setWidth(newWidth); } }, {tag: 'skip-scroll-into-view'}, @@ -268,33 +308,11 @@ function TableCellResizer({editor}: {editor: LexicalEditor}): JSX.Element { const zoom = calculateZoomLevel(event.target as Element); if (isHeightChanging(direction)) { - const height = activeCell.elem.getBoundingClientRect().height; - const heightChange = Math.abs(event.clientY - y) / zoom; - - const isShrinking = direction === 'bottom' && y > event.clientY; - - updateRowHeight( - Math.max( - isShrinking ? height - heightChange : heightChange + height, - MIN_ROW_HEIGHT, - ), - ); + const heightChange = (event.clientY - y) / zoom; + updateRowHeight(heightChange); } else { - const computedStyle = getComputedStyle(activeCell.elem); - let width = activeCell.elem.clientWidth; // width with padding - width -= - parseFloat(computedStyle.paddingLeft) + - parseFloat(computedStyle.paddingRight); - const widthChange = Math.abs(event.clientX - x) / zoom; - - const isShrinking = direction === 'right' && x > event.clientX; - - updateColumnWidth( - Math.max( - isShrinking ? width - widthChange : widthChange + width, - MIN_COLUMN_WIDTH, - ), - ); + const widthChange = (event.clientX - x) / zoom; + updateColumnWidth(widthChange); } resetState(); diff --git a/packages/lexical-table/src/index.ts b/packages/lexical-table/src/index.ts index c9f3964cf07..41462c0237e 100644 --- a/packages/lexical-table/src/index.ts +++ b/packages/lexical-table/src/index.ts @@ -34,6 +34,8 @@ export { TableRowNode, } from './LexicalTableRowNode'; export type { + TableMapType, + TableMapValueType, TableSelection, TableSelectionShape, } from './LexicalTableSelection';