feat: Endlose Fläche als Seitenanordnung (Issue #30, Schritt 2)

Ein endloses Notizbuch hat eine einzige Seite von 10 × 10 m; Eingabe,
Treffertests, Undo und Speichern bleiben dadurch unverändert. Die
Ansicht zeigt bei 100 % einen DIN-A4-Rahmen in der Mitte, Bilder und
Textfelder richten sich nach A4. Wählbar beim Anlegen und im ☰-Menü,
umschalten nur bei einer Seite ohne PDF bzw. zurück, solange alles im
Startausschnitt liegt. Seiten-Knöpfe sind auf der endlosen Fläche aus.

Der Export enthält nur den beschriebenen Bereich mit 10 mm Rand, auf
das Musterraster gelegt; Bilder einer Auswahl rastern nur noch den
Ausschnitt statt der ganzen Seite. Formatversion 9.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-18 22:14:45 +02:00
co-authored by Claude Opus 5.5
parent 8bc29faddc
commit 3c793fe659
11 changed files with 461 additions and 66 deletions
+14 -8
View File
@@ -710,14 +710,20 @@ impl CanvasState {
}
fn recalculate_viewport(&mut self) {
self.viewport = PageViewport::calculate_for(
self.size.width as f32,
self.size.height as f32,
Self::VIEWPORT_MARGIN,
(self.ui_inset_left, self.ui_inset_right),
self.fit_box,
self.page_size,
);
let insets = (self.ui_inset_left, self.ui_inset_right);
let (width, height) = (self.size.width as f32, self.size.height as f32);
self.viewport = if self.page_size == crate::document::ENDLESS_PAGE_SIZE {
PageViewport::calculate_endless(width, height, Self::VIEWPORT_MARGIN, insets)
} else {
PageViewport::calculate_for(
width,
height,
Self::VIEWPORT_MARGIN,
insets,
self.fit_box,
self.page_size,
)
};
self.update_uniforms();
}
+250 -6
View File
@@ -20,6 +20,21 @@ pub const PAGE_HEIGHT: f32 = 2970.0;
pub const A4_SIZE: glam::Vec2 = glam::Vec2::new(PAGE_WIDTH, PAGE_HEIGHT);
pub const UNITS_PER_MM: f32 = 10.0;
/// Größe der Seite eines endlosen Notizbuchs (10 × 10 m, Issue #30): praktisch unbegrenzt,
/// aber mit festen, positiven Koordinaten – Ansicht, Speichern und Rückgängig funktionieren
/// so wie bei jeder anderen Seite.
pub const ENDLESS_PAGE_SIZE: glam::Vec2 = glam::Vec2::new(100_000.0, 100_000.0);
/// Linke obere Ecke des Startausschnitts (ein DIN-A4-Rahmen in der Mitte der endlosen Seite).
pub const ENDLESS_HOME: glam::Vec2 = glam::Vec2::new(
(ENDLESS_PAGE_SIZE.x - PAGE_WIDTH) / 2.0,
(ENDLESS_PAGE_SIZE.y - PAGE_HEIGHT) / 2.0,
);
/// Rand um den Inhalt beim Export einer endlosen Seite (10 mm).
const ENDLESS_EXPORT_MARGIN: f32 = 100.0;
/// Raster, auf das der Exportausschnitt einer endlosen Seite fällt: Vielfaches der Abstände von
/// Karos/Punkten (5 mm) und Linien (7 mm), damit das Muster wie am Bildschirm liegt.
const ENDLESS_EXPORT_SNAP: f32 = 350.0;
/// Ein gefilterter Stylus-Abtastpunkt in DIN-A4-Seitenkoordinaten.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct StylusSample {
@@ -198,18 +213,19 @@ pub const IMAGE_UNITS_PER_PIXEL: f32 = UNITS_PER_MM * 25.4 / 96.0;
/// Ecken (oben links, oben rechts, unten rechts, unten links) für ein neu eingefügtes Bild
/// von `width` × `height` Pixeln um `center`: Bildschirmgröße bei 96 dpi, höchstens 80 % der
/// Seitenbreite und 60 % der Seitenhöhe eines Blatts der Größe `page_size`, und möglichst
/// ganz auf dem Blatt.
/// Breite und 60 % der Höhe von `reference` (meist die Seitengröße, auf der endlosen Seite
/// DIN A4), und möglichst ganz auf dem Blatt der Größe `page_size`.
pub fn place_image(
width: u32,
height: u32,
center: glam::Vec2,
page_size: glam::Vec2,
reference: glam::Vec2,
) -> [glam::Vec2; 4] {
let natural =
glam::Vec2::new(width.max(1) as f32, height.max(1) as f32) * IMAGE_UNITS_PER_PIXEL;
let fit = (page_size.x * 0.8 / natural.x)
.min(page_size.y * 0.6 / natural.y)
let fit = (reference.x * 0.8 / natural.x)
.min(reference.y * 0.6 / natural.y)
.min(1.0);
let size = natural * fit;
let half = size / 2.0;
@@ -774,6 +790,56 @@ impl Page {
self
}
/// Seite eines endlosen Notizbuchs (siehe [`ENDLESS_PAGE_SIZE`]).
pub fn is_endless(&self) -> bool {
self.size == ENDLESS_PAGE_SIZE
}
/// Bezugsgröße für Einfügen und Textumbruch: DIN A4 auf der endlosen Seite, sonst die Seite.
pub fn reference_size(&self) -> glam::Vec2 {
if self.is_endless() {
A4_SIZE
} else {
self.size
}
}
/// Rechter Rand für ein neues Textfeld bei `x`: auf der endlosen Seite eine A4-Breite
/// weiter, sonst der Seitenrand.
pub fn text_right_edge(&self, x: f32) -> f32 {
if self.is_endless() {
x + PAGE_WIDTH
} else {
self.size.x
}
}
/// Kopie der Seite als Ausschnitt ab `origin` mit Größe `size`: Alle Objekte werden um
/// `-origin` verschoben (für Export und Bilder einer Auswahl).
pub fn cropped(&self, origin: glam::Vec2, size: glam::Vec2) -> Page {
let mut page = self.clone();
for stroke in &mut page.strokes {
stroke.translate(-origin);
}
page.size = size;
page.is_dirty = true;
page
}
/// Exportausschnitt einer endlosen Seite: der Inhalt mit 10 mm Rand (leer: der
/// Startausschnitt). Die linke obere Ecke liegt auf dem Musterraster.
pub fn endless_export_bounds(&self) -> (glam::Vec2, glam::Vec2) {
let Some(content) = Self::selection_aabb(&self.strokes) else {
return (ENDLESS_HOME, A4_SIZE);
};
let max_width = self.strokes.iter().map(|s| s.width).fold(0.0, f32::max);
let content = content.expanded(max_width / 2.0 + ENDLESS_EXPORT_MARGIN);
let origin = (content.min / ENDLESS_EXPORT_SNAP).floor() * ENDLESS_EXPORT_SNAP;
let origin = origin.max(glam::Vec2::ZERO);
let end = content.max.min(self.size);
(origin, (end - origin).max(glam::Vec2::splat(100.0)))
}
pub fn is_readonly(&self) -> bool {
self.persist == PersistState::ReadOnlyAfterLoadError
}
@@ -1194,6 +1260,61 @@ impl Notebook {
)
}
/// Endloses Notizbuch: eine einzige, praktisch unbegrenzte Seite (Issue #30).
pub fn is_endless(&self) -> bool {
self.pages.len() == 1 && self.pages[0].is_endless()
}
/// Macht aus einem Notizbuch mit einer einzigen Seite (ohne importiertes PDF) ein
/// endloses: Die Seite wird zur endlosen Fläche, ihr Inhalt landet im Startausschnitt.
/// Der Verlauf der Seite wird geleert (er kennt nur die alten Koordinaten).
pub fn make_endless(&mut self) -> bool {
if !self.can_make_endless() {
return false;
}
let page = &mut self.pages[0];
for stroke in &mut page.strokes {
stroke.translate(ENDLESS_HOME);
}
page.size = ENDLESS_PAGE_SIZE;
page.history.clear();
page.is_dirty = true;
true
}
/// Siehe [`Self::make_endless`].
pub fn can_make_endless(&self) -> bool {
matches!(self.pages.as_slice(), [page] if page.is_loaded
&& !page.is_endless()
&& page.pdf_background.is_none())
}
/// Siehe [`Self::make_paged`].
pub fn can_make_paged(&self) -> bool {
if !self.is_endless() || !self.pages[0].is_loaded {
return false;
}
Page::selection_aabb(&self.pages[0].strokes).is_none_or(|content| {
content.min.cmpge(ENDLESS_HOME).all() && content.max.cmple(ENDLESS_HOME + A4_SIZE).all()
})
}
/// Macht aus einem endlosen Notizbuch wieder eine DIN-A4-Seite – nur, wenn der Inhalt im
/// Startausschnitt liegt (sonst ginge er über den Seitenrand). Leert den Verlauf der Seite.
pub fn make_paged(&mut self) -> bool {
if !self.can_make_paged() {
return false;
}
let page = &mut self.pages[0];
for stroke in &mut page.strokes {
stroke.translate(-ENDLESS_HOME);
}
page.size = A4_SIZE;
page.history.clear();
page.is_dirty = true;
true
}
/// Hängt eine neue Seite mit dem angegebenen Hintergrund und der Größe der aktuellen
/// Seite an.
pub fn add_page(&mut self, background: PageBackground) -> usize {
@@ -1401,6 +1522,16 @@ impl Notebook {
if dy.abs() < 1.0 {
return None;
}
// Endlose Seite: keine weiteren Seiten anlegen, nichts über den Rand schieben
let page = &self.pages[page_index];
if page.is_endless()
&& page
.strokes
.iter()
.any(|s| s.aabb.min.y >= y && s.aabb.max.y + dy > page.size.y)
{
return None;
}
// Seiten als durchgehende Spalte: Lage jeder Seite (auch noch anzulegender)
let last_size = self.pages[run.end - 1].size;
@@ -1691,6 +1822,32 @@ impl PageViewport {
}
}
/// Ansicht der endlosen Seite: Maßstab und Lage wie bei einer DIN-A4-Seite, die am
/// Startausschnitt ([`ENDLESS_HOME`]) der endlosen Seite liegt.
pub fn calculate_endless(
window_width: f32,
window_height: f32,
margin: f32,
insets: (f32, f32),
) -> Self {
let a4 = Self::calculate_for(
window_width,
window_height,
margin,
insets,
A4_SIZE,
A4_SIZE,
);
Self {
page_x: a4.page_x - ENDLESS_HOME.x * a4.scale,
page_y: a4.page_y - ENDLESS_HOME.y * a4.scale,
page_width: ENDLESS_PAGE_SIZE.x * a4.scale,
page_height: ENDLESS_PAGE_SIZE.y * a4.scale,
page_size: ENDLESS_PAGE_SIZE,
..a4
}
}
/// Transformiert einen Fenster-Pixelpunkt in Seitenkoordinaten.
///
/// Liefert `None`, falls der Punkt außerhalb der Seite liegt.
@@ -2046,13 +2203,13 @@ mod tests {
fn test_place_image() {
let center = glam::Vec2::new(PAGE_WIDTH / 2.0, PAGE_HEIGHT / 2.0);
// Kleines Bild: Bildschirmgröße, zentriert
let [tl, tr, br, bl] = place_image(200, 100, center, A4_SIZE);
let [tl, tr, br, bl] = place_image(200, 100, center, A4_SIZE, A4_SIZE);
assert!(((tr - tl).length() - 200.0 * IMAGE_UNITS_PER_PIXEL).abs() < 1e-3);
assert!(((bl - tl).length() - 100.0 * IMAGE_UNITS_PER_PIXEL).abs() < 1e-3);
assert!(((tl + br) / 2.0 - center).length() < 1e-3);
// Großer Screenshot: auf 80 % der Breite verkleinert, Seitenverhältnis bleibt
let [tl, tr, br, _] = place_image(3840, 2160, center, A4_SIZE);
let [tl, tr, br, _] = place_image(3840, 2160, center, A4_SIZE, A4_SIZE);
let size = br - tl;
assert!((size.x - PAGE_WIDTH * 0.8).abs() < 1e-2);
assert!((size.x / size.y - 3840.0 / 2160.0).abs() < 1e-3);
@@ -2064,6 +2221,7 @@ mod tests {
400,
glam::Vec2::new(-50.0, PAGE_HEIGHT + 50.0),
A4_SIZE,
A4_SIZE,
);
for c in corners {
assert!(c.x >= -1e-3 && c.x <= PAGE_WIDTH + 1e-3);
@@ -2534,4 +2692,90 @@ mod tests {
glam::Vec2::new(30.0, 40.0)
);
}
fn line(id: u64, from: glam::Vec2, to: glam::Vec2) -> Stroke {
let sample = |position, timestamp| StylusSample {
position,
pressure: 0.5,
timestamp,
};
Stroke::new(
id,
vec![sample(from, 0.0), sample(to, 0.01)],
Vec::new(),
[0.0, 0.0, 0.0, 1.0],
)
}
#[test]
fn test_endless_notebook_round_trip() {
let mut nb = Notebook::new();
nb.pages[0].add_stroke(line(
1,
glam::Vec2::new(100.0, 100.0),
glam::Vec2::new(500.0, 300.0),
));
assert!(!nb.is_endless());
assert!(nb.make_endless());
assert!(nb.is_endless());
// Inhalt liegt im Startausschnitt, der Verlauf ist leer
let p = nb.pages[0].strokes[0].points[0].position;
assert!((p - (ENDLESS_HOME + glam::Vec2::new(100.0, 100.0))).length() < 1e-3);
assert!(!nb.pages[0].history.can_undo());
assert!(!nb.make_endless(), "schon endlos");
// Zurück zu Seiten: Inhalt wieder an der alten Stelle
assert!(nb.make_paged());
assert_eq!(nb.pages[0].size, A4_SIZE);
let p = nb.pages[0].strokes[0].points[0].position;
assert!((p - glam::Vec2::new(100.0, 100.0)).length() < 1e-2);
// Inhalt außerhalb des Startausschnitts: kein Zurück
assert!(nb.make_endless());
nb.pages[0].add_stroke(line(2, ENDLESS_HOME - 500.0, ENDLESS_HOME - 400.0));
assert!(!nb.can_make_paged());
assert!(!nb.make_paged());
assert!(nb.is_endless());
// Mehrere Seiten oder PDF-Seite: nicht endlos
let mut two = Notebook::new();
two.add_page(PageBackground::Blank);
assert!(!two.make_endless());
}
#[test]
fn test_endless_viewport_shows_home_like_a4() {
let a4 = PageViewport::calculate_for(1600.0, 900.0, 20.0, (0.0, 0.0), A4_SIZE, A4_SIZE);
let endless = PageViewport::calculate_endless(1600.0, 900.0, 20.0, (0.0, 0.0));
assert_eq!(endless.page_size, ENDLESS_PAGE_SIZE);
assert!((endless.scale - a4.scale).abs() < 1e-6);
// Die Ecke des Startausschnitts liegt dort, wo sonst die A4-Seite beginnt
let home = endless
.screen_to_page(a4.page_x + 1.0, a4.page_y + 1.0)
.unwrap();
assert!((home - ENDLESS_HOME - glam::Vec2::splat(1.0 / a4.scale)).length() < 0.05);
// Weit neben dem Startausschnitt lässt sich weiter schreiben
assert!(endless.screen_to_page(-5000.0, -5000.0).is_some());
}
#[test]
fn test_endless_export_bounds_follow_content() {
let mut page = Page::new(1, PageBackground::Grid5mm).with_size(ENDLESS_PAGE_SIZE);
// Leer: der Startausschnitt
assert_eq!(page.endless_export_bounds(), (ENDLESS_HOME, A4_SIZE));
let from = glam::Vec2::new(30_123.0, 40_456.0);
let to = glam::Vec2::new(36_000.0, 41_000.0);
page.add_stroke(line(1, from, to));
let (origin, size) = page.endless_export_bounds();
// Ecke auf dem Musterraster, Inhalt mit Rand vollständig enthalten
assert_eq!(origin % ENDLESS_EXPORT_SNAP, glam::Vec2::ZERO);
assert!(origin.cmple(from - ENDLESS_EXPORT_MARGIN).all());
assert!((origin + size).cmpge(to + ENDLESS_EXPORT_MARGIN).all());
assert!(size.x < 7_000.0 && size.y < 2_000.0, "{size:?}");
let cropped = page.cropped(origin, size);
assert_eq!(cropped.size, size);
assert!((cropped.strokes[0].points[0].position - (from - origin)).length() < 1e-2);
}
}
+65
View File
@@ -191,6 +191,28 @@ pub fn export_notebook_to_bytes(
assets: Option<&AssetStore>,
pattern: PatternColor,
) -> Result<Vec<u8>, Box<dyn Error>> {
// Endlose Seite: nur der beschriebene Ausschnitt wird zur PDF-Seite
if notebook.pages.iter().any(|p| p.is_endless()) {
let pages = notebook
.pages
.iter()
.map(|p| {
if p.is_endless() {
let (origin, size) = p.endless_export_bounds();
p.cropped(origin, size)
} else {
p.clone()
}
})
.collect();
let cropped = Notebook::from_pages(
pages,
notebook.current_page_index,
notebook.next_stroke_id,
notebook.next_page_id,
);
return export_notebook_to_bytes(&cropped, strategy, assets, pattern);
}
let doc = generate_pdf_document(notebook, strategy, assets, pattern)?;
let bytes = add_alpha_states(fix_text_encoding(doc.save_to_bytes()?, notebook)?, notebook)?;
@@ -1772,6 +1794,49 @@ mod tests {
.is_ok());
}
#[test]
fn test_endless_page_exports_only_the_written_area() {
let mut notebook = Notebook::new();
assert!(notebook.make_endless());
let sample = |x: f32, y: f32| StylusSample {
position: glam::Vec2::new(x, y),
pressure: 0.5,
timestamp: 0.0,
};
let page = &mut notebook.pages[0];
page.add_stroke(Stroke::new(
1,
vec![sample(20_000.0, 30_000.0), sample(23_000.0, 31_000.0)],
vec![
InkVertex::new(20_000.0, 30_000.0, [0.0, 0.0, 0.0, 1.0]),
InkVertex::new(23_000.0, 31_000.0, [0.0, 0.0, 0.0, 1.0]),
InkVertex::new(23_000.0, 31_010.0, [0.0, 0.0, 0.0, 1.0]),
],
[0.0, 0.0, 0.0, 1.0],
));
let (_, size) = notebook.pages[0].endless_export_bounds();
let bytes = export_notebook_to_bytes(
&notebook,
ExportStrategy::FilledPolygon,
None,
PatternColor::default(),
)
.unwrap();
let sizes = crate::pdf_doc::page_sizes(bytes).unwrap();
assert_eq!(sizes.len(), 1);
// Seiteneinheiten (10 = 1 mm) in Punkt
let pt = |units: f32| units / 10.0 / 25.4 * 72.0;
assert!(
(sizes[0].0 - pt(size.x)).abs() < 1.0,
"{sizes:?} statt {size:?}"
);
assert!(
(sizes[0].1 - pt(size.y)).abs() < 1.0,
"{sizes:?} statt {size:?}"
);
assert!(sizes[0].0 < pt(4_000.0), "nicht die ganze endlose Seite");
}
#[test]
fn test_page_titles_become_pdf_bookmarks() {
let mut notebook = Notebook::new();
+4
View File
@@ -86,10 +86,12 @@ pub const EN: &[(&str, &str)] = &[
("Eigene Farbe wählen", "Choose a custom color"),
("Ein Ordner kann nicht in sich selbst verschoben werden", "A folder cannot be moved into itself"),
("Ein-/Ausgabefehler: {e}", "I/O error: {e}"),
("Eine Fläche ohne Rand: in alle Richtungen schreiben, verschieben und zoomen. Wechsel nur bei einer einzigen Seite ohne PDF bzw. zurück, solange alles im Startausschnitt liegt.", "One surface without edges: write, pan and zoom in every direction. Switching only works for a single page without a PDF, and back only while everything lies in the starting area."),
("Einfügen (Strg+V)", "Paste (Ctrl+V)"),
("EINGABE", "INPUT"),
("Einstellung nicht gespeichert: {err}", "Setting not saved: {err}"),
("Ellipse", "Ellipse"),
("Endlos geht nur bei einem Notizbuch mit einer einzigen Seite ohne PDF", "Endless only works for a notebook with a single page without a PDF"),
("Entwicklung / Lokal", "Development / local"),
("Export fehlgeschlagen: {err}", "Export failed: {err}"),
("Farbe {number} – erneut tippen, um sie anzupassen", "Color {number} – tap again to adjust it"),
@@ -376,6 +378,7 @@ pub const EN: &[(&str, &str)] = &[
("ZOOM", "ZOOM"),
("Zu viele Weiterleitungen (Redirect-Schleife abgebrochen).", "Too many redirects (redirect loop stopped)."),
("Zugriffsfehler (I/O). Die Datei existiert, kann aber nicht gelesen werden.\nDie Seite bleibt schreibgeschützt, um ein Überschreiben zu verhindern.", "Access error (I/O). The file exists but cannot be read.\nThe page stays read-only to prevent overwriting it."),
("Zurück zu Seiten geht nur, solange alles im Startausschnitt liegt", "Back to pages only works while everything lies in the starting area"),
("Zurücksetzen (Strg+0)", "Reset (Ctrl+0)"),
("{count} Objekt(e) eingefügt", "{count} object(s) pasted"),
("{count} Objekt(e) gelöscht", "{count} object(s) deleted"),
@@ -398,6 +401,7 @@ pub const EN: &[(&str, &str)] = &[
("↩ Rückgängig", "↩ Undo"),
("⏳ Update lädt … {percent} %", "⏳ Downloading update … {percent} %"),
("⏷ {mm} mm Platz", "⏷ {mm} mm space"),
("♾ Endlos", "♾ Endless"),
("⚙ Erweiterte Server-Einstellungen", "⚙ Advanced server settings"),
("⚠ Beschädigt", "⚠ Damaged"),
("⚠ Datenintegritäts-Warnung", "⚠ Data integrity warning"),
+47 -9
View File
@@ -577,6 +577,7 @@ fn insert_image_on_page(
imported.height,
center,
notebook.current_page().size,
notebook.current_page().reference_size(),
);
let page = notebook.current_page_mut();
page.add_stroke(ink_notes::document::Stroke::new_image(
@@ -828,7 +829,7 @@ fn insert_text_on_page(
let mut edit = ink_notes::text::TextEditing::new_at(
notebook.current_page().id,
position,
notebook.current_page().size.x,
notebook.current_page().text_right_edge(position.x),
ui_state.text_size_pt * ink_notes::text::UNITS_PER_PT,
ui_state.text_font,
ui_state.text_color,
@@ -922,7 +923,7 @@ fn text_tool_click(
None => Some(ink_notes::text::TextEditing::new_at(
page.id,
page_pt,
page.size.x,
page.text_right_edge(page_pt.x),
ui_state.text_size_pt * ink_notes::text::UNITS_PER_PT,
ui_state.text_font,
ui_state.text_color,
@@ -1523,8 +1524,15 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
event_loop.run(move |event, target| {
// Anordnung der Seiten gehört zum offenen Notizbuch (Issue #30)
ui_state.continuous_scroll =
manifest.layout_or(ui_settings.continuous_scroll) == NotebookLayout::Continuous;
// Endlos ist ein Notizbuch an seiner einzigen, endlosen Seite erkennbar
ui_state.endless = notebook.is_endless();
ui_state.continuous_scroll = !ui_state.endless
&& manifest.layout_or(ui_settings.continuous_scroll) == NotebookLayout::Continuous;
ui_state.endless_switch_possible = if ui_state.endless {
notebook.can_make_paged()
} else {
notebook.can_make_endless()
};
match event {
Event::AboutToWait => {
let prev_status = autosave.status();
@@ -3301,6 +3309,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
}
}
// Die endlose Fläche bleibt eine einzige Seite
UiAction::AddPage if notebook.is_endless() => {}
UiAction::AddPage => {
// Leere Seite direkt nach der aktuellen (in PDFs: nach der Folie,
// auf der man gerade ist) und dorthin wechseln
@@ -3568,9 +3578,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
UiAction::CreateNotebook { title, folder, layout } => {
let _ = autosave.flush();
match storage::create_new_notebook(&storage_base_dir, &title) {
Ok((new_nb, mut new_manifest, new_dir)) => {
Ok((mut new_nb, mut new_manifest, new_dir)) => {
let id = new_manifest.id.clone();
new_manifest.layout = Some(layout);
if layout == NotebookLayout::Endless {
new_nb.make_endless();
}
change_library(&storage_base_dir, &mut ui_state, |library| {
library.move_notebook(&id, &folder)
});
@@ -3580,7 +3593,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
notebook = new_nb;
manifest = new_manifest;
storage_dir = new_dir;
autosave.mark_manifest_dirty(&manifest);
autosave.mark_dirty(&notebook, &mut manifest);
canvas.reset_view();
selection_state.clear();
ui_state.current_notebook_id = manifest.id.clone();
ui_state.document_title = manifest.title.clone();
@@ -3826,9 +3840,33 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
window.request_redraw();
}
UiAction::SetNotebookLayout(layout) => {
manifest.layout = Some(layout);
ui_state.continuous_scroll = layout == NotebookLayout::Continuous;
autosave.mark_manifest_dirty(&manifest);
// Von und zur endlosen Fläche nur mit einer Seite, deren Inhalt passt
let was_endless = notebook.is_endless();
let to_endless = layout == NotebookLayout::Endless;
let converted = match (was_endless, to_endless) {
(false, true) => notebook.make_endless(),
(true, false) => notebook.make_paged(),
_ => true,
};
if !converted {
ui_state.set_export_message(if to_endless {
t!("Endlos geht nur bei einem Notizbuch mit einer einzigen Seite ohne PDF").to_string()
} else {
t!("Zurück zu Seiten geht nur, solange alles im Startausschnitt liegt").to_string()
});
} else {
manifest.layout = Some(layout);
ui_state.endless = to_endless;
ui_state.continuous_scroll = layout == NotebookLayout::Continuous;
if was_endless != to_endless {
selection_state.clear();
canvas.sync_page(notebook.current_page_mut());
canvas.reset_view();
autosave.mark_dirty(&notebook, &mut manifest);
} else {
autosave.mark_manifest_dirty(&manifest);
}
}
window.request_redraw();
}
UiAction::SaveStylusButtons => {
+11 -25
View File
@@ -13,7 +13,7 @@ use hayro::{PixmapSettings, RenderCache, RenderSettings};
use crate::assets::{AssetStore, RgbaImage};
use crate::background::PatternColor;
use crate::document::{Aabb, Notebook, Page, PageBackground, A4_WIDTH_MM, PAGE_WIDTH};
use crate::document::{Aabb, Notebook, Page, PageBackground};
use crate::export::{export_notebook_to_bytes, ExportStrategy};
use crate::t;
@@ -145,38 +145,24 @@ pub fn selection_image(
};
let max_width = selected.strokes.iter().map(|s| s.width).fold(0.0, f32::max);
let bounds: Aabb = bounds.expanded(max_width / 2.0 + 4.0);
let full = rasterize(
single_page_pdf(&selected, assets, PatternColor::default())?,
let size = bounds.max - bounds.min;
if size.min_element() < 1.0 {
return Ok(None);
}
// Nur den Ausschnitt rastern (auf der endlosen Seite wäre die ganze Seite riesig)
let cropped = selected.cropped(bounds.min, size);
let image = rasterize(
single_page_pdf(&cropped, assets, PatternColor::default())?,
SELECTION_PNG_DPI,
true,
)?;
// Seiteneinheiten → Pixel
let px_per_unit = SELECTION_PNG_DPI / 25.4 * A4_WIDTH_MM / PAGE_WIDTH;
let x0 = ((bounds.min.x * px_per_unit).floor().max(0.0) as u32).min(full.width);
let y0 = ((bounds.min.y * px_per_unit).floor().max(0.0) as u32).min(full.height);
let x1 = ((bounds.max.x * px_per_unit).ceil().max(0.0) as u32).min(full.width);
let y1 = ((bounds.max.y * px_per_unit).ceil().max(0.0) as u32).min(full.height);
if x1 <= x0 || y1 <= y0 {
return Ok(None);
}
let (w, h) = (x1 - x0, y1 - y0);
let mut pixels = Vec::with_capacity((w * h * 4) as usize);
for y in y0..y1 {
let start = ((y * full.width + x0) * 4) as usize;
pixels.extend_from_slice(&full.pixels[start..start + (w * 4) as usize]);
}
Ok(Some(RgbaImage {
width: w,
height: h,
pixels,
}))
Ok(Some(image))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::document::{Stroke, StylusSample, PAGE_HEIGHT};
use crate::document::{Stroke, StylusSample, A4_WIDTH_MM, PAGE_HEIGHT, PAGE_WIDTH};
fn page_with_line() -> Page {
let mut page = Page::new(1, PageBackground::Grid5mm);
+18 -1
View File
@@ -168,7 +168,9 @@ impl StrokeData {
/// - Version 6: Seitentitel (`title` in Seite und Manifest).
/// - Version 7: Textfelder (`text` am Strich).
/// - Version 8: Seitengröße je Seite (`size` in Seite und Manifest), Querformat für Folien.
pub const CURRENT_FORMAT_VERSION: u32 = 8;
/// - Version 9: endlose Notizbücher (`layout: "Endless"` im Manifest, eine Seite mit
/// `document::ENDLESS_PAGE_SIZE`). Ältere Versionen würden sie als riesige Seite öffnen.
pub const CURRENT_FORMAT_VERSION: u32 = 9;
/// Standardwert für ältere Dateien ohne explizite Formatversion (Legacy v0).
pub fn legacy_format_version() -> u32 {
@@ -200,8 +202,12 @@ fn stored_size(size: glam::Vec2) -> Option<[f32; 2]> {
}
/// Seitengröße aus einer (evtl. von Hand bearbeiteten) Datei; unbrauchbare Werte ergeben A4.
/// Höchstens 2 × 2 m – außer der endlosen Seite.
pub fn page_size_from(stored: Option<[f32; 2]>) -> glam::Vec2 {
match stored {
Some(size) if glam::Vec2::from(size) == crate::document::ENDLESS_PAGE_SIZE => {
crate::document::ENDLESS_PAGE_SIZE
}
Some([w, h]) if w.is_finite() && h.is_finite() && w >= 100.0 && h >= 100.0 => {
glam::Vec2::new(w.min(20_000.0), h.min(20_000.0))
}
@@ -294,6 +300,8 @@ pub enum NotebookLayout {
Paged,
/// Seiten hängen untereinander und werden durchgehend gescrollt
Continuous,
/// Eine einzige, praktisch unbegrenzte Fläche (siehe `document::ENDLESS_PAGE_SIZE`)
Endless,
}
impl NotebookManifest {
@@ -1651,6 +1659,15 @@ mod tests {
assert_eq!(old.layout_or(false), NotebookLayout::Paged);
// … und schreibt beim Speichern auch keins dazu
assert!(!serde_json::to_string(&old).unwrap().contains("\"layout\""));
// Endlos: die endlose Seite übersteht Speichern und Laden
let (mut nb, mut manifest, dir) = create_new_notebook(temp_dir.path(), "Endlos").unwrap();
assert!(nb.make_endless());
manifest.layout = Some(NotebookLayout::Endless);
save_notebook(&dir, &nb, &mut manifest).unwrap();
let (loaded_nb, loaded) = load_notebook(&dir).unwrap();
assert_eq!(loaded.layout, Some(NotebookLayout::Endless));
assert!(loaded_nb.is_endless());
}
#[test]
+38 -11
View File
@@ -435,6 +435,11 @@ pub struct UiState {
pub pattern_color: PatternColor,
/// Seiten hängen untereinander und werden durchgehend gescrollt
pub continuous_scroll: bool,
/// Offenes Notizbuch ist eine endlose Fläche (eine Seite ohne Rand, Issue #30)
pub endless: bool,
/// Wechsel zur bzw. von der endlosen Fläche ist gerade möglich (eine Seite ohne PDF bzw.
/// Inhalt im Startausschnitt)
pub endless_switch_possible: bool,
/// Text-Werkzeug: Farbe, Schriftgröße (Punkt) und Schriftart neuer Textfelder
pub text_color: [f32; 4],
pub text_size_pt: f32,
@@ -526,6 +531,8 @@ impl Default for UiState {
adapt_ink_colors: true,
pattern_color: PatternColor::Light,
continuous_scroll: true,
endless: false,
endless_switch_possible: true,
text_color: PEN_COLOR_PRESETS[0].0,
text_size_pt: crate::text::DEFAULT_TEXT_SIZE_PT,
text_font: TextFont::Sans,
@@ -1645,9 +1652,11 @@ fn page_title_list(ui: &mut egui::Ui, notebook: &Notebook) -> Option<usize> {
chosen
}
/// Auswahl der Seitenanordnung eines Notizbuchs. `true`, wenn sie geändert wurde.
fn layout_picker(ui: &mut egui::Ui, layout: &mut NotebookLayout) -> bool {
/// Auswahl der Seitenanordnung eines Notizbuchs. Ohne `can_cross` lässt sich nicht zwischen
/// Seiten und endloser Fläche wechseln. `true`, wenn sie geändert wurde.
fn layout_picker(ui: &mut egui::Ui, layout: &mut NotebookLayout, can_cross: bool) -> bool {
let mut changed = false;
let endless_now = *layout == NotebookLayout::Endless;
ui.horizontal_wrapped(|ui| {
for (option, label, tooltip) in [
(
@@ -1662,10 +1671,24 @@ fn layout_picker(ui: &mut egui::Ui, layout: &mut NotebookLayout) -> bool {
(Strg+Mausrad zoomt); wer unten auf der letzten Seite schreibt, \
bekommt automatisch eine neue Seite."),
),
(
NotebookLayout::Endless,
t!("♾ Endlos"),
t!(
"Eine Fläche ohne Rand: in alle Richtungen schreiben, verschieben und zoomen. \
Wechsel nur bei einer einzigen Seite ohne PDF bzw. zurück, solange alles im \
Startausschnitt liegt."
),
),
] {
let enabled = can_cross || (option == NotebookLayout::Endless) == endless_now;
if ui
.selectable_label(*layout == option, label)
.add_enabled(
enabled,
egui::SelectableLabel::new(*layout == option, label),
)
.on_hover_text(tooltip)
.on_disabled_hover_text(tooltip)
.clicked()
&& *layout != option
{
@@ -3210,7 +3233,7 @@ pub fn render_ui(
// --- Seitennavigation ---
// In der durchgehenden Ansicht wird gescrollt; Blätterpfeile gibt
// es nur in der Einzelseitenansicht
let paging = !state.continuous_scroll;
let paging = !state.continuous_scroll && !state.endless;
if paging
&& sidebar_button(
ui,
@@ -3235,7 +3258,7 @@ pub fn render_ui(
btn,
SidebarIcon::Text(&page_text),
state.show_page_jump_popup,
true,
!state.endless,
None,
t!("Seite wählen"),
);
@@ -3264,7 +3287,7 @@ pub fn render_ui(
btn,
SidebarIcon::Glyph("➕"),
false,
true,
!state.endless,
None,
t!("Neue Seite nach dieser einfügen"),
)
@@ -3676,7 +3699,10 @@ pub fn render_ui(
}
});
if ui
.add_sized([ui.available_width(), 30.0], egui::Button::new(t!("➕ Neue Seite einfügen")))
.add_enabled_ui(!state.endless, |ui| {
ui.add_sized([ui.available_width(), 30.0], egui::Button::new(t!("➕ Neue Seite einfügen")))
})
.inner
.on_hover_text(t!("Leere Seite direkt nach der aktuellen Seite"))
.clicked()
{
@@ -3790,13 +3816,14 @@ pub fn render_ui(
});
section(ui, t!("SEITEN DIESES NOTIZBUCHS"));
let mut layout = if state.continuous_scroll {
let mut layout = if state.endless {
NotebookLayout::Endless
} else if state.continuous_scroll {
NotebookLayout::Continuous
} else {
NotebookLayout::Paged
};
if layout_picker(ui, &mut layout) {
state.continuous_scroll = layout == NotebookLayout::Continuous;
if layout_picker(ui, &mut layout, state.endless_switch_possible) {
actions.push(UiAction::SetNotebookLayout(layout));
}
@@ -4146,7 +4173,7 @@ pub fn render_ui(
});
ui.horizontal(|ui| {
ui.label(t!("Seiten:"));
layout_picker(ui, &mut state.new_notebook_layout);
layout_picker(ui, &mut state.new_notebook_layout, true);
});
ui.add_space(8.0);