fix(reader): fix PDF viewer with shallowRef and parallel page loading

This commit is contained in:
neon
2025-12-27 20:54:45 -06:00
parent 28954d3df5
commit 98bb29a519
3 changed files with 401 additions and 248 deletions
@@ -1,133 +1,209 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue' import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ArrowLeft, Minus, Plus, Maximize2, AlignJustify } from 'lucide-vue-next' import { AlignJustify, ArrowLeft, ChevronLeft, ChevronRight, Maximize2, Minus, Plus, RotateCw, Search, X } from 'lucide-vue-next'
import { usePdf, type ZoomMode } from '../composables/usePdf' import { usePdf } from '../composables/usePdf'
import { useReaderProgress } from '../composables/useReaderProgress' import { useReaderProgress } from '../composables/useReaderProgress'
import { useVisibility } from '../composables/useVisibility' import { useVisibility } from '../composables/useVisibility'
import type { PageDim } from '../composables/usePdf'
const props = defineProps<{ bookId: number; fileId: number }>() const props = defineProps<{ bookId: number; fileId: number }>()
const router = useRouter() const router = useRouter()
const { pdfDoc, totalPages, loading, error, load, renderPageToCanvas, getPageViewport } = usePdf()
const { pdfDoc, totalPages, loading, error, load, getAllPageDims, renderPage, getTextContent } = usePdf()
const progress = useReaderProgress(props.bookId, props.fileId) const progress = useReaderProgress(props.bookId, props.fileId)
const { headerVisible, showHeader } = useVisibility() const { headerVisible, showHeader } = useVisibility()
// ── Layout ──────────────────────────────────────────────────────────────────── // ── State ─────────────────────────────────────────────────────────────────────
const scrollRef = ref<HTMLElement | null>(null) const scrollRef = ref<HTMLElement | null>(null)
const currentPage = ref(1) const currentPage = ref(1)
const pageInput = ref(1)
const rotation = ref<0 | 90 | 180 | 270>(0)
const spread = ref<'none' | 'odd' | 'even'>('none')
const showFind = ref(false)
const findQuery = ref('')
const findMatches = ref(0)
const PAGE_GAP = 16
// ── Page dimensions ───────────────────────────────────────────────────────────
const pageDims = ref<PageDim[]>([])
// After rotation, swap width/height for 90°/270°.
const effectiveDims = computed(() =>
pageDims.value.map((d) => (rotation.value === 90 || rotation.value === 270 ? { width: d.height, height: d.width } : d)),
)
// ── Container size ────────────────────────────────────────────────────────────
const containerW = ref(0)
const containerH = ref(0)
// ── Zoom ──────────────────────────────────────────────────────────────────────
type ZoomMode = 'fit-width' | 'fit-page' | 'custom'
const zoomMode = ref<ZoomMode>('fit-width') const zoomMode = ref<ZoomMode>('fit-width')
const customScale = ref(1.0) 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 scale = computed(() => {
const first = pageDims.value[0] const first = effectiveDims.value[0]
if (!first) return 1 if (!first || !containerW.value) return 1
if (zoomMode.value === 'fit-width') return (containerWidth.value - 32) / first.width if (spread.value !== 'none') {
if (zoomMode.value === 'fit-page') { // Two-page spread: fit two pages side by side
const sw = (containerWidth.value - 32) / first.width const sw = (containerW.value - 48) / (first.width * 2 + PAGE_GAP)
const sh = (containerHeight.value - 48) / first.height if (zoomMode.value === 'fit-page') return Math.min(sw, (containerH.value - 48) / first.height)
return Math.min(sw, sh) if (zoomMode.value === 'fit-width') return sw
} }
if (zoomMode.value === 'fit-width') return (containerW.value - 32) / first.width
if (zoomMode.value === 'fit-page') return Math.min((containerW.value - 32) / first.width, (containerH.value - 48) / first.height)
return customScale.value return customScale.value
}) })
const zoomPercent = computed(() => Math.round(scale.value * 100)) const zoomPct = computed(() => Math.round(scale.value * 100))
// ── Page placeholder heights at current scale ───────────────────────────────── function adjustZoom(delta: number) {
const pageHeights = computed(() => pageDims.value.map((d) => Math.round(d.height * scale.value))) customScale.value = Math.round(Math.max(0.25, Math.min(4, (zoomMode.value === 'custom' ? customScale.value : scale.value) + delta) * 20) / 20)
zoomMode.value = 'custom'
}
// ── Canvas render tracking ──────────────────────────────────────────────────── // ── Spread pages into rows ────────────────────────────────────────────────────
const canvasRefs = ref<(HTMLCanvasElement | null)[]>([]) // Returns array of rows, each row = [pageNum] or [pageNum, pageNum+1]
const rendered = new Set<number>() // 1-based page numbers already rendered const pageRows = computed<number[][]>(() => {
const rows: number[][] = []
const total = totalPages.value
if (spread.value === 'none') {
for (let i = 1; i <= total; i++) rows.push([i])
return rows
}
// 'odd' spread: page 1 alone, then pairs [2,3], [4,5]...
// 'even' spread: pairs [1,2], [3,4]...
let i = spread.value === 'odd' ? 1 : 0
if (spread.value === 'odd' && total >= 1) {
rows.push([1])
i = 2
}
while (i < total) {
const a = i + 1
const b = i + 2
rows.push(b <= total ? [a, b] : [a])
i += 2
}
return rows
})
// Height of each row in px
const rowHeights = computed(() =>
pageRows.value.map((row) => {
const maxH = Math.max(...row.map((n) => Math.round((effectiveDims.value[n - 1]?.height ?? 842) * scale.value)))
return maxH
}),
)
// ── Canvas / text-layer tracking ──────────────────────────────────────────────
const canvasMap = ref(new Map<number, HTMLCanvasElement>())
const textLayerMap = ref(new Map<number, HTMLElement>())
const rendered = new Set<number>()
let renderQueue: number[] = [] let renderQueue: number[] = []
let rendering = false let isRendering = false
async function flushQueue() { async function flushQueue() {
if (rendering || renderQueue.length === 0) return if (isRendering) return
rendering = true isRendering = true
while (renderQueue.length > 0) { while (renderQueue.length > 0) {
const pageNum = renderQueue.shift()! const pageNum = renderQueue.shift()!
if (!pdfDoc.value) break if (!pdfDoc.value) break
const canvas = canvasRefs.value[pageNum - 1] const canvas = canvasMap.value.get(pageNum)
if (!canvas) continue if (!canvas) continue
await renderPageToCanvas(pdfDoc.value as Parameters<typeof renderPageToCanvas>[0], pageNum, canvas, scale.value) await renderPage(pageNum, canvas, scale.value)
rendered.add(pageNum) rendered.add(pageNum)
// Text layer
await renderTextLayer(pageNum)
} }
rendering = false isRendering = false
} }
function enqueuePage(pageNum: number) { async function renderTextLayer(pageNum: number) {
const container = textLayerMap.value.get(pageNum)
if (!container) return
const result = await getTextContent(pageNum)
if (!result) return
const { content, viewport } = result
const scaledVp = viewport.clone({ scale: scale.value, rotation: rotation.value })
container.innerHTML = ''
container.style.width = `${scaledVp.width}px`
container.style.height = `${scaledVp.height}px`
for (const item of content.items) {
if (!('str' in item) || !item.str.trim()) continue
const [a, b, c, d, e, f] = item.transform
const span = document.createElement('span')
span.textContent = item.str
const tx = pdfjsLib_scaleTransform([a, b, c, d, e, f], scale.value, scaledVp.height)
span.style.cssText = `position:absolute;left:${tx[4]}px;top:${scaledVp.height - tx[5] - (item.height ?? 0) * scale.value}px;font-size:${Math.abs(tx[0]) || Math.abs(tx[1])}px;transform-origin:0% 0%;white-space:pre;color:transparent;cursor:text;`
container.appendChild(span)
}
}
// Scale a 2D transform matrix
function pdfjsLib_scaleTransform([a, b, c, d, e, f]: number[], s: number, _h: number) {
return [a * s, b * s, c * s, d * s, e * s, f * s]
}
function enqueue(pageNum: number) {
if (rendered.has(pageNum) || renderQueue.includes(pageNum)) return if (rendered.has(pageNum) || renderQueue.includes(pageNum)) return
renderQueue.push(pageNum) renderQueue.push(pageNum)
flushQueue() flushQueue()
} }
// When scale changes, clear rendered cache and re-render visible pages. // Invalidate and re-render on zoom/rotation change
watch(scale, () => { watch([scale, rotation], () => {
rendered.clear() rendered.clear()
renderQueue = [] renderQueue = []
rendering = false isRendering = false
nextTick(() => observerCallback(currentObserverEntries)) nextTick(() => ioEntries.forEach((e) => ioCallback([e])))
}) })
// ── IntersectionObserver — detects visible pages ────────────────────────────── // ── IntersectionObserver ──────────────────────────────────────────────────────
let currentObserverEntries: IntersectionObserverEntry[] = [] let ioEntries: IntersectionObserverEntry[] = []
let observer: IntersectionObserver | null = null let io: IntersectionObserver | null = null
function observerCallback(entries: IntersectionObserverEntry[]) { function ioCallback(entries: IntersectionObserverEntry[]) {
currentObserverEntries = entries for (const e of entries) {
for (const entry of entries) { if (!e.isIntersecting) continue
if (!entry.isIntersecting) continue const nums = (e.target as HTMLElement).dataset.pages?.split(',').map(Number) ?? []
const idx = Number((entry.target as HTMLElement).dataset.page) for (const n of nums) {
enqueuePage(idx) enqueue(n)
// Preload adjacent pages if (n > 1) enqueue(n - 1)
if (idx > 1) enqueuePage(idx - 1) if (n < totalPages.value) enqueue(n + 1)
if (idx < totalPages.value) enqueuePage(idx + 1) }
} }
ioEntries = entries
} }
function setupObserver() { function setupIO() {
observer?.disconnect() io?.disconnect()
observer = new IntersectionObserver(observerCallback, { io = new IntersectionObserver(ioCallback, { root: scrollRef.value, rootMargin: '300px', threshold: 0 })
root: scrollRef.value, nextTick(() => scrollRef.value?.querySelectorAll('[data-pages]').forEach((el) => io!.observe(el)))
rootMargin: '200px',
threshold: 0,
})
nextTick(() => {
const items = scrollRef.value?.querySelectorAll('[data-page]') ?? []
items.forEach((el) => observer!.observe(el))
})
} }
// ── Scroll → current page tracking ─────────────────────────────────────────── // ── Scroll → page tracking ────────────────────────────────────────────────────
let saveTimer: ReturnType<typeof setTimeout> | null = null let saveTimer: ReturnType<typeof setTimeout> | null = null
function onScroll() { function onScroll() {
const el = scrollRef.value const el = scrollRef.value
if (!el || !pageHeights.value.length) return if (!el || !rowHeights.value.length) return
let offset = el.scrollTop let offset = el.scrollTop
let pg = 1 let rowIdx = 0
for (let i = 0; i < pageHeights.value.length; i++) { for (let i = 0; i < rowHeights.value.length; i++) {
const h = (pageHeights.value[i] ?? 0) + PAGE_GAP const h = (rowHeights.value[i] ?? 0) + PAGE_GAP
if (offset < h) { if (offset < h) {
pg = i + 1 rowIdx = i
break break
} }
offset -= h offset -= h
pg = i + 2 rowIdx = i + 1
} }
currentPage.value = Math.min(pg, totalPages.value) const row = pageRows.value[rowIdx]
currentPage.value = row?.[0] ?? totalPages.value
// Debounced progress save
if (saveTimer) clearTimeout(saveTimer) if (saveTimer) clearTimeout(saveTimer)
saveTimer = setTimeout(() => { saveTimer = setTimeout(() => {
progress.pageNumber.value = currentPage.value progress.pageNumber.value = currentPage.value
@@ -136,65 +212,77 @@ function onScroll() {
}, 2000) }, 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) => { watch(currentPage, (v) => {
pageInput.value = v pageInput.value = v
}) })
function onPageInputCommit() { // ── Navigation ────────────────────────────────────────────────────────────────
function goToPage(n: number) {
const el = scrollRef.value
if (!el) return
const clamped = Math.max(1, Math.min(n, totalPages.value))
// Find which row contains this page
const rowIdx = pageRows.value.findIndex((row) => row.includes(clamped))
if (rowIdx < 0) return
let top = 0
for (let i = 0; i < rowIdx; i++) top += (rowHeights.value[i] ?? 0) + PAGE_GAP
el.scrollTo({ top, behavior: 'smooth' })
}
function onPageCommit() {
const n = parseInt(String(pageInput.value)) const n = parseInt(String(pageInput.value))
if (!isNaN(n)) goToPage(n) if (!isNaN(n)) goToPage(n)
} }
// ── Zoom helpers ────────────────────────────────────────────────────────────── // ── Keyboard ──────────────────────────────────────────────────────────────────
function setZoom(mode: ZoomMode) { function onKeyDown(e: KeyboardEvent) {
zoomMode.value = mode if (showFind.value && (e.target as HTMLElement)?.tagName === 'INPUT') return
if (mode !== 'custom') return const el = scrollRef.value
if (!el) return
const ph = (rowHeights.value[pageRows.value.findIndex((r) => r.includes(currentPage.value))] ?? containerH.value) + PAGE_GAP
if (e.key === 'ArrowDown' || e.key === 'PageDown') {
e.preventDefault()
el.scrollBy({ top: ph, behavior: 'smooth' })
} else if (e.key === 'ArrowUp' || e.key === 'PageUp') {
e.preventDefault()
el.scrollBy({ top: -ph, behavior: 'smooth' })
} else if (e.key === 'Home') goToPage(1)
else if (e.key === 'End') goToPage(totalPages.value)
else if ((e.metaKey || e.ctrlKey) && e.key === 'f') {
e.preventDefault()
showFind.value = true
} else if (e.key === 'Escape') showFind.value = false
} }
function adjustZoom(delta: number) { // ── Rotation ──────────────────────────────────────────────────────────────────
const next = Math.max(0.25, Math.min(4, (zoomMode.value === 'custom' ? customScale.value : scale.value) + delta)) function rotate() {
customScale.value = Math.round(next * 20) / 20 // snap to 5% increments rotation.value = ((rotation.value + 90) % 360) as 0 | 90 | 180 | 270
zoomMode.value = 'custom'
} }
// ── Resize observer ─────────────────────────────────────────────────────────── // ── Find ──────────────────────────────────────────────────────────────────────
let resizeObserver: ResizeObserver | null = null watch(findQuery, (q) => {
if (!q.trim()) {
findMatches.value = 0
return
}
// Naive: count rendered text spans containing query
let count = 0
scrollRef.value?.querySelectorAll('[data-text-layer] span').forEach((el) => {
if (el.textContent?.toLowerCase().includes(q.toLowerCase())) count++
})
findMatches.value = count
})
function measureContainer() { // ── Resize ────────────────────────────────────────────────────────────────────
let ro: ResizeObserver | null = null
function measure() {
if (!scrollRef.value) return if (!scrollRef.value) return
containerWidth.value = scrollRef.value.clientWidth containerW.value = scrollRef.value.clientWidth
containerHeight.value = scrollRef.value.clientHeight containerH.value = scrollRef.value.clientHeight
} }
// ── Initialisation ──────────────────────────────────────────────────────────── // ── Mount ─────────────────────────────────────────────────────────────────────
onMounted(async () => { onMounted(async () => {
window.addEventListener('keydown', onKeyDown) window.addEventListener('keydown', onKeyDown)
@@ -202,30 +290,24 @@ onMounted(async () => {
await load(props.fileId) await load(props.fileId)
if (!pdfDoc.value) return if (!pdfDoc.value) return
// Fetch all page natural dimensions (scale=1) upfront pageDims.value = await getAllPageDims()
const dims: { width: number; height: number }[] = [] canvasMap.value = new Map()
for (let i = 1; i <= totalPages.value; i++) { textLayerMap.value = new Map()
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() await nextTick()
measureContainer() measure()
resizeObserver = new ResizeObserver(() => { ro = new ResizeObserver(() => {
measureContainer() measure()
rendered.clear() rendered.clear()
renderQueue = [] renderQueue = []
rendering = false isRendering = false
nextTick(() => observerCallback(currentObserverEntries)) nextTick(() => ioEntries.forEach((e) => ioCallback([e])))
}) })
if (scrollRef.value) resizeObserver.observe(scrollRef.value) if (scrollRef.value) ro.observe(scrollRef.value)
setupObserver() setupIO()
// Restore saved page
if (progress.pageNumber.value && progress.pageNumber.value > 1) { if (progress.pageNumber.value && progress.pageNumber.value > 1) {
await nextTick() await nextTick()
goToPage(progress.pageNumber.value) goToPage(progress.pageNumber.value)
@@ -234,43 +316,32 @@ onMounted(async () => {
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keydown', onKeyDown)
observer?.disconnect() io?.disconnect()
resizeObserver?.disconnect() ro?.disconnect()
if (saveTimer) clearTimeout(saveTimer) if (saveTimer) clearTimeout(saveTimer)
}) })
// ── Derived ─────────────────────────────────────────────────────────────────── // ── Derived ───────────────────────────────────────────────────────────────────
const progressPercent = computed(() => (totalPages.value ? Math.round((currentPage.value / totalPages.value) * 100) : 0)) const progressPct = computed(() => (totalPages.value ? Math.round((currentPage.value / totalPages.value) * 100) : 0))
</script> </script>
<template> <template>
<div class="fixed inset-0 bg-[#404040] flex flex-col overflow-hidden" @mousemove="showHeader()"> <div class="fixed inset-0 bg-[#525659] flex flex-col overflow-hidden select-none" @mousemove="showHeader()">
<!-- Header --> <!-- ── Header ─────────────────────────────────────────────────────────── -->
<div <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="absolute top-0 left-0 right-0 z-50 flex flex-col transition-all duration-300"
:class="headerVisible ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-full pointer-events-none'" :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 --> <!-- Main toolbar -->
<button <div
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" class="h-12 flex items-center px-2 gap-0.5"
@click="router.back()" style="background: rgba(50, 54, 57, 0.96); backdrop-filter: blur(8px); border-bottom: 1px solid rgba(255, 255, 255, 0.08)"
title="Go back"
> >
<ArrowLeft :size="18" /> <button class="pdf-btn" @click="router.back()" title="Back"><ArrowLeft :size="16" /></button>
</button> <div class="w-px h-5 mx-1 bg-white/15 shrink-0" />
<div class="w-px h-5 mx-1 bg-white/15 shrink-0" /> <!-- Page nav -->
<button class="pdf-btn" :disabled="currentPage <= 1" @click="goToPage(currentPage - 1)"><ChevronLeft :size="16" /></button>
<!-- 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"> <div class="flex items-center gap-1 text-sm text-white/80">
<input <input
v-model.number="pageInput" v-model.number="pageInput"
@@ -278,109 +349,161 @@ const progressPercent = computed(() => (totalPages.value ? Math.round((currentPa
min="1" min="1"
:max="totalPages" :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" 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" @keydown.enter="onPageCommit"
@blur="onPageInputCommit" @blur="onPageCommit"
/> />
<span class="text-white/50">/</span> <span class="text-white/40">/</span>
<span>{{ totalPages }}</span> <span class="tabular-nums">{{ totalPages }}</span>
</div> </div>
<button <button class="pdf-btn" :disabled="currentPage >= totalPages" @click="goToPage(currentPage + 1)"><ChevronRight :size="16" /></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" <div class="w-px h-5 mx-1 bg-white/15 shrink-0" />
@click="goToPage(currentPage + 1)"
> <!-- Zoom -->
› <button class="pdf-btn" @click="adjustZoom(-0.1)" title="Zoom out"><Minus :size="14" /></button>
<span class="w-12 text-center text-xs text-white/60 font-mono tabular-nums">{{ zoomPct }}%</span>
<button class="pdf-btn" @click="adjustZoom(0.1)" title="Zoom in"><Plus :size="14" /></button>
<button class="pdf-btn" :class="zoomMode === 'fit-width' ? 'bg-white/20 text-white' : ''" @click="zoomMode = 'fit-width'" title="Fit width">
<AlignJustify :size="14" />
</button>
<button class="pdf-btn" :class="zoomMode === 'fit-page' ? 'bg-white/20 text-white' : ''" @click="zoomMode = 'fit-page'" title="Fit page">
<Maximize2 :size="14" />
</button>
<div class="w-px h-5 mx-1 bg-white/15 shrink-0" />
<!-- Rotation -->
<button class="pdf-btn" @click="rotate" title="Rotate 90°"><RotateCw :size="14" /></button>
<!-- Spread -->
<div class="flex items-center gap-0.5 ml-0.5">
<button
v-for="s in [
['none', '▬'],
['odd', '▬▬'],
['even', '▬▬'],
] as const"
:key="s[0]"
class="pdf-btn text-[10px] font-bold px-1.5"
:class="spread === s[0] ? 'bg-white/20 text-white' : ''"
:title="s[0] === 'none' ? 'Single page' : s[0] === 'odd' ? 'Spread (cover)' : 'Spread'"
@click="spread = s[0]"
>
{{ s[1] }}
</button>
</div>
<div class="flex-1" />
<!-- Find -->
<button class="pdf-btn" :class="showFind ? 'bg-white/20 text-white' : ''" @click="showFind = !showFind" title="Find (⌘F)">
<Search :size="14" />
</button> </button>
</div> </div>
<div class="flex-1" /> <!-- Find bar -->
<div
<!-- Zoom controls --> v-if="showFind"
<div class="flex items-center gap-0.5"> class="flex items-center gap-2 px-3 py-2"
style="background: rgba(50, 54, 57, 0.98); border-bottom: 1px solid rgba(255, 255, 255, 0.08)"
>
<Search :size="13" class="text-white/40 shrink-0" />
<input
v-model="findQuery"
type="text"
placeholder="Find in document…"
class="flex-1 bg-transparent text-sm text-white outline-none placeholder:text-white/30"
autofocus
@keydown.escape="showFind = false"
/>
<span v-if="findQuery" class="text-xs text-white/40 tabular-nums shrink-0">{{ findMatches }} match{{ findMatches !== 1 ? 'es' : '' }}</span>
<button <button
class="flex items-center justify-center w-7 h-7 rounded text-white/70 hover:text-white hover:bg-white/10 transition-colors" class="pdf-btn w-6 h-6"
@click="adjustZoom(-0.1)" @click="
title="Zoom out" showFind = false
findQuery = ''
"
> >
<Minus :size="14" /> <X :size="12" />
</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> </button>
</div> </div>
</div> </div>
<!-- Hover zone to re-show header --> <!-- Hover zone -->
<div class="absolute top-0 left-0 right-0 z-40 h-16 pointer-events-auto" @mouseenter="showHeader()" /> <div class="absolute top-0 left-0 right-0 z-40 h-16 pointer-events-auto" @mouseenter="showHeader()" />
<!-- Loading --> <!-- Loading -->
<div v-if="loading" class="flex-1 flex items-center justify-center"> <div v-if="loading" class="flex-1 flex items-center justify-center">
<div class="flex flex-col items-center gap-3"> <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" /> <div class="w-8 h-8 rounded-full border-2 border-white/20 border-t-white/80 animate-spin" />
<p class="text-sm text-white/60">Loading PDF…</p> <p class="text-sm text-white/50">Loading PDF…</p>
</div> </div>
</div> </div>
<!-- Error --> <!-- Error -->
<div v-else-if="error" class="flex-1 flex items-center justify-center p-8"> <div v-else-if="error" class="flex-1 flex items-center justify-center p-8 text-center">
<div class="text-center max-w-sm"> <div>
<p class="text-sm font-medium text-white/90 mb-2">Failed to load PDF</p> <p class="text-sm font-medium text-white/80 mb-1">Failed to load PDF</p>
<p class="text-xs text-white/50">{{ error }}</p> <p class="text-xs text-white/40">{{ error }}</p>
</div> </div>
</div> </div>
<!-- Pages --> <!-- ── Pages ─────────────────────────────────────────────────────────── -->
<div v-else ref="scrollRef" class="flex-1 overflow-y-auto overflow-x-auto" @scroll="onScroll"> <div v-else ref="scrollRef" class="flex-1 overflow-auto" @scroll.passive="onScroll">
<div class="flex flex-col items-center py-4" :style="{ gap: `${PAGE_GAP}px` }"> <div class="flex flex-col items-center py-4" :style="{ gap: `${PAGE_GAP}px`, paddingTop: '56px' }">
<!-- Each row = one or two pages side-by-side -->
<div <div
v-for="n in totalPages" v-for="(row, ri) in pageRows"
:key="n" :key="ri"
:data-page="n" :data-pages="row.join(',')"
class="bg-white shadow-lg" class="flex gap-1 items-start"
:style="{ :style="{ height: `${rowHeights[ri] ?? 0}px` }"
width: `${Math.round((pageDims[n - 1]?.width ?? 595) * scale)}px`,
height: `${pageHeights[n - 1] ?? 0}px`,
}"
> >
<canvas <div
:ref=" v-for="pageNum in row"
(el) => { :key="pageNum"
canvasRefs[n - 1] = el as HTMLCanvasElement | null class="relative bg-white shadow-xl overflow-hidden"
} :style="{
" width: `${Math.round((effectiveDims[pageNum - 1]?.width ?? 595) * scale)}px`,
class="block" height: `${Math.round((effectiveDims[pageNum - 1]?.height ?? 842) * scale)}px`,
/> }"
>
<!-- Canvas -->
<canvas
class="block"
:ref="
(el) => {
if (el) canvasMap.set(pageNum, el as HTMLCanvasElement)
}
"
/>
<!-- Text layer (for selection / find highlight) -->
<div
data-text-layer
class="absolute inset-0 overflow-hidden"
style="pointer-events: none"
:ref="
(el) => {
if (el) textLayerMap.set(pageNum, el as HTMLElement)
}
"
/>
<!-- Find highlight overlay -->
<div v-if="showFind && findQuery" class="absolute inset-0 pointer-events-none" />
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Footer progress bar --> <!-- Progress bar -->
<div v-if="!loading && !error && totalPages > 0" class="absolute bottom-0 left-0 right-0 h-0.5 bg-white/10"> <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 class="h-full bg-blue-400/60 transition-all duration-500" :style="{ width: `${progressPct}%` }" />
</div> </div>
</div> </div>
</template> </template>
<style scoped>
.pdf-btn {
@apply flex items-center justify-center w-7 h-7 rounded text-white/60 hover:text-white hover:bg-white/10 transition-colors shrink-0 disabled:opacity-30 disabled:cursor-not-allowed;
}
</style>
@@ -1,4 +1,4 @@
import { onUnmounted, ref } from 'vue' import { onUnmounted, shallowRef, ref } from 'vue'
import * as pdfjsLib from 'pdfjs-dist' import * as pdfjsLib from 'pdfjs-dist'
import pdfWorkerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url' import pdfWorkerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'
@@ -6,23 +6,19 @@ pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorkerUrl
export type ZoomMode = 'fit-width' | 'fit-page' | 'custom' export type ZoomMode = 'fit-width' | 'fit-page' | 'custom'
// Renders one page onto the given canvas at the given scale (pdfjs-dist v5 API). export interface PageDim {
async function renderPageToCanvas(doc: pdfjsLib.PDFDocumentProxy, pageNum: number, canvas: HTMLCanvasElement, scale: number) { width: number
const page = await doc.getPage(pageNum) height: number
const viewport = page.getViewport({ scale })
canvas.width = viewport.width
canvas.height = viewport.height
await page.render({ canvas, viewport }).promise
page.cleanup()
} }
export function usePdf() { export function usePdf() {
const pdfDoc = ref<pdfjsLib.PDFDocumentProxy | null>(null) // shallowRef is critical — Vue's deep proxy breaks PDF.js internal state
const pdfDoc = shallowRef<pdfjsLib.PDFDocumentProxy | null>(null)
const totalPages = ref(0) const totalPages = ref(0)
const loading = ref(false) const loading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
async function load(fileId: number) { async function load(fileId: number): Promise<void> {
loading.value = true loading.value = true
error.value = null error.value = null
try { try {
@@ -39,13 +35,47 @@ export function usePdf() {
} }
} }
// Returns the natural width/height of a page at scale=1 (used to size placeholder divs). // Fetch ALL page natural dims in parallel (capped at 10 concurrent).
async function getPageViewport(pageNum: number, scale: number) { async function getAllPageDims(): Promise<PageDim[]> {
if (!pdfDoc.value) return null const doc = pdfDoc.value
const page = await pdfDoc.value.getPage(pageNum) if (!doc) return []
const vp = page.getViewport({ scale }) const total = doc.numPages
const results: PageDim[] = new Array(total)
const CONCURRENCY = 10
async function fetchDim(i: number) {
const page = await doc.getPage(i + 1)
const vp = page.getViewport({ scale: 1 })
page.cleanup()
results[i] = { width: vp.width, height: vp.height }
}
for (let start = 0; start < total; start += CONCURRENCY) {
const batch = Array.from({ length: Math.min(CONCURRENCY, total - start) }, (_, k) => fetchDim(start + k))
await Promise.all(batch)
}
return results
}
async function renderPage(pageNum: number, canvas: HTMLCanvasElement, scale: number): Promise<void> {
const doc = pdfDoc.value
if (!doc) return
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() page.cleanup()
return vp }
async function getTextContent(pageNum: number) {
const doc = pdfDoc.value
if (!doc) return null
const page = await doc.getPage(pageNum)
const content = await page.getTextContent()
const viewport = page.getViewport({ scale: 1 })
page.cleanup()
return { content, viewport }
} }
onUnmounted(() => { onUnmounted(() => {
@@ -53,5 +83,5 @@ export function usePdf() {
pdfDoc.value = null pdfDoc.value = null
}) })
return { pdfDoc, totalPages, loading, error, load, renderPageToCanvas, getPageViewport } return { pdfDoc, totalPages, loading, error, load, getAllPageDims, renderPage, getTextContent }
} }
+4 -4
View File
@@ -5,28 +5,28 @@
{ {
"idx": 0, "idx": 0,
"version": "7", "version": "7",
"when": 1766926208614, "when": 1766937175999,
"tag": "0000_init_schema", "tag": "0000_init_schema",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 1, "idx": 1,
"version": "7", "version": "7",
"when": 1766926208614, "when": 1766937175999,
"tag": "0001_lethal_pixie", "tag": "0001_lethal_pixie",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 2, "idx": 2,
"version": "7", "version": "7",
"when": 1766926208614, "when": 1766937175999,
"tag": "0002_powerful_thundra", "tag": "0002_powerful_thundra",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 3, "idx": 3,
"version": "7", "version": "7",
"when": 1766926208614, "when": 1766937175999,
"tag": "0003_reading_progress_by_file", "tag": "0003_reading_progress_by_file",
"breakpoints": true "breakpoints": true
} }