Skip to content

Commit c2a5c43

Browse files
author
fureev
committed
feat(GrImageViewer): add wheel-based zoom with smooth gesture handling
- Introduced `wheelZoom` prop (default `true`) for mouse wheel/trackpad pinch zoom. - Implemented smooth exponential zoom with clamped values and batching via `requestAnimationFrame`. - Disabled CSS transitions during active zoom for improved visual performance. - Updated changelog and documentation.
1 parent 2cfe153 commit c2a5c43

3 files changed

Lines changed: 130 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
1818
the fitted (`object-contain`) layout size is tracked via `ResizeObserver`,
1919
and the real scale is a derived `computed` (rotation-independent). Metrics
2020
reset on index / `urlList` change.
21+
- `GrImageViewer`: zoom with the mouse wheel / trackpad pinch gesture. Scrolling
22+
up zooms in, scrolling down zooms out, smoothly (exponential step, clamped to
23+
`minScale` / `maxScale`). Can be disabled with the new `wheelZoom` prop
24+
(defaults to `true`).
25+
26+
### Fixed
27+
28+
- `GrImageViewer`: jitter and visual "overlapping" of the image during
29+
continuous wheel / trackpad zoom. Wheel deltas are now batched and applied
30+
once per animation frame (`requestAnimationFrame`) instead of re-rendering on
31+
every wheel event, and the CSS `transition-transform` is disabled while the
32+
zoom gesture is active (the per-frame updates already keep it smooth) and
33+
restored once the gesture ends.
2134

2235
## [v0.9.2] 2026-06-08
2336

