Lexical: Made table cell up/down arrow nav smarter

Added logic to attempt to retain x position when navigating cells
up/down via arrow keys.
This commit is contained in:
Dan Brown
2026-05-13 16:54:45 +01:00
parent 9f4afac7bc
commit ddb0a22504
4 changed files with 60 additions and 5 deletions

View File

@@ -2219,6 +2219,13 @@ export function $createRangeSelection(): RangeSelection {
return new RangeSelection(anchor, focus, 0, '');
}
export function $createCollapsedRangeSelectionForNode(node: LexicalNode, offset: number = 0): RangeSelection {
const type = $isTextNode(node) ? 'text' : 'element';
const anchor = $createPoint(node.getKey(), offset, type);
const focus = $createPoint(node.getKey(), offset, type);
return new RangeSelection(anchor, focus, 0, '');
}
export function $createNodeSelection(): NodeSelection {
return new NodeSelection(new Set());
}

View File

@@ -1,7 +1,6 @@
import {
$createTextNode,
DOMConversionMap,
DOMExportOutput,
EditorConfig,
ElementNode,
LexicalEditor,

View File

@@ -72,6 +72,7 @@ import {$isTableRowNode} from './LexicalTableRowNode';
import {$isTableSelection} from './LexicalTableSelection';
import {$computeTableMap, $getNodeTriplet} from './LexicalTableUtils';
import {$selectOrCreateAdjacent} from "../../utils/nodes";
import {$selectNodeAtXPixelOffset} from "../../utils/selection";
const LEXICAL_ELEMENT_KEY = '__lexicalTableSelection';
@@ -1073,6 +1074,7 @@ const selectTableNodeInDirection = (
x: number,
y: number,
direction: Direction,
selectionOffset: number = -1
): boolean => {
const isForward = direction === 'forward';
@@ -1112,6 +1114,7 @@ const selectTableNodeInDirection = (
selectTableCellNode(
tableNode.getCellNodeFromCordsOrThrow(x, y - 1, tableObserver.table),
false,
selectionOffset,
);
} else {
$selectOrCreateAdjacent(tableNode, false);
@@ -1124,6 +1127,7 @@ const selectTableNodeInDirection = (
selectTableCellNode(
tableNode.getCellNodeFromCordsOrThrow(x, y + 1, tableObserver.table),
true,
selectionOffset,
);
} else {
$selectOrCreateAdjacent(tableNode, true);
@@ -1197,7 +1201,14 @@ function $isSelectionInTable(
return false;
}
function selectTableCellNode(tableCell: TableCellNode, fromStart: boolean) {
function selectTableCellNode(tableCell: TableCellNode, fromStart: boolean, selectionOffsetPixels : number = -1) {
if (selectionOffsetPixels !== -1) {
const selection = $selectNodeAtXPixelOffset(tableCell, selectionOffsetPixels, fromStart);
if (selection) {
return;
}
}
if (fromStart) {
tableCell.selectStart();
} else {
@@ -1491,12 +1502,14 @@ function $handleArrowKey(
tableObserver.setAnchorCellForSelection(cell);
tableObserver.setFocusCellForSelection(cell, true);
} else {
const selectionOffset = edgeSelectionRect.x - edgeRect.x;
return selectTableNodeInDirection(
tableObserver,
tableNode,
cords.x,
cords.y,
direction,
selectionOffset
);
}

View File

@@ -1,6 +1,6 @@
import {
$createNodeSelection,
$createParagraphNode, $createRangeSelection,
$createParagraphNode, $createRangeSelection, $getEditor, $getNearestNodeFromDOMNode,
$getRoot,
$getSelection, $isBlockElementNode, $isDecoratorNode,
$isElementNode, $isParagraphNode,
@@ -8,7 +8,7 @@ import {
$setSelection,
BaseSelection, DecoratorNode,
ElementNode, LexicalEditor,
LexicalNode,
LexicalNode, RangeSelection,
TextFormatType, TextNode
} from "lexical";
import {$getNearestBlockElementAncestorOrThrow} from "@lexical/utils";
@@ -18,6 +18,7 @@ import {$setBlocksType} from "@lexical/selection";
import {$getNearestNodeBlockParent, $getParentOfType, nodeHasAlignment} from "./nodes";
import {CommonBlockAlignment} from "lexical/nodes/common";
import {$isListItemNode} from "@lexical/list";
import {$createCollapsedRangeSelectionForNode} from "lexical/LexicalSelection";
const lastSelectionByEditor = new WeakMap<LexicalEditor, BaseSelection|null>;
@@ -300,4 +301,39 @@ export function $getDecoratorNodesInSelection(selection: BaseSelection | null):
}
return selection.getNodes().filter(node => $isDecoratorNode(node));
}
}
/**
* Attempt to select the given node at roughly the pixel offset, relative to the left of the node.
* Returns the range selection if a selection could be made.
* Returns null if no selection can be made.
*/
export function $selectNodeAtXPixelOffset(node: LexicalNode, pixelOffset: number, targetStart: boolean = true): RangeSelection|null {
const targetDOM = $getEditor().getElementByKey(node.getKey());
if (!targetDOM) {
return null;
}
const targetChild = targetDOM.children[targetStart ? 0 : targetDOM.children.length - 1] || targetDOM;
const targetBounds = targetChild.getBoundingClientRect();
const targetY = targetBounds[targetStart ? 'top' : 'bottom'] + (targetStart ? 1 : -1);
const targetX = targetBounds.x + pixelOffset;
// Temporary caretRangeFromPoint usage due to caretPositionFromPoint being only
// very recently supported in Safari
// To remove post 2026
const caretRange = document.caretRangeFromPoint?.(targetX, targetY);
const caret = document.caretPositionFromPoint?.(targetX, targetY)
?? (caretRange ? { offsetNode: caretRange.startContainer, offset: caretRange.startOffset } : undefined);
if (!caret) {
return null;
}
const targetNode = $getNearestNodeFromDOMNode(caret.offsetNode);
if (!targetNode) {
return null;
}
const rangeSelection = $createCollapsedRangeSelectionForNode(targetNode, caret.offset);
$setSelection(rangeSelection);
return rangeSelection;
}