From 2acf674aaacdf42e0896a11441e190c6c7fa5bcd Mon Sep 17 00:00:00 2001 From: Khalid A Date: Tue, 21 Apr 2026 01:17:47 -0500 Subject: [PATCH 1/5] Phase 3: Sidebar & Properties Panel - Three-column layout (sidebar/canvas/properties) - Sidebar with tabs: Upload, Stickers, Text - Upload tab with drag-and-drop and click-to-upload - Stickers tab with 6 categories (40+ emojis) - Text tab with font selector, size slider, color picker - Properties panel with position, size, rotation controls - Delete button for selected element - Responsive layout for mobile Co-Authored-By: Claude Sonnet 4.6 --- client/src/App.jsx | 111 +++-- .../components/properties/PropertiesPanel.jsx | 109 +++++ client/src/components/properties/index.js | 1 + client/src/components/sidebar/Sidebar.jsx | 46 ++ client/src/components/sidebar/StickersTab.jsx | 63 +++ client/src/components/sidebar/TextTab.jsx | 96 ++++ client/src/components/sidebar/UploadTab.jsx | 80 ++++ client/src/components/sidebar/index.js | 4 + client/src/index.css | 445 ++++++++++++++++++ 9 files changed, 911 insertions(+), 44 deletions(-) create mode 100644 client/src/components/properties/PropertiesPanel.jsx create mode 100644 client/src/components/properties/index.js create mode 100644 client/src/components/sidebar/Sidebar.jsx create mode 100644 client/src/components/sidebar/StickersTab.jsx create mode 100644 client/src/components/sidebar/TextTab.jsx create mode 100644 client/src/components/sidebar/UploadTab.jsx create mode 100644 client/src/components/sidebar/index.js diff --git a/client/src/App.jsx b/client/src/App.jsx index 2148138..f21be5b 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -1,5 +1,7 @@ -import { useEffect, useCallback } from 'react'; +import { useEffect } from 'react'; import { DesignCanvas } from './components/canvas/DesignCanvas'; +import { Sidebar } from './components/sidebar/Sidebar'; +import { PropertiesPanel } from './components/properties/PropertiesPanel'; import { useDesignEditor } from './hooks/useDesignEditor'; function App() { @@ -13,10 +15,11 @@ function App() { deselectAll, } = useDesignEditor(); + const selectedElement = elements.find((el) => el.id === selectedId); + // Keyboard shortcut: Delete/Backspace removes selected element useEffect(() => { const handleKeyDown = (e) => { - // Don't delete if user is typing in an input if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') { return; } @@ -31,53 +34,73 @@ function App() { return () => window.removeEventListener('keydown', handleKeyDown); }, [selectedId, deleteElement]); - // Test: Add sample image on mount (for Phase 2 testing) - useEffect(() => { - // Add a test image element - const testImageId = addElement({ - type: 'image', - x: 75, - y: 75, - width: 150, - height: 150, - rotation: 0, - src: 'https://placehold.co/150x150/38bdf8/ffffff?text=Test', - }); - console.log('Added test image with ID:', testImageId); - }, []); // eslint-disable-line react-hooks/exhaustive-deps + const handleUpload = (data) => { + if (data.preview?.url) { + addElement({ + type: 'image', + x: 75, + y: 75, + width: 150, + height: 150, + rotation: 0, + src: data.preview.url, + }); + } + }; + + const handleAddElement = (elementData) => { + if (elementData.type === 'sticker') { + // Convert emoji sticker to a text-like element + addElement({ + type: 'text', + text: elementData.emoji, + x: elementData.x, + y: elementData.y, + fontSize: elementData.size, + fontFamily: 'Arial', + fill: '#000000', + rotation: elementData.rotation, + }); + } else if (elementData.type === 'text') { + addElement(elementData); + } + }; return ( -
-

Apparel Designer

-

- T-shirt customization editor -

+
+ {/* Left Sidebar */} + - {/* Canvas container */} -
- +

Apparel Designer

+

T-shirt customization editor

+
+ +
+
+

Elements: {elements.length}

+

Selected: {selectedId || 'None'}

+

Tip: Click to select, drag to move, use handles to resize. Press Delete to remove.

+
+ + + {/* Right Properties Panel */} +
- - {/* Debug info */} -
-

Elements: {elements.length}

-

Selected: {selectedId || 'None'}

-

- Tip: Click to select, drag to move, use handles to resize. Press Delete to remove. -

-
+
); } diff --git a/client/src/components/properties/PropertiesPanel.jsx b/client/src/components/properties/PropertiesPanel.jsx new file mode 100644 index 0000000..7b9140e --- /dev/null +++ b/client/src/components/properties/PropertiesPanel.jsx @@ -0,0 +1,109 @@ +export function PropertiesPanel({ selectedElement, onUpdate, onDelete }) { + if (!selectedElement) { + return ( +
+

Properties

+
+

Select an element to edit its properties

+
+
+ ); + } + + const handlePositionChange = (axis, value) => { + onUpdate(selectedElement.id, { [axis]: Number(value) }); + }; + + const handleSizeChange = (dimension, value) => { + onUpdate(selectedElement.id, { [dimension]: Number(value) }); + }; + + const handleRotationChange = (value) => { + onUpdate(selectedElement.id, { rotation: Number(value) }); + }; + + const getIcon = () => { + if (selectedElement.type === 'image') return '๐Ÿ–ผ๏ธ'; + if (selectedElement.type === 'text') return 'T'; + if (selectedElement.type === 'sticker') return '๐Ÿ˜Š'; + return '๐Ÿ“ฆ'; + }; + + return ( +
+

Properties

+
+ {getIcon()} + + {selectedElement.type === 'text' + ? selectedElement.text?.substring(0, 20) || 'Text' + : `${selectedElement.type}`} + +
+ +
+ +
+
+ X + handlePositionChange('x', e.target.value)} + /> +
+
+ Y + handlePositionChange('y', e.target.value)} + /> +
+
+
+ +
+ +
+
+ W + + handleSizeChange(selectedElement.text ? 'fontSize' : 'width', e.target.value) + } + /> +
+ {selectedElement.type !== 'text' && ( +
+ H + handleSizeChange('height', e.target.value)} + /> +
+ )} +
+
+ +
+ + handleRotationChange(e.target.value)} + className="rotation-slider" + /> +
+ + +
+ ); +} diff --git a/client/src/components/properties/index.js b/client/src/components/properties/index.js new file mode 100644 index 0000000..43c7b2c --- /dev/null +++ b/client/src/components/properties/index.js @@ -0,0 +1 @@ +export { PropertiesPanel } from './PropertiesPanel'; diff --git a/client/src/components/sidebar/Sidebar.jsx b/client/src/components/sidebar/Sidebar.jsx new file mode 100644 index 0000000..0f5e806 --- /dev/null +++ b/client/src/components/sidebar/Sidebar.jsx @@ -0,0 +1,46 @@ +import { useState } from 'react'; +import { UploadTab } from './UploadTab'; +import { StickersTab } from './StickersTab'; +import { TextTab } from './TextTab'; + +const TABS = [ + { id: 'upload', label: 'Upload', icon: '๐Ÿ“' }, + { id: 'stickers', label: 'Stickers', icon: '๐Ÿ˜Š' }, + { id: 'text', label: 'Text', icon: 'T' }, +]; + +export function Sidebar({ onElementAdd, onUpload }) { + const [activeTab, setActiveTab] = useState('upload'); + + const renderTabContent = () => { + switch (activeTab) { + case 'upload': + return ; + case 'stickers': + return ; + case 'text': + return ; + default: + return null; + } + }; + + return ( +
+
+ {TABS.map((tab) => ( + + ))} +
+
{renderTabContent()}
+
+ ); +} diff --git a/client/src/components/sidebar/StickersTab.jsx b/client/src/components/sidebar/StickersTab.jsx new file mode 100644 index 0000000..d6d4c29 --- /dev/null +++ b/client/src/components/sidebar/StickersTab.jsx @@ -0,0 +1,63 @@ +import { useState } from 'react'; + +const STICKER_CATEGORIES = [ + { id: 'all', label: 'All', emojis: [] }, + { id: 'faces', label: 'Faces', emojis: ['๐Ÿ˜€', '๐Ÿ˜', '๐Ÿ˜‚', '๐Ÿคฃ', '๐Ÿ˜ƒ', '๐Ÿ˜„', '๐Ÿ˜…', '๐Ÿ˜†', '๐Ÿ˜‰', '๐Ÿ˜Š', '๐Ÿ˜‹', '๐Ÿ˜Ž', '๐Ÿ˜', '๐Ÿ˜˜', '๐Ÿฅฐ', '๐Ÿ˜—', '๐Ÿ˜™', '๐Ÿ˜š'] }, + { id: 'animals', label: 'Animals', emojis: ['๐Ÿถ', '๐Ÿฑ', '๐Ÿญ', '๐Ÿน', '๐Ÿฐ', '๐ŸฆŠ', '๐Ÿป', '๐Ÿผ', '๐Ÿจ', '๐Ÿฏ', '๐Ÿฆ', '๐Ÿฎ', '๐Ÿท', '๐Ÿธ', '๐Ÿต', '๐Ÿ”', '๐Ÿง', '๐Ÿฆ', '๐Ÿฆ†', '๐Ÿฆ…'] }, + { id: 'food', label: 'Food', emojis: ['๐ŸŽ', '๐Ÿ', '๐ŸŠ', '๐Ÿ‹', '๐ŸŒ', '๐Ÿ‰', '๐Ÿ‡', '๐Ÿ“', '๐Ÿˆ', '๐Ÿ’', '๐Ÿ‘', '๐Ÿฅญ', '๐Ÿ', '๐Ÿฅฅ', '๐Ÿฅ', '๐Ÿ…', '๐Ÿ†', '๐Ÿฅ‘', '๐Ÿฅฆ', '๐Ÿฅฌ'] }, + { id: 'sports', label: 'Sports', emojis: ['โšฝ', '๐Ÿ€', '๐Ÿˆ', 'โšพ', '๐ŸฅŽ', '๐ŸŽพ', '๐Ÿ', '๐Ÿ‰', '๐Ÿฅ', '๐ŸŽฑ', '๐Ÿช€', '๐Ÿ“', '๐Ÿธ', '๐Ÿ’', '๐Ÿ‘', '๐Ÿฅ', '๐Ÿ', '๐Ÿฅ…', 'โ›ณ', '๐Ÿช'] }, + { id: 'symbols', label: 'Symbols', emojis: ['โค๏ธ', '๐Ÿงก', '๐Ÿ’›', '๐Ÿ’š', '๐Ÿ’™', '๐Ÿ’œ', '๐Ÿ–ค', '๐Ÿค', '๐ŸคŽ', '๐Ÿ’”', 'โฃ๏ธ', '๐Ÿ’•', '๐Ÿ’ž', '๐Ÿ’“', '๐Ÿ’—', '๐Ÿ’–', '๐Ÿ’˜', '๐Ÿ’', '๐Ÿ’Ÿ', 'โ˜ฎ๏ธ'] }, + { id: 'objects', label: 'Objects', emojis: ['โŒš', '๐Ÿ“ฑ', '๐Ÿ’ป', 'โŒจ๏ธ', '๐Ÿ–ฅ๏ธ', '๐Ÿ–จ๏ธ', '๐Ÿ–ฑ๏ธ', '๐Ÿ–ฒ๏ธ', '๐Ÿ•น๏ธ', '๐Ÿ—œ๏ธ', '๐Ÿ’ฝ', '๐Ÿ’พ', '๐Ÿ’ฟ', '๐Ÿ“€', '๐Ÿ“ผ', '๐Ÿ“ท', '๐Ÿ“ธ', '๐Ÿ“น', '๐ŸŽฅ', '๐Ÿ“ฝ๏ธ'] }, +]; + +export function StickersTab({ onAddSticker }) { + const [selectedCategory, setSelectedCategory] = useState('faces'); + + const getStickers = () => { + if (selectedCategory === 'all') { + return STICKER_CATEGORIES.flatMap((cat) => cat.emojis).filter(Boolean); + } + const category = STICKER_CATEGORIES.find((cat) => cat.id === selectedCategory); + return category?.emojis || []; + }; + + const handleAddSticker = (emoji) => { + onAddSticker({ + type: 'sticker', + emoji, + x: 100, + y: 100, + size: 64, + rotation: 0, + }); + }; + + return ( +
+

Stickers

+
+ {STICKER_CATEGORIES.map((cat) => ( + + ))} +
+
+ {getStickers().map((emoji, index) => ( + + ))} +
+
+ ); +} diff --git a/client/src/components/sidebar/TextTab.jsx b/client/src/components/sidebar/TextTab.jsx new file mode 100644 index 0000000..b80ce44 --- /dev/null +++ b/client/src/components/sidebar/TextTab.jsx @@ -0,0 +1,96 @@ +import { useState } from 'react'; + +const FONTS = [ + { value: 'Arial', label: 'Arial' }, + { value: 'Helvetica', label: 'Helvetica' }, + { value: 'Times New Roman', label: 'Times New Roman' }, + { value: 'Georgia', label: 'Georgia' }, + { value: 'Verdana', label: 'Verdana' }, + { value: 'Courier New', label: 'Courier New' }, + { value: 'Comic Sans MS', label: 'Comic Sans MS' }, + { value: 'Impact', label: 'Impact' }, +]; + +export function TextTab({ onAddText }) { + const [text, setText] = useState('Your Text'); + const [fontFamily, setFontFamily] = useState('Arial'); + const [fontSize, setFontSize] = useState(32); + const [fill, setFill] = useState('#000000'); + + const handleAddText = () => { + onAddText({ + type: 'text', + text, + fontFamily, + fontSize, + fill, + x: 100, + y: 100, + rotation: 0, + }); + }; + + return ( +
+

Add Text

+
+ + setText(e.target.value)} + placeholder="Enter text" + className="text-input" + /> +
+
+ + +
+
+ + setFontSize(Number(e.target.value))} + className="size-slider" + /> +
+
+ +
+ setFill(e.target.value)} + className="color-picker" + /> + setFill(e.target.value)} + className="color-input" + /> +
+
+ +
+ {text} +
+
+ ); +} diff --git a/client/src/components/sidebar/UploadTab.jsx b/client/src/components/sidebar/UploadTab.jsx new file mode 100644 index 0000000..51325eb --- /dev/null +++ b/client/src/components/sidebar/UploadTab.jsx @@ -0,0 +1,80 @@ +import { useRef, useState } from 'react'; + +export function UploadTab({ onUpload }) { + const fileInputRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); + const [uploading, setUploading] = useState(false); + + const handleFile = async (file) => { + if (!file || !file.type.startsWith('image/')) return; + + setUploading(true); + const formData = new FormData(); + formData.append('image', file); + + try { + const response = await fetch('/api/upload', { + method: 'POST', + body: formData, + }); + + if (response.ok) { + const data = await response.json(); + onUpload(data); + } + } catch (error) { + console.error('Upload failed:', error); + } finally { + setUploading(false); + } + }; + + const handleDrop = (e) => { + e.preventDefault(); + setIsDragging(false); + const file = e.dataTransfer.files[0]; + handleFile(file); + }; + + const handleDragOver = (e) => { + e.preventDefault(); + setIsDragging(true); + }; + + const handleDragLeave = () => { + setIsDragging(false); + }; + + return ( +
+

Upload Image

+
fileInputRef.current?.click()} + > + {uploading ? ( +
+
+

Uploading...

+
+ ) : ( + <> +
๐Ÿ“
+

Drop image here or click to upload

+

PNG, JPG, WEBP up to 20MB

+ + )} + handleFile(e.target.files[0])} + style={{ display: 'none' }} + /> +
+
+ ); +} diff --git a/client/src/components/sidebar/index.js b/client/src/components/sidebar/index.js new file mode 100644 index 0000000..216fdb5 --- /dev/null +++ b/client/src/components/sidebar/index.js @@ -0,0 +1,4 @@ +export { Sidebar } from './Sidebar'; +export { UploadTab } from './UploadTab'; +export { StickersTab } from './StickersTab'; +export { TextTab } from './TextTab'; diff --git a/client/src/index.css b/client/src/index.css index fb6de52..1790f68 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -66,3 +66,448 @@ button { input, textarea, select { font-family: inherit; } + +/* App Layout - Three Column */ +.app-layout { + display: flex; + min-height: 100vh; + background: var(--bg-secondary); +} + +.sidebar-container { + width: 280px; + background: var(--bg-primary); + border-right: 1px solid var(--border); + flex-shrink: 0; + overflow-y: auto; +} + +.canvas-container { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + padding: 2rem; + overflow-y: auto; +} + +.properties-container { + width: 260px; + background: var(--bg-primary); + border-left: 1px solid var(--border); + flex-shrink: 0; + overflow-y: auto; +} + +.app-title { + font-size: 1.5rem; + font-weight: 600; + margin: 0 0 0.5rem 0; + color: var(--text-primary); +} + +.app-subtitle { + color: var(--text-secondary); + margin: 0 0 1.5rem 0; + font-size: 0.875rem; +} + +.canvas-wrapper { + margin-bottom: 1rem; +} + +.debug-info { + padding: 1rem; + background: var(--bg-tertiary); + border-radius: var(--radius-md); + font-size: 0.875rem; + text-align: center; +} + +.debug-info p { + margin: 0.25rem 0; +} + +.debug-info .tip { + color: var(--text-muted); + font-size: 12px; +} + +/* Sidebar */ +.sidebar { + display: flex; + flex-direction: column; + height: 100%; +} + +.sidebar-tabs { + display: flex; + border-bottom: 1px solid var(--border); + background: var(--bg-tertiary); +} + +.sidebar-tab { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + padding: 0.75rem 0.5rem; + background: transparent; + border: none; + border-bottom: 2px solid transparent; + cursor: pointer; + transition: all 0.2s; + font-size: 0.75rem; + color: var(--text-secondary); +} + +.sidebar-tab:hover { + background: var(--bg-primary); +} + +.sidebar-tab.active { + border-bottom-color: var(--accent); + color: var(--text-primary); +} + +.tab-icon { + font-size: 1.25rem; + margin-bottom: 0.25rem; +} + +.tab-label { + font-size: 0.625rem; +} + +.sidebar-content { + padding: 1rem; + flex: 1; +} + +/* Upload Tab */ +.upload-tab h3 { + font-size: 0.875rem; + margin: 0 0 1rem 0; + color: var(--text-primary); +} + +.upload-zone { + border: 2px dashed var(--border); + border-radius: var(--radius-md); + padding: 2rem 1rem; + text-align: center; + cursor: pointer; + transition: all 0.2s; + background: var(--bg-secondary); +} + +.upload-zone:hover { + border-color: var(--accent); + background: var(--accent-bg); +} + +.upload-zone.dragging { + border-color: var(--accent); + background: var(--accent-bg); +} + +.upload-zone.uploading { + opacity: 0.7; + pointer-events: none; +} + +.upload-icon { + font-size: 2.5rem; + margin-bottom: 0.5rem; +} + +.upload-zone p { + margin: 0.25rem 0; + font-size: 0.875rem; + color: var(--text-secondary); +} + +.upload-hint { + font-size: 0.75rem !important; + color: var(--text-muted) !important; +} + +.uploading-state { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; +} + +.spinner { + width: 24px; + height: 24px; + border: 2px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* Stickers Tab */ +.stickers-tab h3 { + font-size: 0.875rem; + margin: 0 0 1rem 0; +} + +.category-pills { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.category-pill { + padding: 0.25rem 0.75rem; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-secondary); + font-size: 0.75rem; + cursor: pointer; + transition: all 0.2s; +} + +.category-pill:hover { + border-color: var(--accent); +} + +.category-pill.active { + background: var(--accent); + border-color: var(--accent); + color: white; +} + +.sticker-grid { + display: grid; + grid-template-columns: repeat(6, 1fr); + gap: 0.5rem; + max-height: 400px; + overflow-y: auto; +} + +.sticker-button { + font-size: 1.5rem; + padding: 0.5rem; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-primary); + cursor: pointer; + transition: all 0.2s; +} + +.sticker-button:hover { + transform: scale(1.1); + border-color: var(--accent); +} + +/* Text Tab */ +.text-tab h3 { + font-size: 0.875rem; + margin: 0 0 1rem 0; +} + +.text-input-group { + margin-bottom: 1rem; +} + +.text-input-group label { + display: block; + font-size: 0.75rem; + color: var(--text-secondary); + margin-bottom: 0.25rem; +} + +.text-input, +.font-select { + width: 100%; + padding: 0.5rem; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + font-size: 0.875rem; +} + +.text-input:focus, +.font-select:focus { + outline: none; + border-color: var(--accent); +} + +.size-slider { + width: 100%; +} + +.color-picker-row { + display: flex; + gap: 0.5rem; +} + +.color-picker { + width: 40px; + height: 36px; + padding: 0; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + cursor: pointer; +} + +.color-input { + flex: 1; + padding: 0.5rem; + border: 1px solid var(--border); + border-radius: var(--radius-sm); +} + +.add-text-btn { + width: 100%; + padding: 0.75rem; + background: var(--accent); + color: white; + border: none; + border-radius: var(--radius-md); + font-weight: 500; + cursor: pointer; + transition: background 0.2s; +} + +.add-text-btn:hover { + background: var(--accent-hover); +} + +.text-preview { + margin-top: 1rem; + padding: 1rem; + background: var(--bg-secondary); + border-radius: var(--radius-md); + text-align: center; +} + +/* Properties Panel */ +.properties-panel { + padding: 1rem; +} + +.properties-panel h3 { + font-size: 0.875rem; + margin: 0 0 1rem 0; + color: var(--text-primary); +} + +.no-selection { + padding: 2rem 1rem; + text-align: center; + color: var(--text-muted); + font-size: 0.875rem; +} + +.element-header { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem; + background: var(--bg-tertiary); + border-radius: var(--radius-md); + margin-bottom: 1rem; +} + +.element-icon { + font-size: 1.25rem; +} + +.element-name { + font-weight: 500; + font-size: 0.875rem; +} + +.property-group { + margin-bottom: 1rem; +} + +.property-group label { + display: block; + font-size: 0.75rem; + color: var(--text-secondary); + margin-bottom: 0.5rem; +} + +.property-row { + display: flex; + gap: 0.5rem; +} + +.property-input { + flex: 1; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.property-label { + font-size: 0.75rem; + color: var(--text-muted); + min-width: 12px; +} + +.property-input input { + flex: 1; + padding: 0.5rem; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + font-size: 0.875rem; + text-align: center; +} + +.property-input input:focus { + outline: none; + border-color: var(--accent); +} + +.rotation-slider { + width: 100%; +} + +.delete-btn { + width: 100%; + padding: 0.75rem; + background: transparent; + color: var(--error); + border: 1px solid var(--error); + border-radius: var(--radius-md); + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.delete-btn:hover { + background: var(--error); + color: white; +} + +/* Responsive */ +@media (max-width: 900px) { + .app-layout { + flex-direction: column; + } + + .sidebar-container, + .properties-container { + width: 100%; + border: none; + border-bottom: 1px solid var(--border); + } + + .sidebar-tabs { + overflow-x: auto; + } + + .sticker-grid { + grid-template-columns: repeat(8, 1fr); + } +} From 4a735e2f2e637a6da7dc9e71d43c635b7826ba8a Mon Sep 17 00:00:00 2001 From: Khalid A Date: Tue, 21 Apr 2026 01:19:03 -0500 Subject: [PATCH 2/5] Phase 4: Background Removal (Transformers.js) - Added @xenova/transformers dependency - useBackgroundRemoval hook with RMBG-1.4 model - Client-side background removal with progress indicator - Background removal button in properties panel (image elements only) - ~170MB model cached after first download Co-Authored-By: Claude Sonnet 4.6 --- client/package.json | 3 +- .../components/properties/PropertiesPanel.jsx | 9 ++ .../sidebar/BackgroundRemovalButton.jsx | 47 +++++++ client/src/hooks/useBackgroundRemoval.js | 120 ++++++++++++++++++ client/src/index.css | 48 +++++++ 5 files changed, 226 insertions(+), 1 deletion(-) create mode 100644 client/src/components/sidebar/BackgroundRemovalButton.jsx create mode 100644 client/src/hooks/useBackgroundRemoval.js diff --git a/client/package.json b/client/package.json index 30b0b2b..08a7785 100644 --- a/client/package.json +++ b/client/package.json @@ -14,7 +14,8 @@ "react-dom": "^19.2.5", "react-konva": "^18.2.10", "konva": "^9.3.18", - "use-image": "^1.1.1" + "use-image": "^1.1.1", + "@xenova/transformers": "^2.17.2" }, "devDependencies": { "@eslint/js": "^9.39.4", diff --git a/client/src/components/properties/PropertiesPanel.jsx b/client/src/components/properties/PropertiesPanel.jsx index 7b9140e..c9b46fb 100644 --- a/client/src/components/properties/PropertiesPanel.jsx +++ b/client/src/components/properties/PropertiesPanel.jsx @@ -1,3 +1,5 @@ +import { BackgroundRemovalButton } from '../sidebar/BackgroundRemovalButton'; + export function PropertiesPanel({ selectedElement, onUpdate, onDelete }) { if (!selectedElement) { return ( @@ -101,6 +103,13 @@ export function PropertiesPanel({ selectedElement, onUpdate, onDelete }) { />
+ {selectedElement.type === 'image' && ( + + )} + diff --git a/client/src/components/sidebar/BackgroundRemovalButton.jsx b/client/src/components/sidebar/BackgroundRemovalButton.jsx new file mode 100644 index 0000000..0f93cf7 --- /dev/null +++ b/client/src/components/sidebar/BackgroundRemovalButton.jsx @@ -0,0 +1,47 @@ +import { useBackgroundRemoval } from '../../hooks/useBackgroundRemoval'; + +export function BackgroundRemovalButton({ selectedElement, onUpdate }) { + const { loading, progress, hasModel, loadModel, removeBackground } = useBackgroundRemoval(); + + const handleRemoveBackground = async () => { + if (!selectedElement || selectedElement.type !== 'image') return; + + if (!hasModel) { + const loaded = await loadModel(); + if (!loaded) return; + } + + const resultUrl = await removeBackground(selectedElement.src); + if (resultUrl) { + onUpdate(selectedElement.id, { src: resultUrl }); + } + }; + + if (!selectedElement || selectedElement.type !== 'image') { + return null; + } + + return ( +
+ + {!hasModel && ( +

+ First use requires downloading ~170MB model. Subsequent uses are cached. +

+ )} +
+ ); +} diff --git a/client/src/hooks/useBackgroundRemoval.js b/client/src/hooks/useBackgroundRemoval.js new file mode 100644 index 0000000..5d2cd77 --- /dev/null +++ b/client/src/hooks/useBackgroundRemoval.js @@ -0,0 +1,120 @@ +import { useState, useCallback } from 'react'; +import { env, AutoModel, AutoProcessor, RawImage } from '@xenova/transformers'; + +// Use local models only +env.allowLocalModels = true; +env.useBrowserCache = true; + +export function useBackgroundRemoval() { + const [loading, setLoading] = useState(false); + const [progress, setProgress] = useState(0); + const [model, setModel] = useState(null); + const [processor, setProcessor] = useState(null); + + const loadModel = useCallback(async () => { + if (model && processor) return true; + + setLoading(true); + setProgress(0); + + try { + const loadedModel = await AutoModel.from_pretrained('Xenova/rmbg-1.4', { + progress_callback: (data) => { + if (data.status === 'progress') { + setProgress(Math.round(data.progress)); + } + }, + local_model_path: '/models/rmbg-1.4', + }); + + const loadedProcessor = await AutoProcessor.from_pretrained('Xenova/rmbg-1.4', { + local_model_path: '/models/rmbg-1.4', + }); + + setModel(loadedModel); + setProcessor(loadedProcessor); + setLoading(false); + return true; + } catch (error) { + console.error('Failed to load background removal model:', error); + setLoading(false); + return false; + } + }, [model, processor]); + + const removeBackground = useCallback(async (imageSrc) => { + if (!model || !processor) { + const loaded = await loadModel(); + if (!loaded) return null; + } + + setLoading(true); + + try { + // Load the image + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.src = imageSrc; + await new Promise((resolve, reject) => { + img.onload = resolve; + img.onerror = reject; + }); + + // Process image through the model + const inputs = await processor(img); + const { pixel_values } = inputs; + + // Run inference + const { output } = await model({ pixel_values }); + + // Get the mask + const maskData = await RawImage.fromTensor(output[0].mul(255).to('uint8')).resize( + img.width, + img.height + ); + + // Create canvas to apply mask + const canvas = document.createElement('canvas'); + canvas.width = img.width; + canvas.height = img.height; + const ctx = canvas.getContext('2d'); + + // Draw original image + ctx.drawImage(img, 0, 0); + + // Get image data + const imageData = ctx.getImageData(0, 0, img.width, img.height); + const data = imageData.data; + const maskPixels = maskData.data; + + // Apply alpha mask + for (let i = 0; i < maskPixels.length; i++) { + const alpha = maskPixels[i]; + data[i * 4 + 3] = alpha; // Set alpha channel + } + + ctx.putImageData(imageData, 0, 0); + + // Convert to blob URL + const blob = await new Promise((resolve) => { + canvas.toBlob(resolve, 'image/png'); + }); + + const url = URL.createObjectURL(blob); + setLoading(false); + return url; + } catch (error) { + console.error('Background removal failed:', error); + setLoading(false); + return null; + } + }, [model, processor, loadModel]); + + return { + loading, + progress, + hasModel: !!model, + loadModel, + removeBackground, + }; +} diff --git a/client/src/index.css b/client/src/index.css index 1790f68..e0f5827 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -490,6 +490,54 @@ input, textarea, select { color: white; } +.bg-removal-container { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--border); +} + +.bg-removal-btn { + width: 100%; + padding: 0.75rem; + background: linear-gradient(135deg, #8b5cf6, #ec4899); + color: white; + border: none; + border-radius: var(--radius-md); + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; +} + +.bg-removal-btn:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: var(--shadow-md); +} + +.bg-removal-btn:disabled { + opacity: 0.7; + cursor: not-allowed; +} + +.bg-removal-hint { + font-size: 0.7rem; + color: var(--text-muted); + margin: 0.5rem 0 0 0; + line-height: 1.4; +} + +.spinner-small { + width: 16px; + height: 16px; + border: 2px solid rgba(255, 255, 255, 0.3); + border-top-color: white; + border-radius: 50%; + animation: spin 1s linear infinite; +} + /* Responsive */ @media (max-width: 900px) { .app-layout { From 7bf9ce3a9c7780aa251c620bd8d3a9411f1113d3 Mon Sep 17 00:00:00 2001 From: Khalid A Date: Tue, 21 Apr 2026 01:20:11 -0500 Subject: [PATCH 3/5] Phase 5: Photo Pre-Editor (Filerobot) - Added react-filerobot-image-editor dependency - PhotoPreEditor component with full editing capabilities - Crop, filters, adjustments, annotations, watermark tabs - Opens after image upload, before adding to canvas - Exports edited image as PNG for canvas use Co-Authored-By: Claude Sonnet 4.6 --- client/package.json | 3 +- .../src/components/editor/PhotoPreEditor.jsx | 58 +++++++++++++++++++ client/src/components/editor/index.js | 1 + client/src/components/sidebar/UploadTab.jsx | 33 +++++++++-- client/src/index.css | 22 +++++++ 5 files changed, 112 insertions(+), 5 deletions(-) create mode 100644 client/src/components/editor/PhotoPreEditor.jsx create mode 100644 client/src/components/editor/index.js diff --git a/client/package.json b/client/package.json index 08a7785..fbd370a 100644 --- a/client/package.json +++ b/client/package.json @@ -15,7 +15,8 @@ "react-konva": "^18.2.10", "konva": "^9.3.18", "use-image": "^1.1.1", - "@xenova/transformers": "^2.17.2" + "@xenova/transformers": "^2.17.2", + "react-filerobot-image-editor": "^4.8.1" }, "devDependencies": { "@eslint/js": "^9.39.4", diff --git a/client/src/components/editor/PhotoPreEditor.jsx b/client/src/components/editor/PhotoPreEditor.jsx new file mode 100644 index 0000000..7167c41 --- /dev/null +++ b/client/src/components/editor/PhotoPreEditor.jsx @@ -0,0 +1,58 @@ +import { useState } from 'react'; +import FilerobotImageEditor from 'react-filerobot-image-editor'; + +export function PhotoPreEditor({ imageSrc, onComplete, onClose }) { + const [saving, setSaving] = useState(false); + + const handleComplete = (editedImageObject, designState) => { + setSaving(true); + + // Export the edited image + editedImageObject.exportAsync({ + quality: 1, + mimeType: 'image/png', + }).then((blob) => { + const url = URL.createObjectURL(blob); + setSaving(false); + onComplete(url); + }).catch((error) => { + console.error('Export failed:', error); + setSaving(false); + onClose(); + }); + }; + + return ( +
+
+ +
+
+ ); +} diff --git a/client/src/components/editor/index.js b/client/src/components/editor/index.js new file mode 100644 index 0000000..d18f55a --- /dev/null +++ b/client/src/components/editor/index.js @@ -0,0 +1 @@ +export { PhotoPreEditor } from './PhotoPreEditor'; diff --git a/client/src/components/sidebar/UploadTab.jsx b/client/src/components/sidebar/UploadTab.jsx index 51325eb..4dbab0c 100644 --- a/client/src/components/sidebar/UploadTab.jsx +++ b/client/src/components/sidebar/UploadTab.jsx @@ -1,9 +1,11 @@ -import { useRef, useState } from 'react'; +import { useState } from 'react'; +import { PhotoPreEditor } from '../editor/PhotoPreEditor'; export function UploadTab({ onUpload }) { - const fileInputRef = useRef(null); + const fileInputRef = useState(null)[0]; const [isDragging, setIsDragging] = useState(false); const [uploading, setUploading] = useState(false); + const [editingImage, setEditingImage] = useState(null); const handleFile = async (file) => { if (!file || !file.type.startsWith('image/')) return; @@ -20,7 +22,8 @@ export function UploadTab({ onUpload }) { if (response.ok) { const data = await response.json(); - onUpload(data); + // Open photo editor with uploaded image + setEditingImage(data.original.url); } } catch (error) { console.error('Upload failed:', error); @@ -45,6 +48,25 @@ export function UploadTab({ onUpload }) { setIsDragging(false); }; + const handleEditorComplete = (editedImageUrl) => { + onUpload({ preview: { url: editedImageUrl } }); + setEditingImage(null); + }; + + const handleEditorClose = () => { + setEditingImage(null); + }; + + if (editingImage) { + return ( + + ); + } + return (

Upload Image

@@ -53,7 +75,7 @@ export function UploadTab({ onUpload }) { onDrop={handleDrop} onDragOver={handleDragOver} onDragLeave={handleDragLeave} - onClick={() => fileInputRef.current?.click()} + onClick={() => fileInputRef?.click()} > {uploading ? (
@@ -65,6 +87,9 @@ export function UploadTab({ onUpload }) {
๐Ÿ“

Drop image here or click to upload

PNG, JPG, WEBP up to 20MB

+

+ Edit with crop, filters, and effects before adding to design +

)} Date: Tue, 21 Apr 2026 01:24:03 -0500 Subject: [PATCH 4/5] Phase 6: Template System - Added 8 pre-designed templates across 8 categories - Templates: Team Sport, Band Merch, Minimal Quote, Funny Cat, Gradient Vibes, Vintage Badge, Nature Lover, Tech Geek - Templates tab with category filter pills - Template preview cards with 2-column grid - One-click template application to canvas Co-Authored-By: Claude Sonnet 4.6 --- client/src/App.jsx | 9 +- client/src/components/sidebar/Sidebar.jsx | 6 +- .../src/components/sidebar/TemplatesTab.jsx | 56 ++++ client/src/components/sidebar/index.js | 1 + client/src/constants/templates.js | 309 ++++++++++++++++++ client/src/index.css | 66 ++++ 6 files changed, 445 insertions(+), 2 deletions(-) create mode 100644 client/src/components/sidebar/TemplatesTab.jsx create mode 100644 client/src/constants/templates.js diff --git a/client/src/App.jsx b/client/src/App.jsx index f21be5b..5b205d6 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -66,11 +66,18 @@ function App() { } }; + const handleApplyTemplate = (template) => { + // Clear existing elements and apply template + template.elements.forEach((el, index) => { + setTimeout(() => addElement({ ...el }), index * 50); + }); + }; + return (
{/* Left Sidebar */} {/* Center Canvas */} diff --git a/client/src/components/sidebar/Sidebar.jsx b/client/src/components/sidebar/Sidebar.jsx index 0f5e806..80dd71d 100644 --- a/client/src/components/sidebar/Sidebar.jsx +++ b/client/src/components/sidebar/Sidebar.jsx @@ -2,14 +2,16 @@ import { useState } from 'react'; import { UploadTab } from './UploadTab'; import { StickersTab } from './StickersTab'; import { TextTab } from './TextTab'; +import { TemplatesTab } from './TemplatesTab'; const TABS = [ { id: 'upload', label: 'Upload', icon: '๐Ÿ“' }, { id: 'stickers', label: 'Stickers', icon: '๐Ÿ˜Š' }, { id: 'text', label: 'Text', icon: 'T' }, + { id: 'templates', label: 'Templates', icon: '๐Ÿ“‹' }, ]; -export function Sidebar({ onElementAdd, onUpload }) { +export function Sidebar({ onElementAdd, onUpload, onApplyTemplate }) { const [activeTab, setActiveTab] = useState('upload'); const renderTabContent = () => { @@ -20,6 +22,8 @@ export function Sidebar({ onElementAdd, onUpload }) { return ; case 'text': return ; + case 'templates': + return ; default: return null; } diff --git a/client/src/components/sidebar/TemplatesTab.jsx b/client/src/components/sidebar/TemplatesTab.jsx new file mode 100644 index 0000000..7a3e913 --- /dev/null +++ b/client/src/components/sidebar/TemplatesTab.jsx @@ -0,0 +1,56 @@ +import { useState } from 'react'; +import { TEMPLATES, TEMPLATE_CATEGORIES } from '../../constants/templates'; + +export function TemplatesTab({ onApplyTemplate }) { + const [selectedCategory, setSelectedCategory] = useState('All'); + + const filteredTemplates = + selectedCategory === 'All' + ? TEMPLATES + : TEMPLATES.filter((t) => t.category === selectedCategory); + + return ( +
+

Templates

+
+ {TEMPLATE_CATEGORIES.map((cat) => ( + + ))} +
+
+ {filteredTemplates.map((template) => ( + + ))} +
+
+ ); +} diff --git a/client/src/components/sidebar/index.js b/client/src/components/sidebar/index.js index 216fdb5..d983c43 100644 --- a/client/src/components/sidebar/index.js +++ b/client/src/components/sidebar/index.js @@ -2,3 +2,4 @@ export { Sidebar } from './Sidebar'; export { UploadTab } from './UploadTab'; export { StickersTab } from './StickersTab'; export { TextTab } from './TextTab'; +export { TemplatesTab } from './TemplatesTab'; diff --git a/client/src/constants/templates.js b/client/src/constants/templates.js new file mode 100644 index 0000000..f2d3969 --- /dev/null +++ b/client/src/constants/templates.js @@ -0,0 +1,309 @@ +// Pre-designed templates for t-shirt customization +export const TEMPLATES = [ + { + id: 'team-sport', + name: 'Team Sport', + category: 'Sports', + description: 'Classic team jersey with number and text', + elements: [ + { + type: 'text', + text: 'TEAM NAME', + x: 75, + y: 80, + fontSize: 28, + fontFamily: 'Impact', + fill: '#ffffff', + rotation: 0, + }, + { + type: 'text', + text: '23', + x: 150, + y: 150, + fontSize: 72, + fontFamily: 'Impact', + fill: '#ffffff', + rotation: 0, + }, + ], + }, + { + id: 'band-merch', + name: 'Band Merch', + category: 'Music', + description: 'Classic band t-shirt design', + elements: [ + { + type: 'text', + text: 'BAND NAME', + x: 150, + y: 70, + fontSize: 32, + fontFamily: 'Georgia', + fill: '#fbbf24', + rotation: 0, + }, + { + type: 'text', + text: 'WORLD TOUR 2026', + x: 150, + y: 110, + fontSize: 16, + fontFamily: 'Arial', + fill: '#ffffff', + rotation: 0, + }, + { + type: 'text', + text: '๐ŸŽธ', + x: 150, + y: 180, + fontSize: 64, + fontFamily: 'Arial', + fill: '#ffffff', + rotation: 0, + }, + ], + }, + { + id: 'minimal-quote', + name: 'Minimal Quote', + category: 'Quotes', + description: 'Simple centered quote design', + elements: [ + { + type: 'text', + text: '"Be the change"', + x: 150, + y: 130, + fontSize: 24, + fontFamily: 'Georgia', + fill: '#1e293b', + rotation: 0, + }, + { + type: 'text', + text: 'you wish to see', + x: 150, + y: 160, + fontSize: 18, + fontFamily: 'Arial', + fill: '#64748b', + rotation: 0, + }, + ], + }, + { + id: 'funny-cat', + name: 'Funny Cat', + category: 'Animals', + description: 'Cute cat with funny text', + elements: [ + { + type: 'text', + text: '๐Ÿ˜ผ', + x: 150, + y: 100, + fontSize: 80, + fontFamily: 'Arial', + fill: '#000000', + rotation: 0, + }, + { + type: 'text', + text: 'I do what I want', + x: 150, + y: 200, + fontSize: 20, + fontFamily: 'Comic Sans MS', + fill: '#475569', + rotation: 0, + }, + ], + }, + { + id: 'gradient-vibes', + name: 'Gradient Vibes', + category: 'Abstract', + description: 'Modern gradient text design', + elements: [ + { + type: 'text', + text: 'GOOD', + x: 150, + y: 110, + fontSize: 48, + fontFamily: 'Impact', + fill: '#ec4899', + rotation: -5, + }, + { + type: 'text', + text: 'VIBES', + x: 150, + y: 160, + fontSize: 48, + fontFamily: 'Impact', + fill: '#8b5cf6', + rotation: 5, + }, + { + type: 'text', + text: 'โœจ', + x: 80, + y: 90, + fontSize: 32, + fontFamily: 'Arial', + fill: '#fbbf24', + rotation: 0, + }, + { + type: 'text', + text: '๐ŸŒ™', + x: 220, + y: 190, + fontSize: 32, + fontFamily: 'Arial', + fill: '#38bdf8', + rotation: 0, + }, + ], + }, + { + id: 'vintage-badge', + name: 'Vintage Badge', + category: 'Vintage', + description: 'Retro badge style design', + elements: [ + { + type: 'text', + text: 'EST.', + x: 150, + y: 80, + fontSize: 18, + fontFamily: 'Times New Roman', + fill: '#78716c', + rotation: 0, + }, + { + type: 'text', + text: '2026', + x: 150, + y: 105, + fontSize: 36, + fontFamily: 'Times New Roman', + fill: '#78716c', + rotation: 0, + }, + { + type: 'text', + text: 'AUTHENTIC', + x: 150, + y: 150, + fontSize: 24, + fontFamily: 'Times New Roman', + fill: '#78716c', + rotation: 0, + }, + { + type: 'text', + text: 'QUALITY', + x: 150, + y: 180, + fontSize: 24, + fontFamily: 'Times New Roman', + fill: '#78716c', + rotation: 0, + }, + ], + }, + { + id: 'nature-lover', + name: 'Nature Lover', + category: 'Nature', + description: 'Mountain and nature themed', + elements: [ + { + type: 'text', + text: '๐Ÿ”๏ธ', + x: 150, + y: 90, + fontSize: 56, + fontFamily: 'Arial', + fill: '#000000', + rotation: 0, + }, + { + type: 'text', + text: 'ADVENTURE', + x: 150, + y: 160, + fontSize: 28, + fontFamily: 'Impact', + fill: '#059669', + rotation: 0, + }, + { + type: 'text', + text: 'AWAITS', + x: 150, + y: 190, + fontSize: 20, + fontFamily: 'Arial', + fill: '#6b7280', + rotation: 0, + }, + ], + }, + { + id: 'tech-geek', + name: 'Tech Geek', + category: 'Tech', + description: 'Programming themed design', + elements: [ + { + type: 'text', + text: '', + x: 150, + y: 100, + fontSize: 64, + fontFamily: 'Courier New', + fill: '#3b82f6', + rotation: 0, + }, + { + type: 'text', + text: 'Hello, World!', + x: 150, + y: 170, + fontSize: 20, + fontFamily: 'Courier New', + fill: '#1e293b', + rotation: 0, + }, + { + type: 'text', + text: '// Code is life', + x: 150, + y: 195, + fontSize: 14, + fontFamily: 'Courier New', + fill: '#94a3b8', + rotation: 0, + }, + ], + }, +]; + +export const TEMPLATE_CATEGORIES = [ + 'All', + 'Sports', + 'Music', + 'Quotes', + 'Animals', + 'Abstract', + 'Vintage', + 'Nature', + 'Tech', +]; diff --git a/client/src/index.css b/client/src/index.css index d65746c..af023d6 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -389,6 +389,72 @@ input, textarea, select { text-align: center; } +/* Templates Tab */ +.templates-tab h3 { + font-size: 0.875rem; + margin: 0 0 1rem 0; +} + +.templates-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.75rem; + max-height: 400px; + overflow-y: auto; +} + +.template-card { + display: flex; + flex-direction: column; + padding: 0.75rem; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--bg-primary); + cursor: pointer; + transition: all 0.2s; + text-align: left; +} + +.template-card:hover { + border-color: var(--accent); + transform: translateY(-2px); + box-shadow: var(--shadow-md); +} + +.template-preview { + height: 60px; + background: var(--bg-tertiary); + border-radius: var(--radius-sm); + margin-bottom: 0.5rem; + display: flex; + align-items: center; + justify-content: center; + gap: 0.25rem; + flex-wrap: wrap; + padding: 0.25rem; +} + +.template-preview-element { + font-weight: 500; +} + +.template-info { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.template-name { + font-size: 0.75rem; + font-weight: 500; + color: var(--text-primary); +} + +.template-category { + font-size: 0.625rem; + color: var(--text-muted); +} + /* Properties Panel */ .properties-panel { padding: 1rem; From 537cfd572d6fb2a7c6aa3f3a8a54979ce682f6ae Mon Sep 17 00:00:00 2001 From: Khalid A Date: Tue, 21 Apr 2026 01:27:51 -0500 Subject: [PATCH 5/5] Phase 7: Undo/Redo - History tracking with 50-state limit in useDesignEditor hook - Undo: Ctrl/Cmd + Z keyboard shortcut - Redo: Ctrl/Cmd + Shift + Z or Ctrl/Cmd + Y - Undo/Redo buttons in canvas header - History saves state after add, update, delete, reorder operations - Disabled button states when history is exhausted Co-Authored-By: Claude Sonnet 4.6 --- client/src/App.jsx | 59 +++++++++++++++++-- client/src/hooks/useDesignEditor.js | 89 ++++++++++++++++++++++++++--- client/src/index.css | 45 ++++++++++++++- 3 files changed, 178 insertions(+), 15 deletions(-) diff --git a/client/src/App.jsx b/client/src/App.jsx index 5b205d6..7bb8b82 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -13,16 +13,45 @@ function App() { deleteElement, selectElement, deselectAll, + undo, + redo, + canUndo, + canRedo, + initializeHistory, } = useDesignEditor(); const selectedElement = elements.find((el) => el.id === selectedId); - // Keyboard shortcut: Delete/Backspace removes selected element + // Initialize history on mount + useEffect(() => { + initializeHistory(); + }, [initializeHistory]); + + // Keyboard shortcuts useEffect(() => { const handleKeyDown = (e) => { if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') { return; } + + // Undo: Ctrl/Cmd + Z + if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) { + e.preventDefault(); + if (canUndo) undo(); + return; + } + + // Redo: Ctrl/Cmd + Shift + Z or Ctrl/Cmd + Y + if ((e.ctrlKey || e.metaKey) && ( + (e.key === 'z' && e.shiftKey) || + e.key === 'y' + )) { + e.preventDefault(); + if (canRedo) redo(); + return; + } + + // Delete/Backspace removes selected element if (e.key === 'Delete' || e.key === 'Backspace') { if (selectedId) { deleteElement(selectedId); @@ -32,7 +61,7 @@ function App() { window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, [selectedId, deleteElement]); + }, [selectedId, deleteElement, undo, redo, canUndo, canRedo]); const handleUpload = (data) => { if (data.preview?.url) { @@ -82,8 +111,30 @@ function App() { {/* Center Canvas */}
-

Apparel Designer

-

T-shirt customization editor

+
+
+

Apparel Designer

+

T-shirt customization editor

+
+
+ + +
+
{ + // Remove any future history if we're in the middle of the stack + if (historyIndexRef.current < historyRef.current.length - 1) { + historyRef.current = historyRef.current.slice(0, historyIndexRef.current + 1); + } + + // Add new state to history + historyRef.current.push(JSON.stringify(newElements)); + + // Limit history size + if (historyRef.current.length > MAX_HISTORY) { + historyRef.current.shift(); + } else { + historyIndexRef.current++; + } + }, []); + + const canUndo = historyIndexRef.current > 0; + const canRedo = historyIndexRef.current < historyRef.current.length - 1; + const addElement = useCallback((element) => { const newElement = { ...element, id: `element-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, }; - setElements((prev) => [...prev, newElement]); + + setElements((prev) => { + const newElements = [...prev, newElement]; + saveToHistory(newElements); + return newElements; + }); + setSelectedId(newElement.id); return newElement.id; - }, []); + }, [saveToHistory]); const updateElement = useCallback((id, attrs) => { - setElements((prev) => - prev.map((el) => (el.id === id ? { ...el, ...attrs } : el)) - ); - }, []); + setElements((prev) => { + const newElements = prev.map((el) => (el.id === id ? { ...el, ...attrs } : el)); + saveToHistory(newElements); + return newElements; + }); + }, [saveToHistory]); const deleteElement = useCallback((id) => { - setElements((prev) => prev.filter((el) => el.id !== id)); + setElements((prev) => { + const newElements = prev.filter((el) => el.id !== id); + saveToHistory(newElements); + return newElements; + }); + if (selectedId === id) { setSelectedId(null); } - }, [selectedId]); + }, [selectedId, saveToHistory]); const selectElement = useCallback((id) => { setSelectedId(id); @@ -39,11 +78,38 @@ export function useDesignEditor() { setElements((prev) => { const index = prev.findIndex((el) => el.id === id); if (index === -1 || index === newOrder) return prev; + const newElements = [...prev]; const [removed] = newElements.splice(index, 1); newElements.splice(newOrder, 0, removed); + + saveToHistory(newElements); return newElements; }); + }, [saveToHistory]); + + const undo = useCallback(() => { + if (historyIndexRef.current > 0) { + historyIndexRef.current--; + const prevState = JSON.parse(historyRef.current[historyIndexRef.current]); + setElements(prevState); + setSelectedId(null); + } + }, []); + + const redo = useCallback(() => { + if (historyIndexRef.current < historyRef.current.length - 1) { + historyIndexRef.current++; + const nextState = JSON.parse(historyRef.current[historyIndexRef.current]); + setElements(nextState); + setSelectedId(null); + } + }, []); + + // Initialize history with empty state + const initializeHistory = useCallback(() => { + historyRef.current = [JSON.stringify([])]; + historyIndexRef.current = 0; }, []); return { @@ -55,5 +121,10 @@ export function useDesignEditor() { selectElement, deselectAll, reorderElement, + undo, + redo, + canUndo, + canRedo, + initializeHistory, }; } diff --git a/client/src/index.css b/client/src/index.css index af023d6..e2513c5 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -102,16 +102,57 @@ input, textarea, select { .app-title { font-size: 1.5rem; font-weight: 600; - margin: 0 0 0.5rem 0; + margin: 0 0 0.25rem 0; color: var(--text-primary); } .app-subtitle { color: var(--text-secondary); - margin: 0 0 1.5rem 0; + margin: 0; font-size: 0.875rem; } +.canvas-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 1rem; + width: 100%; + max-width: 400px; +} + +.undo-redo-buttons { + display: flex; + gap: 0.5rem; +} + +.icon-btn { + padding: 0.5rem 0.75rem; + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + font-size: 0.75rem; + cursor: pointer; + transition: all 0.2s; + color: var(--text-secondary); +} + +.icon-btn:hover:not(:disabled) { + background: var(--accent); + border-color: var(--accent); + color: white; +} + +.icon-btn.disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.icon-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + .canvas-wrapper { margin-bottom: 1rem; }