Merge pull request #10 from outis1one/claude/add-eye-detection-feature-69XOl
Fix UX issues and add Eye Catalog UI
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 ImageCanvas from './components/ImageCanvas';
|
||||||
import Controls from './components/Controls';
|
import Controls from './components/Controls';
|
||||||
import History from './components/History';
|
import History from './components/History';
|
||||||
|
import EyeCatalog from './components/EyeCatalog';
|
||||||
import { projectsApi, editsApi } from './utils/api';
|
import { projectsApi, editsApi } from './utils/api';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
@@ -144,7 +145,7 @@ function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Handle revert
|
// Handle revert
|
||||||
const handleRevert = async (editId) => {
|
const handleRevert = useCallback(async (editId) => {
|
||||||
if (!project) return;
|
if (!project) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -162,10 +163,10 @@ function App() {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
}
|
}
|
||||||
};
|
}, [project]);
|
||||||
|
|
||||||
// Handle reset
|
// Handle reset
|
||||||
const handleReset = async () => {
|
const handleReset = useCallback(async () => {
|
||||||
if (!project) return;
|
if (!project) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -183,7 +184,7 @@ function App() {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
}
|
}
|
||||||
};
|
}, [project]);
|
||||||
|
|
||||||
// Handle download
|
// Handle download
|
||||||
const handleDownload = async () => {
|
const handleDownload = async () => {
|
||||||
@@ -225,7 +226,7 @@ function App() {
|
|||||||
await handleRevert(previousEdit.id);
|
await handleRevert(previousEdit.id);
|
||||||
setCurrentEditIndex(currentEditIndex - 1);
|
setCurrentEditIndex(currentEditIndex - 1);
|
||||||
}
|
}
|
||||||
}, [project, isProcessing, currentEditIndex]);
|
}, [project, isProcessing, currentEditIndex, handleReset, handleRevert]);
|
||||||
|
|
||||||
// Handle redo (Ctrl+Y)
|
// Handle redo (Ctrl+Y)
|
||||||
const handleRedo = useCallback(async () => {
|
const handleRedo = useCallback(async () => {
|
||||||
@@ -237,7 +238,7 @@ function App() {
|
|||||||
const nextEdit = completedEdits[currentEditIndex + 1];
|
const nextEdit = completedEdits[currentEditIndex + 1];
|
||||||
await handleRevert(nextEdit.id);
|
await handleRevert(nextEdit.id);
|
||||||
setCurrentEditIndex(currentEditIndex + 1);
|
setCurrentEditIndex(currentEditIndex + 1);
|
||||||
}, [project, isProcessing, currentEditIndex]);
|
}, [project, isProcessing, currentEditIndex, handleRevert]);
|
||||||
|
|
||||||
// Keyboard shortcut handler
|
// Keyboard shortcut handler
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -343,12 +344,23 @@ function App() {
|
|||||||
prompt={prompt}
|
prompt={prompt}
|
||||||
onPromptChange={setPrompt}
|
onPromptChange={setPrompt}
|
||||||
onFix={handleFix}
|
onFix={handleFix}
|
||||||
onClear={() => setSelection(null)}
|
|
||||||
onDownload={handleDownload}
|
onDownload={handleDownload}
|
||||||
isProcessing={isProcessing}
|
isProcessing={isProcessing}
|
||||||
hasSelection={!!selection}
|
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">
|
<div className="history-wrapper">
|
||||||
<History
|
<History
|
||||||
edits={edits}
|
edits={edits}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ const Controls = ({
|
|||||||
prompt,
|
prompt,
|
||||||
onPromptChange,
|
onPromptChange,
|
||||||
onFix,
|
onFix,
|
||||||
onClear,
|
|
||||||
onDownload,
|
onDownload,
|
||||||
isProcessing,
|
isProcessing,
|
||||||
hasSelection,
|
hasSelection,
|
||||||
@@ -107,13 +106,6 @@ const Controls = ({
|
|||||||
>
|
>
|
||||||
{isProcessing ? 'Processing...' : 'Fix Selected Area'}
|
{isProcessing ? 'Processing...' : 'Fix Selected Area'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
className="clear-btn"
|
|
||||||
onClick={onClear}
|
|
||||||
disabled={isProcessing}
|
|
||||||
>
|
|
||||||
Clear Selection
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="control-section">
|
<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
|
// Re-center and rescale the image if it exists
|
||||||
const bgImage = canvas.backgroundImage;
|
const bgImage = canvas.backgroundImage;
|
||||||
if (bgImage) {
|
if (bgImage) {
|
||||||
|
// Allow scaling up to fill the canvas
|
||||||
const scale = Math.min(
|
const scale = Math.min(
|
||||||
(width - 40) / bgImage.width,
|
(width - 40) / bgImage.width,
|
||||||
(height - 40) / bgImage.height,
|
(height - 40) / bgImage.height
|
||||||
1
|
|
||||||
);
|
);
|
||||||
bgImage.scale(scale);
|
bgImage.scale(scale);
|
||||||
bgImage.set({
|
bgImage.set({
|
||||||
@@ -290,7 +290,7 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const setupLassoMode = (canvas) => {
|
const setupLassoMode = (canvas) => {
|
||||||
let line, points = [];
|
let polygon, points = [], drawingLine;
|
||||||
|
|
||||||
canvas.on('mouse:down', (e) => {
|
canvas.on('mouse:down', (e) => {
|
||||||
// If clicking on existing selection, enable transform mode
|
// If clicking on existing selection, enable transform mode
|
||||||
@@ -314,23 +314,16 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => {
|
|||||||
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 }];
|
||||||
|
|
||||||
line = new fabric.Polyline(points, {
|
// Create a temporary line for visual feedback while drawing
|
||||||
fill: 'rgba(255, 255, 255, 0.3)',
|
drawingLine = new fabric.Polyline(points, {
|
||||||
|
fill: 'transparent',
|
||||||
stroke: '#00ff00',
|
stroke: '#00ff00',
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
selectable: true,
|
selectable: false,
|
||||||
hasControls: true,
|
evented: false,
|
||||||
hasBorders: true,
|
|
||||||
lockRotation: false,
|
|
||||||
cornerColor: '#00ff00',
|
|
||||||
cornerSize: 10,
|
|
||||||
transparentCorners: false,
|
|
||||||
borderColor: '#00ff00',
|
|
||||||
borderScaleFactor: 2,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
canvas.add(line);
|
canvas.add(drawingLine);
|
||||||
setCurrentSelection(line);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
canvas.on('mouse:move', (e) => {
|
canvas.on('mouse:move', (e) => {
|
||||||
@@ -339,16 +332,50 @@ const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => {
|
|||||||
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 });
|
||||||
|
|
||||||
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.renderAll();
|
||||||
});
|
});
|
||||||
|
|
||||||
canvas.on('mouse:up', () => {
|
canvas.on('mouse:up', () => {
|
||||||
if (isDrawing && !isTransformMode) {
|
if (isDrawing && !isTransformMode && points.length > 2) {
|
||||||
setIsDrawing(false);
|
setIsDrawing(false);
|
||||||
lassoPoints.current = points;
|
lassoPoints.current = [...points];
|
||||||
canvas.setActiveObject(line);
|
|
||||||
updateSelection(line, 'lasso');
|
// 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;
|
export default api;
|
||||||
|
|||||||
Reference in New Issue
Block a user