From 923950e67fee4f15f04e844decec14b9035914b7 Mon Sep 17 00:00:00 2001
From: MuRong
Date: Tue, 4 Aug 2026 15:46:31 +0800
Subject: [PATCH 1/3] fix(editor): preserve rich HTML paste formatting
Refs #631
---
apps/desktop/src-tauri/src/clipboard.rs | 71 +++++++++-
apps/desktop/src-tauri/src/lib.rs | 3 +-
apps/desktop/src/runtime/index.ts | 1 +
apps/desktop/src/runtime/tauri/menu.test.ts | 25 +++-
apps/desktop/src/runtime/tauri/menu.ts | 30 ++++-
packages/app/src/lib/tauri/menu.ts | 13 ++
.../src/runtime/context-menu-items.test.ts | 56 +++++++-
.../app/src/runtime/context-menu-items.ts | 115 +++++++++++++---
packages/app/src/runtime/index.ts | 9 ++
.../src/codemirror/clipboard-assets.test.ts | 123 ++++++++++++++++++
.../editor/src/codemirror/clipboard-assets.ts | 4 +-
packages/editor/src/codemirror/html-paste.ts | 122 +++++++++++++++--
12 files changed, 532 insertions(+), 40 deletions(-)
diff --git a/apps/desktop/src-tauri/src/clipboard.rs b/apps/desktop/src-tauri/src/clipboard.rs
index 8c86c1a0..dc013f80 100644
--- a/apps/desktop/src-tauri/src/clipboard.rs
+++ b/apps/desktop/src-tauri/src/clipboard.rs
@@ -1,3 +1,12 @@
+use serde::Serialize;
+
+#[derive(Debug, PartialEq, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub(crate) struct ClipboardContent {
+ html: Option,
+ text: Option,
+}
+
fn clipboard_text_from_result(
result: Result,
) -> Result
",
].join(""),
text: expected,
@@ -253,6 +257,52 @@ describe("codeMirrorClipboardAssetsPlugin", () => {
expect(view.state.doc.toString()).toBe(expected);
});
+ it("does not merge ordinary linked card blocks", () => {
+ const view = createView("");
+ const href = "https://example.test/mock-card";
+
+ paste(view, {
+ html: [
+ 'See ',
+ "Mock title
",
+ "Mock subtitle
",
+ ".
",
+ ].join(""),
+ text: "See Mock title Mock subtitle.",
+ });
+
+ const markdown = view.state.doc.toString();
+ expect(markdown).toContain(`[Mock title](${href})`);
+ expect(markdown).toContain(`[Mock subtitle](${href})`);
+ expect(markdown).not.toContain("Mock titleMock subtitle");
+ });
+
+ it("does not flatten semantic or multiline linked code", () => {
+ const semanticView = createView("");
+ const multilineView = createView("");
+
+ paste(semanticView, {
+ html: [
+ 'See ',
+ 'const mock = 1;
',
+ ".
",
+ ].join(""),
+ text: "See const mock = 1;.",
+ });
+ paste(multilineView, {
+ html: [
+ 'See ',
+ '',
+ "Mock line one
Mock line two",
+ "
.
",
+ ].join(""),
+ text: "See Mock line one\nMock line two.",
+ });
+
+ expect(semanticView.state.doc.toString()).toContain("```\nconst mock = 1;\n```");
+ expect(multilineView.state.doc.toString()).toContain("```\nMock line one\nMock line two\n```");
+ });
+
it("preserves Markdown-looking lines inside a styled mixed-content code block", () => {
const view = createView("");
const code = [
diff --git a/packages/editor/src/codemirror/html-paste.ts b/packages/editor/src/codemirror/html-paste.ts
index efbc5396..b934dc10 100644
--- a/packages/editor/src/codemirror/html-paste.ts
+++ b/packages/editor/src/codemirror/html-paste.ts
@@ -35,7 +35,6 @@ const richTextSelector = [
].join(",");
const preformattedBlockNames = new Set(["DIV", "P", "PRE"]);
const anchorMarkupPattern = /"']|"[^"]*"|'[^']*')*>[\s\S]*?<\/a\s*>/giu;
-const anchorBlockPattern = /<(\/?)(?:div|p|pre)\b((?:[^>"']|"[^"]*"|'[^']*')*)>/giu;
function preformattedStyle(element: Element) {
const style = element.getAttribute("style") ?? "";
@@ -47,15 +46,48 @@ function preformattedElements(document: Document) {
.filter((element) => element.tagName === "PRE" || preformattedStyle(element));
}
-function normalizeAnchorBlockMarkup(html: string) {
+function styledInlineLinkMarkup(link: HTMLAnchorElement) {
+ const blocks = Array.from(link.querySelectorAll("div, p"));
+ // Only flatten compact preformatted wrappers; semantic or multiline content
+ // must stay on the normal code/link conversion path.
+ if (blocks.length === 0 ||
+ link.querySelector("br, code, pre") !== null ||
+ /[\r\n]/u.test(link.textContent ?? "") ||
+ ![link, ...Array.from(link.querySelectorAll("[style]"))]
+ .some((element) => preformattedStyle(element))) {
+ return null;
+ }
+
+ const blockSet = new Set(blocks);
+ const blocksWithFollowingBlock = new Set(blocks.filter(
+ (block) => Boolean(
+ block.nextElementSibling && blockSet.has(block.nextElementSibling as HTMLElement),
+ ),
+ ));
+ for (const block of blocks.reverse()) {
+ const span = link.ownerDocument.createElement("span");
+ for (const attribute of Array.from(block.attributes)) {
+ span.setAttribute(attribute.name, attribute.value);
+ }
+ span.append(...Array.from(block.childNodes));
+ block.replaceWith(span);
+ if (blocksWithFollowingBlock.has(block)) span.after(" ");
+ }
+
+ return link.outerHTML;
+}
+
+function normalizeAnchorBlockMarkup(html: string, parser: DOMParser) {
// A block wrapper inside a paragraph link makes the HTML parser split one
// authored anchor into empty and duplicate links before Turndown sees it.
- return html.replace(anchorMarkupPattern, (anchor) =>
- anchor.replace(
- anchorBlockPattern,
- (_tag, closing: string, attributes: string) => `<${closing}span${attributes}>`,
- )
- );
+ return html.replace(anchorMarkupPattern, (anchor) => {
+ if (!codeFontPattern.test(anchor) && !preformattedWhitespacePattern.test(anchor)) {
+ return anchor;
+ }
+ const fragment = parser.parseFromString(anchor, "text/html");
+ const link = fragment.body.querySelector("a[href]");
+ return link ? styledInlineLinkMarkup(link) ?? anchor : anchor;
+ });
}
function meaningfulBodyNodes(document: Document) {
@@ -215,8 +247,9 @@ export function convertCodeMirrorClipboardHtml(
plainText = "",
): CodeMirrorHtmlPaste | null {
if (!html.trim() || typeof DOMParser === "undefined") return null;
- const document = new DOMParser().parseFromString(
- normalizeAnchorBlockMarkup(html),
+ const parser = new DOMParser();
+ const document = parser.parseFromString(
+ normalizeAnchorBlockMarkup(html, parser),
"text/html",
);
const service = createTurndownService();