Merge branch 'main' into claude/automate-sam-download-mzpRs

This commit is contained in:
outis1one
2026-01-26 00:19:26 -05:00
committed by GitHub
8 changed files with 1511 additions and 721 deletions
+63
View File
@@ -0,0 +1,63 @@
# ==============================================================================
# AI Photo Edit - Unified Container
# Builds React frontend and serves it alongside FastAPI backend
# ==============================================================================
# Stage 1: Build React frontend
FROM node:20-alpine AS frontend-build
WORKDIR /frontend
# Copy package files and install dependencies
COPY frontend/package.json frontend/package-lock.json* ./
RUN npm install
# Copy frontend source and build
COPY frontend/ ./
RUN npm run build
# Stage 2: Python backend with frontend static files
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies for OpenCV, rembg, SAM, and image processing
RUN apt-get update && apt-get install -y \
libgl1 \
libglib2.0-0 \
libsm6 \
libxext6 \
libxrender-dev \
libgomp1 \
wget \
git \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements and install Python dependencies
COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Pre-download rembg model (u2net) to avoid first-run delay
RUN python -c "from rembg import remove; print('rembg model downloaded')" || true
# Copy backend application
COPY backend/ .
# Copy entrypoint script
COPY backend/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Copy scripts
COPY scripts/ /scripts/
# Copy built frontend from stage 1
COPY --from=frontend-build /frontend/dist /app/static
# Create data directories
RUN mkdir -p /app/data/projects /app/data/patches /app/data/models
# Expose port
EXPOSE 8000
# Use entrypoint script
ENTRYPOINT ["/entrypoint.sh"]
+48 -4
View File
@@ -1,7 +1,10 @@
from fastapi import FastAPI from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, HTMLResponse
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path
import os
from app.config import settings from app.config import settings
from app.database import init_db from app.database import init_db
@@ -40,9 +43,9 @@ app.include_router(generate.router)
app.include_router(tools.router) app.include_router(tools.router)
@app.get("/") @app.get("/api")
def root(): def api_root():
"""API root endpoint""" """API info endpoint"""
return { return {
"name": "AI Photo Edit API", "name": "AI Photo Edit API",
"version": "1.0.0", "version": "1.0.0",
@@ -54,3 +57,44 @@ def root():
def health(): def health():
"""Health check endpoint""" """Health check endpoint"""
return {"status": "healthy"} return {"status": "healthy"}
# Static files directory
STATIC_DIR = Path("/app/static")
# Serve static assets (JS, CSS, images)
if STATIC_DIR.exists():
app.mount("/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="assets")
@app.get("/", response_class=HTMLResponse)
async def serve_spa():
"""Serve React SPA index.html"""
index_path = STATIC_DIR / "index.html"
if index_path.exists():
return FileResponse(index_path)
return HTMLResponse("<h1>Frontend not built. Run npm build in frontend/</h1>")
@app.get("/{full_path:path}")
async def serve_spa_routes(request: Request, full_path: str):
"""
Catch-all route for React Router (SPA).
Serves static files if they exist, otherwise returns index.html.
"""
# Don't catch API routes
if full_path.startswith(("projects", "edits", "patches", "tools", "generate", "health", "docs", "openapi.json", "api")):
return {"detail": "Not Found"}
# Check if it's a static file
static_file = STATIC_DIR / full_path
if static_file.exists() and static_file.is_file():
return FileResponse(static_file)
# Otherwise serve index.html for React Router
index_path = STATIC_DIR / "index.html"
if index_path.exists():
return FileResponse(index_path)
return HTMLResponse("<h1>Frontend not built</h1>", status_code=404)
+5 -28
View File
@@ -1,16 +1,13 @@
version: '3.8'
services: services:
backend: app:
build: build:
context: ./backend context: .
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: ai-photo-edit-backend container_name: ai-photo-edit
ports: ports:
- "8101:8000" # External 8101, internal 8000 - "3080:8000"
volumes: volumes:
- ./data:/app/data - ./data:/app/data
- ./backend:/app
- ./scripts:/scripts - ./scripts:/scripts
environment: environment:
- DATABASE_URL=sqlite:///./data/ai_photo_edit.db - DATABASE_URL=sqlite:///./data/ai_photo_edit.db
@@ -19,28 +16,8 @@ services:
- OPENAI_API_KEY=${OPENAI_API_KEY:-} - OPENAI_API_KEY=${OPENAI_API_KEY:-}
- STABILITY_API_KEY=${STABILITY_API_KEY:-} - STABILITY_API_KEY=${STABILITY_API_KEY:-}
- REPLICATE_API_KEY=${REPLICATE_API_KEY:-} - REPLICATE_API_KEY=${REPLICATE_API_KEY:-}
- CORS_ORIGINS=http://localhost:5173,http://localhost:3000,http://localhost - CORS_ORIGINS=*
- AUTO_DOWNLOAD_SAM=${AUTO_DOWNLOAD_SAM:-true}
restart: unless-stopped restart: unless-stopped
networks:
- ai-photo-edit-network
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: ai-photo-edit-frontend
ports:
- "3080:80" # Frontend on port 3080
depends_on:
- backend
restart: unless-stopped
networks:
- ai-photo-edit-network
networks:
ai-photo-edit-network:
driver: bridge
volumes: volumes:
data: data:
+10
View File
@@ -47,6 +47,16 @@ server {
proxy_cache_bypass $http_upgrade; proxy_cache_bypass $http_upgrade;
} }
location /tools {
proxy_pass http://backend:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 300s;
}
location /generate { location /generate {
proxy_pass http://backend:8000; proxy_pass http://backend:8000;
proxy_http_version 1.1; proxy_http_version 1.1;
+590 -68
View File
@@ -1,14 +1,366 @@
.app { /* GIMP-like layout */
min-height: 100vh; * {
background-color: #1a1a1a; box-sizing: border-box;
} }
.error-banner { html, body, #root {
background-color: #ff4444; 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; color: white;
padding: 12px 16px; font-size: 12px;
border-radius: 6px; font-weight: 600;
margin-bottom: 20px; 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; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
@@ -16,115 +368,285 @@
.error-banner button { .error-banner button {
background: none; background: none;
border: none;
color: white; color: white;
font-size: 20px; font-size: 16px;
cursor: pointer;
padding: 0; 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; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 1000;
} }
.project-setup { .project-setup {
max-width: 600px; background: #2d2d2d;
margin: 0 auto; border: 1px solid #404040;
padding: 40px;
background-color: #2a2a2a;
border-radius: 8px; border-radius: 8px;
border: 1px solid #444; padding: 24px;
width: 400px;
max-width: 90vw;
} }
.project-setup h2 { .project-setup h2 {
font-size: 24px; font-size: 18px;
margin-bottom: 24px; margin: 0 0 20px 0;
text-align: center; text-align: center;
} }
.setup-form { .setup-form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 20px; gap: 16px;
} }
.form-group { .form-group {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 6px;
} }
.form-group label { .form-group label {
font-size: 14px; font-size: 12px;
font-weight: 600; color: #888;
color: #cccccc; text-transform: uppercase;
} }
.form-group input { .form-group input[type="text"],
width: 100%; .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 { .file-name {
font-size: 12px; font-size: 11px;
color: #00ff00; color: #0088ff;
margin-top: 4px;
}
.optional-field label::after {
content: '';
}
.optional-field {
opacity: 0.7;
}
.optional-field:focus-within {
opacity: 1;
} }
.create-project-btn { .create-project-btn {
background-color: #0066ff; background: #0066cc;
border: none;
color: white; color: white;
padding: 14px 20px; padding: 12px;
font-size: 16px; font-size: 14px;
font-weight: 600; font-weight: 600;
margin-top: 10px; border-radius: 3px;
cursor: pointer;
margin-top: 8px;
} }
.create-project-btn:hover:not(:disabled) { .create-project-btn:hover:not(:disabled) {
background-color: #0055dd; background: #0077ee;
} }
.workspace { .create-project-btn:disabled {
display: grid; background: #404040;
grid-template-columns: 1fr 400px; color: #666;
gap: 20px;
min-height: calc(100vh - 180px);
height: calc(100vh - 180px);
} }
@media (max-width: 1200px) { /* Layer list */
.workspace { .layer-list {
grid-template-columns: 1fr;
}
}
.left-panel {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; gap: 4px;
min-height: 0;
} }
.right-panel { .layer-item {
display: flex; display: flex;
flex-direction: column; align-items: center;
gap: 12px; gap: 8px;
overflow-y: auto; padding: 6px 8px;
max-height: calc(100vh - 180px); background: #353535;
padding-right: 8px; 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; 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;
} }
+495 -282
View File
@@ -1,37 +1,84 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'; import React, { useState, useEffect, useCallback, useRef } from 'react';
import ImageCanvas from './components/ImageCanvas'; 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 { projectsApi, editsApi, toolsApi } from './utils/api';
import './App.css'; 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() { function App() {
// Project state
const [project, setProject] = useState(null); const [project, setProject] = useState(null);
const [imageFile, setImageFile] = useState(null); const [imageFile, setImageFile] = useState(null);
const [currentImageUrl, setCurrentImageUrl] = useState(null); const [currentImageUrl, setCurrentImageUrl] = useState(null);
const [selection, setSelection] = useState(null); const [showProjectSetup, setShowProjectSetup] = useState(true);
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 [projectName, setProjectName] = useState(''); 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 [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([]); 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 () => { const handleCreateProject = async () => {
if (!imageFile) { if (!imageFile) {
setError('Please select an image'); setError('Please select an image');
@@ -42,25 +89,17 @@ function App() {
setError(null); setError(null);
setIsProcessing(true); setIsProcessing(true);
// Generate default project name from file name or timestamp
const defaultName = projectName.trim() || const defaultName = projectName.trim() ||
imageFile.name.replace(/\.[^/.]+$/, '') || imageFile.name.replace(/\.[^/.]+$/, '') ||
`Project ${Date.now()}`; `Project ${Date.now()}`;
// Create project
const newProject = await projectsApi.create(defaultName); const newProject = await projectsApi.create(defaultName);
setProject(newProject); setProject(newProject);
// Upload image
await projectsApi.uploadImage(newProject.id, imageFile); await projectsApi.uploadImage(newProject.id, imageFile);
// Set current image URL
setCurrentImageUrl(projectsApi.getCurrentImageUrl(newProject.id)); setCurrentImageUrl(projectsApi.getCurrentImageUrl(newProject.id));
setShowProjectSetup(false);
// Hide project input
setShowProjectInput(false);
// Load edits
await loadEdits(newProject.id); await loadEdits(newProject.id);
} catch (err) { } catch (err) {
setError(`Failed to create project: ${err.message}`); setError(`Failed to create project: ${err.message}`);
@@ -69,13 +108,12 @@ function App() {
} }
}; };
// Load edits for the project // Load edits
const loadEdits = async (projectId) => { const loadEdits = async (projectId) => {
try { try {
const projectEdits = await projectsApi.getEdits(projectId); const projectEdits = await projectsApi.getEdits(projectId);
setEdits(projectEdits); setEdits(projectEdits);
editsRef.current = projectEdits; editsRef.current = projectEdits;
// Set index to latest completed edit
const completedEdits = projectEdits.filter(e => e.status === 'completed'); const completedEdits = projectEdits.filter(e => e.status === 'completed');
setCurrentEditIndex(completedEdits.length - 1); setCurrentEditIndex(completedEdits.length - 1);
} catch (err) { } catch (err) {
@@ -83,9 +121,9 @@ function App() {
} }
}; };
// Poll for edit status // Poll edit status
const pollEditStatus = async (editId) => { const pollEditStatus = async (editId) => {
const maxAttempts = 60; // 60 attempts = 1 minute with 1 second interval const maxAttempts = 120;
let attempts = 0; let attempts = 0;
const poll = async () => { const poll = async () => {
@@ -93,28 +131,26 @@ function App() {
const edit = await editsApi.get(editId); const edit = await editsApi.get(editId);
if (edit.status === 'completed') { if (edit.status === 'completed') {
// Reload edits and update image
await loadEdits(project.id); await loadEdits(project.id);
setCurrentImageUrl(projectsApi.getCurrentImageUrl(project.id)); setCurrentImageUrl(projectsApi.getCurrentImageUrl(project.id) + `?t=${Date.now()}`);
setIsProcessing(false); setIsProcessing(false);
setSelection(null); setSelection(null);
return; return;
} else if (edit.status === 'failed') { } else if (edit.status === 'failed') {
setError(`Edit failed: ${edit.error_message}`); setError(`Edit failed: ${edit.error_message}`);
setIsProcessing(false); setIsProcessing(false);
await loadEdits(project.id);
return; return;
} }
attempts++; attempts++;
if (attempts < maxAttempts) { if (attempts < maxAttempts) {
setTimeout(poll, 1000); // Poll every 1 second setTimeout(poll, 1000);
} else { } else {
setError('Edit timeout - please check edit history'); setError('Edit timeout');
setIsProcessing(false); setIsProcessing(false);
} }
} catch (err) { } catch (err) {
setError(`Failed to check edit status: ${err.message}`); setError(`Status check failed: ${err.message}`);
setIsProcessing(false); setIsProcessing(false);
} }
}; };
@@ -122,10 +158,10 @@ function App() {
poll(); poll();
}; };
// Handle fix button // Handle AI fix
const handleFix = async () => { const handleFix = async () => {
if (!selection || !prompt.trim() || !project) { if (!selection || !prompt.trim() || !project) {
setError('Please make a selection and enter a prompt'); setError('Make a selection and enter a prompt');
return; return;
} }
@@ -143,248 +179,214 @@ function App() {
}; };
const edit = await editsApi.create(project.id, editRequest); const edit = await editsApi.create(project.id, editRequest);
// Start polling for status
pollEditStatus(edit.id); pollEditStatus(edit.id);
} catch (err) { } catch (err) {
setError(`Failed to process edit: ${err.message}`); setError(`Edit failed: ${err.message}`);
setIsProcessing(false); setIsProcessing(false);
} }
}; };
// Handle revert // Undo/Redo
const handleRevert = useCallback(async (editId) => { const handleRevert = useCallback(async (editId) => {
if (!project) return; if (!project) return;
try { try {
setError(null);
setIsProcessing(true); setIsProcessing(true);
await editsApi.revert(project.id, editId); await editsApi.revert(project.id, editId);
setCurrentImageUrl(projectsApi.getCurrentImageUrl(project.id) + `?t=${Date.now()}`);
// Update image
setCurrentImageUrl(projectsApi.getCurrentImageUrl(project.id));
await loadEdits(project.id); await loadEdits(project.id);
} catch (err) { } catch (err) {
setError(`Failed to revert: ${err.message}`); setError(`Revert failed: ${err.message}`);
} finally { } finally {
setIsProcessing(false); setIsProcessing(false);
} }
}, [project]); }, [project]);
// Handle reset
const handleReset = useCallback(async () => { const handleReset = useCallback(async () => {
if (!project) return; if (!project) return;
try { try {
setError(null);
setIsProcessing(true); setIsProcessing(true);
await editsApi.reset(project.id); await editsApi.reset(project.id);
setCurrentImageUrl(projectsApi.getCurrentImageUrl(project.id) + `?t=${Date.now()}`);
// Update image
setCurrentImageUrl(projectsApi.getCurrentImageUrl(project.id));
await loadEdits(project.id); await loadEdits(project.id);
} catch (err) { } catch (err) {
setError(`Failed to reset: ${err.message}`); setError(`Reset failed: ${err.message}`);
} finally { } finally {
setIsProcessing(false); setIsProcessing(false);
} }
}, [project]); }, [project]);
// Handle download // Download
const handleDownload = async () => { const handleDownload = async () => {
if (!currentImageUrl) return; if (!currentImageUrl) return;
try { try {
// Fetch the current image const response = await fetch(`${currentImageUrl}&download=1`);
const response = await fetch(`${currentImageUrl}?t=${Date.now()}`);
const blob = await response.blob(); const blob = await response.blob();
// Create download link
const url = window.URL.createObjectURL(blob); const url = window.URL.createObjectURL(blob);
const link = document.createElement('a'); const link = document.createElement('a');
link.href = url; link.href = url;
link.download = `edited-image-${Date.now()}.png`; link.download = `${project?.name || 'image'}-${Date.now()}.png`;
document.body.appendChild(link);
link.click(); link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url); window.URL.revokeObjectURL(url);
} catch (err) { } catch (err) {
setError(`Failed to download: ${err.message}`); setError(`Download failed: ${err.message}`);
} }
}; };
// Handle undo (Ctrl+Z) // Smart Select (SAM)
const handleUndo = useCallback(async () => { const handleSmartSelect = async (x, y) => {
if (!project || isProcessing) return; if (!project) return;
try {
const completedEdits = editsRef.current.filter(e => e.status === 'completed'); setIsProcessing(true);
if (completedEdits.length === 0) return; const maskBlob = await toolsApi.smartSelect(project.id, x, y);
// TODO: Display mask on canvas
if (currentEditIndex <= 0) { console.log('Smart select mask:', maskBlob);
// Revert to original } catch (err) {
await handleReset(); setError(`Smart select failed: ${err.message}`);
setCurrentEditIndex(-1); } finally {
} else { setIsProcessing(false);
// Revert to previous edit
const previousEdit = completedEdits[currentEditIndex - 1];
await handleRevert(previousEdit.id);
setCurrentEditIndex(currentEditIndex - 1);
} }
}, [project, isProcessing, currentEditIndex, handleReset, handleRevert]); };
// Handle redo (Ctrl+Y) // Remove background
const handleRedo = useCallback(async () => { const handleRemoveBackground = async () => {
if (!project || isProcessing) return; 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'); // Apply eye
if (currentEditIndex >= completedEdits.length - 1) return; 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 fetch('/patches/apply', {
await handleRevert(nextEdit.id); method: 'POST',
setCurrentEditIndex(currentEditIndex + 1); body: formData,
}, [project, isProcessing, currentEditIndex, handleRevert]); });
// 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(() => { useEffect(() => {
const handleKeyDown = (e) => { const handleKeyDown = (e) => {
// Don't trigger shortcuts when typing in input fields
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) { // 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(); e.preventDefault();
handleUndo(); if (e.shiftKey) {
} else if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && 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(); e.preventDefault();
handleRedo(); handleDownload();
break;
}
} }
}; };
window.addEventListener('keydown', handleKeyDown); window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleUndo, handleRedo]); }, [selection, currentEditIndex, handleRevert, handleReset]);
// Warn before leaving page when there are unsaved changes // Zoom controls
useEffect(() => { const handleZoomIn = () => setZoom(z => Math.min(z + 25, 400));
const handleBeforeUnload = (e) => { const handleZoomOut = () => setZoom(z => Math.max(z - 25, 25));
if (project && edits.length > 0) { const handleZoomReset = () => setZoom(100);
e.preventDefault();
e.returnValue = 'You have unsaved changes. Are you sure you want to leave?'; // Panel toggle
return e.returnValue; const togglePanel = (panel) => {
} setCollapsedPanels(prev => ({ ...prev, [panel]: !prev[panel] }));
};
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 }]);
};
// 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);
}
}; };
// Project setup overlay
if (showProjectSetup) {
return ( return (
<div className="app"> <div className="app">
<div className="container"> <div className="project-setup-overlay">
<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 ? (
<div className="project-setup"> <div className="project-setup">
<h2>Start Editing</h2> <h2>Open Image</h2>
<div className="setup-form"> <div className="setup-form">
<div className="form-group"> <div className="form-group">
<label>Upload Image</label> <label>Select Image</label>
<input <input
type="file" type="file"
accept="image/*" accept="image/*"
onChange={(e) => setImageFile(e.target.files[0])} onChange={(e) => setImageFile(e.target.files[0])}
disabled={isProcessing} disabled={isProcessing}
/> />
{imageFile && ( {imageFile && <p className="file-name">{imageFile.name}</p>}
<p className="file-name">Selected: {imageFile.name}</p>
)}
</div> </div>
<div className="form-group optional-field"> <div className="form-group">
<label>Project Name (optional)</label> <label>Project Name (optional)</label>
<input <input
type="text" type="text"
value={projectName} value={projectName}
onChange={(e) => setProjectName(e.target.value)} onChange={(e) => setProjectName(e.target.value)}
placeholder="Uses filename if left empty" placeholder="Auto-generated from filename"
disabled={isProcessing} disabled={isProcessing}
/> />
</div> </div>
@@ -393,90 +395,301 @@ function App() {
onClick={handleCreateProject} onClick={handleCreateProject}
disabled={isProcessing || !imageFile} disabled={isProcessing || !imageFile}
> >
{isProcessing ? 'Starting...' : 'Start Editing'} {isProcessing ? 'Opening...' : 'Open'}
</button> </button>
</div> </div>
</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 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}
/>
</div> </div>
</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> </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>
{/* 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>
</div> </div>
); );
+32 -29
View File
@@ -1,47 +1,50 @@
/* ImageCanvas fills the entire canvas wrapper */
.canvas-container { .canvas-container {
position: relative; position: absolute;
width: 100%; top: 0;
height: 100%; left: 0;
min-height: calc(100vh - 180px); right: 0;
background-color: #2a2a2a; bottom: 0;
border: 2px solid #444;
border-radius: 8px;
overflow: hidden; overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
} }
.canvas-container canvas { .canvas-container canvas {
display: block; display: block;
} }
/* Selection hint */
.selection-hint {
position: absolute;
bottom: 40px;
left: 50%;
transform: translateX(-50%);
background-color: rgba(0, 0, 0, 0.8);
color: #0088ff;
padding: 6px 12px;
border-radius: 4px;
font-size: 11px;
z-index: 10;
white-space: nowrap;
pointer-events: none;
}
/* Clear selection button */
.clear-selection-btn { .clear-selection-btn {
position: absolute; position: absolute;
top: 10px; top: 8px;
right: 10px; right: 8px;
background-color: #ff4444; background-color: #cc3333;
color: white; color: white;
padding: 8px 16px; padding: 6px 12px;
font-size: 11px;
border: none;
border-radius: 3px;
cursor: pointer;
z-index: 10; z-index: 10;
} }
.clear-selection-btn:hover { .clear-selection-btn:hover {
background-color: #cc0000; background-color: #dd4444;
}
.selection-hint {
position: absolute;
bottom: 10px;
left: 50%;
transform: translateX(-50%);
background-color: rgba(0, 0, 0, 0.8);
color: #00ff00;
padding: 8px 16px;
border-radius: 4px;
font-size: 12px;
z-index: 10;
white-space: nowrap;
} }
/* Zoom controls */ /* Zoom controls */
+264 -306
View File
@@ -1,23 +1,20 @@
import React, { useEffect, useRef, useState, useCallback } from 'react'; import React, { useEffect, useRef, useState, useCallback, forwardRef, useImperativeHandle } from 'react';
import { fabric } from 'fabric'; import { fabric } from 'fabric';
import './ImageCanvas.css'; import './ImageCanvas.css';
const ImageCanvas = ({ const ImageCanvas = forwardRef(({
imageUrl, imageUrl,
onSelectionChange, onSelectionChange,
selectionMode, selectionMode,
advancedToolMode, activeTool,
onAdvancedToolClick, zoom = 100,
zoom = 1, onSmartSelect,
onZoomChange, isProcessing
externalSelection, // { polygon: [[x,y],...], bbox: {x,y,width,height} } }, ref) => {
}) => {
const canvasRef = useRef(null); const canvasRef = useRef(null);
const fabricCanvasRef = useRef(null); const fabricCanvasRef = useRef(null);
const [currentSelection, setCurrentSelection] = useState(null); const [currentSelection, setCurrentSelection] = useState(null);
const [isDrawing, setIsDrawing] = useState(false); const currentSelectionRef = useRef(null);
const [isTransformMode, setIsTransformMode] = useState(false);
const [currentZoom, setCurrentZoom] = useState(zoom);
const lassoPoints = useRef([]); const lassoPoints = useRef([]);
const onZoomChangeRef = useRef(onZoomChange); const onZoomChangeRef = useRef(onZoomChange);
@@ -26,17 +23,28 @@ const ImageCanvas = ({
onZoomChangeRef.current = onZoomChange; onZoomChangeRef.current = onZoomChange;
}, [onZoomChange]); }, [onZoomChange]);
// Expose methods to parent
useImperativeHandle(ref, () => ({
getCanvas: () => fabricCanvasRef.current,
clearSelection: () => clearSelection(),
}));
// Update selection ref when state changes
useEffect(() => {
currentSelectionRef.current = currentSelection;
}, [currentSelection]);
// Initialize canvas
useEffect(() => { useEffect(() => {
if (!canvasRef.current) return; if (!canvasRef.current) return;
// Initialize Fabric.js canvas
const canvas = new fabric.Canvas(canvasRef.current, { const canvas = new fabric.Canvas(canvasRef.current, {
selection: false, selection: false,
backgroundColor: '#2a2a2a', backgroundColor: 'transparent',
preserveObjectStacking: true,
}); });
fabricCanvasRef.current = canvas; fabricCanvasRef.current = canvas;
// Handle window resize
const handleResize = () => { const handleResize = () => {
const container = canvasRef.current?.parentElement; const container = canvasRef.current?.parentElement;
if (container) { if (container) {
@@ -45,19 +53,9 @@ const ImageCanvas = ({
canvas.setWidth(width); canvas.setWidth(width);
canvas.setHeight(height); canvas.setHeight(height);
// Re-center and rescale the image if it exists // Re-center image if it exists
const bgImage = canvas.backgroundImage; if (imageRef.current) {
if (bgImage) { centerImage(canvas, imageRef.current, zoom / 100);
// Allow scaling up to fill the canvas
const scale = Math.min(
(width - 40) / bgImage.width,
(height - 40) / bgImage.height
);
bgImage.scale(scale);
bgImage.set({
left: (width - bgImage.width * scale) / 2,
top: (height - bgImage.height * scale) / 2,
});
} }
canvas.renderAll(); canvas.renderAll();
} }
@@ -102,6 +100,39 @@ const ImageCanvas = ({
}; };
}, []); // Empty dependency array - only run once on mount }, []); // Empty dependency array - only run once on mount
// Center and scale image
const centerImage = (canvas, img, zoomFactor) => {
if (!img) return;
const padding = 40;
const availableWidth = canvas.width - padding;
const availableHeight = canvas.height - padding;
// Calculate base scale to fit
const fitScale = Math.min(
availableWidth / img.width,
availableHeight / img.height
);
baseScaleRef.current = fitScale;
const scale = fitScale * zoomFactor;
img.scale(scale);
img.set({
left: (canvas.width - img.width * scale) / 2,
top: (canvas.height - img.height * scale) / 2,
});
};
// Apply zoom changes
useEffect(() => {
const canvas = fabricCanvasRef.current;
if (!canvas || !imageRef.current) return;
centerImage(canvas, imageRef.current, zoom / 100);
canvas.renderAll();
}, [zoom]);
// Load image when URL changes // Load image when URL changes
useEffect(() => { useEffect(() => {
if (!fabricCanvasRef.current || !imageUrl) return; if (!fabricCanvasRef.current || !imageUrl) return;
@@ -143,186 +174,155 @@ const ImageCanvas = ({
top: ((canvas.height || 600) - img.height * scale) / 2, top: ((canvas.height || 600) - img.height * scale) / 2,
selectable: false, selectable: false,
evented: false, evented: false,
hoverCursor: 'default',
}); });
imageRef.current = img;
canvas.add(img); canvas.add(img);
canvas.sendToBack(img); canvas.sendToBack(img);
canvas.renderAll();
// Store image reference centerImage(canvas, img, zoom / 100);
canvas.backgroundImage = img; canvas.renderAll();
}, { crossOrigin: 'anonymous' }); }, { crossOrigin: 'anonymous' });
}, [imageUrl]); }, [imageUrl]);
// Handle advanced tool mode clicks (smart-select, color-select) // Handle tool/mode changes
useEffect(() => {
if (!fabricCanvasRef.current || !advancedToolMode) return;
const canvas = fabricCanvasRef.current;
const bgImage = canvas.backgroundImage;
const handleAdvancedClick = async (e) => {
if (!bgImage || !onAdvancedToolClick) return;
const pointer = canvas.getPointer(e.e);
// Convert canvas coordinates to image coordinates
const imgScale = bgImage.scaleX;
const imgLeft = bgImage.left;
const imgTop = bgImage.top;
const imgX = Math.round((pointer.x - imgLeft) / imgScale);
const imgY = Math.round((pointer.y - imgTop) / imgScale);
// Check if click is within image bounds
if (imgX < 0 || imgY < 0 || imgX > bgImage.width || imgY > bgImage.height) {
return;
}
if (advancedToolMode === 'color-select') {
// Get pixel color at click position
const ctx = canvas.getContext('2d');
const canvasX = pointer.x * canvas.getZoom();
const canvasY = pointer.y * canvas.getZoom();
// For color picking, we need to get the color from the image
// Create a temporary canvas to read pixel color
const tempCanvas = document.createElement('canvas');
tempCanvas.width = bgImage.width;
tempCanvas.height = bgImage.height;
const tempCtx = tempCanvas.getContext('2d');
// Draw the image element to temp canvas
const imgElement = bgImage.getElement();
tempCtx.drawImage(imgElement, 0, 0);
const pixelData = tempCtx.getImageData(imgX, imgY, 1, 1).data;
const color = { r: pixelData[0], g: pixelData[1], b: pixelData[2] };
onAdvancedToolClick(imgX, imgY, color);
} else {
onAdvancedToolClick(imgX, imgY, null);
}
};
canvas.on('mouse:down', handleAdvancedClick);
return () => {
canvas.off('mouse:down', handleAdvancedClick);
};
}, [advancedToolMode, onAdvancedToolClick]);
// Handle external selection (from smart-select or color-select)
useEffect(() => {
if (!fabricCanvasRef.current || !externalSelection?.polygon?.length) return;
const canvas = fabricCanvasRef.current;
const bgImage = canvas.backgroundImage;
if (!bgImage) return;
// Clear previous selection
if (currentSelection) {
canvas.remove(currentSelection);
}
// Convert image coordinates to canvas coordinates
const imgScale = bgImage.scaleX;
const imgLeft = bgImage.left;
const imgTop = bgImage.top;
const canvasPoints = externalSelection.polygon.map(([x, y]) => ({
x: x * imgScale + imgLeft,
y: y * imgScale + imgTop,
}));
// Create polygon selection
const polygon = new fabric.Polygon(canvasPoints, {
fill: 'rgba(255, 255, 255, 0.3)',
stroke: '#00ff00',
strokeWidth: 2,
selectable: true,
hasControls: true,
hasBorders: true,
lockRotation: false,
cornerColor: '#00ff00',
cornerSize: 10,
transparentCorners: false,
borderColor: '#00ff00',
borderScaleFactor: 2,
});
canvas.add(polygon);
canvas.setActiveObject(polygon);
setCurrentSelection(polygon);
lassoPoints.current = canvasPoints;
// Notify parent of selection
onSelectionChange({
type: 'polygon',
bbox: externalSelection.bbox,
selectionData: { points: externalSelection.polygon },
});
canvas.renderAll();
}, [externalSelection]);
// Handle selection mode changes
useEffect(() => { useEffect(() => {
if (!fabricCanvasRef.current) return; if (!fabricCanvasRef.current) return;
const canvas = fabricCanvasRef.current; const canvas = fabricCanvasRef.current;
// Don't set up selection handlers if in advanced tool mode // Remove all event handlers
if (advancedToolMode) return;
// Clear previous selection when changing modes
if (currentSelection) {
canvas.remove(currentSelection);
setCurrentSelection(null);
onSelectionChange(null);
}
// Reset transform mode
setIsTransformMode(false);
// Set up event handlers based on mode
canvas.off('mouse:down'); canvas.off('mouse:down');
canvas.off('mouse:move'); canvas.off('mouse:move');
canvas.off('mouse:up'); canvas.off('mouse:up');
canvas.off('object:modified'); canvas.off('object:modified');
canvas.off('object:moving');
canvas.off('object:scaling');
// Set up handlers based on selection mode
if (selectionMode === 'rectangle') { if (selectionMode === 'rectangle') {
setupRectangleMode(canvas); setupRectangleMode(canvas);
} else if (selectionMode === 'ellipse') { } else if (selectionMode === 'ellipse') {
setupEllipseMode(canvas); setupEllipseMode(canvas);
} else if (selectionMode === 'lasso') { } else if (selectionMode === 'lasso') {
setupLassoMode(canvas); setupLassoMode(canvas);
} else if (selectionMode === 'smart') {
setupSmartSelectMode(canvas);
} else if (selectionMode === 'color') {
setupColorSelectMode(canvas);
} else if (activeTool === 'move') {
setupMoveMode(canvas);
} else if (activeTool === 'pan') {
setupPanMode(canvas);
} }
}, [selectionMode, advancedToolMode]); }, [selectionMode, activeTool, onSmartSelect]);
const setupRectangleMode = (canvas) => { const setupMoveMode = (canvas) => {
let rect, isDown, startX, startY; // In move mode, allow selecting and moving selection objects
const sel = currentSelectionRef.current;
if (sel) {
sel.set({ selectable: true, evented: true });
canvas.setActiveObject(sel);
}
canvas.on('object:modified', (e) => {
if (e.target && e.target === currentSelectionRef.current) {
updateTransformedSelection(e.target);
}
});
};
const setupPanMode = (canvas) => {
let isPanning = false;
let lastPosX, lastPosY;
canvas.on('mouse:down', (e) => { canvas.on('mouse:down', (e) => {
// If clicking on existing selection, enable transform mode isPanning = true;
if (e.target && e.target === currentSelection) { lastPosX = e.e.clientX;
setIsTransformMode(true); lastPosY = e.e.clientY;
canvas.setCursor('grabbing');
});
canvas.on('mouse:move', (e) => {
if (!isPanning) return;
const deltaX = e.e.clientX - lastPosX;
const deltaY = e.e.clientY - lastPosY;
canvas.relativePan({ x: deltaX, y: deltaY });
lastPosX = e.e.clientX;
lastPosY = e.e.clientY;
});
canvas.on('mouse:up', () => {
isPanning = false;
canvas.setCursor('grab');
});
canvas.setCursor('grab');
};
const setupSmartSelectMode = (canvas) => {
canvas.on('mouse:down', (e) => {
if (isProcessing) return;
const pointer = canvas.getPointer(e.e);
const img = imageRef.current;
if (!img) return;
// Convert to image coordinates
const imgScale = img.scaleX;
const imgLeft = img.left;
const imgTop = img.top;
const x = Math.round((pointer.x - imgLeft) / imgScale);
const y = Math.round((pointer.y - imgTop) / imgScale);
// Check if click is within image bounds
if (x >= 0 && x < img.width && y >= 0 && y < img.height) {
onSmartSelect?.(x, y);
}
});
canvas.setCursor('crosshair');
};
const setupColorSelectMode = (canvas) => {
canvas.on('mouse:down', (e) => {
if (isProcessing) return;
// TODO: Get pixel color at click position
const pointer = canvas.getPointer(e.e);
console.log('Color select at:', pointer);
});
canvas.setCursor('crosshair');
};
const setupRectangleMode = (canvas) => {
let rect = null;
let isDown = false;
let startX, startY;
canvas.on('mouse:down', (e) => {
// Check if clicking on existing selection
const sel = currentSelectionRef.current;
if (e.target && e.target === sel) {
// Allow moving/transforming
return; return;
} }
// If in transform mode and clicking elsewhere, exit transform mode // Clear previous selection
if (isTransformMode) { if (sel) {
setIsTransformMode(false); canvas.remove(sel);
}
// Clear previous selection if exists
if (currentSelection) {
canvas.remove(currentSelection);
setCurrentSelection(null); setCurrentSelection(null);
} }
isDown = true; isDown = true;
isDrawingRef.current = true;
const pointer = canvas.getPointer(e.e); const pointer = canvas.getPointer(e.e);
startX = pointer.x; startX = pointer.x;
startY = pointer.y; startY = pointer.y;
@@ -332,26 +332,24 @@ const ImageCanvas = ({
top: startY, top: startY,
width: 0, width: 0,
height: 0, height: 0,
fill: 'rgba(255, 255, 255, 0.3)', fill: 'rgba(0, 136, 255, 0.2)',
stroke: '#00ff00', stroke: '#0088ff',
strokeWidth: 2, strokeWidth: 2,
strokeDashArray: [5, 5],
selectable: true, selectable: true,
hasControls: true, hasControls: true,
hasBorders: true, hasBorders: true,
lockRotation: false, cornerColor: '#0088ff',
cornerColor: '#00ff00', cornerSize: 8,
cornerSize: 10,
transparentCorners: false, transparentCorners: false,
borderColor: '#00ff00', borderColor: '#0088ff',
borderScaleFactor: 2,
}); });
canvas.add(rect); canvas.add(rect);
setCurrentSelection(rect);
}); });
canvas.on('mouse:move', (e) => { canvas.on('mouse:move', (e) => {
if (!isDown || isTransformMode) return; if (!isDown || !rect) return;
const pointer = canvas.getPointer(e.e); const pointer = canvas.getPointer(e.e);
const width = pointer.x - startX; const width = pointer.x - startX;
@@ -368,39 +366,40 @@ const ImageCanvas = ({
}); });
canvas.on('mouse:up', () => { canvas.on('mouse:up', () => {
if (isDown && !isTransformMode) { if (isDown && rect && rect.width > 5 && rect.height > 5) {
isDown = false; isDown = false;
isDrawingRef.current = false;
setCurrentSelection(rect);
canvas.setActiveObject(rect); canvas.setActiveObject(rect);
updateSelection(rect, 'rectangle'); updateSelection(rect, 'rectangle');
} else if (isDown && rect) {
// Selection too small, remove it
canvas.remove(rect);
isDown = false;
isDrawingRef.current = false;
} }
}); });
// Update selection when object is modified (moved, scaled, rotated)
canvas.on('object:modified', (e) => { canvas.on('object:modified', (e) => {
if (e.target && e.target === currentSelection) { if (e.target === currentSelectionRef.current) {
updateTransformedSelection(e.target, 'rectangle'); updateTransformedSelection(e.target);
} }
}); });
}; };
const setupEllipseMode = (canvas) => { const setupEllipseMode = (canvas) => {
let ellipse, isDown, startX, startY; let ellipse = null;
let isDown = false;
let startX, startY;
canvas.on('mouse:down', (e) => { canvas.on('mouse:down', (e) => {
// If clicking on existing selection, enable transform mode const sel = currentSelectionRef.current;
if (e.target && e.target === currentSelection) { if (e.target && e.target === sel) {
setIsTransformMode(true);
return; return;
} }
// If in transform mode and clicking elsewhere, exit transform mode if (sel) {
if (isTransformMode) { canvas.remove(sel);
setIsTransformMode(false);
}
// Clear previous selection if exists
if (currentSelection) {
canvas.remove(currentSelection);
setCurrentSelection(null); setCurrentSelection(null);
} }
@@ -414,26 +413,24 @@ const ImageCanvas = ({
top: startY, top: startY,
rx: 0, rx: 0,
ry: 0, ry: 0,
fill: 'rgba(255, 255, 255, 0.3)', fill: 'rgba(0, 136, 255, 0.2)',
stroke: '#00ff00', stroke: '#0088ff',
strokeWidth: 2, strokeWidth: 2,
strokeDashArray: [5, 5],
selectable: true, selectable: true,
hasControls: true, hasControls: true,
hasBorders: true, hasBorders: true,
lockRotation: false, cornerColor: '#0088ff',
cornerColor: '#00ff00', cornerSize: 8,
cornerSize: 10,
transparentCorners: false, transparentCorners: false,
borderColor: '#00ff00', borderColor: '#0088ff',
borderScaleFactor: 2,
}); });
canvas.add(ellipse); canvas.add(ellipse);
setCurrentSelection(ellipse);
}); });
canvas.on('mouse:move', (e) => { canvas.on('mouse:move', (e) => {
if (!isDown || isTransformMode) return; if (!isDown || !ellipse) return;
const pointer = canvas.getPointer(e.e); const pointer = canvas.getPointer(e.e);
const rx = Math.abs(pointer.x - startX) / 2; const rx = Math.abs(pointer.x - startX) / 2;
@@ -442,58 +439,55 @@ const ImageCanvas = ({
ellipse.set({ ellipse.set({
rx: rx, rx: rx,
ry: ry, ry: ry,
left: startX < pointer.x ? startX : pointer.x, left: Math.min(startX, pointer.x),
top: startY < pointer.y ? startY : pointer.y, top: Math.min(startY, pointer.y),
}); });
canvas.renderAll(); canvas.renderAll();
}); });
canvas.on('mouse:up', () => { canvas.on('mouse:up', () => {
if (isDown && !isTransformMode) { if (isDown && ellipse && ellipse.rx > 5 && ellipse.ry > 5) {
isDown = false; isDown = false;
setCurrentSelection(ellipse);
canvas.setActiveObject(ellipse); canvas.setActiveObject(ellipse);
updateSelection(ellipse, 'ellipse'); updateSelection(ellipse, 'ellipse');
} else if (isDown && ellipse) {
canvas.remove(ellipse);
isDown = false;
} }
}); });
// Update selection when object is modified (moved, scaled, rotated)
canvas.on('object:modified', (e) => { canvas.on('object:modified', (e) => {
if (e.target && e.target === currentSelection) { if (e.target === currentSelectionRef.current) {
updateTransformedSelection(e.target, 'ellipse'); updateTransformedSelection(e.target);
} }
}); });
}; };
const setupLassoMode = (canvas) => { const setupLassoMode = (canvas) => {
let polygon, points = [], drawingLine; let points = [];
let drawingLine = null;
let polygon = null;
canvas.on('mouse:down', (e) => { canvas.on('mouse:down', (e) => {
// If clicking on existing selection, enable transform mode const sel = currentSelectionRef.current;
if (e.target && e.target === currentSelection) { if (e.target && e.target === sel) {
setIsTransformMode(true);
return; return;
} }
// If in transform mode and clicking elsewhere, exit transform mode if (sel) {
if (isTransformMode) { canvas.remove(sel);
setIsTransformMode(false);
}
// Clear previous selection if exists
if (currentSelection) {
canvas.remove(currentSelection);
setCurrentSelection(null); setCurrentSelection(null);
} }
setIsDrawing(true); isDrawingRef.current = true;
const pointer = canvas.getPointer(e.e); const pointer = canvas.getPointer(e.e);
points = [{ x: pointer.x, y: pointer.y }]; points = [{ x: pointer.x, y: pointer.y }];
// Create a temporary line for visual feedback while drawing
drawingLine = new fabric.Polyline(points, { drawingLine = new fabric.Polyline(points, {
fill: 'transparent', fill: 'transparent',
stroke: '#00ff00', stroke: '#0088ff',
strokeWidth: 2, strokeWidth: 2,
selectable: false, selectable: false,
evented: false, evented: false,
@@ -503,16 +497,15 @@ const ImageCanvas = ({
}); });
canvas.on('mouse:move', (e) => { canvas.on('mouse:move', (e) => {
if (!isDrawing || isTransformMode) return; if (!isDrawingRef.current) return;
const pointer = canvas.getPointer(e.e); const pointer = canvas.getPointer(e.e);
points.push({ x: pointer.x, y: pointer.y }); points.push({ x: pointer.x, y: pointer.y });
// Remove old line and create new one with updated points
canvas.remove(drawingLine); canvas.remove(drawingLine);
drawingLine = new fabric.Polyline([...points], { drawingLine = new fabric.Polyline([...points], {
fill: 'transparent', fill: 'transparent',
stroke: '#00ff00', stroke: '#0088ff',
strokeWidth: 2, strokeWidth: 2,
selectable: false, selectable: false,
evented: false, evented: false,
@@ -522,59 +515,50 @@ const ImageCanvas = ({
}); });
canvas.on('mouse:up', () => { canvas.on('mouse:up', () => {
if (isDrawing && !isTransformMode && points.length > 2) { if (isDrawingRef.current && points.length > 5) {
setIsDrawing(false); isDrawingRef.current = false;
lassoPoints.current = [...points]; lassoPoints.current = [...points];
// Remove drawing line
canvas.remove(drawingLine); canvas.remove(drawingLine);
// Create final polygon with fill
polygon = new fabric.Polygon(points, { polygon = new fabric.Polygon(points, {
fill: 'rgba(255, 255, 255, 0.3)', fill: 'rgba(0, 136, 255, 0.2)',
stroke: '#00ff00', stroke: '#0088ff',
strokeWidth: 2, strokeWidth: 2,
strokeDashArray: [5, 5],
selectable: true, selectable: true,
hasControls: true, hasControls: true,
hasBorders: true, hasBorders: true,
lockRotation: false, cornerColor: '#0088ff',
cornerColor: '#00ff00', cornerSize: 8,
cornerSize: 10,
transparentCorners: false, transparentCorners: false,
borderColor: '#00ff00', borderColor: '#0088ff',
borderScaleFactor: 2,
}); });
canvas.add(polygon); canvas.add(polygon);
canvas.setActiveObject(polygon); canvas.setActiveObject(polygon);
setCurrentSelection(polygon); setCurrentSelection(polygon);
updateSelection(polygon, 'lasso'); updateSelection(polygon, 'lasso');
} else if (isDrawing) { } else if (isDrawingRef.current) {
setIsDrawing(false); isDrawingRef.current = false;
canvas.remove(drawingLine); canvas.remove(drawingLine);
} }
}); });
// Update selection when object is modified (moved, scaled, rotated)
canvas.on('object:modified', (e) => { canvas.on('object:modified', (e) => {
if (e.target && e.target === currentSelection) { if (e.target === currentSelectionRef.current) {
updateTransformedSelection(e.target, 'lasso'); updateTransformedSelection(e.target);
} }
}); });
}; };
const updateSelection = (selection, type) => { const updateSelection = (selection, type) => {
if (!selection || !fabricCanvasRef.current) return; if (!selection || !imageRef.current) return;
const canvas = fabricCanvasRef.current; const img = imageRef.current;
const bgImage = canvas.backgroundImage; const imgScale = img.scaleX;
const imgLeft = img.left;
if (!bgImage) return; const imgTop = img.top;
// Calculate bounding box in original image coordinates
const imgScale = bgImage.scaleX;
const imgLeft = bgImage.left;
const imgTop = bgImage.top;
let bbox, selectionData = null; let bbox, selectionData = null;
@@ -601,39 +585,32 @@ const ImageCanvas = ({
height: Math.round(bounds.height / imgScale), height: Math.round(bounds.height / imgScale),
}; };
// Convert lasso points to relative coordinates within bbox
const relativePoints = lassoPoints.current.map(p => [ const relativePoints = lassoPoints.current.map(p => [
Math.round((p.x - bounds.left) / imgScale), Math.round((p.x - imgLeft) / imgScale) - bbox.x,
Math.round((p.y - bounds.top) / imgScale), Math.round((p.y - imgTop) / imgScale) - bbox.y,
]); ]);
selectionData = { points: relativePoints }; selectionData = { points: relativePoints };
} }
onSelectionChange({ onSelectionChange?.({
type, type,
bbox, bbox,
selectionData, selectionData,
}); });
}; };
// Update selection after transformation (move, scale, rotate) const updateTransformedSelection = (selection) => {
const updateTransformedSelection = (selection, type) => { if (!selection || !imageRef.current) return;
if (!selection || !fabricCanvasRef.current) return;
const canvas = fabricCanvasRef.current; const img = imageRef.current;
const bgImage = canvas.backgroundImage; const imgScale = img.scaleX;
const imgLeft = img.left;
const imgTop = img.top;
if (!bgImage) return;
const imgScale = bgImage.scaleX;
const imgLeft = bgImage.left;
const imgTop = bgImage.top;
// Get the transformed bounding rect (accounts for scale and rotation)
const bounds = selection.getBoundingRect(true); const bounds = selection.getBoundingRect(true);
let bbox = { const bbox = {
x: Math.round((bounds.left - imgLeft) / imgScale), x: Math.round((bounds.left - imgLeft) / imgScale),
y: Math.round((bounds.top - imgTop) / imgScale), y: Math.round((bounds.top - imgTop) / imgScale),
width: Math.round(bounds.width / imgScale), width: Math.round(bounds.width / imgScale),
@@ -641,8 +618,8 @@ const ImageCanvas = ({
}; };
let selectionData = null; let selectionData = null;
const type = selection.type === 'polygon' ? 'lasso' : (selection.type === 'ellipse' ? 'ellipse' : 'rectangle');
// For lasso, we need to transform the points based on the object's transformation
if (type === 'lasso' && lassoPoints.current.length > 0) { if (type === 'lasso' && lassoPoints.current.length > 0) {
const matrix = selection.calcTransformMatrix(); const matrix = selection.calcTransformMatrix();
const transformedPoints = lassoPoints.current.map(p => { const transformedPoints = lassoPoints.current.map(p => {
@@ -651,14 +628,14 @@ const ImageCanvas = ({
matrix matrix
); );
return [ return [
Math.round((transformed.x - bounds.left) / imgScale), Math.round((transformed.x - imgLeft) / imgScale) - bbox.x,
Math.round((transformed.y - bounds.top) / imgScale), Math.round((transformed.y - imgTop) / imgScale) - bbox.y,
]; ];
}); });
selectionData = { points: transformedPoints }; selectionData = { points: transformedPoints };
} }
onSelectionChange({ onSelectionChange?.({
type, type,
bbox, bbox,
selectionData, selectionData,
@@ -666,10 +643,12 @@ const ImageCanvas = ({
}; };
const clearSelection = () => { const clearSelection = () => {
if (currentSelection && fabricCanvasRef.current) { const canvas = fabricCanvasRef.current;
fabricCanvasRef.current.remove(currentSelection); const sel = currentSelectionRef.current;
if (sel && canvas) {
canvas.remove(sel);
setCurrentSelection(null); setCurrentSelection(null);
onSelectionChange(null); onSelectionChange?.(null);
} }
}; };
@@ -705,36 +684,15 @@ const ImageCanvas = ({
return ( return (
<div className="canvas-container"> <div className="canvas-container">
<canvas ref={canvasRef} /> <canvas ref={canvasRef} />
{currentSelection && (
{/* Zoom controls */}
<div className="zoom-controls">
<button onClick={handleZoomOut} title="Zoom Out"></button>
<span className="zoom-level">{Math.round(currentZoom * 100)}%</span>
<button onClick={handleZoomIn} title="Zoom In">+</button>
<button onClick={handleZoomReset} title="Reset Zoom"></button>
</div>
{/* Advanced tool mode indicator */}
{advancedToolMode && (
<div className="tool-mode-indicator">
{advancedToolMode === 'smart-select' && 'Click on an object to select it'}
{advancedToolMode === 'color-select' && 'Click on a color to select similar pixels'}
{advancedToolMode === 'object-remove' && 'Click on an object to remove it'}
</div>
)}
{currentSelection && !advancedToolMode && (
<>
<div className="selection-hint">
Click selection to move/resize/rotate
</div>
<button className="clear-selection-btn" onClick={clearSelection}> <button className="clear-selection-btn" onClick={clearSelection}>
Clear Selection Clear
</button> </button>
</>
)} )}
</div> </div>
); );
}; });
ImageCanvas.displayName = 'ImageCanvas';
export default ImageCanvas; export default ImageCanvas;