feat(reader): add zoom controls and wheel shortcuts to cbz reader

Add zoom controls and Ctrl + mouse wheel shortcuts to CbzReaderView for desktop devices.

Closes #898
This commit is contained in:
neon
2026-08-06 05:18:29 +00:00
parent d266cc53ae
commit 23813b58f1
3 changed files with 240 additions and 50 deletions
@@ -217,4 +217,52 @@ describe('CbzReaderView', () => {
wrapper.unmount()
})
it('zooms with desktop controls and Ctrl-wheel without turning the page', async () => {
mocks.savedMode = 'paginated'
mocks.pageCount = 6
mocks.savedPageNumber = 2
const wrapper = shallowMount(CbzReaderView, {
props: { bookId: 11, fileId: 22 },
global: {
stubs: {
Tooltip: { template: '<div><slot /></div>' },
TooltipTrigger: { template: '<div><slot /></div>' },
TooltipContent: { template: '<div><slot /></div>' },
},
},
})
await flushPromises()
await nextTick()
const pages = wrapper.get('[data-testid="cbz-paginated-pages"]')
const viewport = wrapper.get('[data-testid="cbz-paginated-viewport"]').element as HTMLElement
Object.defineProperties(viewport, {
clientWidth: { configurable: true, value: 1_000 },
clientHeight: { configurable: true, value: 800 },
})
vi.spyOn(viewport, 'getBoundingClientRect').mockReturnValue({ left: 0, top: 0, width: 1_000, height: 800 } as DOMRect)
expect(pages.attributes('style')).toContain('scale(1)')
await wrapper.get('button[aria-label="reader.cbz.zoomIn"]').trigger('click')
await nextTick()
expect(pages.attributes('style')).toContain('scale(1.25)')
expect(viewport.scrollLeft).toBe(125)
expect(viewport.scrollTop).toBe(100)
const slider = wrapper.get('input[type="range"]')
expect(slider.attributes('value')).toBe('1')
viewport.dispatchEvent(new WheelEvent('wheel', { bubbles: true, cancelable: true, ctrlKey: true, clientX: 500, clientY: 400, deltaY: 100 }))
await nextTick()
expect(pages.attributes('style')).toContain('scale(1)')
expect(viewport.scrollLeft).toBe(0)
expect(viewport.scrollTop).toBe(0)
expect(slider.attributes('value')).toBe('1')
wrapper.unmount()
})
})
+189 -50
View File
@@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n'
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
import { useVirtualizer } from '@tanstack/vue-virtual'
import { useMediaQuery } from '@vueuse/core'
import { ArrowLeft, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Maximize, Minimize, Settings } from '@lucide/vue'
import { ArrowLeft, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Maximize, Minimize, Minus, Plus, Settings } from '@lucide/vue'
import { useVisibility } from '../shared/composables/useVisibility'
import { useReaderProgress } from '../shared/composables/useReaderProgress'
import { useReadingSession } from '../shared/composables/useReadingSession'
@@ -20,6 +20,9 @@ import { Sheet, SheetContent } from '@/components/ui/sheet'
import CbzSettingsPanel from './components/CbzSettingsPanel.vue'
const TWO_PAGE_BREAKPOINT = 900
const MIN_ZOOM_SCALE = 0.5
const MAX_ZOOM_SCALE = 3
const ZOOM_STEP = 0.25
const { t } = useI18n()
@@ -48,13 +51,16 @@ const bookSettings = useReaderSettings(props.fileId, 'cbz')
const currentPage = ref(0)
const showSettings = ref(false)
const scrollContainer = ref<HTMLElement | null>(null)
const paginatedViewport = ref<HTMLElement | null>(null)
const viewportWidth = ref(0)
const viewportHeight = ref(0)
const currentImageLoaded = ref(false)
const pendingImageLoads = ref(0)
const loadedImageCount = ref(0)
const pageRatios = ref<number[]>([])
const pageDimensions = ref<Array<{ width: number; height: number } | undefined>>([])
const stripImagesReady = ref(false)
const zoomScale = ref(1)
watch(showSettings, setVisibilityLock)
@@ -159,6 +165,26 @@ const pageLabel = computed(() => {
})
const fullscreenLabel = computed(() => (isFullscreen.value ? 'Exit fullscreen' : 'Enter fullscreen'))
const zoomPercent = computed(() => Math.round(zoomScale.value * 100))
const canZoomOut = computed(() => zoomScale.value > MIN_ZOOM_SCALE)
const canZoomIn = computed(() => zoomScale.value < MAX_ZOOM_SCALE)
const paginatedStageStyle = computed(() => {
const stageScale = Math.max(1, zoomScale.value)
return {
width: `${stageScale * 100}%`,
height: `${stageScale * 100}%`,
}
})
const paginatedContentStyle = computed(() => {
const stageScale = Math.max(1, zoomScale.value)
return {
width: `${100 / stageScale}%`,
height: `${100 / stageScale}%`,
transform: `scale(${zoomScale.value})`,
}
})
const progressPageIndex = computed(() => {
const spread = currentSpread.value
@@ -179,9 +205,9 @@ const sliderFillPercent = computed(() => {
const stripFrameClass = computed(() => {
if (fitMode.value === 'fit-height' || fitMode.value === 'fit-page') {
return 'w-full h-[100dvh] flex items-center justify-center overflow-hidden'
return 'flex items-center justify-center overflow-hidden'
}
return 'w-full flex justify-center'
return 'flex justify-center'
})
const stripGap = computed(() => (scrollMode.value === 'long-strip' ? 0 : 8))
@@ -194,16 +220,17 @@ const stripVirtualizer = useVirtualizer(
const fit = fitMode.value
const height = Math.max(1, viewportHeight.value)
const width = stripViewportWidth.value
const scale = zoomScale.value
const ratios = pageRatios.value
return {
count: mode === 'paginated' ? 0 : pageCount.value,
getScrollElement: () => scrollContainer.value,
estimateSize: (index: number) => {
if (fit === 'fit-page' || fit === 'fit-height') return height
if (fit === 'fit-page' || fit === 'fit-height') return height * scale
const ratio = ratios[index]
if (fit === 'fit-width' && ratio && ratio > 0) return width / ratio
return height
if (fit === 'fit-width' && ratio && ratio > 0) return (width * scale) / ratio
return height * scale
},
gap: stripGap.value,
paddingStart: stripPadding.value,
@@ -235,6 +262,27 @@ const stripImageClass = computed(() => {
}
})
function stripFrameStyle(pageStart: number) {
const scaledWidth = stripViewportWidth.value * zoomScale.value
const frameHeight = fitMode.value === 'fit-height' || fitMode.value === 'fit-page' ? viewportHeight.value * zoomScale.value : undefined
return {
width: `${scaledWidth}px`,
height: frameHeight === undefined ? undefined : `${frameHeight}px`,
insetInlineStart: `${Math.max(0, (viewportWidth.value - scaledWidth) / 2)}px`,
transform: `translateY(${pageStart}px)`,
}
}
function stripImageStyle(pageIndex: number) {
if (fitMode.value !== 'actual') return undefined
const dimensions = pageDimensions.value[pageIndex]
if (!dimensions) return undefined
return {
width: `${dimensions.width * zoomScale.value}px`,
height: `${dimensions.height * zoomScale.value}px`,
}
}
const canGoPrev = computed(() => {
if (pageCount.value <= 0) return false
if (isTwoPageEffective.value) return spreadLayout.value.prevAnchor(currentPage.value) !== currentPage.value
@@ -265,6 +313,9 @@ function setPageRatio(pageIndex: number, width: number, height: number) {
const next = [...pageRatios.value]
next[pageIndex] = ratio
pageRatios.value = next
const nextDimensions = [...pageDimensions.value]
nextDimensions[pageIndex] = { width, height }
pageDimensions.value = nextDimensions
}
function preload(n: number) {
@@ -381,8 +432,59 @@ function onTouchEnd(e: TouchEvent) {
else prevPage()
}
// ── Wheel (paginated mode only) ────────────────────────────────────────────────
// ── Zoom / wheel navigation ────────────────────────────────────────────────────
interface ZoomFocalPoint {
clientX: number
clientY: number
}
function setZoomScale(nextScale: number, focalPoint?: ZoomFocalPoint) {
const clampedScale = Math.min(MAX_ZOOM_SCALE, Math.max(MIN_ZOOM_SCALE, nextScale))
const previousScale = zoomScale.value
if (clampedScale === previousScale) return
const viewport = scrollMode.value === 'paginated' ? paginatedViewport.value : scrollContainer.value
if (!viewport) {
zoomScale.value = clampedScale
return
}
const bounds = viewport.getBoundingClientRect()
const anchorX = focalPoint ? Math.min(viewport.clientWidth, Math.max(0, focalPoint.clientX - bounds.left)) : viewport.clientWidth / 2
const anchorY = focalPoint ? Math.min(viewport.clientHeight, Math.max(0, focalPoint.clientY - bounds.top)) : viewport.clientHeight / 2
const previousHorizontalInset = previousScale < 1 ? (viewport.clientWidth * (1 - previousScale)) / 2 : 0
const previousVerticalInset = scrollMode.value === 'paginated' && previousScale < 1 ? (viewport.clientHeight * (1 - previousScale)) / 2 : 0
const contentX = (viewport.scrollLeft + anchorX - previousHorizontalInset) / previousScale
const contentY = (viewport.scrollTop + anchorY - previousVerticalInset) / previousScale
zoomScale.value = clampedScale
void nextTick(() => {
const nextHorizontalInset = clampedScale < 1 ? (viewport.clientWidth * (1 - clampedScale)) / 2 : 0
const nextVerticalInset = scrollMode.value === 'paginated' && clampedScale < 1 ? (viewport.clientHeight * (1 - clampedScale)) / 2 : 0
viewport.scrollLeft = Math.max(0, nextHorizontalInset + contentX * clampedScale - anchorX)
viewport.scrollTop = Math.max(0, nextVerticalInset + contentY * clampedScale - anchorY)
})
}
function zoomIn() {
setZoomScale(zoomScale.value + ZOOM_STEP)
}
function zoomOut() {
setZoomScale(zoomScale.value - ZOOM_STEP)
}
function onWheel(e: WheelEvent) {
if (e.ctrlKey || e.metaKey) {
e.preventDefault()
const focalPoint = { clientX: e.clientX, clientY: e.clientY }
if (e.deltaY < 0) setZoomScale(zoomScale.value + ZOOM_STEP, focalPoint)
else if (e.deltaY > 0) setZoomScale(zoomScale.value - ZOOM_STEP, focalPoint)
return
}
if (scrollMode.value !== 'paginated') return
if (zoomScale.value > 1) return
e.preventDefault()
if (e.deltaY > 0) nextPage()
else if (e.deltaY < 0) prevPage()
@@ -423,6 +525,19 @@ function onKeyDown(e: KeyboardEvent) {
case 'Escape':
showSettings.value = false
break
case '+':
case '=':
e.preventDefault()
zoomIn()
break
case '-':
e.preventDefault()
zoomOut()
break
case '0':
e.preventDefault()
setZoomScale(1)
break
}
}
@@ -618,6 +733,25 @@ onUnmounted(() => {
{{ t('reader.peek.startReading') }}
</button>
</div>
<div class="hidden items-center md:flex" :aria-label="t('reader.cbz.zoomControls')" role="group">
<Tooltip>
<TooltipTrigger as-child>
<button class="viewer-btn" :disabled="!canZoomOut" :aria-label="t('reader.cbz.zoomOut')" @click="zoomOut">
<Minus :size="15" />
</button>
</TooltipTrigger>
<TooltipContent>{{ t('reader.cbz.zoomOut') }}</TooltipContent>
</Tooltip>
<span class="min-w-12 text-center text-xs tabular-nums text-muted-foreground" aria-live="polite">{{ zoomPercent }}%</span>
<Tooltip>
<TooltipTrigger as-child>
<button class="viewer-btn" :disabled="!canZoomIn" :aria-label="t('reader.cbz.zoomIn')" @click="zoomIn">
<Plus :size="15" />
</button>
</TooltipTrigger>
<TooltipContent>{{ t('reader.cbz.zoomIn') }}</TooltipContent>
</Tooltip>
</div>
<Tooltip>
<TooltipTrigger as-child>
<button class="viewer-btn" :aria-label="fullscreenLabel" @click="toggleFullscreen">
@@ -691,61 +825,65 @@ onUnmounted(() => {
<!-- ── Paginated view ──────────────────────────────────────────────────── -->
<div
v-if="scrollMode === 'paginated'"
class="absolute inset-0 flex items-center justify-center overflow-hidden"
ref="paginatedViewport"
data-testid="cbz-paginated-viewport"
class="absolute inset-0 overflow-auto"
@click="handleImageClick"
@touchstart.passive="onTouchStart"
@touchend.passive="onTouchEnd"
@wheel.prevent="onWheel"
@wheel="onWheel"
>
<div v-if="!currentImageLoaded && !error" class="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
<div class="w-8 h-8 rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground animate-spin" />
</div>
<div
data-testid="cbz-paginated-pages"
class="flex h-full w-full items-center justify-center px-1"
:style="renderSpread ? spreadContainerStyle : undefined"
>
<template v-if="renderSpread">
<div data-spread-side="left" class="flex h-full min-w-0 flex-1 items-center justify-end">
<img
v-if="renderLeftPage !== null"
:src="pageUrl(renderLeftPage)"
:class="[imgFitClass, 'pointer-events-none transition-opacity duration-150', currentImageLoaded ? 'opacity-100' : 'opacity-0']"
:style="{ maxWidth: '100%', maxHeight: '100%' }"
alt=""
draggable="false"
@load="onPaginatedImageLoad(renderLeftPage, $event)"
/>
<div v-else aria-hidden="true" class="h-[92%] w-[92%] rounded-sm border border-border/60 bg-background/30" />
</div>
<div data-spread-side="right" class="flex h-full min-w-0 flex-1 items-center justify-start">
<img
v-if="renderRightPage !== null"
:src="pageUrl(renderRightPage)"
:class="[imgFitClass, 'pointer-events-none transition-opacity duration-150', currentImageLoaded ? 'opacity-100' : 'opacity-0']"
:style="{ maxWidth: '100%', maxHeight: '100%' }"
alt=""
draggable="false"
@load="onPaginatedImageLoad(renderRightPage, $event)"
/>
<div v-else aria-hidden="true" class="h-[92%] w-[92%] rounded-sm border border-border/60 bg-background/30" />
</div>
</template>
<div class="flex min-h-full min-w-full items-center justify-center" :style="paginatedStageStyle">
<div
data-testid="cbz-paginated-pages"
class="flex shrink-0 items-center justify-center px-1 origin-center"
:style="[paginatedContentStyle, renderSpread ? spreadContainerStyle : undefined]"
>
<template v-if="renderSpread">
<div data-spread-side="left" class="flex h-full min-w-0 flex-1 items-center justify-end">
<img
v-if="renderLeftPage !== null"
:src="pageUrl(renderLeftPage)"
:class="[imgFitClass, 'pointer-events-none transition-opacity duration-150', currentImageLoaded ? 'opacity-100' : 'opacity-0']"
:style="{ maxWidth: '100%', maxHeight: '100%' }"
alt=""
draggable="false"
@load="onPaginatedImageLoad(renderLeftPage, $event)"
/>
<div v-else aria-hidden="true" class="h-[92%] w-[92%] rounded-sm border border-border/60 bg-background/30" />
</div>
<div data-spread-side="right" class="flex h-full min-w-0 flex-1 items-center justify-start">
<img
v-if="renderRightPage !== null"
:src="pageUrl(renderRightPage)"
:class="[imgFitClass, 'pointer-events-none transition-opacity duration-150', currentImageLoaded ? 'opacity-100' : 'opacity-0']"
:style="{ maxWidth: '100%', maxHeight: '100%' }"
alt=""
draggable="false"
@load="onPaginatedImageLoad(renderRightPage, $event)"
/>
<div v-else aria-hidden="true" class="h-[92%] w-[92%] rounded-sm border border-border/60 bg-background/30" />
</div>
</template>
<img
v-else-if="renderSinglePage !== null"
:src="pageUrl(renderSinglePage)"
:class="[imgFitClass, 'pointer-events-none transition-opacity duration-150', currentImageLoaded ? 'opacity-100' : 'opacity-0']"
alt=""
draggable="false"
@load="onPaginatedImageLoad(renderSinglePage, $event)"
/>
<img
v-else-if="renderSinglePage !== null"
:src="pageUrl(renderSinglePage)"
:class="[imgFitClass, 'pointer-events-none transition-opacity duration-150', currentImageLoaded ? 'opacity-100' : 'opacity-0']"
alt=""
draggable="false"
@load="onPaginatedImageLoad(renderSinglePage, $event)"
/>
</div>
</div>
</div>
<!-- ── Infinite / long-strip view ─────────────────────────────────────── -->
<div v-else ref="scrollContainer" class="absolute inset-0 overflow-y-auto overflow-x-hidden" @scroll.passive="onStripScroll">
<div v-else ref="scrollContainer" class="absolute inset-0 overflow-auto" @scroll.passive="onStripScroll" @wheel="onWheel">
<div class="relative w-full" :style="{ height: `${virtualStripSize}px` }">
<div
v-for="page in renderedStripPages"
@@ -755,11 +893,12 @@ onUnmounted(() => {
:data-page="page.index"
class="absolute start-0 top-0"
:class="[stripFrameClass, scrollMode === 'long-strip' ? '' : 'px-2']"
:style="{ transform: `translateY(${page.start}px)` }"
:style="stripFrameStyle(page.start)"
>
<img
:src="pageUrl(page.index)"
:class="stripImageClass"
:style="stripImageStyle(page.index)"
alt=""
decoding="async"
draggable="false"
+3
View File
@@ -6126,6 +6126,9 @@
"widePages": "Wide pages",
"firstPage": "First page",
"lastPage": "Last page",
"zoomControls": "Zoom controls",
"zoomOut": "Zoom out",
"zoomIn": "Zoom in",
"fit": {
"page": "Page Fit",
"width": "Page Width",