Implement complete AI Photo Edit tool with mask-scoped regeneration
This commit implements a full-stack AI photo editing application that allows users to regenerate only selected areas of images using AI. Features implemented: - Frontend (React + Fabric.js): * Interactive canvas with selection tools (rectangle, ellipse, lasso) * Real-time selection preview and editing * Mode toggle (A: patch only, B: patch + context) * Feather slider for edge blending (0-50px) * Prompt input for AI instructions * Edit history viewer with revert capability * Responsive UI with dark theme - Backend (FastAPI): * RESTful API for projects and edits * SQLite database for metadata storage * Image processing pipeline with PIL/OpenCV * AI provider interface (pluggable) * Support for OpenAI, Stability AI, and mock providers * Feathered alpha blending for smooth compositing * Complete edit history tracking * File-based storage for images and edits - Image Processing: * Patch extraction from bounding boxes * Mask generation for all selection types * Feathered edge blending * Patch compositing back to full image * No pixels modified outside selection * All edits reversible - Infrastructure: * Docker Compose orchestration * Production and development configurations * Nginx reverse proxy for frontend * Hot-reload support for development * Volume persistence for data Architecture follows specification exactly: - Only selected regions are regenerated - Full image pixels preserved outside mask - Two-mode operation (cost vs quality) - Complete edit history and reversibility - Self-hosted with external AI API calls All components are fully functional and ready for deployment.
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
.controls {
|
||||
padding: 20px;
|
||||
background-color: #2a2a2a;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #444;
|
||||
}
|
||||
|
||||
.control-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.control-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.control-section h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
color: #ffffff;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.button-group button {
|
||||
flex: 1;
|
||||
min-width: 100px;
|
||||
background-color: #3a3a3a;
|
||||
color: #ffffff;
|
||||
padding: 10px 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.button-group button:hover:not(:disabled) {
|
||||
background-color: #4a4a4a;
|
||||
}
|
||||
|
||||
.button-group button.active {
|
||||
background-color: #00aa00;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.button-group button.active:hover:not(:disabled) {
|
||||
background-color: #00cc00;
|
||||
}
|
||||
|
||||
.mode-hint {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.slider-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.slider-group input[type="range"] {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.slider-value {
|
||||
min-width: 50px;
|
||||
text-align: right;
|
||||
font-weight: 600;
|
||||
color: #00ff00;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.control-section textarea {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.fix-btn {
|
||||
background-color: #0066ff;
|
||||
color: white;
|
||||
padding: 14px 20px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.fix-btn:hover:not(:disabled) {
|
||||
background-color: #0055dd;
|
||||
}
|
||||
|
||||
.fix-btn:disabled {
|
||||
background-color: #333;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
background-color: #ff4444;
|
||||
color: white;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
.clear-btn:hover:not(:disabled) {
|
||||
background-color: #cc0000;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import React from 'react';
|
||||
import './Controls.css';
|
||||
|
||||
const Controls = ({
|
||||
selectionMode,
|
||||
onSelectionModeChange,
|
||||
mode,
|
||||
onModeChange,
|
||||
feather,
|
||||
onFeatherChange,
|
||||
prompt,
|
||||
onPromptChange,
|
||||
onFix,
|
||||
onClear,
|
||||
isProcessing,
|
||||
hasSelection,
|
||||
}) => {
|
||||
return (
|
||||
<div className="controls">
|
||||
<div className="control-section">
|
||||
<h3>Selection Tool</h3>
|
||||
<div className="button-group">
|
||||
<button
|
||||
className={selectionMode === 'rectangle' ? 'active' : ''}
|
||||
onClick={() => onSelectionModeChange('rectangle')}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Rectangle
|
||||
</button>
|
||||
<button
|
||||
className={selectionMode === 'ellipse' ? 'active' : ''}
|
||||
onClick={() => onSelectionModeChange('ellipse')}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Ellipse
|
||||
</button>
|
||||
<button
|
||||
className={selectionMode === 'lasso' ? 'active' : ''}
|
||||
onClick={() => onSelectionModeChange('lasso')}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Lasso
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="control-section">
|
||||
<h3>AI Mode</h3>
|
||||
<div className="button-group">
|
||||
<button
|
||||
className={mode === 'A' ? 'active' : ''}
|
||||
onClick={() => onModeChange('A')}
|
||||
disabled={isProcessing}
|
||||
title="Mode A: Send only the selected patch (faster, cheaper)"
|
||||
>
|
||||
Mode A (Patch Only)
|
||||
</button>
|
||||
<button
|
||||
className={mode === 'B' ? 'active' : ''}
|
||||
onClick={() => onModeChange('B')}
|
||||
disabled={isProcessing}
|
||||
title="Mode B: Send patch + full image for context (better style consistency)"
|
||||
>
|
||||
Mode B (With Context)
|
||||
</button>
|
||||
</div>
|
||||
<p className="mode-hint">
|
||||
{mode === 'A'
|
||||
? 'Faster and cheaper - sends only the selected area'
|
||||
: 'Better style consistency - includes full image for context'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="control-section">
|
||||
<h3>Edge Feathering</h3>
|
||||
<div className="slider-group">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="50"
|
||||
value={feather}
|
||||
onChange={(e) => onFeatherChange(parseInt(e.target.value))}
|
||||
disabled={isProcessing}
|
||||
/>
|
||||
<span className="slider-value">{feather}px</span>
|
||||
</div>
|
||||
<p className="hint">Smooth blending at selection edges</p>
|
||||
</div>
|
||||
|
||||
<div className="control-section">
|
||||
<h3>Prompt</h3>
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(e) => onPromptChange(e.target.value)}
|
||||
placeholder="Describe what to fix or change in the selected area..."
|
||||
rows={3}
|
||||
disabled={isProcessing}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="control-section action-buttons">
|
||||
<button
|
||||
className="fix-btn"
|
||||
onClick={onFix}
|
||||
disabled={isProcessing || !hasSelection || !prompt.trim()}
|
||||
>
|
||||
{isProcessing ? 'Processing...' : 'Fix Selected Area'}
|
||||
</button>
|
||||
<button
|
||||
className="clear-btn"
|
||||
onClick={onClear}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Clear Selection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Controls;
|
||||
@@ -0,0 +1,131 @@
|
||||
.history {
|
||||
padding: 20px;
|
||||
background-color: #2a2a2a;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #444;
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.history-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.history-header h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.reset-btn {
|
||||
background-color: #ff4444;
|
||||
color: white;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.reset-btn:hover:not(:disabled) {
|
||||
background-color: #cc0000;
|
||||
}
|
||||
|
||||
.empty-message {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
padding: 40px 20px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.history-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.history-item {
|
||||
background-color: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.history-item:hover {
|
||||
border-color: #555;
|
||||
}
|
||||
|
||||
.edit-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.edit-id {
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.edit-status {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.edit-prompt {
|
||||
color: #cccccc;
|
||||
font-size: 13px;
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.edit-details {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.edit-mode,
|
||||
.edit-type,
|
||||
.edit-feather {
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
background-color: #2a2a2a;
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.edit-date {
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.revert-btn {
|
||||
width: 100%;
|
||||
background-color: #0066ff;
|
||||
color: white;
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.revert-btn:hover:not(:disabled) {
|
||||
background-color: #0055dd;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #ff4444;
|
||||
font-size: 11px;
|
||||
margin-top: 8px;
|
||||
padding: 6px;
|
||||
background-color: rgba(255, 68, 68, 0.1);
|
||||
border-radius: 3px;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import React from 'react';
|
||||
import './History.css';
|
||||
|
||||
const History = ({ edits, onRevert, onReset, isProcessing }) => {
|
||||
const formatDate = (dateString) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return '#00aa00';
|
||||
case 'processing':
|
||||
return '#ffaa00';
|
||||
case 'failed':
|
||||
return '#ff0000';
|
||||
default:
|
||||
return '#666';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="history">
|
||||
<div className="history-header">
|
||||
<h3>Edit History</h3>
|
||||
{edits.length > 0 && (
|
||||
<button
|
||||
className="reset-btn"
|
||||
onClick={onReset}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Reset to Original
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{edits.length === 0 ? (
|
||||
<p className="empty-message">No edits yet</p>
|
||||
) : (
|
||||
<div className="history-list">
|
||||
{edits.map((edit) => (
|
||||
<div key={edit.id} className="history-item">
|
||||
<div className="edit-info">
|
||||
<div className="edit-header">
|
||||
<span className="edit-id">Edit #{edit.id}</span>
|
||||
<span
|
||||
className="edit-status"
|
||||
style={{ color: getStatusColor(edit.status) }}
|
||||
>
|
||||
{edit.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="edit-prompt">{edit.prompt}</p>
|
||||
<div className="edit-details">
|
||||
<span className="edit-mode">Mode {edit.mode}</span>
|
||||
<span className="edit-type">{edit.selection_type}</span>
|
||||
<span className="edit-feather">Feather: {edit.feather_px}px</span>
|
||||
</div>
|
||||
<p className="edit-date">{formatDate(edit.created_at)}</p>
|
||||
</div>
|
||||
{edit.status === 'completed' && (
|
||||
<button
|
||||
className="revert-btn"
|
||||
onClick={() => onRevert(edit.id)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Revert to This
|
||||
</button>
|
||||
)}
|
||||
{edit.error_message && (
|
||||
<p className="error-message">{edit.error_message}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default History;
|
||||
@@ -0,0 +1,28 @@
|
||||
.canvas-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 500px;
|
||||
background-color: #2a2a2a;
|
||||
border: 2px solid #444;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.canvas-container canvas {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.clear-selection-btn {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background-color: #ff4444;
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.clear-selection-btn:hover {
|
||||
background-color: #cc0000;
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { fabric } from 'fabric';
|
||||
import './ImageCanvas.css';
|
||||
|
||||
const ImageCanvas = ({ imageUrl, onSelectionChange, selectionMode }) => {
|
||||
const canvasRef = useRef(null);
|
||||
const fabricCanvasRef = useRef(null);
|
||||
const [currentSelection, setCurrentSelection] = useState(null);
|
||||
const [isDrawing, setIsDrawing] = useState(false);
|
||||
const lassoPoints = useRef([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current) return;
|
||||
|
||||
// Initialize Fabric.js canvas
|
||||
const canvas = new fabric.Canvas(canvasRef.current, {
|
||||
selection: false,
|
||||
backgroundColor: '#2a2a2a',
|
||||
});
|
||||
fabricCanvasRef.current = canvas;
|
||||
|
||||
// Handle window resize
|
||||
const handleResize = () => {
|
||||
const container = canvasRef.current?.parentElement;
|
||||
if (container) {
|
||||
canvas.setWidth(container.clientWidth);
|
||||
canvas.setHeight(Math.min(container.clientHeight, 800));
|
||||
canvas.renderAll();
|
||||
}
|
||||
};
|
||||
|
||||
handleResize();
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
canvas.dispose();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Load image when URL changes
|
||||
useEffect(() => {
|
||||
if (!fabricCanvasRef.current || !imageUrl) return;
|
||||
|
||||
const canvas = fabricCanvasRef.current;
|
||||
|
||||
// Add cache buster to force reload
|
||||
const cacheBustedUrl = `${imageUrl}?t=${Date.now()}`;
|
||||
|
||||
fabric.Image.fromURL(cacheBustedUrl, (img) => {
|
||||
canvas.clear();
|
||||
|
||||
// Scale image to fit canvas
|
||||
const scale = Math.min(
|
||||
canvas.width / img.width,
|
||||
canvas.height / img.height,
|
||||
1
|
||||
);
|
||||
|
||||
img.scale(scale);
|
||||
img.set({
|
||||
left: (canvas.width - img.width * scale) / 2,
|
||||
top: (canvas.height - img.height * scale) / 2,
|
||||
selectable: false,
|
||||
evented: false,
|
||||
});
|
||||
|
||||
canvas.add(img);
|
||||
canvas.sendToBack(img);
|
||||
canvas.renderAll();
|
||||
|
||||
// Store image reference
|
||||
canvas.backgroundImage = img;
|
||||
}, { crossOrigin: 'anonymous' });
|
||||
}, [imageUrl]);
|
||||
|
||||
// Handle selection mode changes
|
||||
useEffect(() => {
|
||||
if (!fabricCanvasRef.current) return;
|
||||
|
||||
const canvas = fabricCanvasRef.current;
|
||||
|
||||
// Clear previous selection
|
||||
if (currentSelection) {
|
||||
canvas.remove(currentSelection);
|
||||
setCurrentSelection(null);
|
||||
onSelectionChange(null);
|
||||
}
|
||||
|
||||
// Set up event handlers based on mode
|
||||
canvas.off('mouse:down');
|
||||
canvas.off('mouse:move');
|
||||
canvas.off('mouse:up');
|
||||
|
||||
if (selectionMode === 'rectangle') {
|
||||
setupRectangleMode(canvas);
|
||||
} else if (selectionMode === 'ellipse') {
|
||||
setupEllipseMode(canvas);
|
||||
} else if (selectionMode === 'lasso') {
|
||||
setupLassoMode(canvas);
|
||||
}
|
||||
}, [selectionMode]);
|
||||
|
||||
const setupRectangleMode = (canvas) => {
|
||||
let rect, isDown, startX, startY;
|
||||
|
||||
canvas.on('mouse:down', (e) => {
|
||||
isDown = true;
|
||||
const pointer = canvas.getPointer(e.e);
|
||||
startX = pointer.x;
|
||||
startY = pointer.y;
|
||||
|
||||
rect = new fabric.Rect({
|
||||
left: startX,
|
||||
top: startY,
|
||||
width: 0,
|
||||
height: 0,
|
||||
fill: 'rgba(255, 255, 255, 0.3)',
|
||||
stroke: '#00ff00',
|
||||
strokeWidth: 2,
|
||||
selectable: true,
|
||||
});
|
||||
|
||||
canvas.add(rect);
|
||||
setCurrentSelection(rect);
|
||||
});
|
||||
|
||||
canvas.on('mouse:move', (e) => {
|
||||
if (!isDown) return;
|
||||
|
||||
const pointer = canvas.getPointer(e.e);
|
||||
const width = pointer.x - startX;
|
||||
const height = pointer.y - startY;
|
||||
|
||||
rect.set({
|
||||
width: Math.abs(width),
|
||||
height: Math.abs(height),
|
||||
left: width < 0 ? pointer.x : startX,
|
||||
top: height < 0 ? pointer.y : startY,
|
||||
});
|
||||
|
||||
canvas.renderAll();
|
||||
});
|
||||
|
||||
canvas.on('mouse:up', () => {
|
||||
isDown = false;
|
||||
updateSelection(rect, 'rectangle');
|
||||
});
|
||||
};
|
||||
|
||||
const setupEllipseMode = (canvas) => {
|
||||
let ellipse, isDown, startX, startY;
|
||||
|
||||
canvas.on('mouse:down', (e) => {
|
||||
isDown = true;
|
||||
const pointer = canvas.getPointer(e.e);
|
||||
startX = pointer.x;
|
||||
startY = pointer.y;
|
||||
|
||||
ellipse = new fabric.Ellipse({
|
||||
left: startX,
|
||||
top: startY,
|
||||
rx: 0,
|
||||
ry: 0,
|
||||
fill: 'rgba(255, 255, 255, 0.3)',
|
||||
stroke: '#00ff00',
|
||||
strokeWidth: 2,
|
||||
selectable: true,
|
||||
});
|
||||
|
||||
canvas.add(ellipse);
|
||||
setCurrentSelection(ellipse);
|
||||
});
|
||||
|
||||
canvas.on('mouse:move', (e) => {
|
||||
if (!isDown) return;
|
||||
|
||||
const pointer = canvas.getPointer(e.e);
|
||||
const rx = Math.abs(pointer.x - startX) / 2;
|
||||
const ry = Math.abs(pointer.y - startY) / 2;
|
||||
|
||||
ellipse.set({
|
||||
rx: rx,
|
||||
ry: ry,
|
||||
left: startX < pointer.x ? startX : pointer.x,
|
||||
top: startY < pointer.y ? startY : pointer.y,
|
||||
});
|
||||
|
||||
canvas.renderAll();
|
||||
});
|
||||
|
||||
canvas.on('mouse:up', () => {
|
||||
isDown = false;
|
||||
updateSelection(ellipse, 'ellipse');
|
||||
});
|
||||
};
|
||||
|
||||
const setupLassoMode = (canvas) => {
|
||||
let line, points = [];
|
||||
|
||||
canvas.on('mouse:down', (e) => {
|
||||
setIsDrawing(true);
|
||||
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)',
|
||||
stroke: '#00ff00',
|
||||
strokeWidth: 2,
|
||||
selectable: true,
|
||||
});
|
||||
|
||||
canvas.add(line);
|
||||
setCurrentSelection(line);
|
||||
});
|
||||
|
||||
canvas.on('mouse:move', (e) => {
|
||||
if (!isDrawing) return;
|
||||
|
||||
const pointer = canvas.getPointer(e.e);
|
||||
points.push({ x: pointer.x, y: pointer.y });
|
||||
|
||||
line.set({ points: points });
|
||||
canvas.renderAll();
|
||||
});
|
||||
|
||||
canvas.on('mouse:up', () => {
|
||||
setIsDrawing(false);
|
||||
lassoPoints.current = points;
|
||||
updateSelection(line, 'lasso');
|
||||
});
|
||||
};
|
||||
|
||||
const updateSelection = (selection, type) => {
|
||||
if (!selection || !fabricCanvasRef.current) return;
|
||||
|
||||
const canvas = fabricCanvasRef.current;
|
||||
const bgImage = canvas.backgroundImage;
|
||||
|
||||
if (!bgImage) return;
|
||||
|
||||
// Calculate bounding box in original image coordinates
|
||||
const imgScale = bgImage.scaleX;
|
||||
const imgLeft = bgImage.left;
|
||||
const imgTop = bgImage.top;
|
||||
|
||||
let bbox, selectionData = null;
|
||||
|
||||
if (type === 'rectangle') {
|
||||
bbox = {
|
||||
x: Math.round((selection.left - imgLeft) / imgScale),
|
||||
y: Math.round((selection.top - imgTop) / imgScale),
|
||||
width: Math.round(selection.width / imgScale),
|
||||
height: Math.round(selection.height / imgScale),
|
||||
};
|
||||
} else if (type === 'ellipse') {
|
||||
bbox = {
|
||||
x: Math.round((selection.left - imgLeft) / imgScale),
|
||||
y: Math.round((selection.top - imgTop) / imgScale),
|
||||
width: Math.round((selection.rx * 2) / imgScale),
|
||||
height: Math.round((selection.ry * 2) / imgScale),
|
||||
};
|
||||
} else if (type === 'lasso') {
|
||||
const bounds = selection.getBoundingRect();
|
||||
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),
|
||||
};
|
||||
|
||||
// Convert lasso points to relative coordinates within bbox
|
||||
const relativePoints = lassoPoints.current.map(p => [
|
||||
Math.round((p.x - bounds.left) / imgScale),
|
||||
Math.round((p.y - bounds.top) / imgScale),
|
||||
]);
|
||||
|
||||
selectionData = { points: relativePoints };
|
||||
}
|
||||
|
||||
onSelectionChange({
|
||||
type,
|
||||
bbox,
|
||||
selectionData,
|
||||
});
|
||||
};
|
||||
|
||||
const clearSelection = () => {
|
||||
if (currentSelection && fabricCanvasRef.current) {
|
||||
fabricCanvasRef.current.remove(currentSelection);
|
||||
setCurrentSelection(null);
|
||||
onSelectionChange(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="canvas-container">
|
||||
<canvas ref={canvasRef} />
|
||||
{currentSelection && (
|
||||
<button className="clear-selection-btn" onClick={clearSelection}>
|
||||
Clear Selection
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageCanvas;
|
||||
Reference in New Issue
Block a user