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 <noreply@anthropic.com>
This commit is contained in:
Hakan SevenandClaude Opus 5.5 committed 2026-10-13 22:08:14 +03:00
1 parent d4b4fc37e1
commit 11f68a1185
4 files changed
+62 -33

No files matched your search

+3 -5
View File
@@ -2389,11 +2389,9 @@ pub enum Message {
PasteSpecial(Option<String>),
/// 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<u8>)>),
/// Ctrl/Cmd+A — select all layer rows when the Layer Manager is open, or all
/// drawing objects otherwise (#236).
+52 -5
View File
@@ -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<Message> {
#[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<u8>) -> Task<Message> {
let i = self.active_tab;
if self.tabs[i].is_start {
+7 -22
View File
@@ -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 => {
-1
View File
@@ -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,