From 979a56546897ec2094d168aa88297c60f6c8f838 Mon Sep 17 00:00:00 2001 From: iqudoo Date: Sat, 13 Jun 2026 03:09:01 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/LabelDesigner.tsx | 203 ++- src/components/PropSidebar.tsx | 2167 +++++++++++++++--------------- src/index.css | 9 + 3 files changed, 1279 insertions(+), 1100 deletions(-) diff --git a/src/components/LabelDesigner.tsx b/src/components/LabelDesigner.tsx index d1ed605..0de1c95 100644 --- a/src/components/LabelDesigner.tsx +++ b/src/components/LabelDesigner.tsx @@ -3,6 +3,7 @@ import { LabelTemplate, TemplateElement } from '../types'; import { mmToPx, MM_TO_PX, shouldRenderElement } from '../utils'; import { useIsNarrowScreen } from '../hooks/useIsMobile'; import { CanvasLabelImage } from './CanvasLabelImage'; +import { useAppDialog } from './AppDialog'; import { ZoomIn, ZoomOut, @@ -85,6 +86,56 @@ function blurActiveField() { } } +/** 在容器内等比约束画布显示尺寸,避免宽/高单独触顶导致变形 */ +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 } @@ -137,10 +188,12 @@ export const LabelDesigner: React.FC = ({ onSelectElements, variant = 'desktop', }) => { + 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); @@ -296,20 +349,36 @@ export const LabelDesigner: React.FC = ({ [template, onChange, onSelectElements] ); - const deleteSelectedElements = useCallback(() => { - const idSet = new Set( + const deleteSelectedElements = useCallback(async () => { + const ids = selectedElementIds.length > 0 ? selectedElementIds : selectedElementId - ? [selectedElementId] - : [] - ); - if (idSet.size === 0) return; + ? [selectedElementId] + : []; + const deletable = ids.filter((id) => { + const el = template.elements.find((item) => item.id === id); + return el && !el.locked; + }); + if (deletable.length === 0) return; - const remaining = template.elements.filter((el) => !idSet.has(el.id) || el.locked); + const message = + deletable.length === 1 + ? `确定删除「${template.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 = template.elements.filter((el) => !idSet.has(el.id)); onChange({ ...template, elements: remaining }); onSelectElements(selectedElementIds.filter((id) => remaining.some((el) => el.id === id))); - }, [selectedElementIds, selectedElementId, template, onChange, onSelectElements]); + }, [selectedElementIds, selectedElementId, template, onChange, onSelectElements, showConfirm]); const handleToolClick = (tool: ActiveTool) => { if (tool === 'move') { @@ -352,7 +421,7 @@ export const LabelDesigner: React.FC = ({ } if (e.key === 'Delete' || e.key === 'Backspace') { e.preventDefault(); - deleteSelectedElements(); + void deleteSelectedElements(); return; } @@ -470,6 +539,61 @@ export const LabelDesigner: React.FC = ({ 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; + + useEffect(() => { + setZoom((z) => (z > maxZoom ? clampZoomValue(z, maxZoom) : z)); + }, [maxZoom]); + useEffect(() => { const area = canvasAreaRef.current; if (!area) return; @@ -478,7 +602,7 @@ export const LabelDesigner: React.FC = ({ 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))); + setZoom((z) => clampZoomValue(z + delta, maxZoomRef.current)); } }; @@ -533,10 +657,10 @@ export const LabelDesigner: React.FC = ({ .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), + 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); }) @@ -558,7 +682,7 @@ export const LabelDesigner: React.FC = ({ window.removeEventListener('pointerup', handlePointerUp); window.removeEventListener('pointercancel', handlePointerUp); }; - }, [marqueeActive, zoom, onSelectElements]); + }, [marqueeActive, displayZoom, onSelectElements]); useEffect(() => { if (!dragState) return; @@ -578,7 +702,7 @@ export const LabelDesigner: React.FC = ({ if (dragState.type === 'drag' && pastDragThreshold) { dragMovedRef.current = true; } - const MM_PER_PX = 1 / (3.78 * zoom); + const MM_PER_PX = 1 / (3.78 * displayZoomRef.current); const deltaXMm = deltaX * MM_PER_PX; const deltaYMm = deltaY * MM_PER_PX; @@ -610,6 +734,7 @@ export const LabelDesigner: React.FC = ({ 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' @@ -664,8 +789,12 @@ export const LabelDesigner: React.FC = ({ localElementsRef.current = pendingDragElementsRef.current; pendingDragElementsRef.current = null; } - if (localElementsRef.current && dragMovedRef.current) { - onChange({ ...template, elements: localElementsRef.current }); + if (localElementsRef.current) { + const shouldCommit = + dragState.type === 'resize' || dragMovedRef.current; + if (shouldCommit) { + onChange({ ...template, elements: localElementsRef.current }); + } } localElementsRef.current = null; setLocalElements(null); @@ -681,7 +810,7 @@ export const LabelDesigner: React.FC = ({ window.removeEventListener('pointerup', handlePointerUp); window.removeEventListener('pointercancel', handlePointerUp); }; - }, [dragState, template, zoom, onChange, cancelActiveInteraction]); + }, [dragState, template, displayZoom, onChange, cancelActiveInteraction]); const buildGroupStartPositions = (element: TemplateElement) => { const moveIds = @@ -855,7 +984,6 @@ export const LabelDesigner: React.FC = ({ frozenCanvasElements ?? template.elements ); const overlayElements = filterVisibleElements(localElements ?? template.elements); - const canvasRenderScale = Math.max(1, Math.round(MM_TO_PX * zoom * 100) / 100); const tools: { id: ActiveTool; icon: React.ReactNode; label: string; shortcut?: string }[] = [ { id: 'move', icon: , label: '移动工具', shortcut: 'V' }, @@ -897,7 +1025,7 @@ export const LabelDesigner: React.FC = ({ selectedElementIds.length > 0 ? ( - {Math.round(zoom * 100)}% + {Math.round(displayZoom * 100)}% - - - - - - - -
-
- - - handleElemChange({ - ...selectedElem, - textScaleX: Math.max(0.1, Math.min(5, (Number(val) || 100) / 100)), - }) - } - className="ps-field font-mono" - /> -

%

-
-
- - - handleElemChange({ - ...selectedElem, - textScaleY: Math.max(0.1, Math.min(5, (Number(val) || 100) / 100)), - }) - } - className="ps-field font-mono" - /> -

%

-
-
- + handleElemChange({ ...selectedElem, - letterSpacing: Math.max(0, Math.min(10, Number(val) || 0)), + decimalPlaces: Math.max(0, Math.min(6, Number(val) || 0)), }) } className="ps-field font-mono" /> -

mm

-
- -
- -
-
+ + )} +
-
- -
-
- - - -
-
- - - -
-
-
+
+
+ + + handleElemChange({ ...selectedElem, fontSize: Number(val) || 12 }) + } + className="ps-field font-mono" + /> +
+
+ + +
+
- + {/* Text color */} +
+ +
+ handleElemChange({ ...selectedElem, textColor: e.target.value })} + className="color-input shrink-0" + /> + handleElemChange({ ...selectedElem, textColor: e.target.value })} + className="ps-field font-mono text-[11px] flex-1 min-w-0" + /> +
+
+ +
+ +
+ + + + +
+
+ +
+
+ + + handleElemChange({ + ...selectedElem, + textScaleX: Math.max(0.1, Math.min(5, (Number(val) || 100) / 100)), + }) + } + className="ps-field font-mono" + /> +

%

+
+
+ + + handleElemChange({ + ...selectedElem, + textScaleY: Math.max(0.1, Math.min(5, (Number(val) || 100) / 100)), + }) + } + className="ps-field font-mono" + /> +

%

+
+
+ + + handleElemChange({ + ...selectedElem, + letterSpacing: Math.max(0, Math.min(10, Number(val) || 0)), + }) + } + className="ps-field font-mono" + /> +

mm

+
+
+ +
+ +
+ + +
+
+ +
+ +
+
+ + +
- )} - - {/* Barcode Settings */} - {selectedElem.type === 'barcode' && ( -
-
- - 条形码特定格式 -
-
- - -
- - +
+ + +
- )} +
+
- {/* Image Settings */} - {selectedElem.type === 'image' && ( -
-
- - 图片设置 -
-
- - -
-
- )} + +
+ )} - {/* Positions (X, Y) and Sizes (W, H) */} -
-
- - 位置与尺寸 (mm) -
-
-
- + {/* Barcode Settings */} + {selectedElem.type === 'barcode' && ( +
+
+ + 条形码特定格式 +
+
+ + +
+ + +
+ )} + + {/* Image Settings */} + {selectedElem.type === 'image' && ( +
+
+ + 图片设置 +
+
+ + +
+
+ )} + + {/* Positions (X, Y) and Sizes (W, H) */} +
+
+ + 位置与尺寸 (mm) +
+
+
+ + { + handleElemChange({ + ...selectedElem, + x: Math.round((Number(val) || 0) * 10) / 10, + }); + }} + className="ps-field font-mono" + /> +
+
+ + { + handleElemChange({ + ...selectedElem, + y: Math.round((Number(val) || 0) * 10) / 10, + }); + }} + className="ps-field font-mono" + /> +
+
+ + { + const wVal = Math.max(2, Number(val) || 2); + const updated = { ...selectedElem, width: wVal }; + if (selectedElem.type === 'qrcode') { + updated.height = wVal; + } + handleElemChange(updated); + }} + className="ps-field font-mono" + /> +
+
+ + { + const hVal = Math.max(2, Number(val) || 2); + handleElemChange({ ...selectedElem, height: hVal }); + }} + className={`ps-field font-mono ${selectedElem.type === 'qrcode' ? 'opacity-50 select-none' : '' + }`} + /> +
+
+ +
+ { + let deg = Number(val) || 0; + deg = ((deg % 360) + 360) % 360; + handleElemChange({ ...selectedElem, rotation: deg }); + }} + className="ps-field font-mono flex-1" + /> +
+
+
+
+ + {/* Background — all element types */} +
+
+ 背景 +
+
+ 颜色 + +
+
+ + handleElemChange({ + ...selectedElem, + backgroundColor: e.target.value, + backgroundOpacity: + selectedElem.backgroundOpacity && selectedElem.backgroundOpacity > 0 + ? selectedElem.backgroundOpacity + : 100, + }) + } + className="color-input shrink-0" + /> + { + const v = e.target.value.trim(); + if (v === 'transparent' || v === '') { + handleElemChange({ + ...selectedElem, + backgroundColor: 'transparent', + backgroundOpacity: 0, + }); + } else { + handleElemChange({ + ...selectedElem, + backgroundColor: v, + backgroundOpacity: selectedElem.backgroundOpacity ?? 100, + }); + } + }} + className="ps-field font-mono text-[11px] flex-1 min-w-0" + /> +
+
+
+ 不透明度 + + {selectedElem.backgroundColor === 'transparent' + ? 0 + : selectedElem.backgroundOpacity ?? 100} + % + +
+ { + const op = Number(e.target.value); + if (op <= 0) { + handleElemChange({ + ...selectedElem, + backgroundOpacity: 0, + }); + } else { + handleElemChange({ + ...selectedElem, + backgroundColor: + selectedElem.backgroundColor && + selectedElem.backgroundColor !== 'transparent' + ? selectedElem.backgroundColor + : '#ffffff', + backgroundOpacity: op, + }); + } + }} + className="ps-range" + /> +
+
+ + + handleElemChange({ + ...selectedElem, + padding: Math.max(0, Math.min(10, Number(val) || 0)), + }) + } + className="ps-field font-mono" + /> +
+ +
+ {( + [ + ['paddingTop', '上'], + ['paddingRight', '右'], + ['paddingBottom', '下'], + ['paddingLeft', '左'], + ] as const + ).map(([key, label]) => ( +
+ {label} { - handleElemChange({ - ...selectedElem, - x: Math.round((Number(val) || 0) * 10) / 10, - }); - }} - className="ps-field font-mono" - /> -
-
- - { - handleElemChange({ - ...selectedElem, - y: Math.round((Number(val) || 0) * 10) / 10, - }); - }} - className="ps-field font-mono" - /> -
-
- - { - const wVal = Math.max(2, Number(val) || 2); - const updated = { ...selectedElem, width: wVal }; - if (selectedElem.type === 'qrcode') { - updated.height = wVal; + const updated = { ...selectedElem }; + if (val === '' || val === undefined) { + delete updated[key]; + } else { + updated[key] = Math.max(0, Math.min(10, Number(val) || 0)); } handleElemChange(updated); }} - className="ps-field font-mono" + className="ps-field font-mono text-[10px] flex-1 min-w-0" + placeholder="—" />
-
- - { - const hVal = Math.max(2, Number(val) || 2); - handleElemChange({ ...selectedElem, height: hVal }); - }} - className={`ps-field font-mono ${ - selectedElem.type === 'qrcode' ? 'opacity-50 select-none' : '' - }`} - /> -
-
- -
- { - let deg = Number(val) || 0; - deg = ((deg % 360) + 360) % 360; - handleElemChange({ ...selectedElem, rotation: deg }); - }} - className="ps-field font-mono flex-1" - /> -
-
-
+ ))}
+
+
+
- {/* Background — all element types */} -
-
- 背景 -
-
- 颜色 - -
-
- - handleElemChange({ - ...selectedElem, - backgroundColor: e.target.value, - backgroundOpacity: - selectedElem.backgroundOpacity && selectedElem.backgroundOpacity > 0 - ? selectedElem.backgroundOpacity - : 100, - }) - } - className="color-input shrink-0" - /> - { - const v = e.target.value.trim(); - if (v === 'transparent' || v === '') { - handleElemChange({ - ...selectedElem, - backgroundColor: 'transparent', - backgroundOpacity: 0, - }); - } else { - handleElemChange({ - ...selectedElem, - backgroundColor: v, - backgroundOpacity: selectedElem.backgroundOpacity ?? 100, - }); - } - }} - className="ps-field font-mono text-[11px] flex-1 min-w-0" - /> -
-
-
- 不透明度 - - {selectedElem.backgroundColor === 'transparent' - ? 0 - : selectedElem.backgroundOpacity ?? 100} - % - -
- { - const op = Number(e.target.value); - if (op <= 0) { - handleElemChange({ - ...selectedElem, - backgroundOpacity: 0, - }); - } else { - handleElemChange({ - ...selectedElem, - backgroundColor: - selectedElem.backgroundColor && - selectedElem.backgroundColor !== 'transparent' - ? selectedElem.backgroundColor - : '#ffffff', - backgroundOpacity: op, - }); - } - }} - className="ps-range" - /> -
-
- + {/* Border & radius */} +
+
边框与圆角
+ +
+
+ + + handleElemChange({ + ...selectedElem, + borderWidth: Math.max(0, Math.min(5, Number(val) || 0)), + borderColor: selectedElem.borderColor || '#111827', + }) + } + className="ps-field font-mono" + /> +
+
+ + + handleElemChange({ + ...selectedElem, + borderRadius: Math.max(0, Math.min(20, Number(val) || 0)), + }) + } + className="ps-field font-mono" + /> +
+
+ +
+ +
+ + handleElemChange({ + ...selectedElem, + borderColor: e.target.value, + borderWidth: selectedElem.borderWidth || 0.3, + }) + } + className="color-input shrink-0" + /> + + handleElemChange({ + ...selectedElem, + borderColor: e.target.value, + }) + } + className="ps-field font-mono text-[11px] flex-1 min-w-0" + /> +
+
+ +
+ +
+ {( + [ + ['borderWidthTop', '上'], + ['borderWidthRight', '右'], + ['borderWidthBottom', '下'], + ['borderWidthLeft', '左'], + ] as const + ).map(([key, label]) => ( +
+ {label} - handleElemChange({ - ...selectedElem, - padding: Math.max(0, Math.min(10, Number(val) || 0)), - }) - } - className="ps-field font-mono" + max="5" + step="0.1" + value={selectedElem[key] ?? ''} + onCommit={(val) => { + const updated = { ...selectedElem }; + if (val === '' || val === undefined) { + delete updated[key]; + } else { + updated[key] = Math.max(0, Math.min(5, Number(val) || 0)); + } + handleElemChange(updated); + }} + className="ps-field font-mono text-[10px] flex-1 min-w-0" + placeholder="—" /> -
- -
- {( - [ - ['paddingTop', '上'], - ['paddingRight', '右'], - ['paddingBottom', '下'], - ['paddingLeft', '左'], - ] as const - ).map(([key, label]) => ( -
- {label} - { - const updated = { ...selectedElem }; - if (val === '' || val === undefined) { - delete updated[key]; - } else { - updated[key] = Math.max(0, Math.min(10, Number(val) || 0)); - } - handleElemChange(updated); - }} - className="ps-field font-mono text-[10px] flex-1 min-w-0" - placeholder="—" - /> -
- ))} -
-
-
- - {/* Border & radius */} -
-
边框与圆角
- -
-
- - - handleElemChange({ - ...selectedElem, - borderWidth: Math.max(0, Math.min(5, Number(val) || 0)), - borderColor: selectedElem.borderColor || '#111827', - }) - } - className="ps-field font-mono" - /> -
-
- - - handleElemChange({ - ...selectedElem, - borderRadius: Math.max(0, Math.min(20, Number(val) || 0)), - }) - } - className="ps-field font-mono" - /> -
-
- -
- -
- - handleElemChange({ - ...selectedElem, - borderColor: e.target.value, - borderWidth: selectedElem.borderWidth || 0.3, - }) - } - className="color-input shrink-0" - /> - - handleElemChange({ - ...selectedElem, - borderColor: e.target.value, - }) - } - className="ps-field font-mono text-[11px] flex-1 min-w-0" - /> -
-
- -
- -
- {( - [ - ['borderWidthTop', '上'], - ['borderWidthRight', '右'], - ['borderWidthBottom', '下'], - ['borderWidthLeft', '左'], - ] as const - ).map(([key, label]) => ( -
- {label} - { - const updated = { ...selectedElem }; - if (val === '' || val === undefined) { - delete updated[key]; - } else { - updated[key] = Math.max(0, Math.min(5, Number(val) || 0)); - } - handleElemChange(updated); - }} - className="ps-field font-mono text-[10px] flex-1 min-w-0" - placeholder="—" - /> -
- ))} -
-
- -
- -
- {( - [ - ['borderColorTop', '上'], - ['borderColorRight', '右'], - ['borderColorBottom', '下'], - ['borderColorLeft', '左'], - ] as const - ).map(([key, label]) => ( -
- {label} - { - const updated = { ...selectedElem }; - updated[key] = e.target.value; - handleElemChange(updated); - }} - className="color-input shrink-0" - title={`${label}边边框颜色`} - /> - { - const updated = { ...selectedElem }; - if (val === '' || val === undefined) { - delete updated[key]; - } else { - updated[key] = val; - } - handleElemChange(updated); - }} - className="ps-field font-mono text-[10px] flex-1 min-w-0" - placeholder="—" - /> -
- ))} -
-
- -
- -
- {( - [ - ['borderRadiusTL', '左上'], - ['borderRadiusTR', '右上'], - ['borderRadiusBL', '左下'], - ['borderRadiusBR', '右下'], - ] as const - ).map(([key, label]) => ( -
- {label} - { - const updated = { ...selectedElem }; - if (val === '' || val === undefined) { - delete updated[key]; - } else { - updated[key] = Math.max(0, Math.min(20, Number(val) || 0)); - } - handleElemChange(updated); - }} - className="ps-field font-mono text-[10px] flex-1 min-w-0" - placeholder="—" - /> -
- ))} -
-
-
- + ))}
- )} +
+ +
+ +
+ {( + [ + ['borderColorTop', '上'], + ['borderColorRight', '右'], + ['borderColorBottom', '下'], + ['borderColorLeft', '左'], + ] as const + ).map(([key, label]) => ( +
+ {label} + { + const updated = { ...selectedElem }; + updated[key] = e.target.value; + handleElemChange(updated); + }} + className="color-input shrink-0" + title={`${label}边边框颜色`} + /> + { + const updated = { ...selectedElem }; + if (val === '' || val === undefined) { + delete updated[key]; + } else { + updated[key] = val; + } + handleElemChange(updated); + }} + className="ps-field font-mono text-[10px] flex-1 min-w-0" + placeholder="—" + /> +
+ ))} +
+
+ +
+ +
+ {( + [ + ['borderRadiusTL', '左上'], + ['borderRadiusTR', '右上'], + ['borderRadiusBL', '左下'], + ['borderRadiusBR', '右下'], + ] as const + ).map(([key, label]) => ( +
+ {label} + { + const updated = { ...selectedElem }; + if (val === '' || val === undefined) { + delete updated[key]; + } else { + updated[key] = Math.max(0, Math.min(20, Number(val) || 0)); + } + handleElemChange(updated); + }} + className="ps-field font-mono text-[10px] flex-1 min-w-0" + placeholder="—" + /> +
+ ))} +
+
+
+ +
+ )} ); const renderLayersPanelBody = () => ( <> - {template.elements.length === 0 ? ( -
暂无图层
- ) : ( - [...template.elements].reverse().map((elem, revIdx) => { - const actualIdx = template.elements.length - 1 - revIdx; - const isSelected = selectedElementIds.includes(elem.id); - const isLocked = !!elem.locked; + {template.elements.length === 0 ? ( +
暂无图层
+ ) : ( + [...template.elements].reverse().map((elem, revIdx) => { + const actualIdx = template.elements.length - 1 - revIdx; + const isSelected = selectedElementIds.includes(elem.id); + const isLocked = !!elem.locked; - return ( -
{ + return ( +
{ if (!onSelectElements) return; + + if (isSelected) { + onSelectElements(selectedElementIds.filter((id) => id !== elem.id)); + return; + } + + if (variant === 'mobile') { + onSelectElements([...selectedElementIds, elem.id]); + return; + } + const isMulti = e.shiftKey || e.ctrlKey || e.metaKey; if (isMulti) { - if (selectedElementIds.includes(elem.id)) { - onSelectElements(selectedElementIds.filter((id) => id !== elem.id)); - } else { - onSelectElements([...selectedElementIds, elem.id]); - } + onSelectElements([...selectedElementIds, elem.id]); } else { onSelectElements([elem.id]); } - }} - className={`ps-layer-item group ${isSelected ? 'selected' : ''}`} - > -
- - {elem.type === 'text' && } - {elem.type === 'barcode' && } - {elem.type === 'qrcode' && } - {elem.type === 'image' && } - - - {elem.name} - -
+ } + } + className={`ps-layer-item group ${isSelected && !isLocked ? 'selected' : ''} ${isLocked ? 'ps-layer-item-locked' : '' + }`} + > +
+ + {elem.type === 'text' && } + {elem.type === 'barcode' && } + {elem.type === 'qrcode' && } + {elem.type === 'image' && } + + + {elem.name} + +
-
+
+ {!isLocked && ( + <> - - -
-
- ); - }) - )} + + )} + + {!isLocked && ( + + )} +
+
+ ); + }) + )} ); diff --git a/src/index.css b/src/index.css index 497d3c3..f05a6da 100644 --- a/src/index.css +++ b/src/index.css @@ -649,6 +649,15 @@ body { color: #fff; } +.ps-layer-item-locked { + cursor: default; + color: #888; +} + +.ps-layer-item-locked:hover { + background: transparent; +} + .ps-marquee-select { position: absolute; border: 1px solid #31a8ff;