From e61f75d69a1eef18f4eefcf34f35959b9d73c625 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 04:00:12 -0400 Subject: [PATCH] M3: Marquee zoom (drag to fill the window with an area, click to zoom in) and Take a Snapshot (copies an area at the current zoom to the clipboard as an image) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands.rs | 2 + crates/ui-egui/src/canvas.rs | 25 +++++++- crates/ui-egui/src/commands.rs | 5 ++ crates/ui-egui/src/control.rs | 2 + crates/ui-egui/src/lib.rs | 13 ++++ crates/ui-egui/src/zoom_snap.rs | 106 ++++++++++++++++++++++++++++++++ crates/ui-egui/tests/viewer.rs | 36 +++++++++++ parity/acrobat-features.toml | 18 +++--- 8 files changed, 197 insertions(+), 10 deletions(-) create mode 100644 crates/ui-egui/src/zoom_snap.rs diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 8c671f8a..16568272 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -165,6 +165,8 @@ pub const COMMANDS: &[CommandSpec] = &[ ct("edit.redo", "Redo", EDIT, Some(Shortcut::cmd_shift("Z")), Redo, "redo-2"), c("edit.find", "Find…", EDIT, Some(Shortcut::cmd("F")), Document, "search"), c("view.palette", "Find tools and commands…", VIEW, Some(Shortcut::cmd("K")), Nothing, "search"), + c("view.marquee_zoom", "Marquee zoom", VIEW, None, Document, "zoom-in"), + c("edit.snapshot", "Take a snapshot", EDIT, None, Document, "camera"), c("view.full_screen", "Full screen mode", VIEW, Some(Shortcut::cmd("L")), Document, "maximize"), c("view.read_mode", "Read mode", VIEW, Some(Shortcut { command: true, shift: false, mac_ctrl: true, key: "H" }), Document, "book-open"), c("view.theme", "Switch light / dark theme", VIEW, None, Nothing, "moon"), diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8248415d..8b3ec18f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -154,6 +154,9 @@ pub struct DocView { pub sign: crate::sign_ui::SignView, /// Organize: the pages being dragged to a new place. pub org_drag: Option>, + /// Marquee Zoom / Snapshot: the rectangle being dragged (page, start), and a finished one. + pub marquee: Option<(usize, Pos2)>, + pub marquee_done: Option, /// Fill & Sign text being typed. pub fill_text: Option, /// A non-edit action requested by the organize toolbar, handled by the app. @@ -242,6 +245,8 @@ impl DocView { crop_drag: None, sign: Default::default(), org_drag: None, + marquee: None, + marquee_done: None, fill_text: None, } } @@ -517,6 +522,16 @@ impl DocView { if self.rotation % 180 == 90 { (p.height, p.width) } else { (p.width, p.height) } } + /// Marquee Zoom: zoom so `r` (on screen) fills the window, centred. + pub fn zoom_to_rect(&mut self, r: Rect) { + let Some((page, pr)) = self.screen_rects.iter().find(|(_, pr)| pr.contains(r.center())).copied() else { return }; + let k = (self.viewport_screen.width() / r.width().max(1.0)).min(self.viewport_screen.height() / r.height().max(1.0)); + let f = (r.center() - pr.min) / pr.size(); + self.zoom = (self.zoom * k).clamp(0.08, 64.0); + self.fit = Fit::None; + self.zoom_anchor = Some((page, f.x.clamp(0.0, 1.0), f.y.clamp(0.0, 1.0), self.viewport_screen.center() - self.viewport_screen.min)); + } + /// Zoom keeping the centre of the view still. pub fn set_zoom(&mut self, zoom: f32) { let centre = self.viewport_screen.center(); @@ -914,7 +929,9 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { | QuickTool::AddText | QuickTool::Stamp(_) | QuickTool::Link - | QuickTool::SignArea { .. } => false, + | QuickTool::SignArea { .. } + | QuickTool::MarqueeZoom + | QuickTool::Snapshot => false, }; let prefs = &app.comment_prefs; let allowed = doc.allows_annotation(); @@ -1104,6 +1121,9 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { if matches!(tool, QuickTool::SignArea { .. }) { crate::sign_ui::page_input(ui, &resp, &xf, i, info, view); } + if matches!(tool, QuickTool::MarqueeZoom | QuickTool::Snapshot) { + crate::zoom_snap::page_input(ui, &resp, &xf, i, view); + } if tool == QuickTool::Crop && crate::crop::page_input(ui, &resp, &xf, i, info, view, can_crop) { view.current = i; open_boxes = true; @@ -1403,6 +1423,9 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { if tool == QuickTool::Crop && cropped { tool = QuickTool::Select; } + if let Some(done) = app.views[index].marquee_done.take() { + app.finish_marquee(index, done); + } // A signature rectangle was drawn, or an empty signature field clicked. let drawn = app.views[index].sign.drawn.take(); if let (Some((page, rect)), QuickTool::SignArea { certify }) = (drawn, tool) { diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index e348ddd8..5d4abd1c 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -308,6 +308,11 @@ impl PrintCraftApp { } "sign.panel" => self.right = Some(RightPanel::Signatures), "optimize.advanced" => self.dialog = Some(Dialog::Optimize), + "view.marquee_zoom" => self.quick_tool = crate::QuickTool::MarqueeZoom, + "edit.snapshot" => { + self.quick_tool = crate::QuickTool::Snapshot; + self.notify("Drag a rectangle around the area to copy"); + } "page.copy" => self.copy_pages(false), "page.cut" => self.copy_pages(true), "page.paste" => self.paste_pages(), diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index fe8edc39..5b6456cb 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -543,6 +543,8 @@ impl Host for crate::PrintCraftApp { crate::QuickTool::Link => "link".to_string(), crate::QuickTool::SignArea { certify: false } => "sign".to_string(), crate::QuickTool::SignArea { certify: true } => "certify".to_string(), + crate::QuickTool::MarqueeZoom => "marquee-zoom".to_string(), + crate::QuickTool::Snapshot => "snapshot".to_string(), crate::QuickTool::Stamp(k) => format!("stamp-{}", k.name().trim_start_matches("PC").to_ascii_lowercase()), crate::QuickTool::Fill(f) => format!("fill-{}", f.command().trim_start_matches("sign.fill.")), crate::QuickTool::Field(f) => format!("field-{}", f.command().trim_start_matches("form.add.")), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 2a8e1792..71ba44a1 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -18,6 +18,7 @@ mod export_ui; mod marks_ui; mod optimize_ui; mod sign_ui; +mod zoom_snap; /// Header & footer / watermark / background dialog types (tests and automation). pub mod marks { pub use crate::marks_ui::{MarksDraft, PageRange, Subset}; @@ -111,6 +112,10 @@ pub enum QuickTool { SignArea { certify: bool, }, + /// View ▸ Zoom ▸ Marquee Zoom. + MarqueeZoom, + /// Edit ▸ Take a Snapshot. + Snapshot, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -255,6 +260,10 @@ pub struct PrintCraftApp { pub optimize_draft: OptimizeDraft, /// Pages copied or cut in Organize Pages, ready to paste (into any document). pub page_clipboard: Option, + /// The last snapshot (width, height, RGBA); `system_clipboard` also puts it on the + /// system clipboard (tests turn that off). + pub last_snapshot: Option<(u32, u32, Vec)>, + pub system_clipboard: bool, /// Where autosaves go (`None`: autosave off, e.g. on the web and in tests). pub recovery: Option, /// Entries left by a previous session, offered in the Recovery dialog. @@ -360,6 +369,8 @@ impl PrintCraftApp { sig_expanded: Vec::new(), optimize_draft: OptimizeDraft::default(), page_clipboard: None, + last_snapshot: None, + system_clipboard: true, recovery: None, recoverable: Vec::new(), recovery_keys: Default::default(), @@ -826,6 +837,8 @@ impl PrintCraftApp { "add-text" => QuickTool::AddText, "link" => QuickTool::Link, "sign" => QuickTool::SignArea { certify: false }, + "marquee-zoom" => QuickTool::MarqueeZoom, + "snapshot" => QuickTool::Snapshot, "certify" => QuickTool::SignArea { certify: true }, stamp if stamp.starts_with("stamp-") => QuickTool::Stamp( printcraft_engine::StampKind::ALL diff --git a/crates/ui-egui/src/zoom_snap.rs b/crates/ui-egui/src/zoom_snap.rs new file mode 100644 index 00000000..08097338 --- /dev/null +++ b/crates/ui-egui/src/zoom_snap.rs @@ -0,0 +1,106 @@ +//! View ▸ Zoom ▸ Marquee Zoom (drag a rectangle to fill the window with it; click to zoom in) +//! and Edit ▸ Take a Snapshot (drag a rectangle to copy that area as an image). + +use egui::{Color32, CornerRadius, Pos2, Rect, Stroke}; +use printcraft_render::{RenderConfig, RenderRequest, RequestKind, Tile}; + +use crate::canvas::{DocView, PageXform}; +use crate::{PrintCraftApp, QuickTool}; + +/// A finished gesture: (page, the rectangle on screen, the same in page view points +/// [x0, y0, x1, y1]); a plain click has an empty rectangle. +pub type Marquee = (usize, Rect, [f32; 4]); + +/// Drag a rectangle on page `page` (Marquee Zoom, Snapshot). +pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, page: usize, view: &mut DocView) { + let pointer = ui.input(|i| i.pointer.hover_pos()); + let origin = ui.input(|i| i.pointer.press_origin()); + if pointer.is_some_and(|p| xf.rect.contains(p)) { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + } + if resp.drag_started() + && let Some(o) = origin.filter(|o| xf.rect.contains(*o)) + { + view.marquee = Some((page, o)); + } + if let Some((p, start)) = view.marquee + && p == page + && let Some(end) = pointer + { + let end = Pos2::new(end.x.clamp(xf.rect.left(), xf.rect.right()), end.y.clamp(xf.rect.top(), xf.rect.bottom())); + let r = Rect::from_two_pos(start, end); + ui.painter().rect_stroke(r, CornerRadius::ZERO, Stroke::new(1.0, Color32::from_rgb(0x14, 0x73, 0xE6)), egui::StrokeKind::Middle); + if resp.drag_stopped() { + view.marquee = None; + let (a, b) = (xf.screen_to_view(r.left_top()), xf.screen_to_view(r.right_bottom())); + view.marquee_done = Some((page, r, [a.0.min(b.0), a.1.min(b.1), a.0.max(b.0), a.1.max(b.1)])); + } + } + if resp.clicked() + && let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) + { + let v = xf.screen_to_view(p); + view.marquee_done = Some((page, Rect::from_min_size(p, egui::Vec2::ZERO), [v.0, v.1, v.0, v.1])); + } +} + +impl PrintCraftApp { + /// Finish a marquee gesture for the current tool. + pub(crate) fn finish_marquee(&mut self, index: usize, done: Marquee) { + let (page, rect, view_rect) = done; + match self.quick_tool { + QuickTool::MarqueeZoom => { + let view = &mut self.views[index]; + if rect.width() < 6.0 || rect.height() < 6.0 { + // A click zooms in one step around the point. + let z = (view.zoom * 1.5).min(64.0); + view.zoom_at(z, rect.center()); + } else { + view.zoom_to_rect(rect); + } + } + QuickTool::Snapshot => { + if view_rect[2] - view_rect[0] < 2.0 || view_rect[3] - view_rect[1] < 2.0 { + return; + } + match self.snapshot(index, page, view_rect) { + Ok((w, h)) => self.notify(format!("The selected area has been copied ({w} × {h} pixels)")), + Err(e) => self.notify(format!("Couldn't take the snapshot: {e}")), + } + } + _ => {} + } + } + + /// Render `view_rect` (page view points) of `page` at the current zoom and copy it to the + /// clipboard (and keep it in `last_snapshot`). + pub fn snapshot(&mut self, index: usize, page: usize, view_rect: [f32; 4]) -> Result<(u32, u32), String> { + let view = &self.views[index]; + let doc = self.session.get(view.id).ok_or("no document")?; + let ppp = self.ctx.as_ref().map_or(2.0, |c| c.pixels_per_point()); + let scale = (view.zoom * ppp).clamp(0.5, 8.0); + let tile = Tile { + x: (view_rect[0] * scale).floor().max(0.0) as u32, + y: (view_rect[1] * scale).floor().max(0.0) as u32, + w: ((view_rect[2] - view_rect[0]) * scale).ceil().max(1.0) as u32, + h: ((view_rect[3] - view_rect[1]) * scale).ceil().max(1.0) as u32, + }; + if (tile.w as u64) * (tile.h as u64) > 64_000_000 { + return Err("the area is too large at this zoom".into()); + } + let config = RenderConfig { password: doc.password.as_deref().map(std::sync::Arc::from), ..RenderConfig::default() }; + let mut r = printcraft_render::PageRenderer::new(doc.bytes.clone(), config); + let out = r.render(RenderRequest { page, kind: RequestKind::Pixels, tile: Some(tile), scale, tag: 0 }); + if let Some(e) = out.error { + return Err(e); + } + let (w, h) = (out.width, out.height); + #[cfg(not(target_arch = "wasm32"))] + if self.system_clipboard { + let img = arboard::ImageData { width: w as usize, height: h as usize, bytes: std::borrow::Cow::Borrowed(&out.rgba) }; + arboard::Clipboard::new().and_then(|mut c| c.set_image(img)).map_err(|e| e.to_string())?; + } + self.last_snapshot = Some((w, h, out.rgba)); + Ok((w, h)) + } +} diff --git a/crates/ui-egui/tests/viewer.rs b/crates/ui-egui/tests/viewer.rs index 5d891c15..ca52cbe8 100644 --- a/crates/ui-egui/tests/viewer.rs +++ b/crates/ui-egui/tests/viewer.rs @@ -225,3 +225,39 @@ fn initial_view_is_edited_and_honoured_on_open() { let view = &app.views[0]; assert_eq!((view.current, view.cover, app.right), (1, true, Some(printcraft_ui_egui::RightPanel::Bookmarks))); } + +fn drag(h: &mut Harness<'static, PrintCraftApp>, a: egui::Pos2, b: egui::Pos2) { + h.hover_at(a); + h.run_steps(1); + h.drag_at(a); + h.run_steps(1); + for k in 1..=4 { + h.hover_at(a + (b - a) * (k as f32 / 4.0)); + h.run_steps(1); + } + h.drop_at(b); + h.run_steps(3); +} + +#[test] +fn marquee_zoom_and_snapshot() { + let mut h = harness(); + let i = h.state().active.unwrap(); + // Text "Page 2 pages PAGE" sits at y 150 (of 300) from x 20. + let r = h.state().views[i].page_screen_rect(0).expect("on screen"); + let at = |x: f32, y: f32| egui::pos2(r.left() + x / 200.0 * r.width(), r.top() + (300.0 - y) / 300.0 * r.height()); + // Snapshot of the text line. + h.state_mut().system_clipboard = false; + assert!(h.state_mut().execute("edit.snapshot")); + drag(&mut h, at(15.0, 175.0), at(180.0, 140.0)); + let (w, hgt, px) = h.state().last_snapshot.clone().expect("a snapshot"); + assert!(w > 50 && hgt > 10, "{w} × {hgt}"); + assert!(px.chunks_exact(4).any(|p| p[0] < 100), "the text is in it"); + // Marquee zoom on a small area zooms in, centred on it. + let before = h.state().views[i].zoom; + h.state_mut().set_option("quick", "marquee-zoom").unwrap(); + drag(&mut h, at(20.0, 160.0), at(60.0, 140.0)); + h.run_steps(4); + let after = h.state().views[i].zoom; + assert!(after > before * 2.0, "{before} → {after}"); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index a148170f..5e5d98b0 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -983,11 +983,11 @@ title = "Marquee zoom tool" area = "B" tier = "P1" milestone = "M3" -status = "planned" -commands = [] +status = "shipped" +commands = ["view.marquee_zoom"] tools = [] -evidence = [] -notes = "" +evidence = ["crates/ui-egui/tests/viewer.rs::marquee_zoom_and_snapshot"] +notes = "drag to fill the window with an area; click to zoom in" [[feature]] id = "view.dynamic-zoom" @@ -1511,11 +1511,11 @@ title = "Snapshot tool (copy a region as an image)" area = "B" tier = "P1" milestone = "M3" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["edit.snapshot"] +tools = ["page_render"] +evidence = ["crates/ui-egui/tests/viewer.rs::marquee_zoom_and_snapshot"] +notes = "copies the area at the current zoom to the clipboard as an image" [[feature]] id = "view.rulers"