feat: durchgehendes Scrollen

Seiten hängen untereinander (8 mm Abstand) und werden mit Mausrad, Finger
oder Touchpad fließend gescrollt; Strg+Mausrad zoomt. Sichtbare Nachbarseiten
werden bei Bedarf geladen und mit eigener Kamera gezeichnet. Die Seite in der
Fenstermitte wird nach dem Scrollen zur aktuellen; Zeichnen, Radieren und
Auswählen auf einer Nachbarseite wechseln zuerst dorthin. Wer im unteren
Fünftel der letzten Seite schreibt, bekommt automatisch eine neue Seite.
Scrollen bleibt auf das Notizbuch begrenzt. Abschaltbar im ☰-Menü (Ansicht).

Nie gespeicherte, angehängte Seiten laden jetzt als leere Seite statt als
schreibgeschützte Fehlerseite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 23:36:19 +02:00
co-authored by Claude Opus 5.5
parent 9dec688b84
commit 6f2b414bb8
9 changed files with 696 additions and 43 deletions
+211 -1
View File
@@ -32,6 +32,7 @@ use ink_notes::settings::{SidebarSide, UiSettings};
use ink_notes::storage::{self, NotebookManifest};
use ink_notes::ui::{self, ActiveTool, UiAction, UiState};
use ink_notes::updater::UpdaterController;
use ink_notes::viewport::PAGE_PITCH;
/// Aktualisiert den Fenstertitel als minimales On-Screen-Display (OSD) inklusive Autosave-Status.
/// Speichert die UI-Einstellungen; ein Fehler wird gemeldet, bricht aber nichts ab.
@@ -151,11 +152,19 @@ fn commit_stroke_to_notebook(
if let Some(mut stroke) = stroke_opt {
stroke.tool = ui_state.active_tool.stroke_tool(stroke.shape.is_some());
let stroke_bottom = stroke.aabb.max.y;
let page = notebook.current_page_mut();
page.add_stroke(stroke);
canvas.sync_page(page);
manifest.next_stroke_id = notebook.next_stroke_id;
autosave.mark_dirty(notebook, manifest);
// Durchgehende Ansicht: unten auf der letzten Seite geht es auf einer neuen weiter
if ui_state.continuous_scroll && notebook.append_page_if_writing_at_end(stroke_bottom) {
manifest.sync_from_notebook(notebook);
if let Some(new_page) = notebook.pages.last() {
autosave.mark_page_dirty(new_page, manifest);
}
}
update_window_title(
window,
notebook,
@@ -167,6 +176,83 @@ fn commit_stroke_to_notebook(
}
}
/// Macht die Seite `index` zur aktuellen: lädt sie bei Bedarf und aktualisiert GPU-Puffer,
/// Manifest und Fenstertitel. Mit `keep_view` (durchgehende Ansicht) bleibt der Bildausschnitt
/// samt Lineal stehen, sonst erscheint die neue Seite an der Stelle der alten.
#[allow(clippy::too_many_arguments)]
fn switch_to_page(
index: usize,
keep_view: bool,
canvas: &mut CanvasState,
notebook: &mut Notebook,
manifest: &mut NotebookManifest,
autosave: &mut AutosaveController,
storage_dir: &Path,
selection_state: &mut SelectionState,
ui_state: &mut UiState,
window: &Window,
) {
let current = notebook.current_page_index;
if index >= notebook.pages.len() || index == current {
return;
}
if keep_view {
let k = index as i64 - current as i64;
canvas.shift_reference_page(k);
if let Some(ruler) = &mut ui_state.ruler {
ruler.center.y -= k as f32 * PAGE_PITCH;
}
canvas.ruler = ui_state.ruler;
}
selection_state.clear();
notebook.current_page_index = index;
let _ = storage::ensure_page_loaded(storage_dir, notebook, index);
canvas.sync_page(notebook.current_page_mut());
manifest.sync_from_notebook(notebook);
autosave.mark_manifest_dirty(manifest);
update_window_title(
window,
notebook,
manifest,
ui_state.active_tool,
autosave.status(),
);
window.request_redraw();
}
/// Durchgehende Ansicht: Index der Seite unter dem Bildschirmpunkt, falls dort eine andere
/// als die aktuelle Seite liegt und kein Griff der Auswahl getroffen wird. Zeichnen,
/// Radieren und Auswählen wechseln dann zuerst auf diese Seite.
fn page_to_focus(
canvas: &CanvasState,
notebook: &Notebook,
ui_state: &UiState,
selection_state: &SelectionState,
pixels_per_point: f32,
(sx, sy): (f32, f32),
) -> Option<usize> {
if !ui_state.continuous_scroll {
return None;
}
let k = canvas.page_offset_at_screen(sx, sy).filter(|&k| k != 0)?;
let index = usize::try_from(notebook.current_page_index as i64 + k).ok()?;
if index >= notebook.pages.len() {
return None;
}
let grabs_selection = ui_state.active_tool == ActiveTool::Select
&& !selection_state.is_empty()
&& !matches!(
selection_state.hit_test_handles(
notebook.current_page(),
canvas,
Vec2::new(sx, sy),
pixels_per_point
),
SelectionHandleHit::None
);
(!grabs_selection).then_some(index)
}
/// Zwischenablage: interne Objekte (Strg+C in ink-notes) und Bilder aus der
/// Systemzwischenablage (Screenshots, aus anderen Programmen kopierte Bilder).
struct ClipboardBridge {
@@ -618,6 +704,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
ui_state.paper_theme = ui_settings.paper_theme;
ui_state.adapt_ink_colors = ui_settings.adapt_ink_colors;
ui_state.pressure_curve = ui_settings.pressure_curve;
ui_state.continuous_scroll = ui_settings.continuous_scroll;
ui_state.current_notebook_id = manifest.id.clone();
ui_state.document_title = manifest.title.clone();
ui_state.available_notebooks = available_notebooks;
@@ -727,7 +814,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
println!(" Strg + C / V : Ausgewählte Objekte kopieren & einfügen");
println!(" Entf/Backspace : Ausgewählte Objekte löschen (Undo mit Strg+Z)");
println!("Tastatur- & Maus-Shortcuts:");
println!(" Mausrad : Stufenloser Zoom zentriert auf Cursor");
println!(" Mausrad : Scrollen (durchgehende Ansicht), sonst Zoom");
println!(" Strg + Mausrad : Stufenloser Zoom zentriert auf Cursor");
println!(" Mittlere Taste : Canvas verschieben (Pan)");
println!(" Leertaste+Drag : Canvas verschieben (Pan)");
println!(" Strg + 0 : Zoom zurücksetzen (100% zentriert)");
@@ -738,6 +826,38 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
println!(" ESC : Dialoge / Menüs schließen oder Selektion aufheben");
println!(" Strg + Q : Beenden (mit sicherem Disk-Flush)");
// Durchgehende Ansicht: Scrollposition beim letzten Abgleich der aktuellen Seite (Zoom,
// Pan unabhängig von der Bezugsseite)
let mut last_scroll: Option<[f32; 3]> = None;
// Durchgehende Ansicht: vor Zeichnen, Radieren oder Auswählen auf die Seite unter dem
// Bildschirmpunkt wechseln
macro_rules! focus_page_at {
($pos:expr) => {
if let Some(index) = page_to_focus(
&canvas,
&notebook,
&ui_state,
&selection_state,
egui_ctx.pixels_per_point(),
$pos,
) {
switch_to_page(
index,
true,
&mut canvas,
&mut notebook,
&mut manifest,
&mut autosave,
&storage_dir,
&mut selection_state,
&mut ui_state,
&window,
);
}
};
}
event_loop.run(move |event, target| {
match event {
Event::AboutToWait => {
@@ -860,6 +980,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
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,
eraser_radius,
@@ -918,6 +1039,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
continue;
}
focus_page_at!((sx, sy));
let should_erase = tool_is_hardware_eraser
|| stylus_barrel_pressed
|| ui_state.active_tool == ActiveTool::Eraser;
@@ -1563,6 +1685,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
return;
}
focus_page_at!(cursor_screen_pos);
if is_eraser_action {
is_erasing = true;
let (sx, sy) = cursor_screen_pos;
@@ -1832,6 +1955,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
}
} 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, eraser_radius, stroke_max_radius, ui_state.eraser_mode) {
canvas.sync_page(notebook.current_page_mut());
@@ -1863,6 +1987,23 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if is_pointer_in_ui {
return;
}
// Durchgehende Ansicht: Mausrad scrollt (mit Umschalt waagerecht),
// Strg + Mausrad zoomt
if ui_state.continuous_scroll && !modifiers.control_key() {
let (mut dx, mut dy) = match delta {
MouseScrollDelta::LineDelta(x, y) => {
let line = 60.0 * window.scale_factor() as f32;
(x * line, y * line)
}
MouseScrollDelta::PixelDelta(pos) => (pos.x as f32, pos.y as f32),
};
if modifiers.shift_key() && dx == 0.0 {
(dx, dy) = (dy, 0.0);
}
canvas.pan(dx, dy);
window.request_redraw();
return;
}
let zoom_factor = match delta {
MouseScrollDelta::LineDelta(_x, y) => {
if y > 0.0 {
@@ -1967,6 +2108,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
touch_pan_last_pos = (tx, ty);
} else {
// Modus Finger-Zeichnen aktiv
focus_page_at!((tx, ty));
if is_eraser_action {
is_erasing = true;
if let Some(page_pt) = canvas.screen_to_page(tx, ty) {
@@ -2108,6 +2250,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
window.request_redraw();
}
} else if is_erasing {
focus_page_at!((tx, ty));
if let Some(page_pt) = canvas.screen_to_page(tx, ty) {
if notebook.erase_at(page_pt, eraser_radius, stroke_max_radius, ui_state.eraser_mode) {
canvas.sync_page(notebook.current_page_mut());
@@ -2248,6 +2391,48 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
WindowEvent::RedrawRequested => {
// 0. Durchgehende Ansicht: Nach dem Scrollen oder Zoomen wird die Seite in
// der Fenstermitte zur aktuellen (nicht während eines Strichs, einer
// Auswahl oder beim Ziehen des Lineals). Wer auf einer Nachbarseite
// schreibt, bleibt dort, bis wieder gescrollt wird (Undo betrifft sie).
if ui_state.continuous_scroll {
canvas.clamp_scroll(notebook.current_page_index, notebook.pages.len());
let scroll = [
canvas.camera.zoom,
canvas.camera.pan_offset[0],
canvas.camera.pan_offset[1]
- notebook.current_page_index as f32 * PAGE_PITCH,
];
let scrolled = last_scroll
.is_none_or(|last: [f32; 3]| last.iter().zip(scroll).any(|(a, b)| (a - b).abs() > 1e-3));
let busy = is_drawing
|| is_erasing
|| !selection_state.is_empty()
|| selection_state.drag_mode != DragMode::None
|| ui_state.ruler_drag.is_some();
if scrolled && !busy {
last_scroll = Some(scroll);
let target = canvas
.page_offset_at_view_center()
.filter(|&k| k != 0)
.and_then(|k| usize::try_from(notebook.current_page_index as i64 + k).ok());
if let Some(index) = target {
switch_to_page(
index,
true,
&mut canvas,
&mut notebook,
&mut manifest,
&mut autosave,
&storage_dir,
&mut selection_state,
&mut ui_state,
&window,
);
}
}
}
// 1. egui Eingabe erfassen und Frame starten
let raw_input = egui_winit_state.take_egui_input(&window);
egui_ctx.begin_frame(raw_input);
@@ -2851,6 +3036,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
window.request_redraw();
}
UiAction::SetContinuousScroll(on) => {
ui_settings.continuous_scroll = on;
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
window.request_redraw();
}
UiAction::SavePressureCurve(curve) => {
ui_settings.pressure_curve = curve;
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
@@ -2893,6 +3083,26 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
// 5. Canvas (Pass 1) und egui UI (Pass 2) in Surface-Textur rendern
// Durchgehende Ansicht: sichtbare Nachbarseiten laden und mitzeichnen
if ui_state.continuous_scroll {
let current = notebook.current_page_index;
let visible: Vec<(i64, usize)> = canvas
.visible_neighbor_offsets(current, notebook.pages.len())
.into_iter()
.map(|k| (k, (current as i64 + k) as usize))
.collect();
for &(_, index) in &visible {
let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, index);
notebook.pages[index].rebuild_cache_if_dirty();
}
let neighbors: Vec<(i64, &ink_notes::document::Page)> = visible
.iter()
.map(|&(k, index)| (k, &notebook.pages[index]))
.collect();
canvas.set_neighbor_pages(&neighbors);
} else {
canvas.set_neighbor_pages(&[]);
}
let assets = AssetStore::new(&storage_dir);
canvas.prepare_images(&mut |key| texture_for(key, &assets, &mut pdf_renderer));
// Nächste PDF-Seite vorab rendern, damit Blättern sofort klappt