feat(reader): improve CBZ and ebook options layout

This commit is contained in:
neon
2026-04-01 14:51:37 -06:00
parent 777bedaa90
commit efea4ba846
6 changed files with 439 additions and 471 deletions
+17 -6
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { nextTick, onMounted, onUnmounted, ref } from 'vue'
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useFoliate, type RelocateDetail } from './epub/composables/useFoliate'
import { useReaderProgress } from './shared/composables/useReaderProgress'
@@ -77,7 +77,7 @@ const { onActivity } = useReadingSession(fileId, () => ({
}))
const visibility = useVisibility()
const { headerVisible, footerVisible, handleMiddleTap, showHeader, showFooter } = visibility
const { headerVisible, footerVisible, handleMiddleTap, showHeader, showFooter, setVisibilityLock } = visibility
const bookmarks = useBookmarks()
const annotations = useAnnotations()
@@ -210,6 +210,14 @@ function toggleFullscreen() {
}
}
function setSettingsOpen(open: boolean) {
showSettings.value = open
}
watch(showSettings, (open) => {
setVisibilityLock(open)
})
async function handleHighlight(color: string, style: string, note?: string) {
const annotationCfi = selection.cfi.value
if (!selection.text.value || !annotationCfi) return
@@ -294,15 +302,20 @@ function closeSearch() {
<ReaderHeader
:chapterTitle="chapterTitle"
:isBookmarked="bookmarks.isCurrentCfiBookmarked.value"
:settings-open="showSettings"
class="transition-all duration-300"
:class="headerVisible ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-full pointer-events-none'"
@back="router.back()"
@toggleSidebar="showSidebar = !showSidebar"
@toggleSearch="showSearch = !showSearch"
@toggleBookmark="bookmarks.toggle(bookId, cfi ?? '', chapterTitle)"
@toggleSettings="showSettings = !showSettings"
@update:settings-open="setSettingsOpen"
@toggleFullscreen="toggleFullscreen"
/>
>
<template #settingsPanel>
<ReaderSettingsPanel :state="state" @update="applyUpdate" />
</template>
</ReaderHeader>
<div class="absolute inset-0">
<div v-if="loading" class="absolute inset-0 flex items-center justify-center z-10 bg-background">
@@ -366,8 +379,6 @@ function closeSearch() {
@close="closeSearch"
/>
<ReaderSettingsPanel v-if="showSettings" :state="state" @update="applyUpdate" @close="showSettings = false" />
<NoteDialog
v-if="selection.showNoteDialog.value"
:selectedText="selection.text.value"
+159 -28
View File
@@ -1,16 +1,42 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { type Component, computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ArrowLeft, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Settings } from 'lucide-vue-next'
import {
AlignJustify,
ArrowDownUp,
ArrowLeft,
ArrowLeftRight,
ArrowRight,
BookOpen,
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
Image as ImageIcon,
LayoutGrid,
Layers,
Maximize,
ScanLine,
Settings,
} from 'lucide-vue-next'
import { useVisibility } from '../shared/composables/useVisibility'
import { useReaderProgress } from '../shared/composables/useReaderProgress'
import { useReadingSession } from '../shared/composables/useReadingSession'
import { useCbz } from './composables/useCbz'
import { useCbzSettings } from './composables/useCbzSettings'
import type { BgColor, Direction, FitMode, ScrollMode, ViewMode } from './composables/useCbzSettings'
import { useReaderSettings } from '../shared/composables/useReaderSettings'
import CbzSettingsPanel from './components/SettingsPanel.vue'
import type { CbxReaderSettings } from '@projectx/types'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
const props = defineProps<{ bookId: number; fileId: number }>()
const router = useRouter()
@@ -31,6 +57,48 @@ const currentImageLoaded = ref(false)
const showSettings = ref(false)
const scrollContainer = ref<HTMLElement | null>(null)
// ── Settings options ───────────────────────────────────────────────────────────
const FIT_OPTIONS: { value: FitMode; label: string; icon: Component }[] = [
{ value: 'fit-page', label: 'Page Fit', icon: Maximize },
{ value: 'fit-width', label: 'Page Width', icon: ArrowLeftRight },
{ value: 'fit-height', label: 'Page Height', icon: ArrowDownUp },
{ value: 'actual', label: 'Actual Size', icon: ImageIcon },
]
const VIEW_OPTIONS: { value: ViewMode; label: string; icon: Component }[] = [
{ value: 'single', label: 'Single Page', icon: BookOpen },
{ value: 'two-page', label: 'Two Page', icon: LayoutGrid },
]
const SCROLL_OPTIONS: { value: ScrollMode; label: string; icon: Component }[] = [
{ value: 'paginated', label: 'Paginated', icon: ScanLine },
{ value: 'infinite', label: 'Infinite Scroll', icon: Layers },
{ value: 'long-strip', label: 'Long Strip', icon: AlignJustify },
]
const DIRECTION_OPTIONS: { value: Direction; label: string; icon: Component }[] = [
{ value: 'ltr', label: 'Left to Right', icon: ArrowRight },
{ value: 'rtl', label: 'Right to Left', icon: ArrowLeft },
]
const BG_OPTIONS: { value: BgColor; label: string; color: string }[] = [
{ value: 'black', label: 'Black', color: 'bg-zinc-950' },
{ value: 'gray', label: 'Gray', color: 'bg-zinc-500' },
{ value: 'white', label: 'White', color: 'bg-white' },
]
function setFitMode(v: FitMode) {
fitMode.value = v
}
function setViewMode(v: ViewMode) {
viewMode.value = v
}
function setScrollMode(v: ScrollMode) {
scrollMode.value = v
}
function setDirection(v: Direction) {
direction.value = v
}
function setBgColor(v: BgColor) {
bgColor.value = v
}
// ── Derived ────────────────────────────────────────────────────────────────────
// Pages shown in paginated mode (1 or 2), in display order for RTL.
const visiblePages = computed(() => {
@@ -76,10 +144,6 @@ function prevPage() {
// ── Click zones (left / middle / right) ───────────────────────────────────────
function handleImageClick(e: MouseEvent) {
if (showSettings.value) {
showSettings.value = false
return
}
const x = e.clientX / window.innerWidth
if (x < 0.25) {
if (direction.value === 'rtl') nextPage()
@@ -255,15 +319,98 @@ 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 bg-background/90 backdrop-blur-md border-b border-border">
<div class="h-12 flex items-center gap-1 px-3 bg-background/90 backdrop-blur-md border-b border-border">
<button class="viewer-btn" @click="router.back()"><ArrowLeft :size="16" /></button>
<div class="flex-1 min-w-0 flex flex-col justify-center">
<div class="flex-1 min-w-0 flex flex-col justify-center px-2">
<span v-if="bookTitle" class="text-sm font-serif text-foreground truncate leading-tight">{{ bookTitle }}</span>
<span class="text-xs text-muted-foreground tabular-nums">{{ currentPage + 1 }} / {{ pageCount }}</span>
</div>
<button class="viewer-btn" :class="showSettings ? '!bg-muted !text-foreground' : ''" @click="showSettings = !showSettings">
<Settings :size="15" />
</button>
<DropdownMenu v-model:open="showSettings">
<DropdownMenuTrigger as-child>
<button class="viewer-btn" :class="showSettings ? '!bg-muted !text-foreground' : ''">
<Settings :size="15" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-52">
<DropdownMenuLabel class="text-muted-foreground text-xs px-2 py-1">Fit Mode</DropdownMenuLabel>
<DropdownMenuRadioGroup :model-value="fitMode">
<DropdownMenuRadioItem
v-for="opt in FIT_OPTIONS"
:key="opt.value"
:value="opt.value"
class="text-xs gap-2"
@select.prevent="setFitMode(opt.value)"
>
<component :is="opt.icon" :size="13" />
{{ opt.label }}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuLabel class="text-muted-foreground text-xs px-2 py-1">Pages</DropdownMenuLabel>
<DropdownMenuRadioGroup :model-value="viewMode">
<DropdownMenuRadioItem
v-for="opt in VIEW_OPTIONS"
:key="opt.value"
:value="opt.value"
class="text-xs gap-2"
@select.prevent="setViewMode(opt.value)"
>
<component :is="opt.icon" :size="13" />
{{ opt.label }}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuLabel class="text-muted-foreground text-xs px-2 py-1">Scroll Mode</DropdownMenuLabel>
<DropdownMenuRadioGroup :model-value="scrollMode">
<DropdownMenuRadioItem
v-for="opt in SCROLL_OPTIONS"
:key="opt.value"
:value="opt.value"
class="text-xs gap-2"
@select.prevent="setScrollMode(opt.value)"
>
<component :is="opt.icon" :size="13" />
{{ opt.label }}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuLabel class="text-muted-foreground text-xs px-2 py-1">Reading Direction</DropdownMenuLabel>
<DropdownMenuRadioGroup :model-value="direction">
<DropdownMenuRadioItem
v-for="opt in DIRECTION_OPTIONS"
:key="opt.value"
:value="opt.value"
class="text-xs gap-2"
@select.prevent="setDirection(opt.value)"
>
<component :is="opt.icon" :size="13" />
{{ opt.label }}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuLabel class="text-muted-foreground text-xs px-2 py-1">Background</DropdownMenuLabel>
<DropdownMenuRadioGroup :model-value="bgColor">
<DropdownMenuRadioItem
v-for="opt in BG_OPTIONS"
:key="opt.value"
:value="opt.value"
class="text-xs gap-2"
@select.prevent="setBgColor(opt.value)"
>
<span class="size-3 rounded-full ring-1 ring-border shrink-0" :class="opt.color" />
{{ opt.label }}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
@@ -373,22 +520,6 @@ onUnmounted(() => {
<p class="text-sm text-foreground">{{ error }}</p>
</div>
<!-- ── Settings panel ────────────────────────────────────────────────────── -->
<CbzSettingsPanel
v-if="showSettings"
:fitMode="fitMode"
:viewMode="viewMode"
:scrollMode="scrollMode"
:direction="direction"
:bgColor="bgColor"
@close="showSettings = false"
@update:fitMode="fitMode = $event"
@update:viewMode="viewMode = $event"
@update:scrollMode="scrollMode = $event"
@update:direction="direction = $event"
@update:bgColor="bgColor = $event"
/>
<!-- Progress bar -->
<div v-if="!loading && !error && pageCount > 0" class="absolute bottom-0 left-0 right-0 h-0.5 bg-border z-30">
<div class="h-full bg-primary/60 transition-all duration-300" :style="{ width: `${((currentPage + 1) / pageCount) * 100}%` }" />
@@ -1,210 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
import { AlignJustify, Image, Palette, X } from 'lucide-vue-next'
import type { BgColor, Direction, FitMode, ScrollMode, ViewMode } from '../composables/useCbzSettings'
defineProps<{
fitMode: FitMode
viewMode: ViewMode
scrollMode: ScrollMode
direction: Direction
bgColor: BgColor
}>()
const emit = defineEmits<{
close: []
'update:fitMode': [v: FitMode]
'update:viewMode': [v: ViewMode]
'update:scrollMode': [v: ScrollMode]
'update:direction': [v: Direction]
'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' },
{ value: 'fit-height', label: 'Height' },
{ value: 'actual', label: 'Actual' },
]
const SCROLL_OPTIONS: { value: ScrollMode; label: string }[] = [
{ value: 'paginated', label: 'Paginated' },
{ value: 'infinite', label: 'Infinite Scroll' },
{ value: 'long-strip', label: 'Long Strip' },
]
</script>
<template>
<div class="fixed inset-0 z-50 flex flex-col justify-end" @click.self="emit('close')">
<div
class="settings-sheet 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: '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"
>
<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 === '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>
<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>
<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>
.settings-sheet {
animation: slideInFromBottom 0.25s ease;
}
@keyframes slideInFromBottom {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
</style>
@@ -1,11 +1,13 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import { ArrowLeft, BookOpen, Bookmark, BookmarkCheck, Maximize, Minimize, Search, Settings } from 'lucide-vue-next'
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
defineProps<{
const props = defineProps<{
chapterTitle: string
isBookmarked: boolean
settingsOpen: boolean
}>()
const emit = defineEmits<{
@@ -13,7 +15,7 @@ const emit = defineEmits<{
toggleSidebar: []
toggleSearch: []
toggleBookmark: []
toggleSettings: []
'update:settingsOpen': [open: boolean]
toggleFullscreen: []
}>()
@@ -23,6 +25,10 @@ function onFullscreenChange() {
isFullscreen.value = !!document.fullscreenElement
}
function onSettingsOpenChange(open: boolean) {
emit('update:settingsOpen', open)
}
onMounted(() => document.addEventListener('fullscreenchange', onFullscreenChange))
onUnmounted(() => document.removeEventListener('fullscreenchange', onFullscreenChange))
</script>
@@ -72,15 +78,6 @@ onUnmounted(() => document.removeEventListener('fullscreenchange', onFullscreenC
<p class="text-sm font-serif font-medium truncate text-center text-muted-foreground">{{ chapterTitle }}</p>
</div>
<Tooltip>
<TooltipTrigger as-child>
<button class="viewer-btn" @click="emit('toggleSettings')">
<Settings :size="18" />
</button>
</TooltipTrigger>
<TooltipContent>Settings</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button class="viewer-btn" @click="emit('toggleFullscreen')">
@@ -90,5 +87,21 @@ onUnmounted(() => document.removeEventListener('fullscreenchange', onFullscreenC
</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-xl border-border bg-card p-0 shadow-2xl overflow-hidden"
>
<slot name="settingsPanel" />
</DropdownMenuContent>
</DropdownMenu>
</header>
</template>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref } from 'vue'
import { LayoutGrid, Moon, Palette, Sun, Type, X } from 'lucide-vue-next'
import { nextTick, onMounted, ref } from 'vue'
import { LayoutGrid, Moon, Palette, Sun, Type } from 'lucide-vue-next'
import type { ReaderState } from '../composables/useReaderState'
import { themes } from '../constants/themes'
@@ -10,11 +10,24 @@ const props = defineProps<{
const emit = defineEmits<{
update: [partial: Partial<ReaderState>]
close: []
}>()
type Tab = 'theme' | 'typography' | 'layout'
const activeTab = ref<Tab>('theme')
type Tab = 'appearance' | 'text' | 'layout'
const activeTab = ref<Tab>('appearance')
const contentRef = ref<HTMLElement | null>(null)
const hasTabBarShadow = ref(false)
const scrollMemory = ref<Record<Tab, number>>({
appearance: 0,
text: 0,
layout: 0,
})
const tabs: { id: Tab; icon: typeof Palette; label: string }[] = [
{ id: 'appearance', icon: Palette, label: 'Appearance' },
{ id: 'text', icon: Type, label: 'Text' },
{ id: 'layout', icon: LayoutGrid, label: 'Layout' },
]
const fontFamilies = [
{ name: "Publisher's", value: null },
@@ -24,6 +37,10 @@ const fontFamilies = [
{ name: 'Cursive', value: 'cursive' },
]
const stepperButtonClass = 'size-8 rounded-lg border border-border text-lg font-light text-foreground transition-colors hover:bg-muted'
const stepperGroupClass = 'flex min-w-[11rem] items-center justify-end gap-2'
const stepperValueClass = 'w-[4.75rem] text-center text-sm font-mono font-medium tabular-nums text-foreground'
function step(field: keyof ReaderState, delta: number, min: number, max: number, precision = 0) {
const current = props.state[field] as number
const next = Math.max(min, Math.min(max, current + delta))
@@ -34,287 +51,264 @@ function step(field: keyof ReaderState, delta: number, min: number, max: number,
function formatGap(v: number) {
return `${Math.round(v * 100)}%`
}
function onContentScroll() {
if (!contentRef.value) return
scrollMemory.value[activeTab.value] = contentRef.value.scrollTop
hasTabBarShadow.value = contentRef.value.scrollTop > 0
}
function setActiveTab(tab: Tab) {
if (tab === activeTab.value) return
if (contentRef.value) {
scrollMemory.value[activeTab.value] = contentRef.value.scrollTop
}
activeTab.value = tab
nextTick(() => {
if (!contentRef.value) return
contentRef.value.scrollTop = scrollMemory.value[tab] ?? 0
hasTabBarShadow.value = contentRef.value.scrollTop > 0
})
}
onMounted(() => {
if (!contentRef.value) return
contentRef.value.scrollTop = scrollMemory.value[activeTab.value] ?? 0
hasTabBarShadow.value = contentRef.value.scrollTop > 0
})
</script>
<template>
<div class="fixed inset-0 z-50 flex flex-col justify-end" @click.self="emit('close')">
<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="settings-sheet 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
class="sticky top-0 z-10 border-b border-border bg-card/95 px-3 py-3 backdrop-blur-sm transition-shadow"
:class="hasTabBarShadow ? 'shadow-sm' : ''"
>
<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: 'theme', icon: Palette, label: 'Theme' },
{ id: 'typography', icon: Type, label: 'Typography' },
{ id: 'layout', icon: LayoutGrid, label: 'Layout' },
] 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>
<div class="grid grid-cols-3 gap-1 rounded-xl bg-muted/55 p-1">
<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')"
v-for="tab in tabs"
:key="tab.id"
class="flex h-[2.125rem] items-center justify-center gap-2 rounded-lg px-3 text-sm font-medium transition-colors"
:class="
activeTab === tab.id
? 'bg-background text-foreground shadow-sm ring-1 ring-border'
: 'text-muted-foreground hover:text-foreground hover:bg-background/70'
"
@click="setActiveTab(tab.id)"
>
<X :size="16" />
<component :is="tab.icon" :size="15" />
<span>{{ tab.label }}</span>
</button>
</div>
</div>
<div class="px-5 py-5 space-y-6">
<template v-if="activeTab === 'theme'">
<div ref="contentRef" class="overflow-y-auto px-4 py-4 space-y-6" @scroll="onContentScroll">
<template v-if="activeTab === 'appearance'">
<div class="space-y-6">
<div>
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Mode</p>
<p class="mb-2 text-[13px] font-medium text-foreground/90">Mode</p>
<button
class="flex items-center gap-3 w-full px-4 py-3 rounded-xl border-2 transition-colors"
class="flex w-full items-center gap-3 rounded-xl border-2 px-4 py-3 transition-colors"
:class="state.isDark ? 'border-primary bg-primary/10' : 'border-border hover:border-muted-foreground/30'"
@click="emit('update', { isDark: !state.isDark })"
>
<component :is="state.isDark ? Moon : Sun" :size="20" />
<span class="text-sm font-medium">{{ state.isDark ? 'Dark Mode' : 'Light Mode' }}</span>
<div class="ml-auto w-10 h-6 rounded-full transition-colors relative" :class="state.isDark ? 'bg-primary' : 'bg-muted'">
<span class="text-sm font-medium">{{ state.isDark ? 'Dark mode' : 'Light mode' }}</span>
<div class="relative ml-auto h-6 w-10 rounded-full transition-colors" :class="state.isDark ? 'bg-primary' : 'bg-muted'">
<div
class="absolute top-1 w-4 h-4 rounded-full bg-white transition-transform shadow-sm"
class="absolute top-1 h-4 w-4 rounded-full bg-white shadow-sm transition-transform"
:class="state.isDark ? 'translate-x-5' : 'translate-x-1'"
/>
</div>
</button>
</div>
<div class="h-px bg-border/70" />
<div>
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Color Theme</p>
<div class="grid grid-cols-7 gap-2">
<p class="mb-2 text-[13px] font-medium text-foreground/90">Color theme</p>
<div class="grid grid-cols-6 gap-2">
<button
v-for="theme in themes"
:key="theme.name"
class="flex flex-col items-center gap-1.5 group"
class="group flex flex-col items-center gap-1.5"
@click="emit('update', { themeName: theme.name })"
>
<div
class="w-9 h-9 rounded-full border-2 transition-all flex items-center justify-center shadow-sm"
:class="state.themeName === theme.name ? 'border-primary scale-110' : 'border-transparent group-hover:border-muted-foreground/40'"
class="flex h-9 w-9 items-center justify-center rounded-full border-2 shadow-sm transition-all"
:class="state.themeName === theme.name ? 'scale-110 border-primary' : 'border-transparent group-hover:border-muted-foreground/40'"
:style="{ background: state.isDark ? theme.dark.bg : theme.light.bg }"
>
<div class="w-2.5 h-2.5 rounded-full" :style="{ background: state.isDark ? theme.dark.fg : theme.light.fg }" />
<div class="h-2.5 w-2.5 rounded-full" :style="{ background: state.isDark ? theme.dark.fg : theme.light.fg }" />
</div>
<span class="text-[10px] text-muted-foreground truncate w-full text-center">{{ theme.label }}</span>
<span class="w-full truncate text-center text-[10px] text-muted-foreground">{{ theme.label }}</span>
</button>
</div>
</div>
</template>
</div>
</template>
<template v-if="activeTab === 'text'">
<div class="space-y-6">
<div class="flex items-center justify-between gap-4">
<div class="space-y-1 pr-3">
<p class="text-sm font-medium leading-tight">Font size</p>
<p class="text-xs leading-tight text-muted-foreground">Range: 10-32px</p>
</div>
<div :class="stepperGroupClass">
<button :class="stepperButtonClass" @click="step('fontSize', -1, 10, 32)">−</button>
<span :class="stepperValueClass">{{ state.fontSize }}px</span>
<button :class="stepperButtonClass" @click="step('fontSize', 1, 10, 32)">+</button>
</div>
</div>
<div class="flex items-center justify-between gap-4">
<div class="space-y-1 pr-3">
<p class="text-sm font-medium leading-tight">Line height</p>
<p class="text-xs leading-tight text-muted-foreground">Range: 0.8-3.0</p>
</div>
<div :class="stepperGroupClass">
<button :class="stepperButtonClass" @click="step('lineHeight', -0.1, 0.8, 3, 1)">−</button>
<span :class="stepperValueClass">{{ state.lineHeight.toFixed(1) }}</span>
<button :class="stepperButtonClass" @click="step('lineHeight', 0.1, 0.8, 3, 1)">+</button>
</div>
</div>
<div class="h-px bg-border/70" />
<div class="space-y-3">
<div class="space-y-1">
<p class="text-sm font-medium leading-tight">Font family</p>
<p class="text-xs leading-tight text-muted-foreground">Switch between publisher default and common reader fonts.</p>
</div>
<div class="flex flex-wrap gap-2">
<button
v-for="font in fontFamilies"
:key="String(font.value)"
class="rounded-lg border px-3 py-1.5 text-sm transition-colors"
:class="
state.fontFamily === font.value
? 'border-primary bg-primary text-primary-foreground'
: 'border-border hover:border-muted-foreground/40 hover:bg-muted'
"
:style="font.value ? { fontFamily: font.value } : {}"
@click="emit('update', { fontFamily: font.value })"
>
{{ font.name }}
</button>
</div>
</div>
</div>
</template>
<template v-if="activeTab === 'layout'">
<div class="space-y-6">
<div class="space-y-3">
<div class="space-y-1">
<p class="text-sm font-medium leading-tight">Reading flow</p>
<p class="text-xs leading-tight text-muted-foreground">Switch between paged and continuous scrolling.</p>
</div>
<div class="grid grid-cols-2 gap-2">
<button
v-for="f in ['paginated', 'scrolled'] as const"
:key="f"
class="rounded-xl border-2 py-2.5 text-sm font-medium capitalize transition-colors"
:class="
state.flow === f
? 'border-primary bg-primary text-primary-foreground'
: 'border-border text-foreground hover:border-muted-foreground/40 hover:bg-muted'
"
@click="emit('update', { flow: f })"
>
{{ f }}
</button>
</div>
</div>
<div class="h-px bg-border/70" />
<template v-if="activeTab === 'typography'">
<div class="space-y-4">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium">Font Size</p>
<p class="text-xs text-muted-foreground">Range: 10–32px</p>
<div class="flex items-center justify-between gap-4">
<div class="space-y-1 pr-3">
<p class="text-sm font-medium leading-tight">Max columns</p>
<p class="text-xs leading-tight text-muted-foreground">Range: 1-10</p>
</div>
<div class="flex items-center gap-2">
<button
class="w-8 h-8 rounded-lg border border-border hover:bg-muted transition-colors flex items-center justify-center text-lg font-light"
@click="step('fontSize', -1, 10, 32)"
>
−
</button>
<span class="w-14 text-center text-sm font-mono font-medium">{{ state.fontSize }}px</span>
<button
class="w-8 h-8 rounded-lg border border-border hover:bg-muted transition-colors flex items-center justify-center text-lg font-light"
@click="step('fontSize', 1, 10, 32)"
>
+
</button>
<div :class="stepperGroupClass">
<button :class="stepperButtonClass" @click="step('maxColumnCount', -1, 1, 10)">−</button>
<span :class="stepperValueClass">{{ state.maxColumnCount }}</span>
<button :class="stepperButtonClass" @click="step('maxColumnCount', 1, 1, 10)">+</button>
</div>
</div>
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium">Line Height</p>
<p class="text-xs text-muted-foreground">Range: 0.8–3.0</p>
<div class="flex items-center justify-between gap-4">
<div class="space-y-1 pr-3">
<p class="text-sm font-medium leading-tight">Column gap</p>
<p class="text-xs leading-tight text-muted-foreground">Range: 0-50%</p>
</div>
<div class="flex items-center gap-2">
<button
class="w-8 h-8 rounded-lg border border-border hover:bg-muted transition-colors flex items-center justify-center text-lg font-light"
@click="step('lineHeight', -0.1, 0.8, 3, 1)"
>
−
</button>
<span class="w-14 text-center text-sm font-mono font-medium">{{ state.lineHeight.toFixed(1) }}</span>
<button
class="w-8 h-8 rounded-lg border border-border hover:bg-muted transition-colors flex items-center justify-center text-lg font-light"
@click="step('lineHeight', 0.1, 0.8, 3, 1)"
>
+
</button>
<div :class="stepperGroupClass">
<button :class="stepperButtonClass" @click="step('gap', -0.01, 0, 0.5, 2)">−</button>
<span :class="stepperValueClass">{{ formatGap(state.gap) }}</span>
<button :class="stepperButtonClass" @click="step('gap', 0.01, 0, 0.5, 2)">+</button>
</div>
</div>
<div>
<p class="text-sm font-medium mb-2">Font Family</p>
<div class="flex flex-wrap gap-2">
<button
v-for="font in fontFamilies"
:key="String(font.value)"
class="px-3 py-1.5 rounded-lg text-sm border transition-colors"
:class="
state.fontFamily === font.value
? 'bg-primary text-primary-foreground border-primary'
: 'border-border hover:border-muted-foreground/40 hover:bg-muted'
"
:style="font.value ? { fontFamily: font.value } : {}"
@click="emit('update', { fontFamily: font.value })"
>
{{ font.name }}
</button>
<div class="flex items-center justify-between gap-4">
<div class="space-y-1 pr-3">
<p class="text-sm font-medium leading-tight">Max width</p>
<p class="text-xs leading-tight text-muted-foreground">Range: 400-1600px</p>
</div>
<div :class="stepperGroupClass">
<button :class="stepperButtonClass" @click="step('maxInlineSize', -40, 400, 1600)">−</button>
<span :class="stepperValueClass">{{ state.maxInlineSize }}px</span>
<button :class="stepperButtonClass" @click="step('maxInlineSize', 40, 400, 1600)">+</button>
</div>
</div>
</div>
</template>
<template v-if="activeTab === 'layout'">
<div class="space-y-5">
<div>
<p class="text-sm font-medium mb-2">Reading Flow</p>
<div class="grid grid-cols-2 gap-2">
<button
v-for="f in ['paginated', 'scrolled'] as const"
:key="f"
class="py-2.5 rounded-xl text-sm font-medium border-2 transition-colors capitalize"
:class="
state.flow === f
? 'bg-primary text-primary-foreground border-primary'
: 'border-border hover:border-muted-foreground/40 hover:bg-muted text-foreground'
"
@click="emit('update', { flow: f })"
>
{{ f }}
</button>
</div>
</div>
<div class="h-px bg-border/70" />
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium">Max Columns</p>
<p class="text-xs text-muted-foreground">Range: 1–10</p>
</div>
<div class="flex items-center gap-2">
<button
class="w-8 h-8 rounded-lg border border-border hover:bg-muted transition-colors flex items-center justify-center text-lg font-light"
@click="step('maxColumnCount', -1, 1, 10)"
>
−
</button>
<span class="w-10 text-center text-sm font-mono font-medium">{{ state.maxColumnCount }}</span>
<button
class="w-8 h-8 rounded-lg border border-border hover:bg-muted transition-colors flex items-center justify-center text-lg font-light"
@click="step('maxColumnCount', 1, 1, 10)"
>
+
</button>
</div>
</div>
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium">Column Gap</p>
<p class="text-xs text-muted-foreground">Range: 0–50%</p>
</div>
<div class="flex items-center gap-2">
<button
class="w-8 h-8 rounded-lg border border-border hover:bg-muted transition-colors flex items-center justify-center text-lg font-light"
@click="step('gap', -0.01, 0, 0.5, 2)"
>
−
</button>
<span class="w-14 text-center text-sm font-mono font-medium">{{ formatGap(state.gap) }}</span>
<button
class="w-8 h-8 rounded-lg border border-border hover:bg-muted transition-colors flex items-center justify-center text-lg font-light"
@click="step('gap', 0.01, 0, 0.5, 2)"
>
+
</button>
</div>
</div>
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium">Max Width</p>
<p class="text-xs text-muted-foreground">Range: 400–1600px</p>
</div>
<div class="flex items-center gap-2">
<button
class="w-8 h-8 rounded-lg border border-border hover:bg-muted transition-colors flex items-center justify-center text-lg font-light"
@click="step('maxInlineSize', -40, 400, 1600)"
>
−
</button>
<span class="w-16 text-center text-sm font-mono font-medium">{{ state.maxInlineSize }}px</span>
<button
class="w-8 h-8 rounded-lg border border-border hover:bg-muted transition-colors flex items-center justify-center text-lg font-light"
@click="step('maxInlineSize', 40, 400, 1600)"
>
+
</button>
</div>
</div>
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium">Justify Text</p>
<p class="text-xs text-muted-foreground">Full-width justification</p>
<div class="space-y-4">
<div class="flex items-center justify-between gap-4">
<div class="space-y-1 pr-3">
<p class="text-sm font-medium leading-tight">Justify text</p>
<p class="text-xs leading-tight text-muted-foreground">Enable full-width paragraph alignment.</p>
</div>
<button
class="w-11 h-6 rounded-full transition-colors relative shrink-0"
class="relative h-6 w-11 shrink-0 rounded-full transition-colors"
:class="state.justify ? 'bg-primary' : 'bg-muted'"
@click="emit('update', { justify: !state.justify })"
>
<div
class="absolute top-1 w-4 h-4 rounded-full bg-white transition-transform shadow-sm"
class="absolute top-1 h-4 w-4 rounded-full bg-white shadow-sm transition-transform"
:class="state.justify ? 'translate-x-6' : 'translate-x-1'"
/>
</button>
</div>
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium">Hyphenation</p>
<p class="text-xs text-muted-foreground">Auto word-break hyphens</p>
<div class="flex items-center justify-between gap-4">
<div class="space-y-1 pr-3">
<p class="text-sm font-medium leading-tight">Hyphenation</p>
<p class="text-xs leading-tight text-muted-foreground">Enable automatic word-break hyphenation.</p>
</div>
<button
class="w-11 h-6 rounded-full transition-colors relative shrink-0"
class="relative h-6 w-11 shrink-0 rounded-full transition-colors"
:class="state.hyphenate ? 'bg-primary' : 'bg-muted'"
@click="emit('update', { hyphenate: !state.hyphenate })"
>
<div
class="absolute top-1 w-4 h-4 rounded-full bg-white transition-transform shadow-sm"
class="absolute top-1 h-4 w-4 rounded-full bg-white shadow-sm transition-transform"
:class="state.hyphenate ? 'translate-x-6' : 'translate-x-1'"
/>
</button>
</div>
</div>
</template>
</div>
</div>
</template>
</div>
</div>
</section>
</template>
<style scoped>
.settings-sheet {
animation: slideInFromBottom 0.25s ease;
}
@keyframes slideInFromBottom {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
</style>
@@ -5,6 +5,7 @@ export function useVisibility() {
const footerVisible = ref(false)
let isPinned = false
let isVisibilityLocked = false
let hideTimer: ReturnType<typeof setTimeout> | null = null
const HEADER_TRIGGER = 80
@@ -13,7 +14,7 @@ export function useVisibility() {
function scheduleHide() {
if (hideTimer) clearTimeout(hideTimer)
hideTimer = setTimeout(() => {
if (!isPinned) {
if (!isPinned && !isVisibilityLocked) {
headerVisible.value = false
footerVisible.value = false
}
@@ -21,6 +22,8 @@ export function useVisibility() {
}
function onMouseMove(e: MouseEvent) {
if (isVisibilityLocked) return
const y = e.clientY
const height = window.innerHeight
@@ -42,6 +45,8 @@ export function useVisibility() {
}
function handleMiddleTap() {
if (isVisibilityLocked) return
isPinned = !isPinned
headerVisible.value = isPinned
footerVisible.value = isPinned
@@ -51,6 +56,11 @@ export function useVisibility() {
}
function showHeader() {
if (isVisibilityLocked) {
headerVisible.value = true
return
}
if (!isPinned) {
headerVisible.value = true
scheduleHide()
@@ -58,12 +68,31 @@ export function useVisibility() {
}
function showFooter() {
if (isVisibilityLocked) {
footerVisible.value = true
return
}
if (!isPinned) {
footerVisible.value = true
scheduleHide()
}
}
function setVisibilityLock(locked: boolean) {
isVisibilityLocked = locked
if (hideTimer) clearTimeout(hideTimer)
if (locked || isPinned) {
headerVisible.value = true
footerVisible.value = true
return
}
scheduleHide()
}
onMounted(() => {
document.addEventListener('mousemove', onMouseMove)
})
@@ -73,5 +102,5 @@ export function useVisibility() {
if (hideTimer) clearTimeout(hideTimer)
})
return { headerVisible, footerVisible, handleMiddleTap, onMouseMove, showHeader, showFooter }
return { headerVisible, footerVisible, handleMiddleTap, onMouseMove, showHeader, showFooter, setVisibilityLock }
}