From 11f68a1185fe67dd1e16adf1f5405fbd6964a934 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 13 Oct 2026 22:08:14 +0300 Subject: [PATCH] feat(clipboard): paste images from other programs on desktop too A paste that finds no text now reads an image off the system clipboard on desktop as well as on the web. The pixels are written as a PNG beside the drawing (or in the config folder for a drawing never saved) and placed as a raster image at the cursor, the same way the web paste attaches a screenshot. PASTESPEC falls back to the same image paste when the clipboard holds neither objects nor text. Refs #645 Co-Authored-By: Claude Opus 5.5 --- src/app/mod.rs | 8 +++--- src/app/update/command.rs | 57 +++++++++++++++++++++++++++++++++++---- src/app/update/mod.rs | 29 +++++--------------- src/sys.rs | 1 - 4 files changed, 62 insertions(+), 33 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index 75b493be0..e017199c8 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -2389,11 +2389,9 @@ pub enum Message { PasteSpecial(Option), /// Completion of a system text clipboard read requested by PASTECLIP. SystemClipboardPaste(SystemClipboardText), - /// Web: completion of the clipboard-image read the paste fallback starts - /// when no text was found — `Some((name, bytes))` attaches the image, - /// `None` reports the usual unsupported-content error. Native never - /// constructs it. - #[cfg(target_arch = "wasm32")] + /// Completion of the clipboard-image read the paste fallback starts when + /// no text was found — `Some((name, bytes))` attaches the image, `None` + /// reports the usual unsupported-content error. SystemClipboardPasteImage(Option<(String, Vec)>), /// Ctrl/Cmd+A — select all layer rows when the Layer Manager is open, or all /// drawing objects otherwise (#236). diff --git a/src/app/update/command.rs b/src/app/update/command.rs index b0bdad065..9d6ee11fa 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -2162,12 +2162,59 @@ impl OpenCADStudio { } } - /// Attach a clipboard image read on the web (a pasted screenshot) the way - /// the IMAGE command's pick does: register the bytes name-keyed, decode - /// for the file's true pixel size, then commit the entity — but placed - /// directly at the cursor (0,0 before any pointer move) with the command's + /// Read an image off the system clipboard for a paste that found no + /// text. The web gets the encoded file from the Clipboard API; native + /// gets pixels, which are written as a PNG beside the drawing (or, for a + /// drawing never saved, in the config folder) so the image entity keeps + /// a file to point at. + pub(in crate::app) fn read_system_clipboard_image(&self) -> Task { + #[cfg(target_arch = "wasm32")] + { + Task::perform( + crate::sys::read_clipboard_image(), + Message::SystemClipboardPasteImage, + ) + } + #[cfg(not(target_arch = "wasm32"))] + { + let folder = self.tabs[self.active_tab] + .current_path + .as_ref() + .and_then(|path| path.parent().map(std::path::Path::to_path_buf)) + .filter(|folder| !folder.as_os_str().is_empty()) + .or_else(|| crate::config::config_dir().map(|dir| dir.join("pasted"))); + iced::clipboard::read_image().map(move |image| { + let saved = image.ok().zip(folder.clone()).and_then(|(image, folder)| { + let pixels = image::RgbaImage::from_raw( + image.size.width, + image.size.height, + image.rgba.to_vec(), + )?; + let mut png = std::io::Cursor::new(Vec::new()); + pixels.write_to(&mut png, image::ImageFormat::Png).ok()?; + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |elapsed| elapsed.as_millis()); + let name = crate::sys::unique_pasted_image_name( + &format!("pasted-{stamp}"), + "png", + |name| folder.join(name).exists(), + ); + let path = folder.join(name); + std::fs::create_dir_all(&folder).ok()?; + std::fs::write(&path, png.get_ref()).ok()?; + Some((path.to_string_lossy().into_owned(), png.into_inner())) + }); + Message::SystemClipboardPasteImage(saved) + }) + } + } + + /// Attach a clipboard image (a pasted screenshot) the way the IMAGE + /// command's pick does: register the bytes name-keyed, decode for the + /// file's true pixel size, then commit the entity — but placed directly + /// at the cursor (0,0 before any pointer move) with the command's /// default width, skipping the pick prompts a paste should not run. - #[cfg(target_arch = "wasm32")] pub(super) fn paste_clipboard_image(&mut self, name: String, bytes: Vec) -> Task { let i = self.active_tab; if self.tabs[i].is_start { diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index ebce21b8e..a1f199706 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -6895,11 +6895,10 @@ impl OpenCADStudio { self.push_ucs_to_cmd(i); Task::none() } + // Nothing but maybe an image: paste it as one. None if self.clipboard.is_empty() => { self.tabs[i].active_cmd = None; - self.command_line - .push_error(crate::tr!("clipboard", "no-supported-content").as_ref()); - Task::none() + self.read_system_clipboard_image() } None => Task::none(), } @@ -6917,24 +6916,11 @@ impl OpenCADStudio { self.focus_cmd_input() } Text::EmptyOrUnsupported => { - // Web: no text — the clipboard may still hold an IMAGE - // (a pasted screenshot). Read it through the async - // Clipboard API and attach it the way the IMAGE - // command's pick does; only when that also finds + // No text — the clipboard may still hold an IMAGE (a + // pasted screenshot). Read it and attach it the way the + // IMAGE command's pick does; only when that also finds // nothing does the unsupported-content error surface. - // Native has no image route off the sync clipboard. - #[cfg(target_arch = "wasm32")] - return Task::perform( - crate::sys::read_clipboard_image(), - Message::SystemClipboardPasteImage, - ); - #[cfg(not(target_arch = "wasm32"))] - { - self.command_line.push_error( - crate::tr!("clipboard", "no-supported-content").as_ref(), - ); - Task::none() - } + self.read_system_clipboard_image() } Text::Unavailable => { self.command_line @@ -6954,10 +6940,9 @@ impl OpenCADStudio { } } - // Web: the clipboard-image fallback of a text-less paste either + // The clipboard-image fallback of a text-less paste either // attaches the image or falls back to the same error the text // path reports when nothing readable was on the clipboard. - #[cfg(target_arch = "wasm32")] Message::SystemClipboardPasteImage(image) => match image { Some((name, bytes)) => self.paste_clipboard_image(name, bytes), None => { diff --git a/src/sys.rs b/src/sys.rs index 0bbcfb82d..597c13eb2 100644 --- a/src/sys.rs +++ b/src/sys.rs @@ -440,7 +440,6 @@ fn pasted_image_name(mime_type: &str) -> String { /// the candidate already names a registered source. The taken-check is /// injectable so this — the part worth locking with tests — runs natively; /// wasm passes [`crate::scene::model::image_model::has_source`]. -#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // tested everywhere, used on wasm pub(crate) fn unique_pasted_image_name( base: &str, ext: &str,