This commit is contained in:
iqudoo
2026-06-15 16:59:37 +08:00
parent fcd587bd50
commit 455946c51c
15 changed files with 1003 additions and 54 deletions

View File

@@ -1448,6 +1448,7 @@ export default function App() {
{workflowStep === 'print-view' && isMobile && hasTemplates && activeTemplate && ( {workflowStep === 'print-view' && isMobile && hasTemplates && activeTemplate && (
<MobileExportView <MobileExportView
template={activeTemplate} template={activeTemplate}
onChangeTemplate={handleTemplateChange}
templateName={activeTemplate.name ?? '标签'} templateName={activeTemplate.name ?? '标签'}
templateVariables={templateVariables} templateVariables={templateVariables}
importData={importData} importData={importData}
@@ -1579,6 +1580,7 @@ export default function App() {
paper={paper} paper={paper}
onChangePaper={handlePaperChange} onChangePaper={handlePaperChange}
template={activeTemplate} template={activeTemplate}
onChangeTemplate={handleTemplateChange}
rows={mappedRows} rows={mappedRows}
draftExportRange={draftExportRange} draftExportRange={draftExportRange}
onChangeDraftExportRange={setDraftExportRange} onChangeDraftExportRange={setDraftExportRange}

View File

@@ -0,0 +1,285 @@
import React, { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { Upload, X } from 'lucide-react';
import type { ElementMaskConfig, TemplateElement } from '../types';
import { createDefaultElementMask } from '../elementMask';
import { FieldSelect } from './PsSelect';
import { CommitInput } from './CommitInput';
interface ElementMaskDialogProps {
open: boolean;
element: TemplateElement | null;
onClose: () => void;
onConfirm: (mask: ElementMaskConfig | undefined) => void;
}
const round1 = (n: number) => Math.round(n * 10) / 10;
export const ElementMaskDialog: React.FC<ElementMaskDialogProps> = ({
open,
element,
onClose,
onConfirm,
}) => {
const [draft, setDraft] = useState<ElementMaskConfig>({ 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<ElementMaskConfig>) => {
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(
<div className="ps-modal-overlay" onClick={onClose} role="presentation">
<div
className="ps-modal"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="element-mask-dialog-title"
>
<div className="ps-modal-header">
<h3 id="element-mask-dialog-title" className="ps-modal-title">
{element.name}
</h3>
<button type="button" onClick={onClose} className="ps-modal-close" aria-label="关闭">
<X className="w-3.5 h-3.5" />
</button>
</div>
<form onSubmit={handleSubmit}>
<div className="ps-modal-body space-y-3">
<p className="text-[11px] text-[#888] leading-relaxed">
</p>
<div>
<label className="ps-field-label"></label>
<FieldSelect
value={maskType}
onChange={(e) =>
patchDraft({
type: e.target.value as ElementMaskConfig['type'],
})
}
className="ps-field text-[11px]"
>
<option value="rect"></option>
<option value="image"></option>
</FieldSelect>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="ps-field-label">X (mm)</label>
<CommitInput
type="number"
step="0.5"
value={draft.x ?? 0}
onCommit={(val) => patchDraft({ x: round1(Number(val) || 0) })}
className="ps-field font-mono text-[11px]"
/>
</div>
<div>
<label className="ps-field-label">Y (mm)</label>
<CommitInput
type="number"
step="0.5"
value={draft.y ?? 0}
onCommit={(val) => patchDraft({ y: round1(Number(val) || 0) })}
className="ps-field font-mono text-[11px]"
/>
</div>
<div>
<label className="ps-field-label"> (mm)</label>
<CommitInput
type="number"
step="0.5"
min="0.5"
value={regionW}
onCommit={(val) =>
patchDraft({ width: Math.max(0.5, round1(Number(val) || element.width)) })
}
className="ps-field font-mono text-[11px]"
/>
</div>
<div>
<label className="ps-field-label"> (mm)</label>
<CommitInput
type="number"
step="0.5"
min="0.5"
value={regionH}
onCommit={(val) =>
patchDraft({ height: Math.max(0.5, round1(Number(val) || element.height)) })
}
className="ps-field font-mono text-[11px]"
/>
</div>
</div>
{maskType === 'rect' && (
<>
<div>
<label className="ps-field-label"> (mm)</label>
<CommitInput
type="number"
min="0"
max="50"
step="0.5"
value={draft.borderRadius ?? 0}
onCommit={(val) =>
patchDraft({ borderRadius: Math.max(0, round1(Number(val) || 0)) })
}
className="ps-field font-mono text-[11px]"
/>
</div>
<div className="grid grid-cols-2 gap-2">
{(
[
['borderRadiusTL', '左上'],
['borderRadiusTR', '右上'],
['borderRadiusBL', '左下'],
['borderRadiusBR', '右下'],
] as const
).map(([key, label]) => (
<div key={key} className="flex items-center gap-1">
<span className="text-[9px] text-[#777] w-6 shrink-0">{label}</span>
<CommitInput
type="number"
min="0"
max="50"
step="0.5"
value={draft[key] ?? ''}
onCommit={(val) => {
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="—"
/>
</div>
))}
</div>
</>
)}
{maskType === 'image' && (
<div className="space-y-2">
<label className="ps-btn text-[10px] cursor-pointer inline-flex">
<Upload className="w-3.5 h-3.5" />
<input
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => patchDraft({ image: reader.result as string });
reader.readAsDataURL(file);
e.target.value = '';
}}
/>
</label>
<p className="text-[9px] text-[#666] leading-relaxed">
使 PNG
</p>
{draft.image && (
<div className="border border-[#3a3a3a] bg-[#1e1e1e] p-2">
<img
src={draft.image}
alt="蒙版预览"
className="block max-h-28 mx-auto object-contain"
/>
</div>
)}
<div>
<label className="ps-field-label"></label>
<FieldSelect
value={draft.imageFit ?? 'fill'}
onChange={(e) =>
patchDraft({
imageFit: e.target.value as ElementMaskConfig['imageFit'],
})
}
className="ps-field text-[11px]"
>
<option value="fill"> (fill)</option>
<option value="cover"> (cover)</option>
<option value="contain"> (contain)</option>
</FieldSelect>
</div>
</div>
)}
</div>
<div className="ps-modal-footer">
{element.mask?.enabled && (
<button
type="button"
onClick={handleClear}
className="ps-btn text-[11px] text-red-400 hover:text-red-300 mr-auto"
>
</button>
)}
<button type="button" onClick={onClose} className="ps-btn text-[11px]">
</button>
<button
type="submit"
disabled={!canConfirm}
className="ps-btn ps-btn-primary text-[11px] disabled:opacity-50"
>
</button>
</div>
</form>
</div>
</div>,
document.body
);
};

View File

@@ -8,6 +8,7 @@ import { CollapsiblePanelSection } from './CollapsiblePanelSection';
import { DataRangeFields } from './DataRangeFields'; import { DataRangeFields } from './DataRangeFields';
import { FieldSelect } from './PsSelect'; import { FieldSelect } from './PsSelect';
import { PrintModule } from './PrintModule'; import { PrintModule } from './PrintModule';
import { LayoutPageBackgroundFields } from './LayoutPageBackgroundFields';
interface ExportSidebarProps { interface ExportSidebarProps {
importData: ImportData | null; importData: ImportData | null;
@@ -21,6 +22,7 @@ interface ExportSidebarProps {
paper: PaperConfig; paper: PaperConfig;
onChangePaper: (updated: PaperConfig) => void; onChangePaper: (updated: PaperConfig) => void;
template: LabelTemplate; template: LabelTemplate;
onChangeTemplate: (updated: LabelTemplate) => void;
rows: RecordRow[]; rows: RecordRow[];
draftExportRange: ExportRangeConfig; draftExportRange: ExportRangeConfig;
onChangeDraftExportRange: (range: ExportRangeConfig) => void; onChangeDraftExportRange: (range: ExportRangeConfig) => void;
@@ -61,6 +63,7 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
paper, paper,
onChangePaper, onChangePaper,
template, template,
onChangeTemplate,
rows, rows,
draftExportRange, draftExportRange,
onChangeDraftExportRange, onChangeDraftExportRange,
@@ -228,6 +231,9 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
exportRange={draftExportRange} exportRange={draftExportRange}
totalRowCount={rows.length} totalRowCount={rows.length}
/> />
<div className="ps-section-divider">
<LayoutPageBackgroundFields template={template} onChangeTemplate={onChangeTemplate} />
</div>
<div className="ps-section-divider space-y-2"> <div className="ps-section-divider space-y-2">
<div className="ps-section-title"> <div className="ps-section-title">
<Settings className="w-3 h-3" /> <Settings className="w-3 h-3" />

View File

@@ -2,6 +2,7 @@ import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { LabelTemplate, TemplateElement } from '../types'; import { LabelTemplate, TemplateElement } from '../types';
import { mmToPx, MM_TO_PX, shouldRenderElement, centerElementOnLabel } from '../utils'; import { mmToPx, MM_TO_PX, shouldRenderElement, centerElementOnLabel } from '../utils';
import { getSelectionBounds } from '../elementAlign';
import type { ElementClipboardActions } from '../hooks/useElementClipboard'; import type { ElementClipboardActions } from '../hooks/useElementClipboard';
import { import {
createDefaultTableElement, createDefaultTableElement,
@@ -1447,6 +1448,33 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
suppressSelectionChrome || (dragState?.type === 'drag' && dragInteractionVisualActive); suppressSelectionChrome || (dragState?.type === 'drag' && dragInteractionVisualActive);
const showUnselectedDimLayer = activeTool === 'move'; const showUnselectedDimLayer = activeTool === 'move';
const showUnselectedDim = showUnselectedDimLayer && selectedElementIds.length > 0; 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 = const resizingPreviewElement =
dragState?.type === 'resize' dragState?.type === 'resize'
? interactionFloatElements?.find((el) => el.id === dragState.elementId) ? interactionFloatElements?.find((el) => el.id === dragState.elementId)
@@ -1608,7 +1636,9 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
> >
<div <div
data-drag-float-placeholder={el.id} data-drag-float-placeholder={el.id}
className="absolute inset-0 bg-white border border-[#31a8ff]/40 shadow-[0_1px_4px_rgba(0,0,0,0.12)]" className={`absolute inset-0 bg-white shadow-[0_1px_4px_rgba(0,0,0,0.12)]${
dragMultiSelectionBounds ? '' : ' border border-[#31a8ff]/40'
}`}
/> />
<img <img
ref={(node) => { ref={(node) => {
@@ -1645,6 +1675,24 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
</div> </div>
); );
})} })}
{dragMultiSelectionBounds && (
<div
className="absolute ps-resize-outline pointer-events-none z-[5]"
style={{
left: `${mmToPx(dragMultiSelectionBounds.minX, displayZoom)}px`,
top: `${mmToPx(dragMultiSelectionBounds.minY, displayZoom)}px`,
width: `${mmToPx(
dragMultiSelectionBounds.maxX - dragMultiSelectionBounds.minX,
displayZoom
)}px`,
height: `${mmToPx(
dragMultiSelectionBounds.maxY - dragMultiSelectionBounds.minY,
displayZoom
)}px`,
}}
aria-hidden
/>
)}
</div> </div>
)} )}
{resizeFloatElements && resizeFloatElements.length > 0 && ( {resizeFloatElements && resizeFloatElements.length > 0 && (
@@ -1775,32 +1823,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
showUnselectedDim ? ' ps-unselected-dim-layer--visible' : '' showUnselectedDim ? ' ps-unselected-dim-layer--visible' : ''
}`} }`}
aria-hidden={!showUnselectedDim} 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 (
<div
key={`dim-${element.id}`}
className="ps-unselected-dim absolute"
style={{
left: `${elemX}px`,
top: `${elemY}px`,
width: `${elemW}px`,
height: `${elemH}px`,
transform: element.rotation
? `rotate(${element.rotation}deg)`
: undefined,
transformOrigin: 'center center',
}}
/>
);
})}
</div>
)} )}
{selectedLiftElements && ( {selectedLiftElements && (
@@ -1918,6 +1941,17 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
<div className="absolute inset-0 ps-resize-outline pointer-events-none z-[60]" aria-hidden /> <div className="absolute inset-0 ps-resize-outline pointer-events-none z-[60]" aria-hidden />
)} )}
{isSelected &&
!hideSelectionChrome &&
!isLocked &&
selectedElementIds.length === 1 &&
!isBeingResized && (
<div
className="absolute inset-0 ps-resize-outline pointer-events-none z-[60]"
aria-hidden
/>
)}
{showResizeHandles && ( {showResizeHandles && (
<> <>
<div <div
@@ -1997,6 +2031,25 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
</div> </div>
); );
})} })}
{multiSelectionBounds && (
<div
className="absolute ps-resize-outline pointer-events-none z-[1005]"
style={{
left: `${mmToPx(multiSelectionBounds.minX, displayZoom)}px`,
top: `${mmToPx(multiSelectionBounds.minY, displayZoom)}px`,
width: `${mmToPx(
multiSelectionBounds.maxX - multiSelectionBounds.minX,
displayZoom
)}px`,
height: `${mmToPx(
multiSelectionBounds.maxY - multiSelectionBounds.minY,
displayZoom
)}px`,
}}
aria-hidden
/>
)}
</div> </div>
</div> </div>
@@ -2028,14 +2081,17 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
<span> <span>
: {template.width} × {template.height} mm : {template.width} × {template.height} mm
</span> </span>
{selectedElemObj && ( {selectedElemObj && selectedElementIds.length === 1 && (
<span className="text-[#31a8ff]"> <span className="text-[#31a8ff]">
| {selectedElemObj.name} X:{selectedElemObj.x} Y:{selectedElemObj.y} W: | {selectedElemObj.name} X:{selectedElemObj.x} Y:{selectedElemObj.y} W:
{selectedElemObj.width} H:{selectedElemObj.height} mm {selectedElemObj.width} H:{selectedElemObj.height} mm
</span> </span>
)} )}
{selectedElementIds.length > 1 && ( {multiSelectionBounds && (
<span className="text-emerald-400">| {selectedElementIds.length} </span> <span className="text-emerald-400">
| {selectedElementIds.length} X:{multiSelectionBounds.minX} Y:
{multiSelectionBounds.minY} mm
</span>
)} )}
{activeTool === 'move' && ( {activeTool === 'move' && (
<span className="text-[#888] hidden sm:inline"> <span className="text-[#888] hidden sm:inline">

View File

@@ -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<LayoutPageBackgroundFieldsProps> = ({
template,
onChangeTemplate,
}) => (
<div className="space-y-3">
<div className="ps-section-title">
<ImageIcon className="w-3 h-3" />
</div>
<p className="text-[9px] text-[#777] leading-relaxed">
PDF /
</p>
<label className="ps-btn text-[10px] cursor-pointer inline-flex">
<Upload className="w-3.5 h-3.5" />
<input
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () =>
onChangeTemplate({
...template,
layoutPageBackground: reader.result as string,
layoutPageBackgroundOpacity: template.layoutPageBackgroundOpacity ?? 100,
layoutPageBackgroundFit: template.layoutPageBackgroundFit ?? 'cover',
enableLayoutPageBackground: true,
});
reader.readAsDataURL(file);
e.target.value = '';
}}
/>
</label>
{template.layoutPageBackground && (
<>
<div className="border border-[#3a3a3a] bg-[#1e1e1e] p-2">
<img
src={template.layoutPageBackground}
alt="页面背景预览"
className="block max-h-24 mx-auto object-contain"
/>
</div>
<div>
<label className="ps-field-label"></label>
<FieldSelect
value={template.layoutPageBackgroundFit ?? 'cover'}
onChange={(e) =>
onChangeTemplate({
...template,
layoutPageBackgroundFit: e.target.value as 'contain' | 'cover' | 'fill',
})
}
className="ps-field text-[11px]"
>
<option value="cover"> (cover)</option>
<option value="contain"> (contain)</option>
<option value="fill"> (fill)</option>
</FieldSelect>
</div>
<div>
<label className="ps-field-label">
({template.layoutPageBackgroundOpacity ?? 100}%)
</label>
<input
type="range"
min="0"
max="100"
step="1"
value={template.layoutPageBackgroundOpacity ?? 100}
onChange={(e) =>
onChangeTemplate({
...template,
layoutPageBackgroundOpacity: Number(e.target.value),
})
}
className="w-full accent-[#31a8ff]"
/>
</div>
<label className="flex items-start gap-2">
<input
type="checkbox"
checked={template.enableLayoutPageBackground !== false}
onChange={(e) =>
onChangeTemplate({
...template,
enableLayoutPageBackground: e.target.checked ? true : false,
})
}
className="ps-checkbox mt-0.5"
/>
<span className="text-xs text-[#ccc] leading-normal">
<span className="block text-[9px] text-[#777] mt-0.5">
</span>
</span>
</label>
<label className="flex items-start gap-2">
<input
type="checkbox"
checked={template.includeLayoutPageBackgroundInOutput ?? false}
onChange={(e) =>
onChangeTemplate({
...template,
includeLayoutPageBackgroundInOutput: e.target.checked ? true : undefined,
})
}
className="ps-checkbox mt-0.5"
/>
<span className="text-xs text-[#ccc] leading-normal">
PDF
</span>
</label>
<button
type="button"
onClick={() =>
onChangeTemplate({
...template,
layoutPageBackground: undefined,
layoutPageBackgroundOpacity: undefined,
layoutPageBackgroundFit: undefined,
enableLayoutPageBackground: undefined,
includeLayoutPageBackgroundInOutput: undefined,
})
}
className="text-[9px] text-[#31a8ff] hover:underline cursor-pointer"
>
</button>
</>
)}
</div>
);

View File

@@ -9,9 +9,11 @@ import {
} from '../types'; } from '../types';
import { PaperSettingsPanel, PageInput } from './PreviewGrid'; import { PaperSettingsPanel, PageInput } from './PreviewGrid';
import { FieldSelect } from './PsSelect'; import { FieldSelect } from './PsSelect';
import { LayoutPageBackgroundFields } from './LayoutPageBackgroundFields';
interface MobileExportLayoutPanelProps { interface MobileExportLayoutPanelProps {
template: LabelTemplate; template: LabelTemplate;
onChangeTemplate: (updated: LabelTemplate) => void;
paper: PaperConfig; paper: PaperConfig;
onChangePaper: (paper: PaperConfig) => void; onChangePaper: (paper: PaperConfig) => void;
rows: RecordRow[]; rows: RecordRow[];
@@ -22,6 +24,7 @@ interface MobileExportLayoutPanelProps {
export const MobileExportLayoutPanel: React.FC<MobileExportLayoutPanelProps> = ({ export const MobileExportLayoutPanel: React.FC<MobileExportLayoutPanelProps> = ({
template, template,
onChangeTemplate,
paper, paper,
onChangePaper, onChangePaper,
rows, rows,
@@ -40,6 +43,9 @@ export const MobileExportLayoutPanel: React.FC<MobileExportLayoutPanelProps> = (
exportRange={exportRange} exportRange={exportRange}
totalRowCount={rows.length} totalRowCount={rows.length}
/> />
<div className="ps-section-divider">
<LayoutPageBackgroundFields template={template} onChangeTemplate={onChangeTemplate} />
</div>
<div className="ps-section-divider space-y-2"> <div className="ps-section-divider space-y-2">
<div className="ps-section-title"> <div className="ps-section-title">
<Settings className="w-3 h-3" /> <Settings className="w-3 h-3" />

View File

@@ -40,6 +40,7 @@ const MOBILE_EXPORT_MODULES: {
interface MobileExportViewProps { interface MobileExportViewProps {
template: LabelTemplate; template: LabelTemplate;
onChangeTemplate: (updated: LabelTemplate) => void;
templateName: string; templateName: string;
templateVariables: string[]; templateVariables: string[];
importData: ImportData | null; importData: ImportData | null;
@@ -84,6 +85,7 @@ interface MobileExportViewProps {
export const MobileExportView: React.FC<MobileExportViewProps> = ({ export const MobileExportView: React.FC<MobileExportViewProps> = ({
template, template,
onChangeTemplate,
templateName, templateName,
templateVariables, templateVariables,
importData, importData,
@@ -277,6 +279,7 @@ export const MobileExportView: React.FC<MobileExportViewProps> = ({
<div className="ps-panel-body p-3 min-h-0"> <div className="ps-panel-body p-3 min-h-0">
<MobileExportLayoutPanel <MobileExportLayoutPanel
template={template} template={template}
onChangeTemplate={onChangeTemplate}
paper={paper} paper={paper}
onChangePaper={onChangePaper} onChangePaper={onChangePaper}
rows={rows} rows={rows}

View File

@@ -15,7 +15,7 @@ import {
type PrintableLabel, type PrintableLabel,
} from '../utils'; } from '../utils';
import { CanvasLabelImage } from './CanvasLabelImage'; import { CanvasLabelImage } from './CanvasLabelImage';
import { resolveReferenceBackgroundForOutput } from '../labelRenderer'; import { resolveReferenceBackgroundForOutput, resolveLayoutPageBackgroundForPreview } from '../labelRenderer';
import { CommitInput } from './CommitInput'; import { CommitInput } from './CommitInput';
import { useAppDialog } from './AppDialog'; import { useAppDialog } from './AppDialog';
import { FieldSelect } from './PsSelect'; import { FieldSelect } from './PsSelect';
@@ -605,6 +605,15 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
template.includeReferenceBackgroundInOutput, template.includeReferenceBackgroundInOutput,
] ]
); );
const layoutPageBackground = useMemo(
() => resolveLayoutPageBackgroundForPreview(template),
[
template.layoutPageBackground,
template.layoutPageBackgroundOpacity,
template.layoutPageBackgroundFit,
template.enableLayoutPageBackground,
]
);
const pageCutLines = useMemo( const pageCutLines = useMemo(
() => () =>
collectGridCutLinePositions( collectGridCutLinePositions(
@@ -741,16 +750,32 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
</span> */} </span> */}
</div> </div>
<div <div
className="bg-white flex items-center justify-center relative select-none overflow-hidden shrink-0" className="bg-white relative select-none overflow-hidden shrink-0"
style={{ style={{
width: `${pageCardWidthPx}px`, width: `${pageCardWidthPx}px`,
height: `${currentPaperH * previewScale}px`, height: `${currentPaperH * previewScale}px`,
padding: `${paper.marginTop * previewScale}px ${paper.marginRight * previewScale}px ${paper.marginBottom * previewScale}px ${paper.marginLeft * previewScale}px`,
boxSizing: 'border-box',
}} }}
> >
{layoutPageBackground && (
<img
src={layoutPageBackground.background}
alt=""
aria-hidden
className="absolute inset-0 w-full h-full pointer-events-none z-0"
style={{
objectFit: layoutPageBackground.fit,
opacity: layoutPageBackground.opacity / 100,
}}
/>
)}
<div <div
className="grid bg-white relative" className="relative z-10 flex items-center justify-center h-full box-border"
style={{
padding: `${paper.marginTop * previewScale}px ${paper.marginRight * previewScale}px ${paper.marginBottom * previewScale}px ${paper.marginLeft * previewScale}px`,
}}
>
<div
className="grid bg-transparent relative"
style={{ style={{
gridTemplateColumns: `repeat(${gridInfo.cols}, ${labelWPx}px)`, gridTemplateColumns: `repeat(${gridInfo.cols}, ${labelWPx}px)`,
gridAutoRows: `${labelHPx}px`, gridAutoRows: `${labelHPx}px`,
@@ -844,6 +869,7 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
); );
})} })}
</div> </div>
</div>
</div> </div>
</div> </div>
))} ))}

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useMemo, useRef } from 'react'; import React, { useState, useEffect, useMemo, useRef } from 'react';
import { LabelTemplate, TemplateElement, PaperConfig, VisibilityRule } from '../types'; import { LabelTemplate, TemplateElement, PaperConfig, VisibilityRule, ElementMaskConfig } from '../types';
import { import {
extractTemplateVariables, extractTemplateVariables,
extractVariablesFromContent, extractVariablesFromContent,
@@ -11,12 +11,15 @@ import {
withContentAndSyncedName, withContentAndSyncedName,
} from '../utils'; } from '../utils';
import { VisibilityRuleDialog } from './VisibilityRuleDialog'; import { VisibilityRuleDialog } from './VisibilityRuleDialog';
import { ElementMaskDialog } from './ElementMaskDialog';
import { VisibilityRuleSummary } from './VisibilityRuleSummary'; import { VisibilityRuleSummary } from './VisibilityRuleSummary';
import { ValueExtractFields } from './ValueExtractFields'; import { ValueExtractFields } from './ValueExtractFields';
import { TextDisplayAffixFields } from './TextDisplayAffixFields'; import { TextDisplayAffixFields } from './TextDisplayAffixFields';
import { import {
alignToSelectionBounds, alignToSelectionBounds,
alignSelectionToCanvas, alignSelectionToCanvas,
getSelectionBounds,
setSelectionOrigin,
tileSelectedEvenly, tileSelectedEvenly,
tileSelectedFlush, tileSelectedFlush,
type AlignMode, type AlignMode,
@@ -34,6 +37,7 @@ import {
} from './TablePropertyFields'; } from './TablePropertyFields';
import type { TableCellCoord } from '../tableUtils'; import type { TableCellCoord } from '../tableUtils';
import { expandTableForPadding } from '../tableUtils'; import { expandTableForPadding } from '../tableUtils';
import { isElementMaskActive } from '../elementMask';
import { import {
Sliders, Sliders,
LayoutGrid, LayoutGrid,
@@ -77,6 +81,7 @@ import {
Image as ImageIcon, Image as ImageIcon,
Upload, Upload,
Table2, Table2,
Blend,
} from 'lucide-react'; } from 'lucide-react';
const PANEL_COLLAPSE_STORAGE_KEY = 'label_designer_panel_collapse_v2'; const PANEL_COLLAPSE_STORAGE_KEY = 'label_designer_panel_collapse_v2';
@@ -220,6 +225,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
const [visibilityRuleDialog, setVisibilityRuleDialog] = useState< const [visibilityRuleDialog, setVisibilityRuleDialog] = useState<
{ mode: 'add' } | { mode: 'edit'; index: number } | null { mode: 'add' } | { mode: 'edit'; index: number } | null
>(null); >(null);
const [maskDialogElementId, setMaskDialogElementId] = useState<string | null>(null);
const [panelCollapsed, setPanelCollapsed] = useState<PanelCollapseState>(loadPanelCollapseState); const [panelCollapsed, setPanelCollapsed] = useState<PanelCollapseState>(loadPanelCollapseState);
const [internalMobileModule, setInternalMobileModule] = const [internalMobileModule, setInternalMobileModule] =
useState<MobilePropModule>('properties'); useState<MobilePropModule>('properties');
@@ -288,6 +294,24 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
.map((el) => el.id); .map((el) => el.id);
}, [selectedElementIds, template.elements]); }, [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) => { const nudgeSelectedElements = (deltaX: number, deltaY: number) => {
if (nudgeableElementIds.length === 0) return; if (nudgeableElementIds.length === 0) return;
const idSet = new Set(nudgeableElementIds); const idSet = new Set(nudgeableElementIds);
@@ -304,6 +328,19 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
}); });
}; };
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[]) => { const applyVisibilityRules = (next: VisibilityRule[]) => {
if (!selectedElem) return; if (!selectedElem) return;
handleElemChange({ handleElemChange({
@@ -1710,39 +1747,60 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
<div className="space-y-3"> <div className="space-y-3">
<h5 className="ps-section-title"> <h5 className="ps-section-title">
<Ruler className="w-3.5 h-3.5" /> <Ruler className="w-3.5 h-3.5" />
(mm) {isMultiSelect && multiSelectionBounds ? '选区位置 (mm)' : '位置与尺寸 (mm)'}
</h5> </h5>
{isMultiSelect && multiSelectionBounds && (
<p className="text-[10px] text-[#666] leading-snug">
XY
</p>
)}
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="ps-field-label">X ()</label> <label className="ps-field-label">
{isMultiSelect ? 'X 坐标 (选区左边距)' : 'X 坐标 (左边距)'}
</label>
<PropInput <PropInput
type="number" type="number"
step="0.5" step="0.5"
value={selectedElem.x} value={isMultiSelect && multiSelectionBounds ? multiSelectionBounds.minX : selectedElem.x}
onCommit={(val) => { onCommit={(val) => {
const xVal = Math.round((Number(val) || 0) * 10) / 10;
if (isMultiSelect && multiSelectionBounds) {
moveSelectedGroupTo(xVal, multiSelectionBounds.minY);
return;
}
handleElemChange({ handleElemChange({
...selectedElem, ...selectedElem,
x: Math.round((Number(val) || 0) * 10) / 10, x: xVal,
}); });
}} }}
className="ps-field font-mono" className="ps-field font-mono"
/> />
</div> </div>
<div> <div>
<label className="ps-field-label">Y ()</label> <label className="ps-field-label">
{isMultiSelect ? 'Y 坐标 (选区顶边距)' : 'Y 坐标 (顶边距)'}
</label>
<PropInput <PropInput
type="number" type="number"
step="0.5" step="0.5"
value={selectedElem.y} value={isMultiSelect && multiSelectionBounds ? multiSelectionBounds.minY : selectedElem.y}
onCommit={(val) => { onCommit={(val) => {
const yVal = Math.round((Number(val) || 0) * 10) / 10;
if (isMultiSelect && multiSelectionBounds) {
moveSelectedGroupTo(multiSelectionBounds.minX, yVal);
return;
}
handleElemChange({ handleElemChange({
...selectedElem, ...selectedElem,
y: Math.round((Number(val) || 0) * 10) / 10, y: yVal,
}); });
}} }}
className="ps-field font-mono" className="ps-field font-mono"
/> />
</div> </div>
{!isMultiSelect && (
<>
<div> <div>
<label className="ps-field-label"></label> <label className="ps-field-label"></label>
<PropInput <PropInput
@@ -1805,6 +1863,8 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
/> />
</div> </div>
</div> </div>
</>
)}
</div> </div>
</div> </div>
@@ -2251,6 +2311,23 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
</button> </button>
</> </>
)} )}
{!isLocked && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setMaskDialogElementId(elem.id);
}}
title={isElementMaskActive(elem.mask) ? '编辑蒙版' : '添加蒙版'}
className={
isElementMaskActive(elem.mask)
? 'text-[#31a8ff]'
: 'text-[#666] hover:text-[#ccc]'
}
>
<Blend />
</button>
)}
<button <button
type="button" type="button"
onClick={(e) => { e.stopPropagation(); toggleLayerVisibility(actualIdx); }} onClick={(e) => { e.stopPropagation(); toggleLayerVisibility(actualIdx); }}
@@ -2344,14 +2421,16 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
{primaryElem && ( {primaryElem && (
<div className="mobile-nudge-info"> <div className="mobile-nudge-info">
<p className="text-[12px] font-medium text-[#ddd] truncate">{primaryElem.name}</p> <p className="text-[12px] font-medium text-[#ddd] truncate">
{nudgeableElementIds.length > 1 ? `已选 ${nudgeableElementIds.length} 个元素` : primaryElem.name}
</p>
<p className="text-[11px] font-mono text-[#31a8ff] mt-1"> <p className="text-[11px] font-mono text-[#31a8ff] mt-1">
X {primaryElem.x} · Y {primaryElem.y} mm {nudgeableElementIds.length > 1 && multiSelectionBounds
? `X ${multiSelectionBounds.minX} · Y ${multiSelectionBounds.minY} mm`
: `X ${primaryElem.x} · Y ${primaryElem.y} mm`}
</p> </p>
{nudgeableElementIds.length > 1 && ( {nudgeableElementIds.length > 1 && (
<p className="text-[10px] text-[#888] mt-1"> <p className="text-[10px] text-[#888] mt-1"></p>
{nudgeableElementIds.length}
</p>
)} )}
</div> </div>
)} )}
@@ -2372,10 +2451,23 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
/> />
); );
const elementMaskDialogNode = (
<ElementMaskDialog
open={maskDialogElementId !== null}
element={maskDialogElement}
onClose={() => setMaskDialogElementId(null)}
onConfirm={(mask) => {
if (!maskDialogElementId) return;
updateElementMask(maskDialogElementId, mask);
}}
/>
);
if (variant === 'mobile') { if (variant === 'mobile') {
return ( return (
<div className="mobile-design-props"> <div className="mobile-design-props">
{visibilityRuleDialogNode} {visibilityRuleDialogNode}
{elementMaskDialogNode}
<nav className="mobile-design-props-nav" aria-label="属性模块"> <nav className="mobile-design-props-nav" aria-label="属性模块">
{MOBILE_PROP_MODULES.map((item) => ( {MOBILE_PROP_MODULES.map((item) => (
<button <button
@@ -2434,6 +2526,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
return ( return (
<div className="ps-sidebar-stack"> <div className="ps-sidebar-stack">
{visibilityRuleDialogNode} {visibilityRuleDialogNode}
{elementMaskDialogNode}
<CollapsiblePanelSection <CollapsiblePanelSection
sectionClassName="properties-panel flex flex-col" sectionClassName="properties-panel flex flex-col"
collapsed={panelCollapsed.properties} collapsed={panelCollapsed.properties}

View File

@@ -28,6 +28,30 @@ export function getSelectionBounds(selected: TemplateElement[]) {
return { minX, minY, maxX, maxY }; return { minX, minY, maxX, maxY };
} }
/** 将选中元素整体平移,使选区左上角移至 (newMinX, newMinY) */
export function setSelectionOrigin(
elements: TemplateElement[],
selectedIds: string[],
newMinX: number,
newMinY: number
): TemplateElement[] {
const selected = elements.filter((e) => selectedIds.includes(e.id) && !e.locked);
if (selected.length === 0) return elements;
const { minX, minY } = getSelectionBounds(selected);
const deltaX = round1(newMinX - minX);
const deltaY = round1(newMinY - minY);
if (deltaX === 0 && deltaY === 0) return elements;
const idSet = new Set(selected.map((e) => e.id));
return elements.map((el) => {
if (!idSet.has(el.id)) return el;
return {
...el,
x: round1(el.x + deltaX),
y: round1(el.y + deltaY),
};
});
}
/** 选中元素对齐到共同选区边界 */ /** 选中元素对齐到共同选区边界 */
export function alignToSelectionBounds( export function alignToSelectionBounds(
elements: TemplateElement[], elements: TemplateElement[],

149
src/elementMask.ts Normal file
View File

@@ -0,0 +1,149 @@
import type { ElementMaskConfig, TemplateElement } from './types';
import { traceRoundedRect, type CornerRadiiPx } from './elementBox';
function loadImageOrNull(src: string): Promise<HTMLImageElement | null> {
const trimmed = src.trim();
if (!trimmed) return Promise.resolve(null);
return new Promise((resolve) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => resolve(null);
img.src = trimmed;
});
}
function drawImageWithFit(
ctx: CanvasRenderingContext2D,
img: HTMLImageElement,
x: number,
y: number,
w: number,
h: number,
fit: 'contain' | 'cover' | 'fill' = 'contain'
) {
if (fit === 'fill') {
ctx.drawImage(img, x, y, w, h);
return;
}
const imgRatio = img.width / img.height;
const boxRatio = w / h;
if (fit === 'contain') {
let dw = w;
let dh = h;
let dx = x;
let dy = y;
if (imgRatio > boxRatio) {
dh = w / imgRatio;
dy = y + (h - dh) / 2;
} else {
dw = h * imgRatio;
dx = x + (w - dw) / 2;
}
ctx.drawImage(img, dx, dy, dw, dh);
return;
}
let sx = 0;
let sy = 0;
let sw = img.width;
let sh = img.height;
if (imgRatio > boxRatio) {
sw = img.height * boxRatio;
sx = (img.width - sw) / 2;
} else {
sh = img.width / boxRatio;
sy = (img.height - sh) / 2;
}
ctx.drawImage(img, sx, sy, sw, sh, x, y, w, h);
}
export function isElementMaskActive(mask?: ElementMaskConfig): boolean {
if (!mask?.enabled) return false;
if (mask.type === 'image') return !!mask.image?.trim();
return true;
}
export function createDefaultElementMask(elem: TemplateElement): ElementMaskConfig {
return {
enabled: true,
type: 'rect',
x: 0,
y: 0,
width: elem.width,
height: elem.height,
borderRadius: 0,
imageFit: 'fill',
};
}
export function resolveMaskRegionMm(
elem: TemplateElement,
mask: ElementMaskConfig
): { x: number; y: number; width: number; height: number } {
return {
x: mask.x ?? 0,
y: mask.y ?? 0,
width: mask.width ?? elem.width,
height: mask.height ?? elem.height,
};
}
export function resolveMaskCornerRadiiPx(
mask: ElementMaskConfig,
scale: number,
w: number,
h: number
): CornerRadiiPx {
const maxR = Math.min(w, h) / 2;
const baseMm = mask.borderRadius ?? 0;
const radiusFor = (specificMm?: number): number => {
const mm = specificMm !== undefined && specificMm !== null ? specificMm : baseMm;
if (mm <= 0) return 0;
return Math.min(maxR, mm * scale);
};
return {
tl: radiusFor(mask.borderRadiusTL),
tr: radiusFor(mask.borderRadiusTR),
br: radiusFor(mask.borderRadiusBR),
bl: radiusFor(mask.borderRadiusBL),
};
}
/** 使用 destination-in 将已绘制元素裁剪到蒙版区域 */
export async function applyElementMask(
ctx: CanvasRenderingContext2D,
elem: TemplateElement,
ex: number,
ey: number,
scale: number,
mask: ElementMaskConfig
) {
if (!isElementMaskActive(mask)) return;
const region = resolveMaskRegionMm(elem, mask);
const mx = ex + Math.round(region.x * scale);
const my = ey + Math.round(region.y * scale);
const mw = Math.max(1, Math.round(region.width * scale));
const mh = Math.max(1, Math.round(region.height * scale));
ctx.save();
ctx.globalCompositeOperation = 'destination-in';
if (mask.type === 'image' && mask.image) {
const img = await loadImageOrNull(mask.image);
if (img) {
drawImageWithFit(ctx, img, mx, my, mw, mh, mask.imageFit ?? 'fill');
} else {
ctx.fillStyle = '#000';
traceRoundedRect(ctx, mx, my, mw, mh, resolveMaskCornerRadiiPx(mask, scale, mw, mh));
ctx.fill();
}
} else {
ctx.fillStyle = '#000';
traceRoundedRect(ctx, mx, my, mw, mh, resolveMaskCornerRadiiPx(mask, scale, mw, mh));
ctx.fill();
}
ctx.restore();
}

View File

@@ -824,9 +824,9 @@ html.app-dark-shell body {
cursor: pointer; cursor: pointer;
} }
.ps-layer-item-actions button:hover:not(:disabled) { /* .ps-layer-item-actions button:hover:not(:disabled) {
background: #555; background: #555;
} } */
.ps-layer-item-actions button:disabled { .ps-layer-item-actions button:disabled {
opacity: 0.2; opacity: 0.2;
@@ -870,10 +870,6 @@ html.app-dark-shell body {
.ps-unselected-dim-layer--visible { .ps-unselected-dim-layer--visible {
opacity: 1; opacity: 1;
}
.ps-unselected-dim {
position: absolute;
background: rgba(255, 255, 255, 0.3); background: rgba(255, 255, 255, 0.3);
} }

View File

@@ -27,6 +27,7 @@ import {
buildTableGridBoundariesPx, buildTableGridBoundariesPx,
getCellRectPx, getCellRectPx,
} from './tableUtils'; } from './tableUtils';
import { applyElementMask, isElementMaskActive } from './elementMask';
const GENERATOR_TRANSPARENT_BG = '#00000000'; const GENERATOR_TRANSPARENT_BG = '#00000000';
@@ -700,6 +701,79 @@ export function resolveReferenceBackgroundForOutput(
}; };
} }
export type LayoutPageBackgroundRenderOptions = {
background: string;
opacity: number;
fit: 'contain' | 'cover' | 'fill';
};
type LayoutPageBackgroundTemplate = Pick<
LabelTemplate,
| 'layoutPageBackground'
| 'layoutPageBackgroundOpacity'
| 'layoutPageBackgroundFit'
| 'enableLayoutPageBackground'
| 'includeLayoutPageBackgroundInOutput'
>;
function buildLayoutPageBackgroundRenderOptions(
template: LayoutPageBackgroundTemplate,
forOutput: boolean
): LayoutPageBackgroundRenderOptions | null {
if (!template.layoutPageBackground) return null;
if (!forOutput && template.enableLayoutPageBackground === false) return null;
if (forOutput && !template.includeLayoutPageBackgroundInOutput) return null;
return {
background: template.layoutPageBackground,
opacity: template.layoutPageBackgroundOpacity ?? 100,
fit: template.layoutPageBackgroundFit ?? 'cover',
};
}
/** 排版预览:整页背景渲染参数 */
export function resolveLayoutPageBackgroundForPreview(
template: LayoutPageBackgroundTemplate
): LayoutPageBackgroundRenderOptions | null {
return buildLayoutPageBackgroundRenderOptions(template, false);
}
/** PDF / 打印:整页背景渲染参数 */
export function resolveLayoutPageBackgroundForOutput(
template: LayoutPageBackgroundTemplate
): LayoutPageBackgroundRenderOptions | null {
return buildLayoutPageBackgroundRenderOptions(template, true);
}
/** 将整页背景渲染为 data URL白底 + 背景图) */
export async function renderLayoutPageBackgroundDataUrl(
paperWidthMm: number,
paperHeightMm: number,
options: LayoutPageBackgroundRenderOptions,
scale = 4,
imageFormat: 'png' | 'jpeg' = 'png'
): Promise<string | null> {
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(paperWidthMm * scale));
canvas.height = Math.max(1, Math.round(paperHeightMm * scale));
const ctx = canvas.getContext('2d');
if (!ctx) return null;
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
const img = await loadImageOrNull(options.background);
if (!img) return null;
ctx.save();
ctx.globalAlpha = Math.min(1, Math.max(0, options.opacity / 100));
drawImageWithFit(ctx, img, 0, 0, canvas.width, canvas.height, options.fit);
ctx.restore();
return imageFormat === 'jpeg'
? canvas.toDataURL('image/jpeg', 0.92)
: canvas.toDataURL('image/png');
}
/** 离屏渲染标签为 PNG data URL */ /** 离屏渲染标签为 PNG data URL */
export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise<string> { export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise<string> {
const { const {
@@ -891,6 +965,10 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
); );
} }
if (elem.mask && isElementMaskActive(elem.mask)) {
await applyElementMask(ctx, elem, ex, ey, scale, elem.mask);
}
ctx.restore(); ctx.restore();
} }

View File

@@ -1,6 +1,10 @@
import { jsPDF } from 'jspdf'; import { jsPDF } from 'jspdf';
import type { PaperConfig, LabelTemplate, CutLineConfig } from './types'; import type { PaperConfig, LabelTemplate, CutLineConfig } from './types';
import { collectGridCutLinePositions, getCutLineChannel, getCutLineDashSegmentMm, getCutLineStrokeWidthMm, type PrintableLabel } from './utils'; import { collectGridCutLinePositions, getCutLineChannel, getCutLineDashSegmentMm, getCutLineStrokeWidthMm, type PrintableLabel } from './utils';
import {
resolveLayoutPageBackgroundForOutput,
renderLayoutPageBackgroundDataUrl,
} from './labelRenderer';
export interface PdfExportProgress { export interface PdfExportProgress {
done: number; done: number;
@@ -26,6 +30,36 @@ const yieldToMain = () => new Promise<void>((resolve) => setTimeout(resolve, 0))
const PAGE_RENDER_CONCURRENCY = 3; const PAGE_RENDER_CONCURRENCY = 3;
type PageBackgroundCache = { dataUrl: string | null; key: string | null };
async function addPageBackgroundIfNeeded(
pdf: jsPDF,
template: LabelTemplate,
paperW: number,
paperH: number,
imageFormat: 'PNG' | 'JPEG',
cache: PageBackgroundCache
) {
const options = resolveLayoutPageBackgroundForOutput(template);
if (!options) return;
const key = `${options.background}|${options.opacity}|${options.fit}|${paperW}|${paperH}|${imageFormat}`;
if (cache.key !== key) {
cache.key = key;
cache.dataUrl = await renderLayoutPageBackgroundDataUrl(
paperW,
paperH,
options,
4,
imageFormat === 'JPEG' ? 'jpeg' : 'png'
);
}
if (cache.dataUrl) {
pdf.addImage(cache.dataUrl, imageFormat, 0, 0, paperW, paperH, undefined, 'FAST');
}
}
function parseColorToRgb(color: string): [number, number, number] { function parseColorToRgb(color: string): [number, number, number] {
const c = color.trim(); const c = color.trim();
if (c.startsWith('#')) { if (c.startsWith('#')) {
@@ -141,6 +175,7 @@ export async function exportLabelsPdfAsync(options: AsyncExportLabelsPdfOptions)
let done = 0; let done = 0;
let progressTick = 0; let progressTick = 0;
const pageBgCache: PageBackgroundCache = { dataUrl: null, key: null };
const report = (page: number, phase: PdfExportProgress['phase']) => { const report = (page: number, phase: PdfExportProgress['phase']) => {
onProgress?.({ done, total: totalLabels, page, totalPages, phase }); onProgress?.({ done, total: totalLabels, page, totalPages, phase });
@@ -151,6 +186,8 @@ export async function exportLabelsPdfAsync(options: AsyncExportLabelsPdfOptions)
pdf.addPage([paperW, paperH], orientation); pdf.addPage([paperW, paperH], orientation);
} }
await addPageBackgroundIfNeeded(pdf, template, paperW, paperH, imageFormat, pageBgCache);
const pageRows = printablePages[pIdx]; const pageRows = printablePages[pIdx];
const gridRows = Math.ceil(pageRows.length / gridCols); const gridRows = Math.ceil(pageRows.length / gridCols);
const cutChannel = getCutLineChannel(paper.columnGap, paper.rowGap, cutLine); const cutChannel = getCutLineChannel(paper.columnGap, paper.rowGap, cutLine);
@@ -242,6 +279,7 @@ export async function exportLabelsPdfBlobAsync(
let done = 0; let done = 0;
let progressTick = 0; let progressTick = 0;
const pageBgCache: PageBackgroundCache = { dataUrl: null, key: null };
const report = (page: number, phase: PdfExportProgress['phase']) => { const report = (page: number, phase: PdfExportProgress['phase']) => {
onProgress?.({ done, total: totalLabels, page, totalPages, phase }); onProgress?.({ done, total: totalLabels, page, totalPages, phase });
@@ -252,6 +290,8 @@ export async function exportLabelsPdfBlobAsync(
pdf.addPage([paperW, paperH], orientation); pdf.addPage([paperW, paperH], orientation);
} }
await addPageBackgroundIfNeeded(pdf, template, paperW, paperH, imageFormat, pageBgCache);
const pageRows = printablePages[pIdx]; const pageRows = printablePages[pIdx];
const gridRows = Math.ceil(pageRows.length / gridCols); const gridRows = Math.ceil(pageRows.length / gridCols);
const cutChannel = getCutLineChannel(paper.columnGap, paper.rowGap, cutLine); const cutChannel = getCutLineChannel(paper.columnGap, paper.rowGap, cutLine);

View File

@@ -214,6 +214,35 @@ export interface TemplateElement {
tableBorderWidth?: number; tableBorderWidth?: number;
/** 单元格网格线颜色,仅 type=table */ /** 单元格网格线颜色,仅 type=table */
tableBorderColor?: string; tableBorderColor?: string;
/** 图层蒙版;在预览与导出时裁剪元素可见区域 */
mask?: ElementMaskConfig;
}
export type ElementMaskShape = 'rect' | 'image';
export interface ElementMaskConfig {
/** 是否启用蒙版 */
enabled?: boolean;
/** 蒙版形状:矩形矢量区域或图片 alpha */
type?: ElementMaskShape;
/** 相对元素左上角的 X 偏移 (mm) */
x?: number;
/** 相对元素左上角的 Y 偏移 (mm) */
y?: number;
/** 蒙版区域宽度 (mm),默认与元素同宽 */
width?: number;
/** 蒙版区域高度 (mm),默认与元素同高 */
height?: number;
/** 圆角半径 (mm) */
borderRadius?: number;
borderRadiusTL?: number;
borderRadiusTR?: number;
borderRadiusBR?: number;
borderRadiusBL?: number;
/** 图片蒙版data URI / URLtype=image 时使用 */
image?: string;
/** 图片蒙版在区域内的适应方式 */
imageFit?: 'contain' | 'cover' | 'fill';
} }
export interface DataSourceMeta { export interface DataSourceMeta {
@@ -268,6 +297,16 @@ export interface LabelTemplate {
enablePreviewReferenceBackground?: boolean; enablePreviewReferenceBackground?: boolean;
/** 生成 PDF / 打印 / 排版预览输出时是否绘制参考背景,默认 false */ /** 生成 PDF / 打印 / 排版预览输出时是否绘制参考背景,默认 false */
includeReferenceBackgroundInOutput?: boolean; includeReferenceBackgroundInOutput?: boolean;
/** 排版整页背景图URL / data URI */
layoutPageBackground?: string;
/** 排版整页背景不透明度 0100默认 100 */
layoutPageBackgroundOpacity?: number;
/** 排版整页背景适应方式,默认 cover */
layoutPageBackgroundFit?: 'contain' | 'cover' | 'fill';
/** 排版预览是否显示整页背景,默认 true */
enableLayoutPageBackground?: boolean;
/** PDF / 打印输出时是否绘制整页背景,默认 false */
includeLayoutPageBackgroundInOutput?: boolean;
/** 批量导出图片时的文件名模板,支持 {#SEQ}、{#INDEX}、{变量名};留空则按序号补零命名 */ /** 批量导出图片时的文件名模板,支持 {#SEQ}、{#INDEX}、{变量名};留空则按序号补零命名 */
exportImageFileNamePattern?: string; exportImageFileNamePattern?: string;
/** 批量导出图片all=全部导出conditional=按条件过滤 */ /** 批量导出图片all=全部导出conditional=按条件过滤 */