Merge branch 'main' into claude/automate-sam-download-mzpRs
This commit is contained in:
+63
@@ -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
@@ -1,7 +1,10 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse, HTMLResponse
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
import os
|
||||
|
||||
from app.config import settings
|
||||
from app.database import init_db
|
||||
@@ -40,9 +43,9 @@ app.include_router(generate.router)
|
||||
app.include_router(tools.router)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def root():
|
||||
"""API root endpoint"""
|
||||
@app.get("/api")
|
||||
def api_root():
|
||||
"""API info endpoint"""
|
||||
return {
|
||||
"name": "AI Photo Edit API",
|
||||
"version": "1.0.0",
|
||||
@@ -54,3 +57,44 @@ def root():
|
||||
def health():
|
||||
"""Health check endpoint"""
|
||||
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
@@ -1,16 +1,13 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
backend:
|
||||
app:
|
||||
build:
|
||||
context: ./backend
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: ai-photo-edit-backend
|
||||
container_name: ai-photo-edit
|
||||
ports:
|
||||
- "8101:8000" # External 8101, internal 8000
|
||||
- "3080:8000"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./backend:/app
|
||||
- ./scripts:/scripts
|
||||
environment:
|
||||
- DATABASE_URL=sqlite:///./data/ai_photo_edit.db
|
||||
@@ -19,28 +16,8 @@ services:
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
|
||||
- STABILITY_API_KEY=${STABILITY_API_KEY:-}
|
||||
- REPLICATE_API_KEY=${REPLICATE_API_KEY:-}
|
||||
- CORS_ORIGINS=http://localhost:5173,http://localhost:3000,http://localhost
|
||||
- AUTO_DOWNLOAD_SAM=${AUTO_DOWNLOAD_SAM:-true}
|
||||
- CORS_ORIGINS=*
|
||||
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:
|
||||
data:
|
||||
|
||||
@@ -47,6 +47,16 @@ server {
|
||||
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 {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
+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,47 +1,50 @@
|
||||
/* 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 {
|
||||
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 {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background-color: #ff4444;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
background-color: #cc3333;
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
padding: 6px 12px;
|
||||
font-size: 11px;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.clear-selection-btn:hover {
|
||||
background-color: #cc0000;
|
||||
}
|
||||
|
||||
.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;
|
||||
background-color: #dd4444;
|
||||
}
|
||||
|
||||
/* Zoom controls */
|
||||
|
||||
@@ -1,36 +1,47 @@
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import React, { useEffect, useRef, useState, useCallback, forwardRef, useImperativeHandle } from 'react';
|
||||
import { fabric } from 'fabric';
|
||||
import './ImageCanvas.css';
|
||||
|
||||
const ImageCanvas = ({
|
||||
const ImageCanvas = forwardRef(({
|
||||
imageUrl,
|
||||
onSelectionChange,
|
||||
selectionMode,
|
||||
advancedToolMode,
|
||||
onAdvancedToolClick,
|
||||
zoom = 1,
|
||||
onZoomChange,
|
||||
externalSelection, // { polygon: [[x,y],...], bbox: {x,y,width,height} }
|
||||
}) => {
|
||||
activeTool,
|
||||
zoom = 100,
|
||||
onSmartSelect,
|
||||
isProcessing
|
||||
}, ref) => {
|
||||
const canvasRef = useRef(null);
|
||||
const fabricCanvasRef = useRef(null);
|
||||
const [currentSelection, setCurrentSelection] = useState(null);
|
||||
const [isDrawing, setIsDrawing] = useState(false);
|
||||
const [isTransformMode, setIsTransformMode] = useState(false);
|
||||
const [currentZoom, setCurrentZoom] = useState(zoom);
|
||||
const currentSelectionRef = useRef(null);
|
||||
const lassoPoints = useRef([]);
|
||||
const isDrawingRef = useRef(false);
|
||||
const imageRef = useRef(null);
|
||||
const baseScaleRef = useRef(1);
|
||||
|
||||
// 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(() => {
|
||||
if (!canvasRef.current) return;
|
||||
|
||||
// Initialize Fabric.js canvas
|
||||
const canvas = new fabric.Canvas(canvasRef.current, {
|
||||
selection: false,
|
||||
backgroundColor: '#2a2a2a',
|
||||
backgroundColor: 'transparent',
|
||||
preserveObjectStacking: true,
|
||||
});
|
||||
fabricCanvasRef.current = canvas;
|
||||
|
||||
// Handle window resize
|
||||
const handleResize = () => {
|
||||
const container = canvasRef.current?.parentElement;
|
||||
if (container) {
|
||||
@@ -39,19 +50,9 @@ const ImageCanvas = ({
|
||||
canvas.setWidth(width);
|
||||
canvas.setHeight(height);
|
||||
|
||||
// Re-center and rescale the image if it exists
|
||||
const bgImage = canvas.backgroundImage;
|
||||
if (bgImage) {
|
||||
// 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,
|
||||
});
|
||||
// Re-center image if it exists
|
||||
if (imageRef.current) {
|
||||
centerImage(canvas, imageRef.current, zoom / 100);
|
||||
}
|
||||
canvas.renderAll();
|
||||
}
|
||||
@@ -94,6 +95,39 @@ const ImageCanvas = ({
|
||||
};
|
||||
}, [onZoomChange]);
|
||||
|
||||
// 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
|
||||
useEffect(() => {
|
||||
if (!fabricCanvasRef.current || !imageUrl) return;
|
||||
@@ -135,186 +169,155 @@ const ImageCanvas = ({
|
||||
top: ((canvas.height || 600) - img.height * scale) / 2,
|
||||
selectable: false,
|
||||
evented: false,
|
||||
hoverCursor: 'default',
|
||||
});
|
||||
|
||||
imageRef.current = img;
|
||||
canvas.add(img);
|
||||
canvas.sendToBack(img);
|
||||
canvas.renderAll();
|
||||
|
||||
// Store image reference
|
||||
canvas.backgroundImage = img;
|
||||
centerImage(canvas, img, zoom / 100);
|
||||
canvas.renderAll();
|
||||
}, { crossOrigin: 'anonymous' });
|
||||
}, [imageUrl]);
|
||||
|
||||
// Handle advanced tool mode clicks (smart-select, color-select)
|
||||
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
|
||||
// Handle tool/mode changes
|
||||
useEffect(() => {
|
||||
if (!fabricCanvasRef.current) return;
|
||||
|
||||
const canvas = fabricCanvasRef.current;
|
||||
|
||||
// Don't set up selection handlers if in advanced tool mode
|
||||
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
|
||||
// Remove all event handlers
|
||||
canvas.off('mouse:down');
|
||||
canvas.off('mouse:move');
|
||||
canvas.off('mouse:up');
|
||||
canvas.off('object:modified');
|
||||
canvas.off('object:moving');
|
||||
canvas.off('object:scaling');
|
||||
|
||||
// Set up handlers based on selection mode
|
||||
if (selectionMode === 'rectangle') {
|
||||
setupRectangleMode(canvas);
|
||||
} else if (selectionMode === 'ellipse') {
|
||||
setupEllipseMode(canvas);
|
||||
} else if (selectionMode === 'lasso') {
|
||||
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) => {
|
||||
let rect, isDown, startX, startY;
|
||||
const setupMoveMode = (canvas) => {
|
||||
// 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) => {
|
||||
// If clicking on existing selection, enable transform mode
|
||||
if (e.target && e.target === currentSelection) {
|
||||
setIsTransformMode(true);
|
||||
isPanning = true;
|
||||
lastPosX = e.e.clientX;
|
||||
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;
|
||||
}
|
||||
|
||||
// If in transform mode and clicking elsewhere, exit transform mode
|
||||
if (isTransformMode) {
|
||||
setIsTransformMode(false);
|
||||
}
|
||||
|
||||
// Clear previous selection if exists
|
||||
if (currentSelection) {
|
||||
canvas.remove(currentSelection);
|
||||
// Clear previous selection
|
||||
if (sel) {
|
||||
canvas.remove(sel);
|
||||
setCurrentSelection(null);
|
||||
}
|
||||
|
||||
isDown = true;
|
||||
isDrawingRef.current = true;
|
||||
const pointer = canvas.getPointer(e.e);
|
||||
startX = pointer.x;
|
||||
startY = pointer.y;
|
||||
@@ -324,26 +327,24 @@ const ImageCanvas = ({
|
||||
top: startY,
|
||||
width: 0,
|
||||
height: 0,
|
||||
fill: 'rgba(255, 255, 255, 0.3)',
|
||||
stroke: '#00ff00',
|
||||
fill: 'rgba(0, 136, 255, 0.2)',
|
||||
stroke: '#0088ff',
|
||||
strokeWidth: 2,
|
||||
strokeDashArray: [5, 5],
|
||||
selectable: true,
|
||||
hasControls: true,
|
||||
hasBorders: true,
|
||||
lockRotation: false,
|
||||
cornerColor: '#00ff00',
|
||||
cornerSize: 10,
|
||||
cornerColor: '#0088ff',
|
||||
cornerSize: 8,
|
||||
transparentCorners: false,
|
||||
borderColor: '#00ff00',
|
||||
borderScaleFactor: 2,
|
||||
borderColor: '#0088ff',
|
||||
});
|
||||
|
||||
canvas.add(rect);
|
||||
setCurrentSelection(rect);
|
||||
});
|
||||
|
||||
canvas.on('mouse:move', (e) => {
|
||||
if (!isDown || isTransformMode) return;
|
||||
if (!isDown || !rect) return;
|
||||
|
||||
const pointer = canvas.getPointer(e.e);
|
||||
const width = pointer.x - startX;
|
||||
@@ -360,39 +361,40 @@ const ImageCanvas = ({
|
||||
});
|
||||
|
||||
canvas.on('mouse:up', () => {
|
||||
if (isDown && !isTransformMode) {
|
||||
if (isDown && rect && rect.width > 5 && rect.height > 5) {
|
||||
isDown = false;
|
||||
isDrawingRef.current = false;
|
||||
setCurrentSelection(rect);
|
||||
canvas.setActiveObject(rect);
|
||||
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) => {
|
||||
if (e.target && e.target === currentSelection) {
|
||||
updateTransformedSelection(e.target, 'rectangle');
|
||||
if (e.target === currentSelectionRef.current) {
|
||||
updateTransformedSelection(e.target);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const setupEllipseMode = (canvas) => {
|
||||
let ellipse, isDown, startX, startY;
|
||||
let ellipse = null;
|
||||
let isDown = false;
|
||||
let startX, startY;
|
||||
|
||||
canvas.on('mouse:down', (e) => {
|
||||
// If clicking on existing selection, enable transform mode
|
||||
if (e.target && e.target === currentSelection) {
|
||||
setIsTransformMode(true);
|
||||
const sel = currentSelectionRef.current;
|
||||
if (e.target && e.target === sel) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If in transform mode and clicking elsewhere, exit transform mode
|
||||
if (isTransformMode) {
|
||||
setIsTransformMode(false);
|
||||
}
|
||||
|
||||
// Clear previous selection if exists
|
||||
if (currentSelection) {
|
||||
canvas.remove(currentSelection);
|
||||
if (sel) {
|
||||
canvas.remove(sel);
|
||||
setCurrentSelection(null);
|
||||
}
|
||||
|
||||
@@ -406,26 +408,24 @@ const ImageCanvas = ({
|
||||
top: startY,
|
||||
rx: 0,
|
||||
ry: 0,
|
||||
fill: 'rgba(255, 255, 255, 0.3)',
|
||||
stroke: '#00ff00',
|
||||
fill: 'rgba(0, 136, 255, 0.2)',
|
||||
stroke: '#0088ff',
|
||||
strokeWidth: 2,
|
||||
strokeDashArray: [5, 5],
|
||||
selectable: true,
|
||||
hasControls: true,
|
||||
hasBorders: true,
|
||||
lockRotation: false,
|
||||
cornerColor: '#00ff00',
|
||||
cornerSize: 10,
|
||||
cornerColor: '#0088ff',
|
||||
cornerSize: 8,
|
||||
transparentCorners: false,
|
||||
borderColor: '#00ff00',
|
||||
borderScaleFactor: 2,
|
||||
borderColor: '#0088ff',
|
||||
});
|
||||
|
||||
canvas.add(ellipse);
|
||||
setCurrentSelection(ellipse);
|
||||
});
|
||||
|
||||
canvas.on('mouse:move', (e) => {
|
||||
if (!isDown || isTransformMode) return;
|
||||
if (!isDown || !ellipse) return;
|
||||
|
||||
const pointer = canvas.getPointer(e.e);
|
||||
const rx = Math.abs(pointer.x - startX) / 2;
|
||||
@@ -434,58 +434,55 @@ const ImageCanvas = ({
|
||||
ellipse.set({
|
||||
rx: rx,
|
||||
ry: ry,
|
||||
left: startX < pointer.x ? startX : pointer.x,
|
||||
top: startY < pointer.y ? startY : pointer.y,
|
||||
left: Math.min(startX, pointer.x),
|
||||
top: Math.min(startY, pointer.y),
|
||||
});
|
||||
|
||||
canvas.renderAll();
|
||||
});
|
||||
|
||||
canvas.on('mouse:up', () => {
|
||||
if (isDown && !isTransformMode) {
|
||||
if (isDown && ellipse && ellipse.rx > 5 && ellipse.ry > 5) {
|
||||
isDown = false;
|
||||
setCurrentSelection(ellipse);
|
||||
canvas.setActiveObject(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) => {
|
||||
if (e.target && e.target === currentSelection) {
|
||||
updateTransformedSelection(e.target, 'ellipse');
|
||||
if (e.target === currentSelectionRef.current) {
|
||||
updateTransformedSelection(e.target);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const setupLassoMode = (canvas) => {
|
||||
let polygon, points = [], drawingLine;
|
||||
let points = [];
|
||||
let drawingLine = null;
|
||||
let polygon = null;
|
||||
|
||||
canvas.on('mouse:down', (e) => {
|
||||
// If clicking on existing selection, enable transform mode
|
||||
if (e.target && e.target === currentSelection) {
|
||||
setIsTransformMode(true);
|
||||
const sel = currentSelectionRef.current;
|
||||
if (e.target && e.target === sel) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If in transform mode and clicking elsewhere, exit transform mode
|
||||
if (isTransformMode) {
|
||||
setIsTransformMode(false);
|
||||
}
|
||||
|
||||
// Clear previous selection if exists
|
||||
if (currentSelection) {
|
||||
canvas.remove(currentSelection);
|
||||
if (sel) {
|
||||
canvas.remove(sel);
|
||||
setCurrentSelection(null);
|
||||
}
|
||||
|
||||
setIsDrawing(true);
|
||||
isDrawingRef.current = true;
|
||||
const pointer = canvas.getPointer(e.e);
|
||||
points = [{ x: pointer.x, y: pointer.y }];
|
||||
|
||||
// Create a temporary line for visual feedback while drawing
|
||||
drawingLine = new fabric.Polyline(points, {
|
||||
fill: 'transparent',
|
||||
stroke: '#00ff00',
|
||||
stroke: '#0088ff',
|
||||
strokeWidth: 2,
|
||||
selectable: false,
|
||||
evented: false,
|
||||
@@ -495,16 +492,15 @@ const ImageCanvas = ({
|
||||
});
|
||||
|
||||
canvas.on('mouse:move', (e) => {
|
||||
if (!isDrawing || isTransformMode) return;
|
||||
if (!isDrawingRef.current) return;
|
||||
|
||||
const pointer = canvas.getPointer(e.e);
|
||||
points.push({ x: pointer.x, y: pointer.y });
|
||||
|
||||
// Remove old line and create new one with updated points
|
||||
canvas.remove(drawingLine);
|
||||
drawingLine = new fabric.Polyline([...points], {
|
||||
fill: 'transparent',
|
||||
stroke: '#00ff00',
|
||||
stroke: '#0088ff',
|
||||
strokeWidth: 2,
|
||||
selectable: false,
|
||||
evented: false,
|
||||
@@ -514,59 +510,50 @@ const ImageCanvas = ({
|
||||
});
|
||||
|
||||
canvas.on('mouse:up', () => {
|
||||
if (isDrawing && !isTransformMode && points.length > 2) {
|
||||
setIsDrawing(false);
|
||||
if (isDrawingRef.current && points.length > 5) {
|
||||
isDrawingRef.current = false;
|
||||
lassoPoints.current = [...points];
|
||||
|
||||
// Remove drawing line
|
||||
canvas.remove(drawingLine);
|
||||
|
||||
// Create final polygon with fill
|
||||
polygon = new fabric.Polygon(points, {
|
||||
fill: 'rgba(255, 255, 255, 0.3)',
|
||||
stroke: '#00ff00',
|
||||
fill: 'rgba(0, 136, 255, 0.2)',
|
||||
stroke: '#0088ff',
|
||||
strokeWidth: 2,
|
||||
strokeDashArray: [5, 5],
|
||||
selectable: true,
|
||||
hasControls: true,
|
||||
hasBorders: true,
|
||||
lockRotation: false,
|
||||
cornerColor: '#00ff00',
|
||||
cornerSize: 10,
|
||||
cornerColor: '#0088ff',
|
||||
cornerSize: 8,
|
||||
transparentCorners: false,
|
||||
borderColor: '#00ff00',
|
||||
borderScaleFactor: 2,
|
||||
borderColor: '#0088ff',
|
||||
});
|
||||
|
||||
canvas.add(polygon);
|
||||
canvas.setActiveObject(polygon);
|
||||
setCurrentSelection(polygon);
|
||||
updateSelection(polygon, 'lasso');
|
||||
} else if (isDrawing) {
|
||||
setIsDrawing(false);
|
||||
} else if (isDrawingRef.current) {
|
||||
isDrawingRef.current = false;
|
||||
canvas.remove(drawingLine);
|
||||
}
|
||||
});
|
||||
|
||||
// Update selection when object is modified (moved, scaled, rotated)
|
||||
canvas.on('object:modified', (e) => {
|
||||
if (e.target && e.target === currentSelection) {
|
||||
updateTransformedSelection(e.target, 'lasso');
|
||||
if (e.target === currentSelectionRef.current) {
|
||||
updateTransformedSelection(e.target);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const updateSelection = (selection, type) => {
|
||||
if (!selection || !fabricCanvasRef.current) return;
|
||||
if (!selection || !imageRef.current) return;
|
||||
|
||||
const canvas = fabricCanvasRef.current;
|
||||
const bgImage = canvas.backgroundImage;
|
||||
|
||||
if (!bgImage) return;
|
||||
|
||||
// Calculate bounding box in original image coordinates
|
||||
const imgScale = bgImage.scaleX;
|
||||
const imgLeft = bgImage.left;
|
||||
const imgTop = bgImage.top;
|
||||
const img = imageRef.current;
|
||||
const imgScale = img.scaleX;
|
||||
const imgLeft = img.left;
|
||||
const imgTop = img.top;
|
||||
|
||||
let bbox, selectionData = null;
|
||||
|
||||
@@ -593,39 +580,32 @@ const ImageCanvas = ({
|
||||
height: Math.round(bounds.height / imgScale),
|
||||
};
|
||||
|
||||
// Convert lasso points to relative coordinates within bbox
|
||||
const relativePoints = lassoPoints.current.map(p => [
|
||||
Math.round((p.x - bounds.left) / imgScale),
|
||||
Math.round((p.y - bounds.top) / imgScale),
|
||||
Math.round((p.x - imgLeft) / imgScale) - bbox.x,
|
||||
Math.round((p.y - imgTop) / imgScale) - bbox.y,
|
||||
]);
|
||||
|
||||
selectionData = { points: relativePoints };
|
||||
}
|
||||
|
||||
onSelectionChange({
|
||||
onSelectionChange?.({
|
||||
type,
|
||||
bbox,
|
||||
selectionData,
|
||||
});
|
||||
};
|
||||
|
||||
// Update selection after transformation (move, scale, rotate)
|
||||
const updateTransformedSelection = (selection, type) => {
|
||||
if (!selection || !fabricCanvasRef.current) return;
|
||||
const updateTransformedSelection = (selection) => {
|
||||
if (!selection || !imageRef.current) return;
|
||||
|
||||
const canvas = fabricCanvasRef.current;
|
||||
const bgImage = canvas.backgroundImage;
|
||||
const img = imageRef.current;
|
||||
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);
|
||||
|
||||
let bbox = {
|
||||
const bbox = {
|
||||
x: Math.round((bounds.left - imgLeft) / imgScale),
|
||||
y: Math.round((bounds.top - imgTop) / imgScale),
|
||||
width: Math.round(bounds.width / imgScale),
|
||||
@@ -633,8 +613,8 @@ const ImageCanvas = ({
|
||||
};
|
||||
|
||||
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) {
|
||||
const matrix = selection.calcTransformMatrix();
|
||||
const transformedPoints = lassoPoints.current.map(p => {
|
||||
@@ -643,14 +623,14 @@ const ImageCanvas = ({
|
||||
matrix
|
||||
);
|
||||
return [
|
||||
Math.round((transformed.x - bounds.left) / imgScale),
|
||||
Math.round((transformed.y - bounds.top) / imgScale),
|
||||
Math.round((transformed.x - imgLeft) / imgScale) - bbox.x,
|
||||
Math.round((transformed.y - imgTop) / imgScale) - bbox.y,
|
||||
];
|
||||
});
|
||||
selectionData = { points: transformedPoints };
|
||||
}
|
||||
|
||||
onSelectionChange({
|
||||
onSelectionChange?.({
|
||||
type,
|
||||
bbox,
|
||||
selectionData,
|
||||
@@ -658,10 +638,12 @@ const ImageCanvas = ({
|
||||
};
|
||||
|
||||
const clearSelection = () => {
|
||||
if (currentSelection && fabricCanvasRef.current) {
|
||||
fabricCanvasRef.current.remove(currentSelection);
|
||||
const canvas = fabricCanvasRef.current;
|
||||
const sel = currentSelectionRef.current;
|
||||
if (sel && canvas) {
|
||||
canvas.remove(sel);
|
||||
setCurrentSelection(null);
|
||||
onSelectionChange(null);
|
||||
onSelectionChange?.(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -697,36 +679,15 @@ const ImageCanvas = ({
|
||||
return (
|
||||
<div className="canvas-container">
|
||||
<canvas ref={canvasRef} />
|
||||
|
||||
{/* 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}>
|
||||
Clear Selection
|
||||
</button>
|
||||
</>
|
||||
{currentSelection && (
|
||||
<button className="clear-selection-btn" onClick={clearSelection}>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
ImageCanvas.displayName = 'ImageCanvas';
|
||||
|
||||
export default ImageCanvas;
|
||||
|
||||
Reference in New Issue
Block a user