diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index 1bdd088fb..4353ecab9 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -868,13 +868,20 @@ fn story_has_hit(st: &Story, depth: usize, hit: &impl Fn(&Item) -> bool) -> bool /// Does `p` (in the item's parent space) hit `it`? pub fn item_hit(it: &Item, p: Point, tol: f64) -> bool { - if let Content::Group { items } = &it.content { + // A group is hit through its children. A frame with items pasted into it shows them clipped + // to its path, so it is hit by its own area: not where a nested image runs past the frame. + if let Content::Group { items } = &it.content + && !it.has_nested_items() + { let inner = it.xf.inverse() * p; return items.iter().any(|c| item_hit(c, inner, tol)); } let inner = it.xf.inverse() * p; let bp = it.path.to_bezpath(); - let area = !it.fill.is_none() || matches!(it.content, Content::Text(_) | Content::Graphic(_)) || it.path.is_closed() && it.shape != Shape::Path; + let area = !it.fill.is_none() + || matches!(it.content, Content::Text(_) | Content::Graphic(_)) + || it.has_nested_items() + || it.path.is_closed() && it.shape != Shape::Path; if area && it.path.is_closed() && designcraft_geom::hit::fill_contains(&bp, designcraft_geom::FillRule::NonZero, inner) { return true; } @@ -914,6 +921,62 @@ const PATH_TYPE_REACH: f64 = 12.0; mod placed_item_tests { use super::*; + /// A frame at (100, 100)–(200, 200) holding an image pasted into it that runs 150 pt past it on + /// every side, as a document from InDesign keeps a cropped photo. + fn frame_with_nested_image() -> Item { + let layer = LayerId(1); + let mut image = Item::new(ItemId(2), layer, Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 400.0, 400.0))); + image.xf = Affine::translate((-150.0, -150.0)); + image.content = Content::Graphic(crate::Graphic { + layer_options: None, + asset: crate::AssetId(1), + size: (400.0, 400.0), + xf: Affine::IDENTITY, + clip: None, + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + wrap: Default::default(), + fill: Default::default(), + }); + let mut frame = Item::new(ItemId(1), layer, Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0))); + frame.xf = Affine::translate((100.0, 100.0)); + frame.content = Content::Group { items: vec![Arc::new(image)] }; + frame + } + + /// The frame shows its nested image clipped, so a click where the image runs past the frame + /// hits nothing: clicking the empty page beside such a photo used to select it, so it could + /// not be deselected. + #[test] + fn a_frame_with_a_nested_image_is_hit_only_inside_the_frame() { + let frame = frame_with_nested_image(); + assert!(frame.has_nested_items()); + assert!(item_hit(&frame, Point::new(150.0, 150.0), 1.0), "inside the frame"); + assert!(item_hit(&frame, Point::new(100.0, 150.0), 1.0), "on its edge"); + for p in [Point::new(60.0, 60.0), Point::new(300.0, 150.0), Point::new(150.0, 330.0)] { + assert!(!item_hit(&frame, p, 1.0), "{p:?} is outside the frame, over the cropped image"); + } + // A group is still hit through its children. + let mut group = frame.clone(); + group.shape = Shape::Group; + assert!(item_hit(&group, Point::new(60.0, 60.0), 1.0)); + } + + /// Placing or dropping a graphic fills a frame holding only nested graphics, not one holding + /// other objects. + #[test] + fn a_frame_with_only_nested_graphics_takes_a_graphic() { + let frame = frame_with_nested_image(); + assert!(frame.takes_graphic()); + let mut other = frame.clone(); + other.content = Content::Group { items: vec![Arc::new(Item::new(ItemId(3), LayerId(1), Shape::Rectangle, Default::default()))] }; + assert!(!other.takes_graphic()); + let mut group = frame; + group.shape = Shape::Group; + assert!(!group.takes_graphic(), "a group of images is not a frame"); + } + #[test] fn exhaustive_placements_keep_unselected_branches_shared() { let mut d = Document::new(&Default::default()); diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index 4c930c878..d99bfebd4 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -282,6 +282,16 @@ impl Item { self.shape != Shape::Group && matches!(&self.content, Content::Group { items } if !items.is_empty()) } + /// A frame a placed or dropped graphic fills: an empty one, one holding a graphic, or one + /// holding only graphics pasted into it (as a document from InDesign keeps a frame's image). + pub fn takes_graphic(&self) -> bool { + match &self.content { + Content::Unassigned | Content::Graphic(_) => true, + Content::Group { items } => self.has_nested_items() && items.iter().all(|c| matches!(c.content, Content::Graphic(_))), + _ => false, + } + } + pub fn bounds(&self) -> Rect { self.bounds_under(Affine::IDENTITY).unwrap_or(Rect::ZERO) } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index cb0e4a020..e6acc8aa8 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -946,6 +946,12 @@ impl Document { pub fn default_layer(&self) -> LayerId { self.layers.first().map(|l| l.id).unwrap_or_default() } + /// The layer new objects go to when a document opens: the topmost one shown and unlocked, so + /// a document whose top layer is a hidden, locked dieline doesn't put what is placed or drawn + /// where it can't be seen or selected. The top layer when every layer is hidden or locked. + pub fn working_layer(&self) -> LayerId { + self.layers.iter().find(|l| l.visible && !l.locked).map_or_else(|| self.default_layer(), |l| l.id) + } pub fn story(&self, id: StoryId) -> Option<&Story> { self.stories.get(&id).map(|s| &**s) diff --git a/crates/engine/src/cmd/file/place.rs b/crates/engine/src/cmd/file/place.rs index 638742885..81126030f 100644 --- a/crates/engine/src/cmd/file/place.rs +++ b/crates/engine/src/cmd/file/place.rs @@ -99,7 +99,7 @@ pub(super) fn file_place(s: &mut Session, p: &Value) -> Result { return None; } let st = s.active()?; - st.selection.items.iter().copied().find(|i| st.doc.item(*i).is_some_and(|it| matches!(it.content, Content::Unassigned | Content::Graphic(_)))) + st.selection.items.iter().copied().find(|i| st.doc.item(*i).is_some_and(|it| it.takes_graphic())) }); // `"spread": null` counts as omitted, like the other placement parameters. let spread = match p.get("spread") { @@ -756,3 +756,68 @@ mod layout_place_tests { assert!(b.execute("file.place", &json!({"base64": b64, "name": "src.idml", "layoutPage": 2})).is_err(), "page 2 is empty"); } } + +#[cfg(test)] +mod nested_frame_tests { + use std::sync::Arc; + + use designcraft_doc::{Content, Item, ItemId, Shape, SpreadRef}; + use designcraft_geom::{Affine, Rect, shapes}; + use serde_json::json; + + use crate::Session; + + fn png(red: u8) -> String { + let png = designcraft_render::Rendered { width: 40, height: 20, pixels: [red, 90, 30, 255].repeat(800) }.to_png(); + super::super::base64_encode(&png) + } + + /// A graphic placed into a frame holding an image pasted into it (how a document from InDesign + /// keeps a photo) replaces the image and fills the frame, which keeps its size. + #[test] + fn placing_into_a_frame_with_a_nested_image_replaces_it() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let first = s.execute("file.place", &json!({"base64": png(200), "name": "old.png", "x": 0, "y": 0})).unwrap(); + let old = ItemId(first["id"].as_u64().unwrap()); + let frame = s + .edit(|d, _| { + let mut image = (*d.item(old).unwrap()).clone(); + d.remove_item(old)?; + image.xf = Affine::translate((-20.0, -20.0)); + let id = ItemId(d.alloc()); + let mut frame = Item::new(id, image.layer, Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 60.0, 90.0))); + frame.xf = Affine::translate((100.0, 100.0)); + frame.content = Content::Group { items: vec![Arc::new(image)] }; + d.insert_item(SpreadRef::Doc(0), frame, None)?; + Ok(id) + }) + .unwrap(); + s.execute("selection.set", &json!({"ids": [frame.0]})).unwrap(); + s.execute("file.place", &json!({"base64": png(20), "name": "new.png"})).unwrap(); + let d = &s.doc().unwrap().doc; + let it = d.item(frame).unwrap(); + let Content::Graphic(g) = &it.content else { panic!("the frame holds the new graphic: {:?}", it.content) }; + assert_eq!(d.assets[&g.asset].name, "new.png"); + assert_eq!(it.inner_bounds(), Rect::new(0.0, 0.0, 60.0, 90.0), "the frame keeps its size"); + assert_eq!(d.spreads[0].items.len(), 1, "no new object"); + } + + /// A document whose top layer is hidden and locked (a dieline) opens with new objects going to + /// the topmost layer that is shown and unlocked, not where they couldn't be seen or selected. + #[test] + fn a_document_opens_on_its_topmost_visible_unlocked_layer() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("layer.new", &json!({"name": "Artwork"})).unwrap(); + s.execute("layer.new", &json!({"name": "Dieline"})).unwrap(); + let mut d = (*s.doc().unwrap().doc).clone(); + let top = d.layers.first().unwrap().id; + d.layers[0].visible = false; + d.layers[0].locked = true; + let artwork = d.layers[1].id; + assert_eq!(d.default_layer(), top); + let st = crate::DocState::new(d, None); + assert_eq!(st.active_layer, artwork); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index d0922e484..19c50b7c0 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -147,7 +147,7 @@ fn next_revision() -> u64 { impl DocState { /// Initialize saved/live document identity, revision, epoch, active default layer, empty history with limit 1,000 and the initial path. pub fn new(doc: Document, path: Option) -> Self { - let active_layer = doc.default_layer(); + let active_layer = doc.working_layer(); let doc = Arc::new(doc); let revision = next_revision(); let identity_epoch = NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed); @@ -639,7 +639,7 @@ impl Session { if let Some(st) = self.active_mut() && st.doc.layer(st.active_layer).is_none() { - st.active_layer = st.doc.default_layer(); + st.active_layer = st.doc.working_layer(); } self.record_transform(id, params); if self.prefs.smart_text_reflow && spec.undoable { diff --git a/crates/tools/src/nav.rs b/crates/tools/src/nav.rs index e65daec8f..1748c9cfb 100644 --- a/crates/tools/src/nav.rs +++ b/crates/tools/src/nav.rs @@ -136,10 +136,7 @@ impl Tool for PlaceGun { let click = (ev.pos - a).hypot() < cx.tol(3.0); if click && let Some((_, id)) = cx.hit(a) - && cx - .doc - .item(id) - .is_some_and(|i| matches!(i.content, designcraft_doc::Content::Unassigned | designcraft_doc::Content::Graphic(_))) + && cx.doc.item(id).is_some_and(designcraft_doc::Item::takes_graphic) { return vec![Action::Exec("place.drop".into(), json!({"frame": id.0}))]; }