Skip to content

Commit 94f4315

Browse files
committed
feat(02-05): apply crop when clicking Apply & Done
- Add cropDisplayScale to EditState to track coordinate scale factor - Add applyCrop utility function to imageTransforms.ts - Update handleEditApply in BackgroundRemover to actually crop the image - Cropped image and mask replace the originals in app state - Save cropped mask to history for undo support
1 parent edec87a commit 94f4315

File tree

4 files changed

+124
-8
lines changed

4 files changed

+124
-8
lines changed

src/components/remove/BackgroundRemover.tsx

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import EditModal from './EditModal';
77
import { initializeSegmenter, removeBackground } from '../../lib/segmentation';
88
import { renderComposite } from '../../lib/compositing';
99
import { saveMaskSnapshot } from '../../lib/brushTool';
10+
import { applyCrop } from '../../lib/imageTransforms';
1011
import { useAppStore } from '../../stores/appStore';
1112

1213
type ViewState = 'upload' | 'processing' | 'result' | 'editing';
@@ -33,6 +34,7 @@ export default function BackgroundRemover() {
3334
openEditModal,
3435
closeEditModal,
3536
resetEditState,
37+
editState,
3638
pushHistory,
3739
reset,
3840
} = useAppStore();
@@ -136,22 +138,62 @@ export default function BackgroundRemover() {
136138
closeEditModal();
137139
}, [closeEditModal]);
138140

139-
const handleEditApply = useCallback(() => {
140-
// Re-render preview with current state (includes edit transforms/filters)
141-
if (originalImage && maskCanvas && previewCanvasRef.current) {
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
142183
const ctx = previewCanvasRef.current.getContext('2d');
143184
if (ctx) {
144-
// Note: For now, the preview just uses background state
145-
// Full edit state rendering would need to bake transforms into the canvas
146185
renderComposite(ctx, originalImage, maskCanvas, {
147186
type: backgroundType,
148187
color: backgroundColor,
149188
image: backgroundImage ?? undefined,
150189
});
151190
}
152191
}
192+
193+
// Reset edit state and close modal
194+
resetEditState();
153195
closeEditModal();
154-
}, [originalImage, maskCanvas, backgroundType, backgroundColor, backgroundImage, closeEditModal]);
196+
}, [originalImage, maskCanvas, backgroundType, backgroundColor, backgroundImage, editState, setOriginalImage, setMaskCanvas, pushHistory, resetEditState, closeEditModal]);
155197

156198
const handleBackToResult = useCallback(() => {
157199
// Re-render preview canvas with current mask state

src/components/remove/EditPreview.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -311,16 +311,22 @@ export default function EditPreview({
311311
return;
312312
}
313313

314+
// Calculate display scale (canvas size / original image size)
315+
const displayScale = canvas.width / originalImage.width;
316+
314317
// Initialize crop box if not set or if aspect ratio changed to a fixed one
315318
if (!editState.cropBox || aspectRatioValue !== null) {
316319
const defaultCrop = calculateDefaultCrop(
317320
canvas.width,
318321
canvas.height,
319322
editState.aspectRatio
320323
);
321-
setEditState({ cropBox: defaultCrop });
324+
setEditState({ cropBox: defaultCrop, cropDisplayScale: displayScale });
325+
} else {
326+
// Update display scale even if cropBox exists
327+
setEditState({ cropDisplayScale: displayScale });
322328
}
323-
}, [editState.aspectRatio]);
329+
}, [editState.aspectRatio, originalImage.width]);
324330

325331
// Render preview
326332
const renderPreview = useCallback(() => {

src/lib/imageTransforms.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,69 @@ export function calculateCropRect(
8383
};
8484
}
8585
}
86+
87+
export interface CropBox {
88+
x: number;
89+
y: number;
90+
width: number;
91+
height: number;
92+
}
93+
94+
/**
95+
* Apply crop to create new cropped image and mask canvases.
96+
* Returns new HTMLImageElement and HTMLCanvasElement for the cropped region.
97+
*/
98+
export async function applyCrop(
99+
originalImage: HTMLImageElement,
100+
maskCanvas: HTMLCanvasElement,
101+
cropBox: CropBox,
102+
displayScale: number
103+
): Promise<{ croppedImage: HTMLImageElement; croppedMask: HTMLCanvasElement }> {
104+
// Convert display coordinates to actual image coordinates
105+
const actualCrop = {
106+
x: Math.round(cropBox.x / displayScale),
107+
y: Math.round(cropBox.y / displayScale),
108+
width: Math.round(cropBox.width / displayScale),
109+
height: Math.round(cropBox.height / displayScale),
110+
};
111+
112+
// Ensure crop is within bounds
113+
const clampedCrop = {
114+
x: Math.max(0, Math.min(actualCrop.x, originalImage.naturalWidth - 1)),
115+
y: Math.max(0, Math.min(actualCrop.y, originalImage.naturalHeight - 1)),
116+
width: Math.min(actualCrop.width, originalImage.naturalWidth - actualCrop.x),
117+
height: Math.min(actualCrop.height, originalImage.naturalHeight - actualCrop.y),
118+
};
119+
120+
// Create cropped image canvas
121+
const imageCanvas = document.createElement('canvas');
122+
imageCanvas.width = clampedCrop.width;
123+
imageCanvas.height = clampedCrop.height;
124+
const imageCtx = imageCanvas.getContext('2d')!;
125+
imageCtx.drawImage(
126+
originalImage,
127+
clampedCrop.x, clampedCrop.y, clampedCrop.width, clampedCrop.height,
128+
0, 0, clampedCrop.width, clampedCrop.height
129+
);
130+
131+
// Create cropped mask canvas
132+
const croppedMask = document.createElement('canvas');
133+
croppedMask.width = clampedCrop.width;
134+
croppedMask.height = clampedCrop.height;
135+
const maskCtx = croppedMask.getContext('2d')!;
136+
maskCtx.drawImage(
137+
maskCanvas,
138+
clampedCrop.x, clampedCrop.y, clampedCrop.width, clampedCrop.height,
139+
0, 0, clampedCrop.width, clampedCrop.height
140+
);
141+
142+
// Convert cropped image canvas to HTMLImageElement
143+
const croppedImage = await new Promise<HTMLImageElement>((resolve, reject) => {
144+
const img = new Image();
145+
img.onload = () => resolve(img);
146+
img.onerror = reject;
147+
img.src = imageCanvas.toDataURL('image/png');
148+
});
149+
150+
return { croppedImage, croppedMask };
151+
}

src/stores/appStore.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export interface EditState {
2222
};
2323
aspectRatio: 'original' | 'free' | '1:1' | '4:3' | '16:9' | '9:16';
2424
cropBox: CropBox | null; // null when no crop is active (original/free without user interaction)
25+
cropDisplayScale: number; // scale factor to convert cropBox display coords to actual image coords
2526
}
2627

2728
interface AppState {
@@ -93,6 +94,7 @@ const editInitialState: EditState = {
9394
},
9495
aspectRatio: 'original',
9596
cropBox: null,
97+
cropDisplayScale: 1,
9698
};
9799

98100
const initialState = {

0 commit comments

Comments
 (0)