mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user