优化修复

This commit is contained in:
iqudoo
2026-06-15 14:46:30 +08:00
parent 8c9b53b1b6
commit ac117425d7
9 changed files with 415 additions and 160 deletions

View File

@@ -4,7 +4,7 @@ import { mmToPx } from '../utils';
import { renderLabelToDataUrl } from '../labelRenderer';
/** 离屏单元素渲染时预留描边空间,避免画布边缘裁切表格线 */
function getFloatPreviewPaddingMm(element: TemplateElement): number {
export function getFloatPreviewPaddingMm(element: TemplateElement): number {
if (element.type !== 'table') return 0;
return (element.tableBorderWidth ?? 0.2) / 2 + 0.1;
}
@@ -74,10 +74,10 @@ export const ElementFloatPreview: React.FC<ElementFloatPreviewProps> = ({
};
}, [elementVisualKey, element, renderScale, variableDefaults, dataUrlProp]);
const left = mmToPx(element.x, displayZoom);
const top = mmToPx(element.y, displayZoom);
const width = mmToPx(element.width, displayZoom);
const height = mmToPx(element.height, displayZoom);
const padMm = getFloatPreviewPaddingMm(element);
const padPx = mmToPx(padMm, displayZoom);
const boxW = mmToPx(element.width, displayZoom);
const boxH = mmToPx(element.height, displayZoom);
if (!dataUrl) return null;
@@ -87,18 +87,12 @@ export const ElementFloatPreview: React.FC<ElementFloatPreviewProps> = ({
alt=""
className="absolute pointer-events-none select-none"
style={{
left: `${left}px`,
top: `${top}px`,
width: `${width}px`,
height: `${height}px`,
left: `${-padPx}px`,
top: `${-padPx}px`,
width: `${boxW + padPx * 2}px`,
height: `${boxH + padPx * 2}px`,
imageRendering: 'auto',
}}
onLoad={(e) => {
const placeholder = e.currentTarget.previousElementSibling as HTMLElement | null;
if (placeholder?.classList.contains('resize-float-placeholder')) {
placeholder.style.display = 'none';
}
}}
/>
);
};

View File

