Lightbox infinite scrolling improvements (#3894)

This commit is contained in:
DingDongSoLong4
2023-09-19 01:31:34 +02:00
committed by GitHub
parent 62173a924b
commit 81f39bc2f4
2 changed files with 169 additions and 125 deletions

View File

@@ -73,6 +73,9 @@ const CLASSNAME_NAVSELECTED = `${CLASSNAME_NAV}-selected`;
const DEFAULT_SLIDESHOW_DELAY = 5000; const DEFAULT_SLIDESHOW_DELAY = 5000;
const SECONDS_TO_MS = 1000; const SECONDS_TO_MS = 1000;
const MIN_VALID_INTERVAL_SECONDS = 1; const MIN_VALID_INTERVAL_SECONDS = 1;
const MIN_ZOOM = 0.1;
const SCROLL_ZOOM_TIMEOUT = 250;
const ZOOM_NONE_EPSILON = 0.015;
interface IProps { interface IProps {
images: ILightboxImage[]; images: ILightboxImage[];
@@ -120,6 +123,18 @@ export const LightboxComponent: React.FC<IProps> = ({
const oldImages = useRef<ILightboxImage[]>([]); const oldImages = useRef<ILightboxImage[]>([]);
const [zoom, setZoom] = useState(1); 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 [resetPosition, setResetPosition] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
@@ -373,6 +388,14 @@ export const LightboxComponent: React.FC<IProps> = ({
] ]
); );
const firstScroll = useRef<number | null>(null);
const inScrollGroup = useRef(false);
const debouncedScrollReset = useDebounce(() => {
firstScroll.current = null;
inScrollGroup.current = false;
}, SCROLL_ZOOM_TIMEOUT);
const handleKey = useCallback( const handleKey = useCallback(
(e: KeyboardEvent) => { (e: KeyboardEvent) => {
if (e.repeat && (e.key === "ArrowRight" || e.key === "ArrowLeft")) if (e.repeat && (e.key === "ArrowRight" || e.key === "ArrowLeft"))
@@ -842,14 +865,17 @@ export const LightboxComponent: React.FC<IProps> = ({
lightboxSettings?.scrollMode ?? lightboxSettings?.scrollMode ??
GQL.ImageLightboxScrollMode.Zoom GQL.ImageLightboxScrollMode.Zoom
} }
resetPosition={resetPosition}
zoom={i === currentIndex ? zoom : 1}
scrollAttemptsBeforeChange={scrollAttemptsBeforeChange}
firstScroll={firstScroll}
inScrollGroup={inScrollGroup}
current={i === currentIndex}
alignBottom={movingLeft}
setZoom={updateZoom}
debouncedScrollReset={debouncedScrollReset}
onLeft={handleLeft} onLeft={handleLeft}
onRight={handleRight} onRight={handleRight}
alignBottom={movingLeft}
zoom={i === currentIndex ? zoom : 1}
current={i === currentIndex}
scrollAttemptsBeforeChange={scrollAttemptsBeforeChange}
setZoom={(v) => setZoom(v)}
resetPosition={resetPosition}
isVideo={isVideo(image.visual_files?.[0] ?? {})} isVideo={isVideo(image.visual_files?.[0] ?? {})}
/> />
) : undefined} ) : undefined}

View File

@@ -2,7 +2,12 @@ import React, { useEffect, useRef, useState, useCallback } from "react";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
const ZOOM_STEP = 1.1; 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_STEP = 75;
const SCROLL_PAN_FACTOR = 2;
const CLASSNAME = "Lightbox"; const CLASSNAME = "Lightbox";
const CLASSNAME_CAROUSEL = `${CLASSNAME}-carousel`; const CLASSNAME_CAROUSEL = `${CLASSNAME}-carousel`;
const CLASSNAME_IMAGE = `${CLASSNAME_CAROUSEL}-image`; const CLASSNAME_IMAGE = `${CLASSNAME_CAROUSEL}-image`;
@@ -53,10 +58,15 @@ interface IProps {
resetPosition?: boolean; resetPosition?: boolean;
zoom: number; zoom: number;
scrollAttemptsBeforeChange: number; scrollAttemptsBeforeChange: number;
// these refs must be outside of LightboxImage,
// since they need to be shared between all LightboxImages
firstScroll: React.MutableRefObject<number | null>;
inScrollGroup: React.MutableRefObject<boolean>;
current: boolean; current: boolean;
// set to true to align image with bottom instead of top // set to true to align image with bottom instead of top
alignBottom?: boolean; alignBottom?: boolean;
setZoom: (v: number) => void; setZoom: (v: number) => void;
debouncedScrollReset: () => void;
onLeft: () => void; onLeft: () => void;
onRight: () => void; onRight: () => void;
isVideo: boolean; isVideo: boolean;
@@ -64,17 +74,20 @@ interface IProps {
export const LightboxImage: React.FC<IProps> = ({ export const LightboxImage: React.FC<IProps> = ({
src, src,
onLeft,
onRight,
displayMode, displayMode,
scaleUp, scaleUp,
scrollMode, scrollMode,
alignBottom, resetPosition,
zoom, zoom,
scrollAttemptsBeforeChange, scrollAttemptsBeforeChange,
firstScroll,
inScrollGroup,
current, current,
alignBottom,
setZoom, setZoom,
resetPosition, debouncedScrollReset,
onLeft,
onRight,
isVideo, isVideo,
}) => { }) => {
const [defaultZoom, setDefaultZoom] = useState(1); const [defaultZoom, setDefaultZoom] = useState(1);
@@ -253,12 +266,7 @@ export const LightboxImage: React.FC<IProps> = ({
calculateInitialPosition, calculateInitialPosition,
]); ]);
function getScrollMode( function getScrollMode(ev: React.WheelEvent) {
ev:
| React.WheelEvent<HTMLImageElement>
| React.WheelEvent<HTMLVideoElement>
| React.WheelEvent<HTMLDivElement>
) {
if (ev.shiftKey) { if (ev.shiftKey) {
switch (scrollMode) { switch (scrollMode) {
case GQL.ImageLightboxScrollMode.Zoom: case GQL.ImageLightboxScrollMode.Zoom:
@@ -271,54 +279,83 @@ export const LightboxImage: React.FC<IProps> = ({
return scrollMode; return scrollMode;
} }
function onContainerScroll( function onContainerScroll(ev: React.WheelEvent) {
ev:
| React.WheelEvent<HTMLImageElement>
| React.WheelEvent<HTMLVideoElement>
| React.WheelEvent<HTMLDivElement>
) {
// don't zoom if mouse isn't over image // don't zoom if mouse isn't over image
if (getScrollMode(ev) === GQL.ImageLightboxScrollMode.PanY) { if (getScrollMode(ev) === GQL.ImageLightboxScrollMode.PanY) {
onImageScroll(ev); onImageScroll(ev);
} }
} }
function onImageScrollPanY( function onLeftScroll(
ev: ev: React.WheelEvent,
| React.WheelEvent<HTMLImageElement> scrollable: boolean,
| React.WheelEvent<HTMLVideoElement> infinite: boolean
| React.WheelEvent<HTMLDivElement>
) { ) {
if (current) { if (infinite) {
const [minY, maxY] = minMaxY(zoom * defaultZoom); // for infinite scrolls, only change once per scroll "group"
if (ev.deltaY <= -SCROLL_GROUP_THRESHOLD) {
const scrollable = positionY !== maxY || positionY !== minY; if (!inScrollGroup.current) {
onLeft();
let newPositionY = }
positionY + (ev.deltaY < 0 ? SCROLL_PAN_STEP : -SCROLL_PAN_STEP); }
} else {
// #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 // #2535 - require additional scrolls before changing page
if ( if (
!scrollable || !scrollable ||
scrollAttempts.current <= -scrollAttemptsBeforeChange scrollAttempts.current <= -scrollAttemptsBeforeChange
) { ) {
scrollAttempts.current = 0;
onLeft(); onLeft();
} else { } else {
scrollAttempts.current--; scrollAttempts.current--;
} }
} else if (newPositionY < minY && positionY === minY) { }
// #2535 - require additional scrolls before changing page }
if (
!scrollable || function onRightScroll(
scrollAttempts.current >= scrollAttemptsBeforeChange 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 {
// #2535 - require additional scrolls before changing page
if (!scrollable || scrollAttempts.current >= scrollAttemptsBeforeChange) {
scrollAttempts.current = 0;
onRight(); onRight();
} else { } else {
scrollAttempts.current++; scrollAttempts.current++;
} }
}
}
function onImageScrollPanY(ev: React.WheelEvent, infinite: boolean) {
if (!current) return;
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);
}
// #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 { } else {
scrollAttempts.current = 0; scrollAttempts.current = 0;
@@ -331,31 +368,45 @@ export const LightboxImage: React.FC<IProps> = ({
ev.stopPropagation(); ev.stopPropagation();
} }
}
function onImageScroll( function onImageScroll(ev: React.WheelEvent) {
ev: const absDeltaY = Math.abs(ev.deltaY);
| React.WheelEvent<HTMLImageElement> const firstDeltaY = firstScroll.current;
| React.WheelEvent<HTMLVideoElement> // detect infinite scrolling (mousepad, mouse with infinite scrollwheel)
| React.WheelEvent<HTMLDivElement> const infinite =
) { // scrolling is infinite if deltaY is small
const percent = ev.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP; 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)) { switch (getScrollMode(ev)) {
case GQL.ImageLightboxScrollMode.Zoom: 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); setZoom(zoom * percent);
break; break;
case GQL.ImageLightboxScrollMode.PanY: case GQL.ImageLightboxScrollMode.PanY:
onImageScrollPanY(ev); onImageScrollPanY(ev, infinite);
break; 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( function onImageMouseOver(ev: React.MouseEvent) {
ev:
| React.MouseEvent<HTMLImageElement, MouseEvent>
| React.MouseEvent<HTMLVideoElement, MouseEvent>
) {
if (!moving) return; if (!moving) return;
if (!ev.buttons) { if (!ev.buttons) {
@@ -371,22 +422,14 @@ export const LightboxImage: React.FC<IProps> = ({
setPositionY(positionY + posY); setPositionY(positionY + posY);
} }
function onImageMouseDown( function onImageMouseDown(ev: React.MouseEvent) {
ev:
| React.MouseEvent<HTMLImageElement, MouseEvent>
| React.MouseEvent<HTMLVideoElement, MouseEvent>
) {
startPoints.current = [ev.pageX, ev.pageY]; startPoints.current = [ev.pageX, ev.pageY];
setMoving(true); setMoving(true);
mouseDownEvent.current = ev.nativeEvent; mouseDownEvent.current = ev.nativeEvent;
} }
function onImageMouseUp( function onImageMouseUp(ev: React.MouseEvent) {
ev:
| React.MouseEvent<HTMLImageElement, MouseEvent>
| React.MouseEvent<HTMLVideoElement, MouseEvent>
) {
if (ev.button !== 0) return; if (ev.button !== 0) return;
if ( if (
@@ -412,12 +455,7 @@ export const LightboxImage: React.FC<IProps> = ({
} }
} }
function onTouchStart( function onTouchStart(ev: React.TouchEvent) {
ev:
| React.TouchEvent<HTMLImageElement>
| React.TouchEvent<HTMLVideoElement>
| React.TouchEvent<HTMLDivElement>
) {
ev.preventDefault(); ev.preventDefault();
if (ev.touches.length === 1) { if (ev.touches.length === 1) {
startPoints.current = [ev.touches[0].pageX, ev.touches[0].pageY]; startPoints.current = [ev.touches[0].pageX, ev.touches[0].pageY];
@@ -425,12 +463,7 @@ export const LightboxImage: React.FC<IProps> = ({
} }
} }
function onTouchMove( function onTouchMove(ev: React.TouchEvent) {
ev:
| React.TouchEvent<HTMLImageElement>
| React.TouchEvent<HTMLVideoElement>
| React.TouchEvent<HTMLDivElement>
) {
if (!moving) return; if (!moving) return;
if (ev.touches.length === 1) { if (ev.touches.length === 1) {
@@ -443,12 +476,7 @@ export const LightboxImage: React.FC<IProps> = ({
} }
} }
function onPointerDown( function onPointerDown(ev: React.PointerEvent) {
ev:
| React.PointerEvent<HTMLImageElement>
| React.PointerEvent<HTMLVideoElement>
| React.PointerEvent<HTMLDivElement>
) {
// replace pointer event with the same id, if applicable // replace pointer event with the same id, if applicable
pointerCache.current = pointerCache.current.filter( pointerCache.current = pointerCache.current.filter(
(e) => e.pointerId !== ev.pointerId (e) => e.pointerId !== ev.pointerId
@@ -458,12 +486,7 @@ export const LightboxImage: React.FC<IProps> = ({
prevDiff.current = undefined; prevDiff.current = undefined;
} }
function onPointerUp( function onPointerUp(ev: React.PointerEvent) {
ev:
| React.PointerEvent<HTMLImageElement>
| React.PointerEvent<HTMLVideoElement>
| React.PointerEvent<HTMLDivElement>
) {
for (let i = 0; i < pointerCache.current.length; i++) { for (let i = 0; i < pointerCache.current.length; i++) {
if (pointerCache.current[i].pointerId === ev.pointerId) { if (pointerCache.current[i].pointerId === ev.pointerId) {
pointerCache.current.splice(i, 1); pointerCache.current.splice(i, 1);
@@ -472,12 +495,7 @@ export const LightboxImage: React.FC<IProps> = ({
} }
} }
function onPointerMove( function onPointerMove(ev: React.PointerEvent) {
ev:
| React.PointerEvent<HTMLImageElement>
| React.PointerEvent<HTMLVideoElement>
| React.PointerEvent<HTMLDivElement>
) {
// find the event in the cache // find the event in the cache
const cachedIndex = pointerCache.current.findIndex( const cachedIndex = pointerCache.current.findIndex(
(c) => c.pointerId === ev.pointerId (c) => c.pointerId === ev.pointerId
@@ -543,14 +561,14 @@ export const LightboxImage: React.FC<IProps> = ({
draggable={false} draggable={false}
style={customStyle} style={customStyle}
onWheel={current ? (e) => onImageScroll(e) : undefined} onWheel={current ? (e) => onImageScroll(e) : undefined}
onMouseDown={(e) => onImageMouseDown(e)} onMouseDown={onImageMouseDown}
onMouseUp={(e) => onImageMouseUp(e)} onMouseUp={onImageMouseUp}
onMouseMove={(e) => onImageMouseOver(e)} onMouseMove={onImageMouseOver}
onTouchStart={(e) => onTouchStart(e)} onTouchStart={onTouchStart}
onTouchMove={(e) => onTouchMove(e)} onTouchMove={onTouchMove}
onPointerDown={(e) => onPointerDown(e)} onPointerDown={onPointerDown}
onPointerUp={(e) => onPointerUp(e)} onPointerUp={onPointerUp}
onPointerMove={(e) => onPointerMove(e)} onPointerMove={onPointerMove}
/> />
</picture> </picture>
) : undefined} ) : undefined}