mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
97cd03325f
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.
1263 lines
49 KiB
Vue
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>
|