apps/showcase/src/content/generated/componentApi.generated.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2067,6 +2067,13 @@
20672067
"required": false,
20682068
"default": "true"
20692069
},
2070+
{
2071+
"name": "wheelZoom",
2072+
"description": "Включает масштабирование колесом мыши / жестом на трекпаде. По умолчанию включено.",
2073+
"type": "boolean | undefined",
2074+
"required": false,
2075+
"default": "true"
2076+
},
20702077
{
20712078
"name": "prevLabel",
20722079
"description": "i18n: aria-label кнопки «предыдущее изображение».",

packages/granularity/src/components/GrImageViewer/GrImageViewer.vue

Lines changed: 110 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ const props = withDefaults(
2525
closeOnPressEscape?: boolean
2626
showProgress?: boolean
2727
showZoomValue?: boolean
28+
/** Включает масштабирование колесом мыши / жестом на трекпаде. По умолчанию включено. */
29+
wheelZoom?: boolean
2830
zIndex?: number
2931
/** i18n: aria-label кнопки закрытия. */
3032
closeLabel?: string
@@ -54,6 +56,7 @@ const props = withDefaults(
5456
closeOnPressEscape: true,
5557
showProgress: false,
5658
showZoomValue: true,
59+
wheelZoom: true,
5760
zIndex: undefined,
5861
closeLabel: 'Close image viewer',
5962
prevLabel: 'Previous image',
@@ -106,6 +109,11 @@ type GrImageViewerSlotProps = {
106109
107110
const DEFAULT_Z_INDEX = 2000
108111
const TRAILING_ZERO_DECIMAL_RE = /\.0$/
112+
// Чувствительность масштабирования колесом/трекпадом: чем больше — тем резче реакция.
113+
const WHEEL_ZOOM_SENSITIVITY = 0.0015
114+
// Сколько ms простоя после последнего wheel-события считаем «зум завершён»
115+
// (после этого возвращаем плавный CSS-переход).
116+
const WHEEL_IDLE_MS = 120
109117
110118
const open = computed(() => props.modelValue)
111119
const total = computed(() => props.urlList.length)
@@ -119,6 +127,15 @@ const offsetY = ref(0)
119127
120128
const imageEl = ref<HTMLImageElement | null>(null)
121129
130+
// Флаг активного зума колесом/трекпадом. Пока true — CSS-переход отключён,
131+
// чтобы непрерывное масштабирование не «наслаивалось» и не дёргалось.
132+
const isWheelZooming = ref(false)
133+
134+
// Накопленный deltaY между кадрами и id запланированного rAF.
135+
let pendingWheelDelta = 0
136+
let wheelFrameId: number | null = null
137+
let wheelIdleTimer: ReturnType<typeof setTimeout> | null = null
138+
122139
// Натуральный (исходный) размер картинки, читается из `<img>` при загрузке.
123140
const naturalWidth = ref(0)
124141
const naturalHeight = ref(0)
@@ -153,6 +170,12 @@ const imageStyle = computed(() => ({
153170
transform: `translate3d(${offsetX.value}px, ${offsetY.value}px, 0) scale(${scale.value}) rotate(${rotation.value}deg)`,
154171
}))
155172
173+
// Плавный CSS-переход transform только для дискретных зумов (кнопки/клавиши).
174+
// При непрерывном зуме колесом переход отключаем — иначе картинка «дёргается» и наслаивается.
175+
const imageTransitionClass = computed(() =>
176+
isWheelZooming.value ? '' : 'transition-transform duration-150 ease-out',
177+
)
178+
156179
function formatPercent(value: number): string {
157180
if (Number.isInteger(value)) {
158181
return String(value)
@@ -274,6 +297,7 @@ function setIndex(nextIndex: number, options?: { emitSwitch?: boolean }): void {
274297
currentIndex.value = normalizedIndex
275298
resetTransform()
276299
resetImageMetrics()
300+
endWheelZoom()
277301
278302
if (options?.emitSwitch !== false) {
279303
emit('switch', normalizedIndex)
@@ -289,6 +313,7 @@ function syncIndexFromInitial(): void {
289313
currentIndex.value = normalizeIndex(props.initialIndex)
290314
resetTransform()
291315
resetImageMetrics()
316+
endWheelZoom()
292317
}
293318
294319
function preloadAt(index: number): void {
@@ -316,12 +341,84 @@ function next(): void {
316341
setIndex(currentIndex.value + 1)
317342
}
318343
344+
function setScale(value: number): void {
345+
const clamped = Math.min(props.maxScale, Math.max(props.minScale, value))
346+
347+
scale.value = Number(clamped.toFixed(4))
348+
}
349+
319350
function zoomIn(): void {
320-
scale.value = Math.min(props.maxScale, Number((scale.value * props.zoomRate).toFixed(4)))
351+
setScale(scale.value * props.zoomRate)
321352
}
322353
323354
function zoomOut(): void {
324-
scale.value = Math.max(props.minScale, Number((scale.value / props.zoomRate).toFixed(4)))
355+
setScale(scale.value / props.zoomRate)
356+
}
357+
358+
function cancelWheelFrame(): void {
359+
if (wheelFrameId !== null) {
360+
cancelAnimationFrame(wheelFrameId)
361+
wheelFrameId = null
362+
}
363+
}
364+
365+
function endWheelZoom(): void {
366+
if (wheelIdleTimer !== null) {
367+
clearTimeout(wheelIdleTimer)
368+
wheelIdleTimer = null
369+
}
370+
371+
isWheelZooming.value = false
372+
pendingWheelDelta = 0
373+
cancelWheelFrame()
374+
}
375+
376+
// Применяем накопленный за кадр deltaY одним обновлением scale — так рендер
377+
// происходит не чаще одного раза в кадр (без «дёрганья» от частых обновлений).
378+
function flushWheelZoom(): void {
379+
wheelFrameId = null
380+
381+
const delta = pendingWheelDelta
382+
pendingWheelDelta = 0
383+
384+
if (!delta) {
385+
return
386+
}
387+
388+
// Экспоненциальный шаг — плавно и одинаково ощущается и для колеса, и для трекпада.
389+
// delta < 0 (от себя / scroll up) — приближение, delta > 0 — отдаление.
390+
const factor = Math.exp(-delta * WHEEL_ZOOM_SENSITIVITY)
391+
392+
setScale(scale.value * factor)
393+
}
394+
395+
function onWheel(event: WheelEvent): void {
396+
if (!props.wheelZoom || !hasImages.value) {
397+
return
398+
}
399+
400+
// Не даём странице/оверлею проскроллиться — колесо отдаём под зум.
401+
event.preventDefault()
402+
403+
// Во время непрерывного зума отключаем CSS-переход: плавность даёт само
404+
// покадровое обновление, а transition на каждом микрошаге вызывает лаг/наслоение.
405+
isWheelZooming.value = true
406+
407+
pendingWheelDelta += event.deltaY
408+
409+
if (wheelFrameId === null) {
410+
wheelFrameId = requestAnimationFrame(flushWheelZoom)
411+
}
412+
413+
if (wheelIdleTimer !== null) {
414+
clearTimeout(wheelIdleTimer)
415+
}
416+
417+
// По завершении жеста возвращаем плавный переход для последующих дискретных зумов.
418+
wheelIdleTimer = setTimeout(() => {
419+
wheelIdleTimer = null
420+
isWheelZooming.value = false
421+
}, WHEEL_IDLE_MS)
325422
}
326423
327424
function rotateLeft(): void {
@@ -402,6 +499,7 @@ watch(
402499
async (isOpen) => {
403500
if (!isOpen) {
404501
stopObservingImage()
502+
endWheelZoom()
405503
return
406504
}
407505
@@ -448,7 +546,10 @@ watch(
448546
{ immediate: true },
449547
)
450548
451-
onBeforeUnmount(stopObservingImage)
549+
onBeforeUnmount(() => {
550+
stopObservingImage()
551+
endWheelZoom()
552+
})
452553
</script>
453554

454555
<template>
@@ -527,7 +628,10 @@ onBeforeUnmount(stopObservingImage)
527628
</div>
528629
</div>
529630

530-
<div class="relative z-10 h-full w-full flex items-center justify-center px-16 py-16 sm:px-24 sm:py-20">
631+
<div
632+
class="relative z-10 h-full w-full flex items-center justify-center px-16 py-16 sm:px-24 sm:py-20"
633+
@wheel="onWheel"
634+
>
531635
<button
532636
v-if="total > 1"
533637
type="button"
@@ -558,7 +662,8 @@ onBeforeUnmount(stopObservingImage)
558662
:src="currentUrl"
559663
alt=""
560664
draggable="false"
561-
class="max-h-full max-w-full select-none object-contain will-change-transform transition-transform duration-150 ease-out"
665+
class="max-h-full max-w-full select-none object-contain will-change-transform"
666+
:class="imageTransitionClass"
562667
:style="imageStyle"
563668
@load="onImageLoad"
564669
>

0 commit comments

Comments
 (0)