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:
1 parent
face318131
commit
574ba3e6c5
5 files changed
+207
-2
No files matched your search
@@ -1,6 +1,7 @@
|
||||
import { lazy, Suspense, useEffect, useState, useSyncExternalStore } from 'react'
|
||||
import { filesApi, APIError, type FileItem } from '../api/client'
|
||||
import { fileKind } from './filetypes'
|
||||
import { useBackClose } from '../hooks/useBackClose'
|
||||
|
||||
/**
|
||||
* Nachgeladen, nicht mitgeliefert: PDF.js wiegt rund eine halbe Megabyte im
|
||||
@@ -163,11 +164,17 @@ export default function FilePreview({ file, onPrev, onNext, onClose, onSaved, se
|
||||
}
|
||||
}
|
||||
|
||||
function tryClose() {
|
||||
if (dirty && !confirm('Discard your changes?')) return
|
||||
function tryClose(): boolean {
|
||||
if (dirty && !confirm('Discard your changes?')) return false
|
||||
onClose()
|
||||
return true
|
||||
}
|
||||
|
||||
// Zurück-Geste und Zurück-Taste schließen die Vorschau, statt die Anwendung
|
||||
// zu verlassen. Wichtig in der installierten App: Dort gibt es keinen
|
||||
// anderen Weg zurück.
|
||||
useBackClose({ active: true, onClose: tryClose })
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={tryClose}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { officeApi, type FileItem, type OfficeSession } from '../api/client'
|
||||
import { useBackClose } from '../hooks/useBackClose'
|
||||
|
||||
/**
|
||||
* Ein Dokument im Office-Server bearbeiten.
|
||||
@@ -41,6 +42,12 @@ export default function OfficeEditor({ file, onClose }: {
|
||||
// ein Schritt, der nichts entscheidet.
|
||||
useEffect(() => { if (session) formRef.current?.submit() }, [session])
|
||||
|
||||
// Die Zurück-Geste schließt das Dokument. Auf dem Telefon ist sie die
|
||||
// gewohnte Bewegung, und Collabora blendet seinen eigenen Schließen-Knopf
|
||||
// auf kleinen Schirmen aus – ohne das käme man aus dem Editor nur über die
|
||||
// Kopfzeile heraus.
|
||||
useBackClose({ active: true, onClose })
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
// Escape schließt nur, solange der Rahmen nicht den Tastendruck hat –
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -3,6 +3,7 @@ import { filesApi } from '../api/client'
|
||||
import type { FolderItem, User } from '../api/client'
|
||||
import { useNavHistory, type View } from '../hooks/useNavHistory'
|
||||
import { useEdgeSwipe } from '../hooks/useEdgeSwipe'
|
||||
import { useBackClose } from '../hooks/useBackClose'
|
||||
import Sidebar from './Sidebar'
|
||||
import FileBrowser from '../pages/FileBrowser'
|
||||
import TrashPage from '../pages/TrashPage'
|
||||
@@ -34,6 +35,14 @@ export default function Shell({ user, onLogout, onUserChanged }: Props) {
|
||||
onOpen: useCallback(() => setSidebarOpen(true), []),
|
||||
onClose: useCallback(() => setSidebarOpen(false), []),
|
||||
})
|
||||
// Ist die Leiste am Telefon aufgeschoben, holt die Zurück-Geste sie
|
||||
// zurück – sie ist dann das Oberste auf dem Schirm, und genau darauf zielt
|
||||
// die Bewegung.
|
||||
useBackClose({
|
||||
active: sidebarOpen,
|
||||
onClose: useCallback(() => { setSidebarOpen(false) }, []),
|
||||
})
|
||||
|
||||
// Hochgezählt, wenn etwas auf den Papierkorb gezogen wurde – die Dateiliste
|
||||
// hängt daran und holt sich daraufhin neu.
|
||||
const [trashBump, setTrashBump] = useState(0)
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
/**
|
||||
* Die Zurück-Taste schließt die oberste bildschirmfüllende Ansicht, statt die
|
||||
* Anwendung zu verlassen.
|
||||
*
|
||||
* Auf einem Telefon ist „zurück" die Geste vom Rand, und in der installierten
|
||||
* App ist sie der einzige Weg nach draußen – ohne Adresszeile, ohne Reiter.
|
||||
* Wer ein Bild ansieht und zurückwischt, meint das Bild. Bisher fiel er aus
|
||||
* der Anwendung, weil Vorschau und Editor im Verlauf des Browsers gar nicht
|
||||
* vorkamen: Der einzige Eintrag war die Dateiliste, und der davor war der
|
||||
* Startbildschirm.
|
||||
*
|
||||
* Der Kniff ist ein **leerer Eintrag** im Verlauf, der beim Öffnen entsteht.
|
||||
* Die Adresse bleibt dabei gleich – die Vorschau ist kein Ort, auf den ein
|
||||
* Lesezeichen zeigen soll. Zurück verbraucht diesen Eintrag, wir schließen,
|
||||
* und der Ort darunter ist unverändert derselbe.
|
||||
*
|
||||
* **Der Stapel ist nötig, nicht schön:** Liegt der Office-Editor über der
|
||||
* Vorschau, hören beide auf dieselbe Zurück-Taste. Ohne Rangfolge schlössen
|
||||
* sich beide auf einmal. Deshalb weiß jeder Eintrag, der wievielte er ist,
|
||||
* und nur der oberste reagiert.
|
||||
*/
|
||||
|
||||
/** Kennungen der offenen Ansichten, unterste zuerst. */
|
||||
let stack: number[] = []
|
||||
let seq = 0
|
||||
|
||||
/** Wir merken uns unseren Eintrag im State des Browsers, um ihn beim
|
||||
* Zurückkommen wiederzuerkennen. */
|
||||
const stateKey = 'aether:overlay'
|
||||
|
||||
interface Options {
|
||||
/** Ist die Ansicht offen? Der Eintrag entsteht beim Wechsel auf `true`. */
|
||||
active: boolean
|
||||
/**
|
||||
* Schließen. Gibt die Funktion `false` zurück, bleibt die Ansicht offen –
|
||||
* dann legen wir den Eintrag wieder an, sonst führte die nächste
|
||||
* Zurück-Geste aus der Anwendung heraus. Das ist der Fall „ungespeicherte
|
||||
* Änderungen verwerfen?" mit „Abbrechen".
|
||||
*/
|
||||
onClose: () => boolean | void
|
||||
}
|
||||
|
||||
export function useBackClose({ active, onClose }: Options) {
|
||||
// Über eine Referenz gelesen: Der Aufrufer gibt bei jedem Rendern eine neue
|
||||
// Funktion herein, und der Eintrag darf davon nicht neu entstehen.
|
||||
const close = useRef(onClose)
|
||||
close.current = onClose
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return
|
||||
|
||||
const id = ++seq
|
||||
stack.push(id)
|
||||
// Den bisherigen State mitnehmen: Darin steckt die Ansicht der Anwendung
|
||||
// (siehe useNavHistory). Ginge er verloren, stünde man nach dem
|
||||
// Schließen in der Wurzel statt im Ordner.
|
||||
window.history.pushState({ ...window.history.state, [stateKey]: id }, '')
|
||||
|
||||
function push() {
|
||||
window.history.pushState({ ...window.history.state, [stateKey]: id }, '')
|
||||
}
|
||||
|
||||
function onPop() {
|
||||
// Nur der oberste reagiert. Ein tiefer liegender Eintrag hat seinen
|
||||
// eigenen Zurück-Schritt noch vor sich.
|
||||
if (stack.at(-1) !== id) return
|
||||
stack = stack.filter(x => x !== id)
|
||||
if (close.current() === false) {
|
||||
stack.push(id)
|
||||
push()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('popstate', onPop)
|
||||
return () => {
|
||||
window.removeEventListener('popstate', onPop)
|
||||
// Geschlossen wurde über einen Knopf oder die Escape-Taste: Unser
|
||||
// Eintrag steht noch im Verlauf und muss weg, sonst bräuchte die
|
||||
// Zurück-Geste danach einen Schritt ins Leere.
|
||||
if (stack.at(-1) === id) {
|
||||
stack = stack.filter(x => x !== id)
|
||||
window.history.back()
|
||||
} else {
|
||||
stack = stack.filter(x => x !== id)
|
||||
}
|
||||
}
|
||||
}, [active])
|
||||
}
|
||||
Reference in new issue
Block a user