From 2813d930b0228cc00f9feb283eb94c3d0f0e7fdf Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Jan 2026 05:17:10 +0000 Subject: [PATCH] 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 --- frontend/src/App.jsx | 26 ++- frontend/src/components/Controls.jsx | 8 - frontend/src/components/EyeCatalog.css | 209 ++++++++++++++++++++++++ frontend/src/components/EyeCatalog.jsx | 193 ++++++++++++++++++++++ frontend/src/components/ImageCanvas.jsx | 69 +++++--- frontend/src/utils/api.js | 60 +++++++ 6 files changed, 529 insertions(+), 36 deletions(-) create mode 100644 frontend/src/components/EyeCatalog.css create mode 100644 frontend/src/components/EyeCatalog.jsx diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 23184ed..19b23ee 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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} /> + { + // Reload image after applying eye + setCurrentImageUrl(projectsApi.getCurrentImageUrl(project.id)); + await loadEdits(project.id); + }} + isProcessing={isProcessing} + /> +
{isProcessing ? 'Processing...' : 'Fix Selected Area'} -
diff --git a/frontend/src/components/EyeCatalog.css b/frontend/src/components/EyeCatalog.css new file mode 100644 index 0000000..fbf3e1e --- /dev/null +++ b/frontend/src/components/EyeCatalog.css @@ -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; +} diff --git a/frontend/src/components/EyeCatalog.jsx b/frontend/src/components/EyeCatalog.jsx new file mode 100644 index 0000000..0725cc6 --- /dev/null +++ b/frontend/src/components/EyeCatalog.jsx @@ -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 ( +
+
+

Eye Catalog

+ +
+ + {error && ( +
+ {error} + +
+ )} + + {showUpload && ( +
+ setUploadName(e.target.value)} + /> + setUploadFile(e.target.files[0])} + /> + setUploadTags(e.target.value)} + /> + +
+ )} + +
+ {patches.length === 0 ? ( +
+ No eyes in catalog yet. Click "+ Add Eye" to upload. +
+ ) : ( + patches.map((patch) => ( +
setSelectedPatch(patch)} + > + {patch.name} { + e.target.src = patchesApi.getImageUrl(patch.id, false); + }} + /> +
+ {patch.name} + {patch.tags && {patch.tags}} +
+ +
+ )) + )} +
+ + {selectedPatch && ( +
+
+ Selected: {selectedPatch.name} +
+ +

+ Draw a rectangle/ellipse where you want to place the eye +

+
+ )} +
+ ); +}; + +export default EyeCatalog; diff --git a/frontend/src/components/ImageCanvas.jsx b/frontend/src/components/ImageCanvas.jsx index 7df6108..4d87979 100644 --- a/frontend/src/components/ImageCanvas.jsx +++ b/frontend/src/components/ImageCanvas.jsx @@ -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); } }); diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 9560a70..2c75f89 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -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;