Browse Source

Let reference thumbnails open a full preview

The bottom generation composer now treats reference thumbnails as previewable assets: clicking a thumbnail opens a portal lightbox with the full image, backdrop/click-close behavior, and Escape close support. The remove button stops propagation so deletion remains distinct from preview opening.

Constraint: Keep the interaction inside the existing composer surface without introducing a shared modal dependency
Rejected: Reusing output-node lightbox directly | it is coupled to node media state and download/video behavior
Confidence: high
Scope-risk: narrow
Directive: Keep thumbnail click and remove click separated; do not nest buttons
Tested: npm run test:run -- src/components/__tests__/GenerationComposer.test.tsx
Tested: PROVIDER_MODE=popi NEXT_PUBLIC_PROVIDER_MODE=popi npm run build
Tested: Playwright uploaded a reference image, opened the preview dialog, and closed it with Escape
feature/add_agent_md
jiajia 2 months ago
parent
commit
d3d7a8a717
  1. 28
      src/components/__tests__/GenerationComposer.test.tsx
  2. 86
      src/components/composer/GenerationComposer.tsx

28
src/components/__tests__/GenerationComposer.test.tsx

@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { GenerationComposer } from "@/components/composer/GenerationComposer";
import { useWorkflowStore } from "@/store/workflowStore";
import type {
@ -617,6 +617,32 @@ describe("GenerationComposer", () => {
});
});
it("opens a reference image preview from its thumbnail", async () => {
render(<GenerationComposer />);
const imageFile = new File(["preview-reference"], "preview.png", { type: "image/png" });
fireEvent.change(screen.getByLabelText("Upload reference image"), {
target: { files: [imageFile] },
});
await waitFor(() => {
expect(screen.getByTitle("Reference image 1")).toBeInTheDocument();
});
fireEvent.click(screen.getByTitle("Reference image 1"));
const dialog = screen.getByRole("dialog", { name: "Reference image 1" });
expect(within(dialog).getByAltText("Reference image 1")).toHaveAttribute(
"src",
expect.stringMatching(/^data:image\/png;base64,/)
);
fireEvent.keyDown(window, { key: "Escape" });
await waitFor(() => {
expect(screen.queryByRole("dialog", { name: "Reference image 1" })).not.toBeInTheDocument();
});
});
it("keeps upstream text authoritative without overwriting the local fallback prompt", async () => {
useWorkflowStore.setState({
nodes: [

86
src/components/composer/GenerationComposer.tsx

@ -11,6 +11,7 @@ import {
type MouseEvent,
type ReactNode,
} from "react";
import { createPortal } from "react-dom";
import { useReactFlow } from "@xyflow/react";
import { useWorkflowStore } from "@/store/workflowStore";
import {
@ -480,6 +481,7 @@ export function GenerationComposer() {
const [assistMenu, setAssistMenu] = useState<"command" | "reference" | null>(null);
const [modelDialogOpen, setModelDialogOpen] = useState(false);
const [isComposerCollapsed, setIsComposerCollapsed] = useState(false);
const [referencePreviewIndex, setReferencePreviewIndex] = useState<number | null>(null);
const referenceImageInputRef = useRef<HTMLInputElement>(null);
const contextRef = useRef(context);
@ -660,6 +662,8 @@ export function GenerationComposer() {
const connectedReferenceImages = context.mode === "node-edit" ? connectedInputs?.images ?? [] : [];
const referenceImages = connectedReferenceImages.length > 0 ? connectedReferenceImages : draft.inputImages;
const primaryReference = referenceImages[0];
const previewReferenceImage =
referencePreviewIndex === null ? null : referenceImages[referencePreviewIndex] ?? null;
const canEditReferenceImages =
(context.mode === "empty-create" || context.mode === "node-edit") &&
connectedReferenceImages.length === 0;
@ -811,6 +815,23 @@ export function GenerationComposer() {
[canEditReferenceImages, markDraft]
);
useEffect(() => {
if (referencePreviewIndex !== null && referencePreviewIndex >= referenceImages.length) {
setReferencePreviewIndex(null);
}
}, [referenceImages.length, referencePreviewIndex]);
useEffect(() => {
if (!previewReferenceImage) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setReferencePreviewIndex(null);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [previewReferenceImage]);
useEffect(() => {
if (promptReadOnly) {
setAssistMenu(null);
@ -1146,17 +1167,45 @@ export function GenerationComposer() {
<div
key={`${imageIndex}-${referenceImage.slice(0, 24)}`}
className="relative h-14 w-14 shrink-0 overflow-hidden rounded-lg border border-neutral-700 bg-neutral-900"
title={t("composer.referenceImage", { index: imageIndex + 1 })}
>
<img src={referenceImage} alt="" className="h-full w-full object-cover" />
<span className="absolute left-1 top-1 rounded-full bg-neutral-900/80 px-1 text-[10px] text-neutral-200">
<button
type="button"
aria-label={t("composer.referenceImage", { index: imageIndex + 1 })}
title={t("composer.referenceImage", { index: imageIndex + 1 })}
onClick={() => setReferencePreviewIndex(imageIndex)}
className="group h-full w-full overflow-hidden rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400/70"
>
<img
src={referenceImage}
alt=""
className="h-full w-full object-cover transition-transform group-hover:scale-105"
/>
<span className="absolute inset-0 flex items-center justify-center bg-black/0 text-white transition-colors group-hover:bg-black/20">
<svg
className="h-4 w-4 opacity-0 transition-opacity group-hover:opacity-90"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M15 3h6v6" />
<path d="M21 3l-7 7" />
<path d="M9 21H3v-6" />
<path d="M3 21l7-7" />
</svg>
</span>
</button>
<span className="pointer-events-none absolute left-1 top-1 rounded-full bg-neutral-900/80 px-1 text-[10px] text-neutral-200">
{imageIndex + 1}
</span>
{canEditReferenceImages && (
<button
type="button"
aria-label={t("composer.removeReferenceImage", { index: imageIndex + 1 })}
onClick={() => removeReferenceImage(imageIndex)}
onClick={(event) => {
event.stopPropagation();
removeReferenceImage(imageIndex);
}}
className="absolute bottom-1 right-1 flex h-4 w-4 items-center justify-center rounded-full bg-black/70 text-[10px] text-neutral-200 transition-colors hover:bg-red-600 hover:text-white"
>
×
@ -1537,6 +1586,35 @@ export function GenerationComposer() {
onModelSelected={handleModelSelected}
/>
)}
{previewReferenceImage && typeof document !== "undefined" &&
createPortal(
<div
role="dialog"
aria-modal="true"
aria-label={t("composer.referenceImage", { index: (referencePreviewIndex ?? 0) + 1 })}
className="fixed inset-0 z-[120] flex items-center justify-center bg-black/90 p-6"
onClick={() => setReferencePreviewIndex(null)}
>
<div className="relative max-h-full max-w-full" onClick={(event) => event.stopPropagation()}>
<img
src={previewReferenceImage}
alt={t("composer.referenceImage", { index: (referencePreviewIndex ?? 0) + 1 })}
className="max-h-[90vh] max-w-[92vw] rounded-lg object-contain shadow-2xl shadow-black"
/>
<button
type="button"
onClick={() => setReferencePreviewIndex(null)}
aria-label={t("common.close")}
className="absolute right-3 top-3 flex h-8 w-8 items-center justify-center rounded bg-black/60 text-white transition-colors hover:bg-black/80 focus:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400/70"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>,
document.body
)}
</>
);
}

Loading…
Cancel
Save