mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
refactor(reader): add HTTP range requests and improve PDF metadata
This commit is contained in:
@@ -1,8 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ArrowLeft, BookOpen, ChevronLeft, ChevronRight, Minus, Plus, RotateCw, Search, X } from 'lucide-vue-next'
|
||||
import { ArrowLeft, ChevronLeft, ChevronRight, LayoutGrid, Maximize, Minimize, Minus, Plus, RotateCw, Search, 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 type { PageDim } from '../composables/usePdf'
|
||||
@@ -14,254 +17,124 @@ const { pdfDoc, totalPages, loading, error, load, getAllPageDims, renderPage, ge
|
||||
const progress = useReaderProgress(props.bookId, props.fileId)
|
||||
const { headerVisible, showHeader } = useVisibility()
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────────────────────
|
||||
// ── Container & scroll ────────────────────────────────────────────────────────
|
||||
const scrollRef = ref<HTMLElement | null>(null)
|
||||
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
|
||||
const containerW = ref(0)
|
||||
const containerH = ref(0)
|
||||
|
||||
// ── Page dimensions ───────────────────────────────────────────────────────────
|
||||
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)
|
||||
|
||||
// After rotation, swap width/height for 90°/270°.
|
||||
// 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)),
|
||||
)
|
||||
|
||||
// ── Container size ────────────────────────────────────────────────────────────
|
||||
const containerW = ref(0)
|
||||
const containerH = ref(0)
|
||||
function rotate() {
|
||||
rotation.value = ((rotation.value + 90) % 360) as 0 | 90 | 180 | 270
|
||||
}
|
||||
|
||||
// ── Zoom ──────────────────────────────────────────────────────────────────────
|
||||
type ZoomMode = 'fit-width' | 'fit-page' | 'custom'
|
||||
const zoomMode = ref<ZoomMode>('fit-width')
|
||||
const customScale = ref(1.0)
|
||||
// ── Spread (owned here; passed to both zoom and layout) ───────────────────────
|
||||
const spread = ref<'none' | 'odd' | 'even'>('none')
|
||||
|
||||
const scale = computed(() => {
|
||||
const first = effectiveDims.value[0]
|
||||
if (!first || !containerW.value) return 1
|
||||
if (spread.value !== 'none') {
|
||||
// Two-page spread: fit two pages side by side
|
||||
const sw = (containerW.value - 48) / (first.width * 2 + PAGE_GAP)
|
||||
if (zoomMode.value === 'fit-page') return Math.min(sw, (containerH.value - 48) / first.height)
|
||||
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
|
||||
// ── 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
|
||||
|
||||
const renderer = usePdfRenderer(renderPage, getTextContent, scale, rotation, totalPages)
|
||||
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)
|
||||
})
|
||||
|
||||
const zoomPct = computed(() => Math.round(scale.value * 100))
|
||||
|
||||
const showZoomMenu = ref(false)
|
||||
|
||||
const ZOOM_PRESETS = [
|
||||
{ label: 'Fit Page', value: 'fit-page' },
|
||||
{ label: 'Fit Width', value: 'fit-width' },
|
||||
{ label: 'Actual Size', value: '1' },
|
||||
{ label: '50%', value: '0.5' },
|
||||
{ label: '75%', value: '0.75' },
|
||||
{ label: '100%', value: '1' },
|
||||
{ label: '125%', value: '1.25' },
|
||||
{ label: '150%', value: '1.5' },
|
||||
{ label: '200%', value: '2' },
|
||||
] as const
|
||||
|
||||
function applyZoomPreset(value: string) {
|
||||
showZoomMenu.value = false
|
||||
if (value === 'fit-width' || value === 'fit-page') {
|
||||
zoomMode.value = value
|
||||
} else {
|
||||
customScale.value = parseFloat(value)
|
||||
zoomMode.value = 'custom'
|
||||
}
|
||||
}
|
||||
|
||||
function adjustZoom(delta: number) {
|
||||
customScale.value = Math.round(Math.max(0.25, Math.min(4, (zoomMode.value === 'custom' ? customScale.value : scale.value) + delta) * 20) / 20)
|
||||
zoomMode.value = 'custom'
|
||||
}
|
||||
|
||||
const zoomLabel = computed(() => {
|
||||
if (zoomMode.value === 'fit-width') return 'Fit Width'
|
||||
if (zoomMode.value === 'fit-page') return 'Fit Page'
|
||||
return `${zoomPct.value}%`
|
||||
})
|
||||
|
||||
// ── Spread pages into rows ────────────────────────────────────────────────────
|
||||
// Returns array of rows, each row = [pageNum] or [pageNum, pageNum+1]
|
||||
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 isRendering = false
|
||||
|
||||
async function flushQueue() {
|
||||
if (isRendering) return
|
||||
isRendering = true
|
||||
while (renderQueue.length > 0) {
|
||||
const pageNum = renderQueue.shift()!
|
||||
if (!pdfDoc.value) break
|
||||
const canvas = canvasMap.value.get(pageNum)
|
||||
if (!canvas) continue
|
||||
await renderPage(pageNum, canvas, scale.value)
|
||||
rendered.add(pageNum)
|
||||
// Text layer
|
||||
await renderTextLayer(pageNum)
|
||||
}
|
||||
isRendering = false
|
||||
}
|
||||
|
||||
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
|
||||
renderQueue.push(pageNum)
|
||||
flushQueue()
|
||||
}
|
||||
|
||||
// Invalidate and re-render on zoom/rotation change
|
||||
watch([scale, rotation], () => {
|
||||
rendered.clear()
|
||||
renderQueue = []
|
||||
isRendering = false
|
||||
nextTick(() => ioEntries.forEach((e) => ioCallback([e])))
|
||||
})
|
||||
|
||||
// ── IntersectionObserver ──────────────────────────────────────────────────────
|
||||
let ioEntries: IntersectionObserverEntry[] = []
|
||||
let io: IntersectionObserver | null = null
|
||||
|
||||
function ioCallback(entries: IntersectionObserverEntry[]) {
|
||||
for (const e of entries) {
|
||||
if (!e.isIntersecting) continue
|
||||
const nums = (e.target as HTMLElement).dataset.pages?.split(',').map(Number) ?? []
|
||||
for (const n of nums) {
|
||||
enqueue(n)
|
||||
if (n > 1) enqueue(n - 1)
|
||||
if (n < totalPages.value) enqueue(n + 1)
|
||||
}
|
||||
}
|
||||
ioEntries = entries
|
||||
}
|
||||
|
||||
function setupIO() {
|
||||
io?.disconnect()
|
||||
io = new IntersectionObserver(ioCallback, { root: scrollRef.value, rootMargin: '300px', threshold: 0 })
|
||||
nextTick(() => scrollRef.value?.querySelectorAll('[data-pages]').forEach((el) => io!.observe(el)))
|
||||
}
|
||||
|
||||
// ── Scroll → page tracking ────────────────────────────────────────────────────
|
||||
// ── Progress: debounced save on page change ───────────────────────────────────
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function onScroll() {
|
||||
const el = scrollRef.value
|
||||
if (!el || !rowHeights.value.length) return
|
||||
|
||||
let offset = el.scrollTop
|
||||
let rowIdx = 0
|
||||
for (let i = 0; i < rowHeights.value.length; i++) {
|
||||
const h = (rowHeights.value[i] ?? 0) + PAGE_GAP
|
||||
if (offset < h) {
|
||||
rowIdx = i
|
||||
break
|
||||
}
|
||||
offset -= h
|
||||
rowIdx = i + 1
|
||||
}
|
||||
const row = pageRows.value[rowIdx]
|
||||
currentPage.value = row?.[0] ?? totalPages.value
|
||||
|
||||
watch(currentPage, (page) => {
|
||||
if (saveTimer) clearTimeout(saveTimer)
|
||||
saveTimer = setTimeout(() => {
|
||||
progress.pageNumber.value = currentPage.value
|
||||
progress.percentage.value = (currentPage.value / totalPages.value) * 100
|
||||
progress.pageNumber.value = page
|
||||
progress.percentage.value = (page / totalPages.value) * 100
|
||||
progress.save()
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
watch(currentPage, (v) => {
|
||||
pageInput.value = v
|
||||
})
|
||||
|
||||
// ── 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' })
|
||||
// ── Fullscreen ────────────────────────────────────────────────────────────────
|
||||
const isFullscreen = ref(false)
|
||||
|
||||
function toggleFullscreen() {
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen?.()
|
||||
} else {
|
||||
document.documentElement.requestFullscreen?.()
|
||||
}
|
||||
}
|
||||
|
||||
function onPageCommit() {
|
||||
const n = parseInt(String(pageInput.value))
|
||||
if (!isNaN(n)) goToPage(n)
|
||||
// ── 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 = ''
|
||||
}
|
||||
|
||||
// ── Menus (zoom + view dropdowns) ────────────────────────────────────────────
|
||||
const showZoomMenu = ref(false)
|
||||
const showViewMenu = ref(false)
|
||||
const zoomBtnRef = ref<HTMLElement | null>(null)
|
||||
const viewBtnRef = ref<HTMLElement | null>(null)
|
||||
const zoomMenuStyle = ref({ top: '0px', left: '0px' })
|
||||
const viewMenuStyle = ref({ top: '0px', right: '0px' })
|
||||
|
||||
function openZoomMenu() {
|
||||
if (zoomBtnRef.value) {
|
||||
const r = zoomBtnRef.value.getBoundingClientRect()
|
||||
zoomMenuStyle.value = { top: `${r.bottom + 4}px`, left: `${r.left + r.width / 2}px` }
|
||||
}
|
||||
showZoomMenu.value = !showZoomMenu.value
|
||||
showViewMenu.value = false
|
||||
}
|
||||
|
||||
function openViewMenu() {
|
||||
if (viewBtnRef.value) {
|
||||
const r = viewBtnRef.value.getBoundingClientRect()
|
||||
viewMenuStyle.value = { top: `${r.bottom + 4}px`, right: `${window.innerWidth - r.right}px` }
|
||||
}
|
||||
showViewMenu.value = !showViewMenu.value
|
||||
showZoomMenu.value = false
|
||||
}
|
||||
|
||||
function closeAllMenus() {
|
||||
showZoomMenu.value = false
|
||||
showViewMenu.value = false
|
||||
}
|
||||
|
||||
// ── Keyboard ──────────────────────────────────────────────────────────────────
|
||||
@@ -276,75 +149,52 @@ function onKeyDown(e: KeyboardEvent) {
|
||||
} 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') {
|
||||
} 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()
|
||||
showZoomMenu.value = false
|
||||
closeAllMenus()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rotation ──────────────────────────────────────────────────────────────────
|
||||
function rotate() {
|
||||
rotation.value = ((rotation.value + 90) % 360) as 0 | 90 | 180 | 270
|
||||
function onPageCommit() {
|
||||
const n = parseInt(String(pageInput.value))
|
||||
if (!isNaN(n)) goToPage(n)
|
||||
}
|
||||
|
||||
function closeFind() {
|
||||
showFind.value = false
|
||||
findQuery.value = ''
|
||||
}
|
||||
|
||||
// ── Find ──────────────────────────────────────────────────────────────────────
|
||||
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
|
||||
})
|
||||
|
||||
// ── Resize ────────────────────────────────────────────────────────────────────
|
||||
let ro: ResizeObserver | null = null
|
||||
|
||||
function measure() {
|
||||
if (!scrollRef.value) return
|
||||
containerW.value = scrollRef.value.clientWidth
|
||||
containerH.value = scrollRef.value.clientHeight
|
||||
}
|
||||
|
||||
// ── Mount ─────────────────────────────────────────────────────────────────────
|
||||
// ── 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
|
||||
|
||||
pageDims.value = await getAllPageDims()
|
||||
canvasMap.value = new Map()
|
||||
textLayerMap.value = new Map()
|
||||
reset()
|
||||
|
||||
await nextTick()
|
||||
measure()
|
||||
|
||||
ro = new ResizeObserver(() => {
|
||||
let ro: ResizeObserver | null = new ResizeObserver(async () => {
|
||||
measure()
|
||||
rendered.clear()
|
||||
renderQueue = []
|
||||
isRendering = false
|
||||
nextTick(() => ioEntries.forEach((e) => ioCallback([e])))
|
||||
invalidate()
|
||||
await nextTick()
|
||||
if (scrollRef.value) setupIO(scrollRef.value)
|
||||
})
|
||||
if (scrollRef.value) ro.observe(scrollRef.value)
|
||||
|
||||
setupIO()
|
||||
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()
|
||||
@@ -354,32 +204,28 @@ onMounted(async () => {
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
io?.disconnect()
|
||||
ro?.disconnect()
|
||||
destroy()
|
||||
if (saveTimer) clearTimeout(saveTimer)
|
||||
})
|
||||
|
||||
// ── Derived ───────────────────────────────────────────────────────────────────
|
||||
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()" @click="showZoomMenu = false">
|
||||
<div class="fixed inset-0 bg-[#525659] flex flex-col overflow-hidden select-none" @mousemove="showHeader()" @click="closeAllMenus()">
|
||||
<!-- ── 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'"
|
||||
>
|
||||
<!-- Main toolbar -->
|
||||
<!-- Toolbar -->
|
||||
<div
|
||||
class="h-12 flex items-center px-2 gap-0.5 overflow-x-auto"
|
||||
class="h-12 flex items-center px-2 gap-0.5"
|
||||
style="background: rgba(50, 54, 57, 0.96); backdrop-filter: blur(8px); border-bottom: 1px solid rgba(255, 255, 255, 0.08)"
|
||||
>
|
||||
<!-- Back -->
|
||||
<button class="pdf-btn" @click="router.back()" title="Back"><ArrowLeft :size="16" /></button>
|
||||
<div class="sep" />
|
||||
|
||||
<!-- Page navigation -->
|
||||
<button class="pdf-btn" :disabled="currentPage <= 1" @click="goToPage(currentPage - 1)" title="Previous page">
|
||||
<ChevronLeft :size="16" />
|
||||
</button>
|
||||
@@ -401,70 +247,43 @@ const progressPct = computed(() => (totalPages.value ? Math.round((currentPage.v
|
||||
</button>
|
||||
<div class="sep" />
|
||||
|
||||
<!-- Zoom: − [label/dropdown] + -->
|
||||
<button class="pdf-btn" @click="adjustZoom(-0.1)" title="Zoom out (−)"><Minus :size="14" /></button>
|
||||
<div class="relative shrink-0">
|
||||
<button class="pdf-btn" @click.stop="adjustZoom(-0.1)" title="Zoom out"><Minus :size="14" /></button>
|
||||
<div class="shrink-0">
|
||||
<button
|
||||
class="flex items-center gap-1 px-2 py-1 rounded text-xs text-white/80 hover:text-white hover:bg-white/10 transition-colors min-w-[80px] justify-center font-mono"
|
||||
@click="showZoomMenu = !showZoomMenu"
|
||||
title="Zoom level — click to pick preset"
|
||||
ref="zoomBtnRef"
|
||||
class="flex items-center gap-1 px-2 py-1 rounded text-xs text-white/80 hover:text-white hover:bg-white/10 transition-colors min-w-[72px] justify-center font-mono"
|
||||
@click.stop="openZoomMenu"
|
||||
title="Zoom level"
|
||||
>
|
||||
{{ zoomLabel }}
|
||||
<span class="text-white/30 text-[10px]">▾</span>
|
||||
</button>
|
||||
<!-- Dropdown -->
|
||||
<div
|
||||
v-if="showZoomMenu"
|
||||
class="absolute top-full left-1/2 -translate-x-1/2 mt-1 w-36 rounded-lg shadow-2xl z-50 py-1 overflow-hidden"
|
||||
style="background: rgba(35, 38, 41, 0.98); border: 1px solid rgba(255, 255, 255, 0.1)"
|
||||
>
|
||||
<button
|
||||
v-for="preset in ZOOM_PRESETS"
|
||||
:key="preset.label"
|
||||
class="w-full text-left px-3 py-1.5 text-xs text-white/70 hover:text-white hover:bg-white/10 transition-colors"
|
||||
@click="applyZoomPreset(preset.value)"
|
||||
>
|
||||
{{ preset.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="pdf-btn" @click="adjustZoom(0.1)" title="Zoom in (+)"><Plus :size="14" /></button>
|
||||
<button class="pdf-btn" @click.stop="adjustZoom(0.1)" title="Zoom in"><Plus :size="14" /></button>
|
||||
<div class="sep" />
|
||||
|
||||
<!-- Fit shortcuts -->
|
||||
<button class="pdf-btn-label" :class="zoomMode === 'fit-width' ? 'active' : ''" @click="zoomMode = 'fit-width'" title="Fit Width">
|
||||
Fit Width
|
||||
</button>
|
||||
<button class="pdf-btn-label" :class="zoomMode === 'fit-page' ? 'active' : ''" @click="zoomMode = 'fit-page'" title="Fit Page">
|
||||
Fit Page
|
||||
</button>
|
||||
<button
|
||||
class="pdf-btn-label"
|
||||
:class="zoomMode === 'custom' && customScale === 1 ? 'active' : ''"
|
||||
@click="applyZoomPreset('1')"
|
||||
title="Actual size (100%)"
|
||||
>
|
||||
Actual
|
||||
</button>
|
||||
<div class="shrink-0">
|
||||
<button
|
||||
ref="viewBtnRef"
|
||||
class="flex items-center gap-1 px-2 py-1 rounded text-xs text-white/80 hover:text-white hover:bg-white/10 transition-colors"
|
||||
@click.stop="openViewMenu"
|
||||
title="View options"
|
||||
>
|
||||
<LayoutGrid :size="14" />
|
||||
<span class="text-white/30 text-[10px]">▾</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="sep" />
|
||||
|
||||
<!-- Rotation -->
|
||||
<button class="pdf-btn" @click="rotate" title="Rotate 90° clockwise"><RotateCw :size="14" /></button>
|
||||
<div class="sep" />
|
||||
|
||||
<!-- Spread / page layout -->
|
||||
<button class="pdf-btn-label" :class="spread === 'none' ? 'active' : ''" @click="spread = 'none'" title="Single page">
|
||||
<BookOpen :size="13" class="mr-1" />Single
|
||||
</button>
|
||||
<button class="pdf-btn-label" :class="spread === 'odd' ? 'active' : ''" @click="spread = 'odd'" title="Two-page spread (book with cover)">
|
||||
Spread
|
||||
</button>
|
||||
<div class="sep" />
|
||||
|
||||
<!-- Find -->
|
||||
<button class="pdf-btn" :class="showFind ? 'bg-white/20 text-white' : ''" @click="showFind = !showFind" title="Find in document (⌘F)">
|
||||
<button class="pdf-btn" :class="showFind ? 'bg-white/20 text-white' : ''" @click="showFind = !showFind" title="Find (⌘F)">
|
||||
<Search :size="14" />
|
||||
</button>
|
||||
<div class="sep" />
|
||||
|
||||
<button class="pdf-btn" @click="toggleFullscreen" :title="isFullscreen ? 'Exit fullscreen' : 'Fullscreen'">
|
||||
<Minimize v-if="isFullscreen" :size="14" />
|
||||
<Maximize v-else :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Find bar -->
|
||||
@@ -483,16 +302,13 @@ const progressPct = computed(() => (totalPages.value ? Math.round((currentPage.v
|
||||
@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="pdf-btn w-6 h-6" @click="closeFind">
|
||||
<X :size="12" />
|
||||
</button>
|
||||
<button class="pdf-btn w-6 h-6" @click="closeFind"><X :size="12" /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hover zone -->
|
||||
<!-- Hover zone to reveal 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/20 border-t-white/80 animate-spin" />
|
||||
@@ -500,7 +316,6 @@ const progressPct = computed(() => (totalPages.value ? Math.round((currentPage.v
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<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>
|
||||
@@ -508,16 +323,28 @@ const progressPct = computed(() => (totalPages.value ? Math.round((currentPage.v
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Pages ─────────────────────────────────────────────────────────── -->
|
||||
<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`, paddingTop: '56px' }">
|
||||
<!-- Each row = one or two pages side-by-side -->
|
||||
<!-- ── 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 items-start"
|
||||
:style="{ height: `${rowHeights[ri] ?? 0}px` }"
|
||||
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"
|
||||
@@ -528,28 +355,16 @@ const progressPct = computed(() => (totalPages.value ? Math.round((currentPage.v
|
||||
height: `${Math.round((effectiveDims[pageNum - 1]?.height ?? 842) * scale)}px`,
|
||||
}"
|
||||
>
|
||||
<!-- Canvas -->
|
||||
<canvas
|
||||
class="block"
|
||||
:ref="
|
||||
(el) => {
|
||||
if (el) canvasMap.set(pageNum, el as HTMLCanvasElement)
|
||||
}
|
||||
"
|
||||
: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)
|
||||
}
|
||||
"
|
||||
: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>
|
||||
@@ -560,6 +375,96 @@ const progressPct = computed(() => (totalPages.value ? Math.round((currentPage.v
|
||||
<div class="h-full bg-blue-400/60 transition-all duration-500" :style="{ width: `${progressPct}%` }" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Zoom dropdown — teleported to escape stacking context created by header -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="showZoomMenu"
|
||||
class="fixed w-40 rounded-lg shadow-2xl py-1 overflow-hidden"
|
||||
:style="{ zIndex: 9999, background: 'rgba(35,38,41,0.98)', border: '1px solid rgba(255,255,255,0.1)', transform: 'translateX(-50%)', top: zoomMenuStyle.top, left: zoomMenuStyle.left }"
|
||||
@click.stop
|
||||
>
|
||||
<button
|
||||
v-for="preset in ZOOM_PRESETS"
|
||||
:key="preset.label"
|
||||
class="w-full text-left px-3 py-1.5 text-xs hover:bg-white/10 transition-colors flex items-center justify-between"
|
||||
:class="zoomMode === 'custom' && customScale === parseFloat(preset.value) ? 'text-white' : 'text-white/60'"
|
||||
@click="applyZoomPreset(preset.value)"
|
||||
>
|
||||
{{ preset.label }}
|
||||
<span v-if="zoomMode === 'custom' && customScale === parseFloat(preset.value)" class="text-blue-400 text-[10px]">✓</span>
|
||||
</button>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- View dropdown — teleported to escape stacking context created by header -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="showViewMenu"
|
||||
class="fixed w-44 rounded-lg shadow-2xl py-1 overflow-hidden"
|
||||
:style="{ zIndex: 9999, background: 'rgba(35,38,41,0.98)', border: '1px solid rgba(255,255,255,0.1)', top: viewMenuStyle.top, right: viewMenuStyle.right }"
|
||||
@click.stop
|
||||
>
|
||||
<button
|
||||
class="w-full text-left px-3 py-1.5 text-xs hover:bg-white/10 transition-colors flex items-center justify-between"
|
||||
:class="zoomMode === 'fit-width' ? 'text-white' : 'text-white/60'"
|
||||
@click="applyZoomPreset('fit-width'); showViewMenu = false"
|
||||
>
|
||||
Fit Width<span v-if="zoomMode === 'fit-width'" class="text-blue-400 text-[10px]">✓</span>
|
||||
</button>
|
||||
<button
|
||||
class="w-full text-left px-3 py-1.5 text-xs hover:bg-white/10 transition-colors flex items-center justify-between"
|
||||
:class="zoomMode === 'fit-page' ? 'text-white' : 'text-white/60'"
|
||||
@click="applyZoomPreset('fit-page'); showViewMenu = false"
|
||||
>
|
||||
Fit Page<span v-if="zoomMode === 'fit-page'" class="text-blue-400 text-[10px]">✓</span>
|
||||
</button>
|
||||
<button
|
||||
class="w-full text-left px-3 py-1.5 text-xs hover:bg-white/10 transition-colors flex items-center justify-between"
|
||||
:class="zoomMode === 'custom' && customScale === 1 ? 'text-white' : 'text-white/60'"
|
||||
@click="applyZoomPreset('1'); showViewMenu = false"
|
||||
>
|
||||
Actual Size<span v-if="zoomMode === 'custom' && customScale === 1" class="text-blue-400 text-[10px]">✓</span>
|
||||
</button>
|
||||
<div class="my-1 border-t border-white/10" />
|
||||
<button
|
||||
class="w-full text-left px-3 py-1.5 text-xs text-white/60 hover:text-white hover:bg-white/10 transition-colors flex items-center justify-between"
|
||||
@click="rotate(); showViewMenu = false"
|
||||
>
|
||||
Rotate CW <RotateCw :size="12" />
|
||||
</button>
|
||||
<div class="my-1 border-t border-white/10" />
|
||||
<button
|
||||
class="w-full text-left px-3 py-1.5 text-xs hover:bg-white/10 transition-colors flex items-center justify-between"
|
||||
:class="spread === 'none' ? 'text-white' : 'text-white/60'"
|
||||
@click="spread = 'none'; showViewMenu = false"
|
||||
>
|
||||
Single Page<span v-if="spread === 'none'" class="text-blue-400 text-[10px]">✓</span>
|
||||
</button>
|
||||
<button
|
||||
class="w-full text-left px-3 py-1.5 text-xs hover:bg-white/10 transition-colors flex items-center justify-between"
|
||||
:class="spread === 'odd' ? 'text-white' : 'text-white/60'"
|
||||
@click="spread = 'odd'; showViewMenu = false"
|
||||
>
|
||||
Two-Page Spread<span v-if="spread === 'odd'" class="text-blue-400 text-[10px]">✓</span>
|
||||
</button>
|
||||
<div class="my-1 border-t border-white/10" />
|
||||
<button
|
||||
class="w-full text-left px-3 py-1.5 text-xs hover:bg-white/10 transition-colors flex items-center justify-between"
|
||||
:class="scrollMode === 'continuous' ? 'text-white' : 'text-white/60'"
|
||||
@click="scrollMode = 'continuous'; showViewMenu = false"
|
||||
>
|
||||
Continuous Scroll<span v-if="scrollMode === 'continuous'" class="text-blue-400 text-[10px]">✓</span>
|
||||
</button>
|
||||
<button
|
||||
class="w-full text-left px-3 py-1.5 text-xs hover:bg-white/10 transition-colors flex items-center justify-between"
|
||||
:class="scrollMode === 'page' ? 'text-white' : 'text-white/60'"
|
||||
@click="scrollMode = 'page'; showViewMenu = false"
|
||||
>
|
||||
Page by Page<span v-if="scrollMode === 'page'" class="text-blue-400 text-[10px]">✓</span>
|
||||
</button>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -567,12 +472,6 @@ const progressPct = computed(() => (totalPages.value ? Math.round((currentPage.v
|
||||
.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;
|
||||
}
|
||||
.pdf-btn-label {
|
||||
@apply flex items-center px-2 py-1 rounded text-xs text-white/60 hover:text-white hover:bg-white/10 transition-colors shrink-0 whitespace-nowrap;
|
||||
}
|
||||
.pdf-btn-label.active {
|
||||
@apply bg-white/20 text-white;
|
||||
}
|
||||
.sep {
|
||||
@apply w-px h-5 mx-1 bg-white/15 shrink-0;
|
||||
}
|
||||
|
||||
@@ -22,10 +22,13 @@ export function usePdf() {
|
||||
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
|
||||
// Pass URL directly so PDF.js can use HTTP range requests (206 Partial Content).
|
||||
// This avoids downloading the entire file upfront and allows rendering to start immediately.
|
||||
const doc = await pdfjsLib.getDocument({
|
||||
url: `/api/books/files/${fileId}/serve`,
|
||||
rangeChunkSize: 512 * 1024,
|
||||
disableAutoFetch: true,
|
||||
}).promise
|
||||
pdfDoc.value = doc
|
||||
totalPages.value = doc.numPages
|
||||
} catch (e) {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
import type { PageDim } from './usePdf'
|
||||
|
||||
export const PAGE_GAP = 16
|
||||
|
||||
export function usePdfLayout(
|
||||
scrollRef: Ref<HTMLElement | null>,
|
||||
totalPages: Ref<number>,
|
||||
effectiveDims: ComputedRef<PageDim[]>,
|
||||
scale: ComputedRef<number>,
|
||||
containerH: Ref<number>,
|
||||
spread: Ref<'none' | 'odd' | 'even'>,
|
||||
) {
|
||||
const scrollMode = ref<'continuous' | 'page'>('continuous')
|
||||
const currentPage = ref(1)
|
||||
const pageInput = ref(1)
|
||||
|
||||
// Each row is [pageNum] in single mode, or [pageNum, pageNum+1] in spread.
|
||||
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]...
|
||||
let i = 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
|
||||
})
|
||||
|
||||
// In page mode every row fills the viewport; in continuous mode rows have natural height.
|
||||
const rowHeights = computed(() => {
|
||||
if (scrollMode.value === 'page') return pageRows.value.map(() => containerH.value)
|
||||
return pageRows.value.map((row) =>
|
||||
Math.max(...row.map((n) => Math.round((effectiveDims.value[n - 1]?.height ?? 842) * scale.value))),
|
||||
)
|
||||
})
|
||||
|
||||
watch(currentPage, (v) => {
|
||||
pageInput.value = v
|
||||
})
|
||||
|
||||
function goToPage(n: number) {
|
||||
const el = scrollRef.value
|
||||
if (!el) return
|
||||
const clamped = Math.max(1, Math.min(n, totalPages.value))
|
||||
const rowIdx = pageRows.value.findIndex((row) => row.includes(clamped))
|
||||
if (rowIdx < 0) return
|
||||
let top = 0
|
||||
if (scrollMode.value === 'page') {
|
||||
top = rowIdx * containerH.value
|
||||
} else {
|
||||
for (let i = 0; i < rowIdx; i++) top += (rowHeights.value[i] ?? 0) + PAGE_GAP
|
||||
}
|
||||
el.scrollTo({ top, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
function onScroll() {
|
||||
const el = scrollRef.value
|
||||
if (!el || !rowHeights.value.length) return
|
||||
let rowIdx = 0
|
||||
if (scrollMode.value === 'page') {
|
||||
rowIdx = Math.round(el.scrollTop / containerH.value)
|
||||
} else {
|
||||
let offset = el.scrollTop
|
||||
for (let i = 0; i < rowHeights.value.length; i++) {
|
||||
const h = (rowHeights.value[i] ?? 0) + PAGE_GAP
|
||||
if (offset < h) {
|
||||
rowIdx = i
|
||||
break
|
||||
}
|
||||
offset -= h
|
||||
rowIdx = i + 1
|
||||
}
|
||||
}
|
||||
const row = pageRows.value[rowIdx]
|
||||
currentPage.value = row?.[0] ?? totalPages.value
|
||||
}
|
||||
|
||||
return { scrollMode, currentPage, pageInput, pageRows, rowHeights, goToPage, onScroll }
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { nextTick, ref } from 'vue'
|
||||
import type * as pdfjsLib from 'pdfjs-dist'
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
|
||||
type RenderPageFn = (pageNum: number, canvas: HTMLCanvasElement, scale: number) => Promise<void>
|
||||
type GetTextContentFn = (pageNum: number) => Promise<{ content: pdfjsLib.TextContent; viewport: pdfjsLib.PageViewport } | null>
|
||||
|
||||
export function usePdfRenderer(
|
||||
renderPageFn: RenderPageFn,
|
||||
getTextContentFn: GetTextContentFn,
|
||||
scale: ComputedRef<number>,
|
||||
rotation: Ref<0 | 90 | 180 | 270>,
|
||||
totalPages: Ref<number>,
|
||||
) {
|
||||
const canvasMap = ref(new Map<number, HTMLCanvasElement>())
|
||||
const textLayerMap = ref(new Map<number, HTMLElement>())
|
||||
|
||||
const rendered = new Set<number>()
|
||||
let renderQueue: number[] = []
|
||||
let isRendering = false
|
||||
let ioEntries: IntersectionObserverEntry[] = []
|
||||
let io: IntersectionObserver | null = null
|
||||
|
||||
function enqueue(pageNum: number) {
|
||||
if (rendered.has(pageNum) || renderQueue.includes(pageNum)) return
|
||||
renderQueue.push(pageNum)
|
||||
flushQueue()
|
||||
}
|
||||
|
||||
async function flushQueue() {
|
||||
if (isRendering) return
|
||||
isRendering = true
|
||||
while (renderQueue.length > 0) {
|
||||
const pageNum = renderQueue.shift()!
|
||||
const canvas = canvasMap.value.get(pageNum)
|
||||
if (!canvas) continue
|
||||
await renderPageFn(pageNum, canvas, scale.value)
|
||||
rendered.add(pageNum)
|
||||
await buildTextLayer(pageNum)
|
||||
}
|
||||
isRendering = false
|
||||
}
|
||||
|
||||
async function buildTextLayer(pageNum: number) {
|
||||
const container = textLayerMap.value.get(pageNum)
|
||||
if (!container) return
|
||||
const result = await getTextContentFn(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`
|
||||
const s = scale.value
|
||||
for (const item of content.items) {
|
||||
if (!('str' in item) || !(item as pdfjsLib.TextItem).str.trim()) continue
|
||||
const { str, transform, height } = item as pdfjsLib.TextItem
|
||||
const [a, b, , , e, f] = transform
|
||||
const span = document.createElement('span')
|
||||
span.textContent = str
|
||||
span.style.cssText =
|
||||
`position:absolute;` +
|
||||
`left:${e * s}px;` +
|
||||
`top:${scaledVp.height - f * s - (height ?? 0) * s}px;` +
|
||||
`font-size:${Math.abs(a * s) || Math.abs(b * s)}px;` +
|
||||
`transform-origin:0% 0%;white-space:pre;color:transparent;cursor:text;`
|
||||
container.appendChild(span)
|
||||
}
|
||||
}
|
||||
|
||||
function ioCallback(entries: IntersectionObserverEntry[]) {
|
||||
for (const e of entries) {
|
||||
if (!e.isIntersecting) continue
|
||||
const nums = (e.target as HTMLElement).dataset.pages?.split(',').map(Number) ?? []
|
||||
for (const n of nums) {
|
||||
enqueue(n)
|
||||
if (n > 1) enqueue(n - 1)
|
||||
if (n < totalPages.value) enqueue(n + 1)
|
||||
}
|
||||
}
|
||||
ioEntries = entries
|
||||
}
|
||||
|
||||
// Clear render state and re-trigger visible pages — call after zoom, rotation, or resize.
|
||||
function invalidate() {
|
||||
rendered.clear()
|
||||
renderQueue = []
|
||||
isRendering = false
|
||||
nextTick(() => ioEntries.forEach((e) => ioCallback([e])))
|
||||
}
|
||||
|
||||
function setupIO(scrollEl: HTMLElement) {
|
||||
io?.disconnect()
|
||||
io = new IntersectionObserver(ioCallback, { root: scrollEl, rootMargin: '300px', threshold: 0 })
|
||||
nextTick(() => scrollEl.querySelectorAll('[data-pages]').forEach((el) => io!.observe(el)))
|
||||
}
|
||||
|
||||
// Call when a new document is loaded to clear stale canvas/text-layer refs.
|
||||
function reset() {
|
||||
canvasMap.value = new Map()
|
||||
textLayerMap.value = new Map()
|
||||
rendered.clear()
|
||||
renderQueue = []
|
||||
isRendering = false
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
io?.disconnect()
|
||||
}
|
||||
|
||||
return { canvasMap, textLayerMap, invalidate, setupIO, reset, destroy }
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
import type { PageDim } from './usePdf'
|
||||
|
||||
export type ZoomMode = 'fit-width' | 'fit-page' | 'custom'
|
||||
|
||||
export const ZOOM_PRESETS = [
|
||||
{ label: '50%', value: '0.5' },
|
||||
{ label: '75%', value: '0.75' },
|
||||
{ label: '100%', value: '1' },
|
||||
{ label: '125%', value: '1.25' },
|
||||
{ label: '150%', value: '1.5' },
|
||||
{ label: '200%', value: '2' },
|
||||
] as const
|
||||
|
||||
// Horizontal gap between paired pages in spread mode
|
||||
const SPREAD_GAP = 16
|
||||
|
||||
export function usePdfZoom(
|
||||
containerW: Ref<number>,
|
||||
containerH: Ref<number>,
|
||||
effectiveDims: ComputedRef<PageDim[]>,
|
||||
spread: Ref<'none' | 'odd' | 'even'>,
|
||||
) {
|
||||
const zoomMode = ref<ZoomMode>('fit-page')
|
||||
const customScale = ref(1.0)
|
||||
|
||||
const scale = computed(() => {
|
||||
const first = effectiveDims.value[0]
|
||||
if (!first || !containerW.value) return 1
|
||||
if (spread.value !== 'none') {
|
||||
const sw = (containerW.value - 48) / (first.width * 2 + SPREAD_GAP)
|
||||
if (zoomMode.value === 'fit-page') return Math.min(sw, (containerH.value - 48) / first.height)
|
||||
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
|
||||
})
|
||||
|
||||
const zoomPct = computed(() => Math.round(scale.value * 100))
|
||||
|
||||
const zoomLabel = computed(() => {
|
||||
if (zoomMode.value === 'fit-width') return 'Fit Width'
|
||||
if (zoomMode.value === 'fit-page') return 'Fit Page'
|
||||
return `${zoomPct.value}%`
|
||||
})
|
||||
|
||||
function adjustZoom(delta: number) {
|
||||
customScale.value = Math.round(
|
||||
Math.max(0.25, Math.min(4, (zoomMode.value === 'custom' ? customScale.value : scale.value) + delta) * 20) / 20,
|
||||
)
|
||||
zoomMode.value = 'custom'
|
||||
}
|
||||
|
||||
function applyZoomPreset(value: string) {
|
||||
if (value === 'fit-width' || value === 'fit-page') {
|
||||
zoomMode.value = value
|
||||
} else {
|
||||
customScale.value = parseFloat(value)
|
||||
zoomMode.value = 'custom'
|
||||
}
|
||||
}
|
||||
|
||||
return { zoomMode, customScale, scale, zoomPct, zoomLabel, adjustZoom, applyZoomPreset }
|
||||
}
|
||||
+3
-4
@@ -14,7 +14,6 @@
|
||||
"start:prod": "node dist/main",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:seed": "ts-node -r tsconfig-paths/register src/db/seed.ts",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"scan:test": "ts-node -r tsconfig-paths/register src/scripts/scan-test.ts",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
@@ -25,11 +24,11 @@
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/static": "^8.3.0",
|
||||
"@fastify/static": "^9.0.0",
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/config": "^4.0.2",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/platform-fastify": "^11.1.10",
|
||||
"@nestjs/platform-fastify": "^11.1.11",
|
||||
"@types/pdf-parse": "^1.1.5",
|
||||
"chokidar": "^4.0.3",
|
||||
"class-transformer": "^0.5.1",
|
||||
@@ -50,7 +49,7 @@
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.1",
|
||||
"@swc/cli": "^0.7.9",
|
||||
"@swc/core": "^1.15.7",
|
||||
"@swc/core": "^1.15.8",
|
||||
"@types/chokidar": "^1.7.5",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
|
||||
@@ -5,28 +5,28 @@
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1767351466197,
|
||||
"when": 1767368411691,
|
||||
"tag": "0000_init_schema",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1767351466197,
|
||||
"when": 1767368411691,
|
||||
"tag": "0001_lethal_pixie",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1767351466197,
|
||||
"when": 1767368411691,
|
||||
"tag": "0002_powerful_thundra",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1767351466197,
|
||||
"when": 1767368411691,
|
||||
"tag": "0003_reading_progress_by_file",
|
||||
"breakpoints": true
|
||||
}
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||
import { Pool } from 'pg';
|
||||
import * as schema from './schema';
|
||||
import { authors, bookAuthors, bookMetadata, books, libraryFolders, libraries } from './schema';
|
||||
|
||||
async function seed() {
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL ?? 'postgres://projectx:projectx@postgres:5432/projectx',
|
||||
});
|
||||
const db = drizzle(pool, { schema });
|
||||
|
||||
console.log('Clearing existing data...');
|
||||
await db.delete(bookAuthors);
|
||||
await db.delete(bookMetadata);
|
||||
await db.delete(books);
|
||||
await db.delete(authors);
|
||||
await db.delete(libraryFolders);
|
||||
await db.delete(libraries);
|
||||
|
||||
console.log('Seeding library...');
|
||||
const [library] = await db.insert(libraries).values({ name: 'My Library', scanMode: 'manual' }).returning();
|
||||
|
||||
const [folder] = await db.insert(libraryFolders).values({ libraryId: library.id, path: '/books' }).returning();
|
||||
|
||||
console.log('Seeding authors...');
|
||||
const authorNames = [
|
||||
{ name: 'Andy Weir', sortName: 'Weir, Andy' },
|
||||
{ name: 'N.K. Jemisin', sortName: 'Jemisin, N.K.' },
|
||||
{ name: 'Becky Chambers', sortName: 'Chambers, Becky' },
|
||||
{ name: 'Liu Cixin', sortName: 'Liu, Cixin' },
|
||||
];
|
||||
const insertedAuthors = await db.insert(authors).values(authorNames).returning();
|
||||
|
||||
console.log('Seeding books...');
|
||||
const seedBooks = [
|
||||
{ title: 'Project Hail Mary', authorIdx: 0, year: 2021 },
|
||||
{ title: 'The Fifth Season', authorIdx: 1, year: 2015 },
|
||||
{ title: 'A Long Way to a Small Angry Planet', authorIdx: 2, year: 2014 },
|
||||
{ title: 'The Three-Body Problem', authorIdx: 3, year: 2008 },
|
||||
];
|
||||
|
||||
const BATCH = 500;
|
||||
const allBooks = seedBooks.map((b, i) => ({
|
||||
libraryId: library.id,
|
||||
libraryFolderId: folder.id,
|
||||
folderPath: `/books/${b.title.toLowerCase().replace(/\s+/g, '-')}`,
|
||||
status: 'present' as const,
|
||||
}));
|
||||
|
||||
const insertedBooks: (typeof books.$inferSelect)[] = [];
|
||||
for (let i = 0; i < allBooks.length; i += BATCH) {
|
||||
const batch = await db
|
||||
.insert(books)
|
||||
.values(allBooks.slice(i, i + BATCH))
|
||||
.returning();
|
||||
insertedBooks.push(...batch);
|
||||
}
|
||||
|
||||
const metaValues = seedBooks.map((b, i) => ({
|
||||
bookId: insertedBooks[i]!.id,
|
||||
title: b.title,
|
||||
publishedYear: b.year,
|
||||
}));
|
||||
await db.insert(bookMetadata).values(metaValues);
|
||||
|
||||
const authorJoins = seedBooks.map((b, i) => ({
|
||||
bookId: insertedBooks[i]!.id,
|
||||
authorId: insertedAuthors[b.authorIdx]!.id,
|
||||
displayOrder: 0,
|
||||
}));
|
||||
await db.insert(bookAuthors).values(authorJoins);
|
||||
|
||||
await pool.end();
|
||||
console.log(`Done. Seeded ${insertedBooks.length} books.`);
|
||||
}
|
||||
|
||||
seed().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Get, NotFoundException, Param, ParseIntPipe, Post, Query, Res } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Headers, NotFoundException, Param, ParseIntPipe, Post, Query, Res } from '@nestjs/common';
|
||||
import { createReadStream } from 'fs';
|
||||
import type { FastifyReply } from 'fastify';
|
||||
import { BookService } from './book.service';
|
||||
@@ -29,14 +29,32 @@ export class BookController {
|
||||
// These MUST come before `:id/*` routes to avoid NestJS matching 'files' as :id.
|
||||
|
||||
@Get('files/:fileId/serve')
|
||||
async serveFile(@Param('fileId', ParseIntPipe) fileId: number, @Res() reply: FastifyReply) {
|
||||
const { stream, size, format } = await this.bookService.getFileStream(fileId);
|
||||
async serveFile(
|
||||
@Param('fileId', ParseIntPipe) fileId: number,
|
||||
@Headers('range') rangeHeader: string | undefined,
|
||||
@Res() reply: FastifyReply,
|
||||
) {
|
||||
const { path, size, format } = await this.bookService.getFileInfo(fileId);
|
||||
const mimeType = format === 'pdf' ? 'application/pdf' : format === 'cbz' ? 'application/zip' : 'application/epub+zip';
|
||||
reply.header('Content-Length', size);
|
||||
reply.header('Content-Disposition', 'inline');
|
||||
reply.header('Accept-Ranges', 'bytes');
|
||||
reply.header('Content-Disposition', 'inline');
|
||||
reply.type(mimeType);
|
||||
reply.send(stream);
|
||||
|
||||
if (rangeHeader) {
|
||||
const match = /bytes=(\d+)-(\d*)/.exec(rangeHeader);
|
||||
if (match) {
|
||||
const start = parseInt(match[1]);
|
||||
const end = match[2] ? parseInt(match[2]) : size - 1;
|
||||
reply.status(206);
|
||||
reply.header('Content-Range', `bytes ${start}-${end}/${size}`);
|
||||
reply.header('Content-Length', end - start + 1);
|
||||
reply.send(createReadStream(path, { start, end }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
reply.header('Content-Length', size);
|
||||
reply.send(createReadStream(path));
|
||||
}
|
||||
|
||||
@Get('files/:fileId/progress')
|
||||
|
||||
@@ -62,11 +62,11 @@ export class BookService {
|
||||
}
|
||||
}
|
||||
|
||||
async getFileStream(fileId: number): Promise<{ stream: ReturnType<typeof createReadStream>; size: number; format: string }> {
|
||||
async getFileInfo(fileId: number): Promise<{ path: string; size: number; format: string }> {
|
||||
const file = await this.bookRepo.findFileById(fileId);
|
||||
if (!file) throw new NotFoundException(`No file with id ${fileId}`);
|
||||
const { size } = await stat(file.absolutePath);
|
||||
return { stream: createReadStream(file.absolutePath), size, format: file.format ?? 'unknown' };
|
||||
return { path: file.absolutePath, size, format: file.format ?? 'unknown' };
|
||||
}
|
||||
|
||||
async getProgress(fileId: number) {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { IsInt, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
import { IsInt, IsNumber, IsString, Max, Min, ValidateIf } from 'class-validator';
|
||||
|
||||
export class SaveProgressDto {
|
||||
@IsOptional()
|
||||
@ValidateIf((o: SaveProgressDto) => o.cfi != null)
|
||||
@IsString()
|
||||
cfi?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((o: SaveProgressDto) => o.pageNumber != null)
|
||||
@IsInt()
|
||||
pageNumber?: number | null;
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { basename, extname } from 'path';
|
||||
|
||||
export interface ParsedFilename {
|
||||
title: string;
|
||||
publishedYear: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives a display title and optional year from a filename when embedded
|
||||
* metadata is absent. Does NOT attempt to extract author — filename
|
||||
* conventions vary too much (title-first, author-first, series prefixes, etc.)
|
||||
* and a wrong author is worse than no author.
|
||||
*
|
||||
* Transformations applied:
|
||||
* - Strip file extension
|
||||
* - Strip leading series index ("01. Title" → "Title")
|
||||
* - Replace underscores with colons (common filename substitution)
|
||||
* - Strip trailing year "(YYYY)" and return it separately
|
||||
*/
|
||||
export function parseBookFilename(absolutePath: string): ParsedFilename {
|
||||
const stem = basename(absolutePath, extname(absolutePath))
|
||||
.replace(/^\d+\.\s*/, '') // strip leading "01. "
|
||||
.replace(/_/g, ':'); // underscore → colon
|
||||
|
||||
// Strip trailing "(YYYY)" or "(YYYY) (extra stuff)" — keep only first 4-digit year
|
||||
const yearMatch = stem.match(/\((\d{4})\)/);
|
||||
const year = yearMatch ? parseInt(yearMatch[1], 10) : null;
|
||||
|
||||
// Remove all parenthetical suffixes from the end: "(2020)", "(UK)", "(retail)", etc.
|
||||
const title = stem.replace(/\s*\([^)]*\)\s*$/, '').trim();
|
||||
|
||||
return { title, publishedYear: year };
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { readFile } from 'fs/promises';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const pdfParse: (buf: Buffer, opts?: { max?: number }) => Promise<{ info: unknown; numpages: number }> = require('pdf-parse');
|
||||
import { PDFParse } from 'pdf-parse';
|
||||
|
||||
export interface PdfParsed {
|
||||
title: string | null;
|
||||
@@ -52,9 +51,9 @@ function extractFirstJpeg(buf: Buffer): Buffer | null {
|
||||
export async function parsePdfFile(absolutePath: string): Promise<PdfParsed | null> {
|
||||
try {
|
||||
const buf = await readFile(absolutePath);
|
||||
|
||||
// pdf-parse can throw on malformed PDFs; run without text extraction for speed
|
||||
const data = await pdfParse(buf, { max: 1 });
|
||||
const parser = new PDFParse({ data: buf });
|
||||
const data = await parser.getInfo();
|
||||
await parser.destroy();
|
||||
|
||||
const info = (data.info ?? {}) as Record<string, unknown>;
|
||||
|
||||
@@ -65,7 +64,7 @@ export async function parsePdfFile(absolutePath: string): Promise<PdfParsed | nu
|
||||
// PDFs rarely store publisher; some use 'Creator' for the authoring app
|
||||
// and 'Producer' for the PDF engine — neither is a publisher field.
|
||||
const publisher: string | null = null;
|
||||
const pageCount = typeof data.numpages === 'number' ? data.numpages : null;
|
||||
const pageCount = typeof data.total === 'number' ? data.total : null;
|
||||
|
||||
const authors: PdfParsed['authors'] = authorRaw
|
||||
? authorRaw
|
||||
|
||||
@@ -9,6 +9,7 @@ import { authors, bookAuthors, bookMetadata, bookTags, tags } from '../../db/sch
|
||||
import { extractCbzMetadata } from './lib/cbz-metadata';
|
||||
import { extractAndSaveCover } from './lib/cover';
|
||||
import { extractEpubMetadata } from './lib/epub';
|
||||
import { parseBookFilename } from './lib/filename-parser';
|
||||
import { parseMobiFile } from './lib/mobi-parser';
|
||||
import { parsePdfFile } from './lib/pdf-parser';
|
||||
|
||||
@@ -92,14 +93,15 @@ export class MetadataService {
|
||||
} else if (format === 'pdf') {
|
||||
const pdf = await parsePdfFile(absolutePath);
|
||||
if (pdf) {
|
||||
const fb = !pdf.title ? parseBookFilename(absolutePath) : null;
|
||||
parsed = {
|
||||
title: pdf.title,
|
||||
title: pdf.title ?? fb?.title ?? null,
|
||||
subtitle: null,
|
||||
description: pdf.subject,
|
||||
isbn10: null,
|
||||
isbn13: null,
|
||||
publisher: null,
|
||||
publishedYear: null,
|
||||
publishedYear: fb?.publishedYear ?? null,
|
||||
language: null,
|
||||
seriesName: null,
|
||||
seriesIndex: null,
|
||||
|
||||
Reference in New Issue
Block a user