Skip to content

Commit 47579f7

Browse files
authored
Merge pull request #2 from MujahidAbbas/feature/image-editing-flow
feat: Image Editing Flow (v1.1)
2 parents bddb89e + 94f4315 commit 47579f7

File tree

13 files changed

+2025
-13
lines changed

13 files changed

+2025
-13
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,4 @@ pnpm-debug.log*
2626
# AI and docs folders
2727
.claude/
2828
docs/
29+
.planning/

src/components/remove/BackgroundRemover.tsx

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ import UploadZone from './UploadZone';
33
import ProcessingState from './ProcessingState';
44
import ResultsView from './ResultsView';
55
import EditingView from './EditingView';
6+
import EditModal from './EditModal';
67
import { initializeSegmenter, removeBackground } from '../../lib/segmentation';
78
import { renderComposite } from '../../lib/compositing';
89
import { saveMaskSnapshot } from '../../lib/brushTool';
10+
import { applyCrop } from '../../lib/imageTransforms';
911
import { useAppStore } from '../../stores/appStore';
1012

1113
type ViewState = 'upload' | 'processing' | 'result' | 'editing';
@@ -28,6 +30,11 @@ export default function BackgroundRemover() {
2830
backgroundType,
2931
backgroundColor,
3032
backgroundImage,
33+
isEditModalOpen,
34+
openEditModal,
35+
closeEditModal,
36+
resetEditState,
37+
editState,
3138
pushHistory,
3239
reset,
3340
} = useAppStore();
@@ -124,8 +131,69 @@ export default function BackgroundRemover() {
124131
}, [reset]);
125132

126133
const handleEdit = useCallback(() => {
127-
setViewState('editing');
128-
}, []);
134+
openEditModal();
135+
}, [openEditModal]);
136+
137+
const handleEditClose = useCallback(() => {
138+
closeEditModal();
139+
}, [closeEditModal]);
140+
141+
const handleEditApply = useCallback(async () => {
142+
if (!originalImage || !maskCanvas || !previewCanvasRef.current) {
143+
closeEditModal();
144+
return;
145+
}
146+
147+
// Check if there's a crop to apply
148+
if (editState.cropBox && editState.aspectRatio !== 'original') {
149+
try {
150+
// Apply the crop to get new cropped image and mask
151+
const { croppedImage, croppedMask } = await applyCrop(
152+
originalImage,
153+
maskCanvas,
154+
editState.cropBox,
155+
editState.cropDisplayScale
156+
);
157+
158+
// Update the app state with cropped versions
159+
setOriginalImage(croppedImage);
160+
setMaskCanvas(croppedMask);
161+
162+
// Update preview canvas size
163+
previewCanvasRef.current.width = croppedImage.naturalWidth;
164+
previewCanvasRef.current.height = croppedImage.naturalHeight;
165+
166+
// Render the cropped composite
167+
const ctx = previewCanvasRef.current.getContext('2d');
168+
if (ctx) {
169+
renderComposite(ctx, croppedImage, croppedMask, {
170+
type: backgroundType,
171+
color: backgroundColor,
172+
image: backgroundImage ?? undefined,
173+
});
174+
}
175+
176+
// Save cropped mask to history
177+
pushHistory(saveMaskSnapshot(croppedMask));
178+
} catch (error) {
179+
console.error('Failed to apply crop:', error);
180+
}
181+
} else {
182+
// No crop, just re-render with background settings
183+
const ctx = previewCanvasRef.current.getContext('2d');
184+
if (ctx) {
185+
renderComposite(ctx, originalImage, maskCanvas, {
186+
type: backgroundType,
187+
color: backgroundColor,
188+
image: backgroundImage ?? undefined,
189+
});
190+
}
191+
}
192+
193+
// Reset edit state and close modal
194+
resetEditState();
195+
closeEditModal();
196+
}, [originalImage, maskCanvas, backgroundType, backgroundColor, backgroundImage, editState, setOriginalImage, setMaskCanvas, pushHistory, resetEditState, closeEditModal]);
129197

