Image lightbox for chat attachments (#5441)

* add image lightbox for chat attachments

* wrap lightbox image triggers in button elements

* add images to dependency array

* add jsdoc to ChatAttachments and remove filter
This commit is contained in:
Sean Hatfield
2026-04-14 19:47:48 -07:00
committed by GitHub
parent 1cea4df8e6
commit 6ca2d5235d
4 changed files with 164 additions and 14 deletions

View File

@@ -12,6 +12,7 @@ import { FullScreenLoader } from "./components/Preloader";
import { ThemeProvider } from "./ThemeContext";
import { PWAModeProvider } from "./PWAContext";
import KeyboardShortcutsHelp from "@/components/KeyboardShortcutsHelp";
import ImageLightbox from "@/components/ImageLightbox";
import { ErrorBoundary } from "react-error-boundary";
import ErrorBoundaryFallback from "./components/ErrorBoundaryFallback";
@@ -33,6 +34,7 @@ export default function App() {
<Outlet />
<ToastContainer />
<KeyboardShortcutsHelp />
<ImageLightbox />
</I18nextProvider>
</PfpProvider>
</LogoProvider>

View File

@@ -0,0 +1,115 @@
import { useState, useEffect } from "react";
import { createPortal } from "react-dom";
import { X, CaretLeft, CaretRight } from "@phosphor-icons/react";
const OPEN_EVENT = "open-image-lightbox";
/**
* Opens the image lightbox from anywhere in the app.
* @param {{contentString: string, name: string}[]} images
* @param {number} initialIndex
*/
export function openImageLightbox(images, initialIndex = 0) {
window.dispatchEvent(
new CustomEvent(OPEN_EVENT, { detail: { images, initialIndex } })
);
}
export default function ImageLightbox() {
const [images, setImages] = useState(null);
const [currentIndex, setCurrentIndex] = useState(0);
useEffect(() => {
function handleOpen(e) {
setImages(e.detail.images);
setCurrentIndex(e.detail.initialIndex);
}
window.addEventListener(OPEN_EVENT, handleOpen);
return () => window.removeEventListener(OPEN_EVENT, handleOpen);
}, []);
function close() {
setImages(null);
}
function handlePrevious() {
setCurrentIndex((prev) => (prev > 0 ? prev - 1 : images.length - 1));
}
function handleNext() {
setCurrentIndex((prev) => (prev < images.length - 1 ? prev + 1 : 0));
}
useEffect(() => {
if (!images) return;
function handleKeyDown(e) {
if (e.key === "Escape") close();
else if (e.key === "ArrowLeft") handlePrevious();
else if (e.key === "ArrowRight") handleNext();
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [images]);
if (!images || images.length === 0) return null;
const safeIndex = Math.min(currentIndex, images.length - 1);
const currentImage = images[safeIndex];
if (!currentImage) return null;
return createPortal(
<div
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/90"
onClick={close}
>
<button
type="button"
onClick={close}
className="absolute top-4 right-4 p-2 text-white light:text-white hover:text-white/70 transition-colors rounded-full bg-white/10 hover:bg-white/20 border-none cursor-pointer"
aria-label="Close lightbox"
>
<X size={24} weight="bold" />
</button>
{images.length > 1 && (
<>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handlePrevious();
}}
className="absolute left-4 p-3 text-white light:text-white hover:text-white/70 transition-colors rounded-full bg-white/10 hover:bg-white/20 border-none cursor-pointer"
aria-label="Previous image"
>
<CaretLeft size={24} weight="bold" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleNext();
}}
className="absolute right-4 p-3 text-white light:text-white hover:text-white/70 transition-colors rounded-full bg-white/10 hover:bg-white/20 border-none cursor-pointer"
aria-label="Next image"
>
<CaretRight size={24} weight="bold" />
</button>
</>
)}
<img
src={currentImage.contentString}
alt={currentImage.name || "attachment"}
className="max-w-[90vw] max-h-[90vh] object-contain"
onClick={(e) => e.stopPropagation()}
/>
{images.length > 1 && (
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 text-white/70 text-sm bg-black/50 px-3 py-1 rounded-full">
{safeIndex + 1} / {images.length}
</div>
)}
</div>,
document.getElementById("root")
);
}

