Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,14 @@
import {
assertTableHTML as assertHTML,
click,
expect,
focusEditor,
getPageOrFrame,
html,
initialize,
insertTable,
mergeTableCells,
selectCellFromTableCoord,
selectCellsFromTableCords,
test,
toggleColumnHeader,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import {
$deleteTableRowAtSelection,
$getNodeTriplet,
$getTableCellNodeFromLexicalNode,
$getTableColumnIndexFromTableCellNode,
$getTableNodeFromLexicalNodeOrThrow,
$getTableRowIndexFromTableCellNode,
$insertTableColumnAtSelection,
Expand Down Expand Up @@ -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,
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
});
});
53 changes: 45 additions & 8 deletions packages/lexical-rich-text/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
19 changes: 15 additions & 4 deletions packages/lexical-table/src/LexicalTableNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <table> 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) {
Expand Down Expand Up @@ -647,10 +661,7 @@ export class TableNode extends ElementNode {
}
return tableElement;
},
element:
!isHTMLTableElement(element) && isHTMLElement(element)
? element.querySelector('table')
: element,
element: exportedElement,
};
}

Expand Down
11 changes: 10 additions & 1 deletion packages/lexical-table/src/LexicalTableObserver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import invariant from '@lexical/internal/invariant';
import {
$copyNode,
$createParagraphNode,
$createRangeSelection,
$createTextNode,
Expand Down Expand Up @@ -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);
Expand Down
12 changes: 10 additions & 2 deletions packages/lexical-table/src/LexicalTablePluginHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
import {
$createTableCellNode,
$isTableCellNode,
TableCellHeaderStates,
TableCellNode,
} from './LexicalTableCellNode';
import {
Expand Down Expand Up @@ -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);
}
Expand Down
Loading
Loading