feat(ui): Sichere Shortcuts, dediziertes Ctrl+S Speichern und kollisionsfreier PDF-Export
This commit is contained in:
+104
-1
@@ -12,7 +12,7 @@
|
|||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::io::BufWriter;
|
use std::io::BufWriter;
|
||||||
use std::path::Path;
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use printpdf::path::{PaintMode, WindingOrder};
|
use printpdf::path::{PaintMode, WindingOrder};
|
||||||
use printpdf::{Color, Line, Mm, PdfDocument, PdfDocumentReference, Point, Polygon, Rgb};
|
use printpdf::{Color, Line, Mm, PdfDocument, PdfDocumentReference, Point, Polygon, Rgb};
|
||||||
@@ -22,6 +22,70 @@ use crate::document::{
|
|||||||
Notebook, Page, PageBackground, Stroke, A4_HEIGHT_MM, A4_WIDTH_MM, PAGE_HEIGHT, PAGE_WIDTH,
|
Notebook, Page, PageBackground, Stroke, A4_HEIGHT_MM, A4_WIDTH_MM, PAGE_HEIGHT, PAGE_WIDTH,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Bereinigt einen Titel für die Verwendung in Dateinamen (entfernt illegale Zeichen).
|
||||||
|
pub fn sanitize_filename_component(title: &str) -> String {
|
||||||
|
let sanitized: String = title
|
||||||
|
.chars()
|
||||||
|
.map(|c| match c {
|
||||||
|
'/' | '\\' | '\0' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
|
||||||
|
c => c,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let trimmed = sanitized.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
"Notizbuch".to_string()
|
||||||
|
} else {
|
||||||
|
trimmed.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ermittelt das Standard-Dokumentenverzeichnis ($XDG_DOCUMENTS_DIR mit Fallback ~/Documents).
|
||||||
|
pub fn default_documents_dir() -> PathBuf {
|
||||||
|
if let Ok(dir) = std::env::var("XDG_DOCUMENTS_DIR") {
|
||||||
|
if !dir.trim().is_empty() {
|
||||||
|
return PathBuf::from(dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(home) = std::env::var("HOME") {
|
||||||
|
if !home.trim().is_empty() {
|
||||||
|
return PathBuf::from(home).join("Documents");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PathBuf::from(".")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ermittelt den kollisionsfreien Export-Pfad für ein Notizbuch im übergebenen Verzeichnis.
|
||||||
|
/// Existiert die Datei bereits, wird ein Zähler angehängt (`_1`, `_2`, etc.).
|
||||||
|
pub fn resolve_pdf_export_path_in_dir(dir: &Path, title: &str) -> PathBuf {
|
||||||
|
let sanitized_title = sanitize_filename_component(title);
|
||||||
|
let base_name = format!("{}.pdf", sanitized_title);
|
||||||
|
let primary_path = dir.join(&base_name);
|
||||||
|
|
||||||
|
if !primary_path.exists() {
|
||||||
|
return primary_path;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut counter = 1;
|
||||||
|
loop {
|
||||||
|
let candidate_name = format!("{}_{}.pdf", sanitized_title, counter);
|
||||||
|
let candidate_path = dir.join(candidate_name);
|
||||||
|
if !candidate_path.exists() {
|
||||||
|
return candidate_path;
|
||||||
|
}
|
||||||
|
counter += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ermittelt das Ziel für den PDF-Export unter Berücksichtigung von XDG_DOCUMENTS_DIR
|
||||||
|
/// und automatischer Namenskollisionsvermeidung. Erstellt das Verzeichnis, falls nötig.
|
||||||
|
pub fn resolve_pdf_export_path(title: &str) -> PathBuf {
|
||||||
|
let docs_dir = default_documents_dir();
|
||||||
|
let _ = std::fs::create_dir_all(&docs_dir);
|
||||||
|
resolve_pdf_export_path_in_dir(&docs_dir, title)
|
||||||
|
}
|
||||||
|
|
||||||
/// Wählbare Exportstrategie für Vektorstriche.
|
/// Wählbare Exportstrategie für Vektorstriche.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
pub enum ExportStrategy {
|
pub enum ExportStrategy {
|
||||||
@@ -819,4 +883,43 @@ mod tests {
|
|||||||
assert!(bytes.len() > 1024);
|
assert!(bytes.len() > 1024);
|
||||||
assert!(bytes.starts_with(b"%PDF-1."));
|
assert!(bytes.starts_with(b"%PDF-1."));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sanitize_filename_component() {
|
||||||
|
assert_eq!(sanitize_filename_component("Notizbuch 1"), "Notizbuch 1");
|
||||||
|
assert_eq!(
|
||||||
|
sanitize_filename_component("Projekt/2026: Entwurf"),
|
||||||
|
"Projekt_2026_ Entwurf"
|
||||||
|
);
|
||||||
|
assert_eq!(sanitize_filename_component(" "), "Notizbuch");
|
||||||
|
assert_eq!(sanitize_filename_component(""), "Notizbuch");
|
||||||
|
assert_eq!(
|
||||||
|
sanitize_filename_component("Mathe * Vorlesung?"),
|
||||||
|
"Mathe _ Vorlesung_"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resolve_pdf_export_path_collision_avoidance() {
|
||||||
|
let temp_dir = tempfile::tempdir().expect("Konnte tempdir nicht erstellen");
|
||||||
|
let dir = temp_dir.path();
|
||||||
|
|
||||||
|
// 1. Erstmaliger Export: keine Kollision
|
||||||
|
let path1 = resolve_pdf_export_path_in_dir(dir, "Mathe Vorlesung");
|
||||||
|
assert_eq!(path1, dir.join("Mathe Vorlesung.pdf"));
|
||||||
|
|
||||||
|
// Datei anlegen
|
||||||
|
std::fs::write(&path1, b"dummy pdf 1").expect("Konnte Datei nicht schreiben");
|
||||||
|
|
||||||
|
// 2. Zweiter Export: Kollision -> _1
|
||||||
|
let path2 = resolve_pdf_export_path_in_dir(dir, "Mathe Vorlesung");
|
||||||
|
assert_eq!(path2, dir.join("Mathe Vorlesung_1.pdf"));
|
||||||
|
|
||||||
|
// Zweite Datei anlegen
|
||||||
|
std::fs::write(&path2, b"dummy pdf 2").expect("Konnte Datei nicht schreiben");
|
||||||
|
|
||||||
|
// 3. Dritter Export: Kollision -> _2
|
||||||
|
let path3 = resolve_pdf_export_path_in_dir(dir, "Mathe Vorlesung");
|
||||||
|
assert_eq!(path3, dir.join("Mathe Vorlesung_2.pdf"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+77
-38
@@ -16,7 +16,7 @@ use glam::Vec2;
|
|||||||
use ink_notes::autosave::{AutosaveController, AutosaveStatus};
|
use ink_notes::autosave::{AutosaveController, AutosaveStatus};
|
||||||
use ink_notes::canvas::CanvasState;
|
use ink_notes::canvas::CanvasState;
|
||||||
use ink_notes::document::{Notebook, PageBackground};
|
use ink_notes::document::{Notebook, PageBackground};
|
||||||
use ink_notes::export::{export_notebook_to_pdf, ExportStrategy};
|
use ink_notes::export::{self, export_notebook_to_pdf, ExportStrategy};
|
||||||
use ink_notes::geometry::{self, RecognizedShape};
|
use ink_notes::geometry::{self, RecognizedShape};
|
||||||
use ink_notes::selection::{
|
use ink_notes::selection::{
|
||||||
render_selection_overlay, DragMode, SelectionHandleHit, SelectionState,
|
render_selection_overlay, DragMode, SelectionHandleHit, SelectionState,
|
||||||
@@ -181,8 +181,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
println!(" Strg + 0 : Zoom zurücksetzen (100% zentriert)");
|
println!(" Strg + 0 : Zoom zurücksetzen (100% zentriert)");
|
||||||
println!(" Draw-and-Hold : Form am Strichende >400ms halten zum automatischen Einrasten");
|
println!(" Draw-and-Hold : Form am Strichende >400ms halten zum automatischen Einrasten");
|
||||||
println!(" Strg + Z / Y : Transaktionales Undo / Redo für alle Aktionen");
|
println!(" Strg + Z / Y : Transaktionales Undo / Redo für alle Aktionen");
|
||||||
println!(" Strg + E / S : Vektor-PDF-Export (nach ./export.pdf)");
|
println!(" Strg + E : Vektor-PDF-Export (~/Documents/<Titel>.pdf)");
|
||||||
println!(" ESC / Q : Beenden (mit sicherem Disk-Flush)");
|
println!(" Strg + S : Jetzt speichern (Disk Flush)");
|
||||||
|
println!(" ESC : Dialoge / Menüs schließen oder Selektion aufheben");
|
||||||
|
println!(" Strg + Q : Beenden (mit sicherem Disk-Flush)");
|
||||||
|
|
||||||
event_loop.run(move |event, target| {
|
event_loop.run(move |event, target| {
|
||||||
match event {
|
match event {
|
||||||
@@ -310,9 +312,29 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
|
|
||||||
match &logical_key {
|
match &logical_key {
|
||||||
Key::Named(NamedKey::Escape) => {
|
Key::Named(NamedKey::Escape) => {
|
||||||
println!("[Shutdown] Sichere alle ungespeicherten Daten auf Festplatte...");
|
let mut changed = false;
|
||||||
autosave.shutdown();
|
if ui_state.is_pen_menu_open
|
||||||
target.exit();
|
|| ui_state.is_highlighter_menu_open
|
||||||
|
|| ui_state.show_page_jump_popup
|
||||||
|
|| ui_state.is_document_menu_open
|
||||||
|
|| ui_state.is_update_dialog_open
|
||||||
|
|| ui_state.show_updater_config
|
||||||
|
{
|
||||||
|
ui_state.is_pen_menu_open = false;
|
||||||
|
ui_state.is_highlighter_menu_open = false;
|
||||||
|
ui_state.show_page_jump_popup = false;
|
||||||
|
ui_state.is_document_menu_open = false;
|
||||||
|
ui_state.is_update_dialog_open = false;
|
||||||
|
ui_state.show_updater_config = false;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
else if !selection_state.is_empty() {
|
||||||
|
selection_state.clear();
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
|
window.request_redraw();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Undo: Strg + Z
|
// Undo: Strg + Z
|
||||||
@@ -383,45 +405,58 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// PDF-Export: Strg + E oder Strg + S
|
// Jetzt speichern (Disk Flush): Strg + S
|
||||||
Key::Character(c)
|
Key::Character(c) if c.eq_ignore_ascii_case("s") && ctrl => {
|
||||||
if (c.eq_ignore_ascii_case("e") || c.eq_ignore_ascii_case("s"))
|
autosave.flush();
|
||||||
&& ctrl =>
|
ui_state.set_export_message("Gespeichert");
|
||||||
{
|
update_window_title(
|
||||||
|
&window,
|
||||||
|
¬ebook,
|
||||||
|
&manifest,
|
||||||
|
ui_state.active_tool,
|
||||||
|
autosave.status(),
|
||||||
|
);
|
||||||
|
window.request_redraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
// PDF-Export: Strg + E
|
||||||
|
Key::Character(c) if c.eq_ignore_ascii_case("e") && ctrl => {
|
||||||
let notebook_clone = notebook.clone();
|
let notebook_clone = notebook.clone();
|
||||||
let export_path = "export.pdf".to_string();
|
let export_path = export::resolve_pdf_export_path(&manifest.title);
|
||||||
|
let export_path_str = export_path.display().to_string();
|
||||||
let window_ref = window.clone();
|
let window_ref = window.clone();
|
||||||
println!(
|
println!(
|
||||||
"[Export] Starte PDF-Vektorexport nach '{}' (im Hintergrundthread)...",
|
"[Export] Starte PDF-Vektorexport nach '{}' (im Hintergrundthread)...",
|
||||||
export_path
|
export_path_str
|
||||||
);
|
);
|
||||||
ui_state.set_export_message("Exportiere PDF...");
|
ui_state.set_export_message(format!("PDF gespeichert: {}", export_path.display()));
|
||||||
|
let export_path_thread = export_path.clone();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let start = std::time::Instant::now();
|
let start = std::time::Instant::now();
|
||||||
match export_notebook_to_pdf(
|
match export_notebook_to_pdf(
|
||||||
¬ebook_clone,
|
¬ebook_clone,
|
||||||
&export_path,
|
&export_path_thread,
|
||||||
ExportStrategy::FilledPolygon,
|
ExportStrategy::FilledPolygon,
|
||||||
) {
|
) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
let elapsed = start.elapsed();
|
let elapsed = start.elapsed();
|
||||||
println!(
|
println!(
|
||||||
"[Export] PDF erfolgreich exportiert: '{}' ({:.1?} für {} Seiten)",
|
"[Export] PDF erfolgreich exportiert: '{}' ({:.1?} für {} Seiten)",
|
||||||
export_path,
|
export_path_thread.display(),
|
||||||
elapsed,
|
elapsed,
|
||||||
notebook_clone.pages.len()
|
notebook_clone.pages.len()
|
||||||
);
|
);
|
||||||
window_ref.request_redraw();
|
window_ref.request_redraw();
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
eprintln!("[Export-Fehler] Konnte PDF nicht exportieren: {err}");
|
eprintln!("[Export-Fehler] Konnte PDF nicht exportieren: {err}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
ui_state.set_export_message("PDF erfolgreich als export.pdf exportiert!");
|
window.request_redraw();
|
||||||
window.request_redraw();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Radierer umschalten: E
|
// Radierer umschalten: E
|
||||||
Key::Character(c) if c.eq_ignore_ascii_case("e") && !ctrl => {
|
Key::Character(c) if c.eq_ignore_ascii_case("e") && !ctrl => {
|
||||||
ui_state.active_tool = match ui_state.active_tool {
|
ui_state.active_tool = match ui_state.active_tool {
|
||||||
@@ -618,13 +653,14 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Beenden: Q
|
// Beenden: Strg + Q
|
||||||
Key::Character(c) if c.eq_ignore_ascii_case("q") && !ctrl => {
|
Key::Character(c) if c.eq_ignore_ascii_case("q") && ctrl => {
|
||||||
println!("[Shutdown] Sichere alle ungespeicherten Daten auf Festplatte...");
|
println!("[Shutdown] Sichere alle ungespeicherten Daten auf Festplatte...");
|
||||||
autosave.shutdown();
|
autosave.shutdown();
|
||||||
target.exit();
|
target.exit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1498,24 +1534,27 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
UiAction::ExportPdf => {
|
UiAction::ExportPdf => {
|
||||||
let notebook_clone = notebook.clone();
|
let notebook_clone = notebook.clone();
|
||||||
let export_path = "export.pdf".to_string();
|
let export_path = export::resolve_pdf_export_path(&manifest.title);
|
||||||
|
let export_path_str = export_path.display().to_string();
|
||||||
let window_ref = window.clone();
|
let window_ref = window.clone();
|
||||||
println!(
|
println!(
|
||||||
"[Export] Starte PDF-Vektorexport nach '{}' (im Hintergrundthread)...",
|
"[Export] Starte PDF-Vektorexport nach '{}' (im Hintergrundthread)...",
|
||||||
export_path
|
export_path_str
|
||||||
);
|
);
|
||||||
|
ui_state.set_export_message(format!("PDF gespeichert: {}", export_path.display()));
|
||||||
|
let export_path_thread = export_path.clone();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let start = std::time::Instant::now();
|
let start = std::time::Instant::now();
|
||||||
match export_notebook_to_pdf(
|
match export_notebook_to_pdf(
|
||||||
¬ebook_clone,
|
¬ebook_clone,
|
||||||
&export_path,
|
&export_path_thread,
|
||||||
ExportStrategy::FilledPolygon,
|
ExportStrategy::FilledPolygon,
|
||||||
) {
|
) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
let elapsed = start.elapsed();
|
let elapsed = start.elapsed();
|
||||||
println!(
|
println!(
|
||||||
"[Export] PDF erfolgreich exportiert: '{}' ({:.1?} für {} Seiten)",
|
"[Export] PDF erfolgreich exportiert: '{}' ({:.1?} für {} Seiten)",
|
||||||
export_path,
|
export_path_thread.display(),
|
||||||
elapsed,
|
elapsed,
|
||||||
notebook_clone.pages.len()
|
notebook_clone.pages.len()
|
||||||
);
|
);
|
||||||
@@ -1526,9 +1565,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
ui_state.set_export_message("PDF erfolgreich als export.pdf exportiert!");
|
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
UiAction::RefreshNotebooks => {
|
UiAction::RefreshNotebooks => {
|
||||||
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
|
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
|
|||||||
Reference in New Issue
Block a user