diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 7627d5569..b4d65ec60 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -26,7 +26,15 @@ pub fn specs() -> Vec { frame_create ), cmd!("line.create", "Create Line", [], None, "{spread?, a: [x,y], b: [x,y]}", has_doc, line_create), - cmd!("transform.move", "Move", ["Object", "Transform"], None, "{dx, dy, copy?: bool, ids?, toSpread?}", has_selection, transform_move), + cmd!( + "transform.move", + "Move", + ["Object", "Transform"], + None, + "{dx, dy, copy?: bool, ids?, toSpread?, content?: bool (move the placed graphic or pasted-in items inside each frame; the frame stays. Default: whether the selection is content, when neither ids nor copy is given)}", + has_selection, + transform_move + ), cmd!( "transform.resize", "Resize", @@ -931,6 +939,12 @@ fn transform_move(s: &mut Session, p: &Value) -> Result { let ids = targets(s, p)?; let (dx, dy) = (f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0)); let copy = bool_or(p, "copy", false); + let explicit = p.get("ids").is_some() || p.get("id").is_some(); + // Duplicating copies the frame; moving a content selection moves what the frame holds. + let content = p.get("content").and_then(Value::as_bool).unwrap_or(!explicit && !copy && s.doc()?.selection.content); + if content { + return move_content(s, &ids, Vec2::new(dx, dy)); + } let to: Option = p.get("toSpread").and_then(|v| serde_json::from_value(v.clone()).ok()); s.edit(|d, sel| { let ids = if copy { @@ -955,16 +969,61 @@ fn transform_move(s: &mut Session, p: &Value) -> Result { let it = d.remove_item_keep_story(*id)?; d.insert_item(target, it, None)?; } + // A nested item moves in its container's space. + let v = d.find(*id).map_or(Vec2::new(dx, dy), |l| linear_inverse(d.parent_xf(&l), Vec2::new(dx, dy))); let it = d.item_mut(*id).ok_or(designcraft_doc::DocError::NoItem(*id))?; if it.locked { continue; } - it.xf = Affine::translate((dx, dy)) * it.xf; + it.xf = Affine::translate(v) * it.xf; } Ok(json!({"moved": ids.len()})) }) } +/// `v` (a spread-space displacement) in the space `m` maps from. Degenerate maps leave it as is. +fn linear_inverse(m: Affine, v: Vec2) -> Vec2 { + let c = m.as_coeffs(); + let lin = Affine::new([c[0], c[1], c[2], c[3], 0.0, 0.0]); + if lin.determinant().abs() < 1e-12 { + return v; + } + let r = lin.inverse() * Point::new(v.x, v.y); + if r.x.is_finite() && r.y.is_finite() { r.to_vec2() } else { v } +} + +/// Move what each frame holds — its placed graphic or the items pasted into it — by the +/// spread-space displacement `d`; the frame stays, so the visible part of the content changes. +/// Frames without such content (and groups) move as a whole. Auto-fit stays as it is: the next +/// frame resize refits. +fn move_content(s: &mut Session, ids: &[ItemId], d: Vec2) -> Result { + s.edit(|doc, _| { + let mut moved = 0; + for id in ids { + let loc = doc.find(*id).ok_or(designcraft_doc::DocError::NoItem(*id))?; + let parent = doc.parent_xf(&loc); + let it = doc.item_mut(*id).ok_or(designcraft_doc::DocError::NoItem(*id))?; + if it.locked { + continue; + } + let inner = linear_inverse(parent * it.xf, d); + let nested = it.has_nested_items(); + match &mut it.content { + Content::Graphic(g) => g.xf = Affine::translate(inner) * g.xf, + Content::Group { items } if nested => { + for c in items.iter_mut() { + let c = Arc::make_mut(c); + c.xf = Affine::translate(inner) * c.xf; + } + } + _ => it.xf = Affine::translate(linear_inverse(parent, d)) * it.xf, + } + moved += 1; + } + Ok(json!({"moved": moved})) + }) +} + fn transform_resize(s: &mut Session, p: &Value) -> Result { let ids = targets(s, p)?; let from = rect_param(p, "from").ok_or_else(|| bad("transform.resize", "missing from"))?; @@ -3026,3 +3085,75 @@ mod reorder_tests { assert!(s.execute("object.setLayer", &json!({"ids": [a], "layer": 999})).is_err()); } } + +#[cfg(test)] +mod content_move_tests { + use super::*; + + /// A session with a 40×20 image placed in a frame at (100, 100)–(300, 300). + fn placed() -> (Session, ItemId) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let png = designcraft_render::Rendered { width: 40, height: 20, pixels: vec![200; 40 * 20 * 4] }.to_png(); + let f = s.execute("frame.create", &json!({"rect": [100, 100, 300, 300], "content": "graphic"})).unwrap()["id"].as_u64().unwrap(); + s.execute("place.load", &json!({"base64": super::super::base64_encode(&png)})).unwrap(); + s.execute("place.drop", &json!({"frame": f})).unwrap(); + (s, ItemId(f)) + } + + /// Spread bounds of the frame and of its graphic. + fn boxes(s: &Session, id: ItemId) -> (Rect, Rect) { + let it = s.doc().unwrap().doc.item(id).unwrap().clone(); + let g = it.graphic().unwrap(); + (it.bounds(), (it.xf * g.xf).transform_rect_bbox(Rect::new(0.0, 0.0, g.size.0, g.size.1))) + } + + fn near(a: Rect, b: Rect) -> bool { + [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().all(|d| d.abs() < 1e-6) + } + + #[test] + fn content_moves_inside_the_frame_in_one_undo_step() { + let (mut s, f) = placed(); + let (frame0, g0) = boxes(&s, f); + s.execute("selection.set", &json!({"ids": [f.0], "content": true})).unwrap(); + s.execute("transform.move", &json!({"dx": 10, "dy": -5})).unwrap(); + let (frame1, g1) = boxes(&s, f); + assert!(near(frame1, frame0), "the frame stays"); + assert!(near(g1, g0 + Vec2::new(10.0, -5.0)), "{g1:?}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(near(boxes(&s, f).1, g0)); + // Explicit ids with `content` act on the content whatever the selection is. + s.execute("selection.set", &json!({"ids": [f.0]})).unwrap(); + s.execute("transform.move", &json!({"ids": [f.0], "content": true, "dx": 3})).unwrap(); + assert!(near(boxes(&s, f).0, frame0) && near(boxes(&s, f).1, g0 + Vec2::new(3.0, 0.0))); + // A frame selection moves frame and content together. + s.execute("transform.move", &json!({"dx": 4})).unwrap(); + assert!(near(boxes(&s, f).0, frame0 + Vec2::new(4.0, 0.0)) && near(boxes(&s, f).1, g0 + Vec2::new(7.0, 0.0))); + } + + #[test] + fn content_moves_on_the_page_axes_in_a_rotated_frame_and_pasted_items_move_too() { + let (mut s, f) = placed(); + s.execute("transform.rotate", &json!({"ids": [f.0], "angle": 90})).unwrap(); + let (frame0, g0) = boxes(&s, f); + s.execute("transform.move", &json!({"ids": [f.0], "content": true, "dx": 10})).unwrap(); + let (frame1, g1) = boxes(&s, f); + assert!(near(frame1, frame0) && near(g1, g0 + Vec2::new(10.0, 0.0)), "{g0:?} → {g1:?}"); + // Items pasted into a frame are its content. + let a = s.execute("frame.create", &json!({"rect": [400, 100, 420, 120], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); + s.execute("selection.set", &json!({"ids": [a]})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + let b = s.execute("frame.create", &json!({"rect": [400, 300, 500, 400], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); + s.execute("selection.set", &json!({"ids": [b]})).unwrap(); + s.execute("edit.pasteInto", &json!({})).unwrap(); + let kids = |s: &Session| { + let it = s.doc().unwrap().doc.item(ItemId(b)).unwrap().clone(); + (it.bounds(), it.children().iter().map(|c| it.xf.transform_rect_bbox(c.bounds())).collect::>()) + }; + let (fb0, k0) = kids(&s); + s.execute("transform.move", &json!({"ids": [b], "content": true, "dx": 5, "dy": 6})).unwrap(); + let (fb1, k1) = kids(&s); + assert!(near(fb1, fb0) && near(k1[0], k0[0] + Vec2::new(5.0, 6.0)), "{k0:?} → {k1:?}"); + } +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 7da76a658..5ef6003c4 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -845,3 +845,38 @@ fn character_style_delete_rejects_builtin_styles() { let r = s.execute("style.character.delete", &json!({"name": designcraft_doc::NO_CHAR_STYLE})); assert!(r.is_err(), "built-in styles can't be deleted"); } + +#[test] +fn direct_selection_drag_and_arrow_keys_move_placed_content() { + use designcraft_tools::{Mods, PointerEvent, PointerKind, SnapView, ToolKey}; + let mut s = session(); + let png = designcraft_render::Rendered { width: 40, height: 20, pixels: vec![200; 40 * 20 * 4] }.to_png(); + let f = s.execute("frame.create", &json!({"rect": [100, 100, 300, 300], "content": "graphic"})).unwrap()["id"].as_u64().unwrap(); + s.execute("place.load", &json!({"base64": cmd::base64_encode(&png)})).unwrap(); + s.execute("place.drop", &json!({"frame": f})).unwrap(); + let xfs = |s: &Session| { + let it = s.doc().unwrap().doc.item(designcraft_doc::ItemId(f)).unwrap().clone(); + (it.xf, it.graphic().unwrap().xf.translation()) + }; + let (frame0, g0) = xfs(&s); + let v = ViewInfo { snap: SnapView::OFF, ..ViewInfo::at_zoom(1.0) }; + let at = s.layout().to_canvas(designcraft_doc::SpreadRef::Doc(0), designcraft_geom::Point::new(150.0, 150.0)); + s.set_tool("directSelection"); + s.pointer(&PointerEvent::new(PointerKind::Down, at.x, at.y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, at.x + 20.0, at.y + 10.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, at.x + 30.0, at.y + 10.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, at.x + 30.0, at.y + 10.0), v).unwrap(); + assert!(s.doc().unwrap().selection.content); + let (frame1, g1) = xfs(&s); + assert_eq!(frame1, frame0, "the frame stays"); + assert_eq!(g1 - g0, designcraft_geom::Vec2::new(30.0, 10.0)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(xfs(&s).1, g0, "one undo step"); + // Arrow keys nudge the selected content; Shift nudges ten times as far. + s.set_tool("selection"); + s.tool_key(ToolKey::Right, Mods::default(), v).unwrap(); + s.tool_key(ToolKey::Down, Mods { shift: true, ..Default::default() }, v).unwrap(); + let (frame2, g2) = xfs(&s); + assert_eq!(frame2, frame0); + assert_eq!(g2 - g0, designcraft_geom::Vec2::new(1.0, 10.0)); +} diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 440eee8db..b857d7b00 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -130,6 +130,15 @@ impl Session { self.with_ctx(view, |t, cx| t.cursor(cx, p, mods)).unwrap_or_default() } + /// Canvas centres of the content grabbers the Selection tool shows: the selected frame's and + /// the one under `hover`. + pub fn content_grabbers(&mut self, hover: Option, view: ViewInfo) -> Vec { + if self.tool.id() != "selection" { + return vec![]; + } + self.with_ctx(view, |_, cx| designcraft_tools::select::grabbers(cx, hover).into_iter().map(|(_, c)| c).collect()).unwrap_or_default() + } + pub fn tool_busy(&self) -> bool { self.tool.busy() } diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 185d12b62..0031cc62b 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -322,6 +322,9 @@ pub enum Overlay { b: Point, label: String, }, + /// The whole content of this frame drawn faintly, the part outside the frame included (while + /// the content moves inside it). + ContentGhost(ItemId), } /// Correction from a snap, in spread coordinates for `delta`. diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 84e12d3b3..5f2cf0197 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -4,8 +4,12 @@ //! the 8 bounding-box handles resize (Shift keeps proportions, Alt from centre, Cmd scales content), //! empty-canvas drags make a marquee, double-clicking a text frame switches to the Type tool. //! A click on a selected text frame's in or out port loads the text cursor (see [`crate::thread`]). +//! +//! Content (a placed graphic, or items pasted into a frame) moves inside its frame: the Direct +//! Selection tool selects and drags it, the Selection tool drags it by the content grabber (the +//! circle at the frame's centre), and double-clicking a frame toggles between frame and content. -use designcraft_doc::{ItemId, SpreadRef}; +use designcraft_doc::{Content, Item, ItemId, SpreadRef}; use designcraft_geom::{Affine, Point, Rect, Vec2}; use serde_json::{Value, json}; @@ -18,12 +22,15 @@ enum Drag { Pending { start: Point, hit: bool, + /// The drag moves the selection's content inside its frames. + content: bool, }, Move { start: Point, origin_spread: SpreadRef, bounds0: Option, exclude: Vec, + content: bool, }, Resize { handle: usize, @@ -162,6 +169,73 @@ impl SelectionTool { } } +/// A frame whose content moves on its own: a placed graphic or items pasted into it. +pub fn holds_content(it: &Item) -> bool { + !it.is_group() && (matches!(it.content, Content::Graphic(_)) || it.has_nested_items()) +} + +/// Grab radius of the content grabber, in screen pixels. +const GRABBER_PX: f64 = 9.0; + +/// Canvas centre of the content grabber of `id` (the middle of its frame), when it has one. Frames +/// too small to drag around the grabber have none. +pub fn grabber_center(cx: &ToolContext, id: ItemId) -> Option { + let it = cx.doc.item(id)?; + if it.locked || !holds_content(it) { + return None; + } + let b = cx.item_canvas_xf(id)?.transform_rect_bbox(it.bounds()); + let min = cx.tol(GRABBER_PX * 4.0); + (b.width() >= min && b.height() >= min && b.center().x.is_finite() && b.center().y.is_finite()).then(|| b.center()) +} + +/// Content grabbers on show: the single selected frame's and the hovered frame's. +pub fn grabbers(cx: &ToolContext, hover: Option) -> Vec<(ItemId, Point)> { + let mut ids = Vec::new(); + if let [one] = cx.selection.items.as_slice() + && cx.selection.text.is_none() + { + ids.push(*one); + } + if let Some((_, id)) = hover.and_then(|p| cx.hit(p)) { + let id = cx.doc.top_level_of(id).unwrap_or(id); + if !ids.contains(&id) { + ids.push(id); + } + } + ids.into_iter().filter_map(|id| grabber_center(cx, id).map(|c| (id, c))).collect() +} + +/// The frame whose content grabber is under `p` (canvas). +pub fn grabber_at(cx: &ToolContext, p: Point) -> Option { + let r = cx.tol(GRABBER_PX); + grabbers(cx, Some(p)).into_iter().find(|(_, c)| (*c - p).hypot() <= r).map(|(id, _)| id) +} + +/// Canvas union of the content of the selected frames (an item without content counts whole). +fn content_bounds(cx: &ToolContext) -> Option { + let mut acc: Option = None; + for id in &cx.selection.items { + let (Some(it), Some(xf)) = (cx.doc.item(*id), cx.item_canvas_xf(*id)) else { continue }; + let b = match &it.content { + Content::Graphic(g) => (xf * it.xf * g.xf).transform_rect_bbox(Rect::new(0.0, 0.0, g.size.0, g.size.1)), + _ if it.has_nested_items() => { + let Some(r) = it.children().iter().map(|c| (xf * it.xf).transform_rect_bbox(c.bounds())).reduce(|a, b| a.union(b)) else { continue }; + r + } + _ => { + let Some(local) = crate::snap::moving_bounds(it) else { continue }; + xf.transform_rect_bbox(local) + } + }; + if !(b.x0.is_finite() && b.y0.is_finite() && b.x1.is_finite() && b.y1.is_finite()) { + continue; + } + acc = Some(acc.map_or(b, |have| have.union(b))); + } + acc +} + /// Handle positions (canvas) of a rect: 0 TL, 1 T, 2 TR, 3 R, 4 BR, 5 B, 6 BL, 7 L. pub fn handles(r: Rect) -> [Point; 8] { let c = r.center(); @@ -438,6 +512,18 @@ impl Tool for SelectionTool { self.drag = Drag::None; return actions; } + // The second press of a double click on a frame with content: toggle between the frame and + // its content. + if !self.direct + && matches!(ev.kind, PointerKind::Down | PointerKind::DoubleClick) + && ev.click_count() == 2 + && let Some(id) = cx.hit(p).map(|(_, id)| cx.doc.top_level_of(id).unwrap_or(id)) + && cx.doc.item(id).is_some_and(holds_content) + { + self.drag = Drag::None; + let content = !(cx.selection.content && cx.selection.contains(id)); + return vec![Action::Exec("selection.set".into(), json!({"ids": [id.0], "content": content}))]; + } match ev.kind { PointerKind::Move => { self.hover_handle = handle_at(cx, p); @@ -470,13 +556,24 @@ impl Tool for SelectionTool { Drag::Resize { handle, start: p, from: cx.layout.xf(loc.spread).inverse().transform_rect_bbox(b), spread: loc.spread }; return vec![Action::Begin("Resize".into())]; } + // The content grabber: select the frame's content; a drag moves it. + if !self.direct + && !ev.mods.shift + && let Some(id) = grabber_at(cx, p) + { + self.drag = Drag::Pending { start: p, hit: true, content: true }; + if cx.selection.items == [id] && cx.selection.content { + return vec![]; + } + return vec![Action::Exec("selection.set".into(), json!({"ids": [id.0], "content": true}))]; + } // Cmd+Shift-click a parent item on a page: override it there and select the copy. if ev.mods.cmd && ev.mods.shift && cx.hit(p).is_none() && let Some((page, id)) = cx.hit_parent_on_page(p) { - self.drag = Drag::Pending { start: p, hit: true }; + self.drag = Drag::Pending { start: p, hit: true, content: false }; return vec![Action::Exec("layout.overrideParentItems".into(), json!({"page": page, "ids": [id.0]}))]; } match cx.hit(p) { @@ -490,31 +587,46 @@ impl Tool for SelectionTool { } else { out.push(Action::Exec("selection.toggle".into(), json!({"id": id.0}))); } - } else if !cx.selection.contains(id) { + } else if !cx.selection.contains(id) || (self.direct && !cx.selection.content) { out.push(Action::Exec("selection.set".into(), json!({"ids": [id.0], "content": self.direct}))); } - self.drag = Drag::Pending { start: p, hit: true }; + self.drag = Drag::Pending { start: p, hit: true, content: self.direct }; let _ = sr; out } None => { - self.drag = Drag::Pending { start: p, hit: false }; + self.drag = Drag::Pending { start: p, hit: false, content: false }; if ev.mods.shift { vec![] } else { vec![Action::Exec("selection.set".into(), json!({"ids": []}))] } } } } PointerKind::Drag => match self.drag.clone() { - Drag::Pending { start, hit } => { + Drag::Pending { start, hit, content } => { if (p - start).hypot() < cx.tol(3.0) { return vec![]; } self.shift_release = None; if hit && !cx.selection.items.is_empty() { + // Direct Selection on items without content moves (or duplicates) them whole. + let content = content && cx.selection.items.iter().any(|i| cx.doc.item(*i).is_some_and(holds_content)); let sr = cx.selection.items.first().and_then(|i| cx.doc.find(*i)).map(|l| l.spread).unwrap_or(SpreadRef::Doc(0)); let exclude = cx.selection.items.clone(); - self.drag = Drag::Move { start, origin_spread: sr, bounds0: cx.selection_visible_bounds(), exclude }; - let label = if ev.mods.alt { "Duplicate" } else { "Move" }; - let mut v = vec![Action::Begin(label.into())]; + let bounds0 = if content { content_bounds(cx) } else { cx.selection_visible_bounds() }; + self.drag = Drag::Move { start, origin_spread: sr, bounds0, exclude: exclude.clone(), content }; + let mut v = vec![]; + // Dragging a content selection by its frame moves the frame: select it. + if !content && cx.selection.content { + let ids: Vec = exclude.iter().map(|i| i.0).collect(); + v.push(Action::Exec("selection.set".into(), json!({"ids": ids}))); + } + let label = if content { + "Move Content" + } else if ev.mods.alt { + "Duplicate" + } else { + "Move" + }; + v.push(Action::Begin(label.into())); v.extend(self.pointer(cx, ev)); v } else { @@ -522,7 +634,7 @@ impl Tool for SelectionTool { vec![] } } - Drag::Move { start, origin_spread, bounds0, exclude } => { + Drag::Move { start, origin_spread, bounds0, exclude, content } => { let mut d: Vec2 = p - start; let mut screen_x_locked = false; let mut screen_y_locked = false; @@ -536,7 +648,8 @@ impl Tool for SelectionTool { } } // Snap on the spread under the pointer. Shift uses that spread's view rotation. - let target = cx.layout.spread_at(p).map(|(s, _)| s).unwrap_or(origin_spread); + // Content stays in its frame, on the frame's spread. + let target = if content { origin_spread } else { cx.layout.spread_at(p).map(|(s, _)| s).unwrap_or(origin_spread) }; // 180 degrees keeps the screen axis. 90 and 270 swap it into the other spread axis. let odd_turn = cx.layout.slot(target).is_some_and(|slot| slot.rotation % 2 == 1); let spread_x_locked = if odd_turn { screen_y_locked } else { screen_x_locked }; @@ -570,7 +683,7 @@ impl Tool for SelectionTool { x_edges, y_edges, exclude: &exclude, - copying: ev.mods.alt, + copying: ev.mods.alt && !content, lengths: [None, None], angle: None, radius: 0.0, @@ -602,6 +715,10 @@ impl Tool for SelectionTool { let dt = landed - q0; (dt.x, dt.y) }; + if content { + let ids: Vec = exclude.iter().map(|i| i.0).collect(); + return vec![Action::Preview("transform.move".into(), json!({"dx": dx, "dy": dy, "ids": ids, "content": true}))]; + } let mut params = json!({"dx": dx, "dy": dy, "copy": ev.mods.alt}); if target != origin_spread { params = json!({"dx": dx, "dy": dy, "copy": ev.mods.alt, "toSpread": spread_json(target)}); @@ -803,14 +920,25 @@ impl Tool for SelectionTool { fn overlays(&self, _cx: &ToolContext) -> Vec { match &self.drag { Drag::Marquee { start, cur } | Drag::Place { start, cur } => vec![Overlay::Marquee(Rect::from_points(*start, *cur))], + Drag::Move { content: true, exclude, .. } => { + let mut v: Vec = exclude.iter().map(|id| Overlay::ContentGhost(*id)).collect(); + v.extend(self.guides.iter().cloned()); + v + } Drag::Move { .. } | Drag::Resize { .. } | Drag::Rotate { .. } | Drag::Anchor { .. } => self.guides.clone(), _ => vec![], } } fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor { + if let Drag::Move { content: true, .. } = self.drag { + return Cursor::HandGrab; + } if self.direct { - return Cursor::ArrowHollow; + let over_content = matches!(self.drag, Drag::None) + && anchor_at(cx, p).is_none() + && cx.hit(p).is_some_and(|(_, id)| cx.doc.item(id).is_some_and(holds_content)); + return if over_content { Cursor::Hand } else { Cursor::ArrowHollow }; } if let Some(l) = self.loaded.and_then(|l| l.resolve(cx.doc)) { return if crate::thread::port_at(cx, p).is_some() { Cursor::LoadedText } else { l.cursor(cx, p) }; @@ -824,6 +952,7 @@ impl Tool for SelectionTool { match handle_at(cx, p) { Some(h) => handle_cursor(h), None if rotate_zone(cx, p).is_some() => Cursor::Rotate, + None if grabber_at(cx, p).is_some() => Cursor::Hand, None => Cursor::Arrow, } } diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index f898755bf..8801acc2d 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -939,3 +939,117 @@ fn clicks_in_a_row_edit_and_select_text() { // Moving a little while pressed keeps the unit selected. assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, x + 2.0, y)).is_empty()); } + +/// A 100×100 frame at (100, 100) holding a 200×100 graphic that overhangs it. +fn graphic_frame(doc: &mut Document) -> ItemId { + let id = ItemId(doc.alloc()); + let mut item = Item::new(id, doc.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0))); + item.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic { + asset: designcraft_doc::AssetId(1), + size: (200.0, 100.0), + xf: designcraft_geom::Affine::translate((50.0, 100.0)), + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + wrap: Default::default(), + }); + doc.insert_item(SpreadRef::Doc(0), item, None).unwrap(); + id +} + +fn preview_params(a: &[Action]) -> serde_json::Value { + match a.last() { + Some(Action::Preview(cmd, p)) if cmd == "transform.move" => p.clone(), + other => panic!("{other:?}"), + } +} + +#[test] +fn direct_selection_drags_content_inside_its_frame() { + let mut d = Document::new(&NewDocument::default()); + let f = graphic_frame(&mut d); + let (c, l) = (Cache::new(), CanvasLayout::new(&d, false)); + let none = Selection::default(); + let mut t = create("directSelection"); + let at = spread_pt(&l, 120.0, 130.0); + let a = t.pointer(&ctx(&d, &none, &c, &l), &PointerEvent::new(PointerKind::Down, at.x, at.y)); + assert_eq!(a, vec![Action::Exec("selection.set".into(), serde_json::json!({"ids": [f.0], "content": true}))]); + let sel = Selection { content: true, ..Selection::items(vec![f]) }; + let cx = ctx(&d, &sel, &c, &l); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, at.x + 15.0, at.y - 4.0)); + assert_eq!(a.first(), Some(&Action::Begin("Move Content".into()))); + let p = preview_params(&a); + assert_eq!( + (p["dx"].as_f64(), p["dy"].as_f64(), &p["content"], &p["ids"]), + (Some(15.0), Some(-4.0), &serde_json::json!(true), &serde_json::json!([f.0])) + ); + assert_eq!(t.cursor(&cx, at, Mods::default()), Cursor::HandGrab); + assert!(t.overlays(&cx).contains(&Overlay::ContentGhost(f))); + // Shift keeps the drag on one axis. + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, at.x + 15.0, at.y - 4.0).with_mods(Mods { shift: true, ..Default::default() })); + let p = preview_params(&a); + assert_eq!((p["dx"].as_f64(), p["dy"].as_f64()), (Some(15.0), Some(0.0))); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, at.x + 15.0, at.y)), vec![Action::Commit]); +} + +#[test] +fn selection_tool_grabber_moves_content_elsewhere_moves_the_frame() { + let mut d = Document::new(&NewDocument::default()); + let f = graphic_frame(&mut d); + let (c, l) = (Cache::new(), CanvasLayout::new(&d, false)); + let frame = Selection::items(vec![f]); + let content = Selection { content: true, ..Selection::items(vec![f]) }; + let centre = spread_pt(&l, 150.0, 150.0); + let mut t = create("selection"); + // Hovering the grabber shows the hand; it is there on hover before any click, too. + assert_eq!(t.cursor(&ctx(&d, &Selection::default(), &c, &l), centre, Mods::default()), Cursor::Hand); + let g = select::grabbers(&ctx(&d, &Selection::default(), &c, &l), Some(centre)); + assert!(matches!(g.as_slice(), [(id, p)] if *id == f && near(*p, centre)), "{g:?}"); + // Press on the grabber: the content is selected; the drag moves it. + let a = t.pointer(&ctx(&d, &frame, &c, &l), &PointerEvent::new(PointerKind::Down, centre.x, centre.y)); + assert_eq!(a, vec![Action::Exec("selection.set".into(), serde_json::json!({"ids": [f.0], "content": true}))]); + let a = t.pointer(&ctx(&d, &content, &c, &l), &PointerEvent::new(PointerKind::Drag, centre.x - 20.0, centre.y + 10.0)); + let p = preview_params(&a); + assert_eq!((p["dx"].as_f64(), p["dy"].as_f64(), &p["content"]), (Some(-20.0), Some(10.0), &serde_json::json!(true))); + assert_eq!(t.pointer(&ctx(&d, &content, &c, &l), &PointerEvent::new(PointerKind::Up, centre.x - 20.0, centre.y + 10.0)), vec![Action::Commit]); + // A press elsewhere in the frame keeps the content selection; dragging selects and moves the frame. + let edge = spread_pt(&l, 115.0, 185.0); + assert!(t.pointer(&ctx(&d, &content, &c, &l), &PointerEvent::new(PointerKind::Down, edge.x, edge.y)).is_empty()); + let a = t.pointer(&ctx(&d, &content, &c, &l), &PointerEvent::new(PointerKind::Drag, edge.x + 30.0, edge.y)); + assert_eq!(a[0], Action::Exec("selection.set".into(), serde_json::json!({"ids": [f.0]}))); + assert_eq!(a[1], Action::Begin("Move".into())); + let p = preview_params(&a); + assert_eq!((p["dx"].as_f64(), p.get("content"), p.get("ids")), (Some(30.0), None, None)); + t.pointer(&ctx(&d, &frame, &c, &l), &PointerEvent::new(PointerKind::Up, edge.x + 30.0, edge.y)); + // With the frame selected, a drag away from the grabber moves the frame. + t.pointer(&ctx(&d, &frame, &c, &l), &PointerEvent::new(PointerKind::Down, edge.x, edge.y)); + let a = t.pointer(&ctx(&d, &frame, &c, &l), &PointerEvent::new(PointerKind::Drag, edge.x, edge.y + 30.0)); + assert_eq!(a[0], Action::Begin("Move".into())); + assert_eq!(preview_params(&a).get("content"), None); +} + +#[test] +fn double_click_toggles_frame_and_content() { + let mut d = Document::new(&NewDocument::default()); + let f = graphic_frame(&mut d); + let (c, l) = (Cache::new(), CanvasLayout::new(&d, false)); + let at = spread_pt(&l, 120.0, 180.0); + let mut t = create("selection"); + let frame = Selection::items(vec![f]); + let content = Selection { content: true, ..Selection::items(vec![f]) }; + let set = |content: bool| vec![Action::Exec("selection.set".into(), serde_json::json!({"ids": [f.0], "content": content}))]; + // The canvas's presses in a row: the first selects the frame, the second its content. + let a = t.pointer(&ctx(&d, &Selection::default(), &c, &l), &PointerEvent::new(PointerKind::Down, at.x, at.y)); + assert_eq!(a, vec![Action::Exec("selection.set".into(), serde_json::json!({"ids": [f.0], "content": false}))]); + t.pointer(&ctx(&d, &frame, &c, &l), &PointerEvent::new(PointerKind::Up, at.x, at.y)); + assert_eq!(t.pointer(&ctx(&d, &frame, &c, &l), &PointerEvent::new(PointerKind::Down, at.x, at.y).with_clicks(2)), set(true)); + assert!(t.pointer(&ctx(&d, &content, &c, &l), &PointerEvent::new(PointerKind::Up, at.x, at.y)).is_empty()); + // With the content selected, the first press keeps it and the second selects the frame. + assert!(t.pointer(&ctx(&d, &content, &c, &l), &PointerEvent::new(PointerKind::Down, at.x, at.y)).is_empty()); + t.pointer(&ctx(&d, &content, &c, &l), &PointerEvent::new(PointerKind::Up, at.x, at.y)); + assert_eq!(t.pointer(&ctx(&d, &content, &c, &l), &PointerEvent::new(PointerKind::Down, at.x, at.y).with_clicks(2)), set(false)); + // A double click sent as such (control channel) toggles too. + let dbl = PointerEvent::new(PointerKind::DoubleClick, at.x, at.y); + assert_eq!(t.pointer(&ctx(&d, &frame, &c, &l), &dbl), set(true)); + assert_eq!(t.pointer(&ctx(&d, &content, &c, &l), &dbl), set(false)); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 09c1bbe10..819dfcc89 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -322,6 +322,16 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } } let sel_rect = draw_selection(app, &painter, &xf, &doc, &layout); + if !preview { + // Content grabbers: the selected frame's and the hovered one's (Selection tool). + let hover = ui.ctx().pointer_hover_pos().filter(|p| screen.contains(*p)).map(|p| xf.to_canvas(p)); + let vi = app.view_info(); + for c in app.session.content_grabbers(hover, vi) { + let c = xf.to_screen(c); + painter.circle_stroke(c, 8.0, Stroke::new(1.5, Color32::from_white_alpha(170))); + painter.circle_stroke(c, 4.0, Stroke::new(1.5, Color32::from_white_alpha(170))); + } + } draw_tool_overlays(app, &painter, &xf); ime_output(app, ui.ctx(), &xf); if rulers { @@ -905,11 +915,6 @@ fn draw_selection(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Docum painter.rect_filled(r, 0.0, Color32::WHITE); painter.rect_stroke(r, 0.0, Stroke::new(hair(painter), color), StrokeKind::Inside); } - // Content grabber on graphic frames. - if sel.items.len() == 1 && doc.item(sel.items[0]).is_some_and(|i| matches!(i.content, Content::Graphic(_))) { - painter.circle_stroke(u.center(), 8.0, Stroke::new(1.5, Color32::from_white_alpha(170))); - painter.circle_stroke(u.center(), 4.0, Stroke::new(1.5, Color32::from_white_alpha(170))); - } } // Threads. if app.ui.text_threads || sel.items.iter().any(|i| doc.item(*i).is_some_and(|x| x.is_text_frame())) { @@ -1219,6 +1224,8 @@ fn draw_tool_overlays(app: &mut DesignApp, painter: &egui::Painter, xf: &Xf) { } let tok = Tokens::get(painter.ctx()); let ov = app.session.overlays(app.view_info()); + let doc = app.session.active().map(|st| st.doc.clone()); + let layout = app.session.layout(); for o in ov { match o { Overlay::Marquee(r) => { @@ -1261,10 +1268,66 @@ fn draw_tool_overlays(app: &mut DesignApp, painter: &egui::Painter, xf: &Xf) { painter.galley(r.min + vec2(5.0, 3.0), g, tok.measure_text); } Overlay::Path { .. } => {} + Overlay::ContentGhost(id) => { + if let Some(doc) = &doc { + draw_content_ghost(painter, xf, doc, &layout, id); + } + } } } } +/// A frame's whole content drawn faintly over the canvas, the part outside the frame included, +/// with its bounds outlined (while the content moves inside the frame). +fn draw_content_ghost(painter: &egui::Painter, xf: &Xf, doc: &Document, layout: &CanvasLayout, id: designcraft_doc::ItemId) { + let Some(it) = doc.item(id) else { return }; + let Some((a, _)) = item_canvas_xf(doc, layout, id) else { return }; + let outline = Stroke::new(1.0, Color32::from_rgb(196, 111, 43)); + let Content::Graphic(g) = &it.content else { + for c in it.children() { + let r = xf.rect((a * it.xf).transform_rect_bbox(c.bounds())); + painter.rect_stroke(r, 0.0, outline, StrokeKind::Middle); + } + return; + }; + let m = a * it.xf * g.xf; + let (w, h) = g.size; + let corners = [Point::new(0.0, 0.0), Point::new(w, 0.0), Point::new(w, h), Point::new(0.0, h)].map(|p| xf.to_screen(m * p)); + if corners.iter().any(|p| !p.x.is_finite() || !p.y.is_finite()) { + return; + } + if let Some(tex) = ghost_texture(painter.ctx(), doc, g) { + let mut mesh = egui::Mesh::with_texture(tex.id()); + let tint = Color32::from_white_alpha(90); + for (p, uv) in corners.iter().zip([pos2(0.0, 0.0), pos2(1.0, 0.0), pos2(1.0, 1.0), pos2(0.0, 1.0)]) { + mesh.vertices.push(egui::epaint::Vertex { pos: *p, uv, color: tint }); + } + mesh.indices.extend([0, 1, 2, 0, 2, 3]); + painter.add(egui::Shape::mesh(mesh)); + } + painter.add(egui::Shape::closed_line(corners.to_vec(), outline)); +} + +/// A screen-resolution proxy of a placed graphic for the ghost, cached per asset. +fn ghost_texture(ctx: &egui::Context, doc: &Document, g: &designcraft_doc::Graphic) -> Option { + let asset = doc.assets.get(&g.asset)?; + let key = egui::Id::new(("content_ghost", std::sync::Arc::as_ptr(&asset.data) as usize, asset.data.len(), asset.page)); + if let Some(t) = ctx.data(|d| d.get_temp::(key)) { + return Some(t); + } + // A 72 ppi proxy is enough for a faint preview. + let pm = designcraft_render::images::mip(&asset.data, asset.page, g.size.0, 1.0)?; + let (pw, ph) = (pm.width() as usize, pm.height() as usize); + let max = ctx.input(|i| i.max_texture_side).min(4096); + if pw == 0 || ph == 0 || pw > max || ph > max { + return None; + } + let ci = egui::ColorImage::from_rgba_premultiplied([pw, ph], pm.data_as_u8_slice()); + let t = ctx.load_texture("content_ghost", ci, egui::TextureOptions::LINEAR); + ctx.data_mut(|d| d.insert_temp(key, t.clone())); + Some(t) +} + #[allow(clippy::too_many_arguments)] fn draw_rulers(app: &DesignApp, ui: &egui::Ui, full: Rect, rect: Rect, xf: &Xf, doc: &Document, layout: &CanvasLayout, t: &Tokens) { let painter = ui.painter_at(full);