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 |
bold prose
')).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;
+ },
+ },
+ }),
+ ];
+ },
+});