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
43 changes: 27 additions & 16 deletions e2e/helpers/editor-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -910,27 +910,27 @@ export class EditorPage {
* Insert a table with specified dimensions using the grid selector
*/
async insertTable(rows: number, cols: number): Promise<void> {
// Open table grid selector
await this.page.locator('[data-testid="toolbar-insert-table"]').click();

// Wait for grid to appear
await this.page.waitForSelector('.docx-table-grid', { state: 'visible', timeout: 5000 });
const inlinePicker = this.page.locator('[data-testid="toolbar-insert-table"]');

if (await inlinePicker.isVisible().catch(() => false)) {
await inlinePicker.click();
} else {
await this.page.getByRole('button', { name: /^Insert$/ }).click();
const tableMenuItem = this.page.getByRole('button', { name: /^Table$/ }).first();
await tableMenuItem.hover();
}

// Calculate grid cell index (row-major order, 5 columns per row)
// Grid uses 1-based indexing for rows and cols
const cellIndex = (rows - 1) * 5 + cols;
const grid = this.page.getByRole('grid', { name: 'Table size selector' });
await grid.waitFor({ state: 'visible', timeout: 5000 });

// Get the target cell - must HOVER first to set the hover state, then click
// The grid picker only inserts a table when hoverRows > 0 && hoverCols > 0
const targetCell = this.page.locator(`.docx-table-grid > div:nth-child(${cellIndex})`);
const gridCells = grid.getByRole('gridcell');
const totalCells = await gridCells.count();
const gridColumns = Math.max(1, Math.round(Math.sqrt(totalCells)));
const cellIndex = (rows - 1) * gridColumns + (cols - 1);
const targetCell = gridCells.nth(cellIndex);

// Hover over the cell to set the hover state
await targetCell.hover();

// Small delay to ensure hover state is set
await this.page.waitForTimeout(100);

// Click on the target grid cell
await targetCell.click();

// Wait for table to be inserted (use generic table selector since prosemirror-tables
Expand All @@ -950,6 +950,17 @@ export class EditorPage {
await cell.click();
}

/**
* Right-click on a specific visual table cell to open the text context menu
*/
async rightClickTableCell(tableIndex: number, row: number, col: number): Promise<void> {
const table = this.page.locator('.paged-editor__pages .layout-table').nth(tableIndex);
const cell = table.locator('.layout-table-row').nth(row).locator('.layout-table-cell').nth(col);
await cell.scrollIntoViewIfNeeded();
await cell.click({ button: 'right' });
await this.page.waitForSelector('[role="menu"]', { state: 'visible', timeout: 5000 });
}

/**
* Get table cell content
*/
Expand Down
54 changes: 54 additions & 0 deletions e2e/tests/table-add-column-regression.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { test, expect } from '@playwright/test';
import { EditorPage } from '../helpers/editor-page';

async function fillTableWithCoordinates(editor: EditorPage, rows: number, cols: number) {
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
await editor.clickTableCell(0, row, col);
await editor.page.keyboard.type(`${row + 1}${col + 1}`);
}
}
}

async function getTableMatrix(editor: EditorPage) {
return await editor.page.evaluate(() => {
const table = document.querySelector('.ProseMirror table');
if (!table) return null;

return Array.from(table.querySelectorAll('tr')).map((row) =>
Array.from(row.querySelectorAll('td, th')).map((cell) => (cell.textContent || '').trim())
);
});
}

test.describe('Table Add Column Regression', () => {
let editor: EditorPage;

test.beforeEach(async ({ page }) => {
editor = new EditorPage(page);
await editor.goto();
await editor.waitForReady();
await editor.focus();
});

test('adding a row and then a column from the middle cell preserves a 4x4 matrix', async () => {
await editor.insertTable(3, 3);
await fillTableWithCoordinates(editor, 3, 3);

await editor.clickTableCell(0, 1, 1);
await editor.addRowBelow();
await editor.page.waitForTimeout(400);

await editor.clickTableCell(0, 1, 1);
await editor.addColumnRight();
await editor.page.waitForTimeout(600);

const matrix = await getTableMatrix(editor);
expect(matrix).toEqual([
['11', '12', '', '13'],
['21', '22', '', '23'],
['', '', '', ''],
['31', '32', '', '33'],
]);
});
});
62 changes: 62 additions & 0 deletions e2e/tests/table-context-menu.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { test, expect } from '@playwright/test';
import { EditorPage } from '../helpers/editor-page';

