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 <noreply@anthropic.com>
This commit is contained in:
parent
7515170103
commit
e61f75d69a
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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<Vec<usize>>,
|
||||
/// Marquee Zoom / Snapshot: the rectangle being dragged (page, start), and a finished one.
|
||||
pub marquee: Option<(usize, Pos2)>,
|
||||
pub marquee_done: Option<crate::zoom_snap::Marquee>,
|
||||
/// Fill & Sign text being typed.
|
||||
pub fill_text: Option<crate::fill_sign::TypeBox>,
|
||||
/// 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) {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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.")),
|
||||
|
|
|
|||
|
|
@ -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<PageClip>,
|
||||
/// 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<u8>)>,
|
||||
pub system_clipboard: bool,
|
||||
/// Where autosaves go (`None`: autosave off, e.g. on the web and in tests).
|
||||
pub recovery: Option<RecoveryStore>,
|
||||
/// 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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue