mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
Merge pull request #76 from storytold/pr/m4-clipboard-resources
M4: paste carries resources with a Swatch Conflict dialog; paste placement and Paste Remembers Layers (M4.22, M4.23)
This commit is contained in:
21 files changed
+2033
-87
No files matched your search
@@ -472,6 +472,10 @@ pub struct Document {
|
||||
/// File → Document Setup (bleed, transparency grid, paper, type options).
|
||||
#[serde(default, skip_serializing_if = "skip::is_default")]
|
||||
pub setup: DocSetup,
|
||||
/// Layers panel → Paste Remembers Layers: pasted objects go back into the layers (by name)
|
||||
/// they were copied from instead of the current layer.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub paste_remembers_layers: bool,
|
||||
}
|
||||
|
||||
fn ppi72() -> f64 {
|
||||
@@ -528,6 +532,7 @@ impl Document {
|
||||
page_knockout: false,
|
||||
spot_use_lab: true,
|
||||
setup: DocSetup::default(),
|
||||
paste_remembers_layers: false,
|
||||
};
|
||||
let id = d.alloc_id();
|
||||
d.layers.push(Arc::new(Node::layer(id, "Layer 1", LayerColor::Preset(0))));
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
//! 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 internal clipboard and system clipboard interchange.
|
||||
//!
|
||||
//! 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.
|
||||
//! 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.
|
||||
|
||||
mod resources;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, Node, NodeId};
|
||||
use vectorcraft_geom::{Affine, Point};
|
||||
use vectorcraft_brush::Brush;
|
||||
use vectorcraft_color::Swatch;
|
||||
use vectorcraft_doc::{Document, GraphicStyle, ImageBlob, Node, NodeId, PatternDef, Symbol, TextStyleDef};
|
||||
use vectorcraft_geom::{Affine, Rect};
|
||||
|
||||
pub(crate) use resources::SwatchChoices;
|
||||
|
||||
use super::*;
|
||||
use crate::DocState;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -20,10 +32,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Load SVG into Clipboard",
|
||||
[],
|
||||
None,
|
||||
"{svg | dataBase64 (SVG or SVGZ bytes), center?: [x, y]} replace the clipboard with the SVG's objects, centred on `center` (default: the first artboard) → {count}; then run edit.pasteInPlace",
|
||||
"{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
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -33,19 +54,114 @@ pub fn looks_like_svg(text: &str) -> bool {
|
||||
(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>,
|
||||
}
|
||||
|
||||
impl Clipboard {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.nodes.is_empty()
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
// 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", vectorcraft_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)];
|
||||
let d = self.clipboard.to_document();
|
||||
Some(vectorcraft_svg::export(
|
||||
&d,
|
||||
&vectorcraft_svg::ExportOptions { artboard: None, object_ids: vectorcraft_svg::ObjectIds::Minimal, ..Default::default() },
|
||||
@@ -68,34 +184,36 @@ fn import_svg(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(None, None) => return Err(bad(C, "give svg, or dataBase64 of an SVG or SVGZ file")),
|
||||
};
|
||||
let src = vectorcraft_svg::import(&svg).map_err(|e| bad(C, 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 mut clip = Clipboard::from_document(&src);
|
||||
if clip.is_empty() {
|
||||
return Err(bad(C, "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 center = match point_param(p, "center") {
|
||||
Some(c) => c,
|
||||
None => 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 {
|
||||
if let Some(b) = clip.bounds() {
|
||||
let xf = Affine::translate(center - b.center());
|
||||
for n in &mut nodes {
|
||||
for n in &mut clip.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;
|
||||
let count = clip.nodes.len();
|
||||
s.clipboard = clip;
|
||||
Ok(json!({ "count": count }))
|
||||
}
|
||||
|
||||
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::*;
|
||||
|
||||
@@ -0,0 +1,691 @@
|
||||
//! The document resources copied objects use, and adding them to the document they're pasted into.
|
||||
//!
|
||||
//! Objects refer to resources by name (image blobs, symbols, patterns, swatches, character and
|
||||
//! paragraph styles, brushes) and to graphic styles by id. Copy follows those references, also
|
||||
//! through the symbols, patterns and styles it finds, and keeps what they lead to.
|
||||
//!
|
||||
//! Pasting matches each resource by name: an identical one in the document is used, a missing one
|
||||
//! is added, and a different one of the same name is added under a free name, which the pasted
|
||||
//! objects follow. Global and spot swatches are the exception: for a name the document gives
|
||||
//! another colour the caller picks Merge (the objects take the document's swatch) or Add (the
|
||||
//! pasted swatch comes in renamed), the Swatch Conflict dialog's question. Colours linked to a
|
||||
//! swatch then show it as the document does: its colour at their tint, and a Lab spot colour as
|
||||
//! the document's Spot Colors option says. Pasting back into the document the objects came from
|
||||
//! uses its resources as they are now.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_brush::Brush;
|
||||
use vectorcraft_color::{Color, Paint, Swatch};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Document, GraphicStyle, Node, NodeKind, PatternDef, Symbol, TextStyleDef};
|
||||
|
||||
use super::super::patterncmds::add_pattern;
|
||||
use super::super::{Result, bad, unique_name};
|
||||
use super::Clipboard;
|
||||
|
||||
/// A resource kind objects refer to (graphic styles by id, the others by name).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
enum Res {
|
||||
Image,
|
||||
Symbol,
|
||||
Pattern,
|
||||
Swatch,
|
||||
CharStyle,
|
||||
ParaStyle,
|
||||
Brush,
|
||||
GraphicStyle,
|
||||
}
|
||||
|
||||
impl Res {
|
||||
fn id(self) -> &'static str {
|
||||
match self {
|
||||
Res::Image => "image",
|
||||
Res::Symbol => "symbol",
|
||||
Res::Pattern => "pattern",
|
||||
Res::Swatch => "swatch",
|
||||
Res::CharStyle => "charStyle",
|
||||
Res::ParaStyle => "paraStyle",
|
||||
Res::Brush => "brush",
|
||||
Res::GraphicStyle => "graphicStyle",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A reference from art to a resource.
|
||||
enum Ref<'a> {
|
||||
Name(Res, &'a str),
|
||||
/// A colour linked to a swatch, at a tint.
|
||||
Swatch(&'a str, f32),
|
||||
/// A link to the graphic style with this id.
|
||||
Style(u32),
|
||||
}
|
||||
|
||||
type Visit<'v> = &'v mut dyn FnMut(Ref<'_>);
|
||||
|
||||
fn paint_refs(p: &Paint, f: Visit) {
|
||||
match p {
|
||||
Paint::Solid { swatch: Some(s), tint, .. } => f(Ref::Swatch(s, *tint)),
|
||||
Paint::Gradient(g) => {
|
||||
// The gradient swatch it was applied from, and its stops' links.
|
||||
if let Some(s) = &g.swatch {
|
||||
f(Ref::Name(Res::Swatch, s));
|
||||
}
|
||||
g.gradient.stops.iter().filter_map(|st| Some((st.swatch.as_deref()?, st.tint))).for_each(|(s, t)| f(Ref::Swatch(s, t)));
|
||||
}
|
||||
Paint::Pattern { pattern, .. } => f(Ref::Name(Res::Pattern, pattern)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn appearance_refs(a: &Appearance, f: Visit) {
|
||||
for it in &a.items {
|
||||
paint_refs(it.paint(), f);
|
||||
if let AppearanceItem::Stroke(l) = it
|
||||
&& let Some(b) = &l.brush
|
||||
{
|
||||
f(Ref::Name(Res::Brush, b));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The paints a character or paragraph style can set (by their serialized names).
|
||||
const STYLE_PAINTS: [&str; 2] = ["fill", "stroke"];
|
||||
|
||||
fn style_paint(attrs: &Map<String, Value>, key: &str) -> Option<Paint> {
|
||||
serde_json::from_value(attrs.get(key)?.clone()).ok()
|
||||
}
|
||||
|
||||
fn attrs_refs(attrs: &Map<String, Value>, f: Visit) {
|
||||
for p in STYLE_PAINTS.iter().filter_map(|k| style_paint(attrs, k)) {
|
||||
paint_refs(&p, f);
|
||||
}
|
||||
}
|
||||
|
||||
/// Every reference in the subtree of `n` (opacity-mask art included).
|
||||
fn node_refs(n: &Node, f: Visit) {
|
||||
n.walk(&mut |m| {
|
||||
appearance_refs(&m.appearance, f);
|
||||
if let Some(id) = m.graphic_style {
|
||||
f(Ref::Style(id));
|
||||
}
|
||||
if let Some(mask) = &m.mask {
|
||||
node_refs(&mask.art, f);
|
||||
}
|
||||
match &m.kind {
|
||||
NodeKind::Text(t) => {
|
||||
for r in &t.runs {
|
||||
paint_refs(&r.style.fill, f);
|
||||
paint_refs(&r.style.stroke, f);
|
||||
if let Some(s) = &r.style.style_name {
|
||||
f(Ref::Name(Res::CharStyle, s));
|
||||
}
|
||||
}
|
||||
if let Some(s) = &t.para.style_name {
|
||||
f(Ref::Name(Res::ParaStyle, s));
|
||||
}
|
||||
}
|
||||
NodeKind::Image(im) => f(Ref::Name(Res::Image, &im.key)),
|
||||
NodeKind::SymbolInstance { symbol, .. } => f(Ref::Name(Res::Symbol, symbol)),
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// A global or spot colour of its own (not a tint of another swatch): what colours link to.
|
||||
fn is_base(w: &Swatch) -> bool {
|
||||
(w.global || w.spot) && matches!(w.paint, Paint::Solid { swatch: None, .. })
|
||||
}
|
||||
|
||||
/// The definitions references lead to: a document's, or the clipboard's own.
|
||||
struct Defs<'a> {
|
||||
swatches: Vec<&'a Swatch>,
|
||||
symbols: &'a [Symbol],
|
||||
patterns: &'a [PatternDef],
|
||||
styles: &'a [GraphicStyle],
|
||||
char_styles: &'a [TextStyleDef],
|
||||
para_styles: &'a [TextStyleDef],
|
||||
}
|
||||
|
||||
impl<'a> Defs<'a> {
|
||||
fn of(d: &'a Document) -> Self {
|
||||
Self {
|
||||
swatches: d.swatches_iter().collect(),
|
||||
symbols: &d.symbols,
|
||||
patterns: &d.patterns,
|
||||
styles: &d.graphic_styles,
|
||||
char_styles: &d.char_styles,
|
||||
para_styles: &d.para_styles,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What references lead out of.
|
||||
enum Source<'a> {
|
||||
Node(&'a Node),
|
||||
Appearance(&'a Appearance),
|
||||
Attrs(&'a Map<String, Value>),
|
||||
Paint(&'a Paint),
|
||||
}
|
||||
|
||||
/// The resources a set of objects uses, through the definitions they lead to.
|
||||
#[derive(Default)]
|
||||
pub(super) struct Used {
|
||||
names: BTreeMap<Res, BTreeSet<String>>,
|
||||
/// The tints (below 100 %) of the swatches used: (swatch, tint).
|
||||
tints: Vec<(String, f32)>,
|
||||
styles: BTreeSet<u32>,
|
||||
}
|
||||
|
||||
impl Used {
|
||||
fn of<'a>(defs: &Defs<'a>, nodes: &'a [Node]) -> Self {
|
||||
let mut used = Used::default();
|
||||
let mut todo: Vec<Source<'a>> = nodes.iter().map(Source::Node).collect();
|
||||
while let Some(src) = todo.pop() {
|
||||
let mut found = |r: Ref<'_>| used.note(defs, r, &mut todo);
|
||||
match src {
|
||||
Source::Node(n) => node_refs(n, &mut found),
|
||||
Source::Appearance(a) => appearance_refs(a, &mut found),
|
||||
Source::Attrs(a) => attrs_refs(a, &mut found),
|
||||
Source::Paint(p) => paint_refs(p, &mut found),
|
||||
}
|
||||
}
|
||||
used
|
||||
}
|
||||
|
||||
/// Note `r`; a definition seen for the first time joins `todo`.
|
||||
fn note<'a>(&mut self, defs: &Defs<'a>, r: Ref<'_>, todo: &mut Vec<Source<'a>>) {
|
||||
let (res, name) = match r {
|
||||
Ref::Style(id) => {
|
||||
if self.styles.insert(id) {
|
||||
todo.extend(defs.styles.iter().filter(|g| g.id != 0 && g.id == id).map(|g| Source::Appearance(&g.appearance)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
Ref::Swatch(name, tint) => {
|
||||
if tint < 1.0 && !self.has_tint(name, tint) {
|
||||
self.tints.push((name.to_string(), tint));
|
||||
}
|
||||
(Res::Swatch, name)
|
||||
}
|
||||
Ref::Name(res, name) => (res, name),
|
||||
};
|
||||
let set = self.names.entry(res).or_default();
|
||||
if set.contains(name) {
|
||||
return;
|
||||
}
|
||||
set.insert(name.to_string());
|
||||
match res {
|
||||
// A tint swatch's base, a gradient swatch's linked stops.
|
||||
Res::Swatch => todo.extend(defs.swatches.iter().filter(|w| w.name == name).map(|w| Source::Paint(&w.paint))),
|
||||
Res::Symbol => todo.extend(defs.symbols.iter().filter(|s| s.name == name).map(|s| Source::Node(&s.art))),
|
||||
Res::Pattern => todo.extend(defs.patterns.iter().filter(|p| p.name == name).flat_map(|p| p.art.iter().map(|a| Source::Node(a)))),
|
||||
Res::CharStyle => todo.extend(defs.char_styles.iter().filter(|s| s.name == name).map(|s| Source::Attrs(&s.attrs))),
|
||||
Res::ParaStyle => todo.extend(defs.para_styles.iter().filter(|s| s.name == name).map(|s| Source::Attrs(&s.attrs))),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn has(&self, r: Res, name: &str) -> bool {
|
||||
self.names.get(&r).is_some_and(|s| s.contains(name))
|
||||
}
|
||||
fn has_tint(&self, name: &str, tint: f32) -> bool {
|
||||
self.tints.iter().any(|(n, t)| n == name && *t == tint)
|
||||
}
|
||||
/// Does the clipboard carry swatch `w`: a used global or spot colour or gradient swatch, or a
|
||||
/// tint swatch of a tint used? (Never the built-in Registration swatch.)
|
||||
fn carries(&self, w: &Swatch) -> bool {
|
||||
let named = is_base(w) || matches!(w.paint, Paint::Gradient(_));
|
||||
!w.is_reserved() && (named && self.has(Res::Swatch, &w.name) || w.tint_of().is_some_and(|(b, t)| self.has_tint(b, t)))
|
||||
}
|
||||
}
|
||||
|
||||
/// A resource list entry known by its name.
|
||||
trait Named: Clone {
|
||||
fn name(&self) -> &str;
|
||||
fn set_name(&mut self, name: String);
|
||||
}
|
||||
|
||||
macro_rules! named {
|
||||
($($t:ty),+) => {$(
|
||||
impl Named for $t {
|
||||
fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
fn set_name(&mut self, name: String) {
|
||||
self.name = name;
|
||||
}
|
||||
}
|
||||
)+};
|
||||
}
|
||||
named!(Symbol, TextStyleDef, Brush);
|
||||
|
||||
/// Where a pasted resource ended up in the document.
|
||||
enum Placed {
|
||||
/// The document's own of that name is used.
|
||||
Existing,
|
||||
/// The document's own numbered copy ("Name 2", from an earlier paste) is used.
|
||||
Reused(String),
|
||||
Added,
|
||||
/// Added under a free name.
|
||||
Renamed(String),
|
||||
}
|
||||
|
||||
impl Placed {
|
||||
/// Added as `name`, which `from` was free or not.
|
||||
fn added(from: &str, name: String) -> Self {
|
||||
if name == from { Placed::Added } else { Placed::Renamed(name) }
|
||||
}
|
||||
/// The name of the definition the paste added (`None` when the document's own is used).
|
||||
fn new_name(&self, from: &str) -> Option<String> {
|
||||
match self {
|
||||
Placed::Existing | Placed::Reused(_) => None,
|
||||
Placed::Added => Some(from.to_string()),
|
||||
Placed::Renamed(n) => Some(n.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `name` `base` or a numbered copy of it ("base 2")?
|
||||
fn copy_of(base: &str, name: &str) -> bool {
|
||||
name == base || name.strip_prefix(base).and_then(|r| r.strip_prefix(' ')).is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
|
||||
}
|
||||
|
||||
/// The document's own resource for pasted resource `name`, among the document's `names`: the
|
||||
/// one of that name when `same` holds for it (or, pasting back into the source document, at all),
|
||||
/// else a numbered copy of it (added by an earlier paste) that is the same. `None`: add it.
|
||||
fn existing<'a>(name: &str, names: impl IntoIterator<Item = &'a str>, same_doc: bool, same: impl Fn(&str) -> bool) -> Option<Placed> {
|
||||
let mut found: Vec<&str> = names.into_iter().filter(|n| copy_of(name, n)).collect();
|
||||
// The name itself first.
|
||||
found.sort_by_key(|n| *n != name);
|
||||
if same_doc && found.first() == Some(&name) {
|
||||
return Some(Placed::Existing);
|
||||
}
|
||||
found.into_iter().find(|n| same(n)).map(|n| if n == name { Placed::Existing } else { Placed::Reused(n.to_string()) })
|
||||
}
|
||||
|
||||
/// Find `item` in `list` ([`existing`], `same` comparing it under the entry's name) or add it
|
||||
/// under a free name.
|
||||
fn place<T: Named>(list: &mut Vec<T>, item: &T, same_doc: bool, same: impl Fn(&T, &T) -> bool) -> Placed {
|
||||
let same_as = |n: &str| {
|
||||
let mut c = item.clone();
|
||||
c.set_name(n.to_string());
|
||||
list.iter().find(|x| x.name() == n).is_some_and(|x| same(x, &c))
|
||||
};
|
||||
if let Some(p) = existing(item.name(), list.iter().map(Named::name), same_doc, same_as) {
|
||||
return p;
|
||||
}
|
||||
let name = unique_name(item.name(), |n| list.iter().any(|x| x.name() == n));
|
||||
let mut c = item.clone();
|
||||
c.set_name(name.clone());
|
||||
list.push(c);
|
||||
Placed::added(item.name(), name)
|
||||
}
|
||||
|
||||
/// Merge or Add, for a swatch whose name the document gives another colour.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) enum SwatchChoice {
|
||||
#[default]
|
||||
Merge,
|
||||
Add,
|
||||
}
|
||||
|
||||
/// The `swatchConflict` parameter: one choice for every conflict, or one per swatch name (the
|
||||
/// others merge).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct SwatchChoices {
|
||||
all: SwatchChoice,
|
||||
by_name: BTreeMap<String, SwatchChoice>,
|
||||
}
|
||||
|
||||
impl SwatchChoices {
|
||||
pub(crate) fn parse(cmd: &str, p: &Value) -> Result<Self> {
|
||||
let one = |v: &Value| match v.as_str() {
|
||||
Some("merge") => Ok(SwatchChoice::Merge),
|
||||
Some("add") => Ok(SwatchChoice::Add),
|
||||
_ => Err(bad(cmd, format!("swatchConflict must be \"merge\" or \"add\", not {v}"))),
|
||||
};
|
||||
match p.get("swatchConflict") {
|
||||
None | Some(Value::Null) => Ok(Self::default()),
|
||||
Some(Value::Object(m)) => {
|
||||
Ok(Self { all: SwatchChoice::Merge, by_name: m.iter().map(|(k, v)| Ok((k.clone(), one(v)?))).collect::<Result<_>>()? })
|
||||
}
|
||||
Some(v) => Ok(Self { all: one(v)?, by_name: BTreeMap::new() }),
|
||||
}
|
||||
}
|
||||
fn of(&self, name: &str) -> SwatchChoice {
|
||||
self.by_name.get(name).copied().unwrap_or(self.all)
|
||||
}
|
||||
}
|
||||
|
||||
/// How pasted art is relinked to the document's resources.
|
||||
#[derive(Debug, Default)]
|
||||
struct Relink {
|
||||
renames: BTreeMap<Res, BTreeMap<String, String>>,
|
||||
/// Graphic style id on the clipboard → its id in the document. Links to other ids are dropped.
|
||||
styles: BTreeMap<u32, u32>,
|
||||
/// Swatch (its name in the document) → the colour linked colours show at 100 %.
|
||||
colors: BTreeMap<String, Color>,
|
||||
}
|
||||
|
||||
impl Relink {
|
||||
fn name(&self, r: Res, s: &mut String) {
|
||||
if let Some(n) = self.renames.get(&r).and_then(|m| m.get(s.as_str())) {
|
||||
*s = n.clone();
|
||||
}
|
||||
}
|
||||
/// `name` as the document knows it.
|
||||
fn renamed(&self, r: Res, name: &str) -> String {
|
||||
let mut n = name.to_string();
|
||||
self.name(r, &mut n);
|
||||
n
|
||||
}
|
||||
fn paint(&self, p: &mut Paint) {
|
||||
match p {
|
||||
Paint::Pattern { pattern, .. } => self.name(Res::Pattern, pattern),
|
||||
Paint::Gradient(g) => {
|
||||
if let Some(s) = &mut g.swatch {
|
||||
self.name(Res::Swatch, s);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
p.map_links(&mut |c, link, tint| {
|
||||
if let Some(l) = link {
|
||||
self.name(Res::Swatch, l);
|
||||
if let Some(base) = self.colors.get(l.as_str()) {
|
||||
*c = base.tinted(*tint);
|
||||
}
|
||||
}
|
||||
false
|
||||
});
|
||||
}
|
||||
fn appearance(&self, a: &mut Appearance) {
|
||||
for it in &mut a.items {
|
||||
self.paint(it.paint_mut());
|
||||
if let AppearanceItem::Stroke(l) = it
|
||||
&& let Some(b) = &mut l.brush
|
||||
{
|
||||
self.name(Res::Brush, b);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn attrs(&self, a: &mut Map<String, Value>) {
|
||||
for k in STYLE_PAINTS {
|
||||
let Some(before) = style_paint(a, k) else { continue };
|
||||
let mut p = before.clone();
|
||||
self.paint(&mut p);
|
||||
if p != before
|
||||
&& let Ok(v) = serde_json::to_value(&p)
|
||||
{
|
||||
a.insert(k.to_string(), v);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Relink a node tree (the mirror of [`node_refs`]).
|
||||
fn node(&self, n: &mut Node) {
|
||||
self.appearance(&mut n.appearance);
|
||||
n.graphic_style = n.graphic_style.and_then(|id| self.styles.get(&id).copied());
|
||||
if let Some(m) = &mut n.mask {
|
||||
self.node(Arc::make_mut(&mut m.art));
|
||||
}
|
||||
match &mut n.kind {
|
||||
NodeKind::Text(t) => {
|
||||
for r in &mut t.runs {
|
||||
self.paint(&mut r.style.fill);
|
||||
self.paint(&mut r.style.stroke);
|
||||
if let Some(s) = &mut r.style.style_name {
|
||||
self.name(Res::CharStyle, s);
|
||||
}
|
||||
}
|
||||
if let Some(s) = &mut t.para.style_name {
|
||||
self.name(Res::ParaStyle, s);
|
||||
}
|
||||
}
|
||||
NodeKind::Image(im) => self.name(Res::Image, &mut im.key),
|
||||
NodeKind::SymbolInstance { symbol, .. } => self.name(Res::Symbol, symbol),
|
||||
_ => {}
|
||||
}
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
self.node(Arc::make_mut(c));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `ours` (a document definition's art) the art of `theirs` (a pasted one's) once that is
|
||||
/// relinked by `r`? Graphic style links don't count: they are relinked last.
|
||||
fn same_art(r: &Relink, ours: &[Arc<Node>], theirs: &[Arc<Node>]) -> bool {
|
||||
let unlinked = Relink::default();
|
||||
let relinked = |r: &Relink, n: &Node| {
|
||||
let mut n = n.clone();
|
||||
r.node(&mut n);
|
||||
n
|
||||
};
|
||||
ours.len() == theirs.len() && ours.iter().zip(theirs).all(|(a, b)| relinked(&unlinked, a) == relinked(r, b))
|
||||
}
|
||||
|
||||
/// What pasting did to the document's resources (part of the paste result).
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct Imported {
|
||||
relink: Relink,
|
||||
/// Resources added to the document (renamed ones included).
|
||||
pub added: usize,
|
||||
/// Swatch conflicts resolved by merging.
|
||||
pub merged: usize,
|
||||
/// `{kind, from, to}` for every resource the pasted objects now know under another name: added
|
||||
/// renamed, or a copy added renamed by an earlier paste.
|
||||
pub renamed: Vec<Value>,
|
||||
}
|
||||
|
||||
impl Imported {
|
||||
fn placed(&mut self, r: Res, from: &str, p: &Placed) {
|
||||
if matches!(p, Placed::Added | Placed::Renamed(_)) {
|
||||
self.added += 1;
|
||||
}
|
||||
if let Placed::Reused(to) | Placed::Renamed(to) = p {
|
||||
self.renamed.push(json!({"kind": r.id(), "from": from, "to": to}));
|
||||
self.relink.renames.entry(r).or_default().insert(from.to_string(), to.clone());
|
||||
}
|
||||
}
|
||||
/// Point a pasted copy at the document's resources.
|
||||
pub(crate) fn apply(&self, n: &mut Node) {
|
||||
self.relink.node(n);
|
||||
}
|
||||
}
|
||||
|
||||
impl Clipboard {
|
||||
/// `nodes` (from `src`) and the resources of `src` they use.
|
||||
pub(super) fn gather(src: &Document, nodes: Vec<Node>) -> Self {
|
||||
let used = Used::of(&Defs::of(src), &nodes);
|
||||
let pick = |r: Res, list: &[TextStyleDef]| list.iter().filter(|s| used.has(r, &s.name)).cloned().collect::<Vec<_>>();
|
||||
let brushes = match used.names.get(&Res::Brush) {
|
||||
Some(names) => vectorcraft_brush::library(src).into_iter().filter(|b| names.contains(&b.name)).collect(),
|
||||
None => vec![],
|
||||
};
|
||||
Self {
|
||||
images: src.images.iter().filter(|(k, _)| used.has(Res::Image, k)).map(|(k, b)| (k.clone(), b.clone())).collect(),
|
||||
symbols: src.symbols.iter().filter(|s| used.has(Res::Symbol, &s.name)).cloned().collect(),
|
||||
patterns: src.patterns.iter().filter(|p| used.has(Res::Pattern, &p.name)).cloned().collect(),
|
||||
swatches: src.swatches_iter().filter(|w| used.carries(w)).cloned().collect(),
|
||||
graphic_styles: src.graphic_styles.iter().filter(|g| g.id != 0 && used.styles.contains(&g.id)).cloned().collect(),
|
||||
char_styles: pick(Res::CharStyle, &src.char_styles),
|
||||
para_styles: pick(Res::ParaStyle, &src.para_styles),
|
||||
brushes,
|
||||
nodes,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The resources the objects use now (Paste without Formatting leaves text styles behind).
|
||||
pub(super) fn used(&self) -> Used {
|
||||
let defs = Defs {
|
||||
swatches: self.swatches.iter().collect(),
|
||||
symbols: &self.symbols,
|
||||
patterns: &self.patterns,
|
||||
styles: &self.graphic_styles,
|
||||
char_styles: &self.char_styles,
|
||||
para_styles: &self.para_styles,
|
||||
};
|
||||
Used::of(&defs, &self.nodes)
|
||||
}
|
||||
|
||||
/// The used global and spot colours whose name `d` gives another one: (pasted, document).
|
||||
pub(super) fn conflicts<'a, 'd>(&'a self, d: &'d Document, used: &Used) -> Vec<(&'a Swatch, &'d Swatch)> {
|
||||
self.swatches
|
||||
.iter()
|
||||
.filter(|w| is_base(w) && used.has(Res::Swatch, &w.name))
|
||||
.filter_map(|w| d.swatch(&w.name).filter(|t| is_base(t) && (t.paint != w.paint || t.spot != w.spot)).map(|t| (w, t)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Add the resources the objects use to `d` (inside the paste's undo step) and work out how
|
||||
/// pasted copies relink to them.
|
||||
pub(crate) fn import_into(&self, d: &mut Document, choices: &SwatchChoices, same_doc: bool) -> Imported {
|
||||
let used = self.used();
|
||||
let mut out = Imported::default();
|
||||
self.import_images(d, &used, same_doc, &mut out);
|
||||
self.import_brushes(d, &used, same_doc, &mut out);
|
||||
self.import_swatches(d, &used, choices, same_doc, &mut out);
|
||||
// Character and paragraph styles (their colours relinked first).
|
||||
for (r, mine, theirs) in [(Res::CharStyle, &self.char_styles, &mut d.char_styles), (Res::ParaStyle, &self.para_styles, &mut d.para_styles)] {
|
||||
for def in mine.iter().filter(|s| used.has(r, &s.name)) {
|
||||
let mut s = def.clone();
|
||||
out.relink.attrs(&mut s.attrs);
|
||||
let p = place(theirs, &s, same_doc, PartialEq::eq);
|
||||
out.placed(r, &def.name, &p);
|
||||
}
|
||||
}
|
||||
// Symbols and patterns, compared once relinked. Those added are relinked at the end, when
|
||||
// every new name and graphic style id is known.
|
||||
let mut added_symbols = vec![];
|
||||
for def in self.symbols.iter().filter(|s| used.has(Res::Symbol, &s.name)) {
|
||||
let r = &out.relink;
|
||||
let p = place(&mut d.symbols, def, same_doc, |a, b| same_art(r, std::slice::from_ref(&a.art), std::slice::from_ref(&b.art)));
|
||||
added_symbols.extend(p.new_name(&def.name));
|
||||
out.placed(Res::Symbol, &def.name, &p);
|
||||
}
|
||||
let mut added_patterns = vec![];
|
||||
for def in self.patterns.iter().filter(|p| used.has(Res::Pattern, &p.name)) {
|
||||
// Tile options alike, and the art once relinked.
|
||||
let bare = |p: &PatternDef| PatternDef { name: String::new(), art: vec![], ..p.clone() };
|
||||
let same = |n: &str| d.pattern(n).is_some_and(|x| bare(x) == bare(def) && same_art(&out.relink, &x.art, &def.art));
|
||||
let p = existing(&def.name, d.patterns.iter().map(|p| p.name.as_str()), same_doc, same).unwrap_or_else(|| {
|
||||
// Its swatch comes along under the same name.
|
||||
let name = unique_name(&def.name, |n| d.pattern(n).is_some() || d.swatch_name_taken(n));
|
||||
add_pattern(d, PatternDef { name: name.clone(), ..def.clone() });
|
||||
Placed::added(&def.name, name)
|
||||
});
|
||||
added_patterns.extend(p.new_name(&def.name));
|
||||
out.placed(Res::Pattern, &def.name, &p);
|
||||
}
|
||||
self.import_graphic_styles(d, &used, same_doc, &mut out);
|
||||
for s in d.symbols.iter_mut().filter(|s| added_symbols.contains(&s.name)) {
|
||||
out.relink.node(Arc::make_mut(&mut s.art));
|
||||
}
|
||||
for p in d.patterns.iter_mut().filter(|p| added_patterns.contains(&p.name)) {
|
||||
for a in &mut p.art {
|
||||
out.relink.node(Arc::make_mut(a));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Image blobs: a different blob under a key the document uses gets a new key.
|
||||
fn import_images(&self, d: &mut Document, used: &Used, same_doc: bool, out: &mut Imported) {
|
||||
for (key, blob) in self.images.iter().filter(|(k, _)| used.has(Res::Image, k)) {
|
||||
let p = existing(key, d.images.keys().map(String::as_str), same_doc, |k| d.images.get(k) == Some(blob)).unwrap_or_else(|| {
|
||||
let k = unique_name(key, |n| d.images.contains_key(n));
|
||||
d.images.insert(k.clone(), blob.clone());
|
||||
Placed::added(key, k)
|
||||
});
|
||||
out.placed(Res::Image, key, &p);
|
||||
}
|
||||
}
|
||||
|
||||
fn import_brushes(&self, d: &mut Document, used: &Used, same_doc: bool, out: &mut Imported) {
|
||||
let brushes: Vec<&Brush> = self.brushes.iter().filter(|b| used.has(Res::Brush, &b.name)).collect();
|
||||
if brushes.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut lib = vectorcraft_brush::library(d);
|
||||
let before = lib.len();
|
||||
for b in brushes {
|
||||
let p = place(&mut lib, b, same_doc, PartialEq::eq);
|
||||
out.placed(Res::Brush, &b.name, &p);
|
||||
}
|
||||
if lib.len() != before {
|
||||
vectorcraft_brush::store(d, &lib);
|
||||
}
|
||||
}
|
||||
|
||||
/// Global and spot colours (a name conflict merges or adds as `choices` say), then the tint
|
||||
/// swatches of the tints used and the gradient swatches.
|
||||
fn import_swatches(&self, d: &mut Document, used: &Used, choices: &SwatchChoices, same_doc: bool, out: &mut Imported) {
|
||||
let conflicts: BTreeSet<String> =
|
||||
if same_doc { BTreeSet::new() } else { self.conflicts(d, used).into_iter().map(|(w, _)| w.name.clone()).collect() };
|
||||
let bases: Vec<&Swatch> = self.swatches.iter().filter(|w| is_base(w) && used.has(Res::Swatch, &w.name)).collect();
|
||||
for w in &bases {
|
||||
let p = if conflicts.contains(&w.name) && choices.of(&w.name) == SwatchChoice::Merge {
|
||||
out.merged += 1;
|
||||
Placed::Existing
|
||||
} else {
|
||||
add_swatch(d, w, same_doc)
|
||||
};
|
||||
out.placed(Res::Swatch, &w.name, &p);
|
||||
}
|
||||
// Linked colours show the document's swatch of their link.
|
||||
for w in &bases {
|
||||
let name = out.relink.renamed(Res::Swatch, &w.name);
|
||||
if let Some(c) = d.global_color(&name) {
|
||||
out.relink.colors.insert(name, c);
|
||||
}
|
||||
}
|
||||
// Then the tint and gradient swatches, linked like the objects.
|
||||
for def in self.swatches.iter().filter(|w| !is_base(w) && used.carries(w)) {
|
||||
let mut w = def.clone();
|
||||
out.relink.paint(&mut w.paint);
|
||||
let p = add_swatch(d, &w, same_doc);
|
||||
out.placed(Res::Swatch, &def.name, &p);
|
||||
}
|
||||
}
|
||||
|
||||
/// Graphic styles: in the source document by id, elsewhere by name and look.
|
||||
fn import_graphic_styles(&self, d: &mut Document, used: &Used, same_doc: bool, out: &mut Imported) {
|
||||
for g in self.graphic_styles.iter().filter(|g| used.styles.contains(&g.id)) {
|
||||
let mut new = g.clone();
|
||||
out.relink.appearance(&mut new.appearance);
|
||||
let found = match d.graphic_styles.iter().position(|x| same_doc && x.id == g.id) {
|
||||
Some(i) => Some(i),
|
||||
None => {
|
||||
let same = |n: &str| d.graphic_style(n).is_some_and(|x| x.same_look(&new));
|
||||
let p = existing(&g.name, d.graphic_styles.iter().map(|x| x.name.as_str()), same_doc, same);
|
||||
// Objects follow the style's id, not its name.
|
||||
p.and_then(|p| d.graphic_style_index(if let Placed::Reused(n) = &p { n } else { &g.name }))
|
||||
}
|
||||
};
|
||||
let i = match found {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
let name = unique_name(&g.name, |n| d.graphic_style(n).is_some());
|
||||
out.placed(Res::GraphicStyle, &g.name, &Placed::added(&g.name, name.clone()));
|
||||
let id = d.next_graphic_style_id();
|
||||
d.graphic_styles.push(GraphicStyle { name, id, ..new });
|
||||
d.graphic_styles.len() - 1
|
||||
}
|
||||
};
|
||||
out.relink.styles.insert(g.id, d.graphic_style_id(i));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Swatch `w` in `d` ([`existing`]), else added under a free name (swatches and colour groups
|
||||
/// share one namespace).
|
||||
fn add_swatch(d: &mut Document, w: &Swatch, same_doc: bool) -> Placed {
|
||||
let same = |n: &str| d.swatch(n).is_some_and(|t| *t == Swatch { name: n.to_string(), ..w.clone() });
|
||||
existing(&w.name, d.swatches_iter().map(|t| t.name.as_str()), same_doc, same).unwrap_or_else(|| {
|
||||
let name = d.free_swatch_name(&w.name);
|
||||
d.swatches.push(Swatch { name: name.clone(), ..w.clone() });
|
||||
Placed::added(&w.name, name)
|
||||
})
|
||||
}
|
||||
+130
-42
@@ -1,13 +1,15 @@
|
||||
//! File (document-level) and Edit commands, plus document queries.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, Node, NodeId, Unit};
|
||||
use vectorcraft_geom::Affine;
|
||||
|
||||
use super::clipboard::SwatchChoices;
|
||||
use super::*;
|
||||
use crate::{EngineError, inspect};
|
||||
use crate::{Clipboard, EngineError, inspect};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -38,15 +40,51 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, redo),
|
||||
cmd!("edit.cut", "Cut", ["Edit"], Some("Cmd+X"), "{}", has_selection, cut),
|
||||
cmd!("edit.copy", "Copy", ["Edit"], Some("Cmd+C"), "{}", has_selection, copy),
|
||||
cmd!("edit.paste", "Paste", ["Edit"], Some("Cmd+V"), "{dx?, dy?}", has_clipboard, |s, p| paste(s, p, PasteMode::Offset)),
|
||||
cmd!("edit.pasteInFront", "Paste in Front", ["Edit"], Some("Cmd+F"), "{}", has_clipboard, |s, p| paste(s, p, PasteMode::Front)),
|
||||
cmd!("edit.pasteInBack", "Paste in Back", ["Edit"], Some("Cmd+B"), "{}", has_clipboard, |s, p| paste(s, p, PasteMode::Back)),
|
||||
cmd!("edit.pasteInPlace", "Paste in Place", ["Edit"], Some("Cmd+Shift+V"), "{}", has_clipboard, |s, p| paste(s, p, PasteMode::InPlace)),
|
||||
cmd!("edit.pasteOnAllArtboards", "Paste on All Artboards", ["Edit"], Some("Cmd+Alt+Shift+V"), "{}", has_clipboard, |s, p| paste(
|
||||
s,
|
||||
p,
|
||||
PasteMode::AllArtboards
|
||||
)),
|
||||
cmd!(
|
||||
"edit.paste",
|
||||
"Paste",
|
||||
["Edit"],
|
||||
Some("Cmd+V"),
|
||||
"{center?: [x, y], dx?, dy?, swatchConflict?} paste centred on `center` (the app passes the view centre), else offset by dx/dy (default: the Paste Offset preference). Pasting brings the image blobs, symbols, patterns, global and spot swatches (with the tint swatches of the tints used), gradient swatches, graphic styles, character and paragraph styles and brushes the objects use; one of the same name that differs comes in renamed. swatchConflict, for a swatch whose name the document gives another colour (clipboard.conflicts): \"merge\" (default: the objects take the document's swatch) | \"add\" (the pasted swatch comes in renamed) | {name: \"merge\"|\"add\"}. With Paste Remembers Layers on (layer.pasteRemembersLayers), objects go back into the layers they came from (by name; made when missing) → {ids, added: resources added, merged: conflicts merged, renamed: [{kind, from, to}]}",
|
||||
has_clipboard,
|
||||
|s, p| paste(s, p, PasteMode::Offset)
|
||||
),
|
||||
cmd!(
|
||||
"edit.pasteInFront",
|
||||
"Paste in Front",
|
||||
["Edit"],
|
||||
Some("Cmd+F"),
|
||||
"{swatchConflict?} paste in place just above the top selected object, or on top of the current layer when nothing is selected (resources and layers as edit.paste) → {ids, added, merged, renamed}",
|
||||
has_clipboard,
|
||||
|s, p| paste(s, p, PasteMode::Front)
|
||||
),
|
||||
cmd!(
|
||||
"edit.pasteInBack",
|
||||
"Paste in Back",
|
||||
["Edit"],
|
||||
Some("Cmd+B"),
|
||||
"{swatchConflict?} paste in place just below the bottom selected object, or at the bottom of the current layer when nothing is selected (resources and layers as edit.paste) → {ids, added, merged, renamed}",
|
||||
has_clipboard,
|
||||
|s, p| paste(s, p, PasteMode::Back)
|
||||
),
|
||||
cmd!(
|
||||
"edit.pasteInPlace",
|
||||
"Paste in Place",
|
||||
["Edit"],
|
||||
Some("Cmd+Shift+V"),
|
||||
"{swatchConflict?} paste where the objects were copied (resources and layers as edit.paste) → {ids, added, merged, renamed}",
|
||||
has_clipboard,
|
||||
|s, p| paste(s, p, PasteMode::InPlace)
|
||||
),
|
||||
cmd!(
|
||||
"edit.pasteOnAllArtboards",
|
||||
"Paste on All Artboards",
|
||||
["Edit"],
|
||||
Some("Cmd+Alt+Shift+V"),
|
||||
"{swatchConflict?} paste a copy on every artboard at the offset the objects had to the artboard they were copied from (resources and layers as edit.paste) → {ids, added, merged, renamed}",
|
||||
has_clipboard,
|
||||
|s, p| paste(s, p, PasteMode::AllArtboards)
|
||||
),
|
||||
cmd!("edit.clear", "Clear", ["Edit"], Some("Delete"), "{ids?}", has_selection, clear),
|
||||
cmd!("edit.duplicate", "Duplicate", [], None, "{dx?, dy?} duplicate the selection in place (offset optional)", has_selection, duplicate),
|
||||
]
|
||||
@@ -141,11 +179,8 @@ pub(crate) fn roots_of(doc: &Document, ids: Vec<NodeId>) -> Vec<NodeId> {
|
||||
|
||||
fn copy(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let roots = selected_roots(s)?;
|
||||
let st = s.doc()?;
|
||||
let nodes: Vec<Node> = roots.iter().filter_map(|id| st.doc.node(*id).cloned()).collect();
|
||||
let n = nodes.len();
|
||||
s.clipboard = nodes;
|
||||
Ok(json!({ "copied": n }))
|
||||
s.clipboard = Clipboard::copy(s.doc()?, &roots);
|
||||
Ok(json!({ "copied": s.clipboard.nodes.len() }))
|
||||
}
|
||||
|
||||
fn cut(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -175,6 +210,7 @@ fn clear(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum PasteMode {
|
||||
Offset,
|
||||
Front,
|
||||
@@ -183,57 +219,109 @@ enum PasteMode {
|
||||
AllArtboards,
|
||||
}
|
||||
|
||||
impl PasteMode {
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
PasteMode::Front => "Paste in Front",
|
||||
PasteMode::Back => "Paste in Back",
|
||||
PasteMode::InPlace => "Paste in Place",
|
||||
PasteMode::AllArtboards => "Paste on All Artboards",
|
||||
PasteMode::Offset => "Paste",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn paste(s: &mut Session, p: &Value, mode: PasteMode) -> Result<Value> {
|
||||
let clip = s.clipboard.clone();
|
||||
let choices = SwatchChoices::parse(mode.label(), p)?;
|
||||
// Taken out of the session for the edit (and put back whatever happens).
|
||||
let clip = std::mem::take(&mut s.clipboard);
|
||||
let r = paste_clip(s, p, mode, &clip, &choices);
|
||||
s.clipboard = clip;
|
||||
r
|
||||
}
|
||||
|
||||
fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, choices: &SwatchChoices) -> Result<Value> {
|
||||
let off = s.prefs.paste_offset;
|
||||
let st = s.doc()?;
|
||||
let same_doc = clip.source_doc == Some(st.uid);
|
||||
let parent = st.insertion_parent();
|
||||
// Front/back: relative to the selection (top-most / bottom-most selected object).
|
||||
// Paste Remembers Layers (not while isolating a group: pastes stay in it).
|
||||
let remember = st.doc.paste_remembers_layers && parent.is_some_and(|p| st.doc.node(p).is_some_and(Node::is_layer));
|
||||
// Front/back: relative to the selection (top-most / bottom-most selected object); with
|
||||
// nothing selected, the top / bottom of the current layer.
|
||||
let anchor = match mode {
|
||||
PasteMode::Front | PasteMode::Back => {
|
||||
let order = st.selection.in_paint_order(&st.doc);
|
||||
let a = if matches!(mode, PasteMode::Front) { order.last() } else { order.first() };
|
||||
let a = if mode == PasteMode::Front { order.last() } else { order.first() };
|
||||
a.and_then(|id| st.doc.position(*id))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let artboards: Vec<vectorcraft_geom::Rect> = st.doc.artboards.iter().map(|a| a.rect).collect();
|
||||
let dx = f64_or(p, "dx", off);
|
||||
let dy = f64_or(p, "dy", off);
|
||||
let label = match mode {
|
||||
PasteMode::Front => "Paste in Front",
|
||||
PasteMode::Back => "Paste in Back",
|
||||
PasteMode::InPlace => "Paste in Place",
|
||||
PasteMode::AllArtboards => "Paste on All Artboards",
|
||||
PasteMode::Offset => "Paste",
|
||||
let placements: Vec<Affine> = match mode {
|
||||
PasteMode::Offset => vec![match point_param(p, "center") {
|
||||
Some(c) => clip.bounds().map_or(Affine::IDENTITY, |b| Affine::translate(c - b.center())),
|
||||
None => Affine::translate((f64_or(p, "dx", off), f64_or(p, "dy", off))),
|
||||
}],
|
||||
PasteMode::AllArtboards => {
|
||||
let src = clip.source_artboard.or_else(|| st.doc.artboards.first().map(|a| a.rect)).map(|r| r.origin()).unwrap_or_default();
|
||||
st.doc.artboards.iter().map(|a| Affine::translate(a.rect.origin() - src)).collect()
|
||||
}
|
||||
_ => vec![Affine::IDENTITY],
|
||||
};
|
||||
let ids = s.edit(label, |d, sel| {
|
||||
let (ids, imported) = s.edit(mode.label(), |d, sel| {
|
||||
let imported = clip.import_into(d, choices, same_doc);
|
||||
let mut layers: BTreeMap<&str, NodeId> = BTreeMap::new();
|
||||
// Objects pasted into each parent so far (keeps their order in front and back pastes).
|
||||
let mut placed: BTreeMap<Option<NodeId>, usize> = BTreeMap::new();
|
||||
let mut new_ids = vec![];
|
||||
let placements: Vec<Affine> = match mode {
|
||||
PasteMode::Offset => vec![Affine::translate((dx, dy))],
|
||||
PasteMode::AllArtboards => {
|
||||
let src = d.artboards.first().map(|a| a.rect.origin()).unwrap_or_default();
|
||||
artboards.iter().map(|r| Affine::translate(r.origin() - src)).collect()
|
||||
}
|
||||
_ => vec![Affine::IDENTITY],
|
||||
};
|
||||
for xf in placements {
|
||||
for (k, n) in clip.iter().enumerate() {
|
||||
for (k, n) in clip.nodes.iter().enumerate() {
|
||||
let mut c = d.reid(n);
|
||||
imported.apply(&mut c);
|
||||
if xf != Affine::IDENTITY {
|
||||
c.transform(xf, false);
|
||||
}
|
||||
let (par, idx) = match anchor {
|
||||
Some((par, i, _)) => (par, if matches!(mode, PasteMode::Front) { i + 1 + k } else { i + k }),
|
||||
None => (parent, usize::MAX),
|
||||
let layer = match clip.source_layers.get(k) {
|
||||
Some(Some(name)) if remember => Some(*layers.entry(name).or_insert_with(|| layer_named(d, name))),
|
||||
_ => None,
|
||||
};
|
||||
let (par, idx) = match (anchor, layer) {
|
||||
// Next to the anchor, unless the object goes back to another layer.
|
||||
(Some((par, i, _)), l) if l.is_none() || l == par => {
|
||||
let k = placed.get(&par).copied().unwrap_or(0);
|
||||
(par, if mode == PasteMode::Front { i + 1 + k } else { i + k })
|
||||
}
|
||||
(_, l) => {
|
||||
let par = l.or(parent);
|
||||
(par, if mode == PasteMode::Back { placed.get(&par).copied().unwrap_or(0) } else { usize::MAX })
|
||||
}
|
||||
};
|
||||
*placed.entry(par).or_default() += 1;
|
||||
new_ids.push(d.insert(par, idx, c)?);
|
||||
}
|
||||
}
|
||||
sel.set(new_ids.iter().copied());
|
||||
Ok(new_ids)
|
||||
Ok((new_ids, imported))
|
||||
})?;
|
||||
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
Ok(json!({
|
||||
"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(),
|
||||
"added": imported.added,
|
||||
"merged": imported.merged,
|
||||
"renamed": imported.renamed,
|
||||
}))
|
||||
}
|
||||
|
||||
/// The first unlocked layer or sublayer called `name` (topmost first), or a new top layer.
|
||||
fn layer_named(d: &mut Document, name: &str) -> NodeId {
|
||||
let mut found = None;
|
||||
for l in d.layers.iter().rev() {
|
||||
l.walk(&mut |n| {
|
||||
if found.is_none() && n.is_layer() && !n.locked && n.name.as_deref() == Some(name) {
|
||||
found = Some(n.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
found.unwrap_or_else(|| d.add_layer(Some(name)))
|
||||
}
|
||||
|
||||
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -65,9 +65,31 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
target
|
||||
),
|
||||
cmd!(
|
||||
"layer.pasteRemembersLayers",
|
||||
"Paste Remembers Layers",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{on?} (default: toggle) the document option: on, the Paste commands put objects back into the layers they were copied from (by name; a missing one is made on top), off into the current layer; one undo step when it changes (document.inspect → pasteRemembersLayers) → {on}",
|
||||
has_doc,
|
||||
paste_remembers_layers
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// Layers panel → Paste Remembers Layers.
|
||||
fn paste_remembers_layers(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cur = s.doc()?.doc.paste_remembers_layers;
|
||||
let on = bool_or(p, "on", !cur);
|
||||
if on != cur {
|
||||
s.edit("Paste Remembers Layers", |d, _| {
|
||||
d.paste_remembers_layers = on;
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(json!({ "on": on }))
|
||||
}
|
||||
|
||||
/// The Layers panel's target circle.
|
||||
fn target(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "layer.target";
|
||||
|
||||
@@ -97,7 +97,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Paste without Formatting",
|
||||
["Edit"],
|
||||
Some("Cmd+Alt+V"),
|
||||
"{dx?, dy?} paste; pasted text takes the default character style (one run)",
|
||||
"{center?, dx?, dy?, swatchConflict?} paste as edit.paste; pasted text takes the default character style (one run) and leaves its character and paragraph styles behind → {ids, added, merged, renamed}",
|
||||
has_clipboard,
|
||||
paste_plain
|
||||
),
|
||||
@@ -590,13 +590,13 @@ fn strip_formatting(n: &mut vectorcraft_doc::Node) {
|
||||
}
|
||||
|
||||
fn paste_plain(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let saved = s.clipboard.clone();
|
||||
for n in &mut s.clipboard {
|
||||
let saved = s.clipboard.nodes.clone();
|
||||
for n in &mut s.clipboard.nodes {
|
||||
strip_formatting(n);
|
||||
}
|
||||
let paste = find_command("edit.paste").map(|c| c.run).ok_or_else(|| EngineError::Other("paste unavailable".into()))?;
|
||||
let r = paste(s, p);
|
||||
s.clipboard = saved;
|
||||
s.clipboard.nodes = saved;
|
||||
if r.is_ok()
|
||||
&& let Some(e) = s.active_mut().and_then(|d| d.history.undo.last_mut())
|
||||
{
|
||||
|
||||
@@ -71,6 +71,7 @@ pub fn document(s: &Session) -> Value {
|
||||
"objects": d.node_count(),
|
||||
"tool": s.tool_id(),
|
||||
"paint": {"fill": s.paint.fill.label(), "stroke": s.paint.stroke.label(), "strokeWidth": s.paint.stroke_width, "fillActive": s.fill_active, "appearanceItem": s.appearance_item()},
|
||||
"pasteRemembersLayers": d.paste_remembers_layers,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ use vectorcraft_geom::Affine;
|
||||
use vectorcraft_tools::{PaintDefaults, Tool};
|
||||
|
||||
pub use cmd::EyedropperOptions;
|
||||
pub use cmd::clipboard::Clipboard;
|
||||
pub use cmd::rasterfx::{export_pdf, flatten_raster_effects};
|
||||
pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};
|
||||
pub use tooling::{UiRequest, ViewInfo};
|
||||
@@ -500,8 +501,8 @@ pub struct Session {
|
||||
pub paint: PaintDefaults,
|
||||
/// Which proxy is in front (true = Fill, false = Stroke) — the X key toggles.
|
||||
pub fill_active: bool,
|
||||
/// Internal clipboard (serialized nodes).
|
||||
pub clipboard: Vec<vectorcraft_doc::Node>,
|
||||
/// Internal clipboard: the copied objects and the document resources they use.
|
||||
pub clipboard: Clipboard,
|
||||
/// Executed commands (for actions and debugging).
|
||||
pub journal: Vec<(String, Value)>,
|
||||
pub(crate) tool: Box<dyn Tool>,
|
||||
@@ -560,7 +561,7 @@ impl Session {
|
||||
prefs: Prefs::default(),
|
||||
paint: PaintDefaults::default(),
|
||||
fill_active: true,
|
||||
clipboard: vec![],
|
||||
clipboard: Clipboard::default(),
|
||||
journal: vec![],
|
||||
tool: vectorcraft_tools::create("selection"),
|
||||
last_view: ViewInfo::default(),
|
||||
@@ -902,6 +903,8 @@ mod tests_charstroke;
|
||||
#[cfg(test)]
|
||||
mod tests_clip;
|
||||
#[cfg(test)]
|
||||
mod tests_clipboard;
|
||||
#[cfg(test)]
|
||||
mod tests_clippaint;
|
||||
#[cfg(test)]
|
||||
mod tests_cmdsplit;
|
||||
|
||||
@@ -0,0 +1,598 @@
|
||||
//! Copy and paste between documents (resources, swatch conflicts) and paste placement (M4.22,
|
||||
//! M4.23).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Document, Node, NodeKind};
|
||||
use vectorcraft_geom::{Point, Rect};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// A second document in the same session (as when copying between windows); it becomes active.
|
||||
fn new_doc(s: &mut Session) {
|
||||
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
}
|
||||
|
||||
fn id_of(v: &Value) -> NodeId {
|
||||
NodeId(v["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn ids_of(v: &Value) -> Vec<NodeId> {
|
||||
v["ids"].as_array().unwrap().iter().map(|i| NodeId(i.as_u64().unwrap())).collect()
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
|
||||
id_of(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap())
|
||||
}
|
||||
|
||||
fn ellipse(s: &mut Session) -> NodeId {
|
||||
id_of(&s.execute("shape.ellipse", &json!({"x": 300, "y": 200, "width": 40, "height": 20})).unwrap())
|
||||
}
|
||||
|
||||
fn sel(s: &mut Session, ids: &[NodeId]) {
|
||||
s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
|
||||
}
|
||||
|
||||
fn copy(s: &mut Session, ids: &[NodeId]) {
|
||||
sel(s, ids);
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
}
|
||||
|
||||
fn doc(s: &Session) -> &Document {
|
||||
&s.doc().unwrap().doc
|
||||
}
|
||||
|
||||
fn node(s: &Session, id: NodeId) -> Node {
|
||||
doc(s).node(id).unwrap().clone()
|
||||
}
|
||||
|
||||
fn fill(s: &Session, id: NodeId) -> Paint {
|
||||
node(s, id).appearance.fill_paint()
|
||||
}
|
||||
|
||||
fn linked(color: Color, swatch: &str, tint: f32) -> Paint {
|
||||
Paint::Solid { color, swatch: Some(swatch.into()), tint }
|
||||
}
|
||||
|
||||
fn hex(h: &str) -> Color {
|
||||
Color::from_hex(h).unwrap()
|
||||
}
|
||||
|
||||
fn render(s: &Session) -> Vec<u8> {
|
||||
vectorcraft_render::Renderer::new().render_region(doc(s), Rect::new(0.0, 0.0, 400.0, 300.0), 1.0, true).pixels
|
||||
}
|
||||
|
||||
/// A global swatch `name` of `hex` and a rectangle filled with it.
|
||||
fn swatched_rect(s: &mut Session, name: &str, hex: &str) -> NodeId {
|
||||
s.execute("swatch.new", &json!({"name": name, "color": hex, "global": true})).unwrap();
|
||||
let r = rect(s, 20.0, 20.0, 40.0, 40.0);
|
||||
s.execute("paint.setFill", &json!({"ids": [r.0], "swatch": name})).unwrap();
|
||||
r
|
||||
}
|
||||
|
||||
fn renamed(v: &Value) -> Vec<(String, String, String)> {
|
||||
let s = |r: &Value, k: &str| r[k].as_str().unwrap().to_string();
|
||||
v["renamed"].as_array().unwrap().iter().map(|r| (s(r, "kind"), s(r, "from"), s(r, "to"))).collect()
|
||||
}
|
||||
|
||||
// ---------- M4.22: resources ----------
|
||||
|
||||
#[test]
|
||||
fn an_image_symbol_and_pattern_rect_pasted_into_a_new_document_render_the_same() {
|
||||
let mut s = session();
|
||||
// Pattern "Dots" painting a rectangle.
|
||||
let dot = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
s.execute("paint.setFill", &json!({"ids": [dot.0], "color": "#ff0000"})).unwrap();
|
||||
sel(&mut s, &[dot]);
|
||||
s.execute("object.pattern.make", &json!({"name": "Dots", "width": 20, "height": 20})).unwrap();
|
||||
s.execute("object.pattern.done", &json!({})).unwrap();
|
||||
sel(&mut s, &[dot]);
|
||||
s.execute("edit.clear", &json!({})).unwrap();
|
||||
let patterned = rect(&mut s, 20.0, 20.0, 100.0, 80.0);
|
||||
s.execute("paint.setFill", &json!({"ids": [patterned.0], "swatch": "Dots"})).unwrap();
|
||||
// Symbol "Badge" (a green square) placed once.
|
||||
let badge = rect(&mut s, 150.0, 20.0, 50.0, 50.0);
|
||||
s.execute("paint.setFill", &json!({"ids": [badge.0], "color": "#00aa44"})).unwrap();
|
||||
sel(&mut s, &[badge]);
|
||||
s.execute("symbol.new", &json!({"name": "Badge"})).unwrap();
|
||||
// An embedded image (a rasterized blue ellipse).
|
||||
let e = id_of(&s.execute("shape.ellipse", &json!({"x": 240, "y": 40, "width": 90, "height": 60})).unwrap());
|
||||
s.execute("paint.setFill", &json!({"ids": [e.0], "color": "#2040ff"})).unwrap();
|
||||
sel(&mut s, &[e]);
|
||||
s.execute("object.rasterize", &json!({"ppi": 72})).unwrap();
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
assert_eq!(s.clipboard.nodes.len(), 3);
|
||||
assert_eq!((s.clipboard.symbols.len(), s.clipboard.patterns.len(), s.clipboard.images.len()), (1, 1, 1));
|
||||
let before = render(&s);
|
||||
|
||||
new_doc(&mut s);
|
||||
assert!(before != render(&s));
|
||||
let r = s.execute("edit.pasteInPlace", &json!({})).unwrap();
|
||||
// The pattern (its swatch comes along), the symbol and the image blob.
|
||||
assert_eq!((r["added"].as_u64(), r["merged"].as_u64()), (Some(3), Some(0)), "{r}");
|
||||
let d = doc(&s);
|
||||
assert!(d.pattern("Dots").is_some() && d.swatch("Dots").is_some());
|
||||
assert!(d.symbols.iter().any(|x| x.name == "Badge"));
|
||||
assert_eq!(d.images.len(), 1);
|
||||
assert!(before == render(&s), "the pasted art renders as it did in its own document");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_spot_swatch_brings_its_plate() {
|
||||
let mut s = session();
|
||||
let r = swatched_rect(&mut s, "Spot Teal", "#008080");
|
||||
s.execute("swatch.setSpot", &json!({"name": "Spot Teal"})).unwrap();
|
||||
copy(&mut s, &[r]);
|
||||
new_doc(&mut s);
|
||||
let plates = |s: &Session| vectorcraft_render::proof::plates(doc(s)).iter().filter(|p| p.spot).map(|p| p.name.clone()).collect::<Vec<_>>();
|
||||
assert!(plates(&s).is_empty());
|
||||
s.execute("edit.paste", &json!({})).unwrap();
|
||||
assert_eq!(plates(&s), ["Spot Teal"]);
|
||||
let sw = doc(&s).swatch("Spot Teal").unwrap();
|
||||
assert!(sw.spot && sw.global);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swatch_conflicts_merge_relinks_and_add_renames() {
|
||||
let mut s = session();
|
||||
let r = swatched_rect(&mut s, "Brand", "#ff0000");
|
||||
copy(&mut s, &[r]);
|
||||
new_doc(&mut s);
|
||||
s.execute("swatch.new", &json!({"name": "Brand", "color": "#0000ff", "global": true})).unwrap();
|
||||
let c = s.execute("clipboard.conflicts", &json!({})).unwrap();
|
||||
assert_eq!(c["swatches"], json!([{"name": "Brand", "document": "#0000ff", "clipboard": "#ff0000", "spot": false}]));
|
||||
let swatches = doc(&s).swatches.len();
|
||||
|
||||
// Merge (the default): the pasted object takes the document's Brand.
|
||||
let v = s.execute("edit.pasteInPlace", &json!({"swatchConflict": "merge"})).unwrap();
|
||||
assert_eq!((v["merged"].as_u64(), v["added"].as_u64()), (Some(1), Some(0)));
|
||||
assert_eq!(fill(&s, ids_of(&v)[0]), linked(hex("#0000ff"), "Brand", 1.0));
|
||||
assert_eq!(doc(&s).swatches.len(), swatches);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
|
||||
// Add: the pasted swatch joins under a new name and the object follows it.
|
||||
let v = s.execute("edit.pasteInPlace", &json!({"swatchConflict": "add"})).unwrap();
|
||||
assert_eq!(v["renamed"], json!([{"kind": "swatch", "from": "Brand", "to": "Brand 2"}]));
|
||||
assert_eq!(fill(&s, ids_of(&v)[0]), linked(hex("#ff0000"), "Brand 2", 1.0));
|
||||
let added = doc(&s).swatch("Brand 2").unwrap();
|
||||
assert!(added.global && added.paint.color() == Some(hex("#ff0000")));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
|
||||
// Per swatch name; the others merge.
|
||||
let v = s.execute("edit.paste", &json!({"swatchConflict": {"Other": "add"}})).unwrap();
|
||||
assert_eq!(v["merged"], 1);
|
||||
assert!(s.execute("edit.paste", &json!({"swatchConflict": "keep"})).is_err());
|
||||
assert!(s.execute("edit.paste", &json!({"swatchConflict": {"Brand": 1}})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tints_and_their_tint_swatches_follow_the_base() {
|
||||
let mut s = session();
|
||||
s.execute("swatch.new", &json!({"name": "Ink", "color": {"c": 0, "m": 100, "y": 100, "k": 0}, "spot": true})).unwrap();
|
||||
let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
s.execute("paint.setFill", &json!({"ids": [r.0], "swatch": "Ink", "tint": 40})).unwrap();
|
||||
// The 40% tint swatch, and one of another tint the art doesn't use.
|
||||
assert_eq!(s.execute("swatch.new", &json!({})).unwrap()["name"], "Ink 40%");
|
||||
s.execute("paint.setFill", &json!({"ids": [r.0], "swatch": "Ink", "tint": 70})).unwrap();
|
||||
s.execute("swatch.new", &json!({})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"ids": [r.0], "swatch": "Ink", "tint": 40})).unwrap();
|
||||
copy(&mut s, &[r]);
|
||||
assert_eq!(s.clipboard.swatches.iter().map(|w| w.name.as_str()).collect::<Vec<_>>(), ["Ink", "Ink 40%"]);
|
||||
|
||||
// Into a document whose Ink is cyan: merged, the tint is 40% of the document's Ink.
|
||||
new_doc(&mut s);
|
||||
s.execute("swatch.new", &json!({"name": "Ink", "color": {"c": 100, "m": 0, "y": 0, "k": 0}, "spot": true})).unwrap();
|
||||
let v = s.execute("edit.pasteInPlace", &json!({})).unwrap();
|
||||
let cyan = Color::cmyk(1.0, 0.0, 0.0, 0.0);
|
||||
assert_eq!(fill(&s, ids_of(&v)[0]), linked(cyan.tinted(0.4), "Ink", 0.4));
|
||||
let tint = doc(&s).swatch("Ink 40%").unwrap();
|
||||
assert_eq!(tint.paint, linked(cyan.tinted(0.4), "Ink", 0.4), "the tint swatch links to the document's Ink");
|
||||
assert!(doc(&s).swatch("Ink 70%").is_none(), "unused tints stay behind");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
|
||||
// Added: the tint and its tint swatch follow the renamed base.
|
||||
let v = s.execute("edit.pasteInPlace", &json!({"swatchConflict": "add"})).unwrap();
|
||||
let magenta = Color::cmyk(0.0, 1.0, 1.0, 0.0);
|
||||
assert_eq!(fill(&s, ids_of(&v)[0]), linked(magenta.tinted(0.4), "Ink 2", 0.4));
|
||||
assert_eq!(doc(&s).swatch("Ink 40%").unwrap().paint, linked(magenta.tinted(0.4), "Ink 2", 0.4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_stops_stay_linked_to_their_swatches() {
|
||||
let mut s = session();
|
||||
s.execute("swatch.new", &json!({"name": "Sky", "color": "#3080ff", "global": true})).unwrap();
|
||||
let r = rect(&mut s, 0.0, 0.0, 50.0, 50.0);
|
||||
s.execute("paint.setFill", &json!({"ids": [r.0], "gradient": {"kind": "linear"}})).unwrap();
|
||||
s.edit("link stop", |d, _| {
|
||||
let n = d.node_mut(r).unwrap();
|
||||
let Paint::Gradient(mut g) = n.appearance.fill_paint() else { panic!() };
|
||||
g.gradient.stops[0].set_color(Color::from_hex("#3080ff").unwrap(), Some(("Sky".into(), 1.0)));
|
||||
n.appearance.set_fill(Paint::Gradient(g));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
copy(&mut s, &[r]);
|
||||
assert!(s.clipboard.swatches.iter().any(|w| w.name == "Sky"));
|
||||
new_doc(&mut s);
|
||||
s.execute("swatch.new", &json!({"name": "Sky", "color": "#000080", "global": true})).unwrap();
|
||||
let v = s.execute("edit.paste", &json!({"swatchConflict": "add"})).unwrap();
|
||||
let Paint::Gradient(g) = fill(&s, ids_of(&v)[0]) else { panic!() };
|
||||
assert_eq!((g.gradient.stops[0].swatch.as_deref(), g.gradient.stops[0].color), (Some("Sky 2"), hex("#3080ff")));
|
||||
assert!(doc(&s).swatch("Sky 2").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_swatches_come_along_with_their_stop_links() {
|
||||
let mut s = session();
|
||||
s.execute("swatch.new", &json!({"name": "Sky", "color": "#3080ff", "global": true})).unwrap();
|
||||
let glow = json!({"stops": [{"offset": 0, "swatch": "Sky"}, {"offset": 1, "color": "#ffffff"}]});
|
||||
s.execute("swatch.new", &json!({"name": "Glow", "gradient": glow})).unwrap();
|
||||
let r = rect(&mut s, 0.0, 0.0, 50.0, 50.0);
|
||||
s.execute("paint.setFill", &json!({"ids": [r.0], "swatch": "Glow"})).unwrap();
|
||||
copy(&mut s, &[r]);
|
||||
assert_eq!(s.clipboard.swatches.iter().map(|w| w.name.as_str()).collect::<Vec<_>>(), ["Sky", "Glow"]);
|
||||
// A document with another Glow and another Sky.
|
||||
new_doc(&mut s);
|
||||
s.execute("swatch.new", &json!({"name": "Sky", "color": "#000080", "global": true})).unwrap();
|
||||
s.execute("swatch.new", &json!({"name": "Glow", "gradient": {"stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#000000"}]}}))
|
||||
.unwrap();
|
||||
let v = s.execute("edit.paste", &json!({"swatchConflict": "add"})).unwrap();
|
||||
let Paint::Gradient(g) = fill(&s, ids_of(&v)[0]) else { panic!() };
|
||||
assert_eq!((g.swatch.as_deref(), g.gradient.stops[0].swatch.as_deref()), (Some("Glow 2"), Some("Sky 2")));
|
||||
let Paint::Gradient(sw) = doc(&s).swatch("Glow 2").unwrap().paint.clone() else { panic!() };
|
||||
assert_eq!(sw.gradient, g.gradient, "the added gradient swatch links its stop to the added Sky too");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lab_spot_colours_show_as_the_target_documents_spot_option_says() {
|
||||
let mut s = session();
|
||||
let lab = Color::lab(60.0, 50.0, 20.0);
|
||||
s.execute("swatch.new", &json!({"name": "Lab Ink", "color": {"l": 60, "a": 50, "b": 20}, "spot": true})).unwrap();
|
||||
let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
s.execute("paint.setFill", &json!({"ids": [r.0], "swatch": "Lab Ink"})).unwrap();
|
||||
assert_eq!(fill(&s, r).color(), Some(lab));
|
||||
copy(&mut s, &[r]);
|
||||
new_doc(&mut s);
|
||||
s.execute("swatch.spotOptions", &json!({"useLab": false})).unwrap();
|
||||
let id = ids_of(&s.execute("edit.paste", &json!({})).unwrap())[0];
|
||||
let cmyk = doc(&s).global_color("Lab Ink").unwrap();
|
||||
assert!(matches!(cmyk, Color::Cmyk { .. }));
|
||||
assert_eq!(fill(&s, id), linked(cmyk, "Lab Ink", 1.0), "the CMYK equivalent, still linked");
|
||||
assert_eq!(doc(&s).swatch("Lab Ink").unwrap().paint.color(), Some(lab), "the swatch keeps its Lab definition");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registration_is_never_copied_as_a_swatch() {
|
||||
let mut s = session();
|
||||
let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
s.edit("registration", |d, _| {
|
||||
d.node_mut(r).unwrap().appearance.set_fill(Paint::registration());
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
copy(&mut s, &[r]);
|
||||
assert!(s.clipboard.swatches.is_empty());
|
||||
new_doc(&mut s);
|
||||
let swatches = doc(&s).swatches.len();
|
||||
let v = s.execute("edit.paste", &json!({})).unwrap();
|
||||
assert_eq!(v["added"], 0);
|
||||
assert_eq!(doc(&s).swatches.len(), swatches);
|
||||
assert!(fill(&s, ids_of(&v)[0]).is_registration());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphic_style_links_follow_the_style_into_the_document() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 30.0, 30.0);
|
||||
s.execute("paint.setFill", &json!({"ids": [a.0], "color": "#ffcc00"})).unwrap();
|
||||
sel(&mut s, &[a]);
|
||||
s.execute("graphicStyle.new", &json!({"name": "Sun"})).unwrap();
|
||||
let src_id = node(&s, a).graphic_style.unwrap();
|
||||
copy(&mut s, &[a]);
|
||||
assert_eq!(s.clipboard.graphic_styles.iter().map(|g| g.name.as_str()).collect::<Vec<_>>(), ["Sun"]);
|
||||
|
||||
// A document with another "Sun" and styles taking up the source id.
|
||||
new_doc(&mut s);
|
||||
let b = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
s.execute("paint.setFill", &json!({"ids": [b.0], "color": "#0000ff"})).unwrap();
|
||||
sel(&mut s, &[b]);
|
||||
s.execute("graphicStyle.new", &json!({"name": "Sun"})).unwrap();
|
||||
let v = s.execute("edit.paste", &json!({})).unwrap();
|
||||
assert_eq!(renamed(&v), [("graphicStyle".to_string(), "Sun".to_string(), "Sun 2".to_string())]);
|
||||
let d = doc(&s);
|
||||
let linked_to = d.graphic_style_by_id(node(&s, ids_of(&v)[0]).graphic_style.unwrap()).unwrap().clone();
|
||||
assert_eq!(linked_to.name, "Sun 2");
|
||||
assert_eq!(linked_to.appearance.fill_paint(), Paint::solid(hex("#ffcc00")));
|
||||
assert!(d.graphic_styles.iter().filter(|g| g.id == linked_to.id).count() == 1, "a fresh id");
|
||||
// Pasting again finds the same style.
|
||||
let v = s.execute("edit.paste", &json!({})).unwrap();
|
||||
assert_eq!((v["added"].as_u64(), node(&s, ids_of(&v)[0]).graphic_style), (Some(0), Some(linked_to.id)));
|
||||
// Back in the source document the link is kept as it was.
|
||||
s.execute("document.activate", &json!({"index": 0})).unwrap();
|
||||
let v = s.execute("edit.paste", &json!({})).unwrap();
|
||||
assert_eq!(node(&s, ids_of(&v)[0]).graphic_style, Some(src_id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_removes_everything_the_paste_added() {
|
||||
let mut s = session();
|
||||
let r = swatched_rect(&mut s, "Brand", "#ff0000");
|
||||
sel(&mut s, &[r]);
|
||||
s.execute("symbol.new", &json!({"name": "Mark"})).unwrap();
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
new_doc(&mut s);
|
||||
let before = doc(&s).clone();
|
||||
let v = s.execute("edit.paste", &json!({})).unwrap();
|
||||
assert_eq!(v["added"], 2, "{v}");
|
||||
assert!(doc(&s).swatch("Brand").is_some() && !doc(&s).symbols.is_empty());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(*doc(&s) == before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styles_symbols_and_images_of_the_same_name_are_renamed() {
|
||||
let mut s = session();
|
||||
// A character style, a symbol and an image named like the target's own.
|
||||
let t = id_of(&s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Hello"})).unwrap());
|
||||
s.execute("charStyle.new", &json!({"name": "Emphasis", "attrs": {"size": 24.0}})).unwrap();
|
||||
s.execute("charStyle.apply", &json!({"name": "Emphasis", "id": t.0})).unwrap();
|
||||
let m = rect(&mut s, 200.0, 100.0, 30.0, 30.0);
|
||||
sel(&mut s, &[m]);
|
||||
let mark = id_of(&s.execute("symbol.new", &json!({"name": "Mark"})).unwrap());
|
||||
let e = rect(&mut s, 300.0, 100.0, 20.0, 20.0);
|
||||
sel(&mut s, &[e]);
|
||||
let img = id_of(&s.execute("object.rasterize", &json!({"ppi": 72})).unwrap());
|
||||
let key = |s: &Session, id: NodeId| match node(s, id).kind {
|
||||
NodeKind::Image(im) => im.key,
|
||||
_ => panic!("not an image"),
|
||||
};
|
||||
let src_key = key(&s, img);
|
||||
copy(&mut s, &[t, mark, img]);
|
||||
|
||||
new_doc(&mut s);
|
||||
s.execute("charStyle.new", &json!({"name": "Emphasis", "attrs": {"size": 8.0}})).unwrap();
|
||||
let m2 = ellipse(&mut s);
|
||||
sel(&mut s, &[m2]);
|
||||
s.execute("symbol.new", &json!({"name": "Mark"})).unwrap();
|
||||
// Another image under the same key.
|
||||
let e2 = ellipse(&mut s);
|
||||
sel(&mut s, &[e2]);
|
||||
let img2 = id_of(&s.execute("object.rasterize", &json!({"ppi": 72})).unwrap());
|
||||
let other_key = key(&s, img2);
|
||||
s.edit("same key", |d, _| {
|
||||
let blob = d.images.remove(&other_key).unwrap();
|
||||
d.images.insert(src_key.clone(), blob);
|
||||
let NodeKind::Image(im) = &mut d.node_mut(img2).unwrap().kind else { panic!() };
|
||||
im.key = src_key.clone();
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let v = s.execute("edit.pasteInPlace", &json!({})).unwrap();
|
||||
let kinds: Vec<(String, String)> = renamed(&v).into_iter().map(|(k, _, to)| (k, to)).collect();
|
||||
for want in [("charStyle", "Emphasis 2"), ("symbol", "Mark 2"), ("image", &*format!("{src_key} 2"))] {
|
||||
assert!(kinds.contains(&(want.0.to_string(), want.1.to_string())), "{want:?} in {kinds:?}");
|
||||
}
|
||||
let ids = ids_of(&v);
|
||||
let NodeKind::Text(tx) = node(&s, ids[0]).kind else { panic!() };
|
||||
assert_eq!(tx.runs[0].style.style_name.as_deref(), Some("Emphasis 2"));
|
||||
assert!(matches!(node(&s, ids[1]).kind, NodeKind::SymbolInstance { ref symbol, .. } if symbol == "Mark 2"));
|
||||
let new_key = key(&s, ids[2]);
|
||||
assert_ne!(new_key, src_key, "a different image under the same key is re-keyed");
|
||||
assert!(doc(&s).images[&new_key].bytes != doc(&s).images[&src_key].bytes);
|
||||
// Their own objects are untouched.
|
||||
assert_eq!(key(&s, img2), src_key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasting_again_reuses_what_an_earlier_paste_added() {
|
||||
let mut s = session();
|
||||
let r = swatched_rect(&mut s, "Brand", "#ff0000");
|
||||
// A character style whose fill links to Brand.
|
||||
let t = id_of(&s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Hi"})).unwrap());
|
||||
let red = serde_json::to_value(linked(hex("#ff0000"), "Brand", 1.0)).unwrap();
|
||||
s.execute("charStyle.new", &json!({"name": "Loud", "attrs": {"fill": red}})).unwrap();
|
||||
s.execute("charStyle.apply", &json!({"name": "Loud", "id": t.0})).unwrap();
|
||||
copy(&mut s, &[r, t]);
|
||||
new_doc(&mut s);
|
||||
s.execute("swatch.new", &json!({"name": "Brand", "color": "#0000ff", "global": true})).unwrap();
|
||||
let v = s.execute("edit.paste", &json!({"swatchConflict": "add"})).unwrap();
|
||||
assert_eq!(v["added"], 2, "Brand 2 and Loud: {v}");
|
||||
let style_fill = |s: &Session| serde_json::from_value::<Paint>(doc(s).char_styles[0].attrs["fill"].clone()).unwrap();
|
||||
assert_eq!(style_fill(&s), linked(hex("#ff0000"), "Brand 2", 1.0), "the style's colour follows the renamed swatch");
|
||||
// Again: Brand 2 and Loud are the pasted ones, nothing piles up.
|
||||
let v = s.execute("edit.paste", &json!({"swatchConflict": "add"})).unwrap();
|
||||
assert_eq!(v["added"], 0, "{v}");
|
||||
assert_eq!(renamed(&v), [("swatch".to_string(), "Brand".to_string(), "Brand 2".to_string())]);
|
||||
assert_eq!(fill(&s, ids_of(&v)[0]), linked(hex("#ff0000"), "Brand 2", 1.0));
|
||||
assert!(doc(&s).swatch("Brand 3").is_none() && doc(&s).char_styles.len() == 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brushes_travel_with_their_strokes() {
|
||||
let mut s = session();
|
||||
let name = s
|
||||
.execute("brush.new", &json!({"type": "calligraphic", "name": "Wide Nib", "params": {"angle": 30, "roundness": 20, "size": 9}}))
|
||||
.unwrap()["name"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
let r = rect(&mut s, 10.0, 10.0, 50.0, 50.0);
|
||||
s.execute("brush.apply", &json!({"name": name, "ids": [r.0]})).unwrap();
|
||||
copy(&mut s, &[r]);
|
||||
assert_eq!(s.clipboard.brushes.len(), 1);
|
||||
new_doc(&mut s);
|
||||
assert!(vectorcraft_brush::find(doc(&s), &name).is_none());
|
||||
s.execute("edit.paste", &json!({})).unwrap();
|
||||
assert!(vectorcraft_brush::find(doc(&s), &name).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasting_back_into_the_source_document_adds_nothing() {
|
||||
let mut s = session();
|
||||
let r = swatched_rect(&mut s, "Brand", "#ff0000");
|
||||
copy(&mut s, &[r]);
|
||||
// Redefining the swatch afterwards is no conflict in its own document.
|
||||
s.execute("swatch.edit", &json!({"name": "Brand", "color": "#00ff00"})).unwrap();
|
||||
assert_eq!(s.execute("clipboard.conflicts", &json!({})).unwrap()["swatches"], json!([]));
|
||||
let v = s.execute("edit.paste", &json!({})).unwrap();
|
||||
assert_eq!((v["added"].as_u64(), v["renamed"].as_array().map(Vec::len)), (Some(0), Some(0)));
|
||||
assert_eq!(fill(&s, ids_of(&v)[0]), linked(hex("#00ff00"), "Brand", 1.0), "it shows the swatch as it is now");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_without_formatting_leaves_text_styles_behind() {
|
||||
let mut s = session();
|
||||
let t = id_of(&s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Hello"})).unwrap());
|
||||
s.execute("charStyle.new", &json!({"name": "Emphasis", "attrs": {"size": 24.0}})).unwrap();
|
||||
s.execute("charStyle.apply", &json!({"name": "Emphasis", "id": t.0})).unwrap();
|
||||
copy(&mut s, &[t]);
|
||||
new_doc(&mut s);
|
||||
s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap();
|
||||
assert!(doc(&s).char_styles.is_empty());
|
||||
s.execute("edit.paste", &json!({})).unwrap();
|
||||
assert_eq!(doc(&s).char_styles.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipboard_svg_carries_its_own_patterns() {
|
||||
let mut s = session();
|
||||
let dot = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
sel(&mut s, &[dot]);
|
||||
s.execute("object.pattern.make", &json!({"name": "Dots", "width": 20, "height": 20})).unwrap();
|
||||
s.execute("object.pattern.done", &json!({})).unwrap();
|
||||
let r = rect(&mut s, 20.0, 20.0, 100.0, 80.0);
|
||||
s.execute("paint.setFill", &json!({"ids": [r.0], "swatch": "Dots"})).unwrap();
|
||||
copy(&mut s, &[r]);
|
||||
// Another document is active when the clipboard is published.
|
||||
new_doc(&mut s);
|
||||
let svg = s.clipboard_svg().unwrap();
|
||||
assert!(svg.contains("<pattern"), "{svg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_batch_puts_the_clipboard_back() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0);
|
||||
copy(&mut s, &[a]);
|
||||
let steps = json!([
|
||||
{"command": "select.set", "params": {"ids": [a.0, b.0]}},
|
||||
{"command": "edit.copy"},
|
||||
{"command": "no.such.command"},
|
||||
]);
|
||||
assert!(s.execute("command.batch", &json!({"commands": steps})).is_err());
|
||||
assert_eq!(s.clipboard.nodes.len(), 1);
|
||||
}
|
||||
|
||||
// ---------- M4.23: placement ----------
|
||||
|
||||
#[test]
|
||||
fn paste_centres_on_the_given_point() {
|
||||
let mut s = session();
|
||||
let r = rect(&mut s, 0.0, 0.0, 40.0, 20.0);
|
||||
copy(&mut s, &[r]);
|
||||
let v = s.execute("edit.paste", &json!({"center": [300, 200]})).unwrap();
|
||||
let b = doc(&s).bounds_of(&ids_of(&v), false).unwrap();
|
||||
assert!((b.center() - Point::new(300.0, 200.0)).hypot() < 1e-9, "{b:?}");
|
||||
// Without a centre, the offset applies.
|
||||
let v = s.execute("edit.paste", &json!({"dx": 5, "dy": 7})).unwrap();
|
||||
assert_eq!(doc(&s).bounds_of(&ids_of(&v), false).unwrap().origin(), Point::new(5.0, 7.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn front_and_back_without_a_selection_use_the_current_layers_ends() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0);
|
||||
copy(&mut s, &[a, b]);
|
||||
let layer = |s: &Session| doc(s).layers[0].children().unwrap().iter().map(|n| n.id).collect::<Vec<_>>();
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
let back = ids_of(&s.execute("edit.pasteInBack", &json!({})).unwrap());
|
||||
assert_eq!(&layer(&s)[..2], &back[..], "pasted at the bottom (index 0), in order");
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
let front = ids_of(&s.execute("edit.pasteInFront", &json!({})).unwrap());
|
||||
let l = layer(&s);
|
||||
assert_eq!(&l[l.len() - 2..], &front[..], "pasted on top, in order");
|
||||
// With a selection: right above / below it.
|
||||
sel(&mut s, &[a]);
|
||||
let above = ids_of(&s.execute("edit.pasteInFront", &json!({})).unwrap());
|
||||
let l = layer(&s);
|
||||
let i = l.iter().position(|id| *id == a).unwrap();
|
||||
assert_eq!(&l[i + 1..i + 3], &above[..]);
|
||||
sel(&mut s, &[a]);
|
||||
let below = ids_of(&s.execute("edit.pasteInBack", &json!({})).unwrap());
|
||||
let l = layer(&s);
|
||||
let i = l.iter().position(|id| *id == a).unwrap();
|
||||
assert_eq!(&l[i - 2..i], &below[..]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_on_all_artboards_keeps_the_offset_to_the_source_artboard() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 3})).unwrap();
|
||||
let boards: Vec<Rect> = doc(&s).artboards.iter().map(|a| a.rect).collect();
|
||||
// On artboard 2, 10 pt right and 15 pt down from its corner.
|
||||
let r = rect(&mut s, boards[1].x0 + 10.0, boards[1].y0 + 15.0, 20.0, 20.0);
|
||||
copy(&mut s, &[r]);
|
||||
assert_eq!(s.clipboard.source_artboard, Some(boards[1]));
|
||||
let ids = ids_of(&s.execute("edit.pasteOnAllArtboards", &json!({})).unwrap());
|
||||
assert_eq!(ids.len(), 3);
|
||||
for (id, b) in ids.iter().zip(&boards) {
|
||||
assert_eq!(doc(&s).bounds_of(&[*id], false).unwrap().origin(), Point::new(b.x0 + 10.0, b.y0 + 15.0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_remembers_layers_restores_the_source_layers() {
|
||||
let mut s = session();
|
||||
let first = doc(&s).layers[0].id;
|
||||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
let art = id_of(&s.execute("layer.new", &json!({"name": "Art"})).unwrap());
|
||||
let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0);
|
||||
copy(&mut s, &[a, b]);
|
||||
assert_eq!(s.clipboard.source_layers, [Some("Layer 1".to_string()), Some("Art".to_string())]);
|
||||
let other = id_of(&s.execute("layer.new", &json!({"name": "Other"})).unwrap());
|
||||
assert_eq!(s.execute("layer.pasteRemembersLayers", &json!({})).unwrap()["on"], true);
|
||||
assert!(doc(&s).paste_remembers_layers);
|
||||
assert_eq!(s.execute("document.inspect", &json!({})).unwrap()["pasteRemembersLayers"], true);
|
||||
let ids = ids_of(&s.execute("edit.paste", &json!({})).unwrap());
|
||||
let parents: Vec<_> = ids.iter().map(|id| doc(&s).parent_of(*id)).collect();
|
||||
assert_eq!(parents, [Some(first), Some(art)]);
|
||||
// In a document without those layers they are made.
|
||||
new_doc(&mut s);
|
||||
s.execute("layer.pasteRemembersLayers", &json!({"on": true})).unwrap();
|
||||
let ids = ids_of(&s.execute("edit.paste", &json!({})).unwrap());
|
||||
let names: Vec<_> = ids.iter().map(|id| doc(&s).node(doc(&s).parent_of(*id).unwrap()).unwrap().name.clone().unwrap()).collect();
|
||||
assert_eq!(names, ["Layer 1", "Art"]);
|
||||
assert_eq!(doc(&s).layers.len(), 2, "Layer 1 exists; Art is new");
|
||||
// The option is an undoable document setting.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(!doc(&s).paste_remembers_layers);
|
||||
// Off: everything goes to the current layer.
|
||||
s.execute("document.activate", &json!({"index": 0})).unwrap();
|
||||
assert_eq!(s.execute("layer.pasteRemembersLayers", &json!({"on": false})).unwrap()["on"], false);
|
||||
s.execute("layer.setCurrent", &json!({"id": other.0})).unwrap();
|
||||
let ids = ids_of(&s.execute("edit.paste", &json!({})).unwrap());
|
||||
assert!(ids.iter().all(|id| doc(&s).parent_of(*id) == Some(other)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_remembers_layers_survives_save_and_old_files_load_without_it() {
|
||||
let mut s = session();
|
||||
s.execute("layer.pasteRemembersLayers", &json!({"on": true})).unwrap();
|
||||
let bytes = vectorcraft_format::save(doc(&s), false);
|
||||
assert!(vectorcraft_format::load(&bytes).unwrap().paste_remembers_layers);
|
||||
let v: Value = serde_json::from_slice(&vectorcraft_format::save(&Document::new(10.0, 10.0), false)).unwrap();
|
||||
assert!(v.get("paste_remembers_layers").is_none(), "off is not written");
|
||||
assert!(!vectorcraft_format::load(v.to_string().as_bytes()).unwrap().paste_remembers_layers);
|
||||
}
|
||||
@@ -437,7 +437,7 @@ fn paste_without_formatting_resets_text_style() {
|
||||
assert_eq!(tt.first_style().size, 12.0);
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
// Clipboard keeps its formatting.
|
||||
let NodeKind::Text(ct) = &s.clipboard[0].kind else { panic!() };
|
||||
let NodeKind::Text(ct) = &s.clipboard.nodes[0].kind else { panic!() };
|
||||
assert_eq!(ct.first_style().size, 40.0);
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ pub mod save_swatch_library;
|
||||
mod shapes;
|
||||
pub mod spot_colors;
|
||||
pub(crate) mod svg_options;
|
||||
pub mod swatch_conflict;
|
||||
pub mod swatch_options;
|
||||
pub mod tile_edge_color;
|
||||
mod tools;
|
||||
@@ -188,6 +189,7 @@ registry! {
|
||||
RasterOptions: ["pngOptions", "jpgOptions", "webpOptions"] => png_options::SPEC,
|
||||
ExportAs: ["exportAs"] => export_as::SPEC,
|
||||
ImportPdf: [import_pdf::KIND] => import_pdf::SPEC,
|
||||
SwatchConflict: [swatch_conflict::KIND] => swatch_conflict::SPEC,
|
||||
}
|
||||
|
||||
/// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered).
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
//! Swatch Conflict: pasting objects whose global or spot swatch has a name the document gives
|
||||
//! another colour asks, swatch by swatch, whether to merge (the objects take the document's
|
||||
//! swatch) or add (the pasted swatch comes in under a new name); Apply to All answers the rest the
|
||||
//! same way. OK on the last one runs the paste with `swatchConflict`; Cancel pastes nothing.
|
||||
//!
|
||||
//! Fields: `conflicts` (as `clipboard.conflicts` lists them: `[{name, document, clipboard, spot}]`),
|
||||
//! `index` (the one asked about), `choice` (`merge` or `add`), `applyToAll`, `choices` (the
|
||||
//! answers so far, by swatch name), `__command` and `__params` (the paste to run).
|
||||
|
||||
use egui::vec2;
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
|
||||
use super::swatch_options::{grid, label};
|
||||
use super::{DialogResult, DialogSpec};
|
||||
use crate::state::Dialog;
|
||||
use crate::{VectorcraftApp, widgets};
|
||||
|
||||
/// The dialog kind of Swatch Conflict.
|
||||
pub const KIND: &str = "swatchConflict";
|
||||
|
||||
pub(super) const SPEC: DialogSpec =
|
||||
DialogSpec { heading: |_| "Swatch Conflict".into(), body, confirm, min_width: 380.0, max_width: Some(440.0), ..DialogSpec::FORM };
|
||||
|
||||
/// The choices: (`choice` value, label, what it does).
|
||||
const CHOICES: [(&str, &str, &str); 2] = [
|
||||
("merge", "Merge Swatches", "Use the document's swatch: pasted colors take its color."),
|
||||
("add", "Add Swatches", "Keep the pasted color as a new swatch with a numbered name."),
|
||||
];
|
||||
|
||||
/// Before running paste `command` with `params`: when the pasted objects' swatches conflict with
|
||||
/// the document's and `params` don't say how to resolve them (`swatchConflict`), open the dialog
|
||||
/// instead and answer `{dialog}`.
|
||||
pub fn ask(app: &mut VectorcraftApp, command: &str, params: &Value) -> Option<DialogResult> {
|
||||
if params.get("swatchConflict").is_some() {
|
||||
return None;
|
||||
}
|
||||
let found = app.session.execute("clipboard.conflicts", &json!({})).ok()?;
|
||||
let conflicts = found["swatches"].as_array().filter(|a| !a.is_empty())?.clone();
|
||||
let fields = json!({
|
||||
"conflicts": conflicts, "index": 0, "choice": "merge", "applyToAll": false, "choices": {},
|
||||
"__command": command, "__params": params,
|
||||
});
|
||||
app.ui.dialog = Some(Dialog::new(KIND, fields));
|
||||
Some(Ok(json!({ "dialog": KIND })))
|
||||
}
|
||||
|
||||
fn conflicts(d: &Dialog) -> &[Value] {
|
||||
d.fields.get("conflicts").and_then(Value::as_array).map_or(&[], Vec::as_slice)
|
||||
}
|
||||
|
||||
fn index(d: &Dialog) -> usize {
|
||||
d.fields.get("index").and_then(Value::as_u64).unwrap_or(0) as usize
|
||||
}
|
||||
|
||||
/// A chip of a `#rrggbb` colour with a caption under it.
|
||||
fn chip(ui: &mut egui::Ui, hex: Option<&str>, caption: &str) {
|
||||
ui.vertical(|ui| {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(56.0, 28.0), egui::Sense::hover());
|
||||
let paint = hex.and_then(Color::from_hex).map_or(Paint::None, Paint::solid);
|
||||
widgets::paint_chip(ui, r, &paint);
|
||||
label(ui, caption);
|
||||
});
|
||||
}
|
||||
|
||||
fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
let i = index(d);
|
||||
let (count, Some(c)) = (conflicts(d).len(), conflicts(d).get(i).cloned()) else { return true };
|
||||
let name = c["name"].as_str().unwrap_or_default();
|
||||
ui.label(format!("This document already has a swatch named \u{201c}{name}\u{201d} with another color."));
|
||||
if count > 1 {
|
||||
label(ui, &format!("Conflict {} of {count}", i + 1));
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
ui.horizontal(|ui| {
|
||||
chip(ui, c["document"].as_str(), "Document");
|
||||
ui.add_space(12.0);
|
||||
chip(ui, c["clipboard"].as_str(), "Pasted");
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
let choice = d.str("choice");
|
||||
grid(ui, |ui| {
|
||||
for (k, (value, text, help)) in CHOICES.into_iter().enumerate() {
|
||||
label(ui, if k == 0 { "Options:" } else { "" });
|
||||
ui.vertical(|ui| {
|
||||
if widgets::radio(ui, text, choice == value, true) {
|
||||
d.fields.insert("choice".into(), json!(value));
|
||||
}
|
||||
ui.indent(text, |ui| label(ui, help));
|
||||
});
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
let all = d.bool("applyToAll");
|
||||
if widgets::check(ui, "Apply to All", all, count > 1) {
|
||||
d.fields.insert("applyToAll".into(), json!(!all));
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Record the answer; ask about the next conflict, or paste once every one is answered.
|
||||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> DialogResult {
|
||||
let choice = d.str("choice");
|
||||
if !matches!(choice.as_str(), "merge" | "add") {
|
||||
return Err(format!("choice must be \"merge\" or \"add\", not `{choice}`"));
|
||||
}
|
||||
let list = conflicts(d);
|
||||
let i = index(d);
|
||||
let all = d.bool("applyToAll");
|
||||
let mut choices: Map<String, Value> = d.fields.get("choices").and_then(Value::as_object).cloned().unwrap_or_default();
|
||||
let answered = if all { list.get(i..).unwrap_or_default() } else { list.get(i..=i).unwrap_or_default() };
|
||||
for c in answered {
|
||||
if let Some(n) = c["name"].as_str() {
|
||||
choices.insert(n.to_string(), json!(choice));
|
||||
}
|
||||
}
|
||||
if !all && i + 1 < list.len() {
|
||||
let mut next = d.clone();
|
||||
next.fields.insert("index".into(), json!(i + 1));
|
||||
next.fields.insert("choices".into(), Value::Object(choices));
|
||||
app.ui.dialog = Some(next);
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
let mut params = d.fields.get("__params").cloned().filter(Value::is_object).unwrap_or_else(|| json!({}));
|
||||
params["swatchConflict"] = Value::Object(choices);
|
||||
app.ui.dialog = None;
|
||||
app.run(&d.str("__command"), params)
|
||||
}
|
||||
@@ -142,7 +142,7 @@ fn placing_a_pdf_page_asks_for_the_page_and_crop_box() {
|
||||
frame(&mut app);
|
||||
set(&mut app, "page", json!(2));
|
||||
set(&mut app, "cropTo", json!("art"));
|
||||
let clipboard = app.session.clipboard.len();
|
||||
let clipboard = app.session.clipboard.clone();
|
||||
let undo = app.session.doc().unwrap().history.undo.len();
|
||||
confirm(&mut app).unwrap();
|
||||
assert!(app.ui.dialog.is_none());
|
||||
@@ -150,7 +150,7 @@ fn placing_a_pdf_page_asks_for_the_page_and_crop_box() {
|
||||
assert!(clip, "clipped to the art box");
|
||||
assert_eq!((b.width(), b.height()), (50.0, 60.0));
|
||||
assert_eq!(b.center(), vectorcraft_geom::Point::new(100.0, 120.0), "where `at` asked");
|
||||
assert_eq!(app.session.clipboard.len(), clipboard, "the clipboard is untouched");
|
||||
assert_eq!(app.session.clipboard, clipboard, "the clipboard is untouched");
|
||||
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
|
||||
// With a page, no dialog; Bounding Box places the art itself.
|
||||
place(&mut app, &bytes, json!({"page": 3, "crop": "bounding"})).unwrap();
|
||||
|
||||
@@ -41,7 +41,12 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
}
|
||||
x = r.right();
|
||||
}
|
||||
icons::paint(ui, "menu", egui::Rect::from_center_size(strip.right_center() - vec2(14.0, 0.0), vec2(14.0, 14.0)), t.text_dim);
|
||||
let menu_id = match app.ui.dock_tab {
|
||||
DockTab::Properties => "properties",
|
||||
DockTab::Layers => "layers",
|
||||
DockTab::Libraries => "libraries",
|
||||
};
|
||||
panels::panel_menu(app, ui, menu_id, egui::Rect::from_center_size(strip.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0)));
|
||||
egui::Frame::NONE.inner_margin(egui::Margin { left: 12, right: 10, top: 10, bottom: 8 }).show(ui, |ui| match app.ui.dock_tab {
|
||||
DockTab::Properties => {
|
||||
egui::ScrollArea::vertical().id_salt("props").auto_shrink([false, false]).show(ui, |ui| panels::properties::show(app, ui));
|
||||
|
||||
@@ -33,6 +33,8 @@ pub mod unsaved;
|
||||
pub mod widgets;
|
||||
pub mod workspaces;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_clipboard;
|
||||
#[cfg(test)]
|
||||
mod tests_docsetup;
|
||||
#[cfg(test)]
|
||||
@@ -174,8 +176,16 @@ pub struct VectorcraftApp {
|
||||
pub custom_titlebar: bool,
|
||||
/// File → Place: picked files, the place cursor's thumbnails, the Control bar's image details.
|
||||
pub place: place::PlaceState,
|
||||
/// The Paste commands can paste from the system clipboard alone: it holds SVG while the
|
||||
/// internal clipboard is empty. It enables the Paste menu items.
|
||||
pub(crate) system_svg: bool,
|
||||
/// When `system_svg` was last checked (app time, s; at most once per frame).
|
||||
system_svg_at: f64,
|
||||
}
|
||||
|
||||
/// Seconds between two looks at the system clipboard for [`VectorcraftApp::system_svg`].
|
||||
const SYSTEM_CLIPBOARD_POLL: f64 = 0.25;
|
||||
|
||||
impl VectorcraftApp {
|
||||
pub fn new(session: Session, services: Services) -> Self {
|
||||
let views = session.documents().iter().map(|_| View::default()).collect();
|
||||
@@ -214,6 +224,8 @@ impl VectorcraftApp {
|
||||
hover_doc: None,
|
||||
custom_titlebar: false,
|
||||
place: Default::default(),
|
||||
system_svg: false,
|
||||
system_svg_at: f64::NEG_INFINITY,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,8 +270,20 @@ impl VectorcraftApp {
|
||||
if let Some(r) = menus::run_ui_command(self, id, ¶ms) {
|
||||
return r;
|
||||
}
|
||||
let mut params = params;
|
||||
if id.starts_with("edit.paste") {
|
||||
self.adopt_system_clipboard();
|
||||
// Paste (also without formatting) goes to the centre of the view.
|
||||
if matches!(id, "edit.paste" | "edit.pasteWithoutFormatting")
|
||||
&& ["center", "dx", "dy"].iter().all(|k| params.get(k).is_none())
|
||||
&& let Some(c) = self.view().filter(|v| v.fitted).map(|v| v.center)
|
||||
&& let Some(p) = params.as_object_mut()
|
||||
{
|
||||
p.insert("center".into(), serde_json::json!([c.x, c.y]));
|
||||
}
|
||||
if let Some(r) = dialogs::swatch_conflict::ask(self, id, ¶ms) {
|
||||
return r;
|
||||
}
|
||||
}
|
||||
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 {
|
||||
@@ -471,6 +495,19 @@ impl VectorcraftApp {
|
||||
}
|
||||
self.last_time = now;
|
||||
self.sync_views();
|
||||
// Read the system clipboard only when that alone decides whether Paste is enabled, and at
|
||||
// most a few times a second (opening it locks it against other apps on some systems).
|
||||
if !(0.0..SYSTEM_CLIPBOARD_POLL).contains(&(now - self.system_svg_at)) {
|
||||
self.system_svg_at = now;
|
||||
self.system_svg = self.session.clipboard.is_empty()
|
||||
&& self.session.active().is_some()
|
||||
&& self
|
||||
.services
|
||||
.clipboard_read
|
||||
.as_mut()
|
||||
.and_then(|read| read())
|
||||
.is_some_and(|t| vectorcraft_engine::cmd::clipboard::looks_like_svg(&t));
|
||||
}
|
||||
// The window's close button (or the system quitting the app) asks about unsaved documents.
|
||||
if ctx.input(|i| i.viewport().close_requested()) && unsaved::any_dirty(self) {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
|
||||
|
||||
@@ -899,7 +899,8 @@ pub fn shortcut_of(id: &str) -> Option<&'static str> {
|
||||
/// Is a command currently enabled?
|
||||
pub fn enabled(app: &VectorcraftApp, id: &str) -> bool {
|
||||
if let Some(c) = vectorcraft_engine::find_command(id) {
|
||||
return (c.enabled)(&app.session).is_ok();
|
||||
// SVG on the system clipboard can be pasted with an empty internal clipboard.
|
||||
return (c.enabled)(&app.session).is_ok() || app.system_svg && id.starts_with("edit.paste");
|
||||
}
|
||||
match id {
|
||||
"file.save"
|
||||
|
||||
@@ -397,6 +397,31 @@ fn thumb(ui: &Ui, n: &Node, r: egui::Rect) {
|
||||
});
|
||||
}
|
||||
|
||||
/// The Layers panel's (≡) menu.
|
||||
pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
const ITEMS: [(&str, &str); 6] = [
|
||||
("New Layer…", "layer.new"),
|
||||
("New Sublayer…", "layer.newSublayer"),
|
||||
("Duplicate Layer", "layer.duplicate"),
|
||||
("Delete Layer", "layer.delete"),
|
||||
("Make/Release Clipping Mask", "layer.clippingMask.toggle"),
|
||||
("Collect in New Layer", "layer.collectInNew"),
|
||||
];
|
||||
for (i, (label, cmd)) in ITEMS.into_iter().enumerate() {
|
||||
if i == 4 {
|
||||
ui.separator();
|
||||
}
|
||||
if widgets::menu_item(ui, label, crate::menus::enabled(app, cmd), false) {
|
||||
app.run(cmd, json!({})).ok();
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
let remembers = app.session.active().is_some_and(|d| d.doc.paste_remembers_layers);
|
||||
if widgets::menu_item(ui, "Paste Remembers Layers", app.session.active().is_some(), remembers) {
|
||||
app.run("layer.pasteRemembersLayers", json!({"on": !remembers})).ok();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
@@ -135,6 +135,7 @@ pub fn panel_menu_items(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) {
|
||||
flattener_preview::ID => flattener_preview::menu(app, ui),
|
||||
"attributes" => attributes::menu(app, ui),
|
||||
"colorThemes" => color_themes::menu(app, ui),
|
||||
"layers" => layers::menu(app, ui),
|
||||
_ => {
|
||||
ui.add_enabled(false, egui::Button::new("No options").frame(false));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
//! Paste in the app: the Swatch Conflict dialog, pasting at the view centre, the Paste menu items
|
||||
//! enabled by SVG on the system clipboard, and the Layers panel's Paste Remembers Layers.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::NodeId;
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
use crate::{Services, VectorcraftApp, dialogs, menus};
|
||||
|
||||
fn run(app: &mut VectorcraftApp, id: &str, p: Value) -> Value {
|
||||
app.run(id, p).unwrap_or_else(|e| panic!("{id}: {e}"))
|
||||
}
|
||||
|
||||
fn rect(app: &mut VectorcraftApp) -> NodeId {
|
||||
NodeId(run(app, "shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 20}))["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn copy(app: &mut VectorcraftApp, id: NodeId) {
|
||||
run(app, "select.set", json!({"ids": [id.0]}));
|
||||
run(app, "edit.copy", json!({}));
|
||||
}
|
||||
|
||||
/// Objects on the active document's first layer.
|
||||
fn objects(app: &VectorcraftApp) -> Vec<NodeId> {
|
||||
app.session.active().unwrap().doc.layers[0].children().unwrap().iter().map(|n| n.id).collect()
|
||||
}
|
||||
|
||||
fn swatch(app: &mut VectorcraftApp, name: &str, hex: &str) {
|
||||
run(app, "swatch.new", json!({"name": name, "color": hex, "global": true}));
|
||||
}
|
||||
|
||||
/// A rectangle filled with global swatch Brand (red) and stroked with Ink (black) copied from one
|
||||
/// document, and a second document (active) whose Brand is blue and Ink green.
|
||||
fn conflicting() -> VectorcraftApp {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
run(&mut app, "file.new", json!({"width": 200, "height": 200}));
|
||||
swatch(&mut app, "Brand", "#ff0000");
|
||||
swatch(&mut app, "Ink", "#000000");
|
||||
let r = rect(&mut app);
|
||||
run(&mut app, "paint.setFill", json!({"ids": [r.0], "swatch": "Brand"}));
|
||||
run(&mut app, "paint.setStroke", json!({"ids": [r.0], "swatch": "Ink"}));
|
||||
copy(&mut app, r);
|
||||
run(&mut app, "file.new", json!({"width": 200, "height": 200}));
|
||||
swatch(&mut app, "Brand", "#0000ff");
|
||||
swatch(&mut app, "Ink", "#00ff00");
|
||||
app
|
||||
}
|
||||
|
||||
fn paints(app: &VectorcraftApp) -> (Paint, Paint) {
|
||||
let st = app.session.active().unwrap();
|
||||
let n = st.doc.node(objects(app)[0]).unwrap();
|
||||
(n.appearance.fill_paint(), n.appearance.stroke().unwrap().paint.clone())
|
||||
}
|
||||
|
||||
fn linked(hex: &str, name: &str) -> Paint {
|
||||
Paint::Solid { color: Color::from_hex(hex).unwrap(), swatch: Some(name.into()), tint: 1.0 }
|
||||
}
|
||||
|
||||
fn set(app: &mut VectorcraftApp, k: &str, v: Value) {
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert(k.into(), v);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_swatch_conflict_dialog_asks_about_each_swatch_then_pastes() {
|
||||
let mut app = conflicting();
|
||||
assert_eq!(run(&mut app, "edit.paste", json!({})), json!({"dialog": dialogs::swatch_conflict::KIND}));
|
||||
assert!(objects(&app).is_empty(), "nothing is pasted before the answer");
|
||||
// It draws headlessly, naming the swatch it asks about.
|
||||
let text = crate::tests_labels::painted_text(&mut app, |app, ui| dialogs::show(app, ui.ctx()));
|
||||
for label in ["Swatch Conflict", "\u{201c}Brand\u{201d}", "Conflict 1 of 2", "Merge Swatches", "Add Swatches", "Apply to All"] {
|
||||
assert!(text.contains(label), "{label} in {text}");
|
||||
}
|
||||
// Brand: add; Ink: merge.
|
||||
set(&mut app, "choice", json!("add"));
|
||||
dialogs::confirm(&mut app).unwrap();
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().fields["index"], 1, "the next conflict");
|
||||
assert!(objects(&app).is_empty());
|
||||
set(&mut app, "choice", json!("merge"));
|
||||
dialogs::confirm(&mut app).unwrap();
|
||||
assert!(app.ui.dialog.is_none());
|
||||
assert_eq!(paints(&app), (linked("#ff0000", "Brand 2"), linked("#00ff00", "Ink")));
|
||||
// One undo step takes the object and the added swatch away.
|
||||
run(&mut app, "edit.undo", json!({}));
|
||||
assert!(objects(&app).is_empty() && app.session.active().unwrap().doc.swatch("Brand 2").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_to_all_answers_the_rest_and_cancel_pastes_nothing() {
|
||||
let mut app = conflicting();
|
||||
run(&mut app, "edit.paste", json!({}));
|
||||
dialogs::cancel(&mut app);
|
||||
assert!(app.ui.dialog.is_none() && objects(&app).is_empty());
|
||||
run(&mut app, "edit.pasteInPlace", json!({}));
|
||||
set(&mut app, "choice", json!("add"));
|
||||
set(&mut app, "applyToAll", json!(true));
|
||||
dialogs::confirm(&mut app).unwrap();
|
||||
assert!(app.ui.dialog.is_none());
|
||||
assert_eq!(paints(&app), (linked("#ff0000", "Brand 2"), linked("#000000", "Ink 2")));
|
||||
// An answer given with the command needs no dialog (agents).
|
||||
let v = run(&mut app, "edit.pasteInPlace", json!({"swatchConflict": "merge"}));
|
||||
assert_eq!(v["merged"], 2);
|
||||
assert!(app.ui.dialog.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_goes_to_the_centre_of_the_view() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
run(&mut app, "file.new", json!({"width": 400, "height": 300}));
|
||||
let r = rect(&mut app);
|
||||
copy(&mut app, r);
|
||||
let v = app.view_mut().unwrap();
|
||||
(v.center, v.fitted) = (Point::new(250.0, 180.0), true);
|
||||
let ids: Vec<NodeId> = run(&mut app, "edit.paste", json!({}))["ids"].as_array().unwrap().iter().map(|i| NodeId(i.as_u64().unwrap())).collect();
|
||||
let b = app.session.active().unwrap().doc.bounds_of(&ids, true).unwrap();
|
||||
assert!((b.center() - Point::new(250.0, 180.0)).hypot() < 1e-6, "{b:?}");
|
||||
}
|
||||
|
||||
const SVG: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 10"><rect width="5" height="5"/><circle cx="15" cy="5" r="5"/></svg>"##;
|
||||
|
||||
/// An app whose system clipboard holds `text`, with a document, after one frame.
|
||||
fn with_system_clipboard(text: &'static str) -> VectorcraftApp {
|
||||
let services = Services { clipboard_read: Some(Box::new(move || Some(text.to_string()))), ..Default::default() };
|
||||
let mut app = VectorcraftApp::new(Session::new(), services);
|
||||
run(&mut app, "file.new", json!({"width": 200, "height": 200}));
|
||||
assert!(!menus::enabled(&app, "edit.paste"), "not checked before a frame");
|
||||
let ctx = egui::Context::default();
|
||||
ctx.run_ui(Default::default(), |ui| app.logic(ui.ctx())).textures_delta.clear();
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_menu_items_are_enabled_by_svg_on_the_system_clipboard() {
|
||||
let mut app = with_system_clipboard(SVG);
|
||||
assert!(app.session.clipboard.is_empty());
|
||||
for id in ["edit.paste", "edit.pasteInFront", "edit.pasteInBack", "edit.pasteInPlace", "edit.pasteOnAllArtboards", "edit.pasteWithoutFormatting"]
|
||||
{
|
||||
assert!(menus::enabled(&app, id), "{id}");
|
||||
}
|
||||
let entry = menus::menu_entries(&app).into_iter().find(|e| e.command.as_deref() == Some("edit.paste")).unwrap();
|
||||
assert!(entry.enabled);
|
||||
// Choosing it pastes the SVG's objects.
|
||||
menus::invoke(&mut app, "edit.paste", json!({}));
|
||||
assert_eq!(objects(&app).len(), 2);
|
||||
// Plain text is not something to paste.
|
||||
let app = with_system_clipboard("plain text");
|
||||
assert!(!menus::enabled(&app, "edit.paste"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_layers_panel_menu_toggles_paste_remembers_layers() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
run(&mut app, "file.new", json!({"width": 200, "height": 200}));
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let frame = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
|
||||
let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| crate::panels::layers::menu(app, ui));
|
||||
out.textures_delta.clear();
|
||||
let mut texts = vec![];
|
||||
for c in &out.shapes {
|
||||
if let egui::Shape::Text(t) = &c.shape {
|
||||
texts.push((t.galley.text().to_string(), t.visual_bounding_rect()));
|
||||
}
|
||||
}
|
||||
texts
|
||||
};
|
||||
let item = |texts: &[(String, egui::Rect)]| texts.iter().find(|(t, _)| t.ends_with("Paste Remembers Layers")).cloned().unwrap();
|
||||
let (label, r) = item(&frame(&mut app, vec![]));
|
||||
assert!(!label.starts_with('✓'));
|
||||
let button =
|
||||
|pressed| egui::Event::PointerButton { pos: r.center(), button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
for e in [egui::Event::PointerMoved(r.center()), button(true), button(false)] {
|
||||
frame(&mut app, vec![e]);
|
||||
}
|
||||
assert!(app.session.active().unwrap().doc.paste_remembers_layers);
|
||||
assert!(item(&frame(&mut app, vec![])).0.starts_with('✓'), "checked");
|
||||
}
|
||||
@@ -226,3 +226,16 @@ Video, Art & Illustration, Branding, Social), `advanced` (Advanced Options open)
|
||||
changing a field afterwards makes the settings custom (`preset` becomes ""). More Settings is the `newDocumentMore`
|
||||
dialog over the same fields (its Profile is `category`). `ui.dialog.confirm` runs `file.new` with the fields (with
|
||||
`previewMode: "pixel"` the app turns Pixel Preview on); on an error the dialog stays open.
|
||||
|
||||
Swatch Conflict: through the app, when the pasted objects' global or spot swatches conflict with the document's
|
||||
(see `clipboard.conflicts`) and the params have no `swatchConflict`, every Paste command opens the `swatchConflict`
|
||||
dialog instead and answers `{dialog: "swatchConflict"}`. Fields: `conflicts` (`[{name, document, clipboard,
|
||||
spot}]`), `index` (the conflict asked about), `choice` (`merge` or `add`), `applyToAll` and `choices` (the answers so
|
||||
far). `ui.dialog.confirm` records the answer and asks about the next conflict (with `applyToAll`, answers the rest
|
||||
the same); the last one runs the paste with `swatchConflict`. `ui.dialog.cancel` pastes nothing.
|
||||
|
||||
Paste placement: through the app, `edit.paste` and `edit.pasteWithoutFormatting` without `center`, `dx` or `dy`
|
||||
paste at the centre of the view. The Paste menu items are enabled while the system clipboard holds SVG, even with
|
||||
nothing copied in the app (looked at up to four times a second; `ui.menu.list` shows it). The Layers panel menu (≡ on
|
||||
the dock's tab strip while Layers shows) lists the layer commands and Paste Remembers Layers
|
||||
(`layer.pasteRemembersLayers`, checked when on).
|
||||
+29
@@ -863,3 +863,32 @@ with `pointer_gesture` (a click places at 100% with the top-left corner there, a
|
||||
{"name":"run_command","arguments":{"command":"file.place.queue","params":{"paths":["/tmp/a.png","/tmp/b.pdf"]}}}
|
||||
{"name":"pointer_gesture","arguments":{"events":[{"kind":"down","x":40,"y":40},{"kind":"up","x":40,"y":40}]}}
|
||||
```
|
||||
|
||||
## Copy and paste between documents
|
||||
|
||||
`edit.copy` keeps the copied objects with the document resources they use: image blobs, symbols, patterns, global
|
||||
and spot swatches (with the tint swatches of the tints used; never the built-in [Registration] swatch), the gradient
|
||||
swatches their gradients came from, graphic styles the objects are linked to, character and paragraph styles and
|
||||
brushes. Width profiles live in the preferences, so they need no copying. Every `edit.paste*` command (also `edit.pasteWithoutFormatting`, which leaves
|
||||
text styles behind) brings them into the active document in the same undo step: an identical resource is reused,
|
||||
a missing one added, and one of the same name that differs is added renamed ("Mark 2"; a copy an earlier paste
|
||||
added is reused), which the pasted objects follow. Linked colours show the document's swatch at their tint (a Lab
|
||||
spot colour as its Spot Colors option says). The result reports `{ids, added, merged, renamed: [{kind, from,
|
||||
to}]}`.
|
||||
|
||||
A global or spot swatch whose name the document gives another colour is a conflict: `clipboard.conflicts` lists
|
||||
them, and `swatchConflict` answers: `"merge"` (the default: the objects take the document's swatch), `"add"` (the
|
||||
pasted swatch comes in renamed) or one answer per name. Pasting back into the document the objects came from uses
|
||||
its resources as they are now and raises no conflict.
|
||||
|
||||
Placement: `edit.paste {center}` centres the objects on a point (the app passes the view centre), else offsets them
|
||||
by `dx`/`dy` (the Paste Offset preference). With nothing selected, `edit.pasteInFront` / `edit.pasteInBack` put them
|
||||
on top / at the bottom of the current layer. `edit.pasteOnAllArtboards` keeps their offset to the artboard they were
|
||||
copied from. `layer.pasteRemembersLayers {on?}` (a document option, `document.inspect` → `pasteRemembersLayers`)
|
||||
pastes objects back into the layers they came from, by name, making missing ones.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"clipboard.conflicts","params":{}}}
|
||||
{"name":"run_command","arguments":{"command":"edit.paste","params":{"center":[300,200],"swatchConflict":{"Brand":"add"}}}}
|
||||
{"name":"run_command","arguments":{"command":"layer.pasteRemembersLayers","params":{"on":true}}}
|
||||
```
|
||||
Reference in new issue
Block a user