From 1ff7ac75574efe6fa6b0b1064d7ee97b80329351 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 20:27:30 +0200 Subject: [PATCH 1/3] Layers: Merge Selected with a layer and its sublayer highlighted merges into the last top-level layer (#2383) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/layerpanel.rs | 15 ++++++++++++-- crates/engine/src/tests_layers.rs | 32 ++++++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/layerpanel.rs b/crates/engine/src/cmd/layerpanel.rs index f82e2890a..c302a7409 100644 --- a/crates/engine/src/cmd/layerpanel.rs +++ b/crates/engine/src/cmd/layerpanel.rs @@ -271,8 +271,19 @@ fn merge(s: &mut Session, p: &Value) -> Result { if ids.len() < 2 { return Err(bad(C, "highlight two or more rows to merge")); } - let into = - ids.iter().rev().copied().find(|id| d.node(*id).is_some_and(Node::is_layer)).ok_or_else(|| bad(C, "highlight a layer to merge into"))?; + // The last highlighted layer that is not inside another highlighted row: with a layer and its + // sublayer both highlighted (Select All in the panel), the sublayer travels with its layer + // instead of becoming the target (#2383). + let roots: HashSet = row_roots(d, &ids).into_iter().collect(); + let is_layer = |id: &NodeId| d.node(*id).is_some_and(Node::is_layer); + let into = ids + .iter() + .rev() + .copied() + .filter(is_layer) + .find(|id| roots.contains(id)) + .or_else(|| ids.iter().rev().copied().find(is_layer)) + .ok_or_else(|| bad(C, "highlight a layer to merge into"))?; let around = d.ancestry(into).unwrap_or_default(); // (Rows inside the target layer merge too: only rows inside other listed rows go with those.) let rest: Vec = ids.iter().copied().filter(|id| *id != into).collect(); diff --git a/crates/engine/src/tests_layers.rs b/crates/engine/src/tests_layers.rs index 1e2e7e221..1fc789c80 100644 --- a/crates/engine/src/tests_layers.rs +++ b/crates/engine/src/tests_layers.rs @@ -578,7 +578,11 @@ fn collect_merge_flatten_and_reverse() { run(&mut s, "layer.merge", json!({"ids": [col.0, layer.0]})); assert!(s.doc().unwrap().doc.node(col).is_none()); assert_eq!(children(&s, layer), vec![c, sub, a, g]); - assert!(s.execute("layer.merge", &json!({"ids": [layer.0, sub.0]})).is_err(), "not into its own sublayer"); + // A layer and its sublayer: the sublayer merges into its layer, never the other way round. + run(&mut s, "layer.merge", json!({"ids": [layer.0, sub.0]})); + assert!(s.doc().unwrap().doc.node(sub).is_none()); + assert_eq!(children(&s, layer), vec![c, a, g]); + run(&mut s, "edit.undo", json!({})); assert!(s.execute("layer.merge", &json!({"ids": [layer.0]})).is_err()); // Flatten: other visible layers merge in, hidden ones go, templates stay. let l2 = id(&run(&mut s, "layer.new", json!({"top": true}))); @@ -595,6 +599,32 @@ fn collect_merge_flatten_and_reverse() { assert_eq!(undos(&s), before + 1); } +/// Merge Selected with every row highlighted (a layer, its sublayer and another layer, in panel +/// order) merges the layers into the last top-level one instead of refusing a sublayer target (#2383). +#[test] +fn merge_selected_with_all_layer_rows_highlighted_merges_into_the_last_top_level_layer() { + for reversed in [false, true] { + let mut s = session(); + let (layer, sub, a, _, _, c) = tree(&mut s); + let l2 = id(&run(&mut s, "layer.new", json!({"top": true}))); + let d = rect(&mut s, 500.0); + // Panel order, top first: Layer 2, Layer 1, its sublayer. + let mut rows = vec![l2.0, layer.0, sub.0]; + if reversed { + rows.reverse(); + } + run(&mut s, "layer.highlight", json!({"ids": rows})); + let into = id(&run(&mut s, "layer.merge", json!({}))); + let st = s.doc().unwrap(); + assert_eq!(st.doc.layers.len(), 1, "reversed: {reversed}"); + assert_eq!(into, st.doc.layers[0].id); + for n in [a, c, d] { + assert!(st.doc.layer_of(n) == Some(into), "{n:?} reversed: {reversed}"); + } + assert!(st.doc.node(sub).is_some() || st.doc.node(layer).is_some() || st.doc.node(l2).is_some()); + } +} + /// Collect in New Layer puts the new layer beside the topmost row when that row is directly in a /// layer, and on top of the layer holding its group when it is in a group. When that row is in a /// hidden or locked layer, the selected art it collects from another layer is deselected, the key From fdc08e25d6aa2c5341c56a26b6b432765a359d6b Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 20:33:08 +0200 Subject: [PATCH 2/3] Layers: Merge Selected merges the layers of art selected on the canvas (#2383) With no highlighted rows and art selected in two or more layers (Select All), layer.merge merges those layers; the panel menu enables it then. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/layerpanel.rs | 13 +++++++++++-- crates/engine/src/tests_layers.rs | 18 ++++++++++++++++++ crates/ui-egui/src/panels/layers.rs | 4 +++- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/layerpanel.rs b/crates/engine/src/cmd/layerpanel.rs index c302a7409..66cf37ff7 100644 --- a/crates/engine/src/cmd/layerpanel.rs +++ b/crates/engine/src/cmd/layerpanel.rs @@ -263,13 +263,22 @@ fn merge_into(d: &mut Document, into: NodeId, others: &[NodeId]) -> Result<()> { fn merge(s: &mut Session, p: &Value) -> Result { const C: &str = "layer.merge"; - let ids = rows_param(s, p, C)?; + let mut ids = rows_param(s, p, C)?; let st = s.doc()?; let d = &st.doc; + // Nothing named or highlighted, but art selected on the canvas in several layers (Select All): + // merge those layers (#2383). + if p.get("ids").is_none() && p.get("id").is_none() && st.highlighted_rows().is_empty() { + let held: HashSet = st.selection.objects.iter().filter_map(|o| d.layer_of(*o)).collect(); + let layers: Vec = d.layers.iter().map(|l| l.id).filter(|l| held.contains(l)).collect(); + if layers.len() >= 2 { + ids = layers; + } + } let mut seen = HashSet::new(); let ids: Vec = ids.into_iter().filter(|id| d.node(*id).is_some() && seen.insert(*id)).collect(); if ids.len() < 2 { - return Err(bad(C, "highlight two or more rows to merge")); + return Err(bad(C, "highlight two or more rows to merge, or select art in two or more layers")); } // The last highlighted layer that is not inside another highlighted row: with a layer and its // sublayer both highlighted (Select All in the panel), the sublayer travels with its layer diff --git a/crates/engine/src/tests_layers.rs b/crates/engine/src/tests_layers.rs index 1fc789c80..7591873d2 100644 --- a/crates/engine/src/tests_layers.rs +++ b/crates/engine/src/tests_layers.rs @@ -625,6 +625,24 @@ fn merge_selected_with_all_layer_rows_highlighted_merges_into_the_last_top_level } } +/// Merge Selected with no highlighted rows but art selected in several layers (Select All on the +/// canvas) merges those layers; art in one layer still asks for rows (#2383). +#[test] +fn merge_selected_with_art_selected_in_several_layers_merges_those_layers() { + let mut s = session(); + let a = rect(&mut s, 0.0); + run(&mut s, "layer.new", json!({"top": true})); + let b = rect(&mut s, 100.0); + run(&mut s, "select.set", json!({"ids": [b.0]})); + run(&mut s, "layer.highlight", json!({"ids": []})); + assert!(s.execute("layer.merge", &json!({})).is_err()); + run(&mut s, "select.set", json!({"ids": [a.0, b.0]})); + let into = id(&run(&mut s, "layer.merge", json!({}))); + let st = s.doc().unwrap(); + assert_eq!(st.doc.layers.len(), 1); + assert!(st.doc.layer_of(a) == Some(into) && st.doc.layer_of(b) == Some(into)); +} + /// Collect in New Layer puts the new layer beside the topmost row when that row is directly in a /// layer, and on top of the layer holding its group when it is in a group. When that row is in a /// hidden or locked layer, the selected art it collects from another layer is deselected, the key diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 7fe651ced..0642274ea 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -1461,6 +1461,8 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { let rows = st.highlighted_rows(); let current = st.current_layer(); let has_sel = !st.selection.is_empty(); + // How many top-level layers hold the selected art: Merge Selected merges them. + let art_layers = st.selection.objects.iter().filter_map(|o| doc.layer_of(*o)).collect::>().len(); let isolated = st.isolation.is_some(); let targets: Vec = if rows.is_empty() { current.into_iter().collect() } else { rows.clone() }; let one_container = match targets.as_slice() { @@ -1498,7 +1500,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { None, Some(("Locate Object", "layer.locate", json!({}), has_sel, false)), None, - Some(("Merge Selected", "layer.merge", json!({}), rows.len() >= 2, false)), + Some(("Merge Selected", "layer.merge", json!({}), rows.len() >= 2 || (rows.is_empty() && art_layers >= 2), false)), Some(("Flatten Artwork", "layer.flatten", json!({}), true, false)), Some(("Collect in New Layer", "layer.collectInNew", json!({}), !rows.is_empty() || has_sel, false)), // Sentinel: rendered as Collect for Export › As Single / As Multiple (assets.add). From 424a49604cc3131a4c5bab9d32bcca257e957580 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 12:49:08 +0700 Subject: [PATCH 3/3] Layers: Merge Selected keeps to the highlighted layers, as documented (#2383) Reverts the fallback that merged the layers of art selected on the canvas when no layer row is highlighted: Illustrator's Merge Selected merges the highlighted rows into the last one highlighted. --- crates/engine/src/cmd/layerpanel.rs | 13 ++----------- crates/engine/src/tests_layers.rs | 18 ------------------ crates/ui-egui/src/panels/layers.rs | 4 +--- 3 files changed, 3 insertions(+), 32 deletions(-) diff --git a/crates/engine/src/cmd/layerpanel.rs b/crates/engine/src/cmd/layerpanel.rs index 66cf37ff7..c302a7409 100644 --- a/crates/engine/src/cmd/layerpanel.rs +++ b/crates/engine/src/cmd/layerpanel.rs @@ -263,22 +263,13 @@ fn merge_into(d: &mut Document, into: NodeId, others: &[NodeId]) -> Result<()> { fn merge(s: &mut Session, p: &Value) -> Result { const C: &str = "layer.merge"; - let mut ids = rows_param(s, p, C)?; + let ids = rows_param(s, p, C)?; let st = s.doc()?; let d = &st.doc; - // Nothing named or highlighted, but art selected on the canvas in several layers (Select All): - // merge those layers (#2383). - if p.get("ids").is_none() && p.get("id").is_none() && st.highlighted_rows().is_empty() { - let held: HashSet = st.selection.objects.iter().filter_map(|o| d.layer_of(*o)).collect(); - let layers: Vec = d.layers.iter().map(|l| l.id).filter(|l| held.contains(l)).collect(); - if layers.len() >= 2 { - ids = layers; - } - } let mut seen = HashSet::new(); let ids: Vec = ids.into_iter().filter(|id| d.node(*id).is_some() && seen.insert(*id)).collect(); if ids.len() < 2 { - return Err(bad(C, "highlight two or more rows to merge, or select art in two or more layers")); + return Err(bad(C, "highlight two or more rows to merge")); } // The last highlighted layer that is not inside another highlighted row: with a layer and its // sublayer both highlighted (Select All in the panel), the sublayer travels with its layer diff --git a/crates/engine/src/tests_layers.rs b/crates/engine/src/tests_layers.rs index 7591873d2..1fc789c80 100644 --- a/crates/engine/src/tests_layers.rs +++ b/crates/engine/src/tests_layers.rs @@ -625,24 +625,6 @@ fn merge_selected_with_all_layer_rows_highlighted_merges_into_the_last_top_level } } -/// Merge Selected with no highlighted rows but art selected in several layers (Select All on the -/// canvas) merges those layers; art in one layer still asks for rows (#2383). -#[test] -fn merge_selected_with_art_selected_in_several_layers_merges_those_layers() { - let mut s = session(); - let a = rect(&mut s, 0.0); - run(&mut s, "layer.new", json!({"top": true})); - let b = rect(&mut s, 100.0); - run(&mut s, "select.set", json!({"ids": [b.0]})); - run(&mut s, "layer.highlight", json!({"ids": []})); - assert!(s.execute("layer.merge", &json!({})).is_err()); - run(&mut s, "select.set", json!({"ids": [a.0, b.0]})); - let into = id(&run(&mut s, "layer.merge", json!({}))); - let st = s.doc().unwrap(); - assert_eq!(st.doc.layers.len(), 1); - assert!(st.doc.layer_of(a) == Some(into) && st.doc.layer_of(b) == Some(into)); -} - /// Collect in New Layer puts the new layer beside the topmost row when that row is directly in a /// layer, and on top of the layer holding its group when it is in a group. When that row is in a /// hidden or locked layer, the selected art it collects from another layer is deselected, the key diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 0642274ea..7fe651ced 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -1461,8 +1461,6 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { let rows = st.highlighted_rows(); let current = st.current_layer(); let has_sel = !st.selection.is_empty(); - // How many top-level layers hold the selected art: Merge Selected merges them. - let art_layers = st.selection.objects.iter().filter_map(|o| doc.layer_of(*o)).collect::>().len(); let isolated = st.isolation.is_some(); let targets: Vec = if rows.is_empty() { current.into_iter().collect() } else { rows.clone() }; let one_container = match targets.as_slice() { @@ -1500,7 +1498,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { None, Some(("Locate Object", "layer.locate", json!({}), has_sel, false)), None, - Some(("Merge Selected", "layer.merge", json!({}), rows.len() >= 2 || (rows.is_empty() && art_layers >= 2), false)), + Some(("Merge Selected", "layer.merge", json!({}), rows.len() >= 2, false)), Some(("Flatten Artwork", "layer.flatten", json!({}), true, false)), Some(("Collect in New Layer", "layer.collectInNew", json!({}), !rows.is_empty() || has_sel, false)), // Sentinel: rendered as Collect for Export › As Single / As Multiple (assets.add).