feat(pdf): Zoom in festen Fünferschritten statt in fünf Prozent

Gemeint waren runde Zahlen: 65, 70, 75, 80 … Ein fester Faktor ist
rechnerisch die sauberere Größe – er fühlt sich unten wie oben gleich an
–, aber abzulesen war er nicht: Die Anzeige sprang um drei Punkte, dann
um vier, dann um sechs. Wer eine Zahl sieht, erwartet ein Raster.

Weil fünf Punkte bei 25 % ein Fünftel und bei 400 % ein Hundertstel
sind, kann die Schwelle der Geste nicht mehr fest sein. Verlangt wird
jetzt genau die Fingerbewegung, die dem nächsten Schritt entspricht,
mit zwei Prozent als Untergrenze gegen Zittern.

Ein Fehler, den erst die Messung zeigte: 1,1 × 100 ergibt in Fließkomma
110.00000000000001, und die "nächste kleinere Fünferzahl" davon ist
wieder 110. Der Zoom blieb deshalb nach genau einer Stufe stehen –
sichtbar nur beim Zuziehen, weil es aufwärts eine Stufe später gegriffen
hätte. Vor der Rechnung wird jetzt gerundet.

Gemessen, eine lange Geste in jede Richtung:
  auf:  65 → 70 → 75 → 80 → 85 → 90 → 95 → 100 → 105 → 110 → 115 → 120 → 125 → 130
  zu:   125 → 120 → 115 → 110 → 105 → 100 → 95 → 90 → 85 → 80 → 75

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-09-01 00:57:31 +02:00
1 parent ded1134f6a
commit 065372359b
1 file changed
+52 -28
+52 -28
View File
@@ -43,24 +43,19 @@ const standardFontDataUrl = '/pdfjs/standard_fonts/'
const zoomSteps = [0.5, 0.75, 1, 1.25, 1.5, 2, 3]
/**
* Stufenweite für **stufenloses** Zoomen: Finger und Mausrad gehen in
* Zwanzigsteln, nicht auf der Leiter der Knöpfe.
* Zoomschritt für Finger und Mausrad: **fünf Prozentpunkte**, auf runde
* Zahlen gerastet – 65, 70, 75, 80 …
*
* Der Unterschied ist gewollt. Ein Klick soll etwas bewegen – von 100 auf
* 105 % zu springen wäre ein Knopf, den man zehnmal drückt. Eine Geste
* dagegen ist ein Regler: Dort sind Viertelsprünge zu grob, man schießt über
* die Größe hinaus, die man eigentlich wollte.
* Zuerst war es ein fester Faktor (fünf Prozent *vom aktuellen Wert*).
* Rechnerisch ist das die sauberere Größe: Sie fühlt sich unten wie oben
* gleich an. Abzulesen war sie aber nicht – die Anzeige sprang um drei
* Punkte, dann um vier, dann um sechs. Wer eine Zahl sieht, erwartet ein
* Raster, und das hier ist eines.
*
* Multiplikativ statt in festen Prozentpunkten, weil Zoomen relativ ist: Von
* 50 auf 55 % ist ein Zehntel mehr, von 300 auf 305 % ein Sechzigstel. Fünf
* Prozent *vom aktuellen Wert* fühlen sich überall gleich an.
*
* **Achtung beim Ablesen:** Die angezeigte Zahl springt deshalb nicht um
* feste fünf Punkte. Bei 65 % sind fünf Prozent gut drei Punkte, bei 200 %
* sind es zehn. Erst 1,1 war zu grob (gemessen bei 65 % ein Sprung von sieben
* Punkten, kurz darauf von elf).
* Der Preis steht dazu: Bei 400 % ist ein Schritt gut ein Prozent, bei 25 %
* ein Fünftel. Das gleicht die Geste aus, nicht die Rasterung – siehe unten.
*/
const zoomFactor = 1.05
const zoomStepPercent = 5
const minScale = 0.25
const maxScale = 4
@@ -655,13 +650,28 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
: [...zoomSteps].reverse().find(s => s < now - 0.001)
}
/** Ein Zehntel weiter, für Finger und Mausrad. Undefined am Anschlag. */
/**
* Die nächste runde Fünferzahl, für Finger und Mausrad. Undefined am
* Anschlag.
*
* Gerechnet wird in Prozentpunkten und nicht in Bruchteilen: Der Nutzer
* liest Prozente, und ein Raster, das nur *fast* auf 70 trifft, ist keins.
* Aus einer krummen Ausgangsgröße – „einpassen" ergibt zum Beispiel 65,3 %
* – führt der erste Schritt auf die nächste runde Zahl, danach geht es in
* Fünfern weiter.
*/
function nextFine(now: number, dir: 1 | -1): number | undefined {
const raw = dir > 0 ? now * zoomFactor : now / zoomFactor
// Auf ganze Prozent gerundet: Sonst stünden 71,50000000000001 % in der
// Leiste, und zwei Wege zum selben Zoom ergäben zwei verschiedene Zahlen.
const next = Math.round(Math.min(maxScale, Math.max(minScale, raw)) * 100) / 100
return Math.abs(next - now) < 0.005 ? undefined : next
// **Gerundet, bevor gerechnet wird.** 1,1 × 100 ergibt in Fließkomma
// 110.00000000000001, und daraus wird die „nächste kleinere Fünferzahl"
// wieder 110 – der Zoom blieb dann stehen. Gemessen: Beim Zuziehen kam
// genau eine Stufe, danach nichts mehr.
const p = Math.round(now * 100 * 1e6) / 1e6
const step = zoomStepPercent
const raw = dir > 0
? Math.floor(p / step + 1) * step
: Math.ceil(p / step - 1) * step
const next = Math.min(maxScale * 100, Math.max(minScale * 100, raw)) / 100
return Math.abs(next - now) < 0.001 ? undefined : next
}
function zoom(dir: 1 | -1) {
@@ -750,17 +760,31 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
e.preventDefault()
const now = distance(e.touches)
const ratio = now / base
// Die Schwelle folgt der Stufenweite: Wandern die Finger um eine
// Stufe auseinander, gibt es eine Stufe. Bliebe sie größer, ließe sich
// die feinere Weite gar nicht mehr treffen.
const dir: 1 | -1 | 0 = ratio > 1.04 ? 1 : ratio < 0.96 ? -1 : 0
const dir: 1 | -1 | 0 = ratio > 1 ? 1 : ratio < 1 ? -1 : 0
if (!dir) return
pinchBase.current = now
const root2 = scrollRef.current
if (!root2) return
const from = scaleRef.current
const next = nextFine(from, dir)
if (!next) return
/**
* **Die Finger geben den Maßstab vor, nicht eine feste Schwelle.**
*
* Fünf Prozentpunkte sind bei 25 % ein Fünftel und bei 400 % ein
* Hundertstel. Eine feste Schwelle wäre deshalb an einem Ende zu träge
* und am anderen zu nervös: Von 100 auf 400 zu ziehen dauerte ewig,
* unten spränge es bei der kleinsten Bewegung.
*
* Verlangt wird stattdessen genau die Bewegung, die dem nächsten
* Schritt entspricht – zwei Prozent Fingerweg als Untergrenze, damit
* ein Zittern in der Hand nicht schon zoomt.
*/
const needed = next / from
if (dir > 0 && ratio < Math.max(1.02, needed)) return
if (dir < 0 && ratio > Math.min(0.98, needed)) return
pinchBase.current = now
const root2 = scrollRef.current
if (!root2) return
// Festgehalten wird die Mitte zwischen den Fingern – dieselbe Regel wie
// beim Mausrad, nur dass der Zeiger hier aus zwei Punkten entsteht.
const r = root2.getBoundingClientRect()