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:
WithoutPants
2021-10-01 11:52:32 +10:00
committed by GitHub
parent dabf5acefe
commit e3480531a7
8 changed files with 709 additions and 208 deletions

View File

@@ -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))

View File

@@ -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,

View File

@@ -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}

View 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>
);
};

View File

@@ -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;

View File

@@ -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;
} }
} }

View File

@@ -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"

View File

@@ -168,6 +168,8 @@ hr {
.popover { .popover {
&-body { &-body {
color: $text-color;
.btn { .btn {
color: $text-color; color: $text-color;
} }