Recognize Markdown tables on paste and while typing (#339)
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
This commit is contained in:
parent
753338f1be
commit
b7360dd48e
@ -108,6 +108,102 @@ test('a plain-text paste is not mangled into rich structure', async ({ browser }
|
|||||||
await context.close();
|
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 }) => {
|
test('page menu downloads the page as Markdown matching its content', async ({ browser }) => {
|
||||||
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
|
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
|
||||||
const { pondSlug, pageSlug, pageId } = await createPage(context, `E2E MD Export ${Date.now()}`);
|
const { pondSlug, pageSlug, pageId } = await createPage(context, `E2E MD Export ${Date.now()}`);
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import type { AnyExtension } from '@tiptap/core';
|
|||||||
|
|
||||||
import { GapCursor } from './gap-cursor';
|
import { GapCursor } from './gap-cursor';
|
||||||
import { MarkdownClipboard } from './markdown-clipboard';
|
import { MarkdownClipboard } from './markdown-clipboard';
|
||||||
|
import { MarkdownTableInput } from './markdown-table-input';
|
||||||
import { Bold, CodeMark, Italic, LinkMark, Strikethrough } from './marks';
|
import { Bold, CodeMark, Italic, LinkMark, Strikethrough } from './marks';
|
||||||
import { Image } from './nodes/image';
|
import { Image } from './nodes/image';
|
||||||
import { BulletList, ListItem, OrderedList, TaskList } from './nodes/lists';
|
import { BulletList, ListItem, OrderedList, TaskList } from './nodes/lists';
|
||||||
@ -64,5 +65,6 @@ export const documentExtensions: AnyExtension[] = [
|
|||||||
Strikethrough,
|
Strikethrough,
|
||||||
LinkMark,
|
LinkMark,
|
||||||
MarkdownClipboard,
|
MarkdownClipboard,
|
||||||
|
MarkdownTableInput,
|
||||||
GapCursor,
|
GapCursor,
|
||||||
];
|
];
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { looksLikeMarkdown } from './markdown-clipboard';
|
import { htmlIsStyledPlainText, looksLikeMarkdown } from './markdown-clipboard';
|
||||||
|
|
||||||
describe('looksLikeMarkdown (issue #30)', () => {
|
describe('looksLikeMarkdown (issue #30)', () => {
|
||||||
it('recognizes a heading + list document', () => {
|
it('recognizes a heading + list document', () => {
|
||||||
@ -31,3 +32,21 @@ describe('looksLikeMarkdown (issue #30)', () => {
|
|||||||
expect(looksLikeMarkdown(' \n ')).toBe(false);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -26,6 +26,25 @@ export function looksLikeMarkdown(text: string): boolean {
|
|||||||
return matches.length >= 2;
|
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
|
* Markdown on the clipboard, both ways (issue #30, ADR 0004/0009): copying
|
||||||
* puts Markdown on `text/plain` alongside the browser's own HTML (so
|
* puts Markdown on `text/plain` alongside the browser's own HTML (so
|
||||||
@ -65,8 +84,11 @@ export const MarkdownClipboard = Extension.create({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
handlePaste(view, event) {
|
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');
|
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');
|
const text = event.clipboardData?.getData('text/plain');
|
||||||
if (!text || !looksLikeMarkdown(text)) return false;
|
if (!text || !looksLikeMarkdown(text)) return false;
|
||||||
|
|
||||||
|
|||||||
68
apps/web/src/editor/markdown-table-input.ts
Normal file
68
apps/web/src/editor/markdown-table-input.ts
Normal 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;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user