mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(reader): add CBZ/CBR comic reader with backend page streaming
This commit is contained in:
@@ -18,6 +18,7 @@ import SelectionPopup from './components/SelectionPopup.vue'
|
|||||||
import ReaderSearchPanel from './components/ReaderSearchPanel.vue'
|
import ReaderSearchPanel from './components/ReaderSearchPanel.vue'
|
||||||
import NoteDialog from './components/NoteDialog.vue'
|
import NoteDialog from './components/NoteDialog.vue'
|
||||||
import PdfReaderView from './components/PdfReaderView.vue'
|
import PdfReaderView from './components/PdfReaderView.vue'
|
||||||
|
import CbzReaderView from './components/CbzReaderView.vue'
|
||||||
import type { ReaderState } from './composables/useReaderState'
|
import type { ReaderState } from './composables/useReaderState'
|
||||||
import type { FoliateRenderer } from './composables/useFoliate'
|
import type { FoliateRenderer } from './composables/useFoliate'
|
||||||
|
|
||||||
@@ -221,6 +222,7 @@ function navigateSearch(cfiTarget: string) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<PdfReaderView v-if="fileFormat === 'pdf'" :bookId="bookId" :fileId="fileId" />
|
<PdfReaderView v-if="fileFormat === 'pdf'" :bookId="bookId" :fileId="fileId" />
|
||||||
|
<CbzReaderView v-else-if="fileFormat === 'cbz' || fileFormat === 'cbr'" :bookId="bookId" :fileId="fileId" />
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
class="fixed inset-0 overflow-hidden"
|
class="fixed inset-0 overflow-hidden"
|
||||||
|
|||||||
@@ -0,0 +1,346 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { ArrowLeft, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Settings } from 'lucide-vue-next'
|
||||||
|
import { useVisibility } from '../composables/useVisibility'
|
||||||
|
import { useReaderProgress } from '../composables/useReaderProgress'
|
||||||
|
import { useCbz } from '../composables/useCbz'
|
||||||
|
import { useCbzSettings } from '../composables/useCbzSettings'
|
||||||
|
import CbzSettingsPanel from './CbzSettingsPanel.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{ bookId: number; fileId: number }>()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const progress = useReaderProgress(props.bookId, props.fileId)
|
||||||
|
const { headerVisible, footerVisible, handleMiddleTap, showHeader, showFooter } = useVisibility()
|
||||||
|
const { pageCount, bookTitle, loading, error, pageUrl, load } = useCbz(props.fileId, props.bookId)
|
||||||
|
const { fitMode, viewMode, scrollMode, direction, bgColor, bgValue, isTwoPage, imgFitClass } = useCbzSettings()
|
||||||
|
|
||||||
|
const currentPage = ref(0)
|
||||||
|
const currentImageLoaded = ref(false)
|
||||||
|
const showSettings = ref(false)
|
||||||
|
const scrollContainer = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
// ── Derived ────────────────────────────────────────────────────────────────────
|
||||||
|
// Pages shown in paginated mode (1 or 2), in display order for RTL.
|
||||||
|
const visiblePages = computed(() => {
|
||||||
|
if (!isTwoPage.value) return [currentPage.value]
|
||||||
|
const a = currentPage.value
|
||||||
|
const b = a + 1
|
||||||
|
if (direction.value === 'ltr') return b < pageCount.value ? [a, b] : [a]
|
||||||
|
return b < pageCount.value ? [b, a] : [a] // RTL: second page on left
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Preloading ─────────────────────────────────────────────────────────────────
|
||||||
|
const preloadCache = new Map<number, HTMLImageElement>()
|
||||||
|
|
||||||
|
function preload(n: number) {
|
||||||
|
if (n < 0 || n >= pageCount.value || preloadCache.has(n)) return
|
||||||
|
const img = new Image()
|
||||||
|
img.src = pageUrl(n)
|
||||||
|
preloadCache.set(n, img)
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedulePreload(center: number) {
|
||||||
|
for (let i = center - 2; i <= center + 4; i++) preload(i)
|
||||||
|
for (const [k] of preloadCache) {
|
||||||
|
if (Math.abs(k - center) > 8) preloadCache.delete(k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Navigation ─────────────────────────────────────────────────────────────────
|
||||||
|
function goToPage(n: number) {
|
||||||
|
const target = Math.max(0, Math.min(n, pageCount.value - 1))
|
||||||
|
if (target === currentPage.value) return
|
||||||
|
currentPage.value = target
|
||||||
|
currentImageLoaded.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextPage() {
|
||||||
|
goToPage(currentPage.value + (isTwoPage.value ? 2 : 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
function prevPage() {
|
||||||
|
goToPage(currentPage.value - (isTwoPage.value ? 2 : 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Click zones (left / middle / right) ───────────────────────────────────────
|
||||||
|
function handleImageClick(e: MouseEvent) {
|
||||||
|
if (showSettings.value) {
|
||||||
|
showSettings.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const x = e.clientX / window.innerWidth
|
||||||
|
if (x < 0.25) direction.value === 'rtl' ? nextPage() : prevPage()
|
||||||
|
else if (x > 0.75) direction.value === 'rtl' ? prevPage() : nextPage()
|
||||||
|
else handleMiddleTap()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Touch / swipe ──────────────────────────────────────────────────────────────
|
||||||
|
let touchStartX = 0
|
||||||
|
|
||||||
|
function onTouchStart(e: TouchEvent) {
|
||||||
|
touchStartX = e.touches[0].clientX
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchEnd(e: TouchEvent) {
|
||||||
|
const dx = e.changedTouches[0].clientX - touchStartX
|
||||||
|
if (Math.abs(dx) < 50) return
|
||||||
|
if (dx < 0) direction.value === 'rtl' ? prevPage() : nextPage()
|
||||||
|
else direction.value === 'rtl' ? nextPage() : prevPage()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Wheel (paginated mode only) ────────────────────────────────────────────────
|
||||||
|
// Note: this handler is only attached on the paginated view div (v-if), so it is
|
||||||
|
// never active during infinite/long-strip modes — no need to guard with scrollMode check.
|
||||||
|
function onWheel(e: WheelEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (e.deltaY > 0) nextPage()
|
||||||
|
else if (e.deltaY < 0) prevPage()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Keyboard ───────────────────────────────────────────────────────────────────
|
||||||
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
|
if ((e.target as HTMLElement).tagName === 'INPUT') return
|
||||||
|
const isRtl = direction.value === 'rtl'
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowRight':
|
||||||
|
case 'PageDown':
|
||||||
|
e.preventDefault()
|
||||||
|
isRtl ? prevPage() : nextPage()
|
||||||
|
break
|
||||||
|
case 'ArrowLeft':
|
||||||
|
case 'PageUp':
|
||||||
|
e.preventDefault()
|
||||||
|
isRtl ? nextPage() : prevPage()
|
||||||
|
break
|
||||||
|
case ' ':
|
||||||
|
e.preventDefault()
|
||||||
|
e.shiftKey ? prevPage() : nextPage()
|
||||||
|
break
|
||||||
|
case 'Home':
|
||||||
|
goToPage(0)
|
||||||
|
break
|
||||||
|
case 'End':
|
||||||
|
goToPage(pageCount.value - 1)
|
||||||
|
break
|
||||||
|
case 'Escape':
|
||||||
|
showSettings.value = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Infinite scroll page tracking ─────────────────────────────────────────────
|
||||||
|
let io: IntersectionObserver | null = null
|
||||||
|
|
||||||
|
function setupScrollObserver() {
|
||||||
|
io?.disconnect()
|
||||||
|
if (!scrollContainer.value) return
|
||||||
|
io = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
currentPage.value = parseInt((entry.target as HTMLElement).dataset.page ?? '0')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ root: scrollContainer.value, threshold: 0.5 },
|
||||||
|
)
|
||||||
|
nextTick(() => {
|
||||||
|
scrollContainer.value?.querySelectorAll('[data-page]').forEach((el) => io!.observe(el))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(scrollMode, async (mode) => {
|
||||||
|
if (mode !== 'paginated') {
|
||||||
|
await nextTick()
|
||||||
|
setupScrollObserver()
|
||||||
|
scrollContainer.value?.querySelector(`[data-page="${currentPage.value}"]`)?.scrollIntoView()
|
||||||
|
} else {
|
||||||
|
io?.disconnect()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 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
|
||||||
|
|
||||||
|
watch(currentPage, (page) => {
|
||||||
|
schedulePreload(page)
|
||||||
|
if (saveTimer) clearTimeout(saveTimer)
|
||||||
|
saveTimer = setTimeout(() => {
|
||||||
|
progress.pageNumber.value = page + 1
|
||||||
|
progress.percentage.value = pageCount.value ? ((page + 1) / pageCount.value) * 100 : 0
|
||||||
|
progress.save()
|
||||||
|
}, 2000)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Mount / unmount ────────────────────────────────────────────────────────────
|
||||||
|
onMounted(async () => {
|
||||||
|
window.addEventListener('keydown', onKeyDown)
|
||||||
|
await progress.load()
|
||||||
|
await load()
|
||||||
|
const saved = progress.pageNumber.value
|
||||||
|
if (saved && saved > 1) currentPage.value = Math.min(saved - 1, pageCount.value - 1)
|
||||||
|
schedulePreload(currentPage.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('keydown', onKeyDown)
|
||||||
|
io?.disconnect()
|
||||||
|
if (saveTimer) clearTimeout(saveTimer)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="fixed inset-0 select-none overflow-hidden" :style="{ background: bgValue }">
|
||||||
|
<!-- ── Header ──────────────────────────────────────────────────────────── -->
|
||||||
|
<div
|
||||||
|
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-2 px-3" style="background: rgba(0, 0, 0, 0.85); backdrop-filter: blur(8px)">
|
||||||
|
<button class="cbz-btn" @click="router.back()"><ArrowLeft :size="16" /></button>
|
||||||
|
<div class="flex-1 min-w-0 flex flex-col justify-center">
|
||||||
|
<span v-if="bookTitle" class="text-sm text-white/90 truncate leading-tight">{{ bookTitle }}</span>
|
||||||
|
<span class="text-xs text-white/40 tabular-nums">{{ currentPage + 1 }} / {{ pageCount }}</span>
|
||||||
|
</div>
|
||||||
|
<button class="cbz-btn" :class="showSettings ? 'bg-white/20 text-white' : ''" @click="showSettings = !showSettings">
|
||||||
|
<Settings :size="15" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Paginated view ──────────────────────────────────────────────────── -->
|
||||||
|
<div
|
||||||
|
v-if="scrollMode === 'paginated'"
|
||||||
|
class="absolute inset-0 flex items-center justify-center overflow-hidden"
|
||||||
|
@click="handleImageClick"
|
||||||
|
@touchstart.passive="onTouchStart"
|
||||||
|
@touchend.passive="onTouchEnd"
|
||||||
|
@wheel.prevent="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-white/20 border-t-white/70 animate-spin" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-center h-full w-full"
|
||||||
|
:class="isTwoPage ? 'gap-0.5' : ''"
|
||||||
|
:style="isTwoPage && direction === 'rtl' ? { flexDirection: 'row-reverse' } : {}"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-for="pg in visiblePages"
|
||||||
|
:key="pg"
|
||||||
|
:src="pageUrl(pg)"
|
||||||
|
:class="[imgFitClass, 'pointer-events-none transition-opacity duration-150', currentImageLoaded ? 'opacity-100' : 'opacity-0']"
|
||||||
|
:style="isTwoPage ? { maxWidth: '50%', maxHeight: '100%' } : {}"
|
||||||
|
draggable="false"
|
||||||
|
@load="currentImageLoaded = true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Infinite / long-strip view ─────────────────────────────────────── -->
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
ref="scrollContainer"
|
||||||
|
class="absolute inset-0 overflow-y-auto overflow-x-hidden"
|
||||||
|
:class="scrollMode === 'long-strip' ? '' : 'flex flex-col items-center'"
|
||||||
|
>
|
||||||
|
<div :class="scrollMode === 'long-strip' ? '' : 'flex flex-col items-center w-full gap-2 py-4 px-2'">
|
||||||
|
<img
|
||||||
|
v-for="i in pageCount"
|
||||||
|
:key="i - 1"
|
||||||
|
:data-page="i - 1"
|
||||||
|
:src="pageUrl(i - 1)"
|
||||||
|
:class="scrollMode === 'long-strip' ? 'w-full block' : 'max-w-full'"
|
||||||
|
loading="lazy"
|
||||||
|
draggable="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Footer ──────────────────────────────────────────────────────────── -->
|
||||||
|
<div
|
||||||
|
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-16 flex items-center gap-3 px-4" style="background: rgba(0, 0, 0, 0.85); backdrop-filter: blur(8px)">
|
||||||
|
<button class="cbz-btn" title="First page" @click="goToPage(0)"><ChevronsLeft :size="16" /></button>
|
||||||
|
<button class="cbz-btn" :disabled="currentPage === 0" @click="prevPage"><ChevronLeft :size="16" /></button>
|
||||||
|
|
||||||
|
<div class="flex-1 flex flex-col justify-center gap-0.5">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
:min="0"
|
||||||
|
:max="Math.max(0, pageCount - 1)"
|
||||||
|
:value="currentPage"
|
||||||
|
list="cbz-ticks"
|
||||||
|
class="flex-1 w-full accent-blue-400 cursor-pointer"
|
||||||
|
@input="goToPage(Number(($event.target as HTMLInputElement).value))"
|
||||||
|
/>
|
||||||
|
<datalist id="cbz-ticks">
|
||||||
|
<option v-for="t in sliderTicks" :key="t" :value="t" />
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="cbz-btn" :disabled="currentPage >= pageCount - 1" @click="nextPage"><ChevronRight :size="16" /></button>
|
||||||
|
<button class="cbz-btn" title="Last page" @click="goToPage(pageCount - 1)"><ChevronsRight :size="16" /></button>
|
||||||
|
</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">
|
||||||
|
<div class="flex flex-col items-center gap-3">
|
||||||
|
<div class="w-8 h-8 rounded-full border-2 border-white/20 border-t-white/80 animate-spin" />
|
||||||
|
<p class="text-sm text-white/50">Loading…</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" class="absolute inset-0 flex items-center justify-center z-50 p-8 text-center">
|
||||||
|
<p class="text-sm text-white/70">{{ error }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Settings panel ────────────────────────────────────────────────────── -->
|
||||||
|
<CbzSettingsPanel
|
||||||
|
v-if="showSettings"
|
||||||
|
:fitMode="fitMode"
|
||||||
|
:viewMode="viewMode"
|
||||||
|
:scrollMode="scrollMode"
|
||||||
|
:direction="direction"
|
||||||
|
:bgColor="bgColor"
|
||||||
|
@close="showSettings = false"
|
||||||
|
@update:fitMode="fitMode = $event"
|
||||||
|
@update:viewMode="viewMode = $event"
|
||||||
|
@update:scrollMode="scrollMode = $event"
|
||||||
|
@update:direction="direction = $event"
|
||||||
|
@update:bgColor="bgColor = $event"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Progress bar -->
|
||||||
|
<div v-if="!loading && !error && pageCount > 0" class="absolute bottom-0 left-0 right-0 h-0.5 bg-white/10 z-30">
|
||||||
|
<div class="h-full bg-blue-400/60 transition-all duration-300" :style="{ width: `${((currentPage + 1) / pageCount) * 100}%` }" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@reference "tailwindcss";
|
||||||
|
.cbz-btn {
|
||||||
|
@apply flex items-center justify-center w-8 h-8 rounded text-white/60 hover:text-white hover:bg-white/10 transition-colors shrink-0 disabled:opacity-30 disabled:cursor-not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { X } from 'lucide-vue-next'
|
||||||
|
import type { BgColor, Direction, FitMode, ScrollMode, ViewMode } from '../composables/useCbzSettings'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
fitMode: FitMode
|
||||||
|
viewMode: ViewMode
|
||||||
|
scrollMode: ScrollMode
|
||||||
|
direction: Direction
|
||||||
|
bgColor: BgColor
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
'update:fitMode': [v: FitMode]
|
||||||
|
'update:viewMode': [v: ViewMode]
|
||||||
|
'update:scrollMode': [v: ScrollMode]
|
||||||
|
'update:direction': [v: Direction]
|
||||||
|
'update:bgColor': [v: BgColor]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const FIT_OPTIONS: { value: FitMode; label: string }[] = [
|
||||||
|
{ value: 'fit-page', label: 'Page' },
|
||||||
|
{ value: 'fit-width', label: 'Width' },
|
||||||
|
{ value: 'fit-height', label: 'Height' },
|
||||||
|
{ value: 'actual', label: 'Actual' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const SCROLL_OPTIONS: { value: ScrollMode; label: string }[] = [
|
||||||
|
{ value: 'paginated', label: 'Paginated' },
|
||||||
|
{ value: 'infinite', label: 'Infinite Scroll' },
|
||||||
|
{ value: 'long-strip', label: 'Long Strip' },
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="absolute inset-y-0 right-0 z-50 w-64 overflow-y-auto"
|
||||||
|
style="background: rgba(18, 18, 18, 0.97); border-left: 1px solid rgba(255, 255, 255, 0.08)"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between p-4 border-b border-white/10">
|
||||||
|
<span class="text-sm font-medium text-white">Settings</span>
|
||||||
|
<button class="cbz-btn w-7 h-7" @click="emit('close')"><X :size="14" /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4 flex flex-col gap-5">
|
||||||
|
<!-- Fit Mode -->
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-white/40 mb-2 uppercase tracking-wide">Fit</p>
|
||||||
|
<div class="grid grid-cols-2 gap-1">
|
||||||
|
<button
|
||||||
|
v-for="opt in FIT_OPTIONS"
|
||||||
|
:key="opt.value"
|
||||||
|
class="setting-btn"
|
||||||
|
:class="fitMode === opt.value ? 'active' : ''"
|
||||||
|
@click="emit('update:fitMode', opt.value)"
|
||||||
|
>
|
||||||
|
{{ opt.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- View Mode -->
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-white/40 mb-2 uppercase tracking-wide">View</p>
|
||||||
|
<div class="grid grid-cols-2 gap-1">
|
||||||
|
<button class="setting-btn" :class="viewMode === 'single' ? 'active' : ''" @click="emit('update:viewMode', 'single')">Single</button>
|
||||||
|
<button class="setting-btn" :class="viewMode === 'two-page' ? 'active' : ''" @click="emit('update:viewMode', 'two-page')">Two-Page</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scroll Mode -->
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-white/40 mb-2 uppercase tracking-wide">Scroll</p>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<button
|
||||||
|
v-for="opt in SCROLL_OPTIONS"
|
||||||
|
:key="opt.value"
|
||||||
|
class="setting-btn"
|
||||||
|
:class="scrollMode === opt.value ? 'active' : ''"
|
||||||
|
@click="emit('update:scrollMode', opt.value)"
|
||||||
|
>
|
||||||
|
{{ opt.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Direction -->
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-white/40 mb-2 uppercase tracking-wide">Direction</p>
|
||||||
|
<div class="grid grid-cols-2 gap-1">
|
||||||
|
<button class="setting-btn" :class="direction === 'ltr' ? 'active' : ''" @click="emit('update:direction', 'ltr')">LTR →</button>
|
||||||
|
<button class="setting-btn" :class="direction === 'rtl' ? 'active' : ''" @click="emit('update:direction', 'rtl')">← RTL</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Background -->
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-white/40 mb-2 uppercase tracking-wide">Background</p>
|
||||||
|
<div class="grid grid-cols-3 gap-1">
|
||||||
|
<button class="setting-btn" :class="bgColor === 'black' ? 'active' : ''" @click="emit('update:bgColor', 'black')">Black</button>
|
||||||
|
<button class="setting-btn" :class="bgColor === 'gray' ? 'active' : ''" @click="emit('update:bgColor', 'gray')">Gray</button>
|
||||||
|
<button class="setting-btn" :class="bgColor === 'white' ? 'active' : ''" @click="emit('update:bgColor', 'white')">White</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@reference "tailwindcss";
|
||||||
|
.cbz-btn {
|
||||||
|
@apply flex items-center justify-center w-8 h-8 rounded text-white/60 hover:text-white hover:bg-white/10 transition-colors shrink-0;
|
||||||
|
}
|
||||||
|
.setting-btn {
|
||||||
|
@apply px-2 py-1.5 rounded text-xs text-white/60 hover:text-white hover:bg-white/10 transition-colors text-center;
|
||||||
|
}
|
||||||
|
.setting-btn.active {
|
||||||
|
@apply bg-blue-500/20 text-blue-400 hover:bg-blue-500/30;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -99,7 +99,10 @@ const findQuery = ref('')
|
|||||||
const findMatches = ref(0)
|
const findMatches = ref(0)
|
||||||
|
|
||||||
watch(findQuery, (q) => {
|
watch(findQuery, (q) => {
|
||||||
if (!q.trim()) { findMatches.value = 0; return }
|
if (!q.trim()) {
|
||||||
|
findMatches.value = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
let count = 0
|
let count = 0
|
||||||
scrollRef.value?.querySelectorAll('[data-text-layer] span').forEach((el) => {
|
scrollRef.value?.querySelectorAll('[data-text-layer] span').forEach((el) => {
|
||||||
if (el.textContent?.toLowerCase().includes(q.toLowerCase())) count++
|
if (el.textContent?.toLowerCase().includes(q.toLowerCase())) count++
|
||||||
@@ -176,7 +179,9 @@ function onPageCommit() {
|
|||||||
// ── Mount / unmount ───────────────────────────────────────────────────────────
|
// ── Mount / unmount ───────────────────────────────────────────────────────────
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
window.addEventListener('keydown', onKeyDown)
|
window.addEventListener('keydown', onKeyDown)
|
||||||
const onFsChange = () => { isFullscreen.value = !!document.fullscreenElement }
|
const onFsChange = () => {
|
||||||
|
isFullscreen.value = !!document.fullscreenElement
|
||||||
|
}
|
||||||
document.addEventListener('fullscreenchange', onFsChange)
|
document.addEventListener('fullscreenchange', onFsChange)
|
||||||
onUnmounted(() => document.removeEventListener('fullscreenchange', onFsChange))
|
onUnmounted(() => document.removeEventListener('fullscreenchange', onFsChange))
|
||||||
|
|
||||||
@@ -204,7 +209,10 @@ onMounted(async () => {
|
|||||||
ro.observe(scrollRef.value)
|
ro.observe(scrollRef.value)
|
||||||
setupIO(scrollRef.value)
|
setupIO(scrollRef.value)
|
||||||
}
|
}
|
||||||
onUnmounted(() => { ro?.disconnect(); ro = null })
|
onUnmounted(() => {
|
||||||
|
ro?.disconnect()
|
||||||
|
ro = null
|
||||||
|
})
|
||||||
|
|
||||||
if (progress.pageNumber.value && progress.pageNumber.value > 1) {
|
if (progress.pageNumber.value && progress.pageNumber.value > 1) {
|
||||||
await nextTick()
|
await nextTick()
|
||||||
@@ -367,13 +375,21 @@ const progressPct = computed(() => (totalPages.value ? Math.round((currentPage.v
|
|||||||
>
|
>
|
||||||
<canvas
|
<canvas
|
||||||
class="block"
|
class="block"
|
||||||
:ref="(el) => { if (el) canvasMap.set(pageNum, el as HTMLCanvasElement) }"
|
:ref="
|
||||||
|
(el) => {
|
||||||
|
if (el) canvasMap.set(pageNum, el as HTMLCanvasElement)
|
||||||
|
}
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
data-text-layer
|
data-text-layer
|
||||||
class="absolute inset-0 overflow-hidden"
|
class="absolute inset-0 overflow-hidden"
|
||||||
style="pointer-events: none"
|
style="pointer-events: none"
|
||||||
:ref="(el) => { if (el) textLayerMap.set(pageNum, el as HTMLElement) }"
|
:ref="
|
||||||
|
(el) => {
|
||||||
|
if (el) textLayerMap.set(pageNum, el as HTMLElement)
|
||||||
|
}
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -391,7 +407,14 @@ const progressPct = computed(() => (totalPages.value ? Math.round((currentPage.v
|
|||||||
<div
|
<div
|
||||||
v-if="showZoomMenu"
|
v-if="showZoomMenu"
|
||||||
class="fixed w-40 rounded-lg shadow-2xl py-1 overflow-hidden"
|
class="fixed w-40 rounded-lg shadow-2xl py-1 overflow-hidden"
|
||||||
:style="{ zIndex: 9999, background: 'rgba(35,38,41,0.98)', border: '1px solid rgba(255,255,255,0.1)', transform: 'translateX(-50%)', top: zoomMenuStyle.top, left: zoomMenuStyle.left }"
|
:style="{
|
||||||
|
zIndex: 9999,
|
||||||
|
background: 'rgba(35,38,41,0.98)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.1)',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
top: zoomMenuStyle.top,
|
||||||
|
left: zoomMenuStyle.left,
|
||||||
|
}"
|
||||||
@click.stop
|
@click.stop
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
@@ -412,7 +435,13 @@ const progressPct = computed(() => (totalPages.value ? Math.round((currentPage.v
|
|||||||
<div
|
<div
|
||||||
v-if="showViewMenu"
|
v-if="showViewMenu"
|
||||||
class="fixed w-44 rounded-lg shadow-2xl py-1 overflow-hidden"
|
class="fixed w-44 rounded-lg shadow-2xl py-1 overflow-hidden"
|
||||||
:style="{ zIndex: 9999, background: 'rgba(35,38,41,0.98)', border: '1px solid rgba(255,255,255,0.1)', top: viewMenuStyle.top, right: viewMenuStyle.right }"
|
:style="{
|
||||||
|
zIndex: 9999,
|
||||||
|
background: 'rgba(35,38,41,0.98)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.1)',
|
||||||
|
top: viewMenuStyle.top,
|
||||||
|
right: viewMenuStyle.right,
|
||||||
|
}"
|
||||||
@click.stop
|
@click.stop
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -38,7 +38,10 @@ function formatGap(v: number) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="fixed inset-0 z-50 flex flex-col justify-end" @click.self="emit('close')">
|
<div class="fixed inset-0 z-50 flex flex-col justify-end" @click.self="emit('close')">
|
||||||
<div class="bg-card text-card-foreground rounded-t-xl max-h-[85vh] overflow-y-auto shadow-2xl border-t border-border w-full max-w-2xl mx-auto" @click.stop>
|
<div
|
||||||
|
class="bg-card text-card-foreground rounded-t-xl max-h-[85vh] overflow-y-auto shadow-2xl border-t border-border w-full max-w-2xl mx-auto"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
<div class="sticky top-0 bg-card z-10 flex items-center justify-between px-5 py-4 border-b border-border">
|
<div class="sticky top-0 bg-card z-10 flex items-center justify-between px-5 py-4 border-b border-border">
|
||||||
<div class="flex gap-1">
|
<div class="flex gap-1">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -63,97 +63,97 @@ function onCopy() {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<template v-if="visible">
|
<template v-if="visible">
|
||||||
<div class="fixed inset-0 z-[59]" @click="emit('dismiss')" />
|
<div class="fixed inset-0 z-[59]" @click="emit('dismiss')" />
|
||||||
<div
|
<div
|
||||||
class="fixed z-[60] select-none"
|
class="fixed z-[60] select-none"
|
||||||
:style="{
|
:style="{
|
||||||
left: `${position.x}px`,
|
left: `${position.x}px`,
|
||||||
top: `${position.y}px`,
|
top: `${position.y}px`,
|
||||||
transform: showBelow ? 'translateX(-50%)' : 'translateX(-50%) translateY(-100%)',
|
transform: showBelow ? 'translateX(-50%)' : 'translateX(-50%) translateY(-100%)',
|
||||||
}"
|
}"
|
||||||
@mousedown.stop
|
@mousedown.stop
|
||||||
>
|
>
|
||||||
<div class="bg-card text-card-foreground rounded-xl shadow-xl border border-border p-1.5 flex flex-col gap-1">
|
<div class="bg-card text-card-foreground rounded-xl shadow-xl border border-border p-1.5 flex flex-col gap-1">
|
||||||
<div class="flex gap-1">
|
<div class="flex gap-1">
|
||||||
<button
|
<button
|
||||||
class="flex items-center justify-center w-8 h-8 rounded-lg hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
class="flex items-center justify-center w-8 h-8 rounded-lg hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
||||||
@click="onCopy"
|
@click="onCopy"
|
||||||
title="Copy"
|
title="Copy"
|
||||||
>
|
>
|
||||||
<Copy :size="15" />
|
<Copy :size="15" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="flex items-center justify-center w-8 h-8 rounded-lg hover:bg-muted transition-colors"
|
class="flex items-center justify-center w-8 h-8 rounded-lg hover:bg-muted transition-colors"
|
||||||
:class="showColorPicker ? 'bg-yellow-100 text-yellow-600' : 'text-muted-foreground hover:text-foreground'"
|
:class="showColorPicker ? 'bg-yellow-100 text-yellow-600' : 'text-muted-foreground hover:text-foreground'"
|
||||||
@click="onHighlightClick"
|
@click="onHighlightClick"
|
||||||
title="Highlight"
|
title="Highlight"
|
||||||
>
|
>
|
||||||
<Highlighter :size="15" />
|
<Highlighter :size="15" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="flex items-center justify-center w-8 h-8 rounded-lg hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
class="flex items-center justify-center w-8 h-8 rounded-lg hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
||||||
@click="emit('search')"
|
@click="emit('search')"
|
||||||
title="Search"
|
title="Search"
|
||||||
>
|
>
|
||||||
<Search :size="15" />
|
<Search :size="15" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="flex items-center justify-center w-8 h-8 rounded-lg hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
class="flex items-center justify-center w-8 h-8 rounded-lg hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
||||||
@click="emit('note')"
|
@click="emit('note')"
|
||||||
title="Add note"
|
title="Add note"
|
||||||
>
|
>
|
||||||
<FileText :size="15" />
|
<FileText :size="15" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
v-if="overlappingAnnotationId !== null"
|
v-if="overlappingAnnotationId !== null"
|
||||||
class="flex items-center justify-center w-8 h-8 rounded-lg hover:bg-muted transition-colors text-destructive hover:text-destructive/80"
|
class="flex items-center justify-center w-8 h-8 rounded-lg hover:bg-muted transition-colors text-destructive hover:text-destructive/80"
|
||||||
@click="emit('deleteAnnotation', overlappingAnnotationId!)"
|
@click="emit('deleteAnnotation', overlappingAnnotationId!)"
|
||||||
title="Delete annotation"
|
title="Delete annotation"
|
||||||
>
|
>
|
||||||
<Trash2 :size="15" />
|
<Trash2 :size="15" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="showColorPicker" class="border-t border-border pt-1.5 space-y-1.5">
|
<div v-if="showColorPicker" class="border-t border-border pt-1.5 space-y-1.5">
|
||||||
<div class="flex gap-1 px-0.5">
|
<div class="flex gap-1 px-0.5">
|
||||||
<button
|
<button
|
||||||
v-for="c in colors"
|
v-for="c in colors"
|
||||||
:key="c.hex"
|
:key="c.hex"
|
||||||
class="w-6 h-6 rounded-full border-2 transition-all hover:scale-110"
|
class="w-6 h-6 rounded-full border-2 transition-all hover:scale-110"
|
||||||
:class="selectedColor === c.hex ? 'border-foreground scale-110' : 'border-transparent'"
|
:class="selectedColor === c.hex ? 'border-foreground scale-110' : 'border-transparent'"
|
||||||
:style="{ background: c.hex }"
|
:style="{ background: c.hex }"
|
||||||
:title="c.label"
|
:title="c.label"
|
||||||
@click="selectedColor = c.hex"
|
@click="selectedColor = c.hex"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-1 px-0.5">
|
<div class="flex gap-1 px-0.5">
|
||||||
<button
|
<button
|
||||||
v-for="s in styles"
|
v-for="s in styles"
|
||||||
:key="s.id"
|
:key="s.id"
|
||||||
class="w-6 h-6 rounded text-xs font-bold transition-colors border"
|
class="w-6 h-6 rounded text-xs font-bold transition-colors border"
|
||||||
:class="
|
:class="
|
||||||
selectedStyle === s.id ? 'bg-primary text-primary-foreground border-primary' : 'border-border hover:bg-muted text-muted-foreground'
|
selectedStyle === s.id ? 'bg-primary text-primary-foreground border-primary' : 'border-border hover:bg-muted text-muted-foreground'
|
||||||
"
|
"
|
||||||
:title="s.id"
|
:title="s.id"
|
||||||
@click="selectedStyle = s.id"
|
@click="selectedStyle = s.id"
|
||||||
>
|
>
|
||||||
{{ s.label }}
|
{{ s.label }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="flex-1 ml-1 px-2 py-0.5 rounded text-xs font-medium bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
class="flex-1 ml-1 px-2 py-0.5 rounded text-xs font-medium bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||||
@click="applyHighlight(selectedColor, selectedStyle)"
|
@click="applyHighlight(selectedColor, selectedStyle)"
|
||||||
>
|
>
|
||||||
Apply
|
Apply
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</Teleport>
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
export function useCbz(fileId: number, bookId: number) {
|
||||||
|
const pageCount = ref(0)
|
||||||
|
const bookTitle = ref('')
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
function pageUrl(n: number): string {
|
||||||
|
return `/api/cbz/files/${fileId}/pages/${n}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
const [pagesRes, bookRes] = await Promise.all([fetch(`/api/cbz/files/${fileId}/pages`), fetch(`/api/books/${bookId}`)])
|
||||||
|
if (!pagesRes.ok) {
|
||||||
|
error.value = 'Failed to load comic'
|
||||||
|
loading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const [pagesData, bookData] = await Promise.all([pagesRes.json(), bookRes.ok ? bookRes.json() : null])
|
||||||
|
pageCount.value = pagesData.pageCount
|
||||||
|
bookTitle.value = bookData?.title ?? ''
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return { pageCount, bookTitle, loading, error, pageUrl, load }
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
export type FitMode = 'fit-page' | 'fit-width' | 'fit-height' | 'actual'
|
||||||
|
export type ViewMode = 'single' | 'two-page'
|
||||||
|
export type ScrollMode = 'paginated' | 'infinite' | 'long-strip'
|
||||||
|
export type Direction = 'ltr' | 'rtl'
|
||||||
|
export type BgColor = 'black' | 'gray' | 'white'
|
||||||
|
|
||||||
|
interface CbzSettings {
|
||||||
|
fitMode: FitMode
|
||||||
|
viewMode: ViewMode
|
||||||
|
scrollMode: ScrollMode
|
||||||
|
direction: Direction
|
||||||
|
bgColor: BgColor
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'cbz-reader-settings'
|
||||||
|
|
||||||
|
function loadSaved(): Partial<CbzSettings> {
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}')
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCbzSettings() {
|
||||||
|
const saved = loadSaved()
|
||||||
|
|
||||||
|
const fitMode = ref<FitMode>(saved.fitMode ?? 'fit-page')
|
||||||
|
const viewMode = ref<ViewMode>(saved.viewMode ?? 'single')
|
||||||
|
const scrollMode = ref<ScrollMode>(saved.scrollMode ?? 'paginated')
|
||||||
|
const direction = ref<Direction>(saved.direction ?? 'ltr')
|
||||||
|
const bgColor = ref<BgColor>(saved.bgColor ?? 'black')
|
||||||
|
|
||||||
|
watch([fitMode, viewMode, scrollMode, direction, bgColor], () => {
|
||||||
|
localStorage.setItem(
|
||||||
|
STORAGE_KEY,
|
||||||
|
JSON.stringify({
|
||||||
|
fitMode: fitMode.value,
|
||||||
|
viewMode: viewMode.value,
|
||||||
|
scrollMode: scrollMode.value,
|
||||||
|
direction: direction.value,
|
||||||
|
bgColor: bgColor.value,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const bgValue = computed(() => {
|
||||||
|
if (bgColor.value === 'black') return '#0a0a0a'
|
||||||
|
if (bgColor.value === 'gray') return '#525659'
|
||||||
|
return '#e8e8e8'
|
||||||
|
})
|
||||||
|
|
||||||
|
const isTwoPage = computed(() => viewMode.value === 'two-page' && scrollMode.value === 'paginated')
|
||||||
|
|
||||||
|
const imgFitClass = computed(() => {
|
||||||
|
switch (fitMode.value) {
|
||||||
|
case 'fit-width':
|
||||||
|
return 'w-full h-auto max-w-full'
|
||||||
|
case 'fit-height':
|
||||||
|
return 'h-full w-auto max-h-full'
|
||||||
|
case 'actual':
|
||||||
|
return 'max-w-none max-h-none'
|
||||||
|
default: // fit-page
|
||||||
|
return 'max-w-full max-h-full object-contain'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return { fitMode, viewMode, scrollMode, direction, bgColor, bgValue, isTwoPage, imgFitClass }
|
||||||
|
}
|
||||||
@@ -42,9 +42,7 @@ export function usePdfLayout(
|
|||||||
// In page mode every row fills the viewport; in continuous mode rows have natural height.
|
// In page mode every row fills the viewport; in continuous mode rows have natural height.
|
||||||
const rowHeights = computed(() => {
|
const rowHeights = computed(() => {
|
||||||
if (scrollMode.value === 'page') return pageRows.value.map(() => containerH.value)
|
if (scrollMode.value === 'page') return pageRows.value.map(() => containerH.value)
|
||||||
return pageRows.value.map((row) =>
|
return pageRows.value.map((row) => Math.max(...row.map((n) => Math.round((effectiveDims.value[n - 1]?.height ?? 842) * scale.value))))
|
||||||
Math.max(...row.map((n) => Math.round((effectiveDims.value[n - 1]?.height ?? 842) * scale.value))),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(currentPage, (v) => {
|
watch(currentPage, (v) => {
|
||||||
|
|||||||
@@ -47,9 +47,7 @@ export function usePdfZoom(
|
|||||||
})
|
})
|
||||||
|
|
||||||
function adjustZoom(delta: number) {
|
function adjustZoom(delta: number) {
|
||||||
customScale.value = Math.round(
|
customScale.value = Math.round(Math.max(0.25, Math.min(4, (zoomMode.value === 'custom' ? customScale.value : scale.value) + delta) * 20) / 20)
|
||||||
Math.max(0.25, Math.min(4, (zoomMode.value === 'custom' ? customScale.value : scale.value) + delta) * 20) / 20,
|
|
||||||
)
|
|
||||||
zoomMode.value = 'custom'
|
zoomMode.value = 'custom'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"7zip-bin": "^5.2.0",
|
||||||
"@fastify/static": "^9.0.0",
|
"@fastify/static": "^9.0.0",
|
||||||
"@nestjs/common": "^11.0.1",
|
"@nestjs/common": "^11.0.1",
|
||||||
"@nestjs/config": "^4.0.2",
|
"@nestjs/config": "^4.0.2",
|
||||||
@@ -36,6 +37,7 @@
|
|||||||
"drizzle-kit": "^0.31.8",
|
"drizzle-kit": "^0.31.8",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
"fast-xml-parser": "^5.3.3",
|
"fast-xml-parser": "^5.3.3",
|
||||||
|
"node-7z": "^3.0.0",
|
||||||
"pdf-parse": "^2.4.5",
|
"pdf-parse": "^2.4.5",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { appConfig, authConfig, dbConfig, storageConfig } from './config/config'
|
|||||||
import { DbModule } from './db/db.module';
|
import { DbModule } from './db/db.module';
|
||||||
import { AnnotationModule } from './modules/annotation/annotation.module';
|
import { AnnotationModule } from './modules/annotation/annotation.module';
|
||||||
import { AuthModule } from './modules/auth/auth.module';
|
import { AuthModule } from './modules/auth/auth.module';
|
||||||
|
import { CbzModule } from './modules/cbz/cbz.module';
|
||||||
import { BookmarkModule } from './modules/bookmark/bookmark.module';
|
import { BookmarkModule } from './modules/bookmark/bookmark.module';
|
||||||
import { BookModule } from './modules/book/book.module';
|
import { BookModule } from './modules/book/book.module';
|
||||||
import { KoboModule } from './modules/kobo/kobo.module';
|
import { KoboModule } from './modules/kobo/kobo.module';
|
||||||
@@ -29,6 +30,7 @@ import { UserModule } from './modules/user/user.module';
|
|||||||
KoboModule,
|
KoboModule,
|
||||||
BookmarkModule,
|
BookmarkModule,
|
||||||
AnnotationModule,
|
AnnotationModule,
|
||||||
|
CbzModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -5,28 +5,28 @@
|
|||||||
{
|
{
|
||||||
"idx": 0,
|
"idx": 0,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1767491359647,
|
"when": 1767494438065,
|
||||||
"tag": "0000_init_schema",
|
"tag": "0000_init_schema",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 1,
|
"idx": 1,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1767491359647,
|
"when": 1767494438065,
|
||||||
"tag": "0001_lethal_pixie",
|
"tag": "0001_lethal_pixie",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 2,
|
"idx": 2,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1767491359647,
|
"when": 1767494438065,
|
||||||
"tag": "0002_powerful_thundra",
|
"tag": "0002_powerful_thundra",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 3,
|
"idx": 3,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1767491359647,
|
"when": 1767494438065,
|
||||||
"tag": "0003_reading_progress_by_file",
|
"tag": "0003_reading_progress_by_file",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,5 +7,6 @@ import { BookService } from './book.service';
|
|||||||
@Module({
|
@Module({
|
||||||
controllers: [BookController],
|
controllers: [BookController],
|
||||||
providers: [BookService, BookRepository],
|
providers: [BookService, BookRepository],
|
||||||
|
exports: [BookService, BookRepository],
|
||||||
})
|
})
|
||||||
export class BookModule {}
|
export class BookModule {}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Controller, Get, Param, ParseIntPipe, Res } from '@nestjs/common';
|
||||||
|
import type { FastifyReply } from 'fastify';
|
||||||
|
import { CbzService } from './cbz.service';
|
||||||
|
|
||||||
|
@Controller('cbz')
|
||||||
|
export class CbzController {
|
||||||
|
constructor(private readonly cbzService: CbzService) {}
|
||||||
|
|
||||||
|
@Get('files/:fileId/pages')
|
||||||
|
async getPageCount(@Param('fileId', ParseIntPipe) fileId: number) {
|
||||||
|
return { pageCount: await this.cbzService.getPageCount(fileId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('files/:fileId/pages/:pageIndex')
|
||||||
|
async getPage(@Param('fileId', ParseIntPipe) fileId: number, @Param('pageIndex', ParseIntPipe) pageIndex: number, @Res() reply: FastifyReply) {
|
||||||
|
const { stream, mimeType } = await this.cbzService.streamPage(fileId, pageIndex);
|
||||||
|
reply.header('Cache-Control', 'public, max-age=31536000, immutable');
|
||||||
|
reply.type(mimeType);
|
||||||
|
reply.send(stream);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { BookModule } from '../book/book.module';
|
||||||
|
import { CbzController } from './cbz.controller';
|
||||||
|
import { CbzService } from './cbz.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [BookModule],
|
||||||
|
controllers: [CbzController],
|
||||||
|
providers: [CbzService],
|
||||||
|
})
|
||||||
|
export class CbzModule {}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { createReadStream } from 'fs';
|
||||||
|
import { readFile } from 'fs/promises';
|
||||||
|
import { createInflateRaw } from 'zlib';
|
||||||
|
|
||||||
|
import { BookRepository } from '../book/book.repository';
|
||||||
|
|
||||||
|
const IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif', '.bmp', '.avif']);
|
||||||
|
const LFH_SIG = Buffer.from([0x50, 0x4b, 0x03, 0x04]);
|
||||||
|
|
||||||
|
function isImage(name: string): boolean {
|
||||||
|
const dot = name.lastIndexOf('.');
|
||||||
|
return dot !== -1 && IMAGE_EXTS.has(name.substring(dot).toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHidden(name: string): boolean {
|
||||||
|
return name.split('/').some((p) => p.startsWith('.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PageEntry {
|
||||||
|
name: string;
|
||||||
|
dataStart: number; // byte offset of compressed data in the ZIP file
|
||||||
|
compressedSize: number;
|
||||||
|
compression: number; // 0 = STORED, 8 = DEFLATE
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan all local file headers in the buffer and return image entries sorted naturally.
|
||||||
|
// Same technique as cover-cbz.ts — bypasses the central directory, which unzipper
|
||||||
|
// fails to locate when the ZIP has a large comment (e.g. ComicTagger JSON metadata).
|
||||||
|
function buildPageIndex(buf: Buffer): PageEntry[] {
|
||||||
|
const entries: PageEntry[] = [];
|
||||||
|
let pos = 0;
|
||||||
|
|
||||||
|
while (pos < buf.length - 30) {
|
||||||
|
const sigPos = buf.indexOf(LFH_SIG, pos);
|
||||||
|
if (sigPos === -1) break;
|
||||||
|
|
||||||
|
const compression = buf.readUInt16LE(sigPos + 8);
|
||||||
|
const compressedSize = buf.readUInt32LE(sigPos + 18);
|
||||||
|
const fileNameLen = buf.readUInt16LE(sigPos + 26);
|
||||||
|
const extraLen = buf.readUInt16LE(sigPos + 28);
|
||||||
|
const dataStart = sigPos + 30 + fileNameLen + extraLen;
|
||||||
|
const fileName = buf.subarray(sigPos + 30, sigPos + 30 + fileNameLen).toString('utf-8');
|
||||||
|
|
||||||
|
if (!fileName.endsWith('/') && !isHidden(fileName) && isImage(fileName)) {
|
||||||
|
if (compression === 0 || compression === 8) {
|
||||||
|
entries.push({ name: fileName, dataStart, compressedSize, compression });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pos = dataStart + compressedSize;
|
||||||
|
if (compressedSize === 0) pos = sigPos + 4; // guard against infinite loop on data descriptors
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }));
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CbzService {
|
||||||
|
// Caches sorted page entries per fileId — built once on first access, held for server lifetime.
|
||||||
|
private readonly indexCache = new Map<number, PageEntry[]>();
|
||||||
|
|
||||||
|
constructor(private readonly bookRepo: BookRepository) {}
|
||||||
|
|
||||||
|
private async getIndex(fileId: number): Promise<{ path: string; entries: PageEntry[] }> {
|
||||||
|
const file = await this.bookRepo.findFileById(fileId);
|
||||||
|
if (!file) throw new NotFoundException(`File ${fileId} not found`);
|
||||||
|
|
||||||
|
if (!this.indexCache.has(fileId)) {
|
||||||
|
const buf = await readFile(file.absolutePath);
|
||||||
|
this.indexCache.set(fileId, buildPageIndex(buf));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { path: file.absolutePath, entries: this.indexCache.get(fileId)! };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPageCount(fileId: number): Promise<number> {
|
||||||
|
const { entries } = await this.getIndex(fileId);
|
||||||
|
return entries.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async streamPage(fileId: number, pageIndex: number): Promise<{ stream: NodeJS.ReadableStream; mimeType: string }> {
|
||||||
|
const { path, entries } = await this.getIndex(fileId);
|
||||||
|
|
||||||
|
if (pageIndex < 0 || pageIndex >= entries.length) {
|
||||||
|
throw new NotFoundException(`Page ${pageIndex} out of range (0–${entries.length - 1})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = entries[pageIndex];
|
||||||
|
const ext = entry.name.substring(entry.name.lastIndexOf('.')).toLowerCase();
|
||||||
|
const mimeType = ext === '.png' ? 'image/png' : ext === '.webp' ? 'image/webp' : 'image/jpeg';
|
||||||
|
|
||||||
|
const raw = createReadStream(path, { start: entry.dataStart, end: entry.dataStart + entry.compressedSize - 1 });
|
||||||
|
const stream = entry.compression === 0 ? raw : raw.pipe(createInflateRaw());
|
||||||
|
|
||||||
|
return { stream, mimeType };
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user