Repository Public
1// PDF viewer on pdf.js: continuous scroll with only the pages near the viewport rendered, HiDPI canvases, a text2// layer for selection, zoom, page jump and search across the whole document. Everything pdf.js needs (worker, fonts,3// character maps, image decoders) comes from the application bundle: no CDN, no eval, no inline script.4import * as pdfjs from 'pdfjs-dist';5import type { PDFDocumentProxy, PDFPageProxy } from 'pdfjs-dist';6import workerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url';7import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';8import { useT } from '../i18n.ts';9import type { PreviewSource } from '../types.ts';10import { cx, IconButton } from '../ui.tsx';11import { Busy, DownloadLink, explain, Failure, Panel, Tools, ToolText } from './shared.tsx';1213pdfjs.GlobalWorkerOptions.workerSrc = workerUrl;1415// Binary resources pdf.js asks for by file name, mapped to their hashed URLs in the bundle (fetched only when a16// document needs them: non-embedded standard fonts, CJK character maps, JPEG 2000 / JBIG2 / colour decoders).17const urls = (g: Record<string, unknown>) => Object.fromEntries(Object.entries(g).map(([k, v]) => [k.slice(k.lastIndexOf('/') + 1), v as string]));18const BUNDLED: Record<string, Record<string, string>> = {19 standardFontDataUrl: urls(import.meta.glob('/node_modules/pdfjs-dist/standard_fonts/*.{pfb,ttf}', { query: '?url', import: 'default', eager: true })),20 cMapUrl: urls(import.meta.glob('/node_modules/pdfjs-dist/cmaps/*.bcmap', { query: '?url', import: 'default', eager: true })),21 wasmUrl: urls(import.meta.glob('/node_modules/pdfjs-dist/wasm/{openjpeg,jbig2,qcms_bg}.wasm', { query: '?url', import: 'default', eager: true })),22};23class BundledData {24 async fetch({ kind, filename }: { kind: string; filename: string }): Promise<Uint8Array> {25 const url = BUNDLED[kind]?.[filename.slice(filename.lastIndexOf('/') + 1)];26 if (!url) throw new Error(`pdf resource not bundled: ${filename}`);27 const res = await fetch(url);28 if (!res.ok) throw new Error(`pdf resource unavailable: ${filename}`);29 return new Uint8Array(await res.arrayBuffer());30 }31}3233const PAD = 12, GAP = 12, MIN_SCALE = 0.2, MAX_SCALE = 6, STEP = 1.2;34const clampScale = (s: number) => Math.max(MIN_SCALE, Math.min(MAX_SCALE, s));35type Mode = 'auto' | 'width' | 'page' | 'custom';36interface Size { w: number; h: number }37/** Searchable text of one page: the folded text, where each folded character comes from, and where each item starts. */38interface PageIndex { norm: string; map: Uint32Array; starts: number[]; lens: number[]; tops: Float32Array }39interface Match { page: number; s: number; e: number }40interface Hit { s: number; e: number; current: boolean }4142/** Case- and accent-insensitive form used on both sides of a search; whitespace runs count as one space. */43function fold(text: string): { norm: string; map: Uint32Array } {44 const map = new Uint32Array(text.length * 2 + 2);45 let out = '', n = 0, lastSpace = true;46 for (let i = 0; i < text.length; i++) {47 const code = text.charCodeAt(i);48 let f: string;49 if (code <= 32 || code === 0xa0) { if (lastSpace) continue; f = ' '; lastSpace = true; }50 else { lastSpace = false; f = code < 128 ? text[i]!.toLowerCase() : text[i]!.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/[‘’]/g, "'"); }51 for (let k = 0; k < f.length && n < map.length; k++) map[n++] = i;52 out += f;53 }54 return { norm: out, map: map.subarray(0, out.length) };55}5657interface PageProps {58 doc: PDFDocumentProxy; n: number; w: number; h: number; top: number; left: number; scale: number; renderScale: number; hits: Hit[] | undefined;59 indexOf: (n: number) => PageIndex | undefined; label: string;60}61const MAX_CANVAS_PIXELS = 14_000_000; // stays under the canvas limits of mobile browsers62const PdfPage = memo(function PdfPage({ doc, n, w, h, top, left, scale, renderScale, hits, indexOf, label }: PageProps) {63 const root = useRef<HTMLDivElement>(null);64 const host = useRef<HTMLDivElement>(null);65 const text = useRef<HTMLDivElement>(null);66 const layer = useRef<pdfjs.TextLayer | null>(null);67 const [page, setPage] = useState<PDFPageProxy | null>(null);68 const [rendered, setRendered] = useState(false);69 const [textReady, setTextReady] = useState(0);70 const [rects, setRects] = useState<{ l: number; t: number; w: number; h: number; current: boolean }[]>([]);71 const scaleNow = useRef(renderScale); scaleNow.current = renderScale;7273 useEffect(() => {74 let dead = false;75 doc.getPage(n).then((p) => { if (!dead) setPage(p); }, () => {});76 return () => { dead = true; };77 }, [doc, n]);7879 // canvas: drawn off-document, swapped in when complete, so a zoom never shows a blank page80 useEffect(() => {81 if (!page) return;82 const viewport = page.getViewport({ scale: renderScale });83 let os = window.devicePixelRatio || 1;84 if (viewport.width * viewport.height * os * os > MAX_CANVAS_PIXELS) os = Math.sqrt(MAX_CANVAS_PIXELS / (viewport.width * viewport.height));85 const canvas = document.createElement('canvas');86 canvas.width = Math.max(1, Math.floor(viewport.width * os)); canvas.height = Math.max(1, Math.floor(viewport.height * os));87 canvas.setAttribute('aria-hidden', 'true');88 const task = page.render({ canvas, viewport, transform: os !== 1 ? [os, 0, 0, os, 0, 0] : undefined });89 let done = false;90 task.promise.then(() => {91 done = true;92 const el = host.current; if (!el) return;93 for (const old of el.querySelectorAll('canvas')) { old.width = 0; old.height = 0; }94 el.replaceChildren(canvas); setRendered(true);95 }, () => {});96 return () => { if (!done) { task.cancel(); canvas.width = 0; canvas.height = 0; } };97 }, [page, renderScale]);98 useEffect(() => () => { for (const c of host.current?.querySelectorAll('canvas') ?? []) { c.width = 0; c.height = 0; } }, []);99 useEffect(() => () => { try { page?.cleanup(); } catch { /* a render was still winding down */ } }, [page]);100101 // text layer: transparent, selectable text placed over the canvas; it follows the zoom through a CSS variable102 useEffect(() => {103 const container = text.current;104 if (!page || !container) return;105 let dead = false, tl: pdfjs.TextLayer | undefined;106 container.replaceChildren();107 page.getTextContent().then((content) => {108 if (dead) return;109 tl = new pdfjs.TextLayer({ textContentSource: content, container, viewport: page.getViewport({ scale: scaleNow.current }) });110 return tl.render().then(() => { if (!dead) { layer.current = tl!; setTextReady((x) => x + 1); } });111 }).catch(() => {});112 return () => { dead = true; tl?.cancel(); layer.current = null; };113 }, [page]);114115 // search matches: boxes measured from the text layer, stored as percentages of the page so they follow the zoom116 useLayoutEffect(() => {117 const tl = layer.current, el = root.current, index = indexOf(n);118 if (!tl || !el || !index || !hits?.length) { setRects((r) => (r.length ? [] : r)); return; }119 const box = el.getBoundingClientRect();120 const divs = tl.textDivs, out: typeof rects = [];121 const range = document.createRange();122 for (const hit of hits) {123 let i = 0;124 { let lo = 0, hi = index.starts.length - 1; while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (index.starts[mid]! <= hit.s) lo = mid; else hi = mid - 1; } i = lo; }125 for (; i < index.starts.length && index.starts[i]! < hit.e; i++) {126 const node = divs[i]?.firstChild;127 if (!node || node.nodeType !== Node.TEXT_NODE || !divs[i]!.isConnected) continue;128 const len = Math.min(index.lens[i]!, (node as Text).length);129 const from = Math.max(0, hit.s - index.starts[i]!), to = Math.min(len, hit.e - index.starts[i]!);130 if (to <= from) continue;131 range.setStart(node, from); range.setEnd(node, to);132 for (const r of range.getClientRects()) {133 if (r.width < 0.5 || r.height < 0.5) continue;134 out.push({ l: ((r.left - box.left) / box.width) * 100, t: ((r.top - box.top) / box.height) * 100, w: (r.width / box.width) * 100, h: (r.height / box.height) * 100, current: hit.current });135 }136 }137 }138 setRects(out);139 }, [hits, textReady, indexOf, n]);140141 return (142 <div ref={root} className="spd-pdf-page absolute" data-testid="pdf-page" data-page-number={n} data-rendered={rendered ? '1' : '0'} data-text={textReady ? '1' : '0'} role="group" aria-label={label}143 style={{ top, left, width: w, height: h, ['--total-scale-factor' as string]: scale }}>144 <div ref={host} className="absolute inset-0" />145 <div ref={text} className="textLayer" />146 {rects.map((r, i) => <div key={i} className="spd-pdf-hit" data-testid="pdf-hit" data-current={r.current ? '1' : '0'} style={{ left: `${r.l}%`, top: `${r.t}%`, width: `${r.w}%`, height: `${r.h}%`, zIndex: 2 }} />)}147 </div>148 );149});150151type LoadError = { kind: 'password' } | { kind: 'damaged' } | { kind: 'api'; error: unknown };152153export function PdfView({ url, source }: { url: string; source: PreviewSource }) {154 const t = useT();155 const [doc, setDoc] = useState<PDFDocumentProxy | null>(null);156 const [error, setError] = useState<LoadError | null>(null);157 const [progress, setProgress] = useState<number | null>(null);158 const [attempt, setAttempt] = useState(0);159 const [sizes, setSizes] = useState<Size[]>([]);160161 useEffect(() => {162 let dead = false;163 setDoc(null); setError(null); setProgress(null); setSizes([]);164 const task = pdfjs.getDocument({165 url, withCredentials: true, disableRange: false, disableStream: false, rangeChunkSize: 262144, enableXfa: false,166 cMapUrl: 'cmaps/', cMapPacked: true, standardFontDataUrl: 'standard_fonts/', wasmUrl: 'wasm/', useWorkerFetch: false, BinaryDataFactory: BundledData,167 });168 task.onProgress = (p: { loaded: number; total: number }) => { if (!dead && p.total > 0) setProgress(Math.min(1, p.loaded / p.total)); };169 task.promise.then(async (d) => {170 const first = (await d.getPage(1)).getViewport({ scale: 1 });171 if (dead) return;172 setSizes(Array.from({ length: d.numPages }, () => ({ w: first.width, h: first.height })));173 setDoc(d);174 // the other pages may have another format: correct the layout as their sizes become known175 let changed: Map<number, Size> = new Map();176 const flush = () => { if (!changed.size || dead) return; const c = changed; changed = new Map(); setSizes((prev) => prev.map((s, i) => c.get(i) ?? s)); };177 for (let i = 2; i <= d.numPages && !dead; i++) {178 const v = (await d.getPage(i)).getViewport({ scale: 1 });179 if (Math.abs(v.width - first.width) > 0.5 || Math.abs(v.height - first.height) > 0.5) changed.set(i - 1, { w: v.width, h: v.height });180 if (i % 50 === 0) flush();181 }182 flush();183 }).catch(async (e: Error) => {184 if (dead) return;185 if (e?.name === 'PasswordException') return setError({ kind: 'password' });186 const api = await explain(url); // the API's own answer (expired session, removed file, antivirus…) when there is one187 if (!dead) setError(api ? { kind: 'api', error: api } : { kind: 'damaged' });188 });189 return () => { dead = true; void task.destroy().catch(() => {}); };190 }, [url, attempt]);191192 if (error) {193 if (error.kind === 'api') return <Failure error={error.error} source={source} onRetry={() => setAttempt((n) => n + 1)} />;194 return (195 <Panel icon={error.kind === 'password' ? 'lock' : 'warning'} tone="warn" testId={error.kind === 'password' ? 'pdf-password' : 'pdf-damaged'}196 title={t(error.kind === 'password' ? 'preview.pdfPasswordTitle' : 'preview.pdfDamagedTitle')} text={t(error.kind === 'password' ? 'preview.pdfPasswordText' : 'preview.pdfDamagedText')}>197 <DownloadLink source={source} primary />198 </Panel>199 );200 }201 if (!doc || !sizes.length) return <Busy label={t('common.loading')} text={progress !== null && progress < 1 ? `${Math.round(progress * 100)} %` : undefined} />;202 return <Pages doc={doc} sizes={sizes} />;203}204205function Pages({ doc, sizes }: { doc: PDFDocumentProxy; sizes: Size[] }) {206 const t = useT();207 const count = doc.numPages;208 const scroller = useRef<HTMLDivElement>(null);209 const [box, setBox] = useState({ w: 0, h: 0 });210 const [mode, setMode] = useState<Mode>('auto');211 const [custom, setCustom] = useState(1);212 const [range, setRange] = useState({ first: 0, last: 0 });213 const [current, setCurrent] = useState(1);214 const [pageText, setPageText] = useState('1');215 const pinned = useRef<number | null>(null);216217 useLayoutEffect(() => {218 const el = scroller.current!;219 const read = () => setBox((b) => (b.w === el.clientWidth && b.h === el.clientHeight ? b : { w: el.clientWidth, h: el.clientHeight }));220 read();221 const ro = new ResizeObserver(read); ro.observe(el);222 return () => ro.disconnect();223 }, []);224225 const widest = useMemo(() => sizes.reduce((m, s) => Math.max(m, s.w), 1), [sizes]);226 const fitWidth = box.w ? clampScale((box.w - 2 * PAD) / widest) : 1;227 const tallest = useMemo(() => sizes.reduce((m, x) => Math.max(m, x.h), 1), [sizes]);228 const fitPage = box.w ? clampScale(Math.min(fitWidth, (box.h - 2 * PAD) / tallest)) : 1;229 const scale = mode === 'width' ? fitWidth : mode === 'page' ? fitPage : mode === 'auto' ? Math.min(fitWidth, 1.4) : custom;230 // canvases follow the zoom after a short pause, so a pinch or a held key does not render every intermediate size231 const [renderScale, setRenderScale] = useState(scale);232 useEffect(() => { if (!box.w) return; const id = setTimeout(() => setRenderScale(scale), 140); return () => clearTimeout(id); }, [scale, box.w]);233234 const layout = useMemo(() => {235 const tops = new Float64Array(count), w = new Float64Array(count), h = new Float64Array(count);236 let y = PAD, maxW = 0;237 for (let i = 0; i < count; i++) { w[i] = Math.floor(sizes[i]!.w * scale); h[i] = Math.floor(sizes[i]!.h * scale); tops[i] = y; y += h[i]! + GAP; if (w[i]! > maxW) maxW = w[i]!; }238 return { tops, w, h, total: y - GAP + PAD, inner: Math.max(box.w, maxW + 2 * PAD) };239 }, [sizes, scale, count, box.w]);240 const live = useRef({ layout, scale, box }); live.current = { layout, scale, box };241242 /** Index of the page at document offset y. */243 const pageAt = useCallback((y: number) => {244 const { tops } = live.current.layout;245 let lo = 0, hi = count - 1;246 while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (tops[mid]! <= y) lo = mid; else hi = mid - 1; }247 return lo;248 }, [count]);249250 // what must stay still when the layout changes: a point of a page (fx, fy) at a point of the viewport (vx, vy)251 const anchor = useRef({ page: 0, fx: 0.5, fy: 0, vx: 0, vy: 0, held: false, fresh: true });252 const readAnchor = useCallback((vx?: number, vy?: number) => {253 const el = scroller.current; if (!el) return;254 const { layout: L } = live.current;255 const ax = vx ?? el.clientWidth / 2, ay = vy ?? 0;256 const i = pageAt(el.scrollTop + ay);257 const left = (L.inner - L.w[i]!) / 2;258 anchor.current = { page: i, fx: (el.scrollLeft + ax - left) / Math.max(1, L.w[i]!), fy: (el.scrollTop + ay - L.tops[i]!) / Math.max(1, L.h[i]!), vx: ax, vy: ay, held: vx !== undefined, fresh: false };259 }, [pageAt]);260261 const update = useCallback(() => {262 const el = scroller.current; if (!el) return;263 const { layout: L } = live.current;264 const top = el.scrollTop, bottom = top + el.clientHeight;265 const first = pageAt(top), last = pageAt(bottom);266 setRange((r) => (r.first === first && r.last === last ? r : { first, last }));267 let best = first, bestArea = -1;268 for (let i = first; i <= last; i++) { const vis = Math.min(bottom, L.tops[i]! + L.h[i]!) - Math.max(top, L.tops[i]!); if (vis > bestArea + 1) { best = i; bestArea = vis; } }269 const pin = pinned.current;270 const page = pin !== null && pin - 1 >= first && pin - 1 <= last ? pin : best + 1;271 setCurrent(page); setPageText(String(page));272 }, [pageAt]);273274 useLayoutEffect(() => { // a new layout (zoom, page sizes, viewport): put the anchored point back where it was275 const el = scroller.current; if (!el || !box.w) return;276 const a = anchor.current, i = Math.min(a.page, count - 1);277 if (a.fresh) { el.scrollTop = 0; el.scrollLeft = (layout.inner - box.w) / 2; }278 else { el.scrollTop = layout.tops[i]! + a.fy * layout.h[i]! - a.vy; el.scrollLeft = (layout.inner - layout.w[i]!) / 2 + a.fx * layout.w[i]! - a.vx; }279 a.held = false;280 update();281 }, [layout, box.w, box.h, count, update]);282283 useEffect(() => {284 const el = scroller.current!;285 let raf = 0;286 const onScroll = () => { if (raf) return; raf = requestAnimationFrame(() => { raf = 0; if (!anchor.current.held) readAnchor(); update(); }); };287 const release = () => { pinned.current = null; anchor.current.held = false; };288 el.addEventListener('scroll', onScroll, { passive: true });289 el.addEventListener('wheel', release, { passive: true }); el.addEventListener('touchstart', release, { passive: true }); el.addEventListener('pointerdown', release, { passive: true }); el.addEventListener('keydown', release);290 return () => { cancelAnimationFrame(raf); el.removeEventListener('scroll', onScroll); el.removeEventListener('wheel', release); el.removeEventListener('touchstart', release); el.removeEventListener('pointerdown', release); el.removeEventListener('keydown', release); };291 }, [readAnchor, update]);292293 const zoomTo = useCallback((next: number | ((s: number) => number), at?: { x: number; y: number }) => {294 const el = scroller.current;295 if (el) { if (at) readAnchor(at.x, at.y); else readAnchor(el.clientWidth / 2, el.clientHeight / 2); }296 setCustom(clampScale(typeof next === 'function' ? next(live.current.scale) : next)); setMode('custom');297 }, [readAnchor]);298 const fitTo = useCallback((m: Mode) => { readAnchor(); setMode(m); }, [readAnchor]);299 const goTo = useCallback((page: number, fy = 0, centre = false) => {300 const el = scroller.current; if (!el) return;301 const p = Math.max(1, Math.min(count, Math.round(page)));302 const { layout: L } = live.current;303 pinned.current = p; anchor.current.held = false;304 el.scrollTop = Math.max(0, L.tops[p - 1]! + fy * L.h[p - 1]! - (centre ? el.clientHeight / 2 : fy === 0 ? PAD / 2 : 0));305 readAnchor(); update();306 }, [count, readAnchor, update]);307308 // --- search ---309 const [searchOpen, setSearchOpen] = useState(false);310 const [query, setQuery] = useState('');311 const [matches, setMatches] = useState<Match[]>([]);312 const [searching, setSearching] = useState(false);313 const [cursor, setCursor] = useState(-1);314 const input = useRef<HTMLInputElement>(null);315 const indexes = useRef(new Map<number, PageIndex>());316 const pending = useRef(new Map<number, Promise<PageIndex>>());317 useEffect(() => { indexes.current = new Map(); pending.current = new Map(); }, [doc]);318 const indexOf = useCallback((n: number) => indexes.current.get(n), []);319 /** Text of a page, extracted on first need and kept. */320 const loadIndex = useCallback((n: number): Promise<PageIndex> => {321 const hit = pending.current.get(n); if (hit) return hit;322 const p = (async () => {323 const page = await doc.getPage(n);324 const content = await page.getTextContent();325 const [, y0 = 0, , y1 = 1] = page.view;326 const starts: number[] = [], lens: number[] = [], tops: number[] = [];327 let text = '';328 for (const item of content.items) {329 if (!('str' in item)) continue;330 starts.push(text.length); lens.push(item.str.length); tops.push(1 - ((item.transform[5] as number) + item.height - y0) / Math.max(1, y1 - y0));331 text += item.str; if (item.hasEOL) text += '\n';332 }333 const index: PageIndex = { ...fold(text), starts, lens, tops: Float32Array.from(tops) };334 indexes.current.set(n, index);335 return index;336 })();337 pending.current.set(n, p);338 return p;339 }, [doc]);340341 const reveal = useCallback((m: Match | undefined) => {342 if (!m) return;343 const index = indexes.current.get(m.page);344 let fy = 0;345 if (index) { let lo = 0, hi = index.starts.length - 1; while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (index.starts[mid]! <= m.s) lo = mid; else hi = mid - 1; } fy = Math.max(0, Math.min(1, index.tops[lo] ?? 0)); }346 goTo(m.page, fy, true);347 }, [goTo]);348349 const needle = useMemo(() => fold(query.trim()).norm.trim(), [query]);350 const currentRef = useRef(current); currentRef.current = current;351 useEffect(() => {352 setMatches([]); setCursor(-1);353 if (!searchOpen || !needle) { setSearching(false); return; }354 let dead = false;355 setSearching(true);356 const timer = setTimeout(async () => {357 const found: Match[] = [];358 const from = currentRef.current;359 let chosen = false, lastFlush = performance.now();360 const flush = (final: boolean) => {361 if (dead) return;362 setMatches(found.slice());363 if (!chosen) {364 const at = found.findIndex((m) => m.page >= from);365 if (at >= 0 || (final && found.length)) { chosen = true; const i = Math.max(0, at); setCursor(i); reveal(found[i]); }366 }367 lastFlush = performance.now();368 };369 for (let n = 1; n <= count && !dead; n++) {370 const index = await loadIndex(n).catch(() => null);371 if (dead || !index) continue;372 for (let at = index.norm.indexOf(needle); at >= 0; at = index.norm.indexOf(needle, at + needle.length)) {373 found.push({ page: n, s: index.map[at]!, e: index.map[at + needle.length - 1]! + 1 });374 }375 if (performance.now() - lastFlush > 250) flush(false);376 }377 flush(true);378 if (!dead) setSearching(false);379 }, 220);380 return () => { dead = true; clearTimeout(timer); };381 }, [needle, searchOpen, count, loadIndex, reveal]);382383 const step = useCallback((d: number) => {384 if (!matches.length) return;385 const i = (cursor + d + matches.length) % matches.length;386 setCursor(i); reveal(matches[i]);387 }, [matches, cursor, reveal]);388 const hitsByPage = useMemo(() => {389 const map = new Map<number, Hit[]>();390 matches.forEach((m, i) => { let l = map.get(m.page); if (!l) map.set(m.page, (l = [])); l.push({ s: m.s, e: m.e, current: i === cursor }); });391 return map;392 }, [matches, cursor]);393 const openSearch = useCallback(() => { setSearchOpen(true); requestAnimationFrame(() => { input.current?.focus(); input.current?.select(); }); }, []);394 const closeSearch = useCallback(() => { setSearchOpen(false); scroller.current?.focus({ preventScroll: true }); }, []);395396 // keyboard: ⌘F search, ⌘+ ⌘− ⌘0 zoom (the browser's own handling is replaced while the viewer is open)397 useEffect(() => {398 const onKey = (e: KeyboardEvent) => {399 if (!(e.metaKey || e.ctrlKey) || e.altKey) return;400 if (!scroller.current?.closest('[role=dialog]')?.contains(e.target as Node) && e.target !== document.body) return;401 const k = e.key.toLowerCase();402 if (k === 'f') { e.preventDefault(); openSearch(); }403 else if (k === '+' || k === '=') { e.preventDefault(); zoomTo((s) => s * STEP); }404 else if (k === '-') { e.preventDefault(); zoomTo((s) => s / STEP); }405 else if (k === '0') { e.preventDefault(); fitTo('auto'); }406 };407 window.addEventListener('keydown', onKey);408 return () => window.removeEventListener('keydown', onKey);409 }, [openSearch, zoomTo, fitTo]);410411 // pinch on touch screens and on trackpads (ctrl + wheel), zooming around the fingers412 useEffect(() => {413 const el = scroller.current!;414 let pinch: { d: number; scale: number } | null = null;415 const point = (x: number, y: number) => { const r = el.getBoundingClientRect(); return { x: x - r.left, y: y - r.top }; };416 const dist = (e: TouchEvent) => Math.hypot(e.touches[0]!.clientX - e.touches[1]!.clientX, e.touches[0]!.clientY - e.touches[1]!.clientY);417 const onWheel = (e: WheelEvent) => { if (!e.ctrlKey && !e.metaKey) return; e.preventDefault(); zoomTo((s) => s * Math.exp(-e.deltaY * 0.01), point(e.clientX, e.clientY)); };418 const onStart = (e: TouchEvent) => { if (e.touches.length === 2) pinch = { d: dist(e), scale: live.current.scale }; };419 const onMove = (e: TouchEvent) => {420 if (!pinch || e.touches.length !== 2) return;421 e.preventDefault();422 const p = pinch;423 zoomTo(p.scale * (dist(e) / Math.max(1, p.d)), point((e.touches[0]!.clientX + e.touches[1]!.clientX) / 2, (e.touches[0]!.clientY + e.touches[1]!.clientY) / 2));424 };425 const onEnd = (e: TouchEvent) => { if (e.touches.length < 2) pinch = null; };426 const stop = (e: Event) => e.preventDefault(); // Safari's own page zoom gesture427 el.addEventListener('wheel', onWheel, { passive: false }); el.addEventListener('touchstart', onStart, { passive: true }); el.addEventListener('touchmove', onMove, { passive: false });428 el.addEventListener('touchend', onEnd); el.addEventListener('touchcancel', onEnd); el.addEventListener('gesturestart', stop); el.addEventListener('gesturechange', stop);429 return () => {430 el.removeEventListener('wheel', onWheel); el.removeEventListener('touchstart', onStart); el.removeEventListener('touchmove', onMove);431 el.removeEventListener('touchend', onEnd); el.removeEventListener('touchcancel', onEnd); el.removeEventListener('gesturestart', stop); el.removeEventListener('gesturechange', stop);432 };433 }, [zoomTo]);434435 const from = Math.max(0, range.first - 2), to = Math.min(count - 1, range.last + 2);436 const visible: number[] = [];437 if (box.w) for (let i = from; i <= to; i++) visible.push(i);438 const submitPage = () => { const n = Number.parseInt(pageText, 10); if (Number.isFinite(n)) goTo(n); else setPageText(String(current)); };439 return (440 <>441 <Tools>442 <label className="flex shrink-0 items-center gap-1.5 pl-1 text-xs text-ink-2">443 <span className="sr-only">{t('preview.page')}</span>444 <input value={pageText} inputMode="numeric" pattern="[0-9]*" aria-label={t('preview.page')} data-testid="pdf-page-input" enterKeyHint="go"445 onChange={(e) => setPageText(e.target.value.replace(/\D/g, '').slice(0, 6))} onFocus={(e) => e.currentTarget.select()} onBlur={submitPage}446 onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); submitPage(); e.currentTarget.select(); } }}447 className="h-9 w-12 rounded-xl border border-line-strong bg-surface text-center text-sm tabular-nums text-ink focus:border-accent focus:outline-none focus:ring-4 focus:ring-accent/15" />448 <span className="tabular-nums" data-testid="pdf-page-count">/ {count}</span>449 </label>450 <span className="mx-1 hidden h-5 w-px shrink-0 bg-line sm:block" />451 <IconButton icon="zoomOut" label={t('preview.zoomOut')} disabled={scale <= MIN_SCALE} onClick={() => zoomTo((s) => s / STEP)} data-testid="pdf-zoom-out" />452 <ToolText data-testid="pdf-zoom">{Math.round(scale * 100)} %</ToolText>453 <IconButton icon="zoomIn" label={t('preview.zoomIn')} disabled={scale >= MAX_SCALE} onClick={() => zoomTo((s) => s * STEP)} data-testid="pdf-zoom-in" />454 <IconButton icon="fitWidth" label={t('preview.fitWidth')} active={mode === 'width'} onClick={() => fitTo('width')} data-testid="pdf-fit-width" />455 <IconButton icon="fitPage" label={t('preview.fitPage')} active={mode === 'page'} onClick={() => fitTo('page')} data-testid="pdf-fit-page" />456 <IconButton icon="search" label={t('preview.searchOpen')} active={searchOpen} onClick={() => (searchOpen ? closeSearch() : openSearch())} data-testid="pdf-search-toggle" />457 </Tools>458 {searchOpen ? (459 <div role="search" className="flex shrink-0 items-center gap-1 border-b border-line bg-surface px-2 py-1.5 sm:justify-end sm:px-3" data-noswipe>460 <input ref={input} type="search" value={query} onChange={(e) => setQuery(e.target.value)} placeholder={t('preview.searchPlaceholder')} aria-label={t('preview.searchPlaceholder')} data-testid="pdf-search-input" enterKeyHint="search"461 autoComplete="off" autoCorrect="off" spellCheck={false}462 onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); step(e.shiftKey ? -1 : 1); } else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); closeSearch(); } }}463 className="h-10 min-w-0 flex-1 rounded-xl border border-line-strong bg-surface px-3 text-sm text-ink placeholder:text-ink-3 focus:border-accent focus:outline-none focus:ring-4 focus:ring-accent/15 sm:max-w-xs" />464 <span className={cx('min-w-16 shrink-0 px-1 text-center text-xs tabular-nums', needle && !searching && !matches.length ? 'text-danger' : 'text-ink-2')} aria-live="polite" data-testid="pdf-search-count" data-searching={searching ? '1' : '0'}>465 {!needle ? '' : matches.length ? `${cursor + 1} / ${matches.length}${searching ? '…' : ''}` : searching ? '…' : t('preview.searchNone')}466 </span>467 <IconButton icon="arrowUp" label={t('preview.searchPrev')} disabled={!matches.length} onClick={() => step(-1)} data-testid="pdf-search-prev" />468 <IconButton icon="arrowDown" label={t('preview.searchNext')} disabled={!matches.length} onClick={() => step(1)} data-testid="pdf-search-next" />469 <IconButton icon="x" label={t('preview.searchClose')} onClick={closeSearch} />470 </div>471 ) : null}472 <div ref={scroller} tabIndex={0} data-testid="pdf-scroll" data-pages={count} data-page={current} aria-label={t('preview.pdfDocument', { n: count })}473 className="relative min-h-0 flex-1 overflow-auto outline-none [overflow-anchor:none]">474 <div className="relative" style={{ height: layout.total, width: layout.inner }}>475 {visible.map((i) => (476 <PdfPage key={i} doc={doc} n={i + 1} w={layout.w[i]!} h={layout.h[i]!} top={layout.tops[i]!} left={Math.floor((layout.inner - layout.w[i]!) / 2)} scale={scale} renderScale={renderScale}477 hits={hitsByPage.get(i + 1)} indexOf={indexOf} label={t('preview.pageOf', { n: i + 1, total: count })} />478 ))}479 </div>480 </div>481 </>482 );483}484