feat(mobile): Zurück-Geste schließt die Ansicht, zwei Finger zoomen das PDF

Zwei Dinge, die am Telefon fehlten – beide in der installierten App
besonders schmerzhaft, weil es dort keine Adresszeile als Rückweg gibt.

1. Zurück schloss bisher die Anwendung, nicht die Vorschau. Vorschau,
   Office-Editor und die aufgeschobene Seitenleiste kamen im Verlauf des
   Browsers gar nicht vor: Der einzige Eintrag war die Dateiliste. Jetzt
   legt jede bildschirmfüllende Ansicht beim Öffnen einen leeren Eintrag
   an (die Adresse bleibt gleich – eine Vorschau ist kein Ort für ein
   Lesezeichen) und verbraucht ihn beim Schließen wieder, egal ob über
   die Geste, Escape oder den Knopf. Ein Stapel sorgt dafür, dass bei
   übereinanderliegenden Ansichten nur die oberste reagiert.

   Collabora blendet auf kleinen Schirmen seinen eigenen Schließen-Knopf
   aus (closeButtonEnabled && !isSmallScreenDevice) – ohne diesen Weg
   käme man aus einem Dokument am Telefon nur über die Kopfzeile heraus.

2. Die Zusammenziehgeste vergrößerte die ganze Seite statt des PDFs:
   Leiste und Knöpfe wuchsen mit, das Dokument blieb grob. touch-action
   nimmt dem Browser die Geste, ein eigener Zuhörer setzt unsere
   Zoomstufen an ihre Stelle, verankert an der Mitte zwischen den
   Fingern – dieselbe Regel wie bei Strg+Mausrad.

Gemessen in Chromium mit Touch-Emulation, 412x900:
  Vorschau offen -> Verlauf 2->3, Zurück -> Vorschau zu, Anwendung bleibt
  Esc -> Eintrag wieder weg (ein weiteres Zurück verlässt die App)
  Aufziehen 65% -> 125%, Zuziehen -> 50%, visualViewport.scale bleibt 1

Ein Befund aus dieser Messung steckt im Code: Zuerst kam pro Geste nur
eine Stufe, weil sich der Zuhörer bei jeder Zoomänderung neu anmeldete
und der gemerkte Fingerabstand dabei verlorenging. Abstand und Maßstab
liegen deshalb in Referenzen, der Zuhörer meldet sich einmal an.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-31 20:25:59 +02:00
1 parent face318131
commit 574ba3e6c5
5 files changed
+207 -2

No files matched your search

+92
View File
@@ -603,6 +603,11 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
* anspringt.
*/
const keepAnchor = useRef<{ px: number; py: number; cx: number; cy: number; from: number } | null>(null)
/** Fingerabstand, gegen den die Zoomgeste misst. Null heißt: keine Geste. */
const pinchBase = useRef(0)
/** Der aktuelle Maßstab, aus einem Zuhörer heraus lesbar. */
const scaleRef = useRef(effScale)
scaleRef.current = effScale
useEffect(() => {
const anchor = keepAnchor.current
if (anchor) {
@@ -666,6 +671,89 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
return () => root.removeEventListener('wheel', onWheel)
}, [effScale])
/**
* Zwei Finger zoomen das Dokument – nicht die Seite.
*
* Bisher vergrößerte die Zusammenziehgeste die **Anwendung**: Leiste,
* Knöpfe und Suchfeld wuchsen mit, das PDF blieb so grob wie vorher, und
* hinterher stand alles verschoben. Das ist der Grund, aus dem am Telefon
* bisher nur die Knöpfe blieben.
*
* Zwei Teile gehören dazu, und einer allein genügt nicht: `touch-action`
* unten am Behälter nimmt dem Browser die eigene Zoomgeste (Ziehen mit
* einem Finger bleibt erlaubt), und dieser Zuhörer setzt an ihre Stelle
* unsere Stufen.
*
* **Stufen statt stufenlos.** Jede Zwischengröße hieße, alle sichtbaren
* Seiten neu zu zeichnen – auf einem Telefon zwanzigmal während einer
* einzigen Geste. Deshalb rastet der Zoom auf dieselbe Leiter wie die
* Knöpfe, sobald der Abstand der Finger um ein Viertel gewachsen oder um
* ein Fünftel geschrumpft ist. Danach zählt der neue Abstand als Anfang,
* eine lange Geste geht also über mehrere Stufen weiter.
*/
useEffect(() => {
const root = scrollRef.current
if (!root) return
function distance(t: TouchList) {
const dx = t[0].clientX - t[1].clientX
const dy = t[0].clientY - t[1].clientY
return Math.hypot(dx, dy)
}
function onStart(e: TouchEvent) {
pinchBase.current = e.touches.length === 2 ? distance(e.touches) : 0
}
function onMove(e: TouchEvent) {
const base = pinchBase.current
if (e.touches.length !== 2 || !base) return
// Verhindert, dass die Geste nebenbei die Liste weiterschiebt.
e.preventDefault()
const now = distance(e.touches)
const ratio = now / base
const dir: 1 | -1 | 0 = ratio > 1.25 ? 1 : ratio < 0.8 ? -1 : 0
if (!dir) return
pinchBase.current = now
const root2 = scrollRef.current
if (!root2) return
const from = scaleRef.current
const next = nextStep(from, dir)
if (!next) 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()
const cx = (e.touches[0].clientX + e.touches[1].clientX) / 2 - r.left
const cy = (e.touches[0].clientY + e.touches[1].clientY) / 2 - r.top
keepAnchor.current = {
px: root2.scrollLeft + cx, py: root2.scrollTop + cy,
cx, cy, from,
}
setScale(next)
}
function onEnd(e: TouchEvent) {
if (e.touches.length < 2) pinchBase.current = 0
}
// Nicht passiv: Ein passiver Zuhörer dürfte `preventDefault` nicht rufen.
root.addEventListener('touchstart', onStart, { passive: true })
root.addEventListener('touchmove', onMove, { passive: false })
root.addEventListener('touchend', onEnd, { passive: true })
root.addEventListener('touchcancel', onEnd, { passive: true })
return () => {
root.removeEventListener('touchstart', onStart)
root.removeEventListener('touchmove', onMove)
root.removeEventListener('touchend', onEnd)
root.removeEventListener('touchcancel', onEnd)
}
// Ohne Abhängigkeiten: Der Zuhörer meldet sich **einmal** an. Hinge er am
// Maßstab, meldete er sich nach jeder Stufe neu an – mitten in der Geste,
// und der gemerkte Fingerabstand wäre weg. Gemessen: Eine lange Geste kam
// dann über genau eine Stufe nicht hinaus.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const pages = useMemo(() => sizes.map((_, i) => i + 1), [sizes])
/** Verkleinern, Prozentzahl (zugleich „einpassen"), Vergrößern. Einmal
@@ -880,6 +968,10 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
// Auf dem Handy zieht man das Dokument mit dem Finger; die
// Vorschau darunter darf dabei nicht mitwandern.
overscrollBehavior: 'contain',
// Ziehen ja, Zoomen nein: Die Zusammenziehgeste gehört uns (siehe
// den Zuhörer oben), sonst vergrößerte der Browser die ganze Seite
// samt Leiste und Knöpfen.
touchAction: 'pan-x pan-y',
}}
>
{!doc && (