feat: improve PDF reader loading state and UI

This commit is contained in:
neon
2026-04-08 20:33:38 -06:00
parent 9f6b7d4f2b
commit 1cf34fb0ab
4 changed files with 78 additions and 68 deletions
+18 -20
View File
@@ -33,6 +33,8 @@ const bookId = Number(route.params.bookId)
const fileId = Number(route.params.fileId)
const fileFormat = (route.query.format as string) || 'epub'
const isAudioFormat = getFormatGroup(fileFormat) === 'audio'
const isPdfFormat = fileFormat === 'pdf'
const isComicFormat = fileFormat === 'cbz' || fileFormat === 'cbr' || fileFormat === 'cb7'
const containerRef = ref<HTMLElement | null>(null)
const showSidebar = ref(false)
@@ -137,26 +139,22 @@ onMounted(async () => {
document.addEventListener('fullscreenchange', onFullscreenChange)
onUnmounted(() => document.removeEventListener('fullscreenchange', onFullscreenChange))
// Specialized readers own their own progress/settings/loading lifecycle.
if (isAudioFormat || isPdfFormat || isComicFormat) return
await progress.load()
// Audio formats are handled entirely by AudiobookReaderView
if (isAudioFormat) return
// Only epub-group formats use the epub reader; PDF/CBZ are rendered by child components
const isEpubFormat = !['pdf', 'cbz', 'cbr', 'cb7'].includes(fileFormat)
if (isEpubFormat) {
await bookSettings.load()
const effective = bookSettings.effective.value as EpubReaderSettings
if (effective.overrideBookFormatting) {
shouldApplyStyles.value = true
seedState(effective)
} else if (bookSettings.isCustomized.value) {
// Only apply the per-book delta - don't bleed global defaults like dark theme into the book
shouldApplyStyles.value = true
seedState(bookSettings.bookDelta.value as Partial<ReaderState>)
} else {
shouldApplyStyles.value = false
}
await bookSettings.load()
const effective = bookSettings.effective.value as EpubReaderSettings
if (effective.overrideBookFormatting) {
shouldApplyStyles.value = true
seedState(effective)
} else if (bookSettings.isCustomized.value) {
// Only apply the per-book delta - don't bleed global defaults like dark theme into the book
shouldApplyStyles.value = true
seedState(bookSettings.bookDelta.value as Partial<ReaderState>)
} else {
shouldApplyStyles.value = false
}
await open(bookId, fileId, fileFormat, progress.cfi.value)
@@ -287,8 +285,8 @@ function closeSearch() {
</script>
<template>
<PdfReaderView v-if="fileFormat === 'pdf'" :bookId="bookId" :fileId="fileId" />
<CbzReaderView v-else-if="fileFormat === 'cbz' || fileFormat === 'cbr' || fileFormat === 'cb7'" :bookId="bookId" :fileId="fileId" />
<PdfReaderView v-if="isPdfFormat" :bookId="bookId" :fileId="fileId" />
<CbzReaderView v-else-if="isComicFormat" :bookId="bookId" :fileId="fileId" />
<AudiobookReaderView v-else-if="isAudioFormat" :bookId="bookId" :fileId="fileId" />
<div
v-else
@@ -61,7 +61,7 @@ function onDimUpdate(pageNum: number, dim: PageDim) {
}
const renderer = usePdfRenderer(startRenderPage, getTextContent, scale, totalPages, onDimUpdate)
const { canvasMap, textLayerMap, invalidate, setupIO, reset, destroy } = renderer
const { canvasMap, textLayerMap, invalidate, renderPage, setupIO, reset, destroy } = renderer
// ── Find ──────────────────────────────────────────────────────────────────────
const find = usePdfFind(textLayerMap)
@@ -161,6 +161,8 @@ watch(currentPage, (page) => {
})
const progressPct = computed(() => (totalPages.value ? Math.round((currentPage.value / totalPages.value) * 100) : 0))
const readerReady = ref(false)
const isReaderLoading = computed(() => !error.value && (!readerReady.value || loading.value))
// ── Scroll container style ────────────────────────────────────────────────────
const scrollStyle = computed((): CSSProperties => {
@@ -226,49 +228,53 @@ onMounted(async () => {
isFullscreen.value = !!document.fullscreenElement
})
await progress.load()
await bookSettings.load()
try {
await progress.load()
await bookSettings.load()
const ps = bookSettings.effective.value as PdfReaderSettings
// Coerce saved 'continuous' (old value) to 'vertical'
const savedMode = ps.scrollMode as string
scrollMode.value = savedMode === 'continuous' ? 'vertical' : (ps.scrollMode as ScrollMode)
spread.value = ps.spread
zoomMode.value = ps.zoomMode
customScale.value = ps.customScale
const ps = bookSettings.effective.value as PdfReaderSettings
// Coerce saved 'continuous' (old value) to 'vertical'
const savedMode = ps.scrollMode as string
scrollMode.value = savedMode === 'continuous' ? 'vertical' : (ps.scrollMode as ScrollMode)
spread.value = ps.spread
zoomMode.value = ps.zoomMode
customScale.value = ps.customScale
watch(spread, (v) => bookSettings.updateBookSettings({ spread: v }))
watch(zoomMode, (v) => bookSettings.updateBookSettings({ zoomMode: v }))
watch(customScale, (v) => bookSettings.updateBookSettings({ customScale: v }))
watch(scrollMode, (v) => bookSettings.updateBookSettings({ scrollMode: v } as Parameters<typeof bookSettings.updateBookSettings>[0]))
watch(spread, (v) => bookSettings.updateBookSettings({ spread: v }))
watch(zoomMode, (v) => bookSettings.updateBookSettings({ zoomMode: v }))
watch(customScale, (v) => bookSettings.updateBookSettings({ customScale: v }))
watch(scrollMode, (v) => bookSettings.updateBookSettings({ scrollMode: v } as Parameters<typeof bookSettings.updateBookSettings>[0]))
await load(props.fileId)
if (!pdfDoc.value) return
await load(props.fileId)
if (!pdfDoc.value) return
const firstDim = await getPageDim(1)
pageDims.value = Array.from({ length: totalPages.value }, () => ({ ...firstDim }))
reset()
const firstDim = await getPageDim(1)
pageDims.value = Array.from({ length: totalPages.value }, () => ({ ...firstDim }))
reset()
await nextTick()
measure()
await outline.load()
resizeObserver = new ResizeObserver(async () => {
await nextTick()
measure()
invalidate()
await nextTick()
if (scrollRef.value) setupIO(scrollRef.value)
})
if (scrollRef.value) {
resizeObserver.observe(scrollRef.value)
setupIO(scrollRef.value)
}
const initialPage = progress.pageNumber.value && progress.pageNumber.value > 1 ? progress.pageNumber.value : 1
goToPage(initialPage, 'instant')
await renderPage(initialPage)
if (progress.pageNumber.value && progress.pageNumber.value > 1) {
await nextTick()
goToPage(progress.pageNumber.value, 'instant')
resizeObserver = new ResizeObserver(async () => {
measure()
invalidate()
await nextTick()
if (scrollRef.value) setupIO(scrollRef.value)
})
if (scrollRef.value) {
resizeObserver.observe(scrollRef.value)
setupIO(scrollRef.value)
}
readerReady.value = true
outline.load().catch(() => {})
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to initialize PDF reader'
}
})
@@ -286,6 +292,13 @@ onUnmounted(() => {
<template>
<div class="fixed inset-0 flex flex-col overflow-hidden select-none bg-muted">
<div v-if="isReaderLoading" class="absolute inset-0 z-[60] flex items-center justify-center bg-background">
<div class="flex flex-col items-center gap-3">
<div class="w-8 h-8 rounded-full border-2 border-primary border-t-transparent animate-spin" />
<p class="text-sm text-muted-foreground">Loading PDF...</p>
</div>
</div>
<!-- Toolbar (always visible) -->
<PdfToolbar
:current-page="currentPage"
@@ -348,14 +361,7 @@ onUnmounted(() => {
<!-- PDF Viewport -->
<div class="flex-1 min-w-0 relative">
<div v-if="loading" class="absolute inset-0 flex items-center justify-center bg-background">
<div class="flex flex-col items-center gap-3">
<div class="w-8 h-8 rounded-full border-2 border-primary border-t-transparent animate-spin" />
<p class="text-sm text-muted-foreground">Loading PDF...</p>
</div>
</div>
<div v-else-if="error" class="absolute inset-0 flex items-center justify-center p-8 text-center bg-background">
<div v-if="error" class="absolute inset-0 flex items-center justify-center p-8 text-center bg-background">
<div>
<p class="text-sm font-medium text-foreground mb-1">Failed to load PDF</p>
<p class="text-xs text-muted-foreground">{{ error }}</p>
@@ -363,7 +369,7 @@ onUnmounted(() => {
</div>
<div
v-else
v-show="!error"
ref="scrollRef"
class="absolute inset-0"
:class="cursorTool === 'hand' ? 'cursor-grab active:cursor-grabbing' : 'cursor-auto'"
@@ -454,7 +460,7 @@ onUnmounted(() => {
</div>
<!-- Progress bar -->
<div v-if="!loading && !error && totalPages > 0" class="absolute bottom-0 left-0 right-0 h-0.5 bg-border z-10">
<div v-if="readerReady && !error && totalPages > 0" class="absolute bottom-0 left-0 right-0 h-0.5 bg-border z-10">
<div class="h-full bg-primary/60 transition-all duration-500" :style="{ width: `${progressPct}%` }" />
</div>
</div>
@@ -16,7 +16,7 @@ const emit = defineEmits<{
</script>
<template>
<div class="flex flex-col shrink-0 overflow-hidden border-r border-border bg-card" style="width: 240px">
<div class="flex flex-col shrink-0 overflow-hidden border-r border-border bg-card px-1.5 w-64">
<!-- Header -->
<div class="flex items-center gap-1.5 px-3 py-2.5 shrink-0 text-xs text-muted-foreground border-b border-border">
<List :size="12" />
@@ -46,6 +46,12 @@ export function usePdfRenderer(
flushQueue()
}
async function renderPage(pageNum: number) {
if (pageNum < 1 || pageNum > totalPages.value || rendered.has(pageNum)) return
renderQueue = [pageNum, ...renderQueue.filter((n) => n !== pageNum)]
await flushQueue()
}
async function flushQueue() {
if (isRendering) return
isRendering = true
@@ -147,5 +153,5 @@ export function usePdfRenderer(
for (const task of activeRenders.values()) task.cancel()
}
return { canvasMap, textLayerMap, invalidate, setupIO, reset, destroy }
return { canvasMap, textLayerMap, invalidate, renderPage, setupIO, reset, destroy }
}