diff --git a/ROADMAP.md b/ROADMAP.md index 1f03828e2..5c0619255 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 96d51bb14..d12656d8a 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -134,7 +134,7 @@ pub fn specs() -> Vec { "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 Result 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(); diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index f4b0472db..7eed25a52 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -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 { + // 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() diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index 82dd7725a..8822bc4f8 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -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::>(); + 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:?}"); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 3840b2762..dcf7c2ae5 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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);