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:
neon
2026-05-18 22:02:49 -06:00
parent 25117e3d0b
commit 8abde907d8
24 changed files with 1539 additions and 83 deletions
+87 -12
View File
@@ -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
// -------------------------------------------------------------------------
@@ -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)
})
}
+3
View File
@@ -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();