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, String> { @@ -8,6 +17,28 @@ fn clipboard_text_from_result( } } +fn clipboard_content_from_results( + text_result: Result, + html_result: Result, +) -> Result, String> { + let text = clipboard_text_from_result(text_result); + let html = clipboard_text_from_result(html_result); + match (text, html) { + (Ok(text), Ok(html)) => { + Ok((text.is_some() || html.is_some()).then_some(ClipboardContent { html, text })) + } + (Ok(Some(text)), Err(_)) => Ok(Some(ClipboardContent { + html: None, + text: Some(text), + })), + (Err(_), Ok(Some(html))) => Ok(Some(ClipboardContent { + html: Some(html), + text: None, + })), + (Err(error), Ok(None)) | (Ok(None), Err(error)) | (Err(error), Err(_)) => Err(error), + } +} + #[tauri::command] pub(crate) fn read_clipboard_text() -> Result, String> { let mut clipboard = arboard::Clipboard::new() @@ -16,9 +47,19 @@ pub(crate) fn read_clipboard_text() -> Result, String> { clipboard_text_from_result(clipboard.get_text()) } +#[tauri::command] +pub(crate) fn read_clipboard_content() -> Result, String> { + let mut clipboard = arboard::Clipboard::new() + .map_err(|error| format!("Could not access clipboard: {error}"))?; + let text = clipboard.get().text(); + let html = clipboard.get().html(); + + clipboard_content_from_results(text, html) +} + #[cfg(test)] mod tests { - use super::clipboard_text_from_result; + use super::{clipboard_content_from_results, clipboard_text_from_result, ClipboardContent}; #[test] fn returns_none_when_clipboard_text_is_unavailable() { @@ -35,4 +76,32 @@ mod tests { Ok(Some("mock clipboard".to_string())) ); } + + #[test] + fn returns_rich_clipboard_content() { + assert_eq!( + clipboard_content_from_results( + Ok("mock text".to_string()), + Ok("

mock text

".to_string()), + ), + Ok(Some(ClipboardContent { + html: Some("

mock text

".to_string()), + text: Some("mock text".to_string()), + })) + ); + } + + #[test] + fn keeps_plain_text_when_html_is_unavailable() { + assert_eq!( + clipboard_content_from_results( + Ok("mock text".to_string()), + Err(arboard::Error::ContentNotAvailable), + ), + Ok(Some(ClipboardContent { + html: None, + text: Some("mock text".to_string()), + })) + ); + } } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 340e4a84..55fecbe7 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -36,7 +36,7 @@ use ai_http::{request_ai_provider_json, request_native_chat, request_native_chat use app_exit::handle_app_exit_requested; use app_logs::open_log_folder; use backup::backup_markdown_folder; -use clipboard::read_clipboard_text; +use clipboard::{read_clipboard_content, read_clipboard_text}; use external_urls::open_external_url; use fonts::list_system_font_families; use image_upload::{upload_picgo_image, upload_s3_image, upload_webdav_image}; @@ -297,6 +297,7 @@ pub fn run() { delete_markdown_template_file, save_clipboard_attachment, save_clipboard_image, + read_clipboard_content, read_clipboard_text, minimize_current_window, open_blank_editor_window, diff --git a/apps/desktop/src/runtime/index.ts b/apps/desktop/src/runtime/index.ts index fa2a8ecc..4fb0d99e 100644 --- a/apps/desktop/src/runtime/index.ts +++ b/apps/desktop/src/runtime/index.ts @@ -151,6 +151,7 @@ export const desktopRuntime = { installApplicationMenu: menu.installNativeApplicationMenu, installEditorContextMenu: menu.installNativeEditorContextMenu, listenApplicationMenuCommands: menu.listenNativeApplicationMenuCommands, + readClipboardContent: menu.readNativeClipboardContent, readClipboardText: menu.readNativeClipboardText, showMarkdownFileTreeContextMenu: menu.showNativeMarkdownFileTreeContextMenu }, diff --git a/apps/desktop/src/runtime/tauri/menu.test.ts b/apps/desktop/src/runtime/tauri/menu.test.ts index da98baef..01b611fe 100644 --- a/apps/desktop/src/runtime/tauri/menu.test.ts +++ b/apps/desktop/src/runtime/tauri/menu.test.ts @@ -379,7 +379,7 @@ describe("native menu", () => { expect(domMenuItemById("markra:context:table").textContent).toContain("Cmd/Ctrl+Shift+T"); }); - it("uses the native clipboard command for editor context menu paste", async () => { + it("uses the native rich clipboard command for editor context menu paste", async () => { const target = document.createElement("main"); const paper = document.createElement("article"); const execCommand = vi.fn((command: string) => command !== "paste"); @@ -397,7 +397,12 @@ describe("native menu", () => { } }); mockedInvoke.mockImplementation(async (command) => { - if (command === "read_clipboard_text") return "native clipboard text"; + if (command === "read_clipboard_content") { + return { + html: "

