From 6c65a559051935fc3a60331860ff3848ce68e6a5 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 13 Oct 2026 11:01:30 +0200 Subject: [PATCH] fix(object): Ungroup leaves frames with pasted-in content whole A frame holding items pasted into it isn't a group. Ungroup took it apart like one, leaving its content loose and losing the clipping shape, and the Properties panel called it "Group" with an Ungroup quick action. As in InDesign, Ungroup now only takes real groups apart; other objects stay as they are and stay selected (Ungroup used to clear them from the selection). Such frames are labelled by their shape (, ...), so Properties shows "Ellipse" with Convert Shape. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/doc/src/item.rs | 3 ++- crates/engine/src/cmd/object.rs | 47 +++++++++++++++++++++++++++++++-- 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..524baa166 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,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-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index 0a701998e..8942aac6c 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -1171,7 +1171,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 aa5a2cc01..c2ca37f2b 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -1451,8 +1451,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 }; @@ -3327,3 +3333,40 @@ mod spread_target_tests { assert!(s.doc().unwrap().doc.spreads[0].items.is_empty()); } } + +#[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(); + } +}