From eb8aa92cf0da99bf2c27dd25e3e6147cee8816a8 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 15 Apr 2026 14:34:26 +0200 Subject: [PATCH] refactor(pdf): extract usePdfRenderer and PdfControls from PdfViewer (#196) Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/components/PdfControls.svelte | 125 ++++++ frontend/src/lib/components/PdfViewer.svelte | 379 ++++-------------- .../__tests__/usePdfRenderer.svelte.test.ts | 69 ++++ .../src/lib/hooks/usePdfRenderer.svelte.ts | 203 ++++++++++ 4 files changed, 464 insertions(+), 312 deletions(-) create mode 100644 frontend/src/lib/components/PdfControls.svelte create mode 100644 frontend/src/lib/hooks/__tests__/usePdfRenderer.svelte.test.ts create mode 100644 frontend/src/lib/hooks/usePdfRenderer.svelte.ts diff --git a/frontend/src/lib/components/PdfControls.svelte b/frontend/src/lib/components/PdfControls.svelte new file mode 100644 index 00000000..17c3ef96 --- /dev/null +++ b/frontend/src/lib/components/PdfControls.svelte @@ -0,0 +1,125 @@ + + +
+ +
+ + + {#if totalPages > 0} + + {currentPage} / {totalPages} + + {/if} + + +
+ + +
+ + +
+ + + {#if annotationCount > 0} + + {/if} +
diff --git a/frontend/src/lib/components/PdfViewer.svelte b/frontend/src/lib/components/PdfViewer.svelte index 359c9700..87a651f3 100644 --- a/frontend/src/lib/components/PdfViewer.svelte +++ b/frontend/src/lib/components/PdfViewer.svelte @@ -1,6 +1,7 @@ {#if !url}

Keine Datei vorhanden

-{:else if error} +{:else if renderer.error}

Fehler beim Laden der PDF

{annotationUpdateError}
{/if} - -
- -
- - {#if totalPages > 0} - - {currentPage} / {totalPages} - - {/if} - - -
- - -
- - -
- - - {#if annotations.length > 0} - - {/if} -
+ renderer.prevPage()} + onNext={() => renderer.nextPage()} + onZoomIn={() => renderer.zoomIn()} + onZoomOut={() => renderer.zoomOut()} + onToggleAnnotations={() => (showAnnotations = !showAnnotations)} + />
- {#if loading} + {#if renderer.loading}
@@ -501,7 +254,9 @@ function zoomOut() { >
{#if showAnnotations} a.pageNumber === currentPage)} + annotations={visibleAnnotations.filter( + (a) => a.pageNumber === renderer.currentPage + )} canDraw={transcribeMode} color={TRANSCRIPTION_COLOR} blockNumbers={blockNumbers} diff --git a/frontend/src/lib/hooks/__tests__/usePdfRenderer.svelte.test.ts b/frontend/src/lib/hooks/__tests__/usePdfRenderer.svelte.test.ts new file mode 100644 index 00000000..d36b5c66 --- /dev/null +++ b/frontend/src/lib/hooks/__tests__/usePdfRenderer.svelte.test.ts @@ -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); + }); +}); diff --git a/frontend/src/lib/hooks/usePdfRenderer.svelte.ts b/frontend/src/lib/hooks/usePdfRenderer.svelte.ts new file mode 100644 index 00000000..87b37f8c --- /dev/null +++ b/frontend/src/lib/hooks/usePdfRenderer.svelte.ts @@ -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(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 { + 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 { + 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 pdfDoc !== null; + }, + get pdfjsReady() { + return pdfjsReady; + }, + setElements, + init, + loadDocument, + renderCurrentPage, + prerender, + prevPage, + nextPage, + goToPage, + zoomIn, + zoomOut, + destroy + }; +}