@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,465 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
3
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
4
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
5
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
6
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
7
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
8
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
var __generator = (this && this.__generator) || function (thisArg, body) {
|
|
12
|
+
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g;
|
|
13
|
+
return g = { next: verb(0), "throw": verb(1), "return": verb(2) }, typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
|
|
14
|
+
function verb(n) { return function (v) { return step([n, v]); }; }
|
|
15
|
+
function step(op) {
|
|
16
|
+
if (f) throw new TypeError("Generator is already executing.");
|
|
17
|
+
while (g && (g = 0, op[0] && (_ = 0)), _) try {
|
|
18
|
+
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
|
|
19
|
+
if (y = 0, t) op = [op[0] & 2, t.value];
|
|
20
|
+
switch (op[0]) {
|
|
21
|
+
case 0: case 1: t = op; break;
|
|
22
|
+
case 4: _.label++; return { value: op[1], done: false };
|
|
23
|
+
case 5: _.label++; y = op[1]; op = [0]; continue;
|
|
24
|
+
case 7: op = _.ops.pop(); _.trys.pop(); continue;
|
|
25
|
+
default:
|
|
26
|
+
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
|
|
27
|
+
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
|
|
28
|
+
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
|
|
29
|
+
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
|
|
30
|
+
if (t[2]) _.ops.pop();
|
|
31
|
+
_.trys.pop(); continue;
|
|
32
|
+
}
|
|
33
|
+
op = body.call(thisArg, _);
|
|
34
|
+
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
|
|
35
|
+
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.renderDraftPreviewsWithLayers = void 0;
|
|
40
|
+
var ag_psd_1 = require("ag-psd");
|
|
41
|
+
/**
|
|
42
|
+
* Find a layer by name within a PSD layer structure
|
|
43
|
+
*
|
|
44
|
+
* @param layers The array of PSD layers to search through
|
|
45
|
+
* @param name The name of the layer to find
|
|
46
|
+
* @returns The found layer or null if not found
|
|
47
|
+
*/
|
|
48
|
+
function findLayerByName(layers, name) {
|
|
49
|
+
if (!layers)
|
|
50
|
+
return null;
|
|
51
|
+
for (var _i = 0, layers_1 = layers; _i < layers_1.length; _i++) {
|
|
52
|
+
var layer = layers_1[_i];
|
|
53
|
+
if (layer.name === name) {
|
|
54
|
+
return layer;
|
|
55
|
+
}
|
|
56
|
+
if (layer.children) {
|
|
57
|
+
var found = findLayerByName(layer.children, name);
|
|
58
|
+
if (found)
|
|
59
|
+
return found;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Load an image from a URL
|
|
66
|
+
*
|
|
67
|
+
* @param url URL of the image to load
|
|
68
|
+
* @returns Promise that resolves to an HTMLImageElement
|
|
69
|
+
*/
|
|
70
|
+
function loadImage(url) {
|
|
71
|
+
return __awaiter(this, void 0, void 0, function () {
|
|
72
|
+
return __generator(this, function (_a) {
|
|
73
|
+
return [2 /*return*/, new Promise(function (resolve, reject) {
|
|
74
|
+
var img = new Image();
|
|
75
|
+
img.crossOrigin = 'anonymous';
|
|
76
|
+
img.onload = function () { return resolve(img); };
|
|
77
|
+
img.onerror = function (e) { return reject(new Error("Failed to load image from URL: ".concat(url, ", error: ").concat(e))); };
|
|
78
|
+
img.src = url;
|
|
79
|
+
})];
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Validates if a data URL represents a valid image
|
|
85
|
+
*
|
|
86
|
+
* @param dataUrl The data URL to validate
|
|
87
|
+
* @returns Promise that resolves to true if valid, false otherwise
|
|
88
|
+
*/
|
|
89
|
+
function isValidImageDataUrl(dataUrl) {
|
|
90
|
+
return __awaiter(this, void 0, void 0, function () {
|
|
91
|
+
return __generator(this, function (_a) {
|
|
92
|
+
return [2 /*return*/, new Promise(function (resolve) {
|
|
93
|
+
if (!dataUrl || !dataUrl.startsWith('data:image/')) {
|
|
94
|
+
console.warn('Invalid data URL format');
|
|
95
|
+
resolve(false);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
var img = new Image();
|
|
99
|
+
img.onload = function () {
|
|
100
|
+
// Check if the image has actual dimensions
|
|
101
|
+
if (img.width > 0 && img.height > 0) {
|
|
102
|
+
resolve(true);
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
console.warn('Image loaded but has zero dimensions');
|
|
106
|
+
resolve(false);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
img.onerror = function () {
|
|
110
|
+
console.warn('Failed to load image from data URL');
|
|
111
|
+
resolve(false);
|
|
112
|
+
};
|
|
113
|
+
img.src = dataUrl;
|
|
114
|
+
})];
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Creates a fallback preview image with debugging information
|
|
120
|
+
*
|
|
121
|
+
* @param width Width of the image
|
|
122
|
+
* @param height Height of the image
|
|
123
|
+
* @param draftPreviewId ID of the draft preview
|
|
124
|
+
* @param error Optional error message
|
|
125
|
+
* @returns PNG data URL
|
|
126
|
+
*/
|
|
127
|
+
function createFallbackImage(width, height, draftPreviewId, error) {
|
|
128
|
+
var canvas = document.createElement('canvas');
|
|
129
|
+
canvas.width = Math.max(width || 300, 300);
|
|
130
|
+
canvas.height = Math.max(height || 200, 200);
|
|
131
|
+
var ctx = canvas.getContext('2d');
|
|
132
|
+
if (!ctx) {
|
|
133
|
+
// Even more basic fallback if context fails
|
|
134
|
+
var basicCanvas = document.createElement('canvas');
|
|
135
|
+
basicCanvas.width = 300;
|
|
136
|
+
basicCanvas.height = 200;
|
|
137
|
+
var basicCtx = basicCanvas.getContext('2d');
|
|
138
|
+
if (basicCtx) {
|
|
139
|
+
basicCtx.fillStyle = '#ffcccc';
|
|
140
|
+
basicCtx.fillRect(0, 0, 300, 200);
|
|
141
|
+
basicCtx.fillStyle = '#990000';
|
|
142
|
+
basicCtx.font = '16px Arial';
|
|
143
|
+
basicCtx.textAlign = 'center';
|
|
144
|
+
basicCtx.fillText('Preview failed', 150, 100);
|
|
145
|
+
return basicCanvas.toDataURL('image/png');
|
|
146
|
+
}
|
|
147
|
+
return '';
|
|
148
|
+
}
|
|
149
|
+
// Background
|
|
150
|
+
ctx.fillStyle = '#f8f8f8';
|
|
151
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
152
|
+
// Draw a grid pattern
|
|
153
|
+
ctx.strokeStyle = '#e0e0e0';
|
|
154
|
+
ctx.lineWidth = 1;
|
|
155
|
+
var gridSize = 20;
|
|
156
|
+
for (var x = 0; x < canvas.width; x += gridSize) {
|
|
157
|
+
ctx.beginPath();
|
|
158
|
+
ctx.moveTo(x, 0);
|
|
159
|
+
ctx.lineTo(x, canvas.height);
|
|
160
|
+
ctx.stroke();
|
|
161
|
+
}
|
|
162
|
+
for (var y = 0; y < canvas.height; y += gridSize) {
|
|
163
|
+
ctx.beginPath();
|
|
164
|
+
ctx.moveTo(0, y);
|
|
165
|
+
ctx.lineTo(canvas.width, y);
|
|
166
|
+
ctx.stroke();
|
|
167
|
+
}
|
|
168
|
+
// Border
|
|
169
|
+
ctx.strokeStyle = '#cccccc';
|
|
170
|
+
ctx.lineWidth = 2;
|
|
171
|
+
ctx.strokeRect(0, 0, canvas.width, canvas.height);
|
|
172
|
+
// Preview text
|
|
173
|
+
ctx.fillStyle = '#666666';
|
|
174
|
+
ctx.font = 'bold 24px Arial';
|
|
175
|
+
ctx.textAlign = 'center';
|
|
176
|
+
ctx.fillText('Preview', canvas.width / 2, canvas.height / 2 - 15);
|
|
177
|
+
// Preview ID
|
|
178
|
+
if (draftPreviewId !== undefined) {
|
|
179
|
+
ctx.font = '16px Arial';
|
|
180
|
+
ctx.fillText("ID: ".concat(draftPreviewId), canvas.width / 2, canvas.height / 2 + 15);
|
|
181
|
+
}
|
|
182
|
+
// Error message if provided
|
|
183
|
+
if (error) {
|
|
184
|
+
ctx.fillStyle = '#cc0000';
|
|
185
|
+
ctx.font = '14px Arial';
|
|
186
|
+
ctx.fillText(error, canvas.width / 2, canvas.height / 2 + 40);
|
|
187
|
+
}
|
|
188
|
+
return canvas.toDataURL('image/png');
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Converts a data URL to a canvas element
|
|
192
|
+
*
|
|
193
|
+
* @param dataUrl The data URL to convert
|
|
194
|
+
* @returns Promise that resolves to a Canvas element
|
|
195
|
+
*/
|
|
196
|
+
function dataUrlToCanvas(dataUrl) {
|
|
197
|
+
return __awaiter(this, void 0, void 0, function () {
|
|
198
|
+
return __generator(this, function (_a) {
|
|
199
|
+
switch (_a.label) {
|
|
200
|
+
case 0: return [4 /*yield*/, loadImage(dataUrl)];
|
|
201
|
+
case 1:
|
|
202
|
+
// First load the data URL as an image
|
|
203
|
+
return [2 /*return*/, _a.sent()];
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Process mapped previews, replacing layers in PSD files with rendered layers
|
|
210
|
+
* and converting the results to PNG images
|
|
211
|
+
*
|
|
212
|
+
* @param mappedPreviews Array of mapped previews with draftPreview and draftPreviewLayers
|
|
213
|
+
* @returns Promise that resolves to an array of processed previews with PNG data URLs
|
|
214
|
+
*/
|
|
215
|
+
function renderDraftPreviewsWithLayers(mappedPreviews) {
|
|
216
|
+
var _a;
|
|
217
|
+
return __awaiter(this, void 0, void 0, function () {
|
|
218
|
+
var results, _i, mappedPreviews_1, mappedPreview, draftPreview, draftPreviewLayers, hasRenderedLayers, simplePngDataUrl, response, arrayBuffer, firstBytes, signature, psd, canvas, ctx, imageData_1, _loop_1, _b, _c, layer, _d, draftPreviewLayers_1, draftPreviewLayer, psdLayer, layerCanvas, left, top_1, width, height, aspectRatio, targetAspectRatio, drawWidth, drawHeight, offsetX, offsetY, error_1, imageData, hasVisiblePixels, i, pngDataUrl, isValid, fallbackPng, error_2, errorMessage, fallbackPng;
|
|
219
|
+
return __generator(this, function (_e) {
|
|
220
|
+
switch (_e.label) {
|
|
221
|
+
case 0:
|
|
222
|
+
results = [];
|
|
223
|
+
_i = 0, mappedPreviews_1 = mappedPreviews;
|
|
224
|
+
_e.label = 1;
|
|
225
|
+
case 1:
|
|
226
|
+
if (!(_i < mappedPreviews_1.length)) return [3 /*break*/, 14];
|
|
227
|
+
mappedPreview = mappedPreviews_1[_i];
|
|
228
|
+
draftPreview = mappedPreview.draftPreview, draftPreviewLayers = mappedPreview.draftPreviewLayers;
|
|
229
|
+
// Skip if there's no file to process
|
|
230
|
+
if (!((_a = draftPreview === null || draftPreview === void 0 ? void 0 : draftPreview.file) === null || _a === void 0 ? void 0 : _a.viewUrl)) {
|
|
231
|
+
console.warn('Skipping preview without a file URL', draftPreview === null || draftPreview === void 0 ? void 0 : draftPreview.id);
|
|
232
|
+
return [3 /*break*/, 13];
|
|
233
|
+
}
|
|
234
|
+
hasRenderedLayers = draftPreviewLayers.some(function (layer) { return layer.renderedLayer !== null; });
|
|
235
|
+
simplePngDataUrl = null;
|
|
236
|
+
_e.label = 2;
|
|
237
|
+
case 2:
|
|
238
|
+
_e.trys.push([2, 12, , 13]);
|
|
239
|
+
return [4 /*yield*/, fetch(draftPreview.file.viewUrl, {
|
|
240
|
+
mode: 'cors',
|
|
241
|
+
credentials: 'same-origin',
|
|
242
|
+
headers: {
|
|
243
|
+
'Accept': '*/*',
|
|
244
|
+
}
|
|
245
|
+
})];
|
|
246
|
+
case 3:
|
|
247
|
+
response = _e.sent();
|
|
248
|
+
if (!response.ok) {
|
|
249
|
+
throw new Error("Failed to fetch PSD file: ".concat(response.status, " ").concat(response.statusText));
|
|
250
|
+
}
|
|
251
|
+
return [4 /*yield*/, response.arrayBuffer()];
|
|
252
|
+
case 4:
|
|
253
|
+
arrayBuffer = _e.sent();
|
|
254
|
+
firstBytes = new Uint8Array(arrayBuffer, 0, 4);
|
|
255
|
+
signature = String.fromCharCode(firstBytes[0]) +
|
|
256
|
+
String.fromCharCode(firstBytes[1]) +
|
|
257
|
+
String.fromCharCode(firstBytes[2]) +
|
|
258
|
+
String.fromCharCode(firstBytes[3]);
|
|
259
|
+
if (signature !== '8BPS') {
|
|
260
|
+
// Use the simple approach result if we have it
|
|
261
|
+
if (simplePngDataUrl) {
|
|
262
|
+
results.push({
|
|
263
|
+
draftPreviewId: draftPreview.id,
|
|
264
|
+
pngDataUrl: simplePngDataUrl
|
|
265
|
+
});
|
|
266
|
+
return [3 /*break*/, 13];
|
|
267
|
+
}
|
|
268
|
+
throw new Error('Not a PSD file and simple approach failed');
|
|
269
|
+
}
|
|
270
|
+
psd = (0, ag_psd_1.readPsd)(arrayBuffer, {
|
|
271
|
+
skipCompositeImageData: false,
|
|
272
|
+
skipLayerImageData: false,
|
|
273
|
+
skipThumbnail: false
|
|
274
|
+
});
|
|
275
|
+
canvas = document.createElement('canvas');
|
|
276
|
+
canvas.width = psd.width || 500; // Default size if missing
|
|
277
|
+
canvas.height = psd.height || 400;
|
|
278
|
+
ctx = canvas.getContext('2d');
|
|
279
|
+
if (!ctx) {
|
|
280
|
+
throw new Error('Failed to get canvas 2d context');
|
|
281
|
+
}
|
|
282
|
+
// Draw a background to ensure we have something visible
|
|
283
|
+
ctx.fillStyle = 'transparent';
|
|
284
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
285
|
+
// First draw the composite image as a base
|
|
286
|
+
if (psd.imageData) {
|
|
287
|
+
imageData_1 = new ImageData(new Uint8ClampedArray(psd.imageData.data), psd.width, psd.height);
|
|
288
|
+
ctx.putImageData(imageData_1, 0, 0);
|
|
289
|
+
}
|
|
290
|
+
// Now draw base layers that aren't being replaced
|
|
291
|
+
if (psd.children && psd.children.length > 0) {
|
|
292
|
+
_loop_1 = function (layer) {
|
|
293
|
+
// Skip if this layer is hidden or matches one of our replaceable layers
|
|
294
|
+
var shouldReplace = draftPreviewLayers.some(function (dpl) {
|
|
295
|
+
return dpl.layerName === layer.name && dpl.renderedLayer !== null;
|
|
296
|
+
});
|
|
297
|
+
if (!shouldReplace && !layer.hidden && layer.canvas) {
|
|
298
|
+
// Draw the original layer
|
|
299
|
+
ctx.drawImage(layer.canvas, layer.left || 0, layer.top || 0);
|
|
300
|
+
ctx.globalAlpha = 1;
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
for (_b = 0, _c = psd.children; _b < _c.length; _b++) {
|
|
304
|
+
layer = _c[_b];
|
|
305
|
+
_loop_1(layer);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
if (!hasRenderedLayers) return [3 /*break*/, 10];
|
|
309
|
+
_d = 0, draftPreviewLayers_1 = draftPreviewLayers;
|
|
310
|
+
_e.label = 5;
|
|
311
|
+
case 5:
|
|
312
|
+
if (!(_d < draftPreviewLayers_1.length)) return [3 /*break*/, 10];
|
|
313
|
+
draftPreviewLayer = draftPreviewLayers_1[_d];
|
|
314
|
+
// Skip if no layer name or no rendered layer
|
|
315
|
+
if (!draftPreviewLayer.layerName || !draftPreviewLayer.renderedLayer) {
|
|
316
|
+
return [3 /*break*/, 9];
|
|
317
|
+
}
|
|
318
|
+
psdLayer = findLayerByName(psd.children, draftPreviewLayer.layerName);
|
|
319
|
+
if (!psdLayer) {
|
|
320
|
+
console.warn("Layer \"".concat(draftPreviewLayer.layerName, "\" not found in PSD"));
|
|
321
|
+
return [3 /*break*/, 9];
|
|
322
|
+
}
|
|
323
|
+
_e.label = 6;
|
|
324
|
+
case 6:
|
|
325
|
+
_e.trys.push([6, 8, , 9]);
|
|
326
|
+
// Validate the rendered layer image URL
|
|
327
|
+
if (!draftPreviewLayer.renderedLayer.draft ||
|
|
328
|
+
!draftPreviewLayer.renderedLayer.draft.startsWith('data:image/')) {
|
|
329
|
+
throw new Error('Invalid image URL format');
|
|
330
|
+
}
|
|
331
|
+
return [4 /*yield*/, dataUrlToCanvas(draftPreviewLayer.renderedLayer.draft)];
|
|
332
|
+
case 7:
|
|
333
|
+
layerCanvas = _e.sent();
|
|
334
|
+
// Validate the canvas dimensions
|
|
335
|
+
if (layerCanvas.width <= 0 || layerCanvas.height <= 0) {
|
|
336
|
+
console.error('Canvas has invalid dimensions');
|
|
337
|
+
throw new Error('Invalid canvas dimensions');
|
|
338
|
+
}
|
|
339
|
+
left = psdLayer.left || 0;
|
|
340
|
+
top_1 = psdLayer.top || 0;
|
|
341
|
+
width = (psdLayer.right || 0) - (psdLayer.left || 0);
|
|
342
|
+
height = (psdLayer.bottom || 0) - (psdLayer.top || 0);
|
|
343
|
+
if (width <= 0 || height <= 0) {
|
|
344
|
+
console.warn("Invalid layer dimensions: ".concat(width, "x").concat(height, ", skipping"));
|
|
345
|
+
return [3 /*break*/, 9];
|
|
346
|
+
}
|
|
347
|
+
aspectRatio = layerCanvas.width / layerCanvas.height;
|
|
348
|
+
targetAspectRatio = width / height;
|
|
349
|
+
drawWidth = void 0, drawHeight = void 0, offsetX = 0, offsetY = 0;
|
|
350
|
+
if (aspectRatio > targetAspectRatio) {
|
|
351
|
+
// Canvas is wider than target area
|
|
352
|
+
drawWidth = width;
|
|
353
|
+
drawHeight = width / aspectRatio;
|
|
354
|
+
offsetY = (height - drawHeight) / 2;
|
|
355
|
+
}
|
|
356
|
+
else {
|
|
357
|
+
// Canvas is taller than target area
|
|
358
|
+
drawHeight = height;
|
|
359
|
+
drawWidth = height * aspectRatio;
|
|
360
|
+
offsetX = (width - drawWidth) / 2;
|
|
361
|
+
}
|
|
362
|
+
// Draw the rendered layer canvas
|
|
363
|
+
// ctx.globalAlpha = psdLayer.opacity !== undefined ? psdLayer.opacity / 255 : 1;
|
|
364
|
+
ctx.restore();
|
|
365
|
+
ctx.drawImage(layerCanvas, left + offsetX, top_1 + offsetY, drawWidth, drawHeight);
|
|
366
|
+
ctx.globalAlpha = 1;
|
|
367
|
+
ctx.save();
|
|
368
|
+
return [3 /*break*/, 9];
|
|
369
|
+
case 8:
|
|
370
|
+
error_1 = _e.sent();
|
|
371
|
+
console.error("Failed to load rendered layer image for \"".concat(draftPreviewLayer.layerName, "\""), error_1);
|
|
372
|
+
// If we fail to load the replacement, draw the original layer as fallback
|
|
373
|
+
if (psdLayer.canvas) {
|
|
374
|
+
ctx.globalAlpha = psdLayer.opacity !== undefined ? psdLayer.opacity / 255 : 1;
|
|
375
|
+
ctx.drawImage(psdLayer.canvas, psdLayer.left || 0, psdLayer.top || 0);
|
|
376
|
+
ctx.globalAlpha = 1;
|
|
377
|
+
}
|
|
378
|
+
return [3 /*break*/, 9];
|
|
379
|
+
case 9:
|
|
380
|
+
_d++;
|
|
381
|
+
return [3 /*break*/, 5];
|
|
382
|
+
case 10:
|
|
383
|
+
imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
|
384
|
+
hasVisiblePixels = false;
|
|
385
|
+
for (i = 3; i < imageData.data.length; i += 4) {
|
|
386
|
+
if (imageData.data[i] > 0) {
|
|
387
|
+
hasVisiblePixels = true;
|
|
388
|
+
break;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
if (!hasVisiblePixels) {
|
|
392
|
+
console.warn('WARNING: Canvas appears to be completely transparent!');
|
|
393
|
+
// Use the simple approach result if available
|
|
394
|
+
if (simplePngDataUrl) {
|
|
395
|
+
results.push({
|
|
396
|
+
draftPreviewId: draftPreview.id,
|
|
397
|
+
pngDataUrl: simplePngDataUrl
|
|
398
|
+
});
|
|
399
|
+
return [3 /*break*/, 13];
|
|
400
|
+
}
|
|
401
|
+
// As a last resort, draw a visible placeholder
|
|
402
|
+
ctx.fillStyle = 'rgba(200, 200, 200, 0.5)';
|
|
403
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
404
|
+
ctx.fillStyle = 'rgba(150, 150, 150, 1)';
|
|
405
|
+
ctx.font = '20px Arial';
|
|
406
|
+
ctx.textAlign = 'center';
|
|
407
|
+
ctx.fillText('Preview', canvas.width / 2, canvas.height / 2);
|
|
408
|
+
}
|
|
409
|
+
pngDataUrl = canvas.toDataURL('image/png');
|
|
410
|
+
return [4 /*yield*/, isValidImageDataUrl(pngDataUrl)];
|
|
411
|
+
case 11:
|
|
412
|
+
isValid = _e.sent();
|
|
413
|
+
if (!isValid) {
|
|
414
|
+
console.warn('Generated PNG data URL is not valid!');
|
|
415
|
+
// Use the simple approach as fallback if available
|
|
416
|
+
if (simplePngDataUrl) {
|
|
417
|
+
results.push({
|
|
418
|
+
draftPreviewId: draftPreview.id,
|
|
419
|
+
pngDataUrl: simplePngDataUrl
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
else {
|
|
423
|
+
fallbackPng = createFallbackImage(500, 400, draftPreview.id, 'Invalid PNG data');
|
|
424
|
+
results.push({
|
|
425
|
+
draftPreviewId: draftPreview.id,
|
|
426
|
+
pngDataUrl: fallbackPng
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
else {
|
|
431
|
+
// Add the result to our array
|
|
432
|
+
results.push({
|
|
433
|
+
draftPreviewId: draftPreview.id,
|
|
434
|
+
pngDataUrl: pngDataUrl
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
return [3 /*break*/, 13];
|
|
438
|
+
case 12:
|
|
439
|
+
error_2 = _e.sent();
|
|
440
|
+
console.error('Failed to process PSD file for draft preview:', error_2);
|
|
441
|
+
// Use the simple approach as fallback if available
|
|
442
|
+
if (simplePngDataUrl) {
|
|
443
|
+
results.push({
|
|
444
|
+
draftPreviewId: draftPreview.id,
|
|
445
|
+
pngDataUrl: simplePngDataUrl
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
else {
|
|
449
|
+
errorMessage = error_2 instanceof Error ? error_2.message : 'Unknown error';
|
|
450
|
+
fallbackPng = createFallbackImage(500, 400, draftPreview.id, errorMessage);
|
|
451
|
+
results.push({
|
|
452
|
+
draftPreviewId: draftPreview.id,
|
|
453
|
+
pngDataUrl: fallbackPng
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
return [3 /*break*/, 13];
|
|
457
|
+
case 13:
|
|
458
|
+
_i++;
|
|
459
|
+
return [3 /*break*/, 1];
|
|
460
|
+
case 14: return [2 /*return*/, results];
|
|
461
|
+
}
|
|
462
|
+
});
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
exports.renderDraftPreviewsWithLayers = renderDraftPreviewsWithLayers;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
3
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
4
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
5
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
6
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
7
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
8
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
var __generator = (this && this.__generator) || function (thisArg, body) {
|
|
12
|
+
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g;
|
|
13
|
+
return g = { next: verb(0), "throw": verb(1), "return": verb(2) }, typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
|
|
14
|
+
function verb(n) { return function (v) { return step([n, v]); }; }
|
|
15
|
+
function step(op) {
|
|
16
|
+
if (f) throw new TypeError("Generator is already executing.");
|
|
17
|
+
while (g && (g = 0, op[0] && (_ = 0)), _) try {
|
|
18
|
+
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
|
|
19
|
+
if (y = 0, t) op = [op[0] & 2, t.value];
|
|
20
|
+
switch (op[0]) {
|
|
21
|
+
case 0: case 1: t = op; break;
|
|
22
|
+
case 4: _.label++; return { value: op[1], done: false };
|
|
23
|
+
case 5: _.label++; y = op[1]; op = [0]; continue;
|
|
24
|
+
case 7: op = _.ops.pop(); _.trys.pop(); continue;
|
|
25
|
+
default:
|
|
26
|
+
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
|
|
27
|
+
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
|
|
28
|
+
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
|
|
29
|
+
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
|
|
30
|
+
if (t[2]) _.ops.pop();
|
|
31
|
+
_.trys.pop(); continue;
|
|
32
|
+
}
|
|
33
|
+
op = body.call(thisArg, _);
|
|
34
|
+
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
|
|
35
|
+
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.renderEditorOrPreview = void 0;
|
|
40
|
+
var imageUtils_1 = require("./imageUtils");
|
|
41
|
+
var psdConverter_1 = require("./psdConverter");
|
|
42
|
+
// Simplify loadTemplateImage to just load the template without variations
|
|
43
|
+
var renderEditorOrPreview = function (fabricCanvas, template, variations, savedObjects, width, height) { return __awaiter(void 0, void 0, void 0, function () {
|
|
44
|
+
var file, imageUrl, isPsd, error_1;
|
|
45
|
+
var _a;
|
|
46
|
+
return __generator(this, function (_b) {
|
|
47
|
+
switch (_b.label) {
|
|
48
|
+
case 0:
|
|
49
|
+
// Check if we have a valid image URL
|
|
50
|
+
if (!((_a = template.file) === null || _a === void 0 ? void 0 : _a.viewUrl)) {
|
|
51
|
+
return [2 /*return*/, Promise.resolve()];
|
|
52
|
+
}
|
|
53
|
+
file = template.file;
|
|
54
|
+
imageUrl = file.viewUrl;
|
|
55
|
+
isPsd = imageUrl.toLowerCase().endsWith('.psd')
|
|
56
|
+
|| (file.mimetype && file.mimetype.includes('photoshop'));
|
|
57
|
+
if (!isPsd) return [3 /*break*/, 5];
|
|
58
|
+
_b.label = 1;
|
|
59
|
+
case 1:
|
|
60
|
+
_b.trys.push([1, 3, , 5]);
|
|
61
|
+
// Process the PSD file
|
|
62
|
+
return [4 /*yield*/, (0, psdConverter_1.loadPsdOntoCanvas)(fabricCanvas, imageUrl, variations, savedObjects, width, height)];
|
|
63
|
+
case 2:
|
|
64
|
+
// Process the PSD file
|
|
65
|
+
_b.sent();
|
|
66
|
+
return [2 /*return*/, Promise.resolve()];
|
|
67
|
+
case 3:
|
|
68
|
+
error_1 = _b.sent();
|
|
69
|
+
// If PSD processing fails, fall back to our regular image loading
|
|
70
|
+
return [4 /*yield*/, (0, imageUtils_1.loadRegularImagePromise)(fabricCanvas, template, width, height)];
|
|
71
|
+
case 4:
|
|
72
|
+
// If PSD processing fails, fall back to our regular image loading
|
|
73
|
+
_b.sent();
|
|
74
|
+
return [2 /*return*/, Promise.resolve()];
|
|
75
|
+
case 5:
|
|
76
|
+
// For non-PSD files, use the regular image loading approach
|
|
77
|
+
return [4 /*yield*/, (0, imageUtils_1.loadRegularImagePromise)(fabricCanvas, template, width, height)];
|
|
78
|
+
case 6:
|
|
79
|
+
// For non-PSD files, use the regular image loading approach
|
|
80
|
+
_b.sent();
|
|
81
|
+
return [2 /*return*/, Promise.resolve()];
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
}); };
|
|
85
|
+
exports.renderEditorOrPreview = renderEditorOrPreview;
|
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@merchi/product-editor",
|
|
3
|
+
"version": "0.1.9",
|
|
4
|
+
"description": "A React component for editing product images using Fabric.js",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc && copyfiles -u 2 src/styles/*.css dist/styles/",
|
|
9
|
+
"test": "jest",
|
|
10
|
+
"lint": "eslint src/**/*.{ts,tsx}",
|
|
11
|
+
"prepare": "npm run build"
|
|
12
|
+
},
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/merchisdk/merchi_ts.git",
|
|
16
|
+
"directory": "packages/product-editor"
|
|
17
|
+
},
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"react",
|
|
23
|
+
"fabric.js",
|
|
24
|
+
"product-editor",
|
|
25
|
+
"image-editor"
|
|
26
|
+
],
|
|
27
|
+
"author": "",
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"react": ">=16.8.0",
|
|
31
|
+
"react-dom": ">=16.8.0"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@fortawesome/fontawesome-svg-core": "^6.7.2",
|
|
35
|
+
"@fortawesome/free-solid-svg-icons": "^6.7.2",
|
|
36
|
+
"@fortawesome/react-fontawesome": "^0.2.2",
|
|
37
|
+
"@radix-ui/react-icons": "^1.3.2",
|
|
38
|
+
"@types/react-color": "^3.0.13",
|
|
39
|
+
"@types/react-select": "^5.0.1",
|
|
40
|
+
"ag-psd": "^28.0.1",
|
|
41
|
+
"fabric": "^5.5.1",
|
|
42
|
+
"framer-motion": "^12.6.3",
|
|
43
|
+
"grommet-icons": "^4.12.4",
|
|
44
|
+
"lodash": "^4.17.21",
|
|
45
|
+
"psd.js": "^3.9.1",
|
|
46
|
+
"react-color": "^2.19.3",
|
|
47
|
+
"react-hook-form": "^7.56.1",
|
|
48
|
+
"react-icons": "^5.5.0",
|
|
49
|
+
"react-select": "^5.10.1",
|
|
50
|
+
"uuid": "^11.1.0"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@testing-library/jest-dom": "^5.16.0",
|
|
54
|
+
"@testing-library/react": "^13.0.0",
|
|
55
|
+
"@types/fabric": "^5.3.10",
|
|
56
|
+
"@types/react": "^19.0.0",
|
|
57
|
+
"@types/react-dom": "^19.0.0",
|
|
58
|
+
"@typescript-eslint/eslint-plugin": "^5.0.0",
|
|
59
|
+
"@typescript-eslint/parser": "^5.0.0",
|
|
60
|
+
"copyfiles": "^2.4.1",
|
|
61
|
+
"eslint": "^8.0.0",
|
|
62
|
+
"jest": "^29.0.0",
|
|
63
|
+
"ts-jest": "^29.0.0",
|
|
64
|
+
"typescript": "^4.9.0"
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import React, { useState, useRef, useEffect } from 'react';
|
|
2
|
+
import { TextAlignLeft, TextAlignCenter, TextAlignRight } from 'grommet-icons';
|
|
3
|
+
import { useProductEditor } from '../context/ProductEditorContext';
|
|
4
|
+
import '../styles/TextToolbar.css';
|
|
5
|
+
|
|
6
|
+
export const AlignMenu: React.FC = () => {
|
|
7
|
+
const { selectedTextObject, updateSelectedText } = useProductEditor();
|
|
8
|
+
const [showMenu, setShowMenu] = useState(false);
|
|
9
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
10
|
+
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
const handleClickOutside = (e: MouseEvent) => {
|
|
13
|
+
if (ref.current && !ref.current.contains(e.target as Node)) {
|
|
14
|
+
setShowMenu(false);
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
18
|
+
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
19
|
+
}, []);
|
|
20
|
+
|
|
21
|
+
if (!selectedTextObject || !updateSelectedText) return null;
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
const handleAlign = (align: 'left' | 'center' | 'right') => {
|
|
25
|
+
updateSelectedText({ textAlign: align });
|
|
26
|
+
setShowMenu(false);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<div className="align-group" ref={ref}>
|
|
31
|
+
<button
|
|
32
|
+
type="button"
|
|
33
|
+
className="toolbar-button align-trigger"
|
|
34
|
+
onClick={() => setShowMenu(prev => !prev)}
|
|
35
|
+
title="Text Align"
|
|
36
|
+
>
|
|
37
|
+
{selectedTextObject.textAlign === 'center' ? <TextAlignCenter /> :
|
|
38
|
+
selectedTextObject.textAlign === 'right' ? <TextAlignRight /> : <TextAlignLeft />}
|
|
39
|
+
</button>
|
|
40
|
+
{showMenu && (
|
|
41
|
+
<div className="align-popover">
|
|
42
|
+
<button type="button" className={`toolbar-button ${selectedTextObject.textAlign === 'left' ? 'active' : ''}`} onClick={() => handleAlign('left')}><TextAlignLeft /></button>
|
|
43
|
+
<button type="button" className={`toolbar-button ${selectedTextObject.textAlign === 'center' ? 'active' : ''}`} onClick={() => handleAlign('center')}><TextAlignCenter /></button>
|
|
44
|
+
<button type="button" className={`toolbar-button ${selectedTextObject.textAlign === 'right' ? 'active' : ''}`} onClick={() => handleAlign('right')}><TextAlignRight /></button>
|
|
45
|
+
</div>
|
|
46
|
+
)}
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
};
|