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 (
{svg}
); } return (
{svg}
); } /** 画布网格步长(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 ( <>
{/* 宽度标注 */} {displayWidthLabel} {/* 高度标注 */} {displayHeightLabel}
{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 ( {nodes} {selection && Number.isFinite(selW) && selW > 0.5 && ( <> {wLabel && ( {wLabel} )} )} ); } /** * 贯穿预览区全高的纵向标尺:刻度与读数逻辑与横向标尺一致。 */ 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 ( {nodes} {selection && Number.isFinite(selH) && selH > 0.5 && ( <> {hLabel && ( {hLabel} )} )} ); } /** * 多选项在画布上的文案:有 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 (
{previewFmt || '—'}
); } 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 (
{format}
); } return (
); } return (
{example}
); } // (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 (
{example}
); } 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 (
{example}
); } 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 (
{weightNum} {weightUnit}
); } 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 (
{nutritionContent}
); } // 空白占位:预印刷 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}
); })}
); }