feat: add BookletViewer component with pdfjs-dist

Modal PDF viewer for album booklets with page navigation,
zoom controls, and keyboard shortcuts. Uses pdfjs-dist to
render PDF pages from URL in canvas.
This commit is contained in:
vicrodh committed 2026-02-26 14:40:14 -06:00
1 parent c110d9ab85
commit 9f82235bbb
3 files changed
+678

No files matched your search

+271
View File
@@ -14,6 +14,7 @@
"@tauri-apps/plugin-dialog": "^2.5.0",
"@tauri-apps/plugin-opener": "^2",
"lucide-svelte": "^0.562.0",
"pdfjs-dist": "^5.4.624",
"svelte-i18n": "^4.0.0"
},
"devDependencies": {
@@ -772,6 +773,256 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@napi-rs/canvas": {
"version": "0.1.95",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.95.tgz",
"integrity": "sha512-lkg23ge+rgyhgUwXmlbkPEhuhHq/hUi/gXKH+4I7vO+lJrbNfEYcQdJLIGjKyXLQzgFiiyDAwh5vAe/tITAE+w==",
"license": "MIT",
"optional": true,
"workspaces": [
"e2e/*"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "0.1.95",
"@napi-rs/canvas-darwin-arm64": "0.1.95",
"@napi-rs/canvas-darwin-x64": "0.1.95",
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.95",
"@napi-rs/canvas-linux-arm64-gnu": "0.1.95",
"@napi-rs/canvas-linux-arm64-musl": "0.1.95",
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.95",
"@napi-rs/canvas-linux-x64-gnu": "0.1.95",
"@napi-rs/canvas-linux-x64-musl": "0.1.95",
"@napi-rs/canvas-win32-arm64-msvc": "0.1.95",
"@napi-rs/canvas-win32-x64-msvc": "0.1.95"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "0.1.95",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.95.tgz",
"integrity": "sha512-SqTh0wsYbetckMXEvHqmR7HKRJujVf1sYv1xdlhkifg6TlCSysz1opa49LlS3+xWuazcQcfRfmhA07HxxxGsAA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "0.1.95",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.95.tgz",
"integrity": "sha512-F7jT0Syu+B9DGBUBcMk3qCRIxAWiDXmvEjamwbYfbZl7asI1pmXZUnCOoIu49Wt0RNooToYfRDxU9omD6t5Xuw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "0.1.95",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.95.tgz",
"integrity": "sha512-54eb2Ho15RDjYGXO/harjRznBrAvu+j5nQ85Z4Qd6Qg3slR8/Ja+Yvvy9G4yo7rdX6NR9GPkZeSTf2UcKXwaXw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "0.1.95",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.95.tgz",
"integrity": "sha512-hYaLCSLx5bmbnclzQc3ado3PgZ66blJWzjXp0wJmdwpr/kH+Mwhj6vuytJIomgksyJoCdIqIa4N6aiqBGJtJ5Q==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "0.1.95",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.95.tgz",
"integrity": "sha512-J7VipONahKsmScPZsipHVQBqpbZx4favaD8/enWzzlGcjiwycOoymL7f4tNeqdjK0su19bDOUt6mjp9gsPWYlw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "0.1.95",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.95.tgz",
"integrity": "sha512-PXy0UT1J/8MPG8UAkWp6Fd51ZtIZINFzIjGH909JjQrtCuJf3X6nanHYdz1A+Wq9o4aoPAw1YEUpFS1lelsVlg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "0.1.95",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.95.tgz",
"integrity": "sha512-2IzCkW2RHRdcgF9W5/plHvYFpc6uikyjMb5SxjqmNxfyDFz9/HB89yhi8YQo0SNqrGRI7yBVDec7Pt+uMyRWsg==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "0.1.95",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.95.tgz",
"integrity": "sha512-OV/ol/OtcUr4qDhQg8G7SdViZX8XyQeKpPsVv/j3+7U178FGoU4M+yIocdVo1ih/A8GQ63+LjF4jDoEjaVU8Pw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "0.1.95",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.95.tgz",
"integrity": "sha512-Z5KzqBK/XzPz5+SFHKz7yKqClEQ8pOiEDdgk5SlphBLVNb8JFIJkxhtJKSvnJyHh2rjVgiFmvtJzMF0gNwwKyQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
"version": "0.1.95",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.95.tgz",
"integrity": "sha512-aj0YbRpe8qVJ4OzMsK7NfNQePgcf9zkGFzNZ9mSuaxXzhpLHmlF2GivNdCdNOg8WzA/NxV6IU4c5XkXadUMLeA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "0.1.95",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.95.tgz",
"integrity": "sha512-GA8leTTCfdjuHi8reICTIxU0081PhXvl3lzIniLUjeLACx9GubUiyzkwFb+oyeKLS5IAGZFLKnzAf4wm2epRlA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@polka/url": {
"version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
@@ -2469,6 +2720,13 @@
"integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==",
"license": "ISC"
},
"node_modules/node-readable-to-web-readable-stream": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/node-readable-to-web-readable-stream/-/node-readable-to-web-readable-stream-0.4.2.tgz",
"integrity": "sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==",
"license": "MIT",
"optional": true
},
"node_modules/obug": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
@@ -2500,6 +2758,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/pdfjs-dist": {
"version": "5.4.624",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.624.tgz",
"integrity": "sha512-sm6TxKTtWv1Oh6n3C6J6a8odejb5uO4A4zo/2dgkHuC0iu8ZMAXOezEODkVaoVp8nX1Xzr+0WxFJJmUr45hQzg==",
"license": "Apache-2.0",
"engines": {
"node": ">=20.16.0 || >=22.3.0"
},
"optionalDependencies": {
"@napi-rs/canvas": "^0.1.88",
"node-readable-to-web-readable-stream": "^0.4.2"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+1
View File
@@ -30,6 +30,7 @@
"@tauri-apps/plugin-dialog": "^2.5.0",
"@tauri-apps/plugin-opener": "^2",
"lucide-svelte": "^0.562.0",
"pdfjs-dist": "^5.4.624",
"svelte-i18n": "^4.0.0"
},
"devDependencies": {
+406
View File
@@ -0,0 +1,406 @@
<script lang="ts">
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { X, ChevronLeft, ChevronRight, ZoomIn, ZoomOut, Maximize } from 'lucide-svelte';
import * as pdfjsLib from 'pdfjs-dist';
// Configure worker
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url
).toString();
interface Props {
isOpen: boolean;
onClose: () => void;
url: string;
title?: string;
}
let {
isOpen,
onClose,
url,
title = ''
}: Props = $props();
let canvas: HTMLCanvasElement | null = $state(null);
let pdfDoc: any = null;
let currentPage = $state(1);
let totalPages = $state(0);
let zoom = $state(1);
let isLoading = $state(true);
let error = $state('');
let currentRenderTask: any = null;
let containerEl: HTMLDivElement | null = $state(null);
const MIN_ZOOM = 0.5;
const MAX_ZOOM = 3;
const ZOOM_STEP = 0.25;
async function loadPdf() {
if (!url) return;
isLoading = true;
error = '';
try {
const loadingTask = pdfjsLib.getDocument({
url,
disableRange: false,
});
pdfDoc = await loadingTask.promise;
totalPages = pdfDoc.numPages;
currentPage = 1;
await renderPage();
} catch (err: any) {
console.error('[BookletViewer] Failed to load PDF:', err);
error = err?.message || 'Failed to load booklet';
} finally {
isLoading = false;
}
}
async function renderPage() {
if (!pdfDoc || !canvas) return;
if (currentRenderTask) {
currentRenderTask.cancel();
currentRenderTask = null;
}
try {
const page = await pdfDoc.getPage(currentPage);
const dpr = window.devicePixelRatio || 1;
const viewport = page.getViewport({ scale: zoom * dpr * 1.5 });
canvas.width = viewport.width;
canvas.height = viewport.height;
canvas.style.width = `${viewport.width / (dpr * 1.5)}px`;
canvas.style.height = `${viewport.height / (dpr * 1.5)}px`;
const context = canvas.getContext('2d');
if (!context) return;
context.fillStyle = 'white';
context.fillRect(0, 0, canvas.width, canvas.height);
currentRenderTask = page.render({
canvasContext: context,
viewport,
});
await currentRenderTask.promise;
currentRenderTask = null;
} catch (err: any) {
if (err?.name !== 'RenderingCancelledException') {
console.error('[BookletViewer] Render error:', err);
}
}
}
function prevPage() {
if (currentPage > 1) {
currentPage--;
renderPage();
}
}
function nextPage() {
if (currentPage < totalPages) {
currentPage++;
renderPage();
}
}
function zoomIn() {
if (zoom < MAX_ZOOM) {
zoom = Math.min(zoom + ZOOM_STEP, MAX_ZOOM);
renderPage();
}
}
function zoomOut() {
if (zoom > MIN_ZOOM) {
zoom = Math.max(zoom - ZOOM_STEP, MIN_ZOOM);
renderPage();
}
}
function fitToWidth() {
zoom = 1;
renderPage();
}
function handleKeydown(e: KeyboardEvent) {
if (!isOpen) return;
switch (e.key) {
case 'Escape':
onClose();
break;
case 'ArrowLeft':
prevPage();
break;
case 'ArrowRight':
nextPage();
break;
case '+':
case '=':
zoomIn();
break;
case '-':
zoomOut();
break;
}
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
onClose();
}
}
function portal(node: HTMLElement) {
document.body.appendChild(node);
return {
destroy() {
if (pdfDoc) {
pdfDoc.destroy();
pdfDoc = null;
}
node.remove();
}
};
}
$effect(() => {
if (isOpen && url) {
loadPdf();
}
});
$effect(() => {
if (!isOpen && pdfDoc) {
pdfDoc.destroy();
pdfDoc = null;
currentPage = 1;
totalPages = 0;
zoom = 1;
}
});
</script>
<svelte:window onkeydown={handleKeydown} />
{#if isOpen}
<div class="booklet-overlay" use:portal onclick={handleBackdropClick}>
<!-- Toolbar -->
<div class="booklet-toolbar">
<div class="toolbar-left">
<span class="booklet-title">{title || $t('album.booklet')}</span>
</div>
<div class="toolbar-center">
<button class="toolbar-btn" onclick={prevPage} disabled={currentPage <= 1}>
<ChevronLeft size={18} />
</button>
<span class="page-indicator">
{currentPage} / {totalPages}
</span>
<button class="toolbar-btn" onclick={nextPage} disabled={currentPage >= totalPages}>
<ChevronRight size={18} />
</button>
<div class="toolbar-divider"></div>
<button class="toolbar-btn" onclick={zoomOut} disabled={zoom <= MIN_ZOOM}>
<ZoomOut size={16} />
</button>
<span class="zoom-indicator">{Math.round(zoom * 100)}%</span>
<button class="toolbar-btn" onclick={zoomIn} disabled={zoom >= MAX_ZOOM}>
<ZoomIn size={16} />
</button>
<button class="toolbar-btn" onclick={fitToWidth} title={$t('album.bookletFitWidth')}>
<Maximize size={16} />
</button>
</div>
<div class="toolbar-right">
<button class="toolbar-btn close-btn" onclick={onClose}>
<X size={18} />
</button>
</div>
</div>
<!-- Content -->
<div class="booklet-content" bind:this={containerEl}>
{#if isLoading}
<div class="booklet-loading">
<div class="spinner"></div>
<span>{$t('album.bookletLoading')}</span>
</div>
{:else if error}
<div class="booklet-error">
<span>{$t('album.bookletError')}</span>
<span class="error-detail">{error}</span>
</div>
{:else}
<div class="canvas-wrapper">
<canvas bind:this={canvas}></canvas>
</div>
{/if}
</div>
</div>
{/if}
<style>
.booklet-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.85);
display: flex;
flex-direction: column;
z-index: 200000;
animation: booklet-fade-in 150ms ease;
}
@keyframes booklet-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
.booklet-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(12px);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
flex-shrink: 0;
-webkit-app-region: no-drag;
}
.toolbar-left,
.toolbar-right {
flex: 1;
display: flex;
align-items: center;
}
.toolbar-right {
justify-content: flex-end;
}
.toolbar-center {
display: flex;
align-items: center;
gap: 8px;
}
.booklet-title {
color: rgba(255, 255, 255, 0.8);
font-size: 13px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
}
.toolbar-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: transparent;
border: none;
border-radius: 6px;
color: rgba(255, 255, 255, 0.8);
cursor: pointer;
transition: background 120ms ease, color 120ms ease;
}
.toolbar-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.1);
color: white;
}
.toolbar-btn:disabled {
opacity: 0.3;
cursor: default;
}
.close-btn:hover {
background: rgba(255, 60, 60, 0.3);
color: #ff6b6b;
}
.page-indicator,
.zoom-indicator {
color: rgba(255, 255, 255, 0.7);
font-size: 12px;
font-variant-numeric: tabular-nums;
min-width: 48px;
text-align: center;
}
.toolbar-divider {
width: 1px;
height: 20px;
background: rgba(255, 255, 255, 0.15);
margin: 0 4px;
}
.booklet-content {
flex: 1;
overflow: auto;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 24px;
}
.canvas-wrapper {
display: flex;
align-items: center;
justify-content: center;
min-height: 100%;
}
.canvas-wrapper canvas {
border-radius: 4px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.booklet-loading,
.booklet-error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: rgba(255, 255, 255, 0.6);
font-size: 14px;
height: 100%;
align-self: center;
}
.error-detail {
font-size: 12px;
color: rgba(255, 255, 255, 0.35);
max-width: 400px;
text-align: center;
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid rgba(255, 255, 255, 0.15);
border-top-color: rgba(255, 255, 255, 0.6);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>