Compare commits

...
13 Commits
Author SHA1 Message Date
haraldandClaude Opus 5.5 5fafff5eaf style: cargo fmt
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 6m29s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 17:19:10 +02:00
haraldandClaude Opus 5.5 d21fa91315 chore(release): bump version to 0.29.1
Build & Publish Release / Build Packages & Publish to Gitea (push) Failing after 1m47s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:28:51 +02:00
haraldandClaude Opus 5.5 b00767b457 perf: Große Auswahlen flüssig verschieben, drehen und skalieren
Seit 0.27 kopierte die Prüfung des Seitenverhältnis-Schlosses bei jedem
Bild alle ausgewählten Striche und suchte jeden davon quadratisch in der
Seite. Zusammen mit dem Kopieren beim Ziehen lastete das bei großen
Auswahlen den Prozessor aus, das Verschieben kam kaum noch hinterher.

- Schloss und Auswahlrahmen ohne Kopien bestimmen
- Striche beim Ziehen an ihrer Stelle aus dem Ausgangszustand umformen
  statt die Auswahl zweimal zu kopieren; Suche über eine ID-Tabelle
- Stiftbewegungen der Auswahl nur einmal je Bild anwenden
- Endlose Fläche: Mausrad scrollt (Strg+Mausrad zoomt)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:28:51 +02:00
haraldandClaude Opus 5.5 63ab6a3e5b chore(release): bump version to 0.29.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 8m46s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:17:17 +02:00
haraldandClaude Opus 5.5 3c793fe659 feat: Endlose Fläche als Seitenanordnung (Issue #30, Schritt 2)
Ein endloses Notizbuch hat eine einzige Seite von 10 × 10 m; Eingabe,
Treffertests, Undo und Speichern bleiben dadurch unverändert. Die
Ansicht zeigt bei 100 % einen DIN-A4-Rahmen in der Mitte, Bilder und
Textfelder richten sich nach A4. Wählbar beim Anlegen und im ☰-Menü,
umschalten nur bei einer Seite ohne PDF bzw. zurück, solange alles im
Startausschnitt liegt. Seiten-Knöpfe sind auf der endlosen Fläche aus.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:14:45 +02:00
haraldandClaude Opus 5.5 8bc29faddc chore(release): bump version to 0.28.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 8m1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:01:18 +02:00
haraldandClaude Opus 5.5 8ea20aa783 feat: Seitenanordnung je Notizbuch (Issue #30, Schritt 1)
Ob ein Notizbuch Seite für Seite oder durchgehend angezeigt wird, steht
jetzt im Manifest (`layout`). Beim Anlegen ist „Feste Seiten“
vorgewählt; im ☰-Menü lässt sich die Anordnung des offenen Notizbuchs
umschalten. Notizbücher ohne Angabe behalten die frühere globale
Einstellung, ältere App-Versionen ignorieren das Feld.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:58:55 +02:00
haraldandClaude Opus 5.5 1b30f2bd26 chore(release): bump version to 0.27.1
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 13m58s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:48:20 +02:00
haraldandClaude Opus 5.5 657a5ff16e feat: Stifte der Werkzeugleiste in ihrer Farbe (Issue #31)
Statt eines dünnen Strichs auf grauem Papier zeigt jeder Stift jetzt
einen schräg liegenden Stift, dessen Schaft und Mine in seiner Farbe
gefüllt sind. Eine helle Kontur hält dunkle Farben auf der dunklen
Leiste sichtbar; die Stärke steht weiter im Tooltip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:44:51 +02:00
haraldandClaude Opus 5.5 8fddd9e2c3 chore(release): bump version to 0.27.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 21m31s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:18:34 +02:00
haraldandClaude Opus 5.5 1a98c9b8bb feat: Knopf für das Seitenverhältnis-Schloss der Auswahl (Issue #29)
Im Tablet-Betrieb gibt es keine Umschalttaste. Beim Auswahlwerkzeug
steht unter den Stiftfarben jetzt ein Schloss: 🔒 skaliert gleichmäßig,
🔓 frei. Vorgabe ist gesperrt, sobald ein Bild ausgewählt ist, sonst
frei. Das Umschalten gilt für die aktuelle Auswahl; eine neue Auswahl
beginnt wieder mit der Vorgabe. Shift sperrt am Rechner zusätzlich.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:13:15 +02:00
haraldandClaude Opus 5.5 98b78d29e6 feat: Bilder beim Skalieren nicht verzerren (Issue #29)
Enthält die Auswahl ein Bild, skaliert der Eckgriff gleichmäßig, das
Seitenverhältnis bleibt erhalten. Mit gedrückter Umschalttaste gilt das
auch für Striche und Formen. Der Faktor ist die Projektion der
Zugposition auf die Diagonale der Auswahl.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:02:56 +02:00
haraldandClaude Opus 5.5 81ccd93438 docs: Code of Conduct, Mitmach-Anleitung, Sicherheitsrichtlinie und Vorlagen
Für die Community-Standards auf GitHub (Spiegel) und für Gitea:

- CODE_OF_CONDUCT.md: Contributor Covenant 2.1, Kontakt harald@pansi.eu
- CONTRIBUTING.md (Deutsch/Englisch): Gitea als Hauptrepository,
  Prüfungen wie in der CI, Tests, Dateiformat, Übersetzungen
- SECURITY.md: unterstützt wird die neueste Version, Meldungen
  vertraulich per E-Mail
- Issue-Vorlagen für Fehler und Ideen, Kontakt-Links (auf GitHub mit
  Verweis auf Gitea), Pull-Request-Vorlage
- README: Abschnitt „Mitmachen“

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:40:36 +02:00
26 changed files with 1228 additions and 176 deletions
+7
View File
@@ -0,0 +1,7 @@
# Gitea liest die Vorlagen aus .github/ISSUE_TEMPLATE; diese Datei ersetzt dort nur die
# Kontakt-Links (der Verweis auf Gitea selbst entfällt).
blank_issues_enabled: true
contact_links:
- name: "Sicherheitslücke melden / Report a security issue"
url: https://gitea.pansi.eu/harald/ink-notes/src/branch/main/SECURITY.md
about: "Bitte vertraulich per E-Mail, nicht als öffentliches Issue. / Please report privately by e-mail, not as a public issue."
+28
View File
@@ -0,0 +1,28 @@
---
name: "Fehler melden / Bug report"
about: "Etwas funktioniert nicht wie erwartet / Something does not work as expected"
title: ""
---
<!-- Sicherheitslücken bitte nicht hier melden, siehe SECURITY.md. / Please report security issues privately, see SECURITY.md. -->
**Was passiert? / What happens?**
**Was hättest du erwartet? / What did you expect?**
**Schritte zum Nachstellen / Steps to reproduce**
1.
2.
3.
**Umgebung / Environment**
- ink-notes-Version / version (☰ → Updates):
- Installiert als / installed as (.deb, AppImage, Flatpak, selbst gebaut / built from source):
- Distribution:
- Wayland oder / or X11:
- Eingabegerät / input device (Stift / pen, Finger / touch, Maus / mouse, Touchpad):
**Bildschirmfoto, Aufnahme oder Ausgabe im Terminal / Screenshot, recording or terminal output**
<!-- Wenn möglich: ink-notes im Terminal starten und die Ausgabe hier einfügen. / If possible, start ink-notes in a terminal and paste its output here. -->
+8
View File
@@ -0,0 +1,8 @@
blank_issues_enabled: true
contact_links:
- name: "Issues auf Gitea (Hauptrepository) / Issues on Gitea (main repository)"
url: https://gitea.pansi.eu/harald/ink-notes/issues
about: "GitHub ist ein Spiegel; Issues werden bevorzugt auf Gitea bearbeitet. / GitHub is a mirror; issues are preferably handled on Gitea."
- name: "Sicherheitslücke melden / Report a security issue"
url: https://gitea.pansi.eu/harald/ink-notes/src/branch/main/SECURITY.md
about: "Bitte vertraulich per E-Mail, nicht als öffentliches Issue. / Please report privately by e-mail, not as a public issue."
+14
View File
@@ -0,0 +1,14 @@
---
name: "Idee vorschlagen / Feature request"
about: "Eine neue Funktion oder Verbesserung / A new feature or improvement"
title: ""
---
**Was möchtest du tun können? / What would you like to be able to do?**
**Wie machst du es bisher, und was stört dich daran? / How do you do it today, and what bothers you about it?**
**Wie könnte es aussehen? / How could it work?**
<!-- Skizzen, Bildschirmfotos oder Beispiele aus anderen Apps helfen. / Sketches, screenshots or examples from other apps help. -->
+13
View File
@@ -0,0 +1,13 @@
<!-- GitHub ist ein Spiegel von https://gitea.pansi.eu/harald/ink-notes; Pull Requests werden dort von Hand übernommen. / GitHub is a mirror; pull requests are applied on Gitea by hand. -->
**Was ändert sich und warum? / What changes and why?**
**Zugehöriges Issue / Related issue:** #
**Checkliste / Checklist**
- [ ] `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings` und / and `cargo test` laufen durch / pass
- [ ] Tests für neue Funktionen oder behobene Fehler / tests for new features or fixes
- [ ] Neue Texte der Oberfläche auch auf Englisch (`src/i18n/`) / new UI texts translated to English
- [ ] Änderungen am Dateiformat in `docs/FILE_FORMAT.md` beschrieben / file format changes documented (falls nötig / if needed)
- [ ] Ich habe CONTRIBUTING.md gelesen / I have read CONTRIBUTING.md
+85
View File
@@ -0,0 +1,85 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
* Focusing on what is best not just for us as individuals, but for the overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at harald@pansi.eu. All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series of actions.
**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations].
[homepage]: https://www.contributor-covenant.org
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
[Mozilla CoC]: https://github.com/mozilla/diversity
[FAQ]: https://www.contributor-covenant.org/faq
[translations]: https://www.contributor-covenant.org/translations
+43
View File
@@ -0,0 +1,43 @@
# Mitmachen bei ink-notes
Danke, dass du ink-notes verbessern willst! *(English below.)*
## Wo?
Das Hauptrepository liegt auf **Gitea**: <https://gitea.pansi.eu/harald/ink-notes>.
[GitHub](https://github.com/haraldpansi-gif/ink-notes) ist ein Spiegel davon – Issues und Pull Requests sind dort auch willkommen, Pull Requests werden aber von Hand nach Gitea übernommen.
## Fehler melden und Ideen vorschlagen
- Bitte zuerst nachsehen, ob es schon ein [Issue](https://gitea.pansi.eu/harald/ink-notes/issues) dazu gibt.
- Für Fehler die Vorlage „Fehler melden“ verwenden: Version (☰ → Updates), Distribution, Wayland oder X11, Eingabegerät (Stift, Finger, Maus, Touchpad) und die Schritte, mit denen der Fehler auftritt. Ein Bildschirmfoto oder eine kurze Aufnahme hilft oft sehr.
- Deutsch und Englisch sind beide in Ordnung.
- **Sicherheitslücken bitte nicht öffentlich melden**, sondern wie in [SECURITY.md](SECURITY.md) beschrieben.
## Code beitragen
1. Größere Änderungen am besten vorher in einem Issue absprechen.
2. Bauen und testen, wie im [README](README.md#entwicklung) beschrieben. Vor dem Einreichen müssen diese Prüfungen durchlaufen (die CI prüft sie ebenfalls):
```bash
cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo test
```
3. Neue Funktionen und behobene Fehler bekommen einen Test, wo das sinnvoll geht.
4. Änderungen am Dateiformat bitte in [docs/FILE_FORMAT.md](docs/FILE_FORMAT.md) nachtragen; ältere Notizbücher müssen weiter lesbar bleiben.
5. Commit-Nachrichten im Stil von [Conventional Commits](https://www.conventionalcommits.org/), z. B. `fix: …`, `feat: …`, `perf: …`, `docs: …`; ein Bezug auf ein Issue als `(Issue #28)`.
6. Texte der Oberfläche gibt es auf Deutsch und Englisch: Neue Texte mit `t!("…")` schreiben und die englische Übersetzung unter `src/i18n/` ergänzen.
Mit dem Einreichen stimmst du zu, dass dein Beitrag unter der [MIT-Lizenz](LICENSE) des Projekts steht. Für den Umgang miteinander gilt der [Code of Conduct](CODE_OF_CONDUCT.md).
---
## Contributing (English)
The main repository is on **Gitea**: <https://gitea.pansi.eu/harald/ink-notes>. [GitHub](https://github.com/haraldpansi-gif/ink-notes) is a mirror; issues and pull requests are welcome there too, but pull requests are applied to Gitea by hand.
- **Bugs and ideas:** check the [existing issues](https://gitea.pansi.eu/harald/ink-notes/issues) first, then use the bug report or feature request template. Please include the version (☰ → Updates), distribution, Wayland or X11, the input device and the steps to reproduce. German and English are both fine.
- **Security issues:** please do not report them publicly; see [SECURITY.md](SECURITY.md).
- **Code:** discuss larger changes in an issue first. Before submitting, `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings` and `cargo test` must pass. Add tests where it makes sense, document file format changes in `docs/FILE_FORMAT.md`, use Conventional Commits, and add English translations for new UI texts (`t!("…")`, see `src/i18n/`).
By contributing you agree that your contribution is licensed under the project's [MIT License](LICENSE). Everyone taking part is expected to follow the [Code of Conduct](CODE_OF_CONDUCT.md).
Generated
+1 -1
View File
@@ -1393,7 +1393,7 @@ dependencies = [
[[package]] [[package]]
name = "ink-notes" name = "ink-notes"
version = "0.26.2" version = "0.29.1"
dependencies = [ dependencies = [
"ab_glyph", "ab_glyph",
"arboard", "arboard",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "ink-notes" name = "ink-notes"
version = "0.26.2" version = "0.29.1"
edition = "2021" edition = "2021"
description = "Notizbuch-App für Stift, Touch und Maus unter Linux" description = "Notizbuch-App für Stift, Touch und Maus unter Linux"
repository = "https://gitea.pansi.eu/harald/ink-notes" repository = "https://gitea.pansi.eu/harald/ink-notes"
+7 -1
View File
@@ -49,7 +49,7 @@ Die Oberfläche gibt es auf Deutsch und Englisch; ink-notes folgt der Systemspra
### Seiten und Ansicht ### 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. - **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. - **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). - **Feste Seiten, durchgehendes Scrollen oder endlose Fläche, je Notizbuch:** Beim Anlegen wählbar (Vorgabe: feste Seiten, Seite für Seite blättern mit Pfeilen in der Werkzeugleiste), später im ☰-Menü umschaltbar. Durchgehend hängen die Seiten lückenlos untereinander, eine dunkle Linie markiert die Grenze; 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. - **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. - **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. - **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.
@@ -240,6 +240,12 @@ Bleibt ein Job auf „Waiting to run“ stehen: Ist der Runner online (*Einstell
--- ---
## Mitmachen
Fehlermeldungen, Ideen und Beiträge sind willkommen – am liebsten als [Issue auf Gitea](https://gitea.pansi.eu/harald/ink-notes/issues); [GitHub](https://github.com/haraldpansi-gif/ink-notes) ist ein Spiegel. Details in [CONTRIBUTING.md](CONTRIBUTING.md), Sicherheitslücken bitte vertraulich wie in [SECURITY.md](SECURITY.md) beschrieben. Es gilt der [Code of Conduct](CODE_OF_CONDUCT.md).
---
## Lizenz ## Lizenz
[MIT](LICENSE), Copyright © 2026 Harald Pansi. [MIT](LICENSE), Copyright © 2026 Harald Pansi.
+26
View File
@@ -0,0 +1,26 @@
# Sicherheit / Security Policy
## Unterstützte Versionen / Supported versions
Sicherheitskorrekturen gibt es nur für die jeweils **neueste Version** (siehe [Releases](https://gitea.pansi.eu/harald/ink-notes/releases)). Die App bietet Updates selbst an (☰ → Updates); als Flatpak kommen sie über die Software-Verwaltung.
Security fixes are only provided for the **latest release**. The app offers updates itself (☰ → Updates); the Flatpak is updated through the software manager.
## Sicherheitslücke melden / Reporting a vulnerability
Bitte Sicherheitslücken **nicht** in einem öffentlichen Issue melden, sondern per E-Mail an **harald@pansi.eu** – möglichst mit:
- betroffener Version und Installationsart (.deb, AppImage, Flatpak, selbst gebaut),
- einer Beschreibung, was passieren kann,
- den Schritten oder einer Datei (z. B. ein präpariertes Notizbuch oder PDF), mit denen es sich nachstellen lässt.
Du bekommst innerhalb einer Woche eine Antwort. Ist die Lücke bestätigt, erscheint die Korrektur als neue Version; auf Wunsch wirst du in den Release-Notes genannt.
Please do **not** report vulnerabilities in a public issue. Send an e-mail to **harald@pansi.eu** instead, ideally with the affected version and installation type, a description of the impact and steps or a file (e.g. a crafted notebook or PDF) to reproduce it. You will get an answer within a week. Confirmed issues are fixed in a new release; on request you will be credited in the release notes.
## Besonders interessant / Areas of interest
- **Updates:** Prüfung der Minisign-Signatur und der SHA-256-Prüfsummen, Download und Austausch der Pakete (siehe „Sicherheit der Updates“ im [README](README.md#sicherheit-der-updates)).
- **Dateien öffnen:** importierte PDFs und Bilder, Notizbuch-Dateien ([Dateiformat](docs/FILE_FORMAT.md)) – z. B. Abstürze oder Schreiben außerhalb des Datenordners durch präparierte Dateien.
- **Updates:** verification of the Minisign signature and SHA-256 checksums, downloading and replacing packages.
- **Opening files:** imported PDFs and images, notebook files – e.g. crashes or writes outside the data folder caused by crafted files.
+7
View File
@@ -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. - Im Shader wird jeder Punkt mit `transform_scale` und `transform_offset` (aus `CameraUniforms`) direkt in Gerätekoordinaten umgerechnet.
### 3.3 Durchgehende Ansicht ### 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). 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. 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 ## 4. Rendering
Ein Frame besteht aus einem Render-Durchgang auf die Fensterfläche (`canvas.rs`): Ein Frame besteht aus einem Render-Durchgang auf die Fensterfläche (`canvas.rs`):
+9 -4
View File
@@ -1,6 +1,6 @@
# Dateiformat von ink-notes # 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 ```json
{ {
"format_version": 8, "format_version": 9,
"id": "vorlesung_1775460000", "id": "vorlesung_1775460000",
"title": "Vorlesung", "title": "Vorlesung",
"created_at": 1775460000, "created_at": 1775460000,
@@ -63,7 +63,8 @@ Ordner wie „3. Semester › Mathe“ sind rein logisch: Die Notizbücher bleib
], ],
"page_ids": [1, 2], "page_ids": [1, 2],
"next_stroke_id": 42, "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 | | `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) | | `page_ids` | dieselbe Reihenfolge als reine ID-Liste (für ältere Versionen) |
| `next_stroke_id`, `next_page_id` | nächste freie IDs | | `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` | | `background` | Muster: `Blank`, `Grid5mm`, `Lined`, `DotGrid` |
| `title` | Seitentitel (optional) | | `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 | | `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 | | `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) | | 6 | 0.7 | Seitentitel (`title` in Seite und Manifest) |
| 7 | 0.10 | Textfelder (`text`) | | 7 | 0.10 | Textfelder (`text`) |
| 8 | 0.11 | Seitengröße je Seite (`size` in Seite und Manifest) | | 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. Jede neue Version ändert nur Ergänzungen; ältere Dateien werden ohne Umwandlung gelesen und beim nächsten Speichern mit der aktuellen Versionsnummer geschrieben.
+25
View File
@@ -84,6 +84,31 @@
</branding> </branding>
<content_rating type="oars-1.1" /> <content_rating type="oars-1.1" />
<releases> <releases>
<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>
</description>
</release>
<release version="0.27.0" date="2026-10-18">
<description>
<p>Pictures keep their proportions when resized. A lock button in the selection tool switches between proportional and free resizing; pictures start locked, other objects free.</p>
</description>
</release>
<release version="0.26.2" date="2026-10-13"> <release version="0.26.2" date="2026-10-13">
<description> <description>
<p>Smoother scrolling through large imported PDFs: pages no longer stall the display while they are prepared, and pages scrolled past quickly are skipped.</p> <p>Smoother scrolling through large imported PDFs: pages no longer stall the display while they are prepared, and pages scrolled past quickly are skipped.</p>
+25
View File
@@ -84,6 +84,31 @@
</branding> </branding>
<content_rating type="oars-1.1" /> <content_rating type="oars-1.1" />
<releases> <releases>
<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>
</description>
</release>
<release version="0.27.0" date="2026-10-18">
<description>
<p>Pictures keep their proportions when resized. A lock button in the selection tool switches between proportional and free resizing; pictures start locked, other objects free.</p>
</description>
</release>
<release version="0.26.2" date="2026-10-13"> <release version="0.26.2" date="2026-10-13">
<description> <description>
<p>Smoother scrolling through large imported PDFs: pages no longer stall the display while they are prepared, and pages scrolled past quickly are skipped.</p> <p>Smoother scrolling through large imported PDFs: pages no longer stall the display while they are prepared, and pages scrolled past quickly are skipped.</p>
+5
View File
@@ -3,6 +3,11 @@
Notizbuch-App für Stift, Touch und Maus unter Linux (getestet mit Zorin OS 18 / Ubuntu 24.04). Notizbuch-App für Stift, Touch und Maus unter Linux (getestet mit Zorin OS 18 / Ubuntu 24.04).
### Highlights: ### Highlights:
- **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 - **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
- **Screenshots für Flathub**: Die App-Beschreibung (Metainfo) zeigt jetzt drei Screenshots; an der App selbst ändert sich nichts - **Screenshots für Flathub**: Die App-Beschreibung (Metainfo) zeigt jetzt drei Screenshots; an der App selbst ändert sich nichts
- **Auswählen mit dem Finger**: Im Auswahlwerkzeug wählt und verschiebt ein Finger Objekte (auf leerer Fläche scrollt er weiter); Text und Platz schaffen gehen ebenfalls mit dem Finger - **Auswählen mit dem Finger**: Im Auswahlwerkzeug wählt und verschiebt ein Finger Objekte (auf leerer Fläche scrollt er weiter); Text und Platz schaffen gehen ebenfalls mit dem Finger
+14 -8
View File
@@ -710,14 +710,20 @@ impl CanvasState {
} }
fn recalculate_viewport(&mut self) { fn recalculate_viewport(&mut self) {
self.viewport = PageViewport::calculate_for( let insets = (self.ui_inset_left, self.ui_inset_right);
self.size.width as f32, let (width, height) = (self.size.width as f32, self.size.height as f32);
self.size.height as f32, self.viewport = if self.page_size == crate::document::ENDLESS_PAGE_SIZE {
Self::VIEWPORT_MARGIN, PageViewport::calculate_endless(width, height, Self::VIEWPORT_MARGIN, insets)
(self.ui_inset_left, self.ui_inset_right), } else {
self.fit_box, PageViewport::calculate_for(
self.page_size, width,
); height,
Self::VIEWPORT_MARGIN,
insets,
self.fit_box,
self.page_size,
)
};
self.update_uniforms(); self.update_uniforms();
} }
+322 -36
View File
@@ -1,5 +1,6 @@
//! Notizbuch- und Seitenverwaltung mit DIN-A4-Koordinatensystem und Vektor-Stroke-Modell. //! Notizbuch- und Seitenverwaltung mit DIN-A4-Koordinatensystem und Vektor-Stroke-Modell.
use std::collections::{HashMap, HashSet};
use std::ops::Range; use std::ops::Range;
use crate::canvas::InkVertex; 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 A4_SIZE: glam::Vec2 = glam::Vec2::new(PAGE_WIDTH, PAGE_HEIGHT);
pub const UNITS_PER_MM: f32 = 10.0; 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. /// Ein gefilterter Stylus-Abtastpunkt in DIN-A4-Seitenkoordinaten.
#[derive(Debug, Clone, Copy, PartialEq)] #[derive(Debug, Clone, Copy, PartialEq)]
pub struct StylusSample { 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 /// 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 /// 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 /// Breite und 60 % der Höhe von `reference` (meist die Seitengröße, auf der endlosen Seite
/// ganz auf dem Blatt. /// DIN A4), und möglichst ganz auf dem Blatt der Größe `page_size`.
pub fn place_image( pub fn place_image(
width: u32, width: u32,
height: u32, height: u32,
center: glam::Vec2, center: glam::Vec2,
page_size: glam::Vec2, page_size: glam::Vec2,
reference: glam::Vec2,
) -> [glam::Vec2; 4] { ) -> [glam::Vec2; 4] {
let natural = let natural =
glam::Vec2::new(width.max(1) as f32, height.max(1) as f32) * IMAGE_UNITS_PER_PIXEL; 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) let fit = (reference.x * 0.8 / natural.x)
.min(page_size.y * 0.6 / natural.y) .min(reference.y * 0.6 / natural.y)
.min(1.0); .min(1.0);
let size = natural * fit; let size = natural * fit;
let half = size / 2.0; let half = size / 2.0;
@@ -597,18 +614,24 @@ impl Stroke {
/// Berechnet die Bounding Box aus den aktuellen Punkten/Vertizes neu. /// Berechnet die Bounding Box aus den aktuellen Punkten/Vertizes neu.
pub fn recompute_aabb(&mut self) { 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() { self.aabb = if !self.points.is_empty() {
let pts: Vec<glam::Vec2> = self.points.iter().map(|s| s.position).collect(); bounds(Box::new(self.points.iter().map(|s| s.position)))
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)
} else { } 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 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 { pub fn is_readonly(&self) -> bool {
self.persist == PersistState::ReadOnlyAfterLoadError self.persist == PersistState::ReadOnlyAfterLoadError
} }
@@ -1000,6 +1073,7 @@ impl Page {
/// Liefert Klone der Striche mit den angegebenen IDs. /// Liefert Klone der Striche mit den angegebenen IDs.
pub fn get_strokes(&self, ids: &[u64]) -> Vec<Stroke> { pub fn get_strokes(&self, ids: &[u64]) -> Vec<Stroke> {
let ids: HashSet<u64> = ids.iter().copied().collect();
self.strokes self.strokes
.iter() .iter()
.filter(|s| ids.contains(&s.id)) .filter(|s| ids.contains(&s.id))
@@ -1007,24 +1081,31 @@ impl Page {
.collect() .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. /// Aktualisiert eine Menge geänderter Striche transaktional im History-Stack.
pub fn modify_strokes(&mut self, modified: Vec<Stroke>) { pub fn modify_strokes(&mut self, modified: Vec<Stroke>) {
if modified.is_empty() { if modified.is_empty() {
return; return;
} }
let ids: Vec<u64> = modified.iter().map(|s| s.id).collect(); let ids: Vec<u64> = modified.iter().map(|s| s.id).collect();
let before: Vec<Stroke> = self let before = self.get_strokes(&ids);
.strokes self.replace_strokes(&modified);
.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();
}
}
self.history.push(HistoryAction::ModifyStrokes { self.history.push(HistoryAction::ModifyStrokes {
before, before,
@@ -1035,9 +1116,33 @@ impl Page {
/// Aktualisiert Striche flüchtig (z. B. während des Ziehens / Dragging), OHNE die History zu verändern. /// 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]) { pub fn update_strokes_ephemeral(&mut self, modified: &[Stroke]) {
for m in modified { self.replace_strokes(modified);
if let Some(pos) = self.strokes.iter().position(|s| s.id == m.id) { self.is_dirty = true;
self.strokes[pos] = m.clone(); }
/// 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; self.is_dirty = true;
@@ -1048,11 +1153,7 @@ impl Page {
if before.is_empty() || after.is_empty() { if before.is_empty() || after.is_empty() {
return; return;
} }
for m in &after { self.replace_strokes(&after);
if let Some(pos) = self.strokes.iter().position(|s| s.id == m.id) {
self.strokes[pos] = m.clone();
}
}
self.history self.history
.push(HistoryAction::ModifyStrokes { before, after }); .push(HistoryAction::ModifyStrokes { before, after });
self.is_dirty = true; 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 /// Hängt eine neue Seite mit dem angegebenen Hintergrund und der Größe der aktuellen
/// Seite an. /// Seite an.
pub fn add_page(&mut self, background: PageBackground) -> usize { pub fn add_page(&mut self, background: PageBackground) -> usize {
@@ -1401,6 +1557,16 @@ impl Notebook {
if dy.abs() < 1.0 { if dy.abs() < 1.0 {
return None; 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) // Seiten als durchgehende Spalte: Lage jeder Seite (auch noch anzulegender)
let last_size = self.pages[run.end - 1].size; 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. /// Transformiert einen Fenster-Pixelpunkt in Seitenkoordinaten.
/// ///
/// Liefert `None`, falls der Punkt außerhalb der Seite liegt. /// Liefert `None`, falls der Punkt außerhalb der Seite liegt.
@@ -2046,13 +2238,13 @@ mod tests {
fn test_place_image() { fn test_place_image() {
let center = glam::Vec2::new(PAGE_WIDTH / 2.0, PAGE_HEIGHT / 2.0); let center = glam::Vec2::new(PAGE_WIDTH / 2.0, PAGE_HEIGHT / 2.0);
// Kleines Bild: Bildschirmgröße, zentriert // 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!(((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!(((bl - tl).length() - 100.0 * IMAGE_UNITS_PER_PIXEL).abs() < 1e-3);
assert!(((tl + br) / 2.0 - center).length() < 1e-3); assert!(((tl + br) / 2.0 - center).length() < 1e-3);
// Großer Screenshot: auf 80 % der Breite verkleinert, Seitenverhältnis bleibt // 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; let size = br - tl;
assert!((size.x - PAGE_WIDTH * 0.8).abs() < 1e-2); assert!((size.x - PAGE_WIDTH * 0.8).abs() < 1e-2);
assert!((size.x / size.y - 3840.0 / 2160.0).abs() < 1e-3); assert!((size.x / size.y - 3840.0 / 2160.0).abs() < 1e-3);
@@ -2064,6 +2256,7 @@ mod tests {
400, 400,
glam::Vec2::new(-50.0, PAGE_HEIGHT + 50.0), glam::Vec2::new(-50.0, PAGE_HEIGHT + 50.0),
A4_SIZE, A4_SIZE,
A4_SIZE,
); );
for c in corners { for c in corners {
assert!(c.x >= -1e-3 && c.x <= PAGE_WIDTH + 1e-3); assert!(c.x >= -1e-3 && c.x <= PAGE_WIDTH + 1e-3);
@@ -2515,6 +2708,13 @@ mod tests {
// History should not have been updated yet // History should not have been updated yet
assert!(!page.history.can_redo()); 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) // Transactional commit (mouse release)
let before = vec![s.clone()]; let before = vec![s.clone()];
let after = vec![moved.clone()]; let after = vec![moved.clone()];
@@ -2534,4 +2734,90 @@ mod tests {
glam::Vec2::new(30.0, 40.0) glam::Vec2::new(30.0, 40.0)
); );
} }
fn line(id: u64, from: glam::Vec2, to: glam::Vec2) -> Stroke {
let sample = |position, timestamp| StylusSample {
position,
pressure: 0.5,
timestamp,
};
Stroke::new(
id,
vec![sample(from, 0.0), sample(to, 0.01)],
Vec::new(),
[0.0, 0.0, 0.0, 1.0],
)
}
#[test]
fn test_endless_notebook_round_trip() {
let mut nb = Notebook::new();
nb.pages[0].add_stroke(line(
1,
glam::Vec2::new(100.0, 100.0),
glam::Vec2::new(500.0, 300.0),
));
assert!(!nb.is_endless());
assert!(nb.make_endless());
assert!(nb.is_endless());
// Inhalt liegt im Startausschnitt, der Verlauf ist leer
let p = nb.pages[0].strokes[0].points[0].position;
assert!((p - (ENDLESS_HOME + glam::Vec2::new(100.0, 100.0))).length() < 1e-3);
assert!(!nb.pages[0].history.can_undo());
assert!(!nb.make_endless(), "schon endlos");
// Zurück zu Seiten: Inhalt wieder an der alten Stelle
assert!(nb.make_paged());
assert_eq!(nb.pages[0].size, A4_SIZE);
let p = nb.pages[0].strokes[0].points[0].position;
assert!((p - glam::Vec2::new(100.0, 100.0)).length() < 1e-2);
// Inhalt außerhalb des Startausschnitts: kein Zurück
assert!(nb.make_endless());
nb.pages[0].add_stroke(line(2, ENDLESS_HOME - 500.0, ENDLESS_HOME - 400.0));
assert!(!nb.can_make_paged());
assert!(!nb.make_paged());
assert!(nb.is_endless());
// Mehrere Seiten oder PDF-Seite: nicht endlos
let mut two = Notebook::new();
two.add_page(PageBackground::Blank);
assert!(!two.make_endless());
}
#[test]
fn test_endless_viewport_shows_home_like_a4() {
let a4 = PageViewport::calculate_for(1600.0, 900.0, 20.0, (0.0, 0.0), A4_SIZE, A4_SIZE);
let endless = PageViewport::calculate_endless(1600.0, 900.0, 20.0, (0.0, 0.0));
assert_eq!(endless.page_size, ENDLESS_PAGE_SIZE);
assert!((endless.scale - a4.scale).abs() < 1e-6);
// Die Ecke des Startausschnitts liegt dort, wo sonst die A4-Seite beginnt
let home = endless
.screen_to_page(a4.page_x + 1.0, a4.page_y + 1.0)
.unwrap();
assert!((home - ENDLESS_HOME - glam::Vec2::splat(1.0 / a4.scale)).length() < 0.05);
// Weit neben dem Startausschnitt lässt sich weiter schreiben
assert!(endless.screen_to_page(-5000.0, -5000.0).is_some());
}
#[test]
fn test_endless_export_bounds_follow_content() {
let mut page = Page::new(1, PageBackground::Grid5mm).with_size(ENDLESS_PAGE_SIZE);
// Leer: der Startausschnitt
assert_eq!(page.endless_export_bounds(), (ENDLESS_HOME, A4_SIZE));
let from = glam::Vec2::new(30_123.0, 40_456.0);
let to = glam::Vec2::new(36_000.0, 41_000.0);
page.add_stroke(line(1, from, to));
let (origin, size) = page.endless_export_bounds();
// Ecke auf dem Musterraster, Inhalt mit Rand vollständig enthalten
assert_eq!(origin % ENDLESS_EXPORT_SNAP, glam::Vec2::ZERO);
assert!(origin.cmple(from - ENDLESS_EXPORT_MARGIN).all());
assert!((origin + size).cmpge(to + ENDLESS_EXPORT_MARGIN).all());
assert!(size.x < 7_000.0 && size.y < 2_000.0, "{size:?}");
let cropped = page.cropped(origin, size);
assert_eq!(cropped.size, size);
assert!((cropped.strokes[0].points[0].position - (from - origin)).length() < 1e-2);
}
} }
+65
View File
@@ -191,6 +191,28 @@ pub fn export_notebook_to_bytes(
assets: Option<&AssetStore>, assets: Option<&AssetStore>,
pattern: PatternColor, pattern: PatternColor,
) -> Result<Vec<u8>, Box<dyn Error>> { ) -> 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 doc = generate_pdf_document(notebook, strategy, assets, pattern)?;
let bytes = add_alpha_states(fix_text_encoding(doc.save_to_bytes()?, notebook)?, notebook)?; let bytes = add_alpha_states(fix_text_encoding(doc.save_to_bytes()?, notebook)?, notebook)?;
@@ -1772,6 +1794,49 @@ mod tests {
.is_ok()); .is_ok());
} }
#[test]
fn test_endless_page_exports_only_the_written_area() {
let mut notebook = Notebook::new();
assert!(notebook.make_endless());
let sample = |x: f32, y: f32| StylusSample {
position: glam::Vec2::new(x, y),
pressure: 0.5,
timestamp: 0.0,
};
let page = &mut notebook.pages[0];
page.add_stroke(Stroke::new(
1,
vec![sample(20_000.0, 30_000.0), sample(23_000.0, 31_000.0)],
vec![
InkVertex::new(20_000.0, 30_000.0, [0.0, 0.0, 0.0, 1.0]),
InkVertex::new(23_000.0, 31_000.0, [0.0, 0.0, 0.0, 1.0]),
InkVertex::new(23_000.0, 31_010.0, [0.0, 0.0, 0.0, 1.0]),
],
[0.0, 0.0, 0.0, 1.0],
));
let (_, size) = notebook.pages[0].endless_export_bounds();
let bytes = export_notebook_to_bytes(
&notebook,
ExportStrategy::FilledPolygon,
None,
PatternColor::default(),
)
.unwrap();
let sizes = crate::pdf_doc::page_sizes(bytes).unwrap();
assert_eq!(sizes.len(), 1);
// Seiteneinheiten (10 = 1 mm) in Punkt
let pt = |units: f32| units / 10.0 / 25.4 * 72.0;
assert!(
(sizes[0].0 - pt(size.x)).abs() < 1.0,
"{sizes:?} statt {size:?}"
);
assert!(
(sizes[0].1 - pt(size.y)).abs() < 1.0,
"{sizes:?} statt {size:?}"
);
assert!(sizes[0].0 < pt(4_000.0), "nicht die ganze endlose Seite");
}
#[test] #[test]
fn test_page_titles_become_pdf_bookmarks() { fn test_page_titles_become_pdf_bookmarks() {
let mut notebook = Notebook::new(); let mut notebook = Notebook::new();
+12 -3
View File
@@ -20,8 +20,6 @@ pub const EN: &[(&str, &str)] = &[
("Alle", "All"), ("Alle", "All"),
("Alles gespeichert", "All saved"), ("Alles gespeichert", "All saved"),
("Als leere Seite bearbeiten (Schreibschutz aufheben)", "Edit as blank page (remove write protection)"), ("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)"), ("AppImage (Portabel)", "AppImage (portable)"),
("Asset-Name darf nicht leer sein.", "Asset name must not be empty."), ("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"), ("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"), ("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 Ordner kann nicht in sich selbst verschoben werden", "A folder cannot be moved into itself"),
("Ein-/Ausgabefehler: {e}", "I/O error: {e}"), ("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)"), ("Einfügen (Strg+V)", "Paste (Ctrl+V)"),
("EINGABE", "INPUT"), ("EINGABE", "INPUT"),
("Einstellung nicht gespeichert: {err}", "Setting not saved: {err}"), ("Einstellung nicht gespeichert: {err}", "Setting not saved: {err}"),
("Ellipse", "Ellipse"), ("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"), ("Entwicklung / Lokal", "Development / local"),
("Export fehlgeschlagen: {err}", "Export failed: {err}"), ("Export fehlgeschlagen: {err}", "Export failed: {err}"),
("Farbe {number} – erneut tippen, um sie anzupassen", "Color {number} – tap again to adjust it"), ("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"), ("Schwarz", "Black"),
("Sechseck (Hexagon)", "Hexagon"), ("Sechseck (Hexagon)", "Hexagon"),
("Seite als", "Page as"), ("Seite als", "Page as"),
("Seite für Seite blättern", "Flip page by page"),
("Seite ist schreibgeschützt", "Page is read-only"), ("Seite ist schreibgeschützt", "Page is read-only"),
("Seite konnte nicht geladen werden", "Page could not be loaded"), ("Seite konnte nicht geladen werden", "Page could not be loaded"),
("Seite wird exportiert …", "Exporting page …"), ("Seite wird exportiert …", "Exporting page …"),
@@ -285,7 +286,11 @@ pub const EN: &[(&str, &str)] = &[
("Seite {number} – {title}", "Page {number} – {title}"), ("Seite {number} – {title}", "Page {number} – {title}"),
("Seite {number}", "Page {number}"), ("Seite {number}", "Page {number}"),
("Seiten ({count})", "Pages ({count})"), ("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}"), ("Serialisierungsfehler: {e}", "Serialization error: {e}"),
("Server-Einstellungen gespeichert", "Server settings saved"), ("Server-Einstellungen gespeichert", "Server settings saved"),
("Server:", "Server:"), ("Server:", "Server:"),
@@ -373,6 +378,7 @@ pub const EN: &[(&str, &str)] = &[
("ZOOM", "ZOOM"), ("ZOOM", "ZOOM"),
("Zu viele Weiterleitungen (Redirect-Schleife abgebrochen).", "Too many redirects (redirect loop stopped)."), ("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."), ("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)"), ("Zurücksetzen (Strg+0)", "Reset (Ctrl+0)"),
("{count} Objekt(e) eingefügt", "{count} object(s) pasted"), ("{count} Objekt(e) eingefügt", "{count} object(s) pasted"),
("{count} Objekt(e) gelöscht", "{count} object(s) deleted"), ("{count} Objekt(e) gelöscht", "{count} object(s) deleted"),
@@ -395,6 +401,7 @@ pub const EN: &[(&str, &str)] = &[
("↩ Rückgängig", "↩ Undo"), ("↩ Rückgängig", "↩ Undo"),
("⏳ Update lädt … {percent} %", "⏳ Downloading update … {percent} %"), ("⏳ Update lädt … {percent} %", "⏳ Downloading update … {percent} %"),
("⏷ {mm} mm Platz", "⏷ {mm} mm space"), ("⏷ {mm} mm Platz", "⏷ {mm} mm space"),
("♾ Endlos", "♾ Endless"),
("⚙ Erweiterte Server-Einstellungen", "⚙ Advanced server settings"), ("⚙ Erweiterte Server-Einstellungen", "⚙ Advanced server settings"),
("⚠ Beschädigt", "⚠ Damaged"), ("⚠ Beschädigt", "⚠ Damaged"),
("⚠ Datenintegritäts-Warnung", "⚠ Data integrity warning"), ("⚠ Datenintegritäts-Warnung", "⚠ Data integrity warning"),
@@ -416,12 +423,14 @@ pub const EN: &[(&str, &str)] = &[
("📂 Ordner öffnen", "📂 Open folder"), ("📂 Ordner öffnen", "📂 Open folder"),
("📄 Als PDF exportieren (Strg+E)", "📄 Export as PDF (Ctrl+E)"), ("📄 Als PDF exportieren (Strg+E)", "📄 Export as PDF (Ctrl+E)"),
("📄 PDF importieren …", "📄 Import PDF …"), ("📄 PDF importieren …", "📄 Import PDF …"),
("📄 Feste Seiten", "📄 Fixed pages"),
("📄 PDF importieren", "📄 Import PDF"), ("📄 PDF importieren", "📄 Import PDF"),
("📋 Einfügen", "📋 Paste"), ("📋 Einfügen", "📋 Paste"),
("📑 Inhalt", "📑 Contents"), ("📑 Inhalt", "📑 Contents"),
("📚 Alle", "📚 All"), ("📚 Alle", "📚 All"),
("📚 Notizbücher", "📚 Notebooks"), ("📚 Notizbücher", "📚 Notebooks"),
("📚 Oberste Ebene", "📚 Top level"), ("📚 Oberste Ebene", "📚 Top level"),
("📜 Durchgehend", "📜 Continuous"),
("📦 Paket mit Software-Center öffnen / installieren", "📦 Open / install package with the software center"), ("📦 Paket mit Software-Center öffnen / installieren", "📦 Open / install package with the software center"),
("🔄 Suche nach Updates …", "🔄 Checking for updates …"), ("🔄 Suche nach Updates …", "🔄 Checking for updates …"),
("🔄 Updates & Versionen", "🔄 Updates & versions"), ("🔄 Updates & Versionen", "🔄 Updates & versions"),
+116 -59
View File
@@ -31,12 +31,12 @@ use ink_notes::osk::{self, KeyboardRequester};
use ink_notes::pdf_doc::PdfRenderService; use ink_notes::pdf_doc::PdfRenderService;
use ink_notes::platform::{self, DisplayBackend}; use ink_notes::platform::{self, DisplayBackend};
use ink_notes::selection::{ use ink_notes::selection::{
render_selection_overlay, DragMode, SelectionHandleHit, SelectionState, render_selection_overlay, scale_factors, DragMode, SelectionHandleHit, SelectionState,
}; };
use ink_notes::settings::{ use ink_notes::settings::{
SidebarSide, StylusAction, StylusButton, UiSettings, MAX_STYLUS_BUTTONS, 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::t;
use ink_notes::ui::{self, ActiveTool, UiAction, UiState}; use ink_notes::ui::{self, ActiveTool, UiAction, UiState};
use ink_notes::updater::UpdaterController; use ink_notes::updater::UpdaterController;
@@ -577,6 +577,7 @@ fn insert_image_on_page(
imported.height, imported.height,
center, center,
notebook.current_page().size, notebook.current_page().size,
notebook.current_page().reference_size(),
); );
let page = notebook.current_page_mut(); let page = notebook.current_page_mut();
page.add_stroke(ink_notes::document::Stroke::new_image( page.add_stroke(ink_notes::document::Stroke::new_image(
@@ -649,12 +650,18 @@ fn selection_press(
.. ..
}, },
_, _,
) => DragMode::Scale { ) => {
fixed_corner_page, let initial_strokes = page.get_strokes(&selection_state.selected_ids);
start_pos_page, // Schloss der Auswahl (Bilder: an, Issue #29); Shift sperrt zusätzlich
initial_box_size, let keep_aspect = shift || selection_state.keeps_aspect(page);
initial_strokes: page.get_strokes(&selection_state.selected_ids), DragMode::Scale {
}, fixed_corner_page,
start_pos_page,
initial_box_size,
initial_strokes,
keep_aspect,
}
}
(SelectionHandleHit::Body, Some(p)) => DragMode::Move { (SelectionHandleHit::Body, Some(p)) => DragMode::Move {
start_page: p, start_page: p,
initial_strokes: page.get_strokes(&selection_state.selected_ids), initial_strokes: page.get_strokes(&selection_state.selected_ids),
@@ -684,70 +691,65 @@ fn selection_press(
_ => return false, _ => return false,
}; };
selection_state.drag_mode = mode; selection_state.drag_mode = mode;
selection_state.pending_drag = None;
true 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, /// 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( fn selection_drag(
page_pt: Vec2, page_pt: Vec2,
page: &mut ink_notes::document::Page, page: &mut ink_notes::document::Page,
canvas: &mut CanvasState, canvas: &mut CanvasState,
selection_state: &mut SelectionState, selection_state: &mut SelectionState,
) { ) {
let mut updated = match &mut selection_state.drag_mode { match &mut selection_state.drag_mode {
DragMode::BoxSelect { current_page, .. } => { DragMode::BoxSelect { current_page, .. } => {
*current_page = page_pt; *current_page = page_pt;
return; return;
} }
DragMode::None => return, DragMode::None => return,
DragMode::Move { DragMode::Move {
initial_strokes, .. start_page,
} initial_strokes,
| DragMode::Rotate { } => {
initial_strokes, ..
}
| DragMode::Scale {
initial_strokes, ..
} => initial_strokes.clone(),
};
match &selection_state.drag_mode {
DragMode::Move { start_page, .. } => {
let delta = page_pt - *start_page; 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 { DragMode::Rotate {
center_page, center_page,
start_angle, start_angle,
.. initial_strokes,
} => { } => {
let angle = (page_pt.y - center_page.y).atan2(page_pt.x - center_page.x) - start_angle; let angle = (page_pt.y - center_page.y).atan2(page_pt.x - center_page.x) - *start_angle;
updated let center = *center_page;
.iter_mut() page.transform_strokes_ephemeral(initial_strokes, |s| s.rotate(center, angle));
.for_each(|s| s.rotate(*center_page, angle));
} }
DragMode::Scale { DragMode::Scale {
fixed_corner_page, fixed_corner_page,
start_pos_page, start_pos_page,
keep_aspect,
initial_strokes,
.. ..
} => { } => {
let orig = *start_pos_page - *fixed_corner_page; let fixed = *fixed_corner_page;
let new = page_pt - *fixed_corner_page; let factors = scale_factors(fixed, *start_pos_page, page_pt, *keep_aspect);
let factor = |n: f32, o: f32| { page.transform_strokes_ephemeral(initial_strokes, |s| s.scale(fixed, factors));
if o.abs() > 1.0 {
(n / o).clamp(0.05, 20.0)
} else {
1.0
}
};
let factors = Vec2::new(factor(new.x, orig.x), factor(new.y, orig.y));
updated
.iter_mut()
.for_each(|s| s.scale(*fixed_corner_page, factors));
} }
DragMode::BoxSelect { .. } | DragMode::None => return,
} }
page.update_strokes_ephemeral(&updated);
canvas.sync_page(page); canvas.sync_page(page);
} }
@@ -760,7 +762,10 @@ fn selection_release(
manifest: &mut NotebookManifest, manifest: &mut NotebookManifest,
autosave: &mut AutosaveController, autosave: &mut AutosaveController,
selection_state: &mut SelectionState, selection_state: &mut SelectionState,
canvas: &mut CanvasState,
) -> bool { ) -> 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) { match std::mem::take(&mut selection_state.drag_mode) {
DragMode::BoxSelect { DragMode::BoxSelect {
start_page, start_page,
@@ -830,7 +835,7 @@ fn insert_text_on_page(
let mut edit = ink_notes::text::TextEditing::new_at( let mut edit = ink_notes::text::TextEditing::new_at(
notebook.current_page().id, notebook.current_page().id,
position, 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_size_pt * ink_notes::text::UNITS_PER_PT,
ui_state.text_font, ui_state.text_font,
ui_state.text_color, ui_state.text_color,
@@ -924,7 +929,7 @@ fn text_tool_click(
None => Some(ink_notes::text::TextEditing::new_at( None => Some(ink_notes::text::TextEditing::new_at(
page.id, page.id,
page_pt, 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_size_pt * ink_notes::text::UNITS_PER_PT,
ui_state.text_font, ui_state.text_font,
ui_state.text_color, ui_state.text_color,
@@ -1524,6 +1529,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
event_loop.run(move |event, target| { 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 { match event {
Event::AboutToWait => { Event::AboutToWait => {
let prev_status = autosave.status(); let prev_status = autosave.status();
@@ -1609,7 +1624,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
if is_stylus_selecting { if is_stylus_selecting {
is_stylus_selecting = false; 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(); window.request_redraw();
} }
if let Some(tool) = tool_before_button.take() { if let Some(tool) = tool_before_button.take() {
@@ -1705,7 +1720,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
} else if is_stylus_selecting { } else if is_stylus_selecting {
if let Some(page_pt) = canvas.screen_to_page(sx, sy) { 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(); window.request_redraw();
} }
} else if is_stylus_drawing && is_drawing { } else if is_stylus_drawing && is_drawing {
@@ -1849,7 +1864,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if is_stylus_selecting { if is_stylus_selecting {
is_stylus_selecting = false; 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(); window.request_redraw();
} }
} }
@@ -2617,7 +2632,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
if ui_state.active_tool == ActiveTool::Select if ui_state.active_tool == ActiveTool::Select
&& !is_stylus_selecting && !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(); window.request_redraw();
} }
@@ -2673,7 +2688,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
window.request_redraw(); window.request_redraw();
} else if ui_state.active_tool == ActiveTool::Select && selection_state.drag_mode != DragMode::None { } 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) { 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(); window.request_redraw();
} }
} else if is_erasing { } else if is_erasing {
@@ -2709,9 +2724,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if is_pointer_in_ui { if is_pointer_in_ui {
return; return;
} }
// Durchgehende Ansicht: Mausrad scrollt (mit Umschalt waagerecht), // Durchgehende Ansicht und endlose Fläche: Mausrad scrollt (mit Umschalt
// Strg + Mausrad zoomt // waagerecht), Strg + Mausrad zoomt
if ui_state.continuous_scroll && !modifiers.control_key() { if (ui_state.continuous_scroll || ui_state.endless) && !modifiers.control_key() {
let (mut dx, mut dy) = match delta { let (mut dx, mut dy) = match delta {
MouseScrollDelta::LineDelta(x, y) => { MouseScrollDelta::LineDelta(x, y) => {
let line = 60.0 * window.scale_factor() as f32; let line = 60.0 * window.scale_factor() as f32;
@@ -2974,7 +2989,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
} else if ui_state.active_tool == ActiveTool::Select && selection_state.drag_mode != DragMode::None { } 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) { 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(); window.request_redraw();
} }
} }
@@ -3001,7 +3016,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
if ui_state.active_tool == ActiveTool::Select 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(); window.request_redraw();
} }
@@ -3044,6 +3059,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
WindowEvent::RedrawRequested => { 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 // 0. Durchgehende Ansicht: Nach dem Scrollen oder Zoomen wird die Seite in
// der Fenstermitte zur aktuellen (nicht während eines Strichs, einer // der Fenstermitte zur aktuellen (nicht während eines Strichs, einer
// Auswahl oder beim Ziehen des Lineals). Wer auf einer Nachbarseite // Auswahl oder beim Ziehen des Lineals). Wer auf einer Nachbarseite
@@ -3103,6 +3119,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// 3. Toolbar & Widgets rendern // 3. Toolbar & Widgets rendern
updater.update(); updater.update();
ui_state.page_view = Some(page_view(&canvas, window.scale_factor() as f32)); ui_state.page_view = Some(page_view(&canvas, window.scale_factor() as f32));
ui_state.selection_aspect_lock = (!selection_state.is_empty())
.then(|| selection_state.keeps_aspect(notebook.current_page()));
ui_state.restorable_page = last_deleted_page ui_state.restorable_page = last_deleted_page
.as_ref() .as_ref()
.filter(|(id, _, _)| *id == manifest.id) .filter(|(id, _, _)| *id == manifest.id)
@@ -3203,6 +3221,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw(); window.request_redraw();
} }
UiAction::ToggleAspectLock => {
if !selection_state.is_empty() {
selection_state.toggle_aspect_lock(notebook.current_page());
window.request_redraw();
}
}
UiAction::RotateSelected => { UiAction::RotateSelected => {
if !selection_state.is_empty() if !selection_state.is_empty()
&& selection_state.rotate_selected_90_deg(notebook.current_page_mut()) { && selection_state.rotate_selected_90_deg(notebook.current_page_mut()) {
@@ -3292,6 +3316,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
} }
} }
// Die endlose Fläche bleibt eine einzige Seite
UiAction::AddPage if notebook.is_endless() => {}
UiAction::AddPage => { UiAction::AddPage => {
// Leere Seite direkt nach der aktuellen (in PDFs: nach der Folie, // Leere Seite direkt nach der aktuellen (in PDFs: nach der Folie,
// auf der man gerade ist) und dorthin wechseln // auf der man gerade ist) und dorthin wechseln
@@ -3556,11 +3582,15 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
window.request_redraw(); window.request_redraw();
} }
UiAction::CreateNotebook { title, folder } => { UiAction::CreateNotebook { title, folder, layout } => {
let _ = autosave.flush(); let _ = autosave.flush();
match storage::create_new_notebook(&storage_base_dir, &title) { 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(); 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| { change_library(&storage_base_dir, &mut ui_state, |library| {
library.move_notebook(&id, &folder) library.move_notebook(&id, &folder)
}); });
@@ -3570,6 +3600,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
notebook = new_nb; notebook = new_nb;
manifest = new_manifest; manifest = new_manifest;
storage_dir = new_dir; storage_dir = new_dir;
autosave.mark_dirty(&notebook, &mut manifest);
canvas.reset_view();
selection_state.clear(); selection_state.clear();
ui_state.current_notebook_id = manifest.id.clone(); ui_state.current_notebook_id = manifest.id.clone();
ui_state.document_title = manifest.title.clone(); ui_state.document_title = manifest.title.clone();
@@ -3814,9 +3846,34 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state); save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
window.request_redraw(); window.request_redraw();
} }
UiAction::SetContinuousScroll(on) => { UiAction::SetNotebookLayout(layout) => {
ui_settings.continuous_scroll = on; // Von und zur endlosen Fläche nur mit einer Seite, deren Inhalt passt
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state); let was_endless = notebook.is_endless();
let to_endless = layout == NotebookLayout::Endless;
let converted = match (was_endless, to_endless) {
(false, true) => notebook.make_endless(),
(true, false) => notebook.make_paged(),
_ => true,
};
if !converted {
ui_state.set_export_message(if to_endless {
t!("Endlos geht nur bei einem Notizbuch mit einer einzigen Seite ohne PDF").to_string()
} else {
t!("Zurück zu Seiten geht nur, solange alles im Startausschnitt liegt").to_string()
});
} else {
manifest.layout = Some(layout);
ui_state.endless = to_endless;
ui_state.continuous_scroll = layout == NotebookLayout::Continuous;
if was_endless != to_endless {
selection_state.clear();
canvas.sync_page(notebook.current_page_mut());
canvas.reset_view();
autosave.mark_dirty(&notebook, &mut manifest);
} else {
autosave.mark_manifest_dirty(&manifest);
}
}
window.request_redraw(); window.request_redraw();
} }
UiAction::SaveStylusButtons => { UiAction::SaveStylusButtons => {
+11 -25
View File
@@ -13,7 +13,7 @@ use hayro::{PixmapSettings, RenderCache, RenderSettings};
use crate::assets::{AssetStore, RgbaImage}; use crate::assets::{AssetStore, RgbaImage};
use crate::background::PatternColor; 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::export::{export_notebook_to_bytes, ExportStrategy};
use crate::t; 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 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 bounds: Aabb = bounds.expanded(max_width / 2.0 + 4.0);
let size = bounds.max - bounds.min;
let full = rasterize( if size.min_element() < 1.0 {
single_page_pdf(&selected, assets, PatternColor::default())?, 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, SELECTION_PNG_DPI,
true, true,
)?; )?;
// Seiteneinheiten → Pixel Ok(Some(image))
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,
}))
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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 { fn page_with_line() -> Page {
let mut page = Page::new(1, PageBackground::Grid5mm); let mut page = Page::new(1, PageBackground::Grid5mm);
+130 -2
View File
@@ -36,9 +36,34 @@ pub enum DragMode {
start_pos_page: Vec2, start_pos_page: Vec2,
initial_box_size: Vec2, initial_box_size: Vec2,
initial_strokes: Vec<Stroke>, initial_strokes: Vec<Stroke>,
/// Seitenverhältnis beibehalten (Schloss der Auswahl oder Shift gedrückt)
keep_aspect: bool,
}, },
} }
/// Skalierungsfaktoren, wenn der Eckgriff von `start` (relativ zur festen Ecke `fixed`)
/// nach `current` gezogen wird. Mit `keep_aspect` wird gleichmäßig skaliert: der Faktor ist
/// die Projektion der Zugposition auf die Diagonale, damit das Seitenverhältnis bleibt.
pub fn scale_factors(fixed: Vec2, start: Vec2, current: Vec2, keep_aspect: bool) -> Vec2 {
let orig = start - fixed;
let new = current - fixed;
if keep_aspect {
let len_sq = orig.length_squared();
if len_sq <= 1.0 {
return Vec2::ONE;
}
return Vec2::splat((new.dot(orig) / len_sq).clamp(0.05, 20.0));
}
let factor = |n: f32, o: f32| {
if o.abs() > 1.0 {
(n / o).clamp(0.05, 20.0)
} else {
1.0
}
};
Vec2::new(factor(new.x, orig.x), factor(new.y, orig.y))
}
/// Ergebnis eines Hit-Tests auf Auswahl-Griffe (Handles). /// Ergebnis eines Hit-Tests auf Auswahl-Griffe (Handles).
#[derive(Debug, Clone, Copy, PartialEq)] #[derive(Debug, Clone, Copy, PartialEq)]
pub enum SelectionHandleHit { pub enum SelectionHandleHit {
@@ -63,7 +88,13 @@ pub enum SelectionHandleHit {
pub struct SelectionState { pub struct SelectionState {
pub selected_ids: Vec<u64>, pub selected_ids: Vec<u64>,
pub drag_mode: DragMode, 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>, 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`]).
aspect_lock: Option<(Vec<u64>, bool)>,
} }
impl SelectionState { impl SelectionState {
@@ -71,10 +102,31 @@ impl SelectionState {
Self { Self {
selected_ids: Vec::new(), selected_ids: Vec::new(),
drag_mode: DragMode::None, drag_mode: DragMode::None,
pending_drag: None,
clipboard: Vec::new(), clipboard: Vec::new(),
aspect_lock: None,
} }
} }
/// Ob Skalieren an den Eckgriffen das Seitenverhältnis der Auswahl beibehält. Vorgabe:
/// ja, sobald ein Bild ausgewählt ist (Issue #29); per Knopf für diese Auswahl umschaltbar.
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
.strokes
.iter()
.any(|s| s.is_image() && self.selected_ids.contains(&s.id)),
}
}
/// Schaltet das Seitenverhältnis-Schloss der aktuellen Auswahl um.
pub fn toggle_aspect_lock(&mut self, page: &Page) {
let locked = !self.keeps_aspect(page);
self.aspect_lock = Some((self.selected_ids.clone(), locked));
}
/// Prüft, ob aktuell Objekte ausgewählt sind. /// Prüft, ob aktuell Objekte ausgewählt sind.
pub fn is_empty(&self) -> bool { pub fn is_empty(&self) -> bool {
self.selected_ids.is_empty() self.selected_ids.is_empty()
@@ -112,8 +164,13 @@ impl SelectionState {
/// Berechnet die gemeinsame AABB aller ausgewählten Objekte. /// Berechnet die gemeinsame AABB aller ausgewählten Objekte.
pub fn get_selection_aabb(&self, page: &Page) -> Option<Aabb> { pub fn get_selection_aabb(&self, page: &Page) -> Option<Aabb> {
let strokes = page.get_strokes(&self.selected_ids); // Läuft bei jedem Bild: ohne die ausgewählten Striche zu kopieren
Page::selection_aabb(&strokes) 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 /// Führt Hit-Testing auf die Auswahlgriffe (Drehen, Ecken, Körper) in Bildschirmkoordinaten
@@ -523,6 +580,77 @@ mod tests {
assert!((orig_pos.x - restored_pos.x).abs() < 1e-4); assert!((orig_pos.x - restored_pos.x).abs() < 1e-4);
} }
#[test]
fn test_scale_factors_free_and_keep_aspect() {
let fixed = Vec2::new(0.0, 0.0);
let start = Vec2::new(200.0, 100.0);
// Frei: Breite und Höhe unabhängig
let free = scale_factors(fixed, start, Vec2::new(400.0, 100.0), false);
assert_eq!(free, Vec2::new(2.0, 1.0));
// Seitenverhältnis fest: gleicher Faktor in beiden Richtungen
let locked = scale_factors(fixed, start, Vec2::new(400.0, 100.0), true);
assert!((locked.x - locked.y).abs() < 1e-6);
assert!((locked.x - 1.8).abs() < 1e-4);
// Entlang der Diagonale genau verdoppelt
let diag = scale_factors(fixed, start, Vec2::new(400.0, 200.0), true);
assert!((diag - Vec2::splat(2.0)).length() < 1e-5);
// Über die feste Ecke hinaus: kein Spiegeln
let past = scale_factors(fixed, start, Vec2::new(-50.0, -50.0), true);
assert_eq!(past, Vec2::splat(0.05));
// Senkrechte Linie (Breite 0): frei bleibt x unverändert, fest skaliert beides
let line = Vec2::new(0.0, 100.0);
assert_eq!(
scale_factors(fixed, line, Vec2::new(30.0, 200.0), false),
Vec2::new(1.0, 2.0)
);
assert_eq!(
scale_factors(fixed, line, Vec2::new(30.0, 200.0), true),
Vec2::splat(2.0)
);
}
#[test]
fn test_aspect_lock_defaults_to_images_and_toggles_per_selection() {
let mut page = Page::new(1, PageBackground::Blank);
let corners = [
Vec2::new(0.0, 0.0),
Vec2::new(100.0, 0.0),
Vec2::new(100.0, 50.0),
Vec2::new(0.0, 50.0),
];
page.add_stroke(Stroke::new_image(1, "a.png".into(), corners));
page.add_stroke(make_test_stroke(2, 200.0, 200.0, [0.1, 0.1, 0.1, 1.0]));
let mut sel = SelectionState::new();
// Vorgabe: Bilder gesperrt, Striche frei
sel.select_single(1);
assert!(sel.keeps_aspect(&page));
sel.select_single(2);
assert!(!sel.keeps_aspect(&page));
// Umschalten gilt für diese Auswahl
sel.toggle_aspect_lock(&page);
assert!(sel.keeps_aspect(&page));
sel.select_single(1);
sel.toggle_aspect_lock(&page);
assert!(!sel.keeps_aspect(&page));
sel.toggle_aspect_lock(&page);
assert!(sel.keeps_aspect(&page));
sel.toggle_aspect_lock(&page);
// Neue Auswahl: wieder die Vorgabe
sel.select_multiple(vec![1, 2]);
assert!(sel.keeps_aspect(&page));
sel.select_single(1);
assert!(
!sel.keeps_aspect(&page),
"gleiche Auswahl behält das Umschalten"
);
sel.clear();
sel.select_single(1);
assert!(!sel.keeps_aspect(&page));
}
#[test] #[test]
fn test_recolor_ignores_images() { fn test_recolor_ignores_images() {
let mut page = Page::new(1, PageBackground::Blank); let mut page = Page::new(1, PageBackground::Blank);
+2 -1
View File
@@ -212,7 +212,8 @@ pub struct UiSettings {
pub pattern_color: PatternColor, pub pattern_color: PatternColor,
/// Druckkennlinie des Stifts /// Druckkennlinie des Stifts
pub pressure_curve: PressureCurve, 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, pub continuous_scroll: bool,
/// Stifte der Werkzeugleiste und der gewählte davon /// Stifte der Werkzeugleiste und der gewählte davon
pub pen_presets: [PenPreset; PEN_PRESET_COUNT], pub pen_presets: [PenPreset; PEN_PRESET_COUNT],
+67 -1
View File
@@ -168,7 +168,9 @@ impl StrokeData {
/// - Version 6: Seitentitel (`title` in Seite und Manifest). /// - Version 6: Seitentitel (`title` in Seite und Manifest).
/// - Version 7: Textfelder (`text` am Strich). /// - Version 7: Textfelder (`text` am Strich).
/// - Version 8: Seitengröße je Seite (`size` in Seite und Manifest), Querformat für Folien. /// - 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). /// Standardwert für ältere Dateien ohne explizite Formatversion (Legacy v0).
pub fn legacy_format_version() -> u32 { 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. /// 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 { pub fn page_size_from(stored: Option<[f32; 2]>) -> glam::Vec2 {
match stored { 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 => { 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)) 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 page_ids: Vec<u64>,
pub next_stroke_id: u64, pub next_stroke_id: u64,
pub next_page_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 { 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 { pub fn new(id: impl Into<String>, title: impl Into<String>) -> Self {
let now = std::time::SystemTime::now() let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
@@ -299,6 +330,7 @@ impl NotebookManifest {
page_ids: vec![1], page_ids: vec![1],
next_stroke_id: 1, next_stroke_id: 1,
next_page_id: 2, next_page_id: 2,
layout: Some(NotebookLayout::Paged),
} }
} }
@@ -1604,6 +1636,40 @@ mod tests {
assert!(shutdown_res.is_ok()); 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: feste Seiten, unabhängig von der früheren globalen Einstellung
assert_eq!(manifest.layout, Some(NotebookLayout::Paged));
assert_eq!(manifest.layout_or(true), NotebookLayout::Paged);
manifest.layout = Some(NotebookLayout::Continuous);
save_manifest(&dir, &manifest).unwrap();
let (_nb, loaded) = load_notebook(&dir).unwrap();
assert_eq!(loaded.layout, Some(NotebookLayout::Continuous));
// 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] #[test]
fn test_notebook_management() { fn test_notebook_management() {
let temp_dir = tempfile::tempdir().expect("Konnte temporäres Verzeichnis nicht anlegen"); let temp_dir = tempfile::tempdir().expect("Konnte temporäres Verzeichnis nicht anlegen");
+185 -34
View File
@@ -22,7 +22,7 @@ use crate::settings::{
PenPreset, PressureCurve, SidebarSide, DEFAULT_HIGHLIGHTER_COLORS, DEFAULT_PEN_PRESETS, PenPreset, PressureCurve, SidebarSide, DEFAULT_HIGHLIGHTER_COLORS, DEFAULT_PEN_PRESETS,
DEFAULT_TEXT_COLORS, PEN_PRESET_COUNT, 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::text::{TextEditing, TextFont, TEXT_SIZES_PT};
use crate::{t, tn}; use crate::{t, tn};
@@ -255,12 +255,15 @@ pub enum UiAction {
/// Zwischenablage an dieser Stelle der aktuellen Seite einfügen (Kontextmenü) /// Zwischenablage an dieser Stelle der aktuellen Seite einfügen (Kontextmenü)
PasteAt(glam::Vec2), PasteAt(glam::Vec2),
RotateSelected, RotateSelected,
/// Seitenverhältnis beim Skalieren der Auswahl sperren bzw. freigeben
ToggleAspectLock,
// Dokumenten- & Notizbuch-Verwaltung // Dokumenten- & Notizbuch-Verwaltung
OpenNotebook(String), OpenNotebook(String),
/// Neues Notizbuch im Ordner `folder` anlegen und öffnen /// Neues Notizbuch im Ordner `folder` anlegen und öffnen
CreateNotebook { CreateNotebook {
title: String, title: String,
folder: String, folder: String,
layout: NotebookLayout,
}, },
/// Ordnerverwaltung (siehe `library.rs`) /// Ordnerverwaltung (siehe `library.rs`)
CreateFolder { CreateFolder {
@@ -315,8 +318,8 @@ pub enum UiAction {
SaveStylusButtons, SaveStylusButtons,
/// Druckkurve speichern (gilt während des Ziehens bereits über `UiState::pressure_curve`) /// Druckkurve speichern (gilt während des Ziehens bereits über `UiState::pressure_curve`)
SavePressureCurve(PressureCurve), SavePressureCurve(PressureCurve),
/// Seiten durchgehend untereinander statt einzeln anzeigen (wird gespeichert) /// Anordnung der Seiten des offenen Notizbuchs (wird im Manifest gespeichert)
SetContinuousScroll(bool), SetNotebookLayout(NotebookLayout),
/// Farbe von Karos, Linien und Punkten (wird gespeichert, gilt auch für den Export) /// Farbe von Karos, Linien und Punkten (wird gespeichert, gilt auch für den Export)
SetPatternColor(PatternColor), SetPatternColor(PatternColor),
/// Bilddatei auf der aktuellen Seite einfügen /// Bilddatei auf der aktuellen Seite einfügen
@@ -362,6 +365,8 @@ pub struct UiState {
pub document_title: String, pub document_title: String,
pub available_notebooks: Vec<NotebookSummary>, pub available_notebooks: Vec<NotebookSummary>,
pub new_notebook_title_input: String, 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>, pub renaming_notebook_id: Option<String>,
/// Ordner der Notizbücher und der gerade angezeigte Ordner im Dokumenten-Manager /// Ordner der Notizbücher und der gerade angezeigte Ordner im Dokumenten-Manager
pub library: crate::library::Library, pub library: crate::library::Library,
@@ -410,6 +415,8 @@ pub struct UiState {
pub confirm_delete_page: bool, pub confirm_delete_page: bool,
/// Seitenzahl der zuletzt gelöschten Seite, solange sie wiederhergestellt werden kann /// Seitenzahl der zuletzt gelöschten Seite, solange sie wiederhergestellt werden kann
pub restorable_page: Option<usize>, pub restorable_page: Option<usize>,
/// Seitenverhältnis-Schloss der Auswahl (`None`: nichts ausgewählt)
pub selection_aspect_lock: Option<bool>,
/// Fehler beim Flushen vor dem Beenden /// Fehler beim Flushen vor dem Beenden
pub shutdown_error: Option<crate::autosave::AutosaveError>, pub shutdown_error: Option<crate::autosave::AutosaveError>,
/// Fensterseite der Werkzeugleiste /// Fensterseite der Werkzeugleiste
@@ -428,6 +435,11 @@ pub struct UiState {
pub pattern_color: PatternColor, pub pattern_color: PatternColor,
/// Seiten hängen untereinander und werden durchgehend gescrollt /// Seiten hängen untereinander und werden durchgehend gescrollt
pub continuous_scroll: bool, 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 /// Text-Werkzeug: Farbe, Schriftgröße (Punkt) und Schriftart neuer Textfelder
pub text_color: [f32; 4], pub text_color: [f32; 4],
pub text_size_pt: f32, pub text_size_pt: f32,
@@ -479,6 +491,7 @@ impl Default for UiState {
document_title: t!("Standard-Notizbuch").to_string(), document_title: t!("Standard-Notizbuch").to_string(),
available_notebooks: Vec::new(), available_notebooks: Vec::new(),
new_notebook_title_input: String::new(), new_notebook_title_input: String::new(),
new_notebook_layout: NotebookLayout::Paged,
renaming_notebook_id: None, renaming_notebook_id: None,
library: crate::library::Library::default(), library: crate::library::Library::default(),
notebook_folder: String::new(), notebook_folder: String::new(),
@@ -508,6 +521,7 @@ impl Default for UiState {
pressed_stylus_button: None, pressed_stylus_button: None,
confirm_delete_page: false, confirm_delete_page: false,
restorable_page: None, restorable_page: None,
selection_aspect_lock: None,
shutdown_error: None, shutdown_error: None,
sidebar_side: SidebarSide::Left, sidebar_side: SidebarSide::Left,
language: None, language: None,
@@ -517,6 +531,8 @@ impl Default for UiState {
adapt_ink_colors: true, adapt_ink_colors: true,
pattern_color: PatternColor::Light, pattern_color: PatternColor::Light,
continuous_scroll: true, continuous_scroll: true,
endless: false,
endless_switch_possible: true,
text_color: PEN_COLOR_PRESETS[0].0, text_color: PEN_COLOR_PRESETS[0].0,
text_size_pt: crate::text::DEFAULT_TEXT_SIZE_PT, text_size_pt: crate::text::DEFAULT_TEXT_SIZE_PT,
text_font: TextFont::Sans, text_font: TextFont::Sans,
@@ -1182,8 +1198,8 @@ fn color_editor(
changed changed
} }
/// Stift der Werkzeugleiste: ein Strich in Farbe und (angedeuteter) Stärke auf einem /// Stift der Werkzeugleiste: ein schräg liegender Stift in seiner Farbe; die Stärke steht
/// Stück Papier, damit auch schwarze Tinte auf der dunklen Leiste sichtbar ist. /// im Tooltip.
fn pen_preset_swatch( fn pen_preset_swatch(
ui: &mut egui::Ui, ui: &mut egui::Ui,
preset: PenPreset, preset: PenPreset,
@@ -1193,22 +1209,49 @@ fn pen_preset_swatch(
) -> egui::Response { ) -> egui::Response {
let (rect, response) = ui.allocate_exact_size(egui::vec2(size, size), egui::Sense::click()); let (rect, response) = ui.allocate_exact_size(egui::vec2(size, size), egui::Sense::click());
if ui.is_rect_visible(rect) { if ui.is_rect_visible(rect) {
let paper = rect.shrink(2.0); // Schräg liegender Stift ganz in seiner Farbe, damit die Stifte auf einen Blick zu
ui.painter().rect_filled( // unterscheiden sind (Issue #31); die helle Kontur hält dunkle Farben sichtbar
paper,
egui::Rounding::same(5.0),
egui::Color32::from_gray(245),
);
let [r, g, b, _] = preset.color.map(|c| (c.clamp(0.0, 1.0) * 255.0) as u8); let [r, g, b, _] = preset.color.map(|c| (c.clamp(0.0, 1.0) * 255.0) as u8);
let width = (1.0 + preset.width_mm * 2.5).clamp(1.5, paper.height() * 0.35); let ink = egui::Color32::from_rgb(r, g, b);
let inset = 3.0 + width / 2.0; let c = rect.center();
ui.painter().line_segment( let dir = egui::vec2(1.0, -1.0).normalized();
[ let perp = egui::vec2(dir.y, -dir.x);
egui::pos2(paper.left() + inset, paper.bottom() - inset), let half_len = size * 0.44;
egui::pos2(paper.right() - inset, paper.top() + inset), let half_w = size * 0.17;
let p = |along: f32, across: f32| c + dir * along + perp * across;
let tip_start = -half_len * 0.3;
let lead_start = -half_len * 0.72;
let lead_w = half_w * (lead_start + half_len) / (tip_start + half_len);
let outline = egui::Stroke::new(1.0_f32, egui::Color32::from_gray(200));
let painter = ui.painter();
painter.add(egui::Shape::convex_polygon(
vec![
p(tip_start, -half_w),
p(half_len, -half_w),
p(half_len, half_w),
p(tip_start, half_w),
], ],
egui::Stroke::new(width, egui::Color32::from_rgb(r, g, b)), ink,
); outline,
));
painter.add(egui::Shape::convex_polygon(
vec![
p(-half_len, 0.0),
p(tip_start, -half_w),
p(tip_start, half_w),
],
egui::Color32::from_rgb(235, 210, 170),
outline,
));
painter.add(egui::Shape::convex_polygon(
vec![
p(-half_len, 0.0),
p(lead_start, -lead_w),
p(lead_start, lead_w),
],
ink,
egui::Stroke::NONE,
));
if is_selected { if is_selected {
ui.painter().rect_stroke( ui.painter().rect_stroke(
rect.shrink(0.5), rect.shrink(0.5),
@@ -1609,6 +1652,54 @@ fn page_title_list(ui: &mut egui::Ui, notebook: &Notebook) -> Option<usize> {
chosen chosen
} }
/// Auswahl der Seitenanordnung eines Notizbuchs. Ohne `can_cross` lässt sich nicht zwischen
/// Seiten und endloser Fläche wechseln. `true`, wenn sie geändert wurde.
fn layout_picker(ui: &mut egui::Ui, layout: &mut NotebookLayout, can_cross: bool) -> bool {
let mut changed = false;
let endless_now = *layout == NotebookLayout::Endless;
ui.horizontal_wrapped(|ui| {
for (option, label, tooltip) in [
(
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."
),
),
] {
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. /// Auswahlknopf für eine Papierfarbe: Farbmuster und Name.
fn paper_theme_button(ui: &mut egui::Ui, theme: PaperTheme, selected: bool) -> egui::Response { fn paper_theme_button(ui: &mut egui::Ui, theme: PaperTheme, selected: bool) -> egui::Response {
let response = ui.add_sized( let response = ui.add_sized(
@@ -3045,6 +3136,30 @@ pub fn render_ui(
used += btn + spacing; used += btn + spacing;
} }
} }
// Auswahl: unter den 4 Stiftfarben ist Platz für das Schloss des
// Seitenverhältnisses beim Skalieren (Bilder: gesperrt, Issue #29)
if state.active_tool == ActiveTool::Select {
let locked = state.selection_aspect_lock;
let (icon, tooltip) = if locked == Some(true) {
("🔒", t!("Seitenverhältnis gesperrt – tippen zum Freigeben"))
} else {
("🔓", t!("Seitenverhältnis frei – tippen zum Sperren"))
};
if sidebar_button(
ui,
swatch,
SidebarIcon::Glyph(icon),
locked == Some(true),
locked.is_some(),
None,
tooltip,
)
.clicked()
{
actions.push(UiAction::ToggleAspectLock);
}
used += swatch + spacing;
}
let reserved = let reserved =
UiState::MAX_PALETTE_LEN as f32 * (swatch + spacing); UiState::MAX_PALETTE_LEN as f32 * (swatch + spacing);
if reserved > used { if reserved > used {
@@ -3118,7 +3233,7 @@ pub fn render_ui(
// --- Seitennavigation --- // --- Seitennavigation ---
// In der durchgehenden Ansicht wird gescrollt; Blätterpfeile gibt // In der durchgehenden Ansicht wird gescrollt; Blätterpfeile gibt
// es nur in der Einzelseitenansicht // es nur in der Einzelseitenansicht
let paging = !state.continuous_scroll; let paging = !state.continuous_scroll && !state.endless;
if paging if paging
&& sidebar_button( && sidebar_button(
ui, ui,
@@ -3143,7 +3258,7 @@ pub fn render_ui(
btn, btn,
SidebarIcon::Text(&page_text), SidebarIcon::Text(&page_text),
state.show_page_jump_popup, state.show_page_jump_popup,
true, !state.endless,
None, None,
t!("Seite wählen"), t!("Seite wählen"),
); );
@@ -3172,7 +3287,7 @@ pub fn render_ui(
btn, btn,
SidebarIcon::Glyph("➕"), SidebarIcon::Glyph("➕"),
false, false,
true, !state.endless,
None, None,
t!("Neue Seite nach dieser einfügen"), t!("Neue Seite nach dieser einfügen"),
) )
@@ -3584,7 +3699,10 @@ pub fn render_ui(
} }
}); });
if 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")) .on_hover_text(t!("Leere Seite direkt nach der aktuellen Seite"))
.clicked() .clicked()
{ {
@@ -3697,17 +3815,16 @@ pub fn render_ui(
} }
}); });
section(ui, t!("ANSICHT")); section(ui, t!("SEITEN DIESES NOTIZBUCHS"));
if ui let mut layout = if state.endless {
.checkbox(&mut state.continuous_scroll, t!("Seiten durchgehend scrollen")) NotebookLayout::Endless
.on_hover_text( } else if state.continuous_scroll {
t!("An: Seiten hängen untereinander, Mausrad und Finger scrollen \ NotebookLayout::Continuous
(Strg+Mausrad zoomt); wer unten auf der letzten Seite schreibt, \ } else {
bekommt automatisch eine neue Seite.\nAus: Seite für Seite blättern."), NotebookLayout::Paged
) };
.changed() if layout_picker(ui, &mut layout, state.endless_switch_possible) {
{ actions.push(UiAction::SetNotebookLayout(layout));
actions.push(UiAction::SetContinuousScroll(state.continuous_scroll));
} }
section(ui, t!("ZOOM")); section(ui, t!("ZOOM"));
@@ -4042,6 +4159,7 @@ pub fn render_ui(
actions.push(UiAction::CreateNotebook { actions.push(UiAction::CreateNotebook {
title, title,
folder: folder.clone(), folder: folder.clone(),
layout: state.new_notebook_layout,
}); });
state.new_notebook_title_input.clear(); state.new_notebook_title_input.clear();
} else if folder_btn.clicked() { } else if folder_btn.clicked() {
@@ -4053,6 +4171,10 @@ pub fn render_ui(
} }
} }
}); });
ui.horizontal(|ui| {
ui.label(t!("Seiten:"));
layout_picker(ui, &mut state.new_notebook_layout, true);
});
ui.add_space(8.0); ui.add_space(8.0);
ui.separator(); ui.separator();
@@ -5189,6 +5311,35 @@ mod tests {
} }
} }
#[test]
fn test_aspect_lock_button_only_with_selection() {
// Den Knopf in der Leiste des Auswahlwerkzeugs suchen: ohne Auswahl gesperrt
let notebook = Notebook::new();
let toggles = |lock: Option<bool>| {
let mut probe = UiState::new();
probe.active_tool = ActiveTool::Select;
render_frames(&mut probe, &notebook, 10);
let bar = probe.toolbar_rect.unwrap();
let mut found = 0;
let mut y = bar.min.y + 4.0;
while y < bar.max.y {
let mut state = UiState::new();
state.active_tool = ActiveTool::Select;
state.selection_aspect_lock = lock;
let ctx = render_frames(&mut state, &notebook, 10);
let actions = click_at(&ctx, &mut state, &notebook, egui::pos2(bar.center().x, y));
if actions.contains(&UiAction::ToggleAspectLock) {
found += 1;
}
y += 4.0;
}
found
};
assert_eq!(toggles(None), 0);
assert!(toggles(Some(true)) > 0);
assert!(toggles(Some(false)) > 0);
}
#[test] #[test]
fn test_sidebar_fits_window_height_for_every_tool() { fn test_sidebar_fits_window_height_for_every_tool() {
let notebook = Notebook::new(); let notebook = Notebook::new();