From 017795bae8c7cfc3cef74f548f994cbccc93c063 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 18 Mar 2026 10:27:40 -0300 Subject: [PATCH 01/13] fix: use visual geometry for vertical arrow navigation across table rows Refactors `findAdjacentLineElement` to select the next line based on spatial position (center Y + closest X) rather than DOM order, so that ArrowUp/ArrowDown navigate within the same visual table column instead of jumping to DOM-adjacent cells. --- .../vertical-navigation.js | 230 +++++++++++++++--- .../vertical-navigation.test.js | 119 ++++++++- 2 files changed, 308 insertions(+), 41 deletions(-) diff --git a/packages/super-editor/src/extensions/vertical-navigation/vertical-navigation.js b/packages/super-editor/src/extensions/vertical-navigation/vertical-navigation.js index 6cda410e96..b24790dabd 100644 --- a/packages/super-editor/src/extensions/vertical-navigation/vertical-navigation.js +++ b/packages/super-editor/src/extensions/vertical-navigation/vertical-navigation.js @@ -253,7 +253,7 @@ function getAdjacentLineClientTarget(editor, coords, direction) { const caretY = coords.clientY + coords.height / 2; const currentLine = findLineElementAtPoint(doc, caretX, caretY); if (!currentLine) return null; - const adjacentLine = findAdjacentLineElement(currentLine, direction); + const adjacentLine = findAdjacentLineElement(currentLine, direction, caretX); if (!adjacentLine) return null; const pageEl = adjacentLine.closest?.(`.${DOM_CLASS_NAMES.PAGE}`); const pageIndex = pageEl ? Number(pageEl.dataset.pageIndex ?? 'NaN') : null; @@ -327,52 +327,31 @@ function findLineElementAtPoint(doc, x, y) { } /** - * Locates the next or previous line element across fragments/pages. + * Locates the visually adjacent line element across fragments/pages. * @param {Element} currentLine * @param {number} direction -1 for up, 1 for down. + * @param {number} caretX * @returns {Element | null} */ -function findAdjacentLineElement(currentLine, direction) { - const lineClass = DOM_CLASS_NAMES.LINE; - const fragmentClass = DOM_CLASS_NAMES.FRAGMENT; +function findAdjacentLineElement(currentLine, direction, caretX) { const pageClass = DOM_CLASS_NAMES.PAGE; - const headerClass = 'superdoc-page-header'; - const footerClass = 'superdoc-page-footer'; - const fragment = currentLine.closest?.(`.${fragmentClass}`); const page = currentLine.closest?.(`.${pageClass}`); - if (!fragment || !page) return null; + if (!page) return null; - const lineEls = Array.from(fragment.querySelectorAll(`.${lineClass}`)); - const index = lineEls.indexOf(currentLine); - if (index !== -1) { - const nextInFragment = lineEls[index + direction]; - if (nextInFragment) return nextInFragment; - } + const currentLineMetrics = getLineMetrics(currentLine); + if (!currentLineMetrics) return null; - const fragments = Array.from(page.querySelectorAll(`.${fragmentClass}`)).filter((frag) => { - const parent = frag.closest?.(`.${headerClass}, .${footerClass}`); - return !parent; - }); - const fragmentIndex = fragments.indexOf(fragment); - if (fragmentIndex !== -1) { - const nextFragment = fragments[fragmentIndex + direction]; - const fallbackLine = getEdgeLineFromFragment(nextFragment, direction); - if (fallbackLine) return fallbackLine; - } + const currentPageLines = getPageLineElements(page); + const adjacentOnCurrentPage = findClosestLineInDirection(currentPageLines, currentLine, direction, caretX); + if (adjacentOnCurrentPage) return adjacentOnCurrentPage; const pages = Array.from(page.parentElement?.querySelectorAll?.(`.${pageClass}`) ?? []); const pageIndex = pages.indexOf(page); if (pageIndex === -1) return null; const nextPage = pages[pageIndex + direction]; if (!nextPage) return null; - const pageFragments = Array.from(nextPage.querySelectorAll(`.${fragmentClass}`)).filter((frag) => { - const parent = frag.closest?.(`.${headerClass}, .${footerClass}`); - return !parent; - }); - if (direction > 0) { - return getEdgeLineFromFragment(pageFragments[0], direction); - } - return getEdgeLineFromFragment(pageFragments[pageFragments.length - 1], direction); + const nextPageLines = getPageLineElements(nextPage); + return findEdgeLineForPage(nextPageLines, direction, caretX); } /** @@ -431,14 +410,185 @@ export function resolvePositionAtGoalX(editor, pmStart, pmEnd, goalX) { } /** - * Returns the first or last line in a fragment, depending on direction. - * @param {Element | null | undefined} fragment + * Returns all non-header/footer line elements for a page. + * @param {Element} page + * @returns {Element[]} + */ +function getPageLineElements(page) { + const fragmentClass = DOM_CLASS_NAMES.FRAGMENT; + const lineClass = DOM_CLASS_NAMES.LINE; + const headerClass = 'superdoc-page-header'; + const footerClass = 'superdoc-page-footer'; + + return Array.from(page.querySelectorAll(`.${fragmentClass}`)) + .filter((fragment) => !fragment.closest?.(`.${headerClass}, .${footerClass}`)) + .flatMap((fragment) => Array.from(fragment.querySelectorAll(`.${lineClass}`))); +} + +/** + * Chooses the closest visual line in the requested direction. + * @param {Element[]} lineEls + * @param {Element} currentLine + * @param {number} direction + * @param {number} caretX + * @returns {Element | null} + */ +function findClosestLineInDirection(lineEls, currentLine, direction, caretX) { + const currentMetrics = getLineMetrics(currentLine); + if (!currentMetrics) return null; + + const directionalCandidates = lineEls + .filter((line) => line !== currentLine) + .map((line) => ({ line, metrics: getLineMetrics(line) })) + .filter(({ metrics }) => metrics && isLineInDirection(metrics.centerY, currentMetrics.centerY, direction)); + + if (directionalCandidates.length === 0) return null; + + const nearestVerticalDistance = directionalCandidates.reduce((minDistance, { metrics }) => { + const distance = Math.abs(metrics.centerY - currentMetrics.centerY); + return Math.min(minDistance, distance); + }, Infinity); + + const targetRowCenterY = directionalCandidates + .filter(({ metrics }) => + isWithinTolerance(Math.abs(metrics.centerY - currentMetrics.centerY), nearestVerticalDistance, 1), + ) + .reduce((bestCenterY, { metrics }) => { + if (bestCenterY == null) return metrics.centerY; + return direction > 0 ? Math.min(bestCenterY, metrics.centerY) : Math.max(bestCenterY, metrics.centerY); + }, null); + + if (!Number.isFinite(targetRowCenterY)) return null; + + const rowCandidates = directionalCandidates.filter(({ metrics }) => + isWithinTolerance(metrics.centerY, targetRowCenterY, getRowTolerance(currentMetrics, metrics)), + ); + + return chooseLineClosestToX( + rowCandidates.map(({ line, metrics }) => ({ line, metrics })), + caretX, + ); +} + +/** + * Chooses the first/last visual row on a page, then the line closest to caretX. + * @param {Element[]} lineEls * @param {number} direction + * @param {number} caretX * @returns {Element | null} */ -function getEdgeLineFromFragment(fragment, direction) { - if (!fragment) return null; - const lineEls = Array.from(fragment.querySelectorAll(`.${DOM_CLASS_NAMES.LINE}`)); - if (lineEls.length === 0) return null; - return direction > 0 ? lineEls[0] : lineEls[lineEls.length - 1]; +function findEdgeLineForPage(lineEls, direction, caretX) { + const candidates = lineEls.map((line) => ({ line, metrics: getLineMetrics(line) })).filter(({ metrics }) => metrics); + + if (candidates.length === 0) return null; + + const targetRowCenterY = candidates.reduce((edgeCenterY, { metrics }) => { + if (edgeCenterY == null) return metrics.centerY; + return direction > 0 ? Math.min(edgeCenterY, metrics.centerY) : Math.max(edgeCenterY, metrics.centerY); + }, null); + + if (!Number.isFinite(targetRowCenterY)) return null; + + const rowCandidates = candidates.filter(({ metrics }) => + isWithinTolerance(metrics.centerY, targetRowCenterY, Math.max(metrics.height / 2, 1)), + ); + + return chooseLineClosestToX(rowCandidates, caretX); +} + +/** + * Picks the line whose horizontal span is closest to the requested caret X. + * @param {{ line: Element, metrics: ReturnType }[]} candidates + * @param {number} caretX + * @returns {Element | null} + */ +function chooseLineClosestToX(candidates, caretX) { + if (candidates.length === 0) return null; + + let best = null; + for (const candidate of candidates) { + const horizontalDistance = getHorizontalDistanceToLine(candidate.metrics, caretX); + const centerDistance = Math.abs(candidate.metrics.centerX - caretX); + if ( + !best || + horizontalDistance < best.horizontalDistance || + (horizontalDistance === best.horizontalDistance && centerDistance < best.centerDistance) + ) { + best = { + line: candidate.line, + horizontalDistance, + centerDistance, + }; + } + } + + return best?.line ?? null; +} + +/** + * Reads the geometry used for visual row and column matching. + * @param {Element} line + * @returns {{ top: number, bottom: number, left: number, right: number, height: number, centerX: number, centerY: number } | null} + */ +function getLineMetrics(line) { + const rect = line?.getBoundingClientRect?.(); + if (!rect) return null; + + const { top, bottom, left, right, height, width } = rect; + if (![top, bottom, left, right, height, width].every(Number.isFinite)) return null; + + return { + top, + bottom, + left, + right, + height, + centerX: left + width / 2, + centerY: top + height / 2, + }; +} + +/** + * Returns whether a line center lies above or below the current line center. + * @param {number} lineCenterY + * @param {number} currentCenterY + * @param {number} direction + * @returns {boolean} + */ +function isLineInDirection(lineCenterY, currentCenterY, direction) { + const epsilon = 1; + return direction > 0 ? lineCenterY > currentCenterY + epsilon : lineCenterY < currentCenterY - epsilon; +} + +/** + * Returns whether two numeric values are within a tolerance. + * @param {number} value + * @param {number} expected + * @param {number} tolerance + * @returns {boolean} + */ +function isWithinTolerance(value, expected, tolerance) { + return Math.abs(value - expected) <= tolerance; +} + +/** + * Determines the Y tolerance for considering lines part of the same visual row. + * @param {{ height: number }} currentMetrics + * @param {{ height: number }} candidateMetrics + * @returns {number} + */ +function getRowTolerance(currentMetrics, candidateMetrics) { + return Math.max(Math.min(currentMetrics.height, candidateMetrics.height) / 2, 1); +} + +/** + * Returns the horizontal distance from the caret X to a line's bounds. + * @param {{ left: number, right: number }} metrics + * @param {number} caretX + * @returns {number} + */ +function getHorizontalDistanceToLine(metrics, caretX) { + if (caretX < metrics.left) return metrics.left - caretX; + if (caretX > metrics.right) return caretX - metrics.right; + return 0; } diff --git a/packages/super-editor/src/extensions/vertical-navigation/vertical-navigation.test.js b/packages/super-editor/src/extensions/vertical-navigation/vertical-navigation.test.js index a27d7dd80a..733da82109 100644 --- a/packages/super-editor/src/extensions/vertical-navigation/vertical-navigation.test.js +++ b/packages/super-editor/src/extensions/vertical-navigation/vertical-navigation.test.js @@ -37,9 +37,62 @@ const createDomStructure = () => { return { line1, line2 }; }; +const createTableLikeDomStructure = () => { + const page = document.createElement('div'); + page.className = DOM_CLASS_NAMES.PAGE; + page.dataset.pageIndex = '0'; + + const fragment = document.createElement('div'); + fragment.className = DOM_CLASS_NAMES.FRAGMENT; + page.appendChild(fragment); + + const lines = [ + { text: 'Before', top: 360, left: 120, width: 280, pmStart: 1, pmEnd: 7 }, + { text: 'Here', top: 400, left: 120, width: 90, pmStart: 10, pmEnd: 14 }, + { text: 'Is', top: 400, left: 320, width: 90, pmStart: 20, pmEnd: 22 }, + { text: 'a', top: 400, left: 520, width: 90, pmStart: 30, pmEnd: 31 }, + { text: 'table', top: 430, left: 120, width: 90, pmStart: 40, pmEnd: 45 }, + { text: 'for', top: 430, left: 320, width: 90, pmStart: 50, pmEnd: 53 }, + { text: 'Testing', top: 430, left: 520, width: 90, pmStart: 60, pmEnd: 67 }, + { text: 'After', top: 470, left: 120, width: 280, pmStart: 70, pmEnd: 75 }, + ].map(({ text, top, left, width, pmStart, pmEnd }) => { + const line = document.createElement('div'); + line.className = DOM_CLASS_NAMES.LINE; + line.textContent = text; + line.dataset.pmStart = String(pmStart); + line.dataset.pmEnd = String(pmEnd); + vi.spyOn(line, 'getBoundingClientRect').mockReturnValue({ + top, + bottom: top + 20, + left, + right: left + width, + width, + height: 20, + x: left, + y: top, + toJSON: () => ({}), + }); + fragment.appendChild(line); + return line; + }); + + document.body.appendChild(page); + + return { + before: lines[0], + topLeft: lines[1], + topMiddle: lines[2], + topRight: lines[3], + bottomLeft: lines[4], + bottomMiddle: lines[5], + bottomRight: lines[6], + after: lines[7], + }; +}; + const createEnvironment = ({ presenting = true, selection = null, overrides = {} } = {}) => { const schema = createSchema(); - const doc = schema.node('doc', null, [schema.node('paragraph', null, [schema.text('hello world')])]); + const doc = schema.node('doc', null, [schema.node('paragraph', null, [schema.text('x'.repeat(200))])]); const initialSelection = selection ?? TextSelection.create(doc, 1, 1); const visibleHost = document.createElement('div'); @@ -244,6 +297,70 @@ describe('VerticalNavigation', () => { const dispatchedTr = view.dispatch.mock.calls[0][0]; expect(dispatchedTr.getMeta(VerticalNavigationPluginKey)).toMatchObject({ type: 'reset-goal-x' }); }); + + it('moves down within the same visual table column instead of DOM-adjacent cells', () => { + const { topMiddle, bottomMiddle } = createTableLikeDomStructure(); + document.elementsFromPoint = vi.fn(() => [topMiddle]); + + const { plugin, view, presentationEditor } = createEnvironment(); + presentationEditor.hitTest.mockReturnValue({ pos: 52 }); + presentationEditor.denormalizeClientPoint.mockReturnValue({ x: 350, y: 0 }); + + const handled = plugin.props.handleKeyDown(view, { key: 'ArrowDown', shiftKey: false }); + + expect(handled).toBe(true); + expect(presentationEditor.hitTest).toHaveBeenCalledWith(350, 440); + expect(view.state.selection.head).toBe(52); + expect(bottomMiddle.dataset.pmStart).toBe('50'); + }); + + it('moves up within the same visual table column instead of DOM-adjacent cells', () => { + const { topMiddle, bottomMiddle } = createTableLikeDomStructure(); + document.elementsFromPoint = vi.fn(() => [bottomMiddle]); + + const { plugin, view, presentationEditor } = createEnvironment(); + presentationEditor.hitTest.mockReturnValue({ pos: 21 }); + presentationEditor.denormalizeClientPoint.mockReturnValue({ x: 350, y: 0 }); + + const handled = plugin.props.handleKeyDown(view, { key: 'ArrowUp', shiftKey: false }); + + expect(handled).toBe(true); + expect(presentationEditor.hitTest).toHaveBeenCalledWith(350, 410); + expect(view.state.selection.head).toBe(21); + expect(topMiddle.dataset.pmStart).toBe('20'); + }); + + it('exits the table upward to the nearest visual line above', () => { + const { before, topMiddle } = createTableLikeDomStructure(); + document.elementsFromPoint = vi.fn(() => [topMiddle]); + + const { plugin, view, presentationEditor } = createEnvironment(); + presentationEditor.hitTest.mockReturnValue({ pos: 3 }); + presentationEditor.denormalizeClientPoint.mockReturnValue({ x: 180, y: 0 }); + + const handled = plugin.props.handleKeyDown(view, { key: 'ArrowUp', shiftKey: false }); + + expect(handled).toBe(true); + expect(presentationEditor.hitTest).toHaveBeenCalledWith(180, 370); + expect(view.state.selection.head).toBe(3); + expect(before.dataset.pmStart).toBe('1'); + }); + + it('exits the table downward to the nearest visual line below', () => { + const { after, bottomMiddle } = createTableLikeDomStructure(); + document.elementsFromPoint = vi.fn(() => [bottomMiddle]); + + const { plugin, view, presentationEditor } = createEnvironment(); + presentationEditor.hitTest.mockReturnValue({ pos: 72 }); + presentationEditor.denormalizeClientPoint.mockReturnValue({ x: 180, y: 0 }); + + const handled = plugin.props.handleKeyDown(view, { key: 'ArrowDown', shiftKey: false }); + + expect(handled).toBe(true); + expect(presentationEditor.hitTest).toHaveBeenCalledWith(180, 480); + expect(view.state.selection.head).toBe(72); + expect(after.dataset.pmStart).toBe('70'); + }); }); describe('resolvePositionAtGoalX', () => { From ee575a28834994d874c8845dc157c49a829e40ab Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 18 Mar 2026 11:42:59 -0300 Subject: [PATCH 02/13] test: add behavior test for ArrowRight exiting last table cell Adds a Playwright behavior test verifying that pressing ArrowRight at the end of the bottom-right table cell moves the cursor to the paragraph after the table. --- .../table-arrow-boundary-navigation.spec.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 tests/behavior/tests/tables/table-arrow-boundary-navigation.spec.ts diff --git a/tests/behavior/tests/tables/table-arrow-boundary-navigation.spec.ts b/tests/behavior/tests/tables/table-arrow-boundary-navigation.spec.ts new file mode 100644 index 0000000000..d60d2a0932 --- /dev/null +++ b/tests/behavior/tests/tables/table-arrow-boundary-navigation.spec.ts @@ -0,0 +1,28 @@ +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { test } from '../../fixtures/superdoc.js'; + +test.use({ config: { toolbar: 'full', showCaret: true, showSelection: true } }); + +const TEST_FILE = resolve(dirname(fileURLToPath(import.meta.url)), '../../test-data/tables/sd-2236-table-arrow-key-navigation.docx'); + +test('ArrowRight from the end of the bottom-right table cell exits to the paragraph after the table', async ({ + superdoc, +}) => { + await superdoc.loadDocument(TEST_FILE); + + const testingPos = await superdoc.findTextPos('Testing'); + const afterTablePos = await superdoc.findTextPos('This is more text after the table'); + + await superdoc.setTextSelection(testingPos + 'Testing'.length); + await superdoc.waitForStable(); + await superdoc.assertSelection(testingPos + 'Testing'.length); + + const hiddenEditor = superdoc.page.locator('[contenteditable="true"]').first(); + await hiddenEditor.focus(); + await superdoc.press('ArrowRight'); + await superdoc.waitForStable(); + + await superdoc.assertSelection(afterTablePos); +}); From 94e08aaed04ea3d808c745f0ba05f77094d4e6a6 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 18 Mar 2026 12:20:51 -0300 Subject: [PATCH 03/13] fix: add horizontal arrow key navigation into and out of tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the `tableBoundaryNavigation` plugin that handles ArrowLeft/ArrowRight at table boundaries — exiting from the first/last cell and entering from an adjacent paragraph. Includes unit tests and a behavior test for ArrowLeft re-entry. --- .../src/extensions/table/table.js | 3 + .../tableHelpers/tableBoundaryNavigation.js | 341 ++++++++++++++++++ .../tableBoundaryNavigation.test.js | 176 +++++++++ .../table-arrow-boundary-navigation.spec.ts | 22 ++ 4 files changed, 542 insertions(+) create mode 100644 packages/super-editor/src/extensions/table/tableHelpers/tableBoundaryNavigation.js create mode 100644 packages/super-editor/src/extensions/table/tableHelpers/tableBoundaryNavigation.test.js diff --git a/packages/super-editor/src/extensions/table/table.js b/packages/super-editor/src/extensions/table/table.js index 5ef942c374..5a358ca20f 100644 --- a/packages/super-editor/src/extensions/table/table.js +++ b/packages/super-editor/src/extensions/table/table.js @@ -215,6 +215,7 @@ import { } from 'prosemirror-tables'; import { cellAround } from './tableHelpers/cellAround.js'; import { cellWrapping } from './tableHelpers/cellWrapping.js'; +import { createTableBoundaryNavigationPlugin } from './tableHelpers/tableBoundaryNavigation.js'; import { toggleHeaderRow as toggleHeaderRowCommand } from './tableHelpers/toggleHeaderRow.js'; import { resolveTable, @@ -1363,6 +1364,8 @@ export const Table = Node.create({ allowTableNodeSelection: this.options.allowTableNodeSelection, }), + createTableBoundaryNavigationPlugin(), + // Normalize table style on paste / setContent / insertContent. // Only tables explicitly marked with needsTableStyleNormalization // are normalized, so DOCX-imported tables with tableStyleId=null keep diff --git a/packages/super-editor/src/extensions/table/tableHelpers/tableBoundaryNavigation.js b/packages/super-editor/src/extensions/table/tableHelpers/tableBoundaryNavigation.js new file mode 100644 index 0000000000..cb9070bb72 --- /dev/null +++ b/packages/super-editor/src/extensions/table/tableHelpers/tableBoundaryNavigation.js @@ -0,0 +1,341 @@ +// @ts-check +import { Plugin, PluginKey, Selection, TextSelection } from 'prosemirror-state'; +import { TableMap } from 'prosemirror-tables'; + +const TABLE_CELL_ROLES = new Set(['cell', 'header_cell']); + +/** + * Finds the closest ancestor depth that matches the predicate. + * @param {import('prosemirror-model').ResolvedPos} $pos + * @param {(node: import('prosemirror-model').Node) => boolean} predicate + * @returns {number} + */ +function findAncestorDepth($pos, predicate) { + for (let depth = $pos.depth; depth > 0; depth -= 1) { + if (predicate($pos.node(depth))) return depth; + } + return -1; +} + +/** + * Finds the nearest run ancestor within a paragraph. + * @param {import('prosemirror-model').ResolvedPos} $pos + * @param {number} paragraphDepth + * @returns {number} + */ +function findRunDepthWithinParagraph($pos, paragraphDepth) { + for (let depth = $pos.depth; depth > paragraphDepth; depth -= 1) { + if ($pos.node(depth).type.name === 'run') return depth; + } + return -1; +} + +/** + * Returns true when the caret should be treated as being at the effective end + * of the paragraph for horizontal navigation purposes. + * + * This is run-aware: the end of the final run in the paragraph counts as the + * end of the text block even when the selection has not advanced to the raw + * paragraph boundary position yet. + * + * @param {import('prosemirror-model').ResolvedPos} $head + * @returns {boolean} + */ +export function isAtEffectiveParagraphEnd($head) { + const paragraphDepth = findAncestorDepth($head, (node) => node.type.name === 'paragraph'); + if (paragraphDepth < 0) return false; + + const paragraph = $head.node(paragraphDepth); + if (paragraph.content.size === 0) return true; + + if ($head.pos === $head.end(paragraphDepth)) return true; + + const runDepth = findRunDepthWithinParagraph($head, paragraphDepth); + if (runDepth < 0) return false; + if ($head.pos !== $head.end(runDepth)) return false; + + return $head.index(paragraphDepth) === paragraph.childCount - 1; +} + +/** + * Returns true when the caret should be treated as being at the effective start + * of the paragraph for horizontal navigation purposes. + * + * @param {import('prosemirror-model').ResolvedPos} $head + * @returns {boolean} + */ +export function isAtEffectiveParagraphStart($head) { + const paragraphDepth = findAncestorDepth($head, (node) => node.type.name === 'paragraph'); + if (paragraphDepth < 0) return false; + + const paragraph = $head.node(paragraphDepth); + if (paragraph.content.size === 0) return true; + + if ($head.pos === $head.start(paragraphDepth)) return true; + + const runDepth = findRunDepthWithinParagraph($head, paragraphDepth); + if (runDepth < 0) return false; + if ($head.pos !== $head.start(runDepth)) return false; + + return $head.index(paragraphDepth) === 0; +} + +/** + * Returns true when the current paragraph is the last paragraph inside the cell. + * @param {import('prosemirror-model').ResolvedPos} $head + * @param {number} cellDepth + * @returns {boolean} + */ +function isInLastParagraphOfCell($head, cellDepth) { + return $head.index(cellDepth) === $head.node(cellDepth).childCount - 1; +} + +/** + * Returns true when the current paragraph is the first paragraph inside the cell. + * @param {import('prosemirror-model').ResolvedPos} $head + * @param {number} cellDepth + * @returns {boolean} + */ +function isInFirstParagraphOfCell($head, cellDepth) { + return $head.index(cellDepth) === 0; +} + +/** + * Returns the table/cell context for a resolved position, or null when the + * position is outside a table cell. + * @param {import('prosemirror-model').ResolvedPos} $head + * @returns {{ cellDepth: number, cellStart: number, tableDepth: number, tableStart: number, tablePos: number, table: import('prosemirror-model').Node } | null} + */ +function getTableContext($head) { + const cellDepth = findAncestorDepth($head, (node) => TABLE_CELL_ROLES.has(node.type.spec.tableRole)); + if (cellDepth < 0) return null; + + const tableDepth = findAncestorDepth($head, (node) => node.type.spec.tableRole === 'table'); + if (tableDepth < 0) return null; + + const table = $head.node(tableDepth); + return { + cellDepth, + cellStart: $head.before(cellDepth), + tableDepth, + tableStart: $head.start(tableDepth), + tablePos: $head.before(tableDepth), + table, + }; +} + +/** + * Returns true when the current cell touches the bottom-right edge of the table. + * @param {ReturnType} context + * @returns {boolean} + */ +function isLastCellInTable(context) { + if (!context) return false; + const map = TableMap.get(context.table); + const rect = map.findCell(context.cellStart - context.tableStart); + return rect.right === map.width && rect.bottom === map.height; +} + +/** + * Returns true when the current cell touches the top-left edge of the table. + * @param {ReturnType} context + * @returns {boolean} + */ +function isFirstCellInTable(context) { + if (!context) return false; + const map = TableMap.get(context.table); + const rect = map.findCell(context.cellStart - context.tableStart); + return rect.left === 0 && rect.top === 0; +} + +/** + * Finds the first text position inside a node. + * @param {import('prosemirror-model').Node} node + * @param {number} nodePos + * @returns {number | null} + */ +function findFirstTextPosInNode(node, nodePos) { + if (node.isText) return nodePos; + for (let index = 0, offset = 0; index < node.childCount; index += 1) { + const child = node.child(index); + const childPos = nodePos + 1 + offset; + const found = findFirstTextPosInNode(child, childPos); + if (found != null) return found; + offset += child.nodeSize; + } + return null; +} + +/** + * Finds the last text position inside a node. + * @param {import('prosemirror-model').Node} node + * @param {number} nodePos + * @returns {number | null} + */ +function findLastTextPosInNode(node, nodePos) { + if (node.isText) return nodePos + (node.text?.length ?? 0); + for (let index = node.childCount - 1, offset = node.content.size; index >= 0; index -= 1) { + const child = node.child(index); + offset -= child.nodeSize; + const childPos = nodePos + 1 + offset; + const found = findLastTextPosInNode(child, childPos); + if (found != null) return found; + } + return null; +} + +/** + * Finds the first text position after a boundary, or null if no text node exists. + * @param {import('prosemirror-state').EditorState} state + * @param {number} boundaryPos + * @returns {number | null} + */ +function findFirstTextPosAfterBoundary(state, boundaryPos) { + const $boundary = state.doc.resolve(boundaryPos); + const nextNode = $boundary.nodeAfter; + if (!nextNode) return null; + return findFirstTextPosInNode(nextNode, boundaryPos); +} + +/** + * Finds the last text position before a boundary, or null if no text node exists. + * @param {import('prosemirror-state').EditorState} state + * @param {number} boundaryPos + * @returns {number | null} + */ +function findLastTextPosBeforeBoundary(state, boundaryPos) { + const $boundary = state.doc.resolve(boundaryPos); + const prevNode = $boundary.nodeBefore; + if (!prevNode) return null; + return findLastTextPosInNode(prevNode, boundaryPos - prevNode.nodeSize); +} + +/** + * Computes the selection to apply when a horizontal arrow key should exit a + * table from the first or last cell. Returns null when no custom handling is + * required and native/ProseMirror behavior should continue. + * + * @param {import('prosemirror-state').EditorState} state + * @param {-1 | 1} dir + * @returns {import('prosemirror-state').Selection | null} + */ +export function getTableBoundaryExitSelection(state, dir) { + const selection = state.selection; + if (!selection.empty) return null; + + const context = getTableContext(selection.$head); + if (!context) return null; + + if (dir > 0) { + if (!isInLastParagraphOfCell(selection.$head, context.cellDepth)) return null; + if (!isAtEffectiveParagraphEnd(selection.$head)) return null; + if (!isLastCellInTable(context)) return null; + + const targetPos = findFirstTextPosAfterBoundary(state, context.tablePos + context.table.nodeSize); + if (targetPos != null) { + return TextSelection.create(state.doc, targetPos); + } + return ( + Selection.findFrom(state.doc.resolve(context.tablePos + context.table.nodeSize), 1, true) ?? + Selection.near(state.doc.resolve(context.tablePos + context.table.nodeSize), 1) + ); + } + + if (!isInFirstParagraphOfCell(selection.$head, context.cellDepth)) return null; + if (!isAtEffectiveParagraphStart(selection.$head)) return null; + if (!isFirstCellInTable(context)) return null; + + const targetPos = findLastTextPosBeforeBoundary(state, context.tablePos); + if (targetPos != null) { + return TextSelection.create(state.doc, targetPos); + } + return ( + Selection.findFrom(state.doc.resolve(context.tablePos), -1, true) ?? + Selection.near(state.doc.resolve(context.tablePos), -1) + ); +} + +/** + * Computes the selection to apply when a horizontal arrow key should enter a + * table from an adjacent paragraph. Returns null when no custom handling is + * required and native/ProseMirror behavior should continue. + * + * @param {import('prosemirror-state').EditorState} state + * @param {-1 | 1} dir + * @returns {import('prosemirror-state').Selection | null} + */ +export function getAdjacentTableEntrySelection(state, dir) { + const selection = state.selection; + if (!selection.empty) return null; + + const $head = selection.$head; + const paragraphDepth = findAncestorDepth($head, (node) => node.type.name === 'paragraph'); + if (paragraphDepth < 0) return null; + + const boundaryPos = dir > 0 ? $head.end(paragraphDepth) + 1 : $head.start(paragraphDepth) - 1; + const adjacentNode = dir > 0 ? state.doc.resolve(boundaryPos).nodeAfter : state.doc.resolve(boundaryPos).nodeBefore; + + if (!adjacentNode || adjacentNode.type.spec.tableRole !== 'table') return null; + + if (dir > 0) { + if (!isAtEffectiveParagraphEnd($head)) return null; + const targetPos = findFirstTextPosInNode(adjacentNode, boundaryPos); + if (targetPos != null) { + return TextSelection.create(state.doc, targetPos); + } + return ( + Selection.findFrom(state.doc.resolve(boundaryPos), 1, true) ?? Selection.near(state.doc.resolve(boundaryPos), 1) + ); + } + + if (!isAtEffectiveParagraphStart($head)) return null; + const tablePos = boundaryPos - adjacentNode.nodeSize; + const targetPos = findLastTextPosInNode(adjacentNode, tablePos); + if (targetPos != null) { + return TextSelection.create(state.doc, targetPos); + } + return ( + Selection.findFrom(state.doc.resolve(tablePos + adjacentNode.nodeSize), -1, true) ?? + Selection.near(state.doc.resolve(tablePos + adjacentNode.nodeSize), -1) + ); +} + +/** + * Plugin key for horizontal table boundary navigation. + */ +export const TableBoundaryNavigationPluginKey = new PluginKey('tableBoundaryNavigation'); + +/** + * Creates a plugin that exits the table when ArrowLeft/ArrowRight is pressed at + * the effective start/end of the first/last cell. + * + * @returns {import('prosemirror-state').Plugin} + */ +export function createTableBoundaryNavigationPlugin() { + return new Plugin({ + key: TableBoundaryNavigationPluginKey, + props: { + /** + * @param {import('prosemirror-view').EditorView} view + * @param {KeyboardEvent} event + * @returns {boolean} + */ + handleKeyDown(view, event) { + if (event.defaultPrevented) return false; + if (event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) return false; + + const dir = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0; + if (!dir) return false; + + const nextSelection = + getTableBoundaryExitSelection(view.state, /** @type {-1 | 1} */ (dir)) ?? + getAdjacentTableEntrySelection(view.state, /** @type {-1 | 1} */ (dir)); + if (!nextSelection) return false; + + view.dispatch(view.state.tr.setSelection(nextSelection).scrollIntoView()); + event.preventDefault(); + return true; + }, + }, + }); +} diff --git a/packages/super-editor/src/extensions/table/tableHelpers/tableBoundaryNavigation.test.js b/packages/super-editor/src/extensions/table/tableHelpers/tableBoundaryNavigation.test.js new file mode 100644 index 0000000000..143c1bd0a7 --- /dev/null +++ b/packages/super-editor/src/extensions/table/tableHelpers/tableBoundaryNavigation.test.js @@ -0,0 +1,176 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { TextSelection } from 'prosemirror-state'; + +import { initTestEditor } from '@tests/helpers/helpers.js'; + +import { + getAdjacentTableEntrySelection, + getTableBoundaryExitSelection, + isAtEffectiveParagraphEnd, + isAtEffectiveParagraphStart, +} from './tableBoundaryNavigation.js'; + +const DOC = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'run', content: [{ type: 'text', text: 'This is some text before the table' }] }], + }, + { + type: 'table', + attrs: { + tableProperties: {}, + grid: [{ col: 1500 }, { col: 1500 }, { col: 1500 }], + }, + content: [ + { + type: 'tableRow', + content: [ + { + type: 'tableCell', + attrs: { colspan: 1, rowspan: 1, colwidth: [150] }, + content: [{ type: 'paragraph', content: [{ type: 'run', content: [{ type: 'text', text: 'Here' }] }] }], + }, + { + type: 'tableCell', + attrs: { colspan: 1, rowspan: 1, colwidth: [150] }, + content: [{ type: 'paragraph', content: [{ type: 'run', content: [{ type: 'text', text: 'Is' }] }] }], + }, + { + type: 'tableCell', + attrs: { colspan: 1, rowspan: 1, colwidth: [150] }, + content: [{ type: 'paragraph', content: [{ type: 'run', content: [{ type: 'text', text: 'a' }] }] }], + }, + ], + }, + { + type: 'tableRow', + content: [ + { + type: 'tableCell', + attrs: { colspan: 1, rowspan: 1, colwidth: [150] }, + content: [{ type: 'paragraph', content: [{ type: 'run', content: [{ type: 'text', text: 'table' }] }] }], + }, + { + type: 'tableCell', + attrs: { colspan: 1, rowspan: 1, colwidth: [150] }, + content: [{ type: 'paragraph', content: [{ type: 'run', content: [{ type: 'text', text: 'for' }] }] }], + }, + { + type: 'tableCell', + attrs: { colspan: 1, rowspan: 1, colwidth: [150] }, + content: [ + { + type: 'paragraph', + content: [{ type: 'run', content: [{ type: 'text', text: 'Testing' }] }], + }, + ], + }, + ], + }, + ], + }, + { + type: 'paragraph', + content: [{ type: 'run', content: [{ type: 'text', text: 'This is more text after the table' }] }], + }, + ], +}; + +function findTextPos(doc, search) { + let found = null; + doc.descendants((node, pos) => { + if (found != null) return false; + if (!node.isText || !node.text) return true; + const hit = node.text.indexOf(search); + if (hit !== -1) { + found = pos + hit; + return false; + } + return true; + }); + if (found == null) { + throw new Error(`Unable to find text "${search}"`); + } + return found; +} + +describe('tableBoundaryNavigation', () => { + let editor; + let doc; + let beforePos; + let herePos; + let isPos; + let testingPos; + let afterPos; + + beforeEach(() => { + ({ editor } = initTestEditor({ loadFromSchema: true, content: DOC })); + doc = editor.state.doc; + beforePos = findTextPos(doc, 'This is some text before the table'); + herePos = findTextPos(doc, 'Here'); + isPos = findTextPos(doc, 'Is'); + testingPos = findTextPos(doc, 'Testing'); + afterPos = findTextPos(doc, 'This is more text after the table'); + }); + + it('treats the end of the last run in a paragraph as the effective paragraph end', () => { + const endOfTesting = testingPos + 'Testing'.length; + const state = editor.state.apply(editor.state.tr.setSelection(TextSelection.create(doc, endOfTesting))); + + expect(isAtEffectiveParagraphEnd(state.selection.$head)).toBe(true); + }); + + it('treats the start of the first run in a paragraph as the effective paragraph start', () => { + const state = editor.state.apply(editor.state.tr.setSelection(TextSelection.create(doc, herePos))); + + expect(isAtEffectiveParagraphStart(state.selection.$head)).toBe(true); + }); + + it('does not treat an interior run boundary as the effective paragraph end', () => { + const endOfIs = isPos + 'Is'.length; + const state = editor.state.apply(editor.state.tr.setSelection(TextSelection.create(doc, endOfIs))); + + expect(isAtEffectiveParagraphEnd(state.selection.$head)).toBe(true); + expect(getTableBoundaryExitSelection(state, 1)).toBeNull(); + }); + + it('moves right from the end of the last cell to the paragraph after the table', () => { + const endOfTesting = testingPos + 'Testing'.length; + const state = editor.state.apply(editor.state.tr.setSelection(TextSelection.create(doc, endOfTesting))); + + const nextSelection = getTableBoundaryExitSelection(state, 1); + expect(nextSelection).not.toBeNull(); + expect(nextSelection.from).toBe(afterPos); + expect(nextSelection.to).toBe(afterPos); + }); + + it('moves left from the start of the first cell to the paragraph before the table', () => { + const state = editor.state.apply(editor.state.tr.setSelection(TextSelection.create(doc, herePos))); + + const nextSelection = getTableBoundaryExitSelection(state, -1); + expect(nextSelection).not.toBeNull(); + expect(nextSelection.from).toBe(beforePos + 'This is some text before the table'.length); + expect(nextSelection.to).toBe(beforePos + 'This is some text before the table'.length); + }); + + it('moves left from the start of the paragraph after the table back into the last table cell', () => { + const state = editor.state.apply(editor.state.tr.setSelection(TextSelection.create(doc, afterPos))); + + const nextSelection = getAdjacentTableEntrySelection(state, -1); + expect(nextSelection).not.toBeNull(); + expect(nextSelection.from).toBe(testingPos + 'Testing'.length); + expect(nextSelection.to).toBe(testingPos + 'Testing'.length); + }); + + it('moves right from the end of the paragraph before the table into the first table cell', () => { + const endOfBefore = beforePos + 'This is some text before the table'.length; + const state = editor.state.apply(editor.state.tr.setSelection(TextSelection.create(doc, endOfBefore))); + + const nextSelection = getAdjacentTableEntrySelection(state, 1); + expect(nextSelection).not.toBeNull(); + expect(nextSelection.from).toBe(herePos); + expect(nextSelection.to).toBe(herePos); + }); +}); diff --git a/tests/behavior/tests/tables/table-arrow-boundary-navigation.spec.ts b/tests/behavior/tests/tables/table-arrow-boundary-navigation.spec.ts index d60d2a0932..53886cbb51 100644 --- a/tests/behavior/tests/tables/table-arrow-boundary-navigation.spec.ts +++ b/tests/behavior/tests/tables/table-arrow-boundary-navigation.spec.ts @@ -26,3 +26,25 @@ test('ArrowRight from the end of the bottom-right table cell exits to the paragr await superdoc.assertSelection(afterTablePos); }); + +test('ArrowLeft from the paragraph after the table re-enters the bottom-right table cell', async ({ superdoc }) => { + await superdoc.loadDocument(TEST_FILE); + + const testingPos = await superdoc.findTextPos('Testing'); + const afterTablePos = await superdoc.findTextPos('This is more text after the table'); + + await superdoc.setTextSelection(testingPos + 'Testing'.length); + await superdoc.waitForStable(); + await superdoc.assertSelection(testingPos + 'Testing'.length); + + const hiddenEditor = superdoc.page.locator('[contenteditable="true"]').first(); + await hiddenEditor.focus(); + + await superdoc.press('ArrowRight'); + await superdoc.waitForStable(); + await superdoc.assertSelection(afterTablePos); + + await superdoc.press('ArrowLeft'); + await superdoc.waitForStable(); + await superdoc.assertSelection(testingPos + 'Testing'.length); +}); From 5c7541fe3d90be538f1c03452a755da123c27c87 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 18 Mar 2026 12:27:43 -0300 Subject: [PATCH 04/13] refactor: deduplicate forward/backward logic in table boundary navigation Extracts shared helpers (`findParagraphDepth`, `getCellRect`, `findSelectionNearBoundary`, `getDirectionHelpers`) to consolidate the mirrored forward/backward boundary navigation code paths. --- .../tableHelpers/tableBoundaryNavigation.js | 118 +++++++++++------- .../tableBoundaryNavigation.test.js | 2 +- 2 files changed, 76 insertions(+), 44 deletions(-) diff --git a/packages/super-editor/src/extensions/table/tableHelpers/tableBoundaryNavigation.js b/packages/super-editor/src/extensions/table/tableHelpers/tableBoundaryNavigation.js index cb9070bb72..368a12e577 100644 --- a/packages/super-editor/src/extensions/table/tableHelpers/tableBoundaryNavigation.js +++ b/packages/super-editor/src/extensions/table/tableHelpers/tableBoundaryNavigation.js @@ -30,6 +30,15 @@ function findRunDepthWithinParagraph($pos, paragraphDepth) { return -1; } +/** + * Returns the paragraph depth for a position, or -1 when outside a paragraph. + * @param {import('prosemirror-model').ResolvedPos} $pos + * @returns {number} + */ +function findParagraphDepth($pos) { + return findAncestorDepth($pos, (node) => node.type.name === 'paragraph'); +} + /** * Returns true when the caret should be treated as being at the effective end * of the paragraph for horizontal navigation purposes. @@ -42,7 +51,7 @@ function findRunDepthWithinParagraph($pos, paragraphDepth) { * @returns {boolean} */ export function isAtEffectiveParagraphEnd($head) { - const paragraphDepth = findAncestorDepth($head, (node) => node.type.name === 'paragraph'); + const paragraphDepth = findParagraphDepth($head); if (paragraphDepth < 0) return false; const paragraph = $head.node(paragraphDepth); @@ -65,7 +74,7 @@ export function isAtEffectiveParagraphEnd($head) { * @returns {boolean} */ export function isAtEffectiveParagraphStart($head) { - const paragraphDepth = findAncestorDepth($head, (node) => node.type.name === 'paragraph'); + const paragraphDepth = findParagraphDepth($head); if (paragraphDepth < 0) return false; const paragraph = $head.node(paragraphDepth); @@ -104,7 +113,7 @@ function isInFirstParagraphOfCell($head, cellDepth) { * Returns the table/cell context for a resolved position, or null when the * position is outside a table cell. * @param {import('prosemirror-model').ResolvedPos} $head - * @returns {{ cellDepth: number, cellStart: number, tableDepth: number, tableStart: number, tablePos: number, table: import('prosemirror-model').Node } | null} + * @returns {{ cellDepth: number, cellStart: number, tableStart: number, tablePos: number, table: import('prosemirror-model').Node } | null} */ function getTableContext($head) { const cellDepth = findAncestorDepth($head, (node) => TABLE_CELL_ROLES.has(node.type.spec.tableRole)); @@ -117,13 +126,22 @@ function getTableContext($head) { return { cellDepth, cellStart: $head.before(cellDepth), - tableDepth, tableStart: $head.start(tableDepth), tablePos: $head.before(tableDepth), table, }; } +/** + * Returns the current cell rectangle within the table map. + * @param {NonNullable>} context + * @returns {{ map: TableMap, rect: ReturnType }} + */ +function getCellRect(context) { + const map = TableMap.get(context.table); + return { map, rect: map.findCell(context.cellStart - context.tableStart) }; +} + /** * Returns true when the current cell touches the bottom-right edge of the table. * @param {ReturnType} context @@ -131,8 +149,7 @@ function getTableContext($head) { */ function isLastCellInTable(context) { if (!context) return false; - const map = TableMap.get(context.table); - const rect = map.findCell(context.cellStart - context.tableStart); + const { map, rect } = getCellRect(context); return rect.right === map.width && rect.bottom === map.height; } @@ -143,8 +160,7 @@ function isLastCellInTable(context) { */ function isFirstCellInTable(context) { if (!context) return false; - const map = TableMap.get(context.table); - const rect = map.findCell(context.cellStart - context.tableStart); + const { rect } = getCellRect(context); return rect.left === 0 && rect.top === 0; } @@ -210,6 +226,43 @@ function findLastTextPosBeforeBoundary(state, boundaryPos) { return findLastTextPosInNode(prevNode, boundaryPos - prevNode.nodeSize); } +/** + * Returns a nearby selection fallback around a boundary position. + * @param {import('prosemirror-state').EditorState} state + * @param {number} boundaryPos + * @param {-1 | 1} dir + * @returns {import('prosemirror-state').Selection} + */ +function findSelectionNearBoundary(state, boundaryPos, dir) { + return ( + Selection.findFrom(state.doc.resolve(boundaryPos), dir, true) ?? Selection.near(state.doc.resolve(boundaryPos), dir) + ); +} + +/** + * Returns direction-specific helpers for horizontal boundary navigation. + * @param {-1 | 1} dir + */ +function getDirectionHelpers(dir) { + if (dir > 0) { + return { + isAtParagraphBoundary: isAtEffectiveParagraphEnd, + isEdgeParagraphInCell: isInLastParagraphOfCell, + isEdgeCellInTable: isLastCellInTable, + findTextPosAcrossBoundary: findFirstTextPosAfterBoundary, + getTableBoundaryPos: (context) => context.tablePos + context.table.nodeSize, + }; + } + + return { + isAtParagraphBoundary: isAtEffectiveParagraphStart, + isEdgeParagraphInCell: isInFirstParagraphOfCell, + isEdgeCellInTable: isFirstCellInTable, + findTextPosAcrossBoundary: findLastTextPosBeforeBoundary, + getTableBoundaryPos: (context) => context.tablePos, + }; +} + /** * Computes the selection to apply when a horizontal arrow key should exit a * table from the first or last cell. Returns null when no custom handling is @@ -225,34 +278,17 @@ export function getTableBoundaryExitSelection(state, dir) { const context = getTableContext(selection.$head); if (!context) return null; + const helpers = getDirectionHelpers(dir); + if (!helpers.isEdgeParagraphInCell(selection.$head, context.cellDepth)) return null; + if (!helpers.isAtParagraphBoundary(selection.$head)) return null; + if (!helpers.isEdgeCellInTable(context)) return null; - if (dir > 0) { - if (!isInLastParagraphOfCell(selection.$head, context.cellDepth)) return null; - if (!isAtEffectiveParagraphEnd(selection.$head)) return null; - if (!isLastCellInTable(context)) return null; - - const targetPos = findFirstTextPosAfterBoundary(state, context.tablePos + context.table.nodeSize); - if (targetPos != null) { - return TextSelection.create(state.doc, targetPos); - } - return ( - Selection.findFrom(state.doc.resolve(context.tablePos + context.table.nodeSize), 1, true) ?? - Selection.near(state.doc.resolve(context.tablePos + context.table.nodeSize), 1) - ); - } - - if (!isInFirstParagraphOfCell(selection.$head, context.cellDepth)) return null; - if (!isAtEffectiveParagraphStart(selection.$head)) return null; - if (!isFirstCellInTable(context)) return null; - - const targetPos = findLastTextPosBeforeBoundary(state, context.tablePos); + const boundaryPos = helpers.getTableBoundaryPos(context); + const targetPos = helpers.findTextPosAcrossBoundary(state, boundaryPos); if (targetPos != null) { return TextSelection.create(state.doc, targetPos); } - return ( - Selection.findFrom(state.doc.resolve(context.tablePos), -1, true) ?? - Selection.near(state.doc.resolve(context.tablePos), -1) - ); + return findSelectionNearBoundary(state, boundaryPos, dir); } /** @@ -269,35 +305,31 @@ export function getAdjacentTableEntrySelection(state, dir) { if (!selection.empty) return null; const $head = selection.$head; - const paragraphDepth = findAncestorDepth($head, (node) => node.type.name === 'paragraph'); + const paragraphDepth = findParagraphDepth($head); if (paragraphDepth < 0) return null; + const helpers = getDirectionHelpers(dir); + if (!helpers.isAtParagraphBoundary($head)) return null; const boundaryPos = dir > 0 ? $head.end(paragraphDepth) + 1 : $head.start(paragraphDepth) - 1; - const adjacentNode = dir > 0 ? state.doc.resolve(boundaryPos).nodeAfter : state.doc.resolve(boundaryPos).nodeBefore; + const $boundary = state.doc.resolve(boundaryPos); + const adjacentNode = dir > 0 ? $boundary.nodeAfter : $boundary.nodeBefore; if (!adjacentNode || adjacentNode.type.spec.tableRole !== 'table') return null; if (dir > 0) { - if (!isAtEffectiveParagraphEnd($head)) return null; const targetPos = findFirstTextPosInNode(adjacentNode, boundaryPos); if (targetPos != null) { return TextSelection.create(state.doc, targetPos); } - return ( - Selection.findFrom(state.doc.resolve(boundaryPos), 1, true) ?? Selection.near(state.doc.resolve(boundaryPos), 1) - ); + return findSelectionNearBoundary(state, boundaryPos, 1); } - if (!isAtEffectiveParagraphStart($head)) return null; const tablePos = boundaryPos - adjacentNode.nodeSize; const targetPos = findLastTextPosInNode(adjacentNode, tablePos); if (targetPos != null) { return TextSelection.create(state.doc, targetPos); } - return ( - Selection.findFrom(state.doc.resolve(tablePos + adjacentNode.nodeSize), -1, true) ?? - Selection.near(state.doc.resolve(tablePos + adjacentNode.nodeSize), -1) - ); + return findSelectionNearBoundary(state, tablePos + adjacentNode.nodeSize, -1); } /** diff --git a/packages/super-editor/src/extensions/table/tableHelpers/tableBoundaryNavigation.test.js b/packages/super-editor/src/extensions/table/tableHelpers/tableBoundaryNavigation.test.js index 143c1bd0a7..4ea83c2092 100644 --- a/packages/super-editor/src/extensions/table/tableHelpers/tableBoundaryNavigation.test.js +++ b/packages/super-editor/src/extensions/table/tableHelpers/tableBoundaryNavigation.test.js @@ -128,7 +128,7 @@ describe('tableBoundaryNavigation', () => { expect(isAtEffectiveParagraphStart(state.selection.$head)).toBe(true); }); - it('does not treat an interior run boundary as the effective paragraph end', () => { + it('does not exit the table from a non-edge cell even when the caret is at paragraph end', () => { const endOfIs = isPos + 'Is'.length; const state = editor.state.apply(editor.state.tr.setSelection(TextSelection.create(doc, endOfIs))); From 01f1b74533c1168d4d44f594a1cf83856f334cc5 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 18 Mar 2026 14:46:41 -0300 Subject: [PATCH 05/13] fix: ensure trailing separator paragraph when inserting a table and place cursor in first cell Refactors `insertTable` to replace an empty paragraph at the insertion point, add a trailing separator paragraph when the table would otherwise be the last node, and place the selection inside the first cell. --- .../src/extensions/table/table.js | 136 +++++++++++++----- .../src/extensions/table/table.test.js | 53 +++++++ 2 files changed, 156 insertions(+), 33 deletions(-) diff --git a/packages/super-editor/src/extensions/table/table.js b/packages/super-editor/src/extensions/table/table.js index 5a358ca20f..8e2eeb49b6 100644 --- a/packages/super-editor/src/extensions/table/table.js +++ b/packages/super-editor/src/extensions/table/table.js @@ -231,15 +231,21 @@ import { * * @param {import('prosemirror-model').Node} doc * @param {number} pos - Absolute insertion position (between top-level blocks) + * @param {{ from?: number, to?: number }} [replaceRange] * @returns {{ before: boolean, after: boolean }} */ -function tableSeparatorNeeds(doc, pos) { - const $pos = doc.resolve(pos); - if ($pos.depth !== 0) return { before: false, after: false }; +function tableSeparatorNeeds(doc, pos, replaceRange = {}) { + const boundaryBefore = replaceRange.from ?? pos; + const boundaryAfter = replaceRange.to ?? pos; - const indexAfter = $pos.index(0); - const nodeAfter = indexAfter < doc.childCount ? doc.child(indexAfter) : null; - const nodeBefore = indexAfter > 0 ? doc.child(indexAfter - 1) : null; + const $before = doc.resolve(boundaryBefore); + const $after = doc.resolve(boundaryAfter); + if ($before.depth !== 0 || $after.depth !== 0) return { before: false, after: false }; + + const beforeIndex = $before.index(0); + const afterIndex = $after.index(0); + const nodeBefore = beforeIndex > 0 ? doc.child(beforeIndex - 1) : null; + const nodeAfter = afterIndex < doc.childCount ? doc.child(afterIndex) : null; return { before: nodeBefore?.type.name === 'table', @@ -247,6 +253,72 @@ function tableSeparatorNeeds(doc, pos) { }; } +/** + * Creates the separator paragraph used to keep top-level tables from being + * adjacent to another table or the document boundary. + * + * @param {import('prosemirror-model').Schema} schema + * @returns {import('prosemirror-model').Node | null} + */ +function createTableSeparatorParagraph(schema) { + const attrs = { sdBlockId: uuidv4(), paraId: generateDocxHexId() }; + return schema.nodes.paragraph.createAndFill(attrs); +} + +/** + * Inserts a top-level table, adding separator paragraphs before/after when + * required by the surrounding document structure. + * + * @param {import('prosemirror-state').Transaction} tr + * @param {import('prosemirror-model').Node} doc + * @param {number} pos + * @param {import('prosemirror-model').Node} tableNode + * @param {{ from?: number, to?: number }} [replaceRange] + * @returns {{ inserted: boolean, trailingParagraphPos: number | null }} + */ +function insertTopLevelTableWithSeparators(tr, doc, pos, tableNode, replaceRange = {}) { + const replaceFrom = replaceRange.from ?? pos; + const replaceTo = replaceRange.to ?? pos; + const sep = tableSeparatorNeeds(doc, pos, replaceRange); + if (!sep.before && !sep.after) { + tr.replaceWith(replaceFrom, replaceTo, tableNode); + return { inserted: true, trailingParagraphPos: null }; + } + + const nodes = []; + let trailingParagraphPos = null; + + if (sep.before) { + const before = createTableSeparatorParagraph(doc.type.schema); + if (!before) return { inserted: false, trailingParagraphPos: null }; + nodes.push(before); + } + + nodes.push(tableNode); + + if (sep.after) { + const after = createTableSeparatorParagraph(doc.type.schema); + if (!after) return { inserted: false, trailingParagraphPos: null }; + trailingParagraphPos = pos + nodes.reduce((size, node) => size + node.nodeSize, 0); + nodes.push(after); + } + + tr.replaceWith(replaceFrom, replaceTo, Fragment.from(nodes)); + return { inserted: true, trailingParagraphPos }; +} + +/** + * Returns a text position inside the first table cell. + * + * @param {number} tablePos + * @param {import('prosemirror-model').Node} tableNode + * @returns {number} + */ +function getFirstTableCellTextPos(tablePos, tableNode) { + const map = TableMap.get(tableNode); + return tablePos + 1 + map.map[0] + 2; +} + const IMPORT_CONTEXT_SELECTOR = '[data-superdoc-import="true"]'; const IMPORT_DEFAULT_TABLE_WIDTH_PCT = 5000; // OOXML percent units where 5000 == 100% @@ -648,7 +720,7 @@ export const Table = Node.create({ */ insertTable: ({ rows = 3, cols = 3, withHeaderRow = false, columnWidths = null } = {}) => - ({ tr, dispatch, editor }) => { + ({ tr, dispatch, editor, state }) => { const widths = columnWidths ?? computeColumnWidths(editor, cols); const resolved = normalizeNewTableAttrs(editor); @@ -662,13 +734,29 @@ export const Table = Node.create({ if (dispatch) { let offset = tr.selection.$from.end() + 1; - if (tr.selection.$from.parent?.type?.name === 'run') { - // If in a run, we need to insert after the parent paragraph - offset = tr.selection.$from.after(tr.selection.$from.depth - 1); + let replaceRange = undefined; + const paragraphDepth = + tr.selection.$from.parent?.type?.name === 'run' ? tr.selection.$from.depth - 1 : tr.selection.$from.depth; + const paragraph = tr.selection.$from.node(paragraphDepth); + const isTopLevelParagraph = paragraphDepth === 1; + const isEmptyParagraph = paragraph.type.name === 'paragraph' && paragraph.textContent === ''; + + if (isTopLevelParagraph && isEmptyParagraph) { + offset = tr.selection.$from.before(paragraphDepth); + replaceRange = { + from: tr.selection.$from.before(paragraphDepth), + to: tr.selection.$from.after(paragraphDepth), + }; + } else if (tr.selection.$from.parent?.type?.name === 'run') { + // If in a run, insert after the parent paragraph. + offset = tr.selection.$from.after(paragraphDepth); } - tr.replaceSelectionWith(node) - .scrollIntoView() - .setSelection(TextSelection.near(tr.doc.resolve(offset))); + + const { inserted } = insertTopLevelTableWithSeparators(tr, state.doc, offset, node, replaceRange); + if (!inserted) return false; + + const selectionPos = getFirstTableCellTextPos(offset, node); + tr.scrollIntoView().setSelection(TextSelection.near(tr.doc.resolve(selectionPos))); } return true; @@ -724,26 +812,8 @@ export const Table = Node.create({ const tableNode = tableType.createChecked(tableAttrs, rowNodes); if (dispatch) { - const sep = tableSeparatorNeeds(state.doc, pos); - const makeSep = () => { - const attrs = { sdBlockId: uuidv4(), paraId: generateDocxHexId() }; - return state.schema.nodes.paragraph.createAndFill(attrs); - }; - if (sep.before || sep.after) { - const nodes = []; - if (sep.before) { - const s = makeSep(); - if (s) nodes.push(s); - } - nodes.push(tableNode); - if (sep.after) { - const s = makeSep(); - if (s) nodes.push(s); - } - tr.insert(pos, Fragment.from(nodes)); - } else { - tr.insert(pos, tableNode); - } + const { inserted } = insertTopLevelTableWithSeparators(tr, state.doc, pos, tableNode); + if (!inserted) return false; tr.setMeta('inputType', 'programmatic'); if (tracked === true) tr.setMeta('forceTrackChanges', true); else if (tracked === false) tr.setMeta('skipTrackChanges', true); diff --git a/packages/super-editor/src/extensions/table/table.test.js b/packages/super-editor/src/extensions/table/table.test.js index e84f9be1d3..5b9f59ef1f 100644 --- a/packages/super-editor/src/extensions/table/table.test.js +++ b/packages/super-editor/src/extensions/table/table.test.js @@ -1173,6 +1173,59 @@ describe('Table commands', async () => { }); }); + describe('insertTable trailing separator paragraph', () => { + it('inserts table followed by a trailing paragraph when inserted at document end', async () => { + const { docx, media, mediaFiles, fonts } = cachedBlankDoc; + ({ editor } = initTestEditor({ content: docx, media, mediaFiles, fonts })); + + editor.commands.insertTable({ rows: 2, cols: 2 }); + + const doc = editor.state.doc; + let foundTable = false; + let nodeAfterTable = null; + for (let i = 0; i < doc.childCount; i++) { + if (doc.child(i).type.name === 'table' && !foundTable) { + foundTable = true; + if (i + 1 < doc.childCount) { + nodeAfterTable = doc.child(i + 1); + } + } + } + + expect(foundTable).toBe(true); + expect(nodeAfterTable).not.toBeNull(); + expect(nodeAfterTable.type.name).toBe('paragraph'); + }); + + it('places the selection in the first table cell after insertion', async () => { + const { docx, media, mediaFiles, fonts } = cachedBlankDoc; + ({ editor } = initTestEditor({ content: docx, media, mediaFiles, fonts })); + + editor.commands.insertTable({ rows: 2, cols: 2 }); + + const tablePos = findTablePos(editor.state.doc); + const table = editor.state.doc.nodeAt(tablePos); + const map = TableMap.get(table); + const firstCellTextPos = tablePos + 1 + map.map[0] + 2; + + const { $from } = editor.state.selection; + expect(editor.state.selection.from).toBe(firstCellTextPos); + expect($from.parent.type.name).toBe('paragraph'); + expect($from.node($from.depth - 1).type.spec.tableRole).toBe('cell'); + }); + + it('replaces the initial empty paragraph instead of keeping it before the table', async () => { + const { docx, media, mediaFiles, fonts } = cachedBlankDoc; + ({ editor } = initTestEditor({ content: docx, media, mediaFiles, fonts })); + + editor.commands.insertTable({ rows: 2, cols: 2 }); + + expect(editor.state.doc.child(0).type.name).toBe('table'); + expect(editor.state.doc.child(1).type.name).toBe('paragraph'); + expect(editor.state.doc.childCount).toBe(2); + }); + }); + describe('normalizeNewTableAttrs tblLook (SD-2086)', async () => { it('includes DEFAULT_TBL_LOOK in tableProperties when a style is resolved', async () => { const { docx, media, mediaFiles, fonts } = cachedBlankDoc; From 9b89c9329cf6fa495e4e8b11a40658ef59124405 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 18 Mar 2026 14:57:06 -0300 Subject: [PATCH 06/13] fix: block Backspace and Delete in the protected trailing paragraph after a final table Prevents the user from deleting the trailing empty paragraph that separates the last table from the document boundary, which would leave the table without an exit point for horizontal navigation. --- .../tableHelpers/tableBoundaryNavigation.js | 29 +++++ .../tableBoundaryNavigation.test.js | 112 +++++++++++++++++- 2 files changed, 139 insertions(+), 2 deletions(-) diff --git a/packages/super-editor/src/extensions/table/tableHelpers/tableBoundaryNavigation.js b/packages/super-editor/src/extensions/table/tableHelpers/tableBoundaryNavigation.js index 368a12e577..d066998f5a 100644 --- a/packages/super-editor/src/extensions/table/tableHelpers/tableBoundaryNavigation.js +++ b/packages/super-editor/src/extensions/table/tableHelpers/tableBoundaryNavigation.js @@ -263,6 +263,30 @@ function getDirectionHelpers(dir) { }; } +/** + * Returns true when the position is inside the protected trailing empty + * paragraph that follows the last table in the document. + * + * @param {import('prosemirror-state').EditorState} state + * @returns {boolean} + */ +export function isInProtectedTrailingTableParagraph(state) { + const selection = state.selection; + if (!selection.empty) return false; + + const $head = selection.$head; + const paragraphDepth = findParagraphDepth($head); + if (paragraphDepth !== 1) return false; + + const paragraph = $head.node(paragraphDepth); + if (paragraph.type.name !== 'paragraph' || paragraph.textContent !== '') return false; + + const paragraphIndex = $head.index(0); + if (paragraphIndex !== state.doc.childCount - 1 || paragraphIndex === 0) return false; + + return state.doc.child(paragraphIndex - 1)?.type.name === 'table'; +} + /** * Computes the selection to apply when a horizontal arrow key should exit a * table from the first or last cell. Returns null when no custom handling is @@ -356,6 +380,11 @@ export function createTableBoundaryNavigationPlugin() { if (event.defaultPrevented) return false; if (event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) return false; + if ((event.key === 'Backspace' || event.key === 'Delete') && isInProtectedTrailingTableParagraph(view.state)) { + event.preventDefault(); + return true; + } + const dir = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0; if (!dir) return false; diff --git a/packages/super-editor/src/extensions/table/tableHelpers/tableBoundaryNavigation.test.js b/packages/super-editor/src/extensions/table/tableHelpers/tableBoundaryNavigation.test.js index 4ea83c2092..bdf5632ce7 100644 --- a/packages/super-editor/src/extensions/table/tableHelpers/tableBoundaryNavigation.test.js +++ b/packages/super-editor/src/extensions/table/tableHelpers/tableBoundaryNavigation.test.js @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { TextSelection } from 'prosemirror-state'; import { initTestEditor } from '@tests/helpers/helpers.js'; @@ -6,8 +6,10 @@ import { initTestEditor } from '@tests/helpers/helpers.js'; import { getAdjacentTableEntrySelection, getTableBoundaryExitSelection, + isInProtectedTrailingTableParagraph, isAtEffectiveParagraphEnd, isAtEffectiveParagraphStart, + createTableBoundaryNavigationPlugin, } from './tableBoundaryNavigation.js'; const DOC = { @@ -78,6 +80,34 @@ const DOC = { ], }; +const DOC_WITH_PROTECTED_TRAILING_PARAGRAPH = { + type: 'doc', + content: [ + { + type: 'table', + attrs: { + tableProperties: {}, + grid: [{ col: 1500 }], + }, + content: [ + { + type: 'tableRow', + content: [ + { + type: 'tableCell', + attrs: { colspan: 1, rowspan: 1, colwidth: [150] }, + content: [{ type: 'paragraph', content: [{ type: 'run', content: [{ type: 'text', text: 'Cell' }] }] }], + }, + ], + }, + ], + }, + { + type: 'paragraph', + }, + ], +}; + function findTextPos(doc, search) { let found = null; doc.descendants((node, pos) => { @@ -104,7 +134,6 @@ describe('tableBoundaryNavigation', () => { let isPos; let testingPos; let afterPos; - beforeEach(() => { ({ editor } = initTestEditor({ loadFromSchema: true, content: DOC })); doc = editor.state.doc; @@ -173,4 +202,83 @@ describe('tableBoundaryNavigation', () => { expect(nextSelection.from).toBe(herePos); expect(nextSelection.to).toBe(herePos); }); + + it('detects the protected trailing empty paragraph after a final table', () => { + const setup = initTestEditor({ loadFromSchema: true, content: DOC_WITH_PROTECTED_TRAILING_PARAGRAPH }); + const protectedDoc = setup.editor.state.doc; + let trailingParagraphPos = null; + protectedDoc.descendants((node, pos) => { + if (node.type.name === 'paragraph' && node.textContent === '') { + trailingParagraphPos = pos; + return false; + } + return true; + }); + + const state = setup.editor.state.apply( + setup.editor.state.tr.setSelection(TextSelection.create(protectedDoc, trailingParagraphPos + 1)), + ); + + expect(isInProtectedTrailingTableParagraph(state)).toBe(true); + }); + + it('blocks Backspace inside the protected trailing paragraph', () => { + const setup = initTestEditor({ loadFromSchema: true, content: DOC_WITH_PROTECTED_TRAILING_PARAGRAPH }); + const protectedDoc = setup.editor.state.doc; + let protectedPos = null; + protectedDoc.descendants((node, pos) => { + if (node.type.name === 'paragraph' && node.textContent === '') { + protectedPos = pos; + return false; + } + return true; + }); + + setup.editor.view.dispatch( + setup.editor.state.tr.setSelection(TextSelection.create(protectedDoc, protectedPos + 1)), + ); + + const plugin = createTableBoundaryNavigationPlugin(); + const handled = plugin.props.handleKeyDown(setup.editor.view, { + key: 'Backspace', + defaultPrevented: false, + shiftKey: false, + altKey: false, + ctrlKey: false, + metaKey: false, + preventDefault: vi.fn(), + }); + + expect(handled).toBe(true); + }); + + it('blocks Delete inside the protected trailing paragraph', () => { + const setup = initTestEditor({ loadFromSchema: true, content: DOC_WITH_PROTECTED_TRAILING_PARAGRAPH }); + const protectedDoc = setup.editor.state.doc; + let protectedPos = null; + protectedDoc.descendants((node, pos) => { + if (node.type.name === 'paragraph' && node.textContent === '') { + protectedPos = pos; + return false; + } + return true; + }); + + setup.editor.view.dispatch( + setup.editor.state.tr.setSelection(TextSelection.create(protectedDoc, protectedPos + 1)), + ); + + const plugin = createTableBoundaryNavigationPlugin(); + const handled = plugin.props.handleKeyDown(setup.editor.view, { + key: 'Delete', + defaultPrevented: false, + shiftKey: false, + altKey: false, + ctrlKey: false, + metaKey: false, + preventDefault: vi.fn(), + }); + + expect(handled).toBe(true); + }); }); From 28bcf1210874e5fe815d1dcb4d906bbf0d8d1c43 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 18 Mar 2026 15:16:48 -0300 Subject: [PATCH 07/13] test: add behavior test for trailing paragraph protection after final table Adds a Playwright test verifying that Backspace and Delete are no-ops when the cursor is in the protected trailing paragraph following the last table in the document. --- ...able-trailing-paragraph-protection.spec.ts | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 tests/behavior/tests/tables/table-trailing-paragraph-protection.spec.ts diff --git a/tests/behavior/tests/tables/table-trailing-paragraph-protection.spec.ts b/tests/behavior/tests/tables/table-trailing-paragraph-protection.spec.ts new file mode 100644 index 0000000000..34fc32f92e --- /dev/null +++ b/tests/behavior/tests/tables/table-trailing-paragraph-protection.spec.ts @@ -0,0 +1,103 @@ +import { test, expect } from '../../fixtures/superdoc.js'; + +const DOC_WITH_PROTECTED_TRAILING_PARAGRAPH = { + type: 'doc', + content: [ + { + type: 'table', + attrs: { + tableProperties: {}, + grid: [{ col: 1500 }], + }, + content: [ + { + type: 'tableRow', + content: [ + { + type: 'tableCell', + attrs: { colspan: 1, rowspan: 1, colwidth: [150] }, + content: [{ type: 'paragraph', content: [{ type: 'run', content: [{ type: 'text', text: 'Cell' }] }] }], + }, + ], + }, + ], + }, + { + type: 'paragraph', + }, + ], +}; + +async function getProtectedTrailingParagraphState(page: import('@playwright/test').Page) { + return page.evaluate(() => { + const editor = (window as any).editor; + const doc = editor.state.doc; + const topLevel = []; + for (let i = 0; i < doc.childCount; i += 1) { + const child = doc.child(i); + topLevel.push({ + type: child.type.name, + textContent: child.textContent, + }); + } + + const trailingParagraphPos = + doc.childCount >= 2 && doc.child(doc.childCount - 1).type.name === 'paragraph' + ? doc.content.size - doc.child(doc.childCount - 1).nodeSize + 1 + : null; + + return { + topLevel, + selection: { + from: editor.state.selection.from, + to: editor.state.selection.to, + }, + trailingParagraphPos, + }; + }); +} + +test('Backspace and Delete do not remove the protected trailing paragraph after a final table', async ({ + superdoc, +}) => { + await superdoc.page.evaluate((content) => { + const editor = (window as any).editor; + const schema = editor.state.schema; + const nextDoc = schema.nodeFromJSON(content); + editor.view.dispatch(editor.state.tr.replaceWith(0, editor.state.doc.content.size, nextDoc.content)); + }, DOC_WITH_PROTECTED_TRAILING_PARAGRAPH); + + const hiddenEditor = superdoc.page.locator('[contenteditable="true"]').first(); + await hiddenEditor.focus(); + + const initial = await getProtectedTrailingParagraphState(superdoc.page); + expect(initial.topLevel).toEqual([ + { type: 'table', textContent: 'Cell' }, + { type: 'paragraph', textContent: '' }, + ]); + expect(initial.trailingParagraphPos).not.toBeNull(); + + await superdoc.setTextSelection(initial.trailingParagraphPos!); + await superdoc.waitForStable(); + await superdoc.assertSelection(initial.trailingParagraphPos!); + + await superdoc.press('Backspace'); + await superdoc.waitForStable(); + + const afterBackspace = await getProtectedTrailingParagraphState(superdoc.page); + expect(afterBackspace.topLevel).toEqual(initial.topLevel); + expect(afterBackspace.selection).toEqual({ + from: initial.trailingParagraphPos, + to: initial.trailingParagraphPos, + }); + + await superdoc.press('Delete'); + await superdoc.waitForStable(); + + const afterDelete = await getProtectedTrailingParagraphState(superdoc.page); + expect(afterDelete.topLevel).toEqual(initial.topLevel); + expect(afterDelete.selection).toEqual({ + from: initial.trailingParagraphPos, + to: initial.trailingParagraphPos, + }); +}); From 1fdfaf264d139a9f916cdf098450da2a7c8e1f06 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 18 Mar 2026 16:16:48 -0300 Subject: [PATCH 08/13] test: place cursor at right position after inserting table --- .../src/extensions/table/table.test.js | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/packages/super-editor/src/extensions/table/table.test.js b/packages/super-editor/src/extensions/table/table.test.js index 5b9f59ef1f..6cfba1bbbf 100644 --- a/packages/super-editor/src/extensions/table/table.test.js +++ b/packages/super-editor/src/extensions/table/table.test.js @@ -1214,6 +1214,49 @@ describe('Table commands', async () => { expect($from.node($from.depth - 1).type.spec.tableRole).toBe('cell'); }); + it('places the selection in the first table cell when sep.before is true', async () => { + const { docx, media, mediaFiles, fonts } = cachedBlankDoc; + ({ editor } = initTestEditor({ content: docx, media, mediaFiles, fonts })); + + // Insert a first table — produces [table, paragraph] + editor.commands.insertTable({ rows: 2, cols: 2 }); + + // The cursor is now inside the first table cell. Move it to the + // trailing empty paragraph so the next insertTable triggers sep.before. + const doc = editor.state.doc; + const lastChild = doc.child(doc.childCount - 1); + expect(lastChild.type.name).toBe('paragraph'); + const trailingParaPos = doc.content.size - lastChild.nodeSize + 1; + editor.view.dispatch(editor.state.tr.setSelection(TextSelection.near(doc.resolve(trailingParaPos)))); + + // Insert a second table from the trailing paragraph (previous sibling is a table → sep.before = true) + editor.commands.insertTable({ rows: 2, cols: 2 }); + + // Find the SECOND table + let tableCount = 0; + let secondTablePos = null; + editor.state.doc.descendants((node, pos) => { + if (node.type.name === 'table') { + tableCount++; + if (tableCount === 2) { + secondTablePos = pos; + return false; + } + } + return true; + }); + expect(secondTablePos).not.toBeNull(); + + const secondTable = editor.state.doc.nodeAt(secondTablePos); + const map = TableMap.get(secondTable); + const expectedPos = secondTablePos + 1 + map.map[0] + 2; + + const { $from } = editor.state.selection; + expect(editor.state.selection.from).toBe(expectedPos); + expect($from.parent.type.name).toBe('paragraph'); + expect($from.node($from.depth - 1).type.spec.tableRole).toBe('cell'); + }); + it('replaces the initial empty paragraph instead of keeping it before the table', async () => { const { docx, media, mediaFiles, fonts } = cachedBlankDoc; ({ editor } = initTestEditor({ content: docx, media, mediaFiles, fonts })); From 441fe09437db886c7627bccdf6ff92f398569b70 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 19 Mar 2026 17:54:08 -0300 Subject: [PATCH 09/13] fix: handle document-root selections (AllSelection, NodeSelection) in insertTable When the selection is at depth 0 (e.g. Ctrl+A or a selected top-level block like documentSection), $from.end() + 1 overflowed past doc.content.size, causing a RangeError in insertTopLevelTableWithSeparators. Guard on $from.depth === 0 and replace the selected range directly so the table is inserted correctly with a trailing separator paragraph and the cursor lands in the first cell. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/extensions/table/table.js | 46 +++++++----- .../src/extensions/table/table.test.js | 73 ++++++++++++++++++- 2 files changed, 101 insertions(+), 18 deletions(-) diff --git a/packages/super-editor/src/extensions/table/table.js b/packages/super-editor/src/extensions/table/table.js index 8e2eeb49b6..efb0c270e8 100644 --- a/packages/super-editor/src/extensions/table/table.js +++ b/packages/super-editor/src/extensions/table/table.js @@ -188,7 +188,7 @@ import { } from '../table-cell/helpers/legacyBorderMigration.js'; import { isInTable } from '@helpers/isInTable.js'; import { findParentNode } from '@helpers/findParentNode.js'; -import { TextSelection, Plugin, PluginKey } from 'prosemirror-state'; +import { NodeSelection, TextSelection, Plugin, PluginKey } from 'prosemirror-state'; import { isCellSelection } from './tableHelpers/isCellSelection.js'; import { addColumnBefore as originalAddColumnBefore, @@ -733,23 +733,35 @@ export const Table = Node.create({ const node = createTable(editor.schema, rows, cols, withHeaderRow, null, widths, tableAttrs); if (dispatch) { - let offset = tr.selection.$from.end() + 1; + let offset; let replaceRange = undefined; - const paragraphDepth = - tr.selection.$from.parent?.type?.name === 'run' ? tr.selection.$from.depth - 1 : tr.selection.$from.depth; - const paragraph = tr.selection.$from.node(paragraphDepth); - const isTopLevelParagraph = paragraphDepth === 1; - const isEmptyParagraph = paragraph.type.name === 'paragraph' && paragraph.textContent === ''; - - if (isTopLevelParagraph && isEmptyParagraph) { - offset = tr.selection.$from.before(paragraphDepth); - replaceRange = { - from: tr.selection.$from.before(paragraphDepth), - to: tr.selection.$from.after(paragraphDepth), - }; - } else if (tr.selection.$from.parent?.type?.name === 'run') { - // If in a run, insert after the parent paragraph. - offset = tr.selection.$from.after(paragraphDepth); + + if (tr.selection.$from.depth === 0) { + // Selection is at the document root (e.g. AllSelection via Ctrl+A, + // or NodeSelection on a top-level block). Replace the selected + // range with the new table. + offset = tr.selection.from; + replaceRange = { from: tr.selection.from, to: tr.selection.to }; + } else { + offset = tr.selection.$from.end() + 1; + const paragraphDepth = + tr.selection.$from.parent?.type?.name === 'run' + ? tr.selection.$from.depth - 1 + : tr.selection.$from.depth; + const paragraph = tr.selection.$from.node(paragraphDepth); + const isTopLevelParagraph = paragraphDepth === 1; + const isEmptyParagraph = paragraph.type.name === 'paragraph' && paragraph.textContent === ''; + + if (isTopLevelParagraph && isEmptyParagraph) { + offset = tr.selection.$from.before(paragraphDepth); + replaceRange = { + from: tr.selection.$from.before(paragraphDepth), + to: tr.selection.$from.after(paragraphDepth), + }; + } else if (tr.selection.$from.parent?.type?.name === 'run') { + // If in a run, insert after the parent paragraph. + offset = tr.selection.$from.after(paragraphDepth); + } } const { inserted } = insertTopLevelTableWithSeparators(tr, state.doc, offset, node, replaceRange); diff --git a/packages/super-editor/src/extensions/table/table.test.js b/packages/super-editor/src/extensions/table/table.test.js index 6cfba1bbbf..835f34b773 100644 --- a/packages/super-editor/src/extensions/table/table.test.js +++ b/packages/super-editor/src/extensions/table/table.test.js @@ -1,5 +1,5 @@ import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; -import { EditorState, TextSelection } from 'prosemirror-state'; +import { AllSelection, EditorState, NodeSelection, TextSelection } from 'prosemirror-state'; import { CellSelection, TableMap } from 'prosemirror-tables'; import { loadTestDataForEditorTests, initTestEditor } from '@tests/helpers/helpers.js'; import { createTable } from './tableHelpers/createTable.js'; @@ -1267,6 +1267,77 @@ describe('Table commands', async () => { expect(editor.state.doc.child(1).type.name).toBe('paragraph'); expect(editor.state.doc.childCount).toBe(2); }); + + it('does not throw when insertTable is called with a NodeSelection on a top-level block', async () => { + const { docx, media, mediaFiles, fonts } = cachedBlankDoc; + ({ editor } = initTestEditor({ content: docx, media, mediaFiles, fonts })); + + // Insert a documentSection (atom: true, group: 'block') to get a + // selectable top-level block node. When selected as a NodeSelection, + // $from.depth is 0 and $from.end() returns doc.content.size, which + // previously caused insertTable to compute an out-of-range offset. + const { schema } = editor.state; + const sectionNode = schema.nodes.documentSection.create(null, [schema.nodes.paragraph.create()]); + const { tr } = editor.state; + const insertPos = tr.selection.$from.before(1); + tr.insert(insertPos, sectionNode); + tr.setSelection(NodeSelection.create(tr.doc, insertPos)); + editor.view.dispatch(tr); + + expect(editor.state.selection).toBeInstanceOf(NodeSelection); + expect(editor.state.selection.$from.depth).toBe(0); + + // Inserting a table while a top-level node is selected should not throw + expect(() => editor.commands.insertTable({ rows: 2, cols: 2 })).not.toThrow(); + + // Verify a table was actually inserted + const tablePos = findTablePos(editor.state.doc); + expect(tablePos).not.toBeNull(); + + // Verify the cursor is inside the first table cell + const table = editor.state.doc.nodeAt(tablePos); + const map = TableMap.get(table); + const firstCellTextPos = tablePos + 1 + map.map[0] + 2; + + const { $from } = editor.state.selection; + expect(editor.state.selection.from).toBe(firstCellTextPos); + expect($from.parent.type.name).toBe('paragraph'); + expect($from.node($from.depth - 1).type.spec.tableRole).toBe('cell'); + }); + + it('places cursor in first cell and adds trailing paragraph when inserting table with AllSelection', async () => { + const { docx, media, mediaFiles, fonts } = cachedBlankDoc; + ({ editor } = initTestEditor({ content: docx, media, mediaFiles, fonts })); + + // Type some text so the paragraph is non-empty (simulates a real document) + editor.commands.insertContent('This is a test'); + + // Select all content (Ctrl+A equivalent) + editor.view.dispatch(editor.state.tr.setSelection(new AllSelection(editor.state.doc))); + expect(editor.state.selection).toBeInstanceOf(AllSelection); + + // Insert a table while everything is selected + editor.commands.insertTable({ rows: 2, cols: 2 }); + + // The table should be followed by a trailing separator paragraph + const doc = editor.state.doc; + const tablePos = findTablePos(doc); + expect(tablePos).not.toBeNull(); + const table = doc.nodeAt(tablePos); + const tableEndPos = tablePos + table.nodeSize; + const $afterTable = doc.resolve(tableEndPos); + const nodeAfterTable = $afterTable.nodeAfter; + expect(nodeAfterTable?.type.name).toBe('paragraph'); + + // The cursor should be in the first table cell, not the last + const map = TableMap.get(table); + const firstCellTextPos = tablePos + 1 + map.map[0] + 2; + + const { $from } = editor.state.selection; + expect(editor.state.selection.from).toBe(firstCellTextPos); + expect($from.parent.type.name).toBe('paragraph'); + expect($from.node($from.depth - 1).type.spec.tableRole).toBe('cell'); + }); }); describe('normalizeNewTableAttrs tblLook (SD-2086)', async () => { From 351173040dcc68b2c107b1c4d10bef2c8db740a4 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 19 Mar 2026 17:58:19 -0300 Subject: [PATCH 10/13] fix: behavior test execution --- .../tests/tables/table-arrow-boundary-navigation.spec.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/behavior/tests/tables/table-arrow-boundary-navigation.spec.ts b/tests/behavior/tests/tables/table-arrow-boundary-navigation.spec.ts index 53886cbb51..ec143e043b 100644 --- a/tests/behavior/tests/tables/table-arrow-boundary-navigation.spec.ts +++ b/tests/behavior/tests/tables/table-arrow-boundary-navigation.spec.ts @@ -1,3 +1,4 @@ +import fs from 'node:fs'; import { resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -5,7 +6,12 @@ import { test } from '../../fixtures/superdoc.js'; test.use({ config: { toolbar: 'full', showCaret: true, showSelection: true } }); -const TEST_FILE = resolve(dirname(fileURLToPath(import.meta.url)), '../../test-data/tables/sd-2236-table-arrow-key-navigation.docx'); +const TEST_FILE = resolve( + dirname(fileURLToPath(import.meta.url)), + '../../test-data/tables/sd-2236-table-arrow-key-navigation.docx', +); + +test.skip(!fs.existsSync(TEST_FILE), 'Test document not available — run pnpm corpus:pull'); test('ArrowRight from the end of the bottom-right table cell exits to the paragraph after the table', async ({ superdoc, From bfa3db88cc6b5abbd568cee19ad210479eb6e6f7 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 19 Mar 2026 18:03:21 -0300 Subject: [PATCH 11/13] fix: skip inline atom markers when detecting table boundary for arrow key navigation Paragraphs in imported documents can contain invisible inline markers (bookmarkStart, bookmarkEnd, permEnd, commentRangeEnd, etc.) adjacent to runs. The boundary detection functions treated these as meaningful content, preventing ArrowRight/ArrowLeft from exiting or entering the table when the cursor was at the edge of a run followed or preceded by such markers. Replace the strict last-child/first-child index check with allInlineMarkersBetween, which skips over inline non-run nodes that carry no text content. --- .../tableHelpers/tableBoundaryNavigation.js | 36 +++++- .../tableBoundaryNavigation.test.js | 110 ++++++++++++++++++ 2 files changed, 143 insertions(+), 3 deletions(-) diff --git a/packages/super-editor/src/extensions/table/tableHelpers/tableBoundaryNavigation.js b/packages/super-editor/src/extensions/table/tableHelpers/tableBoundaryNavigation.js index d066998f5a..3a9c927490 100644 --- a/packages/super-editor/src/extensions/table/tableHelpers/tableBoundaryNavigation.js +++ b/packages/super-editor/src/extensions/table/tableHelpers/tableBoundaryNavigation.js @@ -39,13 +39,39 @@ function findParagraphDepth($pos) { return findAncestorDepth($pos, (node) => node.type.name === 'paragraph'); } +/** + * Returns true when every sibling of the paragraph between `fromIndex` + * (inclusive) and `toIndex` (exclusive) is an invisible inline marker + * (e.g. bookmarkStart, bookmarkEnd, permEnd, commentRangeEnd). These are + * zero-width nodes the cursor should not stop at, so they should not + * prevent boundary detection. + * + * A node is considered an invisible marker when it is inline, not a run, + * and carries no text content. + * + * @param {import('prosemirror-model').Node} paragraph + * @param {number} fromIndex + * @param {number} toIndex + * @returns {boolean} + */ +function allInlineMarkersBetween(paragraph, fromIndex, toIndex) { + for (let i = fromIndex; i < toIndex; i += 1) { + const child = paragraph.child(i); + if (child.type.name === 'run') return false; + if (!child.isInline) return false; + if (child.textContent !== '') return false; + } + return true; +} + /** * Returns true when the caret should be treated as being at the effective end * of the paragraph for horizontal navigation purposes. * * This is run-aware: the end of the final run in the paragraph counts as the * end of the text block even when the selection has not advanced to the raw - * paragraph boundary position yet. + * paragraph boundary position yet. Trailing inline atoms (bookmarks, + * permission markers, etc.) are ignored. * * @param {import('prosemirror-model').ResolvedPos} $head * @returns {boolean} @@ -63,13 +89,16 @@ export function isAtEffectiveParagraphEnd($head) { if (runDepth < 0) return false; if ($head.pos !== $head.end(runDepth)) return false; - return $head.index(paragraphDepth) === paragraph.childCount - 1; + const runIndex = $head.index(paragraphDepth); + return allInlineMarkersBetween(paragraph, runIndex + 1, paragraph.childCount); } /** * Returns true when the caret should be treated as being at the effective start * of the paragraph for horizontal navigation purposes. * + * Leading inline atoms (bookmarks, permission markers, etc.) are ignored. + * * @param {import('prosemirror-model').ResolvedPos} $head * @returns {boolean} */ @@ -86,7 +115,8 @@ export function isAtEffectiveParagraphStart($head) { if (runDepth < 0) return false; if ($head.pos !== $head.start(runDepth)) return false; - return $head.index(paragraphDepth) === 0; + const runIndex = $head.index(paragraphDepth); + return allInlineMarkersBetween(paragraph, 0, runIndex); } /** diff --git a/packages/super-editor/src/extensions/table/tableHelpers/tableBoundaryNavigation.test.js b/packages/super-editor/src/extensions/table/tableHelpers/tableBoundaryNavigation.test.js index bdf5632ce7..b1df6efbda 100644 --- a/packages/super-editor/src/extensions/table/tableHelpers/tableBoundaryNavigation.test.js +++ b/packages/super-editor/src/extensions/table/tableHelpers/tableBoundaryNavigation.test.js @@ -108,6 +108,70 @@ const DOC_WITH_PROTECTED_TRAILING_PARAGRAPH = { ], }; +/** + * Same table layout as DOC, but the first cell has a leading bookmarkStart + * and the last cell has a trailing bookmarkEnd. This simulates imported + * documents where inline atom markers sit at paragraph edges. + */ +const DOC_WITH_INLINE_ATOMS = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'run', content: [{ type: 'text', text: 'Before' }] }], + }, + { + type: 'table', + attrs: { + tableProperties: {}, + grid: [{ col: 1500 }], + }, + content: [ + { + type: 'tableRow', + content: [ + { + type: 'tableCell', + attrs: { colspan: 1, rowspan: 1, colwidth: [150] }, + content: [ + { + type: 'paragraph', + content: [ + { type: 'bookmarkStart', attrs: { id: '0', name: 'bm1' } }, + { type: 'run', content: [{ type: 'text', text: 'First' }] }, + ], + }, + ], + }, + ], + }, + { + type: 'tableRow', + content: [ + { + type: 'tableCell', + attrs: { colspan: 1, rowspan: 1, colwidth: [150] }, + content: [ + { + type: 'paragraph', + content: [ + { type: 'run', content: [{ type: 'text', text: 'Last' }] }, + { type: 'bookmarkEnd', attrs: { id: '0' } }, + ], + }, + ], + }, + ], + }, + ], + }, + { + type: 'paragraph', + content: [{ type: 'run', content: [{ type: 'text', text: 'After' }] }], + }, + ], +}; + function findTextPos(doc, search) { let found = null; doc.descendants((node, pos) => { @@ -281,4 +345,50 @@ describe('tableBoundaryNavigation', () => { expect(handled).toBe(true); }); + + describe('inline atom markers at paragraph edges', () => { + let atomEditor; + let atomDoc; + + beforeEach(() => { + ({ editor: atomEditor } = initTestEditor({ loadFromSchema: true, content: DOC_WITH_INLINE_ATOMS })); + atomDoc = atomEditor.state.doc; + }); + + it('treats the end of the last run as paragraph end even with a trailing bookmarkEnd', () => { + const lastPos = findTextPos(atomDoc, 'Last'); + const endOfLast = lastPos + 'Last'.length; + const state = atomEditor.state.apply(atomEditor.state.tr.setSelection(TextSelection.create(atomDoc, endOfLast))); + + expect(isAtEffectiveParagraphEnd(state.selection.$head)).toBe(true); + }); + + it('treats the start of the first run as paragraph start even with a leading bookmarkStart', () => { + const firstPos = findTextPos(atomDoc, 'First'); + const state = atomEditor.state.apply(atomEditor.state.tr.setSelection(TextSelection.create(atomDoc, firstPos))); + + expect(isAtEffectiveParagraphStart(state.selection.$head)).toBe(true); + }); + + it('exits the table rightward from the last cell despite a trailing bookmarkEnd', () => { + const lastPos = findTextPos(atomDoc, 'Last'); + const endOfLast = lastPos + 'Last'.length; + const afterPos = findTextPos(atomDoc, 'After'); + const state = atomEditor.state.apply(atomEditor.state.tr.setSelection(TextSelection.create(atomDoc, endOfLast))); + + const nextSelection = getTableBoundaryExitSelection(state, 1); + expect(nextSelection).not.toBeNull(); + expect(nextSelection.from).toBe(afterPos); + }); + + it('exits the table leftward from the first cell despite a leading bookmarkStart', () => { + const firstPos = findTextPos(atomDoc, 'First'); + const beforeEnd = findTextPos(atomDoc, 'Before') + 'Before'.length; + const state = atomEditor.state.apply(atomEditor.state.tr.setSelection(TextSelection.create(atomDoc, firstPos))); + + const nextSelection = getTableBoundaryExitSelection(state, -1); + expect(nextSelection).not.toBeNull(); + expect(nextSelection.from).toBe(beforeEnd); + }); + }); }); From c82e383b272459b99a3ac7a77142b8398d421db6 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 19 Mar 2026 18:35:01 -0300 Subject: [PATCH 12/13] fix(test): drop image inside table cell instead of viewport center The trailing separator paragraph added after tables caused the synthetic drop at viewport center to land outside the cell. Target the rendered `.superdoc-table-fragment` element so the drop coordinates are inside the cell and getMaxContentSize() picks up the cell context. --- .../tests/tables/image-resize-in-cell.spec.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/tests/behavior/tests/tables/image-resize-in-cell.spec.ts b/tests/behavior/tests/tables/image-resize-in-cell.spec.ts index 99810561d1..3e210ced79 100644 --- a/tests/behavior/tests/tables/image-resize-in-cell.spec.ts +++ b/tests/behavior/tests/tables/image-resize-in-cell.spec.ts @@ -75,10 +75,10 @@ async function placeCursorInFirstTableCell(superdoc: SuperDocFixture): Promise Date: Fri, 20 Mar 2026 09:36:28 -0300 Subject: [PATCH 13/13] refactor: address PR review feedback - Remove unused NodeSelection import from table.js - Remove dead trailingParagraphPos from insertTopLevelTableWithSeparators - Remove redundant .map() in findClosestLineInDirection - Pass pre-computed currentMetrics instead of measuring the element twice --- .../src/extensions/table/table.js | 14 ++++++-------- .../vertical-navigation.js | 19 ++++++++++--------- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/packages/super-editor/src/extensions/table/table.js b/packages/super-editor/src/extensions/table/table.js index efb0c270e8..beb25689f1 100644 --- a/packages/super-editor/src/extensions/table/table.js +++ b/packages/super-editor/src/extensions/table/table.js @@ -188,7 +188,7 @@ import { } from '../table-cell/helpers/legacyBorderMigration.js'; import { isInTable } from '@helpers/isInTable.js'; import { findParentNode } from '@helpers/findParentNode.js'; -import { NodeSelection, TextSelection, Plugin, PluginKey } from 'prosemirror-state'; +import { TextSelection, Plugin, PluginKey } from 'prosemirror-state'; import { isCellSelection } from './tableHelpers/isCellSelection.js'; import { addColumnBefore as originalAddColumnBefore, @@ -274,7 +274,7 @@ function createTableSeparatorParagraph(schema) { * @param {number} pos * @param {import('prosemirror-model').Node} tableNode * @param {{ from?: number, to?: number }} [replaceRange] - * @returns {{ inserted: boolean, trailingParagraphPos: number | null }} + * @returns {{ inserted: boolean }} */ function insertTopLevelTableWithSeparators(tr, doc, pos, tableNode, replaceRange = {}) { const replaceFrom = replaceRange.from ?? pos; @@ -282,15 +282,14 @@ function insertTopLevelTableWithSeparators(tr, doc, pos, tableNode, replaceRange const sep = tableSeparatorNeeds(doc, pos, replaceRange); if (!sep.before && !sep.after) { tr.replaceWith(replaceFrom, replaceTo, tableNode); - return { inserted: true, trailingParagraphPos: null }; + return { inserted: true }; } const nodes = []; - let trailingParagraphPos = null; if (sep.before) { const before = createTableSeparatorParagraph(doc.type.schema); - if (!before) return { inserted: false, trailingParagraphPos: null }; + if (!before) return { inserted: false }; nodes.push(before); } @@ -298,13 +297,12 @@ function insertTopLevelTableWithSeparators(tr, doc, pos, tableNode, replaceRange if (sep.after) { const after = createTableSeparatorParagraph(doc.type.schema); - if (!after) return { inserted: false, trailingParagraphPos: null }; - trailingParagraphPos = pos + nodes.reduce((size, node) => size + node.nodeSize, 0); + if (!after) return { inserted: false }; nodes.push(after); } tr.replaceWith(replaceFrom, replaceTo, Fragment.from(nodes)); - return { inserted: true, trailingParagraphPos }; + return { inserted: true }; } /** diff --git a/packages/super-editor/src/extensions/vertical-navigation/vertical-navigation.js b/packages/super-editor/src/extensions/vertical-navigation/vertical-navigation.js index b24790dabd..7f96f64fd9 100644 --- a/packages/super-editor/src/extensions/vertical-navigation/vertical-navigation.js +++ b/packages/super-editor/src/extensions/vertical-navigation/vertical-navigation.js @@ -342,7 +342,13 @@ function findAdjacentLineElement(currentLine, direction, caretX) { if (!currentLineMetrics) return null; const currentPageLines = getPageLineElements(page); - const adjacentOnCurrentPage = findClosestLineInDirection(currentPageLines, currentLine, direction, caretX); + const adjacentOnCurrentPage = findClosestLineInDirection( + currentPageLines, + currentLine, + currentLineMetrics, + direction, + caretX, + ); if (adjacentOnCurrentPage) return adjacentOnCurrentPage; const pages = Array.from(page.parentElement?.querySelectorAll?.(`.${pageClass}`) ?? []); @@ -429,14 +435,12 @@ function getPageLineElements(page) { * Chooses the closest visual line in the requested direction. * @param {Element[]} lineEls * @param {Element} currentLine + * @param {NonNullable>} currentMetrics * @param {number} direction * @param {number} caretX * @returns {Element | null} */ -function findClosestLineInDirection(lineEls, currentLine, direction, caretX) { - const currentMetrics = getLineMetrics(currentLine); - if (!currentMetrics) return null; - +function findClosestLineInDirection(lineEls, currentLine, currentMetrics, direction, caretX) { const directionalCandidates = lineEls .filter((line) => line !== currentLine) .map((line) => ({ line, metrics: getLineMetrics(line) })) @@ -464,10 +468,7 @@ function findClosestLineInDirection(lineEls, currentLine, direction, caretX) { isWithinTolerance(metrics.centerY, targetRowCenterY, getRowTolerance(currentMetrics, metrics)), ); - return chooseLineClosestToX( - rowCandidates.map(({ line, metrics }) => ({ line, metrics })), - caretX, - ); + return chooseLineClosestToX(rowCandidates, caretX); } /**