native clipboard text

", + text: "native clipboard text" + }; + } return undefined; }); @@ -409,7 +414,7 @@ describe("native menu", () => { await flushNativeMenuPopup(); await flushNativeMenuPopup(); - expect(mockedInvoke).toHaveBeenCalledWith("read_clipboard_text"); + expect(mockedInvoke).toHaveBeenCalledWith("read_clipboard_content"); expect(browserReadText).not.toHaveBeenCalled(); expect(execCommand).toHaveBeenCalledTimes(1); expect(execCommand).toHaveBeenNthCalledWith(1, "insertText", false, "native clipboard text"); @@ -439,7 +444,12 @@ describe("native menu", () => { value: execCommand }); mockedInvoke.mockImplementation(async (command) => { - if (command === "read_clipboard_text") return "side clipboard text"; + if (command === "read_clipboard_content") { + return { + html: "

side clipboard text

", + text: "side clipboard text" + }; + } return undefined; }); @@ -451,6 +461,13 @@ describe("native menu", () => { await vi.waitFor(() => expect(sidePaste).toHaveBeenCalledTimes(1)); expect(mainPaste).not.toHaveBeenCalled(); + const clipboardEvent = sidePaste.mock.calls[0]?.[0] as ClipboardEvent; + expect(clipboardEvent.clipboardData?.getData("text/html")).toBe( + "

side clipboard text

