mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 20:34:37 +03:00
Lightbox pan, zoom and display mode options (#1708)
* Rewrite lightbox code * Don't render offscreen images * Scroll up to zoom in * Support touch gestures * Add reset zoom button * Align top of image on original/fit horizontal * Add scrollmode setting * Add scale up option * Add option to maintain zoom when transisitioning * Fix image slideshow wrap around * Wrap around on previous on first page/image * Fix single page issues * Fix two-pointer zoom mode incorrectly activated
This commit is contained in:
363
ui/v2.5/src/hooks/Lightbox/LightboxImage.tsx
Normal file
363
ui/v2.5/src/hooks/Lightbox/LightboxImage.tsx
Normal file
@@ -0,0 +1,363 @@
|
||||
import React, { useEffect, useRef, useState, useCallback } from "react";
|
||||
|
||||
const ZOOM_STEP = 1.1;
|
||||
const SCROLL_PAN_STEP = 75;
|
||||
const CLASSNAME = "Lightbox";
|
||||
const CLASSNAME_CAROUSEL = `${CLASSNAME}-carousel`;
|
||||
const CLASSNAME_IMAGE = `${CLASSNAME_CAROUSEL}-image`;
|
||||
|
||||
export enum DisplayMode {
|
||||
ORIGINAL = "ORIGINAL",
|
||||
FIT_XY = "FIT_XY",
|
||||
FIT_X = "FIT_X",
|
||||
}
|
||||
|
||||
export enum ScrollMode {
|
||||
ZOOM = "ZOOM",
|
||||
PAN_Y = "PAN_Y",
|
||||
}
|
||||
|
||||
interface IProps {
|
||||
src: string;
|
||||
displayMode: DisplayMode;
|
||||
scaleUp: boolean;
|
||||
scrollMode: ScrollMode;
|
||||
resetPosition?: boolean;
|
||||
zoom: number;
|
||||
setZoom: (v: number) => void;
|
||||
onLeft: () => void;
|
||||
onRight: () => void;
|
||||
}
|
||||
|
||||
export const LightboxImage: React.FC<IProps> = ({
|
||||
src,
|
||||
onLeft,
|
||||
onRight,
|
||||
displayMode,
|
||||
scaleUp,
|
||||
scrollMode,
|
||||
zoom,
|
||||
setZoom,
|
||||
resetPosition,
|
||||
}) => {
|
||||
const [defaultZoom, setDefaultZoom] = useState(1);
|
||||
const [moving, setMoving] = useState(false);
|
||||
const [positionX, setPositionX] = useState(0);
|
||||
const [positionY, setPositionY] = useState(0);
|
||||
const [width, setWidth] = useState(0);
|
||||
const [height, setHeight] = useState(0);
|
||||
const [boxWidth, setBoxWidth] = useState(0);
|
||||
const [boxHeight, setBoxHeight] = useState(0);
|
||||
|
||||
const mouseDownEvent = useRef<MouseEvent>();
|
||||
const resetPositionRef = useRef(resetPosition);
|
||||
|
||||
const container = React.createRef<HTMLDivElement>();
|
||||
const startPoints = useRef<number[]>([0, 0]);
|
||||
const pointerCache = useRef<React.PointerEvent<HTMLDivElement>[]>([]);
|
||||
const prevDiff = useRef<number | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
const box = container.current;
|
||||
if (box) {
|
||||
setBoxWidth(box.offsetWidth);
|
||||
setBoxHeight(box.offsetHeight);
|
||||
}
|
||||
}, [container]);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
const img = new Image();
|
||||
function onLoad() {
|
||||
if (mounted) {
|
||||
setWidth(img.width);
|
||||
setHeight(img.height);
|
||||
}
|
||||
}
|
||||
|
||||
img.onload = onLoad;
|
||||
img.src = src;
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [src]);
|
||||
|
||||
useEffect(() => {
|
||||
// don't set anything until we have the heights
|
||||
if (!width || !height || !boxWidth || !boxHeight) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!scaleUp && width < boxWidth && height < boxHeight) {
|
||||
setDefaultZoom(1);
|
||||
setPositionX(0);
|
||||
setPositionY(0);
|
||||
return;
|
||||
}
|
||||
|
||||
// set initial zoom level based on options
|
||||
let xZoom: number;
|
||||
let yZoom: number;
|
||||
let newZoom = 1;
|
||||
let newPositionY = 0;
|
||||
switch (displayMode) {
|
||||
case DisplayMode.FIT_XY:
|
||||
xZoom = boxWidth / width;
|
||||
yZoom = boxHeight / height;
|
||||
|
||||
if (!scaleUp) {
|
||||
xZoom = Math.min(xZoom, 1);
|
||||
yZoom = Math.min(yZoom, 1);
|
||||
}
|
||||
newZoom = Math.min(xZoom, yZoom);
|
||||
break;
|
||||
case DisplayMode.FIT_X:
|
||||
newZoom = boxWidth / width;
|
||||
|
||||
if (!scaleUp) {
|
||||
newZoom = Math.min(newZoom, 1);
|
||||
}
|
||||
break;
|
||||
case DisplayMode.ORIGINAL:
|
||||
newZoom = 1;
|
||||
break;
|
||||
}
|
||||
|
||||
// Center image from container's center
|
||||
const newPositionX = Math.min((boxWidth - width) / 2, 0);
|
||||
|
||||
// if fitting to screen, then centre, other
|
||||
if (displayMode === DisplayMode.FIT_XY) {
|
||||
newPositionY = Math.min((boxHeight - height) / 2, 0);
|
||||
} else {
|
||||
// otherwise, align top of image with container
|
||||
newPositionY = Math.min((height * newZoom - height) / 2, 0);
|
||||
}
|
||||
|
||||
setDefaultZoom(newZoom);
|
||||
setPositionX(newPositionX);
|
||||
setPositionY(newPositionY);
|
||||
}, [width, height, boxWidth, boxHeight, displayMode, scaleUp]);
|
||||
|
||||
const calculateTopPosition = useCallback(() => {
|
||||
// Center image from container's center
|
||||
const newPositionX = Math.min((boxWidth - width) / 2, 0);
|
||||
let newPositionY: number;
|
||||
|
||||
if (zoom * defaultZoom * height > boxHeight) {
|
||||
newPositionY = (height * zoom * defaultZoom - height) / 2;
|
||||
} else {
|
||||
newPositionY = Math.min((boxHeight - height) / 2, 0);
|
||||
}
|
||||
|
||||
return [newPositionX, newPositionY];
|
||||
}, [boxWidth, width, boxHeight, height, zoom, defaultZoom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (resetPosition !== resetPositionRef.current) {
|
||||
resetPositionRef.current = resetPosition;
|
||||
|
||||
const [x, y] = calculateTopPosition();
|
||||
setPositionX(x);
|
||||
setPositionY(y);
|
||||
}
|
||||
}, [resetPosition, resetPositionRef, calculateTopPosition]);
|
||||
|
||||
function getScrollMode(ev: React.WheelEvent<HTMLDivElement>) {
|
||||
if (ev.shiftKey) {
|
||||
switch (scrollMode) {
|
||||
case ScrollMode.ZOOM:
|
||||
return ScrollMode.PAN_Y;
|
||||
case ScrollMode.PAN_Y:
|
||||
return ScrollMode.ZOOM;
|
||||
}
|
||||
}
|
||||
|
||||
return scrollMode;
|
||||
}
|
||||
|
||||
function onContainerScroll(ev: React.WheelEvent<HTMLDivElement>) {
|
||||
// don't zoom if mouse isn't over image
|
||||
if (getScrollMode(ev) === ScrollMode.PAN_Y) {
|
||||
onImageScroll(ev);
|
||||
}
|
||||
}
|
||||
|
||||
function onImageScroll(ev: React.WheelEvent<HTMLDivElement>) {
|
||||
const percent = ev.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP;
|
||||
const minY = (defaultZoom * height - height) / 2 - defaultZoom * height + 1;
|
||||
const maxY = (defaultZoom * height - height) / 2 + boxHeight - 1;
|
||||
let newPositionY =
|
||||
positionY + (ev.deltaY < 0 ? SCROLL_PAN_STEP : -SCROLL_PAN_STEP);
|
||||
|
||||
switch (getScrollMode(ev)) {
|
||||
case ScrollMode.ZOOM:
|
||||
setZoom(zoom * percent);
|
||||
break;
|
||||
case ScrollMode.PAN_Y:
|
||||
// ensure image doesn't go offscreen
|
||||
newPositionY = Math.max(newPositionY, minY);
|
||||
newPositionY = Math.min(newPositionY, maxY);
|
||||
|
||||
setPositionY(newPositionY);
|
||||
ev.stopPropagation();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function onImageMouseOver(ev: React.MouseEvent<HTMLDivElement, MouseEvent>) {
|
||||
if (!moving) return;
|
||||
|
||||
if (!ev.buttons) {
|
||||
setMoving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const posX = ev.pageX - startPoints.current[0];
|
||||
const posY = ev.pageY - startPoints.current[1];
|
||||
startPoints.current = [ev.pageX, ev.pageY];
|
||||
|
||||
setPositionX(positionX + posX);
|
||||
setPositionY(positionY + posY);
|
||||
}
|
||||
|
||||
function onImageMouseDown(ev: React.MouseEvent<HTMLDivElement, MouseEvent>) {
|
||||
startPoints.current = [ev.pageX, ev.pageY];
|
||||
setMoving(true);
|
||||
|
||||
mouseDownEvent.current = ev.nativeEvent;
|
||||
}
|
||||
|
||||
function onImageMouseUp(ev: React.MouseEvent<HTMLDivElement, MouseEvent>) {
|
||||
if (
|
||||
!mouseDownEvent.current ||
|
||||
ev.timeStamp - mouseDownEvent.current.timeStamp > 200
|
||||
) {
|
||||
// not a click - ignore
|
||||
return;
|
||||
}
|
||||
|
||||
// must be a click
|
||||
if (
|
||||
ev.pageX !== startPoints.current[0] ||
|
||||
ev.pageY !== startPoints.current[1]
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.nativeEvent.offsetX >= (ev.target as HTMLElement).offsetWidth / 2) {
|
||||
onRight();
|
||||
} else {
|
||||
onLeft();
|
||||
}
|
||||
}
|
||||
|
||||
function onTouchStart(ev: React.TouchEvent<HTMLDivElement>) {
|
||||
ev.preventDefault();
|
||||
if (ev.touches.length === 1) {
|
||||
startPoints.current = [ev.touches[0].pageX, ev.touches[0].pageY];
|
||||
setMoving(true);
|
||||
}
|
||||
}
|
||||
|
||||
function onTouchMove(ev: React.TouchEvent<HTMLDivElement>) {
|
||||
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];
|
||||
startPoints.current = [ev.touches[0].pageX, ev.touches[0].pageY];
|
||||
|
||||
setPositionX(positionX + posX);
|
||||
setPositionY(positionY + posY);
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerDown(ev: React.PointerEvent<HTMLDivElement>) {
|
||||
// replace pointer event with the same id, if applicable
|
||||
pointerCache.current = pointerCache.current.filter(
|
||||
(e) => e.pointerId !== ev.pointerId
|
||||
);
|
||||
|
||||
pointerCache.current.push(ev);
|
||||
prevDiff.current = undefined;
|
||||
}
|
||||
|
||||
function onPointerUp(ev: React.PointerEvent<HTMLDivElement>) {
|
||||
for (let i = 0; i < pointerCache.current.length; i++) {
|
||||
if (pointerCache.current[i].pointerId === ev.pointerId) {
|
||||
pointerCache.current.splice(i, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerMove(ev: React.PointerEvent<HTMLDivElement>) {
|
||||
// find the event in the cache
|
||||
const cachedIndex = pointerCache.current.findIndex(
|
||||
(c) => c.pointerId === ev.pointerId
|
||||
);
|
||||
if (cachedIndex !== -1) {
|
||||
pointerCache.current[cachedIndex] = ev;
|
||||
}
|
||||
|
||||
// compare the difference between the two pointers
|
||||
if (pointerCache.current.length === 2) {
|
||||
const ev1 = pointerCache.current[0];
|
||||
const ev2 = pointerCache.current[1];
|
||||
const diffX = Math.abs(ev1.clientX - ev2.clientX);
|
||||
const diffY = Math.abs(ev1.clientY - ev2.clientY);
|
||||
const diff = Math.sqrt(diffX ** 2 + diffY ** 2);
|
||||
|
||||
if (prevDiff.current !== undefined) {
|
||||
const diffDiff = diff - prevDiff.current;
|
||||
const factor = (Math.abs(diffDiff) / 20) * 0.1 + 1;
|
||||
|
||||
if (diffDiff > 0) {
|
||||
setZoom(zoom * factor);
|
||||
} else if (diffDiff < 0) {
|
||||
setZoom((zoom * 1) / factor);
|
||||
}
|
||||
}
|
||||
|
||||
prevDiff.current = diff;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={container}
|
||||
className={`${CLASSNAME_IMAGE}`}
|
||||
onWheel={(e) => onContainerScroll(e)}
|
||||
>
|
||||
{defaultZoom ? (
|
||||
<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 */}
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
draggable={false}
|
||||
style={{ touchAction: "none" }}
|
||||
onWheel={(e) => onImageScroll(e)}
|
||||
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)}
|
||||
/>
|
||||
</picture>
|
||||
) : undefined}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user