mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-24 10:27:00 -06:00
M3: copied components paste as instances within their file
Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
This commit is contained in:
@@ -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<Value> {
|
||||
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<Value> {
|
||||
// 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")) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -735,6 +735,8 @@ pub struct Clipboard {
|
||||
/// The page-space bounds of the copied selection.
|
||||
pub bounds: Option<figcraft_geom::Rect>,
|
||||
pub source_doc: u64,
|
||||
/// Copied component/set roots, excluding cuts and originals discarded by undo (ids are reused).
|
||||
pub copied_components: std::collections::BTreeSet<NodeId>,
|
||||
/// The document copied from (pastes into other files follow its mains) and its file path.
|
||||
pub source: Option<Arc<Document>>,
|
||||
pub source_path: Option<String>,
|
||||
@@ -1166,6 +1168,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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user