test.describe('Table Context Menu', () => {
let editor: EditorPage;

test.beforeEach(async ({ page }) => {
editor = new EditorPage(page);
await editor.goto();
await editor.waitForReady();
await editor.focus();
});

test('right-click table menu shows merge and split actions and can insert a row', async ({
page,
}) => {
await editor.loadDocxFile('fixtures/with-tables.docx');
await editor.rightClickTableCell(0, 0, 0);

const menu = page.locator('[role="menu"]');
await expect(menu).toHaveCount(1);
await expect(
menu.locator('[role="menuitem"]').filter({ hasText: /^Merge cells$/ })
).toHaveCount(1);
await expect(menu.locator('[role="menuitem"]').filter({ hasText: /^Split cell$/ })).toHaveCount(
1
);

await menu
.locator('[role="menuitem"]')
.filter({ hasText: /^Insert row below$/ })
.click();
await page.waitForTimeout(300);

const dimensions = await editor.getTableDimensions(0);
expect(dimensions.rows).toBe(4);
expect(dimensions.cols).toBeGreaterThan(0);
});

test('right-click split cell applies a one-by-two split', async ({ page }) => {
await editor.loadDocxFile('fixtures/with-tables.docx');
await editor.rightClickTableCell(0, 0, 0);

const menu = page.locator('[role="menu"]');
await menu
.locator('[role="menuitem"]')
.filter({ hasText: /^Split cell$/ })
.click();

const dialog = page.getByRole('dialog', { name: 'Split Cell' });
await expect(dialog).toBeVisible();

const inputs = dialog.locator('input[type="number"]');
await inputs.nth(0).fill('1');
await inputs.nth(1).fill('2');
await dialog.getByRole('button', { name: 'Apply' }).click();
await page.waitForTimeout(300);

const dimensions = await editor.getTableDimensions(0);
expect(dimensions.cols).toBe(4);
});
});
9 changes: 4 additions & 5 deletions e2e/tests/table-merge-split.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
/**
* Table Merge/Split Cell Tests
*
* Tests that prosemirror-tables merge/split commands are properly wired.
* Verifies the commands exist and are integrated in TableExtension and commands/table.ts.
* Tests that merge and dialog-backed split commands are wired into the table UI.
*
* Note: Full E2E CellSelection tests are limited because prosemirror-tables
* CellSelection requires specific mouse interactions in the browser.
Expand Down Expand Up @@ -56,23 +55,23 @@ test.describe('Table Cell Merge/Split', () => {
}
});

test('split cell button disabled when no merged cells', async ({ page }) => {
test('split cell button enabled with a single active cell', async ({ page }) => {
await editor.insertTable(2, 2);
await page.waitForTimeout(300);

// Click in a table cell
await editor.clickTableCell(0, 0, 0);
await page.waitForTimeout(300);

// Open More dropdown and check split cell menu item is disabled
// Open More dropdown and check split cell menu item is enabled
await page.locator('[data-testid="toolbar-table-more"]').click();
await page.waitForSelector('[role="menu"]', { state: 'visible', timeout: 5000 });
const splitItem = page.getByRole('menuitem', { name: 'Split cell' });
if (await splitItem.isVisible()) {
const isDisabled = await splitItem.evaluate(
(el: HTMLElement) => (el as HTMLButtonElement).disabled === true
);
expect(isDisabled).toBe(true);
expect(isDisabled).toBe(false);
}
});

Expand Down
144 changes: 120 additions & 24 deletions packages/core/src/prosemirror/conversion/fromProseDoc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1141,15 +1141,131 @@ function inferTableBorders(rows: TableRow[]): TableBorders | undefined {
return undefined;
}

interface PMTableCellAnchor {
row: number;
col: number;
rowspan: number;
colspan: number;
cell: TableCell;
}

function collectPMTableAnchors(
node: PMNode,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is the third copy of the same anchor-collection algorithm in this PR (also in tableSplit.ts:collectTableAnchors and TableToolbar.tsx:collectDocumentTableAnchors). All three walk the table, build an occupied grid, and produce { row, col, rowspan, colspan } anchors.

The types differ (PMNode vs TableCell) but the grid logic is identical. Worth extracting into a generic helper that takes a row-iterator and a cell-span-getter.

documentCounts?: TrackedChangeCounts
): {
anchors: PMTableCellAnchor[];
totalCols: number;
} {
const occupied: boolean[][] = [];
const anchors: PMTableCellAnchor[] = [];
let totalCols = 0;

for (let rowIndex = 0; rowIndex < node.childCount; rowIndex++) {
const rowNode = node.child(rowIndex);
let colIndex = 0;

rowNode.forEach((cellNode) => {
if (cellNode.type.name !== 'tableCell' && cellNode.type.name !== 'tableHeader') return;

while (occupied[rowIndex]?.[colIndex]) colIndex++;

const rowspan = (cellNode.attrs as TableCellAttrs).rowspan || 1;
const colspan = (cellNode.attrs as TableCellAttrs).colspan || 1;

anchors.push({
row: rowIndex,
col: colIndex,
rowspan,
colspan,
cell: convertPMTableCell(cellNode, documentCounts),
});

for (let r = rowIndex; r < rowIndex + rowspan; r++) {
const rowSlots = occupied[r] ?? [];
occupied[r] = rowSlots;
for (let c = colIndex; c < colIndex + colspan; c++) {
rowSlots[c] = true;
}
}

colIndex += colspan;
totalCols = Math.max(totalCols, colIndex);
});
}

return { anchors, totalCols };
}

function convertPMTable(node: PMNode, documentCounts?: TrackedChangeCounts): Table {
const attrs = node.attrs as TableAttrs;
const { anchors, totalCols } = collectPMTableAnchors(node, documentCounts);
const anchorByStart = new Map<string, PMTableCellAnchor>();
const anchorByCoveredSlot = new Map<string, PMTableCellAnchor>();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This anchorByStart / anchorByCoveredSlot + rebuild loop is the same pattern used in tableSplit.ts:splitActiveTableCell and TableToolbar.tsx:splitTableCell. Here it's used for the save round-trip (PM → DOCX), which means it now runs on every table save — not just split operations.

The old convertPMTableRow was simpler but didn't handle post-split merged cells correctly, so the rewrite is justified. Just flagging that this is a hot path — any bug here silently corrupts saved DOCX files. Consider adding a unit test that round-trips a table with mixed merged/unmerged cells through toProseDocfromProseDoc and asserts the XML is preserved.


for (const anchor of anchors) {
anchorByStart.set(`${anchor.row}-${anchor.col}`, anchor);
for (let row = anchor.row; row < anchor.row + anchor.rowspan; row++) {
for (let col = anchor.col; col < anchor.col + anchor.colspan; col++) {
anchorByCoveredSlot.set(`${row}-${col}`, anchor);
}
}
}

const rows: TableRow[] = [];
for (let rowIndex = 0; rowIndex < node.childCount; rowIndex++) {
const rowNode = node.child(rowIndex);
const cells: TableCell[] = [];

for (let colIndex = 0; colIndex < totalCols; ) {
const anchor = anchorByStart.get(`${rowIndex}-${colIndex}`);
if (anchor) {
const formatting = { ...(anchor.cell.formatting ?? {}) };
if (anchor.colspan > 1) {
formatting.gridSpan = anchor.colspan;
} else {
delete formatting.gridSpan;
}
if (anchor.rowspan > 1) {
formatting.vMerge = 'restart';
} else {
delete formatting.vMerge;
}
cells.push({
...anchor.cell,
formatting: Object.keys(formatting).length ? formatting : undefined,
});
colIndex += anchor.colspan;
continue;
}

const coveringAnchor = anchorByCoveredSlot.get(`${rowIndex}-${colIndex}`);
if (!coveringAnchor) {
colIndex++;
continue;
}

const formatting = { ...(coveringAnchor.cell.formatting ?? {}) };
if (coveringAnchor.colspan > 1) {
formatting.gridSpan = coveringAnchor.colspan;
} else {
delete formatting.gridSpan;
}
formatting.vMerge = 'continue';

node.forEach((rowNode) => {
if (rowNode.type.name === 'tableRow') {
rows.push(convertPMTableRow(rowNode, documentCounts));
cells.push({
...coveringAnchor.cell,
content: [],
formatting,
});
colIndex += coveringAnchor.colspan;
}
});

rows.push({
type: 'tableRow',
formatting: tableRowAttrsToFormatting(rowNode.attrs as TableRowAttrs),
cells,
});
}

const formatting = tableAttrsToFormatting(attrs) || undefined;
if (!formatting?.borders) {
Expand Down Expand Up @@ -1296,26 +1412,6 @@ function tableAttrsToFormatting(attrs: TableAttrs): TableFormatting | undefined
};
}

/**
* Convert a ProseMirror table row node to our TableRow type
*/
function convertPMTableRow(node: PMNode, documentCounts?: TrackedChangeCounts): TableRow {
const attrs = node.attrs as TableRowAttrs;
const cells: TableCell[] = [];

node.forEach((cellNode) => {
if (cellNode.type.name === 'tableCell' || cellNode.type.name === 'tableHeader') {
cells.push(convertPMTableCell(cellNode, documentCounts));
}
});

return {
type: 'tableRow',
formatting: tableRowAttrsToFormatting(attrs),
cells,
};
}

/**
* Convert ProseMirror table row attrs to TableRowFormatting
*/
Expand Down
Loading