@@ -16,7 +16,7 @@ import {
} from '../tableUtils';
import { useIsNarrowScreen } from '../hooks/useIsMobile';
import { CanvasLabelImage } from './CanvasLabelImage';
import { ElementFloatPreview, renderElementFloatPreview } from './ElementFloatPreview';
import { ElementFloatPreview, getFloatPreviewPaddingMm, renderElementFloatPreview } from './ElementFloatPreview';
import { renderLabelToDataUrl } from '../labelRenderer';
import { useAppDialog } from './AppDialog';
import { MobileMainToolButtons, type MobileToolButtonItem } from './MobileMainToolButtons';
@@ -318,16 +318,19 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
selectedIdsRef.current = selectedElementIds;
const templateElementsRef = useRef(template.elements);
templateElementsRef.current = template.elements;
const templateRef = useRef(template);
templateRef.current = template;
const addImageElement = useCallback(
(src: string = '') => {
const current = templateRef.current;
const id = `image_${Date.now()}`;
const n = template.elements.length + 1;
const n = current.elements.length + 1;
const newElement: TemplateElement = {
id,
type: 'image',
name: `图片 ${n}`,
...centerElementOnLabel(template.width, template.height, 30, 30),
...centerElementOnLabel(current.width, current.height, 30, 30),
width: 30,
height: 30,
content: src,
@@ -342,20 +345,21 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
imageFit: 'contain',
};
onChange({
...template,
elements: [...template.elements, newElement],
...current,
elements: [...current.elements, newElement],
});
onSelectElements([id]);
setActiveTool('move');
},
[template, onChange, onSelectElements]
[onChange, onSelectElements]
);
const addElement = useCallback(
(type: 'text' | 'barcode' | 'qrcode' | 'table') => {
const current = templateRef.current;
let newElement: TemplateElement;
const id = `${type}_${Date.now()}`;
const n = template.elements.length + 1;
const n = current.elements.length + 1;
if (type === 'table') {
newElement = createDefaultTableElement(id, n);
@@ -421,24 +425,25 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
}
const centered = centerElementOnLabel(
template.width,
template.height,
current.width,
current.height,
newElement.width,
newElement.height
);
newElement = { ...newElement, ...centered };
onChange({
...template,
elements: [...template.elements, newElement],
...current,
elements: [...current.elements, newElement],
});
onSelectElements([id]);
setActiveTool('move');
},
[template, onChange, onSelectElements]
[onChange, onSelectElements]
);
const deleteSelectedElements = useCallback(async () => {
const current = templateRef.current;
const ids =
selectedElementIds.length > 0
? selectedElementIds
@@ -446,14 +451,14 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
? [selectedElementId]
: [];
const deletable = ids.filter((id) => {
const el = template.elements.find((item) => item.id === id);
const el = current.elements.find((item) => item.id === id);
return el && !el.locked;
});
if (deletable.length === 0) return;
const message =
deletable.length === 1
? `确定删除「${template.elements.find((item) => item.id === deletable[0])?.name ?? '未命名'}」吗?此操作无法撤销。`
? `确定删除「${current.elements.find((item) => item.id === deletable[0])?.name ?? '未命名'}」吗?此操作无法撤销。`
: `确定删除选中的 ${deletable.length} 个元素吗?此操作无法撤销。`;
const confirmed = await showConfirm(message, {
@@ -464,10 +469,10 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
if (!confirmed) return;
const idSet = new Set(deletable);
const remaining = template.elements.filter((el) => !idSet.has(el.id));
onChange({ ...template, elements: remaining });
const remaining = current.elements.filter((el) => !idSet.has(el.id));
onChange({ ...templateRef.current, elements: remaining });
onSelectElements(selectedElementIds.filter((id) => remaining.some((el) => el.id === id)));
}, [selectedElementIds, selectedElementId, template, onChange, onSelectElements, showConfirm]);
}, [selectedElementIds, selectedElementId, onChange, onSelectElements, showConfirm]);
const handleToolClick = (tool: ActiveTool) => {
if (tool === 'move') {
@@ -505,7 +510,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'a') {
e.preventDefault();
setActiveTool('move');
onSelectElements(template.elements.filter((el) => !el.locked).map((el) => el.id));
onSelectElements(templateRef.current.elements.filter((el) => !el.locked).map((el) => el.id));
return;
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'c') {
@@ -541,9 +546,10 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
const idSet = new Set(moveIds);
e.preventDefault();
const current = templateRef.current;
onChange({
...template,
elements: template.elements.map((el) => {
...current,
elements: current.elements.map((el) => {
if (!idSet.has(el.id) || el.locked) return el;
return {
...el,
@@ -1036,7 +1042,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
const shouldCommit =
dragState.type === 'resize' || dragMovedRef.current;
if (shouldCommit) {
onChange({ ...template, elements: localElementsRef.current });
onChange({ ...templateRef.current, elements: localElementsRef.current });
}
}
localElementsRef.current = null;
@@ -1474,8 +1480,8 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
onClick: handleImageToolClick,
},
],
// eslint-disable-next-line react-hooks/exhaustive-deps -- tools 为静态配置,随 activeTool 更新即可
[activeTool]
// eslint-disable-next-line react-hooks/exhaustive-deps -- tools 为静态配置
[activeTool, handleToolClick, handleImageToolClick]
);
const mainToolButtons = (
@@ -1582,15 +1588,22 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
willChange: 'transform',
}}
>
{dragFloatElements.map((el) => (
{dragFloatElements.map((el) => {
const padMm = getFloatPreviewPaddingMm(el);
const padPx = mmToPx(padMm, displayZoom);
const boxW = mmToPx(el.width, displayZoom);
const boxH = mmToPx(el.height, displayZoom);
return (
<div
key={el.id}
className="absolute pointer-events-none"
style={{
left: `${mmToPx(el.x, displayZoom)}px`,
top: `${mmToPx(el.y, displayZoom)}px`,
width: `${mmToPx(el.width, displayZoom)}px`,
height: `${mmToPx(el.height, displayZoom)}px`,
width: `${boxW}px`,
height: `${boxH}px`,
transform: el.rotation ? `rotate(${el.rotation}deg)` : undefined,
transformOrigin: 'center center',
}}
>
<div
@@ -1615,7 +1628,13 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
}
}}
alt=""
className="absolute inset-0 w-full h-full pointer-events-none select-none opacity-0"
className="absolute pointer-events-none select-none opacity-0"
style={{
left: `${-padPx}px`,
top: `${-padPx}px`,
width: `${boxW + padPx * 2}px`,
height: `${boxH + padPx * 2}px`,
}}
onLoad={(e) => {
const img = e.currentTarget;
img.style.opacity = '1';
@@ -1624,7 +1643,8 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
}}
/>
</div>
))}
);
})}
</div>
)}
{resizeFloatElements && resizeFloatElements.length > 0 && (
@@ -1632,27 +1652,34 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
className="absolute inset-0 pointer-events-none"
style={{ overflow: 'visible' }}
>
{resizeFloatElements.map((el) => (
<React.Fragment key={el.id}>
{resizeFloatElements.map((el) => {
const left = mmToPx(el.x, displayZoom);
const top = mmToPx(el.y, displayZoom);
const width = mmToPx(el.width, displayZoom);
const height = mmToPx(el.height, displayZoom);
return (
<div
className="absolute pointer-events-none border border-dashed border-[#31a8ff]/40 resize-float-placeholder"
key={el.id}
className="absolute pointer-events-none"
style={{
left: `${mmToPx(el.x, displayZoom)}px`,
top: `${mmToPx(el.y, displayZoom)}px`,
width: `${mmToPx(el.width, displayZoom)}px`,
height: `${mmToPx(el.height, displayZoom)}px`,
left: `${left}px`,
top: `${top}px`,
width: `${width}px`,
height: `${height}px`,
transform: el.rotation ? `rotate(${el.rotation}deg)` : undefined,
transformOrigin: 'center center',
}}
/>
<ElementFloatPreview
element={el}
renderScale={canvasRenderScale}
displayZoom={displayZoom}
variableDefaults={previewDefaults}
/>
</React.Fragment>
))}
>
<ElementFloatPreview
element={el}
renderScale={canvasRenderScale}
displayZoom={displayZoom}
variableDefaults={previewDefaults}
/>
<div className="absolute inset-0 ps-resize-outline z-[2]" aria-hidden />
</div>
);
})}
</div>
)}
</div>,
@@ -1825,6 +1852,9 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
if (isBeingDragged) {
return null;
}
if (isBeingResized && interactionCanvasReady && resizingPreviewElement) {
return null;
}
const overlayElement =
isBeingResized && resizingPreviewElement ? resizingPreviewElement : element;
@@ -1884,6 +1914,10 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
</div>
)}
{isBeingResized && (
<div className="absolute inset-0 ps-resize-outline pointer-events-none z-[60]" aria-hidden />
)}
{showResizeHandles && (
<>
<div

View File

@@ -8,6 +8,7 @@ import {
removeUnusedTemplateVariable,
resolveVisibilityRules,
toggleVisibilityRuleEnabled,
withContentAndSyncedName,
} from '../utils';
import { VisibilityRuleDialog } from './VisibilityRuleDialog';
import { VisibilityRuleSummary } from './VisibilityRuleSummary';
@@ -55,7 +56,6 @@ import {
Layers,
Trash2,
Copy,
ClipboardPaste,
AlignHorizontalJustifyStart,
AlignHorizontalJustifyCenter,
AlignHorizontalJustifyEnd,
@@ -209,6 +209,8 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
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);
@@ -278,9 +280,10 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
const maxLabelH = Math.max(10, paperH - paper.marginTop - paper.marginBottom);
const handleElemChange = (updatedElem: TemplateElement) => {
const current = templateRef.current;
onChangeTemplate({
...template,
elements: template.elements.map((el) => (el.id === updatedElem.id ? updatedElem : el)),
...current,
elements: current.elements.map((el) => (el.id === updatedElem.id ? updatedElem : el)),
});
};
@@ -292,6 +295,11 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
}
};
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
@@ -424,7 +432,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
: selectedElem.content.trim()
? `${selectedElem.content}${selectedElem.content.endsWith(' ') ? '' : ' '}${token}`
: token;
handleElemChange({ ...selectedElem, content });
handleContentChange(content);
};
const handleAddTemplateVariable = () => {
@@ -565,6 +573,9 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
};
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 };
@@ -572,6 +583,12 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
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) => {
@@ -589,8 +606,8 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
if (!confirmed) return;
onChangeTemplate({
...template,
elements: template.elements.filter((item) => item.id !== id),
...templateRef.current,
elements: templateRef.current.elements.filter((item) => item.id !== id),
});
if (onSelectElements && selectedElementIds.includes(id)) {
onSelectElements(selectedElementIds.filter((selectedId) => selectedId !== id));
@@ -621,9 +638,10 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
});
if (!confirmed) return;
const current = templateRef.current;
const idSet = new Set(deletableSelectedIds);
const remaining = template.elements.filter((el) => !idSet.has(el.id));
onChangeTemplate({ ...template, elements: remaining });
const remaining = current.elements.filter((el) => !idSet.has(el.id));
onChangeTemplate({ ...current, elements: remaining });
onSelectElements?.(selectedElementIds.filter((id) => remaining.some((el) => el.id === id)));
};
@@ -656,8 +674,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () =>
handleElemChange({ ...selectedElem, content: reader.result as string });
reader.onload = () => handleContentChange(reader.result as string);
reader.readAsDataURL(file);
e.target.value = '';
}}
@@ -672,7 +689,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
<button
type="button"
disabled={!selectedElem.content}
onClick={() => handleElemChange({ ...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"
>
@@ -680,7 +697,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
</div>
<PropTextarea
value={selectedElem.content}
onCommit={(val) => handleElemChange({ ...selectedElem, content: val })}
onCommit={(val) => handleContentChange(val)}
rows={selectedElem.type === 'image' ? 3 : 2}
className="ps-field resize-none"
placeholder={
@@ -1123,7 +1140,13 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
<PropInput
type="text"
value={selectedElem.name}
onCommit={(val) => handleElemChange({ ...selectedElem, name: val || '未命名元素' })}
onCommit={(val) =>
handleElemChange({
...selectedElem,
name: val || '未命名元素',
nameCustomized: true,
})
}
className="ps-field mt-2"
/>
</div>
@@ -2146,12 +2169,12 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
}`}
>
<div className="flex items-center gap-2 min-w-0 flex-1 pointer-events-none">
<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]" />}
{elem.type === 'table' && <Table2 className="w-3 h-3 text-[#666]" />}
<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}
@@ -2159,7 +2182,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
</div>
<div
className={`flex items-center gap-0.5 shrink-0 pointer-events-auto ${isNarrowScreen || variant === 'mobile' || isLocked
className={`ps-layer-item-actions pointer-events-auto ${isNarrowScreen || variant === 'mobile' || isLocked
? 'opacity-100'
: 'opacity-0 group-hover:opacity-100 transition-opacity'
}`}
@@ -2171,18 +2194,16 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
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" />
<ArrowUp />
</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" />
<ArrowDown />
</button>
</>
)}
@@ -2190,18 +2211,18 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
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]'}`}
className={isLocked ? 'text-amber-400' : 'text-[#666] hover:text-[#ccc]'}
>
{isLocked ? <Lock className="w-3 h-3" /> : <Unlock className="w-3 h-3" />}
{isLocked ? <Lock /> : <Unlock />}
</button>
{!isLocked && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); void deleteLayer(elem.id); }}
title="删除"
className="p-0.5 hover:text-red-400 text-[#666] cursor-pointer"
className="hover:text-red-400 text-[#666]"
>
<Trash2 className="w-3 h-3" />
<Trash2 />
</button>
)}
</div>
@@ -2316,25 +2337,13 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
</button>
))}
<div className="mobile-design-props-nav-spacer" aria-hidden />
{elementClipboard?.canPaste && (
<button
type="button"
onClick={() => elementClipboard.pasteClipboardElements()}
className="mobile-design-props-nav-btn"
aria-label="粘贴元素"
title={`粘贴 ${elementClipboard.clipboardCount} 个元素`}
>
<ClipboardPaste className="w-4 h-4" />
<span></span>
</button>
)}
{elementClipboard?.canCopy && (
<button
type="button"
onClick={() => elementClipboard.copySelectedElements()}
onClick={() => elementClipboard.copyAndPasteSelectedElements()}
className="mobile-design-props-nav-btn"
aria-label="复制选中元素"
title="复制选中元素"
title="复制选中元素并创建副本"
>
<Copy className="w-4 h-4" />
<span></span>

View File

@@ -113,9 +113,36 @@ export const VisibilityRuleForm: React.FC<VisibilityRuleFormProps> = ({
type="text"
value={rule.value ?? ''}
onChange={(e) => onChange({ value: e.target.value })}
placeholder="比较目标值"
placeholder="比较目标值,可使用 {变量名}"
className="ps-field font-mono text-[11px] w-full mt-0.5"
/>
{variableOptions.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1.5">
{variableOptions.map((v) => (
<button
key={v}
type="button"
onClick={() => {
const token = `{${v}}`;
const current = rule.value ?? '';
onChange({
value: current.includes(token)
? current
: current
? `${current}${current.endsWith(' ') ? '' : ' '}${token}`
: token,
});
}}
className="ps-btn text-[9px] font-mono"
>
{`{${v}}`}
</button>
))}
</div>
)}
<p className="text-[9px] text-[#666] mt-1 leading-relaxed">
{`{变量名}`}
</p>
</div>
)}
</div>