Complete Phase 5 and Phase 6 features

Phase 5 - Photo Pre-Editor:
- Add Filerobot integration for image editing (crop, filters, adjustments)
- Add BG removal prompt modal after Filerobot edit completes
- Add 'Edit Photo' button in PropertiesPanel for re-editing images
- Support re-edit flow with editingElementId state tracking

Phase 6 - Template System:
- Add confirmation dialog when switching templates with existing work
- Add layers.background and layers.overlay to all 8 templates
- Implement auto-crop for slotted images using object-fit: cover logic
- Add Transformer boundBoxFunc to respect slot boundaries during resize
- Pass elements prop to TemplatesTab for detecting existing canvas work

Files modified:
- App.jsx: BG removal prompt, photo edit handlers
- ImageElement.jsx: crop property, slot-aware Transformer constraints
- PropertiesPanel.jsx: Edit Photo button
- TemplatesTab.jsx: confirmation dialog for template switch
- templates.js: layers.background/overlay for all templates
- Sidebar.jsx: pass elements prop to TemplatesTab
- DesignCanvas.jsx: pass slot prop to ImageElement

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Khalid A
2026-04-22 05:21:10 -05:00
parent 66bd69efe7
commit 3d412600d5
6 changed files with 353 additions and 37 deletions

View File

