feat: Lange drücken zum Einfügen (Issue #16)
- Ein Finger, der eine halbe Sekunde still auf dem Blatt liegt, öffnet ein Kontextmenü mit „Einfügen“ und „Bild aus Datei …“ (wie in Rnote) - Bild, Text oder kopierte Objekte aus der Zwischenablage landen an der gedrückten Stelle (Strg+V weiterhin in der Mitte) - Meldung, wenn die Zwischenablage leer ist Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -39,6 +39,7 @@ Getestet wird vor allem unter Zorin OS 18 / Ubuntu 24.04 (Wayland und X11).
|
|||||||
### Text, Bilder und PDFs
|
### 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.
|
- **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.
|
- **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.
|
- **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
|
### Seiten und Ansicht
|
||||||
|
|||||||
+69
-13
@@ -581,6 +581,7 @@ fn selection_release(
|
|||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn insert_text_on_page(
|
fn insert_text_on_page(
|
||||||
content: &str,
|
content: &str,
|
||||||
|
at: Option<Vec2>,
|
||||||
canvas: &mut CanvasState,
|
canvas: &mut CanvasState,
|
||||||
notebook: &mut Notebook,
|
notebook: &mut Notebook,
|
||||||
manifest: &mut NotebookManifest,
|
manifest: &mut NotebookManifest,
|
||||||
@@ -588,10 +589,13 @@ fn insert_text_on_page(
|
|||||||
selection_state: &mut SelectionState,
|
selection_state: &mut SelectionState,
|
||||||
ui_state: &mut UiState,
|
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(
|
let mut edit = ink_notes::text::TextEditing::new_at(
|
||||||
notebook.current_page().id,
|
notebook.current_page().id,
|
||||||
Vec2::new((center.x - 600.0).max(150.0), center.y),
|
position,
|
||||||
notebook.current_page().size.x,
|
notebook.current_page().size.x,
|
||||||
ui_state.text_size_pt * ink_notes::text::UNITS_PER_PT,
|
ui_state.text_size_pt * ink_notes::text::UNITS_PER_PT,
|
||||||
ui_state.text_font,
|
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
|
/// 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)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn paste(
|
fn paste(
|
||||||
|
at: Option<Vec2>,
|
||||||
clipboard: &mut ClipboardBridge,
|
clipboard: &mut ClipboardBridge,
|
||||||
storage_dir: &Path,
|
storage_dir: &Path,
|
||||||
canvas: &mut CanvasState,
|
canvas: &mut CanvasState,
|
||||||
@@ -820,7 +826,7 @@ fn paste(
|
|||||||
image.bytes.into_owned(),
|
image.bytes.into_owned(),
|
||||||
) {
|
) {
|
||||||
Ok(imported) => {
|
Ok(imported) => {
|
||||||
let center = visible_page_center(canvas);
|
let center = at.unwrap_or_else(|| visible_page_center(canvas));
|
||||||
insert_image_on_page(
|
insert_image_on_page(
|
||||||
imported,
|
imported,
|
||||||
center,
|
center,
|
||||||
@@ -844,6 +850,7 @@ fn paste(
|
|||||||
{
|
{
|
||||||
insert_text_on_page(
|
insert_text_on_page(
|
||||||
&text,
|
&text,
|
||||||
|
at,
|
||||||
canvas,
|
canvas,
|
||||||
notebook,
|
notebook,
|
||||||
manifest,
|
manifest,
|
||||||
@@ -874,17 +881,20 @@ fn paste(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let mut next_id = notebook.next_stroke_id;
|
let mut next_id = notebook.next_stroke_id;
|
||||||
let new_ids = selection_state.paste_from_clipboard(notebook.current_page_mut(), &mut || {
|
let new_ids =
|
||||||
let id = next_id;
|
selection_state.paste_from_clipboard(notebook.current_page_mut(), at, &mut || {
|
||||||
next_id += 1;
|
let id = next_id;
|
||||||
id
|
next_id += 1;
|
||||||
});
|
id
|
||||||
|
});
|
||||||
notebook.next_stroke_id = next_id;
|
notebook.next_stroke_id = next_id;
|
||||||
if !new_ids.is_empty() {
|
if !new_ids.is_empty() {
|
||||||
canvas.sync_page(notebook.current_page_mut());
|
canvas.sync_page(notebook.current_page_mut());
|
||||||
manifest.next_stroke_id = notebook.next_stroke_id;
|
manifest.next_stroke_id = notebook.next_stroke_id;
|
||||||
autosave.mark_dirty(notebook, manifest);
|
autosave.mark_dirty(notebook, manifest);
|
||||||
ui_state.set_export_message(format!("{} Objekt(e) eingefügt", new_ids.len()));
|
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<dyn std::error::Error>> {
|
|||||||
let mut is_pinch_gesturing = false;
|
let mut is_pinch_gesturing = false;
|
||||||
let mut is_touch_drawing = false;
|
let mut is_touch_drawing = false;
|
||||||
let mut is_touch_panning = 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);
|
let mut touch_pan_last_pos = (0.0_f32, 0.0_f32);
|
||||||
|
|
||||||
// Stylus / Digitizer Zustand (Octotablet / Wayland Tablet Protocol)
|
// Stylus / Digitizer Zustand (Octotablet / Wayland Tablet Protocol)
|
||||||
@@ -1561,6 +1573,27 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
// Von egui angefordertes, verzögertes Neuzeichnen (Tooltips, Animationen)
|
||||||
if let Some(at) = egui_repaint_at {
|
if let Some(at) = egui_repaint_at {
|
||||||
if Instant::now() >= at {
|
if Instant::now() >= at {
|
||||||
@@ -1578,7 +1611,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
|| is_stylus_in_proximity
|
|| is_stylus_in_proximity
|
||||||
{
|
{
|
||||||
target.set_control_flow(ControlFlow::wait_duration(Duration::from_millis(8)));
|
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));
|
target.set_control_flow(ControlFlow::WaitUntil(at));
|
||||||
} else {
|
} else {
|
||||||
target.set_control_flow(ControlFlow::Wait);
|
target.set_control_flow(ControlFlow::Wait);
|
||||||
@@ -1787,7 +1824,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
// Einfügen: Strg + V
|
// Einfügen: Strg + V
|
||||||
Key::Character(c) if c.eq_ignore_ascii_case("v") && ctrl => {
|
Key::Character(c) if c.eq_ignore_ascii_case("v") && ctrl => {
|
||||||
paste(
|
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,
|
&mut autosave, &mut selection_state, &mut ui_state,
|
||||||
);
|
);
|
||||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||||
@@ -2376,6 +2413,19 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
|
|
||||||
active_touches.insert(touch.id, (tx, ty));
|
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 {
|
if active_touches.len() >= 2 {
|
||||||
// Multi-Touch Geste: Sobald 2+ Finger aufliegen, Inking sofort abbrechen und zoomen/pannen
|
// Multi-Touch Geste: Sobald 2+ Finger aufliegen, Inking sofort abbrechen und zoomen/pannen
|
||||||
if is_touch_drawing {
|
if is_touch_drawing {
|
||||||
@@ -2442,6 +2492,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
TouchPhase::Moved => {
|
TouchPhase::Moved => {
|
||||||
active_touches.insert(touch.id, (tx, ty));
|
active_touches.insert(touch.id, (tx, ty));
|
||||||
|
long_press.moved(touch.id, (tx, ty), window.scale_factor() as f32);
|
||||||
|
|
||||||
if active_touches.len() >= 2 {
|
if active_touches.len() >= 2 {
|
||||||
let pts: Vec<(f32, f32)> = active_touches.values().copied().take(2).collect();
|
let pts: Vec<(f32, f32)> = active_touches.values().copied().take(2).collect();
|
||||||
@@ -2510,6 +2561,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
TouchPhase::Ended | TouchPhase::Cancelled => {
|
TouchPhase::Ended | TouchPhase::Cancelled => {
|
||||||
active_touches.remove(&touch.id);
|
active_touches.remove(&touch.id);
|
||||||
|
long_press.cancel();
|
||||||
|
|
||||||
if active_touches.is_empty() {
|
if active_touches.is_empty() {
|
||||||
is_pinch_gesturing = false;
|
is_pinch_gesturing = false;
|
||||||
@@ -2713,9 +2765,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
UiAction::PasteSelected => {
|
UiAction::PasteSelected | UiAction::PasteAt(_) => {
|
||||||
|
let at = match action {
|
||||||
|
UiAction::PasteAt(at) => Some(at),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
paste(
|
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,
|
&mut autosave, &mut selection_state, &mut ui_state,
|
||||||
);
|
);
|
||||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||||
|
|||||||
+20
-2
@@ -203,13 +203,19 @@ impl SelectionState {
|
|||||||
pub fn paste_from_clipboard(
|
pub fn paste_from_clipboard(
|
||||||
&mut self,
|
&mut self,
|
||||||
page: &mut Page,
|
page: &mut Page,
|
||||||
|
at: Option<Vec2>,
|
||||||
next_id_gen: &mut impl FnMut() -> u64,
|
next_id_gen: &mut impl FnMut() -> u64,
|
||||||
) -> Vec<u64> {
|
) -> Vec<u64> {
|
||||||
if self.clipboard.is_empty() {
|
if self.clipboard.is_empty() {
|
||||||
return Vec::new();
|
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_strokes = Vec::with_capacity(self.clipboard.len());
|
||||||
let mut new_ids = Vec::with_capacity(self.clipboard.len());
|
let mut new_ids = Vec::with_capacity(self.clipboard.len());
|
||||||
|
|
||||||
@@ -457,7 +463,7 @@ mod tests {
|
|||||||
|
|
||||||
// Paste
|
// Paste
|
||||||
let mut next_id = 100;
|
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 += 1;
|
||||||
next_id
|
next_id
|
||||||
});
|
});
|
||||||
@@ -465,6 +471,18 @@ mod tests {
|
|||||||
assert_eq!(page.strokes.len(), 3);
|
assert_eq!(page.strokes.len(), 3);
|
||||||
assert_eq!(sel.selected_ids, new_ids);
|
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
|
// Delete
|
||||||
let deleted = sel.delete_selected(&mut page);
|
let deleted = sel.delete_selected(&mut page);
|
||||||
assert_eq!(deleted.len(), 1);
|
assert_eq!(deleted.len(), 1);
|
||||||
|
|||||||
@@ -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<Instant> {
|
||||||
|
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.
|
/// Laufende Bedienung des Lineals.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
pub enum RulerDrag {
|
pub enum RulerDrag {
|
||||||
@@ -170,6 +223,8 @@ pub enum UiAction {
|
|||||||
DeleteSelected,
|
DeleteSelected,
|
||||||
CopySelected,
|
CopySelected,
|
||||||
PasteSelected,
|
PasteSelected,
|
||||||
|
/// Zwischenablage an dieser Stelle der aktuellen Seite einfügen (Kontextmenü)
|
||||||
|
PasteAt(glam::Vec2),
|
||||||
RotateSelected,
|
RotateSelected,
|
||||||
// Dokumenten- & Notizbuch-Verwaltung
|
// Dokumenten- & Notizbuch-Verwaltung
|
||||||
OpenNotebook(String),
|
OpenNotebook(String),
|
||||||
@@ -279,6 +334,8 @@ pub struct UiState {
|
|||||||
pub is_text_color_menu_open: bool,
|
pub is_text_color_menu_open: bool,
|
||||||
/// Farbwähler in den Farb-Popovern aufgeklappt
|
/// Farbwähler in den Farb-Popovern aufgeklappt
|
||||||
pub show_color_picker: bool,
|
pub show_color_picker: bool,
|
||||||
|
/// Kontextmenü nach langem Drücken (Einfügen an dieser Stelle)
|
||||||
|
pub context_menu: Option<ContextMenu>,
|
||||||
/// Rückfrage „Seite löschen?“ im ☰-Menü ist offen
|
/// Rückfrage „Seite löschen?“ im ☰-Menü ist offen
|
||||||
pub confirm_delete_page: bool,
|
pub confirm_delete_page: bool,
|
||||||
/// Seitenzahl der zuletzt gelöschten Seite, solange sie wiederhergestellt werden kann
|
/// Seitenzahl der zuletzt gelöschten Seite, solange sie wiederhergestellt werden kann
|
||||||
@@ -368,6 +425,7 @@ impl Default for UiState {
|
|||||||
active_text_color: 0,
|
active_text_color: 0,
|
||||||
is_text_color_menu_open: false,
|
is_text_color_menu_open: false,
|
||||||
show_color_picker: false,
|
show_color_picker: false,
|
||||||
|
context_menu: None,
|
||||||
confirm_delete_page: false,
|
confirm_delete_page: false,
|
||||||
restorable_page: None,
|
restorable_page: None,
|
||||||
shutdown_error: None,
|
shutdown_error: None,
|
||||||
@@ -410,6 +468,7 @@ impl UiState {
|
|||||||
/// sich nie mehrere überlagern.
|
/// sich nie mehrere überlagern.
|
||||||
pub fn close_overlays(&mut self) {
|
pub fn close_overlays(&mut self) {
|
||||||
self.confirm_delete_page = false;
|
self.confirm_delete_page = false;
|
||||||
|
self.context_menu = None;
|
||||||
self.is_text_color_menu_open = false;
|
self.is_text_color_menu_open = false;
|
||||||
self.is_pen_menu_open = false;
|
self.is_pen_menu_open = false;
|
||||||
self.is_highlighter_menu_open = false;
|
self.is_highlighter_menu_open = false;
|
||||||
@@ -634,7 +693,8 @@ impl UiState {
|
|||||||
/// - ODER innerhalb des geöffneten Einstellungs-Popovers
|
/// - ODER innerhalb des geöffneten Einstellungs-Popovers
|
||||||
/// - ODER innerhalb geöffneter Modalfenster (Dokumenten-Manager, Update-Center)
|
/// - ODER innerhalb geöffneter Modalfenster (Dokumenten-Manager, Update-Center)
|
||||||
pub fn is_point_in_ui(&self, px: f32, py: f32) -> bool {
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
// Lineal: Fläche und Dreh-Griff bedient die Oberfläche, die Kanten bleiben zum Zeichnen
|
// 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<UiAction>) {
|
||||||
|
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
|
/// Durchgehende Ansicht: dunkle Linie an jeder Grenze zweier Seiten (die Seiten liegen
|
||||||
/// lückenlos untereinander). Unter allen Fenstern der Oberfläche, über der Tinte.
|
/// lückenlos untereinander). Unter allen Fenstern der Oberfläche, über der Tinte.
|
||||||
fn page_separators(ctx: &egui::Context, state: &UiState, notebook: &Notebook) {
|
fn page_separators(ctx: &egui::Context, state: &UiState, notebook: &Notebook) {
|
||||||
@@ -2106,6 +2207,7 @@ pub fn render_ui(
|
|||||||
page_separators(ctx, state, notebook);
|
page_separators(ctx, state, notebook);
|
||||||
ruler_overlay(ctx, state);
|
ruler_overlay(ctx, state);
|
||||||
text_edit_overlay(ctx, state);
|
text_edit_overlay(ctx, state);
|
||||||
|
context_menu(ctx, state, &mut actions);
|
||||||
|
|
||||||
let mut pen_btn_rect = None;
|
let mut pen_btn_rect = None;
|
||||||
let mut high_btn_rect = None;
|
let mut high_btn_rect = None;
|
||||||
@@ -4855,6 +4957,67 @@ mod tests {
|
|||||||
actions
|
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]
|
#[test]
|
||||||
fn test_only_one_dialog_or_popover_is_open() {
|
fn test_only_one_dialog_or_popover_is_open() {
|
||||||
let notebook = Notebook::new();
|
let notebook = Notebook::new();
|
||||||
|
|||||||
Reference in New Issue
Block a user