'use client'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { Dialog, DialogBackdrop, DialogPanel } from '@headlessui/react'; import { createGlobalState } from 'react-global-hooks'; import { SquareDashed, X } from 'lucide-react'; import classNames from 'classnames'; import { BoundingBoxEditor, extractBoxes } from './BoundingBoxOverlay'; import IdeogramCaptionSidebar, { isIdeogramCaption } from './IdeogramCaptionSidebar'; export interface PromptBoxEditorState { prompt: string; // current prompt text (plain or Ideogram JSON) aspectRatio?: string; // 'W:H' to shape the placeholder; defaults to 1:1 title?: string; onApply: (newPrompt: string) => void; } export const promptBoxEditorState = createGlobalState(null); export const openPromptBoxEditor = (state: PromptBoxEditorState) => { promptBoxEditorState.set(state); }; function safeParse(text: string): any { try { return JSON.parse(text); } catch { return null; } } // Empty Ideogram caption skeleton, used to convert a plain prompt into a // structured one so boxes/fields become editable. function seedCaption(highLevel: string): string { return JSON.stringify( { high_level_description: highLevel.trim(), style_description: { aesthetics: '', lighting: '', photo: '', medium: '' }, compositional_deconstruction: { background: '', elements: [] }, }, null, 2, ); } // Parse a 'W:H' string into a CSS aspect-ratio value. Falls back to 1/1. function aspectStyle(aspectRatio?: string): string { if (!aspectRatio) return '1 / 1'; const m = aspectRatio.trim().match(/^(\d+)\s*[:/]\s*(\d+)$/); if (!m) return '1 / 1'; const w = parseInt(m[1], 10); const h = parseInt(m[2], 10); if (!w || !h) return '1 / 1'; return `${w} / ${h}`; } const PromptBoxEditorModal: React.FC = () => { const [mounted, setMounted] = useState(false); const [modalInfo, setModalInfo] = promptBoxEditorState.use(); const isOpen = modalInfo !== null; const [caption, setCaption] = useState(''); const [savedCaption, setSavedCaption] = useState(''); const [selectedBoxIndex, setSelectedBoxIndex] = useState(null); const [isDrawing, setIsDrawing] = useState(false); const [showBoxes, setShowBoxes] = useState(true); useEffect(() => setMounted(true), []); // Seed local state whenever the modal opens with a new prompt. useEffect(() => { const p = modalInfo?.prompt ?? ''; setCaption(p); setSavedCaption(p); setSelectedBoxIndex(null); setIsDrawing(false); setShowBoxes(true); }, [modalInfo]); const isIdeogram = useMemo(() => isIdeogramCaption(caption), [caption]); const isDirty = caption.trim() !== savedCaption.trim(); const apply = useCallback(() => { if (!modalInfo) return; modalInfo.onApply(caption); setSavedCaption(caption); }, [modalInfo, caption]); const onClose = useCallback(() => { // Persist edits on close so nothing is lost. if (modalInfo && caption.trim() !== savedCaption.trim()) { modalInfo.onApply(caption); } setModalInfo(null); }, [modalInfo, caption, savedCaption, setModalInfo]); // Mutate the caption JSON's element array, updating local state only. Returns // whatever the mutator returns. const editCaption = useCallback( (fn: (elements: any[], data: any) => any): any => { const data = safeParse(caption); if (!data) return undefined; const elements = data?.compositional_deconstruction?.elements; if (!Array.isArray(elements)) return undefined; const result = fn(elements, data); setCaption(JSON.stringify(data, null, 2)); return result; }, [caption], ); const handleBoxChange = useCallback( (elementIndex: number, box: { y1: number; x1: number; y2: number; x2: number }) => { editCaption(els => { if (els[elementIndex]) els[elementIndex].bbox = [box.y1, box.x1, box.y2, box.x2]; }); }, [editCaption], ); const handleCreateBox = useCallback( (box: { y1: number; x1: number; y2: number; x2: number }) => { const newIndex = editCaption(els => { els.push({ type: 'obj', bbox: [box.y1, box.x1, box.y2, box.x2], desc: '' }); return els.length - 1; }); setSelectedBoxIndex(typeof newIndex === 'number' ? newIndex : null); setIsDrawing(false); }, [editCaption], ); const editBoxes = useMemo(() => extractBoxes(safeParse(caption)), [caption]); if (!mounted) return null; return createPortal(
{/* Layout preview area: a gradient stand-in for the (nonexistent) image */}
{/* subtle grid so box positions are readable */}
{!isIdeogram && (
No image — this is a layout preview.
Convert to a structured caption to place boxes.
)} {isIdeogram && showBoxes && ( )}
{/* Controls over the preview */}
{isIdeogram && ( )}
{/* Right sidebar: structured caption editor (or plain prompt) */}
{modalInfo?.title ?? 'Edit Prompt'}
{isIdeogram ? ( { setSelectedBoxIndex(i); if (i != null) setShowBoxes(true); }} isDrawing={isDrawing} onToggleDrawing={() => setIsDrawing(d => !d)} onSave={apply} isDirty={isDirty} /> ) : (