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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user