import React, { useCallback, useRef, useEffect, useMemo, useState } from 'react';
import JsBarcode from 'jsbarcode';
import { QRCodeSVG } from 'qrcode.react';
import type {
LabelTemplate,
LabelElement,
NutritionExtraItem,
PrintOrientation,
} from '../../../types/labelTemplate';
import {
canonicalElementType,
isPrintInputElement,
isCompanyAutoElement,
resolveLabelEditorElementFontFamily,
} from '../../../types/labelTemplate';
import {
INVERT_COLORS_BG,
INVERT_COLORS_FG,
readInvertColors,
} from '../../../utils/invertColorsConfig';
import { PRESET_LABEL_SIZES } from '../../../types/labelTemplate';
import { NUTRITION_FIXED_ITEMS } from '../../../types/labelTemplate';
import { cn } from '../../ui/utils';
import { resolvePictureUrlForDisplay } from '../../../services/imageUploadService';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../../ui/select';
import { normalizeBarcodeType, toJsBarcodeFormat } from '../../../lib/barcodeFormat';
import { formatCompanyPrintPreviewText } from '../../../utils/companyPrintFields';
import { pxToUnit, unitToPx } from '@/utils/labelTemplateUnits';
import {
type PreviewRulerDisplayUnit,
convertTemplateLengthToDisplay,
elementPxToDisplayLength,
formatPreviewRulerDisplayValue,
previewRulerUnitLabel,
} from '@/utils/previewRulerUnits';
import { isLikelyResolvedDateTimeLiteral } from '../../../lib/labelFormDatePreview';
export type { PrintOrientation } from '../../../types/labelTemplate';
export function printContentLayerStyle(
orientation: PrintOrientation,
baseW: number,
baseH: number,
): React.CSSProperties {
if (orientation !== 'horizontal') return {};
return {
transform: 'rotate(90deg)',
transformOrigin: `${baseW / 2}px ${baseH / 2}px`,
};
}
/** 横打 preview 层 rotate(90deg) 的逆变换:画布本地坐标 → 模板坐标 */
export function canvasLocalToTemplateSpace(
localX: number,
localY: number,
orientation: PrintOrientation,
baseW: number,
baseH: number,
): { x: number; y: number } {
if (orientation !== 'horizontal') {
return { x: localX, y: localY };
}
const cx = baseW / 2;
const cy = baseH / 2;
// 与 printContentLayerStyle rotate(90deg) 成对
return {
x: cx + localY - cy,
y: cx - localX + cy,
};
}
export function clientToCanvasLocalPoint(
clientX: number,
clientY: number,
canvasEl: HTMLElement | null,
scale: number,
): { x: number; y: number } | null {
if (!canvasEl) return null;
const rect = canvasEl.getBoundingClientRect();
return {
x: (clientX - rect.left) / scale,
y: (clientY - rect.top) / scale,
};
}
/** 横打时将画布位移增量映射到模板坐标增量 */
export function mapPointerDeltaToTemplateSpace(
dx: number,
dy: number,
orientation: PrintOrientation,
): { dx: number; dy: number } {
if (orientation !== 'horizontal') return { dx, dy };
return { dx: dy, dy: -dx };
}
/** 根据指针位置与抓取偏移计算模板坐标(横打跟随视觉方向) */
export function templatePositionFromPointerGrab(
localX: number,
localY: number,
grabOffsetX: number,
grabOffsetY: number,
orientation: PrintOrientation,
baseW: number,
baseH: number,
): { x: number; y: number } {
const templatePtr = canvasLocalToTemplateSpace(localX, localY, orientation, baseW, baseH);
return {
x: templatePtr.x - grabOffsetX,
y: templatePtr.y - grabOffsetY,
};
}
const ELEMENT_DRAG_THRESHOLD_PX = 4;
/** 横打预览 rotate(90deg) 时,屏幕拖拽增量映射到模板宽/高增量 */
export function elementResizeDeltaFromPointer(
dlx: number,
dly: number,
handleId: string,
orientation: PrintOrientation,
): { dw: number; dh: number } {
if (orientation === 'horizontal') {
let dw = 0;
let dh = 0;
if (handleId.includes('e')) dw += dly;
if (handleId.includes('w')) dw -= dly;
if (handleId.includes('s')) dh -= dlx;
if (handleId.includes('n')) dh += dlx;
return { dw, dh };
}
let dw = 0;
let dh = 0;
if (handleId.includes('e')) dw += dlx;
if (handleId.includes('w')) dw -= dlx;
if (handleId.includes('s')) dh += dly;
if (handleId.includes('n')) dh -= dly;
return { dw, dh };
}
/** 将画布本地坐标系中的指针位移转为模板坐标系增量(横打已含 rotate 逆变换) */
export function templatePointerDeltaFromCanvasLocals(
startLocalX: number,
startLocalY: number,
currentLocalX: number,
currentLocalY: number,
orientation: PrintOrientation,
baseW: number,
baseH: number,
): { dtx: number; dty: number } {
const start = canvasLocalToTemplateSpace(startLocalX, startLocalY, orientation, baseW, baseH);
const current = canvasLocalToTemplateSpace(currentLocalX, currentLocalY, orientation, baseW, baseH);
return {
dtx: current.x - start.x,
dty: current.y - start.y,
};
}
export function computeResizedElementBox(
elX: number,
elY: number,
w: number,
h: number,
handleId: string,
dlx: number,
dly: number,
orientation: PrintOrientation,
): { x: number; y: number; width: number; height: number } {
const { dw, dh } = elementResizeDeltaFromPointer(dlx, dly, handleId, orientation);
let nw = w;
let nh = h;
let nx = elX;
let ny = elY;
if (handleId.includes('e')) nw = Math.max(20, w + dw);
if (handleId.includes('w')) {
nw = Math.max(20, w - dw);
nx = elX + (w - nw);
}
if (handleId.includes('s')) nh = Math.max(12, h + dh);
if (handleId.includes('n')) {
nh = Math.max(12, h - dh);
ny = elY + (h - nh);
}
return { x: nx, y: ny, width: nw, height: nh };
}
function PrintOrientationToggle({
value,
onChange,
}: {
value: PrintOrientation;
onChange: (v: PrintOrientation) => void;
}) {
const btnBase =
'h-8 w-8 rounded border flex items-center justify-center shrink-0 transition-colors shadow-sm active:scale-95';
const btnActive = 'bg-blue-600 border-blue-600 text-white';
const btnIdle = 'bg-white border-gray-300 text-gray-800 hover:bg-gray-50';
return (
);
}
/** 真实条形码渲染(JsBarcode),支持水平/竖排与制式 */
function BarcodeBlock({
data,
width,
height,
showText,
orientation = 'horizontal',
barcodeType,
fontSize = 14,
textAlign = 'center',
}: {
data: string;
width: number;
height: number;
showText?: boolean;
orientation?: 'horizontal' | 'vertical';
barcodeType?: unknown;
fontSize?: number;
textAlign?: 'left' | 'center' | 'right' | string;
}) {
const svgRef = useRef(null);
const isVertical = orientation === 'vertical';
const labelReserve = showText !== false ? Math.max(12, Math.round(fontSize) + 4) : 4;
const barHeight = Math.max(20, (isVertical ? width : height) - labelReserve);
const jsFormat = toJsBarcodeFormat(barcodeType);
const align =
textAlign === 'right' ? 'flex-end' : textAlign === 'center' ? 'center' : 'flex-start';
useEffect(() => {
if (svgRef.current && data) {
try {
JsBarcode(svgRef.current, data, {
format: jsFormat,
width: 1,
height: barHeight,
displayValue: showText !== false,
margin: 2,
fontOptions: '',
fontSize: Math.max(8, Math.round(fontSize)),
textAlign: textAlign === 'right' ? 'right' : textAlign === 'center' ? 'center' : 'left',
});
} catch {
// invalid data, ignore
}
}
}, [data, barHeight, showText, jsFormat, fontSize, textAlign]);
const svg = ;
if (isVertical) {
return (
);
}
return (
);
}
/** 画布网格步长(px),控件吸附到该步长 */
const GRID_SIZE = 8;
/** 将数值对齐到网格 */
function snapToGrid(value: number): number {
return Math.round(value / GRID_SIZE) * GRID_SIZE;
}
/** 画布内安全边距(px),与主题色虚线框一致;控件不可移出 */
export const LABEL_CANVAS_SAFE_MARGIN_PX = 16;
/** 蓝色虚线安全区(固定在纸张坐标系,竖打时不随控件旋转) */
function CanvasSafeMarginOverlay({ baseW, baseH }: { baseW: number; baseH: number }) {
if (baseW <= LABEL_CANVAS_SAFE_MARGIN_PX * 2 || baseH <= LABEL_CANVAS_SAFE_MARGIN_PX * 2) {
return null;
}
const m = LABEL_CANVAS_SAFE_MARGIN_PX;
const stroke = '#2563eb';
const sw = 2;
const dash = '10 6';
return (
);
}
/** 将控件矩形限制在虚线安全区内(画布像素坐标;数据始终为纸张 WxH 坐标) */
export function clampLabelElementBox(
x: number,
y: number,
w: number,
h: number,
baseW: number,
baseH: number,
marginPx: number = LABEL_CANVAS_SAFE_MARGIN_PX,
orientation: PrintOrientation = 'vertical',
): { x: number; y: number; w: number; h: number } {
if (orientation === 'horizontal') {
const cx = baseW / 2;
const cy = baseH / 2;
const minX = cx - cy + marginPx;
const minY = cx + cy - baseW + marginPx;
const innerW = baseH - marginPx * 2;
const innerH = baseW - marginPx * 2;
if (innerW < GRID_SIZE || innerH < GRID_SIZE) {
return {
x: minX,
y: minY,
w: Math.max(GRID_SIZE, snapToGrid(innerW)),
h: Math.max(GRID_SIZE, snapToGrid(innerH)),
};
}
let cw = Math.min(Math.max(20, snapToGrid(w)), snapToGrid(innerW));
let ch = Math.min(Math.max(12, snapToGrid(h)), snapToGrid(innerH));
const maxX = baseH - marginPx + cx - cy - cw;
const maxY = cx + cy - marginPx - ch;
if (maxX < minX || maxY < minY) {
return { x: minX, y: minY, w: snapToGrid(innerW), h: snapToGrid(innerH) };
}
let nx = snapToGrid(x);
let ny = snapToGrid(y);
nx = Math.min(Math.max(nx, minX), snapToGrid(maxX));
ny = Math.min(Math.max(ny, minY), snapToGrid(maxY));
return { x: nx, y: ny, w: cw, h: ch };
}
const minX = marginPx;
const minY = marginPx;
const maxR = baseW - marginPx;
const maxB = baseH - marginPx;
let cw = Math.max(20, snapToGrid(w));
let ch = Math.max(12, snapToGrid(h));
const innerW = maxR - minX;
const innerH = maxB - minY;
if (innerW < GRID_SIZE || innerH < GRID_SIZE) {
return {
x: minX,
y: minY,
w: Math.max(GRID_SIZE, snapToGrid(innerW)),
h: Math.max(GRID_SIZE, snapToGrid(innerH)),
};
}
cw = Math.min(cw, snapToGrid(innerW));
ch = Math.min(ch, snapToGrid(innerH));
let cx = snapToGrid(x);
let cy = snapToGrid(y);
cx = Math.min(Math.max(cx, minX), Math.max(minX, maxR - cw));
cy = Math.min(Math.max(cy, minY), Math.max(minY, maxB - ch));
return { x: cx, y: cy, w: cw, h: ch };
}
/** 拖拽时仅限制位置,不改变宽高 */
function clampDragPosition(
x: number,
y: number,
w: number,
h: number,
baseW: number,
baseH: number,
marginPx: number,
orientation: PrintOrientation = 'vertical',
): { x: number; y: number } {
if (orientation === 'horizontal') {
const cx = baseW / 2;
const cy = baseH / 2;
const minX = cx - cy + marginPx;
const minY = cx + cy - baseW + marginPx;
const maxX = baseH - marginPx + cx - cy - w;
const maxY = cx + cy - marginPx - h;
if (!Number.isFinite(maxX) || !Number.isFinite(maxY) || maxX < minX || maxY < minY) {
return { x: minX, y: minY };
}
let nx = snapToGrid(x);
let ny = snapToGrid(y);
nx = Math.min(Math.max(nx, minX), snapToGrid(maxX));
ny = Math.min(Math.max(ny, minY), snapToGrid(maxY));
return { x: nx, y: ny };
}
const minX = marginPx;
const minY = marginPx;
const maxR = baseW - marginPx;
const maxB = baseH - marginPx;
const maxX = maxR - w;
const maxY = maxB - h;
if (!Number.isFinite(maxX) || !Number.isFinite(maxY) || maxX < minX || maxY < minY) {
return { x: minX, y: minY };
}
let cx = snapToGrid(x);
let cy = snapToGrid(y);
cx = Math.min(Math.max(cx, minX), snapToGrid(maxX));
cy = Math.min(Math.max(cy, minY), snapToGrid(maxY));
return { x: cx, y: cy };
}
const RULER_H = 24;
const RULER_W = 24;
/** 拖拽/缩放过程中尚未落库的控件几何补丁 */
export type LabelElementLivePatch = {
id: string;
x?: number;
y?: number;
width?: number;
height?: number;
};
export function mergeLabelElementLivePatch(
el: LabelElement,
patch: LabelElementLivePatch | null | undefined,
): LabelElement {
if (!patch || patch.id !== el.id) return el;
return { ...el, ...patch };
}
export function mergeTemplateElementsLivePatch(
elements: LabelElement[],
patch: LabelElementLivePatch | null | undefined,
): LabelElement[] {
if (!patch?.id) return elements;
return elements.map((el) => mergeLabelElementLivePatch(el, patch));
}
/** @deprecated 请从 `@/utils/previewRulerUnits` 导入 */
export type { PreviewRulerDisplayUnit } from '@/utils/previewRulerUnits';
function formatSelectionLengthForPreviewRuler(
lengthPx: number,
basePaperPx: number,
paperSizeTemplate: number,
templateUnit: "cm" | "inch",
displayUnit: PreviewRulerDisplayUnit,
): string | null {
if (
!Number.isFinite(lengthPx) ||
!Number.isFinite(basePaperPx) ||
basePaperPx <= 0 ||
!Number.isFinite(paperSizeTemplate) ||
paperSizeTemplate <= 0
) {
return null;
}
const d = elementPxToDisplayLength(
lengthPx,
basePaperPx,
paperSizeTemplate,
templateUnit,
displayUnit,
);
const label = formatPreviewRulerDisplayValue(d, displayUnit);
return `${label}${previewRulerUnitLabel(displayUnit)}`;
}
/** 画布选中框旁尺寸读数(仅数值,与参考图一致) */
function formatElementDimensionValue(
lengthPx: number,
basePaperPx: number,
paperSizeTemplate: number,
templateUnit: "cm" | "inch",
displayUnit: PreviewRulerDisplayUnit,
): string {
const d = elementPxToDisplayLength(
lengthPx,
basePaperPx,
paperSizeTemplate,
templateUnit,
displayUnit,
);
return formatPreviewRulerDisplayValue(d, displayUnit);
}
const ELEMENT_RESIZE_HANDLE_SIZE = 8;
const ELEMENT_RESIZE_HANDLE_HIT = 14;
const ELEMENT_RESIZE_HANDLES = [
{ id: "nw", cx: 0, cy: 0, cursor: "nwse-resize" },
{ id: "n", cx: 0.5, cy: 0, cursor: "ns-resize" },
{ id: "ne", cx: 1, cy: 0, cursor: "nesw-resize" },
{ id: "e", cx: 1, cy: 0.5, cursor: "ew-resize" },
{ id: "se", cx: 1, cy: 1, cursor: "nwse-resize" },
{ id: "s", cx: 0.5, cy: 1, cursor: "ns-resize" },
{ id: "sw", cx: 0, cy: 1, cursor: "nesw-resize" },
{ id: "w", cx: 0, cy: 0.5, cursor: "ew-resize" },
] as const;
function dimensionArrowHead(
tipX: number,
tipY: number,
dir: "left" | "right" | "up" | "down",
): string {
const s = 3;
if (dir === "left") return `${tipX},${tipY} ${tipX + s},${tipY - s} ${tipX + s},${tipY + s}`;
if (dir === "right") return `${tipX},${tipY} ${tipX - s},${tipY - s} ${tipX - s},${tipY + s}`;
if (dir === "up") return `${tipX},${tipY} ${tipX - s},${tipY + s} ${tipX + s},${tipY + s}`;
return `${tipX},${tipY} ${tipX - s},${tipY - s} ${tipX + s},${tipY - s}`;
}
/** 选中控件:方形虚线框 + 宽高标注 + 8 个红色拖拽点 */
function ElementSelectionFrame({
el,
templateUnit,
paperWidthPx,
paperHeightPx,
paperWidthTemplate,
paperHeightTemplate,
displayUnit,
printOrientation = 'vertical',
interactive = false,
onResizePointerDown,
}: {
el: LabelElement;
templateUnit: "cm" | "inch";
paperWidthPx: number;
paperHeightPx: number;
paperWidthTemplate: number;
paperHeightTemplate: number;
displayUnit: PreviewRulerDisplayUnit;
printOrientation?: PrintOrientation;
interactive?: boolean;
onResizePointerDown?: (e: React.PointerEvent, handleId: string) => void;
}) {
const x = el.x;
const y = el.y;
const w = Math.max(1, el.width);
const h = Math.max(1, el.height);
const wValue = formatElementDimensionValue(
w,
paperWidthPx,
paperWidthTemplate,
templateUnit,
displayUnit,
);
const hValue = formatElementDimensionValue(
h,
paperHeightPx,
paperHeightTemplate,
templateUnit,
displayUnit,
);
const widthAbove = y >= 28;
const widthLineY = widthAbove ? y - 12 : y + h + 12;
const widthTextY = widthAbove ? widthLineY - 6 : widthLineY + 14;
const heightOnLeft = x >= 36;
const heightLineX = heightOnLeft ? x - 12 : x + w + 12;
const heightTextX = heightOnLeft ? heightLineX - 8 : heightLineX + 8;
// 横打预览旋转后,屏幕上的横/竖跨度与模板宽/高对调,标注跟随视觉
const isRotatedPrintPreview = printOrientation === 'horizontal';
const displayWidthLabel = isRotatedPrintPreview ? hValue : wValue;
const displayHeightLabel = isRotatedPrintPreview ? wValue : hValue;
return (
<>
{interactive && onResizePointerDown
? ELEMENT_RESIZE_HANDLES.map((handle) => {
const left = x + w * handle.cx - ELEMENT_RESIZE_HANDLE_HIT / 2;
const top = y + h * handle.cy - ELEMENT_RESIZE_HANDLE_HIT / 2;
const inset = (ELEMENT_RESIZE_HANDLE_HIT - ELEMENT_RESIZE_HANDLE_SIZE) / 2;
return (
{
if (e.button !== 0) return;
e.stopPropagation();
e.preventDefault();
onResizePointerDown(e, handle.id);
}}
onClick={(e) => e.stopPropagation()}
>
);
})
: null}
>
);
}
/**
* 贯穿预览区全宽的横向标尺:刻度按「预览标尺单位」绘制;与模板画布单位无关。
*/
function RulerBarHorizontal({
rulerTotalWidthPx,
paperWidthPx,
paperOffsetLeftPx,
paperWidthTemplate,
templateUnit,
displayUnit,
baseW,
selection,
}: {
rulerTotalWidthPx: number;
paperWidthPx: number;
paperOffsetLeftPx: number;
/** 模板定义的纸张宽度(模板单位) */
paperWidthTemplate: number;
templateUnit: "cm" | "inch";
displayUnit: PreviewRulerDisplayUnit;
baseW: number;
selection: { x: number; width: number } | null;
}) {
const displaySpan = convertTemplateLengthToDisplay(paperWidthTemplate, templateUnit, displayUnit);
if (!Number.isFinite(displaySpan) || displaySpan <= 0 || !Number.isFinite(paperWidthPx) || paperWidthPx < 1) {
return null;
}
if (!Number.isFinite(rulerTotalWidthPx) || rulerTotalWidthPx < 1) {
return null;
}
const h = RULER_H;
const pxPerDisplayUnit = paperWidthPx / displaySpan;
/** 标尺几何中心为刻度 0,向左为负、向右为正 */
const centerPx = rulerTotalWidthPx / 2;
const xAtSignedUnit = (u: number) => centerPx + u * pxPerDisplayUnit;
const nodes: React.ReactNode[] = [];
let labelStep = 1;
if (displayUnit === "mm") {
if (displaySpan > 120) labelStep = 20;
else if (displaySpan > 60) labelStep = 10;
else if (displaySpan > 25) labelStep = 5;
}
const minorDivisions = displayUnit === "inch" ? 8 : 10;
const kMin = Math.floor((0 - centerPx) / pxPerDisplayUnit) - 2;
const kMax = Math.ceil((rulerTotalWidthPx - centerPx) / pxPerDisplayUnit) + 2;
const kLo = Math.max(-5000, Math.min(5000, kMin));
const kHi = Math.max(-5000, Math.min(5000, kMax));
for (let k = kLo; k <= kHi; k++) {
const x = xAtSignedUnit(k);
if (x < -8 || x > rulerTotalWidthPx + 8) continue;
const showLabel = k === 0 || k % labelStep === 0;
nodes.push(
{showLabel ? (
{k}
) : null}
,
);
const midMinor = Math.floor(minorDivisions / 2);
for (let s = 1; s < minorDivisions; s++) {
const u = k + s / minorDivisions;
const x2 = xAtSignedUnit(u);
if (x2 < -4 || x2 > rulerTotalWidthPx + 4) continue;
const y2 = s === midMinor ? 10 : 12;
nodes.push(
,
);
}
}
let selLeft = 0;
let selW = 0;
if (selection && Number.isFinite(baseW) && baseW > 0) {
selLeft = paperOffsetLeftPx + (selection.x / baseW) * paperWidthPx;
selW = (selection.width / baseW) * paperWidthPx;
}
const wLabel = selection
? formatSelectionLengthForPreviewRuler(selection.width, baseW, paperWidthTemplate, templateUnit, displayUnit)
: null;
return (
);
}
/**
* 贯穿预览区全高的纵向标尺:刻度与读数逻辑与横向标尺一致。
*/
function RulerBarVertical({
rulerTotalHeightPx,
paperHeightPx,
paperOffsetTopPx,
paperHeightTemplate,
templateUnit,
displayUnit,
baseH,
selection,
}: {
rulerTotalHeightPx: number;
paperHeightPx: number;
paperOffsetTopPx: number;
/** 模板定义的纸张高度(模板单位) */
paperHeightTemplate: number;
templateUnit: "cm" | "inch";
displayUnit: PreviewRulerDisplayUnit;
baseH: number;
selection: { y: number; height: number } | null;
}) {
const displaySpan = convertTemplateLengthToDisplay(paperHeightTemplate, templateUnit, displayUnit);
if (!Number.isFinite(displaySpan) || displaySpan <= 0 || !Number.isFinite(paperHeightPx) || paperHeightPx < 1) {
return null;
}
if (!Number.isFinite(rulerTotalHeightPx) || rulerTotalHeightPx < 1) {
return null;
}
const w = RULER_W;
const pxPerDisplayUnit = paperHeightPx / displaySpan;
/** 标尺几何中心为刻度 0,向上为负、向下为正 */
const centerPx = rulerTotalHeightPx / 2;
const yAtSignedUnit = (u: number) => centerPx + u * pxPerDisplayUnit;
const nodes: React.ReactNode[] = [];
let labelStep = 1;
if (displayUnit === "mm") {
if (displaySpan > 120) labelStep = 20;
else if (displaySpan > 60) labelStep = 10;
else if (displaySpan > 25) labelStep = 5;
}
const minorDivisions = displayUnit === "inch" ? 8 : 10;
const kMin = Math.floor((0 - centerPx) / pxPerDisplayUnit) - 2;
const kMax = Math.ceil((rulerTotalHeightPx - centerPx) / pxPerDisplayUnit) + 2;
const kLo = Math.max(-5000, Math.min(5000, kMin));
const kHi = Math.max(-5000, Math.min(5000, kMax));
for (let k = kLo; k <= kHi; k++) {
const y = yAtSignedUnit(k);
if (y < -8 || y > rulerTotalHeightPx + 8) continue;
const showLabel = k === 0 || k % labelStep === 0;
nodes.push(
{showLabel ? (
{k}
) : null}
,
);
const midMinor = Math.floor(minorDivisions / 2);
for (let s = 1; s < minorDivisions; s++) {
const u = k + s / minorDivisions;
const y2 = yAtSignedUnit(u);
if (y2 < -4 || y2 > rulerTotalHeightPx + 4) continue;
const x2 = s === midMinor ? 10 : 12;
nodes.push(
,
);
}
}
let selTop = 0;
let selH = 0;
if (selection && Number.isFinite(baseH) && baseH > 0) {
selTop = paperOffsetTopPx + (selection.y / baseH) * paperHeightPx;
selH = (selection.height / baseH) * paperHeightPx;
}
const hLabel = selection
? formatSelectionLengthForPreviewRuler(selection.height, baseH, paperHeightTemplate, templateUnit, displayUnit)
: null;
return (
);
}
/**
* 多选项在画布上的文案:有 prefix 时与 App 打印一致(prefix + 答案/占位);否则在已选字典时显示「字典名称: 内容」。
*/
function formatMultipleOptionsCanvasLine(
cfg: Record,
text: string,
selected: string[],
): string {
const prefix = String(cfg.prefix ?? '').trim();
const dictLabel = String(cfg.multipleOptionName ?? cfg.MultipleOptionName ?? '').trim();
const answers = selected.filter(Boolean).join(', ');
const fallback = text || '…';
if (prefix) {
return answers ? `${prefix}${answers}` : `${prefix}${fallback}`;
}
if (dictLabel) {
const body = answers || fallback;
return `${dictLabel}: ${body}`;
}
return answers || fallback;
}
function nutritionExtraRows(cfg: Record): NutritionExtraItem[] {
const raw = cfg.extraNutrients;
if (!Array.isArray(raw)) return [];
return raw.map((item, idx) => {
const row = item as Record;
return {
id: String(row.id ?? `extra-${idx}`),
name: String(row.name ?? ''),
value: String(row.value ?? ''),
unit: String(row.unit ?? ''),
};
});
}
function nutritionFixedField(
cfg: Record,
key: string,
field: 'value' | 'unit',
): string {
const fixedRows = Array.isArray(cfg.fixedNutrients)
? (cfg.fixedNutrients as Record[])
: [];
const row = fixedRows.find((item) => String(item.key ?? '').trim() === key);
if (row) {
const fromRow = String(row[field] ?? '').trim();
if (fromRow !== '') return fromRow;
}
const directKey = field === 'value' ? key : `${key}Unit`;
const direct = cfg[directKey];
if (direct != null && String(direct).trim() !== '') return String(direct).trim();
return String(row?.[field] ?? '').trim();
}
function formatDateByPreset(format: string, date: Date): string {
const yyyy = String(date.getFullYear());
const yy = yyyy.slice(-2);
const mm = String(date.getMonth() + 1).padStart(2, '0');
const dd = String(date.getDate()).padStart(2, '0');
const hh = String(date.getHours()).padStart(2, '0');
const min = String(date.getMinutes()).padStart(2, '0');
const monthLong = date.toLocaleString('en-US', { month: 'long' }).toUpperCase();
const dayLong = date.toLocaleString('en-US', { weekday: 'long' }).toUpperCase();
const dayShort = date.toLocaleString('en-US', { weekday: 'short' }).toUpperCase();
const monthShort = date.toLocaleString('en-US', { month: 'short' }).toUpperCase();
switch (format) {
case 'DD/MM/YYYY':
return `${dd}/${mm}/${yyyy}`;
case 'MM/DD/YYYY':
return `${mm}/${dd}/${yyyy}`;
case 'DD/MM/YY':
return `${dd}/${mm}/${yy}`;
case 'MM/DD/YY':
return `${mm}/${dd}/${yy}`;
case 'MM/YY':
return `${mm}/${yy}`;
case 'MM/DD':
return `${mm}/${dd}`;
case 'MM':
return mm;
case 'DD':
return dd;
case 'YY':
return yy;
case 'FULLY DAY(WEDNESDAY)':
return dayLong;
case 'DAY (WED)':
return dayShort;
case 'MONTH (DECEMBER)':
return monthLong;
case 'YEAR (2025)':
return yyyy;
case 'DD MONTH YEAR (25 DECEMBER 2025)':
return `${dd} ${monthLong} ${yyyy}`;
default:
return format
.replace('YYYY', yyyy)
.replace('YY', yy)
.replace('MM', mm)
.replace('DD', dd)
.replace('HH', hh)
.replace('mm', min);
}
}
const DURATION_UNITS = new Set([
'Minutes',
'Hours',
'Days',
'Weeks',
'Months (30 Day)',
'Years',
]);
function normalizeWeightUnit(raw: unknown): 'lb' | 'kg' | 'mg' | 'g' | 'oz' {
const unit = String(raw ?? '').trim().toLowerCase();
if (unit === 'milligrams') return 'mg';
if (unit === 'grams') return 'g';
if (unit === 'ounces') return 'oz';
if (unit === 'pounds') return 'lb';
if (unit === 'kilograms') return 'kg';
if (unit === 'lb' || unit === 'kg' || unit === 'mg' || unit === 'g' || unit === 'oz') return unit;
return 'g';
}
/** 根据元素类型与 config 渲染画布上的默认内容 */
function ElementContent({
el,
isAppPrintField,
previewAsPrinted,
previewSelectionHighlight = false,
}: {
el: LabelElement;
isAppPrintField?: boolean;
/** 新建/编辑标签弹窗:按模板控件尺寸与字号展示已填值,而非 APP 占位样式 */
previewAsPrinted?: boolean;
/** 右侧 Print preview 选中高亮:避免子层 bg-white 盖住父级背景 */
previewSelectionHighlight?: boolean;
}) {
const cfg = el.config as Record;
const type = canonicalElementType(el.type);
const isVerticalRotation = el.rotation === 'vertical';
const inverted = readInvertColors(cfg);
// Common styles
const resolvedFontFamily = resolveLabelEditorElementFontFamily(cfg);
const commonStyle: React.CSSProperties = {
fontSize: (cfg?.fontSize as number) ?? 14,
fontFamily: resolvedFontFamily,
fontWeight: (cfg?.fontWeight as string) ?? 'normal',
textAlign: (cfg?.textAlign as any) ?? 'left',
color: inverted ? INVERT_COLORS_FG : ((cfg?.color as string) ?? '#000'),
backgroundColor: inverted ? INVERT_COLORS_BG : undefined,
};
const invertedInputClass = inverted
? 'border-gray-600 bg-black text-white'
: previewSelectionHighlight
? 'border-gray-300 bg-transparent'
: 'border-gray-300 bg-white';
// Rotation support:
// The editor's Rotation is currently a simple horizontal/vertical toggle.
// For text-like elements we render vertical via writing-mode to avoid layout clipping.
const textLike =
type === 'TEXT_STATIC' || type === 'TEXT_PRODUCT' || type === 'TEXT_PRICE';
const textRotationStyle: React.CSSProperties =
isVerticalRotation && textLike
? { writingMode: 'vertical-rl', textOrientation: 'mixed' as any }
: {};
const rotateBoxStyle: React.CSSProperties = isVerticalRotation
? { transform: 'rotate(-90deg)', transformOrigin: 'center center' }
: {};
// 文本类
const inputType = cfg?.inputType as string | undefined;
if (type === 'TEXT_STATIC' && isCompanyAutoElement(el) && !isAppPrintField) {
const previewText = formatCompanyPrintPreviewText(cfg);
return (
{previewText}
);
}
if (type === 'TEXT_STATIC') {
const text = (cfg?.text as string) ?? 'Text';
if (isAppPrintField && previewAsPrinted) {
if (inputType === 'options') {
const selected = Array.isArray(cfg?.selectedOptionValues)
? (cfg.selectedOptionValues as string[])
: [];
const line = formatMultipleOptionsCanvasLine(cfg, text, selected);
const muted = selected.length === 0;
return (
{line}
);
}
const display =
inputType === 'number' ? ((cfg?.text as string) ?? '0') : text;
return (
{display}
);
}
if (isAppPrintField) {
if (inputType === 'options') {
const selected = Array.isArray(cfg?.selectedOptionValues)
? (cfg.selectedOptionValues as string[])
: [];
const line = formatMultipleOptionsCanvasLine(cfg, text, selected);
return (
{line}
);
}
const display =
inputType === 'number' ? ((cfg?.text as string) ?? '0') : text;
return (
{display}
);
}
if (inputType === 'number') {
return (
);
}
if (inputType === 'options') {
// 画布只展示「答案」纯文本,不出现勾选、下拉箭头等控件样式;实际选择在 APP 打印流程中完成
const selected = Array.isArray(cfg?.selectedOptionValues)
? (cfg.selectedOptionValues as string[])
: [];
const line = formatMultipleOptionsCanvasLine(cfg, text, selected);
const muted = selected.length === 0;
return (
{line}
);
}
if (inputType === 'text') {
return (
);
}
return (
{text}
);
}
if (type === 'TEXT_PRODUCT') {
const text = (cfg?.text as string) ?? 'Product name';
return (
{text}
);
}
if (type === 'TEXT_PRICE') {
const text = (cfg?.text as string) ?? '0.00';
return (
{text}
);
}
// 条码(支持水平/竖排、制式、字号与对齐)
if (type === 'BARCODE') {
const data = (cfg?.data as string) ?? '123456789';
const showText = (cfg?.showText as boolean) !== false;
const orientation = (
el.rotation === 'vertical' || (cfg?.orientation as string) === 'vertical'
? 'vertical'
: 'horizontal'
) as 'horizontal' | 'vertical';
const textAlign = (cfg?.textAlign as string) ?? 'center';
const fontSize = (cfg?.fontSize as number) ?? 14;
return (
);
}
// 二维码
if (type === 'QRCODE') {
const data = (cfg?.data as string) ?? 'https://example.com';
const size = Math.min(el.width, el.height) - 4;
return (
);
}
// 图片/Logo
if (type === 'IMAGE') {
const src = cfg?.src as string | undefined;
const imageRotateStyle: React.CSSProperties = isVerticalRotation
? { transform: 'rotate(-90deg)' }
: {};
if (src) {
return (
);
}
return (
Logo
);
}
// 日期/时间
if (type === 'DATE') {
const previewFmtRaw = cfg?.__previewFormatted;
if (typeof previewFmtRaw === 'string') {
const previewFmt = previewFmtRaw.trim();
return (
);
}
const it = String(cfg?.inputType ?? cfg?.InputType ?? '').toLowerCase();
const formatRaw =
(typeof cfg?.format === 'string' && cfg.format.trim()
? cfg.format
: typeof cfg?.Format === 'string' && cfg.Format.trim()
? cfg.Format
: it === 'datetime'
? 'YYYY-MM-DD HH:mm'
: 'DD/MM/YYYY') ?? (it === 'datetime' ? 'YYYY-MM-DD HH:mm' : 'DD/MM/YYYY');
const format = isLikelyResolvedDateTimeLiteral(formatRaw)
? it === 'datetime'
? 'YYYY-MM-DD HH:mm'
: 'DD/MM/YYYY'
: formatRaw;
const offset = Number(cfg?.offsetDays ?? cfg?.OffsetDays ?? 0) || 0;
const d = new Date();
d.setDate(d.getDate() + offset);
const example = formatDateByPreset(format, d);
const isInput = it === 'datetime' || it === 'date';
if (isInput) {
if (isAppPrintField) {
return (
);
}
return (
);
}
return (
);
}
// (Simplified other types similarly for brevity, ensuring style prop is passed)
if (type === 'TIME') {
const previewTime = cfg?.__previewFormatted;
if (typeof previewTime === 'string') {
return (
{previewTime.trim() || '—'}
);
}
const d = new Date();
const example = formatDateByPreset('HH:mm', d);
return (
);
}
if (type === 'DURATION') {
const previewDur = cfg?.__previewFormatted;
if (typeof previewDur === 'string') {
return (
{previewDur.trim() || '—'}
);
}
const rawFormat =
(typeof cfg?.format === 'string' && cfg.format.trim()
? cfg.format
: typeof cfg?.Format === 'string' && cfg.Format.trim()
? cfg.Format
: 'Days') ?? 'Days';
const unit = DURATION_UNITS.has(rawFormat) ? rawFormat : 'Days';
const rawV = cfg?.durationValue ?? cfg?.value ?? cfg?.offsetDays ?? cfg?.DurationValue ?? cfg?.Value ?? cfg?.OffsetDays;
const durationValue = Number.isFinite(Number(rawV)) ? Number(rawV) : 3;
const example = `${durationValue} ${unit}`;
return (
);
}
if (type === 'WEIGHT') {
const rawV = cfg?.value ?? cfg?.Value;
const numVal =
rawV == null || rawV === ''
? 500
: typeof rawV === 'number'
? rawV
: Number(rawV);
const weightNum = Number.isFinite(numVal) ? numVal : 500;
const weightUnit = normalizeWeightUnit(
(typeof cfg?.unit === 'string' && cfg.unit.trim()
? cfg.unit
: typeof cfg?.Unit === 'string' && cfg.Unit.trim()
? cfg.Unit
: 'g') ?? 'g',
);
const weightFontSizeRaw = cfg?.fontSize ?? cfg?.FontSize;
const weightFontSize = Number.isFinite(Number(weightFontSizeRaw)) ? Number(weightFontSizeRaw) : 14;
const weightTextAlignRaw = String(cfg?.textAlign ?? cfg?.TextAlign ?? 'left').toLowerCase();
const weightTextAlign: 'left' | 'center' | 'right' =
weightTextAlignRaw === 'center' || weightTextAlignRaw === 'right' ? weightTextAlignRaw : 'left';
return (
);
}
if (type === 'WEIGHT_PRICE') {
const unitPrice = (cfg?.unitPrice as number) ?? 10;
const weight = (cfg?.weight as number) ?? 0.5;
const currency = (cfg?.currency as string) ?? '$';
return {currency}{(unitPrice * weight).toFixed(2)}
;
}
// 营养成分表
if (type === 'NUTRITION') {
const servingsPerContainer = String(cfg.servingsPerContainer ?? cfg.ServingsPerContainer ?? '').trim();
const servingSize = String(cfg.servingSize ?? cfg.ServingSize ?? '').trim();
const calories = String(cfg.calories ?? cfg.Calories ?? nutritionFixedField(cfg, 'calories', 'value') ?? '').trim();
const nutritionTitleSize = Number(cfg.nutritionTitleFontSize ?? cfg.NutritionTitleFontSize ?? 16) || 16;
const baseRows = NUTRITION_FIXED_ITEMS.map((item) => {
const value = nutritionFixedField(cfg, item.key, 'value');
const unit = nutritionFixedField(cfg, item.key, 'unit') || (item.defaultUnit ?? '');
return {
id: item.key,
label: item.label,
value,
unit,
};
});
const extraRows = nutritionExtraRows(cfg).map((item) => ({
id: item.id,
label: item.name.trim() || 'Other',
value: item.value.trim(),
unit: item.unit.trim(),
}));
const rows = [...baseRows, ...extraRows];
const formatNutritionValue = (value: string, unit: string): string => {
const v = String(value ?? '').trim();
const u = String(unit ?? '').trim();
if (!v && !u) return '';
return `<${v}${u ? ` ${u}` : ''}`;
};
const nutritionContent = (
Nutrition Facts
Calories
{calories ? formatNutritionValue(calories, '') : ''}
Servings Per Container
{servingsPerContainer}
Serving Size
{servingSize}
{rows.map((row) => (
{row.label}
{formatNutritionValue(row.value, row.unit)}
))}
);
return (
);
}
// 空白占位:预印刷 Logo/Image 区域,预览区展示占位字样
if (type === 'BLANK') {
const fontSize = Math.max(11, Math.min(el.width * 0.2, el.height * 0.36, 56));
return (
Blank
Space
);
}
return (
{el.type.replace(/_/g, ' ')}
);
}
interface LabelCanvasProps {
template: LabelTemplate;
canvasBorder?: 'none' | 'line' | 'dotted';
selectedId: string | null;
onSelect: (id: string | null) => void;
onUpdateElement: (id: string, patch: Partial) => void;
onDeleteElement: (id: string) => void;
onTemplateChange?: (patch: Partial) => void;
scale?: number;
onZoomIn?: () => void;
onZoomOut?: () => void;
/** 将缩放还原为 100%(与顶部标尺物理尺寸一致),并重新居中画布 */
onResetZoom?: () => void;
onPreview?: () => void;
/** 为 true 时不在预览工具栏显示画布尺寸预设(改由顶部表单控制) */
hideToolbarPresetSize?: boolean;
/** 预览标尺单位(与右侧属性 W/H 读数一致) */
previewRulerUnit?: PreviewRulerDisplayUnit;
onPreviewRulerUnitChange?: (unit: PreviewRulerDisplayUnit) => void;
/** 拖拽/缩放过程中的临时几何,用于选中框与右侧预览实时同步 */
liveElementPatch?: LabelElementLivePatch | null;
onLiveElementPatchChange?: (patch: LabelElementLivePatch | null) => void;
/** 打印方向:竖打时纸张尺寸不变,内容整体旋转 90° 展示 */
printOrientation?: PrintOrientation;
onPrintOrientationChange?: (orientation: PrintOrientation) => void;
}
type PaperResizeEdge =
| 'bottom'
| 'right'
| 'top'
| 'left'
| 'top-left'
| 'top-right'
| 'bottom-left'
| 'bottom-right';
function cursorForPaperResizeEdge(edge: PaperResizeEdge): string {
if (edge === 'top' || edge === 'bottom') return 'ns-resize';
if (edge === 'left' || edge === 'right') return 'ew-resize';
if (edge === 'top-left' || edge === 'bottom-right') return 'nwse-resize';
return 'nesw-resize';
}
export function LabelCanvas({
template,
canvasBorder,
selectedId,
onSelect,
onUpdateElement,
onDeleteElement,
onTemplateChange,
scale = 1,
onZoomIn,
onZoomOut,
onResetZoom,
onPreview,
hideToolbarPresetSize = false,
previewRulerUnit = "cm",
onPreviewRulerUnitChange,
liveElementPatch = null,
onLiveElementPatchChange,
printOrientation = 'vertical',
onPrintOrientationChange,
}: LabelCanvasProps) {
const scrollContainerRef = useRef(null);
const rulerWorkspaceRef = useRef(null);
const canvasRef = useRef(null);
const dragRef = useRef<{
id: string;
grabOffsetX: number;
grabOffsetY: number;
startLocalX: number;
startLocalY: number;
w: number;
h: number;
active: boolean;
} | null>(null);
const suppressCanvasClickDeselectRef = useRef(false);
const capturedPointerIdRef = useRef(null);
const resizeRef = useRef<{
id: string;
corner: string;
startLocalX: number;
startLocalY: number;
w: number;
h: number;
elX: number;
elY: number;
} | null>(null);
const resizeDocumentListenersRef = useRef<{
move: (e: PointerEvent) => void;
up: (e: PointerEvent) => void;
} | null>(null);
const paperResizeRef = useRef<{
edge: PaperResizeEdge;
startX: number;
startY: number;
startW: number;
startH: number;
startElements: { id: string; x: number; y: number }[];
} | null>(null);
const lastUpdateRef = useRef<{ id: string; x?: number; y?: number; width?: number; height?: number } | null>(null);
const nextFrameRef = useRef(null);
const [isSpacePressed, setIsSpacePressed] = React.useState(false);
const [isPanning, setIsPanning] = React.useState(false);
const [paperResizeCursor, setPaperResizeCursor] = React.useState(null);
const panStartRef = useRef<{ x: number; y: number; scrollLeft: number; scrollTop: number } | null>(null);
const [panOffset, setPanOffset] = React.useState({ x: 0, y: 0 });
const panOffsetStartRef = useRef<{ x: number; y: number; startX: number; startY: number } | null>(null);
/** 滚动容器可视尺寸,用于标尺铺满灰色背景且小画布时不虚增滚动高度 */
const [scrollViewport, setScrollViewport] = React.useState({ width: 0, height: 0 });
/** 标尺工作区实测尺寸(1fr 网格铺满后用于刻度与画布居中) */
const [measuredWorkspace, setMeasuredWorkspace] = React.useState({ width: 0, height: 0 });
/** 仅影响预览区标尺刻度/读数,与顶部表单画布单位(template.unit)不同步 */
const setPreviewRulerUnit = onPreviewRulerUnitChange ?? (() => {});
useEffect(() => {
const el = scrollContainerRef.current;
if (!el) return;
const update = () => {
setScrollViewport({ width: el.clientWidth, height: el.clientHeight });
};
const ro = new ResizeObserver(update);
ro.observe(el);
update();
return () => ro.disconnect();
}, []);
const baseW = unitToPx(Number(template.width) || 0, template.unit);
const baseH = unitToPx(Number(template.height) || 0, template.unit);
/** 缩放后的实际占位,用于滚动区域与居中,避免放大后画布被裁切 */
const widthPx = baseW * scale;
const heightPx = baseH * scale;
const showGrid = template.showGrid !== false;
const isRotatedPrintPreview = printOrientation === 'horizontal';
const effectiveCanvasBorder = canvasBorder ?? template.border ?? 'none';
const canvasBorderClass =
effectiveCanvasBorder === 'line'
? 'border border-gray-500'
: effectiveCanvasBorder === 'dotted'
? 'border border-dotted border-gray-500'
: 'border border-transparent';
const innerAvailW = Math.max(0, scrollViewport.width - RULER_W);
const innerAvailH = Math.max(0, scrollViewport.height - RULER_H);
/** 标尺区域至少铺满灰色可视区;画布更大时随画布扩展 */
const rulerCanvasWidth = Math.max(1, widthPx, innerAvailW);
const rulerTotalHeight = Math.max(1, heightPx, innerAvailH);
const effectiveRulerW = measuredWorkspace.width > 0 ? measuredWorkspace.width : rulerCanvasWidth;
const effectiveRulerH = measuredWorkspace.height > 0 ? measuredWorkspace.height : rulerTotalHeight;
const paperOffsetX = Math.max(0, (effectiveRulerW - widthPx) / 2);
const paperOffsetY = Math.max(0, (effectiveRulerH - heightPx) / 2);
const gridContentW = RULER_W + rulerCanvasWidth;
const gridContentH = RULER_H + rulerTotalHeight;
const wrapperW =
scrollViewport.width > 0
? Math.max(scrollViewport.width, gridContentW)
: gridContentW;
const wrapperH =
scrollViewport.height > 0
? Math.max(scrollViewport.height, gridContentH)
: gridContentH;
useEffect(() => {
const el = rulerWorkspaceRef.current;
if (!el) return;
const update = () => {
setMeasuredWorkspace({
width: Math.max(1, el.clientWidth),
height: Math.max(1, el.clientHeight),
});
};
const ro = new ResizeObserver(update);
ro.observe(el);
update();
return () => ro.disconnect();
}, [scrollViewport.width, scrollViewport.height, widthPx, heightPx, scale]);
const resetScrollToFit = useCallback(() => {
const el = scrollContainerRef.current;
if (!el) return;
const run = () => {
if (el.scrollWidth > el.clientWidth + 1) {
el.scrollLeft = Math.max(0, (el.scrollWidth - el.clientWidth) / 2);
} else {
el.scrollLeft = 0;
}
el.scrollTop = 0;
};
requestAnimationFrame(() => requestAnimationFrame(run));
}, []);
const rulerSelection = useMemo(() => {
if (!selectedId) {
return { x: 0, width: baseW };
}
const el = template.elements.find((x) => x.id === selectedId);
if (!el) return { x: 0, width: baseW };
const live = mergeLabelElementLivePatch(el, liveElementPatch);
return { x: live.x, width: live.width };
}, [selectedId, template.elements, liveElementPatch, baseW]);
const rulerVerticalSelection = useMemo(() => {
if (!selectedId) {
return { y: 0, height: baseH };
}
const el = template.elements.find((x) => x.id === selectedId);
if (!el) return { y: 0, height: baseH };
const live = mergeLabelElementLivePatch(el, liveElementPatch);
return { y: live.y, height: live.height };
}, [selectedId, template.elements, liveElementPatch, baseH]);
const selectedElementLive = useMemo(() => {
if (!selectedId) return null;
const el = template.elements.find((x) => x.id === selectedId);
if (!el) return null;
return mergeLabelElementLivePatch(el, liveElementPatch);
}, [selectedId, template.elements, liveElementPatch]);
const handlePointerDown = useCallback(
(e: React.PointerEvent, id: string) => {
// 如果按住了空格,直接返回,交给外层 panning 处理
// 允许中键 (button 1) 拖动
if (isSpacePressed || e.button === 1) return;
e.stopPropagation();
suppressCanvasClickDeselectRef.current = true;
onSelect(id);
// Focus canvas for keyboard events
canvasRef.current?.focus();
const el = template.elements.find((x) => x.id === id);
if (!el) return;
const local = clientToCanvasLocalPoint(e.clientX, e.clientY, canvasRef.current, scale);
if (!local) return;
const templatePtr = canvasLocalToTemplateSpace(
local.x,
local.y,
printOrientation,
baseW,
baseH,
);
dragRef.current = {
id,
grabOffsetX: templatePtr.x - el.x,
grabOffsetY: templatePtr.y - el.y,
startLocalX: local.x,
startLocalY: local.y,
w: el.width,
h: el.height,
active: false,
};
},
[template.elements, onSelect, isSpacePressed, scale, printOrientation, baseW, baseH]
);
const requestUpdate = useCallback((updateFn: () => void) => {
if (nextFrameRef.current !== null) {
cancelAnimationFrame(nextFrameRef.current);
}
nextFrameRef.current = requestAnimationFrame(() => {
updateFn();
nextFrameRef.current = null;
});
}, []);
const beginPaperResize = useCallback((e: React.PointerEvent, edge: PaperResizeEdge) => {
e.stopPropagation();
paperResizeRef.current = {
edge,
startX: e.clientX,
startY: e.clientY,
startW: template.width,
startH: template.height,
startElements: template.elements.map((el) => ({ id: el.id, x: el.x, y: el.y })),
};
const cursor = cursorForPaperResizeEdge(edge);
setPaperResizeCursor(cursor);
document.body.style.cursor = cursor;
(e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
}, [template.width, template.height, template.elements]);
const detachResizeDocumentListeners = useCallback(() => {
const listeners = resizeDocumentListenersRef.current;
if (!listeners) return;
document.removeEventListener('pointermove', listeners.move);
document.removeEventListener('pointerup', listeners.up);
document.removeEventListener('pointercancel', listeners.up);
resizeDocumentListenersRef.current = null;
}, []);
const applyElementResizeAtClient = useCallback(
(clientX: number, clientY: number) => {
const session = resizeRef.current;
if (!session || !canvasRef.current) return;
const local = clientToCanvasLocalPoint(clientX, clientY, canvasRef.current, scale);
if (!local) return;
const { id, corner, startLocalX, startLocalY, w, h, elX, elY } = session;
const { dtx, dty } = templatePointerDeltaFromCanvasLocals(
startLocalX,
startLocalY,
local.x,
local.y,
printOrientation,
baseW,
baseH,
);
const resized = computeResizedElementBox(elX, elY, w, h, corner, dtx, dty, 'horizontal');
const clamped = clampLabelElementBox(
snapToGrid(resized.x),
snapToGrid(resized.y),
snapToGrid(resized.width),
snapToGrid(resized.height),
baseW,
baseH,
LABEL_CANVAS_SAFE_MARGIN_PX,
printOrientation,
);
lastUpdateRef.current = {
id,
width: clamped.w,
height: clamped.h,
x: clamped.x,
y: clamped.y,
};
onLiveElementPatchChange?.({
id,
x: clamped.x,
y: clamped.y,
width: clamped.w,
height: clamped.h,
});
},
[scale, printOrientation, baseW, baseH, onLiveElementPatchChange],
);
const commitElementResize = useCallback(() => {
if (!resizeDocumentListenersRef.current && !resizeRef.current && !lastUpdateRef.current) {
return;
}
detachResizeDocumentListeners();
if (lastUpdateRef.current) {
const { id, ...patch } = lastUpdateRef.current;
onUpdateElement(id, patch);
}
lastUpdateRef.current = null;
resizeRef.current = null;
onLiveElementPatchChange?.(null);
suppressCanvasClickDeselectRef.current = true;
}, [detachResizeDocumentListeners, onUpdateElement, onLiveElementPatchChange]);
const beginElementResize = useCallback(
(e: React.PointerEvent, elId: string, handleId: string) => {
e.stopPropagation();
e.preventDefault();
suppressCanvasClickDeselectRef.current = true;
onSelect(elId);
detachResizeDocumentListeners();
const el0 = template.elements.find((x) => x.id === elId);
if (!el0) return;
const local = clientToCanvasLocalPoint(e.clientX, e.clientY, canvasRef.current, scale);
if (!local) return;
resizeRef.current = {
id: elId,
corner: handleId,
startLocalX: local.x,
startLocalY: local.y,
w: el0.width,
h: el0.height,
elX: el0.x,
elY: el0.y,
};
onLiveElementPatchChange?.({
id: elId,
x: el0.x,
y: el0.y,
width: el0.width,
height: el0.height,
});
const onMove = (ev: PointerEvent) => {
ev.preventDefault();
applyElementResizeAtClient(ev.clientX, ev.clientY);
};
const onUp = (ev: PointerEvent) => {
applyElementResizeAtClient(ev.clientX, ev.clientY);
commitElementResize();
};
resizeDocumentListenersRef.current = { move: onMove, up: onUp };
document.addEventListener('pointermove', onMove);
document.addEventListener('pointerup', onUp);
document.addEventListener('pointercancel', onUp);
(e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
},
[
template.elements,
onLiveElementPatchChange,
onSelect,
scale,
printOrientation,
baseW,
baseH,
detachResizeDocumentListeners,
applyElementResizeAtClient,
commitElementResize,
],
);
const handlePointerMove = useCallback(
(e: React.PointerEvent) => {
// 画布平移:优先处理(translate 方式,不依赖滚动)
if (isPanning && panOffsetStartRef.current) {
const dx = e.clientX - panOffsetStartRef.current.startX;
const dy = e.clientY - panOffsetStartRef.current.startY;
setPanOffset({
x: panOffsetStartRef.current.x + dx,
y: panOffsetStartRef.current.y + dy,
});
return;
}
if (isPanning && panStartRef.current && scrollContainerRef.current) {
const dx = e.clientX - panStartRef.current.x;
const dy = e.clientY - panStartRef.current.y;
scrollContainerRef.current.scrollLeft = panStartRef.current.scrollLeft - dx;
scrollContainerRef.current.scrollTop = panStartRef.current.scrollTop - dy;
return;
}
// Drag Element
if (dragRef.current) {
const { id, grabOffsetX, grabOffsetY, startLocalX, startLocalY, w, h, active } =
dragRef.current;
const local = clientToCanvasLocalPoint(e.clientX, e.clientY, canvasRef.current, scale);
if (!local) return;
if (!active) {
const dlx = local.x - startLocalX;
const dly = local.y - startLocalY;
if (Math.hypot(dlx, dly) < ELEMENT_DRAG_THRESHOLD_PX) return;
dragRef.current = { ...dragRef.current, active: true };
const domEl = document.getElementById(`element-${id}`);
if (domEl) {
domEl.classList.add('z-50', 'opacity-90', 'shadow-xl', 'ring-2', 'ring-blue-400', 'ring-offset-2');
domEl.style.cursor = 'grabbing';
}
canvasRef.current?.setPointerCapture?.(e.pointerId);
capturedPointerIdRef.current = e.pointerId;
}
requestUpdate(() => {
const { x: rawX, y: rawY } = templatePositionFromPointerGrab(
local.x,
local.y,
grabOffsetX,
grabOffsetY,
printOrientation,
baseW,
baseH,
);
const { x: snappedX, y: snappedY } = clampDragPosition(
rawX,
rawY,
w,
h,
baseW,
baseH,
LABEL_CANVAS_SAFE_MARGIN_PX,
printOrientation,
);
lastUpdateRef.current = { id, x: snappedX, y: snappedY };
onLiveElementPatchChange?.({
id,
x: snappedX,
y: snappedY,
width: w,
height: h,
});
});
}
// Resize Element(document 级监听处理缩放,此处跳过)
if (resizeRef.current && !resizeDocumentListenersRef.current) {
const { id, corner, startLocalX, startLocalY, w, h, elX, elY } = resizeRef.current;
const local = clientToCanvasLocalPoint(e.clientX, e.clientY, canvasRef.current, scale);
if (!local) return;
requestUpdate(() => {
const { dtx, dty } = templatePointerDeltaFromCanvasLocals(
startLocalX,
startLocalY,
local.x,
local.y,
printOrientation,
baseW,
baseH,
);
const resized = computeResizedElementBox(elX, elY, w, h, corner, dtx, dty, 'horizontal');
const clamped = clampLabelElementBox(
snapToGrid(resized.x),
snapToGrid(resized.y),
snapToGrid(resized.width),
snapToGrid(resized.height),
baseW,
baseH,
LABEL_CANVAS_SAFE_MARGIN_PX,
printOrientation,
);
lastUpdateRef.current = {
id,
width: clamped.w,
height: clamped.h,
x: clamped.x,
y: clamped.y,
};
onLiveElementPatchChange?.({
id,
x: clamped.x,
y: clamped.y,
width: clamped.w,
height: clamped.h,
});
});
}
// Resize Paper
if (paperResizeRef.current && onTemplateChange) {
const { edge, startX, startY, startW, startH, startElements } = paperResizeRef.current;
const clientX = e.clientX;
const clientY = e.clientY;
requestUpdate(() => {
const deltaPxX = (clientX - startX) / scale;
const deltaPxY = (clientY - startY) / scale;
const minPaperUnit = 1;
const startWPx = unitToPx(startW, template.unit);
const startHPx = unitToPx(startH, template.unit);
const minWPx = unitToPx(minPaperUnit, template.unit);
const minHPx = unitToPx(minPaperUnit, template.unit);
const affectsTop = edge === 'top' || edge === 'top-left' || edge === 'top-right';
const affectsBottom = edge === 'bottom' || edge === 'bottom-left' || edge === 'bottom-right';
const affectsLeft = edge === 'left' || edge === 'top-left' || edge === 'bottom-left';
const affectsRight = edge === 'right' || edge === 'top-right' || edge === 'bottom-right';
let nextWUnit = startW;
let nextHUnit = startH;
let offsetContentPxX = 0;
let offsetContentPxY = 0;
if (affectsRight) {
const proposedPx = Math.max(minWPx, startWPx + deltaPxX);
nextWUnit = Math.max(minPaperUnit, Math.round(pxToUnit(proposedPx, template.unit)));
}
if (affectsBottom) {
const proposedPx = Math.max(minHPx, startHPx + deltaPxY);
nextHUnit = Math.max(minPaperUnit, Math.round(pxToUnit(proposedPx, template.unit)));
}
if (affectsLeft) {
const proposedPx = Math.max(minWPx, startWPx - deltaPxX);
nextWUnit = Math.max(minPaperUnit, Math.round(pxToUnit(proposedPx, template.unit)));
const snappedPx = unitToPx(nextWUnit, template.unit);
const appliedDelta = startWPx - snappedPx;
offsetContentPxX = appliedDelta;
}
if (affectsTop) {
const proposedPx = Math.max(minHPx, startHPx - deltaPxY);
nextHUnit = Math.max(minPaperUnit, Math.round(pxToUnit(proposedPx, template.unit)));
const snappedPx = unitToPx(nextHUnit, template.unit);
const appliedDelta = startHPx - snappedPx;
offsetContentPxY = appliedDelta;
}
const patch: Partial = {};
if (affectsLeft || affectsRight) patch.width = nextWUnit;
if (affectsTop || affectsBottom) patch.height = nextHUnit;
if ((offsetContentPxX !== 0 || offsetContentPxY !== 0) && startElements.length > 0) {
const byId = new Map(startElements.map(s => [s.id, s]));
patch.elements = template.elements.map((el) => {
const s = byId.get(el.id);
if (!s) return el;
const nx = Math.max(0, s.x - offsetContentPxX);
const ny = Math.max(0, s.y - offsetContentPxY);
return (nx === el.x && ny === el.y) ? el : { ...el, x: nx, y: ny };
});
}
onTemplateChange(patch);
});
}
},
[isPanning, onTemplateChange, scale, template.unit, requestUpdate, baseW, baseH, onLiveElementPatchChange, printOrientation]
);
const handlePointerUp = useCallback(
(e?: React.PointerEvent) => {
// 结束画布平移
if (isPanning) {
setIsPanning(false);
panStartRef.current = null;
panOffsetStartRef.current = null;
}
// Cancel pending animation frame
if (nextFrameRef.current !== null) {
cancelAnimationFrame(nextFrameRef.current);
nextFrameRef.current = null;
}
const activeId = dragRef.current?.id || resizeRef.current?.id;
const dragWasActive = dragRef.current?.active ?? false;
if (activeId) {
const domEl = document.getElementById(`element-${activeId}`);
if (domEl) {
domEl.classList.remove('z-50', 'opacity-90', 'shadow-xl', 'ring-2', 'ring-blue-400', 'ring-offset-2');
domEl.style.cursor = '';
}
}
const pointerId = e?.pointerId ?? capturedPointerIdRef.current;
if (pointerId != null) {
try {
canvasRef.current?.releasePointerCapture?.(pointerId);
} catch {
// ignore
}
}
capturedPointerIdRef.current = null;
if (resizeDocumentListenersRef.current) {
commitElementResize();
} else if (lastUpdateRef.current && (dragWasActive || resizeRef.current)) {
const { id, ...patch } = lastUpdateRef.current;
onUpdateElement(id, patch);
lastUpdateRef.current = null;
onLiveElementPatchChange?.(null);
} else {
lastUpdateRef.current = null;
}
detachResizeDocumentListeners();
dragRef.current = null;
resizeRef.current = null;
paperResizeRef.current = null;
setPaperResizeCursor(null);
document.body.style.cursor = '';
},
[onUpdateElement, onLiveElementPatchChange, isPanning, detachResizeDocumentListeners, commitElementResize],
);
useEffect(() => () => detachResizeDocumentListeners(), [detachResizeDocumentListeners]);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.code === 'Space' && !e.repeat) {
setIsSpacePressed(true);
}
};
const onKeyUp = (e: KeyboardEvent) => {
if (e.code === 'Space') {
setIsSpacePressed(false);
setIsPanning(false);
panStartRef.current = null;
panOffsetStartRef.current = null;
}
};
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
return () => {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
};
}, []);
useEffect(() => {
if (!paperResizeCursor) return;
document.body.style.cursor = paperResizeCursor;
return () => {
document.body.style.cursor = '';
};
}, [paperResizeCursor]);
// 缩放或纸张尺寸变化:清空平移偏移;内容未超出视口时不产生纵向滚动
useEffect(() => {
setPanOffset({ x: 0, y: 0 });
resetScrollToFit();
const t = window.setTimeout(resetScrollToFit, 80);
return () => window.clearTimeout(t);
}, [scale, baseW, baseH, widthPx, heightPx, resetScrollToFit]);
// Keyboard navigation for elements
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (!selectedId) return;
if (e.key === 'Delete' || e.key === 'Backspace') {
// ... existing delete logic
e.preventDefault();
const idx = template.elements.findIndex((x) => x.id === selectedId);
if (idx >= 0) {
const next = template.elements.filter((x) => x.id !== selectedId);
onDeleteElement(selectedId);
onSelect(next[idx]?.id ?? next[idx - 1]?.id ?? null);
}
return;
}
const el = template.elements.find(x => x.id === selectedId);
if (!el) return;
// allow typing in inputs without triggering move?
// Actually our elements are not inputs (unless we implement inline edit).
// But preventDefault is good.
const step = e.shiftKey ? 1 : GRID_SIZE;
let dx = 0;
let dy = 0;
switch (e.key) {
case 'ArrowLeft': dx = -step; break;
case 'ArrowRight': dx = step; break;
case 'ArrowUp': dy = -step; break;
case 'ArrowDown': dy = step; break;
default: return;
}
e.preventDefault();
const nx = el.x + dx;
const ny = el.y + dy;
const { x, y } = clampDragPosition(
nx,
ny,
el.width,
el.height,
baseW,
baseH,
LABEL_CANVAS_SAFE_MARGIN_PX,
printOrientation,
);
onUpdateElement(el.id, { x, y });
}, [selectedId, template.elements, onUpdateElement, onDeleteElement, onSelect, baseW, baseH, printOrientation]);
const canvasClick = () => onSelect(null);
// 容器的 Pan 处理
// 容器的 Pan 处理
const handleContainerPointerDown = (e: React.PointerEvent) => {
if (isSpacePressed || e.button === 1) {
e.preventDefault();
setIsPanning(true);
panStartRef.current = {
x: e.clientX,
y: e.clientY,
scrollLeft: scrollContainerRef.current?.scrollLeft || 0,
scrollTop: scrollContainerRef.current?.scrollTop || 0
};
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
}
};
const handleContainerPointerMove = (e: React.PointerEvent) => {
if (isPanning && panStartRef.current && scrollContainerRef.current) {
const dx = e.clientX - panStartRef.current.x;
const dy = e.clientY - panStartRef.current.y;
scrollContainerRef.current.scrollLeft = panStartRef.current.scrollLeft - dx;
scrollContainerRef.current.scrollTop = panStartRef.current.scrollTop - dy;
}
};
const handleContainerPointerUp = (e: React.PointerEvent) => {
if (isPanning) {
setIsPanning(false);
panStartRef.current = null;
}
};
return (
{/* Label Preview 标题 + 网格/预览/缩放 */}
Label Preview
{onPrintOrientationChange ? (
) : null}
{onPreview && (
)}
{onTemplateChange && !hideToolbarPresetSize ? (
) : null}
{onTemplateChange ? (
) : null}
{Math.round(scale * 100)}%
{onResetZoom ? (
) : null}
{/* Canvas Container:底层灰底铺满可视区,内容层可滚动 */}
{/* 标尺交汇角:仅占位,不渲染背景/边框/拖拽控件 */}
{
if (suppressCanvasClickDeselectRef.current) {
suppressCanvasClickDeselectRef.current = false;
return;
}
// 点击画布空白处取消选中
const target = e.target as HTMLElement;
const isOnElement = target.closest('[id^="element-"]');
const isOnResizeHandle = target.closest('[data-element-resize-handle="true"]');
const isOnPaperResize =
target.closest('[data-paper-resize-handle="true"]') ||
target.closest('[title*="Drag to resize paper"]') ||
target.closest('[title*="Drag to increase paper height"]') ||
target.closest('[title*="Drag to increase paper width"]');
if (!isOnElement && !isOnResizeHandle && !isOnPaperResize) {
onSelect(null);
}
}}
onPointerDown={(e) => {
// 空白处按下即开始平移(在画布内且未点到元素/纸张拖拽条)
const target = e.target as HTMLElement;
const isOnElement = target.closest('[id^="element-"]');
const isOnResizeHandle = target.closest('[data-element-resize-handle="true"]');
const isOnPaperResize =
target.closest('[data-paper-resize-handle="true"]') ||
target.closest('[title*="Drag to resize paper"]') ||
target.closest('[title*="Drag to increase paper height"]') ||
target.closest('[title*="Drag to increase paper width"]');
const isOnCanvasArea = canvasRef.current?.contains(target);
if (isOnCanvasArea && !isOnElement && !isOnResizeHandle && !isOnPaperResize && !dragRef.current && !resizeRef.current) {
if (isSpacePressed || e.button === 1) {
e.preventDefault();
e.stopPropagation();
setIsPanning(true);
panOffsetStartRef.current = {
x: panOffset.x,
y: panOffset.y,
startX: e.clientX,
startY: e.clientY,
};
panStartRef.current = {
x: e.clientX,
y: e.clientY,
scrollLeft: scrollContainerRef.current?.scrollLeft ?? 0,
scrollTop: scrollContainerRef.current?.scrollTop ?? 0,
};
(e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
}
}
}}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onKeyDown={handleKeyDown}
>
{/* 选中元素对齐参考线:延伸至画布四边,蓝色虚线 */}
{selectedElementLive
? (() => {
const el = selectedElementLive;
const lineCls = "pointer-events-none absolute z-[2] border-blue-600";
return (
<>
>
);
})()
: null}
{template.elements.map((el) => {
const effectiveEl = mergeLabelElementLivePatch(el, liveElementPatch);
const isPrintField = isPrintInputElement(el);
const isSelected = selectedId === el.id;
return (
{
e.stopPropagation();
onSelect(el.id);
}}
onPointerDown={(e) => handlePointerDown(e, el.id)}
>
);
})}
{selectedElementLive ? (
beginElementResize(e, selectedElementLive.id, handleId)
}
/>
) : null}
);
}
/** Preview only: no grid, no rulers, no drag; scale to fit. */
export function LabelPreviewOnly({
template,
canvasBorder,
maxWidth = 480,
previewAsPrinted = false,
highlightElementId = null,
previewRulerUnit = "cm",
printOrientation = "vertical",
}: {
template: LabelTemplate;
canvasBorder?: 'none' | 'line' | 'dotted';
maxWidth?: number;
/** 标签新建/编辑:与模板编辑器画布同坐标系,按元素真实字号与框尺寸渲染 */
previewAsPrinted?: boolean;
/** 画布当前选中元素 id:在预览中高亮其框架位置 */
highlightElementId?: string | null;
/** 选中框尺寸读数单位(与编辑器预览标尺一致) */
previewRulerUnit?: PreviewRulerDisplayUnit;
/** 横打时内容整体旋转 90°,纸张尺寸不变;未传时读 template.printOrientation */
printOrientation?: PrintOrientation;
}) {
const effectivePrintOrientation =
printOrientation ?? template.printOrientation ?? 'vertical';
/** 画布 = 模板 width×height×unit(如 2inch×2inch → 192×192px),控件 x/y/width/height 与编辑区 1:1 */
const baseW = unitToPx(Number(template.width) || 0, template.unit);
const baseH = unitToPx(Number(template.height) || 0, template.unit);
const scaleToFit =
maxWidth > 0
? Math.min(maxWidth / baseW, maxWidth / baseH, previewAsPrinted ? 3 : 2)
: 1;
const displayW = baseW * scaleToFit;
const displayH = baseH * scaleToFit;
const effectiveCanvasBorder = canvasBorder ?? template.border ?? 'none';
const previewBorderClass =
effectiveCanvasBorder === 'line'
? 'border border-gray-500'
: effectiveCanvasBorder === 'dotted'
? 'border border-dotted border-gray-500'
: 'border border-transparent';
// 与编辑区一致:内层 baseW×baseH,transformOrigin 0 0 缩放,保证位置/样式一致
return (
{template.elements.map((el) => {
const isPrintField = isPrintInputElement(el);
const isHighlighted = highlightElementId === el.id;
return (
{isHighlighted ? (
) : null}
);
})}
);
}