mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-29 06:28:23 +02:00
Pasting `line1\n\nline2` while the caret was inside a code block ran the text through the Markdown parser, which split on the blank line and tore the code block open, dropping the trailing content into a sibling paragraph. Detect the codeBlock parent on `handlePaste` and insert the clipboard text verbatim instead. Code blocks have `code: true`, so newlines stay literal — exactly what users expect when pasting code or logs. Closes #1982
131 lines
3.8 KiB
TypeScript
131 lines
3.8 KiB
TypeScript
import { describe, it, expect, afterEach } from "vitest";
|
|
import { Editor } from "@tiptap/core";
|
|
import StarterKit from "@tiptap/starter-kit";
|
|
import { Markdown } from "@tiptap/markdown";
|
|
import { createMarkdownPasteExtension } from "./markdown-paste";
|
|
|
|
interface FakeClipboard {
|
|
files: never[];
|
|
getData: (type: string) => string;
|
|
}
|
|
|
|
function fakePasteEvent(text: string, html?: string) {
|
|
const data: FakeClipboard = {
|
|
files: [],
|
|
getData: (type) =>
|
|
type === "text/plain" ? text : type === "text/html" ? (html ?? "") : "",
|
|
};
|
|
return {
|
|
clipboardData: data,
|
|
preventDefault: () => {},
|
|
} as unknown as ClipboardEvent;
|
|
}
|
|
|
|
function makeEditor(content: object) {
|
|
const element = document.createElement("div");
|
|
document.body.appendChild(element);
|
|
return new Editor({
|
|
element,
|
|
extensions: [StarterKit, Markdown, createMarkdownPasteExtension()],
|
|
content,
|
|
});
|
|
}
|
|
|
|
function paste(editor: Editor, text: string, html?: string): boolean {
|
|
const event = fakePasteEvent(text, html);
|
|
return (
|
|
editor.view.someProp("handlePaste", (handler) =>
|
|
handler(editor.view, event, editor.view.state.selection.content()),
|
|
) === true
|
|
);
|
|
}
|
|
|
|
interface JsonNode {
|
|
type: string;
|
|
text?: string;
|
|
content?: JsonNode[];
|
|
}
|
|
|
|
function findFirst(json: JsonNode, type: string): JsonNode | undefined {
|
|
if (json.type === type) return json;
|
|
for (const child of json.content ?? []) {
|
|
const hit = findFirst(child, type);
|
|
if (hit) return hit;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function nodeText(node: JsonNode): string {
|
|
if (node.text !== undefined) return node.text;
|
|
return (node.content ?? []).map(nodeText).join("");
|
|
}
|
|
|
|
describe("markdownPaste — code block context", () => {
|
|
let editor: Editor | null = null;
|
|
|
|
afterEach(() => {
|
|
editor?.destroy();
|
|
editor = null;
|
|
document.body.innerHTML = "";
|
|
});
|
|
|
|
it("preserves blank lines when pasting into a code block (#1982)", () => {
|
|
editor = makeEditor({
|
|
type: "doc",
|
|
content: [{ type: "codeBlock", content: [{ type: "text", text: "x" }] }],
|
|
});
|
|
|
|
// Place caret after "x" inside the code block.
|
|
editor.commands.setTextSelection(2);
|
|
expect(editor.state.selection.$from.parent.type.name).toBe("codeBlock");
|
|
|
|
const handled = paste(editor, "line1\n\nline2");
|
|
expect(handled).toBe(true);
|
|
|
|
const json = editor.getJSON() as JsonNode;
|
|
const codeBlock = findFirst(json, "codeBlock");
|
|
expect(codeBlock).toBeDefined();
|
|
// Code block content is preserved verbatim — blank line stays inside.
|
|
expect(nodeText(codeBlock!)).toBe("xline1\n\nline2");
|
|
// No paragraph leaked out carrying any of the pasted text.
|
|
const leakedParagraph = (json.content ?? []).find(
|
|
(n) => n.type === "paragraph" && nodeText(n).length > 0,
|
|
);
|
|
expect(leakedParagraph).toBeUndefined();
|
|
});
|
|
|
|
it("preserves fence characters pasted into a code block", () => {
|
|
editor = makeEditor({
|
|
type: "doc",
|
|
content: [{ type: "codeBlock", content: [] }],
|
|
});
|
|
|
|
editor.commands.setTextSelection(1);
|
|
expect(editor.state.selection.$from.parent.type.name).toBe("codeBlock");
|
|
|
|
paste(editor, "```\nhello\n```");
|
|
|
|
const json = editor.getJSON() as JsonNode;
|
|
const codeBlock = findFirst(json, "codeBlock");
|
|
expect(codeBlock).toBeDefined();
|
|
expect(nodeText(codeBlock!)).toBe("```\nhello\n```");
|
|
});
|
|
|
|
it("still parses Markdown when pasting into a regular paragraph", () => {
|
|
editor = makeEditor({
|
|
type: "doc",
|
|
content: [{ type: "paragraph" }],
|
|
});
|
|
|
|
editor.commands.setTextSelection(1);
|
|
expect(editor.state.selection.$from.parent.type.name).toBe("paragraph");
|
|
|
|
paste(editor, "# Heading\n\nbody");
|
|
|
|
const json = editor.getJSON() as JsonNode;
|
|
const types = (json.content ?? []).map((n) => n.type);
|
|
// Markdown parsing produced a heading at the top.
|
|
expect(types).toContain("heading");
|
|
});
|
|
});
|