diff --git a/packages/super-editor/src/extensions/table/table.js b/packages/super-editor/src/extensions/table/table.js index 4217173ac7..cc47ea8f1f 100644 --- a/packages/super-editor/src/extensions/table/table.js +++ b/packages/super-editor/src/extensions/table/table.js @@ -212,6 +212,7 @@ import { pickTemplateRowForAppend, buildRowFromTemplateRow, insertRowsAtTableEnd, + insertRowAtIndex, } from './tableHelpers/appendRows.js'; /** @@ -679,42 +680,26 @@ export const Table = Node.create({ */ addRowBefore: () => - ({ state, dispatch, chain }) => { - if (!originalAddRowBefore(state)) return false; - - let { rect, attrs: currentCellAttrs } = getCurrentCellAttrs(state); - - return chain() - .command(() => originalAddRowBefore(state, dispatch)) - .command(({ tr }) => { - let table = tr.doc.nodeAt(rect.tableStart - 1); - if (!table) return false; - let updatedMap = TableMap.get(table); - let newRowIndex = rect.top; - - if (newRowIndex < 0 || newRowIndex >= updatedMap.height) { - return false; - } - - for (let col = 0; col < updatedMap.width; col++) { - let cellIndex = newRowIndex * updatedMap.width + col; - let cellPos = updatedMap.map[cellIndex]; - let cellAbsolutePos = rect.tableStart + cellPos; - let cell = tr.doc.nodeAt(cellAbsolutePos); - if (cell) { - let attrs = { - ...currentCellAttrs, - colspan: cell.attrs.colspan, - rowspan: cell.attrs.rowspan, - colwidth: cell.attrs.colwidth, - }; - tr.setNodeMarkup(cellAbsolutePos, null, attrs); - } - } + ({ state, dispatch, editor }) => { + if (!isInTable(state)) return false; + + const { rect } = getCurrentCellAttrs(state); + const tablePos = rect.tableStart - 1; + const tableNode = state.doc.nodeAt(tablePos); + if (!tableNode) return false; + + const tr = state.tr; + const result = insertRowAtIndex({ + tr, + tablePos, + tableNode, + sourceRowIndex: rect.top, + insertIndex: rect.top, + schema: editor.schema, + }); - return true; - }) - .run(); + if (result && dispatch) dispatch(tr); + return result; }, /** @@ -727,40 +712,26 @@ export const Table = Node.create({ */ addRowAfter: () => - ({ state, dispatch, chain }) => { - if (!originalAddRowAfter(state)) return false; - - let { rect, attrs: currentCellAttrs } = getCurrentCellAttrs(state); - - return chain() - .command(() => originalAddRowAfter(state, dispatch)) - .command(({ tr }) => { - let table = tr.doc.nodeAt(rect.tableStart - 1); - if (!table) return false; - let updatedMap = TableMap.get(table); - let newRowIndex = rect.top + 1; - - if (newRowIndex >= updatedMap.height) return false; - - for (let col = 0; col < updatedMap.width; col++) { - let cellIndex = newRowIndex * updatedMap.width + col; - let cellPos = updatedMap.map[cellIndex]; - let cellAbsolutePos = rect.tableStart + cellPos; - let cell = tr.doc.nodeAt(cellAbsolutePos); - if (cell) { - let attrs = { - ...currentCellAttrs, - colspan: cell.attrs.colspan, - rowspan: cell.attrs.rowspan, - colwidth: cell.attrs.colwidth, - }; - tr.setNodeMarkup(cellAbsolutePos, null, attrs); - } - } + ({ state, dispatch, editor }) => { + if (!isInTable(state)) return false; + + const { rect } = getCurrentCellAttrs(state); + const tablePos = rect.tableStart - 1; + const tableNode = state.doc.nodeAt(tablePos); + if (!tableNode) return false; + + const tr = state.tr; + const result = insertRowAtIndex({ + tr, + tablePos, + tableNode, + sourceRowIndex: rect.top, + insertIndex: rect.top + 1, + schema: editor.schema, + }); - return true; - }) - .run(); + if (result && dispatch) dispatch(tr); + return result; }, /** diff --git a/packages/super-editor/src/extensions/table/table.test.js b/packages/super-editor/src/extensions/table/table.test.js index 688460ac93..165c37a90d 100644 --- a/packages/super-editor/src/extensions/table/table.test.js +++ b/packages/super-editor/src/extensions/table/table.test.js @@ -126,6 +126,376 @@ describe('Table commands', async () => { }); }); + describe('addRowAfter', async () => { + beforeEach(async () => { + await setupTestTable(); + }); + + it('preserves paragraph formatting from source row', async () => { + const tablePos = findTablePos(editor.state.doc); + const table = editor.state.doc.nodeAt(tablePos); + + // Position cursor in the last row (which has styled content) + const lastRowPos = tablePos + 1 + table.child(0).nodeSize; + const cellPos = lastRowPos + 1; + const textPos = cellPos + 2; + editor.commands.setTextSelection(textPos); + + // Add row after + const didAdd = editor.commands.addRowAfter(); + expect(didAdd).toBe(true); + + // Check the new row + const updatedTable = editor.state.doc.nodeAt(tablePos); + expect(updatedTable.childCount).toBe(3); + + const newRow = updatedTable.child(2); + + // Check ALL cells preserve formatting, not just the first + newRow.forEach((cell, _, cellIndex) => { + const blockNode = cell.firstChild; + expect(blockNode.type).toBe(templateBlockType); + if (templateBlockAttrs) { + expect(blockNode.attrs).toMatchObject(templateBlockAttrs); + } + }); + }); + }); + + describe('addRowBefore', async () => { + beforeEach(async () => { + await setupTestTable(); + }); + + it('preserves paragraph formatting from source row', async () => { + const tablePos = findTablePos(editor.state.doc); + const table = editor.state.doc.nodeAt(tablePos); + + // Position cursor in the last row (which has styled content) + const lastRowPos = tablePos + 1 + table.child(0).nodeSize; + const cellPos = lastRowPos + 1; + const textPos = cellPos + 2; + editor.commands.setTextSelection(textPos); + + // Add row before + const didAdd = editor.commands.addRowBefore(); + expect(didAdd).toBe(true); + + // Check the new row (inserted at index 1, pushing styled row to index 2) + const updatedTable = editor.state.doc.nodeAt(tablePos); + expect(updatedTable.childCount).toBe(3); + + const newRow = updatedTable.child(1); + const firstCell = newRow.firstChild; + const blockNode = firstCell.firstChild; + + // Should preserve block type and attrs + expect(blockNode.type).toBe(templateBlockType); + if (templateBlockAttrs) { + expect(blockNode.attrs).toMatchObject(templateBlockAttrs); + } + }); + }); + + describe('addRow with merged cells (rowspan)', async () => { + /** + * Creates a table with a vertically merged cell (rowspan=2) in the first column. + * Structure: + * | Cell A (rowspan=2) | Cell B | + * | | Cell C | + */ + const setupTableWithRowspan = async () => { + let { docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests('blank-doc.docx'); + ({ editor } = initTestEditor({ content: docx, media, mediaFiles, fonts })); + ({ schema } = editor); + + const RowType = schema.nodes.tableRow; + const CellType = schema.nodes.tableCell; + const TableType = schema.nodes.table; + + // First row: cell with rowspan=2, normal cell + const cellA = CellType.create( + { rowspan: 2, colspan: 1 }, + schema.nodes.paragraph.create(null, schema.text('Cell A')), + ); + const cellB = CellType.create( + { rowspan: 1, colspan: 1 }, + schema.nodes.paragraph.create(null, schema.text('Cell B')), + ); + const row1 = RowType.create(null, [cellA, cellB]); + + // Second row: only one cell (first column occupied by rowspan) + const cellC = CellType.create( + { rowspan: 1, colspan: 1 }, + schema.nodes.paragraph.create(null, schema.text('Cell C')), + ); + const row2 = RowType.create(null, [cellC]); + + table = TableType.create(null, [row1, row2]); + + const doc = schema.nodes.doc.create(null, [table]); + const nextState = EditorState.create({ schema, doc, plugins: editor.state.plugins }); + editor.setState(nextState); + }; + + beforeEach(async () => { + await setupTableWithRowspan(); + }); + + it('addRowBefore: increases rowspan of spanning cell when inserting above spanned row', async () => { + const { TextSelection } = await import('prosemirror-state'); + const { TableMap } = await import('prosemirror-tables'); + const tablePos = findTablePos(editor.state.doc); + const table = editor.state.doc.nodeAt(tablePos); + const map = TableMap.get(table); + + // Cell C is at row 1, column 1 (column 0 is occupied by Cell A's rowspan) + const cellCPosInTable = map.map[3]; // row 1 * width 2 + col 1 = index 3 + const absoluteCellCPos = tablePos + 1 + cellCPosInTable; + + // Position inside Cell C's paragraph (+2 for cell open + paragraph open) + const textPos = absoluteCellCPos + 2; + + // Use TextSelection directly (editor.commands.setTextSelection has issues with table cells) + const sel = TextSelection.create(editor.state.doc, textPos); + const tr = editor.state.tr.setSelection(sel); + editor.view.dispatch(tr); + + // Add row before the second row + const didAdd = editor.commands.addRowBefore(); + expect(didAdd).toBe(true); + + // Check the updated table + const updatedTable = editor.state.doc.nodeAt(tablePos); + expect(updatedTable.childCount).toBe(3); // Now 3 rows + + // The first cell (Cell A) should now have rowspan=3 + const firstRow = updatedTable.child(0); + const cellA = firstRow.firstChild; + expect(cellA.attrs.rowspan).toBe(3); + expect(cellA.textContent).toBe('Cell A'); + }); + + it('addRowAfter: increases rowspan of spanning cell when inserting below first row', async () => { + const tablePos = findTablePos(editor.state.doc); + const table = editor.state.doc.nodeAt(tablePos); + + // Position cursor in the first row (row index 0) + let firstRowPos = tablePos + 1; + // Skip the first cell (Cell A with rowspan) and go to second cell (Cell B) + let cellBPos = firstRowPos + 1 + table.child(0).firstChild.nodeSize; + let textPos = cellBPos + 2; + editor.commands.setTextSelection(textPos); + + // Add row after the first row + const didAdd = editor.commands.addRowAfter(); + expect(didAdd).toBe(true); + + // Check the updated table + const updatedTable = editor.state.doc.nodeAt(tablePos); + expect(updatedTable.childCount).toBe(3); // Now 3 rows + + // The first cell (Cell A) should now have rowspan=3 + const firstRow = updatedTable.child(0); + const cellA = firstRow.firstChild; + expect(cellA.attrs.rowspan).toBe(3); + expect(cellA.textContent).toBe('Cell A'); + }); + + it('addRowBefore on first row: does not affect rowspan (no cells span from above)', async () => { + const tablePos = findTablePos(editor.state.doc); + const table = editor.state.doc.nodeAt(tablePos); + + // Position cursor in the first row, first cell + let firstRowPos = tablePos + 1; + let cellPos = firstRowPos + 1; + let textPos = cellPos + 2; + editor.commands.setTextSelection(textPos); + + // Add row before the first row + const didAdd = editor.commands.addRowBefore(); + expect(didAdd).toBe(true); + + // Check the updated table + const updatedTable = editor.state.doc.nodeAt(tablePos); + expect(updatedTable.childCount).toBe(3); // Now 3 rows + + // The new row should be at index 0, original first row now at index 1 + // Cell A (now in row 1) should still have rowspan=2 (unchanged) + const originalFirstRow = updatedTable.child(1); + const cellA = originalFirstRow.firstChild; + expect(cellA.attrs.rowspan).toBe(2); + expect(cellA.textContent).toBe('Cell A'); + + // New row should have 2 cells with rowspan=1 + const newRow = updatedTable.child(0); + expect(newRow.childCount).toBe(2); + newRow.forEach((cell) => { + expect(cell.attrs.rowspan).toBe(1); + }); + }); + + it('addRowAfter: uses correct formatting from source cell when first column is spanned', async () => { + // This test verifies Issue 2: cursor formatting should come from the + // first CREATED cell, not sourceRow.firstChild (which may be spanned) + const { TextSelection } = await import('prosemirror-state'); + const { TableMap } = await import('prosemirror-tables'); + const tablePos = findTablePos(editor.state.doc); + const table = editor.state.doc.nodeAt(tablePos); + const map = TableMap.get(table); + + // Cell C is at row 1, column 1 (column 0 is occupied by Cell A's rowspan) + const cellCPosInTable = map.map[3]; // row 1 * width 2 + col 1 = index 3 + const absoluteCellCPos = tablePos + 1 + cellCPosInTable; + + // Position inside Cell C's paragraph + const textPos = absoluteCellCPos + 2; + const sel = TextSelection.create(editor.state.doc, textPos); + const tr = editor.state.tr.setSelection(sel); + editor.view.dispatch(tr); + + // Add row after the second row + const didAdd = editor.commands.addRowAfter(); + expect(didAdd).toBe(true); + + // Table should now have 3 rows and be structurally valid + const updatedTable = editor.state.doc.nodeAt(tablePos); + expect(updatedTable.childCount).toBe(3); + + // TableMap.get should not throw (table is valid) + expect(() => TableMap.get(updatedTable)).not.toThrow(); + }); + }); + + describe('addRow with colspan + rowspan combination', async () => { + /** + * Creates a table with a cell that has both colspan=2 AND rowspan=2. + * This is a common pattern in Word documents (e.g., a header spanning multiple rows and columns). + * Structure (3x3 table): + * | Cell A (colspan=2, rowspan=2) | Cell B | + * | | Cell C | + * | Cell D | Cell E| Cell F | + */ + const setupTableWithColspanAndRowspan = async () => { + let { docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests('blank-doc.docx'); + ({ editor } = initTestEditor({ content: docx, media, mediaFiles, fonts })); + ({ schema } = editor); + + const RowType = schema.nodes.tableRow; + const CellType = schema.nodes.tableCell; + const TableType = schema.nodes.table; + + // First row: cell with colspan=2 AND rowspan=2, plus one normal cell + const cellA = CellType.create( + { rowspan: 2, colspan: 2 }, + schema.nodes.paragraph.create(null, schema.text('Cell A')), + ); + const cellB = CellType.create( + { rowspan: 1, colspan: 1 }, + schema.nodes.paragraph.create(null, schema.text('Cell B')), + ); + const row1 = RowType.create(null, [cellA, cellB]); + + // Second row: only cell C (columns 0-1 occupied by Cell A's colspan+rowspan) + const cellC = CellType.create( + { rowspan: 1, colspan: 1 }, + schema.nodes.paragraph.create(null, schema.text('Cell C')), + ); + const row2 = RowType.create(null, [cellC]); + + // Third row: three normal cells + const cellD = CellType.create( + { rowspan: 1, colspan: 1 }, + schema.nodes.paragraph.create(null, schema.text('Cell D')), + ); + const cellE = CellType.create( + { rowspan: 1, colspan: 1 }, + schema.nodes.paragraph.create(null, schema.text('Cell E')), + ); + const cellF = CellType.create( + { rowspan: 1, colspan: 1 }, + schema.nodes.paragraph.create(null, schema.text('Cell F')), + ); + const row3 = RowType.create(null, [cellD, cellE, cellF]); + + table = TableType.create(null, [row1, row2, row3]); + + const doc = schema.nodes.doc.create(null, [table]); + const nextState = EditorState.create({ schema, doc, plugins: editor.state.plugins }); + editor.setState(nextState); + }; + + beforeEach(async () => { + await setupTableWithColspanAndRowspan(); + }); + + it('addRowBefore: increases rowspan of cell with both colspan and rowspan', async () => { + const { TextSelection } = await import('prosemirror-state'); + const { TableMap } = await import('prosemirror-tables'); + const tablePos = findTablePos(editor.state.doc); + const table = editor.state.doc.nodeAt(tablePos); + const map = TableMap.get(table); + + // Cell C is at row 1, column 2 (columns 0-1 are occupied by Cell A) + // TableMap index: row 1 * width 3 + col 2 = 5 + const cellCPosInTable = map.map[5]; + const absoluteCellCPos = tablePos + 1 + cellCPosInTable; + const textPos = absoluteCellCPos + 2; + + const sel = TextSelection.create(editor.state.doc, textPos); + const tr = editor.state.tr.setSelection(sel); + editor.view.dispatch(tr); + + // Add row before the second row (which is within Cell A's rowspan) + const didAdd = editor.commands.addRowBefore(); + expect(didAdd).toBe(true); + + // Check the updated table + const updatedTable = editor.state.doc.nodeAt(tablePos); + expect(updatedTable.childCount).toBe(4); // Now 4 rows + + // Cell A should now have rowspan=3 (was 2, increased by 1) + const firstRow = updatedTable.child(0); + const cellA = firstRow.firstChild; + expect(cellA.attrs.rowspan).toBe(3); + expect(cellA.attrs.colspan).toBe(2); // colspan unchanged + expect(cellA.textContent).toBe('Cell A'); + + // Table should be structurally valid + expect(() => TableMap.get(updatedTable)).not.toThrow(); + }); + + it('addRowAfter on row 1: inserts row within colspan+rowspan cell extent', async () => { + const { TableMap } = await import('prosemirror-tables'); + const tablePos = findTablePos(editor.state.doc); + const table = editor.state.doc.nodeAt(tablePos); + + // Position cursor in Cell B (row 0, col 2) + const map = TableMap.get(table); + const cellBPosInTable = map.map[2]; // row 0 * width 3 + col 2 = 2 + const absoluteCellBPos = tablePos + 1 + cellBPosInTable; + const textPos = absoluteCellBPos + 2; + editor.commands.setTextSelection(textPos); + + // Add row after the first row + const didAdd = editor.commands.addRowAfter(); + expect(didAdd).toBe(true); + + // Check the updated table + const updatedTable = editor.state.doc.nodeAt(tablePos); + expect(updatedTable.childCount).toBe(4); // Now 4 rows + + // Cell A should now have rowspan=3 + const firstRow = updatedTable.child(0); + const cellA = firstRow.firstChild; + expect(cellA.attrs.rowspan).toBe(3); + expect(cellA.attrs.colspan).toBe(2); + + // Table should be structurally valid + expect(() => TableMap.get(updatedTable)).not.toThrow(); + }); + }); + describe('deleteCellAndTableBorders', async () => { let table, tablePos; diff --git a/packages/super-editor/src/extensions/table/tableHelpers/appendRows.js b/packages/super-editor/src/extensions/table/tableHelpers/appendRows.js index 117746d0e6..e87742a4bb 100644 --- a/packages/super-editor/src/extensions/table/tableHelpers/appendRows.js +++ b/packages/super-editor/src/extensions/table/tableHelpers/appendRows.js @@ -1,6 +1,26 @@ // @ts-check import { Fragment } from 'prosemirror-model'; import { TableMap } from 'prosemirror-tables'; +import { TextSelection } from 'prosemirror-state'; + +/** + * Zero-width space used as a placeholder to carry marks in empty cells. + * ProseMirror marks can only attach to text nodes, so we use this invisible + * character to preserve formatting (bold, underline, etc.) in empty cells. + */ +const ZERO_WIDTH_SPACE = '\u200B'; + +/** + * Offset from a row's start position to the first text position inside its first cell. + * Calculated as: row open (1) + cell open (1) + paragraph open (1) = 3 + */ +const ROW_START_TO_TEXT_OFFSET = 3; + +/** + * Offset from a cell's position to the first text position inside it. + * Calculated as: cell open (1) + paragraph open (1) = 2 + */ +const CELL_TO_TEXT_OFFSET = 2; /** * Row template formatting @@ -121,9 +141,16 @@ export function extractRowTemplateFormatting(cellNode, schema) { */ export function buildFormattedCellBlock(schema, value, { blockType, blockAttrs, textMarks }, copyRowStyle = false) { const text = typeof value === 'string' ? value : value == null ? '' : String(value); + const type = blockType || schema.nodes.paragraph; const marks = copyRowStyle ? textMarks || [] : []; + + if (!text) { + // Use zero-width space to preserve marks in empty cells when copying style + const content = marks.length > 0 ? schema.text(ZERO_WIDTH_SPACE, marks) : null; + return type.createAndFill(blockAttrs || null, content); + } + const textNode = schema.text(text, marks); - const type = blockType || schema.nodes.paragraph; return type.createAndFill(blockAttrs || null, textNode); } @@ -189,3 +216,140 @@ export function insertRowsAtTableEnd({ tr, tablePos, tableNode, rows }) { const frag = Fragment.fromArray(rows); tr.insert(lastRowAbsEnd, frag); } + +/** + * Insert a new row at a specific index, copying formatting from a source row. + * Handles rowspan cells properly by incrementing their rowspan when they span + * across the insertion point. + * @param {Object} params - Insert parameters + * @param {import('prosemirror-state').Transaction} params.tr - Transaction to mutate + * @param {number} params.tablePos - Absolute position of the table + * @param {import('prosemirror-model').Node} params.tableNode - Table node + * @param {number} params.sourceRowIndex - Index of the row to copy formatting from + * @param {number} params.insertIndex - Index where the new row should be inserted + * @param {import('prosemirror-model').Schema} params.schema - Editor schema + * @returns {boolean} True if successful + */ +export function insertRowAtIndex({ tr, tablePos, tableNode, sourceRowIndex, insertIndex, schema }) { + const sourceRow = tableNode.child(sourceRowIndex); + if (!sourceRow) return false; + + const map = TableMap.get(tableNode); + const { width, height } = map; + + // Track which columns are occupied by spanning cells from above + // and collect the cells we need to create for the new row + const newCells = []; + const cellsToExtend = []; // { pos: number, attrs: object } + + const RowType = schema.nodes.tableRow; + const CellType = schema.nodes.tableCell; + + // Get formatting from source row for new cells + const sourceFormatting = extractRowTemplateFormatting(sourceRow.firstChild, schema); + + for (let col = 0; col < width; ) { + // Check if we're inserting within an existing table (not at the end) + // and if a cell from above spans into this row + if (insertIndex > 0 && insertIndex < height) { + const indexAbove = (insertIndex - 1) * width + col; + const indexAtInsert = insertIndex * width + col; + + // If the cell position is the same, a cell from above spans into this position + if (map.map[indexAbove] === map.map[indexAtInsert]) { + const cellPos = map.map[indexAbove]; + const cell = tableNode.nodeAt(cellPos); + if (cell) { + const attrs = cell.attrs; + // Record this cell needs its rowspan extended + cellsToExtend.push({ + pos: tablePos + 1 + cellPos, + attrs: { ...attrs, rowspan: (attrs.rowspan || 1) + 1 }, + }); + // Skip the columns this cell spans + col += attrs.colspan || 1; + continue; + } + } + } + + // Use TableMap to find the cell at this column in the source row + // This correctly handles cases where the source row has cells from rowspans above + const sourceMapIndex = sourceRowIndex * width + col; + const sourceCellPos = map.map[sourceMapIndex]; + const sourceCell = tableNode.nodeAt(sourceCellPos); + + if (!sourceCell) { + // Fallback: use the first cell of the source row + const fallbackCell = sourceRow.firstChild; + const formatting = extractRowTemplateFormatting(fallbackCell, schema); + const content = buildFormattedCellBlock(schema, '', formatting, true); + const newCell = CellType.createAndFill({ rowspan: 1, colspan: 1 }, content); + if (newCell) newCells.push(newCell); + col += 1; + continue; + } + + const colspan = sourceCell.attrs.colspan || 1; + const formatting = extractRowTemplateFormatting(sourceCell, schema); + + // Create a new cell with formatting but reset rowspan to 1 + const cellAttrs = { + ...sourceCell.attrs, + rowspan: 1, // New cells always have rowspan 1 + }; + + const content = buildFormattedCellBlock(schema, '', formatting, true); + const targetCellType = sourceCell.type.name === 'tableHeader' ? CellType : sourceCell.type; + const newCell = targetCellType.createAndFill(cellAttrs, content); + if (newCell) newCells.push(newCell); + + col += colspan; + } + + // Apply rowspan extensions to spanning cells (before insert to maintain positions) + for (const { pos, attrs } of cellsToExtend) { + tr.setNodeMarkup(pos, null, attrs); + } + + // Calculate insert position + let insertPos = tablePos + 1; + for (let i = 0; i < insertIndex; i++) { + insertPos += tableNode.child(i).nodeSize; + } + + // Create and insert the new row (only if we have cells to add) + if (newCells.length > 0) { + const newRow = RowType.createAndFill(null, newCells); + if (newRow) { + tr.insert(insertPos, newRow); + + // Set cursor in first cell's paragraph and apply stored marks + const cursorPos = insertPos + ROW_START_TO_TEXT_OFFSET; + tr.setSelection(TextSelection.create(tr.doc, cursorPos)); + + // Get formatting from the first CREATED cell, not sourceRow.firstChild + // This fixes cursor marks when column 0 is spanned and cursor lands in a different column + const firstCellBlock = newCells[0].firstChild; + const firstTextNode = firstCellBlock?.firstChild; + if (firstTextNode?.marks?.length) { + tr.setStoredMarks(firstTextNode.marks); + } else if (sourceFormatting.textMarks?.length) { + tr.setStoredMarks(sourceFormatting.textMarks); + } + } + } else { + // Edge case: all columns are occupied by spanning cells from above. + // The rowspans have already been extended (cellsToExtend), so inserting + // a physical cell would create overlap/structural conflict. + // Instead, place cursor in one of the extended spanning cells. + if (cellsToExtend.length > 0) { + const spanningCellPos = cellsToExtend[0].pos; + const cursorPos = spanningCellPos + CELL_TO_TEXT_OFFSET; + tr.setSelection(TextSelection.create(tr.doc, cursorPos)); + } + // No row inserted - the spanning cells already cover this space + } + + return true; +}