mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-10 15:53:01 +00:00
With the Artboard tool chosen, Copy and Cut take its artboard together with
the art fully inside it (when Move/Copy Artwork with Artboard is on), and
Paste brings both back in one undo step: Paste right of the last artboard,
Paste in Place / in Front / in Back where it was, into this or another
document, the art going back into the layers it came from (Paste on All
Artboards pastes the art alone). The clipboard carries the artboard
(Clipboard::artboard); a pasted artboard keeps its name when it is free.
Duplicate Artboards (the Artboards panel menu, and now a row dragged onto
New Artboard) copies the art too: artboard.duplicate {index?, art?} defaults
to the Artboard tool's artboard and moveArt option and reports the copies.
It shares copy_artboard with the Alt-drag copy (artboard.move {copy}).
New commands reachable over MCP: artboard.copy and artboard.cut
{index?, art?}; edit.copy / edit.cut run them while the Artboard tool is
chosen (and are enabled then with nothing selected). Docs (mcp.md), the
tool and panel docs, translations of the new labels and the ROADMAP follow.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
273 lines
13 KiB
Rust
273 lines
13 KiB
Rust
//! The internal clipboard and system clipboard interchange.
|
|
//!
|
|
//! Copy keeps the objects together with the document resources they use ([`Clipboard`]), so
|
|
//! pasting into any document brings those along (see `resources`).
|
|
//!
|
|
//! System clipboard: 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 VectorCraft the internal clipboard stays
|
|
//! lossless (live effects, masks, symbols); SVG is the outside format, with PNG, PDF and plain
|
|
//! text besides it (see `flavours`).
|
|
|
|
mod flavours;
|
|
mod resources;
|
|
|
|
use std::collections::BTreeMap;
|
|
use std::sync::Arc;
|
|
|
|
use serde_json::{Value, json};
|
|
use vectorcraft_brush::Brush;
|
|
use vectorcraft_color::Swatch;
|
|
use vectorcraft_doc::{Document, GraphicStyle, ImageBlob, Node, NodeId, PatternDef, Symbol, TextStyleDef};
|
|
use vectorcraft_geom::Rect;
|
|
|
|
pub use flavours::{BITMAP, EMF, FILE_HEAD, Flavour, PASTE_ORDER, PDF, PNG, SVG, TEXT, file_flavour};
|
|
pub(crate) use resources::SwatchChoices;
|
|
|
|
use super::*;
|
|
use crate::DocState;
|
|
|
|
pub fn specs() -> Vec<CommandSpec> {
|
|
let mut v = 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 | dataBase64 (SVG or SVGZ bytes), center?: [x, y]} replace the clipboard with the SVG's objects (and the images, patterns and symbols they use), centred on `center` (default: the first artboard) → {count}; then run edit.pasteInPlace",
|
|
has_doc,
|
|
import_svg
|
|
),
|
|
cmd!(
|
|
query "clipboard.conflicts",
|
|
"Swatch Conflicts",
|
|
[],
|
|
None,
|
|
"{} → {swatches: [{name, document, clipboard, spot}]} the global or spot swatches the copied objects use whose name the active document gives another colour (`document`/`clipboard`: #rrggbb): Paste asks Merge or Add for each (pass the answer as edit.paste* {swatchConflict}). Pasting back into the document the objects came from raises none",
|
|
has_doc,
|
|
conflicts
|
|
),
|
|
];
|
|
v.extend(flavours::specs());
|
|
v
|
|
}
|
|
|
|
/// 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")
|
|
}
|
|
|
|
/// What Copy puts on the internal clipboard: the objects and every document resource they use.
|
|
#[derive(Clone, Debug, Default, PartialEq)]
|
|
pub struct Clipboard {
|
|
/// The copied objects in paint order (document coordinates).
|
|
pub nodes: Vec<Node>,
|
|
/// Blobs of the images the objects show, by key.
|
|
pub images: BTreeMap<String, ImageBlob>,
|
|
/// Symbols the objects place (also inside other symbols and patterns).
|
|
pub symbols: Vec<Symbol>,
|
|
/// Patterns the objects paint with (pasting makes their swatches again).
|
|
pub patterns: Vec<PatternDef>,
|
|
/// Global and spot swatches the objects' colours link to, the tint swatches of the tints they
|
|
/// use and the gradient swatches their gradients came from. Never the built-in [Registration]
|
|
/// swatch, which every document has.
|
|
pub swatches: Vec<Swatch>,
|
|
/// Graphic styles the objects are linked to.
|
|
pub graphic_styles: Vec<GraphicStyle>,
|
|
pub char_styles: Vec<TextStyleDef>,
|
|
pub para_styles: Vec<TextStyleDef>,
|
|
/// Brushes the objects' strokes use.
|
|
pub brushes: Vec<Brush>,
|
|
/// The artboard the objects were copied from: Paste on All Artboards keeps their offset to it.
|
|
pub source_artboard: Option<Rect>,
|
|
/// Per object: the name of the layer it was copied from (Paste Remembers Layers).
|
|
pub source_layers: Vec<Option<String>>,
|
|
/// The open document the objects came from ([`DocState::uid`]): pasting back into it uses its
|
|
/// own resources as they are now.
|
|
pub source_doc: Option<u64>,
|
|
/// An artboard copied with the Artboard tool (`artboard.copy`); `nodes` are then its art. Pasting
|
|
/// adds a copy of it with the art.
|
|
pub artboard: Option<vectorcraft_doc::Artboard>,
|
|
}
|
|
|
|
impl Clipboard {
|
|
pub fn is_empty(&self) -> bool {
|
|
self.nodes.is_empty() && self.artboard.is_none()
|
|
}
|
|
|
|
/// Objects `roots` of `st`'s document with their resources, source artboard and layers.
|
|
pub(crate) fn copy(st: &DocState, roots: &[NodeId]) -> Self {
|
|
let d = &*st.doc;
|
|
let mut nodes = vec![];
|
|
let mut source_layers = vec![];
|
|
for id in roots {
|
|
let (Some(n), Some(anc)) = (d.node(*id), d.ancestry(*id)) else { continue };
|
|
nodes.push(n.clone());
|
|
// The nearest enclosing layer (or sublayer).
|
|
source_layers.push(anc.iter().rev().skip(1).find_map(|a| d.node(*a).filter(|l| l.is_layer())).and_then(|l| l.name.clone()));
|
|
}
|
|
let bounds = nodes.iter().filter_map(Node::visual_bounds).reduce(|a, b| a.union(b));
|
|
let board = bounds.and_then(|b| d.artboard_at(b.center())).unwrap_or(0);
|
|
Self { source_artboard: d.artboards.get(board).map(|a| a.rect), source_layers, source_doc: Some(st.uid), ..Self::gather(d, nodes) }
|
|
}
|
|
|
|
/// Every object of `src` (each top-level layer's contents) with its resources, for Place and
|
|
/// pasting outside SVG. Their layers don't count for Paste Remembers Layers.
|
|
pub fn from_document(src: &Document) -> Self {
|
|
let nodes = src.layers.iter().flat_map(|l| l.children().into_iter().flatten()).map(|n| (**n).clone()).collect();
|
|
Self::gather(src, nodes)
|
|
}
|
|
|
|
/// Union of the objects' visual bounds.
|
|
pub fn bounds(&self) -> Option<Rect> {
|
|
self.nodes.iter().filter_map(Node::visual_bounds).reduce(|a, b| a.union(b))
|
|
}
|
|
|
|
/// The swatch name conflicts pasting into `st` raises, as (pasted, document) swatches: none
|
|
/// back in the document the objects came from.
|
|
pub fn swatch_conflicts<'a>(&'a self, st: &'a DocState) -> Vec<(&'a Swatch, &'a Swatch)> {
|
|
if self.source_doc == Some(st.uid) {
|
|
return vec![];
|
|
}
|
|
self.conflicts(&st.doc, &self.used())
|
|
}
|
|
|
|
/// A standalone document holding the objects and their resources (SVG export).
|
|
pub fn to_document(&self) -> Document {
|
|
let mut d = Document::new(100.0, 100.0);
|
|
d.images = self.images.clone();
|
|
d.symbols = self.symbols.clone();
|
|
d.patterns = self.patterns.clone();
|
|
d.swatches.extend(self.swatches.iter().cloned());
|
|
d.graphic_styles.extend(self.graphic_styles.iter().cloned());
|
|
d.char_styles = self.char_styles.clone();
|
|
d.para_styles = self.para_styles.clone();
|
|
if !self.brushes.is_empty() {
|
|
let mut lib = vectorcraft_brush::library(&d);
|
|
for b in &self.brushes {
|
|
match lib.iter_mut().find(|x| x.name == b.name) {
|
|
Some(x) => *x = b.clone(),
|
|
None => lib.push(b.clone()),
|
|
}
|
|
}
|
|
vectorcraft_brush::store(&mut d, &lib);
|
|
}
|
|
let mut layer = Node::layer(NodeId(u64::MAX), "Clipboard", vectorcraft_doc::LayerColor::Preset(0));
|
|
if let Some(ch) = layer.children_mut() {
|
|
*ch = self.nodes.iter().cloned().map(Arc::new).collect();
|
|
}
|
|
d.layers = vec![Arc::new(layer)];
|
|
d
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
let d = self.clipboard.to_document();
|
|
Some(vectorcraft_svg::export(
|
|
&d,
|
|
&vectorcraft_svg::ExportOptions { artboard: None, object_ids: vectorcraft_svg::ObjectIds::Minimal, ..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> {
|
|
const C: &str = "clipboard.importSvg";
|
|
let svg = match (str_param(p, "svg"), str_param(p, "dataBase64")) {
|
|
(Some(svg), _) => std::borrow::Cow::Borrowed(svg),
|
|
(None, Some(b64)) => {
|
|
let bytes = vectorcraft_format::base64_decode(b64).ok_or_else(|| bad(C, "bad base64"))?;
|
|
vectorcraft_svg::text_of(&bytes).map_err(|e| bad(C, e.to_string()))?.into_owned().into()
|
|
}
|
|
(None, None) => return Err(bad(C, "give svg, or dataBase64 of an SVG or SVGZ file")),
|
|
};
|
|
let (src, _) = super::fileio::import_svg(&svg, None).map_err(|e| bad(C, e.to_string()))?;
|
|
let clip = Clipboard::from_document(&src);
|
|
if clip.is_empty() {
|
|
return Err(bad(C, "the SVG has no drawable objects"));
|
|
}
|
|
Ok(json!({ "count": s.load_clipboard(clip, p)? }))
|
|
}
|
|
|
|
fn conflicts(s: &mut Session, _: &Value) -> Result<Value> {
|
|
let hex = |w: &Swatch| w.paint.color().map(|c| c.to_hex());
|
|
let list: Vec<Value> = s
|
|
.clipboard
|
|
.swatch_conflicts(s.doc()?)
|
|
.into_iter()
|
|
.map(|(c, t)| json!({"name": c.name, "document": hex(t), "clipboard": hex(c), "spot": c.spot}))
|
|
.collect();
|
|
Ok(json!({ "swatches": list }))
|
|
}
|
|
|
|
#[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 VectorCraft 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 paste_never_lands_in_an_object_that_reused_an_undone_layers_id() {
|
|
// Found by the model-based test: undo restores the id counter, so a compound path made
|
|
// after undoing New Layer gets the dead layer's id, which was still the active layer.
|
|
let mut s = session();
|
|
let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone();
|
|
s.execute("select.set", &json!({"ids": [r]})).unwrap();
|
|
s.execute("edit.copy", &json!({})).unwrap();
|
|
let layer = s.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap();
|
|
s.execute("edit.undo", &json!({})).unwrap();
|
|
s.execute("select.set", &json!({"ids": [r]})).unwrap();
|
|
let c = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap();
|
|
assert_eq!(c, layer, "the scenario needs the id to be reused");
|
|
s.execute("edit.paste", &json!({})).unwrap();
|
|
let d = &s.doc().unwrap().doc;
|
|
let compound = d.node(NodeId(c)).unwrap();
|
|
assert!(compound.children().unwrap().iter().all(|ch| matches!(ch.kind, vectorcraft_doc::NodeKind::Path { .. })));
|
|
}
|
|
|
|
#[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());
|
|
}
|
|
}
|