mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-31 21:48:09 -06:00
Merge PR #3788: M5: drag a line's ends (and a polygon's corners) to change its length and angle
ROADMAP.md: union of progress-log lines. crates/ui-egui/src/i18n/*.tsv (20 catalogs): union of appended keys.
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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<String>,
|
||||
/// 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<String> {
|
||||
@@ -2150,6 +2195,12 @@ pub fn summaries(doc: &Document) -> Vec<Summary> {
|
||||
}
|
||||
}
|
||||
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<Summary> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<f64> = 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<f64> = 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(_))));
|
||||
}
|
||||
|
||||
@@ -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::<Vec<_>>());
|
||||
}
|
||||
v
|
||||
};
|
||||
let roots: Vec<Value> = 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::<Vec<_>>()).as_deref() {
|
||||
Some([Some(x), Some(y)]) => Ok(to_user(&info, *x, *y)),
|
||||
_ => Err(wrong()),
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
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)
|
||||
|
||||
@@ -790,7 +790,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
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<ToolDef> {
|
||||
"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"],
|
||||
)),
|
||||
|
||||
@@ -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(_))));
|
||||
}
|
||||
|
||||
@@ -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<pdfcraft_render::Annotation
|
||||
locked: s.locked,
|
||||
intent: s.intent,
|
||||
fill_sign: s.fill_sign,
|
||||
points: s.points,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -386,6 +386,9 @@ pub struct Annotation {
|
||||
/// A Fill & Sign mark: typed text, a check, cross, dot or line, or a typed, drawn or picture
|
||||
/// signature or initials (the rules of `pdfcraft_annot::is_fill_sign`).
|
||||
pub fill_sign: bool,
|
||||
/// The points that shape a line (its two ends, `/L`) or connected lines, a polygon or a
|
||||
/// cloud (their corners, `/Vertices`), in user space; empty for other comments.
|
||||
pub points: Vec<[f32; 2]>,
|
||||
}
|
||||
|
||||
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<f32> = 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5414,6 +5414,7 @@ mod tests {
|
||||
locked: false,
|
||||
intent: None,
|
||||
fill_sign,
|
||||
points: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+137
-11
@@ -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<f32> },
|
||||
/// 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<usize> {
|
||||
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<Pos2> {
|
||||
|
||||
/// 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<usize> { 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<Pos2>,
|
||||
shift: bool,
|
||||
) -> Option<Vec<[f64; 2]>> {
|
||||
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<Pos2>, 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<Pos2> = 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
|
||||
|
||||
@@ -972,6 +972,7 @@ fn comment_page(edit: &Edit) -> Option<usize> {
|
||||
| Edit::ReplyToAnnotation { page, .. }
|
||||
| Edit::SetAnnotationStatus { page, .. }
|
||||
| Edit::MoveAnnotation { page, .. }
|
||||
| Edit::ReshapeAnnotation { page, .. }
|
||||
| Edit::ResizeAnnotation { page, .. }
|
||||
| Edit::StyleAnnotation { page, .. }
|
||||
| Edit::FillAnnotation { page, .. }
|
||||
|
||||
@@ -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 إعادة تشكيل التعليق
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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 Промяна на формата на коментара
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 16.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 51.
|
@@ -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 Αλλαγή σχήματος σχολίου
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 477 and column 9.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 60.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 39.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 44.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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 コメントの形状を変更
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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 Пікір пішінін өзгерту
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 856 and column 9.
|
@@ -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ą
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 404 and column 9.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -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 Изменить форму комментария
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -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 వ్యాఖ్య ఆకారాన్ని మార్చు
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 13 and column 16.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 855 and column 9.
|
@@ -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 Змінити форму коментаря
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 491 and column 12.
|
@@ -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 更改注释形状
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -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 變更註解形狀
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user