Fix lightbox fullscreen issues (#4149)

* Improve lightbox context hook
* Prevent fullscreen drop while loading
* Fix close not working from fullscreen
This commit is contained in:
WithoutPants
2023-09-25 09:35:55 +10:00
committed by GitHub
parent 462943a903
commit 9f5bcca1eb
3 changed files with 285 additions and 260 deletions

View File

@@ -319,12 +319,12 @@ export const LightboxComponent: React.FC<IProps> = ({
}); });
const close = useCallback(() => { const close = useCallback(() => {
if (!isFullscreen) { if (isFullscreen) document.exitFullscreen();
hide();
document.body.style.overflow = "auto"; hide();
// eslint-disable-next-line @typescript-eslint/no-explicit-any document.body.style.overflow = "auto";
(Mousetrap as any).unpause(); // eslint-disable-next-line @typescript-eslint/no-explicit-any
} else document.exitFullscreen(); (Mousetrap as any).unpause();
}, [isFullscreen, hide]); }, [isFullscreen, hide]);
const handleClose = (e: React.MouseEvent<HTMLDivElement>) => { const handleClose = (e: React.MouseEvent<HTMLDivElement>) => {
@@ -685,64 +685,269 @@ export const LightboxComponent: React.FC<IProps> = ({
); );
} }
function renderBody() {
if (images.length === 0 || isLoading || isSwitchingPage) {
return <LoadingIndicator />;
}
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 (
<>
<div className={CLASSNAME_HEADER}>
<div className={CLASSNAME_LEFT_SPACER}>{renderChapterMenu()}</div>
<div className={CLASSNAME_INDICATOR}>
<span>
{chapterHeader()} {pageHeader}
</span>
{images.length > 1 ? (
<b ref={indicatorRef}>{`${currentIndex + 1} / ${
images.length
}`}</b>
) : undefined}
</div>
<div className={CLASSNAME_RIGHT}>
<div className={CLASSNAME_OPTIONS}>
<div className={CLASSNAME_OPTIONS_ICON}>
<Button
ref={overlayTarget}
variant="link"
title={intl.formatMessage({
id: "dialogs.lightbox.options",
})}
onClick={() => setShowOptions(!showOptions)}
>
<Icon icon={faCog} />
</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 }}
>
<Popover.Title>
{intl.formatMessage({
id: "dialogs.lightbox.options",
})}
</Popover.Title>
<Popover.Content>{renderOptionsForm()}</Popover.Content>
</div>
)}
</Overlay>
</div>
<InputGroup className={CLASSNAME_OPTIONS_INLINE}>
{renderOptionsForm()}
</InputGroup>
</div>
{slideshowEnabled && (
<Button
variant="link"
onClick={toggleSlideshow}
title="Toggle Slideshow"
>
<Icon icon={slideshowInterval !== null ? faPause : faPlay} />
</Button>
)}
{zoom !== 1 && (
<Button
variant="link"
onClick={() => {
setResetPosition(!resetPosition);
setZoom(1);
}}
title="Reset zoom"
>
<Icon icon={faSearchMinus} />
</Button>
)}
{document.fullscreenEnabled && (
<Button
variant="link"
onClick={toggleFullscreen}
title="Toggle Fullscreen"
>
<Icon icon={faExpand} />
</Button>
)}
<Button
variant="link"
onClick={() => close()}
title="Close Lightbox"
>
<Icon icon={faTimes} />
</Button>
</div>
</div>
<div className={CLASSNAME_DISPLAY}>
{allowNavigation && (
<Button
variant="link"
onClick={handleLeft}
className={`${CLASSNAME_NAVBUTTON} d-none d-lg-block`}
>
<Icon icon={faChevronLeft} />
</Button>
)}
<div
className={cx(CLASSNAME_CAROUSEL, {
[CLASSNAME_INSTANT]: instantTransition,
})}
style={{ left: `${currentIndex * -100}vw` }}
ref={carouselRef}
>
{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={lightboxSettings?.scaleUp ?? false}
scrollMode={
lightboxSettings?.scrollMode ??
GQL.ImageLightboxScrollMode.Zoom
}
resetPosition={resetPosition}
zoom={i === currentIndex ? zoom : 1}
scrollAttemptsBeforeChange={scrollAttemptsBeforeChange}
firstScroll={firstScroll}
inScrollGroup={inScrollGroup}
current={i === currentIndex}
alignBottom={movingLeft}
setZoom={updateZoom}
debouncedScrollReset={debouncedScrollReset}
onLeft={handleLeft}
onRight={handleRight}
isVideo={isVideo(image.visual_files?.[0] ?? {})}
/>
) : undefined}
</div>
))}
</div>
{allowNavigation && (
<Button
variant="link"
onClick={handleRight}
className={`${CLASSNAME_NAVBUTTON} d-none d-lg-block`}
>
<Icon icon={faChevronRight} />
</Button>
)}
</div>
{showNavigation && !isFullscreen && images.length > 1 && (
<div className={CLASSNAME_NAV} style={navOffset} ref={navRef}>
<Button
variant="link"
onClick={() => setIndex(images.length - 1)}
className={CLASSNAME_NAVBUTTON}
>
<Icon icon={faArrowLeft} className="mr-4" />
</Button>
{navItems}
<Button
variant="link"
onClick={() => setIndex(0)}
className={CLASSNAME_NAVBUTTON}
>
<Icon icon={faArrowRight} className="ml-4" />
</Button>
</div>
)}
<div className={CLASSNAME_FOOTER}>
<div className={CLASSNAME_FOOTER_LEFT}>
{currentImage?.id !== undefined && (
<>
<div>
<OCounterButton
onDecrement={onDecrementClick}
onIncrement={onIncrementClick}
onReset={onResetClick}
value={currentImage?.o_counter ?? 0}
/>
</div>
<RatingSystem
value={currentImage?.rating100 ?? undefined}
onSetRating={(v) => {
setRating(v ?? null);
}}
/>
</>
)}
</div>
<div>
{currentImage?.title && (
<Link to={`/images/${currentImage.id}`} onClick={() => close()}>
{currentImage.title ?? ""}
</Link>
)}
</div>
<div></div>
</div>
</>
);
}
if (!isVisible) { if (!isVisible) {
return <></>; return <></>;
} }
if (images.length === 0 || isLoading || isSwitchingPage) {
return <LoadingIndicator />;
}
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 ( return (
<div <div
className={CLASSNAME} className={CLASSNAME}
@@ -750,198 +955,7 @@ export const LightboxComponent: React.FC<IProps> = ({
ref={containerRef} ref={containerRef}
onClick={handleClose} onClick={handleClose}
> >
<div className={CLASSNAME_HEADER}> {renderBody()}
<div className={CLASSNAME_LEFT_SPACER}>{renderChapterMenu()}</div>
<div className={CLASSNAME_INDICATOR}>
<span>
{chapterHeader()} {pageHeader}
</span>
{images.length > 1 ? (
<b ref={indicatorRef}>{`${currentIndex + 1} / ${images.length}`}</b>
) : undefined}
</div>
<div className={CLASSNAME_RIGHT}>
<div className={CLASSNAME_OPTIONS}>
<div className={CLASSNAME_OPTIONS_ICON}>
<Button
ref={overlayTarget}
variant="link"
title={intl.formatMessage({
id: "dialogs.lightbox.options",
})}
onClick={() => setShowOptions(!showOptions)}
>
<Icon icon={faCog} />
</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 }}
>
<Popover.Title>
{intl.formatMessage({
id: "dialogs.lightbox.options",
})}
</Popover.Title>
<Popover.Content>{renderOptionsForm()}</Popover.Content>
</div>
)}
</Overlay>
</div>
<InputGroup className={CLASSNAME_OPTIONS_INLINE}>
{renderOptionsForm()}
</InputGroup>
</div>
{slideshowEnabled && (
<Button
variant="link"
onClick={toggleSlideshow}
title="Toggle Slideshow"
>
<Icon icon={slideshowInterval !== null ? faPause : faPlay} />
</Button>
)}
{zoom !== 1 && (
<Button
variant="link"
onClick={() => {
setResetPosition(!resetPosition);
setZoom(1);
}}
title="Reset zoom"
>
<Icon icon={faSearchMinus} />
</Button>
)}
{document.fullscreenEnabled && (
<Button
variant="link"
onClick={toggleFullscreen}
title="Toggle Fullscreen"
>
<Icon icon={faExpand} />
</Button>
)}
<Button variant="link" onClick={() => close()} title="Close Lightbox">
<Icon icon={faTimes} />
</Button>
</div>
</div>
<div className={CLASSNAME_DISPLAY}>
{allowNavigation && (
<Button
variant="link"
onClick={handleLeft}
className={`${CLASSNAME_NAVBUTTON} d-none d-lg-block`}
>
<Icon icon={faChevronLeft} />
</Button>
)}
<div
className={cx(CLASSNAME_CAROUSEL, {
[CLASSNAME_INSTANT]: instantTransition,
})}
style={{ left: `${currentIndex * -100}vw` }}
ref={carouselRef}
>
{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={lightboxSettings?.scaleUp ?? false}
scrollMode={
lightboxSettings?.scrollMode ??
GQL.ImageLightboxScrollMode.Zoom
}
resetPosition={resetPosition}
zoom={i === currentIndex ? zoom : 1}
scrollAttemptsBeforeChange={scrollAttemptsBeforeChange}
firstScroll={firstScroll}
inScrollGroup={inScrollGroup}
current={i === currentIndex}
alignBottom={movingLeft}
setZoom={updateZoom}
debouncedScrollReset={debouncedScrollReset}
onLeft={handleLeft}
onRight={handleRight}
isVideo={isVideo(image.visual_files?.[0] ?? {})}
/>
) : undefined}
</div>
))}
</div>
{allowNavigation && (
<Button
variant="link"
onClick={handleRight}
className={`${CLASSNAME_NAVBUTTON} d-none d-lg-block`}
>
<Icon icon={faChevronRight} />
</Button>
)}
</div>
{showNavigation && !isFullscreen && images.length > 1 && (
<div className={CLASSNAME_NAV} style={navOffset} ref={navRef}>
<Button
variant="link"
onClick={() => setIndex(images.length - 1)}
className={CLASSNAME_NAVBUTTON}
>
<Icon icon={faArrowLeft} className="mr-4" />
</Button>
{navItems}
<Button
variant="link"
onClick={() => setIndex(0)}
className={CLASSNAME_NAVBUTTON}
>
<Icon icon={faArrowRight} className="ml-4" />
</Button>
</div>
)}
<div className={CLASSNAME_FOOTER}>
<div className={CLASSNAME_FOOTER_LEFT}>
{currentImage?.id !== undefined && (
<>
<div>
<OCounterButton
onDecrement={onDecrementClick}
onIncrement={onIncrementClick}
onReset={onResetClick}
value={currentImage?.o_counter ?? 0}
/>
</div>
<RatingSystem
value={currentImage?.rating100 ?? undefined}
onSetRating={(v) => {
setRating(v ?? null);
}}
/>
</>
)}
</div>
<div>
{currentImage?.title && (
<Link to={`/images/${currentImage.id}`} onClick={() => close()}>
{currentImage.title ?? ""}
</Link>
)}
</div>
<div></div>
</div>
</div> </div>
); );
}; };

