diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 861833d25a..30d2799f68 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -863,6 +863,9 @@ impl DocView { self.sign.drag = None; self.redact_drag = None; self.crop_drag = None; + if let Some(q) = &mut self.camera { + q.grab = None; + } self.block_drag = None; self.org_drag = None; self.panel_drag = None; diff --git a/crates/ui-egui/src/canvas/crop.rs b/crates/ui-egui/src/canvas/crop.rs index bdd1e9e135..7858b00dd6 100644 --- a/crates/ui-egui/src/canvas/crop.rs +++ b/crates/ui-egui/src/canvas/crop.rs @@ -27,10 +27,12 @@ pub(crate) struct CropPageInput<'a> { pub(crate) info: &'a DocInfo, pub(crate) view: &'a mut DocView, pub(crate) allowed: bool, + /// Nobody holds the press, and it began on this page: the tool's. + pub(crate) free: bool, } pub(crate) fn page_input(ui: &egui::Ui, input: CropPageInput<'_>) -> bool { - let CropPageInput { resp, xf, ptr, page, info, view, allowed } = input; + let CropPageInput { resp, xf, ptr, page, info, view, allowed, free } = input; let (pointer, origin) = (ptr.pos, ptr.origin); if pointer.is_some_and(|p| xf.rect.contains(p)) { view.cursor.claim(Layer::ToolArea, if allowed { egui::CursorIcon::Crosshair } else { egui::CursorIcon::NotAllowed }); @@ -38,9 +40,9 @@ pub(crate) fn page_input(ui: &egui::Ui, input: CropPageInput<'_>) -> bool { if !allowed { return false; } - // The tool's own press, of the primary button: nothing drawn over the page here holds it. + // The tool's own press, of the primary button: one nobody holds. if resp.drag_started_by(egui::PointerButton::Primary) - && view.router.owner(page).is_none() + && free && let Some(o) = origin.filter(|o| xf.rect.contains(*o)) { view.crop_drag = Some((page, o)); diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 05a302926d..40552fc1ec 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -39,30 +39,20 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut let PageEnv { added, allowed, - author, - by_line, - can_crop, can_fill, can_modify, comments_hidden, - custom_stamp, - date_text, doc, doc_links, editing_content, form, info, - initials, prefs, preparing, redact_mark, - signature, table_preview, text_style, tool, - units, - fill_text_size, - fill_text_color, .. } = frame.env; let PageSlot { i, xf, .. } = *slot; @@ -103,106 +93,11 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut // What this page has to do with the press: its owner's, the active tool's, or another page's. let press = view.router.press_for(i); let context = pointer.filter(|p| resp.secondary_clicked() && xf.rect.contains(*p)).and_then(|p| right_click(&hits, view, p)); - if let QuickTool::Measure(measure_tool) = tool { - crate::measure_ui::page_input(ui, resp, doc, view, &pcx, measure_tool, units); - } - - // Form fields take clicks first with the Select tool (as Acrobat fills fields in - // every viewing mode); then comments; then text selection. - if let QuickTool::Stamp(kind) = tool - && allowed - && let Some(p) = ptr.within(xf.rect) - { - view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); - // The tool's own click: nothing drawn over the page here holds the press. - if resp.clicked() && press == PagePress::Free { - // Centred on the click, upright as the page is shown. - let (vx, vy) = xf.screen_to_view(p); - let (w, h) = kind.size(); - let corners = [(vx as f64 - w / 2.0, vy as f64 - h / 2.0), (vx as f64 + w / 2.0, vy as f64 + h / 2.0)]; - let u: Vec<[f32; 2]> = corners.iter().map(|(x, y)| info.pages[i].view_to_user(*x as f32, *y as f32)).collect(); - let rect = [u[0][0].min(u[1][0]) as f64, u[0][1].min(u[1][1]) as f64, u[0][0].max(u[1][0]) as f64, u[0][1].max(u[1][1]) as f64]; - let by = (kind.group() == pdfcraft_engine::StampGroup::Dynamic).then(|| by_line.clone()); - let shape = pdfcraft_engine::Shape::Stamp { rect, stamp: kind, by }; - view.pending_edit = Some(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { - page: i, - style: pdfcraft_engine::Style::default_for(&shape), - shape, - contents: String::new(), - author: author.clone(), - })); - frame.out.stamp_placed = true; - } - } - if let Some(cs) = custom_stamp.as_ref() - && allowed - && let Some(p) = ptr.within(xf.rect) - { - view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); - // The tool's own click: nothing drawn over the page here holds the press. - if resp.clicked() && press == PagePress::Free { - // Centred on the click at its natural size (the engine sizes it). - let (vx, vy) = xf.screen_to_view(p); - let u = info.pages[i].view_to_user(vx, vy); - let (x, y) = (u[0] as f64, u[1] as f64); - view.pending_edit = Some(pdfcraft_engine::Edit::AddCustomStamp { - page: i, - rect: [x, y, x, y], - name: cs.name.clone(), - file: pdfcraft_engine::MarkFile { name: cs.file.clone(), bytes: cs.data.clone(), page: cs.page }, - author: author.clone(), - }); - frame.out.stamp_placed = true; - } - } // A mark already placed takes its own press (comments, below), and with no button down the - // tool's preview hides over one, where a press would pick it up. + // Fill & Sign tool's preview hides over one, where a press would pick it up. let over_mark = matches!(tool, QuickTool::Fill(_)) && (matches!(press, PagePress::Mine(_)) || !ptr.any_down && pointer.and_then(|p| top(hits.at(view, p), tool)).is_some()); - if let QuickTool::Fill(ft) = tool - && allowed - && !over_mark - { - match crate::fill_sign::page_input( - cx, - crate::fill_sign::PageInput { - tool: ft, - text_size: fill_text_size, - text_color: fill_text_color, - signature: signature.as_ref(), - initials: initials.as_ref(), - preview: frame.signature_preview, - author, - date_text, - }, - view, - ) { - Some(crate::fill_sign::FillAction::Edit(e)) => view.pending_edit = Some(*e), - Some(crate::fill_sign::FillAction::Signature(e)) => { - view.pending_edit = Some(*e); - view.fill_signature_page = Some(i); - } - Some(crate::fill_sign::FillAction::CreateSignature) => frame.out.open_signature = true, - Some(crate::fill_sign::FillAction::CreateInitials) => frame.out.open_initials = true, - Some(crate::fill_sign::FillAction::Refused(why)) => frame.out.refused = Some(why), - None => {} - } - } - if matches!(tool, QuickTool::SignArea { .. }) { - crate::sign_ui::page_input(ui, resp, &xf, ptr, i, info, view); - } - if matches!(tool, QuickTool::MarqueeZoom | QuickTool::Snapshot) { - crate::zoom_snap::page_input(ui, resp, &xf, ptr, i, view); - } - if tool == QuickTool::EnhanceCamera { - crate::camera_ui::page_input(resp, &xf, ptr, i, view, can_modify); - } - if tool == QuickTool::Crop - && crate::crop::page_input(ui, crate::crop::CropPageInput { resp, xf: &xf, ptr, page: i, info, view, allowed: can_crop }) - { - view.current = i; - frame.out.open_boxes = true; - } + tool_input(ui, view, frame, slot, &pcx, press, over_mark); let on_field = if preparing { let o = crate::prepare::page_input(cx, form, field_tool, prepare_allowed, view); frame.out.field_props |= o.properties; @@ -220,7 +115,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut let (vx, vy) = xf.screen_to_view(p); text.as_ref().is_some_and(|t| t.glyphs.iter().any(|g| vx >= g.rect[0] && vx <= g.rect[2] && vy >= g.rect[1] && vy <= g.rect[3])) }; - crate::redact_ui::page_input(cx, over_text, view) + crate::redact_ui::page_input(cx, over_text, press == PagePress::Free, view) }; let on_objects = tool == QuickTool::EditText && can_modify && crate::object_ui::page_input(cx, view); // Add text acts on the page's own paragraphs over them, and on the one its press was on until @@ -281,7 +176,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut } } let (link_owner, link_context) = (link_of(press.owner()), link_of(context)); - let on_link = tool == QuickTool::Link && can_modify && crate::link_ui::page_input(cx, link_owner, link_context, view); + let on_link = tool == QuickTool::Link && can_modify && crate::link_ui::page_input(cx, press == PagePress::Free, link_owner, link_context, view); if tool == QuickTool::Select && can_modify && let Some(index) = link_context @@ -315,6 +210,100 @@ fn link_of(owner: Option) -> Option { } } +/// The active tool's own input on page `i`: the one gate for presses nobody holds. Measure, the +/// stamps, Fill & Sign, Sign area, zoom and snapshot, the camera's quadrilateral and crop act only +/// on a press that began on this page and that no hit won (`press` is Free), and keep their cursor +/// and gestures. `over_mark`: a Fill & Sign mark takes the press, or would take one under the +/// pointer. +fn tool_input( + ui: &egui::Ui, + view: &mut DocView, + frame: &mut PageFrame<'_>, + slot: &PageSlot, + pcx: &comments::PageCx<'_>, + press: PagePress, + over_mark: bool, +) { + let PageEnv { + allowed, + author, + by_line, + can_crop, + can_modify, + custom_stamp, + date_text, + doc, + info, + initials, + signature, + tool, + units, + fill_text_size, + fill_text_color, + .. + } = frame.env; + let PageSlot { i, xf, .. } = *slot; + let PageFrame { resp, ptr, .. } = *frame; + let cx = PageToolContext { ui, resp, xf: &xf, ptr, page: i, doc, info }; + let free = press == PagePress::Free; + if let QuickTool::Measure(measure_tool) = tool { + crate::measure_ui::page_input(ui, crate::measure_ui::Clicks::of(resp, free), doc, view, pcx, measure_tool, units); + } + if let QuickTool::Stamp(kind) = tool + && allowed + && crate::stamps_ui::place(cx, kind, by_line, author, free, view) + { + frame.out.stamp_placed = true; + } + if let Some(cs) = custom_stamp.as_ref() + && allowed + && crate::stamps_ui::place_custom(cx, cs, author, free, view) + { + frame.out.stamp_placed = true; + } + if let QuickTool::Fill(ft) = tool + && allowed + && !over_mark + { + let input = crate::fill_sign::PageInput { + tool: ft, + text_size: fill_text_size, + text_color: fill_text_color, + signature: signature.as_ref(), + initials: initials.as_ref(), + preview: frame.signature_preview, + author, + date_text, + }; + match crate::fill_sign::page_input(cx, input, free, view) { + Some(crate::fill_sign::FillAction::Edit(e)) => view.pending_edit = Some(*e), + Some(crate::fill_sign::FillAction::Signature(e)) => { + view.pending_edit = Some(*e); + view.fill_signature_page = Some(i); + } + Some(crate::fill_sign::FillAction::CreateSignature) => frame.out.open_signature = true, + Some(crate::fill_sign::FillAction::CreateInitials) => frame.out.open_initials = true, + Some(crate::fill_sign::FillAction::Refused(why)) => frame.out.refused = Some(why), + None => {} + } + } + if matches!(tool, QuickTool::SignArea { .. }) { + crate::sign_ui::page_input(cx, free, view); + } + if matches!(tool, QuickTool::MarqueeZoom | QuickTool::Snapshot) { + crate::zoom_snap::page_input(ui, resp, &xf, ptr, i, free, view); + } + if tool == QuickTool::EnhanceCamera { + crate::camera_ui::page_input(resp, &xf, ptr, i, view, can_modify, press); + } + if tool == QuickTool::Crop + && crate::crop::page_input(ui, crate::crop::CropPageInput { resp, xf: &xf, ptr, page: i, info, view, allowed: can_crop, free }) + { + view.current = i; + frame.out.open_boxes = true; + } +} + /// A right-click at `p`: it goes to what lies on top there ([`PageHits::context_at`]), which the /// page's menu then acts on. A comment there is selected, and none elsewhere; a Prepare field is /// selected; links take theirs from what this returns. @@ -460,9 +449,10 @@ impl PageHits<'_> { // together. let picks_up = matches!(tool, QuickTool::Select | QuickTool::AddText | QuickTool::EditText); let added = (editing_content && can_modify && picks_up).then(|| crate::content_ui::hit(xf, info, page, added, view, p)).flatten(); + let corner = (tool == QuickTool::EnhanceCamera && can_modify).then(|| crate::camera_ui::hit(xf, page, view, p)).flatten(); let link = crate::link_ui::hit(xf, info, page, doc_links, tool, p); let comment = if context { comments::context_hit(pcx, p) } else { comments::hit(pcx, view, p, link.is_some()) }; - [link, comment, pictures, paragraphs, added, objects, grips, fields].into_iter().flatten().collect() + [link, comment, pictures, paragraphs, added, objects, grips, corner, fields].into_iter().flatten().collect() } } diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index 865f5051b0..c2d214d0d7 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -150,7 +150,7 @@ fn edges_are_primary_finite_in_order_and_capped() { } /// The page modules: the per-page chain in `canvas::document_area` and what it calls. -const PAGE_MODULES: [&str; 36] = [ +const PAGE_MODULES: [&str; 37] = [ "canvas.rs", "canvas/page_input.rs", "canvas/area.rs", @@ -187,6 +187,7 @@ const PAGE_MODULES: [&str; 36] = [ "canvas/image_pick.rs", "canvas/signature_drag.rs", "tools/calc_order_ui.rs", + "tools/stamps_ui.rs", ]; /// egui's raw pointer state: where the pointer is, and which buttons are down or went down. @@ -434,11 +435,12 @@ const PRESS_IGNORERS: [(&str, &str, &str); 1] = /// The module hit tests, called by path: a press handler calls none of them (HIT_TESTERS says who /// may). -const PAGE_HIT_CALLS: [&str; 10] = [ +const PAGE_HIT_CALLS: [&str; 11] = [ "link_ui::hit(", "prepare::hit(", "forms_ui::hit(", "comments::hit(", + "camera_ui::hit(", "edit_text_ui::hit(", "edit_text_ui::picture_hit(", "edit_text_ui::picture_handle_hit(", diff --git a/crates/ui-egui/src/canvas/page_router.rs b/crates/ui-egui/src/canvas/page_router.rs index f1aa199c67..23eae443e9 100644 --- a/crates/ui-egui/src/canvas/page_router.rs +++ b/crates/ui-egui/src/canvas/page_router.rs @@ -36,6 +36,8 @@ pub(crate) enum Owner { /// Added text or a picture placed on the page, by its index among the page's, and a corner of /// the selected one (left, top) or the item itself. Added { index: usize, corner: Option<(bool, bool)> }, + /// Enhance ▸ Camera image: a corner of the quadrilateral (0 left top, then clockwise). + CameraCorner { corner: usize }, } /// The part of a picture a hit is on. diff --git a/crates/ui-egui/src/canvas/zoom_snap.rs b/crates/ui-egui/src/canvas/zoom_snap.rs index 159adf2b7e..a422907b89 100644 --- a/crates/ui-egui/src/canvas/zoom_snap.rs +++ b/crates/ui-egui/src/canvas/zoom_snap.rs @@ -13,15 +13,15 @@ use crate::{PdfCraftApp, QuickTool}; /// [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, ptr: &PagePointer, page: usize, view: &mut DocView) { +/// Drag a rectangle on page `page` (Marquee Zoom, Snapshot), with a press nobody holds (`free`). +pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, ptr: &PagePointer, page: usize, free: bool, view: &mut DocView) { let (pointer, origin) = (ptr.pos, ptr.origin); if pointer.is_some_and(|p| xf.rect.contains(p)) { view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); } - // The tool's own press, of the primary button: nothing drawn over the page here holds it. + // The tool's own press, of the primary button: one nobody holds. if resp.drag_started_by(egui::PointerButton::Primary) - && view.router.owner(page).is_none() + && free && let Some(o) = origin.filter(|o| xf.rect.contains(*o)) { view.marquee = Some((page, o)); @@ -40,7 +40,8 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, p 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() + if free + && resp.clicked() && let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) { let v = xf.screen_to_view(p); diff --git a/crates/ui-egui/src/tools/camera_ui.rs b/crates/ui-egui/src/tools/camera_ui.rs index abaad1ed5c..a7e7d6c934 100644 --- a/crates/ui-egui/src/tools/camera_ui.rs +++ b/crates/ui-egui/src/tools/camera_ui.rs @@ -7,6 +7,7 @@ use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Shape, Stroke, vec2}; use crate::canvas::{DocView, PageXform, ViewAction}; use crate::page_cursor::Layer; use crate::page_pointer::PagePointer; +use crate::page_router::{Hit, Owner, PagePress}; use crate::theme::Tokens; pub use pdfcraft_engine::camera_clean::Look; @@ -59,8 +60,28 @@ fn screen(xf: &PageXform, c: [f32; 2]) -> Pos2 { xf.norm_to_screen(c[0] / xf.pw.max(1e-3), c[1] / xf.ph.max(1e-3)) } -/// Corner dragging on one page. A press on a page without the quadrilateral moves it there. -pub(crate) fn page_input(resp: &egui::Response, xf: &PageXform, ptr: &PagePointer, page: usize, view: &mut DocView, allowed: bool) { +/// The corner of the quadrilateral under `p` on `page`, the nearest within reach: the one hit test +/// of the camera tool, for the cursor and the press. `None` once the page is enhanced, or on a page +/// without the quadrilateral. +pub(crate) fn hit(xf: &PageXform, page: usize, view: &DocView, p: Pos2) -> Option { + if view.camera_done.is_some() { + return None; + } + let q = view.camera.filter(|q| q.page == page)?; + let corner = q + .corners + .iter() + .enumerate() + .map(|(i, &c)| (i, screen(xf, c).distance(p))) + .filter(|(_, d)| *d <= GRAB) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(i, _)| i)?; + Some(Hit::new(Owner::CameraCorner { corner }, Layer::Handle, egui::CursorIcon::Grab)) +} + +/// Corner dragging on one page: a corner's own press ([`hit`]) drags it, and a press nobody holds +/// on a page without the quadrilateral moves it there. +pub(crate) fn page_input(resp: &egui::Response, xf: &PageXform, ptr: &PagePointer, page: usize, view: &mut DocView, allowed: bool, press: PagePress) { let (pointer, origin) = (ptr.pos, ptr.origin); if !allowed { if pointer.is_some_and(|p| xf.rect.contains(p)) { @@ -76,23 +97,21 @@ pub(crate) fn page_input(resp: &egui::Response, xf: &PageXform, ptr: &PagePointe if view.camera.is_none() && page == view.current { view.camera = Some(CameraQuad::inset(page, xf.pw, xf.ph)); } - if resp.drag_started() || resp.clicked() { - let pressed = origin.filter(|o| xf.rect.contains(*o)); - if pressed.is_some() && view.camera.is_none_or(|q| q.page != page) { - view.camera = Some(CameraQuad::inset(page, xf.pw, xf.ph)); - } - if resp.drag_started() - && let (Some(o), Some(q)) = (pressed, view.camera.as_mut()) - { - q.grab = (0..4) - .map(|i| (i, screen(xf, q.corners[i]).distance(o))) - .filter(|(_, d)| *d <= GRAB) - .min_by(|a, b| a.1.total_cmp(&b.1)) - .map(|(i, _)| i); - } + if (resp.drag_started() || resp.clicked()) + && press == PagePress::Free + && origin.is_some_and(|o| xf.rect.contains(o)) + && view.camera.is_none_or(|q| q.page != page) + { + view.camera = Some(CameraQuad::inset(page, xf.pw, xf.ph)); + } + if resp.drag_started() + && let PagePress::Mine(Owner::CameraCorner { corner }) = press + && let Some(q) = view.camera.as_mut().filter(|q| q.page == page) + { + q.grab = Some(corner); } let Some(q) = view.camera.as_mut().filter(|q| q.page == page) else { return }; - let near = pointer.is_some_and(|p| q.corners.iter().any(|&c| screen(xf, c).distance(p) <= GRAB)); + // Over a corner, its hit claims the grab cursor. if let Some(i) = q.grab { view.cursor.claim(Layer::Drag, egui::CursorIcon::Grabbing); if let Some(p) = pointer { @@ -101,11 +120,9 @@ pub(crate) fn page_input(resp: &egui::Response, xf: &PageXform, ptr: &PagePointe *c = [x.clamp(0.0, xf.pw), y.clamp(0.0, xf.ph)]; } } - if resp.drag_stopped() { + if resp.drag_stopped() || !ptr.primary_down { q.grab = None; } - } else if near { - view.cursor.claim(Layer::Handle, egui::CursorIcon::Grab); } } diff --git a/crates/ui-egui/src/tools/fill_sign.rs b/crates/ui-egui/src/tools/fill_sign.rs index 045bb64974..416be8867e 100644 --- a/crates/ui-egui/src/tools/fill_sign.rs +++ b/crates/ui-egui/src/tools/fill_sign.rs @@ -455,7 +455,7 @@ pub(crate) struct PageInput<'a> { } /// Preview and clicks with a Fill & Sign tool on one page. -pub(crate) fn page_input(cx: PageToolContext<'_>, input: PageInput<'_>, view: &mut DocView) -> Option { +pub(crate) fn page_input(cx: PageToolContext<'_>, input: PageInput<'_>, free: bool, view: &mut DocView) -> Option { let PageToolContext { ui, resp, xf, ptr, page, info, .. } = cx; let PageInput { tool, text_size, text_color, signature, initials, preview, author, date_text } = input; let pointer = ptr.within(xf.rect)?; @@ -475,7 +475,8 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, input: PageInput<'_>, view: &m } else { view.cursor.claim(Layer::ToolArea, if tool == FillTool::Text { egui::CursorIcon::Text } else { egui::CursorIcon::Crosshair }); } - if !resp.clicked() { + // The tool's own click: one nobody holds. + if !(free && resp.clicked()) { return None; } // Typed text starts a little above the click, as displayed. diff --git a/crates/ui-egui/src/tools/link_ui.rs b/crates/ui-egui/src/tools/link_ui.rs index 699383cbfe..d380d1b71b 100644 --- a/crates/ui-egui/src/tools/link_ui.rs +++ b/crates/ui-egui/src/tools/link_ui.rs @@ -87,10 +87,10 @@ fn target_label(info: &DocInfo, target: &LinkTarget) -> String { } } -/// Link tool input on one page: `owner` is the link that holds the press (by its `/Annots` -/// index), and `context` the one a right-click is on. Returns `true` when the gesture belongs to -/// the tool. -pub(crate) fn page_input(cx: PageToolContext<'_>, owner: Option, context: Option, view: &mut DocView) -> bool { +/// Link tool input on one page: `free` when nobody holds the press and it began on this page, +/// `owner` the link that holds it (by its `/Annots` index), and `context` the link a right-click is +/// on. A drag from either draws a new link. Returns `true` when the gesture belongs to the tool. +pub(crate) fn page_input(cx: PageToolContext<'_>, free: bool, owner: Option, context: Option, view: &mut DocView) -> bool { let PageToolContext { ui, resp, xf, ptr, page, info, .. } = cx; let pointer = ptr.pos; let lv = &mut view.links; @@ -128,7 +128,7 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, owner: Option, context: } return true; } - if resp.drag_started() { + if resp.drag_started() && (free || owner.is_some()) { let origin = ptr.origin.unwrap_or(p); lv.drag = Some((page, origin)); return true; diff --git a/crates/ui-egui/src/tools/measure_ui.rs b/crates/ui-egui/src/tools/measure_ui.rs index 5008464ad9..a81f01b0b2 100644 --- a/crates/ui-egui/src/tools/measure_ui.rs +++ b/crates/ui-egui/src/tools/measure_ui.rs @@ -202,9 +202,23 @@ fn snap_to_content( None } +/// The Measure tools' own clicks this frame: those of a press nobody holds. +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct Clicks { + pub single: bool, + pub double: bool, +} + +impl Clicks { + /// `resp`'s clicks, when the press is the tool's (`free`). + pub(crate) fn of(resp: &egui::Response, free: bool) -> Self { + Clicks { single: free && resp.clicked(), double: free && resp.double_clicked() } + } +} + pub(crate) fn page_input( ui: &egui::Ui, - resp: &egui::Response, + clicks: Clicks, doc: &Document, view: &mut DocView, cx: &crate::comments::PageCx<'_>, @@ -254,7 +268,7 @@ pub(crate) fn page_input( point = measure::snap::constrain(*last, point); } hover = Some(point); - if resp.clicked() { + if clicks.single { if state.page != Some(page) { state.cancel(); state.page = Some(page); @@ -284,7 +298,7 @@ pub(crate) fn page_input( } } if state.page == Some(page) && !state.points.is_empty() { - if (resp.double_clicked() || !typing && ui.input(|i| i.key_pressed(egui::Key::Enter))) && matches!(tool, Tool::Perimeter | Tool::Area) { + if (clicks.double || !typing && ui.input(|i| i.key_pressed(egui::Key::Enter))) && matches!(tool, Tool::Perimeter | Tool::Area) { finish(state, &mut view.pending_edit, doc, page, tool, author, units); } if !typing && ui.input(|i| i.key_pressed(egui::Key::Backspace)) { diff --git a/crates/ui-egui/src/tools/redact_ui.rs b/crates/ui-egui/src/tools/redact_ui.rs index e55dd8c5c3..0cca746bf8 100644 --- a/crates/ui-egui/src/tools/redact_ui.rs +++ b/crates/ui-egui/src/tools/redact_ui.rs @@ -233,9 +233,9 @@ fn to_user(xf: &PageXform, info: &DocInfo, page: usize, p: Pos2) -> [f64; 2] { } /// Redact tool input on a page. Presses on text are left to text selection (the selection is -/// marked when it ends, see [`after_text`]); presses elsewhere draw a box. Returns `true` when -/// the gesture is a box. -pub(crate) fn page_input(cx: PageToolContext<'_>, over_text: impl Fn(Pos2) -> bool, view: &mut DocView) -> bool { +/// marked when it ends, see [`after_text`]); presses nobody holds (`free`) elsewhere draw a box. +/// Returns `true` when the gesture is a box. +pub(crate) fn page_input(cx: PageToolContext<'_>, over_text: impl Fn(Pos2) -> bool, free: bool, view: &mut DocView) -> bool { let PageToolContext { resp, xf, ptr, page, info, .. } = cx; let pointer = ptr.pos; if let Some((dp, start)) = view.redact_drag @@ -256,8 +256,8 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, over_text: impl Fn(Pos2) -> bo if !over_text(p) { view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); } - // The tool's own press, of the primary button: nothing drawn over the page here holds it. - if resp.drag_started_by(egui::PointerButton::Primary) && view.router.owner(page).is_none() { + // The tool's own press, of the primary button: one nobody holds. + if resp.drag_started_by(egui::PointerButton::Primary) && free { let origin = ptr.origin.unwrap_or(p); if !over_text(origin) { view.redact_drag = Some((page, origin)); diff --git a/crates/ui-egui/src/tools/sign_ui.rs b/crates/ui-egui/src/tools/sign_ui.rs index b00d07279a..581fd23694 100644 --- a/crates/ui-egui/src/tools/sign_ui.rs +++ b/crates/ui-egui/src/tools/sign_ui.rs @@ -8,10 +8,9 @@ use egui::{Align, Color32, CornerRadius, Layout, Pos2, Rect, Stroke, vec2}; use pdfcraft_engine::sign::{self, Appearance, AppearanceLabels, Certificate, DigitalId, Name, PrivateKey}; use pdfcraft_engine::{SignOptions, SignatureImage, SignatureInfo, SignatureStatus}; -use crate::canvas::{DocView, PageXform, Snapshot, SnapshotKind}; +use crate::canvas::{DocView, PageToolContext, Snapshot, SnapshotKind}; use crate::fill_sign::SavedSig; use crate::page_cursor::Layer; -use crate::page_pointer::PagePointer; use crate::theme::{self, Tokens}; use crate::{PdfCraftApp, icons, widgets}; @@ -162,23 +161,17 @@ pub struct SignView { pub field: Option, } -/// Drag a signature rectangle on one page (Digitally sign, Certify (visible signature)). -pub(crate) fn page_input( - ui: &egui::Ui, - resp: &egui::Response, - xf: &PageXform, - ptr: &PagePointer, - page: usize, - info: &pdfcraft_render::DocInfo, - view: &mut DocView, -) { +/// Drag a signature rectangle on one page (Digitally sign, Certify (visible signature)), from a +/// press nobody holds (`free`). +pub(crate) fn page_input(cx: PageToolContext<'_>, free: bool, view: &mut DocView) { + let PageToolContext { ui, resp, xf, ptr, page, info, .. } = cx; let (pointer, origin) = (ptr.pos, ptr.origin); if pointer.is_some_and(|p| xf.rect.contains(p)) { view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); } - // The tool's own press, of the primary button: nothing drawn over the page here holds it. + // The tool's own press, of the primary button: one nobody holds. if resp.drag_started_by(egui::PointerButton::Primary) - && view.router.owner(page).is_none() + && free && let Some(o) = origin.filter(|o| xf.rect.contains(*o)) { view.sign.drag = Some((page, o)); diff --git a/crates/ui-egui/src/tools/stamps_ui.rs b/crates/ui-egui/src/tools/stamps_ui.rs index 9cddb31d23..484c7eb938 100644 --- a/crates/ui-egui/src/tools/stamps_ui.rs +++ b/crates/ui-egui/src/tools/stamps_ui.rs @@ -6,6 +6,8 @@ use std::sync::Arc; use egui::{Align, Layout}; use serde::{Deserialize, Serialize}; +use crate::canvas::{DocView, PageToolContext}; +use crate::page_cursor::Layer; use crate::theme::{self, Tokens}; use crate::{Dialog, PdfCraftApp, QuickTool, widgets}; @@ -41,6 +43,54 @@ mod b64 { } } +/// The Stamp tool on one page: a click that nobody holds (`free`) places stamp `kind` centred on +/// it, upright as the page is shown. `true` once one is placed. +pub(crate) fn place(cx: PageToolContext<'_>, kind: pdfcraft_engine::StampKind, by_line: &str, author: &str, free: bool, view: &mut DocView) -> bool { + let PageToolContext { resp, xf, ptr, page, info, .. } = cx; + let (Some(p), Some(page_info)) = (ptr.within(xf.rect), info.pages.get(page)) else { return false }; + view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); + if !(free && resp.clicked()) { + return false; + } + let (vx, vy) = xf.screen_to_view(p); + let (w, h) = kind.size(); + let a = page_info.view_to_user((f64::from(vx) - w / 2.0) as f32, (f64::from(vy) - h / 2.0) as f32); + let b = page_info.view_to_user((f64::from(vx) + w / 2.0) as f32, (f64::from(vy) + h / 2.0) as f32); + let rect = [f64::from(a[0].min(b[0])), f64::from(a[1].min(b[1])), f64::from(a[0].max(b[0])), f64::from(a[1].max(b[1]))]; + let by = (kind.group() == pdfcraft_engine::StampGroup::Dynamic).then(|| by_line.to_owned()); + let shape = pdfcraft_engine::Shape::Stamp { rect, stamp: kind, by }; + view.pending_edit = Some(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { + page, + style: pdfcraft_engine::Style::default_for(&shape), + shape, + contents: String::new(), + author: author.to_owned(), + })); + true +} + +/// A custom stamp on one page: a click that nobody holds (`free`) places `stamp` centred on it, at +/// its natural size (the engine sizes it). `true` once one is placed. +pub(crate) fn place_custom(cx: PageToolContext<'_>, stamp: &CustomStamp, author: &str, free: bool, view: &mut DocView) -> bool { + let PageToolContext { resp, xf, ptr, page, info, .. } = cx; + let (Some(p), Some(page_info)) = (ptr.within(xf.rect), info.pages.get(page)) else { return false }; + view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); + if !(free && resp.clicked()) { + return false; + } + let (vx, vy) = xf.screen_to_view(p); + let u = page_info.view_to_user(vx, vy); + let (x, y) = (f64::from(u[0]), f64::from(u[1])); + view.pending_edit = Some(pdfcraft_engine::Edit::AddCustomStamp { + page, + rect: [x, y, x, y], + name: stamp.name.clone(), + file: pdfcraft_engine::MarkFile { name: stamp.file.clone(), bytes: stamp.data.clone(), page: stamp.page }, + author: author.to_owned(), + }); + true +} + /// The Create Custom Stamp dialog: the chosen file, its category and name. #[derive(Clone, Debug, Default, PartialEq)] pub struct StampDraft { diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index b63d9937fb..9ecc955604 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -1155,3 +1155,40 @@ fn a_click_on_a_form_field_deselects_the_selected_comment() { assert_eq!(h.state().views[0].comments.selected, None, "a click on the check box leaves it unselected"); assert_eq!(agree(&h).len(), 1, "and ticks the box"); } + +/// The camera tool's quadrilateral, corner by corner, in page view points. +fn camera_corners(h: &Harness<'_, PdfCraftApp>) -> Option<[[f32; 2]; 4]> { + h.state().views[0].camera.map(|q| q.corners) +} + +/// A corner of the camera tool's quadrilateral is a press of its own: Escape in the middle of its +/// drag cancels it, and once the button is up no corner follows the pointer. Today Escape also +/// leaves the camera tool before the pages see it; this holds whatever the order of the keys. +#[test] +fn escape_mid_corner_drag_leaves_no_corner_following_the_pointer() { + let mut h = open(text_pdf(), "150", false); + h.state_mut().quick_tool = QuickTool::EnhanceCamera; + h.run_steps(4); + // The left top corner starts 5% in from the page's edges: view (15, 20) on the 300×400 page. + let corner = at(&h, 15.0, 380.0); + let start = camera_corners(&h); + h.hover_at(corner); + h.run_steps(1); + h.drag_at(corner); + h.run_steps(1); + h.hover_at(corner + egui::vec2(30.0, 30.0)); + h.run_steps(2); + assert_ne!(camera_corners(&h), start, "the corner follows its drag"); + h.key_press(egui::Key::Escape); + h.run_steps(1); + let end = corner + egui::vec2(60.0, 60.0); + h.hover_at(end); + h.run_steps(1); + h.drop_at(end); + h.run_steps(2); + let after = camera_corners(&h); + h.hover_at(corner + egui::vec2(120.0, 90.0)); + h.run_steps(3); + assert_eq!(camera_corners(&h), after, "no corner follows the pointer once the press is cancelled"); + assert!(h.state().views[0].camera.is_none_or(|q| q.grab.is_none()), "and none is held"); +}