Merge branch 'main' into claude/automate-sam-download-mzpRs
This commit is contained in:
+590
-68
@@ -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;
|
||||
}
|
||||
|
||||
+497
-284
@@ -1,37 +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 [advancedToolMode, setAdvancedToolMode] = useState(null); // 'smart-select', 'color-select', 'object-remove'
|
||||
const [canvasZoom, setCanvasZoom] = useState(1);
|
||||
const [externalSelection, setExternalSelection] = useState(null); // For smart-select/color-select polygon results
|
||||
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');
|
||||
@@ -42,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}`);
|
||||
@@ -69,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) {
|
||||
@@ -83,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 () => {
|
||||
@@ -93,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);
|
||||
}
|
||||
};
|
||||
@@ -122,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;
|
||||
}
|
||||
|
||||
@@ -143,248 +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 (maskData, source) => {
|
||||
setGeneratedMask({ data: maskData, source });
|
||||
// Convert mask to selection if it contains polygon data
|
||||
if (maskData && maskData.polygon && maskData.polygon.length > 0) {
|
||||
// Set external selection for canvas to draw
|
||||
setExternalSelection({
|
||||
polygon: maskData.polygon,
|
||||
bbox: maskData.bbox,
|
||||
});
|
||||
// Also set selection state for fix button
|
||||
setSelection({
|
||||
type: 'polygon',
|
||||
bbox: maskData.bbox,
|
||||
selectionData: { points: maskData.polygon },
|
||||
});
|
||||
}
|
||||
// Reset tool mode after selection
|
||||
setAdvancedToolMode(null);
|
||||
};
|
||||
|
||||
// Handle canvas click for advanced tools (smart select, color select)
|
||||
const handleAdvancedToolClick = async (x, y, color) => {
|
||||
if (!project || !advancedToolMode) return;
|
||||
|
||||
try {
|
||||
setIsProcessing(true);
|
||||
setError(null);
|
||||
|
||||
if (advancedToolMode === 'smart-select') {
|
||||
const result = await toolsApi.smartSelect(project.id, x, y);
|
||||
handleMaskGenerated(result, 'smart-select');
|
||||
} else if (advancedToolMode === 'color-select') {
|
||||
// Color is passed from canvas click
|
||||
if (color) {
|
||||
const result = await toolsApi.colorSelect(project.id, color.r, color.g, color.b, 30);
|
||||
handleMaskGenerated(result, 'color-select');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(`${advancedToolMode} failed: ${err.message}`);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 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 (
|
||||
<div className="app">
|
||||
<div className="container">
|
||||
<div className="header">
|
||||
<h1>AI Photo Edit</h1>
|
||||
<p>Have AI regenerate only a selected area of your photo</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="error-banner">
|
||||
<strong>Error:</strong> {error}
|
||||
<button onClick={() => setError(null)}>✕</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showProjectInput ? (
|
||||
// Project setup overlay
|
||||
if (showProjectSetup) {
|
||||
return (
|
||||
<div className="app">
|
||||
<div className="project-setup-overlay">
|
||||
<div className="project-setup">
|
||||
<h2>Start Editing</h2>
|
||||
<h2>Open Image</h2>
|
||||
<div className="setup-form">
|
||||
<div className="form-group">
|
||||
<label>Upload Image</label>
|
||||
<label>Select Image</label>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => setImageFile(e.target.files[0])}
|
||||
disabled={isProcessing}
|
||||
/>
|
||||
{imageFile && (
|
||||
<p className="file-name">Selected: {imageFile.name}</p>
|
||||
)}
|
||||
{imageFile && <p className="file-name">{imageFile.name}</p>}
|
||||
</div>
|
||||
<div className="form-group optional-field">
|
||||
<div className="form-group">
|
||||
<label>Project Name (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={projectName}
|
||||
onChange={(e) => setProjectName(e.target.value)}
|
||||
placeholder="Uses filename if left empty"
|
||||
placeholder="Auto-generated from filename"
|
||||
disabled={isProcessing}
|
||||
/>
|
||||
</div>
|
||||
@@ -393,90 +395,301 @@ function App() {
|
||||
onClick={handleCreateProject}
|
||||
disabled={isProcessing || !imageFile}
|
||||
>
|
||||
{isProcessing ? 'Starting...' : 'Start Editing'}
|
||||
{isProcessing ? 'Opening...' : 'Open'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="workspace">
|
||||
<div className="left-panel">
|
||||
<ImageCanvas
|
||||
imageUrl={currentImageUrl}
|
||||
onSelectionChange={setSelection}
|
||||
selectionMode={selectionMode}
|
||||
advancedToolMode={advancedToolMode}
|
||||
onAdvancedToolClick={handleAdvancedToolClick}
|
||||
zoom={canvasZoom}
|
||||
onZoomChange={setCanvasZoom}
|
||||
externalSelection={externalSelection}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
{/* Menu Bar */}
|
||||
<div className="menu-bar">
|
||||
<h1>AI Photo Edit</h1>
|
||||
<div className="menu-actions">
|
||||
<button className="menu-btn" onClick={() => setShowProjectSetup(true)}>New</button>
|
||||
<button className="menu-btn" onClick={handleDownload}>Save</button>
|
||||
<button className="menu-btn" onClick={handleReset}>Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Banner */}
|
||||
{error && (
|
||||
<div className="error-banner">
|
||||
{error}
|
||||
<button onClick={() => setError(null)}>✕</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Workspace */}
|
||||
<div className="workspace">
|
||||
{/* Left Toolbar */}
|
||||
<div className="toolbar">
|
||||
<button
|
||||
className={`tool-btn ${activeTool === 'move' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTool('move')}
|
||||
title="Move (V)"
|
||||
>✥</button>
|
||||
|
||||
<div className="tool-divider" />
|
||||
|
||||
<button
|
||||
className={`tool-btn ${activeTool === 'select' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTool('select')}
|
||||
title="Rectangle Select (R)"
|
||||
>▢</button>
|
||||
<button
|
||||
className={`tool-btn ${activeTool === 'ellipse' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTool('ellipse')}
|
||||
title="Ellipse Select (E)"
|
||||
>○</button>
|
||||
<button
|
||||
className={`tool-btn ${activeTool === 'lasso' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTool('lasso')}
|
||||
title="Free Select (F)"
|
||||
>✎</button>
|
||||
<button
|
||||
className={`tool-btn ${activeTool === 'magic' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTool('magic')}
|
||||
title="Smart Select - SAM (W)"
|
||||
>✨</button>
|
||||
<button
|
||||
className={`tool-btn ${activeTool === 'colorPick' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTool('colorPick')}
|
||||
title="Color Select (U)"
|
||||
>◉</button>
|
||||
|
||||
<div className="tool-divider" />
|
||||
|
||||
<button
|
||||
className={`tool-btn ${activeTool === 'brush' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTool('brush')}
|
||||
title="Brush (B)"
|
||||
>🖌</button>
|
||||
<button
|
||||
className={`tool-btn ${activeTool === 'bucket' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTool('bucket')}
|
||||
title="Bucket Fill (G)"
|
||||
>◧</button>
|
||||
<button
|
||||
className={`tool-btn ${activeTool === 'eraser' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTool('eraser')}
|
||||
title="Eraser (Shift+E)"
|
||||
>◫</button>
|
||||
|
||||
<div className="tool-divider" />
|
||||
|
||||
<button
|
||||
className={`tool-btn ${activeTool === 'eyedropper' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTool('eyedropper')}
|
||||
title="Color Picker (O)"
|
||||
>💧</button>
|
||||
<button
|
||||
className={`tool-btn ${activeTool === 'zoom' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTool('zoom')}
|
||||
title="Zoom (Z)"
|
||||
>🔍</button>
|
||||
<button
|
||||
className={`tool-btn ${activeTool === 'pan' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTool('pan')}
|
||||
title="Pan (H)"
|
||||
>✋</button>
|
||||
</div>
|
||||
|
||||
{/* Canvas Area */}
|
||||
<div className="canvas-area">
|
||||
<div className="canvas-wrapper">
|
||||
<ImageCanvas
|
||||
ref={canvasRef}
|
||||
imageUrl={currentImageUrl}
|
||||
onSelectionChange={setSelection}
|
||||
selectionMode={getSelectionMode()}
|
||||
activeTool={activeTool}
|
||||
zoom={zoom}
|
||||
onSmartSelect={handleSmartSelect}
|
||||
isProcessing={isProcessing}
|
||||
/>
|
||||
{isProcessing && (
|
||||
<div className="processing-overlay">
|
||||
<div className="processing-spinner" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="canvas-status">
|
||||
<div className="zoom-controls">
|
||||
<button className="zoom-btn" onClick={handleZoomOut}>−</button>
|
||||
<span className="zoom-level">{zoom}%</span>
|
||||
<button className="zoom-btn" onClick={handleZoomIn}>+</button>
|
||||
<button className="zoom-btn" onClick={handleZoomReset}>⟲</button>
|
||||
</div>
|
||||
<span>Selection: {selection ? `${selection.bbox?.width || 0}×${selection.bbox?.height || 0}` : 'None'}</span>
|
||||
<span>Tool: {TOOLS[activeTool]?.name || activeTool}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="right-panel">
|
||||
<Controls
|
||||
selectionMode={selectionMode}
|
||||
onSelectionModeChange={setSelectionMode}
|
||||
mode={mode}
|
||||
onModeChange={setMode}
|
||||
feather={feather}
|
||||
onFeatherChange={setFeather}
|
||||
prompt={prompt}
|
||||
onPromptChange={setPrompt}
|
||||
onFix={handleFix}
|
||||
onDownload={handleDownload}
|
||||
isProcessing={isProcessing}
|
||||
hasSelection={!!selection}
|
||||
/>
|
||||
|
||||
<AdvancedTools
|
||||
projectId={project?.id}
|
||||
selection={selection}
|
||||
onLayerCreated={handleLayerCreated}
|
||||
onMaskGenerated={handleMaskGenerated}
|
||||
onImageUpdate={() => {
|
||||
setCurrentImageUrl(projectsApi.getCurrentImageUrl(project.id));
|
||||
}}
|
||||
isProcessing={isProcessing}
|
||||
setIsProcessing={setIsProcessing}
|
||||
setError={setError}
|
||||
activeToolMode={advancedToolMode}
|
||||
setActiveToolMode={setAdvancedToolMode}
|
||||
/>
|
||||
|
||||
<Layers
|
||||
projectId={project?.id}
|
||||
layers={layers}
|
||||
setLayers={setLayers}
|
||||
activeLayer={activeLayer}
|
||||
setActiveLayer={setActiveLayer}
|
||||
onFlatten={handleFlattenLayers}
|
||||
isProcessing={isProcessing}
|
||||
onError={setError}
|
||||
/>
|
||||
|
||||
<EyeCatalog
|
||||
projectId={project?.id}
|
||||
selection={selection}
|
||||
feather={feather}
|
||||
onApply={async () => {
|
||||
// Reload image after applying eye
|
||||
setCurrentImageUrl(projectsApi.getCurrentImageUrl(project.id));
|
||||
await loadEdits(project.id);
|
||||
}}
|
||||
isProcessing={isProcessing}
|
||||
/>
|
||||
|
||||
<div className="history-wrapper">
|
||||
<History
|
||||
edits={edits}
|
||||
onRevert={handleRevert}
|
||||
onReset={handleReset}
|
||||
isProcessing={isProcessing}
|
||||
/>
|
||||
{/* Right Sidebar */}
|
||||
<div className="sidebar">
|
||||
{/* Tool Options Panel */}
|
||||
<div className="sidebar-panel">
|
||||
<div className="panel-header" onClick={() => togglePanel('toolOptions')}>
|
||||
<h3>Tool Options</h3>
|
||||
<span className="panel-toggle">{collapsedPanels.toolOptions ? '▶' : '▼'}</span>
|
||||
</div>
|
||||
<div className={`panel-content ${collapsedPanels.toolOptions ? 'collapsed' : ''}`}>
|
||||
<div className="control-group">
|
||||
<label className="control-label">Feather</label>
|
||||
<div className="slider-row">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="50"
|
||||
value={feather}
|
||||
onChange={(e) => setFeather(parseInt(e.target.value))}
|
||||
/>
|
||||
<span className="slider-value">{feather}px</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Edit Panel */}
|
||||
<div className="sidebar-panel">
|
||||
<div className="panel-header" onClick={() => togglePanel('aiEdit')}>
|
||||
<h3>AI Edit</h3>
|
||||
<span className="panel-toggle">{collapsedPanels.aiEdit ? '▶' : '▼'}</span>
|
||||
</div>
|
||||
<div className={`panel-content ${collapsedPanels.aiEdit ? 'collapsed' : ''}`}>
|
||||
<div className="control-group">
|
||||
<label className="control-label">Mode</label>
|
||||
<div className="control-row">
|
||||
<button
|
||||
className={`mode-btn ${mode === 'A' ? 'active' : ''}`}
|
||||
onClick={() => setMode('A')}
|
||||
>Patch Only</button>
|
||||
<button
|
||||
className={`mode-btn ${mode === 'B' ? 'active' : ''}`}
|
||||
onClick={() => setMode('B')}
|
||||
>With Context</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="control-group">
|
||||
<label className="control-label">Prompt</label>
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder="Describe what to fix or change..."
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="action-btn"
|
||||
onClick={handleFix}
|
||||
disabled={isProcessing || !selection || !prompt.trim()}
|
||||
>
|
||||
{isProcessing ? 'Processing...' : 'Apply AI Edit'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions Panel */}
|
||||
<div className="sidebar-panel">
|
||||
<div className="panel-header" onClick={() => togglePanel('actions')}>
|
||||
<h3>Quick Actions</h3>
|
||||
<span className="panel-toggle">{collapsedPanels.actions ? '▶' : '▼'}</span>
|
||||
</div>
|
||||
<div className={`panel-content ${collapsedPanels.actions ? 'collapsed' : ''}`}>
|
||||
<button
|
||||
className="action-btn secondary"
|
||||
onClick={handleRemoveBackground}
|
||||
disabled={isProcessing}
|
||||
>Remove Background</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Eyes Panel */}
|
||||
<div className="sidebar-panel">
|
||||
<div className="panel-header" onClick={() => togglePanel('eyes')}>
|
||||
<h3>Eye Catalog</h3>
|
||||
<span className="panel-toggle">{collapsedPanels.eyes ? '▶' : '▼'}</span>
|
||||
</div>
|
||||
<div className={`panel-content ${collapsedPanels.eyes ? 'collapsed' : ''}`}>
|
||||
{eyes.length > 0 ? (
|
||||
<>
|
||||
<div className="eye-grid">
|
||||
{eyes.map(eye => (
|
||||
<div
|
||||
key={eye.id}
|
||||
className={`eye-item ${selectedEye?.id === eye.id ? 'selected' : ''}`}
|
||||
onClick={() => setSelectedEye(eye)}
|
||||
>
|
||||
<img src={`/patches/${eye.id}/image?thumbnail=true`} alt={eye.name} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="action-btn"
|
||||
onClick={handleApplyEye}
|
||||
disabled={isProcessing || !selection || !selectedEye}
|
||||
style={{ marginTop: '12px' }}
|
||||
>Apply Eye to Selection</button>
|
||||
</>
|
||||
) : (
|
||||
<p style={{ fontSize: '12px', color: '#888' }}>No eyes in catalog</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Layers Panel */}
|
||||
<div className="sidebar-panel">
|
||||
<div className="panel-header" onClick={() => togglePanel('layers')}>
|
||||
<h3>Layers</h3>
|
||||
<span className="panel-toggle">{collapsedPanels.layers ? '▶' : '▼'}</span>
|
||||
</div>
|
||||
<div className={`panel-content ${collapsedPanels.layers ? 'collapsed' : ''}`}>
|
||||
<div className="layer-list">
|
||||
<div className="layer-item active">
|
||||
<button className="layer-visibility visible">👁</button>
|
||||
<span className="layer-name">Background</span>
|
||||
</div>
|
||||
{layers.map((layer) => (
|
||||
<div key={layer.id} className="layer-item">
|
||||
<button className="layer-visibility visible">👁</button>
|
||||
<span className="layer-name">{layer.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* History Panel */}
|
||||
<div className="sidebar-panel">
|
||||
<div className="panel-header" onClick={() => togglePanel('history')}>
|
||||
<h3>History</h3>
|
||||
<span className="panel-toggle">{collapsedPanels.history ? '▶' : '▼'}</span>
|
||||
</div>
|
||||
<div className={`panel-content ${collapsedPanels.history ? 'collapsed' : ''}`}>
|
||||
<div className="history-list">
|
||||
<div
|
||||
className={`history-item ${currentEditIndex === -1 ? 'current' : ''}`}
|
||||
onClick={handleReset}
|
||||
>
|
||||
Original
|
||||
</div>
|
||||
{edits.filter(e => e.status === 'completed').map((edit, i) => (
|
||||
<div
|
||||
key={edit.id}
|
||||
className={`history-item ${currentEditIndex === i ? 'current' : ''}`}
|
||||
onClick={() => handleRevert(edit.id)}
|
||||
>
|
||||
{edit.prompt?.substring(0, 30) || `Edit ${i + 1}`}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
/* ImageCanvas fills the entire canvas wrapper */
|
||||
.canvas-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: calc(100vh - 180px);
|
||||
background-color: #2a2a2a;
|
||||
border: 2px solid #444;
|
||||
border-radius: 8px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.canvas-container canvas {
|
||||
@@ -45,16 +41,36 @@
|
||||
|
||||
.selection-hint {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
bottom: 40px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
color: #00ff00;
|
||||
padding: 8px 16px;
|
||||
color: #0088ff;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
z-index: 10;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Clear selection button */
|
||||
.clear-selection-btn {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
background-color: #cc3333;
|
||||
color: white;
|
||||
padding: 6px 12px;
|
||||
font-size: 11px;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.clear-selection-btn:hover {
|
||||
background-color: #dd4444;
|
||||
}
|
||||
|
||||
/* Zoom controls */
|
||||
|
||||
Reference in New Issue
Block a user