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 <noreply@anthropic.com>
This commit is contained in:
BFlat
2026-10-10 04:30:32 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent bf3a45d938
commit 5a66859ec6
5 changed files with 77 additions and 40 deletions
+2 -1
View File
@@ -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.
+9
View File
@@ -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<u8>)> {
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);
}
+10
View File
@@ -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<u8>)> {
None
}
fn open_url(&mut self, _url: &str) {}
/// Recently opened files (persisted by the app).
fn recent_files(&self) -> Vec<String> {
@@ -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<String>,
}
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,
}
}
}
+49 -25
View File
@@ -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("<svg") || text.trim_start().starts_with("<?xml") {
use base64::Engine as _;
let b64 = base64::engine::general_purpose::STANDARD.encode(text.as_bytes());
app.run("file.import", json!({"data": b64, "name": "pasted.svg", "x": cx, "y": cy}));
} else {
app.run("text.create", json!({"text": text, "x": cx, "y": cy}));
}
return;
}
}
app.run("edit.paste", json!({}));
}
/// The system clipboard's image as PNG bytes, with its size.
fn clipboard_png(app: &mut FigApp) -> Option<(Vec<u8>, 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("<svg") || text.trim_start().starts_with("<?xml") {
use base64::Engine as _;
let b64 = base64::engine::general_purpose::STANDARD.encode(text.as_bytes());
let (cx, cy) = app.session.ds().map(|d| (d.view.cx, d.view.cy)).unwrap_or((0.0, 0.0));
app.run("file.import", json!({"data": b64, "name": "pasted.svg", "x": cx, "y": cy}));
} else if !text.is_empty() {
let (cx, cy) = app.session.ds().map(|d| (d.view.cx, d.view.cy)).unwrap_or((0.0, 0.0));
app.run("text.create", json!({"text": text, "x": cx, "y": cy}));
}
return;
}
app.run(id, json!({}));
// Mirror to the system clipboard (SVG text, else a marker) and remember it, so a later
// paste knows whether the clipboard still holds our layers.
let text = svg.unwrap_or_else(|| format!("figcraft://clipboard/{}", ids.len()));
app.services.clipboard_set_text(&text);
app.ui.last_copy = Some(text);
}
"edit.paste" => 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");
}
+7 -14
View File
@@ -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("<svg") {
use base64::Engine as _;
let b64 = base64::engine::general_purpose::STANDARD.encode(text.as_bytes());
app.run("file.import", json!({"data": b64, "name": "pasted.svg", "x": cx, "y": cy}));
} else {
app.run("text.create", json!({"text": text, "x": cx, "y": cy}));
}
} else {
crate::menus::invoke(app, "edit.paste");
}
}
Event::Paste(text) => crate::menus::paste(app, Some(text)),
Event::Key { key, pressed: true, modifiers, repeat, .. } if key_event(app, *key, *modifiers, *repeat, ctx) => {
// handled
}