Files
familienarchiv/frontend/src/lib/document/viewer/usePdfRenderer.svelte.ts
Marcel e529f9f7d1 refactor(pdf-viewer): export LibLoader type and update callers
Exporting LibLoader gives the type a stable, named identity.
PdfViewer.svelte and PdfViewer.svelte.spec.ts now import it directly
instead of using Parameters<typeof createPdfRenderer>[0].

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 23:12:55 +02:00

206 lines
4.5 KiB
TypeScript

import type { PDFDocumentProxy, RenderTask } from 'pdfjs-dist';
export type LibLoader = () => Promise<readonly [typeof import('pdfjs-dist'), { default: string }]>;
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<string | null>(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<void> {
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<void> {
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<void> {
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<void> {
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
};
}