mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-06 18:52:36 +00:00
refactor(ui): route right-clicks by what lies on top under the pointer
Each right-click handler tested the pointer itself, and the chain's order decided which ran: comments only when no earlier module took the frame, Prepare by its own widget scan. A right-click now goes to what lies on top under the pointer (`PageHits::context_at`), by the layer ranking a press uses, with two differences. Comments count with every tool, as a comment's menu opens whatever tool is active (`comments::context_hit`). A highlight over a link is the highlight's: a left click there follows the link, a right-click opens the highlight's menu. On every right-click the page's menu is anchored at the pointer, and the comment it is on is selected, or none (`comments::context`); a Prepare field is selected (`prepare::context`); links take theirs from the same answer. Edit text's own paragraphs and pictures, and a Prepare field tool, keep their right-clicks as before. Behaviour change (the user's ruling, 2026-10-31): a right-click on a highlight over a link opens the highlight's menu, not the link's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2b256ab0c7
commit
bab615fed1
6 files changed
+120
-30
No files matched your search
@@ -98,8 +98,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut
|
||||
let at = hits.at(view, o);
|
||||
view.router.press(i, at, tool);
|
||||
}
|
||||
// A right-click goes to what a press there would go to: the top hit under the pointer.
|
||||
let context = pointer.filter(|p| resp.secondary_clicked() && xf.rect.contains(*p)).and_then(|p| top(hits.at(view, p), tool)).map(|h| h.owner);
|
||||
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);
|
||||
}
|
||||
@@ -303,6 +302,27 @@ fn link_of(owner: Option<Owner>) -> Option<usize> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn right_click(hits: &PageHits<'_>, view: &mut DocView, p: Pos2) -> Option<Owner> {
|
||||
let context = hits.context_at(view, p);
|
||||
// Edit text's own paragraphs and pictures keep the right-click as they take it today, and a
|
||||
// Prepare field tool the whole page.
|
||||
let editing_here = matches!(hits.tool, QuickTool::AddText | QuickTool::EditText) && top(hits.at(view, p), hits.tool).is_some();
|
||||
if !editing_here && hits.field_tool.is_none() {
|
||||
let comment = match context {
|
||||
Some(Owner::Comment { index, .. }) => Some(index),
|
||||
_ => None,
|
||||
};
|
||||
comments::context(hits.pcx, p, comment, view);
|
||||
}
|
||||
if let Some(Owner::PrepareField { field, widget }) = context {
|
||||
crate::prepare::context(hits.form, field, widget, view);
|
||||
}
|
||||
context
|
||||
}
|
||||
|
||||
/// Edit text's paragraphs and pictures on page `i`, kept while the document is unchanged.
|
||||
fn edit_inventory(
|
||||
view: &mut DocView,
|
||||
@@ -348,12 +368,25 @@ struct PageHits<'a> {
|
||||
}
|
||||
|
||||
impl PageHits<'_> {
|
||||
/// The hits at `p`, bottom up ([`Self::hits`]).
|
||||
fn at(&self, view: &DocView, p: Pos2) -> Vec<crate::page_router::Hit> {
|
||||
self.hits(view, p, false)
|
||||
}
|
||||
|
||||
/// What a right-click at `p` is on: what lies on top there, as for a press, but with every
|
||||
/// tool reaching comments and links, as a comment's menu shows whatever the tool. A highlight
|
||||
/// over a link is the highlight's ([`comments::context_hit`]).
|
||||
fn context_at(&self, view: &DocView, p: Pos2) -> Option<Owner> {
|
||||
top(self.hits(view, p, true), QuickTool::Select).map(|h| h.owner)
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
/// For a right-click (`context`), comments by [`comments::context_hit`].
|
||||
fn hits(&self, view: &DocView, p: Pos2, context: bool) -> Vec<crate::page_router::Hit> {
|
||||
let PageHits {
|
||||
page,
|
||||
xf,
|
||||
@@ -415,7 +448,7 @@ impl PageHits<'_> {
|
||||
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 link = crate::link_ui::hit(xf, info, page, doc_links, tool, p);
|
||||
let comment = comments::hit(pcx, view, p, link.is_some());
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -417,7 +417,11 @@ 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/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",
|
||||
"hits",
|
||||
"`PageHits::hits`: every module's hits at a point, for the pointer's cursor and tip, a right-click 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"),
|
||||
|
||||
@@ -37,7 +37,7 @@ pub(crate) use geometry::{corner_ratio, page_rotation, resized};
|
||||
pub use geometry::{drawn_text_box_rect, text_box_rect};
|
||||
#[cfg(test)]
|
||||
use input::hover_tip;
|
||||
pub(crate) use input::{describe, fill_grabs, hit, keys, nudge_keys, page_after_text, page_input};
|
||||
pub(crate) use input::{context, context_hit, describe, fill_grabs, hit, keys, nudge_keys, page_after_text, page_input};
|
||||
pub use menu::{CanvasAction, fill_picker, recent_custom_colours, remember_custom_colour, status_badge, swatch_grid};
|
||||
#[cfg(test)]
|
||||
use menu::{RECENT_CUSTOM, same_colour};
|
||||
|
||||
@@ -27,12 +27,6 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>,
|
||||
if cv.cancelled_drag {
|
||||
return true;
|
||||
}
|
||||
if resp.secondary_clicked()
|
||||
&& let Some(p) = pointer.filter(|p| page_rect.contains(*p))
|
||||
{
|
||||
cv.context_at = Some((cx.page, cx.to_user(p)));
|
||||
cv.selected = cx.hit(p).map(|a| (cx.page, a.index));
|
||||
}
|
||||
let gestures = Gestures { ui, resp, cx, pointer, origin, page_rect, pressed_here, over_page };
|
||||
match cx.tool {
|
||||
QuickTool::Comment(_) if draw_grab => select_input(ui, resp, cx, view, pointer, origin),
|
||||
@@ -106,6 +100,25 @@ pub(crate) fn hit(cx: &PageCx<'_>, view: &DocView, p: Pos2, on_link: bool) -> Op
|
||||
Some(Hit { declines_drag: markup, ..Hit::new(Owner::Comment { index: a.index, part: CommentPart::Body }, layer, icon) })
|
||||
}
|
||||
|
||||
/// The comment a right-click at `p` is on: the topmost, with every tool, as a comment's menu shows
|
||||
/// whatever tool is active. A highlight over a link is the highlight's here (a left click on it
|
||||
/// follows the link, [`hit`]). `None` off the page or with comments hidden.
|
||||
pub(crate) fn context_hit(cx: &PageCx<'_>, p: Pos2) -> Option<Hit> {
|
||||
if cx.hidden || !cx.xf.rect.contains(p) {
|
||||
return None;
|
||||
}
|
||||
let a = cx.hit(p)?;
|
||||
let layer = if a.fill_sign || !is_markup(&a.subtype) { Layer::Placed } else { Layer::Comment };
|
||||
Some(Hit::new(Owner::Comment { index: a.index, part: CommentPart::Body }, layer, egui::CursorIcon::Default))
|
||||
}
|
||||
|
||||
/// A right-click at `p` on this page: the page's menu is anchored there (Copy Image), and the
|
||||
/// comment it is on ([`context_hit`], when it wins there) is selected, or none.
|
||||
pub(crate) fn context(cx: &PageCx<'_>, p: Pos2, index: Option<usize>, view: &mut DocView) {
|
||||
view.comments.context_at = Some((cx.page, cx.to_user(p)));
|
||||
view.comments.selected = index.map(|i| (cx.page, i));
|
||||
}
|
||||
|
||||
/// Whether the comments take the press on this page (or, with no button down, would take one
|
||||
/// under the pointer), so the tools beneath leave it: a live move, resize or reshape, or a press
|
||||
/// held by a comment that keeps its drag.
|
||||
|
||||
@@ -398,24 +398,6 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, form: &[FormField], tool: Opti
|
||||
return out;
|
||||
}
|
||||
|
||||
// Right-click a field: select it (the context menu acts on it).
|
||||
if resp.secondary_clicked()
|
||||
&& let Some((f, wi)) = form.iter().find_map(|f| {
|
||||
f.widgets.iter().enumerate().find(|(_, w)| w.page == Some(page) && screen_rect(xf, info, page, w.rect).contains(p)).map(|(i, _)| (f, i))
|
||||
})
|
||||
{
|
||||
let key = (f.name.clone(), wi);
|
||||
if let Some(i) = prep.also.iter().position(|k| *k == key) {
|
||||
// Part of the selection: it becomes the anchor the others line up with.
|
||||
prep.also.remove(i);
|
||||
if let Some(old) = prep.selected.replace(key) {
|
||||
prep.also.push(old);
|
||||
}
|
||||
} else if prep.selected.as_ref() != Some(&key) {
|
||||
prep.selected = Some(key);
|
||||
prep.also.clear();
|
||||
}
|
||||
}
|
||||
// The Select tool: what the press was on ([`hit`]), a corner handle or a field; elsewhere a
|
||||
// drag draws a selection rectangle. A press held by something drawn over the fields (a
|
||||
// comment) is not Prepare's.
|
||||
@@ -469,6 +451,24 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, form: &[FormField], tool: Opti
|
||||
out
|
||||
}
|
||||
|
||||
/// A right-click on widget `widget` of field `field` (the top hit there, [`hit`]): the field is
|
||||
/// selected, so the menu acts on it. Part of a selection of several, it becomes the anchor the
|
||||
/// others line up with.
|
||||
pub(crate) fn context(form: &[FormField], field: usize, widget: usize, view: &mut DocView) {
|
||||
let Some(f) = form.get(field) else { return };
|
||||
let key = (f.name.clone(), widget);
|
||||
let prep = &mut view.prepare;
|
||||
if let Some(i) = prep.also.iter().position(|k| *k == key) {
|
||||
prep.also.remove(i);
|
||||
if let Some(old) = prep.selected.replace(key) {
|
||||
prep.also.push(old);
|
||||
}
|
||||
} else if prep.selected.as_ref() != Some(&key) {
|
||||
prep.selected = Some(key);
|
||||
prep.also.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Align, Center, Distribute and Set Fields to Same Size (Prepare a form, several fields
|
||||
/// selected).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
|
||||
@@ -1089,3 +1089,43 @@ fn a_right_click_on_a_note_over_a_link_opens_the_notes_menu() {
|
||||
assert_eq!((v.links.context, v.comments.selected), (None, Some((0, 1))), "the note is selected, not the link");
|
||||
assert!(h.query_by_label("Remove all links").is_none(), "the menu is the note's");
|
||||
}
|
||||
|
||||
/// [`link_pdf`], with a highlight drawn over the middle of its link.
|
||||
fn highlight_over_link_pdf() -> Vec<u8> {
|
||||
pdf(&[
|
||||
CATALOG,
|
||||
PAGES,
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>",
|
||||
"<< /Type /Annot /Subtype /Link /Rect [50 300 250 350] /Border [0 0 0] /A << /S /URI /URI (https://example.org/) >> >>",
|
||||
"<< /Type /Annot /Subtype /Highlight /Rect [100 310 200 340] /QuadPoints [100 340 200 340 100 310 200 310] /C [1 1 0] /F 4 /T (Ana) >>",
|
||||
])
|
||||
}
|
||||
|
||||
/// A click on a highlight over a link follows the link, but a right-click there is the
|
||||
/// highlight's: it is selected and its menu opens. The link's menu used to open, with the
|
||||
/// highlight selected underneath.
|
||||
#[test]
|
||||
fn a_right_click_on_a_highlight_over_a_link_opens_the_highlights_menu() {
|
||||
let mut h = open(highlight_over_link_pdf(), "150", false);
|
||||
let on_both = at(&h, 150.0, 325.0);
|
||||
right_click(&mut h, on_both);
|
||||
let v = &h.state().views[0];
|
||||
assert_eq!((v.links.context, v.comments.selected), (None, Some((0, 1))), "the highlight is selected, not the link");
|
||||
assert!(h.query_by_label("Remove all links").is_none(), "the menu is the highlight's");
|
||||
h.key_press(egui::Key::Escape);
|
||||
h.run_steps(3);
|
||||
mouse_click(&mut h, on_both);
|
||||
assert!(h.state().pending_link.is_some(), "a click there still follows the link");
|
||||
}
|
||||
|
||||
/// A comment's menu opens on a right-click whatever the tool, though a press there is the tool's:
|
||||
/// with the Rectangle tool, a right-click on a comment selects it.
|
||||
#[test]
|
||||
fn a_right_click_on_a_comment_with_a_drawing_tool_opens_its_menu() {
|
||||
let mut h = open(comment_on_text_pdf(), "150", false);
|
||||
h.state_mut().quick_tool = QuickTool::Comment(CommentTool::Rectangle);
|
||||
h.run_steps(4);
|
||||
let on_comment = at(&h, 100.0, 305.0);
|
||||
right_click(&mut h, on_comment);
|
||||
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the comment is selected for its menu");
|
||||
}
|
||||
Reference in new issue
Block a user