Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 39 additions & 68 deletions packages/super-editor/src/extensions/table/table.js
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ import {
pickTemplateRowForAppend,
buildRowFromTemplateRow,
insertRowsAtTableEnd,
insertRowAtIndex,
} from './tableHelpers/appendRows.js';

/**
Expand Down Expand Up @@ -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;
},

/**
Expand All @@ -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;
},

/**
Expand Down
71 changes: 71 additions & 0 deletions packages/super-editor/src/extensions/table/table.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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);
Comment on lines +235 to +239

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Handle rowspans when inserting rows

The new insertRowAtIndex path just clones the source row and inserts it at the computed position without running the prosemirror-tables row insertion logic that splits or adjusts cells spanning multiple rows. If the table contains any rowspan > 1 cells around the insertion point (e.g., a merged cell above or below the cursor), those spans are left intact while the cloned row reuses the same rowspan attrs, causing overlapping cells and an invalid TableMap when the document is next read. The previous originalAddRowBefore/After implementations handled rowspans; this change regresses merged-row tables.

Useful? React with 👍 / 👎.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this may be a relevant concern for complex tables. if you start with:
Screenshot 2025-12-18 at 11 36 50 AM

then 'add row before' on the first row: Screenshot 2025-12-18 at 11 37 18 AM

there may be other weird cases. Should check vertical and horizontally merged cases.


// 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;
}