From cdb1010f404c8150eaad2f9a536827133dccbdbe Mon Sep 17 00:00:00 2001 From: Guillem Hermida Date: Fri, 16 Oct 2026 15:59:12 +0200 Subject: [PATCH] fix(edit): a paragraph's width handle rewraps it from its whole handle, not just the inner half (#2276) In Edit text & images, a paragraph's width handle is drawn centred on its box's edge, and the edge grabs from up to 6 px outside the box. Object selection sees a press first, though, and handed it to the paragraph tool only on the box itself. A press on the outer half of the handle started an area selection instead: its rectangle followed the pointer like a resized frame, and on release the paragraph kept its old width and the text never moved. Object selection now also leaves a press on a closed paragraph's edge grip to the paragraph tool, through edit_text_ui::grabs_edge. That uses the same edge test, box and upright-page rule as the tool itself, so the two can't drift apart. The rewrap is the existing EditTextBlock with a width, so undo, redo and save are unchanged. Fixes #2276 --- crates/ui-egui/src/edit_text_ui.rs | 24 ++++++++++--- crates/ui-egui/src/object_ui.rs | 6 +++- crates/ui-egui/tests/editing.rs | 55 ++++++++++++++++++++++++++++++ parity/acrobat-features.toml | 4 +-- 4 files changed, 82 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index d8881ba47..a6968d26e 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -603,6 +603,24 @@ fn grab_at(boxes: &[Rect], p: Pos2, upright: bool) -> Option<(usize, Grip)> { }) } +/// A paragraph's box on screen: its text rect (user space), with a little room around it. +fn paragraph_box(xf: &PageXform, info: &DocInfo, page: usize, rect: [f64; 4]) -> Rect { + xf.user_rect(info, page, rect.map(|v| v as f32)).expand(2.0) +} + +/// The width handles need the page upright (or upside down): on a quarter-turned page the +/// screen's horizontal is the paragraph's vertical. +fn has_edges(info: &DocInfo, page: usize) -> bool { + info.pages.get(page).is_some_and(|p| p.rotation % 180 == 0) +} + +/// Whether a press at `p` takes hold of the width handle of one of the paragraphs `rects` (user +/// space). The handle is drawn half outside the box and grabs from a little further out still, +/// where a press is not on the box itself, so area selection must leave it to [`page_input`]. +pub(crate) fn grabs_edge(xf: &PageXform, info: &DocInfo, page: usize, mut rects: impl Iterator, p: Pos2) -> bool { + has_edges(info, page) && rects.any(|r| edge_at(paragraph_box(xf, info, page, r), p).is_some()) +} + /// What a right-click on a selected image asks for. #[derive(Clone, Copy, Debug, PartialEq)] pub(crate) enum ImageAction { @@ -988,7 +1006,7 @@ pub(crate) fn page_input( view.block_drag = None; return true; } - let boxes: Vec = lines.iter().map(|l| xf.user_rect(info, page, l.rect.map(|v| v as f32)).expand(2.0)).collect(); + let boxes: Vec = lines.iter().map(|l| paragraph_box(xf, info, page, l.rect)).collect(); let painter = ui.painter(); let active = view.line_editor.as_ref().filter(|e| e.page == page).map(|e| e.block); for (i, b) in boxes.iter().enumerate() { @@ -1008,9 +1026,7 @@ pub(crate) fn page_input( if !allow_interaction { return false; } - // The width handle needs the page upright (or upside down): on a quarter-turned page the - // screen's horizontal is the paragraph's vertical. - let upright = info.pages.get(page).is_some_and(|p| p.rotation % 180 == 0); + let upright = has_edges(info, page); let draw_handles = |b: Rect| { for h in width_handles(b) { painter.rect(h, CornerRadius::same(2), Color32::WHITE, Stroke::new(1.0, ACCENT), egui::StrokeKind::Middle); diff --git a/crates/ui-egui/src/object_ui.rs b/crates/ui-egui/src/object_ui.rs index 025e72d5e..029dc5bc9 100644 --- a/crates/ui-egui/src/object_ui.rs +++ b/crates/ui-egui/src/object_ui.rs @@ -311,7 +311,11 @@ fn input(s: &mut ObjectSelection, ui: &egui::Ui, resp: &egui::Response, xf: &Pag return true; } s.clear(); - if hit.is_some() { + // A closed paragraph's width handle reaches past its box, and a press there rewraps the + // paragraph (as in `edit_text_ui`) instead of starting an area selection (#2276). + let on_edge = view.line_editor.as_ref().is_none_or(|e| e.page != page) + && crate::edit_text_ui::grabs_edge(xf, info, page, objects.iter().filter(|o| o.target.kind == ObjectKind::Text).map(|o| o.rect), p); + if hit.is_some() || on_edge { return false; } leave_single(view); diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index f8db52575..b4ef3afb8 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -3503,6 +3503,61 @@ fn a_quick_flick_on_either_edge_rewraps_the_paragraph() { assert!(h.state().views[0].line_editor.is_none()); } +/// #2276: a paragraph's rewrap handle is drawn half outside its box, and the resize pointer +/// shows a few pixels further out still. A press there started an area selection instead: its +/// rectangle followed the pointer, and on release the box went back to its old width. +#[test] +fn a_paragraph_rewraps_from_the_outer_half_of_its_edge_handles() { + let mut h = harness(1, |_| {}); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + let lines = |h: &Harness<'static, PdfCraftApp>| { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().text_lines(0).iter().map(|l| l.text.clone()).collect::>() + }; + let block = |h: &Harness<'static, PdfCraftApp>| { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().text_blocks(0)[0].clone() + }; + let r = h.state().views[0].page_screen_rect(0).expect("on screen"); + let k = r.width() / 200.0; + let screen = |x: f64, y: f64| egui::pos2(r.left() + x as f32 * k, r.top() + (300.0 - y as f32) * k); + let near = |a: [f64; 4], b: [f64; 4]| a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1.5); + // The box is 2 px outside the text and its handle 7 px wide, centred on the box's edge: press + // 3 px outside the box, on the handle. Near the box's top, clear of the tool's toast. + let outside = 2.0 + 3.0; + let before = block(&h); + let grip = screen(before.rect[2], before.rect[3] - 6.0) + egui::vec2(outside, 0.0); + drag(&mut h, grip, grip - egui::vec2((before.rect[2] - before.rect[0] - 45.0) as f32 * k, 0.0)); + assert_eq!(lines(&h), ["Page", "1"], "narrowed from the right handle"); + let narrowed = block(&h); + assert!(narrowed.rect[2] < before.rect[2] - 20.0 && (narrowed.rect[0] - before.rect[0]).abs() < 1.5, "{:?} → {:?}", before.rect, narrowed.rect); + let id = h.state().views[0].id; + assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Edit text")); + assert!(h.state().views[0].line_editor.is_none()); + // Saved and reopened, the paragraph keeps its new width. + let saved = h.state_mut().session.save_bytes(id).unwrap(); + let mut reopen = pdfcraft_engine::Session::new(); + let rid = reopen.open("saved.pdf", None, saved, None).unwrap(); + assert!(near(reopen.get(rid).unwrap().text_blocks(0)[0].rect, narrowed.rect)); + // Undo gives the old width back, redo the new one. + h.state_mut().undo(); + h.run_steps(2); + assert_eq!(lines(&h), ["Page 1"]); + assert!(near(block(&h).rect, before.rect), "{:?} → {:?}", before.rect, block(&h).rect); + h.state_mut().redo(); + h.run_steps(2); + assert!(near(block(&h).rect, narrowed.rect), "{:?} → {:?}", narrowed.rect, block(&h).rect); + // The left handle's outer half too. + h.state_mut().undo(); + h.run_steps(2); + let grip = screen(before.rect[0], before.rect[3] - 6.0) - egui::vec2(outside, 0.0); + let inward = (before.rect[2] - before.rect[0] - 45.0) as f32; + drag(&mut h, grip, grip + egui::vec2(inward * k, 0.0)); + assert_eq!(lines(&h), ["Page", "1"], "narrowed from the left handle"); + assert!((block(&h).rect[0] - (before.rect[0] + f64::from(inward))).abs() < 1.5, "{:?} → {:?}", before.rect, block(&h).rect); +} + /// The Pages panel, in a window tall enough to show every thumbnail of a short fixture. fn pages_panel(pages: usize) -> Harness<'static, PdfCraftApp> { let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1900.0)).build_eframe(move |_cc| { diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index c3d319f94..ac16d1a10 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1890,8 +1890,8 @@ milestone = "M7" status = "shipped" commands = ["edit.edit_text"] tools = ["text_edit"] -evidence = ["crates/edit/src/tests.rs::paragraphs_move_and_rewrap_to_a_new_width", "crates/ui-egui/tests/editing.rs::dragging_a_paragraph_moves_it_and_its_edge_rewraps_it", "crates/ui-egui/tests/editing.rs::an_open_paragraph_moves_by_its_frame_and_by_the_arrow_keys", "crates/ui-egui/tests/editing.rs::arrow_keys_nudge_a_selected_image_instead_of_turning_the_page", "crates/automation/tests/automation.rs::editing_existing_text_through_tools"] -notes = "existing paragraphs: drag the box to move it, drag the handle on its right edge to rewrap it to a new width (upright pages); the move is applied in page space through the paragraph's CTM, in its own q … Q; a click still opens it for typing (#40); once open, its frame grabs the box (click to select, drag to move) while the text keeps the caret, and the arrow keys nudge the selected box or image a point, Shift ten points (#1520)" +evidence = ["crates/edit/src/tests.rs::paragraphs_move_and_rewrap_to_a_new_width", "crates/ui-egui/tests/editing.rs::dragging_a_paragraph_moves_it_and_its_edge_rewraps_it", "crates/ui-egui/tests/editing.rs::a_paragraph_rewraps_from_the_outer_half_of_its_edge_handles", "crates/ui-egui/tests/editing.rs::an_open_paragraph_moves_by_its_frame_and_by_the_arrow_keys", "crates/ui-egui/tests/editing.rs::arrow_keys_nudge_a_selected_image_instead_of_turning_the_page", "crates/automation/tests/automation.rs::editing_existing_text_through_tools"] +notes = "existing paragraphs: drag the box to move it, drag the handle on its left or right edge to rewrap it to a new width (upright pages), including the half of the handle outside the box, which started an area selection instead (#2276); the move is applied in page space through the paragraph's CTM, in its own q … Q; a click still opens it for typing (#40); once open, its frame grabs the box (click to select, drag to move) while the text keeps the caret, and the arrow keys nudge the selected box or image a point, Shift ten points (#1520)" [[feature]] id = "edit.paragraph-detection"