mirror of
https://github.com/Mintplex-Labs/anything-llm
synced 2026-04-25 17:15:37 +02:00
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:
@@ -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>
|
||||
|
||||
115
frontend/src/components/ImageLightbox/index.jsx
Normal file
115
frontend/src/components/ImageLightbox/index.jsx
Normal 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")
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user