@ -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
) }
< / >
) ;
}