Add miniPaint as new frontend base
This commit is contained in:
@@ -1,107 +0,0 @@
|
||||
.advanced-tools {
|
||||
background-color: #2a2a2a;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #444;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.advanced-tools h3 {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 14px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.tool-group {
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
.tool-group:last-of-type {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.tool-group h4 {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
margin: 0 0 8px 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.tool-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tool-btn {
|
||||
flex: 1;
|
||||
min-width: 100px;
|
||||
background-color: #3a3a3a;
|
||||
color: #ffffff;
|
||||
padding: 10px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border: 1px solid #555;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tool-btn:hover:not(:disabled) {
|
||||
background-color: #4a4a4a;
|
||||
border-color: #666;
|
||||
}
|
||||
|
||||
.tool-btn.active {
|
||||
background-color: #0066ff;
|
||||
border-color: #0066ff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.tool-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.color-tolerance {
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
background-color: #333;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.color-tolerance label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.color-tolerance input[type="range"] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tool-hint {
|
||||
font-size: 11px;
|
||||
color: #00ff00;
|
||||
margin-top: 8px;
|
||||
padding: 8px;
|
||||
background-color: rgba(0, 255, 0, 0.1);
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cancel-mode-btn {
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
background-color: #ff4444;
|
||||
color: white;
|
||||
padding: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.cancel-mode-btn:hover {
|
||||
background-color: #cc0000;
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { toolsApi } from '../utils/api';
|
||||
import './AdvancedTools.css';
|
||||
|
||||
const AdvancedTools = ({
|
||||
projectId,
|
||||
selection,
|
||||
onLayerCreated,
|
||||
onMaskGenerated,
|
||||
onImageUpdate,
|
||||
isProcessing,
|
||||
setIsProcessing,
|
||||
setError,
|
||||
activeToolMode,
|
||||
setActiveToolMode,
|
||||
}) => {
|
||||
const [colorTolerance, setColorTolerance] = useState(30);
|
||||
|
||||
const handleRemoveBackground = async () => {
|
||||
if (!projectId) return;
|
||||
|
||||
try {
|
||||
setIsProcessing(true);
|
||||
setError(null);
|
||||
const result = await toolsApi.removeBackgroundToLayer(projectId);
|
||||
onLayerCreated(result.layer);
|
||||
} catch (err) {
|
||||
setError(`Background removal failed: ${err.message}`);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSmartSelect = () => {
|
||||
setActiveToolMode(activeToolMode === 'smart-select' ? null : 'smart-select');
|
||||
};
|
||||
|
||||
const handleColorSelect = () => {
|
||||
setActiveToolMode(activeToolMode === 'color-select' ? null : 'color-select');
|
||||
};
|
||||
|
||||
const handleObjectRemove = () => {
|
||||
setActiveToolMode(activeToolMode === 'object-remove' ? null : 'object-remove');
|
||||
};
|
||||
|
||||
// Called when user clicks on canvas in smart-select mode
|
||||
const onCanvasClick = async (x, y) => {
|
||||
if (!projectId || !activeToolMode) return;
|
||||
|
||||
if (activeToolMode === 'smart-select') {
|
||||
try {
|
||||
setIsProcessing(true);
|
||||
const maskBlob = await toolsApi.smartSelect(projectId, x, y);
|
||||
onMaskGenerated(maskBlob, 'smart-select');
|
||||
} catch (err) {
|
||||
setError(`Smart select failed: ${err.message}`);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Called when user picks a color for color selection
|
||||
const onColorPicked = async (r, g, b) => {
|
||||
if (!projectId) return;
|
||||
|
||||
try {
|
||||
setIsProcessing(true);
|
||||
const maskBlob = await toolsApi.colorSelect(projectId, r, g, b, colorTolerance);
|
||||
onMaskGenerated(maskBlob, 'color-select');
|
||||
} catch (err) {
|
||||
setError(`Color select failed: ${err.message}`);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExtractObject = async () => {
|
||||
if (!projectId || !selection) {
|
||||
setError('Make a selection first');
|
||||
return;
|
||||
}
|
||||
|
||||
// For this we need the mask from the current selection
|
||||
// This would be generated from the selection shape
|
||||
setError('Extract requires a mask - use Smart Select or Color Select first');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="advanced-tools">
|
||||
<h3>Advanced Tools</h3>
|
||||
|
||||
<div className="tool-group">
|
||||
<h4>Background</h4>
|
||||
<button
|
||||
className="tool-btn"
|
||||
onClick={handleRemoveBackground}
|
||||
disabled={isProcessing || !projectId}
|
||||
title="Remove background and create new layer"
|
||||
>
|
||||
Remove Background
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="tool-group">
|
||||
<h4>Selection Tools</h4>
|
||||
<div className="tool-buttons">
|
||||
<button
|
||||
className={`tool-btn ${activeToolMode === 'smart-select' ? 'active' : ''}`}
|
||||
onClick={handleSmartSelect}
|
||||
disabled={isProcessing || !projectId}
|
||||
title="Click on object to select it"
|
||||
>
|
||||
Smart Select
|
||||
</button>
|
||||
<button
|
||||
className={`tool-btn ${activeToolMode === 'color-select' ? 'active' : ''}`}
|
||||
onClick={handleColorSelect}
|
||||
disabled={isProcessing || !projectId}
|
||||
title="Select all similar colors"
|
||||
>
|
||||
Color Select
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeToolMode === 'color-select' && (
|
||||
<div className="color-tolerance">
|
||||
<label>Tolerance: {colorTolerance}</label>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="100"
|
||||
value={colorTolerance}
|
||||
onChange={(e) => setColorTolerance(parseInt(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeToolMode && (
|
||||
<p className="tool-hint">
|
||||
{activeToolMode === 'smart-select'
|
||||
? 'Click on an object to select it'
|
||||
: 'Click on a color to select all similar pixels'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="tool-group">
|
||||
<h4>Object Tools</h4>
|
||||
<button
|
||||
className={`tool-btn ${activeToolMode === 'object-remove' ? 'active' : ''}`}
|
||||
onClick={handleObjectRemove}
|
||||
disabled={isProcessing || !projectId}
|
||||
title="Remove selected object (uses AI)"
|
||||
>
|
||||
Object Remove
|
||||
</button>
|
||||
<button
|
||||
className="tool-btn"
|
||||
onClick={handleExtractObject}
|
||||
disabled={isProcessing || !projectId || !selection}
|
||||
title="Extract selected area to new layer"
|
||||
>
|
||||
Extract to Layer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeToolMode && (
|
||||
<button
|
||||
className="cancel-mode-btn"
|
||||
onClick={() => setActiveToolMode(null)}
|
||||
>
|
||||
Cancel Tool
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Export the click handler for parent component to use
|
||||
AdvancedTools.handleCanvasClick = null;
|
||||
|
||||
export default AdvancedTools;
|
||||
@@ -1,134 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
import React from 'react';
|
||||
import './Controls.css';
|
||||
|
||||
const Controls = ({
|
||||
selectionMode,
|
||||
onSelectionModeChange,
|
||||
mode,
|
||||
onModeChange,
|
||||
feather,
|
||||
onFeatherChange,
|
||||
prompt,
|
||||
onPromptChange,
|
||||
onFix,
|
||||
onDownload,
|
||||
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>
|
||||
</div>
|
||||
|
||||
<div className="control-section">
|
||||
<button
|
||||
className="download-btn"
|
||||
onClick={onDownload}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Download Image
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Controls;
|
||||
@@ -1,209 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
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;
|
||||
@@ -1,131 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
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;
|
||||
@@ -1,111 +0,0 @@
|
||||
/* ImageCanvas fills the entire canvas wrapper */
|
||||
.canvas-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.canvas-container canvas {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Selection hint */
|
||||
.selection-hint {
|
||||
position: absolute;
|
||||
bottom: 40px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
color: #0088ff;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
z-index: 10;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Clear selection button */
|
||||
.clear-selection-btn {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
background-color: #cc3333;
|
||||
color: white;
|
||||
padding: 6px 12px;
|
||||
font-size: 11px;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.clear-selection-btn:hover {
|
||||
background-color: #dd4444;
|
||||
}
|
||||
|
||||
/* Zoom controls */
|
||||
.zoom-controls {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
right: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.zoom-controls button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
background-color: #444;
|
||||
color: white;
|
||||
border: 1px solid #666;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.zoom-controls button:hover {
|
||||
background-color: #555;
|
||||
}
|
||||
|
||||
.zoom-level {
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
min-width: 45px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Tool mode indicator */
|
||||
.tool-mode-indicator {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background-color: rgba(0, 120, 255, 0.9);
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
z-index: 10;
|
||||
white-space: nowrap;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
@@ -1,704 +0,0 @@
|
||||
import React, { useEffect, useRef, useState, useCallback, forwardRef, useImperativeHandle } from 'react';
|
||||
import { fabric } from 'fabric';
|
||||
import './ImageCanvas.css';
|
||||
|
||||
const ImageCanvas = forwardRef(({
|
||||
imageUrl,
|
||||
onSelectionChange,
|
||||
selectionMode,
|
||||
activeTool,
|
||||
zoom = 100,
|
||||
onSmartSelect,
|
||||
isProcessing
|
||||
}, ref) => {
|
||||
const canvasRef = useRef(null);
|
||||
const fabricCanvasRef = useRef(null);
|
||||
const [currentSelection, setCurrentSelection] = useState(null);
|
||||
const currentSelectionRef = useRef(null);
|
||||
const lassoPoints = useRef([]);
|
||||
const onZoomChangeRef = useRef(onZoomChange);
|
||||
|
||||
// Keep ref updated
|
||||
useEffect(() => {
|
||||
onZoomChangeRef.current = onZoomChange;
|
||||
}, [onZoomChange]);
|
||||
|
||||
// Expose methods to parent
|
||||
useImperativeHandle(ref, () => ({
|
||||
getCanvas: () => fabricCanvasRef.current,
|
||||
clearSelection: () => clearSelection(),
|
||||
}));
|
||||
|
||||
// Update selection ref when state changes
|
||||
useEffect(() => {
|
||||
currentSelectionRef.current = currentSelection;
|
||||
}, [currentSelection]);
|
||||
|
||||
// Initialize canvas
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current) return;
|
||||
|
||||
const canvas = new fabric.Canvas(canvasRef.current, {
|
||||
selection: false,
|
||||
backgroundColor: 'transparent',
|
||||
preserveObjectStacking: true,
|
||||
});
|
||||
fabricCanvasRef.current = canvas;
|
||||
|
||||
const handleResize = () => {
|
||||
const container = canvasRef.current?.parentElement;
|
||||
if (container) {
|
||||
const width = container.clientWidth;
|
||||
const height = container.clientHeight;
|
||||
canvas.setWidth(width);
|
||||
canvas.setHeight(height);
|
||||
|
||||
// Re-center image if it exists
|
||||
if (imageRef.current) {
|
||||
centerImage(canvas, imageRef.current, zoom / 100);
|
||||
}
|
||||
canvas.renderAll();
|
||||
}
|
||||
};
|
||||
|
||||
// Initial resize - use requestAnimationFrame to ensure DOM is ready
|
||||
requestAnimationFrame(() => {
|
||||
handleResize();
|
||||
});
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
// Mouse wheel zoom
|
||||
const handleWheel = (opt) => {
|
||||
const e = opt.e;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const delta = e.deltaY;
|
||||
let newZoom = canvas.getZoom();
|
||||
newZoom *= 0.999 ** delta;
|
||||
|
||||
// Clamp zoom between 0.1x and 10x
|
||||
if (newZoom > 10) newZoom = 10;
|
||||
if (newZoom < 0.1) newZoom = 0.1;
|
||||
|
||||
// Zoom to point under cursor
|
||||
const pointer = canvas.getPointer(e, true);
|
||||
canvas.zoomToPoint({ x: pointer.x, y: pointer.y }, newZoom);
|
||||
|
||||
setCurrentZoom(newZoom);
|
||||
if (onZoomChangeRef.current) {
|
||||
onZoomChangeRef.current(newZoom);
|
||||
}
|
||||
};
|
||||
|
||||
canvas.on('mouse:wheel', handleWheel);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
canvas.off('mouse:wheel', handleWheel);
|
||||
canvas.dispose();
|
||||
};
|
||||
}, []); // Empty dependency array - only run once on mount
|
||||
|
||||
// Center and scale image
|
||||
const centerImage = (canvas, img, zoomFactor) => {
|
||||
if (!img) return;
|
||||
|
||||
const padding = 40;
|
||||
const availableWidth = canvas.width - padding;
|
||||
const availableHeight = canvas.height - padding;
|
||||
|
||||
// Calculate base scale to fit
|
||||
const fitScale = Math.min(
|
||||
availableWidth / img.width,
|
||||
availableHeight / img.height
|
||||
);
|
||||
|
||||
baseScaleRef.current = fitScale;
|
||||
const scale = fitScale * zoomFactor;
|
||||
|
||||
img.scale(scale);
|
||||
img.set({
|
||||
left: (canvas.width - img.width * scale) / 2,
|
||||
top: (canvas.height - img.height * scale) / 2,
|
||||
});
|
||||
};
|
||||
|
||||
// Apply zoom changes
|
||||
useEffect(() => {
|
||||
const canvas = fabricCanvasRef.current;
|
||||
if (!canvas || !imageRef.current) return;
|
||||
|
||||
centerImage(canvas, imageRef.current, zoom / 100);
|
||||
canvas.renderAll();
|
||||
}, [zoom]);
|
||||
|
||||
// Load image when URL changes
|
||||
useEffect(() => {
|
||||
if (!fabricCanvasRef.current || !imageUrl) return;
|
||||
|
||||
const canvas = fabricCanvasRef.current;
|
||||
|
||||
// Ensure canvas has dimensions before loading image
|
||||
if (canvas.width === 0 || canvas.height === 0) {
|
||||
const container = canvasRef.current?.parentElement;
|
||||
if (container) {
|
||||
canvas.setWidth(container.clientWidth || 800);
|
||||
canvas.setHeight(container.clientHeight || 600);
|
||||
}
|
||||
}
|
||||
|
||||
// Add cache buster to force reload
|
||||
const cacheBustedUrl = `${imageUrl}?t=${Date.now()}`;
|
||||
|
||||
fabric.Image.fromURL(cacheBustedUrl, (img) => {
|
||||
if (!img) {
|
||||
console.error('Failed to load image from URL:', cacheBustedUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
canvas.clear();
|
||||
|
||||
// Scale image to fit canvas with padding
|
||||
const padding = 40;
|
||||
const availableWidth = (canvas.width || 800) - padding;
|
||||
const availableHeight = (canvas.height || 600) - padding;
|
||||
const scale = Math.min(
|
||||
availableWidth / img.width,
|
||||
availableHeight / img.height
|
||||
);
|
||||
|
||||
img.scale(scale);
|
||||
img.set({
|
||||
left: ((canvas.width || 800) - img.width * scale) / 2,
|
||||
top: ((canvas.height || 600) - img.height * scale) / 2,
|
||||
selectable: false,
|
||||
evented: false,
|
||||
hoverCursor: 'default',
|
||||
});
|
||||
|
||||
imageRef.current = img;
|
||||
canvas.add(img);
|
||||
canvas.sendToBack(img);
|
||||
|
||||
centerImage(canvas, img, zoom / 100);
|
||||
canvas.renderAll();
|
||||
}, { crossOrigin: 'anonymous' });
|
||||
}, [imageUrl]);
|
||||
|
||||
// Handle tool/mode changes
|
||||
useEffect(() => {
|
||||
if (!fabricCanvasRef.current) return;
|
||||
|
||||
const canvas = fabricCanvasRef.current;
|
||||
|
||||
// Remove all event handlers
|
||||
canvas.off('mouse:down');
|
||||
canvas.off('mouse:move');
|
||||
canvas.off('mouse:up');
|
||||
canvas.off('object:modified');
|
||||
canvas.off('object:moving');
|
||||
canvas.off('object:scaling');
|
||||
|
||||
// Set up handlers based on selection mode
|
||||
if (selectionMode === 'rectangle') {
|
||||
setupRectangleMode(canvas);
|
||||
} else if (selectionMode === 'ellipse') {
|
||||
setupEllipseMode(canvas);
|
||||
} else if (selectionMode === 'lasso') {
|
||||
setupLassoMode(canvas);
|
||||
} else if (selectionMode === 'smart') {
|
||||
setupSmartSelectMode(canvas);
|
||||
} else if (selectionMode === 'color') {
|
||||
setupColorSelectMode(canvas);
|
||||
} else if (activeTool === 'move') {
|
||||
setupMoveMode(canvas);
|
||||
} else if (activeTool === 'pan') {
|
||||
setupPanMode(canvas);
|
||||
}
|
||||
}, [selectionMode, activeTool, onSmartSelect]);
|
||||
|
||||
const setupMoveMode = (canvas) => {
|
||||
// In move mode, allow selecting and moving selection objects
|
||||
const sel = currentSelectionRef.current;
|
||||
if (sel) {
|
||||
sel.set({ selectable: true, evented: true });
|
||||
canvas.setActiveObject(sel);
|
||||
}
|
||||
|
||||
canvas.on('object:modified', (e) => {
|
||||
if (e.target && e.target === currentSelectionRef.current) {
|
||||
updateTransformedSelection(e.target);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const setupPanMode = (canvas) => {
|
||||
let isPanning = false;
|
||||
let lastPosX, lastPosY;
|
||||
|
||||
canvas.on('mouse:down', (e) => {
|
||||
isPanning = true;
|
||||
lastPosX = e.e.clientX;
|
||||
lastPosY = e.e.clientY;
|
||||
canvas.setCursor('grabbing');
|
||||
});
|
||||
|
||||
canvas.on('mouse:move', (e) => {
|
||||
if (!isPanning) return;
|
||||
|
||||
const deltaX = e.e.clientX - lastPosX;
|
||||
const deltaY = e.e.clientY - lastPosY;
|
||||
|
||||
canvas.relativePan({ x: deltaX, y: deltaY });
|
||||
|
||||
lastPosX = e.e.clientX;
|
||||
lastPosY = e.e.clientY;
|
||||
});
|
||||
|
||||
canvas.on('mouse:up', () => {
|
||||
isPanning = false;
|
||||
canvas.setCursor('grab');
|
||||
});
|
||||
|
||||
canvas.setCursor('grab');
|
||||
};
|
||||
|
||||
const setupSmartSelectMode = (canvas) => {
|
||||
canvas.on('mouse:down', (e) => {
|
||||
if (isProcessing) return;
|
||||
|
||||
const pointer = canvas.getPointer(e.e);
|
||||
const img = imageRef.current;
|
||||
|
||||
if (!img) return;
|
||||
|
||||
// Convert to image coordinates
|
||||
const imgScale = img.scaleX;
|
||||
const imgLeft = img.left;
|
||||
const imgTop = img.top;
|
||||
|
||||
const x = Math.round((pointer.x - imgLeft) / imgScale);
|
||||
const y = Math.round((pointer.y - imgTop) / imgScale);
|
||||
|
||||
// Check if click is within image bounds
|
||||
if (x >= 0 && x < img.width && y >= 0 && y < img.height) {
|
||||
onSmartSelect?.(x, y);
|
||||
}
|
||||
});
|
||||
|
||||
canvas.setCursor('crosshair');
|
||||
};
|
||||
|
||||
const setupColorSelectMode = (canvas) => {
|
||||
canvas.on('mouse:down', (e) => {
|
||||
if (isProcessing) return;
|
||||
|
||||
// TODO: Get pixel color at click position
|
||||
const pointer = canvas.getPointer(e.e);
|
||||
console.log('Color select at:', pointer);
|
||||
});
|
||||
|
||||
canvas.setCursor('crosshair');
|
||||
};
|
||||
|
||||
const setupRectangleMode = (canvas) => {
|
||||
let rect = null;
|
||||
let isDown = false;
|
||||
let startX, startY;
|
||||
|
||||
canvas.on('mouse:down', (e) => {
|
||||
// Check if clicking on existing selection
|
||||
const sel = currentSelectionRef.current;
|
||||
if (e.target && e.target === sel) {
|
||||
// Allow moving/transforming
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear previous selection
|
||||
if (sel) {
|
||||
canvas.remove(sel);
|
||||
setCurrentSelection(null);
|
||||
}
|
||||
|
||||
isDown = true;
|
||||
isDrawingRef.current = 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(0, 136, 255, 0.2)',
|
||||
stroke: '#0088ff',
|
||||
strokeWidth: 2,
|
||||
strokeDashArray: [5, 5],
|
||||
selectable: true,
|
||||
hasControls: true,
|
||||
hasBorders: true,
|
||||
cornerColor: '#0088ff',
|
||||
cornerSize: 8,
|
||||
transparentCorners: false,
|
||||
borderColor: '#0088ff',
|
||||
});
|
||||
|
||||
canvas.add(rect);
|
||||
});
|
||||
|
||||
canvas.on('mouse:move', (e) => {
|
||||
if (!isDown || !rect) 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', () => {
|
||||
if (isDown && rect && rect.width > 5 && rect.height > 5) {
|
||||
isDown = false;
|
||||
isDrawingRef.current = false;
|
||||
setCurrentSelection(rect);
|
||||
canvas.setActiveObject(rect);
|
||||
updateSelection(rect, 'rectangle');
|
||||
} else if (isDown && rect) {
|
||||
// Selection too small, remove it
|
||||
canvas.remove(rect);
|
||||
isDown = false;
|
||||
isDrawingRef.current = false;
|
||||
}
|
||||
});
|
||||
|
||||
canvas.on('object:modified', (e) => {
|
||||
if (e.target === currentSelectionRef.current) {
|
||||
updateTransformedSelection(e.target);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const setupEllipseMode = (canvas) => {
|
||||
let ellipse = null;
|
||||
let isDown = false;
|
||||
let startX, startY;
|
||||
|
||||
canvas.on('mouse:down', (e) => {
|
||||
const sel = currentSelectionRef.current;
|
||||
if (e.target && e.target === sel) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sel) {
|
||||
canvas.remove(sel);
|
||||
setCurrentSelection(null);
|
||||
}
|
||||
|
||||
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(0, 136, 255, 0.2)',
|
||||
stroke: '#0088ff',
|
||||
strokeWidth: 2,
|
||||
strokeDashArray: [5, 5],
|
||||
selectable: true,
|
||||
hasControls: true,
|
||||
hasBorders: true,
|
||||
cornerColor: '#0088ff',
|
||||
cornerSize: 8,
|
||||
transparentCorners: false,
|
||||
borderColor: '#0088ff',
|
||||
});
|
||||
|
||||
canvas.add(ellipse);
|
||||
});
|
||||
|
||||
canvas.on('mouse:move', (e) => {
|
||||
if (!isDown || !ellipse) 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: Math.min(startX, pointer.x),
|
||||
top: Math.min(startY, pointer.y),
|
||||
});
|
||||
|
||||
canvas.renderAll();
|
||||
});
|
||||
|
||||
canvas.on('mouse:up', () => {
|
||||
if (isDown && ellipse && ellipse.rx > 5 && ellipse.ry > 5) {
|
||||
isDown = false;
|
||||
setCurrentSelection(ellipse);
|
||||
canvas.setActiveObject(ellipse);
|
||||
updateSelection(ellipse, 'ellipse');
|
||||
} else if (isDown && ellipse) {
|
||||
canvas.remove(ellipse);
|
||||
isDown = false;
|
||||
}
|
||||
});
|
||||
|
||||
canvas.on('object:modified', (e) => {
|
||||
if (e.target === currentSelectionRef.current) {
|
||||
updateTransformedSelection(e.target);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const setupLassoMode = (canvas) => {
|
||||
let points = [];
|
||||
let drawingLine = null;
|
||||
let polygon = null;
|
||||
|
||||
canvas.on('mouse:down', (e) => {
|
||||
const sel = currentSelectionRef.current;
|
||||
if (e.target && e.target === sel) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sel) {
|
||||
canvas.remove(sel);
|
||||
setCurrentSelection(null);
|
||||
}
|
||||
|
||||
isDrawingRef.current = true;
|
||||
const pointer = canvas.getPointer(e.e);
|
||||
points = [{ x: pointer.x, y: pointer.y }];
|
||||
|
||||
drawingLine = new fabric.Polyline(points, {
|
||||
fill: 'transparent',
|
||||
stroke: '#0088ff',
|
||||
strokeWidth: 2,
|
||||
selectable: false,
|
||||
evented: false,
|
||||
});
|
||||
|
||||
canvas.add(drawingLine);
|
||||
});
|
||||
|
||||
canvas.on('mouse:move', (e) => {
|
||||
if (!isDrawingRef.current) return;
|
||||
|
||||
const pointer = canvas.getPointer(e.e);
|
||||
points.push({ x: pointer.x, y: pointer.y });
|
||||
|
||||
canvas.remove(drawingLine);
|
||||
drawingLine = new fabric.Polyline([...points], {
|
||||
fill: 'transparent',
|
||||
stroke: '#0088ff',
|
||||
strokeWidth: 2,
|
||||
selectable: false,
|
||||
evented: false,
|
||||
});
|
||||
canvas.add(drawingLine);
|
||||
canvas.renderAll();
|
||||
});
|
||||
|
||||
canvas.on('mouse:up', () => {
|
||||
if (isDrawingRef.current && points.length > 5) {
|
||||
isDrawingRef.current = false;
|
||||
lassoPoints.current = [...points];
|
||||
|
||||
canvas.remove(drawingLine);
|
||||
|
||||
polygon = new fabric.Polygon(points, {
|
||||
fill: 'rgba(0, 136, 255, 0.2)',
|
||||
stroke: '#0088ff',
|
||||
strokeWidth: 2,
|
||||
strokeDashArray: [5, 5],
|
||||
selectable: true,
|
||||
hasControls: true,
|
||||
hasBorders: true,
|
||||
cornerColor: '#0088ff',
|
||||
cornerSize: 8,
|
||||
transparentCorners: false,
|
||||
borderColor: '#0088ff',
|
||||
});
|
||||
|
||||
canvas.add(polygon);
|
||||
canvas.setActiveObject(polygon);
|
||||
setCurrentSelection(polygon);
|
||||
updateSelection(polygon, 'lasso');
|
||||
} else if (isDrawingRef.current) {
|
||||
isDrawingRef.current = false;
|
||||
canvas.remove(drawingLine);
|
||||
}
|
||||
});
|
||||
|
||||
canvas.on('object:modified', (e) => {
|
||||
if (e.target === currentSelectionRef.current) {
|
||||
updateTransformedSelection(e.target);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const updateSelection = (selection, type) => {
|
||||
if (!selection || !imageRef.current) return;
|
||||
|
||||
const img = imageRef.current;
|
||||
const imgScale = img.scaleX;
|
||||
const imgLeft = img.left;
|
||||
const imgTop = img.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),
|
||||
};
|
||||
|
||||
const relativePoints = lassoPoints.current.map(p => [
|
||||
Math.round((p.x - imgLeft) / imgScale) - bbox.x,
|
||||
Math.round((p.y - imgTop) / imgScale) - bbox.y,
|
||||
]);
|
||||
|
||||
selectionData = { points: relativePoints };
|
||||
}
|
||||
|
||||
onSelectionChange?.({
|
||||
type,
|
||||
bbox,
|
||||
selectionData,
|
||||
});
|
||||
};
|
||||
|
||||
const updateTransformedSelection = (selection) => {
|
||||
if (!selection || !imageRef.current) return;
|
||||
|
||||
const img = imageRef.current;
|
||||
const imgScale = img.scaleX;
|
||||
const imgLeft = img.left;
|
||||
const imgTop = img.top;
|
||||
|
||||
const bounds = selection.getBoundingRect(true);
|
||||
|
||||
const 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;
|
||||
const type = selection.type === 'polygon' ? 'lasso' : (selection.type === 'ellipse' ? 'ellipse' : 'rectangle');
|
||||
|
||||
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 - imgLeft) / imgScale) - bbox.x,
|
||||
Math.round((transformed.y - imgTop) / imgScale) - bbox.y,
|
||||
];
|
||||
});
|
||||
selectionData = { points: transformedPoints };
|
||||
}
|
||||
|
||||
onSelectionChange?.({
|
||||
type,
|
||||
bbox,
|
||||
selectionData,
|
||||
});
|
||||
};
|
||||
|
||||
const clearSelection = () => {
|
||||
const canvas = fabricCanvasRef.current;
|
||||
const sel = currentSelectionRef.current;
|
||||
if (sel && canvas) {
|
||||
canvas.remove(sel);
|
||||
setCurrentSelection(null);
|
||||
onSelectionChange?.(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleZoomIn = () => {
|
||||
if (!fabricCanvasRef.current) return;
|
||||
const canvas = fabricCanvasRef.current;
|
||||
let newZoom = canvas.getZoom() * 1.2;
|
||||
if (newZoom > 10) newZoom = 10;
|
||||
canvas.setZoom(newZoom);
|
||||
setCurrentZoom(newZoom);
|
||||
if (onZoomChangeRef.current) {
|
||||
onZoomChangeRef.current(newZoom);
|
||||
}
|
||||
};
|
||||
|
||||
const handleZoomOut = () => {
|
||||
if (!fabricCanvasRef.current) return;
|
||||
const canvas = fabricCanvasRef.current;
|
||||
let newZoom = canvas.getZoom() / 1.2;
|
||||
if (newZoom < 0.1) newZoom = 0.1;
|
||||
canvas.setZoom(newZoom);
|
||||
setCurrentZoom(newZoom);
|
||||
if (onZoomChangeRef.current) {
|
||||
onZoomChangeRef.current(newZoom);
|
||||
}
|
||||
};
|
||||
|
||||
const handleZoomReset = () => {
|
||||
if (!fabricCanvasRef.current) return;
|
||||
const canvas = fabricCanvasRef.current;
|
||||
canvas.setZoom(1);
|
||||
canvas.setViewportTransform([1, 0, 0, 1, 0, 0]);
|
||||
setCurrentZoom(1);
|
||||
if (onZoomChangeRef.current) {
|
||||
onZoomChangeRef.current(1);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="canvas-container">
|
||||
<canvas ref={canvasRef} />
|
||||
{currentSelection && (
|
||||
<button className="clear-selection-btn" onClick={clearSelection}>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
ImageCanvas.displayName = 'ImageCanvas';
|
||||
|
||||
export default ImageCanvas;
|
||||
@@ -1,152 +0,0 @@
|
||||
.layers-panel {
|
||||
background-color: #2a2a2a;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #444;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.layers-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.layers-header h3 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.flatten-btn {
|
||||
background-color: #555;
|
||||
color: white;
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.flatten-btn:hover:not(:disabled) {
|
||||
background-color: #666;
|
||||
}
|
||||
|
||||
.layers-list {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #333;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.layer-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
background-color: #333;
|
||||
border-bottom: 1px solid #444;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.layer-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.layer-item:hover {
|
||||
background-color: #3a3a3a;
|
||||
}
|
||||
|
||||
.layer-item.active {
|
||||
background-color: #0066ff33;
|
||||
border-left: 3px solid #0066ff;
|
||||
}
|
||||
|
||||
.layer-item.dragging {
|
||||
opacity: 0.5;
|
||||
background-color: #444;
|
||||
}
|
||||
|
||||
.layer-visibility {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.layer-visibility input[type="checkbox"] {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.layer-preview {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background-color: #222;
|
||||
border: 1px solid #555;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.layer-preview img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.layer-preview.background-preview {
|
||||
background: repeating-conic-gradient(#444 0% 25%, #333 0% 50%) 50% / 8px 8px;
|
||||
}
|
||||
|
||||
.layer-name {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.layer-lock {
|
||||
font-size: 10px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.layer-drag-handle {
|
||||
cursor: grab;
|
||||
opacity: 0.5;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.layer-drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.no-layers-hint {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.layers-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.layer-action-btn {
|
||||
flex: 1;
|
||||
background-color: #3a3a3a;
|
||||
color: #ccc;
|
||||
padding: 6px 8px;
|
||||
font-size: 10px;
|
||||
border: 1px solid #555;
|
||||
}
|
||||
|
||||
.layer-action-btn:hover:not(:disabled) {
|
||||
background-color: #4a4a4a;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.layer-action-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { toolsApi } from '../utils/api';
|
||||
import './Layers.css';
|
||||
|
||||
const Layers = ({
|
||||
projectId,
|
||||
layers,
|
||||
setLayers,
|
||||
activeLayer,
|
||||
setActiveLayer,
|
||||
onLayerVisibilityChange,
|
||||
onFlatten,
|
||||
isProcessing,
|
||||
onError,
|
||||
}) => {
|
||||
const [draggedLayer, setDraggedLayer] = useState(null);
|
||||
|
||||
// Load layers on mount and when projectId changes
|
||||
useEffect(() => {
|
||||
if (projectId) {
|
||||
loadLayers();
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
const loadLayers = async () => {
|
||||
if (!projectId) return;
|
||||
try {
|
||||
const result = await toolsApi.listLayers(projectId);
|
||||
setLayers(result.layers || []);
|
||||
} catch (err) {
|
||||
console.error('Failed to load layers:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragStart = (e, index) => {
|
||||
setDraggedLayer(index);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
};
|
||||
|
||||
const handleDragOver = (e, index) => {
|
||||
e.preventDefault();
|
||||
if (draggedLayer === null || draggedLayer === index) return;
|
||||
|
||||
// Reorder layers
|
||||
const newLayers = [...layers];
|
||||
const [removed] = newLayers.splice(draggedLayer, 1);
|
||||
newLayers.splice(index, 0, removed);
|
||||
setLayers(newLayers);
|
||||
setDraggedLayer(index);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setDraggedLayer(null);
|
||||
};
|
||||
|
||||
const toggleVisibility = (layerId) => {
|
||||
const layer = layers.find((l) => l.id === layerId);
|
||||
if (layer) {
|
||||
layer.visible = !layer.visible;
|
||||
setLayers([...layers]);
|
||||
onLayerVisibilityChange?.(layerId, layer.visible);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFlatten = async () => {
|
||||
if (!projectId || layers.length === 0) return;
|
||||
const visibleLayers = layers.filter((l) => l.visible !== false);
|
||||
const layerOrder = visibleLayers.map((l) => l.id);
|
||||
await onFlatten(layerOrder);
|
||||
loadLayers();
|
||||
};
|
||||
|
||||
const handleNewLayer = async () => {
|
||||
if (!projectId) return;
|
||||
try {
|
||||
// Create a new empty transparent layer
|
||||
const newLayer = {
|
||||
id: `layer-${Date.now()}`,
|
||||
name: `Layer ${layers.length + 1}`,
|
||||
visible: true,
|
||||
thumbnail: null,
|
||||
};
|
||||
setLayers([...layers, newLayer]);
|
||||
setActiveLayer(newLayer.id);
|
||||
} catch (err) {
|
||||
onError?.(`Failed to create layer: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteLayer = async () => {
|
||||
if (!projectId || activeLayer === 'background') return;
|
||||
try {
|
||||
const updatedLayers = layers.filter((l) => l.id !== activeLayer);
|
||||
setLayers(updatedLayers);
|
||||
setActiveLayer(updatedLayers.length > 0 ? updatedLayers[updatedLayers.length - 1].id : 'background');
|
||||
} catch (err) {
|
||||
onError?.(`Failed to delete layer: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDuplicateLayer = async () => {
|
||||
if (!projectId || activeLayer === 'background') return;
|
||||
try {
|
||||
const layerToDuplicate = layers.find((l) => l.id === activeLayer);
|
||||
if (!layerToDuplicate) return;
|
||||
|
||||
const newLayer = {
|
||||
...layerToDuplicate,
|
||||
id: `layer-${Date.now()}`,
|
||||
name: `${layerToDuplicate.name} copy`,
|
||||
};
|
||||
|
||||
const activeIndex = layers.findIndex((l) => l.id === activeLayer);
|
||||
const updatedLayers = [...layers];
|
||||
updatedLayers.splice(activeIndex + 1, 0, newLayer);
|
||||
setLayers(updatedLayers);
|
||||
setActiveLayer(newLayer.id);
|
||||
} catch (err) {
|
||||
onError?.(`Failed to duplicate layer: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="layers-panel">
|
||||
<div className="layers-header">
|
||||
<h3>Layers</h3>
|
||||
{layers.length > 0 && (
|
||||
<button
|
||||
className="flatten-btn"
|
||||
onClick={handleFlatten}
|
||||
disabled={isProcessing}
|
||||
title="Merge all visible layers"
|
||||
>
|
||||
Flatten
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="layers-list">
|
||||
{/* Background layer (always present) */}
|
||||
<div
|
||||
className={`layer-item ${activeLayer === 'background' ? 'active' : ''}`}
|
||||
onClick={() => setActiveLayer('background')}
|
||||
>
|
||||
<span className="layer-visibility">
|
||||
<input type="checkbox" checked disabled />
|
||||
</span>
|
||||
<span className="layer-preview background-preview"></span>
|
||||
<span className="layer-name">Background</span>
|
||||
<span className="layer-lock">🔒</span>
|
||||
</div>
|
||||
|
||||
{/* Dynamic layers */}
|
||||
{layers.map((layer, index) => (
|
||||
<div
|
||||
key={layer.id}
|
||||
className={`layer-item ${activeLayer === layer.id ? 'active' : ''} ${
|
||||
draggedLayer === index ? 'dragging' : ''
|
||||
}`}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, index)}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onClick={() => setActiveLayer(layer.id)}
|
||||
>
|
||||
<span className="layer-visibility">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={layer.visible !== false}
|
||||
onChange={() => toggleVisibility(layer.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</span>
|
||||
<span className="layer-preview">
|
||||
{layer.thumbnail && (
|
||||
<img src={layer.thumbnail} alt={layer.name} />
|
||||
)}
|
||||
</span>
|
||||
<span className="layer-name">{layer.name}</span>
|
||||
<span className="layer-drag-handle">⋮⋮</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{layers.length === 0 && (
|
||||
<div className="no-layers-hint">
|
||||
Use "Remove Background" to create layers
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="layers-actions">
|
||||
<button
|
||||
className="layer-action-btn"
|
||||
disabled={isProcessing || !projectId}
|
||||
title="Add new empty layer"
|
||||
onClick={handleNewLayer}
|
||||
>
|
||||
+ New Layer
|
||||
</button>
|
||||
<button
|
||||
className="layer-action-btn"
|
||||
disabled={isProcessing || activeLayer === 'background'}
|
||||
title="Delete selected layer"
|
||||
onClick={handleDeleteLayer}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
className="layer-action-btn"
|
||||
disabled={isProcessing || activeLayer === 'background'}
|
||||
title="Duplicate selected layer"
|
||||
onClick={handleDuplicateLayer}
|
||||
>
|
||||
Duplicate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layers;
|
||||
Reference in New Issue
Block a user