mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
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:
1 parent
f855da4fb8
commit
318a55350a
10 files changed
+237
-2
No files matched your search
Generated
+6
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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 }
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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());
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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, ¶ms) {
|
||||
return r;
|
||||
}
|
||||
if id.starts_with("edit.paste") {
|
||||
self.adopt_system_clipboard();
|
||||
}
|
||||
let r = self.session.execute(id, ¶ms).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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user