@merchi/product-editor 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -0
- package/README.md +174 -0
- package/dist/components/AlignMenu.d.ts +3 -0
- package/dist/components/AlignMenu.js +61 -0
- package/dist/components/FloatingToolbar.d.ts +4 -0
- package/dist/components/FloatingToolbar.js +97 -0
- package/dist/components/FormatButtons.d.ts +2 -0
- package/dist/components/FormatButtons.js +22 -0
- package/dist/components/ImageNavButton.d.ts +7 -0
- package/dist/components/ImageNavButton.js +14 -0
- package/dist/components/ImageZoomModal.d.ts +12 -0
- package/dist/components/ImageZoomModal.js +129 -0
- package/dist/components/LayerPanel.d.ts +4 -0
- package/dist/components/LayerPanel.js +223 -0
- package/dist/components/LoadingOverlay.d.ts +9 -0
- package/dist/components/LoadingOverlay.js +17 -0
- package/dist/components/ProductEditor.d.ts +4 -0
- package/dist/components/ProductEditor.js +67 -0
- package/dist/components/ProductImageGallery.d.ts +6 -0
- package/dist/components/ProductImageGallery.js +148 -0
- package/dist/components/ProductPreviews.d.ts +7 -0
- package/dist/components/ProductPreviews.js +281 -0
- package/dist/components/TextToolbar.d.ts +4 -0
- package/dist/components/TextToolbar.js +117 -0
- package/dist/components/Toolbar.d.ts +3 -0
- package/dist/components/Toolbar.js +47 -0
- package/dist/config/colorConfig.d.ts +1 -0
- package/dist/config/colorConfig.js +10 -0
- package/dist/config/fontConfig.d.ts +5 -0
- package/dist/config/fontConfig.js +21 -0
- package/dist/context/ProductEditorContext.d.ts +60 -0
- package/dist/context/ProductEditorContext.js +743 -0
- package/dist/hooks/useSwipeNavigate.d.ts +11 -0
- package/dist/hooks/useSwipeNavigate.js +35 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +30 -0
- package/dist/styles/BottomPreviewDisplay.css +65 -0
- package/dist/styles/FloatingToolbar.css +41 -0
- package/dist/styles/ImageGallery.css +147 -0
- package/dist/styles/ImageNavButton.css +35 -0
- package/dist/styles/ImageZoomModal.css +101 -0
- package/dist/styles/LayerPanel.css +179 -0
- package/dist/styles/LoadingOverlay.css +48 -0
- package/dist/styles/ProductEditor.css +192 -0
- package/dist/styles/TextToolbar.css +327 -0
- package/dist/styles/Toolbar.css +125 -0
- package/dist/styles/index.css +5 -0
- package/dist/styles/textToolbarSelectStyles.d.ts +3 -0
- package/dist/styles/textToolbarSelectStyles.js +30 -0
- package/dist/types.d.ts +228 -0
- package/dist/types.js +17 -0
- package/dist/utils/canvasAddText.d.ts +11 -0
- package/dist/utils/canvasAddText.js +66 -0
- package/dist/utils/canvasUtils.d.ts +37 -0
- package/dist/utils/canvasUtils.js +342 -0
- package/dist/utils/deviceUtils.d.ts +5 -0
- package/dist/utils/deviceUtils.js +33 -0
- package/dist/utils/draftTemplateUtils.d.ts +23 -0
- package/dist/utils/draftTemplateUtils.js +66 -0
- package/dist/utils/grid.d.ts +32 -0
- package/dist/utils/grid.js +151 -0
- package/dist/utils/imageUtils.d.ts +12 -0
- package/dist/utils/imageUtils.js +178 -0
- package/dist/utils/job.d.ts +40 -0
- package/dist/utils/job.js +115 -0
- package/dist/utils/keyboard.d.ts +1 -0
- package/dist/utils/keyboard.js +26 -0
- package/dist/utils/previewUtils.d.ts +9 -0
- package/dist/utils/previewUtils.js +39 -0
- package/dist/utils/psdConverter.d.ts +40 -0
- package/dist/utils/psdConverter.js +516 -0
- package/dist/utils/psdRenderUtils.d.ts +12 -0
- package/dist/utils/psdRenderUtils.js +465 -0
- package/dist/utils/renderUtils.d.ts +2 -0
- package/dist/utils/renderUtils.js +85 -0
- package/package.json +66 -0
- package/src/components/AlignMenu.tsx +49 -0
- package/src/components/FloatingToolbar.tsx +83 -0
- package/src/components/FormatButtons.tsx +31 -0
- package/src/components/ImageNavButton.tsx +23 -0
- package/src/components/ImageZoomModal.tsx +163 -0
- package/src/components/LayerPanel.tsx +256 -0
- package/src/components/LoadingOverlay.tsx +27 -0
- package/src/components/ProductEditor.tsx +104 -0
- package/src/components/ProductImageGallery.tsx +181 -0
- package/src/components/ProductPreviews.tsx +243 -0
- package/src/components/TextToolbar.tsx +164 -0
- package/src/components/Toolbar.tsx +86 -0
- package/src/config/colorConfig.ts +7 -0
- package/src/config/fontConfig.ts +23 -0
- package/src/context/ProductEditorContext.tsx +829 -0
- package/src/hooks/useSwipeNavigate.ts +40 -0
- package/src/index.ts +6 -0
- package/src/styles/BottomPreviewDisplay.css +65 -0
- package/src/styles/FloatingToolbar.css +41 -0
- package/src/styles/ImageGallery.css +147 -0
- package/src/styles/ImageNavButton.css +35 -0
- package/src/styles/ImageZoomModal.css +101 -0
- package/src/styles/LayerPanel.css +179 -0
- package/src/styles/LoadingOverlay.css +48 -0
- package/src/styles/ProductEditor.css +192 -0
- package/src/styles/TextToolbar.css +327 -0
- package/src/styles/Toolbar.css +125 -0
- package/src/styles/index.css +5 -0
- package/src/styles/textToolbarSelectStyles.ts +60 -0
- package/src/types/ag-psd/index.d.ts +1 -0
- package/src/types/fabric.d.ts +14 -0
- package/src/types/lodash/index.d.ts +1 -0
- package/src/types/psd.d.ts +53 -0
- package/src/types.ts +247 -0
- package/src/utils/canvasAddText.ts +72 -0
- package/src/utils/canvasUtils.ts +406 -0
- package/src/utils/deviceUtils.ts +31 -0
- package/src/utils/draftTemplateUtils.ts +70 -0
- package/src/utils/grid.ts +154 -0
- package/src/utils/imageUtils.ts +218 -0
- package/src/utils/job.ts +128 -0
- package/src/utils/keyboard.ts +28 -0
- package/src/utils/previewUtils.ts +43 -0
- package/src/utils/psdConverter.ts +595 -0
- package/src/utils/psdRenderUtils.ts +453 -0
- package/src/utils/renderUtils.ts +44 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import React, { useState, useRef, useEffect } from 'react';
|
|
2
|
+
import Toolbar from './Toolbar';
|
|
3
|
+
import '../styles/FloatingToolbar.css';
|
|
4
|
+
const FloatingToolbar: React.FC = () => {
|
|
5
|
+
const [position, setPosition] = useState({ x: 15, y: 15 });
|
|
6
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
7
|
+
const toolbarRef = useRef<HTMLDivElement>(null);
|
|
8
|
+
const offset = useRef({ x: 0, y: 0 });
|
|
9
|
+
|
|
10
|
+
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
11
|
+
if (!toolbarRef.current) return;
|
|
12
|
+
// Prevent dragging if clicking on buttons inside the toolbar
|
|
13
|
+
if ((e.target as HTMLElement).closest('.toolbar-button')) {
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
setIsDragging(true);
|
|
17
|
+
const rect = toolbarRef.current.getBoundingClientRect();
|
|
18
|
+
offset.current = {
|
|
19
|
+
x: e.clientX - rect.left,
|
|
20
|
+
y: e.clientY - rect.top,
|
|
21
|
+
};
|
|
22
|
+
if (toolbarRef.current) {
|
|
23
|
+
toolbarRef.current.style.cursor = 'grabbing';
|
|
24
|
+
}
|
|
25
|
+
e.preventDefault();
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const handleMouseMove = (e: MouseEvent) => {
|
|
29
|
+
if (!isDragging || !toolbarRef.current) return;
|
|
30
|
+
let newX = e.clientX - offset.current.x;
|
|
31
|
+
let newY = e.clientY - offset.current.y;
|
|
32
|
+
|
|
33
|
+
const parent = toolbarRef.current.parentElement;
|
|
34
|
+
if (parent) {
|
|
35
|
+
const parentRect = parent.getBoundingClientRect();
|
|
36
|
+
const toolbarRect = toolbarRef.current.getBoundingClientRect();
|
|
37
|
+
|
|
38
|
+
newX = Math.max(parentRect.left, Math.min(newX, parentRect.right - toolbarRect.width));
|
|
39
|
+
newY = Math.max(parentRect.top, Math.min(newY, parentRect.bottom - toolbarRect.height));
|
|
40
|
+
newX -= parentRect.left;
|
|
41
|
+
newY -= parentRect.top;
|
|
42
|
+
}
|
|
43
|
+
setPosition({ x: newX, y: newY });
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const handleMouseUp = () => {
|
|
47
|
+
setIsDragging(false);
|
|
48
|
+
if (toolbarRef.current) {
|
|
49
|
+
toolbarRef.current.style.cursor = 'grab';
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (isDragging) {
|
|
55
|
+
window.addEventListener('mousemove', handleMouseMove);
|
|
56
|
+
window.addEventListener('mouseup', handleMouseUp);
|
|
57
|
+
} else {
|
|
58
|
+
window.removeEventListener('mousemove', handleMouseMove);
|
|
59
|
+
window.removeEventListener('mouseup', handleMouseUp);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return () => {
|
|
63
|
+
window.removeEventListener('mousemove', handleMouseMove);
|
|
64
|
+
window.removeEventListener('mouseup', handleMouseUp);
|
|
65
|
+
};
|
|
66
|
+
}, [isDragging]);
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<div
|
|
70
|
+
ref={toolbarRef}
|
|
71
|
+
className="floating-toolbar"
|
|
72
|
+
style={{
|
|
73
|
+
left: `${position.x}px`,
|
|
74
|
+
top: `${position.y}px`,
|
|
75
|
+
}}
|
|
76
|
+
onMouseDown={handleMouseDown}
|
|
77
|
+
>
|
|
78
|
+
<Toolbar />
|
|
79
|
+
</div>
|
|
80
|
+
);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export default FloatingToolbar;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { useProductEditor } from '../context/ProductEditorContext';
|
|
3
|
+
|
|
4
|
+
export const FormatButtons: React.FC = () => {
|
|
5
|
+
const { selectedTextObject, updateSelectedText } = useProductEditor();
|
|
6
|
+
if (!selectedTextObject || !updateSelectedText) return null;
|
|
7
|
+
|
|
8
|
+
const isBold = selectedTextObject.fontWeight === 'bold';
|
|
9
|
+
const isUnderlined = selectedTextObject.underline === true;
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<>
|
|
13
|
+
<button
|
|
14
|
+
type="button"
|
|
15
|
+
className={`toolbar-button bold-button ${isBold ? 'active' : ''}`}
|
|
16
|
+
onClick={() => updateSelectedText({ fontWeight: isBold ? 'normal' : 'bold' })}
|
|
17
|
+
title="Bold"
|
|
18
|
+
>
|
|
19
|
+
<strong>B</strong>
|
|
20
|
+
</button>
|
|
21
|
+
<button
|
|
22
|
+
type="button"
|
|
23
|
+
className={`toolbar-button underline-button ${isUnderlined ? 'active' : ''}`}
|
|
24
|
+
onClick={() => updateSelectedText({ underline: !isUnderlined })}
|
|
25
|
+
title="Underline"
|
|
26
|
+
>
|
|
27
|
+
<strong><u>U</u></strong>
|
|
28
|
+
</button>
|
|
29
|
+
</>
|
|
30
|
+
);
|
|
31
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { FormPrevious, FormNext } from "grommet-icons";
|
|
3
|
+
|
|
4
|
+
interface ImageNavButtonProps {
|
|
5
|
+
direction: 'left' | 'right';
|
|
6
|
+
onClick: () => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const ImageNavButton = ({ direction, onClick }: ImageNavButtonProps) => (
|
|
10
|
+
<button
|
|
11
|
+
onClick={onClick}
|
|
12
|
+
className={`nav-button ${direction}`}
|
|
13
|
+
aria-label={direction === 'left' ? 'Previous image' : 'Next image'}
|
|
14
|
+
type="button"
|
|
15
|
+
>
|
|
16
|
+
{direction === 'left'
|
|
17
|
+
? <FormPrevious className="nav-button-icon" color="#6b7280" />
|
|
18
|
+
: <FormNext className="nav-button-icon" color="#6b7280" />
|
|
19
|
+
}
|
|
20
|
+
</button>
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
export default ImageNavButton;
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import React, { useEffect, useState } from "react";
|
|
2
|
+
import { createPortal } from "react-dom";
|
|
3
|
+
import { Close, FormPrevious, FormNext } from "grommet-icons";
|
|
4
|
+
import { motion, AnimatePresence, type Variants } from "framer-motion";
|
|
5
|
+
import { useSwipeNavigate } from "../hooks/useSwipeNavigate";
|
|
6
|
+
|
|
7
|
+
const MODAL_BODY_CLASS = 'has-preview-modal-open';
|
|
8
|
+
|
|
9
|
+
interface ImageZoomModalProps {
|
|
10
|
+
isOpen: boolean;
|
|
11
|
+
onClose: () => void;
|
|
12
|
+
imageUrl: string;
|
|
13
|
+
productName: string;
|
|
14
|
+
totalImages: number;
|
|
15
|
+
currentIndex: number;
|
|
16
|
+
allImages: any[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const ImageZoomModal = ({
|
|
20
|
+
isOpen,
|
|
21
|
+
onClose,
|
|
22
|
+
imageUrl,
|
|
23
|
+
productName,
|
|
24
|
+
totalImages,
|
|
25
|
+
currentIndex,
|
|
26
|
+
allImages
|
|
27
|
+
}: ImageZoomModalProps) => {
|
|
28
|
+
const [modalImageIndex, setModalImageIndex] = useState(currentIndex);
|
|
29
|
+
const [currentImageUrl, setCurrentImageUrl] = useState(imageUrl);
|
|
30
|
+
const [slideDirection, setSlideDirection] = useState<'left' | 'right'>('right');
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (isOpen) {
|
|
34
|
+
document.body.style.overflow = 'hidden';
|
|
35
|
+
document.body.classList.add(MODAL_BODY_CLASS);
|
|
36
|
+
document.documentElement.classList.add(MODAL_BODY_CLASS);
|
|
37
|
+
} else {
|
|
38
|
+
document.body.style.overflow = '';
|
|
39
|
+
document.body.classList.remove(MODAL_BODY_CLASS);
|
|
40
|
+
document.documentElement.classList.remove(MODAL_BODY_CLASS);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return () => {
|
|
44
|
+
document.body.style.overflow = '';
|
|
45
|
+
document.body.classList.remove(MODAL_BODY_CLASS);
|
|
46
|
+
document.documentElement.classList.remove(MODAL_BODY_CLASS);
|
|
47
|
+
};
|
|
48
|
+
}, [isOpen]);
|
|
49
|
+
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
setModalImageIndex(currentIndex);
|
|
52
|
+
setCurrentImageUrl(imageUrl);
|
|
53
|
+
setSlideDirection('right');
|
|
54
|
+
}, [currentIndex, imageUrl, isOpen]);
|
|
55
|
+
|
|
56
|
+
const handlePrevious = () => {
|
|
57
|
+
setSlideDirection('left');
|
|
58
|
+
setModalImageIndex((prev) => {
|
|
59
|
+
const newIndex = (prev - 1 + totalImages) % totalImages;
|
|
60
|
+
setCurrentImageUrl(allImages[newIndex].viewUrl);
|
|
61
|
+
return newIndex;
|
|
62
|
+
});
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const handleNext = () => {
|
|
66
|
+
setSlideDirection('right');
|
|
67
|
+
setModalImageIndex((prev) => {
|
|
68
|
+
const newIndex = (prev + 1) % totalImages;
|
|
69
|
+
setCurrentImageUrl(allImages[newIndex].viewUrl);
|
|
70
|
+
return newIndex;
|
|
71
|
+
});
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const swipeHandlers = useSwipeNavigate({
|
|
75
|
+
onSwipeLeft: handleNext,
|
|
76
|
+
onSwipeRight: handlePrevious,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const slideVariants: Variants = {
|
|
80
|
+
enter: (direction: 'left' | 'right') => ({
|
|
81
|
+
x: direction === 'right' ? 1000 : -1000,
|
|
82
|
+
opacity: 0
|
|
83
|
+
}),
|
|
84
|
+
center: {
|
|
85
|
+
zIndex: 1,
|
|
86
|
+
x: 0,
|
|
87
|
+
opacity: 1,
|
|
88
|
+
transition: {
|
|
89
|
+
x: { type: "spring", stiffness: 300, damping: 30 },
|
|
90
|
+
opacity: { duration: 0.2 }
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
exit: (direction: 'left' | 'right') => ({
|
|
94
|
+
zIndex: 0,
|
|
95
|
+
x: direction === 'left' ? 1000 : -1000,
|
|
96
|
+
opacity: 0,
|
|
97
|
+
transition: {
|
|
98
|
+
x: { type: "spring", stiffness: 300, damping: 30 },
|
|
99
|
+
opacity: { duration: 0.05 }
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
if (!isOpen) return null;
|
|
105
|
+
|
|
106
|
+
// Render the modal directly at the document body level
|
|
107
|
+
return createPortal(
|
|
108
|
+
<div className="zoom-modal">
|
|
109
|
+
<div className="zoom-modal-container">
|
|
110
|
+
<button
|
|
111
|
+
onClick={onClose}
|
|
112
|
+
className="close-button"
|
|
113
|
+
type="button"
|
|
114
|
+
>
|
|
115
|
+
<Close width={36} height={36} color="white" />
|
|
116
|
+
</button>
|
|
117
|
+
|
|
118
|
+
{totalImages > 1 && (
|
|
119
|
+
<button
|
|
120
|
+
onClick={handlePrevious}
|
|
121
|
+
className="nav-button-zoom left"
|
|
122
|
+
type="button"
|
|
123
|
+
>
|
|
124
|
+
<FormPrevious width={42} height={42} color="white" />
|
|
125
|
+
</button>
|
|
126
|
+
)}
|
|
127
|
+
|
|
128
|
+
<div
|
|
129
|
+
className="zoom-image-container"
|
|
130
|
+
{...swipeHandlers}
|
|
131
|
+
>
|
|
132
|
+
<AnimatePresence initial={false} custom={slideDirection}>
|
|
133
|
+
<motion.img
|
|
134
|
+
key={modalImageIndex}
|
|
135
|
+
src={currentImageUrl}
|
|
136
|
+
alt={productName}
|
|
137
|
+
className="zoom-image"
|
|
138
|
+
loading="lazy"
|
|
139
|
+
custom={slideDirection}
|
|
140
|
+
variants={slideVariants}
|
|
141
|
+
initial="enter"
|
|
142
|
+
animate="center"
|
|
143
|
+
exit="exit"
|
|
144
|
+
/>
|
|
145
|
+
</AnimatePresence>
|
|
146
|
+
</div>
|
|
147
|
+
|
|
148
|
+
{totalImages > 1 && (
|
|
149
|
+
<button
|
|
150
|
+
onClick={handleNext}
|
|
151
|
+
className="nav-button-zoom right"
|
|
152
|
+
type="button"
|
|
153
|
+
>
|
|
154
|
+
<FormNext width={42} height={42} color="white" />
|
|
155
|
+
</button>
|
|
156
|
+
)}
|
|
157
|
+
</div>
|
|
158
|
+
</div>,
|
|
159
|
+
document.body
|
|
160
|
+
);
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
export default ImageZoomModal;
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import React, { useState, useEffect, useRef } from 'react';
|
|
2
|
+
import { fabric } from 'fabric';
|
|
3
|
+
import { useProductEditor } from '../context/ProductEditorContext';
|
|
4
|
+
import { Close, Drag, TextAlignCenter, Image as ImageIcon, Up, Down } from 'grommet-icons';
|
|
5
|
+
import '../styles/LayerPanel.css';
|
|
6
|
+
|
|
7
|
+
const reorder = (list: any[], startIndex: number, endIndex: number) => {
|
|
8
|
+
const result = Array.from(list);
|
|
9
|
+
const [removed] = result.splice(startIndex, 1);
|
|
10
|
+
result.splice(endIndex, 0, removed);
|
|
11
|
+
return result;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const LAYER_ITEM_HEIGHT_PX = 45;
|
|
15
|
+
|
|
16
|
+
const LayerPanel: React.FC = () => {
|
|
17
|
+
const { canvas, toggleLayerPanel, selectedObjectId, selectObject, isMobileView } = useProductEditor();
|
|
18
|
+
const [layers, setLayers] = useState<fabric.Object[]>([]);
|
|
19
|
+
const dragItemIndex = useRef<number | null>(null);
|
|
20
|
+
const dragOverItemIndex = useRef<number | null>(null);
|
|
21
|
+
const [isDraggingOver, setIsDraggingOver] = useState(false);
|
|
22
|
+
const [dragOverIndexVisual, setDragOverIndexVisual] = useState<number | null>(null);
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (canvas) {
|
|
26
|
+
const updateLayers = () => {
|
|
27
|
+
const filteredObjects = canvas.getObjects().filter(obj =>
|
|
28
|
+
(obj.type === 'i-text' || obj.type === 'image') && obj.selectable
|
|
29
|
+
);
|
|
30
|
+
setLayers([...filteredObjects].reverse());
|
|
31
|
+
};
|
|
32
|
+
updateLayers();
|
|
33
|
+
|
|
34
|
+
const handleCanvasChange = () => updateLayers();
|
|
35
|
+
canvas.on('object:added', handleCanvasChange);
|
|
36
|
+
canvas.on('object:removed', handleCanvasChange);
|
|
37
|
+
canvas.on('stack:changed', handleCanvasChange);
|
|
38
|
+
canvas.on('selection:created', handleCanvasChange);
|
|
39
|
+
canvas.on('selection:updated', handleCanvasChange);
|
|
40
|
+
canvas.on('selection:cleared', handleCanvasChange);
|
|
41
|
+
|
|
42
|
+
return () => {
|
|
43
|
+
canvas.off('object:added', handleCanvasChange);
|
|
44
|
+
canvas.off('object:removed', handleCanvasChange);
|
|
45
|
+
canvas.off('stack:changed', handleCanvasChange);
|
|
46
|
+
canvas.off('selection:created', handleCanvasChange);
|
|
47
|
+
canvas.off('selection:updated', handleCanvasChange);
|
|
48
|
+
canvas.off('selection:cleared', handleCanvasChange);
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
}, [canvas]);
|
|
52
|
+
|
|
53
|
+
const getLayerInfo = (object: fabric.Object): { name: string; icon: React.ReactElement } => {
|
|
54
|
+
if (object.type === 'i-text') {
|
|
55
|
+
return {
|
|
56
|
+
name: (object as fabric.IText).text?.substring(0, 20) || 'Text',
|
|
57
|
+
icon: <TextAlignCenter size="medium" />
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
if (object.type === 'image') {
|
|
61
|
+
const imageObject = object as fabric.Image;
|
|
62
|
+
const src = imageObject.getSrc();
|
|
63
|
+
return {
|
|
64
|
+
name: (object as any).name || (src ? src.split('/').pop()?.substring(0, 20) : undefined) || 'Image',
|
|
65
|
+
icon: <img src={src} alt="layer thumb" className="layer-thumbnail" />
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
return { name: 'Unknown Layer', icon: <></> };
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const handleDragStart = (e: React.DragEvent<HTMLLIElement>, index: number) => {
|
|
72
|
+
if (isMobileView) return;
|
|
73
|
+
dragItemIndex.current = index;
|
|
74
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
75
|
+
setTimeout(() => setIsDraggingOver(true), 0);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const handleDragEnterItem = (e: React.DragEvent<HTMLLIElement>, index: number) => {
|
|
79
|
+
if (isMobileView) return;
|
|
80
|
+
e.preventDefault();
|
|
81
|
+
dragOverItemIndex.current = index;
|
|
82
|
+
setDragOverIndexVisual(index);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const handleDragLeaveList = (e: React.DragEvent<HTMLUListElement>) => {
|
|
86
|
+
if (isMobileView) return;
|
|
87
|
+
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
|
|
88
|
+
dragOverItemIndex.current = null;
|
|
89
|
+
setDragOverIndexVisual(null);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const handleDragOverList = (e: React.DragEvent<HTMLUListElement>) => {
|
|
94
|
+
if (isMobileView) return;
|
|
95
|
+
e.preventDefault();
|
|
96
|
+
e.dataTransfer.dropEffect = 'move';
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const handleDrop = (e: React.DragEvent<HTMLUListElement>) => {
|
|
100
|
+
if (isMobileView) return;
|
|
101
|
+
e.preventDefault();
|
|
102
|
+
const sourceIndex = dragItemIndex.current;
|
|
103
|
+
const destinationIndex = dragOverItemIndex.current;
|
|
104
|
+
|
|
105
|
+
setIsDraggingOver(false);
|
|
106
|
+
setDragOverIndexVisual(null);
|
|
107
|
+
dragItemIndex.current = null;
|
|
108
|
+
dragOverItemIndex.current = null;
|
|
109
|
+
|
|
110
|
+
if (sourceIndex === null || destinationIndex === null || sourceIndex === destinationIndex) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const reorderedLayers = reorder(layers, sourceIndex, destinationIndex);
|
|
115
|
+
|
|
116
|
+
const objectToMove = layers[sourceIndex];
|
|
117
|
+
|
|
118
|
+
setLayers(reorderedLayers);
|
|
119
|
+
|
|
120
|
+
if (canvas && objectToMove) {
|
|
121
|
+
const totalObjects = canvas.getObjects().length;
|
|
122
|
+
const fabricTargetIndex = totalObjects - 1 - destinationIndex;
|
|
123
|
+
canvas.moveTo(objectToMove, fabricTargetIndex);
|
|
124
|
+
canvas.requestRenderAll();
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const handleDragEnd = (e: React.DragEvent<HTMLLIElement>) => {
|
|
129
|
+
if (isMobileView) return;
|
|
130
|
+
setIsDraggingOver(false);
|
|
131
|
+
setDragOverIndexVisual(null);
|
|
132
|
+
dragItemIndex.current = null;
|
|
133
|
+
dragOverItemIndex.current = null;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const moveLayerUp = (index: number) => {
|
|
137
|
+
if (index <= 0 || !canvas) return;
|
|
138
|
+
|
|
139
|
+
const objectToMove = layers[index];
|
|
140
|
+
const objectAbove = layers[index - 1];
|
|
141
|
+
|
|
142
|
+
const fabricIndexOfAbove = canvas.getObjects().indexOf(objectAbove);
|
|
143
|
+
if (fabricIndexOfAbove < 0) {
|
|
144
|
+
console.error("Could not find objectAbove in canvas stack for moveLayerUp");
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const targetFabricIndex = fabricIndexOfAbove;
|
|
149
|
+
canvas.moveTo(objectToMove, targetFabricIndex);
|
|
150
|
+
setLayers(prevLayers => reorder(prevLayers, index, index - 1));
|
|
151
|
+
canvas.requestRenderAll();
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const moveLayerDown = (index: number) => {
|
|
155
|
+
if (index >= layers.length - 1 || !canvas) return;
|
|
156
|
+
|
|
157
|
+
const objectToMove = layers[index];
|
|
158
|
+
const objectBelow = layers[index + 1];
|
|
159
|
+
|
|
160
|
+
const fabricIndexOfBelow = canvas.getObjects().indexOf(objectBelow);
|
|
161
|
+
if (fabricIndexOfBelow < 0) {
|
|
162
|
+
console.error("Could not find objectBelow in canvas stack for moveLayerDown");
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const targetFabricIndex = fabricIndexOfBelow;
|
|
167
|
+
canvas.moveTo(objectToMove, targetFabricIndex);
|
|
168
|
+
setLayers(prevLayers => reorder(prevLayers, index, index + 1));
|
|
169
|
+
canvas.requestRenderAll();
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const handleLayerClick = (layer: fabric.Object) => {
|
|
173
|
+
selectObject(layer);
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
return (
|
|
177
|
+
<div className={`layer-panel`}>
|
|
178
|
+
<div className="layer-panel-header">
|
|
179
|
+
<h3>Layers</h3>
|
|
180
|
+
<button onClick={toggleLayerPanel} className="close-button" title="Close Panel">
|
|
181
|
+
<Close size="small" />
|
|
182
|
+
</button>
|
|
183
|
+
</div>
|
|
184
|
+
<ul
|
|
185
|
+
className={`layer-list ${isDraggingOver ? 'is-dragging-active' : ''}`}
|
|
186
|
+
onDragOver={handleDragOverList}
|
|
187
|
+
onDrop={handleDrop}
|
|
188
|
+
onDragLeave={handleDragLeaveList}
|
|
189
|
+
>
|
|
190
|
+
{layers.map((layer, index) => {
|
|
191
|
+
const layerInfo = getLayerInfo(layer);
|
|
192
|
+
const layerId = (layer as any).id;
|
|
193
|
+
const isSelected = layerId && layerId === selectedObjectId;
|
|
194
|
+
const isDraggingThisItem = !isMobileView && isDraggingOver && dragItemIndex.current === index;
|
|
195
|
+
|
|
196
|
+
let transformStyle = 'translateY(0px)';
|
|
197
|
+
if (!isMobileView && isDraggingOver && dragItemIndex.current !== null && dragOverIndexVisual !== null && !isDraggingThisItem) {
|
|
198
|
+
const draggingFromIndex = dragItemIndex.current;
|
|
199
|
+
const hoveringOverIndex = dragOverIndexVisual;
|
|
200
|
+
|
|
201
|
+
if (index > draggingFromIndex && index <= hoveringOverIndex) {
|
|
202
|
+
transformStyle = `translateY(-${LAYER_ITEM_HEIGHT_PX}px)`;
|
|
203
|
+
}
|
|
204
|
+
else if (index < draggingFromIndex && index >= hoveringOverIndex) {
|
|
205
|
+
transformStyle = `translateY(${LAYER_ITEM_HEIGHT_PX}px)`;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return (
|
|
210
|
+
<li
|
|
211
|
+
key={`layer-${layerId || index}`}
|
|
212
|
+
draggable={!isMobileView}
|
|
213
|
+
onDragStart={(e) => handleDragStart(e, index)}
|
|
214
|
+
onDragEnter={(e) => handleDragEnterItem(e, index)}
|
|
215
|
+
onDragEnd={handleDragEnd}
|
|
216
|
+
onClick={() => handleLayerClick(layer)}
|
|
217
|
+
className={`layer-item ${isSelected ? 'selected' : ''} ${isDraggingThisItem ? 'is-dragging-item' : ''}`}
|
|
218
|
+
style={{ transform: transformStyle }}
|
|
219
|
+
>
|
|
220
|
+
<span className="layer-icon">{layerInfo.icon}</span>
|
|
221
|
+
<span className="layer-name">{layerInfo.name}</span>
|
|
222
|
+
|
|
223
|
+
{isMobileView ? (
|
|
224
|
+
<div className="layer-mobile-controls">
|
|
225
|
+
<button
|
|
226
|
+
className="layer-move-button"
|
|
227
|
+
onClick={(e) => { e.stopPropagation(); moveLayerUp(index); }}
|
|
228
|
+
disabled={index === 0}
|
|
229
|
+
title="Move Up"
|
|
230
|
+
>
|
|
231
|
+
<Up size="small" />
|
|
232
|
+
</button>
|
|
233
|
+
<button
|
|
234
|
+
className="layer-move-button"
|
|
235
|
+
onClick={(e) => { e.stopPropagation(); moveLayerDown(index); }}
|
|
236
|
+
disabled={index === layers.length - 1}
|
|
237
|
+
title="Move Down"
|
|
238
|
+
>
|
|
239
|
+
<Down size="small" />
|
|
240
|
+
</button>
|
|
241
|
+
</div>
|
|
242
|
+
) : (
|
|
243
|
+
<span className="layer-drag-indicator">
|
|
244
|
+
<Drag size="small" />
|
|
245
|
+
</span>
|
|
246
|
+
)}
|
|
247
|
+
</li>
|
|
248
|
+
);
|
|
249
|
+
})}
|
|
250
|
+
{layers.length === 0 && <li className="no-layers">No layers found</li>}
|
|
251
|
+
</ul>
|
|
252
|
+
</div>
|
|
253
|
+
);
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
export default LayerPanel;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import '../styles/LoadingOverlay.css';
|
|
3
|
+
|
|
4
|
+
interface LoadingOverlayProps {
|
|
5
|
+
isLoading: boolean;
|
|
6
|
+
message?: string;
|
|
7
|
+
className?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const LoadingOverlay: React.FC<LoadingOverlayProps> = ({
|
|
11
|
+
isLoading,
|
|
12
|
+
message = "Loading Canvas...",
|
|
13
|
+
className = ""
|
|
14
|
+
}) => {
|
|
15
|
+
if (!isLoading) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<div className={`loading-overlay ${className}`}>
|
|
21
|
+
<div className="loading-spinner"></div>
|
|
22
|
+
<p>{message}</p>
|
|
23
|
+
</div>
|
|
24
|
+
);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export default LoadingOverlay;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { useProductEditor } from '../context/ProductEditorContext';
|
|
3
|
+
import FloatingToolbar from './FloatingToolbar';
|
|
4
|
+
import ProductPreviews from './ProductPreviews';
|
|
5
|
+
import Toolbar from './Toolbar';
|
|
6
|
+
import LoadingOverlay from './LoadingOverlay';
|
|
7
|
+
import TextToolbar from './TextToolbar';
|
|
8
|
+
import LayerPanel from './LayerPanel';
|
|
9
|
+
import '../styles/ProductEditor.css';
|
|
10
|
+
|
|
11
|
+
const ProductEditor: React.FC = () => {
|
|
12
|
+
const {
|
|
13
|
+
canvasRef,
|
|
14
|
+
draftTemplates,
|
|
15
|
+
groupIndex,
|
|
16
|
+
handleTemplateChange,
|
|
17
|
+
isMobileView,
|
|
18
|
+
selectedTemplate,
|
|
19
|
+
showPreview,
|
|
20
|
+
inputName,
|
|
21
|
+
isCanvasLoading,
|
|
22
|
+
hookForm,
|
|
23
|
+
selectedTextObject,
|
|
24
|
+
showLayerPanel,
|
|
25
|
+
renderedDraftPreviews,
|
|
26
|
+
} = useProductEditor();
|
|
27
|
+
|
|
28
|
+
const disableCanvasEvents = (e: React.MouseEvent) => {
|
|
29
|
+
if ((e.target as HTMLElement).closest('.floating-toolbar') ||
|
|
30
|
+
(e.target as HTMLElement).closest('.bottom-preview-section') ||
|
|
31
|
+
(e.target as HTMLElement).closest('.mobile-bottom-toolbar')) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
e.stopPropagation();
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const getTemplatePreviewImage = (templateId?: number) => {
|
|
38
|
+
if (!templateId) return null;
|
|
39
|
+
|
|
40
|
+
const renderedPreview = renderedDraftPreviews?.find(preview => preview.templateId === templateId);
|
|
41
|
+
if (renderedPreview?.canvasPreview) {
|
|
42
|
+
return renderedPreview.canvasPreview;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return null;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const register = hookForm?.register;
|
|
49
|
+
return (
|
|
50
|
+
<div className="product-editor">
|
|
51
|
+
{register && (
|
|
52
|
+
<input
|
|
53
|
+
{...register(inputName)}
|
|
54
|
+
type='hidden'
|
|
55
|
+
/>
|
|
56
|
+
)}
|
|
57
|
+
{!isCanvasLoading && draftTemplates.length > 0 && (
|
|
58
|
+
<div className="template-buttons">
|
|
59
|
+
{draftTemplates.map(({ template }) => (
|
|
60
|
+
<div
|
|
61
|
+
key={template.id}
|
|
62
|
+
className={`template-button ${selectedTemplate === template.id ? 'selected' : ''}`}
|
|
63
|
+
onClick={() => handleTemplateChange(template)}
|
|
64
|
+
>
|
|
65
|
+
{!isMobileView && (getTemplatePreviewImage(template.id) || (template.file && template.file.viewUrl)) && (
|
|
66
|
+
<img
|
|
67
|
+
src={getTemplatePreviewImage(template.id) || (template.file?.viewUrl || '')}
|
|
68
|
+
alt={template.name || `Template ${template.id}`}
|
|
69
|
+
className="template-thumbnail"
|
|
70
|
+
/>
|
|
71
|
+
)}
|
|
72
|
+
<span className="template-name">
|
|
73
|
+
{template.name || `Template ${template.id}`}
|
|
74
|
+
</span>
|
|
75
|
+
</div>
|
|
76
|
+
))}
|
|
77
|
+
</div>
|
|
78
|
+
)}
|
|
79
|
+
<div className="main-editor-layout" style={{ position: 'relative' }}>
|
|
80
|
+
<LoadingOverlay isLoading={isCanvasLoading} className="canvas-loading" />
|
|
81
|
+
<div
|
|
82
|
+
className={`editor-container ${isMobileView && showPreview ? 'has-bottom-padding' : ''}`}
|
|
83
|
+
>
|
|
84
|
+
{selectedTextObject && <TextToolbar />}
|
|
85
|
+
<div className="canvas-area" onClick={disableCanvasEvents}>
|
|
86
|
+
<canvas ref={canvasRef} />
|
|
87
|
+
</div>
|
|
88
|
+
{!isMobileView && !isCanvasLoading && <FloatingToolbar />}
|
|
89
|
+
{showLayerPanel && <LayerPanel />}
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
{showPreview && <ProductPreviews />}
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
{isMobileView && !isCanvasLoading && (
|
|
96
|
+
<div className="mobile-bottom-toolbar">
|
|
97
|
+
<Toolbar />
|
|
98
|
+
</div>
|
|
99
|
+
)}
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export default ProductEditor;
|