Files
label-designer/src/components/PropSidebar.tsx
2026-06-12 21:17:24 +08:00

1798 lines
76 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect, useMemo } from 'react';
import { LabelTemplate, TemplateElement, PaperConfig, VisibilityRule } from '../types';
import {
extractTemplateVariables,
extractVariablesFromContent,
isConditionalVisibility,
isTemplateVariableInUse,
removeUnusedTemplateVariable,
resolveVisibilityRules,
} from '../utils';
import { VisibilityRuleDialog } from './VisibilityRuleDialog';
import { VisibilityRuleSummary } from './VisibilityRuleSummary';
import {
alignToSelectionBounds,
alignSelectionToCanvas,
tileSelectedEvenly,
type AlignMode,
} from '../elementAlign';
import { CollapsiblePanelSection } from './CollapsiblePanelSection';
import { CommitInput } from './CommitInput';
import {
Sliders,
LayoutGrid,
Type,
AlignLeft,
AlignCenter,
AlignRight,
Bold,
Italic,
Underline,
Strikethrough,
HelpCircle,
Lock,
Unlock,
ArrowUp,
ArrowDown,
Layers,
Trash2,
AlignHorizontalJustifyStart,
AlignHorizontalJustifyCenter,
AlignHorizontalJustifyEnd,
AlignVerticalJustifyStart,
AlignVerticalJustifyCenter,
AlignVerticalJustifyEnd,
AlignHorizontalSpaceBetween,
AlignVerticalSpaceBetween,
Link2,
Plus,
Ruler,
RotateCw,
Barcode,
QrCode,
Image as ImageIcon,
Upload,
} from 'lucide-react';
const PANEL_COLLAPSE_STORAGE_KEY = 'label_designer_panel_collapse_v2';
type PanelCollapseState = {
content: boolean;
properties: boolean;
layers: boolean;
};
function loadPanelCollapseState(): PanelCollapseState {
try {
const raw = localStorage.getItem(PANEL_COLLAPSE_STORAGE_KEY);
if (raw) return JSON.parse(raw) as PanelCollapseState;
} catch {
/* ignore */
}
return { content: true, properties: false, layers: true };
}
const alignBtnClass =
'px-2 py-1.5 bg-[#383838] hover:bg-[#444] border border-[#4a4a4a] hover:border-[#31a8ff]/50 text-[10px] text-[#ccc] flex flex-col items-center justify-center gap-0.5 transition cursor-pointer rounded-sm min-h-[52px]';
interface AlignToolButtonProps {
icon: React.ReactNode;
label: string;
title: string;
onClick: () => void;
}
const AlignToolButton: React.FC<AlignToolButtonProps> = ({ icon, label, title, onClick }) => (
<button type="button" className={alignBtnClass} onClick={onClick} title={title}>
{icon}
<span>{label}</span>
</button>
);
const PropInput = CommitInput;
interface PropTextareaProps extends Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'onChange'> {
value: string;
onCommit: (val: string) => void;
}
const PropTextarea: React.FC<PropTextareaProps> = ({ value, onCommit, ...props }) => {
const [localVal, setLocalVal] = useState<string>(value);
useEffect(() => {
setLocalVal(value);
}, [value]);
const handleBlur = () => {
if (localVal !== value) {
onCommit(localVal);
}
};
return (
<textarea
{...props}
value={localVal}
onChange={(e) => setLocalVal(e.target.value)}
onBlur={handleBlur}
/>
);
};
interface PropSidebarProps {
template: LabelTemplate;
onChangeTemplate: (updated: LabelTemplate) => void;
selectedElementIds: string[];
activeTab: 'element' | 'paper';
onChangeTab: (tab: 'element' | 'paper') => void;
paper: PaperConfig;
onSelectElements?: (ids: string[]) => void;
}
export const PropSidebar: React.FC<PropSidebarProps> = ({
template,
onChangeTemplate,
selectedElementIds,
activeTab,
onChangeTab,
paper,
onSelectElements,
}) => {
const selectedElementId = selectedElementIds[0] || null;
const selectedElem = template.elements.find((el) => el.id === selectedElementId);
const [newVarName, setNewVarName] = useState('');
const [visibilityRuleDialog, setVisibilityRuleDialog] = useState<
{ mode: 'add' } | { mode: 'edit'; index: number } | null
>(null);
const [panelCollapsed, setPanelCollapsed] = useState<PanelCollapseState>(loadPanelCollapseState);
const togglePanel = (key: keyof PanelCollapseState) => {
setPanelCollapsed((prev) => {
const isCollapsed = prev[key];
const next: PanelCollapseState = isCollapsed
? { content: true, properties: true, layers: true, [key]: false }
: { ...prev, [key]: true };
try {
localStorage.setItem(PANEL_COLLAPSE_STORAGE_KEY, JSON.stringify(next));
} catch {
/* ignore */
}
return next;
});
};
const templateVariables = useMemo(() => extractTemplateVariables(template), [template]);
const addableVisibilityVariables = useMemo(() => {
if (!selectedElem || !isConditionalVisibility(selectedElem)) return [];
const rules = resolveVisibilityRules(selectedElem);
return templateVariables.filter((v) => !rules.some((r) => r.variable === v));
}, [selectedElem, templateVariables]);
const visibilityRuleDialogVariableOptions = useMemo(() => {
if (!selectedElem || !visibilityRuleDialog) return [];
const rules = resolveVisibilityRules(selectedElem);
if (visibilityRuleDialog.mode === 'add') {
return addableVisibilityVariables;
}
const usedByOthers = rules
.filter((_, i) => i !== visibilityRuleDialog.index)
.map((r) => r.variable);
return templateVariables.filter((v) => !usedByOthers.includes(v));
}, [selectedElem, visibilityRuleDialog, addableVisibilityVariables, templateVariables]);
const editingVisibilityRule = useMemo(() => {
if (!selectedElem || visibilityRuleDialog?.mode !== 'edit') return undefined;
return resolveVisibilityRules(selectedElem)[visibilityRuleDialog.index];
}, [selectedElem, visibilityRuleDialog]);
// Compute maximum allowed label dimensions based on paper configurations
const paperW = paper.orientation === 'portrait' ? paper.width : paper.height;
const paperH = paper.orientation === 'portrait' ? paper.height : paper.width;
const maxLabelW = Math.max(10, paperW - paper.marginLeft - paper.marginRight);
const maxLabelH = Math.max(10, paperH - paper.marginTop - paper.marginBottom);
const handleElemChange = (updatedElem: TemplateElement) => {
onChangeTemplate({
...template,
elements: template.elements.map((el) => (el.id === updatedElem.id ? updatedElem : el)),
});
};
const applyVisibilityRules = (next: VisibilityRule[]) => {
if (!selectedElem) return;
handleElemChange({
...selectedElem,
visibilityMode: 'conditional',
visibilityRules: next,
hideWhenNoData: undefined,
visibilityVariable: undefined,
visibilityVariables: undefined,
});
};
const handleConfirmVisibilityRule = (rule: VisibilityRule) => {
if (!selectedElem || !visibilityRuleDialog) return;
const rules = resolveVisibilityRules(selectedElem);
if (visibilityRuleDialog.mode === 'edit') {
const { index } = visibilityRuleDialog;
if (rules.some((r, i) => i !== index && r.variable === rule.variable)) return;
applyVisibilityRules(rules.map((r, i) => (i === index ? rule : r)));
} else {
if (rules.some((r) => r.variable === rule.variable)) return;
applyVisibilityRules([...rules, rule]);
}
};
const applyMultiAlign = (mode: AlignMode) => {
onChangeTemplate({
...template,
elements: alignToSelectionBounds(template.elements, selectedElementIds, mode),
});
};
const applyTile = (axis: 'horizontal' | 'vertical') => {
onChangeTemplate({
...template,
elements: tileSelectedEvenly(template.elements, selectedElementIds, axis),
});
};
const applyCanvasAlign = (mode: AlignMode) => {
onChangeTemplate({
...template,
elements: alignSelectionToCanvas(
template.elements,
selectedElementIds,
template.width,
template.height,
mode
),
});
};
const renderAlignButtonGrid = (
onAlign: (mode: AlignMode) => void,
scope: 'selection' | 'canvas' = 'selection'
) => {
const toCanvas = scope === 'canvas';
return (
<div className="grid grid-cols-3 gap-1.5">
<AlignToolButton
icon={<AlignHorizontalJustifyStart className="w-3.5 h-3.5" />}
label="左对齐"
title={toCanvas ? '整体贴齐画布左边缘' : '选区内左边缘对齐'}
onClick={() => onAlign('left')}
/>
<AlignToolButton
icon={<AlignHorizontalJustifyCenter className="w-3.5 h-3.5" />}
label="水平居中"
title={toCanvas ? '整体在画布水平居中' : '选区内水平居中对齐'}
onClick={() => onAlign('centerH')}
/>
<AlignToolButton
icon={<AlignHorizontalJustifyEnd className="w-3.5 h-3.5" />}
label="右对齐"
title={toCanvas ? '整体贴齐画布右边缘' : '选区内右边缘对齐'}
onClick={() => onAlign('right')}
/>
<AlignToolButton
icon={<AlignVerticalJustifyStart className="w-3.5 h-3.5" />}
label="顶对齐"
title={toCanvas ? '整体贴齐画布顶边' : '选区内顶边对齐'}
onClick={() => onAlign('top')}
/>
<AlignToolButton
icon={<AlignVerticalJustifyCenter className="w-3.5 h-3.5" />}
label="垂直居中"
title={toCanvas ? '整体在画布垂直居中' : '选区内垂直居中对齐'}
onClick={() => onAlign('centerV')}
/>
<AlignToolButton
icon={<AlignVerticalJustifyEnd className="w-3.5 h-3.5" />}
label="底对齐"
title={toCanvas ? '整体贴齐画布底边' : '选区内底边对齐'}
onClick={() => onAlign('bottom')}
/>
</div>
);
};
const insertVariable = (varName: string) => {
if (!selectedElem) return;
const token = `{${varName}}`;
const content = selectedElem.content.includes(token)
? selectedElem.content
: selectedElem.content.trim()
? `${selectedElem.content}${selectedElem.content.endsWith(' ') ? '' : ' '}${token}`
: token;
handleElemChange({ ...selectedElem, content });
};
const handleAddTemplateVariable = () => {
const name = newVarName.trim();
if (!name || name === '#INDEX') return;
const list = new Set<string>(template.variableList ?? extractTemplateVariables(template));
list.add(name);
onChangeTemplate({
...template,
variableList: Array.from(list).sort((a, b) => a.localeCompare(b, 'zh-CN')),
});
setNewVarName('');
};
const handleRemoveTemplateVariable = (varName: string) => {
if (isTemplateVariableInUse(template, varName)) return;
onChangeTemplate(removeUnusedTemplateVariable(template, varName));
};
const setVariableDefault = (varName: string, value: string) => {
const defaults = { ...(template.variableDefaults || {}) };
if (value.trim()) {
defaults[varName] = value;
} else {
delete defaults[varName];
}
onChangeTemplate({
...template,
variableDefaults: Object.keys(defaults).length > 0 ? defaults : undefined,
});
};
const renderVariableManagementBlock = () => (
<div className="space-y-2 pt-2 border-t border-[#3a3a3a]">
<div className="text-[10px] font-bold text-[#31a8ff]"></div>
<p className="text-[10px] text-[#666] leading-relaxed">
使
</p>
<div className="flex gap-1">
<input
type="text"
value={newVarName}
onChange={(e) => setNewVarName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAddTemplateVariable()}
placeholder="新变量名"
className="ps-field text-[10px] flex-1 min-w-0"
/>
<button
type="button"
onClick={handleAddTemplateVariable}
className="ps-btn shrink-0 text-[10px]"
>
<Plus className="w-3 h-3" />
</button>
</div>
{templateVariables.length === 0 ? (
<p className="text-[10px] text-[#888]"></p>
) : (
<div className="space-y-1">
{templateVariables.map((v) => {
const inUse = isTemplateVariableInUse(template, v);
return (
<div
key={v}
className="grid grid-cols-[72px_minmax(0,1fr)_44px] gap-1 items-center min-w-0"
>
<span
className="text-[9px] font-mono text-[#31a8ff] truncate"
title={v}
>
{`{${v}}`}
</span>
<input
type="text"
value={template.variableDefaults?.[v] ?? ''}
onChange={(e) => setVariableDefault(v, e.target.value)}
placeholder="预览默认值"
className="ps-field font-mono text-[10px] w-full min-w-0"
/>
<div className="flex items-center justify-center w-[44px] shrink-0">
{inUse ? (
<span
className="p-1 text-[#666] cursor-default"
title="已被内容或显示条件引用,不可删除"
>
<Lock className="w-3.5 h-3.5" />
</span>
) : (
<button
type="button"
onClick={() => handleRemoveTemplateVariable(v)}
title="删除未使用的变量"
className="p-1 rounded text-[#666] hover:text-red-400 hover:bg-[#3a2020] transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
);
})}
</div>
)}
</div>
);
// Layer ordering movement
const moveLayer = (idx: number, direction: 'up' | 'down' | 'top' | 'bottom') => {
const list = [...template.elements];
const item = list[idx];
if (!item) return;
if (direction === 'up') {
if (idx >= list.length - 1) return;
list.splice(idx, 1);
list.splice(idx + 1, 0, item);
} else if (direction === 'down') {
if (idx <= 0) return;
list.splice(idx, 1);
list.splice(idx - 1, 0, item);
} else if (direction === 'top') {
if (idx >= list.length - 1) return;
list.splice(idx, 1);
list.push(item);
} else if (direction === 'bottom') {
if (idx <= 0) return;
list.splice(idx, 1);
list.unshift(item);
}
onChangeTemplate({ ...template, elements: list });
};
const toggleLockLayer = (idx: number) => {
const list = template.elements.map((el, i) => {
if (i === idx) {
return { ...el, locked: !el.locked };
}
return el;
});
onChangeTemplate({ ...template, elements: list });
};
const deleteLayer = (id: string) => {
const el = template.elements.find(x => x.id === id);
if (el?.locked) return; // Cannot delete locked elements
onChangeTemplate({
...template,
elements: template.elements.filter((el) => el.id !== id),
});
if (onSelectElements && selectedElementIds.includes(id)) {
onSelectElements([]);
}
};
const renderContentPanelBody = () => {
if (!selectedElem) {
return (
<div className="space-y-3">
<p className="text-[10px] text-[#777] leading-normal flex items-start gap-1">
<HelpCircle className="w-3.5 h-3.5 text-[#31a8ff] shrink-0 mt-0.5" />
<span></span>
</p>
{renderVariableManagementBlock()}
</div>
);
}
return (
<div className="space-y-2 min-w-0 max-w-full overflow-hidden">
{selectedElem.type === 'image' && (
<div>
<label className="ps-field-label"></label>
<label className="ps-btn text-[10px] cursor-pointer">
<Upload className="w-3.5 h-3.5" />
<input
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () =>
handleElemChange({ ...selectedElem, content: reader.result as string });
reader.readAsDataURL(file);
e.target.value = '';
}}
/>
</label>
</div>
)}
<PropTextarea
value={selectedElem.content}
onCommit={(val) => handleElemChange({ ...selectedElem, content: val })}
rows={selectedElem.type === 'image' ? 3 : 2}
className="ps-field resize-none"
placeholder={
selectedElem.type === 'image'
? '图片 URL 或 data URI也可使用 {变量名}'
: '输入文本,使用 {变量名} 引用变量'
}
/>
{templateVariables.length > 0 && (
<div className="space-y-1">
<div className="text-[9px] text-[#777] font-medium"></div>
<div className="flex flex-wrap gap-1">
{templateVariables.map((v) => (
<button
key={v}
type="button"
onClick={() => insertVariable(v)}
className="ps-btn text-[9px] font-mono"
>
{`{${v}}`}
</button>
))}
</div>
</div>
)}
{renderVariableManagementBlock()}
<div className="space-y-2 pt-2 border-t border-[#3a3a3a] min-w-0 max-w-full overflow-hidden">
<div className="text-[10px] font-bold text-[#31a8ff]"></div>
<div>
<label className="ps-field-label"></label>
<select
value={isConditionalVisibility(selectedElem) ? 'conditional' : 'always'}
onChange={(e) => {
const conditional = e.target.value === 'conditional';
if (!conditional) {
handleElemChange({
...selectedElem,
visibilityMode: 'always',
hideWhenNoData: undefined,
visibilityVariable: undefined,
visibilityVariables: undefined,
});
return;
}
const migrated = resolveVisibilityRules(selectedElem);
handleElemChange({
...selectedElem,
visibilityMode: 'conditional',
visibilityRules: migrated.length > 0 ? migrated : [],
hideWhenNoData: undefined,
visibilityVariable: undefined,
visibilityVariables: undefined,
});
}}
className="ps-field text-[11px]"
>
<option value="always"></option>
<option value="conditional"></option>
</select>
</div>
{isConditionalVisibility(selectedElem) && (() => {
const rules = resolveVisibilityRules(selectedElem);
const setRules = (next: VisibilityRule[]) => {
handleElemChange({
...selectedElem,
visibilityMode: 'conditional',
visibilityRules: next,
hideWhenNoData: undefined,
visibilityVariable: undefined,
visibilityVariables: undefined,
});
};
const removeRule = (index: number) => {
setRules(rules.filter((_, i) => i !== index));
};
return (
<div className="space-y-2 min-w-0 max-w-full">
<p className="text-[10px] text-[#666] leading-relaxed">
</p>
{rules.length > 0 ? (
<div className="space-y-1.5">
{rules.map((rule, index) => (
<VisibilityRuleSummary
key={`${rule.variable}-${index}`}
rule={rule}
onEdit={() => setVisibilityRuleDialog({ mode: 'edit', index })}
onDelete={() => removeRule(index)}
/>
))}
</div>
) : (
<p className="text-[10px] text-[#888]"></p>
)}
<button
type="button"
onClick={() => setVisibilityRuleDialog({ mode: 'add' })}
disabled={addableVisibilityVariables.length === 0}
className="ps-btn w-full text-[10px] justify-center disabled:opacity-40"
title={
addableVisibilityVariables.length === 0
? '请先在变量管理中添加变量,或所有变量已添加条件'
: undefined
}
>
<Plus className="w-3 h-3" />
</button>
</div>
);
})()}
</div>
</div>
);
};
return (
<div className="ps-sidebar-stack">
<VisibilityRuleDialog
open={visibilityRuleDialog !== null}
mode={visibilityRuleDialog?.mode ?? 'add'}
initialRule={editingVisibilityRule}
variableOptions={visibilityRuleDialogVariableOptions}
onClose={() => setVisibilityRuleDialog(null)}
onConfirm={handleConfirmVisibilityRule}
/>
<CollapsiblePanelSection
sectionClassName="content-panel"
collapsed={panelCollapsed.content}
onToggle={() => togglePanel('content')}
icon={<Link2 className="w-3.5 h-3.5 text-[#31a8ff] shrink-0" />}
title="内容"
bodyClassName="ps-panel-body p-3 min-h-0"
>
{renderContentPanelBody()}
</CollapsiblePanelSection>
<CollapsiblePanelSection
sectionClassName="properties-panel flex flex-col"
collapsed={panelCollapsed.properties}
onToggle={() => togglePanel('properties')}
icon={<Sliders className="w-3.5 h-3.5 text-[#31a8ff] shrink-0" />}
title="属性"
headerExtra={
selectedElem ? (
<span className="text-[10px] text-[#999] font-normal truncate max-w-[100px]">
{selectedElem.name}
</span>
) : null
}
bodyClassName="ps-panel-body p-3 space-y-4 min-h-0"
>
<>
{/* Label Canvas Dimensions */}
{!selectedElem && (
<div className="space-y-4">
<div className="space-y-3">
<h4 className="text-[10px] font-bold uppercase tracking-widest mb-1">
</h4>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="ps-field-label"> (mm)</label>
<PropInput
type="number"
min="10"
max={maxLabelW}
value={template.width}
onCommit={(val) => {
const wVal = Math.min(maxLabelW, Math.max(10, Number(val) || 10));
// Also sanitize elements that might now be out of bounds
const sanitizedElements = template.elements.map(el => {
const newX = Math.min(wVal - el.width, el.x);
const newW = Math.min(wVal - Math.max(0, newX), el.width);
return { ...el, x: Math.max(0, newX), width: Math.max(2, newW) };
});
onChangeTemplate({ ...template, width: wVal, elements: sanitizedElements });
}}
className="ps-field font-mono"
/>
</div>
<div>
<label className="ps-field-label"> (mm)</label>
<PropInput
type="number"
min="10"
max={maxLabelH}
value={template.height}
onCommit={(val) => {
const hVal = Math.min(maxLabelH, Math.max(10, Number(val) || 10));
// Also sanitize elements that might now be out of bounds
const sanitizedElements = template.elements.map(el => {
const newY = Math.min(hVal - el.height, el.y);
const newH = Math.min(hVal - Math.max(0, newY), el.height);
return {
...el,
y: Math.max(0, newY),
height: el.type === 'qrcode' ? Math.max(2, newH) : Math.max(2, newH),
width: el.type === 'qrcode' ? Math.max(2, newH) : el.width
};
});
onChangeTemplate({ ...template, height: hVal, elements: sanitizedElements });
}}
className="ps-field font-mono"
/>
</div>
</div>
<p className="text-[10px] text-[#777] leading-normal flex items-center gap-1 bg-[#1e1e1e] p-2 border border-[#3a3a3a]">
<HelpCircle className="w-3.5 h-3.5 text-[#31a8ff] shrink-0" />
<span></span>
</p>
</div>
</div>
)}
{/* Selected Element Adjust Properties */}
{selectedElem && (
<div className="space-y-4">
{selectedElementIds.length >= 2 && (
<div className="bg-[#1e1e1e] border border-[#3a3a3a] p-3 space-y-3">
<span className="text-[10px] bg-[#31a8ff] text-white font-bold px-1.5 py-0.5 inline-block">
({selectedElementIds.length})
</span>
<div className="space-y-1.5">
<span className="text-[10px] font-semibold text-[#aaa]"></span>
<p className="text-[10px] text-[#666] leading-snug">
</p>
{renderAlignButtonGrid((mode) => applyMultiAlign(mode), 'selection')}
</div>
<div className="space-y-1.5">
<span className="text-[10px] font-semibold text-[#aaa]"></span>
<p className="text-[10px] text-[#666] leading-snug">
{template.width}×{template.height} mm
</p>
{renderAlignButtonGrid((mode) => applyCanvasAlign(mode), 'canvas')}
</div>
<div className="space-y-1.5">
<span className="text-[10px] font-semibold text-[#aaa]"></span>
<div className="grid grid-cols-2 gap-1.5">
<AlignToolButton
icon={<AlignHorizontalSpaceBetween className="w-3.5 h-3.5" />}
label="水平等距平铺"
title="在选区宽度内水平等距排列"
onClick={() => applyTile('horizontal')}
/>
<AlignToolButton
icon={<AlignVerticalSpaceBetween className="w-3.5 h-3.5" />}
label="垂直等距平铺"
title="在选区高度内垂直等距排列"
onClick={() => applyTile('vertical')}
/>
</div>
</div>
</div>
)}
<div className="space-y-1">
<div className="text-[10px] font-bold text-[#31a8ff] uppercase tracking-widest leading-none">
</div>
<PropInput
type="text"
value={selectedElem.name}
onCommit={(val) => handleElemChange({ ...selectedElem, name: val || '未命名元素' })}
className="ps-field mt-2"
/>
</div>
{/* Alignment Tools */}
<div className="space-y-1.5">
<h5 className="ps-section-title">
</h5>
<div className="grid grid-cols-3 gap-1.5">
<AlignToolButton
icon={<AlignHorizontalJustifyStart className="w-3.5 h-3.5" />}
label="左对齐"
title="靠左对齐 (X=0)"
onClick={() => handleElemChange({ ...selectedElem, x: 0 })}
/>
<AlignToolButton
icon={<AlignHorizontalJustifyCenter className="w-3.5 h-3.5" />}
label="水平居中"
title="水平居中对齐"
onClick={() => {
const newX = Math.round(((template.width - selectedElem.width) / 2) * 10) / 10;
handleElemChange({ ...selectedElem, x: Math.max(0, newX) });
}}
/>
<AlignToolButton
icon={<AlignHorizontalJustifyEnd className="w-3.5 h-3.5" />}
label="右对齐"
title="靠右对齐"
onClick={() => {
const newX = Math.round((template.width - selectedElem.width) * 10) / 10;
handleElemChange({ ...selectedElem, x: Math.max(0, newX) });
}}
/>
<AlignToolButton
icon={<AlignVerticalJustifyStart className="w-3.5 h-3.5" />}
label="顶对齐"
title="靠顶对齐 (Y=0)"
onClick={() => handleElemChange({ ...selectedElem, y: 0 })}
/>
<AlignToolButton
icon={<AlignVerticalJustifyCenter className="w-3.5 h-3.5" />}
label="垂直居中"
title="垂直居中对齐"
onClick={() => {
const newY = Math.round(((template.height - selectedElem.height) / 2) * 10) / 10;
handleElemChange({ ...selectedElem, y: Math.max(0, newY) });
}}
/>
<AlignToolButton
icon={<AlignVerticalJustifyEnd className="w-3.5 h-3.5" />}
label="底对齐"
title="靠底对齐"
onClick={() => {
const newY = Math.round((template.height - selectedElem.height) * 10) / 10;
handleElemChange({ ...selectedElem, y: Math.max(0, newY) });
}}
/>
</div>
</div>
{/* Text Settings */}
{selectedElem.type === 'text' && (
<div className="space-y-3 ps-section-divider">
<h5 className="ps-section-title">
<Type className="w-3.5 h-3.5" />
</h5>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="ps-field-label"></label>
<select
value={selectedElem.textFormat || 'text'}
onChange={(e) =>
handleElemChange({
...selectedElem,
textFormat: e.target.value as 'text' | 'number' | 'percent' | 'currency',
decimalPlaces:
e.target.value === 'text'
? selectedElem.decimalPlaces
: selectedElem.decimalPlaces ?? 2,
})
}
className="ps-field"
>
<option value="text"></option>
<option value="number"></option>
<option value="percent"></option>
<option value="currency"> (¥)</option>
</select>
</div>
{(selectedElem.textFormat === 'number' ||
selectedElem.textFormat === 'percent' ||
selectedElem.textFormat === 'currency') && (
<>
<div>
<label className="ps-field-label"></label>
<PropInput
type="number"
min="0"
max="6"
value={selectedElem.decimalPlaces ?? 2}
onCommit={(val) =>
handleElemChange({
...selectedElem,
decimalPlaces: Math.max(0, Math.min(6, Number(val) || 0)),
})
}
className="ps-field font-mono"
/>
</div>
<div className="col-span-2">
<label className="ps-field-label"></label>
<select
value={selectedElem.numberPartDisplay ?? 'full'}
onChange={(e) =>
handleElemChange({
...selectedElem,
numberPartDisplay: e.target.value as
| 'full'
| 'integer'
| 'fraction',
})
}
className="ps-field"
>
<option value="full"></option>
<option value="integer"> 19.9 19</option>
<option value="fraction"> 19.9 .9</option>
</select>
<p className="text-[9px] text-[#666] mt-1 leading-relaxed">
19.9
</p>
</div>
</>
)}
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="ps-field-label"> (pt)</label>
<PropInput
type="number"
min="6"
max="80"
value={selectedElem.fontSize}
onCommit={(val) =>
handleElemChange({ ...selectedElem, fontSize: Number(val) || 12 })
}
className="ps-field font-mono"
/>
</div>
<div>
<label className="ps-field-label"></label>
<select
value={selectedElem.fontFamily}
onChange={(e) =>
handleElemChange({
...selectedElem,
fontFamily: e.target.value as 'sans' | 'serif' | 'mono',
})
}
className="ps-field"
>
<option value="sans"> (Sans)</option>
<option value="serif"> (Serif)</option>
<option value="mono"> (Mono)</option>
</select>
</div>
</div>
{/* Text color */}
<div className="border-t border-[#3a3a3a] pt-2.5">
<label className="block text-[10px] font-semibold text-[#777] mb-1"></label>
<div className="flex gap-1 items-center">
<input
type="color"
value={selectedElem.textColor || '#111827'}
onChange={(e) => handleElemChange({ ...selectedElem, textColor: e.target.value })}
className="color-input shrink-0"
/>
<input
type="text"
placeholder="#111827"
value={selectedElem.textColor || ''}
onChange={(e) => handleElemChange({ ...selectedElem, textColor: e.target.value })}
className="ps-field font-mono text-[11px] flex-1 min-w-0"
/>
</div>
</div>
<div className="space-y-2">
<label className="ps-field-label mb-0"></label>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() =>
handleElemChange({
...selectedElem,
fontWeight: selectedElem.fontWeight === 'bold' ? 'normal' : 'bold',
})
}
className={`ps-btn ${selectedElem.fontWeight === 'bold' ? 'active' : ''}`}
title="加粗"
>
<Bold className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() =>
handleElemChange({
...selectedElem,
fontStyle:
selectedElem.fontStyle === 'italic' ? 'normal' : 'italic',
})
}
className={`ps-btn ${selectedElem.fontStyle === 'italic' ? 'active' : ''}`}
title="斜体"
>
<Italic className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() =>
handleElemChange({
...selectedElem,
textUnderline: !selectedElem.textUnderline,
})
}
className={`ps-btn ${selectedElem.textUnderline ? 'active' : ''}`}
title="下划线"
>
<Underline className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() =>
handleElemChange({
...selectedElem,
textLineThrough: !selectedElem.textLineThrough,
})
}
className={`ps-btn ${selectedElem.textLineThrough ? 'active' : ''}`}
title="删除线"
>
<Strikethrough className="w-3.5 h-3.5" />
</button>
</div>
</div>
<div className="grid grid-cols-3 gap-2">
<div>
<label className="ps-field-label"></label>
<PropInput
type="number"
min="10"
max="500"
step="5"
value={Math.round((selectedElem.textScaleX ?? 1) * 100)}
onCommit={(val) =>
handleElemChange({
...selectedElem,
textScaleX: Math.max(0.1, Math.min(5, (Number(val) || 100) / 100)),
})
}
className="ps-field font-mono"
/>
<p className="text-[9px] text-[#666] mt-0.5">%</p>
</div>
<div>
<label className="ps-field-label"></label>
<PropInput
type="number"
min="10"
max="500"
step="5"
value={Math.round((selectedElem.textScaleY ?? 1) * 100)}
onCommit={(val) =>
handleElemChange({
...selectedElem,
textScaleY: Math.max(0.1, Math.min(5, (Number(val) || 100) / 100)),
})
}
className="ps-field font-mono"
/>
<p className="text-[9px] text-[#666] mt-0.5">%</p>
</div>
<div>
<label className="ps-field-label"></label>
<PropInput
type="number"
min="0"
max="10"
step="0.1"
value={selectedElem.letterSpacing ?? 0}
onCommit={(val) =>
handleElemChange({
...selectedElem,
letterSpacing: Math.max(0, Math.min(10, Number(val) || 0)),
})
}
className="ps-field font-mono"
/>
<p className="text-[9px] text-[#666] mt-0.5">mm</p>
</div>
</div>
<div className="space-y-2">
<label className="ps-field-label mb-0"></label>
<div className="ps-toggle-group">
<button
type="button"
onClick={() =>
handleElemChange({ ...selectedElem, textWritingMode: 'horizontal' })
}
className={`ps-toggle-btn ${(selectedElem.textWritingMode ?? 'horizontal') === 'horizontal' ? 'active' : ''}`}
title="横排"
>
</button>
<button
type="button"
onClick={() =>
handleElemChange({ ...selectedElem, textWritingMode: 'vertical' })
}
className={`ps-toggle-btn ${selectedElem.textWritingMode === 'vertical' ? 'active' : ''}`}
title="竖排"
>
</button>
</div>
</div>
<div className="space-y-2">
<label className="ps-field-label mb-0"></label>
<div className="flex flex-wrap items-center gap-2">
<div className="ps-toggle-group">
<button
type="button"
onClick={() => handleElemChange({ ...selectedElem, textAlign: 'left' })}
className={`ps-toggle-btn ${selectedElem.textAlign === 'left' ? 'active' : ''}`}
title="左对齐"
>
<AlignLeft className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => handleElemChange({ ...selectedElem, textAlign: 'center' })}
className={`ps-toggle-btn ${selectedElem.textAlign === 'center' ? 'active' : ''}`}
title="水平居中"
>
<AlignCenter className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => handleElemChange({ ...selectedElem, textAlign: 'right' })}
className={`ps-toggle-btn ${selectedElem.textAlign === 'right' ? 'active' : ''}`}
title="右对齐"
>
<AlignRight className="w-3.5 h-3.5" />
</button>
</div>
<div className="ps-toggle-group">
<button
type="button"
onClick={() => handleElemChange({ ...selectedElem, verticalAlign: 'top' })}
className={`ps-toggle-btn ${(selectedElem.verticalAlign ?? 'middle') === 'top' ? 'active' : ''}`}
title="顶对齐"
>
<AlignVerticalJustifyStart className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => handleElemChange({ ...selectedElem, verticalAlign: 'middle' })}
className={`ps-toggle-btn ${(selectedElem.verticalAlign ?? 'middle') === 'middle' ? 'active' : ''}`}
title="垂直居中"
>
<AlignVerticalJustifyCenter className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => handleElemChange({ ...selectedElem, verticalAlign: 'bottom' })}
className={`ps-toggle-btn ${(selectedElem.verticalAlign ?? 'middle') === 'bottom' ? 'active' : ''}`}
title="底对齐"
>
<AlignVerticalJustifyEnd className="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={selectedElem.wordWrap !== false}
onChange={(e) => handleElemChange({ ...selectedElem, wordWrap: e.target.checked })}
className="ps-checkbox"
/>
<span className="text-[12px] text-[#ccc]"></span>
</label>
</div>
)}
{/* Barcode Settings */}
{selectedElem.type === 'barcode' && (
<div className="space-y-3 ps-section-divider">
<h5 className="ps-section-title">
<Barcode className="w-3.5 h-3.5" />
</h5>
<div>
<label className="ps-field-label"></label>
<select
value={selectedElem.barcodeFormat}
onChange={(e) =>
handleElemChange({
...selectedElem,
barcodeFormat: e.target.value as 'CODE128' | 'CODE39' | 'EAN13' | 'ITF',
})
}
className="ps-field"
>
<option value="CODE128">CODE 128 ( · )</option>
<option value="CODE39">CODE 39 ()</option>
<option value="EAN13">EAN 13 ( 13)</option>
<option value="ITF">ITF ()</option>
</select>
</div>
<label className="flex items-center gap-2 cursor-pointer mt-1">
<input
type="checkbox"
checked={selectedElem.showText}
onChange={(e) => handleElemChange({ ...selectedElem, showText: e.target.checked })}
className="ps-checkbox"
/>
<span className="text-xs font-semibold text-[#ccc]"> (Human Readable)</span>
</label>
</div>
)}
{/* Image Settings */}
{selectedElem.type === 'image' && (
<div className="space-y-3 ps-section-divider">
<h5 className="ps-section-title">
<ImageIcon className="w-3.5 h-3.5" />
</h5>
<div>
<label className="ps-field-label"></label>
<select
value={selectedElem.imageFit ?? 'contain'}
onChange={(e) =>
handleElemChange({
...selectedElem,
imageFit: e.target.value as 'contain' | 'cover' | 'fill',
})
}
className="ps-field"
>
<option value="contain"> (contain)</option>
<option value="cover"> (cover)</option>
<option value="fill"> (fill)</option>
</select>
</div>
</div>
)}
{/* Positions (X, Y) and Sizes (W, H) */}
<div className="space-y-3">
<h5 className="ps-section-title">
<Ruler className="w-3.5 h-3.5" />
(mm)
</h5>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="ps-field-label">X ()</label>
<PropInput
type="number"
step="0.5"
value={selectedElem.x}
onCommit={(val) => {
handleElemChange({
...selectedElem,
x: Math.round((Number(val) || 0) * 10) / 10,
});
}}
className="ps-field font-mono"
/>
</div>
<div>
<label className="ps-field-label">Y ()</label>
<PropInput
type="number"
step="0.5"
value={selectedElem.y}
onCommit={(val) => {
handleElemChange({
...selectedElem,
y: Math.round((Number(val) || 0) * 10) / 10,
});
}}
className="ps-field font-mono"
/>
</div>
<div>
<label className="ps-field-label"></label>
<PropInput
type="number"
step="0.5"
min="2"
value={selectedElem.width}
onCommit={(val) => {
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"
/>
</div>
<div>
<label className="ps-field-label"></label>
<PropInput
type="number"
step="0.5"
min="2"
value={selectedElem.height}
disabled={selectedElem.type === 'qrcode'}
onCommit={(val) => {
const hVal = Math.max(2, Number(val) || 2);
handleElemChange({ ...selectedElem, height: hVal });
}}
className={`ps-field font-mono ${
selectedElem.type === 'qrcode' ? 'opacity-50 select-none' : ''
}`}
/>
</div>
<div className="col-span-2">
<label className="ps-field-label flex items-center gap-1">
</label>
<div className="flex items-center gap-2">
<PropInput
type="number"
step="1"
min="-360"
max="360"
value={selectedElem.rotation ?? 0}
onCommit={(val) => {
let deg = Number(val) || 0;
deg = ((deg % 360) + 360) % 360;
handleElemChange({ ...selectedElem, rotation: deg });
}}
className="ps-field font-mono flex-1"
/>
</div>
</div>
</div>
</div>
{/* Background — all element types */}
<div className="space-y-2 ps-section-divider">
<h5 className="ps-section-title">
</h5>
<div className="flex justify-between items-center mb-1">
<span className="text-[10px] text-[#777]"></span>
<button
type="button"
onClick={() =>
handleElemChange({
...selectedElem,
backgroundColor: 'transparent',
backgroundOpacity: 0,
})
}
className="text-[9px] text-[#31a8ff] hover:underline cursor-pointer"
>
</button>
</div>
<div className="flex gap-1 items-center">
<input
type="color"
value={
selectedElem.backgroundColor &&
selectedElem.backgroundColor !== 'transparent'
? selectedElem.backgroundColor
: '#ffffff'
}
onChange={(e) =>
handleElemChange({
...selectedElem,
backgroundColor: e.target.value,
backgroundOpacity:
selectedElem.backgroundOpacity && selectedElem.backgroundOpacity > 0
? selectedElem.backgroundOpacity
: 100,
})
}
className="color-input shrink-0"
/>
<input
type="text"
placeholder="transparent"
value={
selectedElem.backgroundColor === 'transparent'
? 'transparent'
: selectedElem.backgroundColor || ''
}
onChange={(e) => {
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"
/>
</div>
<div>
<div className="flex justify-between items-center mb-1">
<span className="text-[10px] text-[#777]"></span>
<span className="text-[10px] text-[#999] font-mono">
{selectedElem.backgroundColor === 'transparent'
? 0
: selectedElem.backgroundOpacity ?? 100}
%
</span>
</div>
<input
type="range"
min="0"
max="100"
step="1"
value={
selectedElem.backgroundColor === 'transparent'
? 0
: selectedElem.backgroundOpacity ?? 100
}
onChange={(e) => {
const op = Number(e.target.value);
if (op <= 0) {
handleElemChange({
...selectedElem,
backgroundColor: 'transparent',
backgroundOpacity: 0,
});
} else {
handleElemChange({
...selectedElem,
backgroundColor:
selectedElem.backgroundColor &&
selectedElem.backgroundColor !== 'transparent'
? selectedElem.backgroundColor
: '#ffffff',
backgroundOpacity: op,
});
}
}}
className="ps-range"
/>
</div>
<div className="border-t border-[#3a3a3a] pt-2.5 space-y-2">
<label className="ps-field-label"> (mm)</label>
<PropInput
type="number"
min="0"
max="10"
step="0.5"
value={selectedElem.padding ?? 0}
onCommit={(val) =>
handleElemChange({
...selectedElem,
padding: Math.max(0, Math.min(10, Number(val) || 0)),
})
}
className="ps-field font-mono"
/>
<div>
<label className="ps-field-label"> (mm)</label>
<div className="grid grid-cols-2 gap-1.5">
{(
[
['paddingTop', '上'],
['paddingRight', '右'],
['paddingBottom', '下'],
['paddingLeft', '左'],
] as const
).map(([key, label]) => (
<div key={key} className="flex items-center gap-1">
<span className="text-[9px] text-[#777] w-6 shrink-0">{label}</span>
<PropInput
type="number"
min="0"
max="10"
step="0.5"
value={selectedElem[key] ?? ''}
onCommit={(val) => {
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="—"
/>
</div>
))}
</div>
</div>
</div>
</div>
{/* Border & radius */}
<div className="space-y-2 ps-section-divider">
<h5 className="ps-section-title"></h5>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="ps-field-label"> (mm)</label>
<PropInput
type="number"
min="0"
max="5"
step="0.1"
value={selectedElem.borderWidth ?? 0}
onCommit={(val) =>
handleElemChange({
...selectedElem,
borderWidth: Math.max(0, Math.min(5, Number(val) || 0)),
borderColor: selectedElem.borderColor || '#111827',
})
}
className="ps-field font-mono"
/>
</div>
<div>
<label className="ps-field-label"> (mm)</label>
<PropInput
type="number"
min="0"
max="20"
step="0.5"
value={selectedElem.borderRadius ?? 0}
onCommit={(val) =>
handleElemChange({
...selectedElem,
borderRadius: Math.max(0, Math.min(20, Number(val) || 0)),
})
}
className="ps-field font-mono"
/>
</div>
</div>
<div>
<label className="ps-field-label"></label>
<div className="flex gap-1 items-center">
<input
type="color"
value={selectedElem.borderColor || '#111827'}
onChange={(e) =>
handleElemChange({
...selectedElem,
borderColor: e.target.value,
borderWidth: selectedElem.borderWidth || 0.3,
})
}
className="color-input shrink-0"
/>
<input
type="text"
value={selectedElem.borderColor || ''}
placeholder="#111827"
onChange={(e) =>
handleElemChange({
...selectedElem,
borderColor: e.target.value,
})
}
className="ps-field font-mono text-[11px] flex-1 min-w-0"
/>
</div>
</div>
<div>
<label className="ps-field-label"> (mm0 )</label>
<div className="grid grid-cols-2 gap-1.5">
{(
[
['borderWidthTop', '上'],
['borderWidthRight', '右'],
['borderWidthBottom', '下'],
['borderWidthLeft', '左'],
] as const
).map(([key, label]) => (
<div key={key} className="flex items-center gap-1">
<span className="text-[9px] text-[#777] w-6 shrink-0">{label}</span>
<PropInput
type="number"
min="0"
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="—"
/>
</div>
))}
</div>
</div>
<div>
<label className="ps-field-label"> ()</label>
<div className="grid grid-cols-2 gap-1.5">
{(
[
['borderColorTop', '上'],
['borderColorRight', '右'],
['borderColorBottom', '下'],
['borderColorLeft', '左'],
] as const
).map(([key, label]) => (
<div key={key} className="flex items-center gap-1 min-w-0">
<span className="text-[9px] text-[#777] w-6 shrink-0">{label}</span>
<input
type="color"
value={
selectedElem[key]?.trim() ||
selectedElem.borderColor ||
'#111827'
}
onChange={(e) => {
const updated = { ...selectedElem };
updated[key] = e.target.value;
handleElemChange(updated);
}}
className="color-input shrink-0 scale-90"
title={`${label}边边框颜色`}
/>
<PropInput
type="text"
value={selectedElem[key] ?? ''}
onCommit={(val) => {
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="—"
/>
</div>
))}
</div>
</div>
<div>
<label className="ps-field-label"> (mm)</label>
<div className="grid grid-cols-2 gap-1.5">
{(
[
['borderRadiusTL', '左上'],
['borderRadiusTR', '右上'],
['borderRadiusBL', '左下'],
['borderRadiusBR', '右下'],
] as const
).map(([key, label]) => (
<div key={key} className="flex items-center gap-1">
<span className="text-[9px] text-[#777] w-6 shrink-0">{label}</span>
<PropInput
type="number"
min="0"
max="20"
step="0.5"
value={selectedElem[key] ?? ''}
onCommit={(val) => {
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="—"
/>
</div>
))}
</div>
</div>
</div>
</div>
)}
</>
</CollapsiblePanelSection>
<CollapsiblePanelSection
sectionClassName="layers-panel-section"
collapsed={panelCollapsed.layers}
onToggle={() => togglePanel('layers')}
icon={<Layers className="w-3.5 h-3.5 shrink-0 text-[#31a8ff]" />}
title="图层"
headerExtra={
<span className="text-[10px] text-[#666] font-mono">{template.elements.length}</span>
}
bodyClassName="ps-panel-body min-h-0"
>
{template.elements.length === 0 ? (
<div className="text-[11px] text-[#666] text-center py-6"></div>
) : (
[...template.elements].reverse().map((elem, revIdx) => {
const actualIdx = template.elements.length - 1 - revIdx;
const isSelected = selectedElementIds.includes(elem.id);
const isLocked = !!elem.locked;
return (
<div
key={elem.id}
onClick={(e) => {
if (!onSelectElements) 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]);
}
} else {
onSelectElements([elem.id]);
}
}}
className={`ps-layer-item group ${isSelected ? 'selected' : ''}`}
>
<div className="flex items-center gap-2 min-w-0 flex-1">
<span className="shrink-0 w-3.5 flex items-center justify-center">
{elem.type === 'text' && <Type className="w-3 h-3 text-[#666]" />}
{elem.type === 'barcode' && <Barcode className="w-3 h-3 text-[#666]" />}
{elem.type === 'qrcode' && <QrCode className="w-3 h-3 text-[#666]" />}
{elem.type === 'image' && <ImageIcon className="w-3 h-3 text-[#666]" />}
</span>
<span className="truncate" title={elem.name}>
{elem.name}
</span>
</div>
<div className="flex items-center gap-0.5 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
onClick={(e) => { e.stopPropagation(); moveLayer(actualIdx, 'up'); }}
disabled={actualIdx === template.elements.length - 1}
title="上移"
className="p-0.5 hover:bg-[#555] disabled:opacity-20 cursor-pointer text-[#aaa]"
>
<ArrowUp className="w-3 h-3" />
</button>
<button
type="button"
onClick={(e) => { e.stopPropagation(); moveLayer(actualIdx, 'down'); }}
disabled={actualIdx === 0}
title="下移"
className="p-0.5 hover:bg-[#555] disabled:opacity-20 cursor-pointer text-[#aaa]"
>
<ArrowDown className="w-3 h-3" />
</button>
<button
type="button"
onClick={(e) => { e.stopPropagation(); toggleLockLayer(actualIdx); }}
title={isLocked ? '解锁' : '锁定'}
className={`p-0.5 cursor-pointer ${isLocked ? 'text-amber-400' : 'text-[#666] hover:text-[#ccc]'}`}
>
{isLocked ? <Lock className="w-3 h-3" /> : <Unlock className="w-3 h-3" />}
</button>
<button
type="button"
onClick={(e) => { e.stopPropagation(); deleteLayer(elem.id); }}
disabled={isLocked}
title="删除"
className="p-0.5 hover:text-red-400 text-[#666] disabled:opacity-20 cursor-pointer"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
</div>
);
})
)}
</CollapsiblePanelSection>
</div>
);
};