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:
neon
2026-07-01 15:16:17 +00:00
parent 68a395095a
commit 219815941b
13 changed files with 566 additions and 127 deletions
+24 -6
View File
@@ -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">
+3
View File
@@ -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();