refactor(pdf): extract usePdfRenderer and PdfControls from PdfViewer (#196)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-15 14:34:26 +02:00
parent bc3fec11a9
commit eb8aa92cf0
4 changed files with 464 additions and 312 deletions

View File

@@ -0,0 +1,69 @@
import { describe, it, expect } from 'vitest';
import { createPdfRenderer } from '../usePdfRenderer.svelte';
// Note: init() and loadDocument() require pdfjsLib (browser module).
// These tests cover pure state logic only — bounds clamping and zoom limits.
describe('createPdfRenderer', () => {
it('starts at page 1 with scale 1.5 and no error', () => {
const r = createPdfRenderer();
expect(r.currentPage).toBe(1);
expect(r.scale).toBe(1.5);
expect(r.totalPages).toBe(0);
expect(r.loading).toBe(false);
expect(r.error).toBeNull();
expect(r.isLoaded).toBe(false);
expect(r.pdfjsReady).toBe(false);
});
it('prevPage does not go below page 1', () => {
const r = createPdfRenderer();
r.prevPage();
expect(r.currentPage).toBe(1);
});
it('nextPage does not exceed totalPages', () => {
const r = createPdfRenderer();
// totalPages = 0, so 1 < 0 is false → stays at 1
r.nextPage();
expect(r.currentPage).toBe(1);
});
it('goToPage does not navigate when n > totalPages', () => {
const r = createPdfRenderer();
r.goToPage(5);
expect(r.currentPage).toBe(1);
});
it('goToPage does not navigate when n < 1', () => {
const r = createPdfRenderer();
r.goToPage(0);
expect(r.currentPage).toBe(1);
});
it('zoomIn increases scale by 0.25', () => {
const r = createPdfRenderer();
r.zoomIn();
expect(r.scale).toBeCloseTo(1.75);
});
it('zoomOut decreases scale by 0.25', () => {
const r = createPdfRenderer();
r.zoomOut();
expect(r.scale).toBeCloseTo(1.25);
});
it('zoomOut does not go below 0.5', () => {
const r = createPdfRenderer();
for (let i = 0; i < 20; i++) r.zoomOut();
expect(r.scale).toBeCloseTo(0.5);
});
it('loadDocument is a no-op when pdfjsLib not initialized', async () => {
const r = createPdfRenderer();
await r.loadDocument('/some/path');
// No-op because pdfjsLib is null (init not called)
expect(r.error).toBeNull();
expect(r.loading).toBe(false);
});
});

View File

@@ -0,0 +1,203 @@
import type { PDFDocumentProxy, RenderTask } from 'pdfjs-dist';
export function createPdfRenderer() {
// 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 Promise.all([
import('pdfjs-dist'),
import('pdfjs-dist/build/pdf.worker.min.mjs?url')
]);
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 pdfDoc !== null;
},
get pdfjsReady() {
return pdfjsReady;
},
setElements,
init,
loadDocument,
renderCurrentPage,
prerender,
prevPage,
nextPage,
goToPage,
zoomIn,
zoomOut,
destroy
};
}