ChunDe Claude commited on
Commit
044eef3
·
0 Parent(s):

Initial commit: HF Thumbnail Crafter v1.0

Browse files

Full-featured thumbnail creator for HuggingFace with React + TypeScript + Konva.

Features implemented:
- Canvas with multiple size presets (1200x675, LinkedIn, HF, X)
- Background color selector (light/dark)
- 4 layout templates (Serious Collab, Fun Collab, Sandwich, Docs)
- 44 Huggy assets from HuggingFace dataset with infinite scroll
- Text creation with inline editing and auto-sizing
- Multi-select with drag-to-select and Shift+Click
- Object manipulation (drag, scale, rotate, delete)
- Arrow key movement (1px/10px with Shift)
- Undo/Redo system (50 steps, Ctrl+Z/Ctrl+Shift+Z)
- Sidebar with toggle functionality and click-outside deselection
- Export functionality (PNG generation)

Technical stack:
- Vite + React 18 + TypeScript
- Konva.js for canvas manipulation
- TailwindCSS for styling
- Custom hooks for state management

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>

This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .claude/settings.local.json +35 -0
  2. .gitattributes +2 -0
  3. .gitignore +27 -0
  4. PROJECT_PLAN.md +955 -0
  5. SESSION_LOGS.md +331 -0
  6. index.html +18 -0
  7. package-lock.json +0 -0
  8. package.json +28 -0
  9. postcss.config.js +6 -0
  10. public/assets/layouts/HF logo.png +3 -0
  11. public/assets/layouts/collabX.svg +3 -0
  12. public/assets/layouts/docsHFLogo.png +3 -0
  13. public/assets/layouts/docs_thumbnail.png +3 -0
  14. public/assets/layouts/fCollab_huggy_asset.png +3 -0
  15. public/assets/layouts/fCollab_huggy_hand_asset.png +3 -0
  16. public/assets/layouts/fCollab_thumbnail.png +3 -0
  17. public/assets/layouts/logo_placehoder.png +3 -0
  18. public/assets/layouts/sCollab_thumbnail.png +3 -0
  19. public/assets/layouts/sandwitch_thumbnail.png +3 -0
  20. public/assets/layouts/snadwithc_huggy_asset.png +3 -0
  21. public/fonts/Bison-Bold(PersonalUse).ttf +0 -0
  22. src/App.tsx +375 -0
  23. src/assets/icons/huggy-body.svg +7 -0
  24. src/assets/icons/huggy-eyes.svg +6 -0
  25. src/assets/icons/huggy-hands.svg +6 -0
  26. src/assets/icons/huggy-mouth.svg +6 -0
  27. src/assets/icons/image-default.svg +6 -0
  28. src/assets/icons/image-selected.svg +6 -0
  29. src/assets/icons/layout-default.svg +3 -0
  30. src/assets/icons/layout-selected.svg +3 -0
  31. src/assets/icons/text-default.svg +3 -0
  32. src/assets/icons/text-selected.svg +3 -0
  33. src/components/Canvas/Canvas.tsx +595 -0
  34. src/components/Canvas/CanvasContainer.tsx +30 -0
  35. src/components/Canvas/CanvasObject.tsx +194 -0
  36. src/components/Canvas/TextEditable.tsx +136 -0
  37. src/components/CanvasHeader/BgColorSelector.tsx +83 -0
  38. src/components/CanvasHeader/CanvasHeader.tsx +33 -0
  39. src/components/CanvasHeader/CanvasSizeSelector.tsx +100 -0
  40. src/components/ConfirmationDialog/ConfirmationDialog.tsx +133 -0
  41. src/components/ExportButton/ExportButton.tsx +38 -0
  42. src/components/Icons/IconBgDark.tsx +14 -0
  43. src/components/Icons/IconBgLight.tsx +14 -0
  44. src/components/Icons/IconHFSize.tsx +63 -0
  45. src/components/Icons/IconHuggy.tsx +76 -0
  46. src/components/Icons/IconImage.tsx +27 -0
  47. src/components/Icons/IconLayout.tsx +27 -0
  48. src/components/Icons/IconLinkedInSize.tsx +42 -0
  49. src/components/Icons/IconText.tsx +27 -0
  50. src/components/Icons/IconXSize.tsx +27 -0
