From 21dbcf6307e90be1d49ac4f438aaad74dcd0f3c2 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 20:16:54 -0800 Subject: [PATCH 1/6] Links: list and manage linked art in symbols, graph designs, patterns and masks by id Linked images and placed documents kept only in symbols, graph designs, pattern swatches or opacity masks now have ids of their own. links.list lists them after the layers' own, one row per stored object, with an owner naming what holds them; links.check, the missing, modified and updated files reported on open, the link watch and links.findFiles report them by id; relink (All Instances included), update, embed, placementOptions, info and image.info act on them. links.goTo on one clears the selection and returns onCanvas: false with its owner, and the Links panel says the link has no place on the canvas. - Document::visit_stored, stored_node and stored_node_mut find objects in every stored art. fix_next_id counts their ids and gives an object there whose id is 0, past the id range or already used a new one, updating Objects on Path references to renamed children. - Copies of masked objects give the mask art new ids, and symbols and patterns that placed art brings from another document take ids of the document they join. - The Links panel picks such rows in the panel itself, drops the pick when the canvas selection changes, and draws their thumbnails from a lookup made once per list refresh. Select by Link Status leaves them out, as it cannot select them. --- crates/doc/src/document.rs | 9 +- crates/doc/src/document_guides.rs | 7 + crates/doc/src/lib.rs | 2 + crates/doc/src/stored_art.rs | 376 ++++++++++++++++++ crates/engine/src/cmd/linkfiles.rs | 12 +- crates/engine/src/cmd/links.rs | 203 ++++++---- crates/engine/src/cmd/place.rs | 3 +- crates/engine/src/cmd/place/adopt.rs | 3 + crates/engine/src/cmd/place/document.rs | 8 +- crates/engine/src/tests/links.rs | 204 +++++++++- crates/engine/src/tests/linkspanel.rs | 2 +- crates/engine/src/tests/linkwatch.rs | 24 ++ crates/engine/src/tests/place.rs | 27 ++ .../ui-egui/src/addons/select/link_status.rs | 17 +- .../src/addons/select/link_status/tests.rs | 21 + crates/ui-egui/src/i18n/ca.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hi.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/panels/links.rs | 131 +++++- crates/ui-egui/src/panels/tests_links.rs | 46 +++ docs/mcp.md | 20 +- docs/target-app-parity.md | 1 + 41 files changed, 1031 insertions(+), 107 deletions(-) create mode 100644 crates/doc/src/stored_art.rs diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 708d0aee03..057a9df6a6 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -286,7 +286,9 @@ impl Document { self.next_id = self.next_id.max(id); } - /// Ensure `next_id` is above every id in the tree (after deserializing foreign data). + /// Ensure `next_id` is above every id in the tree (after deserializing foreign data), then give + /// the objects outside the layer tree (opacity-mask art, symbols, graph designs and patterns) + /// whose id is missing or already taken a new one, so each stored object has an id of its own. pub fn fix_next_id(&mut self) { // Ids from [`MAX_NEXT_ID`] up (a damaged or hand-made file: #1718) are left out, as is a // counter up there: new ids start under them and never reach them, where counting on from @@ -297,13 +299,12 @@ impl Document { max = max.max(id); } }; - for l in &self.layers { - l.walk(&mut |n| see(n.id.0)); - } + self.visit_stored(|n, _| see(n.id.0)); self.slices.iter().for_each(|s| see(s.id.0)); self.assets.iter().for_each(|a| see(a.id)); let counter = Some(self.next_id).filter(|n| *n <= MAX_NEXT_ID).unwrap_or(0); self.next_id = counter.max(max.saturating_add(1)); + self.renumber_stored_duplicates(); } /// Find a node anywhere in the tree. diff --git a/crates/doc/src/document_guides.rs b/crates/doc/src/document_guides.rs index 4ec7aab1f6..14d0404bfc 100644 --- a/crates/doc/src/document_guides.rs +++ b/crates/doc/src/document_guides.rs @@ -554,6 +554,13 @@ impl Document { t.cached_baselines.clear(); t.cached_ink = None; } + // Opacity-mask art is stored art too: the copy's gets ids of its own. + if let Some(art) = n.extras().mask.as_ref().map(|m| Arc::clone(&m.art)) { + let art = Arc::new(self.reid(&art)); + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) { + m.art = art; + } + } let on_path = n.extras().objects_on_path.is_some(); let mut renamed = std::collections::HashMap::new(); if let Some(ch) = n.children_mut() { diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 3c4bcc20a5..03c7da06c3 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -141,6 +141,7 @@ mod graphic_style; mod guides; mod limits; mod records; +mod stored_art; mod units; pub use artboard::{ARTBOARD_GAP, Artboard, ArtboardSide, LockedArtboards, RetiredArtboardId, VIDEO_PIXEL_ASPECT_RANGE, VIDEO_SAFE_INSETS}; @@ -153,6 +154,7 @@ pub use guides::{GridPrefs, Guide, RulerMode}; use limits::MAX_NEXT_ID; pub use limits::{DocError, FORMAT_VERSION, MAX_COORD}; pub use records::{ImageBlob, MaskEdit, NewArtIds, SavedSelection, SavedView, SvgFilter, Symbol, SymbolEdit}; +pub use stored_art::{StoredPlace, StoredRoot}; pub use units::{ColorMode, Unit, parse_number}; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/doc/src/stored_art.rs b/crates/doc/src/stored_art.rs new file mode 100644 index 0000000000..e623e0ed0e --- /dev/null +++ b/crates/doc/src/stored_art.rs @@ -0,0 +1,376 @@ +//! Every object a document stores, wherever it is kept: in the layers, in opacity-mask art, and in +//! the art of symbols, graph designs and pattern swatches. [`Document::node`] finds objects in the +//! layer tree only; the lookups here find the others by their ids too, which +//! [`Document::fix_next_id`] keeps unique across all of them. + +use std::collections::HashSet; +use std::sync::Arc; + +use super::limits::MAX_NEXT_ID; +use super::{Document, Node, NodeId}; + +/// The art a stored object belongs to. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StoredRoot { + /// Layer `index` of [`Document::layers`]. + Layer(usize), + /// Symbol `index` of [`Document::symbols`]. + Symbol(usize), + /// Graph design `index` of [`Document::graph_designs`]. + GraphDesign(usize), + /// Object `art` of pattern swatch `pattern` of [`Document::patterns`]. + Pattern { pattern: usize, art: usize }, +} + +/// Where a stored object is: its root and, inside opacity-mask art, the object that mask belongs +/// to (the innermost one when masks hold masks). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct StoredPlace { + pub root: StoredRoot, + pub mask_of: Option, +} + +impl StoredPlace { + /// Is the object in the layer tree itself, where [`Document::node`] finds it and the canvas + /// draws it as itself? + pub fn in_layers(self) -> bool { + matches!(self.root, StoredRoot::Layer(_)) && self.mask_of.is_none() + } +} + +/// One step down from a node: to its child at an index, or to its opacity-mask art. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Step { + Child(usize), + Mask, +} + +/// `n` and everything below it with its place, the opacity-mask art right after the object it +/// masks, then the children. +fn visit<'a>(n: &'a Arc, place: StoredPlace, f: &mut dyn FnMut(&'a Arc, StoredPlace)) { + f(n, place); + if let Some(m) = &n.extras().mask { + visit(&m.art, StoredPlace { mask_of: Some(n.id), ..place }, f); + } + for c in n.children().into_iter().flatten() { + visit(c, place, f); + } +} + +/// The steps from `n` to the first node in [`visit`] order with id `id`, and the object whose mask +/// holds it last on the way. +fn find(n: &Node, id: NodeId, steps: &mut Vec, mask_of: &mut Option) -> bool { + if n.id == id { + return true; + } + if let Some(m) = &n.extras().mask { + steps.push(Step::Mask); + let before = *mask_of; + *mask_of = Some(n.id); + if find(&m.art, id, steps, mask_of) { + return true; + } + *mask_of = before; + steps.pop(); + } + for (i, c) in n.children().into_iter().flatten().enumerate() { + steps.push(Step::Child(i)); + if find(c, id, steps, mask_of) { + return true; + } + steps.pop(); + } + false +} + +/// The node `steps` below `n`. +fn at<'a>(mut n: &'a Arc, steps: &[Step]) -> Option<&'a Arc> { + for s in steps { + n = match s { + Step::Mask => &n.extras().mask.as_ref()?.art, + Step::Child(i) => n.children()?.get(*i)?, + }; + } + Some(n) +} + +/// The node `steps` below `n` to change, the nodes on the way copied when shared. +fn at_mut<'a>(mut n: &'a mut Arc, steps: &[Step]) -> Option<&'a mut Node> { + for s in steps { + let node = Arc::make_mut(n); + n = match s { + Step::Mask => &mut node.extras_if_any()?.mask.as_mut()?.art, + Step::Child(i) => node.children_mut()?.get_mut(*i)?, + }; + } + Some(Arc::make_mut(n)) +} + +/// Gives every node from `n` down that `fresh` names a new id from `next`, counting nodes in +/// [`visit`] order from `*count`; only the nodes on the way to one are copied. Each part below is +/// renumbered through a second handle, so a new pointer marks a change. +fn renumber(n: &mut Arc, fresh: &HashSet, count: &mut usize, next: &mut u64) { + let own = fresh.contains(count); + *count += 1; + let mask = n.extras().mask.as_ref().and_then(|m| { + let mut art = Arc::clone(&m.art); + renumber(&mut art, fresh, count, next); + (!Arc::ptr_eq(&art, &m.art)).then_some(art) + }); + let mut slots = vec![]; + // Children's new ids by their old ones, for an Objects on Path group, which names its path and + // members by id. An id another child keeps still names that child. + let mut renamed = std::collections::HashMap::new(); + let mut kept = HashSet::new(); + for (i, c) in n.children().into_iter().flatten().enumerate() { + let mut new = Arc::clone(c); + renumber(&mut new, fresh, count, next); + if new.id == c.id { + kept.insert(c.id); + } else { + renamed.insert(c.id, new.id); + } + if !Arc::ptr_eq(&new, c) { + slots.push((i, new)); + } + } + renamed.retain(|old, _| !kept.contains(old)); + if !own && mask.is_none() && slots.is_empty() { + return; + } + let node = Arc::make_mut(n); + if own { + node.id = NodeId(*next); + *next += 1; + } + if let (Some(art), Some(m)) = (mask, node.extras_if_any().and_then(|e| e.mask.as_mut())) { + m.art = art; + } + if let Some(children) = node.children_mut() { + for (i, c) in slots { + if let Some(slot) = children.get_mut(i) { + *slot = c; + } + } + } + if !renamed.is_empty() + && let Some(on_path) = node.extras_if_any().and_then(|e| e.objects_on_path.as_mut()) + { + on_path.rename(&renamed); + } +} + +impl Document { + /// The roots of the art kept outside the layers, each with its [`StoredRoot`]. + fn definition_roots(&self) -> impl Iterator, StoredRoot)> { + let symbols = self.symbols.iter().enumerate().map(|(i, s)| (&s.art, StoredRoot::Symbol(i))); + let designs = self.graph_designs.iter().enumerate().map(|(i, g)| (&g.art, StoredRoot::GraphDesign(i))); + let patterns = self + .patterns + .iter() + .enumerate() + .flat_map(|(p, def)| def.art.iter().enumerate().map(move |(a, n)| (n, StoredRoot::Pattern { pattern: p, art: a }))); + symbols.chain(designs).chain(patterns) + } + + /// Every root of stored art: the layers, then the art of symbols, graph designs and patterns. + fn stored_roots(&self) -> impl Iterator, StoredRoot)> { + self.layers.iter().enumerate().map(|(i, l)| (l, StoredRoot::Layer(i))).chain(self.definition_roots()) + } + + /// The root `root` names, to change. + fn stored_root_mut(&mut self, root: StoredRoot) -> Option<&mut Arc> { + match root { + StoredRoot::Layer(i) => self.layers.get_mut(i), + StoredRoot::Symbol(i) => self.symbols.get_mut(i).map(|s| &mut s.art), + StoredRoot::GraphDesign(i) => self.graph_designs.get_mut(i).map(|g| &mut g.art), + StoredRoot::Pattern { pattern, art } => self.patterns.get_mut(pattern)?.art.get_mut(art), + } + } + + /// Visit every stored object with its place: the layer tree with the opacity-mask art of its + /// objects, then the art of symbols, graph designs and pattern swatches (their definitions, + /// once each, never the copies instances and tiles draw). + pub fn visit_stored<'a>(&'a self, mut f: impl FnMut(&'a Node, StoredPlace)) { + self.visit_stored_arcs(|n, place| f(n, place)); + } + + /// [`Self::visit_stored`] with each object's shared handle, which keeps its identity from one + /// look to the next while the object is unchanged (for caches such as thumbnails). + pub fn visit_stored_arcs<'a>(&'a self, mut f: impl FnMut(&'a Arc, StoredPlace)) { + for (root, r) in self.stored_roots() { + visit(root, StoredPlace { root: r, mask_of: None }, &mut f); + } + } + + /// Where object `id` is stored: the root, and the steps from it. + fn stored_path(&self, id: NodeId) -> Option<(StoredPlace, Vec)> { + if let Some(path) = self.index_path(id) { + let (first, rest) = path.split_first()?; + return Some((StoredPlace { root: StoredRoot::Layer(*first), mask_of: None }, rest.iter().map(|i| Step::Child(*i)).collect())); + } + // Not in the layer tree: in mask art (in the layers too) or in a definition's art. + for (root, r) in self.stored_roots() { + let (mut steps, mut mask_of) = (vec![], None); + if find(root, id, &mut steps, &mut mask_of) { + return Some((StoredPlace { root: r, mask_of }, steps)); + } + } + None + } + + /// Stored object `id` (see [`Self::visit_stored`]) with its place: one in the layer tree as + /// [`Self::node`] finds it, else the first in visiting order. + pub fn stored_node(&self, id: NodeId) -> Option<(&Arc, StoredPlace)> { + let (place, steps) = self.stored_path(id)?; + let root = self.stored_roots().find(|(_, r)| *r == place.root)?.0; + Some((at(root, &steps)?, place)) + } + + /// [`Self::stored_node`] to change; the nodes on the way are copied when shared. + pub fn stored_node_mut(&mut self, id: NodeId) -> Option<&mut Node> { + let (place, steps) = self.stored_path(id)?; + at_mut(self.stored_root_mut(place.root)?, &steps) + } + + /// Gives the objects outside the layer tree (in opacity-mask art and in the art of symbols, + /// graph designs and patterns) whose id is 0, past the id range, or already taken by an object + /// visited before them (the layer tree's own objects first) a new id, so every stored object + /// can be named by its id. Returns how many changed; the id counter then stands above them. + pub(crate) fn renumber_stored_duplicates(&mut self) -> usize { + let mut seen: HashSet = HashSet::new(); + for l in &self.layers { + l.walk(&mut |n| { + seen.insert(n.id); + }); + } + self.slices.iter().for_each(|s| { + seen.insert(s.id); + }); + // Per root, the visiting indices of the nodes that take a new id. + let mut fresh: Vec<(StoredRoot, HashSet)> = vec![]; + for (root, r) in self.stored_roots() { + let (mut i, mut here) = (0, HashSet::new()); + visit(root, StoredPlace { root: r, mask_of: None }, &mut |n, place| { + if !place.in_layers() && (n.id.0 == 0 || n.id.0 >= MAX_NEXT_ID || !seen.insert(n.id)) { + here.insert(i); + } + i += 1; + }); + if !here.is_empty() { + fresh.push((r, here)); + } + } + let total = fresh.iter().map(|(_, f)| f.len()).sum(); + if total == 0 { + return 0; + } + // No id left to give: the objects keep theirs rather than share one given out later. + if self.peek_next_id().saturating_add(total as u64) > MAX_NEXT_ID { + return 0; + } + let mut next = self.peek_next_id(); + for (r, f) in fresh { + if let Some(root) = self.stored_root_mut(r) { + renumber(root, &f, &mut 0, &mut next); + } + } + self.raise_next_id(next); + total + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{GraphDesign, OpacityMask, PatternDef, Symbol}; + use vectorcraft_geom::Rect; + + fn rect(id: u64) -> Node { + Node::path(NodeId(id), vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), crate::Appearance::default_art()) + } + + fn masked(id: u64, art: Node) -> Node { + rect(id).with_mask(Some(Box::new(OpacityMask::new(art, true)))) + } + + /// A document with object 10 in its layer, mask art 11 on it, and one object in a symbol, + /// a graph design and a pattern (ids `defs`). + fn doc(defs: [u64; 3]) -> Document { + let mut d = Document::new(100.0, 100.0); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, masked(10, rect(11))).unwrap(); + d.symbols.push(Symbol { name: "S".into(), art: Arc::new(rect(defs[0])), dynamic: false }); + d.graph_designs.push(GraphDesign { name: "G".into(), art: Arc::new(rect(defs[1])) }); + d.patterns.push(PatternDef::new("P", vec![Arc::new(rect(defs[2]))])); + d.raise_next_id(20); + d + } + + #[test] + fn stored_objects_are_found_in_every_art() { + let d = doc([12, 13, 14]); + let place = |id: u64| d.stored_node(NodeId(id)).map(|(n, p)| (n.id.0, p)); + assert!(place(10).unwrap().1.in_layers()); + assert_eq!(place(11).unwrap().1, StoredPlace { root: StoredRoot::Layer(0), mask_of: Some(NodeId(10)) }); + assert_eq!(place(12).unwrap().1.root, StoredRoot::Symbol(0)); + assert_eq!(place(13).unwrap().1.root, StoredRoot::GraphDesign(0)); + assert_eq!(place(14).unwrap().1.root, StoredRoot::Pattern { pattern: 0, art: 0 }); + assert!(d.node(NodeId(11)).is_none() && d.node(NodeId(12)).is_none(), "the layer lookup stays the layer tree's"); + assert!(d.stored_node(NodeId(99)).is_none()); + let mut seen = vec![]; + d.visit_stored(|n, _| seen.push(n.id.0)); + assert_eq!(seen, [d.layers[0].id.0, 10, 11, 12, 13, 14]); + } + + #[test] + fn stored_objects_change_by_id() { + let mut d = doc([12, 13, 14]); + let before = d.clone(); + d.stored_node_mut(NodeId(13)).unwrap().name = Some("changed".into()); + assert_eq!(d.graph_designs[0].art.name.as_deref(), Some("changed")); + assert!(Arc::ptr_eq(&d.symbols[0].art, &before.symbols[0].art), "other art is not copied"); + assert_eq!(before.graph_designs[0].art.name, None, "the earlier document is unchanged"); + d.stored_node_mut(NodeId(11)).unwrap().name = Some("mask".into()); + assert_eq!(d.stored_node(NodeId(11)).unwrap().0.name.as_deref(), Some("mask")); + } + + #[test] + fn fix_next_id_gives_nested_duplicates_new_ids() { + // The symbol's object repeats the layer's object, the design's has no id, and the pattern's + // repeats the mask art's. + let mut d = doc([10, 0, 11]); + d.next_id = 1; + d.fix_next_id(); + let mut ids = vec![]; + d.visit_stored(|n, _| ids.push(n.id.0)); + let unique: HashSet = ids.iter().copied().collect(); + assert_eq!(unique.len(), ids.len(), "{ids:?}"); + assert!(!ids.contains(&0)); + assert_eq!(&ids[..3], &[d.layers[0].id.0, 10, 11], "the layer tree and the first mask art keep theirs"); + assert!(ids.iter().all(|id| *id < d.peek_next_id()), "{ids:?} {}", d.peek_next_id()); + // Ids already unique are kept, and a second pass changes nothing. + let again = d.clone(); + assert_eq!(d.renumber_stored_duplicates(), 0); + assert_eq!(d, again); + } + + #[test] + fn renumbered_objects_on_path_keep_their_path_and_members() { + // A symbol's Objects on Path group whose path repeats the layer's object 10 and whose + // member has no id. + let mut d = doc([12, 13, 14]); + let (path, member) = (rect(10), rect(0)); + let rec = crate::objects_on_path::ObjectsOnPath::new(path.id, &[&member], crate::objects_on_path::CENTER, 0.0); + let mut g = Node::new(NodeId(15), crate::NodeKind::Group { children: vec![Arc::new(path), Arc::new(member)], clip: false }); + g.extras_mut().objects_on_path = Some(Box::new(rec)); + d.symbols[0].art = Arc::new(g); + d.fix_next_id(); + let art = &d.symbols[0].art; + let kids: Vec = art.children().unwrap().iter().map(|c| c.id).collect(); + assert!(kids.iter().all(|id| id.0 != 10 && id.0 != 0), "{kids:?}"); + let on_path = art.extras().objects_on_path.as_ref().unwrap(); + assert_eq!(on_path.path, kids[0]); + assert_eq!(on_path.members.iter().map(|m| m.id).collect::>(), [kids[1]]); + } +} diff --git a/crates/engine/src/cmd/linkfiles.rs b/crates/engine/src/cmd/linkfiles.rs index f2ce9d4d12..99295e5c79 100644 --- a/crates/engine/src/cmd/linkfiles.rs +++ b/crates/engine/src/cmd/linkfiles.rs @@ -13,7 +13,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::NodeKind; use super::findfiles::{self, Limits, Visitor}; -use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, bool_or, checked_ids_param, cmd}; +use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, bool_or, cmd, ids_param}; /// The most linked files one search looks for. const MAX_LINKS: usize = 1_000; @@ -143,8 +143,14 @@ fn find(s: &mut Session, p: &Value) -> Result { let ids = match p.get("ids") { None | Some(Value::Null) => None, Some(_) => { - let ids = checked_ids_param(s, p, "ids", C)?; - let linkable = |id: &NodeId| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Image(_) | NodeKind::PlacedDocument(_))); + // Images and placed documents in opacity-mask art and in the art of symbols, graph + // designs and patterns are named by their ids too (`links.list`). + let ids = ids_param(p, "ids", C)?.unwrap_or_default(); + let kind = |id: &NodeId| st.doc.stored_node(*id).map(|(n, _)| &n.kind); + if let Some(value) = ids.iter().find(|id| kind(id).is_none()).map(|id| id.0) { + return Err(bad(C, format!("no such object id {value}"))); + } + let linkable = |id: &NodeId| matches!(kind(id), Some(NodeKind::Image(_) | NodeKind::PlacedDocument(_))); if let Some(id) = ids.iter().find(|id| !linkable(id)) { return Err(bad(C, format!("object {} is not an image or a linked file", id.0))); } diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index d79320c304..3bfd4a1217 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -30,6 +30,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::links::{Align, Preserve, hash_bytes}; use vectorcraft_doc::{ Appearance, DataValue, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind, PageBox, PlacedDocument, PlacementOptions, + StoredPlace, StoredRoot, }; use vectorcraft_geom::{Affine, PathData, Rect, shapes}; @@ -43,7 +44,7 @@ pub fn specs() -> Vec { "Check Links", [], None, - "{ids?: [image or placed document ids] (default: every linked image and placed document)} where each linked file is and whether it changed since it was read → {links: [{name, path?, status: ok|modified|missing, ids, found?: path (found away from its path: relative to the document, or by name in its folder), preview: true (showing the low-resolution preview, not the file), refused?: why the path is not followed (another computer, a device or a URL: it counts as missing)}], missing, modified}; under automation read roots, path and found are left out when the file they name is outside the roots", + "{ids?: [image or placed document ids] (default: every linked image and placed document, in the layers and in opacity-mask art, symbols, graph designs and patterns)} where each linked file is and whether it changed since it was read → {links: [{name, path?, status: ok|modified|missing, ids, found?: path (found away from its path: relative to the document, or by name in its folder), preview: true (showing the low-resolution preview, not the file), refused?: why the path is not followed (another computer, a device or a URL: it counts as missing)}], missing, modified}; under automation read roots, path and found are left out when the file they name is outside the roots", has_doc, check ), @@ -52,7 +53,7 @@ pub fn specs() -> Vec { "Update Links", [], None, - "{ids?: [image or placed document ids] (default: the linked images whose file was modified or is showing its preview, and placed documents whose file was modified)} read their linked files again; each keeps its bounds. The images and placed documents inside symbols, graph designs, patterns and opacity masks that show the same content are read again too (by default also those only that art shows); they have no ids to list. One undo step → {updated: [ids], missing: [ids]}", + "{ids?: [image or placed document ids] (default: the linked images whose file was modified or is showing its preview, and placed documents whose file was modified)} read their linked files again; each keeps its bounds. Images and placed documents inside symbols, graph designs, patterns and opacity masks have ids of their own, as links.list gives them; those that show the same content as one read again are read again too. One undo step → {updated: [ids], missing: [ids]}", has_doc, update ), @@ -70,7 +71,7 @@ pub fn specs() -> Vec { "Relink", [], None, - "{ids?: [image or placed document ids] (default: the selected ones; with folder and nothing selected, every missing link), path | folder, scope?: selected (default: ids) | all (ids and every other object in the layers linked to the same file as one of them) | missingInFolder (with path: ids, then every other missing link whose file name is in path's folder, each to the file of its name there; names not there are in notFound; with copy, only images are looked for) | each (with paths), paths?: [{ids, path}] (with scope each, in place of ids, path and folder: each link to its own file; an id in two entries is an error), copy?: false (with path or paths, for images of a saved document: copy the file into the document's folder and link to the copy; the names Name.png, Name 2.png … Name 99.png are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is used), preserve?: transforms|bounds|fileDimensions|fit|fill (how the new file takes each image's place this time, as links.placementOptions describes; default: each image's Placement Options; fit keeps the new file's aspect ratio inside the old bounds: Replace)} link images (or placed documents, each showing the same artboard or page of the new file) to the file at path, or each to the file of its link's name in folder; embedded images become linked; each keeps its bounds. One undo step → {relinked: [ids], notFound: [file names], copied?: [{from, to}], kept?: [{from, to}] (with copy; to is the file name alone when the document's folder is outside the automation read roots)}", + "{ids?: [image or placed document ids] (default: the selected ones; with folder and nothing selected, every missing link), path | folder, scope?: selected (default: ids) | all (ids and every other object linked to the same file as one of them, in the layers, opacity-mask art, symbols, graph designs and patterns) | missingInFolder (with path: ids, then every other missing link whose file name is in path's folder, each to the file of its name there; names not there are in notFound; with copy, only images are looked for) | each (with paths), paths?: [{ids, path}] (with scope each, in place of ids, path and folder: each link to its own file; an id in two entries is an error), copy?: false (with path or paths, for images of a saved document: copy the file into the document's folder and link to the copy; the names Name.png, Name 2.png … Name 99.png are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is used), preserve?: transforms|bounds|fileDimensions|fit|fill (how the new file takes each image's place this time, as links.placementOptions describes; default: each image's Placement Options; fit keeps the new file's aspect ratio inside the old bounds: Replace)} link images (or placed documents, each showing the same artboard or page of the new file) to the file at path, or each to the file of its link's name in folder; embedded images become linked; each keeps its bounds. One undo step → {relinked: [ids], notFound: [file names], copied?: [{from, to}], kept?: [{from, to}] (with copy; to is the file name alone when the document's folder is outside the automation read roots)}", has_doc, relink ), @@ -79,7 +80,7 @@ pub fn specs() -> Vec { "Links", [], None, - "{show?: all (default)|missing|modified|embedded, sort?: name|kind (file format)|status (missing, modified, ok, embedded); default: stacking order; top first} every image object and placed document in the layers, as the Links panel lists them → {links: [{id, name, linked, status: ok|modified|missing|embedded, format, pixelWidth, pixelHeight, path?, found?: path (found away from its path), page?, preview?: true (showing the saved preview), note?: said of an image past the decode allowance (shown at reduced size, or too large to draw), document?: true (a placed document: pageWidth and pageHeight in pt instead of pixels)}], missing, modified, embedded}; under automation read roots, path and found are left out when the file they name is outside the roots", + "{show?: all (default)|missing|modified|embedded, sort?: name|kind (file format)|status (missing, modified, ok, embedded); default: stacking order; top first} every image object and placed document the document stores, as the Links panel lists them: those in the layers, then those in opacity-mask art and in the art of symbols, graph designs and patterns (one row per stored object, never one per symbol instance or pattern tile) → {links: [{id, name, linked, status: ok|modified|missing|embedded, format, pixelWidth, pixelHeight, path?, found?: path (found away from its path), page?, preview?: true (showing the saved preview), note?: said of an image past the decode allowance (shown at reduced size, or too large to draw), document?: true (a placed document: pageWidth and pageHeight in pt instead of pixels), owner?: {kind: layers|symbol|graphDesign|pattern, name? (of the symbol, graph design or pattern), maskOf?: id (in the opacity-mask art of that object)} (an object outside the layer tree)}], missing, modified, embedded}; under automation read roots, path and found are left out when the file they name is outside the roots", has_doc, list ), @@ -88,7 +89,7 @@ pub fn specs() -> Vec { "Go To Link", [], None, - "{id?} select image or placed document `id` (default: the first selected one); the Links panel scrolls it into view → {id, bounds: [x0, y0, x1, y1]}", + "{id?} select image or placed document `id` (default: the first selected one); the Links panel scrolls it into view → {id, bounds: [x0, y0, x1, y1], onCanvas: true}; one in opacity-mask art or in the art of a symbol, graph design or pattern has no place of its own on the canvas: the selection is cleared → {id, onCanvas: false, owner} (owner as links.list gives it)", has_doc, go_to ), @@ -156,7 +157,8 @@ impl Status { struct Group { link: LinkInfo, key: String, - /// The objects in the layers (images in symbols and patterns share the content, not an id). + /// The stored objects showing it: in the layers, in opacity-mask art and in the art of symbols, + /// graph designs and patterns ([`Document::visit_stored`]), each once. ids: Vec, /// Placed documents: what they show. document: Option, @@ -175,13 +177,16 @@ fn linked(n: &Node) -> Option<(&LinkInfo, &str, Option)> { } } -/// Every linked file of `d` with the ids of the objects showing it (for [`crate::link_watch`]). +/// Every linked file of `d` with the ids of the objects showing it, wherever they are stored (for +/// [`crate::link_watch`]). pub(crate) fn linked_groups(d: &Document) -> Vec<(String, Vec)> { groups(d, None).into_iter().filter(|g| !g.ids.is_empty()).map(|g| (g.link.path, ids_json(&g.ids))).collect() } -/// The linked images and placed documents of `d` by file and content: those in the layers (only `ids` when -/// given), then, without `ids`, the ones only symbols and patterns show. +/// The linked images and placed documents of `d` by file and content, wherever they are stored +/// (only `ids` when given), in [`Document::visit_stored`] order. An object whose id an object +/// visited before it has (art being edited on a layer and its definition) counts once, by its +/// first place; without `ids` its content is still grouped. fn groups(d: &Document, ids: Option<&[NodeId]>) -> Vec { let mut out: Vec = vec![]; let mut index: BTreeMap<(String, String, bool), usize> = BTreeMap::new(); @@ -196,24 +201,48 @@ fn groups(d: &Document, ids: Option<&[NodeId]>) -> Vec { g.ids.push(id); } }; - d.walk(|n| { - if let Some((l, key, document)) = linked(n) - && wanted.as_ref().is_none_or(|ids| ids.contains(&n.id)) - { + let mut seen = std::collections::HashSet::new(); + d.visit_stored(|n, _| { + let Some((l, key, document)) = linked(n) else { return }; + // Id 0 names no object. + let first = n.id.0 != 0 && seen.insert(n.id); + if first && wanted.as_ref().is_none_or(|ids| ids.contains(&n.id)) { add(l, key, document, Some(n.id)); + } else if ids.is_none() { + add(l, key, document, None); } }); - if ids.is_none() { - d.visit_images(|_, im| { - if let Some(l) = &im.link { - add(l, &im.key, None, None); - } - }); - d.visit_placed(|_, p| add(&p.link, &p.key, Some((p.bounding, p.page_box)), None)); - } out } +/// Stored object `id` of `d` ([`Document::stored_node`]). +fn stored(d: &Document, id: NodeId) -> Option<&Node> { + d.stored_node(id).map(|(n, _)| &**n) +} + +/// Of `ids`, the objects of `d` outside the layer tree (where [`Document::node`] finds none). +fn nested_ids(d: &Document, ids: &[NodeId]) -> std::collections::HashSet { + ids.iter().copied().filter(|id| d.node(*id).is_none()).collect() +} + +/// `links.list`'s `owner` of an object stored at `place` of `d`, when it is not in the layer tree +/// itself: `{kind: layers|symbol|graphDesign|pattern, name?, maskOf?}`. +fn owner_json(d: &Document, place: StoredPlace) -> Option { + if place.in_layers() { + return None; + } + let mut owner = match place.root { + StoredRoot::Layer(_) => json!({ "kind": "layers" }), + StoredRoot::Symbol(i) => json!({ "kind": "symbol", "name": d.symbols.get(i).map(|s| s.name.as_str()) }), + StoredRoot::GraphDesign(i) => json!({ "kind": "graphDesign", "name": d.graph_designs.get(i).map(|g| g.name.as_str()) }), + StoredRoot::Pattern { pattern, .. } => json!({ "kind": "pattern", "name": d.patterns.get(pattern).map(|p| p.name.as_str()) }), + }; + if let Some(m) = place.mask_of { + owner["maskOf"] = json!(m.0); + } + Some(owner) +} + /// A linked file where it was found. struct Found { path: String, @@ -455,8 +484,8 @@ pub(crate) fn full_linked_images<'a>(d: &'a Document, base: LinkBase) -> (Cow<'a } /// The linked files of the open document `st` that `links.findFiles` looks for, each with the -/// objects in the layers that show it: the files of objects `ids` whatever their status, else the -/// files that can't be found. +/// stored objects that show it: the files of objects `ids` whatever their status, else the files +/// that can't be found. pub(crate) fn files_to_find(st: &crate::DocState, ids: Option<&[NodeId]>) -> Vec<(LinkInfo, Vec)> { groups(&st.doc, ids) .into_iter() @@ -587,7 +616,7 @@ fn clip_shape((frame, room): Clip) -> PathData { } /// Remakes the linked objects outside the layer tree (in symbols, graph designs, patterns and -/// opacity masks, where [`Document::node`] finds no ids) that `pick` holds for. +/// opacity masks, where [`Document::node`] does not find them) that `pick` holds for. struct Nested<'a> { pick: &'a dyn Fn(&Node) -> bool, /// The object an object becomes, and the clip it takes ([`placed_xf`]). @@ -703,10 +732,10 @@ impl Nested<'_> { } } -/// Placed documents `ids` show `src`, read from the file `link` points at, each placed as its -/// Placement Options say (`force`: as that Preserve choice says, whatever they say); with `also` -/// (a file's path and the content shown), so do the placed documents outside the layer tree that -/// show that content. +/// Placed documents `ids` (in the layers or outside them) show `src`, read from the file `link` +/// points at, each placed as its Placement Options say (`force`: as that Preserve choice says, +/// whatever they say); with `also` (a file's path and the content shown), so do the placed +/// documents outside the layer tree that show that content. fn install_document( d: &mut Document, ids: &[NodeId], @@ -721,8 +750,10 @@ fn install_document( let o = PlacementOptions { preserve: force.unwrap_or(p.placement.preserve), ..p.placement }; placed_xf(p.xf, (p.width, p.height), (1.0, 1.0), (w, h), (1.0, 1.0), o) }; - if let Some(also) = also { - let pick = |n: &Node| shows(n, also, true); + let nested = nested_ids(d, ids); + if also.is_some() || !nested.is_empty() { + let pick = + |n: &Node| matches!(n.kind, NodeKind::PlacedDocument(_)) && (nested.contains(&n.id) || also.is_some_and(|also| shows(n, also, true))); let mut make = |n: &Node| { let mut n = n.clone(); let mut clip = None; @@ -843,22 +874,33 @@ pub(crate) fn set_image_from_path(d: &mut Document, id: NodeId, path: &str, doc_ install(d, &[id], read, Some(Preserve::Bounds), None) } -/// Image objects `ids` show the file read for `link` (`img`), each placed as its Placement -/// Options say (`force`: as that Preserve choice says, whatever they say); with `also` (a file's -/// path and the content shown), so do the images outside the layer tree that show that content. +/// Image objects `ids` (in the layers or outside them) show the file read for `link` (`img`), each +/// placed as its Placement Options say (`force`: as that Preserve choice says, whatever they say); +/// with `also` (a file's path and the content shown), so do the images outside the layer tree that +/// show that content. fn install(d: &mut Document, ids: &[NodeId], (link, img): FileRead, force: Option, also: Option<(&str, &str)>) -> Result<()> { let new_pt = super::place::pt_per_px(img.ppi); let mut clips = vec![]; - if let Some(also) = also { - // Every image picked shows content `also.1`, so one resolution serves them all. - let old_pt = file_pt(d, also.1); - let pick = |n: &Node| shows(n, also, false); + let nested = nested_ids(d, ids); + if also.is_some() || !nested.is_empty() { + let pick = |n: &Node| matches!(n.kind, NodeKind::Image(_)) && (nested.contains(&n.id) || also.is_some_and(|also| shows(n, also, false))); + // The resolution of each content the images picked show, looked up once each. + let mut old_pts: BTreeMap = BTreeMap::new(); + d.visit_stored(|n, _| { + if let NodeKind::Image(im) = &n.kind + && pick(n) + && !old_pts.contains_key(&im.key) + { + old_pts.insert(im.key.clone(), file_pt(d, &im.key)); + } + }); let mut make = |n: &Node| { let mut n = n.clone(); let mut clip = None; if let NodeKind::Image(im) = &mut n.kind { let o = PlacementOptions { preserve: force.unwrap_or(im.placement.preserve), ..im.placement }; let size = |w: u32, h: u32| (w as f64, h as f64); + let old_pt = old_pts.get(&im.key).copied().unwrap_or_else(|| super::place::pt_per_px(None)); let (xf, c) = placed_xf(im.xf, size(im.width, im.height), old_pt, size(img.width, img.height), new_pt, o); (im.key, im.width, im.height, im.xf, im.link) = (img.key.clone(), img.width, img.height, xf, Some(link.clone())); clip = c; @@ -1039,7 +1081,7 @@ pub fn resolve(d: &mut Document, base: LinkBase, update: bool, low_res_proxy_eps } } // The objects in symbols, graph designs, patterns and opacity masks that show the same file and - // content, which have no ids here, whatever else their links hold. + // content, whatever else their links hold, those whose id an earlier object has included. if !moved.is_empty() { let mut by_path: BTreeMap> = BTreeMap::new(); for (path, key, document, new) in moved { @@ -1074,13 +1116,25 @@ fn link_mut(n: &mut Node) -> Option<&mut LinkInfo> { } } -/// Linked image objects (or placed documents) `ids` link to `link`. +/// Linked image objects (or placed documents) `ids`, in the layers or outside them, link to `link`. fn set_links(d: &mut Document, ids: &[NodeId], link: &LinkInfo) { - for id in ids { + let nested = nested_ids(d, ids); + for id in ids.iter().filter(|id| !nested.contains(id)) { if let Some(l) = d.node_mut(*id).and_then(link_mut) { *l = link.clone(); } } + if !nested.is_empty() { + let pick = |n: &Node| nested.contains(&n.id) && linked(n).is_some(); + let mut make = |n: &Node| { + let mut out = n.clone(); + if let Some(l) = link_mut(&mut out) { + *l = link.clone(); + } + (out, None) + }; + Nested { pick: &pick, make: &mut make, next: 0 }.run(d); + } } /// Placed documents of the document saved at `path` (document `saved` of `s`) in the other open @@ -1309,7 +1363,7 @@ fn update(s: &mut Session, p: &Value) -> Result { /// Update Links in the active document of `s` for objects `ids` (default: the linked files that /// changed or show their preview), or with `path`, for the placed documents of the file at `path` /// whatever their status. The objects in symbols, graph designs, patterns and opacity masks that -/// show the same content are read again too, and have no ids to report. +/// show the same content are read again too. fn update_where(s: &mut Session, ids: Option>, path: Option<&str>) -> Result { let st = s.doc()?; let (mut reads, mut missing) = (vec![], vec![]); @@ -1341,7 +1395,7 @@ enum Scope { Each, /// The objects given or selected. Selected, - /// Every object in the layers linked to the same file as one given or selected. + /// Every stored object linked to the same file as one given or selected. All, /// The objects given or selected, then every other missing link whose file name is in the new /// file's folder. @@ -1425,7 +1479,7 @@ fn relink(s: &mut Session, p: &Value) -> Result { }; // An image or a placed document, with its link. let image = |id: &NodeId| { - st.doc.node(*id).and_then(|n| match &n.kind { + stored(&st.doc, *id).and_then(|n| match &n.kind { NodeKind::Image(im) => Some(im.link.as_ref()), NodeKind::PlacedDocument(p) => Some(Some(&p.link)), _ => None, @@ -1450,22 +1504,23 @@ fn relink(s: &mut Session, p: &Value) -> Result { if ids.is_empty() { return Err(bad(C, "select the images to relink, or pass ids")); } - // All Instances: the other objects in the layers linked to the same files. + // All Instances: the other stored objects linked to the same files, each once. if scope == Scope::All { let paths: BTreeSet<&str> = ids.iter().filter_map(|id| image(id).flatten()).map(|l| l.path.as_str()).collect(); - let given: BTreeSet = ids.iter().copied().collect(); + let mut given: BTreeSet = ids.iter().copied().collect(); let mut more = vec![]; - st.doc.walk(|n| { + st.doc.visit_stored(|n, _| { if let Some((l, _, _)) = linked(n) && paths.contains(l.path.as_str()) - && !given.contains(&n.id) + && n.id.0 != 0 + && given.insert(n.id) { more.push(n.id); } }); ids.extend(more); } - if copy_to.is_some() && ids.iter().any(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::PlacedDocument(_)))) { + if copy_to.is_some() && ids.iter().any(|id| matches!(stored(&st.doc, *id).map(|n| &n.kind), Some(NodeKind::PlacedDocument(_)))) { return Err(bad(C, "copy takes images only: relink placed documents where their files are")); } // (ids, the file) per file to read. @@ -1501,7 +1556,7 @@ fn relink(s: &mut Session, p: &Value) -> Result { let mut images = vec![]; let mut documents: Vec<((u32, bool, PageBox), Vec)> = vec![]; for id in ids { - match st.doc.node(id).map(|n| &n.kind) { + match stored(&st.doc, id).map(|n| &n.kind) { Some(NodeKind::PlacedDocument(pl)) => { let k = (pl.page(), pl.bounding, pl.page_box); match documents.iter_mut().find(|(e, _)| *e == k) { @@ -1643,9 +1698,9 @@ fn copy_into_folder(_: &str, _: &[u8], _: &Path) -> Result<(String, bool)> { // ---------- the Links panel ---------- -/// Image object `id` of `d`. +/// Image object `id` of `d`, wherever it is stored. fn image_of(d: &Document, id: NodeId) -> Option<&ImageObject> { - match &d.node(id)?.kind { + match &stored(d, id)?.kind { NodeKind::Image(im) => Some(im), _ => None, } @@ -1663,7 +1718,7 @@ fn image_param(st: &crate::DocState, p: &Value, cmd: &str) -> Result { .ok_or_else(|| bad(cmd, "select an image, or pass id"))?; match image_of(&st.doc, id) { Some(_) => Ok(id), - None if st.doc.node(id).is_none() => Err(crate::EngineError::NoNode(id)), + None if stored(&st.doc, id).is_none() => Err(crate::EngineError::NoNode(id)), None => Err(bad(cmd, format!("object {} is not an image", id.0))), } } @@ -1677,9 +1732,9 @@ fn placement_mut(n: &mut Node) -> Option<&mut PlacementOptions> { } } -/// The Placement Options of image or placed document `id` of `d`. +/// The Placement Options of image or placed document `id` of `d`, wherever it is stored. fn placement_of(d: &Document, id: NodeId) -> Option { - match &d.node(id)?.kind { + match &stored(d, id)?.kind { NodeKind::Image(im) => Some(im.placement), NodeKind::PlacedDocument(p) => Some(p.placement), _ => None, @@ -1692,12 +1747,12 @@ fn linked_param(st: &crate::DocState, p: &Value, cmd: &str) -> Result { let id = object_param(p, "id", cmd)?.or_else(first).ok_or_else(|| bad(cmd, "select an image, or pass id"))?; match placement_of(&st.doc, id) { Some(_) => Ok(id), - None if st.doc.node(id).is_none() => Err(crate::EngineError::NoNode(id)), + None if stored(&st.doc, id).is_none() => Err(crate::EngineError::NoNode(id)), None => Err(bad(cmd, format!("object {} is not an image or a linked file", id.0))), } } -/// Per linked image in the layers of `st`: its file's status, where it was found when away from +/// Per linked image or placed document of `st`, wherever it is stored: its file's status, where it was found when away from /// its path, and whether the image shows its preview (each file probed once). pub(crate) fn statuses(st: &crate::DocState) -> BTreeMap, bool)> { let mut out = BTreeMap::new(); @@ -1740,17 +1795,20 @@ fn list(s: &mut Session, p: &Value) -> Result { return Err(bad(C, format!("show `{show}`: all, missing, modified or embedded"))); } let statuses = statuses(st); - let mut objects = vec![]; - st.doc.walk(|n| { - if matches!(n.kind, NodeKind::Image(_) | NodeKind::PlacedDocument(_)) { - objects.push(n); + // The layer tree's objects, top first as the Layers panel lists them, then the others in the + // order they are stored (opacity-mask art, symbols, graph designs, patterns), each once. + let (mut objects, mut nested) = (vec![], vec![]); + let mut seen = std::collections::HashSet::new(); + st.doc.visit_stored(|n, place| { + if matches!(n.kind, NodeKind::Image(_) | NodeKind::PlacedDocument(_)) && n.id.0 != 0 && seen.insert(n.id) { + if place.in_layers() { objects.push((n, place)) } else { nested.push((n, place)) } } }); - // Top first, as the Layers panel lists them. objects.reverse(); + objects.extend(nested); let mut counts: BTreeMap<&str, usize> = BTreeMap::new(); let mut rows = vec![]; - for n in objects { + for (n, place) in objects { let (status, found, preview) = match statuses.get(&n.id) { Some((s, f, p)) => (s.as_str(), f.as_deref(), *p), None => ("embedded", None, false), @@ -1793,6 +1851,9 @@ fn list(s: &mut Session, p: &Value) -> Result { if preview { row["preview"] = json!(true); } + if let Some(owner) = owner_json(&st.doc, place) { + row["owner"] = owner; + } rows.push(row); } let text = |r: &Value, k: &str| r[k].as_str().unwrap_or_default().to_lowercase(); @@ -1810,15 +1871,21 @@ fn list(s: &mut Session, p: &Value) -> Result { fn go_to(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; let id = linked_param(st, p, "links.goTo")?; + // An object in mask art or a definition has no place of its own on the canvas: the selection + // is cleared, and the reply names what holds it. + if let Some(owner) = st.doc.stored_node(id).and_then(|(_, place)| owner_json(&st.doc, place)) { + s.select(|_, sel| sel.set([]))?; + return Ok(json!({ "id": id.0, "onCanvas": false, "owner": owner })); + } let b = st.doc.node(id).and_then(Node::geometric_bounds).unwrap_or_default(); s.select(|_, sel| sel.set([id]))?; - Ok(json!({ "id": id.0, "bounds": [b.x0, b.y0, b.x1, b.y1] })) + Ok(json!({ "id": id.0, "bounds": [b.x0, b.y0, b.x1, b.y1], "onCanvas": true })) } fn embed(s: &mut Session, p: &Value) -> Result { const C: &str = "links.embed"; let st = s.doc()?; - let kind = |id: &NodeId| st.doc.node(*id).map(|n| &n.kind); + let kind = |id: &NodeId| stored(&st.doc, *id).map(|n| &n.kind); let linked = |id: &NodeId| match kind(id) { Some(NodeKind::Image(im)) => im.link.is_some(), Some(NodeKind::PlacedDocument(_)) => true, @@ -1864,7 +1931,7 @@ fn embed(s: &mut Session, p: &Value) -> Result { install(d, &ids, read, Some(Preserve::Bounds), None)?; } for id in &done { - if let Some(Node { kind: NodeKind::Image(im), .. }) = d.node_mut(*id) { + if let Some(Node { kind: NodeKind::Image(im), .. }) = d.stored_node_mut(*id) { im.link = None; } } @@ -1892,7 +1959,7 @@ fn unembed(s: &mut Session, p: &Value) -> Result { const C: &str = "links.unembed"; let st = s.doc()?; let id = image_param(st, p, C)?; - let n = st.doc.node(id).ok_or(crate::EngineError::NoNode(id))?; + let n = stored(&st.doc, id).ok_or(crate::EngineError::NoNode(id))?; let im = image_of(&st.doc, id).ok_or_else(|| bad(C, format!("object {} is not an image", id.0)))?; if im.link.is_some() { return Err(bad(C, format!("image {} is linked already", id.0))); @@ -1978,7 +2045,7 @@ fn scale_rotation(xf: Affine, (px, py): (f64, f64)) -> (Value, Value) { /// Link Info of placed document `id`. fn placed_info(st: &crate::DocState, id: NodeId) -> Result { - let n = st.doc.node(id).ok_or(crate::EngineError::NoNode(id))?; + let n = stored(&st.doc, id).ok_or(crate::EngineError::NoNode(id))?; let NodeKind::PlacedDocument(pl) = &n.kind else { return Err(bad("links.info", format!("object {} is not a placed document", id.0))) }; let size = n.geometric_bounds().unwrap_or_default(); let (scale, rotation) = scale_rotation(pl.xf, (1.0, 1.0)); @@ -2058,7 +2125,7 @@ fn placement_options(s: &mut Session, p: &Value) -> Result { }; s.edit("Placement Options", |d, _| { for id in &ids { - if let Some(o) = d.node_mut(*id).and_then(placement_mut) { + if let Some(o) = d.stored_node_mut(*id).and_then(placement_mut) { set(o); } } diff --git a/crates/engine/src/cmd/place.rs b/crates/engine/src/cmd/place.rs index 5d56a2b646..6acad1e83b 100644 --- a/crates/engine/src/cmd/place.rs +++ b/crates/engine/src/cmd/place.rs @@ -885,7 +885,8 @@ pub(crate) fn image_info(s: &mut Session, p: &Value) -> Result { (None, [id]) => *id, _ => return Err(bad(C, "select one image or pass id")), }; - let n = st.doc.node(id).ok_or(EngineError::NoNode(id))?; + // An image in opacity-mask art or a definition's art is found by its id too (`links.list`). + let n = st.doc.stored_node(id).map(|(n, _)| &**n).ok_or(EngineError::NoNode(id))?; let NodeKind::Image(im) = &n.kind else { return Err(bad(C, format!("object {} is not an image", id.0))) }; // Effective resolution: 72 pt per inch over the points one pixel spans along each axis. let [a, b, c, d, ..] = im.xf.as_coeffs(); diff --git a/crates/engine/src/cmd/place/adopt.rs b/crates/engine/src/cmd/place/adopt.rs index 15a39f06e5..0a0f08d3c1 100644 --- a/crates/engine/src/cmd/place/adopt.rs +++ b/crates/engine/src/cmd/place/adopt.rs @@ -272,6 +272,8 @@ pub(crate) fn adopt(dst: &mut Document, src: &Document, nodes: &mut [Node]) { if let Some(mut def) = src.symbols.iter().find(|s| s.name == old).cloned() { def.name = new.to_string(); refs(Arc::make_mut(&mut def.art), &mut rename); + // The art takes ids of `dst`: those of `src` may name objects of `dst` already. + def.art = Arc::new(dst.reid(&def.art)); dst.symbols.push(def); } } @@ -280,6 +282,7 @@ pub(crate) fn adopt(dst: &mut Document, src: &Document, nodes: &mut [Node]) { def.name = new.to_string(); for a in &mut def.art { refs(Arc::make_mut(a), &mut rename); + *a = Arc::new(dst.reid(a)); } dst.patterns.push(def); } diff --git a/crates/engine/src/cmd/place/document.rs b/crates/engine/src/cmd/place/document.rs index 9f58e4851d..8e4afaa295 100644 --- a/crates/engine/src/cmd/place/document.rs +++ b/crates/engine/src/cmd/place/document.rs @@ -378,8 +378,10 @@ pub(crate) fn full_documents(d: &Document) -> (Cow<'_, Document>, Vec) { /// Placed document `id` of `d` replaced by an editable copy of its art, as placing its file /// without Link gives (its resources joining `d`, renamed where `d` uses a name for something /// else), keeping the object's id, place, name and transparency: Break Link and Object › Expand. +/// A placed document in opacity-mask art or in the art of a symbol, graph design or pattern is +/// replaced where it is stored. pub(crate) fn expand(d: &mut Document, id: NodeId, cmd: &str) -> Result<()> { - let n = d.node(id).cloned().ok_or(EngineError::NoNode(id))?; + let n = d.stored_node(id).map(|(n, _)| Node::clone(n)).ok_or(EngineError::NoNode(id))?; let NodeKind::PlacedDocument(p) = &n.kind else { return Err(bad(cmd, format!("object {} is not a placed document", id.0))) }; let blob = d.images.get(&p.key).ok_or_else(|| bad(cmd, format!("object {} has lost its file's contents: relink it", id.0)))?; let bytes = vectorcraft_doc::placed_document::full_bytes(p, blob) @@ -395,6 +397,10 @@ pub(crate) fn expand(d: &mut Document, id: NodeId, cmd: &str) -> Result<()> { let art = super::transformed(Node::new(d.alloc_id(), NodeKind::Group { children, clip: a.clip.is_some() }), m); let mut out = n.clone(); out.kind = art.kind; + if d.node(id).is_none() { + *d.stored_node_mut(id).ok_or(EngineError::NoNode(id))? = out; + return Ok(()); + } let (par, index, _) = d.position(id).ok_or(EngineError::NoNode(id))?; d.remove(id)?; d.insert(par, index, out)?; diff --git a/crates/engine/src/tests/links.rs b/crates/engine/src/tests/links.rs index a3c7a72aee..1f82795399 100644 --- a/crates/engine/src/tests/links.rs +++ b/crates/engine/src/tests/links.rs @@ -5,7 +5,7 @@ use std::path::{Path, PathBuf}; use serde_json::{Value, json}; -use vectorcraft_doc::{Document, ImageObject, LinkInfo, NodeKind}; +use vectorcraft_doc::{Document, ImageObject, LinkInfo, NodeKind, StoredRoot}; use vectorcraft_geom::Rect; use crate::*; @@ -446,8 +446,8 @@ fn links_outside_the_layers_find_their_files_after_the_folder_moves() { } /// With Update Links: Automatically, a file in a symbol and in the layers that changed and moved -/// with the document is read again for the layers' image and for the symbol's, and both links take -/// its new path, size and hash. +/// with the document is read again for the layers' image and for the symbol's, both are listed by +/// their ids, and both links take its new path, size and hash. #[test] fn a_symbols_link_to_a_moved_and_modified_file_takes_its_new_path() { let a = Folder::new("nested-modified-a"); @@ -458,13 +458,14 @@ fn a_symbols_link_to_a_moved_and_modified_file_takes_its_new_path() { s.execute("prefs.set", &json!({"key": "updateLinks", "value": "automatically"})).unwrap(); let id = place(&mut s, &pic); s.execute("symbol.new", &json!({"name": "Mark", "ids": [id.0]})).unwrap(); + let in_symbol = stored_image_in(&s, StoredRoot::Symbol(0)); let id = place(&mut s, &pic); save(&mut s, &a.file("poster.vectorcraft")); write(&pic, &png(30, 30, BLUE)); std::fs::remove_dir_all(&b.0).unwrap(); std::fs::rename(&a.0, &b.0).unwrap(); let r = open(&mut s, &b.file("poster.vectorcraft")); - assert_eq!((r["modifiedLinks"].clone(), r["updatedLinks"][0]["ids"].clone()), (json!([]), json!([id.0])), "{r}"); + assert_eq!((r["modifiedLinks"].clone(), r["updatedLinks"][0]["ids"].clone()), (json!([]), json!([id.0, in_symbol.0])), "{r}"); let moved = PathBuf::from(b.file("art/photo.png")); let blue = png(30, 30, BLUE); let mut images = vec![]; @@ -521,7 +522,8 @@ fn a_symbols_link_without_a_relative_path_follows_the_moved_file() { /// Update Links reads a changed file again for the images inside symbols, patterns and opacity /// masks too: those showing the same content as an image in the layers, and those no object in the -/// layers shows. Each takes the file's place as its own Placement Options say, in one undo step. +/// layers shows. Each takes the file's place as its own Placement Options say, in one undo step, +/// and the reply lists each by its id. #[test] fn update_links_reads_files_again_inside_symbols_patterns_and_masks() { let dir = Folder::new("nested-update"); @@ -533,11 +535,13 @@ fn update_links_reads_files_again_inside_symbols_patterns_and_masks() { let id = place(&mut s, &photo); s.execute("links.placementOptions", &json!({"ids": [id.0], "preserve": "fill", "clip": true})).unwrap(); s.execute("symbol.new", &json!({"name": "Mark", "ids": [id.0]})).unwrap(); + let in_symbol = stored_image_in(&s, StoredRoot::Symbol(0)); let id = place(&mut s, &photo); // logo.png only in a pattern and in an opacity mask. let p = place(&mut s, &logo); s.execute("object.pattern.make", &json!({"ids": [p.0], "edit": false})).unwrap(); s.execute("edit.clear", &json!({"ids": [p.0]})).unwrap(); + let p = stored_image_in(&s, StoredRoot::Pattern { pattern: 0, art: 0 }); let masked = super::rect(&mut s, 10.0, 10.0, 60.0, 60.0); let mask = place(&mut s, &logo); s.execute("transparency.makeOpacityMask", &json!({"ids": [masked.0, mask.0]})).unwrap(); @@ -546,7 +550,8 @@ fn update_links_reads_files_again_inside_symbols_patterns_and_masks() { write(&photo, &blue); write(&logo, &green); let u = s.execute("links.update", &json!({})).unwrap(); - assert_eq!(u, json!({"updated": [id.0], "missing": []})); + // By file: photo.png in the layers and the symbol, then logo.png in the mask and the pattern. + assert_eq!(u, json!({"updated": [id.0, in_symbol.0, mask.0, p.0], "missing": []})); // (name, size, hash, pixel size) of each image, which shows its file's pixels. let images = |s: &Session| { let d = &s.doc().unwrap().doc; @@ -576,6 +581,193 @@ fn update_links_reads_files_again_inside_symbols_patterns_and_masks() { assert!(images(&s).iter().all(|(_, _, _, size)| *size == (20, 10)), "{:?}", images(&s)); } +/// The files only art outside the layers shows: one in a symbol, a graph design, a pattern and an +/// opacity mask each, no object in the layers linked to any of them. +const NESTED: [&str; 4] = ["symbol.png", "design.png", "pattern.png", "mask.png"]; + +/// A document whose only links are [`NESTED`]'s files in `dir`. +fn nested_links(dir: &Folder) -> Session { + for (i, name) in (0u8..).zip(NESTED) { + write(&dir.file(name), &png(20, 10, [20 + 50 * i, 90, 160])); + } + let mut s = session(); + let id = place(&mut s, &dir.file("symbol.png")); + s.execute("symbol.new", &json!({"name": "Mark", "ids": [id.0]})).unwrap(); + s.execute("edit.clear", &json!({"ids": [s.doc().unwrap().selection.objects[0].0]})).unwrap(); + let id = place(&mut s, &dir.file("design.png")); + s.execute("graph.design", &json!({"new": "Bar"})).unwrap(); + s.execute("edit.clear", &json!({"ids": [id.0]})).unwrap(); + let id = place(&mut s, &dir.file("pattern.png")); + s.execute("object.pattern.make", &json!({"ids": [id.0], "edit": false})).unwrap(); + s.execute("edit.clear", &json!({"ids": [id.0]})).unwrap(); + let masked = super::rect(&mut s, 10.0, 10.0, 60.0, 60.0); + let mask = place(&mut s, &dir.file("mask.png")); + s.execute("transparency.makeOpacityMask", &json!({"ids": [masked.0, mask.0]})).unwrap(); + s.execute("select.set", &json!({"ids": []})).unwrap(); + s +} + +/// The id of the `links.list` row of file `name`. +fn row_id(s: &mut Session, name: &str) -> u64 { + let list = s.execute("links.list", &json!({})).unwrap(); + let row = list["links"].as_array().unwrap().iter().find(|r| r["name"] == name).unwrap_or_else(|| panic!("{name} in {list}")); + row["id"].as_u64().unwrap() +} + +/// The link of stored object `id`. +fn stored_link(s: &Session, id: u64) -> Option { + match &s.doc().unwrap().doc.stored_node(NodeId(id)).unwrap().0.kind { + NodeKind::Image(im) => im.link.clone(), + k => panic!("not an image: {k:?}"), + } +} + +/// The id of the first image stored in the art of `root`. +fn stored_image_in(s: &Session, root: StoredRoot) -> NodeId { + let mut found = None; + s.doc().unwrap().doc.visit_stored(|n, place| { + if found.is_none() && place.root == root && matches!(n.kind, NodeKind::Image(_)) { + found = Some(n.id); + } + }); + found.unwrap_or_else(|| panic!("no image in {root:?}")) +} + +/// Images in symbols, graph designs, patterns and opacity masks are listed and checked with ids of +/// their own, one row each with what holds it, and the missing filter counts them. +#[test] +fn links_outside_the_layers_are_listed_and_checked_with_ids() { + let dir = Folder::new("nested-ids"); + let mut s = nested_links(&dir); + let list = s.execute("links.list", &json!({})).unwrap(); + let rows = list["links"].as_array().unwrap(); + let owners: Vec<(&str, &str)> = rows.iter().map(|r| (r["name"].as_str().unwrap(), r["owner"]["kind"].as_str().unwrap_or("-"))).collect(); + assert_eq!(owners, [("mask.png", "layers"), ("symbol.png", "symbol"), ("design.png", "graphDesign"), ("pattern.png", "pattern")], "{list}"); + assert_eq!(rows[1]["owner"]["name"], "Mark"); + let masked = s.doc().unwrap().doc.layers[0].children().unwrap().iter().find(|n| n.extras().mask.is_some()).unwrap().id; + assert_eq!(rows[0]["owner"]["maskOf"], json!(masked.0)); + let ids: std::collections::BTreeSet = rows.iter().map(|r| r["id"].as_u64().unwrap()).collect(); + assert_eq!(ids.len(), 4, "{list}"); + assert!(rows.iter().all(|r| r["status"] == "ok" && r["linked"] == true), "{list}"); + let check = s.execute("links.check", &json!({})).unwrap(); + let checked: Vec<(&str, &Value)> = check["links"].as_array().unwrap().iter().map(|l| (l["name"].as_str().unwrap(), &l["ids"])).collect(); + assert_eq!(checked.len(), 4, "{check}"); + for (name, ids) in checked { + assert_eq!(*ids, json!([row_id(&mut s, name)]), "{check}"); + } + std::fs::remove_file(dir.file("design.png")).unwrap(); + let check = s.execute("links.check", &json!({})).unwrap(); + assert_eq!((check["missing"].as_u64(), check["modified"].as_u64()), (Some(1), Some(0)), "{check}"); + let design = row_id(&mut s, "design.png"); + let missing = s.execute("links.list", &json!({"show": "missing"})).unwrap(); + assert_eq!((missing["missing"].as_u64(), &missing["links"][0]["id"]), (Some(1), &json!(design)), "{missing}"); + let one = s.execute("links.check", &json!({"ids": [design]})).unwrap(); + assert_eq!(one["links"][0]["status"], "missing", "{one}"); +} + +/// Every links command takes the id of an image outside the layers and acts on that object: Link +/// Info, Go To (which clears the selection and names the symbol), Placement Options, Relink, Update and +/// Embed. +#[test] +fn links_commands_act_on_images_outside_the_layers_by_id() { + let dir = Folder::new("nested-act"); + let mut s = nested_links(&dir); + let symbol = row_id(&mut s, "symbol.png"); + let info = s.execute("links.info", &json!({"id": symbol})).unwrap(); + assert_eq!((info["fileName"].as_str(), info["status"].as_str(), info["id"].as_u64()), (Some("symbol.png"), Some("ok"), Some(symbol)), "{info}"); + let other = super::rect(&mut s, 0.0, 0.0, 5.0, 5.0); + s.execute("select.set", &json!({"ids": [other.0]})).unwrap(); + let go = s.execute("links.goTo", &json!({"id": symbol})).unwrap(); + assert_eq!( + (go["onCanvas"].clone(), go["owner"]["kind"].clone(), go["owner"]["name"].clone()), + (json!(false), json!("symbol"), json!("Mark")), + "{go}" + ); + assert!(s.doc().unwrap().selection.objects.is_empty(), "nothing on the canvas is selected"); + s.execute("links.placementOptions", &json!({"ids": [symbol], "preserve": "fit"})).unwrap(); + let placement = s.execute("links.placementOptions", &json!({"ids": [symbol]})).unwrap(); + assert_eq!(placement["placement"]["preserve"], "fit"); + // Relink: the symbol's image shows the new file. + let other = dir.file("other.png"); + write(&other, &png(40, 20, BLUE)); + let r = s.execute("links.relink", &json!({"ids": [symbol], "path": other})).unwrap(); + assert_eq!(r["relinked"], json!([symbol]), "{r}"); + assert_eq!(stored_link(&s, symbol).unwrap().path, other); + // Update: a changed pattern file is read again for the pattern's image. + let pattern = row_id(&mut s, "pattern.png"); + let changed = png(30, 30, RED); + write(&dir.file("pattern.png"), &changed); + let r = s.execute("links.update", &json!({"ids": [pattern]})).unwrap(); + assert_eq!(r["updated"], json!([pattern]), "{r}"); + assert_eq!(stored_link(&s, pattern).unwrap().size, Some(changed.len() as u64)); + // Embed: the design's image keeps its pixels and no longer links. + let design = row_id(&mut s, "design.png"); + let r = s.execute("links.embed", &json!({"ids": [design]})).unwrap(); + assert_eq!(r["embedded"], json!([design]), "{r}"); + assert!(stored_link(&s, design).is_none()); + let row = s.execute("links.list", &json!({"show": "embedded"})).unwrap(); + assert_eq!(row["links"][0]["id"], json!(design), "{row}"); + // Relink All Instances takes the other stored objects linked to the same file. + let mask = row_id(&mut s, "mask.png"); + let r = s.execute("links.relink", &json!({"ids": [symbol], "path": dir.file("mask.png"), "scope": "all"})).unwrap(); + assert_eq!(r["relinked"], json!([symbol]), "{r}"); + let r = s.execute("links.relink", &json!({"ids": [mask], "path": other, "scope": "all"})).unwrap(); + let mut relinked: Vec = r["relinked"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect(); + relinked.sort(); + let mut both = vec![symbol, mask]; + both.sort(); + assert_eq!(relinked, both, "{r}"); +} + +/// Opening a document reports missing and modified files that only art outside the layers shows, +/// by their objects' ids, and those ids stay the same after the document is saved and opened again. +#[test] +fn open_reports_links_outside_the_layers_by_id() { + let dir = Folder::new("nested-open"); + let mut s = nested_links(&dir); + let path = dir.file("poster.vectorcraft"); + save(&mut s, &path); + let ids: Vec = NESTED.iter().map(|name| row_id(&mut s, name)).collect(); + std::fs::remove_file(dir.file("symbol.png")).unwrap(); + write(&dir.file("pattern.png"), &png(30, 30, RED)); + let r = open(&mut s, &path); + let names = |k: &str| r[k].as_array().unwrap().iter().map(|l| (l["name"].as_str().unwrap().to_string(), l["ids"].clone())).collect::>(); + assert_eq!(names("missingLinks"), [("symbol.png".to_string(), json!([ids[0]]))], "{r}"); + assert_eq!(names("modifiedLinks"), [("pattern.png".to_string(), json!([ids[2]]))], "{r}"); + let again: Vec = NESTED.iter().map(|name| row_id(&mut s, name)).collect(); + assert_eq!(again, ids, "the ids are saved"); +} + +/// A document whose symbol and mask art repeat the ids of objects in the layers (or have none) +/// opens with ids of their own there, the layer tree's kept; the links commands then tell them apart. +#[test] +fn nested_objects_that_repeat_ids_open_with_ids_of_their_own() { + let dir = Folder::new("nested-repeat"); + let mut s = nested_links(&dir); + let layer_image = place(&mut s, &dir.file("symbol.png")); + let path = dir.file("poster.vectorcraft"); + save(&mut s, &path); + // The symbol's image takes the id of the image in the layers, the mask's image none. + let mut saved: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + saved["document"]["symbols"][0]["art"]["id"] = json!(layer_image.0); + let layer = &mut saved["document"]["layers"][0]["kind"]["children"]; + let masked = layer.as_array_mut().unwrap().iter_mut().find(|n| n.get("mask").is_some() || n["extras"].get("mask").is_some()).unwrap(); + let mask = if masked.get("mask").is_some() { &mut masked["mask"] } else { &mut masked["extras"]["mask"] }; + mask["art"]["id"] = json!(0); + std::fs::write(&path, serde_json::to_vec(&saved).unwrap()).unwrap(); + open(&mut s, &path); + let list = s.execute("links.list", &json!({})).unwrap(); + let ids: Vec = list["links"].as_array().unwrap().iter().map(|r| r["id"].as_u64().unwrap()).collect(); + let unique: std::collections::BTreeSet = ids.iter().copied().collect(); + assert_eq!((ids.len(), unique.len()), (5, 5), "{list}"); + assert!(!ids.contains(&0), "{list}"); + assert_eq!(list["links"][0]["id"], json!(layer_image.0), "the layer tree keeps its ids: {list}"); + let symbol = list["links"].as_array().unwrap().iter().find(|r| r["owner"]["kind"] == "symbol").unwrap()["id"].as_u64().unwrap(); + s.execute("links.embed", &json!({"ids": [symbol]})).unwrap(); + assert!(stored_link(&s, symbol).is_none()); + assert!(image(&s, layer_image).link.is_some(), "the image in the layers stays linked"); +} + #[test] fn a_missing_link_renders_its_preview_and_relinks() { let dir = Folder::new("missing"); diff --git a/crates/engine/src/tests/linkspanel.rs b/crates/engine/src/tests/linkspanel.rs index f029eccd3d..a9a3a17ec7 100644 --- a/crates/engine/src/tests/linkspanel.rs +++ b/crates/engine/src/tests/linkspanel.rs @@ -213,7 +213,7 @@ fn go_to_selects_the_image() { run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})); assert_ne!(s.doc().unwrap().selection.objects, vec![id]); let r = run(&mut s, "links.goTo", json!({"id": id.0})); - assert_eq!(r, json!({"id": id.0, "bounds": [-100.0, 0.0, 500.0, 300.0]})); + assert_eq!(r, json!({"id": id.0, "bounds": [-100.0, 0.0, 500.0, 300.0], "onCanvas": true})); assert_eq!(s.doc().unwrap().selection.objects, vec![id]); let rect = s.doc().unwrap().doc.layers[0].children().unwrap()[1].id; assert!(s.execute("links.goTo", &json!({"id": rect.0})).is_err(), "not an image"); diff --git a/crates/engine/src/tests/linkwatch.rs b/crates/engine/src/tests/linkwatch.rs index 7fc8f0a4ba..0a5b9d7dbe 100644 --- a/crates/engine/src/tests/linkwatch.rs +++ b/crates/engine/src/tests/linkwatch.rs @@ -47,6 +47,30 @@ fn update_changed_reads_changed_files_again_once() { assert_eq!(s.execute("links.check", &json!({})).unwrap()["modified"], 1); } +/// A file only a symbol's art shows is watched too, and read again by the id of the symbol's image. +#[test] +fn update_changed_reads_a_file_only_a_symbol_shows() { + let dir = Folder::new("watch-symbol"); + let path = dir.file("art.png"); + std::fs::write(&path, png(40, 40, RED)).unwrap(); + let mut s = session(); + let id = place(&mut s, &path); + s.execute("symbol.new", &json!({"name": "Mark", "ids": [id.0]})).unwrap(); + let instance = s.doc().unwrap().selection.objects[0]; + s.execute("edit.clear", &json!({"ids": [instance.0]})).unwrap(); + let mut in_symbol = None; + s.doc().unwrap().doc.symbols[0].art.walk(&mut |n| { + if matches!(n.kind, vectorcraft_doc::NodeKind::Image(_)) { + in_symbol = Some(n.id.0); + } + }); + let in_symbol = in_symbol.expect("the symbol's image"); + assert_eq!(s.execute("links.updateChanged", &json!({})).unwrap()["updated"], json!([])); + save(&path, &png(40, 40, BLUE)); + assert_eq!(s.execute("links.updateChanged", &json!({})).unwrap()["updated"], json!([in_symbol])); + assert_eq!(s.execute("links.check", &json!({})).unwrap()["modified"], 0); +} + #[test] fn a_background_look_follows_update_links() { for (mode, key) in [("automatically", "updated"), ("askWhenModified", "ask"), ("manually", "modified")] { diff --git a/crates/engine/src/tests/place.rs b/crates/engine/src/tests/place.rs index a0a8ededa0..455471dbb1 100644 --- a/crates/engine/src/tests/place.rs +++ b/crates/engine/src/tests/place.rs @@ -662,6 +662,33 @@ fn placed_art_brings_its_symbols_and_swatches_renaming_on_conflict() { assert!(linked, "still linked to the swatch it brought"); } +/// The symbol and pattern art placed art brings takes ids of the document's own: the file's ids +/// already name other objects there, and the Links panel names linked art in symbols by its id. +#[test] +fn placed_art_brings_its_symbols_and_patterns_with_ids_of_the_documents_own() { + let mut src = session(); + src.execute("shape.ellipse", &json!({"x": 50, "y": 10, "width": 20, "height": 20})).unwrap(); + src.execute("symbol.new", &json!({"name": "Star"})).unwrap(); + src.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 8, "height": 8})).unwrap(); + let tile = src.doc().unwrap().selection.objects[0]; + src.execute("object.pattern.make", &json!({"ids": [tile.0], "edit": false, "name": "Dots"})).unwrap(); + src.execute("paint.setFill", &json!({"ids": [tile.0], "swatch": "Dots"})).unwrap(); + let bytes = serialize(&mut src, "vectorcraft"); + // The document's objects take the same ids the file's do. + let mut s = session(); + for x in [0, 20, 40, 60] { + s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 5, "height": 5})).unwrap(); + } + s.execute("symbol.new", &json!({"name": "Box"})).unwrap(); + place(&mut s, "logo.vectorcraft", &bytes, json!({})); + let doc = &s.doc().unwrap().doc; + assert!(doc.symbols.iter().any(|x| x.name == "Star") && doc.pattern("Dots").is_some()); + let mut ids = vec![]; + doc.visit_stored(|n, _| ids.push(n.id.0)); + let unique: std::collections::BTreeSet = ids.iter().copied().collect(); + assert_eq!(unique.len(), ids.len(), "{ids:?}"); +} + #[test] fn placed_art_brings_its_brushes_renaming_on_conflict() { // The file: a path with its own brush, and one with a brush the defaults hold. diff --git a/crates/ui-egui/src/addons/select/link_status.rs b/crates/ui-egui/src/addons/select/link_status.rs index 0b367a7ba0..d50d650215 100644 --- a/crates/ui-egui/src/addons/select/link_status.rs +++ b/crates/ui-egui/src/addons/select/link_status.rs @@ -52,12 +52,19 @@ pub struct Present { pub has: [bool; 4], } -/// The statuses the `links.list` result `list` holds, in [`STATUSES`] order. +/// The statuses the rows of the `links.list` result `list` that can be selected hold, in +/// [`STATUSES`] order. pub(crate) fn statuses_in(list: &Value) -> [bool; 4] { - let rows = list["links"].as_array().map_or(&[][..], Vec::as_slice); + let rows = canvas_rows(list); STATUSES.map(|(status, ..)| rows.iter().any(|r| command_status(r) == status)) } +/// The rows of the `links.list` result `list` of objects in the layer tree, which can be +/// selected (a row with an `owner` is in a symbol, graph design, pattern or opacity mask). +fn canvas_rows(list: &Value) -> Vec { + list["links"].as_array().into_iter().flatten().filter(|r| r["owner"].is_null()).cloned().collect() +} + /// The status of a `links.list` row as the command names it (`ok` is `linked`). fn command_status(row: &Value) -> &str { match row["status"].as_str().unwrap_or_default() { @@ -154,11 +161,11 @@ pub(crate) fn message(statuses: &[&str], count: u64, reasons: &[&str]) -> String crate::i18n::msg_n(reasons.len() as u64, one, other, &[("selected", &selected)]) } -/// The dialog's fields to start with: every image and placed document (`__rows`, as `links.list` -/// lists them, read once), those selected chosen, All shown, Zoom to Selection on. +/// The dialog's fields to start with: every image and placed document in the layer tree (`__rows`, +/// as `links.list` lists them, read once), those selected chosen, All shown, Zoom to Selection on. pub(crate) fn fields(app: &mut VectorcraftApp) -> Value { let list = app.session.execute("links.list", &json!({})).unwrap_or_default(); - let rows: Vec = list["links"].as_array().cloned().unwrap_or_default(); + let rows = canvas_rows(&list); let selected: Vec = app.session.active().map(|st| st.selection.objects.iter().map(|id| id.0).collect()).unwrap_or_default(); let chosen: Vec = rows.iter().filter_map(|r| r["id"].as_u64()).filter(|id| selected.contains(id)).collect(); json!({ "__rows": rows, "ids": chosen, "show": "all", "zoom": true }) diff --git a/crates/ui-egui/src/addons/select/link_status/tests.rs b/crates/ui-egui/src/addons/select/link_status/tests.rs index 8ad61f6e5a..c0515e048b 100644 --- a/crates/ui-egui/src/addons/select/link_status/tests.rs +++ b/crates/ui-egui/src/addons/select/link_status/tests.rs @@ -138,6 +138,27 @@ fn menu_items_are_on_for_the_statuses_present_and_shift_adds() { assert!(on("Select Embedded")); } +/// A missing link kept only in a symbol's art can't be selected: Select Missing stays off and +/// the dialog does not list it. +#[test] +fn links_inside_a_symbol_are_left_out_of_selecting_by_status() { + let dir = Folder::new("symbol"); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let path = dir.file("inside.png", &png(30, 20)); + let id = app.run("file.place", json!({"path": path, "at": [100, 100]})).unwrap()["ids"][0].as_u64().unwrap(); + app.run("symbol.new", json!({"name": "Mark", "ids": [id]})).unwrap(); + app.run("edit.clear", json!({})).unwrap(); + std::fs::remove_file(&path).unwrap(); + let list = app.run("links.list", json!({})).unwrap(); + assert_eq!(list["missing"], 1, "{list}"); + refresh(&mut app, 0.0); + let entries = menus::menu_entries(&app); + let missing = entries.iter().find(|e| e.path == ["Object", "Plug-ins", "Selection Tools"] && e.label == "Select Missing").unwrap(); + assert!(!missing.enabled); + assert_eq!(super::fields(&mut app)["__rows"], json!([])); +} + #[test] fn the_links_panel_menu_lists_the_select_items() { let dir = Folder::new("flyout"); diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 33df72c253..3b6cfa4bc6 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5715,6 +5715,7 @@ digits Malayalam Malaiàlam @msg no library `{id}` no hi ha la biblioteca `{id}` @msg no library `{key}` (see {list}) no hi ha la biblioteca `{key}` (vegeu {list}) @msg No linked files to look for No hi ha fitxers enllaçats per buscar +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas L’enllaç és dins d’un símbol, un disseny de gràfic, un motiu o una màscara d’opacitat i no té lloc al llenç @msg no Live Paint edge near that point no hi ha cap aresta de pintura interactiva a prop d’aquell punt @msg no Live Paint face at that point no hi ha cap cara de pintura interactiva en aquell punt @msg no native format no hi ha format natiu diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index ff48f1ecfc..e0cb12f675 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -5400,6 +5400,7 @@ imageTrace Outline Obrys @msg every numbered name for it is taken in the Fonts folder ve složce Fonts jsou obsazena všechna číslovaná jména pro tento soubor @msg every numbered name for {name} is taken in the document's folder ve složce dokumentu jsou obsazena všechna číslovaná jména pro {name} @msg No linked files to look for Žádné propojené soubory k vyhledání +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Propojení je uvnitř symbolu, návrhu grafu, vzorku nebo masky krytí a nemá na plátně žádné místo @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` je odkaz na krok {r}, který vyhodnocují jen `run` a `command_batch` @msg `ids` must be an array of object ids, not {v} `ids` musí být pole ID objektů, ne {v} @msg `colors` must be an array of colors, not {v} `colors` musí být pole barev, ne {v} diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 1d3b12f63a..6be2b55e6b 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5017,6 +5017,7 @@ theme Appearance Erscheinungsbild @msg every numbered name for it is taken in the Fonts folder alle nummerierten Namen dafür sind im Schriftenordner bereits vergeben @msg every numbered name for {name} is taken in the document's folder alle nummerierten Namen für {name} sind im Ordner des Dokuments bereits vergeben @msg No linked files to look for Keine verknüpften Dateien zum Suchen +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Die Verknüpfung befindet sich in einem Symbol, einem Diagrammdesign, einem Muster oder einer Deckkraftmaske und hat keinen Platz auf der Zeichenfläche # The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages. Services Dienste diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 8467b8e831..3861f53fb0 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -5039,6 +5039,7 @@ digits Malayalam Μαλαγιάλαμ @msg every numbered name for it is taken in the Fonts folder όλα τα αριθμημένα ονόματά του χρησιμοποιούνται ήδη στον φάκελο γραμματοσειρών @msg every numbered name for {name} is taken in the document's folder όλα τα αριθμημένα ονόματα για το {name} χρησιμοποιούνται ήδη στον φάκελο εγγράφου @msg No linked files to look for Δεν υπάρχουν συνδεδεμένα αρχεία για αναζήτηση +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Ο σύνδεσμος βρίσκεται μέσα σε σύμβολο, σχεδιασμό γραφήματος, μοτίβο ή μάσκα αδιαφάνειας και δεν έχει θέση στον καμβά Hide VectorCraft Απόκρυψη VectorCraft Bring All to Front Μεταφορά όλων στο προσκήνιο Break Link Κατάργηση σύνδεσης diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 194ba2c602..bf9adc01b6 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -5092,6 +5092,7 @@ axis Both Ambos @msg every numbered name for it is taken in the Fonts folder todos los nombres numerados para él ya existen en la carpeta Fonts @msg every numbered name for {name} is taken in the document's folder todos los nombres numerados para {name} ya existen en la carpeta del documento @msg No linked files to look for No hay archivos vinculados que buscar +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas El vínculo está dentro de un símbolo, un diseño de gráfico, un motivo o una máscara de opacidad y no tiene lugar en el lienzo # The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages. Services Servicios diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index e36c5e891e..3a193a980e 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -5300,6 +5300,7 @@ imageTrace Outline Ääriviiva @msg every numbered name for it is taken in the Fonts folder kaikki sen numeroidut nimet ovat jo käytössä Fonts-kansiossa @msg every numbered name for {name} is taken in the document's folder kaikki kohteen {name} numeroidut nimet ovat jo käytössä asiakirjan kansiossa @msg No linked files to look for Ei etsittäviä linkitettyjä tiedostoja +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Linkki on symbolin, kaaviomallin, kuvion tai peittävyysmaskin sisällä, eikä sillä ole paikkaa piirtoalueella @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` on vaiheviittaus {r}, jonka vain `run` ja `command_batch` ratkaisevat @msg `ids` must be an array of object ids, not {v} `ids` on oltava objektitunnusten taulukko, ei {v} @msg `colors` must be an array of colors, not {v} `colors` on oltava värien taulukko, ei {v} diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 067845f493..5b7d36b7d1 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -5079,6 +5079,7 @@ axis Both Les deux @msg every numbered name for it is taken in the Fonts folder tous les noms numérotés pour ce fichier sont déjà pris dans le dossier Fonts @msg every numbered name for {name} is taken in the document's folder tous les noms numérotés pour {name} sont déjà pris dans le dossier du document @msg No linked files to look for Aucun fichier lié à rechercher +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Le lien se trouve dans un symbole, une conception de graphe, un motif ou un masque d’opacité et n’a pas de place sur le canevas # The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages. Services Services diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 165e04ae7d..3c39e8cf19 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -7192,6 +7192,7 @@ antiAlias Sharp शार्प @msg no library `{key}` (see {list}) कोई लाइब्रेरी `{key}` ({list} देखें) @msg No line breaks to remove हटाने के लिए कोई लाइन ब्रेक नहीं @msg No linked files to look for देखने के लिए कोई लिंक्ड फ़ाइल नहीं +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas लिंक किसी सिंबल, ग्राफ़ डिज़ाइन, पैटर्न या ओपेसिटी मास्क के अंदर है और कैनवास पर इसकी कोई जगह नहीं है @msg no Live Paint edge near that point उस बिंदु के पास कोई लाइव पेंट एज नहीं @msg no Live Paint face at that point उस बिंदु पर कोई लाइव पेंट फेस नहीं @msg no metafile format कोई मेटाफ़ाइल फ़ॉर्मैट diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 2ccf9d57bc..2420ee8319 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5139,6 +5139,7 @@ imageTrace Outline Körvonal @msg every numbered name for it is taken in the Fonts folder a Fonts mappában minden sorszámozott név foglalt hozzá @msg every numbered name for {name} is taken in the document's folder a dokumentum mappájában minden sorszámozott név foglalt: {name} @msg No linked files to look for Nincs keresendő csatolt fájl +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas A csatolás egy szimbólumban, diagrammintában, mintázatban vagy átlátszósági maszkban van, és nincs helye a vásznon @msg every search thread stopped on an internal error (please report this bug) minden keresési szál belső hibával leállt (kérjük, jelentsd ezt a hibát) @msg Expand Appearance: the selection has a basic appearance Megjelenés kibontása: a kijelölésnek alapmegjelenése van @msg Expand: no brush strokes selected Kibontás: nincs kijelölt ecsetvonás diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index be83d78b43..2ef7decb66 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5143,6 +5143,7 @@ axis Both Entrambi @msg every numbered name for it is taken in the Fonts folder tutti i nomi numerati per il file sono già usati nella cartella Fonts @msg every numbered name for {name} is taken in the document's folder tutti i nomi numerati per {name} sono già usati nella cartella del documento @msg No linked files to look for Nessun file collegato da cercare +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Il collegamento si trova in un simbolo, un design di grafico, un motivo o una maschera di opacità e non ha una posizione nell’area di disegno # The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages. Services Servizi diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b27c6d2698..c46278e818 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5210,6 +5210,7 @@ swatch Bright Violet ビビッドバイオレット @msg every numbered name for it is taken in the Fonts folder Fonts フォルダーでは、このファイルの番号付きの名前がすべて使われています @msg every numbered name for {name} is taken in the document's folder ドキュメントのフォルダーでは、{name} の番号付きの名前がすべて使われています @msg No linked files to look for 検索するリンクファイルがありません +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas このリンクはシンボル、グラフデザイン、パターン、または不透明マスクの中にあり、カンバス上に位置がありません @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` はステップ参照 {r} です。ステップ参照を解決できるのは `run` と `command_batch` だけです @msg `ids` must be an array of object ids, not {v} `ids` はオブジェクト ID の配列にしてください ({v} は使えません) @msg `colors` must be an array of colors, not {v} `colors` は色の配列にしてください ({v} は使えません) diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 4ba40baab0..aee875402e 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -5110,6 +5110,7 @@ font-technology Variable ცვლადი @msg the art on them differs from the page's in {_1}% of it: they come from its editing data all the same მათზე არსებული ნახატი გვერდისგან {_1}%-ით განსხვავდება: მიუხედავად ამისა, ისინი რედაქტირების მონაცემებიდან აიღება @msg every numbered name for {name} is taken in the document's folder დოკუმენტის საქაღალდეში {name}-ის ყველა დანომრილი სახელი დაკავებულია @msg No linked files to look for მოსაძებნი დაკავშირებული ფაილები არ არის +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas ბმული სიმბოლოში, დიაგრამის დიზაინში, პატერნში ან გაუმჭვირვალობის ნიღაბშია და ტილოზე ადგილი არ აქვს Ruler Origin სახაზავების სათავე Reset Ruler Origin სახაზავების სათავის ჩამოყრა Artistic მხატვრული diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index c46ce6b4fa..ad8fb62588 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5137,6 +5137,7 @@ theme Appearance 외관 @msg every numbered name for it is taken in the Fonts folder 글꼴 폴더에서 이를 위한 번호 이름이 모두 사용 중입니다 @msg every numbered name for {name} is taken in the document's folder 문서 폴더에서 {name}을(를) 위한 번호 이름이 모두 사용 중입니다 @msg No linked files to look for 찾을 링크된 파일이 없습니다 +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas 링크가 심볼, 그래프 디자인, 패턴 또는 불투명 마스크 안에 있어 캔버스에 위치가 없습니다 @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id`는 단계 참조 {r}이며, `run`과 `command_batch`만 이를 해석합니다 @msg `ids` must be an array of object ids, not {v} `ids`는 {v}이(가) 아니라 개체 id 배열이어야 합니다 @msg `colors` must be an array of colors, not {v} `colors`는 {v}이(가) 아니라 색상 배열이어야 합니다 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index a232ac4e8d..50e1dc63bc 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5226,6 +5226,7 @@ imageTrace Outline Kontur @msg every numbered name for it is taken in the Fonts folder wszystkie numerowane nazwy dla niego są zajęte w folderze czcionek @msg every numbered name for {name} is taken in the document's folder wszystkie numerowane nazwy dla {name} są zajęte w folderze dokumentu @msg No linked files to look for Brak połączonych plików do wyszukania +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Łącze znajduje się w symbolu, wzorze wykresu, wzorku lub masce krycia i nie ma miejsca na obszarze roboczym @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` to odwołanie do kroku {r}, które rozwiązują tylko `run` i `command_batch` @msg `ids` must be an array of object ids, not {v} `ids` musi być tablicą identyfikatorów obiektów, nie {v} @msg `colors` must be an array of colors, not {v} `colors` musi być tablicą kolorów, nie {v} diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 68a225625d..322ad67da4 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -5328,6 +5328,7 @@ axis Both Ambos @msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise o texto de área em molduras baixas demais para uma linha da fonte em que aparece recebeu molduras mais altas ({_1}): senão o texto não apareceria @msg every numbered name for {name} is taken in the document's folder todos os nomes numerados para {name} já existem na pasta do documento @msg No linked files to look for Nenhum arquivo vinculado para procurar +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas O link está dentro de um símbolo, projeto de gráfico, padrão ou máscara de opacidade e não tem lugar na tela De-Interlace Desentrelaçar Eliminate Eliminar Create New Fields by Criar Novos Campos por diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index a54d1eed2c..27b7b47e36 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5129,6 +5129,7 @@ axis Both Оба @msg every numbered name for it is taken in the Fonts folder все нумерованные имена для него уже заняты в папке Fonts @msg every numbered name for {name} is taken in the document's folder все нумерованные имена для {name} уже заняты в папке документа @msg No linked files to look for Нет связанных файлов для поиска +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Связь находится внутри символа, дизайна диаграммы, узора или непрозрачной маски и не имеет места на холсте # The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages. Services Службы diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index d0648b5946..d38c35c259 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -4987,6 +4987,7 @@ axis Both Båda @msg every numbered name for it is taken in the Fonts folder alla numrerade namn för den är upptagna i mappen Typsnitt @msg every numbered name for {name} is taken in the document's folder alla numrerade namn för {name} är upptagna i dokumentets mapp @msg No linked files to look for Inga länkade filer att leta efter +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Länken finns i en symbol, en diagramdesign, ett mönster eller en opacitetsmask och har ingen plats på arbetsytan Services Tjänster Hide VectorCraft Göm VectorCraft Zoom Zoom diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 915a244b43..88f7341c35 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4825,6 +4825,7 @@ axis Both Her İkisi @msg every numbered name for it is taken in the Fonts folder bunun için numaralandırılmış her ad Yazı Tipleri klasöründe alınır @msg every numbered name for {name} is taken in the document's folder {name} için numaralandırılmış her ad belgenin klasöründe alınır @msg No linked files to look for Aranacak bağlantılı dosya yok +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Bağlantı bir sembolün, grafik tasarımının, desenin veya opaklık maskesinin içinde ve tuvalde yeri yok Services Hizmetler Hide VectorCraft VectorCraft'ı gizle Zoom Yakınlaştır diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index e8b5445a50..3a838f8bf8 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5127,6 +5127,7 @@ axis Both Обидві @msg every numbered name for it is taken in the Fonts folder усі нумеровані імена для нього вже зайняті в папці Fonts @msg every numbered name for {name} is taken in the document's folder усі нумеровані імена для {name} уже зайняті в папці документа @msg No linked files to look for Немає пов’язаних файлів для пошуку +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Зв’язок міститься в символі, дизайні діаграми, візерунку або масці непрозорості й не має місця на полотні Services Служби Hide VectorCraft Приховати VectorCraft Zoom Масштаб diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 51681bfed5..5abcae4ee9 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4966,6 +4966,7 @@ theme Appearance Giao diện @msg no library `{id}` không có thư viện `{id}` @msg no library `{key}` (see {list}) không có thư viện `{key}` (xem {list}) @msg No linked files to look for Không có tệp liên kết để tìm kiếm +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Liên kết nằm trong ký hiệu, thiết kế biểu đồ, họa tiết hoặc mặt nạ độ mờ và không có vị trí trên canvas @msg no Live Paint edge near that point không có cạnh tô trực tiếp gần điểm đó @msg no Live Paint face at that point không có mặt tô trực tiếp tại điểm đó @msg no native format không có định dạng gốc diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d1e4de6bc6..9a63ddd188 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -4899,6 +4899,7 @@ imageTrace Outline 轮廓 @msg every numbered name for it is taken in the Fonts folder 字体文件夹中所有带编号的名称均已被占用 @msg every numbered name for {name} is taken in the document's folder 文档所在文件夹中 {name} 的所有带编号的名称均已被占用 @msg No linked files to look for 没有要查找的链接文件 +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas 该链接位于符号、图表设计、图案或不透明度蒙版中,在画布上没有位置 @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` 是步骤引用 {r},只有 `run` 和 `command_batch` 能解析它 @msg `ids` must be an array of object ids, not {v} `ids` 必须是由对象 id 组成的数组,而不是 {v} @msg `colors` must be an array of colors, not {v} `colors` 必须是由颜色组成的数组,而不是 {v} diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 8e49ed109a..203910a663 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2668,6 +2668,7 @@ file-filter Templates 範本 @msg every numbered name for it is taken in the Fonts folder 字體資料夾中所有編號名稱都已被使用 @msg every numbered name for {name} is taken in the document's folder 文件所在資料夾中 {name} 的所有編號名稱都已被使用 @msg No linked files to look for 沒有要尋找的連結檔案 +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas 此連結位於符號、圖表設計、圖樣或不透明度遮色片中,在畫布上沒有位置 @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` 是步驟參照 {r},只有 `run` 和 `command_batch` 能解析它 @msg `ids` must be an array of object ids, not {v} `ids` 必須是由物件 id 組成的陣列,而非 {v} @msg `colors` must be an array of colors, not {v} `colors` 必須是由顏色組成的陣列,而非 {v} diff --git a/crates/ui-egui/src/panels/links.rs b/crates/ui-egui/src/panels/links.rs index 38f9848760..5fa9199f19 100644 --- a/crates/ui-egui/src/panels/links.rs +++ b/crates/ui-egui/src/panels/links.rs @@ -8,6 +8,10 @@ //! Modified (`addon.select.byLinkStatus`, Shift adds), Show filters, Sort, thumbnail size //! and Show in Folder. //! +//! Images and placed documents in opacity-mask art and in the art of symbols, graph designs and +//! patterns are listed after the layers' own, one row each; the canvas can't select them, so a +//! click picks such a row in the panel, and the buttons act on it as on any other. +//! //! Everything runs through the `links.*` commands (`links.editOriginal` and `links.reveal` are UI //! commands: they hand the file to the system). @@ -52,6 +56,8 @@ struct Cache { at: f64, list: Value, info: Option<(u64, Value)>, + /// The objects of the rows outside the layer tree, by id, for their thumbnails. + nested: Vec<(u64, std::sync::Arc)>, } fn options(ctx: &egui::Context) -> Options { @@ -69,13 +75,27 @@ fn set_options(ctx: &egui::Context, f: impl FnOnce(&mut Options)) { fn cached(app: &mut VectorcraftApp, ctx: &egui::Context, o: &Options) -> Option { let st = app.session.active()?; let key = Some((st.uid, st.revision, o.show, o.sort)); - let first = selected_images(app).first().copied(); + let first = selected_links(app, ctx).first().copied(); let now = ctx.input(|i| i.time); let mut c: Cache = pstate(ctx, "links-cache"); let stale = c.key != key || now - c.at > RECHECK; if stale { c.list = app.session.execute("links.list", &json!({ "show": o.show, "sort": o.sort })).ok()?; (c.key, c.at) = (key, now); + // One pass over the stored art for the rows outside the layer tree, not one per frame. + let mut want: std::collections::HashSet = + c.list["links"].as_array().into_iter().flatten().filter(|r| nested_row(r)).filter_map(|r| r["id"].as_u64()).collect(); + c.nested.clear(); + if !want.is_empty() + && let Some(st) = app.session.active() + { + st.doc.visit_stored_arcs(|n, place| { + if !place.in_layers() && want.remove(&n.id.0) { + c.nested.push((n.id.0, std::sync::Arc::clone(n))); + } + }); + c.nested.sort_by_key(|(id, _)| *id); + } } let want = first.filter(|_| o.info); if stale || c.info.as_ref().map(|(id, _)| *id) != want { @@ -96,6 +116,50 @@ fn selected_images(app: &VectorcraftApp) -> Vec { st.selection.objects.iter().filter(|id| image(id)).map(|id| id.0).collect() } +/// Is row `r` of an object outside the layer tree (in opacity-mask art or the art of a symbol, +/// graph design or pattern), which the canvas can't select? +fn nested_row(r: &Value) -> bool { + r["owner"].is_object() +} + +/// The active document's uid and the ids of its selected objects. +fn canvas_selection(app: &VectorcraftApp) -> Option<(u64, Vec)> { + app.session.active().map(|st| (st.uid, st.selection.objects.iter().map(|id| id.0).collect())) +} + +/// The rows outside the layer tree picked in the panel, with the canvas selection +/// ([`canvas_selection`]) they were picked with. +type NestedPicks = Option<((u64, Vec), Vec)>; + +/// The rows outside the layer tree picked in the panel, in the active document. A change of the +/// canvas selection, or another document, drops them (the panel looks every frame). +fn nested_picks(app: &VectorcraftApp, ctx: &egui::Context) -> Vec { + let picks: NestedPicks = pstate(ctx, "links-nested"); + let Some((at, ids)) = picks else { return vec![] }; + if Some(&at) == canvas_selection(app).as_ref() { + return ids; + } + set_pstate::(ctx, "links-nested", None); + vec![] +} + +/// Keep `ids` as the rows picked in the panel, with the canvas selection as it is now. +fn set_nested_picks(app: &VectorcraftApp, ctx: &egui::Context, ids: Vec) { + set_pstate(ctx, "links-nested", canvas_selection(app).map(|at| (at, ids))); +} + +/// The ids of the selected rows: the selected images and placed documents, then the rows outside +/// the layer tree picked in the panel. +fn selected_links(app: &VectorcraftApp, ctx: &egui::Context) -> Vec { + let mut ids = selected_images(app); + for id in nested_picks(app, ctx) { + if !ids.contains(&id) { + ids.push(id); + } + } + ids +} + /// The rows of `list` whose image is selected. fn selected_rows<'a>(list: &'a Value, selected: &[u64]) -> Vec<&'a Value> { list["links"].as_array().into_iter().flatten().filter(|r| r["id"].as_u64().is_some_and(|id| selected.contains(&id))).collect() @@ -170,9 +234,14 @@ fn relink_to_picked_folder(app: &mut VectorcraftApp, ids: Vec) { }); } -/// Select image `id` and scroll it into view. +/// Select image `id` and scroll it into view; one outside the layer tree only says that it has no +/// place on the canvas. pub(crate) fn go_to(app: &mut VectorcraftApp, id: u64) { let Ok(r) = app.run("links.goTo", json!({ "id": id })) else { return }; + if r["onCanvas"] == false { + app.status("The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas"); + return; + } let b: Vec = r["bounds"].as_array().into_iter().flatten().filter_map(Value::as_f64).collect(); if let (&[x0, y0, x1, y1], Some(v)) = (&b[..], app.view_mut()) { v.center = vectorcraft_geom::Point::new((x0 + x1) / 2.0, (y0 + y1) / 2.0); @@ -290,25 +359,46 @@ fn status_tip(r: &Value) -> String { /// Ctrl/Cmd adds or removes it, and Shift selects the images of the rows from the last row clicked /// without Shift in this document to it, or with Ctrl/Cmd adds them to the selection. A /// Shift-click while that row is not shown, or after the last click was in another document, acts -/// as a click without Shift. +/// as a click without Shift. Rows outside the layer tree are picked in the panel itself, as the +/// canvas can't select them; a click without Ctrl/Cmd replaces both kinds of selection. fn click(app: &mut VectorcraftApp, ctx: &egui::Context, rows: &[Value], id: u64, m: egui::Modifiers) { let Some(uid) = app.session.active().map(|st| st.uid) else { return }; let anchor: Option<(u64, u64)> = pstate(ctx, "links-anchor"); let at = |id: u64| rows.iter().position(|r| r["id"].as_u64() == Some(id)); + let nested = |r: &Value| nested_row(r).then(|| r["id"].as_u64()).flatten(); + let mut picks = if m.command { nested_picks(app, ctx) } else { vec![] }; if m.shift && let Some((_, a)) = anchor.filter(|(doc, _)| *doc == uid) && let (Some(i), Some(j)) = (at(a), at(id)) { - let range: Vec = rows.get(i.min(j)..=i.max(j)).unwrap_or_default().iter().filter_map(|r| r["id"].as_u64()).collect(); - app.run(if m.command { "select.add" } else { "select.set" }, json!({ "ids": range })).ok(); + let range = rows.get(i.min(j)..=i.max(j)).unwrap_or_default(); + let canvas: Vec = range.iter().filter(|r| !nested_row(r)).filter_map(|r| r["id"].as_u64()).collect(); + picks.extend(range.iter().filter_map(nested).filter(|id| !picks.contains(id)).collect::>()); + app.run(if m.command { "select.add" } else { "select.set" }, json!({ "ids": canvas })).ok(); + set_nested_picks(app, ctx, picks); return; } set_pstate(ctx, "links-anchor", Some((uid, id))); + let row = rows.iter().find(|r| r["id"].as_u64() == Some(id)); + if row.is_some_and(nested_row) { + match picks.iter().position(|p| *p == id) { + Some(i) if m.command => { + picks.remove(i); + } + _ => picks.push(id), + } + if !m.command { + app.run("select.set", json!({ "ids": [] })).ok(); + } + set_nested_picks(app, ctx, picks); + return; + } if m.command { app.run("select.toggle", json!({ "id": id })).ok(); } else { app.run("select.set", json!({ "ids": [id] })).ok(); } + set_nested_picks(app, ctx, picks); } pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { @@ -322,7 +412,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let Some(st) = app.session.active() else { return }; let doc = st.doc.clone(); let thumbs = super::layers::ThumbSource::of(app, st, &doc); - let selected = selected_images(app); + let selected = selected_links(app, ui.ctx()); let rows: &[Value] = cache.list["links"].as_array().map_or(&[], Vec::as_slice); let mut clicked: Option<(u64, egui::Modifiers, bool)> = None; widgets::list_box(ui, |ui| { @@ -354,7 +444,17 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if o.thumb > 0.0 { let tr = egui::Rect::from_min_size(egui::pos2(x, rect.center().y - o.thumb / 2.0), vec2(o.thumb, o.thumb)); ui.painter().rect_filled(tr, 0.0, egui::Color32::WHITE); - if let Some(n) = super::layers::node_arc(&doc, NodeId(id)) { + let node = if nested_row(r) { + cache + .nested + .binary_search_by_key(&id, |(n, _)| *n) + .ok() + .and_then(|i| cache.nested.get(i)) + .map(|(_, n)| std::sync::Arc::clone(n)) + } else { + super::layers::node_arc(&doc, NodeId(id)) + }; + if let Some(n) = node { super::layers::real_thumb(ui, &thumbs, &n, tr); } ui.painter().rect_stroke(tr, 0.0, egui::Stroke::new(1.0, t.border), egui::StrokeKind::Inside); @@ -411,7 +511,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if widgets::icon_button_enabled(ui, "pencil", tl!("Edit Original"), false, can_edit, 24.0).clicked() { - crate::menus::invoke(app, "links.editOriginal", json!({})); + crate::menus::invoke(app, "links.editOriginal", file_row(&sel)); } if widgets::icon_button_enabled(ui, "rotate-cw", tl!("Update Link"), false, !modified.is_empty(), 24.0).clicked() { let r = app.run("links.update", json!({ "ids": modified })); @@ -452,6 +552,15 @@ fn break_link(app: &mut VectorcraftApp, ids: Vec) { }); } +/// The params of Edit Original and Show in Folder for the selected rows `sel`: the first linked +/// row whose file is there (rows outside the layer tree included), else none (the selection's). +fn file_row(sel: &[&Value]) -> Value { + match ids(sel, |r| r["linked"] == true && !status_is(r, "missing")).first() { + Some(id) => json!({ "id": id }), + None => json!({}), + } +} + /// Pick-a-file actions need a file dialog (the desktop app). fn can_pick(app: &VectorcraftApp) -> bool { app.services.pick_open.is_some() @@ -538,7 +647,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { let o = options(ui.ctx()); // The rows the panel shows (the menu opens from it). let list = pstate::(ui.ctx(), "links-cache").list; - let selected = selected_images(app); + let selected = selected_links(app, ui.ctx()); let sel = selected_rows(&list, &selected); let linked = ids(&sel, |r| r["linked"] == true); let embedded: Vec<&Value> = sel.iter().copied().filter(|r| status_is(r, "embedded")).collect(); @@ -563,7 +672,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { report(app, r, |v| updated_status(v["updated"].as_array().map_or(0, Vec::len))); } if menu_item(ui, tl!("Edit Original"), sel.iter().any(|r| r["linked"] == true && !status_is(r, "missing")), false) { - crate::menus::invoke(app, "links.editOriginal", json!({})); + crate::menus::invoke(app, "links.editOriginal", file_row(&sel)); } ui.separator(); if menu_item(ui, tl!("Embed Image(s)"), !linked.is_empty(), false) { @@ -594,7 +703,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { crate::menus::invoke(app, "ui.placementOptionsDialog", json!({ "ids": ids(&sel, |_| true) })); } if menu_item(ui, tl!("Show in Folder"), sel.iter().any(|r| r["linked"] == true && !status_is(r, "missing")), false) { - crate::menus::invoke(app, "links.reveal", json!({})); + crate::menus::invoke(app, "links.reveal", file_row(&sel)); } ui.separator(); // Select Linked, Embedded, Missing and Modified: every such image in the document, not only diff --git a/crates/ui-egui/src/panels/tests_links.rs b/crates/ui-egui/src/panels/tests_links.rs index 215cf7148a..be5e17e374 100644 --- a/crates/ui-egui/src/panels/tests_links.rs +++ b/crates/ui-egui/src/panels/tests_links.rs @@ -132,6 +132,52 @@ fn shift_click_selects_a_range_of_links_and_ctrl_click_toggles_one() { assert_eq!(selected(&app), id(&[3])); } +/// A linked image kept only in a symbol's art has a row of its own: a click picks it in the panel +/// (the canvas selection is cleared), Link Info shows it, Ctrl/Cmd-click adds an image in the +/// layers, a change of the canvas selection drops the pick, and Go To clears the selection and +/// says it has no place on the canvas. +#[test] +fn a_link_inside_a_symbol_is_listed_and_picked_in_the_panel() { + let dir = Folder::new("nested"); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let inside = dir.file("inside.png", &png(30, 20)); + let id = app.run("file.place", json!({"path": inside, "at": [100, 100]})).unwrap()["ids"][0].as_u64().unwrap(); + app.run("symbol.new", json!({"name": "Mark", "ids": [id]})).unwrap(); + app.run("edit.clear", json!({"ids": selected(&app)})).unwrap(); + let outside = dir.file("layer.png", &png(20, 20)); + let layer = app.run("file.place", json!({"path": outside, "at": [300, 100]})).unwrap()["ids"][0].as_u64().unwrap(); + let list = app.run("links.list", json!({})).unwrap(); + let row = list["links"].as_array().unwrap().iter().find(|r| r["name"] == "inside.png").unwrap_or_else(|| panic!("{list}")).clone(); + assert_eq!(row["owner"]["kind"], "symbol", "{list}"); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let shown = |app: &mut VectorcraftApp, time: f64| -> Vec { + let raw = egui::RawInput { time: Some(time), ..Default::default() }; + super::tests_appearance::frame_raw(&ctx, app, raw, super::links::show).into_iter().map(|(t, _)| t).collect() + }; + assert!(shown(&mut app, 0.5).iter().any(|t| t == "20 × 20 px"), "Link Info of the selected image in the layers"); + click_row(&mut app, &ctx, "inside.png", egui::Modifiers::NONE, 1.0); + assert!(selected(&app).is_empty(), "the canvas can't select it"); + assert!(shown(&mut app, 1.5).iter().any(|t| t == "30 × 20 px"), "Link Info of the symbol's image"); + click_row(&mut app, &ctx, "layer.png", egui::Modifiers::COMMAND, 2.0); + assert_eq!(selected(&app), [layer]); + // Both rows are selected until the canvas selection changes: then the panel's pick goes too. + click_row(&mut app, &ctx, "layer.png", egui::Modifiers::COMMAND, 3.0); + assert!(selected(&app).is_empty()); + assert!(shown(&mut app, 3.5).iter().any(|t| t == "30 × 20 px"), "the symbol's image is still picked"); + app.run("select.set", json!({"ids": [layer]})).unwrap(); + assert!(shown(&mut app, 4.0).iter().any(|t| t == "20 × 20 px")); + app.run("select.set", json!({"ids": []})).unwrap(); + let text = shown(&mut app, 4.5); + assert!(!text.iter().any(|t| t == "30 × 20 px" || t == "20 × 20 px"), "nothing is picked: {text:?}"); + // Go To clears the selection and says the link has no place on the canvas. + app.run("select.set", json!({"ids": [layer]})).unwrap(); + super::links::go_to(&mut app, row["id"].as_u64().unwrap()); + assert_eq!(app.ui.status, "The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas"); + assert!(selected(&app).is_empty(), "the image in the layers is no longer selected"); +} + #[test] fn an_empty_document_shows_how_to_add_images() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); diff --git a/docs/mcp.md b/docs/mcp.md index 2c8afc7aea..0837f48fb4 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6403,8 +6403,8 @@ path isn't followed) when it isn't. The desktop app also checks the active docum comes back to the front, following the same preference. `links.check` reports every link's `status` (`ok`, `modified`, `missing`), `links.update {ids?}` reads modified files again and `links.relink {ids?, path | folder}` points images at another file (or each at the file of its name in a folder); images keep their bounds, one undo step each. -`links.relink`'s `scope` picks which links take the new file (Relink's Replace choice): `selected` (the default) relinks `ids`; `all` also relinks every other object in the layers linked to the same file as -one of them; `missingInFolder` relinks `ids` to `path`, then every other missing link to the file of its name in +`links.relink`'s `scope` picks which links take the new file (Relink's Replace choice): `selected` (the default) relinks `ids`; `all` also relinks every other stored object linked to the same file as +one of them, in the layers, opacity-mask art, symbols, graph designs and patterns; `missingInFolder` relinks `ids` to `path`, then every other missing link to the file of its name in `path`'s folder, and lists the names not there in `notFound` (with `copy`, only missing images are looked for); `each` takes `paths: [{ids, path}]` in place of `ids`, `path` and `folder` and relinks each link to its own file. An unknown scope, `paths` without `each`, `each` with `ids`, `path` or `folder`, an entry without `path` or `ids`, an id in two entries and `missingInFolder` with `folder` are @@ -6412,8 +6412,10 @@ errors, and nothing is relinked. Each call is one undo step. The Links panel's R (`ui.relinkDialog`) with a Replace menu of these four choices. A PNG, JPEG, another file (or each at the file of its name in a folder); images keep their bounds, one undo step each. `links.update`, and `document.open` when it reads a modified file, also read the file again for the images and placed -documents inside symbols, graph designs, patterns and opacity masks that show the same content (by default also for -those that only that art shows); they have no ids, so the results do not list them. Saving a document also reads it +documents inside symbols, graph designs, patterns and opacity masks that show the same content. Those objects have ids +of their own: `links.list`, `links.check`, `links.findFiles`, the `missingLinks`, `modifiedLinks` and `updatedLinks` +rows and the link watch list them, and every `links.*` command takes their ids. Opening a document gives an object +there whose id is 0 or already used by another object a new id, and the next save writes it. Saving a document also reads it again for the placed documents of it inside that art in the other open documents. A PNG, JPEG, GIF or WebP file whose pixels can't be read is not shown: `links.relink` fails with "the image's pixels can't be read", and `links.update`, or `document.open` when it reads the file, lists it as missing, its images keeping what they @@ -6653,8 +6655,11 @@ listed in `warnings`. Binary DXF and DWG files answer an error saying to save th ## Links panel `links.list {show?: all|missing|modified|embedded, sort?: name|kind|status}` lists every image in the layers, top -first, as the Links panel does: `{id, name, linked, status: ok|modified|missing|embedded, format, pixelWidth, -pixelHeight, path?, found?, page?, preview?, note?}` (`note`: the image takes more than the decode allowance, 2 GiB +first, as the Links panel does, then every image and placed document in opacity-mask art and in the art of symbols, +graph designs and patterns, one row per stored object (never one per symbol instance or pattern tile): `{id, name, +linked, status: ok|modified|missing|embedded, format, pixelWidth, pixelHeight, path?, found?, page?, preview?, note?, +owner?}` (`owner`, for an object outside the layer tree: `{kind: layers|symbol|graphDesign|pattern, name?, maskOf?}`, +`maskOf` naming the object whose opacity mask holds it; `note`: the image takes more than the decode allowance, 2 GiB or 512 MiB in the web app, as RGBA or as decoded, and it is drawn at the largest size within the allowance ("shown at reduced size"; the document keeps the whole file) or, when it can't be read that way or has more pixels than the allowance has bytes, drawn as a crossed gray box on the canvas and left out of exports ("too large to draw"); `document.open` and `file.place` say the same in their `warnings`). `links.info {id?}` is the Link Info: `image.info`'s fields plus @@ -6662,7 +6667,8 @@ allowance has bytes, drawn as a crossed gray box on the canvas and left out of e counter-clockwise), `placement` and, for a linked file, `fileName`, `location`, `fileSize`, `created`, `modified`. With automation read roots, a file outside them has no `path` or `found` in `links.list` and `links.check`, and no `link`, `path`, `location`, `fileSize` or `modified` in `links.info`; its row keeps the file's name. -`links.goTo {id}` selects an image; `links.embed {ids?}` keeps linked files' pixels in the document (an image whose +`links.goTo {id}` selects an image (`onCanvas: true`); for an object outside the layer tree it clears the selection and +returns `{id, onCanvas: false, owner}`, and the panel says the link has no place on the canvas. `links.embed {ids?}` keeps linked files' pixels in the document (an image whose file is missing stays linked: relink it first); `links.unembed {id, path}` writes an embedded image to a file (another extension converts it) and links to it, and without `path` returns `{name, dataBase64}`. `links.placementOptions {ids?, preserve?: transforms|bounds|fileDimensions|fit|fill, align?: topLeft…bottomRight, diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 1ce5c5fda5..6bcf0ac446 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1629,6 +1629,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - On Windows, a document opened from a mapped network drive finds its links stored with the share's own path (`\\server\share\…`), as the same document opened from the share does; links on other computers stay missing. - On Windows, a link on a drive mapped to a network share other than the document's (`Y:\…`) is missing, with the reason that the path is on another computer, as a link stored with that share's own path is; links on this computer's drives and on the document's share are followed. - In the Links panel, Shift-click selects a range of links and Ctrl/Cmd-click adds or removes one. + - Linked images and placed documents kept only in symbols, graph designs, patterns or opacity masks are listed in the Links panel after the layers' own, one row per stored object, with their status in its Missing and Modified filters, in Check Links, in the missing and modified files reported when a document opens, in the link watch and in Find in Folder; Relink, Update, Embed, Placement Options and Link Info act on them, and Go To says they have no place on the canvas. Opening a document gives such an object whose id is 0 or repeats another object's a new id. - Linked files another app changes while their document is open follow Preferences › File Handling › Update Links, as they do when it opens: Automatically reads them again (an Update Links undo step), Ask When Modified offers to, Manually leaves them modified in the Links panel. The desktop app compares the active document’s linked files by size and modification time every two seconds and when its window comes back to the front, on a worker thread; a file still being written is tried again (`links.updateChanged`). - A look at the linked files whose worker thread ends without a result, such as after a panic, no longer stops later looks: the app clears it, stops asking for a frame every 100 ms, and the next look starts on schedule (`crates/engine/src/tests/linkwatch.rs`, `crates/ui-egui/src/tests/place.rs`). - Import and paste, 2026-10-13: a multi-artboard `.ai` with notes beside its artboards opens from its editing data, a page's art past its artboard no longer lying over the page before it (#1528); a `.ai` gradient whose stops are listed upwards keeps each midpoint on the stop that starts its part of the ramp (#1530); SVG text on a path follows basic shapes (circle, ellipse, rect, line, polyline, polygon) as their SVG 2 equivalent paths (#1531, fixes #1527); PDFs whose alpha soft masks are drawn as images import with the mask instead of empty art (#1551); EPS and PostScript type set in Type 42 (TrueType) fonts imports at its size instead of 1000 times too large (#1553); PDF and EPS import keep a clipping path whose bounding box covers the page but whose shape doesn't (#1544, fixes #1542). Opening or placing a file with an image too large to decode says so, and the Links panel shows it on the row (#1540, part of #1479). Paste takes a PDF or EPS another app copied as editable vector art: from the macOS pasteboard (with SVG), EPS on Windows beside PDF, and `.eps` files copied in a file manager, with the new command `clipboard.importEps` (#1538, fixes #1499). From a7c82ac7f2598f1ebc26422c21fbdad335ef44c1 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 21:24:12 -0800 Subject: [PATCH 2/6] Links: test a DCS image in a symbol updated and relinked by its id The symbol's image keeps following its DCS file's clipping path when it is updated by the id links.list gives it, and loses that path when it is relinked to a plain image. --- crates/engine/src/tests/dcs.rs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/crates/engine/src/tests/dcs.rs b/crates/engine/src/tests/dcs.rs index 2801432a0a..8a75da8208 100644 --- a/crates/engine/src/tests/dcs.rs +++ b/crates/engine/src/tests/dcs.rs @@ -326,6 +326,31 @@ fn update_link_takes_the_files_clipping_path_into_symbols_too() { assert_eq!(symbol_clip_width(&s, "Pic"), None); } +#[test] +fn update_and_relink_by_id_keep_the_clipping_path_of_a_dcs_image_in_a_symbol() { + let dir = temp_dir("symbol-clip-by-id"); + let path = write(&dir, "pic.eps", &two_plates()); + let mut s = session(); + let r = s.execute("file.place", &json!({ "path": path, "at": [100, 100] })).unwrap(); + s.execute("symbol.new", &json!({ "name": "Pic", "ids": [r["ids"][0]] })).unwrap(); + let list = s.execute("links.list", &json!({})).unwrap(); + let row = list["links"].as_array().unwrap().iter().find(|r| r["owner"]["kind"] == "symbol").cloned().expect("the symbol's image is listed"); + let id = row["id"].as_u64().unwrap(); + assert_eq!(row["format"], "DCS", "{list}"); + let before = symbol_clip_width(&s, "Pic").expect("the file's clipping path"); + // The composite now drawn through the left quarter of the page (same length). + let text = String::from_utf8(two_plates()).unwrap().replacen("10 0 lineto 10 10 lineto", " 5 0 lineto 5 10 lineto", 1); + std::fs::write(&path, text.as_bytes()).unwrap(); + assert_eq!(s.execute("links.update", &json!({ "ids": [id] })).unwrap()["updated"], json!([id])); + let after = symbol_clip_width(&s, "Pic").expect("still clipped"); + assert!((after - before / 2.0).abs() < 1e-6, "the symbol's clipping path follows the file: {before} → {after}"); + // Relinked by its id to a plain image: the DCS file's clipping path goes. + let png = dir.join("plain.png"); + image::RgbImage::from_pixel(2, 2, image::Rgb([9, 9, 9])).save(&png).unwrap(); + s.execute("links.relink", &json!({ "ids": [id], "path": png.to_string_lossy() })).unwrap(); + assert_eq!(symbol_clip_width(&s, "Pic"), None); +} + #[test] fn a_file_that_lost_its_clipping_path_unclips_the_image() { let dir = temp_dir("update-lost-clip"); From c2ed2dd3a93dc9d322f309cc132aca06b34559ed Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 21:38:46 -0800 Subject: [PATCH 3/6] Links: Find in Folder copies the files of images in symbols and patterns With Copy into the document's folder, an image picked in the Links panel by the id of an object in a symbol, graph design, pattern or opacity mask was relinked to the found file instead of a copy, because the check for an image looked only in the layers. --- crates/ui-egui/src/dialogs/find_links.rs | 5 ++-- .../ui-egui/src/dialogs/find_links/tests.rs | 29 +++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dialogs/find_links.rs b/crates/ui-egui/src/dialogs/find_links.rs index 05f0e85b60..2e99f5e97e 100644 --- a/crates/ui-egui/src/dialogs/find_links.rs +++ b/crates/ui-egui/src/dialogs/find_links.rs @@ -112,11 +112,12 @@ pub(crate) fn find_in_folder_command(app: &mut VectorcraftApp, folder: Option bool { let Some(id) = id.as_u64() else { return false }; let st = app.session.active(); - st.and_then(|st| st.doc.node(vectorcraft_doc::NodeId(id))).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Image(_))) + st.and_then(|st| st.doc.stored_node(vectorcraft_doc::NodeId(id))).is_some_and(|(n, _)| matches!(n.kind, vectorcraft_doc::NodeKind::Image(_))) } /// Make the dialog's document the active one: it may be another tab's. diff --git a/crates/ui-egui/src/dialogs/find_links/tests.rs b/crates/ui-egui/src/dialogs/find_links/tests.rs index 4722b8b327..0b618c7e6f 100644 --- a/crates/ui-egui/src/dialogs/find_links/tests.rs +++ b/crates/ui-egui/src/dialogs/find_links/tests.rs @@ -255,6 +255,35 @@ fn copy_into_the_documents_folder_copies_before_relinking() { assert!(!frame(&mut app).contains("Copy into the document's folder")); } +/// Copy into the document's folder copies the file of an image in a symbol's art too, picked in the +/// Links panel by its id. +#[test] +fn copy_takes_an_image_in_a_symbol_into_the_documents_folder() { + let root = folder("copy-symbol"); + let mut app = app_for(&root); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let orig = at(&root, "orig/a.png"); + write(&orig, &png(3)); + let placed = app.run("file.place", json!({"path": orig})).unwrap()["ids"][0].clone(); + app.run("symbol.new", json!({"name": "Mark", "ids": [placed]})).unwrap(); + let doc = at(&root, "doc/doc.vectorcraft"); + std::fs::create_dir_all(doc.parent().unwrap()).unwrap(); + app.run("document.save", json!({"path": doc})).unwrap(); + write(&at(&root, "found/a.png"), &png(3)); + let list = app.run("links.list", json!({})).unwrap(); + let row = list["links"].as_array().unwrap().iter().find(|r| r["owner"]["kind"] == "symbol").cloned().expect("the symbol's image"); + app.run("ui.findLinksInFolder", json!({"folder": root.join("found"), "ids": [row["id"]]})).unwrap(); + searched(&mut app); + app.ui.dialog.as_mut().unwrap().fields.insert("copy".into(), json!(true)); + let r = super::super::confirm(&mut app).unwrap(); + assert_eq!((r["relinked"].as_array().map(Vec::len), &r["failed"]), (Some(1), &json!([])), "{r}"); + let copy = at(&root, "doc/a.png"); + assert_eq!(std::fs::read(©).unwrap(), png(3)); + let list = app.run("links.list", json!({})).unwrap(); + let row = list["links"].as_array().unwrap().iter().find(|r| r["owner"]["kind"] == "symbol").cloned().unwrap(); + assert_eq!(row["path"], json!(copy), "{list}"); +} + /// With Copy into the document's folder, a placed document is relinked where its file is. #[test] fn copy_leaves_placed_documents_where_they_are() { From 072b68583a33ee25df466ac286c182ae09fede39 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 22:50:22 -0800 Subject: [PATCH 4/6] Place: placing the same file again shares the symbols and patterns it brought Symbol and pattern art copied in by a placement takes ids of the document's own, so the next placement of the same file compared it with the file's art, found the ids different and added Star 2, Dots 2 and so on. Definitions are now compared by what their art draws, whatever its ids. A symbol, pattern or brush whose art uses an image, symbol, pattern, brush or swatch that comes in under another name is copied under a free name instead of shared. --- crates/engine/src/cmd/place/adopt.rs | 100 ++++++++++++++++++++++++--- crates/engine/src/tests/place.rs | 68 ++++++++++++++++++ 2 files changed, 160 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/place/adopt.rs b/crates/engine/src/cmd/place/adopt.rs index 0a0f08d3c1..9cd28b7e25 100644 --- a/crates/engine/src/cmd/place/adopt.rs +++ b/crates/engine/src/cmd/place/adopt.rs @@ -10,7 +10,7 @@ use std::sync::Arc; use vectorcraft_brush::{Brush, BrushKind}; use vectorcraft_color::Paint; -use vectorcraft_doc::{AppearanceItem, Document, Node, NodeKind}; +use vectorcraft_doc::{AppearanceItem, Document, Node, NodeKind, PatternDef, Symbol}; use super::unique_name; @@ -118,11 +118,12 @@ impl Used { type Renames = BTreeMap, bool)>; /// Names in `dst` for the `used` definitions of `src` (`def`): the same name when `dst` has none -/// (copied) or an equal one (shared), else a free variant (`taken`, copied). -fn names( +/// (copied) or one that is the `same` (shared), else a free variant (`taken`, copied). +fn names( used: &[String], def: impl Fn(&str) -> Option, existing: impl Fn(&str) -> Option, + same: impl Fn(&T, &T) -> bool, taken: impl Fn(&str) -> bool, ) -> Renames { let mut out = Renames::new(); @@ -130,7 +131,7 @@ fn names( let Some(d) = def(name) else { continue }; let entry = match existing(name) { None => (Some(name.clone()), true), - Some(e) if e == d => (Some(name.clone()), false), + Some(e) if same(&e, &d) => (Some(name.clone()), false), Some(_) => { (Some(unique_name(name, |c| taken(c) || used.iter().any(|u| u == c) || out.values().any(|(n, _)| n.as_deref() == Some(c)))), true) } @@ -140,6 +141,65 @@ fn names( out } +/// `n` as `dst` would copy it in ([`Document::reid`]), with the ids a document of its own gives it: +/// art copied in once, which differs from the file's only in its ids, compares equal to the file's. +fn canon(dst: &Document, n: &Node) -> Node { + let mut own = Document::new(0.0, 0.0); + // Pixels the art carries take a key `dst` doesn't use for other pixels, as they do in `dst`. + let mut keys = vec![]; + vectorcraft_doc::swatches::walk_with_masks(n, &mut |c| { + if let NodeKind::Image(im) = &c.kind + && im.pixels.is_some() + { + keys.push(im.key.clone()); + } + }); + for (k, blob) in &dst.images { + if keys.iter().any(|key| k == key || k.strip_prefix(key.as_str()).is_some_and(|rest| rest.starts_with('-'))) { + own.images.insert(k.clone(), blob.clone()); + } + } + own.reid(n) +} + +/// Do symbols `a` and `b` draw the same, whatever ids their art has ([`canon`])? +fn same_symbol(dst: &Document, a: &Symbol, b: &Symbol) -> bool { + let own = |s: &Symbol| Symbol { art: Arc::new(canon(dst, &s.art)), ..s.clone() }; + own(a) == own(b) +} + +/// Do patterns `a` and `b` draw the same, whatever ids their art has ([`canon`])? +fn same_pattern(dst: &Document, a: &PatternDef, b: &PatternDef) -> bool { + let bare = |p: &PatternDef| PatternDef { art: vec![], ..p.clone() }; + bare(a) == bare(b) && a.art.len() == b.art.len() && a.art.iter().zip(&b.art).all(|(x, y)| canon(dst, x) == canon(dst, y)) +} + +/// Does art `a` use a resource that `m` (images, symbols, patterns, brushes, swatches) gives +/// another name, or a swatch whose link `m` drops? +fn uses_moved(a: &Node, m: [&Renames; 5]) -> bool { + let moved = |map: &Renames, k: &str| map.get(k).is_some_and(|(new, _)| new.as_deref() != Some(k)); + let (mut a, mut any) = (a.clone(), false); + refs(&mut a, &mut |r| { + any |= match r { + Ref::Image(k) => moved(m[0], k), + Ref::Symbol(k) => moved(m[1], k), + Ref::Pattern(k) => moved(m[2], k), + Ref::Brush(k) => moved(m[3], k), + Ref::Swatch(link) => link.as_deref().is_some_and(|k| moved(m[4], k)), + } + }); + any +} + +/// Copy each of `shared` in `map` under a free name, as [`names`] does for a definition `dst` +/// holds differently. +fn unshare(map: &mut Renames, shared: &[String], used: &[String], taken: impl Fn(&str) -> bool) { + for name in shared { + let free = unique_name(name, |c| taken(c) || used.iter().any(|u| u == c) || map.values().any(|(n, _)| n.as_deref() == Some(c))); + map.insert(name.clone(), (Some(free), true)); + } +} + /// The renamed resources whose definitions are copied: (name in `src`, name in `dst`). fn copies(m: &Renames) -> Vec<(&str, &str)> { m.iter().filter(|(_, (_, copy))| *copy).filter_map(|(old, (new, _))| Some((old.as_str(), new.as_deref()?))).collect() @@ -194,14 +254,17 @@ pub(crate) fn adopt(dst: &mut Document, src: &Document, nodes: &mut [Node]) { } } - let images = names(&used.images, |k| src.images.get(k), |k| dst.images.get(k), |c| dst.images.contains_key(c)); - let symbols = names( + let images = names(&used.images, |k| src.images.get(k), |k| dst.images.get(k), |a, b| a == b, |c| dst.images.contains_key(c)); + // Symbol and pattern art copied in takes ids of `dst`, so their art is compared by what it + // draws: placing the same file again shares what the first placement brought. + let mut symbols = names( &used.symbols, |n| src.symbols.iter().find(|s| s.name == n), |n| dst.symbols.iter().find(|s| s.name == n), + |a, b| same_symbol(dst, a, b), |c| dst.symbols.iter().any(|s| s.name == c), ); - let patterns = names(&used.patterns, |n| src.pattern(n), |n| dst.pattern(n), |c| dst.pattern(c).is_some()); + let mut patterns = names(&used.patterns, |n| src.pattern(n), |n| dst.pattern(n), |a, b| same_pattern(dst, a, b), |c| dst.pattern(c).is_some()); // A swatch the document defines differently isn't renamed (that would add look-alike swatches): // the colours linked to it keep their look, unlinked. let swatches: Renames = used @@ -236,12 +299,33 @@ pub(crate) fn adopt(dst: &mut Document, src: &Document, nodes: &mut [Node]) { any }; let mut dst_brushes = vectorcraft_brush::library(dst); - let brushes = names( + let mut brushes = names( &used.brushes, |n| src_brushes.iter().find(|b| b.name == n).map(|b| (b, moves_art(b))), |n| dst_brushes.iter().find(|b| b.name == n).map(|b| (b, false)), + |a, b| a == b, |c| dst_brushes.iter().any(|b| b.name == c), ); + // Likewise a symbol, pattern or brush whose art uses a resource that moves isn't the + // document's of that name: it is copied under a free name, which can move what uses it in turn. + loop { + let maps = [&images, &symbols, &patterns, &brushes, &swatches]; + let shared = |map: &Renames| map.iter().filter(|(_, (_, copy))| !*copy).map(|(k, _)| k.clone()).collect::>(); + let sym: Vec = + shared(&symbols).into_iter().filter(|n| src.symbols.iter().any(|s| s.name == *n && uses_moved(&s.art, maps))).collect(); + let pat: Vec = + shared(&patterns).into_iter().filter(|n| src.pattern(n).is_some_and(|p| p.art.iter().any(|a| uses_moved(a, maps)))).collect(); + let bru: Vec = shared(&brushes) + .into_iter() + .filter(|n| src_brushes.iter().find(|b| b.name == *n).is_some_and(|b| brush_art(&mut b.clone()).into_iter().any(|a| uses_moved(a, maps)))) + .collect(); + if sym.is_empty() && pat.is_empty() && bru.is_empty() { + break; + } + unshare(&mut symbols, &sym, &used.symbols, |c| dst.symbols.iter().any(|s| s.name == c)); + unshare(&mut patterns, &pat, &used.patterns, |c| dst.pattern(c).is_some()); + unshare(&mut brushes, &bru, &used.brushes, |c| dst_brushes.iter().any(|b| b.name == c)); + } let mut rename = |r: Ref| { let (map, k) = match r { diff --git a/crates/engine/src/tests/place.rs b/crates/engine/src/tests/place.rs index 455471dbb1..ab80255376 100644 --- a/crates/engine/src/tests/place.rs +++ b/crates/engine/src/tests/place.rs @@ -689,6 +689,74 @@ fn placed_art_brings_its_symbols_and_patterns_with_ids_of_the_documents_own() { assert_eq!(unique.len(), ids.len(), "{ids:?}"); } +/// Placing the same file again shares the symbols and patterns the first placement brought: their +/// art differs from the file's only in its ids, so no `Star 2` or `Dots 2` joins the document. +#[test] +fn placing_the_same_art_again_shares_its_symbols_and_patterns() { + let mut src = session(); + src.execute("shape.ellipse", &json!({"x": 50, "y": 10, "width": 20, "height": 20})).unwrap(); + src.execute("symbol.new", &json!({"name": "Star"})).unwrap(); + src.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 8, "height": 8})).unwrap(); + let tile = src.doc().unwrap().selection.objects[0]; + src.execute("object.pattern.make", &json!({"ids": [tile.0], "edit": false, "name": "Dots"})).unwrap(); + src.execute("paint.setFill", &json!({"ids": [tile.0], "swatch": "Dots"})).unwrap(); + let bytes = serialize(&mut src, "vectorcraft"); + let mut s = session(); + let counts = |s: &Session| { + let doc = &s.doc().unwrap().doc; + (doc.symbols.len(), doc.patterns.len()) + }; + place(&mut s, "logo.vectorcraft", &bytes, json!({})); + let once = counts(&s); + for _ in 0..2 { + place(&mut s, "logo.vectorcraft", &bytes, json!({})); + } + assert_eq!(counts(&s), once); + let doc = &s.doc().unwrap().doc; + assert!(!doc.symbols.iter().any(|x| x.name == "Star 2") && doc.pattern("Dots 2").is_none()); +} + +/// A symbol whose art matches the document's own but uses a pattern the document defines +/// differently is not shared: it comes in under a free name, using the pattern it brings. +#[test] +fn placed_art_does_not_share_a_symbol_whose_pattern_moves() { + let mut src = session(); + src.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 8, "height": 8})).unwrap(); + let tile = src.doc().unwrap().selection.objects[0]; + src.execute("object.pattern.make", &json!({"ids": [tile.0], "edit": false, "name": "Dots"})).unwrap(); + src.execute("paint.setFill", &json!({"ids": [tile.0], "swatch": "Dots"})).unwrap(); + src.execute("symbol.new", &json!({"name": "Star", "ids": [tile.0]})).unwrap(); + let bytes = serialize(&mut src, "vectorcraft"); + let mut s = session(); + place(&mut s, "logo.vectorcraft", &bytes, json!({})); + // The document's "Dots" changes; its "Star" still matches the file's. + let st = s.doc_mut().unwrap(); + let d = Arc::make_mut(&mut st.doc); + let dots = d.pattern_mut("Dots").unwrap(); + dots.tile = Rect::new(0.0, 0.0, 50.0, 50.0); + let r = place(&mut s, "logo.vectorcraft", &bytes, json!({})); + let mut symbol = None; + node(&s, &r["ids"][0]).walk(&mut |n| { + if let NodeKind::SymbolInstance { symbol: name, .. } = &n.kind { + symbol = Some(name.clone()); + } + }); + assert_eq!(symbol.as_deref(), Some("Star 2")); + let doc = &s.doc().unwrap().doc; + let star2 = doc.symbols.iter().find(|x| x.name == "Star 2").unwrap(); + let mut fills = vec![]; + star2.art.walk(&mut |n| { + fills.extend(n.appearance.items.iter().filter_map(|i| match i { + AppearanceItem::Fill(f) => match &f.paint { + Paint::Pattern { pattern, .. } => Some(pattern.clone()), + _ => None, + }, + _ => None, + })) + }); + assert_eq!(fills, ["Dots 2"]); +} + #[test] fn placed_art_brings_its_brushes_renaming_on_conflict() { // The file: a path with its own brush, and one with a brush the defaults hold. From 64241978ec9e2814135382782e19bf9192eb1d02 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 22:50:22 -0800 Subject: [PATCH 5/6] Links: a nested row's tooltip names the symbol, graph design, pattern or mask holding it Two rows of the same file in different symbols showed the same name, thumbnail and status. The tooltip of a row outside the layer tree now adds the symbol, graph design or pattern whose art holds the image, and the object whose opacity mask holds it. --- crates/ui-egui/src/panels/links.rs | 33 ++++++++++++++++++++++-- crates/ui-egui/src/panels/tests_links.rs | 18 +++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/links.rs b/crates/ui-egui/src/panels/links.rs index af85690e9d..5c95e17bc7 100644 --- a/crates/ui-egui/src/panels/links.rs +++ b/crates/ui-egui/src/panels/links.rs @@ -344,7 +344,31 @@ fn badge(ui: &Ui, r: egui::Rect, status: &str) { } } -fn status_tip(r: &Value) -> String { +/// The tooltip of row `r`. A row outside the layer tree also names where it is stored +/// (`links.list`'s `owner`): the symbol, graph design or pattern whose art holds it, and the object +/// whose opacity mask holds it, by `mask` (that object's name) or else by its id. +pub(super) fn status_tip(r: &Value, mask: Option) -> String { + let tip = file_tip(r); + let owner = &r["owner"]; + let name = owner["name"].as_str().unwrap_or_default(); + let art = match owner["kind"].as_str() { + Some("symbol") => Some(tl!("Symbols")), + Some("graphDesign") => Some(tl!("Graph Design")), + Some("pattern") => Some(tl!("Pattern")), + _ => None, + }; + let tip = match art { + Some(kind) => format!("{tip}\n{kind}: {name}"), + None => tip, + }; + match owner["maskOf"].as_u64() { + Some(id) => format!("{tip}\n{}: {}", tl!("Opacity Mask"), mask.unwrap_or_else(|| id.to_string())), + None => tip, + } +} + +/// The file, status and notes part of [`status_tip`]. +fn file_tip(r: &Value) -> String { let path = r["found"].as_str().or(r["path"].as_str()).unwrap_or_default(); let tip = match r["status"].as_str().unwrap_or_default() { "missing" => crate::i18n::fmt(tl!("Missing: {path}"), &[("path", path)]), @@ -495,7 +519,12 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { egui::FontId::proportional(12.5), t.text, ); - let resp = resp.on_hover_text(status_tip(r)); + let mask = r["owner"]["maskOf"] + .as_u64() + .and_then(|m| doc.stored_node(NodeId(m))) + .map(|(n, _)| n) + .map(|n| super::layers::shown_name(app, n, crate::i18n::current())); + let resp = resp.on_hover_text(status_tip(r, mask)); if resp.double_clicked() { clicked = Some((id, egui::Modifiers::NONE, true)); } else if resp.clicked() { diff --git a/crates/ui-egui/src/panels/tests_links.rs b/crates/ui-egui/src/panels/tests_links.rs index be5e17e374..89dfba5f43 100644 --- a/crates/ui-egui/src/panels/tests_links.rs +++ b/crates/ui-egui/src/panels/tests_links.rs @@ -178,6 +178,24 @@ fn a_link_inside_a_symbol_is_listed_and_picked_in_the_panel() { assert!(selected(&app).is_empty(), "the image in the layers is no longer selected"); } +/// The tooltip of a row outside the layer tree says where the image is stored, so two rows of the +/// same file in different symbols or masks can be told apart. +#[test] +fn a_nested_rows_tooltip_names_what_holds_it() { + let tip = |owner: serde_json::Value, mask: Option<&str>| { + let row = json!({"id": 9, "name": "a.png", "status": "ok", "path": "/x/a.png", "owner": owner}); + super::links::status_tip(&row, mask.map(str::to_string)) + }; + assert_eq!(tip(json!({"kind": "symbol", "name": "Mark"}), None), "/x/a.png\nSymbols: Mark"); + assert_eq!(tip(json!({"kind": "pattern", "name": "Dots"}), None), "/x/a.png\nPattern: Dots"); + assert_eq!(tip(json!({"kind": "graphDesign", "name": "Bar"}), None), "/x/a.png\nGraph Design: Bar"); + assert_eq!(tip(json!({"kind": "layers", "maskOf": 7}), Some("")), "/x/a.png\nOpacity Mask: "); + assert_eq!(tip(json!({"kind": "symbol", "name": "Mark", "maskOf": 7}), None), "/x/a.png\nSymbols: Mark\nOpacity Mask: 7"); + // A row in the layers says only its file. + let row = json!({"id": 9, "name": "a.png", "status": "ok", "path": "/x/a.png"}); + assert_eq!(super::links::status_tip(&row, None), "/x/a.png"); +} + #[test] fn an_empty_document_shows_how_to_add_images() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); From a367065afc0e59511c22bea468292635edfcf5c9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 23:26:25 -0800 Subject: [PATCH 6/6] Links: a mask row's tooltip finds its object without walking the stored art The panel collects the objects whose opacity masks hold a listed image in the same pass over the stored art that collects the rows' objects, once per list rebuild. Each frame then finds a row's mask object by the layer tree's index or in that cache, instead of walking every stored root for objects inside symbols, graph designs and patterns. --- crates/ui-egui/src/panels/links.rs | 40 ++++++++++++++---------- crates/ui-egui/src/panels/tests_links.rs | 33 +++++++++++++++++++ 2 files changed, 57 insertions(+), 16 deletions(-) diff --git a/crates/ui-egui/src/panels/links.rs b/crates/ui-egui/src/panels/links.rs index 5c95e17bc7..2fb130ae3e 100644 --- a/crates/ui-egui/src/panels/links.rs +++ b/crates/ui-egui/src/panels/links.rs @@ -60,10 +60,18 @@ struct Cache { at: f64, list: Value, info: Option<(u64, Value)>, - /// The objects of the rows outside the layer tree, by id, for their thumbnails. + /// The objects outside the layer tree, by id: those of the rows, for their thumbnails, and + /// those whose opacity masks hold a row's image, for the tooltip. nested: Vec<(u64, std::sync::Arc)>, } +impl Cache { + /// Object `id` outside the layer tree, as [`cached`] collected it. + fn nested_node(&self, id: u64) -> Option<&std::sync::Arc> { + self.nested.binary_search_by_key(&id, |(n, _)| *n).ok().and_then(|i| self.nested.get(i)).map(|(_, n)| n) + } +} + fn options(ctx: &egui::Context) -> Options { pstate(ctx, "links-options") } @@ -86,9 +94,16 @@ fn cached(app: &mut VectorcraftApp, ctx: &egui::Context, o: &Options) -> Option< if stale { c.list = app.session.execute("links.list", &json!({ "show": o.show, "sort": o.sort, "dcsTransparency": o.dcs })).ok()?; (c.key, c.at) = (key, now); - // One pass over the stored art for the rows outside the layer tree, not one per frame. - let mut want: std::collections::HashSet = - c.list["links"].as_array().into_iter().flatten().filter(|r| nested_row(r)).filter_map(|r| r["id"].as_u64()).collect(); + // One pass over the stored art for the rows outside the layer tree and the objects whose + // opacity masks hold them, not one per frame. + let mut want: std::collections::HashSet = c.list["links"] + .as_array() + .into_iter() + .flatten() + .filter(|r| nested_row(r)) + .flat_map(|r| [r["id"].as_u64(), r["owner"]["maskOf"].as_u64()]) + .flatten() + .collect(); c.nested.clear(); if !want.is_empty() && let Some(st) = app.session.active() @@ -477,16 +492,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if o.thumb > 0.0 { let tr = egui::Rect::from_min_size(egui::pos2(x, rect.center().y - o.thumb / 2.0), vec2(o.thumb, o.thumb)); ui.painter().rect_filled(tr, 0.0, egui::Color32::WHITE); - let node = if nested_row(r) { - cache - .nested - .binary_search_by_key(&id, |(n, _)| *n) - .ok() - .and_then(|i| cache.nested.get(i)) - .map(|(_, n)| std::sync::Arc::clone(n)) - } else { - super::layers::node_arc(&doc, NodeId(id)) - }; + let node = + if nested_row(r) { cache.nested_node(id).map(std::sync::Arc::clone) } else { super::layers::node_arc(&doc, NodeId(id)) }; if let Some(n) = node { super::layers::real_thumb(ui, &thumbs, &n, tr); } @@ -519,10 +526,11 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { egui::FontId::proportional(12.5), t.text, ); + // The mask's object by the layer tree's index or from the cache: no walk of the + // stored art each frame. let mask = r["owner"]["maskOf"] .as_u64() - .and_then(|m| doc.stored_node(NodeId(m))) - .map(|(n, _)| n) + .and_then(|m| doc.node(NodeId(m)).or_else(|| cache.nested_node(m).map(|n| &**n))) .map(|n| super::layers::shown_name(app, n, crate::i18n::current())); let resp = resp.on_hover_text(status_tip(r, mask)); if resp.double_clicked() { diff --git a/crates/ui-egui/src/panels/tests_links.rs b/crates/ui-egui/src/panels/tests_links.rs index 89dfba5f43..8055c1288c 100644 --- a/crates/ui-egui/src/panels/tests_links.rs +++ b/crates/ui-egui/src/panels/tests_links.rs @@ -196,6 +196,39 @@ fn a_nested_rows_tooltip_names_what_holds_it() { assert_eq!(super::links::status_tip(&row, None), "/x/a.png"); } +/// The tooltip of an image in the opacity mask of an object inside a symbol names that object, +/// which the panel finds among the objects it collected with the list. +#[test] +fn a_mask_rows_tooltip_names_its_object_inside_a_symbol() { + let dir = Folder::new("mask-owner"); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let rect = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 60, "height": 60})).unwrap()["id"].as_u64().unwrap(); + let mask = dir.file("mask.png", &png(20, 20)); + let mask = app.run("file.place", json!({"path": mask, "at": [40, 40]})).unwrap()["ids"][0].as_u64().unwrap(); + app.run("transparency.makeOpacityMask", json!({"ids": [rect, mask]})).unwrap(); + app.run("symbol.new", json!({"name": "Mark", "ids": [rect]})).unwrap(); + app.run("edit.clear", json!({"ids": selected(&app)})).unwrap(); + let list = app.run("links.list", json!({})).unwrap(); + let row = list["links"].as_array().unwrap().iter().find(|r| r["name"] == "mask.png").unwrap_or_else(|| panic!("{list}")).clone(); + assert_eq!(row["owner"]["kind"], "symbol", "{list}"); + let owner = vectorcraft_doc::NodeId(row["owner"]["maskOf"].as_u64().unwrap_or_else(|| panic!("{list}"))); + let doc = app.session.active().unwrap().doc.clone(); + assert!(doc.node(owner).is_none(), "the masked object is outside the layer tree"); + let name = super::layers::shown_name(&app, doc.stored_node(owner).unwrap().0, crate::i18n::current()); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let frame = |app: &mut VectorcraftApp, events: Vec, time: f64| { + super::tests_appearance::frame_raw(&ctx, app, egui::RawInput { events, time: Some(time), ..Default::default() }, super::links::show) + }; + let at = super::tests_appearance::text_rect(&frame(&mut app, vec![], 1.0), "mask.png").center(); + frame(&mut app, vec![egui::Event::PointerMoved(at)], 2.0); + frame(&mut app, vec![], 4.0); + let text: Vec = frame(&mut app, vec![], 4.1).into_iter().map(|(t, _)| t).collect(); + let want = format!("Opacity Mask: {name}"); + assert!(text.iter().any(|t| t.contains(&want)), "{want} in {text:?}"); +} + #[test] fn an_empty_document_shows_how_to_add_images() { let mut app = VectorcraftApp::new(Session::new(), Default::default());