init commit
This commit is contained in:
968
src/components/LabelDesigner.tsx
Normal file
968
src/components/LabelDesigner.tsx
Normal file
@@ -0,0 +1,968 @@
|
||||
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||
import { LabelTemplate, TemplateElement } from '../types';
|
||||
import { mmToPx, shouldRenderElement } from '../utils';
|
||||
import { CanvasLabelImage } from './CanvasLabelImage';
|
||||
import {
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
Type,
|
||||
Barcode,
|
||||
QrCode,
|
||||
Image as ImageIcon,
|
||||
Move,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface LabelDesignerProps {
|
||||
template: LabelTemplate;
|
||||
onChange: (updated: LabelTemplate) => void;
|
||||
selectedElementIds: string[];
|
||||
onSelectElements: (ids: string[]) => void;
|
||||
}
|
||||
|
||||
type ActiveTool = 'move' | 'text' | 'barcode' | 'qrcode';
|
||||
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;
|
||||
|
||||
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<LabelDesignerProps> = ({
|
||||
template,
|
||||
onChange,
|
||||
selectedElementIds,
|
||||
onSelectElements,
|
||||
}) => {
|
||||
const selectedElementId = selectedElementIds[0] || null;
|
||||
const [zoom, setZoom] = useState<number>(2.5);
|
||||
const [activeTool, setActiveTool] = useState<ActiveTool>('move');
|
||||
const canvasAreaRef = useRef<HTMLDivElement | null>(null);
|
||||
const canvasRef = useRef<HTMLDivElement | null>(null);
|
||||
const imageInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const previewDefaults = useMemo(
|
||||
() => template.variableDefaults ?? null,
|
||||
[template.variableDefaults]
|
||||
);
|
||||
|
||||
const [localElements, setLocalElements] = useState<TemplateElement[] | null>(null);
|
||||
const localElementsRef = useRef<TemplateElement[] | null>(null);
|
||||
/** 拖拽/缩放过程中冻结底图合成,仅更新选区框位置,避免每帧重绘闪动 */
|
||||
const [frozenCanvasElements, setFrozenCanvasElements] = useState<TemplateElement[] | null>(null);
|
||||
const dragRafRef = useRef<number | null>(null);
|
||||
const pendingDragElementsRef = useRef<TemplateElement[] | 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;
|
||||
groupStartPositions?: Record<string, { x: number; y: number }>;
|
||||
} | 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 marqueeRef = useRef(marqueeState);
|
||||
marqueeRef.current = marqueeState;
|
||||
const selectedIdsRef = useRef(selectedElementIds);
|
||||
selectedIdsRef.current = selectedElementIds;
|
||||
const templateElementsRef = useRef(template.elements);
|
||||
templateElementsRef.current = template.elements;
|
||||
|
||||
const addImageElement = useCallback(
|
||||
(src: string = '') => {
|
||||
const id = `image_${Date.now()}`;
|
||||
const n = template.elements.length + 1;
|
||||
const newElement: TemplateElement = {
|
||||
id,
|
||||
type: 'image',
|
||||
name: `图片 ${n}`,
|
||||
x: 5,
|
||||
y: 5,
|
||||
width: 30,
|
||||
height: 30,
|
||||
content: src,
|
||||
fontSize: 12,
|
||||
textAlign: 'center',
|
||||
fontWeight: 'normal',
|
||||
barcodeFormat: 'CODE128',
|
||||
showText: false,
|
||||
fontFamily: 'sans',
|
||||
backgroundColor: 'transparent',
|
||||
imageFit: 'contain',
|
||||
};
|
||||
onChange({
|
||||
...template,
|
||||
elements: [...template.elements, newElement],
|
||||
});
|
||||
onSelectElements([id]);
|
||||
setActiveTool('move');
|
||||
},
|
||||
[template, onChange, onSelectElements]
|
||||
);
|
||||
|
||||
const addElement = useCallback(
|
||||
(type: 'text' | 'barcode' | 'qrcode') => {
|
||||
let newElement: TemplateElement;
|
||||
const id = `${type}_${Date.now()}`;
|
||||
const n = template.elements.length + 1;
|
||||
|
||||
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: 'transparent',
|
||||
};
|
||||
} 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: 'transparent',
|
||||
};
|
||||
} 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: 'transparent',
|
||||
};
|
||||
}
|
||||
|
||||
onChange({
|
||||
...template,
|
||||
elements: [...template.elements, newElement],
|
||||
});
|
||||
onSelectElements([id]);
|
||||
setActiveTool('move');
|
||||
},
|
||||
[template, onChange, onSelectElements]
|
||||
);
|
||||
|
||||
const deleteSelectedElements = useCallback(() => {
|
||||
const idSet = new Set(
|
||||
selectedElementIds.length > 0
|
||||
? selectedElementIds
|
||||
: selectedElementId
|
||||
? [selectedElementId]
|
||||
: []
|
||||
);
|
||||
if (idSet.size === 0) return;
|
||||
|
||||
const remaining = template.elements.filter((el) => !idSet.has(el.id) || el.locked);
|
||||
onChange({ ...template, elements: remaining });
|
||||
onSelectElements(selectedElementIds.filter((id) => remaining.some((el) => el.id === id)));
|
||||
}, [selectedElementIds, selectedElementId, template, onChange, onSelectElements]);
|
||||
|
||||
const handleToolClick = (tool: ActiveTool) => {
|
||||
if (tool === 'move') {
|
||||
setActiveTool('move');
|
||||
} else {
|
||||
addElement(tool);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageToolClick = () => {
|
||||
imageInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleImageFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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(template.elements.filter((el) => !el.locked).map((el) => el.id));
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
e.preventDefault();
|
||||
deleteSelectedElements();
|
||||
return;
|
||||
}
|
||||
|
||||
const moveIds =
|
||||
selectedElementIds.length > 0 ? selectedElementIds : selectedElementId ? [selectedElementId] : [];
|
||||
if (moveIds.length === 0) return;
|
||||
|
||||
const increment = e.shiftKey ? 2 : 0.5;
|
||||
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();
|
||||
onChange({
|
||||
...template,
|
||||
elements: template.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,
|
||||
]);
|
||||
|
||||
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) => Math.min(5, Math.max(0.5, Math.round((z + delta) * 100) / 100)));
|
||||
}
|
||||
};
|
||||
|
||||
area.addEventListener('wheel', handleWheel, { passive: false });
|
||||
return () => area.removeEventListener('wheel', handleWheel);
|
||||
}, []);
|
||||
|
||||
const marqueeActive = marqueeState !== null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!marqueeActive) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
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 handleMouseUp = () => {
|
||||
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) {
|
||||
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, zoom),
|
||||
top: mmToPx(el.y, zoom),
|
||||
right: mmToPx(el.x + el.width, zoom),
|
||||
bottom: mmToPx(el.y + el.height, zoom),
|
||||
};
|
||||
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('mousemove', handleMouseMove);
|
||||
window.addEventListener('mouseup', handleMouseUp);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [marqueeActive, zoom, onSelectElements]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragState) return;
|
||||
dragMovedRef.current = false;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const deltaX = e.clientX - dragState.startX;
|
||||
const deltaY = e.clientY - dragState.startY;
|
||||
if (
|
||||
dragState.type === 'drag' &&
|
||||
(Math.abs(deltaX) > DRAG_START_THRESHOLD_PX || Math.abs(deltaY) > DRAG_START_THRESHOLD_PX)
|
||||
) {
|
||||
dragMovedRef.current = true;
|
||||
}
|
||||
const MM_PER_PX = 1 / (3.78 * zoom);
|
||||
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;
|
||||
if (dragRafRef.current === null) {
|
||||
dragRafRef.current = requestAnimationFrame(() => {
|
||||
dragRafRef.current = null;
|
||||
const next = pendingDragElementsRef.current;
|
||||
if (next) {
|
||||
setLocalElements(next);
|
||||
localElementsRef.current = next;
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if (dragState.type === 'resize') {
|
||||
const elem = template.elements.find((el) => el.id === dragState.elementId);
|
||||
if (!elem || elem.locked) return;
|
||||
|
||||
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) =>
|
||||
el.id === dragState.elementId
|
||||
? { ...el, x: rect.x, y: rect.y, width: rect.w, height: rect.h }
|
||||
: el
|
||||
);
|
||||
pendingDragElementsRef.current = updated;
|
||||
if (dragRafRef.current === null) {
|
||||
dragRafRef.current = requestAnimationFrame(() => {
|
||||
dragRafRef.current = null;
|
||||
const next = pendingDragElementsRef.current;
|
||||
if (next) {
|
||||
setLocalElements(next);
|
||||
localElementsRef.current = next;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (dragRafRef.current !== null) {
|
||||
cancelAnimationFrame(dragRafRef.current);
|
||||
dragRafRef.current = null;
|
||||
}
|
||||
if (pendingDragElementsRef.current) {
|
||||
localElementsRef.current = pendingDragElementsRef.current;
|
||||
pendingDragElementsRef.current = null;
|
||||
}
|
||||
if (localElementsRef.current) {
|
||||
onChange({ ...template, elements: localElementsRef.current });
|
||||
localElementsRef.current = null;
|
||||
setLocalElements(null);
|
||||
}
|
||||
setFrozenCanvasElements(null);
|
||||
setDragState(null);
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('mouseup', handleMouseUp);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [dragState, template, zoom, onChange]);
|
||||
|
||||
const buildGroupStartPositions = (element: TemplateElement) => {
|
||||
const moveIds =
|
||||
selectedElementIds.includes(element.id) && selectedElementIds.length > 1
|
||||
? selectedElementIds
|
||||
: [element.id];
|
||||
const groupStartPositions: Record<string, { x: number; y: number }> = {};
|
||||
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.MouseEvent) => {
|
||||
if (activeTool !== 'move' || e.button !== 0) return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const startX = e.clientX - rect.left;
|
||||
const startY = e.clientY - rect.top;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
setMarqueeState({
|
||||
startX,
|
||||
startY,
|
||||
currentX: startX,
|
||||
currentY: startY,
|
||||
additive: e.shiftKey || e.ctrlKey || e.metaKey,
|
||||
});
|
||||
};
|
||||
|
||||
const handleWorkspaceMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (activeTool !== 'move' || e.button !== 0) return;
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('[data-label-element]') || target.closest('.ps-handle')) return;
|
||||
|
||||
startMarquee(e);
|
||||
};
|
||||
|
||||
const handleElementMouseDown = (
|
||||
e: React.MouseEvent,
|
||||
element: TemplateElement,
|
||||
actionType: 'drag' | 'resize'
|
||||
) => {
|
||||
if (e.button !== 0) 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.MouseEvent, element: TemplateElement, actionType: 'drag' | 'resize') => {
|
||||
e.preventDefault();
|
||||
if (!selectedElementIds.includes(element.id)) {
|
||||
onSelectElements([element.id]);
|
||||
}
|
||||
if (element.locked) return;
|
||||
|
||||
setFrozenCanvasElements(template.elements);
|
||||
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: buildGroupStartPositions(element),
|
||||
});
|
||||
};
|
||||
|
||||
const handleResizeStart = (
|
||||
e: React.MouseEvent,
|
||||
element: TemplateElement,
|
||||
corner: ResizeCorner
|
||||
) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onSelectElements([element.id]);
|
||||
if (element.locked) return;
|
||||
|
||||
setFrozenCanvasElements(template.elements);
|
||||
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,
|
||||
});
|
||||
};
|
||||
|
||||
const fitToView = () => {
|
||||
const area = canvasAreaRef.current;
|
||||
if (!area) return;
|
||||
const pad = 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));
|
||||
};
|
||||
|
||||
const selectedElemObj = (localElements || template.elements).find(
|
||||
(el) => el.id === selectedElementId
|
||||
);
|
||||
|
||||
const filterVisibleElements = useCallback(
|
||||
(elements: TemplateElement[]) =>
|
||||
elements.filter((el) =>
|
||||
shouldRenderElement(el, null, 0, previewDefaults, 'design')
|
||||
),
|
||||
[previewDefaults]
|
||||
);
|
||||
|
||||
const canvasPreviewElements = filterVisibleElements(
|
||||
frozenCanvasElements ?? template.elements
|
||||
);
|
||||
const overlayElements = filterVisibleElements(localElements ?? template.elements);
|
||||
|
||||
const tools: { id: ActiveTool; icon: React.ReactNode; label: string; shortcut?: string }[] = [
|
||||
{ id: 'move', icon: <Move className="w-[18px] h-[18px]" />, label: '移动工具', shortcut: 'V' },
|
||||
{ id: 'text', icon: <Type className="w-[18px] h-[18px]" />, label: '文本工具', shortcut: 'T' },
|
||||
{ id: 'barcode', icon: <Barcode className="w-[18px] h-[18px]" />, label: '条形码' },
|
||||
{ id: 'qrcode', icon: <QrCode className="w-[18px] h-[18px]" />, label: '二维码' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 min-w-0 min-h-0">
|
||||
<div className="ps-toolbar flex flex-col">
|
||||
{tools.map((tool) => (
|
||||
<button
|
||||
key={tool.id}
|
||||
className={`ps-tool-btn ${activeTool === tool.id ? 'active' : ''}`}
|
||||
onClick={() => handleToolClick(tool.id)}
|
||||
title={`${tool.label}${tool.shortcut ? ` (${tool.shortcut})` : ''}`}
|
||||
>
|
||||
{tool.icon}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
className="ps-tool-btn"
|
||||
onClick={handleImageToolClick}
|
||||
title="图片工具"
|
||||
>
|
||||
<ImageIcon className="w-[18px] h-[18px]" />
|
||||
</button>
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleImageFile}
|
||||
/>
|
||||
<div className="flex-1" />
|
||||
{selectedElementIds.length > 0 && (
|
||||
<button
|
||||
className="ps-tool-btn hover:!bg-red-900/40 hover:!text-red-400"
|
||||
onClick={deleteSelectedElements}
|
||||
title="删除选中元素 (Del)"
|
||||
>
|
||||
<Trash2 className="w-[18px] h-[18px]" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col flex-1 min-w-0 min-h-0">
|
||||
<div
|
||||
ref={canvasAreaRef}
|
||||
className="flex-1 overflow-auto ps-workspace-bg flex items-center justify-center"
|
||||
onMouseDown={handleWorkspaceMouseDown}
|
||||
onClick={(e) => {
|
||||
if (ignoreCanvasClickRef.current) {
|
||||
ignoreCanvasClickRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (e.target === e.currentTarget) {
|
||||
onSelectElements([]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={canvasRef}
|
||||
className="relative bg-white shadow-[0_2px_20px_rgba(0,0,0,0.5)] select-none"
|
||||
style={{
|
||||
width: `${mmToPx(template.width, zoom)}px`,
|
||||
height: `${mmToPx(template.height, zoom)}px`,
|
||||
}}
|
||||
>
|
||||
<div className="absolute -top-5 left-0 text-[10px] font-mono text-[#aaa]">
|
||||
{template.width} mm
|
||||
</div>
|
||||
<div
|
||||
className="absolute -left-8 top-0 text-[10px] font-mono text-[#aaa]"
|
||||
style={{ writingMode: 'vertical-rl', transform: 'rotate(180deg)' }}
|
||||
>
|
||||
{template.height} mm
|
||||
</div>
|
||||
|
||||
{/* 整页统一渲染:半透明背景按图层层级与下方元素真实合成 */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none z-0">
|
||||
<CanvasLabelImage
|
||||
widthMm={template.width}
|
||||
heightMm={template.height}
|
||||
elements={canvasPreviewElements}
|
||||
rowData={null}
|
||||
rowIndex={0}
|
||||
showBorder={false}
|
||||
variableDefaults={previewDefaults}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{activeTool === 'move' && (
|
||||
<div
|
||||
className="absolute inset-0 z-[1] cursor-crosshair"
|
||||
aria-hidden
|
||||
onMouseDown={startMarquee}
|
||||
/>
|
||||
)}
|
||||
|
||||
{marqueeState && (
|
||||
<div
|
||||
className="ps-marquee-select"
|
||||
style={{
|
||||
left: `${Math.min(marqueeState.startX, marqueeState.currentX)}px`,
|
||||
top: `${Math.min(marqueeState.startY, marqueeState.currentY)}px`,
|
||||
width: `${Math.abs(marqueeState.currentX - marqueeState.startX)}px`,
|
||||
height: `${Math.abs(marqueeState.currentY - marqueeState.startY)}px`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{overlayElements.map((element, index) => {
|
||||
const isLocked = !!element.locked;
|
||||
const isSelected = !isLocked && selectedElementIds.includes(element.id);
|
||||
const isPrimary = selectedElementIds.indexOf(element.id) === 0;
|
||||
const elemX = mmToPx(element.x, zoom);
|
||||
const elemY = mmToPx(element.y, zoom);
|
||||
const elemW = mmToPx(element.width, zoom);
|
||||
const elemH = mmToPx(element.height, zoom);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={element.id}
|
||||
data-label-element
|
||||
className={`absolute group ${
|
||||
isLocked
|
||||
? 'pointer-events-none'
|
||||
: isSelected
|
||||
? 'ps-selection-ring cursor-move'
|
||||
: 'cursor-move hover:outline hover:outline-1 hover:outline-[#31a8ff]/40'
|
||||
}`}
|
||||
style={{
|
||||
left: `${elemX}px`,
|
||||
top: `${elemY}px`,
|
||||
width: `${elemW}px`,
|
||||
height: `${elemH}px`,
|
||||
zIndex: isSelected ? index + 1000 : index + 10,
|
||||
}}
|
||||
onMouseDown={
|
||||
isLocked ? undefined : (e) => handleElementMouseDown(e, element, 'drag')
|
||||
}
|
||||
onClick={isLocked ? undefined : (e) => handleElementClick(e, element)}
|
||||
>
|
||||
{selectedElementIds.length > 1 && isSelected && (
|
||||
<div
|
||||
className={`absolute -top-5 right-0 text-[9px] font-bold px-1 py-0.5 z-50 text-white min-w-[14px] text-center ${
|
||||
isPrimary ? 'bg-[#31a8ff]' : 'bg-emerald-600'
|
||||
}`}
|
||||
>
|
||||
{selectedElementIds.indexOf(element.id) + 1}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSelected && (
|
||||
<>
|
||||
<div
|
||||
className="ps-handle top-0 left-0 -translate-x-1/2 -translate-y-1/2 cursor-nw-resize"
|
||||
onMouseDown={(e) => handleResizeStart(e, element, 'nw')}
|
||||
title="拖拽调整尺寸"
|
||||
/>
|
||||
<div
|
||||
className="ps-handle top-0 right-0 translate-x-1/2 -translate-y-1/2 cursor-ne-resize"
|
||||
onMouseDown={(e) => handleResizeStart(e, element, 'ne')}
|
||||
title="拖拽调整尺寸"
|
||||
/>
|
||||
<div
|
||||
className="ps-handle bottom-0 left-0 -translate-x-1/2 translate-y-1/2 cursor-sw-resize"
|
||||
onMouseDown={(e) => handleResizeStart(e, element, 'sw')}
|
||||
title="拖拽调整尺寸"
|
||||
/>
|
||||
<div
|
||||
className="ps-handle bottom-0 right-0 translate-x-1/2 translate-y-1/2 cursor-se-resize"
|
||||
onMouseDown={(e) => handleResizeStart(e, element, 'se')}
|
||||
title="拖拽调整尺寸"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isLocked && (
|
||||
<div className="absolute top-[-16px] left-0 bg-[#3c3c3c] text-[#ccc] text-[9px] px-1 py-px opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-50">
|
||||
{element.name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ps-status-bar">
|
||||
<div className="flex items-center gap-3">
|
||||
<span>
|
||||
文档: {template.width} × {template.height} mm
|
||||
</span>
|
||||
{selectedElemObj && (
|
||||
<span className="text-[#31a8ff]">
|
||||
| {selectedElemObj.name} — X:{selectedElemObj.x} Y:{selectedElemObj.y} W:
|
||||
{selectedElemObj.width} H:{selectedElemObj.height} mm
|
||||
</span>
|
||||
)}
|
||||
{selectedElementIds.length > 1 && (
|
||||
<span className="text-emerald-400">
|
||||
| 已选 {selectedElementIds.length} 个
|
||||
</span>
|
||||
)}
|
||||
{activeTool === 'move' && (
|
||||
<span className="text-[#888] hidden sm:inline">
|
||||
| 框选 · Shift/Ctrl+点选 · Ctrl+A 全选
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="hidden sm:inline text-[10px]">
|
||||
预览使用变量默认值 · 方向键微调 · Ctrl+滚轮缩放
|
||||
</span>
|
||||
<div className="ps-zoom-control">
|
||||
<button className="ps-zoom-btn" onClick={() => setZoom(Math.max(0.5, zoom - 0.25))} title="缩小">
|
||||
<ZoomOut className="w-3 h-3" />
|
||||
</button>
|
||||
<span className="px-1.5 text-[10px] font-mono text-[#ccc] min-w-[42px] text-center select-none">
|
||||
{Math.round(zoom * 100)}%
|
||||
</span>
|
||||
<button className="ps-zoom-btn" onClick={() => setZoom(Math.min(5, zoom + 0.25))} title="放大">
|
||||
<ZoomIn className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={fitToView}
|
||||
className="text-[10px] text-[#aaa] hover:text-white px-1.5 py-0.5 border border-[#4a4a4a] hover:border-[#666] transition cursor-pointer"
|
||||
title="适合窗口"
|
||||
>
|
||||
适合
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user