show all images in lightbox

This commit is contained in:
hzrd149 2023-08-16 17:03:19 -05:00
parent 018c917b4b
commit 07f67ccc5a
8 changed files with 225 additions and 83 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Show all images in lightbox

View File

@ -1,10 +1,33 @@
import { Box, Image, ImageProps, Link, SimpleGrid, useDisclosure } from "@chakra-ui/react";
import { useRef } from "react";
import { Box, Image, ImageProps, Link, LinkProps, SimpleGrid, useDisclosure } from "@chakra-ui/react";
import appSettings from "../../services/settings/app-settings";
import { ImageGalleryLink } from "../image-gallery";
import { useTrusted } from "../../providers/trust";
import OpenGraphCard from "../open-graph-card";
import { EmbedableContent, defaultGetLocation } from "../../helpers/embeds";
import { matchLink } from "../../helpers/regexp";
import { useRegisterSlide } from "../lightbox-provider";
export const ImageGalleryLink = ({ children, href, ...props }: Omit<LinkProps, "onClick">) => {
const ref = useRef<HTMLAnchorElement | null>(null);
const { show } = useRegisterSlide(ref, href ? { type: "image", src: href } : undefined);
return (
<Link
{...props}
href={href}
onClick={(e) => {
if (href) {
e.preventDefault();
show();
}
}}
ref={ref}
>
{children}
</Link>
);
};
const BlurredImage = (props: ImageProps) => {
const { isOpen, onOpen } = useDisclosure();

View File

@ -1,65 +0,0 @@
import { PropsWithChildren, createContext, forwardRef, useCallback, useContext, useMemo, useState } from "react";
import { LinkProps, Link, useDisclosure } from "@chakra-ui/react";
import Lightbox, { SlideImage } from "yet-another-react-lightbox";
import Zoom from "yet-another-react-lightbox/plugins/zoom";
import Counter from "yet-another-react-lightbox/plugins/counter";
import Download from "yet-another-react-lightbox/plugins/download";
import "yet-another-react-lightbox/styles.css";
import "yet-another-react-lightbox/plugins/counter.css";
const GalleryContext = createContext({
isOpen: false,
openImage(url: string) {},
});
export function useGalleryContext() {
return useContext(GalleryContext);
}
export const ImageGalleryLink = forwardRef(({ children, href, ...props }: Omit<LinkProps, "onClick">, ref) => {
const { openImage } = useGalleryContext();
return (
<Link
{...props}
href={href}
onClick={(e) => {
if (href) {
e.preventDefault();
openImage(href);
}
}}
ref={ref}
>
{children}
</Link>
);
});
export function ImageGalleryProvider({ children }: PropsWithChildren) {
const open = useDisclosure();
const [slides, setSlides] = useState<SlideImage[]>([]);
const openImage = useCallback(
(url: string) => {
setSlides([{ src: url }]);
open.onOpen();
},
[setSlides, open.onOpen]
);
const context = useMemo(() => ({ isOpen: open.isOpen, openImage }), [open.isOpen, openImage]);
return (
<GalleryContext.Provider value={context}>
{children}
<Lightbox
open={open.isOpen}
slides={slides}
close={open.onClose}
plugins={[Zoom, Counter, Download]}
zoom={{ scrollToZoom: true, maxZoomPixelRatio: 4, wheelZoomDistanceFactor: 100 }}
/>
</GalleryContext.Provider>
);
}

View File

@ -0,0 +1,162 @@
import {
DependencyList,
MutableRefObject,
PropsWithChildren,
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useDisclosure } from "@chakra-ui/react";
import { useUnmount } from "react-use";
import Lightbox, { Slide } from "yet-another-react-lightbox";
import Zoom from "yet-another-react-lightbox/plugins/zoom";
import Counter from "yet-another-react-lightbox/plugins/counter";
import Download from "yet-another-react-lightbox/plugins/download";
import "yet-another-react-lightbox/styles.css";
import "yet-another-react-lightbox/plugins/counter.css";
type RefType = MutableRefObject<HTMLElement | null>;
function getElementPath(element: HTMLElement): HTMLElement[] {
if (!element.parentElement) return [element];
return [...getElementPath(element.parentElement), element];
}
function comparePaths(a: HTMLElement[] | null, b: HTMLElement[] | null) {
if (a && !b) return -1;
if (!b && a) return 1;
if (!a || !b) return 0;
for (let i = 0; i < Math.min(a.length, b.length); i++) {
if (a[i] !== b[i] && a[i].parentElement === b[i].parentElement) {
const parent = a[i].parentElement;
if (!parent) return 0;
const children = Array.from(parent.children);
return Math.sign(children.indexOf(a[i]) - children.indexOf(b[i]));
}
}
return 0;
}
const LightboxContext = createContext({
isOpen: false,
removeSlide(ref: RefType) {},
showSlide(ref: RefType) {},
addSlide(ref: RefType, slide: Slide) {},
});
export function useLightbox() {
return useContext(LightboxContext);
}
export function useRegisterSlide(ref?: RefType, slide?: Slide, watch: DependencyList[] = []) {
const { showSlide, addSlide, removeSlide } = useLightbox();
const show = useCallback(() => {
if (ref) showSlide(ref);
}, [ref, showSlide]);
useEffect(() => {
if (ref && slide) addSlide(ref, slide);
}, [ref, ...watch]);
useUnmount(() => {
if (ref) removeSlide(ref);
});
return { show };
}
type DynamicSlide = {
ref: RefType;
slide: Slide;
};
const refPaths = new WeakMap<RefType, HTMLElement[]>();
function getRefPath(ref: RefType) {
if (ref.current === null) return null;
const cache = refPaths.get(ref);
if (cache) return cache;
const path = getElementPath(ref.current);
refPaths.set(ref, path);
return path;
}
export function LightboxProvider({ children }: PropsWithChildren) {
const lightbox = useDisclosure();
const [index, setIndex] = useState(0);
const [slides, setSlides] = useState<DynamicSlide[]>([]);
const orderedSlides = useRef<DynamicSlide[]>([]);
orderedSlides.current = Array.from(Object.values(slides)).sort((a, b) =>
comparePaths(getRefPath(a.ref), getRefPath(b.ref))
);
const addSlide = useCallback(
(ref: RefType, slide: Slide) => {
setSlides((arr) => {
if (arr.some((s) => s.ref === ref)) {
return arr.map((s) => (s.ref === ref ? { ref, slide } : s));
}
return arr.concat({ ref, slide });
});
},
[setSlides]
);
const removeSlide = useCallback(
(ref: RefType) => {
setSlides((arr) => arr.filter((s) => s.ref !== ref));
},
[setSlides]
);
const showSlide = useCallback(
(ref: RefType) => {
for (let i = 0; i < orderedSlides.current.length; i++) {
if (orderedSlides.current[i].ref === ref) {
// set slide index
setIndex(i);
lightbox.onOpen();
return;
}
}
// else select first image
setIndex(0);
lightbox.onOpen();
},
[lightbox.onOpen, setIndex]
);
const context = useMemo(
() => ({ isOpen: lightbox.isOpen, removeSlide, addSlide, showSlide }),
[lightbox.isOpen, removeSlide, addSlide, showSlide]
);
const lightboxSlides = useMemo(() => orderedSlides.current.map((s) => s.slide), [orderedSlides.current, slides]);
const handleView = useCallback(
({ index }: { index: number }) => {
setIndex(index);
},
[setIndex]
);
return (
<LightboxContext.Provider value={context}>
{children}
<Lightbox
index={index}
open={lightbox.isOpen}
slides={lightboxSlides}
close={lightbox.onClose}
plugins={[Zoom, Counter, Download]}
zoom={{ scrollToZoom: true, maxZoomPixelRatio: 4, wheelZoomDistanceFactor: 500 }}
controller={{ closeOnBackdropClick: true, closeOnPullDown: true }}
on={{ view: handleView }}
/>
</LightboxContext.Provider>
);
}

View File

@ -19,7 +19,7 @@ import {
renderOpenGraphUrl,
embedImageGallery,
} from "../embed-types";
import { ImageGalleryProvider } from "../image-gallery";
import { LightboxProvider } from "../lightbox-provider";
import { renderRedditUrl } from "../embed-types/reddit";
function buildContents(event: NostrEvent | DraftNostrEvent) {
@ -62,10 +62,10 @@ export const NoteContents = React.memo(({ event, ...props }: NoteContentsProps &
const content = buildContents(event);
return (
<ImageGalleryProvider>
<LightboxProvider>
<Box whiteSpace="pre-wrap" {...props}>
{content}
</Box>
</ImageGalleryProvider>
</LightboxProvider>
);
});

View File

@ -2,7 +2,7 @@ import { useCallback } from "react";
import { Flex, FlexProps, SimpleGrid } from "@chakra-ui/react";
import IntersectionObserverProvider from "../../providers/intersection-observer";
import GenericNoteTimeline from "./generic-note-timeline";
import { ImageGalleryProvider } from "../image-gallery";
import { LightboxProvider } from "../lightbox-provider";
import MediaTimeline from "./media-timeline";
import { TimelineLoader } from "../../classes/timeline-loader";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
@ -44,11 +44,11 @@ export default function TimelinePage({
case "images":
return (
<ImageGalleryProvider>
<LightboxProvider>
<SimpleGrid columns={[1, 2, 2, 3, 4, 5]} gap="4">
<MediaTimeline timeline={timeline} />
</SimpleGrid>
</ImageGalleryProvider>
</LightboxProvider>
);
case "health":

View File

@ -2,8 +2,8 @@ import React, { useMemo, useRef } from "react";
import { TimelineLoader } from "../../../classes/timeline-loader";
import useSubject from "../../../hooks/use-subject";
import { matchImageUrls } from "../../../helpers/regexp";
import { ImageGalleryLink, ImageGalleryProvider } from "../../image-gallery";
import { Box, IconButton } from "@chakra-ui/react";
import { LightboxProvider, useRegisterSlide } from "../../lightbox-provider";
import { Box, IconButton, Link } from "@chakra-ui/react";
import { useNavigate } from "react-router-dom";
import { useRegisterIntersectionEntity } from "../../../providers/intersection-observer";
import { getSharableNoteId } from "../../../helpers/nip19";
@ -19,9 +19,26 @@ const ImagePreview = React.memo(({ image }: { image: ImagePreview }) => {
const ref = useRef<HTMLDivElement | null>(null);
useRegisterIntersectionEntity(ref, image.eventId);
const { show } = useRegisterSlide(ref, { type: "image", src: image.src });
return (
<ImageGalleryLink href={image.src} position="relative" ref={ref}>
<Box aspectRatio={1} backgroundImage={`url(${image.src})`} backgroundSize="cover" backgroundPosition="center" />
<Link
href={image.src}
position="relative"
onClick={(e) => {
if (image.src) {
e.preventDefault();
show();
}
}}
>
<Box
aspectRatio={1}
backgroundImage={`url(${image.src})`}
backgroundSize="cover"
backgroundPosition="center"
ref={ref}
/>
<IconButton
icon={<ExternalLinkIcon />}
aria-label="Open note"
@ -36,7 +53,7 @@ const ImagePreview = React.memo(({ image }: { image: ImagePreview }) => {
navigate(`/n/${getSharableNoteId(image.eventId)}`);
}}
/>
</ImageGalleryLink>
</Link>
);
});
@ -59,10 +76,10 @@ export default function MediaTimeline({ timeline }: { timeline: TimelineLoader }
}, [events]);
return (
<ImageGalleryProvider>
<LightboxProvider>
{images.map((image) => (
<ImagePreview key={image.eventId + "-" + image.index} image={image} />
))}
</ImageGalleryProvider>
</LightboxProvider>
);
}

View File

@ -21,7 +21,7 @@ import ZapModal from "../../../../components/zap-modal";
import { LightningIcon } from "../../../../components/icons";
import ChatMessage from "./chat-message";
import ZapMessage from "./zap-message";
import { ImageGalleryProvider } from "../../../../components/image-gallery";
import { LightboxProvider } from "../../../../components/lightbox-provider";
import IntersectionObserverProvider from "../../../../providers/intersection-observer";
import useUserLNURLMetadata from "../../../../hooks/use-user-lnurl-metadata";
import { useInvoiceModalContext } from "../../../../providers/invoice-modal";
@ -126,7 +126,7 @@ export default function StreamChat({
return (
<>
<IntersectionObserverProvider callback={callback} root={scrollBox}>
<ImageGalleryProvider>
<LightboxProvider>
<Card {...props} overflow="hidden" background={isChatLog ? "transparent" : undefined}>
{!isPopup && (
<CardHeader py="3" display="flex" justifyContent="space-between" alignItems="center">
@ -184,7 +184,7 @@ export default function StreamChat({
)}
</CardBody>
</Card>
</ImageGalleryProvider>
</LightboxProvider>
</IntersectionObserverProvider>
{zapModal.isOpen && (
<ZapModal