mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(reader): add epub typography controls
Preserve publisher typography by default while allowing explicit spacing and indentation overrides across reader clients. Ref #1206
This commit is contained in:
@@ -83,6 +83,9 @@ const {
|
||||
setFontSize,
|
||||
setLineHeight,
|
||||
setParagraphSpacing,
|
||||
setLetterSpacing,
|
||||
setWordSpacing,
|
||||
setTextIndent,
|
||||
setFontFamily,
|
||||
setFontWeight,
|
||||
setFontStyle,
|
||||
@@ -111,7 +114,9 @@ const { onActivity, elapsedMinutes } = useReadingSession(
|
||||
{ trackingEnabled },
|
||||
)
|
||||
|
||||
const progress = useReaderProgress(bookId, fileId, elapsedMinutes, 0, { trackingEnabled })
|
||||
const progress = useReaderProgress(bookId, fileId, elapsedMinutes, 0, {
|
||||
trackingEnabled,
|
||||
})
|
||||
const { cfi, chapterTitle, sectionIndex, totalSections, fraction, locationTotal, footerMode, cycleFooterMode, updateHeadsFeet } = progress
|
||||
|
||||
const visibility = useVisibility()
|
||||
@@ -320,7 +325,13 @@ onMounted(async () => {
|
||||
await annotations.load(bookId)
|
||||
const drawableAnnotations = annotations.annotations.value.filter((a): a is typeof a & { cfi: string } => a.cfi != null)
|
||||
if (drawableAnnotations.length > 0) {
|
||||
addAnnotations(drawableAnnotations.map((a) => ({ cfi: a.cfi, color: a.color, style: a.style })))
|
||||
addAnnotations(
|
||||
drawableAnnotations.map((a) => ({
|
||||
cfi: a.cfi,
|
||||
color: a.color,
|
||||
style: a.style,
|
||||
})),
|
||||
)
|
||||
}
|
||||
void hydrateSidebarLocationMeta()
|
||||
|
||||
@@ -344,6 +355,9 @@ const epubSetters: Record<string, (v: unknown) => void> = {
|
||||
fontSize: (v) => setFontSize(v as number),
|
||||
lineHeight: (v) => setLineHeight(v as number),
|
||||
paragraphSpacing: (v) => setParagraphSpacing(v as number),
|
||||
letterSpacing: (v) => setLetterSpacing(v as number | null),
|
||||
wordSpacing: (v) => setWordSpacing(v as number | null),
|
||||
textIndent: (v) => setTextIndent(v as number | null),
|
||||
fontFamily: (v) => setFontFamily(v as string | null),
|
||||
fontWeight: (v) => setFontWeight(v as number),
|
||||
fontStyle: (v) => setFontStyle(v as EpubReaderSettings['fontStyle']),
|
||||
@@ -478,7 +492,10 @@ function pruneSidebarLocationMeta(targets: string[]) {
|
||||
sidebarLocationMetaByCfi.value = next
|
||||
}
|
||||
|
||||
function toSidebarLocationMeta(context: FoliateLocationContext): { chapterTitle: string | null; percentage: number | null } {
|
||||
function toSidebarLocationMeta(context: FoliateLocationContext): {
|
||||
chapterTitle: string | null
|
||||
percentage: number | null
|
||||
} {
|
||||
const percentage =
|
||||
typeof context.fraction === 'number' && Number.isFinite(context.fraction) ? Math.max(0, Math.min(100, Math.round(context.fraction * 100))) : null
|
||||
return { chapterTitle: context.chapterTitle, percentage }
|
||||
@@ -505,7 +522,10 @@ async function hydrateSidebarLocationMeta() {
|
||||
)
|
||||
|
||||
if (requestSeq !== sidebarLocationResolveSeq) return
|
||||
sidebarLocationMetaByCfi.value = { ...sidebarLocationMetaByCfi.value, ...Object.fromEntries(entries) }
|
||||
sidebarLocationMetaByCfi.value = {
|
||||
...sidebarLocationMetaByCfi.value,
|
||||
...Object.fromEntries(entries),
|
||||
}
|
||||
}
|
||||
|
||||
function onSearchQuery(q: string) {
|
||||
@@ -633,13 +653,17 @@ watch(
|
||||
<div v-if="loading" class="absolute inset-0 flex items-center justify-center z-10 bg-background">
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-full border-2 border-primary border-t-transparent animate-spin" />
|
||||
<p class="text-sm text-muted-foreground">{{ t('reader.loadingBook') }}</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ t('reader.loadingBook') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error && !loading" class="absolute inset-0 flex items-center justify-center z-10 p-8 bg-background">
|
||||
<div class="text-center max-w-sm">
|
||||
<p class="text-sm font-medium mb-2 text-foreground">{{ t('reader.failedToLoadBook') }}</p>
|
||||
<p class="text-sm font-medium mb-2 text-foreground">
|
||||
{{ t('reader.failedToLoadBook') }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">{{ error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,8 +4,14 @@ import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
EPUB_FONT_SIZE_MAX,
|
||||
EPUB_FONT_SIZE_MIN,
|
||||
EPUB_LETTER_SPACING_MAX,
|
||||
EPUB_LETTER_SPACING_MIN,
|
||||
EPUB_PARAGRAPH_SPACING_MAX,
|
||||
EPUB_PARAGRAPH_SPACING_MIN,
|
||||
EPUB_TEXT_INDENT_MAX,
|
||||
EPUB_TEXT_INDENT_MIN,
|
||||
EPUB_WORD_SPACING_MAX,
|
||||
EPUB_WORD_SPACING_MIN,
|
||||
isCustomFontCssFamily,
|
||||
type FontNamedInstance,
|
||||
type FontVariant,
|
||||
@@ -68,13 +74,34 @@ const modeOptions = computed(() => [
|
||||
])
|
||||
|
||||
const flowOptions = computed(() => [
|
||||
{ value: 'paginated', label: t('reader.settings.flow.paginated'), icon: BookOpen },
|
||||
{ value: 'scrolled', label: t('reader.settings.flow.scrolled'), icon: ScrollText },
|
||||
{
|
||||
value: 'paginated',
|
||||
label: t('reader.settings.flow.paginated'),
|
||||
icon: BookOpen,
|
||||
},
|
||||
{
|
||||
value: 'scrolled',
|
||||
label: t('reader.settings.flow.scrolled'),
|
||||
icon: ScrollText,
|
||||
},
|
||||
])
|
||||
|
||||
const spreadOptions = computed(() => [
|
||||
{ value: 'auto', label: t('reader.settings.spread.bookDefault'), icon: LayoutGrid },
|
||||
{ value: 'none', label: t('reader.settings.spread.singlePage'), icon: BookOpen },
|
||||
{
|
||||
value: 'auto',
|
||||
label: t('reader.settings.spread.bookDefault'),
|
||||
icon: LayoutGrid,
|
||||
},
|
||||
{
|
||||
value: 'none',
|
||||
label: t('reader.settings.spread.singlePage'),
|
||||
icon: BookOpen,
|
||||
},
|
||||
])
|
||||
|
||||
const typographySourceOptions = computed(() => [
|
||||
{ value: 'book', label: t('reader.settings.bookDefault') },
|
||||
{ value: 'custom', label: t('reader.settings.custom') },
|
||||
])
|
||||
|
||||
const currentMode = computed(() => (props.state.isDark ? 'dark' : 'light'))
|
||||
@@ -95,9 +122,21 @@ 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 }),
|
||||
value: formatNumber(props.state.paragraphSpacing, {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
const letterSpacingSource = computed(() => (props.state.letterSpacing === null ? 'book' : 'custom'))
|
||||
const wordSpacingSource = computed(() => (props.state.wordSpacing === null ? 'book' : 'custom'))
|
||||
const textIndentSource = computed(() => (props.state.textIndent === null ? 'book' : 'custom'))
|
||||
const letterSpacingValue = computed(() => props.state.letterSpacing ?? EPUB_LETTER_SPACING_MIN)
|
||||
const wordSpacingValue = computed(() => props.state.wordSpacing ?? EPUB_WORD_SPACING_MIN)
|
||||
const textIndentValue = computed(() => props.state.textIndent ?? EPUB_TEXT_INDENT_MIN)
|
||||
const letterSpacingLabel = computed(() => formatEmValue(letterSpacingValue.value, 2))
|
||||
const wordSpacingLabel = computed(() => formatEmValue(wordSpacingValue.value, 2))
|
||||
const textIndentLabel = computed(() => formatEmValue(textIndentValue.value, 2))
|
||||
|
||||
const columnGapPercent = computed(() => Math.round(props.state.gap * 100))
|
||||
|
||||
@@ -110,15 +149,22 @@ function selectTheme(themeName: string) {
|
||||
}
|
||||
|
||||
function decreaseTextSize() {
|
||||
emit('update', { fontSize: Math.max(EPUB_FONT_SIZE_MIN, props.state.fontSize - 1) })
|
||||
emit('update', {
|
||||
fontSize: Math.max(EPUB_FONT_SIZE_MIN, props.state.fontSize - 1),
|
||||
})
|
||||
}
|
||||
|
||||
function increaseTextSize() {
|
||||
emit('update', { fontSize: Math.min(EPUB_FONT_SIZE_MAX, props.state.fontSize + 1) })
|
||||
emit('update', {
|
||||
fontSize: Math.min(EPUB_FONT_SIZE_MAX, props.state.fontSize + 1),
|
||||
})
|
||||
}
|
||||
|
||||
function selectBuiltInFont(font: ReaderBuiltInFontOption) {
|
||||
emit('update', { fontFamily: font.value, ...variantUpdateFor(builtInVariants()) })
|
||||
emit('update', {
|
||||
fontFamily: font.value,
|
||||
...variantUpdateFor(builtInVariants()),
|
||||
})
|
||||
}
|
||||
|
||||
function setLineHeight(value: number) {
|
||||
@@ -129,6 +175,45 @@ function setParagraphSpacing(value: number) {
|
||||
emit('update', { paragraphSpacing: Math.round(value * 10) / 10 })
|
||||
}
|
||||
|
||||
function formatEmValue(value: number, maximumFractionDigits: number): string {
|
||||
return t('reader.settings.emValue', {
|
||||
value: formatNumber(value, {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
function setLetterSpacingSource(value: string) {
|
||||
emit('update', {
|
||||
letterSpacing: value === 'book' ? null : (props.state.letterSpacing ?? 0),
|
||||
})
|
||||
}
|
||||
|
||||
function setWordSpacingSource(value: string) {
|
||||
emit('update', {
|
||||
wordSpacing: value === 'book' ? null : (props.state.wordSpacing ?? 0),
|
||||
})
|
||||
}
|
||||
|
||||
function setTextIndentSource(value: string) {
|
||||
emit('update', {
|
||||
textIndent: value === 'book' ? null : (props.state.textIndent ?? 0),
|
||||
})
|
||||
}
|
||||
|
||||
function setLetterSpacing(value: number) {
|
||||
emit('update', { letterSpacing: Math.round(value * 100) / 100 })
|
||||
}
|
||||
|
||||
function setWordSpacing(value: number) {
|
||||
emit('update', { wordSpacing: Math.round(value * 20) / 20 })
|
||||
}
|
||||
|
||||
function setTextIndent(value: number) {
|
||||
emit('update', { textIndent: Math.round(value * 4) / 4 })
|
||||
}
|
||||
|
||||
function setPageWidth(value: number) {
|
||||
emit('update', { maxInlineSize: value })
|
||||
}
|
||||
@@ -138,15 +223,21 @@ function setFlow(value: string) {
|
||||
}
|
||||
|
||||
function setFixedLayoutSpread(value: string) {
|
||||
emit('update', { fixedLayoutSpread: value as ReaderState['fixedLayoutSpread'] })
|
||||
emit('update', {
|
||||
fixedLayoutSpread: value as ReaderState['fixedLayoutSpread'],
|
||||
})
|
||||
}
|
||||
|
||||
function decreaseColumns() {
|
||||
emit('update', { maxColumnCount: Math.max(COLUMN_MIN, props.state.maxColumnCount - 1) })
|
||||
emit('update', {
|
||||
maxColumnCount: Math.max(COLUMN_MIN, props.state.maxColumnCount - 1),
|
||||
})
|
||||
}
|
||||
|
||||
function increaseColumns() {
|
||||
emit('update', { maxColumnCount: Math.min(COLUMN_MAX, props.state.maxColumnCount + 1) })
|
||||
emit('update', {
|
||||
maxColumnCount: Math.min(COLUMN_MAX, props.state.maxColumnCount + 1),
|
||||
})
|
||||
}
|
||||
|
||||
function setColumnGap(value: number) {
|
||||
@@ -198,8 +289,16 @@ const customFontSections = computed(() => {
|
||||
const customFonts = props.customFonts
|
||||
if (!customFonts) return []
|
||||
return [
|
||||
{ key: 'server', label: t('reader.settings.fontServer'), families: customFonts.visibleServerFamilies.value },
|
||||
{ key: 'user', label: t('reader.settings.fontYours'), families: customFonts.families.value },
|
||||
{
|
||||
key: 'server',
|
||||
label: t('reader.settings.fontServer'),
|
||||
families: customFonts.visibleServerFamilies.value,
|
||||
},
|
||||
{
|
||||
key: 'user',
|
||||
label: t('reader.settings.fontYours'),
|
||||
families: customFonts.families.value,
|
||||
},
|
||||
].filter((section) => section.families.length > 0)
|
||||
})
|
||||
|
||||
@@ -207,7 +306,11 @@ const hasCustomFontSections = computed(() => customFontSections.value.length > 0
|
||||
|
||||
function selectCustomFont(family: FontFamily) {
|
||||
const cssFamilyName = props.customFonts?.getCssFamilyForDisplay(family.name, family.scope)
|
||||
if (cssFamilyName) emit('update', { fontFamily: cssFamilyName, ...variantUpdateFor(familyVariants(family.variants)) })
|
||||
if (cssFamilyName)
|
||||
emit('update', {
|
||||
fontFamily: cssFamilyName,
|
||||
...variantUpdateFor(familyVariants(family.variants)),
|
||||
})
|
||||
}
|
||||
|
||||
function isCustomFontSelected(family: FontFamily): boolean {
|
||||
@@ -215,7 +318,10 @@ function isCustomFontSelected(family: FontFamily): boolean {
|
||||
return props.customFonts.isFontFamilySelected(family.name, props.state.fontFamily, family.scope)
|
||||
}
|
||||
|
||||
const currentVariant = computed<FontVariant>(() => ({ weight: props.state.fontWeight, style: props.state.fontStyle }))
|
||||
const currentVariant = computed<FontVariant>(() => ({
|
||||
weight: props.state.fontWeight,
|
||||
style: props.state.fontStyle,
|
||||
}))
|
||||
|
||||
/**
|
||||
* The styles the selected family offers. Built-in stacks have no file list to read, so
|
||||
@@ -278,7 +384,9 @@ const cardBaseClass =
|
||||
<template>
|
||||
<section class="flex min-h-0 flex-col overflow-hidden bg-card text-card-foreground">
|
||||
<div class="flex shrink-0 items-center gap-2 border-b border-border bg-card px-4 py-2.5 transition-shadow" :class="isScrolled ? 'shadow-sm' : ''">
|
||||
<h2 class="mr-auto text-sm font-semibold">{{ t('reader.settings.title') }}</h2>
|
||||
<h2 class="mr-auto text-sm font-semibold">
|
||||
{{ t('reader.settings.title') }}
|
||||
</h2>
|
||||
<ReaderSegmentedControl
|
||||
class="w-[9.75rem] shrink-0"
|
||||
:options="modeOptions"
|
||||
@@ -344,9 +452,17 @@ const cardBaseClass =
|
||||
aria-hidden="true"
|
||||
class="relative flex h-9 w-full items-center justify-center overflow-hidden rounded-md font-serif text-[13px] ring-2 ring-offset-2 ring-offset-card transition-all"
|
||||
:class="state.themeName === theme.name ? 'ring-primary' : 'ring-transparent group-hover:ring-border'"
|
||||
:style="{ background: state.isDark ? theme.dark.bg : theme.light.bg, color: state.isDark ? theme.dark.fg : theme.light.fg }"
|
||||
:style="{
|
||||
background: state.isDark ? theme.dark.bg : theme.light.bg,
|
||||
color: state.isDark ? theme.dark.fg : theme.light.fg,
|
||||
}"
|
||||
>
|
||||
<span class="absolute inset-x-0 top-0 h-[3px]" :style="{ background: state.isDark ? theme.dark.link : theme.light.link }" />
|
||||
<span
|
||||
class="absolute inset-x-0 top-0 h-[3px]"
|
||||
:style="{
|
||||
background: state.isDark ? theme.dark.link : theme.light.link,
|
||||
}"
|
||||
/>
|
||||
Aa
|
||||
</span>
|
||||
<span
|
||||
@@ -385,7 +501,9 @@ const cardBaseClass =
|
||||
</div>
|
||||
|
||||
<template v-for="section in customFontSections" :key="section.key">
|
||||
<p class="mb-1.5 mt-3 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">{{ section.label }}</p>
|
||||
<p class="mb-1.5 mt-3 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{{ section.label }}
|
||||
</p>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
v-for="family in section.families"
|
||||
@@ -421,7 +539,11 @@ const cardBaseClass =
|
||||
? 'border-primary bg-primary text-primary-foreground'
|
||||
: 'border-border text-foreground hover:border-muted-foreground/40 hover:bg-muted'
|
||||
"
|
||||
:style="{ fontFamily: variantPreviewFamily, fontWeight: variant.weight, fontStyle: variant.style }"
|
||||
:style="{
|
||||
fontFamily: variantPreviewFamily,
|
||||
fontWeight: variant.weight,
|
||||
fontStyle: variant.style,
|
||||
}"
|
||||
:aria-pressed="isVariantSelected(variant)"
|
||||
@click="selectVariant(variant)"
|
||||
>
|
||||
@@ -527,6 +649,72 @@ const cardBaseClass =
|
||||
@update:model-value="setColumnGap"
|
||||
/>
|
||||
|
||||
<div class="space-y-2">
|
||||
<p class="text-[13px] font-medium text-foreground">
|
||||
{{ t('reader.settings.letterSpacing') }}
|
||||
</p>
|
||||
<ReaderSegmentedControl
|
||||
:options="typographySourceOptions"
|
||||
:model-value="letterSpacingSource"
|
||||
:aria-label="t('reader.settings.letterSpacing')"
|
||||
@update:model-value="setLetterSpacingSource"
|
||||
/>
|
||||
<ReaderRangeField
|
||||
v-if="state.letterSpacing !== null"
|
||||
:model-value="letterSpacingValue"
|
||||
:min="EPUB_LETTER_SPACING_MIN"
|
||||
:max="EPUB_LETTER_SPACING_MAX"
|
||||
:step="0.01"
|
||||
:label="t('reader.settings.letterSpacing')"
|
||||
:display-value="letterSpacingLabel"
|
||||
@update:model-value="setLetterSpacing"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<p class="text-[13px] font-medium text-foreground">
|
||||
{{ t('reader.settings.wordSpacing') }}
|
||||
</p>
|
||||
<ReaderSegmentedControl
|
||||
:options="typographySourceOptions"
|
||||
:model-value="wordSpacingSource"
|
||||
:aria-label="t('reader.settings.wordSpacing')"
|
||||
@update:model-value="setWordSpacingSource"
|
||||
/>
|
||||
<ReaderRangeField
|
||||
v-if="state.wordSpacing !== null"
|
||||
:model-value="wordSpacingValue"
|
||||
:min="EPUB_WORD_SPACING_MIN"
|
||||
:max="EPUB_WORD_SPACING_MAX"
|
||||
:step="0.05"
|
||||
:label="t('reader.settings.wordSpacing')"
|
||||
:display-value="wordSpacingLabel"
|
||||
@update:model-value="setWordSpacing"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<p class="text-[13px] font-medium text-foreground">
|
||||
{{ t('reader.settings.textIndent') }}
|
||||
</p>
|
||||
<ReaderSegmentedControl
|
||||
:options="typographySourceOptions"
|
||||
:model-value="textIndentSource"
|
||||
:aria-label="t('reader.settings.textIndent')"
|
||||
@update:model-value="setTextIndentSource"
|
||||
/>
|
||||
<ReaderRangeField
|
||||
v-if="state.textIndent !== null"
|
||||
:model-value="textIndentValue"
|
||||
:min="EPUB_TEXT_INDENT_MIN"
|
||||
:max="EPUB_TEXT_INDENT_MAX"
|
||||
:step="0.25"
|
||||
:label="t('reader.settings.textIndent')"
|
||||
:display-value="textIndentLabel"
|
||||
@update:model-value="setTextIndent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span :id="justifyLabelId" class="text-[13px] font-medium text-foreground">{{ t('reader.settings.justifyText') }}</span>
|
||||
<ToggleSwitch :model-value="state.justify" :aria-labelledby="justifyLabelId" @update:model-value="setJustify" />
|
||||
@@ -548,7 +736,9 @@ const cardBaseClass =
|
||||
:aria-label="t('reader.settings.pageSpreads')"
|
||||
@update:model-value="setFixedLayoutSpread"
|
||||
/>
|
||||
<p class="mt-2 text-xs leading-snug text-muted-foreground">{{ t('reader.settings.pageSpreadsHint') }}</p>
|
||||
<p class="mt-2 text-xs leading-snug text-muted-foreground">
|
||||
{{ t('reader.settings.pageSpreadsHint') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -12,6 +12,9 @@ function makeState(overrides: Partial<ReaderState> = {}): ReaderState {
|
||||
fontSize: 16,
|
||||
lineHeight: 1.5,
|
||||
paragraphSpacing: 0,
|
||||
letterSpacing: null,
|
||||
wordSpacing: null,
|
||||
textIndent: null,
|
||||
fontFamily: null,
|
||||
fontWeight: 400,
|
||||
fontStyle: 'normal',
|
||||
@@ -58,6 +61,10 @@ function buttonByText(wrapper: VueWrapper, text: string) {
|
||||
return wrapper.findAll('button').find((button) => button.text() === text)
|
||||
}
|
||||
|
||||
function groupByAriaLabel(wrapper: VueWrapper, label: string) {
|
||||
return wrapper.find(`[role="group"][aria-label="${label}"]`)
|
||||
}
|
||||
|
||||
/** Resolves the style chips through the group their heading labels, a11y wiring included. */
|
||||
function styleChips(wrapper: VueWrapper) {
|
||||
const group = wrapper.findAll('[role="group"]').find((candidate) => {
|
||||
@@ -216,7 +223,14 @@ describe('ReaderSettingsPanel', () => {
|
||||
})
|
||||
|
||||
it('emits advanced layout changes', async () => {
|
||||
const wrapper = mountPanel({ state: makeState({ maxColumnCount: 2, gap: 0.05, justify: true, hyphenate: true }) })
|
||||
const wrapper = mountPanel({
|
||||
state: makeState({
|
||||
maxColumnCount: 2,
|
||||
gap: 0.05,
|
||||
justify: true,
|
||||
hyphenate: true,
|
||||
}),
|
||||
})
|
||||
|
||||
await buttonByAriaLabel(wrapper, 'More columns').trigger('click')
|
||||
expect(wrapper.emitted('update')?.[0]).toEqual([{ maxColumnCount: 3 }])
|
||||
@@ -231,6 +245,33 @@ describe('ReaderSettingsPanel', () => {
|
||||
expect(wrapper.emitted('update')?.[3]).toEqual([{ hyphenate: false }])
|
||||
})
|
||||
|
||||
it('offers publisher and custom typography controls with nullable overrides', async () => {
|
||||
const wrapper = mountPanel()
|
||||
|
||||
expect(wrapper.text()).toContain('Letter spacing')
|
||||
expect(wrapper.text()).toContain('Word spacing')
|
||||
expect(wrapper.text()).toContain('First-line indent')
|
||||
expect(groupByAriaLabel(wrapper, 'Letter spacing').get('button[aria-pressed="true"]').text()).toBe('Book')
|
||||
|
||||
await groupByAriaLabel(wrapper, 'Letter spacing').findAll('button')[1]!.trigger('click')
|
||||
expect(wrapper.emitted('update')?.[0]).toEqual([{ letterSpacing: 0 }])
|
||||
|
||||
const customized = mountPanel({
|
||||
state: makeState({ letterSpacing: 0.1, wordSpacing: 0.2, textIndent: 1 }),
|
||||
})
|
||||
await rangeByLabel(customized, 'Letter spacing')!.setValue('0.13')
|
||||
await rangeByLabel(customized, 'Word spacing')!.setValue('0.35')
|
||||
await rangeByLabel(customized, 'First-line indent')!.setValue('1.75')
|
||||
await groupByAriaLabel(customized, 'Letter spacing').findAll('button')[0]!.trigger('click')
|
||||
|
||||
expect(customized.emitted('update')).toEqual([
|
||||
[{ letterSpacing: 0.13 }],
|
||||
[{ wordSpacing: 0.35 }],
|
||||
[{ textIndent: 1.75 }],
|
||||
[{ letterSpacing: null }],
|
||||
])
|
||||
})
|
||||
|
||||
it('resets only when the book carries overrides', async () => {
|
||||
const untouched = mountPanel({ canReset: false })
|
||||
expect(buttonByAriaLabel(untouched, 'Reset to defaults').attributes('disabled')).toBeDefined()
|
||||
@@ -244,13 +285,17 @@ describe('ReaderSettingsPanel', () => {
|
||||
})
|
||||
|
||||
it('offers spread choices and hides text controls for fixed-layout books', async () => {
|
||||
const wrapper = mountPanel({ state: makeState({ fixedLayoutSpread: 'auto' }), isFixedLayout: true })
|
||||
const wrapper = mountPanel({
|
||||
state: makeState({ fixedLayoutSpread: 'auto' }),
|
||||
isFixedLayout: true,
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('Page spreads')
|
||||
expect(wrapper.text()).toContain('Page color')
|
||||
expect(wrapper.text()).not.toContain('Text size')
|
||||
expect(wrapper.text()).not.toContain('Reading flow')
|
||||
expect(wrapper.text()).not.toContain('Advanced layout')
|
||||
expect(wrapper.text()).not.toContain('Letter spacing')
|
||||
|
||||
await buttonByText(wrapper, 'Single page')?.trigger('click')
|
||||
expect(wrapper.emitted('update')?.[0]).toEqual([{ fixedLayoutSpread: 'none' }])
|
||||
@@ -279,7 +324,13 @@ describe('ReaderSettingsPanel', () => {
|
||||
})
|
||||
|
||||
it('marks the style in use as pressed', () => {
|
||||
const wrapper = mountPanel({ state: makeState({ fontFamily: 'serif', fontWeight: 700, fontStyle: 'normal' }) })
|
||||
const wrapper = mountPanel({
|
||||
state: makeState({
|
||||
fontFamily: 'serif',
|
||||
fontWeight: 700,
|
||||
fontStyle: 'normal',
|
||||
}),
|
||||
})
|
||||
|
||||
expect(styleChip(wrapper, 'Bold')?.attributes('aria-pressed')).toBe('true')
|
||||
expect(styleChip(wrapper, 'Regular')?.attributes('aria-pressed')).toBe('false')
|
||||
@@ -299,14 +350,20 @@ describe('ReaderSettingsPanel', () => {
|
||||
}),
|
||||
]),
|
||||
])
|
||||
const wrapper = mountPanel({ state: makeState({ fontFamily: userCss('Inter') }), customFonts })
|
||||
const wrapper = mountPanel({
|
||||
state: makeState({ fontFamily: userCss('Inter') }),
|
||||
customFonts,
|
||||
})
|
||||
|
||||
expect(styleChipLabels(wrapper)).toEqual(['Thin', 'Book', 'Heavy'])
|
||||
})
|
||||
|
||||
it('hides the row for a family that offers a single style', () => {
|
||||
const customFonts = makeCustomFonts([makeFamily('Solo', [makeFont()])])
|
||||
const wrapper = mountPanel({ state: makeState({ fontFamily: userCss('Solo') }), customFonts })
|
||||
const wrapper = mountPanel({
|
||||
state: makeState({ fontFamily: userCss('Solo') }),
|
||||
customFonts,
|
||||
})
|
||||
|
||||
expect(wrapper.text()).not.toContain('Style')
|
||||
expect(styleChipLabels(wrapper)).toEqual([])
|
||||
@@ -314,7 +371,10 @@ describe('ReaderSettingsPanel', () => {
|
||||
|
||||
it('moves to the nearest style when the newly picked family lacks the current one', async () => {
|
||||
const customFonts = makeCustomFonts([makeFamily('Sparse', [makeFont({ weight: 300 }), makeFont({ id: 2, weight: 500 })])])
|
||||
const wrapper = mountPanel({ state: makeState({ fontFamily: 'serif', fontWeight: 700 }), customFonts })
|
||||
const wrapper = mountPanel({
|
||||
state: makeState({ fontFamily: 'serif', fontWeight: 700 }),
|
||||
customFonts,
|
||||
})
|
||||
|
||||
await buttonByText(wrapper, 'Sparse')?.trigger('click')
|
||||
|
||||
@@ -323,7 +383,10 @@ describe('ReaderSettingsPanel', () => {
|
||||
|
||||
it('leaves the style alone when the newly picked family offers it', async () => {
|
||||
const customFonts = makeCustomFonts([makeFamily('Full', [makeFont({ weight: 400 }), makeFont({ id: 2, weight: 700 })])])
|
||||
const wrapper = mountPanel({ state: makeState({ fontFamily: 'serif', fontWeight: 700 }), customFonts })
|
||||
const wrapper = mountPanel({
|
||||
state: makeState({ fontFamily: 'serif', fontWeight: 700 }),
|
||||
customFonts,
|
||||
})
|
||||
|
||||
await buttonByText(wrapper, 'Full')?.trigger('click')
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@ describe('useReaderState', () => {
|
||||
state.setFontSize(100)
|
||||
state.setLineHeight(0.71)
|
||||
state.setParagraphSpacing(3)
|
||||
state.setLetterSpacing(0.3)
|
||||
state.setWordSpacing(0.8)
|
||||
state.setTextIndent(8)
|
||||
state.setMaxColumnCount(0)
|
||||
state.setGap(0.9)
|
||||
state.setMaxInlineSize(200)
|
||||
@@ -16,6 +19,9 @@ describe('useReaderState', () => {
|
||||
expect(state.fontSize.value).toBe(32)
|
||||
expect(state.lineHeight.value).toBe(0.8)
|
||||
expect(state.paragraphSpacing.value).toBe(2)
|
||||
expect(state.letterSpacing.value).toBe(0.2)
|
||||
expect(state.wordSpacing.value).toBe(0.5)
|
||||
expect(state.textIndent.value).toBe(4)
|
||||
expect(state.maxColumnCount.value).toBe(1)
|
||||
expect(state.gap.value).toBe(0.5)
|
||||
expect(state.maxInlineSize.value).toBe(400)
|
||||
@@ -23,8 +29,14 @@ describe('useReaderState', () => {
|
||||
|
||||
state.setFontSize(1)
|
||||
state.setParagraphSpacing(-1)
|
||||
state.setLetterSpacing(-1)
|
||||
state.setWordSpacing(-1)
|
||||
state.setTextIndent(-1)
|
||||
expect(state.fontSize.value).toBe(6)
|
||||
expect(state.paragraphSpacing.value).toBe(0)
|
||||
expect(state.letterSpacing.value).toBe(0)
|
||||
expect(state.wordSpacing.value).toBe(0)
|
||||
expect(state.textIndent.value).toBe(0)
|
||||
})
|
||||
|
||||
it('selects default theme when unknown theme name is set', () => {
|
||||
@@ -154,6 +166,51 @@ describe('useReaderState', () => {
|
||||
expect(css).toContain('margin-block: unset !important;')
|
||||
})
|
||||
|
||||
it('preserves publisher typography when nullable controls are unset', () => {
|
||||
const state = useReaderState()
|
||||
|
||||
expect(state.letterSpacing.value).toBeNull()
|
||||
expect(state.wordSpacing.value).toBeNull()
|
||||
expect(state.textIndent.value).toBeNull()
|
||||
expect(state.generateCSS()).not.toContain('letter-spacing:')
|
||||
expect(state.generateCSS()).not.toContain('word-spacing:')
|
||||
expect(state.generateCSS()).not.toContain('text-indent:')
|
||||
})
|
||||
|
||||
it('emits explicit zero typography overrides and can return to publisher styles', () => {
|
||||
const state = useReaderState()
|
||||
state.setLetterSpacing(0)
|
||||
state.setWordSpacing(0)
|
||||
state.setTextIndent(0)
|
||||
|
||||
const css = state.generateCSS()
|
||||
expect(css).toContain('letter-spacing: 0em !important;')
|
||||
expect(css).toContain('word-spacing: 0em !important;')
|
||||
expect(css).toContain('text-indent: 0em !important;')
|
||||
|
||||
state.setLetterSpacing(null)
|
||||
state.setWordSpacing(null)
|
||||
state.setTextIndent(null)
|
||||
expect(state.state.value).toMatchObject({
|
||||
letterSpacing: null,
|
||||
wordSpacing: null,
|
||||
textIndent: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('generates em-based typography CSS for positive values', () => {
|
||||
const state = useReaderState()
|
||||
state.setLetterSpacing(0.12)
|
||||
state.setWordSpacing(0.35)
|
||||
state.setTextIndent(1.75)
|
||||
|
||||
const css = state.generateCSS()
|
||||
expect(css).toContain('letter-spacing: 0.12em !important;')
|
||||
expect(css).toContain('word-spacing: 0.35em !important;')
|
||||
expect(css).toContain('text-indent: 1.75em !important;')
|
||||
expect(css).toContain(':is(hgroup, header, figure, figcaption, blockquote, li) > p')
|
||||
})
|
||||
|
||||
describe('font style', () => {
|
||||
it('leaves weight and slant out of the CSS while they sit at their defaults', () => {
|
||||
const state = useReaderState()
|
||||
|
||||
@@ -2,9 +2,15 @@ import { computed, ref } from 'vue'
|
||||
import {
|
||||
EPUB_FONT_SIZE_MAX,
|
||||
EPUB_FONT_SIZE_MIN,
|
||||
EPUB_LETTER_SPACING_MAX,
|
||||
EPUB_LETTER_SPACING_MIN,
|
||||
EPUB_PARAGRAPH_SPACING_MAX,
|
||||
EPUB_PARAGRAPH_SPACING_MIN,
|
||||
EPUB_READER_DEFAULTS,
|
||||
EPUB_TEXT_INDENT_MAX,
|
||||
EPUB_TEXT_INDENT_MIN,
|
||||
EPUB_WORD_SPACING_MAX,
|
||||
EPUB_WORD_SPACING_MIN,
|
||||
type EpubReaderSettings,
|
||||
type FontStyle,
|
||||
} from '@bookorbit/types'
|
||||
@@ -16,6 +22,9 @@ export interface ReaderState {
|
||||
fontSize: number
|
||||
lineHeight: number
|
||||
paragraphSpacing: number
|
||||
letterSpacing: number | null
|
||||
wordSpacing: number | null
|
||||
textIndent: number | null
|
||||
fontFamily: string | null
|
||||
fontWeight: number
|
||||
fontStyle: FontStyle
|
||||
@@ -39,6 +48,9 @@ const defaults: ReaderState = {
|
||||
fontSize: 16,
|
||||
lineHeight: 1.5,
|
||||
paragraphSpacing: EPUB_READER_DEFAULTS.paragraphSpacing,
|
||||
letterSpacing: EPUB_READER_DEFAULTS.letterSpacing,
|
||||
wordSpacing: EPUB_READER_DEFAULTS.wordSpacing,
|
||||
textIndent: EPUB_READER_DEFAULTS.textIndent,
|
||||
fontFamily: null,
|
||||
fontWeight: EPUB_READER_DEFAULTS.fontWeight,
|
||||
fontStyle: EPUB_READER_DEFAULTS.fontStyle,
|
||||
@@ -58,6 +70,9 @@ export function useReaderState() {
|
||||
const fontSize = ref(defaults.fontSize)
|
||||
const lineHeight = ref(defaults.lineHeight)
|
||||
const paragraphSpacing = ref(defaults.paragraphSpacing)
|
||||
const letterSpacing = ref<number | null>(defaults.letterSpacing)
|
||||
const wordSpacing = ref<number | null>(defaults.wordSpacing)
|
||||
const textIndent = ref<number | null>(defaults.textIndent)
|
||||
const fontFamily = ref<string | null>(defaults.fontFamily)
|
||||
const fontWeight = ref(defaults.fontWeight)
|
||||
const fontStyle = ref<FontStyle>(defaults.fontStyle)
|
||||
@@ -78,6 +93,9 @@ export function useReaderState() {
|
||||
fontSize: fontSize.value,
|
||||
lineHeight: lineHeight.value,
|
||||
paragraphSpacing: paragraphSpacing.value,
|
||||
letterSpacing: letterSpacing.value,
|
||||
wordSpacing: wordSpacing.value,
|
||||
textIndent: textIndent.value,
|
||||
fontFamily: fontFamily.value,
|
||||
fontWeight: fontWeight.value,
|
||||
fontStyle: fontStyle.value,
|
||||
@@ -104,6 +122,9 @@ export function useReaderState() {
|
||||
const {
|
||||
lineHeight: lh,
|
||||
paragraphSpacing: ps,
|
||||
letterSpacing: ls,
|
||||
wordSpacing: ws,
|
||||
textIndent: ti,
|
||||
justify: j,
|
||||
hyphenate: h,
|
||||
fontSize: fs,
|
||||
@@ -157,6 +178,26 @@ export function useReaderState() {
|
||||
:is(hgroup, header) p {
|
||||
margin-block: unset !important;
|
||||
}`
|
||||
const inlineSpacingDeclarations = [
|
||||
ls === null ? '' : `letter-spacing: ${ls}em !important;`,
|
||||
ws === null ? '' : `word-spacing: ${ws}em !important;`,
|
||||
].filter(Boolean)
|
||||
const inlineSpacingRule = inlineSpacingDeclarations.length
|
||||
? `
|
||||
p, li, blockquote, dd {
|
||||
${inlineSpacingDeclarations.join('\n ')}
|
||||
}`
|
||||
: ''
|
||||
const textIndentRule =
|
||||
ti === null
|
||||
? ''
|
||||
: `
|
||||
p {
|
||||
text-indent: ${ti}em !important;
|
||||
}
|
||||
:is(hgroup, header, figure, figcaption, blockquote, li) > p {
|
||||
text-indent: unset !important;
|
||||
}`
|
||||
|
||||
return `
|
||||
${fontFaceBlock}
|
||||
@@ -240,6 +281,8 @@ export function useReaderState() {
|
||||
hyphens: ${h ? 'auto' : 'none'};
|
||||
}
|
||||
${paragraphSpacingRule}
|
||||
${inlineSpacingRule}
|
||||
${textIndentRule}
|
||||
::selection {
|
||||
background-color: rgba(128, 128, 128, 0.3);
|
||||
}
|
||||
@@ -277,6 +320,15 @@ export function useReaderState() {
|
||||
function setParagraphSpacing(v: number) {
|
||||
paragraphSpacing.value = Math.max(EPUB_PARAGRAPH_SPACING_MIN, Math.min(EPUB_PARAGRAPH_SPACING_MAX, Math.round(v * 10) / 10))
|
||||
}
|
||||
function setLetterSpacing(v: number | null) {
|
||||
letterSpacing.value = clampNullable(v, EPUB_LETTER_SPACING_MIN, EPUB_LETTER_SPACING_MAX, 100)
|
||||
}
|
||||
function setWordSpacing(v: number | null) {
|
||||
wordSpacing.value = clampNullable(v, EPUB_WORD_SPACING_MIN, EPUB_WORD_SPACING_MAX, 20)
|
||||
}
|
||||
function setTextIndent(v: number | null) {
|
||||
textIndent.value = clampNullable(v, EPUB_TEXT_INDENT_MIN, EPUB_TEXT_INDENT_MAX, 4)
|
||||
}
|
||||
function setFontFamily(v: string | null) {
|
||||
fontFamily.value = v
|
||||
}
|
||||
@@ -326,6 +378,9 @@ export function useReaderState() {
|
||||
fontSize,
|
||||
lineHeight,
|
||||
paragraphSpacing,
|
||||
letterSpacing,
|
||||
wordSpacing,
|
||||
textIndent,
|
||||
fontFamily,
|
||||
fontWeight,
|
||||
fontStyle,
|
||||
@@ -347,6 +402,9 @@ export function useReaderState() {
|
||||
setFontSize,
|
||||
setLineHeight,
|
||||
setParagraphSpacing,
|
||||
setLetterSpacing,
|
||||
setWordSpacing,
|
||||
setTextIndent,
|
||||
setFontFamily,
|
||||
setFontWeight,
|
||||
setFontStyle,
|
||||
@@ -363,3 +421,9 @@ export function useReaderState() {
|
||||
setFontFaceCSS,
|
||||
}
|
||||
}
|
||||
|
||||
function clampNullable(value: number | null, min: number, max: number, precision: number): number | null {
|
||||
if (value === null) return null
|
||||
const finiteValue = Number.isFinite(value) ? value : min
|
||||
return Math.max(min, Math.min(max, Math.round(finiteValue * precision) / precision))
|
||||
}
|
||||
|
||||
@@ -5,11 +5,15 @@ import { CBX_READER_DEFAULTS, READER_GROUP_DEFAULTS, type PdfReaderSettings } fr
|
||||
const apiMock = vi.hoisted(() => vi.fn<(...args: unknown[]) => Promise<unknown>>())
|
||||
vi.mock('@/lib/api', () => ({ api: apiMock }))
|
||||
|
||||
const toastMock = vi.hoisted(() => ({ success: vi.fn<(...args: unknown[]) => unknown>() }))
|
||||
const toastMock = vi.hoisted(() => ({
|
||||
success: vi.fn<(...args: unknown[]) => unknown>(),
|
||||
}))
|
||||
vi.mock('vue-sonner', () => ({ toast: toastMock }))
|
||||
|
||||
const useAuthMock = vi.hoisted(() => vi.fn<(...args: unknown[]) => unknown>(() => ({ user: ref(null) })))
|
||||
vi.mock('@/features/auth/composables/useAuth', () => ({ useAuth: useAuthMock }))
|
||||
vi.mock('@/features/auth/composables/useAuth', () => ({
|
||||
useAuth: useAuthMock,
|
||||
}))
|
||||
|
||||
import { useReaderSettings, useReaderDefaultSettings } from './useReaderSettings'
|
||||
|
||||
@@ -69,7 +73,10 @@ describe('useReaderSettings - load() with valid localStorage epub delta', () =>
|
||||
const s = useReaderSettings(BOOK_FILE_ID, 'epub')
|
||||
await s.load()
|
||||
|
||||
expect(s.effective.value).toMatchObject({ ...READER_GROUP_DEFAULTS.epub, fontSize: 24 })
|
||||
expect(s.effective.value).toMatchObject({
|
||||
...READER_GROUP_DEFAULTS.epub,
|
||||
fontSize: 24,
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves the minimum EPUB font size', async () => {
|
||||
@@ -101,6 +108,33 @@ describe('useReaderSettings - load() with valid localStorage epub delta', () =>
|
||||
expect(s.effective.value).toMatchObject({ paragraphSpacing: 0 })
|
||||
})
|
||||
|
||||
it('preserves nullable typography and explicit zero in per-book settings', async () => {
|
||||
localStorage.setItem(
|
||||
'reader:default:epub',
|
||||
JSON.stringify({
|
||||
...READER_GROUP_DEFAULTS.epub,
|
||||
letterSpacing: 0.12,
|
||||
wordSpacing: 0.25,
|
||||
textIndent: 1.5,
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(`reader:book:${BOOK_FILE_ID}`, JSON.stringify({ letterSpacing: null, wordSpacing: 0, textIndent: 0 }))
|
||||
|
||||
const s = useReaderSettings(BOOK_FILE_ID, 'epub')
|
||||
await s.load()
|
||||
|
||||
expect(s.bookDelta.value).toEqual({
|
||||
letterSpacing: null,
|
||||
wordSpacing: 0,
|
||||
textIndent: 0,
|
||||
})
|
||||
expect(s.effective.value).toMatchObject({
|
||||
letterSpacing: null,
|
||||
wordSpacing: 0,
|
||||
textIndent: 0,
|
||||
})
|
||||
})
|
||||
|
||||
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))
|
||||
@@ -143,6 +177,23 @@ describe('useReaderSettings - load() with invalid localStorage value', () => {
|
||||
expect(localStorage.getItem(`reader:book:${BOOK_FILE_ID}`)).toBeNull()
|
||||
})
|
||||
|
||||
it('removes typography values outside their supported ranges', async () => {
|
||||
localStorage.setItem(
|
||||
`reader:book:${BOOK_FILE_ID}`,
|
||||
JSON.stringify({
|
||||
letterSpacing: 0.21,
|
||||
wordSpacing: 0.51,
|
||||
textIndent: 4.01,
|
||||
}),
|
||||
)
|
||||
|
||||
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 () => {
|
||||
localStorage.setItem(`reader:book:${BOOK_FILE_ID}`, JSON.stringify('not-an-object'))
|
||||
|
||||
@@ -193,12 +244,23 @@ describe('useReaderSettings - PDF validation', () => {
|
||||
})
|
||||
|
||||
it('migrates wrapped mode and retains new read-only layout choices', async () => {
|
||||
localStorage.setItem(`reader:book:${BOOK_FILE_ID}`, JSON.stringify({ scrollMode: 'wrapped', spread: 'auto', zoomMode: 'automatic' }))
|
||||
localStorage.setItem(
|
||||
`reader:book:${BOOK_FILE_ID}`,
|
||||
JSON.stringify({
|
||||
scrollMode: 'wrapped',
|
||||
spread: 'auto',
|
||||
zoomMode: 'automatic',
|
||||
}),
|
||||
)
|
||||
|
||||
const settings = useReaderSettings(BOOK_FILE_ID, 'pdf')
|
||||
await settings.load()
|
||||
|
||||
expect(settings.bookDelta.value).toEqual({ scrollMode: 'vertical', spread: 'auto', zoomMode: 'automatic' })
|
||||
expect(settings.bookDelta.value).toEqual({
|
||||
scrollMode: 'vertical',
|
||||
spread: 'auto',
|
||||
zoomMode: 'automatic',
|
||||
})
|
||||
expect(JSON.parse(localStorage.getItem(`reader:book:${BOOK_FILE_ID}`) ?? '{}')).toEqual({
|
||||
scrollMode: 'vertical',
|
||||
spread: 'auto',
|
||||
@@ -226,7 +288,10 @@ describe('useReaderSettings - load() with valid cbx localStorage delta', () => {
|
||||
const s = useReaderSettings(BOOK_FILE_ID, 'cbz')
|
||||
await s.load()
|
||||
|
||||
expect(s.bookDelta.value).toMatchObject({ fitMode: 'fit-page', viewMode: 'two-page' })
|
||||
expect(s.bookDelta.value).toMatchObject({
|
||||
fitMode: 'fit-page',
|
||||
viewMode: 'two-page',
|
||||
})
|
||||
expect(s.isCustomized.value).toBe(true)
|
||||
})
|
||||
|
||||
@@ -236,7 +301,10 @@ describe('useReaderSettings - load() with valid cbx localStorage delta', () => {
|
||||
const valid = useReaderSettings(BOOK_FILE_ID, 'cbz')
|
||||
await valid.load()
|
||||
|
||||
expect(valid.bookDelta.value).toMatchObject({ spreadGap: 0, viewMode: 'two-page' })
|
||||
expect(valid.bookDelta.value).toMatchObject({
|
||||
spreadGap: 0,
|
||||
viewMode: 'two-page',
|
||||
})
|
||||
|
||||
localStorage.setItem(`reader:book:${BOOK_FILE_ID}`, JSON.stringify({ spreadGap: 65, viewMode: 'two-page' }))
|
||||
|
||||
@@ -274,7 +342,9 @@ describe('useReaderSettings - updateBookSettings', () => {
|
||||
})
|
||||
|
||||
it('calls api() with PUT when sync is enabled', () => {
|
||||
useAuthMock.mockReturnValue({ user: ref({ settings: { syncReaderPreferences: true } }) })
|
||||
useAuthMock.mockReturnValue({
|
||||
user: ref({ settings: { syncReaderPreferences: true } }),
|
||||
})
|
||||
apiMock.mockResolvedValue({ ok: true })
|
||||
|
||||
const s = useReaderSettings(BOOK_FILE_ID, 'epub')
|
||||
@@ -305,7 +375,9 @@ describe('useReaderSettings - resetBookSettings', () => {
|
||||
})
|
||||
|
||||
it('calls api() with DELETE when sync is enabled', () => {
|
||||
useAuthMock.mockReturnValue({ user: ref({ settings: { syncReaderPreferences: true } }) })
|
||||
useAuthMock.mockReturnValue({
|
||||
user: ref({ settings: { syncReaderPreferences: true } }),
|
||||
})
|
||||
apiMock.mockResolvedValue({ ok: true })
|
||||
|
||||
const s = useReaderSettings(BOOK_FILE_ID, 'epub')
|
||||
@@ -321,7 +393,10 @@ describe('useReaderSettings - updateDefaultSettings', () => {
|
||||
s.updateDefaultSettings({ fontSize: 18 } as never)
|
||||
|
||||
const stored = JSON.parse(localStorage.getItem('reader:default:epub') ?? 'null')
|
||||
expect(stored).toMatchObject({ ...READER_GROUP_DEFAULTS.epub, fontSize: 18 })
|
||||
expect(stored).toMatchObject({
|
||||
...READER_GROUP_DEFAULTS.epub,
|
||||
fontSize: 18,
|
||||
})
|
||||
})
|
||||
|
||||
it('merges patch into existing defaultSettings', () => {
|
||||
@@ -334,7 +409,9 @@ describe('useReaderSettings - updateDefaultSettings', () => {
|
||||
})
|
||||
|
||||
it('calls api() with PUT when sync is enabled', () => {
|
||||
useAuthMock.mockReturnValue({ user: ref({ settings: { syncReaderPreferences: true } }) })
|
||||
useAuthMock.mockReturnValue({
|
||||
user: ref({ settings: { syncReaderPreferences: true } }),
|
||||
})
|
||||
apiMock.mockResolvedValue({ ok: true })
|
||||
|
||||
const s = useReaderSettings(BOOK_FILE_ID, 'epub')
|
||||
@@ -362,7 +439,9 @@ describe('useReaderSettings - resetDefaultSettings', () => {
|
||||
})
|
||||
|
||||
it('calls api() with DELETE when sync is enabled', () => {
|
||||
useAuthMock.mockReturnValue({ user: ref({ settings: { syncReaderPreferences: true } }) })
|
||||
useAuthMock.mockReturnValue({
|
||||
user: ref({ settings: { syncReaderPreferences: true } }),
|
||||
})
|
||||
apiMock.mockResolvedValue({ ok: true })
|
||||
|
||||
const s = useReaderSettings(BOOK_FILE_ID, 'epub')
|
||||
@@ -390,7 +469,9 @@ describe('useReaderSettings - syncEnabled behavior', () => {
|
||||
})
|
||||
|
||||
it('calls api() when user has syncReaderPreferences: true', () => {
|
||||
useAuthMock.mockReturnValue({ user: ref({ settings: { syncReaderPreferences: true } }) })
|
||||
useAuthMock.mockReturnValue({
|
||||
user: ref({ settings: { syncReaderPreferences: true } }),
|
||||
})
|
||||
apiMock.mockResolvedValue({ ok: true })
|
||||
|
||||
const s = useReaderSettings(BOOK_FILE_ID, 'epub')
|
||||
@@ -400,7 +481,9 @@ describe('useReaderSettings - syncEnabled behavior', () => {
|
||||
})
|
||||
|
||||
it('does not call api() when syncReaderPreferences is false', () => {
|
||||
useAuthMock.mockReturnValue({ user: ref({ settings: { syncReaderPreferences: false } }) })
|
||||
useAuthMock.mockReturnValue({
|
||||
user: ref({ settings: { syncReaderPreferences: false } }),
|
||||
})
|
||||
|
||||
const s = useReaderSettings(BOOK_FILE_ID, 'epub')
|
||||
s.updateBookSettings({ fontSize: 22 } as never)
|
||||
@@ -411,7 +494,9 @@ describe('useReaderSettings - syncEnabled behavior', () => {
|
||||
|
||||
describe('useReaderSettings - syncFromDb (via load() when syncEnabled)', () => {
|
||||
beforeEach(() => {
|
||||
useAuthMock.mockReturnValue({ user: ref({ settings: { syncReaderPreferences: true } }) })
|
||||
useAuthMock.mockReturnValue({
|
||||
user: ref({ settings: { syncReaderPreferences: true } }),
|
||||
})
|
||||
})
|
||||
|
||||
it('updates bookDelta when api returns valid settings', async () => {
|
||||
@@ -425,9 +510,11 @@ describe('useReaderSettings - syncFromDb (via load() when syncEnabled)', () => {
|
||||
})
|
||||
|
||||
it('updates defaultSettings when api returns group key', async () => {
|
||||
apiMock
|
||||
.mockResolvedValueOnce(makeFakeResponse(false, {}))
|
||||
.mockResolvedValueOnce(makeFakeResponse(true, { cbx: { fitMode: 'fit-width', viewMode: 'single' } }))
|
||||
apiMock.mockResolvedValueOnce(makeFakeResponse(false, {})).mockResolvedValueOnce(
|
||||
makeFakeResponse(true, {
|
||||
cbx: { fitMode: 'fit-width', viewMode: 'single' },
|
||||
}),
|
||||
)
|
||||
|
||||
const s = useReaderSettings(BOOK_FILE_ID, 'cbz')
|
||||
await s.load()
|
||||
@@ -464,7 +551,10 @@ describe('useReaderDefaultSettings - load() with localStorage', () => {
|
||||
const s = useReaderDefaultSettings('epub')
|
||||
await s.load()
|
||||
|
||||
expect(s.effective.value).toMatchObject({ fontSize: 20, fixedLayoutSpread: 'auto' })
|
||||
expect(s.effective.value).toMatchObject({
|
||||
fontSize: 20,
|
||||
fixedLayoutSpread: 'auto',
|
||||
})
|
||||
expect(JSON.parse(localStorage.getItem('reader:default:epub') ?? '{}')).toMatchObject({ fixedLayoutSpread: 'auto' })
|
||||
})
|
||||
|
||||
@@ -476,10 +566,35 @@ describe('useReaderDefaultSettings - load() with localStorage', () => {
|
||||
const s = useReaderDefaultSettings('epub')
|
||||
await s.load()
|
||||
|
||||
expect(s.effective.value).toMatchObject({ fontSize: 20, paragraphSpacing: 0 })
|
||||
expect(s.effective.value).toMatchObject({
|
||||
fontSize: 20,
|
||||
paragraphSpacing: 0,
|
||||
})
|
||||
expect(JSON.parse(localStorage.getItem('reader:default:epub') ?? '{}')).toMatchObject({ paragraphSpacing: 0 })
|
||||
})
|
||||
|
||||
it('fills nullable publisher typography for older epub defaults in localStorage', async () => {
|
||||
const stored = { ...READER_GROUP_DEFAULTS.epub, fontSize: 20 }
|
||||
delete (stored as Record<string, unknown>).letterSpacing
|
||||
delete (stored as Record<string, unknown>).wordSpacing
|
||||
delete (stored as Record<string, unknown>).textIndent
|
||||
localStorage.setItem('reader:default:epub', JSON.stringify(stored))
|
||||
|
||||
const s = useReaderDefaultSettings('epub')
|
||||
await s.load()
|
||||
|
||||
expect(s.effective.value).toMatchObject({
|
||||
letterSpacing: null,
|
||||
wordSpacing: null,
|
||||
textIndent: null,
|
||||
})
|
||||
expect(JSON.parse(localStorage.getItem('reader:default:epub') ?? '{}')).toMatchObject({
|
||||
letterSpacing: null,
|
||||
wordSpacing: null,
|
||||
textIndent: null,
|
||||
})
|
||||
})
|
||||
|
||||
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))
|
||||
@@ -487,7 +602,10 @@ describe('useReaderDefaultSettings - load() with localStorage', () => {
|
||||
const s = useReaderDefaultSettings<typeof CBX_READER_DEFAULTS>('cbz')
|
||||
await s.load()
|
||||
|
||||
expect(s.effective.value).toMatchObject({ ...CBX_READER_DEFAULTS, fitMode: 'fit-width' })
|
||||
expect(s.effective.value).toMatchObject({
|
||||
...CBX_READER_DEFAULTS,
|
||||
fitMode: 'fit-width',
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to group defaults when localStorage is empty', async () => {
|
||||
@@ -515,7 +633,9 @@ describe('useReaderDefaultSettings - update', () => {
|
||||
})
|
||||
|
||||
it('calls api() with PUT when sync is enabled', () => {
|
||||
useAuthMock.mockReturnValue({ user: ref({ settings: { syncReaderPreferences: true } }) })
|
||||
useAuthMock.mockReturnValue({
|
||||
user: ref({ settings: { syncReaderPreferences: true } }),
|
||||
})
|
||||
apiMock.mockResolvedValue({ ok: true })
|
||||
|
||||
const s = useReaderDefaultSettings('epub')
|
||||
@@ -544,7 +664,9 @@ describe('useReaderDefaultSettings - reset', () => {
|
||||
})
|
||||
|
||||
it('calls api() with DELETE when sync is enabled', () => {
|
||||
useAuthMock.mockReturnValue({ user: ref({ settings: { syncReaderPreferences: true } }) })
|
||||
useAuthMock.mockReturnValue({
|
||||
user: ref({ settings: { syncReaderPreferences: true } }),
|
||||
})
|
||||
apiMock.mockResolvedValue({ ok: true })
|
||||
|
||||
const s = useReaderDefaultSettings('epub')
|
||||
|
||||
@@ -8,9 +8,15 @@ import {
|
||||
CBX_READER_DEFAULTS,
|
||||
EPUB_FONT_SIZE_MAX,
|
||||
EPUB_FONT_SIZE_MIN,
|
||||
EPUB_LETTER_SPACING_MAX,
|
||||
EPUB_LETTER_SPACING_MIN,
|
||||
EPUB_PARAGRAPH_SPACING_MAX,
|
||||
EPUB_PARAGRAPH_SPACING_MIN,
|
||||
EPUB_READER_DEFAULTS,
|
||||
EPUB_TEXT_INDENT_MAX,
|
||||
EPUB_TEXT_INDENT_MIN,
|
||||
EPUB_WORD_SPACING_MAX,
|
||||
EPUB_WORD_SPACING_MIN,
|
||||
PDF_READER_DEFAULTS,
|
||||
type CbxReaderSettings,
|
||||
type EpubReaderSettings,
|
||||
@@ -60,6 +66,10 @@ function isIntegerInRange(value: unknown, min: number, max: number): value is nu
|
||||
return Number.isInteger(value) && isNumberInRange(value, min, max)
|
||||
}
|
||||
|
||||
function isNullableNumberInRange(value: unknown, min: number, max: number): value is number | null {
|
||||
return value === null || isNumberInRange(value, min, max)
|
||||
}
|
||||
|
||||
function sanitizeEpubPartialSettings(settings: unknown): Partial<EpubReaderSettings> | null {
|
||||
if (!isRecord(settings)) return null
|
||||
|
||||
@@ -89,6 +99,15 @@ function sanitizeEpubPartialSettings(settings: unknown): Partial<EpubReaderSetti
|
||||
if (isNumberInRange(settings.paragraphSpacing, EPUB_PARAGRAPH_SPACING_MIN, EPUB_PARAGRAPH_SPACING_MAX)) {
|
||||
out.paragraphSpacing = settings.paragraphSpacing
|
||||
}
|
||||
if (isNullableNumberInRange(settings.letterSpacing, EPUB_LETTER_SPACING_MIN, EPUB_LETTER_SPACING_MAX)) {
|
||||
out.letterSpacing = settings.letterSpacing
|
||||
}
|
||||
if (isNullableNumberInRange(settings.wordSpacing, EPUB_WORD_SPACING_MIN, EPUB_WORD_SPACING_MAX)) {
|
||||
out.wordSpacing = settings.wordSpacing
|
||||
}
|
||||
if (isNullableNumberInRange(settings.textIndent, EPUB_TEXT_INDENT_MIN, EPUB_TEXT_INDENT_MAX)) {
|
||||
out.textIndent = settings.textIndent
|
||||
}
|
||||
if (isIntegerInRange(settings.maxColumnCount, 1, 10)) {
|
||||
out.maxColumnCount = settings.maxColumnCount
|
||||
}
|
||||
@@ -311,7 +330,10 @@ export function useReaderSettings(bookFileId: number, format: string) {
|
||||
|
||||
// Merges only the changed field(s) into the existing delta — never saves a full snapshot.
|
||||
function updateBookSettings(patch: Partial<ReaderSettings>) {
|
||||
const next = { ...(bookDelta.value ?? undefined), ...patch } as Partial<ReaderSettings>
|
||||
const next = {
|
||||
...(bookDelta.value ?? undefined),
|
||||
...patch,
|
||||
} as Partial<ReaderSettings>
|
||||
bookDelta.value = next
|
||||
isCustomized.value = Object.keys(next).length > 0
|
||||
writeLs(lsBookKey(bookFileId), next)
|
||||
@@ -331,7 +353,9 @@ export function useReaderSettings(bookFileId: number, format: string) {
|
||||
removeLs(lsBookKey(bookFileId))
|
||||
|
||||
if (syncEnabled.value) {
|
||||
api(`/api/v1/reader/preferences/${bookFileId}`, { method: 'DELETE' }).catch(() => {})
|
||||
api(`/api/v1/reader/preferences/${bookFileId}`, {
|
||||
method: 'DELETE',
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,14 @@
|
||||
import { computed, onMounted, onUnmounted, ref, useId, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
EPUB_LETTER_SPACING_MAX,
|
||||
EPUB_LETTER_SPACING_MIN,
|
||||
EPUB_PARAGRAPH_SPACING_MAX,
|
||||
EPUB_PARAGRAPH_SPACING_MIN,
|
||||
EPUB_TEXT_INDENT_MAX,
|
||||
EPUB_TEXT_INDENT_MIN,
|
||||
EPUB_WORD_SPACING_MAX,
|
||||
EPUB_WORD_SPACING_MIN,
|
||||
isCustomFontCssFamily,
|
||||
type EpubReaderSettings,
|
||||
type FontNamedInstance,
|
||||
@@ -39,6 +45,12 @@ const props = withDefaults(
|
||||
const { t } = useI18n()
|
||||
const fontStyleSelectId = `ebook-font-style-${useId()}`
|
||||
const paragraphSpacingInputId = `ebook-paragraph-spacing-${useId()}`
|
||||
const letterSpacingSourceId = `ebook-letter-spacing-source-${useId()}`
|
||||
const letterSpacingInputId = `ebook-letter-spacing-${useId()}`
|
||||
const wordSpacingSourceId = `ebook-word-spacing-source-${useId()}`
|
||||
const wordSpacingInputId = `ebook-word-spacing-${useId()}`
|
||||
const textIndentSourceId = `ebook-text-indent-source-${useId()}`
|
||||
const textIndentInputId = `ebook-text-indent-${useId()}`
|
||||
|
||||
const { effective, load, update, reset } = useReaderDefaultSettings<EpubReaderSettings>('epub')
|
||||
|
||||
@@ -84,22 +96,90 @@ function fontStyleLabel(variant: FontNamedInstance): string {
|
||||
return t('settings.reader.fonts.weightItalicOf', { weight: base })
|
||||
}
|
||||
|
||||
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 }),
|
||||
value: formatNumber(effective.value.paragraphSpacing, {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
const letterSpacingValue = computed(() => effective.value.letterSpacing ?? EPUB_LETTER_SPACING_MIN)
|
||||
const wordSpacingValue = computed(() => effective.value.wordSpacing ?? EPUB_WORD_SPACING_MIN)
|
||||
const textIndentValue = computed(() => effective.value.textIndent ?? EPUB_TEXT_INDENT_MIN)
|
||||
const letterSpacingLabel = computed(() => formatEmValue(letterSpacingValue.value, 2))
|
||||
const wordSpacingLabel = computed(() => formatEmValue(wordSpacingValue.value, 2))
|
||||
const textIndentLabel = computed(() => formatEmValue(textIndentValue.value, 2))
|
||||
|
||||
function selectFontStyle(event: Event) {
|
||||
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) })
|
||||
update({
|
||||
paragraphSpacing: Number((event.target as HTMLInputElement).value),
|
||||
})
|
||||
}
|
||||
|
||||
function formatEmValue(value: number, maximumFractionDigits: number): string {
|
||||
return t('settings.reader.ebook.emValue', {
|
||||
value: formatNumber(value, {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
function setLetterSpacingSource(event: Event) {
|
||||
const source = (event.target as HTMLSelectElement).value
|
||||
update({
|
||||
letterSpacing: source === 'book' ? null : (effective.value.letterSpacing ?? 0),
|
||||
})
|
||||
}
|
||||
|
||||
function setWordSpacingSource(event: Event) {
|
||||
const source = (event.target as HTMLSelectElement).value
|
||||
update({
|
||||
wordSpacing: source === 'book' ? null : (effective.value.wordSpacing ?? 0),
|
||||
})
|
||||
}
|
||||
|
||||
function setTextIndentSource(event: Event) {
|
||||
const source = (event.target as HTMLSelectElement).value
|
||||
update({
|
||||
textIndent: source === 'book' ? null : (effective.value.textIndent ?? 0),
|
||||
})
|
||||
}
|
||||
|
||||
function setLetterSpacing(event: Event) {
|
||||
update({
|
||||
letterSpacing: Math.round(Number((event.target as HTMLInputElement).value) * 100) / 100,
|
||||
})
|
||||
}
|
||||
|
||||
function setWordSpacing(event: Event) {
|
||||
update({
|
||||
wordSpacing: Math.round(Number((event.target as HTMLInputElement).value) * 20) / 20,
|
||||
})
|
||||
}
|
||||
|
||||
function setTextIndent(event: Event) {
|
||||
update({
|
||||
textIndent: Math.round(Number((event.target as HTMLInputElement).value) * 4) / 4,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -109,7 +189,10 @@ function setParagraphSpacing(event: Event) {
|
||||
function selectFontFamily(event: Event) {
|
||||
const fontFamily = (event.target as HTMLSelectElement).value || null
|
||||
const variants = variantsForFamily(fontFamily)
|
||||
const current = { weight: effective.value.fontWeight, style: effective.value.fontStyle }
|
||||
const current = {
|
||||
weight: effective.value.fontWeight,
|
||||
style: effective.value.fontStyle,
|
||||
}
|
||||
|
||||
if (variants.some((variant) => isSameVariant(variant, current))) {
|
||||
update({ fontFamily })
|
||||
@@ -145,7 +228,15 @@ function reconcileSavedFont() {
|
||||
weight: effective.value.fontWeight,
|
||||
style: effective.value.fontStyle,
|
||||
})
|
||||
update(fallback ? { fontFamily: null, fontWeight: fallback.weight, fontStyle: fallback.style } : { fontFamily: null })
|
||||
update(
|
||||
fallback
|
||||
? {
|
||||
fontFamily: null,
|
||||
fontWeight: fallback.weight,
|
||||
fontStyle: fallback.style,
|
||||
}
|
||||
: { fontFamily: null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -579,6 +670,123 @@ function setFixedLayoutSpreadNone() {
|
||||
{{ t('settings.reader.ebook.advanced') }}
|
||||
</p>
|
||||
<div class="settings-card">
|
||||
<div class="px-4 py-3.5 md:px-5 md:py-4 bg-card">
|
||||
<div class="mb-3 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<label :for="letterSpacingSourceId" class="settings-label">{{ t('settings.reader.ebook.letterSpacing') }}</label>
|
||||
<p class="settings-hint">
|
||||
{{ t('settings.reader.ebook.letterSpacingHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<select
|
||||
:id="letterSpacingSourceId"
|
||||
class="text-xs border border-border rounded-md px-2 py-1.5 bg-card text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
:value="effective.letterSpacing === null ? 'book' : 'custom'"
|
||||
@change="setLetterSpacingSource"
|
||||
>
|
||||
<option value="book">
|
||||
{{ t('settings.reader.ebook.bookDefault') }}
|
||||
</option>
|
||||
<option value="custom">
|
||||
{{ t('settings.reader.ebook.custom') }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="effective.letterSpacing !== null" class="flex items-center gap-3">
|
||||
<input
|
||||
:id="letterSpacingInputId"
|
||||
type="range"
|
||||
:min="EPUB_LETTER_SPACING_MIN"
|
||||
:max="EPUB_LETTER_SPACING_MAX"
|
||||
step="0.01"
|
||||
class="w-full accent-primary cursor-pointer"
|
||||
:value="letterSpacingValue"
|
||||
:aria-label="t('settings.reader.ebook.letterSpacing')"
|
||||
:aria-valuetext="letterSpacingLabel"
|
||||
@input="setLetterSpacing"
|
||||
/>
|
||||
<span class="settings-value w-16 text-right">{{ letterSpacingLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-4 py-3.5 md:px-5 md:py-4 bg-card">
|
||||
<div class="mb-3 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<label :for="wordSpacingSourceId" class="settings-label">{{ t('settings.reader.ebook.wordSpacing') }}</label>
|
||||
<p class="settings-hint">
|
||||
{{ t('settings.reader.ebook.wordSpacingHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<select
|
||||
:id="wordSpacingSourceId"
|
||||
class="text-xs border border-border rounded-md px-2 py-1.5 bg-card text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
:value="effective.wordSpacing === null ? 'book' : 'custom'"
|
||||
@change="setWordSpacingSource"
|
||||
>
|
||||
<option value="book">
|
||||
{{ t('settings.reader.ebook.bookDefault') }}
|
||||
</option>
|
||||
<option value="custom">
|
||||
{{ t('settings.reader.ebook.custom') }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="effective.wordSpacing !== null" class="flex items-center gap-3">
|
||||
<input
|
||||
:id="wordSpacingInputId"
|
||||
type="range"
|
||||
:min="EPUB_WORD_SPACING_MIN"
|
||||
:max="EPUB_WORD_SPACING_MAX"
|
||||
step="0.05"
|
||||
class="w-full accent-primary cursor-pointer"
|
||||
:value="wordSpacingValue"
|
||||
:aria-label="t('settings.reader.ebook.wordSpacing')"
|
||||
:aria-valuetext="wordSpacingLabel"
|
||||
@input="setWordSpacing"
|
||||
/>
|
||||
<span class="settings-value w-16 text-right">{{ wordSpacingLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-4 py-3.5 md:px-5 md:py-4 bg-card">
|
||||
<div class="mb-3 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<label :for="textIndentSourceId" class="settings-label">{{ t('settings.reader.ebook.textIndent') }}</label>
|
||||
<p class="settings-hint">
|
||||
{{ t('settings.reader.ebook.textIndentHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<select
|
||||
:id="textIndentSourceId"
|
||||
class="text-xs border border-border rounded-md px-2 py-1.5 bg-card text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
:value="effective.textIndent === null ? 'book' : 'custom'"
|
||||
@change="setTextIndentSource"
|
||||
>
|
||||
<option value="book">
|
||||
{{ t('settings.reader.ebook.bookDefault') }}
|
||||
</option>
|
||||
<option value="custom">
|
||||
{{ t('settings.reader.ebook.custom') }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="effective.textIndent !== null" class="flex items-center gap-3">
|
||||
<input
|
||||
:id="textIndentInputId"
|
||||
type="range"
|
||||
:min="EPUB_TEXT_INDENT_MIN"
|
||||
:max="EPUB_TEXT_INDENT_MAX"
|
||||
step="0.25"
|
||||
class="w-full accent-primary cursor-pointer"
|
||||
:value="textIndentValue"
|
||||
:aria-label="t('settings.reader.ebook.textIndent')"
|
||||
:aria-valuetext="textIndentLabel"
|
||||
@input="setTextIndent"
|
||||
/>
|
||||
<span class="settings-value w-16 text-right">{{ textIndentLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Max inline size -->
|
||||
<div class="px-4 py-3.5 md:px-5 md:py-4 bg-card">
|
||||
<div class="mb-3">
|
||||
|
||||
@@ -15,6 +15,9 @@ const readerSettingsMock = vi.hoisted(() => ({
|
||||
fontSize: 16,
|
||||
lineHeight: 1.5,
|
||||
paragraphSpacing: 0,
|
||||
letterSpacing: null,
|
||||
wordSpacing: null,
|
||||
textIndent: null,
|
||||
maxColumnCount: 2,
|
||||
gap: 0.05,
|
||||
maxInlineSize: 720,
|
||||
@@ -57,6 +60,9 @@ describe('EbookSettings', () => {
|
||||
readerSettingsMock.effective.value.fontFamily = null
|
||||
readerSettingsMock.effective.value.fontWeight = 400
|
||||
readerSettingsMock.effective.value.fontStyle = 'normal'
|
||||
readerSettingsMock.effective.value.letterSpacing = null
|
||||
readerSettingsMock.effective.value.wordSpacing = null
|
||||
readerSettingsMock.effective.value.textIndent = null
|
||||
customFontsMock.cssFamilyAvailable.mockReturnValue(true)
|
||||
customFontsMock.fetchAllFonts.mockResolvedValue(undefined)
|
||||
})
|
||||
@@ -92,7 +98,37 @@ describe('EbookSettings', () => {
|
||||
expect(slider.attributes('aria-valuetext')).toBe('Book default')
|
||||
|
||||
await slider.setValue('1.2')
|
||||
expect(readerSettingsMock.update).toHaveBeenCalledWith({ paragraphSpacing: 1.2 })
|
||||
expect(readerSettingsMock.update).toHaveBeenCalledWith({
|
||||
paragraphSpacing: 1.2,
|
||||
})
|
||||
})
|
||||
|
||||
it('switches typography defaults between publisher and custom values', async () => {
|
||||
const wrapper = mount(EbookSettings, { props: { embedded: true } })
|
||||
await flushPromises()
|
||||
|
||||
const letterSource = wrapper.get('select[id^="ebook-letter-spacing-source-"]')
|
||||
expect((letterSource.element as HTMLSelectElement).value).toBe('book')
|
||||
|
||||
await letterSource.setValue('custom')
|
||||
expect(readerSettingsMock.update).toHaveBeenCalledWith({
|
||||
letterSpacing: 0,
|
||||
})
|
||||
|
||||
readerSettingsMock.effective.value.letterSpacing = 0.1
|
||||
const customized = mount(EbookSettings, { props: { embedded: true } })
|
||||
await flushPromises()
|
||||
const slider = customized.get('input[id^="ebook-letter-spacing-"]')
|
||||
expect(slider.attributes('aria-valuetext')).toBe('0.1 em')
|
||||
await slider.setValue('0.13')
|
||||
expect(readerSettingsMock.update).toHaveBeenCalledWith({
|
||||
letterSpacing: 0.13,
|
||||
})
|
||||
|
||||
await customized.get('select[id^="ebook-letter-spacing-source-"]').setValue('book')
|
||||
expect(readerSettingsMock.update).toHaveBeenCalledWith({
|
||||
letterSpacing: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('waits for both font catalogs before resetting an unavailable saved family', async () => {
|
||||
@@ -107,6 +143,10 @@ describe('EbookSettings', () => {
|
||||
mount(EbookSettings, { props: { embedded: true } })
|
||||
await flushPromises()
|
||||
|
||||
expect(readerSettingsMock.update).toHaveBeenCalledWith({ fontFamily: null, fontWeight: 700, fontStyle: 'italic' })
|
||||
expect(readerSettingsMock.update).toHaveBeenCalledWith({
|
||||
fontFamily: null,
|
||||
fontWeight: 700,
|
||||
fontStyle: 'italic',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1926,6 +1926,15 @@
|
||||
"paragraphSpacingHint": "Space between paragraphs. Book default preserves the publisher's spacing.",
|
||||
"paragraphSpacingDefault": "Book default",
|
||||
"paragraphSpacingValue": "{value} em",
|
||||
"letterSpacing": "Letter spacing",
|
||||
"letterSpacingHint": "Space between letters. Book default preserves the publisher's spacing.",
|
||||
"wordSpacing": "Word spacing",
|
||||
"wordSpacingHint": "Space between words. Book default preserves the publisher's spacing.",
|
||||
"textIndent": "First-line indent",
|
||||
"textIndentHint": "Indent at the start of ordinary paragraphs. Book default preserves the publisher's indentation.",
|
||||
"bookDefault": "Book default",
|
||||
"custom": "Custom",
|
||||
"emValue": "{value} em",
|
||||
"justify": "Justify text",
|
||||
"justifyHint": "Align text to both margins",
|
||||
"hyphenation": "Hyphenation",
|
||||
@@ -8657,6 +8666,12 @@
|
||||
"paragraphSpacing": "Paragraph spacing",
|
||||
"paragraphSpacingDefault": "Default",
|
||||
"paragraphSpacingValue": "{value} em",
|
||||
"letterSpacing": "Letter spacing",
|
||||
"wordSpacing": "Word spacing",
|
||||
"textIndent": "First-line indent",
|
||||
"bookDefault": "Book",
|
||||
"custom": "Custom",
|
||||
"emValue": "{value} em",
|
||||
"pageWidth": "Page width",
|
||||
"pageWidthNarrow": "Narrow",
|
||||
"pageWidthMedium": "Medium",
|
||||
|
||||
Reference in New Issue
Block a user