style(reader): unify viewer chrome with dark header/footer and settings

This commit is contained in:
neon
2026-01-03 12:07:22 -06:00
parent 9ca37cfbec
commit e9300e7d85
9 changed files with 440 additions and 366 deletions
+9
View File
@@ -353,3 +353,12 @@ html.radius-rounded {
@apply bg-background text-foreground font-sans antialiased;
}
}
@layer components {
.viewer-btn {
@apply flex items-center justify-center w-8 h-8 rounded text-white/60 hover:text-white hover:bg-white/10 transition-colors shrink-0 disabled:opacity-30 disabled:cursor-not-allowed;
}
.viewer-sep {
@apply w-px h-5 mx-1 bg-white/15 shrink-0;
}
}
@@ -236,9 +236,6 @@ function navigateSearch(cfiTarget: string) {
<ReaderHeader
:chapterTitle="chapterTitle"
:isBookmarked="bookmarks.isCurrentCfiBookmarked.value"
:isDark="isDark"
:bgColor="activeMode.bg"
:fgColor="activeMode.fg"
class="transition-all duration-300"
:class="headerVisible ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-full pointer-events-none'"
@back="router.back()"
@@ -275,8 +272,6 @@ function navigateSearch(cfiTarget: string) {
:sectionIndex="sectionIndex"
:totalSections="totalSections"
:sectionFractions="sectionFractions"
:bgColor="activeMode.bg"
:fgColor="activeMode.fg"
class="transition-all duration-300"
:class="footerVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-full pointer-events-none'"
@prevSection="goToSection(sectionIndex - 1)"
@@ -208,13 +208,21 @@ onUnmounted(() => {
class="absolute top-0 inset-x-0 z-50 transition-all duration-300"
:class="headerVisible ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-full pointer-events-none'"
>
<div class="h-12 flex items-center gap-2 px-3" style="background: rgba(0, 0, 0, 0.85); backdrop-filter: blur(8px)">
<button class="cbz-btn" @click="router.back()"><ArrowLeft :size="16" /></button>
<div
class="h-12 flex items-center gap-2 px-3"
style="
background: rgba(18, 18, 20, 0.92);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
"
>
<button class="viewer-btn" @click="router.back()"><ArrowLeft :size="16" /></button>
<div class="flex-1 min-w-0 flex flex-col justify-center">
<span v-if="bookTitle" class="text-sm text-white/90 truncate leading-tight">{{ bookTitle }}</span>
<span class="text-xs text-white/40 tabular-nums">{{ currentPage + 1 }} / {{ pageCount }}</span>
</div>
<button class="cbz-btn" :class="showSettings ? 'bg-white/20 text-white' : ''" @click="showSettings = !showSettings">
<button class="viewer-btn" :class="showSettings ? 'bg-white/20 text-white' : ''" @click="showSettings = !showSettings">
<Settings :size="15" />
</button>
</div>
@@ -275,9 +283,17 @@ 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-16 flex items-center gap-3 px-4" style="background: rgba(0, 0, 0, 0.85); backdrop-filter: blur(8px)">
<button class="cbz-btn" title="First page" @click="goToPage(0)"><ChevronsLeft :size="16" /></button>
<button class="cbz-btn" :disabled="currentPage === 0" @click="prevPage"><ChevronLeft :size="16" /></button>
<div
class="h-14 flex items-center gap-3 px-4"
style="
background: rgba(18, 18, 20, 0.92);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border-top: 1px solid rgba(255, 255, 255, 0.08);
"
>
<button class="viewer-btn" title="First page" @click="goToPage(0)"><ChevronsLeft :size="16" /></button>
<button class="viewer-btn" :disabled="currentPage === 0" @click="prevPage"><ChevronLeft :size="16" /></button>
<div class="flex-1 flex flex-col justify-center gap-0.5">
<input
@@ -294,8 +310,8 @@ onUnmounted(() => {
</datalist>
</div>
<button class="cbz-btn" :disabled="currentPage >= pageCount - 1" @click="nextPage"><ChevronRight :size="16" /></button>
<button class="cbz-btn" title="Last page" @click="goToPage(pageCount - 1)"><ChevronsRight :size="16" /></button>
<button class="viewer-btn" :disabled="currentPage >= pageCount - 1" @click="nextPage"><ChevronRight :size="16" /></button>
<button class="viewer-btn" title="Last page" @click="goToPage(pageCount - 1)"><ChevronsRight :size="16" /></button>
</div>
</div>
@@ -337,10 +353,3 @@ onUnmounted(() => {
</div>
</div>
</template>
<style scoped>
@reference "tailwindcss";
.cbz-btn {
@apply flex items-center justify-center w-8 h-8 rounded text-white/60 hover:text-white hover:bg-white/10 transition-colors shrink-0 disabled:opacity-30 disabled:cursor-not-allowed;
}
</style>
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { X } from 'lucide-vue-next'
import { ref } from 'vue'
import { AlignJustify, Image, Palette, X } from 'lucide-vue-next'
import type { BgColor, Direction, FitMode, ScrollMode, ViewMode } from '../composables/useCbzSettings'
defineProps<{
@@ -19,6 +20,9 @@ const emit = defineEmits<{
'update:bgColor': [v: BgColor]
}>()
type Tab = 'view' | 'scroll' | 'display'
const activeTab = ref<Tab>('view')
const FIT_OPTIONS: { value: FitMode; label: string }[] = [
{ value: 'fit-page', label: 'Page' },
{ value: 'fit-width', label: 'Width' },
@@ -34,89 +38,158 @@ const SCROLL_OPTIONS: { value: ScrollMode; label: string }[] = [
</script>
<template>
<div
class="absolute inset-y-0 right-0 z-50 w-64 overflow-y-auto"
style="background: rgba(18, 18, 18, 0.97); border-left: 1px solid rgba(255, 255, 255, 0.08)"
@click.stop
>
<div class="flex items-center justify-between p-4 border-b border-white/10">
<span class="text-sm font-medium text-white">Settings</span>
<button class="cbz-btn w-7 h-7" @click="emit('close')"><X :size="14" /></button>
</div>
<div class="p-4 flex flex-col gap-5">
<!-- Fit Mode -->
<div>
<p class="text-xs text-white/40 mb-2 uppercase tracking-wide">Fit</p>
<div class="grid grid-cols-2 gap-1">
<div class="fixed inset-0 z-50 flex flex-col justify-end" @click.self="emit('close')">
<div
class="bg-card text-card-foreground rounded-t-xl max-h-[85vh] overflow-y-auto shadow-2xl border-t border-border w-full max-w-2xl mx-auto"
@click.stop
>
<div class="sticky top-0 bg-card z-10 flex items-center justify-between px-5 py-4 border-b border-border">
<div class="flex gap-1">
<button
v-for="opt in FIT_OPTIONS"
:key="opt.value"
class="setting-btn"
:class="fitMode === opt.value ? 'active' : ''"
@click="emit('update:fitMode', opt.value)"
v-for="tab in [
{ id: 'view', icon: Image, label: 'View' },
{ id: 'scroll', icon: AlignJustify, label: 'Scroll' },
{ id: 'display', icon: Palette, label: 'Display' },
] as const"
:key="tab.id"
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors"
:class="activeTab === tab.id ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground hover:bg-muted'"
@click="activeTab = tab.id"
>
{{ opt.label }}
<component :is="tab.icon" :size="14" />
{{ tab.label }}
</button>
</div>
<button
class="flex items-center justify-center w-8 h-8 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
@click="emit('close')"
>
<X :size="16" />
</button>
</div>
<!-- View Mode -->
<div>
<p class="text-xs text-white/40 mb-2 uppercase tracking-wide">View</p>
<div class="grid grid-cols-2 gap-1">
<button class="setting-btn" :class="viewMode === 'single' ? 'active' : ''" @click="emit('update:viewMode', 'single')">Single</button>
<button class="setting-btn" :class="viewMode === 'two-page' ? 'active' : ''" @click="emit('update:viewMode', 'two-page')">Two-Page</button>
</div>
</div>
<div class="px-5 py-5 space-y-6">
<template v-if="activeTab === 'view'">
<div>
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Fit Mode</p>
<div class="grid grid-cols-2 gap-2">
<button
v-for="opt in FIT_OPTIONS"
:key="opt.value"
class="py-2.5 rounded-xl text-sm font-medium border-2 transition-colors"
:class="
fitMode === opt.value
? 'bg-primary text-primary-foreground border-primary'
: 'border-border hover:border-muted-foreground/40 hover:bg-muted text-foreground'
"
@click="emit('update:fitMode', opt.value)"
>
{{ opt.label }}
</button>
</div>
</div>
<div>
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Pages</p>
<div class="grid grid-cols-2 gap-2">
<button
class="py-2.5 rounded-xl text-sm font-medium border-2 transition-colors"
:class="
viewMode === 'single'
? 'bg-primary text-primary-foreground border-primary'
: 'border-border hover:border-muted-foreground/40 hover:bg-muted text-foreground'
"
@click="emit('update:viewMode', 'single')"
>
Single
</button>
<button
class="py-2.5 rounded-xl text-sm font-medium border-2 transition-colors"
:class="
viewMode === 'two-page'
? 'bg-primary text-primary-foreground border-primary'
: 'border-border hover:border-muted-foreground/40 hover:bg-muted text-foreground'
"
@click="emit('update:viewMode', 'two-page')"
>
Two Page
</button>
</div>
</div>
</template>
<!-- Scroll Mode -->
<div>
<p class="text-xs text-white/40 mb-2 uppercase tracking-wide">Scroll</p>
<div class="flex flex-col gap-1">
<button
v-for="opt in SCROLL_OPTIONS"
:key="opt.value"
class="setting-btn"
:class="scrollMode === opt.value ? 'active' : ''"
@click="emit('update:scrollMode', opt.value)"
>
{{ opt.label }}
</button>
</div>
</div>
<template v-if="activeTab === 'scroll'">
<div>
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Scroll Mode</p>
<div class="flex flex-col gap-2">
<button
v-for="opt in SCROLL_OPTIONS"
:key="opt.value"
class="py-2.5 rounded-xl text-sm font-medium border-2 transition-colors"
:class="
scrollMode === opt.value
? 'bg-primary text-primary-foreground border-primary'
: 'border-border hover:border-muted-foreground/40 hover:bg-muted text-foreground'
"
@click="emit('update:scrollMode', opt.value)"
>
{{ opt.label }}
</button>
</div>
</div>
<div>
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Reading Direction</p>
<div class="grid grid-cols-2 gap-2">
<button
class="py-2.5 rounded-xl text-sm font-medium border-2 transition-colors"
:class="
direction === 'ltr'
? 'bg-primary text-primary-foreground border-primary'
: 'border-border hover:border-muted-foreground/40 hover:bg-muted text-foreground'
"
@click="emit('update:direction', 'ltr')"
>
LTR →
</button>
<button
class="py-2.5 rounded-xl text-sm font-medium border-2 transition-colors"
:class="
direction === 'rtl'
? 'bg-primary text-primary-foreground border-primary'
: 'border-border hover:border-muted-foreground/40 hover:bg-muted text-foreground'
"
@click="emit('update:direction', 'rtl')"
>
← RTL
</button>
</div>
</div>
</template>
<!-- Direction -->
<div>
<p class="text-xs text-white/40 mb-2 uppercase tracking-wide">Direction</p>
<div class="grid grid-cols-2 gap-1">
<button class="setting-btn" :class="direction === 'ltr' ? 'active' : ''" @click="emit('update:direction', 'ltr')">LTR →</button>
<button class="setting-btn" :class="direction === 'rtl' ? 'active' : ''" @click="emit('update:direction', 'rtl')">← RTL</button>
</div>
</div>
<!-- Background -->
<div>
<p class="text-xs text-white/40 mb-2 uppercase tracking-wide">Background</p>
<div class="grid grid-cols-3 gap-1">
<button class="setting-btn" :class="bgColor === 'black' ? 'active' : ''" @click="emit('update:bgColor', 'black')">Black</button>
<button class="setting-btn" :class="bgColor === 'gray' ? 'active' : ''" @click="emit('update:bgColor', 'gray')">Gray</button>
<button class="setting-btn" :class="bgColor === 'white' ? 'active' : ''" @click="emit('update:bgColor', 'white')">White</button>
</div>
<template v-if="activeTab === 'display'">
<div>
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Background</p>
<div class="grid grid-cols-3 gap-2">
<button
v-for="opt in [
{ value: 'black', label: 'Black' },
{ value: 'gray', label: 'Gray' },
{ value: 'white', label: 'White' },
] as const"
:key="opt.value"
class="py-2.5 rounded-xl text-sm font-medium border-2 transition-colors"
:class="
bgColor === opt.value
? 'bg-primary text-primary-foreground border-primary'
: 'border-border hover:border-muted-foreground/40 hover:bg-muted text-foreground'
"
@click="emit('update:bgColor', opt.value)"
>
{{ opt.label }}
</button>
</div>
</div>
</template>
</div>
</div>
</div>
</template>
<style scoped>
@reference "tailwindcss";
.cbz-btn {
@apply flex items-center justify-center w-8 h-8 rounded text-white/60 hover:text-white hover:bg-white/10 transition-colors shrink-0;
}
.setting-btn {
@apply px-2 py-1.5 rounded text-xs text-white/60 hover:text-white hover:bg-white/10 transition-colors text-center;
}
.setting-btn.active {
@apply bg-blue-500/20 text-blue-400 hover:bg-blue-500/30;
}
</style>
@@ -1,13 +1,14 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ArrowLeft, ChevronLeft, ChevronRight, LayoutGrid, Maximize, Minimize, Minus, Plus, RotateCw, Search, X } from 'lucide-vue-next'
import { ArrowLeft, ChevronLeft, ChevronRight, Maximize, Minimize, Minus, Plus, Search, Settings, X } from 'lucide-vue-next'
import { usePdf } from '../composables/usePdf'
import { usePdfZoom, ZOOM_PRESETS } from '../composables/usePdfZoom'
import { usePdfLayout, PAGE_GAP } from '../composables/usePdfLayout'
import { usePdfRenderer } from '../composables/usePdfRenderer'
import { useReaderProgress } from '../composables/useReaderProgress'
import { useVisibility } from '../composables/useVisibility'
import PdfSettingsPanel from './PdfSettingsPanel.vue'
import type { PageDim } from '../composables/usePdf'
const props = defineProps<{ bookId: number; fileId: number }>()
@@ -93,6 +94,9 @@ function toggleFullscreen() {
}
}
// ── Settings ─────────────────────────────────────────────────────────────────
const showSettings = ref(false)
// ── Find ──────────────────────────────────────────────────────────────────────
const showFind = ref(false)
const findQuery = ref('')
@@ -115,37 +119,6 @@ function closeFind() {
findQuery.value = ''
}
// ── Menus (zoom + view dropdowns) ────────────────────────────────────────────
const showZoomMenu = ref(false)
const showViewMenu = ref(false)
const zoomBtnRef = ref<HTMLElement | null>(null)
const viewBtnRef = ref<HTMLElement | null>(null)
const zoomMenuStyle = ref({ top: '0px', left: '0px' })
const viewMenuStyle = ref({ top: '0px', right: '0px' })
function openZoomMenu() {
if (zoomBtnRef.value) {
const r = zoomBtnRef.value.getBoundingClientRect()
zoomMenuStyle.value = { top: `${r.bottom + 4}px`, left: `${r.left + r.width / 2}px` }
}
showZoomMenu.value = !showZoomMenu.value
showViewMenu.value = false
}
function openViewMenu() {
if (viewBtnRef.value) {
const r = viewBtnRef.value.getBoundingClientRect()
viewMenuStyle.value = { top: `${r.bottom + 4}px`, right: `${window.innerWidth - r.right}px` }
}
showViewMenu.value = !showViewMenu.value
showZoomMenu.value = false
}
function closeAllMenus() {
showZoomMenu.value = false
showViewMenu.value = false
}
// ── Keyboard ──────────────────────────────────────────────────────────────────
function onKeyDown(e: KeyboardEvent) {
if (showFind.value && (e.target as HTMLElement)?.tagName === 'INPUT') return
@@ -167,7 +140,7 @@ function onKeyDown(e: KeyboardEvent) {
showFind.value = true
} else if (e.key === 'Escape') {
closeFind()
closeAllMenus()
showSettings.value = false
}
}
@@ -230,7 +203,7 @@ const progressPct = computed(() => (totalPages.value ? Math.round((currentPage.v
</script>
<template>
<div class="fixed inset-0 bg-[#525659] flex flex-col overflow-hidden select-none" @mousemove="showHeader()" @click="closeAllMenus()">
<div class="fixed inset-0 bg-[#525659] flex flex-col overflow-hidden select-none" @mousemove="showHeader()">
<!-- ── Header ─────────────────────────────────────────────────────────── -->
<div
class="absolute top-0 left-0 right-0 z-50 flex flex-col transition-all duration-300"
@@ -238,13 +211,18 @@ const progressPct = computed(() => (totalPages.value ? Math.round((currentPage.v
>
<!-- Toolbar -->
<div
class="h-12 flex items-center px-2 gap-0.5"
style="background: rgba(50, 54, 57, 0.96); backdrop-filter: blur(8px); border-bottom: 1px solid rgba(255, 255, 255, 0.08)"
class="h-12 flex items-center px-3 gap-0.5"
style="
background: rgba(18, 18, 20, 0.92);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
"
>
<button class="pdf-btn" @click="router.back()" title="Back"><ArrowLeft :size="16" /></button>
<div class="sep" />
<button class="viewer-btn" @click="router.back()" title="Back"><ArrowLeft :size="16" /></button>
<div class="viewer-sep" />
<button class="pdf-btn" :disabled="currentPage <= 1" @click="goToPage(currentPage - 1)" title="Previous page">
<button class="viewer-btn" :disabled="currentPage <= 1" @click="goToPage(currentPage - 1)" title="Previous page">
<ChevronLeft :size="16" />
</button>
<div class="flex items-center gap-1 text-sm text-white/80 shrink-0">
@@ -260,55 +238,37 @@ const progressPct = computed(() => (totalPages.value ? Math.round((currentPage.v
<span class="text-white/40">/</span>
<span class="tabular-nums">{{ totalPages }}</span>
</div>
<button class="pdf-btn" :disabled="currentPage >= totalPages" @click="goToPage(currentPage + 1)" title="Next page">
<button class="viewer-btn" :disabled="currentPage >= totalPages" @click="goToPage(currentPage + 1)" title="Next page">
<ChevronRight :size="16" />
</button>
<div class="sep" />
<div class="viewer-sep" />
<button class="pdf-btn" @click.stop="adjustZoom(-0.1)" title="Zoom out"><Minus :size="14" /></button>
<div class="shrink-0">
<button
ref="zoomBtnRef"
class="flex items-center gap-1 px-2 py-1 rounded text-xs text-white/80 hover:text-white hover:bg-white/10 transition-colors min-w-[72px] justify-center font-mono"
@click.stop="openZoomMenu"
title="Zoom level"
>
{{ zoomLabel }}
<span class="text-white/30 text-[10px]">▾</span>
</button>
</div>
<button class="pdf-btn" @click.stop="adjustZoom(0.1)" title="Zoom in"><Plus :size="14" /></button>
<div class="sep" />
<button class="viewer-btn" @click.stop="adjustZoom(-0.1)" title="Zoom out"><Minus :size="14" /></button>
<span class="text-xs text-white/80 font-mono min-w-[64px] text-center tabular-nums select-none">{{ zoomLabel }}</span>
<button class="viewer-btn" @click.stop="adjustZoom(0.1)" title="Zoom in"><Plus :size="14" /></button>
<div class="viewer-sep" />
<div class="shrink-0">
<button
ref="viewBtnRef"
class="flex items-center gap-1 px-2 py-1 rounded text-xs text-white/80 hover:text-white hover:bg-white/10 transition-colors"
@click.stop="openViewMenu"
title="View options"
>
<LayoutGrid :size="14" />
<span class="text-white/30 text-[10px]">▾</span>
</button>
</div>
<div class="sep" />
<button class="pdf-btn" :class="showFind ? 'bg-white/20 text-white' : ''" @click="showFind = !showFind" title="Find (⌘F)">
<button class="viewer-btn" :class="showFind ? 'bg-white/20 text-white' : ''" @click="showFind = !showFind" title="Find (⌘F)">
<Search :size="14" />
</button>
<div class="sep" />
<div class="viewer-sep" />
<button class="pdf-btn" @click="toggleFullscreen" :title="isFullscreen ? 'Exit fullscreen' : 'Fullscreen'">
<button class="viewer-btn" @click="toggleFullscreen" :title="isFullscreen ? 'Exit fullscreen' : 'Fullscreen'">
<Minimize v-if="isFullscreen" :size="14" />
<Maximize v-else :size="14" />
</button>
<div class="viewer-sep" />
<button class="viewer-btn" :class="showSettings ? 'bg-white/20 text-white' : ''" @click.stop="showSettings = !showSettings" title="Settings">
<Settings :size="14" />
</button>
</div>
<!-- Find bar -->
<div
v-if="showFind"
class="flex items-center gap-2 px-3 py-2"
style="background: rgba(50, 54, 57, 0.98); border-bottom: 1px solid rgba(255, 255, 255, 0.08)"
style="background: rgba(18, 18, 20, 0.98); border-bottom: 1px solid rgba(255, 255, 255, 0.08)"
>
<Search :size="13" class="text-white/40 shrink-0" />
<input
@@ -320,7 +280,7 @@ const progressPct = computed(() => (totalPages.value ? Math.round((currentPage.v
@keydown.escape="showFind = false"
/>
<span v-if="findQuery" class="text-xs text-white/40 tabular-nums shrink-0">{{ findMatches }} match{{ findMatches !== 1 ? 'es' : '' }}</span>
<button class="pdf-btn w-6 h-6" @click="closeFind"><X :size="12" /></button>
<button class="viewer-btn w-6 h-6" @click="closeFind"><X :size="12" /></button>
</div>
</div>
@@ -402,116 +362,19 @@ const progressPct = computed(() => (totalPages.value ? Math.round((currentPage.v
</div>
</div>
<!-- Zoom dropdown — teleported to escape stacking context created by header -->
<Teleport to="body">
<div
v-if="showZoomMenu"
class="fixed w-40 rounded-lg shadow-2xl py-1 overflow-hidden"
:style="{
zIndex: 9999,
background: 'rgba(35,38,41,0.98)',
border: '1px solid rgba(255,255,255,0.1)',
transform: 'translateX(-50%)',
top: zoomMenuStyle.top,
left: zoomMenuStyle.left,
}"
@click.stop
>
<button
v-for="preset in ZOOM_PRESETS"
:key="preset.label"
class="w-full text-left px-3 py-1.5 text-xs hover:bg-white/10 transition-colors flex items-center justify-between"
:class="zoomMode === 'custom' && customScale === parseFloat(preset.value) ? 'text-white' : 'text-white/60'"
@click="applyZoomPreset(preset.value)"
>
{{ preset.label }}
<span v-if="zoomMode === 'custom' && customScale === parseFloat(preset.value)" class="text-blue-400 text-[10px]">✓</span>
</button>
</div>
</Teleport>
<!-- View dropdown — teleported to escape stacking context created by header -->
<Teleport to="body">
<div
v-if="showViewMenu"
class="fixed w-44 rounded-lg shadow-2xl py-1 overflow-hidden"
:style="{
zIndex: 9999,
background: 'rgba(35,38,41,0.98)',
border: '1px solid rgba(255,255,255,0.1)',
top: viewMenuStyle.top,
right: viewMenuStyle.right,
}"
@click.stop
>
<button
class="w-full text-left px-3 py-1.5 text-xs hover:bg-white/10 transition-colors flex items-center justify-between"
:class="zoomMode === 'fit-width' ? 'text-white' : 'text-white/60'"
@click="applyZoomPreset('fit-width'); showViewMenu = false"
>
Fit Width<span v-if="zoomMode === 'fit-width'" class="text-blue-400 text-[10px]">✓</span>
</button>
<button
class="w-full text-left px-3 py-1.5 text-xs hover:bg-white/10 transition-colors flex items-center justify-between"
:class="zoomMode === 'fit-page' ? 'text-white' : 'text-white/60'"
@click="applyZoomPreset('fit-page'); showViewMenu = false"
>
Fit Page<span v-if="zoomMode === 'fit-page'" class="text-blue-400 text-[10px]">✓</span>
</button>
<button
class="w-full text-left px-3 py-1.5 text-xs hover:bg-white/10 transition-colors flex items-center justify-between"
:class="zoomMode === 'custom' && customScale === 1 ? 'text-white' : 'text-white/60'"
@click="applyZoomPreset('1'); showViewMenu = false"
>
Actual Size<span v-if="zoomMode === 'custom' && customScale === 1" class="text-blue-400 text-[10px]">✓</span>
</button>
<div class="my-1 border-t border-white/10" />
<button
class="w-full text-left px-3 py-1.5 text-xs text-white/60 hover:text-white hover:bg-white/10 transition-colors flex items-center justify-between"
@click="rotate(); showViewMenu = false"
>
Rotate CW <RotateCw :size="12" />
</button>
<div class="my-1 border-t border-white/10" />
<button
class="w-full text-left px-3 py-1.5 text-xs hover:bg-white/10 transition-colors flex items-center justify-between"
:class="spread === 'none' ? 'text-white' : 'text-white/60'"
@click="spread = 'none'; showViewMenu = false"
>
Single Page<span v-if="spread === 'none'" class="text-blue-400 text-[10px]">✓</span>
</button>
<button
class="w-full text-left px-3 py-1.5 text-xs hover:bg-white/10 transition-colors flex items-center justify-between"
:class="spread === 'odd' ? 'text-white' : 'text-white/60'"
@click="spread = 'odd'; showViewMenu = false"
>
Two-Page Spread<span v-if="spread === 'odd'" class="text-blue-400 text-[10px]">✓</span>
</button>
<div class="my-1 border-t border-white/10" />
<button
class="w-full text-left px-3 py-1.5 text-xs hover:bg-white/10 transition-colors flex items-center justify-between"
:class="scrollMode === 'continuous' ? 'text-white' : 'text-white/60'"
@click="scrollMode = 'continuous'; showViewMenu = false"
>
Continuous Scroll<span v-if="scrollMode === 'continuous'" class="text-blue-400 text-[10px]">✓</span>
</button>
<button
class="w-full text-left px-3 py-1.5 text-xs hover:bg-white/10 transition-colors flex items-center justify-between"
:class="scrollMode === 'page' ? 'text-white' : 'text-white/60'"
@click="scrollMode = 'page'; showViewMenu = false"
>
Page by Page<span v-if="scrollMode === 'page'" class="text-blue-400 text-[10px]">✓</span>
</button>
</div>
</Teleport>
<!-- Settings panel -->
<PdfSettingsPanel
v-if="showSettings"
:zoomMode="zoomMode"
:customScale="customScale"
:zoomLabel="zoomLabel"
:spread="spread"
:scrollMode="scrollMode"
:rotation="rotation"
@applyZoomPreset="applyZoomPreset"
@update:spread="spread = $event"
@update:scrollMode="scrollMode = $event"
@rotate="rotate()"
@close="showSettings = false"
/>
</template>
<style scoped>
@reference "tailwindcss";
.pdf-btn {
@apply flex items-center justify-center w-7 h-7 rounded text-white/60 hover:text-white hover:bg-white/10 transition-colors shrink-0 disabled:opacity-30 disabled:cursor-not-allowed;
}
.sep {
@apply w-px h-5 mx-1 bg-white/15 shrink-0;
}
</style>
@@ -0,0 +1,180 @@
<script setup lang="ts">
import { ref } from 'vue'
import { LayoutGrid, RotateCw, X, ZoomIn } from 'lucide-vue-next'
import { ZOOM_PRESETS } from '../composables/usePdfZoom'
const props = defineProps<{
zoomMode: string
customScale: number
zoomLabel: string
spread: 'none' | 'odd' | 'even'
scrollMode: 'continuous' | 'page'
rotation: 0 | 90 | 180 | 270
}>()
const emit = defineEmits<{
close: []
applyZoomPreset: [value: string]
'update:spread': [v: 'none' | 'odd' | 'even']
'update:scrollMode': [v: 'continuous' | 'page']
rotate: []
}>()
type Tab = 'zoom' | 'layout' | 'display'
const activeTab = ref<Tab>('zoom')
</script>
<template>
<div class="fixed inset-0 z-50 flex flex-col justify-end" @click.self="emit('close')">
<div
class="bg-card text-card-foreground rounded-t-xl max-h-[85vh] overflow-y-auto shadow-2xl border-t border-border w-full max-w-2xl mx-auto"
@click.stop
>
<div class="sticky top-0 bg-card z-10 flex items-center justify-between px-5 py-4 border-b border-border">
<div class="flex gap-1">
<button
v-for="tab in [
{ id: 'zoom', icon: ZoomIn, label: 'Zoom' },
{ id: 'layout', icon: LayoutGrid, label: 'Layout' },
{ id: 'display', icon: RotateCw, label: 'Display' },
] as const"
:key="tab.id"
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors"
:class="activeTab === tab.id ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground hover:bg-muted'"
@click="activeTab = tab.id"
>
<component :is="tab.icon" :size="14" />
{{ tab.label }}
</button>
</div>
<button
class="flex items-center justify-center w-8 h-8 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
@click="emit('close')"
>
<X :size="16" />
</button>
</div>
<div class="px-5 py-5 space-y-6">
<template v-if="activeTab === 'zoom'">
<div>
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Preset</p>
<div class="grid grid-cols-2 gap-2">
<button
class="py-2.5 rounded-xl text-sm font-medium border-2 transition-colors"
:class="
zoomMode === 'fit-width'
? 'bg-primary text-primary-foreground border-primary'
: 'border-border hover:border-muted-foreground/40 hover:bg-muted text-foreground'
"
@click="emit('applyZoomPreset', 'fit-width')"
>
Fit Width
</button>
<button
class="py-2.5 rounded-xl text-sm font-medium border-2 transition-colors"
:class="
zoomMode === 'fit-page'
? 'bg-primary text-primary-foreground border-primary'
: 'border-border hover:border-muted-foreground/40 hover:bg-muted text-foreground'
"
@click="emit('applyZoomPreset', 'fit-page')"
>
Fit Page
</button>
<button
v-for="preset in ZOOM_PRESETS"
:key="preset.label"
class="py-2.5 rounded-xl text-sm font-medium border-2 transition-colors"
:class="
zoomMode === 'custom' && customScale === parseFloat(preset.value)
? 'bg-primary text-primary-foreground border-primary'
: 'border-border hover:border-muted-foreground/40 hover:bg-muted text-foreground'
"
@click="emit('applyZoomPreset', preset.value)"
>
{{ preset.label }}
</button>
</div>
</div>
</template>
<template v-if="activeTab === 'layout'">
<div>
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Scroll Mode</p>
<div class="grid grid-cols-2 gap-2">
<button
class="py-2.5 rounded-xl text-sm font-medium border-2 transition-colors"
:class="
scrollMode === 'continuous'
? 'bg-primary text-primary-foreground border-primary'
: 'border-border hover:border-muted-foreground/40 hover:bg-muted text-foreground'
"
@click="emit('update:scrollMode', 'continuous')"
>
Continuous
</button>
<button
class="py-2.5 rounded-xl text-sm font-medium border-2 transition-colors"
:class="
scrollMode === 'page'
? 'bg-primary text-primary-foreground border-primary'
: 'border-border hover:border-muted-foreground/40 hover:bg-muted text-foreground'
"
@click="emit('update:scrollMode', 'page')"
>
Page by Page
</button>
</div>
</div>
<div>
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Page Spread</p>
<div class="grid grid-cols-2 gap-2">
<button
class="py-2.5 rounded-xl text-sm font-medium border-2 transition-colors"
:class="
spread === 'none'
? 'bg-primary text-primary-foreground border-primary'
: 'border-border hover:border-muted-foreground/40 hover:bg-muted text-foreground'
"
@click="emit('update:spread', 'none')"
>
Single Page
</button>
<button
class="py-2.5 rounded-xl text-sm font-medium border-2 transition-colors"
:class="
spread !== 'none'
? 'bg-primary text-primary-foreground border-primary'
: 'border-border hover:border-muted-foreground/40 hover:bg-muted text-foreground'
"
@click="emit('update:spread', 'odd')"
>
Two-Page
</button>
</div>
</div>
</template>
<template v-if="activeTab === 'display'">
<div>
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Rotation</p>
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium">Rotate Clockwise</p>
<p class="text-xs text-muted-foreground">Current: {{ rotation }}°</p>
</div>
<button
class="flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium border-2 border-border hover:border-muted-foreground/40 hover:bg-muted text-foreground transition-colors"
@click="emit('rotate')"
>
<RotateCw :size="16" />
Rotate 90°
</button>
</div>
</div>
</template>
</div>
</div>
</div>
</template>
@@ -6,8 +6,6 @@ const props = defineProps<{
sectionIndex: number
totalSections: number
sectionFractions: number[]
bgColor: string
fgColor: string
}>()
const emit = defineEmits<{
@@ -25,21 +23,14 @@ function onSeek(e: Event) {
<template>
<footer
class="fixed bottom-0 left-0 right-0 h-14 z-50 flex items-center gap-3 px-4"
:style="{
background: `color-mix(in srgb, ${bgColor} 92%, transparent)`,
color: fgColor,
backdropFilter: 'blur(8px)',
WebkitBackdropFilter: 'blur(8px)',
borderTop: `1px solid color-mix(in srgb, ${fgColor} 12%, transparent)`,
}"
style="
background: rgba(18, 18, 20, 0.92);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border-top: 1px solid rgba(255, 255, 255, 0.08);
"
>
<button
class="flex items-center justify-center w-8 h-8 rounded-md transition-opacity hover:opacity-70 disabled:opacity-30 shrink-0"
:style="{ color: fgColor }"
:disabled="sectionIndex === 0"
@click="emit('prevSection')"
title="Previous section"
>
<button class="viewer-btn" :disabled="sectionIndex === 0" @click="emit('prevSection')" title="Previous section">
<ChevronLeft :size="18" />
</button>
@@ -53,33 +44,22 @@ function onSeek(e: Event) {
@input="onSeek"
class="w-full h-1 rounded-full appearance-none cursor-pointer"
:style="{
accentColor: fgColor,
background: `linear-gradient(to right, color-mix(in srgb, ${fgColor} 60%, transparent) ${fraction * 100}%, color-mix(in srgb, ${fgColor} 20%, transparent) ${fraction * 100}%)`,
accentColor: 'rgba(255,255,255,0.8)',
background: `linear-gradient(to right, rgba(255,255,255,0.6) ${fraction * 100}%, rgba(255,255,255,0.2) ${fraction * 100}%)`,
}"
/>
<template v-for="(sf, idx) in sectionFractions" :key="idx">
<div
v-if="sf > 0 && sf < 1"
class="absolute top-1/2 -translate-y-1/2 w-px h-3 pointer-events-none"
:style="{
left: `${sf * 100}%`,
background: `color-mix(in srgb, ${fgColor} 35%, transparent)`,
}"
:style="{ left: `${sf * 100}%`, background: 'rgba(255,255,255,0.35)' }"
/>
</template>
</div>
<span class="text-xs tabular-nums shrink-0 min-w-[3rem] text-center" :style="{ color: `color-mix(in srgb, ${fgColor} 60%, transparent)` }">
{{ Math.round(fraction * 100) }}%
</span>
<span class="text-xs tabular-nums shrink-0 min-w-[3rem] text-center text-white/50"> {{ Math.round(fraction * 100) }}% </span>
<button
class="flex items-center justify-center w-8 h-8 rounded-md transition-opacity hover:opacity-70 disabled:opacity-30 shrink-0"
:style="{ color: fgColor }"
:disabled="totalSections > 0 && sectionIndex >= totalSections - 1"
@click="emit('nextSection')"
title="Next section"
>
<button class="viewer-btn" :disabled="totalSections > 0 && sectionIndex >= totalSections - 1" @click="emit('nextSection')" title="Next section">
<ChevronRight :size="18" />
</button>
</footer>
@@ -5,9 +5,6 @@ import { ArrowLeft, BookOpen, Bookmark, BookmarkCheck, Maximize, Minimize, Searc
defineProps<{
chapterTitle: string
isBookmarked: boolean
isDark: boolean
bgColor: string
fgColor: string
}>()
const emit = defineEmits<{
@@ -32,74 +29,42 @@ onUnmounted(() => document.removeEventListener('fullscreenchange', onFullscreenC
<template>
<header
class="fixed top-0 left-0 right-0 h-12 z-50 flex items-center px-3 gap-1"
:style="{
background: `color-mix(in srgb, ${bgColor} 92%, transparent)`,
color: fgColor,
backdropFilter: 'blur(8px)',
WebkitBackdropFilter: 'blur(8px)',
borderBottom: `1px solid color-mix(in srgb, ${fgColor} 12%, transparent)`,
}"
style="
background: rgba(18, 18, 20, 0.92);
color: white;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
"
>
<button
class="flex items-center justify-center w-8 h-8 rounded-md transition-opacity hover:opacity-70 shrink-0"
:style="{ color: fgColor }"
@click="emit('back')"
title="Go back"
>
<button class="viewer-btn" @click="emit('back')" title="Go back">
<ArrowLeft :size="18" />
</button>
<div class="w-px h-5 mx-1 shrink-0" :style="{ background: `color-mix(in srgb, ${fgColor} 20%, transparent)` }" />
<div class="viewer-sep" />
<button
class="flex items-center justify-center w-8 h-8 rounded-md transition-opacity hover:opacity-70 shrink-0"
:style="{ color: fgColor }"
@click="emit('toggleSidebar')"
title="Table of contents"
>
<button class="viewer-btn" @click="emit('toggleSidebar')" title="Table of contents">
<BookOpen :size="18" />
</button>
<button
class="flex items-center justify-center w-8 h-8 rounded-md transition-opacity hover:opacity-70 shrink-0"
:style="{ color: isBookmarked ? '#f87171' : fgColor }"
@click="emit('toggleBookmark')"
title="Toggle bookmark"
>
<button class="viewer-btn" :class="isBookmarked ? '!text-red-400' : ''" @click="emit('toggleBookmark')" title="Toggle bookmark">
<BookmarkCheck v-if="isBookmarked" :size="18" />
<Bookmark v-else :size="18" />
</button>
<button
class="flex items-center justify-center w-8 h-8 rounded-md transition-opacity hover:opacity-70 shrink-0"
:style="{ color: fgColor }"
@click="emit('toggleSearch')"
title="Search"
>
<button class="viewer-btn" @click="emit('toggleSearch')" title="Search">
<Search :size="18" />
</button>
<div class="flex-1 min-w-0 px-3">
<p class="text-sm font-medium truncate text-center" :style="{ color: `color-mix(in srgb, ${fgColor} 70%, transparent)` }">
{{ chapterTitle }}
</p>
<p class="text-sm font-medium truncate text-center text-white/60">{{ chapterTitle }}</p>
</div>
<button
class="flex items-center justify-center w-8 h-8 rounded-md transition-opacity hover:opacity-70 shrink-0"
:style="{ color: fgColor }"
@click="emit('toggleSettings')"
title="Settings"
>
<button class="viewer-btn" @click="emit('toggleSettings')" title="Settings">
<Settings :size="18" />
</button>
<button
class="flex items-center justify-center w-8 h-8 rounded-md transition-opacity hover:opacity-70 shrink-0"
:style="{ color: fgColor }"
@click="emit('toggleFullscreen')"
:title="isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'"
>
<button class="viewer-btn" @click="emit('toggleFullscreen')" :title="isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'">
<Minimize v-if="isFullscreen" :size="18" />
<Maximize v-else :size="18" />
</button>