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>
1194 lines
48 KiB
TypeScript
1194 lines
48 KiB
TypeScript
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>
|
||
)
|
||
}
|