Files
Aether/frontend/src/components/PdfViewer.tsx
T
marcandClaude Opus 5 065372359b feat(pdf): Zoom in festen Fünferschritten statt in fünf Prozent
Gemeint waren runde Zahlen: 65, 70, 75, 80 … Ein fester Faktor ist
rechnerisch die sauberere Größe – er fühlt sich unten wie oben gleich an
–, aber abzulesen war er nicht: Die Anzeige sprang um drei Punkte, dann
um vier, dann um sechs. Wer eine Zahl sieht, erwartet ein Raster.

Weil fünf Punkte bei 25 % ein Fünftel und bei 400 % ein Hundertstel
sind, kann die Schwelle der Geste nicht mehr fest sein. Verlangt wird
jetzt genau die Fingerbewegung, die dem nächsten Schritt entspricht,
mit zwei Prozent als Untergrenze gegen Zittern.

Ein Fehler, den erst die Messung zeigte: 1,1 × 100 ergibt in Fließkomma
110.00000000000001, und die "nächste kleinere Fünferzahl" davon ist
wieder 110. Der Zoom blieb deshalb nach genau einer Stufe stehen –
sichtbar nur beim Zuziehen, weil es aufwärts eine Stufe später gegriffen
hätte. Vor der Rechnung wird jetzt gerundet.

Gemessen, eine lange Geste in jede Richtung:
  auf:  65 → 70 → 75 → 80 → 85 → 90 → 95 → 100 → 105 → 110 → 115 → 120 → 125 → 130
  zu:   125 → 120 → 115 → 110 → 105 → 100 → 95 → 90 → 85 → 80 → 75

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 00:57:31 +02:00

