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

+1 -1
View File
@@ -27,7 +27,7 @@ Die Oberfläche gibt es auf Deutsch und Englisch; ink-notes folgt der Systemspra
- **Glättung in zwei Stufen:** Während des Schreibens glättet ein 1€-Filter das Zittern, die Linie erscheint sofort. Beim Absetzen wird der Strich nachbearbeitet: Ecken (Knicke über 65°) bleiben erhalten und werden rund verbunden, der Druckverlauf wird geglättet, überflüssige Punkte entfallen, die Enden werden rund.
- **Druckkurve** im ☰-Menü an den eigenen Stift anpassen (weicher/härter, Schwelle gegen Grundrauschen).
- **Tasten am Stift** im ☰-Menü belegen: Radieren (Standard), Auswählen, Textmarker, Stift oder nichts – gilt, solange die Taste gedrückt ist. Jede Taste erscheint dort nach dem ersten Drücken.
- **Radierer** in drei Modi: ganze Striche, nur den überstrichenen Teil (Teilstrich) oder nur Textmarker; die Größe stellt man in einem Menü wie bei den Stiften ein (wird gemerkt). Die Stiftrückseite radiert ebenfalls.
- **Radierer** in drei Modi: ganze Striche, nur den überstrichenen Teil (Teilstrich) oder nur Textmarker; berührte Textfelder verschwinden ganz, Bilder bleiben; die Größe stellt man in einem Menü wie bei den Stiften ein (wird gemerkt). Die Stiftrückseite radiert ebenfalls.
- **Virtuelles Lineal** (`L`): verschieben, am Griff drehen (rastet bei 15° ein); Striche entlang einer Kante werden gerade.
- **Nur ansehen** (👁 in der Werkzeugleiste): Stift, Finger und Maus scrollen und zoomen nur; die Werkzeuge verschwinden, nichts lässt sich versehentlich ändern. Erneut 👁 tippen, um zu schreiben.
- **Platz schaffen** (↕ in der Werkzeugleiste): aufs Blatt tippen und nach unten ziehen – alles darunter rutscht mit, auch auf den folgenden Seiten. Was über ein Seitenende rutschen würde, beginnt auf der nächsten Seite; fehlende Seiten werden angehängt. Nach oben ziehen schließt leeren Platz. Ein Schritt für Rückgängig.
+1 -1
View File
@@ -27,7 +27,7 @@ The interface is available in English and German; ink-notes follows the system l
- **Two-stage smoothing:** While you write, a 1€ filter removes jitter and the line appears immediately. When you lift the pen, the stroke is refined: corners (bends over 65°) are kept and drawn round, the pressure curve is smoothed, redundant points are dropped and the ends become round.
- **Pressure curve** adjustable to your pen in the ☰ menu (softer/harder, threshold against base noise).
- **Pen buttons** can be assigned in the ☰ menu: erase (default), select, highlighter, pen or nothing – active while the button is held. Each button appears there after it is pressed for the first time.
- **Eraser** with three modes: whole strokes, only the part you wipe over (partial stroke), or highlighter only; its size is set in a menu like the pens (remembered). The back end of the pen erases too.
- **Eraser** with three modes: whole strokes, only the part you wipe over (partial stroke), or highlighter only; text boxes it touches go as a whole, images stay; its size is set in a menu like the pens (remembered). The back end of the pen erases too.
- **Virtual ruler** (`L`): move it, rotate it by its handle (snaps to 15°); strokes along an edge become straight.
- **View only** (👁 in the toolbar): pen, finger and mouse only scroll and zoom; the tools disappear and nothing can be changed by accident. Tap 👁 again to write.
- **Make space** (↕ in the toolbar): tap the page and drag down – everything below moves along, also on the following pages. Whatever would slide past the end of a page continues on the next one; missing pages are appended. Dragging up closes empty space. Undo reverts it in one step.
+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