mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
feat(reader): add epub paragraph spacing
Preserve publisher spacing at the minimum while allowing synchronized format defaults and per-book overrides. Closes #1206
This commit is contained in:
@@ -82,6 +82,7 @@ const {
|
|||||||
applyToRenderer,
|
applyToRenderer,
|
||||||
setFontSize,
|
setFontSize,
|
||||||
setLineHeight,
|
setLineHeight,
|
||||||
|
setParagraphSpacing,
|
||||||
setFontFamily,
|
setFontFamily,
|
||||||
setFontWeight,
|
setFontWeight,
|
||||||
setFontStyle,
|
setFontStyle,
|
||||||
@@ -342,6 +343,7 @@ onMounted(async () => {
|
|||||||
const epubSetters: Record<string, (v: unknown) => void> = {
|
const epubSetters: Record<string, (v: unknown) => void> = {
|
||||||
fontSize: (v) => setFontSize(v as number),
|
fontSize: (v) => setFontSize(v as number),
|
||||||
lineHeight: (v) => setLineHeight(v as number),
|
lineHeight: (v) => setLineHeight(v as number),
|
||||||
|
paragraphSpacing: (v) => setParagraphSpacing(v as number),
|
||||||
fontFamily: (v) => setFontFamily(v as string | null),
|
fontFamily: (v) => setFontFamily(v as string | null),
|
||||||
fontWeight: (v) => setFontWeight(v as number),
|
fontWeight: (v) => setFontWeight(v as number),
|
||||||
fontStyle: (v) => setFontStyle(v as EpubReaderSettings['fontStyle']),
|
fontStyle: (v) => setFontStyle(v as EpubReaderSettings['fontStyle']),
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onUnmounted, ref, useId, watch } from 'vue'
|
import { computed, onUnmounted, ref, useId, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { EPUB_FONT_SIZE_MAX, EPUB_FONT_SIZE_MIN, isCustomFontCssFamily, type FontNamedInstance, type FontVariant } from '@bookorbit/types'
|
import {
|
||||||
|
EPUB_FONT_SIZE_MAX,
|
||||||
|
EPUB_FONT_SIZE_MIN,
|
||||||
|
EPUB_PARAGRAPH_SPACING_MAX,
|
||||||
|
EPUB_PARAGRAPH_SPACING_MIN,
|
||||||
|
isCustomFontCssFamily,
|
||||||
|
type FontNamedInstance,
|
||||||
|
type FontVariant,
|
||||||
|
} from '@bookorbit/types'
|
||||||
import {
|
import {
|
||||||
BookOpen,
|
BookOpen,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
@@ -20,6 +28,7 @@ import type { FontFamily, useCustomFonts } from '../composables/useCustomFonts'
|
|||||||
import { themes } from '../constants/themes'
|
import { themes } from '../constants/themes'
|
||||||
import { BUILTIN_READER_FONT_OPTIONS, type ReaderBuiltInFontOption } from '@/features/reader/shared/constants/font-options'
|
import { BUILTIN_READER_FONT_OPTIONS, type ReaderBuiltInFontOption } from '@/features/reader/shared/constants/font-options'
|
||||||
import { formatFontFamilyLabel } from '@/features/reader/shared/lib/font-display'
|
import { formatFontFamilyLabel } from '@/features/reader/shared/lib/font-display'
|
||||||
|
import { formatNumber } from '@/i18n/formatters'
|
||||||
import { FONT_WEIGHT_LABEL_KEYS, builtInVariants, closestVariant, familyVariants, isSameVariant } from '@/features/reader/shared/lib/font-variants'
|
import { FONT_WEIGHT_LABEL_KEYS, builtInVariants, closestVariant, familyVariants, isSameVariant } from '@/features/reader/shared/lib/font-variants'
|
||||||
import ReaderRangeField from '@/features/reader/shared/components/ReaderRangeField.vue'
|
import ReaderRangeField from '@/features/reader/shared/components/ReaderRangeField.vue'
|
||||||
import ReaderSegmentedControl from '@/features/reader/shared/components/ReaderSegmentedControl.vue'
|
import ReaderSegmentedControl from '@/features/reader/shared/components/ReaderSegmentedControl.vue'
|
||||||
@@ -82,6 +91,14 @@ const pageWidthLabel = computed(() => {
|
|||||||
return t('reader.settings.pageWidthFull')
|
return t('reader.settings.pageWidthFull')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const paragraphSpacingLabel = computed(() =>
|
||||||
|
props.state.paragraphSpacing === EPUB_PARAGRAPH_SPACING_MIN
|
||||||
|
? t('reader.settings.paragraphSpacingDefault')
|
||||||
|
: t('reader.settings.paragraphSpacingValue', {
|
||||||
|
value: formatNumber(props.state.paragraphSpacing, { minimumFractionDigits: 1, maximumFractionDigits: 1 }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
const columnGapPercent = computed(() => Math.round(props.state.gap * 100))
|
const columnGapPercent = computed(() => Math.round(props.state.gap * 100))
|
||||||
|
|
||||||
function setMode(value: string) {
|
function setMode(value: string) {
|
||||||
@@ -108,6 +125,10 @@ function setLineHeight(value: number) {
|
|||||||
emit('update', { lineHeight: Math.round(value * 10) / 10 })
|
emit('update', { lineHeight: Math.round(value * 10) / 10 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setParagraphSpacing(value: number) {
|
||||||
|
emit('update', { paragraphSpacing: Math.round(value * 10) / 10 })
|
||||||
|
}
|
||||||
|
|
||||||
function setPageWidth(value: number) {
|
function setPageWidth(value: number) {
|
||||||
emit('update', { maxInlineSize: value })
|
emit('update', { maxInlineSize: value })
|
||||||
}
|
}
|
||||||
@@ -424,6 +445,20 @@ const cardBaseClass =
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="border-b border-border px-4 py-3.5">
|
||||||
|
<ReaderRangeField
|
||||||
|
:model-value="state.paragraphSpacing"
|
||||||
|
:min="EPUB_PARAGRAPH_SPACING_MIN"
|
||||||
|
:max="EPUB_PARAGRAPH_SPACING_MAX"
|
||||||
|
:step="0.1"
|
||||||
|
:label="t('reader.settings.paragraphSpacing')"
|
||||||
|
:display-value="paragraphSpacingLabel"
|
||||||
|
:min-icon="Rows4"
|
||||||
|
:max-icon="Rows2"
|
||||||
|
@update:model-value="setParagraphSpacing"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="border-b border-border px-4 py-3.5">
|
<div class="border-b border-border px-4 py-3.5">
|
||||||
<ReaderRangeField
|
<ReaderRangeField
|
||||||
:model-value="state.maxInlineSize"
|
:model-value="state.maxInlineSize"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ function makeState(overrides: Partial<ReaderState> = {}): ReaderState {
|
|||||||
return {
|
return {
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
lineHeight: 1.5,
|
lineHeight: 1.5,
|
||||||
|
paragraphSpacing: 0,
|
||||||
fontFamily: null,
|
fontFamily: null,
|
||||||
fontWeight: 400,
|
fontWeight: 400,
|
||||||
fontStyle: 'normal',
|
fontStyle: 'normal',
|
||||||
@@ -118,6 +119,7 @@ describe('ReaderSettingsPanel', () => {
|
|||||||
expect(wrapper.text()).toContain('Page color')
|
expect(wrapper.text()).toContain('Page color')
|
||||||
expect(wrapper.text()).toContain('Font')
|
expect(wrapper.text()).toContain('Font')
|
||||||
expect(wrapper.text()).toContain('Line spacing')
|
expect(wrapper.text()).toContain('Line spacing')
|
||||||
|
expect(wrapper.text()).toContain('Paragraph spacing')
|
||||||
expect(wrapper.text()).toContain('Page width')
|
expect(wrapper.text()).toContain('Page width')
|
||||||
expect(wrapper.text()).toContain('Reading flow')
|
expect(wrapper.text()).toContain('Reading flow')
|
||||||
expect(wrapper.text()).toContain('Advanced layout')
|
expect(wrapper.text()).toContain('Advanced layout')
|
||||||
@@ -180,6 +182,18 @@ describe('ReaderSettingsPanel', () => {
|
|||||||
expect(wrapper.emitted('update')?.[0]).toEqual([{ lineHeight: 1.8 }])
|
expect(wrapper.emitted('update')?.[0]).toEqual([{ lineHeight: 1.8 }])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('labels and emits paragraph spacing from the slider', async () => {
|
||||||
|
const publisherDefault = mountPanel()
|
||||||
|
expect(rangeByLabel(publisherDefault, 'Paragraph spacing')!.attributes('aria-valuetext')).toBe('Default')
|
||||||
|
|
||||||
|
const wrapper = mountPanel({ state: makeState({ paragraphSpacing: 1.4 }) })
|
||||||
|
const slider = rangeByLabel(wrapper, 'Paragraph spacing')!
|
||||||
|
expect(slider.attributes('aria-valuetext')).toBe('1.4 em')
|
||||||
|
|
||||||
|
await slider.setValue('1.7')
|
||||||
|
expect(wrapper.emitted('update')?.[0]).toEqual([{ paragraphSpacing: 1.7 }])
|
||||||
|
})
|
||||||
|
|
||||||
it('describes page width in words rather than pixels', async () => {
|
it('describes page width in words rather than pixels', async () => {
|
||||||
const wrapper = mountPanel({ state: makeState({ maxInlineSize: 720 }) })
|
const wrapper = mountPanel({ state: makeState({ maxInlineSize: 720 }) })
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ describe('useReaderState', () => {
|
|||||||
|
|
||||||
state.setFontSize(100)
|
state.setFontSize(100)
|
||||||
state.setLineHeight(0.71)
|
state.setLineHeight(0.71)
|
||||||
|
state.setParagraphSpacing(3)
|
||||||
state.setMaxColumnCount(0)
|
state.setMaxColumnCount(0)
|
||||||
state.setGap(0.9)
|
state.setGap(0.9)
|
||||||
state.setMaxInlineSize(200)
|
state.setMaxInlineSize(200)
|
||||||
@@ -14,13 +15,16 @@ describe('useReaderState', () => {
|
|||||||
|
|
||||||
expect(state.fontSize.value).toBe(32)
|
expect(state.fontSize.value).toBe(32)
|
||||||
expect(state.lineHeight.value).toBe(0.8)
|
expect(state.lineHeight.value).toBe(0.8)
|
||||||
|
expect(state.paragraphSpacing.value).toBe(2)
|
||||||
expect(state.maxColumnCount.value).toBe(1)
|
expect(state.maxColumnCount.value).toBe(1)
|
||||||
expect(state.gap.value).toBe(0.5)
|
expect(state.gap.value).toBe(0.5)
|
||||||
expect(state.maxInlineSize.value).toBe(400)
|
expect(state.maxInlineSize.value).toBe(400)
|
||||||
expect(state.maxBlockSize.value).toBe(2400)
|
expect(state.maxBlockSize.value).toBe(2400)
|
||||||
|
|
||||||
state.setFontSize(1)
|
state.setFontSize(1)
|
||||||
|
state.setParagraphSpacing(-1)
|
||||||
expect(state.fontSize.value).toBe(6)
|
expect(state.fontSize.value).toBe(6)
|
||||||
|
expect(state.paragraphSpacing.value).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('selects default theme when unknown theme name is set', () => {
|
it('selects default theme when unknown theme name is set', () => {
|
||||||
@@ -132,6 +136,24 @@ describe('useReaderState', () => {
|
|||||||
expect(css).toContain('hyphens: none;')
|
expect(css).toContain('hyphens: none;')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('preserves publisher paragraph margins at the default spacing', () => {
|
||||||
|
const state = useReaderState()
|
||||||
|
|
||||||
|
expect(state.paragraphSpacing.value).toBe(0)
|
||||||
|
expect(state.generateCSS()).not.toContain('margin-block:')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('generates logical paragraph margins for positive spacing', () => {
|
||||||
|
const state = useReaderState()
|
||||||
|
state.setParagraphSpacing(1.6)
|
||||||
|
|
||||||
|
const css = state.generateCSS()
|
||||||
|
|
||||||
|
expect(css).toContain('margin-block: 0 1.6em !important;')
|
||||||
|
expect(css).toContain(':is(hgroup, header) p')
|
||||||
|
expect(css).toContain('margin-block: unset !important;')
|
||||||
|
})
|
||||||
|
|
||||||
describe('font style', () => {
|
describe('font style', () => {
|
||||||
it('leaves weight and slant out of the CSS while they sit at their defaults', () => {
|
it('leaves weight and slant out of the CSS while they sit at their defaults', () => {
|
||||||
const state = useReaderState()
|
const state = useReaderState()
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { EPUB_FONT_SIZE_MAX, EPUB_FONT_SIZE_MIN, EPUB_READER_DEFAULTS, type EpubReaderSettings, type FontStyle } from '@bookorbit/types'
|
import {
|
||||||
|
EPUB_FONT_SIZE_MAX,
|
||||||
|
EPUB_FONT_SIZE_MIN,
|
||||||
|
EPUB_PARAGRAPH_SPACING_MAX,
|
||||||
|
EPUB_PARAGRAPH_SPACING_MIN,
|
||||||
|
EPUB_READER_DEFAULTS,
|
||||||
|
type EpubReaderSettings,
|
||||||
|
type FontStyle,
|
||||||
|
} from '@bookorbit/types'
|
||||||
import { themes } from '../constants/themes'
|
import { themes } from '../constants/themes'
|
||||||
import type { Theme, ThemeMode } from '../constants/themes'
|
import type { Theme, ThemeMode } from '../constants/themes'
|
||||||
import type { FoliateRenderer } from './useFoliate'
|
import type { FoliateRenderer } from './useFoliate'
|
||||||
@@ -7,6 +15,7 @@ import type { FoliateRenderer } from './useFoliate'
|
|||||||
export interface ReaderState {
|
export interface ReaderState {
|
||||||
fontSize: number
|
fontSize: number
|
||||||
lineHeight: number
|
lineHeight: number
|
||||||
|
paragraphSpacing: number
|
||||||
fontFamily: string | null
|
fontFamily: string | null
|
||||||
fontWeight: number
|
fontWeight: number
|
||||||
fontStyle: FontStyle
|
fontStyle: FontStyle
|
||||||
@@ -29,6 +38,7 @@ export interface ApplyReaderStateOptions {
|
|||||||
const defaults: ReaderState = {
|
const defaults: ReaderState = {
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
lineHeight: 1.5,
|
lineHeight: 1.5,
|
||||||
|
paragraphSpacing: EPUB_READER_DEFAULTS.paragraphSpacing,
|
||||||
fontFamily: null,
|
fontFamily: null,
|
||||||
fontWeight: EPUB_READER_DEFAULTS.fontWeight,
|
fontWeight: EPUB_READER_DEFAULTS.fontWeight,
|
||||||
fontStyle: EPUB_READER_DEFAULTS.fontStyle,
|
fontStyle: EPUB_READER_DEFAULTS.fontStyle,
|
||||||
@@ -47,6 +57,7 @@ const defaults: ReaderState = {
|
|||||||
export function useReaderState() {
|
export function useReaderState() {
|
||||||
const fontSize = ref(defaults.fontSize)
|
const fontSize = ref(defaults.fontSize)
|
||||||
const lineHeight = ref(defaults.lineHeight)
|
const lineHeight = ref(defaults.lineHeight)
|
||||||
|
const paragraphSpacing = ref(defaults.paragraphSpacing)
|
||||||
const fontFamily = ref<string | null>(defaults.fontFamily)
|
const fontFamily = ref<string | null>(defaults.fontFamily)
|
||||||
const fontWeight = ref(defaults.fontWeight)
|
const fontWeight = ref(defaults.fontWeight)
|
||||||
const fontStyle = ref<FontStyle>(defaults.fontStyle)
|
const fontStyle = ref<FontStyle>(defaults.fontStyle)
|
||||||
@@ -66,6 +77,7 @@ export function useReaderState() {
|
|||||||
const state = computed<ReaderState>(() => ({
|
const state = computed<ReaderState>(() => ({
|
||||||
fontSize: fontSize.value,
|
fontSize: fontSize.value,
|
||||||
lineHeight: lineHeight.value,
|
lineHeight: lineHeight.value,
|
||||||
|
paragraphSpacing: paragraphSpacing.value,
|
||||||
fontFamily: fontFamily.value,
|
fontFamily: fontFamily.value,
|
||||||
fontWeight: fontWeight.value,
|
fontWeight: fontWeight.value,
|
||||||
fontStyle: fontStyle.value,
|
fontStyle: fontStyle.value,
|
||||||
@@ -89,7 +101,16 @@ export function useReaderState() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function generateCSS(): string {
|
function generateCSS(): string {
|
||||||
const { lineHeight: lh, justify: j, hyphenate: h, fontSize: fs, fontFamily: ff, fontWeight: fw, fontStyle: fst } = state.value
|
const {
|
||||||
|
lineHeight: lh,
|
||||||
|
paragraphSpacing: ps,
|
||||||
|
justify: j,
|
||||||
|
hyphenate: h,
|
||||||
|
fontSize: fs,
|
||||||
|
fontFamily: ff,
|
||||||
|
fontWeight: fw,
|
||||||
|
fontStyle: fst,
|
||||||
|
} = state.value
|
||||||
const mode = activeMode.value
|
const mode = activeMode.value
|
||||||
const theme = currentTheme.value
|
const theme = currentTheme.value
|
||||||
const lightMode = theme.light
|
const lightMode = theme.light
|
||||||
@@ -126,6 +147,16 @@ export function useReaderState() {
|
|||||||
: ''
|
: ''
|
||||||
|
|
||||||
const fontFaceBlock = fontFaceCSS.value
|
const fontFaceBlock = fontFaceCSS.value
|
||||||
|
const paragraphSpacingRule =
|
||||||
|
ps === EPUB_PARAGRAPH_SPACING_MIN
|
||||||
|
? ''
|
||||||
|
: `
|
||||||
|
p {
|
||||||
|
margin-block: 0 ${ps}em !important;
|
||||||
|
}
|
||||||
|
:is(hgroup, header) p {
|
||||||
|
margin-block: unset !important;
|
||||||
|
}`
|
||||||
|
|
||||||
return `
|
return `
|
||||||
${fontFaceBlock}
|
${fontFaceBlock}
|
||||||
@@ -208,6 +239,7 @@ export function useReaderState() {
|
|||||||
text-align: ${j ? 'justify' : 'start'} !important;
|
text-align: ${j ? 'justify' : 'start'} !important;
|
||||||
hyphens: ${h ? 'auto' : 'none'};
|
hyphens: ${h ? 'auto' : 'none'};
|
||||||
}
|
}
|
||||||
|
${paragraphSpacingRule}
|
||||||
::selection {
|
::selection {
|
||||||
background-color: rgba(128, 128, 128, 0.3);
|
background-color: rgba(128, 128, 128, 0.3);
|
||||||
}
|
}
|
||||||
@@ -242,6 +274,9 @@ export function useReaderState() {
|
|||||||
function setLineHeight(v: number) {
|
function setLineHeight(v: number) {
|
||||||
lineHeight.value = Math.max(0.8, Math.min(3, Math.round(v * 10) / 10))
|
lineHeight.value = Math.max(0.8, Math.min(3, Math.round(v * 10) / 10))
|
||||||
}
|
}
|
||||||
|
function setParagraphSpacing(v: number) {
|
||||||
|
paragraphSpacing.value = Math.max(EPUB_PARAGRAPH_SPACING_MIN, Math.min(EPUB_PARAGRAPH_SPACING_MAX, Math.round(v * 10) / 10))
|
||||||
|
}
|
||||||
function setFontFamily(v: string | null) {
|
function setFontFamily(v: string | null) {
|
||||||
fontFamily.value = v
|
fontFamily.value = v
|
||||||
}
|
}
|
||||||
@@ -290,6 +325,7 @@ export function useReaderState() {
|
|||||||
state,
|
state,
|
||||||
fontSize,
|
fontSize,
|
||||||
lineHeight,
|
lineHeight,
|
||||||
|
paragraphSpacing,
|
||||||
fontFamily,
|
fontFamily,
|
||||||
fontWeight,
|
fontWeight,
|
||||||
fontStyle,
|
fontStyle,
|
||||||
@@ -310,6 +346,7 @@ export function useReaderState() {
|
|||||||
applyToRenderer,
|
applyToRenderer,
|
||||||
setFontSize,
|
setFontSize,
|
||||||
setLineHeight,
|
setLineHeight,
|
||||||
|
setParagraphSpacing,
|
||||||
setFontFamily,
|
setFontFamily,
|
||||||
setFontWeight,
|
setFontWeight,
|
||||||
setFontStyle,
|
setFontStyle,
|
||||||
|
|||||||
@@ -90,6 +90,17 @@ describe('useReaderSettings - load() with valid localStorage epub delta', () =>
|
|||||||
expect(s.bookDelta.value).toEqual({ fontWeight: 350, fontStyle: 'italic' })
|
expect(s.bookDelta.value).toEqual({ fontWeight: 350, fontStyle: 'italic' })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('preserves publisher paragraph spacing as an explicit per-book override', async () => {
|
||||||
|
localStorage.setItem('reader:default:epub', JSON.stringify({ ...READER_GROUP_DEFAULTS.epub, paragraphSpacing: 1.2 }))
|
||||||
|
localStorage.setItem(`reader:book:${BOOK_FILE_ID}`, JSON.stringify({ paragraphSpacing: 0 }))
|
||||||
|
|
||||||
|
const s = useReaderSettings(BOOK_FILE_ID, 'epub')
|
||||||
|
await s.load()
|
||||||
|
|
||||||
|
expect(s.bookDelta.value).toEqual({ paragraphSpacing: 0 })
|
||||||
|
expect(s.effective.value).toMatchObject({ paragraphSpacing: 0 })
|
||||||
|
})
|
||||||
|
|
||||||
it('filters invalid EPUB spread settings from localStorage deltas', async () => {
|
it('filters invalid EPUB spread settings from localStorage deltas', async () => {
|
||||||
const raw = { fontSize: 20, fixedLayoutSpread: 'two-page' }
|
const raw = { fontSize: 20, fixedLayoutSpread: 'two-page' }
|
||||||
localStorage.setItem(`reader:book:${BOOK_FILE_ID}`, JSON.stringify(raw))
|
localStorage.setItem(`reader:book:${BOOK_FILE_ID}`, JSON.stringify(raw))
|
||||||
@@ -122,6 +133,16 @@ describe('useReaderSettings - load() with invalid localStorage value', () => {
|
|||||||
expect(localStorage.getItem(`reader:book:${BOOK_FILE_ID}`)).toBeNull()
|
expect(localStorage.getItem(`reader:book:${BOOK_FILE_ID}`)).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('removes paragraph spacing outside the supported range', async () => {
|
||||||
|
localStorage.setItem(`reader:book:${BOOK_FILE_ID}`, JSON.stringify({ paragraphSpacing: 2.1 }))
|
||||||
|
|
||||||
|
const s = useReaderSettings(BOOK_FILE_ID, 'epub')
|
||||||
|
await s.load()
|
||||||
|
|
||||||
|
expect(s.bookDelta.value).toBeNull()
|
||||||
|
expect(localStorage.getItem(`reader:book:${BOOK_FILE_ID}`)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
it('removes localStorage key and leaves bookDelta null when value is not an object', async () => {
|
it('removes localStorage key and leaves bookDelta null when value is not an object', async () => {
|
||||||
localStorage.setItem(`reader:book:${BOOK_FILE_ID}`, JSON.stringify('not-an-object'))
|
localStorage.setItem(`reader:book:${BOOK_FILE_ID}`, JSON.stringify('not-an-object'))
|
||||||
|
|
||||||
@@ -447,6 +468,18 @@ describe('useReaderDefaultSettings - load() with localStorage', () => {
|
|||||||
expect(JSON.parse(localStorage.getItem('reader:default:epub') ?? '{}')).toMatchObject({ fixedLayoutSpread: 'auto' })
|
expect(JSON.parse(localStorage.getItem('reader:default:epub') ?? '{}')).toMatchObject({ fixedLayoutSpread: 'auto' })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('fills publisher paragraph spacing for older epub defaults in localStorage', async () => {
|
||||||
|
const stored = { ...READER_GROUP_DEFAULTS.epub, fontSize: 20 }
|
||||||
|
delete (stored as Record<string, unknown>).paragraphSpacing
|
||||||
|
localStorage.setItem('reader:default:epub', JSON.stringify(stored))
|
||||||
|
|
||||||
|
const s = useReaderDefaultSettings('epub')
|
||||||
|
await s.load()
|
||||||
|
|
||||||
|
expect(s.effective.value).toMatchObject({ fontSize: 20, paragraphSpacing: 0 })
|
||||||
|
expect(JSON.parse(localStorage.getItem('reader:default:epub') ?? '{}')).toMatchObject({ paragraphSpacing: 0 })
|
||||||
|
})
|
||||||
|
|
||||||
it('merges cbx localStorage settings with CBX_READER_DEFAULTS', async () => {
|
it('merges cbx localStorage settings with CBX_READER_DEFAULTS', async () => {
|
||||||
const stored = { fitMode: 'fit-width', viewMode: 'single' }
|
const stored = { fitMode: 'fit-width', viewMode: 'single' }
|
||||||
localStorage.setItem('reader:default:cbx', JSON.stringify(stored))
|
localStorage.setItem('reader:default:cbx', JSON.stringify(stored))
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
CBX_READER_DEFAULTS,
|
CBX_READER_DEFAULTS,
|
||||||
EPUB_FONT_SIZE_MAX,
|
EPUB_FONT_SIZE_MAX,
|
||||||
EPUB_FONT_SIZE_MIN,
|
EPUB_FONT_SIZE_MIN,
|
||||||
|
EPUB_PARAGRAPH_SPACING_MAX,
|
||||||
|
EPUB_PARAGRAPH_SPACING_MIN,
|
||||||
EPUB_READER_DEFAULTS,
|
EPUB_READER_DEFAULTS,
|
||||||
PDF_READER_DEFAULTS,
|
PDF_READER_DEFAULTS,
|
||||||
type CbxReaderSettings,
|
type CbxReaderSettings,
|
||||||
@@ -84,6 +86,9 @@ function sanitizeEpubPartialSettings(settings: unknown): Partial<EpubReaderSetti
|
|||||||
if (isNumberInRange(settings.lineHeight, 0.8, 3)) {
|
if (isNumberInRange(settings.lineHeight, 0.8, 3)) {
|
||||||
out.lineHeight = settings.lineHeight
|
out.lineHeight = settings.lineHeight
|
||||||
}
|
}
|
||||||
|
if (isNumberInRange(settings.paragraphSpacing, EPUB_PARAGRAPH_SPACING_MIN, EPUB_PARAGRAPH_SPACING_MAX)) {
|
||||||
|
out.paragraphSpacing = settings.paragraphSpacing
|
||||||
|
}
|
||||||
if (isIntegerInRange(settings.maxColumnCount, 1, 10)) {
|
if (isIntegerInRange(settings.maxColumnCount, 1, 10)) {
|
||||||
out.maxColumnCount = settings.maxColumnCount
|
out.maxColumnCount = settings.maxColumnCount
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, onUnmounted, ref, useId, watch } from 'vue'
|
import { computed, onMounted, onUnmounted, ref, useId, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import type { EpubReaderSettings, FontNamedInstance } from '@bookorbit/types'
|
import {
|
||||||
import { isCustomFontCssFamily } from '@bookorbit/types'
|
EPUB_PARAGRAPH_SPACING_MAX,
|
||||||
|
EPUB_PARAGRAPH_SPACING_MIN,
|
||||||
|
isCustomFontCssFamily,
|
||||||
|
type EpubReaderSettings,
|
||||||
|
type FontNamedInstance,
|
||||||
|
} from '@bookorbit/types'
|
||||||
import { useReaderDefaultSettings } from '@/features/reader/shared/composables/useReaderSettings'
|
import { useReaderDefaultSettings } from '@/features/reader/shared/composables/useReaderSettings'
|
||||||
import { useCustomFonts } from '@/features/reader/epub/composables/useCustomFonts'
|
import { useCustomFonts } from '@/features/reader/epub/composables/useCustomFonts'
|
||||||
import { themes } from '@/features/reader/epub/constants/themes'
|
import { themes } from '@/features/reader/epub/constants/themes'
|
||||||
import { BUILTIN_READER_FONT_OPTIONS } from '@/features/reader/shared/constants/font-options'
|
import { BUILTIN_READER_FONT_OPTIONS } from '@/features/reader/shared/constants/font-options'
|
||||||
import { formatFontFamilyLabel } from '@/features/reader/shared/lib/font-display'
|
import { formatFontFamilyLabel } from '@/features/reader/shared/lib/font-display'
|
||||||
|
import { formatNumber } from '@/i18n/formatters'
|
||||||
import {
|
import {
|
||||||
FONT_WEIGHT_LABEL_KEYS,
|
FONT_WEIGHT_LABEL_KEYS,
|
||||||
builtInVariants,
|
builtInVariants,
|
||||||
@@ -32,6 +38,7 @@ const props = withDefaults(
|
|||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const fontStyleSelectId = `ebook-font-style-${useId()}`
|
const fontStyleSelectId = `ebook-font-style-${useId()}`
|
||||||
|
const paragraphSpacingInputId = `ebook-paragraph-spacing-${useId()}`
|
||||||
|
|
||||||
const { effective, load, update, reset } = useReaderDefaultSettings<EpubReaderSettings>('epub')
|
const { effective, load, update, reset } = useReaderDefaultSettings<EpubReaderSettings>('epub')
|
||||||
|
|
||||||
@@ -78,12 +85,23 @@ function fontStyleLabel(variant: FontNamedInstance): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const selectedFontStyle = computed(() => variantKey({ weight: effective.value.fontWeight, style: effective.value.fontStyle }))
|
const selectedFontStyle = computed(() => variantKey({ weight: effective.value.fontWeight, style: effective.value.fontStyle }))
|
||||||
|
const paragraphSpacingLabel = computed(() =>
|
||||||
|
effective.value.paragraphSpacing === EPUB_PARAGRAPH_SPACING_MIN
|
||||||
|
? t('settings.reader.ebook.paragraphSpacingDefault')
|
||||||
|
: t('settings.reader.ebook.paragraphSpacingValue', {
|
||||||
|
value: formatNumber(effective.value.paragraphSpacing, { minimumFractionDigits: 1, maximumFractionDigits: 1 }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
function selectFontStyle(event: Event) {
|
function selectFontStyle(event: Event) {
|
||||||
const chosen = fontStyleOptions.value.find((option) => option.value === (event.target as HTMLSelectElement).value)
|
const chosen = fontStyleOptions.value.find((option) => option.value === (event.target as HTMLSelectElement).value)
|
||||||
if (chosen) update({ fontWeight: chosen.variant.weight, fontStyle: chosen.variant.style })
|
if (chosen) update({ fontWeight: chosen.variant.weight, fontStyle: chosen.variant.style })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setParagraphSpacing(event: Event) {
|
||||||
|
update({ paragraphSpacing: Number((event.target as HTMLInputElement).value) })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Switching family also moves the style when the new family lacks the current one, which
|
* Switching family also moves the style when the new family lacks the current one, which
|
||||||
* would otherwise leave the style select showing a value it cannot offer.
|
* would otherwise leave the style select showing a value it cannot offer.
|
||||||
@@ -501,6 +519,32 @@ function setFixedLayoutSpreadNone() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Paragraph spacing -->
|
||||||
|
<div class="px-4 py-3.5 md:px-5 md:py-4 bg-card">
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<label :for="paragraphSpacingInputId" class="settings-label">
|
||||||
|
{{ t('settings.reader.ebook.paragraphSpacing') }}
|
||||||
|
</label>
|
||||||
|
<span class="settings-value">{{ paragraphSpacingLabel }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="settings-hint">
|
||||||
|
{{ t('settings.reader.ebook.paragraphSpacingHint') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
:id="paragraphSpacingInputId"
|
||||||
|
type="range"
|
||||||
|
:min="EPUB_PARAGRAPH_SPACING_MIN"
|
||||||
|
:max="EPUB_PARAGRAPH_SPACING_MAX"
|
||||||
|
step="0.1"
|
||||||
|
class="w-full accent-primary cursor-pointer"
|
||||||
|
:value="effective.paragraphSpacing"
|
||||||
|
:aria-valuetext="paragraphSpacingLabel"
|
||||||
|
@input="setParagraphSpacing"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Justify -->
|
<!-- Justify -->
|
||||||
<div class="settings-row">
|
<div class="settings-row">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const readerSettingsMock = vi.hoisted(() => ({
|
|||||||
fontStyle: 'normal',
|
fontStyle: 'normal',
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
lineHeight: 1.5,
|
lineHeight: 1.5,
|
||||||
|
paragraphSpacing: 0,
|
||||||
maxColumnCount: 2,
|
maxColumnCount: 2,
|
||||||
gap: 0.05,
|
gap: 0.05,
|
||||||
maxInlineSize: 720,
|
maxInlineSize: 720,
|
||||||
@@ -82,6 +83,18 @@ describe('EbookSettings', () => {
|
|||||||
expect(wrapper.get(`label[for="${select.attributes('id')}"]`).text()).toBe('Font style')
|
expect(wrapper.get(`label[for="${select.attributes('id')}"]`).text()).toBe('Font style')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('shows and updates the paragraph spacing default', async () => {
|
||||||
|
const wrapper = mount(EbookSettings, { props: { embedded: true } })
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const slider = wrapper.get('input[id^="ebook-paragraph-spacing-"]')
|
||||||
|
expect(wrapper.get(`label[for="${slider.attributes('id')}"]`).text()).toBe('Paragraph spacing')
|
||||||
|
expect(slider.attributes('aria-valuetext')).toBe('Book default')
|
||||||
|
|
||||||
|
await slider.setValue('1.2')
|
||||||
|
expect(readerSettingsMock.update).toHaveBeenCalledWith({ paragraphSpacing: 1.2 })
|
||||||
|
})
|
||||||
|
|
||||||
it('waits for both font catalogs before resetting an unavailable saved family', async () => {
|
it('waits for both font catalogs before resetting an unavailable saved family', async () => {
|
||||||
readerSettingsMock.effective.value.fontFamily = fontCssFamilyGroupName('Removed')
|
readerSettingsMock.effective.value.fontFamily = fontCssFamilyGroupName('Removed')
|
||||||
readerSettingsMock.effective.value.fontWeight = 1000
|
readerSettingsMock.effective.value.fontWeight = 1000
|
||||||
|
|||||||
@@ -1922,6 +1922,10 @@
|
|||||||
"fontSizeHint": "Base text size in pixels",
|
"fontSizeHint": "Base text size in pixels",
|
||||||
"lineHeight": "Line height",
|
"lineHeight": "Line height",
|
||||||
"lineHeightHint": "Vertical spacing between lines",
|
"lineHeightHint": "Vertical spacing between lines",
|
||||||
|
"paragraphSpacing": "Paragraph spacing",
|
||||||
|
"paragraphSpacingHint": "Space between paragraphs. Book default preserves the publisher's spacing.",
|
||||||
|
"paragraphSpacingDefault": "Book default",
|
||||||
|
"paragraphSpacingValue": "{value} em",
|
||||||
"justify": "Justify text",
|
"justify": "Justify text",
|
||||||
"justifyHint": "Align text to both margins",
|
"justifyHint": "Align text to both margins",
|
||||||
"hyphenation": "Hyphenation",
|
"hyphenation": "Hyphenation",
|
||||||
@@ -8650,6 +8654,9 @@
|
|||||||
"fontStyle": "Style",
|
"fontStyle": "Style",
|
||||||
"fontStyleItalicOf": "{style} Italic",
|
"fontStyleItalicOf": "{style} Italic",
|
||||||
"lineSpacing": "Line spacing",
|
"lineSpacing": "Line spacing",
|
||||||
|
"paragraphSpacing": "Paragraph spacing",
|
||||||
|
"paragraphSpacingDefault": "Default",
|
||||||
|
"paragraphSpacingValue": "{value} em",
|
||||||
"pageWidth": "Page width",
|
"pageWidth": "Page width",
|
||||||
"pageWidthNarrow": "Narrow",
|
"pageWidthNarrow": "Narrow",
|
||||||
"pageWidthMedium": "Medium",
|
"pageWidthMedium": "Medium",
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ export type ReaderFormatGroup = "epub" | "pdf" | "cbx" | "audio";
|
|||||||
|
|
||||||
export const EPUB_FONT_SIZE_MIN = 6;
|
export const EPUB_FONT_SIZE_MIN = 6;
|
||||||
export const EPUB_FONT_SIZE_MAX = 32;
|
export const EPUB_FONT_SIZE_MAX = 32;
|
||||||
|
export const EPUB_PARAGRAPH_SPACING_MIN = 0;
|
||||||
|
export const EPUB_PARAGRAPH_SPACING_MAX = 2;
|
||||||
export const CBX_SPREAD_GAP_MIN = 0;
|
export const CBX_SPREAD_GAP_MIN = 0;
|
||||||
export const CBX_SPREAD_GAP_MAX = 64;
|
export const CBX_SPREAD_GAP_MAX = 64;
|
||||||
|
|
||||||
@@ -71,6 +73,7 @@ export interface EpubReaderSettings {
|
|||||||
fontStyle: FontStyle;
|
fontStyle: FontStyle;
|
||||||
fontSize: number; // EPUB_FONT_SIZE_MIN-EPUB_FONT_SIZE_MAX
|
fontSize: number; // EPUB_FONT_SIZE_MIN-EPUB_FONT_SIZE_MAX
|
||||||
lineHeight: number; // 0.8-3.0
|
lineHeight: number; // 0.8-3.0
|
||||||
|
paragraphSpacing: number; // EPUB_PARAGRAPH_SPACING_MIN-EPUB_PARAGRAPH_SPACING_MAX em; 0 preserves publisher spacing
|
||||||
maxColumnCount: number; // 1-10
|
maxColumnCount: number; // 1-10
|
||||||
gap: number; // 0-0.5 (column gap as fraction)
|
gap: number; // 0-0.5 (column gap as fraction)
|
||||||
maxInlineSize: number; // 400-1600 (max content width in px)
|
maxInlineSize: number; // 400-1600 (max content width in px)
|
||||||
@@ -133,6 +136,7 @@ export const EPUB_READER_DEFAULTS: EpubReaderSettings = {
|
|||||||
fontStyle: "normal",
|
fontStyle: "normal",
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
lineHeight: 1.5,
|
lineHeight: 1.5,
|
||||||
|
paragraphSpacing: EPUB_PARAGRAPH_SPACING_MIN,
|
||||||
maxColumnCount: 2,
|
maxColumnCount: 2,
|
||||||
gap: 0.05,
|
gap: 0.05,
|
||||||
maxInlineSize: 720,
|
maxInlineSize: 720,
|
||||||
|
|||||||
@@ -223,6 +223,7 @@ describe('ReaderPreferencesService', () => {
|
|||||||
fontStyle: 'normal' as const,
|
fontStyle: 'normal' as const,
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
lineHeight: 1.5,
|
lineHeight: 1.5,
|
||||||
|
paragraphSpacing: 0,
|
||||||
maxColumnCount: 2,
|
maxColumnCount: 2,
|
||||||
gap: 0.05,
|
gap: 0.05,
|
||||||
maxInlineSize: 720,
|
maxInlineSize: 720,
|
||||||
@@ -255,6 +256,12 @@ describe('ReaderPreferencesService', () => {
|
|||||||
await expect(service.upsertDefault(1, 'epub', missingFontStyle)).rejects.toThrow(BadRequestException);
|
await expect(service.upsertDefault(1, 'epub', missingFontStyle)).rejects.toThrow(BadRequestException);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects a full epub default that omits paragraph spacing', async () => {
|
||||||
|
const missingParagraphSpacing = Object.fromEntries(Object.entries(validEpubDefaults).filter(([key]) => key !== 'paragraphSpacing'));
|
||||||
|
|
||||||
|
await expect(service.upsertDefault(1, 'epub', missingParagraphSpacing)).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
it('accepts a full epub default carrying a font weight and style', async () => {
|
it('accepts a full epub default carrying a font weight and style', async () => {
|
||||||
await service.upsertDefault(1, 'epub', { ...validEpubDefaults, fontWeight: 700, fontStyle: 'italic' });
|
await service.upsertDefault(1, 'epub', { ...validEpubDefaults, fontWeight: 700, fontStyle: 'italic' });
|
||||||
|
|
||||||
@@ -284,6 +291,22 @@ describe('ReaderPreferencesService', () => {
|
|||||||
expect(mockRepo.upsertPreference).toHaveBeenCalledWith(7, 26, { fontWeight: 300, fontStyle: 'italic' });
|
expect(mockRepo.upsertPreference).toHaveBeenCalledWith(7, 26, { fontWeight: 300, fontStyle: 'italic' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([[0], [0.8], [2]])('accepts paragraph spacing %s em', async (paragraphSpacing) => {
|
||||||
|
const user = makeUser();
|
||||||
|
mockBookService.verifyFileAccess.mockResolvedValueOnce({ format: 'epub' });
|
||||||
|
|
||||||
|
await service.upsertPreference(user, 27, { paragraphSpacing });
|
||||||
|
|
||||||
|
expect(mockRepo.upsertPreference).toHaveBeenCalledWith(7, 27, { paragraphSpacing });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([[-0.1], [2.1]])('rejects paragraph spacing %s em', async (paragraphSpacing) => {
|
||||||
|
const user = makeUser();
|
||||||
|
mockBookService.verifyFileAccess.mockResolvedValueOnce({ format: 'epub' });
|
||||||
|
|
||||||
|
await expect(service.upsertPreference(user, 27, { paragraphSpacing })).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
it('accepts partial epub per-book settings with footerDisplayMode', async () => {
|
it('accepts partial epub per-book settings with footerDisplayMode', async () => {
|
||||||
const user = makeUser();
|
const user = makeUser();
|
||||||
mockBookService.verifyFileAccess.mockResolvedValueOnce({ format: 'epub' });
|
mockBookService.verifyFileAccess.mockResolvedValueOnce({ format: 'epub' });
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
CSS_FONT_WEIGHT_MIN,
|
CSS_FONT_WEIGHT_MIN,
|
||||||
EPUB_FONT_SIZE_MAX,
|
EPUB_FONT_SIZE_MAX,
|
||||||
EPUB_FONT_SIZE_MIN,
|
EPUB_FONT_SIZE_MIN,
|
||||||
|
EPUB_PARAGRAPH_SPACING_MAX,
|
||||||
|
EPUB_PARAGRAPH_SPACING_MIN,
|
||||||
READER_GROUP_DEFAULTS,
|
READER_GROUP_DEFAULTS,
|
||||||
getFormatGroup,
|
getFormatGroup,
|
||||||
type ReaderFormatGroup,
|
type ReaderFormatGroup,
|
||||||
@@ -28,6 +30,7 @@ const EPUB_SETTINGS_SCHEMA = z
|
|||||||
fontStyle: z.enum(['normal', 'italic']),
|
fontStyle: z.enum(['normal', 'italic']),
|
||||||
fontSize: z.number().min(EPUB_FONT_SIZE_MIN).max(EPUB_FONT_SIZE_MAX),
|
fontSize: z.number().min(EPUB_FONT_SIZE_MIN).max(EPUB_FONT_SIZE_MAX),
|
||||||
lineHeight: z.number().min(0.8).max(3),
|
lineHeight: z.number().min(0.8).max(3),
|
||||||
|
paragraphSpacing: z.number().min(EPUB_PARAGRAPH_SPACING_MIN).max(EPUB_PARAGRAPH_SPACING_MAX),
|
||||||
maxColumnCount: z.number().int().min(1).max(10),
|
maxColumnCount: z.number().int().min(1).max(10),
|
||||||
gap: z.number().min(0).max(0.5),
|
gap: z.number().min(0).max(0.5),
|
||||||
maxInlineSize: z.number().int().min(400).max(1600),
|
maxInlineSize: z.number().int().min(400).max(1600),
|
||||||
|
|||||||
Reference in New Issue
Block a user