mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(reader): improve CBZ and ebook options layout
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user