From 055e39c24e10997283ef6d26cb468b8b2863cf2f Mon Sep 17 00:00:00 2001 From: Harald Date: Mon, 9 Nov 2026 09:10:37 +0100 Subject: [PATCH] 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 --- README.de.md | 2 +- README.md | 2 +- src/document.rs | 82 +++++++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 78 insertions(+), 8 deletions(-) diff --git a/README.de.md b/README.de.md index d4fef2d..29399d6 100644 --- a/README.de.md +++ b/README.de.md @@ -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. diff --git a/README.md b/README.md index 1a99024..67d81e2 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/document.rs b/src/document.rs index 0720572..38d356b 100644 --- a/src/document.rs +++ b/src/document.rs @@ -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![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