feat(pdf): Strg + Mausrad zoomt, Zoomgruppe rechts aussen

Die Zoomknoepfe sitzen jetzt rechts in der Leiste statt links neben der
Seitenzahl - dieselbe Anordnung wie in InkNote. Die dritte Rasterspalte
war ohnehin da, damit die Suche wirklich mittig sitzt; jetzt traegt sie
auch etwas. Schmal bleibt der Zoom in der ersten Zeile, sonst waere die
Leiste am Telefon dreizeilig geworden.

Strg (oder Cmd) + Mausrad zoomt das Dokument. Der Zuhoerer wird von Hand
angemeldet: React meldet Rad-Ereignisse passiv an, und passiv darf man
preventDefault nicht rufen - ohne das zoomt der Browser die ganze Seite
statt des Dokuments.

Beim Rad wird der Punkt unter dem Zeiger festgehalten statt der Seite.
Gemessen: Seite 2 bleibt ueber zwei Zoomstufen unter dem Zeiger.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-25 13:02:06 +02:00
1 parent 3d9a4832f9
commit edefce3572
2 files changed
+128 -29

No files matched your search

+20
View File
@@ -1422,6 +1422,26 @@ Dateien bleiben markiert, statt die Auswahl zu verlieren.
auf dem Handy bleibt der Knopf der Weg
- [ ] Mit vielen Dateien (50+) auf der produktiven Instanz gegenprüfen
## PDF: Strg + Mausrad, Zoom nach rechts (24.08.2026)
| Prüfung | Gemessen |
|---|---|
| Zoomgruppe breit | rechts außen, 1138–1262 px bei 1280 px Fenster |
| Suchfeld bleibt mittig | Mitte 640, Fenstermitte 640 |
| Strg + Rad hoch | 100 % → 125 % |
| Strg + Rad runter (2×) | → 75 % |
| Rad **ohne** Strg | unverändert 75 %, blättert nur |
| `preventDefault` gerufen | ja – sonst zoomte der Browser die ganze Seite |
| Punkt unter dem Zeiger | bleibt: Seite 2 vor und nach zwei Zoomstufen, Bildlauf 1400 → 2250 |
| Schmal (412 px) | Zoom in der **ersten** Zeile neben der Seitenzahl, zweizeilig wie vorher |
Beim Rad wird der Punkt unter dem Zeiger festgehalten, nicht die Seite: Man
zeigt auf eine Stelle und will, dass sie dort bleibt. Die Knöpfe halten
weiterhin die Seite – dort gibt es keinen Zeiger, auf den man sich beziehen
könnte.
- [ ] Am Trackpad gegenprüfen (Zwei-Finger-Zoom meldet sich als Strg + Rad)
## Automatisiert geprüft (22.08.2026)
Diese Punkte hat Claude per API/`curl` gegen Wegwerf-Konten durchgespielt; sie
+108 -29
View File
@@ -596,22 +596,104 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
* einem Zoomschritt am Anfang eines fremden Blattes.
*/
const keepPage = useRef<number | null>(null)
/**
* Beim Zoomen mit dem Mausrad wird nicht die Seite festgehalten, sondern der
* Punkt unter dem Zeiger: Man zeigt auf eine Stelle im Text und will, dass
* sie dort bleibt, wo der Finger hindeutet – nicht, dass der Seitenanfang
* anspringt.
*/
const keepAnchor = useRef<{ px: number; py: number; cx: number; cy: number; from: number } | null>(null)
useEffect(() => {
const anchor = keepAnchor.current
if (anchor) {
keepAnchor.current = null
const root = scrollRef.current
if (root) {
const ratio = effScale / anchor.from
root.scrollTop = Math.max(0, anchor.py * ratio - anchor.cy)
root.scrollLeft = Math.max(0, anchor.px * ratio - anchor.cx)
}
return
}
const n = keepPage.current
if (n === null) return
keepPage.current = null
goToPage(n)
}, [effScale, goToPage])
function zoom(dir: 1 | -1) {
const now = effScale
const next = dir > 0
/** Die nächste Stufe in eine Richtung, oder undefined am Ende der Leiter. */
function nextStep(now: number, dir: 1 | -1): number | undefined {
return dir > 0
? zoomSteps.find(s => s > now + 0.001)
: [...zoomSteps].reverse().find(s => s < now - 0.001)
}
function zoom(dir: 1 | -1) {
const next = nextStep(effScale, dir)
if (next) { keepPage.current = current; setScale(next) }
}
/**
* Strg + Mausrad zoomt – wie in jedem Betrachter, und wie in InkNote.
*
* Von Hand angemeldet statt über onWheel: React meldet Rad-Ereignisse
* passiv an, und ein passiver Zuhörer darf `preventDefault` nicht rufen.
* Ohne das zoomt der **Browser** die ganze Seite, statt dass wir das
* Dokument zoomen – Leiste und Oberfläche würden mitwachsen.
*/
useEffect(() => {
const root = scrollRef.current
if (!root) return
function onWheel(e: WheelEvent) {
// Auch die Meta-Taste: Auf einem Mac ist sie die übliche.
if (!e.ctrlKey && !e.metaKey) return
e.preventDefault()
const root2 = scrollRef.current
if (!root2) return
const dir: 1 | -1 = e.deltaY < 0 ? 1 : -1
const next = nextStep(effScale, dir)
if (!next) return
const r = root2.getBoundingClientRect()
const cx = e.clientX - r.left
const cy = e.clientY - r.top
keepAnchor.current = {
px: root2.scrollLeft + cx, py: root2.scrollTop + cy,
cx, cy, from: effScale,
}
setScale(next)
}
root.addEventListener('wheel', onWheel, { passive: false })
return () => root.removeEventListener('wheel', onWheel)
}, [effScale])
const pages = useMemo(() => sizes.map((_, i) => i + 1), [sizes])
/** Verkleinern, Prozentzahl (zugleich „einpassen"), Vergrößern. Einmal
* beschrieben, an zwei Stellen gezeigt – breit rechts außen, schmal in der
* ersten Zeile. */
const zoomGroup = (
<>
<ToolButton label="Zoom out" onClick={() => zoom(-1)} glyph="minus" />
{/* Zurück zur eingepassten Größe. Bewusst kein Umschalter zwischen
„Breite" und „100 %": Seit die Einpassung bei 100 % gedeckelt ist,
sind das am Schreibtisch oft dieselbe Zahl – ein Knopf, der nichts
tut, ist schlimmer als keiner. */}
<button
onClick={() => { keepPage.current = current; setScale('fit') }}
disabled={scale === 'fit'}
title="Fit width"
style={{
fontSize: 12, color: 'var(--color-text-muted)', minWidth: 52, flexShrink: 0,
border: 'none', background: 'none',
cursor: scale === 'fit' ? 'default' : 'pointer',
fontVariantNumeric: 'tabular-nums',
}}
>
{Math.round(effScale * 100)}%
</button>
<ToolButton label="Zoom in" onClick={() => zoom(1)} glyph="plus" />
</>
)
/**
* Reicht die Breite für drei Spalten? Unterhalb bricht die Leiste um, und
* ein Raster ergäbe eine gequetschte Suche neben gequetschten Knöpfen.
@@ -692,28 +774,12 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
</span>
<ToolButton label="Next page" disabled={!doc || current >= doc.numPages}
onClick={() => goToPage(current + 1)} glyph="down" />
<span style={{ width: 1, height: 20, flexShrink: 0, background: 'var(--color-border)' }} />
<ToolButton label="Zoom out" onClick={() => zoom(-1)} glyph="minus" />
{/* Zurück zur eingepassten Größe. Bewusst kein Umschalter zwischen
„Breite" und „100 %": Seit die Einpassung bei 100 % gedeckelt ist,
sind das am Schreibtisch oft dieselbe Zahl – ein Knopf, der nichts
tut, ist schlimmer als keiner. */}
<button
onClick={() => { keepPage.current = current; setScale('fit') }}
disabled={scale === 'fit'}
title="Fit width"
style={{
fontSize: 12, color: 'var(--color-text-muted)', minWidth: 52, flexShrink: 0,
border: 'none', background: 'none',
cursor: scale === 'fit' ? 'default' : 'pointer',
fontVariantNumeric: 'tabular-nums',
}}
>
{Math.round(effScale * 100)}%
</button>
<ToolButton label="Zoom in" onClick={() => zoom(1)} glyph="plus" />
{barNarrow && (
<>
<span style={{ width: 1, height: 20, flexShrink: 0, background: 'var(--color-border)' }} />
{zoomGroup}
</>
)}
</div>
{/* Suche im Dokument – die mittlere Spalte. Sie ist der Grund, aus dem
@@ -786,10 +852,23 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
</span>
</div>
{/* Die leere dritte Spalte. Sie trägt nichts und ist trotzdem nötig:
Ohne sie hätte das Raster nur zwei Spalten, und „Mitte" wäre
wieder die Mitte des Restes. */}
{!barNarrow && <div />}
{/* Die dritte Spalte: der Zoom, rechtsbündig – dieselbe Anordnung wie
in InkNote. Die Spalte war ohnehin nötig, damit „Mitte" die Mitte
der Leiste ist und nicht die Mitte des Restes; jetzt trägt sie
auch etwas.
Schmal steht der Zoom **nicht** hier, sondern oben neben der
Seitenzahl: Als eigene Zeile wäre die Leiste am Telefon dreizeilig
geworden – gemessen 190 statt 140 Pixel, und die gehen dem
Dokument ab. */}
{!barNarrow && (
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
justifyContent: 'flex-end', minWidth: 0,
}}>
{zoomGroup}
</div>
)}
</div>
{/* Seiten */}