feat(reader): add PDF reader with lazy rendering, zoom, and progress

This commit is contained in:
neon
2025-12-27 17:51:57 -06:00
parent 212434219e
commit 28954d3df5
6 changed files with 725 additions and 7 deletions
+271
View File
@@ -15,6 +15,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-vue-next": "^0.577.0",
"pdfjs-dist": "^5.5.207",
"pinia": "^3.0.4",
"reka-ui": "^2.9.2",
"tailwind-merge": "^3.5.0",
@@ -1485,6 +1486,256 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@napi-rs/canvas": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.97.tgz",
"integrity": "sha512-8cFniXvrIEnVwuNSRCW9wirRZbHvrD3JVujdS2P5n5xiJZNZMOZcfOvJ1pb66c7jXMKHHglJEDVJGbm8XWFcXQ==",
"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.97",
"@napi-rs/canvas-darwin-arm64": "0.1.97",
"@napi-rs/canvas-darwin-x64": "0.1.97",
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.97",
"@napi-rs/canvas-linux-arm64-gnu": "0.1.97",
"@napi-rs/canvas-linux-arm64-musl": "0.1.97",
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.97",
"@napi-rs/canvas-linux-x64-gnu": "0.1.97",
"@napi-rs/canvas-linux-x64-musl": "0.1.97",
"@napi-rs/canvas-win32-arm64-msvc": "0.1.97",
"@napi-rs/canvas-win32-x64-msvc": "0.1.97"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.97.tgz",
"integrity": "sha512-V1c/WVw+NzH8vk7ZK/O8/nyBSCQimU8sfMsB/9qeSvdkGKNU7+mxy/bIF0gTgeBFmHpj30S4E9WHMSrxXGQuVQ==",
"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.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.97.tgz",
"integrity": "sha512-ok+SCEF4YejcxuJ9Rm+WWunHHpf2HmiPxfz6z1a/NFQECGXtsY7A4B8XocK1LmT1D7P174MzwPF9Wy3AUAwEPw==",
"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.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.97.tgz",
"integrity": "sha512-PUP6e6/UGlclUvAQNnuXCcnkpdUou6VYZfQOQxExLp86epOylmiwLkqXIvpFmjoTEDmPmXrI+coL/9EFU1gKPA==",
"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.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.97.tgz",
"integrity": "sha512-XyXH2L/cic8eTNtbrXCcvqHtMX/nEOxN18+7rMrAM2XtLYC/EB5s0wnO1FsLMWmK+04ZSLN9FBGipo7kpIkcOw==",
"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.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.97.tgz",
"integrity": "sha512-Kuq/M3djq0K8ktgz6nPlK7Ne5d4uWeDxPpyKWOjWDK2RIOhHVtLtyLiJw2fuldw7Vn4mhw05EZXCEr4Q76rs9w==",
"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.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.97.tgz",
"integrity": "sha512-kKmSkQVnWeqg7qdsiXvYxKhAFuHz3tkBjW/zyQv5YKUPhotpaVhpBGv5LqCngzyuRV85SXoe+OFj+Tv0a0QXkQ==",
"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.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.97.tgz",
"integrity": "sha512-Jc7I3A51jnEOIAXeLsN/M/+Z28LUeakcsXs07FLq9prXc0eYOtVwsDEv913Gr+06IRo34gJJVgT0TXvmz+N2VA==",
"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.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.97.tgz",
"integrity": "sha512-iDUBe7AilfuBSRbSa8/IGX38Mf+iCSBqoVKLSQ5XaY2JLOaqz1TVyPFEyIck7wT6mRQhQt5sN6ogfjIDfi74tg==",
"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.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.97.tgz",
"integrity": "sha512-AKLFd/v0Z5fvgqBDqhvqtAdx+fHMJ5t9JcUNKq4FIZ5WH+iegGm8HPdj00NFlCSnm83Fp3Ln8I2f7uq1aIiWaA==",
"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.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.97.tgz",
"integrity": "sha512-u883Yr6A6fO7Vpsy9YE4FVCIxzzo5sO+7pIUjjoDLjS3vQaNMkVzx5bdIpEL+ob+gU88WDK4VcxYMZ6nmnoX9A==",
"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.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.97.tgz",
"integrity": "sha512-sWtD2EE3fV0IzN+iiQUqr/Q1SwqWhs2O1FKItFlxtdDkikpEj5g7DKQpY3x55H/MAOnL8iomnlk3mcEeGiUMoQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -5570,6 +5821,13 @@
"dev": true,
"license": "MIT"
},
"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/node-releases": {
"version": "2.0.36",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
@@ -5890,6 +6148,19 @@
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"license": "MIT"
},
"node_modules/pdfjs-dist": {
"version": "5.5.207",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.5.207.tgz",
"integrity": "sha512-WMqqw06w1vUt9ZfT0gOFhMf3wHsWhaCrxGrckGs5Cci6ybDW87IvPaOd2pnBwT6BJuP/CzXDZxjFgmSULLdsdw==",
"license": "Apache-2.0",
"engines": {
"node": ">=20.19.0 || >=22.13.0 || >=24"
},
"optionalDependencies": {
"@napi-rs/canvas": "^0.1.95",
"node-readable-to-web-readable-stream": "^0.4.2"
}
},
"node_modules/perfect-debounce": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
+4 -3
View File
@@ -23,8 +23,9 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-vue-next": "^0.562.0",
"pdfjs-dist": "^5.4.449",
"pinia": "^3.0.4",
"reka-ui": "^2.6.1",
"reka-ui": "^2.7.0",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"vue": "^3.5.26",
@@ -36,7 +37,7 @@
"@types/jsdom": "^27.0.0",
"@types/node": "^22.19.3",
"@vitejs/plugin-vue": "^6.0.3",
"@vitest/eslint-plugin": "^1.6.1",
"@vitest/eslint-plugin": "^1.6.4",
"@vue/eslint-config-typescript": "^14.6.0",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.8.1",
@@ -45,7 +46,7 @@
"eslint-plugin-oxlint": "~1.35.0",
"eslint-plugin-vue": "~10.6.2",
"jiti": "^2.6.1",
"jsdom": "^27.3.0",
"jsdom": "^27.4.0",
"npm-run-all2": "^8.0.4",
"oxlint": "~1.35.0",
"prettier": "3.7.4",
@@ -17,6 +17,7 @@ import ReaderSettingsPanel from './components/ReaderSettingsPanel.vue'
import SelectionPopup from './components/SelectionPopup.vue'
import ReaderSearchPanel from './components/ReaderSearchPanel.vue'
import NoteDialog from './components/NoteDialog.vue'
import PdfReaderView from './components/PdfReaderView.vue'
import type { ReaderState } from './composables/useReaderState'
import type { FoliateRenderer } from './composables/useFoliate'
@@ -219,7 +220,9 @@ function navigateSearch(cfiTarget: string) {
</script>
<template>
<PdfReaderView v-if="fileFormat === 'pdf'" :bookId="bookId" :fileId="fileId" />
<div
v-else
class="fixed inset-0 overflow-hidden"
:style="{
background: activeMode.bg,
@@ -0,0 +1,386 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ArrowLeft, Minus, Plus, Maximize2, AlignJustify } from 'lucide-vue-next'
import { usePdf, type ZoomMode } from '../composables/usePdf'
import { useReaderProgress } from '../composables/useReaderProgress'
import { useVisibility } from '../composables/useVisibility'
const props = defineProps<{ bookId: number; fileId: number }>()
const router = useRouter()
const { pdfDoc, totalPages, loading, error, load, renderPageToCanvas, getPageViewport } = usePdf()
const progress = useReaderProgress(props.bookId, props.fileId)
const { headerVisible, showHeader } = useVisibility()
// ── Layout ────────────────────────────────────────────────────────────────────
const scrollRef = ref<HTMLElement | null>(null)
const currentPage = ref(1)
const zoomMode = ref<ZoomMode>('fit-width')
const customScale = ref(1.0)
const PAGE_GAP = 12 // px between pages
// Page dimension cache: pageNum → { width, height } at scale=1
const pageDims = ref<{ width: number; height: number }[]>([])
// ── Scale computation ─────────────────────────────────────────────────────────
const containerWidth = ref(0)
const containerHeight = ref(0)
const scale = computed(() => {
const first = pageDims.value[0]
if (!first) return 1
if (zoomMode.value === 'fit-width') return (containerWidth.value - 32) / first.width
if (zoomMode.value === 'fit-page') {
const sw = (containerWidth.value - 32) / first.width
const sh = (containerHeight.value - 48) / first.height
return Math.min(sw, sh)
}
return customScale.value
})
const zoomPercent = computed(() => Math.round(scale.value * 100))
// ── Page placeholder heights at current scale ─────────────────────────────────
const pageHeights = computed(() => pageDims.value.map((d) => Math.round(d.height * scale.value)))
// ── Canvas render tracking ────────────────────────────────────────────────────
const canvasRefs = ref<(HTMLCanvasElement | null)[]>([])
const rendered = new Set<number>() // 1-based page numbers already rendered
let renderQueue: number[] = []
let rendering = false
async function flushQueue() {
if (rendering || renderQueue.length === 0) return
rendering = true
while (renderQueue.length > 0) {
const pageNum = renderQueue.shift()!
if (!pdfDoc.value) break
const canvas = canvasRefs.value[pageNum - 1]
if (!canvas) continue
await renderPageToCanvas(pdfDoc.value as Parameters<typeof renderPageToCanvas>[0], pageNum, canvas, scale.value)
rendered.add(pageNum)
}
rendering = false
}
function enqueuePage(pageNum: number) {
if (rendered.has(pageNum) || renderQueue.includes(pageNum)) return
renderQueue.push(pageNum)
flushQueue()
}
// When scale changes, clear rendered cache and re-render visible pages.
watch(scale, () => {
rendered.clear()
renderQueue = []
rendering = false
nextTick(() => observerCallback(currentObserverEntries))
})
// ── IntersectionObserver — detects visible pages ──────────────────────────────
let currentObserverEntries: IntersectionObserverEntry[] = []
let observer: IntersectionObserver | null = null
function observerCallback(entries: IntersectionObserverEntry[]) {
currentObserverEntries = entries
for (const entry of entries) {
if (!entry.isIntersecting) continue
const idx = Number((entry.target as HTMLElement).dataset.page)
enqueuePage(idx)
// Preload adjacent pages
if (idx > 1) enqueuePage(idx - 1)
if (idx < totalPages.value) enqueuePage(idx + 1)
}
}
function setupObserver() {
observer?.disconnect()
observer = new IntersectionObserver(observerCallback, {
root: scrollRef.value,
rootMargin: '200px',
threshold: 0,
})
nextTick(() => {
const items = scrollRef.value?.querySelectorAll('[data-page]') ?? []
items.forEach((el) => observer!.observe(el))
})
}
// ── Scroll → current page tracking ───────────────────────────────────────────
let saveTimer: ReturnType<typeof setTimeout> | null = null
function onScroll() {
const el = scrollRef.value
if (!el || !pageHeights.value.length) return
let offset = el.scrollTop
let pg = 1
for (let i = 0; i < pageHeights.value.length; i++) {
const h = (pageHeights.value[i] ?? 0) + PAGE_GAP
if (offset < h) {
pg = i + 1
break
}
offset -= h
pg = i + 2
}
currentPage.value = Math.min(pg, totalPages.value)
// Debounced progress save
if (saveTimer) clearTimeout(saveTimer)
saveTimer = setTimeout(() => {
progress.pageNumber.value = currentPage.value
progress.percentage.value = (currentPage.value / totalPages.value) * 100
progress.save()
}, 2000)
}
// ── Keyboard navigation ───────────────────────────────────────────────────────
function onKeyDown(e: KeyboardEvent) {
if (!scrollRef.value) return
const pageH = pageHeights.value[currentPage.value - 1] ?? containerHeight.value
if (e.key === 'ArrowDown' || e.key === 'PageDown') {
e.preventDefault()
scrollRef.value.scrollBy({ top: pageH + PAGE_GAP, behavior: 'smooth' })
} else if (e.key === 'ArrowUp' || e.key === 'PageUp') {
e.preventDefault()
scrollRef.value.scrollBy({ top: -(pageH + PAGE_GAP), behavior: 'smooth' })
} else if (e.key === 'Home') {
goToPage(1)
} else if (e.key === 'End') {
goToPage(totalPages.value)
}
}
// ── Navigation helpers ────────────────────────────────────────────────────────
function goToPage(n: number) {
const el = scrollRef.value
if (!el) return
const clamped = Math.max(1, Math.min(n, totalPages.value))
let top = 0
for (let i = 0; i < clamped - 1; i++) top += (pageHeights.value[i] ?? 0) + PAGE_GAP
el.scrollTo({ top, behavior: 'smooth' })
}
const pageInput = ref(1)
watch(currentPage, (v) => {
pageInput.value = v
})
function onPageInputCommit() {
const n = parseInt(String(pageInput.value))
if (!isNaN(n)) goToPage(n)
}
// ── Zoom helpers ──────────────────────────────────────────────────────────────
function setZoom(mode: ZoomMode) {
zoomMode.value = mode
if (mode !== 'custom') return
}
function adjustZoom(delta: number) {
const next = Math.max(0.25, Math.min(4, (zoomMode.value === 'custom' ? customScale.value : scale.value) + delta))
customScale.value = Math.round(next * 20) / 20 // snap to 5% increments
zoomMode.value = 'custom'
}
// ── Resize observer ───────────────────────────────────────────────────────────
let resizeObserver: ResizeObserver | null = null
function measureContainer() {
if (!scrollRef.value) return
containerWidth.value = scrollRef.value.clientWidth
containerHeight.value = scrollRef.value.clientHeight
}
// ── Initialisation ────────────────────────────────────────────────────────────
onMounted(async () => {
window.addEventListener('keydown', onKeyDown)
await progress.load()
await load(props.fileId)
if (!pdfDoc.value) return
// Fetch all page natural dimensions (scale=1) upfront
const dims: { width: number; height: number }[] = []
for (let i = 1; i <= totalPages.value; i++) {
const vp = await getPageViewport(i, 1)
dims.push(vp ? { width: vp.width, height: vp.height } : { width: 595, height: 842 })
}
pageDims.value = dims
canvasRefs.value = new Array(totalPages.value).fill(null)
await nextTick()
measureContainer()
resizeObserver = new ResizeObserver(() => {
measureContainer()
rendered.clear()
renderQueue = []
rendering = false
nextTick(() => observerCallback(currentObserverEntries))
})
if (scrollRef.value) resizeObserver.observe(scrollRef.value)
setupObserver()
// Restore saved page
if (progress.pageNumber.value && progress.pageNumber.value > 1) {
await nextTick()
goToPage(progress.pageNumber.value)
}
})
onUnmounted(() => {
window.removeEventListener('keydown', onKeyDown)
observer?.disconnect()
resizeObserver?.disconnect()
if (saveTimer) clearTimeout(saveTimer)
})
// ── Derived ───────────────────────────────────────────────────────────────────
const progressPercent = computed(() => (totalPages.value ? Math.round((currentPage.value / totalPages.value) * 100) : 0))
</script>
<template>
<div class="fixed inset-0 bg-[#404040] flex flex-col overflow-hidden" @mousemove="showHeader()">
<!-- Header -->
<div
class="absolute top-0 left-0 right-0 z-50 h-12 flex items-center px-3 gap-1 transition-all duration-300"
:class="headerVisible ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-full pointer-events-none'"
style="background: rgba(30, 30, 30, 0.92); backdrop-filter: blur(8px); border-bottom: 1px solid rgba(255, 255, 255, 0.1)"
>
<!-- Back -->
<button
class="flex items-center justify-center w-8 h-8 rounded-md text-white/80 hover:text-white hover:bg-white/10 transition-colors shrink-0"
@click="router.back()"
title="Go back"
>
<ArrowLeft :size="18" />
</button>
<div class="w-px h-5 mx-1 bg-white/15 shrink-0" />
<!-- Page navigation -->
<div class="flex items-center gap-1.5">
<button
class="flex items-center justify-center w-7 h-7 rounded text-white/70 hover:text-white hover:bg-white/10 transition-colors text-sm font-medium disabled:opacity-30"
:disabled="currentPage <= 1"
@click="goToPage(currentPage - 1)"
>
‹
</button>
<div class="flex items-center gap-1 text-sm text-white/80">
<input
v-model.number="pageInput"
type="number"
min="1"
:max="totalPages"
class="w-10 text-center bg-white/10 rounded px-1 py-0.5 text-white text-sm outline-none focus:bg-white/20 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
@keydown.enter="onPageInputCommit"
@blur="onPageInputCommit"
/>
<span class="text-white/50">/</span>
<span>{{ totalPages }}</span>
</div>
<button
class="flex items-center justify-center w-7 h-7 rounded text-white/70 hover:text-white hover:bg-white/10 transition-colors text-sm font-medium disabled:opacity-30"
:disabled="currentPage >= totalPages"
@click="goToPage(currentPage + 1)"
>
›
</button>
</div>
<div class="flex-1" />
<!-- Zoom controls -->
<div class="flex items-center gap-0.5">
<button
class="flex items-center justify-center w-7 h-7 rounded text-white/70 hover:text-white hover:bg-white/10 transition-colors"
@click="adjustZoom(-0.1)"
title="Zoom out"
>
<Minus :size="14" />
</button>
<span class="w-12 text-center text-xs text-white/70 font-mono tabular-nums">{{ zoomPercent }}%</span>
<button
class="flex items-center justify-center w-7 h-7 rounded text-white/70 hover:text-white hover:bg-white/10 transition-colors"
@click="adjustZoom(0.1)"
title="Zoom in"
>
<Plus :size="14" />
</button>
<div class="w-px h-4 mx-1 bg-white/15 shrink-0" />
<button
class="flex items-center justify-center w-7 h-7 rounded transition-colors text-xs font-bold"
:class="zoomMode === 'fit-width' ? 'bg-white/20 text-white' : 'text-white/60 hover:text-white hover:bg-white/10'"
@click="setZoom('fit-width')"
title="Fit width"
>
<AlignJustify :size="14" />
</button>
<button
class="flex items-center justify-center w-7 h-7 rounded transition-colors"
:class="zoomMode === 'fit-page' ? 'bg-white/20 text-white' : 'text-white/60 hover:text-white hover:bg-white/10'"
@click="setZoom('fit-page')"
title="Fit page"
>
<Maximize2 :size="14" />
</button>
</div>
</div>
<!-- Hover zone to re-show header -->
<div class="absolute top-0 left-0 right-0 z-40 h-16 pointer-events-auto" @mouseenter="showHeader()" />
<!-- Loading -->
<div v-if="loading" class="flex-1 flex items-center justify-center">
<div class="flex flex-col items-center gap-3">
<div class="w-8 h-8 rounded-full border-2 border-white/30 border-t-white animate-spin" />
<p class="text-sm text-white/60">Loading PDF…</p>
</div>
</div>
<!-- Error -->
<div v-else-if="error" class="flex-1 flex items-center justify-center p-8">
<div class="text-center max-w-sm">
<p class="text-sm font-medium text-white/90 mb-2">Failed to load PDF</p>
<p class="text-xs text-white/50">{{ error }}</p>
</div>
</div>
<!-- Pages -->
<div v-else ref="scrollRef" class="flex-1 overflow-y-auto overflow-x-auto" @scroll="onScroll">
<div class="flex flex-col items-center py-4" :style="{ gap: `${PAGE_GAP}px` }">
<div
v-for="n in totalPages"
:key="n"
:data-page="n"
class="bg-white shadow-lg"
:style="{
width: `${Math.round((pageDims[n - 1]?.width ?? 595) * scale)}px`,
height: `${pageHeights[n - 1] ?? 0}px`,
}"
>
<canvas
:ref="
(el) => {
canvasRefs[n - 1] = el as HTMLCanvasElement | null
}
"
class="block"
/>
</div>
</div>
</div>
<!-- Footer progress bar -->
<div v-if="!loading && !error && totalPages > 0" class="absolute bottom-0 left-0 right-0 h-0.5 bg-white/10">
<div class="h-full bg-white/40 transition-all duration-300" :style="{ width: `${progressPercent}%` }" />
</div>
</div>
</template>
@@ -0,0 +1,57 @@
import { onUnmounted, ref } from 'vue'
import * as pdfjsLib from 'pdfjs-dist'
import pdfWorkerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorkerUrl
export type ZoomMode = 'fit-width' | 'fit-page' | 'custom'
// Renders one page onto the given canvas at the given scale (pdfjs-dist v5 API).
async function renderPageToCanvas(doc: pdfjsLib.PDFDocumentProxy, pageNum: number, canvas: HTMLCanvasElement, scale: number) {
const page = await doc.getPage(pageNum)
const viewport = page.getViewport({ scale })
canvas.width = viewport.width
canvas.height = viewport.height
await page.render({ canvas, viewport }).promise
page.cleanup()
}
export function usePdf() {
const pdfDoc = ref<pdfjsLib.PDFDocumentProxy | null>(null)
const totalPages = ref(0)
const loading = ref(false)
const error = ref<string | null>(null)
async function load(fileId: number) {
loading.value = true
error.value = null
try {
const res = await fetch(`/api/books/files/${fileId}/serve`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.arrayBuffer()
const doc = await pdfjsLib.getDocument({ data }).promise
pdfDoc.value = doc
totalPages.value = doc.numPages
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load PDF'
} finally {
loading.value = false
}
}
// Returns the natural width/height of a page at scale=1 (used to size placeholder divs).
async function getPageViewport(pageNum: number, scale: number) {
if (!pdfDoc.value) return null
const page = await pdfDoc.value.getPage(pageNum)
const vp = page.getViewport({ scale })
page.cleanup()
return vp
}
onUnmounted(() => {
pdfDoc.value?.destroy()
pdfDoc.value = null
})
return { pdfDoc, totalPages, loading, error, load, renderPageToCanvas, getPageViewport }
}