/** * EditTab - Main component for the Edit mode workspace. * * TWO-VIEW ARCHITECTURE: * 1. Gallery View (Landing): Shows thumbnails of all edited images (like Imagine) * 2. Editor View: Full Grok-style editing workspace when an image is selected * * Features: * - localStorage persistence for edit history * - Cyber-Noir aesthetic (True Black backgrounds) * - Centralized Canvas for immersive editing * - Floating "Conversational" Input Bar at the bottom * - Right-side "Studio" controls panel with advanced edit options * - Horizontal Filmstrip for version history with metadata */ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Sparkles, Trash2, Upload, Loader2, Wand2, AlertCircle, Settings2, Download, History, ChevronRight, ChevronDown, ChevronLeft, Maximize2, X, RotateCcw, Clock, Sliders, Edit3, Plus, PaintBucket, Info, } from 'lucide-react'; import { EditDropzone } from './EditDropzone'; import { LoraManager } from './LoraManager'; import { MaskCanvas } from './MaskCanvas'; import { upscaleImage } from '../enhance/upscaleApi'; import { QuickActions } from './QuickActions'; import { BackgroundTools } from './BackgroundTools'; import { OutpaintTools } from './OutpaintTools'; import { useAvatarCapabilities } from '../useAvatarCapabilities'; import { uploadToEditSession, uploadToEditSessionByUrl, sendEditMessage, selectActiveImage, clearEditSession, getEditSession, extractImages, deleteVersion, } from './editApi'; import { resolveFileUrl } from '../resolveFileUrl'; // ----------------------------------------------------------------------------- // Helpers // ----------------------------------------------------------------------------- function uid() { return `${Date.now()}-${Math.random().toString(16).slice(2)}`; } function formatTimeAgo(timestamp) { const seconds = Math.floor((Date.now() - timestamp) / 1000); if (seconds < 60) return 'just now'; if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; return `${Math.floor(seconds / 86400)}d ago`; } // ----------------------------------------------------------------------------- // Component // ----------------------------------------------------------------------------- export function EditTab({ backendUrl, apiKey, conversationId, onOpenLightbox, provider, providerBaseUrl, providerModel, onNavigateToAvatar, nsfwMode, }) { // Refs const fileInputRef = useRef(null); const resultsEndRef = useRef(null); const gridStartRef = useRef(null); // Avatar identity capabilities (for optional Identity Tools section) const { capabilities: avatarCaps } = useAvatarCapabilities(backendUrl, apiKey); // ========================================================================== // STATE - Gallery (persisted to localStorage) // ========================================================================== const [galleryItems, setGalleryItems] = useState(() => { try { const stored = localStorage.getItem('homepilot_edit_items'); if (stored) { const parsed = JSON.parse(stored); return Array.isArray(parsed) ? parsed : []; } } catch (error) { console.error('Failed to load edit items from localStorage:', error); } return []; }); // View mode: 'gallery' or 'editor' const [viewMode, setViewMode] = useState('gallery'); const [currentEditItem, setCurrentEditItem] = useState(null); // ========================================================================== // STATE - Editor Session // ========================================================================== const [active, setActive] = useState(null); const [versions, setVersions] = useState([]); const [results, setResults] = useState([]); const [prompt, setPrompt] = useState(''); const [lastPrompt, setLastPrompt] = useState(''); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [initialized, setInitialized] = useState(false); const [showSettings, setShowSettings] = useState(true); // State - Advanced Controls const [advancedMode, setAdvancedMode] = useState(false); const [editMode, setEditMode] = useState('auto'); const [steps, setSteps] = useState(30); const [cfg, setCfg] = useState(5.5); const [denoise, setDenoise] = useState(0.55); const [seedLock, setSeedLock] = useState(false); const [seed, setSeed] = useState(0); const [useCN, setUseCN] = useState(false); const [cnStrength, setCnStrength] = useState(1.0); // State - LoRA Add-ons (additive — Golden Rule 1.0) const [activeLoras, setActiveLoras] = useState([]); // State - Inpainting Mask const [showMaskCanvas, setShowMaskCanvas] = useState(false); const [maskDataUrl, setMaskDataUrl] = useState(null); const [uploadingMask, setUploadingMask] = useState(false); // State - Upscaling const [isUpscaling, setIsUpscaling] = useState(false); const hasImage = Boolean(active); // Info overlay state (top-right ℹ button on main image) const [showInfo, setShowInfo] = useState(false); // Look up the version entry for the currently active image const activeVersionInfo = useMemo(() => { if (!active) return null; return versions.find(v => v.url === active) || null; }, [active, versions]); // ========================================================================== // EFFECTS // ========================================================================== // Save gallery items to localStorage useEffect(() => { try { localStorage.setItem('homepilot_edit_items', JSON.stringify(galleryItems)); } catch (error) { console.error('Failed to save edit items to localStorage:', error); } }, [galleryItems]); // Load session when entering editor mode useEffect(() => { if (viewMode !== 'editor' || !currentEditItem) return; let cancelled = false; const loadSession = async () => { try { const session = await getEditSession({ backendUrl, apiKey, conversationId: currentEditItem.conversationId }); if (!cancelled) { setActive(session.active_image_url ?? currentEditItem.url); if (session.versions && session.versions.length > 0) { setVersions(session.versions); } else if (session.history) { setVersions(session.history.map((url, i) => ({ url, instruction: '', created_at: Date.now() / 1000 - i * 60, parent_url: null, settings: {}, }))); } setInitialized(true); } } catch { if (!cancelled) { // Session doesn't exist yet, use the item's URL setActive(currentEditItem.url); setVersions([{ url: currentEditItem.url, instruction: currentEditItem.instruction, created_at: currentEditItem.createdAt / 1000, parent_url: null, settings: {}, }]); setInitialized(true); } } }; loadSession(); return () => { cancelled = true; }; }, [viewMode, currentEditItem, backendUrl, apiKey]); // Scroll to new results useEffect(() => { if (results.length > 0) { resultsEndRef.current?.scrollIntoView({ behavior: 'smooth' }); } }, [results]); // Auto-import image from Avatar Studio or ImageViewer (via sessionStorage handoff) useEffect(() => { const raw = sessionStorage.getItem('homepilot_edit_from_avatar'); if (!raw || viewMode !== 'gallery') return; // Clear immediately to prevent re-processing sessionStorage.removeItem('homepilot_edit_from_avatar'); // Parse source metadata if available, or treat as plain URL let sourceUrl; let sourceType; let sourceId; try { const parsed = JSON.parse(raw); sourceUrl = parsed.url || raw; sourceType = parsed.source_type; sourceId = parsed.source_id; } catch { sourceUrl = raw; } // Entirely server-side: backend resolves the URL to a file on disk // and forwards it to the edit session sidecar — no browser fetch needed const importImage = async () => { setBusy(true); setError(null); try { const newConversationId = uid(); // Backend loads the image from /comfy/view/... or /files/... and // sends it directly to the edit session sidecar const data = await uploadToEditSessionByUrl({ backendUrl, apiKey, conversationId: newConversationId, imageUrl: sourceUrl, }); const uploadedUrl = data.active_image_url; if (!uploadedUrl) { throw new Error('No image URL returned from edit session'); } const newItem = { id: uid(), url: uploadedUrl, createdAt: Date.now(), originalUrl: uploadedUrl, instruction: '[Imported]', conversationId: newConversationId, source: sourceType ? { type: sourceType, id: sourceId, url: sourceUrl, } : undefined, }; setGalleryItems((prev) => [newItem, ...prev].slice(0, 100)); setCurrentEditItem(newItem); setActive(uploadedUrl); setVersions([{ url: uploadedUrl, instruction: '[Imported]', created_at: Date.now() / 1000, parent_url: null, settings: {}, }]); setInitialized(true); setViewMode('editor'); } catch (e) { setError(e instanceof Error ? e.message : 'Failed to import image'); } finally { setBusy(false); } }; importImage(); }, [viewMode]); // eslint-disable-line react-hooks/exhaustive-deps // ========================================================================== // HELPERS // ========================================================================== const buildEditMessage = useCallback((userText, maskUrl) => { const parts = [userText.trim()]; // Always include mask if provided (for inpainting) if (maskUrl) { parts.push(`--mask ${maskUrl}`); parts.push(`--mode inpaint`); } if (!advancedMode) { // Still inject LoRA flags even without advanced mode const enabledLorasBasic = activeLoras.filter((l) => l.enabled); for (const lora of enabledLorasBasic) { parts.push(`--lora ${lora.id}:${lora.weight.toFixed(2)}`); } return parts.join(' '); } // Add advanced parameters if (!maskUrl) { parts.push(`--mode ${editMode}`); } parts.push(`--steps ${steps}`); parts.push(`--cfg ${cfg}`); parts.push(`--denoise ${denoise}`); if (seedLock && seed > 0) { parts.push(`--seed ${seed}`); } if (useCN) { parts.push(`--cn on`); parts.push(`--cn-strength ${cnStrength}`); } // Inject active LoRA flags (additive — works even without advanced mode) const enabledLoras = activeLoras.filter((l) => l.enabled); for (const lora of enabledLoras) { parts.push(`--lora ${lora.id}:${lora.weight.toFixed(2)}`); } return parts.join(' '); }, [advancedMode, editMode, steps, cfg, denoise, seedLock, seed, useCN, cnStrength, activeLoras]); // Upload mask data URL to server and get a URL const uploadMask = useCallback(async (dataUrl) => { try { // Convert data URL to blob const response = await fetch(dataUrl); const blob = await response.blob(); // Create form data const formData = new FormData(); formData.append('file', blob, 'mask.png'); // Upload to backend const uploadUrl = `${backendUrl}/upload`; const uploadRes = await fetch(uploadUrl, { method: 'POST', headers: apiKey ? { 'x-api-key': apiKey } : {}, body: formData, }); if (!uploadRes.ok) { throw new Error(`Upload failed: ${uploadRes.status}`); } const uploadData = await uploadRes.json(); return uploadData.url || null; } catch (e) { console.error('Failed to upload mask:', e); return null; } }, [backendUrl, apiKey]); // ========================================================================== // HANDLERS - Gallery // ========================================================================== const handleUploadNew = useCallback(async (file, sourceInfo) => { setError(null); setBusy(true); const newConversationId = uid(); try { const data = await uploadToEditSession({ backendUrl, apiKey, conversationId: newConversationId, file, }); const uploadedUrl = data.active_image_url; if (!uploadedUrl) { throw new Error('No image URL returned from upload'); } const newItem = { id: uid(), url: uploadedUrl, createdAt: Date.now(), originalUrl: uploadedUrl, instruction: '[Original Upload]', conversationId: newConversationId, source: sourceInfo ? { type: sourceInfo.sourceType, id: sourceInfo.sourceId, url: sourceInfo.sourceUrl, } : undefined, }; setGalleryItems((prev) => [newItem, ...prev].slice(0, 100)); // Open editor for this new item setCurrentEditItem(newItem); setActive(uploadedUrl); setVersions([{ url: uploadedUrl, instruction: '[Original Upload]', created_at: Date.now() / 1000, parent_url: null, settings: {}, }]); setInitialized(true); setViewMode('editor'); } catch (e) { setError(e instanceof Error ? e.message : 'Upload failed'); } finally { setBusy(false); } }, [backendUrl, apiKey]); const handleOpenEditor = useCallback((item) => { setCurrentEditItem(item); setInitialized(false); setActive(null); setVersions([]); setResults([]); setPrompt(''); setError(null); setViewMode('editor'); }, []); const handleBackToGallery = useCallback(() => { setViewMode('gallery'); setCurrentEditItem(null); setActive(null); setVersions([]); setResults([]); setPrompt(''); setError(null); setInitialized(false); }, []); const handleDeleteItem = useCallback(async (item, e) => { if (e) e.stopPropagation(); if (!confirm('Delete this edited image from gallery?')) return; // Remove from local gallery state (and localStorage via effect) setGalleryItems((prev) => prev.filter((i) => i.id !== item.id)); // Also delete backend session data to ensure persistence try { await clearEditSession({ backendUrl, apiKey, conversationId: item.conversationId, }); } catch (err) { // Silently ignore - backend session may not exist or already deleted console.warn('[EditTab] Failed to clear backend session:', err); } }, [backendUrl, apiKey]); // ========================================================================== // HANDLERS - Editor // ========================================================================== const handlePickFile = useCallback(async (file) => { if (viewMode === 'gallery') { return handleUploadNew(file); } if (!currentEditItem) return; setError(null); setBusy(true); setResults([]); try { const data = await uploadToEditSession({ backendUrl, apiKey, conversationId: currentEditItem.conversationId, file }); setActive(data.active_image_url ?? null); if (data.versions && data.versions.length > 0) { setVersions(data.versions); } else if (data.history) { setVersions(data.history.map((url, i) => ({ url, instruction: i === 0 ? '[Original Upload]' : '', created_at: Date.now() / 1000 - i * 60, parent_url: null, settings: {}, }))); } } catch (e) { setError(e instanceof Error ? e.message : 'Upload failed'); } finally { setBusy(false); } }, [viewMode, currentEditItem, backendUrl, apiKey, handleUploadNew]); const runEdit = useCallback(async (text) => { const trimmed = text.trim(); if (!trimmed || !currentEditItem) return; setError(null); setBusy(true); setLastPrompt(trimmed); setResults([]); // Upload mask if one is set let uploadedMaskUrl = null; if (maskDataUrl) { setUploadingMask(true); uploadedMaskUrl = await uploadMask(maskDataUrl); setUploadingMask(false); if (!uploadedMaskUrl) { setError('Failed to upload mask. Please try again.'); setBusy(false); return; } } const messageToSend = buildEditMessage(trimmed, uploadedMaskUrl); try { const out = await sendEditMessage({ backendUrl, apiKey, conversationId: currentEditItem.conversationId, message: messageToSend, provider, provider_base_url: providerBaseUrl, model: providerModel, }); const imgs = extractImages(out.raw); setResults(imgs); if (imgs.length) { const now = Date.now(); const newVersions = imgs.map(url => ({ url, instruction: trimmed, created_at: now / 1000, parent_url: active, settings: { steps, cfg, denoise, editMode, seed: seedLock ? seed : undefined, model: providerModel || undefined, loras: activeLoras.filter(l => l.enabled).map(l => ({ id: l.id, weight: l.weight })), }, })); setVersions((prev) => { const existing = prev.filter(v => !imgs.includes(v.url)); return [...newVersions, ...existing].slice(0, 20); }); // Auto-select first result const firstResult = imgs[0]; setActive(firstResult); // Update gallery - keep only one thumbnail per project (conversationId) // Always show the latest edited version, not duplicates setGalleryItems((prev) => { // Remove any existing items with the same conversationId const filtered = prev.filter(item => item.conversationId !== currentEditItem.conversationId); // Create updated item with the latest version const updatedItem = { id: currentEditItem.id, // Keep same ID url: firstResult, // Use the latest edited version createdAt: now, originalUrl: currentEditItem.originalUrl, instruction: trimmed, conversationId: currentEditItem.conversationId, settings: { steps, cfg, denoise, editMode, seed: seedLock ? seed : undefined, model: providerModel || undefined, loras: activeLoras.filter(l => l.enabled).map(l => ({ id: l.id, weight: l.weight })), }, }; // Add updated item at the beginning (most recent first) return [updatedItem, ...filtered].slice(0, 100); }); // Update current edit item reference setCurrentEditItem(prev => prev ? { ...prev, url: firstResult, createdAt: now, instruction: trimmed, } : prev); // Persist to backend selectActiveImage({ backendUrl, apiKey, conversationId: currentEditItem.conversationId, image_url: firstResult, }).catch(err => console.warn('Failed to persist active image:', err)); // Clear mask after successful edit if (maskDataUrl) { setMaskDataUrl(null); } setResults([]); } } catch (e) { setError(e instanceof Error ? e.message : 'Edit failed'); } finally { setBusy(false); } }, [currentEditItem, buildEditMessage, backendUrl, apiKey, provider, providerBaseUrl, providerModel, active, advancedMode, steps, cfg, denoise, editMode, maskDataUrl, uploadMask]); const handleUse = useCallback(async (url) => { if (!currentEditItem) return; setError(null); setBusy(true); setShowInfo(false); try { const state = await selectActiveImage({ backendUrl, apiKey, conversationId: currentEditItem.conversationId, image_url: url, }); setActive(state.active_image_url ?? url); if (state.versions && state.versions.length > 0) { setVersions(state.versions); } else if (state.history) { setVersions(state.history.map((u, i) => ({ url: u, instruction: '', created_at: Date.now() / 1000 - i * 60, parent_url: null, settings: {}, }))); } setResults([]); } catch (e) { setError(e instanceof Error ? e.message : 'Failed to set active image'); } finally { setBusy(false); } }, [currentEditItem, backendUrl, apiKey]); const handleReset = useCallback(async () => { if (!currentEditItem) return; setError(null); setBusy(true); try { await clearEditSession({ backendUrl, apiKey, conversationId: currentEditItem.conversationId, }); setActive(null); setVersions([]); setResults([]); setPrompt(''); setLastPrompt(''); } catch (e) { setError(e instanceof Error ? e.message : 'Failed to reset'); } finally { setBusy(false); } }, [currentEditItem, backendUrl, apiKey]); const handleSubmit = useCallback((e) => { e?.preventDefault(); if (prompt.trim()) { runEdit(prompt); setPrompt(''); } }, [prompt, runEdit]); const handleUpscale = useCallback(async () => { if (!active || !currentEditItem || isUpscaling) return; setIsUpscaling(true); setError(null); try { const result = await upscaleImage({ backendUrl, apiKey, imageUrl: active, scale: 2, model: '4x-UltraSharp.pth', }); const upscaledUrl = result?.media?.images?.[0]; if (upscaledUrl) { // Add to versions (non-destructive) const now = Date.now(); const newVersion = { url: upscaledUrl, instruction: '[Upscaled 2x]', created_at: now / 1000, parent_url: active, settings: {}, }; setVersions((prev) => [newVersion, ...prev]); setActive(upscaledUrl); // Persist to backend selectActiveImage({ backendUrl, apiKey, conversationId: currentEditItem.conversationId, image_url: upscaledUrl, }).catch(err => console.warn('Failed to persist upscaled image:', err)); } else { setError('Upscale completed but no image was returned.'); } } catch (e) { setError(e instanceof Error ? e.message : 'Upscale failed'); } finally { setIsUpscaling(false); } }, [active, currentEditItem, isUpscaling, backendUrl, apiKey]); // Handler for QuickActions (Enhance, Restore, Fix Faces, Upscale) const handleQuickActionResult = useCallback((resultUrl, mode) => { if (!currentEditItem) return; const now = Date.now(); const modeLabels = { photo: 'Enhanced', restore: 'Restored', faces: 'Faces Fixed', upscale: 'Upscaled', }; const instruction = `[${modeLabels[mode] || mode}]`; const newVersion = { url: resultUrl, instruction, created_at: now / 1000, parent_url: active, settings: { mode }, }; setVersions((prev) => [newVersion, ...prev]); setActive(resultUrl); // Update gallery setGalleryItems((prev) => { const filtered = prev.filter(item => item.conversationId !== currentEditItem.conversationId); const updatedItem = { id: currentEditItem.id, url: resultUrl, createdAt: now, originalUrl: currentEditItem.originalUrl, instruction, conversationId: currentEditItem.conversationId, }; return [updatedItem, ...filtered].slice(0, 100); }); // Update current edit item reference setCurrentEditItem(prev => prev ? { ...prev, url: resultUrl, createdAt: now, instruction } : prev); // Persist to backend selectActiveImage({ backendUrl, apiKey, conversationId: currentEditItem.conversationId, image_url: resultUrl, }).catch(err => console.warn('Failed to persist active image:', err)); }, [currentEditItem, active, backendUrl, apiKey]); // Handler for BackgroundTools (Remove BG, Change BG, Blur BG) const handleBackgroundResult = useCallback((resultUrl, action) => { if (!currentEditItem) return; const now = Date.now(); const actionLabels = { remove: 'BG Removed', replace: 'BG Changed', blur: 'BG Blurred', }; const instruction = `[${actionLabels[action] || action}]`; const newVersion = { url: resultUrl, instruction, created_at: now / 1000, parent_url: active, settings: { action }, }; setVersions((prev) => [newVersion, ...prev]); setActive(resultUrl); // Update gallery setGalleryItems((prev) => { const filtered = prev.filter(item => item.conversationId !== currentEditItem.conversationId); const updatedItem = { id: currentEditItem.id, url: resultUrl, createdAt: now, originalUrl: currentEditItem.originalUrl, instruction, conversationId: currentEditItem.conversationId, }; return [updatedItem, ...filtered].slice(0, 100); }); setCurrentEditItem(prev => prev ? { ...prev, url: resultUrl, createdAt: now, instruction } : prev); selectActiveImage({ backendUrl, apiKey, conversationId: currentEditItem.conversationId, image_url: resultUrl, }).catch(err => console.warn('Failed to persist active image:', err)); }, [currentEditItem, active, backendUrl, apiKey]); // Handler for OutpaintTools (Extend canvas) const handleOutpaintResult = useCallback((resultUrl, direction, newSize) => { if (!currentEditItem) return; const now = Date.now(); const directionLabels = { left: 'Extended Left', right: 'Extended Right', up: 'Extended Up', down: 'Extended Down', horizontal: 'Extended Horizontal', vertical: 'Extended Vertical', all: 'Extended All Sides', }; const instruction = `[${directionLabels[direction] || 'Extended'}] → ${newSize[0]}×${newSize[1]}`; const newVersion = { url: resultUrl, instruction, created_at: now / 1000, parent_url: active, settings: { direction, newSize }, }; setVersions((prev) => [newVersion, ...prev]); setActive(resultUrl); // Update gallery setGalleryItems((prev) => { const filtered = prev.filter(item => item.conversationId !== currentEditItem.conversationId); const updatedItem = { id: currentEditItem.id, url: resultUrl, createdAt: now, originalUrl: currentEditItem.originalUrl, instruction, conversationId: currentEditItem.conversationId, }; return [updatedItem, ...filtered].slice(0, 100); }); setCurrentEditItem(prev => prev ? { ...prev, url: resultUrl, createdAt: now, instruction } : prev); selectActiveImage({ backendUrl, apiKey, conversationId: currentEditItem.conversationId, image_url: resultUrl, }).catch(err => console.warn('Failed to persist active image:', err)); }, [currentEditItem, active, backendUrl, apiKey]); // Handler for IdentityTools (Fix Faces+, Inpaint Preserve, Change BG Preserve, Face Swap) const handleIdentityToolResult = useCallback((resultUrl, toolType) => { if (!currentEditItem) return; const now = Date.now(); const toolLabels = { fix_faces_identity: 'Faces Fixed+', inpaint_identity: 'Inpaint (ID)', change_bg_identity: 'BG Changed (ID)', face_swap: 'Face Swapped', }; const instruction = `[${toolLabels[toolType] || toolType}]`; const newVersion = { url: resultUrl, instruction, created_at: now / 1000, parent_url: active, settings: { mode: toolType }, }; setVersions((prev) => [newVersion, ...prev]); setActive(resultUrl); // Update gallery setGalleryItems((prev) => { const filtered = prev.filter(item => item.conversationId !== currentEditItem.conversationId); const updatedItem = { id: currentEditItem.id, url: resultUrl, createdAt: now, originalUrl: currentEditItem.originalUrl, instruction, conversationId: currentEditItem.conversationId, }; return [updatedItem, ...filtered].slice(0, 100); }); setCurrentEditItem(prev => prev ? { ...prev, url: resultUrl, createdAt: now, instruction } : prev); selectActiveImage({ backendUrl, apiKey, conversationId: currentEditItem.conversationId, image_url: resultUrl, }).catch(err => console.warn('Failed to persist active image:', err)); }, [currentEditItem, active, backendUrl, apiKey]); const handleDeleteVersion = useCallback((versionUrl, e) => { if (e) { e.stopPropagation(); e.preventDefault(); } console.log('[EditTab] Deleting version:', versionUrl); // Delete from backend first, then update frontend state const conversationId = currentEditItem?.conversationId; if (conversationId) { deleteVersion({ backendUrl, apiKey, conversationId, imageUrl: versionUrl, }).catch(err => console.warn('Failed to delete version from backend:', err)); } setVersions((prev) => { const filtered = prev.filter(v => v.url !== versionUrl); console.log('[EditTab] Versions after delete:', filtered.length); // If this was the last version, delete the entire edit project if (filtered.length === 0 && currentEditItem) { console.log('[EditTab] Last version deleted — removing edit project'); // Remove from gallery setGalleryItems((items) => items.filter((i) => i.id !== currentEditItem.id)); // Clear backend session clearEditSession({ backendUrl, apiKey, conversationId: currentEditItem.conversationId, }).catch(err => console.warn('Failed to clear backend session:', err)); // Fully reset editor state and return to gallery setActive(null); setCurrentEditItem(null); setResults([]); setPrompt(''); setError(null); setInitialized(false); setViewMode('gallery'); return filtered; } // Determine new active image after deletion let newActive = null; if (active === versionUrl && filtered.length > 0) { // Deleted the active version — navigate to the nearest alive photo const oldIndex = prev.findIndex(v => v.url === versionUrl); // Pick the previous version (older), or the next one if at start const newIndex = Math.min(oldIndex, filtered.length - 1); newActive = filtered[Math.max(0, newIndex)].url; setActive(newActive); } // Update the gallery thumbnail to show the latest non-deleted version. // The latest version is the first entry in the filtered array (newest first). const thumbnailUrl = newActive || filtered[0]?.url; if (thumbnailUrl && currentEditItem) { setGalleryItems((items) => items.map((item) => item.conversationId === currentEditItem.conversationId ? { ...item, url: thumbnailUrl } : item)); setCurrentEditItem(prev => prev ? { ...prev, url: thumbnailUrl } : prev); // Persist the new active to backend selectActiveImage({ backendUrl, apiKey, conversationId: currentEditItem.conversationId, image_url: thumbnailUrl, }).catch(err => console.warn('Failed to persist active image:', err)); } return filtered; }); }, [active, currentEditItem, backendUrl, apiKey]); // ========================================================================== // RENDER - Gallery View // ========================================================================== if (viewMode === 'gallery') { return (
{hasImage ? 'Session Active' : 'No Image Loaded'}
Draw a mask to edit only specific areas. White = areas to change, black = areas to preserve.