diff --git a/packages/lexical-playground/__tests__/regression/7266-column-header-merged-cells.spec.mjs b/packages/lexical-playground/__tests__/regression/7266-column-header-merged-cells.spec.mjs index 97c3db92d62..a70cd881870 100644 --- a/packages/lexical-playground/__tests__/regression/7266-column-header-merged-cells.spec.mjs +++ b/packages/lexical-playground/__tests__/regression/7266-column-header-merged-cells.spec.mjs @@ -8,11 +8,14 @@ import { assertTableHTML as assertHTML, click, + expect, focusEditor, + getPageOrFrame, html, initialize, insertTable, mergeTableCells, + selectCellFromTableCoord, selectCellsFromTableCords, test, toggleColumnHeader, @@ -171,6 +174,53 @@ test.describe('Regression test #7266', () => { ); }); + test('toggling column header applies to the grid column of the clicked cell, not its index among its row children', async ({ + page, + isPlainText, + isCollab, + }) => { + test.skip(isPlainText); + + await initialize({isCollab, page}); + + await focusEditor(page); + + await insertTable(page, 3, 4); + + // Merge grid columns 1 and 2 of row 1 into one cell, so the cells after it + // in that row sit one grid column to the right of their child index. + await click(page, '.PlaygroundEditorTheme__tableCell'); + await selectCellsFromTableCords( + page, + {x: 1, y: 1}, + {x: 2, y: 1}, + false, + false, + ); + await mergeTableCells(page); + + // The last cell of row 1 is now the third child of its row but still + // occupies grid column 3. + await selectCellFromTableCoord(page, {x: 2, y: 1}, false); + await toggleColumnHeader(page); + + // Read back which grid columns are headers, per row. + const headerGrid = await getPageOrFrame(page).evaluate(() => { + const table = document.querySelector('table'); + return Array.from(table.querySelectorAll(':scope > tr')).map(row => + Array.from(row.children).flatMap(cell => + Array.from({length: cell.colSpan}, () => cell.tagName), + ), + ); + }); + + expect(headerGrid).toEqual([ + ['TH', 'TH', 'TH', 'TH'], + ['TH', 'TD', 'TD', 'TH'], + ['TH', 'TD', 'TD', 'TH'], + ]); + }); + test('toggling row header with merged row cells should only apply row header to the selected row', async ({ page, isPlainText, diff --git a/packages/lexical-playground/src/plugins/TableActionMenuPlugin/index.tsx b/packages/lexical-playground/src/plugins/TableActionMenuPlugin/index.tsx index 654d6f95201..e579740e88d 100644 --- a/packages/lexical-playground/src/plugins/TableActionMenuPlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/TableActionMenuPlugin/index.tsx @@ -15,7 +15,6 @@ import { $deleteTableRowAtSelection, $getNodeTriplet, $getTableCellNodeFromLexicalNode, - $getTableColumnIndexFromTableCellNode, $getTableNodeFromLexicalNodeOrThrow, $getTableRowIndexFromTableCellNode, $insertTableColumnAtSelection, @@ -364,7 +363,16 @@ function TableActionMenu({ const toggleTableColumnIsHeader = useCallback(() => { editor.update(() => { const tableNode = $getTableNodeFromLexicalNodeOrThrow(tableCellNode); - const columnIndex = $getTableColumnIndexFromTableCellNode(tableCellNode); + // $setTableColumnIsHeader indexes the table grid, so the cell's position + // has to come from the table map rather than from its index among its + // row's children — the two diverge once an earlier cell in the row spans + // more than one column. + const [, cellMap] = $computeTableMap( + tableNode, + tableCellNode, + tableCellNode, + ); + const columnIndex = cellMap.startColumn; const isHeader = !tableCellNode.hasHeaderState( TableCellHeaderStates.COLUMN, ); diff --git a/packages/lexical-rich-text/src/__tests__/unit/RichTextBlockCursorRootBoundary.test.ts b/packages/lexical-rich-text/src/__tests__/unit/RichTextBlockCursorRootBoundary.test.ts index 7c5112009ee..da48aec79bb 100644 --- a/packages/lexical-rich-text/src/__tests__/unit/RichTextBlockCursorRootBoundary.test.ts +++ b/packages/lexical-rich-text/src/__tests__/unit/RichTextBlockCursorRootBoundary.test.ts @@ -14,9 +14,12 @@ import { $createTextNode, $getRoot, $getSelection, + $isNodeSelection, $isRangeSelection, $setSelection, KEY_ARROW_DOWN_COMMAND, + KEY_ARROW_LEFT_COMMAND, + KEY_ARROW_RIGHT_COMMAND, KEY_ARROW_UP_COMMAND, type LexicalEditor, } from 'lexical'; @@ -119,3 +122,60 @@ describe('block cursor root boundary navigation (#8886)', () => { expectBlockCursorAt(editor, 3); }); }); + +describe('block cursor root boundary navigation (#7999)', () => { + test('ArrowRight at the block cursor after the last block stays put', () => { + using editor = createBoundaryEditor(); + + editor.update(() => $getRoot().select(3, 3), {discrete: true}); + expectBlockCursorAt(editor, 3); + + const event = makeArrowEvent('ArrowRight'); + const handled = editor.dispatchCommand(KEY_ARROW_RIGHT_COMMAND, event); + expect(handled).toBe(true); + expect(event.defaultPrevented).toBe(true); + expectBlockCursorAt(editor, 3); + }); + + test('ArrowLeft at the block cursor before the first block stays put', () => { + using editor = createBoundaryEditor(); + + editor.update(() => $getRoot().select(0, 0), {discrete: true}); + expectBlockCursorAt(editor, 0); + + const event = makeArrowEvent('ArrowLeft'); + const handled = editor.dispatchCommand(KEY_ARROW_LEFT_COMMAND, event); + expect(handled).toBe(true); + expect(event.defaultPrevented).toBe(true); + expectBlockCursorAt(editor, 0); + }); + + test('ArrowRight at the block cursor before the last block is not consumed', () => { + using editor = createBoundaryEditor(); + + // root:2 sits between the paragraph and the trailing decorator, so there + // is still a block to move into. + editor.update(() => $getRoot().select(2, 2), {discrete: true}); + + const event = makeArrowEvent('ArrowRight'); + editor.dispatchCommand(KEY_ARROW_RIGHT_COMMAND, event); + // The pre-existing decorator navigation still runs and selects the + // trailing decorator rather than leaving the caret at the root. + editor.read(() => { + const s = $getSelection(); + assert($isNodeSelection(s)); + expect(s.getNodes()).toEqual([$getRoot().getChildAtIndex(2)]); + }); + }); + + test('ArrowRight with a non-collapsed selection at the root edge is not consumed', () => { + using editor = createBoundaryEditor(); + + editor.update(() => $getRoot().select(0, 3), {discrete: true}); + + const event = makeArrowEvent('ArrowRight'); + const handled = editor.dispatchCommand(KEY_ARROW_RIGHT_COMMAND, event); + expect(handled).toBe(false); + expect(event.defaultPrevented).toBe(false); + }); +}); diff --git a/packages/lexical-rich-text/src/index.ts b/packages/lexical-rich-text/src/index.ts index 2f18b7cfb12..f1b62652b6a 100644 --- a/packages/lexical-rich-text/src/index.ts +++ b/packages/lexical-rich-text/src/index.ts @@ -584,6 +584,35 @@ function $isSelectionAtStartOfRoot(selection: RangeSelection) { return focus.key === 'root' && focus.offset === 0; } +/** + * True when the selection is a collapsed element point at the very start or + * end of the root, beside a child that renders a block cursor (a decorator, a + * table or another shadow root). There is nothing past the block cursor to + * move to, so horizontal arrow navigation in that direction has to be a no-op. + * Left to the browser, the native caret escapes to the other side of the block + * instead and the caret appears to cycle around it. See #7999. + */ +function $isBlockCursorAtRootEdge( + selection: RangeSelection, + direction: CaretDirection, +): boolean { + if (!selection.isCollapsed()) { + return false; + } + const isNext = direction === 'next'; + if ( + !(isNext + ? $isSelectionAtEndOfRoot(selection) + : $isSelectionAtStartOfRoot(selection)) + ) { + return false; + } + const offset = selection.focus.offset; + return $needsBlockCursorBeside( + $getRoot().getChildAtIndex(isNext ? offset - 1 : offset), + ); +} + function $isSelectionCollapsedAtFrontOfIndentedBlock( selection: RangeSelection, ): boolean { @@ -1491,12 +1520,16 @@ export function registerRichText( if (!$isRangeSelection(selection)) { return false; } + const leftDirection = $isParentRTL(selection.anchor.getNode()) + ? 'next' + : 'previous'; + if ($isBlockCursorAtRootEdge(selection, leftDirection)) { + event.preventDefault(); + return true; + } if ( !event.shiftKey && - $tryBlockCursorShadowRootNavigation( - selection, - $isParentRTL(selection.anchor.getNode()) ? 'next' : 'previous', - ) + $tryBlockCursorShadowRootNavigation(selection, leftDirection) ) { event.preventDefault(); return true; @@ -1539,12 +1572,16 @@ export function registerRichText( if (!$isRangeSelection(selection)) { return false; } + const rightDirection = $isParentRTL(selection.anchor.getNode()) + ? 'previous' + : 'next'; + if ($isBlockCursorAtRootEdge(selection, rightDirection)) { + event.preventDefault(); + return true; + } if ( !event.shiftKey && - $tryBlockCursorShadowRootNavigation( - selection, - $isParentRTL(selection.anchor.getNode()) ? 'previous' : 'next', - ) + $tryBlockCursorShadowRootNavigation(selection, rightDirection) ) { event.preventDefault(); return true; diff --git a/packages/lexical-table/src/LexicalTableNode.ts b/packages/lexical-table/src/LexicalTableNode.ts index 8528a0fe54a..4772a431dc2 100644 --- a/packages/lexical-table/src/LexicalTableNode.ts +++ b/packages/lexical-table/src/LexicalTableNode.ts @@ -575,6 +575,20 @@ export class TableNode extends ElementNode { exportDOM(editor: LexicalEditor): DOMExportOutput { const superExport = super.exportDOM(editor); const {element} = superExport; + const exportedElement = + !isHTMLTableElement(element) && isHTMLElement(element) + ? element.querySelector('table') + : element; + // ElementNode.exportDOM writes `dir` onto whatever createDOM returned, + // which is the scroll wrapper when scrollable tables are active. That + // wrapper is not part of the export, so the direction has to be re-applied + // to the itself — that is where $convertTableElement reads it back. + if (isHTMLTableElement(exportedElement) && exportedElement !== element) { + const direction = this.getDirection(); + if (direction) { + exportedElement.dir = direction; + } + } return { after: tableElement => { if (superExport.after) { @@ -647,10 +661,7 @@ export class TableNode extends ElementNode { } return tableElement; }, - element: - !isHTMLTableElement(element) && isHTMLElement(element) - ? element.querySelector('table') - : element, + element: exportedElement, }; } diff --git a/packages/lexical-table/src/LexicalTableObserver.ts b/packages/lexical-table/src/LexicalTableObserver.ts index 92fe5f08c68..fa46da899c7 100644 --- a/packages/lexical-table/src/LexicalTableObserver.ts +++ b/packages/lexical-table/src/LexicalTableObserver.ts @@ -8,6 +8,7 @@ import invariant from '@lexical/internal/invariant'; import { + $copyNode, $createParagraphNode, $createRangeSelection, $createTextNode, @@ -606,7 +607,15 @@ export class TableObserver { selectedNodes.forEach(cellNode => { if ($isElementNode(cellNode)) { - const paragraphNode = $createParagraphNode(); + // Clearing a cell empties its content; it does not reset how that + // content is laid out. A fresh ParagraphNode would start with no + // format, style, direction or indent, so the cell's paragraph is + // copied instead when there is one — $copyNode carries that state and + // returns it childless. + const firstChild = cellNode.getFirstChild(); + const paragraphNode = $isParagraphNode(firstChild) + ? $copyNode(firstChild) + : $createParagraphNode(); const textNode = $createTextNode(); paragraphNode.append(textNode); cellNode.append(paragraphNode); diff --git a/packages/lexical-table/src/LexicalTablePluginHelpers.ts b/packages/lexical-table/src/LexicalTablePluginHelpers.ts index 4a0b7bcd476..0d879cb2e58 100644 --- a/packages/lexical-table/src/LexicalTablePluginHelpers.ts +++ b/packages/lexical-table/src/LexicalTablePluginHelpers.ts @@ -46,6 +46,7 @@ import { import { $createTableCellNode, $isTableCellNode, + TableCellHeaderStates, TableCellNode, } from './LexicalTableCellNode'; import { @@ -152,9 +153,16 @@ function $tableTransform(node: TableNode) { if (rowLength === maxRowLength) { continue; } + // Padding cells are appended to the end of the row, so they are never in + // a header column — but they are in a header row whenever the row they + // extend is one. Inherit only that bit from the row's last cell, the same + // reference $insertTableColumnAtNode uses when it appends a column. + const lastCell = rowNode.getLastChild(); + const headerState = $isTableCellNode(lastCell) + ? lastCell.getHeaderStyles() & TableCellHeaderStates.ROW + : TableCellHeaderStates.NO_STATUS; for (let j = rowLength; j < maxRowLength; ++j) { - // TODO: inherit header state from another header or body - const newCell = $createTableCellNode(); + const newCell = $createTableCellNode(headerState); newCell.append($createParagraphNode()); rowNode.append(newCell); } diff --git a/packages/lexical-table/src/LexicalTableSelectionHelpers.ts b/packages/lexical-table/src/LexicalTableSelectionHelpers.ts index 51214cc3fe4..1a59a49e483 100644 --- a/packages/lexical-table/src/LexicalTableSelectionHelpers.ts +++ b/packages/lexical-table/src/LexicalTableSelectionHelpers.ts @@ -14,6 +14,7 @@ import invariant from '@lexical/internal/invariant'; import {objectKlassEquals} from '@lexical/utils'; import { $caretFromPoint, + $comparePointCaretNext, $createParagraphNode, $createRangeSelectionFromDom, $createTextNode, @@ -21,6 +22,7 @@ import { $findMatchingParent, $getAdjacentChildCaret, $getChildCaret, + $getCommonAncestor, $getNearestNodeFromDOMNode, $getNodeByKey, $getNodeByKeyOrThrow, @@ -1770,17 +1772,29 @@ function getCorner( return [colName, rowName]; } -function getCornerOrThrow( +/** + * Resolve the corner of `rect` that the anchor sits on. + * + * `$computeTableCellRectBoundary` grows the rect until it contains every + * merged cell that straddles an edge, so the anchor is not guaranteed to be at + * a corner of the result — a cell merged across the rect's edge pushes that + * edge past the anchor. Fall back the same way {@link $extractRectCorners} + * does: to the corner opposite the focus, and finally to the top-left. + */ +function getAnchorCorner( rect: TableCellRectBoundary, - cellValue: TableMapValueType, + anchorCellValue: TableMapValueType, + focusCellValue: TableMapValueType, ): Corner { - const corner = getCorner(rect, cellValue); - invariant( - corner !== null, - 'getCornerOrThrow: cell %s is not at a corner of rect', - cellValue.cell.getKey(), - ); - return corner; + const anchorCorner = getCorner(rect, anchorCellValue); + if (anchorCorner) { + return anchorCorner; + } + const focusCorner = getCorner(rect, focusCellValue); + if (focusCorner) { + return oppositeCorner(focusCorner); + } + return ['minColumn', 'minRow']; } function oppositeCorner([colName, rowName]: Corner): Corner { @@ -1863,7 +1877,7 @@ function $adjustFocusInDirection( ); const spans = $computeTableCellRectSpans(tableMap, rect); const {topSpan, leftSpan, bottomSpan, rightSpan} = spans; - const anchorCorner = getCornerOrThrow(rect, anchorCellValue); + const anchorCorner = getAnchorCorner(rect, anchorCellValue, focusCellValue); const [focusColumn, focusRow] = oppositeCorner(anchorCorner); let fCol = rect[focusColumn]; let fRow = rect[focusRow]; @@ -2095,6 +2109,27 @@ function $findNextTableCell( return null; } +/** + * True when the selection focus sits before `tableNode` in document order — + * the only side an ArrowDown can move the caret into the table from. + */ +function $isSelectionBeforeTable( + selection: null | BaseSelection, + tableNode: TableNode, +): boolean { + if (!$isRangeSelection(selection)) { + return false; + } + const focusCaret = $caretFromPoint(selection.focus, 'next'); + // A ChildCaret is ordered at the table's 'enter' (pre-order) position, so + // any caret strictly before it is outside of and above the table. + const tableCaret = $getChildCaret(tableNode, 'next'); + return ( + $getCommonAncestor(focusCaret.origin, tableCaret.origin) !== null && + $comparePointCaretNext(focusCaret, tableCaret) < 0 + ); +} + function $handleArrowKey( editor: LexicalEditor, event: KeyboardEvent, @@ -2282,7 +2317,17 @@ function $handleArrowKey( } } } - if (direction === 'down' && $isScrollableTablesActive(editor)) { + if ( + direction === 'down' && + $isScrollableTablesActive(editor) && + // Only arm the workaround when ArrowDown could actually move the caret + // into the table. From a caret after the table (e.g. the block cursor + // below a trailing table) ArrowDown moves nothing, so the flag would + // survive to be consumed by an unrelated later selection change — an + // ArrowUp back into the last row would then be snapped to the first + // cell. + $isSelectionBeforeTable(selection, tableNode) + ) { // Enable Firefox workaround tableObservers.setShouldCheckSelectionForTable(tableNode.getKey()); } diff --git a/packages/lexical-table/src/LexicalTableUtils.ts b/packages/lexical-table/src/LexicalTableUtils.ts index 3f90dedd559..aa97e83276a 100644 --- a/packages/lexical-table/src/LexicalTableUtils.ts +++ b/packages/lexical-table/src/LexicalTableUtils.ts @@ -524,7 +524,7 @@ export function $insertTableColumnAtNode( return cell; } let loopRow: TableRowNode = gridFirstChild; - rowLoop: for (let i = 0; i < rowCount; i++) { + for (let i = 0; i < rowCount; i++) { if (i !== 0) { const currentRow = loopRow.getNextSibling(); invariant( @@ -550,32 +550,34 @@ export function $insertTableColumnAtNode( ); continue; } - const { - cell: currentCell, - startColumn: currentStartColumn, - startRow: currentStartRow, - } = rowMap[insertAfterColumn]; + const {cell: currentCell, startColumn: currentStartColumn} = + rowMap[insertAfterColumn]; if (currentStartColumn + currentCell.__colSpan - 1 <= insertAfterColumn) { - let insertAfterCell: TableCellNode = currentCell; - let insertAfterCellRowStart = currentStartRow; - let prevCellIndex = insertAfterColumn; - while (insertAfterCellRowStart !== i && insertAfterCell.__rowSpan > 1) { - // prevCellIndex always sits on the last grid column of insertAfterCell, - // so stepping to the column before it means subtracting *that* cell's - // colSpan, not the colSpan of the cell we started from. - prevCellIndex -= insertAfterCell.__colSpan; - if (prevCellIndex >= 0) { - const {cell: cell_, startRow: startRow_} = rowMap[prevCellIndex]; - insertAfterCell = cell_; - insertAfterCellRowStart = startRow_; - } else { - loopRow.append($createTableCellNodeForInsertTableColumn(headerState)); - continue rowLoop; + // Find the last cell this row actually owns at or before the insertion + // column. Grid positions covered by a rowSpan from an earlier row are not + // children of this row, so they can not be inserted after. + let insertAfterCell: null | TableCellNode = null; + for (let column = 0; column <= insertAfterColumn; column++) { + const currentCellMap = rowMap[column]; + if (currentCellMap.startRow === i) { + insertAfterCell = currentCellMap.cell; + } + if (currentCellMap.cell.__colSpan > 1) { + column += currentCellMap.cell.__colSpan - 1; } } - insertAfterCell.insertAfter( - $createTableCellNodeForInsertTableColumn(headerState), - ); + if (insertAfterCell === null) { + // Every grid column to the left is covered by a rowSpan from an earlier + // row, so the new cell is this row's first child. + $insertFirst( + loopRow, + $createTableCellNodeForInsertTableColumn(headerState), + ); + } else { + insertAfterCell.insertAfter( + $createTableCellNodeForInsertTableColumn(headerState), + ); + } } else { currentCell.setColSpan(currentCell.__colSpan + 1); } @@ -640,7 +642,11 @@ export function $deleteTableRowAtSelection(): void { const {startRow: focusStartRow} = focusCellMap; const focusEndRow = focusStartRow + focusCell.__rowSpan - 1; if (gridMap.length === focusEndRow - anchorStartRow + 1) { - // Empty grid + // Empty grid. Move the selection out of the table before removing it, + // otherwise a TableSelection is left pointing at cells that no longer + // exist — $deleteTableColumnAtSelection and TableObserver.$clearText both + // call selectPrevious() here for the same reason. + grid.selectPrevious(); grid.remove(); return; } @@ -987,13 +993,19 @@ export function $unmergeCellNode(cellNode: TableCellNode): void { return rowStyle; }); + // The cells the merged cell splits into are the same region of the table it + // was, so they keep its per-cell presentation. Only the header state is + // recomputed (above), because that describes the row/column rather than the + // cell. + const $createSplitCell = (headerState: TableCellHeaderState) => + $createTableCellNode(headerState) + .setBackgroundColor(cell.getBackgroundColor()) + .setVerticalAlign(cell.getVerticalAlign()) + .append($createParagraphNode()); + if (colSpan > 1) { for (let i = 1; i < colSpan; i++) { - cell.insertAfter( - $createTableCellNode(colStyles[i] | rowStyles[0]).append( - $createParagraphNode(), - ), - ); + cell.insertAfter($createSplitCell(colStyles[i] | rowStyles[0])); } cell.setColSpan(1); } @@ -1023,17 +1035,13 @@ export function $unmergeCellNode(cellNode: TableCellNode): void { for (let j = colSpan - 1; j >= 0; j--) { $insertFirst( currentRowNode, - $createTableCellNode(colStyles[j] | rowStyles[i]).append( - $createParagraphNode(), - ), + $createSplitCell(colStyles[j] | rowStyles[i]), ); } } else { for (let j = colSpan - 1; j >= 0; j--) { insertAfterCell.insertAfter( - $createTableCellNode(colStyles[j] | rowStyles[i]).append( - $createParagraphNode(), - ), + $createSplitCell(colStyles[j] | rowStyles[i]), ); } } @@ -1591,6 +1599,10 @@ export function $insertTableIntoGrid( if (backgroundColor !== null && backgroundColor !== undefined) { cell.setBackgroundColor(backgroundColor); } + const verticalAlign = templateCell.getVerticalAlign(); + if (verticalAlign !== undefined) { + cell.setVerticalAlign(verticalAlign); + } const originalChildren = cell.getChildren(); templateCell.getChildren().forEach(child => { if ($isTextNode(child)) { diff --git a/packages/lexical-table/src/__tests__/browser/Issue6822BlockCursorArrowKeys.test.ts b/packages/lexical-table/src/__tests__/browser/Issue6822BlockCursorArrowKeys.test.ts new file mode 100644 index 00000000000..834d4f80527 --- /dev/null +++ b/packages/lexical-table/src/__tests__/browser/Issue6822BlockCursorArrowKeys.test.ts @@ -0,0 +1,234 @@ +/** + * 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 {buildEditorFromExtensions} from '@lexical/extension'; +import {RichTextExtension} from '@lexical/rich-text'; +import { + $createTableNodeWithDimensions, + $isTableCellNode, + $isTableRowNode, + TableExtension, +} from '@lexical/table'; +import { + $createParagraphNode, + $createTextNode, + $getRoot, + $getSelection, + $isRangeSelection, + defineExtension, + type LexicalEditor, +} from 'lexical'; +import {describe, expect, onTestFinished, test} from 'vitest'; +import {userEvent} from 'vitest/browser'; + +// Regression tests for #6822. +// +// A table that is the first or last child of the root gets a block cursor +// beside it when the caret steps off its edge. Getting back into the table +// from that block cursor is native caret movement, so these tests run in a +// real browser (see the `browser` project in vitest.config.mts) against real +// layout — jsdom has neither a caret nor line boxes to move it between. +// +// The theme mirrors the playground's block cursor and table CSS because the +// engine's vertical caret movement is layout-driven; an approximation of the +// real stylesheet lands the caret in different cells. +const PLAYGROUND_CSS = ` +.test-blockCursor { display: block; pointer-events: none; position: absolute; } +.test-blockCursor:after { content: ''; display: block; position: absolute; top: -2px; width: 20px; border-top: 1px solid black; } +.test-scrollWrapper { overflow-x: auto; margin: 0px 0px 5px 0px; scrollbar-width: none; } +.test-scrollWrapper > .test-table { margin-top: 0; margin-bottom: 0; } +.test-table { border-collapse: collapse; border-spacing: 0; table-layout: fixed; width: fit-content; margin-top: 25px; margin-bottom: 30px; } +.test-cell { border: 1px solid #bbb; width: 75px; vertical-align: top; text-align: start; padding: 6px 8px; position: relative; outline: none; overflow: auto; } +.test-cell > * { overflow: inherit; } +.test-cellHeader { background-color: #f2f3f5; text-align: start; } +`; + +const THEME = { + blockCursor: 'test-blockCursor', + table: 'test-table', + tableCell: 'test-cell', + tableCellHeader: 'test-cellHeader', + tableScrollableWrapper: 'test-scrollWrapper', +}; + +/** + * A 3x3 table whose cells read "c1".."c9" in document order, so an assertion + * on the anchor's text content names the cell the caret landed in. + */ +function $createNumberedTable() { + const table = $createTableNodeWithDimensions(3, 3, true); + let n = 0; + for (const row of table.getChildren()) { + if (!$isTableRowNode(row)) { + continue; + } + for (const cell of row.getChildren()) { + if (!$isTableCellNode(cell)) { + continue; + } + const paragraph = $createParagraphNode(); + paragraph.append($createTextNode(`c${++n}`)); + cell.clear().append(paragraph); + } + } + return table; +} + +function setUpEditor($initialEditorState: () => void): LexicalEditor { + const style = document.createElement('style'); + style.textContent = PLAYGROUND_CSS; + document.head.appendChild(style); + const container = document.createElement('div'); + document.body.appendChild(container); + const editor = buildEditorFromExtensions( + defineExtension({ + $initialEditorState, + afterRegistration(builtEditor) { + const rootElement = document.createElement('div'); + rootElement.contentEditable = 'true'; + container.appendChild(rootElement); + builtEditor.setRootElement(rootElement); + return () => { + document.body.removeChild(container); + document.head.removeChild(style); + }; + }, + // hasHorizontalScroll defaults on, which is what arms the Firefox + // scroll workaround under test. + dependencies: [RichTextExtension, TableExtension], + name: '[6822-browser]', + theme: THEME, + }), + ); + onTestFinished(() => editor.dispose()); + return editor; +} + +async function press(key: string): Promise { + await userEvent.keyboard(key); + // Let the engine's selectionchange (and Lexical's reconcile of it) settle. + await new Promise(resolve => setTimeout(resolve, 60)); +} + +/** The text of the node the caret is anchored in, or a description of a + * non-text anchor (`block cursor` for a collapsed element point on the root). */ +function anchorText(editor: LexicalEditor): string { + return editor.getEditorState().read(() => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return `not a RangeSelection: ${String(selection)}`; + } + const anchor = selection.anchor; + return anchor.type === 'text' + ? anchor.getNode().getTextContent() + : `element point on ${anchor.getNode().getType()}@${anchor.offset}`; + }); +} + +function hasBlockCursor(editor: LexicalEditor): boolean { + return editor._blockCursorElement !== null; +} + +describe('block cursor beside a table (#6822)', () => { + test('ArrowUp from the block cursor below a trailing table returns to the last row', async () => { + const editor = setUpEditor(() => { + const paragraph = $createParagraphNode(); + paragraph.append($createTextNode('before')); + $getRoot().clear().append(paragraph, $createNumberedTable()); + }); + editor.update( + () => { + // The last descendant of the root is the text in the table's last + // cell in both layouts under test. + $getRoot().getLastDescendant()!.selectEnd(); + }, + {discrete: true}, + ); + editor.focus(); + await new Promise(resolve => setTimeout(resolve, 60)); + expect(anchorText(editor)).toBe('c9'); + + // Step off the bottom edge: the caret becomes a block cursor after the + // table (an element point on the root). + await press('{ArrowDown}'); + expect(hasBlockCursor(editor)).toBe(true); + expect(anchorText(editor)).toBe('element point on root@2'); + + // A second ArrowDown moves nothing — there is nothing below the table. + await press('{ArrowDown}'); + expect(hasBlockCursor(editor)).toBe(true); + expect(anchorText(editor)).toBe('element point on root@2'); + + // ArrowUp must step back into the row the caret left, not jump to the + // top-left cell. + await press('{ArrowUp}'); + expect(hasBlockCursor(editor)).toBe(false); + expect(anchorText(editor)).toBe('c9'); + + await press('z'); + expect( + editor.getEditorState().read(() => $getRoot().getTextContent()), + ).toBe('before\n\nc1\n\nc2\n\nc3\n\nc4\n\nc5\n\nc6\n\nc7\n\nc8\n\nc9z'); + }); + + test('ArrowUp from the block cursor below a table that is the only root child returns to the last row', async () => { + const editor = setUpEditor(() => { + $getRoot().clear().append($createNumberedTable()); + }); + editor.update( + () => { + // The last descendant of the root is the text in the table's last + // cell in both layouts under test. + $getRoot().getLastDescendant()!.selectEnd(); + }, + {discrete: true}, + ); + editor.focus(); + await new Promise(resolve => setTimeout(resolve, 60)); + + await press('{ArrowDown}'); + expect(hasBlockCursor(editor)).toBe(true); + await press('{ArrowDown}'); + expect(hasBlockCursor(editor)).toBe(true); + await press('{ArrowUp}'); + expect(anchorText(editor)).toBe('c9'); + }); + + test('ArrowDown from the block cursor above a leading table still enters the first cell', async () => { + // A control: this passes with and without the fix, because in this engine + // the native ArrowDown lands in the first cell on its own. It is here to + // pin the direction the Firefox scroll workaround is gated to — entering + // the table from above must keep working. + const editor = setUpEditor(() => { + const paragraph = $createParagraphNode(); + paragraph.append($createTextNode('after')); + $getRoot().clear().append($createNumberedTable(), paragraph); + }); + editor.update( + () => { + $getRoot().getFirstDescendant()!.selectStart(); + }, + {discrete: true}, + ); + editor.focus(); + await new Promise(resolve => setTimeout(resolve, 60)); + + await press('{ArrowUp}'); + expect(hasBlockCursor(editor)).toBe(true); + expect(anchorText(editor)).toBe('element point on root@0'); + + await press('{ArrowDown}'); + expect(hasBlockCursor(editor)).toBe(false); + expect(anchorText(editor)).toBe('c1'); + + await press('z'); + expect( + editor.getEditorState().read(() => $getRoot().getTextContent()), + ).toBe('zc1\n\nc2\n\nc3\n\nc4\n\nc5\n\nc6\n\nc7\n\nc8\n\nc9\n\nafter'); + }); +}); diff --git a/packages/lexical-table/src/__tests__/browser/TableTrailingCaret.test.ts b/packages/lexical-table/src/__tests__/browser/TableTrailingCaret.test.ts new file mode 100644 index 00000000000..34eb0c93b67 --- /dev/null +++ b/packages/lexical-table/src/__tests__/browser/TableTrailingCaret.test.ts @@ -0,0 +1,127 @@ +/** + * 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 {buildEditorFromExtensions, defineExtension} from '@lexical/extension'; +import {RichTextExtension} from '@lexical/rich-text'; +import {$createTableNodeWithDimensions, TableExtension} from '@lexical/table'; +import { + $createParagraphNode, + $createTextNode, + $getRoot, + $getSelection, + $isRangeSelection, + type LexicalEditor, +} from 'lexical'; +import {assert, expect, onTestFinished, test} from 'vitest'; +import {userEvent} from 'vitest/browser'; + +// Regression tests for #7999. +// +// With a table as the last node of the document, the caret used to cycle +// around it: right arrow out of the last cell reached the block cursor +// beneath the table, the next right arrow jumped back to the root offset +// *before* the table (where Enter inserts a paragraph above it), and the one +// after that dropped back into the last cell. +// +// Nothing in the Lexical model moves on that second key press - the native +// caret walks around the block cursor element and the selectionchange +// listener imports the result - so this only reproduces in a real browser. +// See the `browser` project in vitest.config.mts. + +function mount($initialEditorState: () => void): { + editor: LexicalEditor; + contentEditable: HTMLElement; +} { + const container = document.createElement('div'); + document.body.appendChild(container); + const contentEditable = document.createElement('div'); + contentEditable.contentEditable = 'true'; + container.appendChild(contentEditable); + const editor = buildEditorFromExtensions( + defineExtension({ + $initialEditorState, + dependencies: [RichTextExtension, TableExtension], + name: 'issue-7999', + onError: (error: Error) => { + throw error; + }, + }), + ); + editor.setRootElement(contentEditable); + onTestFinished(() => { + editor.setRootElement(null); + document.body.removeChild(container); + }); + contentEditable.focus(); + return {contentEditable, editor}; +} + +function expectCaretAfterTable(editor: LexicalEditor): void { + editor.read(() => { + const selection = $getSelection(); + assert($isRangeSelection(selection), 'Expected RangeSelection'); + expect(selection.isCollapsed()).toBe(true); + expect(selection.anchor.type).toBe('element'); + expect(selection.anchor.key).toBe($getRoot().getKey()); + expect(selection.anchor.offset).toBe($getRoot().getChildrenSize()); + }); +} + +test('the caret stops beneath a trailing table instead of cycling around it', async () => { + const {editor} = mount(() => { + $getRoot() + .clear() + .append($createTableNodeWithDimensions(2, 2, false)); + }); + editor.update(() => $getRoot().getLastChildOrThrow().selectEnd(), { + discrete: true, + }); + + // Leaves the last cell and lands on the block cursor beneath the table. + await userEvent.keyboard('{ArrowRight}'); + expectCaretAfterTable(editor); + + // Further presses have nowhere to go and must leave the caret alone. + await userEvent.keyboard('{ArrowRight}'); + expectCaretAfterTable(editor); + await userEvent.keyboard('{ArrowRight}'); + expectCaretAfterTable(editor); + + // Enter at that caret adds the paragraph after the table, not before it. + await userEvent.keyboard('{Enter}'); + expect( + editor.read(() => + $getRoot() + .getChildren() + .map(node => node.getType()), + ), + ).toEqual(['table', 'paragraph']); +}); + +test('the caret still moves past a table that is followed by a paragraph', async () => { + const {editor} = mount(() => { + $getRoot() + .clear() + .append( + $createTableNodeWithDimensions(2, 2, false), + $createParagraphNode().append($createTextNode('after')), + ); + }); + editor.update(() => $getRoot().getFirstChildOrThrow().selectEnd(), { + discrete: true, + }); + + await userEvent.keyboard('{ArrowRight}'); + await userEvent.keyboard('{ArrowRight}'); + + editor.read(() => { + const selection = $getSelection(); + assert($isRangeSelection(selection), 'Expected RangeSelection'); + expect(selection.anchor.getNode().getTextContent()).toBe('after'); + }); +}); diff --git a/packages/lexical-table/src/__tests__/unit/ClearTextKeepsParagraphState.test.ts b/packages/lexical-table/src/__tests__/unit/ClearTextKeepsParagraphState.test.ts new file mode 100644 index 00000000000..7186b6d50d3 --- /dev/null +++ b/packages/lexical-table/src/__tests__/unit/ClearTextKeepsParagraphState.test.ts @@ -0,0 +1,122 @@ +/** + * 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 {buildEditorFromExtensions} from '@lexical/extension'; +import { + $computeTableMapSkipCellCheck, + $createTableNodeWithDimensions, + $createTableSelectionFrom, + $isTableCellNode, + $isTableNode, + $isTableRowNode, + TableExtension, +} from '@lexical/table'; +import { + $createTextNode, + $getRoot, + $isParagraphNode, + $setSelection, + defineExtension, + KEY_BACKSPACE_COMMAND, +} from 'lexical'; +import {afterEach, assert, beforeEach, describe, expect, test} from 'vitest'; + +let container: HTMLDivElement; +let editor: ReturnType; + +beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + editor = buildEditorFromExtensions( + defineExtension({ + dependencies: [TableExtension], + name: 'clear-text-host', + }), + ); + // The table selection observer only registers its key handlers once the + // editor has a root element in the document. + editor.setRootElement(container); +}); + +afterEach(() => { + editor.dispose(); + document.body.removeChild(container); +}); + +function $firstRowParagraphState() { + const table = $getRoot().getFirstChild(); + assert($isTableNode(table), 'expected a TableNode at the root'); + const firstRow = table.getChildren().filter($isTableRowNode)[0]; + return firstRow + .getChildren() + .filter($isTableCellNode) + .map(cell => { + const paragraph = cell.getFirstChild(); + assert($isParagraphNode(paragraph), 'expected a ParagraphNode in a cell'); + return { + direction: paragraph.getDirection(), + format: paragraph.getFormatType(), + style: paragraph.getStyle(), + text: cell.getTextContent(), + }; + }); +} + +describe('clearing a table selection', () => { + test('keeps the paragraph format, style and direction of each cell', () => { + editor.update( + () => { + const table = $createTableNodeWithDimensions(2, 2, false); + $getRoot().clear().append(table); + const [map] = $computeTableMapSkipCellCheck(table, null, null); + for (const {cell} of map[0]) { + const paragraph = cell.getFirstChild(); + assert($isParagraphNode(paragraph), 'expected a ParagraphNode'); + paragraph + .setFormat('center') + .setDirection('rtl') + .setStyle('line-height: 2;'); + paragraph.append($createTextNode('text')); + } + $setSelection( + $createTableSelectionFrom(table, map[0][0].cell, map[0][1].cell), + ); + }, + {discrete: true}, + ); + + // Backspace over a multi-cell selection routes to TableObserver.$clearText. + // The selection covers only the first row, so the table itself survives. + editor.update( + () => { + editor.dispatchCommand( + KEY_BACKSPACE_COMMAND, + new KeyboardEvent('keydown', {key: 'Backspace'}), + ); + }, + {discrete: true}, + ); + + editor.read('latest', () => { + expect($firstRowParagraphState()).toEqual([ + { + direction: 'rtl', + format: 'center', + style: 'line-height: 2;', + text: '', + }, + { + direction: 'rtl', + format: 'center', + style: 'line-height: 2;', + text: '', + }, + ]); + }); + }); +}); diff --git a/packages/lexical-table/src/__tests__/unit/DeleteAllRowsSelection.test.ts b/packages/lexical-table/src/__tests__/unit/DeleteAllRowsSelection.test.ts new file mode 100644 index 00000000000..dda2f1a7374 --- /dev/null +++ b/packages/lexical-table/src/__tests__/unit/DeleteAllRowsSelection.test.ts @@ -0,0 +1,103 @@ +/** + * 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 {buildEditorFromExtensions} from '@lexical/extension'; +import { + $computeTableMapSkipCellCheck, + $createTableNodeWithDimensions, + $createTableSelectionFrom, + $deleteTableColumnAtSelection, + $deleteTableRowAtSelection, + $isTableNode, + $isTableSelection, + TableExtension, +} from '@lexical/table'; +import { + $createParagraphNode, + $createTextNode, + $getRoot, + $getSelection, + $isRangeSelection, + $setSelection, + defineExtension, +} from 'lexical'; +import {assert, describe, expect, test} from 'vitest'; + +function deleteWholeTable(kind: 'row' | 'column'): { + selectionType: string; + staleTableSelection: boolean; + rootChildren: number; +} { + using editor = buildEditorFromExtensions( + defineExtension({ + dependencies: [TableExtension], + name: `delete-all-${kind}-host`, + }), + ); + + editor.update( + () => { + const root = $getRoot().clear(); + root.append($createParagraphNode().append($createTextNode('before'))); + root.append($createTableNodeWithDimensions(2, 2, false)); + const table = root.getChildAtIndex(1); + assert($isTableNode(table), 'expected a TableNode'); + const [map] = $computeTableMapSkipCellCheck(table, null, null); + // Every cell selected, so deleting rows/columns empties the table. + $setSelection( + $createTableSelectionFrom(table, map[0][0].cell, map[1][1].cell), + ); + }, + {discrete: true}, + ); + + editor.update( + () => { + if (kind === 'row') { + $deleteTableRowAtSelection(); + } else { + $deleteTableColumnAtSelection(); + } + }, + {discrete: true}, + ); + + return editor.read(() => { + const selection = $getSelection(); + return { + rootChildren: $getRoot().getChildrenSize(), + + selectionType: $isRangeSelection(selection) + ? 'range' + : $isTableSelection(selection) + ? 'table' + : String(selection), + // A TableSelection left behind by the removed table reports isValid() + // false, because the cells it points at are gone. + staleTableSelection: $isTableSelection(selection) && !selection.isValid(), + }; + }); +} + +describe('deleting every row of a table', () => { + test('leaves the selection outside the removed table', () => { + expect(deleteWholeTable('row')).toEqual({ + rootChildren: 1, + selectionType: 'range', + staleTableSelection: false, + }); + }); + + test('matches what deleting every column already does', () => { + expect(deleteWholeTable('column')).toEqual({ + rootChildren: 1, + selectionType: 'range', + staleTableSelection: false, + }); + }); +}); diff --git a/packages/lexical-table/src/__tests__/unit/LexicalTableExtension.test.ts b/packages/lexical-table/src/__tests__/unit/LexicalTableExtension.test.ts index 8e255739f2b..4d59adb41ea 100644 --- a/packages/lexical-table/src/__tests__/unit/LexicalTableExtension.test.ts +++ b/packages/lexical-table/src/__tests__/unit/LexicalTableExtension.test.ts @@ -23,6 +23,7 @@ import { $isTableSelection, $mergeCells, INSERT_TABLE_COMMAND, + TableCellHeaderStates, type TableCellNode, TableExtension, type TableNode, @@ -516,6 +517,139 @@ describe('TableExtension', () => { ]); }); }); + + test('pasting a table carries the cell backgroundColor and verticalAlign', () => { + editor.update( + () => { + const root = $getRoot().clear(); + const table = $createTableNode(); + const row = $createTableRowNode(); + const cell = $createTableCellNode(); + cell.append($createParagraphNode().append($createTextNode('old'))); + row.append(cell); + table.append(row); + root.append(table); + cell.selectStart(); + }, + {discrete: true}, + ); + + editor.update( + () => { + const template = $createTableNode(); + const row = $createTableRowNode(); + const cell = $createTableCellNode() + .setBackgroundColor('rgb(255, 0, 0)') + .setVerticalAlign('middle'); + cell.append($createParagraphNode().append($createTextNode('new'))); + row.append(cell); + template.append(row); + const selection = $getSelection(); + assert(selection !== null, 'Expected a selection'); + $insertGeneratedNodes(editor, [template], selection); + }, + {discrete: true}, + ); + + editor.read('latest', () => { + const table = $getRoot().getFirstChild(); + assert($isTableNode(table), 'Expected table node'); + const row = table.getFirstChild(); + assert($isTableRowNode(row), 'Expected row node'); + const cell = row.getFirstChild(); + assert($isTableCellNode(cell), 'Expected cell node'); + expect(cell.getTextContent()).toBe('new'); + expect(cell.getBackgroundColor()).toBe('rgb(255, 0, 0)'); + expect(cell.getVerticalAlign()).toBe('middle'); + }); + }); + }); + + describe('$tableTransform padding', () => { + test('a short header row is padded with header cells', () => { + editor.update( + () => { + const root = $getRoot().clear(); + const table = $createTableNode(); + // First row is a header row but is one cell short. + const headerRow = $createTableRowNode(); + headerRow.append( + $createTableCellNode(TableCellHeaderStates.ROW).append( + $createParagraphNode().append($createTextNode('h')), + ), + ); + table.append(headerRow); + const bodyRow = $createTableRowNode(); + for (const text of ['a', 'b']) { + bodyRow.append( + $createTableCellNode().append( + $createParagraphNode().append($createTextNode(text)), + ), + ); + } + table.append(bodyRow); + root.append(table); + }, + {discrete: true}, + ); + + editor.read('latest', () => { + const table = $assertNodeType($getRoot().getFirstChild(), $isTableNode); + const rows = table.getChildren().filter($isTableRowNode); + const tags = rows.map(row => + row + .getChildren() + .filter($isTableCellNode) + .map(cell => cell.getTag()), + ); + expect(tags).toEqual([ + ['th', 'th'], + ['td', 'td'], + ]); + }); + }); + + test('a short body row is padded with body cells', () => { + editor.update( + () => { + const root = $getRoot().clear(); + const table = $createTableNode(); + const headerRow = $createTableRowNode(); + for (const text of ['h1', 'h2']) { + headerRow.append( + $createTableCellNode(TableCellHeaderStates.ROW).append( + $createParagraphNode().append($createTextNode(text)), + ), + ); + } + table.append(headerRow); + const bodyRow = $createTableRowNode(); + bodyRow.append( + $createTableCellNode().append( + $createParagraphNode().append($createTextNode('a')), + ), + ); + table.append(bodyRow); + root.append(table); + }, + {discrete: true}, + ); + + editor.read('latest', () => { + const table = $assertNodeType($getRoot().getFirstChild(), $isTableNode); + const rows = table.getChildren().filter($isTableRowNode); + const tags = rows.map(row => + row + .getChildren() + .filter($isTableCellNode) + .map(cell => cell.getTag()), + ); + expect(tags).toEqual([ + ['th', 'th'], + ['td', 'td'], + ]); + }); + }); }); describe('colWidths', () => { diff --git a/packages/lexical-table/src/__tests__/unit/LexicalTableUtils.test.ts b/packages/lexical-table/src/__tests__/unit/LexicalTableUtils.test.ts index e43cb35250d..e3392865e3f 100644 --- a/packages/lexical-table/src/__tests__/unit/LexicalTableUtils.test.ts +++ b/packages/lexical-table/src/__tests__/unit/LexicalTableUtils.test.ts @@ -21,6 +21,7 @@ import { $setTableColumnIsHeader, $setTableRowIsHeader, TableCellHeaderStates, + type TableCellNode, TableExtension, type TableNode, } from '@lexical/table'; @@ -1276,6 +1277,28 @@ describe('$insertTableColumnAtNode', () => { return tableMap.map(row => row.map(({cell}) => cell.getTextContent())); } + function $cell(text: string, rowSpan = 1, colSpan = 1): TableCellNode { + const cell = $createTableCellNode(); + cell.setRowSpan(rowSpan); + cell.setColSpan(colSpan); + return cell.append($createParagraphNode().append($createTextNode(text))); + } + + function $appendTable(rows: TableCellNode[][]): void { + const table = $createTableNode(); + for (const cells of rows) { + table.append($createTableRowNode().append(...cells)); + } + $getRoot().append(table); + } + + // Inserts a column after the cell that occupies the given grid coordinate. + function $insertColumnAfterGridCell(row: number, column: number): void { + const table = $assertNodeType($getRoot().getFirstChild(), $isTableNode); + const [tableMap] = $computeTableMapSkipCellCheck(table, null, null); + $insertTableColumnAtNode(tableMap[row][column].cell, true, false); + } + test('walks left by each visited cell colSpan when a row is spanned', () => { // Grid: // row0: [A0][X(rowSpan=2)][C(colSpan=2,rowSpan=2)][D0] @@ -1321,4 +1344,90 @@ describe('$insertTableColumnAtNode', () => { ]); }); }); + + test('inserts the new cell in the correct column for rows spanned by a rowSpan cell', () => { + // Grid: + // row0: [A(rowSpan=2), B] + // row1: [C] (grid col 0 is covered by A's rowSpan) + // row2: [D, E] + editor.update( + () => { + $appendTable([ + [$cell('A', 2), $cell('B')], + [$cell('C')], + [$cell('D'), $cell('E')], + ]); + }, + {discrete: true}, + ); + + editor.update(() => $insertColumnAfterGridCell(0, 0), {discrete: true}); + + editor.read('latest', () => { + const table = $assertNodeType($getRoot().getFirstChild(), $isTableNode); + // The inserted (empty) column must line up at grid column 1 in every row. + // Row 1 is entirely covered at column 0 by A's rowSpan, so the new cell + // has to be prepended before C rather than appended after it. + expect($getGridTexts(table)).toEqual([ + ['A', '', 'B'], + ['A', '', 'C'], + ['D', '', 'E'], + ]); + }); + }); + + test('does not prepend when a spanned row still owns a cell left of a colSpan > 1 anchor', () => { + // Grid: + // row0: [P, A(rowSpan=2), C(rowSpan=2, colSpan=2)] cols P=0 A=1 C=2-3 + // row1: [B] cols 1-3 are covered + editor.update( + () => { + $appendTable([ + [$cell('P'), $cell('A', 2), $cell('C', 2, 2)], + [$cell('B')], + ]); + }, + {discrete: true}, + ); + + // Insert after C, i.e. after grid column 3. + editor.update(() => $insertColumnAfterGridCell(0, 3), {discrete: true}); + + editor.read('latest', () => { + const table = $assertNodeType($getRoot().getFirstChild(), $isTableNode); + // Row 1 owns B at column 0, so the new cell belongs after B, not before. + expect($getGridTexts(table)).toEqual([ + ['P', 'A', 'C', 'C', ''], + ['B', 'A', 'C', 'C', ''], + ]); + }); + }); + + test('inserts after the last owned cell of a spanned row, not an earlier one', () => { + // Grid: + // row0: [A, B, V(rowSpan=2), X(rowSpan=2, colSpan=2)] cols V=2 X=3-4 + // row1: [C0, C1] cols 2-4 covered + editor.update( + () => { + $appendTable([ + [$cell('A'), $cell('B'), $cell('V', 2), $cell('X', 2, 2)], + [$cell('C0'), $cell('C1')], + ]); + }, + {discrete: true}, + ); + + // Insert after X, i.e. after grid column 4. + editor.update(() => $insertColumnAfterGridCell(0, 4), {discrete: true}); + + editor.read('latest', () => { + const table = $assertNodeType($getRoot().getFirstChild(), $isTableNode); + // C1 is the last cell row 1 owns before the insertion column, so the new + // cell goes after C1 rather than after C0. + expect($getGridTexts(table)).toEqual([ + ['A', 'B', 'V', 'X', 'X', ''], + ['C0', 'C1', 'V', 'X', 'X', ''], + ]); + }); + }); }); diff --git a/packages/lexical-table/src/__tests__/unit/ShiftArrowOffCornerAnchor.test.ts b/packages/lexical-table/src/__tests__/unit/ShiftArrowOffCornerAnchor.test.ts new file mode 100644 index 00000000000..167807e02e2 --- /dev/null +++ b/packages/lexical-table/src/__tests__/unit/ShiftArrowOffCornerAnchor.test.ts @@ -0,0 +1,103 @@ +/** + * 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 {buildEditorFromExtensions} from '@lexical/extension'; +import { + $computeTableMapSkipCellCheck, + $createTableNodeWithDimensions, + $createTableSelectionFrom, + $isTableNode, + $isTableSelection, + $mergeCells, + TableExtension, +} from '@lexical/table'; +import { + $getRoot, + $getSelection, + $setSelection, + defineExtension, + KEY_ARROW_DOWN_COMMAND, +} from 'lexical'; +import {afterEach, assert, beforeEach, describe, expect, test} from 'vitest'; + +let container: HTMLDivElement; +let editor: ReturnType; + +beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + editor = buildEditorFromExtensions( + defineExtension({ + dependencies: [TableExtension], + name: 'shift-arrow-corner-host', + }), + ); + // The arrow key handlers come from the table selection observer, which needs + // the editor to have a root element. + editor.setRootElement(container); +}); + +afterEach(() => { + editor.dispose(); + document.body.removeChild(container); +}); + +function $table() { + const table = $getRoot().getFirstChild(); + assert($isTableNode(table), 'expected a TableNode at the root'); + return table; +} + +describe('Shift+Arrow with an anchor that is not on a rect corner', () => { + test('extends the selection instead of throwing', () => { + editor.update( + () => { + const table = $createTableNodeWithDimensions(3, 3, false); + $getRoot().clear().append(table); + const [map] = $computeTableMapSkipCellCheck(table, null, null); + // Merge grid columns 0 and 1 of row 1. This cell straddles the left + // edge of the rect selected below, so $computeTableCellRectBoundary + // grows the rect out to column 0 — past the anchor, which then sits on + // no corner of it. + const merged = $mergeCells([map[1][0].cell, map[1][1].cell]); + assert(merged !== null, 'expected the cells to merge'); + }, + {discrete: true}, + ); + + editor.update( + () => { + const table = $table(); + const [map] = $computeTableMapSkipCellCheck(table, null, null); + // Anchor at (row 0, column 1), focus at (row 1, column 2). + $setSelection( + $createTableSelectionFrom(table, map[0][1].cell, map[1][2].cell), + ); + }, + {discrete: true}, + ); + + expect(() => + editor.update( + () => { + editor.dispatchCommand( + KEY_ARROW_DOWN_COMMAND, + new KeyboardEvent('keydown', {key: 'ArrowDown', shiftKey: true}), + ); + }, + {discrete: true}, + ), + ).not.toThrow(); + + editor.read('latest', () => { + const selection = $getSelection(); + assert($isTableSelection(selection), 'expected a TableSelection'); + expect(selection.isValid()).toBe(true); + }); + }); +}); diff --git a/packages/lexical-table/src/__tests__/unit/TableExportDirection.test.ts b/packages/lexical-table/src/__tests__/unit/TableExportDirection.test.ts new file mode 100644 index 00000000000..737fb20755d --- /dev/null +++ b/packages/lexical-table/src/__tests__/unit/TableExportDirection.test.ts @@ -0,0 +1,57 @@ +/** + * 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 {buildEditorFromExtensions, configExtension} from '@lexical/extension'; +import {$generateHtmlFromNodes} from '@lexical/html'; +import { + $createTableNodeWithDimensions, + $isTableNode, + TableExtension, +} from '@lexical/table'; +import {$getRoot, defineExtension} from 'lexical'; +import {assert, describe, expect, test} from 'vitest'; + +function buildEditor(hasHorizontalScroll: boolean) { + return buildEditorFromExtensions( + defineExtension({ + dependencies: [configExtension(TableExtension, {hasHorizontalScroll})], + name: 'table-export-dir-host', + theme: {tableScrollableWrapper: 'scroll-wrapper'}, + }), + ); +} + +function exportRtlTable(hasHorizontalScroll: boolean): string { + using editor = buildEditor(hasHorizontalScroll); + editor.update( + () => { + $getRoot() + .clear() + .append( + $createTableNodeWithDimensions(1, 1, false).setDirection('rtl'), + ); + }, + {discrete: true}, + ); + return editor.read(() => { + const table = $getRoot().getFirstChild(); + assert($isTableNode(table), 'expected a TableNode at the root'); + expect(table.getDirection()).toBe('rtl'); + return $generateHtmlFromNodes(editor); + }); +} + +describe('TableNode.exportDOM direction', () => { + test('exports dir with scrollable tables active', () => { + expect(exportRtlTable(true)).toContain('
'); + }); + + test('exports dir without scrollable tables', () => { + expect(exportRtlTable(false)).toContain('
'); + }); +}); diff --git a/packages/lexical-table/src/__tests__/unit/UnmergeCellStyles.test.ts b/packages/lexical-table/src/__tests__/unit/UnmergeCellStyles.test.ts new file mode 100644 index 00000000000..568efbe2944 --- /dev/null +++ b/packages/lexical-table/src/__tests__/unit/UnmergeCellStyles.test.ts @@ -0,0 +1,116 @@ +/** + * 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 {buildEditorFromExtensions} from '@lexical/extension'; +import { + $createTableNodeWithDimensions, + $isTableCellNode, + $isTableNode, + $isTableRowNode, + $mergeCells, + $unmergeCellNode, + type TableCellNode, + TableExtension, +} from '@lexical/table'; +import {$getRoot, defineExtension} from 'lexical'; +import {assert, describe, expect, test} from 'vitest'; + +function buildEditor() { + return buildEditorFromExtensions( + defineExtension({ + dependencies: [TableExtension], + name: 'unmerge-styles-host', + }), + ); +} + +function $cells(): TableCellNode[][] { + const table = $getRoot().getFirstChild(); + assert($isTableNode(table), 'expected a TableNode at the root'); + return table + .getChildren() + .filter($isTableRowNode) + .map(row => row.getChildren().filter($isTableCellNode)); +} + +describe('$unmergeCellNode keeps the cell presentation', () => { + test('the cells a merged cell splits into keep its backgroundColor and verticalAlign', () => { + using editor = buildEditor(); + + editor.update( + () => { + $getRoot() + .clear() + .append($createTableNodeWithDimensions(2, 2, false)); + const rows = $cells(); + const merged = $mergeCells([ + rows[0][0], + rows[0][1], + rows[1][0], + rows[1][1], + ]); + assert(merged !== null, 'expected the cells to merge'); + merged.setBackgroundColor('rgb(255, 0, 0)').setVerticalAlign('middle'); + }, + {discrete: true}, + ); + + editor.update( + () => { + const merged = $cells()[0][0]; + expect(merged.getColSpan()).toBe(2); + expect(merged.getRowSpan()).toBe(2); + $unmergeCellNode(merged); + }, + {discrete: true}, + ); + + editor.read(() => { + const rows = $cells(); + expect(rows.map(row => row.length)).toEqual([2, 2]); + for (const row of rows) { + for (const cell of row) { + expect(cell.getColSpan()).toBe(1); + expect(cell.getRowSpan()).toBe(1); + expect(cell.getBackgroundColor()).toBe('rgb(255, 0, 0)'); + expect(cell.getVerticalAlign()).toBe('middle'); + } + } + }); + }); + + test('an unstyled merged cell still splits into unstyled cells', () => { + using editor = buildEditor(); + + editor.update( + () => { + $getRoot() + .clear() + .append($createTableNodeWithDimensions(1, 2, false)); + const rows = $cells(); + const merged = $mergeCells([rows[0][0], rows[0][1]]); + assert(merged !== null, 'expected the cells to merge'); + }, + {discrete: true}, + ); + + editor.update( + () => { + $unmergeCellNode($cells()[0][0]); + }, + {discrete: true}, + ); + + editor.read(() => { + for (const cell of $cells()[0]) { + expect(cell.getBackgroundColor()).toBe(null); + expect(cell.getVerticalAlign()).toBe(undefined); + } + }); + }); +});