Merge pull request #9 from outis1one/claude/add-eye-detection-feature-69XOl

Add core editing UX improvements
This commit is contained in:
outis1one
2026-01-24 23:53:40 -05:00
committed by GitHub
6 changed files with 377 additions and 40 deletions
+16 -1
View File
@@ -69,6 +69,18 @@
margin-top: 4px; margin-top: 4px;
} }
.optional-field label::after {
content: '';
}
.optional-field {
opacity: 0.7;
}
.optional-field:focus-within {
opacity: 1;
}
.create-project-btn { .create-project-btn {
background-color: #0066ff; background-color: #0066ff;
color: white; color: white;
@@ -86,7 +98,8 @@
display: grid; display: grid;
grid-template-columns: 1fr 400px; grid-template-columns: 1fr 400px;
gap: 20px; gap: 20px;
min-height: 600px; min-height: calc(100vh - 180px);
height: calc(100vh - 180px);
} }
@media (max-width: 1200px) { @media (max-width: 1200px) {
@@ -98,6 +111,8 @@
.left-panel { .left-panel {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%;
min-height: 0;
} }
.right-panel { .right-panel {
+116 -17
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useCallback, useRef } from 'react';
import ImageCanvas from './components/ImageCanvas'; import ImageCanvas from './components/ImageCanvas';
import Controls from './components/Controls'; import Controls from './components/Controls';
import History from './components/History'; import History from './components/History';
@@ -19,11 +19,13 @@ function App() {
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [projectName, setProjectName] = useState(''); const [projectName, setProjectName] = useState('');
const [showProjectInput, setShowProjectInput] = useState(true); const [showProjectInput, setShowProjectInput] = useState(true);
const [currentEditIndex, setCurrentEditIndex] = useState(-1);
const editsRef = useRef([]);
// Create project and upload image // Create project and upload image
const handleCreateProject = async () => { const handleCreateProject = async () => {
if (!projectName.trim() || !imageFile) { if (!imageFile) {
setError('Please provide a project name and select an image'); setError('Please select an image');
return; return;
} }
@@ -31,8 +33,13 @@ function App() {
setError(null); setError(null);
setIsProcessing(true); setIsProcessing(true);
// Generate default project name from file name or timestamp
const defaultName = projectName.trim() ||
imageFile.name.replace(/\.[^/.]+$/, '') ||
`Project ${Date.now()}`;
// Create project // Create project
const newProject = await projectsApi.create(projectName); const newProject = await projectsApi.create(defaultName);
setProject(newProject); setProject(newProject);
// Upload image // Upload image
@@ -58,6 +65,10 @@ function App() {
try { try {
const projectEdits = await projectsApi.getEdits(projectId); const projectEdits = await projectsApi.getEdits(projectId);
setEdits(projectEdits); 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) { } catch (err) {
console.error('Failed to load edits:', 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 ( return (
<div className="app"> <div className="app">
<div className="container"> <div className="container">
@@ -191,18 +289,8 @@ function App() {
{showProjectInput ? ( {showProjectInput ? (
<div className="project-setup"> <div className="project-setup">
<h2>Create New Project</h2> <h2>Start Editing</h2>
<div className="setup-form"> <div className="setup-form">
<div className="form-group">
<label>Project Name</label>
<input
type="text"
value={projectName}
onChange={(e) => setProjectName(e.target.value)}
placeholder="My Photo Edit Project"
disabled={isProcessing}
/>
</div>
<div className="form-group"> <div className="form-group">
<label>Upload Image</label> <label>Upload Image</label>
<input <input
@@ -215,12 +303,22 @@ function App() {
<p className="file-name">Selected: {imageFile.name}</p> <p className="file-name">Selected: {imageFile.name}</p>
)} )}
</div> </div>
<div className="form-group optional-field">
<label>Project Name (optional)</label>
<input
type="text"
value={projectName}
onChange={(e) => setProjectName(e.target.value)}
placeholder="Uses filename if left empty"
disabled={isProcessing}
/>
</div>
<button <button
className="create-project-btn" className="create-project-btn"
onClick={handleCreateProject} onClick={handleCreateProject}
disabled={isProcessing || !projectName.trim() || !imageFile} disabled={isProcessing || !imageFile}
> >
{isProcessing ? 'Creating...' : 'Create Project'} {isProcessing ? 'Starting...' : 'Start Editing'}
</button> </button>
</div> </div>
</div> </div>
@@ -246,6 +344,7 @@ function App() {
onPromptChange={setPrompt} onPromptChange={setPrompt}
onFix={handleFix} onFix={handleFix}
onClear={() => setSelection(null)} onClear={() => setSelection(null)}
onDownload={handleDownload}
isProcessing={isProcessing} isProcessing={isProcessing}
hasSelection={!!selection} hasSelection={!!selection}
/> />
+13
View File
@@ -119,3 +119,16 @@
.clear-btn:hover:not(:disabled) { .clear-btn:hover:not(:disabled) {
background-color: #cc0000; 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;
}
+11
View File
@@ -12,6 +12,7 @@ const Controls = ({
onPromptChange, onPromptChange,
onFix, onFix,
onClear, onClear,
onDownload,
isProcessing, isProcessing,
hasSelection, hasSelection,
}) => { }) => {
@@ -114,6 +115,16 @@ const Controls = ({
Clear Selection Clear Selection
</button> </button>
</div> </div>
<div className="control-section">
<button
className="download-btn"
onClick={onDownload}
disabled={isProcessing}
>
Download Image
</button>
</div>
</div> </div>
); );
}; };
+18 -1
View File
@@ -2,11 +2,14 @@
position: relative; position: relative;
width: 100%; width: 100%;
height: 100%; height: 100%;
min-height: 500px; min-height: calc(100vh - 180px);
background-color: #2a2a2a; background-color: #2a2a2a;
border: 2px solid #444; border: 2px solid #444;
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
} }
.canvas-container canvas { .canvas-container canvas {
@@ -26,3 +29,17 @@
.clear-selection-btn:hover { .clear-selection-btn:hover {
background-color: #cc0000; 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;
}
+203 -21
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState, useCallback } from 'react';
import { fabric } from 'fabric'; import { fabric } from 'fabric';
import './ImageCanvas.css'; import './ImageCanvas.css';
@@ -7,6 +7,7 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => {
const fabricCanvasRef = useRef(null); const fabricCanvasRef = useRef(null);
const [currentSelection, setCurrentSelection] = useState(null); const [currentSelection, setCurrentSelection] = useState(null);
const [isDrawing, setIsDrawing] = useState(false); const [isDrawing, setIsDrawing] = useState(false);
const [isTransformMode, setIsTransformMode] = useState(false);
const lassoPoints = useRef([]); const lassoPoints = useRef([]);
useEffect(() => { useEffect(() => {
@@ -23,8 +24,25 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => {
const handleResize = () => { const handleResize = () => {
const container = canvasRef.current?.parentElement; const container = canvasRef.current?.parentElement;
if (container) { if (container) {
canvas.setWidth(container.clientWidth); const width = container.clientWidth;
canvas.setHeight(Math.min(container.clientHeight, 800)); 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(); canvas.renderAll();
} }
}; };
@@ -50,11 +68,13 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => {
fabric.Image.fromURL(cacheBustedUrl, (img) => { fabric.Image.fromURL(cacheBustedUrl, (img) => {
canvas.clear(); 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( const scale = Math.min(
canvas.width / img.width, availableWidth / img.width,
canvas.height / img.height, availableHeight / img.height
1
); );
img.scale(scale); img.scale(scale);
@@ -80,17 +100,21 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => {
const canvas = fabricCanvasRef.current; const canvas = fabricCanvasRef.current;
// Clear previous selection // Clear previous selection when changing modes
if (currentSelection) { if (currentSelection) {
canvas.remove(currentSelection); canvas.remove(currentSelection);
setCurrentSelection(null); setCurrentSelection(null);
onSelectionChange(null); onSelectionChange(null);
} }
// Reset transform mode
setIsTransformMode(false);
// Set up event handlers based on mode // Set up event handlers based on mode
canvas.off('mouse:down'); canvas.off('mouse:down');
canvas.off('mouse:move'); canvas.off('mouse:move');
canvas.off('mouse:up'); canvas.off('mouse:up');
canvas.off('object:modified');
if (selectionMode === 'rectangle') { if (selectionMode === 'rectangle') {
setupRectangleMode(canvas); setupRectangleMode(canvas);
@@ -105,6 +129,23 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => {
let rect, isDown, startX, startY; let rect, isDown, startX, startY;
canvas.on('mouse:down', (e) => { 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; isDown = true;
const pointer = canvas.getPointer(e.e); const pointer = canvas.getPointer(e.e);
startX = pointer.x; startX = pointer.x;
@@ -119,6 +160,14 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => {
stroke: '#00ff00', stroke: '#00ff00',
strokeWidth: 2, strokeWidth: 2,
selectable: true, selectable: true,
hasControls: true,
hasBorders: true,
lockRotation: false,
cornerColor: '#00ff00',
cornerSize: 10,
transparentCorners: false,
borderColor: '#00ff00',
borderScaleFactor: 2,
}); });
canvas.add(rect); canvas.add(rect);
@@ -126,7 +175,7 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => {
}); });
canvas.on('mouse:move', (e) => { canvas.on('mouse:move', (e) => {
if (!isDown) return; if (!isDown || isTransformMode) return;
const pointer = canvas.getPointer(e.e); const pointer = canvas.getPointer(e.e);
const width = pointer.x - startX; const width = pointer.x - startX;
@@ -143,8 +192,18 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => {
}); });
canvas.on('mouse:up', () => { canvas.on('mouse:up', () => {
isDown = false; if (isDown && !isTransformMode) {
updateSelection(rect, 'rectangle'); 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; let ellipse, isDown, startX, startY;
canvas.on('mouse:down', (e) => { 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; isDown = true;
const pointer = canvas.getPointer(e.e); const pointer = canvas.getPointer(e.e);
startX = pointer.x; startX = pointer.x;
@@ -166,6 +242,14 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => {
stroke: '#00ff00', stroke: '#00ff00',
strokeWidth: 2, strokeWidth: 2,
selectable: true, selectable: true,
hasControls: true,
hasBorders: true,
lockRotation: false,
cornerColor: '#00ff00',
cornerSize: 10,
transparentCorners: false,
borderColor: '#00ff00',
borderScaleFactor: 2,
}); });
canvas.add(ellipse); canvas.add(ellipse);
@@ -173,7 +257,7 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => {
}); });
canvas.on('mouse:move', (e) => { canvas.on('mouse:move', (e) => {
if (!isDown) return; if (!isDown || isTransformMode) return;
const pointer = canvas.getPointer(e.e); const pointer = canvas.getPointer(e.e);
const rx = Math.abs(pointer.x - startX) / 2; const rx = Math.abs(pointer.x - startX) / 2;
@@ -190,8 +274,18 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => {
}); });
canvas.on('mouse:up', () => { canvas.on('mouse:up', () => {
isDown = false; if (isDown && !isTransformMode) {
updateSelection(ellipse, 'ellipse'); 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 = []; let line, points = [];
canvas.on('mouse:down', (e) => { 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); setIsDrawing(true);
const pointer = canvas.getPointer(e.e); const pointer = canvas.getPointer(e.e);
points = [{ x: pointer.x, y: pointer.y }]; points = [{ x: pointer.x, y: pointer.y }];
@@ -208,6 +319,14 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => {
stroke: '#00ff00', stroke: '#00ff00',
strokeWidth: 2, strokeWidth: 2,
selectable: true, selectable: true,
hasControls: true,
hasBorders: true,
lockRotation: false,
cornerColor: '#00ff00',
cornerSize: 10,
transparentCorners: false,
borderColor: '#00ff00',
borderScaleFactor: 2,
}); });
canvas.add(line); canvas.add(line);
@@ -215,7 +334,7 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => {
}); });
canvas.on('mouse:move', (e) => { canvas.on('mouse:move', (e) => {
if (!isDrawing) return; if (!isDrawing || isTransformMode) return;
const pointer = canvas.getPointer(e.e); const pointer = canvas.getPointer(e.e);
points.push({ x: pointer.x, y: pointer.y }); points.push({ x: pointer.x, y: pointer.y });
@@ -225,9 +344,19 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => {
}); });
canvas.on('mouse:up', () => { canvas.on('mouse:up', () => {
setIsDrawing(false); if (isDrawing && !isTransformMode) {
lassoPoints.current = points; setIsDrawing(false);
updateSelection(line, 'lasso'); 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 = () => { const clearSelection = () => {
if (currentSelection && fabricCanvasRef.current) { if (currentSelection && fabricCanvasRef.current) {
fabricCanvasRef.current.remove(currentSelection); fabricCanvasRef.current.remove(currentSelection);
@@ -297,9 +474,14 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => {
<div className="canvas-container"> <div className="canvas-container">
<canvas ref={canvasRef} /> <canvas ref={canvasRef} />
{currentSelection && ( {currentSelection && (
<button className="clear-selection-btn" onClick={clearSelection}> <>
Clear Selection <div className="selection-hint">
</button> Click selection to move/resize/rotate
</div>
<button className="clear-selection-btn" onClick={clearSelection}>
Clear Selection
</button>
</>
)} )}
</div> </div>
); );