mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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']),
|
||||
|
||||
Reference in New Issue
Block a user