Frames holding pasted-in images are hit inside the frame, take a placed graphic; documents open on a usable layer

A frame with items pasted into it (how a document from InDesign keeps a cropped photo) was hit
through its children like a group, so the whole uncropped image counted: a click on the empty
page beside such a photo selected it, and nothing could be deselected there. It is now hit by
its own area, as it is drawn clipped. Such a frame holding only graphics also takes a placed or
dropped graphic (Item::takes_graphic), which fills it as an empty or graphic frame is filled.

A document opened with new objects going to its top layer even when that layer was hidden and
locked (a dieline), so a placed graphic showed as an empty frame outline. It now opens on the
topmost layer that is shown and unlocked (Document::working_layer).
This commit is contained in:
texjer committed 2026-11-03 09:31:47 -05:00
1 parent f1bdec7fee
commit 5364366074
6 files changed
+150 -9

No files matched your search

+65 -2
View File
@@ -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());
+10
View File
@@ -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)
}
+6
View File
@@ -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)
+66 -1
View File
@@ -99,7 +99,7 @@ pub(super) fn file_place(s: &mut Session, p: &Value) -> Result<Value> {
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);
}
}
+2 -2
View File
@@ -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<String>) -> 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 {
+1 -4
View File
@@ -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}))];
}