diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index 7b2168e30..f24e72992 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -587,6 +587,30 @@ pub fn item_hit(it: &Item, p: Point, tol: f64) -> bool { designcraft_geom::hit::stroke_contains(&bp, it.stroke.weight.max(1.0), tol + reach, inner) } +/// The frontmost descendant of `it` under `p` that `want` accepts, looking inside nested groups; +/// `it` itself when nothing inside it matches and `want` accepts it. `p` is in the space `it` +/// sits in, as [`item_hit`] takes it. +/// +/// Children are tried front to back and a hidden one is skipped, so of several that `want` +/// accepts the answer is the one in front. Objects `want` rejects do not hide the ones behind +/// them: a text frame under a rectangle in the same group is still found. +pub fn hit_descendant(it: &Item, p: Point, tol: f64, want: &dyn Fn(&Item) -> bool) -> Option { + if !item_hit(it, p, tol) { + return None; + } + let inner = it.xf.inverse() * p; + let children: Vec<&Arc> = it.shown_children().collect(); + for c in children.into_iter().rev() { + if c.hidden { + continue; + } + if let Some(id) = hit_descendant(c, inner, tol, want) { + return Some(id); + } + } + want(it).then_some(it.id) +} + /// How far from its path type on a path is hit (points): about a cap height of 12–18 pt type, /// either side (the type may sit on or under the path). const PATH_TYPE_REACH: f64 = 12.0; diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index c0fdc8fe2..3bcbe1a83 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -46,7 +46,7 @@ pub use datamerge::{ }; pub use designcraft_color as color; pub use designcraft_geom as geom; -pub use edit::{ItemLoc, ItemPath, SpreadRef, item_hit as edit_hit}; +pub use edit::{ItemLoc, ItemPath, SpreadRef, hit_descendant, item_hit as edit_hit}; pub use endnotes::{ENDNOTE_REF, EditorialNote, EndnoteOptions, NOTE_MARK}; pub use flex::{FlexAlign, FlexDirection, FlexJustify, FlexLayout, FlexSize}; pub use ids::*; diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 3f042d4a0..6ce0be596 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -232,6 +232,19 @@ impl ToolContext<'_> { self.doc.hit_item(si, sp, self.tol(4.0)).map(|id| (sr, id)) } + /// The frontmost text frame under `p`, looking inside groups. [`Self::hit`] stops at the + /// group, so a loaded text cursor over a text frame in a group found nothing to thread into + /// (gaps 27); InDesign threads into it. + pub fn hit_text_frame(&self, p: Point) -> Option<(SpreadRef, ItemId)> { + let (sr, id) = self.hit(p)?; + let it = self.doc.item(id)?; + if it.is_text_frame() { + return Some((sr, id)); + } + let (_, sp) = self.layout.spread_at(p)?; + designcraft_doc::hit_descendant(it, sp, self.tol(4.0), &|c| c.is_text_frame()).map(|f| (sr, f)) + } + /// The parent item showing (not yet overridden) on the document page under `p`: /// (absolute page, parent item). pub fn hit_parent_on_page(&self, p: Point) -> Option<(usize, ItemId)> { diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index aacfb8d3f..9e76e8a8b 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -1252,7 +1252,8 @@ fn eyedropper_ignores_inactive_states_of_a_multi_state_object() { // Children [vector, raster] with the raster on top: it samples only while it is the active state. for (active, samples) in [(1, true), (0, false)] { let mut group = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(area)); - group.content = Content::Group { items: vec![std::sync::Arc::new(vector.clone()), std::sync::Arc::new(raster.clone())] }; + group.content = + designcraft_doc::item::Content::Group { items: vec![std::sync::Arc::new(vector.clone()), std::sync::Arc::new(raster.clone())] }; group.states = vec!["a".into(), "b".into()]; group.active_state = active; let mut d2 = d.clone(); @@ -1314,3 +1315,52 @@ fn anchor_tools_show_pen_state_cursors() { assert_eq!(cursor("deleteAnchor"), Cursor::PenDelete); assert_eq!(cursor("convertDirection"), Cursor::PenConvert); } + +#[test] +fn loaded_text_cursor_threads_into_a_frame_inside_a_group() { + // `hit` stops at the group, so the loaded cursor showed NotAllowed over a text frame inside + // one and a click did nothing. InDesign threads into it (gaps 27). + let mut td = thread_doc(); + let lid = td.d.default_layer(); + let rect = Rect::new(600.0, 100.0, 700.0, 200.0); + // The frame, then the same frame moved inside a group. Both transforms are the identity, so + // the child's coordinates stay the spread's. + let (inner, _) = td.d.add_text_frame(SpreadRef::Doc(0), rect, lid, "", ParaFormat::default()).unwrap(); + let frame_item = td.d.item(inner).cloned().unwrap(); + td.d.remove_item(inner).unwrap(); + let gid = ItemId(td.d.alloc()); + let mut group = Item::new(gid, lid, Shape::Group, shapes::rectangle(rect)); + group.content = designcraft_doc::item::Content::Group { items: vec![std::sync::Arc::new(frame_item)] }; + td.d.insert_item(SpreadRef::Doc(0), group, None).unwrap(); + td.l = CanvasLayout::new(&td.d, false); + + let mut t = create("selection"); + let cx = td.cx(); + click(&mut t, &cx, td.port(td.a, true), Mods::default()); + let p = td.at(650.0, 150.0); + + // Selection still reaches the group, which is what a plain click there should pick. + assert_eq!(cx.hit(p).map(|(_, i)| i), Some(gid)); + // The loaded text cursor reaches the frame inside it. + assert_eq!(cx.hit_text_frame(p).map(|(_, i)| i), Some(inner), "the frame in the group"); + assert_eq!(t.cursor(&cx, p, Mods::default()), Cursor::ThreadLink); + // And the click threads to that frame, not to the group. + assert_eq!(click(&mut t, &cx, p, Mods::default()), thread_exec(serde_json::json!({"from": td.a.0, "to": inner.0}))); + + // A group with nothing threadable in it still says no. + let mut td2 = thread_doc(); + let r2 = Rect::new(600.0, 300.0, 700.0, 400.0); + let plain = ItemId(td2.d.alloc()); + let child = Item::new(plain, lid, Shape::Rectangle, shapes::rectangle(r2)); + let g2 = ItemId(td2.d.alloc()); + let mut group2 = Item::new(g2, lid, Shape::Group, shapes::rectangle(r2)); + group2.content = designcraft_doc::item::Content::Group { items: vec![std::sync::Arc::new(child)] }; + td2.d.insert_item(SpreadRef::Doc(0), group2, None).unwrap(); + td2.l = CanvasLayout::new(&td2.d, false); + let cx2 = td2.cx(); + let mut t2 = create("selection"); + click(&mut t2, &cx2, td2.port(td2.a, true), Mods::default()); + let q = td2.at(650.0, 350.0); + assert_eq!(cx2.hit_text_frame(q).map(|(_, i)| i), None, "no text frame in that group"); + assert_eq!(t2.cursor(&cx2, q, Mods::default()), Cursor::NotAllowed); +} diff --git a/crates/tools/src/thread.rs b/crates/tools/src/thread.rs index 0a1d69f9e..977524659 100644 --- a/crates/tools/src/thread.rs +++ b/crates/tools/src/thread.rs @@ -194,7 +194,8 @@ impl Loaded { } pub fn target(self, cx: &ToolContext, p: Point) -> Target { - let Some((_, id)) = cx.hit(p) else { return Target::Empty }; + // A text frame inside a group is the frame the click means, not the group (gaps 27). + let Some((_, id)) = cx.hit_text_frame(p).or_else(|| cx.hit(p)) else { return Target::Empty }; if Some(id) == self.neighbour(cx.doc) { return Target::Unthread; }