diff --git a/frontend/src/App.css b/frontend/src/App.css
index 92c87d5..bf7c1fc 100644
--- a/frontend/src/App.css
+++ b/frontend/src/App.css
@@ -69,6 +69,18 @@
margin-top: 4px;
}
+.optional-field label::after {
+ content: '';
+}
+
+.optional-field {
+ opacity: 0.7;
+}
+
+.optional-field:focus-within {
+ opacity: 1;
+}
+
.create-project-btn {
background-color: #0066ff;
color: white;
@@ -86,7 +98,8 @@
display: grid;
grid-template-columns: 1fr 400px;
gap: 20px;
- min-height: 600px;
+ min-height: calc(100vh - 180px);
+ height: calc(100vh - 180px);
}
@media (max-width: 1200px) {
@@ -98,6 +111,8 @@
.left-panel {
display: flex;
flex-direction: column;
+ height: 100%;
+ min-height: 0;
}
.right-panel {
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index a2c1882..23184ed 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1,4 +1,4 @@
-import React, { useState, useEffect } from 'react';
+import React, { useState, useEffect, useCallback, useRef } from 'react';
import ImageCanvas from './components/ImageCanvas';
import Controls from './components/Controls';
import History from './components/History';
@@ -19,11 +19,13 @@ function App() {
const [error, setError] = useState(null);
const [projectName, setProjectName] = useState('');
const [showProjectInput, setShowProjectInput] = useState(true);
+ const [currentEditIndex, setCurrentEditIndex] = useState(-1);
+ const editsRef = useRef([]);
// Create project and upload image
const handleCreateProject = async () => {
- if (!projectName.trim() || !imageFile) {
- setError('Please provide a project name and select an image');
+ if (!imageFile) {
+ setError('Please select an image');
return;
}
@@ -31,8 +33,13 @@ 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(projectName);
+ const newProject = await projectsApi.create(defaultName);
setProject(newProject);
// Upload image
@@ -58,6 +65,10 @@ function App() {
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) {
console.error('Failed to load edits:', err);
}
@@ -174,6 +185,93 @@ function App() {
}
};
+ // Handle download
+ const handleDownload = async () => {
+ if (!currentImageUrl) return;
+
+ try {
+ // Fetch the current image
+ const response = await fetch(`${currentImageUrl}?t=${Date.now()}`);
+ 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.click();
+ document.body.removeChild(link);
+ window.URL.revokeObjectURL(url);
+ } catch (err) {
+ setError(`Failed to download: ${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);
+ }
+ }, [project, isProcessing, currentEditIndex]);
+
+ // Handle redo (Ctrl+Y)
+ const handleRedo = useCallback(async () => {
+ if (!project || isProcessing) return;
+
+ const completedEdits = editsRef.current.filter(e => e.status === 'completed');
+ if (currentEditIndex >= completedEdits.length - 1) return;
+
+ const nextEdit = completedEdits[currentEditIndex + 1];
+ await handleRevert(nextEdit.id);
+ setCurrentEditIndex(currentEditIndex + 1);
+ }, [project, isProcessing, currentEditIndex]);
+
+ // Keyboard shortcut handler
+ 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();
+ }
+ };
+
+ window.addEventListener('keydown', handleKeyDown);
+ return () => window.removeEventListener('keydown', handleKeyDown);
+ }, [handleUndo, handleRedo]);
+
+ // 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;
+ }
+ };
+
+ window.addEventListener('beforeunload', handleBeforeUnload);
+ return () => window.removeEventListener('beforeunload', handleBeforeUnload);
+ }, [project, edits]);
+
return (
@@ -191,18 +289,8 @@ function App() {
{showProjectInput ? (
-
Create New Project
+
Start Editing
@@ -246,6 +344,7 @@ function App() {
onPromptChange={setPrompt}
onFix={handleFix}
onClear={() => setSelection(null)}
+ onDownload={handleDownload}
isProcessing={isProcessing}
hasSelection={!!selection}
/>
diff --git a/frontend/src/components/Controls.css b/frontend/src/components/Controls.css
index 13c6634..6166a6d 100644
--- a/frontend/src/components/Controls.css
+++ b/frontend/src/components/Controls.css
@@ -119,3 +119,16 @@
.clear-btn:hover:not(:disabled) {
background-color: #cc0000;
}
+
+.download-btn {
+ width: 100%;
+ background-color: #00aa00;
+ color: white;
+ padding: 14px 20px;
+ font-size: 16px;
+ font-weight: 600;
+}
+
+.download-btn:hover:not(:disabled) {
+ background-color: #00cc00;
+}
diff --git a/frontend/src/components/Controls.jsx b/frontend/src/components/Controls.jsx
index 8d078cb..e72325a 100644
--- a/frontend/src/components/Controls.jsx
+++ b/frontend/src/components/Controls.jsx
@@ -12,6 +12,7 @@ const Controls = ({
onPromptChange,
onFix,
onClear,
+ onDownload,
isProcessing,
hasSelection,
}) => {
@@ -114,6 +115,16 @@ const Controls = ({
Clear Selection
+
+
+
+
);
};
diff --git a/frontend/src/components/ImageCanvas.css b/frontend/src/components/ImageCanvas.css
index 7613800..c5d03d1 100644
--- a/frontend/src/components/ImageCanvas.css
+++ b/frontend/src/components/ImageCanvas.css
@@ -2,11 +2,14 @@
position: relative;
width: 100%;
height: 100%;
- min-height: 500px;
+ min-height: calc(100vh - 180px);
background-color: #2a2a2a;
border: 2px solid #444;
border-radius: 8px;
overflow: hidden;
+ display: flex;
+ align-items: center;
+ justify-content: center;
}
.canvas-container canvas {
@@ -26,3 +29,17 @@
.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;
+}
diff --git a/frontend/src/components/ImageCanvas.jsx b/frontend/src/components/ImageCanvas.jsx
index f87c2a7..7df6108 100644
--- a/frontend/src/components/ImageCanvas.jsx
+++ b/frontend/src/components/ImageCanvas.jsx
@@ -1,4 +1,4 @@
-import React, { useEffect, useRef, useState } from 'react';
+import React, { useEffect, useRef, useState, useCallback } from 'react';
import { fabric } from 'fabric';
import './ImageCanvas.css';
@@ -7,6 +7,7 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => {
const fabricCanvasRef = useRef(null);
const [currentSelection, setCurrentSelection] = useState(null);
const [isDrawing, setIsDrawing] = useState(false);
+ const [isTransformMode, setIsTransformMode] = useState(false);
const lassoPoints = useRef([]);
useEffect(() => {
@@ -23,8 +24,25 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => {
const handleResize = () => {
const container = canvasRef.current?.parentElement;
if (container) {
- canvas.setWidth(container.clientWidth);
- canvas.setHeight(Math.min(container.clientHeight, 800));
+ const width = container.clientWidth;
+ const height = container.clientHeight;
+ canvas.setWidth(width);
+ canvas.setHeight(height);
+
+ // Re-center and rescale the image if it exists
+ const bgImage = canvas.backgroundImage;
+ if (bgImage) {
+ const scale = Math.min(
+ (width - 40) / bgImage.width,
+ (height - 40) / bgImage.height,
+ 1
+ );
+ bgImage.scale(scale);
+ bgImage.set({
+ left: (width - bgImage.width * scale) / 2,
+ top: (height - bgImage.height * scale) / 2,
+ });
+ }
canvas.renderAll();
}
};
@@ -50,11 +68,13 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => {
fabric.Image.fromURL(cacheBustedUrl, (img) => {
canvas.clear();
- // Scale image to fit canvas
+ // Scale image to fit canvas with padding
+ const padding = 40;
+ const availableWidth = canvas.width - padding;
+ const availableHeight = canvas.height - padding;
const scale = Math.min(
- canvas.width / img.width,
- canvas.height / img.height,
- 1
+ availableWidth / img.width,
+ availableHeight / img.height
);
img.scale(scale);
@@ -80,17 +100,21 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => {
const canvas = fabricCanvasRef.current;
- // Clear previous selection
+ // Clear previous selection when changing modes
if (currentSelection) {
canvas.remove(currentSelection);
setCurrentSelection(null);
onSelectionChange(null);
}
+ // Reset transform mode
+ setIsTransformMode(false);
+
// Set up event handlers based on mode
canvas.off('mouse:down');
canvas.off('mouse:move');
canvas.off('mouse:up');
+ canvas.off('object:modified');
if (selectionMode === 'rectangle') {
setupRectangleMode(canvas);
@@ -105,6 +129,23 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => {
let rect, isDown, startX, startY;
canvas.on('mouse:down', (e) => {
+ // If clicking on existing selection, enable transform mode
+ if (e.target && e.target === currentSelection) {
+ setIsTransformMode(true);
+ 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);
+ setCurrentSelection(null);
+ }
+
isDown = true;
const pointer = canvas.getPointer(e.e);
startX = pointer.x;
@@ -119,6 +160,14 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => {
stroke: '#00ff00',
strokeWidth: 2,
selectable: true,
+ hasControls: true,
+ hasBorders: true,
+ lockRotation: false,
+ cornerColor: '#00ff00',
+ cornerSize: 10,
+ transparentCorners: false,
+ borderColor: '#00ff00',
+ borderScaleFactor: 2,
});
canvas.add(rect);
@@ -126,7 +175,7 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => {
});
canvas.on('mouse:move', (e) => {
- if (!isDown) return;
+ if (!isDown || isTransformMode) return;
const pointer = canvas.getPointer(e.e);
const width = pointer.x - startX;
@@ -143,8 +192,18 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => {
});
canvas.on('mouse:up', () => {
- isDown = false;
- updateSelection(rect, 'rectangle');
+ if (isDown && !isTransformMode) {
+ isDown = false;
+ canvas.setActiveObject(rect);
+ updateSelection(rect, 'rectangle');
+ }
+ });
+
+ // Update selection when object is modified (moved, scaled, rotated)
+ canvas.on('object:modified', (e) => {
+ if (e.target && e.target === currentSelection) {
+ updateTransformedSelection(e.target, 'rectangle');
+ }
});
};
@@ -152,6 +211,23 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => {
let ellipse, isDown, startX, startY;
canvas.on('mouse:down', (e) => {
+ // If clicking on existing selection, enable transform mode
+ if (e.target && e.target === currentSelection) {
+ setIsTransformMode(true);
+ 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);
+ setCurrentSelection(null);
+ }
+
isDown = true;
const pointer = canvas.getPointer(e.e);
startX = pointer.x;
@@ -166,6 +242,14 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => {
stroke: '#00ff00',
strokeWidth: 2,
selectable: true,
+ hasControls: true,
+ hasBorders: true,
+ lockRotation: false,
+ cornerColor: '#00ff00',
+ cornerSize: 10,
+ transparentCorners: false,
+ borderColor: '#00ff00',
+ borderScaleFactor: 2,
});
canvas.add(ellipse);
@@ -173,7 +257,7 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => {
});
canvas.on('mouse:move', (e) => {
- if (!isDown) return;
+ if (!isDown || isTransformMode) return;
const pointer = canvas.getPointer(e.e);
const rx = Math.abs(pointer.x - startX) / 2;
@@ -190,8 +274,18 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => {
});
canvas.on('mouse:up', () => {
- isDown = false;
- updateSelection(ellipse, 'ellipse');
+ if (isDown && !isTransformMode) {
+ isDown = false;
+ canvas.setActiveObject(ellipse);
+ updateSelection(ellipse, 'ellipse');
+ }
+ });
+
+ // Update selection when object is modified (moved, scaled, rotated)
+ canvas.on('object:modified', (e) => {
+ if (e.target && e.target === currentSelection) {
+ updateTransformedSelection(e.target, 'ellipse');
+ }
});
};
@@ -199,6 +293,23 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => {
let line, points = [];
canvas.on('mouse:down', (e) => {
+ // If clicking on existing selection, enable transform mode
+ if (e.target && e.target === currentSelection) {
+ setIsTransformMode(true);
+ 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);
+ setCurrentSelection(null);
+ }
+
setIsDrawing(true);
const pointer = canvas.getPointer(e.e);
points = [{ x: pointer.x, y: pointer.y }];
@@ -208,6 +319,14 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => {
stroke: '#00ff00',
strokeWidth: 2,
selectable: true,
+ hasControls: true,
+ hasBorders: true,
+ lockRotation: false,
+ cornerColor: '#00ff00',
+ cornerSize: 10,
+ transparentCorners: false,
+ borderColor: '#00ff00',
+ borderScaleFactor: 2,
});
canvas.add(line);
@@ -215,7 +334,7 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => {
});
canvas.on('mouse:move', (e) => {
- if (!isDrawing) return;
+ if (!isDrawing || isTransformMode) return;
const pointer = canvas.getPointer(e.e);
points.push({ x: pointer.x, y: pointer.y });
@@ -225,9 +344,19 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => {
});
canvas.on('mouse:up', () => {
- setIsDrawing(false);
- lassoPoints.current = points;
- updateSelection(line, 'lasso');
+ if (isDrawing && !isTransformMode) {
+ setIsDrawing(false);
+ lassoPoints.current = points;
+ canvas.setActiveObject(line);
+ updateSelection(line, 'lasso');
+ }
+ });
+
+ // Update selection when object is modified (moved, scaled, rotated)
+ canvas.on('object:modified', (e) => {
+ if (e.target && e.target === currentSelection) {
+ updateTransformedSelection(e.target, 'lasso');
+ }
});
};
@@ -285,6 +414,54 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => {
});
};
+ // Update selection after transformation (move, scale, rotate)
+ const updateTransformedSelection = (selection, type) => {
+ if (!selection || !fabricCanvasRef.current) return;
+
+ const canvas = fabricCanvasRef.current;
+ const bgImage = canvas.backgroundImage;
+
+ 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 = {
+ x: Math.round((bounds.left - imgLeft) / imgScale),
+ y: Math.round((bounds.top - imgTop) / imgScale),
+ width: Math.round(bounds.width / imgScale),
+ height: Math.round(bounds.height / imgScale),
+ };
+
+ let selectionData = null;
+
+ // 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 => {
+ const transformed = fabric.util.transformPoint(
+ new fabric.Point(p.x, p.y),
+ matrix
+ );
+ return [
+ Math.round((transformed.x - bounds.left) / imgScale),
+ Math.round((transformed.y - bounds.top) / imgScale),
+ ];
+ });
+ selectionData = { points: transformedPoints };
+ }
+
+ onSelectionChange({
+ type,
+ bbox,
+ selectionData,
+ });
+ };
+
const clearSelection = () => {
if (currentSelection && fabricCanvasRef.current) {
fabricCanvasRef.current.remove(currentSelection);
@@ -297,9 +474,14 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => {
{currentSelection && (
-
+ <>
+
+ Click selection to move/resize/rotate
+
+
+ >
)}
);