" + ); + expect(clipboardEvent.clipboardData?.getData("text/plain")).toBe( + "side clipboard text" + ); expect(execCommand).not.toHaveBeenCalled(); }); diff --git a/apps/desktop/src/runtime/tauri/menu.ts b/apps/desktop/src/runtime/tauri/menu.ts index b3e36b1a..f9d58c63 100644 --- a/apps/desktop/src/runtime/tauri/menu.ts +++ b/apps/desktop/src/runtime/tauri/menu.ts @@ -10,6 +10,8 @@ import { showContextMenu, type ContextMenuEntry, type ContextMenuIdPrefixes, + type NativeClipboardContent, + type NativeClipboardContentReader, type RecentMarkdownFile } from "@markra/app/runtime"; import type { MarkdownShortcutMap } from "@markra/editor"; @@ -44,12 +46,14 @@ export type NativeClipboardTextReader = () => string | null | undefined | Promis export type NativeEditorContextMenuOptions = { getAiCommandsAvailable?: () => boolean; markdownShortcuts?: MarkdownShortcutMap; + readClipboardContent?: NativeClipboardContentReader; readClipboardText?: NativeClipboardTextReader; }; export type NativeEditorContextMenuEntryOptions = { aiCommandsAvailable?: boolean; markdownShortcuts?: MarkdownShortcutMap; + readClipboardContent?: NativeClipboardContentReader; readClipboardText?: NativeClipboardTextReader; }; @@ -191,10 +195,28 @@ export async function readNativeClipboardText() { } } -function withNativeClipboardText(options: TOptions) { +export async function readNativeClipboardContent() { + try { + const content = await invokeNative( + "read_clipboard_content" + ); + if (!content || (typeof content.html !== "string" && typeof content.text !== "string")) { + return null; + } + + return content; + } catch { + return null; + } +} + +function withNativeClipboardContent< + TOptions extends { readClipboardContent?: NativeClipboardContentReader } +>(options: TOptions) { return { ...options, - readClipboardText: options.readClipboardText ?? readNativeClipboardText + readClipboardContent: + options.readClipboardContent ?? readNativeClipboardContent }; } @@ -203,7 +225,7 @@ export function createNativeEditorContextMenuItems( language: AppLanguage = "en", options: NativeEditorContextMenuEntryOptions = {} ): ContextMenuEntry[] { - return createEditorContextMenuEntries(handlers, language, withNativeClipboardText(options), desktopContextMenuIdPrefixes); + return createEditorContextMenuEntries(handlers, language, withNativeClipboardContent(options), desktopContextMenuIdPrefixes); } export async function installNativeEditorContextMenu( @@ -226,7 +248,7 @@ export async function installNativeEditorContextMenu( entries: createEditorContextMenuEntriesFromOptions( handlers, language, - withNativeClipboardText(options), + withNativeClipboardContent(options), desktopContextMenuIdPrefixes, element ), diff --git a/packages/app/src/lib/tauri/menu.ts b/packages/app/src/lib/tauri/menu.ts index 576dea31..1424f15e 100644 --- a/packages/app/src/lib/tauri/menu.ts +++ b/packages/app/src/lib/tauri/menu.ts @@ -28,15 +28,28 @@ export type NativeMarkdownFileTreeContextMenuHandlers = { export type NativeClipboardTextReader = () => string | null | undefined | Promise; +export type NativeClipboardContent = { + html?: string | null; + text?: string | null; +}; + +export type NativeClipboardContentReader = () => + | NativeClipboardContent + | null + | undefined + | Promise; + export type NativeEditorContextMenuOptions = { getAiCommandsAvailable?: () => boolean; markdownShortcuts?: MarkdownShortcutMap; + readClipboardContent?: NativeClipboardContentReader; readClipboardText?: NativeClipboardTextReader; }; export type NativeEditorContextMenuEntryOptions = { aiCommandsAvailable?: boolean; markdownShortcuts?: MarkdownShortcutMap; + readClipboardContent?: NativeClipboardContentReader; readClipboardText?: NativeClipboardTextReader; }; diff --git a/packages/app/src/runtime/context-menu-items.test.ts b/packages/app/src/runtime/context-menu-items.test.ts index 69b68564..cb25726e 100644 --- a/packages/app/src/runtime/context-menu-items.test.ts +++ b/packages/app/src/runtime/context-menu-items.test.ts @@ -1,7 +1,10 @@ import { EditorSelection, EditorState, type Extension } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import { history, undo } from "@codemirror/commands"; -import { liveMarkdown } from "@markra/editor/codemirror"; +import { + codeMirrorClipboardAssetsPlugin, + liveMarkdown +} from "@markra/editor/codemirror"; import { createEditorContextMenuEntries, createEditorContextMenuEntriesFromOptions, @@ -51,6 +54,22 @@ function clipboardPasteItem(target: Element, text: string) { ); } +function clipboardContentPasteItem( + target: Element, + content: { html: string; text: string } +) { + return menuItemById( + createEditorContextMenuEntriesFromOptions( + {}, + "en", + { readClipboardContent: () => content }, + {}, + target + ), + "markra:context:paste" + ); +} + describe("editor context menu entries", () => { afterEach(() => { vi.restoreAllMocks(); @@ -92,6 +111,41 @@ describe("editor context menu entries", () => { expect(editor.view.state.doc.toString()).toBe(doc); }); + it("preserves native rich clipboard content through the editor paste pipeline", async () => { + const editor = createEditor( + "", + EditorSelection.cursor(0), + [ + liveMarkdown({ + plugins: [codeMirrorClipboardAssetsPlugin()] + }) + ] + ); + + await Promise.resolve(clipboardContentPasteItem(editor.paper, { + html: [ + "

