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
This commit is contained in:
Guillem Hermida
2026-10-16 15:59:12 +02:00
parent f0c9901c39
commit cdb1010f40
4 changed files with 82 additions and 7 deletions
+20 -4
View File
@@ -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<Item = [f64; 4]>, 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<Rect> = lines.iter().map(|l| xf.user_rect(info, page, l.rect.map(|v| v as f32)).expand(2.0)).collect();
let boxes: Vec<Rect> = 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);
+5 -1
View File
@@ -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);
+55
View File
@@ -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::<Vec<_>>()
};
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| {
+2 -2
View File
@@ -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"