feat(aether): initial commit – self-hosted Cloud, Phasen 1–7

Go-Backend (chi, pgx, golang-migrate) + React/TS-Frontend als PWA.
Enthalten: Auth inkl. OIDC, Dateien mit Versionen und Papierkorb,
Freigaben (Links + intern), WebDAV, Gruppenordner, Admin/Theming,
Volltextsuche mit OCR, eigener PDF-Betrachter und WOPI-Office
(Collabora/OnlyOffice).

Nicht im Repo: deploy/.env (Sitzungsschlüssel, DB- und Admin-Passwort)
sowie alle Daten- und Build-Verzeichnisse – siehe .gitignore.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Opus 5 committed 2026-08-24 09:25:36 +02:00
commit 31a027c93a
225 files changed
+38042

No files matched your search

+957
View File
@@ -0,0 +1,957 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { filesApi } from '../api/client'
import * as pdfjs from 'pdfjs-dist'
import type { PDFDocumentProxy, PDFPageProxy } from 'pdfjs-dist'
import workerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'
import './pdf.css'
/**
* Eigener PDF-Betrachter.
*
* Vorher stand hier ein <iframe> mit dem eingebauten Betrachter des Browsers.
* Der ist gut, aber er gehört uns nicht – wir können ihm nichts sagen und
* nichts von ihm erfahren. Zwei Gründe haben den Wechsel getragen:
*
* 1. **Am Handy.** Chrome auf Android zeigt ein PDF in einem <iframe> nicht
* an, sondern bietet es zum Herunterladen an. Für ein Archiv, in das man
* beim Reklamieren im Laden hineinsieht, war die Vorschau dort also
* schlicht nicht vorhanden.
* 2. **Der Suchtreffer.** Die Volltextsuche findet ein Wort auf Seite 7 eines
* Scans – der fremde Betrachter beginnt trotzdem auf Seite 1, und man
* sucht von Hand weiter. Ihm lässt sich kein Suchwort vorgeben.
*
* Was er absichtlich *nicht* kann: Anmerkungen, Formulare, Unterschriften.
* Das Original wird in dieser Anwendung nie verändert – ein Betrachter, der
* schreiben kann, wäre ein Widerspruch dazu.
*/
// Der Arbeiter kommt gebündelt aus node_modules und wird vom eigenen Server
// ausgeliefert. Kein CDN: Die Sicherheitsrichtlinie erlaubt nur 'self', und
// eine Cloud für private Unterlagen soll beim Öffnen eines Dokuments keine
// fremde Adresse anfragen.
pdfjs.GlobalWorkerOptions.workerSrc = workerUrl
/**
* Die 14 Standardschriften (Helvetica, Times, Courier …). Ältere PDFs betten
* sie nicht ein, sondern setzen sie voraus. Fehlen sie, malt PDF.js mit einer
* Ersatzschrift – die Seite ist lesbar, aber Zeilen brechen an anderer Stelle.
* Kopiert wird der Ordner beim Bauen, siehe vite.config.ts.
*/
const standardFontDataUrl = '/pdfjs/standard_fonts/'
/** Zoomstufen des Knopfes. „Fit" liegt außerhalb und wird gerechnet. */
const zoomSteps = [0.5, 0.75, 1, 1.25, 1.5, 2, 3]
/**
* Innenabstand der Seitenliste. Am Telefon fast keiner: Dort ist die Breite
* das knappe Gut, und ein 16-Pixel-Rand links und rechts kostet zusammen mehr
* als ein Zehntel der Darstellungsgröße.
*/
function padOf(width: number): number {
return width < 560 ? 4 : 16
}
/**
* Ab welcher Breite die Leiste als Dreispaltenraster taugt.
*
* Gerechnet, nicht geschätzt: Die Steuerung links braucht 275 Pixel
* (zwei Knöpfe, Seitenzahl, Trenner, Zoom), die Suche in der Mitte 492 – und
* die rechte Spalte ist genauso breit wie die linke, sonst säße die Suche
* nicht mittig. Macht 2 × 275 + 492.
*
* **Vorher stand hier 720, und dazwischen war die Leiste kaputt:** Das Raster
* gab der linken Spalte weniger, als ihr Inhalt braucht, und die Knöpfe
* schrumpften mit. Gemessen bei 980 Pixeln Fensterbreite – erster Knopf 16
* statt 28 Pixel, bei 760 die ganze Gruppe auf 108 statt 275. Am Ende war von
* der Steuerung nichts mehr zu sehen.
*/
const minGridWidth = 2 * 275 + 492
/**
* Breiten rechts vom Suchfeld – und damit das Maß für das Gegengewicht links.
* Beide Seiten gleich breit heißt: Das Feld sitzt in der Mitte der Leiste.
*/
const counterWidth = 54
const hitButtonsWidth = 60
/** Zähler + Knöpfe + die beiden Abstände dazwischen. */
const hitsWidth = counterWidth + hitButtonsWidth + 6
/** Abstand zwischen zwei Seiten, in Pixeln. Auch der Wert, mit dem gerechnet
* wird, wenn die sichtbare Seite bestimmt wird. */
const pageGap = 12
interface Props {
/** Adresse der Datei – dieselbe, die auch das <iframe> bekam. */
url: string
/** ID derselben Datei – für den erkannten Text vom Server. */
fileId: string
/**
* Suchwort aus der Volltextsuche. Ist es gesetzt, springt der Betrachter
* beim Öffnen zur ersten Fundstelle, statt auf Seite 1 zu beginnen.
*/
searchTerm?: string
}
/**
* Eine Fundstelle: Seite und die wievielte auf dieser Seite.
*
* `exact` unterscheidet die beiden Quellen. Bringt das PDF eigenen Text mit,
* steht die Stelle im Dokument und lässt sich einfärben. Bei einem **Scan**
* steht dort nur ein Bild; der Text kommt dann aus der Erkennung auf dem
* Server, und der weiß zwar die Seite, aber nicht den Ort auf dem Blatt –
* dafür müssten die Koordinaten der Erkennung aufbewahrt werden.
*/
interface Hit { page: number; nth: number; exact: boolean }
export default function PdfViewer({ url, fileId, searchTerm }: Props) {
const [doc, setDoc] = useState<PDFDocumentProxy | null>(null)
const [error, setError] = useState('')
const [sizes, setSizes] = useState<{ w: number; h: number }[]>([])
const [scale, setScale] = useState<number | 'fit'>('fit')
const [fitScale, setFitScale] = useState(1)
const [current, setCurrent] = useState(1)
const [query, setQuery] = useState(searchTerm ?? '')
const [hits, setHits] = useState<Hit[]>([])
const [hitIndex, setHitIndex] = useState(0)
const [searching, setSearching] = useState(false)
/** Erkannter Text vom Server, einmal je Datei geholt. Ohne den Zwischen-
* speicher fragte jeder Tastendruck im Suchfeld erneut an. */
const serverText = useRef<string[] | null>(null)
useEffect(() => { serverText.current = null }, [fileId])
const scrollRef = useRef<HTMLDivElement>(null)
const pageRefs = useRef<(HTMLDivElement | null)[]>([])
const effScale = scale === 'fit' ? fitScale : scale
// ---------------------------------------------------------------- Laden
useEffect(() => {
let alive = true
let task: ReturnType<typeof pdfjs.getDocument> | null = null
setDoc(null); setSizes([]); setError(''); setCurrent(1)
task = pdfjs.getDocument({
url,
// Same-Origin, die Sitzung steckt im Cookie – ohne das käme 401.
withCredentials: true,
standardFontDataUrl,
})
task.promise.then(
async d => {
// Beim Verlassen raeumt die Aufraeumfunktion die Ladeaufgabe ab; die
// nimmt Arbeiter und Dokument mit. Hier genuegt es, nichts mehr zu
// setzen.
if (!alive) return
// Die Maße *aller* Seiten vorab: Sonst müsste die Liste mit einer
// geschätzten Höhe beginnen und beim Zeichnen springen. Ein Dokument
// mit gemischtem Hoch- und Querformat ist bei Scans der Normalfall.
const s: { w: number; h: number }[] = []
for (let i = 1; i <= d.numPages; i++) {
const v = (await d.getPage(i)).getViewport({ scale: 1 })
s.push({ w: v.width, h: v.height })
}
if (!alive) return
setSizes(s)
setDoc(d)
},
(err: unknown) => {
if (!alive) return
setError(
err instanceof Error && /password/i.test(err.message)
? 'This PDF is password protected.'
: 'Could not open this PDF.',
)
},
)
return () => {
alive = false
task?.destroy()
}
}, [url])
// ------------------------------------------------------- Breite einpassen
/**
* „Fit" heißt: **auf die Breite, aber nie größer als das Original.**
*
* Zwei Anläufe, beide falsch, beide erst am echten Dokument sichtbar:
*
* 1. *Nur auf die Breite.* Die Vorschau ist fast bildschirmbreit, ein
* A4-Blatt füllte sie bei 199 % – man sah das obere Drittel.
* 2. *Auf die ganze Seite, also gegen beide Kanten.* Klingt richtig und
* ist es für A4 auch. Bei einem **langen** Blatt – ein Merkzettel, eine
* Rechnung mit vierzig Posten auf einer Seite – kam dabei **10 %**
* heraus: Schriftgröße 1,4 Pixel. Gemessen an einem echten Dokument.
*
* Die Höhe taugt also nicht als Grenze, weil eine Seite beliebig lang sein
* darf. Die Breite taugt, denn sie ist bei jedem Papier ähnlich. Bleibt das
* Aufblähen zu verhindern: Über 100 % wird nichts vergrößert – das Original
* ist so gesetzt, dass man es lesen kann.
*
* Am Telefon greift die Grenze von selbst nicht: Dort ist der Bildschirm
* schmaler als das Blatt, die Breite also unter 100 %.
*
* Gerechnet wird gegen die **häufigste** Seitenbreite, nicht gegen die
* breiteste – warum, steht bei `commonWidth`.
*/
const recomputeFit = useCallback(() => {
const el = scrollRef.current
if (!el || sizes.length === 0) return
// Eine Messung von (fast) null ist keine Messung, sondern ein Zeitpunkt:
// Der Beobachter meldet sich auch, während die Vorschau noch aufgeht und
// der Bereich keine Breite hat. Früher lieferte das die Untergrenze von
// 10 % – und weil danach keine Größenänderung mehr kam, blieb das Dokument
// dabei stehen. Gemessen an einem Dokument, das mal mit 96 %, mal mit
// 10 % aufging, je nachdem wie schnell der Rechner gerade war.
if (el.clientWidth < 50) return
// 16 für die Bildlaufleiste, die je nach Browser Breite wegnimmt.
const byWidth = (el.clientWidth - 2 * padOf(el.clientWidth) - 16) / commonWidth(sizes)
setFitScale(Math.max(0.1, Math.min(1, byWidth)))
}, [sizes])
useEffect(() => {
recomputeFit()
const el = scrollRef.current
if (!el) return
const ro = new ResizeObserver(recomputeFit)
ro.observe(el)
return () => ro.disconnect()
}, [recomputeFit])
// ------------------------------------------------------------- Zeichnen
/**
* Gezeichnet wird nur, was in die Nähe des Sichtfelds kommt.
*
* Ein 200-seitiger Scan hätte sonst 200 Leinwände im Speicher – bei A4 und
* doppelter Pixeldichte sind das je rund 8 MB. Der Beobachter meldet
* Seiten eine Bildschirmhöhe im Voraus an; wieder verlassene Seiten geben
* ihre Leinwand frei.
*/
useEffect(() => {
if (!doc || sizes.length === 0) return
const root = scrollRef.current
if (!root) return
// Läuft eine Zeichnung noch, wenn sich der Maßstab ändert, muss sie
// abgebrochen werden – sonst malt sie in der alten Größe fertig.
const running = new Map<number, ReturnType<PDFPageProxy['render']>>()
const drawn = new Set<number>()
async function draw(n: number) {
const host = pageRefs.current[n - 1]
if (!doc || !host || drawn.has(n)) return
drawn.add(n)
const page = await doc.getPage(n)
const viewport = page.getViewport({ scale: effScale })
const dpr = Math.min(window.devicePixelRatio || 1, 2)
const canvas = document.createElement('canvas')
canvas.width = Math.floor(viewport.width * dpr)
canvas.height = Math.floor(viewport.height * dpr)
canvas.style.width = '100%'
canvas.style.height = '100%'
canvas.style.display = 'block'
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.scale(dpr, dpr)
const task = page.render({ canvas, canvasContext: ctx, viewport })
running.set(n, task)
try {
await task.promise
} catch {
drawn.delete(n) // abgebrochen – beim nächsten Mal neu
return
} finally {
running.delete(n)
}
host.querySelector('canvas')?.remove()
host.prepend(canvas)
// Textebene darüber: unsichtbare, aber markierbare Schrift. Sie ist es
// auch, in der die Suche ihre Fundstellen färbt.
const layer = host.querySelector<HTMLDivElement>('.pdf-textLayer')
if (layer) {
layer.replaceChildren()
const tl = new pdfjs.TextLayer({
textContentSource: await page.getTextContent(),
container: layer,
viewport,
})
await tl.render()
host.dataset.text = 'ready'
host.dispatchEvent(new CustomEvent('pdf-text-ready', { bubbles: true }))
}
}
function release(n: number) {
running.get(n)?.cancel()
running.delete(n)
drawn.delete(n)
const host = pageRefs.current[n - 1]
if (!host) return
host.querySelector('canvas')?.remove()
host.querySelector('.pdf-textLayer')?.replaceChildren()
host.querySelector('.pdf-hitLayer')?.replaceChildren()
delete host.dataset.text
}
const io = new IntersectionObserver(entries => {
for (const e of entries) {
const n = Number((e.target as HTMLElement).dataset.page)
if (e.isIntersecting) void draw(n)
else release(n)
}
}, { root, rootMargin: '100% 0px' })
for (const host of pageRefs.current) if (host) io.observe(host)
return () => {
io.disconnect()
for (const t of running.values()) t.cancel()
}
}, [doc, sizes, effScale])
// --------------------------------------------------- Sichtbare Seitenzahl
useEffect(() => {
const root = scrollRef.current
if (!root || sizes.length === 0) return
function onScroll() {
const mid = root!.scrollTop + root!.clientHeight / 2
let acc = 0
for (let i = 0; i < sizes.length; i++) {
const h = sizes[i].h * effScale + pageGap
if (mid < acc + h) { setCurrent(i + 1); return }
acc += h
}
setCurrent(sizes.length)
}
root.addEventListener('scroll', onScroll, { passive: true })
onScroll()
return () => root.removeEventListener('scroll', onScroll)
}, [sizes, effScale])
/**
* Zu einer Seite fahren – **mit** dem Rand darüber.
*
* Vorher stand hier `scrollIntoView({ block: 'start' })`. Das legt die
* Oberkante der Seite genau auf die Oberkante des Rollbereichs und frisst
* dabei den Innenabstand auf. Sichtbar wurde das beim Suchen: Man tippt,
* und das Blatt rutscht um 16 Pixel nach oben, klebt am Fensterrand – ein
* Ruck, für den es keinen Grund gibt.
*
* Jetzt wird von Hand gerollt und der Rand abgezogen. Die Seite steht dann
* dort, wo sie auch beim Öffnen steht.
*/
const goToPage = useCallback((n: number) => {
const root = scrollRef.current
const host = pageRefs.current[n - 1]
if (!root || !host) return
const r = root.getBoundingClientRect()
const h = host.getBoundingClientRect()
root.scrollTo({ top: Math.max(0, root.scrollTop + (h.top - r.top) - padOf(root.clientWidth)) })
}, [])
/**
* Zu einer Fundstelle rollen – **nur wenn nötig**.
*
* Der zweite Teil desselben Rucks: Jeder Tastendruck im Suchfeld ließ die
* Anzeige zur Fundstelle springen, auch wenn die längst zu sehen war. Wer
* „la" tippt, hat nach dem ersten Buchstaben schon eine andere Trefferliste,
* und das Blatt zuckte bei jedem Zeichen.
*
* Steht der Treffer im mittleren Bereich, passiert jetzt gar nichts.
*/
const revealHit = useCallback((el: Element) => {
const root = scrollRef.current
if (!root) return
const r = root.getBoundingClientRect()
const e = el.getBoundingClientRect()
const margin = 60
if (e.top >= r.top + margin && e.bottom <= r.bottom - margin) return
root.scrollTo({ top: Math.max(0, root.scrollTop + (e.top - r.top) - root.clientHeight / 3) })
}, [])
// ----------------------------------------------------------------- Suche
/**
* Gesucht wird im *ganzen* Dokument auf einmal, nicht nur auf der
* gezeichneten Seite: Nur so lässt sich „3 von 7" anzeigen und gezielt
* weiterspringen. Der Text kommt dabei nicht aus der Datenbank, sondern
* aus dem PDF selbst – nur dort steht, an welcher *Stelle* er steht.
*/
useEffect(() => {
const q = query.trim().toLowerCase()
if (!doc || q.length < 2) { setHits([]); setHitIndex(0); return }
let alive = true
setSearching(true)
;(async () => {
const found: Hit[] = []
for (let n = 1; n <= doc.numPages; n++) {
if (!alive) return
const tc = await doc.getPage(n).then(p => p.getTextContent())
// Zusammengesetzt wie in der Anzeige, damit ein über zwei Stücke
// getrenntes Wort trotzdem zählt.
const text = tc.items
.map(it => ('str' in it ? it.str : ''))
.join('')
.toLowerCase()
found.push(...countIn(text, q, n, true))
}
if (!alive) return
// Nichts gefunden? Dann liegt hier womöglich ein **Scan**, in dem gar
// kein Text steht. Der erkannte Text vom Server ist die einzige Quelle,
// die dann etwas weiß – ausgerechnet für den Dateityp, für den die
// Texterkennung überhaupt gebaut wurde.
//
// Nur als Rückfall, nicht immer: Steht der Text im PDF, ist er dort
// genauer verortet, und die Fundstelle lässt sich einfärben.
if (found.length === 0) {
try {
if (!serverText.current) {
serverText.current = (await filesApi.textPages(fileId)).pages
}
if (!alive) return
serverText.current.forEach((page, i) => {
found.push(...countIn(page.toLowerCase(), q, i + 1, false))
})
} catch {
// Kein Text vorhanden oder Erkennung abgeschaltet: dann eben keine
// Treffer. Eine Fehlermeldung wäre hier nur im Weg.
}
}
if (!alive) return
setHits(found)
setHitIndex(0)
setSearching(false)
})()
return () => { alive = false; setSearching(false) }
}, [doc, query, fileId])
/**
* Färbt die Fundstellen einer Seite.
*
* **Nicht** im Text selbst, sondern als eigene Ebene aus Kästchen darüber.
* Der erste Anlauf setzte ein <mark> mitten in die unsichtbare Schrift –
* und genau das war der Grund für die schiefen, oben angeschnittenen
* Streifen:
*
* Ein <mark> ist ein Textstück. Sein Grund ist so hoch, wie die Schrift
* ihn vorgibt (Oberlänge + Unterlänge der **Ersatz**schrift), nicht so
* hoch wie die Zeile. Gemessen: 23 Pixel Kasten auf 17 Pixel Zeile. Der
* Überstand ragte nach oben aus der Textebene heraus – und die schneidet
* ab (`overflow: clip`). Daher „abgeschnitten und nicht gerade".
*
* Jetzt kommt die Geometrie **vollständig** aus der Auswahl – genau die
* Kästen, die der Browser auch beim Markieren mit der Maus malt. Das darf
* er jetzt auch: Unsere Ebene schneidet nichts ab, das war ja allein die
* Textebene.
*
* Und markiert wird das **ganze Wort**, nicht der Buchstabenschnipsel –
* warum, steht bei `wordAround` unten.
*
* Nebenbei bleibt die Schrift unberührt: Markieren und Kopieren gehen
* währenddessen weiter, was beim Umbauen des Textes nicht mehr sicher war.
*
* Grenze, die ich in Kauf nehme: Ein Wort, das PDF.js über zwei Stücke
* verteilt, wird gezählt, aber nicht eingefärbt. Bei erkanntem Text (OCR)
* ist ein Wort praktisch immer ein Stück.
*/
const paint = useCallback((n: number, activeNth: number) => {
const host = pageRefs.current[n - 1]
const layer = host?.querySelector<HTMLDivElement>('.pdf-hitLayer')
if (!host || !layer) return
layer.replaceChildren()
const q = query.trim().toLowerCase()
if (q.length < 2) return
const base = host.getBoundingClientRect()
let nth = 0
/** Zuletzt gezeichnetes Wort – gegen doppelte Kästen (siehe unten). */
let last: { at: number; to: number; boxes: HTMLElement[] } | null = null
for (const span of host.querySelectorAll<HTMLElement>('.pdf-textLayer span')) {
const text = span.textContent ?? ''
const low = text.toLowerCase()
if (!low.includes(q)) continue
const node = span.firstChild
if (!node || node.nodeType !== Node.TEXT_NODE) continue
last = null
let from = 0
for (;;) {
const at = low.indexOf(q, from)
if (at < 0) break
const [ws, we] = wordAround(text, at, at + q.length)
const cur = nth === activeNth
from = at + q.length
nth++
// „la" findet in einem Wort wie „Salatlöffel" zweimal dieselbe Stelle
// zum Malen. Statt zwei deckungsgleicher Kästen: den vorhandenen
// behalten und höchstens zum aktuellen Treffer aufwerten.
if (last && last.at === ws && last.to === we) {
if (cur) last.boxes.forEach(b => b.classList.add('pdf-hit-current'))
continue
}
const range = document.createRange()
range.setStart(node, ws)
range.setEnd(node, we)
const boxes: HTMLElement[] = []
for (const r of range.getClientRects()) {
const box = document.createElement('div')
box.className = 'pdf-hit' + (cur ? ' pdf-hit-current' : '')
box.style.left = `${r.left - base.left}px`
box.style.top = `${r.top - base.top}px`
box.style.width = `${r.width}px`
box.style.height = `${r.height}px`
layer.append(box)
boxes.push(box)
}
last = { at: ws, to: we, boxes }
}
}
}, [query])
/**
* Leerräumen, sobald das Suchfeld leer ist.
*
* Ohne das blieb die letzte Färbung stehen: `paint` stieg bei kurzem
* Suchwort vorne aus und kam nie zum Aufräumen, und ohne Treffer läuft es
* gar nicht erst. Auf dem Blatt lagen dann Streifen zu einem Wort, das im
* Feld längst nicht mehr stand.
*
* Steht vor den beiden Effekten, die färben – innerhalb einer Änderung
* laufen Effekte in der Reihenfolge ihrer Notierung ab, also erst räumen,
* dann neu färben.
*/
useEffect(() => {
for (const host of pageRefs.current) {
host?.querySelector('.pdf-hitLayer')?.replaceChildren()
}
}, [query, hits])
// Zur aktuellen Fundstelle springen und sie färben. Ist die Seite noch
// nicht gezeichnet, wartet das auf ihre Textebene – deshalb das Ereignis.
const target = hits[hitIndex]
useEffect(() => {
if (!target) return
const host = pageRefs.current[target.page - 1]
if (!host) return
// Nur zur Seite fahren, wenn sie **noch nicht gezeichnet** ist. Ist sie da,
// übernimmt revealHit weiter unten – und das rollt nur, wenn der Treffer
// wirklich außerhalb liegt. Ein `goToPage` an dieser Stelle sprang bei
// jedem Tastendruck an den Seitenanfang, obwohl der Treffer schon zu
// sehen war.
if (host.dataset.text !== 'ready' || !target.exact) goToPage(target.page)
// Gescrollt wird **hier**, nicht in paint(): Die Färbung läuft auch dann,
// wenn eine Seite bloß neu gezeichnet wurde – etwa nach dem Zoomen. Stand
// das Scrollen darin, sprang die Anzeige bei jedem Zoomschritt zurück zur
// Fundstelle, obwohl man längst woanders las. (Gemessen: von Seite 4
// zurück auf Seite 3.)
const show = () => {
// Ein Treffer aus dem erkannten Text kennt nur die Seite. Dann bleibt
// es beim Sprung an den Seitenanfang – ein Wort einzufärben, das wir
// nicht verorten können, hieße raten.
if (!target.exact) return
paint(target.page, target.nth)
const el = host.querySelector('.pdf-hit-current')
if (el) revealHit(el)
}
if (host.dataset.text === 'ready') { show(); return }
host.addEventListener('pdf-text-ready', show, { once: true })
return () => host.removeEventListener('pdf-text-ready', show)
}, [target, goToPage, revealHit, paint])
// Frisch gezeichnete Seiten bekommen ihre Färbung nach – sonst wären beim
// Blättern nur die Treffer der Seite markiert, auf der man gelandet ist.
useEffect(() => {
const root = scrollRef.current
if (!root || hits.length === 0) return
function onReady(e: Event) {
const host = (e.target as HTMLElement)
const n = Number(host.dataset.page)
paint(n, target && target.page === n ? target.nth : -1)
}
root.addEventListener('pdf-text-ready', onReady)
return () => root.removeEventListener('pdf-text-ready', onReady)
}, [hits.length, target, paint])
// Kommt der Betrachter aus einem Suchtreffer, steht das Wort schon da.
useEffect(() => { setQuery(searchTerm ?? '') }, [searchTerm, url])
// ------------------------------------------------------------------ Zoom
/**
* Beim Zoomen ändern sich alle Seitenhöhen – die Bildlaufposition zeigt
* danach irgendwohin. Gemerkt wird deshalb die Seite, auf der man gerade
* war, und nach dem Umbau wieder angesteuert. Ohne das landet man nach
* einem Zoomschritt am Anfang eines fremden Blattes.
*/
const keepPage = useRef<number | null>(null)
useEffect(() => {
const n = keepPage.current
if (n === null) return
keepPage.current = null
goToPage(n)
}, [effScale, goToPage])
function zoom(dir: 1 | -1) {
const now = effScale
const next = dir > 0
? zoomSteps.find(s => s > now + 0.001)
: [...zoomSteps].reverse().find(s => s < now - 0.001)
if (next) { keepPage.current = current; setScale(next) }
}
const pages = useMemo(() => sizes.map((_, i) => i + 1), [sizes])
/**
* Reicht die Breite für drei Spalten? Unterhalb bricht die Leiste um, und
* ein Raster ergäbe eine gequetschte Suche neben gequetschten Knöpfen.
*/
const barRef = useRef<HTMLDivElement>(null)
const [barNarrow, setBarNarrow] = useState(false)
useEffect(() => {
const el = barRef.current
if (!el) return
// Dieselbe Vorsicht wie bei der Einpassung: Breite null heißt „noch nicht
// sichtbar", nicht „schmal". Ohne die Prüfung stand die Leiste beim
// Aufgehen der Vorschau im Umbruch-Zustand und blieb dort.
const set = () => { if (el.clientWidth >= 50) setBarNarrow(el.clientWidth < minGridWidth) }
set()
const ro = new ResizeObserver(set)
ro.observe(el)
return () => ro.disconnect()
}, [])
const [pad, setPad] = useState(16)
useEffect(() => {
const el = scrollRef.current
if (!el) return
const set = () => { if (el.clientWidth >= 50) setPad(padOf(el.clientWidth)) }
set()
const ro = new ResizeObserver(set)
ro.observe(el)
return () => ro.disconnect()
}, [doc])
// ----------------------------------------------------------------- Anzeige
if (error) {
return (
<div style={{ color: 'var(--color-text-muted)', fontSize: 14, textAlign: 'center' }}>
{error}
</div>
)
}
return (
<div style={{
width: '100%', height: '100%', display: 'flex', flexDirection: 'column',
borderRadius: 8, overflow: 'hidden', background: 'var(--color-surface)',
}}>
{/*
Werkzeugleiste.
Am Schreibtisch ein **Dreispaltenraster**: Steuerung links, Suche in
der Mitte, rechts eine leere Spalte gleicher Breite. Erst dadurch sitzt
die Suche wirklich mittig – mit zwei Abstandhaltern in einer Reihe
teilen sich diese nur den *Rest* neben der Steuerung, und das Feld
landete gemessen 79 bzw. 142 Pixel daneben.
Am Telefon reicht die Breite für drei Spalten nicht; dort bleibt es
beim Umbruch, und die Suche steht in der zweiten Zeile.
*/}
<div
ref={barRef}
style={{
display: barNarrow ? 'flex' : 'grid',
gridTemplateColumns: barNarrow ? undefined : 'minmax(0, 1fr) auto minmax(0, 1fr)',
alignItems: 'center', gap: 8, flexWrap: 'wrap',
padding: '6px 10px', flexShrink: 0,
borderBottom: '1px solid var(--color-border)',
background: 'var(--color-surface)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
<ToolButton label="Previous page" disabled={current <= 1}
onClick={() => goToPage(current - 1)} glyph="up" />
<span style={{
fontSize: 12, color: 'var(--color-text-muted)',
minWidth: 62, flexShrink: 0,
textAlign: 'center', fontVariantNumeric: 'tabular-nums',
}}>
{doc ? `${current} / ${doc.numPages}` : '…'}
</span>
<ToolButton label="Next page" disabled={!doc || current >= doc.numPages}
onClick={() => goToPage(current + 1)} glyph="down" />
<span style={{ width: 1, height: 20, flexShrink: 0, background: 'var(--color-border)' }} />
<ToolButton label="Zoom out" onClick={() => zoom(-1)} glyph="minus" />
{/* Zurück zur eingepassten Größe. Bewusst kein Umschalter zwischen
„Breite" und „100 %": Seit die Einpassung bei 100 % gedeckelt ist,
sind das am Schreibtisch oft dieselbe Zahl – ein Knopf, der nichts
tut, ist schlimmer als keiner. */}
<button
onClick={() => { keepPage.current = current; setScale('fit') }}
disabled={scale === 'fit'}
title="Fit width"
style={{
fontSize: 12, color: 'var(--color-text-muted)', minWidth: 52, flexShrink: 0,
border: 'none', background: 'none',
cursor: scale === 'fit' ? 'default' : 'pointer',
fontVariantNumeric: 'tabular-nums',
}}
>
{Math.round(effScale * 100)}%
</button>
<ToolButton label="Zoom in" onClick={() => zoom(1)} glyph="plus" />
</div>
{/* Suche im Dokument – die mittlere Spalte. Sie ist der Grund, aus dem
es diesen Betrachter gibt; rechts an den Rand gedrängt sah sie aus
wie ein Zusatz, der nicht recht dazugehört. */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{/* Gegengewicht. Rechts vom Feld stehen Zähler und Sprungknöpfe;
ohne diesen Ausgleich säße die Gruppe zwar mittig, das **Feld**
aber 79 Pixel daneben – gemessen. Mittig soll das sein, was man
ansieht, nicht der Kasten drumherum.
Am Telefon fällt es weg: Dort gibt es nichts zu zentrieren, und
120 Pixel Leere hätten ein Drittel der Zeile gekostet – während
rechts der zweite Sprungknopf aus dem Bild fiel. */}
{!barNarrow && <span style={{ width: hitsWidth, flexShrink: 0 }} />}
<input
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault()
if (hits.length) setHitIndex(i => (i + (e.shiftKey ? -1 : 1) + hits.length) % hits.length)
}
// Escape gehört hier dem Feld, nicht dem Schließen der Vorschau.
if (e.key === 'Escape' && query) { e.stopPropagation(); setQuery('') }
}}
placeholder="Find in document"
style={{
// Breiter als die 150 vom Anfang: In der Mitte einer 1440 Pixel
// breiten Leiste sah ein Feld in Knopfbreite aus wie ein
// Restposten. Ein Suchbegriff wie „Zahlungsbedingungen" passt
// jetzt auch hinein, ohne zu wandern.
width: 240, minWidth: 0,
padding: '5px 8px', borderRadius: 8, fontSize: 12,
border: '1px solid var(--color-border)',
background: 'var(--color-background)', color: 'var(--color-text)',
outline: 'none',
}}
/>
<span style={{
fontSize: 11, color: 'var(--color-text-muted)',
// Feste Breite, nicht Mindestbreite: Sonst wüchse die rechte Seite
// mit „Searching…" und das Feld rutschte beim Tippen hin und her.
width: counterWidth, flexShrink: 0,
overflow: 'hidden', whiteSpace: 'nowrap',
fontVariantNumeric: 'tabular-nums',
}}>
{searching ? 'Searching…'
: query.trim().length < 2 ? ''
: hits.length === 0 ? 'No hits'
: `${hitIndex + 1} / ${hits.length}${hits[0] && !hits[0].exact ? ' · page' : ''}`}
</span>
{/* Erst zeigen, wenn es etwas zu springen gibt. Ohne Suche stand
hier ein zweites Paar Winkel neben dem zum Blättern – zwei
gleich aussehende Knopfpaare für zwei ganz verschiedene Dinge. */}
{/* Der Platz für die beiden Knöpfe steht immer bereit, auch wenn
sie fehlen. Sonst rückte das Suchfeld beim ersten Treffer um
knapp dreißig Pixel zur Seite – ausgerechnet während man darin
tippt. Leerer Raum fällt niemandem auf, ein wanderndes Feld
schon. */}
<span style={{ width: hitButtonsWidth, flexShrink: 0, display: 'flex', gap: 4 }}>
{hits.length > 0 && (
<>
<ToolButton label="Previous hit" glyph="hitUp"
onClick={() => setHitIndex(i => (i - 1 + hits.length) % hits.length)} />
<ToolButton label="Next hit" glyph="hitDown"
onClick={() => setHitIndex(i => (i + 1) % hits.length)} />
</>
)}
</span>
</div>
{/* Die leere dritte Spalte. Sie trägt nichts und ist trotzdem nötig:
Ohne sie hätte das Raster nur zwei Spalten, und „Mitte" wäre
wieder die Mitte des Restes. */}
{!barNarrow && <div />}
</div>
{/* Seiten */}
<div
ref={scrollRef}
style={{
flex: 1, minHeight: 0, overflow: 'auto',
padding: pad, background: 'var(--color-background)',
// Auf dem Handy zieht man das Dokument mit dem Finger; die
// Vorschau darunter darf dabei nicht mitwandern.
overscrollBehavior: 'contain',
}}
>
{!doc && (
<div style={{ color: 'var(--color-text-muted)', fontSize: 13, textAlign: 'center' }}>
Loading…
</div>
)}
{pages.map(n => (
<div
key={n}
data-page={n}
ref={el => { pageRefs.current[n - 1] = el }}
style={{
position: 'relative',
width: sizes[n - 1].w * effScale,
height: sizes[n - 1].h * effScale,
margin: `0 auto ${pageGap}px`,
background: '#fff',
boxShadow: '0 2px 10px rgba(0,0,0,0.4)',
// Die Textebene rechnet Schriftgrößen gegen diesen Wert.
['--total-scale-factor' as string]: String(effScale),
}}
>
<div className="pdf-textLayer" />
{/* Fundstellen der Suche. Eigene Ebene über der Schrift, damit
sie nichts am Text ändert und nichts abgeschnitten wird. */}
<div className="pdf-hitLayer" />
</div>
))}
</div>
</div>
)
}
/**
* Die häufigste Seitenbreite eines Dokuments.
*
* Vorher stand hier die **breiteste** Seite, mit dem Gedanken: Unterschiedlich
* breite Seiten unterschiedlich zu skalieren sieht nach Fehler aus. Das
* stimmt – nur ist der Preis höher als der Gewinn.
*
* Gemessen an einem zehnseitigen Dokument, in dem **eine** Seite quer liegt:
* Die neun hochkanten Seiten standen dadurch auf 46 % statt 65 %. Auf einem
* 412 Pixel breiten Telefon ist das der Unterschied zwischen lesbar und nicht.
* Eine einzelne Querformat-Seite – ein Anhang, eine breite Tabelle – bestimmt
* so die Darstellung des ganzen Dokuments.
*
* Jetzt entscheidet die Mehrheit. Die abweichende Seite ragt dann über den
* Rand und lässt sich seitlich schieben; das ist genau das, was man bei ihr
* ohnehin tun will.
*
* Bei Gleichstand gewinnt die breitere – dann ragt lieber nichts heraus.
*/
function commonWidth(sizes: { w: number; h: number }[]): number {
const count = new Map<number, number>()
for (const s of sizes) {
const w = Math.round(s.w)
count.set(w, (count.get(w) ?? 0) + 1)
}
let best = 0, bestN = 0
for (const [w, n] of count) {
if (n > bestN || (n === bestN && w > best)) { best = w; bestN = n }
}
return best || 1
}
/**
* Weitet eine Fundstelle auf das **ganze Wort** aus.
*
* Der Grund ist gemessen, nicht Geschmack. Über dem gemalten Bild liegt eine
* unsichtbare *Ersatz*schrift – hier `sans-serif 13,5px` für eine
* Serifenschrift im PDF. PDF.js dehnt sie über `--scale-x` (gemessen 0,937),
* damit die **Zeile insgesamt** stimmt. Die Buchstaben *darin* sitzen deshalb
* um ein bis drei Pixel anders als die gemalten.
*
* Bei einem ganzen Wort fällt das nicht auf. Bei „la" mitten in „Display"
* schon: Der Kasten ist zehn Pixel breit, und drei Pixel Versatz sehen aus,
* als läge er auf den falschen Buchstaben. Genau das war zu sehen.
*
* Gegenprobe im Browser: Unser Kasten und die Auswahl, die der Browser beim
* Markieren mit der Maus malt, liegen auf demselben Pixel (357,08 / 10,38).
* Der Versatz ist also nicht unser Fehler – er lässt sich nur nicht
* wegrechnen, ohne die Schrift des Dokuments zu besitzen. Ihn zu *umgehen*
* geht: ganze Wörter markieren, deren Ränder an Wortgrenzen liegen.
*
* Sehr lange „Wörter" (Adressen, Prüfsummen) bleiben ungedehnt – sonst wäre
* eine halbe Zeile eingefärbt, weil irgendwo darin zwei Buchstaben passten.
*/
const maxWordSnap = 40
function wordAround(text: string, at: number, to: number): [number, number] {
const isWord = (c: string) => /[\p{L}\p{N}]/u.test(c)
let s = at, e = to
while (s > 0 && isWord(text[s - 1])) s--
while (e < text.length && isWord(text[e])) e++
return e - s > maxWordSnap ? [at, to] : [s, e]
}
/** Zählt die Fundstellen eines Suchworts in einem Stück Text. */
function countIn(haystack: string, needle: string, page: number, exact: boolean): Hit[] {
const out: Hit[] = []
let from = 0, nth = 0
for (;;) {
const at = haystack.indexOf(needle, from)
if (at < 0) break
out.push({ page, nth: nth++, exact })
from = at + needle.length
}
return out
}
/**
* Die Knöpfe der Werkzeugleiste steuern **zwei verschiedene** Dinge: links
* die Seite, rechts die Fundstelle. Beide bewegen sich nach oben und unten –
* und mit demselben Winkel-Zeichen sahen beide Paare gleich aus. Wer die
* Leiste zum ersten Mal sieht, kann nicht wissen, welches welches ist.
*
* Deshalb zwei Formen: der schlanke Winkel blättert Seiten, der Pfeil mit
* Schaft springt zur nächsten Fundstelle. Der Pfeil erscheint ohnehin erst,
* wenn es Fundstellen gibt.
*/
const glyphs: Record<string, string> = {
up: '18 15 12 9 6 15',
down: '6 9 12 15 18 9',
}
function ToolButton({ label, onClick, disabled, glyph }: {
label: string; onClick: () => void; disabled?: boolean
glyph: 'up' | 'down' | 'plus' | 'minus' | 'hitUp' | 'hitDown'
}) {
return (
<button
onClick={onClick}
disabled={disabled}
title={label}
aria-label={label}
style={{
width: 28, height: 28, flexShrink: 0, borderRadius: 8, border: 'none',
background: 'none', color: 'var(--color-text-muted)',
cursor: disabled ? 'default' : 'pointer', opacity: disabled ? 0.35 : 1,
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0,
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2" strokeLinecap="round">
{glyph === 'plus' && <><line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" /></>}
{glyph === 'minus' && <line x1="5" y1="12" x2="19" y2="12" />}
{(glyph === 'up' || glyph === 'down') && <polyline points={glyphs[glyph]} />}
{glyph === 'hitUp' && <><line x1="12" y1="19" x2="12" y2="6" /><polyline points="6 12 12 6 18 12" /></>}
{glyph === 'hitDown' && <><line x1="12" y1="5" x2="12" y2="18" /><polyline points="6 12 12 18 18 12" /></>}
</svg>
</button>
)
}