View File

@@ -19,12 +19,21 @@ export interface IState {
onClose?: () => void; onClose?: () => void;
} }
interface IContext { interface IContext {
lightboxState: IState;
setLightboxState: (state: Partial<IState>) => void; setLightboxState: (state: Partial<IState>) => void;
} }
export const LightboxContext = React.createContext<IContext>({ export const LightboxContext = React.createContext<IContext | null>(null);
setLightboxState: () => {},
}); export function useLightboxContext() {
const context = React.useContext(LightboxContext);
if (!context) {
throw new Error(
"useLightboxContext must be used within a LightboxProvider"
);
}
return context;
}
export const LightboxProvider: React.FC = ({ children }) => { export const LightboxProvider: React.FC = ({ children }) => {
const [lightboxState, setLightboxState] = useState<IState>({ const [lightboxState, setLightboxState] = useState<IState>({
@@ -53,7 +62,9 @@ export const LightboxProvider: React.FC = ({ children }) => {
}; };
return ( return (
<LightboxContext.Provider value={{ setLightboxState: setPartialState }}> <LightboxContext.Provider
value={{ lightboxState, setLightboxState: setPartialState }}
>
{children} {children}
<Suspense fallback={<></>}> <Suspense fallback={<></>}>
{lightboxState.isVisible && ( {lightboxState.isVisible && (

View File

@@ -1,13 +1,13 @@
import { useCallback, useContext, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { LightboxContext, IState } from "./context"; import { IState, useLightboxContext } from "./context";
import { IChapter } from "./types"; import { IChapter } from "./types";
export const useLightbox = ( export const useLightbox = (
state: Partial<Omit<IState, "isVisible">>, state: Partial<Omit<IState, "isVisible">>,
chapters: IChapter[] = [] chapters: IChapter[] = []
) => { ) => {
const { setLightboxState } = useContext(LightboxContext); const { setLightboxState } = useLightboxContext();
useEffect(() => { useEffect(() => {
setLightboxState({ setLightboxState({
@@ -50,7 +50,7 @@ export const useLightbox = (
}; };
export const useGalleryLightbox = (id: string, chapters: IChapter[] = []) => { export const useGalleryLightbox = (id: string, chapters: IChapter[] = []) => {
const { setLightboxState } = useContext(LightboxContext); const { setLightboxState } = useLightboxContext();
const pageSize = 40; const pageSize = 40;
const [page, setPage] = useState(1); const [page, setPage] = useState(1);