Mock summary

", + "
  1. First choice
  2. Second choice
", + '

See mock docs.

' + ].join(""), + text: [ + "Mock summary", + "First choice", + "Second choice", + "See [mock docs](https://example.test/mock-docs)." + ].join("\n") + }).onSelect?.()); + + expect(editor.view.state.doc.toString()).toBe([ + "Mock summary", + "", + "1. First `choice`", + "2. Second choice", + "", + "See [mock docs](https://example.test/mock-docs)." + ].join("\n")); + }); + it("does not change a read-only editor", async () => { const doc = "Read only"; const editor = createEditor( diff --git a/packages/app/src/runtime/context-menu-items.ts b/packages/app/src/runtime/context-menu-items.ts index ccc8773a..29d1d853 100644 --- a/packages/app/src/runtime/context-menu-items.ts +++ b/packages/app/src/runtime/context-menu-items.ts @@ -14,6 +14,7 @@ import { type ContextMenuEntry } from "../components/ContextMenu"; import type { + NativeClipboardContent, NativeEditorContextMenuEntryOptions, NativeEditorContextMenuOptions, NativeMarkdownFileTreeContextMenuHandlers, @@ -25,8 +26,12 @@ import type { NativeMarkdownFolderFile } from "../lib/tauri/file"; type BrowserEditCommand = "copy" | "cut" | "paste" | "selectAll"; type ClipboardTextInserter = (text: string) => boolean | Promise; +type ClipboardContentInserter = ( + content: NativeClipboardContent +) => boolean | Promise; type EditorContextMenuEntryOptions = NativeEditorContextMenuEntryOptions & { + insertClipboardContent?: ClipboardContentInserter; insertClipboardText?: ClipboardTextInserter; }; @@ -120,21 +125,32 @@ async function readBrowserClipboardText(documentTarget: Document) { return readText.call(clipboard); } -function clipboardTextData(text: string) { +function clipboardContentData(content: NativeClipboardContent) { + const html = typeof content.html === "string" ? content.html : ""; + const text = typeof content.text === "string" ? content.text : ""; return { files: Object.assign([], { item: () => null }), - getData: (type: string) => type === "text/plain" ? text : "" + getData: (type: string) => { + if (type === "text/html") return html; + if (type === "text/plain") return text; + + return ""; + }, + types: [ + ...(html ? ["text/html"] : []), + ...(text ? ["text/plain"] : []) + ] }; } -function createClipboardTextInserter(target: Element) { +function createClipboardContentInserter(target: Element) { const paper = target.closest(".markdown-paper"); const content = paper?.querySelector(".cm-content"); if (!content) return undefined; - return (text: string) => { + return (clipboardContent: NativeClipboardContent) => { // Native clipboard reads are asynchronous. If the originating editor was // removed meanwhile, consume the paste instead of retargeting another tab. if (!content.isConnected) return true; @@ -145,33 +161,69 @@ function createClipboardTextInserter(target: Element) { cancelable: true }); Object.defineProperty(event, "clipboardData", { - value: clipboardTextData(text) + value: clipboardContentData(clipboardContent) }); content.dispatchEvent(event); return true; }; } -async function pasteClipboardText( - documentTarget: Document, - readClipboardText?: NativeEditorContextMenuOptions["readClipboardText"], - insertClipboardText?: ClipboardTextInserter +function createClipboardTextInserter( + insertContent: ClipboardContentInserter | undefined ) { - const readText = readClipboardText ?? (() => readBrowserClipboardText(documentTarget)); + return insertContent ? (text: string) => insertContent({ text }) : undefined; +} + +function normalizeClipboardContent( + content: NativeClipboardContent | null | undefined +) { + const html = typeof content?.html === "string" ? content.html : ""; + const text = typeof content?.text === "string" ? content.text : ""; + return html || text ? { html, text } : null; +} +async function pasteClipboardContent( + documentTarget: Document, + readClipboardContent: NonNullable, + insertClipboardContent?: ClipboardContentInserter +) { try { - const text = await readText(); - if (!text) return false; - // Live preview replaces Markdown markers in the DOM, so DOM insertion can - // map pasted text before a hidden list marker instead of after it. - if (insertClipboardText && await insertClipboardText(text)) return true; + const content = normalizeClipboardContent(await readClipboardContent()); + if (!content) return false; + if (insertClipboardContent && await insertClipboardContent(content)) { + return true; + } + if (!content.text) return false; - return runDocumentEditCommand(documentTarget, "insertText", false, text); + return runDocumentEditCommand(documentTarget, "insertText", false, content.text); } catch { return false; } } +async function pasteClipboardText( + documentTarget: Document, + readClipboardText?: NativeEditorContextMenuOptions["readClipboardText"], + insertClipboardText?: ClipboardTextInserter +) { + const readText = readClipboardText ?? (() => readBrowserClipboardText(documentTarget)); + // Keep the fallback on the editor pipeline because live preview can hide + // Markdown markers that make direct DOM insertion target the wrong offset. + return pasteClipboardContent( + documentTarget, + async () => { + const text = await readText(); + + return text ? { text } : null; + }, + insertClipboardText + ? (content) => content.text + ? insertClipboardText(content.text) + : false + : undefined + ); +} + async function runInjectedClipboardPasteCommand( documentTarget: Document, readClipboardText: NonNullable, @@ -185,11 +237,25 @@ async function runInjectedClipboardPasteCommand( function runBrowserEditCommand( command: BrowserEditCommand, - options: Pick = {} + options: Pick< + EditorContextMenuEntryOptions, + | "insertClipboardContent" + | "insertClipboardText" + | "readClipboardContent" + | "readClipboardText" + > = {} ) { const documentTarget = typeof document === "undefined" ? null : document; if (!documentTarget) return false; + if (command === "paste" && options.readClipboardContent) { + return pasteClipboardContent( + documentTarget, + options.readClipboardContent, + options.insertClipboardContent + ).then((handled) => handled || runDocumentEditCommand(documentTarget, "paste")); + } + if (command === "paste" && options.readClipboardText) { return runInjectedClipboardPasteCommand( documentTarget, @@ -209,7 +275,13 @@ function browserItem( label: string, accelerator: string, command: BrowserEditCommand, - options: Pick = {} + options: Pick< + EditorContextMenuEntryOptions, + | "insertClipboardContent" + | "insertClipboardText" + | "readClipboardContent" + | "readClipboardText" + > = {} ) { return contextMenuItem(id, label, accelerator, () => runBrowserEditCommand(command, options), false); } @@ -310,13 +382,18 @@ export function createEditorContextMenuEntriesFromOptions( idPrefixes?: Partial, target?: Element ) { + const insertClipboardContent = target + ? createClipboardContentInserter(target) + : undefined; return createEditorContextMenuEntries( handlers, language, { aiCommandsAvailable: readAiCommandsAvailable(options), - insertClipboardText: target ? createClipboardTextInserter(target) : undefined, + insertClipboardContent, + insertClipboardText: createClipboardTextInserter(insertClipboardContent), markdownShortcuts: options.markdownShortcuts, + readClipboardContent: options.readClipboardContent, readClipboardText: options.readClipboardText }, idPrefixes diff --git a/packages/app/src/runtime/index.ts b/packages/app/src/runtime/index.ts index 4b5e1621..873611a3 100644 --- a/packages/app/src/runtime/index.ts +++ b/packages/app/src/runtime/index.ts @@ -63,6 +63,7 @@ import type { WriteNativeWebDavTextFileInput } from "../lib/tauri/file"; import type { + NativeClipboardContent, NativeEditorContextMenuEntryOptions, NativeEditorContextMenuOptions, NativeMarkdownFileTreeContextMenuHandlers, @@ -271,6 +272,7 @@ export type AppMenuRuntime = { options?: NativeEditorContextMenuOptions ) => Promise; listenApplicationMenuCommands: (handlers: NativeMenuHandlers) => Promise; + readClipboardContent: () => Promise; readClipboardText: () => Promise; showMarkdownFileTreeContextMenu: ( handlers: NativeMarkdownFileTreeContextMenuHandlers, @@ -574,6 +576,11 @@ export function createDefaultAppRuntime(): AppRuntime { installApplicationMenu: async () => () => undefined, installEditorContextMenu: async () => () => undefined, listenApplicationMenuCommands: async () => () => undefined, + readClipboardContent: async () => { + const text = await readBrowserClipboardText(); + + return text ? { text } : null; + }, readClipboardText: readBrowserClipboardText, showMarkdownFileTreeContextMenu: async () => undefined }, @@ -732,6 +739,8 @@ export { type ContextMenuIdPrefixes } from "./context-menu-items"; export type { + NativeClipboardContent, + NativeClipboardContentReader, NativeEditorContextMenuOptions, NativeMarkdownFileTreeContextMenuHandlers, NativeMenuCommand, diff --git a/packages/editor/src/codemirror/clipboard-assets.test.ts b/packages/editor/src/codemirror/clipboard-assets.test.ts index d4cf09fc..5391f30e 100644 --- a/packages/editor/src/codemirror/clipboard-assets.test.ts +++ b/packages/editor/src/codemirror/clipboard-assets.test.ts @@ -196,6 +196,208 @@ describe("codeMirrorClipboardAssetsPlugin", () => { expect(view.state.doc.toString()).toContain("Outro"); }); + it("prefers structured rich HTML over Markdown-looking fallback text", () => { + const view = createView(""); + + const event = paste(view, { + html: [ + "

