fix(pdf-viewer): use static worker + lazy page rendering

Worker copied to public/ via prebuild script (avoids Vite resolution issues).
IntersectionObserver renders only visible pages (+200px ahead) instead of all
at once, fixing performance for large PDFs.
This commit is contained in:
Hibryda 2026-03-11 01:27:54 +01:00
parent 199873781b
commit a74d3a74d3
3 changed files with 97 additions and 50 deletions

1
v2/.gitignore vendored
View file

@ -9,6 +9,7 @@ lerna-debug.log*
node_modules node_modules
dist dist
public/pdf.worker.min.mjs
dist-ssr dist-ssr
*.local *.local
sidecar/dist sidecar/dist

View file

@ -5,6 +5,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"prebuild": "cp node_modules/pdfjs-dist/build/pdf.worker.min.mjs public/pdf.worker.min.mjs",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json", "check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json",

View file

@ -3,11 +3,9 @@
import { convertFileSrc } from '@tauri-apps/api/core'; import { convertFileSrc } from '@tauri-apps/api/core';
import * as pdfjsLib from 'pdfjs-dist'; import * as pdfjsLib from 'pdfjs-dist';
// Configure worker — use the bundled worker from pdfjs-dist // Worker copied to public/ — Vite serves it as a static asset.
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( // Avoids Vite/Rollup resolution issues with pdfjs worker imports.
'pdfjs-dist/pdf.worker.min.mjs', pdfjsLib.GlobalWorkerOptions.workerSrc = '/pdf.worker.min.mjs';
import.meta.url,
).href;
interface Props { interface Props {
filePath: string; filePath: string;
@ -22,7 +20,10 @@
let error = $state<string | null>(null); let error = $state<string | null>(null);
let pdfDoc: pdfjsLib.PDFDocumentProxy | null = null; let pdfDoc: pdfjsLib.PDFDocumentProxy | null = null;
let renderTask: { cancel: () => void } | null = null; let observer: IntersectionObserver | null = null;
// Track which pages have been rendered and which are pending
let renderedPages = new Set<number>();
let renderingPages = new Set<number>();
const SCALE_STEP = 0.25; const SCALE_STEP = 0.25;
const MIN_SCALE = 0.5; const MIN_SCALE = 0.5;
@ -31,22 +32,14 @@
async function loadPdf(path: string) { async function loadPdf(path: string) {
loading = true; loading = true;
error = null; error = null;
cleanup();
// Clean up previous document
if (pdfDoc) {
pdfDoc.destroy();
pdfDoc = null;
}
if (container) {
container.querySelectorAll('.pdf-page-canvas').forEach(c => c.remove());
}
try { try {
const assetUrl = convertFileSrc(path); const assetUrl = convertFileSrc(path);
const loadingTask = pdfjsLib.getDocument(assetUrl); const loadingTask = pdfjsLib.getDocument(assetUrl);
pdfDoc = await loadingTask.promise; pdfDoc = await loadingTask.promise;
pageCount = pdfDoc.numPages; pageCount = pdfDoc.numPages;
await renderAllPages(); createPlaceholders();
} catch (e) { } catch (e) {
error = `Failed to load PDF: ${e}`; error = `Failed to load PDF: ${e}`;
console.warn('PDF load error:', e); console.warn('PDF load error:', e);
@ -55,62 +48,115 @@
} }
} }
async function renderAllPages() { /** Create placeholder divs for each page, observed for lazy rendering */
function createPlaceholders() {
if (!pdfDoc || !container) return; if (!pdfDoc || !container) return;
// Clear existing canvases // Clean existing
container.querySelectorAll('.pdf-page-canvas').forEach(c => c.remove()); container.innerHTML = '';
renderedPages.clear();
renderingPages.clear();
// Stop old observer
observer?.disconnect();
observer = new IntersectionObserver(onIntersect, {
root: container,
rootMargin: '200px 0px', // pre-render 200px ahead
});
for (let i = 1; i <= pdfDoc.numPages; i++) { for (let i = 1; i <= pdfDoc.numPages; i++) {
await renderPage(i); const placeholder = document.createElement('div');
placeholder.className = 'pdf-page-slot';
placeholder.dataset.page = String(i);
// Estimate height from first page viewport (or fallback)
placeholder.style.width = '100%';
placeholder.style.minHeight = '20rem';
container.appendChild(placeholder);
observer.observe(placeholder);
} }
} }
async function renderPage(pageNum: number) { function onIntersect(entries: IntersectionObserverEntry[]) {
if (!pdfDoc || !container) return; for (const entry of entries) {
if (!entry.isIntersecting) continue;
const pageNum = Number((entry.target as HTMLElement).dataset.page);
if (!pageNum || renderedPages.has(pageNum) || renderingPages.has(pageNum)) continue;
renderPage(pageNum, entry.target as HTMLElement);
}
}
const page = await pdfDoc.getPage(pageNum); async function renderPage(pageNum: number, slot: HTMLElement) {
const viewport = page.getViewport({ scale: currentScale * window.devicePixelRatio }); if (!pdfDoc) return;
const displayViewport = page.getViewport({ scale: currentScale }); renderingPages.add(pageNum);
const canvas = document.createElement('canvas');
canvas.className = 'pdf-page-canvas';
canvas.width = viewport.width;
canvas.height = viewport.height;
canvas.style.width = `${displayViewport.width}px`;
canvas.style.height = `${displayViewport.height}px`;
container.appendChild(canvas);
const ctx = canvas.getContext('2d');
if (!ctx) return;
renderTask = page.render({ canvasContext: ctx, viewport });
try { try {
await renderTask.promise; const page = await pdfDoc.getPage(pageNum);
const viewport = page.getViewport({ scale: currentScale * window.devicePixelRatio });
const displayViewport = page.getViewport({ scale: currentScale });
const canvas = document.createElement('canvas');
canvas.className = 'pdf-page-canvas';
canvas.width = viewport.width;
canvas.height = viewport.height;
canvas.style.width = `${displayViewport.width}px`;
canvas.style.height = `${displayViewport.height}px`;
// Replace placeholder content with canvas
slot.innerHTML = '';
slot.style.minHeight = '';
slot.appendChild(canvas);
const ctx = canvas.getContext('2d');
if (!ctx) return;
const task = page.render({ canvasContext: ctx, viewport });
await task.promise;
renderedPages.add(pageNum);
// Stop observing once rendered
observer?.unobserve(slot);
} catch (e: unknown) { } catch (e: unknown) {
// Ignore cancelled renders
if (e && typeof e === 'object' && 'name' in e && (e as { name: string }).name !== 'RenderingCancelledException') { if (e && typeof e === 'object' && 'name' in e && (e as { name: string }).name !== 'RenderingCancelledException') {
console.warn(`Failed to render page ${pageNum}:`, e); console.warn(`Failed to render page ${pageNum}:`, e);
} }
} finally {
renderingPages.delete(pageNum);
} }
} }
function rerender() {
renderedPages.clear();
renderingPages.clear();
createPlaceholders();
}
function zoomIn() { function zoomIn() {
if (currentScale >= MAX_SCALE) return; if (currentScale >= MAX_SCALE) return;
currentScale = Math.min(MAX_SCALE, currentScale + SCALE_STEP); currentScale = Math.min(MAX_SCALE, currentScale + SCALE_STEP);
renderAllPages(); rerender();
} }
function zoomOut() { function zoomOut() {
if (currentScale <= MIN_SCALE) return; if (currentScale <= MIN_SCALE) return;
currentScale = Math.max(MIN_SCALE, currentScale - SCALE_STEP); currentScale = Math.max(MIN_SCALE, currentScale - SCALE_STEP);
renderAllPages(); rerender();
} }
function resetZoom() { function resetZoom() {
currentScale = 1.0; currentScale = 1.0;
renderAllPages(); rerender();
}
function cleanup() {
observer?.disconnect();
observer = null;
renderedPages.clear();
renderingPages.clear();
if (container) container.innerHTML = '';
if (pdfDoc) {
pdfDoc.destroy();
pdfDoc = null;
}
} }
onMount(() => { onMount(() => {
@ -128,13 +174,7 @@
}); });
onDestroy(() => { onDestroy(() => {
if (renderTask) { cleanup();
try { renderTask.cancel(); } catch { /* ignore */ }
}
if (pdfDoc) {
pdfDoc.destroy();
pdfDoc = null;
}
}); });
</script> </script>
@ -230,6 +270,11 @@
padding: 0.75rem; padding: 0.75rem;
} }
.pdf-pages :global(.pdf-page-slot) {
display: flex;
justify-content: center;
}
.pdf-pages :global(.pdf-page-canvas) { .pdf-pages :global(.pdf-page-canvas) {
box-shadow: 0 1px 4px color-mix(in srgb, var(--ctp-crust) 60%, transparent); box-shadow: 0 1px 4px color-mix(in srgb, var(--ctp-crust) 60%, transparent);
border-radius: 2px; border-radius: 2px;