feat(tools): resize a selected placed graphic by its own handles

With a placed graphic selected (Direct Selection, or the content
grabber), InDesign shows the graphic's own brown bounding box with eight
handles: dragging one resizes the image inside its frame (Shift keeps its
proportions, Alt from its centre) and leaves the frame as it is. LYH
Studio episode 14 does this at 2:46 to make the photo cover the page.

- tools: a content-handle hit test on the selected graphic's bounds
  (after the frame's anchors under Direct Selection), a ContentResize
  drag previewing `transform.set {content, bounds}`, the handle cursors,
  and the content ghost while dragging. The frame's handles don't resize
  the frame while its graphic is selected.
- engine: `transform.set` takes `bounds` for content: the graphic's new
  bounding box on the spread (normalized, never empty).
- canvas: the brown content box gets its handles; the frame's handles
  are hidden while the graphic is selected.

Builds on #415 (content selection and moves) and #550 (transform.set
on content).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
moppeeer
2026-10-13 12:11:45 +02:00
co-authored by Claude Opus 5.5
parent 37769986ab
commit 8e0e6c2f2e
5 changed files with 158 additions and 4 deletions
+1
View File
@@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
- **2026-10-13** · A selected placed graphic shows its own brown bounding box with handles: dragging one resizes the graphic inside its frame (Shift keeps proportions, Alt from the centre) and leaves the frame as it is; `transform.set` takes the graphic's new spread `bounds`. Builds on #415 and #550. No readiness percentage change.
- **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change.
- **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change.
- **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change.
+40 -1
View File
@@ -134,7 +134,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Transform Panel",
[],
None,
"{x?, y?, width?, height?, scaleX? (%), scaleY? (%), rotation? (°), shear? (°), ref?: 0..8, ids?, content?: bool (the placed graphic inside each frame; the frame stays. Default: whether the selection is content, when no ids are given)} — reference-point based geometry; rotation and shear are absolute (Transformations are Totals decides whether nested objects measure them on the pasteboard)",
"{x?, y?, width?, height?, scaleX? (%), scaleY? (%), rotation? (°), shear? (°), ref?: 0..8, ids?, content?: bool (the placed graphic inside each frame; the frame stays. Default: whether the selection is content, when no ids are given), bounds?: [x0, y0, x1, y1] (content: the graphic's new bounds on the spread, as its handles drag it)} — reference-point based geometry; rotation and shear are absolute (Transformations are Totals decides whether nested objects measure them on the pasteboard)",
has_selection,
transform_set
),
@@ -1439,6 +1439,7 @@ fn transform_content(s: &mut Session, p: &Value, ids: &[ItemId]) -> Result<Value
let get = |k: &str| p.get(k).and_then(Value::as_f64).filter(|v| v.is_finite());
let (rot, shear, sx, sy) = (get("rotation"), get("shear").map(|v| v.clamp(-85.0, 85.0)), get("scaleX"), get("scaleY"));
let (x, y, w, h) = (get("x"), get("y"), get("width").map(|v| v.max(0.01)), get("height").map(|v| v.max(0.01)));
let bounds = rect_param(p, "bounds").filter(|r| [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()));
if [sx, sy].iter().flatten().any(|v| v.abs() < 0.01) {
return Err(bad("transform.set", "scale too small"));
}
@@ -1492,6 +1493,17 @@ fn transform_content(s: &mut Session, p: &Value, ids: &[ItemId]) -> Result<Value
cur = Affine::translate(a) * delta * Affine::translate(-a) * cur;
}
let mut gx = q.inverse() * cur;
// The graphic's bounds dragged on the spread (its content handles): scale and move it so
// its bounding box becomes `bounds` (normalized; at least 0.01 pt each way).
if let Some(r) = bounds {
let to =
Rect::new(r.x0.min(r.x1), r.y0.min(r.y1), r.x0.max(r.x1).max(r.x0.min(r.x1) + 0.01), r.y0.max(r.y1).max(r.y0.min(r.y1) + 0.01));
let b = (frame * gx).transform_rect_bbox(rect);
let m = Affine::translate((to.x0, to.y0))
* Affine::scale_non_uniform(to.width() / b.width().max(1e-9), to.height() / b.height().max(1e-9))
* Affine::translate((-b.x0, -b.y0));
gx = frame.inverse() * m * frame * gx;
}
// Position and size: the graphic's bounds on the page.
if x.is_some() || y.is_some() || w.is_some() || h.is_some() {
let b = (frame * gx).transform_rect_bbox(rect);
@@ -3572,6 +3584,33 @@ mod content_transform_tests {
(s, f)
}
#[test]
fn content_bounds_resize_the_graphic_and_leave_the_frame() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let f = s.execute("frame.create", &json!({"rect": [100, 100, 200, 200], "content": "graphic"})).unwrap()["id"].as_u64().unwrap();
let png = designcraft_render::Rendered { width: 200, height: 100, pixels: vec![200u8; 200 * 100 * 4] }.to_png();
s.execute("file.place", &json!({"base64": crate::cmd::file::base64_encode(&png), "name": "a.png", "frame": f})).unwrap();
let bounds = |s: &Session| {
let it = s.doc().unwrap().doc.item(designcraft_doc::ItemId(f)).unwrap().clone();
let g = it.graphic().unwrap().clone();
(it.bounds(), (it.xf * g.xf).transform_rect_bbox(designcraft_geom::Rect::new(0.0, 0.0, g.size.0, g.size.1)))
};
let (frame0, _) = bounds(&s);
// The handles' drag: the image to (20, 40)–(420, 240), one undo step; the frame stays.
s.execute("transform.set", &json!({"ids": [f], "content": true, "bounds": [20, 40, 420, 240]})).unwrap();
let (frame1, g1) = bounds(&s);
assert_eq!(frame1, frame0);
let near = |a: f64, b: f64| (a - b).abs() < 1e-6;
assert!(near(g1.x0, 20.0) && near(g1.y0, 40.0) && near(g1.x1, 420.0) && near(g1.y1, 240.0), "{g1:?}");
// Dragged past the opposite edge: normalized, never empty.
s.execute("transform.set", &json!({"ids": [f], "content": true, "bounds": [300, 300, 300, 100]})).unwrap();
let (_, g2) = bounds(&s);
assert!(g2.width() > 0.0 && near(g2.y0, 100.0) && near(g2.y1, 300.0), "{g2:?}");
s.execute("edit.undo", &json!({})).unwrap();
assert!(near(bounds(&s).1.x1, 420.0));
}
#[test]
fn rotating_the_content_leaves_the_frame_as_it_is() {
let (mut s, f) = rotated_frame_with_image();
+62 -1
View File
@@ -7,6 +7,8 @@
//! 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.
//! A selected placed graphic shows its own bounding box: its handles resize the graphic (Shift keeps
//! its proportions, Alt from its centre) and leave the frame as it is.
use designcraft_doc::{Content, Item, ItemId, SpreadRef};
use designcraft_geom::{Affine, Point, Rect, Vec2};
@@ -36,6 +38,14 @@ enum Drag {
from: Rect,
spread: SpreadRef,
},
/// Resizing a selected placed graphic by its own bounding-box handles (`from`: its bounds on
/// the spread at pointer down).
ContentResize {
id: ItemId,
handle: usize,
from: Rect,
spread: SpreadRef,
},
Rotate {
center: Point,
start_angle: f64,
@@ -246,7 +256,27 @@ pub fn handles(r: Rect) -> [Point; 8] {
]
}
/// The selected placed graphic (content selection of one graphic frame) and its bounds on the canvas.
fn selected_graphic(cx: &ToolContext) -> Option<(ItemId, Rect)> {
let [id] = cx.selection.items.as_slice() else { return None };
if !cx.selection.content || !matches!(cx.doc.item(*id)?.content, Content::Graphic(_)) {
return None;
}
Some((*id, content_bounds(cx)?))
}
/// A handle of the selected placed graphic's bounding box under `p` (0 TL … 7 L).
fn content_handle_at(cx: &ToolContext, p: Point) -> Option<(ItemId, usize)> {
let (id, b) = selected_graphic(cx)?;
let tol = cx.tol(5.0);
handles(b).iter().position(|h| (h.x - p.x).abs() <= tol && (h.y - p.y).abs() <= tol).map(|h| (id, h))
}
fn handle_at(cx: &ToolContext, p: Point) -> Option<usize> {
// With a placed graphic selected, the handles are the graphic's, not the frame's.
if selected_graphic(cx).is_some() {
return None;
}
let b = cx.selection_bounds()?;
let tol = cx.tol(5.0);
handles(b).iter().position(|h| (h.x - p.x).abs() <= tol && (h.y - p.y).abs() <= tol)
@@ -515,6 +545,17 @@ impl Tool for SelectionTool {
self.drag = Drag::Anchor { id, si, ai, handle, start: p, at };
return vec![Action::Begin(if handle.is_some() { "Move Direction Handle".into() } else { "Move Anchor".into() })];
}
// A selected placed graphic's own handles resize it inside its frame (the
// frame's anchors, under Direct Selection, come first).
if let Some((id, h)) = content_handle_at(cx, p)
&& let (Some(loc), Some((_, b))) = (cx.doc.find(id), selected_graphic(cx))
{
let turns = cx.layout.slot(loc.spread).map_or(0, |s| s.rotation as usize);
let handle = (h + 8 - 2 * turns) % 8;
let from = cx.layout.xf(loc.spread).inverse().transform_rect_bbox(b);
self.drag = Drag::ContentResize { id, handle, from, spread: loc.spread };
return vec![Action::Begin("Resize Content".into())];
}
if !self.direct
&& handle_at(cx, p).is_none()
&& let Some(center) = rotate_zone(cx, p)
@@ -757,6 +798,10 @@ impl Tool for SelectionTool {
json!({"from": rect_json(from), "to": rect_json(to), "content": ev.mods.cmd, "distribute": distribute}),
)]
}
Drag::ContentResize { id, handle, from, spread } => {
let to = resize_rect(from, handle, cx.layout.to_spread(spread, p), ev.mods);
vec![Action::Preview("transform.set".into(), json!({"ids": [id.0], "content": true, "bounds": rect_json(to)}))]
}
Drag::Marquee { start, .. } => {
self.drag = Drag::Marquee { start, cur: p };
vec![]
@@ -851,7 +896,12 @@ impl Tool for SelectionTool {
let release = self.shift_release.take();
let d = std::mem::replace(&mut self.drag, Drag::None);
match d {
Drag::Move { .. } | Drag::Resize { .. } | Drag::Anchor { .. } | Drag::Rotate { .. } | Drag::Bracket { .. } => {
Drag::Move { .. }
| Drag::Resize { .. }
| Drag::ContentResize { .. }
| Drag::Anchor { .. }
| Drag::Rotate { .. }
| Drag::Bracket { .. } => {
vec![Action::Commit]
}
Drag::Pending { .. } => match release {
@@ -928,6 +978,8 @@ impl Tool for SelectionTool {
v
}
Drag::Move { .. } | Drag::Resize { .. } | Drag::Rotate { .. } | Drag::Anchor { .. } => self.guides.clone(),
// The whole graphic shows while it's resized, outside the frame too.
Drag::ContentResize { id, .. } => vec![Overlay::ContentGhost(*id)],
Drag::None | Drag::Bracket { .. } => brackets(cx),
_ => vec![],
}
@@ -940,6 +992,15 @@ impl Tool for SelectionTool {
if let Drag::Move { content: true, .. } = self.drag {
return Cursor::HandGrab;
}
if let Drag::ContentResize { handle, .. } = self.drag {
return handle_cursor(handle);
}
if matches!(self.drag, Drag::None)
&& !(self.direct && anchor_at(cx, p).is_some())
&& let Some((_, h)) = content_handle_at(cx, p)
{
return handle_cursor(h);
}
if self.direct {
let over_content = matches!(self.drag, Drag::None)
&& anchor_at(cx, p).is_none()
+38
View File
@@ -979,3 +979,41 @@ fn double_click_toggles_frame_and_content() {
let a = t.pointer(&ctx(&d, &content, &c, &l), &dbl);
assert_eq!(a, vec![Action::Exec("selection.set".into(), serde_json::json!({"ids": [f.0], "content": false}))]);
}
#[test]
fn a_selected_graphics_own_handles_resize_it_and_leave_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 content = Selection { content: true, ..Selection::items(vec![f]) };
let set_params = |a: &[Action]| match a.last() {
Some(Action::Preview(cmd, p)) if cmd == "transform.set" => p.clone(),
other => panic!("{other:?}"),
};
for tool in ["selection", "directSelection"] {
let mut t = create(tool);
let cx = ctx(&d, &content, &c, &l);
// The graphic's bottom-right corner, (250, 200), outside the frame's (200, 200).
let corner = spread_pt(&l, 250.0, 200.0);
assert_ne!(t.cursor(&cx, corner, Mods::default()), Cursor::Arrow, "{tool}");
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, corner.x, corner.y)), vec![Action::Begin("Resize Content".into())], "{tool}");
let to = spread_pt(&l, 300.0, 250.0);
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, to.x, to.y));
let p = set_params(&a);
assert_eq!(
(&p["ids"], &p["content"], &p["bounds"]),
(&serde_json::json!([f.0]), &serde_json::json!(true), &serde_json::json!([50.0, 100.0, 300.0, 250.0]))
);
assert!(t.overlays(&cx).contains(&Overlay::ContentGhost(f)));
// Shift keeps the graphic's proportions (2:1).
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, to.x, to.y).with_mods(Mods { shift: true, ..Default::default() }));
let b = set_params(&a)["bounds"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect::<Vec<_>>();
assert!(((b[2] - b[0]) / (b[3] - b[1]) - 2.0).abs() < 1e-6, "{b:?}");
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, to.x, to.y)), vec![Action::Commit]);
}
// The frame's own handles don't resize the frame while its graphic is selected.
let mut t = create("selection");
let frame_corner = spread_pt(&l, 200.0, 200.0);
let a = t.pointer(&ctx(&d, &content, &c, &l), &PointerEvent::new(PointerKind::Down, frame_corner.x, frame_corner.y));
assert!(!a.contains(&Action::Begin("Resize".into())), "{a:?}");
}
+17 -2
View File
@@ -826,7 +826,16 @@ fn draw_selection(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Docum
&& sel.content
{
let gb = xf.rect((a * it.xf).transform_rect_bbox(g.xf.transform_rect_bbox(DRect::new(0.0, 0.0, g.size.0, g.size.1))));
painter.rect_stroke(gb, 0.0, Stroke::new(1.0, Color32::from_rgb(196, 111, 43)), StrokeKind::Middle);
let brown = Color32::from_rgb(196, 111, 43);
painter.rect_stroke(gb, 0.0, Stroke::new(1.0, brown), StrokeKind::Middle);
// Its own handles resize the graphic (InDesign's brown content bounding box).
if sel.items.len() == 1 {
for h in designcraft_tools::select::handles(DRect::new(gb.min.x as f64, gb.min.y as f64, gb.max.x as f64, gb.max.y as f64)) {
let r = Rect::from_center_size(pos2(h.x as f32, h.y as f32), vec2(6.5, 6.5));
painter.rect_filled(r, 0.0, Color32::WHITE);
painter.rect_stroke(r, 0.0, Stroke::new(hair(painter), brown), StrokeKind::Inside);
}
}
}
}
let direct = matches!(app.session.tool_id(), "directSelection" | "pen" | "addAnchor" | "deleteAnchor" | "convertDirection");
@@ -869,7 +878,13 @@ fn draw_selection(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Docum
painter.rect_filled(lc, 0.0, Color32::from_rgb(0xff, 0xe5, 0x00));
painter.rect_stroke(lc, 0.0, Stroke::new(1.0, color), StrokeKind::Inside);
}
for h in designcraft_tools::select::handles(DRect::new(u.min.x as f64, u.min.y as f64, u.max.x as f64, u.max.y as f64)) {
// A selected placed graphic has its own handles instead of the frame's.
let graphic_selected =
sel.content && sel.items.len() == 1 && doc.item(sel.items[0]).is_some_and(|i| matches!(i.content, Content::Graphic(_)));
for h in designcraft_tools::select::handles(DRect::new(u.min.x as f64, u.min.y as f64, u.max.x as f64, u.max.y as f64))
.into_iter()
.filter(|_| !graphic_selected)
{
let r = Rect::from_center_size(pos2(h.x as f32, h.y as f32), vec2(hs, hs));
painter.rect_filled(r, 0.0, Color32::WHITE);
painter.rect_stroke(r, 0.0, Stroke::new(hair(painter), color), StrokeKind::Inside);