Recognize Markdown tables on paste and while typing (#339)
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 7m40s
CI / Build container images (pull_request) Successful in 1m21s
CI / Auth e2e pack (pull_request) Successful in 9m30s
CI / Import/export fidelity gate (pull_request) Successful in 56s
CD / Build and push images (push) Successful in 16s
CD / Deploy to Test (push) Successful in 14s
CD / Smoke tests against Test (push) Successful in 1m21s
CD / Promote to Int (push) Successful in 11s
CI / Lint, typecheck, test (push) Successful in 6m55s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 9m19s
CI / Import/export fidelity gate (push) Successful in 57s
Release / Build release images and notes (push) Successful in 2m44s
Release / Release-candidate operations QA (push) Successful in 42s
Prod deploy / Deploy the released images to Prod (push) Successful in 15s
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 7m40s
CI / Build container images (pull_request) Successful in 1m21s
CI / Auth e2e pack (pull_request) Successful in 9m30s
CI / Import/export fidelity gate (pull_request) Successful in 56s
CD / Build and push images (push) Successful in 16s
CD / Deploy to Test (push) Successful in 14s
CD / Smoke tests against Test (push) Successful in 1m21s
CD / Promote to Int (push) Successful in 11s
CI / Lint, typecheck, test (push) Successful in 6m55s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 9m19s
CI / Import/export fidelity gate (push) Successful in 57s
Release / Build release images and notes (push) Successful in 2m44s
Release / Release-candidate operations QA (push) Successful in 42s
Prod deploy / Deploy the released images to Prod (push) Successful in 15s
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
c17ab41a33
commit
f142289813
@ -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()}`);
|
||||
|
||||
@ -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,
|
||||
];
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
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