PdfPreviewModal.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
  1. import { useEffect, useRef, useState } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { ChevronLeft, ChevronRight, FileText, Loader2, X, 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. // Fetching and parsing happen fully in the browser; beyond this size the
  8. // preview shows a notice instead of stalling the tab on a giant download.
  9. export const PDF_PREVIEW_MAX_BYTES = 50 * 1024 * 1024;
  10. interface PdfPreviewModalProps {
  11. libraryFileId: number;
  12. filename: string;
  13. fileSize: number;
  14. onClose: () => void;
  15. /** Called once with a 256px PNG of the first page, for the grid thumbnail (#2976). */
  16. onSnapshot?: (blob: Blob) => void;
  17. }
  18. // Square 256px crop of the rendered page, white-backed like a paper page.
  19. function snapshotFromCanvas(source: HTMLCanvasElement): Promise<Blob | null> {
  20. const size = 256;
  21. const target = document.createElement('canvas');
  22. target.width = size;
  23. target.height = size;
  24. const ctx = target.getContext('2d');
  25. if (!ctx) return Promise.resolve(null);
  26. ctx.fillStyle = '#ffffff';
  27. ctx.fillRect(0, 0, size, size);
  28. const scale = size / Math.max(source.width, source.height);
  29. const w = source.width * scale;
  30. const h = source.height * scale;
  31. ctx.drawImage(source, (size - w) / 2, (size - h) / 2, w, h);
  32. return new Promise((resolve) => target.toBlob(resolve, 'image/png'));
  33. }
  34. export function PdfPreviewModal({ libraryFileId, filename, fileSize, onClose, onSnapshot }: PdfPreviewModalProps) {
  35. const { t } = useTranslation();
  36. const containerRef = useRef<HTMLDivElement>(null);
  37. const canvasRef = useRef<HTMLCanvasElement>(null);
  38. const renderTaskRef = useRef<RenderTask | null>(null);
  39. const snapshotSentRef = useRef(false);
  40. const onSnapshotRef = useRef(onSnapshot);
  41. useEffect(() => {
  42. onSnapshotRef.current = onSnapshot;
  43. });
  44. const [doc, setDoc] = useState<PDFDocumentProxy | null>(null);
  45. const [pageNum, setPageNum] = useState(1);
  46. const [zoom, setZoom] = useState(1);
  47. const [error, setError] = useState<string | null>(null);
  48. const [rendering, setRendering] = useState(true);
  49. useEffect(() => {
  50. const handleKeyDown = (e: KeyboardEvent) => {
  51. if (e.key === 'Escape') onClose();
  52. };
  53. window.addEventListener('keydown', handleKeyDown);
  54. return () => window.removeEventListener('keydown', handleKeyDown);
  55. }, [onClose]);
  56. // Load the document. pdf.js is imported on demand so the viewer and its
  57. // worker stay out of the main bundle.
  58. useEffect(() => {
  59. let cancelled = false;
  60. let loadingTask: PDFDocumentLoadingTask | null = null;
  61. setDoc(null);
  62. setError(null);
  63. setPageNum(1);
  64. setRendering(true);
  65. if (fileSize > PDF_PREVIEW_MAX_BYTES) {
  66. setError(t('fileManager.preview.tooLarge', { size: formatFileSize(fileSize) }));
  67. setRendering(false);
  68. return;
  69. }
  70. const headers: HeadersInit = {};
  71. const token = getAuthToken();
  72. if (token) headers['Authorization'] = `Bearer ${token}`;
  73. (async () => {
  74. const res = await fetch(api.getLibraryFileDownloadUrl(libraryFileId), { headers });
  75. if (!res.ok) throw new Error(`HTTP ${res.status}`);
  76. const buffer = await res.arrayBuffer();
  77. // The legacy build, not the standard one: pdf.js 6 calls
  78. // Map.prototype.getOrInsertComputed (Chrome 145, Firefox 144) and
  79. // other recent APIs directly, so on any older browser every PDF failed
  80. // with "cannot be previewed". The legacy build bundles polyfills for
  81. // them in both the page and the worker. The worker goes through Vite
  82. // (`?worker&url`) rather than being copied as-is (`?url`), so
  83. // `build.target` lowers its class static block for Safari 16.0-16.3
  84. // like the rest of the app, and the baseline check can see it.
  85. const pdfjs = await import('pdfjs-dist/legacy/build/pdf.mjs');
  86. if (!pdfjs.GlobalWorkerOptions.workerSrc) {
  87. pdfjs.GlobalWorkerOptions.workerSrc = (
  88. await import('pdfjs-dist/legacy/build/pdf.worker.min.mjs?worker&url')
  89. ).default;
  90. }
  91. loadingTask = pdfjs.getDocument({ data: new Uint8Array(buffer) });
  92. const loaded = await loadingTask.promise;
  93. if (cancelled) {
  94. // Cleanup below already ran; destroying the task tears down the doc.
  95. return;
  96. }
  97. setDoc(loaded);
  98. })().catch(() => {
  99. if (!cancelled) {
  100. setError(t('fileManager.preview.error'));
  101. setRendering(false);
  102. }
  103. });
  104. return () => {
  105. cancelled = true;
  106. renderTaskRef.current?.cancel();
  107. renderTaskRef.current = null;
  108. // Destroying the loading task also destroys the document and worker.
  109. loadingTask?.destroy();
  110. };
  111. }, [libraryFileId, fileSize, t]);
  112. // Render the current page into the canvas.
  113. useEffect(() => {
  114. if (!doc) return;
  115. let cancelled = false;
  116. setRendering(true);
  117. (async () => {
  118. const page = await doc.getPage(pageNum);
  119. const canvas = canvasRef.current;
  120. const container = containerRef.current;
  121. if (!canvas || !container || cancelled) return;
  122. const baseViewport = page.getViewport({ scale: 1 });
  123. // Fit the page width to the panel at zoom 1; render at device pixels.
  124. const fitScale = Math.max((container.clientWidth - 32) / baseViewport.width, 0.1);
  125. const dpr = Math.min(window.devicePixelRatio || 1, 2);
  126. const viewport = page.getViewport({ scale: fitScale * zoom * dpr });
  127. canvas.width = viewport.width;
  128. canvas.height = viewport.height;
  129. canvas.style.width = `${viewport.width / dpr}px`;
  130. canvas.style.height = `${viewport.height / dpr}px`;
  131. renderTaskRef.current?.cancel();
  132. const task = page.render({ canvas, viewport });
  133. renderTaskRef.current = task;
  134. await task.promise;
  135. if (cancelled) return;
  136. setRendering(false);
  137. if (pageNum === 1 && onSnapshotRef.current && !snapshotSentRef.current) {
  138. snapshotSentRef.current = true;
  139. const blob = await snapshotFromCanvas(canvas);
  140. if (blob && !cancelled) onSnapshotRef.current?.(blob);
  141. }
  142. })().catch((err: unknown) => {
  143. // A cancelled render throws RenderingCancelledException — not an error.
  144. if (!cancelled && (err as { name?: string })?.name !== 'RenderingCancelledException') {
  145. setError(t('fileManager.preview.error'));
  146. setRendering(false);
  147. }
  148. });
  149. return () => {
  150. cancelled = true;
  151. };
  152. }, [doc, pageNum, zoom, t]);
  153. const pageCount = doc?.numPages ?? 0;
  154. return (
  155. <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
  156. <div className="bg-bambu-dark-secondary rounded-lg w-full max-w-5xl h-[85vh] border border-bambu-dark-tertiary flex flex-col">
  157. {/* Header */}
  158. <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
  159. <div className="flex items-center gap-2 min-w-0">
  160. <FileText className="w-5 h-5 text-bambu-green flex-shrink-0" />
  161. <h2 className="text-lg font-semibold text-white truncate">{filename}</h2>
  162. </div>
  163. <div className="flex items-center gap-2 flex-shrink-0">
  164. {doc && pageCount > 1 && (
  165. <div className="flex items-center gap-1 mr-2">
  166. <button
  167. onClick={() => setPageNum((p) => Math.max(1, p - 1))}
  168. disabled={pageNum <= 1}
  169. className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
  170. aria-label={t('fileManager.preview.prevPage')}
  171. >
  172. <ChevronLeft className="w-4 h-4" />
  173. </button>
  174. <span className="text-sm text-bambu-gray whitespace-nowrap">
  175. {t('fileManager.preview.page', { current: pageNum, total: pageCount })}
  176. </span>
  177. <button
  178. onClick={() => setPageNum((p) => Math.min(pageCount, p + 1))}
  179. disabled={pageNum >= pageCount}
  180. className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
  181. aria-label={t('fileManager.preview.nextPage')}
  182. >
  183. <ChevronRight className="w-4 h-4" />
  184. </button>
  185. </div>
  186. )}
  187. {doc && (
  188. <>
  189. <button
  190. onClick={() => setZoom((z) => Math.max(0.4, z * 0.8))}
  191. className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
  192. aria-label={t('fileManager.preview.zoomOut')}
  193. >
  194. <ZoomOut className="w-4 h-4" />
  195. </button>
  196. <button
  197. onClick={() => setZoom((z) => Math.min(4, z * 1.25))}
  198. className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
  199. aria-label={t('fileManager.preview.zoomIn')}
  200. >
  201. <ZoomIn className="w-4 h-4" />
  202. </button>
  203. </>
  204. )}
  205. <button
  206. onClick={onClose}
  207. className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
  208. aria-label={t('common.close')}
  209. >
  210. <X className="w-5 h-5" />
  211. </button>
  212. </div>
  213. </div>
  214. {/* Page */}
  215. <div ref={containerRef} className="relative flex-1 min-h-0 overflow-auto bg-bambu-dark rounded-b-lg p-4">
  216. {error ? (
  217. <div className="h-full flex items-center justify-center">
  218. <p className="text-bambu-gray text-center">{error}</p>
  219. </div>
  220. ) : (
  221. <div className="flex justify-center min-w-fit">
  222. <canvas ref={canvasRef} className="shadow-lg" />
  223. </div>
  224. )}
  225. {!error && rendering && (
  226. <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
  227. <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
  228. </div>
  229. )}
  230. </div>
  231. </div>
  232. </div>
  233. );
  234. }