Compare commits

...

3 Commits

Author SHA1 Message Date
b7360dd48e Recognize Markdown tables on paste and while typing (#339)
Some checks failed
CI / Lint, typecheck, test (pull_request) Failing after 2m6s
CI / Auth e2e pack (pull_request) Has been skipped
CI / Import/export fidelity gate (pull_request) Has been skipped
CI / Build container images (pull_request) Has been skipped
The paste conversion (issue #30) already handled tables, but any
text/html flavor on the clipboard bypassed it. Code editors (VS Code
with copyWithSyntaxHighlighting) ship the plain text a second time as
styled div/span HTML, so a Markdown table copied there arrived verbatim
while the same text from a plain editor converted fine. Clipboard HTML
without a single structural element (table/list/heading/link/emphasis/
code...) is now treated as equivalent to the plain text; anything from a
rich-text source keeps going through ProseMirror's HTML paste.

Pasting inside a code block never converts anymore -- text is code
there, and the conversion would have split the block around rich nodes.

Hand-typed tables: pressing Enter at the end of a GFM separator row
whose previous sibling is a pipe row replaces the two paragraphs with a
real table (input rules cannot express this -- they see only one
textblock). Conversion is refused inside existing tables; body rows are
then typed cell-wise, with Tab appending rows (#338).

Closes #339

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aoPvnakfBP28nAfijgUY9
2026-08-15 21:12:00 +02:00
753338f1be Word-style Tab navigation in tables with an accessible exit (#338)
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m56s
CI / Auth e2e pack (pull_request) Failing after 2m26s
CI / Import/export fidelity gate (pull_request) Has been skipped
CI / Build container images (pull_request) Successful in 5m47s
Tab used to fall through to the browser's focus navigation everywhere.
Inside tables it now moves cell-wise (Shift-Tab backwards) and appends a
new row from the last cell, Word-style. Outside tables every branch
returns false, so Tab keeps leaving the editor.

Capturing Tab inside tables needs a documented way out (WCAG 2.1.2):
Escape places the cursor after the table -- unlike the arrow keys, which
reach the gap cursor (#335) only from the table's edge cells, it works
from every cell, including from a cell selection. When no textblock
follows the table it falls back to the gap cursor position. The
mechanism is announced to assistive tech via an aria-describedby hint
on the editor surface (visually hidden, de+en).

e2e: cell round trip per Tab/Shift-Tab with typed markers, row append
from the last cell, and the full keyboard-only exit (Escape, then Tab
leaves the editor). The table specs now settle briefly after the insert
-- right after it the collab sync can swallow a click's selection
update, which had the markers landing in stale selections.

Closes #338

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aoPvnakfBP28nAfijgUY9
2026-08-15 21:06:07 +02:00
d76c731473 Merge and split table cells (#337)
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m51s
CI / Build container images (pull_request) Successful in 3m57s
CI / Auth e2e pack (pull_request) Failing after 5m22s
CI / Import/export fidelity gate (pull_request) Has been skipped
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 20:54:59 +02:00
15 changed files with 459 additions and 6 deletions

View File

@ -79,23 +79,136 @@ test('gap cursor reaches positions before and after a lone table (issue #335)',
// Inserting into the empty page replaces the placeholder paragraph — the
// table really is the only block, which is the situation of issue #335.
await expect(content.locator(':scope > p')).toHaveCount(0);
// Right after the insert the collab sync can still swallow a click's
// selection update; interact only against a settled editor (established
// pattern, see a11y.spec.ts). The typed markers below verify each click
// really placed the cursor where the locator points.
await page.waitForTimeout(500);
// Keyboard only: ArrowUp from the first cell lands on the gap cursor
// before the table; typing there materializes a paragraph.
await content.locator('th').first().click();
await page.keyboard.type('in');
await expect(content.locator('th').first()).toHaveText('in');
await page.keyboard.press('ArrowUp');
await expect(page.locator('.ProseMirror-gapcursor')).toHaveCount(1);
await page.keyboard.type('above');
await expect(content.locator(':scope > :first-child')).toHaveText('above');
// Same for the position after the table.
await content.locator('td').last().click();
await page.keyboard.type('z');
await expect(content.locator('td').last()).toHaveText('z');
await page.keyboard.press('ArrowDown');
await expect(page.locator('.ProseMirror-gapcursor')).toHaveCount(1);
await page.keyboard.type('below');
await expect(content.locator(':scope > :last-child')).toHaveText('below');
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();
// Settle before clicking into cells — see the gap cursor test.
await page.waitForTimeout(500);
// 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('Tab navigates table cells, extends the table, and never traps focus (issue #338)', async ({
browser,
}) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const { pondSlug, pageSlug } = await createPage(context, `E2E TableTab ${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();
const rows = content.locator('tr');
await expect(rows).toHaveCount(3);
// Settle before clicking into cells — see the gap cursor test.
await page.waitForTimeout(500);
// Tab moves to the next cell, Shift+Tab back. Typed markers prove where
// the cursor really is (the typing assertions also settle the editor
// between keypresses — see the merge test on click/key races).
await content.locator('th').first().click();
await page.keyboard.type('one');
await expect(content.locator('th').first()).toHaveText('one');
await page.keyboard.press('Tab');
await page.keyboard.type('two');
await expect(content.locator('th').nth(1)).toHaveText('two');
await page.keyboard.press('Shift+Tab');
await page.keyboard.type('back');
await expect(content.locator('th').first()).toContainText('back');
// Tab in the last cell appends a row and moves into it (Word behavior).
const lastCell = rows.nth(2).locator('td').nth(2);
await lastCell.click();
await page.keyboard.type('z');
await expect(lastCell).toHaveText('z');
await page.keyboard.press('Tab');
await expect(rows).toHaveCount(4);
await page.keyboard.type('new');
await expect(rows.nth(3).locator('td').first()).toHaveText('new');
// No keyboard trap (WCAG 2.1.2): Escape works from EVERY cell (the gap
// cursor is only reachable per arrow key from edge cells) and places the
// cursor after the table; once outside, Tab leaves the editor entirely.
// The mechanism is announced via the editor's aria-describedby hint.
await page.keyboard.press('Escape');
await expect(page.locator('.ProseMirror-gapcursor')).toHaveCount(1);
await page.keyboard.press('Tab');
await expect(content).not.toBeFocused();
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

@ -108,6 +108,102 @@ test('a plain-text paste is not mangled into rich structure', async ({ browser }
await context.close();
});
test('a Markdown table pasted with code-editor styling HTML becomes a table (issue #339)', async ({
browser,
}) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const { pondSlug, pageSlug } = await createPage(context, `E2E MD TablePaste ${Date.now()}`);
const page = await context.newPage();
await page.goto(`/p/${pondSlug}/${pageSlug}`);
await enterEditMode(page);
await page.locator('.ProseMirror').click();
// VS Code (copyWithSyntaxHighlighting) ships the plain text a second time
// as styled div/span HTML — exactly the flavor that used to shadow the
// Markdown conversion.
await page.evaluate(() => {
const el = document.querySelector('.ProseMirror');
const dataTransfer = new DataTransfer();
dataTransfer.setData('text/plain', '| A | B |\n| --- | --- |\n| 1 | 2 |');
dataTransfer.setData(
'text/html',
'<meta charset="utf-8"><div style="color:#d4d4d4;background-color:#1e1e1e;">' +
'<div><span style="color:#d4d4d4;">| A | B |</span></div>' +
'<div><span style="color:#d4d4d4;">| --- | --- |</span></div>' +
'<div><span style="color:#d4d4d4;">| 1 | 2 |</span></div></div>',
);
el!.dispatchEvent(
new ClipboardEvent('paste', { clipboardData: dataTransfer, bubbles: true, cancelable: true }),
);
});
const content = page.locator('.ProseMirror');
await expect(content.locator('table')).toHaveCount(1);
await expect(content.locator('th').first()).toHaveText('A');
await expect(content.locator('td').first()).toHaveText('1');
await context.close();
});
test('a Markdown table pasted into a code block stays verbatim text (issue #339)', async ({
browser,
}) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const { pondSlug, pageSlug } = await createPage(context, `E2E MD CodePaste ${Date.now()}`);
const page = await context.newPage();
await page.goto(`/p/${pondSlug}/${pageSlug}`);
await enterEditMode(page);
await page.locator('.ProseMirror').click();
await page.getByRole('button', { name: /code block|codeblock/i }).click();
await page.evaluate(() => {
const el = document.querySelector('.ProseMirror');
const dataTransfer = new DataTransfer();
dataTransfer.setData('text/plain', '| A | B |\n| --- | --- |\n| 1 | 2 |');
el!.dispatchEvent(
new ClipboardEvent('paste', { clipboardData: dataTransfer, bubbles: true, cancelable: true }),
);
});
const content = page.locator('.ProseMirror');
await expect(content.locator('table')).toHaveCount(0);
await expect(content.locator('pre')).toContainText('| A | B |');
await context.close();
});
test('typing a Markdown table header plus separator creates a table (issue #339)', async ({
browser,
}) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const { pondSlug, pageSlug } = await createPage(context, `E2E MD TableType ${Date.now()}`);
const page = await context.newPage();
await page.goto(`/p/${pondSlug}/${pageSlug}`);
await enterEditMode(page);
const content = page.locator('.ProseMirror');
await content.click();
await page.keyboard.type('| Name | Rolle |');
await page.keyboard.press('Enter');
await page.keyboard.type('| --- | --- |');
await expect(content).toContainText('| --- | --- |');
await page.keyboard.press('Enter');
await expect(content.locator('table')).toHaveCount(1);
await expect(content.locator('th').first()).toHaveText('Name');
await expect(content).not.toContainText('| --- | --- |');
// The cursor lands in the table; Tab from the last header cell appends the
// first body row (#338), so typing continues seamlessly.
await page.keyboard.type('x');
await expect(content.locator('th').first()).toContainText('x');
await context.close();
});
test('page menu downloads the page as Markdown matching its content', async ({ browser }) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const { pondSlug, pageSlug, pageId } = await createPage(context, `E2E MD Export ${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

@ -2,6 +2,7 @@ import type { AnyExtension } from '@tiptap/core';
import { GapCursor } from './gap-cursor';
import { MarkdownClipboard } from './markdown-clipboard';
import { MarkdownTableInput } from './markdown-table-input';
import { Bold, CodeMark, Italic, LinkMark, Strikethrough } from './marks';
import { Image } from './nodes/image';
import { BulletList, ListItem, OrderedList, TaskList } from './nodes/lists';
@ -64,5 +65,6 @@ export const documentExtensions: AnyExtension[] = [
Strikethrough,
LinkMark,
MarkdownClipboard,
MarkdownTableInput,
GapCursor,
];

View File

@ -1,6 +1,7 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest';
import { looksLikeMarkdown } from './markdown-clipboard';
import { htmlIsStyledPlainText, looksLikeMarkdown } from './markdown-clipboard';
describe('looksLikeMarkdown (issue #30)', () => {
it('recognizes a heading + list document', () => {
@ -31,3 +32,21 @@ describe('looksLikeMarkdown (issue #30)', () => {
expect(looksLikeMarkdown(' \n ')).toBe(false);
});
});
describe('htmlIsStyledPlainText (issue #339)', () => {
it('recognizes VS-Code-style syntax-highlighting HTML as styled plain text', () => {
const vsCode =
'<meta charset="utf-8"><div style="color:#d4d4d4;background-color:#1e1e1e;">' +
'<div><span style="color:#d4d4d4;">| A | B |</span></div>' +
'<div><span>| --- | --- |</span></div></div>';
expect(htmlIsStyledPlainText(vsCode)).toBe(true);
});
it('keeps rich-text clipboard HTML on the HTML paste path', () => {
expect(htmlIsStyledPlainText('<table><tr><td>a</td></tr></table>')).toBe(false);
expect(htmlIsStyledPlainText('<p><strong>bold</strong> prose</p>')).toBe(false);
expect(htmlIsStyledPlainText('<ul><li>one</li></ul>')).toBe(false);
expect(htmlIsStyledPlainText('<p><a href="https://example.org">link</a></p>')).toBe(false);
expect(htmlIsStyledPlainText('<pre><code>x</code></pre>')).toBe(false);
});
});

View File

@ -26,6 +26,25 @@ export function looksLikeMarkdown(text: string): boolean {
return matches.length >= 2;
}
/** Elements whose presence means the clipboard HTML carries real structure
* or semantics that ProseMirror's HTML paste should interpret. */
const STRUCTURAL_HTML =
'table, ul, ol, li, h1, h2, h3, h4, h5, h6, blockquote, pre, code, a, img, b, strong, i, em, u, s';
/**
* Code editors (VS Code with copyWithSyntaxHighlighting, similar tools) put
* an HTML flavor on the clipboard that is nothing but the plain text wrapped
* in styled div/span containers. Treating that as "real HTML" made the paste
* ignore the Markdown heuristic below, so a Markdown table copied out of
* VS Code arrived as verbatim text while the same text from a plain editor
* converted fine (issue #339). Only HTML without any structural element is
* declared equivalent to the plain text anything from a rich-text source
* keeps going through ProseMirror's own HTML paste.
*/
export function htmlIsStyledPlainText(html: string): boolean {
return new DOMParser().parseFromString(html, 'text/html').querySelector(STRUCTURAL_HTML) === null;
}
/**
* Markdown on the clipboard, both ways (issue #30, ADR 0004/0009): copying
* puts Markdown on `text/plain` alongside the browser's own HTML (so
@ -65,8 +84,11 @@ export const MarkdownClipboard = Extension.create({
}
},
handlePaste(view, event) {
// Inside a code block pasted text is code, never a document —
// converting there would split the block around rich nodes.
if (view.state.selection.$from.parent.type.spec.code) return false;
const html = event.clipboardData?.getData('text/html');
if (html && html.trim() !== '') return false;
if (html && html.trim() !== '' && !htmlIsStyledPlainText(html)) return false;
const text = event.clipboardData?.getData('text/plain');
if (!text || !looksLikeMarkdown(text)) return false;

View File

@ -0,0 +1,68 @@
import { markdownToDoc } from '@dorfteich/shared';
import { Extension } from '@tiptap/core';
import { Node as ProseMirrorNode } from '@tiptap/pm/model';
import { Plugin, Selection } from '@tiptap/pm/state';
/** A `| … |` pipe row — the same signal `looksLikeMarkdown` uses. */
const PIPE_ROW = /^\|.+\|\s*$/;
/** The GFM header separator (`| --- | :--- |`). Three dashes minimum keeps
* accidental short rows like `|-|` from ever triggering a conversion. */
const SEPARATOR_ROW = /^\|(?:\s*:?-{3,}:?\s*\|)+\s*$/;
/**
* Hand-typed Markdown tables (issue #339): pressing Enter at the end of a
* separator row whose previous sibling is a pipe row replaces the two
* paragraphs with a real table. TipTap input rules cannot express this
* they only see text inside a single textblock, and a table needs two.
* Conversion is refused inside existing tables (the schema would allow the
* nested table, the reader could not make sense of it). Body rows are then
* typed cell-wise Tab in the last cell appends a row (#338).
*/
export const MarkdownTableInput = Extension.create({
name: 'markdownTableInput',
addProseMirrorPlugins() {
return [
new Plugin({
props: {
handleKeyDown(view, event) {
if (event.key !== 'Enter' || event.shiftKey || event.ctrlKey || event.metaKey)
return false;
const { $from, empty } = view.state.selection;
if (!empty || $from.parent.type.name !== 'paragraph') return false;
if ($from.parentOffset !== $from.parent.content.size) return false;
if (!SEPARATOR_ROW.test($from.parent.textContent)) return false;
for (let depth = $from.depth - 1; depth > 0; depth -= 1) {
if ($from.node(depth).type.spec.tableRole) return false;
}
const container = $from.node($from.depth - 1);
const index = $from.index($from.depth - 1);
if (index === 0) return false;
const headerRow = container.child(index - 1);
if (headerRow.type.name !== 'paragraph' || !PIPE_ROW.test(headerRow.textContent))
return false;
let table: ProseMirrorNode;
try {
const parsed = markdownToDoc(`${headerRow.textContent}\n${$from.parent.textContent}`);
if (parsed.childCount !== 1 || parsed.firstChild?.type.name !== 'table') return false;
// Re-hydrated against the live schema — same identity dance as
// in markdown-clipboard.ts.
table = ProseMirrorNode.fromJSON(view.state.schema, parsed.firstChild.toJSON());
} catch {
return false;
}
const start = $from.before($from.depth) - headerRow.nodeSize;
const end = $from.after($from.depth);
const tr = view.state.tr.replaceWith(start, end, table);
tr.setSelection(Selection.near(tr.doc.resolve(start), 1));
view.dispatch(tr.scrollIntoView());
return true;
},
},
}),
];
},
});

View File

@ -1,4 +1,6 @@
import { Node } from '@tiptap/core';
import { GapCursor } from '@tiptap/pm/gapcursor';
import { Selection } from '@tiptap/pm/state';
import type { Node as PMNode, Schema } from 'prosemirror-model';
import {
addColumnAfter,
@ -8,6 +10,9 @@ import {
deleteColumn,
deleteRow,
deleteTable,
goToNextCell,
mergeCells,
splitCell,
tableEditing,
toggleHeaderRow,
} from 'prosemirror-tables';
@ -34,6 +39,10 @@ declare module '@tiptap/core' {
addRowAfter: () => ReturnType;
deleteRow: () => ReturnType;
deleteTable: () => ReturnType;
mergeCells: () => ReturnType;
splitCell: () => ReturnType;
goToNextCell: () => ReturnType;
goToPreviousCell: () => ReturnType;
toggleHeaderRow: () => ReturnType;
};
}
@ -65,6 +74,37 @@ export const Table = Node.create({
addProseMirrorPlugins() {
return [tableEditing()];
},
addKeyboardShortcuts() {
return {
// Word-style navigation (issue #338): Tab moves cell-wise and appends
// a new row from the last cell. Outside a table every branch returns
// false, so Tab keeps its browser default (focus moves on) and the
// editor is no keyboard trap — from inside a table the arrow keys
// lead out via the gap cursor (#335), then Tab leaves the editor.
Tab: () => {
if (this.editor.commands.goToNextCell()) return true;
if (!this.editor.can().addRowAfter()) return false;
return this.editor.chain().addRowAfter().goToNextCell().run();
},
'Shift-Tab': () => this.editor.commands.goToPreviousCell(),
// The documented exit (aria-describedby hint, #338): the gap cursor is
// only reachable per arrow key from the table's edge cells, so Escape
// is the exit that works from EVERY cell. Falls back to a gap cursor
// when no textblock follows the table (#335 guarantees the position).
Escape: () =>
this.editor.commands.command(({ state, dispatch }) => {
const { $head } = state.selection;
for (let depth = $head.depth; depth > 0; depth -= 1) {
if ($head.node(depth).type.spec.tableRole !== 'table') continue;
const $after = state.doc.resolve($head.after(depth));
const selection = Selection.findFrom($after, 1, true) ?? new GapCursor($after);
if (dispatch) dispatch(state.tr.setSelection(selection).scrollIntoView());
return true;
}
return false;
}),
};
},
addCommands() {
return {
insertTable:
@ -101,6 +141,22 @@ export const Table = Node.create({
() =>
({ state, dispatch }) =>
deleteTable(state, dispatch),
mergeCells:
() =>
({ state, dispatch }) =>
mergeCells(state, dispatch),
splitCell:
() =>
({ state, dispatch }) =>
splitCell(state, dispatch),
goToNextCell:
() =>
({ state, dispatch }) =>
goToNextCell(1)(state, dispatch),
goToPreviousCell:
() =>
({ state, dispatch }) =>
goToNextCell(-1)(state, dispatch),
toggleHeaderRow:
() =>
({ state, dispatch }) =>

View File

@ -227,7 +227,9 @@ function PageEditor({
attributes: {
role: canEdit ? 'textbox' : 'document',
'aria-label': t('contentLabel'),
...(canEdit ? { 'aria-multiline': 'true' } : {}),
...(canEdit
? { 'aria-multiline': 'true', 'aria-describedby': 'editor-keyboard-hint' }
: {}),
},
},
});
@ -361,6 +363,13 @@ function PageEditor({
/>
)}
<EditorContent editor={editor} className="editor-content" />
{/* Referenced via aria-describedby in edit mode: Tab is captured
inside tables (#338), so the way out must be discoverable. */}
{canEdit && (
<p id="editor-keyboard-hint" className="visually-hidden">
{t('keyboardHint')}
</p>
)}
{canEdit && <WikilinkAutocomplete editor={editor} />}
{canEdit && <MentionAutocomplete editor={editor} />}
</div>

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"
},
@ -192,5 +194,6 @@
"due": "Zieldatum",
"start": "Startdatum"
},
"contentLabel": "Seiteninhalt"
"contentLabel": "Seiteninhalt",
"keyboardHint": "In Tabellen wechselt die Tabulatortaste zur nächsten Zelle und legt in der letzten Zelle eine neue Zeile an; Umschalt+Tab geht zurück. Escape stellt den Cursor hinter die Tabelle; außerhalb von Tabellen verlässt die Tabulatortaste den Editor."
}

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"
},
@ -192,5 +194,6 @@
"due": "Due date",
"start": "Start date"
},
"contentLabel": "Page content"
"contentLabel": "Page content",
"keyboardHint": "Inside tables, Tab moves to the next cell and creates a new row from the last cell; Shift+Tab moves back. Escape places the cursor after the table; outside tables, Tab leaves the editor."
}

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