mirror of
https://github.com/stashapp/stash.git
synced 2025-12-06 08:26:00 +01:00
Add swipe navigation to lightbox
This commit is contained in:
parent
d78462bdb4
commit
dcc825c2a6
3 changed files with 110 additions and 33 deletions
|
|
@ -76,6 +76,7 @@ const CLASSNAME_FOOTER_RIGHT = `${CLASSNAME_FOOTER}-right`;
|
|||
const CLASSNAME_DISPLAY = `${CLASSNAME}-display`;
|
||||
const CLASSNAME_CAROUSEL = `${CLASSNAME}-carousel`;
|
||||
const CLASSNAME_INSTANT = `${CLASSNAME_CAROUSEL}-instant`;
|
||||
const CLASSNAME_SWIPE = `${CLASSNAME_CAROUSEL}-swipe`;
|
||||
const CLASSNAME_IMAGE = `${CLASSNAME_CAROUSEL}-image`;
|
||||
const CLASSNAME_IMAGE_CONTAINER = `${CLASSNAME_CAROUSEL}-image-container`;
|
||||
const CLASSNAME_NAVBUTTON = `${CLASSNAME}-navbutton`;
|
||||
|
|
@ -91,7 +92,7 @@ const SCROLL_ZOOM_TIMEOUT = 250;
|
|||
const ZOOM_NONE_EPSILON = 0.015;
|
||||
|
||||
interface ILightboxCarouselProps {
|
||||
instantTransition: boolean;
|
||||
transition: string | null;
|
||||
currentIndex: number;
|
||||
images: ILightboxImage[];
|
||||
displayMode: GQL.ImageLightboxDisplayMode;
|
||||
|
|
@ -106,11 +107,12 @@ interface ILightboxCarouselProps {
|
|||
debouncedScrollReset: () => void;
|
||||
handleLeft: () => void;
|
||||
handleRight: () => void;
|
||||
overrideTransition: (t: string) => void;
|
||||
}
|
||||
|
||||
const LightboxCarousel = forwardRef(function (
|
||||
{
|
||||
instantTransition,
|
||||
transition,
|
||||
currentIndex,
|
||||
images,
|
||||
displayMode,
|
||||
|
|
@ -125,15 +127,36 @@ const LightboxCarousel = forwardRef(function (
|
|||
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={cx(CLASSNAME_CAROUSEL, {
|
||||
[CLASSNAME_INSTANT]: instantTransition,
|
||||
})}
|
||||
style={{ left: `${currentIndex * -100}vw` }}
|
||||
className={CLASSNAME_CAROUSEL + (transition ? ` ${transition}` : "")}
|
||||
style={{ left: `calc(${currentIndex * -100}vw + ${carouselShift}px)` }}
|
||||
ref={carouselRef}
|
||||
>
|
||||
{images.map((image, i) => (
|
||||
|
|
@ -160,6 +183,8 @@ const LightboxCarousel = forwardRef(function (
|
|||
onLeft={handleLeft}
|
||||
onRight={handleRight}
|
||||
isVideo={isVideo(image.visual_files?.[0] ?? {})}
|
||||
moveCarousel={handleMoveCarousel}
|
||||
releaseCarousel={handleReleaseCarousel}
|
||||
/>
|
||||
) : undefined}
|
||||
</div>
|
||||
|
|
@ -203,7 +228,7 @@ export const LightboxComponent: React.FC<IProps> = ({
|
|||
const [index, setIndex] = useState<number | null>(null);
|
||||
const [movingLeft, setMovingLeft] = useState(false);
|
||||
const oldIndex = useRef<number | null>(null);
|
||||
const [instantTransition, setInstantTransition] = useState(false);
|
||||
const [transition, setTransition] = useState<string | null>(null);
|
||||
const [isSwitchingPage, setIsSwitchingPage] = useState(true);
|
||||
const [isFullscreen, setFullscreen] = useState(false);
|
||||
const [showOptions, setShowOptions] = useState(false);
|
||||
|
|
@ -315,15 +340,15 @@ export const LightboxComponent: React.FC<IProps> = ({
|
|||
}
|
||||
}, [isSwitchingPage, images, index]);
|
||||
|
||||
const disableInstantTransition = useDebounce(
|
||||
() => setInstantTransition(false),
|
||||
400
|
||||
);
|
||||
const restoreTransition = useDebounce(() => setTransition(null), 400);
|
||||
|
||||
const setInstant = useCallback(() => {
|
||||
setInstantTransition(true);
|
||||
disableInstantTransition();
|
||||
}, [disableInstantTransition]);
|
||||
const overrideTransition = useCallback(
|
||||
(t: string) => {
|
||||
setTransition(t);
|
||||
restoreTransition();
|
||||
},
|
||||
[restoreTransition]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (images.length < 2) return;
|
||||
|
|
@ -487,12 +512,12 @@ export const LightboxComponent: React.FC<IProps> = ({
|
|||
const handleKey = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.repeat && (e.key === "ArrowRight" || e.key === "ArrowLeft"))
|
||||
setInstant();
|
||||
overrideTransition(CLASSNAME_INSTANT);
|
||||
if (e.key === "ArrowLeft") handleLeft();
|
||||
else if (e.key === "ArrowRight") handleRight();
|
||||
else if (e.key === "Escape") close();
|
||||
},
|
||||
[setInstant, handleLeft, handleRight, close]
|
||||
[overrideTransition, handleLeft, handleRight, close]
|
||||
);
|
||||
const handleFullScreenChange = () => {
|
||||
if (clearIntervalCallback.current) {
|
||||
|
|
@ -934,7 +959,7 @@ export const LightboxComponent: React.FC<IProps> = ({
|
|||
</Button>
|
||||
)}
|
||||
<LightboxCarousel
|
||||
instantTransition={instantTransition}
|
||||
transition={transition}
|
||||
currentIndex={currentIndex}
|
||||
images={images}
|
||||
displayMode={displayMode}
|
||||
|
|
@ -949,6 +974,7 @@ export const LightboxComponent: React.FC<IProps> = ({
|
|||
debouncedScrollReset={debouncedScrollReset}
|
||||
handleLeft={handleLeft}
|
||||
handleRight={handleRight}
|
||||
overrideTransition={overrideTransition}
|
||||
/>
|
||||
{allowNavigation && (
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -102,6 +102,8 @@ interface IProps {
|
|||
debouncedScrollReset: () => void;
|
||||
onLeft: () => void;
|
||||
onRight: () => void;
|
||||
moveCarousel: (v: number) => void;
|
||||
releaseCarousel: (ev: React.TouchEvent, swipeDuration: number) => void;
|
||||
isVideo: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -123,9 +125,11 @@ export const LightboxImage: React.FC<IProps> = ({
|
|||
debouncedScrollReset,
|
||||
onLeft,
|
||||
onRight,
|
||||
moveCarousel,
|
||||
releaseCarousel,
|
||||
isVideo,
|
||||
}) => {
|
||||
const [defaultZoom, setDefaultZoom] = useState(1);
|
||||
const [defaultZoom, setDefaultZoom] = useState<number | null>(null);
|
||||
const [moving, setMoving] = useState(false);
|
||||
const [positionX, setPositionX] = useState(0);
|
||||
const [positionY, setPositionY] = useState(0);
|
||||
|
|
@ -139,6 +143,7 @@ export const LightboxImage: React.FC<IProps> = ({
|
|||
const resetPositionRef = useRef(resetPosition);
|
||||
|
||||
const startPoints = useRef<number[]>([0, 0]);
|
||||
const startTime = useRef<number>(0);
|
||||
const pointerCache = useRef<React.PointerEvent[]>([]);
|
||||
const prevDiff = useRef<number | undefined>();
|
||||
|
||||
|
|
@ -200,7 +205,10 @@ export const LightboxImage: React.FC<IProps> = ({
|
|||
},
|
||||
[imageWidth, boxWidth, imageHeight, boxHeight]
|
||||
);
|
||||
const panBounds = calcPanBounds(defaultZoom * zoom);
|
||||
const panBounds =
|
||||
defaultZoom !== null
|
||||
? calcPanBounds(defaultZoom * zoom)
|
||||
: { minX: 0, maxX: 0, minY: 0, maxY: 0, nonZero: false };
|
||||
|
||||
const minMaxY = useCallback(
|
||||
(appliedZoom: number) => {
|
||||
|
|
@ -279,6 +287,9 @@ export const LightboxImage: React.FC<IProps> = ({
|
|||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (defaultZoom === null) {
|
||||
return;
|
||||
}
|
||||
if (resetPosition !== resetPositionRef.current) {
|
||||
resetPositionRef.current = resetPosition;
|
||||
|
||||
|
|
@ -364,7 +375,7 @@ export const LightboxImage: React.FC<IProps> = ({
|
|||
}
|
||||
|
||||
function onImageScrollPanY(ev: React.WheelEvent, infinite: boolean) {
|
||||
if (!current) return;
|
||||
if (!current || defaultZoom === null) return;
|
||||
|
||||
const [minY, maxY] = minMaxY(zoom * defaultZoom);
|
||||
|
||||
|
|
@ -466,6 +477,7 @@ export const LightboxImage: React.FC<IProps> = ({
|
|||
|
||||
function onImageMouseDown(ev: React.MouseEvent) {
|
||||
startPoints.current = [ev.pageX, ev.pageY];
|
||||
startTime.current = ev.timeStamp;
|
||||
setMoving(true);
|
||||
|
||||
mouseDownEvent.current = ev.nativeEvent;
|
||||
|
|
@ -497,24 +509,58 @@ export const LightboxImage: React.FC<IProps> = ({
|
|||
}
|
||||
}
|
||||
|
||||
function onTouchStart(ev: React.TouchEvent) {
|
||||
ev.preventDefault();
|
||||
if (ev.touches.length === 1) {
|
||||
startPoints.current = [ev.touches[0].pageX, ev.touches[0].pageY];
|
||||
setMoving(true);
|
||||
const onTouchStart = useCallback(
|
||||
(ev: TouchEvent) => {
|
||||
ev.preventDefault();
|
||||
if (ev.touches.length === 1) {
|
||||
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) {
|
||||
if (!moving) return;
|
||||
|
||||
if (ev.touches.length === 1) {
|
||||
const posX = ev.touches[0].pageX - startPoints.current[0];
|
||||
const posY = ev.touches[0].pageY - startPoints.current[1];
|
||||
const deltaX = ev.touches[0].pageX - startPoints.current[0];
|
||||
const deltaY = ev.touches[0].pageY - startPoints.current[1];
|
||||
startPoints.current = [ev.touches[0].pageX, ev.touches[0].pageY];
|
||||
|
||||
setPositionX(positionX + posX);
|
||||
setPositionY(positionY + posY);
|
||||
if (panBounds.minX != panBounds.maxX) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -581,6 +627,8 @@ export const LightboxImage: React.FC<IProps> = ({
|
|||
})}
|
||||
style={{ touchAction: "none" }}
|
||||
onWheel={(e) => onContainerScroll(e)}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
>
|
||||
{defaultZoom ? (
|
||||
<picture>
|
||||
|
|
@ -603,8 +651,6 @@ export const LightboxImage: React.FC<IProps> = ({
|
|||
onMouseDown={onImageMouseDown}
|
||||
onMouseUp={onImageMouseUp}
|
||||
onMouseMove={onImageMouseOver}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchMove={onTouchMove}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerMove={onPointerMove}
|
||||
|
|
|
|||
|
|
@ -144,6 +144,11 @@
|
|||
transition-duration: 0ms;
|
||||
}
|
||||
|
||||
&-swipe {
|
||||
transition-duration: 200ms;
|
||||
transition-timing-function: ease-out;
|
||||
}
|
||||
|
||||
&-image-container {
|
||||
content-visibility: auto;
|
||||
width: 100vw;
|
||||
|
|
|
|||
Loading…
Reference in a new issue