Fix UX issues and add Eye Catalog UI
Fixes: - Remove scale limit to allow image to fill canvas - Fix Lasso tool by using Polygon instead of Polyline - Remove duplicate Clear Selection button - Fix Undo/Redo dependencies with useCallback New Features: - Eye Catalog UI for browsing and applying saved eyes - Upload new eyes to catalog - Apply eyes to selected areas with feathering - Delete eyes from catalog
This commit is contained in:
+19
-7
@@ -2,6 +2,7 @@ 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 { projectsApi, editsApi } from './utils/api';
|
||||
import './App.css';
|
||||
|
||||
@@ -144,7 +145,7 @@ function App() {
|
||||
};
|
||||
|
||||
// Handle revert
|
||||
const handleRevert = async (editId) => {
|
||||
const handleRevert = useCallback(async (editId) => {
|
||||
if (!project) return;
|
||||
|
||||
try {
|
||||
@@ -162,10 +163,10 @@ function App() {
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
}, [project]);
|
||||
|
||||
// Handle reset
|
||||
const handleReset = async () => {
|
||||
const handleReset = useCallback(async () => {
|
||||
if (!project) return;
|
||||
|
||||
try {
|
||||
@@ -183,7 +184,7 @@ function App() {
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
}, [project]);
|
||||
|
||||
// Handle download
|
||||
const handleDownload = async () => {
|
||||
@@ -225,7 +226,7 @@ function App() {
|
||||
await handleRevert(previousEdit.id);
|
||||
setCurrentEditIndex(currentEditIndex - 1);
|
||||
}
|
||||
}, [project, isProcessing, currentEditIndex]);
|
||||
}, [project, isProcessing, currentEditIndex, handleReset, handleRevert]);
|
||||
|
||||
// Handle redo (Ctrl+Y)
|
||||
const handleRedo = useCallback(async () => {
|
||||
@@ -237,7 +238,7 @@ function App() {
|
||||
const nextEdit = completedEdits[currentEditIndex + 1];
|
||||
await handleRevert(nextEdit.id);
|
||||
setCurrentEditIndex(currentEditIndex + 1);
|
||||
}, [project, isProcessing, currentEditIndex]);
|
||||
}, [project, isProcessing, currentEditIndex, handleRevert]);
|
||||
|
||||
// Keyboard shortcut handler
|
||||
useEffect(() => {
|
||||
@@ -343,12 +344,23 @@ function App() {
|
||||
prompt={prompt}
|
||||
onPromptChange={setPrompt}
|
||||
onFix={handleFix}
|
||||
onClear={() => setSelection(null)}
|
||||
onDownload={handleDownload}
|
||||
isProcessing={isProcessing}
|
||||
hasSelection={!!selection}
|
||||
/>
|
||||
|
||||
<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}
|
||||
|
||||
@@ -11,7 +11,6 @@ const Controls = ({
|
||||
prompt,
|
||||
onPromptChange,
|
||||
onFix,
|
||||
onClear,
|
||||
onDownload,
|
||||
isProcessing,
|
||||
hasSelection,
|
||||
@@ -107,13 +106,6 @@ const Controls = ({
|
||||
>
|
||||
{isProcessing ? 'Processing...' : 'Fix Selected Area'}
|
||||
</button>
|
||||
<button
|
||||
className="clear-btn"
|
||||
onClick={onClear}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Clear Selection
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="control-section">
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
.eye-catalog {
|
||||
background-color: #2a2a2a;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #444;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.catalog-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.catalog-header h3 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.upload-toggle-btn {
|
||||
background-color: #00aa00;
|
||||
color: white;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.upload-toggle-btn:hover {
|
||||
background-color: #00cc00;
|
||||
}
|
||||
|
||||
.catalog-error {
|
||||
background-color: #ff4444;
|
||||
color: white;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.catalog-error button {
|
||||
background: none;
|
||||
color: white;
|
||||
padding: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.upload-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
padding: 12px;
|
||||
background-color: #333;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.upload-form input[type="text"],
|
||||
.upload-form input[type="file"] {
|
||||
padding: 8px;
|
||||
border: 1px solid #555;
|
||||
border-radius: 4px;
|
||||
background-color: #2a2a2a;
|
||||
color: white;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.upload-btn {
|
||||
background-color: #0066ff;
|
||||
color: white;
|
||||
padding: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.upload-btn:hover:not(:disabled) {
|
||||
background-color: #0055dd;
|
||||
}
|
||||
|
||||
.patches-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
|
||||
gap: 8px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.no-patches {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
color: #888;
|
||||
font-size: 13px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.patch-item {
|
||||
position: relative;
|
||||
background-color: #333;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.patch-item:hover {
|
||||
border-color: #666;
|
||||
}
|
||||
|
||||
.patch-item.selected {
|
||||
border-color: #00ff00;
|
||||
box-shadow: 0 0 8px rgba(0, 255, 0, 0.3);
|
||||
}
|
||||
|
||||
.patch-item img {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.patch-info {
|
||||
padding: 4px 6px;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.patch-name {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.patch-tags {
|
||||
font-size: 9px;
|
||||
color: #888;
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.delete-patch-btn {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
background-color: rgba(255, 0, 0, 0.8);
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
border-radius: 50%;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.patch-item:hover .delete-patch-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.apply-section {
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #444;
|
||||
}
|
||||
|
||||
.selected-preview {
|
||||
font-size: 13px;
|
||||
margin-bottom: 10px;
|
||||
color: #00ff00;
|
||||
}
|
||||
|
||||
.apply-btn {
|
||||
width: 100%;
|
||||
background-color: #0066ff;
|
||||
color: white;
|
||||
padding: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.apply-btn:hover:not(:disabled) {
|
||||
background-color: #0055dd;
|
||||
}
|
||||
|
||||
.apply-btn:disabled {
|
||||
background-color: #333;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.apply-hint {
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
margin-top: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { patchesApi } from '../utils/api';
|
||||
import './EyeCatalog.css';
|
||||
|
||||
const EyeCatalog = ({
|
||||
projectId,
|
||||
selection,
|
||||
feather,
|
||||
onApply,
|
||||
isProcessing,
|
||||
}) => {
|
||||
const [patches, setPatches] = useState([]);
|
||||
const [selectedPatch, setSelectedPatch] = useState(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [showUpload, setShowUpload] = useState(false);
|
||||
const [uploadName, setUploadName] = useState('');
|
||||
const [uploadFile, setUploadFile] = useState(null);
|
||||
const [uploadTags, setUploadTags] = useState('');
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Load patches on mount
|
||||
useEffect(() => {
|
||||
loadPatches();
|
||||
}, []);
|
||||
|
||||
const loadPatches = async () => {
|
||||
try {
|
||||
const data = await patchesApi.list('eyes');
|
||||
setPatches(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to load patches:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!uploadFile || !uploadName.trim()) {
|
||||
setError('Please provide a name and select a file');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsUploading(true);
|
||||
setError(null);
|
||||
await patchesApi.create(uploadName.trim(), uploadFile, 'eyes', uploadTags);
|
||||
await loadPatches();
|
||||
setShowUpload(false);
|
||||
setUploadName('');
|
||||
setUploadFile(null);
|
||||
setUploadTags('');
|
||||
} catch (err) {
|
||||
setError(`Upload failed: ${err.message}`);
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApply = async () => {
|
||||
if (!selectedPatch || !selection || !projectId) {
|
||||
setError('Please select an eye and make a selection on the image');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
await patchesApi.apply(projectId, selectedPatch.id, selection.bbox, feather);
|
||||
onApply();
|
||||
setSelectedPatch(null);
|
||||
} catch (err) {
|
||||
setError(`Failed to apply: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (patchId) => {
|
||||
if (!window.confirm('Delete this eye from the catalog?')) return;
|
||||
|
||||
try {
|
||||
await patchesApi.delete(patchId);
|
||||
await loadPatches();
|
||||
if (selectedPatch?.id === patchId) {
|
||||
setSelectedPatch(null);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(`Delete failed: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="eye-catalog">
|
||||
<div className="catalog-header">
|
||||
<h3>Eye Catalog</h3>
|
||||
<button
|
||||
className="upload-toggle-btn"
|
||||
onClick={() => setShowUpload(!showUpload)}
|
||||
>
|
||||
{showUpload ? 'Cancel' : '+ Add Eye'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="catalog-error">
|
||||
{error}
|
||||
<button onClick={() => setError(null)}>×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showUpload && (
|
||||
<div className="upload-form">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Eye name (e.g., 'Greek Serene Left')"
|
||||
value={uploadName}
|
||||
onChange={(e) => setUploadName(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => setUploadFile(e.target.files[0])}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Tags (e.g., 'greek,serene,left')"
|
||||
value={uploadTags}
|
||||
onChange={(e) => setUploadTags(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
className="upload-btn"
|
||||
onClick={handleUpload}
|
||||
disabled={isUploading || !uploadFile || !uploadName.trim()}
|
||||
>
|
||||
{isUploading ? 'Uploading...' : 'Upload Eye'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="patches-grid">
|
||||
{patches.length === 0 ? (
|
||||
<div className="no-patches">
|
||||
No eyes in catalog yet. Click "+ Add Eye" to upload.
|
||||
</div>
|
||||
) : (
|
||||
patches.map((patch) => (
|
||||
<div
|
||||
key={patch.id}
|
||||
className={`patch-item ${selectedPatch?.id === patch.id ? 'selected' : ''}`}
|
||||
onClick={() => setSelectedPatch(patch)}
|
||||
>
|
||||
<img
|
||||
src={patchesApi.getImageUrl(patch.id, true)}
|
||||
alt={patch.name}
|
||||
onError={(e) => {
|
||||
e.target.src = patchesApi.getImageUrl(patch.id, false);
|
||||
}}
|
||||
/>
|
||||
<div className="patch-info">
|
||||
<span className="patch-name">{patch.name}</span>
|
||||
{patch.tags && <span className="patch-tags">{patch.tags}</span>}
|
||||
</div>
|
||||
<button
|
||||
className="delete-patch-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(patch.id);
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedPatch && (
|
||||
<div className="apply-section">
|
||||
<div className="selected-preview">
|
||||
<strong>Selected:</strong> {selectedPatch.name}
|
||||
</div>
|
||||
<button
|
||||
className="apply-btn"
|
||||
onClick={handleApply}
|
||||
disabled={isProcessing || !selection}
|
||||
>
|
||||
{!selection ? 'Draw selection first' : 'Apply to Selection'}
|
||||
</button>
|
||||
<p className="apply-hint">
|
||||
Draw a rectangle/ellipse where you want to place the eye
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EyeCatalog;
|
||||
@@ -32,10 +32,10 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => {
|
||||
// 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,
|
||||
1
|
||||
(height - 40) / bgImage.height
|
||||
);
|
||||
bgImage.scale(scale);
|
||||
bgImage.set({
|
||||
@@ -290,7 +290,7 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => {
|
||||
};
|
||||
|
||||
const setupLassoMode = (canvas) => {
|
||||
let line, points = [];
|
||||
let polygon, points = [], drawingLine;
|
||||
|
||||
canvas.on('mouse:down', (e) => {
|
||||
// If clicking on existing selection, enable transform mode
|
||||
@@ -314,23 +314,16 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => {
|
||||
const pointer = canvas.getPointer(e.e);
|
||||
points = [{ x: pointer.x, y: pointer.y }];
|
||||
|
||||
line = new fabric.Polyline(points, {
|
||||
fill: 'rgba(255, 255, 255, 0.3)',
|
||||
// Create a temporary line for visual feedback while drawing
|
||||
drawingLine = new fabric.Polyline(points, {
|
||||
fill: 'transparent',
|
||||
stroke: '#00ff00',
|
||||
strokeWidth: 2,
|
||||
selectable: true,
|
||||
hasControls: true,
|
||||
hasBorders: true,
|
||||
lockRotation: false,
|
||||
cornerColor: '#00ff00',
|
||||
cornerSize: 10,
|
||||
transparentCorners: false,
|
||||
borderColor: '#00ff00',
|
||||
borderScaleFactor: 2,
|
||||
selectable: false,
|
||||
evented: false,
|
||||
});
|
||||
|
||||
canvas.add(line);
|
||||
setCurrentSelection(line);
|
||||
canvas.add(drawingLine);
|
||||
});
|
||||
|
||||
canvas.on('mouse:move', (e) => {
|
||||
@@ -339,16 +332,50 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => {
|
||||
const pointer = canvas.getPointer(e.e);
|
||||
points.push({ x: pointer.x, y: pointer.y });
|
||||
|
||||
line.set({ points: points });
|
||||
// Remove old line and create new one with updated points
|
||||
canvas.remove(drawingLine);
|
||||
drawingLine = new fabric.Polyline([...points], {
|
||||
fill: 'transparent',
|
||||
stroke: '#00ff00',
|
||||
strokeWidth: 2,
|
||||
selectable: false,
|
||||
evented: false,
|
||||
});
|
||||
canvas.add(drawingLine);
|
||||
canvas.renderAll();
|
||||
});
|
||||
|
||||
canvas.on('mouse:up', () => {
|
||||
if (isDrawing && !isTransformMode) {
|
||||
if (isDrawing && !isTransformMode && points.length > 2) {
|
||||
setIsDrawing(false);
|
||||
lassoPoints.current = points;
|
||||
canvas.setActiveObject(line);
|
||||
updateSelection(line, 'lasso');
|
||||
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',
|
||||
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);
|
||||
updateSelection(polygon, 'lasso');
|
||||
} else if (isDrawing) {
|
||||
setIsDrawing(false);
|
||||
canvas.remove(drawingLine);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -86,4 +86,64 @@ export const editsApi = {
|
||||
},
|
||||
};
|
||||
|
||||
export const patchesApi = {
|
||||
// List all patches (eyes)
|
||||
list: async (category = null, tags = null) => {
|
||||
const params = new URLSearchParams();
|
||||
if (category) params.append('category', category);
|
||||
if (tags) params.append('tags', tags);
|
||||
const response = await api.get(`/patches/?${params.toString()}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get a specific patch
|
||||
get: async (patchId) => {
|
||||
const response = await api.get(`/patches/${patchId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Upload a new patch (eye)
|
||||
create: async (name, file, category = 'eyes', tags = '') => {
|
||||
const formData = new FormData();
|
||||
formData.append('name', name);
|
||||
formData.append('source_type', 'imported');
|
||||
formData.append('category', category);
|
||||
formData.append('tags', tags);
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await api.post('/patches/', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Apply a patch to the project
|
||||
apply: async (projectId, patchId, bbox, featherPx = 5) => {
|
||||
const formData = new FormData();
|
||||
formData.append('project_id', projectId);
|
||||
formData.append('patch_id', patchId);
|
||||
formData.append('bbox', JSON.stringify(bbox));
|
||||
formData.append('feather_px', featherPx);
|
||||
|
||||
const response = await api.post('/patches/apply', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Delete a patch
|
||||
delete: async (patchId) => {
|
||||
const response = await api.delete(`/patches/${patchId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get patch image URL
|
||||
getImageUrl: (patchId, thumbnail = false) =>
|
||||
`${API_BASE_URL}/patches/${patchId}/image${thumbnail ? '?thumbnail=true' : ''}`,
|
||||
};
|
||||
|
||||
export default api;
|
||||
|
||||
Reference in New Issue
Block a user