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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '../../ui/select'; import { cn } from '../../ui/utils'; /** 真实条形码渲染(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; } const RULER_H = 20; const RULER_W = 20; /** Ruler at scroll container level - uses container dimensions for tick placement */ function RulerTop({ unit, width }: { unit: 'cm' | 'inch'; width: number }) { const minorStep = unit === 'cm' ? 0.5 : 0.5; const majorStep = unit === 'cm' ? 1 : 1; const widthInUnit = unit === 'cm' ? width / 37.8 : width / 96; const ticks: { value: number; px: number; isMajor: boolean }[] = []; for (let v = 0; v <= widthInUnit + 0.01; v += minorStep) { const px = unitToPx(v, unit); if (px <= width + 1) ticks.push({ value: v, px, isMajor: Math.abs(v % majorStep) < 0.01 }); } return (
{ticks.map((t) => (
))} {ticks.filter((t) => t.isMajor).map((t) => ( {t.value} ))}
); } function RulerLeft({ unit, height }: { unit: 'cm' | 'inch'; height: number }) { const minorStep = unit === 'cm' ? 0.5 : 0.5; const majorStep = unit === 'cm' ? 1 : 1; const heightInUnit = unit === 'cm' ? height / 37.8 : height / 96; const ticks: { value: number; px: number; isMajor: boolean }[] = []; for (let v = 0; v <= heightInUnit + 0.01; v += minorStep) { const px = unitToPx(v, unit); if (px <= height + 1) ticks.push({ value: v, px, isMajor: Math.abs(v % majorStep) < 0.01 }); } return (
{ticks.map((t) => (
))} {ticks.filter((t) => t.isMajor).map((t) => ( {t.value} ))}
); } /** Ruler OUTSIDE canvas frame - variant: top (corner+top) or left. Unit = display unit for ruler. */ function CanvasRulers({ unit, baseW, baseH, variant = 'top', }: { unit: 'cm' | 'inch'; baseW: number; baseH: number; variant?: 'top' | 'left'; }) { const minorStep = unit === 'cm' ? 0.5 : 0.5; const majorStep = unit === 'cm' ? 1 : 1; // Convert canvas size to display unit for tick range (baseW/baseH are in px) const widthInUnit = unit === 'cm' ? baseW / 37.8 : baseW / 96; const heightInUnit = unit === 'cm' ? baseH / 37.8 : baseH / 96; const topTicks: { value: number; px: number; isMajor: boolean }[] = []; for (let v = 0; v <= widthInUnit + 0.01; v += minorStep) { const px = unitToPx(v, unit); if (px <= baseW + 1) { topTicks.push({ value: v, px, isMajor: Math.abs(v % majorStep) < 0.01 }); } } const leftTicks: { value: number; px: number; isMajor: boolean }[] = []; for (let v = 0; v <= heightInUnit + 0.01; v += minorStep) { const px = unitToPx(v, unit); if (px <= baseH + 1) { leftTicks.push({ value: v, px, isMajor: Math.abs(v % majorStep) < 0.01 }); } } const unitLabel = unit === 'cm' ? 'cm' : 'in'; if (variant === 'left') { return (
{leftTicks.map((t) => (
))} {leftTicks.filter((t) => t.isMajor).map((t) => ( {t.value} ))}
); } return ( <>
{unitLabel}
{topTicks.map((t) => (
))} {topTicks.filter((t) => t.isMajor).map((t) => ( {t.value} ))}
); } /** 根据元素类型与 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 || 'Select...'}
); } 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 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 [rulerUnit, setRulerUnit] = React.useState<'cm' | 'inch'>(template.unit); const [containerSize, setContainerSize] = React.useState({ w: 400, h: 430 }); 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; // Sync ruler unit when template unit changes (e.g. preset applied) React.useEffect(() => { setRulerUnit(template.unit); }, [template.unit]); // Measure scroll container for ruler dimensions const measureContainer = React.useCallback(() => { const el = scrollContainerRef.current; if (el) setContainerSize({ w: el.clientWidth, h: el.clientHeight }); }, []); React.useEffect(() => { measureContainer(); const el = scrollContainerRef.current; if (!el) return; const ro = new ResizeObserver(measureContainer); ro.observe(el); return () => ro.disconnect(); }, [measureContainer, template.showRuler]); 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 }; }); } }, [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; }, [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 header */}
Label Preview
{onPreview && ( )} {onTemplateChange && ( <> )}
{Math.round(scale * 100)}%
{/* Canvas area: ruler at this level + scroll container, fills remaining space */}
{template.showRuler ? (
{/* Corner */}
{rulerUnit === 'cm' ? 'cm' : 'in'}
{/* Top ruler - spans scroll area width */} {/* Left ruler - spans scroll area height */} {/* Scroll container - canvas content inside */}
{ const target = e.target as HTMLElement; const isOnElement = target.closest('[id^="element-"]'); const isOnCanvasArea = canvasRef.current?.contains(target); if (isOnCanvasArea && !isOnElement && !dragRef.current && !resizeRef.current) { 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.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); }} /> ))} )}
))}
) : (
{ const target = e.target as HTMLElement; const isOnElement = target.closest('[id^="element-"]'); const isOnCanvasArea = canvasRef.current?.contains(target); if (isOnCanvasArea && !isOnElement && !dragRef.current && !resizeRef.current) { 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.elements.map((el) => (
{ e.stopPropagation(); onSelect(el.id); }} onPointerDown={(e) => handlePointerDown(e, el.id)} > {selectedId === el.id && ( <> {(['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); }} /> ))} {(['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); }} /> ))} )}
))}
)}
); } /** 仅用于预览:无网格、无标尺、无拖拽,按比例缩放 */ 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) => (
))}
); }