import { BaseBoxShapeUtil, Geometry2d, HTMLContainer, Rectangle2d, TLBaseShape, } from "tldraw" import React, { useState } from "react" import { getFalProxyConfig, getWorkerApiUrl } from "@/lib/clientConfig" import { StandardizedToolWrapper } from "@/components/StandardizedToolWrapper" import { usePinnedToView } from "@/hooks/usePinnedToView" import { useMaximize } from "@/hooks/useMaximize" // Feature flag: Set to false when fal.ai API is ready for production const USE_MOCK_API = false // fal.ai model to use for image generation const FAL_IMAGE_MODEL = "fal-ai/flux/dev" // Type definition for fal.ai API responses interface FalImageResponse { images?: Array<{ url: string; width?: number; height?: number; content_type?: string }> error?: string detail?: string } // Individual image entry in the history interface GeneratedImage { id: string prompt: string imageUrl: string timestamp: number } type IImageGen = TLBaseShape< "ImageGen", { w: number h: number prompt: string imageHistory: GeneratedImage[] // Thread of all generated images (newest first) isLoading: boolean loadingPrompt: string | null // The prompt currently being generated error: string | null endpointId?: string // Optional custom endpoint ID tags: string[] pinnedToView: boolean } > export class ImageGenShape extends BaseBoxShapeUtil { static override type = "ImageGen" as const // Image generation theme color: Blue static readonly PRIMARY_COLOR = "#007AFF" MIN_WIDTH = 300 as const MIN_HEIGHT = 300 as const DEFAULT_WIDTH = 400 as const DEFAULT_HEIGHT = 400 as const getDefaultProps(): IImageGen["props"] { return { w: this.DEFAULT_WIDTH, h: this.DEFAULT_HEIGHT, prompt: "", imageHistory: [], isLoading: false, loadingPrompt: null, error: null, tags: ['image', 'ai-generated'], pinnedToView: false, } } getGeometry(shape: IImageGen): Geometry2d { // Ensure minimum dimensions for proper hit testing return new Rectangle2d({ width: Math.max(shape.props.w, 1), height: Math.max(shape.props.h, 1), isFilled: true, }) } component(shape: IImageGen) { // Capture editor reference to avoid stale 'this' during drag operations const editor = this.editor const isSelected = editor.getSelectedShapeIds().includes(shape.id) // Pin to view functionality usePinnedToView(editor, shape.id, shape.props.pinnedToView) // Use the maximize hook for fullscreen functionality const { isMaximized, toggleMaximize } = useMaximize({ editor: editor, shapeId: shape.id, currentW: shape.props.w, currentH: shape.props.h, shapeType: 'ImageGen', }) const handlePinToggle = () => { editor.updateShape({ id: shape.id, type: "ImageGen", props: { pinnedToView: !shape.props.pinnedToView }, }) } const generateImage = async (prompt: string) => { // Store the prompt being used and clear any previous errors editor.updateShape({ id: shape.id, type: "ImageGen", props: { error: null, isLoading: true, loadingPrompt: prompt }, }) try { // Mock API mode: Return placeholder image for testing if (USE_MOCK_API) { await new Promise(resolve => setTimeout(resolve, 1500)) const mockImageUrl = `https://via.placeholder.com/512x512/4F46E5/FFFFFF?text=${encodeURIComponent(prompt.substring(0, 30))}` const currentShape = editor.getShape(shape.id) const currentHistory = currentShape?.props.imageHistory || [] const newImage: GeneratedImage = { id: `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, prompt: prompt, imageUrl: mockImageUrl, timestamp: Date.now() } editor.updateShape({ id: shape.id, type: "ImageGen", props: { imageHistory: [newImage, ...currentHistory], isLoading: false, loadingPrompt: null, error: null }, }) return } // Real API mode: Use fal.ai via worker proxy // fal.ai is faster and more reliable than RunPod for image generation const workerUrl = getWorkerApiUrl() const url = `${workerUrl}/api/fal/subscribe/${FAL_IMAGE_MODEL}` const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ prompt: prompt, image_size: "square_hd", num_inference_steps: 28, guidance_scale: 3.5, num_images: 1, enable_safety_checker: true }) }) if (!response.ok) { const errorText = await response.text() console.error("❌ ImageGen: Error response:", errorText) throw new Error(`HTTP error! status: ${response.status} - ${errorText}`) } const data = await response.json() as FalImageResponse // fal.ai returns { images: [{ url: "..." }] } if (data.images && data.images.length > 0) { const imageUrl = data.images[0].url const currentShape = editor.getShape(shape.id) const currentHistory = currentShape?.props.imageHistory || [] const newImage: GeneratedImage = { id: `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, prompt: prompt, imageUrl: imageUrl, timestamp: Date.now() } editor.updateShape({ id: shape.id, type: "ImageGen", props: { imageHistory: [newImage, ...currentHistory], isLoading: false, loadingPrompt: null, error: null }, }) } else if (data.error || data.detail) { throw new Error(`fal.ai API error: ${data.error || data.detail}`) } else { console.error("❌ ImageGen: Unexpected response structure:", JSON.stringify(data, null, 2)) throw new Error("No images returned from fal.ai API") } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) console.error("❌ ImageGen: Error:", errorMessage) let userFriendlyError = '' if (errorMessage.includes('FAL_API_KEY not configured')) { userFriendlyError = '❌ fal.ai API key not configured on server.' } else if (errorMessage.includes('401') || errorMessage.includes('403') || errorMessage.includes('Unauthorized')) { userFriendlyError = '❌ API key authentication failed.' } else if (errorMessage.includes('404')) { userFriendlyError = '❌ API endpoint not found.' } else if (errorMessage.includes('No images returned')) { userFriendlyError = '❌ Image generation completed but no image was returned.' } else { // Truncate very long error messages for UI display const maxLength = 500 if (errorMessage.length > maxLength) { userFriendlyError = `❌ Error: ${errorMessage.substring(0, maxLength)}...\n\n(Full error in console)` } else { userFriendlyError = `❌ Error: ${errorMessage}` } } editor.updateShape({ id: shape.id, type: "ImageGen", props: { isLoading: false, loadingPrompt: null, error: userFriendlyError }, }) } } const handleGenerate = () => { if (shape.props.prompt.trim() && !shape.props.isLoading) { generateImage(shape.props.prompt) editor.updateShape({ id: shape.id, type: "ImageGen", props: { prompt: "" }, }) } } const [isMinimized, setIsMinimized] = useState(false) const handleClose = () => { editor.deleteShape(shape.id) } const handleMinimize = () => { setIsMinimized(!isMinimized) } const handleTagsChange = (newTags: string[]) => { editor.updateShape({ id: shape.id, type: "ImageGen", props: { tags: newTags }, }) } return ( 🎨 Image Generator Generating... ) : undefined } > {/* Image Thread - scrollable history of generated images */} {/* Loading State - shown at top when generating */} {shape.props.isLoading && ( Generating image... {shape.props.loadingPrompt && ( Prompt: {shape.props.loadingPrompt} )} )} {/* Image History - each image as a card */} {shape.props.imageHistory.map((image, index) => ( {/* Image */} { console.error("❌ ImageGen: Failed to load image:", image.imageUrl) // Remove this image from history const newHistory = shape.props.imageHistory.filter(img => img.id !== image.id) editor.updateShape({ id: shape.id, type: "ImageGen", props: { imageHistory: newHistory }, }) }} /> {/* Prompt and action buttons */} Prompt: {image.prompt} { e.stopPropagation() try { const imageUrl = image.imageUrl if (!imageUrl) return // For base64 images, convert directly if (imageUrl.startsWith('data:')) { const response = await fetch(imageUrl) const blob = await response.blob() await navigator.clipboard.write([ new ClipboardItem({ [blob.type]: blob }) ]) } else { // For URLs, fetch the image first const response = await fetch(imageUrl) const blob = await response.blob() await navigator.clipboard.write([ new ClipboardItem({ [blob.type]: blob }) ]) } } catch (err) { console.error('❌ ImageGen: Failed to copy image:', err) // Fallback: copy the URL await navigator.clipboard.writeText(image.imageUrl) } }} onPointerDown={(e) => e.stopPropagation()} onTouchStart={(e) => e.stopPropagation()} onTouchEnd={(e) => e.stopPropagation()} style={{ flex: 1, padding: '6px 10px', backgroundColor: '#fff', border: '1px solid #ddd', borderRadius: '4px', cursor: 'pointer', fontSize: '11px', fontWeight: 500, color: '#555', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '4px', transition: 'background-color 0.15s', touchAction: 'manipulation', minHeight: '44px', }} onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = '#f0f0f0')} onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = '#fff')} > 📋 Copy { e.stopPropagation() const imageUrl = image.imageUrl if (!imageUrl) return // Create download link const link = document.createElement('a') link.href = imageUrl // Generate filename from prompt const promptSlug = (image.prompt || 'image') .slice(0, 30) .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-|-$/g, '') const timestamp = new Date(image.timestamp).toISOString().slice(0, 10) link.download = `${promptSlug}-${timestamp}.png` document.body.appendChild(link) link.click() document.body.removeChild(link) }} onPointerDown={(e) => e.stopPropagation()} onTouchStart={(e) => e.stopPropagation()} onTouchEnd={(e) => e.stopPropagation()} style={{ flex: 1, padding: '6px 10px', backgroundColor: ImageGenShape.PRIMARY_COLOR, border: 'none', borderRadius: '4px', cursor: 'pointer', fontSize: '11px', fontWeight: 500, color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '4px', transition: 'opacity 0.15s', touchAction: 'manipulation', minHeight: '44px', }} onMouseEnter={(e) => (e.currentTarget.style.opacity = '0.9')} onMouseLeave={(e) => (e.currentTarget.style.opacity = '1')} > ⬇️ Download {/* Delete button for history items */} { e.stopPropagation() const newHistory = shape.props.imageHistory.filter(img => img.id !== image.id) editor.updateShape({ id: shape.id, type: "ImageGen", props: { imageHistory: newHistory }, }) }} onPointerDown={(e) => e.stopPropagation()} onTouchStart={(e) => e.stopPropagation()} onTouchEnd={(e) => e.stopPropagation()} style={{ padding: '6px 10px', backgroundColor: '#fff', border: '1px solid #ddd', borderRadius: '4px', cursor: 'pointer', fontSize: '11px', fontWeight: 500, color: '#999', display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'background-color 0.15s, color 0.15s', touchAction: 'manipulation', minWidth: '44px', minHeight: '44px', }} onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = '#fee' e.currentTarget.style.color = '#c33' }} onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = '#fff' e.currentTarget.style.color = '#999' }} title="Remove from history" > 🗑️ ))} {/* Empty State */} {shape.props.imageHistory.length === 0 && !shape.props.isLoading && !shape.props.error && ( Generated images will appear here )} {/* Input Section - Mobile Optimized */} { editor.updateShape({ id: shape.id, type: "ImageGen", props: { prompt: e.target.value }, }) }} onKeyDown={(e) => { e.stopPropagation() if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() if (shape.props.prompt.trim() && !shape.props.isLoading) { handleGenerate() } } }} onPointerDown={(e) => { e.stopPropagation() }} onTouchStart={(e) => { e.stopPropagation() }} onClick={(e) => { e.stopPropagation() }} disabled={shape.props.isLoading} /> { e.stopPropagation() e.preventDefault() if (shape.props.prompt.trim() && !shape.props.isLoading) { handleGenerate() } }} onTouchStart={(e) => { e.stopPropagation() // Visual feedback on touch e.currentTarget.style.transform = "scale(0.98)" }} onTouchEnd={(e) => { e.stopPropagation() e.preventDefault() e.currentTarget.style.transform = "scale(1)" if (shape.props.prompt.trim() && !shape.props.isLoading) { handleGenerate() } }} onClick={(e) => { e.preventDefault() e.stopPropagation() if (shape.props.prompt.trim() && !shape.props.isLoading) { handleGenerate() } }} disabled={shape.props.isLoading || !shape.props.prompt.trim()} > ✨ Generate {/* Error Display - at bottom */} {shape.props.error && ( ⚠️ {shape.props.error} { editor.updateShape({ id: shape.id, type: "ImageGen", props: { error: null }, }) }} onPointerDown={(e) => e.stopPropagation()} onTouchStart={(e) => e.stopPropagation()} onTouchEnd={(e) => e.stopPropagation()} style={{ padding: "2px 6px", backgroundColor: "#fcc", border: "1px solid #c99", borderRadius: "4px", cursor: "pointer", fontSize: "10px", flexShrink: 0, touchAction: "manipulation", minWidth: "32px", minHeight: "32px", }} > ✕ )} {/* Add CSS for spinner animation */} ) } override indicator(shape: IImageGen) { return ( ) } }