diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index f1a4ada7..7d107d0c 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -515,6 +515,13 @@ pub fn copy_to_clipboard(s: &mut Session, cmd: &str, ids: &[NodeId]) -> Result<( s.clipboard.source = Some(source); s.clipboard.source_path = source_path; s.clipboard.source_doc = uid; + s.clipboard.copied_components = nodes + .iter() + .filter(|(_, sub)| { + cmd == "edit.copy" && sub.first().is_some_and(|n| matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. })) + }) + .map(|(root, _)| *root) + .collect(); s.clipboard.nodes = nodes; s.clipboard.images = images; s.clipboard.bounds = bounds; @@ -593,7 +600,8 @@ fn paste_design_layers(s: &mut Session, p: &Value) -> Result { let (nodes, images, bounds) = copied(&doc, &tops); let props = s.clipboard.props.take(); // No open file has id 0. So paste treats this copy as a copy from a different file. - s.clipboard = crate::Clipboard { nodes, images, props, bounds, source_doc: 0, source: Some(doc), source_path: None }; + s.clipboard = + crate::Clipboard { nodes, images, props, bounds, source_doc: 0, source: Some(doc), source_path: None, copied_components: BTreeSet::new() }; let mut out = paste(s, p)?; if !other.issues.is_empty() { out["warnings"] = json!(other.issues); @@ -623,6 +631,7 @@ pub fn paste_into(s: &mut Session, parent: NodeId, at: Option<(f64, f64)>) -> Re } // From another file: its instances follow the mains this file can have. let foreign = clip.source.as_ref().filter(|_| s.ds().is_ok_and(|d| d.uid != clip.source_doc)); + let copy_local = s.ds()?.uid == clip.source_doc; let doc = s.doc_mut()?; for (k, v) in &clip.images { doc.images.entry(k.clone()).or_insert_with(|| v.clone()); @@ -636,6 +645,16 @@ pub fn paste_into(s: &mut Session, parent: NodeId, at: Option<(f64, f64)>) -> Re let mut out = Vec::new(); let mut all = BTreeMap::new(); for (root, nodes) in &clip.nodes { + if copy_local + && clip.copied_components.contains(root) + && let Some(n) = nodes.first().filter(|n| matches!(n.kind, NodeKind::Component { .. })) + && doc.node(*root).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. })) + { + let instance = crate::components::create_instance(doc, *root, parent, 0.0, 0.0)?; + doc.get_mut(instance)?.transform = doc.world_transform(parent).inverse() * shift * n.transform; + out.push(instance); + continue; + } let mut map = BTreeMap::new(); for n in nodes { map.insert(n.id, doc.alloc_id()); @@ -661,10 +680,12 @@ fn paste(s: &mut Session, p: &Value) -> Result { // else in the frame's centre; otherwise in place on the page (or the viewport centre). let sel = s.selection(); let doc = s.doc()?; + let copy_local = s.ds()?.uid == s.clipboard.source_doc; let target = id(p, "parent").or_else(|| { sel.first() .copied() .filter(|i| sel.len() == 1 && doc.node(*i).is_some_and(|n| n.kind.is_frame_like() && !matches!(n.kind, NodeKind::Instance { .. }))) + .filter(|i| !copy_local || !s.clipboard.copied_components.iter().any(|root| doc.is_ancestor(*root, *i))) }); let parent = target.unwrap_or(page); let at = match (num(p, "x"), num(p, "y")) { diff --git a/crates/engine/src/components_behaviour_tests.rs b/crates/engine/src/components_behaviour_tests.rs index 15f660e1..e068c6cf 100644 --- a/crates/engine/src/components_behaviour_tests.rs +++ b/crates/engine/src/components_behaviour_tests.rs @@ -82,6 +82,118 @@ fn text(n: &Node) -> &TextData { n.kind.text().unwrap() } +#[test] +fn copying_a_main_component_pastes_a_linked_instance() { + for command in ["edit.paste", "edit.pasteOver", "edit.pasteReplace"] { + let mut s = Session::new(); + let k = kit(&mut s); + ex(&mut s, "select.set", json!({"ids": [k.card]})); + ex(&mut s, "edit.copy", json!({})); + if command != "edit.paste" { + ex(&mut s, "select.set", json!({"ids": [k.inst]})); + } + let before = undo_len(&s); + let pasted = crate::params::id_of(&ex(&mut s, command, json!({}))["ids"][0]).unwrap(); + assert_eq!(component_of(&s, pasted), k.card, "{command}"); + assert_eq!(node(&s, pasted).parent, Some(s.page().unwrap())); + assert_eq!(node(&s, pasted).source, Some(k.card)); + assert_eq!(undo_len(&s), before + 1); + ex(&mut s, "edit.undo", json!({})); + assert!(s.doc().unwrap().node(pasted).is_none()); + ex(&mut s, "edit.redo", json!({})); + assert_eq!(component_of(&s, pasted), k.card); + ex(&mut s, "node.set", json!({"id": k.title, "props": {"characters": "Updated"}})); + assert_eq!(chars(&s, copy_of(&s, pasted, k.title)), "Updated"); + healthy(&s); + } +} + +#[test] +fn copying_a_variant_pastes_an_instance_but_copying_its_set_keeps_the_components() { + let mut s = Session::new(); + let (set, _, hover) = button_set(&mut s); + ex(&mut s, "select.set", json!({"ids": [hover]})); + ex(&mut s, "edit.copy", json!({})); + let pasted = crate::params::id_of(&ex(&mut s, "edit.paste", json!({}))["ids"][0]).unwrap(); + assert_eq!(component_of(&s, pasted), hover); + assert_eq!(node(&s, pasted).name, node(&s, set).name); + let NodeKind::Instance { props, .. } = &node(&s, pasted).kind else { panic!("pasted variant is not an instance") }; + assert_eq!(chars(&s, layer(&s, pasted, "Label").unwrap()), "Hover"); + assert_eq!(props["Show dot"], true); + assert_eq!(node(&s, pasted).parent, Some(s.page().unwrap())); + + ex(&mut s, "select.set", json!({"ids": [set]})); + ex(&mut s, "edit.copy", json!({})); + let copied_set = crate::params::id_of(&ex(&mut s, "edit.paste", json!({}))["ids"][0]).unwrap(); + assert!(matches!(node(&s, copied_set).kind, NodeKind::ComponentSet { .. })); + assert_eq!(node(&s, copied_set).parent, Some(s.page().unwrap())); + assert_eq!(kids(&s, copied_set).len(), 2); + for variant in kids(&s, copied_set) { + assert!(matches!(node(&s, variant).kind, NodeKind::Component { .. })); + assert!(!kids(&s, set).contains(&variant)); + } + healthy(&s); +} + +#[test] +fn cut_components_and_components_copied_to_another_file_remain_components() { + for (copy, undo_cut, new_file) in [("edit.cut", false, false), ("edit.cut", true, false), ("edit.copy", false, true)] { + let mut s = Session::new(); + let k = kit(&mut s); + ex(&mut s, copy, json!({"ids": [k.card]})); + if undo_cut { + ex(&mut s, "edit.undo", json!({})); + } + if new_file { + ex(&mut s, "file.new", json!({})); + } + let page = s.page().unwrap(); + let pasted = crate::params::id_of(&ex(&mut s, "edit.paste", json!({"parent": page}))["ids"][0]).unwrap(); + assert!(matches!(node(&s, pasted).kind, NodeKind::Component { .. }), "{copy}, undo={undo_cut}, new_file={new_file}"); + assert_eq!(kids(&s, pasted).len(), 3); + healthy(&s); + } +} + +#[test] +fn copying_a_component_does_not_link_to_an_id_reused_after_undo() { + let mut s = Session::new(); + let frame = idv(&ex(&mut s, "frame.create", json!({"width": 120, "height": 60, "name": "Copied main"}))); + let main = idv(&ex(&mut s, "component.create", json!({"ids": [frame]}))); + ex(&mut s, "edit.copy", json!({"ids": [main]})); + s.undo().unwrap(); + s.undo().unwrap(); + let other = idv(&ex(&mut s, "frame.create", json!({"width": 300, "height": 200, "name": "Unrelated main"}))); + ex(&mut s, "component.create", json!({"ids": [other]})); + assert_eq!(other, main, "undo makes the original id available again"); + let page = s.page().unwrap(); + let pasted = crate::params::id_of(&ex(&mut s, "edit.paste", json!({"parent": page}))["ids"][0]).unwrap(); + assert_eq!(node(&s, pasted).name, "Copied main"); + assert_eq!((node(&s, pasted).width, node(&s, pasted).height), (120.0, 60.0)); + assert!(matches!(node(&s, pasted).kind, NodeKind::Component { .. })); + healthy(&s); +} + +#[test] +fn pasted_component_instances_keep_the_copied_transform_in_the_target_frame() { + let mut s = Session::new(); + let k = kit(&mut s); + ex(&mut s, "node.set", json!({"id": k.card, "props": {"rotation": 30}})); + let original = s.doc().unwrap().world_transform(k.card); + let frame = idv(&ex(&mut s, "frame.create", json!({"x": -100, "y": -100, "width": 1000, "height": 1000}))); + ex(&mut s, "edit.copy", json!({"ids": [k.card]})); + let pasted = crate::params::id_of(&ex(&mut s, "edit.paste", json!({"parent": frame}))["ids"][0]).unwrap(); + assert_eq!(component_of(&s, pasted), k.card); + for (actual, expected) in s.doc().unwrap().world_transform(pasted).as_coeffs().into_iter().zip(original.as_coeffs()) { + assert!((actual - expected).abs() < 1e-9); + } + assert_eq!(node(&s, pasted).parent, Some(frame)); + let before = s.doc().unwrap().clone(); + assert!(s.execute("edit.paste", &json!({"parent": k.card})).is_err()); + assert_eq!(s.doc().unwrap(), &before, "pasting into the main is refused atomically"); + healthy(&s); +} + #[test] fn overridable_properties_stay_through_main_edits_and_reset() { // (what, layer, group, override on the instance, the same property edited on the main, read, diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 345747ef..f55bc1db 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -762,6 +762,8 @@ pub struct Clipboard { /// The page-space bounds of the copied selection. pub bounds: Option, pub source_doc: u64, + /// Copied component/set roots, excluding cuts and originals discarded by undo (ids are reused). + pub copied_components: std::collections::BTreeSet, /// The document copied from (pastes into other files follow its mains) and its file path. pub source: Option>, pub source_path: Option, @@ -1196,6 +1198,13 @@ impl Session { } ds.clean_selection(); ds.revision += 1; + let (uid, doc) = (ds.uid, ds.doc.clone()); + if self.clipboard.source_doc == uid { + self.clipboard.copied_components.retain(|id| { + doc.node(*id) + .is_some_and(|n| matches!(n.kind, figcraft_doc::NodeKind::Component { .. } | figcraft_doc::NodeKind::ComponentSet { .. })) + }); + } Ok(true) }