feat: Bilder einfügen (Datei, Zwischenablage, Drag & Drop)

- Bilder per ☰ → „Bild einfügen …“ (Strg+I, eingebaute Dateiauswahl),
  Strg+V aus der Systemzwischenablage oder durch Ziehen ins Fenster
- Bilder sind Objekte mit vier Ecken: verschieben, drehen, skalieren,
  kopieren, löschen und Undo über die bestehende Auswahl
- Gespeichert einmalig unter ihrer Prüfsumme in <Notizbuch>/assets
  (JPEG unverändert, sonst PNG); Dateiformat-Version 4
- GPU-Darstellung mit Mipmaps unter der Tinte; Radierer und Umfärben
  lassen Bilder unberührt, Tinte auf Bildern hat beim Antippen Vorrang
- PDF-Export: JPEG verlustfrei eingebettet, sonst bis 300 dpi
- Strg+V: die jüngste Kopie gewinnt (Systembild oder interne Objekte);
  Bilder werden beim Einfügen in ein anderes Notizbuch mitkopiert

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 16:50:54 +02:00
co-authored by Claude Opus 5.5
parent c52e958c7f
commit b5c1da07fa
14 changed files with 2261 additions and 63 deletions
+167 -2
View File
@@ -16,6 +16,7 @@ use crate::background::{adapt_ink_color, PaperTheme};
use crate::document::{
EraserMode, Notebook, Page, PageBackground, StrokeTool, PAGE_HEIGHT, PAGE_WIDTH,
};
use crate::file_picker::FilePicker;
use crate::settings::{PressureCurve, SidebarSide};
use crate::storage::NotebookSummary;
@@ -151,6 +152,8 @@ pub enum UiAction {
},
/// Druckkurve speichern (gilt während des Ziehens bereits über `UiState::pressure_curve`)
SavePressureCurve(PressureCurve),
/// Bilddatei auf der aktuellen Seite einfügen
InsertImageFile(std::path::PathBuf),
// Beenden-Sicherheit bei Fehlschlag
RetryShutdown,
ExportRecoveryAndExit,
@@ -212,6 +215,10 @@ pub struct UiState {
pub pressure_curve: PressureCurve,
/// Zuletzt gemessener Rohdruck des Stifts (Anzeige in der Druckkurve)
pub last_raw_pressure: Option<f32>,
/// Geöffnete Dateiauswahl „Bild einfügen“
pub image_picker: Option<FilePicker>,
/// Zuletzt in der Dateiauswahl benutzter Ordner (während der Sitzung)
pub last_image_dir: Option<std::path::PathBuf>,
}
impl Default for UiState {
@@ -252,6 +259,8 @@ impl Default for UiState {
adapt_ink_colors: true,
pressure_curve: PressureCurve::default(),
last_raw_pressure: None,
image_picker: None,
last_image_dir: None,
}
}
}
@@ -273,6 +282,14 @@ impl UiState {
self.show_page_jump_popup = false;
self.is_document_menu_open = false;
self.is_update_dialog_open = false;
self.image_picker = None;
}
/// Öffnet die Dateiauswahl „Bild einfügen“ im zuletzt benutzten Ordner.
pub fn open_image_picker(&mut self) {
let dir = self.last_image_dir.clone();
self.close_overlays();
self.image_picker = Some(FilePicker::open(dir.as_deref()));
}
/// Länge der längsten Farbpalette; so viel Platz hält die Leiste immer frei.
@@ -758,10 +775,19 @@ pub fn page_thumbnail_shapes(
let scale = rect.width() / PAGE_WIDTH;
let to_screen = |p: glam::Vec2| rect.min + egui::vec2(p.x * scale, p.y * scale);
// Bilder als Flächen unter der Tinte
for corners in page.strokes.iter().filter_map(|s| s.image_corners()) {
shapes.push(egui::Shape::convex_polygon(
corners.iter().map(|&c| to_screen(c)).collect(),
egui::Color32::from_rgb(168, 182, 200),
egui::Stroke::NONE,
));
}
let total: usize = page.strokes.iter().map(|s| s.points.len()).sum();
let step = total.div_ceil(THUMBNAIL_POINT_BUDGET).max(1);
for stroke in &page.strokes {
for stroke in page.strokes.iter().filter(|s| !s.is_image()) {
let ink = if adapt {
adapt_ink_color(stroke.color)
} else {
@@ -953,6 +979,110 @@ fn pressure_curve_editor(ui: &mut egui::Ui, state: &mut UiState) -> Option<Press
save.then_some(state.pressure_curve)
}
/// Dialog „Bild einfügen“: Schnellzugriffe, Ordnerpfad und Liste aus Ordnern und Bildern.
fn image_picker_dialog(ctx: &egui::Context, state: &mut UiState, actions: &mut Vec<UiAction>) {
let mut close = false;
let mut navigate: Option<std::path::PathBuf> = None;
let mut go_up = false;
let mut chosen: Option<std::path::PathBuf> = None;
let Some(picker) = state.image_picker.as_ref() else {
return;
};
dialog_window("Bild einfügen", state, false).show(ctx, |ui| {
ui.set_width(460.0);
touch_friendly_spacing(ui);
if dialog_header(ui, "🖼 Bild einfügen", true) {
close = true;
}
ui.horizontal_wrapped(|ui| {
for (label, path) in crate::file_picker::shortcuts() {
if ui.selectable_label(picker.dir == path, label).clicked() {
navigate = Some(path);
}
}
});
ui.add_space(4.0);
ui.horizontal(|ui| {
if ui
.add_enabled(picker.dir.parent().is_some(), egui::Button::new("⏶"))
.on_hover_text("Übergeordneter Ordner")
.clicked()
{
go_up = true;
}
ui.add(
egui::Label::new(
egui::RichText::new(picker.dir.display().to_string())
.color(egui::Color32::from_gray(190)),
)
.truncate(true),
);
});
if let Some(err) = &picker.error {
ui.colored_label(egui::Color32::from_rgb(255, 120, 120), err);
}
ui.add_space(4.0);
egui::ScrollArea::vertical()
.max_height((ctx.screen_rect().height() - 330.0).clamp(160.0, 420.0))
.show(ui, |ui| {
ui.set_width(ui.available_width());
if picker.entries.is_empty() {
ui.label(
egui::RichText::new("Keine Ordner oder Bilder (PNG, JPEG, GIF, WebP, BMP)")
.color(egui::Color32::from_gray(150)),
);
}
for entry in &picker.entries {
let label = if entry.is_dir {
format!("📁 {}", entry.name)
} else {
format!("🖼 {}", entry.name)
};
let button = egui::Button::new(label)
.frame(false)
.min_size(egui::vec2(ui.available_width(), 34.0));
let response = ui.add(button);
if response.clicked() {
if entry.is_dir {
navigate = Some(entry.path.clone());
} else {
chosen = Some(entry.path.clone());
}
}
}
});
ui.add_space(6.0);
ui.label(
egui::RichText::new(
"Tipp: Screenshots mit Strg+V einfügen oder Bilddateien ins Fenster ziehen.",
)
.size(11.5)
.color(egui::Color32::from_gray(150)),
);
});
if let Some(picker) = state.image_picker.as_mut() {
if go_up {
picker.go_up();
}
if let Some(dir) = navigate {
picker.navigate(&dir);
}
}
if let Some(path) = chosen {
state.last_image_dir = state.image_picker.as_ref().map(|p| p.dir.clone());
actions.push(UiAction::InsertImageFile(path));
close = true;
}
if close {
state.image_picker = None;
}
}
/// Rahmen der Popover neben der Werkzeugleiste.
fn popover_frame() -> egui::Frame {
egui::Frame::none()
@@ -1838,6 +1968,13 @@ pub fn render_ui(
{
actions.push(UiAction::AddPage);
}
if ui
.add_sized([ui.available_width(), 30.0], egui::Button::new("🖼 Bild einfügen … (Strg+I)"))
.on_hover_text("Auch per Strg+V aus der Zwischenablage oder durch Ziehen einer Bilddatei ins Fenster")
.clicked()
{
state.open_image_picker();
}
section(ui, "HINTERGRUND DIESER SEITE");
let current_bg = notebook.current_page().background;
@@ -2298,6 +2435,11 @@ pub fn render_ui(
state.document_modal_rect = None;
}
// 3c. Dateiauswahl „Bild einfügen“
if state.image_picker.is_some() {
image_picker_dialog(ctx, state, &mut actions);
}
// 3c. Release- & Update-Center (Gitea Releases)
if state.is_update_dialog_open {
let win = dialog_window("Release- & Update-Center", state, false)
@@ -3139,10 +3281,11 @@ mod tests {
}
/// Quelltexte, deren String-Literale in der Oberfläche erscheinen können.
const UI_SOURCES: [(&str, &str); 3] = [
const UI_SOURCES: [(&str, &str); 4] = [
("ui.rs", include_str!("ui.rs")),
("selection.rs", include_str!("selection.rs")),
("main.rs", include_str!("main.rs")),
("file_picker.rs", include_str!("file_picker.rs")),
];
/// Liefert alle Zeichen aus String-Literalen eines Quelltexts (ohne Zeilenkommentare).
@@ -3421,6 +3564,28 @@ mod tests {
pts.len(),
THUMBNAIL_POINT_BUDGET + 200
);
// Bild: gefüllte Fläche statt Umrisslinie, unter der Tinte
let mut with_image = page_with_strokes(1, 5);
with_image.strokes.insert(
0,
crate::document::Stroke::new_image(
99,
"a.png".into(),
[
glam::Vec2::new(100.0, 100.0),
glam::Vec2::new(900.0, 100.0),
glam::Vec2::new(900.0, 600.0),
glam::Vec2::new(100.0, 600.0),
],
),
);
let shapes = page_thumbnail_shapes(&with_image, rect, PaperTheme::White, true);
assert_eq!(shapes.len(), 3);
assert!(
matches!(shapes[1], egui::Shape::Path(ref p) if p.closed && p.fill != egui::Color32::TRANSPARENT)
);
assert!(matches!(shapes[2], egui::Shape::Path(ref p) if !p.closed));
}
#[test]