study / web /src /components /image-lightbox.tsx
xiaolongmr
Use Fancybox for image lightbox
264ce60
Raw History Blame Contribute Delete
5.52 kB
"use client";
import { useEffect, useRef } from "react";
type LightboxImage = {
id: string;
src: string;
sizeLabel?: string;
dimensions?: string;
};
type ImageLightboxProps = {
images: LightboxImage[];
currentIndex: number;
open: boolean;
onOpenChange: (open: boolean) => void;
onIndexChange: (index: number) => void;
};
type FancyboxItem = {
src: string;
type: "image";
caption?: string;
thumb?: string;
};
type FancyboxInstance = {
close: () => void;
getSlide?: () => { index?: number } | undefined;
};
type FancyboxGlobal = {
show: (
items: FancyboxItem[],
options?: {
startIndex?: number;
Hash?: false;
closeExisting?: boolean;
Carousel?: { infinite?: boolean };
on?: {
close?: () => void;
"Carousel.change"?: (fancyboxRef: FancyboxInstance) => void;
};
},
) => FancyboxInstance;
};
declare global {
interface Window {
Fancybox?: FancyboxGlobal;
}
}
const FANCYBOX_SCRIPT_URL = "https://cdn.jsdelivr.net/npm/@fancyapps/ui@6.1.14/dist/fancybox/fancybox.umd.js";
const FANCYBOX_STYLE_URL = "https://cdn.jsdelivr.net/npm/@fancyapps/ui@6.1.14/dist/fancybox/fancybox.css";
let fancyboxLoader: Promise<FancyboxGlobal> | null = null;
function escapeHtml(value: string) {
return value.replace(/[&<>"']/g, (char) => {
switch (char) {
case "&":
return "&amp;";
case "<":
return "&lt;";
case ">":
return "&gt;";
case "\"":
return "&quot;";
case "'":
return "&#39;";
default:
return char;
}
});
}
function getCaption(image: LightboxImage) {
const parts = [image.sizeLabel, image.dimensions].filter(Boolean);
return parts.length ? escapeHtml(parts.join(" · ")) : undefined;
}
function ensureFancyboxStyle() {
if (document.querySelector(`link[href="${FANCYBOX_STYLE_URL}"]`)) {
return;
}
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = FANCYBOX_STYLE_URL;
document.head.appendChild(link);
}
function loadFancybox() {
if (typeof window === "undefined") {
return Promise.reject(new Error("Fancybox can only be loaded in the browser"));
}
ensureFancyboxStyle();
if (window.Fancybox) {
return Promise.resolve(window.Fancybox);
}
if (fancyboxLoader) {
return fancyboxLoader;
}
fancyboxLoader = new Promise<FancyboxGlobal>((resolve, reject) => {
const existingScript = document.querySelector<HTMLScriptElement>(`script[src="${FANCYBOX_SCRIPT_URL}"]`);
const resolveLoaded = () => {
if (window.Fancybox) {
resolve(window.Fancybox);
} else {
reject(new Error("Fancybox loaded but did not expose window.Fancybox"));
}
};
if (existingScript) {
existingScript.addEventListener("load", resolveLoaded, { once: true });
existingScript.addEventListener("error", () => reject(new Error("Failed to load Fancybox")), { once: true });
return;
}
const script = document.createElement("script");
script.src = FANCYBOX_SCRIPT_URL;
script.async = true;
script.onload = resolveLoaded;
script.onerror = () => reject(new Error("Failed to load Fancybox"));
document.body.appendChild(script);
});
return fancyboxLoader;
}
function clampIndex(index: number, length: number) {
if (length <= 0) return 0;
return Math.max(0, Math.min(index, length - 1));
}
export function ImageLightbox({
images,
currentIndex,
open,
onOpenChange,
onIndexChange,
}: ImageLightboxProps) {
const instanceRef = useRef<FancyboxInstance | null>(null);
const isClosingRef = useRef(false);
useEffect(() => {
if (!open) {
if (instanceRef.current && !isClosingRef.current) {
isClosingRef.current = true;
instanceRef.current.close();
instanceRef.current = null;
isClosingRef.current = false;
}
return;
}
if (images.length === 0) {
onOpenChange(false);
return;
}
if (instanceRef.current) {
return;
}
let cancelled = false;
void loadFancybox()
.then((Fancybox) => {
if (cancelled) return;
const items = images.map((image) => ({
src: image.src,
type: "image" as const,
thumb: image.src,
caption: getCaption(image),
}));
instanceRef.current = Fancybox.show(items, {
startIndex: clampIndex(currentIndex, images.length),
Hash: false,
closeExisting: true,
Carousel: {
infinite: images.length > 1,
},
on: {
close: () => {
instanceRef.current = null;
isClosingRef.current = false;
onOpenChange(false);
},
"Carousel.change": (fancyboxRef) => {
const nextIndex = fancyboxRef.getSlide?.()?.index;
if (typeof nextIndex === "number") {
onIndexChange(clampIndex(nextIndex, images.length));
}
},
},
});
})
.catch((error) => {
console.error(error);
onOpenChange(false);
});
return () => {
cancelled = true;
};
}, [open, images, currentIndex, onOpenChange, onIndexChange]);
useEffect(() => {
return () => {
if (instanceRef.current) {
isClosingRef.current = true;
instanceRef.current.close();
instanceRef.current = null;
}
};
}, []);
return null;
}