import React, { useCallback, useRef, useEffect } from 'react'; import JsBarcode from 'jsbarcode'; import { QRCodeSVG } from 'qrcode.react'; import type { LabelTemplate, LabelElement, ElementType } from '../../../types/labelTemplate'; import { PRESET_LABEL_SIZES } from '../../../types/labelTemplate'; import { cn } from '../../ui/utils'; 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; } /** 根据元素类型与 config 渲染画布上的默认内容 */ function ElementContent({ el }: { el: LabelElement }) { const cfg = el.config as Record; const type = el.type as ElementType; // 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', }; // 文本类 const inputType = cfg?.inputType as string | undefined; if (type === 'TEXT_STATIC') { const text = (cfg?.text as string) ?? '文本'; if (inputType === 'number') { return ( ); } if (inputType === 'options') { return (
{text || '请选择...'}
); } if (inputType === 'text') { return ( ); } return (
{text}
); } if (type === 'TEXT_PRODUCT') { const text = (cfg?.text as string) ?? '商品名'; return (
{text}
); } if (type === 'TEXT_PRICE') { const prefix = (cfg?.prefix as string) ?? '¥'; const text = (cfg?.text as string) ?? '0.00'; return (
{prefix} {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; if (src) { return ( ); } return (
Logo
); } // 日期/时间 if (type === 'DATE') { const format = (cfg?.format as string) ?? 'YYYY-MM-DD'; const example = format.replace('YYYY', '2025').replace('MM', '02').replace('DD', '01'); const isInput = cfg?.inputType === 'datetime' || cfg?.inputType === 'date'; if (isInput) { return ( ); } return
{example}
; } // (Simplified other types similarly for brevity, ensuring style prop is passed) if (type === 'TIME') { const format = (cfg?.format as string) ?? 'HH:mm'; const example = format.replace('HH', '12').replace('mm', '30'); return
{example}
; } if (type === 'DURATION') { return
保质期 2025-02-04
; } if (type === 'WEIGHT') { const value = (cfg?.value as number) ?? 500; const unit = (cfg?.unit as string) ?? 'g'; return
{value}{unit}
; } 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 calories = (cfg?.calories as number) ?? 120; return (
Nutrition Facts
Calories {calories}
); } // 空白占位 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; } 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: 'bottom' | 'right'; startX: number; startY: number; startW: number; startH: 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 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 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); nx = elX + dx; } if (corner.includes('s')) nh = Math.max(12, h + dy); if (corner.includes('n')) { nh = Math.max(12, h - dy); ny = elY + dy; } 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 } = paperResizeRef.current; const clientX = e.clientX; const clientY = e.clientY; requestUpdate(() => { const deltaPxX = (clientX - startX) / scale; const deltaPxY = (clientY - startY) / scale; const deltaUnitX = pxToUnit(deltaPxX, template.unit); const deltaUnitY = pxToUnit(deltaPxY, template.unit); if (edge === 'bottom') { const newH = Math.max(1, startH + deltaUnitY); onTemplateChange({ height: newH }); } else { const newW = Math.max(1, startW + deltaUnitX); onTemplateChange({ width: newW }); } }); } }, [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; }, [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(() => { 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('[title*="拖拽拉高"]') || target.closest('[title*="拖拽拉宽"]'); if (!isOnElement && !isOnPaperResize) { onSelect(null); } }} onPointerDown={(e) => { // 空白处或标尺等非控件区域按下即开始平移(放宽判定:在画布内且未点到元素/纸张拖拽条) const target = e.target as HTMLElement; const isOnElement = target.closest('[id^="element-"]'); const isOnPaperResize = target.closest('[title*="拖拽拉高"]') || target.closest('[title*="拖拽拉宽"]'); 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}
)} {/* 纸张尺寸拖拽:底部拉高 */} {onTemplateChange && (
{ e.stopPropagation(); paperResizeRef.current = { edge: 'bottom', startX: e.clientX, startY: e.clientY, startW: template.width, startH: template.height, }; (e.target as HTMLElement).setPointerCapture?.(e.pointerId); }} > ⋮
)} {/* 纸张尺寸拖拽:右侧拉宽 */} {onTemplateChange && (
{ e.stopPropagation(); paperResizeRef.current = { edge: 'right', startX: e.clientX, startY: e.clientY, startW: template.width, startH: template.height, }; (e.target as HTMLElement).setPointerCapture?.(e.pointerId); }} > ⋮
)} {template.elements.map((el) => (
{ 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 scaleToFit = maxWidth ? Math.min(maxWidth / baseW, maxWidth / baseH, 2) : 1; const displayW = baseW * scaleToFit; const displayH = baseH * scaleToFit; // 与编辑区一致:内层 baseW×baseH,transformOrigin 0 0 缩放,保证位置/样式一致 return (
{template.elements.map((el) => (
))}
); }