Compare commits
1 Commits
3bf9363c34
...
d76c731473
| Author | SHA1 | Date | |
|---|---|---|---|
| d76c731473 |
@ -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()}`);
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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 }) =>
|
||||
|
||||
@ -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"
|
||||
},
|
||||
|
||||
@ -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"
|
||||
},
|
||||
|
||||
@ -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"');
|
||||
|
||||
@ -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>';
|
||||
});
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user