feat(reader): add configurable spread gap for two-page comic view

Allow users to adjust the gap between pages when reading comics in two-page spread mode. Include backend preference validation, shared settings defaults, translation keys, and reader settings UI controls.

Closes #740
This commit is contained in:
neon
2026-07-27 04:28:22 +00:00
parent 92ade7dbba
commit e850e929d9
14 changed files with 193 additions and 15 deletions
@@ -5,7 +5,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import CbzReaderView from './CbzReaderView.vue'
const mocks = vi.hoisted(() => ({
savedMode: 'infinite' as 'infinite' | 'long-strip',
savedMode: 'infinite' as 'paginated' | 'infinite' | 'long-strip',
savedViewMode: 'single' as 'single' | 'two-page',
savedSpreadGap: 0,
pageCount: 10_000,
savedPageNumber: 9_000,
onBeforeRouteLeave: vi.fn<(guard: () => Promise<void>) => void>(),
@@ -69,6 +71,7 @@ vi.mock('./composables/useCbzSettings', () => ({
scrollMode: ref('paginated'),
direction: ref('ltr'),
spreadAlignment: ref('normal'),
spreadGap: ref(0),
forceTwoPage: ref(false),
widePageSingletonMode: ref('auto'),
bgColor: ref('black'),
@@ -81,10 +84,11 @@ vi.mock('../shared/composables/useReaderSettings', () => ({
useReaderSettings: () => ({
effective: computed(() => ({
fitMode: 'fit-page',
viewMode: 'single',
viewMode: mocks.savedViewMode,
scrollMode: mocks.savedMode,
direction: 'ltr',
spreadAlignment: 'normal',
spreadGap: mocks.savedSpreadGap,
forceTwoPage: false,
widePageSingletonMode: 'auto',
bgColor: 'black',
@@ -123,9 +127,12 @@ vi.mock('../shared/composables/useFullscreen', () => ({
useFullscreen: () => ({ isFullscreen: ref(false), toggleFullscreen: vi.fn<() => void>() }),
}))
describe('CbzReaderView continuous position restoration', () => {
describe('CbzReaderView', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.savedMode = 'infinite'
mocks.savedViewMode = 'single'
mocks.savedSpreadGap = 0
mocks.pageCount = 10_000
mocks.savedPageNumber = 9_000
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
@@ -154,4 +161,27 @@ describe('CbzReaderView continuous position restoration', () => {
wrapper.unmount()
})
it.each([0, 24])('anchors two-page images to the spine with a %ipx configurable gap', async (spreadGap) => {
mocks.savedMode = 'paginated'
mocks.savedViewMode = 'two-page'
mocks.savedSpreadGap = spreadGap
mocks.pageCount = 6
mocks.savedPageNumber = 2
window.innerWidth = 1_200
const wrapper = shallowMount(CbzReaderView, {
props: { bookId: 11, fileId: 22 },
})
await flushPromises()
await nextTick()
expect(wrapper.get('[data-testid="cbz-paginated-pages"]').attributes('style')).toContain(`column-gap: ${spreadGap}px`)
expect(wrapper.get('[data-spread-side="left"]').classes()).toContain('justify-end')
expect(wrapper.get('[data-spread-side="right"]').classes()).toContain('justify-start')
expect(wrapper.findAll('[data-spread-side] img')).toHaveLength(2)
wrapper.unmount()
})
})
@@ -34,7 +34,7 @@ import { useCbzSettings } from './composables/useCbzSettings'
import type { BgColor, Direction, FitMode, ScrollMode, SpreadAlignment, ViewMode, WidePageSingletonMode } from './composables/useCbzSettings'
import { useReaderSettings } from '../shared/composables/useReaderSettings'
import { useFullscreen } from '../shared/composables/useFullscreen'
import type { CbxReaderSettings } from '@bookorbit/types'
import { CBX_SPREAD_GAP_MAX, CBX_SPREAD_GAP_MIN, type CbxReaderSettings } from '@bookorbit/types'
import { DEFAULT_WIDE_PAGE_RATIO_THRESHOLD, createCbzSpreadLayout } from './lib/spread-layout'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
@@ -61,7 +61,7 @@ const { onActivity, elapsedMinutes } = useReadingSession(
)
const progress = useReaderProgress(props.bookId, props.fileId, elapsedMinutes, 0, { trackingEnabled })
const { pageCount, bookTitle, loading, error, pageUrl, load } = useCbz(props.fileId, props.bookId)
const { fitMode, viewMode, scrollMode, direction, spreadAlignment, forceTwoPage, widePageSingletonMode, bgColor, bgValue, imgFitClass } =
const { fitMode, viewMode, scrollMode, direction, spreadAlignment, spreadGap, forceTwoPage, widePageSingletonMode, bgColor, bgValue, imgFitClass } =
useCbzSettings()
const bookSettings = useReaderSettings(props.fileId, 'cbz')
@@ -167,6 +167,13 @@ function setDirection(v: Direction) {
function setSpreadAlignment(v: SpreadAlignment) {
spreadAlignment.value = v
}
function setSpreadGapFromEvent(event: Event) {
const target = event.target
if (!(target instanceof HTMLInputElement)) return
const value = Number(target.value)
if (!Number.isInteger(value) || value < CBX_SPREAD_GAP_MIN || value > CBX_SPREAD_GAP_MAX) return
spreadGap.value = value
}
function setWidePageMode(v: WidePageSingletonMode) {
widePageSingletonMode.value = v
}
@@ -183,6 +190,7 @@ function applySettings(s: CbxReaderSettings) {
scrollMode.value = s.scrollMode
direction.value = s.direction
spreadAlignment.value = s.spreadAlignment
spreadGap.value = s.spreadGap
forceTwoPage.value = s.forceTwoPage
widePageSingletonMode.value = s.widePageSingletonMode
bgColor.value = s.bgColor
@@ -233,6 +241,7 @@ const renderSpread = computed(() => currentSpread.value?.kind === 'spread')
const renderSinglePage = computed(() => (currentSpread.value?.kind === 'single' ? currentSpread.value.singlePage : null))
const renderLeftPage = computed(() => (currentSpread.value?.kind === 'spread' ? currentSpread.value.leftPage : null))
const renderRightPage = computed(() => (currentSpread.value?.kind === 'spread' ? currentSpread.value.rightPage : null))
const spreadContainerStyle = computed(() => ({ columnGap: `${spreadGap.value}px` }))
const renderKey = computed(() => {
const spread = currentSpread.value
if (!spread) return 'none'
@@ -666,6 +675,7 @@ onMounted(async () => {
watch(scrollMode, (v) => bookSettings.updateBookSettings({ scrollMode: v }))
watch(direction, (v) => bookSettings.updateBookSettings({ direction: v }))
watch(spreadAlignment, (v) => bookSettings.updateBookSettings({ spreadAlignment: v }))
watch(spreadGap, (v) => bookSettings.updateBookSettings({ spreadGap: v }))
watch(forceTwoPage, (v) => bookSettings.updateBookSettings({ forceTwoPage: v }))
watch(widePageSingletonMode, (v) => bookSettings.updateBookSettings({ widePageSingletonMode: v }))
watch(bgColor, (v) => bookSettings.updateBookSettings({ bgColor: v }))
@@ -921,6 +931,26 @@ onUnmounted(() => {
<div class="h-px bg-border/70" />
<div v-if="isTwoPagePreferred">
<div class="mb-2 flex items-center justify-between gap-3">
<label for="cbz-spread-gap" class="text-[13px] font-medium text-foreground/90">{{ t('reader.cbz.spreadGap') }}</label>
<span class="text-[11px] tabular-nums text-muted-foreground">
{{ t('reader.cbz.spreadGapValue', { value: spreadGap }) }}
</span>
</div>
<input
id="cbz-spread-gap"
type="range"
:min="CBX_SPREAD_GAP_MIN"
:max="CBX_SPREAD_GAP_MAX"
:value="spreadGap"
class="w-full accent-primary"
@input="setSpreadGapFromEvent"
/>
</div>
<div v-if="isTwoPagePreferred" class="h-px bg-border/70" />
<div v-if="showSpreadAlignmentControl">
<p class="mb-2 text-[13px] font-medium text-foreground/90">{{ t('reader.cbz.spreadAlignmentLabel') }}</p>
<div class="grid grid-cols-2 gap-1 rounded-lg bg-muted/55 p-1">
@@ -1028,29 +1058,35 @@ 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 gap-0.5 px-1">
<div
data-testid="cbz-paginated-pages"
class="flex h-full w-full items-center justify-center px-1"
:style="renderSpread ? spreadContainerStyle : undefined"
>
<template v-if="renderSpread">
<div class="h-full w-1/2 flex items-center justify-center">
<div data-spread-side="left" class="flex h-full min-w-0 flex-1 items-center justify-end">
<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%' }"
alt=""
draggable="false"
@load="onPaginatedImageLoad(renderLeftPage, $event)"
/>
<div v-else class="h-[92%] w-[92%] rounded-sm border border-border/60 bg-background/30" />
<div v-else aria-hidden="true" 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">
<div data-spread-side="right" class="flex h-full min-w-0 flex-1 items-center justify-start">
<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%' }"
alt=""
draggable="false"
@load="onPaginatedImageLoad(renderRightPage, $event)"
/>
<div v-else class="h-[92%] w-[92%] rounded-sm border border-border/60 bg-background/30" />
<div v-else aria-hidden="true" class="h-[92%] w-[92%] rounded-sm border border-border/60 bg-background/30" />
</div>
</template>
@@ -1058,6 +1094,7 @@ onUnmounted(() => {
v-else-if="renderSinglePage !== null"
:src="pageUrl(renderSinglePage)"
:class="[imgFitClass, 'pointer-events-none transition-opacity duration-150', currentImageLoaded ? 'opacity-100' : 'opacity-0']"
alt=""
draggable="false"
@load="onPaginatedImageLoad(renderSinglePage, $event)"
/>
@@ -1077,7 +1114,14 @@ onUnmounted(() => {
:class="[stripFrameClass, scrollMode === 'long-strip' ? '' : 'px-2']"
:style="{ transform: `translateY(${page.start}px)` }"
>
<img :src="pageUrl(page.index)" :class="stripImageClass" decoding="async" draggable="false" @load="onStripImageLoad(page.index, $event)" />
<img
:src="pageUrl(page.index)"
:class="stripImageClass"
alt=""
decoding="async"
draggable="false"
@load="onStripImageLoad(page.index, $event)"
/>
</div>
</div>
</div>
@@ -14,6 +14,7 @@ export function useCbzSettings() {
const scrollMode = ref<ScrollMode>('paginated')
const direction = ref<Direction>('ltr')
const spreadAlignment = ref<SpreadAlignment>('normal')
const spreadGap = ref(0)
const forceTwoPage = ref(false)
const widePageSingletonMode = ref<WidePageSingletonMode>('auto')
const bgColor = ref<BgColor>('black')
@@ -45,6 +46,7 @@ export function useCbzSettings() {
scrollMode,
direction,
spreadAlignment,
spreadGap,
forceTwoPage,
widePageSingletonMode,
bgColor,
@@ -171,6 +171,23 @@ describe('useReaderSettings - load() with valid cbx localStorage delta', () => {
expect(s.bookDelta.value).toMatchObject({ fitMode: 'fit-page', viewMode: 'two-page' })
expect(s.isCustomized.value).toBe(true)
})
it('keeps bounded integer spread gaps and removes invalid values', async () => {
localStorage.setItem(`reader:book:${BOOK_FILE_ID}`, JSON.stringify({ spreadGap: 0, viewMode: 'two-page' }))
const valid = useReaderSettings(BOOK_FILE_ID, 'cbz')
await valid.load()
expect(valid.bookDelta.value).toMatchObject({ spreadGap: 0, viewMode: 'two-page' })
localStorage.setItem(`reader:book:${BOOK_FILE_ID}`, JSON.stringify({ spreadGap: 65, viewMode: 'two-page' }))
const invalid = useReaderSettings(BOOK_FILE_ID, 'cbz')
await invalid.load()
expect(invalid.bookDelta.value).toEqual({ viewMode: 'two-page' })
expect(JSON.parse(localStorage.getItem(`reader:book:${BOOK_FILE_ID}`) ?? '{}')).toEqual({ viewMode: 'two-page' })
})
})
describe('useReaderSettings - updateBookSettings', () => {
@@ -3,6 +3,8 @@ import { toast } from 'vue-sonner'
import { api } from '@/lib/api'
import { useAuth } from '@/features/auth/composables/useAuth'
import {
CBX_SPREAD_GAP_MAX,
CBX_SPREAD_GAP_MIN,
CBX_READER_DEFAULTS,
EPUB_READER_DEFAULTS,
PDF_READER_DEFAULTS,
@@ -127,6 +129,9 @@ function sanitizeCbxPartialSettings(settings: unknown): Partial<CbxReaderSetting
if (settings.spreadAlignment === 'normal' || settings.spreadAlignment === 'shifted') {
out.spreadAlignment = settings.spreadAlignment
}
if (isIntegerInRange(settings.spreadGap, CBX_SPREAD_GAP_MIN, CBX_SPREAD_GAP_MAX)) {
out.spreadGap = settings.spreadGap
}
if (typeof settings.forceTwoPage === 'boolean') {
out.forceTwoPage = settings.forceTwoPage
}
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import type { CbxReaderSettings } from '@bookorbit/types'
import { CBX_SPREAD_GAP_MAX, CBX_SPREAD_GAP_MIN, type CbxReaderSettings } from '@bookorbit/types'
import { useReaderDefaultSettings } from '@/features/reader/shared/composables/useReaderSettings'
import SettingsPageHeader from './SettingsPageHeader.vue'
@@ -18,6 +18,14 @@ const props = withDefaults(
const { effective, load, update, reset } = useReaderDefaultSettings<CbxReaderSettings>('cbx')
function updateSpreadGapFromEvent(event: Event) {
const target = event.target
if (!(target instanceof HTMLInputElement)) return
const spreadGap = Number(target.value)
if (!Number.isInteger(spreadGap) || spreadGap < CBX_SPREAD_GAP_MIN || spreadGap > CBX_SPREAD_GAP_MAX) return
update({ spreadGap })
}
onMounted(load)
</script>
@@ -198,6 +206,29 @@ onMounted(load)
</div>
</div>
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between px-4 py-3.5 md:px-5 md:py-4 bg-card">
<div>
<label for="default-cbz-spread-gap" class="settings-label">{{ t('settings.reader.comics.spreadGap') }}</label>
<p class="settings-hint overflow-hidden text-ellipsis whitespace-nowrap md:overflow-visible md:whitespace-normal">
{{ t('settings.reader.comics.spreadGapHint') }}
</p>
</div>
<div class="flex w-full max-w-72 items-center gap-3 md:w-64">
<input
id="default-cbz-spread-gap"
type="range"
:min="CBX_SPREAD_GAP_MIN"
:max="CBX_SPREAD_GAP_MAX"
:value="effective.spreadGap"
class="min-w-0 flex-1 accent-primary"
@input="updateSpreadGapFromEvent"
/>
<span class="w-12 text-end text-xs tabular-nums text-muted-foreground">
{{ t('settings.reader.comics.spreadGapValue', { value: effective.spreadGap }) }}
</span>
</div>
</div>
<!-- Wide page handling -->
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between px-4 py-3.5 md:px-5 md:py-4 bg-card">
<div>
+5
View File
@@ -1579,6 +1579,9 @@
"spreadAlignmentHint": "Paarung nach dem Cover um eine Seite verschieben bei versetzten Scans",
"alignmentNormal": "Normal",
"alignmentShifted": "Verschoben",
"spreadGap": "Spread gap",
"spreadGapHint": "Space between pages in two-page view",
"spreadGapValue": "{value}px",
"widePageHandling": "Umgang mit breiten Seiten",
"widePageHandlingHint": "Automatisch zeigt breite Scans allein im zweiseitigen seitenweisen Modus an",
"widePageAuto": "Automatisch",
@@ -5540,6 +5543,8 @@
"autoFallback": "Automatischer Rückfallmodus",
"spreadAlignmentLabel": "Doppelseitenausrichtung",
"spreadAlignmentHint": "Doppelseitenausrichtung ist im automatischen Rückfallmodus nicht verfügbar.",
"spreadGap": "Spread gap",
"spreadGapValue": "{value}px",
"focusTwoPageToggle": "Zweiseitenansicht fokussieren",
"forceTwoPage": "Zwei Seiten erzwingen",
"off": "Aus",
+5
View File
@@ -1570,6 +1570,9 @@
"spreadAlignmentHint": "Shift pairing by one page after the cover for off-by-one scans",
"alignmentNormal": "Normal",
"alignmentShifted": "Shifted",
"spreadGap": "Spread gap",
"spreadGapHint": "Space between pages in two-page view",
"spreadGapValue": "{value}px",
"widePageHandling": "Wide-page handling",
"widePageHandlingHint": "Auto shows wide scans alone in two-page paginated mode",
"widePageAuto": "Auto",
@@ -5498,6 +5501,8 @@
"autoFallback": "Auto-fallback",
"spreadAlignmentLabel": "Spread alignment",
"spreadAlignmentHint": "Spread alignment is unavailable in auto-fallback mode.",
"spreadGap": "Spread gap",
"spreadGapValue": "{value}px",
"focusTwoPageToggle": "Focus two-page toggle",
"forceTwoPage": "Force two-page",
"off": "Off",
+5
View File
@@ -1579,6 +1579,9 @@
"spreadAlignmentHint": "Verschuif de koppeling met één pagina na de omslag voor scans die net verschoven zijn",
"alignmentNormal": "Normaal",
"alignmentShifted": "Verschoven",
"spreadGap": "Spread gap",
"spreadGapHint": "Space between pages in two-page view",
"spreadGapValue": "{value}px",
"widePageHandling": "Verwerking van brede pagina's",
"widePageHandlingHint": "Auto toont brede scans alleen in gepagineerde modus met twee pagina's",
"widePageAuto": "Auto",
@@ -5540,6 +5543,8 @@
"autoFallback": "Auto-terugval",
"spreadAlignmentLabel": "Spreaduitlijning",
"spreadAlignmentHint": "Spreaduitlijning is niet beschikbaar in de auto-terugvalmodus.",
"spreadGap": "Spread gap",
"spreadGapValue": "{value}px",
"focusTwoPageToggle": "Focus op tweepaginaschakelaar",
"forceTwoPage": "Twee pagina's forceren",
"off": "Uit",
+5
View File
@@ -1579,6 +1579,9 @@
"spreadAlignmentHint": "Deslocar o pareamento em uma página após a capa para digitalizações com descompasso de página",
"alignmentNormal": "Normal",
"alignmentShifted": "Deslocado",
"spreadGap": "Spread gap",
"spreadGapHint": "Space between pages in two-page view",
"spreadGapValue": "{value}px",
"widePageHandling": "Tratamento de páginas largas",
"widePageHandlingHint": "Exibe automaticamente páginas largas sozinhas no modo paginado de duas páginas",
"widePageAuto": "Automático",
@@ -5540,6 +5543,8 @@
"autoFallback": "Preenchimento automático (fallback)",
"spreadAlignmentLabel": "Alinhamento das páginas",
"spreadAlignmentHint": "O alinhamento das páginas não está disponível no modo de fallback automático.",
"spreadGap": "Spread gap",
"spreadGapValue": "{value}px",
"focusTwoPageToggle": "Alternar para focar em duas páginas",
"forceTwoPage": "Forçar duas páginas",
"off": "Desligado",
+5
View File
@@ -1579,6 +1579,9 @@
"spreadAlignmentHint": "Zamik parjenja za eno stran po naslovnici pri neusklajenih skenih",
"alignmentNormal": "Običajno",
"alignmentShifted": "Zamaknjeno",
"spreadGap": "Spread gap",
"spreadGapHint": "Space between pages in two-page view",
"spreadGapValue": "{value}px",
"widePageHandling": "Ravnanje s širokimi stranmi",
"widePageHandlingHint": "Samodejno prikaže široke skene same v dvostranskem načinu po straneh",
"widePageAuto": "Samodejno",
@@ -5540,6 +5543,8 @@
"autoFallback": "Samodejni nadomestni način",
"spreadAlignmentLabel": "Poravnava razpona",
"spreadAlignmentHint": "Poravnava razpona ni na voljo v samodejnem nadomestnem načinu.",
"spreadGap": "Spread gap",
"spreadGapValue": "{value}px",
"focusTwoPageToggle": "Preklop na dvostransko pogled",
"forceTwoPage": "Vsili dve strani",
"off": "Izklopljeno",
+5
View File
@@ -1,5 +1,8 @@
export type ReaderFormatGroup = "epub" | "pdf" | "cbx" | "audio";
export const CBX_SPREAD_GAP_MIN = 0;
export const CBX_SPREAD_GAP_MAX = 64;
// Formats the reader can actually open. Used to show/hide Read/Open buttons.
export const READER_OPENABLE_FORMATS = new Set([
// epub reader (foliate)
@@ -83,6 +86,7 @@ export interface CbxReaderSettings {
scrollMode: "paginated" | "infinite" | "long-strip";
direction: "ltr" | "rtl";
spreadAlignment: "normal" | "shifted";
spreadGap: number;
forceTwoPage: boolean;
widePageSingletonMode: "auto" | "disable";
bgColor: "black" | "gray" | "white";
@@ -136,6 +140,7 @@ export const CBX_READER_DEFAULTS: CbxReaderSettings = {
scrollMode: "paginated",
direction: "ltr",
spreadAlignment: "normal",
spreadGap: 0,
forceTwoPage: false,
widePageSingletonMode: "auto",
bgColor: "black",
@@ -41,6 +41,7 @@ const validCbxDefaults = {
scrollMode: 'paginated',
direction: 'ltr',
spreadAlignment: 'normal',
spreadGap: 0,
forceTwoPage: false,
widePageSingletonMode: 'auto',
bgColor: 'black',
@@ -118,9 +119,17 @@ describe('ReaderPreferencesService', () => {
const user = makeUser();
mockBookService.verifyFileAccess.mockResolvedValueOnce({ format: 'cbz' });
await service.upsertPreference(user, 11, { fitMode: 'fit-page', direction: 'rtl' });
await service.upsertPreference(user, 11, { fitMode: 'fit-page', direction: 'rtl', spreadGap: 24 });
expect(mockRepo.upsertPreference).toHaveBeenCalledWith(7, 11, { fitMode: 'fit-page', direction: 'rtl' });
expect(mockRepo.upsertPreference).toHaveBeenCalledWith(7, 11, { fitMode: 'fit-page', direction: 'rtl', spreadGap: 24 });
});
it('rejects comic spread gaps outside the supported range', async () => {
const user = makeUser();
mockBookService.verifyFileAccess.mockResolvedValueOnce({ format: 'cbz' });
await expect(service.upsertPreference(user, 11, { spreadGap: 65 })).rejects.toThrow(BadRequestException);
expect(mockRepo.upsertPreference).not.toHaveBeenCalled();
});
it('rejects per-book settings with unknown keys', async () => {
@@ -167,6 +176,15 @@ describe('ReaderPreferencesService', () => {
expect(mockRepo.upsertDefault).toHaveBeenCalledWith(3, 'cbx', validCbxDefaults);
});
it('fills the gapless default for cbx payloads saved before spread gaps were supported', async () => {
const legacyCbxDefaults: Partial<typeof validCbxDefaults> = { ...validCbxDefaults };
delete legacyCbxDefaults.spreadGap;
await service.upsertDefault(3, 'cbx', legacyCbxDefaults);
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();
@@ -1,5 +1,5 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { READER_GROUP_DEFAULTS, getFormatGroup, type ReaderFormatGroup } from '@bookorbit/types';
import { CBX_SPREAD_GAP_MAX, CBX_SPREAD_GAP_MIN, READER_GROUP_DEFAULTS, getFormatGroup, type ReaderFormatGroup } from '@bookorbit/types';
import { z } from 'zod';
import type { RequestUser } from '../../common/types/request-user';
@@ -46,6 +46,7 @@ const CBX_SETTINGS_SCHEMA = z
scrollMode: z.enum(['paginated', 'infinite', 'long-strip']),
direction: z.enum(['ltr', 'rtl']),
spreadAlignment: z.enum(['normal', 'shifted']),
spreadGap: z.number().int().min(CBX_SPREAD_GAP_MIN).max(CBX_SPREAD_GAP_MAX).default(CBX_SPREAD_GAP_MIN),
forceTwoPage: z.boolean(),
widePageSingletonMode: z.enum(['auto', 'disable']),
bgColor: z.enum(['black', 'gray', 'white']),