Compare commits

...

1 Commits

Author SHA1 Message Date
3bf9363c34 Merge and split table cells (#337)
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 7m18s
CI / Build container images (pull_request) Successful in 4m41s
CI / Auth e2e pack (pull_request) Successful in 10m4s
CI / Import/export fidelity gate (pull_request) Successful in 56s
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CD / Build and push images (push) Successful in 33s
CI / Lint, typecheck, test (push) Has been cancelled
prosemirror-tables already ships mergeCells/splitCell and the schema
(tableNodes) already carries colspan/rowspan -- only the controls were
missing. Adds the two commands, toolbar buttons whose enabled state
follows the selection (merge needs a multi-cell selection, split a
merged cell), and de+en labels.

Both render paths now carry the spans: docToHtml emits colspan/rowspan
(read mode, exports via the HTML path), and the markdown serializer pads
a colspan with empty cells so every row keeps the table's column count
-- rowspan stays lossy there, GFM cannot express it.

e2e drives merge and split through the toolbar; the cell selection is
made per Shift+Click because a keypress in the same tick as the
preceding click races the editor's post-click rendering (keyboard cell
selection itself works, verified interactively with a settled editor).

Closes #337

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aoPvnakfBP28nAfijgUY9
2026-08-15 21:32:14 +02:00
9 changed files with 124 additions and 1 deletions

View File

