diff --git a/ROADMAP.md b/ROADMAP.md index 0e89d3db4..d171df3c4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -128,6 +128,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-13** · Ungroup only takes groups apart: a frame with pasted-in content stays whole (and selected), is named by its shape in the Layers and Properties panels, and offers Convert Shape rather than Ungroup. Non-groups stay selected. No readiness percentage change. - **2026-10-13** · Copy with a placed graphic selected (Direct Selection) copies just the graphic, uncropped, where it sits on the page and without the frame's stroke, so Paste Into puts the whole image in place; Cut and Delete then remove only the graphic and leave its frame empty. Nested selections copy in their spread coordinates. No readiness percentage change. - **2026-10-13** · Object effects (Basic Feather, Inner Glow, shadows, glows, bevel, satin, directional and gradient feathers) apply to frames with pasted-in content, on screen and in image exports: the frame, its clipped content and its stroke are one object to the effect. PDF export still has no soft effects for any object. No readiness percentage change. - **2026-10-13** · Backspace between two consecutive tables, pasting a table next to a table anchor, and pasting several paragraphs right before one keep every table on its own anchor paragraph. Previously a table was dropped or left without its anchor, and the saved file failed to reopen. No readiness percentage change. diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index 4c6acc696..d0efbc1df 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -1487,7 +1487,8 @@ impl Item { /// The Layers-panel label: ``, the first words of a text frame, the placed file name… pub fn default_label(&self) -> &'static str { match (&self.content, self.shape) { - (Content::Group { .. }, _) => "", + // A frame with items pasted into it is named by its shape, as in InDesign. + (Content::Group { .. }, Shape::Group) => "", (Content::Text(_), _) => "", (Content::Graphic(_), _) => "", (_, Shape::Rectangle) => "", diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 0c8d8d821..1861f9217 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -1798,8 +1798,14 @@ fn ungroup(s: &mut Session, p: &Value) -> Result { for id in &ids { let Some(loc) = d.find(*id) else { continue }; let Some(g) = d.item(*id).cloned() else { continue }; - let Content::Group { items } = &g.content else { continue }; - if loc.path.len() != 1 { + // Only groups come apart: a frame holding pasted-into items stays whole, and objects + // that aren't groups stay selected (as in InDesign). + let Content::Group { items } = &g.content else { + out.push(*id); + continue; + }; + if !g.is_group() || loc.path.len() != 1 { + out.push(*id); continue; } let Some(sp) = d.spread_mut(loc.spread) else { continue }; @@ -4764,3 +4770,40 @@ mod effects_command_tests { assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc)); } } + +#[cfg(test)] +mod ungroup_tests { + use super::*; + + #[test] + fn ungroup_leaves_frames_with_pasted_in_items_whole() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = |r: Value| ItemId(r["id"].as_u64().unwrap()); + let sq = id(s.execute("frame.create", &json!({"rect": [100, 100, 200, 200]})).unwrap()); + let ell = id(s.execute("frame.create", &json!({"rect": [90, 90, 210, 210], "shape": "ellipse"})).unwrap()); + s.execute("selection.set", &json!({"ids": [sq.0]})).unwrap(); + s.execute("edit.cut", &json!({})).unwrap(); + s.execute("selection.set", &json!({"ids": [ell.0]})).unwrap(); + s.execute("edit.pasteInto", &json!({})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + assert_eq!(d.item(ell).unwrap().default_label(), "", "named by its shape, not "); + // Ungroup: the frame keeps its content and stays selected. + s.execute("object.ungroup", &json!({})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + assert!(d.item(ell).unwrap().has_nested_items()); + assert_eq!(d.spreads[0].items.len(), 1); + assert_eq!(s.doc().unwrap().selection.items, vec![ell]); + // A real group with the frame in it still comes apart, the frame whole. + let other = id(s.execute("frame.create", &json!({"rect": [300, 100, 400, 200]})).unwrap()); + s.execute("selection.set", &json!({"ids": [ell.0, other.0]})).unwrap(); + let g = id(s.execute("object.group", &json!({})).unwrap()); + assert_eq!(s.doc().unwrap().doc.item(g).unwrap().default_label(), ""); + s.execute("object.ungroup", &json!({})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + assert!(d.item(g).is_none()); + assert!(d.item(ell).unwrap().has_nested_items()); + assert_eq!(s.doc().unwrap().selection.items.len(), 2); + d.check().unwrap(); + } +}