Files
bookorbit/client/src/features/reader/cbz/CbzReaderView.vue
T
neon 97cd03325f feat(reader): open DjVu documents in the web reader
Scanned books are commonly distributed as DjVu, which the scanner only stored as a supplement.
Decode pages on the server with DjVuLibre so DjVu imports with a cover and page count, opens in
the image reader with its embedded OCR text, and can be the preferred format of an ordinary email
recipient. Migration 0113 widens the recipient format check to allow it.

Decoding runs on validated private snapshots behind a bounded, reader-first queue, and
revalidation and cached pages are answered without a decoder. Released iOS builds cannot open
DjVu, so series continuation offers it only to callers sending includeDjvu=true, and the
dashboard and notebook read file never name it. The first full scan after upgrading promotes
stored .djvu supplements to content.
2026-10-14 21:37:41 +00:00

1263 lines
49 KiB
Vue

<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
import { useVirtualizer } from '@tanstack/vue-virtual'
import { useMediaQuery } from '@vueuse/core'
import {
ArrowLeft,
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
Maximize,
Minimize,
Minus,
Pin,
PinOff,
Plus,
Settings,
FileText,
} from '@lucide/vue'
import { useVisibility } from '../shared/composables/useVisibility'
import { useReaderProgress } from '../shared/composables/useReaderProgress'
import { useReadingSession } from '../shared/composables/useReadingSession'
import { useCbz } from './composables/useCbz'
import { useSeriesNextBook } from '../shared/composables/useSeriesNextBook'
import { useCbzSettings } from './composables/useCbzSettings'
import { useReaderSettings } from '../shared/composables/useReaderSettings'
import { useFullscreen } from '../shared/composables/useFullscreen'
import type { CbxReaderSettings } from '@bookorbit/types'
import { DEFAULT_WIDE_PAGE_RATIO_THRESHOLD, createCbzSpreadLayout } from './lib/spread-layout'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import ReaderSettingsSheet from '@/features/reader/shared/components/ReaderSettingsSheet.vue'
import CbzSettingsPanel from './components/CbzSettingsPanel.vue'
import NextIssueCard from './components/NextIssueCard.vue'
import DjvuTextSheet from '../djvu/DjvuTextSheet.vue'
const TWO_PAGE_BREAKPOINT = 900
// A page turn that only just landed on the last page must not carry through into the next book:
// wheel bursts and held keys arrive in quick succession, so the crossing needs a deliberate turn.
const AUTO_ADVANCE_ARM_DELAY_MS = 500
const MIN_ZOOM_SCALE = 0.5
const MAX_ZOOM_SCALE = 3
const ZOOM_STEP = 0.25
const { t } = useI18n()
const props = defineProps<{ bookId: number; fileId: number; peekMode?: boolean; source?: 'cbz' | 'djvu' }>()
const route = useRoute()
const router = useRouter()
const trackingEnabled = computed(() => !props.peekMode)
const { headerVisible, footerVisible, isPinned, handleMiddleTap, togglePinned, showHeader, showFooter, setVisibilityLock } = useVisibility()
const { isFullscreen, toggleFullscreen } = useFullscreen()
const { onActivity, elapsedMinutes } = useReadingSession(
props.fileId,
() => ({
percentage: progress.percentage.value,
pageNumber: progress.pageNumber.value,
}),
{ trackingEnabled },
)
const progress = useReaderProgress(props.bookId, props.fileId, elapsedMinutes, 0, { trackingEnabled })
const { pageCount, bookTitle, seriesId, loading, error, pageUrl, load } = useCbz(props.fileId, props.bookId, props.source ?? 'cbz')
const {
fitMode,
viewMode,
scrollMode,
direction,
spreadAlignment,
spreadGap,
forceTwoPage,
widePageSingletonMode,
bgColor,
autoAdvance,
bgValue,
imgFitClass,
} = useCbzSettings()
const { nextBook, load: loadNextBook } = useSeriesNextBook('cbx')
const bookSettings = useReaderSettings(props.fileId, props.source === 'djvu' ? 'djvu' : 'cbz')
const currentPage = ref(0)
const imageError = ref(false)
const imageGeneration = ref(0)
const showPageText = ref(false)
function goBack() {
router.back()
}
function firstPage() {
goToPage(0)
}
function lastPage() {
goToPage(pageCount.value - 1)
}
function onPageSlider(event: Event) {
goToPage(Number((event.target as HTMLInputElement).value))
}
function openPageText() {
showPageText.value = true
}
function onTextOpenChange(open: boolean) {
showPageText.value = open
}
function onImageError() {
imageError.value = true
}
async function retryImages() {
await load()
if (disposed || error.value || pageCount.value <= 0) return
currentPage.value = Math.min(currentPage.value, pageCount.value - 1)
imageError.value = false
currentImageLoaded.value = false
loadedImageCount.value = 0
imageGeneration.value++
preloadCache.clear()
}
function onPaginatedLoad(event: Event) {
const image = event.target
if (image instanceof HTMLImageElement) onPaginatedImageLoad(Number(image.dataset.page), event)
}
const showSettings = ref(false)
const scrollContainer = ref<HTMLElement | null>(null)
const paginatedViewport = ref<HTMLElement | null>(null)
const viewportWidth = ref(0)
const viewportHeight = ref(0)
const currentImageLoaded = ref(false)
const pendingImageLoads = ref(0)
const loadedImageCount = ref(0)
const pageRatios = ref<number[]>([])
const pageDimensions = ref<Array<{ width: number; height: number } | undefined>>([])
const stripImagesReady = ref(false)
const zoomScale = ref(1)
// Only keyboard focus holds the controls open; a clicked or tapped button keeps focus too, and
// must not stop the bars from hiding.
const controlsFocused = ref(false)
function onControlsFocus(event: FocusEvent) {
controlsFocused.value = event.target instanceof HTMLElement && event.target.matches(':focus-visible')
}
function onControlsBlur(event: FocusEvent) {
const next = event.relatedTarget
if (!(next instanceof HTMLElement && next.closest('[data-reader-controls]'))) controlsFocused.value = false
}
watch([showSettings, showPageText, controlsFocused], ([settings, text, focused]) => {
setVisibilityLock(settings || text || focused)
if (focused) footerVisible.value = true
})
// The settings surface is one panel in two containers: an anchored popover where there
// is room beside the page, a bottom sheet where the thumb is and the page must stay visible.
const isCompact = useMediaQuery('(max-width: 639px)')
const panelSettings = computed<CbxReaderSettings>(() => ({
fitMode: fitMode.value,
viewMode: viewMode.value,
scrollMode: scrollMode.value,
direction: direction.value,
spreadAlignment: spreadAlignment.value,
spreadGap: spreadGap.value,
forceTwoPage: forceTwoPage.value,
widePageSingletonMode: widePageSingletonMode.value,
bgColor: bgColor.value,
autoAdvance: autoAdvance.value,
}))
// Persists here rather than via watches on the refs, so applySettings can restore
// load-time or reset values without immediately re-recording them as book overrides.
function applyPanelUpdate(partial: Partial<CbxReaderSettings>) {
if (partial.fitMode !== undefined) fitMode.value = partial.fitMode
if (partial.viewMode !== undefined) viewMode.value = partial.viewMode
if (partial.scrollMode !== undefined) scrollMode.value = partial.scrollMode
if (partial.direction !== undefined) direction.value = partial.direction
if (partial.spreadAlignment !== undefined) spreadAlignment.value = partial.spreadAlignment
if (partial.spreadGap !== undefined) spreadGap.value = partial.spreadGap
if (partial.forceTwoPage !== undefined) forceTwoPage.value = partial.forceTwoPage
if (partial.widePageSingletonMode !== undefined) widePageSingletonMode.value = partial.widePageSingletonMode
if (partial.bgColor !== undefined) bgColor.value = partial.bgColor
if (partial.autoAdvance !== undefined) autoAdvance.value = partial.autoAdvance
bookSettings.updateBookSettings(partial)
}
function onSettingsOpenChange(open: boolean) {
showSettings.value = open
}
/** Mirrors PopoverTrigger on the wide path, so the icon means the same thing in both containers. */
function toggleSettings() {
showSettings.value = !showSettings.value
}
function applySettings(s: CbxReaderSettings) {
fitMode.value = s.fitMode
viewMode.value = s.viewMode
scrollMode.value = s.scrollMode
direction.value = s.direction
spreadAlignment.value = s.spreadAlignment
spreadGap.value = s.spreadGap
forceTwoPage.value = s.forceTwoPage
widePageSingletonMode.value = s.widePageSingletonMode
bgColor.value = s.bgColor
autoAdvance.value = s.autoAdvance
}
function resetBookViewSettings() {
bookSettings.resetBookSettings()
applySettings(bookSettings.effective.value as CbxReaderSettings)
if (scrollMode.value === 'paginated' && isTwoPageEffective.value) {
currentPage.value = spreadLayout.value.anchorForPage(currentPage.value)
}
}
// ── Layout engine ──────────────────────────────────────────────────────────────
const isTwoPagePreferred = computed(() => viewMode.value === 'two-page' && scrollMode.value === 'paginated')
const isTwoPageEffective = computed(() => isTwoPagePreferred.value && (forceTwoPage.value || viewportWidth.value >= TWO_PAGE_BREAKPOINT))
const spreadLayout = computed(() =>
createCbzSpreadLayout({
pageCount: pageCount.value,
isTwoPageEffective: isTwoPageEffective.value,
direction: direction.value,
spreadAlignment: spreadAlignment.value,
widePageSingletonMode: widePageSingletonMode.value,
isWidePage: (page) => (pageRatios.value[page] ?? 0) >= DEFAULT_WIDE_PAGE_RATIO_THRESHOLD,
}),
)
const currentSpread = computed(() => spreadLayout.value.spreadForPage(currentPage.value))
const renderSpread = computed(() => currentSpread.value?.kind === 'spread')
const renderSinglePage = computed(() => (currentSpread.value?.kind === 'single' ? currentSpread.value.singlePage : null))
const renderLeftPage = computed(() => (currentSpread.value?.kind === 'spread' ? currentSpread.value.leftPage : null))
const renderRightPage = computed(() => (currentSpread.value?.kind === 'spread' ? currentSpread.value.rightPage : null))
const spreadContainerStyle = computed(() => ({ columnGap: `${spreadGap.value}px` }))
const renderKey = computed(() => {
const spread = currentSpread.value
if (!spread) return 'none'
if (spread.kind === 'single') return `single:${spread.singlePage ?? -1}`
return `spread:${spread.leftPage ?? 'blank'}:${spread.rightPage ?? 'blank'}`
})
const pageLabel = computed(() => {
const spread = currentSpread.value
if (!spread || pageCount.value <= 0) return '0 / 0'
const start = spread.pages[0]
if (start === undefined) return `0 / ${pageCount.value}`
if (spread.pages.length === 2) {
const end = spread.pages[1]
if (end !== undefined) return `${start + 1}-${end + 1} / ${pageCount.value}`
}
return `${start + 1} / ${pageCount.value}`
})
const fullscreenLabel = computed(() => (isFullscreen.value ? 'Exit fullscreen' : 'Enter fullscreen'))
const zoomPercent = computed(() => Math.round(zoomScale.value * 100))
const canZoomOut = computed(() => zoomScale.value > MIN_ZOOM_SCALE)
const canZoomIn = computed(() => zoomScale.value < MAX_ZOOM_SCALE)
const paginatedStageStyle = computed(() => {
const stageScale = Math.max(1, zoomScale.value)
return {
width: `${stageScale * 100}%`,
height: `${stageScale * 100}%`,
}
})
const paginatedContentStyle = computed(() => {
const stageScale = Math.max(1, zoomScale.value)
return {
width: `${100 / stageScale}%`,
height: `${100 / stageScale}%`,
transform: `scale(${zoomScale.value})`,
}
})
const progressPageIndex = computed(() => {
const spread = currentSpread.value
if (!spread || spread.pages.length === 0) return currentPage.value
return spread.pages[spread.pages.length - 1] ?? currentPage.value
})
const progressPercent = computed(() => {
if (pageCount.value <= 0) return 0
return ((Math.max(0, Math.min(progressPageIndex.value, pageCount.value - 1)) + 1) / pageCount.value) * 100
})
const sliderFillPercent = computed(() => {
const max = Math.max(0, pageCount.value - 1)
if (max === 0) return 0
return (Math.max(0, Math.min(currentPage.value, max)) / max) * 100
})
const stripFrameClass = computed(() => {
if (fitMode.value === 'fit-height' || fitMode.value === 'fit-page') {
return 'flex items-center justify-center overflow-hidden'
}
return 'flex justify-center'
})
const stripGap = computed(() => (scrollMode.value === 'long-strip' ? 0 : 8))
const stripPadding = computed(() => (scrollMode.value === 'long-strip' ? 0 : 16))
const stripViewportWidth = computed(() => Math.max(1, viewportWidth.value - (scrollMode.value === 'long-strip' ? 0 : 16)))
const stripVirtualizer = useVirtualizer(
computed(() => {
const mode = scrollMode.value
const fit = fitMode.value
const height = Math.max(1, viewportHeight.value)
const width = stripViewportWidth.value
const scale = zoomScale.value
const ratios = pageRatios.value
return {
count: mode === 'paginated' ? 0 : pageCount.value,
getScrollElement: () => scrollContainer.value,
estimateSize: (index: number) => {
if (fit === 'fit-page' || fit === 'fit-height') return height * scale
const ratio = ratios[index]
if (fit === 'fit-width' && ratio && ratio > 0) return (width * scale) / ratio
return height * scale
},
gap: stripGap.value,
paddingStart: stripPadding.value,
paddingEnd: stripPadding.value,
overscan: 2,
getItemKey: (index: number) => index,
}
}),
)
const virtualStripPages = computed(() => stripVirtualizer.value.getVirtualItems())
const virtualStripSize = computed(() => stripVirtualizer.value.getTotalSize())
const renderedStripPages = computed(() => (stripImagesReady.value ? virtualStripPages.value : []))
function measureStripPage(element: unknown) {
if (element instanceof Element) stripVirtualizer.value.measureElement(element)
}
const stripImageClass = computed(() => {
switch (fitMode.value) {
case 'fit-width':
return 'w-full h-auto max-w-full block'
case 'fit-height':
return 'h-full w-auto max-h-full block'
case 'actual':
return 'max-w-none max-h-none block'
default: // fit-page
return 'max-w-full max-h-full object-contain block'
}
})
function stripFrameStyle(pageStart: number) {
const scaledWidth = stripViewportWidth.value * zoomScale.value
const frameHeight = fitMode.value === 'fit-height' || fitMode.value === 'fit-page' ? viewportHeight.value * zoomScale.value : undefined
return {
width: `${scaledWidth}px`,
height: frameHeight === undefined ? undefined : `${frameHeight}px`,
insetInlineStart: `${Math.max(0, (viewportWidth.value - scaledWidth) / 2)}px`,
transform: `translateY(${pageStart}px)`,
}
}
function stripImageStyle(pageIndex: number) {
if (fitMode.value !== 'actual') return undefined
const dimensions = pageDimensions.value[pageIndex]
if (!dimensions) return undefined
return {
width: `${dimensions.width * zoomScale.value}px`,
height: `${dimensions.height * zoomScale.value}px`,
}
}
const canGoPrev = computed(() => {
if (pageCount.value <= 0) return false
if (isTwoPageEffective.value) return spreadLayout.value.prevAnchor(currentPage.value) !== currentPage.value
return currentPage.value > 0
})
const canGoNext = computed(() => {
if (pageCount.value <= 0) return false
if (isTwoPageEffective.value) return spreadLayout.value.nextAnchor(currentPage.value) !== currentPage.value
return currentPage.value < pageCount.value - 1
})
watch(renderKey, () => {
imageError.value = false
const spread = currentSpread.value
const expected = spread?.pages.length ?? 0
pendingImageLoads.value = expected
loadedImageCount.value = 0
currentImageLoaded.value = expected === 0
})
// ── Preloading ─────────────────────────────────────────────────────────────────
const preloadCache = new Map<number, HTMLImageElement>()
function setPageRatio(pageIndex: number, width: number, height: number) {
if (pageIndex < 0 || pageIndex >= pageCount.value || height <= 0 || width <= 0) return
const ratio = width / height
if (pageRatios.value[pageIndex] === ratio) return
const next = [...pageRatios.value]
next[pageIndex] = ratio
pageRatios.value = next
const nextDimensions = [...pageDimensions.value]
nextDimensions[pageIndex] = { width, height }
pageDimensions.value = nextDimensions
}
function preload(n: number) {
if (n < 0 || n >= pageCount.value || preloadCache.has(n)) return
const img = new Image()
img.onload = () => setPageRatio(n, img.naturalWidth, img.naturalHeight)
img.src = pageUrl(n)
preloadCache.set(n, img)
}
// Every DjVu page is rendered on the server by a small shared decoder pool, so it looks one spread
// ahead instead of four and leaves the pool free for the page on screen.
const PRELOAD_SPREAD_OFFSETS = props.source === 'djvu' ? [0, 1] : [-1, 0, 1, 2]
function schedulePreload(anchorPage: number) {
const layout = spreadLayout.value
const centerSpreadIndex = layout.spreadIndexForPage(anchorPage)
const pagesToPreload = new Set<number>()
for (const offset of PRELOAD_SPREAD_OFFSETS) {
const spread = layout.spreads[centerSpreadIndex + offset]
if (!spread) continue
for (const page of spread.pages) pagesToPreload.add(page)
}
for (const page of pagesToPreload) preload(page)
for (const [page] of preloadCache) {
if (Math.abs(page - anchorPage) > 12) preloadCache.delete(page)
}
}
function onPaginatedImageLoad(pageIndex: number, e: Event) {
const target = e.target
if (!(target instanceof HTMLImageElement)) return
setPageRatio(pageIndex, target.naturalWidth, target.naturalHeight)
loadedImageCount.value += 1
if (loadedImageCount.value >= pendingImageLoads.value) {
currentImageLoaded.value = true
}
}
function onStripImageLoad(pageIndex: number, e: Event) {
const target = e.target
if (!(target instanceof HTMLImageElement)) return
setPageRatio(pageIndex, target.naturalWidth, target.naturalHeight)
}
// ── Series handoff ─────────────────────────────────────────────────────────────
// Peeking is a look inside one book, so it never crosses into another.
const seriesFlowEnabled = computed(() => !props.peekMode)
const isAtLastPage = computed(() => pageCount.value > 0 && !canGoNext.value)
const showNextBookCard = computed(() => seriesFlowEnabled.value && nextBook.value !== null && !loading.value && error.value === null)
const showNextBookOverlay = computed(() => showNextBookCard.value && scrollMode.value === 'paginated' && isAtLastPage.value)
const canAutoAdvance = computed(() => autoAdvance.value && showNextBookCard.value && scrollMode.value === 'paginated')
let lastPageReachedAt = 0
let openingNextBook = false
watch(isAtLastPage, (atLastPage) => {
lastPageReachedAt = atLastPage ? Date.now() : 0
})
function shouldAutoAdvance(): boolean {
if (!canAutoAdvance.value || !isAtLastPage.value) return false
return lastPageReachedAt > 0 && Date.now() - lastPageReachedAt >= AUTO_ADVANCE_ARM_DELAY_MS
}
async function openNextBook() {
const target = nextBook.value
if (!target || openingNextBook) return
openingNextBook = true
try {
// The route keeps its name and only its params change, so the leave guard never runs: the last
// page has to be written here or the book it just finished stays short of complete.
await flushPendingProgress()
await router.push({ name: 'reader', params: { bookId: target.bookId, fileId: target.fileId }, query: { format: target.format } })
} finally {
openingNextBook = false
}
}
// ── Navigation ─────────────────────────────────────────────────────────────────
function goToPage(n: number) {
if (pageCount.value <= 0) return
const clamped = Math.max(0, Math.min(n, pageCount.value - 1))
const target = isTwoPageEffective.value ? spreadLayout.value.anchorForPage(clamped) : clamped
if (scrollMode.value !== 'paginated') {
currentPage.value = target
void scrollContinuousToPage(target)
return
}
if (target === currentPage.value) return
currentPage.value = target
}
function nextPage() {
if (pageCount.value <= 0) return
if (!canGoNext.value) {
if (shouldAutoAdvance()) void openNextBook()
return
}
if (isTwoPageEffective.value) {
goToPage(spreadLayout.value.nextAnchor(currentPage.value))
return
}
goToPage(currentPage.value + 1)
}
function prevPage() {
if (pageCount.value <= 0) return
if (isTwoPageEffective.value) {
goToPage(spreadLayout.value.prevAnchor(currentPage.value))
return
}
goToPage(currentPage.value - 1)
}
// ── Click zones (left / middle / right) ───────────────────────────────────────
function handleImageClick(e: MouseEvent) {
const x = e.clientX / window.innerWidth
if (x < 0.25) {
if (direction.value === 'rtl') nextPage()
else prevPage()
return
}
if (x > 0.75) {
if (direction.value === 'rtl') prevPage()
else nextPage()
return
}
handleMiddleTap()
}
// ── Touch / swipe ──────────────────────────────────────────────────────────────
let touchStartX = 0
function onTouchStart(e: TouchEvent) {
if (e.touches[0]) touchStartX = e.touches[0].clientX
}
function onTouchEnd(e: TouchEvent) {
const touch = e.changedTouches[0]
if (!touch) return
const dx = touch.clientX - touchStartX
if (Math.abs(dx) < 50) return
if (dx < 0) {
if (direction.value === 'rtl') prevPage()
else nextPage()
return
}
if (direction.value === 'rtl') nextPage()
else prevPage()
}
// ── Zoom / wheel navigation ────────────────────────────────────────────────────
interface ZoomFocalPoint {
clientX: number
clientY: number
}
function setZoomScale(nextScale: number, focalPoint?: ZoomFocalPoint) {
const clampedScale = Math.min(MAX_ZOOM_SCALE, Math.max(MIN_ZOOM_SCALE, nextScale))
const previousScale = zoomScale.value
if (clampedScale === previousScale) return
const viewport = scrollMode.value === 'paginated' ? paginatedViewport.value : scrollContainer.value
if (!viewport) {
zoomScale.value = clampedScale
return
}
const bounds = viewport.getBoundingClientRect()
const anchorX = focalPoint ? Math.min(viewport.clientWidth, Math.max(0, focalPoint.clientX - bounds.left)) : viewport.clientWidth / 2
const anchorY = focalPoint ? Math.min(viewport.clientHeight, Math.max(0, focalPoint.clientY - bounds.top)) : viewport.clientHeight / 2
const previousHorizontalInset = previousScale < 1 ? (viewport.clientWidth * (1 - previousScale)) / 2 : 0
const previousVerticalInset = scrollMode.value === 'paginated' && previousScale < 1 ? (viewport.clientHeight * (1 - previousScale)) / 2 : 0
const contentX = (viewport.scrollLeft + anchorX - previousHorizontalInset) / previousScale
const contentY = (viewport.scrollTop + anchorY - previousVerticalInset) / previousScale
zoomScale.value = clampedScale
void nextTick(() => {
const nextHorizontalInset = clampedScale < 1 ? (viewport.clientWidth * (1 - clampedScale)) / 2 : 0
const nextVerticalInset = scrollMode.value === 'paginated' && clampedScale < 1 ? (viewport.clientHeight * (1 - clampedScale)) / 2 : 0
viewport.scrollLeft = Math.max(0, nextHorizontalInset + contentX * clampedScale - anchorX)
viewport.scrollTop = Math.max(0, nextVerticalInset + contentY * clampedScale - anchorY)
})
}
function zoomIn() {
setZoomScale(zoomScale.value + ZOOM_STEP)
}
function zoomOut() {
setZoomScale(zoomScale.value - ZOOM_STEP)
}
function onWheel(e: WheelEvent) {
if (e.ctrlKey || e.metaKey) {
e.preventDefault()
const focalPoint = { clientX: e.clientX, clientY: e.clientY }
if (e.deltaY < 0) setZoomScale(zoomScale.value + ZOOM_STEP, focalPoint)
else if (e.deltaY > 0) setZoomScale(zoomScale.value - ZOOM_STEP, focalPoint)
return
}
if (scrollMode.value !== 'paginated') return
if (zoomScale.value > 1) return
e.preventDefault()
if (e.deltaY > 0) nextPage()
else if (e.deltaY < 0) prevPage()
}
// ── Keyboard ───────────────────────────────────────────────────────────────────
function onKeyDown(e: KeyboardEvent) {
const target = (e.composedPath?.()[0] || e.target) as HTMLElement | null
if (e.defaultPrevented || showPageText.value) return
if (showSettings.value) {
if (e.key === 'Escape') showSettings.value = false
return
}
if (target instanceof HTMLElement && target.closest('input, textarea, select, [contenteditable=true], [role=dialog]')) return
// A clicked button keeps focus, so arrows must still turn pages; only its own activation keys are left to it.
if ((e.key === ' ' || e.key === 'Enter') && target instanceof HTMLElement && target.closest('button, a')) return
const isRtl = direction.value === 'rtl'
switch (e.key) {
case 'ArrowRight':
case 'PageDown':
e.preventDefault()
if (isRtl) prevPage()
else nextPage()
break
case 'ArrowLeft':
case 'PageUp':
e.preventDefault()
if (isRtl) nextPage()
else prevPage()
break
case ' ':
e.preventDefault()
if (e.shiftKey) prevPage()
else nextPage()
break
case 'Home':
e.preventDefault()
goToPage(0)
break
case 'End':
e.preventDefault()
goToPage(pageCount.value - 1)
break
case '+':
case '=':
e.preventDefault()
zoomIn()
break
case '-':
e.preventDefault()
zoomOut()
break
case '0':
e.preventDefault()
setZoomScale(1)
break
}
}
let readerReady = false
let disposed = false
let restoringContinuousPosition = false
let stripScrollFrame: number | null = null
function syncCurrentPageFromScroll() {
stripScrollFrame = null
if (restoringContinuousPosition || !scrollContainer.value) return
const viewportStart = scrollContainer.value.scrollTop
const viewportEnd = viewportStart + scrollContainer.value.clientHeight
let visiblePage = currentPage.value
let visiblePixels = -1
for (const item of stripVirtualizer.value.getVirtualItems()) {
const overlap = Math.max(0, Math.min(item.end, viewportEnd) - Math.max(item.start, viewportStart))
if (overlap > visiblePixels) {
visiblePage = item.index
visiblePixels = overlap
}
}
currentPage.value = visiblePage
}
function onStripScroll() {
if (stripScrollFrame !== null) cancelAnimationFrame(stripScrollFrame)
stripScrollFrame = requestAnimationFrame(syncCurrentPageFromScroll)
}
async function scrollContinuousToPage(page: number, restoring = false) {
if (scrollMode.value === 'paginated' || pageCount.value <= 0) return
restoringContinuousPosition = restoring
await nextTick()
await new Promise<void>((resolve) => {
requestAnimationFrame(() => {
stripVirtualizer.value.scrollToIndex(page, { align: 'start' })
resolve()
})
})
if (restoring) {
requestAnimationFrame(() => {
restoringContinuousPosition = false
})
}
}
watch(scrollMode, async (mode) => {
stripImagesReady.value = false
if (mode === 'paginated' || !readerReady) return
await scrollContinuousToPage(currentPage.value, true)
stripImagesReady.value = true
})
watch(spreadLayout, (layout) => {
if (scrollMode.value !== 'paginated' || !isTwoPageEffective.value || pageCount.value <= 0) return
const anchored = layout.anchorForPage(currentPage.value)
if (anchored !== currentPage.value) currentPage.value = anchored
})
watch([scrollMode, isTwoPageEffective], ([mode, twoPage]) => {
if (mode !== 'paginated' || !twoPage || pageCount.value <= 0) return
const anchored = spreadLayout.value.anchorForPage(currentPage.value)
if (anchored !== currentPage.value) currentPage.value = anchored
})
// ── Slider ticks (max 20, evenly spaced) ──────────────────────────────────────
const sliderTicks = computed(() => {
const max = pageCount.value - 1
if (max <= 0) return []
const count = Math.min(20, max)
const ticks = new Set<number>()
for (let i = 0; i <= count; i++) ticks.add(Math.round((i / count) * max))
return [...ticks]
})
// ── Progress save ──────────────────────────────────────────────────────────────
let saveTimer: ReturnType<typeof setTimeout> | null = null
let progressSavePending = false
async function savePageProgress(page: number) {
progress.pageNumber.value = page + 1
progress.percentage.value = progressPercent.value
progressSavePending = false
await progress.save()
}
async function flushPendingProgress() {
if (!progressSavePending) return
if (saveTimer) {
clearTimeout(saveTimer)
saveTimer = null
}
await savePageProgress(currentPage.value)
}
watch(currentPage, (page) => {
schedulePreload(page)
onActivity()
if (!readerReady || restoringContinuousPosition) return
if (saveTimer) clearTimeout(saveTimer)
progressSavePending = true
saveTimer = setTimeout(() => {
saveTimer = null
void savePageProgress(page)
}, 2000)
})
function onResize() {
viewportWidth.value = window.innerWidth
viewportHeight.value = window.innerHeight
}
async function startTrackedReading() {
const query = { ...route.query }
delete query.mode
await router.replace({ name: 'reader', params: route.params, query })
await nextTick()
progress.pageNumber.value = progressPageIndex.value + 1
progress.percentage.value = progressPercent.value
await progress.save()
onActivity()
}
// ── Mount / unmount ────────────────────────────────────────────────────────────
async function initializeReader() {
viewportWidth.value = window.innerWidth
viewportHeight.value = window.innerHeight
loading.value = true
error.value = null
try {
await progress.load()
if (disposed) return
await bookSettings.load()
if (disposed) return
applySettings(bookSettings.effective.value as CbxReaderSettings)
await load()
if (disposed || error.value || pageCount.value <= 0) return
const saved = progress.pageNumber.value
if (saved && saved > 1) {
currentPage.value = Math.min(saved - 1, pageCount.value - 1)
} else if (progress.percentage.value > 0 && pageCount.value > 1) {
const estimated = Math.round((progress.percentage.value / 100) * pageCount.value)
currentPage.value = Math.max(0, Math.min(estimated - 1, pageCount.value - 1))
}
if (scrollMode.value === 'paginated' && isTwoPageEffective.value) {
currentPage.value = spreadLayout.value.anchorForPage(currentPage.value)
}
if (seriesFlowEnabled.value) void loadNextBook(seriesId.value, props.bookId)
readerReady = true
if (scrollMode.value !== 'paginated') {
await scrollContinuousToPage(currentPage.value, true)
stripImagesReady.value = true
}
schedulePreload(currentPage.value)
} catch {
error.value = 'reader.pageImages.loadFailed'
} finally {
loading.value = false
}
}
onMounted(() => {
window.addEventListener('resize', onResize)
window.addEventListener('keydown', onKeyDown)
void initializeReader()
})
onBeforeRouteLeave(async () => {
await flushPendingProgress()
})
// Before unmount, because useReadingSession ends the session from its own unmounted hook, which
// runs first and would otherwise miss a page turn still inside the save debounce.
onBeforeUnmount(() => {
disposed = true
for (const image of preloadCache.values()) image.onload = null
preloadCache.clear()
void flushPendingProgress()
})
onUnmounted(() => {
window.removeEventListener('resize', onResize)
window.removeEventListener('keydown', onKeyDown)
if (stripScrollFrame !== null) cancelAnimationFrame(stripScrollFrame)
})
</script>
<template>
<div class="fixed inset-0 select-none overflow-hidden" :style="{ background: bgValue }">
<span class="sr-only" role="status" aria-live="polite">{{ pageLabel }}</span>
<!-- ── Header ──────────────────────────────────────────────────────────── -->
<div
data-reader-controls
@focusin="onControlsFocus"
@focusout="onControlsBlur"
class="absolute top-0 inset-x-0 z-50 transition-all duration-300"
:class="headerVisible ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-full pointer-events-none'"
>
<div class="h-12 flex items-center gap-1 px-3 bg-background/90 backdrop-blur-md border-b border-border">
<button
class="viewer-btn focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2"
:aria-label="t('common.back')"
@click="goBack"
>
<ArrowLeft :size="16" />
</button>
<div class="flex-1 min-w-0 flex flex-col justify-center px-2">
<span v-if="bookTitle" class="text-sm font-serif text-foreground truncate leading-tight">{{ bookTitle }}</span>
<span class="text-xs text-muted-foreground tabular-nums">{{ pageLabel }}</span>
</div>
<div v-if="props.peekMode" class="flex h-7 items-center gap-1 rounded-md border border-primary/30 bg-primary/10 px-1.5 text-primary">
<span class="hidden text-[11px] font-medium sm:inline">{{ t('reader.peek.badge') }}</span>
<button
class="h-5 rounded-sm bg-primary px-1.5 text-[10px] font-semibold text-primary-foreground hover:bg-primary/90"
@click="startTrackedReading"
>
{{ t('reader.peek.startReading') }}
</button>
</div>
<div class="hidden items-center md:flex" :aria-label="t('reader.cbz.zoomControls')" role="group">
<Tooltip>
<TooltipTrigger as-child>
<button
class="viewer-btn focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2"
:disabled="!canZoomOut"
:aria-label="t('reader.cbz.zoomOut')"
@click="zoomOut"
>
<Minus :size="15" />
</button>
</TooltipTrigger>
<TooltipContent>{{ t('reader.cbz.zoomOut') }}</TooltipContent>
</Tooltip>
<span class="min-w-12 text-center text-xs tabular-nums text-muted-foreground" aria-live="polite">{{ zoomPercent }}%</span>
<Tooltip>
<TooltipTrigger as-child>
<button
class="viewer-btn focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2"
:disabled="!canZoomIn"
:aria-label="t('reader.cbz.zoomIn')"
@click="zoomIn"
>
<Plus :size="15" />
</button>
</TooltipTrigger>
<TooltipContent>{{ t('reader.cbz.zoomIn') }}</TooltipContent>
</Tooltip>
</div>
<Tooltip>
<TooltipTrigger as-child>
<button
class="viewer-btn focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2"
:aria-label="fullscreenLabel"
@click="toggleFullscreen"
>
<Minimize v-if="isFullscreen" :size="15" />
<Maximize v-else :size="15" />
</button>
</TooltipTrigger>
<TooltipContent>{{ fullscreenLabel }}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button
class="viewer-btn focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2"
:class="isPinned ? '!bg-muted !text-primary' : ''"
:aria-label="t(isPinned ? 'reader.pageImages.unpin' : 'reader.pageImages.pin')"
@click="togglePinned"
>
<PinOff v-if="isPinned" :size="15" />
<Pin v-else :size="15" />
</button>
</TooltipTrigger>
<TooltipContent>{{ t(isPinned ? 'reader.pageImages.unpin' : 'reader.pageImages.pin') }}</TooltipContent>
</Tooltip>
<button
v-if="props.source === 'djvu'"
type="button"
class="viewer-btn focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2"
:aria-label="t('reader.djvu.pageText')"
:title="t('reader.djvu.pageText')"
@click="openPageText"
>
<FileText :size="16" />
</button>
<template v-if="isCompact">
<button
class="viewer-btn focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2"
:class="showSettings ? '!bg-muted !text-foreground' : ''"
:title="t('reader.settings.title')"
:aria-label="t('reader.settings.ariaLabel')"
@click="toggleSettings"
>
<Settings :size="15" />
</button>
<ReaderSettingsSheet :open="showSettings" @update:open="onSettingsOpenChange">
<CbzSettingsPanel
:settings="panelSettings"
:can-reset="bookSettings.isCustomized.value"
:is-spread-active="isTwoPageEffective"
@update="applyPanelUpdate"
@reset="resetBookViewSettings"
/>
</ReaderSettingsSheet>
</template>
<Popover v-else :open="showSettings" @update:open="onSettingsOpenChange">
<PopoverTrigger as-child>
<button
class="viewer-btn focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2"
:class="showSettings ? '!bg-muted !text-foreground' : ''"
:title="t('reader.settings.title')"
:aria-label="t('reader.settings.ariaLabel')"
>
<Settings :size="15" />
</button>
</PopoverTrigger>
<PopoverContent
align="end"
side="bottom"
:side-offset="10"
class="flex max-h-[min(80vh,40rem)] w-[21rem] max-w-[calc(100vw-1rem)] flex-col overflow-hidden rounded-xl border-border bg-card p-0 shadow-2xl"
>
<CbzSettingsPanel
:settings="panelSettings"
:can-reset="bookSettings.isCustomized.value"
:is-spread-active="isTwoPageEffective"
@update="applyPanelUpdate"
@reset="resetBookViewSettings"
/>
</PopoverContent>
</Popover>
</div>
</div>
<!-- ── Paginated view ──────────────────────────────────────────────────── -->
<div
v-if="scrollMode === 'paginated'"
ref="paginatedViewport"
data-testid="cbz-paginated-viewport"
class="absolute inset-0 overflow-auto"
@click="handleImageClick"
@touchstart.passive="onTouchStart"
@touchend.passive="onTouchEnd"
@wheel="onWheel"
>
<div v-if="!currentImageLoaded && !error" class="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
<div class="w-8 h-8 rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground animate-spin" />
</div>
<div class="flex min-h-full min-w-full items-center justify-center" :style="paginatedStageStyle">
<div
data-testid="cbz-paginated-pages"
class="flex shrink-0 items-center-safe justify-center-safe px-1 origin-center"
:style="[paginatedContentStyle, renderSpread ? spreadContainerStyle : undefined]"
>
<template v-if="renderSpread">
<div data-spread-side="left" class="flex h-full min-w-0 flex-1 items-center-safe justify-end">
<img
v-if="renderLeftPage !== null"
:src="pageUrl(renderLeftPage)"
:key="`${renderLeftPage}:${imageGeneration}`"
:data-page="renderLeftPage"
:class="[imgFitClass, 'pointer-events-none transition-opacity duration-150', currentImageLoaded ? 'opacity-100' : 'opacity-0']"
:style="{ maxWidth: '100%', maxHeight: '100%' }"
:alt="t('reader.pageImages.pageAlt', { page: renderLeftPage + 1 })"
draggable="false"
@load="onPaginatedLoad"
@error="onImageError"
/>
<div v-else aria-hidden="true" class="h-[92%] w-[92%] rounded-sm border border-border/60 bg-background/30" />
</div>
<div data-spread-side="right" class="flex h-full min-w-0 flex-1 items-center-safe justify-start">
<img
v-if="renderRightPage !== null"
:src="pageUrl(renderRightPage)"
:key="`${renderRightPage}:${imageGeneration}`"
:data-page="renderRightPage"
:class="[imgFitClass, 'pointer-events-none transition-opacity duration-150', currentImageLoaded ? 'opacity-100' : 'opacity-0']"
:style="{ maxWidth: '100%', maxHeight: '100%' }"
:alt="t('reader.pageImages.pageAlt', { page: renderRightPage + 1 })"
draggable="false"
@load="onPaginatedLoad"
@error="onImageError"
/>
<div v-else aria-hidden="true" class="h-[92%] w-[92%] rounded-sm border border-border/60 bg-background/30" />
</div>
</template>
<img
v-else-if="renderSinglePage !== null"
:src="pageUrl(renderSinglePage)"
:key="`${renderSinglePage}:${imageGeneration}`"
:data-page="renderSinglePage"
:class="[imgFitClass, 'pointer-events-none transition-opacity duration-150', currentImageLoaded ? 'opacity-100' : 'opacity-0']"
:alt="t('reader.pageImages.pageAlt', { page: renderSinglePage + 1 })"
draggable="false"
@load="onPaginatedLoad"
@error="onImageError"
/>
</div>
</div>
</div>
<!-- ── Infinite / long-strip view ─────────────────────────────────────── -->
<div v-else ref="scrollContainer" class="absolute inset-0 overflow-auto" @scroll.passive="onStripScroll" @wheel="onWheel">
<div class="relative w-full" :style="{ height: `${virtualStripSize}px` }">
<div
v-for="page in renderedStripPages"
:key="String(page.key)"
:ref="measureStripPage"
:data-index="page.index"
:data-page="page.index"
class="absolute start-0 top-0"
:class="[stripFrameClass, scrollMode === 'long-strip' ? '' : 'px-2']"
:style="stripFrameStyle(page.start)"
>
<img
:src="pageUrl(page.index)"
:key="`${page.index}:${imageGeneration}`"
:class="stripImageClass"
:style="stripImageStyle(page.index)"
:alt="t('reader.pageImages.pageAlt', { page: page.index + 1 })"
decoding="async"
draggable="false"
@load="onStripImageLoad(page.index, $event)"
@error="onImageError"
/>
</div>
</div>
<div v-if="showNextBookCard && nextBook" class="flex justify-center px-3 py-10">
<NextIssueCard :next-book="nextBook" @open="openNextBook" />
</div>
</div>
<!-- ── End of book: next in series ─────────────────────────────────────── -->
<div v-if="showNextBookOverlay && nextBook" class="absolute inset-x-0 bottom-16 z-40 flex justify-center px-3 sm:bottom-20">
<NextIssueCard :next-book="nextBook" :auto-advance="canAutoAdvance" @open="openNextBook" />
</div>
<!-- ── Footer ──────────────────────────────────────────────────────────── -->
<div
data-reader-controls
@focusin="onControlsFocus"
@focusout="onControlsBlur"
class="absolute bottom-0 inset-x-0 z-50 transition-all duration-300"
:class="footerVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-full pointer-events-none'"
>
<div class="h-12 sm:h-14 flex items-center gap-1.5 px-2 sm:gap-3 sm:px-4 bg-background/90 backdrop-blur-md border-t border-border">
<div class="hidden sm:block">
<Tooltip>
<TooltipTrigger as-child>
<button
class="viewer-btn focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2"
:aria-label="t('reader.cbz.firstPage')"
@click="firstPage"
>
<ChevronsLeft :size="16" />
</button>
</TooltipTrigger>
<TooltipContent>{{ t('reader.cbz.firstPage') }}</TooltipContent>
</Tooltip>
</div>
<button
class="viewer-btn focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2"
:disabled="!canGoPrev"
:aria-label="t('reader.pageImages.previousPage')"
@click="prevPage"
>
<ChevronLeft :size="16" />
</button>
<div class="relative flex-1 min-w-0 flex items-center h-6">
<input
type="range"
:min="0"
:max="Math.max(0, pageCount - 1)"
:value="currentPage"
list="cbz-ticks"
class="w-full h-1 rounded-full cursor-pointer"
:style="{
accentColor: 'var(--primary)',
background: `linear-gradient(to right, var(--primary) ${sliderFillPercent}%, var(--border) ${sliderFillPercent}%)`,
}"
:aria-label="t('reader.pageImages.goToPage')"
@input="onPageSlider"
/>
<datalist id="cbz-ticks">
<option v-for="t in sliderTicks" :key="t" :value="t" />
</datalist>
</div>
<button
class="viewer-btn focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2"
:disabled="!canGoNext"
:aria-label="t('reader.pageImages.nextPage')"
@click="nextPage"
>
<ChevronRight :size="16" />
</button>
<div class="hidden sm:block">
<Tooltip>
<TooltipTrigger as-child>
<button
class="viewer-btn focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2"
:aria-label="t('reader.cbz.lastPage')"
@click="lastPage"
>
<ChevronsRight :size="16" />
</button>
</TooltipTrigger>
<TooltipContent>{{ t('reader.cbz.lastPage') }}</TooltipContent>
</Tooltip>
</div>
</div>
</div>
<!-- Hover zones to reveal header / footer -->
<div class="absolute top-0 inset-x-0 h-16 z-40 pointer-events-auto" @mouseenter="showHeader" />
<div class="absolute bottom-0 inset-x-0 h-16 z-40 pointer-events-auto" @mouseenter="showFooter" />
<!-- ── Loading / error overlays ─────────────────────────────────────────── -->
<div v-if="loading" class="absolute inset-0 flex items-center justify-center z-50 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">{{ t('common.loading') }}</p>
</div>
</div>
<div v-if="error" class="absolute inset-0 flex items-center justify-center z-50 p-8 text-center bg-background">
<div class="flex flex-col items-center gap-4" role="alert">
<p class="text-sm text-foreground">{{ t(error) }}</p>
<button
type="button"
class="viewer-btn focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2"
@click="initializeReader"
>
{{ t('common.retry') }}
</button>
<button type="button" class="viewer-btn focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2" @click="goBack">
{{ t('common.back') }}
</button>
</div>
</div>
<div v-if="imageError && !error && !loading" class="absolute inset-0 z-40 flex items-center justify-center bg-background/95 p-8">
<div class="flex flex-col items-center gap-4 text-center" role="alert">
<p class="text-sm text-foreground">{{ t('reader.pageImages.pageFailed') }}</p>
<button
type="button"
class="viewer-btn focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2"
@click="retryImages"
>
{{ t('common.retry') }}
</button>
<button type="button" class="viewer-btn focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2" @click="goBack">
{{ t('common.back') }}
</button>
</div>
</div>
<DjvuTextSheet
v-if="props.source === 'djvu'"
:open="showPageText"
:file-id="props.fileId"
:page-index="currentPage"
@update:open="onTextOpenChange"
/>
<!-- Progress bar -->
<div v-if="!loading && !error && pageCount > 0" class="absolute bottom-0 left-0 right-0 h-0.5 bg-border z-30">
<div class="h-full bg-primary/60 transition-all duration-300" :style="{ width: `${progressPercent}%` }" />
</div>
</div>
</template>