diff --git a/packages/super-editor/src/extensions/table/table.js b/packages/super-editor/src/extensions/table/table.js index 5ef942c374..beb25689f1 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, @@ -230,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', @@ -246,6 +253,70 @@ 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 }} + */ +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 }; + } + + const nodes = []; + + if (sep.before) { + const before = createTableSeparatorParagraph(doc.type.schema); + if (!before) return { inserted: false }; + nodes.push(before); + } + + nodes.push(tableNode); + + if (sep.after) { + const after = createTableSeparatorParagraph(doc.type.schema); + if (!after) return { inserted: false }; + nodes.push(after); + } + + tr.replaceWith(replaceFrom, replaceTo, Fragment.from(nodes)); + return { inserted: true }; +} + +/** + * 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% @@ -647,7 +718,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); @@ -660,14 +731,42 @@ 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; - 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 offset; + let replaceRange = undefined; + + 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); + } } - 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; @@ -723,26 +822,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); @@ -1363,6 +1444,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/table.test.js b/packages/super-editor/src/extensions/table/table.test.js index e84f9be1d3..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'; @@ -1173,6 +1173,173 @@ 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('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 })); + + 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); + }); + + 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 () => { it('includes DEFAULT_TBL_LOOK in tableProperties when a style is resolved', async () => { const { docx, media, mediaFiles, fonts } = cachedBlankDoc; 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..3a9c927490 --- /dev/null +++ b/packages/super-editor/src/extensions/table/tableHelpers/tableBoundaryNavigation.js @@ -0,0 +1,432 @@ +// @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 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 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. Trailing inline atoms (bookmarks, + * permission markers, etc.) are ignored. + * + * @param {import('prosemirror-model').ResolvedPos} $head + * @returns {boolean} + */ +export function isAtEffectiveParagraphEnd($head) { + const paragraphDepth = findParagraphDepth($head); + 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; + + 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} + */ +export function isAtEffectiveParagraphStart($head) { + const paragraphDepth = findParagraphDepth($head); + 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; + + const runIndex = $head.index(paragraphDepth); + return allInlineMarkersBetween(paragraph, 0, runIndex); +} + +/** + * 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, 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), + 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 + * @returns {boolean} + */ +function isLastCellInTable(context) { + if (!context) return false; + const { map, rect } = getCellRect(context); + 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 { rect } = getCellRect(context); + 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); +} + +/** + * 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, + }; +} + +/** + * 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 + * 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; + 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; + + const boundaryPos = helpers.getTableBoundaryPos(context); + const targetPos = helpers.findTextPosAcrossBoundary(state, boundaryPos); + if (targetPos != null) { + return TextSelection.create(state.doc, targetPos); + } + return findSelectionNearBoundary(state, boundaryPos, dir); +} + +/** + * 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 = 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 $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) { + const targetPos = findFirstTextPosInNode(adjacentNode, boundaryPos); + if (targetPos != null) { + return TextSelection.create(state.doc, targetPos); + } + return findSelectionNearBoundary(state, boundaryPos, 1); + } + + const tablePos = boundaryPos - adjacentNode.nodeSize; + const targetPos = findLastTextPosInNode(adjacentNode, tablePos); + if (targetPos != null) { + return TextSelection.create(state.doc, targetPos); + } + return findSelectionNearBoundary(state, 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; + + 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; + + 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..b1df6efbda --- /dev/null +++ b/packages/super-editor/src/extensions/table/tableHelpers/tableBoundaryNavigation.test.js @@ -0,0 +1,394 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { TextSelection } from 'prosemirror-state'; + +import { initTestEditor } from '@tests/helpers/helpers.js'; + +import { + getAdjacentTableEntrySelection, + getTableBoundaryExitSelection, + isInProtectedTrailingTableParagraph, + isAtEffectiveParagraphEnd, + isAtEffectiveParagraphStart, + createTableBoundaryNavigationPlugin, +} 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' }] }], + }, + ], +}; + +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', + }, + ], +}; + +/** + * 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) => { + 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 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))); + + 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); + }); + + 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); + }); + + 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); + }); + }); +}); 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..7f96f64fd9 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,37 @@ 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; - - 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 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; - } + if (!page) return null; + + const currentLineMetrics = getLineMetrics(currentLine); + if (!currentLineMetrics) return null; + + const currentPageLines = getPageLineElements(page); + const adjacentOnCurrentPage = findClosestLineInDirection( + currentPageLines, + currentLine, + currentLineMetrics, + 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 +416,180 @@ 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 {NonNullable>} currentMetrics * @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 findClosestLineInDirection(lineEls, currentLine, currentMetrics, direction, caretX) { + 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, 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 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', () => { 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 { + 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); +}); + +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); +}); 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, + }); +});