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

This commit is contained in:
outis1one
2026-01-26 00:01:44 -05:00
committed by GitHub
8 changed files with 1514 additions and 721 deletions
+10
View File
@@ -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
View File
@@ -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
View File
@@ -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>
);
+32 -29
View File
@@ -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 */
+269 -308
View File
@@ -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;