Files
label-designer/src/components/PropSidebar.tsx
iqudoo 1d506e0c71 fix
2026-06-15 14:55:54 +08:00

2409 lines
91 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, useRef } from 'react';
import { LabelTemplate, TemplateElement, PaperConfig, VisibilityRule } from '../types';
import {
extractTemplateVariables,
extractVariablesFromContent,
isConditionalVisibility,
isTemplateVariableInUse,
removeUnusedTemplateVariable,
resolveVisibilityRules,
toggleVisibilityRuleEnabled,
withContentAndSyncedName,
} from '../utils';
import { VisibilityRuleDialog } from './VisibilityRuleDialog';
import { VisibilityRuleSummary } from './VisibilityRuleSummary';
import { ValueExtractFields } from './ValueExtractFields';
import { TextDisplayAffixFields } from './TextDisplayAffixFields';
import {
alignToSelectionBounds,
alignSelectionToCanvas,
tileSelectedEvenly,
tileSelectedFlush,
type AlignMode,
} from '../elementAlign';
import { CollapsiblePanelSection } from './CollapsiblePanelSection';
import { FieldSelect } from './PsSelect';
import { CommitInput } from './CommitInput';
import { useAppDialog } from './AppDialog';
import { useIsNarrowScreen } from '../hooks/useIsMobile';
import type { ElementClipboardActions } from '../hooks/useElementClipboard';
import {
TablePropertyFields,
TableCellContentFields,
scaleTableElementSize,
} from './TablePropertyFields';
import type { TableCellCoord } from '../tableUtils';
import { expandTableForPadding } from '../tableUtils';
import {
Sliders,
LayoutGrid,
Type,
AlignLeft,
AlignCenter,
AlignRight,
Bold,
Italic,
Underline,
Strikethrough,
HelpCircle,
Lock,
Unlock,
ArrowUp,
ArrowDown,
ArrowLeft,
ArrowRight,
Move,
Layers,
Trash2,
Copy,
AlignHorizontalJustifyStart,
AlignHorizontalJustifyCenter,
AlignHorizontalJustifyEnd,
AlignVerticalJustifyStart,
AlignVerticalJustifyCenter,
AlignVerticalJustifyEnd,
AlignHorizontalSpaceBetween,
AlignVerticalSpaceBetween,
Columns2,
Rows2,
Link2,
Plus,
Ruler,
RotateCw,
Barcode,
QrCode,
Image as ImageIcon,
Upload,
Table2,
} 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);
const focusedRef = useRef(false);
const localValRef = useRef(localVal);
const valueRef = useRef(value);
const onCommitRef = useRef(onCommit);
localValRef.current = localVal;
valueRef.current = value;
onCommitRef.current = onCommit;
useEffect(() => {
if (!focusedRef.current) {
setLocalVal(value);
}
}, [value]);
useEffect(() => {
return () => {
if (
focusedRef.current &&
localValRef.current !== valueRef.current
) {
onCommitRef.current(localValRef.current);
}
};
}, []);
const handleBlur = () => {
focusedRef.current = false;
if (localValRef.current !== valueRef.current) {
onCommitRef.current(localValRef.current);
}
};
return (
<textarea
{...props}
value={localVal}
onChange={(e) => setLocalVal(e.target.value)}
onFocus={() => {
focusedRef.current = true;
}}
onBlur={handleBlur}
/>
);
};
export type MobilePropModule = 'content' | 'properties' | 'layers' | 'nudge';
const MOBILE_NUDGE_STEP_MM = 0.1;
interface PropSidebarProps {
template: LabelTemplate;
onChangeTemplate: (updated: LabelTemplate) => void;
selectedElementIds: string[];
activeTab: 'element' | 'paper';
onChangeTab: (tab: 'element' | 'paper') => void;
paper: PaperConfig;
onSelectElements?: (ids: string[]) => void;
selectedTableCells?: TableCellCoord[];
onSelectTableCells?: (cells: TableCellCoord[]) => void;
variant?: 'desktop' | 'mobile';
mobileModule?: MobilePropModule;
onMobileModuleChange?: (module: MobilePropModule) => void;
elementClipboard?: ElementClipboardActions;
}
const MOBILE_PROP_MODULES: { id: MobilePropModule; label: string; icon: React.ReactNode }[] = [
{ id: 'properties', label: '属性', icon: <Sliders className="w-4 h-4" /> },
{ id: 'content', label: '内容', icon: <Link2 className="w-4 h-4" /> },
{ id: 'layers', label: '图层', icon: <Layers className="w-4 h-4" /> },
{ id: 'nudge', label: '微调', icon: <Move className="w-4 h-4" /> },
];
export const PropSidebar: React.FC<PropSidebarProps> = ({
template,
onChangeTemplate,
selectedElementIds,
activeTab,
onChangeTab,
paper,
onSelectElements,
selectedTableCells = [],
onSelectTableCells,
variant = 'desktop',
mobileModule: mobileModuleProp,
onMobileModuleChange,
elementClipboard,
}) => {
const { showConfirm } = useAppDialog();
const templateRef = useRef(template);
templateRef.current = template;
const isNarrowScreen = useIsNarrowScreen();
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 [internalMobileModule, setInternalMobileModule] =
useState<MobilePropModule>('properties');
const mobileModule = mobileModuleProp ?? internalMobileModule;
const setMobileModule = (module: MobilePropModule) => {
onMobileModuleChange?.(module);
if (mobileModuleProp === undefined) {
setInternalMobileModule(module);
}
};
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 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) => {
const current = templateRef.current;
onChangeTemplate({
...current,
elements: current.elements.map((el) => (el.id === updatedElem.id ? updatedElem : el)),
});
};
const commitTableAwareChange = (updatedElem: TemplateElement) => {
if (updatedElem.type === 'table') {
handleElemChange(expandTableForPadding(updatedElem));
} else {
handleElemChange(updatedElem);
}
};
const handleContentChange = (content: string) => {
if (!selectedElem || selectedElem.type === 'table') return;
handleElemChange(withContentAndSyncedName(selectedElem, content));
};
const nudgeableElementIds = useMemo(() => {
const idSet = new Set(selectedElementIds);
return template.elements
.filter((el) => idSet.has(el.id) && !el.locked)
.map((el) => el.id);
}, [selectedElementIds, template.elements]);
const nudgeSelectedElements = (deltaX: number, deltaY: number) => {
if (nudgeableElementIds.length === 0) return;
const idSet = new Set(nudgeableElementIds);
onChangeTemplate({
...template,
elements: template.elements.map((el) => {
if (!idSet.has(el.id)) return el;
return {
...el,
x: Math.round((el.x + deltaX) * 10) / 10,
y: Math.round((el.y + deltaY) * 10) / 10,
};
}),
});
};
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', spacing: 'even' | 'none' = 'even') => {
const tile =
spacing === 'even' ? tileSelectedEvenly : tileSelectedFlush;
onChangeTemplate({
...template,
elements: tile(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;
handleContentChange(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-3">
<div className="text-[10px] font-bold text-[#31a8ff]"></div>
<p className="text-[10px] text-[#666] leading-relaxed mb-3">
</p>
{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 target = template.elements[idx];
if (!target) return;
const willLock = !target.locked;
const list = template.elements.map((el, i) => {
if (i === idx) {
return { ...el, locked: !el.locked };
}
return el;
});
onChangeTemplate({ ...template, elements: list });
if (willLock && onSelectElements) {
onSelectElements(selectedElementIds.filter((id) => id !== target.id));
if (target.type === 'table') {
onSelectTableCells?.([]);
}
}
};
const deleteLayer = async (id: string) => {
const el = template.elements.find((x) => x.id === id);
if (el?.locked) return;
const confirmed = await showConfirm(
`确定删除图层「${el?.name ?? '未命名'}」吗?此操作无法撤销。`,
{
title: '删除图层',
confirmLabel: '删除',
variant: 'error',
}
);
if (!confirmed) return;
onChangeTemplate({
...templateRef.current,
elements: templateRef.current.elements.filter((item) => item.id !== id),
});
if (onSelectElements && selectedElementIds.includes(id)) {
onSelectElements(selectedElementIds.filter((selectedId) => selectedId !== id));
}
};
const deletableSelectedIds = useMemo(
() =>
selectedElementIds.filter((id) => {
const el = template.elements.find((item) => item.id === id);
return el && !el.locked;
}),
[selectedElementIds, template.elements]
);
const deleteSelectedElements = async () => {
if (deletableSelectedIds.length === 0) return;
const message =
deletableSelectedIds.length === 1
? `确定删除「${template.elements.find((item) => item.id === deletableSelectedIds[0])?.name ?? '未命名'}」吗?此操作无法撤销。`
: `确定删除选中的 ${deletableSelectedIds.length} 个元素吗?此操作无法撤销。`;
const confirmed = await showConfirm(message, {
title: '删除元素',
confirmLabel: '删除',
variant: 'error',
});
if (!confirmed) return;
const current = templateRef.current;
const idSet = new Set(deletableSelectedIds);
const remaining = current.elements.filter((el) => !idSet.has(el.id));
onChangeTemplate({ ...current, elements: remaining });
onSelectElements?.(selectedElementIds.filter((id) => remaining.some((el) => el.id === id)));
};
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 = () => handleContentChange(reader.result as string);
reader.readAsDataURL(file);
e.target.value = '';
}}
/>
</label>
</div>
)}
{selectedElem.type !== 'table' && (
<>
<div className="flex items-center justify-between gap-2">
<label className="ps-field-label"></label>
<button
type="button"
disabled={!selectedElem.content}
onClick={() => handleContentChange('')}
className="text-[9px] text-[#31a8ff] hover:underline cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed disabled:no-underline shrink-0"
>
</button>
</div>
<PropTextarea
value={selectedElem.content}
onCommit={(val) => handleContentChange(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>
)}
</>
)}
{(selectedElem.type === 'barcode' ||
selectedElem.type === 'qrcode' ||
selectedElem.type === 'image') && (
<label className="flex items-start gap-2 pt-1">
<input
type="checkbox"
checked={selectedElem.showWhenContentEmpty ?? false}
onChange={(e) =>
handleElemChange({
...selectedElem,
showWhenContentEmpty: e.target.checked ? true : undefined,
})
}
className="ps-checkbox mt-0.5"
/>
<span className="text-xs text-[#ccc] leading-normal">
{selectedElem.type === 'image' && (
<span className="block text-[9px] text-[#777] mt-0.5">
</span>
)}
</span>
</label>
)}
{selectedElem.type === 'table' && (
<p className="text-[10px] text-[#777] leading-normal">
</p>
)}
{(selectedElem.type === 'text' ||
selectedElem.type === 'barcode' ||
selectedElem.type === 'qrcode' ||
selectedElem.type === 'image' ||
selectedElem.type === 'table') && (
<div className="pt-2 border-t border-[#3a3a3a]">
{selectedElem.type === 'table' ? (
<TableCellContentFields
elem={selectedElem}
selectedCells={selectedTableCells}
onChange={handleElemChange}
templateVariables={templateVariables}
/>
) : (
<>
<ValueExtractFields elem={selectedElem} onChange={handleElemChange} />
{selectedElem.type === 'text' && (
<TextDisplayAffixFields elem={selectedElem} onChange={handleElemChange} />
)}
</>
)}
</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>
<FieldSelect
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>
</FieldSelect>
</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.target ?? 'variable'}-${rule.variable || 'content'}-${index}`}
rule={rule}
onEdit={() => setVisibilityRuleDialog({ mode: 'edit', index })}
onDelete={() => removeRule(index)}
onToggleEnabled={() =>
setRules(
rules.map((item, i) =>
i === index ? toggleVisibilityRuleEnabled(item) : item
)
)
}
/>
))}
</div>
) : (
<p className="text-[10px] text-[#888]"></p>
)}
<button
type="button"
onClick={() => setVisibilityRuleDialog({ mode: 'add' })}
className="ps-btn w-full text-[10px] justify-center"
>
<Plus className="w-3 h-3" />
</button>
</div>
);
})()}
</div>
</div>
);
};
const renderPreviewReferenceBackgroundFields = () => (
<div className="space-y-3">
<h5 className="ps-section-title">
<ImageIcon className="w-3.5 h-3.5" />
</h5>
<p className="text-[9px] text-[#777] leading-relaxed">
/
</p>
<label className="ps-btn text-[10px] cursor-pointer inline-flex">
<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 = () =>
onChangeTemplate({
...template,
previewReferenceBackground: reader.result as string,
previewReferenceBackgroundOpacity:
template.previewReferenceBackgroundOpacity ?? 100,
previewReferenceBackgroundFit:
template.previewReferenceBackgroundFit ?? 'cover',
enablePreviewReferenceBackground: true,
});
reader.readAsDataURL(file);
e.target.value = '';
}}
/>
</label>
{template.previewReferenceBackground && (
<>
<div className="border border-[#3a3a3a] bg-[#1e1e1e] p-2">
<img
src={template.previewReferenceBackground}
alt="预览参考背景"
className="block max-h-24 mx-auto object-contain"
/>
</div>
<div>
<label className="ps-field-label"></label>
<FieldSelect
value={template.previewReferenceBackgroundFit ?? 'cover'}
onChange={(e) =>
onChangeTemplate({
...template,
previewReferenceBackgroundFit: e.target.value as 'contain' | 'cover' | 'fill',
})
}
className="ps-field text-[11px]"
>
<option value="cover"> (cover)</option>
<option value="contain"> (contain)</option>
<option value="fill"> (fill)</option>
</FieldSelect>
</div>
<div>
<label className="ps-field-label">
({template.previewReferenceBackgroundOpacity ?? 100}%)
</label>
<input
type="range"
min="0"
max="100"
step="1"
value={template.previewReferenceBackgroundOpacity ?? 100}
onChange={(e) =>
onChangeTemplate({
...template,
previewReferenceBackgroundOpacity: Number(e.target.value),
})
}
className="w-full accent-[#31a8ff]"
/>
</div>
<label className="flex items-start gap-2">
<input
type="checkbox"
checked={template.enablePreviewReferenceBackground !== false}
onChange={(e) =>
onChangeTemplate({
...template,
enablePreviewReferenceBackground: e.target.checked ? true : false,
})
}
className="ps-checkbox mt-0.5"
/>
<span className="text-xs text-[#ccc] leading-normal">
<span className="block text-[9px] text-[#777] mt-0.5">
</span>
</span>
</label>
<label className="flex items-start gap-2">
<input
type="checkbox"
checked={template.includeReferenceBackgroundInOutput ?? false}
onChange={(e) =>
onChangeTemplate({
...template,
includeReferenceBackgroundInOutput: e.target.checked ? true : undefined,
})
}
className="ps-checkbox mt-0.5"
/>
<span className="text-xs text-[#ccc] leading-normal">
<span className="block text-[9px] text-[#777] mt-0.5">
PDF
</span>
</span>
</label>
<button
type="button"
onClick={() =>
onChangeTemplate({
...template,
previewReferenceBackground: undefined,
previewReferenceBackgroundOpacity: undefined,
previewReferenceBackgroundFit: undefined,
enablePreviewReferenceBackground: undefined,
includeReferenceBackgroundInOutput: undefined,
})
}
className="text-[9px] text-[#31a8ff] hover:underline cursor-pointer"
>
</button>
</>
)}
</div>
);
const renderPropertiesPanelBody = () => (
<>
{!selectedElem && (
<div className="pb-3 border-b border-[#3a3a3a]">{renderPreviewReferenceBackgroundFields()}</div>
)}
{/* 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')}
/>
<AlignToolButton
icon={<Columns2 className="w-3.5 h-3.5" />}
label="水平无间距平铺"
title="从最左元素起水平紧密排列"
onClick={() => applyTile('horizontal', 'none')}
/>
<AlignToolButton
icon={<Rows2 className="w-3.5 h-3.5" />}
label="垂直无间距平铺"
title="从最上元素起垂直紧密排列"
onClick={() => applyTile('vertical', 'none')}
/>
</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 || '未命名元素',
nameCustomized: true,
})
}
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 === 'table' && selectedElementIds.length === 1 && (
<TablePropertyFields
elem={selectedElem}
selectedCells={selectedTableCells}
onChange={handleElemChange}
onSelectTableCells={onSelectTableCells}
templateVariables={templateVariables}
/>
)}
{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>
<FieldSelect
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>
</FieldSelect>
</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>
<FieldSelect
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>
</FieldSelect>
<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>
<FieldSelect
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>
</FieldSelect>
</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>
<FieldSelect
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>
</FieldSelect>
</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>
<FieldSelect
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>
</FieldSelect>
</div>
<div>
<label className="ps-field-label"></label>
<p className="text-[9px] text-[#777] leading-normal mb-1.5">
使
</p>
<label className="ps-btn text-[10px] cursor-pointer mb-1.5 inline-flex">
<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,
defaultImage: reader.result as string,
});
reader.readAsDataURL(file);
e.target.value = '';
}}
/>
</label>
<PropTextarea
value={selectedElem.defaultImage ?? ''}
onCommit={(val) =>
handleElemChange({
...selectedElem,
defaultImage: val.trim() || undefined,
})
}
rows={2}
className="ps-field resize-none"
placeholder="默认图片 URL 或 data URI"
/>
</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);
if (selectedElem.type === 'qrcode') {
handleElemChange({ ...selectedElem, width: wVal, height: wVal });
} else if (selectedElem.type === 'table') {
handleElemChange(
scaleTableElementSize(selectedElem, wVal, selectedElem.height)
);
} else {
handleElemChange({ ...selectedElem, width: wVal });
}
}}
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);
if (selectedElem.type === 'table') {
handleElemChange(scaleTableElementSize(selectedElem, selectedElem.width, hVal));
} else {
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,
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: '#ffffff',
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,
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) =>
commitTableAwareChange({
...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));
}
commitTableAwareChange(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"
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>
)}
</>
);
const renderLayersPanelBody = () => (
<>
{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={
isLocked
? undefined
: (e) => {
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) {
onSelectElements([...selectedElementIds, elem.id]);
} else {
onSelectElements([elem.id]);
}
}
}
className={`ps-layer-item group ${isSelected && !isLocked ? 'selected' : ''} ${isLocked ? 'ps-layer-item-locked' : ''
}`}
>
<div className="flex items-center gap-2 min-w-0 flex-1 pointer-events-none">
<span className="ps-layer-item-icon">
{elem.type === 'text' && <Type />}
{elem.type === 'barcode' && <Barcode />}
{elem.type === 'qrcode' && <QrCode />}
{elem.type === 'image' && <ImageIcon />}
{elem.type === 'table' && <Table2 />}
</span>
<span className="truncate" title={elem.name}>
{elem.name}
</span>
</div>
<div
className={`ps-layer-item-actions pointer-events-auto ${isNarrowScreen || variant === 'mobile' || isLocked
? 'opacity-100'
: 'opacity-0 group-hover:opacity-100 transition-opacity'
}`}
>
{!isLocked && (
<>
<button
type="button"
onClick={(e) => { e.stopPropagation(); moveLayer(actualIdx, 'up'); }}
disabled={actualIdx === template.elements.length - 1}
title="上移"
>
<ArrowUp />
</button>
<button
type="button"
onClick={(e) => { e.stopPropagation(); moveLayer(actualIdx, 'down'); }}
disabled={actualIdx === 0}
title="下移"
>
<ArrowDown />
</button>
</>
)}
<button
type="button"
onClick={(e) => { e.stopPropagation(); toggleLockLayer(actualIdx); }}
title={isLocked ? '解锁' : '锁定'}
className={isLocked ? 'text-amber-400' : 'text-[#666] hover:text-[#ccc]'}
>
{isLocked ? <Lock /> : <Unlock />}
</button>
{!isLocked && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); void deleteLayer(elem.id); }}
title="删除"
className="hover:text-red-400 text-[#666]"
>
<Trash2 />
</button>
)}
</div>
</div>
);
})
)}
</>
);
const renderNudgePanelBody = () => {
const canNudge = nudgeableElementIds.length > 0;
const primaryElem =
selectedElem && nudgeableElementIds.includes(selectedElem.id) ? selectedElem : null;
return (
<div className="mobile-nudge-panel">
<div className="mobile-nudge-header space-y-1 mb-4">
<h4 className="text-[10px] font-bold uppercase tracking-widest text-[#ccc]"></h4>
<p className="text-[10px] text-[#777]"> {MOBILE_NUDGE_STEP_MM} mm</p>
</div>
{!canNudge ? (
<div className="mobile-nudge-empty">
<Move className="w-8 h-8 text-[#555] mb-2" />
<p className="text-[12px] text-[#888]"></p>
</div>
) : (
<>
<div className="mobile-nudge-pad" role="group" aria-label="位置微调">
<button
type="button"
className="mobile-nudge-btn"
onClick={() => nudgeSelectedElements(0, -MOBILE_NUDGE_STEP_MM)}
aria-label="向上微调"
>
<ArrowUp className="w-5 h-5" />
</button>
<div className="mobile-nudge-pad-row">
<button
type="button"
className="mobile-nudge-btn"
onClick={() => nudgeSelectedElements(-MOBILE_NUDGE_STEP_MM, 0)}
aria-label="向左微调"
>
<ArrowLeft className="w-5 h-5" />
</button>
<div className="mobile-nudge-pad-center" aria-hidden="true" />
<button
type="button"
className="mobile-nudge-btn"
onClick={() => nudgeSelectedElements(MOBILE_NUDGE_STEP_MM, 0)}
aria-label="向右微调"
>
<ArrowRight className="w-5 h-5" />
</button>
</div>
<button
type="button"
className="mobile-nudge-btn"
onClick={() => nudgeSelectedElements(0, MOBILE_NUDGE_STEP_MM)}
aria-label="向下微调"
>
<ArrowDown className="w-5 h-5" />
</button>
</div>
{primaryElem && (
<div className="mobile-nudge-info">
<p className="text-[12px] font-medium text-[#ddd] truncate">{primaryElem.name}</p>
<p className="text-[11px] font-mono text-[#31a8ff] mt-1">
X {primaryElem.x} · Y {primaryElem.y} mm
</p>
{nudgeableElementIds.length > 1 && (
<p className="text-[10px] text-[#888] mt-1">
{nudgeableElementIds.length}
</p>
)}
</div>
)}
</>
)}
</div>
);
};
const visibilityRuleDialogNode = (
<VisibilityRuleDialog
open={visibilityRuleDialog !== null}
mode={visibilityRuleDialog?.mode ?? 'add'}
initialRule={editingVisibilityRule}
variableOptions={templateVariables}
onClose={() => setVisibilityRuleDialog(null)}
onConfirm={handleConfirmVisibilityRule}
/>
);
if (variant === 'mobile') {
return (
<div className="mobile-design-props">
{visibilityRuleDialogNode}
<nav className="mobile-design-props-nav" aria-label="属性模块">
{MOBILE_PROP_MODULES.map((item) => (
<button
key={item.id}
type="button"
onClick={() => setMobileModule(item.id)}
className={`mobile-design-props-nav-btn ${mobileModule === item.id ? 'active' : ''}`}
>
{item.icon}
<span>{item.label}</span>
</button>
))}
<div className="mobile-design-props-nav-spacer" aria-hidden />
{elementClipboard?.canCopy && (
<button
type="button"
onClick={() => elementClipboard.copyAndPasteSelectedElements()}
className="mobile-design-props-nav-btn"
aria-label="复制选中元素"
title="复制选中元素并创建副本"
>
<Copy className="w-4 h-4" />
<span></span>
</button>
)}
<button
type="button"
onClick={() => void deleteSelectedElements()}
disabled={deletableSelectedIds.length === 0}
className="mobile-design-props-nav-btn mobile-design-props-nav-delete"
aria-label="删除选中元素"
title="删除选中元素"
>
<Trash2 className="w-4 h-4" />
<span></span>
</button>
</nav>
<div className="mobile-design-props-panel scrollbar-hide">
{mobileModule === 'content' && (
<div className="ps-panel-body p-3 min-h-0">{renderContentPanelBody()}</div>
)}
{mobileModule === 'properties' && (
<div className="ps-panel-body p-3 space-y-4 min-h-0">{renderPropertiesPanelBody()}</div>
)}
{mobileModule === 'nudge' && (
<div className="ps-panel-body p-3 min-h-0">{renderNudgePanelBody()}</div>
)}
{mobileModule === 'layers' && (
<div className="ps-panel-body min-h-0 mobile-layers-panel">{renderLayersPanelBody()}</div>
)}
</div>
</div>
);
}
return (
<div className="ps-sidebar-stack">
{visibilityRuleDialogNode}
<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"
>
{renderPropertiesPanelBody()}
</CollapsiblePanelSection>
<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="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"
>
{renderLayersPanelBody()}
</CollapsiblePanelSection>
</div>
);
};