diff --git a/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx b/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx index 8218253f706..9388061beb5 100644 --- a/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx +++ b/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx @@ -73,6 +73,9 @@ const CLASSNAME_NAVSELECTED = `${CLASSNAME_NAV}-selected`; const DEFAULT_SLIDESHOW_DELAY = 5000; const SECONDS_TO_MS = 1000; const MIN_VALID_INTERVAL_SECONDS = 1; +const MIN_ZOOM = 0.1; +const SCROLL_ZOOM_TIMEOUT = 250; +const ZOOM_NONE_EPSILON = 0.015; interface IProps { images: ILightboxImage[]; @@ -120,6 +123,18 @@ export const LightboxComponent: React.FC = ({ const oldImages = useRef([]); const [zoom, setZoom] = useState(1); + + function updateZoom(v: number) { + if (v < MIN_ZOOM) { + setZoom(MIN_ZOOM); + } else if (Math.abs(v - 1) < ZOOM_NONE_EPSILON) { + // "snap to 1" effect: if new zoom is close to 1, set to 1 + setZoom(1); + } else { + setZoom(v); + } + } + const [resetPosition, setResetPosition] = useState(false); const containerRef = useRef(null); @@ -373,6 +388,14 @@ export const LightboxComponent: React.FC = ({ ] ); + const firstScroll = useRef(null); + const inScrollGroup = useRef(false); + + const debouncedScrollReset = useDebounce(() => { + firstScroll.current = null; + inScrollGroup.current = false; + }, SCROLL_ZOOM_TIMEOUT); + const handleKey = useCallback( (e: KeyboardEvent) => { if (e.repeat && (e.key === "ArrowRight" || e.key === "ArrowLeft")) @@ -842,14 +865,17 @@ export const LightboxComponent: React.FC = ({ lightboxSettings?.scrollMode ?? GQL.ImageLightboxScrollMode.Zoom } - onLeft={handleLeft} - onRight={handleRight} - alignBottom={movingLeft} + resetPosition={resetPosition} zoom={i === currentIndex ? zoom : 1} - current={i === currentIndex} scrollAttemptsBeforeChange={scrollAttemptsBeforeChange} - setZoom={(v) => setZoom(v)} - resetPosition={resetPosition} + firstScroll={firstScroll} + inScrollGroup={inScrollGroup} + current={i === currentIndex} + alignBottom={movingLeft} + setZoom={updateZoom} + debouncedScrollReset={debouncedScrollReset} + onLeft={handleLeft} + onRight={handleRight} isVideo={isVideo(image.visual_files?.[0] ?? {})} /> ) : undefined} diff --git a/ui/v2.5/src/hooks/Lightbox/LightboxImage.tsx b/ui/v2.5/src/hooks/Lightbox/LightboxImage.tsx index 425a3aacdd4..a7695edd38b 100644 --- a/ui/v2.5/src/hooks/Lightbox/LightboxImage.tsx +++ b/ui/v2.5/src/hooks/Lightbox/LightboxImage.tsx @@ -2,7 +2,12 @@ import React, { useEffect, useRef, useState, useCallback } from "react"; import * as GQL from "src/core/generated-graphql"; const ZOOM_STEP = 1.1; +const ZOOM_FACTOR = 700; +const SCROLL_GROUP_THRESHOLD = 8; +const SCROLL_GROUP_EXIT_THRESHOLD = 4; +const SCROLL_INFINITE_THRESHOLD = 10; const SCROLL_PAN_STEP = 75; +const SCROLL_PAN_FACTOR = 2; const CLASSNAME = "Lightbox"; const CLASSNAME_CAROUSEL = `${CLASSNAME}-carousel`; const CLASSNAME_IMAGE = `${CLASSNAME_CAROUSEL}-image`; @@ -53,10 +58,15 @@ interface IProps { resetPosition?: boolean; zoom: number; scrollAttemptsBeforeChange: number; + // these refs must be outside of LightboxImage, + // since they need to be shared between all LightboxImages + firstScroll: React.MutableRefObject; + inScrollGroup: React.MutableRefObject; current: boolean; // set to true to align image with bottom instead of top alignBottom?: boolean; setZoom: (v: number) => void; + debouncedScrollReset: () => void; onLeft: () => void; onRight: () => void; isVideo: boolean; @@ -64,17 +74,20 @@ interface IProps { export const LightboxImage: React.FC = ({ src, - onLeft, - onRight, displayMode, scaleUp, scrollMode, - alignBottom, + resetPosition, zoom, scrollAttemptsBeforeChange, + firstScroll, + inScrollGroup, current, + alignBottom, setZoom, - resetPosition, + debouncedScrollReset, + onLeft, + onRight, isVideo, }) => { const [defaultZoom, setDefaultZoom] = useState(1); @@ -253,12 +266,7 @@ export const LightboxImage: React.FC = ({ calculateInitialPosition, ]); - function getScrollMode( - ev: - | React.WheelEvent - | React.WheelEvent - | React.WheelEvent - ) { + function getScrollMode(ev: React.WheelEvent) { if (ev.shiftKey) { switch (scrollMode) { case GQL.ImageLightboxScrollMode.Zoom: @@ -271,91 +279,134 @@ export const LightboxImage: React.FC = ({ return scrollMode; } - function onContainerScroll( - ev: - | React.WheelEvent - | React.WheelEvent - | React.WheelEvent - ) { + function onContainerScroll(ev: React.WheelEvent) { // don't zoom if mouse isn't over image if (getScrollMode(ev) === GQL.ImageLightboxScrollMode.PanY) { onImageScroll(ev); } } - function onImageScrollPanY( - ev: - | React.WheelEvent - | React.WheelEvent - | React.WheelEvent + function onLeftScroll( + ev: React.WheelEvent, + scrollable: boolean, + infinite: boolean ) { - if (current) { - const [minY, maxY] = minMaxY(zoom * defaultZoom); - - const scrollable = positionY !== maxY || positionY !== minY; - - let newPositionY = - positionY + (ev.deltaY < 0 ? SCROLL_PAN_STEP : -SCROLL_PAN_STEP); - - // #2389 - if scroll up and at top, then go to previous image - // if scroll down and at bottom, then go to next image - if (newPositionY > maxY && positionY === maxY) { - // #2535 - require additional scrolls before changing page - if ( - !scrollable || - scrollAttempts.current <= -scrollAttemptsBeforeChange - ) { + if (infinite) { + // for infinite scrolls, only change once per scroll "group" + if (ev.deltaY <= -SCROLL_GROUP_THRESHOLD) { + if (!inScrollGroup.current) { onLeft(); - } else { - scrollAttempts.current--; } - } else if (newPositionY < minY && positionY === minY) { - // #2535 - require additional scrolls before changing page - if ( - !scrollable || - scrollAttempts.current >= scrollAttemptsBeforeChange - ) { + } + } else { + // #2535 - require additional scrolls before changing page + if ( + !scrollable || + scrollAttempts.current <= -scrollAttemptsBeforeChange + ) { + scrollAttempts.current = 0; + onLeft(); + } else { + scrollAttempts.current--; + } + } + } + + function onRightScroll( + ev: React.WheelEvent, + scrollable: boolean, + infinite: boolean + ) { + if (infinite) { + // for infinite scrolls, only change once per scroll "group" + if (ev.deltaY >= SCROLL_GROUP_THRESHOLD) { + if (!inScrollGroup.current) { onRight(); - } else { - scrollAttempts.current++; } - } else { + } + } else { + // #2535 - require additional scrolls before changing page + if (!scrollable || scrollAttempts.current >= scrollAttemptsBeforeChange) { scrollAttempts.current = 0; + onRight(); + } else { + scrollAttempts.current++; + } + } + } - // ensure image doesn't go offscreen - newPositionY = Math.max(newPositionY, minY); - newPositionY = Math.min(newPositionY, maxY); + function onImageScrollPanY(ev: React.WheelEvent, infinite: boolean) { + if (!current) return; - setPositionY(newPositionY); - } + const [minY, maxY] = minMaxY(zoom * defaultZoom); + + const scrollable = positionY !== maxY || positionY !== minY; + + let newPositionY: number; + if (infinite) { + newPositionY = positionY - ev.deltaY / SCROLL_PAN_FACTOR; + } else { + newPositionY = + positionY + (ev.deltaY < 0 ? SCROLL_PAN_STEP : -SCROLL_PAN_STEP); + } - ev.stopPropagation(); + // #2389 - if scroll up and at top, then go to previous image + // if scroll down and at bottom, then go to next image + if (newPositionY > maxY && positionY === maxY) { + onLeftScroll(ev, scrollable, infinite); + } else if (newPositionY < minY && positionY === minY) { + onRightScroll(ev, scrollable, infinite); + } else { + scrollAttempts.current = 0; + + // ensure image doesn't go offscreen + newPositionY = Math.max(newPositionY, minY); + newPositionY = Math.min(newPositionY, maxY); + + setPositionY(newPositionY); } + + ev.stopPropagation(); } - function onImageScroll( - ev: - | React.WheelEvent - | React.WheelEvent - | React.WheelEvent - ) { - const percent = ev.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP; + function onImageScroll(ev: React.WheelEvent) { + const absDeltaY = Math.abs(ev.deltaY); + const firstDeltaY = firstScroll.current; + // detect infinite scrolling (mousepad, mouse with infinite scrollwheel) + const infinite = + // scrolling is infinite if deltaY is small + absDeltaY < SCROLL_INFINITE_THRESHOLD || + // or if scroll events come quickly and the first one was small + (firstDeltaY !== null && + Math.abs(firstDeltaY) < SCROLL_INFINITE_THRESHOLD); switch (getScrollMode(ev)) { case GQL.ImageLightboxScrollMode.Zoom: + let percent: number; + if (infinite) { + percent = 1 - ev.deltaY / ZOOM_FACTOR; + } else { + percent = ev.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP; + } setZoom(zoom * percent); break; case GQL.ImageLightboxScrollMode.PanY: - onImageScrollPanY(ev); + onImageScrollPanY(ev, infinite); break; } + if (firstDeltaY === null) { + firstScroll.current = ev.deltaY; + } + if (absDeltaY >= SCROLL_GROUP_THRESHOLD) { + inScrollGroup.current = true; + } else if (absDeltaY <= SCROLL_GROUP_EXIT_THRESHOLD) { + // only "exit" the scroll group if speed has slowed considerably + inScrollGroup.current = false; + } + debouncedScrollReset(); } - function onImageMouseOver( - ev: - | React.MouseEvent - | React.MouseEvent - ) { + function onImageMouseOver(ev: React.MouseEvent) { if (!moving) return; if (!ev.buttons) { @@ -371,22 +422,14 @@ export const LightboxImage: React.FC = ({ setPositionY(positionY + posY); } - function onImageMouseDown( - ev: - | React.MouseEvent - | React.MouseEvent - ) { + function onImageMouseDown(ev: React.MouseEvent) { startPoints.current = [ev.pageX, ev.pageY]; setMoving(true); mouseDownEvent.current = ev.nativeEvent; } - function onImageMouseUp( - ev: - | React.MouseEvent - | React.MouseEvent - ) { + function onImageMouseUp(ev: React.MouseEvent) { if (ev.button !== 0) return; if ( @@ -412,12 +455,7 @@ export const LightboxImage: React.FC = ({ } } - function onTouchStart( - ev: - | React.TouchEvent - | React.TouchEvent - | React.TouchEvent - ) { + function onTouchStart(ev: React.TouchEvent) { ev.preventDefault(); if (ev.touches.length === 1) { startPoints.current = [ev.touches[0].pageX, ev.touches[0].pageY]; @@ -425,12 +463,7 @@ export const LightboxImage: React.FC = ({ } } - function onTouchMove( - ev: - | React.TouchEvent - | React.TouchEvent - | React.TouchEvent - ) { + function onTouchMove(ev: React.TouchEvent) { if (!moving) return; if (ev.touches.length === 1) { @@ -443,12 +476,7 @@ export const LightboxImage: React.FC = ({ } } - function onPointerDown( - ev: - | React.PointerEvent - | React.PointerEvent - | React.PointerEvent - ) { + function onPointerDown(ev: React.PointerEvent) { // replace pointer event with the same id, if applicable pointerCache.current = pointerCache.current.filter( (e) => e.pointerId !== ev.pointerId @@ -458,12 +486,7 @@ export const LightboxImage: React.FC = ({ prevDiff.current = undefined; } - function onPointerUp( - ev: - | React.PointerEvent - | React.PointerEvent - | React.PointerEvent - ) { + function onPointerUp(ev: React.PointerEvent) { for (let i = 0; i < pointerCache.current.length; i++) { if (pointerCache.current[i].pointerId === ev.pointerId) { pointerCache.current.splice(i, 1); @@ -472,12 +495,7 @@ export const LightboxImage: React.FC = ({ } } - function onPointerMove( - ev: - | React.PointerEvent - | React.PointerEvent - | React.PointerEvent - ) { + function onPointerMove(ev: React.PointerEvent) { // find the event in the cache const cachedIndex = pointerCache.current.findIndex( (c) => c.pointerId === ev.pointerId @@ -543,14 +561,14 @@ export const LightboxImage: React.FC = ({ draggable={false} style={customStyle} onWheel={current ? (e) => onImageScroll(e) : undefined} - onMouseDown={(e) => onImageMouseDown(e)} - onMouseUp={(e) => onImageMouseUp(e)} - onMouseMove={(e) => onImageMouseOver(e)} - onTouchStart={(e) => onTouchStart(e)} - onTouchMove={(e) => onTouchMove(e)} - onPointerDown={(e) => onPointerDown(e)} - onPointerUp={(e) => onPointerUp(e)} - onPointerMove={(e) => onPointerMove(e)} + onMouseDown={onImageMouseDown} + onMouseUp={onImageMouseUp} + onMouseMove={onImageMouseOver} + onTouchStart={onTouchStart} + onTouchMove={onTouchMove} + onPointerDown={onPointerDown} + onPointerUp={onPointerUp} + onPointerMove={onPointerMove} /> ) : undefined}