import React, { useCallback, useEffect, useRef, useState } from "react"; import { Button, Col, InputGroup, Overlay, Popover, Form, Row, Dropdown, } from "react-bootstrap"; import cx from "classnames"; import Mousetrap from "mousetrap"; import { Icon } from "src/components/Shared/Icon"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import useInterval from "../Interval"; import usePageVisibility from "../PageVisibility"; import { useToast } from "../Toast"; import { FormattedMessage, useIntl } from "react-intl"; import { LightboxImage } from "./LightboxImage"; import { ConfigurationContext } from "../Config"; import { Link } from "react-router-dom"; import { OCounterButton } from "src/components/Scenes/SceneDetails/OCounterButton"; import { mutateImageIncrementO, mutateImageDecrementO, mutateImageResetO, useImageUpdate, } from "src/core/StashService"; import * as GQL from "src/core/generated-graphql"; import { useInterfaceLocalForage } from "../LocalForage"; import { imageLightboxDisplayModeIntlMap } from "src/core/enums"; import { ILightboxImage, IChapter } from "./types"; import { faArrowLeft, faArrowRight, faChevronLeft, faChevronRight, faCog, faExpand, faPause, faPlay, faSearchMinus, faTimes, faBars, } from "@fortawesome/free-solid-svg-icons"; import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; import { useDebounce } from "../debounce"; import { isVideo } from "src/utils/visualFile"; const CLASSNAME = "Lightbox"; const CLASSNAME_HEADER = `${CLASSNAME}-header`; const CLASSNAME_LEFT_SPACER = `${CLASSNAME_HEADER}-left-spacer`; const CLASSNAME_CHAPTERS = `${CLASSNAME_HEADER}-chapters`; const CLASSNAME_CHAPTER_BUTTON = `${CLASSNAME_HEADER}-chapter-button`; const CLASSNAME_INDICATOR = `${CLASSNAME_HEADER}-indicator`; 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_FOOTER = `${CLASSNAME}-footer`; const CLASSNAME_FOOTER_LEFT = `${CLASSNAME_FOOTER}-left`; const CLASSNAME_DISPLAY = `${CLASSNAME}-display`; const CLASSNAME_CAROUSEL = `${CLASSNAME}-carousel`; const CLASSNAME_INSTANT = `${CLASSNAME_CAROUSEL}-instant`; const CLASSNAME_IMAGE = `${CLASSNAME_CAROUSEL}-image`; const CLASSNAME_NAVBUTTON = `${CLASSNAME}-navbutton`; const CLASSNAME_NAV = `${CLASSNAME}-nav`; const CLASSNAME_NAVIMAGE = `${CLASSNAME_NAV}-image`; const CLASSNAME_NAVSELECTED = `${CLASSNAME_NAV}-selected`; const DEFAULT_SLIDESHOW_DELAY = 5000; const SECONDS_TO_MS = 1000; const MIN_VALID_INTERVAL_SECONDS = 1; const MIN_ZOOM = 0.1; const SCROLL_ZOOM_TIMEOUT = 250; const ZOOM_NONE_EPSILON = 0.015; interface IProps { images: ILightboxImage[]; isVisible: boolean; isLoading: boolean; initialIndex?: number; showNavigation: boolean; slideshowEnabled?: boolean; page?: number; pages?: number; pageSize?: number; pageCallback?: (props: { direction?: number; page?: number }) => void; chapters?: IChapter[]; hide: () => void; } export const LightboxComponent: React.FC = ({ images, isVisible, isLoading, initialIndex = 0, showNavigation, slideshowEnabled = false, page, pages, pageSize: pageSize = 40, pageCallback, chapters = [], hide, }) => { const [updateImage] = useImageUpdate(); // zero-based const [index, setIndex] = useState(null); const [movingLeft, setMovingLeft] = useState(false); const oldIndex = useRef(null); const [instantTransition, setInstantTransition] = useState(false); const [isSwitchingPage, setIsSwitchingPage] = useState(true); const [isFullscreen, setFullscreen] = useState(false); const [showOptions, setShowOptions] = useState(false); const [showChapters, setShowChapters] = useState(false); const [imagesLoaded, setImagesLoaded] = useState(0); const [navOffset, setNavOffset] = useState(); const oldImages = useRef([]); const [zoom, setZoom] = useState(1); function updateZoom(v: number) { if (v < MIN_ZOOM) { setZoom(MIN_ZOOM); } else if (Math.abs(v - 1) < ZOOM_NONE_EPSILON) { // "snap to 1" effect: if new zoom is close to 1, set to 1 setZoom(1); } else { setZoom(v); } } const [resetPosition, setResetPosition] = useState(false); const containerRef = useRef(null); const overlayTarget = useRef(null); const carouselRef = useRef(null); const indicatorRef = useRef(null); const navRef = useRef(null); const clearIntervalCallback = useRef<() => void>(); const resetIntervalCallback = useRef<() => void>(); const allowNavigation = images.length > 1 || pageCallback; const Toast = useToast(); const intl = useIntl(); const { configuration: config } = React.useContext(ConfigurationContext); const [interfaceLocalForage, setInterfaceLocalForage] = useInterfaceLocalForage(); const lightboxSettings = interfaceLocalForage.data?.imageLightbox; function setLightboxSettings(v: Partial) { setInterfaceLocalForage((prev) => { return { ...prev, imageLightbox: { ...prev.imageLightbox, ...v, }, }; }); } function setScaleUp(value: boolean) { setLightboxSettings({ scaleUp: value }); } function setResetZoomOnNav(v: boolean) { setLightboxSettings({ resetZoomOnNav: v }); } function setScrollMode(v: GQL.ImageLightboxScrollMode) { setLightboxSettings({ scrollMode: v }); } const configuredDelay = config?.interface.imageLightbox.slideshowDelay ? config.interface.imageLightbox.slideshowDelay * SECONDS_TO_MS : undefined; const savedDelay = lightboxSettings?.slideshowDelay ? lightboxSettings.slideshowDelay * SECONDS_TO_MS : undefined; const slideshowDelay = savedDelay ?? configuredDelay ?? DEFAULT_SLIDESHOW_DELAY; const scrollAttemptsBeforeChange = Math.max( 0, config?.interface.imageLightbox.scrollAttemptsBeforeChange ?? 0 ); function setSlideshowDelay(v: number) { setLightboxSettings({ slideshowDelay: v }); } const displayMode = lightboxSettings?.displayMode ?? GQL.ImageLightboxDisplayMode.FitXy; const oldDisplayMode = useRef(displayMode); function setDisplayMode(v: GQL.ImageLightboxDisplayMode) { setLightboxSettings({ displayMode: v }); } // slideshowInterval is used for controlling the logic // displaySlideshowInterval is for display purposes only // keeping them separate and independant allows us to handle the logic however we want // while still displaying something that makes sense to the user const [slideshowInterval, setSlideshowInterval] = useState( null ); const [displayedSlideshowInterval, setDisplayedSlideshowInterval] = useState((slideshowDelay / SECONDS_TO_MS).toString()); useEffect(() => { if (images !== oldImages.current && isSwitchingPage) { if (index === -1) setIndex(images.length - 1); setIsSwitchingPage(false); } }, [isSwitchingPage, images, index]); const disableInstantTransition = useDebounce( () => setInstantTransition(false), 400 ); const setInstant = useCallback(() => { setInstantTransition(true); disableInstantTransition(); }, [disableInstantTransition]); useEffect(() => { if (images.length < 2) return; if (index === oldIndex.current) return; if (index === null) return; // reset zoom status // setResetZoom((r) => !r); // setZoomed(false); if (lightboxSettings?.resetZoomOnNav) { setZoom(1); } setResetPosition((r) => !r); oldIndex.current = index; }, [index, images.length, lightboxSettings?.resetZoomOnNav]); const getNavOffset = useCallback(() => { if (images.length < 2) return; if (index === undefined || index === null) return; if (navRef.current) { const currentThumb = navRef.current.children[index + 1]; if (currentThumb instanceof HTMLImageElement) { const offset = -1 * (currentThumb.offsetLeft - document.documentElement.clientWidth / 2); return { left: `${offset}px` }; } } }, [index, images.length]); useEffect(() => { // reset images loaded counter for new images setImagesLoaded(0); }, [images]); useEffect(() => { setNavOffset(getNavOffset() ?? undefined); }, [getNavOffset]); useEffect(() => { if (displayMode !== oldDisplayMode.current) { // reset zoom status // setResetZoom((r) => !r); // setZoomed(false); if (lightboxSettings?.resetZoomOnNav) { setZoom(1); } setResetPosition((r) => !r); } oldDisplayMode.current = displayMode; }, [displayMode, lightboxSettings?.resetZoomOnNav]); const selectIndex = (e: React.MouseEvent, i: number) => { setIndex(i); e.stopPropagation(); }; useEffect(() => { if (isVisible) { if (index === null) setIndex(initialIndex); document.body.style.overflow = "hidden"; Mousetrap.pause(); } }, [initialIndex, isVisible, setIndex, index]); const toggleSlideshow = useCallback(() => { if (slideshowInterval) { setSlideshowInterval(null); } else { setSlideshowInterval(slideshowDelay); } }, [slideshowInterval, slideshowDelay]); // stop slideshow when the page is hidden usePageVisibility((hidden: boolean) => { if (hidden) { setSlideshowInterval(null); } }); const close = useCallback(() => { if (isFullscreen) document.exitFullscreen(); hide(); document.body.style.overflow = "auto"; Mousetrap.unpause(); }, [isFullscreen, hide]); const handleClose = (e: React.MouseEvent) => { const { className } = e.target as Element; if (className && className.includes && className.includes(CLASSNAME_IMAGE)) close(); }; const handleLeft = useCallback( (isUserAction = true) => { if (isSwitchingPage || index === -1) return; setShowChapters(false); setMovingLeft(true); if (index === 0) { // go to next page, or loop back if no callback is set if (pageCallback) { pageCallback({ direction: -1 }); setIndex(-1); oldImages.current = images; setIsSwitchingPage(true); } else setIndex(images.length - 1); } else setIndex((index ?? 0) - 1); if (isUserAction && resetIntervalCallback.current) { resetIntervalCallback.current(); } }, [images, pageCallback, isSwitchingPage, resetIntervalCallback, index] ); const handleRight = useCallback( (isUserAction = true) => { if (isSwitchingPage) return; setMovingLeft(false); setShowChapters(false); if (index === images.length - 1) { // go to preview page, or loop back if no callback is set if (pageCallback) { pageCallback({ direction: 1 }); oldImages.current = images; setIsSwitchingPage(true); setIndex(0); } else setIndex(0); } else setIndex((index ?? 0) + 1); if (isUserAction && resetIntervalCallback.current) { resetIntervalCallback.current(); } }, [ images, setIndex, pageCallback, isSwitchingPage, resetIntervalCallback, index, ] ); const firstScroll = useRef(null); const inScrollGroup = useRef(false); const debouncedScrollReset = useDebounce(() => { firstScroll.current = null; inScrollGroup.current = false; }, SCROLL_ZOOM_TIMEOUT); const handleKey = useCallback( (e: KeyboardEvent) => { if (e.repeat && (e.key === "ArrowRight" || e.key === "ArrowLeft")) setInstant(); if (e.key === "ArrowLeft") handleLeft(); else if (e.key === "ArrowRight") handleRight(); else if (e.key === "Escape") close(); }, [setInstant, handleLeft, handleRight, close] ); const handleFullScreenChange = () => { if (clearIntervalCallback.current) { clearIntervalCallback.current(); } setFullscreen(document.fullscreenElement !== null); }; const [clearCallback, resetCallback] = useInterval( () => { handleRight(false); }, slideshowEnabled ? slideshowInterval : null ); resetIntervalCallback.current = resetCallback; clearIntervalCallback.current = clearCallback; useEffect(() => { if (isVisible) { document.addEventListener("keydown", handleKey); document.addEventListener("fullscreenchange", handleFullScreenChange); } return () => { document.removeEventListener("keydown", handleKey); document.removeEventListener("fullscreenchange", handleFullScreenChange); }; }, [isVisible, handleKey]); const toggleFullscreen = useCallback(() => { if (!isFullscreen) containerRef.current?.requestFullscreen(); else document.exitFullscreen(); }, [isFullscreen]); function imageLoaded() { setImagesLoaded((loaded) => loaded + 1); if (imagesLoaded === images.length - 1) { // all images are loaded - update the nav offset setNavOffset(getNavOffset() ?? undefined); } } const navItems = images.map((image, i) => React.createElement(image.paths.preview != "" ? "video" : "img", { loop: image.paths.preview != "", autoPlay: image.paths.preview != "", src: image.paths.preview != "" ? image.paths.preview ?? "" : image.paths.thumbnail ?? "", alt: "", className: cx(CLASSNAME_NAVIMAGE, { [CLASSNAME_NAVSELECTED]: i === index, }), onClick: (e: React.MouseEvent) => selectIndex(e, i), role: "presentation", loading: "lazy", key: image.paths.thumbnail, onLoad: imageLoaded, }) ); const onDelayChange = (e: React.ChangeEvent) => { let numberValue = Number.parseInt(e.currentTarget.value, 10); setDisplayedSlideshowInterval(e.currentTarget.value); // Without this exception, the blocking of updates for invalid values is even weirder if (e.currentTarget.value === "-" || e.currentTarget.value === "") { return; } numberValue = numberValue >= MIN_VALID_INTERVAL_SECONDS ? numberValue : MIN_VALID_INTERVAL_SECONDS; setSlideshowDelay(numberValue); if (slideshowInterval !== null) { setSlideshowInterval(numberValue * SECONDS_TO_MS); } }; const currentIndex = index === null ? initialIndex : index; function gotoPage(imageIndex: number) { const indexInPage = (imageIndex - 1) % pageSize; if (pageCallback) { let jumppage = Math.floor((imageIndex - 1) / pageSize) + 1; if (page !== jumppage) { pageCallback({ page: jumppage }); oldImages.current = images; setIsSwitchingPage(true); } } setIndex(indexInPage); setShowChapters(false); } function chapterHeader() { const imageNumber = (index ?? 0) + 1; const globalIndex = page ? (page - 1) * pageSize + imageNumber : imageNumber; let chapterTitle = ""; chapters.forEach(function (chapter) { if (chapter.image_index > globalIndex) { return; } chapterTitle = chapter.title; }); return chapterTitle ?? ""; } const renderChapterMenu = () => { if (chapters.length <= 0) return; const popoverContent = chapters.map(({ id, title, image_index }) => ( gotoPage(image_index)}> {" "} {title} {title.length > 0 ? " - #" : "#"} {image_index} )); return ( setShowChapters(!showChapters)} > {popoverContent} ); }; // #2451: making OptionsForm an inline component means it // get re-rendered each time. This makes the text // field lose focus on input. Use function instead. function renderOptionsForm() { return ( <> {slideshowEnabled ? ( ) : undefined} setDisplayMode(e.target.value as GQL.ImageLightboxDisplayMode) } value={displayMode} className="btn-secondary mx-1 mb-1" > {Array.from(imageLightboxDisplayModeIntlMap.entries()).map( (v) => ( ) )} setScaleUp(v.currentTarget.checked)} /> {intl.formatMessage({ id: "dialogs.lightbox.scale_up.description", })} setResetZoomOnNav(v.currentTarget.checked)} /> setScrollMode(e.target.value as GQL.ImageLightboxScrollMode) } value={ lightboxSettings?.scrollMode ?? GQL.ImageLightboxScrollMode.Zoom } className="btn-secondary mx-1 mb-1" > {intl.formatMessage({ id: "dialogs.lightbox.scroll_mode.description", })} ); } function renderBody() { if (images.length === 0 || isLoading || isSwitchingPage) { return ; } const currentImage: ILightboxImage | undefined = images[currentIndex]; function setRating(v: number | null) { if (currentImage?.id) { updateImage({ variables: { input: { id: currentImage.id, rating100: v, }, }, }); } } async function onIncrementClick() { if (currentImage?.id === undefined) return; try { await mutateImageIncrementO(currentImage.id); } catch (e) { Toast.error(e); } } async function onDecrementClick() { if (currentImage?.id === undefined) return; try { await mutateImageDecrementO(currentImage.id); } catch (e) { Toast.error(e); } } async function onResetClick() { if (currentImage?.id === undefined) return; try { await mutateImageResetO(currentImage?.id); } catch (e) { Toast.error(e); } } const pageHeader = page && pages ? intl.formatMessage( { id: "dialogs.lightbox.page_header" }, { page, total: pages } ) : ""; return ( <>
{renderChapterMenu()}
{chapterHeader()} {pageHeader} {images.length > 1 ? ( {`${currentIndex + 1} / ${ images.length }`} ) : undefined}
setShowOptions(false)} > {({ placement, arrowProps, show: _show, ...props }) => (
{intl.formatMessage({ id: "dialogs.lightbox.options", })} {renderOptionsForm()}
)}
{renderOptionsForm()}
{slideshowEnabled && ( )} {zoom !== 1 && ( )} {document.fullscreenEnabled && ( )}
{allowNavigation && ( )}
{images.map((image, i) => (
{i >= currentIndex - 1 && i <= currentIndex + 1 ? ( ) : undefined}
))}
{allowNavigation && ( )}
{showNavigation && !isFullscreen && images.length > 1 && (
{navItems}
)}
{currentImage?.id !== undefined && ( <>
setRating(v)} clickToRate withoutContext /> )}
{currentImage?.title && ( close()}> {currentImage.title ?? ""} )}
); } if (!isVisible) { return <>; } return (
{renderBody()}
); }; export default LightboxComponent;