open-webui / src /lib /components /common /PDFViewer.svelte
oki692's picture
Deploy Open WebUI
87a665c verified
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import pdfWorkerUrl from 'pdfjs-dist/build/pdf.worker.mjs?url';
import panzoom, { type PanZoom } from 'panzoom';
import Spinner from './Spinner.svelte';
export let url: string | null = null;
export let data: ArrayBuffer | Uint8Array | null = null;
export let className = 'w-full h-[70vh]';
let outerContainer: HTMLDivElement;
let sceneElement: HTMLDivElement;
let loading = true;
let error = '';
let pdfDoc: any = null;
let pzInstance: PanZoom | null = null;
let zoomLevel = 1;
let rerenderTimer: ReturnType<typeof setTimeout> | null = null;
let lastRenderedZoom = 1;
const initPanzoom = () => {
if (pzInstance) {
pzInstance.dispose();
}
if (sceneElement) {
pzInstance = panzoom(sceneElement, {
bounds: true,
boundsPadding: 0.1,
zoomSpeed: 0.065,
beforeWheel: (e) => {
// Only zoom on pinch (ctrlKey / metaKey); let normal scroll pass through
if (!e.ctrlKey && !e.metaKey) {
return true; // returning true cancels the panzoom wheel handling
}
return false;
},
beforeMouseDown: (e) => {
// Only allow drag-to-pan when zoomed in (not at default scale)
const transform = pzInstance?.getTransform();
if (transform && Math.abs(transform.scale - 1) < 0.01) {
return true; // cancel panzoom mouse handling at 1x — allow text selection / normal interaction
}
return false;
}
});
pzInstance.on('zoom', () => {
zoomLevel = pzInstance?.getTransform()?.scale ?? 1;
// Debounced re-render at new resolution so text stays crisp
if (rerenderTimer) clearTimeout(rerenderTimer);
rerenderTimer = setTimeout(() => {
if (Math.abs(zoomLevel - lastRenderedZoom) > 0.05) {
rerenderPages(zoomLevel);
}
}, 300);
});
}
};
const zoomIn = () => {
if (!pzInstance || !outerContainer) return;
const cx = outerContainer.clientWidth / 2;
const cy = outerContainer.clientHeight / 2;
pzInstance.zoomTo(cx, cy, 1.25); // +25%
zoomLevel = pzInstance.getTransform().scale;
};
const zoomOut = () => {
if (!pzInstance || !outerContainer) return;
const cx = outerContainer.clientWidth / 2;
const cy = outerContainer.clientHeight / 2;
pzInstance.zoomTo(cx, cy, 0.8); // -20% (inverse of 1.25)
zoomLevel = pzInstance.getTransform().scale;
};
export const resetView = () => {
if (pzInstance) {
pzInstance.moveTo(0, 0);
pzInstance.zoomAbs(0, 0, 1);
zoomLevel = 1;
rerenderPages(1);
}
};
// Re-render existing canvases at a new zoom level (preserves panzoom transform)
const rerenderPages = async (forZoom: number) => {
if (!pdfDoc || !sceneElement) return;
const dpr = window.devicePixelRatio || 1;
const containerWidth = outerContainer?.clientWidth || 800;
const canvases = sceneElement.querySelectorAll('canvas');
for (let i = 0; i < canvases.length; i++) {
const page = await pdfDoc.getPage(i + 1);
const viewport = page.getViewport({ scale: 1 });
const cssScale = containerWidth / viewport.width;
const renderScale = cssScale * forZoom * dpr;
const scaledViewport = page.getViewport({ scale: renderScale });
const canvas = canvases[i];
canvas.width = scaledViewport.width;
canvas.height = scaledViewport.height;
const ctx = canvas.getContext('2d');
if (ctx) {
await page.render({ canvasContext: ctx, viewport: scaledViewport }).promise;
}
}
lastRenderedZoom = forZoom;
};
const renderAllPages = async () => {
if (!pdfDoc || !sceneElement) return;
// Clear previous canvases
sceneElement.innerHTML = '';
const dpr = window.devicePixelRatio || 1;
for (let i = 1; i <= pdfDoc.numPages; i++) {
const page = await pdfDoc.getPage(i);
const viewport = page.getViewport({ scale: 1 });
// Scale to fit container width
const containerWidth = outerContainer?.clientWidth || 800;
const cssScale = containerWidth / viewport.width;
const renderScale = cssScale * dpr;
const scaledViewport = page.getViewport({ scale: renderScale });
const canvas = document.createElement('canvas');
canvas.width = scaledViewport.width;
canvas.height = scaledViewport.height;
// CSS size stays at the CSS-pixel dimensions for layout
canvas.style.width = `${Math.round(cssScale * viewport.width)}px`;
canvas.style.height = `${Math.round(cssScale * viewport.height)}px`;
canvas.style.display = 'block';
if (i > 1) {
canvas.style.marginTop = '4px';
}
sceneElement.appendChild(canvas);
const ctx = canvas.getContext('2d');
await page.render({
canvasContext: ctx,
viewport: scaledViewport
}).promise;
}
lastRenderedZoom = 1;
initPanzoom();
};
const loadPdf = async () => {
if (!url && !data) return;
loading = true;
error = '';
try {
const pdfjs = await import('pdfjs-dist');
pdfjs.GlobalWorkerOptions.workerSrc = pdfWorkerUrl;
let pdfData: ArrayBuffer | Uint8Array;
if (data) {
pdfData = data;
} else {
// Fetch with credentials so auth cookies are sent
const res = await fetch(url!, { credentials: 'include' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
pdfData = await res.arrayBuffer();
}
pdfDoc = await pdfjs.getDocument({ data: pdfData }).promise;
await renderAllPages();
} catch (e) {
console.error('PDF render error:', e);
error = 'Failed to load PDF.';
} finally {
loading = false;
}
};
onMount(() => {
loadPdf();
});
onDestroy(() => {
if (rerenderTimer) clearTimeout(rerenderTimer);
pzInstance?.dispose();
if (pdfDoc) {
pdfDoc.destroy();
pdfDoc = null;
}
});
</script>
<div class="relative {className}">
{#if loading}
<div class="absolute inset-0 flex items-center justify-center">
<Spinner className="size-5" />
</div>
{:else if error}
<div class="absolute inset-0 flex items-center justify-center text-sm text-red-500">
{error}
</div>
{/if}
<div class="overflow-y-auto h-full" bind:this={outerContainer}>
<div bind:this={sceneElement} class="w-full"></div>
</div>
{#if !loading && !error && pdfDoc}
<div
class="absolute bottom-3 left-1/2 -translate-x-1/2 z-10 flex items-center gap-0.5 rounded-lg bg-white/90 dark:bg-gray-850/90 backdrop-blur-sm shadow-lg border border-gray-200/60 dark:border-gray-700/60 px-1 py-0.5"
>
<button
class="p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-500 dark:text-gray-400"
on:click={zoomOut}
aria-label="Zoom out"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
fill-rule="evenodd"
d="M4 10a.75.75 0 0 1 .75-.75h10.5a.75.75 0 0 1 0 1.5H4.75A.75.75 0 0 1 4 10Z"
clip-rule="evenodd"
/>
</svg>
</button>
<button
class="px-1.5 py-1 min-w-[3rem] text-center text-[11px] font-medium text-gray-500 dark:text-gray-400 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition tabular-nums"
on:click={resetView}
aria-label="Reset zoom"
>
{Math.round(zoomLevel * 100)}%
</button>
<button
class="p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-500 dark:text-gray-400"
on:click={zoomIn}
aria-label="Zoom in"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
d="M10.75 4.75a.75.75 0 0 0-1.5 0v4.5h-4.5a.75.75 0 0 0 0 1.5h4.5v4.5a.75.75 0 0 0 1.5 0v-4.5h4.5a.75.75 0 0 0 0-1.5h-4.5v-4.5Z"
/>
</svg>
</button>
</div>
{/if}
</div>