mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-03 19:20:07 +02:00
fix(editor): avoid parsing JSON and large text paste (#2301)
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 <code> 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);
|
||||
|
||||
Reference in New Issue
Block a user