From a4f4869443fea70a705a411c4706c01afa5662f3 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:10:00 +0200 Subject: [PATCH] Point duplicated page navigation at the copied frames Fixes #419 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/lib.rs | 17 +++++++ crates/engine/src/cmd/page.rs | 5 +- crates/engine/src/lib.rs | 2 + .../engine/src/page_copy_navigation_tests.rs | 48 +++++++++++++++++++ 4 files changed, 69 insertions(+), 3 deletions(-) create mode 100644 crates/engine/src/page_copy_navigation_tests.rs diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 513800e3..71c6da39 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -781,6 +781,23 @@ impl Document { self.paste_nodes(&src_nodes, &map, id, parent, index) } + /// Deep-copy each of `ids`' subtrees to the end of `parent`, in order, returning the new root + /// ids. One id map covers every copy, so references between the copied subtrees (a navigation + /// from one copied frame to another) point at the copies. + pub fn duplicate_all_into(&mut self, ids: &[NodeId], parent: NodeId) -> DocResult> { + let mut map = BTreeMap::new(); + let mut trees = Vec::with_capacity(ids.len()); + for &id in ids { + let src_nodes: Vec = self.subtree(id).into_iter().filter_map(|i| self.node(i).cloned()).collect(); + for n in &src_nodes { + let nid = self.alloc_id(); + map.insert(n.id, nid); + } + trees.push((id, src_nodes)); + } + trees.into_iter().map(|(id, src_nodes)| self.paste_nodes(&src_nodes, &map, id, parent, None)).collect() + } + /// Insert copies of `nodes` (a subtree rooted at `root`, from this or another document) with ids /// remapped by `map`. Interactions, instance references and pattern sources pointing inside the /// subtree are remapped too. diff --git a/crates/engine/src/cmd/page.rs b/crates/engine/src/cmd/page.rs index 8fccd607..e6c1876f 100644 --- a/crates/engine/src/cmd/page.rs +++ b/crates/engine/src/cmd/page.rs @@ -92,9 +92,8 @@ pub fn specs() -> Vec { { *b = background; } - for c in doc.get(src)?.children.clone() { - doc.duplicate_into(c, np, None)?; - } + let children = doc.get(src)?.children.clone(); + doc.duplicate_all_into(&children, np)?; // Keep the new page right after the source. if let Some(si) = doc.pages.iter().position(|x| *x == src) { doc.pages.retain(|x| *x != np); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 42423dbc..c10dd04f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1528,6 +1528,8 @@ mod open_issues_tests; #[cfg(test)] mod outline_options_tests; #[cfg(test)] +mod page_copy_navigation_tests; +#[cfg(test)] mod paint_reorder_tests; #[cfg(test)] mod paint_tests; diff --git a/crates/engine/src/page_copy_navigation_tests.rs b/crates/engine/src/page_copy_navigation_tests.rs new file mode 100644 index 00000000..ddbc956a --- /dev/null +++ b/crates/engine/src/page_copy_navigation_tests.rs @@ -0,0 +1,48 @@ +//! Duplicating a page copies its prototype connections: navigation between two frames that are both +//! on the page leads between the copies, while a destination outside the copied contents is kept. + +use figcraft_doc::NodeId; +use serde_json::{Value, json}; + +use crate::Session; +use crate::params::id_of; + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +fn frame(s: &mut Session, x: f64) -> NodeId { + id_of(&ex(s, "frame.create", json!({"x": x, "y": 0, "width": 100, "height": 100}))["id"]).unwrap() +} + +fn dest(s: &Session, id: NodeId) -> NodeId { + s.doc().unwrap().node(id).unwrap().interactions[0].actions[0].dest().unwrap() +} + +#[test] +fn page_copy_navigation_uses_the_copied_destination() { + let mut s = Session::new(); + let first = frame(&mut s, 0.0); + let second = frame(&mut s, 200.0); + ex(&mut s, "proto.interaction.add", json!({"id": first, "action": {"type": "navigate", "dest": second}})); + let copy = id_of(&ex(&mut s, "page.duplicate", json!({}))["id"]).unwrap(); + let children = s.doc().unwrap().node(copy).unwrap().children.clone(); + assert_eq!(children.len(), 2); + assert_eq!(dest(&s, children[0]), children[1]); + let visited = Default::default(); + assert_eq!(crate::proto_run::navigate_target(s.doc().unwrap(), dest(&s, children[0]), &visited), Some(children[1])); + assert_eq!(dest(&s, first), second, "the original keeps its destination"); +} + +#[test] +fn frame_copy_keeps_an_outside_destination() { + let mut s = Session::new(); + let first = frame(&mut s, 0.0); + let second = frame(&mut s, 200.0); + ex(&mut s, "proto.interaction.add", json!({"id": first, "action": {"type": "navigate", "dest": second}})); + ex(&mut s, "select.set", json!({"ids": [first]})); + ex(&mut s, "edit.duplicate", json!({})); + let copy = s.selection()[0]; + assert_ne!(copy, first); + assert_eq!(dest(&s, copy), second); +}