refactor(pdf): extract usePdfRenderer and PdfControls from PdfViewer (#196)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
125
frontend/src/lib/components/PdfControls.svelte
Normal file
125
frontend/src/lib/components/PdfControls.svelte
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
currentPage: number;
|
||||||
|
totalPages: number;
|
||||||
|
isLoaded: boolean;
|
||||||
|
showAnnotations: boolean;
|
||||||
|
annotationCount: number;
|
||||||
|
onPrev: () => void;
|
||||||
|
onNext: () => void;
|
||||||
|
onZoomIn: () => void;
|
||||||
|
onZoomOut: () => void;
|
||||||
|
onToggleAnnotations: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
currentPage,
|
||||||
|
totalPages,
|
||||||
|
isLoaded,
|
||||||
|
showAnnotations,
|
||||||
|
annotationCount,
|
||||||
|
onPrev,
|
||||||
|
onNext,
|
||||||
|
onZoomIn,
|
||||||
|
onZoomOut,
|
||||||
|
onToggleAnnotations
|
||||||
|
}: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex shrink-0 items-center justify-between gap-2 border-b border-pdf-ctrl px-4 py-2">
|
||||||
|
<!-- Page navigation: prev button, page counter, next button -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onclick={onPrev}
|
||||||
|
disabled={currentPage <= 1}
|
||||||
|
aria-label="Zurück"
|
||||||
|
class="rounded p-1 text-ink-3 transition hover:bg-surface/10 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if totalPages > 0}
|
||||||
|
<span class="font-sans text-xs text-ink-2 tabular-nums">
|
||||||
|
{currentPage} / {totalPages}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onclick={onNext}
|
||||||
|
disabled={!isLoaded || currentPage >= totalPages}
|
||||||
|
aria-label="Weiter"
|
||||||
|
class="rounded p-1 text-ink-3 transition hover:bg-surface/10 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Zoom controls -->
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onclick={onZoomOut}
|
||||||
|
aria-label="Verkleinern"
|
||||||
|
class="rounded p-1 text-ink-3 transition hover:bg-surface/10"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
<path stroke-linecap="round" d="M21 21l-4.35-4.35M8 11h6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={onZoomIn}
|
||||||
|
aria-label="Vergrößern"
|
||||||
|
class="rounded p-1 text-ink-3 transition hover:bg-surface/10"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
<path stroke-linecap="round" d="M21 21l-4.35-4.35M11 8v6M8 11h6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Annotation visibility toggle (only when annotations exist) -->
|
||||||
|
{#if annotationCount > 0}
|
||||||
|
<button
|
||||||
|
onclick={onToggleAnnotations}
|
||||||
|
aria-label={showAnnotations ? m.pdf_annotations_hide() : m.pdf_annotations_show()}
|
||||||
|
class="flex items-center gap-1.5 rounded px-2 py-1 font-sans text-xs transition {showAnnotations
|
||||||
|
? 'text-ink-2 hover:bg-surface/10'
|
||||||
|
: 'bg-surface/10 text-accent'}"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-3.5 w-3.5 shrink-0"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
{#if showAnnotations}
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</svg>
|
||||||
|
{showAnnotations ? m.pdf_annotations_hide() : m.pdf_annotations_show()}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, setContext } from 'svelte';
|
import { onMount, setContext } from 'svelte';
|
||||||
import type { PDFDocumentProxy, PDFPageProxy, RenderTask } from 'pdfjs-dist';
|
import { createPdfRenderer } from '$lib/hooks/usePdfRenderer.svelte';
|
||||||
|
import PdfControls from './PdfControls.svelte';
|
||||||
import AnnotationLayer from './AnnotationLayer.svelte';
|
import AnnotationLayer from './AnnotationLayer.svelte';
|
||||||
import type { Annotation } from '$lib/types';
|
import type { Annotation } from '$lib/types';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
@@ -34,26 +35,12 @@ let {
|
|||||||
flashAnnotationId?: string | null;
|
flashAnnotationId?: string | null;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let pdfDoc = $state<PDFDocumentProxy | null>(null);
|
const renderer = createPdfRenderer();
|
||||||
let currentPage = $state(1);
|
|
||||||
let totalPages = $state(0);
|
|
||||||
let scale = $state(1.5);
|
|
||||||
let loading = $state(false);
|
|
||||||
let error = $state<string | null>(null);
|
|
||||||
|
|
||||||
// Canvas and text layer container refs — bound via bind:this, not reactive state
|
// Canvas and text layer container refs — bound via bind:this
|
||||||
let canvasEl = $state<HTMLCanvasElement | null>(null);
|
let canvasEl = $state<HTMLCanvasElement | null>(null);
|
||||||
let textLayerEl = $state<HTMLDivElement | null>(null);
|
let textLayerEl = $state<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
// Internal mutable refs for in-flight tasks — NOT $state to avoid reactive loops
|
|
||||||
let renderTask: RenderTask | null = null;
|
|
||||||
let textLayerInstance: { cancel: () => void } | null = null;
|
|
||||||
|
|
||||||
// Holds the dynamically-loaded pdfjs module (browser-only)
|
|
||||||
// Not $state — we use pdfjsReady as the reactive trigger instead
|
|
||||||
let pdfjsLib: typeof import('pdfjs-dist') | null = null;
|
|
||||||
let pdfjsReady = $state(false);
|
|
||||||
|
|
||||||
let annotations = $state<Annotation[]>([]);
|
let annotations = $state<Annotation[]>([]);
|
||||||
let showAnnotations = $state(true);
|
let showAnnotations = $state(true);
|
||||||
let annotationUpdateError = $state<string | null>(null);
|
let annotationUpdateError = $state<string | null>(null);
|
||||||
@@ -66,115 +53,63 @@ const visibleAnnotations = $derived(
|
|||||||
const outdatedCount = $derived(annotations.length - visibleAnnotations.length);
|
const outdatedCount = $derived(annotations.length - visibleAnnotations.length);
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
// Dynamic import keeps pdfjs out of the SSR bundle entirely
|
await renderer.init();
|
||||||
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;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadDocument(src: string) {
|
// Wire DOM elements to the renderer after they mount
|
||||||
if (!pdfjsLib) return;
|
$effect(() => {
|
||||||
loading = true;
|
if (canvasEl && textLayerEl) {
|
||||||
error = null;
|
renderer.setElements(canvasEl, textLayerEl);
|
||||||
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 renderPage(doc: PDFDocumentProxy, pageNum: number) {
|
$effect(() => {
|
||||||
if (!pdfjsLib || !canvasEl || !textLayerEl) return;
|
if (renderer.pdfjsReady && url) {
|
||||||
|
renderer.loadDocument(url);
|
||||||
// Cancel any in-flight render
|
|
||||||
if (renderTask) {
|
|
||||||
renderTask.cancel();
|
|
||||||
renderTask = null;
|
|
||||||
}
|
|
||||||
if (textLayerInstance) {
|
|
||||||
textLayerInstance.cancel();
|
|
||||||
textLayerInstance = null;
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let page: PDFPageProxy;
|
$effect(() => {
|
||||||
try {
|
// Read scale and currentPage synchronously so Svelte tracks them as dependencies.
|
||||||
page = await doc.getPage(pageNum);
|
if (renderer.isLoaded && renderer.currentPage && renderer.scale > 0) {
|
||||||
} catch {
|
renderer.renderCurrentPage().then(() => renderer.prerender());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (documentId && annotationReloadKey >= 0) {
|
||||||
|
loadAnnotations(documentId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (transcribeMode) showAnnotations = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Scroll-sync: when activeAnnotationId changes, navigate to its page
|
||||||
|
let prevActiveAnnotationId: string | null = null;
|
||||||
|
$effect(() => {
|
||||||
|
const id = activeAnnotationId;
|
||||||
|
if (!id || id === prevActiveAnnotationId || !renderer.isLoaded) {
|
||||||
|
prevActiveAnnotationId = id;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
prevActiveAnnotationId = id;
|
||||||
|
|
||||||
const dpr = window.devicePixelRatio || 1;
|
const ann = annotations.find((a) => a.id === id);
|
||||||
const viewport = page.getViewport({ scale: scale * dpr });
|
if (!ann) return;
|
||||||
|
|
||||||
const canvas = canvasEl;
|
if (ann.pageNumber !== renderer.currentPage) {
|
||||||
const ctx = canvas.getContext('2d');
|
renderer.goToPage(ann.pageNumber);
|
||||||
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;
|
|
||||||
|
|
||||||
// Text layer
|
requestAnimationFrame(() => {
|
||||||
const textDiv = textLayerEl;
|
requestAnimationFrame(() => {
|
||||||
if (!textDiv) return;
|
const el = document.querySelector(`[data-testid="annotation-${id}"]`);
|
||||||
textDiv.innerHTML = '';
|
el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
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(doc: PDFDocumentProxy, pageNum: number) {
|
|
||||||
const neighbors = [pageNum - 1, pageNum + 1].filter((n) => n >= 1 && n <= doc.numPages);
|
|
||||||
for (const n of neighbors) {
|
|
||||||
try {
|
|
||||||
await doc.getPage(n);
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadAnnotations(docId: string) {
|
async function loadAnnotations(docId: string) {
|
||||||
if (!docId) return;
|
if (!docId) return;
|
||||||
@@ -213,7 +148,7 @@ setContext('annotationUpdate', updateAnnotation);
|
|||||||
|
|
||||||
async function handleDraw(rect: { x: number; y: number; width: number; height: number }) {
|
async function handleDraw(rect: { x: number; y: number; width: number; height: number }) {
|
||||||
if (!documentId || !transcribeMode) return;
|
if (!documentId || !transcribeMode) return;
|
||||||
await onTranscriptionDraw?.({ ...rect, pageNumber: currentPage });
|
await onTranscriptionDraw?.({ ...rect, pageNumber: renderer.currentPage });
|
||||||
await loadAnnotations(documentId);
|
await loadAnnotations(documentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,82 +156,13 @@ function handleAnnotationClick(id: string) {
|
|||||||
activeAnnotationId = id;
|
activeAnnotationId = id;
|
||||||
onAnnotationClick?.(id);
|
onAnnotationClick?.(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (pdfjsReady && url) {
|
|
||||||
loadDocument(url);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
// Read scale synchronously so Svelte tracks it as a dependency.
|
|
||||||
// Without this, zoom changes don't re-trigger the effect because
|
|
||||||
// scale is only read inside the async renderPage call.
|
|
||||||
if (pdfDoc && currentPage && scale > 0) {
|
|
||||||
renderPage(pdfDoc, currentPage).then(() => {
|
|
||||||
if (pdfDoc) prerender(pdfDoc, currentPage);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (documentId && annotationReloadKey >= 0) {
|
|
||||||
loadAnnotations(documentId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (transcribeMode) showAnnotations = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Scroll-sync: when activeAnnotationId changes, navigate to its page
|
|
||||||
let prevActiveAnnotationId: string | null = null;
|
|
||||||
$effect(() => {
|
|
||||||
const id = activeAnnotationId;
|
|
||||||
if (!id || id === prevActiveAnnotationId || !pdfDoc) {
|
|
||||||
prevActiveAnnotationId = id;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
prevActiveAnnotationId = id;
|
|
||||||
|
|
||||||
const ann = annotations.find((a) => a.id === id);
|
|
||||||
if (!ann) return;
|
|
||||||
|
|
||||||
if (ann.pageNumber !== currentPage) {
|
|
||||||
currentPage = ann.pageNumber;
|
|
||||||
}
|
|
||||||
|
|
||||||
// After page renders, scroll the annotation into view (double-rAF for async render)
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const el = document.querySelector(`[data-testid="annotation-${id}"]`);
|
|
||||||
el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function prevPage() {
|
|
||||||
if (currentPage > 1) currentPage -= 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
function nextPage() {
|
|
||||||
if (pdfDoc && currentPage < pdfDoc.numPages) currentPage += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
function zoomIn() {
|
|
||||||
scale += 0.25;
|
|
||||||
}
|
|
||||||
|
|
||||||
function zoomOut() {
|
|
||||||
if (scale > 0.5) scale -= 0.25;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if !url}
|
{#if !url}
|
||||||
<div class="flex h-full w-full items-center justify-center bg-pdf-bg text-ink-3">
|
<div class="flex h-full w-full items-center justify-center bg-pdf-bg text-ink-3">
|
||||||
<p class="font-sans text-sm">Keine Datei vorhanden</p>
|
<p class="font-sans text-sm">Keine Datei vorhanden</p>
|
||||||
</div>
|
</div>
|
||||||
{:else if error}
|
{:else if renderer.error}
|
||||||
<div class="flex h-full w-full flex-col items-center justify-center gap-3 bg-pdf-bg text-ink-3">
|
<div class="flex h-full w-full flex-col items-center justify-center gap-3 bg-pdf-bg text-ink-3">
|
||||||
<p class="font-sans text-sm text-red-400">Fehler beim Laden der PDF</p>
|
<p class="font-sans text-sm text-red-400">Fehler beim Laden der PDF</p>
|
||||||
<a
|
<a
|
||||||
@@ -351,136 +217,23 @@ function zoomOut() {
|
|||||||
<span class="font-sans text-xs text-red-300">{annotationUpdateError}</span>
|
<span class="font-sans text-xs text-red-300">{annotationUpdateError}</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<!-- Controls -->
|
|
||||||
<div
|
|
||||||
class="flex shrink-0 items-center justify-between gap-2 border-b border-pdf-ctrl px-4 py-2"
|
|
||||||
>
|
|
||||||
<!-- Page navigation -->
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onclick={prevPage}
|
|
||||||
disabled={currentPage <= 1}
|
|
||||||
aria-label="Zurück"
|
|
||||||
class="rounded p-1 text-ink-3 transition hover:bg-surface/10 disabled:opacity-40"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="h-4 w-4"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{#if totalPages > 0}
|
<PdfControls
|
||||||
<span class="font-sans text-xs text-ink-2 tabular-nums">
|
currentPage={renderer.currentPage}
|
||||||
{currentPage} / {totalPages}
|
totalPages={renderer.totalPages}
|
||||||
</span>
|
isLoaded={renderer.isLoaded}
|
||||||
{/if}
|
showAnnotations={showAnnotations}
|
||||||
|
annotationCount={annotations.length}
|
||||||
<button
|
onPrev={() => renderer.prevPage()}
|
||||||
onclick={nextPage}
|
onNext={() => renderer.nextPage()}
|
||||||
disabled={!pdfDoc || currentPage >= totalPages}
|
onZoomIn={() => renderer.zoomIn()}
|
||||||
aria-label="Weiter"
|
onZoomOut={() => renderer.zoomOut()}
|
||||||
class="rounded p-1 text-ink-3 transition hover:bg-surface/10 disabled:opacity-40"
|
onToggleAnnotations={() => (showAnnotations = !showAnnotations)}
|
||||||
>
|
/>
|
||||||
<svg
|
|
||||||
class="h-4 w-4"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Zoom controls -->
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<button
|
|
||||||
onclick={zoomOut}
|
|
||||||
aria-label="Verkleinern"
|
|
||||||
class="rounded p-1 text-ink-3 transition hover:bg-surface/10"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="h-4 w-4"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<circle cx="11" cy="11" r="8" /><path
|
|
||||||
stroke-linecap="round"
|
|
||||||
d="M21 21l-4.35-4.35M8 11h6"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onclick={zoomIn}
|
|
||||||
aria-label="Vergrößern"
|
|
||||||
class="rounded p-1 text-ink-3 transition hover:bg-surface/10"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="h-4 w-4"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<circle cx="11" cy="11" r="8" /><path
|
|
||||||
stroke-linecap="round"
|
|
||||||
d="M21 21l-4.35-4.35M11 8v6M8 11h6"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Annotation visibility toggle (shown when annotations exist) -->
|
|
||||||
{#if annotations.length > 0}
|
|
||||||
<button
|
|
||||||
onclick={() => (showAnnotations = !showAnnotations)}
|
|
||||||
aria-label={showAnnotations ? m.pdf_annotations_hide() : m.pdf_annotations_show()}
|
|
||||||
class="flex items-center gap-1.5 rounded px-2 py-1 font-sans text-xs transition {showAnnotations
|
|
||||||
? 'text-ink-2 hover:bg-surface/10'
|
|
||||||
: 'bg-surface/10 text-accent'}"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="h-3.5 w-3.5 shrink-0"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
{#if showAnnotations}
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</svg>
|
|
||||||
{showAnnotations ? m.pdf_annotations_hide() : m.pdf_annotations_show()}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- PDF canvas area -->
|
<!-- PDF canvas area -->
|
||||||
<div class="relative flex-1 overflow-auto">
|
<div class="relative flex-1 overflow-auto">
|
||||||
{#if loading}
|
{#if renderer.loading}
|
||||||
<div class="flex h-full items-center justify-center">
|
<div class="flex h-full items-center justify-center">
|
||||||
<div
|
<div
|
||||||
class="h-8 w-8 animate-spin rounded-full border-2 border-white/20 border-t-white"
|
class="h-8 w-8 animate-spin rounded-full border-2 border-white/20 border-t-white"
|
||||||
@@ -490,7 +243,7 @@ function zoomOut() {
|
|||||||
<div class="flex min-h-full items-start justify-center p-4">
|
<div class="flex min-h-full items-start justify-center p-4">
|
||||||
<div
|
<div
|
||||||
class="pdf-page relative shadow-xl"
|
class="pdf-page relative shadow-xl"
|
||||||
data-page-number={currentPage}
|
data-page-number={renderer.currentPage}
|
||||||
style="position: relative"
|
style="position: relative"
|
||||||
>
|
>
|
||||||
<canvas bind:this={canvasEl}></canvas>
|
<canvas bind:this={canvasEl}></canvas>
|
||||||
@@ -501,7 +254,9 @@ function zoomOut() {
|
|||||||
></div>
|
></div>
|
||||||
{#if showAnnotations}
|
{#if showAnnotations}
|
||||||
<AnnotationLayer
|
<AnnotationLayer
|
||||||
annotations={visibleAnnotations.filter((a) => a.pageNumber === currentPage)}
|
annotations={visibleAnnotations.filter(
|
||||||
|
(a) => a.pageNumber === renderer.currentPage
|
||||||
|
)}
|
||||||
canDraw={transcribeMode}
|
canDraw={transcribeMode}
|
||||||
color={TRANSCRIPTION_COLOR}
|
color={TRANSCRIPTION_COLOR}
|
||||||
blockNumbers={blockNumbers}
|
blockNumbers={blockNumbers}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
203
frontend/src/lib/hooks/usePdfRenderer.svelte.ts
Normal file
203
frontend/src/lib/hooks/usePdfRenderer.svelte.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user