Mock summary

", + "
  1. First choice
  2. Second choice
", + '

See mock docs.

', + ].join(""), + text: [ + "Mock summary", + "First choice", + "Second choice", + "See [mock docs](https://example.test/mock-docs).", + ].join("\n"), + }); + + expect(event.defaultPrevented).toBe(true); + expect(view.state.doc.toString()).toBe([ + "Mock summary", + "", + "1. First `choice`", + "2. Second choice", + "", + "See [mock docs](https://example.test/mock-docs).", + ].join("\n")); + }); + + it("keeps styled file badges as inline links", () => { + const view = createView(""); + const expected = [ + "Mock changes: ", + "[example-a.ts (line 108)](/mock-project/src/example-a.ts:108), ", + "[example-b.ts (line 438)](C:/mock-project/src/example-b.ts:438), ", + "[example-c.ts (line 7)](https://example.test/mock-file#L7).", + ].join(""); + + const event = paste(view, { + html: [ + "

Mock changes: ", + '', + '

", + ", ", + '', + "
example-b.ts
", + "
(line 438)
", + "
, ", + '', + '

example-c.ts (line 7)

', + "
.

", + ].join(""), + text: expected, + }); + + expect(event.defaultPrevented).toBe(true); + 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 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 ', + '

.

", + ].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 = [ + "# Mock score", + "=", + "+ reward × 100", + "", + "- resource cost", + ].join("\n"); + + const event = paste(view, { + html: [ + "

