M4.8: system clipboard — copy/cut publish SVG markup, paste adopts SVG from other apps (centred in view); Copy/Cut/Paste events handled off macOS; Place bypasses the clipboard hook

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-01 08:27:54 -04:00
1 parent f855da4fb8
commit 318a55350a
10 files changed
+237 -2

No files matched your search

Generated
+6
View File
@@ -827,6 +827,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
name = "drawcraft"
version = "0.1.0"
dependencies = [
"arboard",
"drawcraft-engine",
"drawcraft-ui-egui",
"eframe",
@@ -853,9 +854,14 @@ dependencies = [
name = "drawcraft-cli"
version = "0.1.0"
dependencies = [
"drawcraft-color",
"drawcraft-doc",
"drawcraft-engine",
"drawcraft-format",
"drawcraft-geom",
"drawcraft-mcp",
"drawcraft-render",
"drawcraft-svg",
"drawcraft-testkit",
"serde_json",
]
+1
View File
@@ -54,6 +54,7 @@ egui_extras = { version = "0.36", default-features = false, features = ["svg"] }
eframe = { version = "0.36", default-features = false }
egui_kittest = "0.36"
rfd = "0.17"
arboard = { version = "3.6", default-features = false }
[workspace.lints.rust]
unsafe_code = "deny"
+1 -1
View File
@@ -57,7 +57,7 @@ _Last updated: 2026-10-01._
| M1 | Selection, transform, layers, MCP | ✅ mostly done (rotated persistent bbox pending) | 3–5 |
| M2 | Drawing tools + smart guides | ✅ mostly done (Shaper, Pen modifier nuances) | 5–10 |
| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | 🟡 panels done; opacity masks done (make/release, clip, invert, disable, link; render + SVG `<mask>` in/out + PDF soft mask); mask-editing mode, freeform gradients pending | 6–10 |
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | 🟡 Export for Screens (PNG/JPG/WebP/SVG/PDF × scales) done; headless CLI/MCP export every format; system clipboard SVG/PNG interop, EPS/DXF pending | 8–12 |
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | 🟡 Export for Screens (PNG/JPG/WebP/SVG/PDF × scales) done; headless CLI/MCP export every format; system clipboard: copy puts SVG markup on it, paste takes SVG from other apps (Ctrl/Cmd+C/X/V now also work off macOS); PNG/PDF clipboard flavours, EPS/DXF pending | 8–12 |
| M5 | Performance | 🟡 background render + caches + MT done; `drawcraft-cli bench`; raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 10–20 |
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Shape Builder edge erase, large-offset bug open) | 3–6 |
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 in progress | 20–30 |
+1
View File
@@ -19,6 +19,7 @@ log = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rfd = { workspace = true }
arboard = { workspace = true }
[target.'cfg(target_os = "macos")'.dependencies]
muda = { version = "0.21", default-features = false }
+2
View File
@@ -96,6 +96,8 @@ fn services() -> Services {
pick_save: Some(Box::new(|name: &str| rfd::FileDialog::new().set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string()))),
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))),
// Menu-bar Paste never sees egui's Paste event, so read the clipboard directly.
clipboard_read: Some(Box::new(|| arboard::Clipboard::new().ok()?.get_text().ok())),
..Default::default()
}
}
+128
View File
@@ -0,0 +1,128 @@
//! System clipboard interchange: the copied objects as SVG markup (what other apps paste), and
//! SVG markup from other apps turned into clipboard objects (pasted with the Paste commands).
//!
//! The UI owns the platform clipboard; these commands only convert. Within DrawCraft the
//! internal clipboard stays lossless (live effects, masks, symbols); SVG is the outside format.
use std::sync::Arc;
use drawcraft_doc::{Document, Node, NodeId};
use drawcraft_geom::{Affine, Point};
use serde_json::{Value, json};
use super::*;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(query "clipboard.exportSvg", "Clipboard as SVG", [], None, "{} → {svg} the copied objects as standalone SVG (null when the clipboard is empty)", always, export_svg),
cmd!(
query "clipboard.importSvg",
"Load SVG into Clipboard",
[],
None,
"{svg, center?: [x, y]} replace the clipboard with the SVG's objects, centred on `center` (default: the first artboard) → {count}; then run edit.pasteInPlace",
has_doc,
import_svg
),
]
}
/// Is `text` SVG markup (as other apps put on the clipboard)?
pub fn looks_like_svg(text: &str) -> bool {
let t = text.trim_start_matches('\u{feff}').trim_start();
(t.starts_with("<svg") || t.starts_with("<?xml") || t.starts_with("<!--") || t.starts_with("<!DOCTYPE svg")) && t.contains("<svg")
}
impl Session {
/// The internal clipboard as standalone SVG (`None` when it is empty).
pub fn clipboard_svg(&self) -> Option<String> {
if self.clipboard.is_empty() {
return None;
}
// The active document supplies swatches, symbols, patterns and image blobs.
let mut d = self.active().map(|st| (*st.doc).clone()).unwrap_or_else(|| Document::new(100.0, 100.0));
let mut layer = Node::layer(NodeId(u64::MAX), "Clipboard", drawcraft_doc::LayerColor::Preset(0));
if let Some(ch) = layer.children_mut() {
*ch = self.clipboard.iter().cloned().map(Arc::new).collect();
}
d.layers = vec![Arc::new(layer)];
Some(drawcraft_svg::export(&d, &drawcraft_svg::ExportOptions { artboard: None, object_ids: false, ..Default::default() }))
}
}
fn export_svg(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!({ "svg": s.clipboard_svg() }))
}
fn import_svg(s: &mut Session, p: &Value) -> Result<Value> {
let svg = str_param(p, "svg").ok_or_else(|| bad("clipboard.importSvg", "missing svg"))?;
let src = drawcraft_svg::import(svg).map_err(|e| bad("clipboard.importSvg", e.to_string()))?;
let mut nodes: Vec<Node> = src.layers.iter().flat_map(|l| l.children().cloned().unwrap_or_default()).map(|n| (*n).clone()).collect();
if nodes.is_empty() {
return Err(bad("clipboard.importSvg", "the SVG has no drawable objects"));
}
let center = match p.get("center").and_then(Value::as_array) {
Some(c) if c.len() == 2 => Point::new(c[0].as_f64().unwrap_or(0.0), c[1].as_f64().unwrap_or(0.0)),
_ => s.doc()?.doc.artboards.first().map(|a| a.rect.center()).unwrap_or_default(),
};
let bounds = nodes.iter().filter_map(|n| n.visual_bounds()).reduce(|a, b| a.union(b));
if let Some(b) = bounds {
let xf = Affine::translate(center - b.center());
for n in &mut nodes {
n.transform(xf, false);
}
}
// Embedded images: their blobs join the document (keys are content hashes).
if !src.images.is_empty() {
let st = s.doc_mut()?;
let d = Arc::make_mut(&mut st.doc);
for (k, b) in &src.images {
d.images.entry(k.clone()).or_insert_with(|| b.clone());
}
}
let count = nodes.len();
s.clipboard = nodes;
Ok(json!({ "count": count }))
}
#[cfg(test)]
mod tests {
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
s
}
#[test]
fn copy_exports_svg_that_round_trips_through_paste() {
let mut s = session();
let id = s.execute("shape.rectangle", &json!({"x": 10, "y": 20, "width": 30, "height": 40})).unwrap()["id"].as_u64().unwrap();
s.execute("paint.setFill", &json!({"color": "#12ab34"})).unwrap();
s.execute("select.set", &json!({"ids": [id]})).unwrap();
assert!(s.execute("clipboard.exportSvg", &json!({})).unwrap()["svg"].is_null() || !s.clipboard.is_empty());
s.execute("edit.copy", &json!({})).unwrap();
let svg = s.execute("clipboard.exportSvg", &json!({})).unwrap()["svg"].as_str().unwrap().to_string();
assert!(looks_like_svg(&svg) && svg.contains("#12ab34"), "{svg}");
// As if pasted into another DrawCraft window.
let mut t = session();
assert_eq!(t.execute("clipboard.importSvg", &json!({"svg": svg, "center": [200, 150]})).unwrap()["count"], 1);
t.execute("edit.pasteInPlace", &json!({})).unwrap();
let st = t.doc().unwrap();
let b = st.doc.bounds_of(&st.selection.in_paint_order(&st.doc), false).unwrap();
assert!((b.center().x - 200.0).abs() < 1e-6 && (b.center().y - 150.0).abs() < 1e-6, "{b:?}");
assert!((b.width() - 30.0).abs() < 1e-6 && (b.height() - 40.0).abs() < 1e-6);
}
#[test]
fn foreign_svg_and_non_svg_text() {
let mut s = session();
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><circle cx="5" cy="5" r="5" fill="red"/><rect width="2" height="2"/></svg>"##;
assert!(looks_like_svg(svg) && looks_like_svg(&format!("<?xml version=\"1.0\"?>\n{svg}")));
assert!(!looks_like_svg("hello <svg> world") && !looks_like_svg("plain text"));
assert_eq!(s.execute("clipboard.importSvg", &json!({"svg": svg})).unwrap()["count"], 2);
assert!(s.execute("clipboard.importSvg", &json!({"svg": "<svg xmlns=\"http://www.w3.org/2000/svg\"/>"})).is_err());
assert!(s.execute("clipboard.importSvg", &json!({"svg": "not svg"})).is_err());
}
}
+2
View File
@@ -2,6 +2,7 @@
mod brushsym;
mod buildcmds;
pub mod clipboard;
mod colorcmds;
pub mod colormgmt;
mod create;
@@ -126,6 +127,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
SPECS.get_or_init(|| {
let mut v = Vec::new();
v.extend(edit::specs());
v.extend(clipboard::specs());
v.extend(recolor::specs());
v.extend(file::specs());
v.extend(create::specs());
+3 -1
View File
@@ -206,7 +206,9 @@ pub fn place_bytes(app: &mut DrawcraftApp, name: &str, bytes: &[u8]) -> Result<(
let src = drawcraft_svg::import(s).map_err(|e| e.to_string())?;
let nodes: Vec<Node> = src.layers.iter().flat_map(|l| l.children().cloned().unwrap_or_default()).map(|n| (*n).clone()).collect();
app.session.clipboard = nodes;
app.run("edit.pasteInPlace", serde_json::json!({}))?;
// Straight to the engine: `app.run` would let the system clipboard replace these nodes.
app.session.execute("edit.pasteInPlace", &serde_json::json!({})).map_err(|e| e.to_string())?;
app.sync_views();
return Ok(());
}
let (w, h) = image_size(bytes)?;
+36
View File
@@ -58,6 +58,9 @@ pub struct Services {
pub download: Option<DownloadFn>,
/// Web: start an async open (bytes arrive via `inbox`).
pub open_async: Option<Box<dyn FnMut()>>,
/// Read the system clipboard's text (desktop). Without it, pasted text only arrives with
/// egui's Paste event (web, and keyboard paste everywhere).
pub clipboard_read: Option<Box<dyn FnMut() -> Option<String>>>,
}
/// Cached canvas raster.
@@ -122,6 +125,11 @@ pub struct DrawcraftApp {
pub canvas_rect: Option<egui::Rect>,
/// Hover position in document coordinates.
pub hover_doc: Option<drawcraft_geom::Point>,
/// System clipboard: SVG to publish next frame, the last SVG we published (so pasting it back
/// uses the lossless internal clipboard) and text that arrived with a Paste event.
clipboard_out: Option<String>,
clipboard_published: Option<String>,
pub(crate) clipboard_in: Option<String>,
}
impl DrawcraftApp {
@@ -145,6 +153,9 @@ impl DrawcraftApp {
native_menu: false,
last_effect: None,
native_shortcuts: Default::default(),
clipboard_out: None,
clipboard_published: None,
clipboard_in: None,
control_rx: None,
pending_screenshots: vec![],
queued_screenshots: vec![],
@@ -197,7 +208,14 @@ impl DrawcraftApp {
if let Some(r) = menus::run_ui_command(self, id, &params) {
return r;
}
if id.starts_with("edit.paste") {
self.adopt_system_clipboard();
}
let r = self.session.execute(id, &params).map_err(|e| e.to_string());
if r.is_ok() && matches!(id, "edit.copy" | "edit.cut") && self.session.prefs.copy_as_svg {
self.clipboard_out = self.session.clipboard_svg();
self.clipboard_published = self.clipboard_out.clone();
}
self.sync_views();
match &r {
Err(e) => self.ui.status = e.clone(),
@@ -210,6 +228,21 @@ impl DrawcraftApp {
r
}
/// Before a paste: SVG that another app put on the system clipboard replaces the internal
/// clipboard (centred in the view). Our own published SVG keeps the lossless internal copy.
fn adopt_system_clipboard(&mut self) {
let text = self.clipboard_in.take().or_else(|| self.services.clipboard_read.as_mut().and_then(|f| f()));
let Some(text) = text.filter(|t| drawcraft_engine::cmd::clipboard::looks_like_svg(t)) else { return };
if self.clipboard_published.as_deref() == Some(text.as_str()) {
return;
}
let center = self.view().map(|v| [v.center.x, v.center.y]);
match self.session.execute("clipboard.importSvg", &serde_json::json!({ "svg": text, "center": center })) {
Ok(_) => self.clipboard_published = Some(text),
Err(e) => self.ui.status = format!("Couldn't paste SVG: {e}"),
}
}
/// Select a tool (also used by the toolbar and shortcuts).
pub fn select_tool(&mut self, id: &str) {
let v = self.view_info();
@@ -354,6 +387,9 @@ impl DrawcraftApp {
}
self.collect_screenshots(ctx);
self.issue_screenshots(ctx);
if let Some(t) = self.clipboard_out.take() {
ctx.copy_text(t);
}
self.drain_inbox();
if self.fonts_ready {
shortcuts::handle(self, ctx);
+57
View File
@@ -115,6 +115,27 @@ pub fn handle(app: &mut DrawcraftApp, ctx: &egui::Context) {
}
return;
}
// Clipboard keys arrive as events, not key presses (except where the native menu has them).
let mut clip = vec![];
ctx.input_mut(|i| {
i.events.retain(|e| {
let (id, text) = match e {
egui::Event::Copy => ("edit.copy", None),
egui::Event::Cut => ("edit.cut", None),
egui::Event::Paste(t) => ("edit.paste", Some(t.clone())),
_ => return true,
};
if app.native_shortcuts.contains(id) {
return true;
}
clip.push((id, text));
false
})
});
for (id, text) in clip {
app.clipboard_in = text;
crate::menus::invoke(app, id, json!({}));
}
if busy {
for (k, tk) in [(Key::ArrowUp, ToolKey::Up), (Key::ArrowDown, ToolKey::Down)] {
if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, k)) {
@@ -186,6 +207,42 @@ pub fn handle(app: &mut DrawcraftApp, ctx: &egui::Context) {
mod tests {
use super::*;
/// One headless frame delivering `events` to the shortcut handler.
fn frame(app: &mut DrawcraftApp, events: Vec<egui::Event>) -> egui::FullOutput {
let ctx = egui::Context::default();
let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| {
handle(app, ui.ctx());
app.logic(ui.ctx());
});
out.textures_delta.clear();
out
}
#[test]
fn copy_and_paste_events_use_the_system_clipboard() {
let mut app = DrawcraftApp::new(drawcraft_engine::Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
let id = app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone();
app.session.execute("select.set", &json!({"ids": [id]})).unwrap();
// Copy publishes SVG to the system clipboard (egui's CopyText output command).
let out = frame(&mut app, vec![egui::Event::Copy]);
let copied = out.platform_output.commands.iter().find_map(|c| match c {
egui::OutputCommand::CopyText(t) => Some(t.clone()),
_ => None,
});
let svg = copied.expect("copy publishes SVG");
assert!(svg.contains("<svg"));
// Pasting our own SVG back uses the internal clipboard; foreign SVG replaces it.
frame(&mut app, vec![egui::Event::Paste(svg)]);
assert_eq!(app.session.doc().unwrap().doc.layers[0].children().unwrap().len(), 2);
let foreign = r##"<svg xmlns="http://www.w3.org/2000/svg"><circle cx="5" cy="5" r="5"/><circle cx="20" cy="5" r="5"/><circle cx="35" cy="5" r="5"/></svg>"##;
frame(&mut app, vec![egui::Event::Paste(foreign.into())]);
assert_eq!(app.session.doc().unwrap().doc.layers[0].children().unwrap().len(), 5);
// Plain text is not art: nothing is pasted from it (the internal clipboard is reused).
frame(&mut app, vec![egui::Event::Paste("hello".into())]);
assert_eq!(app.session.doc().unwrap().doc.layers[0].children().unwrap().len(), 8);
}
#[test]
fn parses() {
let s = parse("Cmd+Shift+]").unwrap();