From bdbe36f9e5244e2aff6040a1b57261b81624f564 Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Thu, 2 Apr 2026 08:07:00 -0600 Subject: [PATCH] feat(reader): add CBZ spread layout engine and settings --- .../src/features/reader/cbz/CbzReaderView.vue | 595 ++++++++++++++---- .../reader/cbz/composables/useCbzSettings.ts | 19 +- .../cbz/lib/__tests__/spread-layout.spec.ts | 166 +++++ .../features/reader/cbz/lib/spread-layout.ts | 186 ++++++ .../shared/composables/useReaderSettings.ts | 133 +++- .../src/features/settings/ComicsSettings.vue | 82 +++ packages/types/src/reader-settings.ts | 6 + .../reader-preferences.service.test.ts | 30 + .../reader-preferences.service.ts | 3 + 9 files changed, 1073 insertions(+), 147 deletions(-) create mode 100644 client/src/features/reader/cbz/lib/__tests__/spread-layout.spec.ts create mode 100644 client/src/features/reader/cbz/lib/spread-layout.ts diff --git a/client/src/features/reader/cbz/CbzReaderView.vue b/client/src/features/reader/cbz/CbzReaderView.vue index 04c1e16f6..c63053d84 100644 --- a/client/src/features/reader/cbz/CbzReaderView.vue +++ b/client/src/features/reader/cbz/CbzReaderView.vue @@ -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(null) +const viewportWidth = ref(0) +const currentImageLoaded = ref(false) +const pendingImageLoads = ref(0) +const loadedImageCount = ref(0) +const pageRatios = ref([]) +const highlightForceTwoPage = ref(false) +const forceTwoPageToggleButton = ref(null) +let forceToggleHighlightTimer: ReturnType | 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() +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() + + 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 | 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) }) @@ -325,7 +535,7 @@ onUnmounted(() => {
{{ bookTitle }} - {{ currentPage + 1 }} / {{ pageCount }} + {{ pageLabel }}
@@ -333,84 +543,181 @@ onUnmounted(() => { - - Fit Mode - - + Reader Settings + +
+

Fit mode

+
+ +
+
+ +
+
+

Page view

+ + + Auto-fallback active + +
+
+ +
+
+ +
+

Reading direction

+
+ +
+
+ +
+

Background

+
+ +
+
+ +
+

Spread alignment

+
+ +
+
+ + + + Spread alignment unavailable in auto-fallback + Focus toggle below + + + + +
+

Scroll mode

+
+ +
+
+ +
+
+

Force two-page (Bypass mobile fallback)

+
+ +
- +
+

Wide pages

+
+ +
+
- Pages - - - - {{ opt.label }} - - - - - - Scroll Mode - - - - {{ opt.label }} - - - - - - Reading Direction - - - - {{ opt.label }} - - - - - - Background - - - - {{ opt.label }} - - + + + Reset book view settings +
@@ -429,19 +736,38 @@ onUnmounted(() => {
-
+
+ +
@@ -462,6 +788,7 @@ onUnmounted(() => { :class="scrollMode === 'long-strip' ? 'w-full block' : 'max-w-full'" loading="lazy" draggable="false" + @load="onStripImageLoad(i - 1, $event)" /> @@ -478,7 +805,7 @@ onUnmounted(() => { First page - +
{
- + @@ -524,7 +851,7 @@ onUnmounted(() => {
-
+
diff --git a/client/src/features/reader/cbz/composables/useCbzSettings.ts b/client/src/features/reader/cbz/composables/useCbzSettings.ts index 0a6586b72..30188610e 100644 --- a/client/src/features/reader/cbz/composables/useCbzSettings.ts +++ b/client/src/features/reader/cbz/composables/useCbzSettings.ts @@ -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('single') const scrollMode = ref('paginated') const direction = ref('ltr') + const spreadAlignment = ref('normal') + const forceTwoPage = ref(false) + const widePageSingletonMode = ref('auto') const bgColor = ref('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, + } } diff --git a/client/src/features/reader/cbz/lib/__tests__/spread-layout.spec.ts b/client/src/features/reader/cbz/lib/__tests__/spread-layout.spec.ts new file mode 100644 index 000000000..3cf0217fb --- /dev/null +++ b/client/src/features/reader/cbz/lib/__tests__/spread-layout.spec.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from 'vitest' +import { createCbzSpreadLayout } from '../spread-layout' + +function digest(layout: ReturnType) { + 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) + }) +}) diff --git a/client/src/features/reader/cbz/lib/spread-layout.ts b/client/src/features/reader/cbz/lib/spread-layout.ts new file mode 100644 index 000000000..8dd32b1a4 --- /dev/null +++ b/client/src/features/reader/cbz/lib/spread-layout.ts @@ -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() + + 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, + } +} diff --git a/client/src/features/reader/shared/composables/useReaderSettings.ts b/client/src/features/reader/shared/composables/useReaderSettings.ts index cd2b4fdcb..1536e2a86 100644 --- a/client/src/features/reader/shared/composables/useReaderSettings.ts +++ b/client/src/features/reader/shared/composables/useReaderSettings.ts @@ -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 { + 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 | null { + if (!isRecord(settings)) return null + + const out: Partial = {} + + 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 | null { + if (group !== 'cbx') return isRecord(raw) ? (raw as Partial) : null + const sanitized = sanitizeCbxPartialSettings(raw) + return sanitized as Partial | 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(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 - 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(format: strin async function load() { const ls = readLs(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)) + } } } } diff --git a/client/src/features/settings/ComicsSettings.vue b/client/src/features/settings/ComicsSettings.vue index d1041b098..96b62000c 100644 --- a/client/src/features/settings/ComicsSettings.vue +++ b/client/src/features/settings/ComicsSettings.vue @@ -140,6 +140,88 @@ onMounted(load)
+ + +
+
+

Spread alignment

+

Shift pairing by one page after the cover for off-by-one scans

+
+
+ + +
+
+ + +
+
+

Wide-page handling

+

Auto shows wide scans alone in two-page paginated mode

+
+
+ + +
+
+ + +
+
+

Force two-page on small screens

+

Bypass mobile auto-fallback when paginated two-page mode is selected

+
+
+ + +
+
diff --git a/packages/types/src/reader-settings.ts b/packages/types/src/reader-settings.ts index c9c9f75a0..2baed4bc5 100644 --- a/packages/types/src/reader-settings.ts +++ b/packages/types/src/reader-settings.ts @@ -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", }; diff --git a/server/src/modules/reader-preferences/reader-preferences.service.test.ts b/server/src/modules/reader-preferences/reader-preferences.service.test.ts index 26796fc85..650169ce4 100644 --- a/server/src/modules/reader-preferences/reader-preferences.service.test.ts +++ b/server/src/modules/reader-preferences/reader-preferences.service.test.ts @@ -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 } | null>>(), upsertPreference: vi.fn<(...args: [number, number, Record]) => Promise>(), @@ -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'); diff --git a/server/src/modules/reader-preferences/reader-preferences.service.ts b/server/src/modules/reader-preferences/reader-preferences.service.ts index a055c5fff..4dabe2610 100644 --- a/server/src/modules/reader-preferences/reader-preferences.service.ts +++ b/server/src/modules/reader-preferences/reader-preferences.service.ts @@ -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();