V: Power Zoom with the Hand tool

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-03 04:21:15 -04:00
1 parent eec8bd3d9e
commit 0454044d74
6 files changed
+74 -3

No files matched your search

+1
View File
@@ -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.
+14
View File
@@ -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<Point>,
power: bool,
}
impl Tool for HandTool {
@@ -16,10 +19,21 @@ impl Tool for HandTool {
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
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![] };
+19
View File
@@ -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<Action>| 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());
+34
View File
@@ -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 {
+3
View File
@@ -262,6 +262,8 @@ pub struct DesignApp {
pub views: HashMap<u64, View>,
pub canvas: CanvasCache,
pub canvas_rect: Option<egui::Rect>,
/// 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<egui::Event>,
/// 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,
+3 -3
View File
@@ -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 |