Files
2026-03-26 00:10:58 +07:00

280 lines
6.8 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>