From d3d7a8a717e41337402c70ab3cb74098ce1ae8c0 Mon Sep 17 00:00:00 2001 From: jiajia Date: Mon, 18 May 2026 18:57:41 +0800 Subject: [PATCH] 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 --- .../__tests__/GenerationComposer.test.tsx | 28 +++++- .../composer/GenerationComposer.tsx | 86 ++++++++++++++++++- 2 files changed, 109 insertions(+), 5 deletions(-) diff --git a/src/components/__tests__/GenerationComposer.test.tsx b/src/components/__tests__/GenerationComposer.test.tsx index bf7cb1b8..e1ac7f90 100644 --- a/src/components/__tests__/GenerationComposer.test.tsx +++ b/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(); + + 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: [ diff --git a/src/components/composer/GenerationComposer.tsx b/src/components/composer/GenerationComposer.tsx index 9d3f6079..a4a16fa2 100644 --- a/src/components/composer/GenerationComposer.tsx +++ b/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(null); const referenceImageInputRef = useRef(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() {
- - + + {imageIndex + 1} {canEditReferenceImages && ( +
+ , + document.body + )} ); }