@@ -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
107110const DEFAULT_Z_INDEX = 2000
108111const 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
110118const open = computed (() => props .modelValue )
111119const total = computed (() => props .urlList .length )
@@ -119,6 +127,15 @@ const offsetY = ref(0)
119127
120128const 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>` при загрузке.
123140const naturalWidth = ref (0 )
124141const 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+
156179function 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
294319function 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+
319350function zoomIn(): void {
320- scale . value = Math . min ( props . maxScale , Number (( scale .value * props .zoomRate ). toFixed ( 4 )) )
351+ setScale ( scale .value * props .zoomRate )
321352}
322353
323354function 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
327424function 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