@@ -13,6 +13,9 @@ import { TEMPLATES } from './constants/templates';
function App() {
const [editingElement, setEditingElement] = useState(null);
const [pendingImage, setPendingImage] = useState(null);
const [showBgRemovalPrompt, setShowBgRemovalPrompt] = useState(false);
const [editingElementId, setEditingElementId] = useState(null);
const {
elements,
@@ -128,18 +131,50 @@ function App() {
// Handle photo editing
const handleEditPhoto = (element) => {
setEditingElement(element);
setEditingElementId(element.id);
setPendingImage({ src: element.src, id: element.id });
setShowBgRemovalPrompt(false);
};
const handlePhotoEditComplete = (editedImageUrl) => {
if (editingElement) {
updateElement(editingElement.id, { src: editedImageUrl });
}
setEditingElement(null);
setPendingImage(prev => ({ ...prev, editedUrl: editedImageUrl }));
setShowBgRemovalPrompt(true);
};
const handlePhotoEditClose = () => {
setEditingElement(null);
setPendingImage(null);
setEditingElementId(null);
setShowBgRemovalPrompt(false);
};
const handleBgRemovalYes = async () => {
if (pendingImage?.editedUrl) {
try {
const response = await fetch('/api/remove-background', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image: pendingImage.editedUrl }),
});
const data = await response.json();
if (data.success && editingElementId) {
updateElement(editingElementId, { src: data.result });
}
} catch (err) {
console.error('Background removal failed:', err);
}
}
setShowBgRemovalPrompt(false);
setPendingImage(null);
setEditingElementId(null);
};
const handleBgRemovalNo = () => {
if (pendingImage?.editedUrl && editingElementId) {
updateElement(editingElementId, { src: pendingImage.editedUrl });
}
setShowBgRemovalPrompt(false);
setPendingImage(null);
setEditingElementId(null);
};
return (
@@ -157,6 +192,7 @@ function App() {
onAddText={handleAddText}
onAddTemplate={handleAddTemplate}
onSlotImageUpload={handleSlotImageUpload}
elements={elements}
/>
{/* Center Canvas Area */}
@@ -280,13 +316,86 @@ function App() {
/>
{/* Photo Pre-Editor Modal */}
{editingElement && (
{pendingImage && (
<PhotoPreEditor
imageSrc={editingElement.src}
imageSrc={pendingImage.src}
onComplete={handlePhotoEditComplete}
onClose={handlePhotoEditClose}
/>
)}
{/* Background removal prompt after Filerobot edit */}
{showBgRemovalPrompt && (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
}}>
<div style={{
background: 'var(--bg-primary)',
borderRadius: 'var(--radius-md)',
padding: '1.5rem',
maxWidth: '360px',
boxShadow: 'var(--shadow-lg)',
}}>
<h3 style={{
margin: '0 0 0.75rem 0',
fontSize: '16px',
fontWeight: '600',
color: 'var(--text-primary)',
}}>
Remove Background?
</h3>
<p style={{
margin: '0 0 1.25rem 0',
fontSize: '13px',
color: 'var(--text-secondary)',
lineHeight: '1.4',
}}>
AI can automatically remove the background from your image.
</p>
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}>
<button
onClick={handleBgRemovalNo}
style={{
padding: '0.5rem 1rem',
border: `1px solid var(--border)`,
borderRadius: 'var(--radius-sm)',
background: 'var(--bg-primary)',
color: 'var(--text-primary)',
fontSize: '13px',
fontWeight: 500,
cursor: 'pointer',
}}
>
Skip
</button>
<button
onClick={handleBgRemovalYes}
style={{
padding: '0.5rem 1rem',
border: 'none',
borderRadius: 'var(--radius-sm)',
background: 'var(--accent)',
color: '#fff',
fontSize: '13px',
fontWeight: 600,
cursor: 'pointer',
}}
>
Remove Background
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -61,6 +61,7 @@ export const DesignCanvas = memo(function DesignCanvas({
<Layer>
{elements.map((el) => {
if (el.type === 'image') {
const slot = el.slotId ? slots.find(s => s.id === el.slotId) : null;
return (
<ImageElement
key={el.id}
@@ -71,7 +72,7 @@ export const DesignCanvas = memo(function DesignCanvas({
height={el.height}
rotation={el.rotation}
src={el.src}
crop={el.crop}
slot={slot}
isSelected={el.id === selectedId}
onSelect={() => onSelect(el.id)}
onUpdate={(attrs) => onUpdate(el.id, attrs)}

View File

@@ -2,9 +2,9 @@ import { useEffect, useState, memo } from 'react';
import { Image, Transformer } from 'react-konva';
import useImage from 'use-image';
function URLImage({ src, ...props }) {
function URLImage({ src, crop, ...props }) {
const [img] = useImage(src, 'anonymous');
return <Image image={img} {...props} />;
return <Image image={img} crop={crop} {...props} />;
}
export const ImageElement = memo(function ImageElement({
@@ -15,20 +15,44 @@ export const ImageElement = memo(function ImageElement({
height,
rotation,
src,
slot,
isSelected,
onSelect,
onUpdate,
onCommit,
dragBoundFunc,
}) {
const shapeRef = null;
const trRef = null;
const [crop, setCrop] = useState(null);
// Calculate crop for slotted images using object-fit: cover logic
useEffect(() => {
if (isSelected && trRef.current) {
trRef.current.nodes([shapeRef.current]);
trRef.current.getLayer().batchDraw();
if (slot && src) {
const img = new Image();
img.src = src;
img.onload = () => {
const imageRatio = img.width / img.height;
const slotRatio = slot.bounds.width / slot.bounds.height;
let cropWidth, cropHeight, cropX = 0, cropY = 0;
if (imageRatio > slotRatio) {
// Image is wider than slot - crop width
cropHeight = img.height;
cropWidth = img.height * slotRatio;
cropX = (img.width - cropWidth) / 2;
} else {
// Image is taller than slot - crop height
cropWidth = img.width;
cropHeight = img.width / slotRatio;
cropY = (img.height - cropHeight) / 2;
}
setCrop({ x: cropX, y: cropY, width: cropWidth, height: cropHeight });
};
}
}, [isSelected]);
}, [slot, src]);
return (
<>
@@ -40,6 +64,7 @@ export const ImageElement = memo(function ImageElement({
height={height}
rotation={rotation}
src={src}
crop={crop}
draggable
onClick={onSelect}
onTap={onSelect}
@@ -77,6 +102,10 @@ export const ImageElement = memo(function ImageElement({
<Transformer
ref={trRef}
boundBoxFunc={(oldBox, newBox) => {
// Respect slot boundaries during resize
if (slot && dragBoundFunc) {
return dragBoundFunc(oldBox, newBox);
}
// Limit resize to minimum size
if (newBox.width < 20 || newBox.height < 20) {
return oldBox;

View File

@@ -11,7 +11,7 @@ const TABS = [
{ id: 'templates', label: 'Templates', icon: '📋' },
];
export function Sidebar({ onAddImage, onAddSticker, onAddText, onAddTemplate, onSlotImageUpload }) {
export function Sidebar({ onAddImage, onAddSticker, onAddText, onAddTemplate, onSlotImageUpload, elements }) {
const [activeTab, setActiveTab] = useState('upload');
const renderTabContent = () => {
@@ -23,7 +23,7 @@ export function Sidebar({ onAddImage, onAddSticker, onAddText, onAddTemplate, on
case 'text':
return <TextTab onAddText={onAddText} />;
case 'templates':
return <TemplatesTab onAddTemplate={onAddTemplate} onSlotImageUpload={onSlotImageUpload} />;
return <TemplatesTab onAddTemplate={onAddTemplate} onSlotImageUpload={onSlotImageUpload} elements={elements} />;
default:
return null;
}

View File

@@ -16,9 +16,13 @@ function getCategoryEmoji(category) {
return emojis[category] || '🎨';
}
export function TemplatesTab({ onAddTemplate, onSlotImageUpload }) {
export function TemplatesTab({ onAddTemplate, onSlotImageUpload, elements = [] }) {
const [selectedTemplateId, setSelectedTemplateId] = useState(null);
const [uploadSlotId, setUploadSlotId] = useState(null);
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [pendingTemplate, setPendingTemplate] = useState(null);
const hasElements = elements && elements.length > 0;
const templates = [
{
@@ -36,9 +40,29 @@ export function TemplatesTab({ onAddTemplate, onSlotImageUpload }) {
})),
];
const handleSelectTemplate = (template) => {
const performTemplateSwitch = (template) => {
setSelectedTemplateId(template.id);
onAddTemplate(template.id);
setPendingTemplate(null);
setShowConfirmDialog(false);
};
const handleSelectTemplate = (template) => {
if (hasElements) {
setPendingTemplate(template);
setShowConfirmDialog(true);
} else {
performTemplateSwitch(template);
}
};
const handleConfirmSwitch = () => {
performTemplateSwitch(pendingTemplate);
};
const handleCancelSwitch = () => {
setPendingTemplate(null);
setShowConfirmDialog(false);
};
const handleSlotClick = (slotId) => {
@@ -63,24 +87,6 @@ export function TemplatesTab({ onAddTemplate, onSlotImageUpload }) {
// Get slots for selected template
const selectedTemplate = TEMPLATES.find(t => t.id === selectedTemplateId);
const slots = selectedTemplate?.slots || [];
const templates = [
{
id: 'freeform',
name: 'Freeform',
description: 'No template - design freely',
thumbnail: '🎨',
},
...TEMPLATES.map(t => ({
id: t.id,
name: t.name,
description: t.description,
thumbnail: getCategoryEmoji(t.category),
})),
];
const handleSelectTemplate = (template) => {
onAddTemplate(template.id);
};
// Hidden file input for slot image uploads
const renderFileInput = () => (
@@ -252,6 +258,80 @@ export function TemplatesTab({ onAddTemplate, onSlotImageUpload }) {
</div>
{renderSlotUploads()}
{/* Confirmation dialog for template switch */}
{showConfirmDialog && (
<div style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 100,
borderRadius: 'var(--radius-md)',
}}>
<div style={{
background: 'var(--bg-primary)',
borderRadius: 'var(--radius-md)',
padding: '1.25rem',
maxWidth: '320px',
boxShadow: 'var(--shadow-lg)',
}}>
<h4 style={{
margin: '0 0 0.75rem 0',
fontSize: '14px',
fontWeight: '600',
color: 'var(--text-primary)',
}}>
Switch Template?
</h4>
<p style={{
margin: '0 0 1.25rem 0',
fontSize: '12px',
color: 'var(--text-secondary)',
lineHeight: '1.4',
}}>
Your current design has elements on the canvas. Switching templates may discard your work.
</p>
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}>
<button
onClick={handleCancelSwitch}
style={{
padding: '0.5rem 0.75rem',
border: `1px solid var(--border)`,
borderRadius: 'var(--radius-sm)',
background: 'var(--bg-primary)',
color: 'var(--text-primary)',
fontSize: '12px',
fontWeight: 500,
cursor: 'pointer',
}}
>
Cancel
</button>
<button
onClick={handleConfirmSwitch}
style={{
padding: '0.5rem 0.75rem',
border: 'none',
borderRadius: 'var(--radius-sm)',
background: 'var(--accent)',
color: '#fff',
fontSize: '12px',
fontWeight: 600,
cursor: 'pointer',
}}
>
Switch Anyway
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -19,6 +19,18 @@ export const TEMPLATES = [
aspectRatio: 1,
},
],
layers: {
background: {
type: 'rect',
x: 0,
y: 0,
width: 300,
height: 300,
fill: '#1e40af',
opacity: 0.08,
},
overlay: null,
},
elements: [
{
type: 'text',
@@ -47,6 +59,18 @@ export const TEMPLATES = [
name: 'Band Merch',
category: 'Music',
description: 'Classic band t-shirt design',
layers: {
background: {
type: 'rect',
x: 0,
y: 0,
width: 300,
height: 300,
fill: '#000000',
opacity: 0.12,
},
overlay: null,
},
elements: [
{
type: 'text',
@@ -85,6 +109,18 @@ export const TEMPLATES = [
name: 'Minimal Quote',
category: 'Quotes',
description: 'Simple centered quote design',
layers: {
background: {
type: 'rect',
x: 0,
y: 0,
width: 300,
height: 300,
fill: '#f1f5f9',
opacity: 0.05,
},
overlay: null,
},
elements: [
{
type: 'text',
@@ -113,6 +149,18 @@ export const TEMPLATES = [
name: 'Funny Cat',
category: 'Animals',
description: 'Cute cat with funny text',
layers: {
background: {
type: 'rect',
x: 0,
y: 0,
width: 300,
height: 300,
fill: '#fef3c7',
opacity: 0.08,
},
overlay: null,
},
elements: [
{
type: 'text',
@@ -141,6 +189,18 @@ export const TEMPLATES = [
name: 'Gradient Vibes',
category: 'Abstract',
description: 'Modern gradient text design',
layers: {
background: {
type: 'rect',
x: 0,
y: 0,
width: 300,
height: 300,
fill: '#f3e8ff',
opacity: 0.1,
},
overlay: null,
},
elements: [
{
type: 'text',
@@ -189,6 +249,18 @@ export const TEMPLATES = [
name: 'Vintage Badge',
category: 'Vintage',
description: 'Retro badge style design',
layers: {
background: {
type: 'rect',
x: 0,
y: 0,
width: 300,
height: 300,
fill: '#78716c',
opacity: 0.08,
},
overlay: null,
},
elements: [
{
type: 'text',
@@ -237,6 +309,18 @@ export const TEMPLATES = [
name: 'Nature Lover',
category: 'Nature',
description: 'Mountain and nature themed',
layers: {
background: {
type: 'rect',
x: 0,
y: 0,
width: 300,
height: 300,
fill: '#059669',
opacity: 0.08,
},
overlay: null,
},
elements: [
{
type: 'text',
@@ -275,6 +359,18 @@ export const TEMPLATES = [
name: 'Tech Geek',
category: 'Tech',
description: 'Programming themed design',
layers: {
background: {
type: 'rect',
x: 0,
y: 0,
width: 300,
height: 300,
fill: '#3b82f6',
opacity: 0.08,
},
overlay: null,
},
elements: [
{
type: 'text',
@@ -309,6 +405,7 @@ export const TEMPLATES = [
],
},
];
];
export const TEMPLATE_CATEGORIES = [
'All',