mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-11 06:01:18 +00:00
Merge pull request #6044 from dotCooCoo/fix/paste-many
Paste: skips relinking copies with no renames, global swatches or styles, and finds type in one walk
This commit is contained in:
@@ -348,11 +348,14 @@ impl Document {
|
||||
t.cached_bounds = None;
|
||||
t.cached_baselines.clear();
|
||||
}
|
||||
let on_path = n.extras().objects_on_path.is_some();
|
||||
let mut renamed = std::collections::HashMap::new();
|
||||
if let Some(ch) = n.children_mut() {
|
||||
let old: Vec<Arc<Node>> = std::mem::take(ch);
|
||||
*ch = old.iter().map(|c| Arc::new(self.reid(c))).collect();
|
||||
renamed.extend(old.iter().zip(ch.iter()).map(|(o, c)| (o.id, c.id)));
|
||||
if on_path {
|
||||
renamed.extend(old.iter().zip(ch.iter()).map(|(o, c)| (o.id, c.id)));
|
||||
}
|
||||
}
|
||||
// An Objects on Path group names its path and its objects by id: the copy names the copies.
|
||||
if let Some(e) = n.extras_if_any()
|
||||
|
||||
@@ -669,6 +669,9 @@ impl SymbolOptions {
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct Imported {
|
||||
relink: Relink,
|
||||
/// Whether relinking can change a pasted copy: a resource has another name or color in the
|
||||
/// document, or the objects link a graphic style.
|
||||
relinks: bool,
|
||||
/// Resources added to the document (renamed ones included).
|
||||
pub added: usize,
|
||||
/// Swatch conflicts resolved by merging.
|
||||
@@ -690,7 +693,9 @@ impl Imported {
|
||||
}
|
||||
/// Point a pasted copy at the document's resources.
|
||||
pub(crate) fn apply(&self, n: &mut Node) {
|
||||
self.relink.node(n);
|
||||
if self.relinks {
|
||||
self.relink.node(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -822,6 +827,7 @@ impl Clipboard {
|
||||
}
|
||||
vectorcraft_brush::store(d, &lib);
|
||||
}
|
||||
out.relinks = !(out.relink.renames.is_empty() && out.relink.styles.is_empty() && out.relink.colors.is_empty() && used.styles.is_empty());
|
||||
out
|
||||
}
|
||||
|
||||
|
||||
@@ -432,7 +432,7 @@ fn columns_hold(lines: &[vectorcraft_text::LineInfo], cols: usize, y0: f64, inse
|
||||
/// Transform panel and hit testing would fall back to [`TextObject::estimate_bounds`] (a rough
|
||||
/// 0.55 em per character) until the text is edited — the box comes up short and alignment looks off.
|
||||
pub(crate) fn refresh_bounds_of(d: &mut Document, ids: &[NodeId]) {
|
||||
for id in text_ids(d, ids) {
|
||||
for id in text_ids_where(d, ids, |t| t.cached_bounds.is_none()) {
|
||||
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind)
|
||||
&& t.cached_bounds.is_none()
|
||||
{
|
||||
@@ -498,11 +498,22 @@ fn overflow_note(ids: &[NodeId], left_out: &str) -> Option<String> {
|
||||
|
||||
/// Text objects among `ids` and their descendants.
|
||||
fn text_ids(d: &Document, ids: &[NodeId]) -> Vec<NodeId> {
|
||||
text_ids_where(d, ids, |_| true)
|
||||
}
|
||||
|
||||
/// The text objects among `ids` and their descendants that `keep` accepts, each once, in the order
|
||||
/// they are found. All of `ids` are looked up in one walk of the tree.
|
||||
fn text_ids_where(d: &Document, ids: &[NodeId], keep: impl Fn(&TextObject) -> bool) -> Vec<NodeId> {
|
||||
let paths = d.index_paths(ids.iter().copied());
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut out = vec![];
|
||||
for id in ids {
|
||||
if let Some(n) = d.node(*id) {
|
||||
if let Some(n) = paths.get(id).and_then(|p| d.node_at(p)) {
|
||||
n.walk(&mut |c| {
|
||||
if matches!(c.kind, NodeKind::Text(_)) && !out.contains(&c.id) {
|
||||
if let NodeKind::Text(t) = &c.kind
|
||||
&& keep(t)
|
||||
&& seen.insert(c.id)
|
||||
{
|
||||
out.push(c.id);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -108,11 +108,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
/// Text objects among `ids` and their descendants.
|
||||
pub(crate) fn text_ids(d: &Document, ids: &[NodeId]) -> Vec<NodeId> {
|
||||
let paths = d.index_paths(ids.iter().copied());
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut out = vec![];
|
||||
for id in ids {
|
||||
if let Some(n) = d.node(*id) {
|
||||
if let Some(n) = paths.get(id).and_then(|p| d.node_at(p)) {
|
||||
n.walk(&mut |c| {
|
||||
if matches!(c.kind, NodeKind::Text(_)) && !out.contains(&c.id) {
|
||||
if matches!(c.kind, NodeKind::Text(_)) && seen.insert(c.id) {
|
||||
out.push(c.id);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ mod known_bugs;
|
||||
mod lossy_overwrite;
|
||||
mod model_based;
|
||||
mod param_keys;
|
||||
mod paste_many;
|
||||
mod pen_click_after_smooth;
|
||||
mod pen_endpoint_picking;
|
||||
mod ruler_origin;
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
//! Paste of many objects: what lands in the document (nodes, ids, names, order, selection, one undo
|
||||
//! step), and the shortcuts the paste takes for objects that have nothing to relink or rename.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind};
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
fn run(s: &mut Session, command: &str, params: Value) -> Value {
|
||||
s.execute(command, ¶ms).unwrap()
|
||||
}
|
||||
|
||||
fn id_of(v: &Value) -> NodeId {
|
||||
NodeId(v["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn ids_of(v: &Value) -> Vec<NodeId> {
|
||||
v["ids"].as_array().unwrap().iter().map(|i| NodeId(i.as_u64().unwrap())).collect()
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session, x: f64) -> NodeId {
|
||||
id_of(&run(s, "shape.rectangle", json!({"x": x, "y": 10, "width": 8, "height": 8})))
|
||||
}
|
||||
|
||||
/// Three named groups of three named rectangles each, on the document's only layer.
|
||||
fn named_groups() -> Session {
|
||||
let mut doc = Document::new(400.0, 400.0);
|
||||
let layer = doc.layers[0].id;
|
||||
for g in 0..3 {
|
||||
let group = doc.alloc_id();
|
||||
let mut node = Node::group(group, vec![]);
|
||||
node.name = Some(format!("group {g}"));
|
||||
doc.insert(Some(layer), usize::MAX, node).unwrap();
|
||||
for r in 0..3 {
|
||||
let x = f64::from(g * 3 + r) * 10.0;
|
||||
let shape = vectorcraft_geom::rect_from_points(Point::new(x, 0.0), Point::new(x + 8.0, 8.0));
|
||||
let appearance = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 1.0);
|
||||
let id = doc.alloc_id();
|
||||
let mut node = Node::path(id, vectorcraft_geom::shapes::rectangle(shape), appearance);
|
||||
node.name = Some(format!("rect {g}.{r}"));
|
||||
doc.insert(Some(group), usize::MAX, node).unwrap();
|
||||
}
|
||||
}
|
||||
let mut s = Session::new();
|
||||
s.add_document(doc, None);
|
||||
s
|
||||
}
|
||||
|
||||
/// `n` and its descendants in tree order, as (id, name).
|
||||
fn tree(n: &Node) -> Vec<(NodeId, Option<String>)> {
|
||||
let mut out = vec![];
|
||||
n.walk(&mut |c| out.push((c.id, c.name.clone())));
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_paste_of_groups_gives_the_same_nodes_new_ids_in_tree_order_and_one_undo_step() {
|
||||
let mut s = named_groups();
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "edit.copy", json!({}));
|
||||
let st = s.doc().unwrap();
|
||||
let (first_id, steps, before) = (st.doc.peek_next_id(), st.history.undo.len(), st.doc.clone());
|
||||
let sources: Vec<NodeId> = st.selection.objects.clone();
|
||||
|
||||
let pasted = run(&mut s, "edit.paste", json!({"dx": 5, "dy": 7}));
|
||||
let ids = ids_of(&pasted);
|
||||
let st = s.doc().unwrap();
|
||||
|
||||
// The copies, on top of the layer in the order of their sources, are selected.
|
||||
assert_eq!(ids.len(), sources.len());
|
||||
assert_eq!(st.selection.objects, ids);
|
||||
let top: Vec<NodeId> = st.doc.layers[0].children().unwrap().iter().map(|c| c.id).collect();
|
||||
assert_eq!(top[sources.len()..], ids[..]);
|
||||
assert_eq!(top[..sources.len()], sources[..]);
|
||||
|
||||
// Names and geometry match the sources; ids run on from the counter in tree order.
|
||||
let mut next = first_id;
|
||||
for (source, copy) in sources.iter().zip(&ids) {
|
||||
let (source, copy) = (st.doc.node(*source).unwrap(), st.doc.node(*copy).unwrap());
|
||||
let (a, b) = (tree(source), tree(copy));
|
||||
assert_eq!(a.len(), b.len());
|
||||
for ((_, name_a), (id_b, name_b)) in a.iter().zip(&b) {
|
||||
assert_eq!(name_a, name_b);
|
||||
assert_eq!(id_b.0, next);
|
||||
next += 1;
|
||||
}
|
||||
let (from, to) = (source.geometric_bounds().unwrap(), copy.geometric_bounds().unwrap());
|
||||
assert!((to.x0 - from.x0 - 5.0).abs() < 1e-9 && (to.y0 - from.y0 - 7.0).abs() < 1e-9, "{from:?} {to:?}");
|
||||
}
|
||||
assert_eq!(st.doc.peek_next_id(), next);
|
||||
|
||||
// One undo step, back to the document as it was.
|
||||
assert_eq!(st.history.undo.len(), steps + 1);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(st.history.undo.len(), steps);
|
||||
assert_eq!(*st.doc, *before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasted_type_in_a_group_gets_its_layout_bounds() {
|
||||
let mut s = Session::new();
|
||||
run(&mut s, "file.new", json!({"width": 500, "height": 200}));
|
||||
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="500" height="200"><g><text x="0" y="40" font-family="Source Sans 3" font-size="20">WWW</text><text x="0" y="80" font-family="Source Sans 3" font-size="20">MMM</text></g></svg>"#;
|
||||
run(&mut s, "clipboard.importSvg", json!({"svg": svg}));
|
||||
run(&mut s, "edit.paste", json!({}));
|
||||
let mut checked = 0;
|
||||
s.doc().unwrap().doc.walk(|n| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
assert!(t.cached_bounds.is_some(), "{:?} has no layout bounds", n.id);
|
||||
checked += 1;
|
||||
}
|
||||
});
|
||||
assert_eq!(checked, 2);
|
||||
}
|
||||
|
||||
/// A group holding point type and a rectangle: (the group, the type).
|
||||
fn group_with_type() -> (Session, NodeId, NodeId) {
|
||||
let mut s = Session::new();
|
||||
run(&mut s, "file.new", json!({"width": 500, "height": 200}));
|
||||
let text = id_of(&run(&mut s, "text.create", json!({"x": 10, "y": 40, "text": "a", "size": 12})));
|
||||
let other = rect(&mut s, 100.0);
|
||||
run(&mut s, "select.set", json!({"ids": [text.0, other.0]}));
|
||||
let group = id_of(&run(&mut s, "object.group", json!({})));
|
||||
(s, group, text)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_and_replace_given_a_group_and_type_in_it_changes_that_type_once() {
|
||||
let (mut s, group, text) = group_with_type();
|
||||
// Replacing "a" with "aa" twice would leave "aaaa".
|
||||
run(&mut s, "edit.findReplace", json!({"find": "a", "replace": "aa", "ids": [group.0, text.0, group.0]}));
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let NodeKind::Text(t) = &doc.node(text).unwrap().kind else { panic!("the type is gone") };
|
||||
assert_eq!(t.runs.iter().map(|r| r.text.as_str()).collect::<String>(), "aa");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_type_command_given_a_group_and_type_in_it_lists_that_type_once() {
|
||||
let (mut s, group, text) = group_with_type();
|
||||
let changed = run(&mut s, "type.orientation.vertical", json!({"ids": [group.0, text.0, group.0]}));
|
||||
assert_eq!(ids_of(&changed), vec![text]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pasted_objects_on_path_group_names_its_own_copies() {
|
||||
let mut s = Session::new();
|
||||
run(&mut s, "file.new", json!({"width": 500, "height": 200}));
|
||||
let (a, b) = (rect(&mut s, 10.0), rect(&mut s, 40.0));
|
||||
let path = id_of(&run(&mut s, "shape.line", json!({"x1": 0, "y1": 100, "x2": 300, "y2": 100})));
|
||||
let group = id_of(&run(&mut s, "object.objectsOnPath.attach", json!({"path": path.0, "ids": [a.0, b.0]})));
|
||||
|
||||
// Inside another group too: every level of a copy gets new ids.
|
||||
let plain = rect(&mut s, 200.0);
|
||||
run(&mut s, "select.set", json!({"ids": [group.0, plain.0]}));
|
||||
let outer = id_of(&run(&mut s, "object.group", json!({})));
|
||||
run(&mut s, "select.set", json!({"ids": [outer.0]}));
|
||||
run(&mut s, "edit.copy", json!({}));
|
||||
let pasted = ids_of(&run(&mut s, "edit.paste", json!({})));
|
||||
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let copy = doc.node(pasted[0]).unwrap().children().unwrap().iter().find(|c| c.extras().objects_on_path.is_some()).unwrap();
|
||||
let rec = copy.extras().objects_on_path.as_deref().unwrap();
|
||||
let children: HashSet<NodeId> = copy.children().unwrap().iter().map(|c| c.id).collect();
|
||||
assert!(children.contains(&rec.path), "the path {:?} is not in the copy", rec.path);
|
||||
assert_ne!(rec.path, path);
|
||||
assert_eq!(rec.members.len(), 2);
|
||||
for m in &rec.members {
|
||||
assert!(children.contains(&m.id), "the member {:?} is not in the copy", m.id);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pasted_object_keeps_its_graphic_style_under_the_id_it_gets_in_the_document() {
|
||||
let mut s = Session::new();
|
||||
run(&mut s, "file.new", json!({"width": 500, "height": 200}));
|
||||
let source = rect(&mut s, 10.0);
|
||||
run(&mut s, "paint.setFill", json!({"ids": [source.0], "color": "#ff0000"}));
|
||||
run(&mut s, "graphicStyle.new", json!({"name": "Copied", "id": source.0}));
|
||||
run(&mut s, "select.set", json!({"ids": [source.0]}));
|
||||
run(&mut s, "edit.copy", json!({}));
|
||||
|
||||
// A second document whose own style already has the id the copied one has.
|
||||
run(&mut s, "file.new", json!({"width": 500, "height": 200}));
|
||||
let own = rect(&mut s, 10.0);
|
||||
run(&mut s, "paint.setFill", json!({"ids": [own.0], "color": "#0000ff"}));
|
||||
run(&mut s, "graphicStyle.new", json!({"name": "Own", "id": own.0}));
|
||||
let pasted = ids_of(&run(&mut s, "edit.paste", json!({})));
|
||||
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let link = doc.node(pasted[0]).unwrap().extras().graphic_style;
|
||||
let copied = doc.graphic_styles.iter().find(|g| g.name == "Copied").unwrap().id;
|
||||
assert_ne!(copied, doc.graphic_styles.iter().find(|g| g.name == "Own").unwrap().id);
|
||||
assert_eq!(link, Some(copied));
|
||||
assert_eq!(doc.node(own).unwrap().extras().graphic_style, doc.graphic_styles.iter().find(|g| g.name == "Own").map(|g| g.id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pasted_object_without_a_graphic_style_stays_without_one() {
|
||||
let mut s = named_groups();
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "edit.copy", json!({}));
|
||||
for id in ids_of(&run(&mut s, "edit.paste", json!({}))) {
|
||||
s.doc().unwrap().doc.node(id).unwrap().walk(&mut |n| assert_eq!(n.extras().graphic_style, None));
|
||||
}
|
||||
}
|
||||
@@ -1398,6 +1398,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- **Live objects in documents with many symbols:** a live object, or an object with effects, that shows symbols or patterns is not walked again in a later frame to find them while the document's symbols and patterns stay the same. One that shows a placed file is walked in every frame. At 1000 × 1000 pixels, a page of 2,000 envelopes around instances of the last 2,000 of 10,000 symbols takes 30 ms a frame, and 83 ms when every frame walks them again, with the same pixels.
|
||||
|
||||
- **Control bar and task bar on large paths:** the Control bar and the contextual task bar read the first selected object in place each frame instead of copying it; the task bar copies the object only when it is a basic shape, for its Shape Properties. With one selected open path of 200,000 anchors, an idle frame with both bars shown takes 3.8 ms in a release build instead of 8.0 ms (medians of five interleaved runs). Tests: `the_control_bar_label_follows_edits_undo_documents_and_selection`, `the_task_bar_follows_the_active_document`, `the_task_bar_follows_edits_and_undo`.
|
||||
- **Paste of many objects:** Paste leaves pasted copies as they are when no resource has another name or color in the document and the objects link no graphic style. It looks up the pasted type in one walk of the tree. A paste and undo of 20,000 rectangles in 200 groups uses 49.2 ms of CPU time in a release build instead of 59.4 ms, and of 5,000 point type objects 25.3 ms instead of 29.4 ms (medians of seven interleaved rounds, each round the CPU time of a 61-paste run minus a 1-paste run, divided by 60). Tests: `crates/engine/tests/it/paste_many.rs`.
|
||||
|
||||
- **Type drawn as glyph outlines:** under a geometry effect, in EMF and WMF export, in DXF export when it is written as outlines, flattened with Convert All Text to Outlines, and inside an envelope, type keeps its underline and strikethrough, the weight and dashes of its character strokes when it is scaled, and the placement of its gradients and patterns, as the canvas draws it. A group's or layer's own fills and strokes also paint the underline and strikethrough of the type in it.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user