mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
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:
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user