diff --git a/ROADMAP.md b/ROADMAP.md index 96f6ede4b9..36b023cab8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -175,6 +175,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-20 (M6, calculation order page):** Prepare a form ▸ Calculation order, a page of its own, lists the calculated fields in the order they calculate with their formulas and values, moved with Up/Down arrows (as Acrobat's Set Field Calculation Order), Alt+↑/↓ or by dragging. A card says what is wrong with the order and holds Fix order; a loop has a card of its own that opens one of its formulas. On the page, each calculated field shows its step, and arrows show what the field in focus reads and what reads it. Estimates unchanged. - **2026-10-20 (M6, calculation order):** The order a form's fields calculate in (`/CO`) is read, checked, fixed and set headlessly with `form_calculation_order`: it says which fields read one calculated later (they show stale values), which read each other in a loop, and which the PDF's order leaves out; Fix order moves only what must move, in one undo step, and the form recalculates. Editing a formula no longer moves its field to the end of the order, and a field the order lists twice calculates once. Estimates unchanged. - **2026-10-19 (M6, renames keep formulas):** Renaming a field renames it in every calculation that reads it, in the same undo step: `AFSimple_Calculate` field lists, simplified field notation (the comment and the JavaScript after it) and `getField` calls naming it in one string literal; the rest of each script is kept as written. A sum used to drop the renamed field, notation read it as 0 and a script kept a stale value. Field Properties says how many formulas changed and `form_set_props` lists them as `formulas_updated`. Names built at run time and other events' scripts are not rewritten. Estimates unchanged. +- **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. diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 0cdd8f3887..435027ae40 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -1788,6 +1788,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> { @@ -2096,6 +2138,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 { @@ -2150,6 +2195,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, @@ -2169,6 +2220,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 ba15b81a6b..926397c1d2 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -1405,3 +1405,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 59ecf04252..cd3374507f 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -790,7 +790,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(), @@ -800,6 +800,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 2c87162d5d..189019ee1e 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -6973,3 +6973,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 2125a5b9d8..2adbb3bae2 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -263,6 +263,7 @@ fn scope_of(edit: &Edit) -> Scope { | Edit::ReplaceText { .. } | Edit::EraseInk { .. } | Edit::MoveAnnotation { .. } + | Edit::ReshapeAnnotation { .. } | Edit::ResizeAnnotation { .. } | Edit::StyleAnnotation { .. } | Edit::FillAnnotation { .. } @@ -1442,6 +1443,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, @@ -1887,6 +1895,7 @@ pub const EDIT_LABELS: &[&str] = &[ "Lock comment", "Unlock comment", "Move comment", + "Reshape comment", "Resize comment", "Change comment properties", "Fill in {name}", @@ -2012,6 +2021,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") @@ -2168,6 +2178,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 { .. } @@ -2434,6 +2445,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())?; @@ -2771,6 +2783,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 3d78f952d6..848e718fd6 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -5414,6 +5414,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 13281f263a..3cef94f624 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)] @@ -713,6 +716,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 { @@ -1025,7 +1064,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. @@ -1041,7 +1080,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 }; @@ -1057,12 +1097,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 { @@ -1087,9 +1125,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, @@ -1105,7 +1148,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 }); @@ -1127,7 +1174,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() @@ -1154,6 +1201,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) }); + } + } _ => {} } } @@ -1264,6 +1319,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) { @@ -1276,6 +1334,56 @@ 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. +/// 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 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); + 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) { @@ -1984,6 +2092,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 diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 977c88ade3..49f3706598 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -972,6 +972,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 f6b3d093d5..3ba63e9b99 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2757,3 +2757,5 @@ certificate Version الإصدار Paragraph spacing تباعد الفقرات Less أقل Other options خيارات أخرى +# 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 90e152c27b..5ffb53a3cc 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2444,3 +2444,5 @@ animation effects Off Изключени Paragraph spacing Разстояние между абзаците Less По-малко Other options Други опции +# 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 1366cb2546..b97ea9d448 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2555,3 +2555,5 @@ animation effects Off Vypnuto Paragraph spacing Mezera mezi odstavci Less Méně Other options Další možnosti +# 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 ac136cd91a..4569808fff 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2482,3 +2482,5 @@ animation effects Off Aus Paragraph spacing Absatzabstand Less Weniger Other options Weitere Optionen +# 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 133e8ca8e6..4bd39b3709 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2487,3 +2487,5 @@ animation effects Off Ανενεργά Paragraph spacing Απόσταση παραγράφων Less Λιγότερα Other options Άλλες επιλογές +# 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 7c7b7c8d71..868a6ab577 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2621,3 +2621,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 aa83f81970..5d3cbb1206 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2481,3 +2481,5 @@ animation effects Off Désactivés Paragraph spacing Espacement des paragraphes Less Moins Other options Autres options +# 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 123be343f3..e18b4cefc0 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2480,3 +2480,5 @@ animation effects Off Ki Paragraph spacing Bekezdéstávolság Less Kevesebb Other options Egyéb beállítások +# 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 71715dc5d7..9be91dbcd5 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2693,3 +2693,5 @@ certificate SHA-256 digest Impronta SHA-256 Paragraph spacing Spaziatura paragrafi Less Meno Other options Altre opzioni +# 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 f73e753def..42c96799d0 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2481,3 +2481,5 @@ animation effects Off オフ Paragraph spacing 段落間隔 Less 折りたたむ Other options その他のオプション +# 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 7f113d4bfc..902366b5bd 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -2479,3 +2479,5 @@ animation effects Off Өшірулі Paragraph spacing Абзац аралығы Less Азырақ Other options Басқа параметрлер +# 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 6448c6949e..7007f2af9b 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -2731,3 +2731,5 @@ theme Appearance Išvaizda Paragraph spacing Tarpas tarp pastraipų Less Mažiau Other options Kitos parinktys +# 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 578b512bb7..3f2a3a604e 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2457,3 +2457,5 @@ animation effects Off Wyłączone Paragraph spacing Odstęp między akapitami Less Mniej Other options Inne opcje +# 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 ee12fe7ab2..73fb01255b 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2495,3 +2495,5 @@ animation effects Off Desativados Paragraph spacing Espaçamento entre parágrafos Less Menos Other options Outras opções +# 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 3baec45600..7e50b2a90e 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2394,3 +2394,5 @@ animation effects Off Выкл Paragraph spacing Интервал между абзацами Less Меньше Other options Другие параметры +# 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 5ba9dc90c3..3bf1ab551b 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -2486,3 +2486,5 @@ animation effects Off Izklopljeni Paragraph spacing Razmik med odstavki Less Manj Other options Druge možnosti +# 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 e0d6a2b34c..da5bc68f24 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2390,3 +2390,5 @@ animation effects Off ఆఫ్ Paragraph spacing పేరా అంతరం Less తక్కువ Other options ఇతర ఎంపికలు +# 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 48d34a30da..e17792fcd7 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2477,3 +2477,5 @@ animation effects Off Kapalı Paragraph spacing Paragraf aralığı Less Daha az Other options Diğer seçenekler +# 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 246a4a2c56..cf634d734b 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2503,3 +2503,5 @@ animation effects Off Вимкнено Paragraph spacing Інтервал між абзацами Less Менше Other options Інші параметри +# 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 56caf38859..4ee53e9b87 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2733,3 +2733,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 645c0ab301..f99808c356 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2445,3 +2445,5 @@ animation effects Off 关 Paragraph spacing 段落间距 Less 收起 Other options 其他选项 +# 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 712f0666a5..9382739d1e 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2431,3 +2431,5 @@ animation effects Off 關 Paragraph spacing 段落間距 Less 收起 Other options 其他選項 +# 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 601d4f8b22..203abfca7a 100644 --- a/crates/ui-egui/tests/comments.rs +++ b/crates/ui-egui/tests/comments.rs @@ -645,6 +645,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 ad00b0115d..64d59972bf 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"