From 5a66859ec6dfe2426d288fed1c0fa984cb90fae6 Mon Sep 17 00:00:00 2001 From: BFlat Date: Sat, 10 Oct 2026 04:30:32 +0700 Subject: [PATCH] M6: paste images from other apps; the newest clipboard content wins (#26) - Cmd/Ctrl+V pastes an image from the system clipboard (screenshots, images copied in other apps) as an image layer centred in the view; an image-only clipboard (no paste event) is handled from the key. - FigCraft remembers what it last put on the system clipboard (SVG of the copied layers, or a marker): when the clipboard still holds it, paste uses our copied layers; anything newer from another app (image, SVG, text) is pasted instead. Cut now also mirrors to the system clipboard. - Desktop services read clipboard images (size-capped); the browser falls back to text. Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 3 +- apps/figcraft/src/services.rs | 9 ++++ crates/ui-egui/src/lib.rs | 10 +++++ crates/ui-egui/src/menus.rs | 74 ++++++++++++++++++++++----------- crates/ui-egui/src/shortcuts.rs | 21 ++++------ 5 files changed, 77 insertions(+), 40 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 74dc01f7..af9f6d34 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -72,7 +72,8 @@ dimension measured side by side. / CLI: pages, layer tree, geometry, paints, strokes, effects, text runs, vectors from geometry paths, auto layout, constraints, grids, components, sets, instances with overrides, component properties, styles; image fills from an `images` folder or placeholders; warnings for the rest). - *Left:* .fig import (best effort), PDF export (in progress), paste from other apps, REST import + Paste from other apps: images, SVG and text (the newest clipboard content wins over our own + copy). *Left:* .fig import (best effort), PDF export (in progress), REST import of prototype interactions and variables. 20–32 h. 7. **Look & feel and interaction-fidelity pass**: tool by tool, panel by panel against the incumbent (cursors, modifier keys, hover states, menus, popovers). 40–60 h. diff --git a/apps/figcraft/src/services.rs b/apps/figcraft/src/services.rs index 7d33906e..1a2cc2b0 100644 --- a/apps/figcraft/src/services.rs +++ b/apps/figcraft/src/services.rs @@ -87,6 +87,15 @@ impl Services for Desktop { let _ = c.set_image(arboard::ImageData { width: w as usize, height: h as usize, bytes: std::borrow::Cow::Borrowed(rgba) }); } } + fn clipboard_get_image(&mut self) -> Option<(u32, u32, Vec)> { + let img = self.clipboard.as_mut()?.get_image().ok()?; + let (w, h) = (u32::try_from(img.width).ok()?, u32::try_from(img.height).ok()?); + // Refuse absurd sizes (and mismatched buffers) rather than allocate. + if w == 0 || h == 0 || u64::from(w) * u64::from(h) > 100_000_000 || img.bytes.len() != (w as usize) * (h as usize) * 4 { + return None; + } + Some((w, h, img.bytes.into_owned())) + } fn open_url(&mut self, url: &str) { let _ = webbrowser::open(url); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 90b704cb..516c2906 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -59,6 +59,10 @@ pub trait Services { None } fn clipboard_set_png(&mut self, _w: u32, _h: u32, _rgba: &[u8]) {} + /// An image on the system clipboard as (width, height, straight RGBA). + fn clipboard_get_image(&mut self) -> Option<(u32, u32, Vec)> { + None + } fn open_url(&mut self, _url: &str) {} /// Recently opened files (persisted by the app). fn recent_files(&self) -> Vec { @@ -124,6 +128,10 @@ pub struct UiState { pub left_collapsed: bool, pub comments: comments::CommentUi, pub versions: versions::VersionUi, + + /// What FigCraft last put on the system clipboard: when the clipboard still holds it, a paste + /// uses our copied layers; anything else there (an image, SVG, text) came from another app. + pub last_copy: Option, } impl Default for UiState { @@ -165,6 +173,8 @@ impl Default for UiState { left_collapsed: false, comments: comments::CommentUi::default(), versions: versions::VersionUi::default(), + + last_copy: None, } } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 9dba90bc..a63c339b 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -27,6 +27,46 @@ pub const UI_COMMANDS: &[&str] = &[ ]; /// Run a menu item: some open dialogs or use platform services, the rest go to the engine. +/// Paste: the system clipboard wins when it holds something newer than our own copy (an image, SVG +/// or text from another app); otherwise our copied layers. `event_text` is the text the platform +/// handed over with the paste event (the browser has no other way to read it). +pub fn paste(app: &mut FigApp, event_text: Option<&str>) { + let sys_text = app.services.clipboard_get_text().or_else(|| event_text.map(str::to_string)).filter(|t| !t.is_empty()); + let ours = sys_text.is_some() && sys_text == app.ui.last_copy; + let (cx, cy) = app.session.ds().map(|d| (d.view.cx, d.view.cy)).unwrap_or((0.0, 0.0)); + if !ours { + if let Some((png, w, h)) = clipboard_png(app) { + use base64::Engine as _; + let b64 = base64::engine::general_purpose::STANDARD.encode(png); + // Centred in the view. + let (x, y) = ((cx - f64::from(w) / 2.0).round(), (cy - f64::from(h) / 2.0).round()); + app.run("file.import", json!({"data": b64, "name": "Pasted image.png", "x": x, "y": y})); + return; + } + // Text from elsewhere when it's newer than our copy, or when we have nothing to paste. + if let Some(text) = sys_text.filter(|_| app.ui.last_copy.is_some() || app.session.clipboard.nodes.is_empty()) { + if text.trim_start().starts_with(" Option<(Vec, u32, u32)> { + let (w, h, rgba) = app.services.clipboard_get_image()?; + let img = image::RgbaImage::from_raw(w, h, rgba)?; + let mut out = std::io::Cursor::new(Vec::new()); + img.write_to(&mut out, image::ImageFormat::Png).ok()?; + Some((out.into_inner(), w, h)) +} + pub fn invoke(app: &mut FigApp, id: &str) { match id { "file.open" => app.open_dialog(), @@ -78,33 +118,17 @@ pub fn invoke(app: &mut FigApp, id: &str) { } "edit.findReplace" => app.ui.find = Some((String::new(), String::new())), "edit.copy" | "edit.cut" => { + let ids = app.session.selection(); + // SVG for other apps, taken before a cut removes the layers. + let svg = app.session.doc().ok().and_then(|d| figcraft_svg_export(d, &ids)); app.run(id, json!({})); - // Mirror to the system clipboard as SVG text so other apps can paste. - let ids: Vec<_> = app.session.clipboard.nodes.iter().map(|(r, _)| *r).collect(); - if id == "edit.copy" - && let Some(svg) = app.session.doc().ok().and_then(|d| figcraft_svg_export(d, &ids)) - { - app.services.clipboard_set_text(&svg); - } - } - "edit.paste" => { - // Paste SVG / text from the system clipboard when our clipboard is empty. - if app.session.clipboard.nodes.is_empty() - && let Some(text) = app.services.clipboard_get_text() - { - if text.trim_start().starts_with(" paste(app, None), "file.close" if app.session.ds().map(|d| d.is_dirty()).unwrap_or(false) => { app.toast("Unsaved changes: save first (⌘S), or close the tab with ⌥-click to discard"); } diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 3aa081df..c9d82aa4 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -59,24 +59,17 @@ pub fn handle(app: &mut FigApp, ctx: &Context) { text_keys(app, ctx, &events); return; } + // An image-only clipboard brings no paste event, just the key: paste from the key then. + let paste_event = events.iter().any(|e| matches!(e, Event::Paste(_))); + let paste_key = events.iter().any(|e| matches!(e, Event::Key { key: Key::V, pressed: true, repeat: false, modifiers, .. } if modifiers.command && !modifiers.shift && !modifiers.alt)); + if paste_key && !paste_event { + crate::menus::paste(app, None); + } for ev in &events { match ev { Event::Copy => crate::menus::invoke(app, "edit.copy"), Event::Cut => crate::menus::invoke(app, "edit.cut"), - Event::Paste(text) => { - if app.session.clipboard.nodes.is_empty() && !text.is_empty() { - let (cx, cy) = app.session.ds().map(|d| (d.view.cx, d.view.cy)).unwrap_or((0.0, 0.0)); - if text.trim_start().starts_with(" crate::menus::paste(app, Some(text)), Event::Key { key, pressed: true, modifiers, repeat, .. } if key_event(app, *key, *modifiers, *repeat, ctx) => { // handled }