From 3fa9f5c2a1cce82d1ab8715cb5f335152244d873 Mon Sep 17 00:00:00 2001 From: Alok Kumar Bishoyi Date: Wed, 1 Apr 2026 01:12:01 +0530 Subject: [PATCH] Fix table context menu actions and cell splitting --- e2e/helpers/editor-page.ts | 43 ++- e2e/tests/table-add-column-regression.spec.ts | 54 +++ e2e/tests/table-context-menu.spec.ts | 62 +++ e2e/tests/table-merge-split.spec.ts | 9 +- .../prosemirror/conversion/fromProseDoc.ts | 144 +++++-- .../extensions/nodes/TableExtension.ts | 40 +- packages/react/i18n/de.json | 9 + packages/react/i18n/en.json | 9 + packages/react/i18n/pl.json | 9 + packages/react/src/components/DocxEditor.tsx | 131 +++++-- .../react/src/components/TextContextMenu.tsx | 28 ++ .../components/dialogs/SplitCellDialog.tsx | 213 +++++++++++ packages/react/src/components/tableSplit.ts | 352 ++++++++++++++++++ .../react/src/components/ui/TableToolbar.tsx | 293 ++++++++++++++- packages/react/src/hooks/useTableSelection.ts | 61 ++- packages/react/src/index.ts | 3 + .../react/src/paged-editor/PagedEditor.tsx | 7 +- packages/react/src/ui.ts | 3 + 18 files changed, 1371 insertions(+), 99 deletions(-) create mode 100644 e2e/tests/table-add-column-regression.spec.ts create mode 100644 e2e/tests/table-context-menu.spec.ts create mode 100644 packages/react/src/components/dialogs/SplitCellDialog.tsx create mode 100644 packages/react/src/components/tableSplit.ts diff --git a/e2e/helpers/editor-page.ts b/e2e/helpers/editor-page.ts index 5ed8b193..219fe4ec 100644 --- a/e2e/helpers/editor-page.ts +++ b/e2e/helpers/editor-page.ts @@ -910,27 +910,27 @@ export class EditorPage { * Insert a table with specified dimensions using the grid selector */ async insertTable(rows: number, cols: number): Promise { - // 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 @@ -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 { + 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 */ diff --git a/e2e/tests/table-add-column-regression.spec.ts b/e2e/tests/table-add-column-regression.spec.ts new file mode 100644 index 00000000..80ddb6c0 --- /dev/null +++ b/e2e/tests/table-add-column-regression.spec.ts @@ -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'], + ]); + }); +}); diff --git a/e2e/tests/table-context-menu.spec.ts b/e2e/tests/table-context-menu.spec.ts new file mode 100644 index 00000000..96391891 --- /dev/null +++ b/e2e/tests/table-context-menu.spec.ts @@ -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); + }); +}); diff --git a/e2e/tests/table-merge-split.spec.ts b/e2e/tests/table-merge-split.spec.ts index 164c14cd..863503c5 100644 --- a/e2e/tests/table-merge-split.spec.ts +++ b/e2e/tests/table-merge-split.spec.ts @@ -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. @@ -56,7 +55,7 @@ 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); @@ -64,7 +63,7 @@ test.describe('Table Cell Merge/Split', () => { 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' }); @@ -72,7 +71,7 @@ test.describe('Table Cell Merge/Split', () => { const isDisabled = await splitItem.evaluate( (el: HTMLElement) => (el as HTMLButtonElement).disabled === true ); - expect(isDisabled).toBe(true); + expect(isDisabled).toBe(false); } }); diff --git a/packages/core/src/prosemirror/conversion/fromProseDoc.ts b/packages/core/src/prosemirror/conversion/fromProseDoc.ts index f4011ec5..69735187 100644 --- a/packages/core/src/prosemirror/conversion/fromProseDoc.ts +++ b/packages/core/src/prosemirror/conversion/fromProseDoc.ts @@ -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, + 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(); + const anchorByCoveredSlot = new Map(); + + 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) { @@ -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 */ diff --git a/packages/core/src/prosemirror/extensions/nodes/TableExtension.ts b/packages/core/src/prosemirror/extensions/nodes/TableExtension.ts index e4d043d5..3bc4dff3 100644 --- a/packages/core/src/prosemirror/extensions/nodes/TableExtension.ts +++ b/packages/core/src/prosemirror/extensions/nodes/TableExtension.ts @@ -525,6 +525,7 @@ function getTableContext(state: EditorState): TableContextInfo { // Detect CellSelection (multi-cell selection from prosemirror-tables) const isCellSel = selection instanceof CellSelection; + const hasMultiCellSelection = isCellSel && selection.$anchorCell.pos !== selection.$headCell.pos; let table: PMNode | undefined; let tablePos: number | undefined; @@ -582,8 +583,7 @@ function getTableContext(state: EditorState): TableContextInfo { } }); - const canSplitCell = - cellNode && ((cellNode.attrs.colspan || 1) > 1 || (cellNode.attrs.rowspan || 1) > 1); + const canSplitCell = !!cellNode && !hasMultiCellSelection; // Extract border color and background color from current cell let cellBorderColor: TableContextInfo['cellBorderColor']; @@ -616,7 +616,7 @@ function getTableContext(state: EditorState): TableContextInfo { columnIndex, rowCount, columnCount, - hasMultiCellSelection: isCellSel, + hasMultiCellSelection, canSplitCell: !!canSplitCell, cellBorderColor, cellBackgroundColor, @@ -1048,17 +1048,23 @@ export const TablePluginExtension = createExtension({ let tr = state.tr; const newColumnCount = (context.columnCount || 1) + 1; const newColWidthPercent = Math.floor(100 / newColumnCount); - + const rowStarts: number[] = []; let rowPos = context.tablePos + 1; - let rowIndex = 0; context.table.forEach((row) => { + rowStarts.push(rowPos); + rowPos += row.nodeSize; + }); + + context.table.forEach((row, _offset, rowIndex) => { if (row.type.name === 'tableRow') { - let cellPos = rowPos + 1; + const mappedRowPos = tr.mapping.map(rowStarts[rowIndex]); + let cellPos = mappedRowPos + 1; let colIdx = 0; + let inserted = false; row.forEach((cell) => { - if (colIdx === context.columnIndex) { + if (!inserted && colIdx === context.columnIndex) { const paragraph = schema.nodes.paragraph.create(); const cellAttrs: any = buildCellAttrsFromTemplate(cell, { colspan: 1, @@ -1068,12 +1074,13 @@ export const TablePluginExtension = createExtension({ cellAttrs.widthType = 'pct'; const newCell = schema.nodes.tableCell.create(cellAttrs, paragraph); tr = tr.insert(cellPos, newCell); + inserted = true; } cellPos += cell.nodeSize; colIdx += cell.attrs.colspan || 1; }); - if (colIdx <= context.columnIndex!) { + if (!inserted && colIdx <= context.columnIndex!) { const paragraph = schema.nodes.paragraph.create(); const cellAttrs: any = buildCellAttrsFromTemplate( row.child(row.childCount - 1) ?? null, @@ -1084,10 +1091,7 @@ export const TablePluginExtension = createExtension({ const newCell = schema.nodes.tableCell.create(cellAttrs, paragraph); tr = tr.insert(cellPos, newCell); } - - rowIndex++; } - rowPos += row.nodeSize; }); const updatedTable = tr.doc.nodeAt(context.tablePos); @@ -1136,13 +1140,18 @@ export const TablePluginExtension = createExtension({ let tr = state.tr; const newColumnCount = (context.columnCount || 1) + 1; const newColWidthPercent = Math.floor(100 / newColumnCount); - + const rowStarts: number[] = []; let rowPos = context.tablePos + 1; - let rowIndex = 0; context.table.forEach((row) => { + rowStarts.push(rowPos); + rowPos += row.nodeSize; + }); + + context.table.forEach((row, _offset, rowIndex) => { if (row.type.name === 'tableRow') { - let cellPos = rowPos + 1; + const mappedRowPos = tr.mapping.map(rowStarts[rowIndex]); + let cellPos = mappedRowPos + 1; let colIdx = 0; let inserted = false; @@ -1175,10 +1184,7 @@ export const TablePluginExtension = createExtension({ const newCell = schema.nodes.tableCell.create(cellAttrs, paragraph); tr = tr.insert(cellPos, newCell); } - - rowIndex++; } - rowPos += row.nodeSize; }); const updatedTable = tr.doc.nodeAt(context.tablePos); diff --git a/packages/react/i18n/de.json b/packages/react/i18n/de.json index 625b13ac..d56c60a0 100644 --- a/packages/react/i18n/de.json +++ b/packages/react/i18n/de.json @@ -228,6 +228,15 @@ "insertButton": "Tabelle einfügen", "sizeSelector": "Tabellengröße auswählen" }, + "splitCell": { + "title": "Zelle teilen", + "description": "Legen Sie fest, in wie viele Zeilen und Spalten die ausgewählte Zelle geteilt werden soll.", + "rowsLabel": "Zeilen:", + "columnsLabel": "Spalten:", + "currentMinimum": "Minimum aus der aktuellen Spanne: {rows} Zeile(n) x {cols} Spalte(n)", + "minValue": "Verwenden Sie mindestens {rows} Zeile(n) und {cols} Spalte(n).", + "notOneByOne": "Wählen Sie mindestens zwei resultierende Zellen." + }, "insertImage": { "title": "Grafik einfügen", "uploadAriaLabel": "Klicken oder ziehen, um ein Bild hochzuladen", diff --git a/packages/react/i18n/en.json b/packages/react/i18n/en.json index 0c8e38fe..bca229fa 100644 --- a/packages/react/i18n/en.json +++ b/packages/react/i18n/en.json @@ -228,6 +228,15 @@ "insertButton": "Insert Table", "sizeSelector": "Table size selector" }, + "splitCell": { + "title": "Split Cell", + "description": "Set how many rows and columns to split the selected cell into.", + "rowsLabel": "Rows:", + "columnsLabel": "Columns:", + "currentMinimum": "Minimum from current span: {rows} row(s) x {cols} column(s)", + "minValue": "Use at least {rows} row(s) and {cols} column(s).", + "notOneByOne": "Choose at least two resulting cells." + }, "insertImage": { "title": "Insert Image", "uploadAriaLabel": "Click or drag to upload image", diff --git a/packages/react/i18n/pl.json b/packages/react/i18n/pl.json index 11358033..e6012008 100644 --- a/packages/react/i18n/pl.json +++ b/packages/react/i18n/pl.json @@ -228,6 +228,15 @@ "insertButton": "Wstaw tabelę", "sizeSelector": "Wybór rozmiaru tabeli" }, + "splitCell": { + "title": "Podziel komórkę", + "description": "Ustaw, na ile wierszy i kolumn ma zostać podzielona wybrana komórka.", + "rowsLabel": "Wiersze:", + "columnsLabel": "Kolumny:", + "currentMinimum": "Minimum z bieżącego zakresu: {rows} wiersz(y) x {cols} kolumna(y)", + "minValue": "Użyj co najmniej {rows} wiersz(y) i {cols} kolumna(y).", + "notOneByOne": "Wybierz co najmniej dwie wynikowe komórki." + }, "insertImage": { "title": "Wstaw obraz", "uploadAriaLabel": "Kliknij lub przeciągnij, aby przesłać obraz", diff --git a/packages/react/src/components/DocxEditor.tsx b/packages/react/src/components/DocxEditor.tsx index da11094c..449af0cf 100644 --- a/packages/react/src/components/DocxEditor.tsx +++ b/packages/react/src/components/DocxEditor.tsx @@ -27,6 +27,7 @@ import type { HeaderFooter, SectionProperties, } from '@eigenpal/docx-core/types/document'; +import defaultLocale from '../../i18n/en.json'; import { ToolbarButton, @@ -76,6 +77,7 @@ const HyperlinkDialog = lazy(() => import('./dialogs/HyperlinkDialog')); const TablePropertiesDialog = lazy(() => import('./dialogs/TablePropertiesDialog').then((m) => ({ default: m.TablePropertiesDialog })) ); +const SplitCellDialog = lazy(() => import('./dialogs/SplitCellDialog')); const ImagePositionDialog = lazy(() => import('./dialogs/ImagePositionDialog').then((m) => ({ default: m.ImagePositionDialog })) ); @@ -109,6 +111,7 @@ import { resolveColor } from '@eigenpal/docx-core/utils/colorResolver'; import { executeCommand } from '@eigenpal/docx-core/agent/executor'; import { useTableSelection } from '../hooks/useTableSelection'; import { useDocumentHistory } from '../hooks/useHistory'; +import { getSplitCellDialogConfig, splitActiveTableCell } from './tableSplit'; // Extension system import { createStarterKit } from '@eigenpal/docx-core/prosemirror/extensions/StarterKit'; @@ -166,7 +169,6 @@ import { // Table of Contents command generateTOC, // Table commands - isInTable, getTableContext, insertTable, addRowAbove, @@ -180,7 +182,6 @@ import { selectRow as pmSelectRow, selectColumn as pmSelectColumn, mergeCells as pmMergeCells, - splitCell as pmSplitCell, setCellBorder, setCellVerticalAlign, setCellMargins, @@ -840,6 +841,14 @@ export const DocxEditor = forwardRef(function Do // Table properties dialog state const [tablePropsOpen, setTablePropsOpen] = useState(false); + const [splitCellDialogState, setSplitCellDialogState] = useState({ + isOpen: false, + initialRows: 1, + initialCols: 2, + minRows: 1, + minCols: 1, + source: null as 'pm' | 'legacy' | null, + }); // Image position dialog state const [imagePositionOpen, setImagePositionOpen] = useState(false); // Image properties dialog state @@ -890,7 +899,14 @@ export const DocxEditor = forwardRef(function Do position: { x: number; y: number }; hasSelection: boolean; cursorInTable: boolean; - }>({ isOpen: false, position: { x: 0, y: 0 }, hasSelection: false, cursorInTable: false }); + tableContext: TableContextInfo | null; + }>({ + isOpen: false, + position: { x: 0, y: 0 }, + hasSelection: false, + cursorInTable: false, + tableContext: null, + }); // Debounce timers (avoid full doc walk on every keystroke) const extractTrackedChangesTimerRef = useRef | null>(null); @@ -1957,11 +1973,32 @@ export const DocxEditor = forwardRef(function Do [history, pushDocument] ); + const openSplitCellDialog = useCallback(() => { + const view = getActiveEditorView(); + const pmConfig = view ? getSplitCellDialogConfig(view.state) : null; + const legacyConfig = pmConfig ? null : tableSelection.getSplitCellConfig(); + const config = pmConfig ?? legacyConfig; + if (!config) return; + + setSplitCellDialogState({ + isOpen: true, + ...config, + source: pmConfig ? 'pm' : 'legacy', + }); + }, [getActiveEditorView, tableSelection]); + // Handle table action from Toolbar - use ProseMirror commands const handleTableAction = useCallback( (action: TableAction) => { const view = getActiveEditorView(); - if (!view) return; + if (!view) { + if (action === 'splitCell') { + openSplitCellDialog(); + } else if (typeof action !== 'object') { + tableSelection.handleAction(action); + } + return; + } switch (action) { case 'addRowAbove': @@ -1998,7 +2035,7 @@ export const DocxEditor = forwardRef(function Do pmMergeCells(view.state, view.dispatch); break; case 'splitCell': - pmSplitCell(view.state, view.dispatch); + openSplitCellDialog(); break; // Border actions — use current border spec from toolbar case 'borderAll': @@ -2140,22 +2177,27 @@ export const DocxEditor = forwardRef(function Do focusActiveEditor(); }, - [tableSelection, getActiveEditorView, focusActiveEditor] + [tableSelection, getActiveEditorView, focusActiveEditor, openSplitCellDialog] ); // Context menu handler const handleEditorContextMenu = useCallback((e: React.MouseEvent) => { + const target = e.target as HTMLElement | null; + if (target?.closest('.paged-editor__pages')) { + return; + } e.preventDefault(); e.stopPropagation(); const view = pagedEditorRef.current?.getView(); - const inTable = view ? isInTable(view.state) : false; + const tableContext = view ? getTableContext(view.state) : { isInTable: false }; const { from, to } = view?.state.selection ?? { from: 0, to: 0 }; const hasSel = from !== to; setContextMenu({ isOpen: true, position: { x: e.clientX, y: e.clientY }, hasSelection: hasSel, - cursorInTable: inTable, + cursorInTable: tableContext.isInTable, + tableContext: tableContext.isInTable ? tableContext : null, }); }, []); @@ -2326,7 +2368,27 @@ export const DocxEditor = forwardRef(function Do } } }, - [getActiveEditorView] + [getActiveEditorView, openSplitCellDialog] + ); + + const handleSplitCellDialogClose = useCallback(() => { + setSplitCellDialogState((prev) => ({ ...prev, isOpen: false, source: null })); + }, []); + + const handleSplitCellDialogApply = useCallback( + (rows: number, cols: number) => { + if (splitCellDialogState.source === 'legacy') { + tableSelection.applySplitCell(rows, cols); + focusActiveEditor(); + return; + } + + const view = getActiveEditorView(); + if (!view) return; + splitActiveTableCell(view.state, view.dispatch, rows, cols); + focusActiveEditor(); + }, + [focusActiveEditor, getActiveEditorView, splitCellDialogState.source, tableSelection] ); // Handle zoom change @@ -2550,12 +2612,13 @@ export const DocxEditor = forwardRef(function Do // Right-click context menu handlers const handleContextMenu = useCallback((data: { x: number; y: number; hasSelection: boolean }) => { const view = pagedEditorRef.current?.getView(); - const inTable = view ? isInTable(view.state) : false; + const tableContext = view ? getTableContext(view.state) : { isInTable: false }; setContextMenu({ isOpen: true, position: data, hasSelection: data.hasSelection, - cursorInTable: inTable, + cursorInTable: tableContext.isInTable, + tableContext: tableContext.isInTable ? tableContext : null, }); }, []); @@ -2565,6 +2628,7 @@ export const DocxEditor = forwardRef(function Do position: { x: 0, y: 0 }, hasSelection: false, cursorInTable: false, + tableContext: null, }); }, []); @@ -2602,12 +2666,23 @@ export const DocxEditor = forwardRef(function Do { action: 'deleteRow', label: 'Delete row', dividerAfter: true }, { action: 'addColumnLeft', label: 'Insert column left' }, { action: 'addColumnRight', label: 'Insert column right' }, - { action: 'deleteColumn', label: 'Delete column', dividerAfter: true } + { action: 'deleteColumn', label: 'Delete column' }, + { + action: 'mergeCells', + label: i18n?.table?.mergeCells ?? defaultLocale.table.mergeCells, + disabled: !contextMenu.tableContext?.hasMultiCellSelection, + }, + { + action: 'splitCell', + label: i18n?.table?.splitCell ?? defaultLocale.table.splitCell, + disabled: !contextMenu.tableContext?.canSplitCell, + dividerAfter: true, + } ); } items.push({ action: 'selectAll', label: 'Select All', shortcut: `${mod}+A` }); return items; - }, [contextMenu.hasSelection, contextMenu.cursorInTable]); + }, [contextMenu.hasSelection, contextMenu.cursorInTable, contextMenu.tableContext]); const handleContextMenuAction = useCallback( async (action: TextContextAction) => { @@ -2698,6 +2773,12 @@ export const DocxEditor = forwardRef(function Do case 'deleteColumn': pmDeleteColumn(view.state, view.dispatch); break; + case 'mergeCells': + pmMergeCells(view.state, view.dispatch); + break; + case 'splitCell': + openSplitCellDialog(); + break; // Comment — same flow as floating comment button case 'addComment': { const { from, to } = view.state.selection; @@ -2725,7 +2806,7 @@ export const DocxEditor = forwardRef(function Do } // TextContextMenu calls onClose after onAction, so no need to close here }, - [getActiveEditorView, focusActiveEditor] + [getActiveEditorView, focusActiveEditor, openSplitCellDialog] ); // Handle margin changes from rulers @@ -4012,17 +4093,6 @@ body { background: white; } )} - {/* Right-click context menu */} - setContextMenu((prev) => ({ ...prev, isOpen: false }))} - /> - {/* Inline Header/Footer Editor — positioned over the target area */} {hfEditPosition && (() => { @@ -4199,6 +4269,17 @@ body { background: white; } } /> )} + {splitCellDialogState.isOpen && ( + + )} {imagePositionOpen && ( ( ); +const MergeCellsIcon = () => ( + + + + + + +); + +const SplitCellIcon = () => ( + + + + + +); + const CommentIcon = () => ( ; case 'deleteColumn': return ; + case 'mergeCells': + return ; + case 'splitCell': + return ; case 'addComment': return ; default: @@ -764,6 +788,8 @@ export function getTextActionLabel(action: TextContextAction): string { addColumnLeft: 'Insert column left', addColumnRight: 'Insert column right', deleteColumn: 'Delete column', + mergeCells: defaultLocale.table.mergeCells, + splitCell: defaultLocale.table.splitCell, addComment: 'Comment', }; return labels[action]; @@ -787,6 +813,8 @@ export function getTextActionShortcut(action: TextContextAction): string { addColumnLeft: '', addColumnRight: '', deleteColumn: '', + mergeCells: '', + splitCell: '', addComment: '', }; return shortcuts[action]; diff --git a/packages/react/src/components/dialogs/SplitCellDialog.tsx b/packages/react/src/components/dialogs/SplitCellDialog.tsx new file mode 100644 index 00000000..555ec913 --- /dev/null +++ b/packages/react/src/components/dialogs/SplitCellDialog.tsx @@ -0,0 +1,213 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import type { CSSProperties } from 'react'; +import { useTranslation } from '../../i18n'; + +export interface SplitCellDialogProps { + isOpen: boolean; + onClose: () => void; + onApply: (rows: number, cols: number) => void; + initialRows?: number; + initialCols?: number; + minRows?: number; + minCols?: number; +} + +const overlayStyle: CSSProperties = { + position: 'fixed', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + zIndex: 10000, +}; + +const dialogStyle: CSSProperties = { + backgroundColor: 'white', + borderRadius: 8, + boxShadow: '0 4px 20px rgba(0, 0, 0, 0.15)', + minWidth: 360, + maxWidth: 440, + width: '100%', + margin: 20, +}; + +const headerStyle: CSSProperties = { + padding: '16px 20px 12px', + borderBottom: '1px solid var(--doc-border)', + fontSize: 16, + fontWeight: 600, +}; + +const bodyStyle: CSSProperties = { + padding: '16px 20px', + display: 'flex', + flexDirection: 'column', + gap: 12, +}; + +const rowStyle: CSSProperties = { + display: 'flex', + alignItems: 'center', + gap: 12, +}; + +const labelStyle: CSSProperties = { + width: 88, + fontSize: 13, + color: 'var(--doc-text-muted)', +}; + +const inputStyle: CSSProperties = { + flex: 1, + padding: '6px 8px', + border: '1px solid var(--doc-border)', + borderRadius: 4, + fontSize: 13, +}; + +const helperStyle: CSSProperties = { + fontSize: 12, + color: 'var(--doc-text-muted)', + lineHeight: 1.5, +}; + +const errorStyle: CSSProperties = { + ...helperStyle, + color: 'var(--doc-error)', +}; + +const footerStyle: CSSProperties = { + padding: '12px 20px 16px', + borderTop: '1px solid var(--doc-border)', + display: 'flex', + justifyContent: 'flex-end', + gap: 8, +}; + +const btnStyle: CSSProperties = { + padding: '6px 16px', + fontSize: 13, + border: '1px solid var(--doc-border)', + borderRadius: 4, + cursor: 'pointer', +}; + +export function SplitCellDialog({ + isOpen, + onClose, + onApply, + initialRows = 1, + initialCols = 1, + minRows = 1, + minCols = 1, +}: SplitCellDialogProps): React.ReactElement | null { + const { t } = useTranslation(); + const [rows, setRows] = useState(initialRows); + const [cols, setCols] = useState(initialCols); + + useEffect(() => { + if (isOpen) { + setRows(initialRows); + setCols(initialCols); + } + }, [initialCols, initialRows, isOpen]); + + const validationError = useMemo(() => { + if (rows < minRows || cols < minCols) { + return t('dialogs.splitCell.minValue', { rows: minRows, cols: minCols }); + } + if (rows === 1 && cols === 1) { + return t('dialogs.splitCell.notOneByOne'); + } + return null; + }, [cols, minCols, minRows, rows, t]); + + const handleApply = useCallback(() => { + if (validationError) return; + onApply(rows, cols); + onClose(); + }, [cols, onApply, onClose, rows, validationError]); + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === 'Escape') onClose(); + if (event.key === 'Enter') handleApply(); + }, + [handleApply, onClose] + ); + + if (!isOpen) return null; + + return ( +
+
event.stopPropagation()} + role="dialog" + aria-label={t('dialogs.splitCell.title')} + > +
{t('dialogs.splitCell.title')}
+ +
+
{t('dialogs.splitCell.description')}
+ +
+ + setRows(Math.max(0, Number(event.target.value) || 0))} + /> +
+ +
+ + setCols(Math.max(0, Number(event.target.value) || 0))} + /> +
+ +
+ {validationError ?? + t('dialogs.splitCell.currentMinimum', { rows: minRows, cols: minCols })} +
+
+ +
+ + +
+
+
+ ); +} + +export default SplitCellDialog; diff --git a/packages/react/src/components/tableSplit.ts b/packages/react/src/components/tableSplit.ts new file mode 100644 index 00000000..f63dbd65 --- /dev/null +++ b/packages/react/src/components/tableSplit.ts @@ -0,0 +1,352 @@ +import type { Node as PMNode } from 'prosemirror-model'; +import type { EditorState, Transaction } from 'prosemirror-state'; +import { TextSelection } from 'prosemirror-state'; + +interface TableAnchor { + node: PMNode; + row: number; + col: number; + rowspan: number; + colspan: number; +} + +export interface SplitCellDialogConfig { + minRows: number; + minCols: number; + initialRows: number; + initialCols: number; +} + +interface ActiveTableCellInfo { + table: PMNode; + tablePos: number; + cell: PMNode; + row: number; + col: number; + rowspan: number; + colspan: number; +} + +function findActiveTableCell(state: EditorState): ActiveTableCellInfo | null { + const { $from } = state.selection; + + let table: PMNode | null = null; + let tablePos: number | null = null; + let cell: PMNode | null = null; + let row = -1; + let col = -1; + + for (let depth = $from.depth; depth > 0; depth--) { + const node = $from.node(depth); + + if (node.type.name === 'tableCell' || node.type.name === 'tableHeader') { + cell = node; + const rowNode = $from.node(depth - 1); + if (rowNode?.type.name === 'tableRow') { + let currentCol = 0; + rowNode.forEach((child, _offset, index) => { + if (col !== -1) return; + if (index === $from.index(depth - 1)) { + col = currentCol; + return; + } + currentCol += child.attrs.colspan || 1; + }); + } + } else if (node.type.name === 'tableRow') { + const parent = $from.node(depth - 1); + if (parent?.type.name === 'table') { + row = $from.index(depth - 1); + } + } else if (node.type.name === 'table') { + table = node; + tablePos = $from.before(depth); + break; + } + } + + if (!table || tablePos == null || !cell || row < 0 || col < 0) return null; + + return { + table, + tablePos, + cell, + row, + col, + rowspan: cell.attrs.rowspan || 1, + colspan: cell.attrs.colspan || 1, + }; +} + +function collectTableAnchors(table: PMNode): { anchors: TableAnchor[]; totalCols: number } { + const occupied: boolean[][] = []; + const anchors: TableAnchor[] = []; + let totalCols = 0; + + for (let row = 0; row < table.childCount; row++) { + const rowNode = table.child(row); + let col = 0; + + rowNode.forEach((cell) => { + while (occupied[row]?.[col]) col++; + + const rowspan = cell.attrs.rowspan || 1; + const colspan = cell.attrs.colspan || 1; + + anchors.push({ node: cell, row, col, rowspan, colspan }); + + for (let r = row; r < row + rowspan; r++) { + const rowSlots = occupied[r] ?? []; + occupied[r] = rowSlots; + for (let c = col; c < col + colspan; c++) { + rowSlots[c] = true; + } + } + + col += colspan; + totalCols = Math.max(totalCols, col); + }); + } + + return { anchors, totalCols }; +} + +function sumColumnWidths(widths: number[], start: number, span: number): number { + let total = 0; + for (let index = start; index < start + span && index < widths.length; index++) { + total += widths[index]; + } + return total; +} + +function splitColumnWidths( + table: PMNode, + totalCols: number, + startCol: number, + currentSpan: number, + targetSpan: number +): number[] { + const tableWidth = (table.attrs.width as number | null) ?? 9360; + const existing = + Array.isArray(table.attrs.columnWidths) && table.attrs.columnWidths.length > 0 + ? [...(table.attrs.columnWidths as number[])] + : Array.from({ length: totalCols }, () => Math.floor(tableWidth / Math.max(totalCols, 1))); + + const sliceWidth = sumColumnWidths(existing, startCol, currentSpan); + const nextSegmentWidth = Math.floor(sliceWidth / Math.max(targetSpan, 1)); + const remainder = sliceWidth - nextSegmentWidth * targetSpan; + const replacement = Array.from( + { length: targetSpan }, + (_, index) => nextSegmentWidth + (index < remainder ? 1 : 0) + ); + + return [ + ...existing.slice(0, startCol), + ...replacement, + ...existing.slice(startCol + currentSpan), + ]; +} + +function buildCellAttrs( + cell: PMNode, + colStart: number, + colspan: number, + rowspan: number, + columnWidths: number[] +): Record { + const attrs = { ...cell.attrs }; + const totalWidth = columnWidths.reduce((sum, width) => sum + width, 0); + const cellWidth = sumColumnWidths(columnWidths, colStart, colspan); + const spansChanged = + colspan !== (cell.attrs.colspan || 1) || rowspan !== (cell.attrs.rowspan || 1); + + attrs.colspan = colspan; + attrs.rowspan = rowspan; + attrs.colwidth = null; + if (totalWidth > 0) { + attrs.width = Math.round((cellWidth / totalWidth) * 100); + attrs.widthType = 'pct'; + } + if (spansChanged) { + attrs._originalFormatting = null; + } + + return attrs; +} + +function createEmptySplitCellContent(cell: PMNode): PMNode[] { + const paragraph = cell.type.schema.nodes.paragraph.create(); + return [paragraph]; +} + +function findCellStartPos( + table: PMNode, + tablePos: number, + rowIndex: number, + colIndex: number +): number | null { + let rowPos = tablePos + 1; + + for (let row = 0; row < table.childCount; row++) { + const rowNode = table.child(row); + let cellPos = rowPos + 1; + let currentCol = 0; + + for (let cellIndex = 0; cellIndex < rowNode.childCount; cellIndex++) { + const cell = rowNode.child(cellIndex); + if (row === rowIndex && currentCol === colIndex) { + return cellPos; + } + currentCol += cell.attrs.colspan || 1; + cellPos += cell.nodeSize; + } + + rowPos += rowNode.nodeSize; + } + + return null; +} + +export function getSplitCellDialogConfig(state: EditorState): SplitCellDialogConfig | null { + const activeCell = findActiveTableCell(state); + if (!activeCell) return null; + + const initialRows = activeCell.rowspan; + const initialCols = + activeCell.rowspan > 1 || activeCell.colspan > 1 ? activeCell.colspan : activeCell.colspan + 1; + + return { + minRows: activeCell.rowspan, + minCols: activeCell.colspan, + initialRows, + initialCols, + }; +} + +export function splitActiveTableCell( + state: EditorState, + dispatch: ((tr: Transaction) => void) | undefined, + rows: number, + cols: number +): boolean { + const activeCell = findActiveTableCell(state); + if (!activeCell || !dispatch) return false; + if (rows < activeCell.rowspan || cols < activeCell.colspan) return false; + if (rows === 1 && cols === 1) return false; + + const { anchors, totalCols } = collectTableAnchors(activeCell.table); + const totalRows = activeCell.table.childCount; + const deltaRows = rows - activeCell.rowspan; + const deltaCols = cols - activeCell.colspan; + const newRowCount = totalRows + deltaRows; + const newColumnWidths = splitColumnWidths( + activeCell.table, + totalCols, + activeCell.col, + activeCell.colspan, + cols + ); + + const target = anchors.find( + (anchor) => anchor.row === activeCell.row && anchor.col === activeCell.col + ); + if (!target) return false; + + const nextAnchors: TableAnchor[] = []; + const targetRowEnd = activeCell.row + activeCell.rowspan; + const targetColEnd = activeCell.col + activeCell.colspan; + + for (const anchor of anchors) { + if (anchor === target) continue; + + const rowEnd = anchor.row + anchor.rowspan; + const colEnd = anchor.col + anchor.colspan; + const rowIntersectsBand = anchor.row < targetRowEnd && rowEnd > activeCell.row; + const colIntersectsBand = anchor.col < targetColEnd && colEnd > activeCell.col; + + nextAnchors.push({ + node: anchor.node, + row: anchor.row >= targetRowEnd ? anchor.row + deltaRows : anchor.row, + col: anchor.col >= targetColEnd ? anchor.col + deltaCols : anchor.col, + rowspan: + anchor.rowspan + (deltaRows > 0 && rowIntersectsBand && !colIntersectsBand ? deltaRows : 0), + colspan: + anchor.colspan + (deltaCols > 0 && colIntersectsBand && !rowIntersectsBand ? deltaCols : 0), + }); + } + + for (let rowOffset = 0; rowOffset < rows; rowOffset++) { + for (let colOffset = 0; colOffset < cols; colOffset++) { + const content = + rowOffset === 0 && colOffset === 0 + ? target.node.content + : createEmptySplitCellContent(target.node); + const attrs = buildCellAttrs(target.node, activeCell.col + colOffset, 1, 1, newColumnWidths); + nextAnchors.push({ + node: target.node.type.create(attrs, content), + row: activeCell.row + rowOffset, + col: activeCell.col + colOffset, + rowspan: 1, + colspan: 1, + }); + } + } + + const rowAttrs = Array.from({ length: newRowCount }, (_, rowIndex) => { + if (rowIndex < targetRowEnd) { + return { ...(activeCell.table.child(rowIndex)?.attrs ?? {}) }; + } + if (rowIndex < activeCell.row + rows) { + return { ...(activeCell.table.child(targetRowEnd - 1)?.attrs ?? {}) }; + } + return { ...(activeCell.table.child(rowIndex - deltaRows)?.attrs ?? {}) }; + }); + + const rowChildren = Array.from({ length: newRowCount }, () => [] as PMNode[]); + nextAnchors + .sort((a, b) => (a.row === b.row ? a.col - b.col : a.row - b.row)) + .forEach((anchor) => { + const attrs = buildCellAttrs( + anchor.node, + anchor.col, + anchor.colspan, + anchor.rowspan, + newColumnWidths + ); + rowChildren[anchor.row].push(anchor.node.type.create(attrs, anchor.node.content)); + }); + + const rowNodes = rowChildren.map((cells, rowIndex) => + activeCell.table.type.schema.nodes.tableRow.create(rowAttrs[rowIndex], cells) + ); + + const newTable = activeCell.table.type.create( + { + ...activeCell.table.attrs, + columnWidths: newColumnWidths, + }, + rowNodes + ); + + let tr = state.tr.replaceWith( + activeCell.tablePos, + activeCell.tablePos + activeCell.table.nodeSize, + newTable + ); + + const replacedTable = tr.doc.nodeAt(activeCell.tablePos); + if (replacedTable) { + const selectionCellPos = findCellStartPos( + replacedTable, + activeCell.tablePos, + activeCell.row, + activeCell.col + ); + if (selectionCellPos != null) { + tr = tr.setSelection(TextSelection.near(tr.doc.resolve(selectionCellPos + 2))); + } + } + + dispatch(tr.scrollIntoView()); + return true; +} diff --git a/packages/react/src/components/ui/TableToolbar.tsx b/packages/react/src/components/ui/TableToolbar.tsx index deabd8a1..7c1d2e64 100644 --- a/packages/react/src/components/ui/TableToolbar.tsx +++ b/packages/react/src/components/ui/TableToolbar.tsx @@ -127,6 +127,13 @@ export interface TableContext { columnCount: number; } +export interface TableSplitConfig { + minRows: number; + minCols: number; + initialRows: number; + initialCols: number; +} + /** * Props for TableToolbar component */ @@ -536,12 +543,10 @@ export function createTableContext(table: Table, selection: TableSelection): Tab selection.selectedCells.startCol !== selection.selectedCells.endCol) ); - // Check if current cell can be split (has gridSpan > 1 or vMerge) const currentCell = getCellAt(table, selection.rowIndex, selection.columnIndex); - const canSplitCell = !!( - currentCell && - ((currentCell.formatting?.gridSpan ?? 1) > 1 || currentCell.formatting?.vMerge === 'restart') - ); + // Split is available for a single active cell. The UI opens a dialog and + // applies the requested row/column split explicitly. + const canSplitCell = !!currentCell && !hasMultiCellSelection; return { table, @@ -704,6 +709,278 @@ export function createEmptyCell(): TableCell { }; } +interface DocumentTableAnchor { + cell: TableCell; + row: number; + col: number; + rowspan: number; + colspan: number; +} + +function getRowCellStartingAt(row: TableRow, targetCol: number): TableCell | null { + let currentCol = 0; + for (const cell of row.cells) { + const colspan = cell.formatting?.gridSpan ?? 1; + if (currentCol === targetCol) { + return cell; + } + currentCol += colspan; + } + return null; +} + +function collectDocumentTableAnchors(table: Table): { + anchors: DocumentTableAnchor[]; + totalCols: number; +} { + const anchors: DocumentTableAnchor[] = []; + let totalCols = 0; + + for (let rowIndex = 0; rowIndex < table.rows.length; rowIndex++) { + const row = table.rows[rowIndex]; + let colIndex = 0; + + for (const cell of row.cells) { + const colspan = cell.formatting?.gridSpan ?? 1; + if (cell.formatting?.vMerge !== 'continue') { + let rowspan = 1; + if (cell.formatting?.vMerge === 'restart') { + for (let nextRow = rowIndex + 1; nextRow < table.rows.length; nextRow++) { + const continuation = getRowCellStartingAt(table.rows[nextRow], colIndex); + if (!continuation || continuation.formatting?.vMerge !== 'continue') break; + rowspan += 1; + } + } + + anchors.push({ + cell, + row: rowIndex, + col: colIndex, + rowspan, + colspan, + }); + } + + colIndex += colspan; + totalCols = Math.max(totalCols, colIndex); + } + } + + return { anchors, totalCols }; +} + +function findDocumentTableAnchor( + table: Table, + rowIndex: number, + columnIndex: number +): DocumentTableAnchor | null { + const { anchors } = collectDocumentTableAnchors(table); + return ( + anchors.find( + (anchor) => + rowIndex >= anchor.row && + rowIndex < anchor.row + anchor.rowspan && + columnIndex >= anchor.col && + columnIndex < anchor.col + anchor.colspan + ) ?? null + ); +} + +function splitTableColumnWidths( + table: Table, + totalCols: number, + startCol: number, + currentSpan: number, + targetSpan: number +): number[] | undefined { + const existing = + table.columnWidths && table.columnWidths.length > 0 + ? [...table.columnWidths] + : Array.from({ length: totalCols }, () => 1440); + + const sliceWidth = existing + .slice(startCol, startCol + currentSpan) + .reduce((sum, width) => sum + width, 0); + const baseWidth = Math.floor(sliceWidth / Math.max(targetSpan, 1)); + const remainder = sliceWidth - baseWidth * targetSpan; + const replacement = Array.from( + { length: targetSpan }, + (_, index) => baseWidth + (index < remainder ? 1 : 0) + ); + + return [ + ...existing.slice(0, startCol), + ...replacement, + ...existing.slice(startCol + currentSpan), + ]; +} + +function toAnchorCellFormatting(cell: TableCell, colspan: number, rowspan: number) { + const formatting = { ...(cell.formatting ?? {}) }; + if (colspan > 1) formatting.gridSpan = colspan; + else delete formatting.gridSpan; + if (rowspan > 1) formatting.vMerge = 'restart'; + else delete formatting.vMerge; + return Object.keys(formatting).length ? formatting : undefined; +} + +function toContinuationFormatting(cell: TableCell, colspan: number) { + const formatting = { ...(cell.formatting ?? {}) }; + if (colspan > 1) formatting.gridSpan = colspan; + else delete formatting.gridSpan; + formatting.vMerge = 'continue'; + return formatting; +} + +function createEmptySplitCell(template: TableCell): TableCell { + return { + type: 'tableCell', + content: [{ type: 'paragraph', content: [], formatting: {} }], + formatting: toAnchorCellFormatting(template, 1, 1), + }; +} + +export function getTableSplitCellDialogConfig( + table: Table, + rowIndex: number, + columnIndex: number +): TableSplitConfig | null { + const anchor = findDocumentTableAnchor(table, rowIndex, columnIndex); + if (!anchor) return null; + + return { + minRows: anchor.rowspan, + minCols: anchor.colspan, + initialRows: anchor.rowspan, + initialCols: anchor.rowspan > 1 || anchor.colspan > 1 ? anchor.colspan : anchor.colspan + 1, + }; +} + +export function splitTableCell( + table: Table, + rowIndex: number, + columnIndex: number, + rows: number, + cols: number +): Table { + const { anchors, totalCols } = collectDocumentTableAnchors(table); + const target = anchors.find( + (anchor) => + rowIndex >= anchor.row && + rowIndex < anchor.row + anchor.rowspan && + columnIndex >= anchor.col && + columnIndex < anchor.col + anchor.colspan + ); + if (!target) return table; + if (rows < target.rowspan || cols < target.colspan) return table; + if (rows === 1 && cols === 1) return table; + + const deltaRows = rows - target.rowspan; + const deltaCols = cols - target.colspan; + const targetRowEnd = target.row + target.rowspan; + const targetColEnd = target.col + target.colspan; + const nextAnchors: DocumentTableAnchor[] = []; + + for (const anchor of anchors) { + if (anchor === target) continue; + + const rowEnd = anchor.row + anchor.rowspan; + const colEnd = anchor.col + anchor.colspan; + const rowIntersectsBand = anchor.row < targetRowEnd && rowEnd > target.row; + const colIntersectsBand = anchor.col < targetColEnd && colEnd > target.col; + + nextAnchors.push({ + cell: anchor.cell, + row: anchor.row >= targetRowEnd ? anchor.row + deltaRows : anchor.row, + col: anchor.col >= targetColEnd ? anchor.col + deltaCols : anchor.col, + rowspan: + anchor.rowspan + (deltaRows > 0 && rowIntersectsBand && !colIntersectsBand ? deltaRows : 0), + colspan: + anchor.colspan + (deltaCols > 0 && colIntersectsBand && !rowIntersectsBand ? deltaCols : 0), + }); + } + + for (let rowOffset = 0; rowOffset < rows; rowOffset++) { + for (let colOffset = 0; colOffset < cols; colOffset++) { + nextAnchors.push({ + cell: + rowOffset === 0 && colOffset === 0 + ? { + ...target.cell, + formatting: toAnchorCellFormatting(target.cell, 1, 1), + } + : createEmptySplitCell(target.cell), + row: target.row + rowOffset, + col: target.col + colOffset, + rowspan: 1, + colspan: 1, + }); + } + } + + const anchorByStart = new Map(); + const anchorByCoveredSlot = new Map(); + for (const anchor of nextAnchors) { + 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 newRowCount = table.rows.length + deltaRows; + const newColCount = totalCols + deltaCols; + const newRows: TableRow[] = []; + + for (let row = 0; row < newRowCount; row++) { + const sourceRow = + row < targetRowEnd + ? table.rows[row] + : row < target.row + rows + ? table.rows[targetRowEnd - 1] + : table.rows[row - deltaRows]; + + const cells: TableCell[] = []; + for (let col = 0; col < newColCount; ) { + const anchor = anchorByStart.get(`${row}-${col}`); + if (anchor) { + cells.push({ + ...anchor.cell, + formatting: toAnchorCellFormatting(anchor.cell, anchor.colspan, anchor.rowspan), + }); + col += anchor.colspan; + continue; + } + + const coveringAnchor = anchorByCoveredSlot.get(`${row}-${col}`); + if (!coveringAnchor) { + col += 1; + continue; + } + + cells.push({ + ...coveringAnchor.cell, + content: [], + formatting: toContinuationFormatting(coveringAnchor.cell, coveringAnchor.colspan), + }); + col += coveringAnchor.colspan; + } + + newRows.push({ + type: 'tableRow', + formatting: sourceRow?.formatting ? { ...sourceRow.formatting } : undefined, + cells, + }); + } + + return { + ...table, + rows: newRows, + columnWidths: splitTableColumnWidths(table, totalCols, target.col, target.colspan, cols), + }; +} + /** * Add a row to a table at the specified index */ @@ -918,7 +1195,11 @@ export function mergeCells(table: Table, selection: TableSelection): Table { } /** - * Split a merged cell + * Backward-compatible helper for callers that still use the older merged-cell + * split behavior directly. + * + * User-facing Split cell is now dialog-driven. For document-model tables, use + * `getTableSplitCellDialogConfig()` and `splitTableCell()` instead. */ export function splitCell(table: Table, rowIndex: number, columnIndex: number): Table { const cell = getCellAt(table, rowIndex, columnIndex); diff --git a/packages/react/src/hooks/useTableSelection.ts b/packages/react/src/hooks/useTableSelection.ts index bd96a08e..835441d4 100644 --- a/packages/react/src/hooks/useTableSelection.ts +++ b/packages/react/src/hooks/useTableSelection.ts @@ -13,7 +13,12 @@ import { deleteTableFromDocument, } from '@eigenpal/docx-core'; import type { Document, Table } from '@eigenpal/docx-core/types/document'; -import type { TableContext, TableSelection, TableAction } from '../components/ui/TableToolbar'; +import type { + TableContext, + TableSelection, + TableAction, + TableSplitConfig, +} from '../components/ui/TableToolbar'; import { createTableContext, addRow, @@ -21,7 +26,8 @@ import { addColumn, deleteColumn, mergeCells, - splitCell, + getTableSplitCellDialogConfig, + splitTableCell, getColumnCount, } from '../components/ui/TableToolbar'; @@ -54,6 +60,8 @@ export interface UseTableSelectionReturn { state: TableSelectionState; handleCellClick: (tableIndex: number, rowIndex: number, columnIndex: number) => void; handleAction: (action: TableAction) => void; + getSplitCellConfig: () => TableSplitConfig | null; + applySplitCell: (rows: number, cols: number) => void; clearSelection: () => void; isCellSelected: (tableIndex: number, rowIndex: number, columnIndex: number) => boolean; tableContext: TableContext | null; @@ -126,6 +134,49 @@ export function useTableSelection({ onSelectionChange?.(null); }, [manager, onSelectionChange]); + const getSplitCellConfig = useCallback((): TableSplitConfig | null => { + if (!state.table || state.rowIndex === null || state.columnIndex === null) { + return null; + } + + return getTableSplitCellDialogConfig(state.table, state.rowIndex, state.columnIndex); + }, [state.columnIndex, state.rowIndex, state.table]); + + const applySplitCell = useCallback( + (rows: number, cols: number) => { + if ( + !doc || + !state.table || + state.tableIndex === null || + state.rowIndex === null || + state.columnIndex === null + ) { + return; + } + + const newTable = splitTableCell(state.table, state.rowIndex, state.columnIndex, rows, cols); + if (newTable === state.table) { + return; + } + + const newDoc = updateTableInDocument(doc, state.tableIndex, newTable); + onChange?.(newDoc); + + if (newDoc) { + handleCellClick(state.tableIndex, state.rowIndex, state.columnIndex); + } + }, + [ + doc, + handleCellClick, + onChange, + state.columnIndex, + state.rowIndex, + state.table, + state.tableIndex, + ] + ); + const handleAction = useCallback( (action: TableAction) => { if ( @@ -193,9 +244,7 @@ export function useTableSelection({ break; case 'splitCell': - if (state.context.canSplitCell) { - newTable = splitCell(table, state.rowIndex, state.columnIndex); - } + // Split cell requires row/column input from the shared dialog. break; case 'deleteTable': @@ -228,6 +277,8 @@ export function useTableSelection({ state, handleCellClick, handleAction, + getSplitCellConfig, + applySplitCell, clearSelection, isCellSelected, tableContext: state.context, diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 62935f3f..16ce7481 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -203,12 +203,15 @@ export { type TableContext, type TableSelection, type TableAction, + type TableSplitConfig, createTableContext, addRow, deleteRow, addColumn, deleteColumn, mergeCells, + getTableSplitCellDialogConfig, + splitTableCell, splitCell, getColumnCount, getCellAt, diff --git a/packages/react/src/paged-editor/PagedEditor.tsx b/packages/react/src/paged-editor/PagedEditor.tsx index 131c3904..9d49cc5f 100644 --- a/packages/react/src/paged-editor/PagedEditor.tsx +++ b/packages/react/src/paged-editor/PagedEditor.tsx @@ -3489,7 +3489,12 @@ const PagedEditorComponent = forwardRef( // If the right-click is within the existing selection, keep it // Otherwise, move cursor to the right-click position if (pmPos !== null && (from === to || pmPos < from || pmPos > to)) { - hiddenPMRef.current?.setSelection(pmPos); + const cellPos = findCellPosFromPmPos(pmPos); + if (cellPos !== null) { + hiddenPMRef.current?.setSelection(cellPos + 1); + } else { + hiddenPMRef.current?.setSelection(pmPos); + } hiddenPMRef.current?.focus(); setIsFocused(true); } diff --git a/packages/react/src/ui.ts b/packages/react/src/ui.ts index a6f6261f..7d72fbc8 100644 --- a/packages/react/src/ui.ts +++ b/packages/react/src/ui.ts @@ -82,12 +82,15 @@ export { type TableContext, type TableSelection, type TableAction, + type TableSplitConfig, createTableContext, addRow, deleteRow, addColumn, deleteColumn, mergeCells, + getTableSplitCellDialogConfig, + splitTableCell, splitCell, getColumnCount, getCellAt,