mirror of
https://github.com/stashapp/stash.git
synced 2025-12-18 04:44: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:
@@ -1,6 +1,7 @@
|
|||||||
#### 💥 Note: Please check your logs after migrating to this release. A log warning will be generated on startup if duplicate image checksums exist in your system. Search for the images using the logged checksums, and remove the unwanted ones.
|
#### 💥 Note: Please check your logs after migrating to this release. A log warning will be generated on startup if duplicate image checksums exist in your system. Search for the images using the logged checksums, and remove the unwanted ones.
|
||||||
|
|
||||||
### ✨ New Features
|
### ✨ New Features
|
||||||
|
* Revamped image lightbox to support zoom, pan and various display modes. ([#1708](https://github.com/stashapp/stash/pull/1708))
|
||||||
* Support subpaths when serving stash via reverse proxy. ([#1719](https://github.com/stashapp/stash/pull/1719))
|
* Support subpaths when serving stash via reverse proxy. ([#1719](https://github.com/stashapp/stash/pull/1719))
|
||||||
* Added options to generate webp and static preview files for markers. ([#1604](https://github.com/stashapp/stash/pull/1604))
|
* Added options to generate webp and static preview files for markers. ([#1604](https://github.com/stashapp/stash/pull/1604))
|
||||||
* Added sort by option for gallery rating. ([#1720](https://github.com/stashapp/stash/pull/1720))
|
* Added sort by option for gallery rating. ([#1720](https://github.com/stashapp/stash/pull/1720))
|
||||||
|
|||||||
@@ -42,23 +42,21 @@ const ImageWall: React.FC<IImageWallProps> = ({
|
|||||||
const handleLightBoxPage = useCallback(
|
const handleLightBoxPage = useCallback(
|
||||||
(direction: number) => {
|
(direction: number) => {
|
||||||
if (direction === -1) {
|
if (direction === -1) {
|
||||||
if (currentPage === 1) return false;
|
if (currentPage === 1) {
|
||||||
onChangePage(currentPage - 1);
|
onChangePage(pageCount);
|
||||||
} else {
|
} else {
|
||||||
if (currentPage === pageCount) {
|
onChangePage(currentPage - 1);
|
||||||
// if the slideshow is running
|
}
|
||||||
// return to the first page
|
} else if (direction === 1) {
|
||||||
if (slideshowRunning) {
|
if (currentPage === pageCount) {
|
||||||
onChangePage(0);
|
// return to the first page
|
||||||
return true;
|
onChangePage(1);
|
||||||
}
|
} else {
|
||||||
return false;
|
onChangePage(currentPage + 1);
|
||||||
}
|
}
|
||||||
onChangePage(currentPage + 1);
|
|
||||||
}
|
}
|
||||||
return direction === -1 || direction === 1;
|
|
||||||
},
|
},
|
||||||
[onChangePage, currentPage, pageCount, slideshowRunning]
|
[onChangePage, currentPage, pageCount]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
@@ -68,7 +66,7 @@ const ImageWall: React.FC<IImageWallProps> = ({
|
|||||||
const showLightbox = useLightbox({
|
const showLightbox = useLightbox({
|
||||||
images,
|
images,
|
||||||
showNavigation: false,
|
showNavigation: false,
|
||||||
pageCallback: handleLightBoxPage,
|
pageCallback: pageCount > 1 ? handleLightBoxPage : undefined,
|
||||||
pageHeader: `Page ${currentPage} / ${pageCount}`,
|
pageHeader: `Page ${currentPage} / ${pageCount}`,
|
||||||
slideshowEnabled: slideshowRunning,
|
slideshowEnabled: slideshowRunning,
|
||||||
onClose: handleClose,
|
onClose: handleClose,
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ import * as GQL from "src/core/generated-graphql";
|
|||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Col,
|
Col,
|
||||||
FormControl,
|
|
||||||
InputGroup,
|
InputGroup,
|
||||||
FormLabel,
|
Overlay,
|
||||||
OverlayTrigger,
|
|
||||||
Popover,
|
Popover,
|
||||||
|
Form,
|
||||||
|
Row,
|
||||||
} from "react-bootstrap";
|
} from "react-bootstrap";
|
||||||
import cx from "classnames";
|
import cx from "classnames";
|
||||||
import Mousetrap from "mousetrap";
|
import Mousetrap from "mousetrap";
|
||||||
@@ -16,14 +16,16 @@ import debounce from "lodash/debounce";
|
|||||||
import { Icon, LoadingIndicator } from "src/components/Shared";
|
import { Icon, LoadingIndicator } from "src/components/Shared";
|
||||||
import { useInterval, usePageVisibility } from "src/hooks";
|
import { useInterval, usePageVisibility } from "src/hooks";
|
||||||
import { useConfiguration } from "src/core/StashService";
|
import { useConfiguration } from "src/core/StashService";
|
||||||
|
import { FormattedMessage, useIntl } from "react-intl";
|
||||||
|
import { DisplayMode, LightboxImage, ScrollMode } from "./LightboxImage";
|
||||||
|
|
||||||
const CLASSNAME = "Lightbox";
|
const CLASSNAME = "Lightbox";
|
||||||
const CLASSNAME_HEADER = `${CLASSNAME}-header`;
|
const CLASSNAME_HEADER = `${CLASSNAME}-header`;
|
||||||
const CLASSNAME_LEFT_SPACER = `${CLASSNAME_HEADER}-left-spacer`;
|
const CLASSNAME_LEFT_SPACER = `${CLASSNAME_HEADER}-left-spacer`;
|
||||||
const CLASSNAME_INDICATOR = `${CLASSNAME_HEADER}-indicator`;
|
const CLASSNAME_INDICATOR = `${CLASSNAME_HEADER}-indicator`;
|
||||||
const CLASSNAME_DELAY = `${CLASSNAME_HEADER}-delay`;
|
const CLASSNAME_OPTIONS = `${CLASSNAME_HEADER}-options`;
|
||||||
const CLASSNAME_DELAY_ICON = `${CLASSNAME_DELAY}-icon`;
|
const CLASSNAME_OPTIONS_ICON = `${CLASSNAME_OPTIONS}-icon`;
|
||||||
const CLASSNAME_DELAY_INLINE = `${CLASSNAME_DELAY}-inline`;
|
const CLASSNAME_OPTIONS_INLINE = `${CLASSNAME_OPTIONS}-inline`;
|
||||||
const CLASSNAME_RIGHT = `${CLASSNAME_HEADER}-right`;
|
const CLASSNAME_RIGHT = `${CLASSNAME_HEADER}-right`;
|
||||||
const CLASSNAME_DISPLAY = `${CLASSNAME}-display`;
|
const CLASSNAME_DISPLAY = `${CLASSNAME}-display`;
|
||||||
const CLASSNAME_CAROUSEL = `${CLASSNAME}-carousel`;
|
const CLASSNAME_CAROUSEL = `${CLASSNAME}-carousel`;
|
||||||
@@ -31,7 +33,6 @@ const CLASSNAME_INSTANT = `${CLASSNAME_CAROUSEL}-instant`;
|
|||||||
const CLASSNAME_IMAGE = `${CLASSNAME_CAROUSEL}-image`;
|
const CLASSNAME_IMAGE = `${CLASSNAME_CAROUSEL}-image`;
|
||||||
const CLASSNAME_NAVBUTTON = `${CLASSNAME}-navbutton`;
|
const CLASSNAME_NAVBUTTON = `${CLASSNAME}-navbutton`;
|
||||||
const CLASSNAME_NAV = `${CLASSNAME}-nav`;
|
const CLASSNAME_NAV = `${CLASSNAME}-nav`;
|
||||||
const CLASSNAME_NAVZONE = `${CLASSNAME}-navzone`;
|
|
||||||
const CLASSNAME_NAVIMAGE = `${CLASSNAME_NAV}-image`;
|
const CLASSNAME_NAVIMAGE = `${CLASSNAME_NAV}-image`;
|
||||||
const CLASSNAME_NAVSELECTED = `${CLASSNAME_NAV}-selected`;
|
const CLASSNAME_NAVSELECTED = `${CLASSNAME_NAV}-selected`;
|
||||||
|
|
||||||
@@ -48,7 +49,7 @@ interface IProps {
|
|||||||
showNavigation: boolean;
|
showNavigation: boolean;
|
||||||
slideshowEnabled?: boolean;
|
slideshowEnabled?: boolean;
|
||||||
pageHeader?: string;
|
pageHeader?: string;
|
||||||
pageCallback?: (direction: number) => boolean;
|
pageCallback?: (direction: number) => void;
|
||||||
hide: () => void;
|
hide: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,16 +64,35 @@ export const LightboxComponent: React.FC<IProps> = ({
|
|||||||
pageCallback,
|
pageCallback,
|
||||||
hide,
|
hide,
|
||||||
}) => {
|
}) => {
|
||||||
const index = useRef<number | null>(null);
|
const [index, setIndex] = useState<number | null>(null);
|
||||||
|
const oldIndex = useRef<number | null>(null);
|
||||||
const [instantTransition, setInstantTransition] = useState(false);
|
const [instantTransition, setInstantTransition] = useState(false);
|
||||||
const [isSwitchingPage, setIsSwitchingPage] = useState(false);
|
const [isSwitchingPage, setIsSwitchingPage] = useState(true);
|
||||||
const [isFullscreen, setFullscreen] = useState(false);
|
const [isFullscreen, setFullscreen] = useState(false);
|
||||||
|
const [showOptions, setShowOptions] = useState(false);
|
||||||
|
|
||||||
|
const oldImages = useRef<Image[]>([]);
|
||||||
|
|
||||||
|
const [displayMode, setDisplayMode] = useState(DisplayMode.FIT_XY);
|
||||||
|
const oldDisplayMode = useRef(displayMode);
|
||||||
|
|
||||||
|
const [scaleUp, setScaleUp] = useState(false);
|
||||||
|
const [scrollMode, setScrollMode] = useState(ScrollMode.ZOOM);
|
||||||
|
const [resetZoomOnNav, setResetZoomOnNav] = useState(true);
|
||||||
|
const [zoom, setZoom] = useState(1);
|
||||||
|
const [resetPosition, setResetPosition] = useState(false);
|
||||||
|
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const overlayTarget = useRef<HTMLButtonElement | null>(null);
|
||||||
const carouselRef = useRef<HTMLDivElement | 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>();
|
||||||
const resetIntervalCallback = useRef<() => void>();
|
const resetIntervalCallback = useRef<() => void>();
|
||||||
|
|
||||||
|
const allowNavigation = images.length > 1 || pageCallback;
|
||||||
|
|
||||||
|
const intl = useIntl();
|
||||||
const config = useConfiguration();
|
const config = useConfiguration();
|
||||||
|
|
||||||
const userSelectedSlideshowDelayOrDefault =
|
const userSelectedSlideshowDelayOrDefault =
|
||||||
@@ -94,9 +114,12 @@ export const LightboxComponent: React.FC<IProps> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsSwitchingPage(false);
|
if (images !== oldImages.current && isSwitchingPage) {
|
||||||
if (index.current === -1) index.current = images.length - 1;
|
oldImages.current = images;
|
||||||
}, [images]);
|
if (index === -1) setIndex(images.length - 1);
|
||||||
|
setIsSwitchingPage(false);
|
||||||
|
}
|
||||||
|
}, [isSwitchingPage, images, index]);
|
||||||
|
|
||||||
const disableInstantTransition = debounce(
|
const disableInstantTransition = debounce(
|
||||||
() => setInstantTransition(false),
|
() => setInstantTransition(false),
|
||||||
@@ -108,35 +131,56 @@ export const LightboxComponent: React.FC<IProps> = ({
|
|||||||
disableInstantTransition();
|
disableInstantTransition();
|
||||||
}, [disableInstantTransition]);
|
}, [disableInstantTransition]);
|
||||||
|
|
||||||
const setIndex = useCallback(
|
useEffect(() => {
|
||||||
(i: number) => {
|
if (images.length < 2) return;
|
||||||
if (images.length < 2) return;
|
if (index === oldIndex.current) return;
|
||||||
|
if (index === null) return;
|
||||||
|
|
||||||
index.current = i;
|
// reset zoom status
|
||||||
if (carouselRef.current) carouselRef.current.style.left = `${i * -100}vw`;
|
// setResetZoom((r) => !r);
|
||||||
if (indicatorRef.current)
|
// setZoomed(false);
|
||||||
indicatorRef.current.innerHTML = `${i + 1} / ${images.length}`;
|
if (resetZoomOnNav) {
|
||||||
if (navRef.current) {
|
setZoom(1);
|
||||||
const currentThumb = navRef.current.children[i + 1];
|
}
|
||||||
if (currentThumb instanceof HTMLImageElement) {
|
setResetPosition((r) => !r);
|
||||||
const offset =
|
|
||||||
-1 *
|
|
||||||
(currentThumb.offsetLeft -
|
|
||||||
document.documentElement.clientWidth / 2);
|
|
||||||
navRef.current.style.left = `${offset}px`;
|
|
||||||
|
|
||||||
const previouslySelected = navRef.current.getElementsByClassName(
|
if (carouselRef.current)
|
||||||
CLASSNAME_NAVSELECTED
|
carouselRef.current.style.left = `${index * -100}vw`;
|
||||||
)?.[0];
|
if (indicatorRef.current)
|
||||||
if (previouslySelected)
|
indicatorRef.current.innerHTML = `${index + 1} / ${images.length}`;
|
||||||
previouslySelected.className = CLASSNAME_NAVIMAGE;
|
if (navRef.current) {
|
||||||
|
const currentThumb = navRef.current.children[index + 1];
|
||||||
|
if (currentThumb instanceof HTMLImageElement) {
|
||||||
|
const offset =
|
||||||
|
-1 *
|
||||||
|
(currentThumb.offsetLeft - document.documentElement.clientWidth / 2);
|
||||||
|
navRef.current.style.left = `${offset}px`;
|
||||||
|
|
||||||
currentThumb.className = `${CLASSNAME_NAVIMAGE} ${CLASSNAME_NAVSELECTED}`;
|
const previouslySelected = navRef.current.getElementsByClassName(
|
||||||
}
|
CLASSNAME_NAVSELECTED
|
||||||
|
)?.[0];
|
||||||
|
if (previouslySelected)
|
||||||
|
previouslySelected.className = CLASSNAME_NAVIMAGE;
|
||||||
|
|
||||||
|
currentThumb.className = `${CLASSNAME_NAVIMAGE} ${CLASSNAME_NAVSELECTED}`;
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
[images]
|
|
||||||
);
|
oldIndex.current = index;
|
||||||
|
}, [index, images.length, resetZoomOnNav]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (displayMode !== oldDisplayMode.current) {
|
||||||
|
// reset zoom status
|
||||||
|
// setResetZoom((r) => !r);
|
||||||
|
// setZoomed(false);
|
||||||
|
if (resetZoomOnNav) {
|
||||||
|
setZoom(1);
|
||||||
|
}
|
||||||
|
setResetPosition((r) => !r);
|
||||||
|
}
|
||||||
|
oldDisplayMode.current = displayMode;
|
||||||
|
}, [displayMode, resetZoomOnNav]);
|
||||||
|
|
||||||
const selectIndex = (e: React.MouseEvent, i: number) => {
|
const selectIndex = (e: React.MouseEvent, i: number) => {
|
||||||
setIndex(i);
|
setIndex(i);
|
||||||
@@ -145,12 +189,12 @@ export const LightboxComponent: React.FC<IProps> = ({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isVisible) {
|
if (isVisible) {
|
||||||
if (index.current === null) setIndex(initialIndex);
|
if (index === null) setIndex(initialIndex);
|
||||||
document.body.style.overflow = "hidden";
|
document.body.style.overflow = "hidden";
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
(Mousetrap as any).pause();
|
(Mousetrap as any).pause();
|
||||||
}
|
}
|
||||||
}, [initialIndex, isVisible, setIndex]);
|
}, [initialIndex, isVisible, setIndex, index]);
|
||||||
|
|
||||||
const toggleSlideshow = useCallback(() => {
|
const toggleSlideshow = useCallback(() => {
|
||||||
if (slideshowInterval) {
|
if (slideshowInterval) {
|
||||||
@@ -185,54 +229,55 @@ export const LightboxComponent: React.FC<IProps> = ({
|
|||||||
|
|
||||||
const handleClose = (e: React.MouseEvent<HTMLDivElement>) => {
|
const handleClose = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
const { className } = e.target as Element;
|
const { className } = e.target as Element;
|
||||||
if (className === CLASSNAME_IMAGE) close();
|
if (className && className.includes && className.includes(CLASSNAME_IMAGE))
|
||||||
|
close();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLeft = useCallback(
|
const handleLeft = useCallback(
|
||||||
(isUserAction = true) => {
|
(isUserAction = true) => {
|
||||||
if (isSwitchingPage || index.current === -1) return;
|
if (isSwitchingPage || index === -1) return;
|
||||||
|
|
||||||
if (index.current === 0) {
|
if (index === 0) {
|
||||||
|
// go to next page, or loop back if no callback is set
|
||||||
if (pageCallback) {
|
if (pageCallback) {
|
||||||
setIsSwitchingPage(true);
|
pageCallback(-1);
|
||||||
setIndex(-1);
|
setIndex(-1);
|
||||||
// Check if calling page wants to swap page
|
setIsSwitchingPage(true);
|
||||||
const repage = pageCallback(-1);
|
|
||||||
if (!repage) {
|
|
||||||
setIsSwitchingPage(false);
|
|
||||||
setIndex(0);
|
|
||||||
}
|
|
||||||
} else setIndex(images.length - 1);
|
} else setIndex(images.length - 1);
|
||||||
} else setIndex((index.current ?? 0) - 1);
|
} else setIndex((index ?? 0) - 1);
|
||||||
|
|
||||||
if (isUserAction && resetIntervalCallback.current) {
|
if (isUserAction && resetIntervalCallback.current) {
|
||||||
resetIntervalCallback.current();
|
resetIntervalCallback.current();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[images, setIndex, pageCallback, isSwitchingPage, resetIntervalCallback]
|
[images, pageCallback, isSwitchingPage, resetIntervalCallback, index]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRight = useCallback(
|
const handleRight = useCallback(
|
||||||
(isUserAction = true) => {
|
(isUserAction = true) => {
|
||||||
if (isSwitchingPage) return;
|
if (isSwitchingPage) return;
|
||||||
|
|
||||||
if (index.current === images.length - 1) {
|
if (index === images.length - 1) {
|
||||||
|
// go to preview page, or loop back if no callback is set
|
||||||
if (pageCallback) {
|
if (pageCallback) {
|
||||||
|
pageCallback(1);
|
||||||
setIsSwitchingPage(true);
|
setIsSwitchingPage(true);
|
||||||
setIndex(0);
|
setIndex(0);
|
||||||
const repage = pageCallback?.(1);
|
|
||||||
if (!repage) {
|
|
||||||
setIsSwitchingPage(false);
|
|
||||||
setIndex(images.length - 1);
|
|
||||||
}
|
|
||||||
} else setIndex(0);
|
} else setIndex(0);
|
||||||
} else setIndex((index.current ?? 0) + 1);
|
} else setIndex((index ?? 0) + 1);
|
||||||
|
|
||||||
if (isUserAction && resetIntervalCallback.current) {
|
if (isUserAction && resetIntervalCallback.current) {
|
||||||
resetIntervalCallback.current();
|
resetIntervalCallback.current();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[images, setIndex, pageCallback, isSwitchingPage, resetIntervalCallback]
|
[
|
||||||
|
images,
|
||||||
|
setIndex,
|
||||||
|
pageCallback,
|
||||||
|
isSwitchingPage,
|
||||||
|
resetIntervalCallback,
|
||||||
|
index,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleKey = useCallback(
|
const handleKey = useCallback(
|
||||||
@@ -252,51 +297,6 @@ export const LightboxComponent: React.FC<IProps> = ({
|
|||||||
setFullscreen(document.fullscreenElement !== null);
|
setFullscreen(document.fullscreenElement !== null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTouchStart = (ev: React.TouchEvent<HTMLDivElement>) => {
|
|
||||||
setInstantTransition(true);
|
|
||||||
|
|
||||||
const el = ev.currentTarget;
|
|
||||||
if (ev.touches.length !== 1) return;
|
|
||||||
|
|
||||||
const startX = ev.touches[0].clientX;
|
|
||||||
let position = 0;
|
|
||||||
|
|
||||||
const resetPosition = () => {
|
|
||||||
if (carouselRef.current)
|
|
||||||
carouselRef.current.style.left = `${(index.current ?? 0) * -100}vw`;
|
|
||||||
};
|
|
||||||
const handleMove = (e: TouchEvent) => {
|
|
||||||
position = e.touches[0].clientX;
|
|
||||||
if (carouselRef.current)
|
|
||||||
carouselRef.current.style.left = `calc(${
|
|
||||||
(index.current ?? 0) * -100
|
|
||||||
}vw + ${e.touches[0].clientX - startX}px)`;
|
|
||||||
};
|
|
||||||
const handleEnd = () => {
|
|
||||||
const diff = position - startX;
|
|
||||||
if (diff <= -50) handleRight();
|
|
||||||
else if (diff >= 50) handleLeft();
|
|
||||||
else resetPosition();
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
|
||||||
cleanup();
|
|
||||||
};
|
|
||||||
const handleCancel = () => {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
|
||||||
cleanup();
|
|
||||||
resetPosition();
|
|
||||||
};
|
|
||||||
const cleanup = () => {
|
|
||||||
el.removeEventListener("touchmove", handleMove);
|
|
||||||
el.removeEventListener("touchend", handleEnd);
|
|
||||||
el.removeEventListener("touchcancel", handleCancel);
|
|
||||||
setInstantTransition(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
el.addEventListener("touchmove", handleMove);
|
|
||||||
el.addEventListener("touchend", handleEnd);
|
|
||||||
el.addEventListener("touchcancel", handleCancel);
|
|
||||||
};
|
|
||||||
|
|
||||||
const [clearCallback, resetCallback] = useInterval(
|
const [clearCallback, resetCallback] = useInterval(
|
||||||
() => {
|
() => {
|
||||||
handleRight(false);
|
handleRight(false);
|
||||||
@@ -332,7 +332,7 @@ export const LightboxComponent: React.FC<IProps> = ({
|
|||||||
src={image.paths.thumbnail ?? ""}
|
src={image.paths.thumbnail ?? ""}
|
||||||
alt=""
|
alt=""
|
||||||
className={cx(CLASSNAME_NAVIMAGE, {
|
className={cx(CLASSNAME_NAVIMAGE, {
|
||||||
[CLASSNAME_NAVSELECTED]: i === index.current,
|
[CLASSNAME_NAVSELECTED]: i === index,
|
||||||
})}
|
})}
|
||||||
onClick={(e: React.MouseEvent) => selectIndex(e, i)}
|
onClick={(e: React.MouseEvent) => selectIndex(e, i)}
|
||||||
role="presentation"
|
role="presentation"
|
||||||
@@ -359,36 +359,142 @@ export const LightboxComponent: React.FC<IProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const currentIndex = index.current === null ? initialIndex : index.current;
|
const currentIndex = index === null ? initialIndex : index;
|
||||||
|
|
||||||
const DelayForm: React.FC<{}> = () => (
|
const OptionsForm: React.FC<{}> = () => (
|
||||||
<>
|
<>
|
||||||
<FormLabel column sm="5">
|
{slideshowEnabled ? (
|
||||||
Delay (Sec)
|
<Form.Group controlId="delay" as={Row} className="form-container">
|
||||||
</FormLabel>
|
<Col xs={4}>
|
||||||
<Col sm="4">
|
<Form.Label className="col-form-label">
|
||||||
<FormControl
|
<FormattedMessage id="dialogs.lightbox.delay" />
|
||||||
type="number"
|
</Form.Label>
|
||||||
className="text-input"
|
</Col>
|
||||||
min={1}
|
<Col xs={8}>
|
||||||
value={displayedSlideshowInterval ?? 0}
|
<Form.Control
|
||||||
onChange={onDelayChange}
|
type="number"
|
||||||
size="sm"
|
className="text-input"
|
||||||
id="delay-input"
|
min={1}
|
||||||
/>
|
value={displayedSlideshowInterval ?? 0}
|
||||||
</Col>
|
onChange={onDelayChange}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Form.Group>
|
||||||
|
) : undefined}
|
||||||
|
|
||||||
|
<Form.Group controlId="displayMode" as={Row}>
|
||||||
|
<Col xs={4}>
|
||||||
|
<Form.Label className="col-form-label">
|
||||||
|
<FormattedMessage id="dialogs.lightbox.display_mode.label" />
|
||||||
|
</Form.Label>
|
||||||
|
</Col>
|
||||||
|
<Col xs={8}>
|
||||||
|
<Form.Control
|
||||||
|
as="select"
|
||||||
|
onChange={(e) => setDisplayMode(e.target.value as DisplayMode)}
|
||||||
|
value={displayMode}
|
||||||
|
className="btn-secondary mx-1 mb-1"
|
||||||
|
>
|
||||||
|
<option value={DisplayMode.ORIGINAL} key={DisplayMode.ORIGINAL}>
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: "dialogs.lightbox.display_mode.original",
|
||||||
|
})}
|
||||||
|
</option>
|
||||||
|
<option value={DisplayMode.FIT_XY} key={DisplayMode.FIT_XY}>
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: "dialogs.lightbox.display_mode.fit_to_screen",
|
||||||
|
})}
|
||||||
|
</option>
|
||||||
|
<option value={DisplayMode.FIT_X} key={DisplayMode.FIT_X}>
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: "dialogs.lightbox.display_mode.fit_horizontally",
|
||||||
|
})}
|
||||||
|
</option>
|
||||||
|
</Form.Control>
|
||||||
|
</Col>
|
||||||
|
</Form.Group>
|
||||||
|
<Form.Group>
|
||||||
|
<Form.Group controlId="scaleUp" as={Row} className="mb-1">
|
||||||
|
<Col>
|
||||||
|
<Form.Check
|
||||||
|
type="checkbox"
|
||||||
|
label={intl.formatMessage({
|
||||||
|
id: "dialogs.lightbox.scale_up.label",
|
||||||
|
})}
|
||||||
|
checked={scaleUp}
|
||||||
|
disabled={displayMode === DisplayMode.ORIGINAL}
|
||||||
|
onChange={(v) => setScaleUp(v.currentTarget.checked)}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Form.Group>
|
||||||
|
<Form.Text className="text-muted">
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: "dialogs.lightbox.scale_up.description",
|
||||||
|
})}
|
||||||
|
</Form.Text>
|
||||||
|
</Form.Group>
|
||||||
|
<Form.Group>
|
||||||
|
<Form.Group controlId="resetZoomOnNav" as={Row} className="mb-1">
|
||||||
|
<Col>
|
||||||
|
<Form.Check
|
||||||
|
type="checkbox"
|
||||||
|
label={intl.formatMessage({
|
||||||
|
id: "dialogs.lightbox.reset_zoom_on_nav",
|
||||||
|
})}
|
||||||
|
checked={resetZoomOnNav}
|
||||||
|
onChange={(v) => setResetZoomOnNav(v.currentTarget.checked)}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Form.Group>
|
||||||
|
</Form.Group>
|
||||||
|
<Form.Group controlId="scrollMode">
|
||||||
|
<Form.Group as={Row} className="mb-1">
|
||||||
|
<Col xs={4}>
|
||||||
|
<Form.Label className="col-form-label">
|
||||||
|
<FormattedMessage id="dialogs.lightbox.scroll_mode.label" />
|
||||||
|
</Form.Label>
|
||||||
|
</Col>
|
||||||
|
<Col xs={8}>
|
||||||
|
<Form.Control
|
||||||
|
as="select"
|
||||||
|
onChange={(e) => setScrollMode(e.target.value as ScrollMode)}
|
||||||
|
value={scrollMode}
|
||||||
|
className="btn-secondary mx-1 mb-1"
|
||||||
|
>
|
||||||
|
<option value={ScrollMode.ZOOM} key={ScrollMode.ZOOM}>
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: "dialogs.lightbox.scroll_mode.zoom",
|
||||||
|
})}
|
||||||
|
</option>
|
||||||
|
<option value={ScrollMode.PAN_Y} key={ScrollMode.PAN_Y}>
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: "dialogs.lightbox.scroll_mode.pan_y",
|
||||||
|
})}
|
||||||
|
</option>
|
||||||
|
</Form.Control>
|
||||||
|
</Col>
|
||||||
|
</Form.Group>
|
||||||
|
<Form.Text className="text-muted">
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: "dialogs.lightbox.scroll_mode.description",
|
||||||
|
})}
|
||||||
|
</Form.Text>
|
||||||
|
</Form.Group>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
const delayPopover = (
|
const optionsPopover = (
|
||||||
<Popover id="basic-bitch">
|
<>
|
||||||
<Popover.Title>Set slideshow delay</Popover.Title>
|
<Popover.Title>
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: "dialogs.lightbox.options",
|
||||||
|
})}
|
||||||
|
</Popover.Title>
|
||||||
<Popover.Content>
|
<Popover.Content>
|
||||||
<InputGroup>
|
<OptionsForm />
|
||||||
<DelayForm />
|
|
||||||
</InputGroup>
|
|
||||||
</Popover.Content>
|
</Popover.Content>
|
||||||
</Popover>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
const element = isVisible ? (
|
const element = isVisible ? (
|
||||||
@@ -396,7 +502,7 @@ export const LightboxComponent: React.FC<IProps> = ({
|
|||||||
className={CLASSNAME}
|
className={CLASSNAME}
|
||||||
role="presentation"
|
role="presentation"
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
onMouseDown={handleClose}
|
onClick={handleClose}
|
||||||
>
|
>
|
||||||
{images.length > 0 && !isLoading && !isSwitchingPage ? (
|
{images.length > 0 && !isLoading && !isSwitchingPage ? (
|
||||||
<>
|
<>
|
||||||
@@ -409,34 +515,59 @@ export const LightboxComponent: React.FC<IProps> = ({
|
|||||||
</b>
|
</b>
|
||||||
</div>
|
</div>
|
||||||
<div className={CLASSNAME_RIGHT}>
|
<div className={CLASSNAME_RIGHT}>
|
||||||
{slideshowEnabled && (
|
<div className={CLASSNAME_OPTIONS}>
|
||||||
<>
|
<div className={CLASSNAME_OPTIONS_ICON}>
|
||||||
<div className={CLASSNAME_DELAY}>
|
|
||||||
<div className={CLASSNAME_DELAY_ICON}>
|
|
||||||
<OverlayTrigger
|
|
||||||
trigger="click"
|
|
||||||
placement="bottom"
|
|
||||||
overlay={delayPopover}
|
|
||||||
>
|
|
||||||
<Button variant="link" title="Slideshow delay settings">
|
|
||||||
<Icon icon="cog" />
|
|
||||||
</Button>
|
|
||||||
</OverlayTrigger>
|
|
||||||
</div>
|
|
||||||
<InputGroup className={CLASSNAME_DELAY_INLINE}>
|
|
||||||
<DelayForm />
|
|
||||||
</InputGroup>
|
|
||||||
</div>
|
|
||||||
<Button
|
<Button
|
||||||
|
ref={overlayTarget}
|
||||||
variant="link"
|
variant="link"
|
||||||
onClick={toggleSlideshow}
|
title="Options"
|
||||||
title="Toggle Slideshow"
|
onClick={() => setShowOptions(!showOptions)}
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon icon="cog" />
|
||||||
icon={slideshowInterval !== null ? "pause" : "play"}
|
|
||||||
/>
|
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
<Overlay
|
||||||
|
target={overlayTarget.current}
|
||||||
|
show={showOptions}
|
||||||
|
placement="bottom"
|
||||||
|
container={containerRef}
|
||||||
|
rootClose
|
||||||
|
onHide={() => setShowOptions(false)}
|
||||||
|
>
|
||||||
|
{({ placement, arrowProps, show: _show, ...props }) => (
|
||||||
|
<div
|
||||||
|
className="popover"
|
||||||
|
{...props}
|
||||||
|
style={{ ...props.style }}
|
||||||
|
>
|
||||||
|
{optionsPopover}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Overlay>
|
||||||
|
</div>
|
||||||
|
<InputGroup className={CLASSNAME_OPTIONS_INLINE}>
|
||||||
|
<OptionsForm />
|
||||||
|
</InputGroup>
|
||||||
|
</div>
|
||||||
|
{slideshowEnabled && (
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
onClick={toggleSlideshow}
|
||||||
|
title="Toggle Slideshow"
|
||||||
|
>
|
||||||
|
<Icon icon={slideshowInterval !== null ? "pause" : "play"} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{zoom !== 1 && (
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
onClick={() => {
|
||||||
|
setResetPosition(!resetPosition);
|
||||||
|
setZoom(1);
|
||||||
|
}}
|
||||||
|
title="Reset zoom"
|
||||||
|
>
|
||||||
|
<Icon icon="search-minus" />
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
{document.fullscreenEnabled && (
|
{document.fullscreenEnabled && (
|
||||||
<Button
|
<Button
|
||||||
@@ -456,8 +587,8 @@ export const LightboxComponent: React.FC<IProps> = ({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={CLASSNAME_DISPLAY} onTouchStart={handleTouchStart}>
|
<div className={CLASSNAME_DISPLAY}>
|
||||||
{images.length > 1 && (
|
{allowNavigation && (
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
onClick={handleLeft}
|
onClick={handleLeft}
|
||||||
@@ -474,32 +605,26 @@ export const LightboxComponent: React.FC<IProps> = ({
|
|||||||
style={{ left: `${currentIndex * -100}vw` }}
|
style={{ left: `${currentIndex * -100}vw` }}
|
||||||
ref={carouselRef}
|
ref={carouselRef}
|
||||||
>
|
>
|
||||||
{images.map((image) => (
|
{images.map((image, i) => (
|
||||||
<div className={CLASSNAME_IMAGE} key={image.paths.image}>
|
<div className={`${CLASSNAME_IMAGE}`} key={image.paths.image}>
|
||||||
<picture>
|
{i >= currentIndex - 1 && i <= currentIndex + 1 ? (
|
||||||
<source
|
<LightboxImage
|
||||||
srcSet={image.paths.image ?? ""}
|
src={image.paths.image ?? ""}
|
||||||
media="(min-width: 800px)"
|
displayMode={displayMode}
|
||||||
|
scaleUp={scaleUp}
|
||||||
|
scrollMode={scrollMode}
|
||||||
|
onLeft={handleLeft}
|
||||||
|
onRight={handleRight}
|
||||||
|
zoom={i === currentIndex ? zoom : 1}
|
||||||
|
setZoom={(v) => setZoom(v)}
|
||||||
|
resetPosition={resetPosition}
|
||||||
/>
|
/>
|
||||||
<img src={image.paths.thumbnail ?? ""} alt="" />
|
) : undefined}
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
aria-hidden
|
|
||||||
className={CLASSNAME_NAVZONE}
|
|
||||||
onClick={handleLeft}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
aria-hidden
|
|
||||||
className={CLASSNAME_NAVZONE}
|
|
||||||
onClick={handleRight}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</picture>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{images.length > 1 && (
|
{allowNavigation && (
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
onClick={handleRight}
|
onClick={handleRight}
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -10,7 +10,7 @@ export interface IState {
|
|||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
showNavigation: boolean;
|
showNavigation: boolean;
|
||||||
initialIndex?: number;
|
initialIndex?: number;
|
||||||
pageCallback?: (direction: number) => boolean;
|
pageCallback?: (direction: number) => void;
|
||||||
pageHeader?: string;
|
pageHeader?: string;
|
||||||
slideshowEnabled: boolean;
|
slideshowEnabled: boolean;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
|
|||||||
@@ -30,6 +30,10 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
|
@media (max-width: 575px) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&-indicator {
|
&-indicator {
|
||||||
@@ -40,7 +44,7 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
&-delay {
|
&-options {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
margin-left: 100px;
|
margin-left: 100px;
|
||||||
@@ -53,16 +57,6 @@
|
|||||||
&-inline {
|
&-inline {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (min-width: 1300px) {
|
|
||||||
&-icon {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-inline {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&-right {
|
&-right {
|
||||||
@@ -103,7 +97,6 @@
|
|||||||
|
|
||||||
picture {
|
picture {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 100%;
|
|
||||||
margin: auto;
|
margin: auto;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
@@ -116,9 +109,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
img {
|
img {
|
||||||
margin: auto;
|
cursor: pointer;
|
||||||
max-height: 100%;
|
|
||||||
max-width: 100%;
|
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -444,6 +444,27 @@
|
|||||||
"edit_entity_title": "Edit {count, plural, one {{singularEntity}} other {{pluralEntity}}}",
|
"edit_entity_title": "Edit {count, plural, one {{singularEntity}} other {{pluralEntity}}}",
|
||||||
"export_include_related_objects": "Include related objects in export",
|
"export_include_related_objects": "Include related objects in export",
|
||||||
"export_title": "Export",
|
"export_title": "Export",
|
||||||
|
"lightbox": {
|
||||||
|
"delay": "Delay (Sec)",
|
||||||
|
"display_mode": {
|
||||||
|
"label": "Display Mode",
|
||||||
|
"original": "Original",
|
||||||
|
"fit_to_screen": "Fit to screen",
|
||||||
|
"fit_horizontally": "Fit horizontally"
|
||||||
|
},
|
||||||
|
"options": "Options",
|
||||||
|
"reset_zoom_on_nav": "Reset zoom level when changing image",
|
||||||
|
"scale_up": {
|
||||||
|
"label": "Scale up to fit",
|
||||||
|
"description": "Scale smaller images up to fill screen"
|
||||||
|
},
|
||||||
|
"scroll_mode": {
|
||||||
|
"label": "Scroll Mode",
|
||||||
|
"zoom": "Zoom",
|
||||||
|
"pan_y": "Pan Y",
|
||||||
|
"description": "Hold shift to temporarily use other mode."
|
||||||
|
}
|
||||||
|
},
|
||||||
"merge_tags": {
|
"merge_tags": {
|
||||||
"destination": "Destination",
|
"destination": "Destination",
|
||||||
"source": "Source"
|
"source": "Source"
|
||||||
|
|||||||
@@ -168,6 +168,8 @@ hr {
|
|||||||
|
|
||||||
.popover {
|
.popover {
|
||||||
&-body {
|
&-body {
|
||||||
|
color: $text-color;
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
color: $text-color;
|
color: $text-color;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user