diff --git a/packages/super-editor/src/extensions/table/table.js b/packages/super-editor/src/extensions/table/table.js index a2d4c6674..cb09608c6 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'; /** @@ -690,42 +691,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; }, /** @@ -738,40 +723,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 557f06e74..74e8280e2 100644 --- a/packages/super-editor/src/extensions/table/table.test.js +++ b/packages/super-editor/src/extensions/table/table.test.js @@ -126,6 +126,77 @@ 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('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 117746d0e..2f0a243a9 100644 --- a/packages/super-editor/src/extensions/table/tableHelpers/appendRows.js +++ b/packages/super-editor/src/extensions/table/tableHelpers/appendRows.js @@ -1,6 +1,14 @@ // @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'; /** * Row template formatting @@ -121,9 +129,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 +204,47 @@ 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. + * @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; + + // Build row with formatting using existing helper + const newRow = buildRowFromTemplateRow({ + schema, + tableNode, + templateRow: sourceRow, + values: [], + copyRowStyle: true, + }); + if (!newRow) return false; + + // Calculate insert position + let insertPos = tablePos + 1; + for (let i = 0; i < insertIndex; i++) { + insertPos += tableNode.child(i).nodeSize; + } + + tr.insert(insertPos, newRow); + + // Set cursor in first cell's paragraph and apply stored marks + const formatting = extractRowTemplateFormatting(sourceRow.firstChild, schema); + const cursorPos = insertPos + 3; // row start + cell start + paragraph start + tr.setSelection(TextSelection.create(tr.doc, cursorPos)); + if (formatting.textMarks?.length) { + tr.setStoredMarks(formatting.textMarks); + } + + return true; +}