diff --git a/packages/views/editor/extensions/markdown-paste.test.ts b/packages/views/editor/extensions/markdown-paste.test.ts index 391f3c7795..83590006f0 100644 --- a/packages/views/editor/extensions/markdown-paste.test.ts +++ b/packages/views/editor/extensions/markdown-paste.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, afterEach } from "vitest"; +import { describe, it, expect, afterEach, vi } from "vitest"; import { Editor } from "@tiptap/core"; import StarterKit from "@tiptap/starter-kit"; import { Markdown } from "@tiptap/markdown"; @@ -60,10 +60,23 @@ function nodeText(node: JsonNode): string { return (node.content ?? []).map(nodeText).join(""); } +function expectLiteralPaste(editor: Editor, text: string) { + editor.commands.setTextSelection(1); + const parseSpy = vi.spyOn(editor.markdown!, "parse"); + + const handled = paste(editor, text); + + expect(handled).toBe(true); + expect(parseSpy).not.toHaveBeenCalled(); + expect(editor.getText()).toBe(text); + expect(editor.getMarkdown()).toBe(text); +} + describe("markdownPaste — code block context", () => { let editor: Editor | null = null; afterEach(() => { + vi.restoreAllMocks(); editor?.destroy(); editor = null; document.body.innerHTML = ""; @@ -127,4 +140,55 @@ describe("markdownPaste — code block context", () => { // Markdown parsing produced a heading at the top. expect(types).toContain("heading"); }); + + it("inserts JSON clipboard text without running the Markdown parser", () => { + editor = makeEditor({ + type: "doc", + content: [{ type: "paragraph" }], + }); + + const json = JSON.stringify( + { + type: "issue.comment", + payload: { + title: "Paste JSON into a reply", + nested: { ok: true, count: 3 }, + items: ["alpha", "beta", "gamma"], + }, + }, + null, + 2, + ); + + expectLiteralPaste(editor, json); + }); + + it("inserts very large plain text without running the Markdown parser", () => { + editor = makeEditor({ + type: "doc", + content: [{ type: "paragraph" }], + }); + + const text = Array.from( + { length: 1600 }, + (_, index) => `log ${index}: ${"payload".repeat(6)}`, + ).join("\n"); + expect(text.length).toBeGreaterThan(50_000); + + expectLiteralPaste(editor, text); + }); + + it("does not parse oversized bracketed plain text as JSON", () => { + editor = makeEditor({ + type: "doc", + content: [{ type: "paragraph" }], + }); + + const parseJsonSpy = vi.spyOn(JSON, "parse"); + const text = `{${"not-json".repeat(7_000)}}`; + expect(text.length).toBeGreaterThan(50_000); + + expectLiteralPaste(editor, text); + expect(parseJsonSpy).not.toHaveBeenCalled(); + }); }); diff --git a/packages/views/editor/extensions/markdown-paste.ts b/packages/views/editor/extensions/markdown-paste.ts index 3252d0730e..4eabe4675f 100644 --- a/packages/views/editor/extensions/markdown-paste.ts +++ b/packages/views/editor/extensions/markdown-paste.ts @@ -12,20 +12,71 @@ * `data-pm-slice` in the HTML — this attribute is added by ProseMirror's * own clipboard serializer. If present, the source is another ProseMirror * editor and its HTML is structurally correct — let ProseMirror handle it. - * Otherwise, ignore the HTML and parse text/plain as Markdown. + * Otherwise, classify text/plain into one of three paths: + * - native: let ProseMirror or another extension handle it + * - literal: insert exact text without Markdown parsing + * - markdown: parse text/plain as Markdown * * Why not clipboardTextParser? It only runs when there's NO text/html on * the clipboard (ProseMirror source: `let asText = !!text && !html`). * * Why not heuristic detection (looksLikeMarkdown / hasRichHtml)? Unreliable. * VS Code's HTML contains tags that fool rich-content detectors. - * Markdown pattern matching has too many edge cases. The data-pm-slice - * check is deterministic — no false positives. + * Markdown pattern matching has too many edge cases. Instead, the classifier + * only keeps narrow deterministic exits for editor-owned slices, code block + * context, structured plain text, and large payloads. */ import { Extension } from "@tiptap/core"; import { Plugin, PluginKey } from "@tiptap/pm/state"; import { Slice } from "@tiptap/pm/model"; +const LARGE_PASTE_TEXT_THRESHOLD = 50_000; + +type PasteMode = "native" | "literal" | "markdown"; + +interface PasteClassificationInput { + text: string; + html: string; + hasFiles: boolean; + isInsideCodeBlock: boolean; +} + +function isJsonDocumentText(text: string): boolean { + const trimmed = text.trim(); + if (!trimmed) return false; + + const startsLikeJson = + (trimmed.startsWith("{") && trimmed.endsWith("}")) || + (trimmed.startsWith("[") && trimmed.endsWith("]")); + if (!startsLikeJson) return false; + + try { + JSON.parse(trimmed); + return true; + } catch { + return false; + } +} + +function isStructuredPlainText(text: string): boolean { + return isJsonDocumentText(text); +} + +function classifyPaste({ + text, + html, + hasFiles, + isInsideCodeBlock, +}: PasteClassificationInput): PasteMode { + if (hasFiles) return "native"; + if (!text) return "native"; + if (isInsideCodeBlock) return "literal"; + if (html && html.includes("data-pm-slice")) return "native"; + if (text.length > LARGE_PASTE_TEXT_THRESHOLD) return "literal"; + if (isStructuredPlainText(text)) return "literal"; + return "markdown"; +} + export function createMarkdownPasteExtension() { return Extension.create({ name: "markdownPaste", @@ -40,29 +91,23 @@ export function createMarkdownPasteExtension() { const clipboard = event.clipboardData; if (!clipboard) return false; - // If clipboard has files, defer to the fileUpload extension. - if (clipboard.files?.length) return false; - const text = clipboard.getData("text/plain"); - if (!text) return false; - - // If the caret is inside a code block, insert the text as-is. - // Code blocks must keep newlines literal; running Markdown - // parsing here would split a blank line (\n\n) into two - // paragraphs and tear the code block open. (#1982) + const html = clipboard.getData("text/html"); const { $from } = view.state.selection; - if ($from.parent.type.name === "codeBlock") { + const mode = classifyPaste({ + text, + html, + hasFiles: Boolean(clipboard.files?.length), + isInsideCodeBlock: $from.parent.type.name === "codeBlock", + }); + + if (mode === "native") return false; + + if (mode === "literal") { view.dispatch(view.state.tr.insertText(text)); return true; } - const html = clipboard.getData("text/html"); - - // If HTML contains data-pm-slice, the source is another - // ProseMirror editor — let ProseMirror use its native HTML - // clipboard path to preserve exact node structure. - if (html && html.includes("data-pm-slice")) return false; - // Everything else (VS Code, text editors, .md files, terminals, // web pages): parse text/plain as Markdown. const json = editor.markdown.parse(text);