mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(reader): support single-page mode for fixed-layout epubs
Adds a fixedLayoutSpread preference to EPUB reader settings to allow forcing single-page display. This is particularly useful for image-based EPUBs like comics and manga. Closes #487
This commit is contained in:
@@ -84,6 +84,7 @@ const {
|
||||
setIsDark,
|
||||
setThemeName,
|
||||
setFlow,
|
||||
setFixedLayoutSpread,
|
||||
setFontFaceCSS,
|
||||
} = readerState
|
||||
|
||||
@@ -216,7 +217,7 @@ function onRelocateHandler(detail: RelocateDetail) {
|
||||
|
||||
function onApplyStylesHandler(renderer: FoliateRenderer) {
|
||||
if (shouldApplyStyles.value) {
|
||||
applyToRenderer(renderer)
|
||||
applyToRenderer(renderer, isFixedLayout.value ? { flow: 'paginated' } : undefined)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,6 +243,7 @@ const {
|
||||
setAnnotationClickHandler,
|
||||
view: foliateView,
|
||||
bookLanguage,
|
||||
isFixedLayout,
|
||||
} = useFoliate(() => containerRef.value, onRelocateHandler, onApplyStylesHandler, onMiddleTapHandler)
|
||||
|
||||
function handleTextSelected(detail: SelectionDetail) {
|
||||
@@ -290,7 +292,9 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
const hadProgress = progress.percentage.value > 0
|
||||
await open(bookId, fileId, fileFormat, progress.cfi.value, hadProgress ? progress.percentage.value / 100 : undefined)
|
||||
await open(bookId, fileId, fileFormat, progress.cfi.value, hadProgress ? progress.percentage.value / 100 : undefined, {
|
||||
fixedLayoutSpread: state.value.fixedLayoutSpread,
|
||||
})
|
||||
setChapters(getChapters())
|
||||
sectionFractions.value = getSectionFractions()
|
||||
await bookmarks.load(bookId)
|
||||
@@ -330,6 +334,7 @@ const epubSetters: Record<string, (v: unknown) => void> = {
|
||||
isDark: (v) => setIsDark(v as boolean),
|
||||
themeName: (v) => setThemeName(v as string),
|
||||
flow: (v) => setFlow(v as 'paginated' | 'scrolled'),
|
||||
fixedLayoutSpread: (v) => setFixedLayoutSpread(v as EpubReaderSettings['fixedLayoutSpread']),
|
||||
}
|
||||
|
||||
// Applies settings to reactive refs (and renderer if open) without touching the delta.
|
||||
@@ -339,15 +344,28 @@ function seedState(partial: Partial<ReaderState>) {
|
||||
epubSetters[key]?.(value)
|
||||
}
|
||||
const renderer = getRenderer()
|
||||
if (renderer) applyToRenderer(renderer)
|
||||
if (renderer) applyToRenderer(renderer, isFixedLayout.value ? { flow: 'paginated' } : undefined)
|
||||
}
|
||||
|
||||
async function reopenEpubAtCurrentLocation() {
|
||||
const fallbackFraction = fraction.value > 0 ? fraction.value : progress.percentage.value > 0 ? progress.percentage.value / 100 : undefined
|
||||
await open(bookId, fileId, fileFormat, null, fallbackFraction, {
|
||||
fixedLayoutSpread: state.value.fixedLayoutSpread,
|
||||
})
|
||||
setChapters(getChapters())
|
||||
sectionFractions.value = getSectionFractions()
|
||||
}
|
||||
|
||||
// Applies a user-initiated change: updates reactive refs AND saves the changed field to delta.
|
||||
// Also enables style injection from this point forward (user has opted in by changing something).
|
||||
function applyUpdate(partial: Partial<ReaderState>) {
|
||||
async function applyUpdate(partial: Partial<ReaderState>) {
|
||||
const shouldReopenForSpread = partial.fixedLayoutSpread !== undefined && partial.fixedLayoutSpread !== state.value.fixedLayoutSpread
|
||||
shouldApplyStyles.value = true
|
||||
seedState(partial)
|
||||
bookSettings.updateBookSettings(partial)
|
||||
if (shouldReopenForSpread) {
|
||||
await reopenEpubAtCurrentLocation()
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFullscreen() {
|
||||
@@ -382,7 +400,7 @@ watch(
|
||||
() => {
|
||||
setFontFaceCSS(customFonts.generateFontFaceCSS())
|
||||
const renderer = getRenderer()
|
||||
if (renderer && shouldApplyStyles.value) applyToRenderer(renderer)
|
||||
if (renderer && shouldApplyStyles.value) applyToRenderer(renderer, isFixedLayout.value ? { flow: 'paginated' } : undefined)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -582,7 +600,7 @@ watch(
|
||||
@startReading="startTrackedReading"
|
||||
>
|
||||
<template #settingsPanel>
|
||||
<ReaderSettingsPanel :state="state" :customFonts="customFonts" @update="applyUpdate" />
|
||||
<ReaderSettingsPanel :state="state" :customFonts="customFonts" :is-fixed-layout="isFixedLayout" @update="applyUpdate" />
|
||||
</template>
|
||||
</ReaderHeader>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { BookOpen, LayoutGrid, Moon, Palette, ScrollText, Sun, Type } from '@lucide/vue'
|
||||
import type { ReaderState } from '../composables/useReaderState'
|
||||
import type { useCustomFonts } from '../composables/useCustomFonts'
|
||||
@@ -10,6 +10,7 @@ import { formatFontFamilyLabel } from '@/features/reader/shared/lib/font-display
|
||||
const props = defineProps<{
|
||||
state: ReaderState
|
||||
customFonts?: ReturnType<typeof useCustomFonts>
|
||||
isFixedLayout?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -27,12 +28,14 @@ const scrollMemory = ref<Record<Tab, number>>({
|
||||
layout: 0,
|
||||
})
|
||||
|
||||
const tabs: { id: Tab; icon: typeof Palette; label: string }[] = [
|
||||
const allTabs: { id: Tab; icon: typeof Palette; label: string }[] = [
|
||||
{ id: 'appearance', icon: Palette, label: 'Appearance' },
|
||||
{ id: 'text', icon: Type, label: 'Text' },
|
||||
{ id: 'layout', icon: LayoutGrid, label: 'Layout' },
|
||||
]
|
||||
|
||||
const tabs = computed(() => (props.isFixedLayout ? allTabs.filter((tab) => tab.id !== 'text') : allTabs))
|
||||
|
||||
const stepperButtonClass = 'size-8 rounded-lg border border-border text-lg font-light text-foreground transition-colors hover:bg-muted'
|
||||
const stepperGroupClass = 'flex min-w-[11rem] items-center justify-end gap-2'
|
||||
const stepperValueClass = 'w-[4rem] text-center text-sm font-mono font-medium tabular-nums text-foreground'
|
||||
@@ -105,6 +108,15 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.isFixedLayout,
|
||||
(fixed) => {
|
||||
if (fixed && activeTab.value === 'text') {
|
||||
activeTab.value = 'layout'
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function selectCustomFont(familyName: string) {
|
||||
if (!props.customFonts) return
|
||||
const cssFamilyName = props.customFonts.getCssFamilyForDisplay(familyName)
|
||||
@@ -115,6 +127,14 @@ function isCustomFontSelected(familyName: string): boolean {
|
||||
if (!props.customFonts) return false
|
||||
return props.customFonts.isFontFamilySelected(familyName, props.state.fontFamily)
|
||||
}
|
||||
|
||||
function setFixedLayoutSpreadAuto() {
|
||||
emit('update', { fixedLayoutSpread: 'auto' })
|
||||
}
|
||||
|
||||
function setFixedLayoutSpreadNone() {
|
||||
emit('update', { fixedLayoutSpread: 'none' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -201,7 +221,7 @@ function isCustomFontSelected(familyName: string): boolean {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="activeTab === 'text'">
|
||||
<template v-if="activeTab === 'text' && !isFixedLayout">
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="space-y-1 pr-3">
|
||||
@@ -282,116 +302,153 @@ function isCustomFontSelected(familyName: string): boolean {
|
||||
|
||||
<template v-if="activeTab === 'layout'">
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium leading-tight">Reading flow</p>
|
||||
<p class="text-xs leading-tight text-muted-foreground">Switch between paged and continuous scrolling.</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-1 rounded-lg bg-muted/55 p-1">
|
||||
<button
|
||||
class="flex h-[2.125rem] items-center justify-center gap-2 rounded-lg px-3 text-sm font-medium transition-colors"
|
||||
:class="
|
||||
state.flow === 'paginated'
|
||||
? 'bg-background text-foreground shadow-sm ring-1 ring-border'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-background/70'
|
||||
"
|
||||
@click="emit('update', { flow: 'paginated' })"
|
||||
>
|
||||
<BookOpen :size="15" />
|
||||
<span>Paginated</span>
|
||||
</button>
|
||||
<button
|
||||
class="flex h-[2.125rem] items-center justify-center gap-2 rounded-lg px-3 text-sm font-medium transition-colors"
|
||||
:class="
|
||||
state.flow === 'scrolled'
|
||||
? 'bg-background text-foreground shadow-sm ring-1 ring-border'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-background/70'
|
||||
"
|
||||
@click="emit('update', { flow: 'scrolled' })"
|
||||
>
|
||||
<ScrollText :size="15" />
|
||||
<span>Scrolled</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-px bg-border/70" />
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="space-y-1 pr-3">
|
||||
<p class="text-sm font-medium leading-tight">Max columns</p>
|
||||
<p class="text-xs leading-tight text-muted-foreground">Range: 1-10</p>
|
||||
<template v-if="isFixedLayout">
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium leading-tight">Page spreads</p>
|
||||
<p class="text-xs leading-tight text-muted-foreground">Choose how this image-based EPUB pairs pages.</p>
|
||||
</div>
|
||||
<div :class="stepperGroupClass">
|
||||
<button :class="stepperButtonClass" @click="step('maxColumnCount', -1, 1, 10)">−</button>
|
||||
<span :class="stepperValueClass">{{ state.maxColumnCount }}</span>
|
||||
<button :class="stepperButtonClass" @click="step('maxColumnCount', 1, 1, 10)">+</button>
|
||||
<div class="grid grid-cols-2 gap-1 rounded-lg bg-muted/55 p-1">
|
||||
<button
|
||||
class="flex h-[2.125rem] items-center justify-center gap-2 rounded-lg px-3 text-sm font-medium transition-colors"
|
||||
:class="
|
||||
state.fixedLayoutSpread === 'auto'
|
||||
? 'bg-background text-foreground shadow-sm ring-1 ring-border'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-background/70'
|
||||
"
|
||||
@click="setFixedLayoutSpreadAuto"
|
||||
>
|
||||
<LayoutGrid :size="15" />
|
||||
<span>Book default</span>
|
||||
</button>
|
||||
<button
|
||||
class="flex h-[2.125rem] items-center justify-center gap-2 rounded-lg px-3 text-sm font-medium transition-colors"
|
||||
:class="
|
||||
state.fixedLayoutSpread === 'none'
|
||||
? 'bg-background text-foreground shadow-sm ring-1 ring-border'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-background/70'
|
||||
"
|
||||
@click="setFixedLayoutSpreadNone"
|
||||
>
|
||||
<BookOpen :size="15" />
|
||||
<span>Single page</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium leading-tight">Reading flow</p>
|
||||
<p class="text-xs leading-tight text-muted-foreground">Switch between paged and continuous scrolling.</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-1 rounded-lg bg-muted/55 p-1">
|
||||
<button
|
||||
class="flex h-[2.125rem] items-center justify-center gap-2 rounded-lg px-3 text-sm font-medium transition-colors"
|
||||
:class="
|
||||
state.flow === 'paginated'
|
||||
? 'bg-background text-foreground shadow-sm ring-1 ring-border'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-background/70'
|
||||
"
|
||||
@click="emit('update', { flow: 'paginated' })"
|
||||
>
|
||||
<BookOpen :size="15" />
|
||||
<span>Paginated</span>
|
||||
</button>
|
||||
<button
|
||||
class="flex h-[2.125rem] items-center justify-center gap-2 rounded-lg px-3 text-sm font-medium transition-colors"
|
||||
:class="
|
||||
state.flow === 'scrolled'
|
||||
? 'bg-background text-foreground shadow-sm ring-1 ring-border'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-background/70'
|
||||
"
|
||||
@click="emit('update', { flow: 'scrolled' })"
|
||||
>
|
||||
<ScrollText :size="15" />
|
||||
<span>Scrolled</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="space-y-1 pr-3">
|
||||
<p class="text-sm font-medium leading-tight">Column gap</p>
|
||||
<p class="text-xs leading-tight text-muted-foreground">Range: 0-50%</p>
|
||||
<div class="h-px bg-border/70" />
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="space-y-1 pr-3">
|
||||
<p class="text-sm font-medium leading-tight">Text columns</p>
|
||||
<p class="text-xs leading-tight text-muted-foreground">Range: 1-10</p>
|
||||
</div>
|
||||
<div :class="stepperGroupClass">
|
||||
<button :class="stepperButtonClass" @click="step('maxColumnCount', -1, 1, 10)">−</button>
|
||||
<span :class="stepperValueClass">{{ state.maxColumnCount }}</span>
|
||||
<button :class="stepperButtonClass" @click="step('maxColumnCount', 1, 1, 10)">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="stepperGroupClass">
|
||||
<button :class="stepperButtonClass" @click="step('gap', -0.01, 0, 0.5, 2)">−</button>
|
||||
<span :class="stepperValueClass">{{ formatGap(state.gap) }}</span>
|
||||
<button :class="stepperButtonClass" @click="step('gap', 0.01, 0, 0.5, 2)">+</button>
|
||||
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="space-y-1 pr-3">
|
||||
<p class="text-sm font-medium leading-tight">Column gap</p>
|
||||
<p class="text-xs leading-tight text-muted-foreground">Range: 0-50%</p>
|
||||
</div>
|
||||
<div :class="stepperGroupClass">
|
||||
<button :class="stepperButtonClass" @click="step('gap', -0.01, 0, 0.5, 2)">−</button>
|
||||
<span :class="stepperValueClass">{{ formatGap(state.gap) }}</span>
|
||||
<button :class="stepperButtonClass" @click="step('gap', 0.01, 0, 0.5, 2)">+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="space-y-1 pr-3">
|
||||
<p class="text-sm font-medium leading-tight">Max width</p>
|
||||
<p class="text-xs leading-tight text-muted-foreground">Range: 400-1600</p>
|
||||
</div>
|
||||
<div :class="stepperGroupClass">
|
||||
<button :class="stepperButtonClass" @click="step('maxInlineSize', -40, 400, 1600)">−</button>
|
||||
<span :class="stepperValueClass">{{ state.maxInlineSize }}px</span>
|
||||
<button :class="stepperButtonClass" @click="step('maxInlineSize', 40, 400, 1600)">+</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="space-y-1 pr-3">
|
||||
<p class="text-sm font-medium leading-tight">Max width</p>
|
||||
<p class="text-xs leading-tight text-muted-foreground">Range: 400-1600</p>
|
||||
<div class="h-px bg-border/70" />
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="space-y-1 pr-3">
|
||||
<p class="text-sm font-medium leading-tight">Justify text</p>
|
||||
<p class="text-xs leading-tight text-muted-foreground">Enable full-width paragraph alignment.</p>
|
||||
</div>
|
||||
<button
|
||||
class="relative h-6 w-11 shrink-0 rounded-full transition-colors"
|
||||
:class="state.justify ? 'bg-primary' : 'bg-muted'"
|
||||
@click="emit('update', { justify: !state.justify })"
|
||||
>
|
||||
<div
|
||||
class="absolute top-1 h-4 w-4 rounded-full bg-white shadow-sm transition-transform"
|
||||
:class="state.justify ? 'translate-x-6' : 'translate-x-1'"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div :class="stepperGroupClass">
|
||||
<button :class="stepperButtonClass" @click="step('maxInlineSize', -40, 400, 1600)">−</button>
|
||||
<span :class="stepperValueClass">{{ state.maxInlineSize }}px</span>
|
||||
<button :class="stepperButtonClass" @click="step('maxInlineSize', 40, 400, 1600)">+</button>
|
||||
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="space-y-1 pr-3">
|
||||
<p class="text-sm font-medium leading-tight">Hyphenation</p>
|
||||
<p class="text-xs leading-tight text-muted-foreground">Enable automatic word-break hyphenation.</p>
|
||||
</div>
|
||||
<button
|
||||
class="relative h-6 w-11 shrink-0 rounded-full transition-colors"
|
||||
:class="state.hyphenate ? 'bg-primary' : 'bg-muted'"
|
||||
@click="emit('update', { hyphenate: !state.hyphenate })"
|
||||
>
|
||||
<div
|
||||
class="absolute top-1 h-4 w-4 rounded-full bg-white shadow-sm transition-transform"
|
||||
:class="state.hyphenate ? 'translate-x-6' : 'translate-x-1'"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-px bg-border/70" />
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="space-y-1 pr-3">
|
||||
<p class="text-sm font-medium leading-tight">Justify text</p>
|
||||
<p class="text-xs leading-tight text-muted-foreground">Enable full-width paragraph alignment.</p>
|
||||
</div>
|
||||
<button
|
||||
class="relative h-6 w-11 shrink-0 rounded-full transition-colors"
|
||||
:class="state.justify ? 'bg-primary' : 'bg-muted'"
|
||||
@click="emit('update', { justify: !state.justify })"
|
||||
>
|
||||
<div
|
||||
class="absolute top-1 h-4 w-4 rounded-full bg-white shadow-sm transition-transform"
|
||||
:class="state.justify ? 'translate-x-6' : 'translate-x-1'"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="space-y-1 pr-3">
|
||||
<p class="text-sm font-medium leading-tight">Hyphenation</p>
|
||||
<p class="text-xs leading-tight text-muted-foreground">Enable automatic word-break hyphenation.</p>
|
||||
</div>
|
||||
<button
|
||||
class="relative h-6 w-11 shrink-0 rounded-full transition-colors"
|
||||
:class="state.hyphenate ? 'bg-primary' : 'bg-muted'"
|
||||
@click="emit('update', { hyphenate: !state.hyphenate })"
|
||||
>
|
||||
<div
|
||||
class="absolute top-1 h-4 w-4 rounded-full bg-white shadow-sm transition-transform"
|
||||
:class="state.hyphenate ? 'translate-x-6' : 'translate-x-1'"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -17,6 +17,7 @@ function makeState(overrides: Partial<ReaderState> = {}): ReaderState {
|
||||
isDark: false,
|
||||
themeName: 'default',
|
||||
flow: 'paginated',
|
||||
fixedLayoutSpread: 'auto',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
@@ -57,4 +58,58 @@ describe('ReaderSettingsPanel', () => {
|
||||
|
||||
expect(wrapper.emitted('update')?.[1]).toEqual([{ flow: 'scrolled' }])
|
||||
})
|
||||
|
||||
it('emits fixed-layout EPUB spread updates', async () => {
|
||||
const wrapper = mount(ReaderSettingsPanel, {
|
||||
props: {
|
||||
state: makeState({ fixedLayoutSpread: 'auto' }),
|
||||
isFixedLayout: true,
|
||||
},
|
||||
})
|
||||
|
||||
const layoutTab = wrapper.findAll('button').find((btn) => btn.text().includes('Layout'))
|
||||
await layoutTab?.trigger('click')
|
||||
|
||||
const singlePageButton = wrapper.findAll('button').find((btn) => btn.text().includes('Single page'))
|
||||
await singlePageButton?.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('update')?.[0]).toEqual([{ fixedLayoutSpread: 'none' }])
|
||||
})
|
||||
|
||||
it('hides fixed-layout spread controls for reflowable books', async () => {
|
||||
const wrapper = mount(ReaderSettingsPanel, {
|
||||
props: {
|
||||
state: makeState({ fixedLayoutSpread: 'auto' }),
|
||||
},
|
||||
})
|
||||
|
||||
const layoutTab = wrapper.findAll('button').find((btn) => btn.text().includes('Layout'))
|
||||
await layoutTab?.trigger('click')
|
||||
|
||||
expect(wrapper.text()).toContain('Reading flow')
|
||||
expect(wrapper.text()).toContain('Text columns')
|
||||
expect(wrapper.text()).not.toContain('Page spreads')
|
||||
expect(wrapper.text()).not.toContain('Single page')
|
||||
})
|
||||
|
||||
it('shows only fixed-layout layout controls for fixed-layout books', async () => {
|
||||
const wrapper = mount(ReaderSettingsPanel, {
|
||||
props: {
|
||||
state: makeState({ fixedLayoutSpread: 'auto', flow: 'scrolled' }),
|
||||
isFixedLayout: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.findAll('button').some((btn) => btn.text().includes('Text'))).toBe(false)
|
||||
|
||||
const layoutTab = wrapper.findAll('button').find((btn) => btn.text().includes('Layout'))
|
||||
await layoutTab?.trigger('click')
|
||||
|
||||
expect(wrapper.text()).toContain('Page spreads')
|
||||
expect(wrapper.text()).toContain('Book default')
|
||||
expect(wrapper.text()).toContain('Single page')
|
||||
expect(wrapper.text()).not.toContain('Reading flow')
|
||||
expect(wrapper.text()).not.toContain('Scrolled')
|
||||
expect(wrapper.text()).not.toContain('Text columns')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -35,6 +35,7 @@ import { useFoliate } from '../useFoliate'
|
||||
|
||||
describe('useFoliate.open', () => {
|
||||
let container: HTMLDivElement
|
||||
let mockOpen: ReturnType<typeof vi.fn>
|
||||
let mockGoTo: ReturnType<typeof vi.fn>
|
||||
let mockGoToFraction: ReturnType<typeof vi.fn>
|
||||
let includeGoToFraction: boolean
|
||||
@@ -43,8 +44,9 @@ describe('useFoliate.open', () => {
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
|
||||
mockGoTo = vi.fn<() => Promise<void>>().mockResolvedValue(undefined)
|
||||
mockGoToFraction = vi.fn<() => void>()
|
||||
mockOpen = vi.fn<(book?: unknown) => Promise<void>>().mockResolvedValue(undefined)
|
||||
mockGoTo = vi.fn<(target?: unknown) => Promise<unknown>>().mockResolvedValue({ index: 0 })
|
||||
mockGoToFraction = vi.fn<(fraction?: unknown) => Promise<void>>().mockResolvedValue(undefined)
|
||||
includeGoToFraction = true
|
||||
|
||||
const originalCreateElement = document.createElement.bind(document)
|
||||
@@ -52,7 +54,7 @@ describe('useFoliate.open', () => {
|
||||
if (tag === 'foliate-view') {
|
||||
const el = originalCreateElement('div')
|
||||
const view = {
|
||||
open: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
|
||||
open: mockOpen,
|
||||
goTo: mockGoTo,
|
||||
renderer: {
|
||||
setAttribute: vi.fn<(name: string, value: string) => void>(),
|
||||
@@ -102,6 +104,60 @@ describe('useFoliate.open', () => {
|
||||
expect(makeStreamingBook).toHaveBeenCalledWith(1, '/api/v1/epub', {}, api, null, 2)
|
||||
})
|
||||
|
||||
it('forces fixed-layout EPUB spreads off before opening when requested', async () => {
|
||||
const book = { type: 'book', rendition: { layout: 'pre-paginated', spread: 'both' } }
|
||||
;(window as unknown as { makeStreamingBook: ReturnType<typeof vi.fn> }).makeStreamingBook.mockResolvedValueOnce(book)
|
||||
const foliate = useFoliate(() => container)
|
||||
|
||||
await foliate.open(1, 2, 'epub', null, undefined, { fixedLayoutSpread: 'none' })
|
||||
|
||||
expect(book.rendition).toMatchObject({ layout: 'pre-paginated', spread: 'none' })
|
||||
expect(mockOpen).toHaveBeenCalledWith(book)
|
||||
})
|
||||
|
||||
it('leaves EPUB spread metadata unchanged when fixed-layout spread mode is auto', async () => {
|
||||
const book = { type: 'book', rendition: { layout: 'pre-paginated', spread: 'both' } }
|
||||
;(window as unknown as { makeStreamingBook: ReturnType<typeof vi.fn> }).makeStreamingBook.mockResolvedValueOnce(book)
|
||||
const foliate = useFoliate(() => container)
|
||||
|
||||
await foliate.open(1, 2, 'epub', null, undefined, { fixedLayoutSpread: 'auto' })
|
||||
|
||||
expect(book.rendition).toMatchObject({ layout: 'pre-paginated', spread: 'both' })
|
||||
expect(mockOpen).toHaveBeenCalledWith(book)
|
||||
})
|
||||
|
||||
it('does not apply fixed-layout spread options to reflowable EPUBs', async () => {
|
||||
const book = { type: 'book', rendition: { layout: 'reflowable', spread: 'auto' } }
|
||||
;(window as unknown as { makeStreamingBook: ReturnType<typeof vi.fn> }).makeStreamingBook.mockResolvedValueOnce(book)
|
||||
const foliate = useFoliate(() => container)
|
||||
|
||||
await foliate.open(1, 2, 'epub', null, undefined, { fixedLayoutSpread: 'none' })
|
||||
|
||||
expect(book.rendition).toMatchObject({ layout: 'reflowable', spread: 'auto' })
|
||||
expect(foliate.isFixedLayout.value).toBe(false)
|
||||
})
|
||||
|
||||
it('tracks whether the opened EPUB is fixed-layout', async () => {
|
||||
const book = { type: 'book', rendition: { layout: 'pre-paginated', spread: 'auto' } }
|
||||
;(window as unknown as { makeStreamingBook: ReturnType<typeof vi.fn> }).makeStreamingBook.mockResolvedValueOnce(book)
|
||||
const foliate = useFoliate(() => container)
|
||||
|
||||
await foliate.open(1, 2, 'epub', null, undefined)
|
||||
|
||||
expect(foliate.isFixedLayout.value).toBe(true)
|
||||
})
|
||||
|
||||
it('restores fixed-layout EPUBs by fraction instead of CFI', async () => {
|
||||
const book = { type: 'book', rendition: { layout: 'pre-paginated', spread: 'auto' } }
|
||||
;(window as unknown as { makeStreamingBook: ReturnType<typeof vi.fn> }).makeStreamingBook.mockResolvedValueOnce(book)
|
||||
const foliate = useFoliate(() => container)
|
||||
|
||||
await foliate.open(1, 2, 'epub', 'epubcfi(/6/14)', 0.69)
|
||||
|
||||
expect(mockGoTo).not.toHaveBeenCalledWith('epubcfi(/6/14)')
|
||||
expect(mockGoToFraction).toHaveBeenCalledWith(0.69)
|
||||
})
|
||||
|
||||
it('navigates to fallback fraction when cfi is null and fraction > 0', async () => {
|
||||
const foliate = useFoliate(() => container)
|
||||
|
||||
@@ -156,6 +212,16 @@ describe('useFoliate.open', () => {
|
||||
expect(mockGoToFraction).toHaveBeenCalledWith(0.42)
|
||||
})
|
||||
|
||||
it('falls back to fraction when CFI navigation resolves without opening a target', async () => {
|
||||
mockGoTo.mockResolvedValueOnce(undefined)
|
||||
const foliate = useFoliate(() => container)
|
||||
|
||||
await foliate.open(1, 1, 'epub', 'epubcfi(/bad)', 0.42)
|
||||
|
||||
expect(mockGoTo).toHaveBeenCalledWith('epubcfi(/bad)')
|
||||
expect(mockGoToFraction).toHaveBeenCalledWith(0.42)
|
||||
})
|
||||
|
||||
it('does not call goTo or goToFraction when container is null', async () => {
|
||||
const foliate = useFoliate(() => null)
|
||||
|
||||
|
||||
@@ -28,6 +28,17 @@ describe('useReaderState', () => {
|
||||
expect(state.currentTheme.value.name).toBe('default')
|
||||
})
|
||||
|
||||
it('tracks fixed-layout EPUB spread mode', () => {
|
||||
const state = useReaderState()
|
||||
|
||||
expect(state.fixedLayoutSpread.value).toBe('auto')
|
||||
|
||||
state.setFixedLayoutSpread('none')
|
||||
|
||||
expect(state.fixedLayoutSpread.value).toBe('none')
|
||||
expect(state.state.value.fixedLayoutSpread).toBe('none')
|
||||
})
|
||||
|
||||
it('switches active mode between light and dark variants', () => {
|
||||
const state = useReaderState()
|
||||
|
||||
@@ -83,6 +94,24 @@ describe('useReaderState', () => {
|
||||
expect(renderer.setAttribute).toHaveBeenCalledWith('flow', 'scrolled')
|
||||
})
|
||||
|
||||
it('can override renderer flow without changing saved reader state', () => {
|
||||
const state = useReaderState()
|
||||
state.setFlow('scrolled')
|
||||
|
||||
const renderer = {
|
||||
setAttribute: vi.fn<(name: string, value: string) => void>(),
|
||||
removeAttribute: vi.fn<(name: string) => void>(),
|
||||
setStyles: vi.fn<(css: string) => void>(),
|
||||
}
|
||||
|
||||
state.applyToRenderer(renderer, { flow: 'paginated' })
|
||||
|
||||
expect(state.flow.value).toBe('scrolled')
|
||||
expect(renderer.setAttribute).toHaveBeenCalledWith('margin', '40px')
|
||||
expect(renderer.setAttribute).toHaveBeenCalledWith('flow', 'paginated')
|
||||
expect(renderer.removeAttribute).not.toHaveBeenCalledWith('margin')
|
||||
})
|
||||
|
||||
it('generates CSS with optional font-family override and text controls', () => {
|
||||
const state = useReaderState()
|
||||
state.setFontFamily('serif')
|
||||
|
||||
@@ -3,7 +3,7 @@ import { api } from '@/lib/api'
|
||||
import { useFoliateAnnotations } from './useFoliateAnnotations'
|
||||
import { useFoliateSelection } from './useFoliateSelection'
|
||||
import { useFoliateInput } from './useFoliateInput'
|
||||
import type { EpubBookInfo } from '@bookorbit/types'
|
||||
import type { EpubBookInfo, EpubReaderSettings } from '@bookorbit/types'
|
||||
|
||||
export interface RelocateDetail {
|
||||
cfi?: string | null
|
||||
@@ -35,6 +35,30 @@ export interface FoliateLocationContext {
|
||||
fraction: number | null
|
||||
}
|
||||
|
||||
export interface EpubOpenOptions {
|
||||
fixedLayoutSpread?: EpubReaderSettings['fixedLayoutSpread']
|
||||
}
|
||||
|
||||
function isResolvedNavigation(value: unknown): boolean {
|
||||
return Boolean(value && typeof value === 'object' && typeof (value as { index?: unknown }).index === 'number')
|
||||
}
|
||||
|
||||
function isFixedLayoutBook(book: unknown): boolean {
|
||||
return Boolean(book && typeof book === 'object' && (book as { rendition?: { layout?: unknown } }).rendition?.layout === 'pre-paginated')
|
||||
}
|
||||
|
||||
function applyEpubOpenOptions(book: unknown, options: EpubOpenOptions | undefined) {
|
||||
if (!book || typeof book !== 'object') return
|
||||
if (options?.fixedLayoutSpread !== 'none') return
|
||||
if (!isFixedLayoutBook(book)) return
|
||||
|
||||
const epubBook = book as { rendition?: Record<string, unknown> }
|
||||
epubBook.rendition = {
|
||||
...epubBook.rendition,
|
||||
spread: 'none',
|
||||
}
|
||||
}
|
||||
|
||||
export function useFoliate(
|
||||
container: () => HTMLElement | null,
|
||||
onRelocate?: (detail: RelocateDetail) => void,
|
||||
@@ -46,6 +70,7 @@ export function useFoliate(
|
||||
const fraction = ref(0)
|
||||
const viewRef = ref<unknown>(null)
|
||||
const bookLanguage = ref<string>('en')
|
||||
const isFixedLayout = ref(false)
|
||||
|
||||
let onAnnotationClick: ((cfi: string, popupPosition: { x: number; y: number; showBelow: boolean }) => void) | null = null
|
||||
|
||||
@@ -71,14 +96,16 @@ export function useFoliate(
|
||||
await customElements.whenDefined('foliate-view')
|
||||
}
|
||||
|
||||
async function open(bookId: number, fileId: number, format: string, cfi?: string | null, fallbackFraction?: number) {
|
||||
async function open(bookId: number, fileId: number, format: string, cfi?: string | null, fallbackFraction?: number, options?: EpubOpenOptions) {
|
||||
const el = container()
|
||||
if (!el) return
|
||||
|
||||
loading.value = true
|
||||
error.value = null
|
||||
isFixedLayout.value = false
|
||||
|
||||
let loadTimeoutId: ReturnType<typeof setTimeout> | undefined
|
||||
let initialNavigationPending = true
|
||||
|
||||
try {
|
||||
await loadScript()
|
||||
@@ -86,8 +113,8 @@ export function useFoliate(
|
||||
const view = document.createElement('foliate-view') as HTMLElement & {
|
||||
renderer: FoliateRenderer
|
||||
open: (file: File) => Promise<void>
|
||||
goTo: (target: string | number) => Promise<void>
|
||||
goToFraction?: (f: number) => void
|
||||
goTo: (target: string | number) => Promise<unknown>
|
||||
goToFraction?: (f: number) => void | Promise<void>
|
||||
book?: { toc?: unknown[] }
|
||||
getSectionFractions?: () => number[]
|
||||
prev?: () => void
|
||||
@@ -100,6 +127,7 @@ export function useFoliate(
|
||||
clearSearch?: () => void
|
||||
}
|
||||
view.style.cssText = 'width:100%;height:100%;display:block;'
|
||||
getViewEl()?.destroy?.()
|
||||
el.innerHTML = ''
|
||||
el.appendChild(view)
|
||||
viewRef.value = view
|
||||
@@ -111,7 +139,9 @@ export function useFoliate(
|
||||
view.addEventListener('load', (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail
|
||||
clearTimeout(loadTimeoutId)
|
||||
loading.value = false
|
||||
if (!initialNavigationPending) {
|
||||
loading.value = false
|
||||
}
|
||||
// The paginator's internal #view reference is updated in a microtask after the
|
||||
// 'load' event fires. Deferring to a macrotask ensures setStyles targets the
|
||||
// new chapter document, not the previous one.
|
||||
@@ -191,6 +221,8 @@ export function useFoliate(
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
let shouldRestoreByFraction = false
|
||||
|
||||
if (format === 'epub') {
|
||||
const infoRes = await api(`/api/v1/epub/${bookId}/info?fileId=${fileId}`)
|
||||
if (!infoRes.ok) throw new Error(`Failed to fetch EPUB info: ${infoRes.status}`)
|
||||
@@ -210,6 +242,9 @@ export function useFoliate(
|
||||
| undefined
|
||||
if (!makeStreamingBook) throw new Error('makeStreamingBook not available')
|
||||
const book = await makeStreamingBook(bookId, '/api/v1/epub', bookInfo, api, null, fileId)
|
||||
applyEpubOpenOptions(book, options)
|
||||
shouldRestoreByFraction = isFixedLayoutBook(book)
|
||||
isFixedLayout.value = shouldRestoreByFraction
|
||||
await view.open(book as never)
|
||||
} else {
|
||||
const mimeType = format === 'pdf' ? 'application/pdf' : 'application/zip'
|
||||
@@ -222,18 +257,18 @@ export function useFoliate(
|
||||
}
|
||||
if (onApplyStyles) onApplyStyles(view.renderer)
|
||||
let didNavigate = false
|
||||
if (cfi) {
|
||||
if (cfi && !shouldRestoreByFraction) {
|
||||
await view
|
||||
.goTo(cfi)
|
||||
.then(() => {
|
||||
didNavigate = true
|
||||
.then((result) => {
|
||||
didNavigate = isResolvedNavigation(result)
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
if (!didNavigate && fallbackFraction !== undefined && fallbackFraction > 0) {
|
||||
if (typeof view.goToFraction === 'function') {
|
||||
try {
|
||||
view.goToFraction(fallbackFraction)
|
||||
await view.goToFraction(fallbackFraction)
|
||||
didNavigate = true
|
||||
} catch {
|
||||
didNavigate = false
|
||||
@@ -243,6 +278,8 @@ export function useFoliate(
|
||||
if (!didNavigate) {
|
||||
await view.goTo(0).catch(() => {})
|
||||
}
|
||||
initialNavigationPending = false
|
||||
loading.value = false
|
||||
} catch (e) {
|
||||
console.error('[useFoliate]', e)
|
||||
clearTimeout(loadTimeoutId)
|
||||
@@ -256,8 +293,8 @@ export function useFoliate(
|
||||
| (ReturnType<typeof document.createElement> & {
|
||||
prev?: () => void
|
||||
next?: () => void
|
||||
goTo?: (t: string | number) => Promise<void>
|
||||
goToFraction?: (f: number) => void
|
||||
goTo?: (t: string | number) => Promise<unknown>
|
||||
goToFraction?: (f: number) => void | Promise<void>
|
||||
getSectionFractions?: () => number[]
|
||||
resolveNavigation?: (target: string | number) => { index?: number } | Promise<{ index?: number }>
|
||||
getTOCItemOf?: (target: string | number) => Promise<{ label?: string } | null>
|
||||
@@ -311,9 +348,10 @@ export function useFoliate(
|
||||
error,
|
||||
fraction,
|
||||
bookLanguage,
|
||||
isFixedLayout,
|
||||
view: viewRef,
|
||||
open: (bookId: number, fileId: number, format: string, cfi?: string | null, fallbackFraction?: number) =>
|
||||
open(bookId, fileId, format, cfi, fallbackFraction),
|
||||
open: (bookId: number, fileId: number, format: string, cfi?: string | null, fallbackFraction?: number, options?: EpubOpenOptions) =>
|
||||
open(bookId, fileId, format, cfi, fallbackFraction, options),
|
||||
prev: () => getViewEl()?.prev?.(),
|
||||
next: () => getViewEl()?.next?.(),
|
||||
goTo: (t: string | number) => getViewEl()?.goTo?.(t),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import type { EpubReaderSettings } from '@bookorbit/types'
|
||||
import { themes } from '../constants/themes'
|
||||
import type { Theme, ThemeMode } from '../constants/themes'
|
||||
import type { FoliateRenderer } from './useFoliate'
|
||||
@@ -16,6 +17,11 @@ export interface ReaderState {
|
||||
isDark: boolean
|
||||
themeName: string
|
||||
flow: 'paginated' | 'scrolled'
|
||||
fixedLayoutSpread: EpubReaderSettings['fixedLayoutSpread']
|
||||
}
|
||||
|
||||
export interface ApplyReaderStateOptions {
|
||||
flow?: ReaderState['flow']
|
||||
}
|
||||
|
||||
const defaults: ReaderState = {
|
||||
@@ -31,6 +37,7 @@ const defaults: ReaderState = {
|
||||
isDark: false,
|
||||
themeName: 'default',
|
||||
flow: 'paginated',
|
||||
fixedLayoutSpread: 'auto',
|
||||
}
|
||||
|
||||
export function useReaderState() {
|
||||
@@ -46,6 +53,7 @@ export function useReaderState() {
|
||||
const isDark = ref(defaults.isDark)
|
||||
const themeName = ref(defaults.themeName)
|
||||
const flow = ref<'paginated' | 'scrolled'>(defaults.flow)
|
||||
const fixedLayoutSpread = ref<EpubReaderSettings['fixedLayoutSpread']>(defaults.fixedLayoutSpread)
|
||||
|
||||
const fontFaceCSS = ref('')
|
||||
|
||||
@@ -62,6 +70,7 @@ export function useReaderState() {
|
||||
isDark: isDark.value,
|
||||
themeName: themeName.value,
|
||||
flow: flow.value,
|
||||
fixedLayoutSpread: fixedLayoutSpread.value,
|
||||
}))
|
||||
|
||||
const currentTheme = computed<Theme>(() => themes.find((t) => t.name === themeName.value) ?? themes[0]!)
|
||||
@@ -186,19 +195,20 @@ export function useReaderState() {
|
||||
`
|
||||
}
|
||||
|
||||
function applyToRenderer(renderer: FoliateRenderer): void {
|
||||
function applyToRenderer(renderer: FoliateRenderer, options: ApplyReaderStateOptions = {}): void {
|
||||
if (!renderer) return
|
||||
const s = state.value
|
||||
const rendererFlow = options.flow ?? s.flow
|
||||
renderer.setAttribute('max-column-count', String(s.maxColumnCount))
|
||||
renderer.setAttribute('gap', `${s.gap * 100}%`)
|
||||
renderer.setAttribute('max-inline-size', `${s.maxInlineSize}px`)
|
||||
renderer.setAttribute('max-block-size', `${s.maxBlockSize}px`)
|
||||
if (s.flow === 'paginated') {
|
||||
if (rendererFlow === 'paginated') {
|
||||
renderer.setAttribute('margin', '40px')
|
||||
} else {
|
||||
renderer.removeAttribute('margin')
|
||||
}
|
||||
renderer.setAttribute('flow', s.flow)
|
||||
renderer.setAttribute('flow', rendererFlow)
|
||||
if (typeof renderer.setStyles === 'function') {
|
||||
renderer.setStyles(generateCSS())
|
||||
}
|
||||
@@ -240,6 +250,9 @@ export function useReaderState() {
|
||||
function setFlow(v: 'paginated' | 'scrolled') {
|
||||
flow.value = v
|
||||
}
|
||||
function setFixedLayoutSpread(v: EpubReaderSettings['fixedLayoutSpread']) {
|
||||
fixedLayoutSpread.value = v
|
||||
}
|
||||
|
||||
function setFontFaceCSS(css: string) {
|
||||
fontFaceCSS.value = css
|
||||
@@ -259,6 +272,7 @@ export function useReaderState() {
|
||||
isDark,
|
||||
themeName,
|
||||
flow,
|
||||
fixedLayoutSpread,
|
||||
currentTheme,
|
||||
activeMode,
|
||||
themes,
|
||||
@@ -276,6 +290,7 @@ export function useReaderState() {
|
||||
setIsDark,
|
||||
setThemeName,
|
||||
setFlow,
|
||||
setFixedLayoutSpread,
|
||||
setFontFaceCSS,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,17 @@ describe('useReaderSettings - load() with valid localStorage epub delta', () =>
|
||||
|
||||
expect(s.effective.value).toMatchObject({ ...READER_GROUP_DEFAULTS.epub, fontSize: 24 })
|
||||
})
|
||||
|
||||
it('filters invalid EPUB spread settings from localStorage deltas', async () => {
|
||||
const raw = { fontSize: 20, fixedLayoutSpread: 'two-page' }
|
||||
localStorage.setItem(`reader:book:${BOOK_FILE_ID}`, JSON.stringify(raw))
|
||||
|
||||
const s = useReaderSettings(BOOK_FILE_ID, 'epub')
|
||||
await s.load()
|
||||
|
||||
expect(s.bookDelta.value).toEqual({ fontSize: 20 })
|
||||
expect(localStorage.getItem(`reader:book:${BOOK_FILE_ID}`)).toBe(JSON.stringify({ fontSize: 20 }))
|
||||
})
|
||||
})
|
||||
|
||||
describe('useReaderSettings - load() with invalid localStorage value', () => {
|
||||
@@ -329,6 +340,18 @@ describe('useReaderDefaultSettings - load() with localStorage', () => {
|
||||
expect(s.effective.value).toMatchObject({ fontSize: 20 })
|
||||
})
|
||||
|
||||
it('fills fixedLayoutSpread for older epub defaults in localStorage', async () => {
|
||||
const stored = { ...READER_GROUP_DEFAULTS.epub, fontSize: 20 }
|
||||
delete (stored as Record<string, unknown>).fixedLayoutSpread
|
||||
localStorage.setItem('reader:default:epub', JSON.stringify(stored))
|
||||
|
||||
const s = useReaderDefaultSettings('epub')
|
||||
await s.load()
|
||||
|
||||
expect(s.effective.value).toMatchObject({ fontSize: 20, fixedLayoutSpread: 'auto' })
|
||||
expect(JSON.parse(localStorage.getItem('reader:default:epub') ?? '{}')).toMatchObject({ fixedLayoutSpread: 'auto' })
|
||||
})
|
||||
|
||||
it('merges cbx localStorage settings with CBX_READER_DEFAULTS', async () => {
|
||||
const stored = { fitMode: 'fit-width', viewMode: 'single' }
|
||||
localStorage.setItem('reader:default:cbx', JSON.stringify(stored))
|
||||
|
||||
@@ -4,7 +4,9 @@ import { api } from '@/lib/api'
|
||||
import { useAuth } from '@/features/auth/composables/useAuth'
|
||||
import {
|
||||
CBX_READER_DEFAULTS,
|
||||
EPUB_READER_DEFAULTS,
|
||||
type CbxReaderSettings,
|
||||
type EpubReaderSettings,
|
||||
type ReaderFormatGroup,
|
||||
type ReaderSettings,
|
||||
READER_GROUP_DEFAULTS,
|
||||
@@ -41,6 +43,68 @@ function jsonEqual(a: unknown, b: unknown): boolean {
|
||||
return JSON.stringify(a) === JSON.stringify(b)
|
||||
}
|
||||
|
||||
function isNumberInRange(value: unknown, min: number, max: number): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value >= min && value <= max
|
||||
}
|
||||
|
||||
function isIntegerInRange(value: unknown, min: number, max: number): value is number {
|
||||
return Number.isInteger(value) && isNumberInRange(value, min, max)
|
||||
}
|
||||
|
||||
function sanitizeEpubPartialSettings(settings: unknown): Partial<EpubReaderSettings> | null {
|
||||
if (!isRecord(settings)) return null
|
||||
|
||||
const out: Partial<EpubReaderSettings> = {}
|
||||
|
||||
if (typeof settings.themeName === 'string' && settings.themeName.length > 0) {
|
||||
out.themeName = settings.themeName
|
||||
}
|
||||
if (typeof settings.isDark === 'boolean') {
|
||||
out.isDark = settings.isDark
|
||||
}
|
||||
if ((typeof settings.fontFamily === 'string' && settings.fontFamily.length > 0) || settings.fontFamily === null) {
|
||||
out.fontFamily = settings.fontFamily
|
||||
}
|
||||
if (isNumberInRange(settings.fontSize, 10, 32)) {
|
||||
out.fontSize = settings.fontSize
|
||||
}
|
||||
if (isNumberInRange(settings.lineHeight, 0.8, 3)) {
|
||||
out.lineHeight = settings.lineHeight
|
||||
}
|
||||
if (isIntegerInRange(settings.maxColumnCount, 1, 10)) {
|
||||
out.maxColumnCount = settings.maxColumnCount
|
||||
}
|
||||
if (isNumberInRange(settings.gap, 0, 0.5)) {
|
||||
out.gap = settings.gap
|
||||
}
|
||||
if (isIntegerInRange(settings.maxInlineSize, 400, 1600)) {
|
||||
out.maxInlineSize = settings.maxInlineSize
|
||||
}
|
||||
if (isIntegerInRange(settings.maxBlockSize, 600, 2400)) {
|
||||
out.maxBlockSize = settings.maxBlockSize
|
||||
}
|
||||
if (typeof settings.justify === 'boolean') {
|
||||
out.justify = settings.justify
|
||||
}
|
||||
if (typeof settings.hyphenate === 'boolean') {
|
||||
out.hyphenate = settings.hyphenate
|
||||
}
|
||||
if (settings.flow === 'paginated' || settings.flow === 'scrolled') {
|
||||
out.flow = settings.flow
|
||||
}
|
||||
if (typeof settings.overrideBookFormatting === 'boolean') {
|
||||
out.overrideBookFormatting = settings.overrideBookFormatting
|
||||
}
|
||||
if (settings.footerDisplayMode === 0 || settings.footerDisplayMode === 1 || settings.footerDisplayMode === 2) {
|
||||
out.footerDisplayMode = settings.footerDisplayMode
|
||||
}
|
||||
if (settings.fixedLayoutSpread === 'auto' || settings.fixedLayoutSpread === 'none') {
|
||||
out.fixedLayoutSpread = settings.fixedLayoutSpread
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
function sanitizeCbxPartialSettings(settings: unknown): Partial<CbxReaderSettings> | null {
|
||||
if (!isRecord(settings)) return null
|
||||
|
||||
@@ -75,12 +139,21 @@ function sanitizeCbxPartialSettings(settings: unknown): Partial<CbxReaderSetting
|
||||
}
|
||||
|
||||
function sanitizeBookDelta(group: ReaderFormatGroup, raw: unknown): Partial<ReaderSettings> | null {
|
||||
if (group === 'epub') return sanitizeEpubPartialSettings(raw) as Partial<ReaderSettings> | null
|
||||
if (group !== 'cbx') return isRecord(raw) ? (raw as Partial<ReaderSettings>) : null
|
||||
const sanitized = sanitizeCbxPartialSettings(raw)
|
||||
return sanitized as Partial<ReaderSettings> | null
|
||||
}
|
||||
|
||||
function sanitizeDefaultSettings(group: ReaderFormatGroup, raw: unknown): ReaderSettings | null {
|
||||
if (group === 'epub') {
|
||||
const sanitized = sanitizeEpubPartialSettings(raw)
|
||||
if (!sanitized) return null
|
||||
return {
|
||||
...EPUB_READER_DEFAULTS,
|
||||
...sanitized,
|
||||
} as ReaderSettings
|
||||
}
|
||||
if (group !== 'cbx') return isRecord(raw) ? (raw as unknown as ReaderSettings) : null
|
||||
|
||||
const sanitized = sanitizeCbxPartialSettings(raw)
|
||||
|
||||
@@ -70,6 +70,14 @@ onUnmounted(() => {
|
||||
previewStyleEl.value?.remove()
|
||||
previewStyleEl.value = null
|
||||
})
|
||||
|
||||
function setFixedLayoutSpreadAuto() {
|
||||
update({ fixedLayoutSpread: 'auto' })
|
||||
}
|
||||
|
||||
function setFixedLayoutSpreadNone() {
|
||||
update({ fixedLayoutSpread: 'none' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -144,6 +152,34 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fixed-layout spread -->
|
||||
<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>
|
||||
<p class="settings-label">Fixed-layout page spreads</p>
|
||||
<p class="settings-hint">Default for manga, comics, and image-based EPUBs</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-1.5 p-1 rounded-lg border border-border bg-muted/50 self-start">
|
||||
<button
|
||||
class="h-8 px-3 rounded-md text-xs font-medium transition-colors"
|
||||
:class="
|
||||
effective.fixedLayoutSpread === 'auto' ? 'bg-background shadow-xs text-foreground' : 'text-muted-foreground hover:text-foreground'
|
||||
"
|
||||
@click="setFixedLayoutSpreadAuto"
|
||||
>
|
||||
Book default
|
||||
</button>
|
||||
<button
|
||||
class="h-8 px-3 rounded-md text-xs font-medium transition-colors"
|
||||
:class="
|
||||
effective.fixedLayoutSpread === 'none' ? 'bg-background shadow-xs text-foreground' : 'text-muted-foreground hover:text-foreground'
|
||||
"
|
||||
@click="setFixedLayoutSpreadNone"
|
||||
>
|
||||
Single page
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Columns -->
|
||||
<div class="px-4 py-3.5 md:px-5 md:py-4 bg-card">
|
||||
<div class="mb-3">
|
||||
|
||||
@@ -65,6 +65,8 @@ export interface EpubReaderSettings {
|
||||
overrideBookFormatting: boolean;
|
||||
// In-page footer display mode: 0 = pages, 1 = time remaining + session, 2 = chapter info
|
||||
footerDisplayMode: 0 | 1 | 2;
|
||||
// Fixed-layout EPUB spread handling. auto = respect book metadata; none = force one spine item per page.
|
||||
fixedLayoutSpread: "auto" | "none";
|
||||
}
|
||||
|
||||
export interface PdfReaderSettings {
|
||||
@@ -117,6 +119,7 @@ export const EPUB_READER_DEFAULTS: EpubReaderSettings = {
|
||||
flow: "paginated",
|
||||
overrideBookFormatting: true,
|
||||
footerDisplayMode: 0,
|
||||
fixedLayoutSpread: "auto",
|
||||
};
|
||||
|
||||
export const PDF_READER_DEFAULTS: PdfReaderSettings = {
|
||||
|
||||
@@ -200,6 +200,7 @@ describe('ReaderPreferencesService', () => {
|
||||
flow: 'paginated' as const,
|
||||
overrideBookFormatting: true,
|
||||
footerDisplayMode: 0 as const,
|
||||
fixedLayoutSpread: 'auto' as const,
|
||||
};
|
||||
|
||||
it('accepts valid footerDisplayMode values (0, 1, 2) in full epub defaults', async () => {
|
||||
@@ -225,6 +226,15 @@ describe('ReaderPreferencesService', () => {
|
||||
expect(mockRepo.upsertPreference).toHaveBeenCalledWith(7, 20, { footerDisplayMode: 1 });
|
||||
});
|
||||
|
||||
it('accepts partial epub per-book settings with fixedLayoutSpread', async () => {
|
||||
const user = makeUser();
|
||||
mockBookService.verifyFileAccess.mockResolvedValueOnce({ format: 'epub' });
|
||||
|
||||
await service.upsertPreference(user, 22, { fixedLayoutSpread: 'none' });
|
||||
|
||||
expect(mockRepo.upsertPreference).toHaveBeenCalledWith(7, 22, { fixedLayoutSpread: 'none' });
|
||||
});
|
||||
|
||||
it('rejects partial epub per-book settings with invalid footerDisplayMode', async () => {
|
||||
const user = makeUser();
|
||||
mockBookService.verifyFileAccess.mockResolvedValueOnce({ format: 'epub' });
|
||||
@@ -233,11 +243,26 @@ describe('ReaderPreferencesService', () => {
|
||||
expect(mockRepo.upsertPreference).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects partial epub per-book settings with invalid fixedLayoutSpread', async () => {
|
||||
const user = makeUser();
|
||||
mockBookService.verifyFileAccess.mockResolvedValueOnce({ format: 'epub' });
|
||||
|
||||
await expect(service.upsertPreference(user, 23, { fixedLayoutSpread: 'two-page' })).rejects.toThrow(BadRequestException);
|
||||
expect(mockRepo.upsertPreference).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requires footerDisplayMode when saving full epub defaults', async () => {
|
||||
const withoutFooterMode = { ...validEpubDefaults };
|
||||
delete (withoutFooterMode as Record<string, unknown>).footerDisplayMode;
|
||||
|
||||
await expect(service.upsertDefault(1, 'epub', withoutFooterMode)).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('requires fixedLayoutSpread when saving full epub defaults', async () => {
|
||||
const withoutSpread = { ...validEpubDefaults };
|
||||
delete (withoutSpread as Record<string, unknown>).fixedLayoutSpread;
|
||||
|
||||
await expect(service.upsertDefault(1, 'epub', withoutSpread)).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ const EPUB_SETTINGS_SCHEMA = z
|
||||
flow: z.enum(['paginated', 'scrolled']),
|
||||
overrideBookFormatting: z.boolean(),
|
||||
footerDisplayMode: z.union([z.literal(0), z.literal(1), z.literal(2)]),
|
||||
fixedLayoutSpread: z.enum(['auto', 'none']),
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user