collective

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

diff --git a/crates/ui-egui/src/tools/comments/input.rs b/crates/ui-egui/src/tools/comments/input.rs--- a/crates/ui-egui/src/tools/comments/input.rs+++ b/crates/ui-egui/src/tools/comments/input.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.diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs--- a/crates/ui-egui/tests/it/comments.rs+++ b/crates/ui-egui/tests/it/comments.rs@@ -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");+}