import React, { useCallback, useRef, useEffect } 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'; /** 真实条形码渲染(JsBarcode),支持水平/竖排 */ function BarcodeBlock({ data, width, height, showText, orientation = 'horizontal', }: { data: string; width: number; height: number; showText?: boolean; orientation?: 'horizontal' | 'vertical'; }) { const svgRef = useRef(null); const isVertical = orientation === 'vertical'; const barHeight = Math.max(20, (isVertical ? width : height) - (showText ? 14 : 4)); useEffect(() => { if (svgRef.current && data) { try { JsBarcode(svgRef.current, data, { format: 'CODE128', width: 1, height: barHeight, displayValue: showText !== false, margin: 2, fontOptions: '', fontSize: 10, }); } catch { // invalid data, ignore } } }, [data, barHeight, showText]); 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; } /** 1cm ≈ 37.8px (96 DPI); 1 inch = 96px */ function unitToPx(value: number, unit: 'cm' | 'inch'): number { return unit === 'cm' ? value * 37.8 : value * 96; } /** px 转单位(用于拖拽调整纸张尺寸) */ function pxToUnit(px: number, unit: 'cm' | 'inch'): number { return unit === 'cm' ? px / 37.8 : px / 96; } /** * 多选项在画布上的文案:有 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 directKey = field === 'value' ? key : `${key}Unit`; const direct = cfg[directKey]; if (direct != null && String(direct).trim() !== '') return String(direct).trim(); const fixedRows = Array.isArray(cfg.fixedNutrients) ? (cfg.fixedNutrients as Record[]) : []; const row = fixedRows.find((item) => String(item.key ?? '').trim() === key); 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 = ((cfg?.orientation as string) === 'vertical' ? 'vertical' : 'horizontal') as 'horizontal' | 'vertical'; 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 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 format = 'HH:mm'; const example = format.replace('HH', '12').replace('mm', '30'); return (
{example}
); } if (type === 'DURATION') { 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; 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; onPreview?: () => 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, selectedId, onSelect, onUpdateElement, onDeleteElement, onTemplateChange, scale = 1, onZoomIn, onZoomOut, onPreview, }: LabelCanvasProps) { const scrollContainerRef = useRef(null); const canvasRef = useRef(null); const dragRef = useRef<{ id: string; startX: number; startY: number; elX: number; elY: 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); const baseW = unitToPx(template.width, template.unit); const baseH = unitToPx(template.height, template.unit); const widthPx = baseW * scale; const heightPx = baseH * scale; const showGrid = template.showGrid !== false; 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 }; 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) { // e.persist(); // React 17+ doesn't strictly need this for properties access in rAF closure if we read them now const { id, startX, startY, elX, elY } = dragRef.current; const clientX = e.clientX; const clientY = e.clientY; requestUpdate(() => { const dx = (clientX - startX) / scale; const dy = (clientY - startY) / scale; const rawX = Math.max(0, elX + dx); const rawY = Math.max(0, elY + dy); const snappedX = snapToGrid(rawX); const snappedY = snapToGrid(rawY); // 直接操作 DOM 避免频繁重渲染 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 }; // 注意:这里不再更新 dragRef.current,因为我们在闭包里计算 dx, dy 也是 Ok 的。 // 只要我们始终基于 startX/elX 计算,就不会有精度积累误差。 }); } // 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); // 直接操作 DOM const domEl = document.getElementById(`element-${id}`); if (domEl) { domEl.style.width = `${snappedW}px`; domEl.style.height = `${snappedH}px`; domEl.style.left = `${snappedX}px`; domEl.style.top = `${snappedY}px`; } lastUpdateRef.current = { id, width: snappedW, height: snappedH, x: snappedX, y: snappedY }; }); } // 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] ); 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]); // 画布初始居中:挂载或尺寸/缩放变化后让内容居中 useEffect(() => { const el = scrollContainerRef.current; if (!el) return; const center = () => { el.scrollLeft = Math.max(0, (el.scrollWidth - el.clientWidth) / 2); el.scrollTop = Math.max(0, (el.scrollHeight - el.clientHeight) / 2); }; const raf = requestAnimationFrame(center); const t = setTimeout(center, 100); return () => { cancelAnimationFrame(raf); clearTimeout(t); }; }, [scale, baseW, baseH]); // 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; // Wait, ArrowDown should be +step (y increases downwards) default: return; } // Fix: ArrowDown +step if (e.key === 'ArrowDown') dy = step; e.preventDefault(); onUpdateElement(el.id, { x: Math.max(0, el.x + dx), y: Math.max(0, el.y + dy) }); }, [selectedId, template.elements, onUpdateElement, onDeleteElement, onSelect]); 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 && ( <> )}
{Math.round(scale * 100)}%
{/* 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} > {template.showRuler && (
{template.unit} {template.width} × {template.height}
)} {/* Paper resize: top */} {onTemplateChange && (
beginPaperResize(e, 'top')} > ⋮
)} {/* Paper resize: left */} {onTemplateChange && (
beginPaperResize(e, 'left')} > ⋮
)} {/* Paper resize: corners (optional but helpful) */} {onTemplateChange && ( <>
beginPaperResize(e, 'top-left')} />
beginPaperResize(e, 'top-right')} />
beginPaperResize(e, 'bottom-left')} />
beginPaperResize(e, 'bottom-right')} /> )} {/* 纸张尺寸拖拽:底部拉高 */} {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); }} /> ))} )}
); })}
); } /** Preview only: no grid, no rulers, no drag; scale to fit. */ export function LabelPreviewOnly({ template, maxWidth = 480, }: { template: LabelTemplate; maxWidth?: number; }) { const baseW = unitToPx(template.width, template.unit); const baseH = unitToPx(template.height, template.unit); const minX = Math.min(0, ...template.elements.map((el) => el.x)); const minY = Math.min(0, ...template.elements.map((el) => el.y)); const maxX = Math.max(baseW, ...template.elements.map((el) => el.x + el.width)); const maxY = Math.max(baseH, ...template.elements.map((el) => el.y + el.height)); const contentW = Math.max(1, maxX - minX); const contentH = Math.max(1, maxY - minY); const scaleToFit = maxWidth ? Math.min(maxWidth / contentW, maxWidth / contentH, 2) : 1; const displayW = contentW * scaleToFit; const displayH = contentH * scaleToFit; // 与编辑区一致:内层 baseW×baseH,transformOrigin 0 0 缩放,保证位置/样式一致 return (
{template.elements.map((el) => { const isPrintField = isPrintInputElement(el); return (
); })}
); }