280 lines
6.8 KiB
Vue
280 lines
6.8 KiB
Vue
<template>
|
||
<div class="pdf-viewer">
|
||
<!-- Toolbar -->
|
||
<div class="pdf-toolbar">
|
||
<span class="pdf-toolbar__name">{{ fileName || src }}</span>
|
||
<div class="pdf-toolbar__right">
|
||
<span class="pdf-toolbar__pages" v-if="totalPages">
|
||
{{ totalPages }} pages
|
||
</span>
|
||
<button class="pdf-zoom-btn" @click="zoom(-0.25)" title="Zoom out">
|
||
−
|
||
</button>
|
||
<span class="pdf-zoom-label">{{ Math.round(scale * 100) }}%</span>
|
||
<button class="pdf-zoom-btn" @click="zoom(+0.25)" title="Zoom in">
|
||
+
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Scroll container — all pages rendered vertically -->
|
||
<div class="pdf-scroll" ref="scrollEl">
|
||
<div v-if="loading" class="pdf-msg">Loading…</div>
|
||
<div v-else-if="error" class="pdf-msg pdf-msg--err">{{ error }}</div>
|
||
<div v-else class="pdf-pages">
|
||
<canvas
|
||
v-for="n in totalPages"
|
||
:key="n"
|
||
:ref="(el) => setCanvasRef(el, n)"
|
||
class="pdf-page"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from "vue";
|
||
|
||
const props = defineProps({
|
||
/** URL string, Uint8Array, or ArrayBuffer */
|
||
src: { type: [String, Object], required: true },
|
||
fileName: { type: String },
|
||
initialScale: { type: Number, default: 1.25 },
|
||
});
|
||
|
||
// ── state ──────────────────────────────────────────────────────
|
||
const scrollEl = ref(null);
|
||
const loading = ref(true);
|
||
const error = ref(null);
|
||
const totalPages = ref(0);
|
||
const scale = ref(props.initialScale);
|
||
|
||
let pdfjsLib = null;
|
||
let pdfDoc = null;
|
||
const canvasMap = {}; // pageNum → <canvas> element
|
||
const taskMap = {}; // pageNum → active renderTask
|
||
|
||
// ── canvas ref collector ───────────────────────────────────────
|
||
function setCanvasRef(el, n) {
|
||
if (el) canvasMap[n] = el;
|
||
}
|
||
|
||
// ── load ───────────────────────────────────────────────────────
|
||
async function loadPdf(src) {
|
||
loading.value = true;
|
||
error.value = null;
|
||
totalPages.value = 0;
|
||
|
||
try {
|
||
if (!pdfjsLib) {
|
||
// Dynamic import — never runs in Node/SSR
|
||
pdfjsLib = await import("pdfjs-dist");
|
||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||
"pdfjs-dist/build/pdf.worker.mjs",
|
||
import.meta.url,
|
||
).toString();
|
||
}
|
||
|
||
if (pdfDoc) {
|
||
pdfDoc.destroy();
|
||
pdfDoc = null;
|
||
}
|
||
|
||
pdfDoc = await pdfjsLib.getDocument(src).promise;
|
||
totalPages.value = pdfDoc.numPages;
|
||
loading.value = false;
|
||
|
||
// Wait for Vue to stamp out all the <canvas> elements
|
||
await nextTick();
|
||
await renderAll();
|
||
} catch (e) {
|
||
error.value = `Failed to load PDF: ${e.message}`;
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
// ── render all pages ───────────────────────────────────────────
|
||
async function renderAll() {
|
||
// Cancel any in-flight tasks first
|
||
for (const task of Object.values(taskMap)) {
|
||
try {
|
||
task.cancel();
|
||
} catch {}
|
||
}
|
||
|
||
// Render pages sequentially — avoids memory thrash on mobile/Android
|
||
for (let n = 1; n <= totalPages.value; n++) {
|
||
await renderPage(n);
|
||
}
|
||
}
|
||
|
||
async function renderPage(n) {
|
||
if (!pdfDoc) return;
|
||
const canvas = canvasMap[n];
|
||
if (!canvas) return;
|
||
|
||
try {
|
||
const page = await pdfDoc.getPage(n);
|
||
const viewport = page.getViewport({ scale: scale.value });
|
||
|
||
canvas.width = viewport.width;
|
||
canvas.height = viewport.height;
|
||
|
||
const task = page.render({
|
||
canvasContext: canvas.getContext("2d"),
|
||
viewport,
|
||
});
|
||
taskMap[n] = task;
|
||
await task.promise;
|
||
delete taskMap[n];
|
||
} catch (e) {
|
||
if (e?.name !== "RenderingCancelledException") {
|
||
console.warn(`Page ${n} render error:`, e.message);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── zoom ───────────────────────────────────────────────────────
|
||
async function zoom(delta) {
|
||
scale.value = Math.min(4, Math.max(0.5, +(scale.value + delta).toFixed(2)));
|
||
await nextTick();
|
||
await renderAll();
|
||
}
|
||
|
||
// ── lifecycle ──────────────────────────────────────────────────
|
||
onMounted(() => loadPdf(props.src));
|
||
|
||
watch(
|
||
() => props.src,
|
||
(src) => loadPdf(src),
|
||
);
|
||
|
||
onBeforeUnmount(() => {
|
||
for (const task of Object.values(taskMap)) {
|
||
try {
|
||
task.cancel();
|
||
} catch {}
|
||
}
|
||
if (pdfDoc) pdfDoc.destroy();
|
||
});
|
||
</script>
|
||
|
||
<style scoped>
|
||
/* ── root ── */
|
||
.pdf-viewer {
|
||
--bar-bg: #141414;
|
||
--bg: #1e1e1e;
|
||
--border: #2e2e2e;
|
||
--text: #d4d4d4;
|
||
--muted: #666;
|
||
--accent: #5ba3f5;
|
||
|
||
display: flex;
|
||
flex-direction: column;
|
||
height: 100%; /* parent must supply a bounded height */
|
||
max-height: 80vh;
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
/* font-family: "JetBrains Mono", "Fira Mono", monospace; */
|
||
border-radius: 6px;
|
||
}
|
||
|
||
/* ── toolbar ── */
|
||
.pdf-toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
flex-shrink: 0;
|
||
padding: 0 14px;
|
||
height: 40px;
|
||
background: var(--bar-bg);
|
||
border-bottom: 1px solid var(--border);
|
||
gap: 12px;
|
||
}
|
||
|
||
.pdf-toolbar__name {
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: var(--text);
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.pdf-toolbar__right {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.pdf-toolbar__pages {
|
||
font-size: 11px;
|
||
color: var(--muted);
|
||
}
|
||
|
||
.pdf-zoom-btn {
|
||
background: none;
|
||
border: 1px solid var(--border);
|
||
color: var(--text);
|
||
border-radius: 4px;
|
||
width: 26px;
|
||
height: 26px;
|
||
font-size: 16px;
|
||
line-height: 1;
|
||
cursor: pointer;
|
||
transition:
|
||
border-color 0.15s,
|
||
color 0.15s;
|
||
}
|
||
|
||
.pdf-zoom-btn:hover {
|
||
border-color: var(--accent);
|
||
color: var(--accent);
|
||
}
|
||
|
||
.pdf-zoom-label {
|
||
font-size: 11px;
|
||
color: var(--muted);
|
||
min-width: 36px;
|
||
text-align: center;
|
||
}
|
||
|
||
/* ── scroll area — the fix lives here ── */
|
||
.pdf-scroll {
|
||
flex: 1;
|
||
min-height: 0; /* lets the flex child shrink so overflow-y activates */
|
||
overflow-y: auto;
|
||
overflow-x: auto;
|
||
padding: 20px 0;
|
||
background: var(--bg);
|
||
}
|
||
|
||
/* ── pages stack ── */
|
||
.pdf-pages {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 16px;
|
||
}
|
||
|
||
.pdf-page {
|
||
display: block;
|
||
box-shadow: 0 2px 16px rgba(0, 0, 0, 0.6);
|
||
border-radius: 2px;
|
||
}
|
||
|
||
/* ── status ── */
|
||
.pdf-msg {
|
||
text-align: center;
|
||
padding: 60px 20px;
|
||
font-size: 13px;
|
||
color: var(--muted);
|
||
}
|
||
|
||
.pdf-msg--err {
|
||
color: #e06c6c;
|
||
}
|
||
</style>
|