Compare commits

...

1 Commits

Author SHA1 Message Date
Jiang Bohan
565d45cdcc fix(editor): keep blank-line paste inside the code block
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
2026-05-04 21:06:10 +08:00
2 changed files with 140 additions and 0 deletions

View File

@@ -0,0 +1,130 @@
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");
});
});

View File

@@ -46,6 +46,16 @@ export function createMarkdownPasteExtension() {
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 { $from } = view.state.selection;
if ($from.parent.type.name === "codeBlock") {
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