Add advanced editing features: layers, background removal, smart selection

Backend:
- Add /tools router with background removal, smart select, color select
- Add rembg dependency for AI background removal
- Add layer management API (list, flatten)
- Fix transparency preservation in blend_patch (veil collapse fix)
- Preserve alpha channel when reverting/resetting images

Frontend:
- Add AdvancedTools panel with background removal, smart select, color select
- Add Layers panel with drag-to-reorder, visibility toggle, flatten
- Add toolsApi for new backend endpoints
- Make right panel scrollable for additional controls

This adds "Photoshop light" capabilities:
- Remove background and create layer
- Smart object selection (click to select)
- Color selection with tolerance
- Layer system with compositing
This commit is contained in:
Claude
2026-01-25 15:20:11 +00:00
parent 7a86d53c88
commit 909bb41f8a
12 changed files with 1166 additions and 14 deletions
+107
View File
@@ -0,0 +1,107 @@
.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;
}
+182
View File
@@ -0,0 +1,182 @@
import React, { useState } from 'react';
import { toolsApi } from '../utils/api';
import './AdvancedTools.css';
const AdvancedTools = ({
projectId,
selection,
onLayerCreated,
onMaskGenerated,
onImageUpdate,
isProcessing,
setIsProcessing,
setError,
}) => {
const [activeToolMode, setActiveToolMode] = useState(null);
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;
+152
View File
@@ -0,0 +1,152 @@
.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;
}
+167
View File
@@ -0,0 +1,167 @@
import React, { useState, useEffect } from 'react';
import { toolsApi } from '../utils/api';
import './Layers.css';
const Layers = ({
projectId,
layers,
setLayers,
activeLayer,
setActiveLayer,
onLayerVisibilityChange,
onFlatten,
isProcessing,
}) => {
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();
};
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"
>
+ New Layer
</button>
<button
className="layer-action-btn"
disabled={isProcessing || activeLayer === 'background'}
title="Delete selected layer"
>
Delete
</button>
<button
className="layer-action-btn"
disabled={isProcessing || activeLayer === 'background'}
title="Duplicate selected layer"
>
Duplicate
</button>
</div>
</div>
);
};
export default Layers;