优化修复
This commit is contained in:
53
src/App.tsx
53
src/App.tsx
@@ -198,25 +198,35 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Modify active template elements
|
// Modify active template elements
|
||||||
const handleTemplateChange = (updated: LabelTemplate) => {
|
const activeTemplateRef = useRef(activeTemplate);
|
||||||
const updatedList = templates.map(t => t.id === updated.id ? updated : t);
|
activeTemplateRef.current = activeTemplate;
|
||||||
saveTemplatesToStorage(updatedList);
|
|
||||||
};
|
const handleTemplateChange = useCallback((updated: LabelTemplate) => {
|
||||||
|
setTemplates((prev) => {
|
||||||
|
const updatedList = prev.map((t) => (t.id === updated.id ? updated : t));
|
||||||
|
try {
|
||||||
|
const stripped = updatedList.map(stripTemplateForStorage);
|
||||||
|
localStorage.setItem('label_templates_list_v2', JSON.stringify(stripped));
|
||||||
|
} catch (e) {}
|
||||||
|
return updatedList;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleDesignTemplateChange = useCallback(
|
const handleDesignTemplateChange = useCallback(
|
||||||
(updated: LabelTemplate) => {
|
(updated: LabelTemplate) => {
|
||||||
|
const current = activeTemplateRef.current;
|
||||||
if (
|
if (
|
||||||
!skipDesignHistoryRef.current &&
|
!skipDesignHistoryRef.current &&
|
||||||
workflowStep === 'template-design' &&
|
workflowStep === 'template-design' &&
|
||||||
activeTemplate &&
|
current &&
|
||||||
updated.id === activeTemplate.id
|
updated.id === current.id
|
||||||
) {
|
) {
|
||||||
designHistory.record(activeTemplate, updated);
|
designHistory.record(current, updated);
|
||||||
}
|
}
|
||||||
skipDesignHistoryRef.current = false;
|
skipDesignHistoryRef.current = false;
|
||||||
handleTemplateChange(updated);
|
handleTemplateChange(updated);
|
||||||
},
|
},
|
||||||
[workflowStep, activeTemplate, designHistory, templates]
|
[workflowStep, designHistory, handleTemplateChange]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDesignUndo = useCallback(() => {
|
const handleDesignUndo = useCallback(() => {
|
||||||
@@ -257,6 +267,13 @@ export default function App() {
|
|||||||
[showToast]
|
[showToast]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleDuplicateElementsSuccess = useCallback(
|
||||||
|
(count: number) => {
|
||||||
|
showToast(`已创建 ${count} 个副本`, { variant: 'success' });
|
||||||
|
},
|
||||||
|
[showToast]
|
||||||
|
);
|
||||||
|
|
||||||
const elementClipboard = useElementClipboard({
|
const elementClipboard = useElementClipboard({
|
||||||
template: activeTemplate ?? EMPTY_TEMPLATE_PLACEHOLDER,
|
template: activeTemplate ?? EMPTY_TEMPLATE_PLACEHOLDER,
|
||||||
onChange: handleDesignTemplateChange,
|
onChange: handleDesignTemplateChange,
|
||||||
@@ -264,6 +281,7 @@ export default function App() {
|
|||||||
onSelectElements: handleSelectElements,
|
onSelectElements: handleSelectElements,
|
||||||
onSelectTableCells: setSelectedTableCells,
|
onSelectTableCells: setSelectedTableCells,
|
||||||
onCopySuccess: handleCopyElementsSuccess,
|
onCopySuccess: handleCopyElementsSuccess,
|
||||||
|
onDuplicateSuccess: handleDuplicateElementsSuccess,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -393,24 +411,7 @@ export default function App() {
|
|||||||
name: `${newTmplName}`,
|
name: `${newTmplName}`,
|
||||||
width: Math.max(10, Number(newTmplW) || 70),
|
width: Math.max(10, Number(newTmplW) || 70),
|
||||||
height: Math.max(10, Number(newTmplH) || 40),
|
height: Math.max(10, Number(newTmplH) || 40),
|
||||||
elements: [
|
elements: [],
|
||||||
{
|
|
||||||
id: `desc_welcome`,
|
|
||||||
type: 'text',
|
|
||||||
name: '主文字内容',
|
|
||||||
x: 5,
|
|
||||||
y: 5,
|
|
||||||
width: Math.max(10, newTmplW - 10),
|
|
||||||
height: 10,
|
|
||||||
content: '{变量}',
|
|
||||||
fontSize: 12,
|
|
||||||
textAlign: 'center',
|
|
||||||
fontWeight: 'bold',
|
|
||||||
barcodeFormat: 'CODE128',
|
|
||||||
showText: false,
|
|
||||||
fontFamily: 'sans'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
paper: DEFAULT_PAPER_CONFIG,
|
paper: DEFAULT_PAPER_CONFIG,
|
||||||
cutLine: DEFAULT_CUT_LINE_CONFIG,
|
cutLine: DEFAULT_CUT_LINE_CONFIG,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { mmToPx } from '../utils';
|
|||||||
import { renderLabelToDataUrl } from '../labelRenderer';
|
import { renderLabelToDataUrl } from '../labelRenderer';
|
||||||
|
|
||||||
/** 离屏单元素渲染时预留描边空间,避免画布边缘裁切表格线 */
|
/** 离屏单元素渲染时预留描边空间,避免画布边缘裁切表格线 */
|
||||||
function getFloatPreviewPaddingMm(element: TemplateElement): number {
|
export function getFloatPreviewPaddingMm(element: TemplateElement): number {
|
||||||
if (element.type !== 'table') return 0;
|
if (element.type !== 'table') return 0;
|
||||||
return (element.tableBorderWidth ?? 0.2) / 2 + 0.1;
|
return (element.tableBorderWidth ?? 0.2) / 2 + 0.1;
|
||||||
}
|
}
|
||||||
@@ -74,10 +74,10 @@ export const ElementFloatPreview: React.FC<ElementFloatPreviewProps> = ({
|
|||||||
};
|
};
|
||||||
}, [elementVisualKey, element, renderScale, variableDefaults, dataUrlProp]);
|
}, [elementVisualKey, element, renderScale, variableDefaults, dataUrlProp]);
|
||||||
|
|
||||||
const left = mmToPx(element.x, displayZoom);
|
const padMm = getFloatPreviewPaddingMm(element);
|
||||||
const top = mmToPx(element.y, displayZoom);
|
const padPx = mmToPx(padMm, displayZoom);
|
||||||
const width = mmToPx(element.width, displayZoom);
|
const boxW = mmToPx(element.width, displayZoom);
|
||||||
const height = mmToPx(element.height, displayZoom);
|
const boxH = mmToPx(element.height, displayZoom);
|
||||||
|
|
||||||
if (!dataUrl) return null;
|
if (!dataUrl) return null;
|
||||||
|
|
||||||
@@ -87,18 +87,12 @@ export const ElementFloatPreview: React.FC<ElementFloatPreviewProps> = ({
|
|||||||
alt=""
|
alt=""
|
||||||
className="absolute pointer-events-none select-none"
|
className="absolute pointer-events-none select-none"
|
||||||
style={{
|
style={{
|
||||||
left: `${left}px`,
|
left: `${-padPx}px`,
|
||||||
top: `${top}px`,
|
top: `${-padPx}px`,
|
||||||
width: `${width}px`,
|
width: `${boxW + padPx * 2}px`,
|
||||||
height: `${height}px`,
|
height: `${boxH + padPx * 2}px`,
|
||||||
imageRendering: 'auto',
|
imageRendering: 'auto',
|
||||||
}}
|
}}
|
||||||
onLoad={(e) => {
|
|
||||||
const placeholder = e.currentTarget.previousElementSibling as HTMLElement | null;
|
|
||||||
if (placeholder?.classList.contains('resize-float-placeholder')) {
|
|
||||||
placeholder.style.display = 'none';
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
} from '../tableUtils';
|
} from '../tableUtils';
|
||||||
import { useIsNarrowScreen } from '../hooks/useIsMobile';
|
import { useIsNarrowScreen } from '../hooks/useIsMobile';
|
||||||
import { CanvasLabelImage } from './CanvasLabelImage';
|
import { CanvasLabelImage } from './CanvasLabelImage';
|
||||||
import { ElementFloatPreview, renderElementFloatPreview } from './ElementFloatPreview';
|
import { ElementFloatPreview, getFloatPreviewPaddingMm, renderElementFloatPreview } from './ElementFloatPreview';
|
||||||
import { renderLabelToDataUrl } from '../labelRenderer';
|
import { renderLabelToDataUrl } from '../labelRenderer';
|
||||||
import { useAppDialog } from './AppDialog';
|
import { useAppDialog } from './AppDialog';
|
||||||
import { MobileMainToolButtons, type MobileToolButtonItem } from './MobileMainToolButtons';
|
import { MobileMainToolButtons, type MobileToolButtonItem } from './MobileMainToolButtons';
|
||||||
@@ -318,16 +318,19 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
selectedIdsRef.current = selectedElementIds;
|
selectedIdsRef.current = selectedElementIds;
|
||||||
const templateElementsRef = useRef(template.elements);
|
const templateElementsRef = useRef(template.elements);
|
||||||
templateElementsRef.current = template.elements;
|
templateElementsRef.current = template.elements;
|
||||||
|
const templateRef = useRef(template);
|
||||||
|
templateRef.current = template;
|
||||||
|
|
||||||
const addImageElement = useCallback(
|
const addImageElement = useCallback(
|
||||||
(src: string = '') => {
|
(src: string = '') => {
|
||||||
|
const current = templateRef.current;
|
||||||
const id = `image_${Date.now()}`;
|
const id = `image_${Date.now()}`;
|
||||||
const n = template.elements.length + 1;
|
const n = current.elements.length + 1;
|
||||||
const newElement: TemplateElement = {
|
const newElement: TemplateElement = {
|
||||||
id,
|
id,
|
||||||
type: 'image',
|
type: 'image',
|
||||||
name: `图片 ${n}`,
|
name: `图片 ${n}`,
|
||||||
...centerElementOnLabel(template.width, template.height, 30, 30),
|
...centerElementOnLabel(current.width, current.height, 30, 30),
|
||||||
width: 30,
|
width: 30,
|
||||||
height: 30,
|
height: 30,
|
||||||
content: src,
|
content: src,
|
||||||
@@ -342,20 +345,21 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
imageFit: 'contain',
|
imageFit: 'contain',
|
||||||
};
|
};
|
||||||
onChange({
|
onChange({
|
||||||
...template,
|
...current,
|
||||||
elements: [...template.elements, newElement],
|
elements: [...current.elements, newElement],
|
||||||
});
|
});
|
||||||
onSelectElements([id]);
|
onSelectElements([id]);
|
||||||
setActiveTool('move');
|
setActiveTool('move');
|
||||||
},
|
},
|
||||||
[template, onChange, onSelectElements]
|
[onChange, onSelectElements]
|
||||||
);
|
);
|
||||||
|
|
||||||
const addElement = useCallback(
|
const addElement = useCallback(
|
||||||
(type: 'text' | 'barcode' | 'qrcode' | 'table') => {
|
(type: 'text' | 'barcode' | 'qrcode' | 'table') => {
|
||||||
|
const current = templateRef.current;
|
||||||
let newElement: TemplateElement;
|
let newElement: TemplateElement;
|
||||||
const id = `${type}_${Date.now()}`;
|
const id = `${type}_${Date.now()}`;
|
||||||
const n = template.elements.length + 1;
|
const n = current.elements.length + 1;
|
||||||
|
|
||||||
if (type === 'table') {
|
if (type === 'table') {
|
||||||
newElement = createDefaultTableElement(id, n);
|
newElement = createDefaultTableElement(id, n);
|
||||||
@@ -421,24 +425,25 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const centered = centerElementOnLabel(
|
const centered = centerElementOnLabel(
|
||||||
template.width,
|
current.width,
|
||||||
template.height,
|
current.height,
|
||||||
newElement.width,
|
newElement.width,
|
||||||
newElement.height
|
newElement.height
|
||||||
);
|
);
|
||||||
newElement = { ...newElement, ...centered };
|
newElement = { ...newElement, ...centered };
|
||||||
|
|
||||||
onChange({
|
onChange({
|
||||||
...template,
|
...current,
|
||||||
elements: [...template.elements, newElement],
|
elements: [...current.elements, newElement],
|
||||||
});
|
});
|
||||||
onSelectElements([id]);
|
onSelectElements([id]);
|
||||||
setActiveTool('move');
|
setActiveTool('move');
|
||||||
},
|
},
|
||||||
[template, onChange, onSelectElements]
|
[onChange, onSelectElements]
|
||||||
);
|
);
|
||||||
|
|
||||||
const deleteSelectedElements = useCallback(async () => {
|
const deleteSelectedElements = useCallback(async () => {
|
||||||
|
const current = templateRef.current;
|
||||||
const ids =
|
const ids =
|
||||||
selectedElementIds.length > 0
|
selectedElementIds.length > 0
|
||||||
? selectedElementIds
|
? selectedElementIds
|
||||||
@@ -446,14 +451,14 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
? [selectedElementId]
|
? [selectedElementId]
|
||||||
: [];
|
: [];
|
||||||
const deletable = ids.filter((id) => {
|
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;
|
return el && !el.locked;
|
||||||
});
|
});
|
||||||
if (deletable.length === 0) return;
|
if (deletable.length === 0) return;
|
||||||
|
|
||||||
const message =
|
const message =
|
||||||
deletable.length === 1
|
deletable.length === 1
|
||||||
? `确定删除「${template.elements.find((item) => item.id === deletable[0])?.name ?? '未命名'}」吗?此操作无法撤销。`
|
? `确定删除「${current.elements.find((item) => item.id === deletable[0])?.name ?? '未命名'}」吗?此操作无法撤销。`
|
||||||
: `确定删除选中的 ${deletable.length} 个元素吗?此操作无法撤销。`;
|
: `确定删除选中的 ${deletable.length} 个元素吗?此操作无法撤销。`;
|
||||||
|
|
||||||
const confirmed = await showConfirm(message, {
|
const confirmed = await showConfirm(message, {
|
||||||
@@ -464,10 +469,10 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
|
|
||||||
const idSet = new Set(deletable);
|
const idSet = new Set(deletable);
|
||||||
const remaining = template.elements.filter((el) => !idSet.has(el.id));
|
const remaining = current.elements.filter((el) => !idSet.has(el.id));
|
||||||
onChange({ ...template, elements: remaining });
|
onChange({ ...templateRef.current, elements: remaining });
|
||||||
onSelectElements(selectedElementIds.filter((id) => remaining.some((el) => el.id === id)));
|
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) => {
|
const handleToolClick = (tool: ActiveTool) => {
|
||||||
if (tool === 'move') {
|
if (tool === 'move') {
|
||||||
@@ -505,7 +510,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'a') {
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'a') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setActiveTool('move');
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'c') {
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'c') {
|
||||||
@@ -541,9 +546,10 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
|
|
||||||
const idSet = new Set(moveIds);
|
const idSet = new Set(moveIds);
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
const current = templateRef.current;
|
||||||
onChange({
|
onChange({
|
||||||
...template,
|
...current,
|
||||||
elements: template.elements.map((el) => {
|
elements: current.elements.map((el) => {
|
||||||
if (!idSet.has(el.id) || el.locked) return el;
|
if (!idSet.has(el.id) || el.locked) return el;
|
||||||
return {
|
return {
|
||||||
...el,
|
...el,
|
||||||
@@ -1036,7 +1042,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
const shouldCommit =
|
const shouldCommit =
|
||||||
dragState.type === 'resize' || dragMovedRef.current;
|
dragState.type === 'resize' || dragMovedRef.current;
|
||||||
if (shouldCommit) {
|
if (shouldCommit) {
|
||||||
onChange({ ...template, elements: localElementsRef.current });
|
onChange({ ...templateRef.current, elements: localElementsRef.current });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
localElementsRef.current = null;
|
localElementsRef.current = null;
|
||||||
@@ -1474,8 +1480,8 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
onClick: handleImageToolClick,
|
onClick: handleImageToolClick,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- tools 为静态配置,随 activeTool 更新即可
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- tools 为静态配置
|
||||||
[activeTool]
|
[activeTool, handleToolClick, handleImageToolClick]
|
||||||
);
|
);
|
||||||
|
|
||||||
const mainToolButtons = (
|
const mainToolButtons = (
|
||||||
@@ -1582,15 +1588,22 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
willChange: 'transform',
|
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
|
<div
|
||||||
key={el.id}
|
key={el.id}
|
||||||
className="absolute pointer-events-none"
|
className="absolute pointer-events-none"
|
||||||
style={{
|
style={{
|
||||||
left: `${mmToPx(el.x, displayZoom)}px`,
|
left: `${mmToPx(el.x, displayZoom)}px`,
|
||||||
top: `${mmToPx(el.y, displayZoom)}px`,
|
top: `${mmToPx(el.y, displayZoom)}px`,
|
||||||
width: `${mmToPx(el.width, displayZoom)}px`,
|
width: `${boxW}px`,
|
||||||
height: `${mmToPx(el.height, displayZoom)}px`,
|
height: `${boxH}px`,
|
||||||
|
transform: el.rotation ? `rotate(${el.rotation}deg)` : undefined,
|
||||||
|
transformOrigin: 'center center',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -1615,7 +1628,13 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
alt=""
|
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) => {
|
onLoad={(e) => {
|
||||||
const img = e.currentTarget;
|
const img = e.currentTarget;
|
||||||
img.style.opacity = '1';
|
img.style.opacity = '1';
|
||||||
@@ -1624,7 +1643,8 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{resizeFloatElements && resizeFloatElements.length > 0 && (
|
{resizeFloatElements && resizeFloatElements.length > 0 && (
|
||||||
@@ -1632,27 +1652,34 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
className="absolute inset-0 pointer-events-none"
|
className="absolute inset-0 pointer-events-none"
|
||||||
style={{ overflow: 'visible' }}
|
style={{ overflow: 'visible' }}
|
||||||
>
|
>
|
||||||
{resizeFloatElements.map((el) => (
|
{resizeFloatElements.map((el) => {
|
||||||
<React.Fragment key={el.id}>
|
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
|
<div
|
||||||
className="absolute pointer-events-none border border-dashed border-[#31a8ff]/40 resize-float-placeholder"
|
key={el.id}
|
||||||
|
className="absolute pointer-events-none"
|
||||||
style={{
|
style={{
|
||||||
left: `${mmToPx(el.x, displayZoom)}px`,
|
left: `${left}px`,
|
||||||
top: `${mmToPx(el.y, displayZoom)}px`,
|
top: `${top}px`,
|
||||||
width: `${mmToPx(el.width, displayZoom)}px`,
|
width: `${width}px`,
|
||||||
height: `${mmToPx(el.height, displayZoom)}px`,
|
height: `${height}px`,
|
||||||
transform: el.rotation ? `rotate(${el.rotation}deg)` : undefined,
|
transform: el.rotation ? `rotate(${el.rotation}deg)` : undefined,
|
||||||
transformOrigin: 'center center',
|
transformOrigin: 'center center',
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
<ElementFloatPreview
|
<ElementFloatPreview
|
||||||
element={el}
|
element={el}
|
||||||
renderScale={canvasRenderScale}
|
renderScale={canvasRenderScale}
|
||||||
displayZoom={displayZoom}
|
displayZoom={displayZoom}
|
||||||
variableDefaults={previewDefaults}
|
variableDefaults={previewDefaults}
|
||||||
/>
|
/>
|
||||||
</React.Fragment>
|
<div className="absolute inset-0 ps-resize-outline z-[2]" aria-hidden />
|
||||||
))}
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>,
|
</div>,
|
||||||
@@ -1825,6 +1852,9 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
if (isBeingDragged) {
|
if (isBeingDragged) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (isBeingResized && interactionCanvasReady && resizingPreviewElement) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const overlayElement =
|
const overlayElement =
|
||||||
isBeingResized && resizingPreviewElement ? resizingPreviewElement : element;
|
isBeingResized && resizingPreviewElement ? resizingPreviewElement : element;
|
||||||
@@ -1884,6 +1914,10 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isBeingResized && (
|
||||||
|
<div className="absolute inset-0 ps-resize-outline pointer-events-none z-[60]" aria-hidden />
|
||||||
|
)}
|
||||||
|
|
||||||
{showResizeHandles && (
|
{showResizeHandles && (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
removeUnusedTemplateVariable,
|
removeUnusedTemplateVariable,
|
||||||
resolveVisibilityRules,
|
resolveVisibilityRules,
|
||||||
toggleVisibilityRuleEnabled,
|
toggleVisibilityRuleEnabled,
|
||||||
|
withContentAndSyncedName,
|
||||||
} from '../utils';
|
} from '../utils';
|
||||||
import { VisibilityRuleDialog } from './VisibilityRuleDialog';
|
import { VisibilityRuleDialog } from './VisibilityRuleDialog';
|
||||||
import { VisibilityRuleSummary } from './VisibilityRuleSummary';
|
import { VisibilityRuleSummary } from './VisibilityRuleSummary';
|
||||||
@@ -55,7 +56,6 @@ import {
|
|||||||
Layers,
|
Layers,
|
||||||
Trash2,
|
Trash2,
|
||||||
Copy,
|
Copy,
|
||||||
ClipboardPaste,
|
|
||||||
AlignHorizontalJustifyStart,
|
AlignHorizontalJustifyStart,
|
||||||
AlignHorizontalJustifyCenter,
|
AlignHorizontalJustifyCenter,
|
||||||
AlignHorizontalJustifyEnd,
|
AlignHorizontalJustifyEnd,
|
||||||
@@ -209,6 +209,8 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
elementClipboard,
|
elementClipboard,
|
||||||
}) => {
|
}) => {
|
||||||
const { showConfirm } = useAppDialog();
|
const { showConfirm } = useAppDialog();
|
||||||
|
const templateRef = useRef(template);
|
||||||
|
templateRef.current = template;
|
||||||
const isNarrowScreen = useIsNarrowScreen();
|
const isNarrowScreen = useIsNarrowScreen();
|
||||||
const selectedElementId = selectedElementIds[0] || null;
|
const selectedElementId = selectedElementIds[0] || null;
|
||||||
const selectedElem = template.elements.find((el) => el.id === selectedElementId);
|
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 maxLabelH = Math.max(10, paperH - paper.marginTop - paper.marginBottom);
|
||||||
|
|
||||||
const handleElemChange = (updatedElem: TemplateElement) => {
|
const handleElemChange = (updatedElem: TemplateElement) => {
|
||||||
|
const current = templateRef.current;
|
||||||
onChangeTemplate({
|
onChangeTemplate({
|
||||||
...template,
|
...current,
|
||||||
elements: template.elements.map((el) => (el.id === updatedElem.id ? updatedElem : el)),
|
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 nudgeableElementIds = useMemo(() => {
|
||||||
const idSet = new Set(selectedElementIds);
|
const idSet = new Set(selectedElementIds);
|
||||||
return template.elements
|
return template.elements
|
||||||
@@ -424,7 +432,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
: selectedElem.content.trim()
|
: selectedElem.content.trim()
|
||||||
? `${selectedElem.content}${selectedElem.content.endsWith(' ') ? '' : ' '}${token}`
|
? `${selectedElem.content}${selectedElem.content.endsWith(' ') ? '' : ' '}${token}`
|
||||||
: token;
|
: token;
|
||||||
handleElemChange({ ...selectedElem, content });
|
handleContentChange(content);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddTemplateVariable = () => {
|
const handleAddTemplateVariable = () => {
|
||||||
@@ -565,6 +573,9 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const toggleLockLayer = (idx: number) => {
|
const toggleLockLayer = (idx: number) => {
|
||||||
|
const target = template.elements[idx];
|
||||||
|
if (!target) return;
|
||||||
|
const willLock = !target.locked;
|
||||||
const list = template.elements.map((el, i) => {
|
const list = template.elements.map((el, i) => {
|
||||||
if (i === idx) {
|
if (i === idx) {
|
||||||
return { ...el, locked: !el.locked };
|
return { ...el, locked: !el.locked };
|
||||||
@@ -572,6 +583,12 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
return el;
|
return el;
|
||||||
});
|
});
|
||||||
onChangeTemplate({ ...template, elements: list });
|
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 deleteLayer = async (id: string) => {
|
||||||
@@ -589,8 +606,8 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
|
|
||||||
onChangeTemplate({
|
onChangeTemplate({
|
||||||
...template,
|
...templateRef.current,
|
||||||
elements: template.elements.filter((item) => item.id !== id),
|
elements: templateRef.current.elements.filter((item) => item.id !== id),
|
||||||
});
|
});
|
||||||
if (onSelectElements && selectedElementIds.includes(id)) {
|
if (onSelectElements && selectedElementIds.includes(id)) {
|
||||||
onSelectElements(selectedElementIds.filter((selectedId) => selectedId !== id));
|
onSelectElements(selectedElementIds.filter((selectedId) => selectedId !== id));
|
||||||
@@ -621,9 +638,10 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
});
|
});
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
const current = templateRef.current;
|
||||||
const idSet = new Set(deletableSelectedIds);
|
const idSet = new Set(deletableSelectedIds);
|
||||||
const remaining = template.elements.filter((el) => !idSet.has(el.id));
|
const remaining = current.elements.filter((el) => !idSet.has(el.id));
|
||||||
onChangeTemplate({ ...template, elements: remaining });
|
onChangeTemplate({ ...current, elements: remaining });
|
||||||
onSelectElements?.(selectedElementIds.filter((id) => remaining.some((el) => el.id === id)));
|
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];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = () =>
|
reader.onload = () => handleContentChange(reader.result as string);
|
||||||
handleElemChange({ ...selectedElem, content: reader.result as string });
|
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
}}
|
}}
|
||||||
@@ -672,7 +689,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!selectedElem.content}
|
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"
|
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>
|
</div>
|
||||||
<PropTextarea
|
<PropTextarea
|
||||||
value={selectedElem.content}
|
value={selectedElem.content}
|
||||||
onCommit={(val) => handleElemChange({ ...selectedElem, content: val })}
|
onCommit={(val) => handleContentChange(val)}
|
||||||
rows={selectedElem.type === 'image' ? 3 : 2}
|
rows={selectedElem.type === 'image' ? 3 : 2}
|
||||||
className="ps-field resize-none"
|
className="ps-field resize-none"
|
||||||
placeholder={
|
placeholder={
|
||||||
@@ -1123,7 +1140,13 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
<PropInput
|
<PropInput
|
||||||
type="text"
|
type="text"
|
||||||
value={selectedElem.name}
|
value={selectedElem.name}
|
||||||
onCommit={(val) => handleElemChange({ ...selectedElem, name: val || '未命名元素' })}
|
onCommit={(val) =>
|
||||||
|
handleElemChange({
|
||||||
|
...selectedElem,
|
||||||
|
name: val || '未命名元素',
|
||||||
|
nameCustomized: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
className="ps-field mt-2"
|
className="ps-field mt-2"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<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">
|
<span className="ps-layer-item-icon">
|
||||||
{elem.type === 'text' && <Type className="w-3 h-3 text-[#666]" />}
|
{elem.type === 'text' && <Type />}
|
||||||
{elem.type === 'barcode' && <Barcode className="w-3 h-3 text-[#666]" />}
|
{elem.type === 'barcode' && <Barcode />}
|
||||||
{elem.type === 'qrcode' && <QrCode className="w-3 h-3 text-[#666]" />}
|
{elem.type === 'qrcode' && <QrCode />}
|
||||||
{elem.type === 'image' && <ImageIcon className="w-3 h-3 text-[#666]" />}
|
{elem.type === 'image' && <ImageIcon />}
|
||||||
{elem.type === 'table' && <Table2 className="w-3 h-3 text-[#666]" />}
|
{elem.type === 'table' && <Table2 />}
|
||||||
</span>
|
</span>
|
||||||
<span className="truncate" title={elem.name}>
|
<span className="truncate" title={elem.name}>
|
||||||
{elem.name}
|
{elem.name}
|
||||||
@@ -2159,7 +2182,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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-100'
|
||||||
: 'opacity-0 group-hover:opacity-100 transition-opacity'
|
: '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'); }}
|
onClick={(e) => { e.stopPropagation(); moveLayer(actualIdx, 'up'); }}
|
||||||
disabled={actualIdx === template.elements.length - 1}
|
disabled={actualIdx === template.elements.length - 1}
|
||||||
title="上移"
|
title="上移"
|
||||||
className="p-0.5 hover:bg-[#555] disabled:opacity-20 cursor-pointer text-[#aaa]"
|
|
||||||
>
|
>
|
||||||
<ArrowUp className="w-3 h-3" />
|
<ArrowUp />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => { e.stopPropagation(); moveLayer(actualIdx, 'down'); }}
|
onClick={(e) => { e.stopPropagation(); moveLayer(actualIdx, 'down'); }}
|
||||||
disabled={actualIdx === 0}
|
disabled={actualIdx === 0}
|
||||||
title="下移"
|
title="下移"
|
||||||
className="p-0.5 hover:bg-[#555] disabled:opacity-20 cursor-pointer text-[#aaa]"
|
|
||||||
>
|
>
|
||||||
<ArrowDown className="w-3 h-3" />
|
<ArrowDown />
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -2190,18 +2211,18 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => { e.stopPropagation(); toggleLockLayer(actualIdx); }}
|
onClick={(e) => { e.stopPropagation(); toggleLockLayer(actualIdx); }}
|
||||||
title={isLocked ? '解锁' : '锁定'}
|
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>
|
</button>
|
||||||
{!isLocked && (
|
{!isLocked && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => { e.stopPropagation(); void deleteLayer(elem.id); }}
|
onClick={(e) => { e.stopPropagation(); void deleteLayer(elem.id); }}
|
||||||
title="删除"
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -2316,25 +2337,13 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<div className="mobile-design-props-nav-spacer" aria-hidden />
|
<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 && (
|
{elementClipboard?.canCopy && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => elementClipboard.copySelectedElements()}
|
onClick={() => elementClipboard.copyAndPasteSelectedElements()}
|
||||||
className="mobile-design-props-nav-btn"
|
className="mobile-design-props-nav-btn"
|
||||||
aria-label="复制选中元素"
|
aria-label="复制选中元素"
|
||||||
title="复制选中元素"
|
title="复制选中元素并创建副本"
|
||||||
>
|
>
|
||||||
<Copy className="w-4 h-4" />
|
<Copy className="w-4 h-4" />
|
||||||
<span>复制</span>
|
<span>复制</span>
|
||||||
|
|||||||
@@ -113,9 +113,36 @@ export const VisibilityRuleForm: React.FC<VisibilityRuleFormProps> = ({
|
|||||||
type="text"
|
type="text"
|
||||||
value={rule.value ?? ''}
|
value={rule.value ?? ''}
|
||||||
onChange={(e) => onChange({ value: e.target.value })}
|
onChange={(e) => onChange({ value: e.target.value })}
|
||||||
placeholder="比较目标值"
|
placeholder="比较目标值,可使用 {变量名}"
|
||||||
className="ps-field font-mono text-[11px] w-full mt-0.5"
|
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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useRef, useState } from 'react';
|
||||||
import type { LabelTemplate, TemplateElement } from '../types';
|
import type { LabelTemplate, TemplateElement } from '../types';
|
||||||
import type { TableCellCoord } from '../tableUtils';
|
import type { TableCellCoord } from '../tableUtils';
|
||||||
import { cloneTemplateElementsForPaste } from '../utils';
|
import { cloneTemplateElementsForPaste } from '../utils';
|
||||||
@@ -21,11 +21,14 @@ export interface UseElementClipboardOptions {
|
|||||||
onSelectElements: (ids: string[]) => void;
|
onSelectElements: (ids: string[]) => void;
|
||||||
onSelectTableCells?: (cells: TableCellCoord[]) => void;
|
onSelectTableCells?: (cells: TableCellCoord[]) => void;
|
||||||
onCopySuccess?: (count: number) => void;
|
onCopySuccess?: (count: number) => void;
|
||||||
|
onDuplicateSuccess?: (count: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ElementClipboardActions {
|
export interface ElementClipboardActions {
|
||||||
copySelectedElements: () => void;
|
copySelectedElements: () => void;
|
||||||
pasteClipboardElements: () => void;
|
pasteClipboardElements: () => void;
|
||||||
|
/** 复制并立即粘贴(移动端快捷复制) */
|
||||||
|
copyAndPasteSelectedElements: () => void;
|
||||||
canCopy: boolean;
|
canCopy: boolean;
|
||||||
canPaste: boolean;
|
canPaste: boolean;
|
||||||
clipboardCount: number;
|
clipboardCount: number;
|
||||||
@@ -38,7 +41,10 @@ export function useElementClipboard({
|
|||||||
onSelectElements,
|
onSelectElements,
|
||||||
onSelectTableCells,
|
onSelectTableCells,
|
||||||
onCopySuccess,
|
onCopySuccess,
|
||||||
|
onDuplicateSuccess,
|
||||||
}: UseElementClipboardOptions): ElementClipboardActions {
|
}: UseElementClipboardOptions): ElementClipboardActions {
|
||||||
|
const templateRef = useRef(template);
|
||||||
|
templateRef.current = template;
|
||||||
const [clipboardCount, setClipboardCount] = useState(getClipboardCount);
|
const [clipboardCount, setClipboardCount] = useState(getClipboardCount);
|
||||||
|
|
||||||
const syncClipboardCount = useCallback((elements: TemplateElement[] | null) => {
|
const syncClipboardCount = useCallback((elements: TemplateElement[] | null) => {
|
||||||
@@ -51,30 +57,55 @@ export function useElementClipboard({
|
|||||||
|
|
||||||
const copySelectedElements = useCallback(() => {
|
const copySelectedElements = useCallback(() => {
|
||||||
if (selectedElementIds.length === 0) return;
|
if (selectedElementIds.length === 0) return;
|
||||||
|
const current = templateRef.current;
|
||||||
const idSet = new Set(selectedElementIds);
|
const idSet = new Set(selectedElementIds);
|
||||||
const toCopy = template.elements.filter((el) => idSet.has(el.id));
|
const toCopy = current.elements.filter((el) => idSet.has(el.id));
|
||||||
if (toCopy.length === 0) return;
|
if (toCopy.length === 0) return;
|
||||||
const cloned = JSON.parse(JSON.stringify(toCopy)) as TemplateElement[];
|
const cloned = JSON.parse(JSON.stringify(toCopy)) as TemplateElement[];
|
||||||
syncClipboardCount(cloned);
|
syncClipboardCount(cloned);
|
||||||
onCopySuccess?.(toCopy.length);
|
onCopySuccess?.(toCopy.length);
|
||||||
}, [selectedElementIds, template.elements, syncClipboardCount, onCopySuccess]);
|
}, [selectedElementIds, syncClipboardCount, onCopySuccess]);
|
||||||
|
|
||||||
const pasteClipboardElements = useCallback(() => {
|
const pasteClipboardElements = useCallback(() => {
|
||||||
const source = globalElementClipboard.elements;
|
const source = globalElementClipboard.elements;
|
||||||
if (!source || source.length === 0) return;
|
if (!source || source.length === 0) return;
|
||||||
|
const current = templateRef.current;
|
||||||
const pasted = cloneTemplateElementsForPaste(source, 1);
|
const pasted = cloneTemplateElementsForPaste(source, 1);
|
||||||
onChange({
|
onChange({
|
||||||
...template,
|
...current,
|
||||||
elements: [...template.elements, ...pasted],
|
elements: [...current.elements, ...pasted],
|
||||||
});
|
});
|
||||||
onSelectElements(pasted.map((el) => el.id));
|
onSelectElements(pasted.map((el) => el.id));
|
||||||
onSelectTableCells?.([]);
|
onSelectTableCells?.([]);
|
||||||
syncClipboardCount(null);
|
syncClipboardCount(null);
|
||||||
}, [template, onChange, onSelectElements, onSelectTableCells, syncClipboardCount]);
|
}, [onChange, onSelectElements, onSelectTableCells, syncClipboardCount]);
|
||||||
|
|
||||||
|
const copyAndPasteSelectedElements = useCallback(() => {
|
||||||
|
if (selectedElementIds.length === 0) return;
|
||||||
|
const current = templateRef.current;
|
||||||
|
const idSet = new Set(selectedElementIds);
|
||||||
|
const toCopy = current.elements.filter((el) => idSet.has(el.id));
|
||||||
|
if (toCopy.length === 0) return;
|
||||||
|
const pasted = cloneTemplateElementsForPaste(toCopy, 1);
|
||||||
|
onChange({
|
||||||
|
...current,
|
||||||
|
elements: [...current.elements, ...pasted],
|
||||||
|
});
|
||||||
|
onSelectElements(pasted.map((el) => el.id));
|
||||||
|
onSelectTableCells?.([]);
|
||||||
|
onDuplicateSuccess?.(toCopy.length);
|
||||||
|
}, [
|
||||||
|
selectedElementIds,
|
||||||
|
onChange,
|
||||||
|
onSelectElements,
|
||||||
|
onSelectTableCells,
|
||||||
|
onDuplicateSuccess,
|
||||||
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
copySelectedElements,
|
copySelectedElements,
|
||||||
pasteClipboardElements,
|
pasteClipboardElements,
|
||||||
|
copyAndPasteSelectedElements,
|
||||||
canCopy,
|
canCopy,
|
||||||
canPaste,
|
canPaste,
|
||||||
clipboardCount,
|
clipboardCount,
|
||||||
|
|||||||
@@ -756,8 +756,10 @@ html.app-dark-shell body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 4px 8px;
|
gap: 8px;
|
||||||
font-size: 11px;
|
min-height: 40px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
border-bottom: 1px solid #222;
|
border-bottom: 1px solid #222;
|
||||||
@@ -783,6 +785,51 @@ html.app-dark-shell body {
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ps-layer-item-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ps-layer-item-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ps-layer-item-actions button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 2px;
|
||||||
|
color: #aaa;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ps-layer-item-actions button:hover:not(:disabled) {
|
||||||
|
background: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ps-layer-item-actions button:disabled {
|
||||||
|
opacity: 0.2;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ps-layer-item-actions button svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.ps-marquee-select {
|
.ps-marquee-select {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
border: 1px solid #31a8ff;
|
border: 1px solid #31a8ff;
|
||||||
@@ -797,6 +844,12 @@ html.app-dark-shell body {
|
|||||||
outline-offset: 0;
|
outline-offset: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ps-resize-outline {
|
||||||
|
outline: 1px dashed var(--color-ps-accent);
|
||||||
|
outline-offset: 0;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(49, 168, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
.ps-unselected-dim-layer {
|
.ps-unselected-dim-layer {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export interface VisibilityRule {
|
|||||||
target?: VisibilityRuleTarget;
|
target?: VisibilityRuleTarget;
|
||||||
variable: string;
|
variable: string;
|
||||||
operator: VisibilityOperator;
|
operator: VisibilityOperator;
|
||||||
/** gt / lt / eq / neq / contains / not_contains 时的比较目标值 */
|
/** gt / lt / eq / neq / contains / not_contains 时的比较目标值,支持 {变量名} */
|
||||||
value?: string;
|
value?: string;
|
||||||
/** 为 false 时跳过该条件;默认启用 */
|
/** 为 false 时跳过该条件;默认启用 */
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
@@ -91,6 +91,8 @@ export interface TemplateElement {
|
|||||||
id: string;
|
id: string;
|
||||||
type: 'text' | 'barcode' | 'qrcode' | 'image' | 'table';
|
type: 'text' | 'barcode' | 'qrcode' | 'image' | 'table';
|
||||||
name: string;
|
name: string;
|
||||||
|
/** 用户是否手动修改过图层名称;为 true 时不再根据内容自动同步名称 */
|
||||||
|
nameCustomized?: boolean;
|
||||||
x: number; // in mm
|
x: number; // in mm
|
||||||
y: number; // in mm
|
y: number; // in mm
|
||||||
width: number; // in mm
|
width: number; // in mm
|
||||||
|
|||||||
150
src/utils.ts
150
src/utils.ts
@@ -45,6 +45,70 @@ export function centerElementOnLabel(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const AUTO_ELEMENT_NAME_RE = /^(文本|条形码|二维码|图片|表格) \d+$/;
|
||||||
|
const MAX_LAYER_NAME_FROM_CONTENT = 40;
|
||||||
|
|
||||||
|
/** 是否为新建时的默认图层名(含粘贴副本后缀) */
|
||||||
|
export function isAutoGeneratedElementName(name: string): boolean {
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!trimmed || trimmed === '未命名元素') return true;
|
||||||
|
if (AUTO_ELEMENT_NAME_RE.test(trimmed)) return true;
|
||||||
|
if (trimmed.endsWith(' 副本')) {
|
||||||
|
const base = trimmed.slice(0, -3).trim();
|
||||||
|
if (AUTO_ELEMENT_NAME_RE.test(base) || base === '未命名元素') return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否应根据内容自动同步图层名称 */
|
||||||
|
export function shouldSyncElementNameFromContent(elem: TemplateElement): boolean {
|
||||||
|
if (elem.nameCustomized) return false;
|
||||||
|
return isAutoGeneratedElementName(elem.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从元素内容推导图层显示名称 */
|
||||||
|
export function deriveElementNameFromContent(
|
||||||
|
content: string,
|
||||||
|
type: TemplateElement['type']
|
||||||
|
): string {
|
||||||
|
const trimmed = content.trim();
|
||||||
|
if (!trimmed) return '未命名元素';
|
||||||
|
|
||||||
|
if (type === 'image') {
|
||||||
|
if (trimmed.startsWith('data:')) return '图片';
|
||||||
|
const withoutVars = trimmed.replace(/\{[^}]+\}/g, '').trim();
|
||||||
|
if (withoutVars && /^https?:\/\//i.test(withoutVars)) {
|
||||||
|
try {
|
||||||
|
const path = withoutVars.split(/[?#]/)[0];
|
||||||
|
const segment = path.split('/').filter(Boolean).pop();
|
||||||
|
if (segment) {
|
||||||
|
return decodeURIComponent(segment).slice(0, MAX_LAYER_NAME_FROM_CONTENT);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const singleLine = trimmed.split(/\r?\n/)[0].trim();
|
||||||
|
if (!singleLine) return '未命名元素';
|
||||||
|
if (singleLine.startsWith('data:')) return type === 'image' ? '图片' : '未命名元素';
|
||||||
|
return singleLine.slice(0, MAX_LAYER_NAME_FROM_CONTENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新内容并在需要时同步图层名称 */
|
||||||
|
export function withContentAndSyncedName(
|
||||||
|
elem: TemplateElement,
|
||||||
|
content: string
|
||||||
|
): TemplateElement {
|
||||||
|
const updated = { ...elem, content };
|
||||||
|
if (!shouldSyncElementNameFromContent(elem)) return updated;
|
||||||
|
return {
|
||||||
|
...updated,
|
||||||
|
name: deriveElementNameFromContent(content, elem.type),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** 深拷贝元素并生成新 id,用于复制/粘贴 */
|
/** 深拷贝元素并生成新 id,用于复制/粘贴 */
|
||||||
export function cloneTemplateElementsForPaste(
|
export function cloneTemplateElementsForPaste(
|
||||||
elements: TemplateElement[],
|
elements: TemplateElement[],
|
||||||
@@ -251,8 +315,8 @@ export function getExportRangeIndices(
|
|||||||
export const DEFAULT_CUT_LINE_CONFIG: CutLineConfig = {
|
export const DEFAULT_CUT_LINE_CONFIG: CutLineConfig = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
style: 'dashed',
|
style: 'dashed',
|
||||||
width: 0.1,
|
width: 0.2,
|
||||||
color: '#cccccc',
|
color: '#333333',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 裁切线有效线宽 (mm),预览与 PDF 共用 */
|
/** 裁切线有效线宽 (mm),预览与 PDF 共用 */
|
||||||
@@ -469,8 +533,8 @@ export const DEFAULT_PAPER_CONFIG: PaperConfig = {
|
|||||||
marginBottom: AUTO_LAYOUT_MIN_PAGE_MARGIN_MM,
|
marginBottom: AUTO_LAYOUT_MIN_PAGE_MARGIN_MM,
|
||||||
marginLeft: AUTO_LAYOUT_MIN_PAGE_MARGIN_MM,
|
marginLeft: AUTO_LAYOUT_MIN_PAGE_MARGIN_MM,
|
||||||
marginRight: AUTO_LAYOUT_MIN_PAGE_MARGIN_MM,
|
marginRight: AUTO_LAYOUT_MIN_PAGE_MARGIN_MM,
|
||||||
columnGap: 2,
|
columnGap: 0,
|
||||||
rowGap: 2,
|
rowGap: 0,
|
||||||
flow: 'left-right-top-bottom',
|
flow: 'left-right-top-bottom',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -571,6 +635,24 @@ export function resolveElementBackgroundFill(
|
|||||||
return color;
|
return color;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 从显示/过滤条件中收集引用的变量名 */
|
||||||
|
function collectVariablesFromVisibilityRule(rule: VisibilityRule, vars: Set<string>) {
|
||||||
|
const name = (rule.variable ?? '').trim();
|
||||||
|
if (name) vars.add(name);
|
||||||
|
if (rule.value) {
|
||||||
|
for (const v of extractVariablesFromContent(rule.value)) {
|
||||||
|
vars.add(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectVariablesFromVisibilityRules(rules: VisibilityRule[] | undefined, vars: Set<string>) {
|
||||||
|
if (!rules) return;
|
||||||
|
for (const rule of rules) {
|
||||||
|
collectVariablesFromVisibilityRule(rule, vars);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 从元素内容(含表格单元格)提取变量名 */
|
/** 从元素内容(含表格单元格)提取变量名 */
|
||||||
export function extractVariablesFromElement(elem: TemplateElement): string[] {
|
export function extractVariablesFromElement(elem: TemplateElement): string[] {
|
||||||
const vars = new Set<string>();
|
const vars = new Set<string>();
|
||||||
@@ -593,32 +675,36 @@ export function extractVariablesFromElement(elem: TemplateElement): string[] {
|
|||||||
export function extractTemplateVariables(template: {
|
export function extractTemplateVariables(template: {
|
||||||
variableList?: string[];
|
variableList?: string[];
|
||||||
elements: TemplateElement[];
|
elements: TemplateElement[];
|
||||||
|
dataFilterRules?: VisibilityRule[];
|
||||||
|
exportImageFilterRules?: VisibilityRule[];
|
||||||
}): string[] {
|
}): string[] {
|
||||||
const vars = new Set<string>(template.variableList ?? []);
|
const vars = new Set<string>(template.variableList ?? []);
|
||||||
for (const elem of template.elements) {
|
for (const elem of template.elements) {
|
||||||
for (const v of extractVariablesFromElement(elem)) {
|
for (const v of extractVariablesFromElement(elem)) {
|
||||||
vars.add(v);
|
vars.add(v);
|
||||||
}
|
}
|
||||||
for (const rule of resolveVisibilityRules(elem)) {
|
collectVariablesFromVisibilityRules(resolveVisibilityRules(elem), vars);
|
||||||
const name = (rule.variable ?? '').trim();
|
|
||||||
if (name) vars.add(name);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
collectVariablesFromVisibilityRules(template.dataFilterRules, vars);
|
||||||
|
collectVariablesFromVisibilityRules(template.exportImageFilterRules, vars);
|
||||||
return Array.from(vars).sort((a, b) => a.localeCompare(b, 'zh-CN'));
|
return Array.from(vars).sort((a, b) => a.localeCompare(b, 'zh-CN'));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 被元素内容或显示条件引用的变量 */
|
/** 被元素内容或显示条件引用的变量 */
|
||||||
export function getUsedTemplateVariables(template: { elements: TemplateElement[] }): string[] {
|
export function getUsedTemplateVariables(template: {
|
||||||
|
elements: TemplateElement[];
|
||||||
|
dataFilterRules?: VisibilityRule[];
|
||||||
|
exportImageFilterRules?: VisibilityRule[];
|
||||||
|
}): string[] {
|
||||||
const vars = new Set<string>();
|
const vars = new Set<string>();
|
||||||
for (const elem of template.elements) {
|
for (const elem of template.elements) {
|
||||||
for (const v of extractVariablesFromElement(elem)) {
|
for (const v of extractVariablesFromElement(elem)) {
|
||||||
vars.add(v);
|
vars.add(v);
|
||||||
}
|
}
|
||||||
for (const rule of resolveVisibilityRules(elem)) {
|
collectVariablesFromVisibilityRules(resolveVisibilityRules(elem), vars);
|
||||||
const name = (rule.variable ?? '').trim();
|
|
||||||
if (name) vars.add(name);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
collectVariablesFromVisibilityRules(template.dataFilterRules, vars);
|
||||||
|
collectVariablesFromVisibilityRules(template.exportImageFilterRules, vars);
|
||||||
return Array.from(vars);
|
return Array.from(vars);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -781,6 +867,20 @@ function getVisibilityRuleRawValue(
|
|||||||
return getVariableRawValue(rule.variable, row, defaults, mode);
|
return getVariableRawValue(rule.variable, row, defaults, mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 解析条件目标值(支持 {变量名}) */
|
||||||
|
export function resolveVisibilityRuleCompareTarget(
|
||||||
|
targetTemplate: string | undefined,
|
||||||
|
row: RecordRow | null,
|
||||||
|
defaults: Record<string, string> | null | undefined,
|
||||||
|
mode: ContentResolveMode,
|
||||||
|
rowIndex: number = 0
|
||||||
|
): string {
|
||||||
|
const trimmed = (targetTemplate ?? '').trim();
|
||||||
|
if (!trimmed) return '';
|
||||||
|
if (!trimmed.includes('{')) return trimmed;
|
||||||
|
return resolveContent(trimmed, row, rowIndex, defaults, { mode }).trim();
|
||||||
|
}
|
||||||
|
|
||||||
/** 单条显示条件是否满足 */
|
/** 单条显示条件是否满足 */
|
||||||
export function evaluateVisibilityRule(
|
export function evaluateVisibilityRule(
|
||||||
rule: VisibilityRule,
|
rule: VisibilityRule,
|
||||||
@@ -792,6 +892,13 @@ export function evaluateVisibilityRule(
|
|||||||
): boolean {
|
): boolean {
|
||||||
const raw = getVisibilityRuleRawValue(rule, elem, row, rowIndex, defaults, mode);
|
const raw = getVisibilityRuleRawValue(rule, elem, row, rowIndex, defaults, mode);
|
||||||
const isEmpty = raw === undefined;
|
const isEmpty = raw === undefined;
|
||||||
|
const compareTarget = resolveVisibilityRuleCompareTarget(
|
||||||
|
rule.value,
|
||||||
|
row,
|
||||||
|
defaults,
|
||||||
|
mode,
|
||||||
|
rowIndex
|
||||||
|
);
|
||||||
|
|
||||||
switch (rule.operator) {
|
switch (rule.operator) {
|
||||||
case 'empty':
|
case 'empty':
|
||||||
@@ -802,23 +909,20 @@ export function evaluateVisibilityRule(
|
|||||||
case 'lt':
|
case 'lt':
|
||||||
case 'eq': {
|
case 'eq': {
|
||||||
if (isEmpty) return false;
|
if (isEmpty) return false;
|
||||||
return compareVariableToTarget(raw!, (rule.value ?? '').trim(), rule.operator);
|
return compareVariableToTarget(raw!, compareTarget, rule.operator);
|
||||||
}
|
}
|
||||||
case 'neq': {
|
case 'neq': {
|
||||||
const target = (rule.value ?? '').trim();
|
if (isEmpty) return compareTarget !== '';
|
||||||
if (isEmpty) return target !== '';
|
return compareVariableToTarget(raw!, compareTarget, 'neq');
|
||||||
return compareVariableToTarget(raw!, target, 'neq');
|
|
||||||
}
|
}
|
||||||
case 'contains': {
|
case 'contains': {
|
||||||
const target = (rule.value ?? '').trim();
|
if (!compareTarget || isEmpty) return false;
|
||||||
if (!target || isEmpty) return false;
|
return raw!.includes(compareTarget);
|
||||||
return raw!.includes(target);
|
|
||||||
}
|
}
|
||||||
case 'not_contains': {
|
case 'not_contains': {
|
||||||
const target = (rule.value ?? '').trim();
|
if (!compareTarget) return true;
|
||||||
if (!target) return true;
|
|
||||||
if (isEmpty) return true;
|
if (isEmpty) return true;
|
||||||
return !raw!.includes(target);
|
return !raw!.includes(compareTarget);
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
Reference in New Issue
Block a user