Files
bookorbit/client/src/features/reader/components/PdfReaderView.vue
T

381 lines
15 KiB
Vue

<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ArrowLeft, ChevronLeft, ChevronRight, Maximize, Minimize, Minus, Plus, Search, Settings, X } from 'lucide-vue-next'
import { usePdf } from '../composables/usePdf'
import { usePdfZoom, ZOOM_PRESETS } from '../composables/usePdfZoom'
import { usePdfLayout, PAGE_GAP } from '../composables/usePdfLayout'
import { usePdfRenderer } from '../composables/usePdfRenderer'
import { useReaderProgress } from '../composables/useReaderProgress'
import { useVisibility } from '../composables/useVisibility'
import PdfSettingsPanel from './PdfSettingsPanel.vue'
import type { PageDim } from '../composables/usePdf'
const props = defineProps<{ bookId: number; fileId: number }>()
const router = useRouter()
const { pdfDoc, totalPages, loading, error, load, getPageDim, renderPage, getTextContent } = usePdf()
const progress = useReaderProgress(props.bookId, props.fileId)
const { headerVisible, showHeader } = useVisibility()
// ── Container & scroll ────────────────────────────────────────────────────────
const scrollRef = ref<HTMLElement | null>(null)
const containerW = ref(0)
const containerH = ref(0)
function measure() {
if (!scrollRef.value) return
containerW.value = scrollRef.value.clientWidth
containerH.value = scrollRef.value.clientHeight
}
// ── Page dims + rotation ──────────────────────────────────────────────────────
const pageDims = ref<PageDim[]>([])
const rotation = ref<0 | 90 | 180 | 270>(0)
// Swap width/height for 90°/270° rotations.
const effectiveDims = computed(() =>
pageDims.value.map((d) => (rotation.value === 90 || rotation.value === 270 ? { width: d.height, height: d.width } : d)),
)
function rotate() {
rotation.value = ((rotation.value + 90) % 360) as 0 | 90 | 180 | 270
}
// ── Spread (owned here; passed to both zoom and layout) ───────────────────────
const spread = ref<'none' | 'odd' | 'even'>('none')
// ── Composables ───────────────────────────────────────────────────────────────
const zoom = usePdfZoom(containerW, containerH, effectiveDims, spread)
const { scale, zoomMode, customScale, zoomLabel, adjustZoom, applyZoomPreset } = zoom
const layout = usePdfLayout(scrollRef, totalPages, effectiveDims, scale, containerH, spread)
const { scrollMode, currentPage, pageInput, pageRows, rowHeights, goToPage, onScroll } = layout
function onDimUpdate(pageNum: number, dim: PageDim) {
const prev = pageDims.value[pageNum - 1]
if (prev && prev.width === dim.width && prev.height === dim.height) return
pageDims.value = pageDims.value.map((d, i) => (i === pageNum - 1 ? dim : d))
}
const renderer = usePdfRenderer(renderPage, getTextContent, scale, rotation, totalPages, onDimUpdate)
const { canvasMap, textLayerMap, invalidate, setupIO, reset, destroy } = renderer
// ── Invalidation ──────────────────────────────────────────────────────────────
watch([scale, rotation], invalidate)
// Spread/scrollMode change alters DOM structure — rebuild IO observers after re-render.
watch([spread, scrollMode], async () => {
invalidate()
await nextTick()
if (scrollRef.value) setupIO(scrollRef.value)
})
// ── Progress: debounced save on page change ───────────────────────────────────
let saveTimer: ReturnType<typeof setTimeout> | null = null
watch(currentPage, (page) => {
if (saveTimer) clearTimeout(saveTimer)
saveTimer = setTimeout(() => {
progress.pageNumber.value = page
progress.percentage.value = (page / totalPages.value) * 100
progress.save()
}, 2000)
})
// ── Fullscreen ────────────────────────────────────────────────────────────────
const isFullscreen = ref(false)
function toggleFullscreen() {
if (document.fullscreenElement) {
document.exitFullscreen?.()
} else {
document.documentElement.requestFullscreen?.()
}
}
// ── Settings ─────────────────────────────────────────────────────────────────
const showSettings = ref(false)
// ── Find ──────────────────────────────────────────────────────────────────────
const showFind = ref(false)
const findQuery = ref('')
const findMatches = ref(0)
watch(findQuery, (q) => {
if (!q.trim()) {
findMatches.value = 0
return
}
let count = 0
scrollRef.value?.querySelectorAll('[data-text-layer] span').forEach((el) => {
if (el.textContent?.toLowerCase().includes(q.toLowerCase())) count++
})
findMatches.value = count
})
function closeFind() {
showFind.value = false
findQuery.value = ''
}
// ── Keyboard ──────────────────────────────────────────────────────────────────
function onKeyDown(e: KeyboardEvent) {
if (showFind.value && (e.target as HTMLElement)?.tagName === 'INPUT') 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') {
closeFind()
showSettings.value = false
}
}
function onPageCommit() {
const n = parseInt(String(pageInput.value))
if (!isNaN(n)) goToPage(n)
}
// ── Mount / unmount ───────────────────────────────────────────────────────────
onMounted(async () => {
window.addEventListener('keydown', onKeyDown)
const onFsChange = () => {
isFullscreen.value = !!document.fullscreenElement
}
document.addEventListener('fullscreenchange', onFsChange)
onUnmounted(() => document.removeEventListener('fullscreenchange', onFsChange))
await progress.load()
await load(props.fileId)
if (!pdfDoc.value) return
// Load only page 1 to establish initial layout dims. All pages are seeded with
// page 1's size (uniform assumption). onDimUpdate corrects individual pages
// lazily as they are rendered — no upfront full-document traversal.
const firstDim = await getPageDim(1)
pageDims.value = Array.from({ length: totalPages.value }, () => ({ ...firstDim }))
reset()
await nextTick()
measure()
let ro: ResizeObserver | null = new ResizeObserver(async () => {
measure()
invalidate()
await nextTick()
if (scrollRef.value) setupIO(scrollRef.value)
})
if (scrollRef.value) {
ro.observe(scrollRef.value)
setupIO(scrollRef.value)
}
onUnmounted(() => {
ro?.disconnect()
ro = null
})
if (progress.pageNumber.value && progress.pageNumber.value > 1) {
await nextTick()
goToPage(progress.pageNumber.value)
}
})
onUnmounted(() => {
window.removeEventListener('keydown', onKeyDown)
destroy()
if (saveTimer) clearTimeout(saveTimer)
})
const progressPct = computed(() => (totalPages.value ? Math.round((currentPage.value / totalPages.value) * 100) : 0))
</script>
<template>
<div class="fixed inset-0 bg-[#525659] flex flex-col overflow-hidden select-none" @mousemove="showHeader()">
<!-- ── Header ─────────────────────────────────────────────────────────── -->
<div
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'"
>
<!-- Toolbar -->
<div
class="h-12 flex items-center px-3 gap-0.5"
style="
background: rgba(18, 18, 20, 0.92);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
"
>
<button class="viewer-btn" @click="router.back()" title="Back"><ArrowLeft :size="16" /></button>
<div class="viewer-sep" />
<button class="viewer-btn" :disabled="currentPage <= 1" @click="goToPage(currentPage - 1)" title="Previous page">
<ChevronLeft :size="16" />
</button>
<div class="flex items-center gap-1 text-sm text-white/80 shrink-0">
<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="onPageCommit"
@blur="onPageCommit"
/>
<span class="text-white/40">/</span>
<span class="tabular-nums">{{ totalPages }}</span>
</div>
<button class="viewer-btn" :disabled="currentPage >= totalPages" @click="goToPage(currentPage + 1)" title="Next page">
<ChevronRight :size="16" />
</button>
<div class="viewer-sep" />
<button class="viewer-btn" @click.stop="adjustZoom(-0.1)" title="Zoom out"><Minus :size="14" /></button>
<span class="text-xs text-white/80 font-mono min-w-[64px] text-center tabular-nums select-none">{{ zoomLabel }}</span>
<button class="viewer-btn" @click.stop="adjustZoom(0.1)" title="Zoom in"><Plus :size="14" /></button>
<div class="viewer-sep" />
<button class="viewer-btn" :class="showFind ? 'bg-white/20 text-white' : ''" @click="showFind = !showFind" title="Find (⌘F)">
<Search :size="14" />
</button>
<div class="viewer-sep" />
<button class="viewer-btn" @click="toggleFullscreen" :title="isFullscreen ? 'Exit fullscreen' : 'Fullscreen'">
<Minimize v-if="isFullscreen" :size="14" />
<Maximize v-else :size="14" />
</button>
<div class="viewer-sep" />
<button class="viewer-btn" :class="showSettings ? 'bg-white/20 text-white' : ''" @click.stop="showSettings = !showSettings" title="Settings">
<Settings :size="14" />
</button>
</div>
<!-- Find bar -->
<div
v-if="showFind"
class="flex items-center gap-2 px-3 py-2"
style="background: rgba(18, 18, 20, 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 class="viewer-btn w-6 h-6" @click="closeFind"><X :size="12" /></button>
</div>
</div>
<!-- Hover zone to reveal header -->
<div class="absolute top-0 left-0 right-0 z-40 h-16 pointer-events-auto" @mouseenter="showHeader()" />
<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/20 border-t-white/80 animate-spin" />
<p class="text-sm text-white/50">Loading PDF…</p>
</div>
</div>
<div v-else-if="error" class="flex-1 flex items-center justify-center p-8 text-center">
<div>
<p class="text-sm font-medium text-white/80 mb-1">Failed to load PDF</p>
<p class="text-xs text-white/40">{{ error }}</p>
</div>
</div>
<!-- ── Pages ──────────────────────────────────────────────────────────── -->
<div
v-else
ref="scrollRef"
class="flex-1 overflow-auto"
:style="scrollMode === 'page' ? { scrollSnapType: 'y mandatory' } : {}"
@scroll.passive="onScroll"
>
<div
class="flex flex-col items-center"
:style="scrollMode === 'page' ? { gap: '0px', paddingTop: '0' } : { gap: `${PAGE_GAP}px`, paddingTop: '56px' }"
>
<div
v-for="(row, ri) in pageRows"
:key="ri"
:data-pages="row.join(',')"
class="flex gap-1"
:style="
scrollMode === 'page'
? { height: `${containerH}px`, scrollSnapAlign: 'start', alignItems: 'center', justifyContent: 'center' }
: { height: `${rowHeights[ri] ?? 0}px`, alignItems: 'flex-start' }
"
>
<div
v-for="pageNum in row"
:key="pageNum"
class="relative bg-white shadow-xl overflow-hidden"
:style="{
width: `${Math.round((effectiveDims[pageNum - 1]?.width ?? 595) * scale)}px`,
height: `${Math.round((effectiveDims[pageNum - 1]?.height ?? 842) * scale)}px`,
}"
>
<canvas
class="block"
:ref="
(el) => {
if (el) canvasMap.set(pageNum, el as HTMLCanvasElement)
}
"
/>
<div
data-text-layer
class="absolute inset-0 overflow-hidden"
style="pointer-events: none"
:ref="
(el) => {
if (el) textLayerMap.set(pageNum, el as HTMLElement)
}
"
/>
</div>
</div>
</div>
</div>
<!-- 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-blue-400/60 transition-all duration-500" :style="{ width: `${progressPct}%` }" />
</div>
</div>
<!-- Settings panel -->
<PdfSettingsPanel
v-if="showSettings"
:zoomMode="zoomMode"
:customScale="customScale"
:zoomLabel="zoomLabel"
:spread="spread"
:scrollMode="scrollMode"
:rotation="rotation"
@applyZoomPreset="applyZoomPreset"
@update:spread="spread = $event"
@update:scrollMode="scrollMode = $event"
@rotate="rotate()"
@close="showSettings = false"
/>
</template>