diff --git a/README.md b/README.md index 3822621..03a2423 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Getestet wird vor allem unter Zorin OS 18 / Ubuntu 24.04 (Wayland und X11). - **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 Stiftrückseite radiert ebenfalls. - **Virtuelles Lineal** (`L`): verschieben, am Griff drehen (rastet bei 15° ein); Striche entlang einer Kante werden gerade. +- **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. ### Formerkennung - **Formen-Werkzeug** (`S`/`F`) oder **Halten** am Strichende (über 400 ms stillhalten): Die Skizze wird durch eine saubere Form ersetzt. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index de0498b..3eb214f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -132,6 +132,8 @@ Alles auf einer Seite ist ein `Stroke` (`document.rs`): ### 5.2 Rückgängig Jede Seite hat einen eigenen Verlauf (`history.rs`): Hinzufügen, Entfernen, Ersetzen und Ändern von Strichen. Verschieben/Drehen/Skalieren einer Auswahl, ein Radierzug mit dem Teilstrich-Radierer und eine Textbearbeitung sind jeweils ein Schritt. +**Platz schaffen** (`Notebook::make_space`) betrifft mehrere Seiten: Die Seite, auf der angesetzt wurde, und die folgenden bis vor die nächste PDF-Seite gelten als durchgehende Spalte. Alles unterhalb der Ansatzhöhe rutscht um denselben Betrag; ein Objekt, das über ein Seitenende ragen würde, beginnt 10 mm unter dem Anfang der nächsten Seite, und alles danach rutscht entsprechend weiter. Fehlende Seiten werden eingefügt. Der Schritt (`SpaceShift`: jeder verschobene Strich vorher/nachher mit Seite) landet im Verlauf der Seite, auf der angesetzt wurde; `Notebook::undo`/`redo` buchen ihn über alle Seiten zurück (später geänderte Striche bleiben unberührt) und entfernen dafür angelegte, leer gebliebene Seiten wieder. + --- ## 6. Formerkennung diff --git a/src/canvas.rs b/src/canvas.rs index 32bfce0..b46387e 100644 --- a/src/canvas.rs +++ b/src/canvas.rs @@ -128,6 +128,8 @@ struct NeighborFingerprint { vertex_count: usize, stroke_count: usize, last_stroke_id: Option, + /// Neuaufbau des Caches (z. B. verschobener Inhalt bei gleicher Strichzahl) + cache_generation: u64, } impl NeighborFingerprint { @@ -141,6 +143,7 @@ impl NeighborFingerprint { vertex_count: page.vertex_cache.len(), stroke_count: page.strokes.len(), last_stroke_id: page.strokes.last().map(|s| s.id), + cache_generation: page.cache_generation, } } } diff --git a/src/document.rs b/src/document.rs index 6e6380b..87435e2 100644 --- a/src/document.rs +++ b/src/document.rs @@ -722,6 +722,8 @@ pub struct Page { pub vertex_cache: Vec, pub stroke_ranges: Vec>, pub is_dirty: bool, + /// Zählt die Neuaufbauten des Vertex-Caches (erkennt geänderte Nachbarseiten) + pub cache_generation: u64, pub is_loaded: bool, pub persist: PersistState, pub history: HistoryStack, @@ -742,6 +744,7 @@ impl Page { is_loaded: true, persist: PersistState::Normal, history: HistoryStack::new(), + cache_generation: 0, } } @@ -760,6 +763,7 @@ impl Page { is_loaded: false, persist: PersistState::Normal, history: HistoryStack::new(), + cache_generation: 0, } } @@ -958,6 +962,7 @@ impl Page { } self.is_dirty = false; + self.cache_generation += 1; } /// Berechnet die kombinierte AABB einer Menge von Strichen. @@ -1315,6 +1320,298 @@ impl Default for Notebook { } } +/// Abstand zum oberen Rand, mit dem ein Objekt auf der nächsten Seite landet, wenn es beim +/// Platzschaffen über das Seitenende rutschen würde (10 mm). +pub const SPACE_PAGE_BREAK_MARGIN: f32 = 10.0 * UNITS_PER_MM; + +/// Ein beim Platzschaffen verschobener Strich: vorher und nachher, jeweils mit Seite. +#[derive(Debug, Clone)] +pub struct StrokeMove { + pub from_page: u64, + pub before: Stroke, + pub to_page: u64, + pub after: Stroke, +} + +/// Für das Platzschaffen neu eingefügte Seite. +#[derive(Debug, Clone)] +pub struct AddedPage { + pub id: u64, + /// Seite, hinter der sie liegt + pub after_page: u64, + pub size: glam::Vec2, + pub background: PageBackground, +} + +/// Ein Platzschaffen-Schritt (für Rückgängig und Wiederholen). +#[derive(Debug, Clone)] +pub struct SpaceShift { + pub moves: Vec, + pub added_pages: Vec, +} + +impl Notebook { + /// Seiten, die beim Platzschaffen auf Seite `page_index` mitrutschen: sie selbst und die + /// folgenden bis vor die nächste Seite mit importiertem PDF (deren Notizen gehören zur + /// PDF-Seite). `None` auf einer PDF-Seite. + pub fn space_run(&self, page_index: usize) -> Option> { + let page = self.pages.get(page_index)?; + if page.pdf_background.is_some() { + return None; + } + let end = (page_index + 1..self.pages.len()) + .find(|&i| self.pages[i].pdf_background.is_some()) + .unwrap_or(self.pages.len()); + Some(page_index..end) + } + + /// Wie weit sich der Inhalt unter `y` auf Seite `page_index` höchstens nach oben schieben + /// lässt: bis an das unterste Objekt darüber (bzw. den oberen Seitenrand). + pub fn space_up_limit(&self, page_index: usize, y: f32) -> f32 { + let Some(page) = self.pages.get(page_index) else { + return 0.0; + }; + let bottom = page + .strokes + .iter() + .filter(|s| s.aabb.min.y < y) + .map(|s| s.aabb.max.y) + .fold(0.0_f32, f32::max); + (y - bottom).max(0.0) + } + + /// Platz schaffen: Alles, was auf Seite `page_index` unterhalb von `y` beginnt, und der + /// ganze Inhalt der folgenden Seiten (siehe [`Self::space_run`]) rutscht um `dy` nach unten + /// (negativ: nach oben, höchstens bis [`Self::space_up_limit`]). Die Seiten gelten dabei + /// als durchgehende Spalte: Was über ein Seitenende hinausragen würde, beginnt auf der + /// nächsten Seite (mit [`SPACE_PAGE_BREAK_MARGIN`] Abstand), alles danach rutscht + /// entsprechend weiter; fehlende Seiten werden angehängt. Der Schritt landet im Verlauf + /// der Seite und gibt die Kennungen der geänderten Seiten zurück (`None`: nichts zu tun). + pub fn make_space(&mut self, page_index: usize, y: f32, dy: f32) -> Option> { + let run = self.space_run(page_index)?; + if self.pages[run.clone()].iter().any(|p| !p.is_loaded) { + return None; + } + let dy = if dy < 0.0 { + dy.max(-self.space_up_limit(page_index, y)) + } else { + dy + }; + if dy.abs() < 1.0 { + return None; + } + + // Seiten als durchgehende Spalte: Lage jeder Seite (auch noch anzulegender) + let last_size = self.pages[run.end - 1].size; + let mut tops = Vec::new(); + let mut heights = Vec::new(); + let mut top = 0.0; + for page in &self.pages[run.clone()] { + tops.push(top); + heights.push(page.size.y); + top += page.size.y; + } + + // Verschoben wird alles unterhalb von y, in der Reihenfolge von oben nach unten + let mut movable: Vec<(usize, f32, Stroke)> = Vec::new(); + for (k, page) in self.pages[run.clone()].iter().enumerate() { + for stroke in &page.strokes { + if k > 0 || stroke.aabb.min.y >= y { + movable.push((k, tops[k] + stroke.aabb.min.y, stroke.clone())); + } + } + } + if movable.is_empty() { + return None; + } + movable.sort_by(|a, b| a.1.total_cmp(&b.1)); + + let mut extra = 0.0_f32; + let mut targets = Vec::with_capacity(movable.len()); + for (_, global_top, stroke) in &movable { + let height = stroke.aabb.max.y - stroke.aabb.min.y; + let mut new_top = global_top + dy + extra; + let mut target = 0; + for _ in 0..2 { + // Seite, auf der das Objekt beginnt (bei Bedarf weitere Seiten) + target = 0; + while target + 1 < tops.len() && tops[target + 1] <= new_top { + target += 1; + } + while tops[target] + heights[target] <= new_top { + tops.push(tops[target] + heights[target]); + heights.push(last_size.y); + target += 1; + } + let page_end = tops[target] + heights[target]; + let fits_on_a_page = height <= heights[target] - 2.0 * SPACE_PAGE_BREAK_MARGIN; + if new_top + height <= page_end || !fits_on_a_page { + break; + } + // Ragt über das Seitenende: auf die nächste Seite, alles Folgende rutscht mit + let margin = if dy > 0.0 { + SPACE_PAGE_BREAK_MARGIN + } else { + 0.0 + }; + let bump = page_end + margin - new_top; + extra += bump; + new_top += bump; + } + targets.push((target, new_top)); + } + + // Fehlende Seiten hinter dem Abschnitt einfügen + let needed = targets.iter().map(|(t, _)| t + 1).max().unwrap_or(0); + let mut added_pages = Vec::new(); + let background = self.pages[run.end - 1].background; + for k in run.len()..needed { + let after_page = self.pages[run.start + k - 1].id; + let id = self.next_page_id; + self.next_page_id += 1; + self.pages.insert( + run.start + k, + Page::new(id, background).with_size(last_size), + ); + added_pages.push(AddedPage { + id, + after_page, + size: last_size, + background, + }); + } + + // Umbuchen + let mut moves = Vec::with_capacity(movable.len()); + for ((k, _, before), (target, new_top)) in movable.into_iter().zip(targets) { + let mut after = before.clone(); + after.translate(glam::Vec2::new( + 0.0, + new_top - tops[target] - before.aabb.min.y, + )); + moves.push(StrokeMove { + from_page: self.pages[run.start + k].id, + before, + to_page: self.pages[run.start + target].id, + after, + }); + } + let shift = SpaceShift { moves, added_pages }; + let changed = self.apply_space_moves(&shift, false); + self.pages[page_index] + .history + .push(HistoryAction::SpaceShift(Box::new(shift))); + Some(changed) + } + + /// Bucht die Striche eines Platzschaffen-Schritts um (`undo`: zurück) und gibt die + /// geänderten Seiten zurück. Später geänderte oder gelöschte Striche bleiben unberührt. + fn apply_space_moves(&mut self, shift: &SpaceShift, undo: bool) -> Vec { + let mut changed = Vec::new(); + let index_of = |pages: &[Page], id: u64| pages.iter().position(|p| p.id == id); + for m in &shift.moves { + let (from, old, to, new) = if undo { + (m.to_page, &m.after, m.from_page, &m.before) + } else { + (m.from_page, &m.before, m.to_page, &m.after) + }; + let (Some(from_i), Some(to_i)) = + (index_of(&self.pages, from), index_of(&self.pages, to)) + else { + continue; + }; + let Some(pos) = self.pages[from_i] + .strokes + .iter() + .position(|s| s.id == old.id) + else { + continue; + }; + self.pages[from_i].strokes.remove(pos); + self.pages[to_i].strokes.push(new.clone()); + for i in [from_i, to_i] { + self.pages[i].is_dirty = true; + if !changed.contains(&self.pages[i].id) { + changed.push(self.pages[i].id); + } + } + } + changed + } + + /// Macht die letzte Aktion der aktuellen Seite rückgängig; ein Platzschaffen-Schritt + /// betrifft dabei alle beteiligten Seiten. Gibt die geänderten Seiten zurück. + pub fn undo(&mut self) -> Option> { + let index = self.current_page_index; + if let Some(HistoryAction::SpaceShift(_)) = self.pages[index].history.undo_stack.last() { + let Some(HistoryAction::SpaceShift(shift)) = self.pages[index].history.undo_stack.pop() + else { + unreachable!() + }; + let mut changed = self.apply_space_moves(&shift, true); + // Dafür angelegte Seiten wieder entfernen, sofern sie leer geblieben sind + for added in shift.added_pages.iter().rev() { + if let Some(i) = self.pages.iter().position(|p| p.id == added.id) { + if self.pages[i].strokes.is_empty() && self.pages.len() > 1 { + self.pages.remove(i); + if self.current_page_index >= i && self.current_page_index > 0 { + self.current_page_index -= 1; + } + changed.retain(|&id| id != added.id); + } + } + } + // Der Schritt gehört weiter zur aktuellen Seite (sie selbst wird nie entfernt) + let owner = self.current_page_index; + self.pages[owner] + .history + .redo_stack + .push(HistoryAction::SpaceShift(shift)); + return Some(changed); + } + let page = &mut self.pages[index]; + page.undo().then(|| vec![page.id]) + } + + /// Stellt die zuletzt rückgängig gemachte Aktion der aktuellen Seite wieder her. + pub fn redo(&mut self) -> Option> { + let index = self.current_page_index; + if let Some(HistoryAction::SpaceShift(_)) = self.pages[index].history.redo_stack.last() { + let Some(HistoryAction::SpaceShift(shift)) = self.pages[index].history.redo_stack.pop() + else { + unreachable!() + }; + // Entfernte Seiten wieder einfügen + for added in &shift.added_pages { + if self.pages.iter().any(|p| p.id == added.id) { + continue; + } + let at = self + .pages + .iter() + .position(|p| p.id == added.after_page) + .map_or(self.pages.len(), |i| i + 1); + self.pages.insert( + at, + Page::new(added.id, added.background).with_size(added.size), + ); + if at <= self.current_page_index { + self.current_page_index += 1; + } + } + let changed = self.apply_space_moves(&shift, false); + let owner = self.current_page_index; + self.pages[owner] + .history + .undo_stack + .push(HistoryAction::SpaceShift(shift)); + return Some(changed); + } + let page = &mut self.pages[index]; + page.redo().then(|| vec![page.id]) + } +} + /// Lage der aktuellen Seite im Fenster bei Zoom 100 % ohne Verschiebung: Ein Rahmen der /// Größe `fit_box` (alle Seiten des Notizbuchs passen hinein) wird ins Fenster eingepasst /// (Letterboxing/Pillarboxing); die aktuelle Seite liegt darin waagerecht zentriert und oben. @@ -2005,6 +2302,111 @@ mod tests { assert!(new.pdf_background.is_none()); } + /// Senkrechter Strich bei x = 300 von `y` bis `y + h`. + fn block(id: u64, y: f32, h: f32) -> Stroke { + let samples = [y, y + h] + .map(|y| StylusSample { + position: glam::Vec2::new(300.0, y), + pressure: 0.5, + timestamp: 0.0, + }) + .to_vec(); + Stroke::new(id, samples, Vec::new(), [0.0, 0.0, 0.0, 1.0]) + } + + fn top_of(nb: &Notebook, page: usize, id: u64) -> Option { + nb.pages[page] + .strokes + .iter() + .find(|s| s.id == id) + .map(|s| s.aabb.min.y) + } + + #[test] + fn test_make_space_moves_content_below_and_following_pages() { + let mut nb = Notebook::new(); + nb.add_page(PageBackground::Lined); + let a4 = A4_SIZE.y; + nb.pages[0].strokes = vec![block(1, 200.0, 100.0), block(2, 1000.0, 100.0)]; + nb.pages[1].strokes = vec![block(3, 500.0, 100.0)]; + + // 30 mm Platz bei y = 800 auf Seite 1 + let changed = nb.make_space(0, 800.0, 300.0).unwrap(); + assert_eq!(top_of(&nb, 0, 1), Some(200.0), "darüber bleibt stehen"); + assert_eq!(top_of(&nb, 0, 2), Some(1300.0)); + assert_eq!( + top_of(&nb, 1, 3), + Some(800.0), + "die nächste Seite rutscht mit" + ); + assert_eq!(changed.len(), 2); + assert_eq!(nb.pages.len(), 2); + + // Rückgängig und wiederholen über beide Seiten + nb.current_page_index = 0; + assert!(nb.undo().is_some()); + assert_eq!(top_of(&nb, 0, 2), Some(1000.0)); + assert_eq!(top_of(&nb, 1, 3), Some(500.0)); + assert!(nb.redo().is_some()); + assert_eq!(top_of(&nb, 0, 2), Some(1300.0)); + assert_eq!(top_of(&nb, 1, 3), Some(800.0)); + + // Über das Seitenende: auf die nächste Seite mit Abstand, das Folgende rutscht weiter + let mut nb = Notebook::new(); + nb.pages[0].strokes = vec![block(1, a4 - 400.0, 200.0), block(2, a4 - 150.0, 100.0)]; + nb.make_space(0, a4 - 500.0, 300.0).unwrap(); + assert_eq!(nb.pages.len(), 2, "eine Seite wurde angehängt"); + assert_eq!(nb.pages[1].background, nb.pages[0].background); + assert!(nb.pages[0].strokes.is_empty()); + let first = top_of(&nb, 1, 1).unwrap(); + assert!((first - SPACE_PAGE_BREAK_MARGIN).abs() < 1e-3, "{first}"); + // Abstand zwischen beiden bleibt (50 Einheiten unter dem Ende des ersten) + assert!((top_of(&nb, 1, 2).unwrap() - (first + 250.0)).abs() < 1e-3); + // Rückgängig entfernt die angehängte leere Seite + assert!(nb.undo().is_some()); + assert_eq!(nb.pages.len(), 1); + assert_eq!(top_of(&nb, 0, 2), Some(a4 - 150.0)); + assert!(nb.redo().is_some()); + assert_eq!(nb.pages.len(), 2); + } + + #[test] + fn test_make_space_upwards_only_closes_empty_space() { + let mut nb = Notebook::new(); + nb.pages[0].strokes = vec![block(1, 200.0, 100.0), block(2, 1000.0, 100.0)]; + // Nach oben höchstens bis an das Objekt darüber (Unterkante 300) + assert_eq!(nb.space_up_limit(0, 900.0), 600.0); + nb.make_space(0, 900.0, -2000.0).unwrap(); + assert_eq!(top_of(&nb, 0, 2), Some(400.0)); + assert_eq!(top_of(&nb, 0, 1), Some(200.0)); + // Kein Platz mehr: nichts zu tun + assert!(nb.make_space(0, 300.5, -50.0).is_none()); + } + + #[test] + fn test_make_space_stops_before_pdf_pages() { + let mut nb = Notebook::new(); + nb.add_page(PageBackground::Blank); + nb.add_page(PageBackground::Blank); + nb.pages[1].pdf_background = Some(PdfBackground { + asset: "x.pdf".to_string(), + page: 0, + width_pt: 595.0, + height_pt: 842.0, + }); + nb.pages[0].strokes = vec![block(1, 1000.0, 100.0)]; + nb.pages[2].strokes = vec![block(2, 500.0, 100.0)]; + assert!(nb.space_run(1).is_none(), "nicht auf PDF-Seiten"); + assert_eq!(nb.space_run(0), Some(0..1)); + nb.make_space(0, 900.0, 300.0).unwrap(); + assert_eq!(top_of(&nb, 0, 1), Some(1300.0)); + assert_eq!( + top_of(&nb, 2, 2), + Some(500.0), + "hinter der PDF-Seite bleibt alles" + ); + } + #[test] fn test_remove_and_restore_current_page() { let mut nb = Notebook::new(); diff --git a/src/history.rs b/src/history.rs index 51b960f..2223650 100644 --- a/src/history.rs +++ b/src/history.rs @@ -21,6 +21,9 @@ pub enum HistoryAction { before: Vec, after: Vec, }, + /// Platz geschaffen: Inhalt mehrerer Seiten wurde verschoben. Rückgängig macht das das + /// Notizbuch (`Notebook::undo`), nicht die einzelne Seite. + SpaceShift(Box), /// Teilradierer: Striche wurden durch ihre übrig gebliebenen Stücke ersetzt. SplitStrokes { removed: Vec, @@ -139,6 +142,10 @@ impl HistoryStack { self.redo_stack .push(HistoryAction::ModifyStrokes { before, after }); } + // Betrifft mehrere Seiten: Das Notizbuch macht es rückgängig, hier nur umbuchen + HistoryAction::SpaceShift(shift) => { + self.redo_stack.push(HistoryAction::SpaceShift(shift)); + } HistoryAction::SplitStrokes { removed, added, at } => { strokes.retain(|s| !added.iter().any(|a| a.id == s.id)); strokes.extend(removed.iter().cloned()); @@ -190,6 +197,9 @@ impl HistoryStack { self.undo_stack .push(HistoryAction::ModifyStrokes { before, after }); } + HistoryAction::SpaceShift(shift) => { + self.undo_stack.push(HistoryAction::SpaceShift(shift)); + } HistoryAction::SplitStrokes { removed, added, at } => { strokes.retain(|s| !removed.iter().any(|r| r.id == s.id)); strokes.extend(added.iter().cloned()); diff --git a/src/main.rs b/src/main.rs index 51022eb..56a4d66 100644 --- a/src/main.rs +++ b/src/main.rs @@ -37,6 +37,89 @@ use ink_notes::storage::{self, NotebookManifest}; use ink_notes::ui::{self, ActiveTool, UiAction, UiState}; use ink_notes::updater::UpdaterController; +/// Platz schaffen: Druck auf das Blatt (physische Pixel) setzt die Ansatzhöhe auf der +/// aktuellen Seite. `false` neben dem Blatt oder auf einer PDF-Seite. +fn space_press( + screen: (f32, f32), + canvas: &CanvasState, + notebook: &Notebook, + ui_state: &mut UiState, +) -> bool { + let Some(page_pt) = canvas.screen_to_page(screen.0, screen.1) else { + return false; + }; + let index = notebook.current_page_index; + if notebook.space_run(index).is_none() { + ui_state.set_export_message( + "Auf Seiten mit importiertem PDF lässt sich kein Platz schaffen".to_string(), + ); + return false; + } + ui_state.space_drag = Some(ui::SpaceDrag { + page_index: index, + y: page_pt.y, + dy: 0.0, + up_limit: notebook.space_up_limit(index, page_pt.y), + }); + true +} + +/// Platz schaffen: Verschiebung aus dem senkrechten Abstand zur Ansatzhöhe. +fn space_move(screen: (f32, f32), canvas: &CanvasState, ui_state: &mut UiState) { + if let Some(drag) = &mut ui_state.space_drag { + let start = canvas.page_to_screen(Vec2::new(0.0, drag.y)); + let below = canvas.page_to_screen(Vec2::new(0.0, drag.y + 1000.0)); + let px_per_unit = (below[1] - start[1]) / 1000.0; + if px_per_unit > 0.0 { + drag.dy = (screen.1 - start[1]) / px_per_unit; + } + } +} + +/// Platz schaffen beim Loslassen ausführen (alle beteiligten Seiten werden dafür geladen). +fn space_release( + canvas: &mut CanvasState, + notebook: &mut Notebook, + manifest: &mut NotebookManifest, + autosave: &mut AutosaveController, + storage_dir: &Path, + ui_state: &mut UiState, +) { + let Some(drag) = ui_state.space_drag.take() else { + return; + }; + if let Some(run) = notebook.space_run(drag.page_index) { + for index in run { + let _ = storage::ensure_page_loaded(storage_dir, notebook, index); + } + } + let dy = drag.clamped_dy(); + if let Some(changed) = notebook.make_space(drag.page_index, drag.y, dy) { + persist_pages(&changed, notebook, manifest, autosave); + canvas.sync_page(notebook.current_page_mut()); + let mm = (dy / ink_notes::document::UNITS_PER_MM).abs().round(); + ui_state.set_export_message(if dy > 0.0 { + format!("{mm} mm Platz geschaffen (Rückgängig mit Strg+Z)") + } else { + format!("{mm} mm Platz geschlossen (Rückgängig mit Strg+Z)") + }); + } +} + +/// Merkt geänderte Seiten (Kennungen) und das Manifest zum Speichern vor. +fn persist_pages( + ids: &[u64], + notebook: &Notebook, + manifest: &mut NotebookManifest, + autosave: &mut AutosaveController, +) { + manifest.sync_from_notebook(notebook); + for page in notebook.pages.iter().filter(|p| ids.contains(&p.id)) { + autosave.mark_page_dirty(page, manifest); + } + autosave.mark_manifest_dirty(manifest); +} + /// Wählt `tool` und stellt Farbe und Stärke des Live-Strichs passend ein. fn activate_tool(tool: ActiveTool, ui_state: &mut UiState, canvas: &mut CanvasState) { ui_state.active_tool = tool; @@ -49,7 +132,7 @@ fn activate_tool(tool: ActiveTool, ui_state: &mut UiState, canvas: &mut CanvasSt canvas.set_stroke_color(ui_state.highlighter_color); canvas.set_stroke_width_mm(ui_state.highlighter_stroke_width_mm); } - ActiveTool::Eraser | ActiveTool::Select | ActiveTool::Text => {} + ActiveTool::Eraser | ActiveTool::Select | ActiveTool::Text | ActiveTool::Space => {} } } @@ -1219,6 +1302,9 @@ fn main() -> Result<(), Box> { // einer Werkzeug-Taste (Auswählen, Textmarker, Stift) vertreten wird let mut stylus_held: Option<(u64, StylusAction)> = None; let mut tool_before_button: Option = None; + // Platz schaffen läuft mit dem Stift bzw. dem Finger (sonst mit der Maus) + let mut is_stylus_spacing = false; + let mut is_touch_spacing = false; let mut stylus_screen_pos = (0.0_f32, 0.0_f32); // Stift hat auf der UI aufgesetzt: alle Ereignisse bis zum Abheben gehen an egui let mut stylus_ui_capture = false; @@ -1390,6 +1476,11 @@ fn main() -> Result<(), Box> { if is_erasing { is_erasing = false; } + if is_stylus_spacing { + is_stylus_spacing = false; + space_release(&mut canvas, &mut notebook, &mut manifest, &mut autosave, &storage_dir, &mut ui_state); + window.request_redraw(); + } if is_stylus_selecting { is_stylus_selecting = false; selection_release(false, &mut notebook, &mut manifest, &mut autosave, &mut selection_state); @@ -1465,7 +1556,10 @@ fn main() -> Result<(), Box> { continue; } - if is_erasing { + if is_stylus_spacing { + space_move((sx, sy), &canvas, &mut ui_state); + window.request_redraw(); + } else if is_erasing { focus_page_at!((sx, sy)); if let Some(page_pt) = canvas.screen_to_page(sx, sy) { if notebook.erase_at(page_pt, @@ -1549,7 +1643,10 @@ fn main() -> Result<(), Box> { || button_action == Some(StylusAction::Erase) || ui_state.active_tool == ActiveTool::Eraser; - if !should_erase && ui_state.active_tool == ActiveTool::Text { + if !should_erase && ui_state.active_tool == ActiveTool::Space { + is_stylus_spacing = space_press((sx, sy), &canvas, ¬ebook, &mut ui_state); + window.request_redraw(); + } else if !should_erase && ui_state.active_tool == ActiveTool::Text { if let Some(page_pt) = canvas.screen_to_page(sx, sy) { text_tool_click(page_pt, &mut canvas, &mut notebook, &mut manifest, &mut autosave, &mut ui_state); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); @@ -1618,6 +1715,12 @@ fn main() -> Result<(), Box> { is_erasing = false; } + if is_stylus_spacing { + is_stylus_spacing = false; + space_release(&mut canvas, &mut notebook, &mut manifest, &mut autosave, &storage_dir, &mut ui_state); + window.request_redraw(); + } + if is_stylus_selecting { is_stylus_selecting = false; if selection_release(false, &mut notebook, &mut manifest, &mut autosave, &mut selection_state) { @@ -1905,10 +2008,10 @@ fn main() -> Result<(), Box> { // Undo: Strg + Z Key::Character(c) if c.eq_ignore_ascii_case("z") && ctrl && !shift => { finish_text_edit(&mut canvas, &mut notebook, &mut manifest, &mut autosave, &mut ui_state); - if notebook.current_page_mut().undo() { + if let Some(changed) = notebook.undo() { selection_state.clear(); canvas.sync_page(notebook.current_page_mut()); - autosave.mark_dirty(¬ebook, &mut manifest); + persist_pages(&changed, ¬ebook, &mut manifest, &mut autosave); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); window.request_redraw(); } @@ -1920,10 +2023,10 @@ fn main() -> Result<(), Box> { || (c.eq_ignore_ascii_case("z") && ctrl && shift) => { finish_text_edit(&mut canvas, &mut notebook, &mut manifest, &mut autosave, &mut ui_state); - if notebook.current_page_mut().redo() { + if let Some(changed) = notebook.redo() { selection_state.clear(); canvas.sync_page(notebook.current_page_mut()); - autosave.mark_dirty(¬ebook, &mut manifest); + persist_pages(&changed, ¬ebook, &mut manifest, &mut autosave); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); window.request_redraw(); } @@ -2010,7 +2113,7 @@ fn main() -> Result<(), Box> { // Radierer umschalten: E Key::Character(c) if c.eq_ignore_ascii_case("e") && !ctrl => { ui_state.active_tool = match ui_state.active_tool { - ActiveTool::Pen | ActiveTool::Highlighter | ActiveTool::Shape | ActiveTool::Select | ActiveTool::Text => ActiveTool::Eraser, + ActiveTool::Pen | ActiveTool::Highlighter | ActiveTool::Shape | ActiveTool::Select | ActiveTool::Text | ActiveTool::Space => ActiveTool::Eraser, ActiveTool::Eraser => ActiveTool::Pen, }; ui_state.is_pen_menu_open = false; @@ -2287,6 +2390,13 @@ fn main() -> Result<(), Box> { } else if button == MouseButton::Left { let (sx, sy) = cursor_screen_pos; + // Platz schaffen: Ansatzhöhe merken + if ui_state.active_tool == ActiveTool::Space { + space_press((sx, sy), &canvas, ¬ebook, &mut ui_state); + window.request_redraw(); + return; + } + // Text-Werkzeug: Textfeld öffnen if ui_state.active_tool == ActiveTool::Text { if let Some(page_pt) = canvas.screen_to_page(sx, sy) { @@ -2348,6 +2458,10 @@ fn main() -> Result<(), Box> { if is_erasing { is_erasing = false; } + if ui_state.space_drag.is_some() && !is_stylus_spacing && !is_touch_spacing { + space_release(&mut canvas, &mut notebook, &mut manifest, &mut autosave, &storage_dir, &mut ui_state); + window.request_redraw(); + } if ui_state.active_tool == ActiveTool::Select && !is_stylus_selecting && selection_release(modifiers.shift_key(), &mut notebook, &mut manifest, &mut autosave, &mut selection_state) @@ -2401,7 +2515,10 @@ fn main() -> Result<(), Box> { // Wenn der Stylus aktiv zeichnet, keine Mauscursor-Bewegungen verarbeiten if !is_stylus_drawing && !is_stylus_selecting { - if ui_state.active_tool == ActiveTool::Select && selection_state.drag_mode != DragMode::None { + if ui_state.space_drag.is_some() && !is_stylus_spacing && !is_touch_spacing { + space_move((sx, sy), &canvas, &mut ui_state); + window.request_redraw(); + } else if ui_state.active_tool == ActiveTool::Select && selection_state.drag_mode != DragMode::None { if let Some(page_pt) = canvas.screen_to_page(sx, sy) { selection_drag(page_pt, notebook.current_page_mut(), &mut canvas, &mut selection_state); window.request_redraw(); @@ -2554,6 +2671,10 @@ fn main() -> Result<(), Box> { window.request_redraw(); } is_touch_panning = false; + if is_touch_spacing { + is_touch_spacing = false; + ui_state.space_drag = None; + } is_pinch_gesturing = true; let pts: Vec<(f32, f32)> = active_touches.values().copied().take(2).collect(); @@ -2594,6 +2715,9 @@ fn main() -> Result<(), Box> { if selection_press((tx, ty), false, &canvas, notebook.current_page(), egui_ctx.pixels_per_point(), &mut selection_state) { window.request_redraw(); } + } else if ui_state.active_tool == ActiveTool::Space { + is_touch_spacing = space_press((tx, ty), &canvas, ¬ebook, &mut ui_state); + window.request_redraw(); } else if ui_state.active_tool.is_drawing() || ui_state.active_tool == ActiveTool::Shape { if let Some(page_pt) = canvas.screen_to_page(tx, ty) { is_touch_drawing = true; @@ -2660,6 +2784,9 @@ fn main() -> Result<(), Box> { canvas.extend_stroke(page_pt.x, page_pt.y, pressure, now); window.request_redraw(); } + } else if is_touch_spacing { + space_move((tx, ty), &canvas, &mut ui_state); + window.request_redraw(); } else if is_erasing { focus_page_at!((tx, ty)); if let Some(page_pt) = canvas.screen_to_page(tx, ty) { @@ -2692,6 +2819,12 @@ fn main() -> Result<(), Box> { is_erasing = false; } + if is_touch_spacing { + is_touch_spacing = false; + space_release(&mut canvas, &mut notebook, &mut manifest, &mut autosave, &storage_dir, &mut ui_state); + window.request_redraw(); + } + if ui_state.active_tool == ActiveTool::Select && selection_release(false, &mut notebook, &mut manifest, &mut autosave, &mut selection_state) { @@ -2754,6 +2887,7 @@ fn main() -> Result<(), Box> { .is_none_or(|last: [f32; 3]| last.iter().zip(scroll).any(|(a, b)| (a - b).abs() > 1e-3)); let busy = is_drawing || is_erasing + || ui_state.space_drag.is_some() || !selection_state.is_empty() || selection_state.drag_mode != DragMode::None || ui_state.ruler_drag.is_some() @@ -2831,7 +2965,7 @@ fn main() -> Result<(), Box> { ui_state.highlighter_color = color; canvas.set_stroke_color(color); } - ActiveTool::Eraser | ActiveTool::Select | ActiveTool::Text => {} + ActiveTool::Eraser | ActiveTool::Select | ActiveTool::Text | ActiveTool::Space => {} } window.request_redraw(); } @@ -2845,7 +2979,7 @@ fn main() -> Result<(), Box> { ui_state.highlighter_stroke_width_mm = width; canvas.set_stroke_width_mm(width); } - ActiveTool::Eraser | ActiveTool::Select | ActiveTool::Text => {} + ActiveTool::Eraser | ActiveTool::Select | ActiveTool::Text | ActiveTool::Space => {} } window.request_redraw(); } @@ -2920,20 +3054,20 @@ fn main() -> Result<(), Box> { } UiAction::Undo => { finish_text_edit(&mut canvas, &mut notebook, &mut manifest, &mut autosave, &mut ui_state); - if notebook.current_page_mut().undo() { + if let Some(changed) = notebook.undo() { selection_state.clear(); canvas.sync_page(notebook.current_page_mut()); - autosave.mark_dirty(¬ebook, &mut manifest); + persist_pages(&changed, ¬ebook, &mut manifest, &mut autosave); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); window.request_redraw(); } } UiAction::Redo => { finish_text_edit(&mut canvas, &mut notebook, &mut manifest, &mut autosave, &mut ui_state); - if notebook.current_page_mut().redo() { + if let Some(changed) = notebook.redo() { selection_state.clear(); canvas.sync_page(notebook.current_page_mut()); - autosave.mark_dirty(¬ebook, &mut manifest); + persist_pages(&changed, ¬ebook, &mut manifest, &mut autosave); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); window.request_redraw(); } diff --git a/src/ui.rs b/src/ui.rs index c56c662..2c21026 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -35,6 +35,8 @@ pub enum ActiveTool { Select, /// Textfelder schreiben und bearbeiten Text, + /// Platz schaffen: Inhalt ab einer Höhe nach unten oder oben schieben + Space, } impl ActiveTool { @@ -46,6 +48,7 @@ impl ActiveTool { ActiveTool::Shape => "Formen (Shape)", ActiveTool::Select => "Auswahl / Lasso (Select)", ActiveTool::Text => "Text (T)", + ActiveTool::Space => "Platz schaffen", } } @@ -179,6 +182,23 @@ pub enum MoveSubject { Folder(String), } +/// Laufendes Platzschaffen (für die Vorschau): Seite, Höhe auf der Seite, bisherige +/// Verschiebung und wie weit es nach oben höchstens geht (Seiteneinheiten). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct SpaceDrag { + pub page_index: usize, + pub y: f32, + pub dy: f32, + pub up_limit: f32, +} + +impl SpaceDrag { + /// Verschiebung nach oben begrenzt auf den leeren Platz. + pub fn clamped_dy(&self) -> f32 { + self.dy.max(-self.up_limit) + } +} + /// Kontextmenü nach langem Drücken auf das Blatt. #[derive(Debug, Clone, Copy, PartialEq)] pub struct ContextMenu { @@ -378,6 +398,8 @@ pub struct UiState { pub show_color_picker: bool, /// Kontextmenü nach langem Drücken (Einfügen an dieser Stelle) pub context_menu: Option, + /// Laufendes Platzschaffen (Vorschau) + pub space_drag: Option, /// Tasten am Stift und ihre Belegung; die gerade gedrückte (Index) wird hervorgehoben pub stylus_buttons: Vec, pub pressed_stylus_button: Option, @@ -476,6 +498,7 @@ impl Default for UiState { is_text_color_menu_open: false, show_color_picker: false, context_menu: None, + space_drag: None, stylus_buttons: Vec::new(), pressed_stylus_button: None, confirm_delete_page: false, @@ -593,10 +616,10 @@ impl UiState { /// Blätterpfeile). pub fn sidebar_button_size(tool: ActiveTool, max_height: f32, continuous: bool) -> f32 { let select = matches!(tool, ActiveTool::Select | ActiveTool::Text); - // Notizbücher, 6 Werkzeuge, Undo/Redo, Seitenzahl und neue Seite (einzeln blättern: - // dazu zwei Pfeile), Menü (+ 4 Auswahl-Aktionen bzw. Schriftknöpfe) + // Notizbücher, 7 Werkzeuge, Lineal, Undo/Redo, Seitenzahl und neue Seite (einzeln + // blättern: dazu zwei Pfeile), Menü (+ 4 Auswahl-Aktionen bzw. Schriftknöpfe) let page_buttons = if continuous { 2 } else { 4 }; - let buttons = 10 + page_buttons + if select { 4 } else { 0 }; + let buttons = 11 + page_buttons + if select { 4 } else { 0 }; let dividers = 5 + usize::from(select); let spacing = 3.0; let needed = |b: f32| { @@ -679,7 +702,7 @@ impl UiState { ActiveTool::Pen | ActiveTool::Shape | ActiveTool::Select => self.pen_color, ActiveTool::Highlighter => self.highlighter_color, ActiveTool::Text => self.text_color, - ActiveTool::Eraser => [0.0, 0.0, 0.0, 0.0], + ActiveTool::Eraser | ActiveTool::Space => [0.0, 0.0, 0.0, 0.0], } } @@ -690,7 +713,7 @@ impl UiState { self.pen_stroke_width_mm } ActiveTool::Highlighter => self.highlighter_stroke_width_mm, - ActiveTool::Eraser => 3.0, + ActiveTool::Eraser | ActiveTool::Space => 3.0, } } @@ -2125,6 +2148,55 @@ fn context_menu(ctx: &egui::Context, state: &mut UiState, actions: &mut Vec= 0.0 { + ( + egui::Color32::from_rgba_unmultiplied(46, 204, 113, 50), + egui::Color32::from_rgb(39, 174, 96), + ) + } else { + ( + egui::Color32::from_rgba_unmultiplied(230, 126, 34, 60), + egui::Color32::from_rgb(211, 84, 0), + ) + }; + let band = egui::Rect::from_two_pos(left, moved); + painter.rect_filled(band, 0.0, fill); + painter.line_segment([left, right], egui::Stroke::new(2.0_f32, line)); + painter.line_segment( + [egui::pos2(left.x, moved.y), moved], + egui::Stroke::new(2.0_f32, line), + ); + let mm = dy / crate::document::UNITS_PER_MM; + painter.text( + egui::pos2(right.x - 8.0, moved.y.max(left.y) + 4.0), + egui::Align2::RIGHT_TOP, + if dy >= 0.0 { + format!("⏷ {mm:.0} mm Platz") + } else { + format!("⏶ {:.0} mm", -mm) + }, + egui::FontId::proportional(14.0), + line, + ); +} + /// Durchgehende Ansicht: dunkle Linie an jeder Grenze zweier Seiten (die Seiten liegen /// lückenlos untereinander). Unter allen Fenstern der Oberfläche, über der Tinte. fn page_separators(ctx: &egui::Context, state: &UiState, notebook: &Notebook) { @@ -2555,6 +2627,7 @@ pub fn render_ui( let mut actions = std::mem::take(&mut state.queued_actions); state.screen_size = ctx.screen_rect().size(); page_separators(ctx, state, notebook); + space_preview(ctx, state, notebook); ruler_overlay(ctx, state); text_edit_overlay(ctx, state); context_menu(ctx, state, &mut actions); @@ -2733,6 +2806,13 @@ pub fn render_ui( SidebarIcon::Lasso, "Auswählen: verschieben, drehen, skalieren, kopieren (V)", ), + ( + ActiveTool::Space, + SidebarIcon::Glyph("↕"), + "Platz schaffen: auf das Blatt tippen und nach unten ziehen – alles \ + darunter rutscht mit, auch auf die folgenden Seiten; nach oben \ + schließt leeren Platz", + ), ( ActiveTool::Text, SidebarIcon::Glyph("T"),