feat(eraser): erase text boxes as a whole (Issue #54)

The eraser skipped text boxes along with images, so a typed text or an
inserted math result could only be removed with the selection tool. The
whole-stroke and partial erasers now remove a text box once they touch
it; the highlighter eraser leaves it alone and images are still never
erased.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
haraldandClaude Opus 5.5 committed 2026-11-09 09:10:37 +01:00
1 parent 1b22ffa526
commit 055e39c24e
3 files changed
+78 -8

No files matched your search

+76 -6
View File
@@ -275,7 +275,7 @@ pub enum EraserMode {
/// Nur Textmarker – die Handschrift darunter bleibt erhalten
HighlighterOnly,
/// Nur den überstrichenen Teil eines Strichs entfernen; der Rest bleibt in Stücken
/// erhalten. Formen werden als Ganzes radiert.
/// erhalten. Formen und Textfelder werden als Ganzes radiert.
Partial,
}
@@ -578,6 +578,12 @@ impl Stroke {
min_dist
}
/// A text box goes as a whole once the eraser touches it, like a typed or inserted math
/// result (Issue #54). Images are never erased.
fn erased_as_text(&self, eraser_pos: glam::Vec2, eraser_radius: f32) -> bool {
self.is_text() && self.distance_to_point(eraser_pos) <= eraser_radius
}
/// Prüft, ob dieser Strich von einem Radierer an Position `eraser_pos` mit Radius `eraser_radius` getroffen wird.
pub fn intersects_eraser(
&self,
@@ -942,10 +948,15 @@ impl Page {
let mut hit_indices = Vec::new();
for (i, stroke) in self.strokes.iter().enumerate() {
if !stroke.is_object()
&& mode.erases(stroke.tool)
&& stroke.intersects_eraser(eraser_pos, eraser_radius, max_stroke_radius)
{
let hit = if stroke.is_text() {
mode != EraserMode::HighlighterOnly
&& stroke.erased_as_text(eraser_pos, eraser_radius)
} else {
!stroke.is_object()
&& mode.erases(stroke.tool)
&& stroke.intersects_eraser(eraser_pos, eraser_radius, max_stroke_radius)
};
if hit {
hit_indices.push(i);
}
}
@@ -970,7 +981,7 @@ impl Page {
/// Teilradierer: entfernt den Teil aller Striche im Kreis um `eraser_pos`. Getroffene
/// Striche werden durch ihre übrig gebliebenen Stücke ersetzt (neue IDs aus `next_id`),
/// Formen als Ganzes entfernt, Bilder und Textfelder nie. Aufeinanderfolgende Schnitte
/// Formen und Textfelder als Ganzes entfernt, Bilder nie. Aufeinanderfolgende Schnitte
/// eines Radierzugs bilden einen gemeinsamen Undo-Schritt. Gibt `true` zurück, wenn sich etwas geändert hat.
pub fn erase_partial_at(
&mut self,
@@ -982,6 +993,14 @@ impl Page {
let mut added = Vec::new();
let mut result = Vec::with_capacity(self.strokes.len());
for stroke in std::mem::take(&mut self.strokes) {
if stroke.is_text() {
if stroke.erased_as_text(eraser_pos, eraser_radius) {
removed.push(stroke);
} else {
result.push(stroke);
}
continue;
}
let reach = eraser_radius + stroke.width / 2.0;
if stroke.is_object() || !stroke.aabb.expanded(reach).contains_point(eraser_pos) {
result.push(stroke);
@@ -2177,6 +2196,57 @@ mod tests {
assert!(page.strokes.is_empty());
}
#[test]
fn test_eraser_removes_text_boxes_but_not_images() {
let corners = |x: f32| {
[
glam::Vec2::new(x, 100.0),
glam::Vec2::new(x + 100.0, 100.0),
glam::Vec2::new(x + 100.0, 150.0),
glam::Vec2::new(x, 150.0),
]
};
let text = || {
Stroke::new_text(
1,
crate::text::TextBox::new("10".into(), 40.0, crate::text::TextFont::Sans, 100.0),
corners(100.0),
[0.0, 0.0, 0.0, 1.0],
)
};
for mode in [EraserMode::All, EraserMode::Partial] {
let mut page = Page::new(1, PageBackground::Grid5mm);
page.add_stroke(text());
page.add_stroke(Stroke::new_image(2, "a".into(), corners(300.0)));
let mut next_id = 10;
let mut erase = |page: &mut Page, at: glam::Vec2| match mode {
EraserMode::Partial => page.erase_partial_at(at, 10.0, &mut next_id),
_ => !page.erase_at(at, 10.0, 2.0, mode).is_empty(),
};
// Next to the box: nothing; touching its edge or inside: the whole box goes
assert!(!erase(&mut page, glam::Vec2::new(85.0, 120.0)));
assert!(!erase(&mut page, glam::Vec2::new(350.0, 120.0)));
assert!(erase(&mut page, glam::Vec2::new(95.0, 120.0)));
assert_eq!(
page.strokes.iter().map(|s| s.id).collect::<Vec<_>>(),
vec![2]
);
assert!(page.undo());
assert_eq!(page.strokes.len(), 2);
}
// The highlighter eraser leaves text alone
let mut page = Page::new(1, PageBackground::Grid5mm);
page.add_stroke(text());
assert!(page
.erase_at(
glam::Vec2::new(150.0, 120.0),
10.0,
2.0,
EraserMode::HighlighterOnly
)
.is_empty());
}
#[test]
fn test_pdf_page_size_follows_orientation() {
// Hochformat (A4, Letter) bleibt A4 hoch