mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-03 06:51:50 -06:00
The page view had no single reading of the pointer. About twenty page modules each read egui's raw state (hover_pos, latest_pos, interact_pos, press_origin) and tested it against an unclipped page rectangle. That state knows nothing of what is drawn over the pages, and hover_pos is None on the frame a finger or a Windows pen lifts: egui-winit sends PointerGone with every touch release, and winit reports Windows pen input as touch. So a tap placed no stamp, followed no link and ticked no check box. A touch drag of a comment never committed, and the next drag anywhere on the page moved the comment. The Stamp crosshair showed on the command palette, on the quick bar and on the chrome over a page scrolled under it, a link's tip floated over the Unlock prompt, and a double-click on a dialog's backdrop selected the word under it. canvas::document_area now reads one PagePointer per frame from the pages' content widget (step 1 of the page pointer router in the cursor-handling review): - pos: interact_pos, which keeps the position through a touch release, only while egui has the pages under the pointer with nothing over them (contains_pointer, and the top layer there is the pages', as the closed #2626 did) or while the pages hold a press. - origin: where the held press began, also on the frame that brings both the press and its release. This replaces the text layer's raw-event fallback (#2100). - edges: the frame's primary presses and releases on the pages, in order. It reaches the page modules through comments::PageCx and as an argument: the stamp blocks, text layer, page press and link and comment hover in canvas.rs; comments and the signature drag preview; forms and Prepare a form; Fill & Sign and the Sign area; crop, marquee zoom, snapshot, camera and measure; redact and the Link tool; added content, Edit text, images and objects. page_covered, a hand-kept list of what covers the pages that missed the Unlock prompt, is gone. The pages' hover tip is click-through, as a tooltip is, so a quick move onto it does not take the pointer off the link under it. Tests: tests/it/support/input.rs sends pointer input in the frames the platform delivers it in (a touch tap and drag, a click in one frame, a double-click). kittest otherwise runs one frame per event, so the suite never saw a touch release. tests/it/pointer.rs holds the review's probes and #2626's layer tests. With the fix reverted, the tap (stamp, link, check box), touch drag, palette, quick bar, chrome, Unlock tip and dialog double-click tests fail, and the mouse-click and one-frame-click controls pass. The tip test fails with the tip interactable, and each page_pointer unit test fails under a mutation of the part it pins. The probes for later steps stay ignored: the link hand and the I-beam over other tools' targets (step 2), paragraph edges (step 3) and Escape mid-drag (step 4). touch_tap_ticks_a_check_box is ignored until the check box focus fix that follows: since #2223 a tap on a check box counts as a click away from its focus. tests/it/comments.rs: the multiline markup hover test (#3541) hovered a point below the window on a page rotated 270 degrees, and its tip showed only because the hover was tested against the page's whole rectangle. Its harness now zooms to 100 %, so every rotation shows the whole page, and the hover helper asserts the point is on screen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
184 lines
8.3 KiB
Rust
184 lines
8.3 KiB
Rust
//! 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 pdfcraft_render::{RenderConfig, RenderRequest, RequestKind, Tile};
|
||
|
||
use crate::canvas::{DocView, PageXform};
|
||
use crate::page_pointer::PagePointer;
|
||
use crate::{PdfCraftApp, 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, ptr: &PagePointer, page: usize, view: &mut DocView) {
|
||
let (pointer, origin) = (ptr.pos, ptr.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 PdfCraftApp {
|
||
/// 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;
|
||
}
|
||
// The tool outlives the tab it was chosen on.
|
||
if self.views.get(index).map(|v| v.id).is_some_and(|id| !self.may_copy(id)) {
|
||
return;
|
||
}
|
||
match self.snapshot(index, page, view_rect) {
|
||
Ok((w, h)) => {
|
||
self.notify_fmt("The selected area has been copied ({w} × {h} pixels)", &[("w", &w.to_string()), ("h", &h.to_string())])
|
||
}
|
||
Err(e) => self.notify_fmt("Couldn't take the snapshot: {e}", &[("e", &e.to_string())]),
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
/// View ▸ Zoom ▸ Fit Visible on the current page: find where the page has ink (a quick
|
||
/// low-resolution render) and zoom so that spans the window's width. A blank page fits the
|
||
/// width.
|
||
pub fn fit_visible(&mut self, index: usize) -> Result<(), String> {
|
||
let view = &self.views[index];
|
||
let page = view.current;
|
||
let doc = self.session.get(view.id).ok_or("no document")?;
|
||
let config = RenderConfig { password: doc.password.as_deref().map(std::sync::Arc::from), ..RenderConfig::default() };
|
||
let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), config);
|
||
// About 800 px along the longer side is plenty to find the margins.
|
||
let size = doc.info.pages.get(page).map_or(792.0, |p| p.width.max(p.height));
|
||
let out =
|
||
r.render(RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale: (800.0 / size).clamp(0.1, 4.0), tag: 0, preview: false });
|
||
if let Some(e) = out.error {
|
||
return Err(e);
|
||
}
|
||
let rot = view.rotation;
|
||
let view = &mut self.views[index];
|
||
match ink_bounds(out.width as usize, out.height as usize, &out.rgba) {
|
||
Some([u0, v0, u1, v1]) => {
|
||
// View space → the rotated view on screen.
|
||
let rotate = |u: f32, v: f32| match rot {
|
||
90 => (1.0 - v, u),
|
||
180 => (1.0 - u, 1.0 - v),
|
||
270 => (v, 1.0 - u),
|
||
_ => (u, v),
|
||
};
|
||
let (a, b) = (rotate(u0, v0), rotate(u1, v1));
|
||
if !view.fit_content(page, [a.0.min(b.0), a.1.min(b.1), a.0.max(b.0), a.1.max(b.1)]) {
|
||
view.fit = crate::canvas::Fit::Width;
|
||
}
|
||
}
|
||
None => view.fit = crate::canvas::Fit::Width,
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// 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 = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), config);
|
||
let out = r.render(RenderRequest { page, kind: RequestKind::Pixels, tile: Some(tile), scale, tag: 0, preview: false });
|
||
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))
|
||
}
|
||
}
|
||
|
||
/// The normalised bounds [u0, v0, u1, v1] of the pixels that aren't (near) white, if any.
|
||
pub(crate) fn ink_bounds(w: usize, h: usize, rgba: &[u8]) -> Option<[f32; 4]> {
|
||
if w == 0 || h == 0 || rgba.len() < w * h * 4 {
|
||
return None;
|
||
}
|
||
let ink = |p: &[u8]| p[3] > 16 && (p[0] < 240 || p[1] < 240 || p[2] < 240);
|
||
let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0, 0);
|
||
for (y, row) in rgba.chunks_exact(w * 4).take(h).enumerate() {
|
||
for (x, p) in row.as_chunks::<4>().0.iter().enumerate() {
|
||
if ink(p) {
|
||
x0 = x0.min(x);
|
||
x1 = x1.max(x + 1);
|
||
y0 = y0.min(y);
|
||
y1 = y1.max(y + 1);
|
||
}
|
||
}
|
||
}
|
||
(x1 > x0 && y1 > y0).then(|| [x0 as f32 / w as f32, y0 as f32 / h as f32, x1 as f32 / w as f32, y1 as f32 / h as f32])
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
#[test]
|
||
fn ink_bounds_finds_the_marked_area() {
|
||
let (w, h) = (10, 20);
|
||
let mut px = vec![255u8; w * h * 4];
|
||
for (x, y) in [(2, 5), (7, 15)] {
|
||
px[(y * w + x) * 4] = 0;
|
||
}
|
||
assert_eq!(super::ink_bounds(w, h, &px), Some([0.2, 0.25, 0.8, 0.8]));
|
||
assert_eq!(super::ink_bounds(w, h, &vec![255u8; w * h * 4]), None);
|
||
}
|
||
}
|