From 6165d2ec85a0b139a73af82b8e9e1e8d3d75c6d2 Mon Sep 17 00:00:00 2001 From: YarilaFox <102650794+YarilaFox@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:12:47 +0500 Subject: [PATCH] M7: preserve bindings and design layers when pasting images --- crates/engine/src/cmd/create.rs | 10 ++++++-- crates/engine/src/cmd/edit.rs | 3 +-- crates/ui-egui/src/design_paste_tests.rs | 28 +++++++++++++++++++++ crates/ui-egui/src/menus.rs | 32 ++++++++++++------------ crates/ui-egui/src/paste_image_tests.rs | 17 +++++++++++++ 5 files changed, 70 insertions(+), 20 deletions(-) diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 68375716..dc316056 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -377,6 +377,13 @@ pub fn accepts_image_fill(kind: &NodeKind) -> bool { kind.is_shape() || kind.is_frame_like() || matches!(kind, NodeKind::Text(_) | NodeKind::BooleanOp { .. }) } +/// Replace the fill list and detach variable bindings on paints that the image replaces. +pub(crate) fn replace_with_image_fill(node: &mut Node, image: String, mode: ImageScaleMode) { + node.fills = vec![Paint::image(image, mode)]; + node.bound.retain(|key, _| !matches!(super::lists::list_binding(key), Some((super::lists::BoundList::Fills, _, _)))); + node.styles.fill = None; +} + /// Place an image as a rectangle with an image fill (Figma's image layer). pub fn place_image(s: &mut Session, p: &Value) -> Result { let cmd = "image.place"; @@ -407,8 +414,7 @@ pub fn place_image(s: &mut Session, p: &Value) -> Result { let (hash, _, _) = figcraft_format::add_image(doc, bytes).map_err(|e| bad(cmd, e.to_string()))?; let profile = figcraft_render::images::profile_name(&hash, doc.images.get(&hash)); let node = doc.get_mut(id)?; - node.fills = vec![Paint::image(hash.clone(), mode)]; - node.styles.fill = None; + replace_with_image_fill(node, hash.clone(), mode); return Ok(json!({"id": id, "image": hash, "width": node.width, "height": node.height, "imageProfile": profile})); } let page = s.page()?; diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index dc9c7b77..93636cab 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -701,8 +701,7 @@ fn paste(s: &mut Session, p: &Value) -> Result { let doc = s.doc_mut()?; doc.images.entry(hash.clone()).or_insert(data); let node = doc.get_mut(*target)?; - node.fills = vec![figcraft_color::Paint::image(hash, figcraft_color::ImageScaleMode::Fill)]; - node.styles.fill = None; + super::create::replace_with_image_fill(node, hash, figcraft_color::ImageScaleMode::Fill); return Ok(json!({"ids": [target]})); } let page = s.page()?; diff --git a/crates/ui-egui/src/design_paste_tests.rs b/crates/ui-egui/src/design_paste_tests.rs index 35743a81..82c366c7 100644 --- a/crates/ui-egui/src/design_paste_tests.rs +++ b/crates/ui-egui/src/design_paste_tests.rs @@ -45,6 +45,34 @@ fn paste_takes_layers_copied_in_another_design_app() { assert_eq!(selected_names(&app), ["Copied layer"]); } +struct HtmlAndImage(String); + +impl Services for HtmlAndImage { + fn clipboard_get_html(&mut self) -> Option { + Some(self.0.clone()) + } + fn clipboard_get_image(&mut self) -> Option<(u32, u32, Vec)> { + Some((2, 2, vec![255; 16])) + } +} + +#[test] +fn editable_design_layers_take_priority_over_a_png_representation() { + let mut session = Session::new(); + let selected = session.execute("shape.ellipse", &json!({"x": 100, "y": 100, "width": 80, "height": 80})).unwrap()["id"].as_u64().unwrap(); + let mut app = FigApp::new(session, Box::new(HtmlAndImage(copied_html()))); + let before = app.session.doc().unwrap().node_count(); + + crate::menus::paste(&mut app, None, None); + + assert_eq!(selected_names(&app), ["Copied layer"]); + assert_eq!(app.session.doc().unwrap().node_count(), before + 1); + assert!(matches!( + app.session.doc().unwrap().node(figcraft_doc::NodeId(selected)).unwrap().fills[0].kind, + figcraft_color::PaintKind::Solid { .. } + )); +} + #[test] fn paste_here_puts_copied_design_layers_at_the_point() { let html = copied_html(); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 2002be0b..b3647a8f 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -36,22 +36,6 @@ pub const UI_COMMANDS: &[&str] = &[ /// other way to read it). `at`: the page point to paste at (Paste here); `None` pastes in the /// middle of the view. pub fn paste(app: &mut FigApp, event_text: Option<&str>, at: Option<(f64, f64)>) { - let selected_layer = match app.session.selection().as_slice() { - [id] if at.is_none() - && app.session.doc().ok().and_then(|d| d.node(*id)).is_some_and(|n| figcraft_engine::cmd::create::accepts_image_fill(&n.kind)) => - { - Some(*id) - } - _ => None, - }; - if let Some(target) = selected_layer - && let Some((png, _, _)) = clipboard_png(app) - { - use base64::Engine as _; - let b64 = base64::engine::general_purpose::STANDARD.encode(png); - app.run("image.place", json!({"data": b64, "target": target})); - return; - } // Only HTML that holds readable design data takes this path; other HTML pastes as before. if let Some(html) = app.services.clipboard_get_html().filter(|h| figcraft_engine::cmd::edit::design_clipboard_buffer(h).is_some()) { let mut params = json!({"html": html}); @@ -69,6 +53,22 @@ pub fn paste(app: &mut FigApp, event_text: Option<&str>, at: Option<(f64, f64)>) } return; } + let selected_layer = match app.session.selection().as_slice() { + [id] if at.is_none() + && app.session.doc().ok().and_then(|d| d.node(*id)).is_some_and(|n| figcraft_engine::cmd::create::accepts_image_fill(&n.kind)) => + { + Some(*id) + } + _ => None, + }; + if let Some(target) = selected_layer + && let Some((png, _, _)) = clipboard_png(app) + { + use base64::Engine as _; + let b64 = base64::engine::general_purpose::STANDARD.encode(png); + app.run("image.place", json!({"data": b64, "target": target})); + return; + } let sys_text = app.services.clipboard_get_text().or_else(|| event_text.map(str::to_string)).filter(|t| !t.is_empty()); let ours = sys_text.is_some() && sys_text == app.ui.last_copy; let (cx, cy) = at.or_else(|| app.session.ds().ok().map(|d| (d.view.cx, d.view.cy))).unwrap_or((0.0, 0.0)); diff --git a/crates/ui-egui/src/paste_image_tests.rs b/crates/ui-egui/src/paste_image_tests.rs index 6fbc4101..c659569c 100644 --- a/crates/ui-egui/src/paste_image_tests.rs +++ b/crates/ui-egui/src/paste_image_tests.rs @@ -208,6 +208,23 @@ fn cmd_v_fills_a_selected_oval_without_adding_a_layer() { assert_eq!(w.app.session.doc().unwrap().node(oval).unwrap().fills, original_fill); } +#[test] +fn pasting_an_image_detaches_the_replaced_fill_variable() { + let mut w = Win::new(); + let oval = + NodeId(w.app.session.execute("shape.ellipse", &json!({"x": 200, "y": 100, "width": 120, "height": 80})).unwrap()["id"].as_u64().unwrap()); + w.app.session.execute("variable.createCollection", &json!({"name": "Theme"})).unwrap(); + w.app.session.execute("variable.create", &json!({"collection": "Theme", "name": "brand", "type": "color", "value": "#FF0000"})).unwrap(); + w.app.session.execute("variable.bind", &json!({"ids": [oval.0], "field": "fill", "variable": "brand"})).unwrap(); + assert!(!w.app.session.doc().unwrap().node(oval).unwrap().bound.is_empty()); + + w.copy_image_elsewhere(); + w.cmd_v(CMD); + let node = w.app.session.doc().unwrap().node(oval).unwrap(); + assert!(matches!(node.fills[0].kind, figcraft_color::PaintKind::Image(_))); + assert!(node.bound.is_empty(), "the old colour variable must not remain on the image fill"); +} + #[test] fn cmd_v_fills_a_selected_oval_with_an_image_copied_in_figcraft() { let mut w = Win::new();