import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'; import { createPortal } from 'react-dom'; import { LabelTemplate, TemplateElement } from '../types'; import { mmToPx, MM_TO_PX, shouldRenderElement, centerElementOnLabel } from '../utils'; import { getSelectionBounds } from '../elementAlign'; import type { ElementClipboardActions } from '../hooks/useElementClipboard'; import { createDefaultTableElement, getTableColWidths, getTableRowHeights, iterTableCells, toggleTableCellSelection, scaleTableTracks, getCellAnchor, getCellRectPx, type TableCellCoord, } from '../tableUtils'; import { useIsNarrowScreen } from '../hooks/useIsMobile'; import { CanvasLabelImage } from './CanvasLabelImage'; import { ElementFloatPreview, getFloatPreviewPaddingMm, renderElementFloatPreview } from './ElementFloatPreview'; import { renderLabelToDataUrl } from '../labelRenderer'; import { useAppDialog } from './AppDialog'; import { MobileMainToolButtons, type MobileToolButtonItem } from './MobileMainToolButtons'; import { ZoomIn, ZoomOut, Type, Barcode, QrCode, Image as ImageIcon, Move, Trash2, Table2, } from 'lucide-react'; interface LabelDesignerProps { template: LabelTemplate; onChange: (updated: LabelTemplate) => void; selectedElementIds: string[]; onSelectElements: (ids: string[]) => void; selectedTableCells?: TableCellCoord[]; onSelectTableCells?: (cells: TableCellCoord[] | ((prev: TableCellCoord[]) => TableCellCoord[])) => void; variant?: 'desktop' | 'mobile'; /** 微调等场景下隐藏选区框与缩放手柄 */ suppressSelectionChrome?: boolean; elementClipboard?: Pick< ElementClipboardActions, 'copySelectedElements' | 'pasteClipboardElements' | 'canCopy' | 'canPaste' | 'clipboardCount' >; } type ActiveTool = 'move' | 'text' | 'barcode' | 'qrcode' | 'table'; type ResizeCorner = 'nw' | 'ne' | 'sw' | 'se'; const MIN_ELEM_W = 2; const MIN_ELEM_H = 2; function applyCornerResize( startX: number, startY: number, startW: number, startH: number, deltaXMm: number, deltaYMm: number, corner: ResizeCorner ) { let x = startX; let y = startY; let w = startW; let h = startH; if (corner.includes('e')) w = startW + deltaXMm; if (corner.includes('w')) { w = startW - deltaXMm; x = startX + deltaXMm; } if (corner.includes('s')) h = startH + deltaYMm; if (corner.includes('n')) { h = startH - deltaYMm; y = startY + deltaYMm; } if (w < MIN_ELEM_W) { if (corner.includes('w')) x += w - MIN_ELEM_W; w = MIN_ELEM_W; } if (h < MIN_ELEM_H) { if (corner.includes('n')) y += h - MIN_ELEM_H; h = MIN_ELEM_H; } return { x: Math.round(x * 10) / 10, y: Math.round(y * 10) / 10, w: Math.round(w * 10) / 10, h: Math.round(h * 10) / 10, }; } const MARQUEE_MIN_PX = 4; const DRAG_START_THRESHOLD_PX = 4; /** 松手前极短时间内的微小位移视为抖动,提交上一稳定位置 */ const DRAG_RELEASE_JITTER_PX = 4; const DRAG_RELEASE_JITTER_MS = 48; const MODERATE_ZOOM_MAX = 2.0; function blurActiveField() { const active = document.activeElement; if ( active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement || active instanceof HTMLSelectElement ) { active.blur(); } } /** 在容器内等比约束画布显示尺寸,避免宽/高单独触顶导致变形 */ function constrainCanvasDisplaySize( widthMm: number, heightMm: number, zoom: number, maxW: number, maxH: number ) { let displayW = mmToPx(widthMm, zoom); let displayH = mmToPx(heightMm, zoom); if (displayW > maxW) { displayW = maxW; const cappedZoom = displayW / (widthMm * MM_TO_PX); displayH = mmToPx(heightMm, cappedZoom); } if (displayH > maxH) { displayH = maxH; const cappedZoom = displayH / (heightMm * MM_TO_PX); displayW = mmToPx(widthMm, cappedZoom); } const displayZoom = displayW / (widthMm * MM_TO_PX); const canvasRenderScale = Math.max( 1, Math.round(MM_TO_PX * displayZoom * 100) / 100 ); return { displayW, displayH, displayZoom, canvasRenderScale }; } const ZOOM_MIN = 0.5; const ZOOM_MAX = 5; function getMaxZoomForContainer( widthMm: number, heightMm: number, maxW: number, maxH: number ) { if (maxW <= 0 || maxH <= 0) return ZOOM_MAX; const byW = maxW / (widthMm * MM_TO_PX); const byH = maxH / (heightMm * MM_TO_PX); return Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, byW, byH)); } function clampZoomValue(value: number, maxZoom: number) { return Math.min(maxZoom, Math.max(ZOOM_MIN, Math.round(value * 100) / 100)); } function rectFullyContains( outer: { left: number; top: number; right: number; bottom: number }, inner: { left: number; top: number; right: number; bottom: number } ) { return ( outer.left <= inner.left && outer.top <= inner.top && outer.right >= inner.right && outer.bottom >= inner.bottom ); } function applyQrcodeSquareResize( startX: number, startY: number, startW: number, startH: number, corner: ResizeCorner, deltaXMm: number, deltaYMm: number ) { const rect = applyCornerResize( startX, startY, startW, startH, deltaXMm, deltaYMm, corner ); const size = Math.max(rect.w, rect.h, MIN_ELEM_W); let x = startX; let y = startY; if (corner.includes('w')) x = startX + startW - size; if (corner.includes('n')) y = startY + startH - size; return { x: Math.round(x * 10) / 10, y: Math.round(y * 10) / 10, w: Math.round(size * 10) / 10, h: Math.round(size * 10) / 10, }; } export const LabelDesigner: React.FC = ({ template, onChange, selectedElementIds, onSelectElements, selectedTableCells = [], onSelectTableCells, variant = 'desktop', suppressSelectionChrome = false, elementClipboard, }) => { const { showConfirm } = useAppDialog(); const selectedElementId = selectedElementIds[0] || null; const isMobileLayout = variant === 'mobile'; const isNarrow = useIsNarrowScreen() || isMobileLayout; const [zoom, setZoom] = useState(MODERATE_ZOOM_MAX); const [canvasAreaSize, setCanvasAreaSize] = useState({ w: 0, h: 0 }); const [activeTool, setActiveTool] = useState('move'); const canvasAreaRef = useRef(null); const canvasRef = useRef(null); const imageInputRef = useRef(null); const previewDefaults = useMemo( () => template.variableDefaults ?? null, [template.variableDefaults] ); const printColorMode = template.paper?.printColorMode ?? 'cmyk'; const showReferencePreview = useMemo( () => !!template.previewReferenceBackground && template.enablePreviewReferenceBackground !== false, [template.previewReferenceBackground, template.enablePreviewReferenceBackground] ); const localElementsRef = useRef(null); /** 交互开始时的元素快照:拖动底图分层、缩放节流重绘 */ const [interactionSnapshot, setInteractionSnapshot] = useState( null ); /** 缩放时仅重绘当前元素的浮层内容 */ const [interactionFloatElements, setInteractionFloatElements] = useState< TemplateElement[] | null >(null); const dragRafRef = useRef(null); const pendingDragElementsRef = useRef(null); const pendingDragDeltaRef = useRef({ x: 0, y: 0 }); type DragCommitSnapshot = { elements: TemplateElement[]; delta: { x: number; y: number }; time: number; }; const dragCommitHistoryRef = useRef(null); const dragCommitHistoryPrevRef = useRef(null); const dragFloatLayerRef = useRef(null); const canvasWrapperRef = useRef(null); const interactionPortalRef = useRef(null); const dragFloatImgRefs = useRef>(new Map()); const dragFloatUrlsRef = useRef>({}); const dragFloatElementsRef = useRef([]); const dragVisualActivatedRef = useRef(false); const dragTransformLoopRef = useRef(null); const [dragFloatElements, setDragFloatElements] = useState([]); const [dragInteractionVisualActive, setDragInteractionVisualActive] = useState(false); const [interactionCanvasReady, setInteractionCanvasReady] = useState(false); const interactionFloatPaintRef = useRef(0); const interactionFloatTimerRef = useRef | null>(null); const [dragState, setDragState] = useState<{ elementId: string; type: 'drag' | 'resize'; resizeCorner?: ResizeCorner; startX: number; startY: number; startElemX: number; startElemY: number; startElemW: number; startElemH: number; startTableRowHeights?: number[]; startTableColWidths?: number[]; groupStartPositions?: Record; } | null>(null); const [marqueeState, setMarqueeState] = useState<{ startX: number; startY: number; currentX: number; currentY: number; additive: boolean; } | null>(null); const ignoreCanvasClickRef = useRef(false); const dragMovedRef = useRef(false); const tableCellPointerRef = useRef<{ row: number; col: number; elementId: string; startX: number; startY: number; pointerId: number; dragStarted: boolean; } | null>(null); const multiTouchActiveRef = useRef(false); const touchPointerMapRef = useRef>(new Map()); const marqueeRef = useRef(marqueeState); marqueeRef.current = marqueeState; const selectedIdsRef = useRef(selectedElementIds); selectedIdsRef.current = selectedElementIds; const templateElementsRef = useRef(template.elements); templateElementsRef.current = template.elements; const templateRef = useRef(template); templateRef.current = template; const addImageElement = useCallback( (src: string = '') => { const current = templateRef.current; const id = `image_${Date.now()}`; const n = current.elements.length + 1; const newElement: TemplateElement = { id, type: 'image', name: `图片 ${n}`, ...centerElementOnLabel(current.width, current.height, 30, 30), width: 30, height: 30, content: src, fontSize: 12, textAlign: 'center', fontWeight: 'normal', barcodeFormat: 'CODE128', showText: false, fontFamily: 'sans', backgroundColor: '#ffffff', backgroundOpacity: 0, imageFit: 'contain', }; onChange({ ...current, elements: [...current.elements, newElement], }); onSelectElements([id]); setActiveTool('move'); }, [onChange, onSelectElements] ); const addElement = useCallback( (type: 'text' | 'barcode' | 'qrcode' | 'table') => { const current = templateRef.current; let newElement: TemplateElement; const id = `${type}_${Date.now()}`; const n = current.elements.length + 1; if (type === 'table') { newElement = createDefaultTableElement(id, n); } else if (type === 'text') { newElement = { id, type: 'text', name: `文本 ${n}`, x: 5, y: 5, width: 30, height: 8, content: '文本内容', fontSize: 12, textAlign: 'left', verticalAlign: 'middle', wordWrap: true, fontWeight: 'normal', barcodeFormat: 'CODE128', showText: false, fontFamily: 'sans', backgroundColor: '#ffffff', backgroundOpacity: 0, }; } else if (type === 'barcode') { newElement = { id, type: 'barcode', name: `条形码 ${n}`, x: 5, y: 15, width: 50, height: 12, content: '123456789', fontSize: 10, textAlign: 'center', fontWeight: 'normal', barcodeFormat: 'CODE128', showText: true, fontFamily: 'mono', backgroundColor: '#ffffff', backgroundOpacity: 0, }; } else { newElement = { id, type: 'qrcode', name: `二维码 ${n}`, x: 40, y: 10, width: 20, height: 20, content: 'https://example.com', fontSize: 10, textAlign: 'center', fontWeight: 'normal', barcodeFormat: 'CODE128', showText: false, fontFamily: 'sans', backgroundColor: '#ffffff', backgroundOpacity: 0, }; } const centered = centerElementOnLabel( current.width, current.height, newElement.width, newElement.height ); newElement = { ...newElement, ...centered }; onChange({ ...current, elements: [...current.elements, newElement], }); onSelectElements([id]); setActiveTool('move'); }, [onChange, onSelectElements] ); const deleteSelectedElements = useCallback(async () => { const current = templateRef.current; const ids = selectedElementIds.length > 0 ? selectedElementIds : selectedElementId ? [selectedElementId] : []; const deletable = ids.filter((id) => { const el = current.elements.find((item) => item.id === id); return el && !el.locked; }); if (deletable.length === 0) return; const message = deletable.length === 1 ? `确定删除「${current.elements.find((item) => item.id === deletable[0])?.name ?? '未命名'}」吗?此操作无法撤销。` : `确定删除选中的 ${deletable.length} 个元素吗?此操作无法撤销。`; const confirmed = await showConfirm(message, { title: '删除元素', confirmLabel: '删除', variant: 'error', }); if (!confirmed) return; const idSet = new Set(deletable); const remaining = current.elements.filter((el) => !idSet.has(el.id)); onChange({ ...templateRef.current, elements: remaining }); onSelectElements(selectedElementIds.filter((id) => remaining.some((el) => el.id === id))); }, [selectedElementIds, selectedElementId, onChange, onSelectElements, showConfirm]); const handleToolClick = (tool: ActiveTool) => { if (tool === 'move') { setActiveTool('move'); } else { addElement(tool); } }; const handleImageToolClick = () => { imageInputRef.current?.click(); }; const handleImageFile = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file || !file.type.startsWith('image/')) { addImageElement(''); e.target.value = ''; return; } const reader = new FileReader(); reader.onload = () => addImageElement(reader.result as string); reader.onerror = () => addImageElement(''); reader.readAsDataURL(file); e.target.value = ''; }; useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { const activeTag = document.activeElement?.tagName.toLowerCase(); if (activeTag === 'input' || activeTag === 'textarea' || activeTag === 'select') return; if (e.key === 'v' || e.key === 'V') setActiveTool('move'); if (e.key === 't' || e.key === 'T') addElement('text'); if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'a') { e.preventDefault(); setActiveTool('move'); onSelectElements(templateRef.current.elements.filter((el) => !el.locked).map((el) => el.id)); return; } if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'c') { e.preventDefault(); elementClipboard?.copySelectedElements(); return; } if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'v') { e.preventDefault(); elementClipboard?.pasteClipboardElements(); setActiveTool('move'); return; } if (e.key === 'Delete' || e.key === 'Backspace') { e.preventDefault(); void deleteSelectedElements(); return; } const moveIds = selectedElementIds.length > 0 ? selectedElementIds : selectedElementId ? [selectedElementId] : []; if (moveIds.length === 0) return; const increment = e.shiftKey ? 0.5 : 0.1; let deltaX = 0; let deltaY = 0; if (e.key === 'ArrowUp') deltaY = -increment; else if (e.key === 'ArrowDown') deltaY = increment; else if (e.key === 'ArrowLeft') deltaX = -increment; else if (e.key === 'ArrowRight') deltaX = increment; else return; const idSet = new Set(moveIds); e.preventDefault(); const current = templateRef.current; onChange({ ...current, elements: current.elements.map((el) => { if (!idSet.has(el.id) || el.locked) return el; return { ...el, x: Math.round((el.x + deltaX) * 10) / 10, y: Math.round((el.y + deltaY) * 10) / 10, }; }), }); }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [ selectedElementId, selectedElementIds, template, onChange, addElement, onSelectElements, deleteSelectedElements, elementClipboard, ]); const clearInteractionCanvasState = useCallback(() => { if (interactionFloatTimerRef.current !== null) { clearTimeout(interactionFloatTimerRef.current); interactionFloatTimerRef.current = null; } interactionFloatPaintRef.current = 0; if (dragFloatLayerRef.current) { dragFloatLayerRef.current.style.transform = ''; } if (dragTransformLoopRef.current !== null) { cancelAnimationFrame(dragTransformLoopRef.current); dragTransformLoopRef.current = null; } setInteractionSnapshot(null); setInteractionFloatElements(null); setDragFloatElements([]); dragFloatElementsRef.current = []; dragVisualActivatedRef.current = false; setDragInteractionVisualActive(false); setInteractionCanvasReady(false); dragFloatImgRefs.current.clear(); dragFloatUrlsRef.current = {}; pendingDragDeltaRef.current = { x: 0, y: 0 }; dragCommitHistoryRef.current = null; dragCommitHistoryPrevRef.current = null; }, []); const cancelActiveInteraction = useCallback(() => { if (dragRafRef.current !== null) { cancelAnimationFrame(dragRafRef.current); dragRafRef.current = null; } pendingDragElementsRef.current = null; localElementsRef.current = null; clearInteractionCanvasState(); setDragState(null); dragMovedRef.current = false; setMarqueeState(null); }, [clearInteractionCanvasState]); const shouldIgnoreTouchPointer = (e: { pointerType: string; isPrimary: boolean }) => { if (multiTouchActiveRef.current) return true; if (e.pointerType === 'touch' && !e.isPrimary) return true; return false; }; useEffect(() => { if (!isMobileLayout && !isNarrow) return; const onPointerDown = (e: PointerEvent) => { if (e.pointerType !== 'touch') return; const target = e.target as Node | null; if (!canvasAreaRef.current?.contains(target)) return; touchPointerMapRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY }); if (touchPointerMapRef.current.size > 1) { multiTouchActiveRef.current = true; cancelActiveInteraction(); } }; const onPointerUp = (e: PointerEvent) => { if (e.pointerType !== 'touch') return; touchPointerMapRef.current.delete(e.pointerId); if (touchPointerMapRef.current.size <= 1) { multiTouchActiveRef.current = false; } }; window.addEventListener('pointerdown', onPointerDown, true); window.addEventListener('pointerup', onPointerUp, true); window.addEventListener('pointercancel', onPointerUp, true); return () => { window.removeEventListener('pointerdown', onPointerDown, true); window.removeEventListener('pointerup', onPointerUp, true); window.removeEventListener('pointercancel', onPointerUp, true); touchPointerMapRef.current.clear(); multiTouchActiveRef.current = false; }; }, [isMobileLayout, isNarrow, cancelActiveInteraction]); const applyModerateZoom = useCallback(() => { const area = canvasAreaRef.current; if (!area) return; const pad = isNarrow ? 32 : 80; const availW = area.clientWidth - pad; const availH = area.clientHeight - pad; if (availW <= 0 || availH <= 0) return; const docW = mmToPx(template.width, 1); const docH = mmToPx(template.height, 1); const fitZoom = Math.min(availW / docW, availH / docH, MODERATE_ZOOM_MAX); setZoom(Math.max(0.5, Math.round(fitZoom * 100) / 100)); }, [template.width, template.height, isNarrow]); useEffect(() => { applyModerateZoom(); }, [template.id, applyModerateZoom]); useEffect(() => { const area = canvasAreaRef.current; if (!area) return; const updateSize = () => { setCanvasAreaSize({ w: area.clientWidth, h: area.clientHeight }); }; updateSize(); const observer = new ResizeObserver(updateSize); observer.observe(area); return () => observer.disconnect(); }, []); const canvasPad = 32; const canvasLayout = useMemo(() => { const maxW = canvasAreaSize.w - canvasPad; const maxH = canvasAreaSize.h - canvasPad; const maxZoom = getMaxZoomForContainer(template.width, template.height, maxW, maxH); if (maxW <= 0 || maxH <= 0) { const effectiveZoom = clampZoomValue(zoom, maxZoom); const displayW = mmToPx(template.width, effectiveZoom); const displayH = mmToPx(template.height, effectiveZoom); return { displayW, displayH, displayZoom: effectiveZoom, canvasRenderScale: Math.max(1, Math.round(MM_TO_PX * effectiveZoom * 100) / 100), maxZoom, }; } const constrained = constrainCanvasDisplaySize( template.width, template.height, zoom, maxW, maxH ); return { ...constrained, maxZoom }; }, [template.width, template.height, zoom, canvasAreaSize.w, canvasAreaSize.h]); const { displayW: canvasDisplayW, displayH: canvasDisplayH, displayZoom, canvasRenderScale, maxZoom, } = canvasLayout; const displayZoomRef = useRef(displayZoom); displayZoomRef.current = displayZoom; const maxZoomRef = useRef(maxZoom); maxZoomRef.current = maxZoom; const prefetchDragFloatImages = useCallback( (elements: TemplateElement[]) => { if (elements.length === 0) return; void Promise.all( elements.map(async (el) => { try { const url = await renderElementFloatPreview( el, canvasRenderScale, previewDefaults ); dragFloatUrlsRef.current[el.id] = url; const img = dragFloatImgRefs.current.get(el.id); if (img) { img.src = url; if (img.complete) { img.style.opacity = '1'; const placeholder = img.previousElementSibling as HTMLElement | null; if (placeholder) placeholder.style.display = 'none'; } } } catch (err) { console.error('Drag float preview error:', err); } }) ); }, [canvasRenderScale, previewDefaults] ); useEffect(() => { setZoom((z) => (z > maxZoom ? clampZoomValue(z, maxZoom) : z)); }, [maxZoom]); useEffect(() => { const area = canvasAreaRef.current; if (!area) return; const handleWheel = (e: WheelEvent) => { if (e.ctrlKey || e.metaKey) { e.preventDefault(); const delta = e.deltaY > 0 ? -0.25 : 0.25; setZoom((z) => clampZoomValue(z + delta, maxZoomRef.current)); } }; area.addEventListener('wheel', handleWheel, { passive: false }); return () => area.removeEventListener('wheel', handleWheel); }, []); const marqueeActive = marqueeState !== null; useEffect(() => { if (!marqueeActive) return; const handlePointerMove = (e: PointerEvent) => { if (multiTouchActiveRef.current) return; const canvas = canvasRef.current; if (!canvas) return; const rect = canvas.getBoundingClientRect(); const currentX = e.clientX - rect.left; const currentY = e.clientY - rect.top; setMarqueeState((prev) => (prev ? { ...prev, currentX, currentY } : null)); }; const handlePointerUp = () => { if (multiTouchActiveRef.current) { setMarqueeState(null); return; } const state = marqueeRef.current; setMarqueeState(null); if (!state) return; const left = Math.min(state.startX, state.currentX); const top = Math.min(state.startY, state.currentY); const right = Math.max(state.startX, state.currentX); const bottom = Math.max(state.startY, state.currentY); const isTiny = right - left < MARQUEE_MIN_PX && bottom - top < MARQUEE_MIN_PX; ignoreCanvasClickRef.current = true; if (isTiny) { if (!state.additive) { requestAnimationFrame(() => onSelectElements([])); } return; } const marqueeRect = { left, top, right, bottom }; const elements = localElementsRef.current || templateElementsRef.current; const hitIds = elements .filter((el) => { if (el.locked) return false; const elRect = { left: mmToPx(el.x, displayZoomRef.current), top: mmToPx(el.y, displayZoomRef.current), right: mmToPx(el.x + el.width, displayZoomRef.current), bottom: mmToPx(el.y + el.height, displayZoomRef.current), }; return rectFullyContains(marqueeRect, elRect); }) .map((el) => el.id); if (state.additive) { const merged = new Set([...selectedIdsRef.current, ...hitIds]); onSelectElements([...merged]); } else { onSelectElements(hitIds); } }; window.addEventListener('pointermove', handlePointerMove); window.addEventListener('pointerup', handlePointerUp); window.addEventListener('pointercancel', handlePointerUp); return () => { window.removeEventListener('pointermove', handlePointerMove); window.removeEventListener('pointerup', handlePointerUp); window.removeEventListener('pointercancel', handlePointerUp); }; }, [marqueeActive, displayZoom, onSelectElements]); useEffect(() => { if (!dragState) return; dragMovedRef.current = false; const syncInteractionPortalLayout = () => { const wrapper = canvasWrapperRef.current; const portal = interactionPortalRef.current; if (wrapper && portal) { const rect = wrapper.getBoundingClientRect(); portal.style.left = `${rect.left}px`; portal.style.top = `${rect.top}px`; portal.style.width = `${rect.width}px`; portal.style.height = `${rect.height}px`; } }; const runDragTransformLoop = () => { const tick = () => { syncInteractionPortalLayout(); const layer = dragFloatLayerRef.current; if (layer) { const { x, y } = pendingDragDeltaRef.current; layer.style.transform = `translate3d(${x}px, ${y}px, 0)`; } dragTransformLoopRef.current = requestAnimationFrame(tick); }; dragTransformLoopRef.current = requestAnimationFrame(tick); }; if (dragState.type === 'drag') { runDragTransformLoop(); } const handlePointerMove = (e: PointerEvent) => { if (multiTouchActiveRef.current) return; const deltaX = e.clientX - dragState.startX; const deltaY = e.clientY - dragState.startY; if (dragState.type === 'drag') { pendingDragDeltaRef.current = { x: deltaX, y: deltaY }; } const pastDragThreshold = Math.abs(deltaX) > DRAG_START_THRESHOLD_PX || Math.abs(deltaY) > DRAG_START_THRESHOLD_PX; if (dragState.type === 'drag' && !pastDragThreshold) { return; } if (dragState.type === 'drag' && pastDragThreshold) { dragMovedRef.current = true; if (!dragVisualActivatedRef.current) { dragVisualActivatedRef.current = true; setDragInteractionVisualActive(true); prefetchDragFloatImages(dragFloatElementsRef.current); } } const MM_PER_PX = 1 / (3.78 * displayZoomRef.current); const deltaXMm = deltaX * MM_PER_PX; const deltaYMm = deltaY * MM_PER_PX; if (dragState.type === 'drag') { const groupStarts = dragState.groupStartPositions; if (!groupStarts || Object.keys(groupStarts).length === 0) return; const updated = template.elements.map((el) => { const start = groupStarts[el.id]; if (!start || el.locked) return el; return { ...el, x: Math.round((start.x + deltaXMm) * 10) / 10, y: Math.round((start.y + deltaYMm) * 10) / 10, }; }); pendingDragElementsRef.current = updated; dragCommitHistoryPrevRef.current = dragCommitHistoryRef.current; dragCommitHistoryRef.current = { elements: updated, delta: { x: deltaX, y: deltaY }, time: performance.now(), }; } else if (dragState.type === 'resize') { const elem = template.elements.find((el) => el.id === dragState.elementId); if (!elem || elem.locked) return; dragMovedRef.current = true; const corner = dragState.resizeCorner ?? 'se'; const rect = elem.type === 'qrcode' ? applyQrcodeSquareResize( dragState.startElemX, dragState.startElemY, dragState.startElemW, dragState.startElemH, corner, deltaXMm, deltaYMm ) : applyCornerResize( dragState.startElemX, dragState.startElemY, dragState.startElemW, dragState.startElemH, deltaXMm, deltaYMm, corner ); const updated = template.elements.map((el) => { if (el.id !== dragState.elementId) return el; if ( el.type === 'table' && dragState.startTableRowHeights && dragState.startTableColWidths ) { return scaleTableTracks( { ...el, x: rect.x, y: rect.y }, rect.w, rect.h, dragState.startElemW, dragState.startElemH, dragState.startTableRowHeights, dragState.startTableColWidths ); } return { ...el, x: rect.x, y: rect.y, width: rect.w, height: rect.h }; }); pendingDragElementsRef.current = updated; const paintInteractionFloat = () => { interactionFloatPaintRef.current = performance.now(); interactionFloatTimerRef.current = null; const pending = pendingDragElementsRef.current?.find( (el) => el.id === dragState.elementId ); if (pending) setInteractionFloatElements([pending]); }; if (dragRafRef.current === null) { dragRafRef.current = requestAnimationFrame(() => { dragRafRef.current = null; const next = pendingDragElementsRef.current; if (next) { localElementsRef.current = next; const now = performance.now(); const elapsed = now - interactionFloatPaintRef.current; if (elapsed >= 66 || interactionFloatPaintRef.current === 0) { paintInteractionFloat(); } else if (interactionFloatTimerRef.current === null) { interactionFloatTimerRef.current = setTimeout( paintInteractionFloat, 66 - elapsed ); } } }); } } }; const resolveDragCommitElements = (): TemplateElement[] | null => { if (dragState.type !== 'drag') { return pendingDragElementsRef.current ?? localElementsRef.current; } const cur = dragCommitHistoryRef.current; const prev = dragCommitHistoryPrevRef.current; if (cur && prev) { const micro = Math.hypot(cur.delta.x - prev.delta.x, cur.delta.y - prev.delta.y); const dt = cur.time - prev.time; if (micro > 0 && micro <= DRAG_RELEASE_JITTER_PX && dt <= DRAG_RELEASE_JITTER_MS) { return prev.elements; } return cur.elements; } return cur?.elements ?? pendingDragElementsRef.current ?? localElementsRef.current; }; const handlePointerUp = () => { if (multiTouchActiveRef.current) { cancelActiveInteraction(); return; } if (dragRafRef.current !== null) { cancelAnimationFrame(dragRafRef.current); dragRafRef.current = null; } const commitElements = resolveDragCommitElements(); if (commitElements) { localElementsRef.current = commitElements; } pendingDragElementsRef.current = null; if (localElementsRef.current) { const shouldCommit = dragState.type === 'resize' || dragMovedRef.current; if (shouldCommit) { onChange({ ...templateRef.current, elements: localElementsRef.current }); } } localElementsRef.current = null; clearInteractionCanvasState(); setDragState(null); }; window.addEventListener('pointermove', handlePointerMove); window.addEventListener('pointerup', handlePointerUp); window.addEventListener('pointercancel', handlePointerUp); return () => { if (dragTransformLoopRef.current !== null) { cancelAnimationFrame(dragTransformLoopRef.current); dragTransformLoopRef.current = null; } window.removeEventListener('pointermove', handlePointerMove); window.removeEventListener('pointerup', handlePointerUp); window.removeEventListener('pointercancel', handlePointerUp); }; }, [dragState, template, displayZoom, onChange, cancelActiveInteraction, clearInteractionCanvasState, prefetchDragFloatImages]); const buildGroupStartPositions = (element: TemplateElement) => { const moveIds = selectedElementIds.includes(element.id) && selectedElementIds.length > 1 ? selectedElementIds : [element.id]; const groupStartPositions: Record = {}; for (const id of moveIds) { const el = template.elements.find((item) => item.id === id); if (el && !el.locked) { groupStartPositions[id] = { x: el.x, y: el.y }; } } return groupStartPositions; }; const startMarquee = (e: React.PointerEvent) => { if (activeTool !== 'move' || e.button !== 0) return; if (shouldIgnoreTouchPointer(e)) return; const canvas = canvasRef.current; if (!canvas) return; blurActiveField(); const rect = canvas.getBoundingClientRect(); const startX = e.clientX - rect.left; const startY = e.clientY - rect.top; e.preventDefault(); e.stopPropagation(); e.currentTarget.setPointerCapture(e.pointerId); setMarqueeState({ startX, startY, currentX: startX, currentY: startY, additive: e.shiftKey || e.ctrlKey || e.metaKey, }); }; const handleWorkspacePointerDown = (e: React.PointerEvent) => { if (activeTool !== 'move' || e.button !== 0) return; if (shouldIgnoreTouchPointer(e)) return; const target = e.target as HTMLElement; if (target.closest('[data-label-element]') || target.closest('.ps-handle')) return; startMarquee(e); }; const handleElementPointerDown = ( e: React.PointerEvent, element: TemplateElement, actionType: 'drag' | 'resize' ) => { if (e.button !== 0) return; if (shouldIgnoreTouchPointer(e)) return; e.stopPropagation(); if (e.shiftKey || e.ctrlKey || e.metaKey) { return; } handleDragStart(e, element, actionType); }; const handleElementClick = (e: React.MouseEvent, element: TemplateElement) => { e.stopPropagation(); if (dragMovedRef.current) { dragMovedRef.current = false; return; } const isMulti = e.shiftKey || e.ctrlKey || e.metaKey; if (isMulti) { if (selectedElementIds.includes(element.id)) { onSelectElements(selectedElementIds.filter((x) => x !== element.id)); } else { onSelectElements([...selectedElementIds, element.id]); } } else { onSelectElements([element.id]); } }; const handleDragStart = ( e: React.PointerEvent, element: TemplateElement, actionType: 'drag' | 'resize' ) => { if (shouldIgnoreTouchPointer(e)) return; e.preventDefault(); if (!selectedElementIds.includes(element.id)) { onSelectElements([element.id]); } if (element.locked) return; e.currentTarget.setPointerCapture(e.pointerId); const groupStartPositions = buildGroupStartPositions(element); const moveIds = new Set(Object.keys(groupStartPositions)); const floating = template.elements.filter( (el) => moveIds.has(el.id) && shouldRenderElement(el, null, 0, previewDefaults, 'design') ); setInteractionSnapshot(template.elements); setInteractionFloatElements(null); interactionFloatPaintRef.current = 0; dragFloatUrlsRef.current = {}; dragFloatImgRefs.current.clear(); dragCommitHistoryRef.current = null; dragCommitHistoryPrevRef.current = null; dragVisualActivatedRef.current = false; setDragInteractionVisualActive(false); setInteractionCanvasReady(false); dragFloatElementsRef.current = floating; setDragFloatElements(floating); setDragState({ elementId: element.id, type: actionType, startX: e.clientX, startY: e.clientY, startElemX: element.x, startElemY: element.y, startElemW: element.width, startElemH: element.height, groupStartPositions, }); }; const handleTableCellPointerDown = ( e: React.PointerEvent, element: TemplateElement, row: number, col: number ) => { if (e.button !== 0 || shouldIgnoreTouchPointer(e) || element.locked) return; e.stopPropagation(); e.currentTarget.setPointerCapture(e.pointerId); tableCellPointerRef.current = { row, col, elementId: element.id, startX: e.clientX, startY: e.clientY, pointerId: e.pointerId, dragStarted: false, }; }; const handleTableCellPointerMove = ( e: React.PointerEvent, element: TemplateElement ) => { const pending = tableCellPointerRef.current; if (!pending || pending.pointerId !== e.pointerId || pending.elementId !== element.id) { return; } if (pending.dragStarted || element.locked) return; const dx = e.clientX - pending.startX; const dy = e.clientY - pending.startY; if ( Math.abs(dx) > DRAG_START_THRESHOLD_PX || Math.abs(dy) > DRAG_START_THRESHOLD_PX ) { pending.dragStarted = true; dragMovedRef.current = true; tableCellPointerRef.current = null; try { (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); } catch { /* ignore */ } handleDragStart(e, element, 'drag'); } }; const handleTableCellPointerUp = (e: React.PointerEvent) => { const pending = tableCellPointerRef.current; if (!pending || pending.pointerId !== e.pointerId) return; if (!pending.dragStarted && onSelectTableCells) { onSelectTableCells((prev) => toggleTableCellSelection(prev, { row: pending.row, col: pending.col }) ); } tableCellPointerRef.current = null; try { (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); } catch { /* ignore */ } }; const handleResizeStart = ( e: React.PointerEvent, element: TemplateElement, corner: ResizeCorner ) => { if (shouldIgnoreTouchPointer(e)) return; e.stopPropagation(); e.preventDefault(); onSelectElements([element.id]); if (element.locked) return; e.currentTarget.setPointerCapture(e.pointerId); setInteractionSnapshot(template.elements); setInteractionFloatElements([element]); interactionFloatPaintRef.current = 0; setInteractionCanvasReady(false); setDragState({ elementId: element.id, type: 'resize', resizeCorner: corner, startX: e.clientX, startY: e.clientY, startElemX: element.x, startElemY: element.y, startElemW: element.width, startElemH: element.height, startTableRowHeights: element.type === 'table' ? getTableRowHeights(element) : undefined, startTableColWidths: element.type === 'table' ? getTableColWidths(element) : undefined, }); }; const fitToView = () => { const area = canvasAreaRef.current; if (!area) return; const pad = isMobileLayout ? 32 : 80; const availW = area.clientWidth - pad; const availH = area.clientHeight - pad; const docW = mmToPx(template.width, 1); const docH = mmToPx(template.height, 1); const fitZoom = Math.min(availW / docW, availH / docH, 5); setZoom(Math.max(0.5, Math.round(fitZoom * 100) / 100)); }; useEffect(() => { if (!isMobileLayout) return; const timer = window.setTimeout(() => fitToView(), 0); return () => window.clearTimeout(timer); }, [isMobileLayout, template.width, template.height]); const selectedElemObj = template.elements.find((el) => el.id === selectedElementId); const filterVisibleElements = useCallback( (elements: TemplateElement[]) => elements.filter((el) => shouldRenderElement(el, null, 0, previewDefaults, 'design') ), [previewDefaults] ); const dragMovingIdSet = useMemo(() => { if (dragState?.type !== 'drag' || !dragState.groupStartPositions) return null; return new Set(Object.keys(dragState.groupStartPositions)); }, [dragState]); const liftSelectedAboveDim = activeTool === 'move' && selectedElementIds.length > 0; const staticCanvasElements = useMemo(() => { let elements: TemplateElement[]; if ( dragState?.type === 'drag' && interactionSnapshot && dragMovingIdSet && dragInteractionVisualActive && interactionCanvasReady ) { elements = filterVisibleElements( interactionSnapshot.filter((el) => !dragMovingIdSet.has(el.id)) ); } else if (dragState?.type === 'resize' && interactionSnapshot && interactionCanvasReady) { elements = filterVisibleElements( interactionSnapshot.filter((el) => el.id !== dragState.elementId) ); } else { elements = filterVisibleElements(template.elements); } if (liftSelectedAboveDim) { const selectedSet = new Set(selectedElementIds); elements = elements.filter((el) => !selectedSet.has(el.id)); } return elements; }, [ dragState?.type, dragState?.elementId, interactionSnapshot, dragMovingIdSet, dragInteractionVisualActive, interactionCanvasReady, template.elements, filterVisibleElements, liftSelectedAboveDim, selectedElementIds, ]); const selectedLiftElements = useMemo(() => { if (!liftSelectedAboveDim) return null; let elements = filterVisibleElements( template.elements.filter((el) => selectedElementIds.includes(el.id)) ); if (dragState?.type === 'drag' && dragInteractionVisualActive && dragMovingIdSet) { elements = elements.filter((el) => !dragMovingIdSet.has(el.id)); } if (dragState?.type === 'resize' && interactionCanvasReady && dragState.elementId) { elements = elements.filter((el) => el.id !== dragState.elementId); } return elements.length > 0 ? elements : null; }, [ liftSelectedAboveDim, selectedElementIds, template.elements, filterVisibleElements, dragState?.type, dragState?.elementId, dragInteractionVisualActive, dragMovingIdSet, interactionCanvasReady, ]); const resizeFloatElements = useMemo(() => { if (dragState?.type !== 'resize' || !interactionFloatElements?.length) return null; return filterVisibleElements(interactionFloatElements); }, [dragState?.type, interactionFloatElements, filterVisibleElements]); const allowInteractionOverflow = dragState !== null; const syncInteractionPortalLayout = useCallback(() => { const wrapper = canvasWrapperRef.current; const portal = interactionPortalRef.current; if (!wrapper || !portal) return; const rect = wrapper.getBoundingClientRect(); portal.style.left = `${rect.left}px`; portal.style.top = `${rect.top}px`; portal.style.width = `${rect.width}px`; portal.style.height = `${rect.height}px`; }, []); useEffect(() => { if (!allowInteractionOverflow) return; syncInteractionPortalLayout(); window.addEventListener('scroll', syncInteractionPortalLayout, true); window.addEventListener('resize', syncInteractionPortalLayout); return () => { window.removeEventListener('scroll', syncInteractionPortalLayout, true); window.removeEventListener('resize', syncInteractionPortalLayout); }; }, [allowInteractionOverflow, syncInteractionPortalLayout]); useEffect(() => { const shouldPrepareCanvas = (dragState?.type === 'drag' && dragInteractionVisualActive) || dragState?.type === 'resize'; if (!shouldPrepareCanvas) { setInteractionCanvasReady(false); return; } setInteractionCanvasReady(false); let cancelled = false; requestAnimationFrame(() => { requestAnimationFrame(() => { if (!cancelled) setInteractionCanvasReady(true); }); }); return () => { cancelled = true; }; }, [dragState, dragInteractionVisualActive]); const hideSelectionChrome = suppressSelectionChrome || (dragState?.type === 'drag' && dragInteractionVisualActive); const showUnselectedDimLayer = activeTool === 'move'; const showUnselectedDim = showUnselectedDimLayer && selectedElementIds.length > 0; const multiSelectionBounds = useMemo(() => { if (selectedElementIds.length < 2 || hideSelectionChrome) return null; const selected = template.elements.filter( (el) => selectedElementIds.includes(el.id) && !el.locked ); if (selected.length < 2) return null; return getSelectionBounds(selected); }, [selectedElementIds, template.elements, hideSelectionChrome]); const dragMultiSelectionBounds = useMemo(() => { if ( selectedElementIds.length < 2 || dragState?.type !== 'drag' || !dragInteractionVisualActive || dragFloatElements.length < 2 ) { return null; } return getSelectionBounds(dragFloatElements); }, [ selectedElementIds.length, dragState?.type, dragInteractionVisualActive, dragFloatElements, ]); const resizingPreviewElement = dragState?.type === 'resize' ? interactionFloatElements?.find((el) => el.id === dragState.elementId) : null; const overlayElements = filterVisibleElements(template.elements); const tools: { id: ActiveTool; icon: React.ReactNode; label: string; shortcut?: string }[] = [ { id: 'move', icon: , label: '移动', shortcut: 'V' }, { id: 'text', icon: , label: '文本', shortcut: 'T' }, { id: 'table', icon: , label: '表格' }, { id: 'barcode', icon: , label: '条形码' }, { id: 'qrcode', icon: , label: '二维码' }, ]; const mobileToolItems = useMemo( () => [ ...tools.map((tool) => ({ id: tool.id, icon: tool.icon, label: tool.label, shortcut: tool.shortcut, active: activeTool === tool.id, onClick: () => handleToolClick(tool.id), })), { id: 'image', icon: , label: '图片', active: false, onClick: handleImageToolClick, }, ], // eslint-disable-next-line react-hooks/exhaustive-deps -- tools 为静态配置 [activeTool, handleToolClick, handleImageToolClick] ); const mainToolButtons = ( <> {tools.map((tool) => ( ))} ); const deleteToolButton = selectedElementIds.length > 0 ? ( ) : null; const zoomControls = (
{Math.round(displayZoom * 100)}%
); const showInteractionFloatPortal = typeof document !== 'undefined' && ((dragState?.type === 'drag' && dragInteractionVisualActive && dragFloatElements.length > 0) || (resizeFloatElements && resizeFloatElements.length > 0)); const interactionFloatPortal = showInteractionFloatPortal ? createPortal(
{dragState?.type === 'drag' && dragFloatElements.length > 0 && (
{dragFloatElements.map((el) => { const padMm = getFloatPreviewPaddingMm(el); const padPx = mmToPx(padMm, displayZoom); const boxW = mmToPx(el.width, displayZoom); const boxH = mmToPx(el.height, displayZoom); return (
{ if (node) { dragFloatImgRefs.current.set(el.id, node); const url = dragFloatUrlsRef.current[el.id]; if (url) { node.src = url; if (node.complete) { node.style.opacity = '1'; const placeholder = node.previousElementSibling as HTMLElement | null; if (placeholder) placeholder.style.display = 'none'; } } } else { dragFloatImgRefs.current.delete(el.id); } }} alt="" className="absolute pointer-events-none select-none opacity-0" style={{ left: `${-padPx}px`, top: `${-padPx}px`, width: `${boxW + padPx * 2}px`, height: `${boxH + padPx * 2}px`, }} onLoad={(e) => { const img = e.currentTarget; img.style.opacity = '1'; const placeholder = img.previousElementSibling as HTMLElement | null; if (placeholder) placeholder.style.display = 'none'; }} /> {selectedElementIds.length === 1 && (
)}
); })} {dragMultiSelectionBounds && (
)}
)} {resizeFloatElements && resizeFloatElements.length > 0 && (
{resizeFloatElements.map((el) => { const left = mmToPx(el.x, displayZoom); const top = mmToPx(el.y, displayZoom); const width = mmToPx(el.width, displayZoom); const height = mmToPx(el.height, displayZoom); return (
); })}
)}
, document.body ) : null; return ( <>
{!isMobileLayout && (
{mainToolButtons}
{deleteToolButton}
)}
{ if (ignoreCanvasClickRef.current) { ignoreCanvasClickRef.current = false; return; } if (e.target === e.currentTarget) { requestAnimationFrame(() => onSelectElements([])); } }} >
{showReferencePreview && ( )} {/* 整页统一渲染:半透明背景按图层层级与下方元素真实合成 */}
{showUnselectedDimLayer && (
)} {selectedLiftElements && (
)} {activeTool === 'move' && (
)} {marqueeState && (
)} {overlayElements.map((element, index) => { const isLocked = !!element.locked; const isSelected = !isLocked && selectedElementIds.includes(element.id); const isPrimary = selectedElementIds.indexOf(element.id) === 0; const isBeingDragged = dragInteractionVisualActive && dragState?.type === 'drag' && !!dragMovingIdSet?.has(element.id); const isBeingResized = dragState?.type === 'resize' && dragState.elementId === element.id; if (isBeingDragged) { return null; } if (isBeingResized && interactionCanvasReady && resizingPreviewElement) { return null; } const overlayElement = isBeingResized && resizingPreviewElement ? resizingPreviewElement : element; const showResizeHandles = isSelected && !hideSelectionChrome && !isLocked && selectedElementIds.length === 1 && dragState?.type !== 'resize'; const elemX = mmToPx(overlayElement.x, displayZoom); const elemY = mmToPx(overlayElement.y, displayZoom); const elemW = mmToPx(overlayElement.width, displayZoom); const elemH = mmToPx(overlayElement.height, displayZoom); return (
handleElementPointerDown(e, element, 'drag') } onClick={isLocked ? undefined : (e) => handleElementClick(e, element)} > {selectedElementIds.length > 1 && isSelected && (
{selectedElementIds.indexOf(element.id) + 1}
)} {isBeingResized && (
)} {isSelected && !hideSelectionChrome && !isLocked && selectedElementIds.length === 1 && !isBeingResized && (
)} {showResizeHandles && ( <>
handleResizeStart(e, element, 'nw')} title="拖拽调整尺寸" />
handleResizeStart(e, element, 'ne')} title="拖拽调整尺寸" />
handleResizeStart(e, element, 'sw')} title="拖拽调整尺寸" />
handleResizeStart(e, element, 'se')} title="拖拽调整尺寸" /> )} {!isLocked && (
{element.name}
)} {isSelected && !hideSelectionChrome && !isLocked && selectedElementIds.length === 1 && overlayElement.type === 'table' && onSelectTableCells && (
{iterTableCells(overlayElement).map(({ row, col }) => { const { x: cellX, y: cellY, width: cellW, height: cellH } = getCellRectPx(overlayElement, row, col, canvasRenderScale); const isCellSelected = selectedTableCells.some((c) => { const anchor = getCellAnchor(overlayElement, c.row, c.col); return anchor.row === row && anchor.col === col; }); return (
handleTableCellPointerDown(e, overlayElement, row, col) } onPointerMove={(e) => handleTableCellPointerMove(e, overlayElement) } onPointerUp={(e) => handleTableCellPointerUp(e)} onPointerCancel={(e) => handleTableCellPointerUp(e)} title={`单元格 ${row + 1},${col + 1}(拖动移动表格)`} /> ); })}
)}
); })} {multiSelectionBounds && (
)}
{isMobileLayout ? (
{zoomControls}
) : (
{isNarrow ? ( {template.width}×{template.height} {selectedElemObj ? ` · ${selectedElemObj.name}` : ''} {selectedElementIds.length > 1 ? ` · ${selectedElementIds.length}选` : ''} ) : ( <> 文档: {template.width} × {template.height} mm {selectedElemObj && selectedElementIds.length === 1 && ( | {selectedElemObj.name} — X:{selectedElemObj.x} Y:{selectedElemObj.y} W: {selectedElemObj.width} H:{selectedElemObj.height} mm )} {multiSelectionBounds && ( | 已选 {selectedElementIds.length} 个 — X:{multiSelectionBounds.minX} Y: {multiSelectionBounds.minY} mm )} {activeTool === 'move' && ( | 框选 · Shift/Ctrl+点选 · Ctrl+A 全选 · Ctrl+C/V 复制粘贴 )} )}
{!isNarrow && ( 预览使用变量默认值 · 方向键微调 · Ctrl+滚轮缩放 )} {zoomControls}
)}
{interactionFloatPortal} ); };