Canvas: drag the frame of a text box being edited to move it

While a text box or placeholder is being edited, every press inside its
box placed the caret, so the box could not be moved without leaving
editing first. Like PowerPoint, a band on either side of the box edge
(the hit tolerance wide, in the shape's rotated frame) is now its frame:
hit_test reports Hit::Frame there, the canvas shows the move cursor, and
a press leaves text editing, selects the box and drags it as one undo
step. A click without a drag just selects the whole box. Inside the box
the caret and text selection work as before; resize and adjust handles
still win. The text-click zone now follows the rotated box instead of
its axis-aligned bounds.
This commit is contained in:
pierrevbc
2026-10-13 11:26:11 +02:00
parent 1660982e58
commit 9d185aeb68
3 changed files with 224 additions and 22 deletions
+143
View File
@@ -835,3 +835,146 @@ fn slide_ranges_parse_like_powerpoints_print_box() {
let opts = cmd::file::pdf_options(&json!({"slides": [0, 7, 1]}), 2).unwrap();
assert_eq!(opts.slides, Some(vec![0, 1]), "indices past the end are dropped");
}
/// A blank slide holding one text box being edited, with smart guides and grid snapping off so
/// drags move by exactly the pointer delta. Returns the session, the box id and its box.
fn editing_text_box(rot: f64) -> (Session, u64, deckcraft_geom::Xfrm) {
let mut s = session();
s.prefs.snap_to_grid = false;
s.prefs.smart_guides = false;
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
let id = s.execute("insert.textBox", &json!({"rect": [200.0, 150.0, 240.0, 60.0], "text": "Hello world"})).unwrap()["id"].as_u64().unwrap();
if rot != 0.0 {
s.execute("shape.rotate", &json!({"ids": [id], "deg": rot})).unwrap();
}
s.execute("text.edit", &json!({"id": id, "at": [0, 0]})).unwrap();
let x = text_box_xfrm(&s, id);
(s, id, x)
}
fn text_box_xfrm(s: &Session, id: u64) -> deckcraft_geom::Xfrm {
let st = s.doc().unwrap();
cmd::xfrm_of(&st.doc, &st.selection, st.shape(deckcraft_model::ShapeId(id as u32)).unwrap())
}
/// Slide point for a point in the box's own (rotated) frame.
fn on_box(x: &deckcraft_geom::Xfrm, lx: f64, ly: f64) -> (f64, f64) {
let p = x.affine() * deckcraft_geom::Point::new(lx, ly);
(p.x, p.y)
}
fn drag_gesture(s: &mut Session, from: (f64, f64), to: (f64, f64)) -> serde_json::Value {
let ev = |kind, (x, y): (f64, f64)| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0 };
let down = s.pointer(ev(PointerKind::Down, from)).unwrap();
let mid = ((from.0 + to.0) / 2.0, (from.1 + to.1) / 2.0);
s.pointer(ev(PointerKind::Drag, mid)).unwrap();
s.pointer(ev(PointerKind::Drag, to)).unwrap();
s.pointer(ev(PointerKind::Up, to)).unwrap();
down
}
#[test]
fn dragging_the_frame_of_edited_text_moves_the_box() {
let (mut s, id, x) = editing_text_box(0.0);
assert!(x.h > 12.0, "{x:?}");
let sid = deckcraft_model::ShapeId(id as u32);
// Hovering the band on either side of the edge says "move"; the inside says "text".
for ly in [-2.0, 2.0, x.h + 2.0] {
let (px, py) = on_box(&x, x.w / 4.0, ly);
s.pointer(PointerEvent { kind: PointerKind::Move, x: px, y: py, mods: Mods::default(), tol: 3.0 }).unwrap();
assert_eq!(s.tool.hover, Some(tools::Hit::Frame { id: sid }), "ly {ly}");
}
let (px, py) = on_box(&x, x.w / 4.0, x.h / 2.0);
s.pointer(PointerEvent { kind: PointerKind::Move, x: px, y: py, mods: Mods::default(), tol: 3.0 }).unwrap();
assert_eq!(s.tool.hover, Some(tools::Hit::Shape { id: sid, text: true }));
// Press on the top edge, just outside, and drag: the box moves, text editing ends.
let undo_before = s.doc().unwrap().history.undo.len();
let from = on_box(&x, x.w / 4.0, -1.0);
let down = drag_gesture(&mut s, from, (from.0 + 30.0, from.1 + 20.0));
assert_eq!(down["hit"], json!({"kind": "frame", "id": id}));
let moved = text_box_xfrm(&s, id);
assert!((moved.x - (x.x + 30.0)).abs() < 1e-9 && (moved.y - (x.y + 20.0)).abs() < 1e-9, "{moved:?}");
assert_eq!((moved.w, moved.h), (x.w, x.h));
let st = s.doc().unwrap();
assert!(st.selection.text.is_none());
assert_eq!(st.selection.shapes, vec![sid]);
// One undo step, which puts the box back.
assert_eq!(st.history.undo.len(), undo_before + 1);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(text_box_xfrm(&s, id), x);
// The inner side of the band works too; a click there without a drag selects the box whole
// and changes nothing.
s.execute("text.edit", &json!({"id": id, "at": [0, 0]})).unwrap();
let undo_before = s.doc().unwrap().history.undo.len();
let (px, py) = on_box(&x, x.w * 0.75, x.h - 1.0);
let ev = |kind| PointerEvent { kind, x: px, y: py, mods: Mods::default(), tol: 3.0 };
s.pointer(ev(PointerKind::Down)).unwrap();
s.pointer(ev(PointerKind::Up)).unwrap();
let st = s.doc().unwrap();
assert!(st.selection.text.is_none());
assert_eq!(st.selection.shapes, vec![sid]);
assert_eq!(st.history.undo.len(), undo_before);
assert_eq!(text_box_xfrm(&s, id), x);
}
#[test]
fn dragging_inside_edited_text_selects_text() {
let (mut s, id, x) = editing_text_box(0.0);
let undo_before = s.doc().unwrap().history.undo.len();
let down = drag_gesture(&mut s, on_box(&x, 8.0, x.h / 2.0), on_box(&x, x.w - 8.0, x.h / 2.0));
assert!(down.get("caret").is_some(), "{down}");
assert_eq!(text_box_xfrm(&s, id), x);
let st = s.doc().unwrap();
let t = st.selection.text.as_ref().expect("still editing");
assert!(t.is_range(), "{t:?}");
assert_eq!(t.ordered(), ((0, 0), (0, 11)));
assert_eq!(st.history.undo.len(), undo_before);
}
#[test]
fn frame_of_rotated_edited_text_follows_the_rotation() {
let (mut s, id, x) = editing_text_box(30.0);
assert_eq!(x.rot, 30.0);
let sid = deckcraft_model::ShapeId(id as u32);
// The rotated bottom edge is the frame; dragging it moves the box and keeps its rotation.
let from = on_box(&x, x.w / 4.0, x.h + 1.0);
assert_eq!(s.hit_test(deckcraft_geom::Point::new(from.0, from.1), 3.0), Some(tools::Hit::Frame { id: sid }));
let down = drag_gesture(&mut s, from, (from.0 + 25.0, from.1 - 10.0));
assert_eq!(down["hit"]["kind"], "frame");
let moved = text_box_xfrm(&s, id);
assert!((moved.x - (x.x + 25.0)).abs() < 1e-9 && (moved.y - (x.y - 10.0)).abs() < 1e-9, "{moved:?}");
assert_eq!(moved.rot, 30.0);
// Inside the rotated box, pressing still places the caret.
s.execute("text.edit", &json!({"id": id, "at": [0, 0]})).unwrap();
let mx = text_box_xfrm(&s, id);
let (px, py) = on_box(&mx, mx.w / 2.0, mx.h / 2.0);
let r = s.pointer(PointerEvent { kind: PointerKind::Down, x: px, y: py, mods: Mods::default(), tol: 3.0 }).unwrap();
s.pointer(PointerEvent { kind: PointerKind::Up, x: px, y: py, mods: Mods::default(), tol: 3.0 }).unwrap();
assert!(r.get("caret").is_some(), "{r}");
assert_eq!(text_box_xfrm(&s, id), mx);
// Inside the axis-aligned bounds but off the rotated box: neither text nor frame, so a press
// there leaves editing instead of moving the caret.
let off = on_box(&mx, mx.w / 2.0, -mx.h);
let off_pt = deckcraft_geom::Point::new(off.0, off.1);
assert!(mx.bounds().contains(off_pt));
assert_ne!(s.hit_test(off_pt, 3.0), Some(tools::Hit::Frame { id: sid }));
let r = s.pointer(PointerEvent { kind: PointerKind::Down, x: off.0, y: off.1, mods: Mods::default(), tol: 3.0 }).unwrap();
s.pointer(PointerEvent { kind: PointerKind::Up, x: off.0, y: off.1, mods: Mods::default(), tol: 3.0 }).unwrap();
assert!(r.get("caret").is_none(), "{r}");
assert!(s.doc().unwrap().selection.text.is_none());
assert_eq!(text_box_xfrm(&s, id), mx);
}
#[test]
fn resize_handles_beat_the_frame_of_edited_text() {
let (mut s, id, x) = editing_text_box(0.0);
// The SE corner lies in the frame band, but its handle wins.
let from = on_box(&x, x.w, x.h);
let down = drag_gesture(&mut s, from, (from.0 + 40.0, from.1));
assert_eq!(down["hit"]["kind"], "resize");
let sized = text_box_xfrm(&s, id);
assert!((sized.w - (x.w + 40.0)).abs() < 1e-9, "{sized:?}");
assert_eq!((sized.x, sized.y), (x.x, x.y));
}
+80 -21
View File
@@ -85,6 +85,11 @@ pub enum Hit {
id: ShapeId,
text: bool,
},
/// The frame of the box whose text is being edited (a band on either side of its edge):
/// pressing there leaves text editing and drags the box, like PowerPoint.
Frame {
id: ShapeId,
},
}
#[derive(Clone, Debug)]
@@ -250,6 +255,13 @@ impl Session {
}
}
}
// The frame of the text being edited: grabbing it moves the box.
if let Some(e) = edited_box(st)
&& e.movable
&& edit_zone(&e.xfrm, p, tol) == EditZone::Frame
{
return Some(Hit::Frame { id: e.id });
}
// Shapes, topmost first.
// Locked objects are still picked (to format them or type in them); they just don't move.
for sh in st.shapes().iter().rev() {
@@ -362,29 +374,25 @@ impl Session {
ToolKind::Select => {}
}
let st = self.doc()?;
// Clicking inside the text being edited moves the caret / starts a text selection.
if let Some(t) = st.selection.text.clone()
&& !t.notes
&& t.cell.is_none()
&& let Some(sh) = st.shape(t.shape)
// Clicking inside the text being edited moves the caret / starts a text selection. Its
// handles and its frame (see `Hit::Frame`) win: they size, rotate or move the box.
if let Some(e) = edited_box(st)
&& edit_zone(&e.xfrm, p, tol) != EditZone::Outside
&& !matches!(self.hit_test(p, tol), Some(Hit::Resize { .. } | Hit::Rotate { .. } | Hit::Adjust { .. } | Hit::Frame { .. }))
&& let Some(sh) = st.shape(e.id)
{
let x = xfrm_of(&st.doc, &st.selection, sh);
if x.bounds().inflate(tol, tol).contains(p)
&& !matches!(self.hit_test(p, tol), Some(Hit::Resize { .. } | Hit::Rotate { .. } | Hit::Adjust { .. }))
{
let pos = text_pos(st, sh, p);
let shift = mods.shift;
self.select(|_, sel| {
if let Some(tt) = sel.text.as_mut() {
tt.caret = pos;
if !shift {
tt.anchor = pos;
}
let pos = text_pos(st, sh, p);
let shift = mods.shift;
self.select(|_, sel| {
if let Some(tt) = sel.text.as_mut() {
tt.caret = pos;
if !shift {
tt.anchor = pos;
}
})?;
self.tool.drag = Some(Drag::TextSelect { shape: t.shape });
return Ok(json!({"caret": [pos.0, pos.1]}));
}
}
})?;
self.tool.drag = Some(Drag::TextSelect { shape: e.id });
return Ok(json!({"caret": [pos.0, pos.1]}));
}
let hit = self.hit_test(p, tol);
let st = self.doc()?;
@@ -412,6 +420,17 @@ impl Session {
self.begin("Adjust Shape");
self.tool.drag = Some(Drag::Adjust { id, index, orig });
}
Some(Hit::Frame { id }) => {
// Like PowerPoint: the box leaves text editing and is selected whole (a click
// without a drag stops there), and dragging moves it as one undo step.
let origs: Vec<(ShapeId, Xfrm)> = st.shape(id).map(|s| (id, xfrm_of(&st.doc, &st.selection, s))).into_iter().collect();
self.select(|_, sel| {
sel.shapes = vec![id];
sel.text = None;
sel.cells = None;
})?;
self.tool.drag = Some(Drag::Move { ids: vec![id], start: p, origs, moved: false, duplicate: mods.alt, enter_text: None });
}
Some(Hit::Shape { id, text }) => {
let selected = st.selection.shapes.contains(&id);
let toggle = mods.shift || mods.cmd;
@@ -1097,6 +1116,46 @@ fn is_text_first(st: &crate::DocState, id: ShapeId) -> bool {
st.shape(id).is_some_and(|s| s.ph.is_some() || s.text_box)
}
/// The box whose text is being edited on the slide (not speaker notes, not a table cell).
struct EditedBox {
id: ShapeId,
xfrm: Xfrm,
/// Its frame can be grabbed to move it: not a line (no frame to grab) and not locked.
movable: bool,
}
fn edited_box(st: &crate::DocState) -> Option<EditedBox> {
let t = st.selection.text.as_ref()?;
if t.notes || t.cell.is_some() {
return None;
}
let sh = st.shape(t.shape)?;
Some(EditedBox { id: t.shape, xfrm: xfrm_of(&st.doc, &st.selection, sh), movable: !sh.is_line() && !sh.locked })
}
/// Where a point falls against the box of the text being edited.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum EditZone {
/// Well inside: the caret goes there.
Inside,
/// On the frame: a band `tol` wide on either side of the box edge.
Frame,
Outside,
}
/// Classify slide point `p` against box `x`, in the box's own (rotated, flipped) frame. Inside
/// small boxes the band narrows to a quarter of the box so their text stays reachable.
fn edit_zone(x: &Xfrm, p: Point, tol: f64) -> EditZone {
let l = x.affine().inverse() * p;
let (w, h) = (x.w.max(0.0), x.h.max(0.0));
let tol = if tol.is_finite() { tol.max(0.0) } else { 0.0 };
if !(l.x.is_finite() && l.y.is_finite()) || l.x < -tol || l.y < -tol || l.x > w + tol || l.y > h + tol {
return EditZone::Outside;
}
let inner = tol.min(w / 4.0).min(h / 4.0);
if l.x > inner && l.y > inner && l.x < w - inner && l.y < h - inner { EditZone::Inside } else { EditZone::Frame }
}
fn contains_rect(outer: Rect, inner: Rect) -> bool {
inner.x0 >= outer.x0 && inner.y0 >= outer.y0 && inner.x1 <= outer.x1 && inner.y1 <= outer.y1
}
+1 -1
View File
@@ -412,7 +412,7 @@ fn pointer(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) {
egui::CursorIcon::Move
}
}
(_, Some(Hit::Shape { .. })) => egui::CursorIcon::Move,
(_, Some(Hit::Shape { .. } | Hit::Frame { .. })) => egui::CursorIcon::Move,
_ => egui::CursorIcon::Default,
};
if resp.hovered() {