diff --git a/ROADMAP.md b/ROADMAP.md index 78cc939..745f32b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -86,6 +86,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Move table rows and columns (`table.moveRow`, `table.moveColumn`, Table panel buttons; dragging on the canvas isn't there yet). - Object States (`states.create/show/rename/release/list`, Object States panel): multi-state objects show one state on screen and in PDF. - Buttons (`button.set/clear/list`, Buttons and Forms panel): go to page / next / previous / first / last / URL as PDF link annotations. Form fields need AcroForm support the PDF writer lacks. +- Power Zoom (Hand tool, Alt-press): zoom out to the spread, aim the red view rectangle, release to zoom back in there. InDesign triggers it by press-and-hold instead. - 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/tools/src/nav.rs b/crates/tools/src/nav.rs index e9a0b85..6883ac0 100644 --- a/crates/tools/src/nav.rs +++ b/crates/tools/src/nav.rs @@ -5,9 +5,12 @@ use serde_json::json; use crate::{Action, Cursor, Mods, PointerEvent, PointerKind, Tool, ToolContext}; +/// Hand tool: drag to pan. Alt-press starts Power Zoom: the view zooms out to the spread, the +/// pointer aims a rectangle of the previous view, and releasing zooms back in there. #[derive(Default)] pub struct HandTool { last: Option, + power: bool, } impl Tool for HandTool { @@ -16,10 +19,21 @@ impl Tool for HandTool { } fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { match ev.kind { + PointerKind::Down if ev.mods.alt => { + self.power = true; + self.last = Some(ev.pos); + vec![Action::View(json!({"powerZoom": "start", "at": [ev.pos.x, ev.pos.y]}))] + } PointerKind::Down => { self.last = Some(ev.pos); vec![] } + PointerKind::Drag if self.power => vec![Action::View(json!({"powerZoom": "move", "at": [ev.pos.x, ev.pos.y]}))], + PointerKind::Up if self.power => { + self.power = false; + self.last = None; + vec![Action::View(json!({"powerZoom": "end", "at": [ev.pos.x, ev.pos.y]}))] + } PointerKind::Drag => { // Canvas positions shift as we pan, so convert the delta to screen pixels once. let Some(l) = self.last else { return vec![] }; diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index ed04ef9..f1f488f 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -204,6 +204,25 @@ fn zoom_tool_click_and_scrub() { assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 100.0)).is_empty()); } +#[test] +fn hand_tool_alt_press_power_zooms() { + let d = Document::new(&NewDocument::default()); + let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false)); + let cx = ctx(&d, &s, &c, &l); + let mut t = create("hand"); + let alt = Mods { alt: true, ..Default::default() }; + let phase = |a: Vec| match &a[..] { + [Action::View(v)] => v["powerZoom"].as_str().unwrap_or("").to_string(), + other => panic!("{other:?}"), + }; + assert_eq!(phase(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0).with_mods(alt))), "start"); + assert_eq!(phase(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 50.0, 60.0))), "move"); + assert_eq!(phase(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 50.0, 60.0))), "end"); + // A plain press pans as before. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 10.0, 10.0)).is_empty()); +} + #[test] fn page_tool_opens_page_size() { let d = Document::new(&NewDocument::default()); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 35b5804..d30056a 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -145,6 +145,34 @@ pub fn set_zoom(app: &mut DesignApp, z: f64) { } pub fn apply_view_request(app: &mut DesignApp, p: &Value) { + // Power Zoom (Hand tool, Alt-press): zoom out to the spread, aim a view-sized rectangle, zoom + // back in where it was released. + if let Some(phase) = p.get("powerZoom").and_then(Value::as_str) { + let at = p.get("at").and_then(Value::as_array).map(|a| Point::new(a[0].as_f64().unwrap_or(0.0), a[1].as_f64().unwrap_or(0.0))); + match phase { + "start" => { + if let (Some(v), Some(rect)) = (app.view().copied(), app.canvas_rect) { + let centre = Xf::new(rect, &v).to_canvas(rect.center()); + app.power_zoom = Some((v.zoom, at.unwrap_or(centre))); + fit(app, rect, "spread"); + } + } + "move" => { + if let (Some(pz), Some(a)) = (&mut app.power_zoom, at) { + pz.1 = a; + } + } + _ => { + if let (Some((z, c)), Some(rect)) = (app.power_zoom.take(), app.canvas_rect) + && let Some(v) = app.view_mut() + { + let c = at.unwrap_or(c); + v.zoom = z; + v.origin = Point::new(c.x - rect.width() as f64 / 2.0 / z, c.y - rect.height() as f64 / 2.0 / z); + } + } + } + } if let Some(d) = p.get("pan").and_then(Value::as_array) { let (dx, dy) = (d.first().and_then(Value::as_f64).unwrap_or(0.0), d.get(1).and_then(Value::as_f64).unwrap_or(0.0)); if let Some(v) = app.view_mut() { @@ -1076,6 +1104,12 @@ fn caret_x(l: &compose::Line, pos: usize) -> f64 { } fn draw_tool_overlays(app: &mut DesignApp, painter: &egui::Painter, xf: &Xf) { + if let (Some((z, c)), Some(rect)) = (app.power_zoom, app.canvas_rect) { + // The view that releasing returns to. + let (w, h) = (rect.width() as f64 / z, rect.height() as f64 / z); + let r = xf.rect(designcraft_geom::Rect::new(c.x - w / 2.0, c.y - h / 2.0, c.x + w / 2.0, c.y + h / 2.0)); + painter.rect_stroke(r, 0.0, egui::Stroke::new(1.5, Color32::from_rgb(230, 40, 40)), egui::StrokeKind::Middle); + } let ov = app.session.overlays(app.view_info()); for o in ov { match o { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 2b7c8e1..8eb6067 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -262,6 +262,8 @@ pub struct DesignApp { pub views: HashMap, pub canvas: CanvasCache, pub canvas_rect: Option, + /// Power Zoom in progress: the zoom to return to and the canvas point it will centre on. + pub power_zoom: Option<(f64, designcraft_geom::Point)>, pub perf: Perf, pub synthetic: Vec, /// Story open in the Story Editor. @@ -308,6 +310,7 @@ impl DesignApp { patches: 0, }, canvas_rect: None, + power_zoom: None, perf: Perf::default(), synthetic: vec![], story_editor: None, diff --git a/docs/parity.md b/docs/parity.md index 269671c..58c2b4c 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -11,7 +11,7 @@ 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 | 35 | 23 | 18 | 61% | +| P2 | 76 | 35 | 24 | 17 | 62% | | Area | Parity | |---|---| @@ -31,7 +31,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia | Interactivity & digital | 90% | | Output & production | 80% | | XML & automation | 83% | -| View & navigation | 88% | +| View & navigation | 89% | | Undo, history, saving | 100% | | Accessibility | 70% | @@ -297,7 +297,7 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t | XML & automation | Snippets | P1 | D | | XML & automation | Object Library | P2 | D | | View & navigation | Zoom commands | P0 | D | -| View & navigation | Power Zoom | P2 | M | +| View & navigation | Power Zoom | P2 | P | | View & navigation | Animated/scrubby zoom | P1 | D | | View & navigation | Screen modes | P0 | D | | View & navigation | Overprint preview | P1 | D |