.claude/settings.local.json ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(claude mcp add:*)",
5
+ "mcp__figma__get_screenshot",
6
+ "mcp__figma__get_metadata",
7
+ "mcp__figma__get_design_context",
8
+ "mcp__figma__get_variable_defs",
9
+ "Bash(npm create:*)",
10
+ "Bash(dir:*)",
11
+ "Bash(npm install)",
12
+ "Bash(npm run dev:*)",
13
+ "Bash(timeout /t 3)",
14
+ "Bash(ping:*)",
15
+ "Bash(nul)",
16
+ "mcp__figma-desktop__get_screenshot",
17
+ "mcp__figma-desktop__get_metadata",
18
+ "mcp__figma-desktop__get_design_context",
19
+ "Bash(npm run build:*)",
20
+ "Bash(curl:*)",
21
+ "Bash(if not exist \"src\\assets\" mkdir \"src\\assets\")",
22
+ "WebFetch(domain:localhost)",
23
+ "Bash(del \"C:\\HuggingFace\\Huggy thumbnail crafter\\src\\data\\layouts.ts\")",
24
+ "Bash(unzip:*)",
25
+ "WebSearch",
26
+ "Bash(if not exist \"src\\assets\\layouts\" mkdir \"src\\assets\\layouts\")",
27
+ "Bash(if not exist \"src\\assets\\layouts\\thumbnails\" mkdir \"src\\assets\\layouts\\thumbnails\")",
28
+ "Bash(if not exist \"public\\assets\\layouts\\huggys\" mkdir \"public\\assets\\layouts\\huggys\")",
29
+ "Bash(git init:*)",
30
+ "Bash(git add:*)"
31
+ ],
32
+ "deny": [],
33
+ "ask": []
34
+ }
35
+ }
.gitattributes ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ dist/**/*.png filter=lfs diff=lfs merge=lfs -text
2
+ public/**/*.png filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Logs
2
+ logs
3
+ *.log
4
+ npm-debug.log*
5
+ yarn-debug.log*
6
+ yarn-error.log*
7
+ pnpm-debug.log*
8
+ lerna-debug.log*
9
+
10
+ node_modules
11
+ dist
12
+ dist-ssr
13
+ *.local
14
+
15
+ # Editor directories and files
16
+ .vscode/*
17
+ !.vscode/extensions.json
18
+ .idea
19
+ .DS_Store
20
+ *.suo
21
+ *.ntvs*
22
+ *.njsproj
23
+ *.sln
24
+ *.sw?
25
+
26
+ # Windows reserved names
27
+ nul
PROJECT_PLAN.md ADDED
@@ -0,0 +1,955 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # HF Thumbnail Crafter - Project Plan
2
+
3
+ ## Project Overview
4
+ A web-based thumbnail creator for HuggingFace users to craft quick thumbnails with pre-made layouts and visual assets (Huggys). The project will be hosted on HuggingFace Spaces as a static site.
5
+
6
+ ## Design Resources
7
+ - **Figma File:** https://www.figma.com/design/b7rA38IJxS0sK8ppXE8Gjb/HF-Thumbnail-Crafter?node-id=91-4463&t=oTLFdnJ8jDG93HCw-1
8
+ - **Figma MCP Connected:** Yes (http://127.0.0.1:3845/mcp)
9
+
10
+ ## Tech Stack Decisions
11
+
12
+ ### Core Framework
13
+ - **Build Tool:** Vite
14
+ - **Framework:** React 18
15
+ - **Language:** TypeScript
16
+ - **Canvas Library:** react-konva + Konva.js
17
+ - **Styling:** Tailwind CSS (Figma exports already in Tailwind format)
18
+ - **Icons:** Lucide React (placeholder until custom icons ready)
19
+ - **Fonts:** Inter (default), IBM Plex Mono
20
+ - **Deployment:** HuggingFace Spaces (Static Site)
21
+
22
+ ### Why These Choices?
23
+ - **React + Konva:** Excellent integration via react-konva library
24
+ - **Vite:** Fast, modern, lightweight - perfect for HF Spaces static deployment
25
+ - **TypeScript:** Type safety for complex canvas state management
26
+ - **Tailwind CSS:** Figma MCP exports Tailwind classes - zero conversion work needed, fast development
27
+
28
+ ## Key Requirements
29
+
30
+ ### 1. Floating Sidebar (Left)
31
+ Four buttons with icons and labels:
32
+ 1. **Layout** - Load pre-designed layout templates
33
+ 2. **Huggy** - Add visual assets from HuggingFace dataset
34
+ 3. **Image** - Upload local images or drag-and-drop
35
+ 4. **Text** - Add text objects to canvas
36
+
37
+ **Design specs:**
38
+ - Width: 87px
39
+ - Background: #f8f9fa
40
+ - Border radius: 10px
41
+ - Spacing: 15px between buttons
42
+ - Position: Fixed left, floating
43
+
44
+ ### 2. Canvas Area (Center)
45
+
46
+ #### Canvas Header
47
+ **Left side - Background Color Selector:**
48
+ - Label: "Background color:"
49
+ - Two options: Light (white) and Dark (purple/dark)
50
+ - Toggle between backgrounds
51
+
52
+ **Right side - Canvas Size Selector:**
53
+ - Label: "Size:"
54
+ - Three options:
55
+ 1. **1200×675** (default, X icon)
56
+ 2. **LinkedIn size** (LinkedIn icon)
57
+ 3. **HF custom size** (HF icon)
58
+
59
+ #### Canvas
60
+ - Default size: 1200×675px
61
+ - Background: Switchable (light/dark)
62
+ - Supports multiple object types: rectangles, images, text, Huggys
63
+
64
+ **Canvas Object Interactions:**
65
+ - ✅ Drag to reposition
66
+ - ✅ Proportional scaling (maintain aspect ratio)
67
+ - ✅ Rotate objects
68
+ - ✅ Layer ordering (bring to front/send to back)
69
+ - ✅ Delete objects (keyboard + button)
70
+ - Selection with bounding box and transformer handles
71
+
72
+ ### 3. Export Button (Top Right)
73
+ - Position: Fixed top-right
74
+ - Shows: Download icon + editable filename + ".png"
75
+ - Default filename: "thumbnail_name"
76
+ - Format: PNG only
77
+ - Functionality: Export canvas as PNG using Konva's toDataURL
78
+
79
+ ### 4. Screen Background
80
+ - Dotted pattern wallpaper
81
+ - Base color: #f8f9fa with dot pattern overlay
82
+
83
+ ## Feature Details
84
+
85
+ ### Layout Feature
86
+ **How it works:**
87
+ 1. Click "Layout" button → Shows layout selector menu
88
+ 2. Menu displays thumbnails of 6 pre-designed layouts:
89
+ - Serious Collab
90
+ - Fun Collab
91
+ - Sandwich
92
+ - Docs
93
+ - 1:2 (two variations)
94
+ 3. Click layout → Loads objects (shapes, text, images) onto canvas
95
+ 4. User edits from there
96
+
97
+ **Layout Implementation Workflow:**
98
+ 1. Designer creates layouts in Figma (for all 3 canvas sizes)
99
+ 2. Developer extracts layout specs using Figma MCP
100
+ 3. Convert to JSON format:
101
+ ```json
102
+ {
103
+ "id": "serious-collab",
104
+ "name": "Serious Collab",
105
+ "canvasSize": "1200x675",
106
+ "objects": [
107
+ {
108
+ "type": "rect",
109
+ "x": 100,
110
+ "y": 50,
111
+ "width": 200,
112
+ "height": 150,
113
+ "fill": "#000000",
114
+ "rotation": 0,
115
+ "zIndex": 1
116
+ },
117
+ {
118
+ "type": "text",
119
+ "x": 150,
120
+ "y": 300,
121
+ "text": "Title Here",
122
+ "fontSize": 100,
123
+ "fontFamily": "Inter",
124
+ "fill": "#000000",
125
+ "bold": false,
126
+ "italic": false,
127
+ "rotation": 0,
128
+ "zIndex": 2
129
+ }
130
+ ]
131
+ }
132
+ ```
133
+ 4. Store layouts in `/src/data/layouts.ts`
134
+ 5. Future layouts follow same process
135
+
136
+ ### Huggy Feature
137
+ **Current status:** Visual assets will be stored in HuggingFace dataset (future)
138
+
139
+ **Functionality:**
140
+ 1. Click "Huggy" button → Opens Huggy selector menu
141
+ 2. Menu includes:
142
+ - Search bar at top ("Search Huggy")
143
+ - Grid of Huggy thumbnails (3+ rows visible)
144
+ - Scroll for more Huggys
145
+ 3. Example Huggys from Figma:
146
+ - IDEFICS Huggy
147
+ - Robot Huggy
148
+ - Computer Vision Huggy
149
+ - SF Meetup Huggy
150
+ - Transformer Agent Huggy
151
+ 4. Click Huggy → Adds to canvas center as draggable image
152
+ 5. Huggy behaves like any canvas object (drag, scale, rotate, delete)
153
+
154
+ **Implementation notes:**
155
+ - Phase 1: Use exported Huggy images from Figma as placeholders
156
+ - Phase 2: Connect to HuggingFace dataset API
157
+ - Search filters Huggys by name
158
+
159
+ ### Image Upload Feature
160
+ **Functionality:**
161
+ 1. Click "Image" button OR drag-and-drop image onto canvas
162
+ 2. Drag hint shown: "Drag n drop your image anywhere to upload"
163
+ 3. Supported formats: PNG, JPG, WebP
164
+ 4. Image appears in canvas center
165
+ 5. User can drag, scale, rotate, layer-order, delete
166
+
167
+ **Implementation:**
168
+ - Hidden file input triggered by button click
169
+ - Drag-over visual feedback on canvas area
170
+ - Load image as Konva Image node
171
+
172
+ ### Text Feature
173
+ **Basic functionality:**
174
+ 1. Click "Text" button → Adds default text object to canvas center
175
+ 2. Default text: "Pretty Short Title"
176
+ 3. Default style: Inter, 100px, black, normal weight
177
+
178
+ **Editing modes:**
179
+ 1. **Click once** → Select text object (shows transformer)
180
+ 2. **Double-click** → Enter inline edit mode (temporary textarea overlay)
181
+ 3. **Select text** → Shows floating text toolbar below canvas
182
+
183
+ **Text toolbar appears when:**
184
+ - Text object is selected on canvas
185
+ - "Add Text" button is clicked
186
+
187
+ ### Text Toolbar (Floating)
188
+ **Position:** Below canvas, horizontally centered
189
+
190
+ **Controls (left to right):**
191
+ 1. **Font Family Dropdown**
192
+ - Options: Inter (default), IBM Plex Mono
193
+ - Shows current font name
194
+ - Dropdown arrow icon
195
+
196
+ 2. **Divider line**
197
+
198
+ 3. **Color Picker**
199
+ - Button shows current color swatch
200
+ - Click → Opens color picker popover (matches Figma design)
201
+ - Picker includes:
202
+ - Gradient palette area
203
+ - Hue slider
204
+ - Saturation slider
205
+ - Hex input field
206
+ - Format dropdown (Hex)
207
+
208
+ 4. **Bold Button**
209
+ - Icon: Bold "B"
210
+ - Toggle on/off
211
+ - Active state styling
212
+
213
+ 5. **Italic Button**
214
+ - Icon: Italic "I"
215
+ - Toggle on/off
216
+ - Active state styling
217
+
218
+ **Design specs:**
219
+ - Background: Dark (#2b2d31 or similar)
220
+ - Height: 44px
221
+ - Padding: 2px border, 2px inner padding
222
+ - Border radius: 5px
223
+ - Toolbar width: ~288px
224
+
225
+ ## Development Phases
226
+
227
+ ### Phase 1: Project Setup ✅
228
+ - [x] Initialize Vite + React + TypeScript
229
+ - [x] Install dependencies: react-konva, konva, lucide-react
230
+ - [x] Create folder structure:
231
+ ```
232
+ /src
233
+ /components
234
+ /Sidebar
235
+ /Canvas
236
+ /CanvasHeader
237
+ /ExportButton
238
+ /LayoutSelector
239
+ /HuggyMenu
240
+ /TextToolbar
241
+ /types
242
+ canvas.types.ts
243
+ layout.types.ts
244
+ /utils
245
+ export.utils.ts
246
+ canvas.utils.ts
247
+ /data
248
+ layouts.ts
249
+ huggys.ts
250
+ /assets
251
+ /icons (placeholder until custom icons ready)
252
+ /huggy-images
253
+ ```
254
+ - [x] Set up base configuration (tsconfig, vite.config)
255
+
256
+ ### Phase 2: Core UI Structure ✅ (Fully Complete)
257
+ **Components:**
258
+ - ✅ `App.tsx` - Main layout with dotted background
259
+ - ✅ `Sidebar.tsx` - Floating sidebar with 4 buttons and custom Figma icons
260
+ - ✅ `CanvasContainer.tsx` - Wrapper for canvas area with smooth transitions
261
+ - ✅ `ExportButton.tsx` - Top-right export button
262
+
263
+ **Icons:**
264
+ - ✅ `IconLayout.tsx` - Custom layout icon with default and selected states
265
+ - ✅ `IconText.tsx` - Custom text icon with default and selected states
266
+ - ✅ `IconImage.tsx` - Custom image icon with default and selected states
267
+ - ✅ `IconHuggy.tsx` - Custom Huggy mascot icon
268
+
269
+ **Design implementation:**
270
+ - ✅ Extract styles from Figma
271
+ - ✅ Create dotted background pattern
272
+ - ✅ Position all major UI elements
273
+ - ✅ Responsive container sizing
274
+ - ✅ Replace Lucide icons with custom Figma-exported sidebar icons
275
+
276
+ ### Phase 3: Canvas Foundation ✅
277
+ **Setup:**
278
+ - ✅ Create `Canvas.tsx` with Konva Stage and Layer
279
+ - ✅ Implement canvas state management:
280
+ ```typescript
281
+ interface CanvasObject {
282
+ id: string;
283
+ type: 'rect' | 'image' | 'text' | 'huggy';
284
+ x: number;
285
+ y: number;
286
+ width: number;
287
+ height: number;
288
+ rotation: number;
289
+ zIndex: number;
290
+ // type-specific properties
291
+ }
292
+ ```
293
+ - ✅ Set up three canvas sizes with switching
294
+ - ✅ Create base object renderers
295
+
296
+ ### Phase 4: Canvas Header ✅ (Fully Complete)
297
+ **Components:**
298
+ - ✅ `BgColorSelector.tsx` - Light/dark toggle with hover states
299
+ - ✅ `CanvasSizeSelector.tsx` - Three size options with animated expansion
300
+ - ✅ `CanvasHeader.tsx` - Main container component with smooth transitions
301
+
302
+ **Functionality:**
303
+ - ✅ Background color state management (light/dark toggle)
304
+ - ✅ Canvas size switching (1200×675, LinkedIn, HF)
305
+ - ✅ Active state styling for selected options
306
+ - ✅ Hover states with subtle `#f0f2f4` background on all buttons
307
+ - ✅ Dimension text at 80% opacity for better visual hierarchy
308
+ - ✅ Smooth 150ms ease-in-out transitions for all state changes
309
+ - ✅ Canvas dimensions animate smoothly (150ms), background color instant
310
+ - ✅ Header position adjusts smoothly when canvas size changes
311
+ - ✅ Animated button expansion with text slide-in and fade-in effects
312
+ - ✅ Icon-only display for unselected options, icon + label for selected
313
+ - ✅ Integrated into App.tsx above Canvas component
314
+
315
+ **Animations:**
316
+ - ✅ Button width expansion/contraction when selecting canvas size
317
+ - ✅ Text fade-in/fade-out (0 → 0.8 opacity)
318
+ - ✅ Text slide-in effect (translateX animation)
319
+ - ✅ All animations synchronized at 150ms ease-in-out
320
+
321
+ ### Phase 5: Layout Feature
322
+ **Components:**
323
+ - `LayoutSelector.tsx` - Floating menu component (matching Figma LayoutOptionsList design)
324
+ - Layout thumbnail components (4 layouts: Serious Collab, Fun Collab, Sandwich, Docs)
325
+ - Note: Only 4 unique layouts, NOT 6. Each layout has 3 size variations (1200×675, LinkedIn, HF)
326
+
327
+ **Data structure:**
328
+ - Define TypeScript interfaces for layouts
329
+ - Create layout data for all 4 layouts × 3 canvas sizes (12 total variants)
330
+ - Logo placeholder object type with Konva blur filter support (blur radius: 11.415px)
331
+ - Export all placeholder assets as PNG (Huggys, logo placeholders, decorative elements)
332
+ - Text placeholders remain as editable text objects
333
+
334
+ **Font additions:**
335
+ - Add Bison Bold font for Sandwich layout title text
336
+ - Update TextObject fontFamily type to include 'Bison'
337
+
338
+ **Implementation details:**
339
+ - Extract layout specs from Figma using MCP
340
+ - Convert to JSON format with object positions, sizes, rotations
341
+ - Store in `/src/data/layouts.ts`
342
+ - Implement layout loading logic to populate canvas
343
+
344
+ **Future enhancement (Post-Phase 5):**
345
+ - **Asset Swap Feature:** When hovering over logo placeholder or Huggy objects, show a small swap button
346
+ - Clicking swap button allows users to replace with their own image from local storage
347
+ - This will be implemented in a future phase after core layout functionality is complete
348
+
349
+ **Collaboration checkpoint:**
350
+ - Designer provides Figma layout specs
351
+ - Developer extracts and converts to JSON
352
+ - Test layout loading on all canvas sizes
353
+
354
+ ### Phase 6: Canvas Interactions ✅ (Partial - Core Complete)
355
+ **Features implemented:**
356
+ - ✅ Object selection with Transformer
357
+ - ✅ Drag functionality (Konva draggable)
358
+ - ✅ Proportional scaling (keepRatio enabled)
359
+ - ✅ Rotation handles
360
+ - ✅ Delete functionality (keyboard Delete/Backspace)
361
+ - ✅ Transformer styling (blue border, white-filled corner handles)
362
+
363
+ **Features to implement:**
364
+ - ⚠️ Layer ordering controls (floating LayerContainer)
365
+ - ⚠️ Delete button UI
366
+ - ⚠️ Multi-select support (optional)
367
+
368
+ **LayerContainer Component:**
369
+ - **When:** Only appears when an object is selected
370
+ - **What:** Dynamically visualizes ALL canvas objects as stacked layer icons
371
+ - **Selected state:** Selected object's layer shown in blue
372
+ - **Position:** Floats near the right side of the selected object
373
+ - **Interactions:**
374
+ - Drag layers up/down to reorder in stack
375
+ - Click a layer to bring that object to top of clicked layer
376
+ - **Visual:** Vertical stack showing layer hierarchy
377
+
378
+ **UI elements:**
379
+ - LayerContainer (floating mini layers panel)
380
+ - Delete button
381
+ - Transformer styling
382
+
383
+ ### Phase 7: Huggy Feature
384
+ **Components:**
385
+ - `HuggyMenu.tsx` - Floating menu with search
386
+ - `HuggyGrid.tsx` - Grid display of Huggys
387
+ - `HuggyCard.tsx` - Individual Huggy thumbnail
388
+
389
+ **Implementation:**
390
+ - Phase 1: Use exported Huggy images from Figma
391
+ - Create Huggy data structure:
392
+ ```typescript
393
+ interface Huggy {
394
+ id: string;
395
+ name: string;
396
+ thumbnail: string;
397
+ fullImage: string;
398
+ tags?: string[];
399
+ }
400
+ ```
401
+ - Search/filter functionality
402
+ - Add Huggy to canvas as Konva Image
403
+
404
+ **Future:** Connect to HuggingFace dataset API
405
+
406
+ ### Phase 8: Image Upload
407
+ **Features:**
408
+ - File input button (hidden, triggered by sidebar)
409
+ - Drag-and-drop zone (entire canvas area)
410
+ - Visual drag-over feedback
411
+ - Image loading and processing
412
+ - Add to canvas as Konva Image node
413
+
414
+ **Error handling:**
415
+ - File type validation
416
+ - File size limits
417
+ - Loading states
418
+
419
+ ### Phase 9: Text Feature ✅ (Fully Complete)
420
+ **Components:**
421
+ - Text object renderer in Canvas
422
+ - Inline text editor (textarea overlay)
423
+ - Text hint box in Sidebar
424
+
425
+ **Functionality:**
426
+ - ✅ Add default text on button click (68px default font)
427
+ - ✅ Keyboard shortcut: Press 'T' to activate text creation mode
428
+ - ✅ Single-click selection with transformer/bounding box
429
+ - ✅ Double-click inline editing with cursor at exact click position
430
+ - ✅ Text object transformation (drag, scale, rotate)
431
+ - ✅ Font size scales proportionally during transformation
432
+ - ✅ Font loading (Inter, IBM Plex Mono)
433
+ - ✅ Two-stage text editing system:
434
+ - **Stage 1:** Auto-growing text box on initial creation
435
+ - **Stage 2:** Fixed-size box with dynamic font scaling to fit
436
+ - ✅ Real-time font size adaptation while typing
437
+ - ✅ Live bounding box resize while editing (shrinks/grows with content)
438
+ - ✅ Delete key context awareness (deletes characters while editing, deletes object when selected)
439
+ - ✅ Text hint box positioned next to Text button
440
+
441
+ **Bug Fixes Completed (2025-11-20):**
442
+ 1. ✅ **Critical bounding box visibility issue**
443
+ - Refs were passed as objects instead of callback functions
444
+ - Fixed with callback ref pattern to properly store Konva nodes
445
+ 2. ✅ **Duplicate/transparent text during editing**
446
+ - Fixed by setting text opacity to 0 during editing
447
+ 3. ✅ **Delete key context awareness**
448
+ - Delete key only removes object when NOT editing
449
+ 4. ✅ **Text scaling on transform**
450
+ - Font size now scales proportionally with text box
451
+ 5. ✅ **Live bounding box resize**
452
+ - Box shrinks/grows in real-time while typing
453
+ 6. ✅ **Cursor position on double-click**
454
+ - Cursor appears at exact click position instead of selecting all
455
+ 7. ✅ **Text hint box positioning**
456
+ - Hint box aligned with Text button at same vertical level
457
+
458
+ **Phase 9 Complete:** All core functionality and UX refinements implemented.
459
+
460
+ **Next:** Phase 10 (Text Toolbar) - Will be implemented after other core features
461
+
462
+ ### Phase 10: Text Toolbar
463
+ **Components:**
464
+ - `TextToolbar.tsx` - Main toolbar container
465
+ - `FontFamilyDropdown.tsx`
466
+ - `ColorPicker.tsx` - Full color picker UI
467
+ - `BoldButton.tsx`
468
+ - `ItalicButton.tsx`
469
+
470
+ **Functionality:**
471
+ - Show/hide based on text selection
472
+ - Apply styles to selected text object
473
+ - Color picker popover positioning
474
+ - Real-time preview of changes
475
+
476
+ **Design fidelity:**
477
+ - Match Figma design exactly
478
+ - Smooth animations
479
+ - Proper z-index layering
480
+
481
+ ### Phase 11: Export Feature
482
+ **Functionality:**
483
+ - Editable filename input
484
+ - PNG generation using Konva's `stage.toDataURL()`
485
+ - Download trigger
486
+ - Loading state during export
487
+
488
+ **Technical details:**
489
+ - Set proper canvas dimensions
490
+ - Handle high-DPI displays (pixelRatio)
491
+ - File naming conventions
492
+ - Browser download API
493
+
494
+ ### Phase 12: Polish & Deploy
495
+ **Tasks:**
496
+ - Keyboard shortcuts (Delete, Undo)
497
+ - Loading states for all async operations
498
+ - Error boundaries
499
+ - Performance optimization:
500
+ - Lazy load Huggy images
501
+ - Debounce canvas updates
502
+ - Optimize re-renders
503
+ - Accessibility improvements
504
+ - Create README.md for HF Spaces
505
+ - Test on multiple browsers
506
+ - Deploy to HuggingFace Spaces
507
+
508
+ **HF Spaces setup:**
509
+ - Create `README.md` with metadata:
510
+ ```yaml
511
+ ---
512
+ title: HF Thumbnail Crafter
513
+ emoji: 🎨
514
+ colorFrom: blue
515
+ colorTo: purple
516
+ sdk: static
517
+ pinned: false
518
+ ---
519
+ ```
520
+ - Configure build output
521
+ - Test deployment
522
+
523
+ ## Important Notes
524
+
525
+ ### State Management
526
+ **No persistence:** Canvas state is lost on page refresh (as per requirements)
527
+ - Use React state (useState/useReducer) for canvas objects
528
+ - No localStorage/database needed
529
+
530
+ ### Icon System
531
+ **Current:** Use Lucide React as placeholder
532
+ **Future:** Replace with custom icons from Figma
533
+ - Export SVGs from Figma
534
+ - Create React icon component wrapper
535
+ - Place in `/src/assets/icons/`
536
+
537
+ ### Huggy Assets
538
+ **Current:** Export from Figma and place in `/src/assets/huggy-images/`
539
+ **Future:** Fetch from HuggingFace dataset
540
+ - API integration in Phase 7
541
+ - Caching strategy for performance
542
+
543
+ ### Canvas Sizes Reference
544
+ 1. **Default (X):** 1200×675px
545
+ 2. **LinkedIn:** TBD (standard LinkedIn post size)
546
+ 3. **HF Custom:** TBD (to be defined)
547
+
548
+ ## Context for Future Sessions
549
+
550
+ ### What's been decided:
551
+ ✅ Tech stack: Vite + React + TypeScript + Konva
552
+ ✅ Canvas library: react-konva
553
+ ✅ No state persistence (refresh loses work)
554
+ ✅ PNG export only
555
+ ✅ Three canvas sizes with switcher
556
+ ✅ Text fonts: Inter and IBM Plex Mono
557
+ ✅ Deployment: HuggingFace Spaces
558
+
559
+ ### What needs clarification:
560
+ ⚠️ LinkedIn and HF canvas sizes (exact dimensions)
561
+ ⚠️ Complete icon set from Figma (using Lucide temporarily)
562
+ ⚠️ HuggingFace dataset API endpoint for Huggys
563
+ ⚠️ Layout specs for all 6 layouts (will extract in Phase 5)
564
+
565
+ ### Session resumption checklist:
566
+ 1. Review this PROJECT_PLAN.md
567
+ 2. Check current phase status in todo list
568
+ 3. Verify Figma MCP connection: `claude mcp add --transport http figma-desktop http://127.0.0.1:3845/mcp`
569
+ 4. Continue from last completed phase
570
+
571
+ ## Contact & Resources
572
+ - **Figma:** https://www.figma.com/design/b7rA38IJxS0sK8ppXE8Gjb/HF-Thumbnail-Crafter
573
+ - **HuggingFace Spaces Docs:** https://huggingface.co/docs/hub/spaces
574
+ - **Konva Docs:** https://konvajs.org/
575
+ - **react-konva Docs:** https://konvajs.org/docs/react/
576
+
577
+ ---
578
+
579
+ ## Implementation Status
580
+
581
+ ### Completed Phases:
582
+ - ✅ **Phase 1:** Project Setup
583
+ - ✅ **Phase 2:** Core UI Structure (Fully Complete - Including custom Figma sidebar icons)
584
+ - ✅ **Phase 3:** Canvas Foundation
585
+ - ✅ **Phase 4:** Canvas Header (Fully Complete - Background color & size selectors with hover states, smooth transitions, and animated button expansion)
586
+ - ✅ **Phase 6:** Canvas Interactions (Partial - Core Complete)
587
+ - ✅ **Phase 9:** Text Feature (Fully Complete)
588
+
589
+ ### Current Phase:
590
+ **Phase 7:** Huggy Feature (Completed) ✅
591
+ **Next:** Phase 8 (Image Upload)
592
+
593
+ **Progress (2025-11-22):**
594
+ - ✅ Fixed sidebar icons with correct selected states from Figma icons page
595
+ - Text, Layout, and Image icons now have proper default (gray) and selected (white) states
596
+ - All icons saved to `src/assets/icons/` and using local files
597
+ - ✅ Integrated Bison Bold font
598
+ - Downloaded and extracted to `public/fonts/`
599
+ - Added @font-face declaration in `src/index.css`
600
+ - Updated TypeScript types to include 'Bison' in fontFamily union type
601
+ - ✅ Updated PROJECT_PLAN.md with asset swap feature documentation (Post-Phase 5)
602
+ - ✅ Confirmed 4 layouts × 3 sizes = 12 total layout variants to implement
603
+ - ✅ Created LogoPlaceholderObject TypeScript interface with blur support
604
+ - ✅ Extended CanvasObject union type to include LogoPlaceholderObject
605
+ - ✅ Implemented logo placeholder renderer with Konva blur filter (11.415px radius)
606
+ - ✅ Extracted layout specifications from Figma for all 4 layouts
607
+ - Sandwich layout: gradient background, logo placeholder, singing Huggy, divider
608
+ - Docs layout: gradient background, logo placeholder, partial Huggy, divider
609
+ - Serious Collab: solid gray background (#d9d9d9)
610
+ - Fun Collab: solid white background (#ffffff)
611
+ - ✅ Created `/src/data/layouts.ts` with layout definitions and helper functions
612
+ - ✅ Built LayoutSelector component matching Figma design
613
+ - Floating menu positioned next to sidebar
614
+ - 4 layout options with thumbnails
615
+ - Clean hover states
616
+ - ✅ Integrated LayoutSelector into Sidebar component
617
+ - ✅ Implemented layout loading logic in App.tsx
618
+ - Applies layout background (solid or gradient)
619
+ - Loads all layout objects onto canvas
620
+ - Resets selection after loading
621
+
622
+ **Critical Fixes (2025-11-22 - Afternoon):**
623
+ - ✅ Fixed LayoutSelector component to match Figma 2×2 grid design
624
+ - Was using vertical list, now uses proper 2×2 grid layout
625
+ - Width: 233px, proper padding and spacing
626
+ - Each layout button: 96.5px × 47.291px thumbnail area
627
+ - ✅ Extracted correct layout specifications from Figma Layouts page (node 219:1155)
628
+ - **Serious Collab**: HF logo image, logo placeholder with blur, "Logo" text, divider
629
+ - **Fun Collab**: "Pretty Short Title" text, 2 Huggy images, rotated logo placeholder with "Logo" text
630
+ - **Sandwich**: "Pretty short title" (Bison font), "supportive description", Singing Huggy image
631
+ - **Docs**: Masked Huggy image, "Transformers" title, "Documentation" subtitle
632
+ - ✅ Removed background property from Layout interface
633
+ - Layouts now only contain objects, as intended
634
+ - Background color is controlled separately by canvas settings
635
+ - ✅ Fixed App.tsx handleSelectLayout to not apply background colors
636
+ - Layouts only load objects onto canvas
637
+ - Users control background independently
638
+
639
+ **Final Implementation (2025-11-22 - Evening):**
640
+ - ✅ User manually exported all layout assets to `public/assets/layouts/`:
641
+ - Layout thumbnails: sCollab_thumbnail.png, fCollab_thumbnail.png, sandwitch_thumbnail.png, docs_thumbnail.png
642
+ - Logo placeholders: logo_placehoder.png (single PNG with blur + text combined)
643
+ - HF logos: HF logo.png, docsHFLogo.png
644
+ - Huggy assets: fCollab_huggy_asset.png, fCollab_huggy_hand_asset.png, snadwithc_huggy_asset.png
645
+ - ✅ Updated all layouts.ts with correct asset paths and positions from Figma
646
+ - ✅ Fixed text rendering issue by setting `isFixedSize: true` on all layout text objects
647
+ - ✅ Logo placeholder now uses single image approach (no separate blur filter)
648
+ - ✅ Cleaned up empty folders (huggys/, logos/)
649
+ - ✅ All 4 layouts ready for testing in browser
650
+
651
+ **Polish and Fixes (2025-11-22 - Late Evening):**
652
+ - ✅ Fixed Docs layout text centering issue
653
+ - Both "Transformers" and "Documentation" texts were positioned by left edge
654
+ - Updated to use center-point positioning with `offsetX` property
655
+ - "Transformers" text: x=600 (canvas center) with offsetX=321.5 (width/2)
656
+ - "Documentation" text: x=600 (canvas center) with offsetX=210.5 (width/2)
657
+ - ✅ Added SVG support for canvas objects
658
+ - Konva.Image component natively supports SVG files
659
+ - Updated Serious Collab layout to use SVG x-icon (collabX.svg)
660
+ - Changed divider rect to image object with proper positioning
661
+ - ✅ Implemented undo/redo system
662
+ - Added history management to `useCanvasState` hook
663
+ - Tracks up to 50 previous canvas states
664
+ - Keyboard shortcuts: Ctrl+Z (undo), Ctrl+Shift+Z (redo)
665
+ - Also supports Cmd+Z/Cmd+Shift+Z on Mac
666
+ - Automatically deselects objects when undoing/redoing
667
+ - Does not interfere with text editing (shortcuts disabled while editing)
668
+ - Covers: adding/deleting objects, moving/transforming, editing text, loading layouts
669
+ - Excludes: background color changes, canvas size changes (view settings)
670
+
671
+ **Phase 5 Status:** ✅ COMPLETE - All Features Implemented & Tested
672
+
673
+ **Additional Refinements (2025-11-23):**
674
+ - ✅ Fixed layout positioning issues across all 4 layouts
675
+ - Huggy assets repositioned to properly overlap titles (Sandwich y: 160→130, Fun Collab y: 81→60)
676
+ - Text bounding boxes tightened to reduce extra space
677
+ - Added verticalAlign="top" to text objects to minimize bottom padding
678
+ - Fixed "supportive description" horizontal centering (x=600, offsetX=375, align='center')
679
+ - ✅ Improved font loading reliability
680
+ - Explicitly preload Bison, Inter, and IBM Plex Mono fonts before first render
681
+ - Added 50ms delay after document.fonts.ready for browser sync
682
+ - Changed Bison font-display from 'swap' to 'block' to prevent fallback flash
683
+ - Fixes "Documentation" and "Pretty short title" positioning on first load
684
+ - ✅ Layout switch prompt for user-added objects
685
+ - Added `isFromLayout` flag to track object origins
686
+ - Prompts user to keep or discard custom objects when switching layouts
687
+ - Only triggers when NEW user-added objects exist (not for edited layout objects)
688
+ - ✅ Z-index and rendering order fixes
689
+ - Reordered layout objects arrays to ensure correct visual layering
690
+ - Huggy assets now properly overlap text placeholders as designed
691
+ - ✅ Text object dimensions optimized
692
+ - Sandwich title: 194px→170px height
693
+ - Sandwich description: 107px→95px height, 1015px→750px width
694
+ - Fun Collab title: 140px→120px height
695
+ - Docs title: 111px→110px height
696
+ - Docs subtitle: 56px→55px height
697
+
698
+ **Known Limitations:**
699
+ - Text bounding boxes have inherent font metrics spacing (ascent/descent)
700
+ - With `isFixedSize: true`, bounding boxes can't dynamically shrink to exact text dimensions
701
+ - `verticalAlign="top"` minimizes visible bottom padding but doesn't eliminate font metrics spacing
702
+
703
+ **Advanced Interaction Features (2025-11-23 - Latest):**
704
+ - ✅ **Arrow key movement with Shift modifier**
705
+ - Press arrow keys to move selected object(s) 1px in any direction
706
+ - Hold Shift while pressing arrow keys to move 10px (faster positioning)
707
+ - Works with both single and multiple selections
708
+ - Disabled during text editing to prevent interference
709
+ - Records in undo/redo history
710
+ - ✅ **Multi-select with drag-to-select box**
711
+ - Click and drag on empty canvas to create selection box (>5px threshold)
712
+ - Blue semi-transparent box with dashed border (rgba(63, 174, 230, 0.1))
713
+ - Automatically selects all objects that intersect with the selection box
714
+ - Visual feedback during drag operation
715
+ - **Deselection on click**: Single click on empty canvas (no drag) deselects all objects
716
+ - Distinguishes between click and drag to prevent accidental deselection
717
+ - ✅ **Shift+Click to add/remove from selection**
718
+ - Click object normally: selects only that object (clears previous selection)
719
+ - Shift+Click object: adds object to current selection
720
+ - Shift+Click selected object: removes it from selection
721
+ - Works seamlessly with drag-to-select functionality
722
+ - All selected objects show transformer handles
723
+ - ✅ **Responsive layout repositioning on canvas size change**
724
+ - When canvas size changes (1200×675 ↔ 1200×627 ↔ 1280×720), all objects scale proportionally
725
+ - **Maintains aspect ratios**: Uses uniform scale factor (min of scaleX/scaleY) for width/height to prevent distortion
726
+ - Positions scale based on their respective canvas dimension ratios (scaleX for x, scaleY for y)
727
+ - Font sizes scale uniformly to maintain readability
728
+ - Prevents layout breakage and asset distortion when switching between size presets
729
+ - Recorded in undo/redo history
730
+
731
+ **Multi-Select Implementation Details:**
732
+ - Changed `selectedId: string | null` to `selectedIds: string[]` in state management
733
+ - Updated all selection-related functions to handle arrays
734
+ - Transformer component now supports multiple nodes simultaneously
735
+ - Arrow key movement and delete operations work with multiple selected objects
736
+ - Undo/redo system handles multi-select operations correctly
737
+
738
+ **Refinements (2025-11-23 - Final):**
739
+ - ✅ **Fixed responsive scaling to maintain aspect ratios** (App.tsx:155-167)
740
+ - Changed from independent scaleX/scaleY to uniform scale factor
741
+ - Prevents asset distortion when switching canvas sizes
742
+ - Width and height now scale with `Math.min(scaleX, scaleY)`
743
+ - Positions still scale independently (x with scaleX, y with scaleY) for proper placement
744
+ - ✅ **Fixed deselection on empty canvas click** (Canvas.tsx:400-449)
745
+ - Added 5px drag threshold to distinguish between click and drag
746
+ - Single click on empty canvas now properly deselects all objects
747
+ - Drag-to-select still works as expected with visual feedback
748
+ - Empty selection box (no intersecting objects) also deselects all
749
+ - ✅ **Added outside canvas click deselection** (App.tsx:178-192, CanvasContainer.tsx:18)
750
+ - Clicking anywhere outside the canvas container deselects all objects
751
+ - Uses global mousedown event listener on document
752
+ - Targets `.canvas-container` className for boundary detection
753
+ - Only triggers deselection when objects are currently selected (performance optimization)
754
+
755
+ ### Next Steps:
756
+ 1. ✅ Complete Phase 5: Layout Feature (DONE)
757
+ 2. ✅ Complete Phase 7: Huggy Feature (DONE)
758
+ 3. Complete Phase 8: Image Upload
759
+ 4. Complete Phase 10: Text Toolbar
760
+ 5. Complete Phase 11: Export Feature
761
+ 6. Complete Phase 12: Polish & Deploy
762
+
763
+ ---
764
+
765
+ **Last Updated:** 2025-11-24
766
+
767
+ **Recent Improvements (2025-11-24):**
768
+ - ✅ **Sidebar Deselection Enhancement**
769
+ - Clicking the same sidebar button now toggles it off (deselects)
770
+ - Clicking anywhere outside sidebar components deselects active button
771
+ - Improves UX by allowing users to close menus without navigating away
772
+ - **Fixed:** Canvas clicks in text creation mode now properly preserved
773
+ - ✅ **Huggy Menu UX Improvements & Fixes**
774
+ - **Infinite scroll fixed:**
775
+ - Initial display increased to 12 Huggys (from 6) to ensure scrollbar appears
776
+ - Added overflow check that auto-loads more content if container isn't full
777
+ - Scroll event triggers at 80% to load 6 more items at a time
778
+ - **Height constraint:** Maximum 4 rows visible (430px max-height) to prevent excessive menu length
779
+ - **Default scrollbar:** Removed custom scrollbar styling, using native OS scrollbars
780
+ - **Browser tooltip only:** Removed custom hover overlay, using native browser tooltip (title attribute)
781
+ - Transparent background on Huggy thumbnails (removed white bg)
782
+ - Auto-selection: Newly added Huggys are immediately selected on canvas
783
+ - Unified grid spacing: 5px gap and padding for balanced layout
784
+ - ✅ **Text Creation Bug Fix**
785
+ - **Fixed:** Text creation mode (button click or 'T' key) now works correctly
786
+ - Issue was click-outside-sidebar handler deselecting activeButton before canvas click event fired
787
+ - Solution: Added exception for canvas clicks when in text creation mode
788
+ - Text creation mode preserved when clicking on canvas to add text
789
+ - ✅ **Undo/Redo System Fixed & Re-enabled** 🎉
790
+ - **Fixed:** Complete rewrite of history management system
791
+ - **Root causes identified and resolved:**
792
+ - Race condition: State updates and history recording competing
793
+ - Missing state change detection: Now only records when state actually changes
794
+ - Flag management: Changed from `isUndoRedo` to `isApplyingHistory` with proper async handling
795
+ - **Improvements:**
796
+ - Added `setTimeout` to ensure state updates complete before recording
797
+ - Duplicate state prevention: Compares JSON strings before adding to history
798
+ - Proper flag reset after undo/redo operations
799
+ - 50 steps of history (configurable)
800
+ - **Keyboard shortcuts re-enabled:**
801
+ - Ctrl+Z / Cmd+Z: Undo
802
+ - Ctrl+Shift+Z / Cmd+Shift+Z: Redo
803
+ - Disabled during text editing to prevent interference
804
+ - **Memory trade-off:** 50 steps ≈ 250KB (negligible for modern browsers)
805
+ - Can undo all the way back to empty canvas (expected behavior)
806
+ - ✅ **Huggy Menu Scrollbar Refinement**
807
+ - Reduced scrollbar width from default to 6px (thinner, less intrusive)
808
+ - Transparent track background for cleaner look
809
+ - Gray thumb (#b8b8b8) with darker hover state (#999999)
810
+ - Firefox support with `scrollbar-width: thin`
811
+ - ✅ **Feature Backlog Updated**
812
+ - Hover to show object bounding box (future enhancement)
813
+ - Dynamic canvas corner radius & drop shadow on hover (future enhancement)
814
+
815
+ **Current Status:**
816
+ - Phase 5 (Layout Feature): ✅ COMPLETE WITH KNOWN ISSUES
817
+ - All 4 layouts implemented with correct specifications
818
+ - Layout positioning refined for proper Huggy overlap
819
+ - Text centering and alignment issues resolved
820
+ - Font loading reliability improved for consistent first-load rendering
821
+ - SVG support added and tested
822
+ - Layout switch prompts implemented for user-added objects
823
+ - Text bounding boxes optimized (known limitation: font metrics spacing)
824
+ - Arrow key movement with Shift modifier (1px/10px)
825
+ - Multi-select with drag-to-select box
826
+ - Shift+Click to add/remove objects from selection
827
+ - All assets exported and integrated
828
+ - Phase 6 (Canvas Interactions): ✅ ENHANCED
829
+ - Multi-select functionality fully implemented
830
+ - Arrow key positioning for precise control
831
+ - Phase 7 (Huggy Feature): ✅ FULLY COMPLETE WITH HUGGINGFACE DATASET
832
+ - **HuggingFace Dataset Integration:** All 44 Huggy assets loaded from `https://huggingface.co/datasets/Chunte/Huggy`
833
+ - **26 Modern Huggies** + **18 Outlined Huggies** (PNG only, GIFs excluded)
834
+ - **Infinite Scroll Loading:** Display 12 Huggys initially, auto-load more when scrolling to 80%
835
+ - Automatic overflow detection: loads more if content doesn't fill container
836
+ - **Native Scrollbar:** Uses default OS scrollbar styling
837
+ - **Search Functionality:** Filter by name, category (Modern/Outlined), and tags
838
+ - Built HuggyMenu component with search bar and loading states
839
+ - **Browser Tooltip:** Native title attribute shows Huggy name on hover
840
+ - Summary footer showing "X of Y Huggys"
841
+ - Added click handler to insert Huggys onto canvas as draggable images (200×200px default)
842
+ - **Auto-Selection:** Newly added Huggys are automatically selected on canvas for immediate editing
843
+ - Integrated HuggyMenu into Sidebar with toggle functionality
844
+ - Users can drag, scale, rotate, and delete Huggys like any canvas object
845
+ - **Transparent Background:** Huggy thumbnails display without white background for cleaner look
846
+ - **Unified Grid Spacing:** 5px gap between Huggy thumbnails with 5px padding for balanced layout
847
+ - **Future Assets:** Automatically loads new Huggys when added to the dataset
848
+ - All previous phases (1-4, 9) fully functional
849
+ - Dev server running cleanly at http://localhost:3000
850
+
851
+ ## Known Issues (To Be Fixed Later)
852
+
853
+ ### 1. Responsive Layout Scaling Issue ⚠️
854
+ **Problem:** When switching between different canvas sizes, layout assets progressively reduce in size with each change. This cumulative scaling is unwanted behavior.
855
+
856
+ **Impact:** Layout objects become unusably small after multiple canvas size switches.
857
+
858
+ **Temporary Solution:** Remove the responsive layout repositioning feature (App.tsx:155-167) until a proper fix is implemented.
859
+
860
+ **Root Cause:** The scaling logic applies transformation to already-transformed objects, causing cumulative scale reduction.
861
+
862
+ **Future Fix:** Implement absolute positioning system or store original dimensions to calculate scale from baseline rather than current state.
863
+
864
+ ---
865
+
866
+ **Note:** This issue is documented for future fixes and does not block progression to other phases.
867
+
868
+ ---
869
+
870
+ ## Feature Backlog
871
+
872
+ ### Huggy Menu "Expand" Feature ⏸️
873
+ **Status:** Backlogged for future implementation
874
+
875
+ **Original Requirement:**
876
+ - Add an "Expand" button in the Huggy menu
877
+ - Allow users to see more Huggys at once (e.g., 12 or 18 instead of 6)
878
+ - Toggle between compressed and expanded views
879
+
880
+ **Current Workaround:**
881
+ - Users can click "Load More" button to progressively load additional Huggys
882
+ - Scroll to browse all 44 Huggys
883
+
884
+ **Why Backlogged:**
885
+ - Current "Load More" functionality provides similar UX
886
+ - Prioritizing core features (Image Upload, Export) first
887
+ - Can be added as a quality-of-life enhancement later
888
+
889
+ **Future Implementation Notes:**
890
+ - Add "Expand View" toggle button in menu header
891
+ - Options: Show 6 (default) | 12 (expanded) | 18 (full expanded)
892
+ - Maintain scroll position when toggling
893
+ - Consider performance with many images loaded simultaneously
894
+
895
+ ---
896
+
897
+ ### Hover to Show Object Bounding Box ⏸️
898
+ **Status:** Backlogged for future implementation
899
+
900
+ **Original Requirement:**
901
+ - When hovering over any canvas object, display its bounding box
902
+ - Visual feedback to show interactive areas before clicking
903
+ - Helps users identify object boundaries
904
+
905
+ **Implementation Details:**
906
+ - Add hover state detection to CanvasObject component
907
+ - Show subtle bounding box outline on hover (different from selection transformer)
908
+ - Use lighter color or dashed border to distinguish from selection state
909
+ - Should not interfere with drag operations or transformer handles
910
+
911
+ **Why Backlogged:**
912
+ - Nice-to-have UX enhancement, not critical for core functionality
913
+ - Current selection system (click to select) works adequately
914
+ - Prioritizing core features (Image Upload, Export) first
915
+
916
+ **Future Implementation Notes:**
917
+ - Consider performance impact with many objects on canvas
918
+ - Ensure hover state doesn't conflict with selection/transformer states
919
+ - May need throttling for smooth hover detection
920
+
921
+ ---
922
+
923
+ ### Dynamic Canvas Corner Radius & Drop Shadow ⏸️
924
+ **Status:** Backlogged for future implementation
925
+
926
+ **Original Requirement:**
927
+ - **Cursor IN canvas area:**
928
+ - Corner radius: 0px (sharp corners)
929
+ - Drop shadow: Increased intensity (more obvious)
930
+ - **Cursor OUTSIDE canvas area:**
931
+ - Corner radius: 10px (rounded corners)
932
+ - Drop shadow: Original subtle value
933
+
934
+ **Visual Effect:**
935
+ - Creates a "focus mode" when user is working on the canvas
936
+ - Sharp corners give more workspace feel when actively editing
937
+ - Rounded corners provide softer appearance when not in use
938
+
939
+ **Implementation Details:**
940
+ - Track cursor position with mouseEnter/mouseLeave events on canvas container
941
+ - CSS transition for smooth corner radius and shadow changes
942
+ - Canvas component style updates based on hover state
943
+ - Transition duration: ~200ms for smooth effect
944
+
945
+ **Why Backlogged:**
946
+ - Polish/aesthetic feature, not functional requirement
947
+ - May cause visual distraction during active editing
948
+ - Prioritizing core features (Image Upload, Export) first
949
+
950
+ **Future Implementation Notes:**
951
+ - Test with various canvas sizes to ensure effect works well
952
+ - Consider if drop shadow change affects perceived canvas position
953
+ - May need to adjust for different screen sizes/resolutions
954
+ - Original drop shadow value: TBD (measure current implementation)
955
+ - Increased drop shadow value: TBD (design decision needed)
SESSION_LOGS.md ADDED
@@ -0,0 +1,331 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # HF Thumbnail Crafter - Session Logs
2
+
3
+ ## Session 2025-11-21: Canvas Header Polish & Sidebar Icon Replacement
4
+
5
+ ### Session Summary
6
+ Completed Phase 4 (Canvas Header) with polished hover states, smooth transitions, and animated button expansions. Replaced all Lucide sidebar icons with custom Figma-exported icons featuring proper default and selected states.
7
+
8
+ ### Changes Made
9
+
10
+ #### 1. Canvas Header Hover States ✅
11
+ **Components Modified:** `BgColorSelector.tsx`, `CanvasSizeSelector.tsx`
12
+
13
+ **Removed hover animations:**
14
+ - Removed text expansion animation on hover from CanvasSizeSelector
15
+ - Text labels now only appear when option is selected
16
+ - Added subtle `#f0f2f4` hover background to both components
17
+
18
+ **Updated transitions:**
19
+ - Changed from `0.2s` to `150ms ease-in-out` for consistency
20
+ - Applied to both background color and canvas size selectors
21
+
22
+ #### 2. Dimension Text Opacity ✅
23
+ **Component Modified:** `CanvasSizeSelector.tsx`
24
+ - Set dimension text (e.g., "1200x675") to **80% opacity** for better visual hierarchy
25
+ - Improves readability while maintaining design aesthetic
26
+
27
+ #### 3. Canvas Area Transitions ✅
28
+ **Components Modified:** `Canvas.tsx`, `CanvasContainer.tsx`, `CanvasHeader.tsx`
29
+
30
+ **Canvas transitions:**
31
+ - Width and height animate smoothly with `150ms ease-in-out`
32
+ - Background color changes instantly (no transition) as requested
33
+ - Overflow hidden prevents layout shift during animation
34
+
35
+ **Container transitions:**
36
+ - Added `all 0.15s ease-in-out` transition to canvas container
37
+ - Header position adjusts smoothly when canvas size changes
38
+
39
+ #### 4. Animated Button Expansion ✅
40
+ **Component Modified:** `CanvasSizeSelector.tsx`
41
+
42
+ **Three-part animation system:**
43
+ 1. **Width expansion/contraction:**
44
+ - Unselected: `minWidth: '38px'` (icon only)
45
+ - Selected: Expands to fit icon + text
46
+ - Transition: `150ms ease-in-out`
47
+
48
+ 2. **Text fade-in/fade-out:**
49
+ - Unselected: `opacity: 0`
50
+ - Selected: `opacity: 0.8`
51
+ - Transition: `150ms ease-in-out`
52
+
53
+ 3. **Text slide-in effect:**
54
+ - Unselected: `translateX(-10px)`
55
+ - Selected: `translateX(0)`
56
+ - Transition: `150ms ease-in-out`
57
+
58
+ **Implementation details:**
59
+ - Text span always rendered (not conditional) to enable smooth transitions
60
+ - Button `justifyContent` switches between `center` (unselected) and `flex-start` (selected)
61
+ - Padding adjusts dynamically: `9px` unselected, `10px` selected
62
+ - All animations synchronized for smooth, professional effect
63
+
64
+ #### 5. Sidebar Icon Replacement ✅
65
+ **New Icon Components Created:**
66
+ - `IconLayout.tsx` - Layout/grid icon with default (#545865) and selected (white) states
67
+ - `IconText.tsx` - Text "T" icon with default and selected states
68
+ - `IconImage.tsx` - Image/photo icon with default and selected states
69
+ - `IconHuggy.tsx` - Huggy mascot icon (single state as requested)
70
+
71
+ **Component Modified:** `Sidebar.tsx`
72
+ - Removed Lucide React imports (`Layers`, `Smile`, `Image`, `Type`)
73
+ - Replaced with custom Figma-exported icon components
74
+ - Icons loaded from localhost Figma MCP server URLs
75
+
76
+ **Icon Implementation Pattern:**
77
+ ```typescript
78
+ interface IconProps {
79
+ selected?: boolean;
80
+ }
81
+
82
+ export default function Icon({ selected = false }: IconProps) {
83
+ const imgDefault = "http://localhost:3845/assets/[hash].svg";
84
+ const imgSelected = "http://localhost:3845/assets/[hash].svg";
85
+
86
+ return (
87
+ <div style={{ position: 'relative', width: '32px', height: '32px' }}>
88
+ <img src={selected ? imgSelected : imgDefault} />
89
+ </div>
90
+ );
91
+ }
92
+ ```
93
+
94
+ **Huggy Icon Fix:**
95
+ - Initially missed `display: 'contents'` CSS property on Face container
96
+ - This property makes container transparent in layout, allowing children to position correctly
97
+ - Fixed by adding `display: 'contents'` to Face container div
98
+
99
+ ### Files Modified
100
+ - `src/components/CanvasHeader/BgColorSelector.tsx`
101
+ - `src/components/CanvasHeader/CanvasSizeSelector.tsx`
102
+ - `src/components/CanvasHeader/CanvasHeader.tsx`
103
+ - `src/components/Canvas/Canvas.tsx`
104
+ - `src/components/Canvas/CanvasContainer.tsx`
105
+ - `src/components/Sidebar/Sidebar.tsx`
106
+ - `src/components/Icons/IconLayout.tsx` (created)
107
+ - `src/components/Icons/IconText.tsx` (created)
108
+ - `src/components/Icons/IconImage.tsx` (created)
109
+ - `src/components/Icons/IconHuggy.tsx` (created)
110
+ - `PROJECT_PLAN.md` (updated)
111
+
112
+ ### Test Results
113
+ ✅ Build successful without errors
114
+ ✅ Dev server running at http://localhost:3001/
115
+ ✅ All hover states working smoothly
116
+ ✅ Canvas size changes animate beautifully
117
+ ✅ Background color changes instantly
118
+ ✅ Button expansion animations synchronized perfectly
119
+ ✅ All sidebar icons display correctly with proper states
120
+ ✅ Huggy mascot icon renders correctly
121
+
122
+ ### Phase Status
123
+ **Phase 2: Core UI Structure** - ✅ Fully Complete (with custom Figma icons)
124
+ **Phase 4: Canvas Header** - ✅ Fully Complete (with polished animations and transitions)
125
+
126
+ ### Next Session
127
+ Ready to proceed with **Phase 5: Layout Feature** - Pre-designed layout templates with floating menu component.
128
+
129
+ ---
130
+
131
+ ## Session 2025-11-20: Text Editing Bug Fixes
132
+
133
+ ### Session Summary
134
+ Fixed critical text editing issues and improved the text feature implementation. The main focus was resolving the missing bounding box problem and improving text editing behavior.
135
+
136
+ ### Issues Addressed
137
+
138
+ #### 1. Delete Key Behavior ✅
139
+ **Problem:** When editing text, pressing Delete/Backspace would delete the entire text object instead of individual characters.
140
+
141
+ **Solution:** Added context-aware delete key handling in `App.tsx`:
142
+ ```typescript
143
+ // Check if user is editing text
144
+ const isEditingText = objects.some(obj => obj.type === 'text' && obj.isEditing);
145
+
146
+ // Delete selected object (only when NOT editing text)
147
+ if ((e.key === 'Delete' || e.key === 'Backspace') && selectedId && !isEditingText) {
148
+ e.preventDefault();
149
+ deleteSelected();
150
+ }
151
+ ```
152
+
153
+ #### 2. Missing Bounding Box (Critical) ✅
154
+ **Problem:** Transformer/bounding box not appearing when text objects were selected. Console showed `Node: undefined` repeatedly.
155
+
156
+ **Root Cause:** In `Canvas.tsx` line ~286, refs were passed as objects instead of callback functions:
157
+ ```typescript
158
+ // BROKEN:
159
+ shapeRef={{ current: shapeRefs.current.get(obj.id) || null }}
160
+ ```
161
+ This created new objects on each render but never actually stored the node references.
162
+
163
+ **Solution:** Implemented callback ref pattern:
164
+ ```typescript
165
+ // FIXED:
166
+ shapeRef={(node: Konva.Node | null) => {
167
+ if (node) {
168
+ shapeRefs.current.set(obj.id, node);
169
+ } else {
170
+ shapeRefs.current.delete(obj.id);
171
+ }
172
+ }}
173
+ ```
174
+
175
+ **Additional Changes:**
176
+ - Updated type definitions in all components to accept both callback and RefObject refs:
177
+ ```typescript
178
+ shapeRef?: ((node: Konva.Node | null) => void) | React.RefObject<Konva.Node>;
179
+ ```
180
+ - Updated `TextEditable.tsx`, `CanvasObject.tsx`, and `ImageRenderer` to handle both ref patterns
181
+
182
+ #### 3. Duplicate/Transparent Text During Editing ✅
183
+ **Problem:** Half-transparent text showing behind textarea during editing, creating visual duplicates.
184
+
185
+ **Solution:** Changed text opacity to 0 during editing in `TextEditable.tsx`:
186
+ ```typescript
187
+ <KonvaText
188
+ // ... other props
189
+ opacity={object.isEditing ? 0 : 1} // Completely hide during edit
190
+ listening={!object.isEditing}
191
+ />
192
+ ```
193
+
194
+ #### 4. Textarea Rendering ✅
195
+ **Problem:** Initial attempt to render textarea inside Konva tree caused errors ("Konva has no node with the type textarea").
196
+
197
+ **Solution:** Moved textarea rendering completely outside Konva tree in `Canvas.tsx`:
198
+ ```typescript
199
+ {editingText && (
200
+ <textarea
201
+ ref={textareaRef}
202
+ value={editingText.text}
203
+ // ... positioned over canvas using fixed positioning
204
+ />
205
+ )}
206
+ ```
207
+
208
+ ### TypeScript Fixes
209
+ Fixed multiple TypeScript compilation errors:
210
+ - Removed unused imports (`useState`, `getCanvasDimensions`, `KonvaText`)
211
+ - Removed unused variables (`editingTextId`, `setEditingTextId`)
212
+ - Removed `onTextChange` prop (no longer needed, handled directly in Canvas.tsx)
213
+ - Fixed union type handling in `useCanvasState.ts` using `DistributiveOmit` type
214
+ - Fixed type assertion in `updateSelected` function
215
+
216
+ ### Files Modified
217
+ - `src/App.tsx` - Delete key behavior
218
+ - `src/components/Canvas/Canvas.tsx` - Callback refs, textarea rendering, transformer updates
219
+ - `src/components/Canvas/CanvasObject.tsx` - Ref handling, removed unused props
220
+ - `src/components/Canvas/TextEditable.tsx` - Opacity handling, ref pattern
221
+ - `src/hooks/useCanvasState.ts` - DistributiveOmit type, type assertions
222
+ - `src/types/canvas.types.ts` - (already had `isEditing` and `isFixedSize` properties)
223
+
224
+ ### Test Results
225
+ ✅ Build successful without errors
226
+ ✅ Dev server running at http://localhost:3000/
227
+ ✅ Bounding box now visible when selecting text objects
228
+ ✅ Delete key works correctly in both contexts
229
+ ✅ No duplicate/transparent text during editing
230
+ ✅ Real-time font size adaptation working
231
+ ✅ Text position remains stable between edit/view modes
232
+
233
+ ### Current Feature Status
234
+ **Phase 9: Text Feature** is now **Core Complete**:
235
+ - ✅ Text creation with button and 'T' keyboard shortcut
236
+ - ✅ Stage 1: Auto-growing text box on initial creation (68px default font)
237
+ - ✅ Stage 2: Fixed-size box with dynamic font scaling after first transform
238
+ - ✅ Real-time font size adaptation while typing
239
+ - ✅ Transformer/bounding box with blue border and white-filled corner handles
240
+ - ✅ Drag, scale, rotate functionality
241
+ - ✅ Double-click to edit
242
+ - ✅ Context-aware delete key
243
+ - ⚠️ Text toolbar (Phase 10) - Still TODO
244
+
245
+ ### Next Session
246
+ Start with **Phase 4: Canvas Header** - Implement background color selector and canvas size selector controls.
247
+
248
+ ---
249
+
250
+ ## Session 2025-11-20 (Continued): Additional Text Feature Refinements
251
+
252
+ ### Additional Issues Fixed
253
+
254
+ #### 5. Text Scaling on Transform ✅
255
+ **Problem:** When scaling up a text object, the font size would revert to default after releasing the mouse.
256
+
257
+ **Root Cause:** The `fontSize` property wasn't being updated during transformation - only width/height were changing.
258
+
259
+ **Solution:** Modified `handleTransformEnd` in Canvas.tsx:
260
+ ```typescript
261
+ // Handle text objects: scale fontSize and mark as fixed size
262
+ if (obj.type === 'text') {
263
+ // Use the smaller scale factor to maintain readability
264
+ const scaleFactor = Math.min(scaleX, scaleY);
265
+ const newFontSize = Math.max(10, obj.fontSize * scaleFactor);
266
+
267
+ return {
268
+ ...updated,
269
+ fontSize: newFontSize,
270
+ isFixedSize: true
271
+ };
272
+ }
273
+ ```
274
+
275
+ **Behavior:** Font size now scales proportionally with the text box during transformation (minimum 10px).
276
+
277
+ #### 6. Live Bounding Box Resize While Editing ✅
278
+ **Problem:** When deleting letters, the bounding box didn't shrink to hug the content, leaving empty space.
279
+
280
+ **Solution:** Modified `handleTextareaChange` in Canvas.tsx (lines 166-168):
281
+ ```typescript
282
+ // Always auto-grow/shrink box while editing (live resize)
283
+ const newWidth = Math.max(100, tempText.width() + 20);
284
+ const newHeight = Math.max(40, tempText.height() + 10);
285
+ ```
286
+
287
+ **Behavior:** Text box now dynamically resizes in real-time as you type or delete characters.
288
+
289
+ #### 7. Cursor Position on Double-Click ✅
290
+ **Problem:** When double-clicking text to edit, all text was selected instead of placing cursor at click position.
291
+
292
+ **Solution:**
293
+ 1. Added `calculateCursorPosition` helper function in Canvas.tsx (lines 19-55)
294
+ 2. Updated `handleDoubleClick` in TextEditable.tsx to capture click coordinates
295
+ 3. Modified textarea focus logic to set cursor at calculated position
296
+
297
+ **Behavior:** Cursor now appears at the character closest to where you clicked.
298
+
299
+ #### 8. Text Hint Box Positioning ✅
300
+ **Problem:** Text hint box appeared in the middle-right of sidebar instead of next to the Text button.
301
+
302
+ **Solution:** Repositioned hint box in Sidebar.tsx:
303
+ - Moved `relative` positioning to inner sidebar container
304
+ - Positioned hint box absolutely with `left-[calc(100%+4px)] bottom-[5px]`
305
+ - Added 4px gap between sidebar and hint box
306
+
307
+ **Behavior:** Hint box now appears directly to the right of the Text button at the same vertical level.
308
+
309
+ ### Files Modified
310
+ - `src/components/Canvas/Canvas.tsx` - Text scaling, live resize, cursor positioning
311
+ - `src/components/Canvas/TextEditable.tsx` - Capture click coordinates for cursor
312
+ - `src/components/Canvas/CanvasObject.tsx` - Updated type signatures for cursor positioning
313
+ - `src/components/Sidebar/Sidebar.tsx` - Hint box positioning
314
+
315
+ ### Test Results
316
+ ✅ Font size scales proportionally when transforming text objects
317
+ ✅ Bounding box shrinks/grows live while editing
318
+ ✅ Cursor appears at exact click position on double-click
319
+ ✅ Text hint box aligned with Text button
320
+ ✅ All buttons in sidebar maintain consistent width
321
+
322
+ ### Phase 9 Status
323
+ **Phase 9: Text Feature** is now **Fully Complete** with all refinements:
324
+ - ✅ All core functionality working
325
+ - ✅ All edge cases handled
326
+ - ✅ All UX improvements implemented
327
+
328
+ ### Next Session
329
+ Ready to proceed with **Phase 4: Canvas Header** - Implement background color selector and canvas size selector controls.
330
+
331
+ ---
index.html ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>HF Thumbnail Crafter</title>
8
+
9
+ <!-- Google Fonts -->
10
+ <link rel="preconnect" href="https://fonts.googleapis.com">
11
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
12
+ <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,400;0,700;1,400;1,700&family=Inter:wght@400;700&display=swap" rel="stylesheet">
13
+ </head>
14
+ <body>
15
+ <div id="root"></div>
16
+ <script type="module" src="/src/main.tsx"></script>
17
+ </body>
18
+ </html>
package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
package.json ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "hf-thumbnail-crafter",
3
+ "private": true,
4
+ "version": "0.1.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "tsc && vite build",
9
+ "preview": "vite preview"
10
+ },
11
+ "dependencies": {
12
+ "react": "^18.3.1",
13
+ "react-dom": "^18.3.1",
14
+ "react-konva": "^18.2.10",
15
+ "konva": "^9.3.6",
16
+ "lucide-react": "^0.344.0"
17
+ },
18
+ "devDependencies": {
19
+ "@types/react": "^18.3.3",
20
+ "@types/react-dom": "^18.3.0",
21
+ "@vitejs/plugin-react": "^4.3.1",
22
+ "typescript": "^5.5.3",
23
+ "vite": "^5.4.2",
24
+ "tailwindcss": "^3.4.1",
25
+ "postcss": "^8.4.35",
26
+ "autoprefixer": "^10.4.17"
27
+ }
28
+ }
postcss.config.js ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ export default {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {},
5
+ },
6
+ }
public/assets/layouts/HF logo.png ADDED

Git LFS Details

  • SHA256: 5f411ff4ff03623cb191fd29b0fdc1caf8d0727d5359a513f791149a5d60089d
  • Pointer size: 130 Bytes
  • Size of remote file: 24.2 kB
public/assets/layouts/collabX.svg ADDED
public/assets/layouts/docsHFLogo.png ADDED

Git LFS Details

  • SHA256: d316c53de9eec1aa1fdb5a1ded737b1ec7a0a784875010f4aba12051dae71ba3
  • Pointer size: 130 Bytes
  • Size of remote file: 82 kB
public/assets/layouts/docs_thumbnail.png ADDED

Git LFS Details

  • SHA256: cb8dd0d975672aa19fc021757fc786ddeae32f113dc85f9d8b6d97ecad16fa0c
  • Pointer size: 129 Bytes
  • Size of remote file: 5.69 kB
public/assets/layouts/fCollab_huggy_asset.png ADDED

Git LFS Details

  • SHA256: cf1abed7a8e5ac98d2cd8285b976ebc96663710b73c634e879dfe96a6e494511
  • Pointer size: 131 Bytes
  • Size of remote file: 260 kB
public/assets/layouts/fCollab_huggy_hand_asset.png ADDED

Git LFS Details

  • SHA256: 29ee391a131cf7f657b73a66f5181e3aea7614ebd313ac5b8f84a38f2f1d0989
  • Pointer size: 130 Bytes
  • Size of remote file: 45.5 kB
public/assets/layouts/fCollab_thumbnail.png ADDED

Git LFS Details

  • SHA256: a5c7abaa9c25ff8b5dd4f2b69d131f890ccb166614658f7edf29844fae5da934
  • Pointer size: 129 Bytes
  • Size of remote file: 5.62 kB
public/assets/layouts/logo_placehoder.png ADDED

Git LFS Details

  • SHA256: 0c0df67b939b843f779ea72026f4de1ce1f4a2cab5c0df1555a5689c934a6402
  • Pointer size: 131 Bytes
  • Size of remote file: 178 kB
public/assets/layouts/sCollab_thumbnail.png ADDED

Git LFS Details

  • SHA256: c74c951bed7a2e3da996e4cd71cf6c178a1ec2b030d52c96989e60f9781e4474
  • Pointer size: 129 Bytes
  • Size of remote file: 4.91 kB
public/assets/layouts/sandwitch_thumbnail.png ADDED

Git LFS Details

  • SHA256: b6bbdf806c9dc28194cbf2ff011366ee748fbabb9b5756bf24bf71430fe298c3
  • Pointer size: 129 Bytes
  • Size of remote file: 6.63 kB
public/assets/layouts/snadwithc_huggy_asset.png ADDED

Git LFS Details

  • SHA256: 3d5b4ca3a240a4128de6eec5d78601aa210b57d926457de594310520c9c05f11
  • Pointer size: 130 Bytes
  • Size of remote file: 32.9 kB
public/fonts/Bison-Bold(PersonalUse).ttf ADDED
Binary file (28 kB). View file
 
src/App.tsx ADDED
@@ -0,0 +1,375 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useState } from 'react';
2
+ import Sidebar from './components/Sidebar/Sidebar';
3
+ import ExportButton from './components/ExportButton/ExportButton';
4
+ import CanvasContainer from './components/Canvas/CanvasContainer';
5
+ import Canvas from './components/Canvas/Canvas';
6
+ import CanvasHeader from './components/CanvasHeader/CanvasHeader';
7
+ import { useCanvasState } from './hooks/useCanvasState';
8
+ import { LayoutType } from './types/canvas.types';
9
+ import { getLayoutById } from './data/layouts';
10
+ import { getCanvasDimensions } from './utils/canvas.utils';
11
+ import { Huggy } from './data/huggys';
12
+
13
+ function App() {
14
+ const {
15
+ objects,
16
+ selectedIds,
17
+ canvasSize,
18
+ bgColor,
19
+ setObjects,
20
+ setSelectedIds,
21
+ setCanvasSize,
22
+ setBgColor,
23
+ addObject,
24
+ deleteSelected,
25
+ undo,
26
+ redo
27
+ } = useCanvasState();
28
+
29
+ const [textCreationMode, setTextCreationMode] = useState(false);
30
+ const [activeButton, setActiveButton] = useState<'layout' | 'huggy' | 'image' | 'text' | null>(null);
31
+
32
+ const handleLayoutClick = () => {
33
+ console.log('Layout clicked');
34
+ // Toggle off if already active, otherwise activate
35
+ setActiveButton(activeButton === 'layout' ? null : 'layout');
36
+ setTextCreationMode(false);
37
+ };
38
+
39
+ const handleHuggyClick = () => {
40
+ console.log('Huggy clicked');
41
+ // Toggle off if already active, otherwise activate
42
+ setActiveButton(activeButton === 'huggy' ? null : 'huggy');
43
+ setTextCreationMode(false);
44
+ };
45
+
46
+ const handleImageClick = () => {
47
+ console.log('Image clicked');
48
+ // Toggle off if already active, otherwise activate
49
+ setActiveButton(activeButton === 'image' ? null : 'image');
50
+ setTextCreationMode(false);
51
+ };
52
+
53
+ const handleTextClick = () => {
54
+ // Toggle off if already active, otherwise activate text creation mode
55
+ if (activeButton === 'text') {
56
+ setTextCreationMode(false);
57
+ setActiveButton(null);
58
+ } else {
59
+ setTextCreationMode(true);
60
+ setActiveButton('text');
61
+ }
62
+ };
63
+
64
+ const handleSelectLayout = (layoutId: LayoutType) => {
65
+ const layout = getLayoutById(layoutId);
66
+ if (!layout) return;
67
+
68
+ // Check if there are user-added objects (not from layout)
69
+ const hasUserObjects = objects.some(obj => !obj.isFromLayout);
70
+
71
+ if (hasUserObjects) {
72
+ // Prompt user to choose
73
+ const keepEdits = window.confirm(
74
+ 'You have custom objects on the canvas. Do you want to keep them with the new layout?\n\n' +
75
+ 'Click OK to keep your objects.\n' +
76
+ 'Click Cancel to load a fresh layout (your objects will be removed).'
77
+ );
78
+
79
+ if (keepEdits) {
80
+ // Keep existing objects and add new layout objects
81
+ const layoutObjects = layout.objects.map(obj => ({
82
+ ...obj,
83
+ id: `${obj.id}-${Date.now()}`,
84
+ isFromLayout: true
85
+ }));
86
+ setObjects([...objects, ...layoutObjects]);
87
+ } else {
88
+ // Replace all with fresh layout
89
+ const layoutObjects = layout.objects.map(obj => ({
90
+ ...obj,
91
+ id: `${obj.id}-${Date.now()}`,
92
+ isFromLayout: true
93
+ }));
94
+ setObjects(layoutObjects);
95
+ }
96
+ } else {
97
+ // No user objects, just load the layout normally
98
+ const layoutObjects = layout.objects.map(obj => ({
99
+ ...obj,
100
+ id: `${obj.id}-${Date.now()}`,
101
+ isFromLayout: true
102
+ }));
103
+ setObjects(layoutObjects);
104
+ }
105
+
106
+ setSelectedIds([]);
107
+ setActiveButton(null);
108
+ };
109
+
110
+ const handleSelectHuggy = (huggy: Huggy) => {
111
+ // Get canvas dimensions to center the Huggy
112
+ const dimensions = getCanvasDimensions(canvasSize);
113
+ const huggySize = 200; // Default Huggy size (can be scaled by user later)
114
+
115
+ // Add Huggy image to the center of the canvas
116
+ addObject({
117
+ type: 'image',
118
+ x: dimensions.width / 2 - huggySize / 2,
119
+ y: dimensions.height / 2 - huggySize / 2,
120
+ width: huggySize,
121
+ height: huggySize,
122
+ src: huggy.thumbnail,
123
+ rotation: 0,
124
+ isFromLayout: false
125
+ });
126
+
127
+ // Close the Huggy menu
128
+ setActiveButton(null);
129
+ };
130
+
131
+ const handleTextCreate = (x: number, y: number) => {
132
+ // Create text object at clicked position with default 68px font
133
+ addObject({
134
+ type: 'text',
135
+ x: x,
136
+ y: y,
137
+ width: 100, // Initial small width, will grow with text
138
+ height: 80, // Initial height for 68px font
139
+ rotation: 0,
140
+ text: '',
141
+ fontSize: 68,
142
+ fontFamily: 'Inter',
143
+ fill: '#000000',
144
+ bold: false,
145
+ italic: false,
146
+ isFixedSize: false, // Start in Stage 1 (auto-growing mode)
147
+ isEditing: false
148
+ });
149
+
150
+ // Deactivate text creation mode after creating text
151
+ setTextCreationMode(false);
152
+ setActiveButton(null);
153
+ };
154
+
155
+ const handleExport = (filename: string) => {
156
+ console.log('Export:', filename);
157
+ // Export functionality will be implemented in Phase 11
158
+ };
159
+
160
+ // DISABLED: Handle responsive layout repositioning when canvas size changes
161
+ // This feature caused cumulative scaling issues - see Known Issues in PROJECT_PLAN.md
162
+ // TODO: Re-implement with absolute positioning system
163
+ // useEffect(() => {
164
+ // const prevSize = previousCanvasSizeRef.current;
165
+ // const currentSize = canvasSize;
166
+
167
+ // // Skip if this is the initial render
168
+ // if (prevSize === currentSize) {
169
+ // return;
170
+ // }
171
+
172
+ // // Skip if there are no objects to scale
173
+ // if (objects.length === 0) {
174
+ // previousCanvasSizeRef.current = currentSize;
175
+ // return;
176
+ // }
177
+
178
+ // // Get dimensions for previous and current canvas sizes
179
+ // const prevDimensions = getCanvasDimensions(prevSize);
180
+ // const currentDimensions = getCanvasDimensions(currentSize);
181
+
182
+ // // Calculate scale factors for positions
183
+ // const scaleX = currentDimensions.width / prevDimensions.width;
184
+ // const scaleY = currentDimensions.height / prevDimensions.height;
185
+
186
+ // // Use uniform scale factor to maintain aspect ratios
187
+ // // We use the minimum to ensure everything fits within the canvas
188
+ // const uniformScale = Math.min(scaleX, scaleY);
189
+
190
+ // // Scale all objects proportionally
191
+ // const scaledObjects = objects.map(obj => ({
192
+ // ...obj,
193
+ // x: obj.x * scaleX,
194
+ // y: obj.y * scaleY,
195
+ // width: obj.width * uniformScale,
196
+ // height: obj.height * uniformScale,
197
+ // // Scale fontSize for text objects with uniform scale
198
+ // ...(obj.type === 'text' && { fontSize: obj.fontSize * uniformScale }),
199
+ // // Scale offsetX and offsetY if they exist
200
+ // ...(obj.offsetX !== undefined && { offsetX: obj.offsetX * scaleX }),
201
+ // ...(obj.offsetY !== undefined && { offsetY: obj.offsetY * scaleY })
202
+ // }));
203
+
204
+ // setObjects(scaledObjects);
205
+ // previousCanvasSizeRef.current = currentSize;
206
+ // // eslint-disable-next-line react-hooks/exhaustive-deps
207
+ // }, [canvasSize]);
208
+
209
+ // Handle clicks outside canvas to deselect
210
+ useEffect(() => {
211
+ const handleClickOutside = (e: MouseEvent) => {
212
+ // Find the canvas container element
213
+ const canvasContainer = document.querySelector('.canvas-container');
214
+
215
+ // If click is outside canvas container and there are selected objects, deselect
216
+ if (canvasContainer && !canvasContainer.contains(e.target as Node) && selectedIds.length > 0) {
217
+ setSelectedIds([]);
218
+ }
219
+ };
220
+
221
+ document.addEventListener('mousedown', handleClickOutside);
222
+ return () => document.removeEventListener('mousedown', handleClickOutside);
223
+ }, [selectedIds, setSelectedIds]);
224
+
225
+ // Handle clicks outside sidebar to deselect active button
226
+ useEffect(() => {
227
+ const handleClickOutsideSidebar = (e: MouseEvent) => {
228
+ // Only process if there's an active button
229
+ if (!activeButton) return;
230
+
231
+ // Find all sidebar-related elements (sidebar + menus + hints)
232
+ const sidebar = document.querySelector('.sidebar-container');
233
+ const layoutSelector = document.querySelector('.layout-selector');
234
+ const huggyMenu = document.querySelector('.huggy-menu');
235
+ const textHint = document.querySelector('.text-hint');
236
+ const canvasContainer = document.querySelector('.canvas-container');
237
+
238
+ // Check if click is inside canvas (allow text creation mode to work)
239
+ const isInsideCanvas = canvasContainer && canvasContainer.contains(e.target as Node);
240
+ if (isInsideCanvas && textCreationMode) {
241
+ // Don't deselect if clicking on canvas in text creation mode
242
+ return;
243
+ }
244
+
245
+ // Check if click is outside all sidebar components
246
+ const isOutsideSidebar = sidebar && !sidebar.contains(e.target as Node);
247
+ const isOutsideLayoutSelector = !layoutSelector || !layoutSelector.contains(e.target as Node);
248
+ const isOutsideHuggyMenu = !huggyMenu || !huggyMenu.contains(e.target as Node);
249
+ const isOutsideTextHint = !textHint || !textHint.contains(e.target as Node);
250
+
251
+ // If click is outside all sidebar components, deselect
252
+ if (isOutsideSidebar && isOutsideLayoutSelector && isOutsideHuggyMenu && isOutsideTextHint) {
253
+ setActiveButton(null);
254
+ setTextCreationMode(false);
255
+ }
256
+ };
257
+
258
+ document.addEventListener('mousedown', handleClickOutsideSidebar);
259
+ return () => document.removeEventListener('mousedown', handleClickOutsideSidebar);
260
+ }, [activeButton, textCreationMode]);
261
+
262
+ // Handle keyboard shortcuts
263
+ useEffect(() => {
264
+ const handleKeyDown = (e: KeyboardEvent) => {
265
+ // Check if user is editing text
266
+ const isEditingText = objects.some(obj => obj.type === 'text' && obj.isEditing);
267
+
268
+ // Undo with Ctrl+Z (or Cmd+Z on Mac)
269
+ if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey && !isEditingText) {
270
+ e.preventDefault();
271
+ undo();
272
+ return;
273
+ }
274
+
275
+ // Redo with Ctrl+Shift+Z (or Cmd+Shift+Z on Mac)
276
+ if ((e.ctrlKey || e.metaKey) && e.key === 'z' && e.shiftKey && !isEditingText) {
277
+ e.preventDefault();
278
+ redo();
279
+ return;
280
+ }
281
+
282
+ // Arrow key movement (only when NOT editing text and has selection)
283
+ if (!isEditingText && selectedIds.length > 0 && ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
284
+ e.preventDefault();
285
+ const moveDistance = e.shiftKey ? 10 : 1;
286
+
287
+ const updatedObjects = objects.map(obj => {
288
+ if (selectedIds.includes(obj.id)) {
289
+ let newX = obj.x;
290
+ let newY = obj.y;
291
+
292
+ switch (e.key) {
293
+ case 'ArrowUp':
294
+ newY -= moveDistance;
295
+ break;
296
+ case 'ArrowDown':
297
+ newY += moveDistance;
298
+ break;
299
+ case 'ArrowLeft':
300
+ newX -= moveDistance;
301
+ break;
302
+ case 'ArrowRight':
303
+ newX += moveDistance;
304
+ break;
305
+ }
306
+
307
+ return { ...obj, x: newX, y: newY };
308
+ }
309
+ return obj;
310
+ });
311
+
312
+ setObjects(updatedObjects);
313
+ return;
314
+ }
315
+
316
+ // Delete selected objects (only when NOT editing text)
317
+ if ((e.key === 'Delete' || e.key === 'Backspace') && selectedIds.length > 0 && !isEditingText) {
318
+ e.preventDefault();
319
+ deleteSelected();
320
+ }
321
+
322
+ // Activate text creation mode with 'T' key
323
+ if (e.key === 't' || e.key === 'T') {
324
+ // Don't activate if user is typing in an input or textarea
325
+ const target = e.target as HTMLElement;
326
+ if (target.tagName !== 'INPUT' && target.tagName !== 'TEXTAREA') {
327
+ handleTextClick();
328
+ }
329
+ }
330
+ };
331
+
332
+ window.addEventListener('keydown', handleKeyDown);
333
+ return () => window.removeEventListener('keydown', handleKeyDown);
334
+ }, [selectedIds, deleteSelected, objects, setObjects, undo, redo]);
335
+
336
+ return (
337
+ <div className="w-full h-full bg-[#f8f9fa] relative overflow-hidden dotted-background">
338
+ {/* Sidebar */}
339
+ <Sidebar
340
+ onLayoutClick={handleLayoutClick}
341
+ onHuggyClick={handleHuggyClick}
342
+ onImageClick={handleImageClick}
343
+ onTextClick={handleTextClick}
344
+ onSelectLayout={handleSelectLayout}
345
+ onSelectHuggy={handleSelectHuggy}
346
+ activeButton={activeButton}
347
+ />
348
+
349
+ {/* Export Button */}
350
+ <ExportButton onExport={handleExport} />
351
+
352
+ {/* Canvas Container */}
353
+ <CanvasContainer>
354
+ <CanvasHeader
355
+ canvasSize={canvasSize}
356
+ bgColor={bgColor}
357
+ onCanvasSizeChange={setCanvasSize}
358
+ onBgColorChange={setBgColor}
359
+ />
360
+ <Canvas
361
+ canvasSize={canvasSize}
362
+ bgColor={bgColor}
363
+ objects={objects}
364
+ selectedIds={selectedIds}
365
+ onSelect={setSelectedIds}
366
+ onObjectsChange={setObjects}
367
+ textCreationMode={textCreationMode}
368
+ onTextCreate={handleTextCreate}
369
+ />
370
+ </CanvasContainer>
371
+ </div>
372
+ );
373
+ }
374
+
375
+ export default App;
src/assets/icons/huggy-body.svg ADDED
src/assets/icons/huggy-eyes.svg ADDED
src/assets/icons/huggy-hands.svg ADDED
src/assets/icons/huggy-mouth.svg ADDED
src/assets/icons/image-default.svg ADDED
src/assets/icons/image-selected.svg ADDED
src/assets/icons/layout-default.svg ADDED
src/assets/icons/layout-selected.svg ADDED
src/assets/icons/text-default.svg ADDED
src/assets/icons/text-selected.svg ADDED
src/components/Canvas/Canvas.tsx ADDED
@@ -0,0 +1,595 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useRef, useEffect, useState } from 'react';
2
+ import { Stage, Layer, Transformer, Rect } from 'react-konva';
3
+ import { CanvasObject, CanvasSize, CanvasBgColor, TextObject } from '../../types/canvas.types';
4
+ import { getCanvasDimensions, sortByZIndex } from '../../utils/canvas.utils';
5
+ import CanvasObjectRenderer from './CanvasObject';
6
+ import Konva from 'konva';
7
+
8
+ interface CanvasProps {
9
+ canvasSize: CanvasSize;
10
+ bgColor: CanvasBgColor;
11
+ objects: CanvasObject[];
12
+ selectedIds: string[];
13
+ onSelect: (ids: string[]) => void;
14
+ onObjectsChange: (objects: CanvasObject[]) => void;
15
+ textCreationMode?: boolean;
16
+ onTextCreate?: (x: number, y: number) => void;
17
+ }
18
+
19
+ // Helper function to calculate cursor position from click coordinates
20
+ function calculateCursorPosition(text: string, clickX: number, fontSize: number, fontFamily: string, bold: boolean, italic: boolean): number {
21
+ if (!text || clickX <= 0) return 0;
22
+
23
+ try {
24
+ const tempText = new Konva.Text({
25
+ text: text,
26
+ fontSize: fontSize,
27
+ fontFamily: fontFamily,
28
+ fontStyle: `${bold ? 'bold' : 'normal'} ${italic ? 'italic' : ''}`
29
+ });
30
+
31
+ // Find the closest character position
32
+ let closestPos = 0;
33
+ let closestDist = Infinity;
34
+
35
+ for (let i = 0; i <= text.length; i++) {
36
+ const substr = text.substring(0, i);
37
+ tempText.text(substr);
38
+ const width = tempText.width();
39
+ const dist = Math.abs(width - clickX);
40
+
41
+ if (dist < closestDist) {
42
+ closestDist = dist;
43
+ closestPos = i;
44
+ }
45
+ }
46
+
47
+ tempText.destroy();
48
+ return closestPos;
49
+ } catch (error) {
50
+ console.error('Error calculating cursor position:', error);
51
+ return text.length; // Default to end
52
+ }
53
+ }
54
+
55
+ export default function Canvas({
56
+ canvasSize,
57
+ bgColor,
58
+ objects,
59
+ selectedIds,
60
+ onSelect,
61
+ onObjectsChange,
62
+ textCreationMode = false,
63
+ onTextCreate
64
+ }: CanvasProps) {
65
+ const stageRef = useRef<Konva.Stage>(null);
66
+ const transformerRef = useRef<Konva.Transformer>(null);
67
+ const shapeRefs = useRef<Map<string, Konva.Node>>(new Map());
68
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
69
+ const cursorPositionRef = useRef<number | null>(null);
70
+ const [fontsLoaded, setFontsLoaded] = useState(false);
71
+ const [selectionBox, setSelectionBox] = useState<{ x: number; y: number; width: number; height: number } | null>(null);
72
+ const selectionStartRef = useRef<{ x: number; y: number } | null>(null);
73
+
74
+ const dimensions = getCanvasDimensions(canvasSize);
75
+ const backgroundColor = bgColor === 'light' ? '#ffffff' : '#1a1a2e';
76
+
77
+ // Wait for fonts to load before rendering
78
+ useEffect(() => {
79
+ const loadFonts = async () => {
80
+ if (document.fonts) {
81
+ try {
82
+ // Explicitly load all fonts used in layouts
83
+ await Promise.all([
84
+ document.fonts.load('bold 100px Bison'),
85
+ document.fonts.load('bold 100px Inter'),
86
+ document.fonts.load('400 50px "IBM Plex Mono"'),
87
+ ]);
88
+ // Wait for all fonts to be ready
89
+ await document.fonts.ready;
90
+ // Add small delay to ensure fonts are fully applied
91
+ await new Promise(resolve => setTimeout(resolve, 50));
92
+ setFontsLoaded(true);
93
+ // Force canvas re-render after fonts load
94
+ if (stageRef.current) {
95
+ const layer = stageRef.current.getLayers()[0];
96
+ if (layer) {
97
+ layer.batchDraw();
98
+ }
99
+ }
100
+ } catch (error) {
101
+ console.error('Font loading error:', error);
102
+ setFontsLoaded(true);
103
+ }
104
+ } else {
105
+ // Fallback for browsers without Font Loading API
106
+ setFontsLoaded(true);
107
+ }
108
+ };
109
+
110
+ loadFonts();
111
+ }, []);
112
+
113
+ // Force re-render when objects change and fonts are loaded
114
+ useEffect(() => {
115
+ if (fontsLoaded && stageRef.current) {
116
+ const layer = stageRef.current.getLayers()[0];
117
+ if (layer) {
118
+ layer.batchDraw();
119
+ }
120
+ }
121
+ }, [objects, fontsLoaded]);
122
+
123
+ // Get the editing text object
124
+ const editingText = objects.find(obj => obj.type === 'text' && obj.isEditing) as TextObject | undefined;
125
+
126
+ // Sort objects by zIndex for proper rendering order
127
+ const sortedObjects = sortByZIndex(objects);
128
+
129
+ // Update transformer when selection changes or objects change
130
+ useEffect(() => {
131
+ if (!transformerRef.current) return;
132
+
133
+ if (selectedIds.length > 0) {
134
+ const nodes = selectedIds
135
+ .map(id => shapeRefs.current.get(id))
136
+ .filter((node): node is Konva.Node => node !== undefined);
137
+
138
+ console.log('Attaching transformer to:', selectedIds, 'Nodes:', nodes);
139
+ if (nodes.length > 0) {
140
+ transformerRef.current.nodes(nodes);
141
+ transformerRef.current.show();
142
+ transformerRef.current.forceUpdate();
143
+ } else {
144
+ transformerRef.current.nodes([]);
145
+ }
146
+ } else {
147
+ transformerRef.current.nodes([]);
148
+ }
149
+
150
+ const layer = transformerRef.current.getLayer();
151
+ if (layer) {
152
+ layer.batchDraw();
153
+ }
154
+ }, [selectedIds, objects]);
155
+
156
+ // Handle object selection
157
+ const handleSelect = (id: string, shiftKey: boolean = false) => {
158
+ if (shiftKey) {
159
+ // Shift+Click: add/remove from selection
160
+ if (selectedIds.includes(id)) {
161
+ // Remove from selection
162
+ onSelect(selectedIds.filter(selectedId => selectedId !== id));
163
+ } else {
164
+ // Add to selection
165
+ onSelect([...selectedIds, id]);
166
+ }
167
+ } else {
168
+ // Normal click: select only this object
169
+ onSelect([id]);
170
+ }
171
+ };
172
+
173
+ // Handle drag end to update object position
174
+ const handleDragEnd = (id: string) => (e: Konva.KonvaEventObject<DragEvent>) => {
175
+ const node = e.target;
176
+ const updatedObjects = objects.map(obj =>
177
+ obj.id === id
178
+ ? { ...obj, x: node.x(), y: node.y() }
179
+ : obj
180
+ );
181
+ onObjectsChange(updatedObjects);
182
+ };
183
+
184
+ // Handle transform end to update object dimensions and rotation
185
+ const handleTransformEnd = (id: string) => (e: Konva.KonvaEventObject<Event>) => {
186
+ const node = e.target;
187
+ const scaleX = node.scaleX();
188
+ const scaleY = node.scaleY();
189
+
190
+ // Reset scale to 1 and update width/height instead
191
+ node.scaleX(1);
192
+ node.scaleY(1);
193
+
194
+ const updatedObjects = objects.map(obj => {
195
+ if (obj.id === id) {
196
+ const updated = {
197
+ ...obj,
198
+ x: node.x(),
199
+ y: node.y(),
200
+ width: Math.max(5, node.width() * scaleX),
201
+ height: Math.max(5, node.height() * scaleY),
202
+ rotation: node.rotation()
203
+ };
204
+
205
+ // Handle text objects: scale fontSize and mark as fixed size
206
+ if (obj.type === 'text') {
207
+ // Use the smaller scale factor to maintain readability
208
+ const scaleFactor = Math.min(scaleX, scaleY);
209
+ const newFontSize = Math.max(10, obj.fontSize * scaleFactor);
210
+
211
+ return {
212
+ ...updated,
213
+ fontSize: newFontSize,
214
+ isFixedSize: true
215
+ };
216
+ }
217
+ return updated;
218
+ }
219
+ return obj;
220
+ });
221
+ onObjectsChange(updatedObjects);
222
+ };
223
+
224
+ // Handle text content changes
225
+ const handleTextChange = (id: string, text: string, width: number, height: number) => {
226
+ const updatedObjects = objects.map(obj =>
227
+ obj.id === id && obj.type === 'text'
228
+ ? { ...obj, text, width, height }
229
+ : obj
230
+ );
231
+ onObjectsChange(updatedObjects);
232
+ };
233
+
234
+ // Handle editing state changes
235
+ const handleEditingChange = (id: string, isEditing: boolean, clickX?: number, clickY?: number) => {
236
+ const updatedObjects = objects.map(obj =>
237
+ obj.id === id && obj.type === 'text'
238
+ ? { ...obj, isEditing, isFixedSize: isEditing ? obj.isFixedSize : true }
239
+ : obj
240
+ );
241
+ onObjectsChange(updatedObjects);
242
+
243
+ // Calculate cursor position if click coordinates provided
244
+ if (isEditing && clickX !== undefined && clickY !== undefined) {
245
+ const textObj = objects.find(obj => obj.id === id && obj.type === 'text') as TextObject | undefined;
246
+ if (textObj) {
247
+ const cursorPos = calculateCursorPosition(
248
+ textObj.text,
249
+ clickX,
250
+ textObj.fontSize,
251
+ textObj.fontFamily,
252
+ textObj.bold,
253
+ textObj.italic
254
+ );
255
+ cursorPositionRef.current = cursorPos;
256
+ }
257
+ } else if (isEditing) {
258
+ // If no click position, set cursor to end
259
+ cursorPositionRef.current = null;
260
+ }
261
+ };
262
+
263
+ // Focus textarea when editing starts
264
+ useEffect(() => {
265
+ if (editingText && textareaRef.current) {
266
+ textareaRef.current.focus();
267
+
268
+ // Set cursor position based on click location
269
+ if (cursorPositionRef.current !== null) {
270
+ const pos = cursorPositionRef.current;
271
+ textareaRef.current.setSelectionRange(pos, pos);
272
+ cursorPositionRef.current = null; // Reset after use
273
+ } else {
274
+ // Default: cursor at end
275
+ const len = textareaRef.current.value.length;
276
+ textareaRef.current.setSelectionRange(len, len);
277
+ }
278
+ }
279
+ }, [editingText?.id]);
280
+
281
+ // Handle textarea text change
282
+ const handleTextareaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
283
+ if (!editingText) return;
284
+
285
+ const newText = e.target.value;
286
+
287
+ try {
288
+ // Calculate text dimensions
289
+ const tempText = new Konva.Text({
290
+ text: newText || 'M',
291
+ fontSize: editingText.fontSize,
292
+ fontFamily: editingText.fontFamily,
293
+ fontStyle: `${editingText.bold ? 'bold' : 'normal'} ${editingText.italic ? 'italic' : ''}`
294
+ });
295
+
296
+ // Always auto-grow/shrink box while editing (live resize)
297
+ const newWidth = Math.max(100, tempText.width() + 20);
298
+ const newHeight = Math.max(40, tempText.height() + 10);
299
+
300
+ tempText.destroy();
301
+ handleTextChange(editingText.id, newText, newWidth, newHeight);
302
+ } catch (error) {
303
+ console.error('Error in textarea change:', error);
304
+ handleTextChange(editingText.id, newText, editingText.width, editingText.height);
305
+ }
306
+ };
307
+
308
+ // Handle textarea blur
309
+ const handleTextareaBlur = () => {
310
+ if (editingText) {
311
+ handleEditingChange(editingText.id, false);
312
+ }
313
+ };
314
+
315
+ // Get textarea position
316
+ const getTextareaPosition = () => {
317
+ if (!editingText || !stageRef.current) return { top: 0, left: 0 };
318
+
319
+ const stage = stageRef.current;
320
+ const container = stage.container();
321
+ const containerRect = container.getBoundingClientRect();
322
+
323
+ return {
324
+ top: containerRect.top + editingText.y,
325
+ left: containerRect.left + editingText.x
326
+ };
327
+ };
328
+
329
+ const textareaPos = editingText ? getTextareaPosition() : { top: 0, left: 0 };
330
+
331
+ // Calculate font size to fit text in fixed box (for live preview during editing)
332
+ const calculateFitFontSize = (textObj: TextObject): number => {
333
+ if (!textObj.isFixedSize || !textObj.text) return textObj.fontSize;
334
+
335
+ try {
336
+ const tempText = new Konva.Text({
337
+ text: textObj.text,
338
+ fontSize: textObj.fontSize,
339
+ fontFamily: textObj.fontFamily,
340
+ fontStyle: `${textObj.bold ? 'bold' : 'normal'} ${textObj.italic ? 'italic' : ''}`,
341
+ width: textObj.width,
342
+ height: textObj.height
343
+ });
344
+
345
+ let fontSize = textObj.fontSize;
346
+ const maxWidth = textObj.width;
347
+ const maxHeight = textObj.height;
348
+
349
+ while (fontSize > 10) {
350
+ tempText.fontSize(fontSize);
351
+ const size = tempText.measureSize(textObj.text);
352
+
353
+ if (size.width <= maxWidth && size.height <= maxHeight) {
354
+ break;
355
+ }
356
+ fontSize -= 1;
357
+ }
358
+
359
+ tempText.destroy();
360
+ return fontSize;
361
+ } catch (error) {
362
+ return textObj.fontSize;
363
+ }
364
+ };
365
+
366
+ // Handle mouse down for selection box
367
+ const handleMouseDown = (e: Konva.KonvaEventObject<MouseEvent>) => {
368
+ // Only start selection box on empty canvas area
369
+ if (e.target !== e.target.getStage()) return;
370
+
371
+ // Don't start selection box in text creation mode
372
+ if (textCreationMode) return;
373
+
374
+ const stage = e.target.getStage();
375
+ const pos = stage?.getPointerPosition();
376
+ if (pos) {
377
+ selectionStartRef.current = pos;
378
+ setSelectionBox({ x: pos.x, y: pos.y, width: 0, height: 0 });
379
+ }
380
+ };
381
+
382
+ // Handle mouse move for selection box
383
+ const handleMouseMove = (e: Konva.KonvaEventObject<MouseEvent>) => {
384
+ if (!selectionStartRef.current) return;
385
+
386
+ const stage = e.target.getStage();
387
+ const pos = stage?.getPointerPosition();
388
+ if (pos) {
389
+ const start = selectionStartRef.current;
390
+ setSelectionBox({
391
+ x: Math.min(start.x, pos.x),
392
+ y: Math.min(start.y, pos.y),
393
+ width: Math.abs(pos.x - start.x),
394
+ height: Math.abs(pos.y - start.y)
395
+ });
396
+ }
397
+ };
398
+
399
+ // Handle mouse up for selection box
400
+ const handleMouseUp = (e: Konva.KonvaEventObject<MouseEvent>) => {
401
+ if (!selectionStartRef.current) {
402
+ return;
403
+ }
404
+
405
+ // Check if this was a drag or just a click
406
+ const wasDragging = selectionBox && (selectionBox.width > 5 || selectionBox.height > 5);
407
+
408
+ if (wasDragging) {
409
+ // Find objects within selection box
410
+ const selected: string[] = [];
411
+ objects.forEach(obj => {
412
+ // Check if object intersects with selection box
413
+ const objBox = {
414
+ x: obj.x,
415
+ y: obj.y,
416
+ width: obj.width,
417
+ height: obj.height
418
+ };
419
+
420
+ const boxIntersects = !(
421
+ selectionBox.x > objBox.x + objBox.width ||
422
+ selectionBox.x + selectionBox.width < objBox.x ||
423
+ selectionBox.y > objBox.y + objBox.height ||
424
+ selectionBox.y + selectionBox.height < objBox.y
425
+ );
426
+
427
+ if (boxIntersects) {
428
+ selected.push(obj.id);
429
+ }
430
+ });
431
+
432
+ // Update selection
433
+ if (selected.length > 0) {
434
+ onSelect(selected);
435
+ } else {
436
+ // No objects in selection box, deselect all
437
+ onSelect([]);
438
+ }
439
+ } else {
440
+ // Was a click, not a drag - deselect all
441
+ if (e.target === e.target.getStage()) {
442
+ onSelect([]);
443
+ }
444
+ }
445
+
446
+ // Reset selection box
447
+ selectionStartRef.current = null;
448
+ setSelectionBox(null);
449
+ };
450
+
451
+ return (
452
+ <div
453
+ style={{
454
+ width: dimensions.width,
455
+ height: dimensions.height,
456
+ backgroundColor,
457
+ borderRadius: '4px',
458
+ overflow: 'hidden',
459
+ transition: 'width 0.15s ease-in-out, height 0.15s ease-in-out'
460
+ }}
461
+ >
462
+ <Stage
463
+ ref={stageRef}
464
+ width={dimensions.width}
465
+ height={dimensions.height}
466
+ onMouseDown={handleMouseDown}
467
+ onMouseMove={handleMouseMove}
468
+ onMouseUp={handleMouseUp}
469
+ onClick={(e) => {
470
+ // Check if clicking on empty canvas area
471
+ if (e.target === e.target.getStage()) {
472
+ if (textCreationMode && onTextCreate) {
473
+ // Get click position relative to the stage
474
+ const stage = e.target.getStage();
475
+ const pointerPosition = stage?.getPointerPosition();
476
+ if (pointerPosition) {
477
+ onTextCreate(pointerPosition.x, pointerPosition.y);
478
+ }
479
+ }
480
+ // Note: deselection is now handled in handleMouseUp
481
+ }
482
+ }}
483
+ style={{ cursor: textCreationMode ? 'text' : 'default' }}
484
+ >
485
+ <Layer>
486
+ {sortedObjects.map((obj) => {
487
+ return (
488
+ <CanvasObjectRenderer
489
+ key={obj.id}
490
+ object={obj}
491
+ isSelected={selectedIds.includes(obj.id)}
492
+ onSelect={(e?: Konva.KonvaEventObject<MouseEvent>) => {
493
+ const shiftKey = e?.evt?.shiftKey || false;
494
+ handleSelect(obj.id, shiftKey);
495
+ }}
496
+ onDragEnd={handleDragEnd(obj.id)}
497
+ onTransformEnd={handleTransformEnd(obj.id)}
498
+ onEditingChange={handleEditingChange}
499
+ shapeRef={(node: Konva.Node | null) => {
500
+ if (node) {
501
+ shapeRefs.current.set(obj.id, node);
502
+ } else {
503
+ shapeRefs.current.delete(obj.id);
504
+ }
505
+ }}
506
+ />
507
+ );
508
+ })}
509
+
510
+ {/* Selection box */}
511
+ {selectionBox && (
512
+ <Rect
513
+ x={selectionBox.x}
514
+ y={selectionBox.y}
515
+ width={selectionBox.width}
516
+ height={selectionBox.height}
517
+ fill="rgba(63, 174, 230, 0.1)"
518
+ stroke="#3faee6"
519
+ strokeWidth={1}
520
+ dash={[5, 5]}
521
+ listening={false}
522
+ />
523
+ )}
524
+
525
+ {/* Transformer for selected objects */}
526
+ <Transformer
527
+ ref={transformerRef}
528
+ keepRatio={true}
529
+ enabledAnchors={[
530
+ 'top-left',
531
+ 'top-right',
532
+ 'bottom-left',
533
+ 'bottom-right'
534
+ ]}
535
+ rotateEnabled={true}
536
+ anchorSize={8}
537
+ anchorCornerRadius={2}
538
+ borderStroke="#3faee6"
539
+ borderStrokeWidth={2}
540
+ anchorStroke="#3faee6"
541
+ anchorFill="#ffffff"
542
+ anchorStrokeWidth={2}
543
+ boundBoxFunc={(oldBox, newBox) => {
544
+ // Limit minimum size
545
+ if (newBox.width < 5 || newBox.height < 5) {
546
+ return oldBox;
547
+ }
548
+ return newBox;
549
+ }}
550
+ />
551
+ </Layer>
552
+ </Stage>
553
+
554
+ {/* Textarea for text editing - rendered outside Konva */}
555
+ {editingText && (
556
+ <textarea
557
+ ref={textareaRef}
558
+ value={editingText.text}
559
+ onChange={handleTextareaChange}
560
+ onBlur={handleTextareaBlur}
561
+ onKeyDown={(e) => {
562
+ if (e.key === 'Escape') {
563
+ handleTextareaBlur();
564
+ }
565
+ }}
566
+ style={{
567
+ position: 'fixed',
568
+ top: `${textareaPos.top}px`,
569
+ left: `${textareaPos.left}px`,
570
+ width: `${editingText.width}px`,
571
+ height: `${editingText.height}px`,
572
+ fontSize: `${editingText.isFixedSize ? calculateFitFontSize(editingText) : editingText.fontSize}px`,
573
+ fontFamily: editingText.fontFamily,
574
+ fontWeight: editingText.bold ? 'bold' : 'normal',
575
+ fontStyle: editingText.italic ? 'italic' : 'normal',
576
+ color: editingText.fill,
577
+ background: 'transparent',
578
+ border: 'none',
579
+ borderRadius: '0',
580
+ padding: '0',
581
+ margin: '0',
582
+ resize: 'none',
583
+ outline: 'none',
584
+ overflow: 'hidden',
585
+ lineHeight: '1',
586
+ verticalAlign: 'top',
587
+ zIndex: 999,
588
+ pointerEvents: 'auto',
589
+ boxSizing: 'border-box'
590
+ }}
591
+ />
592
+ )}
593
+ </div>
594
+ );
595
+ }
src/components/Canvas/CanvasContainer.tsx ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ReactNode } from 'react';
2
+
3
+ interface CanvasContainerProps {
4
+ children: ReactNode;
5
+ }
6
+
7
+ export default function CanvasContainer({ children }: CanvasContainerProps) {
8
+ return (
9
+ <div style={{
10
+ display: 'flex',
11
+ flexDirection: 'column',
12
+ alignItems: 'center',
13
+ justifyContent: 'center',
14
+ width: '100%',
15
+ height: '100%'
16
+ }}>
17
+ <div
18
+ className="canvas-container"
19
+ style={{
20
+ display: 'flex',
21
+ flexDirection: 'column',
22
+ gap: '10px',
23
+ transition: 'all 0.15s ease-in-out'
24
+ }}
25
+ >
26
+ {children}
27
+ </div>
28
+ </div>
29
+ );
30
+ }
src/components/Canvas/CanvasObject.tsx ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Rect, Image as KonvaImage, Group } from 'react-konva';
2
+ import { CanvasObject, LogoPlaceholderObject } from '../../types/canvas.types';
3
+ import { useEffect, useRef, useState } from 'react';
4
+ import Konva from 'konva';
5
+ import TextEditable from './TextEditable';
6
+
7
+ interface CanvasObjectProps {
8
+ object: CanvasObject;
9
+ isSelected: boolean;
10
+ onSelect: (e?: Konva.KonvaEventObject<MouseEvent>) => void;
11
+ onDragEnd?: (e: Konva.KonvaEventObject<DragEvent>) => void;
12
+ onTransformEnd?: (e: Konva.KonvaEventObject<Event>) => void;
13
+ onEditingChange?: (id: string, isEditing: boolean, clickX?: number, clickY?: number) => void;
14
+ shapeRef?: ((node: Konva.Node | null) => void) | React.RefObject<Konva.Node>;
15
+ }
16
+
17
+ export default function CanvasObjectRenderer({
18
+ object,
19
+ isSelected,
20
+ onSelect,
21
+ onDragEnd,
22
+ onTransformEnd,
23
+ onEditingChange,
24
+ shapeRef
25
+ }: CanvasObjectProps) {
26
+ switch (object.type) {
27
+ case 'rect':
28
+ return (
29
+ <Rect
30
+ id={object.id}
31
+ ref={(node) => {
32
+ if (typeof shapeRef === 'function') {
33
+ shapeRef(node);
34
+ } else if (shapeRef) {
35
+ (shapeRef as React.MutableRefObject<Konva.Rect | null>).current = node;
36
+ }
37
+ }}
38
+ x={object.x}
39
+ y={object.y}
40
+ width={object.width}
41
+ height={object.height}
42
+ fill={object.fill}
43
+ stroke={object.stroke}
44
+ strokeWidth={object.strokeWidth}
45
+ rotation={object.rotation}
46
+ draggable
47
+ onClick={(e) => onSelect(e)}
48
+ onTap={(e) => onSelect(e)}
49
+ onDragEnd={onDragEnd}
50
+ onTransformEnd={onTransformEnd}
51
+ />
52
+ );
53
+
54
+ case 'image':
55
+ case 'huggy':
56
+ return (
57
+ <ImageRenderer
58
+ object={object}
59
+ onSelect={onSelect}
60
+ onDragEnd={onDragEnd}
61
+ onTransformEnd={onTransformEnd}
62
+ shapeRef={shapeRef}
63
+ />
64
+ );
65
+
66
+ case 'text':
67
+ return (
68
+ <TextEditable
69
+ object={object}
70
+ isSelected={isSelected}
71
+ onSelect={onSelect}
72
+ onDragEnd={onDragEnd}
73
+ onTransformEnd={onTransformEnd}
74
+ onEditingChange={onEditingChange || (() => {})}
75
+ shapeRef={shapeRef as React.RefObject<Konva.Text>}
76
+ />
77
+ );
78
+
79
+ case 'logoPlaceholder':
80
+ return (
81
+ <LogoPlaceholderRenderer
82
+ object={object}
83
+ onSelect={onSelect}
84
+ onDragEnd={onDragEnd}
85
+ onTransformEnd={onTransformEnd}
86
+ shapeRef={shapeRef}
87
+ />
88
+ );
89
+
90
+ default:
91
+ return null;
92
+ }
93
+ }
94
+
95
+ // Image renderer with image loading
96
+ function ImageRenderer({
97
+ object,
98
+ onSelect,
99
+ onDragEnd,
100
+ onTransformEnd,
101
+ shapeRef
102
+ }: Omit<CanvasObjectProps, 'isSelected'>) {
103
+ const [image, setImage] = useState<HTMLImageElement | null>(null);
104
+ const imageRef = useRef<HTMLImageElement | null>(null);
105
+
106
+ useEffect(() => {
107
+ const img = new window.Image();
108
+ img.src = object.type === 'image' || object.type === 'huggy' ? object.src : '';
109
+ img.crossOrigin = 'anonymous';
110
+
111
+ img.onload = () => {
112
+ setImage(img);
113
+ imageRef.current = img;
114
+ };
115
+
116
+ return () => {
117
+ imageRef.current = null;
118
+ };
119
+ }, [object]);
120
+
121
+ if (!image) return null;
122
+
123
+ return (
124
+ <KonvaImage
125
+ id={object.id}
126
+ ref={(node) => {
127
+ if (typeof shapeRef === 'function') {
128
+ shapeRef(node);
129
+ } else if (shapeRef) {
130
+ (shapeRef as React.MutableRefObject<Konva.Image | null>).current = node;
131
+ }
132
+ }}
133
+ x={object.x}
134
+ y={object.y}
135
+ width={object.width}
136
+ height={object.height}
137
+ image={image}
138
+ rotation={object.rotation}
139
+ draggable
140
+ onClick={(e) => onSelect(e)}
141
+ onTap={(e) => onSelect(e)}
142
+ onDragEnd={onDragEnd}
143
+ onTransformEnd={onTransformEnd}
144
+ />
145
+ );
146
+ }
147
+
148
+ // Logo placeholder renderer with blur effect
149
+ function LogoPlaceholderRenderer({
150
+ object,
151
+ onSelect,
152
+ onDragEnd,
153
+ onTransformEnd,
154
+ shapeRef
155
+ }: Omit<CanvasObjectProps, 'isSelected'>) {
156
+ const rectRef = useRef<Konva.Rect>(null);
157
+
158
+ useEffect(() => {
159
+ if (rectRef.current && object.type === 'logoPlaceholder') {
160
+ rectRef.current.cache();
161
+ rectRef.current.filters([Konva.Filters.Blur]);
162
+ rectRef.current.blurRadius(object.blurRadius);
163
+ }
164
+ }, [object]);
165
+
166
+ if (object.type !== 'logoPlaceholder') return null;
167
+
168
+ return (
169
+ <Rect
170
+ id={object.id}
171
+ ref={(node) => {
172
+ rectRef.current = node;
173
+ if (typeof shapeRef === 'function') {
174
+ shapeRef(node);
175
+ } else if (shapeRef) {
176
+ (shapeRef as React.MutableRefObject<Konva.Rect | null>).current = node;
177
+ }
178
+ }}
179
+ x={object.x}
180
+ y={object.y}
181
+ width={object.width}
182
+ height={object.height}
183
+ fill="rgba(255, 255, 255, 0.3)"
184
+ stroke="rgba(200, 200, 200, 0.5)"
185
+ strokeWidth={2}
186
+ rotation={object.rotation}
187
+ draggable
188
+ onClick={(e) => onSelect(e)}
189
+ onTap={(e) => onSelect(e)}
190
+ onDragEnd={onDragEnd}
191
+ onTransformEnd={onTransformEnd}
192
+ />
193
+ );
194
+ }
src/components/Canvas/TextEditable.tsx ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useRef } from 'react';
2
+ import { Text as KonvaText } from 'react-konva';
3
+ import { TextObject } from '../../types/canvas.types';
4
+ import Konva from 'konva';
5
+
6
+ interface TextEditableProps {
7
+ object: TextObject;
8
+ isSelected: boolean;
9
+ onSelect: (e?: Konva.KonvaEventObject<MouseEvent>) => void;
10
+ onDragEnd?: (e: Konva.KonvaEventObject<DragEvent>) => void;
11
+ onTransformEnd?: (e: Konva.KonvaEventObject<Event>) => void;
12
+ onEditingChange: (id: string, isEditing: boolean, clickX?: number, clickY?: number) => void;
13
+ shapeRef?: ((node: Konva.Text | null) => void) | React.RefObject<Konva.Text>;
14
+ }
15
+
16
+ export default function TextEditable({
17
+ object,
18
+ isSelected,
19
+ onSelect,
20
+ onDragEnd,
21
+ onTransformEnd,
22
+ onEditingChange,
23
+ shapeRef
24
+ }: TextEditableProps) {
25
+ const textNodeRef = useRef<Konva.Text | null>(null);
26
+
27
+ // Auto-enter edit mode when text is first created (empty text)
28
+ useEffect(() => {
29
+ if (object.text === '' && isSelected && !object.isFixedSize && !object.isEditing) {
30
+ // Delay to ensure Konva node is fully rendered
31
+ const timer = setTimeout(() => {
32
+ onEditingChange(object.id, true);
33
+ }, 100);
34
+ return () => clearTimeout(timer);
35
+ }
36
+ }, [object.text, isSelected, object.isFixedSize, object.isEditing, object.id, onEditingChange]);
37
+
38
+ // Handle double-click to edit
39
+ const handleDoubleClick = (e: Konva.KonvaEventObject<MouseEvent>) => {
40
+ if (!object.isEditing) {
41
+ const stage = e.target.getStage();
42
+ const pointerPos = stage?.getPointerPosition();
43
+ if (pointerPos) {
44
+ // Get click position relative to the text object
45
+ const clickX = pointerPos.x - object.x;
46
+ const clickY = pointerPos.y - object.y;
47
+ onEditingChange(object.id, true, clickX, clickY);
48
+ } else {
49
+ onEditingChange(object.id, true);
50
+ }
51
+ }
52
+ };
53
+
54
+ // Calculate font size to fit text in fixed box (Stage 2)
55
+ const calculateFitFontSize = (): number => {
56
+ if (!object.isFixedSize) return object.fontSize;
57
+ if (!object.text) return object.fontSize;
58
+
59
+ try {
60
+ // Create temporary text to measure
61
+ const tempText = new Konva.Text({
62
+ text: object.text || 'M',
63
+ fontSize: object.fontSize,
64
+ fontFamily: object.fontFamily,
65
+ fontStyle: `${object.bold ? 'bold' : 'normal'} ${object.italic ? 'italic' : ''}`,
66
+ width: object.width,
67
+ height: object.height
68
+ });
69
+
70
+ let fontSize = object.fontSize;
71
+ const maxWidth = object.width;
72
+ const maxHeight = object.height;
73
+
74
+ // Scale down font if text doesn't fit
75
+ while (fontSize > 10) {
76
+ tempText.fontSize(fontSize);
77
+ const size = tempText.measureSize(object.text || 'M');
78
+
79
+ if (size.width <= maxWidth && size.height <= maxHeight) {
80
+ break;
81
+ }
82
+ fontSize -= 1;
83
+ }
84
+
85
+ tempText.destroy();
86
+ return fontSize;
87
+ } catch (error) {
88
+ console.error('Error calculating fit font size:', error);
89
+ return object.fontSize;
90
+ }
91
+ };
92
+
93
+ const displayFontSize = object.isFixedSize ? calculateFitFontSize() : object.fontSize;
94
+
95
+ return (
96
+ <KonvaText
97
+ id={object.id}
98
+ ref={(node) => {
99
+ // Call the callback ref if it's a function
100
+ if (typeof shapeRef === 'function') {
101
+ shapeRef(node);
102
+ } else if (shapeRef) {
103
+ // Set the RefObject if it's an object
104
+ (shapeRef as React.MutableRefObject<Konva.Text | null>).current = node;
105
+ }
106
+ // Also store internally for our own use
107
+ textNodeRef.current = node;
108
+ }}
109
+ x={object.x}
110
+ y={object.y}
111
+ width={object.width}
112
+ height={object.height}
113
+ offsetX={object.offsetX || 0}
114
+ offsetY={object.offsetY || 0}
115
+ text={object.text}
116
+ fontSize={displayFontSize}
117
+ fontFamily={object.fontFamily}
118
+ fill={object.fill}
119
+ fontStyle={`${object.bold ? 'bold' : 'normal'} ${object.italic ? 'italic' : ''}`}
120
+ align={object.align || 'left'}
121
+ verticalAlign="top"
122
+ rotation={object.rotation}
123
+ padding={0}
124
+ lineHeight={1}
125
+ draggable={true}
126
+ onClick={(e) => onSelect(e)}
127
+ onTap={(e) => onSelect(e)}
128
+ onDblClick={handleDoubleClick}
129
+ onDblTap={handleDoubleClick}
130
+ onDragEnd={onDragEnd}
131
+ onTransformEnd={onTransformEnd}
132
+ opacity={object.isEditing ? 0 : 1}
133
+ listening={!object.isEditing}
134
+ />
135
+ );
136
+ }
src/components/CanvasHeader/BgColorSelector.tsx ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from 'react';
2
+ import { CanvasBgColor } from '../../types/canvas.types';
3
+ import IconBgLight from '../Icons/IconBgLight';
4
+ import IconBgDark from '../Icons/IconBgDark';
5
+
6
+ interface BgColorSelectorProps {
7
+ bgColor: CanvasBgColor;
8
+ onChange: (color: CanvasBgColor) => void;
9
+ }
10
+
11
+ export default function BgColorSelector({ bgColor, onChange }: BgColorSelectorProps) {
12
+ const [hoveredColor, setHoveredColor] = useState<CanvasBgColor | null>(null);
13
+
14
+ return (
15
+ <div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
16
+ <span style={{
17
+ color: '#999999',
18
+ fontSize: '16px',
19
+ fontWeight: 'normal',
20
+ fontFamily: 'Inter, sans-serif'
21
+ }}>
22
+ Background color:
23
+ </span>
24
+
25
+ {/* Outer pill container */}
26
+ <div style={{
27
+ display: 'flex',
28
+ alignItems: 'center',
29
+ gap: '5px',
30
+ height: '40px',
31
+ padding: '2px',
32
+ background: '#f8f9fa',
33
+ borderRadius: '99px'
34
+ }}>
35
+ {/* Light option */}
36
+ <button
37
+ onClick={() => onChange('light')}
38
+ onMouseEnter={() => setHoveredColor('light')}
39
+ onMouseLeave={() => setHoveredColor(null)}
40
+ style={{
41
+ display: 'flex',
42
+ alignItems: 'center',
43
+ justifyContent: 'center',
44
+ width: '38px',
45
+ height: '36px',
46
+ padding: '10px',
47
+ background: bgColor === 'light' ? '#e5e9ed' : (hoveredColor === 'light' ? '#f0f2f4' : 'transparent'),
48
+ border: 'none',
49
+ borderRadius: '99px',
50
+ cursor: 'pointer',
51
+ transition: 'background 0.15s ease-in-out'
52
+ }}
53
+ title="Light background"
54
+ >
55
+ <IconBgLight />
56
+ </button>
57
+
58
+ {/* Dark option */}
59
+ <button
60
+ onClick={() => onChange('dark')}
61
+ onMouseEnter={() => setHoveredColor('dark')}
62
+ onMouseLeave={() => setHoveredColor(null)}
63
+ style={{
64
+ display: 'flex',
65
+ alignItems: 'center',
66
+ justifyContent: 'center',
67
+ width: '38px',
68
+ height: '36px',
69
+ padding: '10px',
70
+ background: bgColor === 'dark' ? '#e5e9ed' : (hoveredColor === 'dark' ? '#f0f2f4' : 'transparent'),
71
+ border: 'none',
72
+ borderRadius: '99px',
73
+ cursor: 'pointer',
74
+ transition: 'background 0.15s ease-in-out'
75
+ }}
76
+ title="Dark background"
77
+ >
78
+ <IconBgDark />
79
+ </button>
80
+ </div>
81
+ </div>
82
+ );
83
+ }
src/components/CanvasHeader/CanvasHeader.tsx ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { CanvasSize, CanvasBgColor } from '../../types/canvas.types';
2
+ import BgColorSelector from './BgColorSelector';
3
+ import CanvasSizeSelector from './CanvasSizeSelector';
4
+
5
+ interface CanvasHeaderProps {
6
+ canvasSize: CanvasSize;
7
+ bgColor: CanvasBgColor;
8
+ onCanvasSizeChange: (size: CanvasSize) => void;
9
+ onBgColorChange: (color: CanvasBgColor) => void;
10
+ }
11
+
12
+ export default function CanvasHeader({
13
+ canvasSize,
14
+ bgColor,
15
+ onCanvasSizeChange,
16
+ onBgColorChange
17
+ }: CanvasHeaderProps) {
18
+ return (
19
+ <div style={{
20
+ display: 'flex',
21
+ alignItems: 'center',
22
+ justifyContent: 'space-between',
23
+ marginBottom: '10px',
24
+ transition: 'all 0.15s ease-in-out'
25
+ }}>
26
+ {/* Left side - Background Color Selector */}
27
+ <BgColorSelector bgColor={bgColor} onChange={onBgColorChange} />
28
+
29
+ {/* Right side - Canvas Size Selector */}
30
+ <CanvasSizeSelector canvasSize={canvasSize} onChange={onCanvasSizeChange} />
31
+ </div>
32
+ );
33
+ }
src/components/CanvasHeader/CanvasSizeSelector.tsx ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from 'react';
2
+ import { CanvasSize } from '../../types/canvas.types';
3
+ import IconXSize from '../Icons/IconXSize';
4
+ import IconLinkedInSize from '../Icons/IconLinkedInSize';
5
+ import IconHFSize from '../Icons/IconHFSize';
6
+
7
+ interface CanvasSizeSelectorProps {
8
+ canvasSize: CanvasSize;
9
+ onChange: (size: CanvasSize) => void;
10
+ }
11
+
12
+ const sizeLabels: Record<CanvasSize, string> = {
13
+ '1200x675': '1200x675',
14
+ 'linkedin': '1200x627',
15
+ 'hf': '1160x580'
16
+ };
17
+
18
+ export default function CanvasSizeSelector({ canvasSize, onChange }: CanvasSizeSelectorProps) {
19
+ const [hoveredSize, setHoveredSize] = useState<CanvasSize | null>(null);
20
+
21
+ const renderSizeButton = (
22
+ size: CanvasSize,
23
+ Icon: React.ComponentType<{ selected?: boolean }>,
24
+ title: string
25
+ ) => {
26
+ const isSelected = canvasSize === size;
27
+ const isHovered = hoveredSize === size;
28
+
29
+ return (
30
+ <button
31
+ onClick={() => onChange(size)}
32
+ onMouseEnter={() => setHoveredSize(size)}
33
+ onMouseLeave={() => setHoveredSize(null)}
34
+ style={{
35
+ display: 'flex',
36
+ alignItems: 'center',
37
+ justifyContent: isSelected ? 'flex-start' : 'center',
38
+ gap: '5px',
39
+ height: '36px',
40
+ minWidth: '38px',
41
+ paddingLeft: isSelected ? '10px' : '9px',
42
+ paddingRight: isSelected ? '10px' : '9px',
43
+ background: isSelected ? '#e5e9ed' : (isHovered ? '#f0f2f4' : 'transparent'),
44
+ border: 'none',
45
+ borderRadius: '99px',
46
+ cursor: 'pointer',
47
+ transition: 'background 0.15s ease-in-out, padding 0.15s ease-in-out, min-width 0.15s ease-in-out',
48
+ overflow: 'hidden'
49
+ }}
50
+ title={title}
51
+ >
52
+ <Icon selected={isSelected} />
53
+ <span
54
+ style={{
55
+ color: '#32343d',
56
+ fontSize: '16px',
57
+ fontWeight: 'normal',
58
+ fontFamily: 'Inter, sans-serif',
59
+ whiteSpace: 'nowrap',
60
+ opacity: isSelected ? 0.8 : 0,
61
+ transform: isSelected ? 'translateX(0)' : 'translateX(-10px)',
62
+ transition: 'opacity 0.15s ease-in-out, transform 0.15s ease-in-out',
63
+ width: isSelected ? 'auto' : '0',
64
+ overflow: 'hidden'
65
+ }}
66
+ >
67
+ {sizeLabels[size]}
68
+ </span>
69
+ </button>
70
+ );
71
+ };
72
+
73
+ return (
74
+ <div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
75
+ <span style={{
76
+ color: '#999999',
77
+ fontSize: '16px',
78
+ fontWeight: 'normal',
79
+ fontFamily: 'Inter, sans-serif'
80
+ }}>
81
+ Size:
82
+ </span>
83
+
84
+ {/* Outer pill container */}
85
+ <div style={{
86
+ display: 'flex',
87
+ alignItems: 'center',
88
+ gap: '5px',
89
+ height: '40px',
90
+ padding: '2px',
91
+ background: '#f8f9fa',
92
+ borderRadius: '99px'
93
+ }}>
94
+ {renderSizeButton('1200x675', IconXSize, '1200×675 (Default)')}
95
+ {renderSizeButton('linkedin', IconLinkedInSize, 'LinkedIn size (1200x627)')}
96
+ {renderSizeButton('hf', IconHFSize, 'HF custom size (1160x580)')}
97
+ </div>
98
+ </div>
99
+ );
100
+ }
src/components/ConfirmationDialog/ConfirmationDialog.tsx ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ interface ConfirmationDialogProps {
2
+ isOpen: boolean;
3
+ title: string;
4
+ message: string;
5
+ confirmText?: string;
6
+ cancelText?: string;
7
+ onConfirm: () => void;
8
+ onCancel: () => void;
9
+ }
10
+
11
+ export default function ConfirmationDialog({
12
+ isOpen,
13
+ title,
14
+ message,
15
+ confirmText = 'Confirm',
16
+ cancelText = 'Cancel',
17
+ onConfirm,
18
+ onCancel
19
+ }: ConfirmationDialogProps) {
20
+ if (!isOpen) return null;
21
+
22
+ return (
23
+ <>
24
+ {/* Backdrop */}
25
+ <div
26
+ style={{
27
+ position: 'fixed',
28
+ inset: 0,
29
+ backgroundColor: 'rgba(0, 0, 0, 0.5)',
30
+ zIndex: 9998,
31
+ backdropFilter: 'blur(2px)'
32
+ }}
33
+ onClick={onCancel}
34
+ />
35
+
36
+ {/* Dialog */}
37
+ <div
38
+ style={{
39
+ position: 'fixed',
40
+ left: '50%',
41
+ top: '50%',
42
+ transform: 'translate(-50%, -50%)',
43
+ backgroundColor: '#ffffff',
44
+ borderRadius: '10px',
45
+ padding: '24px',
46
+ minWidth: '400px',
47
+ maxWidth: '500px',
48
+ zIndex: 9999,
49
+ boxShadow: '0 10px 40px rgba(0, 0, 0, 0.2)'
50
+ }}
51
+ >
52
+ {/* Title */}
53
+ <h2
54
+ style={{
55
+ margin: '0 0 12px 0',
56
+ fontSize: '20px',
57
+ fontWeight: '600',
58
+ color: '#32343d',
59
+ fontFamily: 'Inter, sans-serif'
60
+ }}
61
+ >
62
+ {title}
63
+ </h2>
64
+
65
+ {/* Message */}
66
+ <p
67
+ style={{
68
+ margin: '0 0 24px 0',
69
+ fontSize: '16px',
70
+ color: '#545865',
71
+ fontFamily: 'Inter, sans-serif',
72
+ lineHeight: '1.5'
73
+ }}
74
+ >
75
+ {message}
76
+ </p>
77
+
78
+ {/* Buttons */}
79
+ <div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
80
+ {/* Cancel Button */}
81
+ <button
82
+ onClick={onCancel}
83
+ style={{
84
+ padding: '10px 20px',
85
+ fontSize: '16px',
86
+ fontWeight: '500',
87
+ fontFamily: 'Inter, sans-serif',
88
+ color: '#545865',
89
+ backgroundColor: '#f8f9fa',
90
+ border: 'none',
91
+ borderRadius: '7px',
92
+ cursor: 'pointer',
93
+ transition: 'background-color 0.15s ease-in-out'
94
+ }}
95
+ onMouseEnter={(e) => {
96
+ e.currentTarget.style.backgroundColor = '#e9ecef';
97
+ }}
98
+ onMouseLeave={(e) => {
99
+ e.currentTarget.style.backgroundColor = '#f8f9fa';
100
+ }}
101
+ >
102
+ {cancelText}
103
+ </button>
104
+
105
+ {/* Confirm Button */}
106
+ <button
107
+ onClick={onConfirm}
108
+ style={{
109
+ padding: '10px 20px',
110
+ fontSize: '16px',
111
+ fontWeight: '500',
112
+ fontFamily: 'Inter, sans-serif',
113
+ color: '#ffffff',
114
+ backgroundColor: '#3faee6',
115
+ border: 'none',
116
+ borderRadius: '7px',
117
+ cursor: 'pointer',
118
+ transition: 'background-color 0.15s ease-in-out'
119
+ }}
120
+ onMouseEnter={(e) => {
121
+ e.currentTarget.style.backgroundColor = '#2e9dd4';
122
+ }}
123
+ onMouseLeave={(e) => {
124
+ e.currentTarget.style.backgroundColor = '#3faee6';
125
+ }}
126
+ >
127
+ {confirmText}
128
+ </button>
129
+ </div>
130
+ </div>
131
+ </>
132
+ );
133
+ }
src/components/ExportButton/ExportButton.tsx ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Download } from 'lucide-react';
2
+ import { useState } from 'react';
3
+
4
+ interface ExportButtonProps {
5
+ onExport: (filename: string) => void;
6
+ }
7
+
8
+ export default function ExportButton({ onExport }: ExportButtonProps) {
9
+ const [filename, setFilename] = useState('thumbnail_name');
10
+
11
+ const handleExport = () => {
12
+ onExport(filename);
13
+ };
14
+
15
+ return (
16
+ <button
17
+ onClick={handleExport}
18
+ className="fixed top-[10px] right-[22px] z-50 bg-[#2e2e30] rounded-[10px] px-[10px] py-[5px] flex items-center gap-[10px] shadow-lg hover:bg-[#3e3e40] transition-colors"
19
+ >
20
+ {/* Download Icon */}
21
+ <div className="flex items-center justify-center p-[2px]">
22
+ <Download size={16} className="text-white" />
23
+ </div>
24
+
25
+ {/* Filename Container */}
26
+ <div className="flex items-center gap-[2px]">
27
+ <input
28
+ type="text"
29
+ value={filename}
30
+ onChange={(e) => setFilename(e.target.value)}
31
+ className="bg-transparent text-white text-[16px] font-normal outline-none border-none px-0 py-[5px] rounded-[4px] focus:bg-[#3e3e40] min-w-[125px]"
32
+ onClick={(e) => e.stopPropagation()}
33
+ />
34
+ <span className="text-white text-[16px] font-normal">.png</span>
35
+ </div>
36
+ </button>
37
+ );
38
+ }
src/components/Icons/IconBgDark.tsx ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export default function IconBgDark() {
2
+ return (
3
+ <div
4
+ style={{
5
+ width: '18px',
6
+ height: '18px',
7
+ borderRadius: '999px',
8
+ border: '1px solid #e5e9ed',
9
+ background: 'linear-gradient(130.786deg, rgba(147, 28, 186, 1) 15.907%, rgba(26, 26, 48, 1) 52.739%, rgba(4, 107, 226, 1) 90.547%)',
10
+ overflow: 'hidden'
11
+ }}
12
+ />
13
+ );
14
+ }
src/components/Icons/IconBgLight.tsx ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export default function IconBgLight() {
2
+ return (
3
+ <div
4
+ style={{
5
+ width: '18px',
6
+ height: '18px',
7
+ borderRadius: '999px',
8
+ border: '1px solid #e5e9ed',
9
+ background: 'linear-gradient(180deg, #ffffff 27.928%, #e7e9f5 100%)',
10
+ overflow: 'hidden'
11
+ }}
12
+ />
13
+ );
14
+ }
src/components/Icons/IconHFSize.tsx ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ interface IconHFSizeProps {
2
+ selected?: boolean;
3
+ }
4
+
5
+ export default function IconHFSize({ selected = false }: IconHFSizeProps) {
6
+ const imgVector = "https://www.figma.com/api/mcp/asset/dc4babfa-0327-4090-bd17-25804606399d";
7
+ const imgVectorFill = "https://www.figma.com/api/mcp/asset/f1d195b1-f4d3-4007-a212-f7105729352c";
8
+ const imgStrokeDefault = "https://www.figma.com/api/mcp/asset/ebdd32b2-a8e3-46ef-b7de-75bdb592db69";
9
+ const imgStrokeSelected = "https://www.figma.com/api/mcp/asset/c693f57d-7397-4d5f-9f10-c6bda6e9dfe7";
10
+
11
+ return (
12
+ <div style={{ position: 'relative', width: '16px', height: '16px' }}>
13
+ <div
14
+ style={{
15
+ position: 'absolute',
16
+ left: '50%',
17
+ top: '50%',
18
+ transform: 'translate(-50%, -50%)',
19
+ width: '12.7px',
20
+ height: '10.877px'
21
+ }}
22
+ >
23
+ <img
24
+ alt=""
25
+ style={{ display: 'block', maxWidth: 'none', width: '100%', height: '100%' }}
26
+ src={imgVector}
27
+ />
28
+ </div>
29
+ <div
30
+ style={{
31
+ position: 'absolute',
32
+ left: '50%',
33
+ top: '50%',
34
+ transform: 'translate(-50%, -50%)',
35
+ width: '15.755px',
36
+ height: '13.932px'
37
+ }}
38
+ >
39
+ <img
40
+ alt=""
41
+ style={{ display: 'block', maxWidth: 'none', width: '100%', height: '100%' }}
42
+ src={selected ? imgStrokeSelected : imgStrokeDefault}
43
+ />
44
+ </div>
45
+ <div
46
+ style={{
47
+ position: 'absolute',
48
+ left: '50%',
49
+ top: '50%',
50
+ transform: 'translate(-50%, -50%)',
51
+ width: '12.7px',
52
+ height: '10.877px'
53
+ }}
54
+ >
55
+ <img
56
+ alt="HF"
57
+ style={{ display: 'block', maxWidth: 'none', width: '100%', height: '100%' }}
58
+ src={imgVectorFill}
59
+ />
60
+ </div>
61
+ </div>
62
+ );
63
+ }
src/components/Icons/IconHuggy.tsx ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export default function IconHuggy() {
2
+ const imgHands = "/src/assets/icons/huggy-hands.svg";
3
+ const imgEyes = "/src/assets/icons/huggy-eyes.svg";
4
+ const imgMouth = "/src/assets/icons/huggy-mouth.svg";
5
+ const imgBody = "/src/assets/icons/huggy-body.svg";
6
+
7
+ return (
8
+ <div style={{ width: '32px', height: '32px', position: 'relative' }}>
9
+ <div
10
+ style={{
11
+ height: '23.256px',
12
+ position: 'absolute',
13
+ left: '50%',
14
+ top: '50%',
15
+ transform: 'translate(-50%, -50%)',
16
+ width: '32px'
17
+ }}
18
+ >
19
+ {/* Hands */}
20
+ <div
21
+ style={{
22
+ position: 'absolute',
23
+ bottom: 0,
24
+ left: 0,
25
+ right: 0,
26
+ top: '46.19%'
27
+ }}
28
+ >
29
+ <img alt="" style={{ display: 'block', maxWidth: 'none', width: '100%', height: '100%' }} src={imgHands} />
30
+ </div>
31
+ {/* Face container */}
32
+ <div
33
+ style={{
34
+ position: 'absolute',
35
+ bottom: '3.6%',
36
+ display: 'contents',
37
+ left: '16.68%',
38
+ right: '3.92%',
39
+ top: 0
40
+ }}
41
+ >
42
+ {/* Eyes */}
43
+ <div
44
+ style={{
45
+ position: 'absolute',
46
+ inset: '30.62% 32.64% 54.33% 35.75%'
47
+ }}
48
+ >
49
+ <img alt="" style={{ display: 'block', maxWidth: 'none', width: '100%', height: '100%' }} src={imgEyes} />
50
+ </div>
51
+ {/* Mouth */}
52
+ <div
53
+ style={{
54
+ position: 'absolute',
55
+ inset: '49.16% 36.4% 27.17% 38.44%'
56
+ }}
57
+ >
58
+ <img alt="" style={{ display: 'block', maxWidth: 'none', width: '100%', height: '100%' }} src={imgMouth} />
59
+ </div>
60
+ {/* Body */}
61
+ <div
62
+ style={{
63
+ position: 'absolute',
64
+ bottom: '3.6%',
65
+ left: '16.68%',
66
+ right: '3.92%',
67
+ top: 0
68
+ }}
69
+ >
70
+ <img alt="" style={{ display: 'block', maxWidth: 'none', width: '100%', height: '100%' }} src={imgBody} />
71
+ </div>
72
+ </div>
73
+ </div>
74
+ </div>
75
+ );
76
+ }
src/components/Icons/IconImage.tsx ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ interface IconImageProps {
2
+ selected?: boolean;
3
+ }
4
+
5
+ export default function IconImage({ selected = false }: IconImageProps) {
6
+ const imgDefault = "/src/assets/icons/image-default.svg";
7
+ const imgSelected = "/src/assets/icons/image-selected.svg";
8
+
9
+ return (
10
+ <div style={{ position: 'relative', width: '32px', height: '32px' }}>
11
+ <div
12
+ style={{
13
+ position: 'absolute',
14
+ inset: '8.33%',
15
+ width: 'calc(100% - 16.66%)',
16
+ height: 'calc(100% - 16.66%)'
17
+ }}
18
+ >
19
+ <img
20
+ alt="Image"
21
+ style={{ display: 'block', maxWidth: 'none', width: '100%', height: '100%' }}
22
+ src={selected ? imgSelected : imgDefault}
23
+ />
24
+ </div>
25
+ </div>
26
+ );
27
+ }
src/components/Icons/IconLayout.tsx ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ interface IconLayoutProps {
2
+ selected?: boolean;
3
+ }
4
+
5
+ export default function IconLayout({ selected = false }: IconLayoutProps) {
6
+ const imgDefault = "/src/assets/icons/layout-default.svg";
7
+ const imgSelected = "/src/assets/icons/layout-selected.svg";
8
+
9
+ return (
10
+ <div style={{ position: 'relative', width: '32px', height: '32px' }}>
11
+ <div
12
+ style={{
13
+ position: 'absolute',
14
+ inset: '12.5% 4.17%',
15
+ width: 'calc(100% - 8.34%)',
16
+ height: 'calc(100% - 25%)'
17
+ }}
18
+ >
19
+ <img
20
+ alt="Layout"
21
+ style={{ display: 'block', maxWidth: 'none', width: '100%', height: '100%' }}
22
+ src={selected ? imgSelected : imgDefault}
23
+ />
24
+ </div>
25
+ </div>
26
+ );
27
+ }
src/components/Icons/IconLinkedInSize.tsx ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ interface IconLinkedInSizeProps {
2
+ selected?: boolean;
3
+ }
4
+
5
+ export default function IconLinkedInSize({ selected = false }: IconLinkedInSizeProps) {
6
+ const imgIcon = "https://www.figma.com/api/mcp/asset/d9e44317-3cc6-4348-b17e-f587f69630ee";
7
+ const imgBgDefault = "https://www.figma.com/api/mcp/asset/2e267c46-60e2-4884-9957-cdba8e0be2d6";
8
+ const imgBgSelected = "https://www.figma.com/api/mcp/asset/83c9f178-e411-4f37-b143-1a78582205f3";
9
+
10
+ return (
11
+ <div style={{ position: 'relative', width: '16px', height: '16px' }}>
12
+ <div
13
+ style={{
14
+ position: 'absolute',
15
+ inset: '2.34%',
16
+ width: 'calc(100% - 4.68%)',
17
+ height: 'calc(100% - 4.68%)'
18
+ }}
19
+ >
20
+ <img
21
+ alt=""
22
+ style={{ display: 'block', maxWidth: 'none', width: '100%', height: '100%' }}
23
+ src={selected ? imgBgSelected : imgBgDefault}
24
+ />
25
+ </div>
26
+ <div
27
+ style={{
28
+ position: 'absolute',
29
+ inset: '15.41% 16.41% 16.41% 15.33%',
30
+ width: 'calc(100% - 31.74%)',
31
+ height: 'calc(100% - 31.82%)'
32
+ }}
33
+ >
34
+ <img
35
+ alt="LinkedIn"
36
+ style={{ display: 'block', maxWidth: 'none', width: '100%', height: '100%' }}
37
+ src={imgIcon}
38
+ />
39
+ </div>
40
+ </div>
41
+ );
42
+ }
src/components/Icons/IconText.tsx ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ interface IconTextProps {
2
+ selected?: boolean;
3
+ }
4
+
5
+ export default function IconText({ selected = false }: IconTextProps) {
6
+ const imgDefault = "/src/assets/icons/text-default.svg";
7
+ const imgSelected = "/src/assets/icons/text-selected.svg";
8
+
9
+ return (
10
+ <div style={{ position: 'relative', width: '32px', height: '32px' }}>
11
+ <div
12
+ style={{
13
+ position: 'absolute',
14
+ inset: '8.33%',
15
+ width: 'calc(100% - 16.66%)',
16
+ height: 'calc(100% - 16.66%)'
17
+ }}
18
+ >
19
+ <img
20
+ alt="Text"
21
+ style={{ display: 'block', maxWidth: 'none', width: '100%', height: '100%' }}
22
+ src={selected ? imgSelected : imgDefault}
23
+ />
24
+ </div>
25
+ </div>
26
+ );
27
+ }
src/components/Icons/IconXSize.tsx ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ interface IconXSizeProps {
2
+ selected?: boolean;
3
+ }
4
+
5
+ export default function IconXSize({ selected = false }: IconXSizeProps) {
6
+ const imgDefault = "https://www.figma.com/api/mcp/asset/3cd42b29-76a9-4bb8-b405-673df2b9302c";
7
+ const imgSelected = "https://www.figma.com/api/mcp/asset/943fa43f-086d-4ef2-8825-9c2e91a4d05c";
8
+
9
+ return (
10
+ <div style={{ position: 'relative', width: '16px', height: '16px' }}>
11
+ <div
12
+ style={{
13
+ position: 'absolute',
14
+ inset: '4.17%',
15
+ width: 'calc(100% - 8.34%)',
16
+ height: 'calc(100% - 8.34%)'
17
+ }}
18
+ >
19
+ <img
20
+ alt="X"
21
+ style={{ display: 'block', maxWidth: 'none', width: '100%', height: '100%' }}
22
+ src={selected ? imgSelected : imgDefault}
23
+ />
24
+ </div>
25
+ </div>
26
+ );
27
+ }