From e850e929d94294ce9fe784ad5f9ff011d3b56539 Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Mon, 27 Jul 2026 04:28:22 +0000 Subject: [PATCH] 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 --- .../features/reader/cbz/CbzReaderView.spec.ts | 36 ++++++++++- .../src/features/reader/cbz/CbzReaderView.vue | 60 ++++++++++++++++--- .../reader/cbz/composables/useCbzSettings.ts | 2 + .../composables/useReaderSettings.test.ts | 17 ++++++ .../shared/composables/useReaderSettings.ts | 5 ++ .../src/features/settings/ComicsSettings.vue | 33 +++++++++- client/src/locales/de.json | 5 ++ client/src/locales/en.json | 5 ++ client/src/locales/nl.json | 5 ++ client/src/locales/pt.json | 5 ++ client/src/locales/sl.json | 5 ++ packages/types/src/reader-settings.ts | 5 ++ .../reader-preferences.service.test.ts | 22 ++++++- .../reader-preferences.service.ts | 3 +- 14 files changed, 193 insertions(+), 15 deletions(-) diff --git a/client/src/features/reader/cbz/CbzReaderView.spec.ts b/client/src/features/reader/cbz/CbzReaderView.spec.ts index cf68ccaa8..8413857dc 100644 --- a/client/src/features/reader/cbz/CbzReaderView.spec.ts +++ b/client/src/features/reader/cbz/CbzReaderView.spec.ts @@ -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>(), @@ -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() + }) }) diff --git a/client/src/features/reader/cbz/CbzReaderView.vue b/client/src/features/reader/cbz/CbzReaderView.vue index 5ad505354..9b85a696a 100644 --- a/client/src/features/reader/cbz/CbzReaderView.vue +++ b/client/src/features/reader/cbz/CbzReaderView.vue @@ -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(() => {
+
+
+ + + {{ t('reader.cbz.spreadGapValue', { value: spreadGap }) }} + +
+ +
+ +
+

{{ t('reader.cbz.spreadAlignmentLabel') }}

@@ -1028,29 +1058,35 @@ onUnmounted(() => {
-
+
@@ -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)` }" > - +
diff --git a/client/src/features/reader/cbz/composables/useCbzSettings.ts b/client/src/features/reader/cbz/composables/useCbzSettings.ts index 30188610e..1e4921ed6 100644 --- a/client/src/features/reader/cbz/composables/useCbzSettings.ts +++ b/client/src/features/reader/cbz/composables/useCbzSettings.ts @@ -14,6 +14,7 @@ export function useCbzSettings() { const scrollMode = ref('paginated') const direction = ref('ltr') const spreadAlignment = ref('normal') + const spreadGap = ref(0) const forceTwoPage = ref(false) const widePageSingletonMode = ref('auto') const bgColor = ref('black') @@ -45,6 +46,7 @@ export function useCbzSettings() { scrollMode, direction, spreadAlignment, + spreadGap, forceTwoPage, widePageSingletonMode, bgColor, diff --git a/client/src/features/reader/shared/composables/useReaderSettings.test.ts b/client/src/features/reader/shared/composables/useReaderSettings.test.ts index f19e87729..176ec4592 100644 --- a/client/src/features/reader/shared/composables/useReaderSettings.test.ts +++ b/client/src/features/reader/shared/composables/useReaderSettings.test.ts @@ -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', () => { diff --git a/client/src/features/reader/shared/composables/useReaderSettings.ts b/client/src/features/reader/shared/composables/useReaderSettings.ts index 380b03034..8163aa3eb 100644 --- a/client/src/features/reader/shared/composables/useReaderSettings.ts +++ b/client/src/features/reader/shared/composables/useReaderSettings.ts @@ -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 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('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) @@ -198,6 +206,29 @@ onMounted(load)
+
+
+ +

+ {{ t('settings.reader.comics.spreadGapHint') }} +

+
+
+ + + {{ t('settings.reader.comics.spreadGapValue', { value: effective.spreadGap }) }} + +
+
+
diff --git a/client/src/locales/de.json b/client/src/locales/de.json index dc96845f8..93a3c2478 100644 --- a/client/src/locales/de.json +++ b/client/src/locales/de.json @@ -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", diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 62253fb9d..c9e8ab977 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -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", diff --git a/client/src/locales/nl.json b/client/src/locales/nl.json index 0d5c70ac1..36e8bfd30 100644 --- a/client/src/locales/nl.json +++ b/client/src/locales/nl.json @@ -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", diff --git a/client/src/locales/pt.json b/client/src/locales/pt.json index d519d4606..7bcddbd21 100644 --- a/client/src/locales/pt.json +++ b/client/src/locales/pt.json @@ -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", diff --git a/client/src/locales/sl.json b/client/src/locales/sl.json index 9381222ac..87784a084 100644 --- a/client/src/locales/sl.json +++ b/client/src/locales/sl.json @@ -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", diff --git a/packages/types/src/reader-settings.ts b/packages/types/src/reader-settings.ts index be6d655e0..cd71e688c 100644 --- a/packages/types/src/reader-settings.ts +++ b/packages/types/src/reader-settings.ts @@ -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", 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 4231104f5..a0108e9e1 100644 --- a/server/src/modules/reader-preferences/reader-preferences.service.test.ts +++ b/server/src/modules/reader-preferences/reader-preferences.service.test.ts @@ -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 = { ...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(); diff --git a/server/src/modules/reader-preferences/reader-preferences.service.ts b/server/src/modules/reader-preferences/reader-preferences.service.ts index ab6a3ac75..30588dd5d 100644 --- a/server/src/modules/reader-preferences/reader-preferences.service.ts +++ b/server/src/modules/reader-preferences/reader-preferences.service.ts @@ -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']),