Browse / github.com/storytold/pdfcraft / patch
fix(comments): keep off-page text-box resize handles selectable (#4825)
Comments: keep off-page text-box resize handles selectable When a text-box comment extends beyond a page boundary, its exterior resize handles can remain visible but cannot be grabbed. Keep those visible handles hit-testable so the user can resize or move the box back within the page. Expected behavior: an off-page portion does not make the corresponding visible selection handle unreachable; interactions should remain clipped/safe for the page canvas.
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- YusufMntm (imported from pull request #4955, open)
- Base
1dd2737b47a9c1c9562f0af563053d1bf510e4bf- Size
- +77 −7 in 2 files
- Review
- pending
- Shared
- 2026-10-28
- Id
173b4dcdab7ef9259babbb6e
How it went
No other operator has reported using it yet.
Diff
raw · crates/ui-egui/src/tools/comments/input.rs crates/ui-egui/tests/it/comments.rs
@@ -12,6 +12,7 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, let (pointer, origin) = (cx.ptr.pos, cx.ptr.origin); let page_rect = cx.xf.rect; let pressed_here = origin.is_some_and(|o| page_rect.contains(o));+ let handle_pressed = origin.is_some_and(|o| page_rect.expand(12.0).contains(o)); let over_page = pointer.is_some_and(|p| page_rect.contains(p)); let fill_grab = fill_grabs(cx, view); let draw_grab = draw_grabs(cx, view);@@ -34,7 +35,7 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, match cx.tool { // The shape just drawn is selected with its handles showing: they resize it without a // trip to the Select tool (#2338). Anywhere else the tool keeps drawing.- QuickTool::Comment(_) if draw_grab => select_input(ui, resp, cx, view, pointer, origin, pressed_here),+ QuickTool::Comment(_) if draw_grab => select_input(ui, resp, cx, view, pointer, origin, handle_pressed), QuickTool::Comment(tool) if tool.draws() => { if !cx.allowed { return false;@@ -272,8 +273,8 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, } true }- QuickTool::Select => select_input(ui, resp, cx, view, pointer, origin, pressed_here),- QuickTool::Fill(_) if fill_grab => select_input(ui, resp, cx, view, pointer, origin, pressed_here),+ QuickTool::Select => select_input(ui, resp, cx, view, pointer, origin, handle_pressed),+ QuickTool::Fill(_) if fill_grab => select_input(ui, resp, cx, view, pointer, origin, handle_pressed), _ => false, } }@@ -327,9 +328,9 @@ fn grabs(cx: &PageCx<'_>, view: &DocView, pick: impl Fn(&Annotation) -> bool) -> } /// Where a gesture on this page is decided: the press while the button is down, otherwise the-/// pointer (egui clears the press origin on release). `None` off the page.+/// pointer (egui clears the press origin on release). `None` off the page or beyond handle reach. fn press_point(cx: &PageCx<'_>) -> Option<Pos2> {- (if cx.ptr.any_down { cx.ptr.origin } else { cx.ptr.pos }).filter(|p| cx.xf.rect.contains(*p))+ (if cx.ptr.any_down { cx.ptr.origin } else { cx.ptr.pos }).filter(|p| cx.xf.rect.expand(12.0).contains(*p)) } /// Whether `p` is on a resize handle of the selected comment, when it is a resizable `pick`.@@ -446,7 +447,7 @@ fn select_input( } } // (egui clears the press origin on release, so clicks are located by the pointer.)- let on_page = pointer.is_some_and(|p| cx.xf.rect.contains(p));+ let on_page = pointer.is_some_and(|p| cx.xf.rect.expand(12.0).contains(p)); if resp.clicked() && on_page { let follow_link = cx.tool == QuickTool::Select && !consumed@@ -454,7 +455,8 @@ fn select_input( && !resp.double_clicked() && pointer .is_some_and(|p| cx.info.links.iter().any(|link| link.page == cx.page && cx.xf.user_rect(cx.info, cx.page, link.rect).contains(p)));- match pointer.and_then(|p| cx.hit(p)) {+ let handle_hit = selected.filter(|_| pointer.is_some_and(|p| handle_at(p).is_some() || point_at(p).is_some()));+ match handle_hit.or_else(|| pointer.and_then(|p| cx.hit(p))) { Some(a) if a.subtype == "Highlight" && follow_link => { // A plain click follows the link beneath text highlighting. Keep other // annotations and active gestures in control of their own input.@@ -2657,3 +2657,71 @@ fn text_comments_can_save_a_zero_width_default() { assert_eq!(restored.comment_prefs.style(CommentTool::TextBox).width, 0.0); assert_eq!(restored.comment_prefs.style(CommentTool::Callout).width, 0.0); }++/// #4825: A comment or text box placed near the edge of a page can have its resize handles+/// extend slightly outside the page boundary. Dragging an off-page handle must be recognized,+/// entering `Gesture::Resize` and updating the comment's size, while starting a new drawing off-page remains blocked.+#[test]+fn off_page_resize_handle_is_selectable_and_resizes() {+ use pdfcraft_engine::{Edit, NewAnnotation, Shape};+ use pdfcraft_ui_egui::comments::Gesture;+ let mut h = harness(|app| app.set_option("quick", "select").unwrap());+ let shape = Shape::TextBox { rect: [0.0, 50.0, 100.0, 150.0], font_size: 12.0 };+ let style = pdfcraft_engine::Style::default_for(&shape);+ h.state_mut().apply_edit(Edit::AddAnnotation(NewAnnotation { page: 0, shape, style, contents: "Edge box".into(), author: "Tester".into() }));+ h.run_steps(2);+ let c = comments(&h);+ // Select the comment so its resize handles appear.+ let idx = c[0].index;+ h.state_mut().views[0].comments.selected = Some((0, idx));+ h.run_steps(2);++ let page_rect = h.state().views[0].page_screen_rect(0).expect("page 1 on screen");+ // The comment's left edge is at x = 0.0, which corresponds to page_rect.left().+ // The left-middle handle (-1, 0) is centered at x = page_rect.left().+ // Position the pointer 3 pixels to the left of the page (outside page_rect, but on the handle).+ let handle_y = page_rect.top() + 0.5 * page_rect.height();+ let off_page_from = pos2(page_rect.left() - 3.0, handle_y);+ assert!(!page_rect.contains(off_page_from), "start point is outside the page bounds");++ // Begin dragging the off-page handle to the right.+ let to = pos2(page_rect.left() + 30.0, handle_y);+ h.hover_at(off_page_from);+ h.run_steps(1);+ h.drag_at(off_page_from);+ h.run_steps(1);+ h.hover_at(off_page_from + (to - off_page_from) * 0.25);+ h.run_steps(1);++ // Verify that the resize gesture is actively recognized even though the press was off-page.+ assert!(matches!(h.state().views[0].comments.gesture, Some(Gesture::Resize { .. })), "dragging an off-page handle starts a resize gesture");++ for k in 2..=4 {+ h.hover_at(off_page_from + (to - off_page_from) * (k as f32 / 4.0));+ h.run_steps(1);+ }+ h.drop_at(to);+ h.run_steps(3);++ // The comment must have been resized and registered in undo history.+ let c = comments(&h);+ assert_eq!(c.len(), 1);+ let after_rect = c[0].rect;+ assert!(after_rect[0] > 0.0, "left edge moved inward: {after_rect:?}");+ assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Resize comment"));++ // Verify that drawing a new shape starting off-page does not begin a drag or create a comment.+ let mut h_draw = harness(|app| app.set_option("quick", "square").unwrap());+ let page_rect_draw = h_draw.state().views[0].page_screen_rect(0).expect("page 1 on screen");+ let off_page_draw = pos2(page_rect_draw.left() - 4.0, page_rect_draw.top() + 50.0);+ h_draw.hover_at(off_page_draw);+ h_draw.run_steps(1);+ h_draw.drag_at(off_page_draw);+ h_draw.run_steps(1);+ h_draw.hover_at(pos2(page_rect_draw.left() + 40.0, page_rect_draw.top() + 50.0));+ h_draw.run_steps(1);+ assert!(h_draw.state().views[0].comments.gesture.is_none(), "off-page drag does not start a new drawing");+ h_draw.drop_at(pos2(page_rect_draw.left() + 40.0, page_rect_draw.top() + 50.0));+ h_draw.run_steps(2);+ assert_eq!(comments(&h_draw).len(), 0, "no comment created from off-page press");+}