Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 70 additions & 1 deletion apps/desktop/src-tauri/src/clipboard.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
use serde::Serialize;

#[derive(Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ClipboardContent {
html: Option<String>,
text: Option<String>,
}

fn clipboard_text_from_result(
result: Result<String, arboard::Error>,
) -> Result<Option<String>, String> {
Expand All @@ -8,6 +17,28 @@ fn clipboard_text_from_result(
}
}

fn clipboard_content_from_results(
text_result: Result<String, arboard::Error>,
html_result: Result<String, arboard::Error>,
) -> Result<Option<ClipboardContent>, 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<Option<String>, String> {
let mut clipboard = arboard::Clipboard::new()
Expand All @@ -16,9 +47,19 @@ pub(crate) fn read_clipboard_text() -> Result<Option<String>, String> {
clipboard_text_from_result(clipboard.get_text())
}

#[tauri::command]
pub(crate) fn read_clipboard_content() -> Result<Option<ClipboardContent>, 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() {
Expand All @@ -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("<p><strong>mock text</strong></p>".to_string()),
),
Ok(Some(ClipboardContent {
html: Some("<p><strong>mock text</strong></p>".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()),
}))
);
}
}
3 changes: 2 additions & 1 deletion apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/runtime/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
Expand Down
25 changes: 21 additions & 4 deletions apps/desktop/src/runtime/tauri/menu.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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: "<p><strong>native clipboard text</strong></p>",
text: "native clipboard text"
};
}

return undefined;
});
Expand All @@ -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");
Expand Down Expand Up @@ -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: "<p><strong>side clipboard text</strong></p>",
text: "side clipboard text"
};
}

return undefined;
});
Expand All @@ -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(
"<p><strong>side clipboard text</strong></p>"
);
expect(clipboardEvent.clipboardData?.getData("text/plain")).toBe(
"side clipboard text"
);
expect(execCommand).not.toHaveBeenCalled();
});

Expand Down
30 changes: 26 additions & 4 deletions apps/desktop/src/runtime/tauri/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
};

Expand Down Expand Up @@ -191,10 +195,28 @@ export async function readNativeClipboardText() {
}
}

function withNativeClipboardText<TOptions extends { readClipboardText?: NativeClipboardTextReader }>(options: TOptions) {
export async function readNativeClipboardContent() {
try {
const content = await invokeNative<NativeClipboardContent | null>(
"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
};
}

Expand All @@ -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(
Expand All @@ -226,7 +248,7 @@ export async function installNativeEditorContextMenu(
entries: createEditorContextMenuEntriesFromOptions(
handlers,
language,
withNativeClipboardText(options),
withNativeClipboardContent(options),
desktopContextMenuIdPrefixes,
element
),
Expand Down
13 changes: 13 additions & 0 deletions packages/app/src/lib/tauri/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,28 @@ export type NativeMarkdownFileTreeContextMenuHandlers = {

export type NativeClipboardTextReader = () => string | null | undefined | Promise<string | null | undefined>;

export type NativeClipboardContent = {
html?: string | null;
text?: string | null;
};

export type NativeClipboardContentReader = () =>
| NativeClipboardContent
| null
| undefined
| Promise<NativeClipboardContent | null | undefined>;

export type NativeEditorContextMenuOptions = {
getAiCommandsAvailable?: () => boolean;
markdownShortcuts?: MarkdownShortcutMap;
readClipboardContent?: NativeClipboardContentReader;
readClipboardText?: NativeClipboardTextReader;
};

export type NativeEditorContextMenuEntryOptions = {
aiCommandsAvailable?: boolean;
markdownShortcuts?: MarkdownShortcutMap;
readClipboardContent?: NativeClipboardContentReader;
readClipboardText?: NativeClipboardTextReader;
};

Expand Down
56 changes: 55 additions & 1 deletion packages/app/src/runtime/context-menu-items.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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: [
"<p>Mock summary</p>",
"<ol><li>First <code>choice</code></li><li>Second choice</li></ol>",
'<p>See <a href="https://example.test/mock-docs">mock docs</a>.</p>'
].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(
Expand Down
Loading
Loading