feat(reader): redesign comic settings as one panel in two containers

Replace the three-tab settings dropdown with a single scrollable column
rendered in a desktop popover and a mobile bottom sheet. Group spread
controls contextually under two-page layout, render backgrounds as color
swatches, move spread gap onto a labelled slider, and disable the header
reset while the book carries no overrides. Persist through the panel
update handler so reset no longer re-records defaults as overrides.
Restructure reader.cbz locale keys across all catalogs.
This commit is contained in:
neon
2026-08-04 00:02:08 +00:00
parent 563086328d
commit 854b1a75d1
21 changed files with 602 additions and 648 deletions
@@ -3,6 +3,7 @@ import { flushPromises, shallowMount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import CbzReaderView from './CbzReaderView.vue'
import CbzSettingsPanel from './components/CbzSettingsPanel.vue'
const mocks = vi.hoisted(() => ({
savedMode: 'infinite' as 'paginated' | 'infinite' | 'long-strip',
@@ -14,6 +15,8 @@ const mocks = vi.hoisted(() => ({
pageUrl: vi.fn<(page: number) => string>((page) => `/api/v1/cbz/files/22/pages/${page}`),
progressSave: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
scrollToIndex: vi.fn<(index: number, options: { align: string }) => void>(),
updateBookSettings: vi.fn<(patch: unknown) => void>(),
resetBookSettings: vi.fn<() => void>(),
}))
vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) }))
@@ -93,9 +96,10 @@ vi.mock('../shared/composables/useReaderSettings', () => ({
widePageSingletonMode: 'auto',
bgColor: 'black',
})),
isCustomized: ref(false),
load: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
updateBookSettings: vi.fn<(patch: unknown) => void>(),
resetBookSettings: vi.fn<() => void>(),
updateBookSettings: mocks.updateBookSettings,
resetBookSettings: mocks.resetBookSettings,
}),
}))
@@ -184,4 +188,33 @@ describe('CbzReaderView', () => {
wrapper.unmount()
})
it('routes settings panel updates and reset through the per-book settings store', async () => {
mocks.savedMode = 'paginated'
const wrapper = shallowMount(CbzReaderView, {
props: { bookId: 11, fileId: 22 },
global: {
stubs: {
Popover: { template: '<div><slot /></div>' },
PopoverTrigger: { template: '<div><slot /></div>' },
PopoverContent: { template: '<div><slot /></div>' },
},
},
})
await flushPromises()
await nextTick()
const panel = wrapper.getComponent(CbzSettingsPanel)
expect(panel.props('settings')).toMatchObject({ fitMode: 'fit-page', bgColor: 'black' })
panel.vm.$emit('update', { bgColor: 'white' })
await nextTick()
expect(mocks.updateBookSettings).toHaveBeenCalledWith({ bgColor: 'white' })
panel.vm.$emit('reset')
expect(mocks.resetBookSettings).toHaveBeenCalledOnce()
wrapper.unmount()
})
})
+92 -449
View File
@@ -1,43 +1,23 @@
<script setup lang="ts">
import { type Component, computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
import { useVirtualizer } from '@tanstack/vue-virtual'
import {
AlignJustify,
ArrowDownUp,
ArrowLeft,
ArrowLeftRight,
ArrowRight,
BookOpen,
Circle,
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
Image as ImageIcon,
Info,
Layers,
LayoutGrid,
Maximize,
Minimize,
Moon,
ScanLine,
Settings,
Sun,
} from '@lucide/vue'
import { useMediaQuery } from '@vueuse/core'
import { ArrowLeft, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Maximize, Minimize, Settings } from '@lucide/vue'
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, SpreadAlignment, ViewMode, WidePageSingletonMode } from './composables/useCbzSettings'
import { useReaderSettings } from '../shared/composables/useReaderSettings'
import { useFullscreen } from '../shared/composables/useFullscreen'
import { CBX_SPREAD_GAP_MAX, CBX_SPREAD_GAP_MIN, type CbxReaderSettings } from '@bookorbit/types'
import type { CbxReaderSettings } from '@bookorbit/types'
import { DEFAULT_WIDE_PAGE_RATIO_THRESHOLD, createCbzSpreadLayout } from './lib/spread-layout'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Sheet, SheetContent } from '@/components/ui/sheet'
import CbzSettingsPanel from './components/CbzSettingsPanel.vue'
const TWO_PAGE_BREAKPOINT = 900
@@ -67,15 +47,6 @@ const bookSettings = useReaderSettings(props.fileId, 'cbz')
const currentPage = ref(0)
const showSettings = ref(false)
type SettingsTab = 'view' | 'reading' | 'layout'
const settingsTab = ref<SettingsTab>('view')
const settingsContentRef = ref<HTMLElement | null>(null)
const hasSettingsTabBarShadow = ref(false)
const settingsScrollMemory = ref<Record<SettingsTab, number>>({
view: 0,
reading: 0,
layout: 0,
})
const scrollContainer = ref<HTMLElement | null>(null)
const viewportWidth = ref(0)
const viewportHeight = ref(0)
@@ -84,104 +55,46 @@ const pendingImageLoads = ref(0)
const loadedImageCount = ref(0)
const pageRatios = ref<number[]>([])
const stripImagesReady = ref(false)
const highlightForceTwoPage = ref(false)
const forceTwoPageToggleButton = ref<HTMLButtonElement | null>(null)
let forceToggleHighlightTimer: ReturnType<typeof setTimeout> | null = null
watch(showSettings, (open) => {
setVisibilityLock(open)
if (!open) return
settingsTab.value = 'view'
nextTick(() => {
if (!settingsContentRef.value) return
settingsContentRef.value.scrollTop = settingsScrollMemory.value.view ?? 0
hasSettingsTabBarShadow.value = settingsContentRef.value.scrollTop > 0
})
})
watch(showSettings, setVisibilityLock)
// ── Settings options ───────────────────────────────────────────────────────────
const FIT_OPTIONS = computed<{ value: FitMode; label: string; icon: Component }[]>(() => [
{ value: 'fit-page', label: t('reader.cbz.fit.page'), icon: Maximize },
{ value: 'fit-width', label: t('reader.cbz.fit.width'), icon: ArrowLeftRight },
{ value: 'fit-height', label: t('reader.cbz.fit.height'), icon: ArrowDownUp },
{ value: 'actual', label: t('reader.cbz.fit.actual'), icon: ImageIcon },
])
const VIEW_OPTIONS = computed<{ value: ViewMode; label: string; icon: Component }[]>(() => [
{ value: 'single', label: t('reader.cbz.view.single'), icon: BookOpen },
{ value: 'two-page', label: t('reader.cbz.view.twoPage'), icon: LayoutGrid },
])
const SCROLL_OPTIONS = computed<{ value: ScrollMode; label: string; icon: Component }[]>(() => [
{ value: 'paginated', label: t('reader.cbz.scroll.paged'), icon: ScanLine },
{ value: 'infinite', label: t('reader.cbz.scroll.infinite'), icon: Layers },
{ value: 'long-strip', label: t('reader.cbz.scroll.noGaps'), icon: AlignJustify },
])
const DIRECTION_OPTIONS = computed<{ value: Direction; label: string; icon: Component }[]>(() => [
{ value: 'ltr', label: t('reader.cbz.direction.ltr'), icon: ArrowRight },
{ value: 'rtl', label: t('reader.cbz.direction.rtl'), icon: ArrowLeft },
])
const SPREAD_ALIGNMENT_OPTIONS = computed<{ value: SpreadAlignment; label: string; icon: Component }[]>(() => [
{ value: 'normal', label: t('reader.cbz.spreadAlignment.normal'), icon: LayoutGrid },
{ value: 'shifted', label: t('reader.cbz.spreadAlignment.shifted'), icon: BookOpen },
])
const WIDE_PAGE_OPTIONS = computed<{ value: WidePageSingletonMode; label: string; icon: Component }[]>(() => [
{ value: 'auto', label: t('reader.cbz.widePage.auto'), icon: ImageIcon },
{ value: 'disable', label: t('reader.cbz.widePage.inSpreads'), icon: LayoutGrid },
])
const BG_OPTIONS = computed<{ value: BgColor; label: string; icon: Component }[]>(() => [
{ value: 'black', label: t('reader.cbz.bg.black'), icon: Moon },
{ value: 'gray', label: t('reader.cbz.bg.gray'), icon: Circle },
{ value: 'white', label: t('reader.cbz.bg.white'), icon: Sun },
])
// The settings surface is one panel in two containers: an anchored popover where there
// is room beside the page, a bottom sheet where the thumb is and the page must stay visible.
const isCompact = useMediaQuery('(max-width: 639px)')
function onSettingsContentScroll() {
if (!settingsContentRef.value) return
settingsScrollMemory.value[settingsTab.value] = settingsContentRef.value.scrollTop
hasSettingsTabBarShadow.value = settingsContentRef.value.scrollTop > 0
const panelSettings = computed<CbxReaderSettings>(() => ({
fitMode: fitMode.value,
viewMode: viewMode.value,
scrollMode: scrollMode.value,
direction: direction.value,
spreadAlignment: spreadAlignment.value,
spreadGap: spreadGap.value,
forceTwoPage: forceTwoPage.value,
widePageSingletonMode: widePageSingletonMode.value,
bgColor: bgColor.value,
}))
// Persists here rather than via watches on the refs, so applySettings can restore
// load-time or reset values without immediately re-recording them as book overrides.
function applyPanelUpdate(partial: Partial<CbxReaderSettings>) {
if (partial.fitMode !== undefined) fitMode.value = partial.fitMode
if (partial.viewMode !== undefined) viewMode.value = partial.viewMode
if (partial.scrollMode !== undefined) scrollMode.value = partial.scrollMode
if (partial.direction !== undefined) direction.value = partial.direction
if (partial.spreadAlignment !== undefined) spreadAlignment.value = partial.spreadAlignment
if (partial.spreadGap !== undefined) spreadGap.value = partial.spreadGap
if (partial.forceTwoPage !== undefined) forceTwoPage.value = partial.forceTwoPage
if (partial.widePageSingletonMode !== undefined) widePageSingletonMode.value = partial.widePageSingletonMode
if (partial.bgColor !== undefined) bgColor.value = partial.bgColor
bookSettings.updateBookSettings(partial)
}
function setSettingsTab(tab: SettingsTab) {
if (tab === settingsTab.value) return
if (settingsContentRef.value) {
settingsScrollMemory.value[settingsTab.value] = settingsContentRef.value.scrollTop
}
settingsTab.value = tab
nextTick(() => {
if (!settingsContentRef.value) return
settingsContentRef.value.scrollTop = settingsScrollMemory.value[tab] ?? 0
hasSettingsTabBarShadow.value = settingsContentRef.value.scrollTop > 0
})
function onSettingsOpenChange(open: boolean) {
showSettings.value = open
}
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 setSpreadAlignment(v: SpreadAlignment) {
spreadAlignment.value = v
}
function setSpreadGapFromEvent(event: Event) {
const target = event.target
if (!(target instanceof HTMLInputElement)) return
const value = Number(target.value)
if (!Number.isInteger(value) || value < CBX_SPREAD_GAP_MIN || value > CBX_SPREAD_GAP_MAX) return
spreadGap.value = value
}
function setWidePageMode(v: WidePageSingletonMode) {
widePageSingletonMode.value = v
}
function setForceTwoPage(v: boolean) {
forceTwoPage.value = v
}
function setBgColor(v: BgColor) {
bgColor.value = v
function openSettings() {
showSettings.value = true
}
function applySettings(s: CbxReaderSettings) {
@@ -204,22 +117,6 @@ function resetBookViewSettings() {
}
}
function confirmResetBookViewSettings() {
if (!confirm(t('reader.cbz.resetConfirm'))) return
resetBookViewSettings()
}
function focusForceTwoPageFromHint() {
nextTick(() => {
forceTwoPageToggleButton.value?.focus()
highlightForceTwoPage.value = true
if (forceToggleHighlightTimer) clearTimeout(forceToggleHighlightTimer)
forceToggleHighlightTimer = setTimeout(() => {
highlightForceTwoPage.value = false
}, 1400)
})
}
// ── Layout engine ──────────────────────────────────────────────────────────────
const isTwoPagePreferred = computed(() => viewMode.value === 'two-page' && scrollMode.value === 'paginated')
const isTwoPageEffective = computed(() => isTwoPagePreferred.value && (forceTwoPage.value || viewportWidth.value >= TWO_PAGE_BREAKPOINT))
@@ -249,10 +146,6 @@ const renderKey = computed(() => {
return `spread:${spread.leftPage ?? 'blank'}:${spread.rightPage ?? 'blank'}`
})
const showSpreadAlignmentControl = computed(() => isTwoPageEffective.value)
const showAutoFallbackBadge = computed(() => isTwoPagePreferred.value && !isTwoPageEffective.value)
const showSpreadAlignmentHint = computed(() => isTwoPagePreferred.value && !isTwoPageEffective.value)
const pageLabel = computed(() => {
const spread = currentSpread.value
if (!spread || pageCount.value <= 0) return '0 / 0'
@@ -670,16 +563,6 @@ onMounted(async () => {
applySettings(bookSettings.effective.value as CbxReaderSettings)
watch(fitMode, (v) => bookSettings.updateBookSettings({ fitMode: v }))
watch(viewMode, (v) => bookSettings.updateBookSettings({ viewMode: v }))
watch(scrollMode, (v) => bookSettings.updateBookSettings({ scrollMode: v }))
watch(direction, (v) => bookSettings.updateBookSettings({ direction: v }))
watch(spreadAlignment, (v) => bookSettings.updateBookSettings({ spreadAlignment: v }))
watch(spreadGap, (v) => bookSettings.updateBookSettings({ spreadGap: v }))
watch(forceTwoPage, (v) => bookSettings.updateBookSettings({ forceTwoPage: v }))
watch(widePageSingletonMode, (v) => bookSettings.updateBookSettings({ widePageSingletonMode: v }))
watch(bgColor, (v) => bookSettings.updateBookSettings({ bgColor: v }))
await load()
const saved = progress.pageNumber.value
if (saved && saved > 1) {
@@ -710,7 +593,6 @@ onUnmounted(() => {
window.removeEventListener('keydown', onKeyDown)
if (stripScrollFrame !== null) cancelAnimationFrame(stripScrollFrame)
void flushPendingProgress()
if (forceToggleHighlightTimer) clearTimeout(forceToggleHighlightTimer)
})
</script>
@@ -745,303 +627,64 @@ onUnmounted(() => {
</TooltipTrigger>
<TooltipContent>{{ fullscreenLabel }}</TooltipContent>
</Tooltip>
<DropdownMenu v-model:open="showSettings">
<DropdownMenuTrigger as-child>
<button class="viewer-btn" :class="showSettings ? '!bg-muted !text-foreground' : ''">
<template v-if="isCompact">
<button
class="viewer-btn"
:class="showSettings ? '!bg-muted !text-foreground' : ''"
:title="t('reader.settings.title')"
:aria-label="t('reader.settings.ariaLabel')"
@click="openSettings"
>
<Settings :size="15" />
</button>
<Sheet :open="showSettings" @update:open="onSettingsOpenChange">
<SheetContent
side="bottom"
hide-close
class="max-h-[85vh] gap-0 rounded-t-2xl border-border bg-card p-0"
:aria-label="t('reader.settings.ariaLabel')"
>
<div class="flex shrink-0 justify-center pt-2.5 pb-1">
<div class="h-1 w-9 rounded-full bg-border" />
</div>
<CbzSettingsPanel
:settings="panelSettings"
:can-reset="bookSettings.isCustomized.value"
:is-spread-active="isTwoPageEffective"
@update="applyPanelUpdate"
@reset="resetBookViewSettings"
/>
</SheetContent>
</Sheet>
</template>
<Popover v-else :open="showSettings" @update:open="onSettingsOpenChange">
<PopoverTrigger as-child>
<button
class="viewer-btn"
:class="showSettings ? '!bg-muted !text-foreground' : ''"
:title="t('reader.settings.title')"
:aria-label="t('reader.settings.ariaLabel')"
>
<Settings :size="15" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
</PopoverTrigger>
<PopoverContent
align="end"
side="bottom"
:side-offset="10"
class="w-[22rem] max-w-[calc(100vw-1rem)] max-h-[min(80vh,38rem)] rounded-lg border-border bg-card p-0 shadow-2xl overflow-hidden"
class="flex max-h-[min(80vh,40rem)] w-[21rem] max-w-[calc(100vw-1rem)] flex-col overflow-hidden rounded-xl border-border bg-card p-0 shadow-2xl"
>
<section
class="bg-card text-card-foreground flex max-h-[min(80vh,38rem)] flex-col overflow-hidden [&_button:focus-visible]:outline-none [&_button:focus-visible]:ring-2 [&_button:focus-visible]:ring-primary/55 [&_button:focus-visible]:ring-offset-1 [&_button:focus-visible]:ring-offset-card"
>
<div
class="sticky top-0 z-10 border-b border-border bg-card/95 px-3 py-3 backdrop-blur-sm transition-shadow"
:class="hasSettingsTabBarShadow ? 'shadow-sm' : ''"
>
<div class="grid grid-cols-3 gap-1 rounded-lg bg-muted/55 p-1">
<button
class="flex h-8.5 min-w-0 items-center justify-center gap-1.5 rounded-lg px-2 text-[12px] font-medium leading-none transition-colors"
:class="
settingsTab === 'view'
? 'bg-background text-foreground shadow-sm ring-1 ring-border'
: 'text-muted-foreground hover:text-foreground hover:bg-background/70'
"
@click.stop="setSettingsTab('view')"
>
<ImageIcon :size="13" />
<span class="truncate whitespace-nowrap">{{ t('reader.cbz.tabs.view') }}</span>
</button>
<button
class="flex h-8.5 min-w-0 items-center justify-center gap-1.5 rounded-lg px-2 text-[12px] font-medium leading-none transition-colors"
:class="
settingsTab === 'reading'
? 'bg-background text-foreground shadow-sm ring-1 ring-border'
: 'text-muted-foreground hover:text-foreground hover:bg-background/70'
"
@click.stop="setSettingsTab('reading')"
>
<ScanLine :size="13" />
<span class="truncate whitespace-nowrap">{{ t('reader.cbz.tabs.reading') }}</span>
</button>
<button
class="flex h-8.5 min-w-0 items-center justify-center gap-1.5 rounded-lg px-2 text-[12px] font-medium leading-none transition-colors"
:class="
settingsTab === 'layout'
? 'bg-background text-foreground shadow-sm ring-1 ring-border'
: 'text-muted-foreground hover:text-foreground hover:bg-background/70'
"
@click.stop="setSettingsTab('layout')"
>
<LayoutGrid :size="13" />
<span class="truncate whitespace-nowrap">{{ t('reader.cbz.tabs.layout') }}</span>
</button>
</div>
</div>
<div ref="settingsContentRef" class="overflow-y-auto p-5.5 space-y-6" @scroll="onSettingsContentScroll">
<template v-if="settingsTab === 'view'">
<div class="space-y-6">
<div>
<p class="mb-2 text-[13px] font-medium text-foreground">{{ t('reader.cbz.fitMode') }}</p>
<div class="grid grid-cols-2 gap-1 rounded-lg bg-muted/55 p-1">
<button
v-for="opt in FIT_OPTIONS"
:key="opt.value"
class="flex h-[2.125rem] min-w-0 items-center justify-center gap-1.5 rounded-lg px-2.5 text-[12px] font-medium leading-none transition-colors"
:class="
fitMode === opt.value
? 'bg-background text-foreground shadow-sm ring-1 ring-border'
: 'text-muted-foreground hover:text-foreground hover:bg-background/70'
"
@click.stop="setFitMode(opt.value)"
>
<component :is="opt.icon" :size="13" />
<span class="truncate whitespace-nowrap">{{ opt.label }}</span>
</button>
</div>
</div>
<div class="h-px bg-border/70" />
<div>
<p class="mb-2 text-[13px] font-medium text-foreground">{{ t('reader.cbz.background') }}</p>
<div class="grid grid-cols-3 gap-1 rounded-lg bg-muted/55 p-1">
<button
v-for="opt in BG_OPTIONS"
:key="opt.value"
class="flex h-[2.125rem] min-w-0 items-center justify-center gap-1.5 rounded-lg px-2.5 text-[12px] font-medium leading-none transition-colors"
:class="
bgColor === opt.value
? 'bg-background text-foreground shadow-sm ring-1 ring-border'
: 'text-muted-foreground hover:text-foreground hover:bg-background/70'
"
@click.stop="setBgColor(opt.value)"
>
<component :is="opt.icon" :size="13" />
<span class="truncate whitespace-nowrap">{{ opt.label }}</span>
</button>
</div>
</div>
</div>
</template>
<template v-else-if="settingsTab === 'reading'">
<div class="space-y-6">
<div>
<p class="mb-2 text-[13px] font-medium text-foreground">{{ t('reader.cbz.scrollMode') }}</p>
<div class="grid grid-cols-3 gap-1 rounded-lg bg-muted/55 p-1">
<button
v-for="opt in SCROLL_OPTIONS"
:key="opt.value"
class="flex h-[2.125rem] min-w-0 items-center justify-center gap-1.5 rounded-lg px-2 text-[12px] font-medium leading-none transition-colors"
:class="
scrollMode === opt.value
? 'bg-background text-foreground shadow-sm ring-1 ring-border'
: 'text-muted-foreground hover:text-foreground hover:bg-background/70'
"
@click.stop="setScrollMode(opt.value)"
>
<component :is="opt.icon" :size="13" />
<span class="truncate whitespace-nowrap">{{ opt.label }}</span>
</button>
</div>
<p class="mt-1.5 text-[11px] leading-tight text-muted-foreground">{{ t('reader.cbz.scrollModeHint') }}</p>
</div>
<div class="h-px bg-border/70" />
<div>
<p class="mb-2 text-[13px] font-medium text-foreground">{{ t('reader.cbz.readingDirection') }}</p>
<div class="grid grid-cols-2 gap-1 rounded-lg bg-muted/55 p-1">
<button
v-for="opt in DIRECTION_OPTIONS"
:key="opt.value"
class="flex h-[2.125rem] min-w-0 items-center justify-center gap-1.5 rounded-lg px-2.5 text-[12px] font-medium leading-none transition-colors"
:class="
direction === opt.value
? 'bg-background text-foreground shadow-sm ring-1 ring-border'
: 'text-muted-foreground hover:text-foreground hover:bg-background/70'
"
@click.stop="setDirection(opt.value)"
>
<component :is="opt.icon" :size="13" />
<span class="truncate whitespace-nowrap">{{ opt.label }}</span>
</button>
</div>
</div>
</div>
</template>
<template v-else>
<div class="space-y-6">
<div>
<div class="mb-2 flex items-center justify-between">
<p class="text-[13px] font-medium text-foreground">{{ t('reader.cbz.pageView') }}</p>
<span
v-if="showAutoFallbackBadge"
class="inline-flex items-center gap-1 rounded-full border border-border bg-muted px-2 py-0.5 text-[10px] text-muted-foreground"
>
<Info :size="11" />
{{ t('reader.cbz.autoFallback') }}
</span>
</div>
<div class="grid grid-cols-2 gap-1 rounded-lg bg-muted/55 p-1">
<button
v-for="opt in VIEW_OPTIONS"
:key="opt.value"
class="flex h-[2.125rem] min-w-0 items-center justify-center gap-1.5 rounded-lg px-2.5 text-[12px] font-medium leading-none transition-colors"
:class="
viewMode === opt.value
? 'bg-background text-foreground shadow-sm ring-1 ring-border'
: 'text-muted-foreground hover:text-foreground hover:bg-background/70'
"
@click.stop="setViewMode(opt.value)"
>
<component :is="opt.icon" :size="13" />
<span class="truncate whitespace-nowrap">{{ opt.label }}</span>
</button>
</div>
</div>
<div class="h-px bg-border/70" />
<div v-if="isTwoPagePreferred">
<div class="mb-2 flex items-center justify-between gap-3">
<label for="cbz-spread-gap" class="text-[13px] font-medium text-foreground">{{ t('reader.cbz.spreadGap') }}</label>
<span class="text-[11px] tabular-nums text-muted-foreground">
{{ t('reader.cbz.spreadGapValue', { value: spreadGap }) }}
</span>
</div>
<input
id="cbz-spread-gap"
type="range"
:min="CBX_SPREAD_GAP_MIN"
:max="CBX_SPREAD_GAP_MAX"
:value="spreadGap"
class="w-full accent-primary"
@input="setSpreadGapFromEvent"
/>
</div>
<div v-if="isTwoPagePreferred" class="h-px bg-border/70" />
<div v-if="showSpreadAlignmentControl">
<p class="mb-2 text-[13px] font-medium text-foreground">{{ t('reader.cbz.spreadAlignmentLabel') }}</p>
<div class="grid grid-cols-2 gap-1 rounded-lg bg-muted/55 p-1">
<button
v-for="opt in SPREAD_ALIGNMENT_OPTIONS"
:key="opt.value"
class="flex h-[2.125rem] min-w-0 items-center justify-center gap-1.5 rounded-lg px-2.5 text-[12px] font-medium leading-none transition-colors"
:class="
spreadAlignment === opt.value
? 'bg-background text-foreground shadow-sm ring-1 ring-border'
: 'text-muted-foreground hover:text-foreground hover:bg-background/70'
"
@click.stop="setSpreadAlignment(opt.value)"
>
<component :is="opt.icon" :size="13" />
<span class="truncate whitespace-nowrap">{{ opt.label }}</span>
</button>
</div>
</div>
<div v-else-if="showSpreadAlignmentHint" class="rounded-lg border border-border/70 bg-muted/35 px-3 py-2">
<p class="text-xs leading-tight text-muted-foreground">{{ t('reader.cbz.spreadAlignmentHint') }}</p>
<button class="mt-1 text-xs text-primary hover:underline" @click.stop="focusForceTwoPageFromHint">
{{ t('reader.cbz.focusTwoPageToggle') }}
</button>
</div>
<div>
<p class="mb-2 text-[13px] font-medium text-foreground">{{ t('reader.cbz.forceTwoPage') }}</p>
<div class="grid grid-cols-2 gap-1 rounded-lg bg-muted/55 p-1">
<button
ref="forceTwoPageToggleButton"
class="flex h-[2.125rem] min-w-0 items-center justify-center rounded-lg px-2.5 text-[12px] font-medium leading-none transition-colors"
:class="[
!forceTwoPage
? 'bg-background text-foreground shadow-sm ring-1 ring-border'
: 'text-muted-foreground hover:text-foreground hover:bg-background/70',
highlightForceTwoPage ? 'ring-2 ring-primary/50' : '',
]"
@click.stop="setForceTwoPage(false)"
>
{{ t('reader.cbz.off') }}
</button>
<button
class="flex h-[2.125rem] min-w-0 items-center justify-center rounded-lg px-2.5 text-[12px] font-medium leading-none transition-colors"
:class="
forceTwoPage
? 'bg-background text-foreground shadow-sm ring-1 ring-border'
: 'text-muted-foreground hover:text-foreground hover:bg-background/70'
"
@click.stop="setForceTwoPage(true)"
>
{{ t('reader.cbz.on') }}
</button>
</div>
</div>
<div>
<p class="mb-2 text-[13px] font-medium text-foreground">{{ t('reader.cbz.widePages') }}</p>
<div class="grid grid-cols-2 gap-1 rounded-lg bg-muted/55 p-1">
<button
v-for="opt in WIDE_PAGE_OPTIONS"
:key="opt.value"
class="flex h-[2.125rem] min-w-0 items-center justify-center gap-1.5 rounded-lg px-2.5 text-[12px] font-medium leading-none transition-colors"
:class="
widePageSingletonMode === opt.value
? 'bg-background text-foreground shadow-sm ring-1 ring-border'
: 'text-muted-foreground hover:text-foreground hover:bg-background/70'
"
@click.stop="setWidePageMode(opt.value)"
>
<component :is="opt.icon" :size="13" />
<span class="truncate whitespace-nowrap">{{ opt.label }}</span>
</button>
</div>
</div>
<div class="h-px bg-border/70" />
<button
class="w-full rounded-lg border border-destructive/40 px-3 py-2 text-sm font-medium text-destructive transition-colors hover:bg-destructive/10"
@click.stop="confirmResetBookViewSettings"
>
{{ t('reader.cbz.resetBookViewSettings') }}
</button>
</div>
</template>
</div>
</section>
</DropdownMenuContent>
</DropdownMenu>
<CbzSettingsPanel
:settings="panelSettings"
:can-reset="bookSettings.isCustomized.value"
:is-spread-active="isTwoPageEffective"
@update="applyPanelUpdate"
@reset="resetBookViewSettings"
/>
</PopoverContent>
</Popover>
</div>
</div>
@@ -0,0 +1,276 @@
<script setup lang="ts">
import { computed, ref, useId } from 'vue'
import { useI18n } from 'vue-i18n'
import {
AlignJustify,
ArrowDownUp,
ArrowLeft,
ArrowLeftRight,
ArrowRight,
BookOpen,
Image as ImageIcon,
Layers,
LayoutGrid,
Maximize,
RotateCcw,
ScanLine,
} from '@lucide/vue'
import type { Component } from 'vue'
import { CBX_SPREAD_GAP_MAX, CBX_SPREAD_GAP_MIN, type CbxReaderSettings } from '@bookorbit/types'
import { CBZ_BG_VALUES } from '../composables/useCbzSettings'
import type { BgColor, FitMode } from '../composables/useCbzSettings'
import ReaderRangeField from '@/features/reader/shared/components/ReaderRangeField.vue'
import ReaderSegmentedControl from '@/features/reader/shared/components/ReaderSegmentedControl.vue'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
const { t } = useI18n()
const props = defineProps<{
settings: CbxReaderSettings
canReset?: boolean
/** Whether spreads are actually rendering; false means the viewport fell back to single pages. */
isSpreadActive?: boolean
}>()
const emit = defineEmits<{
update: [partial: Partial<CbxReaderSettings>]
reset: []
}>()
const contentRef = ref<HTMLElement | null>(null)
const isScrolled = ref(false)
const forceTwoPageLabelId = `cbz-force-two-page-${useId()}`
function onContentScroll() {
isScrolled.value = (contentRef.value?.scrollTop ?? 0) > 0
}
const fitOptions = computed<{ value: FitMode; label: string; icon: Component }[]>(() => [
{ value: 'fit-page', label: t('reader.cbz.fit.page'), icon: Maximize },
{ value: 'fit-width', label: t('reader.cbz.fit.width'), icon: ArrowLeftRight },
{ value: 'fit-height', label: t('reader.cbz.fit.height'), icon: ArrowDownUp },
{ value: 'actual', label: t('reader.cbz.fit.actual'), icon: ImageIcon },
])
const viewOptions = computed(() => [
{ value: 'single', label: t('reader.cbz.view.single'), icon: BookOpen },
{ value: 'two-page', label: t('reader.cbz.view.twoPage'), icon: LayoutGrid },
])
const scrollOptions = computed(() => [
{ value: 'paginated', label: t('reader.cbz.scroll.paged'), icon: ScanLine },
{ value: 'infinite', label: t('reader.cbz.scroll.infinite'), icon: Layers },
{ value: 'long-strip', label: t('reader.cbz.scroll.noGaps'), icon: AlignJustify },
])
const directionOptions = computed(() => [
{ value: 'ltr', label: t('reader.cbz.direction.ltr'), icon: ArrowRight },
{ value: 'rtl', label: t('reader.cbz.direction.rtl'), icon: ArrowLeft },
])
const alignmentOptions = computed(() => [
{ value: 'normal', label: t('reader.cbz.spreadAlignment.normal'), icon: LayoutGrid },
{ value: 'shifted', label: t('reader.cbz.spreadAlignment.shifted'), icon: BookOpen },
])
const widePageOptions = computed(() => [
{ value: 'auto', label: t('reader.cbz.widePage.auto'), icon: ImageIcon },
{ value: 'disable', label: t('reader.cbz.widePage.inSpreads'), icon: LayoutGrid },
])
const bgSwatches = computed<{ value: BgColor; label: string; color: string }[]>(() => [
{ value: 'black', label: t('reader.cbz.bg.black'), color: CBZ_BG_VALUES.black },
{ value: 'gray', label: t('reader.cbz.bg.gray'), color: CBZ_BG_VALUES.gray },
{ value: 'white', label: t('reader.cbz.bg.white'), color: CBZ_BG_VALUES.white },
])
const isTwoPagePreferred = computed(() => props.settings.viewMode === 'two-page' && props.settings.scrollMode === 'paginated')
const showPagedOnlyHint = computed(() => props.settings.viewMode === 'two-page' && props.settings.scrollMode !== 'paginated')
function selectFit(value: FitMode) {
emit('update', { fitMode: value })
}
function setViewMode(value: string) {
emit('update', { viewMode: value as CbxReaderSettings['viewMode'] })
}
function setScrollMode(value: string) {
emit('update', { scrollMode: value as CbxReaderSettings['scrollMode'] })
}
function setDirection(value: string) {
emit('update', { direction: value as CbxReaderSettings['direction'] })
}
function setSpreadAlignment(value: string) {
emit('update', { spreadAlignment: value as CbxReaderSettings['spreadAlignment'] })
}
function setWidePageMode(value: string) {
emit('update', { widePageSingletonMode: value as CbxReaderSettings['widePageSingletonMode'] })
}
function setSpreadGap(value: number) {
emit('update', { spreadGap: Math.round(value) })
}
function setForceTwoPage(value: boolean) {
emit('update', { forceTwoPage: value })
}
function selectBackground(value: BgColor) {
emit('update', { bgColor: value })
}
function requestReset() {
emit('reset')
}
const groupLabelClass = 'mb-2 block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground'
</script>
<template>
<section class="flex min-h-0 flex-col overflow-hidden bg-card text-card-foreground">
<div class="flex shrink-0 items-center gap-2 border-b border-border bg-card px-4 py-2.5 transition-shadow" :class="isScrolled ? 'shadow-sm' : ''">
<h2 class="mr-auto text-sm font-semibold">{{ t('reader.settings.title') }}</h2>
<button
type="button"
class="flex size-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/55 disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-muted-foreground"
:disabled="!canReset"
:title="t('reader.settings.reset')"
:aria-label="t('reader.settings.reset')"
@click="requestReset"
>
<RotateCcw :size="15" />
</button>
</div>
<div ref="contentRef" class="min-h-0 flex-1 overflow-y-auto" @scroll="onContentScroll">
<div class="border-b border-border px-4 py-3.5">
<p :class="groupLabelClass">{{ t('reader.cbz.fitMode') }}</p>
<div class="grid grid-cols-2 gap-2">
<button
v-for="option in fitOptions"
:key="option.value"
type="button"
class="flex h-10 min-w-0 items-center justify-center gap-1.5 truncate rounded-lg border px-2 text-[13px] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/55 focus-visible:ring-offset-1 focus-visible:ring-offset-card"
:class="
settings.fitMode === option.value
? 'border-primary bg-primary text-primary-foreground'
: 'border-border text-foreground hover:border-muted-foreground/40 hover:bg-muted'
"
:aria-pressed="settings.fitMode === option.value"
@click="selectFit(option.value)"
>
<component :is="option.icon" :size="14" class="shrink-0" />
<span class="truncate">{{ option.label }}</span>
</button>
</div>
</div>
<div class="border-b border-border px-4 py-3.5">
<p :class="groupLabelClass">{{ t('reader.cbz.pageView') }}</p>
<ReaderSegmentedControl
:options="viewOptions"
:model-value="settings.viewMode"
:aria-label="t('reader.cbz.pageView')"
@update:model-value="setViewMode"
/>
<p v-if="showPagedOnlyHint" class="mt-2 text-xs leading-snug text-muted-foreground">{{ t('reader.cbz.twoPagePagedOnly') }}</p>
</div>
<div v-if="isTwoPagePreferred" class="border-b border-border px-4 py-3.5">
<p :class="groupLabelClass">{{ t('reader.cbz.spreadSection') }}</p>
<div class="space-y-4">
<p v-if="!isSpreadActive" class="text-xs leading-snug text-muted-foreground">{{ t('reader.cbz.spreadFallbackHint') }}</p>
<div class="flex items-center justify-between gap-3">
<span :id="forceTwoPageLabelId" class="text-[13px] font-medium text-foreground">{{ t('reader.cbz.forceTwoPage') }}</span>
<ToggleSwitch :model-value="settings.forceTwoPage" :aria-labelledby="forceTwoPageLabelId" @update:model-value="setForceTwoPage" />
</div>
<div>
<p :class="groupLabelClass">{{ t('reader.cbz.spreadAlignmentLabel') }}</p>
<ReaderSegmentedControl
:options="alignmentOptions"
:model-value="settings.spreadAlignment"
:aria-label="t('reader.cbz.spreadAlignmentLabel')"
@update:model-value="setSpreadAlignment"
/>
</div>
<div>
<p :class="groupLabelClass">{{ t('reader.cbz.widePages') }}</p>
<ReaderSegmentedControl
:options="widePageOptions"
:model-value="settings.widePageSingletonMode"
:aria-label="t('reader.cbz.widePages')"
@update:model-value="setWidePageMode"
/>
</div>
<ReaderRangeField
:model-value="settings.spreadGap"
:min="CBX_SPREAD_GAP_MIN"
:max="CBX_SPREAD_GAP_MAX"
:step="1"
:label="t('reader.cbz.spreadGap')"
:display-value="t('reader.cbz.spreadGapValue', { value: settings.spreadGap })"
@update:model-value="setSpreadGap"
/>
</div>
</div>
<div class="border-b border-border px-4 py-3.5">
<p :class="groupLabelClass">{{ t('reader.cbz.scrollMode') }}</p>
<ReaderSegmentedControl
:options="scrollOptions"
:model-value="settings.scrollMode"
:aria-label="t('reader.cbz.scrollMode')"
@update:model-value="setScrollMode"
/>
<p class="mt-2 text-xs leading-snug text-muted-foreground">{{ t('reader.cbz.scrollModeHint') }}</p>
</div>
<div class="border-b border-border px-4 py-3.5">
<p :class="groupLabelClass">{{ t('reader.cbz.readingDirection') }}</p>
<ReaderSegmentedControl
:options="directionOptions"
:model-value="settings.direction"
:aria-label="t('reader.cbz.readingDirection')"
@update:model-value="setDirection"
/>
</div>
<div class="px-4 py-3.5">
<p :class="groupLabelClass">{{ t('reader.cbz.background') }}</p>
<div class="grid grid-cols-3 gap-x-2 gap-y-1.5">
<button
v-for="swatch in bgSwatches"
:key="swatch.value"
type="button"
class="group rounded-lg transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/55 focus-visible:ring-offset-1 focus-visible:ring-offset-card"
:aria-pressed="settings.bgColor === swatch.value"
:aria-label="swatch.label"
@click="selectBackground(swatch.value)"
>
<span
aria-hidden="true"
class="block h-9 w-full rounded-md border border-border/60 ring-2 ring-offset-2 ring-offset-card transition-all"
:class="settings.bgColor === swatch.value ? 'ring-primary' : 'ring-transparent group-hover:ring-border'"
:style="{ background: swatch.color }"
/>
<span
class="mt-0.5 block truncate text-center text-[10px] leading-tight"
:class="settings.bgColor === swatch.value ? 'text-foreground' : 'text-muted-foreground'"
>
{{ swatch.label }}
</span>
</button>
</div>
</div>
</div>
</section>
</template>
@@ -0,0 +1,144 @@
import { mount, type VueWrapper } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { CBX_READER_DEFAULTS, type CbxReaderSettings } from '@bookorbit/types'
import CbzSettingsPanel from '../CbzSettingsPanel.vue'
function makeSettings(overrides: Partial<CbxReaderSettings> = {}): CbxReaderSettings {
return { ...CBX_READER_DEFAULTS, ...overrides }
}
function mountPanel(props: Partial<InstanceType<typeof CbzSettingsPanel>['$props']> = {}) {
return mount(CbzSettingsPanel, {
props: { settings: makeSettings(), ...props },
})
}
function twoPageSettings(overrides: Partial<CbxReaderSettings> = {}): CbxReaderSettings {
return makeSettings({ viewMode: 'two-page', scrollMode: 'paginated', ...overrides })
}
/** Resolves a slider through its <label for>, which also asserts the control is programmatically labelled. */
function rangeByLabel(wrapper: VueWrapper, labelText: string) {
const label = wrapper.findAll('label').find((candidate) => candidate.text() === labelText)
const id = label?.attributes('for')
return id ? wrapper.find(`[id="${id}"]`) : undefined
}
/** Resolves a switch through aria-labelledby, so the a11y wiring is covered alongside the behaviour. */
function switchByLabel(wrapper: VueWrapper, labelText: string) {
return wrapper.findAll('[role="switch"]').find((candidate) => {
const labelId = candidate.attributes('aria-labelledby')
return labelId ? wrapper.find(`[id="${labelId}"]`).text() === labelText : false
})
}
function buttonByAriaLabel(wrapper: VueWrapper, label: string) {
return wrapper.find(`button[aria-label="${label}"]`)
}
function buttonByText(wrapper: VueWrapper, text: string) {
return wrapper.findAll('button').find((button) => button.text() === text)
}
describe('CbzSettingsPanel', () => {
it('presents every setting without tab navigation', () => {
const wrapper = mountPanel()
expect(wrapper.text()).toContain('Fit mode')
expect(wrapper.text()).toContain('Page view')
expect(wrapper.text()).toContain('Scroll mode')
expect(wrapper.text()).toContain('Reading direction')
expect(wrapper.text()).toContain('Background')
})
it('selects a fit mode and marks the active choice', async () => {
const wrapper = mountPanel()
const width = buttonByText(wrapper, 'Page Width')
expect(width?.attributes('aria-pressed')).toBe('false')
expect(buttonByText(wrapper, 'Page Fit')?.attributes('aria-pressed')).toBe('true')
await width?.trigger('click')
expect(wrapper.emitted('update')?.[0]).toEqual([{ fitMode: 'fit-width' }])
})
it('switches the page view', async () => {
const wrapper = mountPanel()
await buttonByText(wrapper, 'Two-page')?.trigger('click')
expect(wrapper.emitted('update')?.[0]).toEqual([{ viewMode: 'two-page' }])
})
it('switches scroll mode and reading direction', async () => {
const wrapper = mountPanel()
await buttonByText(wrapper, 'Infinite')?.trigger('click')
expect(wrapper.emitted('update')?.[0]).toEqual([{ scrollMode: 'infinite' }])
await buttonByText(wrapper, 'R to L')?.trigger('click')
expect(wrapper.emitted('update')?.[1]).toEqual([{ direction: 'rtl' }])
})
it('selects a background color', async () => {
const wrapper = mountPanel()
const white = buttonByAriaLabel(wrapper, 'White')
expect(white.attributes('aria-pressed')).toBe('false')
expect(buttonByAriaLabel(wrapper, 'Black').attributes('aria-pressed')).toBe('true')
await white.trigger('click')
expect(wrapper.emitted('update')?.[0]).toEqual([{ bgColor: 'white' }])
})
it('hides the spread group until two-page paged layout is chosen', () => {
const single = mountPanel()
expect(single.text()).not.toContain('Two-page spread')
const scrolled = mountPanel({ settings: makeSettings({ viewMode: 'two-page', scrollMode: 'infinite' }) })
expect(scrolled.text()).not.toContain('Two-page spread')
expect(scrolled.text()).toContain('Two pages are shown only in paged mode.')
const spread = mountPanel({ settings: twoPageSettings(), isSpreadActive: true })
expect(spread.text()).toContain('Two-page spread')
expect(spread.text()).toContain('Spread alignment')
expect(spread.text()).toContain('Wide pages')
expect(spread.text()).toContain('Spread gap')
})
it('emits spread adjustments', async () => {
const wrapper = mountPanel({ settings: twoPageSettings(), isSpreadActive: true })
await buttonByText(wrapper, 'Shifted')?.trigger('click')
expect(wrapper.emitted('update')?.[0]).toEqual([{ spreadAlignment: 'shifted' }])
await buttonByText(wrapper, 'In spreads')?.trigger('click')
expect(wrapper.emitted('update')?.[1]).toEqual([{ widePageSingletonMode: 'disable' }])
await rangeByLabel(wrapper, 'Spread gap')!.setValue('24')
expect(wrapper.emitted('update')?.[2]).toEqual([{ spreadGap: 24 }])
await switchByLabel(wrapper, 'Force two-page')!.trigger('click')
expect(wrapper.emitted('update')?.[3]).toEqual([{ forceTwoPage: true }])
})
it('explains the single-page fallback only while spreads are inactive', () => {
const fallback = mountPanel({ settings: twoPageSettings(), isSpreadActive: false })
expect(fallback.text()).toContain('This screen is too narrow for two pages, so single pages are shown.')
const active = mountPanel({ settings: twoPageSettings(), isSpreadActive: true })
expect(active.text()).not.toContain('This screen is too narrow for two pages, so single pages are shown.')
})
it('resets only when the book carries overrides', async () => {
const untouched = mountPanel({ canReset: false })
expect(buttonByAriaLabel(untouched, 'Reset to defaults').attributes('disabled')).toBeDefined()
const customized = mountPanel({ canReset: true })
const reset = buttonByAriaLabel(customized, 'Reset to defaults')
expect(reset.attributes('disabled')).toBeUndefined()
await reset.trigger('click')
expect(customized.emitted('reset')).toHaveLength(1)
})
})
@@ -8,6 +8,12 @@ export type SpreadAlignment = 'normal' | 'shifted'
export type WidePageSingletonMode = 'auto' | 'disable'
export type BgColor = 'black' | 'gray' | 'white'
export const CBZ_BG_VALUES: Record<BgColor, string> = {
black: '#0a0a0a',
gray: '#525659',
white: '#e8e8e8',
}
export function useCbzSettings() {
const fitMode = ref<FitMode>('fit-page')
const viewMode = ref<ViewMode>('single')
@@ -19,11 +25,7 @@ export function useCbzSettings() {
const widePageSingletonMode = ref<WidePageSingletonMode>('auto')
const bgColor = ref<BgColor>('black')
const bgValue = computed(() => {
if (bgColor.value === 'black') return '#0a0a0a'
if (bgColor.value === 'gray') return '#525659'
return '#e8e8e8'
})
const bgValue = computed(() => CBZ_BG_VALUES[bgColor.value])
const isTwoPage = computed(() => viewMode.value === 'two-page' && scrollMode.value === 'paginated')
+3 -12
View File
@@ -6105,29 +6105,20 @@
"cycleFooterMode": "Cykovat informační režim zápatí"
},
"cbz": {
"tabs": {
"view": "Zobrazit",
"reading": "Čtení",
"layout": "Rozložení"
},
"fitMode": "Přizpůsobit režim",
"background": "Pozadí",
"scrollMode": "Režim rolování",
"scrollModeHint": "Pro webové hračky a vertikální proužky použijte \"Žádné mezery\".",
"readingDirection": "Směr čtení",
"pageView": "Zobrazení stránky",
"autoFallback": "Auto-fallback",
"twoPagePagedOnly": "Two pages are shown only in paged mode.",
"spreadSection": "Two-page spread",
"spreadFallbackHint": "This screen is too narrow for two pages, so single pages are shown.",
"spreadAlignmentLabel": "Zarovnání rozpětí",
"spreadAlignmentHint": "Zarovnání šíře není v režimu automatického záložního nastavení dostupné.",
"spreadGap": "Rozšíření mezery",
"spreadGapValue": "{value}px",
"focusTwoPageToggle": "Přepínání dvoustránkového režimu ostření",
"forceTwoPage": "Vynutit dvoustránku",
"off": "Vypnuto",
"on": "Zapnuto",
"widePages": "Široké stránky",
"resetBookViewSettings": "Obnovit nastavení zobrazení knihy",
"resetConfirm": "Obnovit nastavení zobrazení pro tuto knihu na vaše globální nastavení?",
"firstPage": "První stránka",
"lastPage": "Poslední stránka",
"fit": {
+3 -12
View File
@@ -6105,29 +6105,20 @@
"cycleFooterMode": "Cyklus footer info tilstand"
},
"cbz": {
"tabs": {
"view": "Vis",
"reading": "Læser",
"layout": "Layout"
},
"fitMode": "Tilpas tilstand",
"background": "Baggrund",
"scrollMode": "Rul tilstand",
"scrollModeHint": "Brug \"Ingen huller\" til webtoons og lodrette strimler.",
"readingDirection": "Læser retning",
"pageView": "Side visning",
"autoFallback": "Auto-fallback",
"twoPagePagedOnly": "Two pages are shown only in paged mode.",
"spreadSection": "Two-page spread",
"spreadFallbackHint": "This screen is too narrow for two pages, so single pages are shown.",
"spreadAlignmentLabel": "Spred justering",
"spreadAlignmentHint": "Spredning justering er utilgængelig i auto-fallback-tilstand.",
"spreadGap": "Spred mellemrum",
"spreadGapValue": "{value}px",
"focusTwoPageToggle": "Fokusér to-sidet skift",
"forceTwoPage": "Gennemtving to-sidet",
"off": "Fra",
"on": "Til",
"widePages": "Brede sider",
"resetBookViewSettings": "Nulstil indstillinger for bogvisning",
"resetConfirm": "Nulstil visningsindstillingerne for denne bog til dine globale standarder?",
"firstPage": "Første side",
"lastPage": "Sidste side",
"fit": {
+3 -12
View File
@@ -6105,29 +6105,20 @@
"cycleFooterMode": "Fußzeilen-Infomodus wechseln"
},
"cbz": {
"tabs": {
"view": "Ansicht",
"reading": "Lesen",
"layout": "Layout"
},
"fitMode": "Anpassungsmodus",
"background": "Hintergrund",
"scrollMode": "Scroll-Modus",
"scrollModeHint": "Verwenden Sie \"Keine Lücken\" für Webtoons und vertikale Streifen.",
"readingDirection": "Leserichtung",
"pageView": "Seitenansicht",
"autoFallback": "Automatischer Rückfallmodus",
"twoPagePagedOnly": "Two pages are shown only in paged mode.",
"spreadSection": "Two-page spread",
"spreadFallbackHint": "This screen is too narrow for two pages, so single pages are shown.",
"spreadAlignmentLabel": "Doppelseitenausrichtung",
"spreadAlignmentHint": "Doppelseitenausrichtung ist im automatischen Rückfallmodus nicht verfügbar.",
"spreadGap": "Spread gap",
"spreadGapValue": "{value}px",
"focusTwoPageToggle": "Zweiseitenansicht fokussieren",
"forceTwoPage": "Zwei Seiten erzwingen",
"off": "Aus",
"on": "Ein",
"widePages": "Breite Seiten",
"resetBookViewSettings": "Ansichtseinstellungen des Buches zurücksetzen",
"resetConfirm": "Ansichtseinstellungen für dieses Buch auf Ihre globalen Standardwerte zurücksetzen?",
"firstPage": "Erste Seite",
"lastPage": "Letzte Seite",
"fit": {
+3 -12
View File
@@ -6105,29 +6105,20 @@
"cycleFooterMode": "Cycle footer info mode"
},
"cbz": {
"tabs": {
"view": "View",
"reading": "Reading",
"layout": "Layout"
},
"fitMode": "Fit mode",
"background": "Background",
"scrollMode": "Scroll mode",
"scrollModeHint": "Use \"No gaps\" for webtoons and vertical strips.",
"readingDirection": "Reading direction",
"pageView": "Page view",
"autoFallback": "Auto-fallback",
"twoPagePagedOnly": "Two pages are shown only in paged mode.",
"spreadSection": "Two-page spread",
"spreadFallbackHint": "This screen is too narrow for two pages, so single pages are shown.",
"spreadAlignmentLabel": "Spread alignment",
"spreadAlignmentHint": "Spread alignment is unavailable in auto-fallback mode.",
"spreadGap": "Spread gap",
"spreadGapValue": "{value}px",
"focusTwoPageToggle": "Focus two-page toggle",
"forceTwoPage": "Force two-page",
"off": "Off",
"on": "On",
"widePages": "Wide pages",
"resetBookViewSettings": "Reset book view settings",
"resetConfirm": "Reset view settings for this book to your global defaults?",
"firstPage": "First page",
"lastPage": "Last page",
"fit": {
+3 -12
View File
@@ -6105,29 +6105,20 @@
"cycleFooterMode": "Modo de información de pie de página de ciclo"
},
"cbz": {
"tabs": {
"view": "Ver",
"reading": "leyendo",
"layout": "Diseño"
},
"fitMode": "Modo de ajuste",
"background": "Antecedentes",
"scrollMode": "Modo de desplazamiento",
"scrollModeHint": "Utilice \"Sin espacios\" para webtoons y tiras verticales.",
"readingDirection": "dirección de lectura",
"pageView": "Vista de página",
"autoFallback": "Modo alternativo automático",
"twoPagePagedOnly": "Two pages are shown only in paged mode.",
"spreadSection": "Two-page spread",
"spreadFallbackHint": "This screen is too narrow for two pages, so single pages are shown.",
"spreadAlignmentLabel": "Alineación extendida",
"spreadAlignmentHint": "La alineación de páginas no está disponible en el modo alternativo automático.",
"spreadGap": "Brecha de propagación",
"spreadGapValue": "{value}px",
"focusTwoPageToggle": "Alternar enfoque de dos páginas",
"forceTwoPage": "Forzar dos páginas",
"off": "Apagado",
"on": "encendido",
"widePages": "paginas anchas",
"resetBookViewSettings": "Restablecer la configuración de vista de libro",
"resetConfirm": "¿Restablecer la configuración de visualización de este libro a sus valores predeterminados globales?",
"firstPage": "Primera pagina",
"lastPage": "Última página",
"fit": {
+3 -12
View File
@@ -6105,29 +6105,20 @@
"cycleFooterMode": "Syklin alatunnisteen tiedot -tila"
},
"cbz": {
"tabs": {
"view": "Näytä",
"reading": "Luetaan",
"layout": "Asettelu"
},
"fitMode": "Sovita tila",
"background": "Tausta",
"scrollMode": "Vieritystila",
"scrollModeHint": "Käytä \"Ei aukkoja\" webtooneille ja pystysuorille nauhoille.",
"readingDirection": "Lukemisen suunta",
"pageView": "Sivun näkymä",
"autoFallback": "Auto-fallback",
"twoPagePagedOnly": "Two pages are shown only in paged mode.",
"spreadSection": "Two-page spread",
"spreadFallbackHint": "This screen is too narrow for two pages, so single pages are shown.",
"spreadAlignmentLabel": "Levityksen kohdistus",
"spreadAlignmentHint": "Levityksen tasaus ei ole käytettävissä automaattisessa varmennustilassa.",
"spreadGap": "Leviämisen väli",
"spreadGapValue": "{value}px",
"focusTwoPageToggle": "Tarkenna kaksisivuiseen käyttöön",
"forceTwoPage": "Pakota kaksisivu",
"off": "Pois",
"on": "Päällä",
"widePages": "Laajat sivut",
"resetBookViewSettings": "Nollaa kirjan näkymäasetukset",
"resetConfirm": "Nollaa tämän kirjan asetukset globaaleihin oletuksiisi?",
"firstPage": "Ensimmäinen sivu",
"lastPage": "Viimeinen sivu",
"fit": {
+3 -12
View File
@@ -6105,29 +6105,20 @@
"cycleFooterMode": "Mode d'information du pied de page du cycle"
},
"cbz": {
"tabs": {
"view": "Voir",
"reading": "Lecture",
"layout": "Disposition"
},
"fitMode": "Mode d'ajustement",
"background": "Contexte",
"scrollMode": "Mode défilement",
"scrollModeHint": "Utilisez \"Pas d'espaces\" pour les webtoons et les bandes verticales.",
"readingDirection": "Sens de lecture",
"pageView": "Affichage des pages",
"autoFallback": "Repli automatique",
"twoPagePagedOnly": "Two pages are shown only in paged mode.",
"spreadSection": "Two-page spread",
"spreadFallbackHint": "This screen is too narrow for two pages, so single pages are shown.",
"spreadAlignmentLabel": "Alignement de la propagation",
"spreadAlignmentHint": "L’alignement de la propagation n’est pas disponible en mode de repli automatique.",
"spreadGap": "Écart de propagation",
"spreadGapValue": "{value}px",
"focusTwoPageToggle": "Focus sur la bascule de deux pages",
"forceTwoPage": "Forcer deux pages",
"off": "Désactivé",
"on": "Sur",
"widePages": "Pages larges",
"resetBookViewSettings": "Réinitialiser les paramètres d'affichage du livre",
"resetConfirm": "Réinitialiser les paramètres d'affichage de ce livre à vos valeurs par défaut globales ?",
"firstPage": "Première page",
"lastPage": "Dernière page",
"fit": {
+3 -12
View File
@@ -6105,29 +6105,20 @@
"cycleFooterMode": "Scorri la modalità informazioni piè di pagina"
},
"cbz": {
"tabs": {
"view": "Visualizza",
"reading": "Lettura",
"layout": "Disposizione"
},
"fitMode": "Modalità adatta",
"background": "Sfondo",
"scrollMode": "Modalità di scorrimento",
"scrollModeHint": "Utilizza \"Nessun intervallo\" per i webtoon e le strisce verticali.",
"readingDirection": "Direzione della lettura",
"pageView": "Visualizzazione della pagina",
"autoFallback": "Modalità alternativa automatica",
"twoPagePagedOnly": "Two pages are shown only in paged mode.",
"spreadSection": "Two-page spread",
"spreadFallbackHint": "This screen is too narrow for two pages, so single pages are shown.",
"spreadAlignmentLabel": "Allineamento della diffusione",
"spreadAlignmentHint": "L'allineamento delle pagine non è disponibile nella modalità alternativa automatica.",
"spreadGap": "Divario di diffusione",
"spreadGapValue": "{value}px",
"focusTwoPageToggle": "Attiva/disattiva Focus su due pagine",
"forceTwoPage": "Forza due pagine",
"off": "Spento",
"on": "Su",
"widePages": "Pagine larghe",
"resetBookViewSettings": "Ripristina le impostazioni di visualizzazione del libro",
"resetConfirm": "Ripristinare le impostazioni di visualizzazione di questo libro sui valori predefiniti globali?",
"firstPage": "Prima pagina",
"lastPage": "Ultima pagina",
"fit": {
+3 -12
View File
@@ -6105,29 +6105,20 @@
"cycleFooterMode": "Voettekstinfo wisselen"
},
"cbz": {
"tabs": {
"view": "Weergave",
"reading": "Lezen",
"layout": "Indeling"
},
"fitMode": "Passingsmodus",
"background": "Achtergrond",
"scrollMode": "Scrollmodus",
"scrollModeHint": "Gebruik \"Geen tussenruimte\" voor webtoons en verticale stroken.",
"readingDirection": "Leesrichting",
"pageView": "Paginaweergave",
"autoFallback": "Auto-terugval",
"twoPagePagedOnly": "Two pages are shown only in paged mode.",
"spreadSection": "Two-page spread",
"spreadFallbackHint": "This screen is too narrow for two pages, so single pages are shown.",
"spreadAlignmentLabel": "Spreaduitlijning",
"spreadAlignmentHint": "Spreaduitlijning is niet beschikbaar in de auto-terugvalmodus.",
"spreadGap": "Spread gap",
"spreadGapValue": "{value}px",
"focusTwoPageToggle": "Focus op tweepaginaschakelaar",
"forceTwoPage": "Twee pagina's forceren",
"off": "Uit",
"on": "Aan",
"widePages": "Brede pagina's",
"resetBookViewSettings": "Weergave-instellingen van boek herstellen",
"resetConfirm": "Weergave-instellingen voor dit boek herstellen naar je algemene standaardwaarden?",
"firstPage": "Eerste pagina",
"lastPage": "Laatste pagina",
"fit": {
+3 -12
View File
@@ -6105,29 +6105,20 @@
"cycleFooterMode": "Tryb informacyjny stopki cyklu"
},
"cbz": {
"tabs": {
"view": "Zobacz",
"reading": "Czytanie",
"layout": "Układ"
},
"fitMode": "Tryb dopasowania",
"background": "Tło",
"scrollMode": "Tryb przewijania",
"scrollModeHint": "W przypadku webtoonów i pasków pionowych użyj opcji „Bez przerw”.",
"readingDirection": "Kierunek czytania",
"pageView": "Widok strony",
"autoFallback": "Automatyczny tryb zastępczy",
"twoPagePagedOnly": "Two pages are shown only in paged mode.",
"spreadSection": "Two-page spread",
"spreadFallbackHint": "This screen is too narrow for two pages, so single pages are shown.",
"spreadAlignmentLabel": "Wyrównanie rozprzestrzeniania się",
"spreadAlignmentHint": "Wyrównanie rozkładówki jest niedostępne w automatycznym trybie zastępczym.",
"spreadGap": "Rozłóż lukę",
"spreadGapValue": "{value}px",
"focusTwoPageToggle": "Przełącznik fokusu na dwie strony",
"forceTwoPage": "Wymuś dwie strony",
"off": "Wyłączone",
"on": "Włączone",
"widePages": "Szerokie strony",
"resetBookViewSettings": "Zresetuj ustawienia widoku książki",
"resetConfirm": "Zresetować ustawienia widoku tej książki do ustawień globalnych?",
"firstPage": "Pierwsza strona",
"lastPage": "Ostatnia strona",
"fit": {
+3 -12
View File
@@ -6105,29 +6105,20 @@
"cycleFooterMode": "Alternar modo de rodapé"
},
"cbz": {
"tabs": {
"view": "Ver",
"reading": "Leitura",
"layout": "Layout"
},
"fitMode": "Modo de preenchimento",
"background": "Fundo",
"scrollMode": "Modo de rolagem",
"scrollModeHint": "Use \"Sem lacunas\" para webtoons e tiras verticais.",
"readingDirection": "Direção de leitura",
"pageView": "Visualização da página",
"autoFallback": "Preenchimento automático (fallback)",
"twoPagePagedOnly": "Two pages are shown only in paged mode.",
"spreadSection": "Two-page spread",
"spreadFallbackHint": "This screen is too narrow for two pages, so single pages are shown.",
"spreadAlignmentLabel": "Alinhamento das páginas",
"spreadAlignmentHint": "O alinhamento das páginas não está disponível no modo de fallback automático.",
"spreadGap": "Espalhar espaço",
"spreadGapValue": "{value}px",
"focusTwoPageToggle": "Alternar para focar em duas páginas",
"forceTwoPage": "Forçar duas páginas",
"off": "Desligado",
"on": "Ligado",
"widePages": "Páginas largas",
"resetBookViewSettings": "Redefinir configurações de exibição do livro",
"resetConfirm": "Redefinir as configurações de visualização deste livro para os seus padrões globais?",
"firstPage": "Primeira página",
"lastPage": "Última página",
"fit": {
+3 -12
View File
@@ -6105,29 +6105,20 @@
"cycleFooterMode": "Информация о нижнем колонтитуле"
},
"cbz": {
"tabs": {
"view": "Вид",
"reading": "Чтение",
"layout": "Макет"
},
"fitMode": "По размеру",
"background": "Справочная информация",
"scrollMode": "Режим прокрутки",
"scrollModeHint": "Используйте \"Без пробелов\" для вебсайтов и вертикальных полос.",
"readingDirection": "Направление чтения",
"pageView": "Вид страницы",
"autoFallback": "Auto-fallback",
"twoPagePagedOnly": "Two pages are shown only in paged mode.",
"spreadSection": "Two-page spread",
"spreadFallbackHint": "This screen is too narrow for two pages, so single pages are shown.",
"spreadAlignmentLabel": "Распространение выравнивания",
"spreadAlignmentHint": "Спрэд выравнивание недоступно в режиме авто-резервного возврата.",
"spreadGap": "Распространяемый разрыв",
"spreadGapValue": "{value}px",
"focusTwoPageToggle": "Переключить фокус на две страницы",
"forceTwoPage": "Принудительно две страницы",
"off": "Выкл",
"on": "На",
"widePages": "Широкие страницы",
"resetBookViewSettings": "Сбросить настройки просмотра книги",
"resetConfirm": "Сбросить настройки просмотра этой книги к вашим глобальным настройкам?",
"firstPage": "Первая страница",
"lastPage": "Последняя страница",
"fit": {
+3 -12
View File
@@ -6105,29 +6105,20 @@
"cycleFooterMode": "Preklopi način nožne vrstice"
},
"cbz": {
"tabs": {
"view": "Pogled",
"reading": "Branje",
"layout": "Postavitev"
},
"fitMode": "Način prilagajanja",
"background": "Ozadje",
"scrollMode": "Način drsenja",
"scrollModeHint": "Uporabite \"Brez vrzeli\" za webtoone in navpične trakove.",
"readingDirection": "Smer branja",
"pageView": "Pogled strani",
"autoFallback": "Samodejni nadomestni način",
"twoPagePagedOnly": "Two pages are shown only in paged mode.",
"spreadSection": "Two-page spread",
"spreadFallbackHint": "This screen is too narrow for two pages, so single pages are shown.",
"spreadAlignmentLabel": "Poravnava razpona",
"spreadAlignmentHint": "Poravnava razpona ni na voljo v samodejnem nadomestnem načinu.",
"spreadGap": "Razmik",
"spreadGapValue": "{value}px",
"focusTwoPageToggle": "Preklop na dvostransko pogled",
"forceTwoPage": "Vsili dve strani",
"off": "Izklopljeno",
"on": "Vklopljeno",
"widePages": "Široke strani",
"resetBookViewSettings": "Ponastavi nastavitve pogleda knjige",
"resetConfirm": "Ali želite ponastaviti nastavitve pogleda za to knjigo na globalne privzete vrednosti?",
"firstPage": "Prva stran",
"lastPage": "Zadnja stran",
"fit": {
+3 -12
View File
@@ -6105,29 +6105,20 @@
"cycleFooterMode": "Cykel sidfotsinfo läge"
},
"cbz": {
"tabs": {
"view": "Visa",
"reading": "Läser",
"layout": "Layout"
},
"fitMode": "Anpassa läge",
"background": "Bakgrund",
"scrollMode": "Rulla läge",
"scrollModeHint": "Använd \"Inga luckor\" för webtoons och vertikala remsor.",
"readingDirection": "Läsa riktning",
"pageView": "Sidvy",
"autoFallback": "Auto-fallback",
"twoPagePagedOnly": "Two pages are shown only in paged mode.",
"spreadSection": "Two-page spread",
"spreadFallbackHint": "This screen is too narrow for two pages, so single pages are shown.",
"spreadAlignmentLabel": "Sprid justering",
"spreadAlignmentHint": "Spread justering är inte tillgänglig i auto-reservläge.",
"spreadGap": "Sprid mellanrum",
"spreadGapValue": "{value}px",
"focusTwoPageToggle": "Fokusera tvåsidig växling",
"forceTwoPage": "Tvinga två sidor",
"off": "Av",
"on": "På",
"widePages": "Breda sidor",
"resetBookViewSettings": "Återställ bokvyn inställningar",
"resetConfirm": "Återställ vyinställningar för denna bok till dina globala standardinställningar?",
"firstPage": "Första sidan",
"lastPage": "Sista sidan",
"fit": {
+3 -12
View File
@@ -6105,29 +6105,20 @@
"cycleFooterMode": "Зміна режиму інформації в футері"
},
"cbz": {
"tabs": {
"view": "Дивитись",
"reading": "Читання",
"layout": "Макет"
},
"fitMode": "За розміром",
"background": "Фон",
"scrollMode": "Режим прокрутки",
"scrollModeHint": "Використовуйте \"Немає прогалин для вебтонів та вертикальних смуг.",
"readingDirection": "Напрямок читання",
"pageView": "Перегляд сторінки",
"autoFallback": "Auto-fallback",
"twoPagePagedOnly": "Two pages are shown only in paged mode.",
"spreadSection": "Two-page spread",
"spreadFallbackHint": "This screen is too narrow for two pages, so single pages are shown.",
"spreadAlignmentLabel": "Поширення вирівнювання",
"spreadAlignmentHint": "Розширення вирівнювання недоступне в режимі авторезервування.",
"spreadGap": "Поширити розрив",
"spreadGapValue": "{value}px",
"focusTwoPageToggle": "Фокусуватися на двосторінках",
"forceTwoPage": "Примусово використовувати дві сторінки",
"off": "Вимкнено",
"on": "Увімк",
"widePages": "Широкі сторінки",
"resetBookViewSettings": "Скинути налаштування перегляду книги",
"resetConfirm": "Скинути параметри перегляду цієї книги до стандартних за замовчуванням?",
"firstPage": "Перша сторінка",
"lastPage": "Остання сторінка",
"fit": {
+3 -12
View File
@@ -6105,29 +6105,20 @@
"cycleFooterMode": "循环页脚信息模式"
},
"cbz": {
"tabs": {
"view": "查看",
"reading": "正在阅读",
"layout": "布局"
},
"fitMode": "适合模式",
"background": "二. 背景",
"scrollMode": "滚动模式",
"scrollModeHint": "对网络玩具和垂直条纹使用“无差距”。",
"readingDirection": "阅读方向",
"pageView": "页面视图",
"autoFallback": "Auto-fallback",
"twoPagePagedOnly": "Two pages are shown only in paged mode.",
"spreadSection": "Two-page spread",
"spreadFallbackHint": "This screen is too narrow for two pages, so single pages are shown.",
"spreadAlignmentLabel": "扩展对齐",
"spreadAlignmentHint": "在自动回退模式下,扩展对齐不可用。",
"spreadGap": "2. 扩大差距",
"spreadGapValue": "{value}px",
"focusTwoPageToggle": "焦点两页切换",
"forceTwoPage": "强制两页",
"off": "关闭",
"on": "开启",
"widePages": "宽页面",
"resetBookViewSettings": "重置书签视图设置",
"resetConfirm": "将此书的视图设置重置为全局默认值?",
"firstPage": "首页",
"lastPage": "最后页",
"fit": {