init commit

This commit is contained in:
iqudoo
2026-06-12 19:34:27 +08:00
commit 89b59ecc1b
38 changed files with 17719 additions and 0 deletions

View 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>
);
};