diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a6f423a..a2a238e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -102,6 +102,27 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "wl-clipboard-rs", + "x11rb", +] + [[package]] name = "arc-swap" version = "1.8.2" @@ -617,6 +638,15 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "combine" version = "4.6.7" @@ -1090,6 +1120,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + [[package]] name = "event-listener" version = "5.4.1" @@ -1123,6 +1159,26 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fax" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" +dependencies = [ + "fax_derive", +] + +[[package]] +name = "fax_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "fdeflate" version = "0.3.7" @@ -1168,6 +1224,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.9" @@ -1480,6 +1542,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link 0.2.1", +] + [[package]] name = "getrandom" version = "0.1.16" @@ -1691,6 +1763,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -1752,12 +1835,14 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" name = "helixnotes" version = "1.1.8" dependencies = [ + "arboard", "chrono", "dirs", "futures", "gray_matter", "log", "notify", + "png 0.17.16", "rayon", "regex", "reqwest 0.12.28", @@ -2075,6 +2160,7 @@ dependencies = [ "moxcms", "num-traits", "png 0.18.1", + "tiff", ] [[package]] @@ -2653,6 +2739,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "notify" version = "8.2.0" @@ -3007,6 +3102,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.45.0", +] + [[package]] name = "osakit" version = "0.3.1" @@ -3096,6 +3201,17 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap 2.13.0", +] + [[package]] name = "phf" version = "0.8.0" @@ -3449,6 +3565,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.38.4" @@ -4734,7 +4856,7 @@ version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "847434d4af57b32e309f4ab1b4f1707a6c566656264caa427ff4285c4d9d0b82" dependencies = [ - "nom", + "nom 7.1.3", ] [[package]] @@ -5273,6 +5395,20 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tiff" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + [[package]] name = "time" version = "0.3.47" @@ -5585,6 +5721,17 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom 8.0.0", + "petgraph", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -5952,6 +6099,76 @@ dependencies = [ "semver", ] +[[package]] +name = "wayland-backend" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fee64194ccd96bf648f42a65a7e589547096dfa702f7cadef84347b66ad164f9" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.4", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e6faa537fbb6c186cb9f1d41f2f811a4120d1b57ec61f50da451a0c5122bec" +dependencies = [ + "bitflags 2.11.0", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baeda9ffbcfc8cd6ddaade385eaf2393bd2115a69523c735f12242353c3df4f3" +dependencies = [ + "bitflags 2.11.0", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9597cdf02cf0c34cd5823786dce6b5ae8598f05c2daf5621b6e178d4f7345f3" +dependencies = [ + "bitflags 2.11.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6dbfc3ac5ef974c92a2235805cc0114033018ae1290a72e474aa8b28cbbdfd" +dependencies = [ + "pkg-config", +] + [[package]] name = "web-sys" version = "0.3.89" @@ -6070,6 +6287,12 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "winapi" version = "0.3.9" @@ -6620,6 +6843,24 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wl-clipboard-rs" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix 1.1.4", + "thiserror 2.0.18", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "writeable" version = "0.6.2" @@ -6701,6 +6942,23 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix 1.1.4", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + [[package]] name = "xattr" version = "1.6.1" @@ -6961,6 +7219,21 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[package]] +name = "zune-jpeg" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +dependencies = [ + "zune-core", +] + [[package]] name = "zvariant" version = "5.10.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b41c6b1..b06daaa 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -38,6 +38,8 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "str futures = "0.3" rayon = "1" rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] } +arboard = { version = "3", features = ["image-data", "wayland-data-control"] } +png = "0.17" [target.'cfg(not(target_os = "android"))'.dependencies] tauri-plugin-updater = "2" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index e2773f4..7861bac 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -462,6 +462,34 @@ pub fn save_vault_state(state: State<'_, AppState>, vault_state: VaultState) -> operations::save_vault_state(vault_path, &vault_state) } +// ── Clipboard ── + +/// Read image from system clipboard (bypasses WebKitGTK clipboard bug). +/// Returns PNG bytes as Vec, or error if no image on clipboard. +#[tauri::command] +pub fn read_clipboard_image() -> Result, String> { + let mut clipboard = + arboard::Clipboard::new().map_err(|e| format!("Clipboard init failed: {}", e))?; + let img = clipboard + .get_image() + .map_err(|_| "No image on clipboard".to_string())?; + // Encode RGBA data to PNG + let mut buf: Vec = Vec::new(); + { + let mut encoder = + png::Encoder::new(std::io::Cursor::new(&mut buf), img.width as u32, img.height as u32); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + let mut writer = encoder + .write_header() + .map_err(|e| format!("PNG header failed: {}", e))?; + writer + .write_image_data(&img.bytes) + .map_err(|e| format!("PNG encode failed: {}", e))?; + } + Ok(buf) +} + // ── Attachments ── #[tauri::command] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3aec366..5246ca1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -115,6 +115,7 @@ pub fn run() { commands::empty_trash, commands::load_vault_state, commands::save_vault_state, + commands::read_clipboard_image, commands::save_image, commands::save_attachment, commands::get_notebook_icons, diff --git a/src/lib/api.ts b/src/lib/api.ts index 0f242ae..6f89bc2 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -164,6 +164,10 @@ export async function saveVaultState(vaultState: VaultState): Promise { return invoke("save_vault_state", { vaultState }); } +export async function readClipboardImage(): Promise { + return invoke("read_clipboard_image"); +} + export async function saveImage(name: string, data: number[]): Promise { return invoke("save_image", { name, data }); } diff --git a/src/lib/components/AppLayout.svelte b/src/lib/components/AppLayout.svelte index cb78d06..ec91cce 100644 --- a/src/lib/components/AppLayout.svelte +++ b/src/lib/components/AppLayout.svelte @@ -98,6 +98,7 @@ noteHistoryIndex = newIndex; navigatingFromHistory = true; readNote(path).then((content) => { + editor?.flushSave(); $activeNote = content; $activeNotePath = path; $editorDirty = false; @@ -114,6 +115,7 @@ if (!vaultRoot || !filePath.startsWith(vaultRoot)) return; try { const content = await readNote(filePath); + editor?.flushSave(); $activeNote = content; $activeNotePath = filePath; $editorDirty = false; @@ -167,6 +169,7 @@ try { const entry = await createDailyNote(); const content = await readNote(entry.path); + editor?.flushSave(); $activeNote = content; $activeNotePath = entry.path; $editorDirty = false; @@ -391,7 +394,7 @@
- sidebar?.refresh()} /> + editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} />
@@ -485,7 +488,7 @@ {/if}
- sidebar?.refresh()} /> + editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} />
diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index 27ed258..ba4268f 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -39,7 +39,7 @@ import { openFile, copyFileTo } from '$lib/api'; import { save as saveDialog } from '@tauri-apps/plugin-dialog'; import { activeNote, activeNotePath, appConfig, editorDirty, sourceMode, focusMode, readOnly, quickAccessPaths, notes } from '$lib/stores/app'; - import { saveNote, saveImage, saveAttachment, addQuickAccess, removeQuickAccess, getQuickAccess, getNoteVersions, getNoteVersionContent, createVersion, aiAsk, getAllNoteTitles, readNote, renameNote } from '$lib/api'; + import { saveNote, saveImage, saveAttachment, readClipboardImage, addQuickAccess, removeQuickAccess, getQuickAccess, getNoteVersions, getNoteVersionContent, createVersion, aiAsk, getAllNoteTitles, readNote, renameNote } from '$lib/api'; import type { VersionEntry, AiStreamEvent, NoteTitleEntry } from '$lib/types'; import { listen } from '@tauri-apps/api/event'; import { debounce } from '$lib/utils/debounce'; @@ -1203,22 +1203,25 @@ }); } - export function loadNote(path: string, content: string) { - // Flush any unsaved changes for the CURRENT note before loading the new one - if ($editorDirty && $activeNote && $activeNotePath && $activeNotePath !== path) { - try { - const body = $sourceMode - ? restoreTitleH1(sourceContent) - : editorToMarkdown(); - const trimmed = body.replace(/^#.*\n?/, '').trim(); - if (trimmed || !$activeNote.content || $activeNote.content.trim().length <= 10) { - saveNote($activeNotePath, $activeNote.meta, body); - } - } catch (e) { - console.error('Pre-switch save failed:', e); + /** Flush unsaved editor content to disk (synchronous serialize + fire-and-forget save). + * Call BEFORE updating $activeNote/$activeNotePath stores when switching notes. */ + export function flushSave() { + if (!$editorDirty || !$activeNote || !$activeNotePath) return; + try { + const body = $sourceMode + ? restoreTitleH1(sourceContent) + : editorToMarkdown(); + const trimmed = body.replace(/^#.*\n?/, '').trim(); + if (trimmed || !$activeNote.content || $activeNote.content.trim().length <= 10) { + saveNote($activeNotePath, $activeNote.meta, body); } - $editorDirty = false; + } catch (e) { + console.error('Pre-switch save failed:', e); } + $editorDirty = false; + } + + export function loadNote(path: string, content: string) { loadedPath = path; lastSourceMode = $sourceMode; isLoadingNote = true; @@ -2018,21 +2021,7 @@ }); function destroyEditor() { - // Flush unsaved changes before destroying - if ($editorDirty && $activeNote && $activeNotePath && editor) { - try { - const body = $sourceMode - ? restoreTitleH1(sourceContent) - : editorToMarkdown(); - const trimmed = body.replace(/^#.*\n?/, '').trim(); - if (trimmed || !$activeNote.content || $activeNote.content.trim().length <= 10) { - saveNote($activeNotePath, $activeNote.meta, body); - } - } catch (e) { - console.error('Destroy-save failed:', e); - } - $editorDirty = false; - } + flushSave(); if (editor) { editor.destroy(); editor = null; @@ -2757,9 +2746,34 @@ } return true; } + // WebKitGTK fallback (bug #218519): older WebKitGTK versions return + // empty DataTransferItemList for image pastes. Detect this and read + // the image directly from the system clipboard via Rust/arboard. + if (items.length === 0) { + const hasText = event.clipboardData!.getData('text/plain'); + const hasHtml = event.clipboardData!.getData('text/html'); + if (!hasText && !hasHtml) { + event.preventDefault(); + insertClipboardImage(); + return true; + } + } return false; } + async function insertClipboardImage() { + try { + const data = await readClipboardImage(); + const relativePath = await saveImage('pasted-image.png', data); + if (editor) { + const displaySrc = resolveImageSrc(relativePath); + editor.chain().focus().setImage({ src: displaySrc }).run(); + } + } catch (e) { + console.error('Clipboard image fallback failed:', e); + } + } + async function insertImage(file: File) { try { const buffer = await file.arrayBuffer(); diff --git a/src/lib/components/NoteList.svelte b/src/lib/components/NoteList.svelte index 86940cc..7a67e89 100644 --- a/src/lib/components/NoteList.svelte +++ b/src/lib/components/NoteList.svelte @@ -35,9 +35,10 @@ import { openNoteWindow } from '$lib/utils/window'; import type { NoteEntry, SortMode } from '$lib/types'; - let { onNoteSelected = (_path: string, _content: string) => {}, onNoteMoved = () => {} }: { + let { onNoteSelected = (_path: string, _content: string) => {}, onNoteMoved = () => {}, onBeforeNoteSwitch = () => {} }: { onNoteSelected?: (path: string, content: string) => void; onNoteMoved?: () => void; + onBeforeNoteSwitch?: () => void; } = $props(); const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl'; @@ -184,6 +185,7 @@ if ($activeNotePath === note.path) return; try { const content = await readNote(note.path); + onBeforeNoteSwitch(); $activeNote = content; $activeNotePath = note.path; $editorDirty = false;