mirror of
https://github.com/stashapp/stash.git
synced 2025-12-06 08:26:00 +01:00
Merge dcc825c2a6 into 39fd8a6550
This commit is contained in:
commit
015ab38151
3 changed files with 325 additions and 157 deletions
|
|
@ -1,4 +1,11 @@
|
||||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
import React, {
|
||||||
|
ForwardedRef,
|
||||||
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Col,
|
Col,
|
||||||
|
|
@ -69,7 +76,9 @@ const CLASSNAME_FOOTER_RIGHT = `${CLASSNAME_FOOTER}-right`;
|
||||||
const CLASSNAME_DISPLAY = `${CLASSNAME}-display`;
|
const CLASSNAME_DISPLAY = `${CLASSNAME}-display`;
|
||||||
const CLASSNAME_CAROUSEL = `${CLASSNAME}-carousel`;
|
const CLASSNAME_CAROUSEL = `${CLASSNAME}-carousel`;
|
||||||
const CLASSNAME_INSTANT = `${CLASSNAME_CAROUSEL}-instant`;
|
const CLASSNAME_INSTANT = `${CLASSNAME_CAROUSEL}-instant`;
|
||||||
|
const CLASSNAME_SWIPE = `${CLASSNAME_CAROUSEL}-swipe`;
|
||||||
const CLASSNAME_IMAGE = `${CLASSNAME_CAROUSEL}-image`;
|
const CLASSNAME_IMAGE = `${CLASSNAME_CAROUSEL}-image`;
|
||||||
|
const CLASSNAME_IMAGE_CONTAINER = `${CLASSNAME_CAROUSEL}-image-container`;
|
||||||
const CLASSNAME_NAVBUTTON = `${CLASSNAME}-navbutton`;
|
const CLASSNAME_NAVBUTTON = `${CLASSNAME}-navbutton`;
|
||||||
const CLASSNAME_NAV = `${CLASSNAME}-nav`;
|
const CLASSNAME_NAV = `${CLASSNAME}-nav`;
|
||||||
const CLASSNAME_NAVIMAGE = `${CLASSNAME_NAV}-image`;
|
const CLASSNAME_NAVIMAGE = `${CLASSNAME_NAV}-image`;
|
||||||
|
|
@ -82,6 +91,108 @@ const MIN_ZOOM = 0.1;
|
||||||
const SCROLL_ZOOM_TIMEOUT = 250;
|
const SCROLL_ZOOM_TIMEOUT = 250;
|
||||||
const ZOOM_NONE_EPSILON = 0.015;
|
const ZOOM_NONE_EPSILON = 0.015;
|
||||||
|
|
||||||
|
interface ILightboxCarouselProps {
|
||||||
|
transition: string | null;
|
||||||
|
currentIndex: number;
|
||||||
|
images: ILightboxImage[];
|
||||||
|
displayMode: GQL.ImageLightboxDisplayMode;
|
||||||
|
lightboxSettings: GQL.ConfigImageLightboxInput | undefined;
|
||||||
|
resetPosition?: boolean;
|
||||||
|
zoom: number;
|
||||||
|
scrollAttemptsBeforeChange: number;
|
||||||
|
firstScroll: React.MutableRefObject<number | null>;
|
||||||
|
inScrollGroup: React.MutableRefObject<boolean>;
|
||||||
|
movingLeft: boolean;
|
||||||
|
updateZoom: (v: number) => void;
|
||||||
|
debouncedScrollReset: () => void;
|
||||||
|
handleLeft: () => void;
|
||||||
|
handleRight: () => void;
|
||||||
|
overrideTransition: (t: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LightboxCarousel = forwardRef(function (
|
||||||
|
{
|
||||||
|
transition,
|
||||||
|
currentIndex,
|
||||||
|
images,
|
||||||
|
displayMode,
|
||||||
|
lightboxSettings,
|
||||||
|
resetPosition,
|
||||||
|
zoom,
|
||||||
|
scrollAttemptsBeforeChange,
|
||||||
|
firstScroll,
|
||||||
|
inScrollGroup,
|
||||||
|
movingLeft,
|
||||||
|
updateZoom,
|
||||||
|
debouncedScrollReset,
|
||||||
|
handleLeft,
|
||||||
|
handleRight,
|
||||||
|
overrideTransition,
|
||||||
|
}: ILightboxCarouselProps,
|
||||||
|
carouselRef: ForwardedRef<HTMLDivElement>
|
||||||
|
) {
|
||||||
|
const [carouselShift, setCarouselShift] = useState(0);
|
||||||
|
|
||||||
|
function handleMoveCarousel(delta: number) {
|
||||||
|
overrideTransition(CLASSNAME_INSTANT);
|
||||||
|
setCarouselShift(carouselShift + delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReleaseCarousel(
|
||||||
|
event: React.TouchEvent,
|
||||||
|
swipeDuration: number
|
||||||
|
) {
|
||||||
|
const cappedDuration = Math.max(50, Math.min(500, swipeDuration)) / 1000;
|
||||||
|
const adjustedShift = carouselShift / (2 * cappedDuration);
|
||||||
|
if (adjustedShift < -window.innerWidth / 2) {
|
||||||
|
handleRight();
|
||||||
|
} else if (adjustedShift > window.innerWidth / 2) {
|
||||||
|
handleLeft();
|
||||||
|
}
|
||||||
|
setCarouselShift(0);
|
||||||
|
overrideTransition(CLASSNAME_SWIPE);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={CLASSNAME_CAROUSEL + (transition ? ` ${transition}` : "")}
|
||||||
|
style={{ left: `calc(${currentIndex * -100}vw + ${carouselShift}px)` }}
|
||||||
|
ref={carouselRef}
|
||||||
|
>
|
||||||
|
{images.map((image, i) => (
|
||||||
|
<div className={`${CLASSNAME_IMAGE_CONTAINER}`} key={image.paths.image}>
|
||||||
|
{i >= currentIndex - 1 && i <= currentIndex + 1 ? (
|
||||||
|
<LightboxImage
|
||||||
|
src={image.paths.image ?? ""}
|
||||||
|
width={image.visual_files?.[0]?.width ?? 0}
|
||||||
|
height={image.visual_files?.[0]?.height ?? 0}
|
||||||
|
displayMode={displayMode}
|
||||||
|
scaleUp={lightboxSettings?.scaleUp ?? false}
|
||||||
|
scrollMode={
|
||||||
|
lightboxSettings?.scrollMode ?? 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}
|
||||||
|
onRight={handleRight}
|
||||||
|
isVideo={isVideo(image.visual_files?.[0] ?? {})}
|
||||||
|
moveCarousel={handleMoveCarousel}
|
||||||
|
releaseCarousel={handleReleaseCarousel}
|
||||||
|
/>
|
||||||
|
) : undefined}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
images: ILightboxImage[];
|
images: ILightboxImage[];
|
||||||
isVisible: boolean;
|
isVisible: boolean;
|
||||||
|
|
@ -117,7 +228,7 @@ export const LightboxComponent: React.FC<IProps> = ({
|
||||||
const [index, setIndex] = useState<number | null>(null);
|
const [index, setIndex] = useState<number | null>(null);
|
||||||
const [movingLeft, setMovingLeft] = useState(false);
|
const [movingLeft, setMovingLeft] = useState(false);
|
||||||
const oldIndex = useRef<number | null>(null);
|
const oldIndex = useRef<number | null>(null);
|
||||||
const [instantTransition, setInstantTransition] = useState(false);
|
const [transition, setTransition] = useState<string | null>(null);
|
||||||
const [isSwitchingPage, setIsSwitchingPage] = useState(true);
|
const [isSwitchingPage, setIsSwitchingPage] = useState(true);
|
||||||
const [isFullscreen, setFullscreen] = useState(false);
|
const [isFullscreen, setFullscreen] = useState(false);
|
||||||
const [showOptions, setShowOptions] = useState(false);
|
const [showOptions, setShowOptions] = useState(false);
|
||||||
|
|
@ -144,7 +255,6 @@ export const LightboxComponent: React.FC<IProps> = ({
|
||||||
|
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const overlayTarget = useRef<HTMLButtonElement | null>(null);
|
const overlayTarget = useRef<HTMLButtonElement | null>(null);
|
||||||
const carouselRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const indicatorRef = useRef<HTMLDivElement | null>(null);
|
const indicatorRef = useRef<HTMLDivElement | null>(null);
|
||||||
const navRef = useRef<HTMLDivElement | null>(null);
|
const navRef = useRef<HTMLDivElement | null>(null);
|
||||||
const clearIntervalCallback = useRef<() => void>();
|
const clearIntervalCallback = useRef<() => void>();
|
||||||
|
|
@ -232,15 +342,15 @@ export const LightboxComponent: React.FC<IProps> = ({
|
||||||
}
|
}
|
||||||
}, [isSwitchingPage, images, index]);
|
}, [isSwitchingPage, images, index]);
|
||||||
|
|
||||||
const disableInstantTransition = useDebounce(
|
const restoreTransition = useDebounce(() => setTransition(null), 400);
|
||||||
() => setInstantTransition(false),
|
|
||||||
400
|
|
||||||
);
|
|
||||||
|
|
||||||
const setInstant = useCallback(() => {
|
const overrideTransition = useCallback(
|
||||||
setInstantTransition(true);
|
(t: string) => {
|
||||||
disableInstantTransition();
|
setTransition(t);
|
||||||
}, [disableInstantTransition]);
|
restoreTransition();
|
||||||
|
},
|
||||||
|
[restoreTransition]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (images.length < 2) return;
|
if (images.length < 2) return;
|
||||||
|
|
@ -422,12 +532,12 @@ export const LightboxComponent: React.FC<IProps> = ({
|
||||||
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"))
|
||||||
setInstant();
|
overrideTransition(CLASSNAME_INSTANT);
|
||||||
if (e.key === "ArrowLeft") handleLeft();
|
if (e.key === "ArrowLeft") handleLeft();
|
||||||
else if (e.key === "ArrowRight") handleRight();
|
else if (e.key === "ArrowRight") handleRight();
|
||||||
else if (e.key === "Escape") close();
|
else if (e.key === "Escape") close();
|
||||||
},
|
},
|
||||||
[setInstant, handleLeft, handleRight, close]
|
[overrideTransition, handleLeft, handleRight, close]
|
||||||
);
|
);
|
||||||
const handleFullScreenChange = () => {
|
const handleFullScreenChange = () => {
|
||||||
if (clearIntervalCallback.current) {
|
if (clearIntervalCallback.current) {
|
||||||
|
|
@ -869,45 +979,24 @@ export const LightboxComponent: React.FC<IProps> = ({
|
||||||
<Icon icon={faChevronLeft} />
|
<Icon icon={faChevronLeft} />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
<LightboxCarousel
|
||||||
<div
|
transition={transition}
|
||||||
className={cx(CLASSNAME_CAROUSEL, {
|
currentIndex={currentIndex}
|
||||||
[CLASSNAME_INSTANT]: instantTransition,
|
images={images}
|
||||||
})}
|
displayMode={displayMode}
|
||||||
style={{ left: `${currentIndex * -100}vw` }}
|
lightboxSettings={lightboxSettings}
|
||||||
ref={carouselRef}
|
resetPosition={resetPosition}
|
||||||
>
|
zoom={zoom}
|
||||||
{images.map((image, i) => (
|
scrollAttemptsBeforeChange={scrollAttemptsBeforeChange}
|
||||||
<div className={`${CLASSNAME_IMAGE}`} key={image.paths.image}>
|
firstScroll={firstScroll}
|
||||||
{i >= currentIndex - 1 && i <= currentIndex + 1 ? (
|
inScrollGroup={inScrollGroup}
|
||||||
<LightboxImage
|
movingLeft={movingLeft}
|
||||||
src={image.paths.image ?? ""}
|
updateZoom={updateZoom}
|
||||||
width={image.visual_files?.[0]?.width ?? 0}
|
debouncedScrollReset={debouncedScrollReset}
|
||||||
height={image.visual_files?.[0]?.height ?? 0}
|
handleLeft={handleLeft}
|
||||||
displayMode={displayMode}
|
handleRight={handleRight}
|
||||||
scaleUp={lightboxSettings?.scaleUp ?? false}
|
overrideTransition={overrideTransition}
|
||||||
scrollMode={
|
/>
|
||||||
lightboxSettings?.scrollMode ??
|
|
||||||
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}
|
|
||||||
onRight={handleRight}
|
|
||||||
isVideo={isVideo(image.visual_files?.[0] ?? {})}
|
|
||||||
/>
|
|
||||||
) : undefined}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{allowNavigation && (
|
{allowNavigation && (
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,13 @@
|
||||||
import React, { useEffect, useRef, useState, useCallback } from "react";
|
import React, {
|
||||||
|
MutableRefObject,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
useCallback,
|
||||||
|
} from "react";
|
||||||
|
import useResizeObserver from "@react-hook/resize-observer";
|
||||||
import * as GQL from "src/core/generated-graphql";
|
import * as GQL from "src/core/generated-graphql";
|
||||||
|
import cx from "classnames";
|
||||||
|
|
||||||
const ZOOM_STEP = 1.1;
|
const ZOOM_STEP = 1.1;
|
||||||
const ZOOM_FACTOR = 700;
|
const ZOOM_FACTOR = 700;
|
||||||
|
|
@ -11,6 +19,7 @@ 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`;
|
||||||
|
const CLASSNAME_IMAGE_PAN = `${CLASSNAME_IMAGE}-pan`;
|
||||||
|
|
||||||
function calculateDefaultZoom(
|
function calculateDefaultZoom(
|
||||||
width: number,
|
width: number,
|
||||||
|
|
@ -50,6 +59,28 @@ function calculateDefaultZoom(
|
||||||
return newZoom;
|
return newZoom;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface IDimension {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useContainerDimensions = <
|
||||||
|
T extends HTMLElement = HTMLDivElement
|
||||||
|
>(): [MutableRefObject<T | null>, IDimension] => {
|
||||||
|
const target = useRef<T | null>(null);
|
||||||
|
const [dimension, setDimension] = useState<IDimension>({
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
useResizeObserver(target, (entry) => {
|
||||||
|
const { inlineSize: width, blockSize: height } = entry.contentBoxSize[0];
|
||||||
|
setDimension({ width, height });
|
||||||
|
});
|
||||||
|
|
||||||
|
return [target, dimension];
|
||||||
|
};
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
src: string;
|
src: string;
|
||||||
width: number;
|
width: number;
|
||||||
|
|
@ -71,6 +102,8 @@ interface IProps {
|
||||||
debouncedScrollReset: () => void;
|
debouncedScrollReset: () => void;
|
||||||
onLeft: () => void;
|
onLeft: () => void;
|
||||||
onRight: () => void;
|
onRight: () => void;
|
||||||
|
moveCarousel: (v: number) => void;
|
||||||
|
releaseCarousel: (ev: React.TouchEvent, swipeDuration: number) => void;
|
||||||
isVideo: boolean;
|
isVideo: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -92,38 +125,34 @@ export const LightboxImage: React.FC<IProps> = ({
|
||||||
debouncedScrollReset,
|
debouncedScrollReset,
|
||||||
onLeft,
|
onLeft,
|
||||||
onRight,
|
onRight,
|
||||||
|
moveCarousel,
|
||||||
|
releaseCarousel,
|
||||||
isVideo,
|
isVideo,
|
||||||
}) => {
|
}) => {
|
||||||
const [defaultZoom, setDefaultZoom] = useState(1);
|
const [defaultZoom, setDefaultZoom] = useState<number | null>(null);
|
||||||
const [moving, setMoving] = useState(false);
|
const [moving, setMoving] = useState(false);
|
||||||
const [positionX, setPositionX] = useState(0);
|
const [positionX, setPositionX] = useState(0);
|
||||||
const [positionY, setPositionY] = useState(0);
|
const [positionY, setPositionY] = useState(0);
|
||||||
const [imageWidth, setImageWidth] = useState(width);
|
const [imageWidth, setImageWidth] = useState(width);
|
||||||
const [imageHeight, setImageHeight] = useState(height);
|
const [imageHeight, setImageHeight] = useState(height);
|
||||||
const [boxWidth, setBoxWidth] = useState(0);
|
|
||||||
const [boxHeight, setBoxHeight] = useState(0);
|
|
||||||
const dimensionsProvided = width > 0 && height > 0;
|
const dimensionsProvided = width > 0 && height > 0;
|
||||||
|
const [containerRef, { width: boxWidth, height: boxHeight }] =
|
||||||
|
useContainerDimensions();
|
||||||
|
|
||||||
const mouseDownEvent = useRef<MouseEvent>();
|
const mouseDownEvent = useRef<MouseEvent>();
|
||||||
const resetPositionRef = useRef(resetPosition);
|
const resetPositionRef = useRef(resetPosition);
|
||||||
|
|
||||||
const container = React.createRef<HTMLDivElement>();
|
|
||||||
const startPoints = useRef<number[]>([0, 0]);
|
const startPoints = useRef<number[]>([0, 0]);
|
||||||
|
const startTime = useRef<number>(0);
|
||||||
const pointerCache = useRef<React.PointerEvent[]>([]);
|
const pointerCache = useRef<React.PointerEvent[]>([]);
|
||||||
const prevDiff = useRef<number | undefined>();
|
const prevDiff = useRef<number | undefined>();
|
||||||
|
|
||||||
const scrollAttempts = useRef(0);
|
const scrollAttempts = useRef(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const box = container.current;
|
|
||||||
if (box) {
|
|
||||||
setBoxWidth(box.offsetWidth);
|
|
||||||
setBoxHeight(box.offsetHeight);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleVideoPlay() {
|
function toggleVideoPlay() {
|
||||||
if (container.current) {
|
if (containerRef.current) {
|
||||||
let openVideo = container.current.getElementsByTagName("video");
|
let openVideo = containerRef.current.getElementsByTagName("video");
|
||||||
if (openVideo.length > 0) {
|
if (openVideo.length > 0) {
|
||||||
let rect = openVideo[0].getBoundingClientRect();
|
let rect = openVideo[0].getBoundingClientRect();
|
||||||
if (Math.abs(rect.x) < document.body.clientWidth / 2) {
|
if (Math.abs(rect.x) < document.body.clientWidth / 2) {
|
||||||
|
|
@ -138,7 +167,7 @@ export const LightboxImage: React.FC<IProps> = ({
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
toggleVideoPlay();
|
toggleVideoPlay();
|
||||||
}, 250);
|
}, 250);
|
||||||
}, [container]);
|
}, [containerRef]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (dimensionsProvided) {
|
if (dimensionsProvided) {
|
||||||
|
|
@ -161,27 +190,30 @@ export const LightboxImage: React.FC<IProps> = ({
|
||||||
};
|
};
|
||||||
}, [src, dimensionsProvided]);
|
}, [src, dimensionsProvided]);
|
||||||
|
|
||||||
|
const calcPanBounds = useCallback(
|
||||||
|
(appliedZoom: number) => {
|
||||||
|
const xRange = Math.max(appliedZoom * imageWidth - boxWidth, 0);
|
||||||
|
const yRange = Math.max(appliedZoom * imageHeight - boxHeight, 0);
|
||||||
|
const nonZero = xRange != 0 || yRange != 0;
|
||||||
|
return {
|
||||||
|
minX: -xRange / 2,
|
||||||
|
maxX: xRange / 2,
|
||||||
|
minY: -yRange / 2,
|
||||||
|
maxY: yRange / 2,
|
||||||
|
nonZero,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[imageWidth, boxWidth, imageHeight, boxHeight]
|
||||||
|
);
|
||||||
|
const panBounds =
|
||||||
|
defaultZoom !== null
|
||||||
|
? calcPanBounds(defaultZoom * zoom)
|
||||||
|
: { minX: 0, maxX: 0, minY: 0, maxY: 0, nonZero: false };
|
||||||
|
|
||||||
const minMaxY = useCallback(
|
const minMaxY = useCallback(
|
||||||
(appliedZoom: number) => {
|
(appliedZoom: number) => {
|
||||||
let minY, maxY: number;
|
const minY = Math.min((boxHeight - appliedZoom * imageHeight) / 2, 0);
|
||||||
const inBounds = appliedZoom * imageHeight <= boxHeight;
|
const maxY = Math.max((appliedZoom * imageHeight - boxHeight) / 2, 0);
|
||||||
|
|
||||||
// NOTE: I don't even know how these work, but they do
|
|
||||||
if (!inBounds) {
|
|
||||||
if (imageHeight > boxHeight) {
|
|
||||||
minY =
|
|
||||||
(appliedZoom * imageHeight - imageHeight) / 2 -
|
|
||||||
appliedZoom * imageHeight +
|
|
||||||
boxHeight;
|
|
||||||
maxY = (appliedZoom * imageHeight - imageHeight) / 2;
|
|
||||||
} else {
|
|
||||||
minY = (boxHeight - appliedZoom * imageHeight) / 2;
|
|
||||||
maxY = (appliedZoom * imageHeight - boxHeight) / 2;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
minY = Math.min((boxHeight - imageHeight) / 2, 0);
|
|
||||||
maxY = minY;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [minY, maxY];
|
return [minY, maxY];
|
||||||
},
|
},
|
||||||
|
|
@ -190,33 +222,21 @@ export const LightboxImage: React.FC<IProps> = ({
|
||||||
|
|
||||||
const calculateInitialPosition = useCallback(
|
const calculateInitialPosition = useCallback(
|
||||||
(appliedZoom: number) => {
|
(appliedZoom: number) => {
|
||||||
// Center image from container's center
|
// If image is smaller than container, place in center. Otherwise, align
|
||||||
const newPositionX = Math.min((boxWidth - imageWidth) / 2, 0);
|
// the left side of the image with the left side of the container, and
|
||||||
let newPositionY: number;
|
// align either the top or bottom of the image with the corresponding
|
||||||
|
// edge of container, depending on whether navigation is forwards or
|
||||||
if (displayMode === GQL.ImageLightboxDisplayMode.FitXy) {
|
// backwards.
|
||||||
newPositionY = Math.min((boxHeight - imageHeight) / 2, 0);
|
const [minY, maxY] = minMaxY(appliedZoom);
|
||||||
} else {
|
const newPositionX = Math.max(
|
||||||
// otherwise, align image with container
|
(appliedZoom * imageWidth - boxWidth) / 2,
|
||||||
const [minY, maxY] = minMaxY(appliedZoom);
|
0
|
||||||
if (!alignBottom) {
|
);
|
||||||
newPositionY = maxY;
|
const newPositionY = alignBottom ? minY : maxY;
|
||||||
} else {
|
|
||||||
newPositionY = minY;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [newPositionX, newPositionY];
|
return [newPositionX, newPositionY];
|
||||||
},
|
},
|
||||||
[
|
[boxWidth, imageWidth, alignBottom, minMaxY]
|
||||||
displayMode,
|
|
||||||
boxWidth,
|
|
||||||
imageWidth,
|
|
||||||
boxHeight,
|
|
||||||
imageHeight,
|
|
||||||
alignBottom,
|
|
||||||
minMaxY,
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -267,6 +287,9 @@ export const LightboxImage: React.FC<IProps> = ({
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (defaultZoom === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (resetPosition !== resetPositionRef.current) {
|
if (resetPosition !== resetPositionRef.current) {
|
||||||
resetPositionRef.current = resetPosition;
|
resetPositionRef.current = resetPosition;
|
||||||
|
|
||||||
|
|
@ -352,7 +375,7 @@ export const LightboxImage: React.FC<IProps> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
function onImageScrollPanY(ev: React.WheelEvent, infinite: boolean) {
|
function onImageScrollPanY(ev: React.WheelEvent, infinite: boolean) {
|
||||||
if (!current) return;
|
if (!current || defaultZoom === null) return;
|
||||||
|
|
||||||
const [minY, maxY] = minMaxY(zoom * defaultZoom);
|
const [minY, maxY] = minMaxY(zoom * defaultZoom);
|
||||||
|
|
||||||
|
|
@ -386,6 +409,8 @@ export const LightboxImage: React.FC<IProps> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
function onImageScroll(ev: React.WheelEvent) {
|
function onImageScroll(ev: React.WheelEvent) {
|
||||||
|
if (defaultZoom === null) return;
|
||||||
|
|
||||||
const absDeltaY = Math.abs(ev.deltaY);
|
const absDeltaY = Math.abs(ev.deltaY);
|
||||||
const firstDeltaY = firstScroll.current;
|
const firstDeltaY = firstScroll.current;
|
||||||
// detect infinite scrolling (mousepad, mouse with infinite scrollwheel)
|
// detect infinite scrolling (mousepad, mouse with infinite scrollwheel)
|
||||||
|
|
@ -405,6 +430,9 @@ export const LightboxImage: React.FC<IProps> = ({
|
||||||
percent = ev.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP;
|
percent = ev.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP;
|
||||||
}
|
}
|
||||||
setZoom(zoom * percent);
|
setZoom(zoom * percent);
|
||||||
|
const bounds = calcPanBounds(defaultZoom * zoom * percent);
|
||||||
|
setPositionX(Math.max(bounds.minX, Math.min(bounds.maxX, positionX)));
|
||||||
|
setPositionY(Math.max(bounds.minY, Math.min(bounds.maxY, positionY)));
|
||||||
break;
|
break;
|
||||||
case GQL.ImageLightboxScrollMode.PanY:
|
case GQL.ImageLightboxScrollMode.PanY:
|
||||||
onImageScrollPanY(ev, infinite);
|
onImageScrollPanY(ev, infinite);
|
||||||
|
|
@ -430,16 +458,26 @@ export const LightboxImage: React.FC<IProps> = ({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const posX = ev.pageX - startPoints.current[0];
|
const deltaX = ev.pageX - startPoints.current[0];
|
||||||
const posY = ev.pageY - startPoints.current[1];
|
const deltaY = ev.pageY - startPoints.current[1];
|
||||||
startPoints.current = [ev.pageX, ev.pageY];
|
startPoints.current = [ev.pageX, ev.pageY];
|
||||||
|
|
||||||
setPositionX(positionX + posX);
|
const newPositionX = Math.max(
|
||||||
setPositionY(positionY + posY);
|
panBounds.minX,
|
||||||
|
Math.min(panBounds.maxX, positionX + deltaX)
|
||||||
|
);
|
||||||
|
const newPositionY = Math.max(
|
||||||
|
panBounds.minY,
|
||||||
|
Math.min(panBounds.maxY, positionY + deltaY)
|
||||||
|
);
|
||||||
|
|
||||||
|
setPositionX(newPositionX);
|
||||||
|
setPositionY(newPositionY);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onImageMouseDown(ev: React.MouseEvent) {
|
function onImageMouseDown(ev: React.MouseEvent) {
|
||||||
startPoints.current = [ev.pageX, ev.pageY];
|
startPoints.current = [ev.pageX, ev.pageY];
|
||||||
|
startTime.current = ev.timeStamp;
|
||||||
setMoving(true);
|
setMoving(true);
|
||||||
|
|
||||||
mouseDownEvent.current = ev.nativeEvent;
|
mouseDownEvent.current = ev.nativeEvent;
|
||||||
|
|
@ -471,24 +509,58 @@ export const LightboxImage: React.FC<IProps> = ({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onTouchStart(ev: React.TouchEvent) {
|
const onTouchStart = useCallback(
|
||||||
ev.preventDefault();
|
(ev: TouchEvent) => {
|
||||||
if (ev.touches.length === 1) {
|
ev.preventDefault();
|
||||||
startPoints.current = [ev.touches[0].pageX, ev.touches[0].pageY];
|
if (ev.touches.length === 1) {
|
||||||
setMoving(true);
|
startPoints.current = [ev.touches[0].pageX, ev.touches[0].pageY];
|
||||||
|
startTime.current = ev.timeStamp;
|
||||||
|
setMoving(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[startPoints, startTime, setMoving]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
container.addEventListener("touchstart", onTouchStart);
|
||||||
|
return () => {
|
||||||
|
container.removeEventListener("touchstart", onTouchStart);
|
||||||
|
};
|
||||||
|
}, [containerRef, onTouchStart]);
|
||||||
|
|
||||||
function onTouchMove(ev: React.TouchEvent) {
|
function onTouchMove(ev: React.TouchEvent) {
|
||||||
if (!moving) return;
|
if (!moving) return;
|
||||||
|
|
||||||
if (ev.touches.length === 1) {
|
if (ev.touches.length === 1) {
|
||||||
const posX = ev.touches[0].pageX - startPoints.current[0];
|
const deltaX = ev.touches[0].pageX - startPoints.current[0];
|
||||||
const posY = ev.touches[0].pageY - startPoints.current[1];
|
const deltaY = ev.touches[0].pageY - startPoints.current[1];
|
||||||
startPoints.current = [ev.touches[0].pageX, ev.touches[0].pageY];
|
startPoints.current = [ev.touches[0].pageX, ev.touches[0].pageY];
|
||||||
|
|
||||||
setPositionX(positionX + posX);
|
if (panBounds.minX != panBounds.maxX) {
|
||||||
setPositionY(positionY + posY);
|
const newPositionX = Math.max(
|
||||||
|
panBounds.minX,
|
||||||
|
Math.min(panBounds.maxX, positionX + deltaX)
|
||||||
|
);
|
||||||
|
const newPositionY = Math.max(
|
||||||
|
panBounds.minY,
|
||||||
|
Math.min(panBounds.maxY, positionY + deltaY)
|
||||||
|
);
|
||||||
|
|
||||||
|
setPositionX(newPositionX);
|
||||||
|
setPositionY(newPositionY);
|
||||||
|
} else {
|
||||||
|
moveCarousel(deltaX);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchEnd(ev: React.TouchEvent) {
|
||||||
|
if (ev.changedTouches.length === 1 && ev.touches.length === 0) {
|
||||||
|
releaseCarousel(ev, ev.timeStamp - startTime.current);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -520,6 +592,8 @@ export const LightboxImage: React.FC<IProps> = ({
|
||||||
pointerCache.current[cachedIndex] = ev;
|
pointerCache.current[cachedIndex] = ev;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (defaultZoom === null) return;
|
||||||
|
|
||||||
// compare the difference between the two pointers
|
// compare the difference between the two pointers
|
||||||
if (pointerCache.current.length === 2) {
|
if (pointerCache.current.length === 2) {
|
||||||
const ev1 = pointerCache.current[0];
|
const ev1 = pointerCache.current[0];
|
||||||
|
|
@ -532,11 +606,11 @@ export const LightboxImage: React.FC<IProps> = ({
|
||||||
const diffDiff = diff - prevDiff.current;
|
const diffDiff = diff - prevDiff.current;
|
||||||
const factor = (Math.abs(diffDiff) / 20) * 0.1 + 1;
|
const factor = (Math.abs(diffDiff) / 20) * 0.1 + 1;
|
||||||
|
|
||||||
if (diffDiff > 0) {
|
let newZoom = diffDiff > 0 ? zoom * factor : zoom / factor;
|
||||||
setZoom(zoom * factor);
|
setZoom(newZoom);
|
||||||
} else if (diffDiff < 0) {
|
const bounds = calcPanBounds(defaultZoom * newZoom);
|
||||||
setZoom((zoom * 1) / factor);
|
setPositionX(Math.max(bounds.minX, Math.min(bounds.maxX, positionX)));
|
||||||
}
|
setPositionY(Math.max(bounds.minY, Math.min(bounds.maxY, positionY)));
|
||||||
}
|
}
|
||||||
|
|
||||||
prevDiff.current = diff;
|
prevDiff.current = diff;
|
||||||
|
|
@ -547,32 +621,36 @@ export const LightboxImage: React.FC<IProps> = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={container}
|
ref={containerRef}
|
||||||
className={`${CLASSNAME_IMAGE}`}
|
className={cx(CLASSNAME_IMAGE, {
|
||||||
|
[CLASSNAME_IMAGE_PAN]: panBounds.nonZero,
|
||||||
|
})}
|
||||||
|
style={{ touchAction: "none" }}
|
||||||
onWheel={(e) => onContainerScroll(e)}
|
onWheel={(e) => onContainerScroll(e)}
|
||||||
|
onTouchMove={onTouchMove}
|
||||||
|
onTouchEnd={onTouchEnd}
|
||||||
>
|
>
|
||||||
{defaultZoom ? (
|
{defaultZoom ? (
|
||||||
<picture
|
<picture>
|
||||||
style={{
|
|
||||||
transform: `translate(${positionX}px, ${positionY}px) scale(${
|
|
||||||
defaultZoom * zoom
|
|
||||||
})`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<source srcSet={src} media="(min-width: 800px)" />
|
|
||||||
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */}
|
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */}
|
||||||
<ImageView
|
<ImageView
|
||||||
|
style={{
|
||||||
|
touchAction: "none",
|
||||||
|
position: "relative",
|
||||||
|
left: "50%",
|
||||||
|
top: "50%",
|
||||||
|
transform: `translate(-50%, -50%) translate(${positionX}px, ${positionY}px) scale(${
|
||||||
|
defaultZoom * zoom
|
||||||
|
})`,
|
||||||
|
}}
|
||||||
loop={isVideo}
|
loop={isVideo}
|
||||||
src={src}
|
src={src}
|
||||||
alt=""
|
alt=""
|
||||||
draggable={false}
|
draggable={false}
|
||||||
style={{ touchAction: "none" }}
|
|
||||||
onWheel={current ? (e) => onImageScroll(e) : undefined}
|
onWheel={current ? (e) => onImageScroll(e) : undefined}
|
||||||
onMouseDown={onImageMouseDown}
|
onMouseDown={onImageMouseDown}
|
||||||
onMouseUp={onImageMouseUp}
|
onMouseUp={onImageMouseUp}
|
||||||
onMouseMove={onImageMouseOver}
|
onMouseMove={onImageMouseOver}
|
||||||
onTouchStart={onTouchStart}
|
|
||||||
onTouchMove={onTouchMove}
|
|
||||||
onPointerDown={onPointerDown}
|
onPointerDown={onPointerDown}
|
||||||
onPointerUp={onPointerUp}
|
onPointerUp={onPointerUp}
|
||||||
onPointerMove={onPointerMove}
|
onPointerMove={onPointerMove}
|
||||||
|
|
|
||||||
|
|
@ -144,28 +144,29 @@
|
||||||
transition-duration: 0ms;
|
transition-duration: 0ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
&-image {
|
&-swipe {
|
||||||
|
transition-duration: 200ms;
|
||||||
|
transition-timing-function: ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-image-container {
|
||||||
content-visibility: auto;
|
content-visibility: auto;
|
||||||
display: flex;
|
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
|
}
|
||||||
|
|
||||||
picture {
|
&-image {
|
||||||
display: flex;
|
height: 100%;
|
||||||
margin: auto;
|
width: 100%;
|
||||||
position: relative;
|
|
||||||
|
|
||||||
> div {
|
|
||||||
display: flex;
|
|
||||||
height: 100%;
|
|
||||||
position: absolute;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
img {
|
img {
|
||||||
cursor: pointer;
|
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&-pan {
|
||||||
|
img {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue