SPB Git

Repository Public

PdfView.tsx 29.4 KB 484 lines tsx

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
Files main
Menu

Theme