diff --git a/apps/web/e2e/markdown.spec.ts b/apps/web/e2e/markdown.spec.ts index 2b8549d..f4a74b5 100644 --- a/apps/web/e2e/markdown.spec.ts +++ b/apps/web/e2e/markdown.spec.ts @@ -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', + '
' + + '
| A | B |
' + + '
| --- | --- |
' + + '
| 1 | 2 |
', + ); + 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()}`); diff --git a/apps/web/src/editor/document-extensions.ts b/apps/web/src/editor/document-extensions.ts index e4f90f8..ca25108 100644 --- a/apps/web/src/editor/document-extensions.ts +++ b/apps/web/src/editor/document-extensions.ts @@ -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, ]; diff --git a/apps/web/src/editor/markdown-clipboard.test.ts b/apps/web/src/editor/markdown-clipboard.test.ts index 5159a12..315865f 100644 --- a/apps/web/src/editor/markdown-clipboard.test.ts +++ b/apps/web/src/editor/markdown-clipboard.test.ts @@ -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 = + '
' + + '
| A | B |
' + + '
| --- | --- |
'; + expect(htmlIsStyledPlainText(vsCode)).toBe(true); + }); + + it('keeps rich-text clipboard HTML on the HTML paste path', () => { + expect(htmlIsStyledPlainText('
a
')).toBe(false); + expect(htmlIsStyledPlainText('

bold prose

')).toBe(false); + expect(htmlIsStyledPlainText('')).toBe(false); + expect(htmlIsStyledPlainText('

link

')).toBe(false); + expect(htmlIsStyledPlainText('
x
')).toBe(false); + }); +}); diff --git a/apps/web/src/editor/markdown-clipboard.ts b/apps/web/src/editor/markdown-clipboard.ts index af78370..2184fca 100644 --- a/apps/web/src/editor/markdown-clipboard.ts +++ b/apps/web/src/editor/markdown-clipboard.ts @@ -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; diff --git a/apps/web/src/editor/markdown-table-input.ts b/apps/web/src/editor/markdown-table-input.ts new file mode 100644 index 0000000..501313e --- /dev/null +++ b/apps/web/src/editor/markdown-table-input.ts @@ -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; + }, + }, + }), + ]; + }, +});