From 9922b2a03e402a09991e75234fb19b7a21580958 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Jan 2026 00:56:28 +0000 Subject: [PATCH] GIMP-like UI overhaul - toolbar, sidebar, full-width canvas - Redesign layout: left toolbar, center canvas, right sidebar - Add vertical tool strip with keyboard shortcuts (V, R, E, F, W, B, G, Z, H) - Collapsible sidebar panels: Tool Options, AI Edit, Quick Actions, Eyes, Layers, History - Canvas now fills available space with checkerboard background - Add zoom controls in status bar - Add menu bar with New, Save, Reset - Remove old multi-column layout --- frontend/src/App.css | 658 ++++++++++++++++++--- frontend/src/App.jsx | 730 ++++++++++++++++-------- frontend/src/components/ImageCanvas.css | 61 +- 3 files changed, 1119 insertions(+), 330 deletions(-) diff --git a/frontend/src/App.css b/frontend/src/App.css index 86634e7..a0b9eeb 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,14 +1,366 @@ -.app { - min-height: 100vh; - background-color: #1a1a1a; +/* GIMP-like layout */ +* { + box-sizing: border-box; } -.error-banner { - background-color: #ff4444; +html, body, #root { + margin: 0; + padding: 0; + height: 100%; + overflow: hidden; +} + +.app { + height: 100vh; + display: flex; + flex-direction: column; + background-color: #1a1a1a; + color: #e0e0e0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; +} + +/* Header / Menu bar */ +.menu-bar { + display: flex; + align-items: center; + justify-content: space-between; + background-color: #2d2d2d; + border-bottom: 1px solid #404040; + padding: 4px 12px; + height: 36px; + flex-shrink: 0; +} + +.menu-bar h1 { + font-size: 14px; + font-weight: 600; + margin: 0; + color: #fff; +} + +.menu-actions { + display: flex; + gap: 8px; +} + +.menu-btn { + background: #404040; + border: none; + color: #e0e0e0; + padding: 4px 12px; + font-size: 12px; + border-radius: 3px; + cursor: pointer; +} + +.menu-btn:hover { + background: #505050; +} + +.menu-btn.primary { + background: #0066cc; +} + +.menu-btn.primary:hover { + background: #0077ee; +} + +/* Main workspace */ +.workspace { + display: flex; + flex: 1; + min-height: 0; + overflow: hidden; +} + +/* Left toolbar - GIMP style vertical tools */ +.toolbar { + width: 48px; + background-color: #2d2d2d; + border-right: 1px solid #404040; + display: flex; + flex-direction: column; + padding: 4px; + gap: 2px; + flex-shrink: 0; +} + +.tool-btn { + width: 40px; + height: 40px; + background: transparent; + border: 2px solid transparent; + border-radius: 4px; + color: #b0b0b0; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + transition: all 0.15s; +} + +.tool-btn:hover { + background: #404040; + color: #fff; +} + +.tool-btn.active { + background: #0066cc; + border-color: #0088ff; + color: #fff; +} + +.tool-btn[title]:hover::after { + content: attr(title); + position: absolute; + left: 52px; + background: #000; + color: #fff; + padding: 4px 8px; + border-radius: 3px; + font-size: 11px; + white-space: nowrap; + z-index: 100; +} + +.tool-divider { + height: 1px; + background: #404040; + margin: 4px 0; +} + +/* Canvas area - main editing space */ +.canvas-area { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + background: #1a1a1a; + position: relative; +} + +.canvas-wrapper { + flex: 1; + position: relative; + overflow: hidden; + background: + linear-gradient(45deg, #252525 25%, transparent 25%), + linear-gradient(-45deg, #252525 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, #252525 75%), + linear-gradient(-45deg, transparent 75%, #252525 75%); + background-size: 20px 20px; + background-position: 0 0, 0 10px, 10px -10px, -10px 0px; + background-color: #1e1e1e; +} + +/* Canvas status bar */ +.canvas-status { + height: 24px; + background: #2d2d2d; + border-top: 1px solid #404040; + display: flex; + align-items: center; + padding: 0 12px; + font-size: 11px; + color: #888; + gap: 20px; +} + +.zoom-controls { + display: flex; + align-items: center; + gap: 8px; +} + +.zoom-btn { + background: #404040; + border: none; + color: #e0e0e0; + width: 24px; + height: 20px; + border-radius: 3px; + cursor: pointer; + font-size: 14px; +} + +.zoom-btn:hover { + background: #505050; +} + +.zoom-level { + min-width: 50px; + text-align: center; +} + +/* Right sidebar */ +.sidebar { + width: 280px; + background-color: #2d2d2d; + border-left: 1px solid #404040; + display: flex; + flex-direction: column; + overflow-y: auto; + flex-shrink: 0; +} + +.sidebar-panel { + border-bottom: 1px solid #404040; +} + +.panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: #353535; + cursor: pointer; + user-select: none; +} + +.panel-header h3 { + font-size: 12px; + font-weight: 600; + margin: 0; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.panel-toggle { + font-size: 10px; + color: #888; +} + +.panel-content { + padding: 12px; +} + +.panel-content.collapsed { + display: none; +} + +/* Form controls */ +.control-group { + margin-bottom: 12px; +} + +.control-group:last-child { + margin-bottom: 0; +} + +.control-label { + display: block; + font-size: 11px; + color: #888; + margin-bottom: 4px; + text-transform: uppercase; +} + +.control-row { + display: flex; + gap: 4px; +} + +.mode-btn { + flex: 1; + padding: 6px 8px; + background: #404040; + border: 1px solid #505050; + color: #e0e0e0; + font-size: 11px; + border-radius: 3px; + cursor: pointer; +} + +.mode-btn:hover { + background: #505050; +} + +.mode-btn.active { + background: #0066cc; + border-color: #0088ff; +} + +input[type="range"] { + width: 100%; + height: 4px; + background: #404040; + border-radius: 2px; + outline: none; + -webkit-appearance: none; +} + +input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + width: 14px; + height: 14px; + background: #0088ff; + border-radius: 50%; + cursor: pointer; +} + +textarea { + width: 100%; + background: #1a1a1a; + border: 1px solid #404040; + border-radius: 3px; + color: #e0e0e0; + padding: 8px; + font-size: 12px; + resize: vertical; + min-height: 60px; +} + +textarea:focus { + border-color: #0088ff; + outline: none; +} + +.action-btn { + width: 100%; + padding: 10px; + background: #0066cc; + border: none; color: white; - padding: 12px 16px; - border-radius: 6px; - margin-bottom: 20px; + font-size: 12px; + font-weight: 600; + border-radius: 3px; + cursor: pointer; + text-transform: uppercase; +} + +.action-btn:hover:not(:disabled) { + background: #0077ee; +} + +.action-btn:disabled { + background: #404040; + color: #666; + cursor: not-allowed; +} + +.action-btn.secondary { + background: #404040; +} + +.action-btn.secondary:hover:not(:disabled) { + background: #505050; +} + +.action-btn.danger { + background: #cc3333; +} + +.action-btn.danger:hover:not(:disabled) { + background: #dd4444; +} + +/* Error banner */ +.error-banner { + background-color: #cc3333; + color: white; + padding: 8px 12px; + font-size: 12px; display: flex; justify-content: space-between; align-items: center; @@ -16,115 +368,285 @@ .error-banner button { background: none; + border: none; color: white; - font-size: 20px; + font-size: 16px; + cursor: pointer; padding: 0; - width: 24px; - height: 24px; +} + +/* Project setup modal */ +.project-setup-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.8); display: flex; align-items: center; justify-content: center; + z-index: 1000; } .project-setup { - max-width: 600px; - margin: 0 auto; - padding: 40px; - background-color: #2a2a2a; + background: #2d2d2d; + border: 1px solid #404040; border-radius: 8px; - border: 1px solid #444; + padding: 24px; + width: 400px; + max-width: 90vw; } .project-setup h2 { - font-size: 24px; - margin-bottom: 24px; + font-size: 18px; + margin: 0 0 20px 0; text-align: center; } .setup-form { display: flex; flex-direction: column; - gap: 20px; + gap: 16px; } .form-group { display: flex; flex-direction: column; - gap: 8px; + gap: 6px; } .form-group label { - font-size: 14px; - font-weight: 600; - color: #cccccc; + font-size: 12px; + color: #888; + text-transform: uppercase; } -.form-group input { - width: 100%; +.form-group input[type="text"], +.form-group input[type="file"] { + background: #1a1a1a; + border: 1px solid #404040; + border-radius: 3px; + color: #e0e0e0; + padding: 10px; + font-size: 14px; +} + +.form-group input[type="text"]:focus { + border-color: #0088ff; + outline: none; } .file-name { - font-size: 12px; - color: #00ff00; - margin-top: 4px; -} - -.optional-field label::after { - content: ''; -} - -.optional-field { - opacity: 0.7; -} - -.optional-field:focus-within { - opacity: 1; + font-size: 11px; + color: #0088ff; } .create-project-btn { - background-color: #0066ff; + background: #0066cc; + border: none; color: white; - padding: 14px 20px; - font-size: 16px; + padding: 12px; + font-size: 14px; font-weight: 600; - margin-top: 10px; + border-radius: 3px; + cursor: pointer; + margin-top: 8px; } .create-project-btn:hover:not(:disabled) { - background-color: #0055dd; + background: #0077ee; } -.workspace { - display: grid; - grid-template-columns: 1fr 400px; - gap: 20px; - min-height: calc(100vh - 180px); - height: calc(100vh - 180px); +.create-project-btn:disabled { + background: #404040; + color: #666; } -@media (max-width: 1200px) { - .workspace { - grid-template-columns: 1fr; - } -} - -.left-panel { +/* Layer list */ +.layer-list { display: flex; flex-direction: column; - height: 100%; - min-height: 0; + gap: 4px; } -.right-panel { +.layer-item { display: flex; - flex-direction: column; - gap: 12px; - overflow-y: auto; - max-height: calc(100vh - 180px); - padding-right: 8px; + align-items: center; + gap: 8px; + padding: 6px 8px; + background: #353535; + border-radius: 3px; + cursor: pointer; } -.history-wrapper { +.layer-item:hover { + background: #404040; +} + +.layer-item.active { + background: #0066cc; +} + +.layer-visibility { + width: 16px; + height: 16px; + background: none; + border: none; + color: #888; + cursor: pointer; + padding: 0; + font-size: 12px; +} + +.layer-visibility.visible { + color: #0088ff; +} + +.layer-name { flex: 1; - min-height: 0; + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* History list */ +.history-list { + display: flex; + flex-direction: column; + gap: 4px; + max-height: 200px; + overflow-y: auto; +} + +.history-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 8px; + background: #353535; + border-radius: 3px; + font-size: 11px; +} + +.history-item:hover { + background: #404040; +} + +.history-item.current { + background: #0066cc; +} + +.history-item-actions { + display: flex; + gap: 4px; +} + +.history-btn { + background: #505050; + border: none; + color: #e0e0e0; + padding: 2px 8px; + font-size: 10px; + border-radius: 2px; + cursor: pointer; +} + +.history-btn:hover { + background: #606060; +} + +/* Eye catalog */ +.eye-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; +} + +.eye-item { + aspect-ratio: 1; + background: #353535; + border: 2px solid transparent; + border-radius: 4px; + cursor: pointer; + overflow: hidden; +} + +.eye-item:hover { + border-color: #0088ff; +} + +.eye-item.selected { + border-color: #0088ff; +} + +.eye-item img { + width: 100%; + height: 100%; + object-fit: cover; +} + +/* Processing overlay */ +.processing-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; +} + +.processing-spinner { + width: 40px; + height: 40px; + border: 3px solid #404040; + border-top-color: #0088ff; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* Slider with value */ +.slider-row { + display: flex; + align-items: center; + gap: 8px; +} + +.slider-row input[type="range"] { + flex: 1; +} + +.slider-value { + font-size: 11px; + color: #0088ff; + min-width: 35px; + text-align: right; +} + +/* Scrollbar styling */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: #1a1a1a; +} + +::-webkit-scrollbar-thumb { + background: #404040; + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: #505050; } diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index a61287e..8dcfe65 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,34 +1,84 @@ import React, { useState, useEffect, useCallback, useRef } from 'react'; import ImageCanvas from './components/ImageCanvas'; -import Controls from './components/Controls'; -import History from './components/History'; -import EyeCatalog from './components/EyeCatalog'; -import AdvancedTools from './components/AdvancedTools'; -import Layers from './components/Layers'; import { projectsApi, editsApi, toolsApi } from './utils/api'; import './App.css'; +// Tool definitions +const TOOLS = { + move: { icon: '✥', name: 'Move', shortcut: 'V' }, + select: { icon: '▢', name: 'Rectangle Select', shortcut: 'R' }, + ellipse: { icon: '○', name: 'Ellipse Select', shortcut: 'E' }, + lasso: { icon: '✎', name: 'Free Select (Lasso)', shortcut: 'F' }, + magic: { icon: '✨', name: 'Smart Select (SAM)', shortcut: 'W' }, + colorPick: { icon: '◉', name: 'Color Select', shortcut: 'U' }, + brush: { icon: '🖌', name: 'Brush', shortcut: 'B' }, + bucket: { icon: '◧', name: 'Bucket Fill', shortcut: 'G' }, + eraser: { icon: '◫', name: 'Eraser', shortcut: 'Shift+E' }, + eyedropper: { icon: '💧', name: 'Color Picker', shortcut: 'O' }, + zoom: { icon: '🔍', name: 'Zoom', shortcut: 'Z' }, + pan: { icon: '✋', name: 'Pan', shortcut: 'H' }, +}; + function App() { + // Project state const [project, setProject] = useState(null); const [imageFile, setImageFile] = useState(null); const [currentImageUrl, setCurrentImageUrl] = useState(null); - const [selection, setSelection] = useState(null); - const [selectionMode, setSelectionMode] = useState('rectangle'); - const [mode, setMode] = useState('A'); - const [feather, setFeather] = useState(5); - const [prompt, setPrompt] = useState(''); - const [edits, setEdits] = useState([]); - const [isProcessing, setIsProcessing] = useState(false); - const [error, setError] = useState(null); + const [showProjectSetup, setShowProjectSetup] = useState(true); const [projectName, setProjectName] = useState(''); - const [showProjectInput, setShowProjectInput] = useState(true); + + // Tool state + const [activeTool, setActiveTool] = useState('select'); + const [selection, setSelection] = useState(null); + + // Edit state + const [prompt, setPrompt] = useState(''); + const [feather, setFeather] = useState(5); + const [mode, setMode] = useState('A'); + const [edits, setEdits] = useState([]); const [currentEditIndex, setCurrentEditIndex] = useState(-1); - const [layers, setLayers] = useState([]); - const [activeLayer, setActiveLayer] = useState('background'); - const [generatedMask, setGeneratedMask] = useState(null); const editsRef = useRef([]); - // Create project and upload image + // Layer state + const [layers, setLayers] = useState([]); + + // UI state + const [isProcessing, setIsProcessing] = useState(false); + const [error, setError] = useState(null); + const [zoom, setZoom] = useState(100); + const [collapsedPanels, setCollapsedPanels] = useState({}); + const [eyes, setEyes] = useState([]); + const [selectedEye, setSelectedEye] = useState(null); + + // Canvas ref for tool interactions + const canvasRef = useRef(null); + + // Map tool to selection mode + const getSelectionMode = () => { + switch (activeTool) { + case 'select': return 'rectangle'; + case 'ellipse': return 'ellipse'; + case 'lasso': return 'lasso'; + case 'magic': return 'smart'; + case 'colorPick': return 'color'; + default: return null; + } + }; + + // Load eyes catalog + useEffect(() => { + const loadEyes = async () => { + try { + const patches = await fetch('/patches/?category=eyes').then(r => r.json()).catch(() => []); + setEyes(patches); + } catch (err) { + console.error('Failed to load eyes:', err); + } + }; + if (project) loadEyes(); + }, [project]); + + // Create project const handleCreateProject = async () => { if (!imageFile) { setError('Please select an image'); @@ -39,25 +89,17 @@ function App() { setError(null); setIsProcessing(true); - // Generate default project name from file name or timestamp const defaultName = projectName.trim() || imageFile.name.replace(/\.[^/.]+$/, '') || `Project ${Date.now()}`; - // Create project const newProject = await projectsApi.create(defaultName); setProject(newProject); - // Upload image await projectsApi.uploadImage(newProject.id, imageFile); - - // Set current image URL setCurrentImageUrl(projectsApi.getCurrentImageUrl(newProject.id)); + setShowProjectSetup(false); - // Hide project input - setShowProjectInput(false); - - // Load edits await loadEdits(newProject.id); } catch (err) { setError(`Failed to create project: ${err.message}`); @@ -66,13 +108,12 @@ function App() { } }; - // Load edits for the project + // Load edits const loadEdits = async (projectId) => { try { const projectEdits = await projectsApi.getEdits(projectId); setEdits(projectEdits); editsRef.current = projectEdits; - // Set index to latest completed edit const completedEdits = projectEdits.filter(e => e.status === 'completed'); setCurrentEditIndex(completedEdits.length - 1); } catch (err) { @@ -80,9 +121,9 @@ function App() { } }; - // Poll for edit status + // Poll edit status const pollEditStatus = async (editId) => { - const maxAttempts = 60; // 60 attempts = 1 minute with 1 second interval + const maxAttempts = 120; let attempts = 0; const poll = async () => { @@ -90,28 +131,26 @@ function App() { const edit = await editsApi.get(editId); if (edit.status === 'completed') { - // Reload edits and update image await loadEdits(project.id); - setCurrentImageUrl(projectsApi.getCurrentImageUrl(project.id)); + setCurrentImageUrl(projectsApi.getCurrentImageUrl(project.id) + `?t=${Date.now()}`); setIsProcessing(false); setSelection(null); return; } else if (edit.status === 'failed') { setError(`Edit failed: ${edit.error_message}`); setIsProcessing(false); - await loadEdits(project.id); return; } attempts++; if (attempts < maxAttempts) { - setTimeout(poll, 1000); // Poll every 1 second + setTimeout(poll, 1000); } else { - setError('Edit timeout - please check edit history'); + setError('Edit timeout'); setIsProcessing(false); } } catch (err) { - setError(`Failed to check edit status: ${err.message}`); + setError(`Status check failed: ${err.message}`); setIsProcessing(false); } }; @@ -119,10 +158,10 @@ function App() { poll(); }; - // Handle fix button + // Handle AI fix const handleFix = async () => { if (!selection || !prompt.trim() || !project) { - setError('Please make a selection and enter a prompt'); + setError('Make a selection and enter a prompt'); return; } @@ -140,208 +179,214 @@ function App() { }; const edit = await editsApi.create(project.id, editRequest); - - // Start polling for status pollEditStatus(edit.id); } catch (err) { - setError(`Failed to process edit: ${err.message}`); + setError(`Edit failed: ${err.message}`); setIsProcessing(false); } }; - // Handle revert + // Undo/Redo const handleRevert = useCallback(async (editId) => { if (!project) return; - try { - setError(null); setIsProcessing(true); - await editsApi.revert(project.id, editId); - - // Update image - setCurrentImageUrl(projectsApi.getCurrentImageUrl(project.id)); - + setCurrentImageUrl(projectsApi.getCurrentImageUrl(project.id) + `?t=${Date.now()}`); await loadEdits(project.id); } catch (err) { - setError(`Failed to revert: ${err.message}`); + setError(`Revert failed: ${err.message}`); } finally { setIsProcessing(false); } }, [project]); - // Handle reset const handleReset = useCallback(async () => { if (!project) return; - try { - setError(null); setIsProcessing(true); - await editsApi.reset(project.id); - - // Update image - setCurrentImageUrl(projectsApi.getCurrentImageUrl(project.id)); - + setCurrentImageUrl(projectsApi.getCurrentImageUrl(project.id) + `?t=${Date.now()}`); await loadEdits(project.id); } catch (err) { - setError(`Failed to reset: ${err.message}`); + setError(`Reset failed: ${err.message}`); } finally { setIsProcessing(false); } }, [project]); - // Handle download + // Download const handleDownload = async () => { if (!currentImageUrl) return; - try { - // Fetch the current image - const response = await fetch(`${currentImageUrl}?t=${Date.now()}`); + const response = await fetch(`${currentImageUrl}&download=1`); const blob = await response.blob(); - - // Create download link const url = window.URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; - link.download = `edited-image-${Date.now()}.png`; - document.body.appendChild(link); + link.download = `${project?.name || 'image'}-${Date.now()}.png`; link.click(); - document.body.removeChild(link); window.URL.revokeObjectURL(url); } catch (err) { - setError(`Failed to download: ${err.message}`); + setError(`Download failed: ${err.message}`); } }; - // Handle undo (Ctrl+Z) - const handleUndo = useCallback(async () => { - if (!project || isProcessing) return; - - const completedEdits = editsRef.current.filter(e => e.status === 'completed'); - if (completedEdits.length === 0) return; - - if (currentEditIndex <= 0) { - // Revert to original - await handleReset(); - setCurrentEditIndex(-1); - } else { - // Revert to previous edit - const previousEdit = completedEdits[currentEditIndex - 1]; - await handleRevert(previousEdit.id); - setCurrentEditIndex(currentEditIndex - 1); + // Smart Select (SAM) + const handleSmartSelect = async (x, y) => { + if (!project) return; + try { + setIsProcessing(true); + const maskBlob = await toolsApi.smartSelect(project.id, x, y); + // TODO: Display mask on canvas + console.log('Smart select mask:', maskBlob); + } catch (err) { + setError(`Smart select failed: ${err.message}`); + } finally { + setIsProcessing(false); } - }, [project, isProcessing, currentEditIndex, handleReset, handleRevert]); + }; - // Handle redo (Ctrl+Y) - const handleRedo = useCallback(async () => { - if (!project || isProcessing) return; + // Remove background + const handleRemoveBackground = async () => { + if (!project) return; + try { + setIsProcessing(true); + await toolsApi.removeBackgroundToLayer(project.id); + setCurrentImageUrl(projectsApi.getCurrentImageUrl(project.id) + `?t=${Date.now()}`); + } catch (err) { + setError(`Background removal failed: ${err.message}`); + } finally { + setIsProcessing(false); + } + }; - const completedEdits = editsRef.current.filter(e => e.status === 'completed'); - if (currentEditIndex >= completedEdits.length - 1) return; + // Apply eye + const handleApplyEye = async () => { + if (!project || !selection || !selectedEye) { + setError('Select an area and an eye to apply'); + return; + } + try { + setIsProcessing(true); + const formData = new FormData(); + formData.append('project_id', project.id); + formData.append('patch_id', selectedEye.id); + formData.append('bbox', JSON.stringify(selection.bbox)); + formData.append('feather_px', feather); - const nextEdit = completedEdits[currentEditIndex + 1]; - await handleRevert(nextEdit.id); - setCurrentEditIndex(currentEditIndex + 1); - }, [project, isProcessing, currentEditIndex, handleRevert]); + await fetch('/patches/apply', { + method: 'POST', + body: formData, + }); - // Keyboard shortcut handler + setCurrentImageUrl(projectsApi.getCurrentImageUrl(project.id) + `?t=${Date.now()}`); + await loadEdits(project.id); + } catch (err) { + setError(`Apply eye failed: ${err.message}`); + } finally { + setIsProcessing(false); + } + }; + + // Keyboard shortcuts useEffect(() => { const handleKeyDown = (e) => { - // Don't trigger shortcuts when typing in input fields if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; - if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) { - e.preventDefault(); - handleUndo(); - } else if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) { - e.preventDefault(); - handleRedo(); + // Tool shortcuts + if (!e.ctrlKey && !e.metaKey) { + switch (e.key.toLowerCase()) { + case 'v': setActiveTool('move'); break; + case 'r': setActiveTool('select'); break; + case 'e': setActiveTool('ellipse'); break; + case 'f': setActiveTool('lasso'); break; + case 'w': setActiveTool('magic'); break; + case 'b': setActiveTool('brush'); break; + case 'g': setActiveTool('bucket'); break; + case 'z': setActiveTool('zoom'); break; + case 'h': setActiveTool('pan'); break; + case 'delete': + case 'backspace': + if (selection) { + // TODO: Delete selected area + } + break; + } + } + + // Ctrl shortcuts + if (e.ctrlKey || e.metaKey) { + switch (e.key.toLowerCase()) { + case 'z': + e.preventDefault(); + if (e.shiftKey) { + // Redo + const completed = editsRef.current.filter(ed => ed.status === 'completed'); + if (currentEditIndex < completed.length - 1) { + handleRevert(completed[currentEditIndex + 1].id); + setCurrentEditIndex(i => i + 1); + } + } else { + // Undo + if (currentEditIndex >= 0) { + if (currentEditIndex === 0) { + handleReset(); + } else { + const completed = editsRef.current.filter(ed => ed.status === 'completed'); + handleRevert(completed[currentEditIndex - 1].id); + } + setCurrentEditIndex(i => i - 1); + } + } + break; + case 's': + e.preventDefault(); + handleDownload(); + break; + } } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, [handleUndo, handleRedo]); + }, [selection, currentEditIndex, handleRevert, handleReset]); - // Warn before leaving page when there are unsaved changes - useEffect(() => { - const handleBeforeUnload = (e) => { - if (project && edits.length > 0) { - e.preventDefault(); - e.returnValue = 'You have unsaved changes. Are you sure you want to leave?'; - return e.returnValue; - } - }; + // Zoom controls + const handleZoomIn = () => setZoom(z => Math.min(z + 25, 400)); + const handleZoomOut = () => setZoom(z => Math.max(z - 25, 25)); + const handleZoomReset = () => setZoom(100); - window.addEventListener('beforeunload', handleBeforeUnload); - return () => window.removeEventListener('beforeunload', handleBeforeUnload); - }, [project, edits]); - - // Handle layer creation from advanced tools - const handleLayerCreated = (layer) => { - setLayers((prev) => [...prev, { ...layer, visible: true }]); + // Panel toggle + const togglePanel = (panel) => { + setCollapsedPanels(prev => ({ ...prev, [panel]: !prev[panel] })); }; - // Handle mask generation from smart select / color select - const handleMaskGenerated = async (maskBlob, source) => { - setGeneratedMask({ blob: maskBlob, source }); - // The mask can be used for various operations - }; - - // Handle flatten layers - const handleFlattenLayers = async (layerOrder) => { - if (!project) return; - try { - setIsProcessing(true); - await toolsApi.flattenLayers(project.id, layerOrder); - setCurrentImageUrl(projectsApi.getCurrentImageUrl(project.id)); - setLayers([]); - } catch (err) { - setError(`Failed to flatten layers: ${err.message}`); - } finally { - setIsProcessing(false); - } - }; - - return ( -
-
-
-

AI Photo Edit

-

Have AI regenerate only a selected area of your photo

-
- - {error && ( -
- Error: {error} - -
- )} - - {showProjectInput ? ( + // Project setup overlay + if (showProjectSetup) { + return ( +
+
-

Start Editing

+

Open Image

- + setImageFile(e.target.files[0])} disabled={isProcessing} /> - {imageFile && ( -

Selected: {imageFile.name}

- )} + {imageFile &&

{imageFile.name}

}
-
+
setProjectName(e.target.value)} - placeholder="Uses filename if left empty" + placeholder="Auto-generated from filename" disabled={isProcessing} />
@@ -350,82 +395,301 @@ function App() { onClick={handleCreateProject} disabled={isProcessing || !imageFile} > - {isProcessing ? 'Starting...' : 'Start Editing'} + {isProcessing ? 'Opening...' : 'Open'}
- ) : ( -
-
- +
+
+ ); + } + + return ( +
+ {/* Menu Bar */} +
+

AI Photo Edit

+
+ + + +
+
+ + {/* Error Banner */} + {error && ( +
+ {error} + +
+ )} + + {/* Main Workspace */} +
+ {/* Left Toolbar */} +
+ + +
+ + + + + + + +
+ + + + + +
+ + + + +
+ + {/* Canvas Area */} +
+
+ + {isProcessing && ( +
+
+
+ )} +
+
+
+ + {zoom}% + +
+ Selection: {selection ? `${selection.bbox?.width || 0}×${selection.bbox?.height || 0}` : 'None'} + Tool: {TOOLS[activeTool]?.name || activeTool} +
+
-
- - - { - setCurrentImageUrl(projectsApi.getCurrentImageUrl(project.id)); - }} - isProcessing={isProcessing} - setIsProcessing={setIsProcessing} - setError={setError} - /> - - - - { - // Reload image after applying eye - setCurrentImageUrl(projectsApi.getCurrentImageUrl(project.id)); - await loadEdits(project.id); - }} - isProcessing={isProcessing} - /> - -
- + {/* Right Sidebar */} +
+ {/* Tool Options Panel */} +
+
togglePanel('toolOptions')}> +

Tool Options

+ {collapsedPanels.toolOptions ? '▶' : '▼'} +
+
+
+ +
+ setFeather(parseInt(e.target.value))} + /> + {feather}px +
- )} + + {/* AI Edit Panel */} +
+
togglePanel('aiEdit')}> +

AI Edit

+ {collapsedPanels.aiEdit ? '▶' : '▼'} +
+
+
+ +
+ + +
+
+
+ +