import type { PDFDocumentProxy, RenderTask } from 'pdfjs-dist'; export type LibLoader = () => Promise; const defaultLibLoader: LibLoader = () => Promise.all([import('pdfjs-dist'), import('pdfjs-dist/build/pdf.worker.min.mjs?url')]); export function createPdfRenderer(libLoader: LibLoader = defaultLibLoader) { // Reactive state — exposed via getters let currentPage = $state(1); let totalPages = $state(0); let scale = $state(1.5); let loading = $state(false); let error = $state(null); let pdfjsReady = $state(false); // Internal mutable refs — NOT $state to avoid reactive loops let pdfDoc: PDFDocumentProxy | null = null; let canvasEl: HTMLCanvasElement | null = null; let textLayerEl: HTMLDivElement | null = null; let renderTask: RenderTask | null = null; let textLayerInstance: { cancel: () => void } | null = null; let pdfjsLib: typeof import('pdfjs-dist') | null = null; async function init(): Promise { if (pdfjsReady) return; const [lib, { default: workerUrl }] = await libLoader(); lib.GlobalWorkerOptions.workerSrc = workerUrl; pdfjsLib = lib; pdfjsReady = true; } function setElements(canvas: HTMLCanvasElement, textLayer: HTMLDivElement): void { canvasEl = canvas; textLayerEl = textLayer; } async function loadDocument(src: string): Promise { if (!pdfjsLib) return; loading = true; error = null; pdfDoc = null; currentPage = 1; totalPages = 0; try { const loadingTask = pdfjsLib.getDocument(src); const doc = await loadingTask.promise; pdfDoc = doc; totalPages = doc.numPages; } catch (e) { error = e instanceof Error ? e.message : 'Failed to load PDF'; } finally { loading = false; } } async function renderCurrentPage(): Promise { if (!pdfjsLib || !canvasEl || !textLayerEl || !pdfDoc) return; if (renderTask) { renderTask.cancel(); renderTask = null; } if (textLayerInstance) { textLayerInstance.cancel(); textLayerInstance = null; } let page; try { page = await pdfDoc.getPage(currentPage); } catch { return; } const dpr = window.devicePixelRatio || 1; const viewport = page.getViewport({ scale: scale * dpr }); const canvas = canvasEl; const ctx = canvas.getContext('2d'); if (!ctx) return; canvas.width = viewport.width; canvas.height = viewport.height; canvas.style.width = `${viewport.width / dpr}px`; canvas.style.height = `${viewport.height / dpr}px`; const task = page.render({ canvas, canvasContext: ctx, viewport }); renderTask = task; try { await task.promise; } catch (e: unknown) { if ( typeof e === 'object' && e !== null && 'name' in e && (e as { name: string }).name === 'RenderingCancelledException' ) return; return; } renderTask = null; const textDiv = textLayerEl; if (!textDiv) return; textDiv.innerHTML = ''; textDiv.style.width = `${viewport.width / dpr}px`; textDiv.style.height = `${viewport.height / dpr}px`; const tl = new pdfjsLib.TextLayer({ textContentSource: page.streamTextContent(), container: textDiv, viewport }); textLayerInstance = tl; try { await tl.render(); } catch { // cancelled } } async function prerender(): Promise { if (!pdfDoc) return; const neighbors = [currentPage - 1, currentPage + 1].filter( (n) => n >= 1 && n <= (pdfDoc?.numPages ?? 0) ); for (const n of neighbors) { try { await pdfDoc.getPage(n); } catch { // ignore } } } function prevPage(): void { if (currentPage > 1) currentPage -= 1; } function nextPage(): void { if (currentPage < totalPages) currentPage += 1; } function goToPage(n: number): void { if (n >= 1 && n <= totalPages) currentPage = n; } function zoomIn(): void { scale += 0.25; } function zoomOut(): void { if (scale > 0.5) scale -= 0.25; } function destroy(): void { if (renderTask) { renderTask.cancel(); renderTask = null; } if (textLayerInstance) { textLayerInstance.cancel(); textLayerInstance = null; } pdfDoc?.destroy(); pdfDoc = null; } return { get currentPage() { return currentPage; }, get totalPages() { return totalPages; }, get scale() { return scale; }, get loading() { return loading; }, get error() { return error; }, get isLoaded() { return totalPages > 0; }, get pdfjsReady() { return pdfjsReady; }, setElements, init, loadDocument, renderCurrentPage, prerender, prevPage, nextPage, goToPage, zoomIn, zoomOut, destroy }; }