@ -96,6 +96,51 @@ test('gap cursor reaches positions before and after a lone table (issue #335)',
await context.close();
});
test('cells can be merged and split from the toolbar (issue #337)', async ({ browser }) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const { pondSlug, pageSlug } = await createPage(context, `E2E MergeSplit ${Date.now()}`);
const page = await context.newPage();
await page.goto(`/p/${pondSlug}/${pageSlug}`);
await page.getByRole('button', { name: /edit|bearbeiten/i }).click();
const status = page.locator('.editor-connection');
await expect(status).toHaveAttribute('data-status', 'connected', { timeout: 10000 });
const content = page.locator('.ProseMirror');
await content.click();
await page.getByRole('button', { name: /insert table|tabelle einfügen/i }).click();
await expect(content.locator('table')).toBeVisible();
const mergeButton = page.getByRole('button', { name: /merge cells|zellen verbinden/i });
const splitButton = page.getByRole('button', { name: /split cell|zelle teilen/i });
await expect(mergeButton).toBeDisabled();
await expect(splitButton).toBeDisabled();
// Extending the selection across the cell border turns it into a cell
// selection (prosemirror-tables), which is what merge operates on.
// Shift+Click, not Shift+ArrowRight: a keypress fired in the same tick as
// the preceding click races the editor's post-click rendering and gets
// dropped — no human types that fast (works fine interactively).
await content.locator('td').first().click();
await content
.locator('td')
.nth(1)
.click({ modifiers: ['Shift'] });
await expect(content.locator('.selectedCell')).toHaveCount(2);
await expect(mergeButton).toBeEnabled();
await mergeButton.click();
await expect(content.locator('td[colspan="2"]')).toHaveCount(1);
// Splitting the merged cell restores the row's full cell count.
await content.locator('td[colspan="2"]').click();
await expect(splitButton).toBeEnabled();
await splitButton.click();
await expect(content.locator('td[colspan="2"]')).toHaveCount(0);
await expect(content.locator('tr').nth(1).locator('td')).toHaveCount(3);
await context.close();
});
test('edit mode hides the sidebar; leaving edit mode restores it', async ({ browser }) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const { pondSlug, pageSlug } = await createPage(context, `E2E Sidebar ${Date.now()}`);

View File

@ -127,6 +127,8 @@ export function Toolbar({
canAddColumn: e.can().addColumnAfter(),
canDeleteColumn: e.can().deleteColumn(),
canDeleteTable: e.can().deleteTable(),
canMergeCells: e.can().mergeCells(),
canSplitCell: e.can().splitCell(),
canToggleHeaderRow: e.can().toggleHeaderRow(),
canUndo: e.can().undo(),
canRedo: e.can().redo(),
@ -296,6 +298,22 @@ export function Toolbar({
>
×
</ToolbarButton>
<ToolbarButton
label={t('toolbar.table.mergeCells')}
disabled={!state.canMergeCells}
onClick={() => editor.chain().focus().mergeCells().run()}
>
{/* Arrows collapsing onto / leaving a cell border: merge removes
the border between selected cells, split restores it. */}
|
</ToolbarButton>
<ToolbarButton
label={t('toolbar.table.splitCell')}
disabled={!state.canSplitCell}
onClick={() => editor.chain().focus().splitCell().run()}
>
|
</ToolbarButton>
<ToolbarButton
label={t('toolbar.table.toggleHeaderRow')}
disabled={!state.canToggleHeaderRow}

View File

@ -8,6 +8,8 @@ import {
deleteColumn,
deleteRow,
deleteTable,
mergeCells,
splitCell,
tableEditing,
toggleHeaderRow,
} from 'prosemirror-tables';
@ -34,6 +36,8 @@ declare module '@tiptap/core' {
addRowAfter: () => ReturnType;
deleteRow: () => ReturnType;
deleteTable: () => ReturnType;
mergeCells: () => ReturnType;
splitCell: () => ReturnType;
toggleHeaderRow: () => ReturnType;
};
}
@ -101,6 +105,14 @@ export const Table = Node.create({
() =>
({ state, dispatch }) =>
deleteTable(state, dispatch),
mergeCells:
() =>
({ state, dispatch }) =>
mergeCells(state, dispatch),
splitCell:
() =>
({ state, dispatch }) =>
splitCell(state, dispatch),
toggleHeaderRow:
() =>
({ state, dispatch }) =>

View File

@ -76,6 +76,8 @@
"addRowBefore": "Zeile davor einfügen",
"addRowAfter": "Zeile danach einfügen",
"deleteRow": "Zeile löschen",
"mergeCells": "Zellen verbinden",
"splitCell": "Zelle teilen",
"toggleHeaderRow": "Kopfzeile umschalten",
"deleteTable": "Tabelle löschen"
},

View File

@ -76,6 +76,8 @@
"addRowBefore": "Add row before",
"addRowAfter": "Add row after",
"deleteRow": "Delete row",
"mergeCells": "Merge cells",
"splitCell": "Split cell",
"toggleHeaderRow": "Toggle header row",
"deleteTable": "Delete table"
},

View File

@ -25,6 +25,26 @@ describe('docToHtml (issue #24)', () => {
);
});
it('keeps the cell spans of merged cells (issue #337)', () => {
// Built directly: markdown cannot express merged cells.
const cell = (text: string, attrs: { colspan?: number; rowspan?: number } | null = null) =>
editorSchema.node('table_cell', attrs, [
editorSchema.node('paragraph', null, [editorSchema.text(text)]),
]);
const doc = editorSchema.node('doc', null, [
editorSchema.node('table', null, [
editorSchema.node('table_row', null, [
cell('wide', { colspan: 2 }),
cell('tall', { rowspan: 2 }),
]),
editorSchema.node('table_row', null, [cell('a'), cell('b')]),
]),
]);
expect(docToHtml(doc)).toBe(
'<table><tr><td colspan="2"><p>wide</p></td><td rowspan="2"><p>tall</p></td></tr><tr><td><p>a</p></td><td><p>b</p></td></tr></table>',
);
});
it('allowlists link protocols, neutralizing javascript: hrefs', () => {
const safe = markdownToDoc('[go](https://example.org)');
expect(docToHtml(safe)).toContain('href="https://example.org"');

View File

@ -111,7 +111,12 @@ function renderTable(node: Node): string {
out += '<tr>';
row.forEach((cell) => {
const tag = cell.type.name === 'table_header' ? 'th' : 'td';
out += `<${tag}>${renderBlocks(cell)}</${tag}>`;
// Merged cells (issue #337): without the span attributes the read-mode
// table silently loses the merge the editor shows.
const colspan = cell.attrs.colspan as number;
const rowspan = cell.attrs.rowspan as number;
const spans = `${colspan > 1 ? ` colspan="${colspan}"` : ''}${rowspan > 1 ? ` rowspan="${rowspan}"` : ''}`;
out += `<${tag}${spans}>${renderBlocks(cell)}</${tag}>`;
});
out += '</tr>';
});

View File

@ -148,6 +148,21 @@ describe('markdown round-trip (issue #24)', () => {
expect(doc.firstChild?.attrs.data).toEqual({});
});
it('pads a colspan cell so every row keeps the column count (issue #337)', () => {
// Built directly: markdown cannot express merged cells, so the export
// is lossy by format — but it must stay a well-formed GFM table.
const schema = markdownToDoc('x').type.schema;
const cell = (type: string, text: string, attrs: { colspan?: number } | null = null) =>
schema.node(type, attrs, [schema.node('paragraph', null, [schema.text(text)])]);
const doc = schema.node('doc', null, [
schema.node('table', null, [
schema.node('table_row', null, [cell('table_header', 'A'), cell('table_header', 'B')]),
schema.node('table_row', null, [cell('table_cell', 'wide', { colspan: 2 })]),
]),
]);
expect(docToMarkdown(doc)).toBe('| A | B |\n| --- | --- |\n| wide | |');
});
it('escalates the fence when the data contains backticks (issue #76)', () => {
const doc = markdownToDoc('```dorfteich-plugin p/t\n{"code":"x"}\n```');
const withTicks = doc.type.schema.nodes.plugin_block!.create({

View File

@ -510,6 +510,10 @@ function renderTable(state: MarkdownSerializerState, node: Node): void {
const cells: string[] = [];
row.forEach((cell) => {
cells.push(cell.textContent.replace(/\|/g, '\\|').replace(/\r?\n/g, ' ').trim());
// GFM has no cell spans: pad a colspan with empty cells so every row
// keeps the table's column count (rowspan stays lossy — the covered
// rows are simply shorter; issue #337).
for (let extra = 1; extra < (cell.attrs.colspan as number); extra += 1) cells.push('');
});
rows.push(cells);
});