diff --git a/README.md b/README.md index 54e44fd..795d48b 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ Getestet wird vor allem unter Zorin OS 18 / Ubuntu 24.04 (Wayland und X11). ### Text, Bilder und PDFs - **Text-Werkzeug** (`T`): ins Blatt tippen und schreiben – normale oder Code-Schrift (feste Zeichenbreite, Tab rückt ein), 8–36 pt, Zeilenumbruch am Feldrand. Vorhandenen Text antippen zum Ändern, `Esc` beendet. Textfelder lassen sich wie Bilder verschieben, drehen und skalieren. In anderen Programmen kopierter Text wird mit `Strg + V` zum Textfeld. - **Bilder** (PNG, JPEG, GIF, WebP, BMP) über `Strg + I`, aus der Zwischenablage oder per Ziehen ins Fenster einfügen. +- **Einfügen per Finger:** Lange (½ s) mit dem Finger aufs Blatt drücken öffnet ein Menü „Einfügen“ – Screenshot, kopierter Text oder kopierte Objekte landen genau an dieser Stelle. - **PDF importieren** (☰-Menü): Jede PDF-Seite wird eine Notizbuchseite mit der PDF-Seite als Hintergrund. Hochformat-Seiten werden DIN A4, Querformat-Seiten (z. B. Folien) bekommen eine Querformat-Seite ohne leeren Streifen darunter. Beim Hineinzoomen werden PDF-Seiten schärfer nachgerendert. ### Seiten und Ansicht diff --git a/src/main.rs b/src/main.rs index 91c95b6..7becd37 100644 --- a/src/main.rs +++ b/src/main.rs @@ -581,6 +581,7 @@ fn selection_release( #[allow(clippy::too_many_arguments)] fn insert_text_on_page( content: &str, + at: Option, canvas: &mut CanvasState, notebook: &mut Notebook, manifest: &mut NotebookManifest, @@ -588,10 +589,13 @@ fn insert_text_on_page( selection_state: &mut SelectionState, ui_state: &mut UiState, ) { - let center = visible_page_center(canvas); + let position = at.unwrap_or_else(|| { + let center = visible_page_center(canvas); + Vec2::new((center.x - 600.0).max(150.0), center.y) + }); let mut edit = ink_notes::text::TextEditing::new_at( notebook.current_page().id, - Vec2::new((center.x - 600.0).max(150.0), center.y), + position, notebook.current_page().size.x, ui_state.text_size_pt * ink_notes::text::UNITS_PER_PT, ui_state.text_font, @@ -794,9 +798,11 @@ fn visible_page_center(canvas: &CanvasState) -> Vec2 { } /// Strg+V bzw. „Einfügen“: ein neu kopiertes Systembild hat Vorrang, sonst werden die intern -/// kopierten Objekte eingefügt. Gibt die Meldung für die Statuszeile zurück. +/// kopierten Objekte eingefügt. `at`: Stelle auf der Seite (lange gedrückt → Einfügen), sonst +/// die Mitte des sichtbaren Bereichs. #[allow(clippy::too_many_arguments)] fn paste( + at: Option, clipboard: &mut ClipboardBridge, storage_dir: &Path, canvas: &mut CanvasState, @@ -820,7 +826,7 @@ fn paste( image.bytes.into_owned(), ) { Ok(imported) => { - let center = visible_page_center(canvas); + let center = at.unwrap_or_else(|| visible_page_center(canvas)); insert_image_on_page( imported, center, @@ -844,6 +850,7 @@ fn paste( { insert_text_on_page( &text, + at, canvas, notebook, manifest, @@ -874,17 +881,20 @@ fn paste( } } let mut next_id = notebook.next_stroke_id; - let new_ids = selection_state.paste_from_clipboard(notebook.current_page_mut(), &mut || { - let id = next_id; - next_id += 1; - id - }); + let new_ids = + selection_state.paste_from_clipboard(notebook.current_page_mut(), at, &mut || { + let id = next_id; + next_id += 1; + id + }); notebook.next_stroke_id = next_id; if !new_ids.is_empty() { canvas.sync_page(notebook.current_page_mut()); manifest.next_stroke_id = notebook.next_stroke_id; autosave.mark_dirty(notebook, manifest); ui_state.set_export_message(format!("{} Objekt(e) eingefügt", new_ids.len())); + } else { + ui_state.set_export_message("Die Zwischenablage ist leer".to_string()); } } @@ -1127,6 +1137,8 @@ fn main() -> Result<(), Box> { let mut is_pinch_gesturing = false; let mut is_touch_drawing = false; let mut is_touch_panning = false; + // Langes Drücken mit einem Finger öffnet das Kontextmenü + let mut long_press = ui::LongPress::default(); let mut touch_pan_last_pos = (0.0_f32, 0.0_f32); // Stylus / Digitizer Zustand (Octotablet / Wayland Tablet Protocol) @@ -1561,6 +1573,27 @@ fn main() -> Result<(), Box> { } } + // Langes Drücken mit dem Finger: Kontextmenü zum Einfügen an dieser Stelle + if let Some(pos) = long_press.fire(Instant::now()) { + if is_touch_drawing { + is_touch_drawing = false; + is_drawing = false; + canvas.cancel_stroke(); + } + is_touch_panning = false; + focus_page_at!(pos); + if let Some(page_pt) = canvas.screen_to_page(pos.0, pos.1) { + let scale = window.scale_factor() as f32; + ui_state.close_overlays(); + // Leicht versetzt, damit der Finger das Menü nicht verdeckt + ui_state.context_menu = Some(ui::ContextMenu { + screen: egui::pos2(pos.0 / scale + 12.0, pos.1 / scale + 12.0), + page: page_pt, + }); + } + window.request_redraw(); + } + // Von egui angefordertes, verzögertes Neuzeichnen (Tooltips, Animationen) if let Some(at) = egui_repaint_at { if Instant::now() >= at { @@ -1578,7 +1611,11 @@ fn main() -> Result<(), Box> { || is_stylus_in_proximity { target.set_control_flow(ControlFlow::wait_duration(Duration::from_millis(8))); - } else if let Some(at) = egui_repaint_at { + } else if let Some(at) = [egui_repaint_at, long_press.deadline()] + .into_iter() + .flatten() + .min() + { target.set_control_flow(ControlFlow::WaitUntil(at)); } else { target.set_control_flow(ControlFlow::Wait); @@ -1787,7 +1824,7 @@ fn main() -> Result<(), Box> { // Einfügen: Strg + V Key::Character(c) if c.eq_ignore_ascii_case("v") && ctrl => { paste( - &mut clipboard, &storage_dir, &mut canvas, &mut notebook, &mut manifest, + None, &mut clipboard, &storage_dir, &mut canvas, &mut notebook, &mut manifest, &mut autosave, &mut selection_state, &mut ui_state, ); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); @@ -2376,6 +2413,19 @@ fn main() -> Result<(), Box> { active_touches.insert(touch.id, (tx, ty)); + // Langes Drücken: nur beim Verschieben oder Zeichnen mit dem Finger, nicht + // beim Radieren, Auswählen oder Text (dort hält man auch mal still) + if active_touches.len() == 1 + && !is_pinch_gesturing + && (!ui_state.touch_draw_enabled + || ui_state.active_tool.is_drawing() + || ui_state.active_tool == ActiveTool::Shape) + { + long_press.start(touch.id, (tx, ty), Instant::now()); + } else { + long_press.cancel(); + } + if active_touches.len() >= 2 { // Multi-Touch Geste: Sobald 2+ Finger aufliegen, Inking sofort abbrechen und zoomen/pannen if is_touch_drawing { @@ -2442,6 +2492,7 @@ fn main() -> Result<(), Box> { } TouchPhase::Moved => { active_touches.insert(touch.id, (tx, ty)); + long_press.moved(touch.id, (tx, ty), window.scale_factor() as f32); if active_touches.len() >= 2 { let pts: Vec<(f32, f32)> = active_touches.values().copied().take(2).collect(); @@ -2510,6 +2561,7 @@ fn main() -> Result<(), Box> { } TouchPhase::Ended | TouchPhase::Cancelled => { active_touches.remove(&touch.id); + long_press.cancel(); if active_touches.is_empty() { is_pinch_gesturing = false; @@ -2713,9 +2765,13 @@ fn main() -> Result<(), Box> { window.request_redraw(); } } - UiAction::PasteSelected => { + UiAction::PasteSelected | UiAction::PasteAt(_) => { + let at = match action { + UiAction::PasteAt(at) => Some(at), + _ => None, + }; paste( - &mut clipboard, &storage_dir, &mut canvas, &mut notebook, &mut manifest, + at, &mut clipboard, &storage_dir, &mut canvas, &mut notebook, &mut manifest, &mut autosave, &mut selection_state, &mut ui_state, ); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); diff --git a/src/selection.rs b/src/selection.rs index c9c931b..3c5e589 100644 --- a/src/selection.rs +++ b/src/selection.rs @@ -203,13 +203,19 @@ impl SelectionState { pub fn paste_from_clipboard( &mut self, page: &mut Page, + at: Option, next_id_gen: &mut impl FnMut() -> u64, ) -> Vec { if self.clipboard.is_empty() { return Vec::new(); } - let offset = Vec2::new(30.0, 30.0); // ca. 3 mm Versatz nach rechts-unten + // Mit `at` (lange gedrückt → Einfügen) landet die Mitte der Objekte dort, sonst + // ca. 3 mm nach rechts unten versetzt + let offset = match (at, Page::selection_aabb(&self.clipboard)) { + (Some(at), Some(aabb)) => at - (aabb.min + aabb.max) / 2.0, + _ => Vec2::new(30.0, 30.0), + }; let mut new_strokes = Vec::with_capacity(self.clipboard.len()); let mut new_ids = Vec::with_capacity(self.clipboard.len()); @@ -457,7 +463,7 @@ mod tests { // Paste let mut next_id = 100; - let new_ids = sel.paste_from_clipboard(&mut page, &mut || { + let new_ids = sel.paste_from_clipboard(&mut page, None, &mut || { next_id += 1; next_id }); @@ -465,6 +471,18 @@ mod tests { assert_eq!(page.strokes.len(), 3); assert_eq!(sel.selected_ids, new_ids); + // An einer Stelle einfügen: die Mitte der Kopie liegt dort + let target = Vec2::new(1000.0, 1500.0); + let placed = sel.paste_from_clipboard(&mut page, Some(target), &mut || { + next_id += 1; + next_id + }); + let aabb = sel.get_selection_aabb(&page).unwrap(); + assert_eq!(sel.selected_ids, placed); + assert!(((aabb.min + aabb.max) / 2.0 - target).length() < 0.01); + sel.delete_selected(&mut page); + sel.selected_ids = new_ids.clone(); + // Delete let deleted = sel.delete_selected(&mut page); assert_eq!(deleted.len(), 1); diff --git a/src/ui.rs b/src/ui.rs index 8f3a38a..830c6c2 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -126,6 +126,59 @@ impl PageView { } } +/// Erkennt langes Drücken mit einem Finger: still liegen (höchstens [`Self::SLOP`] logische +/// Punkte Bewegung) für [`Self::DURATION`]. Positionen in physischen Pixeln. +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub struct LongPress { + pending: Option<(u64, Instant, (f32, f32))>, +} + +impl LongPress { + pub const DURATION: std::time::Duration = std::time::Duration::from_millis(500); + pub const SLOP: f32 = 10.0; + + /// Finger `id` hat bei `pos` aufgesetzt. + pub fn start(&mut self, id: u64, pos: (f32, f32), now: Instant) { + self.pending = Some((id, now, pos)); + } + + /// Bewegung: zu weit (oder ein anderer Finger) bricht ab. + pub fn moved(&mut self, id: u64, pos: (f32, f32), scale: f32) { + if let Some((pending, _, start)) = self.pending { + if pending != id || (pos.0 - start.0).hypot(pos.1 - start.1) > Self::SLOP * scale { + self.pending = None; + } + } + } + + /// Finger abgehoben, zweiter Finger oder sonstiger Abbruch. + pub fn cancel(&mut self) { + self.pending = None; + } + + /// Wann ausgelöst wird (zum Aufwecken der Ereignisschleife). + pub fn deadline(&self) -> Option { + self.pending.map(|(_, started, _)| started + Self::DURATION) + } + + /// Ist die Zeit um, wird einmal die Stelle des Drückens geliefert. + pub fn fire(&mut self, now: Instant) -> Option<(f32, f32)> { + let (_, started, pos) = self.pending?; + (now >= started + Self::DURATION).then(|| { + self.pending = None; + pos + }) + } +} + +/// Kontextmenü nach langem Drücken auf das Blatt. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ContextMenu { + /// Bildschirmposition (logische Punkte) und Stelle auf der aktuellen Seite + pub screen: egui::Pos2, + pub page: glam::Vec2, +} + /// Laufende Bedienung des Lineals. #[derive(Debug, Clone, Copy, PartialEq)] pub enum RulerDrag { @@ -170,6 +223,8 @@ pub enum UiAction { DeleteSelected, CopySelected, PasteSelected, + /// Zwischenablage an dieser Stelle der aktuellen Seite einfügen (Kontextmenü) + PasteAt(glam::Vec2), RotateSelected, // Dokumenten- & Notizbuch-Verwaltung OpenNotebook(String), @@ -279,6 +334,8 @@ pub struct UiState { pub is_text_color_menu_open: bool, /// Farbwähler in den Farb-Popovern aufgeklappt pub show_color_picker: bool, + /// Kontextmenü nach langem Drücken (Einfügen an dieser Stelle) + pub context_menu: Option, /// Rückfrage „Seite löschen?“ im ☰-Menü ist offen pub confirm_delete_page: bool, /// Seitenzahl der zuletzt gelöschten Seite, solange sie wiederhergestellt werden kann @@ -368,6 +425,7 @@ impl Default for UiState { active_text_color: 0, is_text_color_menu_open: false, show_color_picker: false, + context_menu: None, confirm_delete_page: false, restorable_page: None, shutdown_error: None, @@ -410,6 +468,7 @@ impl UiState { /// sich nie mehrere überlagern. pub fn close_overlays(&mut self) { self.confirm_delete_page = false; + self.context_menu = None; self.is_text_color_menu_open = false; self.is_pen_menu_open = false; self.is_highlighter_menu_open = false; @@ -634,7 +693,8 @@ impl UiState { /// - ODER innerhalb des geöffneten Einstellungs-Popovers /// - ODER innerhalb geöffneter Modalfenster (Dokumenten-Manager, Update-Center) pub fn is_point_in_ui(&self, px: f32, py: f32) -> bool { - if self.is_point_inside_toolbar(px, py) { + // Offenes Kontextmenü: ein Tippen daneben schließt es nur + if self.context_menu.is_some() || self.is_point_inside_toolbar(px, py) { return true; } // Lineal: Fläche und Dreh-Griff bedient die Oberfläche, die Kanten bleiben zum Zeichnen @@ -1674,6 +1734,47 @@ fn delete_page_buttons( } } +/// Kontextmenü nach langem Drücken auf das Blatt: Einfügen an dieser Stelle. Ein Tippen +/// daneben oder Esc schließt es. +fn context_menu(ctx: &egui::Context, state: &mut UiState, actions: &mut Vec) { + let Some(menu) = state.context_menu else { + return; + }; + let response = egui::Area::new(egui::Id::new("canvas_context_menu")) + .fixed_pos(menu.screen) + .constrain(true) + .order(egui::Order::Foreground) + .show(ctx, |ui| { + popover_frame().show(ui, |ui| { + touch_friendly_spacing(ui); + ui.set_width(220.0); + if ui + .add_sized([220.0, 36.0], egui::Button::new("📋 Einfügen")) + .on_hover_text( + "Bild, Text oder kopierte Objekte aus der Zwischenablage hier einfügen", + ) + .clicked() + { + actions.push(UiAction::PasteAt(menu.page)); + state.context_menu = None; + } + if ui + .add_sized([220.0, 36.0], egui::Button::new("🖼 Bild aus Datei …")) + .clicked() + { + state.open_image_picker(); + } + }) + }); + let rect = response.response.rect; + let pressed_outside = ctx.input(|i| { + i.pointer.any_pressed() && i.pointer.press_origin().is_some_and(|p| !rect.contains(p)) + }); + if pressed_outside || ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + state.context_menu = None; + } +} + /// 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) { @@ -2106,6 +2207,7 @@ pub fn render_ui( page_separators(ctx, state, notebook); ruler_overlay(ctx, state); text_edit_overlay(ctx, state); + context_menu(ctx, state, &mut actions); let mut pen_btn_rect = None; let mut high_btn_rect = None; @@ -4855,6 +4957,67 @@ mod tests { actions } + #[test] + fn test_long_press_detection() { + let t0 = Instant::now(); + let ms = |n| t0 + std::time::Duration::from_millis(n); + let mut press = LongPress::default(); + assert_eq!(press.fire(ms(1000)), None); + + // Stillhalten (kleines Zittern erlaubt): nach 500 ms genau einmal + press.start(1, (100.0, 100.0), t0); + press.moved(1, (106.0, 103.0), 1.0); + assert_eq!(press.deadline(), Some(ms(500))); + assert_eq!(press.fire(ms(499)), None); + assert_eq!(press.fire(ms(500)), Some((100.0, 100.0))); + assert_eq!(press.fire(ms(900)), None); + + // Verschieben bricht ab; bei 2× Skalierung zählt der Abstand in logischen Punkten + press.start(1, (100.0, 100.0), t0); + press.moved(1, (115.0, 100.0), 2.0); + assert!(press.deadline().is_some()); + press.moved(1, (125.0, 100.0), 2.0); + assert_eq!(press.fire(ms(600)), None); + + // Abheben oder ein anderer Finger brechen ab + press.start(1, (100.0, 100.0), t0); + press.cancel(); + assert_eq!(press.fire(ms(600)), None); + press.start(1, (100.0, 100.0), t0); + press.moved(2, (100.0, 100.0), 1.0); + assert_eq!(press.fire(ms(600)), None); + } + + #[test] + fn test_context_menu_pastes_at_pressed_point() { + let notebook = Notebook::new(); + let mut state = UiState::new(); + let at = glam::Vec2::new(800.0, 1200.0); + state.context_menu = Some(ContextMenu { + screen: egui::pos2(600.0, 400.0), + page: at, + }); + let ctx = render_frames(&mut state, ¬ebook, 3); + // Solange es offen ist, gehört jeder Punkt der Oberfläche (Tippen daneben schließt) + assert!(state.is_point_in_ui(1000.0, 700.0)); + + // „Einfügen“ ist der erste Eintrag + let actions = click_at(&ctx, &mut state, ¬ebook, egui::pos2(700.0, 425.0)); + assert!(actions.contains(&UiAction::PasteAt(at)), "{actions:?}"); + assert!(state.context_menu.is_none()); + + // Tippen daneben schließt das Menü ohne Aktion + state.context_menu = Some(ContextMenu { + screen: egui::pos2(600.0, 400.0), + page: at, + }); + let ctx = render_frames(&mut state, ¬ebook, 3); + let actions = click_at(&ctx, &mut state, ¬ebook, egui::pos2(1000.0, 700.0)); + assert!(state.context_menu.is_none()); + assert!(!actions.iter().any(|a| matches!(a, UiAction::PasteAt(_)))); + assert!(!state.is_point_in_ui(1000.0, 700.0)); + } + #[test] fn test_only_one_dialog_or_popover_is_open() { let notebook = Notebook::new();