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])
+}