Mock formula

", + "
  • First constraint
  • Second constraint
", + "

Use this synthetic model:

", + '
', + "
# Mock score
", + "
=
", + "
+ reward × 100
", + "

", + "
- resource cost
", + "
", + ].join(""), + text: [ + "Mock formula", + "First constraint", + "Second constraint", + "Use this synthetic model:", + code, + ].join("\n"), + }); + + expect(event.defaultPrevented).toBe(true); + expect(view.state.doc.toString()).toBe([ + "## Mock formula", + "", + "- First constraint", + "- Second constraint", + "", + "Use this synthetic model:", + "", + "```", + code, + "```", + ].join("\n")); + }); + + it("preserves language metadata from a styled mixed-content code block", () => { + const code = "print('mock value')"; + const view = createView(""); + + paste(view, { + html: [ + "

Mock introduction

", + '
', + `${code}`, + "
", + "

Mock conclusion

", + ].join(""), + text: ["Mock introduction", code, "Mock conclusion"].join("\n"), + }); + + expect(view.state.doc.toString()).toBe([ + "Mock introduction", + "", + "```python", + code, + "```", + "", + "Mock conclusion", + ].join("\n")); + }); + + it("keeps Markdown source from a non-semantic editor clipboard", () => { + const source = [ + "# Mock heading", + "", + "- First item", + "- Second item", + ].join("\n"); + const view = createView(""); + + paste(view, { + html: [ + '
# Mock heading
', + '

