Add gallery wall view, and new lightbox (#1008)

This commit is contained in:
InfiniteTF
2020-12-24 01:17:15 +01:00
committed by GitHub
parent c8bcaaf27d
commit 232a69c518
24 changed files with 979 additions and 216 deletions

View File

@@ -0,0 +1,350 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import * as GQL from "src/core/generated-graphql";
import { Button } from "react-bootstrap";
import cx from "classnames";
import Mousetrap from "mousetrap";
import { debounce } from "lodash";
import { Icon, LoadingIndicator } from "src/components/Shared";
const CLASSNAME = "Lightbox";
const CLASSNAME_HEADER = `${CLASSNAME}-header`;
const CLASSNAME_INDICATOR = `${CLASSNAME_HEADER}-indicator`;
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`;
type Image = Pick<GQL.Image, "paths">;
interface IProps {
images: Image[];
isVisible: boolean;
isLoading: boolean;
initialIndex?: number;
showNavigation: boolean;
pageHeader?: string;
pageCallback?: (direction: number) => boolean;
hide: () => void;
}
export const LightboxComponent: React.FC<IProps> = ({
images,
isVisible,
isLoading,
initialIndex = 0,
showNavigation,
pageHeader,
pageCallback,
hide,
}) => {
const index = useRef<number | null>(null);
const [instantTransition, setInstantTransition] = useState(false);
const [isSwitchingPage, setIsSwitchingPage] = useState(false);
const [isFullscreen, setFullscreen] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
const carouselRef = useRef<HTMLDivElement | null>(null);
const indicatorRef = useRef<HTMLDivElement | null>(null);
const navRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
setIsSwitchingPage(false);
if (index.current === -1) index.current = images.length - 1;
}, [images]);
const disableInstantTransition = debounce(
() => setInstantTransition(false),
400
);
const setInstant = useCallback(() => {
setInstantTransition(true);
disableInstantTransition();
}, [disableInstantTransition]);
const setIndex = useCallback(
(i: number) => {
if (images.length < 2) return;
index.current = i;
if (carouselRef.current) carouselRef.current.style.left = `${i * -100}vw`;
if (indicatorRef.current)
indicatorRef.current.innerHTML = `${i + 1} / ${images.length}`;
if (navRef.current) {
const currentThumb = navRef.current.children[i + 1];
if (currentThumb instanceof HTMLImageElement) {
const offset =
-1 *
(currentThumb.offsetLeft -
document.documentElement.clientWidth / 2);
navRef.current.style.left = `${offset}px`;
const previouslySelected = navRef.current.getElementsByClassName(
CLASSNAME_NAVSELECTED
)?.[0];
if (previouslySelected)
previouslySelected.className = CLASSNAME_NAVIMAGE;
currentThumb.className = `${CLASSNAME_NAVIMAGE} ${CLASSNAME_NAVSELECTED}`;
}
}
},
[images]
);
const selectIndex = (e: React.MouseEvent, i: number) => {
setIndex(i);
e.stopPropagation();
};
useEffect(() => {
if (isVisible) {
if (index.current === null) setIndex(initialIndex);
document.body.style.overflow = "hidden";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(Mousetrap as any).pause();
}
}, [initialIndex, isVisible, setIndex]);
const close = useCallback(() => {
if (!isFullscreen) {
hide();
document.body.style.overflow = "auto";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(Mousetrap as any).unpause();
} else document.exitFullscreen();
}, [isFullscreen, hide]);
const handleClose = (e: React.MouseEvent<HTMLDivElement>) => {
const { nodeName } = e.target as Node;
if (nodeName === "DIV" || nodeName === "PICTURE") close();
};
const handleLeft = useCallback(() => {
if (isSwitchingPage || index.current === -1) return;
if (index.current === 0) {
if (pageCallback) {
setIsSwitchingPage(true);
setIndex(-1);
// Check if calling page wants to swap page
const repage = pageCallback(-1);
if (!repage) {
setIsSwitchingPage(false);
setIndex(0);
}
} else setIndex(images.length - 1);
} else setIndex((index.current ?? 0) - 1);
}, [images, setIndex, pageCallback, isSwitchingPage]);
const handleRight = useCallback(() => {
if (isSwitchingPage) return;
if (index.current === images.length - 1) {
if (pageCallback) {
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);
}, [images, setIndex, pageCallback, isSwitchingPage]);
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 = () =>
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);
};
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]);
const navItems = images.map((image, i) => (
<img
src={image.paths.thumbnail ?? ""}
alt=""
className={cx(CLASSNAME_NAVIMAGE, {
[CLASSNAME_NAVSELECTED]: i === index.current,
})}
onClick={(e: React.MouseEvent) => selectIndex(e, i)}
role="presentation"
loading="lazy"
key={image.paths.thumbnail}
/>
));
const currentIndex = index.current === null ? initialIndex : index.current;
const element = isVisible ? (
<div
className={CLASSNAME}
role="presentation"
ref={containerRef}
onClick={handleClose}
>
{images.length > 0 && !isLoading && !isSwitchingPage ? (
<>
<div className={CLASSNAME_HEADER}>
<div className={CLASSNAME_INDICATOR}>
<span>{pageHeader}</span>
<b ref={indicatorRef}>
{`${currentIndex + 1} / ${images.length}`}
</b>
</div>
{document.fullscreenEnabled && (
<Button
variant="link"
onClick={toggleFullscreen}
title="Toggle Fullscreen"
>
<Icon icon="expand" />
</Button>
)}
<Button
variant="link"
onClick={() => close()}
title="Close Lightbox"
>
<Icon icon="times" />
</Button>
</div>
<div className={CLASSNAME_DISPLAY} onTouchStart={handleTouchStart}>
{images.length > 1 && (
<Button
variant="link"
onClick={handleLeft}
className={`${CLASSNAME_NAVBUTTON} d-none d-lg-block`}
>
<Icon icon="chevron-left" />
</Button>
)}
<div
className={cx(CLASSNAME_CAROUSEL, {
[CLASSNAME_INSTANT]: instantTransition,
})}
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)"
/>
<img src={image.paths.thumbnail ?? ""} alt="" />
</picture>
</div>
))}
</div>
{images.length > 1 && (
<Button
variant="link"
onClick={handleRight}
className={`${CLASSNAME_NAVBUTTON} d-none d-lg-block`}
>
<Icon icon="chevron-right" />
</Button>
)}
</div>
{showNavigation && !isFullscreen && images.length > 1 && (
<div className={CLASSNAME_NAV} ref={navRef}>
<Button
variant="link"
onClick={() => setIndex(images.length - 1)}
className={CLASSNAME_NAVBUTTON}
>
<Icon icon="arrow-left" className="mr-4" />
</Button>
{navItems}
<Button
variant="link"
onClick={() => setIndex(0)}
className={CLASSNAME_NAVBUTTON}
>
<Icon icon="arrow-right" className="ml-4" />
</Button>
</div>
)}
</>
) : (
<LoadingIndicator />
)}
</div>
) : (
<></>
);
return element;
};

View File

@@ -0,0 +1,54 @@
import React, { useCallback, useState } from "react";
import * as GQL from "src/core/generated-graphql";
import { LightboxComponent } from "./Lightbox";
type Image = Pick<GQL.Image, "paths">;
export interface IState {
images: Image[];
isVisible: boolean;
isLoading: boolean;
showNavigation: boolean;
initialIndex?: number;
pageCallback?: (direction: number) => boolean;
pageHeader?: string;
}
interface IContext {
setLightboxState: (state: Partial<IState>) => void;
}
export const LightboxContext = React.createContext<IContext>({
setLightboxState: () => {},
});
const Lightbox: React.FC = ({ children }) => {
const [lightboxState, setLightboxState] = useState<IState>({
images: [],
isVisible: false,
isLoading: false,
showNavigation: true,
});
const setPartialState = useCallback(
(state: Partial<IState>) => {
setLightboxState((currentState: IState) => ({
...currentState,
...state,
}));
},
[setLightboxState]
);
return (
<LightboxContext.Provider value={{ setLightboxState: setPartialState }}>
{children}
{lightboxState.isVisible && (
<LightboxComponent
{...lightboxState}
hide={() => setLightboxState({ ...lightboxState, isVisible: false })}
/>
)}
</LightboxContext.Provider>
);
};
export default Lightbox;

View File

@@ -0,0 +1,73 @@
import { useCallback, useContext, useEffect } from "react";
import * as GQL from "src/core/generated-graphql";
import { LightboxContext, IState } from "./context";
export const useLightbox = (state: Partial<Omit<IState, "isVisible">>) => {
const { setLightboxState } = useContext(LightboxContext);
useEffect(() => {
setLightboxState({
images: state.images,
showNavigation: state.showNavigation,
pageCallback: state.pageCallback,
initialIndex: state.initialIndex,
pageHeader: state.pageHeader,
});
}, [
setLightboxState,
state.images,
state.showNavigation,
state.pageCallback,
state.initialIndex,
state.pageHeader,
]);
const show = useCallback(
(index?: number) => {
setLightboxState({
initialIndex: index,
isVisible: true,
});
},
[setLightboxState]
);
return show;
};
export const useGalleryLightbox = (id: string) => {
const { setLightboxState } = useContext(LightboxContext);
const [fetchGallery, { data }] = GQL.useFindGalleryLazyQuery({
variables: { id },
});
useEffect(() => {
if (data)
setLightboxState({
images: data.findGallery?.images ?? [],
isLoading: false,
isVisible: true,
});
}, [setLightboxState, data]);
const show = () => {
if (data)
setLightboxState({
isLoading: false,
isVisible: true,
images: data.findGallery?.images ?? [],
pageCallback: undefined,
pageHeader: undefined,
});
else {
setLightboxState({
isLoading: true,
isVisible: true,
pageCallback: undefined,
pageHeader: undefined,
});
fetchGallery();
}
};
return show;
};

View File

@@ -0,0 +1,2 @@
export * from "./context";
export * from "./hooks";

View File

@@ -0,0 +1,124 @@
.Lightbox {
background-color: rgba(20, 20, 20, 0.8);
bottom: 0;
display: flex;
flex-direction: column;
left: 0;
position: fixed;
right: 0;
top: 0;
z-index: 1040;
.fa-icon {
path {
fill: white;
}
opacity: 0.4;
&:hover {
opacity: 1;
}
}
&-header {
align-items: center;
display: flex;
flex-shrink: 0;
height: 4rem;
&-indicator {
display: flex;
flex-direction: column;
margin-left: 49%;
margin-right: auto;
text-align: center;
}
.fa-icon {
height: 1.5rem;
opacity: 1;
width: 1.5rem;
}
}
&-display {
display: flex;
height: 100%;
justify-content: space-between;
margin-bottom: 2rem;
position: relative;
}
&-carousel {
display: flex;
height: 100%;
position: absolute;
transition: left 400ms;
&-instant {
transition-duration: 0ms;
}
&-image {
content-visibility: auto;
display: flex;
height: 100%;
width: 100vw;
picture {
display: flex;
height: 100%;
margin: auto;
}
img {
margin: auto;
max-height: 100%;
max-width: 100%;
object-fit: contain;
}
}
}
&-navbutton {
z-index: 1045;
.fa-icon {
height: 4rem;
width: 4rem;
}
&:focus {
box-shadow: none;
}
&:hover {
filter: drop-shadow(2px 2px 2px black);
}
}
&-nav {
display: flex;
flex-direction: row;
flex-shrink: 0;
height: 10rem;
margin: 0 auto 2rem 0;
padding: 0 10rem;
position: relative;
transition: left 400ms;
@media (max-height: 800px) {
display: none;
}
&-selected {
box-shadow: 0 0 0 6px white;
}
&-image {
cursor: pointer;
height: 100%;
margin-right: 1rem;
}
}
}