feat(reader): add CBZ/CBR comic reader with backend page streaming

This commit is contained in:
neon
2026-01-03 07:43:11 -06:00
parent 475c48fce9
commit dd5b2700da
17 changed files with 835 additions and 103 deletions
@@ -18,6 +18,7 @@ import SelectionPopup from './components/SelectionPopup.vue'
import ReaderSearchPanel from './components/ReaderSearchPanel.vue'
import NoteDialog from './components/NoteDialog.vue'
import PdfReaderView from './components/PdfReaderView.vue'
import CbzReaderView from './components/CbzReaderView.vue'
import type { ReaderState } from './composables/useReaderState'
import type { FoliateRenderer } from './composables/useFoliate'
@@ -221,6 +222,7 @@ function navigateSearch(cfiTarget: string) {
<template>
<PdfReaderView v-if="fileFormat === 'pdf'" :bookId="bookId" :fileId="fileId" />
<CbzReaderView v-else-if="fileFormat === 'cbz' || fileFormat === 'cbr'" :bookId="bookId" :fileId="fileId" />
<div
v-else
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)
watch(findQuery, (q) => {
if (!q.trim()) { findMatches.value = 0; return }
if (!q.trim()) {
findMatches.value = 0
return
}
let count = 0
scrollRef.value?.querySelectorAll('[data-text-layer] span').forEach((el) => {
if (el.textContent?.toLowerCase().includes(q.toLowerCase())) count++
@@ -176,7 +179,9 @@ function onPageCommit() {
// ── Mount / unmount ───────────────────────────────────────────────────────────
onMounted(async () => {
window.addEventListener('keydown', onKeyDown)
const onFsChange = () => { isFullscreen.value = !!document.fullscreenElement }
const onFsChange = () => {
isFullscreen.value = !!document.fullscreenElement
}
document.addEventListener('fullscreenchange', onFsChange)
onUnmounted(() => document.removeEventListener('fullscreenchange', onFsChange))
@@ -204,7 +209,10 @@ onMounted(async () => {
ro.observe(scrollRef.value)
setupIO(scrollRef.value)
}
onUnmounted(() => { ro?.disconnect(); ro = null })
onUnmounted(() => {
ro?.disconnect()
ro = null
})
if (progress.pageNumber.value && progress.pageNumber.value > 1) {
await nextTick()
@@ -367,13 +375,21 @@ const progressPct = computed(() => (totalPages.value ? Math.round((currentPage.v
>
<canvas
class="block"
:ref="(el) => { if (el) canvasMap.set(pageNum, el as HTMLCanvasElement) }"
:ref="
(el) => {
if (el) canvasMap.set(pageNum, el as HTMLCanvasElement)
}
"
/>
<div
data-text-layer
class="absolute inset-0 overflow-hidden"
style="pointer-events: none"
:ref="(el) => { if (el) textLayerMap.set(pageNum, el as HTMLElement) }"
:ref="
(el) => {
if (el) textLayerMap.set(pageNum, el as HTMLElement)
}
"
/>
</div>
</div>
@@ -391,7 +407,14 @@ const progressPct = computed(() => (totalPages.value ? Math.round((currentPage.v
<div
v-if="showZoomMenu"
class="fixed w-40 rounded-lg shadow-2xl py-1 overflow-hidden"
:style="{ zIndex: 9999, background: 'rgba(35,38,41,0.98)', border: '1px solid rgba(255,255,255,0.1)', transform: 'translateX(-50%)', top: zoomMenuStyle.top, left: zoomMenuStyle.left }"
:style="{
zIndex: 9999,
background: 'rgba(35,38,41,0.98)',
border: '1px solid rgba(255,255,255,0.1)',
transform: 'translateX(-50%)',
top: zoomMenuStyle.top,
left: zoomMenuStyle.left,
}"
@click.stop
>
<button
@@ -412,7 +435,13 @@ const progressPct = computed(() => (totalPages.value ? Math.round((currentPage.v
<div
v-if="showViewMenu"
class="fixed w-44 rounded-lg shadow-2xl py-1 overflow-hidden"
:style="{ zIndex: 9999, background: 'rgba(35,38,41,0.98)', border: '1px solid rgba(255,255,255,0.1)', top: viewMenuStyle.top, right: viewMenuStyle.right }"
:style="{
zIndex: 9999,
background: 'rgba(35,38,41,0.98)',
border: '1px solid rgba(255,255,255,0.1)',
top: viewMenuStyle.top,
right: viewMenuStyle.right,
}"
@click.stop
>
<button
@@ -38,7 +38,10 @@ function formatGap(v: number) {
<template>
<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="flex gap-1">
<button
@@ -63,97 +63,97 @@ function onCopy() {
<template>
<Teleport to="body">
<template v-if="visible">
<div class="fixed inset-0 z-[59]" @click="emit('dismiss')" />
<div
class="fixed z-[60] select-none"
:style="{
left: `${position.x}px`,
top: `${position.y}px`,
transform: showBelow ? 'translateX(-50%)' : 'translateX(-50%) translateY(-100%)',
}"
@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="flex gap-1">
<button
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"
title="Copy"
>
<Copy :size="15" />
</button>
<template v-if="visible">
<div class="fixed inset-0 z-[59]" @click="emit('dismiss')" />
<div
class="fixed z-[60] select-none"
:style="{
left: `${position.x}px`,
top: `${position.y}px`,
transform: showBelow ? 'translateX(-50%)' : 'translateX(-50%) translateY(-100%)',
}"
@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="flex gap-1">
<button
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"
title="Copy"
>
<Copy :size="15" />
</button>
<button
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'"
@click="onHighlightClick"
title="Highlight"
>
<Highlighter :size="15" />
</button>
<button
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'"
@click="onHighlightClick"
title="Highlight"
>
<Highlighter :size="15" />
</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"
@click="emit('search')"
title="Search"
>
<Search :size="15" />
</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"
@click="emit('search')"
title="Search"
>
<Search :size="15" />
</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"
@click="emit('note')"
title="Add note"
>
<FileText :size="15" />
</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"
@click="emit('note')"
title="Add note"
>
<FileText :size="15" />
</button>
<button
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"
@click="emit('deleteAnnotation', overlappingAnnotationId!)"
title="Delete annotation"
>
<Trash2 :size="15" />
</button>
</div>
<button
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"
@click="emit('deleteAnnotation', overlappingAnnotationId!)"
title="Delete annotation"
>
<Trash2 :size="15" />
</button>
</div>
<div v-if="showColorPicker" class="border-t border-border pt-1.5 space-y-1.5">
<div class="flex gap-1 px-0.5">
<button
v-for="c in colors"
:key="c.hex"
class="w-6 h-6 rounded-full border-2 transition-all hover:scale-110"
:class="selectedColor === c.hex ? 'border-foreground scale-110' : 'border-transparent'"
:style="{ background: c.hex }"
:title="c.label"
@click="selectedColor = c.hex"
/>
</div>
<div class="flex gap-1 px-0.5">
<button
v-for="s in styles"
:key="s.id"
class="w-6 h-6 rounded text-xs font-bold transition-colors border"
:class="
selectedStyle === s.id ? 'bg-primary text-primary-foreground border-primary' : 'border-border hover:bg-muted text-muted-foreground'
"
:title="s.id"
@click="selectedStyle = s.id"
>
{{ s.label }}
</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"
@click="applyHighlight(selectedColor, selectedStyle)"
>
Apply
</button>
<div v-if="showColorPicker" class="border-t border-border pt-1.5 space-y-1.5">
<div class="flex gap-1 px-0.5">
<button
v-for="c in colors"
:key="c.hex"
class="w-6 h-6 rounded-full border-2 transition-all hover:scale-110"
:class="selectedColor === c.hex ? 'border-foreground scale-110' : 'border-transparent'"
:style="{ background: c.hex }"
:title="c.label"
@click="selectedColor = c.hex"
/>
</div>
<div class="flex gap-1 px-0.5">
<button
v-for="s in styles"
:key="s.id"
class="w-6 h-6 rounded text-xs font-bold transition-colors border"
:class="
selectedStyle === s.id ? 'bg-primary text-primary-foreground border-primary' : 'border-border hover:bg-muted text-muted-foreground'
"
:title="s.id"
@click="selectedStyle = s.id"
>
{{ s.label }}
</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"
@click="applyHighlight(selectedColor, selectedStyle)"
>
Apply
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
</template>
</Teleport>
</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.
const rowHeights = computed(() => {
if (scrollMode.value === 'page') return pageRows.value.map(() => containerH.value)
return pageRows.value.map((row) =>
Math.max(...row.map((n) => Math.round((effectiveDims.value[n - 1]?.height ?? 842) * scale.value))),
)
return pageRows.value.map((row) => Math.max(...row.map((n) => Math.round((effectiveDims.value[n - 1]?.height ?? 842) * scale.value))))
})
watch(currentPage, (v) => {
@@ -47,9 +47,7 @@ export function usePdfZoom(
})
function adjustZoom(delta: number) {
customScale.value = Math.round(
Math.max(0.25, Math.min(4, (zoomMode.value === 'custom' ? customScale.value : scale.value) + delta) * 20) / 20,
)
customScale.value = Math.round(Math.max(0.25, Math.min(4, (zoomMode.value === 'custom' ? customScale.value : scale.value) + delta) * 20) / 20)
zoomMode.value = 'custom'
}