130198
const handleBackToResult = useCallback(() => {
131199
// Re-render preview canvas with current mask state
@@ -206,6 +274,16 @@ export default function BackgroundRemover() {
206274
onReset={handleReset}
207275
/>
208276
)}
277+
278+
{/* Edit Modal */}
279+
{isEditModalOpen && originalImage && maskCanvas && (
280+
<EditModal
281+
originalImage={originalImage}
282+
maskCanvas={maskCanvas}
283+
onClose={handleEditClose}
284+
onApply={handleEditApply}
285+
/>
286+
)}
209287
</div>
210288
);
211289
}
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import { useRef, useState, useEffect } from 'react';
2+
import { useAppStore } from '@/stores/appStore';
3+
4+
const PRESET_BACKGROUNDS = [
5+
{ id: 'transparent', label: 'None', value: null, type: 'transparent' },
6+
{ id: 'white', label: 'White', value: '#ffffff', type: 'color' },
7+
{ id: 'black', label: 'Black', value: '#000000', type: 'color' },
8+
{ id: 'mist', label: 'Mist', value: '#e8e8e8', type: 'color' },
9+
{ id: 'sky', label: 'Sky', value: '#87ceeb', type: 'color' },
10+
{ id: 'honeydew', label: 'Honeydew', value: '#f0fff0', type: 'color' },
11+
{ id: 'ivory', label: 'Ivory', value: '#fffff0', type: 'color' },
12+
{ id: 'lavender', label: 'Lavender', value: '#e6e6fa', type: 'color' },
13+
{ id: 'cyan', label: 'Cyan', value: '#e0ffff', type: 'color' },
14+
{ id: 'mint', label: 'Mint', value: '#98ff98', type: 'color' },
15+
{ id: 'peach', label: 'Peach', value: '#ffdab9', type: 'color' },
16+
{ id: 'rose', label: 'Rose', value: '#ffe4e1', type: 'color' },
17+
] as const;
18+
19+
export default function BackgroundSelector() {
20+
const fileInputRef = useRef<HTMLInputElement>(null);
21+
22+
const backgroundType = useAppStore((state) => state.backgroundType);
23+
const backgroundColor = useAppStore((state) => state.backgroundColor);
24+
const setBackgroundType = useAppStore((state) => state.setBackgroundType);
25+
const setBackgroundColor = useAppStore((state) => state.setBackgroundColor);
26+
const setBackgroundImage = useAppStore((state) => state.setBackgroundImage);
27+
const setHasUnsavedEdits = useAppStore((state) => state.setHasUnsavedEdits);
28+
29+
// Initialize customColor from current backgroundColor if type is 'color'
30+
const [customColor, setCustomColor] = useState(
31+
backgroundType === 'color' ? backgroundColor : '#ffffff'
32+
);
33+
34+
// Sync customColor when backgroundColor changes externally
35+
useEffect(() => {
36+
if (backgroundType === 'color') {
37+
setCustomColor(backgroundColor);
38+
}
39+
}, [backgroundColor, backgroundType]);
40+
41+
function handlePresetClick(preset: (typeof PRESET_BACKGROUNDS)[number]) {
42+
if (preset.type === 'transparent') {
43+
setBackgroundType('transparent');
44+
} else {
45+
setBackgroundType('color');
46+
setBackgroundColor(preset.value!);
47+
}
48+
setHasUnsavedEdits(true);
49+
}
50+
51+
function handleCustomColorChange(e: React.ChangeEvent<HTMLInputElement>) {
52+
const color = e.target.value;
53+
setCustomColor(color);
54+
setBackgroundType('color');
55+
setBackgroundColor(color);
56+
setHasUnsavedEdits(true);
57+
}
58+
59+
function handleUploadClick() {
60+
fileInputRef.current?.click();
61+
}
62+
63+
function handleFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
64+
const file = e.target.files?.[0];
65+
if (!file) return;
66+
67+
const img = new Image();
68+
const objectUrl = URL.createObjectURL(file);
69+
img.src = objectUrl;
70+
71+
img.onload = () => {
72+
URL.revokeObjectURL(objectUrl);
73+
setBackgroundType('image');
74+
setBackgroundImage(img);
75+
setHasUnsavedEdits(true);
76+
};
77+
78+
img.onerror = () => {
79+
URL.revokeObjectURL(objectUrl);
80+
console.error('Failed to load background image');
81+
};
82+
}
83+
84+
function isSelected(preset: (typeof PRESET_BACKGROUNDS)[number]): boolean {
85+
if (preset.type === 'transparent') {
86+
return backgroundType === 'transparent';
87+
}
88+
return backgroundType === 'color' && backgroundColor === preset.value;
89+
}
90+
91+
return (
92+
<div className="space-y-4">
93+
{/* Section Header */}
94+
<h3 className="text-sm font-semibold text-gray-700">Background</h3>
95+
96+
{/* Preset Swatches Grid */}
97+
<div className="grid grid-cols-6 gap-2">
98+
{PRESET_BACKGROUNDS.map((preset) => (
99+
<button
100+
key={preset.id}
101+
onClick={() => handlePresetClick(preset)}
102+
className={`
103+
w-10 h-10 rounded-lg border-2 transition-all
104+
${
105+
isSelected(preset)
106+
? 'border-blue-500 ring-2 ring-blue-500/30'
107+
: 'border-gray-200 hover:border-gray-300'
108+
}
109+
`}
110+
style={
111+
preset.type === 'transparent'
112+
? {
113+
backgroundColor: '#fff',
114+
backgroundImage: `
115+
linear-gradient(45deg, #ccc 25%, transparent 25%, transparent 75%, #ccc 75%),
116+
linear-gradient(45deg, #ccc 25%, transparent 25%, transparent 75%, #ccc 75%)
117+
`,
118+
backgroundSize: '8px 8px',
119+
backgroundPosition: '0 0, 4px 4px',
120+
}
121+
: { backgroundColor: preset.value || undefined }
122+
}
123+
aria-label={`${preset.label} background`}
124+
title={preset.label}
125+
/>
126+
))}
127+
</div>
128+
129+
{/* Custom Color Row */}
130+
<div className="flex items-center gap-3">
131+
<label htmlFor="custom-color" className="text-sm text-gray-600">
132+
Custom Color
133+
</label>
134+
<input
135+
type="color"
136+
id="custom-color"
137+
value={customColor}
138+
onChange={handleCustomColorChange}
139+
className="w-10 h-10 rounded-lg cursor-pointer border border-gray-200"
140+
aria-label="Select custom background color"
141+
/>
142+
</div>
143+
144+
{/* Photo Upload */}
145+
<div>
146+
<button
147+
onClick={handleUploadClick}
148+
className="w-full py-2 px-4 border-2 border-dashed border-gray-300 rounded-lg
149+
text-sm text-gray-600 hover:border-blue-500 hover:text-blue-500
150+
transition-colors flex items-center justify-center gap-2"
151+
>
152+
<svg
153+
className="w-4 h-4"
154+
fill="none"
155+
stroke="currentColor"
156+
viewBox="0 0 24 24"
157+
xmlns="http://www.w3.org/2000/svg"
158+
>
159+
<path
160+
strokeLinecap="round"
161+
strokeLinejoin="round"
162+
strokeWidth={2}
163+
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
164+
/>
165+
</svg>
166+
Upload Photo Background
167+
</button>
168+
<input
169+
type="file"
170+
ref={fileInputRef}
171+
accept="image/*"
172+
onChange={handleFileSelect}
173+
className="hidden"
174+
/>
175+
</div>
176+
</div>
177+
);
178+
}

0 commit comments

Comments
 (0)