PdfPreviewModal.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471
  1. import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { ChevronLeft, ChevronRight, FileText, Loader2, ZoomIn, ZoomOut } from 'lucide-react';
  4. import type { PDFDocumentLoadingTask, PDFDocumentProxy, RenderTask } from 'pdfjs-dist/legacy/build/pdf.mjs';
  5. import { api, getAuthToken } from '../api/client';
  6. import { formatFileSize } from '../utils/file';
  7. import { PreviewModalShell, previewIconButtonClass } from './PreviewModalShell';
  8. import { usePreviewFullscreen } from '../hooks/usePreviewFullscreen';
  9. // Fetching and parsing happen fully in the browser; beyond this size the
  10. // preview shows a notice instead of stalling the tab on a giant download.
  11. export const PDF_PREVIEW_MAX_BYTES = 50 * 1024 * 1024;
  12. const MIN_ZOOM = 0.4;
  13. const MAX_ZOOM = 4;
  14. // One button press, one key press, one mouse-wheel notch.
  15. const ZOOM_STEP = 1.25;
  16. // A pinch arrives as many small ctrlKey wheel events, a mouse notch as one
  17. // ±100; clamping the delta before the exponent gives the notch one ZOOM_STEP
  18. // and the pinch a smooth ramp.
  19. const WHEEL_DELTA_CLAMP = 30;
  20. const WHEEL_ZOOM_RATE = Math.log(ZOOM_STEP) / WHEEL_DELTA_CLAMP;
  21. // Wheel zoom changes the displayed size at once (CSS) and re-rasterises after
  22. // the gesture settles, so a scroll burst costs one pdf.js render, not twenty.
  23. const RERENDER_DEBOUNCE_MS = 150;
  24. // iOS Safari refuses to back a canvas past roughly 16.7M pixels and hands back
  25. // a blank one instead of failing; zoom 4 on a dpr-2 screen crosses that on any
  26. // ordinary page. Past the cap the raster stops getting sharper, which costs
  27. // detail rather than the whole page.
  28. const MAX_CANVAS_PIXELS = 16 * 1024 * 1024;
  29. // pdf.js fetches its CMaps, ICC profiles, standard fonts and wasm decoders at
  30. // runtime instead of bundling them; vite.config.ts publishes them here. Left
  31. // unset, CJK text, JPEG2000/JBIG2 images and ICC colour silently fail (#2976).
  32. const PDFJS_ASSET_BASE = `${import.meta.env.BASE_URL}assets/pdfjs/`;
  33. const clampZoom = (zoom: number) => Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom));
  34. interface PdfPreviewModalProps {
  35. libraryFileId: number;
  36. filename: string;
  37. fileSize: number;
  38. onClose: () => void;
  39. /** Called once with a 256px PNG of the first page, for the grid thumbnail (#2976). */
  40. onSnapshot?: (blob: Blob) => void;
  41. }
  42. // Where a zoom step should keep the page still: a point on the canvas
  43. // (fractions) pinned to a point in the scroll viewport (pixels).
  44. interface ZoomAnchor {
  45. fx: number;
  46. fy: number;
  47. px: number;
  48. py: number;
  49. }
  50. interface ActivePointer {
  51. x: number;
  52. y: number;
  53. }
  54. // Square 256px crop of the rendered page, white-backed like a paper page.
  55. function snapshotFromCanvas(source: HTMLCanvasElement): Promise<Blob | null> {
  56. const size = 256;
  57. const target = document.createElement('canvas');
  58. target.width = size;
  59. target.height = size;
  60. const ctx = target.getContext('2d');
  61. if (!ctx) return Promise.resolve(null);
  62. ctx.fillStyle = '#ffffff';
  63. ctx.fillRect(0, 0, size, size);
  64. const scale = size / Math.max(source.width, source.height);
  65. const w = source.width * scale;
  66. const h = source.height * scale;
  67. ctx.drawImage(source, (size - w) / 2, (size - h) / 2, w, h);
  68. return new Promise((resolve) => target.toBlob(resolve, 'image/png'));
  69. }
  70. // deltaMode 1 is lines (Firefox mouse wheel), 2 is pages; both scaled to pixels.
  71. function wheelDeltaPixels(e: WheelEvent): number {
  72. if (e.deltaMode === 1) return e.deltaY * 16;
  73. if (e.deltaMode === 2) return e.deltaY * 100;
  74. return e.deltaY;
  75. }
  76. export function PdfPreviewModal({ libraryFileId, filename, fileSize, onClose, onSnapshot }: PdfPreviewModalProps) {
  77. const { t } = useTranslation();
  78. const fullscreen = usePreviewFullscreen();
  79. const { panelRef, isFullscreen, toggleFullscreen } = fullscreen;
  80. const containerRef = useRef<HTMLDivElement>(null);
  81. const canvasRef = useRef<HTMLCanvasElement>(null);
  82. const renderTaskRef = useRef<RenderTask | null>(null);
  83. const snapshotSentRef = useRef(false);
  84. const onSnapshotRef = useRef(onSnapshot);
  85. useEffect(() => {
  86. onSnapshotRef.current = onSnapshot;
  87. });
  88. const [doc, setDoc] = useState<PDFDocumentProxy | null>(null);
  89. const [pageNum, setPageNum] = useState(1);
  90. // `zoom` is what the user sees (applied as CSS size immediately);
  91. // `renderZoom` trails it and drives the pdf.js raster.
  92. const [zoom, setZoom] = useState(1);
  93. const [renderZoom, setRenderZoom] = useState(1);
  94. const [layoutVersion, setLayoutVersion] = useState(0);
  95. const [error, setError] = useState<string | null>(null);
  96. const [rendering, setRendering] = useState(true);
  97. const zoomRef = useRef(zoom);
  98. // CSS size of the page at zoom 1, known once a page has been rendered.
  99. const baseCssSizeRef = useRef<{ width: number; height: number } | null>(null);
  100. const anchorRef = useRef<ZoomAnchor | null>(null);
  101. const pointersRef = useRef<Map<number, ActivePointer>>(new Map());
  102. const pinchDistanceRef = useRef<number | null>(null);
  103. const zoomAt = useCallback((factor: number, clientX?: number, clientY?: number) => {
  104. const container = containerRef.current;
  105. const canvas = canvasRef.current;
  106. if (container && canvas) {
  107. const viewport = container.getBoundingClientRect();
  108. const page = canvas.getBoundingClientRect();
  109. // Without a pointer, keep whatever is in the middle of the viewport.
  110. const px = clientX == null ? viewport.width / 2 : clientX - viewport.left;
  111. const py = clientY == null ? viewport.height / 2 : clientY - viewport.top;
  112. anchorRef.current =
  113. page.width > 0 && page.height > 0
  114. ? {
  115. fx: (px + viewport.left - page.left) / page.width,
  116. fy: (py + viewport.top - page.top) / page.height,
  117. px,
  118. py,
  119. }
  120. : null;
  121. }
  122. setZoom((current) => clampZoom(current * factor));
  123. }, []);
  124. useEffect(() => {
  125. const handleKeyDown = (e: KeyboardEvent) => {
  126. // Ctrl/⌘ combinations are the browser's own zoom; leave them alone.
  127. if (e.ctrlKey || e.metaKey || e.altKey) return;
  128. if (e.key === '+' || e.key === '=') {
  129. e.preventDefault();
  130. zoomAt(ZOOM_STEP);
  131. } else if (e.key === '-') {
  132. e.preventDefault();
  133. zoomAt(1 / ZOOM_STEP);
  134. } else if (e.key === '0') {
  135. e.preventDefault();
  136. anchorRef.current = null;
  137. setZoom(1);
  138. }
  139. };
  140. window.addEventListener('keydown', handleKeyDown);
  141. return () => window.removeEventListener('keydown', handleKeyDown);
  142. }, [zoomAt]);
  143. // Wheel: Ctrl/⌘ (which is also how a trackpad pinch arrives) always zooms;
  144. // a plain wheel zooms only while the page fits the viewport, because then
  145. // there is nothing to scroll. Native listener: React registers wheel as
  146. // passive, so preventDefault — needed to keep the browser from zooming the
  147. // whole page — would be ignored there.
  148. useEffect(() => {
  149. const container = containerRef.current;
  150. if (!container) return;
  151. const handleWheel = (e: WheelEvent) => {
  152. if (!canvasRef.current || !baseCssSizeRef.current) return;
  153. const modified = e.ctrlKey || e.metaKey;
  154. if (!modified) {
  155. const fits =
  156. container.scrollHeight <= container.clientHeight && container.scrollWidth <= container.clientWidth;
  157. if (!fits) return;
  158. }
  159. e.preventDefault();
  160. const delta = Math.max(-WHEEL_DELTA_CLAMP, Math.min(WHEEL_DELTA_CLAMP, wheelDeltaPixels(e)));
  161. zoomAt(Math.exp(-delta * WHEEL_ZOOM_RATE), e.clientX, e.clientY);
  162. };
  163. container.addEventListener('wheel', handleWheel, { passive: false });
  164. return () => container.removeEventListener('wheel', handleWheel);
  165. }, [zoomAt]);
  166. // Touch: one finger pans the page, two fingers pinch-zoom around their
  167. // midpoint. `touch-action: none` on the container hands both gestures to
  168. // these handlers instead of the browser's scroll / page-zoom.
  169. const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
  170. if (e.pointerType !== 'touch') return;
  171. pointersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
  172. pinchDistanceRef.current = null;
  173. };
  174. const handlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
  175. if (e.pointerType !== 'touch') return;
  176. const pointers = pointersRef.current;
  177. const previous = pointers.get(e.pointerId);
  178. if (!previous) return;
  179. pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
  180. if (pointers.size === 1) {
  181. const container = containerRef.current;
  182. if (container) {
  183. container.scrollLeft -= e.clientX - previous.x;
  184. container.scrollTop -= e.clientY - previous.y;
  185. }
  186. return;
  187. }
  188. if (pointers.size !== 2) return;
  189. const [a, b] = Array.from(pointers.values());
  190. const distance = Math.hypot(a.x - b.x, a.y - b.y);
  191. const last = pinchDistanceRef.current;
  192. pinchDistanceRef.current = distance;
  193. if (last != null && last > 0 && distance > 0) {
  194. zoomAt(distance / last, (a.x + b.x) / 2, (a.y + b.y) / 2);
  195. }
  196. };
  197. const handlePointerEnd = (e: React.PointerEvent<HTMLDivElement>) => {
  198. if (e.pointerType !== 'touch') return;
  199. pointersRef.current.delete(e.pointerId);
  200. pinchDistanceRef.current = null;
  201. };
  202. // Show the new zoom right away by resizing the existing raster, and keep
  203. // the anchored point under the pointer. The sharp re-render follows.
  204. useLayoutEffect(() => {
  205. zoomRef.current = zoom;
  206. const canvas = canvasRef.current;
  207. const container = containerRef.current;
  208. const base = baseCssSizeRef.current;
  209. if (!canvas || !container || !base) return;
  210. canvas.style.width = `${base.width * zoom}px`;
  211. canvas.style.height = `${base.height * zoom}px`;
  212. const anchor = anchorRef.current;
  213. anchorRef.current = null;
  214. if (!anchor) return;
  215. container.scrollLeft = canvas.offsetLeft + anchor.fx * canvas.offsetWidth - anchor.px;
  216. container.scrollTop = canvas.offsetTop + anchor.fy * canvas.offsetHeight - anchor.py;
  217. }, [zoom]);
  218. useEffect(() => {
  219. if (zoom === renderZoom) return;
  220. const id = window.setTimeout(() => setRenderZoom(zoom), RERENDER_DEBOUNCE_MS);
  221. return () => window.clearTimeout(id);
  222. }, [zoom, renderZoom]);
  223. // The page is fitted to the panel width, so a panel resize (fullscreen,
  224. // window resize) needs a fresh fit. Watching the panel rather than the
  225. // scroll container keeps a scrollbar appearing from re-fitting the page.
  226. useEffect(() => {
  227. const panel = panelRef.current;
  228. if (!panel) return;
  229. const observer = new ResizeObserver(() => setLayoutVersion((v) => v + 1));
  230. observer.observe(panel);
  231. return () => observer.disconnect();
  232. }, [panelRef]);
  233. // Load the document. pdf.js is imported on demand so the viewer and its
  234. // worker stay out of the main bundle.
  235. useEffect(() => {
  236. let cancelled = false;
  237. let loadingTask: PDFDocumentLoadingTask | null = null;
  238. setDoc(null);
  239. setError(null);
  240. setPageNum(1);
  241. setRendering(true);
  242. if (fileSize > PDF_PREVIEW_MAX_BYTES) {
  243. setError(t('fileManager.preview.tooLarge', { size: formatFileSize(fileSize) }));
  244. setRendering(false);
  245. return;
  246. }
  247. const headers: HeadersInit = {};
  248. const token = getAuthToken();
  249. if (token) headers['Authorization'] = `Bearer ${token}`;
  250. (async () => {
  251. const res = await fetch(api.getLibraryFileDownloadUrl(libraryFileId), { headers });
  252. if (!res.ok) throw new Error(`HTTP ${res.status}`);
  253. const buffer = await res.arrayBuffer();
  254. // The legacy build, not the standard one: pdf.js 6 calls
  255. // Map.prototype.getOrInsertComputed (Chrome 145, Firefox 144) and
  256. // other recent APIs directly, so on any older browser every PDF failed
  257. // with "cannot be previewed". The legacy build bundles polyfills for
  258. // them in both the page and the worker. The worker goes through Vite
  259. // (`?worker&url`) rather than being copied as-is (`?url`), so
  260. // `build.target` lowers its class static block for Safari 16.0-16.3
  261. // like the rest of the app, and the baseline check can see it.
  262. const pdfjs = await import('pdfjs-dist/legacy/build/pdf.mjs');
  263. if (!pdfjs.GlobalWorkerOptions.workerSrc) {
  264. pdfjs.GlobalWorkerOptions.workerSrc = (
  265. await import('pdfjs-dist/legacy/build/pdf.worker.min.mjs?worker&url')
  266. ).default;
  267. }
  268. loadingTask = pdfjs.getDocument({
  269. data: new Uint8Array(buffer),
  270. cMapUrl: `${PDFJS_ASSET_BASE}cmaps/`,
  271. iccUrl: `${PDFJS_ASSET_BASE}iccs/`,
  272. standardFontDataUrl: `${PDFJS_ASSET_BASE}standard_fonts/`,
  273. wasmUrl: `${PDFJS_ASSET_BASE}wasm/`,
  274. });
  275. if (cancelled) {
  276. // The modal closed during the fetch/import above, so cleanup ran while
  277. // `loadingTask` was still null and left this task — and its worker —
  278. // running. Nothing else will destroy it.
  279. loadingTask.destroy();
  280. return;
  281. }
  282. const loaded = await loadingTask.promise;
  283. if (cancelled) {
  284. // Cleanup ran after the assignment above, so it destroyed the task
  285. // already — and with it the document.
  286. return;
  287. }
  288. setDoc(loaded);
  289. })().catch((err: unknown) => {
  290. // The reason never reaches the UI beyond a generic line, so leave it in
  291. // the console: an HTTP status, a refused worker or a parser failure each
  292. // need a different fix, and "cannot be previewed" hides which one it was.
  293. console.error('[pdf-preview] load failed', err);
  294. if (!cancelled) {
  295. setError(t('fileManager.preview.error'));
  296. setRendering(false);
  297. }
  298. });
  299. return () => {
  300. cancelled = true;
  301. renderTaskRef.current?.cancel();
  302. renderTaskRef.current = null;
  303. // Destroying the loading task also destroys the document and worker.
  304. loadingTask?.destroy();
  305. };
  306. }, [libraryFileId, fileSize, t]);
  307. // Render the current page into the canvas.
  308. useEffect(() => {
  309. if (!doc) return;
  310. let cancelled = false;
  311. setRendering(true);
  312. (async () => {
  313. const page = await doc.getPage(pageNum);
  314. const canvas = canvasRef.current;
  315. const container = containerRef.current;
  316. if (!canvas || !container || cancelled) return;
  317. const baseViewport = page.getViewport({ scale: 1 });
  318. // Fit the page width to the panel at zoom 1; render at device pixels.
  319. const fitScale = Math.max((container.clientWidth - 32) / baseViewport.width, 0.1);
  320. const dpr = Math.min(window.devicePixelRatio || 1, 2);
  321. const wanted = fitScale * renderZoom * dpr;
  322. const pixels = baseViewport.width * wanted * (baseViewport.height * wanted);
  323. const scale = pixels > MAX_CANVAS_PIXELS ? wanted * Math.sqrt(MAX_CANVAS_PIXELS / pixels) : wanted;
  324. const viewport = page.getViewport({ scale });
  325. canvas.width = viewport.width;
  326. canvas.height = viewport.height;
  327. baseCssSizeRef.current = { width: baseViewport.width * fitScale, height: baseViewport.height * fitScale };
  328. // The displayed zoom may already be ahead of this raster.
  329. canvas.style.width = `${baseCssSizeRef.current.width * zoomRef.current}px`;
  330. canvas.style.height = `${baseCssSizeRef.current.height * zoomRef.current}px`;
  331. renderTaskRef.current?.cancel();
  332. const task = page.render({ canvas, viewport });
  333. renderTaskRef.current = task;
  334. await task.promise;
  335. if (cancelled) return;
  336. setRendering(false);
  337. if (pageNum === 1 && onSnapshotRef.current && !snapshotSentRef.current) {
  338. snapshotSentRef.current = true;
  339. const blob = await snapshotFromCanvas(canvas);
  340. if (blob && !cancelled) onSnapshotRef.current?.(blob);
  341. }
  342. })().catch((err: unknown) => {
  343. // A cancelled render throws RenderingCancelledException — not an error.
  344. if (!cancelled && (err as { name?: string })?.name !== 'RenderingCancelledException') {
  345. console.error('[pdf-preview] render failed', err);
  346. setError(t('fileManager.preview.error'));
  347. setRendering(false);
  348. }
  349. });
  350. return () => {
  351. cancelled = true;
  352. };
  353. }, [doc, pageNum, renderZoom, layoutVersion, t]);
  354. const pageCount = doc?.numPages ?? 0;
  355. const iconButtonClass = previewIconButtonClass;
  356. return (
  357. <PreviewModalShell
  358. title={filename}
  359. fullscreen={fullscreen}
  360. onClose={onClose}
  361. icon={<FileText className="w-5 h-5 text-bambu-green flex-shrink-0" />}
  362. actions={
  363. <>
  364. {doc && pageCount > 1 && (
  365. <div className="flex items-center gap-1 mr-2">
  366. <button
  367. onClick={() => setPageNum((p) => Math.max(1, p - 1))}
  368. disabled={pageNum <= 1}
  369. className={`${iconButtonClass} disabled:opacity-40 disabled:cursor-not-allowed`}
  370. aria-label={t('fileManager.preview.prevPage')}
  371. >
  372. <ChevronLeft className="w-4 h-4" />
  373. </button>
  374. <span className="text-sm text-bambu-gray whitespace-nowrap">
  375. {t('fileManager.preview.page', { current: pageNum, total: pageCount })}
  376. </span>
  377. <button
  378. onClick={() => setPageNum((p) => Math.min(pageCount, p + 1))}
  379. disabled={pageNum >= pageCount}
  380. className={`${iconButtonClass} disabled:opacity-40 disabled:cursor-not-allowed`}
  381. aria-label={t('fileManager.preview.nextPage')}
  382. >
  383. <ChevronRight className="w-4 h-4" />
  384. </button>
  385. </div>
  386. )}
  387. {doc && (
  388. <>
  389. <button
  390. onClick={() => zoomAt(1 / ZOOM_STEP)}
  391. className={iconButtonClass}
  392. aria-label={t('fileManager.preview.zoomOut')}
  393. >
  394. <ZoomOut className="w-4 h-4" />
  395. </button>
  396. <button
  397. onClick={() => zoomAt(ZOOM_STEP)}
  398. className={iconButtonClass}
  399. aria-label={t('fileManager.preview.zoomIn')}
  400. >
  401. <ZoomIn className="w-4 h-4" />
  402. </button>
  403. </>
  404. )}
  405. </>
  406. }
  407. >
  408. {/* Page */}
  409. <div
  410. ref={containerRef}
  411. data-testid="pdf-preview-page"
  412. className={`relative flex-1 min-h-0 overflow-auto bg-bambu-dark p-4 touch-none ${isFullscreen ? '' : 'rounded-b-lg'}`}
  413. onDoubleClick={toggleFullscreen}
  414. onPointerDown={handlePointerDown}
  415. onPointerMove={handlePointerMove}
  416. onPointerUp={handlePointerEnd}
  417. onPointerCancel={handlePointerEnd}
  418. onPointerLeave={handlePointerEnd}
  419. >
  420. {error ? (
  421. <div className="h-full flex items-center justify-center">
  422. <p className="text-bambu-gray text-center">{error}</p>
  423. </div>
  424. ) : (
  425. <div className="flex justify-center min-w-fit">
  426. <canvas ref={canvasRef} className="shadow-lg" />
  427. </div>
  428. )}
  429. {!error && rendering && (
  430. <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
  431. <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
  432. </div>
  433. )}
  434. </div>
  435. </PreviewModalShell>
  436. );
  437. }