feat(reader,ui): improve mobile reader ergonomics and touch handling

- overhaul EPUB/CBZ/PDF mobile header, footer, toolbar, sidebar, and settings interactions
- add EPUB search minimum query length enforcement with updated unit tests
- improve PDF rendering stability by drawing from an offscreen render canvas
- add app-shell level horizontal overflow/touch constraints to prevent sideways pan and double-tap zoom issues
- make statistics header controls and configure drawer responsive on mobile
This commit is contained in:
neon
2026-05-09 23:47:26 -06:00
parent 6669c4cb51
commit e59608ceb3
16 changed files with 758 additions and 414 deletions
+37
View File
@@ -299,10 +299,47 @@
@apply border-border outline-ring/50;
scrollbar-color: var(--scrollbar-thumb) transparent;
}
html,
body,
#app,
.app-shell,
.app-shell-inset,
.app-shell-scroll {
max-width: 100%;
overflow-x: clip;
overscroll-behavior-x: none;
}
@supports not (overflow: clip) {
html,
body,
#app,
.app-shell,
.app-shell-inset,
.app-shell-scroll {
overflow-x: hidden;
}
}
body {
@apply bg-background text-foreground font-sans antialiased;
}
@media (pointer: coarse) {
.app-shell-inset,
.app-shell-scroll {
touch-action: pan-y;
}
/* Prevent double-tap zoom on tappable controls while preserving normal taps. */
a,
button,
[role='button'] {
touch-action: manipulation;
}
}
/* Webkit scrollbar styles */
::-webkit-scrollbar {
width: 10px;
+3 -3
View File
@@ -24,14 +24,14 @@ const viewKey = computed(() => {
</script>
<template>
<SidebarProvider class="glow-wrapper min-h-screen" :class="backgroundClass">
<SidebarProvider class="app-shell glow-wrapper min-h-screen" :class="backgroundClass">
<AppSidebar />
<SidebarInset class="flex flex-col h-screen overflow-hidden relative bg-transparent pb-3">
<SidebarInset class="app-shell-inset flex flex-col h-screen overflow-hidden relative bg-transparent pb-3">
<!-- 1. Global App Header: Fixed at the top, independent of views -->
<AppHeader />
<!-- 2. Independent View Area: Everything below the header scrolls here -->
<div class="px-2 sm:px-4 pt-2 flex-1 overflow-y-auto overflow-x-hidden relative scroll-smooth bg-transparent">
<div class="app-shell-scroll px-2 sm:px-4 pt-2 flex-1 overflow-y-auto overflow-x-hidden relative scroll-smooth bg-transparent">
<router-view v-slot="{ Component }">
<Transition name="page" mode="out-in">
<div
@@ -118,6 +118,15 @@ const scrollerStyle = computed(() => ({
<style scoped>
.book-grid-scroller {
width: 100%;
max-width: 100%;
overflow-x: hidden;
overscroll-behavior-x: none;
}
@media (pointer: coarse) {
.book-grid-scroller {
touch-action: pan-y;
}
}
.book-grid-cell {
+335 -204
View File
@@ -33,14 +33,7 @@ import { useReaderSettings } from '../shared/composables/useReaderSettings'
import type { CbxReaderSettings } from '@bookorbit/types'
import { DEFAULT_WIDE_PAGE_RATIO_THRESHOLD, createCbzSpreadLayout } from './lib/spread-layout'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
const TWO_PAGE_BREAKPOINT = 900
@@ -61,6 +54,15 @@ const bookSettings = useReaderSettings(props.fileId, 'cbz')
const currentPage = ref(0)
const showSettings = ref(false)
type SettingsTab = 'view' | 'reading' | 'layout'
const settingsTab = ref<SettingsTab>('view')
const settingsContentRef = ref<HTMLElement | null>(null)
const hasSettingsTabBarShadow = ref(false)
const settingsScrollMemory = ref<Record<SettingsTab, number>>({
view: 0,
reading: 0,
layout: 0,
})
const scrollContainer = ref<HTMLElement | null>(null)
const viewportWidth = ref(0)
const currentImageLoaded = ref(false)
@@ -71,7 +73,16 @@ const highlightForceTwoPage = ref(false)
const forceTwoPageToggleButton = ref<HTMLButtonElement | null>(null)
let forceToggleHighlightTimer: ReturnType<typeof setTimeout> | null = null
watch(showSettings, (open) => setVisibilityLock(open))
watch(showSettings, (open) => {
setVisibilityLock(open)
if (!open) return
settingsTab.value = 'view'
nextTick(() => {
if (!settingsContentRef.value) return
settingsContentRef.value.scrollTop = settingsScrollMemory.value.view ?? 0
hasSettingsTabBarShadow.value = settingsContentRef.value.scrollTop > 0
})
})
// ── Settings options ───────────────────────────────────────────────────────────
const FIT_OPTIONS: { value: FitMode; label: string; icon: Component }[] = [
@@ -85,9 +96,9 @@ const VIEW_OPTIONS: { value: ViewMode; label: string; icon: Component }[] = [
{ value: 'two-page', label: 'Two-page', icon: LayoutGrid },
]
const SCROLL_OPTIONS: { value: ScrollMode; label: string; icon: Component }[] = [
{ value: 'paginated', label: 'Paginated', icon: ScanLine },
{ value: 'paginated', label: 'Paged', icon: ScanLine },
{ value: 'infinite', label: 'Infinite', icon: Layers },
{ value: 'long-strip', label: 'Long strip', icon: AlignJustify },
{ value: 'long-strip', label: 'Strip', icon: AlignJustify },
]
const DIRECTION_OPTIONS: { value: Direction; label: string; icon: Component }[] = [
{ value: 'ltr', label: 'L to R', icon: ArrowRight },
@@ -98,8 +109,8 @@ const SPREAD_ALIGNMENT_OPTIONS: { value: SpreadAlignment; label: string; icon: C
{ value: 'shifted', label: 'Shifted', icon: BookOpen },
]
const WIDE_PAGE_OPTIONS: { value: WidePageSingletonMode; label: string; icon: Component }[] = [
{ value: 'auto', label: 'Auto singleton', icon: ImageIcon },
{ value: 'disable', label: 'Keep in spreads', icon: LayoutGrid },
{ value: 'auto', label: 'Auto', icon: ImageIcon },
{ value: 'disable', label: 'In spreads', icon: LayoutGrid },
]
const BG_OPTIONS: { value: BgColor; label: string; icon: Component }[] = [
{ value: 'black', label: 'Black', icon: Moon },
@@ -107,6 +118,25 @@ const BG_OPTIONS: { value: BgColor; label: string; icon: Component }[] = [
{ value: 'white', label: 'White', icon: Sun },
]
function onSettingsContentScroll() {
if (!settingsContentRef.value) return
settingsScrollMemory.value[settingsTab.value] = settingsContentRef.value.scrollTop
hasSettingsTabBarShadow.value = settingsContentRef.value.scrollTop > 0
}
function setSettingsTab(tab: SettingsTab) {
if (tab === settingsTab.value) return
if (settingsContentRef.value) {
settingsScrollMemory.value[settingsTab.value] = settingsContentRef.value.scrollTop
}
settingsTab.value = tab
nextTick(() => {
if (!settingsContentRef.value) return
settingsContentRef.value.scrollTop = settingsScrollMemory.value[tab] ?? 0
hasSettingsTabBarShadow.value = settingsContentRef.value.scrollTop > 0
})
}
function setFitMode(v: FitMode) {
fitMode.value = v
}
@@ -128,9 +158,6 @@ function setWidePageMode(v: WidePageSingletonMode) {
function setForceTwoPage(v: boolean) {
forceTwoPage.value = v
}
function toggleForceTwoPage() {
setForceTwoPage(!forceTwoPage.value)
}
function setBgColor(v: BgColor) {
bgColor.value = v
}
@@ -225,6 +252,12 @@ const progressPercent = computed(() => {
return ((Math.max(0, Math.min(progressPageIndex.value, pageCount.value - 1)) + 1) / pageCount.value) * 100
})
const sliderFillPercent = computed(() => {
const max = Math.max(0, pageCount.value - 1)
if (max === 0) return 0
return (Math.max(0, Math.min(currentPage.value, max)) / max) * 100
})
const canGoPrev = computed(() => {
if (pageCount.value <= 0) return false
if (isTwoPageEffective.value) return spreadLayout.value.prevAnchor(currentPage.value) !== currentPage.value
@@ -543,181 +576,272 @@ onUnmounted(() => {
<Settings :size="15" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-80 p-2">
<DropdownMenuLabel class="text-muted-foreground text-xs px-1 py-1">Reader Settings</DropdownMenuLabel>
<div class="px-1 py-1.5 space-y-1.5">
<p class="text-[11px] text-muted-foreground">Fit mode</p>
<div class="grid grid-cols-2 gap-1">
<button
v-for="opt in FIT_OPTIONS"
:key="opt.value"
class="h-7 px-2 rounded-md border text-[11px] transition-colors flex items-center justify-center gap-1"
:class="
fitMode === opt.value ? 'border-primary text-primary bg-primary/10' : 'border-border text-muted-foreground hover:text-foreground'
"
@click.stop="setFitMode(opt.value)"
>
<component :is="opt.icon" :size="11" />
{{ opt.label }}
</button>
</div>
</div>
<div class="px-1 py-1.5 space-y-1.5">
<div class="flex items-center justify-between">
<p class="text-[11px] text-muted-foreground">Page view</p>
<span
v-if="showAutoFallbackBadge"
class="inline-flex items-center gap-1 rounded-full border border-border bg-muted px-2 py-0.5 text-[10px] text-muted-foreground"
>
<Info :size="11" />
Auto-fallback active
</span>
</div>
<div class="grid grid-cols-2 gap-1">
<button
v-for="opt in VIEW_OPTIONS"
:key="opt.value"
class="h-7 px-2 rounded-md border text-xs transition-colors flex items-center justify-center gap-1"
:class="
viewMode === opt.value ? 'border-primary text-primary bg-primary/10' : 'border-border text-muted-foreground hover:text-foreground'
"
@click.stop="setViewMode(opt.value)"
>
<component :is="opt.icon" :size="11" />
{{ opt.label }}
</button>
</div>
</div>
<div class="px-1 py-1.5 space-y-1.5">
<p class="text-[11px] text-muted-foreground">Reading direction</p>
<div class="grid grid-cols-2 gap-1">
<button
v-for="opt in DIRECTION_OPTIONS"
:key="opt.value"
class="h-7 px-2 rounded-md border text-xs transition-colors flex items-center justify-center gap-1"
:class="
direction === opt.value
? 'border-primary text-primary bg-primary/10'
: 'border-border text-muted-foreground hover:text-foreground'
"
@click.stop="setDirection(opt.value)"
>
<component :is="opt.icon" :size="11" />
{{ opt.label }}
</button>
</div>
</div>
<div class="px-1 py-1.5 space-y-1.5">
<p class="text-[11px] text-muted-foreground">Background</p>
<div class="grid grid-cols-3 gap-1">
<button
v-for="opt in BG_OPTIONS"
:key="opt.value"
class="h-7 px-2 rounded-md border text-xs transition-colors flex items-center justify-center gap-1"
:class="
bgColor === opt.value ? 'border-primary text-primary bg-primary/10' : 'border-border text-muted-foreground hover:text-foreground'
"
@click.stop="setBgColor(opt.value)"
>
<component :is="opt.icon" :size="11" />
{{ opt.label }}
</button>
</div>
</div>
<div v-if="showSpreadAlignmentControl" class="px-1 py-1.5 space-y-1.5">
<p class="text-[11px] text-muted-foreground">Spread alignment</p>
<div class="grid grid-cols-2 gap-1">
<button
v-for="opt in SPREAD_ALIGNMENT_OPTIONS"
:key="opt.value"
class="h-7 px-2 rounded-md border text-xs transition-colors flex items-center justify-center gap-1"
:class="
spreadAlignment === opt.value
? 'border-primary text-primary bg-primary/10'
: 'border-border text-muted-foreground hover:text-foreground'
"
@click.stop="setSpreadAlignment(opt.value)"
>
<component :is="opt.icon" :size="11" />
{{ opt.label }}
</button>
</div>
</div>
<DropdownMenuItem v-if="showSpreadAlignmentHint" class="text-xs mt-1" @select.prevent="focusForceTwoPageFromHint">
<Info :size="13" />
Spread alignment unavailable in auto-fallback
<span class="ml-auto text-[10px] text-muted-foreground">Focus toggle below</span>
</DropdownMenuItem>
<DropdownMenuSeparator class="my-1" />
<div class="px-1 py-1.5 space-y-1.5">
<p class="text-[11px] text-muted-foreground">Scroll mode</p>
<div class="grid grid-cols-3 gap-1">
<button
v-for="opt in SCROLL_OPTIONS"
:key="opt.value"
class="h-7 px-2 rounded-md border text-[11px] transition-colors flex items-center justify-center gap-1"
:class="
scrollMode === opt.value
? 'border-primary text-primary bg-primary/10'
: 'border-border text-muted-foreground hover:text-foreground'
"
@click.stop="setScrollMode(opt.value)"
>
<component :is="opt.icon" :size="11" />
{{ opt.label }}
</button>
</div>
</div>
<div class="px-1 py-1.5 flex items-center justify-between">
<div>
<p class="text-[11px] text-muted-foreground">Force two-page (Bypass mobile fallback)</p>
</div>
<button
ref="forceTwoPageToggleButton"
class="h-7 px-3 rounded-md border text-xs transition-colors"
:class="[
forceTwoPage ? 'border-primary text-primary bg-primary/10' : 'border-border text-muted-foreground hover:text-foreground',
highlightForceTwoPage ? 'ring-2 ring-primary/50' : '',
]"
@click.stop="toggleForceTwoPage"
<DropdownMenuContent
align="end"
side="bottom"
:side-offset="10"
class="w-[22rem] max-w-[calc(100vw-1rem)] max-h-[min(80vh,38rem)] rounded-lg border-border bg-card p-0 shadow-2xl overflow-hidden"
>
<section
class="bg-card text-card-foreground flex max-h-[min(80vh,38rem)] flex-col overflow-hidden [&_button:focus-visible]:outline-none [&_button:focus-visible]:ring-2 [&_button:focus-visible]:ring-primary/55 [&_button:focus-visible]:ring-offset-1 [&_button:focus-visible]:ring-offset-card"
>
<div
class="sticky top-0 z-10 border-b border-border bg-card/95 px-3 py-3 backdrop-blur-sm transition-shadow"
:class="hasSettingsTabBarShadow ? 'shadow-sm' : ''"
>
{{ forceTwoPage ? 'On' : 'Off' }}
</button>
</div>
<div class="px-1 py-1.5 space-y-1.5">
<p class="text-[11px] text-muted-foreground">Wide pages</p>
<div class="grid grid-cols-2 gap-1">
<button
v-for="opt in WIDE_PAGE_OPTIONS"
:key="opt.value"
class="h-7 px-2 rounded-md border text-[11px] transition-colors flex items-center justify-center gap-1"
:class="
widePageSingletonMode === opt.value
? 'border-primary text-primary bg-primary/10'
: 'border-border text-muted-foreground hover:text-foreground'
"
@click.stop="setWidePageMode(opt.value)"
>
<component :is="opt.icon" :size="11" />
{{ opt.label }}
</button>
<div class="grid grid-cols-3 gap-1 rounded-lg bg-muted/55 p-1">
<button
class="flex h-8.5 min-w-0 items-center justify-center gap-1.5 rounded-lg px-2 text-[12px] font-medium leading-none transition-colors"
:class="
settingsTab === 'view'
? 'bg-background text-foreground shadow-sm ring-1 ring-border'
: 'text-muted-foreground hover:text-foreground hover:bg-background/70'
"
@click.stop="setSettingsTab('view')"
>
<ImageIcon :size="13" />
<span class="truncate whitespace-nowrap">View</span>
</button>
<button
class="flex h-8.5 min-w-0 items-center justify-center gap-1.5 rounded-lg px-2 text-[12px] font-medium leading-none transition-colors"
:class="
settingsTab === 'reading'
? 'bg-background text-foreground shadow-sm ring-1 ring-border'
: 'text-muted-foreground hover:text-foreground hover:bg-background/70'
"
@click.stop="setSettingsTab('reading')"
>
<ScanLine :size="13" />
<span class="truncate whitespace-nowrap">Reading</span>
</button>
<button
class="flex h-8.5 min-w-0 items-center justify-center gap-1.5 rounded-lg px-2 text-[12px] font-medium leading-none transition-colors"
:class="
settingsTab === 'layout'
? 'bg-background text-foreground shadow-sm ring-1 ring-border'
: 'text-muted-foreground hover:text-foreground hover:bg-background/70'
"
@click.stop="setSettingsTab('layout')"
>
<LayoutGrid :size="13" />
<span class="truncate whitespace-nowrap">Layout</span>
</button>
</div>
</div>
</div>
<DropdownMenuSeparator class="my-1" />
<DropdownMenuItem class="text-xs text-destructive focus:text-destructive" @select.prevent="confirmResetBookViewSettings">
Reset book view settings
</DropdownMenuItem>
<div ref="settingsContentRef" class="overflow-y-auto p-5.5 space-y-6" @scroll="onSettingsContentScroll">
<template v-if="settingsTab === 'view'">
<div class="space-y-6">
<div>
<p class="mb-2 text-[13px] font-medium text-foreground/90">Fit mode</p>
<div class="grid grid-cols-2 gap-1 rounded-lg bg-muted/55 p-1">
<button
v-for="opt in FIT_OPTIONS"
:key="opt.value"
class="flex h-[2.125rem] min-w-0 items-center justify-center gap-1.5 rounded-lg px-2.5 text-[12px] font-medium leading-none transition-colors"
:class="
fitMode === opt.value
? 'bg-background text-foreground shadow-sm ring-1 ring-border'
: 'text-muted-foreground hover:text-foreground hover:bg-background/70'
"
@click.stop="setFitMode(opt.value)"
>
<component :is="opt.icon" :size="13" />
<span class="truncate whitespace-nowrap">{{ opt.label }}</span>
</button>
</div>
</div>
<div class="h-px bg-border/70" />
<div>
<p class="mb-2 text-[13px] font-medium text-foreground/90">Background</p>
<div class="grid grid-cols-3 gap-1 rounded-lg bg-muted/55 p-1">
<button
v-for="opt in BG_OPTIONS"
:key="opt.value"
class="flex h-[2.125rem] min-w-0 items-center justify-center gap-1.5 rounded-lg px-2.5 text-[12px] font-medium leading-none transition-colors"
:class="
bgColor === opt.value
? 'bg-background text-foreground shadow-sm ring-1 ring-border'
: 'text-muted-foreground hover:text-foreground hover:bg-background/70'
"
@click.stop="setBgColor(opt.value)"
>
<component :is="opt.icon" :size="13" />
<span class="truncate whitespace-nowrap">{{ opt.label }}</span>
</button>
</div>
</div>
</div>
</template>
<template v-else-if="settingsTab === 'reading'">
<div class="space-y-6">
<div>
<p class="mb-2 text-[13px] font-medium text-foreground/90">Scroll mode</p>
<div class="grid grid-cols-3 gap-1 rounded-lg bg-muted/55 p-1">
<button
v-for="opt in SCROLL_OPTIONS"
:key="opt.value"
class="flex h-[2.125rem] min-w-0 items-center justify-center gap-1.5 rounded-lg px-2 text-[12px] font-medium leading-none transition-colors"
:class="
scrollMode === opt.value
? 'bg-background text-foreground shadow-sm ring-1 ring-border'
: 'text-muted-foreground hover:text-foreground hover:bg-background/70'
"
@click.stop="setScrollMode(opt.value)"
>
<component :is="opt.icon" :size="13" />
<span class="truncate whitespace-nowrap">{{ opt.label }}</span>
</button>
</div>
</div>
<div class="h-px bg-border/70" />
<div>
<p class="mb-2 text-[13px] font-medium text-foreground/90">Reading direction</p>
<div class="grid grid-cols-2 gap-1 rounded-lg bg-muted/55 p-1">
<button
v-for="opt in DIRECTION_OPTIONS"
:key="opt.value"
class="flex h-[2.125rem] min-w-0 items-center justify-center gap-1.5 rounded-lg px-2.5 text-[12px] font-medium leading-none transition-colors"
:class="
direction === opt.value
? 'bg-background text-foreground shadow-sm ring-1 ring-border'
: 'text-muted-foreground hover:text-foreground hover:bg-background/70'
"
@click.stop="setDirection(opt.value)"
>
<component :is="opt.icon" :size="13" />
<span class="truncate whitespace-nowrap">{{ opt.label }}</span>
</button>
</div>
</div>
</div>
</template>
<template v-else>
<div class="space-y-6">
<div>
<div class="mb-2 flex items-center justify-between">
<p class="text-[13px] font-medium text-foreground/90">Page view</p>
<span
v-if="showAutoFallbackBadge"
class="inline-flex items-center gap-1 rounded-full border border-border bg-muted px-2 py-0.5 text-[10px] text-muted-foreground"
>
<Info :size="11" />
Auto-fallback
</span>
</div>
<div class="grid grid-cols-2 gap-1 rounded-lg bg-muted/55 p-1">
<button
v-for="opt in VIEW_OPTIONS"
:key="opt.value"
class="flex h-[2.125rem] min-w-0 items-center justify-center gap-1.5 rounded-lg px-2.5 text-[12px] font-medium leading-none transition-colors"
:class="
viewMode === opt.value
? 'bg-background text-foreground shadow-sm ring-1 ring-border'
: 'text-muted-foreground hover:text-foreground hover:bg-background/70'
"
@click.stop="setViewMode(opt.value)"
>
<component :is="opt.icon" :size="13" />
<span class="truncate whitespace-nowrap">{{ opt.label }}</span>
</button>
</div>
</div>
<div class="h-px bg-border/70" />
<div v-if="showSpreadAlignmentControl">
<p class="mb-2 text-[13px] font-medium text-foreground/90">Spread alignment</p>
<div class="grid grid-cols-2 gap-1 rounded-lg bg-muted/55 p-1">
<button
v-for="opt in SPREAD_ALIGNMENT_OPTIONS"
:key="opt.value"
class="flex h-[2.125rem] min-w-0 items-center justify-center gap-1.5 rounded-lg px-2.5 text-[12px] font-medium leading-none transition-colors"
:class="
spreadAlignment === opt.value
? 'bg-background text-foreground shadow-sm ring-1 ring-border'
: 'text-muted-foreground hover:text-foreground hover:bg-background/70'
"
@click.stop="setSpreadAlignment(opt.value)"
>
<component :is="opt.icon" :size="13" />
<span class="truncate whitespace-nowrap">{{ opt.label }}</span>
</button>
</div>
</div>
<div v-else-if="showSpreadAlignmentHint" class="rounded-lg border border-border/70 bg-muted/35 px-3 py-2">
<p class="text-xs leading-tight text-muted-foreground">Spread alignment is unavailable in auto-fallback mode.</p>
<button class="mt-1 text-xs text-primary hover:underline" @click.stop="focusForceTwoPageFromHint">Focus two-page toggle</button>
</div>
<div>
<p class="mb-2 text-[13px] font-medium text-foreground/90">Force two-page</p>
<div class="grid grid-cols-2 gap-1 rounded-lg bg-muted/55 p-1">
<button
ref="forceTwoPageToggleButton"
class="flex h-[2.125rem] min-w-0 items-center justify-center rounded-lg px-2.5 text-[12px] font-medium leading-none transition-colors"
:class="[
!forceTwoPage
? 'bg-background text-foreground shadow-sm ring-1 ring-border'
: 'text-muted-foreground hover:text-foreground hover:bg-background/70',
highlightForceTwoPage ? 'ring-2 ring-primary/50' : '',
]"
@click.stop="setForceTwoPage(false)"
>
Off
</button>
<button
class="flex h-[2.125rem] min-w-0 items-center justify-center rounded-lg px-2.5 text-[12px] font-medium leading-none transition-colors"
:class="
forceTwoPage
? 'bg-background text-foreground shadow-sm ring-1 ring-border'
: 'text-muted-foreground hover:text-foreground hover:bg-background/70'
"
@click.stop="setForceTwoPage(true)"
>
On
</button>
</div>
</div>
<div>
<p class="mb-2 text-[13px] font-medium text-foreground/90">Wide pages</p>
<div class="grid grid-cols-2 gap-1 rounded-lg bg-muted/55 p-1">
<button
v-for="opt in WIDE_PAGE_OPTIONS"
:key="opt.value"
class="flex h-[2.125rem] min-w-0 items-center justify-center gap-1.5 rounded-lg px-2.5 text-[12px] font-medium leading-none transition-colors"
:class="
widePageSingletonMode === opt.value
? 'bg-background text-foreground shadow-sm ring-1 ring-border'
: 'text-muted-foreground hover:text-foreground hover:bg-background/70'
"
@click.stop="setWidePageMode(opt.value)"
>
<component :is="opt.icon" :size="13" />
<span class="truncate whitespace-nowrap">{{ opt.label }}</span>
</button>
</div>
</div>
<div class="h-px bg-border/70" />
<button
class="w-full rounded-lg border border-destructive/40 px-3 py-2 text-sm font-medium text-destructive transition-colors hover:bg-destructive/10"
@click.stop="confirmResetBookViewSettings"
>
Reset book view settings
</button>
</div>
</template>
</div>
</section>
</DropdownMenuContent>
</DropdownMenu>
</div>
@@ -798,24 +922,29 @@ onUnmounted(() => {
class="absolute bottom-0 inset-x-0 z-50 transition-all duration-300"
:class="footerVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-full pointer-events-none'"
>
<div class="h-14 flex items-center gap-3 px-4 bg-background/90 backdrop-blur-md border-t border-border">
<Tooltip>
<TooltipTrigger as-child>
<button class="viewer-btn" @click="goToPage(0)"><ChevronsLeft :size="16" /></button>
</TooltipTrigger>
<TooltipContent>First page</TooltipContent>
</Tooltip>
<div class="h-12 sm:h-14 flex items-center gap-1.5 px-2 sm:gap-3 sm:px-4 bg-background/90 backdrop-blur-md border-t border-border">
<div class="hidden sm:block">
<Tooltip>
<TooltipTrigger as-child>
<button class="viewer-btn" @click="goToPage(0)"><ChevronsLeft :size="16" /></button>
</TooltipTrigger>
<TooltipContent>First page</TooltipContent>
</Tooltip>
</div>
<button class="viewer-btn" :disabled="!canGoPrev" @click="prevPage"><ChevronLeft :size="16" /></button>
<div class="flex-1 flex flex-col justify-center gap-0.5">
<div class="relative flex-1 min-w-0 flex items-center h-6">
<input
type="range"
:min="0"
:max="Math.max(0, pageCount - 1)"
:value="currentPage"
list="cbz-ticks"
class="flex-1 w-full cursor-pointer"
style="accent-color: var(--primary)"
class="w-full h-1 rounded-full cursor-pointer"
:style="{
accentColor: 'var(--primary)',
background: `linear-gradient(to right, var(--primary) ${sliderFillPercent}%, var(--border) ${sliderFillPercent}%)`,
}"
@input="goToPage(Number(($event.target as HTMLInputElement).value))"
/>
<datalist id="cbz-ticks">
@@ -824,12 +953,14 @@ onUnmounted(() => {
</div>
<button class="viewer-btn" :disabled="!canGoNext" @click="nextPage"><ChevronRight :size="16" /></button>
<Tooltip>
<TooltipTrigger as-child>
<button class="viewer-btn" @click="goToPage(pageCount - 1)"><ChevronsRight :size="16" /></button>
</TooltipTrigger>
<TooltipContent>Last page</TooltipContent>
</Tooltip>
<div class="hidden sm:block">
<Tooltip>
<TooltipTrigger as-child>
<button class="viewer-btn" @click="goToPage(pageCount - 1)"><ChevronsRight :size="16" /></button>
</TooltipTrigger>
<TooltipContent>Last page</TooltipContent>
</Tooltip>
</div>
</div>
</div>
@@ -22,7 +22,9 @@ function onSeek(e: Event) {
</script>
<template>
<footer class="fixed bottom-0 left-0 right-0 h-14 z-50 flex items-center gap-3 px-4 bg-background/90 backdrop-blur-md border-t border-border">
<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"
>
<Tooltip>
<TooltipTrigger as-child>
<button class="viewer-btn" :disabled="sectionIndex === 0" @click="emit('prevSection')">
@@ -34,74 +34,85 @@ onUnmounted(() => document.removeEventListener('fullscreenchange', onFullscreenC
</script>
<template>
<header class="fixed top-0 left-0 right-0 h-12 z-50 flex items-center px-3 gap-1 bg-background/90 backdrop-blur-md border-b border-border">
<Tooltip>
<TooltipTrigger as-child>
<button class="viewer-btn" @click="emit('back')">
<ArrowLeft :size="18" />
</button>
</TooltipTrigger>
<TooltipContent>Go back</TooltipContent>
</Tooltip>
<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"
>
<!-- Left button group -->
<div class="flex items-center gap-1 shrink-0">
<Tooltip>
<TooltipTrigger as-child>
<button class="viewer-btn" @click="emit('back')">
<ArrowLeft :size="18" />
</button>
</TooltipTrigger>
<TooltipContent>Go back</TooltipContent>
</Tooltip>
<div class="viewer-sep" />
<div class="viewer-sep" />
<Tooltip>
<TooltipTrigger as-child>
<button class="viewer-btn" @click="emit('toggleSidebar')">
<BookOpen :size="18" />
</button>
</TooltipTrigger>
<TooltipContent>Table of contents</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button class="viewer-btn" @click="emit('toggleSidebar')">
<BookOpen :size="18" />
</button>
</TooltipTrigger>
<TooltipContent>Table of contents</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button class="viewer-btn" :class="isBookmarked ? '!text-primary' : ''" @click="emit('toggleBookmark')">
<BookmarkCheck v-if="isBookmarked" :size="18" />
<Bookmark v-else :size="18" />
</button>
</TooltipTrigger>
<TooltipContent>Toggle bookmark</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button class="viewer-btn" @click="emit('toggleSearch')">
<Search :size="18" />
</button>
</TooltipTrigger>
<TooltipContent>Search</TooltipContent>
</Tooltip>
<div class="flex-1 min-w-0 px-3">
<p class="text-sm font-serif font-medium truncate text-center text-muted-foreground">{{ chapterTitle }}</p>
<Tooltip>
<TooltipTrigger as-child>
<button class="viewer-btn" :class="isBookmarked ? '!text-primary' : ''" @click="emit('toggleBookmark')">
<BookmarkCheck v-if="isBookmarked" :size="18" />
<Bookmark v-else :size="18" />
</button>
</TooltipTrigger>
<TooltipContent>Toggle bookmark</TooltipContent>
</Tooltip>
</div>
<Tooltip>
<TooltipTrigger as-child>
<button class="viewer-btn" @click="emit('toggleFullscreen')">
<Minimize v-if="isFullscreen" :size="18" />
<Maximize v-else :size="18" />
</button>
</TooltipTrigger>
<TooltipContent>{{ isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen' }}</TooltipContent>
</Tooltip>
<!-- 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>
</div>
<DropdownMenu :open="props.settingsOpen" @update:open="onSettingsOpenChange">
<DropdownMenuTrigger as-child>
<button class="viewer-btn" :class="props.settingsOpen ? '!bg-muted !text-foreground' : ''" title="Settings" aria-label="Reader settings">
<Settings :size="18" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
side="bottom"
:side-offset="10"
class="w-[22rem] max-w-[calc(100vw-1rem)] max-h-[min(80vh,38rem)] rounded-lg border-border bg-card p-0 shadow-2xl overflow-hidden"
>
<slot name="settingsPanel" />
</DropdownMenuContent>
</DropdownMenu>
<!-- 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')">
<Search :size="18" />
</button>
</TooltipTrigger>
<TooltipContent>Search</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button class="viewer-btn" @click="emit('toggleFullscreen')">
<Minimize v-if="isFullscreen" :size="18" />
<Maximize v-else :size="18" />
</button>
</TooltipTrigger>
<TooltipContent>{{ isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen' }}</TooltipContent>
</Tooltip>
<DropdownMenu :open="props.settingsOpen" @update:open="onSettingsOpenChange">
<DropdownMenuTrigger as-child>
<button class="viewer-btn" :class="props.settingsOpen ? '!bg-muted !text-foreground' : ''" title="Settings" aria-label="Reader settings">
<Settings :size="18" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
side="bottom"
:side-offset="10"
class="w-[22rem] max-w-[calc(100vw-1rem)] max-h-[min(80vh,38rem)] rounded-lg border-border bg-card p-0 shadow-2xl overflow-hidden"
>
<slot name="settingsPanel" />
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
</template>
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ChevronLeft, Loader2, Search, X } from 'lucide-vue-next'
import { computed, onUnmounted, ref, watch } from 'vue'
import { ChevronRight, Loader2, Search, X } from 'lucide-vue-next'
import type { SearchResult } from '../composables/useSearch'
import { MIN_SEARCH_QUERY_LENGTH } from '../composables/useSearch'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
const props = defineProps<{
@@ -18,19 +19,30 @@ const emit = defineEmits<{
}>()
const inputValue = ref(props.initialQuery ?? '')
const trimmedInputValue = computed(() => inputValue.value.trim())
const isTooShort = computed(() => trimmedInputValue.value.length > 0 && trimmedInputValue.value.length < MIN_SEARCH_QUERY_LENGTH)
let debounceTimer: ReturnType<typeof setTimeout> | null = null
watch(inputValue, (val) => {
if (debounceTimer) clearTimeout(debounceTimer)
if (!val.trim()) {
const normalized = val.trim()
if (!normalized) {
emit('clear')
return
}
if (normalized.length < MIN_SEARCH_QUERY_LENGTH) {
emit('clear')
return
}
debounceTimer = setTimeout(() => {
emit('search', val.trim())
emit('search', normalized)
}, 600)
})
onUnmounted(() => {
if (debounceTimer) clearTimeout(debounceTimer)
})
function onClear() {
inputValue.value = ''
emit('clear')
@@ -38,8 +50,10 @@ function onClear() {
</script>
<template>
<div class="fixed inset-0 z-50 flex">
<div class="search-panel w-80 h-full bg-card text-card-foreground flex flex-col shadow-2xl border-r border-border" @click.stop>
<div class="fixed inset-0 z-50 flex justify-end">
<div class="flex-1" @click="emit('close')" />
<div class="search-panel w-80 h-full bg-card text-card-foreground flex flex-col shadow-2xl border-l border-border" @click.stop>
<div class="flex items-center gap-2 px-3 py-2.5 border-b border-border shrink-0">
<Tooltip>
<TooltipTrigger as-child>
@@ -47,7 +61,7 @@ function onClear() {
class="flex items-center justify-center w-7 h-7 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors shrink-0"
@click="emit('close')"
>
<ChevronLeft :size="18" />
<ChevronRight :size="18" />
</button>
</TooltipTrigger>
<TooltipContent>Close</TooltipContent>
@@ -56,7 +70,7 @@ function onClear() {
<input
v-model="inputValue"
type="text"
placeholder="Search in book..."
placeholder="Search in book (min 2 chars)..."
class="flex-1 min-w-0 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
autofocus
/>
@@ -75,11 +89,15 @@ function onClear() {
Searching…
</div>
<div v-else-if="inputValue && !isSearching && results.length === 0" class="px-4 py-8 text-center text-sm text-muted-foreground">
<div v-else-if="isTooShort" class="px-4 py-8 text-center text-sm text-muted-foreground">
Type at least {{ MIN_SEARCH_QUERY_LENGTH }} characters
</div>
<div v-else-if="trimmedInputValue && !isSearching && results.length === 0" class="px-4 py-8 text-center text-sm text-muted-foreground">
No results found
</div>
<div v-else-if="!inputValue" class="px-4 py-8 text-center text-sm text-muted-foreground">Type to search</div>
<div v-else-if="!trimmedInputValue" class="px-4 py-8 text-center text-sm text-muted-foreground">Type to search</div>
<ul v-else class="divide-y divide-border">
<li
@@ -104,19 +122,17 @@ function onClear() {
</p>
</div>
</div>
<div class="flex-1" @click="emit('close')" />
</div>
</template>
<style scoped>
.search-panel {
animation: slideInFromLeft 0.25s ease;
animation: slideInFromRight 0.25s ease;
}
@keyframes slideInFromLeft {
@keyframes slideInFromRight {
from {
transform: translateX(-100%);
transform: translateX(100%);
}
to {
transform: translateX(0);
@@ -1,6 +1,7 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import ReaderSearchPanel from '../ReaderSearchPanel.vue'
import { MIN_SEARCH_QUERY_LENGTH } from '../../composables/useSearch'
const globalStubs = {
stubs: {
@@ -33,6 +34,27 @@ describe('ReaderSearchPanel', () => {
vi.useRealTimers()
})
it('requires minimum query length before searching', async () => {
vi.useFakeTimers()
const wrapper = mount(ReaderSearchPanel, {
props: {
results: [],
isSearching: false,
},
global: globalStubs,
})
await wrapper.get('input').setValue('x'.repeat(MIN_SEARCH_QUERY_LENGTH - 1))
vi.advanceTimersByTime(700)
expect(wrapper.emitted('search')).toBeUndefined()
expect(wrapper.emitted('clear')?.length).toBe(1)
expect(wrapper.text()).toContain(`Type at least ${MIN_SEARCH_QUERY_LENGTH} characters`)
vi.useRealTimers()
})
it('emits clear when input becomes empty and when clear button is clicked', async () => {
const wrapper = mount(ReaderSearchPanel, {
props: {
@@ -72,8 +94,9 @@ describe('ReaderSearchPanel', () => {
await wrapper.get('li').trigger('click')
expect(wrapper.emitted('navigate')?.[0]).toEqual(['epubcfi(/6/4)'])
const overlays = wrapper.findAll('.flex-1')
await overlays[overlays.length - 1]!.trigger('click')
const dismissOverlay = wrapper.find('.fixed.inset-0.z-50.flex.justify-end > .flex-1')
expect(dismissOverlay.exists()).toBe(true)
await dismissOverlay.trigger('click')
expect(wrapper.emitted('close')?.length).toBe(1)
})
})
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { useSearch, type FoliateView } from '../useSearch'
import { MIN_SEARCH_QUERY_LENGTH, useSearch, type FoliateView } from '../useSearch'
describe('useSearch', () => {
it('returns early for empty query and does not call foliate search', async () => {
@@ -20,6 +20,25 @@ describe('useSearch', () => {
expect(store.isSearching.value).toBe(false)
})
it('returns early for short query and does not call foliate search', async () => {
const view: FoliateView = {
search: vi.fn<(opts: { query: string }) => AsyncGenerator<Record<string, unknown>, void, unknown>>(async function* () {
yield {}
}),
clearSearch: vi.fn<() => void>(),
}
const store = useSearch()
store.results.value = [{ cfi: 'x', excerpt: { pre: '', match: 'x', post: '' } }]
await store.search(view, 'x'.repeat(MIN_SEARCH_QUERY_LENGTH - 1))
expect(view.search).not.toHaveBeenCalled()
expect(view.clearSearch).toHaveBeenCalledTimes(1)
expect(store.results.value).toEqual([])
expect(store.isSearching.value).toBe(false)
})
it('maps yielded sections into flattened search results', async () => {
const view: FoliateView = {
search: async function* () {
@@ -1,5 +1,7 @@
import { ref } from 'vue'
export const MIN_SEARCH_QUERY_LENGTH = 2
export interface SearchExcerpt {
pre: string
match: string
@@ -30,8 +32,13 @@ export function useSearch() {
let cancelled = false
async function search(view: FoliateView, q: string) {
if (!q.trim()) {
const normalized = q.trim()
if (normalized.length < MIN_SEARCH_QUERY_LENGTH) {
cancelled = true
isSearching.value = false
results.value = []
query.value = normalized
view.clearSearch?.()
return
}
@@ -39,12 +46,12 @@ export function useSearch() {
await Promise.resolve()
cancelled = false
query.value = q
query.value = normalized
results.value = []
isSearching.value = true
try {
const generator = view.search({ query: q })
const generator = view.search({ query: normalized })
for await (const section of generator) {
if (cancelled) break
if (!section || typeof section !== 'object' || !('subitems' in section)) continue
@@ -348,10 +348,19 @@ onUnmounted(() => {
/>
<!-- Body: sidebar + pages -->
<div class="flex flex-1 min-h-0 min-w-0">
<div class="flex-1 min-h-0 min-w-0 relative">
<!-- Mobile sidebar mask -->
<button
v-if="showSidebar"
class="absolute inset-0 z-10 bg-black/35 md:hidden"
aria-label="Close sidebar overlay"
@click="showSidebar = false"
/>
<!-- Sidebar -->
<PdfSidebar
v-if="showSidebar"
class="absolute left-0 top-0 bottom-0 z-20 shadow-xl"
:total-pages="totalPages"
:current-page="currentPage"
:outline="outline.outline.value"
@@ -360,7 +369,7 @@ onUnmounted(() => {
/>
<!-- PDF Viewport -->
<div class="flex-1 min-w-0 relative">
<div class="h-full w-full min-w-0 relative">
<div v-if="error" class="absolute inset-0 flex items-center justify-center p-8 text-center bg-background">
<div>
<p class="text-sm font-medium text-foreground mb-1">Failed to load PDF</p>
@@ -42,53 +42,59 @@ defineExpose({
</script>
<template>
<div class="flex items-center gap-1.5 px-3 py-1.5 shrink-0 bg-card border-b border-border">
<input
ref="inputRef"
v-model="query"
type="search"
placeholder="Find in document..."
class="flex-1 min-w-0 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground/70"
style="max-width: 260px"
@keydown.enter.exact="emit('next')"
@keydown.enter.shift.exact.prevent="emit('prev')"
@keydown.escape="emit('close')"
/>
<div class="px-2.5 py-2 sm:px-3 sm:py-1.5 shrink-0 bg-card border-b border-border">
<div class="flex items-center gap-1.5 min-w-0">
<input
ref="inputRef"
v-model="query"
type="search"
placeholder="Find in document..."
class="flex-1 min-w-0 sm:max-w-[260px] bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground/70"
@keydown.enter.exact="emit('next')"
@keydown.enter.shift.exact.prevent="emit('prev')"
@keydown.escape="emit('close')"
/>
<!-- Match count -->
<span class="text-xs text-muted-foreground tabular-nums shrink-0 min-w-[48px]">
<template v-if="query && matchCount > 0">{{ currentIndex + 1 }} of {{ matchCount }}</template>
<template v-else-if="query && matchCount === 0">No results</template>
</span>
<!-- Match count -->
<span class="text-xs text-muted-foreground tabular-nums text-center shrink-0 min-w-[52px]">
<template v-if="query && matchCount > 0">{{ currentIndex + 1 }} of {{ matchCount }}</template>
<template v-else-if="query && matchCount === 0">No results</template>
</span>
<!-- Prev / Next -->
<button class="viewer-btn w-7 h-7" :disabled="matchCount === 0" title="Previous match" @click="emit('prev')">
<ChevronUp :size="13" />
</button>
<button class="viewer-btn w-7 h-7" :disabled="matchCount === 0" title="Next match" @click="emit('next')">
<ChevronDown :size="13" />
</button>
<!-- Prev / Next -->
<button class="viewer-btn w-7 h-7" :disabled="matchCount === 0" title="Previous match" @click="emit('prev')">
<ChevronUp :size="13" />
</button>
<button class="viewer-btn w-7 h-7" :disabled="matchCount === 0" title="Next match" @click="emit('next')">
<ChevronDown :size="13" />
</button>
<div class="w-px h-4 bg-border mx-0.5 shrink-0" />
<div class="hidden sm:block w-px h-4 bg-border mx-0.5 shrink-0" />
<!-- Options -->
<label class="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground cursor-pointer select-none transition-colors">
<input v-model="matchCase" type="checkbox" class="w-3 h-3" style="accent-color: var(--primary)" />
Match Case
</label>
<label class="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground cursor-pointer select-none transition-colors">
<input v-model="wholeWord" type="checkbox" class="w-3 h-3" style="accent-color: var(--primary)" />
Whole Words
</label>
<label class="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground cursor-pointer select-none transition-colors">
<input v-model="highlightAll" type="checkbox" class="w-3 h-3" style="accent-color: var(--primary)" />
Highlight All
</label>
<button class="viewer-btn w-7 h-7" title="Close find bar" @click="emit('close')">
<X :size="13" />
</button>
</div>
<div class="w-px h-4 bg-border mx-0.5 shrink-0" />
<button class="viewer-btn w-7 h-7" title="Close find bar" @click="emit('close')">
<X :size="13" />
</button>
<div class="mt-2 sm:mt-1 flex items-center gap-3 overflow-x-auto pb-0.5 sm:pb-0">
<label
class="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground cursor-pointer select-none transition-colors whitespace-nowrap"
>
<input v-model="matchCase" type="checkbox" class="w-3 h-3 shrink-0" style="accent-color: var(--primary)" />
Match Case
</label>
<label
class="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground cursor-pointer select-none transition-colors whitespace-nowrap"
>
<input v-model="wholeWord" type="checkbox" class="w-3 h-3 shrink-0" style="accent-color: var(--primary)" />
Whole Words
</label>
<label
class="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground cursor-pointer select-none transition-colors whitespace-nowrap"
>
<input v-model="highlightAll" type="checkbox" class="w-3 h-3 shrink-0" style="accent-color: var(--primary)" />
Highlight All
</label>
</div>
</div>
</template>
@@ -15,13 +15,17 @@ import {
Maximize,
Minimize,
Minus,
MoveHorizontal,
MoreHorizontal,
MousePointer,
PanelLeft,
PanelLeftOpen,
PanelRightOpen,
Plus,
Ratio,
Search,
ScanLine,
WrapText,
Square,
} from 'lucide-vue-next'
import {
DropdownMenu,
@@ -88,6 +92,19 @@ const ZOOM_PERCENT_PRESETS = [
{ label: '400%', value: '4' },
] as const
const ZOOM_MODE_PRESETS: { label: string; value: 'fit-page' | 'fit-width'; icon: Component }[] = [
{ label: 'Page Fit', value: 'fit-page', icon: Ratio },
{ label: 'Page Width', value: 'fit-width', icon: MoveHorizontal },
]
const MOBILE_ZOOM_PERCENT_PRESETS = [
{ label: '75%', value: '0.75' },
{ label: '100%', value: '1' },
{ label: '125%', value: '1.25' },
{ label: '150%', value: '1.5' },
{ label: '200%', value: '2' },
] as const
const SCROLL_MODES: { label: string; value: ScrollMode; icon: Component }[] = [
{ label: 'Vertical Scrolling', value: 'vertical', icon: ArrowDownUp },
{ label: 'Horizontal Scrolling', value: 'horizontal', icon: ArrowLeftRight },
@@ -95,10 +112,10 @@ const SCROLL_MODES: { label: string; value: ScrollMode; icon: Component }[] = [
{ label: 'Page Scrolling', value: 'page', icon: ScanLine },
]
const SPREAD_MODES = [
{ label: 'No Spreads', value: 'none' as const },
{ label: 'Odd Spreads', value: 'odd' as const },
{ label: 'Even Spreads', value: 'even' as const },
const SPREAD_MODES: { label: string; value: 'none' | 'odd' | 'even'; icon: Component }[] = [
{ label: 'No Spreads', value: 'none', icon: Square },
{ label: 'Odd Spreads', value: 'odd', icon: PanelLeftOpen },
{ label: 'Even Spreads', value: 'even', icon: PanelRightOpen },
]
function onPageInput(e: Event) {
@@ -161,7 +178,7 @@ function handleApplyZoomPreset(value: AcceptableValue) {
<TooltipContent>Previous Page</TooltipContent>
</Tooltip>
<div class="flex items-center gap-1 mx-0.5">
<div class="hidden md:flex items-center gap-1 mx-0.5">
<input
:value="pageInput"
type="number"
@@ -174,6 +191,7 @@ function handleApplyZoomPreset(value: AcceptableValue) {
<span class="text-muted-foreground text-xs">/</span>
<span class="text-muted-foreground text-xs tabular-nums">{{ totalPages }}</span>
</div>
<div class="md:hidden min-w-[3.6rem] text-center text-xs tabular-nums text-muted-foreground px-0.5">{{ currentPage }} / {{ totalPages }}</div>
<Tooltip>
<TooltipTrigger as-child>
@@ -184,69 +202,81 @@ function handleApplyZoomPreset(value: AcceptableValue) {
<TooltipContent>Next Page</TooltipContent>
</Tooltip>
<div class="viewer-sep" />
<div class="viewer-sep hidden md:block" />
<!-- Zoom controls -->
<Tooltip>
<TooltipTrigger as-child>
<button class="viewer-btn" @click="emit('zoomOut')"><Minus :size="13" /></button>
</TooltipTrigger>
<TooltipContent>Zoom Out</TooltipContent>
</Tooltip>
<div class="hidden md:block">
<Tooltip>
<TooltipTrigger as-child>
<button class="viewer-btn" @click="emit('zoomOut')"><Minus :size="13" /></button>
</TooltipTrigger>
<TooltipContent>Zoom Out</TooltipContent>
</Tooltip>
</div>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<button
class="flex items-center gap-1 px-2 h-7 rounded text-foreground/80 hover:text-foreground hover:bg-muted text-xs tabular-nums transition-colors"
style="min-width: 84px"
>
<span class="flex-1 text-center">{{ zoomLabel }}</span>
<ChevronDown :size="10" class="text-muted-foreground" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" class="min-w-[9rem]">
<DropdownMenuRadioGroup :model-value="currentZoomValue()" @update:model-value="handleApplyZoomPreset">
<DropdownMenuRadioItem value="fit-page" class="text-xs">Page Fit</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="fit-width" class="text-xs">Page Width</DropdownMenuRadioItem>
<DropdownMenuSeparator />
<DropdownMenuRadioItem v-for="preset in ZOOM_PERCENT_PRESETS" :key="preset.value" :value="preset.value" class="text-xs">
{{ preset.label }}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<div class="hidden md:block">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<button
class="flex items-center gap-1 px-2 h-7 rounded text-foreground/80 hover:text-foreground hover:bg-muted text-xs tabular-nums transition-colors"
style="min-width: 84px"
>
<span class="flex-1 text-center">{{ zoomLabel }}</span>
<ChevronDown :size="10" class="text-muted-foreground" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" class="min-w-[9rem]">
<DropdownMenuRadioGroup :model-value="currentZoomValue()" @update:model-value="handleApplyZoomPreset">
<DropdownMenuRadioItem v-for="preset in ZOOM_MODE_PRESETS" :key="preset.value" :value="preset.value" class="text-xs gap-2">
<component :is="preset.icon" :size="13" />
{{ preset.label }}
</DropdownMenuRadioItem>
<DropdownMenuSeparator />
<DropdownMenuRadioItem v-for="preset in ZOOM_PERCENT_PRESETS" :key="preset.value" :value="preset.value" class="text-xs">
{{ preset.label }}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
<Tooltip>
<TooltipTrigger as-child>
<button class="viewer-btn" @click="emit('zoomIn')"><Plus :size="13" /></button>
</TooltipTrigger>
<TooltipContent>Zoom In</TooltipContent>
</Tooltip>
<div class="hidden md:block">
<Tooltip>
<TooltipTrigger as-child>
<button class="viewer-btn" @click="emit('zoomIn')"><Plus :size="13" /></button>
</TooltipTrigger>
<TooltipContent>Zoom In</TooltipContent>
</Tooltip>
</div>
<!-- Spacer -->
<div class="flex-1" />
<!-- Right side controls -->
<Tooltip>
<TooltipTrigger as-child>
<button class="viewer-btn" @click="emit('toggleFullscreen')">
<Minimize v-if="isFullscreen" :size="14" />
<Maximize v-else :size="14" />
</button>
</TooltipTrigger>
<TooltipContent>{{ isFullscreen ? 'Exit Fullscreen' : 'Fullscreen' }}</TooltipContent>
</Tooltip>
<div class="hidden md:block">
<Tooltip>
<TooltipTrigger as-child>
<button class="viewer-btn" @click="emit('toggleFullscreen')">
<Minimize v-if="isFullscreen" :size="14" />
<Maximize v-else :size="14" />
</button>
</TooltipTrigger>
<TooltipContent>{{ isFullscreen ? 'Exit Fullscreen' : 'Fullscreen' }}</TooltipContent>
</Tooltip>
</div>
<Tooltip v-if="hasPermission('library_download')">
<TooltipTrigger as-child>
<a :href="`/api/v1/books/files/${fileId}/download`" class="viewer-btn flex items-center justify-center">
<Download :size="14" />
</a>
</TooltipTrigger>
<TooltipContent>Download</TooltipContent>
</Tooltip>
<div v-if="hasPermission('library_download')" class="hidden md:block">
<Tooltip>
<TooltipTrigger as-child>
<a :href="`/api/v1/books/files/${fileId}/download`" class="viewer-btn flex items-center justify-center">
<Download :size="14" />
</a>
</TooltipTrigger>
<TooltipContent>Download</TooltipContent>
</Tooltip>
</div>
<div class="viewer-sep" />
<div class="viewer-sep hidden md:block" />
<!-- Secondary toolbar (More tools) -->
<DropdownMenu>
@@ -255,7 +285,42 @@ function handleApplyZoomPreset(value: AcceptableValue) {
<MoreHorizontal :size="16" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-52">
<DropdownMenuContent align="end" class="w-52 max-h-[72vh] overflow-y-auto">
<div class="md:hidden">
<DropdownMenuGroup>
<DropdownMenuItem class="text-xs gap-2" @click="emit('zoomOut')"> <Minus :size="13" /> Zoom Out </DropdownMenuItem>
<DropdownMenuItem class="text-xs gap-2" @click="emit('zoomIn')"> <Plus :size="13" /> Zoom In </DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuLabel class="text-muted-foreground text-xs px-2 py-1">Zoom</DropdownMenuLabel>
<DropdownMenuRadioGroup :model-value="currentZoomValue()" @update:model-value="handleApplyZoomPreset">
<DropdownMenuRadioItem v-for="preset in ZOOM_MODE_PRESETS" :key="`mobile-${preset.value}`" :value="preset.value" class="text-xs gap-2">
<component :is="preset.icon" :size="13" />
{{ preset.label }}
</DropdownMenuRadioItem>
<DropdownMenuSeparator />
<DropdownMenuRadioItem
v-for="preset in MOBILE_ZOOM_PERCENT_PRESETS"
:key="`mobile-${preset.value}`"
:value="preset.value"
class="text-xs"
>
{{ preset.label }}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem class="text-xs gap-2" @click="emit('toggleFullscreen')">
<Minimize v-if="isFullscreen" :size="13" />
<Maximize v-else :size="13" />
{{ isFullscreen ? 'Exit Fullscreen' : 'Fullscreen' }}
</DropdownMenuItem>
<DropdownMenuItem v-if="hasPermission('library_download')" as-child class="text-xs gap-2">
<a :href="`/api/v1/books/files/${fileId}/download`"> <Download :size="13" /> Download </a>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
</div>
<DropdownMenuGroup>
<DropdownMenuItem class="text-xs gap-2" @click="emit('firstPage')"> <ChevronFirst :size="13" /> First Page </DropdownMenuItem>
<DropdownMenuItem class="text-xs gap-2" @click="emit('lastPage')"> <ChevronLast :size="13" /> Last Page </DropdownMenuItem>
@@ -283,8 +348,9 @@ function handleApplyZoomPreset(value: AcceptableValue) {
<DropdownMenuLabel class="text-muted-foreground text-xs px-2 py-1">Page Spread</DropdownMenuLabel>
<DropdownMenuRadioGroup :model-value="spread" @update:model-value="emit('update:spread', $event as 'none' | 'odd' | 'even')">
<DropdownMenuRadioItem v-for="sp in SPREAD_MODES" :key="sp.value" :value="sp.value" class="text-xs">
<WrapText :size="13" /> {{ sp.label }}
<DropdownMenuRadioItem v-for="sp in SPREAD_MODES" :key="sp.value" :value="sp.value" class="text-xs gap-2">
<component :is="sp.icon" :size="13" />
{{ sp.label }}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
@@ -63,13 +63,20 @@ export function usePdf() {
}
const dpr = window.devicePixelRatio || 1
const viewport = page.getViewport({ scale: scale * dpr })
canvas.width = viewport.width
canvas.height = viewport.height
canvas.style.width = `${Math.round(viewport.width / dpr)}px`
canvas.style.height = `${Math.round(viewport.height / dpr)}px`
renderTask = page.render({ canvas, viewport })
const renderCanvas = document.createElement('canvas')
renderCanvas.width = viewport.width
renderCanvas.height = viewport.height
renderTask = page.render({ canvas: renderCanvas, viewport })
try {
await renderTask.promise
if (cancelled) return
const ctx = canvas.getContext('2d')
if (!ctx) return
canvas.width = viewport.width
canvas.height = viewport.height
canvas.style.width = `${Math.round(viewport.width / dpr)}px`
canvas.style.height = `${Math.round(viewport.height / dpr)}px`
ctx.drawImage(renderCanvas, 0, 0)
} catch {
// cancelled or interrupted — not an error
} finally {
@@ -63,7 +63,8 @@ export function usePdfRenderer(
// Cancel any prior render on this canvas before starting a new one.
activeRenders.get(pageNum)?.cancel()
const task = startRenderPageFn(pageNum, canvas, scale.value)
const renderScale = scale.value
const task = startRenderPageFn(pageNum, canvas, renderScale)
activeRenders.set(pageNum, task)
await task.promise
@@ -72,7 +73,7 @@ export function usePdfRenderer(
activeRenders.delete(pageNum)
const dpr = window.devicePixelRatio || 1
onDimUpdate(pageNum, { width: canvas.width / (scale.value * dpr), height: canvas.height / (scale.value * dpr) })
onDimUpdate(pageNum, { width: canvas.width / (renderScale * dpr), height: canvas.height / (renderScale * dpr) })
rendered.add(pageNum)
await buildTextLayer(pageNum)
}
@@ -121,11 +121,11 @@ function setTab(tab: 'library' | 'user') {
<template>
<div class="flex flex-col gap-6 pt-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-1 rounded-lg bg-muted p-1">
<div class="flex w-full flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div class="flex w-full items-center gap-1 rounded-lg bg-muted p-1 sm:w-auto">
<button
:class="[
'rounded-md px-4 py-1.5 text-sm font-medium transition-colors',
'flex-1 rounded-md px-4 py-1.5 text-sm font-medium transition-colors sm:flex-none',
activeTab === 'library' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
]"
@click="setTab('library')"
@@ -134,7 +134,7 @@ function setTab(tab: 'library' | 'user') {
</button>
<button
:class="[
'rounded-md px-4 py-1.5 text-sm font-medium transition-colors',
'flex-1 rounded-md px-4 py-1.5 text-sm font-medium transition-colors sm:flex-none',
activeTab === 'user' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
]"
@click="setTab('user')"
@@ -143,16 +143,16 @@ function setTab(tab: 'library' | 'user') {
</button>
</div>
<div class="flex items-center gap-2">
<div class="flex w-full min-w-0 items-center gap-2 sm:w-auto sm:justify-end">
<Popover v-if="libraries.length > 1">
<PopoverTrigger as-child>
<button
:class="[
'flex h-8 items-center gap-1.5 rounded-md border px-3 text-sm transition-colors',
'flex h-8 min-w-0 flex-1 items-center justify-between gap-1.5 rounded-md border px-3 text-sm transition-colors sm:w-auto sm:flex-none sm:justify-start',
isFiltered ? 'border-primary/40 bg-primary/10 text-primary hover:bg-primary/15' : 'border-border text-foreground hover:bg-accent',
]"
>
{{ libraryLabel }}
<span class="truncate">{{ libraryLabel }}</span>
<ChevronDown class="size-3.5 opacity-60" />
</button>
</PopoverTrigger>
@@ -187,7 +187,7 @@ function setTab(tab: 'library' | 'user') {
</Popover>
<button
class="bg-muted text-foreground hover:bg-muted/70 flex h-8 items-center gap-1.5 rounded-md px-2.5 text-sm font-medium transition-colors"
class="bg-muted text-foreground hover:bg-muted/70 flex h-8 shrink-0 items-center gap-1.5 rounded-md px-2.5 text-sm font-medium transition-colors"
@click="openConfig"
>
<Settings2 class="size-3.5" />
@@ -199,7 +199,7 @@ function setTab(tab: 'library' | 'user') {
<StatisticsSummaryCard />
<Sheet v-model:open="configOpen">
<SheetContent side="right" class="w-[420px] sm:max-w-[420px]">
<SheetContent side="right" class="w-[90dvw] max-w-[90dvw] sm:w-[420px] sm:max-w-[420px]">
<SheetHeader>
<div class="flex items-center justify-between pr-8">
<SheetTitle>Configure Charts</SheetTitle>