Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76968f9e58 | ||
|
|
b75858b43a | ||
|
|
5fafff5eaf | ||
|
|
d21fa91315 | ||
|
|
b00767b457 | ||
|
|
63ab6a3e5b | ||
|
|
3c793fe659 | ||
|
|
8bc29faddc | ||
|
|
8ea20aa783 |
Generated
+1
-1
@@ -1393,7 +1393,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ink-notes"
|
||||
version = "0.27.1"
|
||||
version = "0.30.0"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"arboard",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ink-notes"
|
||||
version = "0.27.1"
|
||||
version = "0.30.0"
|
||||
edition = "2021"
|
||||
description = "Notizbuch-App für Stift, Touch und Maus unter Linux"
|
||||
repository = "https://gitea.pansi.eu/harald/ink-notes"
|
||||
|
||||
@@ -49,7 +49,7 @@ Die Oberfläche gibt es auf Deutsch und Englisch; ink-notes folgt der Systemspra
|
||||
### Seiten und Ansicht
|
||||
- **Hintergründe:** blanko, kariert (5 mm), liniert (7,5 mm), Punktraster (5 mm). Die **Farbe von Karos, Linien und Punkten** ist wählbar (hell, mittel, kräftig, blau, grün) und gilt auch im Export.
|
||||
- **Papierfarbe** der Anzeige: weiß, creme, dunkelgrau, schwarz; auf dunklem Papier werden dunkle Stiftfarben auf Wunsch aufgehellt. Gespeichert und exportiert wird immer auf weißem Papier.
|
||||
- **Durchgehendes Scrollen:** Seiten hängen lückenlos untereinander, eine dunkle Linie markiert die Grenze; wer im unteren Fünftel der letzten Seite schreibt, bekommt automatisch eine neue Seite. Abschaltbar (dann Seite für Seite blättern, mit Pfeilen in der Werkzeugleiste).
|
||||
- **Durchgehendes Scrollen, endlose Fläche oder feste Seiten, je Notizbuch:** Beim Anlegen wählbar (Vorgabe: durchgehend; oder endlos), im ☰-Menü umschaltbar – dort auch auf feste Seiten (Seite für Seite blättern mit Pfeilen in der Werkzeugleiste). Durchgehend hängen die Seiten lückenlos untereinander, eine dunkle Linie markiert die Grenze; schnelles Wischen gleitet nach, und mit der Bildlaufleiste am Rand gegenüber der Werkzeugleiste springt man samt Seitenzahl durch lange PDFs; wer im unteren Fünftel der letzten Seite schreibt, bekommt automatisch eine neue Seite. Endlos ist eine einzige Fläche ohne Rand, auf der man in alle Richtungen schreibt; der PDF-Export enthält nur den beschriebenen Bereich.
|
||||
- **Seiten einfügen und löschen:** ➕ in der Werkzeugleiste fügt nach der aktuellen Seite eine leere Seite ein. Löschen im ☰-Menü (mit Rückfrage); die zuletzt gelöschte Seite lässt sich dort wiederherstellen, solange die App läuft.
|
||||
- **Zoom** von 25 % bis 1000 % (`Strg + Mausrad`, zwei Finger, `Strg + +/-/0`), Verschieben mit mittlerer Maustaste, `Leertaste + Ziehen` oder einem Finger.
|
||||
- **Seiten** anhängen oder direkt nach der aktuellen Seite einfügen, **Seitentitel** vergeben; die Seitenübersicht zeigt Vorschaubilder oder ein Inhaltsverzeichnis. Seitentitel werden im PDF zu Lesezeichen.
|
||||
|
||||
@@ -93,12 +93,19 @@ $$\vec{p}_{\text{neu}} = \vec{c} - (\vec{c} - \vec{p}_{\text{alt}}) \cdot \frac{
|
||||
- Im Shader wird jeder Punkt mit `transform_scale` und `transform_offset` (aus `CameraUniforms`) direkt in Gerätekoordinaten umgerechnet.
|
||||
|
||||
### 3.3 Durchgehende Ansicht
|
||||
Ob ein Notizbuch Seite für Seite, durchgehend oder als endlose Fläche angezeigt wird, steht in seinem Manifest (`layout`, siehe [`FILE_FORMAT.md`](FILE_FORMAT.md)).
|
||||
|
||||
Alle Koordinaten beziehen sich auf die **aktuelle Seite**. `PageStack` (`viewport.rs`) legt die übrigen Seiten lückenlos darunter bzw. darüber (die Grenze zeichnet die Oberfläche als dunkle Linie), waagerecht zur aktuellen Seite zentriert. Jede sichtbare Nachbarseite wird mit einer eigenen Kamera gezeichnet, die um die Lage der Seite verschoben ist (höchstens 12 Nachbarseiten).
|
||||
|
||||
Nach dem Scrollen wird die Seite in der Fenstermitte zur aktuellen Seite; die Kamera wird dabei so verschoben, dass sich das Bild nicht bewegt. Während eines Strichs, einer Auswahl, beim Ziehen des Lineals oder beim Schreiben von Text wird nicht gewechselt. Wer auf einer sichtbaren Nachbarseite zu zeichnen beginnt, wechselt zuerst auf diese Seite.
|
||||
|
||||
---
|
||||
|
||||
### 3.4 Endlose Fläche
|
||||
Ein endloses Notizbuch hat eine einzige Seite von 10 × 10 m (`ENDLESS_PAGE_SIZE`). Damit bleibt alles wie bei jeder anderen Seite – Eingabe, Treffertests, Undo, Speichern –, nur der Rand ist praktisch unerreichbar. `PageViewport::calculate_endless` wählt Maßstab und Lage so, als läge eine DIN-A4-Seite am Startausschnitt (`ENDLESS_HOME`, ein A4-Rahmen in der Mitte); 100 % Zoom und „Ansicht zurücksetzen“ zeigen also diesen Rahmen. Bilder und Textfelder richten ihre Größe bzw. ihren Umbruch an DIN A4 aus (`Page::reference_size`, `Page::text_right_edge`).
|
||||
|
||||
Beim Wechsel von und zu Seiten (`Notebook::make_endless`/`make_paged`) wird der Inhalt um `ENDLESS_HOME` verschoben und der Verlauf der Seite geleert. Zurück zu Seiten geht nur, solange alles im Startausschnitt liegt. Export und Bilder einer Auswahl rastern bzw. exportieren nur einen Ausschnitt (`Page::cropped`), beim Export auf das Musterraster gelegt (`Page::endless_export_bounds`).
|
||||
|
||||
## 4. Rendering
|
||||
|
||||
Ein Frame besteht aus einem Render-Durchgang auf die Fensterfläche (`canvas.rs`):
|
||||
|
||||
+9
-4
@@ -1,6 +1,6 @@
|
||||
# Dateiformat von ink-notes
|
||||
|
||||
Dieses Dokument beschreibt, wo und wie ink-notes Notizbücher speichert, und die Versionen des Dateiformats. Aktuell ist **Version 8**.
|
||||
Dieses Dokument beschreibt, wo und wie ink-notes Notizbücher speichert, und die Versionen des Dateiformats. Aktuell ist **Version 9**.
|
||||
|
||||
---
|
||||
|
||||
@@ -51,7 +51,7 @@ Ordner wie „3. Semester › Mathe“ sind rein logisch: Die Notizbücher bleib
|
||||
|
||||
```json
|
||||
{
|
||||
"format_version": 8,
|
||||
"format_version": 9,
|
||||
"id": "vorlesung_1775460000",
|
||||
"title": "Vorlesung",
|
||||
"created_at": 1775460000,
|
||||
@@ -63,7 +63,8 @@ Ordner wie „3. Semester › Mathe“ sind rein logisch: Die Notizbücher bleib
|
||||
],
|
||||
"page_ids": [1, 2],
|
||||
"next_stroke_id": 42,
|
||||
"next_page_id": 3
|
||||
"next_page_id": 3,
|
||||
"layout": "Paged"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -76,6 +77,9 @@ Ordner wie „3. Semester › Mathe“ sind rein logisch: Die Notizbücher bleib
|
||||
| `pages` | Seiten in ihrer Reihenfolge: `id`, `background`, optional `title` und `size` (siehe unten) – damit Seitenübersicht und Ansicht sie kennen, ohne jede Seite zu laden |
|
||||
| `page_ids` | dieselbe Reihenfolge als reine ID-Liste (für ältere Versionen) |
|
||||
| `next_stroke_id`, `next_page_id` | nächste freie IDs |
|
||||
| `layout` | Anordnung der Seiten: `Paged` (Seite für Seite), `Continuous` (durchgehend untereinander) oder `Endless` (endlose Fläche, siehe unten). Seit 0.28; fehlt das Feld, gilt die frühere globale Einstellung `continuous_scroll` aus `ui.json` |
|
||||
|
||||
**Endlose Notizbücher** (Version 9) haben genau eine Seite der Größe `[100000.0, 100000.0]` (10 × 10 m). Der Startausschnitt ist ein DIN-A4-Rahmen in ihrer Mitte (ab `[48950, 48515]`); Koordinaten bleiben positiv. Beim Export wird nur der beschriebene Bereich mit 10 mm Rand zur PDF-Seite.
|
||||
|
||||
---
|
||||
|
||||
@@ -99,7 +103,7 @@ Jede Seite liegt in einer eigenen Datei (kompaktes JSON, hier zur Lesbarkeit umb
|
||||
| :--- | :--- |
|
||||
| `background` | Muster: `Blank`, `Grid5mm`, `Lined`, `DotGrid` |
|
||||
| `title` | Seitentitel (optional) |
|
||||
| `size` | Seitengröße `[breite, höhe]` in Seiteneinheiten (10 = 1 mm); fehlt bei DIN A4 hoch (`[2100, 2970]`) |
|
||||
| `size` | Seitengröße `[breite, höhe]` in Seiteneinheiten (10 = 1 mm); fehlt bei DIN A4 hoch (`[2100, 2970]`). Höchstens 2 × 2 m, außer der endlosen Seite `[100000, 100000]` |
|
||||
| `pdf_background` | importierte PDF-Seite als Hintergrund (optional): Datei, Seitennummer ab 0, Größe in Punkt |
|
||||
| `strokes` | alle Objekte der Seite, von unten nach oben |
|
||||
|
||||
@@ -182,6 +186,7 @@ Beim ersten Speichern einer unversionierten Datei (Version 0) wird vorher eine K
|
||||
| 6 | 0.7 | Seitentitel (`title` in Seite und Manifest) |
|
||||
| 7 | 0.10 | Textfelder (`text`) |
|
||||
| 8 | 0.11 | Seitengröße je Seite (`size` in Seite und Manifest) |
|
||||
| 9 | 0.29 | Seitenanordnung je Notizbuch (`layout` im Manifest), endlose Notizbücher |
|
||||
|
||||
Jede neue Version ändert nur Ergänzungen; ältere Dateien werden ohne Umwandlung gelesen und beim nächsten Speichern mit der aktuellen Versionsnummer geschrieben.
|
||||
|
||||
|
||||
@@ -84,6 +84,26 @@
|
||||
</branding>
|
||||
<content_rating type="oars-1.1" />
|
||||
<releases>
|
||||
<release version="0.30.0" date="2026-10-19">
|
||||
<description>
|
||||
<p>Faster scrolling through long notebooks and PDFs: a quick finger swipe keeps gliding, and a scrollbar with page numbers jumps through the notebook. The mouse wheel scrolls further per step. New notebooks scroll continuously by default.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.29.1" date="2026-10-19">
|
||||
<description>
|
||||
<p>Moving, rotating and scaling large selections is smooth again and no longer keeps the processor busy. On endless notebooks the mouse wheel scrolls (Ctrl+wheel zooms).</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.29.0" date="2026-10-18">
|
||||
<description>
|
||||
<p>Endless notebooks: one surface without edges to write, pan and zoom in every direction. Choose it when creating a notebook or switch a single-page notebook in the menu; PDF export contains only the written area.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.28.0" date="2026-10-18">
|
||||
<description>
|
||||
<p>Each notebook remembers whether it flips page by page or scrolls continuously. New notebooks start with fixed pages; the layout can be chosen when creating a notebook and changed later in the menu.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.27.1" date="2026-10-18">
|
||||
<description>
|
||||
<p>The pens in the toolbar are shown as pencils in their own colour, so they are easy to tell apart.</p>
|
||||
|
||||
@@ -84,6 +84,26 @@
|
||||
</branding>
|
||||
<content_rating type="oars-1.1" />
|
||||
<releases>
|
||||
<release version="0.30.0" date="2026-10-19">
|
||||
<description>
|
||||
<p>Faster scrolling through long notebooks and PDFs: a quick finger swipe keeps gliding, and a scrollbar with page numbers jumps through the notebook. The mouse wheel scrolls further per step. New notebooks scroll continuously by default.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.29.1" date="2026-10-19">
|
||||
<description>
|
||||
<p>Moving, rotating and scaling large selections is smooth again and no longer keeps the processor busy. On endless notebooks the mouse wheel scrolls (Ctrl+wheel zooms).</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.29.0" date="2026-10-18">
|
||||
<description>
|
||||
<p>Endless notebooks: one surface without edges to write, pan and zoom in every direction. Choose it when creating a notebook or switch a single-page notebook in the menu; PDF export contains only the written area.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.28.0" date="2026-10-18">
|
||||
<description>
|
||||
<p>Each notebook remembers whether it flips page by page or scrolls continuously. New notebooks start with fixed pages; the layout can be chosen when creating a notebook and changed later in the menu.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.27.1" date="2026-10-18">
|
||||
<description>
|
||||
<p>The pens in the toolbar are shown as pencils in their own colour, so they are easy to tell apart.</p>
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
Notizbuch-App für Stift, Touch und Maus unter Linux (getestet mit Zorin OS 18 / Ubuntu 24.04).
|
||||
|
||||
### Highlights:
|
||||
- **Schneller scrollen**: Schnelles Wischen mit dem Finger gleitet nach; in der durchgehenden Ansicht springt eine Bildlaufleiste (gegenüber der Werkzeugleiste, mit Seitenzahl beim Ziehen) durch lange Notizbücher und PDFs; das Mausrad scrollt doppelt so weit. Neue Notizbücher sind durchgehend, feste Seiten gibt es weiter im ☰-Menü
|
||||
- **Fehlerbehebung Auswahl**: Verschieben, Drehen und Skalieren großer Auswahlen ist wieder flüssig und lastet den Prozessor nicht mehr aus (seit 0.27 wurde die Auswahl bei jedem Bild mehrfach kopiert). Auf der endlosen Fläche scrollt das Mausrad (Strg+Mausrad zoomt)
|
||||
- **Endlose Fläche**: Neue Seitenanordnung „♾ Endlos“ – eine Fläche ohne Rand, auf der man in alle Richtungen schreibt, verschiebt und zoomt. Wählbar beim Anlegen oder im ☰-Menü (bei Notizbüchern mit einer Seite); der PDF-Export enthält nur den beschriebenen Bereich. Neue Dateiformatversion 9: ältere App-Versionen öffnen Notizbücher dieser Version nicht mehr
|
||||
- **Seitenanordnung je Notizbuch**: Beim Anlegen wählbar – „Feste Seiten“ (Vorgabe) oder „Durchgehend“ –, später im ☰-Menü umschaltbar. Bestehende Notizbücher behalten die bisherige Einstellung
|
||||
- **Stifte in ihrer Farbe**: Die Stifte in der Werkzeugleiste erscheinen als Stifte in ihrer eigenen Farbe statt als dünner Strich und sind so auf einen Blick zu unterscheiden
|
||||
- **Bilder ohne Verzerren skalieren**: Beim Auswahlwerkzeug schaltet ein Schloss unter den Stiftfarben zwischen gleichmäßigem (🔒) und freiem Skalieren (🔓) um. Bilder sind zu Beginn gesperrt, Striche und Formen frei; am Rechner sperrt zusätzlich die Umschalttaste
|
||||
- **Flüssigeres Scrollen in großen PDFs**: Neu gerenderte PDF-Seiten halten die Anzeige nicht mehr an, und Seiten, über die man schnell hinwegscrollt, werden übersprungen statt der Reihe nach gerendert
|
||||
|
||||
+26
-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();
|
||||
}
|
||||
|
||||
@@ -837,6 +843,18 @@ impl CanvasState {
|
||||
changed
|
||||
}
|
||||
|
||||
/// Durchgehende Ansicht: Scrollt senkrecht, bis die Fenstermitte auf Höhe `y`
|
||||
/// (Koordinaten der aktuellen Seite) liegt.
|
||||
pub fn scroll_center_to(&mut self, y: f32) {
|
||||
let c = self.camera.screen_to_page_unclamped(
|
||||
self.viewport.page_x + self.viewport.page_width / 2.0,
|
||||
self.size.height as f32 / 2.0,
|
||||
&self.viewport,
|
||||
);
|
||||
self.camera.pan_offset[1] += c.y - y;
|
||||
self.update_uniforms();
|
||||
}
|
||||
|
||||
/// Durchgehende Ansicht: Die Seite mit der Ecke `origin` (Koordinaten der aktuellen Seite)
|
||||
/// wird zur Bezugsseite, ohne dass sich der Bildausschnitt bewegt. Danach ist die neue
|
||||
/// Seite mit [`Self::sync_page`] zu setzen.
|
||||
|
||||
+322
-36
@@ -1,5 +1,6 @@
|
||||
//! Notizbuch- und Seitenverwaltung mit DIN-A4-Koordinatensystem und Vektor-Stroke-Modell.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::ops::Range;
|
||||
|
||||
use crate::canvas::InkVertex;
|
||||
@@ -20,6 +21,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 +214,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;
|
||||
@@ -597,18 +614,24 @@ impl Stroke {
|
||||
|
||||
/// Berechnet die Bounding Box aus den aktuellen Punkten/Vertizes neu.
|
||||
pub fn recompute_aabb(&mut self) {
|
||||
// Ohne Zwischenliste: läuft beim Ziehen einer Auswahl für jeden Strich und jede Bewegung
|
||||
let bounds = |mut pts: Box<dyn Iterator<Item = glam::Vec2> + '_>| {
|
||||
pts.next()
|
||||
.map_or(Aabb::new(glam::Vec2::ZERO, glam::Vec2::ZERO), |first| {
|
||||
pts.fold(Aabb::new(first, first), |mut aabb, p| {
|
||||
aabb.expand_by_point(p);
|
||||
aabb
|
||||
})
|
||||
})
|
||||
};
|
||||
self.aabb = if !self.points.is_empty() {
|
||||
let pts: Vec<glam::Vec2> = self.points.iter().map(|s| s.position).collect();
|
||||
Aabb::from_points(&pts)
|
||||
} else if !self.vertices.is_empty() {
|
||||
let pts: Vec<glam::Vec2> = self
|
||||
.vertices
|
||||
.iter()
|
||||
.map(|v| glam::Vec2::new(v.position[0], v.position[1]))
|
||||
.collect();
|
||||
Aabb::from_points(&pts)
|
||||
bounds(Box::new(self.points.iter().map(|s| s.position)))
|
||||
} else {
|
||||
Aabb::new(glam::Vec2::ZERO, glam::Vec2::ZERO)
|
||||
bounds(Box::new(
|
||||
self.vertices
|
||||
.iter()
|
||||
.map(|v| glam::Vec2::new(v.position[0], v.position[1])),
|
||||
))
|
||||
};
|
||||
}
|
||||
|
||||
@@ -774,6 +797,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
|
||||
}
|
||||
@@ -1000,6 +1073,7 @@ impl Page {
|
||||
|
||||
/// Liefert Klone der Striche mit den angegebenen IDs.
|
||||
pub fn get_strokes(&self, ids: &[u64]) -> Vec<Stroke> {
|
||||
let ids: HashSet<u64> = ids.iter().copied().collect();
|
||||
self.strokes
|
||||
.iter()
|
||||
.filter(|s| ids.contains(&s.id))
|
||||
@@ -1007,24 +1081,31 @@ impl Page {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Ersetzt die Striche mit den IDs aus `modified` an ihrer Stelle. Über eine Tabelle
|
||||
/// statt je Strich die ganze Seite zu durchsuchen: Beim Ziehen einer großen Auswahl
|
||||
/// läuft das bei jeder Stiftbewegung.
|
||||
fn replace_strokes(&mut self, modified: &[Stroke]) {
|
||||
let index: HashMap<u64, usize> = self
|
||||
.strokes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, s)| (s.id, i))
|
||||
.collect();
|
||||
for m in modified {
|
||||
if let Some(&pos) = index.get(&m.id) {
|
||||
self.strokes[pos] = m.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Aktualisiert eine Menge geänderter Striche transaktional im History-Stack.
|
||||
pub fn modify_strokes(&mut self, modified: Vec<Stroke>) {
|
||||
if modified.is_empty() {
|
||||
return;
|
||||
}
|
||||
let ids: Vec<u64> = modified.iter().map(|s| s.id).collect();
|
||||
let before: Vec<Stroke> = self
|
||||
.strokes
|
||||
.iter()
|
||||
.filter(|s| ids.contains(&s.id))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
for m in modified.iter() {
|
||||
if let Some(pos) = self.strokes.iter().position(|s| s.id == m.id) {
|
||||
self.strokes[pos] = m.clone();
|
||||
}
|
||||
}
|
||||
let before = self.get_strokes(&ids);
|
||||
self.replace_strokes(&modified);
|
||||
|
||||
self.history.push(HistoryAction::ModifyStrokes {
|
||||
before,
|
||||
@@ -1035,9 +1116,33 @@ impl Page {
|
||||
|
||||
/// Aktualisiert Striche flüchtig (z. B. während des Ziehens / Dragging), OHNE die History zu verändern.
|
||||
pub fn update_strokes_ephemeral(&mut self, modified: &[Stroke]) {
|
||||
for m in modified {
|
||||
if let Some(pos) = self.strokes.iter().position(|s| s.id == m.id) {
|
||||
self.strokes[pos] = m.clone();
|
||||
self.replace_strokes(modified);
|
||||
self.is_dirty = true;
|
||||
}
|
||||
|
||||
/// Wie [`Self::update_strokes_ephemeral`] für eine Transformation der Ausgangsstriche
|
||||
/// `originals`: setzt die Striche auf der Seite auf ihren Ausgangszustand zurück und wendet
|
||||
/// `transform` an. Verwendet die Puffer der Seitenstriche weiter, statt bei jeder
|
||||
/// Stiftbewegung die ganze Auswahl zweimal zu kopieren.
|
||||
pub fn transform_strokes_ephemeral(
|
||||
&mut self,
|
||||
originals: &[Stroke],
|
||||
transform: impl Fn(&mut Stroke),
|
||||
) {
|
||||
let index: HashMap<u64, usize> = self
|
||||
.strokes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, s)| (s.id, i))
|
||||
.collect();
|
||||
for original in originals {
|
||||
if let Some(&pos) = index.get(&original.id) {
|
||||
let stroke = &mut self.strokes[pos];
|
||||
// Nur was Verschieben, Drehen und Skalieren verändern
|
||||
stroke.points.clone_from(&original.points);
|
||||
stroke.vertices.clone_from(&original.vertices);
|
||||
stroke.shape.clone_from(&original.shape);
|
||||
transform(stroke);
|
||||
}
|
||||
}
|
||||
self.is_dirty = true;
|
||||
@@ -1048,11 +1153,7 @@ impl Page {
|
||||
if before.is_empty() || after.is_empty() {
|
||||
return;
|
||||
}
|
||||
for m in &after {
|
||||
if let Some(pos) = self.strokes.iter().position(|s| s.id == m.id) {
|
||||
self.strokes[pos] = m.clone();
|
||||
}
|
||||
}
|
||||
self.replace_strokes(&after);
|
||||
self.history
|
||||
.push(HistoryAction::ModifyStrokes { before, after });
|
||||
self.is_dirty = true;
|
||||
@@ -1194,6 +1295,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 +1557,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 +1857,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 +2238,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 +2256,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);
|
||||
@@ -2515,6 +2708,13 @@ mod tests {
|
||||
// History should not have been updated yet
|
||||
assert!(!page.history.can_redo());
|
||||
|
||||
// Ziehen über die Ausgangsstriche: jede Bewegung gilt ab dem Ausgangszustand
|
||||
for delta in [glam::Vec2::new(5.0, 0.0), glam::Vec2::new(20.0, 30.0)] {
|
||||
page.transform_strokes_ephemeral(std::slice::from_ref(&s), |st| st.translate(delta));
|
||||
}
|
||||
assert_eq!(page.strokes[0], moved);
|
||||
assert!(!page.history.can_redo());
|
||||
|
||||
// Transactional commit (mouse release)
|
||||
let before = vec![s.clone()];
|
||||
let after = vec![moved.clone()];
|
||||
@@ -2534,4 +2734,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();
|
||||
|
||||
+10
-3
@@ -20,8 +20,6 @@ pub const EN: &[(&str, &str)] = &[
|
||||
("Alle", "All"),
|
||||
("Alles gespeichert", "All saved"),
|
||||
("Als leere Seite bearbeiten (Schreibschutz aufheben)", "Edit as blank page (remove write protection)"),
|
||||
("An: Seiten hängen untereinander, Mausrad und Finger scrollen (Strg+Mausrad zoomt); wer unten auf der letzten Seite schreibt, bekommt automatisch eine neue Seite.\nAus: Seite für Seite blättern.", "On: pages are stacked, mouse wheel and finger scroll (Ctrl+wheel zooms); writing at the bottom of the last page adds a new page automatically.\nOff: flip page by page."),
|
||||
("ANSICHT", "VIEW"),
|
||||
("AppImage (Portabel)", "AppImage (portable)"),
|
||||
("Asset-Name darf nicht leer sein.", "Asset name must not be empty."),
|
||||
("Auch per Strg+V aus der Zwischenablage oder durch Ziehen einer Bilddatei ins Fenster", "Also with Ctrl+V from the clipboard or by dragging an image file into the window"),
|
||||
@@ -88,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"),
|
||||
@@ -267,6 +267,7 @@ pub const EN: &[(&str, &str)] = &[
|
||||
("Schwarz", "Black"),
|
||||
("Sechseck (Hexagon)", "Hexagon"),
|
||||
("Seite als", "Page as"),
|
||||
("Seite für Seite blättern", "Flip page by page"),
|
||||
("Seite ist schreibgeschützt", "Page is read-only"),
|
||||
("Seite konnte nicht geladen werden", "Page could not be loaded"),
|
||||
("Seite wird exportiert …", "Exporting page …"),
|
||||
@@ -285,7 +286,9 @@ pub const EN: &[(&str, &str)] = &[
|
||||
("Seite {number} – {title}", "Page {number} – {title}"),
|
||||
("Seite {number}", "Page {number}"),
|
||||
("Seiten ({count})", "Pages ({count})"),
|
||||
("Seiten durchgehend scrollen", "Scroll pages continuously"),
|
||||
("SEITEN DIESES NOTIZBUCHS", "PAGES OF THIS NOTEBOOK"),
|
||||
("Seiten hängen untereinander, Mausrad und Finger scrollen (Strg+Mausrad zoomt); wer unten auf der letzten Seite schreibt, bekommt automatisch eine neue Seite.", "Pages are stacked, mouse wheel and finger scroll (Ctrl+wheel zooms); writing at the bottom of the last page adds a new page automatically."),
|
||||
("Seiten:", "Pages:"),
|
||||
("Seitenverhältnis frei – tippen zum Sperren", "Aspect ratio free – tap to lock"),
|
||||
("Seitenverhältnis gesperrt – tippen zum Freigeben", "Aspect ratio locked – tap to unlock"),
|
||||
("Serialisierungsfehler: {e}", "Serialization error: {e}"),
|
||||
@@ -375,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"),
|
||||
@@ -397,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"),
|
||||
@@ -418,12 +423,14 @@ pub const EN: &[(&str, &str)] = &[
|
||||
("📂 Ordner öffnen", "📂 Open folder"),
|
||||
("📄 Als PDF exportieren (Strg+E)", "📄 Export as PDF (Ctrl+E)"),
|
||||
("📄 PDF importieren …", "📄 Import PDF …"),
|
||||
("📄 Feste Seiten", "📄 Fixed pages"),
|
||||
("📄 PDF importieren", "📄 Import PDF"),
|
||||
("📋 Einfügen", "📋 Paste"),
|
||||
("📑 Inhalt", "📑 Contents"),
|
||||
("📚 Alle", "📚 All"),
|
||||
("📚 Notizbücher", "📚 Notebooks"),
|
||||
("📚 Oberste Ebene", "📚 Top level"),
|
||||
("📜 Durchgehend", "📜 Continuous"),
|
||||
("📦 Paket mit Software-Center öffnen / installieren", "📦 Open / install package with the software center"),
|
||||
("🔄 Suche nach Updates …", "🔄 Checking for updates …"),
|
||||
("🔄 Updates & Versionen", "🔄 Updates & versions"),
|
||||
|
||||
+123
-45
@@ -36,7 +36,7 @@ use ink_notes::selection::{
|
||||
use ink_notes::settings::{
|
||||
SidebarSide, StylusAction, StylusButton, UiSettings, MAX_STYLUS_BUTTONS,
|
||||
};
|
||||
use ink_notes::storage::{self, NotebookManifest};
|
||||
use ink_notes::storage::{self, NotebookLayout, NotebookManifest};
|
||||
use ink_notes::t;
|
||||
use ink_notes::ui::{self, ActiveTool, UiAction, UiState};
|
||||
use ink_notes::updater::UpdaterController;
|
||||
@@ -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(
|
||||
@@ -690,62 +691,65 @@ fn selection_press(
|
||||
_ => return false,
|
||||
};
|
||||
selection_state.drag_mode = mode;
|
||||
selection_state.pending_drag = None;
|
||||
true
|
||||
}
|
||||
|
||||
/// Wendet die zuletzt gemeldete Zugposition der Auswahl an (siehe [`selection_drag`]).
|
||||
fn flush_selection_drag(
|
||||
page: &mut ink_notes::document::Page,
|
||||
canvas: &mut CanvasState,
|
||||
selection_state: &mut SelectionState,
|
||||
) {
|
||||
if let Some(page_pt) = selection_state.pending_drag.take() {
|
||||
selection_drag(page_pt, page, canvas, selection_state);
|
||||
}
|
||||
}
|
||||
|
||||
/// Auswahlwerkzeug: Ziehen nach `page_pt` – Rahmen aufziehen oder die Auswahl verschieben,
|
||||
/// drehen bzw. skalieren (vorläufig, ohne Undo-Schritt).
|
||||
/// drehen bzw. skalieren (vorläufig, ohne Undo-Schritt). Bei Stiftbewegungen über
|
||||
/// `pending_drag` und [`flush_selection_drag`] nur einmal je Bild, weil das bei großen
|
||||
/// Auswahlen teuer ist.
|
||||
fn selection_drag(
|
||||
page_pt: Vec2,
|
||||
page: &mut ink_notes::document::Page,
|
||||
canvas: &mut CanvasState,
|
||||
selection_state: &mut SelectionState,
|
||||
) {
|
||||
let mut updated = match &mut selection_state.drag_mode {
|
||||
match &mut selection_state.drag_mode {
|
||||
DragMode::BoxSelect { current_page, .. } => {
|
||||
*current_page = page_pt;
|
||||
return;
|
||||
}
|
||||
DragMode::None => return,
|
||||
DragMode::Move {
|
||||
initial_strokes, ..
|
||||
}
|
||||
| DragMode::Rotate {
|
||||
initial_strokes, ..
|
||||
}
|
||||
| DragMode::Scale {
|
||||
initial_strokes, ..
|
||||
} => initial_strokes.clone(),
|
||||
};
|
||||
match &selection_state.drag_mode {
|
||||
DragMode::Move { start_page, .. } => {
|
||||
start_page,
|
||||
initial_strokes,
|
||||
} => {
|
||||
let delta = page_pt - *start_page;
|
||||
updated.iter_mut().for_each(|s| s.translate(delta));
|
||||
page.transform_strokes_ephemeral(initial_strokes, |s| s.translate(delta));
|
||||
}
|
||||
DragMode::Rotate {
|
||||
center_page,
|
||||
start_angle,
|
||||
..
|
||||
initial_strokes,
|
||||
} => {
|
||||
let angle = (page_pt.y - center_page.y).atan2(page_pt.x - center_page.x) - start_angle;
|
||||
updated
|
||||
.iter_mut()
|
||||
.for_each(|s| s.rotate(*center_page, angle));
|
||||
let angle = (page_pt.y - center_page.y).atan2(page_pt.x - center_page.x) - *start_angle;
|
||||
let center = *center_page;
|
||||
page.transform_strokes_ephemeral(initial_strokes, |s| s.rotate(center, angle));
|
||||
}
|
||||
DragMode::Scale {
|
||||
fixed_corner_page,
|
||||
start_pos_page,
|
||||
keep_aspect,
|
||||
initial_strokes,
|
||||
..
|
||||
} => {
|
||||
let factors = scale_factors(*fixed_corner_page, *start_pos_page, page_pt, *keep_aspect);
|
||||
updated
|
||||
.iter_mut()
|
||||
.for_each(|s| s.scale(*fixed_corner_page, factors));
|
||||
let fixed = *fixed_corner_page;
|
||||
let factors = scale_factors(fixed, *start_pos_page, page_pt, *keep_aspect);
|
||||
page.transform_strokes_ephemeral(initial_strokes, |s| s.scale(fixed, factors));
|
||||
}
|
||||
DragMode::BoxSelect { .. } | DragMode::None => return,
|
||||
}
|
||||
page.update_strokes_ephemeral(&updated);
|
||||
canvas.sync_page(page);
|
||||
}
|
||||
|
||||
@@ -758,7 +762,10 @@ fn selection_release(
|
||||
manifest: &mut NotebookManifest,
|
||||
autosave: &mut AutosaveController,
|
||||
selection_state: &mut SelectionState,
|
||||
canvas: &mut CanvasState,
|
||||
) -> bool {
|
||||
// Die letzte Bewegung gehört noch zum Ziehen
|
||||
flush_selection_drag(notebook.current_page_mut(), canvas, selection_state);
|
||||
match std::mem::take(&mut selection_state.drag_mode) {
|
||||
DragMode::BoxSelect {
|
||||
start_page,
|
||||
@@ -828,7 +835,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 +929,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,
|
||||
@@ -1415,6 +1422,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Langes Drücken mit einem Finger öffnet das Kontextmenü
|
||||
let mut long_press = ui::LongPress::default();
|
||||
let mut touch_pan_last_pos = (0.0_f32, 0.0_f32);
|
||||
// Nachgleiten nach schnellem Wischen mit dem Finger (Issue #28)
|
||||
let mut fling = ink_notes::viewport::Fling::default();
|
||||
|
||||
// Stylus / Digitizer Zustand (Octotablet / Wayland Tablet Protocol)
|
||||
let mut is_stylus_drawing = false;
|
||||
@@ -1522,6 +1531,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
|
||||
event_loop.run(move |event, target| {
|
||||
// Anordnung der Seiten gehört zum offenen Notizbuch (Issue #30)
|
||||
// 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();
|
||||
@@ -1607,7 +1626,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
if is_stylus_selecting {
|
||||
is_stylus_selecting = false;
|
||||
selection_release(false, &mut notebook, &mut manifest, &mut autosave, &mut selection_state);
|
||||
selection_release(false, &mut notebook, &mut manifest, &mut autosave, &mut selection_state, &mut canvas);
|
||||
window.request_redraw();
|
||||
}
|
||||
if let Some(tool) = tool_before_button.take() {
|
||||
@@ -1703,7 +1722,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
} else if is_stylus_selecting {
|
||||
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
|
||||
selection_drag(page_pt, notebook.current_page_mut(), &mut canvas, &mut selection_state);
|
||||
selection_state.pending_drag = Some(page_pt);
|
||||
window.request_redraw();
|
||||
}
|
||||
} else if is_stylus_drawing && is_drawing {
|
||||
@@ -1847,7 +1866,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
if is_stylus_selecting {
|
||||
is_stylus_selecting = false;
|
||||
if selection_release(false, &mut notebook, &mut manifest, &mut autosave, &mut selection_state) {
|
||||
if selection_release(false, &mut notebook, &mut manifest, &mut autosave, &mut selection_state, &mut canvas) {
|
||||
window.request_redraw();
|
||||
}
|
||||
}
|
||||
@@ -1944,6 +1963,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
window.request_redraw();
|
||||
}
|
||||
|
||||
// Nachgleiten nach dem Wischen
|
||||
if let Some(delta) = fling.step(Instant::now()) {
|
||||
canvas.pan(delta.x, delta.y);
|
||||
window.request_redraw();
|
||||
}
|
||||
|
||||
// Von egui angefordertes, verzögertes Neuzeichnen (Tooltips, Animationen)
|
||||
if let Some(at) = egui_repaint_at {
|
||||
if Instant::now() >= at {
|
||||
@@ -1959,6 +1984,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|| (is_drawing && !stroke_already_snapped)
|
||||
|| is_stylus_drawing
|
||||
|| is_stylus_in_proximity
|
||||
|| fling.is_active()
|
||||
{
|
||||
target.set_control_flow(ControlFlow::wait_duration(Duration::from_millis(8)));
|
||||
} else if let Some(at) = [egui_repaint_at, long_press.deadline()]
|
||||
@@ -2063,6 +2089,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
pinch_prev_center = None;
|
||||
is_touch_drawing = false;
|
||||
is_touch_panning = false;
|
||||
fling.stop();
|
||||
is_stylus_drawing = false;
|
||||
is_stylus_selecting = false;
|
||||
is_stylus_in_proximity = false;
|
||||
@@ -2519,6 +2546,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
return;
|
||||
}
|
||||
|
||||
fling.stop();
|
||||
// 1. Pan via mittlere Maustaste oder Leertaste + Linksklick
|
||||
if button == MouseButton::Middle || (button == MouseButton::Left && is_space_pressed) {
|
||||
is_panning = true;
|
||||
@@ -2615,7 +2643,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
if ui_state.active_tool == ActiveTool::Select
|
||||
&& !is_stylus_selecting
|
||||
&& selection_release(modifiers.shift_key(), &mut notebook, &mut manifest, &mut autosave, &mut selection_state)
|
||||
&& selection_release(modifiers.shift_key(), &mut notebook, &mut manifest, &mut autosave, &mut selection_state, &mut canvas)
|
||||
{
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -2671,7 +2699,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
window.request_redraw();
|
||||
} else if ui_state.active_tool == ActiveTool::Select && selection_state.drag_mode != DragMode::None {
|
||||
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
|
||||
selection_drag(page_pt, notebook.current_page_mut(), &mut canvas, &mut selection_state);
|
||||
selection_state.pending_drag = Some(page_pt);
|
||||
window.request_redraw();
|
||||
}
|
||||
} else if is_erasing {
|
||||
@@ -2704,15 +2732,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
|
||||
WindowEvent::MouseWheel { delta, .. } => {
|
||||
fling.stop();
|
||||
if is_pointer_in_ui {
|
||||
return;
|
||||
}
|
||||
// Durchgehende Ansicht: Mausrad scrollt (mit Umschalt waagerecht),
|
||||
// Strg + Mausrad zoomt
|
||||
if ui_state.continuous_scroll && !modifiers.control_key() {
|
||||
// Durchgehende Ansicht und endlose Fläche: Mausrad scrollt (mit Umschalt
|
||||
// waagerecht), Strg + Mausrad zoomt
|
||||
if (ui_state.continuous_scroll || ui_state.endless) && !modifiers.control_key() {
|
||||
let (mut dx, mut dy) = match delta {
|
||||
MouseScrollDelta::LineDelta(x, y) => {
|
||||
let line = 60.0 * window.scale_factor() as f32;
|
||||
let line = 120.0 * window.scale_factor() as f32;
|
||||
(x * line, y * line)
|
||||
}
|
||||
MouseScrollDelta::PixelDelta(pos) => (pos.x as f32, pos.y as f32),
|
||||
@@ -2785,6 +2814,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
match touch.phase {
|
||||
TouchPhase::Started => {
|
||||
// Jede neue Berührung hält ein gleitendes Blatt an
|
||||
fling.stop();
|
||||
// Event-Isolation für Touch-Eingaben
|
||||
let is_touch_in_ui = ui::is_screen_point_in_ui(
|
||||
&egui_ctx,
|
||||
@@ -2940,6 +2971,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let dy = ty - touch_pan_last_pos.1;
|
||||
touch_pan_last_pos = (tx, ty);
|
||||
canvas.pan(dx, dy);
|
||||
fling.track(Vec2::new(dx, dy), Instant::now());
|
||||
window.request_redraw();
|
||||
} else if is_touch_drawing {
|
||||
current_pressure = pressure;
|
||||
@@ -2972,7 +3004,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
} else if ui_state.active_tool == ActiveTool::Select && selection_state.drag_mode != DragMode::None {
|
||||
if let Some(page_pt) = canvas.screen_to_page(tx, ty) {
|
||||
selection_drag(page_pt, notebook.current_page_mut(), &mut canvas, &mut selection_state);
|
||||
selection_state.pending_drag = Some(page_pt);
|
||||
window.request_redraw();
|
||||
}
|
||||
}
|
||||
@@ -2986,6 +3018,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
is_pinch_gesturing = false;
|
||||
pinch_prev_distance = None;
|
||||
pinch_prev_center = None;
|
||||
if is_touch_panning && touch.phase == TouchPhase::Ended {
|
||||
fling.release(Instant::now());
|
||||
window.request_redraw();
|
||||
}
|
||||
is_touch_panning = false;
|
||||
|
||||
if is_erasing {
|
||||
@@ -2999,7 +3035,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
|
||||
if ui_state.active_tool == ActiveTool::Select
|
||||
&& selection_release(false, &mut notebook, &mut manifest, &mut autosave, &mut selection_state)
|
||||
&& selection_release(false, &mut notebook, &mut manifest, &mut autosave, &mut selection_state, &mut canvas)
|
||||
{
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -3042,6 +3078,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
|
||||
WindowEvent::RedrawRequested => {
|
||||
flush_selection_drag(notebook.current_page_mut(), &mut canvas, &mut selection_state);
|
||||
// 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
|
||||
@@ -3049,7 +3086,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
canvas.set_fit_box(notebook.fit_box());
|
||||
if ui_state.continuous_scroll {
|
||||
let stack = notebook.page_stack();
|
||||
canvas.clamp_scroll(&stack);
|
||||
if canvas.clamp_scroll(&stack) {
|
||||
// Am Anfang oder Ende des Notizbuchs angekommen
|
||||
fling.stop();
|
||||
}
|
||||
// Unabhängig von der Bezugsseite: Lage bezogen auf die erste Seite
|
||||
let scroll = [
|
||||
canvas.camera.zoom,
|
||||
@@ -3298,6 +3338,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
|
||||
@@ -3562,11 +3604,15 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
window.request_redraw();
|
||||
}
|
||||
UiAction::CreateNotebook { title, folder } => {
|
||||
UiAction::CreateNotebook { title, folder, layout } => {
|
||||
let _ = autosave.flush();
|
||||
match storage::create_new_notebook(&storage_base_dir, &title) {
|
||||
Ok((new_nb, 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)
|
||||
});
|
||||
@@ -3576,6 +3622,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
notebook = new_nb;
|
||||
manifest = new_manifest;
|
||||
storage_dir = new_dir;
|
||||
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();
|
||||
@@ -3820,9 +3868,39 @@ 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);
|
||||
UiAction::ScrollTo(y) => {
|
||||
fling.stop();
|
||||
canvas.scroll_center_to(y);
|
||||
window.request_redraw();
|
||||
}
|
||||
UiAction::SetNotebookLayout(layout) => {
|
||||
// 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);
|
||||
|
||||
+14
-4
@@ -88,6 +88,9 @@ pub enum SelectionHandleHit {
|
||||
pub struct SelectionState {
|
||||
pub selected_ids: Vec<u64>,
|
||||
pub drag_mode: DragMode,
|
||||
/// Letzte Zugposition, die noch nicht angewendet ist: Der Stift meldet sich öfter, als
|
||||
/// gezeichnet wird; angewendet wird einmal je Bild (siehe `flush_selection_drag`).
|
||||
pub pending_drag: Option<Vec2>,
|
||||
pub clipboard: Vec<Stroke>,
|
||||
/// Von Hand umgeschaltetes Seitenverhältnis-Schloss und die Auswahl (IDs), für die es
|
||||
/// gilt. Ändert sich die Auswahl, gilt wieder die Vorgabe (siehe [`Self::keeps_aspect`]).
|
||||
@@ -99,6 +102,7 @@ impl SelectionState {
|
||||
Self {
|
||||
selected_ids: Vec::new(),
|
||||
drag_mode: DragMode::None,
|
||||
pending_drag: None,
|
||||
clipboard: Vec::new(),
|
||||
aspect_lock: None,
|
||||
}
|
||||
@@ -109,10 +113,11 @@ impl SelectionState {
|
||||
pub fn keeps_aspect(&self, page: &Page) -> bool {
|
||||
match &self.aspect_lock {
|
||||
Some((ids, locked)) if *ids == self.selected_ids => *locked,
|
||||
// Läuft bei jedem Bild: erst die (wenigen) Bilder suchen, ohne Striche zu kopieren
|
||||
_ => page
|
||||
.get_strokes(&self.selected_ids)
|
||||
.strokes
|
||||
.iter()
|
||||
.any(|s| s.is_image()),
|
||||
.any(|s| s.is_image() && self.selected_ids.contains(&s.id)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,8 +164,13 @@ impl SelectionState {
|
||||
|
||||
/// Berechnet die gemeinsame AABB aller ausgewählten Objekte.
|
||||
pub fn get_selection_aabb(&self, page: &Page) -> Option<Aabb> {
|
||||
let strokes = page.get_strokes(&self.selected_ids);
|
||||
Page::selection_aabb(&strokes)
|
||||
// Läuft bei jedem Bild: ohne die ausgewählten Striche zu kopieren
|
||||
let ids: std::collections::HashSet<u64> = self.selected_ids.iter().copied().collect();
|
||||
page.strokes
|
||||
.iter()
|
||||
.filter(|s| ids.contains(&s.id))
|
||||
.map(|s| s.aabb)
|
||||
.reduce(|a, b| a.union(&b))
|
||||
}
|
||||
|
||||
/// Führt Hit-Testing auf die Auswahlgriffe (Drehen, Ecken, Körper) in Bildschirmkoordinaten
|
||||
|
||||
+2
-1
@@ -212,7 +212,8 @@ pub struct UiSettings {
|
||||
pub pattern_color: PatternColor,
|
||||
/// Druckkennlinie des Stifts
|
||||
pub pressure_curve: PressureCurve,
|
||||
/// Seiten durchgehend untereinander anzeigen (sonst einzeln blättern)
|
||||
/// Frühere globale Einstellung „Seiten durchgehend scrollen“: gilt nur noch für Notizbücher
|
||||
/// ohne eigene Anordnung im Manifest (vor 0.28 angelegt), wird nicht mehr geändert
|
||||
pub continuous_scroll: bool,
|
||||
/// Stifte der Werkzeugleiste und der gewählte davon
|
||||
pub pen_presets: [PenPreset; PEN_PRESET_COUNT],
|
||||
|
||||
+67
-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))
|
||||
}
|
||||
@@ -280,9 +286,34 @@ pub struct NotebookManifest {
|
||||
pub page_ids: Vec<u64>,
|
||||
pub next_stroke_id: u64,
|
||||
pub next_page_id: u64,
|
||||
/// Anordnung der Seiten. Fehlt bei Notizbüchern von vor 0.28; dann gilt die frühere
|
||||
/// globale Einstellung `continuous_scroll` aus `ui.json` (siehe [`Self::layout_or`]).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub layout: Option<NotebookLayout>,
|
||||
}
|
||||
|
||||
/// Anordnung der Seiten eines Notizbuchs (Issue #30).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub enum NotebookLayout {
|
||||
/// Feste Seiten: Seite für Seite blättern
|
||||
#[default]
|
||||
Paged,
|
||||
/// Seiten hängen untereinander und werden durchgehend gescrollt
|
||||
Continuous,
|
||||
/// Eine einzige, praktisch unbegrenzte Fläche (siehe `document::ENDLESS_PAGE_SIZE`)
|
||||
Endless,
|
||||
}
|
||||
|
||||
impl NotebookManifest {
|
||||
/// Anordnung des Notizbuchs; ohne eigene Angabe die frühere globale Einstellung.
|
||||
pub fn layout_or(&self, legacy_continuous: bool) -> NotebookLayout {
|
||||
self.layout.unwrap_or(if legacy_continuous {
|
||||
NotebookLayout::Continuous
|
||||
} else {
|
||||
NotebookLayout::Paged
|
||||
})
|
||||
}
|
||||
|
||||
pub fn new(id: impl Into<String>, title: impl Into<String>) -> Self {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
@@ -299,6 +330,7 @@ impl NotebookManifest {
|
||||
page_ids: vec![1],
|
||||
next_stroke_id: 1,
|
||||
next_page_id: 2,
|
||||
layout: Some(NotebookLayout::Continuous),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1604,6 +1636,40 @@ mod tests {
|
||||
assert!(shutdown_res.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_notebook_layout_is_saved_and_old_manifests_fall_back() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let (_nb, mut manifest, dir) = create_new_notebook(temp_dir.path(), "Layout").unwrap();
|
||||
// Neue Notizbücher: durchgehend, unabhängig von der früheren globalen Einstellung
|
||||
assert_eq!(manifest.layout, Some(NotebookLayout::Continuous));
|
||||
assert_eq!(manifest.layout_or(false), NotebookLayout::Continuous);
|
||||
|
||||
manifest.layout = Some(NotebookLayout::Paged);
|
||||
save_manifest(&dir, &manifest).unwrap();
|
||||
let (_nb, loaded) = load_notebook(&dir).unwrap();
|
||||
assert_eq!(loaded.layout, Some(NotebookLayout::Paged));
|
||||
|
||||
// Manifest von vor 0.28 ohne `layout`: es gilt die globale Einstellung
|
||||
let mut json: serde_json::Value =
|
||||
serde_json::from_str(&fs::read_to_string(dir.join("manifest.json")).unwrap()).unwrap();
|
||||
json.as_object_mut().unwrap().remove("layout");
|
||||
let old: NotebookManifest = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(old.layout, None);
|
||||
assert_eq!(old.layout_or(true), NotebookLayout::Continuous);
|
||||
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]
|
||||
fn test_notebook_management() {
|
||||
let temp_dir = tempfile::tempdir().expect("Konnte temporäres Verzeichnis nicht anlegen");
|
||||
|
||||
@@ -22,7 +22,7 @@ use crate::settings::{
|
||||
PenPreset, PressureCurve, SidebarSide, DEFAULT_HIGHLIGHTER_COLORS, DEFAULT_PEN_PRESETS,
|
||||
DEFAULT_TEXT_COLORS, PEN_PRESET_COUNT,
|
||||
};
|
||||
use crate::storage::NotebookSummary;
|
||||
use crate::storage::{NotebookLayout, NotebookSummary};
|
||||
use crate::text::{TextEditing, TextFont, TEXT_SIZES_PT};
|
||||
use crate::{t, tn};
|
||||
|
||||
@@ -263,6 +263,7 @@ pub enum UiAction {
|
||||
CreateNotebook {
|
||||
title: String,
|
||||
folder: String,
|
||||
layout: NotebookLayout,
|
||||
},
|
||||
/// Ordnerverwaltung (siehe `library.rs`)
|
||||
CreateFolder {
|
||||
@@ -317,8 +318,11 @@ pub enum UiAction {
|
||||
SaveStylusButtons,
|
||||
/// Druckkurve speichern (gilt während des Ziehens bereits über `UiState::pressure_curve`)
|
||||
SavePressureCurve(PressureCurve),
|
||||
/// Seiten durchgehend untereinander statt einzeln anzeigen (wird gespeichert)
|
||||
SetContinuousScroll(bool),
|
||||
/// Anordnung der Seiten des offenen Notizbuchs (wird im Manifest gespeichert)
|
||||
SetNotebookLayout(NotebookLayout),
|
||||
/// Durchgehende Ansicht: Fenstermitte auf diese Höhe (Koordinaten der aktuellen Seite)
|
||||
/// scrollen – Bildlaufleiste, Issue #28
|
||||
ScrollTo(f32),
|
||||
/// Farbe von Karos, Linien und Punkten (wird gespeichert, gilt auch für den Export)
|
||||
SetPatternColor(PatternColor),
|
||||
/// Bilddatei auf der aktuellen Seite einfügen
|
||||
@@ -364,6 +368,8 @@ pub struct UiState {
|
||||
pub document_title: String,
|
||||
pub available_notebooks: Vec<NotebookSummary>,
|
||||
pub new_notebook_title_input: String,
|
||||
/// Anordnung für das nächste neue Notizbuch
|
||||
pub new_notebook_layout: NotebookLayout,
|
||||
pub renaming_notebook_id: Option<String>,
|
||||
/// Ordner der Notizbücher und der gerade angezeigte Ordner im Dokumenten-Manager
|
||||
pub library: crate::library::Library,
|
||||
@@ -432,6 +438,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,
|
||||
@@ -447,6 +458,8 @@ pub struct UiState {
|
||||
/// Eingeblendetes Lineal
|
||||
pub ruler: Option<Ruler>,
|
||||
pub ruler_drag: Option<RulerDrag>,
|
||||
/// Bildlaufleiste wird gezogen: Abstand des Fingers von der Oberkante des Griffs
|
||||
pub scrollbar_grab: Option<f32>,
|
||||
/// Aktuelle Abbildung Seite ↔ Bildschirm (für Lineal und Treffertests)
|
||||
pub page_view: Option<PageView>,
|
||||
/// Fenstergröße in logischen Punkten (vom letzten Bild)
|
||||
@@ -483,6 +496,7 @@ impl Default for UiState {
|
||||
document_title: t!("Standard-Notizbuch").to_string(),
|
||||
available_notebooks: Vec::new(),
|
||||
new_notebook_title_input: String::new(),
|
||||
new_notebook_layout: NotebookLayout::Continuous,
|
||||
renaming_notebook_id: None,
|
||||
library: crate::library::Library::default(),
|
||||
notebook_folder: String::new(),
|
||||
@@ -522,6 +536,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,
|
||||
@@ -532,6 +548,7 @@ impl Default for UiState {
|
||||
queued_actions: Vec::new(),
|
||||
ruler: None,
|
||||
ruler_drag: None,
|
||||
scrollbar_grab: None,
|
||||
page_view: None,
|
||||
screen_size: egui::Vec2::ZERO,
|
||||
page_title_input: String::new(),
|
||||
@@ -1641,6 +1658,63 @@ fn page_title_list(ui: &mut egui::Ui, notebook: &Notebook) -> Option<usize> {
|
||||
chosen
|
||||
}
|
||||
|
||||
/// Auswahl der Seitenanordnung eines Notizbuchs. Ohne `can_cross` lässt sich nicht zwischen
|
||||
/// Seiten und endloser Fläche wechseln; ohne `offer_paged` fehlen feste Seiten (beim Anlegen,
|
||||
/// Issue #30 – umstellen geht im ☰-Menü). `true`, wenn sie geändert wurde.
|
||||
fn layout_picker(
|
||||
ui: &mut egui::Ui,
|
||||
layout: &mut NotebookLayout,
|
||||
can_cross: bool,
|
||||
offer_paged: bool,
|
||||
) -> bool {
|
||||
let mut changed = false;
|
||||
let endless_now = *layout == NotebookLayout::Endless;
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for (option, label, tooltip) in [
|
||||
(
|
||||
NotebookLayout::Paged,
|
||||
t!("📄 Feste Seiten"),
|
||||
t!("Seite für Seite blättern"),
|
||||
),
|
||||
(
|
||||
NotebookLayout::Continuous,
|
||||
t!("📜 Durchgehend"),
|
||||
t!("Seiten hängen untereinander, Mausrad und Finger scrollen \
|
||||
(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."
|
||||
),
|
||||
),
|
||||
] {
|
||||
if option == NotebookLayout::Paged && !offer_paged {
|
||||
continue;
|
||||
}
|
||||
let enabled = can_cross || (option == NotebookLayout::Endless) == endless_now;
|
||||
if ui
|
||||
.add_enabled(
|
||||
enabled,
|
||||
egui::SelectableLabel::new(*layout == option, label),
|
||||
)
|
||||
.on_hover_text(tooltip)
|
||||
.on_disabled_hover_text(tooltip)
|
||||
.clicked()
|
||||
&& *layout != option
|
||||
{
|
||||
*layout = option;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
changed
|
||||
}
|
||||
|
||||
/// Auswahlknopf für eine Papierfarbe: Farbmuster und Name.
|
||||
fn paper_theme_button(ui: &mut egui::Ui, theme: PaperTheme, selected: bool) -> egui::Response {
|
||||
let response = ui.add_sized(
|
||||
@@ -2285,6 +2359,147 @@ fn page_separators(ctx: &egui::Context, state: &UiState, notebook: &Notebook) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Lage der Bildlaufleiste: Leiste (Trefferfläche) und Griff in logischen Punkten. Der Griff
|
||||
/// zeigt, wo die Fenstermitte `center` zwischen Ober- und Unterkante des Notizbuchs liegt;
|
||||
/// `visible` ist die sichtbare Höhe (Seiteneinheiten) und bestimmt seine Länge.
|
||||
fn scrollbar_layout(
|
||||
screen: egui::Rect,
|
||||
side: SidebarSide,
|
||||
(top, bottom): (f32, f32),
|
||||
center: f32,
|
||||
visible: f32,
|
||||
) -> (egui::Rect, egui::Rect) {
|
||||
const WIDTH: f32 = 28.0;
|
||||
const MARGIN: f32 = 12.0;
|
||||
// Gegenüber der Werkzeugleiste
|
||||
let x = match side {
|
||||
SidebarSide::Left => screen.right() - WIDTH,
|
||||
SidebarSide::Right => screen.left(),
|
||||
};
|
||||
let track = egui::Rect::from_min_size(
|
||||
egui::pos2(x, screen.top() + MARGIN),
|
||||
egui::vec2(WIDTH, (screen.height() - 2.0 * MARGIN).max(1.0)),
|
||||
);
|
||||
let total = (bottom - top).max(1.0);
|
||||
let thumb_h = (visible / (total + visible) * track.height())
|
||||
.clamp(48.0_f32.min(track.height()), track.height());
|
||||
let fraction = ((center - top) / total).clamp(0.0, 1.0);
|
||||
let thumb = egui::Rect::from_min_size(
|
||||
egui::pos2(
|
||||
track.left(),
|
||||
track.top() + fraction * (track.height() - thumb_h),
|
||||
),
|
||||
egui::vec2(WIDTH, thumb_h),
|
||||
);
|
||||
(track, thumb)
|
||||
}
|
||||
|
||||
/// Durchgehende Ansicht: Bildlaufleiste am Fensterrand gegenüber der Werkzeugleiste. Den
|
||||
/// Griff ziehen (oder auf die Leiste tippen) springt schnell durch lange Notizbücher und
|
||||
/// PDFs (Issue #28); beim Ziehen steht die Seitenzahl daneben.
|
||||
fn page_scrollbar(
|
||||
ctx: &egui::Context,
|
||||
state: &mut UiState,
|
||||
notebook: &Notebook,
|
||||
actions: &mut Vec<UiAction>,
|
||||
) {
|
||||
let Some(view) = state.page_view.filter(|_| state.continuous_scroll) else {
|
||||
state.scrollbar_grab = None;
|
||||
return;
|
||||
};
|
||||
if notebook.pages.len() < 2 {
|
||||
state.scrollbar_grab = None;
|
||||
return;
|
||||
}
|
||||
let stack = notebook.page_stack();
|
||||
let (min, max) = stack.bounds();
|
||||
let screen = ctx.screen_rect();
|
||||
let center = view.to_page(screen.center()).y;
|
||||
let visible = screen.height() / view.scale.max(1e-6);
|
||||
let (track, thumb) =
|
||||
scrollbar_layout(screen, state.sidebar_side, (min.y, max.y), center, visible);
|
||||
|
||||
let dark = state.paper_theme.is_dark();
|
||||
let response = egui::Area::new(egui::Id::new("page_scrollbar"))
|
||||
.order(egui::Order::Middle)
|
||||
.fixed_pos(track.min)
|
||||
.show(ctx, |ui| {
|
||||
let (_, response) = ui.allocate_exact_size(track.size(), egui::Sense::drag());
|
||||
let active = response.hovered() || response.dragged();
|
||||
let bar_w = if active { 10.0 } else { 6.0 };
|
||||
let bar = egui::Rect::from_center_size(
|
||||
egui::pos2(thumb.center().x, thumb.center().y),
|
||||
egui::vec2(bar_w, thumb.height()),
|
||||
);
|
||||
let alpha = if active { 200 } else { 110 };
|
||||
let color = if dark {
|
||||
egui::Color32::from_white_alpha(alpha)
|
||||
} else {
|
||||
egui::Color32::from_black_alpha(alpha)
|
||||
};
|
||||
ui.painter()
|
||||
.rect_filled(bar, egui::Rounding::same(bar_w / 2.0), color);
|
||||
response
|
||||
})
|
||||
.inner;
|
||||
|
||||
if response.drag_started() {
|
||||
let y = response
|
||||
.interact_pointer_pos()
|
||||
.map_or(thumb.center().y, |p| p.y);
|
||||
// Auf den Griff: dort festhalten; daneben: Griff unter den Finger springen lassen
|
||||
state.scrollbar_grab = Some(if thumb.y_range().contains(y) {
|
||||
y - thumb.top()
|
||||
} else {
|
||||
thumb.height() / 2.0
|
||||
});
|
||||
}
|
||||
if !response.dragged() {
|
||||
state.scrollbar_grab = None;
|
||||
return;
|
||||
}
|
||||
let (Some(grab), Some(pos)) = (state.scrollbar_grab, response.interact_pointer_pos()) else {
|
||||
return;
|
||||
};
|
||||
let free = (track.height() - thumb.height()).max(1.0);
|
||||
let fraction = ((pos.y - grab - track.top()) / free).clamp(0.0, 1.0);
|
||||
let target = min.y + fraction * (max.y - min.y);
|
||||
if (target - center).abs() > 0.5 {
|
||||
actions.push(UiAction::ScrollTo(target));
|
||||
}
|
||||
if let Some(page) = stack.page_at(target) {
|
||||
let label_x = match state.sidebar_side {
|
||||
SidebarSide::Left => track.left() - 8.0,
|
||||
SidebarSide::Right => track.right() + 8.0,
|
||||
};
|
||||
let anchor = match state.sidebar_side {
|
||||
SidebarSide::Left => egui::Align2::RIGHT_CENTER,
|
||||
SidebarSide::Right => egui::Align2::LEFT_CENTER,
|
||||
};
|
||||
let painter = ctx.layer_painter(egui::LayerId::new(
|
||||
egui::Order::Foreground,
|
||||
egui::Id::new("page_scrollbar_label"),
|
||||
));
|
||||
let text = format!("{} / {}", page + 1, notebook.pages.len());
|
||||
let galley =
|
||||
painter.layout_no_wrap(text, egui::FontId::proportional(18.0), egui::Color32::WHITE);
|
||||
let rect = anchor
|
||||
.anchor_size(egui::pos2(label_x, pos.y), galley.size())
|
||||
.expand2(egui::vec2(10.0, 6.0));
|
||||
painter.rect_filled(
|
||||
rect,
|
||||
egui::Rounding::same(8.0),
|
||||
egui::Color32::from_black_alpha(190),
|
||||
);
|
||||
painter.galley(
|
||||
rect.min + egui::vec2(10.0, 6.0),
|
||||
galley,
|
||||
egui::Color32::WHITE,
|
||||
);
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
/// Zeichnet das Lineal über dem Blatt und bedient Verschieben und Drehen.
|
||||
fn ruler_overlay(ctx: &egui::Context, state: &mut UiState) {
|
||||
let (Some(mut ruler), Some(view)) = (state.ruler, state.page_view) else {
|
||||
@@ -2692,6 +2907,7 @@ pub fn render_ui(
|
||||
let mut actions = std::mem::take(&mut state.queued_actions);
|
||||
state.screen_size = ctx.screen_rect().size();
|
||||
page_separators(ctx, state, notebook);
|
||||
page_scrollbar(ctx, state, notebook, &mut actions);
|
||||
space_preview(ctx, state, notebook);
|
||||
ruler_overlay(ctx, state);
|
||||
text_edit_overlay(ctx, state);
|
||||
@@ -3174,7 +3390,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,
|
||||
@@ -3199,7 +3415,7 @@ pub fn render_ui(
|
||||
btn,
|
||||
SidebarIcon::Text(&page_text),
|
||||
state.show_page_jump_popup,
|
||||
true,
|
||||
!state.endless,
|
||||
None,
|
||||
t!("Seite wählen"),
|
||||
);
|
||||
@@ -3228,7 +3444,7 @@ pub fn render_ui(
|
||||
btn,
|
||||
SidebarIcon::Glyph("➕"),
|
||||
false,
|
||||
true,
|
||||
!state.endless,
|
||||
None,
|
||||
t!("Neue Seite nach dieser einfügen"),
|
||||
)
|
||||
@@ -3640,7 +3856,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()
|
||||
{
|
||||
@@ -3753,17 +3972,16 @@ pub fn render_ui(
|
||||
}
|
||||
});
|
||||
|
||||
section(ui, t!("ANSICHT"));
|
||||
if ui
|
||||
.checkbox(&mut state.continuous_scroll, t!("Seiten durchgehend scrollen"))
|
||||
.on_hover_text(
|
||||
t!("An: Seiten hängen untereinander, Mausrad und Finger scrollen \
|
||||
(Strg+Mausrad zoomt); wer unten auf der letzten Seite schreibt, \
|
||||
bekommt automatisch eine neue Seite.\nAus: Seite für Seite blättern."),
|
||||
)
|
||||
.changed()
|
||||
{
|
||||
actions.push(UiAction::SetContinuousScroll(state.continuous_scroll));
|
||||
section(ui, t!("SEITEN DIESES NOTIZBUCHS"));
|
||||
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.endless_switch_possible, true) {
|
||||
actions.push(UiAction::SetNotebookLayout(layout));
|
||||
}
|
||||
|
||||
section(ui, t!("ZOOM"));
|
||||
@@ -4098,6 +4316,7 @@ pub fn render_ui(
|
||||
actions.push(UiAction::CreateNotebook {
|
||||
title,
|
||||
folder: folder.clone(),
|
||||
layout: state.new_notebook_layout,
|
||||
});
|
||||
state.new_notebook_title_input.clear();
|
||||
} else if folder_btn.clicked() {
|
||||
@@ -4109,6 +4328,10 @@ pub fn render_ui(
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(t!("Seiten:"));
|
||||
layout_picker(ui, &mut state.new_notebook_layout, true, false);
|
||||
});
|
||||
|
||||
ui.add_space(8.0);
|
||||
ui.separator();
|
||||
@@ -4961,6 +5184,86 @@ mod tests {
|
||||
ctx
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_page_scrollbar_jumps_through_the_notebook() {
|
||||
// Layout: Griff oben am Anfang, unten am Ende, gegenüber der Werkzeugleiste
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 850.0));
|
||||
let (track, top) =
|
||||
scrollbar_layout(screen, SidebarSide::Left, (0.0, 80_000.0), 0.0, 1000.0);
|
||||
assert_eq!(track.right(), screen.right());
|
||||
assert_eq!(top.top(), track.top());
|
||||
assert!(top.height() >= 48.0);
|
||||
let (_, end) =
|
||||
scrollbar_layout(screen, SidebarSide::Left, (0.0, 80_000.0), 80_000.0, 1000.0);
|
||||
assert_eq!(end.bottom(), track.bottom());
|
||||
let (left, _) = scrollbar_layout(screen, SidebarSide::Right, (0.0, 1.0), 0.0, 1.0);
|
||||
assert_eq!(left.left(), screen.left());
|
||||
|
||||
// Notizbuch mit 80 Seiten, durchgehend, Ansicht auf Seite 1
|
||||
let mut notebook = Notebook::new();
|
||||
for _ in 1..80 {
|
||||
notebook.add_page(PageBackground::Blank);
|
||||
}
|
||||
let mut state = UiState::new();
|
||||
state.continuous_scroll = true;
|
||||
state.page_view = Some(PageView {
|
||||
origin: egui::pos2(300.0, 0.0),
|
||||
scale: 0.25,
|
||||
});
|
||||
let ctx = render_frames(&mut state, ¬ebook, 2);
|
||||
let updater = crate::updater::UpdaterController::new();
|
||||
let x = screen.right() - 14.0;
|
||||
let mut actions = Vec::new();
|
||||
// Griff greifen und bis ans untere Ende ziehen
|
||||
for (pos, pressed) in [
|
||||
(egui::pos2(x, 30.0), Some(true)),
|
||||
(egui::pos2(x, 400.0), None),
|
||||
(egui::pos2(x, 845.0), None),
|
||||
(egui::pos2(x, 845.0), Some(false)),
|
||||
] {
|
||||
let mut events = vec![egui::Event::PointerMoved(pos)];
|
||||
if let Some(pressed) = pressed {
|
||||
events.push(egui::Event::PointerButton {
|
||||
pos,
|
||||
button: egui::PointerButton::Primary,
|
||||
pressed,
|
||||
modifiers: egui::Modifiers::default(),
|
||||
});
|
||||
}
|
||||
ctx.begin_frame(egui::RawInput {
|
||||
screen_rect: Some(screen),
|
||||
events,
|
||||
..Default::default()
|
||||
});
|
||||
actions.extend(render_ui(
|
||||
&ctx,
|
||||
&mut state,
|
||||
¬ebook,
|
||||
AutosaveStatus::Saved,
|
||||
&updater,
|
||||
));
|
||||
let _ = ctx.end_frame();
|
||||
}
|
||||
let targets: Vec<f32> = actions
|
||||
.iter()
|
||||
.filter_map(|a| match a {
|
||||
UiAction::ScrollTo(y) => Some(*y),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let (_, max) = notebook.page_stack().bounds();
|
||||
assert!(targets.len() >= 2, "{actions:?}");
|
||||
assert!(targets.windows(2).all(|w| w[1] >= w[0]));
|
||||
assert!((targets.last().unwrap() - max.y).abs() < 1.0, "{targets:?}");
|
||||
assert!(state.scrollbar_grab.is_none());
|
||||
|
||||
// Feste Seiten: keine Leiste, Tippen an den Rand scrollt nicht
|
||||
state.continuous_scroll = false;
|
||||
let ctx = render_frames(&mut state, ¬ebook, 2);
|
||||
let actions = click_at(&ctx, &mut state, ¬ebook, egui::pos2(x, 400.0));
|
||||
assert!(!actions.iter().any(|a| matches!(a, UiAction::ScrollTo(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_text_edit_overlay_takes_typing_and_escape() {
|
||||
let notebook = Notebook::new();
|
||||
|
||||
+118
@@ -305,6 +305,84 @@ impl ViewportState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Schwung beim Scrollen mit dem Finger (Issue #28): Nach dem Abheben gleitet der
|
||||
/// Ausschnitt mit der zuletzt gewischten Geschwindigkeit weiter und bremst langsam ab.
|
||||
/// Geschwindigkeiten in Bildschirmpixeln je Sekunde.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct Fling {
|
||||
velocity: Vec2,
|
||||
/// Zeit der letzten Fingerbewegung bzw. des letzten Gleitschritts
|
||||
last: Option<std::time::Instant>,
|
||||
active: bool,
|
||||
}
|
||||
|
||||
impl Fling {
|
||||
/// Abbremsen je Sekunde (Anteil der Geschwindigkeit, der nach 1 s bleibt: e^-FRICTION)
|
||||
const FRICTION: f32 = 2.5;
|
||||
/// Langsamer wischen gleitet nicht nach
|
||||
const MIN_START_SPEED: f32 = 400.0;
|
||||
/// Darunter hört das Gleiten auf
|
||||
const MIN_SPEED: f32 = 40.0;
|
||||
/// Liegt der Finger vor dem Abheben so lange still, gleitet nichts nach
|
||||
const MAX_REST: std::time::Duration = std::time::Duration::from_millis(80);
|
||||
|
||||
/// Finger hat sich um `delta` verschoben.
|
||||
pub fn track(&mut self, delta: Vec2, now: std::time::Instant) {
|
||||
self.active = false;
|
||||
let dt = self
|
||||
.last
|
||||
.map_or(0.0, |last| now.duration_since(last).as_secs_f32());
|
||||
self.last = Some(now);
|
||||
if dt <= 0.0 {
|
||||
return;
|
||||
}
|
||||
// Geglättet: einzelne ungleichmäßige Touch-Ereignisse sollen nicht zählen
|
||||
let sample = delta / dt.max(0.004);
|
||||
self.velocity = self.velocity.lerp(sample, 0.4);
|
||||
}
|
||||
|
||||
/// Finger abgehoben: gleitet weiter, wenn zuletzt schnell genug gewischt wurde.
|
||||
pub fn release(&mut self, now: std::time::Instant) {
|
||||
let resting = self
|
||||
.last
|
||||
.is_none_or(|last| now.duration_since(last) > Self::MAX_REST);
|
||||
self.active = !resting && self.velocity.length() >= Self::MIN_START_SPEED;
|
||||
self.last = Some(now);
|
||||
if !self.active {
|
||||
self.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/// Hält an (neue Berührung, Mausklick, Mausrad).
|
||||
pub fn stop(&mut self) {
|
||||
*self = Self::default();
|
||||
}
|
||||
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.active
|
||||
}
|
||||
|
||||
/// Verschiebung (Bildschirmpixel) seit dem letzten Schritt; `None`, wenn nichts gleitet.
|
||||
pub fn step(&mut self, now: std::time::Instant) -> Option<Vec2> {
|
||||
if !self.active {
|
||||
return None;
|
||||
}
|
||||
let dt = self
|
||||
.last
|
||||
.map_or(0.0, |last| now.duration_since(last).as_secs_f32())
|
||||
.min(0.1);
|
||||
self.last = Some(now);
|
||||
// Exakt integriert, damit die Strecke nicht von der Bildrate abhängt
|
||||
let decay = (-Self::FRICTION * dt).exp();
|
||||
let delta = self.velocity * (1.0 - decay) / Self::FRICTION;
|
||||
self.velocity *= decay;
|
||||
if self.velocity.length() < Self::MIN_SPEED {
|
||||
self.stop();
|
||||
}
|
||||
Some(delta)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -316,6 +394,46 @@ mod tests {
|
||||
PageStack::new(vec![A4; count], current)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fling_glides_and_slows_down() {
|
||||
use std::time::{Duration, Instant};
|
||||
let t0 = Instant::now();
|
||||
let ms = |n: u64| t0 + Duration::from_millis(n);
|
||||
let mut fling = Fling::default();
|
||||
// Schnell nach oben wischen: 30 px alle 10 ms = 3000 px/s
|
||||
for i in 0..=10 {
|
||||
fling.track(Vec2::new(0.0, -30.0), ms(i * 10));
|
||||
}
|
||||
fling.release(ms(105));
|
||||
assert!(fling.is_active());
|
||||
let first = fling.step(ms(121)).unwrap();
|
||||
assert!(first.y < -20.0, "{first:?}");
|
||||
let mut total = first;
|
||||
let mut t = 121;
|
||||
while let Some(d) = fling.step(ms(t)) {
|
||||
total += d;
|
||||
t += 16;
|
||||
assert!(t < 10_000, "gleitet endlos");
|
||||
}
|
||||
// Gleitet ungefähr v / FRICTION = 1200 px weiter, dann ist Schluss
|
||||
assert!(total.y < -800.0 && total.y > -1300.0, "{total:?}");
|
||||
assert!(!fling.is_active());
|
||||
|
||||
// Langsam schieben oder vor dem Abheben stillhalten: kein Nachgleiten
|
||||
let mut slow = Fling::default();
|
||||
for i in 0..=10 {
|
||||
slow.track(Vec2::new(0.0, -2.0), ms(i * 10));
|
||||
}
|
||||
slow.release(ms(105));
|
||||
assert!(!slow.is_active());
|
||||
let mut rested = Fling::default();
|
||||
for i in 0..=10 {
|
||||
rested.track(Vec2::new(0.0, -30.0), ms(i * 10));
|
||||
}
|
||||
rested.release(ms(400));
|
||||
assert!(!rested.is_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_continuous_layout() {
|
||||
let stack = a4_stack(5, 1);
|
||||
|
||||
Reference in New Issue
Block a user