feat: Werkzeug „Platz schaffen“ (Issue #22)

- Neues Werkzeug ↕: aufs Blatt tippen und ziehen; alles unterhalb der
  Ansatzhöhe rutscht mit, auch der Inhalt der folgenden Seiten (bis vor die
  nächste PDF-Seite)
- Was über ein Seitenende rutschen würde, beginnt auf der nächsten Seite
  (10 mm Abstand), alles danach rutscht weiter; fehlende Seiten werden angehängt
- Nach oben wird nur leerer Platz geschlossen
- Vorschau beim Ziehen (Band mit Millimeterangabe); mit Maus, Stift und Finger
- Ein Schritt für Rückgängig/Wiederholen über alle beteiligten Seiten
- Nachbarseiten werden neu gezeichnet, wenn sich ihr Inhalt bei gleicher
  Strichzahl ändert

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 20:15:18 +02:00
co-authored by Claude Opus 5.5
parent f212b2e23f
commit 693f62c2e7
7 changed files with 652 additions and 20 deletions
+1
View File
@@ -25,6 +25,7 @@ Getestet wird vor allem unter Zorin OS 18 / Ubuntu 24.04 (Wayland und X11).
- **Tasten am Stift** im ☰-Menü belegen: Radieren (Standard), Auswählen, Textmarker, Stift oder nichts – gilt, solange die Taste gedrückt ist. Jede Taste erscheint dort nach dem ersten Drücken. - **Tasten am Stift** im ☰-Menü belegen: Radieren (Standard), Auswählen, Textmarker, Stift oder nichts – gilt, solange die Taste gedrückt ist. Jede Taste erscheint dort nach dem ersten Drücken.
- **Radierer** in drei Modi: ganze Striche, nur den überstrichenen Teil (Teilstrich) oder nur Textmarker. Die Stiftrückseite radiert ebenfalls. - **Radierer** in drei Modi: ganze Striche, nur den überstrichenen Teil (Teilstrich) oder nur Textmarker. Die Stiftrückseite radiert ebenfalls.
- **Virtuelles Lineal** (`L`): verschieben, am Griff drehen (rastet bei 15° ein); Striche entlang einer Kante werden gerade. - **Virtuelles Lineal** (`L`): verschieben, am Griff drehen (rastet bei 15° ein); Striche entlang einer Kante werden gerade.
- **Platz schaffen** (↕ in der Werkzeugleiste): aufs Blatt tippen und nach unten ziehen – alles darunter rutscht mit, auch auf den folgenden Seiten. Was über ein Seitenende rutschen würde, beginnt auf der nächsten Seite; fehlende Seiten werden angehängt. Nach oben ziehen schließt leeren Platz. Ein Schritt für Rückgängig.
### Formerkennung ### Formerkennung
- **Formen-Werkzeug** (`S`/`F`) oder **Halten** am Strichende (über 400 ms stillhalten): Die Skizze wird durch eine saubere Form ersetzt. - **Formen-Werkzeug** (`S`/`F`) oder **Halten** am Strichende (über 400 ms stillhalten): Die Skizze wird durch eine saubere Form ersetzt.
+2
View File
@@ -132,6 +132,8 @@ Alles auf einer Seite ist ein `Stroke` (`document.rs`):
### 5.2 Rückgängig ### 5.2 Rückgängig
Jede Seite hat einen eigenen Verlauf (`history.rs`): Hinzufügen, Entfernen, Ersetzen und Ändern von Strichen. Verschieben/Drehen/Skalieren einer Auswahl, ein Radierzug mit dem Teilstrich-Radierer und eine Textbearbeitung sind jeweils ein Schritt. Jede Seite hat einen eigenen Verlauf (`history.rs`): Hinzufügen, Entfernen, Ersetzen und Ändern von Strichen. Verschieben/Drehen/Skalieren einer Auswahl, ein Radierzug mit dem Teilstrich-Radierer und eine Textbearbeitung sind jeweils ein Schritt.
**Platz schaffen** (`Notebook::make_space`) betrifft mehrere Seiten: Die Seite, auf der angesetzt wurde, und die folgenden bis vor die nächste PDF-Seite gelten als durchgehende Spalte. Alles unterhalb der Ansatzhöhe rutscht um denselben Betrag; ein Objekt, das über ein Seitenende ragen würde, beginnt 10 mm unter dem Anfang der nächsten Seite, und alles danach rutscht entsprechend weiter. Fehlende Seiten werden eingefügt. Der Schritt (`SpaceShift`: jeder verschobene Strich vorher/nachher mit Seite) landet im Verlauf der Seite, auf der angesetzt wurde; `Notebook::undo`/`redo` buchen ihn über alle Seiten zurück (später geänderte Striche bleiben unberührt) und entfernen dafür angelegte, leer gebliebene Seiten wieder.
--- ---
## 6. Formerkennung ## 6. Formerkennung
+3
View File
@@ -128,6 +128,8 @@ struct NeighborFingerprint {
vertex_count: usize, vertex_count: usize,
stroke_count: usize, stroke_count: usize,
last_stroke_id: Option<u64>, last_stroke_id: Option<u64>,
/// Neuaufbau des Caches (z. B. verschobener Inhalt bei gleicher Strichzahl)
cache_generation: u64,
} }
impl NeighborFingerprint { impl NeighborFingerprint {
@@ -141,6 +143,7 @@ impl NeighborFingerprint {
vertex_count: page.vertex_cache.len(), vertex_count: page.vertex_cache.len(),
stroke_count: page.strokes.len(), stroke_count: page.strokes.len(),
last_stroke_id: page.strokes.last().map(|s| s.id), last_stroke_id: page.strokes.last().map(|s| s.id),
cache_generation: page.cache_generation,
} }
} }
} }
+402
View File
@@ -722,6 +722,8 @@ pub struct Page {
pub vertex_cache: Vec<InkVertex>, pub vertex_cache: Vec<InkVertex>,
pub stroke_ranges: Vec<Range<u32>>, pub stroke_ranges: Vec<Range<u32>>,
pub is_dirty: bool, pub is_dirty: bool,
/// Zählt die Neuaufbauten des Vertex-Caches (erkennt geänderte Nachbarseiten)
pub cache_generation: u64,
pub is_loaded: bool, pub is_loaded: bool,
pub persist: PersistState, pub persist: PersistState,
pub history: HistoryStack, pub history: HistoryStack,
@@ -742,6 +744,7 @@ impl Page {
is_loaded: true, is_loaded: true,
persist: PersistState::Normal, persist: PersistState::Normal,
history: HistoryStack::new(), history: HistoryStack::new(),
cache_generation: 0,
} }
} }
@@ -760,6 +763,7 @@ impl Page {
is_loaded: false, is_loaded: false,
persist: PersistState::Normal, persist: PersistState::Normal,
history: HistoryStack::new(), history: HistoryStack::new(),
cache_generation: 0,
} }
} }
@@ -958,6 +962,7 @@ impl Page {
} }
self.is_dirty = false; self.is_dirty = false;
self.cache_generation += 1;
} }
/// Berechnet die kombinierte AABB einer Menge von Strichen. /// Berechnet die kombinierte AABB einer Menge von Strichen.
@@ -1315,6 +1320,298 @@ impl Default for Notebook {
} }
} }
/// Abstand zum oberen Rand, mit dem ein Objekt auf der nächsten Seite landet, wenn es beim
/// Platzschaffen über das Seitenende rutschen würde (10 mm).
pub const SPACE_PAGE_BREAK_MARGIN: f32 = 10.0 * UNITS_PER_MM;
/// Ein beim Platzschaffen verschobener Strich: vorher und nachher, jeweils mit Seite.
#[derive(Debug, Clone)]
pub struct StrokeMove {
pub from_page: u64,
pub before: Stroke,
pub to_page: u64,
pub after: Stroke,
}
/// Für das Platzschaffen neu eingefügte Seite.
#[derive(Debug, Clone)]
pub struct AddedPage {
pub id: u64,
/// Seite, hinter der sie liegt
pub after_page: u64,
pub size: glam::Vec2,
pub background: PageBackground,
}
/// Ein Platzschaffen-Schritt (für Rückgängig und Wiederholen).
#[derive(Debug, Clone)]
pub struct SpaceShift {
pub moves: Vec<StrokeMove>,
pub added_pages: Vec<AddedPage>,
}
impl Notebook {
/// Seiten, die beim Platzschaffen auf Seite `page_index` mitrutschen: sie selbst und die
/// folgenden bis vor die nächste Seite mit importiertem PDF (deren Notizen gehören zur
/// PDF-Seite). `None` auf einer PDF-Seite.
pub fn space_run(&self, page_index: usize) -> Option<std::ops::Range<usize>> {
let page = self.pages.get(page_index)?;
if page.pdf_background.is_some() {
return None;
}
let end = (page_index + 1..self.pages.len())
.find(|&i| self.pages[i].pdf_background.is_some())
.unwrap_or(self.pages.len());
Some(page_index..end)
}
/// Wie weit sich der Inhalt unter `y` auf Seite `page_index` höchstens nach oben schieben
/// lässt: bis an das unterste Objekt darüber (bzw. den oberen Seitenrand).
pub fn space_up_limit(&self, page_index: usize, y: f32) -> f32 {
let Some(page) = self.pages.get(page_index) else {
return 0.0;
};
let bottom = page
.strokes
.iter()
.filter(|s| s.aabb.min.y < y)
.map(|s| s.aabb.max.y)
.fold(0.0_f32, f32::max);
(y - bottom).max(0.0)
}
/// Platz schaffen: Alles, was auf Seite `page_index` unterhalb von `y` beginnt, und der
/// ganze Inhalt der folgenden Seiten (siehe [`Self::space_run`]) rutscht um `dy` nach unten
/// (negativ: nach oben, höchstens bis [`Self::space_up_limit`]). Die Seiten gelten dabei
/// als durchgehende Spalte: Was über ein Seitenende hinausragen würde, beginnt auf der
/// nächsten Seite (mit [`SPACE_PAGE_BREAK_MARGIN`] Abstand), alles danach rutscht
/// entsprechend weiter; fehlende Seiten werden angehängt. Der Schritt landet im Verlauf
/// der Seite und gibt die Kennungen der geänderten Seiten zurück (`None`: nichts zu tun).
pub fn make_space(&mut self, page_index: usize, y: f32, dy: f32) -> Option<Vec<u64>> {
let run = self.space_run(page_index)?;
if self.pages[run.clone()].iter().any(|p| !p.is_loaded) {
return None;
}
let dy = if dy < 0.0 {
dy.max(-self.space_up_limit(page_index, y))
} else {
dy
};
if dy.abs() < 1.0 {
return None;
}
// Seiten als durchgehende Spalte: Lage jeder Seite (auch noch anzulegender)
let last_size = self.pages[run.end - 1].size;
let mut tops = Vec::new();
let mut heights = Vec::new();
let mut top = 0.0;
for page in &self.pages[run.clone()] {
tops.push(top);
heights.push(page.size.y);
top += page.size.y;
}
// Verschoben wird alles unterhalb von y, in der Reihenfolge von oben nach unten
let mut movable: Vec<(usize, f32, Stroke)> = Vec::new();
for (k, page) in self.pages[run.clone()].iter().enumerate() {
for stroke in &page.strokes {
if k > 0 || stroke.aabb.min.y >= y {
movable.push((k, tops[k] + stroke.aabb.min.y, stroke.clone()));
}
}
}
if movable.is_empty() {
return None;
}
movable.sort_by(|a, b| a.1.total_cmp(&b.1));
let mut extra = 0.0_f32;
let mut targets = Vec::with_capacity(movable.len());
for (_, global_top, stroke) in &movable {
let height = stroke.aabb.max.y - stroke.aabb.min.y;
let mut new_top = global_top + dy + extra;
let mut target = 0;
for _ in 0..2 {
// Seite, auf der das Objekt beginnt (bei Bedarf weitere Seiten)
target = 0;
while target + 1 < tops.len() && tops[target + 1] <= new_top {
target += 1;
}
while tops[target] + heights[target] <= new_top {
tops.push(tops[target] + heights[target]);
heights.push(last_size.y);
target += 1;
}
let page_end = tops[target] + heights[target];
let fits_on_a_page = height <= heights[target] - 2.0 * SPACE_PAGE_BREAK_MARGIN;
if new_top + height <= page_end || !fits_on_a_page {
break;
}
// Ragt über das Seitenende: auf die nächste Seite, alles Folgende rutscht mit
let margin = if dy > 0.0 {
SPACE_PAGE_BREAK_MARGIN
} else {
0.0
};
let bump = page_end + margin - new_top;
extra += bump;
new_top += bump;
}
targets.push((target, new_top));
}
// Fehlende Seiten hinter dem Abschnitt einfügen
let needed = targets.iter().map(|(t, _)| t + 1).max().unwrap_or(0);
let mut added_pages = Vec::new();
let background = self.pages[run.end - 1].background;
for k in run.len()..needed {
let after_page = self.pages[run.start + k - 1].id;
let id = self.next_page_id;
self.next_page_id += 1;
self.pages.insert(
run.start + k,
Page::new(id, background).with_size(last_size),
);
added_pages.push(AddedPage {
id,
after_page,
size: last_size,
background,
});
}
// Umbuchen
let mut moves = Vec::with_capacity(movable.len());
for ((k, _, before), (target, new_top)) in movable.into_iter().zip(targets) {
let mut after = before.clone();
after.translate(glam::Vec2::new(
0.0,
new_top - tops[target] - before.aabb.min.y,
));
moves.push(StrokeMove {
from_page: self.pages[run.start + k].id,
before,
to_page: self.pages[run.start + target].id,
after,
});
}
let shift = SpaceShift { moves, added_pages };
let changed = self.apply_space_moves(&shift, false);
self.pages[page_index]
.history
.push(HistoryAction::SpaceShift(Box::new(shift)));
Some(changed)
}
/// Bucht die Striche eines Platzschaffen-Schritts um (`undo`: zurück) und gibt die
/// geänderten Seiten zurück. Später geänderte oder gelöschte Striche bleiben unberührt.
fn apply_space_moves(&mut self, shift: &SpaceShift, undo: bool) -> Vec<u64> {
let mut changed = Vec::new();
let index_of = |pages: &[Page], id: u64| pages.iter().position(|p| p.id == id);
for m in &shift.moves {
let (from, old, to, new) = if undo {
(m.to_page, &m.after, m.from_page, &m.before)
} else {
(m.from_page, &m.before, m.to_page, &m.after)
};
let (Some(from_i), Some(to_i)) =
(index_of(&self.pages, from), index_of(&self.pages, to))
else {
continue;
};
let Some(pos) = self.pages[from_i]
.strokes
.iter()
.position(|s| s.id == old.id)
else {
continue;
};
self.pages[from_i].strokes.remove(pos);
self.pages[to_i].strokes.push(new.clone());
for i in [from_i, to_i] {
self.pages[i].is_dirty = true;
if !changed.contains(&self.pages[i].id) {
changed.push(self.pages[i].id);
}
}
}
changed
}
/// Macht die letzte Aktion der aktuellen Seite rückgängig; ein Platzschaffen-Schritt
/// betrifft dabei alle beteiligten Seiten. Gibt die geänderten Seiten zurück.
pub fn undo(&mut self) -> Option<Vec<u64>> {
let index = self.current_page_index;
if let Some(HistoryAction::SpaceShift(_)) = self.pages[index].history.undo_stack.last() {
let Some(HistoryAction::SpaceShift(shift)) = self.pages[index].history.undo_stack.pop()
else {
unreachable!()
};
let mut changed = self.apply_space_moves(&shift, true);
// Dafür angelegte Seiten wieder entfernen, sofern sie leer geblieben sind
for added in shift.added_pages.iter().rev() {
if let Some(i) = self.pages.iter().position(|p| p.id == added.id) {
if self.pages[i].strokes.is_empty() && self.pages.len() > 1 {
self.pages.remove(i);
if self.current_page_index >= i && self.current_page_index > 0 {
self.current_page_index -= 1;
}
changed.retain(|&id| id != added.id);
}
}
}
// Der Schritt gehört weiter zur aktuellen Seite (sie selbst wird nie entfernt)
let owner = self.current_page_index;
self.pages[owner]
.history
.redo_stack
.push(HistoryAction::SpaceShift(shift));
return Some(changed);
}
let page = &mut self.pages[index];
page.undo().then(|| vec![page.id])
}
/// Stellt die zuletzt rückgängig gemachte Aktion der aktuellen Seite wieder her.
pub fn redo(&mut self) -> Option<Vec<u64>> {
let index = self.current_page_index;
if let Some(HistoryAction::SpaceShift(_)) = self.pages[index].history.redo_stack.last() {
let Some(HistoryAction::SpaceShift(shift)) = self.pages[index].history.redo_stack.pop()
else {
unreachable!()
};
// Entfernte Seiten wieder einfügen
for added in &shift.added_pages {
if self.pages.iter().any(|p| p.id == added.id) {
continue;
}
let at = self
.pages
.iter()
.position(|p| p.id == added.after_page)
.map_or(self.pages.len(), |i| i + 1);
self.pages.insert(
at,
Page::new(added.id, added.background).with_size(added.size),
);
if at <= self.current_page_index {
self.current_page_index += 1;
}
}
let changed = self.apply_space_moves(&shift, false);
let owner = self.current_page_index;
self.pages[owner]
.history
.undo_stack
.push(HistoryAction::SpaceShift(shift));
return Some(changed);
}
let page = &mut self.pages[index];
page.redo().then(|| vec![page.id])
}
}
/// Lage der aktuellen Seite im Fenster bei Zoom 100 % ohne Verschiebung: Ein Rahmen der /// Lage der aktuellen Seite im Fenster bei Zoom 100 % ohne Verschiebung: Ein Rahmen der
/// Größe `fit_box` (alle Seiten des Notizbuchs passen hinein) wird ins Fenster eingepasst /// Größe `fit_box` (alle Seiten des Notizbuchs passen hinein) wird ins Fenster eingepasst
/// (Letterboxing/Pillarboxing); die aktuelle Seite liegt darin waagerecht zentriert und oben. /// (Letterboxing/Pillarboxing); die aktuelle Seite liegt darin waagerecht zentriert und oben.
@@ -2005,6 +2302,111 @@ mod tests {
assert!(new.pdf_background.is_none()); assert!(new.pdf_background.is_none());
} }
/// Senkrechter Strich bei x = 300 von `y` bis `y + h`.
fn block(id: u64, y: f32, h: f32) -> Stroke {
let samples = [y, y + h]
.map(|y| StylusSample {
position: glam::Vec2::new(300.0, y),
pressure: 0.5,
timestamp: 0.0,
})
.to_vec();
Stroke::new(id, samples, Vec::new(), [0.0, 0.0, 0.0, 1.0])
}
fn top_of(nb: &Notebook, page: usize, id: u64) -> Option<f32> {
nb.pages[page]
.strokes
.iter()
.find(|s| s.id == id)
.map(|s| s.aabb.min.y)
}
#[test]
fn test_make_space_moves_content_below_and_following_pages() {
let mut nb = Notebook::new();
nb.add_page(PageBackground::Lined);
let a4 = A4_SIZE.y;
nb.pages[0].strokes = vec![block(1, 200.0, 100.0), block(2, 1000.0, 100.0)];
nb.pages[1].strokes = vec![block(3, 500.0, 100.0)];
// 30 mm Platz bei y = 800 auf Seite 1
let changed = nb.make_space(0, 800.0, 300.0).unwrap();
assert_eq!(top_of(&nb, 0, 1), Some(200.0), "darüber bleibt stehen");
assert_eq!(top_of(&nb, 0, 2), Some(1300.0));
assert_eq!(
top_of(&nb, 1, 3),
Some(800.0),
"die nächste Seite rutscht mit"
);
assert_eq!(changed.len(), 2);
assert_eq!(nb.pages.len(), 2);
// Rückgängig und wiederholen über beide Seiten
nb.current_page_index = 0;
assert!(nb.undo().is_some());
assert_eq!(top_of(&nb, 0, 2), Some(1000.0));
assert_eq!(top_of(&nb, 1, 3), Some(500.0));
assert!(nb.redo().is_some());
assert_eq!(top_of(&nb, 0, 2), Some(1300.0));
assert_eq!(top_of(&nb, 1, 3), Some(800.0));
// Über das Seitenende: auf die nächste Seite mit Abstand, das Folgende rutscht weiter
let mut nb = Notebook::new();
nb.pages[0].strokes = vec![block(1, a4 - 400.0, 200.0), block(2, a4 - 150.0, 100.0)];
nb.make_space(0, a4 - 500.0, 300.0).unwrap();
assert_eq!(nb.pages.len(), 2, "eine Seite wurde angehängt");
assert_eq!(nb.pages[1].background, nb.pages[0].background);
assert!(nb.pages[0].strokes.is_empty());
let first = top_of(&nb, 1, 1).unwrap();
assert!((first - SPACE_PAGE_BREAK_MARGIN).abs() < 1e-3, "{first}");
// Abstand zwischen beiden bleibt (50 Einheiten unter dem Ende des ersten)
assert!((top_of(&nb, 1, 2).unwrap() - (first + 250.0)).abs() < 1e-3);
// Rückgängig entfernt die angehängte leere Seite
assert!(nb.undo().is_some());
assert_eq!(nb.pages.len(), 1);
assert_eq!(top_of(&nb, 0, 2), Some(a4 - 150.0));
assert!(nb.redo().is_some());
assert_eq!(nb.pages.len(), 2);
}
#[test]
fn test_make_space_upwards_only_closes_empty_space() {
let mut nb = Notebook::new();
nb.pages[0].strokes = vec![block(1, 200.0, 100.0), block(2, 1000.0, 100.0)];
// Nach oben höchstens bis an das Objekt darüber (Unterkante 300)
assert_eq!(nb.space_up_limit(0, 900.0), 600.0);
nb.make_space(0, 900.0, -2000.0).unwrap();
assert_eq!(top_of(&nb, 0, 2), Some(400.0));
assert_eq!(top_of(&nb, 0, 1), Some(200.0));
// Kein Platz mehr: nichts zu tun
assert!(nb.make_space(0, 300.5, -50.0).is_none());
}
#[test]
fn test_make_space_stops_before_pdf_pages() {
let mut nb = Notebook::new();
nb.add_page(PageBackground::Blank);
nb.add_page(PageBackground::Blank);
nb.pages[1].pdf_background = Some(PdfBackground {
asset: "x.pdf".to_string(),
page: 0,
width_pt: 595.0,
height_pt: 842.0,
});
nb.pages[0].strokes = vec![block(1, 1000.0, 100.0)];
nb.pages[2].strokes = vec![block(2, 500.0, 100.0)];
assert!(nb.space_run(1).is_none(), "nicht auf PDF-Seiten");
assert_eq!(nb.space_run(0), Some(0..1));
nb.make_space(0, 900.0, 300.0).unwrap();
assert_eq!(top_of(&nb, 0, 1), Some(1300.0));
assert_eq!(
top_of(&nb, 2, 2),
Some(500.0),
"hinter der PDF-Seite bleibt alles"
);
}
#[test] #[test]
fn test_remove_and_restore_current_page() { fn test_remove_and_restore_current_page() {
let mut nb = Notebook::new(); let mut nb = Notebook::new();
+10
View File
@@ -21,6 +21,9 @@ pub enum HistoryAction {
before: Vec<Stroke>, before: Vec<Stroke>,
after: Vec<Stroke>, after: Vec<Stroke>,
}, },
/// Platz geschaffen: Inhalt mehrerer Seiten wurde verschoben. Rückgängig macht das das
/// Notizbuch (`Notebook::undo`), nicht die einzelne Seite.
SpaceShift(Box<crate::document::SpaceShift>),
/// Teilradierer: Striche wurden durch ihre übrig gebliebenen Stücke ersetzt. /// Teilradierer: Striche wurden durch ihre übrig gebliebenen Stücke ersetzt.
SplitStrokes { SplitStrokes {
removed: Vec<Stroke>, removed: Vec<Stroke>,
@@ -139,6 +142,10 @@ impl HistoryStack {
self.redo_stack self.redo_stack
.push(HistoryAction::ModifyStrokes { before, after }); .push(HistoryAction::ModifyStrokes { before, after });
} }
// Betrifft mehrere Seiten: Das Notizbuch macht es rückgängig, hier nur umbuchen
HistoryAction::SpaceShift(shift) => {
self.redo_stack.push(HistoryAction::SpaceShift(shift));
}
HistoryAction::SplitStrokes { removed, added, at } => { HistoryAction::SplitStrokes { removed, added, at } => {
strokes.retain(|s| !added.iter().any(|a| a.id == s.id)); strokes.retain(|s| !added.iter().any(|a| a.id == s.id));
strokes.extend(removed.iter().cloned()); strokes.extend(removed.iter().cloned());
@@ -190,6 +197,9 @@ impl HistoryStack {
self.undo_stack self.undo_stack
.push(HistoryAction::ModifyStrokes { before, after }); .push(HistoryAction::ModifyStrokes { before, after });
} }
HistoryAction::SpaceShift(shift) => {
self.undo_stack.push(HistoryAction::SpaceShift(shift));
}
HistoryAction::SplitStrokes { removed, added, at } => { HistoryAction::SplitStrokes { removed, added, at } => {
strokes.retain(|s| !removed.iter().any(|r| r.id == s.id)); strokes.retain(|s| !removed.iter().any(|r| r.id == s.id));
strokes.extend(added.iter().cloned()); strokes.extend(added.iter().cloned());
+149 -15
View File
@@ -37,6 +37,89 @@ use ink_notes::storage::{self, NotebookManifest};
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;
/// Platz schaffen: Druck auf das Blatt (physische Pixel) setzt die Ansatzhöhe auf der
/// aktuellen Seite. `false` neben dem Blatt oder auf einer PDF-Seite.
fn space_press(
screen: (f32, f32),
canvas: &CanvasState,
notebook: &Notebook,
ui_state: &mut UiState,
) -> bool {
let Some(page_pt) = canvas.screen_to_page(screen.0, screen.1) else {
return false;
};
let index = notebook.current_page_index;
if notebook.space_run(index).is_none() {
ui_state.set_export_message(
"Auf Seiten mit importiertem PDF lässt sich kein Platz schaffen".to_string(),
);
return false;
}
ui_state.space_drag = Some(ui::SpaceDrag {
page_index: index,
y: page_pt.y,
dy: 0.0,
up_limit: notebook.space_up_limit(index, page_pt.y),
});
true
}
/// Platz schaffen: Verschiebung aus dem senkrechten Abstand zur Ansatzhöhe.
fn space_move(screen: (f32, f32), canvas: &CanvasState, ui_state: &mut UiState) {
if let Some(drag) = &mut ui_state.space_drag {
let start = canvas.page_to_screen(Vec2::new(0.0, drag.y));
let below = canvas.page_to_screen(Vec2::new(0.0, drag.y + 1000.0));
let px_per_unit = (below[1] - start[1]) / 1000.0;
if px_per_unit > 0.0 {
drag.dy = (screen.1 - start[1]) / px_per_unit;
}
}
}
/// Platz schaffen beim Loslassen ausführen (alle beteiligten Seiten werden dafür geladen).
fn space_release(
canvas: &mut CanvasState,
notebook: &mut Notebook,
manifest: &mut NotebookManifest,
autosave: &mut AutosaveController,
storage_dir: &Path,
ui_state: &mut UiState,
) {
let Some(drag) = ui_state.space_drag.take() else {
return;
};
if let Some(run) = notebook.space_run(drag.page_index) {
for index in run {
let _ = storage::ensure_page_loaded(storage_dir, notebook, index);
}
}
let dy = drag.clamped_dy();
if let Some(changed) = notebook.make_space(drag.page_index, drag.y, dy) {
persist_pages(&changed, notebook, manifest, autosave);
canvas.sync_page(notebook.current_page_mut());
let mm = (dy / ink_notes::document::UNITS_PER_MM).abs().round();
ui_state.set_export_message(if dy > 0.0 {
format!("{mm} mm Platz geschaffen (Rückgängig mit Strg+Z)")
} else {
format!("{mm} mm Platz geschlossen (Rückgängig mit Strg+Z)")
});
}
}
/// Merkt geänderte Seiten (Kennungen) und das Manifest zum Speichern vor.
fn persist_pages(
ids: &[u64],
notebook: &Notebook,
manifest: &mut NotebookManifest,
autosave: &mut AutosaveController,
) {
manifest.sync_from_notebook(notebook);
for page in notebook.pages.iter().filter(|p| ids.contains(&p.id)) {
autosave.mark_page_dirty(page, manifest);
}
autosave.mark_manifest_dirty(manifest);
}
/// Wählt `tool` und stellt Farbe und Stärke des Live-Strichs passend ein. /// Wählt `tool` und stellt Farbe und Stärke des Live-Strichs passend ein.
fn activate_tool(tool: ActiveTool, ui_state: &mut UiState, canvas: &mut CanvasState) { fn activate_tool(tool: ActiveTool, ui_state: &mut UiState, canvas: &mut CanvasState) {
ui_state.active_tool = tool; ui_state.active_tool = tool;
@@ -49,7 +132,7 @@ fn activate_tool(tool: ActiveTool, ui_state: &mut UiState, canvas: &mut CanvasSt
canvas.set_stroke_color(ui_state.highlighter_color); canvas.set_stroke_color(ui_state.highlighter_color);
canvas.set_stroke_width_mm(ui_state.highlighter_stroke_width_mm); canvas.set_stroke_width_mm(ui_state.highlighter_stroke_width_mm);
} }
ActiveTool::Eraser | ActiveTool::Select | ActiveTool::Text => {} ActiveTool::Eraser | ActiveTool::Select | ActiveTool::Text | ActiveTool::Space => {}
} }
} }
@@ -1219,6 +1302,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// einer Werkzeug-Taste (Auswählen, Textmarker, Stift) vertreten wird // einer Werkzeug-Taste (Auswählen, Textmarker, Stift) vertreten wird
let mut stylus_held: Option<(u64, StylusAction)> = None; let mut stylus_held: Option<(u64, StylusAction)> = None;
let mut tool_before_button: Option<ActiveTool> = None; let mut tool_before_button: Option<ActiveTool> = None;
// Platz schaffen läuft mit dem Stift bzw. dem Finger (sonst mit der Maus)
let mut is_stylus_spacing = false;
let mut is_touch_spacing = false;
let mut stylus_screen_pos = (0.0_f32, 0.0_f32); let mut stylus_screen_pos = (0.0_f32, 0.0_f32);
// Stift hat auf der UI aufgesetzt: alle Ereignisse bis zum Abheben gehen an egui // Stift hat auf der UI aufgesetzt: alle Ereignisse bis zum Abheben gehen an egui
let mut stylus_ui_capture = false; let mut stylus_ui_capture = false;
@@ -1390,6 +1476,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if is_erasing { if is_erasing {
is_erasing = false; is_erasing = false;
} }
if is_stylus_spacing {
is_stylus_spacing = false;
space_release(&mut canvas, &mut notebook, &mut manifest, &mut autosave, &storage_dir, &mut ui_state);
window.request_redraw();
}
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);
@@ -1465,7 +1556,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
continue; continue;
} }
if is_erasing { if is_stylus_spacing {
space_move((sx, sy), &canvas, &mut ui_state);
window.request_redraw();
} else if is_erasing {
focus_page_at!((sx, sy)); focus_page_at!((sx, sy));
if let Some(page_pt) = canvas.screen_to_page(sx, sy) { if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
if notebook.erase_at(page_pt, if notebook.erase_at(page_pt,
@@ -1549,7 +1643,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|| button_action == Some(StylusAction::Erase) || button_action == Some(StylusAction::Erase)
|| ui_state.active_tool == ActiveTool::Eraser; || ui_state.active_tool == ActiveTool::Eraser;
if !should_erase && ui_state.active_tool == ActiveTool::Text { if !should_erase && ui_state.active_tool == ActiveTool::Space {
is_stylus_spacing = space_press((sx, sy), &canvas, &notebook, &mut ui_state);
window.request_redraw();
} else if !should_erase && ui_state.active_tool == ActiveTool::Text {
if let Some(page_pt) = canvas.screen_to_page(sx, sy) { if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
text_tool_click(page_pt, &mut canvas, &mut notebook, &mut manifest, &mut autosave, &mut ui_state); text_tool_click(page_pt, &mut canvas, &mut notebook, &mut manifest, &mut autosave, &mut ui_state);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
@@ -1618,6 +1715,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
is_erasing = false; is_erasing = false;
} }
if is_stylus_spacing {
is_stylus_spacing = false;
space_release(&mut canvas, &mut notebook, &mut manifest, &mut autosave, &storage_dir, &mut ui_state);
window.request_redraw();
}
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) {
@@ -1905,10 +2008,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// Undo: Strg + Z // Undo: Strg + Z
Key::Character(c) if c.eq_ignore_ascii_case("z") && ctrl && !shift => { Key::Character(c) if c.eq_ignore_ascii_case("z") && ctrl && !shift => {
finish_text_edit(&mut canvas, &mut notebook, &mut manifest, &mut autosave, &mut ui_state); finish_text_edit(&mut canvas, &mut notebook, &mut manifest, &mut autosave, &mut ui_state);
if notebook.current_page_mut().undo() { if let Some(changed) = notebook.undo() {
selection_state.clear(); selection_state.clear();
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
autosave.mark_dirty(&notebook, &mut manifest); persist_pages(&changed, &notebook, &mut manifest, &mut autosave);
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();
} }
@@ -1920,10 +2023,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|| (c.eq_ignore_ascii_case("z") && ctrl && shift) => || (c.eq_ignore_ascii_case("z") && ctrl && shift) =>
{ {
finish_text_edit(&mut canvas, &mut notebook, &mut manifest, &mut autosave, &mut ui_state); finish_text_edit(&mut canvas, &mut notebook, &mut manifest, &mut autosave, &mut ui_state);
if notebook.current_page_mut().redo() { if let Some(changed) = notebook.redo() {
selection_state.clear(); selection_state.clear();
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
autosave.mark_dirty(&notebook, &mut manifest); persist_pages(&changed, &notebook, &mut manifest, &mut autosave);
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();
} }
@@ -2010,7 +2113,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// Radierer umschalten: E // Radierer umschalten: E
Key::Character(c) if c.eq_ignore_ascii_case("e") && !ctrl => { Key::Character(c) if c.eq_ignore_ascii_case("e") && !ctrl => {
ui_state.active_tool = match ui_state.active_tool { ui_state.active_tool = match ui_state.active_tool {
ActiveTool::Pen | ActiveTool::Highlighter | ActiveTool::Shape | ActiveTool::Select | ActiveTool::Text => ActiveTool::Eraser, ActiveTool::Pen | ActiveTool::Highlighter | ActiveTool::Shape | ActiveTool::Select | ActiveTool::Text | ActiveTool::Space => ActiveTool::Eraser,
ActiveTool::Eraser => ActiveTool::Pen, ActiveTool::Eraser => ActiveTool::Pen,
}; };
ui_state.is_pen_menu_open = false; ui_state.is_pen_menu_open = false;
@@ -2287,6 +2390,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} else if button == MouseButton::Left { } else if button == MouseButton::Left {
let (sx, sy) = cursor_screen_pos; let (sx, sy) = cursor_screen_pos;
// Platz schaffen: Ansatzhöhe merken
if ui_state.active_tool == ActiveTool::Space {
space_press((sx, sy), &canvas, &notebook, &mut ui_state);
window.request_redraw();
return;
}
// Text-Werkzeug: Textfeld öffnen // Text-Werkzeug: Textfeld öffnen
if ui_state.active_tool == ActiveTool::Text { if ui_state.active_tool == ActiveTool::Text {
if let Some(page_pt) = canvas.screen_to_page(sx, sy) { if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
@@ -2348,6 +2458,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if is_erasing { if is_erasing {
is_erasing = false; is_erasing = false;
} }
if ui_state.space_drag.is_some() && !is_stylus_spacing && !is_touch_spacing {
space_release(&mut canvas, &mut notebook, &mut manifest, &mut autosave, &storage_dir, &mut ui_state);
window.request_redraw();
}
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)
@@ -2401,7 +2515,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// Wenn der Stylus aktiv zeichnet, keine Mauscursor-Bewegungen verarbeiten // Wenn der Stylus aktiv zeichnet, keine Mauscursor-Bewegungen verarbeiten
if !is_stylus_drawing && !is_stylus_selecting { if !is_stylus_drawing && !is_stylus_selecting {
if ui_state.active_tool == ActiveTool::Select && selection_state.drag_mode != DragMode::None { if ui_state.space_drag.is_some() && !is_stylus_spacing && !is_touch_spacing {
space_move((sx, sy), &canvas, &mut ui_state);
window.request_redraw();
} else if ui_state.active_tool == ActiveTool::Select && selection_state.drag_mode != DragMode::None {
if let Some(page_pt) = canvas.screen_to_page(sx, sy) { 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_drag(page_pt, notebook.current_page_mut(), &mut canvas, &mut selection_state);
window.request_redraw(); window.request_redraw();
@@ -2554,6 +2671,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
window.request_redraw(); window.request_redraw();
} }
is_touch_panning = false; is_touch_panning = false;
if is_touch_spacing {
is_touch_spacing = false;
ui_state.space_drag = None;
}
is_pinch_gesturing = true; is_pinch_gesturing = true;
let pts: Vec<(f32, f32)> = active_touches.values().copied().take(2).collect(); let pts: Vec<(f32, f32)> = active_touches.values().copied().take(2).collect();
@@ -2594,6 +2715,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if selection_press((tx, ty), false, &canvas, notebook.current_page(), egui_ctx.pixels_per_point(), &mut selection_state) { if selection_press((tx, ty), false, &canvas, notebook.current_page(), egui_ctx.pixels_per_point(), &mut selection_state) {
window.request_redraw(); window.request_redraw();
} }
} else if ui_state.active_tool == ActiveTool::Space {
is_touch_spacing = space_press((tx, ty), &canvas, &notebook, &mut ui_state);
window.request_redraw();
} else if ui_state.active_tool.is_drawing() || ui_state.active_tool == ActiveTool::Shape { } else if ui_state.active_tool.is_drawing() || ui_state.active_tool == ActiveTool::Shape {
if let Some(page_pt) = canvas.screen_to_page(tx, ty) { if let Some(page_pt) = canvas.screen_to_page(tx, ty) {
is_touch_drawing = true; is_touch_drawing = true;
@@ -2660,6 +2784,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
canvas.extend_stroke(page_pt.x, page_pt.y, pressure, now); canvas.extend_stroke(page_pt.x, page_pt.y, pressure, now);
window.request_redraw(); window.request_redraw();
} }
} else if is_touch_spacing {
space_move((tx, ty), &canvas, &mut ui_state);
window.request_redraw();
} else if is_erasing { } else if is_erasing {
focus_page_at!((tx, ty)); focus_page_at!((tx, ty));
if let Some(page_pt) = canvas.screen_to_page(tx, ty) { if let Some(page_pt) = canvas.screen_to_page(tx, ty) {
@@ -2692,6 +2819,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
is_erasing = false; is_erasing = false;
} }
if is_touch_spacing {
is_touch_spacing = false;
space_release(&mut canvas, &mut notebook, &mut manifest, &mut autosave, &storage_dir, &mut ui_state);
window.request_redraw();
}
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)
{ {
@@ -2754,6 +2887,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
.is_none_or(|last: [f32; 3]| last.iter().zip(scroll).any(|(a, b)| (a - b).abs() > 1e-3)); .is_none_or(|last: [f32; 3]| last.iter().zip(scroll).any(|(a, b)| (a - b).abs() > 1e-3));
let busy = is_drawing let busy = is_drawing
|| is_erasing || is_erasing
|| ui_state.space_drag.is_some()
|| !selection_state.is_empty() || !selection_state.is_empty()
|| selection_state.drag_mode != DragMode::None || selection_state.drag_mode != DragMode::None
|| ui_state.ruler_drag.is_some() || ui_state.ruler_drag.is_some()
@@ -2831,7 +2965,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
ui_state.highlighter_color = color; ui_state.highlighter_color = color;
canvas.set_stroke_color(color); canvas.set_stroke_color(color);
} }
ActiveTool::Eraser | ActiveTool::Select | ActiveTool::Text => {} ActiveTool::Eraser | ActiveTool::Select | ActiveTool::Text | ActiveTool::Space => {}
} }
window.request_redraw(); window.request_redraw();
} }
@@ -2845,7 +2979,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
ui_state.highlighter_stroke_width_mm = width; ui_state.highlighter_stroke_width_mm = width;
canvas.set_stroke_width_mm(width); canvas.set_stroke_width_mm(width);
} }
ActiveTool::Eraser | ActiveTool::Select | ActiveTool::Text => {} ActiveTool::Eraser | ActiveTool::Select | ActiveTool::Text | ActiveTool::Space => {}
} }
window.request_redraw(); window.request_redraw();
} }
@@ -2920,20 +3054,20 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
UiAction::Undo => { UiAction::Undo => {
finish_text_edit(&mut canvas, &mut notebook, &mut manifest, &mut autosave, &mut ui_state); finish_text_edit(&mut canvas, &mut notebook, &mut manifest, &mut autosave, &mut ui_state);
if notebook.current_page_mut().undo() { if let Some(changed) = notebook.undo() {
selection_state.clear(); selection_state.clear();
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
autosave.mark_dirty(&notebook, &mut manifest); persist_pages(&changed, &notebook, &mut manifest, &mut autosave);
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::Redo => { UiAction::Redo => {
finish_text_edit(&mut canvas, &mut notebook, &mut manifest, &mut autosave, &mut ui_state); finish_text_edit(&mut canvas, &mut notebook, &mut manifest, &mut autosave, &mut ui_state);
if notebook.current_page_mut().redo() { if let Some(changed) = notebook.redo() {
selection_state.clear(); selection_state.clear();
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
autosave.mark_dirty(&notebook, &mut manifest); persist_pages(&changed, &notebook, &mut manifest, &mut autosave);
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();
} }
+85 -5
View File
@@ -35,6 +35,8 @@ pub enum ActiveTool {
Select, Select,
/// Textfelder schreiben und bearbeiten /// Textfelder schreiben und bearbeiten
Text, Text,
/// Platz schaffen: Inhalt ab einer Höhe nach unten oder oben schieben
Space,
} }
impl ActiveTool { impl ActiveTool {
@@ -46,6 +48,7 @@ impl ActiveTool {
ActiveTool::Shape => "Formen (Shape)", ActiveTool::Shape => "Formen (Shape)",
ActiveTool::Select => "Auswahl / Lasso (Select)", ActiveTool::Select => "Auswahl / Lasso (Select)",
ActiveTool::Text => "Text (T)", ActiveTool::Text => "Text (T)",
ActiveTool::Space => "Platz schaffen",
} }
} }
@@ -179,6 +182,23 @@ pub enum MoveSubject {
Folder(String), Folder(String),
} }
/// Laufendes Platzschaffen (für die Vorschau): Seite, Höhe auf der Seite, bisherige
/// Verschiebung und wie weit es nach oben höchstens geht (Seiteneinheiten).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SpaceDrag {
pub page_index: usize,
pub y: f32,
pub dy: f32,
pub up_limit: f32,
}
impl SpaceDrag {
/// Verschiebung nach oben begrenzt auf den leeren Platz.
pub fn clamped_dy(&self) -> f32 {
self.dy.max(-self.up_limit)
}
}
/// Kontextmenü nach langem Drücken auf das Blatt. /// Kontextmenü nach langem Drücken auf das Blatt.
#[derive(Debug, Clone, Copy, PartialEq)] #[derive(Debug, Clone, Copy, PartialEq)]
pub struct ContextMenu { pub struct ContextMenu {
@@ -378,6 +398,8 @@ pub struct UiState {
pub show_color_picker: bool, pub show_color_picker: bool,
/// Kontextmenü nach langem Drücken (Einfügen an dieser Stelle) /// Kontextmenü nach langem Drücken (Einfügen an dieser Stelle)
pub context_menu: Option<ContextMenu>, pub context_menu: Option<ContextMenu>,
/// Laufendes Platzschaffen (Vorschau)
pub space_drag: Option<SpaceDrag>,
/// Tasten am Stift und ihre Belegung; die gerade gedrückte (Index) wird hervorgehoben /// Tasten am Stift und ihre Belegung; die gerade gedrückte (Index) wird hervorgehoben
pub stylus_buttons: Vec<crate::settings::StylusButton>, pub stylus_buttons: Vec<crate::settings::StylusButton>,
pub pressed_stylus_button: Option<usize>, pub pressed_stylus_button: Option<usize>,
@@ -476,6 +498,7 @@ impl Default for UiState {
is_text_color_menu_open: false, is_text_color_menu_open: false,
show_color_picker: false, show_color_picker: false,
context_menu: None, context_menu: None,
space_drag: None,
stylus_buttons: Vec::new(), stylus_buttons: Vec::new(),
pressed_stylus_button: None, pressed_stylus_button: None,
confirm_delete_page: false, confirm_delete_page: false,
@@ -593,10 +616,10 @@ impl UiState {
/// Blätterpfeile). /// Blätterpfeile).
pub fn sidebar_button_size(tool: ActiveTool, max_height: f32, continuous: bool) -> f32 { pub fn sidebar_button_size(tool: ActiveTool, max_height: f32, continuous: bool) -> f32 {
let select = matches!(tool, ActiveTool::Select | ActiveTool::Text); let select = matches!(tool, ActiveTool::Select | ActiveTool::Text);
// Notizbücher, 6 Werkzeuge, Undo/Redo, Seitenzahl und neue Seite (einzeln blättern: // Notizbücher, 7 Werkzeuge, Lineal, Undo/Redo, Seitenzahl und neue Seite (einzeln
// dazu zwei Pfeile), Menü (+ 4 Auswahl-Aktionen bzw. Schriftknöpfe) // blättern: dazu zwei Pfeile), Menü (+ 4 Auswahl-Aktionen bzw. Schriftknöpfe)
let page_buttons = if continuous { 2 } else { 4 }; let page_buttons = if continuous { 2 } else { 4 };
let buttons = 10 + page_buttons + if select { 4 } else { 0 }; let buttons = 11 + page_buttons + if select { 4 } else { 0 };
let dividers = 5 + usize::from(select); let dividers = 5 + usize::from(select);
let spacing = 3.0; let spacing = 3.0;
let needed = |b: f32| { let needed = |b: f32| {
@@ -679,7 +702,7 @@ impl UiState {
ActiveTool::Pen | ActiveTool::Shape | ActiveTool::Select => self.pen_color, ActiveTool::Pen | ActiveTool::Shape | ActiveTool::Select => self.pen_color,
ActiveTool::Highlighter => self.highlighter_color, ActiveTool::Highlighter => self.highlighter_color,
ActiveTool::Text => self.text_color, ActiveTool::Text => self.text_color,
ActiveTool::Eraser => [0.0, 0.0, 0.0, 0.0], ActiveTool::Eraser | ActiveTool::Space => [0.0, 0.0, 0.0, 0.0],
} }
} }
@@ -690,7 +713,7 @@ impl UiState {
self.pen_stroke_width_mm self.pen_stroke_width_mm
} }
ActiveTool::Highlighter => self.highlighter_stroke_width_mm, ActiveTool::Highlighter => self.highlighter_stroke_width_mm,
ActiveTool::Eraser => 3.0, ActiveTool::Eraser | ActiveTool::Space => 3.0,
} }
} }
@@ -2125,6 +2148,55 @@ fn context_menu(ctx: &egui::Context, state: &mut UiState, actions: &mut Vec<UiAc
} }
} }
/// Vorschau beim Platzschaffen: Linie an der Ansatzhöhe und das Band, das eingefügt (grün)
/// bzw. geschlossen (orange) wird, mit Angabe in Millimetern.
fn space_preview(ctx: &egui::Context, state: &UiState, notebook: &Notebook) {
let (Some(drag), Some(view)) = (state.space_drag, state.page_view) else {
return;
};
let Some(page) = notebook.pages.get(drag.page_index) else {
return;
};
let dy = drag.clamped_dy();
let painter = ctx.layer_painter(egui::LayerId::new(
egui::Order::Foreground,
egui::Id::new("space_preview"),
));
let left = view.to_screen(glam::Vec2::new(0.0, drag.y));
let right = view.to_screen(glam::Vec2::new(page.size.x, drag.y));
let moved = view.to_screen(glam::Vec2::new(page.size.x, drag.y + dy));
let (fill, line) = if dy >= 0.0 {
(
egui::Color32::from_rgba_unmultiplied(46, 204, 113, 50),
egui::Color32::from_rgb(39, 174, 96),
)
} else {
(
egui::Color32::from_rgba_unmultiplied(230, 126, 34, 60),
egui::Color32::from_rgb(211, 84, 0),
)
};
let band = egui::Rect::from_two_pos(left, moved);
painter.rect_filled(band, 0.0, fill);
painter.line_segment([left, right], egui::Stroke::new(2.0_f32, line));
painter.line_segment(
[egui::pos2(left.x, moved.y), moved],
egui::Stroke::new(2.0_f32, line),
);
let mm = dy / crate::document::UNITS_PER_MM;
painter.text(
egui::pos2(right.x - 8.0, moved.y.max(left.y) + 4.0),
egui::Align2::RIGHT_TOP,
if dy >= 0.0 {
format!("⏷ {mm:.0} mm Platz")
} else {
format!("⏶ {:.0} mm", -mm)
},
egui::FontId::proportional(14.0),
line,
);
}
/// Durchgehende Ansicht: dunkle Linie an jeder Grenze zweier Seiten (die Seiten liegen /// Durchgehende Ansicht: dunkle Linie an jeder Grenze zweier Seiten (die Seiten liegen
/// lückenlos untereinander). Unter allen Fenstern der Oberfläche, über der Tinte. /// lückenlos untereinander). Unter allen Fenstern der Oberfläche, über der Tinte.
fn page_separators(ctx: &egui::Context, state: &UiState, notebook: &Notebook) { fn page_separators(ctx: &egui::Context, state: &UiState, notebook: &Notebook) {
@@ -2555,6 +2627,7 @@ pub fn render_ui(
let mut actions = std::mem::take(&mut state.queued_actions); let mut actions = std::mem::take(&mut state.queued_actions);
state.screen_size = ctx.screen_rect().size(); state.screen_size = ctx.screen_rect().size();
page_separators(ctx, state, notebook); page_separators(ctx, state, notebook);
space_preview(ctx, state, notebook);
ruler_overlay(ctx, state); ruler_overlay(ctx, state);
text_edit_overlay(ctx, state); text_edit_overlay(ctx, state);
context_menu(ctx, state, &mut actions); context_menu(ctx, state, &mut actions);
@@ -2733,6 +2806,13 @@ pub fn render_ui(
SidebarIcon::Lasso, SidebarIcon::Lasso,
"Auswählen: verschieben, drehen, skalieren, kopieren (V)", "Auswählen: verschieben, drehen, skalieren, kopieren (V)",
), ),
(
ActiveTool::Space,
SidebarIcon::Glyph("↕"),
"Platz schaffen: auf das Blatt tippen und nach unten ziehen – alles \
darunter rutscht mit, auch auf die folgenden Seiten; nach oben \
schließt leeren Platz",
),
( (
ActiveTool::Text, ActiveTool::Text,
SidebarIcon::Glyph("T"), SidebarIcon::Glyph("T"),