From 455946c51ceeaebf5249355e07d8a566140a201e Mon Sep 17 00:00:00 2001 From: iqudoo Date: Mon, 15 Jun 2026 16:59:37 +0800 Subject: [PATCH] =?UTF-8?q?=E8=92=99=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 2 + src/components/ElementMaskDialog.tsx | 285 ++++++++++++++++++ src/components/ExportSidebar.tsx | 6 + src/components/LabelDesigner.tsx | 116 +++++-- src/components/LayoutPageBackgroundFields.tsx | 146 +++++++++ src/components/MobileExportLayoutPanel.tsx | 6 + src/components/MobileExportView.tsx | 3 + src/components/PreviewGrid.tsx | 36 ++- src/components/PropSidebar.tsx | 119 +++++++- src/elementAlign.ts | 24 ++ src/elementMask.ts | 149 +++++++++ src/index.css | 8 +- src/labelRenderer.ts | 78 +++++ src/pdfExport.ts | 40 +++ src/types.ts | 39 +++ 15 files changed, 1003 insertions(+), 54 deletions(-) create mode 100644 src/components/ElementMaskDialog.tsx create mode 100644 src/components/LayoutPageBackgroundFields.tsx create mode 100644 src/elementMask.ts diff --git a/src/App.tsx b/src/App.tsx index 404bca4..1d19a7d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1448,6 +1448,7 @@ export default function App() { {workflowStep === 'print-view' && isMobile && hasTemplates && activeTemplate && ( void; + onConfirm: (mask: ElementMaskConfig | undefined) => void; +} + +const round1 = (n: number) => Math.round(n * 10) / 10; + +export const ElementMaskDialog: React.FC = ({ + open, + element, + onClose, + onConfirm, +}) => { + const [draft, setDraft] = useState({ enabled: true, type: 'rect' }); + + useEffect(() => { + if (!open || !element) return; + setDraft( + element.mask?.enabled + ? { ...createDefaultElementMask(element), ...element.mask, enabled: true } + : createDefaultElementMask(element) + ); + }, [open, element]); + + useEffect(() => { + if (!open) return; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + onClose(); + } + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [open, onClose]); + + if (!open || !element || typeof document === 'undefined') return null; + + const regionW = draft.width ?? element.width; + const regionH = draft.height ?? element.height; + const maskType = draft.type ?? 'rect'; + const canConfirm = maskType !== 'image' || !!draft.image?.trim(); + + const patchDraft = (patch: Partial) => { + setDraft((prev) => ({ ...prev, ...patch })); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!canConfirm) return; + onConfirm({ ...draft, enabled: true }); + onClose(); + }; + + const handleClear = () => { + onConfirm(undefined); + onClose(); + }; + + return createPortal( +
+
e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-labelledby="element-mask-dialog-title" + > +
+

+ 蒙版设置 — {element.name} +

+ +
+
+
+

+ 蒙版用于裁剪图层可见区域,相对元素左上角定位;预览、导出与打印均生效。 +

+ +
+ + + patchDraft({ + type: e.target.value as ElementMaskConfig['type'], + }) + } + className="ps-field text-[11px]" + > + + + +
+ +
+
+ + patchDraft({ x: round1(Number(val) || 0) })} + className="ps-field font-mono text-[11px]" + /> +
+
+ + patchDraft({ y: round1(Number(val) || 0) })} + className="ps-field font-mono text-[11px]" + /> +
+
+ + + patchDraft({ width: Math.max(0.5, round1(Number(val) || element.width)) }) + } + className="ps-field font-mono text-[11px]" + /> +
+
+ + + patchDraft({ height: Math.max(0.5, round1(Number(val) || element.height)) }) + } + className="ps-field font-mono text-[11px]" + /> +
+
+ + {maskType === 'rect' && ( + <> +
+ + + patchDraft({ borderRadius: Math.max(0, round1(Number(val) || 0)) }) + } + className="ps-field font-mono text-[11px]" + /> +
+
+ {( + [ + ['borderRadiusTL', '左上'], + ['borderRadiusTR', '右上'], + ['borderRadiusBL', '左下'], + ['borderRadiusBR', '右下'], + ] as const + ).map(([key, label]) => ( +
+ {label} + { + if (val === '' || val === undefined) { + const next = { ...draft }; + delete next[key]; + setDraft(next); + return; + } + patchDraft({ [key]: Math.max(0, round1(Number(val) || 0)) }); + }} + className="ps-field font-mono text-[10px] flex-1 min-w-0" + placeholder="—" + /> +
+ ))} +
+ + )} + + {maskType === 'image' && ( +
+ +

+ 推荐使用带透明区域的 PNG;不透明区域将保留图层内容,透明区域将被裁剪。 +

+ {draft.image && ( +
+ 蒙版预览 +
+ )} +
+ + + patchDraft({ + imageFit: e.target.value as ElementMaskConfig['imageFit'], + }) + } + className="ps-field text-[11px]" + > + + + + +
+
+ )} +
+
+ {element.mask?.enabled && ( + + )} + + +
+
+
+
, + document.body + ); +}; diff --git a/src/components/ExportSidebar.tsx b/src/components/ExportSidebar.tsx index 36f31a7..dad2926 100644 --- a/src/components/ExportSidebar.tsx +++ b/src/components/ExportSidebar.tsx @@ -8,6 +8,7 @@ import { CollapsiblePanelSection } from './CollapsiblePanelSection'; import { DataRangeFields } from './DataRangeFields'; import { FieldSelect } from './PsSelect'; import { PrintModule } from './PrintModule'; +import { LayoutPageBackgroundFields } from './LayoutPageBackgroundFields'; interface ExportSidebarProps { importData: ImportData | null; @@ -21,6 +22,7 @@ interface ExportSidebarProps { paper: PaperConfig; onChangePaper: (updated: PaperConfig) => void; template: LabelTemplate; + onChangeTemplate: (updated: LabelTemplate) => void; rows: RecordRow[]; draftExportRange: ExportRangeConfig; onChangeDraftExportRange: (range: ExportRangeConfig) => void; @@ -61,6 +63,7 @@ export const ExportSidebar: React.FC = ({ paper, onChangePaper, template, + onChangeTemplate, rows, draftExportRange, onChangeDraftExportRange, @@ -228,6 +231,9 @@ export const ExportSidebar: React.FC = ({ exportRange={draftExportRange} totalRowCount={rows.length} /> +
+ +
diff --git a/src/components/LabelDesigner.tsx b/src/components/LabelDesigner.tsx index 52bb3e4..a11866e 100644 --- a/src/components/LabelDesigner.tsx +++ b/src/components/LabelDesigner.tsx @@ -2,6 +2,7 @@ import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react' import { createPortal } from 'react-dom'; import { LabelTemplate, TemplateElement } from '../types'; import { mmToPx, MM_TO_PX, shouldRenderElement, centerElementOnLabel } from '../utils'; +import { getSelectionBounds } from '../elementAlign'; import type { ElementClipboardActions } from '../hooks/useElementClipboard'; import { createDefaultTableElement, @@ -1447,6 +1448,33 @@ export const LabelDesigner: React.FC = ({ suppressSelectionChrome || (dragState?.type === 'drag' && dragInteractionVisualActive); const showUnselectedDimLayer = activeTool === 'move'; const showUnselectedDim = showUnselectedDimLayer && selectedElementIds.length > 0; + + const multiSelectionBounds = useMemo(() => { + if (selectedElementIds.length < 2 || hideSelectionChrome) return null; + const selected = template.elements.filter( + (el) => selectedElementIds.includes(el.id) && !el.locked + ); + if (selected.length < 2) return null; + return getSelectionBounds(selected); + }, [selectedElementIds, template.elements, hideSelectionChrome]); + + const dragMultiSelectionBounds = useMemo(() => { + if ( + selectedElementIds.length < 2 || + dragState?.type !== 'drag' || + !dragInteractionVisualActive || + dragFloatElements.length < 2 + ) { + return null; + } + return getSelectionBounds(dragFloatElements); + }, [ + selectedElementIds.length, + dragState?.type, + dragInteractionVisualActive, + dragFloatElements, + ]); + const resizingPreviewElement = dragState?.type === 'resize' ? interactionFloatElements?.find((el) => el.id === dragState.elementId) @@ -1608,7 +1636,9 @@ export const LabelDesigner: React.FC = ({ >
{ @@ -1645,6 +1675,24 @@ export const LabelDesigner: React.FC = ({
); })} + {dragMultiSelectionBounds && ( +
+ )}
)} {resizeFloatElements && resizeFloatElements.length > 0 && ( @@ -1775,32 +1823,7 @@ export const LabelDesigner: React.FC = ({ showUnselectedDim ? ' ps-unselected-dim-layer--visible' : '' }`} aria-hidden={!showUnselectedDim} - > - {overlayElements.map((element) => { - if (selectedElementIds.includes(element.id)) return null; - if (dragState?.type === 'drag' && dragMovingIdSet?.has(element.id)) return null; - const elemX = mmToPx(element.x, displayZoom); - const elemY = mmToPx(element.y, displayZoom); - const elemW = mmToPx(element.width, displayZoom); - const elemH = mmToPx(element.height, displayZoom); - return ( -
- ); - })} -
+ /> )} {selectedLiftElements && ( @@ -1918,6 +1941,17 @@ export const LabelDesigner: React.FC = ({
)} + {isSelected && + !hideSelectionChrome && + !isLocked && + selectedElementIds.length === 1 && + !isBeingResized && ( +
+ )} + {showResizeHandles && ( <>
= ({
); })} + + {multiSelectionBounds && ( +
+ )}
@@ -2028,14 +2081,17 @@ export const LabelDesigner: React.FC = ({ 文档: {template.width} × {template.height} mm - {selectedElemObj && ( + {selectedElemObj && selectedElementIds.length === 1 && ( | {selectedElemObj.name} — X:{selectedElemObj.x} Y:{selectedElemObj.y} W: {selectedElemObj.width} H:{selectedElemObj.height} mm )} - {selectedElementIds.length > 1 && ( - | 已选 {selectedElementIds.length} 个 + {multiSelectionBounds && ( + + | 已选 {selectedElementIds.length} 个 — X:{multiSelectionBounds.minX} Y: + {multiSelectionBounds.minY} mm + )} {activeTool === 'move' && ( diff --git a/src/components/LayoutPageBackgroundFields.tsx b/src/components/LayoutPageBackgroundFields.tsx new file mode 100644 index 0000000..9b683aa --- /dev/null +++ b/src/components/LayoutPageBackgroundFields.tsx @@ -0,0 +1,146 @@ +import React from 'react'; +import { Image as ImageIcon, Upload } from 'lucide-react'; +import type { LabelTemplate } from '../types'; +import { FieldSelect } from './PsSelect'; + +interface LayoutPageBackgroundFieldsProps { + template: LabelTemplate; + onChangeTemplate: (updated: LabelTemplate) => void; +} + +export const LayoutPageBackgroundFields: React.FC = ({ + template, + onChangeTemplate, +}) => ( +
+
+ + 页面背景 +
+

+ 上传底图作为排版后整页背景;可分别控制排版预览与 PDF / 打印输出是否显示。 +

+ + {template.layoutPageBackground && ( + <> +
+ 页面背景预览 +
+
+ + + onChangeTemplate({ + ...template, + layoutPageBackgroundFit: e.target.value as 'contain' | 'cover' | 'fill', + }) + } + className="ps-field text-[11px]" + > + + + + +
+
+ + + onChangeTemplate({ + ...template, + layoutPageBackgroundOpacity: Number(e.target.value), + }) + } + className="w-full accent-[#31a8ff]" + /> +
+ + + + + )} +
+); diff --git a/src/components/MobileExportLayoutPanel.tsx b/src/components/MobileExportLayoutPanel.tsx index 6d89839..20e2385 100644 --- a/src/components/MobileExportLayoutPanel.tsx +++ b/src/components/MobileExportLayoutPanel.tsx @@ -9,9 +9,11 @@ import { } from '../types'; import { PaperSettingsPanel, PageInput } from './PreviewGrid'; import { FieldSelect } from './PsSelect'; +import { LayoutPageBackgroundFields } from './LayoutPageBackgroundFields'; interface MobileExportLayoutPanelProps { template: LabelTemplate; + onChangeTemplate: (updated: LabelTemplate) => void; paper: PaperConfig; onChangePaper: (paper: PaperConfig) => void; rows: RecordRow[]; @@ -22,6 +24,7 @@ interface MobileExportLayoutPanelProps { export const MobileExportLayoutPanel: React.FC = ({ template, + onChangeTemplate, paper, onChangePaper, rows, @@ -40,6 +43,9 @@ export const MobileExportLayoutPanel: React.FC = ( exportRange={exportRange} totalRowCount={rows.length} /> +
+ +
diff --git a/src/components/MobileExportView.tsx b/src/components/MobileExportView.tsx index 25555fa..bde401a 100644 --- a/src/components/MobileExportView.tsx +++ b/src/components/MobileExportView.tsx @@ -40,6 +40,7 @@ const MOBILE_EXPORT_MODULES: { interface MobileExportViewProps { template: LabelTemplate; + onChangeTemplate: (updated: LabelTemplate) => void; templateName: string; templateVariables: string[]; importData: ImportData | null; @@ -84,6 +85,7 @@ interface MobileExportViewProps { export const MobileExportView: React.FC = ({ template, + onChangeTemplate, templateName, templateVariables, importData, @@ -277,6 +279,7 @@ export const MobileExportView: React.FC = ({
= ({ template.includeReferenceBackgroundInOutput, ] ); + const layoutPageBackground = useMemo( + () => resolveLayoutPageBackgroundForPreview(template), + [ + template.layoutPageBackground, + template.layoutPageBackgroundOpacity, + template.layoutPageBackgroundFit, + template.enableLayoutPageBackground, + ] + ); const pageCutLines = useMemo( () => collectGridCutLinePositions( @@ -741,16 +750,32 @@ export const LayoutPreview: React.FC = ({ */}
+ {layoutPageBackground && ( + + )}
+
= ({ ); })}
+
))} diff --git a/src/components/PropSidebar.tsx b/src/components/PropSidebar.tsx index cb3aaec..6c00401 100644 --- a/src/components/PropSidebar.tsx +++ b/src/components/PropSidebar.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useMemo, useRef } from 'react'; -import { LabelTemplate, TemplateElement, PaperConfig, VisibilityRule } from '../types'; +import { LabelTemplate, TemplateElement, PaperConfig, VisibilityRule, ElementMaskConfig } from '../types'; import { extractTemplateVariables, extractVariablesFromContent, @@ -11,12 +11,15 @@ import { withContentAndSyncedName, } from '../utils'; import { VisibilityRuleDialog } from './VisibilityRuleDialog'; +import { ElementMaskDialog } from './ElementMaskDialog'; import { VisibilityRuleSummary } from './VisibilityRuleSummary'; import { ValueExtractFields } from './ValueExtractFields'; import { TextDisplayAffixFields } from './TextDisplayAffixFields'; import { alignToSelectionBounds, alignSelectionToCanvas, + getSelectionBounds, + setSelectionOrigin, tileSelectedEvenly, tileSelectedFlush, type AlignMode, @@ -34,6 +37,7 @@ import { } from './TablePropertyFields'; import type { TableCellCoord } from '../tableUtils'; import { expandTableForPadding } from '../tableUtils'; +import { isElementMaskActive } from '../elementMask'; import { Sliders, LayoutGrid, @@ -77,6 +81,7 @@ import { Image as ImageIcon, Upload, Table2, + Blend, } from 'lucide-react'; const PANEL_COLLAPSE_STORAGE_KEY = 'label_designer_panel_collapse_v2'; @@ -220,6 +225,7 @@ export const PropSidebar: React.FC = ({ const [visibilityRuleDialog, setVisibilityRuleDialog] = useState< { mode: 'add' } | { mode: 'edit'; index: number } | null >(null); + const [maskDialogElementId, setMaskDialogElementId] = useState(null); const [panelCollapsed, setPanelCollapsed] = useState(loadPanelCollapseState); const [internalMobileModule, setInternalMobileModule] = useState('properties'); @@ -288,6 +294,24 @@ export const PropSidebar: React.FC = ({ .map((el) => el.id); }, [selectedElementIds, template.elements]); + const isMultiSelect = selectedElementIds.length >= 2; + + const multiSelectionBounds = useMemo(() => { + if (!isMultiSelect) return null; + const selected = template.elements.filter( + (el) => selectedElementIds.includes(el.id) && !el.locked + ); + if (selected.length < 2) return null; + return getSelectionBounds(selected); + }, [isMultiSelect, selectedElementIds, template.elements]); + + const moveSelectedGroupTo = (newMinX: number, newMinY: number) => { + onChangeTemplate({ + ...template, + elements: setSelectionOrigin(template.elements, selectedElementIds, newMinX, newMinY), + }); + }; + const nudgeSelectedElements = (deltaX: number, deltaY: number) => { if (nudgeableElementIds.length === 0) return; const idSet = new Set(nudgeableElementIds); @@ -304,6 +328,19 @@ export const PropSidebar: React.FC = ({ }); }; + const maskDialogElement = maskDialogElementId + ? template.elements.find((el) => el.id === maskDialogElementId) ?? null + : null; + + const updateElementMask = (elementId: string, mask: ElementMaskConfig | undefined) => { + onChangeTemplate({ + ...template, + elements: template.elements.map((el) => + el.id === elementId ? { ...el, mask: mask || undefined } : el + ), + }); + }; + const applyVisibilityRules = (next: VisibilityRule[]) => { if (!selectedElem) return; handleElemChange({ @@ -1710,39 +1747,60 @@ export const PropSidebar: React.FC = ({
- 位置与尺寸 (mm) + {isMultiSelect && multiSelectionBounds ? '选区位置 (mm)' : '位置与尺寸 (mm)'}
+ {isMultiSelect && multiSelectionBounds && ( +

+ X、Y 为选中元素整体包围框左上角,修改后将整体平移 +

+ )}
- + { + const xVal = Math.round((Number(val) || 0) * 10) / 10; + if (isMultiSelect && multiSelectionBounds) { + moveSelectedGroupTo(xVal, multiSelectionBounds.minY); + return; + } handleElemChange({ ...selectedElem, - x: Math.round((Number(val) || 0) * 10) / 10, + x: xVal, }); }} className="ps-field font-mono" />
- + { + const yVal = Math.round((Number(val) || 0) * 10) / 10; + if (isMultiSelect && multiSelectionBounds) { + moveSelectedGroupTo(multiSelectionBounds.minX, yVal); + return; + } handleElemChange({ ...selectedElem, - y: Math.round((Number(val) || 0) * 10) / 10, + y: yVal, }); }} className="ps-field font-mono" />
+ {!isMultiSelect && ( + <>
= ({ />
+ + )}
@@ -2251,6 +2311,23 @@ export const PropSidebar: React.FC = ({ )} + {!isLocked && ( + + )}