Files
Aether/frontend/src/components/PdfViewer.tsx
T
HoneyandClaude Opus 5 31a027c93a 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>
2026-08-24 09:25:36 +02:00

958 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}