', + '
- First item
', + '
- Second item
', + ].join(""), + text: source, + }); + + expect(view.state.doc.toString()).toBe(source); + }); + it("wraps code copied with syntax-highlighted HTML in a fenced block", () => { const code = [ "const mock_value = items[0];", diff --git a/packages/editor/src/codemirror/clipboard-assets.ts b/packages/editor/src/codemirror/clipboard-assets.ts index 76f31774..9b160b6d 100644 --- a/packages/editor/src/codemirror/clipboard-assets.ts +++ b/packages/editor/src/codemirror/clipboard-assets.ts @@ -431,9 +431,11 @@ function insertHtmlPaste( const html = event.clipboardData?.getData("text/html") ?? ""; if (!html) return false; const plainText = event.clipboardData?.getData("text/plain") ?? ""; - if (looksLikeMarkdownSource(plainText)) return false; const converted = convertCodeMirrorClipboardHtml(html, plainText); if (!converted) return false; + // Rendered rich text can contain an incidental Markdown-looking fragment. + // Only preserve the raw source when the HTML has no authored document structure. + if (looksLikeMarkdownSource(plainText) && !converted.structured) return false; const { from, to } = view.state.selection.main; const replacements = saveRemoteImage diff --git a/packages/editor/src/codemirror/html-paste.ts b/packages/editor/src/codemirror/html-paste.ts index 1ce82bd2..b934dc10 100644 --- a/packages/editor/src/codemirror/html-paste.ts +++ b/packages/editor/src/codemirror/html-paste.ts @@ -4,31 +4,175 @@ import type { RemoteClipboardImage } from "../clipboard-asset-types.ts"; export interface CodeMirrorHtmlPaste { readonly markdown: string; readonly remoteImages: readonly RemoteClipboardImage[]; + readonly structured: boolean; } const codeFontPattern = /(?:monospace|menlo|monaco|consolas|courier|sfmono|fira code|jetbrains mono|cascadia code|source code pro)/iu; const preformattedWhitespacePattern = /white-space\s*:\s*(?:pre|pre-wrap|break-spaces)/iu; +const richTextSelector = [ + "a[href]", + "b", + "blockquote", + "del", + "em", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "hr", + "i", + "img", + "ol", + "s", + "strike", + "strong", + "sub", + "sup", + "table", + "ul", +].join(","); +const preformattedBlockNames = new Set(["DIV", "P", "PRE"]); +const anchorMarkupPattern = /"']|"[^"]*"|'[^']*')*>[\s\S]*?<\/a\s*>/giu; + +function preformattedStyle(element: Element) { + const style = element.getAttribute("style") ?? ""; + return codeFontPattern.test(style) || preformattedWhitespacePattern.test(style); +} + +function preformattedElements(document: Document) { + return Array.from(document.querySelectorAll("pre, [style]")) + .filter((element) => element.tagName === "PRE" || preformattedStyle(element)); +} + +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) => { + 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) { + return Array.from(document.body.childNodes).filter( + (node) => node.nodeType === 1 || + (node.nodeType === 3 && Boolean(node.textContent?.trim())), + ); +} + +function hasMixedPreformattedContent(document: Document) { + const bodyNodes = meaningfulBodyNodes(document); + return preformattedElements(document).some( + (element) => bodyNodes.some( + (node) => node !== element && !element.contains(node), + ), + ); +} + +function hasStructuredHtml(document: Document) { + return document.querySelector(richTextSelector) !== null || + hasMixedPreformattedContent(document); +} function syntaxHighlightedPlainText( document: Document, plainText: string, ) { if (!plainText || document.querySelector("pre > code")) return null; - const preformatted = document.querySelector("pre") !== null || - Array.from(document.querySelectorAll("[style]")).some( - (element) => { - const style = element.getAttribute("style") ?? ""; - return codeFontPattern.test(style) || - preformattedWhitespacePattern.test(style); - }, - ); - if (!preformatted) return null; + if (preformattedElements(document).length === 0 || hasStructuredHtml(document)) { + return null; + } // Syntax-highlighted clipboard HTML represents punctuation as ordinary text. // Turndown would escape it as Markdown, so preserve the accompanying source. return plainText.replace(/\r\n?/gu, "\n"); } +function preformattedNodeText(node: Node): string { + if (node.nodeType === 3) return node.textContent ?? ""; + if (node.nodeType !== 1) return ""; + const element = node as Element; + if (element.tagName === "BR") return "\n"; + + const text = Array.from(element.childNodes) + .map((child) => preformattedNodeText(child)) + .join(""); + if (!preformattedBlockNames.has(element.tagName)) return text; + return `${text.replace(/\n+$/u, "")}\n`; +} + +function codeLanguageClass(element: Element) { + const candidates = [element, ...Array.from(element.querySelectorAll("[class]"))]; + for (const candidate of candidates) { + for (const className of candidate.classList) { + const match = /^(?:lang(?:uage)?)-(.+)$/iu.exec(className); + if (match?.[1]) return `language-${match[1]}`; + } + } + + return ""; +} + +function normalizeStyledCodeBlocks(document: Document) { + for (const element of preformattedElements(document)) { + if (!preformattedBlockNames.has(element.tagName)) continue; + if (element.tagName === "PRE" && element.querySelector(":scope > code")) { + continue; + } + const parent = element.parentElement; + if (parent && preformattedStyle(parent)) continue; + + const codeText = preformattedNodeText(element) + .replace(/\r\n?/gu, "\n") + .replace(/\n+$/u, ""); + if (!codeText) continue; + const pre = document.createElement("pre"); + const code = document.createElement("code"); + const languageClass = codeLanguageClass(element); + if (languageClass) code.classList.add(languageClass); + code.textContent = codeText; + pre.append(code); + element.replaceWith(pre); + } +} + function normalizedCellMarkdown(service: TurndownService, cell: Element) { return service .turndown(cell.innerHTML) @@ -103,9 +247,17 @@ export function convertCodeMirrorClipboardHtml( plainText = "", ): CodeMirrorHtmlPaste | null { if (!html.trim() || typeof DOMParser === "undefined") return null; - const document = new DOMParser().parseFromString(html, "text/html"); + const parser = new DOMParser(); + const document = parser.parseFromString( + normalizeAnchorBlockMarkup(html, parser), + "text/html", + ); const service = createTurndownService(); + const structured = hasStructuredHtml(document); const code = syntaxHighlightedPlainText(document, plainText); + // Turndown collapses whitespace in ordinary styled elements before applying + // rules, so semanticize code containers while their authored line breaks remain. + if (!code) normalizeStyledCodeBlocks(document); const markdown = code ?? service .turndown(document.body.innerHTML) .replace(/\r\n?/gu, "\n") @@ -119,5 +271,6 @@ export function convertCodeMirrorClipboardHtml( const remote = remoteImage(image); return remote ? [remote] : []; }), + structured, }; }