diff --git a/frontend/src/components/FilePreview.tsx b/frontend/src/components/FilePreview.tsx index 5298cb2..4614e62 100644 --- a/frontend/src/components/FilePreview.tsx +++ b/frontend/src/components/FilePreview.tsx @@ -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 (
{ 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 – diff --git a/frontend/src/components/PdfViewer.tsx b/frontend/src/components/PdfViewer.tsx index 026a6c9..4657a9c 100644 --- a/frontend/src/components/PdfViewer.tsx +++ b/frontend/src/components/PdfViewer.tsx @@ -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 && ( diff --git a/frontend/src/components/Shell.tsx b/frontend/src/components/Shell.tsx index bed1d68..9cafe91 100644 --- a/frontend/src/components/Shell.tsx +++ b/frontend/src/components/Shell.tsx @@ -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) diff --git a/frontend/src/hooks/useBackClose.ts b/frontend/src/hooks/useBackClose.ts new file mode 100644 index 0000000..19e6ea7 --- /dev/null +++ b/frontend/src/hooks/useBackClose.ts @@ -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]) +}