mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-06 10:47:27 +00:00
refactor(ui): move page input out of canvas.rs into canvas/page_input.rs
What the tools do with the pointer on one page now lives in its own file: `PageInput`, `PageToolContext` and `page_tool_input`; `PageHits`, `edit_inventory`, `link_hit` and `cancel_on_escape`; the text layer, `text_layer` to `finish_text_pointer_gesture`; and `page_overlays`, which follows links and picks images. `cached_edit_images` stays in canvas.rs, where painting and the menus use it too. No change in behaviour. canvas.rs goes from 4,073 to 3,265 lines. The guard's page modules gain `canvas/page_input.rs`, and its allowlist entries move with the functions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0cbd61d611
commit
71d222cacf
3 files changed
+826
-811
No files matched your search
@@ -12,7 +12,7 @@ use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Stroke, TextureHand
|
||||
use pdfcraft_engine::{DocId, Edit};
|
||||
use pdfcraft_render::{DestView, DocInfo, LayerOp, LinkTarget, PRINT_TAG, PageText, RenderPool, RenderRequest, RequestKind, Tile, device_pixels};
|
||||
|
||||
use crate::page_cursor::{Layer, Subject};
|
||||
use crate::page_cursor::Layer;
|
||||
use crate::page_pointer::PagePointer;
|
||||
use crate::theme::{self, Tokens};
|
||||
use crate::{PdfCraftApp, QuickTool, RightPanel, comments, icons, widgets};
|
||||
@@ -28,6 +28,7 @@ pub(super) mod image_pick;
|
||||
mod navigation;
|
||||
mod organize;
|
||||
pub(super) mod page_cursor;
|
||||
mod page_input;
|
||||
pub(super) mod page_pointer;
|
||||
pub(super) mod page_router;
|
||||
pub mod scroll_speed;
|
||||
@@ -63,6 +64,8 @@ pub(crate) use organize::grid_page_rect;
|
||||
use organize::organize_grid;
|
||||
#[cfg(test)]
|
||||
use organize::sharp_enough;
|
||||
pub(crate) use page_input::PageToolContext;
|
||||
use page_input::{PageInput, cancel_on_escape, page_overlays, page_tool_input, text_layer};
|
||||
use selection::{Selection, Span, click_unit, selection_actions};
|
||||
pub use xform::PageXform;
|
||||
use xform::snap_to_pixels;
|
||||
@@ -1444,308 +1447,6 @@ fn paint_page_tiles(
|
||||
}
|
||||
}
|
||||
|
||||
/// What the page's tools did with the pointer this frame.
|
||||
struct PageInput {
|
||||
/// A tool, a field or a comment took the pointer: text selection must leave it alone.
|
||||
consumed: bool,
|
||||
/// Redact or Highlight draws boxes off text.
|
||||
area_tool: bool,
|
||||
}
|
||||
|
||||
/// Common geometry and pointer state for a tool acting on one page.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct PageToolContext<'a> {
|
||||
pub ui: &'a egui::Ui,
|
||||
pub resp: &'a egui::Response,
|
||||
pub xf: &'a PageXform,
|
||||
pub ptr: &'a PagePointer,
|
||||
pub page: usize,
|
||||
pub doc: &'a pdfcraft_engine::Document,
|
||||
pub info: &'a DocInfo,
|
||||
}
|
||||
|
||||
/// Comments: tools, selection, moving and resizing come before text selection. Form fields take
|
||||
/// clicks first with the Select tool (as Acrobat fills fields in every viewing mode); then
|
||||
/// comments; then text selection.
|
||||
fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot) -> PageInput {
|
||||
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;
|
||||
let PageFrame { resp, ptr, pointer, .. } = *frame;
|
||||
let cx = PageToolContext { ui, resp, xf: &xf, ptr, page: i, doc, info };
|
||||
let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark };
|
||||
let (lines, images) =
|
||||
if matches!(tool, QuickTool::AddText | QuickTool::EditText) && can_modify { edit_inventory(view, doc, i) } else { Default::default() };
|
||||
let field_tool = match tool {
|
||||
QuickTool::Field(f) => Some(f),
|
||||
_ => None,
|
||||
};
|
||||
// While a table is being set up, nothing else is drawn or moved.
|
||||
let prepare_allowed = can_modify && table_preview.is_none() && frame.out.open_table.is_none();
|
||||
let hits = PageHits {
|
||||
page: i,
|
||||
xf: &xf,
|
||||
info,
|
||||
tool,
|
||||
pcx: &pcx,
|
||||
form,
|
||||
can_fill,
|
||||
can_modify,
|
||||
editing_content,
|
||||
preparing,
|
||||
field_tool,
|
||||
prepare_allowed,
|
||||
added,
|
||||
lines: &lines,
|
||||
images: &images,
|
||||
clip: ui.clip_rect(),
|
||||
};
|
||||
if let Some(o) = ptr.pressed().filter(|o| xf.rect.contains(*o)) {
|
||||
let at = hits.at(view, o);
|
||||
view.router.press(i, at, tool);
|
||||
}
|
||||
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() && view.router.owner(i).is_none() {
|
||||
// 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() && view.router.owner(i).is_none() {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
// Over a mark already placed, the Fill & Sign tool picks it up (comments, below).
|
||||
if let QuickTool::Fill(ft) = tool
|
||||
&& allowed
|
||||
&& !comments::fill_grabs(&pcx, view)
|
||||
{
|
||||
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;
|
||||
}
|
||||
let on_field = if preparing {
|
||||
let o = crate::prepare::page_input(cx, form, field_tool, prepare_allowed, view);
|
||||
frame.out.field_props |= o.properties;
|
||||
frame.out.field_placed |= o.placed;
|
||||
frame.out.open_table = frame.out.open_table.or(o.table);
|
||||
o.consumed || field_tool.is_some()
|
||||
} else {
|
||||
tool == QuickTool::Select && crate::forms_ui::page_input(cx, form, can_fill, view)
|
||||
};
|
||||
// Redact draws boxes off text; so does Highlight (an area highlight, as in Acrobat).
|
||||
let area_tool = tool == QuickTool::Redact && can_modify || tool == QuickTool::Comment(comments::CommentTool::Highlight) && allowed;
|
||||
let boxing = area_tool && {
|
||||
let text = view.page_text(i);
|
||||
let over_text = |p: Pos2| {
|
||||
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)
|
||||
};
|
||||
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
|
||||
// the button is up: a drag that leaves the box still ends, and no new box takes it over.
|
||||
let over_source = tool == QuickTool::AddText
|
||||
&& (matches!(view.router.owner(i), Some(crate::page_router::Owner::Paragraph { .. }))
|
||||
|| pointer.is_some_and(|p| {
|
||||
!crate::content_ui::hits_added(&xf, info, i, added, p) && crate::edit_text_ui::over_paragraph(&xf, info, i, &lines, p)
|
||||
}));
|
||||
let source_blocked = over_source && view.content.hold_blocked();
|
||||
if over_source && resp.clicked() && !source_blocked {
|
||||
if let Some(edit) = view.content.finish_draft(added) {
|
||||
view.pending_edit = Some(edit);
|
||||
}
|
||||
view.content.selected = None;
|
||||
}
|
||||
let on_content = !on_objects && editing_content && can_modify && {
|
||||
source_blocked
|
||||
|| crate::content_ui::page_input(
|
||||
cx,
|
||||
added,
|
||||
tool == QuickTool::AddText && !over_source,
|
||||
matches!(tool, QuickTool::AddText | QuickTool::EditText),
|
||||
text_style,
|
||||
view,
|
||||
)
|
||||
|| tool == QuickTool::AddText && !over_source
|
||||
};
|
||||
// Added content owns its gesture; its source text must not queue a second edit.
|
||||
// Edit text & images boxes every paragraph and image whenever the tool is on. Only the
|
||||
// input waits while selected objects or added content own the pointer, or while page
|
||||
// input is blocked (another tab, a dialog, the window unfocused): the boxes stay drawn
|
||||
// instead of vanishing until the pointer comes back.
|
||||
let others_own_pointer = on_objects || on_content;
|
||||
let on_edit_text = (tool == QuickTool::EditText || over_source) && can_modify && {
|
||||
// Editable text takes precedence over background/overlapping artwork. Both
|
||||
// inventories are painted every frame, even when text owns the pointer.
|
||||
let text_used = crate::edit_text_ui::page_input(cx, &lines, view, !others_own_pointer);
|
||||
let image_cx = crate::edit_text_ui::ImageCx {
|
||||
ui,
|
||||
resp,
|
||||
xf: &xf,
|
||||
ptr,
|
||||
page: i,
|
||||
info,
|
||||
allow_interaction: !others_own_pointer && (matches!(view.router.owner(i), Some(crate::page_router::Owner::Picture { .. })) || !text_used),
|
||||
};
|
||||
let image_used = crate::edit_text_ui::image_input(&image_cx, &images, view);
|
||||
!others_own_pointer && (text_used || image_used)
|
||||
};
|
||||
if tool == QuickTool::EditText {
|
||||
if others_own_pointer {
|
||||
if ptr.pressed().is_some() || resp.clicked() || resp.secondary_clicked() {
|
||||
view.image_selection = None;
|
||||
}
|
||||
} else {
|
||||
crate::content_ui::sync_image_selection(view, i, &images, added);
|
||||
}
|
||||
}
|
||||
let on_link = tool == QuickTool::Link && can_modify && crate::link_ui::page_input(cx, doc_links, view);
|
||||
if tool == QuickTool::Select
|
||||
&& resp.secondary_clicked()
|
||||
&& can_modify
|
||||
&& let Some(p) = pointer.filter(|p| xf.rect.contains(*p))
|
||||
&& let Some(l) = doc.links().iter().find(|l| l.page == i && crate::link_ui::screen_rect(&xf, info, i, l.rect).expand(2.0).contains(p))
|
||||
{
|
||||
view.links.context = Some((i, l.index));
|
||||
}
|
||||
let consumed = on_objects || on_edit_text || on_link || on_content || boxing || on_field || comments::page_input(ui, resp, &pcx, view);
|
||||
// The cursor and the tip from what lies under the pointer, after the tools claimed theirs (the
|
||||
// last claim on a layer stands).
|
||||
if frame.env.unobstructed
|
||||
&& let Some(p) = pointer
|
||||
{
|
||||
for h in hits.at(view, p) {
|
||||
view.cursor.claim_with_tip(h.layer, h.icon, h.tip);
|
||||
}
|
||||
}
|
||||
PageInput { consumed, area_tool }
|
||||
}
|
||||
|
||||
/// Edit text's paragraphs and pictures on page `i`, kept while the document is unchanged.
|
||||
fn edit_inventory(
|
||||
view: &mut DocView,
|
||||
doc: &pdfcraft_engine::Document,
|
||||
i: usize,
|
||||
) -> (Vec<pdfcraft_engine::TextBlock>, Vec<pdfcraft_engine::PageImage>) {
|
||||
let generation = doc.edit_generation();
|
||||
let lines = match view.edit_lines.get(&i) {
|
||||
Some((g, l)) if *g == generation => l.clone(),
|
||||
_ => {
|
||||
let l = doc.text_blocks(i);
|
||||
view.edit_lines.insert(i, (generation, l.clone()));
|
||||
l
|
||||
}
|
||||
};
|
||||
let images = cached_edit_images(view, doc, i).to_vec();
|
||||
(lines, images)
|
||||
}
|
||||
|
||||
/// Images parsed once per page and document edit generation, shared by painting and menus.
|
||||
fn cached_edit_images<'a>(view: &'a mut DocView, doc: &pdfcraft_engine::Document, page: usize) -> &'a [pdfcraft_engine::PageImage] {
|
||||
let generation = doc.edit_generation();
|
||||
@@ -1756,472 +1457,6 @@ fn cached_edit_images<'a>(view: &'a mut DocView, doc: &pdfcraft_engine::Document
|
||||
&entry.1
|
||||
}
|
||||
|
||||
/// What the page modules have under a point on one page, for the page router.
|
||||
struct PageHits<'a> {
|
||||
page: usize,
|
||||
xf: &'a PageXform,
|
||||
info: &'a DocInfo,
|
||||
tool: QuickTool,
|
||||
pcx: &'a comments::PageCx<'a>,
|
||||
form: &'a [pdfcraft_engine::FormField],
|
||||
can_fill: bool,
|
||||
can_modify: bool,
|
||||
/// Edit a PDF is open: added text and pictures can be picked up.
|
||||
editing_content: bool,
|
||||
preparing: bool,
|
||||
field_tool: Option<crate::prepare::FieldTool>,
|
||||
/// Prepare may draw and move fields (not while a table is being set up).
|
||||
prepare_allowed: bool,
|
||||
added: &'a [pdfcraft_engine::Added],
|
||||
lines: &'a [pdfcraft_engine::TextBlock],
|
||||
images: &'a [pdfcraft_engine::PageImage],
|
||||
/// The pages' visible area (the picture's rotate knob stays inside it).
|
||||
clip: Rect,
|
||||
}
|
||||
|
||||
impl PageHits<'_> {
|
||||
/// The hits at `p`, bottom up: links, comments; Edit text's pictures under its paragraphs (text
|
||||
/// on top of artwork takes the press), added content, the objects selected together and the
|
||||
/// open paragraph's frame, the selected picture's grips over them all; then form fields
|
||||
/// (Prepare's, being moved, over the comments; filled ones under added content and the
|
||||
/// comments that move).
|
||||
fn at(&self, view: &DocView, p: Pos2) -> Vec<crate::page_router::Hit> {
|
||||
let PageHits {
|
||||
page,
|
||||
xf,
|
||||
info,
|
||||
tool,
|
||||
pcx,
|
||||
form,
|
||||
can_fill,
|
||||
can_modify,
|
||||
editing_content,
|
||||
preparing,
|
||||
field_tool,
|
||||
prepare_allowed,
|
||||
added,
|
||||
lines,
|
||||
images,
|
||||
clip,
|
||||
} = *self;
|
||||
// Crop mode owns every press on its page (`edit_text_ui::crop_hit`).
|
||||
if tool == QuickTool::EditText
|
||||
&& can_modify
|
||||
&& let Some(crop) = crate::edit_text_ui::crop_hit(view, page)
|
||||
{
|
||||
return vec![crop];
|
||||
}
|
||||
let fields = if preparing {
|
||||
crate::prepare::hit(xf, info, page, form, field_tool, prepare_allowed, &view.prepare, p)
|
||||
} else if tool == QuickTool::Select {
|
||||
crate::forms_ui::hit(xf, info, page, form, can_fill, p)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// With Add text, only the paragraphs and pictures not under added content.
|
||||
let editing =
|
||||
can_modify && (tool == QuickTool::EditText || tool == QuickTool::AddText && !crate::content_ui::hits_added(xf, info, page, added, p));
|
||||
let (pictures, paragraphs, objects, grips) = if editing {
|
||||
let open = view.line_editor.as_ref().filter(|e| e.page == page);
|
||||
let frame = open
|
||||
.filter(|e| !e.has_changes())
|
||||
.and_then(|e| lines.get(e.block))
|
||||
.map(|l| xf.user_rect(info, page, l.rect.map(|v| v as f32)).expand(2.0));
|
||||
// Add text acts on the page's paragraphs only in their boxes, edges included (it
|
||||
// finishes the box being typed there, `over_source`): not on pictures, nor beside a
|
||||
// paragraph, where a click starts a new box.
|
||||
let edit_text = tool == QuickTool::EditText;
|
||||
let on_paragraphs = edit_text || crate::edit_text_ui::over_paragraph(xf, info, page, lines, p);
|
||||
(
|
||||
edit_text.then(|| crate::edit_text_ui::picture_hit(xf, page, info, images, view, p)).flatten(),
|
||||
on_paragraphs.then(|| crate::edit_text_ui::hit(xf, info, page, lines, open.map(|e| e.block), p)).flatten(),
|
||||
edit_text.then(|| crate::object_ui::hit(view, xf, info, page, frame, p)).flatten(),
|
||||
edit_text.then(|| crate::edit_text_ui::picture_handle_hit(clip, xf, page, info, images, view, p)).flatten(),
|
||||
)
|
||||
} else {
|
||||
(None, None, None, None)
|
||||
};
|
||||
// Added text and pictures, over the page's own paragraphs and under the objects selected
|
||||
// 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();
|
||||
[link_hit(info, xf, page, p), comments::hit(pcx, view, p), pictures, paragraphs, added, objects, grips, fields]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Text layer: find matches, selection, I-beam and drag-to-select.
|
||||
fn text_layer(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot, input: &PageInput) {
|
||||
let PageEnv { info, selects_text, tool, .. } = frame.env;
|
||||
let PageSlot { i, r, xf } = *slot;
|
||||
let PageFrame { resp, ptr, pointer, .. } = *frame;
|
||||
if let Some(text) = view.texts.get(&i).cloned() {
|
||||
paint_text_marks(ui, view, &text, i, xf, resp.dragged());
|
||||
if selects_text
|
||||
&& !input.consumed
|
||||
&& let Some(p) = pointer.filter(|p| r.contains(*p))
|
||||
{
|
||||
text_pointer_input(ui, view, &text, TextPointer { page: i, xf, rect: r, info, tool, resp, ptr: ptr.clone(), pointer: p });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn paint_text_marks(ui: &egui::Ui, view: &DocView, text: &PageText, page: usize, xf: PageXform, dragging: bool) {
|
||||
let painter = ui.painter();
|
||||
let to_screen = |g: [f32; 4]| xf.view_rect(g);
|
||||
if let Some(find) = &view.find {
|
||||
for (index, (match_page, range)) in find.matches.iter().enumerate() {
|
||||
if *match_page != page {
|
||||
continue;
|
||||
}
|
||||
let fill = if find.current == Some(index) {
|
||||
Color32::from_rgba_unmultiplied(255, 140, 0, 110)
|
||||
} else {
|
||||
Color32::from_rgba_unmultiplied(255, 214, 0, 90)
|
||||
};
|
||||
for line_rect in text.line_rects(range.clone()) {
|
||||
painter.rect_filled(to_screen(line_rect).expand(1.0), CornerRadius::same(2), fill);
|
||||
}
|
||||
}
|
||||
}
|
||||
if view.all_text {
|
||||
for line_rect in text.line_rects(0..text.glyphs.len()) {
|
||||
painter.rect_filled(to_screen(line_rect), CornerRadius::same(1), Color32::from_rgba_unmultiplied(0x3A, 0x7B, 0xF0, 70));
|
||||
}
|
||||
}
|
||||
if let Some(selection) = view.selection.filter(|selection| selection.page == page) {
|
||||
for line_rect in selection.rects(text) {
|
||||
painter.rect_filled(to_screen(line_rect), CornerRadius::same(1), Color32::from_rgba_unmultiplied(0x3A, 0x7B, 0xF0, 70));
|
||||
}
|
||||
if let Span::Column { rect } = selection.span
|
||||
&& dragging
|
||||
{
|
||||
let outline = Stroke::new(1.0, Color32::from_rgb(0x3A, 0x7B, 0xF0));
|
||||
painter.rect_stroke(to_screen(rect), CornerRadius::ZERO, outline, egui::StrokeKind::Middle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TextPointer<'a> {
|
||||
page: usize,
|
||||
xf: PageXform,
|
||||
rect: Rect,
|
||||
info: &'a DocInfo,
|
||||
tool: QuickTool,
|
||||
resp: &'a egui::Response,
|
||||
ptr: PagePointer,
|
||||
pointer: Pos2,
|
||||
}
|
||||
|
||||
struct TextGesture<'a> {
|
||||
page: usize,
|
||||
xf: PageXform,
|
||||
rect: Rect,
|
||||
tool: QuickTool,
|
||||
ptr: PagePointer,
|
||||
resp: &'a egui::Response,
|
||||
pointer: Pos2,
|
||||
vx: f32,
|
||||
vy: f32,
|
||||
over_text: bool,
|
||||
over_link: bool,
|
||||
column: bool,
|
||||
}
|
||||
|
||||
fn text_pointer_input(ui: &mut egui::Ui, view: &mut DocView, text: &PageText, pointer: TextPointer<'_>) {
|
||||
let TextPointer { page, xf, rect, info, tool, resp, ptr, pointer: screen_pointer } = pointer;
|
||||
let (vx, vy) = xf.screen_to_view(screen_pointer);
|
||||
let over_text = text.glyphs.iter().any(|glyph| vx >= glyph.rect[0] && vx <= glyph.rect[2] && vy >= glyph.rect[1] && vy <= glyph.rect[3]);
|
||||
let over_link = info.links.iter().any(|link| link.page == page && xf.user_rect(info, page, link.rect).contains(screen_pointer));
|
||||
let column = tool == QuickTool::ColumnSelect || ui.input(|input| input.modifiers.alt);
|
||||
let gesture = TextGesture { page, xf, rect, tool, ptr, resp, pointer: screen_pointer, vx, vy, over_text, over_link, column };
|
||||
update_text_cursor(view, &gesture);
|
||||
update_selection_on_press(ui, view, text, &gesture);
|
||||
update_dragged_selection(view, text, &gesture);
|
||||
finish_text_pointer_gesture(ui, view, text, &gesture);
|
||||
}
|
||||
|
||||
fn update_text_cursor(view: &mut DocView, gesture: &TextGesture<'_>) {
|
||||
// A press on a link selects nothing (it is the link's), so no cursor here says it would.
|
||||
if gesture.column && !gesture.over_link {
|
||||
view.cursor.claim(Layer::Text, egui::CursorIcon::Crosshair);
|
||||
} else if gesture.over_text && !gesture.over_link {
|
||||
view.cursor.claim(Layer::Text, egui::CursorIcon::Text);
|
||||
}
|
||||
}
|
||||
|
||||
fn update_selection_on_press(ui: &egui::Ui, view: &mut DocView, text: &PageText, gesture: &TextGesture<'_>) {
|
||||
// A press held by what keeps its drag (a link, a comment that moves) selects no text; one on
|
||||
// text markup lets its drag select the text underneath.
|
||||
let press_here = gesture.ptr.origin.is_some_and(|origin| gesture.rect.contains(origin));
|
||||
if gesture.ptr.pressed().is_some() && press_here && !view.router.keeps_drag() {
|
||||
let now = ui.input(|input| input.time);
|
||||
let (max_delay, max_dist) = ui.ctx().options(|options| (options.input_options.max_double_click_delay, options.input_options.max_click_dist));
|
||||
let consecutive = now - view.last_press_time <= max_delay && view.last_press_pos.is_some_and(|pos| pos.distance(gesture.pointer) <= max_dist);
|
||||
view.clicks = if consecutive { view.clicks.saturating_add(1) } else { 1 };
|
||||
view.last_press_time = now;
|
||||
view.last_press_pos = Some(gesture.pointer);
|
||||
let clicks = selection_click_count(view.clicks, gesture.tool);
|
||||
if clicks >= 2
|
||||
&& gesture.over_text
|
||||
&& let Some((first, last)) = text.nearest(gesture.vx, gesture.vy).and_then(|anchor| click_unit(text, clicks, anchor))
|
||||
{
|
||||
view.selection = Some(Selection::run(gesture.page, first, last));
|
||||
}
|
||||
}
|
||||
if gesture.resp.drag_started() && press_here && !view.router.keeps_drag() {
|
||||
view.actions_dismissed = None;
|
||||
view.selection_drag = true;
|
||||
let origin = gesture.ptr.origin.unwrap_or(gesture.pointer);
|
||||
let (ox, oy) = gesture.xf.screen_to_view(origin);
|
||||
if view.clicks < 2 {
|
||||
view.selection = if gesture.column {
|
||||
Some(Selection { page: gesture.page, span: Span::Column { rect: [ox, oy, gesture.vx, gesture.vy] } })
|
||||
} else {
|
||||
text.nearest(ox, oy).map(|anchor| Selection::run(gesture.page, anchor, anchor))
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn selection_click_count(clicks: u32, tool: QuickTool) -> u32 {
|
||||
if clicks > 2 && tool != QuickTool::Select { 1 } else { clicks }
|
||||
}
|
||||
|
||||
fn update_dragged_selection(view: &mut DocView, text: &PageText, gesture: &TextGesture<'_>) {
|
||||
let clicks = selection_click_count(view.clicks, gesture.tool);
|
||||
if gesture.resp.dragged()
|
||||
&& let Some(selection) = view.selection.as_mut().filter(|selection| selection.page == gesture.page)
|
||||
{
|
||||
match &mut selection.span {
|
||||
Span::Run { anchor, head } if clicks >= 2 => {
|
||||
if let Some(hit) = text.nearest(gesture.vx, gesture.vy)
|
||||
&& let Some((first, last)) = click_unit(text, clicks, *anchor)
|
||||
{
|
||||
let (to_first, to_last) = click_unit(text, clicks, hit).unwrap_or((hit, hit));
|
||||
(*anchor, *head) = if to_first < first { (last, to_first) } else { (first, to_last.max(last)) };
|
||||
}
|
||||
}
|
||||
Span::Run { head, .. } => {
|
||||
if let Some(hit) = text.nearest(gesture.vx, gesture.vy) {
|
||||
*head = hit;
|
||||
}
|
||||
}
|
||||
Span::Column { rect } => {
|
||||
rect[2] = gesture.vx;
|
||||
rect[3] = gesture.vy;
|
||||
}
|
||||
}
|
||||
}
|
||||
if gesture.resp.drag_stopped()
|
||||
&& view.selection.is_some_and(|selection| {
|
||||
selection.page == gesture.page && matches!(selection.span, Span::Column { .. }) && selection.text(text).is_empty()
|
||||
})
|
||||
{
|
||||
view.selection = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn finish_text_pointer_gesture(ui: &egui::Ui, view: &mut DocView, text: &PageText, gesture: &TextGesture<'_>) {
|
||||
let release_clicks = if gesture.resp.triple_clicked() {
|
||||
view.clicks.max(2).saturating_add(1)
|
||||
} else if gesture.resp.double_clicked() {
|
||||
view.clicks.max(2)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
if release_clicks > 0 {
|
||||
view.clicks = release_clicks;
|
||||
}
|
||||
let clicks = selection_click_count(view.clicks, gesture.tool);
|
||||
if release_clicks > 0
|
||||
&& clicks >= 2
|
||||
&& gesture.over_text
|
||||
&& view.selection.is_none()
|
||||
&& let Some((first, last)) = text.nearest(gesture.vx, gesture.vy).and_then(|anchor| click_unit(text, clicks, anchor))
|
||||
{
|
||||
view.selection = Some(Selection::run(gesture.page, first, last));
|
||||
} else if gesture.resp.clicked() && view.clicks < 2 && !view.router.keeps_drag() {
|
||||
let shift = ui.input(|input| input.modifiers.shift);
|
||||
match (
|
||||
view.selection.as_mut().filter(|selection| shift && selection.page == gesture.page).map(|selection| &mut selection.span),
|
||||
text.nearest(gesture.vx, gesture.vy),
|
||||
) {
|
||||
(Some(Span::Run { head, .. }), Some(hit)) => *head = hit,
|
||||
_ => view.selection = None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything drawn over the page after its text: pictures, comments, content, links, fields, marks
|
||||
/// and badges.
|
||||
fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot, input: &PageInput) {
|
||||
let PageEnv {
|
||||
added,
|
||||
allowed,
|
||||
calc_order,
|
||||
can_modify,
|
||||
comments_hidden,
|
||||
doc,
|
||||
doc_links,
|
||||
editing_content,
|
||||
form,
|
||||
generation,
|
||||
info,
|
||||
prefs,
|
||||
preparing,
|
||||
redact_mark,
|
||||
table_preview,
|
||||
tool,
|
||||
unobstructed,
|
||||
..
|
||||
} = frame.env;
|
||||
let PageSlot { i, xf, .. } = *slot;
|
||||
let PageFrame { resp, ptr, pointer, .. } = *frame;
|
||||
let painter = ui.painter();
|
||||
let tool_cx = PageToolContext { ui, resp, xf: &xf, ptr, page: i, doc, info };
|
||||
let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark };
|
||||
if tool == QuickTool::Select && !input.consumed {
|
||||
crate::image_pick::page_input(resp, &xf, i, info, doc, pointer, view);
|
||||
}
|
||||
crate::image_pick::drop_stale(doc, view);
|
||||
crate::image_pick::paint(painter, &xf, i, info, view);
|
||||
// A cancelled press ends no selection: no markup or redaction is made from it.
|
||||
if !view.press_cancelled {
|
||||
comments::page_after_text(resp, &pcx, view);
|
||||
if tool == QuickTool::Redact && can_modify {
|
||||
crate::redact_ui::after_text(resp, i, info, view);
|
||||
}
|
||||
}
|
||||
if input.area_tool {
|
||||
crate::redact_ui::paint(painter, i, view, pointer);
|
||||
}
|
||||
comments::paint_page(ui, painter, &pcx, view);
|
||||
if editing_content {
|
||||
crate::content_ui::paint_page(painter, &xf, i, info, added, view, pointer);
|
||||
}
|
||||
if tool == QuickTool::EditText {
|
||||
crate::object_ui::paint(painter, &xf, i, info, &view.objects);
|
||||
}
|
||||
if tool == QuickTool::EnhanceCamera {
|
||||
crate::camera_ui::paint(painter, &xf, i, view);
|
||||
}
|
||||
if tool == QuickTool::Link {
|
||||
crate::link_ui::paint(painter, &xf, i, info, doc_links, view, pointer);
|
||||
}
|
||||
if preparing {
|
||||
crate::prepare::paint_page(tool_cx, form, view);
|
||||
if let Some(preview) = &table_preview {
|
||||
crate::field_table_ui::paint_preview(painter, &xf, i, preview);
|
||||
}
|
||||
if calc_order {
|
||||
let selected = view.prepare.selected.as_ref().map(|(name, _)| name.as_str());
|
||||
if let Some(field) = crate::calc_order_ui::paint_page(tool_cx, form, &view.calc_order, generation, selected) {
|
||||
frame.calc_hover = Some(field);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
crate::forms_ui::paint_page(painter, &xf, i, info, form, view, pointer);
|
||||
}
|
||||
|
||||
// Form-field highlight (Acrobat's "Highlight existing fields"); required fields get a
|
||||
// red border. Radio buttons and round XFA check boxes are round, and so is theirs (as
|
||||
// in Acrobat). Only what can be filled is marked: a read-only widget (its field's, or a
|
||||
// radio button's own) is left as it is drawn.
|
||||
paint_field_highlights(painter, &xf, info, form.as_slice(), i, view.highlight_fields);
|
||||
// Links: the hand and the link's target, which count only for the tools that follow or
|
||||
// edit links (`Layer::reached_by`). Tips draw above the palette, so none shows while it is
|
||||
// open, even beside it. A click follows the link its press was on, released over it: one that
|
||||
// slips off the link first follows nothing, as a button's does.
|
||||
if unobstructed
|
||||
&& resp.clicked()
|
||||
&& tool == QuickTool::Select
|
||||
&& !input.consumed
|
||||
&& let Some(owner @ crate::page_router::Owner::Link { index }) = view.router.owner(i)
|
||||
&& pointer.and_then(|p| link_hit(info, &xf, i, p)).is_some_and(|h| h.owner == owner)
|
||||
&& let Some(l) = info.links.get(index)
|
||||
{
|
||||
frame.out.clicked_link = Some(l.target.clone());
|
||||
}
|
||||
// Comments say what they are with any tool, beside its cursor, as Acrobat's do.
|
||||
comments::describe(&pcx, view);
|
||||
for (mp, mr, mc) in &view.compare_marks {
|
||||
if *mp == i {
|
||||
let sr = xf.user_rect(info, i, *mr).expand(1.5);
|
||||
painter.rect_filled(sr, CornerRadius::same(2), mc.gamma_multiply(0.28));
|
||||
}
|
||||
}
|
||||
if let Some((fp, fr, t0)) = view.flash
|
||||
&& fp == i
|
||||
{
|
||||
let now = ui.input(|inp| inp.time);
|
||||
let t0 = if t0 == 0.0 { now } else { t0 };
|
||||
view.flash = Some((fp, fr, t0));
|
||||
let age = (now - t0) as f32;
|
||||
if age < 1.6 {
|
||||
let a = ((1.6 - age) / 1.6 * 255.0) as u8;
|
||||
let sr = xf.user_rect(info, i, fr).expand(4.0);
|
||||
painter.rect_stroke(
|
||||
sr,
|
||||
CornerRadius::same(3),
|
||||
Stroke::new(2.5, Color32::from_rgba_unmultiplied(0x1B, 0x63, 0xE0, a)),
|
||||
egui::StrokeKind::Outside,
|
||||
);
|
||||
ui.ctx().request_repaint();
|
||||
} else {
|
||||
view.flash = None;
|
||||
}
|
||||
}
|
||||
// Validation is viewer chrome over the signer's original artwork, which
|
||||
// may still say "Signature Not Verified". These badges never enter document
|
||||
// streams, image/text exports, vector printing or Print as Image.
|
||||
for badge in signature_badges(&doc.signatures, i) {
|
||||
let [x0, y0, x1, y1] = badge.rect;
|
||||
let sr = xf.user_rect(info, i, [x0 as f32, y0 as f32, x1 as f32, y1 as f32]);
|
||||
let (icon, color) = crate::sign_ui::signature_status_style(badge.status);
|
||||
painter.rect_stroke(sr, CornerRadius::same(2), Stroke::new(1.5, color), egui::StrokeKind::Outside);
|
||||
// A widget too small for the mark keeps its outline: the verdict still shows.
|
||||
if sr.width() < 4.0 || sr.height() < 4.0 {
|
||||
continue;
|
||||
}
|
||||
// Big enough to read at a glance, small enough to leave the signer's own
|
||||
// text visible around it.
|
||||
let side = (sr.width().min(sr.height()) * 0.8).clamp(24.0, 192.0);
|
||||
let mark = Rect::from_center_size(sr.center(), vec2(side, side));
|
||||
icons::paint(ui, mark, icon, side * 0.64, color);
|
||||
// Overlapping widgets paint in order, so the topmost badge's verdict is the tip, with
|
||||
// any tool; a link's or a comment's tip wins over any badge.
|
||||
if pointer.is_some_and(|p| mark.expand(2.0).contains(p)) {
|
||||
view.cursor.describe(Subject::SignatureBadge, badge.summary.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn paint_field_highlights(painter: &egui::Painter, xf: &PageXform, info: &DocInfo, form: &[pdfcraft_engine::FormField], page: usize, enabled: bool) {
|
||||
if !enabled {
|
||||
return;
|
||||
}
|
||||
for field in form.iter() {
|
||||
let required = field.has(pdfcraft_engine::field_flags::REQUIRED);
|
||||
for widget in field.widgets.iter().filter(|widget| widget.page == Some(page) && !widget.hidden && !widget.read_only) {
|
||||
let rect = widget.rect;
|
||||
let screen_rect = xf.user_rect(info, page, [rect[0] as f32, rect[1] as f32, rect[2] as f32, rect[3] as f32]);
|
||||
let tint = Color32::from_rgba_unmultiplied(0x6E, 0x8E, 0xF5, 48);
|
||||
let (width, color) = if required { (2.0, Color32::from_rgb(0xE3, 0x22, 0x22)) } else { (1.0, Color32::from_rgb(0x6E, 0x8E, 0xF5)) };
|
||||
if widget.round {
|
||||
let radius = screen_rect.width().min(screen_rect.height()) / 2.0;
|
||||
painter.circle_filled(screen_rect.center(), radius, tint);
|
||||
painter.circle_stroke(screen_rect.center(), radius - width / 2.0, Stroke::new(width, color));
|
||||
} else {
|
||||
painter.rect_filled(screen_rect, CornerRadius::same(1), tint);
|
||||
painter.rect_stroke(screen_rect, CornerRadius::same(1), Stroke::new(width, color), egui::StrokeKind::Inside);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_page(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, i: usize) {
|
||||
let PageEnv { allowed, comments_hidden, doc, editing_content, info, ppp, prefs, rects, redact_mark, scale, t, tool, .. } = frame.env;
|
||||
let PageFrame { ptr, origin, visible, pressed, pointer, .. } = *frame;
|
||||
@@ -2283,43 +1518,6 @@ fn draw_page(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, i
|
||||
page_overlays(ui, view, frame, &slot, &input);
|
||||
}
|
||||
|
||||
/// The link under `p` on `page`: the hand, and where a click goes. Only the Select tool follows
|
||||
/// links and the Link tool edits them (`Layer::reached_by`).
|
||||
fn link_hit(info: &DocInfo, xf: &PageXform, page: usize, p: Pos2) -> Option<crate::page_router::Hit> {
|
||||
let (index, l) = info.links.iter().enumerate().rfind(|(_, l)| l.page == page && xf.user_rect(info, page, l.rect).contains(p))?;
|
||||
let label = match &l.target {
|
||||
LinkTarget::Page(n, _) => crate::i18n::fmt(tl!("Go to page {p}"), &[("p", info.pages.get(*n).map(|p| p.label.as_str()).unwrap_or("?"))]),
|
||||
LinkTarget::Uri(u) => u.clone(),
|
||||
LinkTarget::SetLayers { .. } => tl!("Set layer visibility").to_string(),
|
||||
LinkTarget::RemoteGoTo { file, .. } => file.clone(),
|
||||
LinkTarget::Other(s) => crate::i18n::fmt(tl!("{s} action"), &[("s", s)]),
|
||||
};
|
||||
let owner = crate::page_router::Owner::Link { index };
|
||||
Some(crate::page_router::Hit { tip: Some(label), ..crate::page_router::Hit::new(owner, Layer::Link, egui::CursorIcon::PointingHand) })
|
||||
}
|
||||
|
||||
/// Escape while the pages hold a press abandons the gesture it drives, whichever tool's: nothing is
|
||||
/// placed, drawn, moved, resized or opened, and the press is ignored until the button is up. The
|
||||
/// frame of the release still belongs to the cancelled press.
|
||||
fn cancel_on_escape(ctx: &egui::Context, view: &mut DocView, ptr: &PagePointer) {
|
||||
// Over once the button is up and its release seen, or when a new press comes (its release and
|
||||
// the next press can share a frame).
|
||||
let new_press = matches!(ptr.edges.last(), Some(crate::page_pointer::Edge::Press(_)));
|
||||
if view.press_cancelled && (new_press || !ptr.any_down && ptr.edges.is_empty()) {
|
||||
view.press_cancelled = false;
|
||||
}
|
||||
if !view.press_cancelled && ptr.origin.is_some() && ptr.primary_down && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape))
|
||||
{
|
||||
// A text selection being dragged goes with its drag: the markup, redaction or Replace
|
||||
// Text that its end would make is not made.
|
||||
if view.selection_drag {
|
||||
view.clear_selection();
|
||||
}
|
||||
view.cancel_pointer_gestures();
|
||||
view.press_cancelled = true;
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::cognitive_complexity)]
|
||||
fn draw_pages(
|
||||
ui: &mut egui::Ui,
|
||||
|
||||
@@ -0,0 +1,815 @@
|
||||
//! Page input: what the tools do with the pointer on one page, what lies under it for the page
|
||||
//! router, the text layer, and what is drawn over the page after its text.
|
||||
|
||||
use egui::{Color32, CornerRadius, Pos2, Rect, Stroke, vec2};
|
||||
use pdfcraft_render::{DocInfo, LinkTarget, PageText};
|
||||
|
||||
use super::{DocView, PageEnv, PageFrame, PageSlot, PageXform, Selection, Span, cached_edit_images, click_unit, signature_badges};
|
||||
use crate::page_cursor::{Layer, Subject};
|
||||
use crate::page_pointer::PagePointer;
|
||||
use crate::{QuickTool, comments, icons};
|
||||
|
||||
/// What the page's tools did with the pointer this frame.
|
||||
pub(super) struct PageInput {
|
||||
/// A tool, a field or a comment took the pointer: text selection must leave it alone.
|
||||
pub(super) consumed: bool,
|
||||
/// Redact or Highlight draws boxes off text.
|
||||
pub(super) area_tool: bool,
|
||||
}
|
||||
|
||||
/// Common geometry and pointer state for a tool acting on one page.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct PageToolContext<'a> {
|
||||
pub ui: &'a egui::Ui,
|
||||
pub resp: &'a egui::Response,
|
||||
pub xf: &'a PageXform,
|
||||
pub ptr: &'a PagePointer,
|
||||
pub page: usize,
|
||||
pub doc: &'a pdfcraft_engine::Document,
|
||||
pub info: &'a DocInfo,
|
||||
}
|
||||
|
||||
/// Comments: tools, selection, moving and resizing come before text selection. Form fields take
|
||||
/// clicks first with the Select tool (as Acrobat fills fields in every viewing mode); then
|
||||
/// comments; then text selection.
|
||||
pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot) -> PageInput {
|
||||
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;
|
||||
let PageFrame { resp, ptr, pointer, .. } = *frame;
|
||||
let cx = PageToolContext { ui, resp, xf: &xf, ptr, page: i, doc, info };
|
||||
let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark };
|
||||
let (lines, images) =
|
||||
if matches!(tool, QuickTool::AddText | QuickTool::EditText) && can_modify { edit_inventory(view, doc, i) } else { Default::default() };
|
||||
let field_tool = match tool {
|
||||
QuickTool::Field(f) => Some(f),
|
||||
_ => None,
|
||||
};
|
||||
// While a table is being set up, nothing else is drawn or moved.
|
||||
let prepare_allowed = can_modify && table_preview.is_none() && frame.out.open_table.is_none();
|
||||
let hits = PageHits {
|
||||
page: i,
|
||||
xf: &xf,
|
||||
info,
|
||||
tool,
|
||||
pcx: &pcx,
|
||||
form,
|
||||
can_fill,
|
||||
can_modify,
|
||||
editing_content,
|
||||
preparing,
|
||||
field_tool,
|
||||
prepare_allowed,
|
||||
added,
|
||||
lines: &lines,
|
||||
images: &images,
|
||||
clip: ui.clip_rect(),
|
||||
};
|
||||
if let Some(o) = ptr.pressed().filter(|o| xf.rect.contains(*o)) {
|
||||
let at = hits.at(view, o);
|
||||
view.router.press(i, at, tool);
|
||||
}
|
||||
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() && view.router.owner(i).is_none() {
|
||||
// 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() && view.router.owner(i).is_none() {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
// Over a mark already placed, the Fill & Sign tool picks it up (comments, below).
|
||||
if let QuickTool::Fill(ft) = tool
|
||||
&& allowed
|
||||
&& !comments::fill_grabs(&pcx, view)
|
||||
{
|
||||
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;
|
||||
}
|
||||
let on_field = if preparing {
|
||||
let o = crate::prepare::page_input(cx, form, field_tool, prepare_allowed, view);
|
||||
frame.out.field_props |= o.properties;
|
||||
frame.out.field_placed |= o.placed;
|
||||
frame.out.open_table = frame.out.open_table.or(o.table);
|
||||
o.consumed || field_tool.is_some()
|
||||
} else {
|
||||
tool == QuickTool::Select && crate::forms_ui::page_input(cx, form, can_fill, view)
|
||||
};
|
||||
// Redact draws boxes off text; so does Highlight (an area highlight, as in Acrobat).
|
||||
let area_tool = tool == QuickTool::Redact && can_modify || tool == QuickTool::Comment(comments::CommentTool::Highlight) && allowed;
|
||||
let boxing = area_tool && {
|
||||
let text = view.page_text(i);
|
||||
let over_text = |p: Pos2| {
|
||||
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)
|
||||
};
|
||||
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
|
||||
// the button is up: a drag that leaves the box still ends, and no new box takes it over.
|
||||
let over_source = tool == QuickTool::AddText
|
||||
&& (matches!(view.router.owner(i), Some(crate::page_router::Owner::Paragraph { .. }))
|
||||
|| pointer.is_some_and(|p| {
|
||||
!crate::content_ui::hits_added(&xf, info, i, added, p) && crate::edit_text_ui::over_paragraph(&xf, info, i, &lines, p)
|
||||
}));
|
||||
let source_blocked = over_source && view.content.hold_blocked();
|
||||
if over_source && resp.clicked() && !source_blocked {
|
||||
if let Some(edit) = view.content.finish_draft(added) {
|
||||
view.pending_edit = Some(edit);
|
||||
}
|
||||
view.content.selected = None;
|
||||
}
|
||||
let on_content = !on_objects && editing_content && can_modify && {
|
||||
source_blocked
|
||||
|| crate::content_ui::page_input(
|
||||
cx,
|
||||
added,
|
||||
tool == QuickTool::AddText && !over_source,
|
||||
matches!(tool, QuickTool::AddText | QuickTool::EditText),
|
||||
text_style,
|
||||
view,
|
||||
)
|
||||
|| tool == QuickTool::AddText && !over_source
|
||||
};
|
||||
// Added content owns its gesture; its source text must not queue a second edit.
|
||||
// Edit text & images boxes every paragraph and image whenever the tool is on. Only the
|
||||
// input waits while selected objects or added content own the pointer, or while page
|
||||
// input is blocked (another tab, a dialog, the window unfocused): the boxes stay drawn
|
||||
// instead of vanishing until the pointer comes back.
|
||||
let others_own_pointer = on_objects || on_content;
|
||||
let on_edit_text = (tool == QuickTool::EditText || over_source) && can_modify && {
|
||||
// Editable text takes precedence over background/overlapping artwork. Both
|
||||
// inventories are painted every frame, even when text owns the pointer.
|
||||
let text_used = crate::edit_text_ui::page_input(cx, &lines, view, !others_own_pointer);
|
||||
let image_cx = crate::edit_text_ui::ImageCx {
|
||||
ui,
|
||||
resp,
|
||||
xf: &xf,
|
||||
ptr,
|
||||
page: i,
|
||||
info,
|
||||
allow_interaction: !others_own_pointer && (matches!(view.router.owner(i), Some(crate::page_router::Owner::Picture { .. })) || !text_used),
|
||||
};
|
||||
let image_used = crate::edit_text_ui::image_input(&image_cx, &images, view);
|
||||
!others_own_pointer && (text_used || image_used)
|
||||
};
|
||||
if tool == QuickTool::EditText {
|
||||
if others_own_pointer {
|
||||
if ptr.pressed().is_some() || resp.clicked() || resp.secondary_clicked() {
|
||||
view.image_selection = None;
|
||||
}
|
||||
} else {
|
||||
crate::content_ui::sync_image_selection(view, i, &images, added);
|
||||
}
|
||||
}
|
||||
let on_link = tool == QuickTool::Link && can_modify && crate::link_ui::page_input(cx, doc_links, view);
|
||||
if tool == QuickTool::Select
|
||||
&& resp.secondary_clicked()
|
||||
&& can_modify
|
||||
&& let Some(p) = pointer.filter(|p| xf.rect.contains(*p))
|
||||
&& let Some(l) = doc.links().iter().find(|l| l.page == i && crate::link_ui::screen_rect(&xf, info, i, l.rect).expand(2.0).contains(p))
|
||||
{
|
||||
view.links.context = Some((i, l.index));
|
||||
}
|
||||
let consumed = on_objects || on_edit_text || on_link || on_content || boxing || on_field || comments::page_input(ui, resp, &pcx, view);
|
||||
// The cursor and the tip from what lies under the pointer, after the tools claimed theirs (the
|
||||
// last claim on a layer stands).
|
||||
if frame.env.unobstructed
|
||||
&& let Some(p) = pointer
|
||||
{
|
||||
for h in hits.at(view, p) {
|
||||
view.cursor.claim_with_tip(h.layer, h.icon, h.tip);
|
||||
}
|
||||
}
|
||||
PageInput { consumed, area_tool }
|
||||
}
|
||||
|
||||
/// Edit text's paragraphs and pictures on page `i`, kept while the document is unchanged.
|
||||
fn edit_inventory(
|
||||
view: &mut DocView,
|
||||
doc: &pdfcraft_engine::Document,
|
||||
i: usize,
|
||||
) -> (Vec<pdfcraft_engine::TextBlock>, Vec<pdfcraft_engine::PageImage>) {
|
||||
let generation = doc.edit_generation();
|
||||
let lines = match view.edit_lines.get(&i) {
|
||||
Some((g, l)) if *g == generation => l.clone(),
|
||||
_ => {
|
||||
let l = doc.text_blocks(i);
|
||||
view.edit_lines.insert(i, (generation, l.clone()));
|
||||
l
|
||||
}
|
||||
};
|
||||
let images = cached_edit_images(view, doc, i).to_vec();
|
||||
(lines, images)
|
||||
}
|
||||
|
||||
/// What the page modules have under a point on one page, for the page router.
|
||||
struct PageHits<'a> {
|
||||
page: usize,
|
||||
xf: &'a PageXform,
|
||||
info: &'a DocInfo,
|
||||
tool: QuickTool,
|
||||
pcx: &'a comments::PageCx<'a>,
|
||||
form: &'a [pdfcraft_engine::FormField],
|
||||
can_fill: bool,
|
||||
can_modify: bool,
|
||||
/// Edit a PDF is open: added text and pictures can be picked up.
|
||||
editing_content: bool,
|
||||
preparing: bool,
|
||||
field_tool: Option<crate::prepare::FieldTool>,
|
||||
/// Prepare may draw and move fields (not while a table is being set up).
|
||||
prepare_allowed: bool,
|
||||
added: &'a [pdfcraft_engine::Added],
|
||||
lines: &'a [pdfcraft_engine::TextBlock],
|
||||
images: &'a [pdfcraft_engine::PageImage],
|
||||
/// The pages' visible area (the picture's rotate knob stays inside it).
|
||||
clip: Rect,
|
||||
}
|
||||
|
||||
impl PageHits<'_> {
|
||||
/// The hits at `p`, bottom up: links, comments; Edit text's pictures under its paragraphs (text
|
||||
/// on top of artwork takes the press), added content, the objects selected together and the
|
||||
/// open paragraph's frame, the selected picture's grips over them all; then form fields
|
||||
/// (Prepare's, being moved, over the comments; filled ones under added content and the
|
||||
/// comments that move).
|
||||
fn at(&self, view: &DocView, p: Pos2) -> Vec<crate::page_router::Hit> {
|
||||
let PageHits {
|
||||
page,
|
||||
xf,
|
||||
info,
|
||||
tool,
|
||||
pcx,
|
||||
form,
|
||||
can_fill,
|
||||
can_modify,
|
||||
editing_content,
|
||||
preparing,
|
||||
field_tool,
|
||||
prepare_allowed,
|
||||
added,
|
||||
lines,
|
||||
images,
|
||||
clip,
|
||||
} = *self;
|
||||
// Crop mode owns every press on its page (`edit_text_ui::crop_hit`).
|
||||
if tool == QuickTool::EditText
|
||||
&& can_modify
|
||||
&& let Some(crop) = crate::edit_text_ui::crop_hit(view, page)
|
||||
{
|
||||
return vec![crop];
|
||||
}
|
||||
let fields = if preparing {
|
||||
crate::prepare::hit(xf, info, page, form, field_tool, prepare_allowed, &view.prepare, p)
|
||||
} else if tool == QuickTool::Select {
|
||||
crate::forms_ui::hit(xf, info, page, form, can_fill, p)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// With Add text, only the paragraphs and pictures not under added content.
|
||||
let editing =
|
||||
can_modify && (tool == QuickTool::EditText || tool == QuickTool::AddText && !crate::content_ui::hits_added(xf, info, page, added, p));
|
||||
let (pictures, paragraphs, objects, grips) = if editing {
|
||||
let open = view.line_editor.as_ref().filter(|e| e.page == page);
|
||||
let frame = open
|
||||
.filter(|e| !e.has_changes())
|
||||
.and_then(|e| lines.get(e.block))
|
||||
.map(|l| xf.user_rect(info, page, l.rect.map(|v| v as f32)).expand(2.0));
|
||||
// Add text acts on the page's paragraphs only in their boxes, edges included (it
|
||||
// finishes the box being typed there, `over_source`): not on pictures, nor beside a
|
||||
// paragraph, where a click starts a new box.
|
||||
let edit_text = tool == QuickTool::EditText;
|
||||
let on_paragraphs = edit_text || crate::edit_text_ui::over_paragraph(xf, info, page, lines, p);
|
||||
(
|
||||
edit_text.then(|| crate::edit_text_ui::picture_hit(xf, page, info, images, view, p)).flatten(),
|
||||
on_paragraphs.then(|| crate::edit_text_ui::hit(xf, info, page, lines, open.map(|e| e.block), p)).flatten(),
|
||||
edit_text.then(|| crate::object_ui::hit(view, xf, info, page, frame, p)).flatten(),
|
||||
edit_text.then(|| crate::edit_text_ui::picture_handle_hit(clip, xf, page, info, images, view, p)).flatten(),
|
||||
)
|
||||
} else {
|
||||
(None, None, None, None)
|
||||
};
|
||||
// Added text and pictures, over the page's own paragraphs and under the objects selected
|
||||
// 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();
|
||||
[link_hit(info, xf, page, p), comments::hit(pcx, view, p), pictures, paragraphs, added, objects, grips, fields]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Text layer: find matches, selection, I-beam and drag-to-select.
|
||||
pub(super) fn text_layer(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot, input: &PageInput) {
|
||||
let PageEnv { info, selects_text, tool, .. } = frame.env;
|
||||
let PageSlot { i, r, xf } = *slot;
|
||||
let PageFrame { resp, ptr, pointer, .. } = *frame;
|
||||
if let Some(text) = view.texts.get(&i).cloned() {
|
||||
paint_text_marks(ui, view, &text, i, xf, resp.dragged());
|
||||
if selects_text
|
||||
&& !input.consumed
|
||||
&& let Some(p) = pointer.filter(|p| r.contains(*p))
|
||||
{
|
||||
text_pointer_input(ui, view, &text, TextPointer { page: i, xf, rect: r, info, tool, resp, ptr: ptr.clone(), pointer: p });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn paint_text_marks(ui: &egui::Ui, view: &DocView, text: &PageText, page: usize, xf: PageXform, dragging: bool) {
|
||||
let painter = ui.painter();
|
||||
let to_screen = |g: [f32; 4]| xf.view_rect(g);
|
||||
if let Some(find) = &view.find {
|
||||
for (index, (match_page, range)) in find.matches.iter().enumerate() {
|
||||
if *match_page != page {
|
||||
continue;
|
||||
}
|
||||
let fill = if find.current == Some(index) {
|
||||
Color32::from_rgba_unmultiplied(255, 140, 0, 110)
|
||||
} else {
|
||||
Color32::from_rgba_unmultiplied(255, 214, 0, 90)
|
||||
};
|
||||
for line_rect in text.line_rects(range.clone()) {
|
||||
painter.rect_filled(to_screen(line_rect).expand(1.0), CornerRadius::same(2), fill);
|
||||
}
|
||||
}
|
||||
}
|
||||
if view.all_text {
|
||||
for line_rect in text.line_rects(0..text.glyphs.len()) {
|
||||
painter.rect_filled(to_screen(line_rect), CornerRadius::same(1), Color32::from_rgba_unmultiplied(0x3A, 0x7B, 0xF0, 70));
|
||||
}
|
||||
}
|
||||
if let Some(selection) = view.selection.filter(|selection| selection.page == page) {
|
||||
for line_rect in selection.rects(text) {
|
||||
painter.rect_filled(to_screen(line_rect), CornerRadius::same(1), Color32::from_rgba_unmultiplied(0x3A, 0x7B, 0xF0, 70));
|
||||
}
|
||||
if let Span::Column { rect } = selection.span
|
||||
&& dragging
|
||||
{
|
||||
let outline = Stroke::new(1.0, Color32::from_rgb(0x3A, 0x7B, 0xF0));
|
||||
painter.rect_stroke(to_screen(rect), CornerRadius::ZERO, outline, egui::StrokeKind::Middle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TextPointer<'a> {
|
||||
page: usize,
|
||||
xf: PageXform,
|
||||
rect: Rect,
|
||||
info: &'a DocInfo,
|
||||
tool: QuickTool,
|
||||
resp: &'a egui::Response,
|
||||
ptr: PagePointer,
|
||||
pointer: Pos2,
|
||||
}
|
||||
|
||||
struct TextGesture<'a> {
|
||||
page: usize,
|
||||
xf: PageXform,
|
||||
rect: Rect,
|
||||
tool: QuickTool,
|
||||
ptr: PagePointer,
|
||||
resp: &'a egui::Response,
|
||||
pointer: Pos2,
|
||||
vx: f32,
|
||||
vy: f32,
|
||||
over_text: bool,
|
||||
over_link: bool,
|
||||
column: bool,
|
||||
}
|
||||
|
||||
fn text_pointer_input(ui: &mut egui::Ui, view: &mut DocView, text: &PageText, pointer: TextPointer<'_>) {
|
||||
let TextPointer { page, xf, rect, info, tool, resp, ptr, pointer: screen_pointer } = pointer;
|
||||
let (vx, vy) = xf.screen_to_view(screen_pointer);
|
||||
let over_text = text.glyphs.iter().any(|glyph| vx >= glyph.rect[0] && vx <= glyph.rect[2] && vy >= glyph.rect[1] && vy <= glyph.rect[3]);
|
||||
let over_link = info.links.iter().any(|link| link.page == page && xf.user_rect(info, page, link.rect).contains(screen_pointer));
|
||||
let column = tool == QuickTool::ColumnSelect || ui.input(|input| input.modifiers.alt);
|
||||
let gesture = TextGesture { page, xf, rect, tool, ptr, resp, pointer: screen_pointer, vx, vy, over_text, over_link, column };
|
||||
update_text_cursor(view, &gesture);
|
||||
update_selection_on_press(ui, view, text, &gesture);
|
||||
update_dragged_selection(view, text, &gesture);
|
||||
finish_text_pointer_gesture(ui, view, text, &gesture);
|
||||
}
|
||||
|
||||
fn update_text_cursor(view: &mut DocView, gesture: &TextGesture<'_>) {
|
||||
// A press on a link selects nothing (it is the link's), so no cursor here says it would.
|
||||
if gesture.column && !gesture.over_link {
|
||||
view.cursor.claim(Layer::Text, egui::CursorIcon::Crosshair);
|
||||
} else if gesture.over_text && !gesture.over_link {
|
||||
view.cursor.claim(Layer::Text, egui::CursorIcon::Text);
|
||||
}
|
||||
}
|
||||
|
||||
fn update_selection_on_press(ui: &egui::Ui, view: &mut DocView, text: &PageText, gesture: &TextGesture<'_>) {
|
||||
// A press held by what keeps its drag (a link, a comment that moves) selects no text; one on
|
||||
// text markup lets its drag select the text underneath.
|
||||
let press_here = gesture.ptr.origin.is_some_and(|origin| gesture.rect.contains(origin));
|
||||
if gesture.ptr.pressed().is_some() && press_here && !view.router.keeps_drag() {
|
||||
let now = ui.input(|input| input.time);
|
||||
let (max_delay, max_dist) = ui.ctx().options(|options| (options.input_options.max_double_click_delay, options.input_options.max_click_dist));
|
||||
let consecutive = now - view.last_press_time <= max_delay && view.last_press_pos.is_some_and(|pos| pos.distance(gesture.pointer) <= max_dist);
|
||||
view.clicks = if consecutive { view.clicks.saturating_add(1) } else { 1 };
|
||||
view.last_press_time = now;
|
||||
view.last_press_pos = Some(gesture.pointer);
|
||||
let clicks = selection_click_count(view.clicks, gesture.tool);
|
||||
if clicks >= 2
|
||||
&& gesture.over_text
|
||||
&& let Some((first, last)) = text.nearest(gesture.vx, gesture.vy).and_then(|anchor| click_unit(text, clicks, anchor))
|
||||
{
|
||||
view.selection = Some(Selection::run(gesture.page, first, last));
|
||||
}
|
||||
}
|
||||
if gesture.resp.drag_started() && press_here && !view.router.keeps_drag() {
|
||||
view.actions_dismissed = None;
|
||||
view.selection_drag = true;
|
||||
let origin = gesture.ptr.origin.unwrap_or(gesture.pointer);
|
||||
let (ox, oy) = gesture.xf.screen_to_view(origin);
|
||||
if view.clicks < 2 {
|
||||
view.selection = if gesture.column {
|
||||
Some(Selection { page: gesture.page, span: Span::Column { rect: [ox, oy, gesture.vx, gesture.vy] } })
|
||||
} else {
|
||||
text.nearest(ox, oy).map(|anchor| Selection::run(gesture.page, anchor, anchor))
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn selection_click_count(clicks: u32, tool: QuickTool) -> u32 {
|
||||
if clicks > 2 && tool != QuickTool::Select { 1 } else { clicks }
|
||||
}
|
||||
|
||||
fn update_dragged_selection(view: &mut DocView, text: &PageText, gesture: &TextGesture<'_>) {
|
||||
let clicks = selection_click_count(view.clicks, gesture.tool);
|
||||
if gesture.resp.dragged()
|
||||
&& let Some(selection) = view.selection.as_mut().filter(|selection| selection.page == gesture.page)
|
||||
{
|
||||
match &mut selection.span {
|
||||
Span::Run { anchor, head } if clicks >= 2 => {
|
||||
if let Some(hit) = text.nearest(gesture.vx, gesture.vy)
|
||||
&& let Some((first, last)) = click_unit(text, clicks, *anchor)
|
||||
{
|
||||
let (to_first, to_last) = click_unit(text, clicks, hit).unwrap_or((hit, hit));
|
||||
(*anchor, *head) = if to_first < first { (last, to_first) } else { (first, to_last.max(last)) };
|
||||
}
|
||||
}
|
||||
Span::Run { head, .. } => {
|
||||
if let Some(hit) = text.nearest(gesture.vx, gesture.vy) {
|
||||
*head = hit;
|
||||
}
|
||||
}
|
||||
Span::Column { rect } => {
|
||||
rect[2] = gesture.vx;
|
||||
rect[3] = gesture.vy;
|
||||
}
|
||||
}
|
||||
}
|
||||
if gesture.resp.drag_stopped()
|
||||
&& view.selection.is_some_and(|selection| {
|
||||
selection.page == gesture.page && matches!(selection.span, Span::Column { .. }) && selection.text(text).is_empty()
|
||||
})
|
||||
{
|
||||
view.selection = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn finish_text_pointer_gesture(ui: &egui::Ui, view: &mut DocView, text: &PageText, gesture: &TextGesture<'_>) {
|
||||
let release_clicks = if gesture.resp.triple_clicked() {
|
||||
view.clicks.max(2).saturating_add(1)
|
||||
} else if gesture.resp.double_clicked() {
|
||||
view.clicks.max(2)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
if release_clicks > 0 {
|
||||
view.clicks = release_clicks;
|
||||
}
|
||||
let clicks = selection_click_count(view.clicks, gesture.tool);
|
||||
if release_clicks > 0
|
||||
&& clicks >= 2
|
||||
&& gesture.over_text
|
||||
&& view.selection.is_none()
|
||||
&& let Some((first, last)) = text.nearest(gesture.vx, gesture.vy).and_then(|anchor| click_unit(text, clicks, anchor))
|
||||
{
|
||||
view.selection = Some(Selection::run(gesture.page, first, last));
|
||||
} else if gesture.resp.clicked() && view.clicks < 2 && !view.router.keeps_drag() {
|
||||
let shift = ui.input(|input| input.modifiers.shift);
|
||||
match (
|
||||
view.selection.as_mut().filter(|selection| shift && selection.page == gesture.page).map(|selection| &mut selection.span),
|
||||
text.nearest(gesture.vx, gesture.vy),
|
||||
) {
|
||||
(Some(Span::Run { head, .. }), Some(hit)) => *head = hit,
|
||||
_ => view.selection = None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything drawn over the page after its text: pictures, comments, content, links, fields, marks
|
||||
/// and badges.
|
||||
pub(super) fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot, input: &PageInput) {
|
||||
let PageEnv {
|
||||
added,
|
||||
allowed,
|
||||
calc_order,
|
||||
can_modify,
|
||||
comments_hidden,
|
||||
doc,
|
||||
doc_links,
|
||||
editing_content,
|
||||
form,
|
||||
generation,
|
||||
info,
|
||||
prefs,
|
||||
preparing,
|
||||
redact_mark,
|
||||
table_preview,
|
||||
tool,
|
||||
unobstructed,
|
||||
..
|
||||
} = frame.env;
|
||||
let PageSlot { i, xf, .. } = *slot;
|
||||
let PageFrame { resp, ptr, pointer, .. } = *frame;
|
||||
let painter = ui.painter();
|
||||
let tool_cx = PageToolContext { ui, resp, xf: &xf, ptr, page: i, doc, info };
|
||||
let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark };
|
||||
if tool == QuickTool::Select && !input.consumed {
|
||||
crate::image_pick::page_input(resp, &xf, i, info, doc, pointer, view);
|
||||
}
|
||||
crate::image_pick::drop_stale(doc, view);
|
||||
crate::image_pick::paint(painter, &xf, i, info, view);
|
||||
// A cancelled press ends no selection: no markup or redaction is made from it.
|
||||
if !view.press_cancelled {
|
||||
comments::page_after_text(resp, &pcx, view);
|
||||
if tool == QuickTool::Redact && can_modify {
|
||||
crate::redact_ui::after_text(resp, i, info, view);
|
||||
}
|
||||
}
|
||||
if input.area_tool {
|
||||
crate::redact_ui::paint(painter, i, view, pointer);
|
||||
}
|
||||
comments::paint_page(ui, painter, &pcx, view);
|
||||
if editing_content {
|
||||
crate::content_ui::paint_page(painter, &xf, i, info, added, view, pointer);
|
||||
}
|
||||
if tool == QuickTool::EditText {
|
||||
crate::object_ui::paint(painter, &xf, i, info, &view.objects);
|
||||
}
|
||||
if tool == QuickTool::EnhanceCamera {
|
||||
crate::camera_ui::paint(painter, &xf, i, view);
|
||||
}
|
||||
if tool == QuickTool::Link {
|
||||
crate::link_ui::paint(painter, &xf, i, info, doc_links, view, pointer);
|
||||
}
|
||||
if preparing {
|
||||
crate::prepare::paint_page(tool_cx, form, view);
|
||||
if let Some(preview) = &table_preview {
|
||||
crate::field_table_ui::paint_preview(painter, &xf, i, preview);
|
||||
}
|
||||
if calc_order {
|
||||
let selected = view.prepare.selected.as_ref().map(|(name, _)| name.as_str());
|
||||
if let Some(field) = crate::calc_order_ui::paint_page(tool_cx, form, &view.calc_order, generation, selected) {
|
||||
frame.calc_hover = Some(field);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
crate::forms_ui::paint_page(painter, &xf, i, info, form, view, pointer);
|
||||
}
|
||||
|
||||
// Form-field highlight (Acrobat's "Highlight existing fields"); required fields get a
|
||||
// red border. Radio buttons and round XFA check boxes are round, and so is theirs (as
|
||||
// in Acrobat). Only what can be filled is marked: a read-only widget (its field's, or a
|
||||
// radio button's own) is left as it is drawn.
|
||||
paint_field_highlights(painter, &xf, info, form.as_slice(), i, view.highlight_fields);
|
||||
// Links: the hand and the link's target, which count only for the tools that follow or
|
||||
// edit links (`Layer::reached_by`). Tips draw above the palette, so none shows while it is
|
||||
// open, even beside it. A click follows the link its press was on, released over it: one that
|
||||
// slips off the link first follows nothing, as a button's does.
|
||||
if unobstructed
|
||||
&& resp.clicked()
|
||||
&& tool == QuickTool::Select
|
||||
&& !input.consumed
|
||||
&& let Some(owner @ crate::page_router::Owner::Link { index }) = view.router.owner(i)
|
||||
&& pointer.and_then(|p| link_hit(info, &xf, i, p)).is_some_and(|h| h.owner == owner)
|
||||
&& let Some(l) = info.links.get(index)
|
||||
{
|
||||
frame.out.clicked_link = Some(l.target.clone());
|
||||
}
|
||||
// Comments say what they are with any tool, beside its cursor, as Acrobat's do.
|
||||
comments::describe(&pcx, view);
|
||||
for (mp, mr, mc) in &view.compare_marks {
|
||||
if *mp == i {
|
||||
let sr = xf.user_rect(info, i, *mr).expand(1.5);
|
||||
painter.rect_filled(sr, CornerRadius::same(2), mc.gamma_multiply(0.28));
|
||||
}
|
||||
}
|
||||
if let Some((fp, fr, t0)) = view.flash
|
||||
&& fp == i
|
||||
{
|
||||
let now = ui.input(|inp| inp.time);
|
||||
let t0 = if t0 == 0.0 { now } else { t0 };
|
||||
view.flash = Some((fp, fr, t0));
|
||||
let age = (now - t0) as f32;
|
||||
if age < 1.6 {
|
||||
let a = ((1.6 - age) / 1.6 * 255.0) as u8;
|
||||
let sr = xf.user_rect(info, i, fr).expand(4.0);
|
||||
painter.rect_stroke(
|
||||
sr,
|
||||
CornerRadius::same(3),
|
||||
Stroke::new(2.5, Color32::from_rgba_unmultiplied(0x1B, 0x63, 0xE0, a)),
|
||||
egui::StrokeKind::Outside,
|
||||
);
|
||||
ui.ctx().request_repaint();
|
||||
} else {
|
||||
view.flash = None;
|
||||
}
|
||||
}
|
||||
// Validation is viewer chrome over the signer's original artwork, which
|
||||
// may still say "Signature Not Verified". These badges never enter document
|
||||
// streams, image/text exports, vector printing or Print as Image.
|
||||
for badge in signature_badges(&doc.signatures, i) {
|
||||
let [x0, y0, x1, y1] = badge.rect;
|
||||
let sr = xf.user_rect(info, i, [x0 as f32, y0 as f32, x1 as f32, y1 as f32]);
|
||||
let (icon, color) = crate::sign_ui::signature_status_style(badge.status);
|
||||
painter.rect_stroke(sr, CornerRadius::same(2), Stroke::new(1.5, color), egui::StrokeKind::Outside);
|
||||
// A widget too small for the mark keeps its outline: the verdict still shows.
|
||||
if sr.width() < 4.0 || sr.height() < 4.0 {
|
||||
continue;
|
||||
}
|
||||
// Big enough to read at a glance, small enough to leave the signer's own
|
||||
// text visible around it.
|
||||
let side = (sr.width().min(sr.height()) * 0.8).clamp(24.0, 192.0);
|
||||
let mark = Rect::from_center_size(sr.center(), vec2(side, side));
|
||||
icons::paint(ui, mark, icon, side * 0.64, color);
|
||||
// Overlapping widgets paint in order, so the topmost badge's verdict is the tip, with
|
||||
// any tool; a link's or a comment's tip wins over any badge.
|
||||
if pointer.is_some_and(|p| mark.expand(2.0).contains(p)) {
|
||||
view.cursor.describe(Subject::SignatureBadge, badge.summary.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn paint_field_highlights(painter: &egui::Painter, xf: &PageXform, info: &DocInfo, form: &[pdfcraft_engine::FormField], page: usize, enabled: bool) {
|
||||
if !enabled {
|
||||
return;
|
||||
}
|
||||
for field in form.iter() {
|
||||
let required = field.has(pdfcraft_engine::field_flags::REQUIRED);
|
||||
for widget in field.widgets.iter().filter(|widget| widget.page == Some(page) && !widget.hidden && !widget.read_only) {
|
||||
let rect = widget.rect;
|
||||
let screen_rect = xf.user_rect(info, page, [rect[0] as f32, rect[1] as f32, rect[2] as f32, rect[3] as f32]);
|
||||
let tint = Color32::from_rgba_unmultiplied(0x6E, 0x8E, 0xF5, 48);
|
||||
let (width, color) = if required { (2.0, Color32::from_rgb(0xE3, 0x22, 0x22)) } else { (1.0, Color32::from_rgb(0x6E, 0x8E, 0xF5)) };
|
||||
if widget.round {
|
||||
let radius = screen_rect.width().min(screen_rect.height()) / 2.0;
|
||||
painter.circle_filled(screen_rect.center(), radius, tint);
|
||||
painter.circle_stroke(screen_rect.center(), radius - width / 2.0, Stroke::new(width, color));
|
||||
} else {
|
||||
painter.rect_filled(screen_rect, CornerRadius::same(1), tint);
|
||||
painter.rect_stroke(screen_rect, CornerRadius::same(1), Stroke::new(width, color), egui::StrokeKind::Inside);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The link under `p` on `page`: the hand, and where a click goes. Only the Select tool follows
|
||||
/// links and the Link tool edits them (`Layer::reached_by`).
|
||||
fn link_hit(info: &DocInfo, xf: &PageXform, page: usize, p: Pos2) -> Option<crate::page_router::Hit> {
|
||||
let (index, l) = info.links.iter().enumerate().rfind(|(_, l)| l.page == page && xf.user_rect(info, page, l.rect).contains(p))?;
|
||||
let label = match &l.target {
|
||||
LinkTarget::Page(n, _) => crate::i18n::fmt(tl!("Go to page {p}"), &[("p", info.pages.get(*n).map(|p| p.label.as_str()).unwrap_or("?"))]),
|
||||
LinkTarget::Uri(u) => u.clone(),
|
||||
LinkTarget::SetLayers { .. } => tl!("Set layer visibility").to_string(),
|
||||
LinkTarget::RemoteGoTo { file, .. } => file.clone(),
|
||||
LinkTarget::Other(s) => crate::i18n::fmt(tl!("{s} action"), &[("s", s)]),
|
||||
};
|
||||
let owner = crate::page_router::Owner::Link { index };
|
||||
Some(crate::page_router::Hit { tip: Some(label), ..crate::page_router::Hit::new(owner, Layer::Link, egui::CursorIcon::PointingHand) })
|
||||
}
|
||||
|
||||
/// Escape while the pages hold a press abandons the gesture it drives, whichever tool's: nothing is
|
||||
/// placed, drawn, moved, resized or opened, and the press is ignored until the button is up. The
|
||||
/// frame of the release still belongs to the cancelled press.
|
||||
pub(super) fn cancel_on_escape(ctx: &egui::Context, view: &mut DocView, ptr: &PagePointer) {
|
||||
// Over once the button is up and its release seen, or when a new press comes (its release and
|
||||
// the next press can share a frame).
|
||||
let new_press = matches!(ptr.edges.last(), Some(crate::page_pointer::Edge::Press(_)));
|
||||
if view.press_cancelled && (new_press || !ptr.any_down && ptr.edges.is_empty()) {
|
||||
view.press_cancelled = false;
|
||||
}
|
||||
if !view.press_cancelled && ptr.origin.is_some() && ptr.primary_down && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape))
|
||||
{
|
||||
// A text selection being dragged goes with its drag: the markup, redaction or Replace
|
||||
// Text that its end would make is not made.
|
||||
if view.selection_drag {
|
||||
view.clear_selection();
|
||||
}
|
||||
view.cancel_pointer_gestures();
|
||||
view.press_cancelled = true;
|
||||
}
|
||||
}
|
||||
@@ -150,8 +150,9 @@ 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; 35] = [
|
||||
const PAGE_MODULES: [&str; 36] = [
|
||||
"canvas.rs",
|
||||
"canvas/page_input.rs",
|
||||
"canvas/area.rs",
|
||||
"canvas/bars.rs",
|
||||
"canvas/cache.rs",
|
||||
@@ -387,7 +388,7 @@ const ROUTER_WRITES: [&str; 3] = ["router.press(", "router.settle(", "router.can
|
||||
|
||||
/// The functions that decide, keep or drop the press the pages hold: (file, function, why).
|
||||
const ROUTER_WRITERS: [(&str, &str, &str); 3] = [
|
||||
("canvas.rs", "page_tool_input", "the hits at the press point decide its owner, once per press"),
|
||||
("canvas/page_input.rs", "page_tool_input", "the hits at the press point decide its owner, once per press"),
|
||||
("canvas.rs", "draw_pages", "the press is over once the button is up and its release seen, and a new one drops the last owner"),
|
||||
("canvas.rs", "cancel_pointer_gestures", "every pointer gesture's cancel: Escape, a dialog, automation's cancel"),
|
||||
];
|
||||
@@ -416,8 +417,8 @@ fn hit_test_calls(line: &str) -> Vec<String> {
|
||||
/// The functions that call a hit test: (file, function, why). Only `PageHits::at` asks at the
|
||||
/// press point; the others ask where the pointer is.
|
||||
const HIT_TESTERS: [(&str, &str, &str); 4] = [
|
||||
("canvas.rs", "at", "`PageHits::at`: every module's hits at a point, for the pointer's cursor and tip and, once, the press's owner"),
|
||||
("canvas.rs", "page_overlays", "a link's click counts only released over the link its press was on"),
|
||||
("canvas/page_input.rs", "at", "`PageHits::at`: every module's hits at a point, for the pointer's cursor and tip and, once, the press's owner"),
|
||||
("canvas/page_input.rs", "page_overlays", "a link's click counts only released over the link its press was on"),
|
||||
("tools/forms_ui.rs", "page_input", "the field's tooltip under the pointer, and a click counts only released over the widget its press was on"),
|
||||
("tools/comments/input.rs", "grabs", "with no button down, whether a comment would take a press under the pointer, for hover painting"),
|
||||
];
|
||||
@@ -426,7 +427,8 @@ const HIT_TESTERS: [(&str, &str, &str); 4] = [
|
||||
const PRESS_IGNORES: [&str; 1] = ["press_cancelled = true"];
|
||||
|
||||
/// The one function that ignores a press until the button is up: (file, function, why).
|
||||
const PRESS_IGNORERS: [(&str, &str, &str); 1] = [("canvas.rs", "cancel_on_escape", "Escape abandons the gesture the press drives, whichever tool's")];
|
||||
const PRESS_IGNORERS: [(&str, &str, &str); 1] =
|
||||
[("canvas/page_input.rs", "cancel_on_escape", "Escape abandons the gesture the press drives, whichever tool's")];
|
||||
|
||||
/// The module hit tests, called by path: a press handler calls none of them (HIT_TESTERS says who
|
||||
/// may).
|
||||
|
||||
Reference in new issue
Block a user