embroidery-qc-image 1.0.24 → 1.0.26
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/dist/components/EmbroideryQCImage.d.ts.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.esm.js +493 -141
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +493 -141
- package/dist/index.js.map +1 -1
- package/dist/types/index.d.ts +5 -0
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/index.esm.js
CHANGED
|
@@ -55,20 +55,15 @@ const LAYOUT = {
|
|
|
55
55
|
TEXT_ALIGN: "left",
|
|
56
56
|
TEXT_BASELINE: "top",
|
|
57
57
|
// Spacing
|
|
58
|
-
LINE_GAP:
|
|
59
|
-
PADDING:
|
|
58
|
+
LINE_GAP: 50,
|
|
59
|
+
PADDING: 50,
|
|
60
60
|
SECTION_SPACING: 60,
|
|
61
61
|
ELEMENT_SPACING: 100,
|
|
62
62
|
SWATCH_SPACING: 25,
|
|
63
|
-
FLORAL_SPACING: 300,
|
|
64
63
|
// Visual styling
|
|
65
64
|
SWATCH_HEIGHT_RATIO: 2.025,
|
|
66
65
|
UNDERLINE_POSITION: 0.9,
|
|
67
|
-
UNDERLINE_WIDTH: 10
|
|
68
|
-
// Swatch reserved space
|
|
69
|
-
SWATCH_RESERVED_SPACE: 1000,
|
|
70
|
-
MIN_TEXT_WIDTH: 400,
|
|
71
|
-
};
|
|
66
|
+
UNDERLINE_WIDTH: 10};
|
|
72
67
|
// ============================================================================
|
|
73
68
|
// HELPER FUNCTIONS
|
|
74
69
|
// ============================================================================
|
|
@@ -103,6 +98,17 @@ const getImageUrl = (type, value) => {
|
|
|
103
98
|
return `${BASE_URLS.THREAD_COLOR}/${value}.webp`;
|
|
104
99
|
};
|
|
105
100
|
const getProxyUrl = (url) => `https://proxy-img.c8p.workers.dev?url=${encodeURIComponent(url)}`;
|
|
101
|
+
const getIconImageUrl = (position) => {
|
|
102
|
+
if (position.is_delete_icon)
|
|
103
|
+
return null;
|
|
104
|
+
if (position.icon_image && position.icon_image.trim().length > 0) {
|
|
105
|
+
return position.icon_image;
|
|
106
|
+
}
|
|
107
|
+
if (position.icon !== 0) {
|
|
108
|
+
return getImageUrl("icon", position.icon);
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
};
|
|
106
112
|
const ensureImage = (existing) => {
|
|
107
113
|
if (existing && existing.crossOrigin === "anonymous") {
|
|
108
114
|
return existing;
|
|
@@ -134,14 +140,14 @@ const loadImage = (url, imageRefs, onLoad) => {
|
|
|
134
140
|
img.onerror = () => {
|
|
135
141
|
if (!attemptedProxy) {
|
|
136
142
|
attemptedProxy = true;
|
|
137
|
-
img.src = getProxyUrl(url);
|
|
143
|
+
img.src = getProxyUrl(getResizeUrl(url));
|
|
138
144
|
return;
|
|
139
145
|
}
|
|
140
146
|
img.dataset.proxyUsed = attemptedProxy ? "true" : "false";
|
|
141
147
|
cleanup();
|
|
142
148
|
onLoad();
|
|
143
149
|
};
|
|
144
|
-
img.src = attemptedProxy ? getProxyUrl(url) : url;
|
|
150
|
+
img.src = attemptedProxy ? getProxyUrl(getResizeUrl(url)) : getResizeUrl(url);
|
|
145
151
|
};
|
|
146
152
|
const loadImageAsync = (url, imageRefs, cacheKey) => {
|
|
147
153
|
const key = cacheKey ?? url;
|
|
@@ -179,13 +185,13 @@ const loadImageAsync = (url, imageRefs, cacheKey) => {
|
|
|
179
185
|
target.onerror = () => {
|
|
180
186
|
if (!attemptedProxy) {
|
|
181
187
|
attemptedProxy = true;
|
|
182
|
-
target.src = getProxyUrl(url);
|
|
188
|
+
target.src = getProxyUrl(getResizeUrl(url));
|
|
183
189
|
return;
|
|
184
190
|
}
|
|
185
191
|
target.dataset.proxyUsed = attemptedProxy ? "true" : "false";
|
|
186
192
|
finalize();
|
|
187
193
|
};
|
|
188
|
-
const desiredSrc = attemptedProxy ? getProxyUrl(url) : url;
|
|
194
|
+
const desiredSrc = attemptedProxy ? getProxyUrl(getResizeUrl(url)) : getResizeUrl(url);
|
|
189
195
|
if (target.src !== desiredSrc) {
|
|
190
196
|
target.src = desiredSrc;
|
|
191
197
|
}
|
|
@@ -194,6 +200,57 @@ const loadImageAsync = (url, imageRefs, cacheKey) => {
|
|
|
194
200
|
}
|
|
195
201
|
});
|
|
196
202
|
};
|
|
203
|
+
const getResizeUrl = (url) => {
|
|
204
|
+
try {
|
|
205
|
+
const urlObj = new URL(url);
|
|
206
|
+
// Xử lý cdn.shopify.com
|
|
207
|
+
if (urlObj.hostname === 'cdn.shopify.com') {
|
|
208
|
+
// Set hoặc update query param width=400
|
|
209
|
+
urlObj.searchParams.set('width', '400');
|
|
210
|
+
return urlObj.toString();
|
|
211
|
+
}
|
|
212
|
+
// Xử lý m.media-amazon.com
|
|
213
|
+
if (urlObj.hostname === 'm.media-amazon.com') {
|
|
214
|
+
const pathname = urlObj.pathname;
|
|
215
|
+
// Split pathname theo dấu /
|
|
216
|
+
const pathArr = pathname.split('/');
|
|
217
|
+
// Lấy filename (phần cuối cùng)
|
|
218
|
+
const filename = pathArr[pathArr.length - 1];
|
|
219
|
+
// Xóa pattern ._.*_ (ví dụ: ._AC_SX569_)
|
|
220
|
+
const cleanedFilename = filename.replace(/\._.*_/g, '');
|
|
221
|
+
// Split filename đã clean theo dấu .
|
|
222
|
+
const parts = cleanedFilename.split('.');
|
|
223
|
+
if (parts.length >= 2) {
|
|
224
|
+
// Lấy phần đầu và phần cuối
|
|
225
|
+
const firstPart = parts[0];
|
|
226
|
+
const lastPart = parts[parts.length - 1];
|
|
227
|
+
// Chèn _AC_SX400_ vào giữa và join lại
|
|
228
|
+
const newFilename = `${firstPart}._AC_SX400_.${lastPart}`;
|
|
229
|
+
// Thay filename mới vào pathArr
|
|
230
|
+
pathArr[pathArr.length - 1] = newFilename;
|
|
231
|
+
// Join lại
|
|
232
|
+
urlObj.pathname = pathArr.join('/');
|
|
233
|
+
return urlObj.toString();
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
// Xử lý i.etsystatic.com
|
|
237
|
+
if (urlObj.hostname === 'i.etsystatic.com') {
|
|
238
|
+
const pathname = urlObj.pathname;
|
|
239
|
+
// Thay il_fullxfull bằng il_400x400
|
|
240
|
+
if (pathname.includes('il_fullxfull')) {
|
|
241
|
+
const newPathname = pathname.replace(/il_fullxfull/g, 'il_400x400');
|
|
242
|
+
urlObj.pathname = newPathname;
|
|
243
|
+
return urlObj.toString();
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
// Nếu không phải các domain cần xử lý, return URL gốc
|
|
247
|
+
return url;
|
|
248
|
+
}
|
|
249
|
+
catch (error) {
|
|
250
|
+
// Nếu URL không hợp lệ, return URL gốc
|
|
251
|
+
return url;
|
|
252
|
+
}
|
|
253
|
+
};
|
|
197
254
|
const preloadFonts = async (config) => {
|
|
198
255
|
if (config.error_message || !config.sides?.length)
|
|
199
256
|
return;
|
|
@@ -223,12 +280,10 @@ const preloadImages = async (config, imageRefs) => {
|
|
|
223
280
|
config.sides.forEach((side) => {
|
|
224
281
|
side.positions.forEach((position) => {
|
|
225
282
|
if (position.type === "ICON") {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
seen.add(iconUrl);
|
|
231
|
-
}
|
|
283
|
+
const iconUrl = getIconImageUrl(position);
|
|
284
|
+
if (iconUrl && !seen.has(iconUrl)) {
|
|
285
|
+
entries.push({ url: iconUrl });
|
|
286
|
+
seen.add(iconUrl);
|
|
232
287
|
}
|
|
233
288
|
if (position.color) {
|
|
234
289
|
const threadUrl = getImageUrl("threadColor", position.color);
|
|
@@ -301,23 +356,46 @@ const wrapText = (ctx, text, x, y, maxWidth, lineHeight) => {
|
|
|
301
356
|
};
|
|
302
357
|
};
|
|
303
358
|
const buildWrappedLines = (ctx, text, maxWidth) => {
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
currentLine = words[i];
|
|
359
|
+
// Mỗi '\n' tương đương với một line break giống như khi wrap tự động.
|
|
360
|
+
const segments = text.split("\n");
|
|
361
|
+
const result = [];
|
|
362
|
+
segments.forEach((segment) => {
|
|
363
|
+
const words = segment.split(" ").filter((word) => word.length > 0);
|
|
364
|
+
if (words.length === 0) {
|
|
365
|
+
// Nếu đoạn rỗng, thêm một dòng trống (break đúng 1 line)
|
|
366
|
+
result.push("");
|
|
367
|
+
return;
|
|
314
368
|
}
|
|
315
|
-
|
|
316
|
-
|
|
369
|
+
let currentLine = words[0];
|
|
370
|
+
for (let i = 1; i < words.length; i++) {
|
|
371
|
+
const testLine = `${currentLine} ${words[i]}`;
|
|
372
|
+
if (ctx.measureText(testLine).width > maxWidth && currentLine.length > 0) {
|
|
373
|
+
result.push(currentLine);
|
|
374
|
+
currentLine = words[i];
|
|
375
|
+
}
|
|
376
|
+
else {
|
|
377
|
+
currentLine = testLine;
|
|
378
|
+
}
|
|
317
379
|
}
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
return
|
|
380
|
+
result.push(currentLine);
|
|
381
|
+
});
|
|
382
|
+
return result.length ? result : [""];
|
|
383
|
+
};
|
|
384
|
+
const calculateSwatchesWidth = (colors, swatchHeight, scaleFactor, imageRefs) => {
|
|
385
|
+
let totalWidth = 0;
|
|
386
|
+
colors.forEach((color, index) => {
|
|
387
|
+
const url = getImageUrl("threadColor", color);
|
|
388
|
+
const img = imageRefs.current.get(url);
|
|
389
|
+
if (img && img.complete && img.naturalHeight > 0) {
|
|
390
|
+
const ratio = img.naturalWidth / img.naturalHeight;
|
|
391
|
+
const swatchW = Math.max(1, Math.floor(swatchHeight * ratio));
|
|
392
|
+
totalWidth += swatchW;
|
|
393
|
+
if (index < colors.length - 1) {
|
|
394
|
+
totalWidth += LAYOUT.SWATCH_SPACING * scaleFactor;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
return totalWidth;
|
|
321
399
|
};
|
|
322
400
|
const drawSwatches = (ctx, colors, startX, startY, swatchHeight, scaleFactor, imageRefs) => {
|
|
323
401
|
let swatchX = startX;
|
|
@@ -381,8 +459,9 @@ const EmbroideryQCImage = ({ config, className = "", style = {}, }) => {
|
|
|
381
459
|
config.sides.forEach((side) => {
|
|
382
460
|
side.positions.forEach((position) => {
|
|
383
461
|
if (position.type === "ICON") {
|
|
384
|
-
|
|
385
|
-
|
|
462
|
+
const iconUrl = getIconImageUrl(position);
|
|
463
|
+
if (iconUrl) {
|
|
464
|
+
loadImage(iconUrl, imageRefs, incrementCounter);
|
|
386
465
|
}
|
|
387
466
|
position.layer_colors?.forEach((color) => {
|
|
388
467
|
loadImage(getImageUrl("threadColor", color), imageRefs, incrementCounter);
|
|
@@ -433,7 +512,7 @@ const renderEmbroideryCanvas = (canvas, config, canvasSize, imageRefs) => {
|
|
|
433
512
|
return;
|
|
434
513
|
ctx.textAlign = LAYOUT.TEXT_ALIGN;
|
|
435
514
|
ctx.textBaseline = LAYOUT.TEXT_BASELINE;
|
|
436
|
-
// Calculate warning height (with scaleFactor = 1 for measurement)
|
|
515
|
+
// Calculate warning & message height (with scaleFactor = 1 for measurement)
|
|
437
516
|
let warningHeight = 0;
|
|
438
517
|
let warningLineHeight = 0;
|
|
439
518
|
let warningLineCount = 0;
|
|
@@ -452,28 +531,30 @@ const renderEmbroideryCanvas = (canvas, config, canvasSize, imageRefs) => {
|
|
|
452
531
|
warningHeight = warningLineCount * warningLineHeight + LAYOUT.PADDING;
|
|
453
532
|
}
|
|
454
533
|
}
|
|
534
|
+
let messageHeight = 0;
|
|
535
|
+
let messageLineHeight = 0;
|
|
536
|
+
let messageLineCount = 0;
|
|
537
|
+
if (config.message) {
|
|
538
|
+
const measureMessageCanvas = document.createElement("canvas");
|
|
539
|
+
measureMessageCanvas.width = canvas.width;
|
|
540
|
+
measureMessageCanvas.height = canvas.height;
|
|
541
|
+
const measureMessageCtx = measureMessageCanvas.getContext("2d");
|
|
542
|
+
if (measureMessageCtx) {
|
|
543
|
+
measureMessageCtx.textAlign = "left";
|
|
544
|
+
measureMessageCtx.textBaseline = "top";
|
|
545
|
+
measureMessageCtx.font = `${LAYOUT.HEADER_FONT_SIZE * 0.7}px ${LAYOUT.FONT_FAMILY}`;
|
|
546
|
+
const messageLines = buildWrappedLines(measureMessageCtx, config.message.trim(), canvas.width - LAYOUT.PADDING * 4);
|
|
547
|
+
messageLineCount = messageLines.length;
|
|
548
|
+
messageLineHeight = LAYOUT.HEADER_FONT_SIZE * 0.7 + LAYOUT.LINE_GAP;
|
|
549
|
+
messageHeight = messageLineCount * messageLineHeight + LAYOUT.PADDING;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
455
552
|
if (config.image_url) {
|
|
456
553
|
const mockupImage = imageRefs.current.get(config.image_url);
|
|
457
554
|
if (mockupImage) {
|
|
458
555
|
imageRefs.current.set("mockup", mockupImage);
|
|
459
556
|
}
|
|
460
557
|
}
|
|
461
|
-
const floralAssets = [];
|
|
462
|
-
const seenFlorals = new Set();
|
|
463
|
-
config.sides.forEach((side) => {
|
|
464
|
-
side.positions.forEach((position) => {
|
|
465
|
-
if (position.type === "TEXT" && position.floral_pattern) {
|
|
466
|
-
const url = getImageUrl("floral", position.floral_pattern);
|
|
467
|
-
if (!seenFlorals.has(url)) {
|
|
468
|
-
const img = imageRefs.current.get(url);
|
|
469
|
-
if (img?.complete && img.naturalWidth > 0) {
|
|
470
|
-
floralAssets.push(img);
|
|
471
|
-
seenFlorals.add(url);
|
|
472
|
-
}
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
});
|
|
476
|
-
});
|
|
477
558
|
const measureCanvas = document.createElement("canvas");
|
|
478
559
|
measureCanvas.width = canvas.width;
|
|
479
560
|
measureCanvas.height = canvas.height;
|
|
@@ -484,27 +565,40 @@ const renderEmbroideryCanvas = (canvas, config, canvasSize, imageRefs) => {
|
|
|
484
565
|
measureCtx.textBaseline = LAYOUT.TEXT_BASELINE;
|
|
485
566
|
let measureY = LAYOUT.PADDING;
|
|
486
567
|
const measureSpacing = LAYOUT.ELEMENT_SPACING;
|
|
487
|
-
// Add warning text height (without bottom padding, no spacing)
|
|
568
|
+
// Add warning & message text height (without bottom padding, no spacing)
|
|
488
569
|
if (config.warning_message) {
|
|
489
570
|
const warningTextHeight = warningHeight - LAYOUT.PADDING;
|
|
490
571
|
measureY += warningTextHeight;
|
|
491
572
|
}
|
|
573
|
+
if (config.message) {
|
|
574
|
+
const messageTextHeight = messageHeight - LAYOUT.PADDING;
|
|
575
|
+
measureY += messageTextHeight;
|
|
576
|
+
}
|
|
492
577
|
config.sides.forEach((side) => {
|
|
493
578
|
const sideHeight = renderSide(measureCtx, side, measureY, canvas.width, 1, imageRefs);
|
|
494
579
|
measureY += sideHeight + measureSpacing;
|
|
495
580
|
});
|
|
496
|
-
const scaleFactor = Math.max(0.5, Math.min(1, (canvas.height - LAYOUT.PADDING - warningHeight) / measureY));
|
|
497
|
-
drawMockupAndFlorals(ctx, canvas,
|
|
498
|
-
// Render warning with scaleFactor and get actual
|
|
581
|
+
const scaleFactor = Math.max(0.5, Math.min(1, (canvas.height - LAYOUT.PADDING - warningHeight - messageHeight) / measureY));
|
|
582
|
+
drawMockupAndFlorals(ctx, canvas, imageRefs);
|
|
583
|
+
// Render warning & message with scaleFactor and get actual heights
|
|
499
584
|
let actualWarningHeight = 0;
|
|
585
|
+
let actualMessageHeight = 0;
|
|
500
586
|
if (config.warning_message) {
|
|
501
587
|
actualWarningHeight = renderWarning(ctx, canvas, config.warning_message, scaleFactor);
|
|
502
588
|
}
|
|
503
|
-
|
|
589
|
+
if (config.message) {
|
|
590
|
+
actualMessageHeight = renderWarning(ctx, canvas, config.message, scaleFactor, actualWarningHeight, "", // message: không cần prefix "Note"
|
|
591
|
+
DEFAULT_ERROR_COLOR // message: hiển thị màu đỏ
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
// Calculate currentY: padding top + actual warning & message height (no spacing)
|
|
504
595
|
let currentY = LAYOUT.PADDING * scaleFactor;
|
|
505
596
|
if (config.warning_message && actualWarningHeight > 0) {
|
|
506
597
|
currentY += actualWarningHeight;
|
|
507
598
|
}
|
|
599
|
+
if (config.message && actualMessageHeight > 0) {
|
|
600
|
+
currentY += actualMessageHeight;
|
|
601
|
+
}
|
|
508
602
|
config.sides.forEach((side) => {
|
|
509
603
|
const sideHeight = renderSide(ctx, side, currentY, canvas.width, scaleFactor, imageRefs);
|
|
510
604
|
currentY += sideHeight + measureSpacing * scaleFactor;
|
|
@@ -546,8 +640,8 @@ const renderErrorState = (ctx, canvas, message) => {
|
|
|
546
640
|
});
|
|
547
641
|
ctx.restore();
|
|
548
642
|
};
|
|
549
|
-
const renderWarning = (ctx, canvas, message, scaleFactor = 1) => {
|
|
550
|
-
const sanitizedMessage =
|
|
643
|
+
const renderWarning = (ctx, canvas, message, scaleFactor = 1, offsetY = 0, prefix = "Note: ", color = DEFAULT_WARNING_COLOR) => {
|
|
644
|
+
const sanitizedMessage = `${prefix}${message.trim()}`;
|
|
551
645
|
const horizontalPadding = LAYOUT.PADDING * 2 * scaleFactor;
|
|
552
646
|
const maxWidth = canvas.width - horizontalPadding * 2;
|
|
553
647
|
const baseFontSize = LAYOUT.HEADER_FONT_SIZE * 0.7 * scaleFactor;
|
|
@@ -556,7 +650,7 @@ const renderWarning = (ctx, canvas, message, scaleFactor = 1) => {
|
|
|
556
650
|
ctx.save();
|
|
557
651
|
ctx.textAlign = "left";
|
|
558
652
|
ctx.textBaseline = "top";
|
|
559
|
-
ctx.fillStyle =
|
|
653
|
+
ctx.fillStyle = color;
|
|
560
654
|
ctx.font = `${baseFontSize}px ${LAYOUT.FONT_FAMILY}`;
|
|
561
655
|
let fontSize = baseFontSize;
|
|
562
656
|
let lineGap = LAYOUT.LINE_GAP * scaleFactor;
|
|
@@ -575,7 +669,7 @@ const renderWarning = (ctx, canvas, message, scaleFactor = 1) => {
|
|
|
575
669
|
lines = buildWrappedLines(ctx, sanitizedMessage, maxWidth);
|
|
576
670
|
longestLineWidth = Math.max(...lines.map((line) => ctx.measureText(line).width));
|
|
577
671
|
}
|
|
578
|
-
const startY = LAYOUT.PADDING * scaleFactor;
|
|
672
|
+
const startY = LAYOUT.PADDING * scaleFactor + offsetY;
|
|
579
673
|
lines.forEach((line, index) => {
|
|
580
674
|
const y = startY + index * lineHeight;
|
|
581
675
|
ctx.fillText(line, leftX, y);
|
|
@@ -584,7 +678,7 @@ const renderWarning = (ctx, canvas, message, scaleFactor = 1) => {
|
|
|
584
678
|
// Return the actual height of the warning (number of lines * lineHeight)
|
|
585
679
|
return lines.length * lineHeight;
|
|
586
680
|
};
|
|
587
|
-
const drawMockupAndFlorals = (ctx, canvas,
|
|
681
|
+
const drawMockupAndFlorals = (ctx, canvas, imageRefs) => {
|
|
588
682
|
const mockupImg = imageRefs.current.get("mockup");
|
|
589
683
|
if (!mockupImg?.complete || !mockupImg.naturalWidth)
|
|
590
684
|
return;
|
|
@@ -597,19 +691,7 @@ const drawMockupAndFlorals = (ctx, canvas, floralAssets, imageRefs) => {
|
|
|
597
691
|
const x = canvas.width - margin - width;
|
|
598
692
|
const y = canvas.height - margin - height;
|
|
599
693
|
ctx.drawImage(mockupImg, x, y, width, height);
|
|
600
|
-
//
|
|
601
|
-
if (floralAssets.length > 0) {
|
|
602
|
-
const floralH = Math.min(900, height);
|
|
603
|
-
let currentX = x - LAYOUT.FLORAL_SPACING;
|
|
604
|
-
for (let i = floralAssets.length - 1; i >= 0; i--) {
|
|
605
|
-
const img = floralAssets[i];
|
|
606
|
-
const ratio = img.naturalWidth / Math.max(1, img.naturalHeight);
|
|
607
|
-
const w = Math.max(1, Math.floor(floralH * ratio));
|
|
608
|
-
currentX -= w;
|
|
609
|
-
ctx.drawImage(img, currentX, y + height - floralH, w, floralH);
|
|
610
|
-
currentX -= LAYOUT.FLORAL_SPACING;
|
|
611
|
-
}
|
|
612
|
-
}
|
|
694
|
+
// Bỏ phần vẽ florals cạnh mockup vì đã hiển thị cạnh text rồi
|
|
613
695
|
};
|
|
614
696
|
const renderSide = (ctx, side, startY, width, scaleFactor, imageRefs) => {
|
|
615
697
|
let currentY = startY;
|
|
@@ -631,8 +713,33 @@ const renderSide = (ctx, side, startY, width, scaleFactor, imageRefs) => {
|
|
|
631
713
|
ctx.stroke();
|
|
632
714
|
currentY += headerResult.height + LAYOUT.SECTION_SPACING * scaleFactor;
|
|
633
715
|
ctx.restore();
|
|
634
|
-
//
|
|
716
|
+
// Kiểm tra xem có phải trường hợp "không thêu gì" không
|
|
635
717
|
const textPositions = side.positions.filter((p) => p.type === "TEXT");
|
|
718
|
+
const iconPositions = side.positions.filter((p) => p.type === "ICON");
|
|
719
|
+
// Kiểm tra tất cả TEXT positions có trống không
|
|
720
|
+
// Nếu không có TEXT positions, coi như "tất cả TEXT trống" = true
|
|
721
|
+
const allTextEmpty = textPositions.length === 0 || textPositions.every((p) => {
|
|
722
|
+
const text = p.text ?? "";
|
|
723
|
+
return text.trim() === "";
|
|
724
|
+
});
|
|
725
|
+
// Kiểm tra tất cả ICON positions có is_delete_icon = true không
|
|
726
|
+
// Nếu không có ICON positions, coi như "tất cả ICON bị xóa" = true
|
|
727
|
+
const allIconsDeleted = iconPositions.length === 0 || iconPositions.every((p) => {
|
|
728
|
+
return p.is_delete_icon === true;
|
|
729
|
+
});
|
|
730
|
+
// Nếu tất cả TEXT trống và tất cả ICON bị xóa, chỉ render dòng "(không thêu gì)"
|
|
731
|
+
if (allTextEmpty && allIconsDeleted && side.positions.length > 0) {
|
|
732
|
+
ctx.save();
|
|
733
|
+
const otherFontSize = LAYOUT.OTHER_FONT_SIZE * scaleFactor;
|
|
734
|
+
const lineGap = LAYOUT.LINE_GAP * scaleFactor;
|
|
735
|
+
ctx.font = `${otherFontSize}px ${LAYOUT.FONT_FAMILY}`;
|
|
736
|
+
ctx.fillStyle = DEFAULT_ERROR_COLOR;
|
|
737
|
+
ctx.fillText("(không thêu gì)", padding, currentY);
|
|
738
|
+
currentY += otherFontSize + lineGap;
|
|
739
|
+
ctx.restore();
|
|
740
|
+
return currentY - startY;
|
|
741
|
+
}
|
|
742
|
+
// Compute uniform properties
|
|
636
743
|
const iconColorPositions = side.positions.filter((p) => p.type === "ICON" && (!p.layer_colors?.length || p.layer_colors.length === 1));
|
|
637
744
|
const iconColorValues = iconColorPositions
|
|
638
745
|
.map((p) => {
|
|
@@ -817,23 +924,87 @@ const renderUniformLabels = (ctx, uniformProps, x, y, maxWidth, scaleFactor, ima
|
|
|
817
924
|
rendered++;
|
|
818
925
|
}
|
|
819
926
|
if (values.color && values.color !== "None" && shouldRenderField("color")) {
|
|
820
|
-
const textMaxWidth = Math.max(LAYOUT.MIN_TEXT_WIDTH * scaleFactor, maxWidth - LAYOUT.SWATCH_RESERVED_SPACE * scaleFactor);
|
|
821
|
-
const result = wrapText(ctx, `Màu chỉ: ${values.color}`, x, cursorY, textMaxWidth, fontSize + lineGap);
|
|
822
|
-
const swatchH = Math.floor(fontSize * LAYOUT.SWATCH_HEIGHT_RATIO);
|
|
823
|
-
const swatchX = x +
|
|
824
|
-
Math.ceil(result.lastLineWidth) +
|
|
825
|
-
LAYOUT.ELEMENT_SPACING * scaleFactor;
|
|
826
|
-
const swatchY = result.lastLineY + Math.floor(fontSize / 2 - swatchH / 2);
|
|
827
927
|
const colors = values.color.includes(",")
|
|
828
928
|
? values.color.split(",").map((s) => s.trim())
|
|
829
929
|
: [values.color];
|
|
930
|
+
const swatchH = Math.floor(fontSize * LAYOUT.SWATCH_HEIGHT_RATIO);
|
|
931
|
+
const totalSwatchWidth = calculateSwatchesWidth(colors, swatchH, scaleFactor, imageRefs);
|
|
932
|
+
// Vẽ text với maxWidth bình thường (không trừ SWATCH_RESERVED_SPACE)
|
|
933
|
+
const result = wrapText(ctx, `Màu chỉ: ${values.color}`, x, cursorY, maxWidth, fontSize + lineGap);
|
|
934
|
+
// Kiểm tra xem có đủ chỗ cho swatches trên cùng dòng không
|
|
935
|
+
const textEndX = x + Math.ceil(result.lastLineWidth);
|
|
936
|
+
const spacing = LAYOUT.ELEMENT_SPACING * scaleFactor;
|
|
937
|
+
const swatchesStartX = textEndX + spacing;
|
|
938
|
+
const swatchesEndX = swatchesStartX + totalSwatchWidth;
|
|
939
|
+
const shouldWrapSwatches = swatchesEndX > x + maxWidth;
|
|
940
|
+
let swatchX;
|
|
941
|
+
let swatchY;
|
|
942
|
+
if (shouldWrapSwatches) {
|
|
943
|
+
// Không đủ chỗ, cho TẤT CẢ swatches xuống dòng mới
|
|
944
|
+
swatchX = x;
|
|
945
|
+
swatchY = result.lastLineY + fontSize + lineGap;
|
|
946
|
+
cursorY += result.height + fontSize + lineGap;
|
|
947
|
+
}
|
|
948
|
+
else {
|
|
949
|
+
// Đủ chỗ, vẽ swatches ngay sau text trên cùng dòng
|
|
950
|
+
swatchX = swatchesStartX;
|
|
951
|
+
swatchY = result.lastLineY + Math.floor(fontSize / 2 - swatchH / 2);
|
|
952
|
+
cursorY += result.height;
|
|
953
|
+
}
|
|
830
954
|
drawSwatches(ctx, colors, swatchX, swatchY, swatchH, scaleFactor, imageRefs);
|
|
831
|
-
|
|
955
|
+
if (shouldWrapSwatches) {
|
|
956
|
+
cursorY += swatchH;
|
|
957
|
+
}
|
|
832
958
|
rendered++;
|
|
833
959
|
}
|
|
834
960
|
if (values.floral && values.floral !== "None" && shouldRenderField("floral")) {
|
|
835
|
-
const
|
|
836
|
-
|
|
961
|
+
const floralUrl = getImageUrl("floral", values.floral);
|
|
962
|
+
const floralImg = imageRefs.current.get(floralUrl);
|
|
963
|
+
// Tính kích thước ảnh floral (thêm 50% = 2.5x fontSize)
|
|
964
|
+
const floralH = fontSize * 2.5;
|
|
965
|
+
let totalFloralWidth = 0;
|
|
966
|
+
if (floralImg?.complete && floralImg.naturalHeight > 0) {
|
|
967
|
+
const ratio = floralImg.naturalWidth / floralImg.naturalHeight;
|
|
968
|
+
totalFloralWidth = Math.max(1, Math.floor(floralH * ratio));
|
|
969
|
+
}
|
|
970
|
+
// Line height giống icon_image: floralH + lineGap
|
|
971
|
+
const floralLineHeight = floralH + lineGap;
|
|
972
|
+
// Text align bottom: đặt text ở dưới cùng của dòng
|
|
973
|
+
const textBottomY = cursorY + floralH;
|
|
974
|
+
// Đo width trước khi vẽ
|
|
975
|
+
ctx.font = `${fontSize}px ${LAYOUT.FONT_FAMILY}`;
|
|
976
|
+
const labelText = `Mẫu hoa: ${values.floral}`;
|
|
977
|
+
const labelWidth = ctx.measureText(labelText).width;
|
|
978
|
+
// Vẽ text với textBaseline = bottom
|
|
979
|
+
ctx.textBaseline = "bottom";
|
|
980
|
+
ctx.fillStyle = LAYOUT.LABEL_COLOR;
|
|
981
|
+
ctx.fillText(labelText, x, textBottomY);
|
|
982
|
+
// Reset textBaseline về top cho các phần tiếp theo
|
|
983
|
+
ctx.textBaseline = LAYOUT.TEXT_BASELINE;
|
|
984
|
+
// Kiểm tra xem có đủ chỗ cho ảnh floral trên cùng dòng không
|
|
985
|
+
const textEndX = x + labelWidth;
|
|
986
|
+
const spacing = LAYOUT.ELEMENT_SPACING * scaleFactor;
|
|
987
|
+
const floralStartX = textEndX + spacing;
|
|
988
|
+
const floralEndX = floralStartX + totalFloralWidth;
|
|
989
|
+
const shouldWrapFloral = floralEndX > x + maxWidth;
|
|
990
|
+
let floralX;
|
|
991
|
+
let floralY;
|
|
992
|
+
if (shouldWrapFloral) {
|
|
993
|
+
// Không đủ chỗ, cho ảnh floral xuống dòng mới
|
|
994
|
+
floralX = x;
|
|
995
|
+
floralY = textBottomY + lineGap;
|
|
996
|
+
cursorY += floralLineHeight;
|
|
997
|
+
}
|
|
998
|
+
else {
|
|
999
|
+
// Đủ chỗ, vẽ ảnh floral ngay sau text trên cùng dòng
|
|
1000
|
+
floralX = floralStartX;
|
|
1001
|
+
floralY = textBottomY - floralH; // Align bottom với text
|
|
1002
|
+
cursorY += floralLineHeight;
|
|
1003
|
+
}
|
|
1004
|
+
// Vẽ ảnh floral
|
|
1005
|
+
if (floralImg?.complete && floralImg.naturalHeight > 0) {
|
|
1006
|
+
ctx.drawImage(floralImg, floralX, floralY, totalFloralWidth, floralH);
|
|
1007
|
+
}
|
|
837
1008
|
rendered++;
|
|
838
1009
|
}
|
|
839
1010
|
if (rendered > 0)
|
|
@@ -841,40 +1012,82 @@ const renderUniformLabels = (ctx, uniformProps, x, y, maxWidth, scaleFactor, ima
|
|
|
841
1012
|
ctx.restore();
|
|
842
1013
|
return cursorY - y;
|
|
843
1014
|
};
|
|
844
|
-
const renderTextPosition = (ctx, position, x, y, maxWidth,
|
|
1015
|
+
const renderTextPosition = (ctx, position, x, y, maxWidth, // tổng chiều rộng usable (không tính padding ngoài)
|
|
1016
|
+
displayIndex, showLabels, scaleFactor, imageRefs) => {
|
|
845
1017
|
ctx.save();
|
|
846
1018
|
const textFontSize = LAYOUT.TEXT_FONT_SIZE * scaleFactor;
|
|
847
1019
|
const otherFontSize = LAYOUT.OTHER_FONT_SIZE * scaleFactor;
|
|
848
1020
|
const lineGap = LAYOUT.LINE_GAP * scaleFactor;
|
|
849
1021
|
let currentY = y;
|
|
850
1022
|
let drawnHeight = 0;
|
|
851
|
-
//
|
|
1023
|
+
// Chuẩn hóa xuống dòng:
|
|
1024
|
+
// - Hỗ trợ cả newline thật (\n) và chuỗi literal "\\n" từ JSON
|
|
1025
|
+
const normalizeNewlines = (text) => text
|
|
1026
|
+
.replace(/\r\n/g, "\n")
|
|
1027
|
+
.replace(/\r/g, "\n")
|
|
1028
|
+
.replace(/\\n/g, "\n");
|
|
1029
|
+
// Get display text (handle empty/null/undefined) sau khi normalize
|
|
1030
|
+
const rawOriginalText = position.text ?? "";
|
|
1031
|
+
const normalizedText = normalizeNewlines(rawOriginalText);
|
|
1032
|
+
const isEmptyText = normalizedText.trim() === "";
|
|
1033
|
+
// ===========================================================================
|
|
1034
|
+
// PHẦN TEXT CHÍNH
|
|
1035
|
+
// - Giữ nguyên format: chỉ xuống dòng khi có '\n'
|
|
1036
|
+
// - Không tự wrap theo maxWidth
|
|
1037
|
+
// - Nếu tổng chiều ngang > maxWidth, tự động giảm font-size để vừa
|
|
1038
|
+
// ===========================================================================
|
|
1039
|
+
// Label "Text N: " luôn là font mặc định (không bị co theo nội dung)
|
|
852
1040
|
const textLabel = `Text ${displayIndex}: `;
|
|
853
1041
|
ctx.font = `bold ${textFontSize}px ${LAYOUT.FONT_FAMILY}`;
|
|
854
1042
|
ctx.fillStyle = LAYOUT.LABEL_COLOR;
|
|
855
1043
|
const labelWidth = ctx.measureText(textLabel).width;
|
|
856
1044
|
ctx.fillText(textLabel, x, currentY);
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
const
|
|
860
|
-
//
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
1045
|
+
// Phần text value bắt đầu sau label
|
|
1046
|
+
const valueStartX = x + labelWidth;
|
|
1047
|
+
const availableWidth = Math.max(1, maxWidth - labelWidth);
|
|
1048
|
+
// Chuẩn hóa nội dung text để render
|
|
1049
|
+
const rawText = isEmptyText ? "(không có text)" : normalizedText;
|
|
1050
|
+
const lines = rawText.split("\n");
|
|
1051
|
+
// Tính font-size hiệu dụng cho phần value sao cho:
|
|
1052
|
+
// - Không vượt quá availableWidth
|
|
1053
|
+
// - Có thể thu nhỏ tùy ý (theo yêu cầu, không giới hạn tối thiểu)
|
|
1054
|
+
const measureMaxLineWidth = (fontSize) => {
|
|
1055
|
+
ctx.font = `${fontSize}px ${LAYOUT.FONT_FAMILY}`;
|
|
1056
|
+
let maxLineWidth = 0;
|
|
1057
|
+
lines.forEach((line) => {
|
|
1058
|
+
const w = ctx.measureText(line).width;
|
|
1059
|
+
if (w > maxLineWidth)
|
|
1060
|
+
maxLineWidth = w;
|
|
1061
|
+
});
|
|
1062
|
+
return maxLineWidth;
|
|
1063
|
+
};
|
|
1064
|
+
let effectiveTextFontSize = textFontSize;
|
|
1065
|
+
if (!isEmptyText) {
|
|
1066
|
+
const baseMaxWidth = measureMaxLineWidth(textFontSize);
|
|
1067
|
+
if (baseMaxWidth > availableWidth) {
|
|
1068
|
+
const shrinkRatio = availableWidth / baseMaxWidth;
|
|
1069
|
+
effectiveTextFontSize = textFontSize * shrinkRatio;
|
|
1070
|
+
}
|
|
872
1071
|
}
|
|
1072
|
+
// Vẽ phần value với font hiệu dụng, màu đỏ
|
|
1073
|
+
ctx.font = `${effectiveTextFontSize}px ${LAYOUT.FONT_FAMILY}`;
|
|
1074
|
+
ctx.fillStyle = DEFAULT_ERROR_COLOR;
|
|
1075
|
+
const valueLineHeight = effectiveTextFontSize; // giữ giống wrapText cũ (lineHeight = fontSize)
|
|
1076
|
+
let localY = currentY;
|
|
1077
|
+
lines.forEach((line, idx) => {
|
|
1078
|
+
ctx.fillText(line, valueStartX, localY);
|
|
1079
|
+
localY += valueLineHeight;
|
|
1080
|
+
});
|
|
1081
|
+
const textBlockHeight = lines.length * valueLineHeight;
|
|
1082
|
+
currentY += textBlockHeight;
|
|
1083
|
+
drawnHeight += textBlockHeight;
|
|
873
1084
|
// Draw additional labels (skip when text is empty)
|
|
874
1085
|
if (!isEmptyText) {
|
|
875
1086
|
currentY += lineGap;
|
|
876
1087
|
ctx.font = `${otherFontSize}px ${LAYOUT.FONT_FAMILY}`;
|
|
877
1088
|
ctx.fillStyle = LAYOUT.LABEL_COLOR;
|
|
1089
|
+
// Lưu ý: phần dưới này vẫn có thể wrap theo maxWidth vì đây chỉ là label mô tả,
|
|
1090
|
+
// không phải nội dung Text chính cần giữ nguyên format.
|
|
878
1091
|
if (showLabels.shape && position.text_shape) {
|
|
879
1092
|
const result = wrapText(ctx, `Kiểu chữ: ${position.text_shape}`, x, currentY, maxWidth, otherFontSize + lineGap);
|
|
880
1093
|
currentY += result.height;
|
|
@@ -905,74 +1118,213 @@ const renderTextPosition = (ctx, position, x, y, maxWidth, displayIndex, showLab
|
|
|
905
1118
|
if (showLabels.color) {
|
|
906
1119
|
const colorValue = position.character_colors?.join(", ") || position.color;
|
|
907
1120
|
if (colorValue) {
|
|
908
|
-
const textMaxWidth = Math.max(LAYOUT.MIN_TEXT_WIDTH * scaleFactor, maxWidth - LAYOUT.SWATCH_RESERVED_SPACE * scaleFactor);
|
|
909
|
-
const result = wrapText(ctx, `Màu chỉ: ${colorValue}`, x, currentY, textMaxWidth, otherFontSize + lineGap);
|
|
910
|
-
const swatchH = Math.floor(otherFontSize * LAYOUT.SWATCH_HEIGHT_RATIO);
|
|
911
|
-
const swatchX = x +
|
|
912
|
-
Math.ceil(result.lastLineWidth) +
|
|
913
|
-
LAYOUT.ELEMENT_SPACING * scaleFactor;
|
|
914
|
-
const swatchY = result.lastLineY + Math.floor(otherFontSize / 2 - swatchH / 2);
|
|
915
1121
|
const colors = position.character_colors || [position.color];
|
|
1122
|
+
const swatchH = Math.floor(otherFontSize * LAYOUT.SWATCH_HEIGHT_RATIO);
|
|
1123
|
+
const totalSwatchWidth = calculateSwatchesWidth(colors, swatchH, scaleFactor, imageRefs);
|
|
1124
|
+
// Vẽ text với maxWidth bình thường (không trừ SWATCH_RESERVED_SPACE)
|
|
1125
|
+
const result = wrapText(ctx, `Màu chỉ: ${colorValue}`, x, currentY, maxWidth, otherFontSize + lineGap);
|
|
1126
|
+
// Kiểm tra xem có đủ chỗ cho swatches trên cùng dòng không
|
|
1127
|
+
const textEndX = x + Math.ceil(result.lastLineWidth);
|
|
1128
|
+
const spacing = LAYOUT.ELEMENT_SPACING * scaleFactor;
|
|
1129
|
+
const swatchesStartX = textEndX + spacing;
|
|
1130
|
+
const swatchesEndX = swatchesStartX + totalSwatchWidth;
|
|
1131
|
+
const shouldWrapSwatches = swatchesEndX > x + maxWidth;
|
|
1132
|
+
let swatchX;
|
|
1133
|
+
let swatchY;
|
|
1134
|
+
if (shouldWrapSwatches) {
|
|
1135
|
+
// Không đủ chỗ, cho TẤT CẢ swatches xuống dòng mới
|
|
1136
|
+
swatchX = x;
|
|
1137
|
+
swatchY = result.lastLineY + otherFontSize + lineGap;
|
|
1138
|
+
currentY += result.height + otherFontSize + lineGap;
|
|
1139
|
+
drawnHeight += result.height + otherFontSize + lineGap;
|
|
1140
|
+
}
|
|
1141
|
+
else {
|
|
1142
|
+
// Đủ chỗ, vẽ swatches ngay sau text trên cùng dòng
|
|
1143
|
+
swatchX = swatchesStartX;
|
|
1144
|
+
swatchY = result.lastLineY + Math.floor(otherFontSize / 2 - swatchH / 2);
|
|
1145
|
+
currentY += result.height;
|
|
1146
|
+
drawnHeight += result.height;
|
|
1147
|
+
}
|
|
916
1148
|
drawSwatches(ctx, colors, swatchX, swatchY, swatchH, scaleFactor, imageRefs);
|
|
917
|
-
|
|
918
|
-
|
|
1149
|
+
if (shouldWrapSwatches) {
|
|
1150
|
+
currentY += swatchH;
|
|
1151
|
+
drawnHeight += swatchH;
|
|
1152
|
+
}
|
|
919
1153
|
}
|
|
920
1154
|
}
|
|
921
1155
|
if (showLabels.floral && position.floral_pattern) {
|
|
922
|
-
const
|
|
923
|
-
|
|
924
|
-
|
|
1156
|
+
const floralUrl = getImageUrl("floral", position.floral_pattern);
|
|
1157
|
+
const floralImg = imageRefs.current.get(floralUrl);
|
|
1158
|
+
// Tính kích thước ảnh floral (thêm 50% = 2.5x otherFontSize)
|
|
1159
|
+
const floralH = otherFontSize * 2.5;
|
|
1160
|
+
let totalFloralWidth = 0;
|
|
1161
|
+
if (floralImg?.complete && floralImg.naturalHeight > 0) {
|
|
1162
|
+
const ratio = floralImg.naturalWidth / floralImg.naturalHeight;
|
|
1163
|
+
totalFloralWidth = Math.max(1, Math.floor(floralH * ratio));
|
|
1164
|
+
}
|
|
1165
|
+
// Line height giống icon_image: floralH + lineGap
|
|
1166
|
+
const floralLineHeight = floralH + lineGap;
|
|
1167
|
+
// Text align bottom: đặt text ở dưới cùng của dòng
|
|
1168
|
+
const textBottomY = currentY + floralH;
|
|
1169
|
+
// Đo width trước khi vẽ
|
|
1170
|
+
ctx.font = `${otherFontSize}px ${LAYOUT.FONT_FAMILY}`;
|
|
1171
|
+
const labelText = `Mẫu hoa: ${position.floral_pattern}`;
|
|
1172
|
+
const labelWidth = ctx.measureText(labelText).width;
|
|
1173
|
+
// Vẽ text với textBaseline = bottom
|
|
1174
|
+
ctx.textBaseline = "bottom";
|
|
1175
|
+
ctx.fillStyle = LAYOUT.LABEL_COLOR;
|
|
1176
|
+
ctx.fillText(labelText, x, textBottomY);
|
|
1177
|
+
// Reset textBaseline về top cho các phần tiếp theo
|
|
1178
|
+
ctx.textBaseline = LAYOUT.TEXT_BASELINE;
|
|
1179
|
+
// Kiểm tra xem có đủ chỗ cho ảnh floral trên cùng dòng không
|
|
1180
|
+
const textEndX = x + labelWidth;
|
|
1181
|
+
const spacing = LAYOUT.ELEMENT_SPACING * scaleFactor;
|
|
1182
|
+
const floralStartX = textEndX + spacing;
|
|
1183
|
+
const floralEndX = floralStartX + totalFloralWidth;
|
|
1184
|
+
const shouldWrapFloral = floralEndX > x + maxWidth;
|
|
1185
|
+
let floralX;
|
|
1186
|
+
let floralY;
|
|
1187
|
+
if (shouldWrapFloral) {
|
|
1188
|
+
// Không đủ chỗ, cho ảnh floral xuống dòng mới
|
|
1189
|
+
floralX = x;
|
|
1190
|
+
floralY = textBottomY + lineGap;
|
|
1191
|
+
currentY += floralLineHeight;
|
|
1192
|
+
drawnHeight += floralLineHeight;
|
|
1193
|
+
}
|
|
1194
|
+
else {
|
|
1195
|
+
// Đủ chỗ, vẽ ảnh floral ngay sau text trên cùng dòng
|
|
1196
|
+
floralX = floralStartX;
|
|
1197
|
+
floralY = textBottomY - floralH; // Align bottom với text
|
|
1198
|
+
currentY += floralLineHeight;
|
|
1199
|
+
drawnHeight += floralLineHeight;
|
|
1200
|
+
}
|
|
1201
|
+
// Vẽ ảnh floral
|
|
1202
|
+
if (floralImg?.complete && floralImg.naturalHeight > 0) {
|
|
1203
|
+
ctx.drawImage(floralImg, floralX, floralY, totalFloralWidth, floralH);
|
|
1204
|
+
}
|
|
925
1205
|
}
|
|
926
1206
|
}
|
|
927
1207
|
ctx.restore();
|
|
928
1208
|
return drawnHeight;
|
|
929
1209
|
};
|
|
930
1210
|
const renderIconPosition = (ctx, position, x, y, maxWidth, scaleFactor, imageRefs, options) => {
|
|
931
|
-
|
|
1211
|
+
// Dùng cùng font size với Text cho label và value icon
|
|
1212
|
+
const iconFontSize = LAYOUT.TEXT_FONT_SIZE * scaleFactor;
|
|
932
1213
|
const lineGap = LAYOUT.LINE_GAP * scaleFactor;
|
|
933
1214
|
ctx.save();
|
|
934
|
-
ctx.font = `${iconFontSize}px ${LAYOUT.FONT_FAMILY}`;
|
|
935
1215
|
ctx.fillStyle = LAYOUT.LABEL_COLOR;
|
|
936
1216
|
let cursorY = y;
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
1217
|
+
// Tách label "Icon:" (in đậm) và phần value (thường)
|
|
1218
|
+
const iconLabel = "Icon:";
|
|
1219
|
+
let iconValue;
|
|
1220
|
+
if (position.is_delete_icon) {
|
|
1221
|
+
// Ưu tiên hiển thị không có icon nếu được đánh dấu xóa
|
|
1222
|
+
iconValue = "(không có icon)";
|
|
1223
|
+
}
|
|
1224
|
+
else if (position.note) {
|
|
1225
|
+
iconValue = position.note;
|
|
1226
|
+
}
|
|
1227
|
+
else if (position.icon_name && position.icon_name.trim().length > 0) {
|
|
1228
|
+
// Nếu có icon_name thì hiển thị tên đó
|
|
1229
|
+
iconValue = position.icon_name;
|
|
1230
|
+
}
|
|
1231
|
+
else if (position.icon === 0) {
|
|
1232
|
+
// Icon mặc định theo file thêu
|
|
1233
|
+
iconValue = "(icon mặc định theo file thêu)";
|
|
1234
|
+
}
|
|
1235
|
+
else {
|
|
1236
|
+
// Fallback: hiển thị mã icon (ép sang string)
|
|
1237
|
+
iconValue = String(position.icon);
|
|
1238
|
+
}
|
|
1239
|
+
// Kiểm tra xem có icon_image không để tính height phù hợp
|
|
1240
|
+
const hasIconImage = position.icon_image && position.icon_image.trim().length > 0;
|
|
1241
|
+
const iconImageHeight = hasIconImage ? iconFontSize * 2 : iconFontSize;
|
|
1242
|
+
// Text align bottom: đặt text ở dưới cùng của dòng
|
|
1243
|
+
const textBottomY = cursorY + iconImageHeight;
|
|
1244
|
+
// Đo width trước khi vẽ
|
|
1245
|
+
ctx.font = `bold ${iconFontSize}px ${LAYOUT.FONT_FAMILY}`;
|
|
1246
|
+
const labelWidth = ctx.measureText(iconLabel).width;
|
|
1247
|
+
ctx.font = `${iconFontSize}px ${LAYOUT.FONT_FAMILY}`;
|
|
1248
|
+
const valueText = ` ${iconValue}`;
|
|
1249
|
+
const valueWidth = ctx.measureText(valueText).width;
|
|
1250
|
+
// Vẽ text với textBaseline = bottom
|
|
1251
|
+
ctx.textBaseline = "bottom";
|
|
1252
|
+
ctx.font = `bold ${iconFontSize}px ${LAYOUT.FONT_FAMILY}`;
|
|
1253
|
+
ctx.fillStyle = LAYOUT.LABEL_COLOR;
|
|
1254
|
+
ctx.fillText(iconLabel, x, textBottomY);
|
|
1255
|
+
ctx.font = `${iconFontSize}px ${LAYOUT.FONT_FAMILY}`;
|
|
1256
|
+
ctx.fillStyle = DEFAULT_ERROR_COLOR;
|
|
1257
|
+
ctx.fillText(valueText, x + labelWidth, textBottomY);
|
|
1258
|
+
ctx.fillStyle = LAYOUT.LABEL_COLOR;
|
|
1259
|
+
// Reset textBaseline về top cho các phần tiếp theo
|
|
1260
|
+
ctx.textBaseline = LAYOUT.TEXT_BASELINE;
|
|
1261
|
+
const iconResult = {
|
|
1262
|
+
height: iconImageHeight + lineGap,
|
|
1263
|
+
// tổng width của cả label + value, dùng để canh icon image lệch sang phải
|
|
1264
|
+
lastLineWidth: labelWidth + valueWidth};
|
|
942
1265
|
// Draw icon image
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
const img = imageRefs.current.get(
|
|
1266
|
+
const iconUrl = getIconImageUrl(position);
|
|
1267
|
+
if (iconUrl) {
|
|
1268
|
+
const img = imageRefs.current.get(iconUrl);
|
|
946
1269
|
if (img?.complete && img.naturalHeight > 0) {
|
|
947
1270
|
const ratio = img.naturalWidth / img.naturalHeight;
|
|
948
|
-
|
|
1271
|
+
// Nếu có icon_image thì hiển thị to gấp đôi
|
|
1272
|
+
const iconHeight = hasIconImage ? iconFontSize * 2 : iconFontSize;
|
|
1273
|
+
const swatchW = Math.max(1, Math.floor(iconHeight * ratio));
|
|
949
1274
|
const iconX = x +
|
|
950
1275
|
Math.ceil(iconResult.lastLineWidth) +
|
|
951
1276
|
LAYOUT.ELEMENT_SPACING * scaleFactor;
|
|
952
|
-
const iconY =
|
|
953
|
-
ctx.drawImage(img, iconX, iconY, swatchW,
|
|
1277
|
+
const iconY = textBottomY - iconHeight; // Align bottom với text
|
|
1278
|
+
ctx.drawImage(img, iconX, iconY, swatchW, iconHeight);
|
|
954
1279
|
}
|
|
955
1280
|
}
|
|
956
1281
|
cursorY += iconResult.height;
|
|
957
1282
|
// Draw color swatches (prefer layer_colors, fallback to single color)
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
1283
|
+
// Nếu icon đã bị xóa thì không cần hiển thị màu chỉ nữa
|
|
1284
|
+
const iconColors = position.is_delete_icon
|
|
1285
|
+
? null
|
|
1286
|
+
: position.layer_colors?.length
|
|
1287
|
+
? position.layer_colors
|
|
1288
|
+
: position.color
|
|
1289
|
+
? [position.color]
|
|
1290
|
+
: null;
|
|
963
1291
|
const layerCount = position.layer_colors?.length ?? 0;
|
|
964
1292
|
const hasMultiLayerColors = layerCount > 1;
|
|
965
1293
|
const shouldSkipColorSection = options?.hideColor && !hasMultiLayerColors;
|
|
966
1294
|
if (iconColors?.length && !shouldSkipColorSection) {
|
|
967
|
-
|
|
968
|
-
const
|
|
969
|
-
const swatchH = Math.floor(
|
|
970
|
-
const
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
1295
|
+
// Dòng "Màu chỉ:" của icon dùng OTHER_FONT_SIZE, không dùng iconFontSize
|
|
1296
|
+
const otherFontSize = LAYOUT.OTHER_FONT_SIZE * scaleFactor;
|
|
1297
|
+
const swatchH = Math.floor(otherFontSize * LAYOUT.SWATCH_HEIGHT_RATIO);
|
|
1298
|
+
const totalSwatchWidth = calculateSwatchesWidth(iconColors, swatchH, scaleFactor, imageRefs);
|
|
1299
|
+
// Set font về OTHER_FONT_SIZE trước khi vẽ text "Màu chỉ:"
|
|
1300
|
+
ctx.font = `${otherFontSize}px ${LAYOUT.FONT_FAMILY}`;
|
|
1301
|
+
ctx.fillStyle = LAYOUT.LABEL_COLOR;
|
|
1302
|
+
// Vẽ text với maxWidth bình thường (không trừ SWATCH_RESERVED_SPACE)
|
|
1303
|
+
const colorResult = wrapText(ctx, `Màu chỉ: ${iconColors.join(", ")}`, x, cursorY, maxWidth, otherFontSize + lineGap);
|
|
1304
|
+
// Kiểm tra xem có đủ chỗ cho swatches trên cùng dòng không
|
|
1305
|
+
const textEndX = x + Math.ceil(colorResult.lastLineWidth);
|
|
1306
|
+
const spacing = LAYOUT.ELEMENT_SPACING * scaleFactor;
|
|
1307
|
+
const swatchesStartX = textEndX + spacing;
|
|
1308
|
+
const swatchesEndX = swatchesStartX + totalSwatchWidth;
|
|
1309
|
+
const shouldWrapSwatches = swatchesEndX > x + maxWidth;
|
|
1310
|
+
let swatchX;
|
|
1311
|
+
let swatchY;
|
|
1312
|
+
if (shouldWrapSwatches) {
|
|
1313
|
+
// Không đủ chỗ, cho TẤT CẢ swatches xuống dòng mới
|
|
1314
|
+
swatchX = x;
|
|
1315
|
+
swatchY = colorResult.lastLineY + otherFontSize + lineGap;
|
|
1316
|
+
cursorY += colorResult.height + otherFontSize + lineGap;
|
|
1317
|
+
}
|
|
1318
|
+
else {
|
|
1319
|
+
// Đủ chỗ, vẽ swatches ngay sau text trên cùng dòng
|
|
1320
|
+
swatchX = swatchesStartX;
|
|
1321
|
+
swatchY = colorResult.lastLineY + Math.floor(otherFontSize / 2 - swatchH / 2);
|
|
1322
|
+
cursorY += colorResult.height;
|
|
1323
|
+
}
|
|
974
1324
|
drawSwatches(ctx, iconColors, swatchX, swatchY, swatchH, scaleFactor, imageRefs);
|
|
975
|
-
|
|
1325
|
+
if (shouldWrapSwatches) {
|
|
1326
|
+
cursorY += swatchH;
|
|
1327
|
+
}
|
|
976
1328
|
}
|
|
977
1329
|
ctx.restore();
|
|
978
1330
|
return cursorY - y;
|