Merge pull request #9 from outis1one/claude/add-eye-detection-feature-69XOl
Add core editing UX improvements
This commit is contained in:
+16
-1
@@ -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
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user