1194 lines
48 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 der **Knöpfe**. „Fit" liegt außerhalb und wird gerechnet. */
const zoomSteps = [0.5, 0.75, 1, 1.25, 1.5, 2, 3]
/**
* Zoomschritt für Finger und Mausrad: **fünf Prozentpunkte**, auf runde
* Zahlen gerastet – 65, 70, 75, 80 …
*
* Zuerst war es ein fester Faktor (fünf Prozent *vom aktuellen Wert*).
* Rechnerisch ist das die sauberere Größe: Sie fühlt sich unten wie oben
* gleich an. Abzulesen war sie aber nicht – die Anzeige sprang um drei
* Punkte, dann um vier, dann um sechs. Wer eine Zahl sieht, erwartet ein
* Raster, und das hier ist eines.
*
* Der Preis steht dazu: Bei 400 % ist ein Schritt gut ein Prozent, bei 25 %
* ein Fünftel. Das gleicht die Geste aus, nicht die Rasterung – siehe unten.
*/
const zoomStepPercent = 5
const minScale = 0.25
const maxScale = 4
/**
* 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)
/**
* Beim Zoomen mit dem Mausrad wird nicht die Seite festgehalten, sondern der
* Punkt unter dem Zeiger: Man zeigt auf eine Stelle im Text und will, dass
* sie dort bleibt, wo der Finger hindeutet – nicht, dass der Seitenanfang
* 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) {
keepAnchor.current = null
const root = scrollRef.current
if (root) {
const ratio = effScale / anchor.from
root.scrollTop = Math.max(0, anchor.py * ratio - anchor.cy)
root.scrollLeft = Math.max(0, anchor.px * ratio - anchor.cx)
}
return
}
const n = keepPage.current
if (n === null) return
keepPage.current = null
goToPage(n)
}, [effScale, goToPage])
/** Die nächste Stufe in eine Richtung, oder undefined am Ende der Leiter. */
function nextStep(now: number, dir: 1 | -1): number | undefined {
return dir > 0
? zoomSteps.find(s => s > now + 0.001)
: [...zoomSteps].reverse().find(s => s < now - 0.001)
}
/**
* Die nächste runde Fünferzahl, für Finger und Mausrad. Undefined am
* Anschlag.
*
* Gerechnet wird in Prozentpunkten und nicht in Bruchteilen: Der Nutzer
* liest Prozente, und ein Raster, das nur *fast* auf 70 trifft, ist keins.
* Aus einer krummen Ausgangsgröße – „einpassen" ergibt zum Beispiel 65,3 %
* – führt der erste Schritt auf die nächste runde Zahl, danach geht es in
* Fünfern weiter.
*/
function nextFine(now: number, dir: 1 | -1): number | undefined {
// **Gerundet, bevor gerechnet wird.** 1,1 × 100 ergibt in Fließkomma
// 110.00000000000001, und daraus wird die „nächste kleinere Fünferzahl"
// wieder 110 – der Zoom blieb dann stehen. Gemessen: Beim Zuziehen kam
// genau eine Stufe, danach nichts mehr.
const p = Math.round(now * 100 * 1e6) / 1e6
const step = zoomStepPercent
const raw = dir > 0
? Math.floor(p / step + 1) * step
: Math.ceil(p / step - 1) * step
const next = Math.min(maxScale * 100, Math.max(minScale * 100, raw)) / 100
return Math.abs(next - now) < 0.001 ? undefined : next
}
function zoom(dir: 1 | -1) {
const next = nextStep(effScale, dir)
if (next) { keepPage.current = current; setScale(next) }
}
/**
* Strg + Mausrad zoomt – wie in jedem Betrachter, und wie in InkNote.
*
* Von Hand angemeldet statt über onWheel: React meldet Rad-Ereignisse
* passiv an, und ein passiver Zuhörer darf `preventDefault` nicht rufen.
* Ohne das zoomt der **Browser** die ganze Seite, statt dass wir das
* Dokument zoomen – Leiste und Oberfläche würden mitwachsen.
*/
useEffect(() => {
const root = scrollRef.current
if (!root) return
function onWheel(e: WheelEvent) {
// Auch die Meta-Taste: Auf einem Mac ist sie die übliche.
if (!e.ctrlKey && !e.metaKey) return
e.preventDefault()
const root2 = scrollRef.current
if (!root2) return
const dir: 1 | -1 = e.deltaY < 0 ? 1 : -1
const next = nextFine(effScale, dir)
if (!next) return
const r = root2.getBoundingClientRect()
const cx = e.clientX - r.left
const cy = e.clientY - r.top
keepAnchor.current = {
px: root2.scrollLeft + cx, py: root2.scrollTop + cy,
cx, cy, from: effScale,
}
setScale(next)
}
root.addEventListener('wheel', onWheel, { passive: false })
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.
*
* **In Zwanzigsteln, nicht stufenlos.** Der Zoom rastet auf fünf Prozent
* des aktuellen Werts, sobald der Fingerabstand um ebenso viel gewachsen
* oder geschrumpft ist. Danach zählt der neue Abstand als Anfang, eine
* lange Geste geht also über viele Stufen weiter.
*
* Dass daraus keine Ruckelei wird, liegt nicht an dieser Stelle: Die
* Zeichenschleife bricht eine laufende Darstellung ab, sobald sich der
* Maßstab ändert. Von zwanzig Stufen einer Geste wird also nur die letzte
* fertig gemalt.
*
* Die Leiter der Knöpfe (0,5 / 0,75 / 1 / …) wäre hier zu grob: Ein
* Viertelsprung schießt über die Größe hinaus, die man mit den Fingern
* gerade sucht.
*/
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 ? 1 : ratio < 1 ? -1 : 0
if (!dir) return
const from = scaleRef.current
const next = nextFine(from, dir)
if (!next) return
/**
* **Die Finger geben den Maßstab vor, nicht eine feste Schwelle.**
*
* Fünf Prozentpunkte sind bei 25 % ein Fünftel und bei 400 % ein
* Hundertstel. Eine feste Schwelle wäre deshalb an einem Ende zu träge
* und am anderen zu nervös: Von 100 auf 400 zu ziehen dauerte ewig,
* unten spränge es bei der kleinsten Bewegung.
*
* Verlangt wird stattdessen genau die Bewegung, die dem nächsten
* Schritt entspricht – zwei Prozent Fingerweg als Untergrenze, damit
* ein Zittern in der Hand nicht schon zoomt.
*/
const needed = next / from
if (dir > 0 && ratio < Math.max(1.02, needed)) return
if (dir < 0 && ratio > Math.min(0.98, needed)) return
pinchBase.current = now
const root2 = scrollRef.current
if (!root2) 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
* beschrieben, an zwei Stellen gezeigt – breit rechts außen, schmal in der
* ersten Zeile. */
const zoomGroup = (
<>
<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" />
</>
)
/**
* 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" />
{barNarrow && (
<>
<span style={{ width: 1, height: 20, flexShrink: 0, background: 'var(--color-border)' }} />
{zoomGroup}
</>
)}
</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 dritte Spalte: der Zoom, rechtsbündig – dieselbe Anordnung wie
in InkNote. Die Spalte war ohnehin nötig, damit „Mitte" die Mitte
der Leiste ist und nicht die Mitte des Restes; jetzt trägt sie
auch etwas.
Schmal steht der Zoom **nicht** hier, sondern oben neben der
Seitenzahl: Als eigene Zeile wäre die Leiste am Telefon dreizeilig
geworden – gemessen 190 statt 140 Pixel, und die gehen dem
Dokument ab. */}
{!barNarrow && (
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
justifyContent: 'flex-end', minWidth: 0,
}}>
{zoomGroup}
</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',
// 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 && (
<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>
)
}