import React, { useCallback, useRef, useEffect, useLayoutEffect, useMemo, useState } from 'react'; import JsBarcode from 'jsbarcode'; import { QRCodeSVG } from 'qrcode.react'; import type { LabelTemplate, LabelElement, NutritionExtraItem, } from '../../../types/labelTemplate'; import { canonicalElementType, isPrintInputElement } from '../../../types/labelTemplate'; 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 { pxToUnit, unitToPx } from '@/utils/labelTemplateUnits'; /** 真实条形码渲染(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; /** 将控件矩形限制在虚线安全区内(画布像素坐标) */ export function clampLabelElementBox( x: number, y: number, w: number, h: number, baseW: number, baseH: number, marginPx: number = LABEL_CANVAS_SAFE_MARGIN_PX, ): { x: number; y: number; w: number; h: number } { 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, ): { x: number; y: number } { 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; /** 预览区标尺显示单位(与模板 `template.unit` 独立,仅影响刻度与读数) */ export type PreviewRulerDisplayUnit = "cm" | "mm" | "inch"; /** 将长度从模板单位换算到预览标尺显示单位 */ function convertTemplateLengthToDisplay( value: number, templateUnit: "cm" | "inch", displayUnit: PreviewRulerDisplayUnit, ): number { if (!Number.isFinite(value)) return 0; const cm = templateUnit === "cm" ? value : value * 2.54; if (displayUnit === "cm") return cm; if (displayUnit === "mm") return cm * 10; return cm / 2.54; } function formatSelectionWidthForPreviewRuler( lengthPx: number, baseW: number, paperWidthTemplate: number, templateUnit: "cm" | "inch", displayUnit: PreviewRulerDisplayUnit, ): string | null { if (!Number.isFinite(lengthPx) || !Number.isFinite(baseW) || baseW <= 0 || !Number.isFinite(paperWidthTemplate) || paperWidthTemplate <= 0) { return null; } const inTemplate = (lengthPx / baseW) * paperWidthTemplate; const d = convertTemplateLengthToDisplay(inTemplate, templateUnit, displayUnit); if (displayUnit === "mm") return `${Math.round(d)}mm`; if (displayUnit === "inch") return `${d.toFixed(3)}in`; return `${d.toFixed(2)}cm`; } /** * 贯穿预览区全宽的横向标尺:刻度按「预览标尺单位」绘制;与模板画布单位无关。 */ 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 ? formatSelectionWidthForPreviewRuler(selection.width, baseW, paperWidthTemplate, templateUnit, displayUnit) : null; return ( {nodes} {selection && Number.isFinite(selW) && selW > 0.5 && ( <> {wLabel && ( {wLabel} )} )} ); } /** * 多选项在画布上的文案:有 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 }: { el: LabelElement; isAppPrintField?: boolean }) { const cfg = el.config as Record; const type = canonicalElementType(el.type); const isVerticalRotation = el.rotation === 'vertical'; // Common styles const commonStyle: React.CSSProperties = { fontSize: (cfg?.fontSize as number) ?? 14, fontFamily: (cfg?.fontFamily as string) ?? 'Arial', fontWeight: (cfg?.fontWeight as string) ?? 'normal', textAlign: (cfg?.textAlign as any) ?? 'left', color: (cfg?.color as string) ?? '#000', }; // 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') { const text = (cfg?.text as string) ?? 'Text'; 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 format = (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 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 format = 'HH:mm'; const example = format.replace('HH', '12').replace('mm', '30'); 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'); if (!value) return null; return { id: item.key, label: item.label, value, unit, }; }).filter(Boolean) as Array<{ id: string; label: string; value: string; unit: string }>; const extraRows = nutritionExtraRows(cfg) .filter((item) => item.value.trim()) .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, '')}
) : null} {servingsPerContainer ? (
Servings Per Container {servingsPerContainer}
) : null} {servingSize ? (
Serving Size {servingSize}
) : null}
{rows.length === 0 ? (
No nutrients
) : ( rows.slice(0, 18).map((row) => (
{row.label} {formatNutritionValue(row.value, row.unit)}
)) )}
); return (
{nutritionContent}
); } // 空白占位 if (type === 'BLANK') { return
; } 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; } 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 = 'none', selectedId, onSelect, onUpdateElement, onDeleteElement, onTemplateChange, scale = 1, onZoomIn, onZoomOut, onResetZoom, onPreview, hideToolbarPresetSize = false, }: LabelCanvasProps) { const scrollContainerRef = useRef(null); const canvasRef = useRef(null); const dragRef = useRef<{ id: string; startX: number; startY: number; elX: number; elY: number; w: number; h: number; } | null>(null); const resizeRef = useRef<{ id: string; corner: string; startX: number; startY: number; w: number; h: number; elX: number; elY: number } | 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); /** 仅影响预览区标尺刻度/读数,与顶部表单画布单位(template.unit)不同步 */ const [previewRulerUnit, setPreviewRulerUnit] = useState("cm"); 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 canvasBorderClass = canvasBorder === 'line' ? 'border border-gray-500' : canvasBorder === 'dotted' ? 'border border-dotted border-gray-500' : 'border border-transparent'; const labelWorkspaceLayoutRef = useRef(null); const [rulerLayoutWidth, setRulerLayoutWidth] = useState(0); useLayoutEffect(() => { const el = labelWorkspaceLayoutRef.current; if (!el) return; const ro = new ResizeObserver((entries) => { for (const e of entries) { setRulerLayoutWidth(e.contentRect.width); } }); ro.observe(el); setRulerLayoutWidth(el.getBoundingClientRect().width); return () => ro.disconnect(); }, []); const rulerTotalWidth = Math.max(1, rulerLayoutWidth, widthPx); const paperOffsetX = Math.max(0, (rulerTotalWidth - widthPx) / 2); const rulerSelection = useMemo(() => { if (!selectedId) return null; const el = template.elements.find((x) => x.id === selectedId); if (!el) return null; return { x: el.x, width: el.width }; }, [selectedId, template.elements]); const handlePointerDown = useCallback( (e: React.PointerEvent, id: string) => { // 如果按住了空格,直接返回,交给外层 panning 处理 // 允许中键 (button 1) 拖动 if (isSpacePressed || e.button === 1) return; e.stopPropagation(); onSelect(id); // Focus canvas for keyboard events canvasRef.current?.focus(); const el = template.elements.find((x) => x.id === id); if (!el) return; 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'; } dragRef.current = { id, startX: e.clientX, startY: e.clientY, elX: el.x, elY: el.y, w: el.width, h: el.height, }; lastUpdateRef.current = { id, x: el.x, y: el.y }; // 初始化 (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId); }, [template.elements, onSelect, isSpacePressed] ); 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 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, startX, startY, elX, elY, w, h } = dragRef.current; const clientX = e.clientX; const clientY = e.clientY; requestUpdate(() => { const dx = (clientX - startX) / scale; const dy = (clientY - startY) / scale; const rawX = elX + dx; const rawY = elY + dy; const { x: snappedX, y: snappedY } = clampDragPosition( rawX, rawY, w, h, baseW, baseH, LABEL_CANVAS_SAFE_MARGIN_PX, ); const domEl = document.getElementById(`element-${id}`); if (domEl) { domEl.style.left = `${snappedX}px`; domEl.style.top = `${snappedY}px`; } lastUpdateRef.current = { id, x: snappedX, y: snappedY }; }); } // Resize Element if (resizeRef.current) { const { id, corner, startX, startY, w, h, elX, elY } = resizeRef.current; const clientX = e.clientX; const clientY = e.clientY; requestUpdate(() => { const dx = (clientX - startX) / scale; const dy = (clientY - startY) / scale; let nw = w; let nh = h; let nx = elX; let ny = elY; if (corner.includes('e')) nw = Math.max(20, w + dx); if (corner.includes('w')) { nw = Math.max(20, w - dx); // Keep the right edge anchored when width hits min limit. nx = elX + (w - nw); } if (corner.includes('s')) nh = Math.max(12, h + dy); if (corner.includes('n')) { nh = Math.max(12, h - dy); // Keep the bottom edge anchored when height hits min limit. ny = elY + (h - nh); } const snappedW = snapToGrid(nw); const snappedH = snapToGrid(nh); const snappedX = snapToGrid(nx); const snappedY = snapToGrid(ny); const clamped = clampLabelElementBox( snappedX, snappedY, snappedW, snappedH, baseW, baseH, LABEL_CANVAS_SAFE_MARGIN_PX, ); const domEl = document.getElementById(`element-${id}`); if (domEl) { domEl.style.width = `${clamped.w}px`; domEl.style.height = `${clamped.h}px`; domEl.style.left = `${clamped.x}px`; domEl.style.top = `${clamped.y}px`; } lastUpdateRef.current = { id, width: clamped.w, height: clamped.h, x: clamped.x, y: clamped.y, }; }); } // 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] ); const handlePointerUp = useCallback(() => { // 结束画布平移 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; 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 = ''; } } if (lastUpdateRef.current) { const { id, ...patch } = lastUpdateRef.current; onUpdateElement(id, patch); lastUpdateRef.current = null; } dragRef.current = null; resizeRef.current = null; paperResizeRef.current = null; setPaperResizeCursor(null); document.body.style.cursor = ''; }, [onUpdateElement]); 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]); const centerScrollInViewport = useCallback(() => { const el = scrollContainerRef.current; if (!el) return; const run = () => { el.scrollLeft = Math.max(0, (el.scrollWidth - el.clientWidth) / 2); el.scrollTop = Math.max(0, (el.scrollHeight - el.clientHeight) / 2); }; requestAnimationFrame(() => requestAnimationFrame(run)); }, []); // 缩放或纸张尺寸变化:清空平移偏移,并把画布重新滚到视口中央,避免放大后靠边被遮挡 useEffect(() => { setPanOffset({ x: 0, y: 0 }); centerScrollInViewport(); const t = window.setTimeout(centerScrollInViewport, 80); return () => window.clearTimeout(t); }, [scale, baseW, baseH, rulerLayoutWidth, centerScrollInViewport]); // 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, ); onUpdateElement(el.id, { x, y }); }, [selectedId, template.elements, onUpdateElement, onDeleteElement, onSelect, baseW, baseH]); 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
{onPreview && ( )} {onTemplateChange && !hideToolbarPresetSize ? ( ) : null} {onTemplateChange ? ( ) : null}
{Math.round(scale * 100)}%
{onResetZoom ? ( ) : null}
{/* Canvas Container:底层灰底铺满可视区,内容层可滚动 */}
{ // 点击画布空白处取消选中 const target = e.target as HTMLElement; const isOnElement = target.closest('[id^="element-"]'); 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 && !isOnPaperResize) { onSelect(null); } }} onPointerDown={(e) => { // 空白处按下即开始平移(在画布内且未点到元素/纸张拖拽条) const target = e.target as HTMLElement; const isOnElement = target.closest('[id^="element-"]'); 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 && !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} > {/* 选中元素对齐参考线:延伸至画布四边,蓝色虚线 */} {selectedId ? (() => { const el = template.elements.find((e) => e.id === selectedId); if (!el) return null; const lineCls = "pointer-events-none absolute z-[2] border-blue-600"; return ( <>
); })() : null} {/* Paper resize: top */} {onTemplateChange && (
beginPaperResize(e, 'top')} > ⋮
)} {/* Paper resize: left */} {onTemplateChange && (
beginPaperResize(e, 'left')} > ⋮
)} {/* 纸张四角拖拽缩放已关闭;宽高请在顶部表单修改 */} {/* 纸张尺寸拖拽:底部拉高 */} {onTemplateChange && (
beginPaperResize(e, 'bottom')} > ⋮
)} {/* 纸张尺寸拖拽:右侧拉宽 */} {onTemplateChange && (
beginPaperResize(e, 'right')} > ⋮
)} {template.elements.map((el) => { const isPrintField = isPrintInputElement(el); return (
{ e.stopPropagation(); onSelect(el.id); }} onPointerDown={(e) => handlePointerDown(e, el.id)} >
{selectedId === el.id && ( <> {/* 4 Corners */} {(['nw', 'ne', 'sw', 'se'] as const).map((corner) => (
{ e.stopPropagation(); const el0 = template.elements.find((x) => x.id === el.id)!; resizeRef.current = { id: el.id, corner, startX: e.clientX, startY: e.clientY, w: el0.width, h: el0.height, elX: el0.x, elY: el0.y, }; (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId); }} /> ))} {/* 4 Edges */} {(['n', 's', 'w', 'e'] as const).map((edge) => (
{ e.stopPropagation(); const el0 = template.elements.find((x) => x.id === el.id)!; const domEl = document.getElementById(`element-${el.id}`); if (domEl) { domEl.classList.add('z-50', 'opacity-90'); } resizeRef.current = { id: el.id, corner: edge, startX: e.clientX, startY: e.clientY, w: el0.width, h: el0.height, elX: el0.x, elY: el0.y, }; (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId); }} /> ))} )}
); })} {baseW > LABEL_CANVAS_SAFE_MARGIN_PX * 2 && baseH > LABEL_CANVAS_SAFE_MARGIN_PX * 2 ? ( {(() => { const m = LABEL_CANVAS_SAFE_MARGIN_PX; const stroke = "#2563eb"; const sw = 2; const dash = "10 6"; return ( <> ); })()} ) : null}
); } /** Preview only: no grid, no rulers, no drag; scale to fit. */ export function LabelPreviewOnly({ template, canvasBorder = 'none', maxWidth = 480, }: { template: LabelTemplate; canvasBorder?: 'none' | 'line' | 'dotted'; maxWidth?: number; }) { /** 画布 = 模板 width×height×unit(如 5cm×5cm → 189×189px),控件坐标与编辑区 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, 2) : 1; const displayW = baseW * scaleToFit; const displayH = baseH * scaleToFit; const previewBorderClass = canvasBorder === 'line' ? 'border border-gray-500' : canvasBorder === 'dotted' ? 'border border-dotted border-gray-500' : 'border border-transparent'; // 与编辑区一致:内层 baseW×baseH,transformOrigin 0 0 缩放,保证位置/样式一致 return (
{template.elements.map((el) => { const isPrintField = isPrintInputElement(el); return (
); })}
); }