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:
+14
-8
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
¬ebook,
|
||||
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();
|
||||
|
||||
@@ -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
@@ -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, ¬ebook, &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(¬ebook, &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(¬ebook, &mut manifest);
|
||||
} else {
|
||||
autosave.mark_manifest_dirty(&manifest);
|
||||
}
|
||||
}
|
||||
window.request_redraw();
|
||||
}
|
||||
UiAction::SaveStylusButtons => {
|
||||
|
||||
+11
-25
@@ -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
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user