From ddb0a22504bedb121a3cbe0b7d3233704f0f2903 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 13 May 2026 16:54:45 +0100 Subject: [PATCH] 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. --- .../wysiwyg/lexical/core/LexicalSelection.ts | 7 ++++ .../lexical/table/LexicalCaptionNode.ts | 1 - .../table/LexicalTableSelectionHelpers.ts | 15 ++++++- resources/js/wysiwyg/utils/selection.ts | 42 +++++++++++++++++-- 4 files changed, 60 insertions(+), 5 deletions(-) diff --git a/resources/js/wysiwyg/lexical/core/LexicalSelection.ts b/resources/js/wysiwyg/lexical/core/LexicalSelection.ts index 2904867f0..73ce0edbb 100644 --- a/resources/js/wysiwyg/lexical/core/LexicalSelection.ts +++ b/resources/js/wysiwyg/lexical/core/LexicalSelection.ts @@ -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()); } diff --git a/resources/js/wysiwyg/lexical/table/LexicalCaptionNode.ts b/resources/js/wysiwyg/lexical/table/LexicalCaptionNode.ts index d9d83562c..5f2ef9a25 100644 --- a/resources/js/wysiwyg/lexical/table/LexicalCaptionNode.ts +++ b/resources/js/wysiwyg/lexical/table/LexicalCaptionNode.ts @@ -1,7 +1,6 @@ import { $createTextNode, DOMConversionMap, - DOMExportOutput, EditorConfig, ElementNode, LexicalEditor, diff --git a/resources/js/wysiwyg/lexical/table/LexicalTableSelectionHelpers.ts b/resources/js/wysiwyg/lexical/table/LexicalTableSelectionHelpers.ts index 6e5e5416f..7e2d93ad9 100644 --- a/resources/js/wysiwyg/lexical/table/LexicalTableSelectionHelpers.ts +++ b/resources/js/wysiwyg/lexical/table/LexicalTableSelectionHelpers.ts @@ -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 ); } diff --git a/resources/js/wysiwyg/utils/selection.ts b/resources/js/wysiwyg/utils/selection.ts index e6815e250..77aa902f0 100644 --- a/resources/js/wysiwyg/utils/selection.ts +++ b/resources/js/wysiwyg/utils/selection.ts @@ -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; @@ -300,4 +301,39 @@ export function $getDecoratorNodesInSelection(selection: BaseSelection | null): } return selection.getNodes().filter(node => $isDecoratorNode(node)); -} \ No newline at end of file +} + +/** + * 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; +}