From 1824d3c27542fadab291faa437c3e82fac481e2b Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 03:05:53 -0400 Subject: [PATCH] Q: gridify while drawing frames Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/object.rs | 30 +++++++++++++++++++++ crates/engine/src/tests.rs | 21 +++++++++++++++ crates/tools/src/frame.rs | 46 +++++++++++++++++++++++++++++---- docs/parity.md | 6 ++--- 5 files changed, 96 insertions(+), 8 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 28f49aade..1b2d56830 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -73,6 +73,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Place a page of an IDML or DesignCraft document (`file.place {layoutPage}`): its objects, stories, styles and swatches as one group (INDD files are not readable). - Articles panel (`article.new/add/remove/options/delete/list`): reading order and content of EPUB and HTML exports. - Object ▸ Clipping Path (`object.clippingPath {type: alpha|edges, threshold, tolerance}`): traces the placed image and makes the outline the frame. +- Gridify: arrow keys while dragging a frame or shape tool add columns/rows (`frame.grid`), one undo step. - Drag and drop text editing: drag selected text to move it (Alt copies) with its formatting (`text.release`). - Image Import Options: place any page of a multi-page PDF (`file.place {pdfPage}`; the app asks which page), shown on screen and embedded as that page in PDF export. - Define Lists: named numbered lists that continue across stories in page order (`list.define`, paragraph `listName` / `startAt`), round-tripped through IDML; `null` in `type.para` / `type.char` now removes an override. diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index d3d268476..f9e6f4c4d 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -437,6 +437,36 @@ pub fn specs() -> Vec { Ok(json!({"sides": s.prefs.polygon_sides, "starInset": (s.prefs.star_inset * 100.0).round()})) } ), + cmd!( + "frame.grid", + "Gridify", + [], + None, + "{rect, cols, rows, gutter? (pt, 12), shape?, content?, spread?} — a grid of frames filling `rect` (frame tools: arrow keys while dragging)", + has_doc, + |s, p| { + let rect = rect_param(p, "rect").ok_or_else(|| bad("frame.grid", "missing rect"))?; + let cols = p.get("cols").and_then(Value::as_u64).unwrap_or(1).clamp(1, 100) as usize; + let rows = p.get("rows").and_then(Value::as_u64).unwrap_or(1).clamp(1, 100) as usize; + let gutter = p.get("gutter").and_then(Value::as_f64).unwrap_or(12.0).max(0.0); + let cw = ((rect.width() - gutter * (cols - 1) as f64) / cols as f64).max(0.5); + let ch = ((rect.height() - gutter * (rows - 1) as f64) / rows as f64).max(0.5); + let mut ids = Vec::new(); + for r in 0..rows { + for c in 0..cols { + let x0 = rect.x0 + c as f64 * (cw + gutter); + let y0 = rect.y0 + r as f64 * (ch + gutter); + let mut q = p.clone(); + q["rect"] = json!([x0, y0, x0 + cw, y0 + ch]); + q["caret"] = json!(false); + ids.push(frame_create(s, &q)?["id"].clone()); + } + } + let sel: Vec = ids.iter().filter_map(Value::as_u64).map(ItemId).collect(); + s.doc_mut()?.selection = designcraft_doc::Selection::items(sel); + Ok(json!({"ids": ids})) + } + ), cmd!( "object.clippingPath", "Clipping Path…", diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 34a679cea..5c80e1c11 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -369,3 +369,24 @@ fn object_set_flags_and_wrap_invert() { s.execute("object.textWrap", &json!({"mode": "boundingBox", "invert": true})).unwrap(); assert!(item(&s).wrap.invert); } + +#[test] +fn gridify_while_drawing_frames() { + use designcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; + let mut s = session(); + let n0 = s.doc().unwrap().doc.spreads[0].items.len(); + s.set_tool("rectangleFrame"); + let v = ViewInfo { zoom: 1.0 }; + s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 100.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 312.0, 212.0), v).unwrap(); + for k in [ToolKey::Right, ToolKey::Right, ToolKey::Up, ToolKey::Left] { + assert!(s.tool_key(k, Mods::default(), v).unwrap(), "arrow keys gridify while dragging"); + } + s.pointer(&PointerEvent::new(PointerKind::Up, 312.0, 212.0), v).unwrap(); + let d = &s.doc().unwrap().doc; + let new: Vec<_> = d.spreads[0].items[n0..].iter().map(|i| i.bounds()).collect(); + assert_eq!(new.len(), 4, "2 columns × 2 rows"); + assert!((new[0].width() - 100.0).abs() < 1e-6 && (new[1].x0 - 212.0).abs() < 1e-6, "12 pt gutters: {new:?}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.spreads[0].items.len(), n0, "one undo step"); +} diff --git a/crates/tools/src/frame.rs b/crates/tools/src/frame.rs index 51e05ca23..6bec5493d 100644 --- a/crates/tools/src/frame.rs +++ b/crates/tools/src/frame.rs @@ -12,12 +12,29 @@ pub struct FrameTool { cur: Point, active: bool, guides: Vec, + /// Gridify: columns and rows (arrow keys while dragging), and the last drawn rect. + grid: (u32, u32), + last: Option<(serde_json::Value, Rect)>, } impl FrameTool { pub fn new(id: &str) -> Self { let id = crate::tool_info(id).map(|t| t.id).unwrap_or("rectangleFrame"); - Self { id, start: None, cur: Point::ZERO, active: false, guides: vec![] } + Self { id, start: None, cur: Point::ZERO, active: false, guides: vec![], grid: (1, 1), last: None } + } + + /// The preview for `r`: one frame, or a grid of them. + fn preview(&self, spread: serde_json::Value, r: Rect) -> Action { + let (shape, content) = self.kind(); + let (c, rw) = self.grid; + if c * rw > 1 { + Action::Preview( + "frame.grid".into(), + json!({"spread": spread, "shape": shape, "content": content, "rect": rect_json(r), "cols": c, "rows": rw}), + ) + } else { + Action::Preview("frame.create".into(), json!({"spread": spread, "shape": shape, "content": content, "rect": rect_json(r)})) + } } fn kind(&self) -> (&'static str, &'static str) { match self.id { @@ -62,6 +79,8 @@ impl Tool for FrameTool { self.start = Some(pos); self.cur = ev.pos; self.active = false; + self.grid = (1, 1); + self.last = None; vec![] } PointerKind::Drag => { @@ -98,10 +117,8 @@ impl Tool for FrameTool { out.push(Action::Preview("line.create".into(), json!({"spread": spread_json(sr), "a": [sa.x, sa.y], "b": [b.x, b.y]}))); } else { let r = drag_rect(sa, ev.pos - off, ev.mods); - out.push(Action::Preview( - "frame.create".into(), - json!({"spread": spread_json(sr), "shape": shape, "content": content, "rect": rect_json(r)}), - )); + self.last = Some((spread_json(sr), r)); + out.push(self.preview(spread_json(sr), r)); } out } @@ -128,6 +145,25 @@ impl Tool for FrameTool { } } + fn key(&mut self, _cx: &ToolContext, key: crate::ToolKey, _mods: Mods) -> Vec { + // Gridify while dragging: ←/→ columns, ↑/↓ rows. + if !self.active || self.kind().0 == "line" { + return vec![]; + } + let (c, r) = &mut self.grid; + match key { + crate::ToolKey::Right => *c += 1, + crate::ToolKey::Left => *c = (*c).saturating_sub(1).max(1), + crate::ToolKey::Up => *r += 1, + crate::ToolKey::Down => *r = (*r).saturating_sub(1).max(1), + _ => return vec![], + } + match self.last.clone() { + Some((sp, rect)) => vec![self.preview(sp, rect)], + None => vec![], + } + } + fn overlays(&self, cx: &ToolContext) -> Vec { if let (true, Some(a)) = (self.active, self.start) { let r = Rect::from_points(a, self.cur); diff --git a/docs/parity.md b/docs/parity.md index 4389beafb..bb7897d5e 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -11,14 +11,14 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia |---|---|---|---|---|---| | P0 | 102 | 99 | 3 | 0 | 99% | | P1 | 97 | 92 | 4 | 1 | 97% | -| P2 | 76 | 25 | 14 | 37 | 42% | +| P2 | 76 | 26 | 14 | 36 | 43% | | Area | Parity | |---|---| | Application shell & workspace | 82% | | Documents, pages, spreads | 87% | | Layers | 95% | -| Frames, shapes & paths | 96% | +| Frames, shapes & paths | 98% | | Transform | 100% | | Fill, stroke, colour | 95% | | Effects & transparency | 71% | @@ -113,7 +113,7 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t | Frames, shapes & paths | Content grabber, select content/container | P0 | D | | Frames, shapes & paths | Auto-fit | P1 | D | | Frames, shapes & paths | Step and Repeat | P1 | D | -| Frames, shapes & paths | Gridify while drawing | P2 | M | +| Frames, shapes & paths | Gridify while drawing | P2 | D | | Frames, shapes & paths | Gap tool | P2 | M | | Frames, shapes & paths | Live Distribute | P2 | M | | Frames, shapes & paths | Generate QR Code | P2 | D |