mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(reader): add CBZ spread layout engine and settings
This commit is contained in:
@@ -8,36 +8,42 @@ import {
|
||||
ArrowLeftRight,
|
||||
ArrowRight,
|
||||
BookOpen,
|
||||
Circle,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
Image as ImageIcon,
|
||||
LayoutGrid,
|
||||
Info,
|
||||
Layers,
|
||||
LayoutGrid,
|
||||
Maximize,
|
||||
Moon,
|
||||
ScanLine,
|
||||
Settings,
|
||||
Sun,
|
||||
} from 'lucide-vue-next'
|
||||
import { useVisibility } from '../shared/composables/useVisibility'
|
||||
import { useReaderProgress } from '../shared/composables/useReaderProgress'
|
||||
import { useReadingSession } from '../shared/composables/useReadingSession'
|
||||
import { useCbz } from './composables/useCbz'
|
||||
import { useCbzSettings } from './composables/useCbzSettings'
|
||||
import type { BgColor, Direction, FitMode, ScrollMode, ViewMode } from './composables/useCbzSettings'
|
||||
import type { BgColor, Direction, FitMode, ScrollMode, SpreadAlignment, ViewMode, WidePageSingletonMode } from './composables/useCbzSettings'
|
||||
import { useReaderSettings } from '../shared/composables/useReaderSettings'
|
||||
import type { CbxReaderSettings } from '@projectx/types'
|
||||
import { DEFAULT_WIDE_PAGE_RATIO_THRESHOLD, createCbzSpreadLayout } from './lib/spread-layout'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
|
||||
const TWO_PAGE_BREAKPOINT = 900
|
||||
|
||||
const props = defineProps<{ bookId: number; fileId: number }>()
|
||||
const router = useRouter()
|
||||
|
||||
@@ -49,13 +55,21 @@ const { onActivity } = useReadingSession(props.fileId, () => ({
|
||||
pageNumber: progress.pageNumber.value,
|
||||
}))
|
||||
const { pageCount, bookTitle, loading, error, pageUrl, load } = useCbz(props.fileId, props.bookId)
|
||||
const { fitMode, viewMode, scrollMode, direction, bgColor, bgValue, isTwoPage, imgFitClass } = useCbzSettings()
|
||||
const { fitMode, viewMode, scrollMode, direction, spreadAlignment, forceTwoPage, widePageSingletonMode, bgColor, bgValue, imgFitClass } =
|
||||
useCbzSettings()
|
||||
const bookSettings = useReaderSettings(props.fileId, 'cbz')
|
||||
|
||||
const currentPage = ref(0)
|
||||
const currentImageLoaded = ref(false)
|
||||
const showSettings = ref(false)
|
||||
const scrollContainer = ref<HTMLElement | null>(null)
|
||||
const viewportWidth = ref(0)
|
||||
const currentImageLoaded = ref(false)
|
||||
const pendingImageLoads = ref(0)
|
||||
const loadedImageCount = ref(0)
|
||||
const pageRatios = ref<number[]>([])
|
||||
const highlightForceTwoPage = ref(false)
|
||||
const forceTwoPageToggleButton = ref<HTMLButtonElement | null>(null)
|
||||
let forceToggleHighlightTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
watch(showSettings, (open) => setVisibilityLock(open))
|
||||
|
||||
@@ -67,22 +81,30 @@ const FIT_OPTIONS: { value: FitMode; label: string; icon: Component }[] = [
|
||||
{ value: 'actual', label: 'Actual Size', icon: ImageIcon },
|
||||
]
|
||||
const VIEW_OPTIONS: { value: ViewMode; label: string; icon: Component }[] = [
|
||||
{ value: 'single', label: 'Single Page', icon: BookOpen },
|
||||
{ value: 'two-page', label: 'Two Page', icon: LayoutGrid },
|
||||
{ value: 'single', label: 'Single', icon: BookOpen },
|
||||
{ value: 'two-page', label: 'Two-page', icon: LayoutGrid },
|
||||
]
|
||||
const SCROLL_OPTIONS: { value: ScrollMode; label: string; icon: Component }[] = [
|
||||
{ value: 'paginated', label: 'Paginated', icon: ScanLine },
|
||||
{ value: 'infinite', label: 'Infinite Scroll', icon: Layers },
|
||||
{ value: 'long-strip', label: 'Long Strip', icon: AlignJustify },
|
||||
{ value: 'infinite', label: 'Infinite', icon: Layers },
|
||||
{ value: 'long-strip', label: 'Long strip', icon: AlignJustify },
|
||||
]
|
||||
const DIRECTION_OPTIONS: { value: Direction; label: string; icon: Component }[] = [
|
||||
{ value: 'ltr', label: 'Left to Right', icon: ArrowRight },
|
||||
{ value: 'rtl', label: 'Right to Left', icon: ArrowLeft },
|
||||
{ value: 'ltr', label: 'L to R', icon: ArrowRight },
|
||||
{ value: 'rtl', label: 'R to L', icon: ArrowLeft },
|
||||
]
|
||||
const BG_OPTIONS: { value: BgColor; label: string; color: string }[] = [
|
||||
{ value: 'black', label: 'Black', color: 'bg-zinc-950' },
|
||||
{ value: 'gray', label: 'Gray', color: 'bg-zinc-500' },
|
||||
{ value: 'white', label: 'White', color: 'bg-white' },
|
||||
const SPREAD_ALIGNMENT_OPTIONS: { value: SpreadAlignment; label: string; icon: Component }[] = [
|
||||
{ value: 'normal', label: 'Normal', icon: LayoutGrid },
|
||||
{ value: 'shifted', label: 'Shifted', icon: BookOpen },
|
||||
]
|
||||
const WIDE_PAGE_OPTIONS: { value: WidePageSingletonMode; label: string; icon: Component }[] = [
|
||||
{ value: 'auto', label: 'Auto singleton', icon: ImageIcon },
|
||||
{ value: 'disable', label: 'Keep in spreads', icon: LayoutGrid },
|
||||
]
|
||||
const BG_OPTIONS: { value: BgColor; label: string; icon: Component }[] = [
|
||||
{ value: 'black', label: 'Black', icon: Moon },
|
||||
{ value: 'gray', label: 'Gray', icon: Circle },
|
||||
{ value: 'white', label: 'White', icon: Sun },
|
||||
]
|
||||
|
||||
function setFitMode(v: FitMode) {
|
||||
@@ -97,51 +119,214 @@ function setScrollMode(v: ScrollMode) {
|
||||
function setDirection(v: Direction) {
|
||||
direction.value = v
|
||||
}
|
||||
function setSpreadAlignment(v: SpreadAlignment) {
|
||||
spreadAlignment.value = v
|
||||
}
|
||||
function setWidePageMode(v: WidePageSingletonMode) {
|
||||
widePageSingletonMode.value = v
|
||||
}
|
||||
function setForceTwoPage(v: boolean) {
|
||||
forceTwoPage.value = v
|
||||
}
|
||||
function toggleForceTwoPage() {
|
||||
setForceTwoPage(!forceTwoPage.value)
|
||||
}
|
||||
function setBgColor(v: BgColor) {
|
||||
bgColor.value = v
|
||||
}
|
||||
|
||||
// ── 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
|
||||
function applySettings(s: CbxReaderSettings) {
|
||||
fitMode.value = s.fitMode
|
||||
viewMode.value = s.viewMode
|
||||
scrollMode.value = s.scrollMode
|
||||
direction.value = s.direction
|
||||
spreadAlignment.value = s.spreadAlignment
|
||||
forceTwoPage.value = s.forceTwoPage
|
||||
widePageSingletonMode.value = s.widePageSingletonMode
|
||||
bgColor.value = s.bgColor
|
||||
}
|
||||
|
||||
function resetBookViewSettings() {
|
||||
bookSettings.resetBookSettings()
|
||||
applySettings(bookSettings.effective.value as CbxReaderSettings)
|
||||
if (scrollMode.value === 'paginated' && isTwoPageEffective.value) {
|
||||
currentPage.value = spreadLayout.value.anchorForPage(currentPage.value)
|
||||
}
|
||||
}
|
||||
|
||||
function confirmResetBookViewSettings() {
|
||||
if (!confirm('Reset view settings for this book to your global defaults?')) return
|
||||
resetBookViewSettings()
|
||||
}
|
||||
|
||||
function focusForceTwoPageFromHint() {
|
||||
nextTick(() => {
|
||||
forceTwoPageToggleButton.value?.focus()
|
||||
highlightForceTwoPage.value = true
|
||||
if (forceToggleHighlightTimer) clearTimeout(forceToggleHighlightTimer)
|
||||
forceToggleHighlightTimer = setTimeout(() => {
|
||||
highlightForceTwoPage.value = false
|
||||
}, 1400)
|
||||
})
|
||||
}
|
||||
|
||||
// ── 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 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 showSpreadAlignmentControl = computed(() => isTwoPageEffective.value)
|
||||
const showAutoFallbackBadge = computed(() => isTwoPagePreferred.value && !isTwoPageEffective.value)
|
||||
const showSpreadAlignmentHint = computed(() => isTwoPagePreferred.value && !isTwoPageEffective.value)
|
||||
|
||||
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 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 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, () => {
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
function schedulePreload(anchorPage: number) {
|
||||
const layout = spreadLayout.value
|
||||
const centerSpreadIndex = layout.spreadIndexForPage(anchorPage)
|
||||
const pagesToPreload = new Set<number>()
|
||||
|
||||
for (const offset of [-1, 0, 1, 2]) {
|
||||
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)
|
||||
}
|
||||
|
||||
// ── Navigation ─────────────────────────────────────────────────────────────────
|
||||
function goToPage(n: number) {
|
||||
const target = Math.max(0, Math.min(n, pageCount.value - 1))
|
||||
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 (target === currentPage.value) return
|
||||
|
||||
currentPage.value = target
|
||||
currentImageLoaded.value = false
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
goToPage(currentPage.value + (isTwoPage.value ? 2 : 1))
|
||||
if (pageCount.value <= 0) return
|
||||
if (isTwoPageEffective.value) {
|
||||
goToPage(spreadLayout.value.nextAnchor(currentPage.value))
|
||||
return
|
||||
}
|
||||
goToPage(currentPage.value + 1)
|
||||
}
|
||||
|
||||
function prevPage() {
|
||||
goToPage(currentPage.value - (isTwoPage.value ? 2 : 1))
|
||||
if (pageCount.value <= 0) return
|
||||
if (isTwoPageEffective.value) {
|
||||
goToPage(spreadLayout.value.prevAnchor(currentPage.value))
|
||||
return
|
||||
}
|
||||
goToPage(currentPage.value - 1)
|
||||
}
|
||||
|
||||
// ── Click zones (left / middle / right) ───────────────────────────────────────
|
||||
@@ -182,8 +367,6 @@ function onTouchEnd(e: TouchEvent) {
|
||||
}
|
||||
|
||||
// ── 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()
|
||||
@@ -194,6 +377,7 @@ function onWheel(e: WheelEvent) {
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if ((e.target as HTMLElement).tagName === 'INPUT') return
|
||||
const isRtl = direction.value === 'rtl'
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowRight':
|
||||
case 'PageDown':
|
||||
@@ -213,9 +397,11 @@ function onKeyDown(e: KeyboardEvent) {
|
||||
else nextPage()
|
||||
break
|
||||
case 'Home':
|
||||
e.preventDefault()
|
||||
goToPage(0)
|
||||
break
|
||||
case 'End':
|
||||
e.preventDefault()
|
||||
goToPage(pageCount.value - 1)
|
||||
break
|
||||
case 'Escape':
|
||||
@@ -230,6 +416,7 @@ let io: IntersectionObserver | null = null
|
||||
function setupScrollObserver() {
|
||||
io?.disconnect()
|
||||
if (!scrollContainer.value) return
|
||||
|
||||
io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
@@ -240,8 +427,9 @@ function setupScrollObserver() {
|
||||
},
|
||||
{ root: scrollContainer.value, threshold: 0.5 },
|
||||
)
|
||||
|
||||
nextTick(() => {
|
||||
scrollContainer.value?.querySelectorAll('[data-page]').forEach((el) => io!.observe(el))
|
||||
scrollContainer.value?.querySelectorAll('[data-page]').forEach((el) => io?.observe(el))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -255,6 +443,18 @@ watch(scrollMode, async (mode) => {
|
||||
}
|
||||
})
|
||||
|
||||
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
|
||||
@@ -271,46 +471,56 @@ let saveTimer: ReturnType<typeof setTimeout> | null = null
|
||||
watch(currentPage, (page) => {
|
||||
schedulePreload(page)
|
||||
onActivity()
|
||||
|
||||
if (saveTimer) clearTimeout(saveTimer)
|
||||
saveTimer = setTimeout(() => {
|
||||
progress.pageNumber.value = page + 1
|
||||
progress.percentage.value = pageCount.value ? ((page + 1) / pageCount.value) * 100 : 0
|
||||
progress.percentage.value = progressPercent.value
|
||||
progress.save()
|
||||
}, 2000)
|
||||
})
|
||||
|
||||
function onResize() {
|
||||
viewportWidth.value = window.innerWidth
|
||||
}
|
||||
|
||||
// ── Mount / unmount ────────────────────────────────────────────────────────────
|
||||
onMounted(async () => {
|
||||
viewportWidth.value = window.innerWidth
|
||||
window.addEventListener('resize', onResize)
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
|
||||
await progress.load()
|
||||
await bookSettings.load()
|
||||
|
||||
// Seed refs from effective (hardcoded fallback → format defaults → per-book delta)
|
||||
const s = bookSettings.effective.value as CbxReaderSettings
|
||||
fitMode.value = s.fitMode
|
||||
viewMode.value = s.viewMode
|
||||
scrollMode.value = s.scrollMode
|
||||
direction.value = s.direction
|
||||
bgColor.value = s.bgColor
|
||||
applySettings(bookSettings.effective.value as CbxReaderSettings)
|
||||
|
||||
// Register per-field watches AFTER seeding so the assignments above don't trigger saves.
|
||||
// Each watch sends only the one changed field to keep the delta minimal.
|
||||
watch(fitMode, (v) => bookSettings.updateBookSettings({ fitMode: v }))
|
||||
watch(viewMode, (v) => bookSettings.updateBookSettings({ viewMode: v }))
|
||||
watch(scrollMode, (v) => bookSettings.updateBookSettings({ scrollMode: v }))
|
||||
watch(direction, (v) => bookSettings.updateBookSettings({ direction: v }))
|
||||
watch(spreadAlignment, (v) => bookSettings.updateBookSettings({ spreadAlignment: v }))
|
||||
watch(forceTwoPage, (v) => bookSettings.updateBookSettings({ forceTwoPage: v }))
|
||||
watch(widePageSingletonMode, (v) => bookSettings.updateBookSettings({ widePageSingletonMode: v }))
|
||||
watch(bgColor, (v) => bookSettings.updateBookSettings({ bgColor: v }))
|
||||
|
||||
await load()
|
||||
const saved = progress.pageNumber.value
|
||||
if (saved && saved > 1) currentPage.value = Math.min(saved - 1, pageCount.value - 1)
|
||||
|
||||
if (scrollMode.value === 'paginated' && isTwoPageEffective.value) {
|
||||
currentPage.value = spreadLayout.value.anchorForPage(currentPage.value)
|
||||
}
|
||||
|
||||
schedulePreload(currentPage.value)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', onResize)
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
io?.disconnect()
|
||||
if (saveTimer) clearTimeout(saveTimer)
|
||||
if (forceToggleHighlightTimer) clearTimeout(forceToggleHighlightTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -325,7 +535,7 @@ onUnmounted(() => {
|
||||
<button class="viewer-btn" @click="router.back()"><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">{{ currentPage + 1 }} / {{ pageCount }}</span>
|
||||
<span class="text-xs text-muted-foreground tabular-nums">{{ pageLabel }}</span>
|
||||
</div>
|
||||
<DropdownMenu v-model:open="showSettings">
|
||||
<DropdownMenuTrigger as-child>
|
||||
@@ -333,84 +543,181 @@ onUnmounted(() => {
|
||||
<Settings :size="15" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" class="w-52">
|
||||
<DropdownMenuLabel class="text-muted-foreground text-xs px-2 py-1">Fit Mode</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup :model-value="fitMode">
|
||||
<DropdownMenuRadioItem
|
||||
v-for="opt in FIT_OPTIONS"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
class="text-xs gap-2"
|
||||
@select.prevent="setFitMode(opt.value)"
|
||||
<DropdownMenuContent align="end" class="w-80 p-2">
|
||||
<DropdownMenuLabel class="text-muted-foreground text-xs px-1 py-1">Reader Settings</DropdownMenuLabel>
|
||||
|
||||
<div class="px-1 py-1.5 space-y-1.5">
|
||||
<p class="text-[11px] text-muted-foreground">Fit mode</p>
|
||||
<div class="grid grid-cols-2 gap-1">
|
||||
<button
|
||||
v-for="opt in FIT_OPTIONS"
|
||||
:key="opt.value"
|
||||
class="h-7 px-2 rounded-md border text-[11px] transition-colors flex items-center justify-center gap-1"
|
||||
:class="
|
||||
fitMode === opt.value ? 'border-primary text-primary bg-primary/10' : 'border-border text-muted-foreground hover:text-foreground'
|
||||
"
|
||||
@click.stop="setFitMode(opt.value)"
|
||||
>
|
||||
<component :is="opt.icon" :size="11" />
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-1 py-1.5 space-y-1.5">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-[11px] text-muted-foreground">Page view</p>
|
||||
<span
|
||||
v-if="showAutoFallbackBadge"
|
||||
class="inline-flex items-center gap-1 rounded-full border border-border bg-muted px-2 py-0.5 text-[10px] text-muted-foreground"
|
||||
>
|
||||
<Info :size="11" />
|
||||
Auto-fallback active
|
||||
</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-1">
|
||||
<button
|
||||
v-for="opt in VIEW_OPTIONS"
|
||||
:key="opt.value"
|
||||
class="h-7 px-2 rounded-md border text-xs transition-colors flex items-center justify-center gap-1"
|
||||
:class="
|
||||
viewMode === opt.value ? 'border-primary text-primary bg-primary/10' : 'border-border text-muted-foreground hover:text-foreground'
|
||||
"
|
||||
@click.stop="setViewMode(opt.value)"
|
||||
>
|
||||
<component :is="opt.icon" :size="11" />
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-1 py-1.5 space-y-1.5">
|
||||
<p class="text-[11px] text-muted-foreground">Reading direction</p>
|
||||
<div class="grid grid-cols-2 gap-1">
|
||||
<button
|
||||
v-for="opt in DIRECTION_OPTIONS"
|
||||
:key="opt.value"
|
||||
class="h-7 px-2 rounded-md border text-xs transition-colors flex items-center justify-center gap-1"
|
||||
:class="
|
||||
direction === opt.value
|
||||
? 'border-primary text-primary bg-primary/10'
|
||||
: 'border-border text-muted-foreground hover:text-foreground'
|
||||
"
|
||||
@click.stop="setDirection(opt.value)"
|
||||
>
|
||||
<component :is="opt.icon" :size="11" />
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-1 py-1.5 space-y-1.5">
|
||||
<p class="text-[11px] text-muted-foreground">Background</p>
|
||||
<div class="grid grid-cols-3 gap-1">
|
||||
<button
|
||||
v-for="opt in BG_OPTIONS"
|
||||
:key="opt.value"
|
||||
class="h-7 px-2 rounded-md border text-xs transition-colors flex items-center justify-center gap-1"
|
||||
:class="
|
||||
bgColor === opt.value ? 'border-primary text-primary bg-primary/10' : 'border-border text-muted-foreground hover:text-foreground'
|
||||
"
|
||||
@click.stop="setBgColor(opt.value)"
|
||||
>
|
||||
<component :is="opt.icon" :size="11" />
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showSpreadAlignmentControl" class="px-1 py-1.5 space-y-1.5">
|
||||
<p class="text-[11px] text-muted-foreground">Spread alignment</p>
|
||||
<div class="grid grid-cols-2 gap-1">
|
||||
<button
|
||||
v-for="opt in SPREAD_ALIGNMENT_OPTIONS"
|
||||
:key="opt.value"
|
||||
class="h-7 px-2 rounded-md border text-xs transition-colors flex items-center justify-center gap-1"
|
||||
:class="
|
||||
spreadAlignment === opt.value
|
||||
? 'border-primary text-primary bg-primary/10'
|
||||
: 'border-border text-muted-foreground hover:text-foreground'
|
||||
"
|
||||
@click.stop="setSpreadAlignment(opt.value)"
|
||||
>
|
||||
<component :is="opt.icon" :size="11" />
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DropdownMenuItem v-if="showSpreadAlignmentHint" class="text-xs mt-1" @select.prevent="focusForceTwoPageFromHint">
|
||||
<Info :size="13" />
|
||||
Spread alignment unavailable in auto-fallback
|
||||
<span class="ml-auto text-[10px] text-muted-foreground">Focus toggle below</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator class="my-1" />
|
||||
|
||||
<div class="px-1 py-1.5 space-y-1.5">
|
||||
<p class="text-[11px] text-muted-foreground">Scroll mode</p>
|
||||
<div class="grid grid-cols-3 gap-1">
|
||||
<button
|
||||
v-for="opt in SCROLL_OPTIONS"
|
||||
:key="opt.value"
|
||||
class="h-7 px-2 rounded-md border text-[11px] transition-colors flex items-center justify-center gap-1"
|
||||
:class="
|
||||
scrollMode === opt.value
|
||||
? 'border-primary text-primary bg-primary/10'
|
||||
: 'border-border text-muted-foreground hover:text-foreground'
|
||||
"
|
||||
@click.stop="setScrollMode(opt.value)"
|
||||
>
|
||||
<component :is="opt.icon" :size="11" />
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-1 py-1.5 flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-[11px] text-muted-foreground">Force two-page (Bypass mobile fallback)</p>
|
||||
</div>
|
||||
<button
|
||||
ref="forceTwoPageToggleButton"
|
||||
class="h-7 px-3 rounded-md border text-xs transition-colors"
|
||||
:class="[
|
||||
forceTwoPage ? 'border-primary text-primary bg-primary/10' : 'border-border text-muted-foreground hover:text-foreground',
|
||||
highlightForceTwoPage ? 'ring-2 ring-primary/50' : '',
|
||||
]"
|
||||
@click.stop="toggleForceTwoPage"
|
||||
>
|
||||
<component :is="opt.icon" :size="13" />
|
||||
{{ opt.label }}
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
{{ forceTwoPage ? 'On' : 'Off' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<div class="px-1 py-1.5 space-y-1.5">
|
||||
<p class="text-[11px] text-muted-foreground">Wide pages</p>
|
||||
<div class="grid grid-cols-2 gap-1">
|
||||
<button
|
||||
v-for="opt in WIDE_PAGE_OPTIONS"
|
||||
:key="opt.value"
|
||||
class="h-7 px-2 rounded-md border text-[11px] transition-colors flex items-center justify-center gap-1"
|
||||
:class="
|
||||
widePageSingletonMode === opt.value
|
||||
? 'border-primary text-primary bg-primary/10'
|
||||
: 'border-border text-muted-foreground hover:text-foreground'
|
||||
"
|
||||
@click.stop="setWidePageMode(opt.value)"
|
||||
>
|
||||
<component :is="opt.icon" :size="11" />
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DropdownMenuLabel class="text-muted-foreground text-xs px-2 py-1">Pages</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup :model-value="viewMode">
|
||||
<DropdownMenuRadioItem
|
||||
v-for="opt in VIEW_OPTIONS"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
class="text-xs gap-2"
|
||||
@select.prevent="setViewMode(opt.value)"
|
||||
>
|
||||
<component :is="opt.icon" :size="13" />
|
||||
{{ opt.label }}
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuLabel class="text-muted-foreground text-xs px-2 py-1">Scroll Mode</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup :model-value="scrollMode">
|
||||
<DropdownMenuRadioItem
|
||||
v-for="opt in SCROLL_OPTIONS"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
class="text-xs gap-2"
|
||||
@select.prevent="setScrollMode(opt.value)"
|
||||
>
|
||||
<component :is="opt.icon" :size="13" />
|
||||
{{ opt.label }}
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuLabel class="text-muted-foreground text-xs px-2 py-1">Reading Direction</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup :model-value="direction">
|
||||
<DropdownMenuRadioItem
|
||||
v-for="opt in DIRECTION_OPTIONS"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
class="text-xs gap-2"
|
||||
@select.prevent="setDirection(opt.value)"
|
||||
>
|
||||
<component :is="opt.icon" :size="13" />
|
||||
{{ opt.label }}
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuLabel class="text-muted-foreground text-xs px-2 py-1">Background</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup :model-value="bgColor">
|
||||
<DropdownMenuRadioItem
|
||||
v-for="opt in BG_OPTIONS"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
class="text-xs gap-2"
|
||||
@select.prevent="setBgColor(opt.value)"
|
||||
>
|
||||
<span class="size-3 rounded-full ring-1 ring-border shrink-0" :class="opt.color" />
|
||||
{{ opt.label }}
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
<DropdownMenuSeparator class="my-1" />
|
||||
<DropdownMenuItem class="text-xs text-destructive focus:text-destructive" @select.prevent="confirmResetBookViewSettings">
|
||||
Reset book view settings
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
@@ -429,19 +736,38 @@ onUnmounted(() => {
|
||||
<div class="w-8 h-8 rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground 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' } : {}"
|
||||
>
|
||||
<div class="flex items-center justify-center h-full w-full gap-0.5 px-1">
|
||||
<template v-if="renderSpread">
|
||||
<div class="h-full w-1/2 flex items-center justify-center">
|
||||
<img
|
||||
v-if="renderLeftPage !== null"
|
||||
:src="pageUrl(renderLeftPage)"
|
||||
:class="[imgFitClass, 'pointer-events-none transition-opacity duration-150', currentImageLoaded ? 'opacity-100' : 'opacity-0']"
|
||||
:style="{ maxWidth: '100%', maxHeight: '100%' }"
|
||||
draggable="false"
|
||||
@load="onPaginatedImageLoad(renderLeftPage, $event)"
|
||||
/>
|
||||
<div v-else class="h-[92%] w-[92%] rounded-sm border border-border/60 bg-background/30" />
|
||||
</div>
|
||||
<div class="h-full w-1/2 flex items-center justify-center">
|
||||
<img
|
||||
v-if="renderRightPage !== null"
|
||||
:src="pageUrl(renderRightPage)"
|
||||
:class="[imgFitClass, 'pointer-events-none transition-opacity duration-150', currentImageLoaded ? 'opacity-100' : 'opacity-0']"
|
||||
:style="{ maxWidth: '100%', maxHeight: '100%' }"
|
||||
draggable="false"
|
||||
@load="onPaginatedImageLoad(renderRightPage, $event)"
|
||||
/>
|
||||
<div v-else class="h-[92%] w-[92%] rounded-sm border border-border/60 bg-background/30" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<img
|
||||
v-for="pg in visiblePages"
|
||||
:key="pg"
|
||||
:src="pageUrl(pg)"
|
||||
v-else-if="renderSinglePage !== null"
|
||||
:src="pageUrl(renderSinglePage)"
|
||||
: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"
|
||||
@load="onPaginatedImageLoad(renderSinglePage, $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -462,6 +788,7 @@ onUnmounted(() => {
|
||||
:class="scrollMode === 'long-strip' ? 'w-full block' : 'max-w-full'"
|
||||
loading="lazy"
|
||||
draggable="false"
|
||||
@load="onStripImageLoad(i - 1, $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -478,7 +805,7 @@ onUnmounted(() => {
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>First page</TooltipContent>
|
||||
</Tooltip>
|
||||
<button class="viewer-btn" :disabled="currentPage === 0" @click="prevPage"><ChevronLeft :size="16" /></button>
|
||||
<button class="viewer-btn" :disabled="!canGoPrev" @click="prevPage"><ChevronLeft :size="16" /></button>
|
||||
|
||||
<div class="flex-1 flex flex-col justify-center gap-0.5">
|
||||
<input
|
||||
@@ -496,7 +823,7 @@ onUnmounted(() => {
|
||||
</datalist>
|
||||
</div>
|
||||
|
||||
<button class="viewer-btn" :disabled="currentPage >= pageCount - 1" @click="nextPage"><ChevronRight :size="16" /></button>
|
||||
<button class="viewer-btn" :disabled="!canGoNext" @click="nextPage"><ChevronRight :size="16" /></button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button class="viewer-btn" @click="goToPage(pageCount - 1)"><ChevronsRight :size="16" /></button>
|
||||
@@ -524,7 +851,7 @@ onUnmounted(() => {
|
||||
|
||||
<!-- 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: `${((currentPage + 1) / pageCount) * 100}%` }" />
|
||||
<div class="h-full bg-primary/60 transition-all duration-300" :style="{ width: `${progressPercent}%` }" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -4,6 +4,8 @@ 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 SpreadAlignment = 'normal' | 'shifted'
|
||||
export type WidePageSingletonMode = 'auto' | 'disable'
|
||||
export type BgColor = 'black' | 'gray' | 'white'
|
||||
|
||||
export function useCbzSettings() {
|
||||
@@ -11,6 +13,9 @@ export function useCbzSettings() {
|
||||
const viewMode = ref<ViewMode>('single')
|
||||
const scrollMode = ref<ScrollMode>('paginated')
|
||||
const direction = ref<Direction>('ltr')
|
||||
const spreadAlignment = ref<SpreadAlignment>('normal')
|
||||
const forceTwoPage = ref(false)
|
||||
const widePageSingletonMode = ref<WidePageSingletonMode>('auto')
|
||||
const bgColor = ref<BgColor>('black')
|
||||
|
||||
const bgValue = computed(() => {
|
||||
@@ -34,5 +39,17 @@ export function useCbzSettings() {
|
||||
}
|
||||
})
|
||||
|
||||
return { fitMode, viewMode, scrollMode, direction, bgColor, bgValue, isTwoPage, imgFitClass }
|
||||
return {
|
||||
fitMode,
|
||||
viewMode,
|
||||
scrollMode,
|
||||
direction,
|
||||
spreadAlignment,
|
||||
forceTwoPage,
|
||||
widePageSingletonMode,
|
||||
bgColor,
|
||||
bgValue,
|
||||
isTwoPage,
|
||||
imgFitClass,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createCbzSpreadLayout } from '../spread-layout'
|
||||
|
||||
function digest(layout: ReturnType<typeof createCbzSpreadLayout>) {
|
||||
return layout.spreads.map((spread) => ({
|
||||
kind: spread.kind,
|
||||
anchorPage: spread.anchorPage,
|
||||
pages: spread.pages,
|
||||
singlePage: spread.singlePage,
|
||||
leftPage: spread.leftPage,
|
||||
rightPage: spread.rightPage,
|
||||
hasVirtualBlank: spread.hasVirtualBlank,
|
||||
}))
|
||||
}
|
||||
|
||||
describe('createCbzSpreadLayout', () => {
|
||||
it('builds single-page layout when two-page is not effective', () => {
|
||||
const layout = createCbzSpreadLayout({
|
||||
pageCount: 4,
|
||||
isTwoPageEffective: false,
|
||||
direction: 'ltr',
|
||||
spreadAlignment: 'normal',
|
||||
widePageSingletonMode: 'auto',
|
||||
isWidePage: () => false,
|
||||
})
|
||||
|
||||
expect(digest(layout)).toEqual([
|
||||
{ kind: 'single', anchorPage: 0, pages: [0], singlePage: 0, leftPage: null, rightPage: null, hasVirtualBlank: false },
|
||||
{ kind: 'single', anchorPage: 1, pages: [1], singlePage: 1, leftPage: null, rightPage: null, hasVirtualBlank: false },
|
||||
{ kind: 'single', anchorPage: 2, pages: [2], singlePage: 2, leftPage: null, rightPage: null, hasVirtualBlank: false },
|
||||
{ kind: 'single', anchorPage: 3, pages: [3], singlePage: 3, leftPage: null, rightPage: null, hasVirtualBlank: false },
|
||||
])
|
||||
})
|
||||
|
||||
it('uses cover-alone + strict LTR parity and inserts a virtual blank at the end', () => {
|
||||
const layout = createCbzSpreadLayout({
|
||||
pageCount: 6,
|
||||
isTwoPageEffective: true,
|
||||
direction: 'ltr',
|
||||
spreadAlignment: 'normal',
|
||||
widePageSingletonMode: 'disable',
|
||||
isWidePage: () => false,
|
||||
})
|
||||
|
||||
expect(digest(layout)).toEqual([
|
||||
{ kind: 'single', anchorPage: 0, pages: [0], singlePage: 0, leftPage: null, rightPage: null, hasVirtualBlank: false },
|
||||
{ kind: 'spread', anchorPage: 1, pages: [1, 2], singlePage: null, leftPage: 1, rightPage: 2, hasVirtualBlank: false },
|
||||
{ kind: 'spread', anchorPage: 3, pages: [3, 4], singlePage: null, leftPage: 3, rightPage: 4, hasVirtualBlank: false },
|
||||
{ kind: 'spread', anchorPage: 5, pages: [5], singlePage: null, leftPage: 5, rightPage: null, hasVirtualBlank: true },
|
||||
])
|
||||
})
|
||||
|
||||
it('mirrors spread placement for RTL while preserving story-order anchors', () => {
|
||||
const layout = createCbzSpreadLayout({
|
||||
pageCount: 6,
|
||||
isTwoPageEffective: true,
|
||||
direction: 'rtl',
|
||||
spreadAlignment: 'normal',
|
||||
widePageSingletonMode: 'disable',
|
||||
isWidePage: () => false,
|
||||
})
|
||||
|
||||
expect(digest(layout)).toEqual([
|
||||
{ kind: 'single', anchorPage: 0, pages: [0], singlePage: 0, leftPage: null, rightPage: null, hasVirtualBlank: false },
|
||||
{ kind: 'spread', anchorPage: 1, pages: [1, 2], singlePage: null, leftPage: 2, rightPage: 1, hasVirtualBlank: false },
|
||||
{ kind: 'spread', anchorPage: 3, pages: [3, 4], singlePage: null, leftPage: 4, rightPage: 3, hasVirtualBlank: false },
|
||||
{ kind: 'spread', anchorPage: 5, pages: [5], singlePage: null, leftPage: null, rightPage: 5, hasVirtualBlank: true },
|
||||
])
|
||||
})
|
||||
|
||||
it('applies shifted alignment after cover, then resumes pairing', () => {
|
||||
const layout = createCbzSpreadLayout({
|
||||
pageCount: 7,
|
||||
isTwoPageEffective: true,
|
||||
direction: 'ltr',
|
||||
spreadAlignment: 'shifted',
|
||||
widePageSingletonMode: 'disable',
|
||||
isWidePage: () => false,
|
||||
})
|
||||
|
||||
expect(digest(layout)).toEqual([
|
||||
{ kind: 'single', anchorPage: 0, pages: [0], singlePage: 0, leftPage: null, rightPage: null, hasVirtualBlank: false },
|
||||
{ kind: 'single', anchorPage: 1, pages: [1], singlePage: 1, leftPage: null, rightPage: null, hasVirtualBlank: false },
|
||||
{ kind: 'spread', anchorPage: 2, pages: [2, 3], singlePage: null, leftPage: 2, rightPage: 3, hasVirtualBlank: false },
|
||||
{ kind: 'spread', anchorPage: 4, pages: [4, 5], singlePage: null, leftPage: 4, rightPage: 5, hasVirtualBlank: false },
|
||||
{ kind: 'spread', anchorPage: 6, pages: [6], singlePage: null, leftPage: 6, rightPage: null, hasVirtualBlank: true },
|
||||
])
|
||||
})
|
||||
|
||||
it('supports shifted alignment in RTL with mirrored spread placement', () => {
|
||||
const layout = createCbzSpreadLayout({
|
||||
pageCount: 6,
|
||||
isTwoPageEffective: true,
|
||||
direction: 'rtl',
|
||||
spreadAlignment: 'shifted',
|
||||
widePageSingletonMode: 'disable',
|
||||
isWidePage: () => false,
|
||||
})
|
||||
|
||||
expect(digest(layout)).toEqual([
|
||||
{ kind: 'single', anchorPage: 0, pages: [0], singlePage: 0, leftPage: null, rightPage: null, hasVirtualBlank: false },
|
||||
{ kind: 'single', anchorPage: 1, pages: [1], singlePage: 1, leftPage: null, rightPage: null, hasVirtualBlank: false },
|
||||
{ kind: 'spread', anchorPage: 2, pages: [2, 3], singlePage: null, leftPage: 3, rightPage: 2, hasVirtualBlank: false },
|
||||
{ kind: 'spread', anchorPage: 4, pages: [4, 5], singlePage: null, leftPage: 5, rightPage: 4, hasVirtualBlank: false },
|
||||
])
|
||||
})
|
||||
|
||||
it('shows wide pages alone and keeps progression deterministic', () => {
|
||||
const layout = createCbzSpreadLayout({
|
||||
pageCount: 8,
|
||||
isTwoPageEffective: true,
|
||||
direction: 'ltr',
|
||||
spreadAlignment: 'normal',
|
||||
widePageSingletonMode: 'auto',
|
||||
isWidePage: (page) => page === 3 || page === 4,
|
||||
})
|
||||
|
||||
expect(digest(layout)).toEqual([
|
||||
{ kind: 'single', anchorPage: 0, pages: [0], singlePage: 0, leftPage: null, rightPage: null, hasVirtualBlank: false },
|
||||
{ kind: 'spread', anchorPage: 1, pages: [1, 2], singlePage: null, leftPage: 1, rightPage: 2, hasVirtualBlank: false },
|
||||
{ kind: 'single', anchorPage: 3, pages: [3], singlePage: 3, leftPage: null, rightPage: null, hasVirtualBlank: false },
|
||||
{ kind: 'single', anchorPage: 4, pages: [4], singlePage: 4, leftPage: null, rightPage: null, hasVirtualBlank: false },
|
||||
{ kind: 'spread', anchorPage: 5, pages: [5, 6], singlePage: null, leftPage: 5, rightPage: 6, hasVirtualBlank: false },
|
||||
{ kind: 'spread', anchorPage: 7, pages: [7], singlePage: null, leftPage: 7, rightPage: null, hasVirtualBlank: true },
|
||||
])
|
||||
})
|
||||
|
||||
it('inserts a virtual blank when the second page in a pair is wide', () => {
|
||||
const layout = createCbzSpreadLayout({
|
||||
pageCount: 6,
|
||||
isTwoPageEffective: true,
|
||||
direction: 'ltr',
|
||||
spreadAlignment: 'normal',
|
||||
widePageSingletonMode: 'auto',
|
||||
isWidePage: (page) => page === 2,
|
||||
})
|
||||
|
||||
expect(digest(layout)).toEqual([
|
||||
{ kind: 'single', anchorPage: 0, pages: [0], singlePage: 0, leftPage: null, rightPage: null, hasVirtualBlank: false },
|
||||
{ kind: 'spread', anchorPage: 1, pages: [1], singlePage: null, leftPage: 1, rightPage: null, hasVirtualBlank: true },
|
||||
{ kind: 'single', anchorPage: 2, pages: [2], singlePage: 2, leftPage: null, rightPage: null, hasVirtualBlank: false },
|
||||
{ kind: 'spread', anchorPage: 3, pages: [3, 4], singlePage: null, leftPage: 3, rightPage: 4, hasVirtualBlank: false },
|
||||
{ kind: 'spread', anchorPage: 5, pages: [5], singlePage: null, leftPage: 5, rightPage: null, hasVirtualBlank: true },
|
||||
])
|
||||
})
|
||||
|
||||
it('anchors any page to the containing spread and supports spread-step navigation', () => {
|
||||
const layout = createCbzSpreadLayout({
|
||||
pageCount: 10,
|
||||
isTwoPageEffective: true,
|
||||
direction: 'ltr',
|
||||
spreadAlignment: 'normal',
|
||||
widePageSingletonMode: 'disable',
|
||||
isWidePage: () => false,
|
||||
})
|
||||
|
||||
expect(layout.anchorForPage(4)).toBe(3)
|
||||
expect(layout.anchorForPage(5)).toBe(5)
|
||||
expect(layout.anchorForPage(9)).toBe(9)
|
||||
|
||||
expect(layout.prevAnchor(5)).toBe(3)
|
||||
expect(layout.nextAnchor(5)).toBe(7)
|
||||
expect(layout.prevAnchor(0)).toBe(0)
|
||||
expect(layout.nextAnchor(9)).toBe(9)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,186 @@
|
||||
export type CbzReadingDirection = 'ltr' | 'rtl'
|
||||
export type CbzSpreadAlignment = 'normal' | 'shifted'
|
||||
export type CbzWidePageSingletonMode = 'auto' | 'disable'
|
||||
export type CbzSpreadKind = 'single' | 'spread'
|
||||
|
||||
export const DEFAULT_WIDE_PAGE_RATIO_THRESHOLD = 1.2
|
||||
|
||||
export interface CbzSpread {
|
||||
kind: CbzSpreadKind
|
||||
anchorPage: number
|
||||
pages: number[]
|
||||
singlePage: number | null
|
||||
leftPage: number | null
|
||||
rightPage: number | null
|
||||
hasVirtualBlank: boolean
|
||||
}
|
||||
|
||||
export interface CreateCbzSpreadLayoutInput {
|
||||
pageCount: number
|
||||
isTwoPageEffective: boolean
|
||||
direction: CbzReadingDirection
|
||||
spreadAlignment: CbzSpreadAlignment
|
||||
widePageSingletonMode: CbzWidePageSingletonMode
|
||||
isWidePage: (pageIndex: number) => boolean
|
||||
}
|
||||
|
||||
export interface CbzSpreadLayout {
|
||||
spreads: CbzSpread[]
|
||||
anchorForPage: (pageIndex: number) => number
|
||||
spreadIndexForPage: (pageIndex: number) => number
|
||||
spreadForPage: (pageIndex: number) => CbzSpread | null
|
||||
spreadForAnchor: (anchorPage: number) => CbzSpread | null
|
||||
nextAnchor: (anchorPage: number) => number
|
||||
prevAnchor: (anchorPage: number) => number
|
||||
}
|
||||
|
||||
function clampPage(pageIndex: number, pageCount: number): number {
|
||||
if (pageCount <= 0) return 0
|
||||
if (pageIndex < 0) return 0
|
||||
if (pageIndex >= pageCount) return pageCount - 1
|
||||
return pageIndex
|
||||
}
|
||||
|
||||
function makeSingle(pageIndex: number): CbzSpread {
|
||||
return {
|
||||
kind: 'single',
|
||||
anchorPage: pageIndex,
|
||||
pages: [pageIndex],
|
||||
singlePage: pageIndex,
|
||||
leftPage: null,
|
||||
rightPage: null,
|
||||
hasVirtualBlank: false,
|
||||
}
|
||||
}
|
||||
|
||||
function makeSpread(direction: CbzReadingDirection, firstPage: number, secondPage: number | null, hasVirtualBlank: boolean): CbzSpread {
|
||||
return {
|
||||
kind: 'spread',
|
||||
anchorPage: firstPage,
|
||||
pages: secondPage === null ? [firstPage] : [firstPage, secondPage],
|
||||
singlePage: null,
|
||||
leftPage: direction === 'ltr' ? firstPage : secondPage,
|
||||
rightPage: direction === 'ltr' ? secondPage : firstPage,
|
||||
hasVirtualBlank,
|
||||
}
|
||||
}
|
||||
|
||||
function buildSpreads(input: CreateCbzSpreadLayoutInput): CbzSpread[] {
|
||||
const { pageCount, isTwoPageEffective, direction, spreadAlignment, widePageSingletonMode, isWidePage } = input
|
||||
if (pageCount <= 0) return []
|
||||
|
||||
if (!isTwoPageEffective) {
|
||||
const singles: CbzSpread[] = []
|
||||
for (let page = 0; page < pageCount; page++) singles.push(makeSingle(page))
|
||||
return singles
|
||||
}
|
||||
|
||||
const spreads: CbzSpread[] = []
|
||||
|
||||
// Cover is always shown alone.
|
||||
spreads.push(makeSingle(0))
|
||||
let cursor = 1
|
||||
|
||||
// Shifted alignment inserts a singleton after the cover, then resumes pairing.
|
||||
if (spreadAlignment === 'shifted' && cursor < pageCount) {
|
||||
spreads.push(makeSingle(cursor))
|
||||
cursor += 1
|
||||
}
|
||||
|
||||
while (cursor < pageCount) {
|
||||
const first = cursor
|
||||
const second = cursor + 1
|
||||
|
||||
if (widePageSingletonMode === 'auto' && isWidePage(first)) {
|
||||
spreads.push(makeSingle(first))
|
||||
cursor += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (second >= pageCount) {
|
||||
spreads.push(makeSpread(direction, first, null, true))
|
||||
cursor += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (widePageSingletonMode === 'auto' && isWidePage(second)) {
|
||||
spreads.push(makeSpread(direction, first, null, true))
|
||||
cursor += 1
|
||||
continue
|
||||
}
|
||||
|
||||
spreads.push(makeSpread(direction, first, second, false))
|
||||
cursor += 2
|
||||
}
|
||||
|
||||
return spreads
|
||||
}
|
||||
|
||||
export function createCbzSpreadLayout(input: CreateCbzSpreadLayoutInput): CbzSpreadLayout {
|
||||
const spreads = buildSpreads(input)
|
||||
const pageCount = input.pageCount
|
||||
|
||||
const pageToSpreadIndex = Array.from({ length: Math.max(0, pageCount) }, () => 0)
|
||||
const anchorToSpreadIndex = new Map<number, number>()
|
||||
|
||||
for (let spreadIndex = 0; spreadIndex < spreads.length; spreadIndex++) {
|
||||
const spread = spreads[spreadIndex]
|
||||
if (!spread) continue
|
||||
anchorToSpreadIndex.set(spread.anchorPage, spreadIndex)
|
||||
for (const page of spread.pages) {
|
||||
if (page >= 0 && page < pageToSpreadIndex.length) pageToSpreadIndex[page] = spreadIndex
|
||||
}
|
||||
}
|
||||
|
||||
function spreadIndexForPage(pageIndex: number): number {
|
||||
if (spreads.length === 0 || pageToSpreadIndex.length === 0) return 0
|
||||
const clamped = clampPage(pageIndex, pageCount)
|
||||
return pageToSpreadIndex[clamped] ?? 0
|
||||
}
|
||||
|
||||
function anchorForPage(pageIndex: number): number {
|
||||
if (spreads.length === 0) return 0
|
||||
const spread = spreads[spreadIndexForPage(pageIndex)]
|
||||
return spread?.anchorPage ?? 0
|
||||
}
|
||||
|
||||
function spreadForPage(pageIndex: number): CbzSpread | null {
|
||||
if (spreads.length === 0) return null
|
||||
return spreads[spreadIndexForPage(pageIndex)] ?? null
|
||||
}
|
||||
|
||||
function spreadForAnchor(anchorPage: number): CbzSpread | null {
|
||||
if (spreads.length === 0) return null
|
||||
const spreadIndex = anchorToSpreadIndex.get(clampPage(anchorPage, pageCount))
|
||||
if (spreadIndex === undefined) return spreadForPage(anchorPage)
|
||||
return spreads[spreadIndex] ?? null
|
||||
}
|
||||
|
||||
function nextAnchor(anchorPage: number): number {
|
||||
if (spreads.length === 0) return 0
|
||||
const spread = spreadForAnchor(anchorPage)
|
||||
if (!spread) return 0
|
||||
const currentIndex = anchorToSpreadIndex.get(spread.anchorPage) ?? 0
|
||||
const next = spreads[Math.min(currentIndex + 1, spreads.length - 1)]
|
||||
return next?.anchorPage ?? spread.anchorPage
|
||||
}
|
||||
|
||||
function prevAnchor(anchorPage: number): number {
|
||||
if (spreads.length === 0) return 0
|
||||
const spread = spreadForAnchor(anchorPage)
|
||||
if (!spread) return 0
|
||||
const currentIndex = anchorToSpreadIndex.get(spread.anchorPage) ?? 0
|
||||
const prev = spreads[Math.max(currentIndex - 1, 0)]
|
||||
return prev?.anchorPage ?? spread.anchorPage
|
||||
}
|
||||
|
||||
return {
|
||||
spreads,
|
||||
anchorForPage,
|
||||
spreadIndexForPage,
|
||||
spreadForPage,
|
||||
spreadForAnchor,
|
||||
nextAnchor,
|
||||
prevAnchor,
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,14 @@ import { computed, ref } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { api } from '@/lib/api'
|
||||
import { useAuth } from '@/features/auth/composables/useAuth'
|
||||
import { type ReaderFormatGroup, type ReaderSettings, READER_GROUP_DEFAULTS, getFormatGroup } from '@projectx/types'
|
||||
import {
|
||||
CBX_READER_DEFAULTS,
|
||||
type CbxReaderSettings,
|
||||
type ReaderFormatGroup,
|
||||
type ReaderSettings,
|
||||
READER_GROUP_DEFAULTS,
|
||||
getFormatGroup,
|
||||
} from '@projectx/types'
|
||||
|
||||
// -- Shared localStorage helpers --
|
||||
|
||||
@@ -26,6 +33,65 @@ function removeLs(key: string): void {
|
||||
localStorage.removeItem(key)
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
function jsonEqual(a: unknown, b: unknown): boolean {
|
||||
return JSON.stringify(a) === JSON.stringify(b)
|
||||
}
|
||||
|
||||
function sanitizeCbxPartialSettings(settings: unknown): Partial<CbxReaderSettings> | null {
|
||||
if (!isRecord(settings)) return null
|
||||
|
||||
const out: Partial<CbxReaderSettings> = {}
|
||||
|
||||
if (settings.fitMode === 'fit-page' || settings.fitMode === 'fit-width' || settings.fitMode === 'fit-height' || settings.fitMode === 'actual') {
|
||||
out.fitMode = settings.fitMode
|
||||
}
|
||||
if (settings.viewMode === 'single' || settings.viewMode === 'two-page') {
|
||||
out.viewMode = settings.viewMode
|
||||
}
|
||||
if (settings.scrollMode === 'paginated' || settings.scrollMode === 'infinite' || settings.scrollMode === 'long-strip') {
|
||||
out.scrollMode = settings.scrollMode
|
||||
}
|
||||
if (settings.direction === 'ltr' || settings.direction === 'rtl') {
|
||||
out.direction = settings.direction
|
||||
}
|
||||
if (settings.spreadAlignment === 'normal' || settings.spreadAlignment === 'shifted') {
|
||||
out.spreadAlignment = settings.spreadAlignment
|
||||
}
|
||||
if (typeof settings.forceTwoPage === 'boolean') {
|
||||
out.forceTwoPage = settings.forceTwoPage
|
||||
}
|
||||
if (settings.widePageSingletonMode === 'auto' || settings.widePageSingletonMode === 'disable') {
|
||||
out.widePageSingletonMode = settings.widePageSingletonMode
|
||||
}
|
||||
if (settings.bgColor === 'black' || settings.bgColor === 'gray' || settings.bgColor === 'white') {
|
||||
out.bgColor = settings.bgColor
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
function sanitizeBookDelta(group: ReaderFormatGroup, raw: unknown): Partial<ReaderSettings> | null {
|
||||
if (group !== 'cbx') return isRecord(raw) ? (raw as Partial<ReaderSettings>) : null
|
||||
const sanitized = sanitizeCbxPartialSettings(raw)
|
||||
return sanitized as Partial<ReaderSettings> | null
|
||||
}
|
||||
|
||||
function sanitizeDefaultSettings(group: ReaderFormatGroup, raw: unknown): ReaderSettings | null {
|
||||
if (group !== 'cbx') return isRecord(raw) ? (raw as unknown as ReaderSettings) : null
|
||||
|
||||
const sanitized = sanitizeCbxPartialSettings(raw)
|
||||
if (!sanitized) return null
|
||||
|
||||
return {
|
||||
...CBX_READER_DEFAULTS,
|
||||
...sanitized,
|
||||
} as ReaderSettings
|
||||
}
|
||||
|
||||
// -- Per-book settings (used inside the reader) --
|
||||
|
||||
export function useReaderSettings(bookFileId: number, format: string) {
|
||||
@@ -54,11 +120,26 @@ export function useReaderSettings(bookFileId: number, format: string) {
|
||||
const lsDefault = readLs<ReaderSettings>(lsDefaultKey(group))
|
||||
|
||||
if (lsBook) {
|
||||
bookDelta.value = lsBook
|
||||
isCustomized.value = Object.keys(lsBook).length > 0
|
||||
const sanitized = sanitizeBookDelta(group, lsBook)
|
||||
if (sanitized && Object.keys(sanitized).length > 0) {
|
||||
bookDelta.value = sanitized
|
||||
isCustomized.value = true
|
||||
if (!jsonEqual(lsBook, sanitized)) writeLs(lsBookKey(bookFileId), sanitized)
|
||||
} else {
|
||||
bookDelta.value = null
|
||||
isCustomized.value = false
|
||||
removeLs(lsBookKey(bookFileId))
|
||||
}
|
||||
}
|
||||
if (lsDefault) {
|
||||
defaultSettings.value = lsDefault
|
||||
const sanitized = sanitizeDefaultSettings(group, lsDefault)
|
||||
if (sanitized) {
|
||||
defaultSettings.value = sanitized
|
||||
if (!jsonEqual(lsDefault, sanitized)) writeLs(lsDefaultKey(group), sanitized)
|
||||
} else {
|
||||
defaultSettings.value = null
|
||||
removeLs(lsDefaultKey(group))
|
||||
}
|
||||
}
|
||||
|
||||
if (syncEnabled.value) {
|
||||
@@ -73,13 +154,26 @@ export function useReaderSettings(bookFileId: number, format: string) {
|
||||
])
|
||||
|
||||
if (prefRes?.settings) {
|
||||
bookDelta.value = prefRes.settings as Partial<ReaderSettings>
|
||||
isCustomized.value = Object.keys(prefRes.settings).length > 0
|
||||
writeLs(lsBookKey(bookFileId), prefRes.settings)
|
||||
const sanitized = sanitizeBookDelta(group, prefRes.settings)
|
||||
if (sanitized && Object.keys(sanitized).length > 0) {
|
||||
bookDelta.value = sanitized
|
||||
isCustomized.value = true
|
||||
writeLs(lsBookKey(bookFileId), sanitized)
|
||||
} else {
|
||||
bookDelta.value = null
|
||||
isCustomized.value = false
|
||||
removeLs(lsBookKey(bookFileId))
|
||||
}
|
||||
}
|
||||
if (defRes?.[group]) {
|
||||
defaultSettings.value = defRes[group] as ReaderSettings
|
||||
writeLs(lsDefaultKey(group), defRes[group])
|
||||
const sanitized = sanitizeDefaultSettings(group, defRes[group])
|
||||
if (sanitized) {
|
||||
defaultSettings.value = sanitized
|
||||
writeLs(lsDefaultKey(group), sanitized)
|
||||
} else {
|
||||
defaultSettings.value = null
|
||||
removeLs(lsDefaultKey(group))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,15 +251,30 @@ export function useReaderDefaultSettings<T extends ReaderSettings>(format: strin
|
||||
|
||||
async function load() {
|
||||
const ls = readLs<T>(lsDefaultKey(group))
|
||||
if (ls) settings.value = ls
|
||||
if (ls) {
|
||||
const sanitized = sanitizeDefaultSettings(group, ls)
|
||||
if (sanitized) {
|
||||
settings.value = sanitized as T
|
||||
if (!jsonEqual(ls, sanitized)) writeLs(lsDefaultKey(group), sanitized)
|
||||
} else {
|
||||
settings.value = null
|
||||
removeLs(lsDefaultKey(group))
|
||||
}
|
||||
}
|
||||
|
||||
if (syncEnabled.value) {
|
||||
const res = await api('/api/v1/reader/defaults')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
if (data[group]) {
|
||||
settings.value = data[group] as T
|
||||
writeLs(lsDefaultKey(group), data[group])
|
||||
const sanitized = sanitizeDefaultSettings(group, data[group])
|
||||
if (sanitized) {
|
||||
settings.value = sanitized as T
|
||||
writeLs(lsDefaultKey(group), sanitized)
|
||||
} else {
|
||||
settings.value = null
|
||||
removeLs(lsDefaultKey(group))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,6 +140,88 @@ onMounted(load)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Spread alignment -->
|
||||
<div class="flex items-center justify-between px-5 py-4 bg-card">
|
||||
<div>
|
||||
<p class="settings-label">Spread alignment</p>
|
||||
<p class="settings-hint">Shift pairing by one page after the cover for off-by-one scans</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 p-1 rounded-lg border border-border bg-muted/50">
|
||||
<button
|
||||
class="px-3 py-1.5 rounded-md text-xs font-medium transition-colors"
|
||||
:class="
|
||||
effective.spreadAlignment === 'normal' ? 'bg-background shadow-sm text-foreground' : 'text-muted-foreground hover:text-foreground'
|
||||
"
|
||||
@click="update({ spreadAlignment: 'normal' })"
|
||||
>
|
||||
Normal
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-1.5 rounded-md text-xs font-medium transition-colors"
|
||||
:class="
|
||||
effective.spreadAlignment === 'shifted' ? 'bg-background shadow-sm text-foreground' : 'text-muted-foreground hover:text-foreground'
|
||||
"
|
||||
@click="update({ spreadAlignment: 'shifted' })"
|
||||
>
|
||||
Shifted
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Wide page handling -->
|
||||
<div class="flex items-center justify-between px-5 py-4 bg-card">
|
||||
<div>
|
||||
<p class="settings-label">Wide-page handling</p>
|
||||
<p class="settings-hint">Auto shows wide scans alone in two-page paginated mode</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 p-1 rounded-lg border border-border bg-muted/50">
|
||||
<button
|
||||
class="px-3 py-1.5 rounded-md text-xs font-medium transition-colors"
|
||||
:class="
|
||||
effective.widePageSingletonMode === 'auto' ? 'bg-background shadow-sm text-foreground' : 'text-muted-foreground hover:text-foreground'
|
||||
"
|
||||
@click="update({ widePageSingletonMode: 'auto' })"
|
||||
>
|
||||
Auto
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-1.5 rounded-md text-xs font-medium transition-colors"
|
||||
:class="
|
||||
effective.widePageSingletonMode === 'disable'
|
||||
? 'bg-background shadow-sm text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
"
|
||||
@click="update({ widePageSingletonMode: 'disable' })"
|
||||
>
|
||||
Disable
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Force two-page -->
|
||||
<div class="flex items-center justify-between px-5 py-4 bg-card">
|
||||
<div>
|
||||
<p class="settings-label">Force two-page on small screens</p>
|
||||
<p class="settings-hint">Bypass mobile auto-fallback when paginated two-page mode is selected</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 p-1 rounded-lg border border-border bg-muted/50">
|
||||
<button
|
||||
class="px-3 py-1.5 rounded-md text-xs font-medium transition-colors"
|
||||
:class="!effective.forceTwoPage ? 'bg-background shadow-sm text-foreground' : 'text-muted-foreground hover:text-foreground'"
|
||||
@click="update({ forceTwoPage: false })"
|
||||
>
|
||||
Off
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-1.5 rounded-md text-xs font-medium transition-colors"
|
||||
:class="effective.forceTwoPage ? 'bg-background shadow-sm text-foreground' : 'text-muted-foreground hover:text-foreground'"
|
||||
@click="update({ forceTwoPage: true })"
|
||||
>
|
||||
On
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -67,6 +67,9 @@ export interface CbxReaderSettings {
|
||||
viewMode: "single" | "two-page";
|
||||
scrollMode: "paginated" | "infinite" | "long-strip";
|
||||
direction: "ltr" | "rtl";
|
||||
spreadAlignment: "normal" | "shifted";
|
||||
forceTwoPage: boolean;
|
||||
widePageSingletonMode: "auto" | "disable";
|
||||
bgColor: "black" | "gray" | "white";
|
||||
}
|
||||
|
||||
@@ -115,6 +118,9 @@ export const CBX_READER_DEFAULTS: CbxReaderSettings = {
|
||||
viewMode: "single",
|
||||
scrollMode: "paginated",
|
||||
direction: "ltr",
|
||||
spreadAlignment: "normal",
|
||||
forceTwoPage: false,
|
||||
widePageSingletonMode: "auto",
|
||||
bgColor: "black",
|
||||
};
|
||||
|
||||
|
||||
@@ -32,6 +32,17 @@ const validPdfDefaults = {
|
||||
rotation: 0,
|
||||
} as const;
|
||||
|
||||
const validCbxDefaults = {
|
||||
fitMode: 'fit-page',
|
||||
viewMode: 'single',
|
||||
scrollMode: 'paginated',
|
||||
direction: 'ltr',
|
||||
spreadAlignment: 'normal',
|
||||
forceTwoPage: false,
|
||||
widePageSingletonMode: 'auto',
|
||||
bgColor: 'black',
|
||||
} as const;
|
||||
|
||||
const mockRepo = {
|
||||
findPreference: vi.fn<(...args: [number, number]) => Promise<{ settings: Record<string, unknown> } | null>>(),
|
||||
upsertPreference: vi.fn<(...args: [number, number, Record<string, unknown>]) => Promise<void>>(),
|
||||
@@ -135,6 +146,12 @@ describe('ReaderPreferencesService', () => {
|
||||
expect(mockRepo.upsertDefault).toHaveBeenCalledWith(3, 'pdf', validPdfDefaults);
|
||||
});
|
||||
|
||||
it('validates full cbx default payloads including spread settings', async () => {
|
||||
await service.upsertDefault(3, 'cbx', validCbxDefaults);
|
||||
|
||||
expect(mockRepo.upsertDefault).toHaveBeenCalledWith(3, 'cbx', validCbxDefaults);
|
||||
});
|
||||
|
||||
it('rejects default writes for invalid format groups', async () => {
|
||||
await expect(service.upsertDefault(3, 'video', validPdfDefaults)).rejects.toThrow(BadRequestException);
|
||||
expect(mockRepo.upsertDefault).not.toHaveBeenCalled();
|
||||
@@ -145,6 +162,19 @@ describe('ReaderPreferencesService', () => {
|
||||
expect(mockRepo.upsertDefault).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects cbx defaults that omit required spread settings', async () => {
|
||||
await expect(
|
||||
service.upsertDefault(3, 'cbx', {
|
||||
fitMode: 'fit-page',
|
||||
viewMode: 'single',
|
||||
scrollMode: 'paginated',
|
||||
direction: 'ltr',
|
||||
bgColor: 'black',
|
||||
}),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
expect(mockRepo.upsertDefault).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('normalizes format group values when deleting defaults', async () => {
|
||||
await service.deleteDefault(9, 'AuDiO');
|
||||
|
||||
|
||||
@@ -43,6 +43,9 @@ const CBX_SETTINGS_SCHEMA = z
|
||||
viewMode: z.enum(['single', 'two-page']),
|
||||
scrollMode: z.enum(['paginated', 'infinite', 'long-strip']),
|
||||
direction: z.enum(['ltr', 'rtl']),
|
||||
spreadAlignment: z.enum(['normal', 'shifted']),
|
||||
forceTwoPage: z.boolean(),
|
||||
widePageSingletonMode: z.enum(['auto', 'disable']),
|
||||
bgColor: z.enum(['black', 'gray', 'white']),
|
||||
})
|
||||
.strict();
|
||||
|
||||
Reference in New Issue
Block a user