mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
feat(reader): add footer modes, wake lock, keyboard shortcuts, and go-to input
- Add tap-to-cycle footer modes (pages, time/session, chapter) along with elapsed reading time and chapter progress on the slider - Prevent screen dimming during reading using the Wake Lock API - Add keyboard shortcuts (t/s/b/f/Esc/?/Home/End) with an integrated help modal - Add go-to-location support (page number or percentage) and a resume-reading toast on book open
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { useFoliate, type RelocateDetail } from './epub/composables/useFoliate'
|
||||
import { useReaderProgress } from './shared/composables/useReaderProgress'
|
||||
import { useReadingSession } from './shared/composables/useReadingSession'
|
||||
@@ -8,11 +9,13 @@ import { useReaderState } from './epub/composables/useReaderState'
|
||||
import { useReaderSettings } from './shared/composables/useReaderSettings'
|
||||
import { useCustomFonts } from './epub/composables/useCustomFonts'
|
||||
import { useVisibility } from './shared/composables/useVisibility'
|
||||
import { useWakeLock } from './shared/composables/useWakeLock'
|
||||
import { useBookmarks } from './epub/composables/useBookmarks'
|
||||
import { useAnnotations } from './epub/composables/useAnnotations'
|
||||
import { useToc } from './epub/composables/useToc'
|
||||
import { useSearch, type FoliateView } from './epub/composables/useSearch'
|
||||
import { useReaderSelection } from './epub/composables/useReaderSelection'
|
||||
import { useReaderKeyboardShortcuts } from './epub/composables/useReaderKeyboardShortcuts'
|
||||
import ReaderHeader from './epub/components/ReaderHeader.vue'
|
||||
import ReaderFooter from './epub/components/ReaderFooter.vue'
|
||||
import ReaderSidebar from './epub/components/ReaderSidebar.vue'
|
||||
@@ -23,6 +26,7 @@ import NoteDialog from './epub/components/NoteDialog.vue'
|
||||
import DictionaryPopover from './epub/components/DictionaryPopover.vue'
|
||||
import TranslationPopover from './epub/components/TranslationPopover.vue'
|
||||
import TranslationSheet from './epub/components/TranslationSheet.vue'
|
||||
import KeyboardShortcutsModal from './epub/components/KeyboardShortcutsModal.vue'
|
||||
import PdfV4ReaderView from './pdf-v4/PdfV4ReaderView.vue'
|
||||
import CbzReaderView from './cbz/CbzReaderView.vue'
|
||||
import AudiobookReaderView from './audiobook/AudiobookReaderView.vue'
|
||||
@@ -76,17 +80,19 @@ const {
|
||||
|
||||
const customFonts = useCustomFonts()
|
||||
|
||||
const progress = useReaderProgress(bookId, fileId)
|
||||
const { cfi, chapterTitle, sectionIndex, totalSections, fraction, updateHeadsFeet } = progress
|
||||
|
||||
const { onActivity } = useReadingSession(fileId, () => ({
|
||||
const { onActivity, elapsedMinutes } = useReadingSession(fileId, () => ({
|
||||
percentage: progress.percentage.value,
|
||||
cfi: progress.cfi.value,
|
||||
pageNumber: progress.pageNumber.value,
|
||||
}))
|
||||
|
||||
const progress = useReaderProgress(bookId, fileId, elapsedMinutes)
|
||||
const { cfi, chapterTitle, sectionIndex, totalSections, fraction, locationTotal, footerMode, cycleFooterMode, updateHeadsFeet } = progress
|
||||
|
||||
const visibility = useVisibility()
|
||||
const { headerVisible, footerVisible, handleMiddleTap, showHeader, showFooter, setVisibilityLock } = visibility
|
||||
const { headerVisible, footerVisible, handleMiddleTap, setVisibilityLock } = visibility
|
||||
|
||||
useWakeLock()
|
||||
|
||||
const bookmarks = useBookmarks()
|
||||
const annotations = useAnnotations()
|
||||
@@ -99,6 +105,51 @@ const { results: searchResults, isSearching, search: doSearch, clear: clearSearc
|
||||
|
||||
const selection = useReaderSelection()
|
||||
|
||||
function closeAnyPanel() {
|
||||
if (showSearch.value) {
|
||||
closeSearch()
|
||||
} else if (showSidebar.value) {
|
||||
showSidebar.value = false
|
||||
} else if (showSettings.value) {
|
||||
showSettings.value = false
|
||||
} else {
|
||||
handleMiddleTap()
|
||||
}
|
||||
}
|
||||
|
||||
const { showHelpModal } = useReaderKeyboardShortcuts({
|
||||
toggleSidebar: () => {
|
||||
showSidebar.value = !showSidebar.value
|
||||
},
|
||||
toggleSearch: () => {
|
||||
showSearch.value = !showSearch.value
|
||||
},
|
||||
toggleBookmark: () => {
|
||||
bookmarks.toggle(bookId, cfi.value ?? '', chapterTitle.value)
|
||||
},
|
||||
toggleFullscreen,
|
||||
cycleFooterMode,
|
||||
closePanel: closeAnyPanel,
|
||||
goToStart: () => goToFraction(0),
|
||||
goToEnd: () => goToFraction(1),
|
||||
})
|
||||
|
||||
function toggleHelpModal() {
|
||||
showHelpModal.value = !showHelpModal.value
|
||||
}
|
||||
|
||||
const chapterStartFraction = computed(() => {
|
||||
const fracs = sectionFractions.value
|
||||
const idx = sectionIndex.value
|
||||
return fracs[idx] ?? 0
|
||||
})
|
||||
|
||||
const chapterEndFraction = computed(() => {
|
||||
const fracs = sectionFractions.value
|
||||
const idx = sectionIndex.value
|
||||
return fracs[idx + 1] ?? 1
|
||||
})
|
||||
|
||||
const showDictionary = ref(false)
|
||||
const dictionaryWord = ref('')
|
||||
const dictionaryPosition = ref({ x: 0, y: 0, showBelow: false })
|
||||
@@ -188,18 +239,21 @@ onMounted(async () => {
|
||||
|
||||
await bookSettings.load()
|
||||
const effective = bookSettings.effective.value as EpubReaderSettings
|
||||
if (effective.footerDisplayMode !== undefined) {
|
||||
footerMode.value = effective.footerDisplayMode
|
||||
}
|
||||
if (effective.overrideBookFormatting) {
|
||||
shouldApplyStyles.value = true
|
||||
seedState(effective)
|
||||
} else if (bookSettings.isCustomized.value) {
|
||||
// Only apply the per-book delta - don't bleed global defaults like dark theme into the book
|
||||
shouldApplyStyles.value = true
|
||||
seedState(bookSettings.bookDelta.value as Partial<ReaderState>)
|
||||
} else {
|
||||
shouldApplyStyles.value = false
|
||||
}
|
||||
|
||||
await open(bookId, fileId, fileFormat, progress.cfi.value, progress.percentage.value > 0 ? progress.percentage.value / 100 : undefined)
|
||||
const hadProgress = progress.percentage.value > 0
|
||||
await open(bookId, fileId, fileFormat, progress.cfi.value, hadProgress ? progress.percentage.value / 100 : undefined)
|
||||
setChapters(getChapters())
|
||||
sectionFractions.value = getSectionFractions()
|
||||
await bookmarks.load(bookId)
|
||||
@@ -207,6 +261,12 @@ onMounted(async () => {
|
||||
if (annotations.annotations.value.length > 0) {
|
||||
addAnnotations(annotations.annotations.value.map((a) => ({ cfi: a.cfi, color: a.color, style: a.style })))
|
||||
}
|
||||
|
||||
if (hadProgress) {
|
||||
const pct = Math.round(progress.percentage.value)
|
||||
const label = chapterTitle.value || `Chapter ${sectionIndex.value + 1}`
|
||||
toast.info(`Resumed at ${pct}% - ${label}`, { duration: 2500 })
|
||||
}
|
||||
})
|
||||
|
||||
const epubSetters: Record<string, (v: unknown) => void> = {
|
||||
@@ -250,6 +310,17 @@ function toggleFullscreen() {
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => footerMode.value,
|
||||
(mode) => {
|
||||
bookSettings.updateBookSettings({ footerDisplayMode: mode })
|
||||
const renderer = getRenderer()
|
||||
if (renderer) {
|
||||
updateHeadsFeet(renderer, activeMode.value)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function setSettingsOpen(open: boolean) {
|
||||
showSettings.value = open
|
||||
}
|
||||
@@ -346,12 +417,11 @@ function closeSearch() {
|
||||
shouldApplyStyles ? { background: activeMode.bg, colorScheme: isDark ? 'dark' : 'light' } : { background: '#ffffff', colorScheme: 'light' }
|
||||
"
|
||||
>
|
||||
<div class="absolute top-0 left-0 right-0 z-40 h-20 pointer-events-auto" @mouseenter="showHeader()" />
|
||||
|
||||
<ReaderHeader
|
||||
:chapterTitle="chapterTitle"
|
||||
:isBookmarked="bookmarks.isCurrentCfiBookmarked.value"
|
||||
:settings-open="showSettings"
|
||||
:footerMode="footerMode"
|
||||
class="transition-all duration-300"
|
||||
:class="headerVisible ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-full pointer-events-none'"
|
||||
@back="router.back()"
|
||||
@@ -360,6 +430,8 @@ function closeSearch() {
|
||||
@toggleBookmark="bookmarks.toggle(bookId, cfi ?? '', chapterTitle)"
|
||||
@update:settings-open="setSettingsOpen"
|
||||
@toggleFullscreen="toggleFullscreen"
|
||||
@toggleHelp="toggleHelpModal"
|
||||
@cycleFooterMode="cycleFooterMode"
|
||||
>
|
||||
<template #settingsPanel>
|
||||
<ReaderSettingsPanel :state="state" :customFonts="customFonts" @update="applyUpdate" />
|
||||
@@ -395,6 +467,9 @@ function closeSearch() {
|
||||
:sectionIndex="sectionIndex"
|
||||
:totalSections="totalSections"
|
||||
:sectionFractions="sectionFractions"
|
||||
:chapterStartFraction="chapterStartFraction"
|
||||
:chapterEndFraction="chapterEndFraction"
|
||||
:locationTotal="locationTotal"
|
||||
class="transition-all duration-300"
|
||||
:class="footerVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-full pointer-events-none'"
|
||||
@prevSection="goToSection(sectionIndex - 1)"
|
||||
@@ -402,8 +477,6 @@ function closeSearch() {
|
||||
@seek="goToFraction($event)"
|
||||
/>
|
||||
|
||||
<div class="absolute bottom-0 left-0 right-0 z-40 h-20 pointer-events-auto" @mouseenter="showFooter()" />
|
||||
|
||||
<ReaderSidebar
|
||||
v-if="showSidebar"
|
||||
:chapters="chapters"
|
||||
@@ -475,6 +548,8 @@ function closeSearch() {
|
||||
/>
|
||||
|
||||
<TranslationSheet v-if="showTranslation && isMobile" :text="translationText" @close="showTranslation = false" />
|
||||
|
||||
<KeyboardShortcutsModal v-if="showHelpModal" @close="showHelpModal = false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -40,13 +40,13 @@ const TWO_PAGE_BREAKPOINT = 900
|
||||
const props = defineProps<{ bookId: number; fileId: number }>()
|
||||
const router = useRouter()
|
||||
|
||||
const progress = useReaderProgress(props.bookId, props.fileId)
|
||||
const { headerVisible, footerVisible, handleMiddleTap, showHeader, showFooter, setVisibilityLock } = useVisibility()
|
||||
|
||||
const { onActivity } = useReadingSession(props.fileId, () => ({
|
||||
const { onActivity, elapsedMinutes } = useReadingSession(props.fileId, () => ({
|
||||
percentage: progress.percentage.value,
|
||||
pageNumber: progress.pageNumber.value,
|
||||
}))
|
||||
const progress = useReaderProgress(props.bookId, props.fileId, elapsedMinutes)
|
||||
const { pageCount, bookTitle, loading, error, pageUrl, load } = useCbz(props.fileId, props.bookId)
|
||||
const { fitMode, viewMode, scrollMode, direction, spreadAlignment, forceTwoPage, widePageSingletonMode, bgColor, bgValue, imgFitClass } =
|
||||
useCbzSettings()
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
import { Keyboard, X } from 'lucide-vue-next'
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const shortcutGroups = [
|
||||
{
|
||||
title: 'Panels',
|
||||
shortcuts: [
|
||||
{ key: 't', description: 'Toggle table of contents' },
|
||||
{ key: 's', description: 'Toggle search' },
|
||||
{ key: '?', description: 'Show/hide this help' },
|
||||
{ key: 'Esc', description: 'Close panel' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Reading',
|
||||
shortcuts: [
|
||||
{ key: '← →', description: 'Previous/next page' },
|
||||
{ key: 'Home', description: 'Go to start of book' },
|
||||
{ key: 'End', description: 'Go to end of book' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Actions',
|
||||
shortcuts: [
|
||||
{ key: 'b', description: 'Toggle bookmark' },
|
||||
{ key: 'f', description: 'Toggle fullscreen' },
|
||||
{ key: 'm', description: 'Cycle footer info mode' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if ((e.target as HTMLElement).dataset.backdrop !== undefined) {
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('keydown', onKeyDown, true))
|
||||
onUnmounted(() => document.removeEventListener('keydown', onKeyDown, true))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div data-backdrop class="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 px-4 backdrop-blur-sm" @click="handleBackdropClick">
|
||||
<div class="w-full max-w-md rounded-xl border border-border/80 bg-card shadow-2xl">
|
||||
<div class="flex items-center justify-between border-b border-border/80 bg-gradient-to-br from-muted/75 via-card to-muted/40 px-4 py-3">
|
||||
<div class="inline-flex items-center gap-2">
|
||||
<Keyboard :size="14" class="text-primary" />
|
||||
<h2 class="text-sm font-semibold text-foreground">Keyboard Shortcuts</h2>
|
||||
</div>
|
||||
|
||||
<button class="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" @click="emit('close')">
|
||||
<X :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-2.5 px-4 py-3 sm:grid-cols-2">
|
||||
<section v-for="group in shortcutGroups" :key="group.title" class="rounded-lg border border-border/70 bg-background/40 p-2.5">
|
||||
<div class="mb-1.5">
|
||||
<h3 class="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">{{ group.title }}</h3>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<div v-for="shortcut in group.shortcuts" :key="shortcut.key" class="flex items-center gap-2 rounded-md px-1 py-0.5 text-sm">
|
||||
<kbd
|
||||
class="inline-flex h-7 min-w-12 items-center justify-center rounded-md border border-border bg-card px-2.5 text-xs font-bold tracking-wide text-foreground shadow-[inset_0_-1px_0_0_hsl(var(--border))]"
|
||||
>
|
||||
{{ shortcut.key }}
|
||||
</kbd>
|
||||
<span class="text-[12px] leading-5 text-muted-foreground">{{ shortcut.description }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,12 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-vue-next'
|
||||
import { ref } from 'vue'
|
||||
import { ChevronLeft, ChevronRight, ChevronsUpDown } from 'lucide-vue-next'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
fraction: number
|
||||
sectionIndex: number
|
||||
totalSections: number
|
||||
sectionFractions: number[]
|
||||
chapterStartFraction: number
|
||||
chapterEndFraction: number
|
||||
locationTotal: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -15,19 +19,62 @@ const emit = defineEmits<{
|
||||
seek: [fraction: number]
|
||||
}>()
|
||||
|
||||
const showGoToInput = ref(false)
|
||||
const goToValue = ref('')
|
||||
const goToInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
function onSeek(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
emit('seek', Number(input.value))
|
||||
}
|
||||
|
||||
function handlePercentageClick() {
|
||||
showGoToInput.value = true
|
||||
goToValue.value = ''
|
||||
setTimeout(() => goToInputRef.value?.focus(), 0)
|
||||
}
|
||||
|
||||
function handleGoToSubmit() {
|
||||
const raw = goToValue.value.trim()
|
||||
if (!raw) {
|
||||
showGoToInput.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (raw.toLowerCase().startsWith('p') && props.locationTotal > 0) {
|
||||
const page = parseInt(raw.slice(1), 10)
|
||||
if (!isNaN(page) && page >= 1 && page <= props.locationTotal) {
|
||||
emit('seek', (page - 1) / props.locationTotal)
|
||||
}
|
||||
} else {
|
||||
const pct = parseFloat(raw)
|
||||
if (!isNaN(pct) && pct >= 0 && pct <= 100) {
|
||||
emit('seek', pct / 100)
|
||||
}
|
||||
}
|
||||
|
||||
showGoToInput.value = false
|
||||
}
|
||||
|
||||
function handleGoToKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
showGoToInput.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleGoToBlur() {
|
||||
showGoToInput.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer
|
||||
class="fixed bottom-0 left-0 right-0 h-12 sm:h-14 z-50 flex items-center gap-2 px-2 sm:gap-3 sm:px-4 bg-background/90 backdrop-blur-md border-t border-border"
|
||||
class="fixed bottom-0 left-0 right-0 h-10 sm:h-11 z-50 flex items-center gap-2 px-2 sm:gap-3 sm:px-4 bg-background/90 backdrop-blur-md border-t border-border"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button class="viewer-btn" :disabled="sectionIndex === 0" @click="emit('prevSection')">
|
||||
<button type="button" aria-label="Previous section" class="viewer-btn" :disabled="sectionIndex === 0" @click="emit('prevSection')">
|
||||
<ChevronLeft :size="18" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
@@ -35,6 +82,17 @@ function onSeek(e: Event) {
|
||||
</Tooltip>
|
||||
|
||||
<div class="relative flex-1 flex items-center h-6">
|
||||
<!-- Chapter highlight segment -->
|
||||
<div
|
||||
v-if="chapterStartFraction < chapterEndFraction"
|
||||
class="absolute top-1/2 -translate-y-1/2 h-2.5 rounded-full pointer-events-none"
|
||||
:style="{
|
||||
left: `${chapterStartFraction * 100}%`,
|
||||
width: `${(chapterEndFraction - chapterStartFraction) * 100}%`,
|
||||
background: 'color-mix(in oklch, var(--primary) 25%, transparent)',
|
||||
}"
|
||||
/>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
@@ -42,7 +100,7 @@ function onSeek(e: Event) {
|
||||
step="0.001"
|
||||
:value="fraction"
|
||||
@input="onSeek"
|
||||
class="w-full h-1 rounded-full appearance-none cursor-pointer"
|
||||
class="w-full h-1 rounded-full appearance-none cursor-pointer relative z-10"
|
||||
:style="{
|
||||
accentColor: 'var(--primary)',
|
||||
background: `linear-gradient(to right, var(--primary) ${fraction * 100}%, var(--border) ${fraction * 100}%)`,
|
||||
@@ -51,17 +109,51 @@ function onSeek(e: Event) {
|
||||
<template v-for="(sf, idx) in sectionFractions" :key="idx">
|
||||
<div
|
||||
v-if="sf > 0 && sf < 1"
|
||||
class="absolute top-1/2 -translate-y-1/2 w-px h-3 pointer-events-none"
|
||||
class="absolute top-1/2 -translate-y-1/2 w-px h-3 pointer-events-none z-10"
|
||||
:style="{ left: `${sf * 100}%`, background: 'var(--muted-foreground)' }"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<span class="text-xs tabular-nums shrink-0 min-w-[3rem] text-center text-muted-foreground"> {{ Math.round(fraction * 100) }}% </span>
|
||||
<template v-if="showGoToInput">
|
||||
<input
|
||||
ref="goToInputRef"
|
||||
v-model="goToValue"
|
||||
type="text"
|
||||
placeholder="45 or p123"
|
||||
class="w-24 h-8 text-sm tabular-nums text-center bg-muted border border-border rounded px-2 py-1 text-foreground outline-none focus:ring-1 focus:ring-primary"
|
||||
@keydown.enter="handleGoToSubmit"
|
||||
@keydown="handleGoToKeydown"
|
||||
@blur="handleGoToBlur"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Jump to location"
|
||||
class="h-8 px-2 rounded-md border border-transparent hover:border-border text-xs tabular-nums shrink-0 min-w-18 text-center text-muted-foreground hover:text-foreground transition-colors inline-flex items-center justify-center gap-1"
|
||||
@click="handlePercentageClick"
|
||||
>
|
||||
<span class="text-[11px] uppercase tracking-wide">Go</span>
|
||||
<span>{{ Math.round(fraction * 100) }}%</span>
|
||||
<ChevronsUpDown :size="12" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Jump to % or page (e.g. 45 or p123)</TooltipContent>
|
||||
</Tooltip>
|
||||
</template>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button class="viewer-btn" :disabled="totalSections > 0 && sectionIndex >= totalSections - 1" @click="emit('nextSection')">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Next section"
|
||||
class="viewer-btn"
|
||||
:disabled="totalSections > 0 && sectionIndex >= totalSections - 1"
|
||||
@click="emit('nextSection')"
|
||||
>
|
||||
<ChevronRight :size="18" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { ArrowLeft, BookOpen, Bookmark, BookmarkCheck, Maximize, Minimize, Search, Settings } from 'lucide-vue-next'
|
||||
import {
|
||||
ArrowLeft,
|
||||
BookOpen,
|
||||
BookText,
|
||||
Bookmark,
|
||||
BookmarkCheck,
|
||||
CircleHelp,
|
||||
Clock3,
|
||||
FileText,
|
||||
Maximize,
|
||||
Minimize,
|
||||
Search,
|
||||
Settings,
|
||||
} from 'lucide-vue-next'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
|
||||
@@ -8,6 +21,7 @@ const props = defineProps<{
|
||||
chapterTitle: string
|
||||
isBookmarked: boolean
|
||||
settingsOpen: boolean
|
||||
footerMode: 0 | 1 | 2
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -17,6 +31,8 @@ const emit = defineEmits<{
|
||||
toggleBookmark: []
|
||||
'update:settingsOpen': [open: boolean]
|
||||
toggleFullscreen: []
|
||||
toggleHelp: []
|
||||
cycleFooterMode: []
|
||||
}>()
|
||||
|
||||
const isFullscreen = ref(false)
|
||||
@@ -29,19 +45,31 @@ function onSettingsOpenChange(open: boolean) {
|
||||
emit('update:settingsOpen', open)
|
||||
}
|
||||
|
||||
function getFooterModeIcon(mode: 0 | 1 | 2) {
|
||||
if (mode === 0) return FileText
|
||||
if (mode === 1) return Clock3
|
||||
return BookText
|
||||
}
|
||||
|
||||
function getFooterModeTooltip(mode: 0 | 1 | 2): string {
|
||||
if (mode === 0) return 'Footer info: page + progress'
|
||||
if (mode === 1) return 'Footer info: reading session + time left'
|
||||
return 'Footer info: chapter + chapter time left'
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('fullscreenchange', onFullscreenChange))
|
||||
onUnmounted(() => document.removeEventListener('fullscreenchange', onFullscreenChange))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header
|
||||
class="fixed top-0 left-0 right-0 h-11 sm:h-12 z-50 flex items-center px-2 sm:px-3 gap-1 bg-background/90 backdrop-blur-md border-b border-border"
|
||||
class="fixed top-0 left-0 right-0 h-10 sm:h-11 z-50 flex items-center px-2 sm:px-3 gap-1 bg-background/90 backdrop-blur-md border-b border-border"
|
||||
>
|
||||
<!-- Left button group -->
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button class="viewer-btn" @click="emit('back')">
|
||||
<button class="viewer-btn" aria-label="Go back" @click="emit('back')">
|
||||
<ArrowLeft :size="18" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
@@ -52,7 +80,7 @@ onUnmounted(() => document.removeEventListener('fullscreenchange', onFullscreenC
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button class="viewer-btn" @click="emit('toggleSidebar')">
|
||||
<button class="viewer-btn" aria-label="Table of contents" @click="emit('toggleSidebar')">
|
||||
<BookOpen :size="18" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
@@ -61,7 +89,7 @@ onUnmounted(() => document.removeEventListener('fullscreenchange', onFullscreenC
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button class="viewer-btn" :class="isBookmarked ? '!text-primary' : ''" @click="emit('toggleBookmark')">
|
||||
<button class="viewer-btn" :class="isBookmarked ? '!text-primary' : ''" aria-label="Toggle bookmark" @click="emit('toggleBookmark')">
|
||||
<BookmarkCheck v-if="isBookmarked" :size="18" />
|
||||
<Bookmark v-else :size="18" />
|
||||
</button>
|
||||
@@ -70,18 +98,16 @@ onUnmounted(() => document.removeEventListener('fullscreenchange', onFullscreenC
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<!-- Title: absolutely centered on mobile, flex-1 on sm+ -->
|
||||
<div
|
||||
class="absolute inset-x-0 top-0 h-11 sm:h-12 flex items-center justify-center pointer-events-none sm:static sm:flex-1 sm:min-w-0 sm:h-auto sm:px-3 sm:pointer-events-auto"
|
||||
>
|
||||
<p class="text-sm font-serif font-medium truncate text-center text-muted-foreground max-w-[40vw] sm:max-w-none">{{ chapterTitle }}</p>
|
||||
<!-- Title: desktop/tablet only to avoid overlap on narrow mobile headers -->
|
||||
<div class="hidden sm:absolute sm:inset-x-0 sm:top-0 sm:h-12 sm:flex sm:items-center sm:justify-center sm:pointer-events-none">
|
||||
<p class="text-sm font-serif font-medium truncate text-center text-muted-foreground max-w-[40vw]">{{ chapterTitle }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Right button group -->
|
||||
<div class="flex items-center gap-1 shrink-0 ml-auto">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button class="viewer-btn" @click="emit('toggleSearch')">
|
||||
<button class="viewer-btn" aria-label="Search" @click="emit('toggleSearch')">
|
||||
<Search :size="18" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
@@ -90,7 +116,29 @@ onUnmounted(() => document.removeEventListener('fullscreenchange', onFullscreenC
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button class="viewer-btn" @click="emit('toggleFullscreen')">
|
||||
<button class="viewer-btn hidden sm:flex" aria-label="Cycle footer info mode" @click="emit('cycleFooterMode')">
|
||||
<component :is="getFooterModeIcon(props.footerMode)" :size="16" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ getFooterModeTooltip(props.footerMode) }}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button class="viewer-btn hidden sm:flex" aria-label="Keyboard shortcuts" @click="emit('toggleHelp')">
|
||||
<CircleHelp :size="18" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Keyboard shortcuts (?)</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
class="viewer-btn hidden sm:flex"
|
||||
:aria-label="isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'"
|
||||
@click="emit('toggleFullscreen')"
|
||||
>
|
||||
<Minimize v-if="isFullscreen" :size="18" />
|
||||
<Maximize v-else :size="18" />
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import KeyboardShortcutsModal from '../KeyboardShortcutsModal.vue'
|
||||
|
||||
const globalStubs = {
|
||||
stubs: {
|
||||
X: { template: '<span>X</span>' },
|
||||
},
|
||||
}
|
||||
|
||||
describe('KeyboardShortcutsModal', () => {
|
||||
it('renders all shortcut entries', () => {
|
||||
const wrapper = mount(KeyboardShortcutsModal, { global: globalStubs })
|
||||
|
||||
const kbds = wrapper.findAll('kbd')
|
||||
expect(kbds.length).toBeGreaterThanOrEqual(8)
|
||||
|
||||
const text = wrapper.text()
|
||||
expect(text).toContain('Toggle table of contents')
|
||||
expect(text).toContain('Toggle search')
|
||||
expect(text).toContain('Toggle bookmark')
|
||||
expect(text).toContain('Toggle fullscreen')
|
||||
expect(text).toContain('Cycle footer info mode')
|
||||
expect(text).toContain('Show/hide this help')
|
||||
expect(text).toContain('Go to start of book')
|
||||
expect(text).toContain('Go to end of book')
|
||||
expect(text).toContain('Close panel')
|
||||
expect(text).toContain('Previous/next page')
|
||||
})
|
||||
|
||||
it('renders the title', () => {
|
||||
const wrapper = mount(KeyboardShortcutsModal, { global: globalStubs })
|
||||
expect(wrapper.text()).toContain('Keyboard Shortcuts')
|
||||
})
|
||||
|
||||
it('emits close when close button is clicked', async () => {
|
||||
const wrapper = mount(KeyboardShortcutsModal, { global: globalStubs })
|
||||
|
||||
const closeButton = wrapper.find('button')
|
||||
await closeButton.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('close')?.length).toBe(1)
|
||||
})
|
||||
|
||||
it('emits close when Escape is pressed', async () => {
|
||||
const wrapper = mount(KeyboardShortcutsModal, { global: globalStubs })
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.emitted('close')?.length).toBe(1)
|
||||
})
|
||||
|
||||
it('emits close when backdrop is clicked', async () => {
|
||||
const wrapper = mount(KeyboardShortcutsModal, { global: globalStubs })
|
||||
|
||||
const backdrop = wrapper.find('[data-backdrop]')
|
||||
await backdrop.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('close')?.length).toBe(1)
|
||||
})
|
||||
|
||||
it('does not emit close when modal content is clicked', async () => {
|
||||
const wrapper = mount(KeyboardShortcutsModal, { global: globalStubs })
|
||||
|
||||
const content = wrapper.find('[class*="rounded-lg"]')
|
||||
await content.trigger('click')
|
||||
|
||||
// The click handler checks data-backdrop, so clicking inside should not emit close
|
||||
expect(wrapper.emitted('close')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -10,50 +10,55 @@ const globalStubs = {
|
||||
},
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
fraction: 0.33,
|
||||
sectionIndex: 2,
|
||||
totalSections: 5,
|
||||
sectionFractions: [0, 0.2, 0.4, 0.6, 0.8, 1],
|
||||
chapterStartFraction: 0.2,
|
||||
chapterEndFraction: 0.4,
|
||||
locationTotal: 100,
|
||||
}
|
||||
|
||||
describe('ReaderFooter', () => {
|
||||
it('disables previous button on first section and next button on last section', async () => {
|
||||
const first = mount(ReaderFooter, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
fraction: 0.1,
|
||||
sectionIndex: 0,
|
||||
totalSections: 5,
|
||||
sectionFractions: [],
|
||||
},
|
||||
global: globalStubs,
|
||||
})
|
||||
|
||||
const firstButtons = first.findAll('button')
|
||||
expect(firstButtons[0]?.attributes('disabled')).toBeDefined()
|
||||
expect(firstButtons[1]?.attributes('disabled')).toBeUndefined()
|
||||
const firstPrevButton = first.get('button[aria-label="Previous section"]')
|
||||
const firstNextButton = first.get('button[aria-label="Next section"]')
|
||||
expect(firstPrevButton.attributes('disabled')).toBeDefined()
|
||||
expect(firstNextButton.attributes('disabled')).toBeUndefined()
|
||||
|
||||
const last = mount(ReaderFooter, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
fraction: 0.9,
|
||||
sectionIndex: 4,
|
||||
totalSections: 5,
|
||||
sectionFractions: [],
|
||||
},
|
||||
global: globalStubs,
|
||||
})
|
||||
|
||||
const lastButtons = last.findAll('button')
|
||||
expect(lastButtons[1]?.attributes('disabled')).toBeDefined()
|
||||
const lastNextButton = last.get('button[aria-label="Next section"]')
|
||||
expect(lastNextButton.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('emits prev/next section and seek events', async () => {
|
||||
const wrapper = mount(ReaderFooter, {
|
||||
props: {
|
||||
fraction: 0.33,
|
||||
sectionIndex: 2,
|
||||
totalSections: 5,
|
||||
sectionFractions: [0.2, 0.5, 0.8],
|
||||
},
|
||||
props: defaultProps,
|
||||
global: globalStubs,
|
||||
})
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
await buttons[0]!.trigger('click')
|
||||
await buttons[1]!.trigger('click')
|
||||
await wrapper.get('button[aria-label="Previous section"]').trigger('click')
|
||||
await wrapper.get('button[aria-label="Next section"]').trigger('click')
|
||||
|
||||
const slider = wrapper.get('input[type="range"]')
|
||||
await slider.setValue('0.42')
|
||||
@@ -62,4 +67,108 @@ describe('ReaderFooter', () => {
|
||||
expect(wrapper.emitted('nextSection')?.length).toBe(1)
|
||||
expect(wrapper.emitted('seek')?.[0]).toEqual([0.42])
|
||||
})
|
||||
|
||||
it('renders the chapter progress segment', () => {
|
||||
const wrapper = mount(ReaderFooter, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
chapterStartFraction: 0.2,
|
||||
chapterEndFraction: 0.4,
|
||||
},
|
||||
global: globalStubs,
|
||||
})
|
||||
|
||||
const segment = wrapper.find('[class*="pointer-events-none"][class*="rounded-full"]')
|
||||
expect(segment.exists()).toBe(true)
|
||||
expect(segment.attributes('style')).toContain('left: 20%')
|
||||
expect(segment.attributes('style')).toContain('width: 20%')
|
||||
})
|
||||
|
||||
it('does not render chapter segment when start equals end', () => {
|
||||
const wrapper = mount(ReaderFooter, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
chapterStartFraction: 0.5,
|
||||
chapterEndFraction: 0.5,
|
||||
},
|
||||
global: globalStubs,
|
||||
})
|
||||
|
||||
const segments = wrapper.findAll('[class*="pointer-events-none"][class*="rounded-full"]')
|
||||
expect(segments.length).toBe(0)
|
||||
})
|
||||
|
||||
it('shows percentage display by default', () => {
|
||||
const wrapper = mount(ReaderFooter, {
|
||||
props: defaultProps,
|
||||
global: globalStubs,
|
||||
})
|
||||
|
||||
const pctDisplay = wrapper.find('button[aria-label="Jump to location"]')
|
||||
expect(pctDisplay.exists()).toBe(true)
|
||||
expect(pctDisplay.text()).toContain('33%')
|
||||
})
|
||||
|
||||
it('shows go-to input when percentage is clicked', async () => {
|
||||
const wrapper = mount(ReaderFooter, {
|
||||
props: defaultProps,
|
||||
global: globalStubs,
|
||||
})
|
||||
|
||||
const pctDisplay = wrapper.find('button[aria-label="Jump to location"]')
|
||||
await pctDisplay.trigger('click')
|
||||
|
||||
const input = wrapper.find('input[type="text"]')
|
||||
expect(input.exists()).toBe(true)
|
||||
expect(input.attributes('placeholder')).toBe('45 or p123')
|
||||
})
|
||||
|
||||
it('emits seek with correct fraction when percentage is entered', async () => {
|
||||
const wrapper = mount(ReaderFooter, {
|
||||
props: defaultProps,
|
||||
global: globalStubs,
|
||||
})
|
||||
|
||||
const pctDisplay = wrapper.find('button[aria-label="Jump to location"]')
|
||||
await pctDisplay.trigger('click')
|
||||
|
||||
const input = wrapper.find('input[type="text"]')
|
||||
await input.setValue('50')
|
||||
await input.trigger('keydown.enter')
|
||||
|
||||
expect(wrapper.emitted('seek')?.[0]).toEqual([0.5])
|
||||
})
|
||||
|
||||
it('emits seek with correct fraction for page input', async () => {
|
||||
const wrapper = mount(ReaderFooter, {
|
||||
props: { ...defaultProps, locationTotal: 200 },
|
||||
global: globalStubs,
|
||||
})
|
||||
|
||||
const pctDisplay = wrapper.find('button[aria-label="Jump to location"]')
|
||||
await pctDisplay.trigger('click')
|
||||
|
||||
const input = wrapper.find('input[type="text"]')
|
||||
await input.setValue('p101')
|
||||
await input.trigger('keydown.enter')
|
||||
|
||||
expect(wrapper.emitted('seek')?.[0]).toEqual([100 / 200])
|
||||
})
|
||||
|
||||
it('closes go-to input on Escape', async () => {
|
||||
const wrapper = mount(ReaderFooter, {
|
||||
props: defaultProps,
|
||||
global: globalStubs,
|
||||
})
|
||||
|
||||
const pctDisplay = wrapper.find('button[aria-label="Jump to location"]')
|
||||
await pctDisplay.trigger('click')
|
||||
|
||||
expect(wrapper.find('input[type="text"]').exists()).toBe(true)
|
||||
|
||||
const input = wrapper.find('input[type="text"]')
|
||||
await input.trigger('keydown', { key: 'Escape' })
|
||||
|
||||
expect(wrapper.find('input[type="text"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -27,21 +27,25 @@ describe('ReaderHeader', () => {
|
||||
chapterTitle: 'Chapter 4',
|
||||
isBookmarked: false,
|
||||
settingsOpen: false,
|
||||
footerMode: 0,
|
||||
},
|
||||
global,
|
||||
})
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
await buttons[0]!.trigger('click')
|
||||
await buttons[1]!.trigger('click')
|
||||
await buttons[2]!.trigger('click')
|
||||
await buttons[3]!.trigger('click')
|
||||
await buttons[4]!.trigger('click')
|
||||
await wrapper.get('button[aria-label="Go back"]').trigger('click')
|
||||
await wrapper.get('button[aria-label="Table of contents"]').trigger('click')
|
||||
await wrapper.get('button[aria-label="Toggle bookmark"]').trigger('click')
|
||||
await wrapper.get('button[aria-label="Search"]').trigger('click')
|
||||
await wrapper.get('button[aria-label="Cycle footer info mode"]').trigger('click')
|
||||
await wrapper.get('button[aria-label="Keyboard shortcuts"]').trigger('click')
|
||||
await wrapper.get('button[aria-label="Enter fullscreen"]').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('back')?.length).toBe(1)
|
||||
expect(wrapper.emitted('toggleSidebar')?.length).toBe(1)
|
||||
expect(wrapper.emitted('toggleBookmark')?.length).toBe(1)
|
||||
expect(wrapper.emitted('toggleSearch')?.length).toBe(1)
|
||||
expect(wrapper.emitted('cycleFooterMode')?.length).toBe(1)
|
||||
expect(wrapper.emitted('toggleHelp')?.length).toBe(1)
|
||||
expect(wrapper.emitted('toggleFullscreen')?.length).toBe(1)
|
||||
})
|
||||
|
||||
@@ -51,6 +55,7 @@ describe('ReaderHeader', () => {
|
||||
chapterTitle: 'Chapter 4',
|
||||
isBookmarked: true,
|
||||
settingsOpen: true,
|
||||
footerMode: 1,
|
||||
},
|
||||
global,
|
||||
})
|
||||
|
||||
@@ -568,6 +568,30 @@ describe('useDictionary', () => {
|
||||
expect(result!.provider).toBe('free-dictionary')
|
||||
})
|
||||
|
||||
it('lang normalization: English language name maps to en', async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockOk(makeFreeDictResponse()))
|
||||
const { lookup } = await load()
|
||||
const result = await lookup('hello', 'English')
|
||||
expect(result!.provider).toBe('free-dictionary')
|
||||
expect(fetchMock.mock.calls[0]![0]).toContain('api.dictionaryapi.dev')
|
||||
})
|
||||
|
||||
it('lang normalization: ISO-639-2 eng maps to en', async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockOk(makeFreeDictResponse()))
|
||||
const { lookup } = await load()
|
||||
const result = await lookup('hello', 'eng')
|
||||
expect(result!.provider).toBe('free-dictionary')
|
||||
expect(fetchMock.mock.calls[0]![0]).toContain('api.dictionaryapi.dev')
|
||||
})
|
||||
|
||||
it('lang normalization: unknown long language labels fall back to en', async () => {
|
||||
fetchMock.mockResolvedValueOnce(mockStatus(404)).mockResolvedValueOnce(mockOk(makeWiktionaryResponse()))
|
||||
const { lookup } = await load()
|
||||
const result = await lookup('hello', 'some-unknown-language')
|
||||
expect(result!.provider).toBe('wiktionary')
|
||||
expect(fetchMock.mock.calls[1]![0]).toContain('en.wiktionary.org')
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Word trimming
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { defineComponent } from 'vue'
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest'
|
||||
import { useReaderKeyboardShortcuts, type ShortcutHandlers } from '../useReaderKeyboardShortcuts'
|
||||
|
||||
function makeHandlers(): ShortcutHandlers {
|
||||
return {
|
||||
toggleSidebar: vi.fn<() => void>(),
|
||||
toggleSearch: vi.fn<() => void>(),
|
||||
toggleBookmark: vi.fn<() => void>(),
|
||||
toggleFullscreen: vi.fn<() => void>(),
|
||||
cycleFooterMode: vi.fn<() => void>(),
|
||||
closePanel: vi.fn<() => void>(),
|
||||
goToStart: vi.fn<() => void>(),
|
||||
goToEnd: vi.fn<() => void>(),
|
||||
}
|
||||
}
|
||||
|
||||
function fireKey(key: string) {
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }))
|
||||
}
|
||||
|
||||
function mountWithShortcuts(handlers: ShortcutHandlers) {
|
||||
let result: ReturnType<typeof useReaderKeyboardShortcuts>
|
||||
const wrapper = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
result = useReaderKeyboardShortcuts(handlers)
|
||||
return result
|
||||
},
|
||||
template: '<div />',
|
||||
}),
|
||||
)
|
||||
return { wrapper, result: result! }
|
||||
}
|
||||
|
||||
describe('useReaderKeyboardShortcuts', () => {
|
||||
let handlers: ShortcutHandlers
|
||||
let wrapper: ReturnType<typeof mount>
|
||||
let result: ReturnType<typeof useReaderKeyboardShortcuts>
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
})
|
||||
|
||||
function setup() {
|
||||
handlers = makeHandlers()
|
||||
const mounted = mountWithShortcuts(handlers)
|
||||
wrapper = mounted.wrapper
|
||||
result = mounted.result
|
||||
}
|
||||
|
||||
it('triggers toggleSidebar on "t" key', () => {
|
||||
setup()
|
||||
fireKey('t')
|
||||
expect(handlers.toggleSidebar).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('triggers toggleSearch on "s" key', () => {
|
||||
setup()
|
||||
fireKey('s')
|
||||
expect(handlers.toggleSearch).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('triggers toggleBookmark on "b" key', () => {
|
||||
setup()
|
||||
fireKey('b')
|
||||
expect(handlers.toggleBookmark).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('triggers toggleFullscreen on "f" key', () => {
|
||||
setup()
|
||||
fireKey('f')
|
||||
expect(handlers.toggleFullscreen).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('triggers cycleFooterMode on "m" key', () => {
|
||||
setup()
|
||||
fireKey('m')
|
||||
expect(handlers.cycleFooterMode).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('triggers closePanel on "Escape" key', () => {
|
||||
setup()
|
||||
fireKey('Escape')
|
||||
expect(handlers.closePanel).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('triggers goToStart on "Home" key', () => {
|
||||
setup()
|
||||
fireKey('Home')
|
||||
expect(handlers.goToStart).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('triggers goToEnd on "End" key', () => {
|
||||
setup()
|
||||
fireKey('End')
|
||||
expect(handlers.goToEnd).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('toggles help modal on "?" key', () => {
|
||||
setup()
|
||||
expect(result.showHelpModal.value).toBe(false)
|
||||
fireKey('?')
|
||||
expect(result.showHelpModal.value).toBe(true)
|
||||
fireKey('?')
|
||||
expect(result.showHelpModal.value).toBe(false)
|
||||
})
|
||||
|
||||
it('closes help modal on Escape when modal is open', () => {
|
||||
setup()
|
||||
fireKey('?')
|
||||
expect(result.showHelpModal.value).toBe(true)
|
||||
|
||||
fireKey('Escape')
|
||||
expect(result.showHelpModal.value).toBe(false)
|
||||
expect(handlers.closePanel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores shortcuts when input element is focused', () => {
|
||||
setup()
|
||||
const input = document.createElement('input')
|
||||
document.body.appendChild(input)
|
||||
input.focus()
|
||||
|
||||
const event = new KeyboardEvent('keydown', { key: 't', bubbles: true })
|
||||
Object.defineProperty(event, 'target', { value: input })
|
||||
document.dispatchEvent(event)
|
||||
|
||||
expect(handlers.toggleSidebar).not.toHaveBeenCalled()
|
||||
document.body.removeChild(input)
|
||||
})
|
||||
|
||||
it('ignores shortcuts when textarea is focused', () => {
|
||||
setup()
|
||||
const textarea = document.createElement('textarea')
|
||||
document.body.appendChild(textarea)
|
||||
textarea.focus()
|
||||
|
||||
const event = new KeyboardEvent('keydown', { key: 's', bubbles: true })
|
||||
Object.defineProperty(event, 'target', { value: textarea })
|
||||
document.dispatchEvent(event)
|
||||
|
||||
expect(handlers.toggleSearch).not.toHaveBeenCalled()
|
||||
document.body.removeChild(textarea)
|
||||
})
|
||||
|
||||
it('does not trigger on unregistered keys', () => {
|
||||
setup()
|
||||
fireKey('x')
|
||||
fireKey('z')
|
||||
fireKey('1')
|
||||
|
||||
expect(handlers.toggleSidebar).not.toHaveBeenCalled()
|
||||
expect(handlers.toggleSearch).not.toHaveBeenCalled()
|
||||
expect(handlers.toggleBookmark).not.toHaveBeenCalled()
|
||||
expect(handlers.toggleFullscreen).not.toHaveBeenCalled()
|
||||
expect(handlers.cycleFooterMode).not.toHaveBeenCalled()
|
||||
expect(handlers.closePanel).not.toHaveBeenCalled()
|
||||
expect(handlers.goToStart).not.toHaveBeenCalled()
|
||||
expect(handlers.goToEnd).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cleans up listener on unmount', () => {
|
||||
setup()
|
||||
wrapper.unmount()
|
||||
|
||||
fireKey('t')
|
||||
expect(handlers.toggleSidebar).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,12 +1,118 @@
|
||||
import type { DictionaryDefinition, DictionaryEntry, DictionaryResult } from '@bookorbit/types'
|
||||
|
||||
const LANGUAGE_ALIASES: Record<string, string> = {
|
||||
ar: 'ar',
|
||||
ara: 'ar',
|
||||
arabic: 'ar',
|
||||
cs: 'cs',
|
||||
ces: 'cs',
|
||||
cze: 'cs',
|
||||
czech: 'cs',
|
||||
da: 'da',
|
||||
dan: 'da',
|
||||
danish: 'da',
|
||||
de: 'de',
|
||||
deu: 'de',
|
||||
ger: 'de',
|
||||
german: 'de',
|
||||
el: 'el',
|
||||
ell: 'el',
|
||||
gre: 'el',
|
||||
greek: 'el',
|
||||
en: 'en',
|
||||
eng: 'en',
|
||||
english: 'en',
|
||||
es: 'es',
|
||||
spa: 'es',
|
||||
spanish: 'es',
|
||||
fi: 'fi',
|
||||
fin: 'fi',
|
||||
finnish: 'fi',
|
||||
fr: 'fr',
|
||||
fra: 'fr',
|
||||
fre: 'fr',
|
||||
french: 'fr',
|
||||
he: 'he',
|
||||
heb: 'he',
|
||||
hebrew: 'he',
|
||||
hi: 'hi',
|
||||
hin: 'hi',
|
||||
hindi: 'hi',
|
||||
hu: 'hu',
|
||||
hun: 'hu',
|
||||
hungarian: 'hu',
|
||||
id: 'id',
|
||||
ind: 'id',
|
||||
indonesian: 'id',
|
||||
it: 'it',
|
||||
ita: 'it',
|
||||
italian: 'it',
|
||||
ja: 'ja',
|
||||
jpn: 'ja',
|
||||
japanese: 'ja',
|
||||
ko: 'ko',
|
||||
kor: 'ko',
|
||||
korean: 'ko',
|
||||
nl: 'nl',
|
||||
nld: 'nl',
|
||||
dut: 'nl',
|
||||
dutch: 'nl',
|
||||
no: 'no',
|
||||
nor: 'no',
|
||||
norwegian: 'no',
|
||||
pl: 'pl',
|
||||
pol: 'pl',
|
||||
polish: 'pl',
|
||||
pt: 'pt',
|
||||
por: 'pt',
|
||||
portuguese: 'pt',
|
||||
ro: 'ro',
|
||||
ron: 'ro',
|
||||
rum: 'ro',
|
||||
romanian: 'ro',
|
||||
ru: 'ru',
|
||||
rus: 'ru',
|
||||
russian: 'ru',
|
||||
sv: 'sv',
|
||||
swe: 'sv',
|
||||
swedish: 'sv',
|
||||
th: 'th',
|
||||
tha: 'th',
|
||||
thai: 'th',
|
||||
tr: 'tr',
|
||||
tur: 'tr',
|
||||
turkish: 'tr',
|
||||
uk: 'uk',
|
||||
ukr: 'uk',
|
||||
ukrainian: 'uk',
|
||||
vi: 'vi',
|
||||
vie: 'vi',
|
||||
vietnamese: 'vi',
|
||||
zh: 'zh',
|
||||
zho: 'zh',
|
||||
chi: 'zh',
|
||||
chinese: 'zh',
|
||||
}
|
||||
|
||||
function stripHtml(html: string): string {
|
||||
return (new DOMParser().parseFromString(html, 'text/html').body.textContent ?? '').trim()
|
||||
}
|
||||
|
||||
function normalizeLang(lang: string): string {
|
||||
if (!lang) return 'en'
|
||||
return (lang.split('-')[0] ?? 'en').toLowerCase()
|
||||
const normalized = lang.trim().toLowerCase()
|
||||
if (!normalized) return 'en'
|
||||
|
||||
const primary = normalized.split(/[;,/|]/)[0]?.trim() ?? ''
|
||||
if (!primary) return 'en'
|
||||
|
||||
const base = primary.split('-')[0]?.trim() ?? ''
|
||||
const alias = LANGUAGE_ALIASES[base]
|
||||
if (alias) return alias
|
||||
|
||||
if (/^[a-z]{2,3}$/.test(base)) return base
|
||||
|
||||
return 'en'
|
||||
}
|
||||
|
||||
function extractPhonetics(entry: Record<string, unknown>): { phonetic: string | null; audioUrl: string | null } {
|
||||
|
||||
@@ -11,6 +11,9 @@ export interface RelocateDetail {
|
||||
index?: number
|
||||
total?: number
|
||||
tocItem?: { label?: string; href?: string }
|
||||
section?: { current: number; total: number }
|
||||
location?: { current: number; next: number; total: number }
|
||||
time?: { section: number; total: number }
|
||||
}
|
||||
|
||||
export interface FoliateRenderer {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
export interface ShortcutHandlers {
|
||||
toggleSidebar: () => void
|
||||
toggleSearch: () => void
|
||||
toggleBookmark: () => void
|
||||
toggleFullscreen: () => void
|
||||
cycleFooterMode: () => void
|
||||
closePanel: () => void
|
||||
goToStart: () => void
|
||||
goToEnd: () => void
|
||||
}
|
||||
|
||||
const INTERACTIVE_TAGS = new Set(['INPUT', 'TEXTAREA', 'SELECT'])
|
||||
|
||||
export function useReaderKeyboardShortcuts(handlers: ShortcutHandlers) {
|
||||
const showHelpModal = ref(false)
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
const target = e.target as HTMLElement | null
|
||||
if (target && (INTERACTIVE_TAGS.has(target.tagName) || target.isContentEditable)) return
|
||||
|
||||
switch (e.key) {
|
||||
case 't':
|
||||
e.preventDefault()
|
||||
handlers.toggleSidebar()
|
||||
break
|
||||
case 's':
|
||||
e.preventDefault()
|
||||
handlers.toggleSearch()
|
||||
break
|
||||
case 'b':
|
||||
e.preventDefault()
|
||||
handlers.toggleBookmark()
|
||||
break
|
||||
case 'f':
|
||||
e.preventDefault()
|
||||
handlers.toggleFullscreen()
|
||||
break
|
||||
case 'm':
|
||||
e.preventDefault()
|
||||
handlers.cycleFooterMode()
|
||||
break
|
||||
case 'Escape':
|
||||
e.preventDefault()
|
||||
if (showHelpModal.value) {
|
||||
showHelpModal.value = false
|
||||
} else {
|
||||
handlers.closePanel()
|
||||
}
|
||||
break
|
||||
case '?':
|
||||
e.preventDefault()
|
||||
showHelpModal.value = !showHelpModal.value
|
||||
break
|
||||
case 'Home':
|
||||
e.preventDefault()
|
||||
handlers.goToStart()
|
||||
break
|
||||
case 'End':
|
||||
e.preventDefault()
|
||||
handlers.goToEnd()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
})
|
||||
|
||||
return { showHelpModal }
|
||||
}
|
||||
@@ -14,12 +14,12 @@ const props = defineProps<{ bookId: number; fileId: number }>()
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
|
||||
const progress = useReaderProgress(props.bookId, props.fileId)
|
||||
const bookSettings = useReaderSettings(props.fileId, 'pdf')
|
||||
const { onActivity } = useReadingSession(props.fileId, () => ({
|
||||
const { onActivity, elapsedMinutes } = useReadingSession(props.fileId, () => ({
|
||||
percentage: progress.percentage.value,
|
||||
pageNumber: progress.pageNumber.value,
|
||||
}))
|
||||
const progress = useReaderProgress(props.bookId, props.fileId, elapsedMinutes)
|
||||
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let unsubPageChange: (() => void) | null = null
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { formatTimeRemaining, useReaderProgress } from '../useReaderProgress'
|
||||
import type { RelocateDetail } from '../../../epub/composables/useFoliate'
|
||||
|
||||
const apiMock = vi.hoisted(() => vi.fn<(...args: unknown[]) => Promise<unknown>>())
|
||||
vi.mock('@/lib/api', () => ({ api: apiMock }))
|
||||
|
||||
describe('formatTimeRemaining', () => {
|
||||
it('returns empty string for NaN', () => {
|
||||
expect(formatTimeRemaining(NaN)).toBe('')
|
||||
})
|
||||
|
||||
it('returns empty string for negative values', () => {
|
||||
expect(formatTimeRemaining(-5)).toBe('')
|
||||
})
|
||||
|
||||
it('returns empty string for Infinity', () => {
|
||||
expect(formatTimeRemaining(Infinity)).toBe('')
|
||||
})
|
||||
|
||||
it('returns "< 1 min" for values less than 1', () => {
|
||||
expect(formatTimeRemaining(0)).toBe('< 1 min')
|
||||
expect(formatTimeRemaining(0.5)).toBe('< 1 min')
|
||||
expect(formatTimeRemaining(0.99)).toBe('< 1 min')
|
||||
})
|
||||
|
||||
it('returns minutes for values less than 60', () => {
|
||||
expect(formatTimeRemaining(1)).toBe('1 min')
|
||||
expect(formatTimeRemaining(15)).toBe('15 min')
|
||||
expect(formatTimeRemaining(59.4)).toBe('59 min')
|
||||
})
|
||||
|
||||
it('returns hours only when remainder is 0', () => {
|
||||
expect(formatTimeRemaining(60)).toBe('1 hr')
|
||||
expect(formatTimeRemaining(120)).toBe('2 hr')
|
||||
})
|
||||
|
||||
it('returns hours and minutes for larger values', () => {
|
||||
expect(formatTimeRemaining(90)).toBe('1 hr 30 min')
|
||||
expect(formatTimeRemaining(150)).toBe('2 hr 30 min')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useReaderProgress', () => {
|
||||
const elapsedMinutes = ref(0)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
apiMock.mockResolvedValue({ ok: true, json: async () => ({}) })
|
||||
elapsedMinutes.value = 0
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function makeDetail(overrides?: Partial<RelocateDetail>): RelocateDetail {
|
||||
return {
|
||||
cfi: 'epubcfi(/6/4)',
|
||||
fraction: 0.35,
|
||||
index: 2,
|
||||
total: 10,
|
||||
tocItem: { label: 'Chapter 3', href: 'ch3.xhtml' },
|
||||
section: { current: 2, total: 10 },
|
||||
location: { current: 35, next: 36, total: 100 },
|
||||
time: { section: 12.5, total: 45.3 },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
it('populates all refs from a full relocate detail', () => {
|
||||
const progress = useReaderProgress(1, 1, elapsedMinutes)
|
||||
progress.onRelocate(makeDetail())
|
||||
|
||||
expect(progress.cfi.value).toBe('epubcfi(/6/4)')
|
||||
expect(progress.fraction.value).toBe(0.35)
|
||||
expect(progress.percentage.value).toBe(35)
|
||||
expect(progress.chapterTitle.value).toBe('Chapter 3')
|
||||
expect(progress.sectionIndex.value).toBe(2)
|
||||
expect(progress.totalSections.value).toBe(10)
|
||||
expect(progress.locationCurrent.value).toBe(35)
|
||||
expect(progress.locationTotal.value).toBe(100)
|
||||
expect(progress.sectionCurrent.value).toBe(2)
|
||||
expect(progress.sectionTotal.value).toBe(10)
|
||||
expect(progress.timeSection.value).toBe(12.5)
|
||||
expect(progress.timeTotal.value).toBe(45.3)
|
||||
})
|
||||
|
||||
it('handles missing location/section/time gracefully', () => {
|
||||
const progress = useReaderProgress(1, 1, elapsedMinutes)
|
||||
progress.onRelocate({
|
||||
cfi: 'epubcfi(/6/2)',
|
||||
fraction: 0.1,
|
||||
index: 1,
|
||||
total: 5,
|
||||
})
|
||||
|
||||
expect(progress.locationCurrent.value).toBe(0)
|
||||
expect(progress.locationTotal.value).toBe(0)
|
||||
expect(progress.sectionCurrent.value).toBe(0)
|
||||
expect(progress.sectionTotal.value).toBe(0)
|
||||
expect(progress.timeSection.value).toBe(0)
|
||||
expect(progress.timeTotal.value).toBe(0)
|
||||
})
|
||||
|
||||
it('falls back to index/total when section is missing', () => {
|
||||
const progress = useReaderProgress(1, 1, elapsedMinutes)
|
||||
progress.onRelocate({
|
||||
cfi: 'epubcfi(/6/2)',
|
||||
fraction: 0.1,
|
||||
index: 3,
|
||||
total: 8,
|
||||
})
|
||||
|
||||
expect(progress.sectionIndex.value).toBe(3)
|
||||
expect(progress.totalSections.value).toBe(8)
|
||||
})
|
||||
|
||||
it('cycles footer mode 0 -> 1 -> 2 -> 0', () => {
|
||||
const progress = useReaderProgress(1, 1, elapsedMinutes, 0)
|
||||
|
||||
expect(progress.footerMode.value).toBe(0)
|
||||
progress.cycleFooterMode()
|
||||
expect(progress.footerMode.value).toBe(1)
|
||||
progress.cycleFooterMode()
|
||||
expect(progress.footerMode.value).toBe(2)
|
||||
progress.cycleFooterMode()
|
||||
expect(progress.footerMode.value).toBe(0)
|
||||
})
|
||||
|
||||
it('initializes footer mode from parameter', () => {
|
||||
const progress = useReaderProgress(1, 1, elapsedMinutes, 2)
|
||||
expect(progress.footerMode.value).toBe(2)
|
||||
})
|
||||
|
||||
it('handles null/undefined detail gracefully', () => {
|
||||
const progress = useReaderProgress(1, 1, elapsedMinutes)
|
||||
progress.onRelocate(undefined as unknown as RelocateDetail)
|
||||
|
||||
expect(progress.cfi.value).toBeNull()
|
||||
expect(progress.fraction.value).toBe(0)
|
||||
expect(progress.percentage.value).toBe(0)
|
||||
})
|
||||
|
||||
describe('buildFooterContent (tested via internal state)', () => {
|
||||
it('mode 0: shows page info and percentage', () => {
|
||||
const progress = useReaderProgress(1, 1, elapsedMinutes, 0)
|
||||
progress.onRelocate(makeDetail({ fraction: 0.35, location: { current: 35, next: 36, total: 100 } }))
|
||||
|
||||
// We can't directly test buildFooterContent since it's private,
|
||||
// but we verify the underlying state that feeds it
|
||||
expect(progress.locationCurrent.value).toBe(35)
|
||||
expect(progress.locationTotal.value).toBe(100)
|
||||
expect(progress.footerMode.value).toBe(0)
|
||||
})
|
||||
|
||||
it('mode 1: uses elapsed minutes from session', () => {
|
||||
elapsedMinutes.value = 15
|
||||
const progress = useReaderProgress(1, 1, elapsedMinutes, 1)
|
||||
progress.onRelocate(makeDetail({ time: { section: 10, total: 42 } }))
|
||||
|
||||
expect(progress.timeTotal.value).toBe(42)
|
||||
expect(progress.footerMode.value).toBe(1)
|
||||
})
|
||||
|
||||
it('mode 2: uses section/chapter time', () => {
|
||||
const progress = useReaderProgress(1, 1, elapsedMinutes, 2)
|
||||
progress.onRelocate(makeDetail({ section: { current: 3, total: 12 }, time: { section: 8, total: 45 } }))
|
||||
|
||||
expect(progress.sectionCurrent.value).toBe(3)
|
||||
expect(progress.sectionTotal.value).toBe(12)
|
||||
expect(progress.timeSection.value).toBe(8)
|
||||
})
|
||||
})
|
||||
|
||||
it('debounces save calls on relocate', async () => {
|
||||
vi.useFakeTimers()
|
||||
const progress = useReaderProgress(1, 1, elapsedMinutes)
|
||||
|
||||
progress.onRelocate(makeDetail({ fraction: 0.1 }))
|
||||
progress.onRelocate(makeDetail({ fraction: 0.2 }))
|
||||
progress.onRelocate(makeDetail({ fraction: 0.3 }))
|
||||
|
||||
expect(apiMock).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2500)
|
||||
|
||||
const saveCalls = apiMock.mock.calls.filter(
|
||||
(c: unknown[]) => typeof c[0] === 'string' && (c[0] as string).includes('/progress') && (c[1] as { method?: string })?.method === 'POST',
|
||||
)
|
||||
expect(saveCalls.length).toBe(1)
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { useReadingSession } from '../useReadingSession'
|
||||
|
||||
const apiMock = vi.hoisted(() => vi.fn<(...args: unknown[]) => Promise<unknown>>())
|
||||
vi.mock('@/lib/api', () => ({ api: apiMock }))
|
||||
|
||||
describe('useReadingSession - elapsedMinutes', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
apiMock.mockResolvedValue({ ok: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('starts elapsedMinutes at 0', () => {
|
||||
const { elapsedMinutes } = useReadingSession(1, () => ({ percentage: 0 }))
|
||||
expect(elapsedMinutes.value).toBe(0)
|
||||
})
|
||||
|
||||
it('updates elapsedMinutes after onActivity and elapsed time', async () => {
|
||||
const { onActivity, elapsedMinutes } = useReadingSession(1, () => ({ percentage: 10 }))
|
||||
|
||||
onActivity()
|
||||
expect(elapsedMinutes.value).toBe(0)
|
||||
|
||||
// Advance 2 minutes
|
||||
await vi.advanceTimersByTimeAsync(2 * 60 * 1000)
|
||||
onActivity()
|
||||
|
||||
// After the 30-second interval update
|
||||
await vi.advanceTimersByTimeAsync(30 * 1000)
|
||||
|
||||
expect(elapsedMinutes.value).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('resets elapsedMinutes to 0 when session ends', async () => {
|
||||
const { onActivity, elapsedMinutes, endSession } = useReadingSession(1, () => ({ percentage: 10 }))
|
||||
|
||||
onActivity()
|
||||
await vi.advanceTimersByTimeAsync(60 * 1000)
|
||||
onActivity()
|
||||
await vi.advanceTimersByTimeAsync(30 * 1000)
|
||||
|
||||
expect(elapsedMinutes.value).toBeGreaterThanOrEqual(1)
|
||||
|
||||
endSession()
|
||||
expect(elapsedMinutes.value).toBe(0)
|
||||
})
|
||||
|
||||
it('pauses elapsed tracking when visibility is hidden', async () => {
|
||||
const { onActivity, elapsedMinutes } = useReadingSession(1, () => ({ percentage: 10 }))
|
||||
|
||||
onActivity()
|
||||
await vi.advanceTimersByTimeAsync(60 * 1000)
|
||||
|
||||
Object.defineProperty(document, 'visibilityState', { value: 'hidden', writable: true, configurable: true })
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
|
||||
const elapsed1 = elapsedMinutes.value
|
||||
|
||||
// Time passes while hidden
|
||||
await vi.advanceTimersByTimeAsync(5 * 60 * 1000)
|
||||
|
||||
// Elapsed should not have increased significantly (only from the interval that ran before pause)
|
||||
expect(elapsedMinutes.value).toBeLessThanOrEqual(elapsed1 + 1)
|
||||
|
||||
// Restore visibility
|
||||
Object.defineProperty(document, 'visibilityState', { value: 'visible', writable: true, configurable: true })
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
})
|
||||
|
||||
it('starts a new session after idle timeout', async () => {
|
||||
const { onActivity, elapsedMinutes } = useReadingSession(1, () => ({ percentage: 10 }))
|
||||
|
||||
onActivity()
|
||||
await vi.advanceTimersByTimeAsync(60 * 1000)
|
||||
onActivity()
|
||||
await vi.advanceTimersByTimeAsync(30 * 1000)
|
||||
|
||||
const elapsed = elapsedMinutes.value
|
||||
expect(elapsed).toBeGreaterThanOrEqual(1)
|
||||
|
||||
// Wait for idle timeout (5 minutes)
|
||||
await vi.advanceTimersByTimeAsync(5 * 60 * 1000 + 1000)
|
||||
|
||||
// After idle timeout, session ends, elapsedMinutes resets
|
||||
expect(elapsedMinutes.value).toBe(0)
|
||||
|
||||
// New activity starts a new session
|
||||
onActivity()
|
||||
expect(elapsedMinutes.value).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { defineComponent } from 'vue'
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { useWakeLock } from '../useWakeLock'
|
||||
|
||||
function mountWithWakeLock() {
|
||||
const wrapper = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
useWakeLock()
|
||||
return {}
|
||||
},
|
||||
template: '<div />',
|
||||
}),
|
||||
)
|
||||
return wrapper
|
||||
}
|
||||
|
||||
describe('useWakeLock', () => {
|
||||
let releaseFn: ReturnType<typeof vi.fn>
|
||||
let addEventListenerSpy: ReturnType<typeof vi.fn>
|
||||
let requestFn: ReturnType<typeof vi.fn>
|
||||
let docAddEventListenerSpy: ReturnType<typeof vi.spyOn>
|
||||
let docRemoveEventListenerSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
releaseFn = vi.fn<() => Promise<void>>().mockResolvedValue(undefined)
|
||||
addEventListenerSpy = vi.fn<(type: string, listener: EventListenerOrEventListenerObject) => void>()
|
||||
requestFn = vi.fn<() => Promise<{ release: typeof releaseFn; addEventListener: typeof addEventListenerSpy }>>().mockResolvedValue({
|
||||
release: releaseFn,
|
||||
addEventListener: addEventListenerSpy,
|
||||
})
|
||||
|
||||
Object.defineProperty(navigator, 'wakeLock', {
|
||||
value: { request: requestFn },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
docAddEventListenerSpy = vi.spyOn(document, 'addEventListener')
|
||||
docRemoveEventListenerSpy = vi.spyOn(document, 'removeEventListener')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('acquires wake lock on mount', async () => {
|
||||
mountWithWakeLock()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(requestFn).toHaveBeenCalledWith('screen')
|
||||
})
|
||||
})
|
||||
|
||||
it('registers visibilitychange listener on mount', () => {
|
||||
mountWithWakeLock()
|
||||
|
||||
expect(docAddEventListenerSpy).toHaveBeenCalledWith('visibilitychange', expect.any(Function))
|
||||
})
|
||||
|
||||
it('releases wake lock on unmount', async () => {
|
||||
const wrapper = mountWithWakeLock()
|
||||
await vi.waitFor(() => expect(requestFn).toHaveBeenCalled())
|
||||
|
||||
wrapper.unmount()
|
||||
|
||||
expect(releaseFn).toHaveBeenCalled()
|
||||
expect(docRemoveEventListenerSpy).toHaveBeenCalledWith('visibilitychange', expect.any(Function))
|
||||
})
|
||||
|
||||
it('handles missing wakeLock API gracefully', () => {
|
||||
Object.defineProperty(navigator, 'wakeLock', {
|
||||
value: undefined,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
expect(() => mountWithWakeLock()).not.toThrow()
|
||||
})
|
||||
|
||||
it('handles rejected wake lock request gracefully', async () => {
|
||||
requestFn.mockRejectedValueOnce(new Error('Not allowed'))
|
||||
|
||||
expect(() => mountWithWakeLock()).not.toThrow()
|
||||
await vi.waitFor(() => expect(requestFn).toHaveBeenCalled())
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,20 @@
|
||||
import { onUnmounted, ref } from 'vue'
|
||||
import { onUnmounted, ref, type Ref } from 'vue'
|
||||
import { api } from '@/lib/api'
|
||||
import type { FoliateRenderer, RelocateDetail } from '../../epub/composables/useFoliate'
|
||||
|
||||
export function useReaderProgress(bookId: number, fileId: number) {
|
||||
export type FooterDisplayMode = 0 | 1 | 2
|
||||
|
||||
export function formatTimeRemaining(minutes: number): string {
|
||||
if (!Number.isFinite(minutes) || minutes < 0) return ''
|
||||
if (minutes < 1) return '< 1 min'
|
||||
if (minutes < 60) return `${Math.round(minutes)} min`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const remainder = Math.round(minutes % 60)
|
||||
if (remainder === 0) return `${hours} hr`
|
||||
return `${hours} hr ${remainder} min`
|
||||
}
|
||||
|
||||
export function useReaderProgress(bookId: number, fileId: number, elapsedMinutes: Ref<number>, initialFooterMode: FooterDisplayMode = 0) {
|
||||
const cfi = ref<string | null>(null)
|
||||
const pageNumber = ref<number | null>(null)
|
||||
const percentage = ref(0)
|
||||
@@ -11,6 +23,15 @@ export function useReaderProgress(bookId: number, fileId: number) {
|
||||
const totalSections = ref(0)
|
||||
const fraction = ref(0)
|
||||
|
||||
const locationCurrent = ref(0)
|
||||
const locationTotal = ref(0)
|
||||
const sectionCurrent = ref(0)
|
||||
const sectionTotal = ref(0)
|
||||
const timeSection = ref(0)
|
||||
const timeTotal = ref(0)
|
||||
|
||||
const footerMode = ref<FooterDisplayMode>(initialFooterMode)
|
||||
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -31,8 +52,15 @@ export function useReaderProgress(bookId: number, fileId: number) {
|
||||
fraction.value = detail?.fraction ?? 0
|
||||
percentage.value = fraction.value * 100
|
||||
chapterTitle.value = detail?.tocItem?.label ?? ''
|
||||
sectionIndex.value = detail?.index ?? 0
|
||||
totalSections.value = detail?.total ?? 0
|
||||
sectionIndex.value = detail?.section?.current ?? detail?.index ?? 0
|
||||
totalSections.value = detail?.section?.total ?? detail?.total ?? 0
|
||||
|
||||
locationCurrent.value = detail?.location?.current ?? 0
|
||||
locationTotal.value = detail?.location?.total ?? 0
|
||||
sectionCurrent.value = detail?.section?.current ?? 0
|
||||
sectionTotal.value = detail?.section?.total ?? 0
|
||||
timeSection.value = detail?.time?.section ?? 0
|
||||
timeTotal.value = detail?.time?.total ?? 0
|
||||
|
||||
if (saveTimer) clearTimeout(saveTimer)
|
||||
saveTimer = setTimeout(() => save(), 2000)
|
||||
@@ -50,6 +78,32 @@ export function useReaderProgress(bookId: number, fileId: number) {
|
||||
})
|
||||
}
|
||||
|
||||
function cycleFooterMode() {
|
||||
footerMode.value = ((footerMode.value + 1) % 3) as FooterDisplayMode
|
||||
}
|
||||
|
||||
function buildFooterContent(mode: FooterDisplayMode): { left: string; right: string } {
|
||||
const pct = Math.round(fraction.value * 100)
|
||||
|
||||
switch (mode) {
|
||||
case 0: {
|
||||
const left = locationTotal.value > 0 ? `Page ${locationCurrent.value + 1} of ${locationTotal.value}` : ''
|
||||
return { left, right: `${pct}%` }
|
||||
}
|
||||
case 1: {
|
||||
const elapsed = elapsedMinutes.value
|
||||
const left = elapsed > 0 ? `Reading: ${elapsed}m` : 'Reading: < 1m'
|
||||
const right = timeTotal.value > 0 ? `${formatTimeRemaining(timeTotal.value)} left` : `${pct}%`
|
||||
return { left, right }
|
||||
}
|
||||
case 2: {
|
||||
const left = sectionTotal.value > 0 ? `Ch. ${sectionCurrent.value + 1} of ${sectionTotal.value}` : ''
|
||||
const right = timeSection.value > 0 ? `${formatTimeRemaining(timeSection.value)} left in ch.` : `${pct}%`
|
||||
return { left, right }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateHeadsFeet(renderer: FoliateRenderer, theme: { fg: string; bg: string }) {
|
||||
if (!renderer || !renderer.heads?.length) return
|
||||
|
||||
@@ -92,38 +146,46 @@ export function useReaderProgress(bookId: number, fileId: number) {
|
||||
|
||||
if (!renderer.feet?.length) return
|
||||
|
||||
const pct = Math.round(fraction.value * 100)
|
||||
const { left, right } = buildFooterContent(footerMode.value)
|
||||
const totalCols = renderer.feet.length
|
||||
|
||||
renderer.feet.forEach((footEl: HTMLElement, index: number) => {
|
||||
if (!footEl) return
|
||||
const div = document.createElement('div')
|
||||
div.style.cssText = style
|
||||
div.style.cursor = 'pointer'
|
||||
div.addEventListener('click', (e) => {
|
||||
e.stopPropagation()
|
||||
cycleFooterMode()
|
||||
updateHeadsFeet(renderer, theme)
|
||||
})
|
||||
|
||||
if (isSingleColumn) {
|
||||
const timeSpan = document.createElement('span')
|
||||
timeSpan.textContent = ''
|
||||
timeSpan.style.textAlign = 'left'
|
||||
const leftSpan = document.createElement('span')
|
||||
leftSpan.textContent = left
|
||||
leftSpan.style.textAlign = 'left'
|
||||
|
||||
const progressSpan = document.createElement('span')
|
||||
progressSpan.textContent = `${pct}%`
|
||||
progressSpan.style.textAlign = 'right'
|
||||
const rightSpan = document.createElement('span')
|
||||
rightSpan.textContent = right
|
||||
rightSpan.style.textAlign = 'right'
|
||||
|
||||
div.appendChild(timeSpan)
|
||||
div.appendChild(progressSpan)
|
||||
div.appendChild(leftSpan)
|
||||
div.appendChild(rightSpan)
|
||||
} else {
|
||||
if (index === 0) {
|
||||
const spacer = document.createElement('span')
|
||||
div.appendChild(spacer)
|
||||
const leftSpan = document.createElement('span')
|
||||
leftSpan.textContent = left
|
||||
leftSpan.style.textAlign = 'left'
|
||||
div.appendChild(leftSpan)
|
||||
div.appendChild(document.createElement('span'))
|
||||
} else if (index === totalCols - 1) {
|
||||
const spacer = document.createElement('span')
|
||||
div.appendChild(spacer)
|
||||
|
||||
const progressSpan = document.createElement('span')
|
||||
progressSpan.textContent = `${pct}%`
|
||||
progressSpan.style.textAlign = 'right'
|
||||
div.appendChild(progressSpan)
|
||||
const rightSpan = document.createElement('span')
|
||||
rightSpan.textContent = right
|
||||
rightSpan.style.textAlign = 'right'
|
||||
div.appendChild(rightSpan)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,9 +201,17 @@ export function useReaderProgress(bookId: number, fileId: number) {
|
||||
sectionIndex,
|
||||
totalSections,
|
||||
fraction,
|
||||
locationCurrent,
|
||||
locationTotal,
|
||||
sectionCurrent,
|
||||
sectionTotal,
|
||||
timeSection,
|
||||
timeTotal,
|
||||
footerMode,
|
||||
load,
|
||||
onRelocate,
|
||||
save,
|
||||
cycleFooterMode,
|
||||
updateHeadsFeet,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { onUnmounted } from 'vue'
|
||||
import { onUnmounted, ref } from 'vue'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
export interface ProgressSnapshot {
|
||||
@@ -9,6 +9,7 @@ export interface ProgressSnapshot {
|
||||
|
||||
const IDLE_TIMEOUT_MS = 5 * 60 * 1000
|
||||
const MIN_SESSION_MS = 10 * 1000
|
||||
const ELAPSED_UPDATE_INTERVAL_MS = 30 * 1000
|
||||
|
||||
function generateSessionId(): string {
|
||||
return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
||||
@@ -25,6 +26,34 @@ export function useReadingSession(bookFileId: number, getProgress: () => Progres
|
||||
let startProgress: number | null = null
|
||||
let ended = false
|
||||
|
||||
const elapsedMinutes = ref(0)
|
||||
let elapsedInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function getActiveMs(): number {
|
||||
if (activeStart === null) return activeMs
|
||||
return activeMs + (Date.now() - activeStart)
|
||||
}
|
||||
|
||||
function updateElapsed() {
|
||||
if (!startedAt || ended) {
|
||||
elapsedMinutes.value = 0
|
||||
return
|
||||
}
|
||||
elapsedMinutes.value = Math.floor(getActiveMs() / 60_000)
|
||||
}
|
||||
|
||||
function startElapsedInterval() {
|
||||
stopElapsedInterval()
|
||||
elapsedInterval = setInterval(updateElapsed, ELAPSED_UPDATE_INTERVAL_MS)
|
||||
}
|
||||
|
||||
function stopElapsedInterval() {
|
||||
if (elapsedInterval !== null) {
|
||||
clearInterval(elapsedInterval)
|
||||
elapsedInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
function startSession() {
|
||||
startedAt = new Date()
|
||||
activeStart = Date.now()
|
||||
@@ -32,6 +61,8 @@ export function useReadingSession(bookFileId: number, getProgress: () => Progres
|
||||
ended = false
|
||||
startProgress = getProgress().percentage
|
||||
resetIdleTimer()
|
||||
updateElapsed()
|
||||
startElapsedInterval()
|
||||
}
|
||||
|
||||
function pauseTimer() {
|
||||
@@ -61,7 +92,7 @@ export function useReadingSession(bookFileId: number, getProgress: () => Progres
|
||||
}
|
||||
|
||||
function onActivity() {
|
||||
// No active session or previous session ended (e.g. after idle timeout) — start fresh.
|
||||
// No active session or previous session ended (e.g. after idle timeout) - start fresh.
|
||||
if (!startedAt || ended) {
|
||||
sessionId = generateSessionId()
|
||||
startSession()
|
||||
@@ -75,12 +106,15 @@ export function useReadingSession(bookFileId: number, getProgress: () => Progres
|
||||
if (ended || !startedAt) return
|
||||
ended = true
|
||||
clearIdleTimer()
|
||||
stopElapsedInterval()
|
||||
|
||||
if (activeStart !== null) {
|
||||
activeMs += Date.now() - activeStart
|
||||
activeStart = null
|
||||
}
|
||||
|
||||
elapsedMinutes.value = 0
|
||||
|
||||
if (activeMs < MIN_SESSION_MS) return
|
||||
|
||||
const endedAt = new Date()
|
||||
@@ -124,9 +158,10 @@ export function useReadingSession(bookFileId: number, getProgress: () => Progres
|
||||
|
||||
onUnmounted(() => {
|
||||
endSession()
|
||||
stopElapsedInterval()
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||
window.removeEventListener('beforeunload', onBeforeUnload)
|
||||
})
|
||||
|
||||
return { onActivity, endSession }
|
||||
return { onActivity, endSession, elapsedMinutes }
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ export function useVisibility() {
|
||||
let isVisibilityLocked = false
|
||||
let hideTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const HEADER_TRIGGER = 80
|
||||
const FOOTER_TRIGGER = 80
|
||||
const HEADER_TRIGGER = 24
|
||||
const FOOTER_TRIGGER = 24
|
||||
|
||||
function scheduleHide() {
|
||||
if (hideTimer) clearTimeout(hideTimer)
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
|
||||
export function useWakeLock() {
|
||||
let wakeLock: WakeLockSentinel | null = null
|
||||
|
||||
async function acquire() {
|
||||
if (!('wakeLock' in navigator)) return
|
||||
try {
|
||||
wakeLock = await navigator.wakeLock.request('screen')
|
||||
wakeLock.addEventListener('release', () => {
|
||||
wakeLock = null
|
||||
})
|
||||
} catch {
|
||||
// Silently ignore - browser may deny or not support
|
||||
}
|
||||
}
|
||||
|
||||
function release() {
|
||||
wakeLock?.release().catch(() => {})
|
||||
wakeLock = null
|
||||
}
|
||||
|
||||
function onVisibilityChange() {
|
||||
if (document.visibilityState === 'visible' && !wakeLock) {
|
||||
acquire()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
acquire()
|
||||
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
release()
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||
})
|
||||
}
|
||||
@@ -63,6 +63,8 @@ export interface EpubReaderSettings {
|
||||
// When false, new books open with the publisher's embedded styles instead of these defaults.
|
||||
// Per-book settings always apply regardless of this flag.
|
||||
overrideBookFormatting: boolean;
|
||||
// In-page footer display mode: 0 = pages, 1 = time remaining + session, 2 = chapter info
|
||||
footerDisplayMode: 0 | 1 | 2;
|
||||
}
|
||||
|
||||
export interface PdfReaderSettings {
|
||||
@@ -114,6 +116,7 @@ export const EPUB_READER_DEFAULTS: EpubReaderSettings = {
|
||||
hyphenate: true,
|
||||
flow: "paginated",
|
||||
overrideBookFormatting: true,
|
||||
footerDisplayMode: 0,
|
||||
};
|
||||
|
||||
export const PDF_READER_DEFAULTS: PdfReaderSettings = {
|
||||
|
||||
@@ -180,4 +180,61 @@ describe('ReaderPreferencesService', () => {
|
||||
|
||||
expect(mockRepo.deleteDefault).toHaveBeenCalledWith(9, 'audio');
|
||||
});
|
||||
|
||||
describe('epub footerDisplayMode validation', () => {
|
||||
const validEpubDefaults = {
|
||||
themeName: 'default',
|
||||
isDark: false,
|
||||
fontFamily: null,
|
||||
fontSize: 16,
|
||||
lineHeight: 1.5,
|
||||
maxColumnCount: 2,
|
||||
gap: 0.05,
|
||||
maxInlineSize: 720,
|
||||
maxBlockSize: 1440,
|
||||
justify: true,
|
||||
hyphenate: true,
|
||||
flow: 'paginated' as const,
|
||||
overrideBookFormatting: true,
|
||||
footerDisplayMode: 0 as const,
|
||||
};
|
||||
|
||||
it('accepts valid footerDisplayMode values (0, 1, 2) in full epub defaults', async () => {
|
||||
for (const mode of [0, 1, 2] as const) {
|
||||
vi.clearAllMocks();
|
||||
mockRepo.upsertDefault.mockResolvedValue(undefined);
|
||||
await service.upsertDefault(1, 'epub', { ...validEpubDefaults, footerDisplayMode: mode });
|
||||
expect(mockRepo.upsertDefault).toHaveBeenCalledWith(1, 'epub', expect.objectContaining({ footerDisplayMode: mode }));
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects invalid footerDisplayMode values in epub defaults', async () => {
|
||||
await expect(service.upsertDefault(1, 'epub', { ...validEpubDefaults, footerDisplayMode: 3 })).rejects.toThrow(BadRequestException);
|
||||
await expect(service.upsertDefault(1, 'epub', { ...validEpubDefaults, footerDisplayMode: -1 })).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('accepts partial epub per-book settings with footerDisplayMode', async () => {
|
||||
const user = makeUser();
|
||||
mockBookService.verifyFileAccess.mockResolvedValueOnce({ format: 'epub' });
|
||||
|
||||
await service.upsertPreference(user, 20, { footerDisplayMode: 1 });
|
||||
|
||||
expect(mockRepo.upsertPreference).toHaveBeenCalledWith(7, 20, { footerDisplayMode: 1 });
|
||||
});
|
||||
|
||||
it('rejects partial epub per-book settings with invalid footerDisplayMode', async () => {
|
||||
const user = makeUser();
|
||||
mockBookService.verifyFileAccess.mockResolvedValueOnce({ format: 'epub' });
|
||||
|
||||
await expect(service.upsertPreference(user, 21, { footerDisplayMode: 5 })).rejects.toThrow(BadRequestException);
|
||||
expect(mockRepo.upsertPreference).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requires footerDisplayMode when saving full epub defaults', async () => {
|
||||
const withoutFooterMode = { ...validEpubDefaults };
|
||||
delete (withoutFooterMode as Record<string, unknown>).footerDisplayMode;
|
||||
|
||||
await expect(service.upsertDefault(1, 'epub', withoutFooterMode)).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,7 @@ const EPUB_SETTINGS_SCHEMA = z
|
||||
hyphenate: z.boolean(),
|
||||
flow: z.enum(['paginated', 'scrolled']),
|
||||
overrideBookFormatting: z.boolean(),
|
||||
footerDisplayMode: z.union([z.literal(0), z.literal(1), z.literal(2)]),
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user