M5: Eraser for drawings (Draw flyout): rubs out the parts of ink strokes it passes over, splitting strokes and deleting emptied drawings, in one undo step

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-02 04:12:36 -04:00
co-authored by Claude Opus 5.5
parent 968d26a41e
commit e0d954f10a
7 changed files with 179 additions and 8 deletions
+86
View File
@@ -1562,3 +1562,89 @@ pub fn add_text_replacement(
})?;
Ok(s)
}
/// The Eraser (Draw tools): remove the parts of drawing `(page, index)` within `radius` points
/// of `path`, splitting strokes where they are cut. A drawing with nothing left is deleted.
/// Returns whether anything was erased.
pub fn erase_ink(doc: &mut Document, page: usize, index: usize, path: &[[f64; 2]], radius: f64, meta: &Meta) -> Result<bool, AnnotError> {
let (_, r) = annot_ref(doc, page, index)?;
unlocked(doc, r)?;
let d = annot_dict(doc, r);
if d.name(b"Subtype") != Some(b"Ink") {
return Err(AnnotError::Invalid("only drawings can be erased".into()));
}
if path.is_empty() || !path.iter().all(|p| finite(p)) || !radius.is_finite() || radius <= 0.0 {
return Err(AnnotError::Invalid("invalid eraser path".into()));
}
let strokes: Vec<Vec<[f64; 2]>> = d
.get(b"InkList")
.map(|l| doc.resolve(l))
.and_then(|l| l.as_array().cloned())
.unwrap_or_default()
.iter()
.map(|s| {
let v: Vec<f64> = doc.resolve(s).as_array().map(|a| a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect()).unwrap_or_default();
v.chunks_exact(2).map(|p| [p[0], p[1]]).collect()
})
.collect();
// Distance from a point to the eraser's path (segments, or a single point).
let near = |p: [f64; 2]| -> bool {
if path.len() == 1 {
return (p[0] - path[0][0]).hypot(p[1] - path[0][1]) <= radius;
}
path.windows(2).any(|w| {
let (a, b) = (w[0], w[1]);
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
let len2 = dx * dx + dy * dy;
let t = if len2 > 0.0 { (((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2).clamp(0.0, 1.0) } else { 0.0 };
(p[0] - (a[0] + t * dx)).hypot(p[1] - (a[1] + t * dy)) <= radius
})
};
let mut kept: Vec<Vec<[f64; 2]>> = Vec::new();
let mut erased = false;
for s in &strokes {
// Densify so long segments are cut where the eraser crosses them.
let mut pts: Vec<[f64; 2]> = Vec::new();
for w in s.windows(2) {
let n = ((w[1][0] - w[0][0]).hypot(w[1][1] - w[0][1]) / (radius / 2.0)).ceil().clamp(1.0, 2000.0) as usize;
for k in 0..n {
let t = k as f64 / n as f64;
pts.push([w[0][0] + (w[1][0] - w[0][0]) * t, w[0][1] + (w[1][1] - w[0][1]) * t]);
}
}
if let Some(last) = s.last() {
pts.push(*last);
}
let mut run: Vec<[f64; 2]> = Vec::new();
for p in pts {
if near(p) {
erased = true;
if run.len() >= 2 {
kept.push(std::mem::take(&mut run));
}
run.clear();
} else {
run.push(p);
}
}
if run.len() >= 2 {
kept.push(run);
}
}
if !erased {
return Ok(false);
}
if kept.is_empty() {
delete_annotation(doc, page, index)?;
return Ok(true);
}
let width = d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()).unwrap_or(1.0);
let rect = grow(bounds(kept.iter().flatten().copied()).unwrap_or_default(), width / 2.0 + 1.0);
doc.update_dict(r, |d| {
d.set(b"InkList".to_vec(), Object::Array(kept.iter().map(|s| num_array(&s.concat())).collect()));
d.set(b"Rect".to_vec(), num_array(&rect));
touch(d, meta);
})?;
set_appearance(doc, r)?;
Ok(true)
}
+19
View File
@@ -560,3 +560,22 @@ fn files_attach_as_comments() {
.is_err()
);
}
#[test]
fn the_eraser_cuts_strokes_and_removes_empty_drawings() {
let mut doc = fixture();
let line = |y: f64| (0..=10).map(|k| [100.0 + k as f64 * 20.0, y]).collect::<Vec<_>>();
let shape = Shape::Ink { strokes: vec![line(500.0), line(400.0)] };
let i = add_annotation(&mut doc, &new(0, shape), &meta("i")).unwrap();
// A vertical swipe through the middle of both lines.
assert!(erase_ink(&mut doc, 0, i, &[[200.0, 550.0], [200.0, 350.0]], 5.0, &meta("e")).unwrap());
let d = &list(&doc, 0)[i];
let strokes = d.get(b"InkList").unwrap().as_array().unwrap().len();
assert_eq!(strokes, 4, "each line cut in two");
assert!(!erase_ink(&mut doc, 0, i, &[[600.0, 100.0]], 5.0, &meta("e")).unwrap(), "nothing under the eraser");
// Rubbing out everything deletes the drawing.
let all: Vec<[f64; 2]> = (0..=20).map(|k| [100.0 + k as f64 * 10.0, 500.0]).chain((0..=20).map(|k| [100.0 + k as f64 * 10.0, 400.0])).collect();
erase_ink(&mut doc, 0, i, &all[..21], 8.0, &meta("e")).unwrap();
erase_ink(&mut doc, 0, i, &all[21..], 8.0, &meta("e")).unwrap();
assert!(!list(&doc, 0).iter().any(|d| d.name(b"Subtype") == Some(b"Ink")));
}
+1
View File
@@ -188,6 +188,7 @@ pub const COMMANDS: &[CommandSpec] = &[
c("comment.caret", "Insert text", None, None, Annotate, "text-cursor-input"),
c("comment.replace", "Replace text", None, None, Annotate, "replace"),
c("comment.attach", "Attach a file", None, None, Annotate, "paperclip"),
c("comment.eraser", "Erase drawings", None, None, Annotate, "eraser"),
c("form.fields", "Form fields panel", VIEW, None, Document, "list"),
c("form.clear", "Clear form", EDIT, None, FillForms, "eraser"),
c("comment.flatten", "Flatten comments", None, None, HasComments, "layers"),
+13
View File
@@ -91,6 +91,7 @@ fn scope_of(edit: &Edit) -> Scope {
| Edit::MarkAnnotation { .. }
| Edit::LockAnnotation { .. }
| Edit::ReplaceText { .. }
| Edit::EraseInk { .. }
| Edit::MoveAnnotation { .. }
| Edit::ResizeAnnotation { .. }
| Edit::StyleAnnotation { .. }
@@ -529,6 +530,13 @@ pub enum Edit {
state: ReviewState,
author: String,
},
/// The Eraser: rub out the parts of drawing `(page, index)` near `path` (user space).
EraseInk {
page: usize,
index: usize,
path: Vec<[f64; 2]>,
radius: f64,
},
/// Replace Text: strike out `quads` and add a grouped caret holding `text`.
ReplaceText {
page: usize,
@@ -757,6 +765,7 @@ impl Edit {
Edit::ReplyToAnnotation { .. } => "Reply".into(),
Edit::SetAnnotationStatus { state, .. } => format!("Set status {}", state.name()),
Edit::ReplaceText { .. } => "Replace text".into(),
Edit::EraseInk { .. } => "Erase".into(),
Edit::MarkAnnotation { marked: true, .. } => "Mark with checkmark".into(),
Edit::MarkAnnotation { .. } => "Remove checkmark".into(),
Edit::LockAnnotation { locked: true, .. } => "Lock comment".into(),
@@ -874,6 +883,7 @@ fn check_permission(edit: &Edit, p: &printcraft_cos::Permissions) -> Result<(),
| Edit::SetAnnotationStatus { .. }
| Edit::MarkAnnotation { .. }
| Edit::ReplaceText { .. }
| Edit::EraseInk { .. }
| Edit::LockAnnotation { .. }
| Edit::MoveAnnotation { .. }
| Edit::ResizeAnnotation { .. }
@@ -1041,6 +1051,9 @@ fn run_edit(doc: &mut printcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -
Edit::SetAnnotationStatus { page, index, state, author } => {
printcraft_annot::set_review_state(doc, *page, *index, *state, author, &cx.meta())?;
}
Edit::EraseInk { page, index, path, radius } => {
printcraft_annot::erase_ink(doc, *page, *index, path, *radius, &cx.meta())?;
}
Edit::ReplaceText { page, quads, text, author, strike, caret } => {
printcraft_annot::add_text_replacement(doc, *page, quads, text, author, strike, caret, &cx.meta())?;
}
+39 -4
View File
@@ -42,6 +42,7 @@ pub enum CommentTool {
Caret,
ReplaceText,
Attach,
Eraser,
}
/// The quick-bar flyout groups, in Acrobat's order: Comment ▸, Highlight ▸, Draw ▸.
@@ -57,10 +58,12 @@ pub const GROUPS: [&[CommentTool]; 3] = [
CommentTool::PolyLine,
CommentTool::Polygon,
CommentTool::Cloud,
CommentTool::Eraser,
],
];
pub const ALL: [CommentTool; 17] = [
pub const ALL: [CommentTool; 18] = [
CommentTool::Eraser,
CommentTool::ReplaceText,
CommentTool::Attach,
CommentTool::Polygon,
@@ -100,6 +103,7 @@ impl CommentTool {
Self::Caret => "comment.caret",
Self::ReplaceText => "comment.replace",
Self::Attach => "comment.attach",
Self::Eraser => "comment.eraser",
}
}
@@ -126,6 +130,7 @@ impl CommentTool {
Self::Caret => "Insert text",
Self::ReplaceText => "Replace text",
Self::Attach => "Attach file",
Self::Eraser => "Eraser",
}
}
@@ -148,6 +153,7 @@ impl CommentTool {
Self::Caret => "text-cursor-input",
Self::ReplaceText => "replace",
Self::Attach => "paperclip",
Self::Eraser => "eraser",
}
}
@@ -166,7 +172,7 @@ impl CommentTool {
/// Tools that draw with a drag gesture.
pub fn draws(self) -> bool {
matches!(self, Self::Ink | Self::Line | Self::Arrow | Self::Rectangle | Self::Oval)
matches!(self, Self::Ink | Self::Line | Self::Arrow | Self::Rectangle | Self::Oval | Self::Eraser)
}
/// Tools that place points one click at a time (double-click or Enter finishes).
@@ -198,6 +204,7 @@ impl CommentTool {
Self::Callout => Shape::Callout { rect: [0.0; 4], knee: [0.0; 2], point: [0.0; 2], font_size: 10.0 },
Self::Caret => Shape::Caret { rect: [0.0; 4] },
Self::ReplaceText => Shape::TextMarkup { kind: Markup::StrikeOut, quads: Vec::new() },
Self::Eraser => Shape::Ink { strokes: Vec::new() },
Self::Attach => Shape::Attachment { at: [0.0; 2], icon: printcraft_engine::AttachIcon::PushPin, file: String::new(), data: Vec::new() },
}
}
@@ -535,7 +542,7 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>,
{
let p = clamp_to(page_rect, p);
let u = cx.to_user(p);
if *tool == CommentTool::Ink {
if matches!(*tool, CommentTool::Ink | CommentTool::Eraser) {
let last = points.last().map(|l| cx.to_screen(*l)).unwrap_or(p);
if last.distance(p) >= 1.5 {
points.push(u);
@@ -550,7 +557,9 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>,
&& page == cx.page
{
cv.gesture = None;
if let Some(shape) = drawn_shape(tool, &points) {
if tool == CommentTool::Eraser {
view.pending_edit = erase(cx, &points);
} else if let Some(shape) = drawn_shape(tool, &points) {
view.pending_edit = Some(new_comment(cx, tool, shape, String::new()));
}
}
@@ -885,6 +894,9 @@ fn paint_gesture(painter: &egui::Painter, cx: &PageCx<'_>, view: &DocView) {
CommentTool::Ink => {
painter.add(egui::Shape::line(pts, stroke));
}
CommentTool::Eraser => {
painter.add(egui::Shape::line(pts, Stroke::new(12.0, Color32::from_gray(128).gamma_multiply(0.35))));
}
CommentTool::Line | CommentTool::Arrow if pts.len() == 2 => {
painter.line_segment([pts[0], pts[1]], stroke);
if *tool == CommentTool::Arrow {
@@ -942,6 +954,29 @@ fn drawn_shape(tool: CommentTool, points: &[[f64; 2]]) -> Option<Shape> {
}
}
/// Eraser: rub out drawings along `path` (user space); one undo step for all of them.
fn erase(cx: &PageCx<'_>, path: &[[f64; 2]]) -> Option<Edit> {
let zoom = (cx.xf.rect.width() / cx.xf.pw.max(1.0)) as f64;
let radius = (6.0 / zoom).max(1.0);
let b = path.iter().fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |r, p| [r[0].min(p[0]), r[1].min(p[1]), r[2].max(p[0]), r[3].max(p[1])]);
let mut hits: Vec<usize> = cx
.comments()
.filter(|a| a.subtype == "Ink" && !a.locked)
.filter(|a| {
let r = a.rect;
(r[0] as f64) <= b[2] + radius && (r[2] as f64) >= b[0] - radius && (r[1] as f64) <= b[3] + radius && (r[3] as f64) >= b[1] - radius
})
.map(|a| a.index)
.collect();
if hits.is_empty() {
return None;
}
// Later indices first: deleting an emptied drawing shifts the ones after it.
hits.sort_unstable_by(|a, b| b.cmp(a));
let edits = hits.into_iter().map(|index| Edit::EraseInk { page: cx.page, index, path: path.to_vec(), radius }).collect();
Some(Edit::Batch { label: "Erase".into(), edits })
}
fn new_comment(cx: &PageCx<'_>, tool: CommentTool, shape: Shape, contents: String) -> Edit {
Edit::AddAnnotation(NewAnnotation { page: cx.page, shape, style: cx.prefs.style(tool), contents, author: cx.prefs.author.clone() })
}
+17
View File
@@ -501,3 +501,20 @@ fn attaching_a_file_as_a_comment() {
assert!(doc.info.attachments.iter().any(|a| a.name == "notes.txt"), "listed in the Attachments panel");
assert_eq!(s.quick_tool, QuickTool::Select);
}
#[test]
fn erasing_part_of_a_drawing() {
let mut h = harness(|app| app.set_option("quick", "ink").unwrap());
drag_pt(&mut h, (40.0, 60.0), (260.0, 60.0));
assert_eq!(comments(&h).len(), 1);
h.state_mut().set_option("quick", "eraser").unwrap();
drag_pt(&mut h, (150.0, 90.0), (150.0, 30.0));
let c = comments(&h);
assert_eq!(c.len(), 1);
let s = h.state();
let doc = s.session.get(s.views[0].id).unwrap();
assert_eq!(doc.can_undo(), Some("Erase"));
// Rubbing along the rest removes the drawing.
drag_pt(&mut h, (40.0, 60.0), (260.0, 60.0));
assert!(comments(&h).is_empty(), "{:?}", comments(&h));
}
+4 -4
View File
@@ -3587,11 +3587,11 @@ title = "Eraser for ink strokes"
area = "E"
tier = "P1"
milestone = "M5"
status = "planned"
commands = []
status = "shipped"
commands = ["comment.eraser"]
tools = []
evidence = []
notes = ""
evidence = ["crates/annot/src/tests.rs::the_eraser_cuts_strokes_and_removes_empty_drawings", "crates/ui-egui/tests/comments.rs::erasing_part_of_a_drawing"]
notes = "cuts strokes where the eraser passes; an emptied drawing is deleted; one undo step"
[[feature]]
id = "comment.line"