View File

@@ -19,6 +19,7 @@ import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { chatQueryRefusalResponse } from "@/utils/chat";
import HistoricalOutputs from "./HistoricalOutputs";
import { openImageLightbox } from "@/components/ImageLightbox";
const HistoricalMessage = ({
uuid = v4(),
@@ -205,17 +206,27 @@ export default memo(
}
);
/**
* Currently only renders image attachments as clickable thumbnails that open in the lightbox.
* Other attachment types may be supported here in the future.
*/
function ChatAttachments({ attachments = [] }) {
if (!attachments.length) return null;
return (
<div className="flex flex-wrap gap-4 mt-4">
{attachments.map((item) => (
<img
alt={`Attachment: ${item.name}`}
{attachments.map((item, index) => (
<button
type="button"
key={item.name}
src={item.contentString}
className="w-[120px] h-[120px] object-cover rounded-lg"
/>
onClick={() => openImageLightbox(attachments, index)}
className="p-0 border-none bg-transparent cursor-pointer hover:opacity-80 transition-opacity"
>
<img
alt={`Attachment: ${item.name}`}
src={item.contentString}
className="w-[120px] h-[120px] object-cover rounded-lg"
/>
</button>
))}
</div>
);

View File

@@ -11,6 +11,7 @@ import {
X,
} from "@phosphor-icons/react";
import { REMOVE_ATTACHMENT_EVENT } from "../../DnDWrapper";
import { openImageLightbox } from "@/components/ImageLightbox";
/**
* @param {{attachments: import("../../DnDWrapper").Attachment[]}}
@@ -18,10 +19,25 @@ import { REMOVE_ATTACHMENT_EVENT } from "../../DnDWrapper";
*/
export default function AttachmentManager({ attachments }) {
if (attachments.length === 0) return null;
function handleImageClick(attachment) {
const imageAttachments = attachments
.filter((a) => a.type === "attachment" && a.contentString)
.map((a) => ({ contentString: a.contentString, name: a.file.name }));
const idx = imageAttachments.findIndex(
(img) => img.name === attachment.file?.name
);
if (idx !== -1) openImageLightbox(imageAttachments, idx);
}
return (
<div className="flex flex-wrap gap-2 mt-2 mb-4">
{attachments.map((attachment) => (
<AttachmentItem key={attachment.uid} attachment={attachment} />
<AttachmentItem
key={attachment.uid}
attachment={attachment}
onImageClick={() => handleImageClick(attachment)}
/>
))}
</div>
);
@@ -30,7 +46,7 @@ export default function AttachmentManager({ attachments }) {
/**
* @param {{attachment: import("../../DnDWrapper").Attachment}}
*/
function AttachmentItem({ attachment }) {
function AttachmentItem({ attachment, onImageClick }) {
const { uid, file, status, error, document, type, contentString } =
attachment;
const { iconBgColor, Icon } = displayFromFile(file);
@@ -115,12 +131,18 @@ function AttachmentItem({ attachment }) {
<X size={10} className="flex-shrink-0" />
</button>
</div>
<img
alt={`Preview of ${file.name}`}
src={contentString}
style={{ objectFit: "cover", objectPosition: "center" }}
className={`${iconBgColor} w-[40px] h-[40px] rounded-lg flex items-center justify-center`}
/>
<button
type="button"
onClick={onImageClick}
className="p-0 border-none bg-transparent cursor-pointer"
>
<img
alt={`Preview of ${file.name}`}
src={contentString}
style={{ objectFit: "cover", objectPosition: "center" }}
className={`${iconBgColor} w-[40px] h-[40px] rounded-lg flex items-center justify-center`}
/>
</button>
</div>
);
}