From 74f0b086a0bf148b157d3ce2d393bae8ab7e11f0 Mon Sep 17 00:00:00 2001 From: Oleksandr Trukhnii Date: Sun, 12 Jul 2026 06:18:20 +0200 Subject: [PATCH] [lexical-table][lexical-playground] Bug Fix: Auto-scroll while drag-selecting cells past the visible edge (#8822) Co-authored-by: Bob Ippolito --- .../__tests__/e2e/Tables.spec.mjs | 65 +++++ .../src/LexicalTableSelectionHelpers.ts | 268 +++++++++++++++--- 2 files changed, 297 insertions(+), 36 deletions(-) diff --git a/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs b/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs index a2d035afed0..734f6fcc96b 100644 --- a/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs @@ -9326,6 +9326,71 @@ test.describe('Tables', () => { ).toHaveCount(0); }); + test(`Drag-selecting to the edge of a scrollable table auto-scrolls it #7153`, async ({ + page, + isPlainText, + isCollab, + }) => { + test.skip(isPlainText || isCollab); + // The horizontal scroll wrapper only exists when scrollable tables are on. + test.skip(!IS_TABLE_HORIZONTAL_SCROLL); + await initialize({isCollab, page}); + await focusEditor(page); + + // A table with many columns overflows the editor width, so it becomes + // horizontally scrollable. + await insertTable(page, 3, 15); + + const wrapperSelector = 'div.PlaygroundEditorTheme__tableScrollableWrapper'; + const getScroll = () => + evaluate( + page, + selector => { + const wrapper = document.querySelector(selector); + return { + left: wrapper.scrollLeft, + max: wrapper.scrollWidth - wrapper.clientWidth, + }; + }, + wrapperSelector, + ); + + // Sanity check: the table is actually wider than its scroll container. + const {max} = await getScroll(); + expect(max).toBeGreaterThan(0); + + // Start a drag in a left-hand cell and hold the pointer just inside the + // right edge of the scroll container (within the auto-scroll edge zone), + // leaving the mouse button down. + const anchorBox = await selectorBoundingBox( + page, + `${nthTableSelector(1)} > tr:nth-of-type(2) > td:nth-child(2)`, + ); + const wrapperBox = await selectorBoundingBox(page, wrapperSelector); + const holdY = anchorBox.y + anchorBox.height / 2; + await dragMouse( + page, + anchorBox, + {height: 0, width: 0, x: wrapperBox.x + wrapperBox.width - 5, y: holdY}, + {mouseDown: true, mouseUp: false, slow: true}, + ); + + // While the pointer is held near the edge, the requestAnimationFrame loop + // scrolls the wrapper all the way to the end... + await expect + .poll(async () => (await getScroll()).left, {timeout: 5000}) + .toBeGreaterThanOrEqual(max - 1); + + // ...and the selection focus reaches the last column (index 14), which was + // initially off-screen — the behavior that regressed in #7153. + await assertTableSelectionCoordinates(page, { + anchor: {x: 1}, + focus: {x: 14}, + }); + + await page.mouse.up(); + }); + test.describe('shift-selection tests', () => { test('Range-select from above table into it selects the entire table', async ({ page, diff --git a/packages/lexical-table/src/LexicalTableSelectionHelpers.ts b/packages/lexical-table/src/LexicalTableSelectionHelpers.ts index 2db2c7a732d..7bccb326e32 100644 --- a/packages/lexical-table/src/LexicalTableSelectionHelpers.ts +++ b/packages/lexical-table/src/LexicalTableSelectionHelpers.ts @@ -124,6 +124,34 @@ const isPointerDownOnEvent = (event: PointerEvent) => { return (event.buttons & 1) === 1; }; +// Distance (px) from a scroll container edge at which drag auto-scroll kicks +// in, and the maximum scroll delta applied per animation frame. +const AUTO_SCROLL_EDGE_ZONE = 40; +const AUTO_SCROLL_MAX_STEP = 18; + +// Given a pointer position and the start/end edges of a scroll container on one +// axis, return the signed per-frame scroll delta: negative near the start edge, +// positive near the end edge, 0 while outside both edge zones. The delta ramps +// up the deeper the pointer is into the zone (and is capped once it reaches or +// passes the edge). +function autoScrollStep(pos: number, start: number, end: number): number { + const speed = (depth: number) => + Math.max( + 1, + Math.ceil( + (Math.min(AUTO_SCROLL_EDGE_ZONE, depth) / AUTO_SCROLL_EDGE_ZONE) * + AUTO_SCROLL_MAX_STEP, + ), + ); + if (pos <= start + AUTO_SCROLL_EDGE_ZONE) { + return -speed(start + AUTO_SCROLL_EDGE_ZONE - pos); + } + if (pos >= end - AUTO_SCROLL_EDGE_ZONE) { + return speed(pos - (end - AUTO_SCROLL_EDGE_ZONE)); + } + return 0; +} + export function isHTMLTableElement(el: unknown): el is HTMLTableElement { return isHTMLElement(el) && el.nodeName === 'TABLE'; } @@ -274,23 +302,217 @@ function $handleTableClick( }); } - const onPointerUp = () => { + let lastClientX = event.clientX; + let lastClientY = event.clientY; + let autoScrollRafId: number | null = null; + + const stopSelecting = () => { tableObserver.isSelecting = false; + if (autoScrollRafId !== null) { + editorWindow.cancelAnimationFrame(autoScrollRafId); + autoScrollRafId = null; + } editorWindow.removeEventListener('pointerup', onPointerUp); editorWindow.removeEventListener('pointermove', onPointerMove); }; + // Resolve the table cell under the given viewport coordinates via the + // table's own root so elementsFromPoint isn't retargeted; narrow with the + // type guards rather than casting so the detached-table case (Node) falls + // through to no hit-test. + const resolveFocusCellFromPoint = ( + clientX: number, + clientY: number, + ): TableDOMCell | null => { + const tableRoot = tableElement.getRootNode(); + if (!isDOMDocumentNode(tableRoot) && !isDOMShadowRoot(tableRoot)) { + return null; + } + for (const el of tableRoot.elementsFromPoint(clientX, clientY)) { + const cell = getDOMCellInTableFromTarget(tableElement, el); + if (cell) { + return cell; + } + } + return null; + }; + + const applyFocusCell = (focusCell: TableDOMCell, override: boolean) => { + // Fallback: set anchor if still missing (handles race conditions) + if (tableObserver.anchorCell === null) { + editor.update(() => { + tableObserver.$setAnchorCellForSelection(focusCell); + }); + } + if ( + tableObserver.focusCell === null || + focusCell.elem !== tableObserver.focusCell.elem + ) { + tableObservers.setNextFocus({ + focusCell, + override, + tableKey: tableObserver.tableNodeKey, + }); + editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined); + } + }; + + // Walk up from the table to the nearest ancestor that can actually scroll + // on the requested axis (the scrollable-tables wrapper for 'x'). Returns + // null when none is found, in which case the caller may fall back to the + // window. + const findScrollContainer = (axis: 'x' | 'y'): HTMLElement | null => { + for ( + let el: HTMLElement | null = tableElement.parentElement; + el; + el = el.parentElement + ) { + const canScroll = + axis === 'x' + ? el.scrollWidth > el.clientWidth + : el.scrollHeight > el.clientHeight; + if (canScroll) { + const style = editorWindow.getComputedStyle(el); + const overflow = axis === 'x' ? style.overflowX : style.overflowY; + if (overflow === 'auto' || overflow === 'scroll') { + return el; + } + } + } + return null; + }; + + // Scroll `container` (or the window when null) on `axis` if the pointer is + // within the edge zone. Returns whether it actually scrolled. + const scrollAxis = ( + container: HTMLElement | null, + pos: number, + axis: 'x' | 'y', + ): boolean => { + let start: number; + let end: number; + if (container === null) { + start = 0; + end = axis === 'x' ? editorWindow.innerWidth : editorWindow.innerHeight; + } else { + const rect = container.getBoundingClientRect(); + start = axis === 'x' ? rect.left : rect.top; + end = axis === 'x' ? rect.right : rect.bottom; + } + const step = autoScrollStep(pos, start, end); + if (step === 0) { + return false; + } + if (container === null) { + const before = + axis === 'x' ? editorWindow.scrollX : editorWindow.scrollY; + editorWindow.scrollBy(axis === 'x' ? step : 0, axis === 'x' ? 0 : step); + return ( + (axis === 'x' ? editorWindow.scrollX : editorWindow.scrollY) !== + before + ); + } + if (axis === 'x') { + const before = container.scrollLeft; + container.scrollLeft += step; + return container.scrollLeft !== before; + } + const before = container.scrollTop; + container.scrollTop += step; + return container.scrollTop !== before; + }; + + // Clamp the last pointer position into the visible bounds of the scroll + // container(s) so a pointer dragged past an edge still hit-tests onto the + // newly-revealed cell instead of empty space beyond the table. + const clampHitPoint = ( + hContainer: HTMLElement | null, + vContainer: HTMLElement | null, + ): [number, number] => { + let x = lastClientX; + let y = lastClientY; + if (hContainer === null) { + x = Math.min(Math.max(x, 1), editorWindow.innerWidth - 1); + } else { + const rect = hContainer.getBoundingClientRect(); + x = Math.min(Math.max(x, rect.left + 1), rect.right - 1); + } + if (vContainer === null) { + y = Math.min(Math.max(y, 1), editorWindow.innerHeight - 1); + } else { + const rect = vContainer.getBoundingClientRect(); + y = Math.min(Math.max(y, rect.top + 1), rect.bottom - 1); + } + return [x, y]; + }; + + const isNearScrollEdge = (): boolean => { + const hContainer = findScrollContainer('x'); + if (hContainer !== null) { + const rect = hContainer.getBoundingClientRect(); + if (autoScrollStep(lastClientX, rect.left, rect.right) !== 0) { + return true; + } + } + const vContainer = findScrollContainer('y'); + const vStart = + vContainer === null ? 0 : vContainer.getBoundingClientRect().top; + const vEnd = + vContainer === null + ? editorWindow.innerHeight + : vContainer.getBoundingClientRect().bottom; + return autoScrollStep(lastClientY, vStart, vEnd) !== 0; + }; + + const tickAutoScroll = () => { + autoScrollRafId = null; + if (!tableObserver.isSelecting) { + return; + } + const hContainer = findScrollContainer('x'); + const vContainer = findScrollContainer('y'); + // Only the table's own wrapper scrolls horizontally; pages don't + // auto-scroll sideways. Vertically we fall back to the window. + const scrolledX = + hContainer !== null && scrollAxis(hContainer, lastClientX, 'x'); + const scrolledY = scrollAxis(vContainer, lastClientY, 'y'); + if (scrolledX || scrolledY) { + const [hitX, hitY] = clampHitPoint(hContainer, vContainer); + const focusCell = resolveFocusCellFromPoint(hitX, hitY); + if (focusCell) { + applyFocusCell(focusCell, false); + } + autoScrollRafId = editorWindow.requestAnimationFrame(tickAutoScroll); + } + }; + + const maybeStartAutoScroll = () => { + // Touch taps don't initiate table selection, so they shouldn't scroll. + if ( + autoScrollRafId !== null || + tableObserver.pointerType === 'touch' || + !isNearScrollEdge() + ) { + return; + } + autoScrollRafId = editorWindow.requestAnimationFrame(tickAutoScroll); + }; + + const onPointerUp = () => { + stopSelecting(); + }; + const onPointerMove = (moveEvent: PointerEvent) => { if (!isPointerDownOnEvent(moveEvent) && tableObserver.isSelecting) { - tableObserver.isSelecting = false; - editorWindow.removeEventListener('pointerup', onPointerUp); - editorWindow.removeEventListener('pointermove', onPointerMove); + stopSelecting(); return; } const moveTarget = getComposedEventTarget(moveEvent); if (!isDOMNode(moveTarget)) { return; } + lastClientX = moveEvent.clientX; + lastClientY = moveEvent.clientY; let focusCell: null | TableDOMCell = null; // In firefox the moveEvent.target may be captured so we must always // consult the coordinates #7245 @@ -298,43 +520,17 @@ function $handleTableClick( if (override) { focusCell = getDOMCellInTableFromTarget(tableElement, moveTarget); } else { - // Resolve via the table's own root so elementsFromPoint isn't - // retargeted; narrow with the type guards rather than casting so the - // detached-table case (Node) falls through to no hit-test. - const tableRoot = tableElement.getRootNode(); - if (!isDOMDocumentNode(tableRoot) && !isDOMShadowRoot(tableRoot)) { - return; - } - for (const el of tableRoot.elementsFromPoint( + focusCell = resolveFocusCellFromPoint( moveEvent.clientX, moveEvent.clientY, - )) { - focusCell = getDOMCellInTableFromTarget(tableElement, el); - if (focusCell) { - break; - } - } + ); } if (focusCell) { - const anchorCell = focusCell; - // Fallback: set anchor if still missing (handles race conditions) - if (tableObserver.anchorCell === null) { - editor.update(() => { - tableObserver.$setAnchorCellForSelection(anchorCell); - }); - } - if ( - tableObserver.focusCell === null || - focusCell.elem !== tableObserver.focusCell.elem - ) { - tableObservers.setNextFocus({ - focusCell, - override, - tableKey: tableObserver.tableNodeKey, - }); - editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined); - } + applyFocusCell(focusCell, override); } + // Keep the selection reachable when dragging toward/past an edge of a + // scrollable table (#7153). + maybeStartAutoScroll(); }; editorWindow.addEventListener( 'pointerup',