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.
|
||||
|
||||
### ✨ 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))
|
||||
* 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))
|
||||
|
||||
@@ -42,23 +42,21 @@ const ImageWall: React.FC<IImageWallProps> = ({
|
||||
const handleLightBoxPage = useCallback(
|
||||
(direction: number) => {
|
||||
if (direction === -1) {
|
||||
if (currentPage === 1) return false;
|
||||
onChangePage(currentPage - 1);
|
||||
if (currentPage === 1) {
|
||||
onChangePage(pageCount);
|
||||
} else {
|
||||
onChangePage(currentPage - 1);
|
||||
}
|
||||
} else if (direction === 1) {
|
||||
if (currentPage === pageCount) {
|
||||
// if the slideshow is running
|
||||
// return to the first page
|
||||
if (slideshowRunning) {
|
||||
onChangePage(0);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
onChangePage(1);
|
||||
} else {
|
||||
onChangePage(currentPage + 1);
|
||||
}
|
||||
return direction === -1 || direction === 1;
|
||||
}
|
||||
},
|
||||
[onChangePage, currentPage, pageCount, slideshowRunning]
|
||||
[onChangePage, currentPage, pageCount]
|
||||
);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
@@ -68,7 +66,7 @@ const ImageWall: React.FC<IImageWallProps> = ({
|
||||
const showLightbox = useLightbox({
|
||||
images,
|
||||
showNavigation: false,
|
||||
pageCallback: handleLightBoxPage,
|
||||
pageCallback: pageCount > 1 ? handleLightBoxPage : undefined,
|
||||
pageHeader: `Page ${currentPage} / ${pageCount}`,
|
||||
slideshowEnabled: slideshowRunning,
|
||||
onClose: handleClose,
|
||||
|
||||
@@ -3,11 +3,11 @@ import * as GQL from "src/core/generated-graphql";
|
||||
import {
|
||||
Button,
|
||||
Col,
|
||||
FormControl,
|
||||
InputGroup,
|
||||
FormLabel,
|
||||
OverlayTrigger,
|
||||
Overlay,
|
||||
Popover,
|
||||
Form,
|
||||
Row,
|
||||
} from "react-bootstrap";
|
||||
import cx from "classnames";
|
||||
import Mousetrap from "mousetrap";
|
||||
@@ -16,14 +16,16 @@ import debounce from "lodash/debounce";
|
||||
import { Icon, LoadingIndicator } from "src/components/Shared";
|
||||
import { useInterval, usePageVisibility } from "src/hooks";
|
||||
import { useConfiguration } from "src/core/StashService";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { DisplayMode, LightboxImage, ScrollMode } from "./LightboxImage";
|
||||
|
||||
const CLASSNAME = "Lightbox";
|
||||
const CLASSNAME_HEADER = `${CLASSNAME}-header`;
|
||||
const CLASSNAME_LEFT_SPACER = `${CLASSNAME_HEADER}-left-spacer`;
|
||||
const CLASSNAME_INDICATOR = `${CLASSNAME_HEADER}-indicator`;
|
||||
const CLASSNAME_DELAY = `${CLASSNAME_HEADER}-delay`;
|
||||
const CLASSNAME_DELAY_ICON = `${CLASSNAME_DELAY}-icon`;
|
||||
const CLASSNAME_DELAY_INLINE = `${CLASSNAME_DELAY}-inline`;
|
||||
const CLASSNAME_OPTIONS = `${CLASSNAME_HEADER}-options`;
|
||||
const CLASSNAME_OPTIONS_ICON = `${CLASSNAME_OPTIONS}-icon`;
|
||||
const CLASSNAME_OPTIONS_INLINE = `${CLASSNAME_OPTIONS}-inline`;
|
||||
const CLASSNAME_RIGHT = `${CLASSNAME_HEADER}-right`;
|
||||
const CLASSNAME_DISPLAY = `${CLASSNAME}-display`;
|
||||
const CLASSNAME_CAROUSEL = `${CLASSNAME}-carousel`;
|
||||
@@ -31,7 +33,6 @@ const CLASSNAME_INSTANT = `${CLASSNAME_CAROUSEL}-instant`;
|
||||
const CLASSNAME_IMAGE = `${CLASSNAME_CAROUSEL}-image`;
|
||||
const CLASSNAME_NAVBUTTON = `${CLASSNAME}-navbutton`;
|
||||
const CLASSNAME_NAV = `${CLASSNAME}-nav`;
|
||||
const CLASSNAME_NAVZONE = `${CLASSNAME}-navzone`;
|
||||
const CLASSNAME_NAVIMAGE = `${CLASSNAME_NAV}-image`;
|
||||
const CLASSNAME_NAVSELECTED = `${CLASSNAME_NAV}-selected`;
|
||||
|
||||
@@ -48,7 +49,7 @@ interface IProps {
|
||||
showNavigation: boolean;
|
||||
slideshowEnabled?: boolean;
|
||||
pageHeader?: string;
|
||||
pageCallback?: (direction: number) => boolean;
|
||||
pageCallback?: (direction: number) => void;
|
||||
hide: () => void;
|
||||
}
|
||||
|
||||
@@ -63,16 +64,35 @@ export const LightboxComponent: React.FC<IProps> = ({
|
||||
pageCallback,
|
||||
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 [isSwitchingPage, setIsSwitchingPage] = useState(false);
|
||||
const [isSwitchingPage, setIsSwitchingPage] = useState(true);
|
||||
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 overlayTarget = useRef<HTMLButtonElement | null>(null);
|
||||
const carouselRef = useRef<HTMLDivElement | null>(null);
|
||||
const indicatorRef = useRef<HTMLDivElement | null>(null);
|
||||
const navRef = useRef<HTMLDivElement | null>(null);
|
||||
const clearIntervalCallback = useRef<() => void>();
|
||||
const resetIntervalCallback = useRef<() => void>();
|
||||
|
||||
const allowNavigation = images.length > 1 || pageCallback;
|
||||
|
||||
const intl = useIntl();
|
||||
const config = useConfiguration();
|
||||
|
||||
const userSelectedSlideshowDelayOrDefault =
|
||||
@@ -94,9 +114,12 @@ export const LightboxComponent: React.FC<IProps> = ({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (images !== oldImages.current && isSwitchingPage) {
|
||||
oldImages.current = images;
|
||||
if (index === -1) setIndex(images.length - 1);
|
||||
setIsSwitchingPage(false);
|
||||
if (index.current === -1) index.current = images.length - 1;
|
||||
}, [images]);
|
||||
}
|
||||
}, [isSwitchingPage, images, index]);
|
||||
|
||||
const disableInstantTransition = debounce(
|
||||
() => setInstantTransition(false),
|
||||
@@ -108,21 +131,29 @@ export const LightboxComponent: React.FC<IProps> = ({
|
||||
disableInstantTransition();
|
||||
}, [disableInstantTransition]);
|
||||
|
||||
const setIndex = useCallback(
|
||||
(i: number) => {
|
||||
useEffect(() => {
|
||||
if (images.length < 2) return;
|
||||
if (index === oldIndex.current) return;
|
||||
if (index === null) return;
|
||||
|
||||
index.current = i;
|
||||
if (carouselRef.current) carouselRef.current.style.left = `${i * -100}vw`;
|
||||
// reset zoom status
|
||||
// setResetZoom((r) => !r);
|
||||
// setZoomed(false);
|
||||
if (resetZoomOnNav) {
|
||||
setZoom(1);
|
||||
}
|
||||
setResetPosition((r) => !r);
|
||||
|
||||
if (carouselRef.current)
|
||||
carouselRef.current.style.left = `${index * -100}vw`;
|
||||
if (indicatorRef.current)
|
||||
indicatorRef.current.innerHTML = `${i + 1} / ${images.length}`;
|
||||
indicatorRef.current.innerHTML = `${index + 1} / ${images.length}`;
|
||||
if (navRef.current) {
|
||||
const currentThumb = navRef.current.children[i + 1];
|
||||
const currentThumb = navRef.current.children[index + 1];
|
||||
if (currentThumb instanceof HTMLImageElement) {
|
||||
const offset =
|
||||
-1 *
|
||||
(currentThumb.offsetLeft -
|
||||
document.documentElement.clientWidth / 2);
|
||||
(currentThumb.offsetLeft - document.documentElement.clientWidth / 2);
|
||||
navRef.current.style.left = `${offset}px`;
|
||||
|
||||
const previouslySelected = navRef.current.getElementsByClassName(
|
||||
@@ -134,9 +165,22 @@ export const LightboxComponent: React.FC<IProps> = ({
|
||||
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) => {
|
||||
setIndex(i);
|
||||
@@ -145,12 +189,12 @@ export const LightboxComponent: React.FC<IProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible) {
|
||||
if (index.current === null) setIndex(initialIndex);
|
||||
if (index === null) setIndex(initialIndex);
|
||||
document.body.style.overflow = "hidden";
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(Mousetrap as any).pause();
|
||||
}
|
||||
}, [initialIndex, isVisible, setIndex]);
|
||||
}, [initialIndex, isVisible, setIndex, index]);
|
||||
|
||||
const toggleSlideshow = useCallback(() => {
|
||||
if (slideshowInterval) {
|
||||
@@ -185,54 +229,55 @@ export const LightboxComponent: React.FC<IProps> = ({
|
||||
|
||||
const handleClose = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const { className } = e.target as Element;
|
||||
if (className === CLASSNAME_IMAGE) close();
|
||||
if (className && className.includes && className.includes(CLASSNAME_IMAGE))
|
||||
close();
|
||||
};
|
||||
|
||||
const handleLeft = useCallback(
|
||||
(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) {
|
||||
setIsSwitchingPage(true);
|
||||
pageCallback(-1);
|
||||
setIndex(-1);
|
||||
// Check if calling page wants to swap page
|
||||
const repage = pageCallback(-1);
|
||||
if (!repage) {
|
||||
setIsSwitchingPage(false);
|
||||
setIndex(0);
|
||||
}
|
||||
setIsSwitchingPage(true);
|
||||
} else setIndex(images.length - 1);
|
||||
} else setIndex((index.current ?? 0) - 1);
|
||||
} else setIndex((index ?? 0) - 1);
|
||||
|
||||
if (isUserAction && resetIntervalCallback.current) {
|
||||
resetIntervalCallback.current();
|
||||
}
|
||||
},
|
||||
[images, setIndex, pageCallback, isSwitchingPage, resetIntervalCallback]
|
||||
[images, pageCallback, isSwitchingPage, resetIntervalCallback, index]
|
||||
);
|
||||
|
||||
const handleRight = useCallback(
|
||||
(isUserAction = true) => {
|
||||
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) {
|
||||
pageCallback(1);
|
||||
setIsSwitchingPage(true);
|
||||
setIndex(0);
|
||||
const repage = pageCallback?.(1);
|
||||
if (!repage) {
|
||||
setIsSwitchingPage(false);
|
||||
setIndex(images.length - 1);
|
||||
}
|
||||
} else setIndex(0);
|
||||
} else setIndex((index.current ?? 0) + 1);
|
||||
} else setIndex((index ?? 0) + 1);
|
||||
|
||||
if (isUserAction && resetIntervalCallback.current) {
|
||||
resetIntervalCallback.current();
|
||||
}
|
||||
},
|
||||
[images, setIndex, pageCallback, isSwitchingPage, resetIntervalCallback]
|
||||
[
|
||||
images,
|
||||
setIndex,
|
||||
pageCallback,
|
||||
isSwitchingPage,
|
||||
resetIntervalCallback,
|
||||
index,
|
||||
]
|
||||
);
|
||||
|
||||
const handleKey = useCallback(
|
||||
@@ -252,51 +297,6 @@ export const LightboxComponent: React.FC<IProps> = ({
|
||||
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(
|
||||
() => {
|
||||
handleRight(false);
|
||||
@@ -332,7 +332,7 @@ export const LightboxComponent: React.FC<IProps> = ({
|
||||
src={image.paths.thumbnail ?? ""}
|
||||
alt=""
|
||||
className={cx(CLASSNAME_NAVIMAGE, {
|
||||
[CLASSNAME_NAVSELECTED]: i === index.current,
|
||||
[CLASSNAME_NAVSELECTED]: i === index,
|
||||
})}
|
||||
onClick={(e: React.MouseEvent) => selectIndex(e, i)}
|
||||
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">
|
||||
Delay (Sec)
|
||||
</FormLabel>
|
||||
<Col sm="4">
|
||||
<FormControl
|
||||
{slideshowEnabled ? (
|
||||
<Form.Group controlId="delay" as={Row} className="form-container">
|
||||
<Col xs={4}>
|
||||
<Form.Label className="col-form-label">
|
||||
<FormattedMessage id="dialogs.lightbox.delay" />
|
||||
</Form.Label>
|
||||
</Col>
|
||||
<Col xs={8}>
|
||||
<Form.Control
|
||||
type="number"
|
||||
className="text-input"
|
||||
min={1}
|
||||
value={displayedSlideshowInterval ?? 0}
|
||||
onChange={onDelayChange}
|
||||
size="sm"
|
||||
id="delay-input"
|
||||
/>
|
||||
</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 = (
|
||||
<Popover id="basic-bitch">
|
||||
<Popover.Title>Set slideshow delay</Popover.Title>
|
||||
const optionsPopover = (
|
||||
<>
|
||||
<Popover.Title>
|
||||
{intl.formatMessage({
|
||||
id: "dialogs.lightbox.options",
|
||||
})}
|
||||
</Popover.Title>
|
||||
<Popover.Content>
|
||||
<InputGroup>
|
||||
<DelayForm />
|
||||
</InputGroup>
|
||||
<OptionsForm />
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
|
||||
const element = isVisible ? (
|
||||
@@ -396,7 +502,7 @@ export const LightboxComponent: React.FC<IProps> = ({
|
||||
className={CLASSNAME}
|
||||
role="presentation"
|
||||
ref={containerRef}
|
||||
onMouseDown={handleClose}
|
||||
onClick={handleClose}
|
||||
>
|
||||
{images.length > 0 && !isLoading && !isSwitchingPage ? (
|
||||
<>
|
||||
@@ -409,34 +515,59 @@ export const LightboxComponent: React.FC<IProps> = ({
|
||||
</b>
|
||||
</div>
|
||||
<div className={CLASSNAME_RIGHT}>
|
||||
{slideshowEnabled && (
|
||||
<>
|
||||
<div className={CLASSNAME_DELAY}>
|
||||
<div className={CLASSNAME_DELAY_ICON}>
|
||||
<OverlayTrigger
|
||||
trigger="click"
|
||||
placement="bottom"
|
||||
overlay={delayPopover}
|
||||
<div className={CLASSNAME_OPTIONS}>
|
||||
<div className={CLASSNAME_OPTIONS_ICON}>
|
||||
<Button
|
||||
ref={overlayTarget}
|
||||
variant="link"
|
||||
title="Options"
|
||||
onClick={() => setShowOptions(!showOptions)}
|
||||
>
|
||||
<Button variant="link" title="Slideshow delay settings">
|
||||
<Icon icon="cog" />
|
||||
</Button>
|
||||
</OverlayTrigger>
|
||||
<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>
|
||||
<InputGroup className={CLASSNAME_DELAY_INLINE}>
|
||||
<DelayForm />
|
||||
)}
|
||||
</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"}
|
||||
/>
|
||||
<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 && (
|
||||
<Button
|
||||
@@ -456,8 +587,8 @@ export const LightboxComponent: React.FC<IProps> = ({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={CLASSNAME_DISPLAY} onTouchStart={handleTouchStart}>
|
||||
{images.length > 1 && (
|
||||
<div className={CLASSNAME_DISPLAY}>
|
||||
{allowNavigation && (
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={handleLeft}
|
||||
@@ -474,32 +605,26 @@ export const LightboxComponent: React.FC<IProps> = ({
|
||||
style={{ left: `${currentIndex * -100}vw` }}
|
||||
ref={carouselRef}
|
||||
>
|
||||
{images.map((image) => (
|
||||
<div className={CLASSNAME_IMAGE} key={image.paths.image}>
|
||||
<picture>
|
||||
<source
|
||||
srcSet={image.paths.image ?? ""}
|
||||
media="(min-width: 800px)"
|
||||
{images.map((image, i) => (
|
||||
<div className={`${CLASSNAME_IMAGE}`} key={image.paths.image}>
|
||||
{i >= currentIndex - 1 && i <= currentIndex + 1 ? (
|
||||
<LightboxImage
|
||||
src={image.paths.image ?? ""}
|
||||
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="" />
|
||||
<div>
|
||||
<div
|
||||
aria-hidden
|
||||
className={CLASSNAME_NAVZONE}
|
||||
onClick={handleLeft}
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
className={CLASSNAME_NAVZONE}
|
||||
onClick={handleRight}
|
||||
/>
|
||||
</div>
|
||||
</picture>
|
||||
) : undefined}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{images.length > 1 && (
|
||||
{allowNavigation && (
|
||||
<Button
|
||||
variant="link"
|
||||
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;
|
||||
showNavigation: boolean;
|
||||
initialIndex?: number;
|
||||
pageCallback?: (direction: number) => boolean;
|
||||
pageCallback?: (direction: number) => void;
|
||||
pageHeader?: string;
|
||||
slideshowEnabled: boolean;
|
||||
onClose?: () => void;
|
||||
|
||||
@@ -30,6 +30,10 @@
|
||||
display: flex;
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
|
||||
@media (max-width: 575px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&-indicator {
|
||||
@@ -40,7 +44,7 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&-delay {
|
||||
&-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-left: 100px;
|
||||
@@ -53,16 +57,6 @@
|
||||
&-inline {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1300px) {
|
||||
&-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&-inline {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-right {
|
||||
@@ -103,7 +97,6 @@
|
||||
|
||||
picture {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
margin: auto;
|
||||
position: relative;
|
||||
|
||||
@@ -116,9 +109,7 @@
|
||||
}
|
||||
|
||||
img {
|
||||
margin: auto;
|
||||
max-height: 100%;
|
||||
max-width: 100%;
|
||||
cursor: pointer;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -444,6 +444,27 @@
|
||||
"edit_entity_title": "Edit {count, plural, one {{singularEntity}} other {{pluralEntity}}}",
|
||||
"export_include_related_objects": "Include related objects in 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": {
|
||||
"destination": "Destination",
|
||||
"source": "Source"
|
||||
|
||||
@@ -168,6 +168,8 @@ hr {
|
||||
|
||||
.popover {
|
||||
&-body {
|
||||
color: $text-color;
|
||||
|
||||
.btn {
|
||||
color: $text-color;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user