feat: Add Shift constraint and smart snapping for drag operations
Browse files- Implemented Shift key constraint for horizontal/vertical movement
- Hold Shift while dragging to lock to primary axis (larger delta)
- Works with all canvas object types
- Implemented smart snapping to canvas center lines
- Objects snap to vertical/horizontal center when within 10px
- Properly accounts for offsetX/offsetY in calculations
- Visual feedback with red guide lines during snap
- Only active when Shift is not pressed (Shift has priority)
- Fixed text clipping and positioning issues
- Textarea positioning now accounts for offsetX/offsetY
- Restored proper Figma positions with correct offsetX values
- Updated layout text dimensions to prevent clipping
Files modified:
- src/components/Canvas/Canvas.tsx
- src/components/Canvas/CanvasObject.tsx
- src/components/Canvas/TextEditable.tsx
- src/components/CanvasHeader/CanvasSizeSelector.tsx
- src/data/layouts.ts
🤖 Generated with Claude Code
Co-Authored-By: Claude <[email protected]>
- .claude/settings.local.json +4 -1
- PROJECT_PLAN.md +359 -50
- package-lock.json +12 -1
- package.json +7 -6
- public/assets/backgrounds/bg_Light_HF.png +3 -0
- public/assets/backgrounds/bg_Light_LinkedIn.png +3 -0
- public/assets/backgrounds/bg_Light_twitter.png +3 -0
- public/assets/backgrounds/bg_dark_HF.png +3 -0
- public/assets/backgrounds/bg_dark_LinkedIn.png +3 -0
- public/assets/backgrounds/bg_dark_twitter.png +3 -0
- public/assets/backgrounds/bg_sLight_HF.png +3 -0
- public/assets/backgrounds/bg_sLight_LinkedIn.png +3 -0
- public/assets/backgrounds/bg_sLight_twitter.png +3 -0
- src/App.tsx +501 -49
- src/components/Canvas/Canvas.tsx +323 -55
- src/components/Canvas/CanvasContainer.tsx +3 -3
- src/components/Canvas/CanvasObject.tsx +55 -2
- src/components/Canvas/TextEditable.tsx +12 -0
- src/components/CanvasHeader/BgColorSelector.tsx +33 -8
- src/components/CanvasHeader/CanvasSizeSelector.tsx +6 -5
- src/components/ExportButton/ExportButton.tsx +131 -15
- src/components/Icons/IconBgGradient.tsx +14 -0
- src/components/Layout/LayoutSelector.tsx +5 -5
- src/components/Layout/LayoutSwitchConfirmation.tsx +101 -0
- src/components/Sidebar/HuggyMenu.tsx +28 -40
- src/components/Sidebar/Sidebar.tsx +1 -1
- src/components/TextToolbar/ColorPicker.tsx +402 -0
- src/components/TextToolbar/TextToolbar.tsx +247 -0
- src/data/layouts.ts +14 -11
- src/hooks/useCanvasState.ts +112 -9
- src/hooks/useViewportScale.ts +57 -0
- src/index.css +3 -3
- src/types/canvas.types.ts +1 -1
|
@@ -27,7 +27,10 @@
|
|
| 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": []
|
|
|
|
| 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 |
+
"Bash(git commit:*)",
|
| 32 |
+
"Bash(npm install:*)",
|
| 33 |
+
"Bash(ren:*)"
|
| 34 |
],
|
| 35 |
"deny": [],
|
| 36 |
"ask": []
|
|
@@ -403,18 +403,27 @@ Four buttons with icons and labels:
|
|
| 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
|
| 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 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 418 |
|
| 419 |
### Phase 9: Text Feature ✅ (Fully Complete)
|
| 420 |
**Components:**
|
|
@@ -478,18 +487,29 @@ Four buttons with icons and labels:
|
|
| 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 |
-
-
|
| 490 |
-
-
|
| 491 |
-
-
|
| 492 |
-
- Browser download API
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 493 |
|
| 494 |
### Phase 12: Polish & Deploy
|
| 495 |
**Tasks:**
|
|
@@ -587,8 +607,8 @@ Four buttons with icons and labels:
|
|
| 587 |
- ✅ **Phase 9:** Text Feature (Fully Complete)
|
| 588 |
|
| 589 |
### Current Phase:
|
| 590 |
-
**Phase
|
| 591 |
-
**Next:** Phase
|
| 592 |
|
| 593 |
**Progress (2025-11-22):**
|
| 594 |
- ✅ Fixed sidebar icons with correct selected states from Figma icons page
|
|
@@ -755,9 +775,9 @@ Four buttons with icons and labels:
|
|
| 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
|
| 760 |
-
5. Complete Phase
|
| 761 |
6. Complete Phase 12: Polish & Deploy
|
| 762 |
|
| 763 |
---
|
|
@@ -808,10 +828,68 @@ Four buttons with icons and labels:
|
|
| 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
|
|
@@ -825,6 +903,7 @@ Four buttons with icons and labels:
|
|
| 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
|
|
@@ -845,25 +924,42 @@ Four buttons with icons and labels:
|
|
| 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 |
-
**
|
| 859 |
-
|
| 860 |
-
|
|
|
|
|
|
|
|
|
|
| 861 |
|
| 862 |
-
**
|
| 863 |
|
| 864 |
---
|
| 865 |
|
| 866 |
-
**Note:**
|
| 867 |
|
| 868 |
---
|
| 869 |
|
|
@@ -894,29 +990,18 @@ Four buttons with icons and labels:
|
|
| 894 |
|
| 895 |
---
|
| 896 |
|
| 897 |
-
### Hover to Show Object Bounding Box
|
| 898 |
-
**Status:**
|
| 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
|
| 906 |
-
-
|
| 907 |
-
-
|
| 908 |
-
-
|
| 909 |
-
-
|
|
|
|
|
|
|
| 910 |
|
| 911 |
-
**
|
| 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 |
|
|
@@ -953,3 +1038,227 @@ Four buttons with icons and labels:
|
|
| 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)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 403 |
|
| 404 |
**Future:** Connect to HuggingFace dataset API
|
| 405 |
|
| 406 |
+
### Phase 8: Image Upload ✅ (COMPLETE)
|
| 407 |
**Features:**
|
| 408 |
+
- ✅ File input button (hidden, triggered by sidebar Image button)
|
| 409 |
+
- ✅ Drag-and-drop zone (entire application area)
|
| 410 |
+
- ✅ Visual drag-over feedback with blue overlay and "Drop your image anywhere to upload" message
|
| 411 |
+
- ✅ Image loading and processing using FileReader API
|
| 412 |
+
- ✅ Add to canvas as Konva Image node with random offset from center
|
| 413 |
+
- ✅ Auto-selection after upload for immediate editing
|
| 414 |
|
| 415 |
**Error handling:**
|
| 416 |
+
- ✅ File type validation (PNG, JPG, WebP only)
|
| 417 |
+
- ✅ File size limits (10MB maximum)
|
| 418 |
+
- ✅ User-friendly error alerts
|
| 419 |
+
|
| 420 |
+
**Implementation details:**
|
| 421 |
+
- Hidden file input triggered by clicking Image button in sidebar
|
| 422 |
+
- Drag-and-drop works across entire app surface with visual feedback
|
| 423 |
+
- Images positioned near center with ±100px random offset to prevent stacking
|
| 424 |
+
- Default image size: 300×300px (user can scale after upload)
|
| 425 |
+
- Uses FileReader.readAsDataURL() for client-side image loading
|
| 426 |
+
- Input value reset after upload to allow re-uploading same file
|
| 427 |
|
| 428 |
### Phase 9: Text Feature ✅ (Fully Complete)
|
| 429 |
**Components:**
|
|
|
|
| 487 |
- Smooth animations
|
| 488 |
- Proper z-index layering
|
| 489 |
|
| 490 |
+
### Phase 11: Export Feature ✅ (COMPLETE)
|
| 491 |
**Functionality:**
|
| 492 |
+
- ✅ Editable filename input in ExportButton component
|
| 493 |
+
- ✅ PNG generation using Konva's `stage.toDataURL()`
|
| 494 |
+
- ✅ Download trigger with automatic file download
|
| 495 |
+
- ✅ Loading state during export with spinner icon
|
| 496 |
+
- ✅ Disabled button and input during export to prevent duplicates
|
| 497 |
|
| 498 |
**Technical details:**
|
| 499 |
+
- ✅ Stage ref passed from App.tsx to Canvas component
|
| 500 |
+
- ✅ High-DPI display support using `window.devicePixelRatio`
|
| 501 |
+
- ✅ Quality set to 1 (maximum) for best output
|
| 502 |
+
- ✅ Browser download API using temporary anchor element
|
| 503 |
+
- ✅ Selection temporarily hidden during export for clean output
|
| 504 |
+
- ✅ Previous selection restored after export completes
|
| 505 |
+
|
| 506 |
+
**Implementation details:**
|
| 507 |
+
- Export button shows loading spinner (Loader2 icon) while exporting
|
| 508 |
+
- Button and filename input disabled during export
|
| 509 |
+
- Transformer automatically hidden before export
|
| 510 |
+
- 50ms delay to ensure transformer is hidden before capturing
|
| 511 |
+
- Error handling with user-friendly alert messages
|
| 512 |
+
- Works with all canvas sizes (1200×675, 1200×627, 1280×720)
|
| 513 |
|
| 514 |
### Phase 12: Polish & Deploy
|
| 515 |
**Tasks:**
|
|
|
|
| 607 |
- ✅ **Phase 9:** Text Feature (Fully Complete)
|
| 608 |
|
| 609 |
### Current Phase:
|
| 610 |
+
**Phase 11:** Export Feature (Completed) ✅
|
| 611 |
+
**Next:** Phase 12 (Polish & Deploy) - Skipping Phase 10 (Text Toolbar)
|
| 612 |
|
| 613 |
**Progress (2025-11-22):**
|
| 614 |
- ✅ Fixed sidebar icons with correct selected states from Figma icons page
|
|
|
|
| 775 |
### Next Steps:
|
| 776 |
1. ✅ Complete Phase 5: Layout Feature (DONE)
|
| 777 |
2. ✅ Complete Phase 7: Huggy Feature (DONE)
|
| 778 |
+
3. ✅ Complete Phase 8: Image Upload (DONE)
|
| 779 |
+
4. ✅ Complete Phase 11: Export Feature (DONE)
|
| 780 |
+
5. Complete Phase 10: Text Toolbar (Optional - Can be added later)
|
| 781 |
6. Complete Phase 12: Polish & Deploy
|
| 782 |
|
| 783 |
---
|
|
|
|
| 828 |
- Transparent track background for cleaner look
|
| 829 |
- Gray thumb (#b8b8b8) with darker hover state (#999999)
|
| 830 |
- Firefox support with `scrollbar-width: thin`
|
| 831 |
+
- ✅ **Huggy Placement Randomization**
|
| 832 |
+
- **Issue:** All Huggys were placed at exact center, causing stacking/overlap
|
| 833 |
+
- **Solution:** Random offset ±100px from center on both X and Y axes
|
| 834 |
+
- Makes auto-selection more obvious (Huggys no longer perfectly overlap)
|
| 835 |
+
- Creates more natural, scattered appearance when adding multiple Huggys
|
| 836 |
+
- Auto-selection confirmed working (already implemented in useCanvasState.ts:98)
|
| 837 |
+
- ✅ **Transformer/Bounding Box Race Condition Fix** (Partial)
|
| 838 |
+
- **Issue:** Newly added Huggys didn't show bounding box (transformer handles) despite being selected
|
| 839 |
+
- **Root cause:** Image loading is asynchronous - transformer tried to attach before image node was rendered
|
| 840 |
+
- When Huggy added → ImageRenderer returns null (image still loading)
|
| 841 |
+
- Transformer useEffect runs → can't find node → no bounding box
|
| 842 |
+
- Image loads → node renders → but transformer doesn't re-run
|
| 843 |
+
- **Solution attempted:** Retry mechanism with delays up to 10 attempts
|
| 844 |
+
- Nodes found on attempt 5, but bounding box still not consistently showing
|
| 845 |
+
- Further investigation needed (may be z-index or visibility issue)
|
| 846 |
+
- ✅ **Multiselect Scaling Fix**
|
| 847 |
+
- **Issue:** When scaling multiple selected objects, they would scale incorrectly
|
| 848 |
+
- Objects scaled individually from their own centers instead of as a group
|
| 849 |
+
- Positions would drift and relative sizes wouldn't be maintained
|
| 850 |
+
- **Root cause:**
|
| 851 |
+
- Each object's `onTransformEnd` called separately
|
| 852 |
+
- Each call only updated ONE object (`obj.id === id`)
|
| 853 |
+
- Didn't account for group transformation
|
| 854 |
+
- **Solution:**
|
| 855 |
+
- Detect multiselect (`selectedIds.length > 1`)
|
| 856 |
+
- When multiselect: Update ALL selected objects together
|
| 857 |
+
- Read each node's individual transform and apply proportionally
|
| 858 |
+
- Debounce updates (10ms) to prevent duplicate state changes
|
| 859 |
+
- Single-select still uses original optimized logic
|
| 860 |
+
- Now multiselect scaling works correctly with objects maintaining relative positions
|
| 861 |
- ✅ **Feature Backlog Updated**
|
| 862 |
- Hover to show object bounding box (future enhancement)
|
| 863 |
- Dynamic canvas corner radius & drop shadow on hover (future enhancement)
|
| 864 |
|
| 865 |
+
**Latest Improvements (2025-11-24 - Evening):**
|
| 866 |
+
- ✅ **Hover Bounding Box Feature**
|
| 867 |
+
- **Implemented:** Visual bounding box appears when hovering over canvas objects
|
| 868 |
+
- **No scaling handles:** Just a simple dashed outline for visual feedback
|
| 869 |
+
- **Smart visibility:** Only shows when hovering over objects that are NOT selected
|
| 870 |
+
- **Visual style:** Light blue dashed outline (opacity 0.5, 4px dash pattern)
|
| 871 |
+
- **Rotation aware:** Uses `getClientRect()` to properly account for rotation and transformations
|
| 872 |
+
- **Implementation:**
|
| 873 |
+
- Added `hoveredId` state to track hovered object
|
| 874 |
+
- Added `onMouseEnter`/`onMouseLeave` handlers to all object types (Rect, Image, Text, LogoPlaceholder)
|
| 875 |
+
- Hover box rendered in Canvas.tsx Layer after selection box but before transformer
|
| 876 |
+
- Provides clear visual feedback for interactive areas without interfering with selection
|
| 877 |
+
- ✅ **Image Upload Feature (Phase 8 Complete)**
|
| 878 |
+
- **Click to upload:** Image button in sidebar triggers hidden file input
|
| 879 |
+
- **Drag and drop:** Entire app surface accepts dragged image files
|
| 880 |
+
- **Visual feedback:** Blue overlay with dashed border and "Drop your image anywhere to upload" message
|
| 881 |
+
- **File validation:** PNG, JPG, WebP only, 10MB max file size
|
| 882 |
+
- **Smart positioning:** Images placed near center with random ±100px offset
|
| 883 |
+
- **Auto-selection:** Uploaded images immediately selected for editing
|
| 884 |
+
- **User-friendly errors:** Clear alerts for invalid file types or sizes
|
| 885 |
+
- ✅ **Export Feature (Phase 11 Complete)**
|
| 886 |
+
- **PNG export:** Click export button to download canvas as high-quality PNG
|
| 887 |
+
- **Editable filename:** Default "thumbnail_name.png", user can customize before export
|
| 888 |
+
- **Loading state:** Spinner icon and disabled controls during export
|
| 889 |
+
- **High-DPI support:** Automatically uses device pixel ratio for crisp exports
|
| 890 |
+
- **Clean output:** Transformer/selection automatically hidden during export
|
| 891 |
+
- **Error handling:** User-friendly alerts if export fails
|
| 892 |
+
|
| 893 |
**Current Status:**
|
| 894 |
- Phase 5 (Layout Feature): ✅ COMPLETE WITH KNOWN ISSUES
|
| 895 |
- All 4 layouts implemented with correct specifications
|
|
|
|
| 903 |
- Multi-select with drag-to-select box
|
| 904 |
- Shift+Click to add/remove objects from selection
|
| 905 |
- All assets exported and integrated
|
| 906 |
+
- **Hover bounding box feature implemented**
|
| 907 |
- Phase 6 (Canvas Interactions): ✅ ENHANCED
|
| 908 |
- Multi-select functionality fully implemented
|
| 909 |
- Arrow key positioning for precise control
|
|
|
|
| 924 |
- **Transparent Background:** Huggy thumbnails display without white background for cleaner look
|
| 925 |
- **Unified Grid Spacing:** 5px gap between Huggy thumbnails with 5px padding for balanced layout
|
| 926 |
- **Future Assets:** Automatically loads new Huggys when added to the dataset
|
| 927 |
+
- Phase 8 (Image Upload): ✅ COMPLETE
|
| 928 |
+
- Click-to-upload via sidebar Image button
|
| 929 |
+
- Drag-and-drop functionality across entire app
|
| 930 |
+
- Visual feedback with blue overlay during drag
|
| 931 |
+
- File validation (PNG, JPG, WebP, 10MB max)
|
| 932 |
+
- Auto-selection after upload
|
| 933 |
+
- Random positioning to prevent stacking
|
| 934 |
+
- Phase 11 (Export Feature): ✅ COMPLETE
|
| 935 |
+
- PNG export with editable filename
|
| 936 |
+
- Loading state with spinner icon
|
| 937 |
+
- High-DPI display support (pixelRatio)
|
| 938 |
+
- Clean export (transformer hidden)
|
| 939 |
+
- Error handling with user alerts
|
| 940 |
+
- Works across all canvas sizes
|
| 941 |
- All previous phases (1-4, 9) fully functional
|
| 942 |
- Dev server running cleanly at http://localhost:3000
|
| 943 |
|
| 944 |
## Known Issues (To Be Fixed Later)
|
| 945 |
|
| 946 |
+
### 1. Responsive Layout Scaling Issue ✅ FIXED
|
| 947 |
**Problem:** When switching between different canvas sizes, layout assets progressively reduce in size with each change. This cumulative scaling is unwanted behavior.
|
| 948 |
|
| 949 |
**Impact:** Layout objects become unusably small after multiple canvas size switches.
|
| 950 |
|
| 951 |
+
**Solution Implemented (2025-11-23):**
|
| 952 |
+
- Changed from independent scaleX/scaleY to uniform scale factor using `Math.min(scaleX, scaleY)`
|
| 953 |
+
- Prevents asset distortion when switching canvas sizes
|
| 954 |
+
- Width and height now scale uniformly to maintain aspect ratios
|
| 955 |
+
- Positions still scale independently (x with scaleX, y with scaleY) for proper placement
|
| 956 |
+
- Implementation location: App.tsx:155-167
|
| 957 |
|
| 958 |
+
**Status:** ✅ RESOLVED - Responsive scaling now maintains aspect ratios correctly across all canvas size changes.
|
| 959 |
|
| 960 |
---
|
| 961 |
|
| 962 |
+
**Note:** All previously known issues have been resolved.
|
| 963 |
|
| 964 |
---
|
| 965 |
|
|
|
|
| 990 |
|
| 991 |
---
|
| 992 |
|
| 993 |
+
### Hover to Show Object Bounding Box ✅
|
| 994 |
+
**Status:** COMPLETED (2025-11-24)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 995 |
|
| 996 |
+
**Implementation:**
|
| 997 |
+
- Visual bounding box appears when hovering over canvas objects
|
| 998 |
+
- Light blue dashed outline (opacity 0.5, 4px dash pattern)
|
| 999 |
+
- Only shows when hovering over objects that are NOT selected
|
| 1000 |
+
- Uses `getClientRect()` to properly account for rotation and transformations
|
| 1001 |
+
- Added `onMouseEnter`/`onMouseLeave` handlers to all object types
|
| 1002 |
+
- Does not interfere with drag operations or transformer handles
|
| 1003 |
|
| 1004 |
+
**Result:** Provides clear visual feedback for interactive areas without interfering with selection system.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1005 |
|
| 1006 |
---
|
| 1007 |
|
|
|
|
| 1038 |
- May need to adjust for different screen sizes/resolutions
|
| 1039 |
- Original drop shadow value: TBD (measure current implementation)
|
| 1040 |
- Increased drop shadow value: TBD (design decision needed)
|
| 1041 |
+
|
| 1042 |
+
---
|
| 1043 |
+
|
| 1044 |
+
### Responsive Design Implementation 🚧
|
| 1045 |
+
**Status:** Planned - Ready for Implementation
|
| 1046 |
+
|
| 1047 |
+
**Goal:** Make HF Thumbnail Crafter fully responsive across mobile, tablet, and desktop devices while maintaining design fidelity and usability.
|
| 1048 |
+
|
| 1049 |
+
**Current Issues:**
|
| 1050 |
+
1. **Canvas sizes are fixed** (1200x675, 1200x627, 1280x720) - no scaling for smaller viewports
|
| 1051 |
+
2. **Sidebar is fixed position** (left-5) - may overlap canvas on small screens
|
| 1052 |
+
3. **CanvasHeader components** - fixed widths may not fit on mobile
|
| 1053 |
+
4. **TextToolbar** - position calculations assume desktop viewport
|
| 1054 |
+
5. **ExportButton** - fixed top-right position may not work on mobile
|
| 1055 |
+
6. **Menus** (HuggyMenu, LayoutSelector) - fixed widths may overflow
|
| 1056 |
+
|
| 1057 |
+
**Breakpoint Strategy:**
|
| 1058 |
+
- `sm`: 640px (Mobile)
|
| 1059 |
+
- `md`: 768px (Tablet)
|
| 1060 |
+
- `lg`: 1024px (Small Desktop)
|
| 1061 |
+
- `xl`: 1280px (Desktop)
|
| 1062 |
+
- `2xl`: 1536px (Large Desktop)
|
| 1063 |
+
|
| 1064 |
+
#### Phase 1: Canvas Scaling (Priority: HIGH) 🔴
|
| 1065 |
+
**Goal:** Scale canvas dynamically to fit viewport
|
| 1066 |
+
|
| 1067 |
+
**Implementation:**
|
| 1068 |
+
- Calculate scale factor: `min(1, (viewportWidth - padding) / canvasWidth)`
|
| 1069 |
+
- Apply CSS transform: `scale(scaleFactor)` to canvas container
|
| 1070 |
+
- Update all position calculations (toolbar, etc.) to account for scale
|
| 1071 |
+
- Add padding on mobile to ensure canvas never touches edges
|
| 1072 |
+
|
| 1073 |
+
**Files to modify:**
|
| 1074 |
+
- `src/components/Canvas/CanvasContainer.tsx` - Add scale calculation
|
| 1075 |
+
- `src/components/TextToolbar/TextToolbar.tsx` - Update position calculations
|
| 1076 |
+
- `src/App.tsx` - Pass scale factor to components
|
| 1077 |
+
|
| 1078 |
+
**Technical approach:**
|
| 1079 |
+
- Use `useEffect` + `window.addEventListener('resize')` for viewport changes
|
| 1080 |
+
- Consider using custom hook `useViewportScale()` for reusability
|
| 1081 |
+
- Maintain aspect ratios for canvas content during scaling
|
| 1082 |
+
|
| 1083 |
+
#### Phase 2: Sidebar Responsive Layout (Priority: HIGH) 🔴
|
| 1084 |
+
**Mobile (< 768px):**
|
| 1085 |
+
- Move sidebar to bottom of screen as horizontal bar
|
| 1086 |
+
- Show icons only (hide labels)
|
| 1087 |
+
- Make it full-width with buttons in a row
|
| 1088 |
+
- Menus (Layout/Huggy) appear as bottom sheets or modals
|
| 1089 |
+
|
| 1090 |
+
**Tablet (768px - 1024px):**
|
| 1091 |
+
- Keep sidebar on left but reduce size
|
| 1092 |
+
- Icons only, no labels
|
| 1093 |
+
- Reduce width from 87px to 60px
|
| 1094 |
+
|
| 1095 |
+
**Desktop (> 1024px):**
|
| 1096 |
+
- Current design (icons + labels)
|
| 1097 |
+
|
| 1098 |
+
**Files to modify:**
|
| 1099 |
+
- `src/components/Sidebar/Sidebar.tsx` - Add responsive classes
|
| 1100 |
+
- `src/components/Sidebar/HuggyMenu.tsx` - Position based on screen size
|
| 1101 |
+
- `src/components/Layout/LayoutSelector.tsx` - Position based on screen size
|
| 1102 |
+
|
| 1103 |
+
#### Phase 3: Header & Controls (Priority: MEDIUM) 🟡
|
| 1104 |
+
**CanvasHeader Mobile (< 640px):**
|
| 1105 |
+
- Stack BgColorSelector and CanvasSizeSelector vertically
|
| 1106 |
+
- Move above canvas with smaller size
|
| 1107 |
+
- Size selector: Show icon only, expand on click
|
| 1108 |
+
|
| 1109 |
+
**CanvasHeader Tablet (640px - 1024px):**
|
| 1110 |
+
- Keep horizontal layout but reduce sizes
|
| 1111 |
+
- Smaller buttons and text
|
| 1112 |
+
|
| 1113 |
+
**ExportButton Mobile (< 640px):**
|
| 1114 |
+
- Move to bottom-right or bottom-center
|
| 1115 |
+
- Smaller button size
|
| 1116 |
+
- Consider icon-only mode with filename edit in modal
|
| 1117 |
+
|
| 1118 |
+
**Files to modify:**
|
| 1119 |
+
- `src/components/CanvasHeader/CanvasHeader.tsx` - Add flex-col on mobile
|
| 1120 |
+
- `src/components/CanvasHeader/CanvasSizeSelector.tsx` - Responsive button sizing
|
| 1121 |
+
- `src/components/CanvasHeader/BgColorSelector.tsx` - Responsive button sizing
|
| 1122 |
+
- `src/components/ExportButton/ExportButton.tsx` - Add responsive positioning
|
| 1123 |
+
|
| 1124 |
+
#### Phase 4: TextToolbar Adaptation (Priority: HIGH) 🔴
|
| 1125 |
+
**Mobile (< 768px):**
|
| 1126 |
+
- Position at bottom of screen (full width or centered)
|
| 1127 |
+
- Stack controls vertically or reduce button sizes
|
| 1128 |
+
- Consider making it a bottom sheet
|
| 1129 |
+
|
| 1130 |
+
**Tablet & Desktop:**
|
| 1131 |
+
- Current positioning logic works
|
| 1132 |
+
|
| 1133 |
+
**Files to modify:**
|
| 1134 |
+
- `src/components/TextToolbar/TextToolbar.tsx` - Add responsive positioning
|
| 1135 |
+
|
| 1136 |
+
#### Phase 5: Menus & Modals (Priority: MEDIUM) 🟡
|
| 1137 |
+
**Mobile:**
|
| 1138 |
+
- LayoutSelector: Full-screen modal or bottom sheet
|
| 1139 |
+
- HuggyMenu: Full-screen modal with larger touch targets (44x44px minimum)
|
| 1140 |
+
- LayoutSwitchConfirmation: Center and add backdrop
|
| 1141 |
+
|
| 1142 |
+
**Files to modify:**
|
| 1143 |
+
- `src/components/Sidebar/HuggyMenu.tsx` - Responsive width/positioning
|
| 1144 |
+
- `src/components/Layout/LayoutSelector.tsx` - Responsive width/positioning
|
| 1145 |
+
- `src/components/Layout/LayoutSwitchConfirmation.tsx` - Responsive sizing
|
| 1146 |
+
|
| 1147 |
+
#### Phase 6: Polish & Testing (Priority: LOW) 🟢
|
| 1148 |
+
- Test on actual devices (not just Chrome DevTools)
|
| 1149 |
+
- Fix edge cases and viewport edge scenarios
|
| 1150 |
+
- Add orientation support (landscape/portrait)
|
| 1151 |
+
- Verify touch targets are at least 44x44px on mobile
|
| 1152 |
+
- Performance optimization for mobile devices
|
| 1153 |
+
|
| 1154 |
+
**Technical Considerations:**
|
| 1155 |
+
- Use `react-responsive` or custom hooks for breakpoints
|
| 1156 |
+
- Ensure touch targets are at least 44x44px on mobile
|
| 1157 |
+
- Test with Chrome DevTools device emulation AND actual devices
|
| 1158 |
+
- Consider using `@media (hover: none)` for touch-only devices
|
| 1159 |
+
- Memory-conscious state management for mobile devices
|
| 1160 |
+
|
| 1161 |
+
**Implementation Status:** 🚧 PAUSED - Phase 1 Attempted but Reverted
|
| 1162 |
+
**Next Action:** Redesign responsive approach without CSS transforms
|
| 1163 |
+
|
| 1164 |
+
**Session Notes (2025-01-26):**
|
| 1165 |
+
- ✅ Implemented hover state consistency (LayoutSelector → HuggyMenu)
|
| 1166 |
+
- ✅ Enhanced BgColorSelector with 3 options (seriousLight, light, dark)
|
| 1167 |
+
- ✅ Implemented image-based backgrounds using `use-image` package
|
| 1168 |
+
- ✅ Created IconBgGradient component with inline CSS gradient
|
| 1169 |
+
- ⚠️ **ATTEMPTED Phase 1: Canvas Scaling** - Had to revert due to text editing issues
|
| 1170 |
+
- Used `useViewportScale` hook to calculate scale factor
|
| 1171 |
+
- Applied CSS `transform: scale()` to CanvasContainer
|
| 1172 |
+
- **ISSUE**: Textarea positioning broke during text editing
|
| 1173 |
+
- **ROOT CAUSE**: `position: fixed` textarea outside scaled container caused misalignment
|
| 1174 |
+
- **REVERTED**: Removed CSS transform from CanvasContainer (lines 24-26)
|
| 1175 |
+
- **REVERTED**: Simplified textarea positioning back to basic calculation
|
| 1176 |
+
- **LESSON LEARNED**: Cannot use CSS transform for responsive scaling when using fixed-position overlays
|
| 1177 |
+
|
| 1178 |
+
**Technical Debt:**
|
| 1179 |
+
- Text editing textarea positioning is fragile - relies on `getBoundingClientRect()`
|
| 1180 |
+
- Need different approach for responsive scaling that doesn't use CSS transforms
|
| 1181 |
+
- Consider: Native Konva scaling, or repositioning approach instead of transform
|
| 1182 |
+
|
| 1183 |
+
**Files Modified This Session:**
|
| 1184 |
+
- `src/components/Sidebar/HuggyMenu.tsx` - Applied hover state from LayoutSelector
|
| 1185 |
+
- `src/types/canvas.types.ts` - Extended CanvasBgColor type (3 options)
|
| 1186 |
+
- `src/hooks/useCanvasState.ts` - Changed default bgColor to 'seriousLight'
|
| 1187 |
+
- `src/components/CanvasHeader/BgColorSelector.tsx` - Added 3rd background button
|
| 1188 |
+
- `src/components/Icons/IconBgGradient.tsx` - NEW: Gradient icon component
|
| 1189 |
+
- `src/components/Canvas/Canvas.tsx` - Image-based backgrounds, scale prop (reverted)
|
| 1190 |
+
- `src/App.tsx` - Integrated useViewportScale (temporarily)
|
| 1191 |
+
- `src/hooks/useViewportScale.ts` - NEW: Custom hook for responsive scaling (unused)
|
| 1192 |
+
- `src/components/Canvas/CanvasContainer.tsx` - Added/reverted scale transform
|
| 1193 |
+
- `src/components/TextToolbar/TextToolbar.tsx` - Added/reverted scale calculations
|
| 1194 |
+
|
| 1195 |
+
**Recommended Next Approach for Responsive:**
|
| 1196 |
+
1. **Option A**: Use Konva's native `scale()` method on Stage instead of CSS transform
|
| 1197 |
+
2. **Option B**: Dynamically adjust canvas dimensions instead of scaling
|
| 1198 |
+
3. **Option C**: Keep textarea inside Konva Stage using Html layer (Konva v7+)
|
| 1199 |
+
4. **Option D**: Use absolute positioning for textarea and recalculate on every render
|
| 1200 |
+
|
| 1201 |
+
---
|
| 1202 |
+
|
| 1203 |
+
## Recent Bug Fixes (2025-11-27)
|
| 1204 |
+
|
| 1205 |
+
### Text Clipping and Positioning Issues ✅ RESOLVED
|
| 1206 |
+
|
| 1207 |
+
**Problem:**
|
| 1208 |
+
1. Text objects were getting clipped after undo operations
|
| 1209 |
+
2. "supportive description" text in sandwich layout was misplaced and jumping during editing
|
| 1210 |
+
3. All centered text objects (funCollab, docs layouts) were incorrectly positioned
|
| 1211 |
+
|
| 1212 |
+
**Root Causes:**
|
| 1213 |
+
1. **Clipping Issue**: Layout text dimensions were too small for actual text content
|
| 1214 |
+
2. **Positioning Issue**: Textarea positioning didn't account for Konva's `offsetX/offsetY` properties
|
| 1215 |
+
3. **offsetX Confusion**: Centered text objects need `offsetX = width/2` to properly center at their x position
|
| 1216 |
+
|
| 1217 |
+
**Solutions Implemented:**
|
| 1218 |
+
|
| 1219 |
+
1. **Fixed textarea positioning in Canvas.tsx (line 420-436)**:
|
| 1220 |
+
```typescript
|
| 1221 |
+
const offsetX = editingText.offsetX || 0;
|
| 1222 |
+
const offsetY = editingText.offsetY || 0;
|
| 1223 |
+
|
| 1224 |
+
return {
|
| 1225 |
+
top: containerRect.top + editingText.y - offsetY,
|
| 1226 |
+
left: containerRect.left + editingText.x - offsetX
|
| 1227 |
+
};
|
| 1228 |
+
```
|
| 1229 |
+
|
| 1230 |
+
2. **Updated layout text dimensions in layouts.ts**:
|
| 1231 |
+
- funCollab title: width 852→900, height 120→140
|
| 1232 |
+
- sandwich title: width 986→1050, height 170→190
|
| 1233 |
+
- sandwich description: width 750→1015, height 95→107
|
| 1234 |
+
- docs title: height 110→111
|
| 1235 |
+
- docs subtitle: height 55→56
|
| 1236 |
+
|
| 1237 |
+
3. **Restored proper offsetX for centered texts**:
|
| 1238 |
+
- funCollab "Pretty Short Title": x=550, width=852, offsetX=426
|
| 1239 |
+
- sandwich "supportive description": x=600, width=1015, offsetX=507.5
|
| 1240 |
+
- docs "Transformers": x=570, width=643, offsetX=321.5
|
| 1241 |
+
- docs "Documentation": x=600, width=421, offsetX=210.5
|
| 1242 |
+
|
| 1243 |
+
4. **Added dimension recalculation in applyLayout() (App.tsx:122-147)**:
|
| 1244 |
+
- When layouts load, text dimensions are recalculated using Konva.Text measurement
|
| 1245 |
+
- Ensures text always fits properly, preventing clipping on initial load
|
| 1246 |
+
|
| 1247 |
+
5. **Fixed undo/redo text recalculation in useCanvasState.ts**:
|
| 1248 |
+
- Removed forced `isFixedSize: true` from recalculation
|
| 1249 |
+
- Text dimensions properly recalculated during history navigation
|
| 1250 |
+
|
| 1251 |
+
**Files Modified:**
|
| 1252 |
+
- `src/components/Canvas/Canvas.tsx` - Fixed textarea positioning with offsetX/offsetY support
|
| 1253 |
+
- `src/data/layouts.ts` - Updated text dimensions and restored proper offsetX values
|
| 1254 |
+
- `src/App.tsx` - Added text dimension recalculation in applyLayout()
|
| 1255 |
+
- `src/hooks/useCanvasState.ts` - Fixed undo/redo text recalculation
|
| 1256 |
+
|
| 1257 |
+
**Result:**
|
| 1258 |
+
- ✅ No text clipping in any layout
|
| 1259 |
+
- ✅ Text editing works correctly with proper textarea positioning
|
| 1260 |
+
- ✅ Centered text objects properly positioned and centered
|
| 1261 |
+
- ✅ Undo/redo maintains correct text dimensions
|
| 1262 |
+
- ✅ Text stays centered when users resize text boxes
|
| 1263 |
+
|
| 1264 |
+
**Status:** FULLY RESOLVED
|
|
@@ -12,7 +12,8 @@
|
|
| 12 |
"lucide-react": "^0.344.0",
|
| 13 |
"react": "^18.3.1",
|
| 14 |
"react-dom": "^18.3.1",
|
| 15 |
-
"react-konva": "^18.2.10"
|
|
|
|
| 16 |
},
|
| 17 |
"devDependencies": {
|
| 18 |
"@types/react": "^18.3.3",
|
|
@@ -2999,6 +3000,16 @@
|
|
| 2999 |
"browserslist": ">= 4.21.0"
|
| 3000 |
}
|
| 3001 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3002 |
"node_modules/util-deprecate": {
|
| 3003 |
"version": "1.0.2",
|
| 3004 |
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
|
|
|
| 12 |
"lucide-react": "^0.344.0",
|
| 13 |
"react": "^18.3.1",
|
| 14 |
"react-dom": "^18.3.1",
|
| 15 |
+
"react-konva": "^18.2.10",
|
| 16 |
+
"use-image": "^1.1.4"
|
| 17 |
},
|
| 18 |
"devDependencies": {
|
| 19 |
"@types/react": "^18.3.3",
|
|
|
|
| 3000 |
"browserslist": ">= 4.21.0"
|
| 3001 |
}
|
| 3002 |
},
|
| 3003 |
+
"node_modules/use-image": {
|
| 3004 |
+
"version": "1.1.4",
|
| 3005 |
+
"resolved": "https://registry.npmjs.org/use-image/-/use-image-1.1.4.tgz",
|
| 3006 |
+
"integrity": "sha512-P+swhszzHHgEb2X2yQ+vQNPCq/8Ks3hyfdXAVN133pvnvK7UK++bUaZUa5E+A3S02Mw8xOCBr9O6CLhk2fjrWA==",
|
| 3007 |
+
"license": "MIT",
|
| 3008 |
+
"peerDependencies": {
|
| 3009 |
+
"react": ">=16.8.0",
|
| 3010 |
+
"react-dom": ">=16.8.0"
|
| 3011 |
+
}
|
| 3012 |
+
},
|
| 3013 |
"node_modules/util-deprecate": {
|
| 3014 |
"version": "1.0.2",
|
| 3015 |
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
|
@@ -9,20 +9,21 @@
|
|
| 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 |
-
"
|
| 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 |
-
"
|
| 23 |
-
"vite": "^5.4.2",
|
| 24 |
-
"tailwindcss": "^3.4.1",
|
| 25 |
"postcss": "^8.4.35",
|
| 26 |
-
"
|
|
|
|
|
|
|
| 27 |
}
|
| 28 |
}
|
|
|
|
| 9 |
"preview": "vite preview"
|
| 10 |
},
|
| 11 |
"dependencies": {
|
| 12 |
+
"konva": "^9.3.6",
|
| 13 |
+
"lucide-react": "^0.344.0",
|
| 14 |
"react": "^18.3.1",
|
| 15 |
"react-dom": "^18.3.1",
|
| 16 |
"react-konva": "^18.2.10",
|
| 17 |
+
"use-image": "^1.1.4"
|
|
|
|
| 18 |
},
|
| 19 |
"devDependencies": {
|
| 20 |
"@types/react": "^18.3.3",
|
| 21 |
"@types/react-dom": "^18.3.0",
|
| 22 |
"@vitejs/plugin-react": "^4.3.1",
|
| 23 |
+
"autoprefixer": "^10.4.17",
|
|
|
|
|
|
|
| 24 |
"postcss": "^8.4.35",
|
| 25 |
+
"tailwindcss": "^3.4.1",
|
| 26 |
+
"typescript": "^5.5.3",
|
| 27 |
+
"vite": "^5.4.2"
|
| 28 |
}
|
| 29 |
}
|
|
|
Git LFS Details
|
|
|
Git LFS Details
|
|
|
Git LFS Details
|
|
|
Git LFS Details
|
|
|
Git LFS Details
|
|
|
Git LFS Details
|
|
|
Git LFS Details
|
|
|
Git LFS Details
|
|
|
Git LFS Details
|
|
@@ -1,14 +1,18 @@
|
|
| 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 {
|
|
|
|
| 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 {
|
|
@@ -23,11 +27,33 @@ function App() {
|
|
| 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');
|
|
@@ -45,9 +71,8 @@ function App() {
|
|
| 45 |
|
| 46 |
const handleImageClick = () => {
|
| 47 |
console.log('Image clicked');
|
| 48 |
-
//
|
| 49 |
-
|
| 50 |
-
setTextCreationMode(false);
|
| 51 |
};
|
| 52 |
|
| 53 |
const handleTextClick = () => {
|
|
@@ -69,54 +94,114 @@ function App() {
|
|
| 69 |
const hasUserObjects = objects.some(obj => !obj.isFromLayout);
|
| 70 |
|
| 71 |
if (hasUserObjects) {
|
| 72 |
-
//
|
| 73 |
-
|
| 74 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
addObject({
|
| 117 |
type: 'image',
|
| 118 |
-
x:
|
| 119 |
-
y:
|
| 120 |
width: huggySize,
|
| 121 |
height: huggySize,
|
| 122 |
src: huggy.thumbnail,
|
|
@@ -152,9 +237,177 @@ function App() {
|
|
| 152 |
setActiveButton(null);
|
| 153 |
};
|
| 154 |
|
| 155 |
-
const
|
| 156 |
-
|
| 157 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
};
|
| 159 |
|
| 160 |
// DISABLED: Handle responsive layout repositioning when canvas size changes
|
|
@@ -211,9 +464,13 @@ function App() {
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 217 |
setSelectedIds([]);
|
| 218 |
}
|
| 219 |
};
|
|
@@ -266,14 +523,14 @@ function App() {
|
|
| 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;
|
|
@@ -319,6 +576,31 @@ function App() {
|
|
| 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
|
|
@@ -334,7 +616,32 @@ function App() {
|
|
| 334 |
}, [selectedIds, deleteSelected, objects, setObjects, undo, redo]);
|
| 335 |
|
| 336 |
return (
|
| 337 |
-
<div
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 338 |
{/* Sidebar */}
|
| 339 |
<Sidebar
|
| 340 |
onLayoutClick={handleLayoutClick}
|
|
@@ -347,10 +654,15 @@ function App() {
|
|
| 347 |
/>
|
| 348 |
|
| 349 |
{/* Export Button */}
|
| 350 |
-
<ExportButton
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 351 |
|
| 352 |
{/* Canvas Container */}
|
| 353 |
-
<CanvasContainer>
|
| 354 |
<CanvasHeader
|
| 355 |
canvasSize={canvasSize}
|
| 356 |
bgColor={bgColor}
|
|
@@ -363,11 +675,151 @@ function App() {
|
|
| 363 |
objects={objects}
|
| 364 |
selectedIds={selectedIds}
|
| 365 |
onSelect={setSelectedIds}
|
| 366 |
-
onObjectsChange={
|
| 367 |
textCreationMode={textCreationMode}
|
| 368 |
onTextCreate={handleTextCreate}
|
|
|
|
|
|
|
| 369 |
/>
|
| 370 |
</CanvasContainer>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 371 |
</div>
|
| 372 |
);
|
| 373 |
}
|
|
|
|
| 1 |
+
import { useEffect, useState, useRef } 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 LayoutSwitchConfirmation from './components/Layout/LayoutSwitchConfirmation';
|
| 8 |
+
import TextToolbar from './components/TextToolbar/TextToolbar';
|
| 9 |
import { useCanvasState } from './hooks/useCanvasState';
|
| 10 |
+
import { useViewportScale } from './hooks/useViewportScale';
|
| 11 |
+
import { LayoutType, TextObject } from './types/canvas.types';
|
| 12 |
import { getLayoutById } from './data/layouts';
|
| 13 |
+
import { getCanvasDimensions, generateId, getNextZIndex } from './utils/canvas.utils';
|
| 14 |
import { Huggy } from './data/huggys';
|
| 15 |
+
import Konva from 'konva';
|
| 16 |
|
| 17 |
function App() {
|
| 18 |
const {
|
|
|
|
| 27 |
addObject,
|
| 28 |
deleteSelected,
|
| 29 |
undo,
|
| 30 |
+
redo,
|
| 31 |
+
setSkipHistoryRecording
|
| 32 |
} = useCanvasState();
|
| 33 |
|
| 34 |
const [textCreationMode, setTextCreationMode] = useState(false);
|
| 35 |
const [activeButton, setActiveButton] = useState<'layout' | 'huggy' | 'image' | 'text' | null>(null);
|
| 36 |
+
const [isDraggingFile, setIsDraggingFile] = useState(false);
|
| 37 |
+
const [isExporting, setIsExporting] = useState(false);
|
| 38 |
+
const [showLayoutConfirmation, setShowLayoutConfirmation] = useState(false);
|
| 39 |
+
const [pendingLayoutId, setPendingLayoutId] = useState<LayoutType | null>(null);
|
| 40 |
+
const [currentLayout, setCurrentLayout] = useState<LayoutType | null>(null);
|
| 41 |
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
| 42 |
+
const stageRef = useRef<Konva.Stage>(null);
|
| 43 |
+
|
| 44 |
+
// Wrapper for setObjects that detects text editing state changes
|
| 45 |
+
const handleObjectsChange = (newObjects: typeof objects) => {
|
| 46 |
+
const isNowEditing = newObjects.some(obj => obj.type === 'text' && obj.isEditing);
|
| 47 |
+
|
| 48 |
+
// Skip history recording during text editing (intermediate keystrokes)
|
| 49 |
+
// History will be recorded when editing ends since isEditing flag is ignored in comparison
|
| 50 |
+
setSkipHistoryRecording(isNowEditing);
|
| 51 |
+
setObjects(newObjects);
|
| 52 |
+
};
|
| 53 |
+
|
| 54 |
+
// Get canvas dimensions and calculate viewport scale
|
| 55 |
+
const dimensions = getCanvasDimensions(canvasSize);
|
| 56 |
+
const { scale } = useViewportScale(dimensions.width, dimensions.height);
|
| 57 |
|
| 58 |
const handleLayoutClick = () => {
|
| 59 |
console.log('Layout clicked');
|
|
|
|
| 71 |
|
| 72 |
const handleImageClick = () => {
|
| 73 |
console.log('Image clicked');
|
| 74 |
+
// Trigger file input instead of activating a mode
|
| 75 |
+
fileInputRef.current?.click();
|
|
|
|
| 76 |
};
|
| 77 |
|
| 78 |
const handleTextClick = () => {
|
|
|
|
| 94 |
const hasUserObjects = objects.some(obj => !obj.isFromLayout);
|
| 95 |
|
| 96 |
if (hasUserObjects) {
|
| 97 |
+
// Show custom confirmation dialog
|
| 98 |
+
setPendingLayoutId(layoutId);
|
| 99 |
+
setShowLayoutConfirmation(true);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
} else {
|
| 101 |
// No user objects, just load the layout normally
|
| 102 |
+
applyLayout(layoutId, false);
|
| 103 |
+
}
|
| 104 |
+
};
|
| 105 |
+
|
| 106 |
+
const applyLayout = (layoutId: LayoutType, keepExisting: boolean) => {
|
| 107 |
+
const layout = getLayoutById(layoutId);
|
| 108 |
+
if (!layout) return;
|
| 109 |
+
|
| 110 |
+
// Get the maximum zIndex from existing objects
|
| 111 |
+
const maxZIndex = objects.length > 0 ? Math.max(...objects.map(obj => obj.zIndex)) : 0;
|
| 112 |
+
|
| 113 |
+
const layoutObjects = layout.objects.map((obj, index) => {
|
| 114 |
+
const baseObj = {
|
| 115 |
...obj,
|
| 116 |
id: `${obj.id}-${Date.now()}`,
|
| 117 |
+
isFromLayout: true,
|
| 118 |
+
// If keeping existing, place new layout underneath by giving it lower zIndex
|
| 119 |
+
zIndex: keepExisting ? obj.zIndex - maxZIndex - 100 : obj.zIndex
|
| 120 |
+
};
|
| 121 |
+
|
| 122 |
+
// For text objects, recalculate dimensions to ensure text fits properly
|
| 123 |
+
if (obj.type === 'text' && obj.text) {
|
| 124 |
+
try {
|
| 125 |
+
const tempText = new Konva.Text({
|
| 126 |
+
text: obj.text,
|
| 127 |
+
fontSize: obj.fontSize,
|
| 128 |
+
fontFamily: obj.fontFamily,
|
| 129 |
+
fontStyle: `${obj.bold ? 'bold' : 'normal'} ${obj.italic ? 'italic' : ''}`
|
| 130 |
+
});
|
| 131 |
+
|
| 132 |
+
const calculatedWidth = Math.max(100, tempText.width() + 20);
|
| 133 |
+
const calculatedHeight = Math.max(40, tempText.height() + 10);
|
| 134 |
+
|
| 135 |
+
tempText.destroy();
|
| 136 |
+
|
| 137 |
+
// Use calculated dimensions if they're larger than layout dimensions
|
| 138 |
+
return {
|
| 139 |
+
...baseObj,
|
| 140 |
+
width: Math.max(obj.width, calculatedWidth),
|
| 141 |
+
height: Math.max(obj.height, calculatedHeight)
|
| 142 |
+
};
|
| 143 |
+
} catch (error) {
|
| 144 |
+
console.error('Error recalculating layout text dimensions:', error);
|
| 145 |
+
return baseObj;
|
| 146 |
+
}
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
return baseObj;
|
| 150 |
+
});
|
| 151 |
+
|
| 152 |
+
if (keepExisting) {
|
| 153 |
+
// Add new layout objects underneath existing objects
|
| 154 |
+
setObjects([...layoutObjects, ...objects]);
|
| 155 |
+
} else {
|
| 156 |
+
// Replace all with fresh layout
|
| 157 |
setObjects(layoutObjects);
|
| 158 |
}
|
| 159 |
|
| 160 |
+
// Track current layout
|
| 161 |
+
setCurrentLayout(layoutId);
|
| 162 |
setSelectedIds([]);
|
| 163 |
setActiveButton(null);
|
| 164 |
};
|
| 165 |
|
| 166 |
+
const handleLayoutKeep = () => {
|
| 167 |
+
if (pendingLayoutId) {
|
| 168 |
+
applyLayout(pendingLayoutId, true);
|
| 169 |
+
}
|
| 170 |
+
setShowLayoutConfirmation(false);
|
| 171 |
+
setPendingLayoutId(null);
|
| 172 |
+
};
|
| 173 |
+
|
| 174 |
+
const handleLayoutReplace = () => {
|
| 175 |
+
if (pendingLayoutId) {
|
| 176 |
+
applyLayout(pendingLayoutId, false);
|
| 177 |
+
}
|
| 178 |
+
setShowLayoutConfirmation(false);
|
| 179 |
+
setPendingLayoutId(null);
|
| 180 |
+
};
|
| 181 |
+
|
| 182 |
+
const handleLayoutCancel = () => {
|
| 183 |
+
setShowLayoutConfirmation(false);
|
| 184 |
+
setPendingLayoutId(null);
|
| 185 |
+
};
|
| 186 |
+
|
| 187 |
const handleSelectHuggy = (huggy: Huggy) => {
|
| 188 |
// Get canvas dimensions to center the Huggy
|
| 189 |
const dimensions = getCanvasDimensions(canvasSize);
|
| 190 |
const huggySize = 200; // Default Huggy size (can be scaled by user later)
|
| 191 |
|
| 192 |
+
// Add random offset around center to prevent stacking
|
| 193 |
+
// Random offset range: -100 to +100 pixels from center
|
| 194 |
+
const randomOffsetX = Math.floor(Math.random() * 200) - 100;
|
| 195 |
+
const randomOffsetY = Math.floor(Math.random() * 200) - 100;
|
| 196 |
+
|
| 197 |
+
const centerX = dimensions.width / 2 - huggySize / 2;
|
| 198 |
+
const centerY = dimensions.height / 2 - huggySize / 2;
|
| 199 |
+
|
| 200 |
+
// Add Huggy image near the center with random offset
|
| 201 |
addObject({
|
| 202 |
type: 'image',
|
| 203 |
+
x: centerX + randomOffsetX,
|
| 204 |
+
y: centerY + randomOffsetY,
|
| 205 |
width: huggySize,
|
| 206 |
height: huggySize,
|
| 207 |
src: huggy.thumbnail,
|
|
|
|
| 237 |
setActiveButton(null);
|
| 238 |
};
|
| 239 |
|
| 240 |
+
const handleFileSelect = (file: File, dropX?: number, dropY?: number) => {
|
| 241 |
+
// Validate file type
|
| 242 |
+
const validTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/webp'];
|
| 243 |
+
if (!validTypes.includes(file.type)) {
|
| 244 |
+
alert('Please upload a valid image file (PNG, JPG, or WebP)');
|
| 245 |
+
return;
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
// Validate file size (max 10MB)
|
| 249 |
+
const maxSize = 10 * 1024 * 1024; // 10MB
|
| 250 |
+
if (file.size > maxSize) {
|
| 251 |
+
alert('File size must be less than 10MB');
|
| 252 |
+
return;
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
// Create image URL
|
| 256 |
+
const reader = new FileReader();
|
| 257 |
+
reader.onload = (e) => {
|
| 258 |
+
const imageUrl = e.target?.result as string;
|
| 259 |
+
|
| 260 |
+
// Load the actual image to get its dimensions
|
| 261 |
+
const img = new Image();
|
| 262 |
+
img.onload = () => {
|
| 263 |
+
const dimensions = getCanvasDimensions(canvasSize);
|
| 264 |
+
let width = img.width;
|
| 265 |
+
let height = img.height;
|
| 266 |
+
|
| 267 |
+
// If image is larger than canvas, resize to ~1/2 canvas size
|
| 268 |
+
const canvasMaxDimension = Math.max(dimensions.width, dimensions.height);
|
| 269 |
+
const imageMaxDimension = Math.max(width, height);
|
| 270 |
+
|
| 271 |
+
if (imageMaxDimension > canvasMaxDimension) {
|
| 272 |
+
// Scale to 1/2 of canvas size
|
| 273 |
+
const targetSize = canvasMaxDimension * 0.5;
|
| 274 |
+
const scale = targetSize / imageMaxDimension;
|
| 275 |
+
width = width * scale;
|
| 276 |
+
height = height * scale;
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
let posX: number;
|
| 280 |
+
let posY: number;
|
| 281 |
+
|
| 282 |
+
if (dropX !== undefined && dropY !== undefined) {
|
| 283 |
+
// Use drop position, centered on cursor
|
| 284 |
+
posX = dropX - width / 2;
|
| 285 |
+
posY = dropY - height / 2;
|
| 286 |
+
} else {
|
| 287 |
+
// Fallback: center with random offset (for file input click)
|
| 288 |
+
const randomOffsetX = Math.floor(Math.random() * 200) - 100;
|
| 289 |
+
const randomOffsetY = Math.floor(Math.random() * 200) - 100;
|
| 290 |
+
posX = dimensions.width / 2 - width / 2 + randomOffsetX;
|
| 291 |
+
posY = dimensions.height / 2 - height / 2 + randomOffsetY;
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
// Add image to canvas with correct aspect ratio
|
| 295 |
+
addObject({
|
| 296 |
+
type: 'image',
|
| 297 |
+
x: posX,
|
| 298 |
+
y: posY,
|
| 299 |
+
width: width,
|
| 300 |
+
height: height,
|
| 301 |
+
src: imageUrl,
|
| 302 |
+
rotation: 0,
|
| 303 |
+
isFromLayout: false
|
| 304 |
+
});
|
| 305 |
+
};
|
| 306 |
+
img.src = imageUrl;
|
| 307 |
+
};
|
| 308 |
+
reader.readAsDataURL(file);
|
| 309 |
+
};
|
| 310 |
+
|
| 311 |
+
const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 312 |
+
const file = e.target.files?.[0];
|
| 313 |
+
if (file) {
|
| 314 |
+
handleFileSelect(file);
|
| 315 |
+
}
|
| 316 |
+
// Reset input value to allow selecting the same file again
|
| 317 |
+
e.target.value = '';
|
| 318 |
+
};
|
| 319 |
+
|
| 320 |
+
const handleDragOver = (e: React.DragEvent) => {
|
| 321 |
+
e.preventDefault();
|
| 322 |
+
setIsDraggingFile(true);
|
| 323 |
+
};
|
| 324 |
+
|
| 325 |
+
const handleDragLeave = (e: React.DragEvent) => {
|
| 326 |
+
e.preventDefault();
|
| 327 |
+
setIsDraggingFile(false);
|
| 328 |
+
};
|
| 329 |
+
|
| 330 |
+
const handleDrop = (e: React.DragEvent) => {
|
| 331 |
+
e.preventDefault();
|
| 332 |
+
setIsDraggingFile(false);
|
| 333 |
+
|
| 334 |
+
const file = e.dataTransfer.files?.[0];
|
| 335 |
+
if (file) {
|
| 336 |
+
// Get the canvas container element to calculate relative position
|
| 337 |
+
const canvasContainer = document.querySelector('.canvas-container');
|
| 338 |
+
if (canvasContainer) {
|
| 339 |
+
const rect = canvasContainer.getBoundingClientRect();
|
| 340 |
+
|
| 341 |
+
// Calculate position relative to canvas
|
| 342 |
+
const dropX = e.clientX - rect.left;
|
| 343 |
+
const dropY = e.clientY - rect.top;
|
| 344 |
+
|
| 345 |
+
// Find the actual canvas stage element (the white/dark canvas area)
|
| 346 |
+
const canvasStage = canvasContainer.querySelector('.konvajs-content');
|
| 347 |
+
if (canvasStage) {
|
| 348 |
+
const canvasRect = canvasStage.getBoundingClientRect();
|
| 349 |
+
const canvasRelativeX = e.clientX - canvasRect.left;
|
| 350 |
+
const canvasRelativeY = e.clientY - canvasRect.top;
|
| 351 |
+
|
| 352 |
+
// Check if drop is within canvas bounds
|
| 353 |
+
if (canvasRelativeX >= 0 && canvasRelativeX <= canvasRect.width &&
|
| 354 |
+
canvasRelativeY >= 0 && canvasRelativeY <= canvasRect.height) {
|
| 355 |
+
handleFileSelect(file, canvasRelativeX, canvasRelativeY);
|
| 356 |
+
return;
|
| 357 |
+
}
|
| 358 |
+
}
|
| 359 |
+
}
|
| 360 |
+
|
| 361 |
+
// Fallback: use default positioning
|
| 362 |
+
handleFileSelect(file);
|
| 363 |
+
}
|
| 364 |
+
};
|
| 365 |
+
|
| 366 |
+
const handleExport = async (filename: string) => {
|
| 367 |
+
if (!stageRef.current) {
|
| 368 |
+
console.error('Stage ref not available');
|
| 369 |
+
return;
|
| 370 |
+
}
|
| 371 |
+
|
| 372 |
+
setIsExporting(true);
|
| 373 |
+
|
| 374 |
+
try {
|
| 375 |
+
// Deselect all objects before exporting (hide transformer)
|
| 376 |
+
const previousSelection = selectedIds;
|
| 377 |
+
setSelectedIds([]);
|
| 378 |
+
|
| 379 |
+
// Small delay to allow transformer to hide
|
| 380 |
+
await new Promise(resolve => setTimeout(resolve, 50));
|
| 381 |
+
|
| 382 |
+
// Get the stage and export as PNG
|
| 383 |
+
const stage = stageRef.current;
|
| 384 |
+
|
| 385 |
+
// Get pixel ratio for high-DPI displays
|
| 386 |
+
const pixelRatio = window.devicePixelRatio || 1;
|
| 387 |
+
|
| 388 |
+
// Export canvas as data URL
|
| 389 |
+
const dataURL = stage.toDataURL({
|
| 390 |
+
mimeType: 'image/png',
|
| 391 |
+
quality: 1,
|
| 392 |
+
pixelRatio: pixelRatio
|
| 393 |
+
});
|
| 394 |
+
|
| 395 |
+
// Create a download link
|
| 396 |
+
const link = document.createElement('a');
|
| 397 |
+
link.download = `${filename}.png`;
|
| 398 |
+
link.href = dataURL;
|
| 399 |
+
document.body.appendChild(link);
|
| 400 |
+
link.click();
|
| 401 |
+
document.body.removeChild(link);
|
| 402 |
+
|
| 403 |
+
// Restore previous selection
|
| 404 |
+
setSelectedIds(previousSelection);
|
| 405 |
+
} catch (error) {
|
| 406 |
+
console.error('Error exporting canvas:', error);
|
| 407 |
+
alert('Failed to export canvas. Please try again.');
|
| 408 |
+
} finally {
|
| 409 |
+
setIsExporting(false);
|
| 410 |
+
}
|
| 411 |
};
|
| 412 |
|
| 413 |
// DISABLED: Handle responsive layout repositioning when canvas size changes
|
|
|
|
| 464 |
const handleClickOutside = (e: MouseEvent) => {
|
| 465 |
// Find the canvas container element
|
| 466 |
const canvasContainer = document.querySelector('.canvas-container');
|
| 467 |
+
const textToolbar = document.querySelector('.text-toolbar');
|
| 468 |
|
| 469 |
+
// If click is outside canvas container and text toolbar, and there are selected objects, deselect
|
| 470 |
+
const clickedInsideCanvas = canvasContainer && canvasContainer.contains(e.target as Node);
|
| 471 |
+
const clickedInsideToolbar = textToolbar && textToolbar.contains(e.target as Node);
|
| 472 |
+
|
| 473 |
+
if (!clickedInsideCanvas && !clickedInsideToolbar && selectedIds.length > 0) {
|
| 474 |
setSelectedIds([]);
|
| 475 |
}
|
| 476 |
};
|
|
|
|
| 523 |
const isEditingText = objects.some(obj => obj.type === 'text' && obj.isEditing);
|
| 524 |
|
| 525 |
// Undo with Ctrl+Z (or Cmd+Z on Mac)
|
| 526 |
+
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z' && !e.shiftKey && !isEditingText) {
|
| 527 |
e.preventDefault();
|
| 528 |
undo();
|
| 529 |
return;
|
| 530 |
}
|
| 531 |
|
| 532 |
+
// Redo with Ctrl+Shift+Z (or Cmd+Shift+Z on Mac) or Ctrl+Y on Windows
|
| 533 |
+
if ((e.ctrlKey || e.metaKey) && (e.key.toLowerCase() === 'z' && e.shiftKey || e.key.toLowerCase() === 'y') && !isEditingText) {
|
| 534 |
e.preventDefault();
|
| 535 |
redo();
|
| 536 |
return;
|
|
|
|
| 576 |
deleteSelected();
|
| 577 |
}
|
| 578 |
|
| 579 |
+
// Duplicate selected objects with Ctrl+D (only when NOT editing text)
|
| 580 |
+
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'd' && selectedIds.length > 0 && !isEditingText) {
|
| 581 |
+
e.preventDefault();
|
| 582 |
+
|
| 583 |
+
// Get selected objects
|
| 584 |
+
const selectedObjects = objects.filter(obj => selectedIds.includes(obj.id));
|
| 585 |
+
|
| 586 |
+
// Create duplicates with offset position
|
| 587 |
+
const offset = 20; // pixels to offset the duplicate
|
| 588 |
+
const duplicatedObjects = selectedObjects.map(obj => ({
|
| 589 |
+
...obj,
|
| 590 |
+
id: generateId(),
|
| 591 |
+
x: obj.x + offset,
|
| 592 |
+
y: obj.y + offset,
|
| 593 |
+
zIndex: getNextZIndex(objects),
|
| 594 |
+
isFromLayout: false // User-created duplicates are not from layout
|
| 595 |
+
}));
|
| 596 |
+
|
| 597 |
+
// Add duplicates to canvas
|
| 598 |
+
setObjects([...objects, ...duplicatedObjects]);
|
| 599 |
+
|
| 600 |
+
// Select the duplicated objects
|
| 601 |
+
setSelectedIds(duplicatedObjects.map(obj => obj.id));
|
| 602 |
+
}
|
| 603 |
+
|
| 604 |
// Activate text creation mode with 'T' key
|
| 605 |
if (e.key === 't' || e.key === 'T') {
|
| 606 |
// Don't activate if user is typing in an input or textarea
|
|
|
|
| 616 |
}, [selectedIds, deleteSelected, objects, setObjects, undo, redo]);
|
| 617 |
|
| 618 |
return (
|
| 619 |
+
<div
|
| 620 |
+
className="w-full h-full bg-[#F8F9FB] relative overflow-hidden dotted-background"
|
| 621 |
+
onDragOver={handleDragOver}
|
| 622 |
+
onDragLeave={handleDragLeave}
|
| 623 |
+
onDrop={handleDrop}
|
| 624 |
+
>
|
| 625 |
+
{/* Hidden file input for image upload */}
|
| 626 |
+
<input
|
| 627 |
+
ref={fileInputRef}
|
| 628 |
+
type="file"
|
| 629 |
+
accept="image/png,image/jpeg,image/jpg,image/webp"
|
| 630 |
+
onChange={handleFileInputChange}
|
| 631 |
+
style={{ display: 'none' }}
|
| 632 |
+
/>
|
| 633 |
+
|
| 634 |
+
{/* Drag hint overlay */}
|
| 635 |
+
{isDraggingFile && (
|
| 636 |
+
<div className="absolute inset-0 z-50 bg-blue-500 bg-opacity-10 border-4 border-dashed border-blue-500 flex items-center justify-center pointer-events-none">
|
| 637 |
+
<div className="bg-white rounded-lg shadow-lg px-8 py-6">
|
| 638 |
+
<p className="text-xl font-semibold text-gray-800">
|
| 639 |
+
Drop your image anywhere to upload
|
| 640 |
+
</p>
|
| 641 |
+
</div>
|
| 642 |
+
</div>
|
| 643 |
+
)}
|
| 644 |
+
|
| 645 |
{/* Sidebar */}
|
| 646 |
<Sidebar
|
| 647 |
onLayoutClick={handleLayoutClick}
|
|
|
|
| 654 |
/>
|
| 655 |
|
| 656 |
{/* Export Button */}
|
| 657 |
+
<ExportButton
|
| 658 |
+
onExport={handleExport}
|
| 659 |
+
isExporting={isExporting}
|
| 660 |
+
currentLayout={currentLayout}
|
| 661 |
+
canvasSize={canvasSize}
|
| 662 |
+
/>
|
| 663 |
|
| 664 |
{/* Canvas Container */}
|
| 665 |
+
<CanvasContainer scale={scale}>
|
| 666 |
<CanvasHeader
|
| 667 |
canvasSize={canvasSize}
|
| 668 |
bgColor={bgColor}
|
|
|
|
| 675 |
objects={objects}
|
| 676 |
selectedIds={selectedIds}
|
| 677 |
onSelect={setSelectedIds}
|
| 678 |
+
onObjectsChange={handleObjectsChange}
|
| 679 |
textCreationMode={textCreationMode}
|
| 680 |
onTextCreate={handleTextCreate}
|
| 681 |
+
stageRef={stageRef}
|
| 682 |
+
scale={scale}
|
| 683 |
/>
|
| 684 |
</CanvasContainer>
|
| 685 |
+
|
| 686 |
+
{/* Layout Switch Confirmation Dialog */}
|
| 687 |
+
{showLayoutConfirmation && (
|
| 688 |
+
<LayoutSwitchConfirmation
|
| 689 |
+
onKeep={handleLayoutKeep}
|
| 690 |
+
onReplace={handleLayoutReplace}
|
| 691 |
+
onCancel={handleLayoutCancel}
|
| 692 |
+
/>
|
| 693 |
+
)}
|
| 694 |
+
|
| 695 |
+
{/* Text Toolbar - Show when text is selected, editing, or in creation mode */}
|
| 696 |
+
{(() => {
|
| 697 |
+
// Check if any text object is selected or being edited
|
| 698 |
+
const selectedTextObjects = objects.filter(
|
| 699 |
+
(obj): obj is TextObject =>
|
| 700 |
+
obj.type === 'text' && (selectedIds.includes(obj.id) || obj.isEditing)
|
| 701 |
+
);
|
| 702 |
+
|
| 703 |
+
const showToolbar = textCreationMode || selectedTextObjects.length > 0;
|
| 704 |
+
|
| 705 |
+
if (!showToolbar) return null;
|
| 706 |
+
|
| 707 |
+
// Get properties from the first selected/editing text object, or use defaults
|
| 708 |
+
const textObj = selectedTextObjects[0];
|
| 709 |
+
const fontFamily = textObj?.fontFamily || 'Inter';
|
| 710 |
+
const fill = textObj?.fill || '#000000';
|
| 711 |
+
const bold = textObj?.bold || false;
|
| 712 |
+
const italic = textObj?.italic || false;
|
| 713 |
+
|
| 714 |
+
// Get canvas dimensions
|
| 715 |
+
const dimensions = getCanvasDimensions(canvasSize);
|
| 716 |
+
|
| 717 |
+
return (
|
| 718 |
+
<TextToolbar
|
| 719 |
+
fontFamily={fontFamily}
|
| 720 |
+
fontSize={textObj?.fontSize || 68}
|
| 721 |
+
fill={fill}
|
| 722 |
+
bold={bold}
|
| 723 |
+
italic={italic}
|
| 724 |
+
canvasWidth={dimensions.width}
|
| 725 |
+
canvasHeight={dimensions.height}
|
| 726 |
+
scale={scale}
|
| 727 |
+
stageRef={stageRef}
|
| 728 |
+
onFontFamilyChange={(font) => {
|
| 729 |
+
setObjects(prevObjects =>
|
| 730 |
+
prevObjects.map(o => {
|
| 731 |
+
if (o.type === 'text' && selectedTextObjects.some(selected => selected.id === o.id)) {
|
| 732 |
+
// Recalculate text dimensions with new font
|
| 733 |
+
try {
|
| 734 |
+
const tempText = new Konva.Text({
|
| 735 |
+
text: o.text || 'M',
|
| 736 |
+
fontSize: o.fontSize,
|
| 737 |
+
fontFamily: font,
|
| 738 |
+
fontStyle: `${o.bold ? 'bold' : 'normal'} ${o.italic ? 'italic' : ''}`
|
| 739 |
+
});
|
| 740 |
+
|
| 741 |
+
const newWidth = Math.max(100, tempText.width() + 20);
|
| 742 |
+
const newHeight = Math.max(40, tempText.height() + 10);
|
| 743 |
+
|
| 744 |
+
tempText.destroy();
|
| 745 |
+
return { ...o, fontFamily: font, width: newWidth, height: newHeight };
|
| 746 |
+
} catch (error) {
|
| 747 |
+
console.error('Error recalculating text size:', error);
|
| 748 |
+
return { ...o, fontFamily: font };
|
| 749 |
+
}
|
| 750 |
+
}
|
| 751 |
+
return o;
|
| 752 |
+
})
|
| 753 |
+
);
|
| 754 |
+
}}
|
| 755 |
+
onFillChange={(color) => {
|
| 756 |
+
setObjects(prevObjects =>
|
| 757 |
+
prevObjects.map(o =>
|
| 758 |
+
o.type === 'text' && selectedTextObjects.some(selected => selected.id === o.id)
|
| 759 |
+
? { ...o, fill: color }
|
| 760 |
+
: o
|
| 761 |
+
)
|
| 762 |
+
);
|
| 763 |
+
}}
|
| 764 |
+
onBoldToggle={() => {
|
| 765 |
+
setObjects(prevObjects =>
|
| 766 |
+
prevObjects.map(o => {
|
| 767 |
+
if (o.type === 'text' && selectedTextObjects.some(selected => selected.id === o.id)) {
|
| 768 |
+
const newBold = !o.bold;
|
| 769 |
+
// Recalculate text dimensions with new bold state
|
| 770 |
+
try {
|
| 771 |
+
const tempText = new Konva.Text({
|
| 772 |
+
text: o.text || 'M',
|
| 773 |
+
fontSize: o.fontSize,
|
| 774 |
+
fontFamily: o.fontFamily,
|
| 775 |
+
fontStyle: `${newBold ? 'bold' : 'normal'} ${o.italic ? 'italic' : ''}`
|
| 776 |
+
});
|
| 777 |
+
|
| 778 |
+
const newWidth = Math.max(100, tempText.width() + 20);
|
| 779 |
+
const newHeight = Math.max(40, tempText.height() + 10);
|
| 780 |
+
|
| 781 |
+
tempText.destroy();
|
| 782 |
+
return { ...o, bold: newBold, width: newWidth, height: newHeight };
|
| 783 |
+
} catch (error) {
|
| 784 |
+
console.error('Error recalculating text size:', error);
|
| 785 |
+
return { ...o, bold: newBold };
|
| 786 |
+
}
|
| 787 |
+
}
|
| 788 |
+
return o;
|
| 789 |
+
})
|
| 790 |
+
);
|
| 791 |
+
}}
|
| 792 |
+
onItalicToggle={() => {
|
| 793 |
+
setObjects(prevObjects =>
|
| 794 |
+
prevObjects.map(o => {
|
| 795 |
+
if (o.type === 'text' && selectedTextObjects.some(selected => selected.id === o.id)) {
|
| 796 |
+
const newItalic = !o.italic;
|
| 797 |
+
// Recalculate text dimensions with new italic state
|
| 798 |
+
try {
|
| 799 |
+
const tempText = new Konva.Text({
|
| 800 |
+
text: o.text || 'M',
|
| 801 |
+
fontSize: o.fontSize,
|
| 802 |
+
fontFamily: o.fontFamily,
|
| 803 |
+
fontStyle: `${o.bold ? 'bold' : 'normal'} ${newItalic ? 'italic' : ''}`
|
| 804 |
+
});
|
| 805 |
+
|
| 806 |
+
const newWidth = Math.max(100, tempText.width() + 20);
|
| 807 |
+
const newHeight = Math.max(40, tempText.height() + 10);
|
| 808 |
+
|
| 809 |
+
tempText.destroy();
|
| 810 |
+
return { ...o, italic: newItalic, width: newWidth, height: newHeight };
|
| 811 |
+
} catch (error) {
|
| 812 |
+
console.error('Error recalculating text size:', error);
|
| 813 |
+
return { ...o, italic: newItalic };
|
| 814 |
+
}
|
| 815 |
+
}
|
| 816 |
+
return o;
|
| 817 |
+
})
|
| 818 |
+
);
|
| 819 |
+
}}
|
| 820 |
+
/>
|
| 821 |
+
);
|
| 822 |
+
})()}
|
| 823 |
</div>
|
| 824 |
);
|
| 825 |
}
|
|
@@ -1,9 +1,10 @@
|
|
| 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;
|
|
@@ -14,6 +15,8 @@ interface CanvasProps {
|
|
| 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
|
|
@@ -60,9 +63,12 @@ export default function Canvas({
|
|
| 60 |
onSelect,
|
| 61 |
onObjectsChange,
|
| 62 |
textCreationMode = false,
|
| 63 |
-
onTextCreate
|
|
|
|
|
|
|
| 64 |
}: CanvasProps) {
|
| 65 |
-
const
|
|
|
|
| 66 |
const transformerRef = useRef<Konva.Transformer>(null);
|
| 67 |
const shapeRefs = useRef<Map<string, Konva.Node>>(new Map());
|
| 68 |
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
@@ -70,9 +76,33 @@ export default function Canvas({
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
|
| 77 |
// Wait for fonts to load before rendering
|
| 78 |
useEffect(() => {
|
|
@@ -130,27 +160,38 @@ export default function Canvas({
|
|
| 130 |
useEffect(() => {
|
| 131 |
if (!transformerRef.current) return;
|
| 132 |
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
} else {
|
| 144 |
-
transformerRef.current
|
| 145 |
}
|
| 146 |
-
}
|
| 147 |
-
transformerRef.current.nodes([]);
|
| 148 |
-
}
|
| 149 |
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
layer.batchDraw();
|
| 153 |
-
}
|
| 154 |
}, [selectedIds, objects]);
|
| 155 |
|
| 156 |
// Handle object selection
|
|
@@ -170,9 +211,90 @@ export default function Canvas({
|
|
| 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() }
|
|
@@ -187,38 +309,92 @@ export default function Canvas({
|
|
| 187 |
const scaleX = node.scaleX();
|
| 188 |
const scaleY = node.scaleY();
|
| 189 |
|
| 190 |
-
//
|
| 191 |
-
|
| 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 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
|
|
|
| 210 |
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
}
|
| 217 |
-
return
|
| 218 |
-
}
|
| 219 |
-
|
| 220 |
-
}
|
| 221 |
-
onObjectsChange(updatedObjects);
|
| 222 |
};
|
| 223 |
|
| 224 |
// Handle text content changes
|
|
@@ -233,6 +409,17 @@ export default function Canvas({
|
|
| 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 }
|
|
@@ -320,9 +507,13 @@ export default function Canvas({
|
|
| 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 |
|
|
@@ -450,13 +641,20 @@ export default function Canvas({
|
|
| 450 |
|
| 451 |
return (
|
| 452 |
<div
|
|
|
|
|
|
|
| 453 |
style={{
|
| 454 |
width: dimensions.width,
|
| 455 |
height: dimensions.height,
|
| 456 |
-
|
| 457 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 458 |
overflow: 'hidden',
|
| 459 |
-
transition: 'width 0.15s ease-in-out, height 0.15s ease-in-out'
|
| 460 |
}}
|
| 461 |
>
|
| 462 |
<Stage
|
|
@@ -483,6 +681,26 @@ export default function Canvas({
|
|
| 483 |
style={{ cursor: textCreationMode ? 'text' : 'default' }}
|
| 484 |
>
|
| 485 |
<Layer>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 486 |
{sortedObjects.map((obj) => {
|
| 487 |
return (
|
| 488 |
<CanvasObjectRenderer
|
|
@@ -493,9 +711,13 @@ export default function Canvas({
|
|
| 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);
|
|
@@ -522,6 +744,52 @@ export default function Canvas({
|
|
| 522 |
/>
|
| 523 |
)}
|
| 524 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 525 |
{/* Transformer for selected objects */}
|
| 526 |
<Transformer
|
| 527 |
ref={transformerRef}
|
|
|
|
| 1 |
import { useRef, useEffect, useState } from 'react';
|
| 2 |
+
import { Stage, Layer, Transformer, Rect, Image as KonvaImage } 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 |
+
import useImage from 'use-image';
|
| 8 |
|
| 9 |
interface CanvasProps {
|
| 10 |
canvasSize: CanvasSize;
|
|
|
|
| 15 |
onObjectsChange: (objects: CanvasObject[]) => void;
|
| 16 |
textCreationMode?: boolean;
|
| 17 |
onTextCreate?: (x: number, y: number) => void;
|
| 18 |
+
stageRef?: React.RefObject<Konva.Stage>;
|
| 19 |
+
scale?: number;
|
| 20 |
}
|
| 21 |
|
| 22 |
// Helper function to calculate cursor position from click coordinates
|
|
|
|
| 63 |
onSelect,
|
| 64 |
onObjectsChange,
|
| 65 |
textCreationMode = false,
|
| 66 |
+
onTextCreate,
|
| 67 |
+
stageRef: externalStageRef,
|
| 68 |
+
scale = 1
|
| 69 |
}: CanvasProps) {
|
| 70 |
+
const internalStageRef = useRef<Konva.Stage>(null);
|
| 71 |
+
const stageRef = externalStageRef || internalStageRef;
|
| 72 |
const transformerRef = useRef<Konva.Transformer>(null);
|
| 73 |
const shapeRefs = useRef<Map<string, Konva.Node>>(new Map());
|
| 74 |
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
|
|
| 76 |
const [fontsLoaded, setFontsLoaded] = useState(false);
|
| 77 |
const [selectionBox, setSelectionBox] = useState<{ x: number; y: number; width: number; height: number } | null>(null);
|
| 78 |
const selectionStartRef = useRef<{ x: number; y: number } | null>(null);
|
| 79 |
+
const transformEndTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
| 80 |
+
const [hoveredId, setHoveredId] = useState<string | null>(null);
|
| 81 |
+
const [isCanvasHovered, setIsCanvasHovered] = useState(false);
|
| 82 |
+
const [snapGuides, setSnapGuides] = useState<{ vertical: boolean; horizontal: boolean }>({ vertical: false, horizontal: false });
|
| 83 |
|
| 84 |
const dimensions = getCanvasDimensions(canvasSize);
|
| 85 |
+
|
| 86 |
+
// Map canvas size to background image filename prefix
|
| 87 |
+
const sizePrefix = canvasSize === '1200x675' ? 'twitter' :
|
| 88 |
+
canvasSize === 'linkedin' ? 'LinkedIn' :
|
| 89 |
+
'HF';
|
| 90 |
+
|
| 91 |
+
// Get background image path based on bgColor and canvas size
|
| 92 |
+
const getBackgroundImage = () => {
|
| 93 |
+
if (bgColor === 'seriousLight') {
|
| 94 |
+
return `/assets/backgrounds/bg_sLight_${sizePrefix}.png`;
|
| 95 |
+
} else if (bgColor === 'light') {
|
| 96 |
+
return `/assets/backgrounds/bg_Light_${sizePrefix}.png`;
|
| 97 |
+
} else {
|
| 98 |
+
return `/assets/backgrounds/bg_dark_${sizePrefix}.png`;
|
| 99 |
+
}
|
| 100 |
+
};
|
| 101 |
+
|
| 102 |
+
const backgroundImage = getBackgroundImage();
|
| 103 |
+
|
| 104 |
+
// Load background image
|
| 105 |
+
const [bgImageElement] = useImage(backgroundImage);
|
| 106 |
|
| 107 |
// Wait for fonts to load before rendering
|
| 108 |
useEffect(() => {
|
|
|
|
| 160 |
useEffect(() => {
|
| 161 |
if (!transformerRef.current) return;
|
| 162 |
|
| 163 |
+
const attemptAttachment = (attempt: number = 0) => {
|
| 164 |
+
if (selectedIds.length > 0) {
|
| 165 |
+
const nodes = selectedIds
|
| 166 |
+
.map(id => shapeRefs.current.get(id))
|
| 167 |
+
.filter((node): node is Konva.Node => node !== undefined);
|
| 168 |
+
|
| 169 |
+
console.log(`Attempt ${attempt}: Attaching transformer to:`, selectedIds, 'Found nodes:', nodes.length);
|
| 170 |
+
|
| 171 |
+
if (nodes.length > 0) {
|
| 172 |
+
console.log('SUCCESS: Attaching transformer to', nodes.length, 'nodes');
|
| 173 |
+
transformerRef.current!.nodes(nodes);
|
| 174 |
+
transformerRef.current!.show();
|
| 175 |
+
transformerRef.current!.forceUpdate();
|
| 176 |
+
const layer = transformerRef.current!.getLayer();
|
| 177 |
+
if (layer) {
|
| 178 |
+
layer.batchDraw();
|
| 179 |
+
}
|
| 180 |
+
console.log('Transformer attached and layer redrawn');
|
| 181 |
+
} else if (attempt < 10) {
|
| 182 |
+
// Retry up to 10 times with increasing delays (up to 500ms)
|
| 183 |
+
const delay = Math.min(50 * (attempt + 1), 100);
|
| 184 |
+
setTimeout(() => attemptAttachment(attempt + 1), delay);
|
| 185 |
+
} else {
|
| 186 |
+
console.error('Failed to attach transformer after 10 attempts. Selected IDs:', selectedIds);
|
| 187 |
+
}
|
| 188 |
} else {
|
| 189 |
+
transformerRef.current!.nodes([]);
|
| 190 |
}
|
| 191 |
+
};
|
|
|
|
|
|
|
| 192 |
|
| 193 |
+
// Start attempting attachment
|
| 194 |
+
attemptAttachment(0);
|
|
|
|
|
|
|
| 195 |
}, [selectedIds, objects]);
|
| 196 |
|
| 197 |
// Handle object selection
|
|
|
|
| 211 |
}
|
| 212 |
};
|
| 213 |
|
| 214 |
+
// Store initial drag position for shift constraint
|
| 215 |
+
const dragStartPosRef = useRef<{ x: number; y: number } | null>(null);
|
| 216 |
+
|
| 217 |
+
// Handle drag start to store initial position
|
| 218 |
+
const handleDragStart = (e: Konva.KonvaEventObject<DragEvent>) => {
|
| 219 |
+
const node = e.target;
|
| 220 |
+
dragStartPosRef.current = { x: node.x(), y: node.y() };
|
| 221 |
+
};
|
| 222 |
+
|
| 223 |
+
// Handle drag move to constrain to horizontal/vertical when Shift is held
|
| 224 |
+
const handleDragMove = (e: Konva.KonvaEventObject<DragEvent>) => {
|
| 225 |
+
const node = e.target;
|
| 226 |
+
const shiftPressed = e.evt.shiftKey;
|
| 227 |
+
|
| 228 |
+
// Shift key constraint (higher priority)
|
| 229 |
+
if (shiftPressed && dragStartPosRef.current) {
|
| 230 |
+
const startPos = dragStartPosRef.current;
|
| 231 |
+
const currentX = node.x();
|
| 232 |
+
const currentY = node.y();
|
| 233 |
+
|
| 234 |
+
// Calculate movement deltas
|
| 235 |
+
const deltaX = Math.abs(currentX - startPos.x);
|
| 236 |
+
const deltaY = Math.abs(currentY - startPos.y);
|
| 237 |
+
|
| 238 |
+
// Constrain to the axis with larger movement
|
| 239 |
+
if (deltaX > deltaY) {
|
| 240 |
+
// Horizontal movement - lock Y
|
| 241 |
+
node.y(startPos.y);
|
| 242 |
+
} else {
|
| 243 |
+
// Vertical movement - lock X
|
| 244 |
+
node.x(startPos.x);
|
| 245 |
+
}
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
// Smart snapping to canvas center lines (only when Shift is NOT pressed)
|
| 249 |
+
if (!shiftPressed) {
|
| 250 |
+
const snapThreshold = 10; // pixels
|
| 251 |
+
const centerX = dimensions.width / 2;
|
| 252 |
+
const centerY = dimensions.height / 2;
|
| 253 |
+
|
| 254 |
+
const nodeWidth = node.width();
|
| 255 |
+
const nodeHeight = node.height();
|
| 256 |
+
const nodeX = node.x();
|
| 257 |
+
const nodeY = node.y();
|
| 258 |
+
|
| 259 |
+
// Calculate object center position (accounting for offsetX/offsetY if present)
|
| 260 |
+
const offsetX = (node as any).attrs.offsetX || 0;
|
| 261 |
+
const offsetY = (node as any).attrs.offsetY || 0;
|
| 262 |
+
const objectCenterX = nodeX + (nodeWidth / 2) - offsetX;
|
| 263 |
+
const objectCenterY = nodeY + (nodeHeight / 2) - offsetY;
|
| 264 |
+
|
| 265 |
+
let snappedX = nodeX;
|
| 266 |
+
let snappedY = nodeY;
|
| 267 |
+
let showVerticalGuide = false;
|
| 268 |
+
let showHorizontalGuide = false;
|
| 269 |
+
|
| 270 |
+
// Check horizontal center snapping
|
| 271 |
+
if (Math.abs(objectCenterX - centerX) < snapThreshold) {
|
| 272 |
+
snappedX = centerX - (nodeWidth / 2) + offsetX;
|
| 273 |
+
showVerticalGuide = true;
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
// Check vertical center snapping
|
| 277 |
+
if (Math.abs(objectCenterY - centerY) < snapThreshold) {
|
| 278 |
+
snappedY = centerY - (nodeHeight / 2) + offsetY;
|
| 279 |
+
showHorizontalGuide = true;
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
// Apply snapping
|
| 283 |
+
if (showVerticalGuide || showHorizontalGuide) {
|
| 284 |
+
node.x(snappedX);
|
| 285 |
+
node.y(snappedY);
|
| 286 |
+
setSnapGuides({ vertical: showVerticalGuide, horizontal: showHorizontalGuide });
|
| 287 |
+
} else {
|
| 288 |
+
setSnapGuides({ vertical: false, horizontal: false });
|
| 289 |
+
}
|
| 290 |
+
}
|
| 291 |
+
};
|
| 292 |
+
|
| 293 |
// Handle drag end to update object position
|
| 294 |
const handleDragEnd = (id: string) => (e: Konva.KonvaEventObject<DragEvent>) => {
|
| 295 |
const node = e.target;
|
| 296 |
+
dragStartPosRef.current = null; // Reset drag start position
|
| 297 |
+
setSnapGuides({ vertical: false, horizontal: false }); // Clear snap guides
|
| 298 |
const updatedObjects = objects.map(obj =>
|
| 299 |
obj.id === id
|
| 300 |
? { ...obj, x: node.x(), y: node.y() }
|
|
|
|
| 309 |
const scaleX = node.scaleX();
|
| 310 |
const scaleY = node.scaleY();
|
| 311 |
|
| 312 |
+
// Check if multiple objects are selected
|
| 313 |
+
const isMultiSelect = selectedIds.length > 1;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 314 |
|
| 315 |
+
if (isMultiSelect) {
|
| 316 |
+
// For multiselect, debounce to avoid multiple rapid updates
|
| 317 |
+
// Clear any pending timeout
|
| 318 |
+
if (transformEndTimeoutRef.current) {
|
| 319 |
+
clearTimeout(transformEndTimeoutRef.current);
|
| 320 |
+
}
|
| 321 |
|
| 322 |
+
// Schedule update after a brief delay
|
| 323 |
+
transformEndTimeoutRef.current = setTimeout(() => {
|
| 324 |
+
// Update ALL selected objects proportionally
|
| 325 |
+
const updatedObjects = objects.map(obj => {
|
| 326 |
+
if (selectedIds.includes(obj.id)) {
|
| 327 |
+
const objNode = shapeRefs.current.get(obj.id);
|
| 328 |
+
if (objNode) {
|
| 329 |
+
const nodeScaleX = objNode.scaleX();
|
| 330 |
+
const nodeScaleY = objNode.scaleY();
|
| 331 |
+
|
| 332 |
+
// Reset the node's scale
|
| 333 |
+
objNode.scaleX(1);
|
| 334 |
+
objNode.scaleY(1);
|
| 335 |
+
|
| 336 |
+
const updated = {
|
| 337 |
+
...obj,
|
| 338 |
+
x: objNode.x(),
|
| 339 |
+
y: objNode.y(),
|
| 340 |
+
width: Math.max(5, objNode.width() * nodeScaleX),
|
| 341 |
+
height: Math.max(5, objNode.height() * nodeScaleY),
|
| 342 |
+
rotation: objNode.rotation()
|
| 343 |
+
};
|
| 344 |
+
|
| 345 |
+
// Handle text objects: scale fontSize
|
| 346 |
+
if (obj.type === 'text') {
|
| 347 |
+
const scaleFactor = Math.min(nodeScaleX, nodeScaleY);
|
| 348 |
+
const newFontSize = Math.max(10, obj.fontSize * scaleFactor);
|
| 349 |
+
return {
|
| 350 |
+
...updated,
|
| 351 |
+
fontSize: newFontSize,
|
| 352 |
+
isFixedSize: true
|
| 353 |
+
};
|
| 354 |
+
}
|
| 355 |
+
return updated;
|
| 356 |
+
}
|
| 357 |
+
}
|
| 358 |
+
return obj;
|
| 359 |
+
});
|
| 360 |
+
onObjectsChange(updatedObjects);
|
| 361 |
+
transformEndTimeoutRef.current = null;
|
| 362 |
+
}, 10);
|
| 363 |
+
} else {
|
| 364 |
+
// Single object transformation (original logic)
|
| 365 |
+
// Reset scale to 1 and update width/height instead
|
| 366 |
+
node.scaleX(1);
|
| 367 |
+
node.scaleY(1);
|
| 368 |
+
|
| 369 |
+
const updatedObjects = objects.map(obj => {
|
| 370 |
+
if (obj.id === id) {
|
| 371 |
+
const updated = {
|
| 372 |
+
...obj,
|
| 373 |
+
x: node.x(),
|
| 374 |
+
y: node.y(),
|
| 375 |
+
width: Math.max(5, node.width() * scaleX),
|
| 376 |
+
height: Math.max(5, node.height() * scaleY),
|
| 377 |
+
rotation: node.rotation()
|
| 378 |
};
|
| 379 |
+
|
| 380 |
+
// Handle text objects: scale fontSize and mark as fixed size
|
| 381 |
+
if (obj.type === 'text') {
|
| 382 |
+
// Use the smaller scale factor to maintain readability
|
| 383 |
+
const scaleFactor = Math.min(scaleX, scaleY);
|
| 384 |
+
const newFontSize = Math.max(10, obj.fontSize * scaleFactor);
|
| 385 |
+
|
| 386 |
+
return {
|
| 387 |
+
...updated,
|
| 388 |
+
fontSize: newFontSize,
|
| 389 |
+
isFixedSize: true
|
| 390 |
+
};
|
| 391 |
+
}
|
| 392 |
+
return updated;
|
| 393 |
}
|
| 394 |
+
return obj;
|
| 395 |
+
});
|
| 396 |
+
onObjectsChange(updatedObjects);
|
| 397 |
+
}
|
|
|
|
| 398 |
};
|
| 399 |
|
| 400 |
// Handle text content changes
|
|
|
|
| 409 |
|
| 410 |
// Handle editing state changes
|
| 411 |
const handleEditingChange = (id: string, isEditing: boolean, clickX?: number, clickY?: number) => {
|
| 412 |
+
// If exiting edit mode (!isEditing), check if text is empty and delete if so
|
| 413 |
+
if (!isEditing) {
|
| 414 |
+
const textObj = objects.find(obj => obj.id === id && obj.type === 'text') as TextObject | undefined;
|
| 415 |
+
if (textObj && textObj.text.trim() === '') {
|
| 416 |
+
// Text is empty - delete the object
|
| 417 |
+
const updatedObjects = objects.filter(obj => obj.id !== id);
|
| 418 |
+
onObjectsChange(updatedObjects);
|
| 419 |
+
return;
|
| 420 |
+
}
|
| 421 |
+
}
|
| 422 |
+
|
| 423 |
const updatedObjects = objects.map(obj =>
|
| 424 |
obj.id === id && obj.type === 'text'
|
| 425 |
? { ...obj, isEditing, isFixedSize: isEditing ? obj.isFixedSize : true }
|
|
|
|
| 507 |
const container = stage.container();
|
| 508 |
const containerRect = container.getBoundingClientRect();
|
| 509 |
|
| 510 |
+
// Account for offsetX and offsetY when positioning textarea
|
| 511 |
+
const offsetX = editingText.offsetX || 0;
|
| 512 |
+
const offsetY = editingText.offsetY || 0;
|
| 513 |
+
|
| 514 |
return {
|
| 515 |
+
top: containerRect.top + editingText.y - offsetY,
|
| 516 |
+
left: containerRect.left + editingText.x - offsetX
|
| 517 |
};
|
| 518 |
};
|
| 519 |
|
|
|
|
| 641 |
|
| 642 |
return (
|
| 643 |
<div
|
| 644 |
+
onMouseEnter={() => setIsCanvasHovered(true)}
|
| 645 |
+
onMouseLeave={() => setIsCanvasHovered(false)}
|
| 646 |
style={{
|
| 647 |
width: dimensions.width,
|
| 648 |
height: dimensions.height,
|
| 649 |
+
backgroundImage: bgImageElement ? `url(${backgroundImage})` : 'none',
|
| 650 |
+
backgroundColor: bgImageElement ? 'transparent' : '#ffffff',
|
| 651 |
+
backgroundSize: 'cover',
|
| 652 |
+
backgroundPosition: 'center',
|
| 653 |
+
border: '1px solid #EBEBEB',
|
| 654 |
+
borderRadius: isCanvasHovered ? '0px' : '10px',
|
| 655 |
+
boxShadow: '0 4px 6px -2px rgba(5, 32, 81, 0.04), 0 12px 16px -4px rgba(5, 32, 81, 0.09)',
|
| 656 |
overflow: 'hidden',
|
| 657 |
+
transition: 'width 0.15s ease-in-out, height 0.15s ease-in-out, border-radius 0.15s ease-in-out'
|
| 658 |
}}
|
| 659 |
>
|
| 660 |
<Stage
|
|
|
|
| 681 |
style={{ cursor: textCreationMode ? 'text' : 'default' }}
|
| 682 |
>
|
| 683 |
<Layer>
|
| 684 |
+
{/* Background image for export */}
|
| 685 |
+
{bgImageElement ? (
|
| 686 |
+
<KonvaImage
|
| 687 |
+
x={0}
|
| 688 |
+
y={0}
|
| 689 |
+
width={dimensions.width}
|
| 690 |
+
height={dimensions.height}
|
| 691 |
+
image={bgImageElement}
|
| 692 |
+
listening={false}
|
| 693 |
+
/>
|
| 694 |
+
) : (
|
| 695 |
+
<Rect
|
| 696 |
+
x={0}
|
| 697 |
+
y={0}
|
| 698 |
+
width={dimensions.width}
|
| 699 |
+
height={dimensions.height}
|
| 700 |
+
fill="#ffffff"
|
| 701 |
+
listening={false}
|
| 702 |
+
/>
|
| 703 |
+
)}
|
| 704 |
{sortedObjects.map((obj) => {
|
| 705 |
return (
|
| 706 |
<CanvasObjectRenderer
|
|
|
|
| 711 |
const shiftKey = e?.evt?.shiftKey || false;
|
| 712 |
handleSelect(obj.id, shiftKey);
|
| 713 |
}}
|
| 714 |
+
onDragStart={handleDragStart}
|
| 715 |
+
onDragMove={handleDragMove}
|
| 716 |
onDragEnd={handleDragEnd(obj.id)}
|
| 717 |
onTransformEnd={handleTransformEnd(obj.id)}
|
| 718 |
onEditingChange={handleEditingChange}
|
| 719 |
+
onMouseEnter={() => setHoveredId(obj.id)}
|
| 720 |
+
onMouseLeave={() => setHoveredId(null)}
|
| 721 |
shapeRef={(node: Konva.Node | null) => {
|
| 722 |
if (node) {
|
| 723 |
shapeRefs.current.set(obj.id, node);
|
|
|
|
| 744 |
/>
|
| 745 |
)}
|
| 746 |
|
| 747 |
+
{/* Snap guide lines */}
|
| 748 |
+
{snapGuides.vertical && (
|
| 749 |
+
<Rect
|
| 750 |
+
x={dimensions.width / 2}
|
| 751 |
+
y={0}
|
| 752 |
+
width={1}
|
| 753 |
+
height={dimensions.height}
|
| 754 |
+
fill="#FF6B6B"
|
| 755 |
+
listening={false}
|
| 756 |
+
opacity={0.8}
|
| 757 |
+
/>
|
| 758 |
+
)}
|
| 759 |
+
{snapGuides.horizontal && (
|
| 760 |
+
<Rect
|
| 761 |
+
x={0}
|
| 762 |
+
y={dimensions.height / 2}
|
| 763 |
+
width={dimensions.width}
|
| 764 |
+
height={1}
|
| 765 |
+
fill="#FF6B6B"
|
| 766 |
+
listening={false}
|
| 767 |
+
opacity={0.8}
|
| 768 |
+
/>
|
| 769 |
+
)}
|
| 770 |
+
|
| 771 |
+
{/* Hover bounding box - simple outline without handles */}
|
| 772 |
+
{hoveredId && !selectedIds.includes(hoveredId) && (() => {
|
| 773 |
+
const hoveredNode = shapeRefs.current.get(hoveredId);
|
| 774 |
+
if (hoveredNode) {
|
| 775 |
+
const box = hoveredNode.getClientRect();
|
| 776 |
+
return (
|
| 777 |
+
<Rect
|
| 778 |
+
x={box.x}
|
| 779 |
+
y={box.y}
|
| 780 |
+
width={box.width}
|
| 781 |
+
height={box.height}
|
| 782 |
+
stroke="#3faee6"
|
| 783 |
+
strokeWidth={1}
|
| 784 |
+
dash={[4, 4]}
|
| 785 |
+
listening={false}
|
| 786 |
+
opacity={0.5}
|
| 787 |
+
/>
|
| 788 |
+
);
|
| 789 |
+
}
|
| 790 |
+
return null;
|
| 791 |
+
})()}
|
| 792 |
+
|
| 793 |
{/* Transformer for selected objects */}
|
| 794 |
<Transformer
|
| 795 |
ref={transformerRef}
|
|
@@ -2,9 +2,10 @@ 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',
|
|
@@ -19,8 +20,7 @@ export default function CanvasContainer({ children }: CanvasContainerProps) {
|
|
| 19 |
style={{
|
| 20 |
display: 'flex',
|
| 21 |
flexDirection: 'column',
|
| 22 |
-
gap: '10px'
|
| 23 |
-
transition: 'all 0.15s ease-in-out'
|
| 24 |
}}
|
| 25 |
>
|
| 26 |
{children}
|
|
|
|
| 2 |
|
| 3 |
interface CanvasContainerProps {
|
| 4 |
children: ReactNode;
|
| 5 |
+
scale?: number;
|
| 6 |
}
|
| 7 |
|
| 8 |
+
export default function CanvasContainer({ children, scale = 1 }: CanvasContainerProps) {
|
| 9 |
return (
|
| 10 |
<div style={{
|
| 11 |
display: 'flex',
|
|
|
|
| 20 |
style={{
|
| 21 |
display: 'flex',
|
| 22 |
flexDirection: 'column',
|
| 23 |
+
gap: '10px'
|
|
|
|
| 24 |
}}
|
| 25 |
>
|
| 26 |
{children}
|
|
@@ -8,9 +8,13 @@ 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 |
|
|
@@ -18,9 +22,13 @@ export default function CanvasObjectRenderer({
|
|
| 18 |
object,
|
| 19 |
isSelected,
|
| 20 |
onSelect,
|
|
|
|
|
|
|
| 21 |
onDragEnd,
|
| 22 |
onTransformEnd,
|
| 23 |
onEditingChange,
|
|
|
|
|
|
|
| 24 |
shapeRef
|
| 25 |
}: CanvasObjectProps) {
|
| 26 |
switch (object.type) {
|
|
@@ -46,8 +54,12 @@ export default function CanvasObjectRenderer({
|
|
| 46 |
draggable
|
| 47 |
onClick={(e) => onSelect(e)}
|
| 48 |
onTap={(e) => onSelect(e)}
|
|
|
|
|
|
|
| 49 |
onDragEnd={onDragEnd}
|
| 50 |
onTransformEnd={onTransformEnd}
|
|
|
|
|
|
|
| 51 |
/>
|
| 52 |
);
|
| 53 |
|
|
@@ -57,8 +69,12 @@ export default function CanvasObjectRenderer({
|
|
| 57 |
<ImageRenderer
|
| 58 |
object={object}
|
| 59 |
onSelect={onSelect}
|
|
|
|
|
|
|
| 60 |
onDragEnd={onDragEnd}
|
| 61 |
onTransformEnd={onTransformEnd}
|
|
|
|
|
|
|
| 62 |
shapeRef={shapeRef}
|
| 63 |
/>
|
| 64 |
);
|
|
@@ -69,9 +85,13 @@ export default function CanvasObjectRenderer({
|
|
| 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 |
);
|
|
@@ -81,8 +101,12 @@ export default function CanvasObjectRenderer({
|
|
| 81 |
<LogoPlaceholderRenderer
|
| 82 |
object={object}
|
| 83 |
onSelect={onSelect}
|
|
|
|
|
|
|
| 84 |
onDragEnd={onDragEnd}
|
| 85 |
onTransformEnd={onTransformEnd}
|
|
|
|
|
|
|
| 86 |
shapeRef={shapeRef}
|
| 87 |
/>
|
| 88 |
);
|
|
@@ -96,12 +120,17 @@ export default function CanvasObjectRenderer({
|
|
| 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();
|
|
@@ -111,6 +140,16 @@ function ImageRenderer({
|
|
| 111 |
img.onload = () => {
|
| 112 |
setImage(img);
|
| 113 |
imageRef.current = img;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
};
|
| 115 |
|
| 116 |
return () => {
|
|
@@ -124,6 +163,8 @@ function ImageRenderer({
|
|
| 124 |
<KonvaImage
|
| 125 |
id={object.id}
|
| 126 |
ref={(node) => {
|
|
|
|
|
|
|
| 127 |
if (typeof shapeRef === 'function') {
|
| 128 |
shapeRef(node);
|
| 129 |
} else if (shapeRef) {
|
|
@@ -139,8 +180,12 @@ function ImageRenderer({
|
|
| 139 |
draggable
|
| 140 |
onClick={(e) => onSelect(e)}
|
| 141 |
onTap={(e) => onSelect(e)}
|
|
|
|
|
|
|
| 142 |
onDragEnd={onDragEnd}
|
| 143 |
onTransformEnd={onTransformEnd}
|
|
|
|
|
|
|
| 144 |
/>
|
| 145 |
);
|
| 146 |
}
|
|
@@ -149,10 +194,14 @@ function ImageRenderer({
|
|
| 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(() => {
|
|
@@ -187,8 +236,12 @@ function LogoPlaceholderRenderer({
|
|
| 187 |
draggable
|
| 188 |
onClick={(e) => onSelect(e)}
|
| 189 |
onTap={(e) => onSelect(e)}
|
|
|
|
|
|
|
| 190 |
onDragEnd={onDragEnd}
|
| 191 |
onTransformEnd={onTransformEnd}
|
|
|
|
|
|
|
| 192 |
/>
|
| 193 |
);
|
| 194 |
}
|
|
|
|
| 8 |
object: CanvasObject;
|
| 9 |
isSelected: boolean;
|
| 10 |
onSelect: (e?: Konva.KonvaEventObject<MouseEvent>) => void;
|
| 11 |
+
onDragStart?: (e: Konva.KonvaEventObject<DragEvent>) => void;
|
| 12 |
+
onDragMove?: (e: Konva.KonvaEventObject<DragEvent>) => void;
|
| 13 |
onDragEnd?: (e: Konva.KonvaEventObject<DragEvent>) => void;
|
| 14 |
onTransformEnd?: (e: Konva.KonvaEventObject<Event>) => void;
|
| 15 |
onEditingChange?: (id: string, isEditing: boolean, clickX?: number, clickY?: number) => void;
|
| 16 |
+
onMouseEnter?: () => void;
|
| 17 |
+
onMouseLeave?: () => void;
|
| 18 |
shapeRef?: ((node: Konva.Node | null) => void) | React.RefObject<Konva.Node>;
|
| 19 |
}
|
| 20 |
|
|
|
|
| 22 |
object,
|
| 23 |
isSelected,
|
| 24 |
onSelect,
|
| 25 |
+
onDragStart,
|
| 26 |
+
onDragMove,
|
| 27 |
onDragEnd,
|
| 28 |
onTransformEnd,
|
| 29 |
onEditingChange,
|
| 30 |
+
onMouseEnter,
|
| 31 |
+
onMouseLeave,
|
| 32 |
shapeRef
|
| 33 |
}: CanvasObjectProps) {
|
| 34 |
switch (object.type) {
|
|
|
|
| 54 |
draggable
|
| 55 |
onClick={(e) => onSelect(e)}
|
| 56 |
onTap={(e) => onSelect(e)}
|
| 57 |
+
onDragStart={onDragStart}
|
| 58 |
+
onDragMove={onDragMove}
|
| 59 |
onDragEnd={onDragEnd}
|
| 60 |
onTransformEnd={onTransformEnd}
|
| 61 |
+
onMouseEnter={onMouseEnter}
|
| 62 |
+
onMouseLeave={onMouseLeave}
|
| 63 |
/>
|
| 64 |
);
|
| 65 |
|
|
|
|
| 69 |
<ImageRenderer
|
| 70 |
object={object}
|
| 71 |
onSelect={onSelect}
|
| 72 |
+
onDragStart={onDragStart}
|
| 73 |
+
onDragMove={onDragMove}
|
| 74 |
onDragEnd={onDragEnd}
|
| 75 |
onTransformEnd={onTransformEnd}
|
| 76 |
+
onMouseEnter={onMouseEnter}
|
| 77 |
+
onMouseLeave={onMouseLeave}
|
| 78 |
shapeRef={shapeRef}
|
| 79 |
/>
|
| 80 |
);
|
|
|
|
| 85 |
object={object}
|
| 86 |
isSelected={isSelected}
|
| 87 |
onSelect={onSelect}
|
| 88 |
+
onDragStart={onDragStart}
|
| 89 |
+
onDragMove={onDragMove}
|
| 90 |
onDragEnd={onDragEnd}
|
| 91 |
onTransformEnd={onTransformEnd}
|
| 92 |
onEditingChange={onEditingChange || (() => {})}
|
| 93 |
+
onMouseEnter={onMouseEnter}
|
| 94 |
+
onMouseLeave={onMouseLeave}
|
| 95 |
shapeRef={shapeRef as React.RefObject<Konva.Text>}
|
| 96 |
/>
|
| 97 |
);
|
|
|
|
| 101 |
<LogoPlaceholderRenderer
|
| 102 |
object={object}
|
| 103 |
onSelect={onSelect}
|
| 104 |
+
onDragStart={onDragStart}
|
| 105 |
+
onDragMove={onDragMove}
|
| 106 |
onDragEnd={onDragEnd}
|
| 107 |
onTransformEnd={onTransformEnd}
|
| 108 |
+
onMouseEnter={onMouseEnter}
|
| 109 |
+
onMouseLeave={onMouseLeave}
|
| 110 |
shapeRef={shapeRef}
|
| 111 |
/>
|
| 112 |
);
|
|
|
|
| 120 |
function ImageRenderer({
|
| 121 |
object,
|
| 122 |
onSelect,
|
| 123 |
+
onDragStart,
|
| 124 |
+
onDragMove,
|
| 125 |
onDragEnd,
|
| 126 |
onTransformEnd,
|
| 127 |
+
onMouseEnter,
|
| 128 |
+
onMouseLeave,
|
| 129 |
shapeRef
|
| 130 |
+
}: Omit<CanvasObjectProps, 'isSelected' | 'onEditingChange'>) {
|
| 131 |
const [image, setImage] = useState<HTMLImageElement | null>(null);
|
| 132 |
const imageRef = useRef<HTMLImageElement | null>(null);
|
| 133 |
+
const nodeRef = useRef<Konva.Image | null>(null);
|
| 134 |
|
| 135 |
useEffect(() => {
|
| 136 |
const img = new window.Image();
|
|
|
|
| 140 |
img.onload = () => {
|
| 141 |
setImage(img);
|
| 142 |
imageRef.current = img;
|
| 143 |
+
// Force a small delay to ensure ref is registered and transformer can update
|
| 144 |
+
setTimeout(() => {
|
| 145 |
+
if (nodeRef.current) {
|
| 146 |
+
// Trigger a layer redraw to ensure transformer picks up the node
|
| 147 |
+
const layer = nodeRef.current.getLayer();
|
| 148 |
+
if (layer) {
|
| 149 |
+
layer.batchDraw();
|
| 150 |
+
}
|
| 151 |
+
}
|
| 152 |
+
}, 10);
|
| 153 |
};
|
| 154 |
|
| 155 |
return () => {
|
|
|
|
| 163 |
<KonvaImage
|
| 164 |
id={object.id}
|
| 165 |
ref={(node) => {
|
| 166 |
+
nodeRef.current = node;
|
| 167 |
+
console.log('ImageRenderer ref callback:', object.id, 'Node:', node);
|
| 168 |
if (typeof shapeRef === 'function') {
|
| 169 |
shapeRef(node);
|
| 170 |
} else if (shapeRef) {
|
|
|
|
| 180 |
draggable
|
| 181 |
onClick={(e) => onSelect(e)}
|
| 182 |
onTap={(e) => onSelect(e)}
|
| 183 |
+
onDragStart={onDragStart}
|
| 184 |
+
onDragMove={onDragMove}
|
| 185 |
onDragEnd={onDragEnd}
|
| 186 |
onTransformEnd={onTransformEnd}
|
| 187 |
+
onMouseEnter={onMouseEnter}
|
| 188 |
+
onMouseLeave={onMouseLeave}
|
| 189 |
/>
|
| 190 |
);
|
| 191 |
}
|
|
|
|
| 194 |
function LogoPlaceholderRenderer({
|
| 195 |
object,
|
| 196 |
onSelect,
|
| 197 |
+
onDragStart,
|
| 198 |
+
onDragMove,
|
| 199 |
onDragEnd,
|
| 200 |
onTransformEnd,
|
| 201 |
+
onMouseEnter,
|
| 202 |
+
onMouseLeave,
|
| 203 |
shapeRef
|
| 204 |
+
}: Omit<CanvasObjectProps, 'isSelected' | 'onEditingChange'>) {
|
| 205 |
const rectRef = useRef<Konva.Rect>(null);
|
| 206 |
|
| 207 |
useEffect(() => {
|
|
|
|
| 236 |
draggable
|
| 237 |
onClick={(e) => onSelect(e)}
|
| 238 |
onTap={(e) => onSelect(e)}
|
| 239 |
+
onDragStart={onDragStart}
|
| 240 |
+
onDragMove={onDragMove}
|
| 241 |
onDragEnd={onDragEnd}
|
| 242 |
onTransformEnd={onTransformEnd}
|
| 243 |
+
onMouseEnter={onMouseEnter}
|
| 244 |
+
onMouseLeave={onMouseLeave}
|
| 245 |
/>
|
| 246 |
);
|
| 247 |
}
|
|
@@ -7,9 +7,13 @@ 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 |
|
|
@@ -17,9 +21,13 @@ 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);
|
|
@@ -127,8 +135,12 @@ export default function TextEditable({
|
|
| 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 |
/>
|
|
|
|
| 7 |
object: TextObject;
|
| 8 |
isSelected: boolean;
|
| 9 |
onSelect: (e?: Konva.KonvaEventObject<MouseEvent>) => void;
|
| 10 |
+
onDragStart?: (e: Konva.KonvaEventObject<DragEvent>) => void;
|
| 11 |
+
onDragMove?: (e: Konva.KonvaEventObject<DragEvent>) => void;
|
| 12 |
onDragEnd?: (e: Konva.KonvaEventObject<DragEvent>) => void;
|
| 13 |
onTransformEnd?: (e: Konva.KonvaEventObject<Event>) => void;
|
| 14 |
onEditingChange: (id: string, isEditing: boolean, clickX?: number, clickY?: number) => void;
|
| 15 |
+
onMouseEnter?: () => void;
|
| 16 |
+
onMouseLeave?: () => void;
|
| 17 |
shapeRef?: ((node: Konva.Text | null) => void) | React.RefObject<Konva.Text>;
|
| 18 |
}
|
| 19 |
|
|
|
|
| 21 |
object,
|
| 22 |
isSelected,
|
| 23 |
onSelect,
|
| 24 |
+
onDragStart,
|
| 25 |
+
onDragMove,
|
| 26 |
onDragEnd,
|
| 27 |
onTransformEnd,
|
| 28 |
onEditingChange,
|
| 29 |
+
onMouseEnter,
|
| 30 |
+
onMouseLeave,
|
| 31 |
shapeRef
|
| 32 |
}: TextEditableProps) {
|
| 33 |
const textNodeRef = useRef<Konva.Text | null>(null);
|
|
|
|
| 135 |
onTap={(e) => onSelect(e)}
|
| 136 |
onDblClick={handleDoubleClick}
|
| 137 |
onDblTap={handleDoubleClick}
|
| 138 |
+
onDragStart={onDragStart}
|
| 139 |
+
onDragMove={onDragMove}
|
| 140 |
onDragEnd={onDragEnd}
|
| 141 |
onTransformEnd={onTransformEnd}
|
| 142 |
+
onMouseEnter={onMouseEnter}
|
| 143 |
+
onMouseLeave={onMouseLeave}
|
| 144 |
opacity={object.isEditing ? 0 : 1}
|
| 145 |
listening={!object.isEditing}
|
| 146 |
/>
|
|
@@ -1,6 +1,7 @@
|
|
| 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 {
|
|
@@ -28,10 +29,34 @@ export default function BgColorSelector({ bgColor, onChange }: BgColorSelectorPr
|
|
| 28 |
alignItems: 'center',
|
| 29 |
gap: '5px',
|
| 30 |
height: '40px',
|
| 31 |
-
padding: '
|
| 32 |
-
background: '#
|
|
|
|
| 33 |
borderRadius: '99px'
|
| 34 |
}}>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
{/* Light option */}
|
| 36 |
<button
|
| 37 |
onClick={() => onChange('light')}
|
|
@@ -42,17 +67,17 @@ export default function BgColorSelector({ bgColor, onChange }: BgColorSelectorPr
|
|
| 42 |
alignItems: 'center',
|
| 43 |
justifyContent: 'center',
|
| 44 |
width: '38px',
|
| 45 |
-
height: '
|
| 46 |
padding: '10px',
|
| 47 |
-
background: bgColor === 'light' ? '#
|
| 48 |
border: 'none',
|
| 49 |
borderRadius: '99px',
|
| 50 |
cursor: 'pointer',
|
| 51 |
transition: 'background 0.15s ease-in-out'
|
| 52 |
}}
|
| 53 |
-
title="Light background"
|
| 54 |
>
|
| 55 |
-
<
|
| 56 |
</button>
|
| 57 |
|
| 58 |
{/* Dark option */}
|
|
@@ -65,9 +90,9 @@ export default function BgColorSelector({ bgColor, onChange }: BgColorSelectorPr
|
|
| 65 |
alignItems: 'center',
|
| 66 |
justifyContent: 'center',
|
| 67 |
width: '38px',
|
| 68 |
-
height: '
|
| 69 |
padding: '10px',
|
| 70 |
-
background: bgColor === 'dark' ? '#
|
| 71 |
border: 'none',
|
| 72 |
borderRadius: '99px',
|
| 73 |
cursor: 'pointer',
|
|
|
|
| 1 |
import { useState } from 'react';
|
| 2 |
import { CanvasBgColor } from '../../types/canvas.types';
|
| 3 |
import IconBgLight from '../Icons/IconBgLight';
|
| 4 |
+
import IconBgGradient from '../Icons/IconBgGradient';
|
| 5 |
import IconBgDark from '../Icons/IconBgDark';
|
| 6 |
|
| 7 |
interface BgColorSelectorProps {
|
|
|
|
| 29 |
alignItems: 'center',
|
| 30 |
gap: '5px',
|
| 31 |
height: '40px',
|
| 32 |
+
padding: '4px',
|
| 33 |
+
background: '#EDF0F2',
|
| 34 |
+
border: '1px solid #F8F9FA',
|
| 35 |
borderRadius: '99px'
|
| 36 |
}}>
|
| 37 |
+
{/* Serious Light option */}
|
| 38 |
+
<button
|
| 39 |
+
onClick={() => onChange('seriousLight')}
|
| 40 |
+
onMouseEnter={() => setHoveredColor('seriousLight')}
|
| 41 |
+
onMouseLeave={() => setHoveredColor(null)}
|
| 42 |
+
style={{
|
| 43 |
+
display: 'flex',
|
| 44 |
+
alignItems: 'center',
|
| 45 |
+
justifyContent: 'center',
|
| 46 |
+
width: '38px',
|
| 47 |
+
height: '32px',
|
| 48 |
+
padding: '10px',
|
| 49 |
+
background: bgColor === 'seriousLight' ? '#DEE2E7' : (hoveredColor === 'seriousLight' ? '#f0f2f4' : 'transparent'),
|
| 50 |
+
border: 'none',
|
| 51 |
+
borderRadius: '99px',
|
| 52 |
+
cursor: 'pointer',
|
| 53 |
+
transition: 'background 0.15s ease-in-out'
|
| 54 |
+
}}
|
| 55 |
+
title="Serious Light background"
|
| 56 |
+
>
|
| 57 |
+
<IconBgLight />
|
| 58 |
+
</button>
|
| 59 |
+
|
| 60 |
{/* Light option */}
|
| 61 |
<button
|
| 62 |
onClick={() => onChange('light')}
|
|
|
|
| 67 |
alignItems: 'center',
|
| 68 |
justifyContent: 'center',
|
| 69 |
width: '38px',
|
| 70 |
+
height: '32px',
|
| 71 |
padding: '10px',
|
| 72 |
+
background: bgColor === 'light' ? '#DEE2E7' : (hoveredColor === 'light' ? '#f0f2f4' : 'transparent'),
|
| 73 |
border: 'none',
|
| 74 |
borderRadius: '99px',
|
| 75 |
cursor: 'pointer',
|
| 76 |
transition: 'background 0.15s ease-in-out'
|
| 77 |
}}
|
| 78 |
+
title="Light background with gradients"
|
| 79 |
>
|
| 80 |
+
<IconBgGradient />
|
| 81 |
</button>
|
| 82 |
|
| 83 |
{/* Dark option */}
|
|
|
|
| 90 |
alignItems: 'center',
|
| 91 |
justifyContent: 'center',
|
| 92 |
width: '38px',
|
| 93 |
+
height: '32px',
|
| 94 |
padding: '10px',
|
| 95 |
+
background: bgColor === 'dark' ? '#DEE2E7' : (hoveredColor === 'dark' ? '#f0f2f4' : 'transparent'),
|
| 96 |
border: 'none',
|
| 97 |
borderRadius: '99px',
|
| 98 |
cursor: 'pointer',
|
|
@@ -36,11 +36,11 @@ export default function CanvasSizeSelector({ canvasSize, onChange }: CanvasSizeS
|
|
| 36 |
alignItems: 'center',
|
| 37 |
justifyContent: isSelected ? 'flex-start' : 'center',
|
| 38 |
gap: '5px',
|
| 39 |
-
height: '
|
| 40 |
minWidth: '38px',
|
| 41 |
paddingLeft: isSelected ? '10px' : '9px',
|
| 42 |
paddingRight: isSelected ? '10px' : '9px',
|
| 43 |
-
background: isSelected ? '#
|
| 44 |
border: 'none',
|
| 45 |
borderRadius: '99px',
|
| 46 |
cursor: 'pointer',
|
|
@@ -57,7 +57,7 @@ export default function CanvasSizeSelector({ canvasSize, onChange }: CanvasSizeS
|
|
| 57 |
fontWeight: 'normal',
|
| 58 |
fontFamily: 'Inter, sans-serif',
|
| 59 |
whiteSpace: 'nowrap',
|
| 60 |
-
opacity: isSelected ? 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',
|
|
@@ -87,8 +87,9 @@ export default function CanvasSizeSelector({ canvasSize, onChange }: CanvasSizeS
|
|
| 87 |
alignItems: 'center',
|
| 88 |
gap: '5px',
|
| 89 |
height: '40px',
|
| 90 |
-
padding: '
|
| 91 |
-
background: '#
|
|
|
|
| 92 |
borderRadius: '99px'
|
| 93 |
}}>
|
| 94 |
{renderSizeButton('1200x675', IconXSize, '1200×675 (Default)')}
|
|
|
|
| 36 |
alignItems: 'center',
|
| 37 |
justifyContent: isSelected ? 'flex-start' : 'center',
|
| 38 |
gap: '5px',
|
| 39 |
+
height: '32px',
|
| 40 |
minWidth: '38px',
|
| 41 |
paddingLeft: isSelected ? '10px' : '9px',
|
| 42 |
paddingRight: isSelected ? '10px' : '9px',
|
| 43 |
+
background: isSelected ? '#DEE2E7' : (isHovered ? '#f0f2f4' : 'transparent'),
|
| 44 |
border: 'none',
|
| 45 |
borderRadius: '99px',
|
| 46 |
cursor: 'pointer',
|
|
|
|
| 57 |
fontWeight: 'normal',
|
| 58 |
fontFamily: 'Inter, sans-serif',
|
| 59 |
whiteSpace: 'nowrap',
|
| 60 |
+
opacity: isSelected ? 0.6 : 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',
|
|
|
|
| 87 |
alignItems: 'center',
|
| 88 |
gap: '5px',
|
| 89 |
height: '40px',
|
| 90 |
+
padding: '4px',
|
| 91 |
+
background: '#EDF0F2',
|
| 92 |
+
border: '1px solid #F8F9FA',
|
| 93 |
borderRadius: '99px'
|
| 94 |
}}>
|
| 95 |
{renderSizeButton('1200x675', IconXSize, '1200×675 (Default)')}
|
|
@@ -1,38 +1,154 @@
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
const handleExport = () => {
|
|
|
|
| 12 |
onExport(filename);
|
| 13 |
};
|
| 14 |
|
| 15 |
return (
|
| 16 |
-
<
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
>
|
| 20 |
-
{/* Download Icon */}
|
| 21 |
-
<
|
| 22 |
-
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
{/* Filename Container */}
|
| 26 |
-
<div
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
<input
|
| 28 |
type="text"
|
| 29 |
value={filename}
|
| 30 |
onChange={(e) => setFilename(e.target.value)}
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
onClick={(e) => e.stopPropagation()}
|
| 33 |
/>
|
| 34 |
-
<span
|
|
|
|
|
|
|
| 35 |
</div>
|
| 36 |
-
</
|
| 37 |
);
|
| 38 |
}
|
|
|
|
| 1 |
+
import { Download, Loader2 } from 'lucide-react';
|
| 2 |
+
import { useState, useRef, useEffect } from 'react';
|
| 3 |
+
import { LayoutType, CanvasSize } from '../../types/canvas.types';
|
| 4 |
|
| 5 |
interface ExportButtonProps {
|
| 6 |
onExport: (filename: string) => void;
|
| 7 |
+
isExporting?: boolean;
|
| 8 |
+
currentLayout: LayoutType | null;
|
| 9 |
+
canvasSize: CanvasSize;
|
| 10 |
}
|
| 11 |
|
| 12 |
+
export default function ExportButton({ onExport, isExporting = false, currentLayout, canvasSize }: ExportButtonProps) {
|
| 13 |
+
// Map canvas size to friendly name
|
| 14 |
+
const getCanvasSizeName = (size: CanvasSize): string => {
|
| 15 |
+
switch (size) {
|
| 16 |
+
case '1200x675':
|
| 17 |
+
return 'Twitter';
|
| 18 |
+
case 'linkedin':
|
| 19 |
+
return 'LinkedIn';
|
| 20 |
+
case 'hf':
|
| 21 |
+
return 'HF';
|
| 22 |
+
default:
|
| 23 |
+
return 'Twitter';
|
| 24 |
+
}
|
| 25 |
+
};
|
| 26 |
+
|
| 27 |
+
// Generate smart default filename
|
| 28 |
+
const generateDefaultFilename = () => {
|
| 29 |
+
const sizeName = getCanvasSizeName(canvasSize);
|
| 30 |
+
if (currentLayout) {
|
| 31 |
+
return `${currentLayout}_${sizeName}`;
|
| 32 |
+
}
|
| 33 |
+
return `thumbnail_${sizeName}`;
|
| 34 |
+
};
|
| 35 |
+
|
| 36 |
+
const [filename, setFilename] = useState(generateDefaultFilename());
|
| 37 |
+
const [isFocused, setIsFocused] = useState(false);
|
| 38 |
+
const [inputWidth, setInputWidth] = useState(0);
|
| 39 |
+
const [isHoveringButton, setIsHoveringButton] = useState(false);
|
| 40 |
+
const spanRef = useRef<HTMLSpanElement>(null);
|
| 41 |
+
|
| 42 |
+
// Update filename when layout or canvas size changes
|
| 43 |
+
useEffect(() => {
|
| 44 |
+
setFilename(generateDefaultFilename());
|
| 45 |
+
}, [currentLayout, canvasSize]);
|
| 46 |
+
|
| 47 |
+
// Calculate input width based on text content
|
| 48 |
+
useEffect(() => {
|
| 49 |
+
if (spanRef.current) {
|
| 50 |
+
setInputWidth(spanRef.current.offsetWidth);
|
| 51 |
+
}
|
| 52 |
+
}, [filename]);
|
| 53 |
|
| 54 |
const handleExport = () => {
|
| 55 |
+
if (isExporting) return;
|
| 56 |
onExport(filename);
|
| 57 |
};
|
| 58 |
|
| 59 |
return (
|
| 60 |
+
<div
|
| 61 |
+
style={{
|
| 62 |
+
position: 'fixed',
|
| 63 |
+
top: '10px',
|
| 64 |
+
right: '22px',
|
| 65 |
+
zIndex: 50,
|
| 66 |
+
backgroundColor: '#262933',
|
| 67 |
+
borderRadius: '10px',
|
| 68 |
+
padding: '5px',
|
| 69 |
+
display: 'inline-flex',
|
| 70 |
+
alignItems: 'stretch',
|
| 71 |
+
gap: '5px',
|
| 72 |
+
boxShadow: '0px 0px 0px 0px rgba(0,0,0,0), 0px 0px 0px 0px rgba(0,0,0,0.03), 0px 0px 6.417px 0px rgba(0,0,0,0.09), 0px 0px 4.583px 0px rgba(0,0,0,0.15), 0px 0px 2.75px 0px rgba(0,0,0,0.17), 0px 16.847px 21.059px -4.212px rgba(14,13,13,0.1), 0px 8.423px 8.423px -4.212px rgba(0,0,0,0.04)',
|
| 73 |
+
opacity: isExporting ? 0.5 : 1,
|
| 74 |
+
transition: 'all 0.2s ease-in-out',
|
| 75 |
+
width: 'fit-content'
|
| 76 |
+
}}
|
| 77 |
>
|
| 78 |
+
{/* Download/Loading Icon with Blue Background - Clickable */}
|
| 79 |
+
<button
|
| 80 |
+
onClick={handleExport}
|
| 81 |
+
disabled={isExporting}
|
| 82 |
+
onMouseEnter={() => setIsHoveringButton(true)}
|
| 83 |
+
onMouseLeave={() => setIsHoveringButton(false)}
|
| 84 |
+
style={{
|
| 85 |
+
display: 'flex',
|
| 86 |
+
alignItems: 'center',
|
| 87 |
+
justifyContent: 'center',
|
| 88 |
+
backgroundColor: isHoveringButton ? '#0d6ecc' : '#1888ff',
|
| 89 |
+
borderRadius: '5px',
|
| 90 |
+
width: '32px',
|
| 91 |
+
aspectRatio: '1',
|
| 92 |
+
flexShrink: 0,
|
| 93 |
+
border: 'none',
|
| 94 |
+
cursor: isExporting ? 'not-allowed' : 'pointer',
|
| 95 |
+
padding: 0
|
| 96 |
+
}}
|
| 97 |
+
>
|
| 98 |
+
{isExporting ? (
|
| 99 |
+
<Loader2 size={16} color="white" style={{ animation: 'spin 1s linear infinite' }} />
|
| 100 |
+
) : (
|
| 101 |
+
<Download size={16} color="white" />
|
| 102 |
+
)}
|
| 103 |
+
</button>
|
| 104 |
|
| 105 |
{/* Filename Container */}
|
| 106 |
+
<div style={{ display: 'flex', alignItems: 'center', gap: '2px', paddingRight: '5px', minHeight: '32px' }}>
|
| 107 |
+
{/* Hidden span to measure text width */}
|
| 108 |
+
<span
|
| 109 |
+
ref={spanRef}
|
| 110 |
+
style={{
|
| 111 |
+
position: 'absolute',
|
| 112 |
+
visibility: 'hidden',
|
| 113 |
+
whiteSpace: 'pre',
|
| 114 |
+
fontSize: '16px',
|
| 115 |
+
fontFamily: 'Inter, sans-serif',
|
| 116 |
+
fontWeight: 'normal',
|
| 117 |
+
padding: '5px'
|
| 118 |
+
}}
|
| 119 |
+
>
|
| 120 |
+
{filename || 'thumbnail_name'}
|
| 121 |
+
</span>
|
| 122 |
+
|
| 123 |
<input
|
| 124 |
type="text"
|
| 125 |
value={filename}
|
| 126 |
onChange={(e) => setFilename(e.target.value)}
|
| 127 |
+
onFocus={() => setIsFocused(true)}
|
| 128 |
+
onBlur={() => setIsFocused(false)}
|
| 129 |
+
disabled={isExporting}
|
| 130 |
+
placeholder="thumbnail_name"
|
| 131 |
+
style={{
|
| 132 |
+
backgroundColor: 'rgba(255, 255, 255, 0.05)',
|
| 133 |
+
color: 'white',
|
| 134 |
+
fontSize: '16px',
|
| 135 |
+
fontFamily: 'Inter, sans-serif',
|
| 136 |
+
fontWeight: 'normal',
|
| 137 |
+
outline: 'none',
|
| 138 |
+
border: 'none',
|
| 139 |
+
padding: '5px',
|
| 140 |
+
borderRadius: '4px',
|
| 141 |
+
width: `${inputWidth}px`,
|
| 142 |
+
cursor: isExporting ? 'not-allowed' : 'text',
|
| 143 |
+
opacity: isFocused ? 1 : 0.5,
|
| 144 |
+
transition: 'opacity 0.2s'
|
| 145 |
+
}}
|
| 146 |
onClick={(e) => e.stopPropagation()}
|
| 147 |
/>
|
| 148 |
+
<span style={{ color: 'white', fontSize: '16px', fontFamily: 'Inter, sans-serif', fontWeight: 'normal' }}>
|
| 149 |
+
.png
|
| 150 |
+
</span>
|
| 151 |
</div>
|
| 152 |
+
</div>
|
| 153 |
);
|
| 154 |
}
|
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export default function IconBgGradient() {
|
| 2 |
+
return (
|
| 3 |
+
<div
|
| 4 |
+
style={{
|
| 5 |
+
width: '18px',
|
| 6 |
+
height: '18px',
|
| 7 |
+
borderRadius: '999px',
|
| 8 |
+
border: '1px solid #e5e9ed',
|
| 9 |
+
background: 'linear-gradient(135deg, rgba(232, 199, 255, 0.8) 0%, rgba(173, 216, 255, 0.8) 100%)',
|
| 10 |
+
overflow: 'hidden'
|
| 11 |
+
}}
|
| 12 |
+
/>
|
| 13 |
+
);
|
| 14 |
+
}
|
|
@@ -9,7 +9,7 @@ export default function LayoutSelector({ onSelectLayout }: LayoutSelectorProps)
|
|
| 9 |
const layouts = getAllLayouts();
|
| 10 |
|
| 11 |
return (
|
| 12 |
-
<div className="layout-selector absolute left-[calc(100%+4px)] top-[5px] bg-[#f8f9fa] rounded-[10px] p-[5px]
|
| 13 |
{/* 2x2 Grid */}
|
| 14 |
<div className="flex flex-col gap-0">
|
| 15 |
{/* First Row */}
|
|
@@ -20,8 +20,8 @@ export default function LayoutSelector({ onSelectLayout }: LayoutSelectorProps)
|
|
| 20 |
onClick={() => onSelectLayout(layout.id)}
|
| 21 |
className="flex flex-col items-center gap-[5px] p-[10px] rounded-[5px] hover:bg-[#e9ecef] transition-colors flex-1"
|
| 22 |
>
|
| 23 |
-
<div className="w-[96.5px] h-[47.291px]
|
| 24 |
-
<img src={layout.thumbnail} alt={layout.name} className="w-full h-full object-
|
| 25 |
</div>
|
| 26 |
<p className="text-[14px] font-normal text-[#545865] text-center">
|
| 27 |
{layout.name}
|
|
@@ -37,8 +37,8 @@ export default function LayoutSelector({ onSelectLayout }: LayoutSelectorProps)
|
|
| 37 |
onClick={() => onSelectLayout(layout.id)}
|
| 38 |
className="flex flex-col items-center gap-[5px] p-[10px] rounded-[5px] hover:bg-[#e9ecef] transition-colors flex-1"
|
| 39 |
>
|
| 40 |
-
<div className="w-[96.5px] h-[47.291px]
|
| 41 |
-
<img src={layout.thumbnail} alt={layout.name} className="w-full h-full object-
|
| 42 |
</div>
|
| 43 |
<p className="text-[14px] font-normal text-[#545865] text-center">
|
| 44 |
{layout.name}
|
|
|
|
| 9 |
const layouts = getAllLayouts();
|
| 10 |
|
| 11 |
return (
|
| 12 |
+
<div className="layout-selector absolute left-[calc(100%+4px)] top-[5px] bg-[#f8f9fa] border border-[#3faee6] rounded-[10px] p-[5px] shadow-lg inline-block">
|
| 13 |
{/* 2x2 Grid */}
|
| 14 |
<div className="flex flex-col gap-0">
|
| 15 |
{/* First Row */}
|
|
|
|
| 20 |
onClick={() => onSelectLayout(layout.id)}
|
| 21 |
className="flex flex-col items-center gap-[5px] p-[10px] rounded-[5px] hover:bg-[#e9ecef] transition-colors flex-1"
|
| 22 |
>
|
| 23 |
+
<div className="w-[96.5px] h-[47.291px] rounded-[5px] flex items-center justify-center overflow-hidden">
|
| 24 |
+
<img src={layout.thumbnail} alt={layout.name} className="w-full h-full object-cover" />
|
| 25 |
</div>
|
| 26 |
<p className="text-[14px] font-normal text-[#545865] text-center">
|
| 27 |
{layout.name}
|
|
|
|
| 37 |
onClick={() => onSelectLayout(layout.id)}
|
| 38 |
className="flex flex-col items-center gap-[5px] p-[10px] rounded-[5px] hover:bg-[#e9ecef] transition-colors flex-1"
|
| 39 |
>
|
| 40 |
+
<div className="w-[96.5px] h-[47.291px] rounded-[5px] flex items-center justify-center overflow-hidden">
|
| 41 |
+
<img src={layout.thumbnail} alt={layout.name} className="w-full h-full object-cover" />
|
| 42 |
</div>
|
| 43 |
<p className="text-[14px] font-normal text-[#545865] text-center">
|
| 44 |
{layout.name}
|
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { X } from 'lucide-react';
|
| 2 |
+
|
| 3 |
+
interface LayoutSwitchConfirmationProps {
|
| 4 |
+
onKeep: () => void;
|
| 5 |
+
onReplace: () => void;
|
| 6 |
+
onCancel: () => void;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
export default function LayoutSwitchConfirmation({
|
| 10 |
+
onKeep,
|
| 11 |
+
onReplace,
|
| 12 |
+
onCancel
|
| 13 |
+
}: LayoutSwitchConfirmationProps) {
|
| 14 |
+
return (
|
| 15 |
+
<div
|
| 16 |
+
className="fixed inset-0 z-[100] flex items-center justify-center bg-black bg-opacity-50 backdrop-blur-sm"
|
| 17 |
+
onClick={onCancel}
|
| 18 |
+
>
|
| 19 |
+
{/* Modal Container */}
|
| 20 |
+
<div
|
| 21 |
+
className="bg-[#2b2d31] rounded-[12px] shadow-2xl max-w-[480px] w-full mx-4 overflow-hidden"
|
| 22 |
+
onClick={(e) => e.stopPropagation()}
|
| 23 |
+
>
|
| 24 |
+
{/* Header */}
|
| 25 |
+
<div className="flex items-center justify-between px-6 py-4 border-b border-[#3e4044]">
|
| 26 |
+
<h2 className="text-white text-[18px] font-semibold">
|
| 27 |
+
Switch Layout
|
| 28 |
+
</h2>
|
| 29 |
+
<button
|
| 30 |
+
onClick={onCancel}
|
| 31 |
+
className="text-gray-400 hover:text-white transition-colors p-1 hover:bg-[#3e4044] rounded"
|
| 32 |
+
>
|
| 33 |
+
<X size={20} />
|
| 34 |
+
</button>
|
| 35 |
+
</div>
|
| 36 |
+
|
| 37 |
+
{/* Content */}
|
| 38 |
+
<div className="px-6 py-5">
|
| 39 |
+
<p className="text-gray-300 text-[15px] leading-relaxed mb-4">
|
| 40 |
+
You have custom objects on the canvas. What would you like to do?
|
| 41 |
+
</p>
|
| 42 |
+
|
| 43 |
+
{/* Options */}
|
| 44 |
+
<div className="space-y-3">
|
| 45 |
+
{/* Keep Option */}
|
| 46 |
+
<div className="bg-[#1e1f22] rounded-[8px] p-4 border border-[#3e4044]">
|
| 47 |
+
<div className="flex items-start gap-3">
|
| 48 |
+
<div className="w-5 h-5 rounded-full border-2 border-[#5865f2] flex-shrink-0 mt-0.5" />
|
| 49 |
+
<div>
|
| 50 |
+
<p className="text-white font-medium text-[14px] mb-1">
|
| 51 |
+
Keep my objects
|
| 52 |
+
</p>
|
| 53 |
+
<p className="text-gray-400 text-[13px]">
|
| 54 |
+
Add the new layout objects alongside your existing work
|
| 55 |
+
</p>
|
| 56 |
+
</div>
|
| 57 |
+
</div>
|
| 58 |
+
</div>
|
| 59 |
+
|
| 60 |
+
{/* Replace Option */}
|
| 61 |
+
<div className="bg-[#1e1f22] rounded-[8px] p-4 border border-[#3e4044]">
|
| 62 |
+
<div className="flex items-start gap-3">
|
| 63 |
+
<div className="w-5 h-5 rounded-full border-2 border-[#ed4245] flex-shrink-0 mt-0.5" />
|
| 64 |
+
<div>
|
| 65 |
+
<p className="text-white font-medium text-[14px] mb-1">
|
| 66 |
+
Replace everything
|
| 67 |
+
</p>
|
| 68 |
+
<p className="text-gray-400 text-[13px]">
|
| 69 |
+
Remove all objects and load a fresh layout
|
| 70 |
+
</p>
|
| 71 |
+
</div>
|
| 72 |
+
</div>
|
| 73 |
+
</div>
|
| 74 |
+
</div>
|
| 75 |
+
</div>
|
| 76 |
+
|
| 77 |
+
{/* Actions */}
|
| 78 |
+
<div className="px-6 py-4 bg-[#1e1f22] flex items-center justify-end gap-3">
|
| 79 |
+
<button
|
| 80 |
+
onClick={onCancel}
|
| 81 |
+
className="px-4 py-2 rounded-[6px] text-white text-[14px] font-medium hover:bg-[#3e4044] transition-colors"
|
| 82 |
+
>
|
| 83 |
+
Cancel
|
| 84 |
+
</button>
|
| 85 |
+
<button
|
| 86 |
+
onClick={onReplace}
|
| 87 |
+
className="px-4 py-2 rounded-[6px] bg-[#ed4245] text-white text-[14px] font-medium hover:bg-[#d13438] transition-colors"
|
| 88 |
+
>
|
| 89 |
+
Replace
|
| 90 |
+
</button>
|
| 91 |
+
<button
|
| 92 |
+
onClick={onKeep}
|
| 93 |
+
className="px-4 py-2 rounded-[6px] bg-[#5865f2] text-white text-[14px] font-medium hover:bg-[#4752c4] transition-colors"
|
| 94 |
+
>
|
| 95 |
+
Keep
|
| 96 |
+
</button>
|
| 97 |
+
</div>
|
| 98 |
+
</div>
|
| 99 |
+
</div>
|
| 100 |
+
);
|
| 101 |
+
}
|
|
@@ -91,7 +91,7 @@ export default function HuggyMenu({ onSelectHuggy, onClose }: HuggyMenuProps) {
|
|
| 91 |
/>
|
| 92 |
|
| 93 |
{/* Huggy Menu */}
|
| 94 |
-
<div className="huggy-menu fixed left-[107px] top-[20px] z-20 w-[
|
| 95 |
{/* Search Bar */}
|
| 96 |
<div className="border-b border-[#ebebeb] p-[5px]">
|
| 97 |
<input
|
|
@@ -115,45 +115,33 @@ export default function HuggyMenu({ onSelectHuggy, onClose }: HuggyMenuProps) {
|
|
| 115 |
No Huggys found
|
| 116 |
</div>
|
| 117 |
) : (
|
| 118 |
-
<div className="
|
| 119 |
-
{
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
loading="lazy"
|
| 146 |
-
onLoadStart={() => handleImageLoadStart(huggy.id)}
|
| 147 |
-
onLoad={() => handleImageLoad(huggy.id)}
|
| 148 |
-
onError={() => handleImageLoad(huggy.id)}
|
| 149 |
-
/>
|
| 150 |
-
</button>
|
| 151 |
-
))}
|
| 152 |
-
{/* Empty placeholder if odd number of items in last row */}
|
| 153 |
-
{rowHuggys.length === 1 && <div className="w-[100px] h-[100px]" />}
|
| 154 |
-
</div>
|
| 155 |
-
);
|
| 156 |
-
})}
|
| 157 |
</div>
|
| 158 |
)}
|
| 159 |
</div>
|
|
|
|
| 91 |
/>
|
| 92 |
|
| 93 |
{/* Huggy Menu */}
|
| 94 |
+
<div className="huggy-menu fixed left-[107px] top-[20px] z-20 w-[340px] bg-[#f8f9fa] border border-[#3faee6] rounded-[10px] flex flex-col overflow-hidden shadow-lg">
|
| 95 |
{/* Search Bar */}
|
| 96 |
<div className="border-b border-[#ebebeb] p-[5px]">
|
| 97 |
<input
|
|
|
|
| 115 |
No Huggys found
|
| 116 |
</div>
|
| 117 |
) : (
|
| 118 |
+
<div className="grid grid-cols-2 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3 gap-[5px] p-[5px]">
|
| 119 |
+
{displayedHuggys.map((huggy) => (
|
| 120 |
+
<button
|
| 121 |
+
key={huggy.id}
|
| 122 |
+
onClick={() => handleHuggyClick(huggy)}
|
| 123 |
+
className="relative w-full aspect-square rounded-[5px] overflow-hidden hover:bg-[#e9ecef] transition-colors cursor-pointer border-none p-0"
|
| 124 |
+
title={huggy.name}
|
| 125 |
+
>
|
| 126 |
+
{/* Loading Placeholder */}
|
| 127 |
+
{loadingImages.has(huggy.id) && (
|
| 128 |
+
<div className="absolute inset-0 flex items-center justify-center bg-[#f0f0f0]">
|
| 129 |
+
<div className="w-6 h-6 border-2 border-[#3faee6] border-t-transparent rounded-full animate-spin"></div>
|
| 130 |
+
</div>
|
| 131 |
+
)}
|
| 132 |
+
|
| 133 |
+
{/* Huggy Image */}
|
| 134 |
+
<img
|
| 135 |
+
src={huggy.thumbnail}
|
| 136 |
+
alt={huggy.name}
|
| 137 |
+
className="w-full h-full object-cover"
|
| 138 |
+
loading="lazy"
|
| 139 |
+
onLoadStart={() => handleImageLoadStart(huggy.id)}
|
| 140 |
+
onLoad={() => handleImageLoad(huggy.id)}
|
| 141 |
+
onError={() => handleImageLoad(huggy.id)}
|
| 142 |
+
/>
|
| 143 |
+
</button>
|
| 144 |
+
))}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
</div>
|
| 146 |
)}
|
| 147 |
</div>
|
|
@@ -28,7 +28,7 @@ export default function Sidebar({
|
|
| 28 |
}: SidebarProps) {
|
| 29 |
return (
|
| 30 |
<div className="fixed left-5 top-1/2 -translate-y-1/2 z-50">
|
| 31 |
-
<div className="sidebar-container bg-[#f8f9fa] rounded-[
|
| 32 |
{/* Layout Button */}
|
| 33 |
<button
|
| 34 |
onClick={onLayoutClick}
|
|
|
|
| 28 |
}: SidebarProps) {
|
| 29 |
return (
|
| 30 |
<div className="fixed left-5 top-1/2 -translate-y-1/2 z-50">
|
| 31 |
+
<div className="sidebar-container bg-[#f8f9fa] border border-[#D7DCE2] rounded-[12px] p-[5px] flex flex-col gap-[15px] w-[87px] relative">
|
| 32 |
{/* Layout Button */}
|
| 33 |
<button
|
| 34 |
onClick={onLayoutClick}
|
|
@@ -0,0 +1,402 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useRef, useEffect } from 'react';
|
| 2 |
+
import { Pipette } from 'lucide-react';
|
| 3 |
+
|
| 4 |
+
interface ColorPickerProps {
|
| 5 |
+
color: string;
|
| 6 |
+
onChange: (color: string) => void;
|
| 7 |
+
onClose: () => void;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
// Type declaration for EyeDropper API
|
| 11 |
+
interface EyeDropper {
|
| 12 |
+
open(): Promise<{ sRGBHex: string }>;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
declare global {
|
| 16 |
+
interface Window {
|
| 17 |
+
EyeDropper?: {
|
| 18 |
+
new(): EyeDropper;
|
| 19 |
+
};
|
| 20 |
+
}
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
// Convert hex to HSV
|
| 24 |
+
function hexToHSV(hex: string): { h: number; s: number; v: number } {
|
| 25 |
+
const r = parseInt(hex.slice(1, 3), 16) / 255;
|
| 26 |
+
const g = parseInt(hex.slice(3, 5), 16) / 255;
|
| 27 |
+
const b = parseInt(hex.slice(5, 7), 16) / 255;
|
| 28 |
+
|
| 29 |
+
const max = Math.max(r, g, b);
|
| 30 |
+
const min = Math.min(r, g, b);
|
| 31 |
+
const delta = max - min;
|
| 32 |
+
|
| 33 |
+
let h = 0;
|
| 34 |
+
if (delta !== 0) {
|
| 35 |
+
if (max === r) h = ((g - b) / delta + (g < b ? 6 : 0)) / 6;
|
| 36 |
+
else if (max === g) h = ((b - r) / delta + 2) / 6;
|
| 37 |
+
else h = ((r - g) / delta + 4) / 6;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
const s = max === 0 ? 0 : delta / max;
|
| 41 |
+
const v = max;
|
| 42 |
+
|
| 43 |
+
return { h: h * 360, s: s * 100, v: v * 100 };
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
// Convert HSV to hex
|
| 47 |
+
function hsvToHex(h: number, s: number, v: number): string {
|
| 48 |
+
h = h / 360;
|
| 49 |
+
s = s / 100;
|
| 50 |
+
v = v / 100;
|
| 51 |
+
|
| 52 |
+
const i = Math.floor(h * 6);
|
| 53 |
+
const f = h * 6 - i;
|
| 54 |
+
const p = v * (1 - s);
|
| 55 |
+
const q = v * (1 - f * s);
|
| 56 |
+
const t = v * (1 - (1 - f) * s);
|
| 57 |
+
|
| 58 |
+
let r = 0, g = 0, b = 0;
|
| 59 |
+
switch (i % 6) {
|
| 60 |
+
case 0: r = v; g = t; b = p; break;
|
| 61 |
+
case 1: r = q; g = v; b = p; break;
|
| 62 |
+
case 2: r = p; g = v; b = t; break;
|
| 63 |
+
case 3: r = p; g = q; b = v; break;
|
| 64 |
+
case 4: r = t; g = p; b = v; break;
|
| 65 |
+
case 5: r = v; g = p; b = q; break;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
const toHex = (n: number) => {
|
| 69 |
+
const hex = Math.round(n * 255).toString(16);
|
| 70 |
+
return hex.length === 1 ? '0' + hex : hex;
|
| 71 |
+
};
|
| 72 |
+
|
| 73 |
+
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
export default function ColorPicker({ color, onChange, onClose }: ColorPickerProps) {
|
| 77 |
+
const hsv = hexToHSV(color);
|
| 78 |
+
const [hue, setHue] = useState(hsv.h);
|
| 79 |
+
const [saturation, setSaturation] = useState(hsv.s);
|
| 80 |
+
const [value, setValue] = useState(hsv.v);
|
| 81 |
+
const [hexInput, setHexInput] = useState(color.toUpperCase());
|
| 82 |
+
|
| 83 |
+
const paletteRef = useRef<HTMLDivElement>(null);
|
| 84 |
+
const hueRef = useRef<HTMLDivElement>(null);
|
| 85 |
+
const satRef = useRef<HTMLDivElement>(null);
|
| 86 |
+
const [isDraggingPalette, setIsDraggingPalette] = useState(false);
|
| 87 |
+
const [isDraggingHue, setIsDraggingHue] = useState(false);
|
| 88 |
+
const [isDraggingSat, setIsDraggingSat] = useState(false);
|
| 89 |
+
const [isTyping, setIsTyping] = useState(false);
|
| 90 |
+
|
| 91 |
+
// Update hex when HSV changes (but not when user is typing)
|
| 92 |
+
useEffect(() => {
|
| 93 |
+
if (!isTyping) {
|
| 94 |
+
const newHex = hsvToHex(hue, saturation, value);
|
| 95 |
+
setHexInput(newHex.toUpperCase());
|
| 96 |
+
onChange(newHex);
|
| 97 |
+
}
|
| 98 |
+
}, [hue, saturation, value, onChange, isTyping]);
|
| 99 |
+
|
| 100 |
+
// Palette drag handlers
|
| 101 |
+
const handlePaletteMouseDown = (e: React.MouseEvent) => {
|
| 102 |
+
setIsDraggingPalette(true);
|
| 103 |
+
handlePaletteMove(e);
|
| 104 |
+
};
|
| 105 |
+
|
| 106 |
+
const handlePaletteMove = (e: React.MouseEvent | MouseEvent) => {
|
| 107 |
+
if (!paletteRef.current) return;
|
| 108 |
+
const rect = paletteRef.current.getBoundingClientRect();
|
| 109 |
+
const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
|
| 110 |
+
const y = Math.max(0, Math.min(e.clientY - rect.top, rect.height));
|
| 111 |
+
setSaturation((x / rect.width) * 100);
|
| 112 |
+
setValue(100 - (y / rect.height) * 100);
|
| 113 |
+
};
|
| 114 |
+
|
| 115 |
+
// Hue drag handlers
|
| 116 |
+
const handleHueMouseDown = (e: React.MouseEvent) => {
|
| 117 |
+
setIsDraggingHue(true);
|
| 118 |
+
handleHueMove(e);
|
| 119 |
+
};
|
| 120 |
+
|
| 121 |
+
const handleHueMove = (e: React.MouseEvent | MouseEvent) => {
|
| 122 |
+
if (!hueRef.current) return;
|
| 123 |
+
const rect = hueRef.current.getBoundingClientRect();
|
| 124 |
+
const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
|
| 125 |
+
setHue((x / rect.width) * 360);
|
| 126 |
+
};
|
| 127 |
+
|
| 128 |
+
// Saturation drag handlers
|
| 129 |
+
const handleSatMouseDown = (e: React.MouseEvent) => {
|
| 130 |
+
setIsDraggingSat(true);
|
| 131 |
+
handleSatMove(e);
|
| 132 |
+
};
|
| 133 |
+
|
| 134 |
+
const handleSatMove = (e: React.MouseEvent | MouseEvent) => {
|
| 135 |
+
if (!satRef.current) return;
|
| 136 |
+
const rect = satRef.current.getBoundingClientRect();
|
| 137 |
+
const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
|
| 138 |
+
setSaturation((x / rect.width) * 100);
|
| 139 |
+
};
|
| 140 |
+
|
| 141 |
+
// Global mouse handlers
|
| 142 |
+
useEffect(() => {
|
| 143 |
+
const handleMouseMove = (e: MouseEvent) => {
|
| 144 |
+
if (isDraggingPalette) handlePaletteMove(e);
|
| 145 |
+
if (isDraggingHue) handleHueMove(e);
|
| 146 |
+
if (isDraggingSat) handleSatMove(e);
|
| 147 |
+
};
|
| 148 |
+
|
| 149 |
+
const handleMouseUp = () => {
|
| 150 |
+
setIsDraggingPalette(false);
|
| 151 |
+
setIsDraggingHue(false);
|
| 152 |
+
setIsDraggingSat(false);
|
| 153 |
+
};
|
| 154 |
+
|
| 155 |
+
if (isDraggingPalette || isDraggingHue || isDraggingSat) {
|
| 156 |
+
document.addEventListener('mousemove', handleMouseMove);
|
| 157 |
+
document.addEventListener('mouseup', handleMouseUp);
|
| 158 |
+
return () => {
|
| 159 |
+
document.removeEventListener('mousemove', handleMouseMove);
|
| 160 |
+
document.removeEventListener('mouseup', handleMouseUp);
|
| 161 |
+
};
|
| 162 |
+
}
|
| 163 |
+
}, [isDraggingPalette, isDraggingHue, isDraggingSat]);
|
| 164 |
+
|
| 165 |
+
// Handle hex input change
|
| 166 |
+
const handleHexChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 167 |
+
setIsTyping(true);
|
| 168 |
+
let value = e.target.value.toUpperCase();
|
| 169 |
+
if (!value.startsWith('#')) value = '#' + value;
|
| 170 |
+
setHexInput(value);
|
| 171 |
+
|
| 172 |
+
// Validate and update if valid hex
|
| 173 |
+
if (/^#[0-9A-F]{6}$/i.test(value)) {
|
| 174 |
+
const hsv = hexToHSV(value);
|
| 175 |
+
setHue(hsv.h);
|
| 176 |
+
setSaturation(hsv.s);
|
| 177 |
+
setValue(hsv.v);
|
| 178 |
+
onChange(value);
|
| 179 |
+
setIsTyping(false);
|
| 180 |
+
}
|
| 181 |
+
};
|
| 182 |
+
|
| 183 |
+
// Handle eyedropper with browser EyeDropper API
|
| 184 |
+
const handleEyedropper = async () => {
|
| 185 |
+
if (!window.EyeDropper) {
|
| 186 |
+
alert('EyeDropper API is not supported in your browser. Please use Chrome or Edge.');
|
| 187 |
+
return;
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
try {
|
| 191 |
+
const eyeDropper = new window.EyeDropper();
|
| 192 |
+
const result = await eyeDropper.open();
|
| 193 |
+
const pickedColor = result.sRGBHex;
|
| 194 |
+
|
| 195 |
+
// Update color
|
| 196 |
+
const hsv = hexToHSV(pickedColor);
|
| 197 |
+
setHue(hsv.h);
|
| 198 |
+
setSaturation(hsv.s);
|
| 199 |
+
setValue(hsv.v);
|
| 200 |
+
setHexInput(pickedColor.toUpperCase());
|
| 201 |
+
onChange(pickedColor);
|
| 202 |
+
} catch (error) {
|
| 203 |
+
// User cancelled the eyedropper
|
| 204 |
+
console.log('Eyedropper cancelled');
|
| 205 |
+
}
|
| 206 |
+
};
|
| 207 |
+
|
| 208 |
+
// Calculate handle positions
|
| 209 |
+
const paletteHandleX = (saturation / 100) * 100; // percentage
|
| 210 |
+
const paletteHandleY = (1 - value / 100) * 100; // percentage
|
| 211 |
+
const hueHandleX = (hue / 360) * 100; // percentage
|
| 212 |
+
|
| 213 |
+
return (
|
| 214 |
+
<div
|
| 215 |
+
onMouseDown={(e) => e.stopPropagation()}
|
| 216 |
+
onClick={(e) => e.stopPropagation()}
|
| 217 |
+
style={{
|
| 218 |
+
position: 'absolute',
|
| 219 |
+
bottom: 'calc(100% + 8px)',
|
| 220 |
+
right: 0,
|
| 221 |
+
width: '280px',
|
| 222 |
+
backgroundColor: '#252525',
|
| 223 |
+
border: '1px solid #1b1b1b',
|
| 224 |
+
borderRadius: '8px',
|
| 225 |
+
padding: '16px',
|
| 226 |
+
boxShadow: '0 4px 6px -2px rgba(0, 0, 0, 0.1), 0 12px 16px -4px rgba(0, 0, 0, 0.17)',
|
| 227 |
+
zIndex: 1000
|
| 228 |
+
}}
|
| 229 |
+
>
|
| 230 |
+
{/* Color Palette */}
|
| 231 |
+
<div
|
| 232 |
+
ref={paletteRef}
|
| 233 |
+
onMouseDown={handlePaletteMouseDown}
|
| 234 |
+
style={{
|
| 235 |
+
position: 'relative',
|
| 236 |
+
width: '100%',
|
| 237 |
+
height: '112px',
|
| 238 |
+
borderRadius: '8px',
|
| 239 |
+
marginBottom: '16px',
|
| 240 |
+
cursor: 'crosshair',
|
| 241 |
+
background: `
|
| 242 |
+
linear-gradient(to bottom, transparent, black),
|
| 243 |
+
linear-gradient(to right, white, transparent),
|
| 244 |
+
hsl(${hue}, 100%, 50%)
|
| 245 |
+
`
|
| 246 |
+
}}
|
| 247 |
+
>
|
| 248 |
+
{/* Palette Handle */}
|
| 249 |
+
<div
|
| 250 |
+
style={{
|
| 251 |
+
position: 'absolute',
|
| 252 |
+
left: `${paletteHandleX}%`,
|
| 253 |
+
top: `${paletteHandleY}%`,
|
| 254 |
+
width: '12px',
|
| 255 |
+
height: '12px',
|
| 256 |
+
border: '2px solid white',
|
| 257 |
+
borderRadius: '50%',
|
| 258 |
+
transform: 'translate(-50%, -50%)',
|
| 259 |
+
boxShadow: '0 0 0 1px rgba(0, 0, 0, 0.3)',
|
| 260 |
+
pointerEvents: 'none'
|
| 261 |
+
}}
|
| 262 |
+
/>
|
| 263 |
+
</div>
|
| 264 |
+
|
| 265 |
+
{/* Color Icon & Sliders Container */}
|
| 266 |
+
<div style={{ display: 'flex', gap: '10px', marginBottom: '16px' }}>
|
| 267 |
+
{/* Eyedropper Button */}
|
| 268 |
+
<button
|
| 269 |
+
onClick={handleEyedropper}
|
| 270 |
+
title="Pick color from screen"
|
| 271 |
+
style={{
|
| 272 |
+
width: '24px',
|
| 273 |
+
height: '24px',
|
| 274 |
+
borderRadius: '4px',
|
| 275 |
+
backgroundColor: 'rgba(255, 255, 255, 0.05)',
|
| 276 |
+
border: '1px solid rgba(255, 255, 255, 0.1)',
|
| 277 |
+
flexShrink: 0,
|
| 278 |
+
cursor: 'pointer',
|
| 279 |
+
display: 'flex',
|
| 280 |
+
alignItems: 'center',
|
| 281 |
+
justifyContent: 'center',
|
| 282 |
+
padding: 0
|
| 283 |
+
}}
|
| 284 |
+
>
|
| 285 |
+
<Pipette size={14} color="white" />
|
| 286 |
+
</button>
|
| 287 |
+
|
| 288 |
+
{/* Sliders */}
|
| 289 |
+
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
| 290 |
+
{/* Hue Slider */}
|
| 291 |
+
<div
|
| 292 |
+
ref={hueRef}
|
| 293 |
+
onMouseDown={handleHueMouseDown}
|
| 294 |
+
style={{
|
| 295 |
+
position: 'relative',
|
| 296 |
+
width: '100%',
|
| 297 |
+
height: '12px',
|
| 298 |
+
borderRadius: '100px',
|
| 299 |
+
background: 'linear-gradient(to right, #ff0000 0%, #ffff00 17%, #00ff00 33%, #00ffff 50%, #0000ff 67%, #ff00ff 83%, #ff0000 100%)',
|
| 300 |
+
cursor: 'pointer'
|
| 301 |
+
}}
|
| 302 |
+
>
|
| 303 |
+
{/* Hue Handle */}
|
| 304 |
+
<div
|
| 305 |
+
style={{
|
| 306 |
+
position: 'absolute',
|
| 307 |
+
left: `${hueHandleX}%`,
|
| 308 |
+
top: '50%',
|
| 309 |
+
width: '12px',
|
| 310 |
+
height: '12px',
|
| 311 |
+
border: '2px solid white',
|
| 312 |
+
borderRadius: '50%',
|
| 313 |
+
transform: 'translate(-50%, -50%)',
|
| 314 |
+
boxShadow: '0 0 0 1px rgba(0, 0, 0, 0.3)',
|
| 315 |
+
pointerEvents: 'none'
|
| 316 |
+
}}
|
| 317 |
+
/>
|
| 318 |
+
</div>
|
| 319 |
+
|
| 320 |
+
{/* Saturation Slider */}
|
| 321 |
+
<div
|
| 322 |
+
ref={satRef}
|
| 323 |
+
onMouseDown={handleSatMouseDown}
|
| 324 |
+
style={{
|
| 325 |
+
position: 'relative',
|
| 326 |
+
width: '100%',
|
| 327 |
+
height: '12px',
|
| 328 |
+
borderRadius: '100px',
|
| 329 |
+
background: `linear-gradient(to right, white, hsl(${hue}, 100%, 50%))`,
|
| 330 |
+
cursor: 'pointer'
|
| 331 |
+
}}
|
| 332 |
+
>
|
| 333 |
+
{/* Saturation Handle */}
|
| 334 |
+
<div
|
| 335 |
+
style={{
|
| 336 |
+
position: 'absolute',
|
| 337 |
+
left: `${saturation}%`,
|
| 338 |
+
top: '50%',
|
| 339 |
+
width: '12px',
|
| 340 |
+
height: '12px',
|
| 341 |
+
border: '2px solid white',
|
| 342 |
+
borderRadius: '50%',
|
| 343 |
+
transform: 'translate(-50%, -50%)',
|
| 344 |
+
boxShadow: '0 0 0 1px rgba(0, 0, 0, 0.3)',
|
| 345 |
+
pointerEvents: 'none'
|
| 346 |
+
}}
|
| 347 |
+
/>
|
| 348 |
+
</div>
|
| 349 |
+
</div>
|
| 350 |
+
</div>
|
| 351 |
+
|
| 352 |
+
{/* Format & Hex Input */}
|
| 353 |
+
<div style={{ display: 'flex', gap: '4px' }}>
|
| 354 |
+
{/* Format Label (Hex only for now) */}
|
| 355 |
+
<div
|
| 356 |
+
style={{
|
| 357 |
+
padding: '8px',
|
| 358 |
+
borderRadius: '8px',
|
| 359 |
+
color: '#eeeeee',
|
| 360 |
+
fontSize: '14px',
|
| 361 |
+
fontFamily: 'Inter, sans-serif',
|
| 362 |
+
opacity: 0.8,
|
| 363 |
+
width: '72px',
|
| 364 |
+
textAlign: 'center',
|
| 365 |
+
display: 'flex',
|
| 366 |
+
alignItems: 'center',
|
| 367 |
+
justifyContent: 'center'
|
| 368 |
+
}}
|
| 369 |
+
>
|
| 370 |
+
Hex
|
| 371 |
+
</div>
|
| 372 |
+
|
| 373 |
+
{/* Hex Input */}
|
| 374 |
+
<input
|
| 375 |
+
type="text"
|
| 376 |
+
value={hexInput}
|
| 377 |
+
onChange={handleHexChange}
|
| 378 |
+
onBlur={() => setIsTyping(false)}
|
| 379 |
+
onKeyDown={(e) => e.stopPropagation()}
|
| 380 |
+
onKeyUp={(e) => e.stopPropagation()}
|
| 381 |
+
onKeyPress={(e) => e.stopPropagation()}
|
| 382 |
+
maxLength={7}
|
| 383 |
+
style={{
|
| 384 |
+
flex: 1,
|
| 385 |
+
padding: '8px 4px',
|
| 386 |
+
backgroundColor: '#1b1b1b',
|
| 387 |
+
border: 'none',
|
| 388 |
+
borderRadius: '6px',
|
| 389 |
+
color: '#eeeeee',
|
| 390 |
+
fontSize: '12px',
|
| 391 |
+
fontFamily: 'Inter, sans-serif',
|
| 392 |
+
fontWeight: 500,
|
| 393 |
+
textAlign: 'center',
|
| 394 |
+
outline: 'none'
|
| 395 |
+
}}
|
| 396 |
+
onClick={(e) => e.stopPropagation()}
|
| 397 |
+
onMouseDown={(e) => e.stopPropagation()}
|
| 398 |
+
/>
|
| 399 |
+
</div>
|
| 400 |
+
</div>
|
| 401 |
+
);
|
| 402 |
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from 'react';
|
| 2 |
+
import { ChevronDown, Bold, Italic } from 'lucide-react';
|
| 3 |
+
import ColorPicker from './ColorPicker';
|
| 4 |
+
import Konva from 'konva';
|
| 5 |
+
|
| 6 |
+
interface TextToolbarProps {
|
| 7 |
+
fontFamily: 'Inter' | 'IBM Plex Mono' | 'Bison';
|
| 8 |
+
fontSize: number;
|
| 9 |
+
fill: string;
|
| 10 |
+
bold: boolean;
|
| 11 |
+
italic: boolean;
|
| 12 |
+
canvasWidth: number;
|
| 13 |
+
canvasHeight: number;
|
| 14 |
+
scale?: number;
|
| 15 |
+
stageRef?: React.RefObject<Konva.Stage>;
|
| 16 |
+
onFontFamilyChange: (font: 'Inter' | 'IBM Plex Mono' | 'Bison') => void;
|
| 17 |
+
onFillChange: (color: string) => void;
|
| 18 |
+
onBoldToggle: () => void;
|
| 19 |
+
onItalicToggle: () => void;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
export default function TextToolbar({
|
| 23 |
+
fontFamily,
|
| 24 |
+
fill,
|
| 25 |
+
bold,
|
| 26 |
+
italic,
|
| 27 |
+
canvasWidth,
|
| 28 |
+
canvasHeight,
|
| 29 |
+
scale = 1,
|
| 30 |
+
stageRef,
|
| 31 |
+
onFontFamilyChange,
|
| 32 |
+
onFillChange,
|
| 33 |
+
onBoldToggle,
|
| 34 |
+
onItalicToggle
|
| 35 |
+
}: TextToolbarProps) {
|
| 36 |
+
const [showFontDropdown, setShowFontDropdown] = useState(false);
|
| 37 |
+
const [showColorPicker, setShowColorPicker] = useState(false);
|
| 38 |
+
|
| 39 |
+
const fonts: Array<'Inter' | 'IBM Plex Mono' | 'Bison'> = ['Inter', 'IBM Plex Mono', 'Bison'];
|
| 40 |
+
|
| 41 |
+
// Font variation availability
|
| 42 |
+
const fontVariations = {
|
| 43 |
+
'Inter': { bold: true, italic: true },
|
| 44 |
+
'IBM Plex Mono': { bold: true, italic: true },
|
| 45 |
+
'Bison': { bold: false, italic: false }
|
| 46 |
+
};
|
| 47 |
+
|
| 48 |
+
const hasBold = fontVariations[fontFamily].bold;
|
| 49 |
+
const hasItalic = fontVariations[fontFamily].italic;
|
| 50 |
+
|
| 51 |
+
// Calculate position: flush right with canvas, 10px below
|
| 52 |
+
// Account for: canvas height + header (~60px) + gaps (20px) = ~80px total overhead
|
| 53 |
+
// Apply scale factor to canvas dimensions for accurate positioning
|
| 54 |
+
const scaledWidth = canvasWidth * scale;
|
| 55 |
+
const scaledHeight = canvasHeight * scale;
|
| 56 |
+
const scaledOverhead = 80 * scale;
|
| 57 |
+
|
| 58 |
+
const rightPosition = `calc((100vw - ${scaledWidth}px) / 2)`;
|
| 59 |
+
const bottomPosition = `calc((100vh - ${scaledHeight}px - ${scaledOverhead}px) / 2 - 10px - 44px)`;
|
| 60 |
+
|
| 61 |
+
return (
|
| 62 |
+
<div
|
| 63 |
+
className="text-toolbar"
|
| 64 |
+
onMouseDown={(e) => {
|
| 65 |
+
// Prevent deselection when clicking on toolbar
|
| 66 |
+
e.stopPropagation();
|
| 67 |
+
}}
|
| 68 |
+
onClick={(e) => {
|
| 69 |
+
// Prevent deselection when clicking on toolbar
|
| 70 |
+
e.stopPropagation();
|
| 71 |
+
}}
|
| 72 |
+
style={{
|
| 73 |
+
position: 'fixed',
|
| 74 |
+
right: rightPosition,
|
| 75 |
+
bottom: bottomPosition,
|
| 76 |
+
zIndex: 100,
|
| 77 |
+
backgroundColor: '#27272A',
|
| 78 |
+
borderRadius: '8px',
|
| 79 |
+
padding: '4px',
|
| 80 |
+
boxShadow: '0 4px 6px -2px rgba(0, 0, 0, 0.1), 0 12px 16px -4px rgba(0, 0, 0, 0.17)',
|
| 81 |
+
transition: 'right 0.15s ease-in-out, bottom 0.15s ease-in-out'
|
| 82 |
+
}}
|
| 83 |
+
>
|
| 84 |
+
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
| 85 |
+
{/* Font Family Dropdown */}
|
| 86 |
+
<div style={{ position: 'relative' }}>
|
| 87 |
+
<button
|
| 88 |
+
onClick={() => setShowFontDropdown(!showFontDropdown)}
|
| 89 |
+
style={{
|
| 90 |
+
display: 'flex',
|
| 91 |
+
alignItems: 'center',
|
| 92 |
+
gap: '20px',
|
| 93 |
+
padding: '8px',
|
| 94 |
+
backgroundColor: 'rgba(255, 255, 255, 0.05)',
|
| 95 |
+
border: 'none',
|
| 96 |
+
borderRadius: '4px',
|
| 97 |
+
color: 'white',
|
| 98 |
+
fontSize: '16px',
|
| 99 |
+
fontFamily: 'Source Sans Pro, sans-serif',
|
| 100 |
+
cursor: 'pointer',
|
| 101 |
+
whiteSpace: 'nowrap'
|
| 102 |
+
}}
|
| 103 |
+
>
|
| 104 |
+
{fontFamily}
|
| 105 |
+
<ChevronDown size={14} />
|
| 106 |
+
</button>
|
| 107 |
+
|
| 108 |
+
{showFontDropdown && (
|
| 109 |
+
<div
|
| 110 |
+
style={{
|
| 111 |
+
position: 'absolute',
|
| 112 |
+
top: 'calc(100% + 4px)',
|
| 113 |
+
left: 0,
|
| 114 |
+
backgroundColor: '#27272A',
|
| 115 |
+
border: '1px solid #09090B',
|
| 116 |
+
borderRadius: '8px',
|
| 117 |
+
padding: '4px',
|
| 118 |
+
minWidth: '160px',
|
| 119 |
+
zIndex: 1000
|
| 120 |
+
}}
|
| 121 |
+
>
|
| 122 |
+
{fonts.map((font) => (
|
| 123 |
+
<button
|
| 124 |
+
key={font}
|
| 125 |
+
onClick={() => {
|
| 126 |
+
onFontFamilyChange(font);
|
| 127 |
+
setShowFontDropdown(false);
|
| 128 |
+
}}
|
| 129 |
+
style={{
|
| 130 |
+
display: 'block',
|
| 131 |
+
width: '100%',
|
| 132 |
+
padding: '8px 12px',
|
| 133 |
+
backgroundColor: fontFamily === font ? 'rgba(255, 255, 255, 0.1)' : 'transparent',
|
| 134 |
+
border: 'none',
|
| 135 |
+
borderRadius: '4px',
|
| 136 |
+
color: 'white',
|
| 137 |
+
fontSize: '14px',
|
| 138 |
+
fontFamily: font,
|
| 139 |
+
textAlign: 'left',
|
| 140 |
+
cursor: 'pointer'
|
| 141 |
+
}}
|
| 142 |
+
onMouseEnter={(e) => {
|
| 143 |
+
if (fontFamily !== font) {
|
| 144 |
+
e.currentTarget.style.backgroundColor = 'rgba(255, 255, 255, 0.05)';
|
| 145 |
+
}
|
| 146 |
+
}}
|
| 147 |
+
onMouseLeave={(e) => {
|
| 148 |
+
if (fontFamily !== font) {
|
| 149 |
+
e.currentTarget.style.backgroundColor = 'transparent';
|
| 150 |
+
}
|
| 151 |
+
}}
|
| 152 |
+
>
|
| 153 |
+
{font}
|
| 154 |
+
</button>
|
| 155 |
+
))}
|
| 156 |
+
</div>
|
| 157 |
+
)}
|
| 158 |
+
</div>
|
| 159 |
+
|
| 160 |
+
{/* Divider */}
|
| 161 |
+
<div
|
| 162 |
+
style={{
|
| 163 |
+
width: '1px',
|
| 164 |
+
height: '18px',
|
| 165 |
+
backgroundColor: 'rgba(255, 255, 255, 0.2)'
|
| 166 |
+
}}
|
| 167 |
+
/>
|
| 168 |
+
|
| 169 |
+
{/* Color Picker Button */}
|
| 170 |
+
<div style={{ position: 'relative' }}>
|
| 171 |
+
<button
|
| 172 |
+
onClick={() => setShowColorPicker(!showColorPicker)}
|
| 173 |
+
style={{
|
| 174 |
+
display: 'flex',
|
| 175 |
+
alignItems: 'center',
|
| 176 |
+
justifyContent: 'center',
|
| 177 |
+
padding: '10px',
|
| 178 |
+
backgroundColor: 'transparent',
|
| 179 |
+
border: 'none',
|
| 180 |
+
borderRadius: '99px',
|
| 181 |
+
cursor: 'pointer'
|
| 182 |
+
}}
|
| 183 |
+
>
|
| 184 |
+
<div
|
| 185 |
+
style={{
|
| 186 |
+
width: '16px',
|
| 187 |
+
height: '16px',
|
| 188 |
+
borderRadius: '999px',
|
| 189 |
+
backgroundColor: fill,
|
| 190 |
+
border: '1px solid #e5e9ed'
|
| 191 |
+
}}
|
| 192 |
+
/>
|
| 193 |
+
</button>
|
| 194 |
+
|
| 195 |
+
{/* Color Picker Popover */}
|
| 196 |
+
{showColorPicker && (
|
| 197 |
+
<ColorPicker
|
| 198 |
+
color={fill}
|
| 199 |
+
onChange={onFillChange}
|
| 200 |
+
onClose={() => setShowColorPicker(false)}
|
| 201 |
+
/>
|
| 202 |
+
)}
|
| 203 |
+
</div>
|
| 204 |
+
|
| 205 |
+
{/* Bold Button */}
|
| 206 |
+
<button
|
| 207 |
+
onClick={hasBold ? onBoldToggle : undefined}
|
| 208 |
+
disabled={!hasBold}
|
| 209 |
+
style={{
|
| 210 |
+
display: 'flex',
|
| 211 |
+
alignItems: 'center',
|
| 212 |
+
justifyContent: 'center',
|
| 213 |
+
padding: '8px',
|
| 214 |
+
backgroundColor: bold ? 'rgba(255, 255, 255, 0.1)' : 'transparent',
|
| 215 |
+
border: 'none',
|
| 216 |
+
borderRadius: '4px',
|
| 217 |
+
cursor: hasBold ? 'pointer' : 'not-allowed',
|
| 218 |
+
color: 'white',
|
| 219 |
+
opacity: hasBold ? 1 : 0.3
|
| 220 |
+
}}
|
| 221 |
+
>
|
| 222 |
+
<Bold size={20} />
|
| 223 |
+
</button>
|
| 224 |
+
|
| 225 |
+
{/* Italic Button */}
|
| 226 |
+
<button
|
| 227 |
+
onClick={hasItalic ? onItalicToggle : undefined}
|
| 228 |
+
disabled={!hasItalic}
|
| 229 |
+
style={{
|
| 230 |
+
display: 'flex',
|
| 231 |
+
alignItems: 'center',
|
| 232 |
+
justifyContent: 'center',
|
| 233 |
+
padding: '8px',
|
| 234 |
+
backgroundColor: italic ? 'rgba(255, 255, 255, 0.1)' : 'transparent',
|
| 235 |
+
border: 'none',
|
| 236 |
+
borderRadius: '4px',
|
| 237 |
+
cursor: hasItalic ? 'pointer' : 'not-allowed',
|
| 238 |
+
color: 'white',
|
| 239 |
+
opacity: hasItalic ? 1 : 0.3
|
| 240 |
+
}}
|
| 241 |
+
>
|
| 242 |
+
<Italic size={20} />
|
| 243 |
+
</button>
|
| 244 |
+
</div>
|
| 245 |
+
</div>
|
| 246 |
+
);
|
| 247 |
+
}
|
|
@@ -52,10 +52,10 @@ export const LAYOUTS: Record<string, Layout> = {
|
|
| 52 |
{
|
| 53 |
id: 'title-text',
|
| 54 |
type: 'text',
|
| 55 |
-
x:
|
| 56 |
y: 81.58,
|
| 57 |
width: 852,
|
| 58 |
-
height:
|
| 59 |
rotation: 0,
|
| 60 |
zIndex: 1,
|
| 61 |
text: 'Pretty Short Title',
|
|
@@ -66,6 +66,7 @@ export const LAYOUTS: Record<string, Layout> = {
|
|
| 66 |
italic: false,
|
| 67 |
align: 'center',
|
| 68 |
isFixedSize: true,
|
|
|
|
| 69 |
},
|
| 70 |
{
|
| 71 |
id: 'logo-placeholder',
|
|
@@ -115,8 +116,8 @@ export const LAYOUTS: Record<string, Layout> = {
|
|
| 115 |
type: 'text',
|
| 116 |
x: 112.57,
|
| 117 |
y: 51.67,
|
| 118 |
-
width:
|
| 119 |
-
height:
|
| 120 |
rotation: 0,
|
| 121 |
zIndex: 1,
|
| 122 |
text: 'Pretty short title',
|
|
@@ -133,8 +134,8 @@ export const LAYOUTS: Record<string, Layout> = {
|
|
| 133 |
type: 'text',
|
| 134 |
x: 600,
|
| 135 |
y: 503.48,
|
| 136 |
-
width:
|
| 137 |
-
height:
|
| 138 |
rotation: 0,
|
| 139 |
zIndex: 2,
|
| 140 |
text: 'supportive description',
|
|
@@ -145,7 +146,7 @@ export const LAYOUTS: Record<string, Layout> = {
|
|
| 145 |
italic: false,
|
| 146 |
align: 'center',
|
| 147 |
isFixedSize: true,
|
| 148 |
-
offsetX:
|
| 149 |
},
|
| 150 |
{
|
| 151 |
id: 'singing-huggy',
|
|
@@ -181,10 +182,10 @@ export const LAYOUTS: Record<string, Layout> = {
|
|
| 181 |
{
|
| 182 |
id: 'title-text',
|
| 183 |
type: 'text',
|
| 184 |
-
x:
|
| 185 |
y: 320.67,
|
| 186 |
width: 643,
|
| 187 |
-
height:
|
| 188 |
rotation: 0,
|
| 189 |
zIndex: 2,
|
| 190 |
text: 'Transformers',
|
|
@@ -195,14 +196,15 @@ export const LAYOUTS: Record<string, Layout> = {
|
|
| 195 |
italic: false,
|
| 196 |
align: 'center',
|
| 197 |
isFixedSize: true,
|
|
|
|
| 198 |
},
|
| 199 |
{
|
| 200 |
id: 'subtitle-text',
|
| 201 |
type: 'text',
|
| 202 |
-
x:
|
| 203 |
y: 436.67,
|
| 204 |
width: 421,
|
| 205 |
-
height:
|
| 206 |
rotation: 0,
|
| 207 |
zIndex: 3,
|
| 208 |
text: 'Documentation',
|
|
@@ -213,6 +215,7 @@ export const LAYOUTS: Record<string, Layout> = {
|
|
| 213 |
italic: false,
|
| 214 |
align: 'center',
|
| 215 |
isFixedSize: true,
|
|
|
|
| 216 |
},
|
| 217 |
],
|
| 218 |
},
|
|
|
|
| 52 |
{
|
| 53 |
id: 'title-text',
|
| 54 |
type: 'text',
|
| 55 |
+
x: 550,
|
| 56 |
y: 81.58,
|
| 57 |
width: 852,
|
| 58 |
+
height: 140,
|
| 59 |
rotation: 0,
|
| 60 |
zIndex: 1,
|
| 61 |
text: 'Pretty Short Title',
|
|
|
|
| 66 |
italic: false,
|
| 67 |
align: 'center',
|
| 68 |
isFixedSize: true,
|
| 69 |
+
offsetX: 426,
|
| 70 |
},
|
| 71 |
{
|
| 72 |
id: 'logo-placeholder',
|
|
|
|
| 116 |
type: 'text',
|
| 117 |
x: 112.57,
|
| 118 |
y: 51.67,
|
| 119 |
+
width: 1050,
|
| 120 |
+
height: 190,
|
| 121 |
rotation: 0,
|
| 122 |
zIndex: 1,
|
| 123 |
text: 'Pretty short title',
|
|
|
|
| 134 |
type: 'text',
|
| 135 |
x: 600,
|
| 136 |
y: 503.48,
|
| 137 |
+
width: 1015,
|
| 138 |
+
height: 107,
|
| 139 |
rotation: 0,
|
| 140 |
zIndex: 2,
|
| 141 |
text: 'supportive description',
|
|
|
|
| 146 |
italic: false,
|
| 147 |
align: 'center',
|
| 148 |
isFixedSize: true,
|
| 149 |
+
offsetX: 507.5,
|
| 150 |
},
|
| 151 |
{
|
| 152 |
id: 'singing-huggy',
|
|
|
|
| 182 |
{
|
| 183 |
id: 'title-text',
|
| 184 |
type: 'text',
|
| 185 |
+
x: 570,
|
| 186 |
y: 320.67,
|
| 187 |
width: 643,
|
| 188 |
+
height: 111,
|
| 189 |
rotation: 0,
|
| 190 |
zIndex: 2,
|
| 191 |
text: 'Transformers',
|
|
|
|
| 196 |
italic: false,
|
| 197 |
align: 'center',
|
| 198 |
isFixedSize: true,
|
| 199 |
+
offsetX: 321.5,
|
| 200 |
},
|
| 201 |
{
|
| 202 |
id: 'subtitle-text',
|
| 203 |
type: 'text',
|
| 204 |
+
x: 600,
|
| 205 |
y: 436.67,
|
| 206 |
width: 421,
|
| 207 |
+
height: 56,
|
| 208 |
rotation: 0,
|
| 209 |
zIndex: 3,
|
| 210 |
text: 'Documentation',
|
|
|
|
| 215 |
italic: false,
|
| 216 |
align: 'center',
|
| 217 |
isFixedSize: true,
|
| 218 |
+
offsetX: 210.5,
|
| 219 |
},
|
| 220 |
],
|
| 221 |
},
|
|
@@ -1,6 +1,7 @@
|
|
| 1 |
import { useState, useRef, useCallback } from 'react';
|
| 2 |
import { CanvasObject, CanvasSize, CanvasBgColor } from '../types/canvas.types';
|
| 3 |
import { generateId, getNextZIndex } from '../utils/canvas.utils';
|
|
|
|
| 4 |
|
| 5 |
// Distributed Omit type for union types
|
| 6 |
type DistributiveOmit<T, K extends keyof any> = T extends any ? Omit<T, K> : never;
|
|
@@ -11,12 +12,13 @@ export function useCanvasState() {
|
|
| 11 |
const [objects, setObjectsState] = useState<CanvasObject[]>([]);
|
| 12 |
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
| 13 |
const [canvasSize, setCanvasSize] = useState<CanvasSize>('1200x675');
|
| 14 |
-
const [bgColor, setBgColor] = useState<CanvasBgColor>('
|
| 15 |
|
| 16 |
// History management - store complete state snapshots
|
| 17 |
const history = useRef<CanvasObject[][]>([[]]); // Start with empty canvas
|
| 18 |
const historyIndex = useRef<number>(0);
|
| 19 |
const isApplyingHistory = useRef<boolean>(false);
|
|
|
|
| 20 |
|
| 21 |
// Set objects with history tracking
|
| 22 |
const setObjects = useCallback((newObjects: CanvasObject[]) => {
|
|
@@ -29,19 +31,48 @@ export function useCanvasState() {
|
|
| 29 |
|
| 30 |
// Use setTimeout to ensure state has been updated before recording
|
| 31 |
setTimeout(() => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
// Remove any future history if we're not at the end
|
| 33 |
if (historyIndex.current < history.current.length - 1) {
|
| 34 |
history.current = history.current.slice(0, historyIndex.current + 1);
|
| 35 |
}
|
| 36 |
|
| 37 |
-
// Deep clone
|
| 38 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
-
// Only add to history if state actually changed
|
| 41 |
-
const
|
| 42 |
-
const stateChanged = JSON.stringify(currentState) !== JSON.stringify(stateCopy);
|
| 43 |
|
| 44 |
if (stateChanged) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
history.current.push(stateCopy);
|
| 46 |
historyIndex.current++;
|
| 47 |
|
|
@@ -60,7 +91,45 @@ export function useCanvasState() {
|
|
| 60 |
isApplyingHistory.current = true;
|
| 61 |
historyIndex.current--;
|
| 62 |
const previousState = JSON.parse(JSON.stringify(history.current[historyIndex.current]));
|
| 63 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
setSelectedIds([]); // Deselect when undoing
|
| 65 |
|
| 66 |
// Reset flag after state update
|
|
@@ -76,7 +145,35 @@ export function useCanvasState() {
|
|
| 76 |
isApplyingHistory.current = true;
|
| 77 |
historyIndex.current++;
|
| 78 |
const nextState = JSON.parse(JSON.stringify(history.current[historyIndex.current]));
|
| 79 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
setSelectedIds([]); // Deselect when redoing
|
| 81 |
|
| 82 |
// Reset flag after state update
|
|
@@ -159,6 +256,11 @@ export function useCanvasState() {
|
|
| 159 |
));
|
| 160 |
};
|
| 161 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
return {
|
| 163 |
objects,
|
| 164 |
selectedIds,
|
|
@@ -176,6 +278,7 @@ export function useCanvasState() {
|
|
| 176 |
moveForward,
|
| 177 |
moveBackward,
|
| 178 |
undo,
|
| 179 |
-
redo
|
|
|
|
| 180 |
};
|
| 181 |
}
|
|
|
|
| 1 |
import { useState, useRef, useCallback } from 'react';
|
| 2 |
import { CanvasObject, CanvasSize, CanvasBgColor } from '../types/canvas.types';
|
| 3 |
import { generateId, getNextZIndex } from '../utils/canvas.utils';
|
| 4 |
+
import Konva from 'konva';
|
| 5 |
|
| 6 |
// Distributed Omit type for union types
|
| 7 |
type DistributiveOmit<T, K extends keyof any> = T extends any ? Omit<T, K> : never;
|
|
|
|
| 12 |
const [objects, setObjectsState] = useState<CanvasObject[]>([]);
|
| 13 |
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
| 14 |
const [canvasSize, setCanvasSize] = useState<CanvasSize>('1200x675');
|
| 15 |
+
const [bgColor, setBgColor] = useState<CanvasBgColor>('seriousLight');
|
| 16 |
|
| 17 |
// History management - store complete state snapshots
|
| 18 |
const history = useRef<CanvasObject[][]>([[]]); // Start with empty canvas
|
| 19 |
const historyIndex = useRef<number>(0);
|
| 20 |
const isApplyingHistory = useRef<boolean>(false);
|
| 21 |
+
const skipHistoryRecording = useRef<boolean>(false); // Flag to skip history for text editing
|
| 22 |
|
| 23 |
// Set objects with history tracking
|
| 24 |
const setObjects = useCallback((newObjects: CanvasObject[]) => {
|
|
|
|
| 31 |
|
| 32 |
// Use setTimeout to ensure state has been updated before recording
|
| 33 |
setTimeout(() => {
|
| 34 |
+
// Check skip flag inside setTimeout to catch updates
|
| 35 |
+
if (skipHistoryRecording.current) {
|
| 36 |
+
return;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
// Remove any future history if we're not at the end
|
| 40 |
if (historyIndex.current < history.current.length - 1) {
|
| 41 |
history.current = history.current.slice(0, historyIndex.current + 1);
|
| 42 |
}
|
| 43 |
|
| 44 |
+
// Deep clone and normalize state (remove isEditing and isFixedSize flags for comparison)
|
| 45 |
+
const normalizeState = (state: CanvasObject[]) => {
|
| 46 |
+
return state.map((obj: CanvasObject) => {
|
| 47 |
+
if (obj.type === 'text') {
|
| 48 |
+
const { isEditing, isFixedSize, ...rest } = obj as any;
|
| 49 |
+
return rest;
|
| 50 |
+
}
|
| 51 |
+
return obj;
|
| 52 |
+
});
|
| 53 |
+
};
|
| 54 |
+
|
| 55 |
+
const normalizedNewState = normalizeState(newObjects);
|
| 56 |
+
const normalizedCurrentState = history.current[historyIndex.current]
|
| 57 |
+
? normalizeState(history.current[historyIndex.current])
|
| 58 |
+
: [];
|
| 59 |
|
| 60 |
+
// Only add to history if state actually changed (ignoring isEditing/isFixedSize)
|
| 61 |
+
const stateChanged = JSON.stringify(normalizedCurrentState) !== JSON.stringify(normalizedNewState);
|
|
|
|
| 62 |
|
| 63 |
if (stateChanged) {
|
| 64 |
+
// Store the normalized state (without isEditing/isFixedSize)
|
| 65 |
+
const stateCopy = JSON.parse(JSON.stringify(normalizedNewState));
|
| 66 |
+
|
| 67 |
+
console.log('Recording history:', {
|
| 68 |
+
textObjects: stateCopy.filter((obj: any) => obj.type === 'text').map((obj: any) => ({
|
| 69 |
+
text: obj.text,
|
| 70 |
+
width: obj.width,
|
| 71 |
+
height: obj.height,
|
| 72 |
+
fontSize: obj.fontSize
|
| 73 |
+
}))
|
| 74 |
+
});
|
| 75 |
+
|
| 76 |
history.current.push(stateCopy);
|
| 77 |
historyIndex.current++;
|
| 78 |
|
|
|
|
| 91 |
isApplyingHistory.current = true;
|
| 92 |
historyIndex.current--;
|
| 93 |
const previousState = JSON.parse(JSON.stringify(history.current[historyIndex.current]));
|
| 94 |
+
|
| 95 |
+
// Recalculate text dimensions for text objects to fix clipping issues
|
| 96 |
+
const recalculatedState = previousState.map((obj: CanvasObject) => {
|
| 97 |
+
if (obj.type === 'text' && obj.text) {
|
| 98 |
+
try {
|
| 99 |
+
const Konva = require('konva').default;
|
| 100 |
+
const tempText = new Konva.Text({
|
| 101 |
+
text: obj.text,
|
| 102 |
+
fontSize: obj.fontSize,
|
| 103 |
+
fontFamily: obj.fontFamily,
|
| 104 |
+
fontStyle: `${obj.bold ? 'bold' : 'normal'} ${obj.italic ? 'italic' : ''}`
|
| 105 |
+
});
|
| 106 |
+
|
| 107 |
+
const newWidth = Math.max(100, tempText.width() + 20);
|
| 108 |
+
const newHeight = Math.max(40, tempText.height() + 10);
|
| 109 |
+
|
| 110 |
+
console.log('UNDO - Recalculating text:', {
|
| 111 |
+
text: obj.text,
|
| 112 |
+
oldWidth: obj.width,
|
| 113 |
+
oldHeight: obj.height,
|
| 114 |
+
newWidth,
|
| 115 |
+
newHeight,
|
| 116 |
+
fontSize: obj.fontSize,
|
| 117 |
+
fontFamily: obj.fontFamily
|
| 118 |
+
});
|
| 119 |
+
|
| 120 |
+
tempText.destroy();
|
| 121 |
+
|
| 122 |
+
// Always recalculate to ensure proper fit
|
| 123 |
+
return { ...obj, width: newWidth, height: newHeight, isEditing: false };
|
| 124 |
+
} catch (error) {
|
| 125 |
+
console.error('Error recalculating text dimensions:', error);
|
| 126 |
+
return obj;
|
| 127 |
+
}
|
| 128 |
+
}
|
| 129 |
+
return obj;
|
| 130 |
+
});
|
| 131 |
+
|
| 132 |
+
setObjectsState(recalculatedState);
|
| 133 |
setSelectedIds([]); // Deselect when undoing
|
| 134 |
|
| 135 |
// Reset flag after state update
|
|
|
|
| 145 |
isApplyingHistory.current = true;
|
| 146 |
historyIndex.current++;
|
| 147 |
const nextState = JSON.parse(JSON.stringify(history.current[historyIndex.current]));
|
| 148 |
+
|
| 149 |
+
// Recalculate text dimensions for text objects to fix clipping issues
|
| 150 |
+
const recalculatedState = nextState.map((obj: CanvasObject) => {
|
| 151 |
+
if (obj.type === 'text' && obj.text) {
|
| 152 |
+
try {
|
| 153 |
+
const Konva = require('konva').default;
|
| 154 |
+
const tempText = new Konva.Text({
|
| 155 |
+
text: obj.text,
|
| 156 |
+
fontSize: obj.fontSize,
|
| 157 |
+
fontFamily: obj.fontFamily,
|
| 158 |
+
fontStyle: `${obj.bold ? 'bold' : 'normal'} ${obj.italic ? 'italic' : ''}`
|
| 159 |
+
});
|
| 160 |
+
|
| 161 |
+
const newWidth = Math.max(100, tempText.width() + 20);
|
| 162 |
+
const newHeight = Math.max(40, tempText.height() + 10);
|
| 163 |
+
|
| 164 |
+
tempText.destroy();
|
| 165 |
+
|
| 166 |
+
// Always recalculate to ensure proper fit
|
| 167 |
+
return { ...obj, width: newWidth, height: newHeight, isEditing: false };
|
| 168 |
+
} catch (error) {
|
| 169 |
+
console.error('Error recalculating text dimensions:', error);
|
| 170 |
+
return obj;
|
| 171 |
+
}
|
| 172 |
+
}
|
| 173 |
+
return obj;
|
| 174 |
+
});
|
| 175 |
+
|
| 176 |
+
setObjectsState(recalculatedState);
|
| 177 |
setSelectedIds([]); // Deselect when redoing
|
| 178 |
|
| 179 |
// Reset flag after state update
|
|
|
|
| 256 |
));
|
| 257 |
};
|
| 258 |
|
| 259 |
+
// Enable/disable history recording (for text editing)
|
| 260 |
+
const setSkipHistoryRecording = useCallback((skip: boolean) => {
|
| 261 |
+
skipHistoryRecording.current = skip;
|
| 262 |
+
}, []);
|
| 263 |
+
|
| 264 |
return {
|
| 265 |
objects,
|
| 266 |
selectedIds,
|
|
|
|
| 278 |
moveForward,
|
| 279 |
moveBackward,
|
| 280 |
undo,
|
| 281 |
+
redo,
|
| 282 |
+
setSkipHistoryRecording
|
| 283 |
};
|
| 284 |
}
|
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect } from 'react';
|
| 2 |
+
|
| 3 |
+
interface ViewportScale {
|
| 4 |
+
scale: number;
|
| 5 |
+
canFit: boolean;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
/**
|
| 9 |
+
* Custom hook to calculate canvas scale factor based on viewport size
|
| 10 |
+
* Returns scale factor to fit canvas within viewport with padding
|
| 11 |
+
*/
|
| 12 |
+
export function useViewportScale(canvasWidth: number, canvasHeight: number): ViewportScale {
|
| 13 |
+
const [scale, setScale] = useState(1);
|
| 14 |
+
const [canFit, setCanFit] = useState(true);
|
| 15 |
+
|
| 16 |
+
useEffect(() => {
|
| 17 |
+
const calculateScale = () => {
|
| 18 |
+
// Get viewport dimensions
|
| 19 |
+
const viewportWidth = window.innerWidth;
|
| 20 |
+
const viewportHeight = window.innerHeight;
|
| 21 |
+
|
| 22 |
+
// Define padding/margins to ensure canvas doesn't touch edges
|
| 23 |
+
// Account for sidebar (107px), padding (40px each side), header space (~100px)
|
| 24 |
+
const horizontalPadding = 107 + 80; // sidebar + left/right padding
|
| 25 |
+
const verticalPadding = 200; // top/bottom space for header, toolbar, etc.
|
| 26 |
+
|
| 27 |
+
// Calculate available space
|
| 28 |
+
const availableWidth = viewportWidth - horizontalPadding;
|
| 29 |
+
const availableHeight = viewportHeight - verticalPadding;
|
| 30 |
+
|
| 31 |
+
// Calculate scale factors for width and height
|
| 32 |
+
const scaleX = availableWidth / canvasWidth;
|
| 33 |
+
const scaleY = availableHeight / canvasHeight;
|
| 34 |
+
|
| 35 |
+
// Use the smaller scale to ensure canvas fits in both dimensions
|
| 36 |
+
const calculatedScale = Math.min(scaleX, scaleY, 1); // Never scale up, max 1
|
| 37 |
+
|
| 38 |
+
// Determine if canvas can fit at full size
|
| 39 |
+
const fits = calculatedScale >= 1;
|
| 40 |
+
|
| 41 |
+
setScale(calculatedScale);
|
| 42 |
+
setCanFit(fits);
|
| 43 |
+
};
|
| 44 |
+
|
| 45 |
+
// Calculate on mount
|
| 46 |
+
calculateScale();
|
| 47 |
+
|
| 48 |
+
// Recalculate on window resize
|
| 49 |
+
window.addEventListener('resize', calculateScale);
|
| 50 |
+
|
| 51 |
+
return () => {
|
| 52 |
+
window.removeEventListener('resize', calculateScale);
|
| 53 |
+
};
|
| 54 |
+
}, [canvasWidth, canvasHeight]);
|
| 55 |
+
|
| 56 |
+
return { scale, canFit };
|
| 57 |
+
}
|
|
@@ -25,9 +25,9 @@ body {
|
|
| 25 |
|
| 26 |
/* Dotted background pattern */
|
| 27 |
.dotted-background {
|
| 28 |
-
background-image: radial-gradient(circle, #d1d5db
|
| 29 |
-
background-size:
|
| 30 |
-
background-position: 0 0
|
| 31 |
}
|
| 32 |
|
| 33 |
/* Custom thin scrollbar for Huggy menu */
|
|
|
|
| 25 |
|
| 26 |
/* Dotted background pattern */
|
| 27 |
.dotted-background {
|
| 28 |
+
background-image: radial-gradient(circle, #d1d5db 1.25px, transparent 1.25px);
|
| 29 |
+
background-size: 28.75px 28.75px;
|
| 30 |
+
background-position: 0 0;
|
| 31 |
}
|
| 32 |
|
| 33 |
/* Custom thin scrollbar for Huggy menu */
|
|
@@ -57,7 +57,7 @@ export type CanvasObject = RectObject | ImageObject | HuggyObject | TextObject |
|
|
| 57 |
export type CanvasSize = '1200x675' | 'linkedin' | 'hf';
|
| 58 |
|
| 59 |
// Canvas background type
|
| 60 |
-
export type CanvasBgColor = 'light' | 'dark';
|
| 61 |
|
| 62 |
// Logo placeholder object with blur effect
|
| 63 |
export interface LogoPlaceholderObject extends CanvasObjectBase {
|
|
|
|
| 57 |
export type CanvasSize = '1200x675' | 'linkedin' | 'hf';
|
| 58 |
|
| 59 |
// Canvas background type
|
| 60 |
+
export type CanvasBgColor = 'seriousLight' | 'light' | 'dark';
|
| 61 |
|
| 62 |
// Logo placeholder object with blur effect
|
| 63 |
export interface LogoPlaceholderObject extends CanvasObjectBase {
|