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:
parent
199873781b
commit
a74d3a74d3
3 changed files with 97 additions and 50 deletions
1
v2/.gitignore
vendored
1
v2/.gitignore
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue