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