From 4cd646811ad579eb9a7a4f007710918e33026e81 Mon Sep 17 00:00:00 2001 From: Oliver Hagmann <268387159+ohagmann@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:58:51 +0200 Subject: [PATCH 1/2] M5: drag a line's ends (and a polygon's corners) to change its length and angle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Once drawn, a line or arrow could be moved but not changed: selected, it showed a frame and no handles, so a wrong length or angle meant drawing it again. A selected line, connected lines, polygon or cloud now has a handle on each end or corner instead of the frame; dragging one moves that point (Shift holds it to 45° from the point before), the new shape is drawn while dragging, and the release is one undo step. The drawing tools keep drawing from an end (a new line starts there); the Select and Fill & Sign tools pick the handles up. pdfcraft_annot::set_points sets /L or /Vertices, lets /Rect follow with room for the width and arrowheads (the rule a new comment is drawn with) and draws the appearance anew; measurements keep the points they measured, locked comments their shape. Edit::ReshapeAnnotation ("Reshape comment"), comment_edit `points`; the comment list (Summary, DocInfo, comment_list) carries a line's ends and a polygon's corners. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 + crates/annot/src/lib.rs | 52 ++++++++++++ crates/annot/src/tests.rs | 47 +++++++++++ crates/automation/src/comments.rs | 28 ++++++- crates/automation/src/tools.rs | 3 +- crates/automation/tests/automation.rs | 18 ++++ crates/engine/src/lib.rs | 13 +++ crates/render/src/inspect.rs | 14 ++++ crates/ui-egui/src/canvas.rs | 1 + crates/ui-egui/src/comments.rs | 113 +++++++++++++++++++++++--- crates/ui-egui/src/editing.rs | 1 + crates/ui-egui/src/i18n/ar.tsv | 2 + crates/ui-egui/src/i18n/bg.tsv | 2 + crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/el.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/kk.tsv | 2 + crates/ui-egui/src/i18n/lt.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/sl.tsv | 2 + crates/ui-egui/src/i18n/te.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/vi.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/tests/comments.rs | 52 ++++++++++++ parity/acrobat-features.toml | 12 +++ 35 files changed, 385 insertions(+), 15 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b1283e7751..3dc22e4a2e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -172,6 +172,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-10-20 (M5, reshape lines):** A line or arrow could only be moved after it was drawn; its length and angle stayed. A selected line, connected lines, polygon or cloud now has a handle on each end or corner: dragging one reshapes it (Shift holds it to 45°), as one undo step, with the new shape drawn while dragging. `pdfcraft_annot::set_points`, `Edit::ReshapeAnnotation`, `comment_edit points`; comment lists carry the points. Estimates unchanged. + - **2026-10-19 (M14, #3373):** The main menu and its long submenus scroll in short windows, keeping their commands reachable. Estimates unchanged. - **2026-10-19 (M3, #3387):** Save-prompt shortcuts yield to later consent and utility dialogs; a synthetic UI regression checks that an edited document stays open. Estimates unchanged. diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 587cd4466f..7c29e9f6d4 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -1774,6 +1774,48 @@ pub fn set_rect(doc: &mut Document, page: usize, index: usize, rect: [f64; 4], m apply_text_box(doc, r, rect, meta, false) } +/// At most this many points reshape a comment (a drawn polygon has a handful). +const MAX_POINTS: usize = 100_000; + +/// Reshape a line, connected lines, a polygon or a cloud: its points in user space (a line's two +/// ends, the others' corners), as dragging the comment's handles sets them. `/Rect` follows the +/// points with room for the line's width and endings, as when the comment was drawn, and the +/// appearance is redrawn. A measurement keeps the points it measured. +pub fn set_points(doc: &mut Document, page: usize, index: usize, points: &[[f64; 2]], meta: &Meta) -> Result<(), AnnotError> { + let (_, r) = annot_ref(doc, page, index)?; + unlocked(doc, r)?; + let d = annot_dict(doc, r); + if d.contains(b"Measure") { + return Err(AnnotError::Invalid("a measurement's points are where it measured".into())); + } + if points.len() > MAX_POINTS { + return Err(AnnotError::Invalid(format!("at most {MAX_POINTS} points"))); + } + let style = Style { width: border_width_of(&d), ..Style::default() }; + let ends = line_endings_of(&d).unwrap_or_default(); + let (start, end) = (ends.first().copied().unwrap_or(LineEnding::None), ends.get(1).copied().unwrap_or(LineEnding::None)); + let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned(); + let (shape, key): (Shape, &[u8]) = match (subtype.as_str(), points) { + ("Line", [from, to]) => (Shape::Line { from: *from, to: *to, start, end }, b"L"), + ("Line", _) => return Err(AnnotError::Invalid("a line has two ends".into())), + ("PolyLine", _) => (Shape::PolyLine { vertices: points.to_vec(), start, end }, b"Vertices"), + ("Polygon", _) => { + let cloud = d.get(b"BE").and_then(|b| b.as_dict()).is_some_and(|b| b.name(b"S") == Some(b"C")); + (Shape::Polygon { vertices: points.to_vec(), cloud }, b"Vertices") + } + (other, _) => return Err(AnnotError::Invalid(format!("{other} comments have no points to move"))), + }; + // Checks the points too: finite, and far enough apart to draw. + let rect = rect_for(&shape, &style)?; + let before = d; + doc.update_dict(r, |d| { + d.set(key.to_vec(), num_array(&points.concat())); + d.set(b"Rect".to_vec(), num_array(&rect)); + touch(d, meta); + })?; + redraw_or_restore(doc, r, before) +} + /// Scale a drawing's strokes into `rect`. The margin between the strokes and the old `/Rect` (half /// the line width and a little more) stays the same on each side, so the line isn't clipped. fn resize_ink(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta) -> Result<(), AnnotError> { @@ -2082,6 +2124,9 @@ pub struct Summary { pub intent: Option, /// A Fill & Sign mark ([`is_fill_sign`]). pub fill_sign: bool, + /// A line's two ends (`/L`), or the corners of connected lines, a polygon or a cloud + /// (`/Vertices`), in user space; empty for other comments. + pub points: Vec<[f32; 2]>, } fn text_value(doc: &Document, d: &Dict, key: &[u8]) -> Option { @@ -2136,6 +2181,12 @@ pub fn summaries(doc: &Document) -> Vec { } } let quads = nums(b"QuadPoints").as_chunks::<8>().0.to_vec(); + // Keep in step with `pdfcraft_render::inspect`. + let points = match subtype.as_str() { + "Line" => Some(nums(b"L")).filter(|l| l.len() == 4).map(|l| l.as_chunks::<2>().0.to_vec()).unwrap_or_default(), + "PolyLine" | "Polygon" => nums(b"Vertices").as_chunks::<2>().0.to_vec(), + _ => Vec::new(), + }; let in_reply_to = d.get(b"IRT").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).and_then(|p| text_value(doc, &p, b"NM")); out.push(Summary { page, @@ -2155,6 +2206,7 @@ pub fn summaries(doc: &Document) -> Vec { locked: annotation_flags(doc, d) & FLAG_LOCKED != 0, intent: d.get(b"IT").and_then(|o| doc.resolve(o).as_name().map(|n| String::from_utf8_lossy(n).into_owned())), fill_sign: is_fill_sign(doc, d), + points, }); } } diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 4a4dfb1064..810c21c8b5 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -1382,3 +1382,50 @@ fn free_text_properties_show_legacy_or_absent_border_width() { .unwrap(); assert_eq!(props(&doc, 0, index).unwrap().width, Some(4.0)); } + +/// A line's ends and the corners of connected lines and polygons can be moved after drawing: +/// the points change, `/Rect` follows them (with room for an arrowhead), and the appearance is +/// drawn anew. A line needs two ends far enough apart; a locked comment keeps its shape. +#[test] +fn lines_and_polygons_are_reshaped_by_their_points() { + let mut doc = fixture(); + let l = add_annotation( + &mut doc, + &new(0, Shape::Line { from: [100.0, 100.0], to: [200.0, 100.0], start: LineEnding::None, end: LineEnding::OpenArrow }), + &meta("l"), + ) + .unwrap(); + let before = ap_content(&doc, &list(&doc, 0)[l]); + set_points(&mut doc, 0, l, &[[100.0, 100.0], [100.0, 300.0]], &meta("")).unwrap(); + let doc2 = reopen(&doc); + let d = &list(&doc2, 0)[l]; + let ends: Vec = d.get(b"L").unwrap().as_array().unwrap().iter().map(|o| o.as_f64().unwrap()).collect(); + assert_eq!(ends, [100.0, 100.0, 100.0, 300.0]); + let r = rect(d); + assert!(r[0] < 100.0 && r[2] > 100.0 && r[1] < 100.0 && r[3] > 300.0, "the rectangle holds the line and its arrowhead: {r:?}"); + assert!(r[2] - r[0] < 40.0, "and no more: {r:?}"); + assert_ne!(ap_content(&doc2, d), before, "drawn anew"); + assert_eq!(summaries(&doc2).into_iter().find(|s| s.index == l).unwrap().points, vec![[100.0, 100.0], [100.0, 300.0]]); + // Two ends exactly, far enough apart. + assert!(matches!(set_points(&mut doc, 0, l, &[[1.0, 1.0]], &meta("")), Err(AnnotError::Invalid(_)))); + assert!(matches!(set_points(&mut doc, 0, l, &[[1.0, 1.0], [1.2, 1.0]], &meta("")), Err(AnnotError::Invalid(_)))); + assert!(matches!(set_points(&mut doc, 0, l, &[[f64::NAN, 1.0], [10.0, 1.0]], &meta("")), Err(AnnotError::Invalid(_)))); + // A polygon's corner. + let g = add_annotation( + &mut doc, + &new(0, Shape::Polygon { vertices: vec![[300.0, 300.0], [400.0, 300.0], [350.0, 380.0]], cloud: false }), + &meta("g"), + ) + .unwrap(); + set_points(&mut doc, 0, g, &[[300.0, 300.0], [450.0, 300.0], [350.0, 380.0], [300.0, 360.0]], &meta("")).unwrap(); + let d = &list(&doc, 0)[g]; + let v: Vec = d.get(b"Vertices").unwrap().as_array().unwrap().iter().map(|o| o.as_f64().unwrap()).collect(); + assert_eq!(v.len(), 8); + assert!(rect(d)[2] > 450.0); + // Locked: refused, and nothing changes. + set_locked(&mut doc, 0, l, true).unwrap(); + assert!(set_points(&mut doc, 0, l, &[[0.0, 0.0], [50.0, 50.0]], &meta("")).is_err()); + // Other comments have no points to move. + let s = add_annotation(&mut doc, &new(0, Shape::Rectangle { rect: [10.0, 10.0, 50.0, 50.0] }), &meta("s")).unwrap(); + assert!(matches!(set_points(&mut doc, 0, s, &[[0.0, 0.0], [50.0, 50.0]], &meta("")), Err(AnnotError::Invalid(_)))); +} diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index 302688f729..e1e0b15a74 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -174,7 +174,7 @@ impl Automation { let all = self.comments(a)?; let view = |c: &Annotation| -> Value { let p = &doc.info.pages[c.page.min(doc.info.pages.len().saturating_sub(1))]; - json!({ + let mut v = json!({ "id": c.name, "page": c.page + 1, "index": c.index + 1, @@ -184,7 +184,14 @@ impl Automation { "modified": c.modified, "rect": rect_to_view(p, c.rect), "color": c.color.map(hex), - }) + }); + // A line's ends, or the corners of connected lines, a polygon or a cloud, as + // comment_edit's `points` takes them. + if !c.points.is_empty() { + let round = |v: f32| (v * 100.0).round() / 100.0; + v["points"] = json!(c.points.iter().map(|q| p.user_to_view(q[0], q[1]).map(round)).collect::>()); + } + v }; let roots: Vec = all .iter() @@ -632,8 +639,23 @@ impl Automation { let (o, d) = (to_user(&info, 0.0, 0.0), to_user(&info, dx, dy)); edits.push(Edit::MoveAnnotation { page, index, dx: d[0] - o[0], dy: d[1] - o[1] }); } + if let Some(pts) = a.get("points") { + let wrong = || ToolError::InvalidArgs("points must be an array of [x, y] points (a line's two ends, or every corner)".into()); + let points = pts + .as_array() + .ok_or_else(wrong)? + .iter() + .map(|p| match p.as_array().map(|p| p.iter().map(|v| v.as_f64()).collect::>()).as_deref() { + Some([Some(x), Some(y)]) => Ok(to_user(&info, *x, *y)), + _ => Err(wrong()), + }) + .collect::>>()?; + edits.push(Edit::ReshapeAnnotation { page, index, points }); + } if edits.is_empty() { - return Err(ToolError::InvalidArgs("nothing to change: pass contents, color, opacity, width, fill, font_size, rect or move".into())); + return Err(ToolError::InvalidArgs( + "nothing to change: pass contents, color, opacity, width, fill, font_size, rect, move or points".into(), + )); } let edit = if edits.len() == 1 { edits.remove(0) } else { Edit::Batch { label: "Edit comment".into(), edits } }; self.apply(a, edit) diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index e154e1eebc..acfbd2f9da 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -784,7 +784,7 @@ pub fn tools() -> Vec { json!({ "doc": doc(), "sort": { "type": "string", "enum": ["page", "author", "date", "type"] }, "out": save_out, "open": open }), &["doc"], )), - t("comment_edit", "Edit a comment", "Change a comment's text, colour, opacity, line width, font size (text box/typewriter/callout), fill (rectangle/oval/polygon/text box/callout; \"none\" removes it), rectangle (rectangle/oval/text box/stamp) or position (`move` [dx, dy] in points). Stamps keep their original appearance when resized. One undo step.").with(schema( + t("comment_edit", "Edit a comment", "Change a comment's text, colour, opacity, line width, font size (text box/typewriter/callout), fill (rectangle/oval/polygon/text box/callout; \"none\" removes it), rectangle (rectangle/oval/text box/stamp), position (`move` [dx, dy] in points) or shape (`points`: a line's two ends, or the corners of connected lines, a polygon or a cloud). Stamps keep their original appearance when resized. One undo step.").with(schema( comment_ref(json!({ "contents": { "type": "string" }, "color": color(), @@ -794,6 +794,7 @@ pub fn tools() -> Vec { "font_size": { "type": "number", "minimum": 1, "maximum": 400, "description": "Font size in points of a text box, typewriter text or callout; the box grows to hold the text." }, "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 }, "move": point(), + "points": { "type": "array", "items": point(), "minItems": 2, "description": "A line's two ends, or every corner of connected lines, a polygon or a cloud, in page coordinates." }, })), &["doc"], )), diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index cbf40f1962..d2de626a6d 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -6855,3 +6855,21 @@ fn text_box_and_callout_fill_and_border_through_tools() { assert_eq!(session.get(saved).unwrap().comment_props(0, index).unwrap().fill, None); } } + +/// `comment_edit points` reshapes a line, connected lines or a polygon, in one undo step, and +/// `comment_list` reports the points it takes. +#[test] +fn comments_are_reshaped_through_the_tool() { + let dir = workdir("reshape"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "line", "from": [20, 40], "to": [120, 40] })); + let list = ok(&mut a, "comment_list", json!({ "doc": doc, "page": 1 })); + assert_eq!(list["comments"][0]["points"], json!([[20.0, 40.0], [120.0, 40.0]])); + ok(&mut a, "comment_edit", json!({ "doc": doc, "page": 1, "index": 1, "points": [[20, 40], [20, 140]] })); + let list = ok(&mut a, "comment_list", json!({ "doc": doc, "page": 1 })); + assert_eq!(list["comments"][0]["points"], json!([[20.0, 40.0], [20.0, 140.0]])); + assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Reshape comment"); + assert!(a.call("comment_edit", &json!({ "doc": doc, "page": 1, "index": 1, "points": [[1, 2]] })).is_err(), "a line has two ends"); + assert!(matches!(a.call("comment_edit", &json!({ "doc": doc, "page": 1, "index": 1, "points": "no" })), Err(ToolError::InvalidArgs(_)))); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index dfe11b25c6..0682e4ab73 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -262,6 +262,7 @@ fn scope_of(edit: &Edit) -> Scope { | Edit::ReplaceText { .. } | Edit::EraseInk { .. } | Edit::MoveAnnotation { .. } + | Edit::ReshapeAnnotation { .. } | Edit::ResizeAnnotation { .. } | Edit::StyleAnnotation { .. } | Edit::FillAnnotation { .. } @@ -1435,6 +1436,13 @@ pub enum Edit { dx: f64, dy: f64, }, + /// Reshape a line, connected lines, a polygon or a cloud: its points in user space (a line's + /// two ends, the others' corners). + ReshapeAnnotation { + page: usize, + index: usize, + points: Vec<[f64; 2]>, + }, /// Resize a rectangle, oval or text box. ResizeAnnotation { page: usize, @@ -1875,6 +1883,7 @@ pub const EDIT_LABELS: &[&str] = &[ "Lock comment", "Unlock comment", "Move comment", + "Reshape comment", "Resize comment", "Change comment properties", "Fill in {name}", @@ -1999,6 +2008,7 @@ impl Edit { Edit::LockAnnotation { locked: true, .. } => Label::of("Lock comment"), Edit::LockAnnotation { .. } => Label::of("Unlock comment"), Edit::MoveAnnotation { .. } => Label::of("Move comment"), + Edit::ReshapeAnnotation { .. } => Label::of("Reshape comment"), Edit::ResizeAnnotation { .. } => Label::of("Resize comment"), Edit::StyleAnnotation { .. } | Edit::FillAnnotation { .. } | Edit::FontSizeAnnotation { .. } | Edit::SetAnnotationInfo { .. } => { Label::of("Change comment properties") @@ -2154,6 +2164,7 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed | Edit::EraseInk { .. } | Edit::LockAnnotation { .. } | Edit::MoveAnnotation { .. } + | Edit::ReshapeAnnotation { .. } | Edit::ResizeAnnotation { .. } | Edit::StyleAnnotation { .. } | Edit::FillAnnotation { .. } @@ -2419,6 +2430,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> } Edit::LockAnnotation { page, index, locked } => pdfcraft_annot::set_locked(doc, *page, *index, *locked)?, Edit::MoveAnnotation { page, index, dx, dy } => pdfcraft_annot::move_annotation(doc, *page, *index, *dx, *dy, &cx.meta())?, + Edit::ReshapeAnnotation { page, index, points } => pdfcraft_annot::set_points(doc, *page, *index, points, &cx.meta())?, Edit::ResizeAnnotation { page, index, rect } => pdfcraft_annot::set_rect(doc, *page, *index, *rect, &cx.meta())?, Edit::StyleAnnotation { page, index, color, opacity, width, endings } => { pdfcraft_annot::set_style(doc, *page, *index, *color, *opacity, *width, endings.as_deref(), &cx.meta())?; @@ -2748,6 +2760,7 @@ fn comment_list(doc: &pdfcraft_cos::Document) -> Vec, } impl Annotation { @@ -1149,6 +1152,16 @@ impl<'a> Inspector<'a> { }; let in_reply_to = d.get(b"IRT").ok().and_then(|o| self.dict(o)).and_then(|p| self.text(p, b"NM")); let fill_sign = self.is_fill_sign(d, &subtype); + // Keep in step with `pdfcraft_annot::summaries`. + let key: &[u8] = if subtype == "Line" { b"L" } else { b"Vertices" }; + let points = match d.get(key).map(|o| self.resolve(o)) { + Ok(Object::Array(v)) if matches!(subtype.as_str(), "Line" | "PolyLine" | "Polygon") => { + let v: Vec = v.iter().map(|o| o.as_float().unwrap_or(0.0)).collect(); + let pts: Vec<[f32; 2]> = v.as_chunks::<2>().0.to_vec(); + if subtype == "Line" && pts.len() != 2 { Vec::new() } else { pts } + } + _ => Vec::new(), + }; info.annotations.push(Annotation { page, subtype, @@ -1167,6 +1180,7 @@ impl<'a> Inspector<'a> { locked: d.get(b"F").ok().and_then(|f| self.resolve(f).as_i64().ok()).unwrap_or(0) & 128 != 0, intent: self.name(d, b"IT"), fill_sign, + points, }); } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index eb9244a284..2677356f65 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -5392,6 +5392,7 @@ mod tests { locked: false, intent: None, fill_sign, + points: Vec::new(), } } diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index 7fd9b7a622..8a5679f06a 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -385,6 +385,9 @@ pub enum Gesture { Move { page: usize, index: usize, from: Pos2 }, /// Resizing a comment by one of its handles: (dx, dy) ∈ {-1, 0, 1}² says which sides move. Resize { page: usize, index: usize, handle: (i8, i8), from: Pos2, aspect_ratio: Option }, + /// Dragging point `point` of a line, connected lines, a polygon or a cloud (its `points` + /// before the drag, in user space) to where the pointer is. + Reshape { page: usize, index: usize, point: usize, points: Vec<[f64; 2]> }, } #[derive(Clone, Copy, Debug, PartialEq)] @@ -681,6 +684,42 @@ fn resizable(a: &Annotation) -> bool { matches!(a.subtype.as_str(), "Square" | "Circle" | "FreeText" | "Stamp" | "Ink") && a.intent.as_deref() != Some("FreeTextCallout") } +/// Lines, connected lines, polygons and clouds: their ends and corners have handles of their own +/// (measurements keep the points they measured, and a locked comment its shape). +fn reshapable(a: &Annotation) -> bool { + matches!(a.subtype.as_str(), "Line" | "PolyLine" | "Polygon") + && !a.points.is_empty() + && !a.locked + && !a.intent.as_deref().is_some_and(|i| i.ends_with("Dimension")) +} + +/// Whether `tool` picks up a line's end and corner handles: the Select and Fill & Sign tools. +/// The drawing tools draw from where they are pressed, so a new line starts at the end of the +/// one just drawn rather than dragging it. +fn grabs_points(tool: QuickTool) -> bool { + !matches!(tool, QuickTool::Comment(t) if t.draws() || t.clicks_points()) +} + +/// The point of `a` (an end or a corner) whose handle is at `p` on screen. +fn point_handle(cx: &PageCx<'_>, a: &Annotation, p: Pos2) -> Option { + a.points.iter().position(|q| cx.to_screen([f64::from(q[0]), f64::from(q[1])]).distance(p) <= 7.0) +} + +/// `points` with point `i` moved to `to`; with Shift, held to the nearest 45° from the point +/// before it (a line's other end; the first corner turns about the second). +pub(crate) fn reshaped(points: &[[f64; 2]], i: usize, to: [f64; 2], shift: bool) -> Vec<[f64; 2]> { + let mut out = points.to_vec(); + let anchor = if i == 0 { points.get(1) } else { points.get(i - 1) }; + let to = match anchor { + Some(a) if shift => pdfcraft_engine::measure::snap::constrain(*a, to), + _ => to, + }; + if let Some(p) = out.get_mut(i) { + *p = to; + } + out +} + const HANDLES: [(i8, i8); 8] = [(-1, -1), (0, -1), (1, -1), (1, 0), (1, 1), (0, 1), (-1, 1), (-1, 0)]; fn handle_pos(r: Rect, (hx, hy): (i8, i8)) -> Pos2 { @@ -993,7 +1032,7 @@ pub(crate) fn draw_grabs(ui: &egui::Ui, cx: &PageCx<'_>, view: &DocView) -> bool return false; } let cv = &view.comments; - if matches!(cv.gesture, Some(Gesture::Resize { page, .. }) if page == cx.page) { + if matches!(cv.gesture, Some(Gesture::Resize { page, .. } | Gesture::Reshape { page, .. }) if page == cx.page) { return true; } // A polygon being clicked out keeps its clicks, even near an old shape's handle. @@ -1009,7 +1048,8 @@ fn grabs(ui: &egui::Ui, cx: &PageCx<'_>, view: &DocView, pick: impl Fn(&Annotati if cx.hidden { return false; } - if matches!(view.comments.gesture, Some(Gesture::Move { page, .. } | Gesture::Resize { page, .. }) if page == cx.page) { + if matches!(view.comments.gesture, Some(Gesture::Move { page, .. } | Gesture::Resize { page, .. } | Gesture::Reshape { page, .. }) if page == cx.page) + { return true; } let Some(p) = press_point(ui, cx) else { return false }; @@ -1025,12 +1065,10 @@ fn press_point(ui: &egui::Ui, cx: &PageCx<'_>) -> Option { /// Whether `p` is on a resize handle of the selected comment, when it is a resizable `pick`. fn on_handle(cx: &PageCx<'_>, view: &DocView, p: Pos2, pick: impl Fn(&Annotation) -> bool) -> bool { - view.comments - .selected - .filter(|(page, _)| *page == cx.page) - .and_then(|(_, i)| cx.get(i)) - .filter(|a| pick(a) && cx.allowed && resizable(a)) - .is_some_and(|a| HANDLES.into_iter().any(|h| handle_pos(cx.screen_rect(a), h).distance(p) <= 7.0)) + view.comments.selected.filter(|(page, _)| *page == cx.page).and_then(|(_, i)| cx.get(i)).filter(|a| pick(a) && cx.allowed).is_some_and(|a| { + (resizable(a) && HANDLES.into_iter().any(|h| handle_pos(cx.screen_rect(a), h).distance(p) <= 7.0)) + || (grabs_points(cx.tool) && reshapable(a) && point_handle(cx, a, p).is_some()) + }) } fn clamp_to(r: Rect, p: Pos2) -> Pos2 { @@ -1055,9 +1093,14 @@ fn select_input( let r = cx.screen_rect(a); HANDLES.into_iter().find(|h| handle_pos(r, *h).distance(p) <= 7.0) }; + // Ends and corners of a selected line, connected lines, polygon or cloud. + let point_at = + |p: Pos2| -> Option { selected.filter(|a| cx.allowed && grabs_points(cx.tool) && reshapable(a)).and_then(|a| point_handle(cx, a, p)) }; let mut consumed = false; if let Some(p) = pointer { - if let Some(h) = handle_at(p) { + if point_at(p).is_some() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + } else if let Some(h) = handle_at(p) { ui.ctx().set_cursor_icon(match h { (0, _) => egui::CursorIcon::ResizeVertical, (_, 0) => egui::CursorIcon::ResizeHorizontal, @@ -1073,7 +1116,11 @@ fn select_input( && pressed_here && let Some(o) = origin { - if let (Some(h), Some(a)) = (handle_at(o), selected) { + if let (Some(point), Some(a)) = (point_at(o), selected) { + let points = a.points.iter().map(|q| [f64::from(q[0]), f64::from(q[1])]).collect(); + cv.gesture = Some(Gesture::Reshape { page: cx.page, index: a.index, point, points }); + consumed = true; + } else if let (Some(h), Some(a)) = (handle_at(o), selected) { // On screen, the image is also turned by the view's rotation. let turned = !cx.xf.rot.is_multiple_of(180); let aspect_ratio = view.signature_drag.aspect_ratio(cx.page, a.index).map(|ratio| if turned { ratio.recip() } else { ratio }); @@ -1095,7 +1142,7 @@ fn select_input( consumed = true; } } - if matches!(cv.gesture, Some(Gesture::Move { page, .. } | Gesture::Resize { page, .. }) if page == cx.page) { + if matches!(cv.gesture, Some(Gesture::Move { page, .. } | Gesture::Resize { page, .. } | Gesture::Reshape { page, .. }) if page == cx.page) { consumed = true; } if resp.drag_stopped() @@ -1122,6 +1169,14 @@ fn select_input( } } } + Some(Gesture::Reshape { page, index, point, points }) if page == cx.page => { + cv.gesture = None; + let shift = ui.input(|i| i.modifiers.shift); + let moved = points.get(point).is_some_and(|q| cx.to_screen(*q).distance(p) >= 2.0); + if moved { + view.pending_edit = Some(Edit::ReshapeAnnotation { page, index, points: reshaped(&points, point, cx.to_user(p), shift) }); + } + } _ => {} } } @@ -1232,6 +1287,9 @@ pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, cx: &PageCx<'_> return paint_gesture(painter, cx, view); } let shift = ui.input(|i| i.modifiers.shift); + if cx.allowed && grabs_points(cx.tool) && reshapable(a) { + return paint_points(painter, cx, view, a, pointer, shift); + } let r = cx.adjusted_rect(a, cv.gesture.as_ref(), pointer, shift, view.pending_edit.as_ref()).expand(2.0); painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(1.0, SELECT_BLUE), egui::StrokeKind::Middle); if cx.allowed && resizable(a) { @@ -1244,6 +1302,39 @@ pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, cx: &PageCx<'_> paint_gesture(painter, cx, view); } +/// A selected line, connected lines, polygon or cloud: a handle on each end or corner and, while +/// one is dragged (or its edit is on its way), the new shape over the old. +fn paint_points(painter: &egui::Painter, cx: &PageCx<'_>, view: &DocView, a: &Annotation, pointer: Option, shift: bool) { + let cv = &view.comments; + let mut points: Vec<[f64; 2]> = a.points.iter().map(|q| [f64::from(q[0]), f64::from(q[1])]).collect(); + let mut changed = false; + if let Some(Gesture::Reshape { page, index, point, points: from }) = &cv.gesture + && (*page, *index) == (cx.page, a.index) + && let Some(p) = pointer + { + points = reshaped(from, *point, cx.to_user(p), shift); + changed = true; + } else if let Some(Edit::ReshapeAnnotation { page, index, points: to }) = &view.pending_edit + && (*page, *index) == (cx.page, a.index) + { + points.clone_from(to); + changed = true; + } + let screen: Vec = points.iter().map(|q| cx.to_screen(*q)).collect(); + if changed { + let stroke = Stroke::new(1.5, SELECT_BLUE); + painter.add(if a.subtype == "Polygon" { + egui::Shape::closed_line(screen.clone(), stroke) + } else { + egui::Shape::line(screen.clone(), stroke) + }); + } + for c in screen { + painter.circle(c, 4.5, Color32::WHITE, Stroke::new(1.0, SELECT_BLUE)); + } + paint_gesture(painter, cx, view); +} + fn paint_gesture(painter: &egui::Painter, cx: &PageCx<'_>, view: &DocView) { let now = painter.ctx().input(|i| i.time); for s in view.comments.settling.iter().filter(|s| s.page == cx.page && now - s.since < SETTLE_SECS) { diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 91480ac7b5..5e766d65b9 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -969,6 +969,7 @@ fn comment_page(edit: &Edit) -> Option { | Edit::ReplyToAnnotation { page, .. } | Edit::SetAnnotationStatus { page, .. } | Edit::MoveAnnotation { page, .. } + | Edit::ReshapeAnnotation { page, .. } | Edit::ResizeAnnotation { page, .. } | Edit::StyleAnnotation { page, .. } | Edit::FillAnnotation { page, .. } diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 8697887e10..8f5a91ba8c 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2711,3 +2711,5 @@ certificate Version الإصدار Make properties default جعل الخصائص افتراضية Pages selected الصفحات المحددة Page properties خصائص الصفحة +# Lines, connected lines and polygons reshaped by their ends and corners (M5). + Reshape comment إعادة تشكيل التعليق diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 813a2c7ae8..28614a0080 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2397,3 +2397,5 @@ animation effects Off Изключени Make properties default Задаване като свойства по подразбиране Pages selected Избрани страници Page properties Свойства на страницата +# Lines, connected lines and polygons reshaped by their ends and corners (M5). + Reshape comment Промяна на формата на коментара diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 9936325523..82698a4979 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2508,3 +2508,5 @@ animation effects Off Vypnuto Make properties default Nastavit vlastnosti jako výchozí Pages selected Vybrané stránky Page properties Vlastnosti stránky +# Lines, connected lines and polygons reshaped by their ends and corners (M5). + Reshape comment Změnit tvar komentáře diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 4d9207eb9d..bc685e0df4 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2435,3 +2435,5 @@ animation effects Off Aus Make properties default Eigenschaften als Standard festlegen Pages selected Ausgewählte Seiten Page properties Seiteneigenschaften +# Lines, connected lines and polygons reshaped by their ends and corners (M5). + Reshape comment Kommentar umformen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 06fe2b540b..4b5a5ea9bd 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2440,3 +2440,5 @@ animation effects Off Ανενεργά Make properties default Ορισμός ιδιοτήτων ως προεπιλογή Pages selected Επιλεγμένες σελίδες Page properties Ιδιότητες σελίδας +# Lines, connected lines and polygons reshaped by their ends and corners (M5). + Reshape comment Αλλαγή σχήματος σχολίου diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 79c668992c..71abbaabe6 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2574,3 +2574,5 @@ animation effects Off Desactivados # Historical snapshot notice bar (View signed version / View revision). You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. Estás viendo la versión firmada de «{from}» (revisión {rev}). Es una instantánea: los cambios aquí no afectarán al original. You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. Estás viendo la revisión {rev} de «{from}». Es una instantánea: los cambios aquí no afectarán al original. +# Lines, connected lines and polygons reshaped by their ends and corners (M5). + Reshape comment Cambiar la forma del comentario diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 9bdcf0bc8a..5758900f2c 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2434,3 +2434,5 @@ animation effects Off Désactivés Make properties default Définir comme propriétés par défaut Pages selected Pages sélectionnées Page properties Propriétés de la page +# Lines, connected lines and polygons reshaped by their ends and corners (M5). + Reshape comment Modifier la forme du commentaire diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index d537677c7d..6966f98007 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2433,3 +2433,5 @@ animation effects Off Ki Make properties default Tulajdonságok beállítása alapértelmezettként Pages selected Kijelölt oldalak Page properties Oldaltulajdonságok +# Lines, connected lines and polygons reshaped by their ends and corners (M5). + Reshape comment Megjegyzés alakjának módosítása diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 35cba7a410..bc1d6d1321 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2646,3 +2646,5 @@ certificate Key usage Utilizzo chiave certificate Self-signed Autofirmato certificate SHA-1 digest Impronta SHA-1 certificate SHA-256 digest Impronta SHA-256 +# Lines, connected lines and polygons reshaped by their ends and corners (M5). + Reshape comment Modifica la forma del commento diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index e845a736da..16d795186f 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2434,3 +2434,5 @@ animation effects Off オフ Make properties default プロパティをデフォルトに設定 Pages selected 選択したページ Page properties ページのプロパティ +# Lines, connected lines and polygons reshaped by their ends and corners (M5). + Reshape comment コメントの形状を変更 diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 9acb4ec1be..4a9fe23793 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -2432,3 +2432,5 @@ animation effects Off Өшірулі Make properties default Қасиеттерді әдепкі ету Pages selected Таңдалған беттер Page properties Бет қасиеттері +# Lines, connected lines and polygons reshaped by their ends and corners (M5). + Reshape comment Пікір пішінін өзгерту diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index d1dfc33044..2899684a6f 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -2685,3 +2685,5 @@ key Space Tarpas The password must have at least 6 characters. Slaptažodį turi sudaryti bent 6 simboliai. This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. Šis failas diske pasikeitė. Įkelkite jį iš naujo, kad pamatytumėte naują versiją; neįrašyti pakeitimai bus prarasti. theme Appearance Išvaizda +# Lines, connected lines and polygons reshaped by their ends and corners (M5). + Reshape comment Keisti komentaro formą diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index c432a6a92e..b6539c848a 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2410,3 +2410,5 @@ animation effects Off Wyłączone Make properties default Ustaw właściwości jako domyślne Pages selected Zaznaczone strony Page properties Właściwości strony +# Lines, connected lines and polygons reshaped by their ends and corners (M5). + Reshape comment Zmień kształt komentarza diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 89ff80f9dd..3d2b4ef68e 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2448,3 +2448,5 @@ animation effects Off Desativados Make properties default Tornar propriedades padrão Pages selected Páginas selecionadas Page properties Propriedades da página +# Lines, connected lines and polygons reshaped by their ends and corners (M5). + Reshape comment Alterar a forma do comentário diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 73f21aa0d8..9649324d76 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2347,3 +2347,5 @@ animation effects Off Выкл Make properties default Сделать свойства стандартными Pages selected Выделенные страницы Page properties Свойства страницы +# Lines, connected lines and polygons reshaped by their ends and corners (M5). + Reshape comment Изменить форму комментария diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index dd169bc961..1d1a94146f 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -2439,3 +2439,5 @@ animation effects Off Izklopljeni Make properties default Nastavi lastnosti kot privzete Pages selected Izbrane strani Page properties Lastnosti strani +# Lines, connected lines and polygons reshaped by their ends and corners (M5). + Reshape comment Spremeni obliko komentarja diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 47aee36f8b..b24857baa9 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2343,3 +2343,5 @@ animation effects Off ఆఫ్ Make properties default లక్షణాలను డిఫాల్ట్‌గా చేయి Pages selected ఎంచుకున్న పేజీలు Page properties పేజీ లక్షణాలు +# Lines, connected lines and polygons reshaped by their ends and corners (M5). + Reshape comment వ్యాఖ్య ఆకారాన్ని మార్చు diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 95ca0b608c..ba4f925ab7 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2430,3 +2430,5 @@ animation effects Off Kapalı Make properties default Özellikleri varsayılan yap Pages selected Seçilen sayfalar Page properties Sayfa özellikleri +# Lines, connected lines and polygons reshaped by their ends and corners (M5). + Reshape comment Yorumun şeklini değiştir diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 7993a06185..ed2a631a86 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2456,3 +2456,5 @@ animation effects Off Вимкнено Make properties default Зробити властивості типовими Pages selected Вибрані сторінки Page properties Властивості сторінки +# Lines, connected lines and polygons reshaped by their ends and corners (M5). + Reshape comment Змінити форму коментаря diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index bae599bf6a..6200bc1e96 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2687,3 +2687,5 @@ key Space Space # Historical snapshot notice bar (View signed version / View revision). You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. Bạn đang xem phiên bản đã ký của “{from}” (bản sửa đổi {rev}). Đây là ảnh nhanh: các thay đổi ở đây không được áp dụng đến bản gốc. You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. Bạn đang xem bản sửa đổi {rev} của “{from}”. Đây là ảnh nhanh: các thay đổi ở đây không được áp dụng đến bản gốc. +# Lines, connected lines and polygons reshaped by their ends and corners (M5). + Reshape comment Đổi hình dạng nhận xét diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 1ddecc655f..707bd7d365 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2398,3 +2398,5 @@ animation effects Off 关 Make properties default 将属性设为默认 Pages selected 选定的页面 Page properties 页面属性 +# Lines, connected lines and polygons reshaped by their ends and corners (M5). + Reshape comment 更改注释形状 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index bb5ec3649b..b1d26f226d 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2384,3 +2384,5 @@ animation effects Off 關 Make properties default 將內容設為預設 Pages selected 選取的頁面 Page properties 頁面屬性 +# Lines, connected lines and polygons reshaped by their ends and corners (M5). + Reshape comment 變更註解形狀 diff --git a/crates/ui-egui/tests/comments.rs b/crates/ui-egui/tests/comments.rs index 01166a41d1..c88c873d09 100644 --- a/crates/ui-egui/tests/comments.rs +++ b/crates/ui-egui/tests/comments.rs @@ -552,6 +552,58 @@ fn comments_are_selected_moved_and_deleted_with_the_select_tool() { assert_eq!(comments(&h).len(), 1); } +/// A line's ends have handles: dragging one with the Select tool changes the line's length and +/// angle (Shift holds it to 45°), as one undo step. The line tool keeps drawing from an end: a +/// new line starts there rather than dragging the one just drawn. +#[test] +fn line_ends_are_dragged_to_change_length_and_angle() { + let mut h = harness(|app| app.set_option("quick", "line").unwrap()); + drag_pt(&mut h, (40.0, 100.0), (140.0, 100.0)); + let line = comments(&h).into_iter().find(|a| a.subtype == "Line").expect("a line"); + assert_eq!(line.points.len(), 2); + let ends = |h: &Harness<'static, PdfCraftApp>| comments(h).into_iter().find(|a| a.index == line.index).unwrap().points; + // With the line tool, a drag from the end just drawn draws another line. + let first = ends(&h); + drag_pt(&mut h, (first[1][0], first[1][1]), (140.0, 40.0)); + assert_eq!(comments(&h).iter().filter(|a| a.subtype == "Line").count(), 2, "a second line"); + assert_eq!(ends(&h), first, "the first one stays"); + h.state_mut().set_option("quick", "select").unwrap(); + click_pt(&mut h, (90.0, 100.0)); + assert_eq!(h.state().views[0].comments.selected, Some((0, line.index))); + // Drag the right end up and further right: longer, and turned. + let [_, end] = [line.points[0], line.points[1]]; + drag_pt(&mut h, (end[0], end[1]), (200.0, 160.0)); + let p = ends(&h); + assert!((p[0][0] - 40.0).abs() < 1.0 && (p[0][1] - 100.0).abs() < 1.0, "the other end stays: {p:?}"); + assert!((p[1][0] - 200.0).abs() < 1.0 && (p[1][1] - 160.0).abs() < 1.0, "the dragged end follows: {p:?}"); + let id = h.state().views[0].id; + assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Reshape comment")); + // Shift: held to the nearest 45° from the other end (39° here). + let a = at(&h, 200.0, 160.0); + let b = at(&h, 150.0, 190.0); + h.hover_at(a); + h.run_steps(1); + h.event(egui::Event::ModifiersChanged(egui::Modifiers::SHIFT)); + h.drag_at(a); + h.run_steps(1); + for k in 1..=4 { + h.hover_at(a + (b - a) * (k as f32 / 4.0)); + h.run_steps(1); + } + h.drop_at(b); + h.run_steps(3); + h.event(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); + h.run_steps(1); + let p = ends(&h); + let (dx, dy) = (p[1][0] - p[0][0], p[1][1] - p[0][1]); + assert!(dx > 50.0 && (dx - dy).abs() < 0.5, "45°: {p:?}"); + // Undo puts the line back as it was before the drag. + h.state_mut().undo(); + h.run_steps(2); + let p = ends(&h); + assert!((p[1][0] - 200.0).abs() < 1.0 && (p[1][1] - 160.0).abs() < 1.0, "{p:?}"); +} + #[test] fn a_shape_just_drawn_resizes_from_its_handles_without_the_select_tool() { // #2338: the new shape showed its handles, but only the Select tool answered them. diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 7aca7d5c8b..790a461ef7 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3959,6 +3959,18 @@ tools = ["comment_delete"] evidence = ["crates/ui-egui/tests/comments.rs::comments_are_selected_moved_and_deleted_with_the_select_tool", "crates/annot/src/tests.rs::delete_takes_popup_and_reply_chain"] notes = "deletes the pop-up and the reply thread" +[[feature]] +id = "comment.reshape" +title = "Change a line's ends and a polygon's corners after drawing" +area = "E" +tier = "P1" +milestone = "M5" +status = "shipped" +commands = [] +tools = ["comment_edit"] +evidence = ["crates/annot/src/tests.rs::lines_and_polygons_are_reshaped_by_their_points", "crates/ui-egui/tests/comments.rs::line_ends_are_dragged_to_change_length_and_angle", "crates/automation/tests/automation.rs::comments_are_reshaped_through_the_tool"] +notes = "A selected line, arrow, connected lines, polygon or cloud has a handle on each end or corner (instead of the frame); dragging one changes the length and angle (Shift holds it to 45° from the point before), with the new shape drawn while dragging, as one undo step (Edit::ReshapeAnnotation). The drawing tools keep drawing from an end (a new line starts there); the Select tool reshapes. /Rect follows the points with room for the width and arrowheads, and the appearance is drawn anew. Headless: comment_edit `points`; comment_list reports them. Measurements keep the points they measured; locked comments keep their shape. Not yet: adding or removing a corner." + [[feature]] id = "comment.edit-comment-text" title = "Edit comment text" From ea0c1ae106b9643684114fdc6431d673e17a006e Mon Sep 17 00:00:00 2001 From: Oliver Hagmann <268387159+ohagmann@users.noreply.github.com> Date: Wed, 21 Oct 2026 08:26:54 +0200 Subject: [PATCH 2/2] Review: a selected line or polygon moved by its body is drawn where it is going CodeRabbit on #3788: the handles and outline of a selected line, connected lines, polygon or cloud followed a dragged end but not a drag of the whole comment, which kept its old handles until the page was drawn again (the frame such comments had before showed the move). The points now follow a body drag and the released move as well (changing_points, one rule for reshaping and moving). Test: a_moved_or_reshaped_line_is_drawn_at_its_new_points. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/comments.rs | 63 ++++++++++++++++++++++++++-------- 1 file changed, 49 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index 8a5679f06a..3d2a3d4f41 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -1304,22 +1304,39 @@ pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, cx: &PageCx<'_> /// A selected line, connected lines, polygon or cloud: a handle on each end or corner and, while /// one is dragged (or its edit is on its way), the new shape over the old. +/// The points a selected line, polygon or cloud is drawn with while it changes: an end or corner +/// being dragged, the whole comment being moved by its body, or the edit just released (until the +/// page is drawn again). `None` while it isn't changing. +fn changing_points( + gesture: Option<&Gesture>, + pending: Option<&Edit>, + (page, index): (usize, usize), + points: &[[f64; 2]], + to_user: impl Fn(Pos2) -> [f64; 2], + pointer: Option, + shift: bool, +) -> Option> { + let shifted = |dx: f64, dy: f64| points.iter().map(|q| [q[0] + dx, q[1] + dy]).collect(); + match (gesture, pending) { + (Some(Gesture::Reshape { page: p, index: i, point, points: from }), _) if (*p, *i) == (page, index) => { + pointer.map(|at| reshaped(from, *point, to_user(at), shift)) + } + (Some(Gesture::Move { page: p, index: i, from }), _) if (*p, *i) == (page, index) => pointer.map(|at| { + let (a, b) = (to_user(*from), to_user(at)); + shifted(b[0] - a[0], b[1] - a[1]) + }), + (_, Some(Edit::ReshapeAnnotation { page: p, index: i, points: to })) if (*p, *i) == (page, index) => Some(to.clone()), + (_, Some(Edit::MoveAnnotation { page: p, index: i, dx, dy })) if (*p, *i) == (page, index) => Some(shifted(*dx, *dy)), + _ => None, + } +} + fn paint_points(painter: &egui::Painter, cx: &PageCx<'_>, view: &DocView, a: &Annotation, pointer: Option, shift: bool) { let cv = &view.comments; - let mut points: Vec<[f64; 2]> = a.points.iter().map(|q| [f64::from(q[0]), f64::from(q[1])]).collect(); - let mut changed = false; - if let Some(Gesture::Reshape { page, index, point, points: from }) = &cv.gesture - && (*page, *index) == (cx.page, a.index) - && let Some(p) = pointer - { - points = reshaped(from, *point, cx.to_user(p), shift); - changed = true; - } else if let Some(Edit::ReshapeAnnotation { page, index, points: to }) = &view.pending_edit - && (*page, *index) == (cx.page, a.index) - { - points.clone_from(to); - changed = true; - } + let points: Vec<[f64; 2]> = a.points.iter().map(|q| [f64::from(q[0]), f64::from(q[1])]).collect(); + let changing = changing_points(cv.gesture.as_ref(), view.pending_edit.as_ref(), (cx.page, a.index), &points, |p| cx.to_user(p), pointer, shift); + let changed = changing.is_some(); + let points = changing.unwrap_or(points); let screen: Vec = points.iter().map(|q| cx.to_screen(*q)).collect(); if changed { let stroke = Stroke::new(1.5, SELECT_BLUE); @@ -2043,6 +2060,24 @@ pub fn status_badge(state: &str) -> Option<(&'static str, &'static str, Color32) #[cfg(test)] mod tests { use super::*; + + /// A selected line is drawn where it is going: moved by its body (both ends follow the + /// pointer), with an end dragged (that end alone), and as released until the page is drawn. + #[test] + fn a_moved_or_reshaped_line_is_drawn_at_its_new_points() { + let line = [[10.0, 10.0], [50.0, 10.0]]; + let to_user = |p: Pos2| [f64::from(p.x), f64::from(p.y)]; + let moving = Gesture::Move { page: 0, index: 3, from: pos2(20.0, 10.0) }; + let at = Some(pos2(25.0, 30.0)); + assert_eq!(changing_points(Some(&moving), None, (0, 3), &line, to_user, at, false), Some(vec![[15.0, 30.0], [55.0, 30.0]])); + assert_eq!(changing_points(Some(&moving), None, (0, 4), &line, to_user, at, false), None, "another comment's move"); + let released = Edit::MoveAnnotation { page: 0, index: 3, dx: 5.0, dy: 20.0 }; + assert_eq!(changing_points(None, Some(&released), (0, 3), &line, to_user, None, false), Some(vec![[15.0, 30.0], [55.0, 30.0]])); + let dragging = Gesture::Reshape { page: 0, index: 3, point: 1, points: line.to_vec() }; + let to = Some(pos2(60.0, 10.0)); + assert_eq!(changing_points(Some(&dragging), None, (0, 3), &line, to_user, to, false), Some(vec![[10.0, 10.0], [60.0, 10.0]])); + assert_eq!(changing_points(None, None, (0, 3), &line, to_user, to, false), None, "not changing"); + } use pdfcraft_render::TextGlyph; /// One glyph per character, `w` points wide, on a line of `em`-point text whose baseline is at `y`, drawn