mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-24 10:27:00 -06:00
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:
+2
-1
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user