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