蒙版
This commit is contained in:
@@ -1448,6 +1448,7 @@ export default function App() {
|
||||
{workflowStep === 'print-view' && isMobile && hasTemplates && activeTemplate && (
|
||||
<MobileExportView
|
||||
template={activeTemplate}
|
||||
onChangeTemplate={handleTemplateChange}
|
||||
templateName={activeTemplate.name ?? '标签'}
|
||||
templateVariables={templateVariables}
|
||||
importData={importData}
|
||||
@@ -1579,6 +1580,7 @@ export default function App() {
|
||||
paper={paper}
|
||||
onChangePaper={handlePaperChange}
|
||||
template={activeTemplate}
|
||||
onChangeTemplate={handleTemplateChange}
|
||||
rows={mappedRows}
|
||||
draftExportRange={draftExportRange}
|
||||
onChangeDraftExportRange={setDraftExportRange}
|
||||
|
||||
285
src/components/ElementMaskDialog.tsx
Normal file
285
src/components/ElementMaskDialog.tsx
Normal 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
|
||||
);
|
||||
};
|
||||
@@ -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<ExportSidebarProps> = ({
|
||||
paper,
|
||||
onChangePaper,
|
||||
template,
|
||||
onChangeTemplate,
|
||||
rows,
|
||||
draftExportRange,
|
||||
onChangeDraftExportRange,
|
||||
@@ -228,6 +231,9 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
|
||||
exportRange={draftExportRange}
|
||||
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-title">
|
||||
<Settings className="w-3 h-3" />
|
||||
|
||||
@@ -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<LabelDesignerProps> = ({
|
||||
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<LabelDesignerProps> = ({
|
||||
>
|
||||
<div
|
||||
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
|
||||
ref={(node) => {
|
||||
@@ -1645,6 +1675,24 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
||||
</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>
|
||||
)}
|
||||
{resizeFloatElements && resizeFloatElements.length > 0 && (
|
||||
@@ -1775,32 +1823,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
||||
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 (
|
||||
<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 && (
|
||||
@@ -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 />
|
||||
)}
|
||||
|
||||
{isSelected &&
|
||||
!hideSelectionChrome &&
|
||||
!isLocked &&
|
||||
selectedElementIds.length === 1 &&
|
||||
!isBeingResized && (
|
||||
<div
|
||||
className="absolute inset-0 ps-resize-outline pointer-events-none z-[60]"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
|
||||
{showResizeHandles && (
|
||||
<>
|
||||
<div
|
||||
@@ -1997,6 +2031,25 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
||||
</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>
|
||||
@@ -2028,14 +2081,17 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
||||
<span>
|
||||
文档: {template.width} × {template.height} mm
|
||||
</span>
|
||||
{selectedElemObj && (
|
||||
{selectedElemObj && selectedElementIds.length === 1 && (
|
||||
<span className="text-[#31a8ff]">
|
||||
| {selectedElemObj.name} — X:{selectedElemObj.x} Y:{selectedElemObj.y} W:
|
||||
{selectedElemObj.width} H:{selectedElemObj.height} mm
|
||||
</span>
|
||||
)}
|
||||
{selectedElementIds.length > 1 && (
|
||||
<span className="text-emerald-400">| 已选 {selectedElementIds.length} 个</span>
|
||||
{multiSelectionBounds && (
|
||||
<span className="text-emerald-400">
|
||||
| 已选 {selectedElementIds.length} 个 — X:{multiSelectionBounds.minX} Y:
|
||||
{multiSelectionBounds.minY} mm
|
||||
</span>
|
||||
)}
|
||||
{activeTool === 'move' && (
|
||||
<span className="text-[#888] hidden sm:inline">
|
||||
|
||||
146
src/components/LayoutPageBackgroundFields.tsx
Normal file
146
src/components/LayoutPageBackgroundFields.tsx
Normal 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>
|
||||
);
|
||||
@@ -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<MobileExportLayoutPanelProps> = ({
|
||||
template,
|
||||
onChangeTemplate,
|
||||
paper,
|
||||
onChangePaper,
|
||||
rows,
|
||||
@@ -40,6 +43,9 @@ export const MobileExportLayoutPanel: React.FC<MobileExportLayoutPanelProps> = (
|
||||
exportRange={exportRange}
|
||||
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-title">
|
||||
<Settings className="w-3 h-3" />
|
||||
|
||||
@@ -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<MobileExportViewProps> = ({
|
||||
template,
|
||||
onChangeTemplate,
|
||||
templateName,
|
||||
templateVariables,
|
||||
importData,
|
||||
@@ -277,6 +279,7 @@ export const MobileExportView: React.FC<MobileExportViewProps> = ({
|
||||
<div className="ps-panel-body p-3 min-h-0">
|
||||
<MobileExportLayoutPanel
|
||||
template={template}
|
||||
onChangeTemplate={onChangeTemplate}
|
||||
paper={paper}
|
||||
onChangePaper={onChangePaper}
|
||||
rows={rows}
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
type PrintableLabel,
|
||||
} from '../utils';
|
||||
import { CanvasLabelImage } from './CanvasLabelImage';
|
||||
import { resolveReferenceBackgroundForOutput } from '../labelRenderer';
|
||||
import { resolveReferenceBackgroundForOutput, resolveLayoutPageBackgroundForPreview } from '../labelRenderer';
|
||||
import { CommitInput } from './CommitInput';
|
||||
import { useAppDialog } from './AppDialog';
|
||||
import { FieldSelect } from './PsSelect';
|
||||
@@ -605,6 +605,15 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
|
||||
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<LayoutPreviewProps> = ({
|
||||
</span> */}
|
||||
</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={{
|
||||
width: `${pageCardWidthPx}px`,
|
||||
height: `${currentPaperH * previewScale}px`,
|
||||
}}
|
||||
>
|
||||
{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
|
||||
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`,
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="grid bg-white relative"
|
||||
className="grid bg-transparent relative"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${gridInfo.cols}, ${labelWPx}px)`,
|
||||
gridAutoRows: `${labelHPx}px`,
|
||||
@@ -846,6 +871,7 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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<PropSidebarProps> = ({
|
||||
const [visibilityRuleDialog, setVisibilityRuleDialog] = useState<
|
||||
{ mode: 'add' } | { mode: 'edit'; index: number } | null
|
||||
>(null);
|
||||
const [maskDialogElementId, setMaskDialogElementId] = useState<string | null>(null);
|
||||
const [panelCollapsed, setPanelCollapsed] = useState<PanelCollapseState>(loadPanelCollapseState);
|
||||
const [internalMobileModule, setInternalMobileModule] =
|
||||
useState<MobilePropModule>('properties');
|
||||
@@ -288,6 +294,24 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
||||
.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<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[]) => {
|
||||
if (!selectedElem) return;
|
||||
handleElemChange({
|
||||
@@ -1710,39 +1747,60 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
||||
<div className="space-y-3">
|
||||
<h5 className="ps-section-title">
|
||||
<Ruler className="w-3.5 h-3.5" />
|
||||
位置与尺寸 (mm)
|
||||
{isMultiSelect && multiSelectionBounds ? '选区位置 (mm)' : '位置与尺寸 (mm)'}
|
||||
</h5>
|
||||
{isMultiSelect && multiSelectionBounds && (
|
||||
<p className="text-[10px] text-[#666] leading-snug">
|
||||
X、Y 为选中元素整体包围框左上角,修改后将整体平移
|
||||
</p>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="ps-field-label">X 坐标 (左边距)</label>
|
||||
<label className="ps-field-label">
|
||||
{isMultiSelect ? 'X 坐标 (选区左边距)' : 'X 坐标 (左边距)'}
|
||||
</label>
|
||||
<PropInput
|
||||
type="number"
|
||||
step="0.5"
|
||||
value={selectedElem.x}
|
||||
value={isMultiSelect && multiSelectionBounds ? multiSelectionBounds.minX : selectedElem.x}
|
||||
onCommit={(val) => {
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="ps-field-label">Y 坐标 (顶边距)</label>
|
||||
<label className="ps-field-label">
|
||||
{isMultiSelect ? 'Y 坐标 (选区顶边距)' : 'Y 坐标 (顶边距)'}
|
||||
</label>
|
||||
<PropInput
|
||||
type="number"
|
||||
step="0.5"
|
||||
value={selectedElem.y}
|
||||
value={isMultiSelect && multiSelectionBounds ? multiSelectionBounds.minY : selectedElem.y}
|
||||
onCommit={(val) => {
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
{!isMultiSelect && (
|
||||
<>
|
||||
<div>
|
||||
<label className="ps-field-label">元素宽度</label>
|
||||
<PropInput
|
||||
@@ -1805,6 +1863,8 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2251,6 +2311,23 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
||||
</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
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); toggleLayerVisibility(actualIdx); }}
|
||||
@@ -2344,14 +2421,16 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
||||
|
||||
{primaryElem && (
|
||||
<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">
|
||||
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>
|
||||
{nudgeableElementIds.length > 1 && (
|
||||
<p className="text-[10px] text-[#888] mt-1">
|
||||
已选 {nudgeableElementIds.length} 个元素,将一起移动
|
||||
</p>
|
||||
<p className="text-[10px] text-[#888] mt-1">将一起移动</p>
|
||||
)}
|
||||
</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') {
|
||||
return (
|
||||
<div className="mobile-design-props">
|
||||
{visibilityRuleDialogNode}
|
||||
{elementMaskDialogNode}
|
||||
<nav className="mobile-design-props-nav" aria-label="属性模块">
|
||||
{MOBILE_PROP_MODULES.map((item) => (
|
||||
<button
|
||||
@@ -2434,6 +2526,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
||||
return (
|
||||
<div className="ps-sidebar-stack">
|
||||
{visibilityRuleDialogNode}
|
||||
{elementMaskDialogNode}
|
||||
<CollapsiblePanelSection
|
||||
sectionClassName="properties-panel flex flex-col"
|
||||
collapsed={panelCollapsed.properties}
|
||||
|
||||
@@ -28,6 +28,30 @@ export function getSelectionBounds(selected: TemplateElement[]) {
|
||||
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(
|
||||
elements: TemplateElement[],
|
||||
|
||||
149
src/elementMask.ts
Normal file
149
src/elementMask.ts
Normal 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();
|
||||
}
|
||||
@@ -824,9 +824,9 @@ html.app-dark-shell body {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ps-layer-item-actions button:hover:not(:disabled) {
|
||||
/* .ps-layer-item-actions button:hover:not(:disabled) {
|
||||
background: #555;
|
||||
}
|
||||
} */
|
||||
|
||||
.ps-layer-item-actions button:disabled {
|
||||
opacity: 0.2;
|
||||
@@ -870,10 +870,6 @@ html.app-dark-shell body {
|
||||
|
||||
.ps-unselected-dim-layer--visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.ps-unselected-dim {
|
||||
position: absolute;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
buildTableGridBoundariesPx,
|
||||
getCellRectPx,
|
||||
} from './tableUtils';
|
||||
import { applyElementMask, isElementMaskActive } from './elementMask';
|
||||
|
||||
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 */
|
||||
export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise<string> {
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { jsPDF } from 'jspdf';
|
||||
import type { PaperConfig, LabelTemplate, CutLineConfig } from './types';
|
||||
import { collectGridCutLinePositions, getCutLineChannel, getCutLineDashSegmentMm, getCutLineStrokeWidthMm, type PrintableLabel } from './utils';
|
||||
import {
|
||||
resolveLayoutPageBackgroundForOutput,
|
||||
renderLayoutPageBackgroundDataUrl,
|
||||
} from './labelRenderer';
|
||||
|
||||
export interface PdfExportProgress {
|
||||
done: number;
|
||||
@@ -26,6 +30,36 @@ const yieldToMain = () => new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
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] {
|
||||
const c = color.trim();
|
||||
if (c.startsWith('#')) {
|
||||
@@ -141,6 +175,7 @@ export async function exportLabelsPdfAsync(options: AsyncExportLabelsPdfOptions)
|
||||
|
||||
let done = 0;
|
||||
let progressTick = 0;
|
||||
const pageBgCache: PageBackgroundCache = { dataUrl: null, key: null };
|
||||
|
||||
const report = (page: number, phase: PdfExportProgress['phase']) => {
|
||||
onProgress?.({ done, total: totalLabels, page, totalPages, phase });
|
||||
@@ -151,6 +186,8 @@ export async function exportLabelsPdfAsync(options: AsyncExportLabelsPdfOptions)
|
||||
pdf.addPage([paperW, paperH], orientation);
|
||||
}
|
||||
|
||||
await addPageBackgroundIfNeeded(pdf, template, paperW, paperH, imageFormat, pageBgCache);
|
||||
|
||||
const pageRows = printablePages[pIdx];
|
||||
const gridRows = Math.ceil(pageRows.length / gridCols);
|
||||
const cutChannel = getCutLineChannel(paper.columnGap, paper.rowGap, cutLine);
|
||||
@@ -242,6 +279,7 @@ export async function exportLabelsPdfBlobAsync(
|
||||
|
||||
let done = 0;
|
||||
let progressTick = 0;
|
||||
const pageBgCache: PageBackgroundCache = { dataUrl: null, key: null };
|
||||
|
||||
const report = (page: number, phase: PdfExportProgress['phase']) => {
|
||||
onProgress?.({ done, total: totalLabels, page, totalPages, phase });
|
||||
@@ -252,6 +290,8 @@ export async function exportLabelsPdfBlobAsync(
|
||||
pdf.addPage([paperW, paperH], orientation);
|
||||
}
|
||||
|
||||
await addPageBackgroundIfNeeded(pdf, template, paperW, paperH, imageFormat, pageBgCache);
|
||||
|
||||
const pageRows = printablePages[pIdx];
|
||||
const gridRows = Math.ceil(pageRows.length / gridCols);
|
||||
const cutChannel = getCutLineChannel(paper.columnGap, paper.rowGap, cutLine);
|
||||
|
||||
39
src/types.ts
39
src/types.ts
@@ -214,6 +214,35 @@ export interface TemplateElement {
|
||||
tableBorderWidth?: number;
|
||||
/** 单元格网格线颜色,仅 type=table */
|
||||
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 / URL),type=image 时使用 */
|
||||
image?: string;
|
||||
/** 图片蒙版在区域内的适应方式 */
|
||||
imageFit?: 'contain' | 'cover' | 'fill';
|
||||
}
|
||||
|
||||
export interface DataSourceMeta {
|
||||
@@ -268,6 +297,16 @@ export interface LabelTemplate {
|
||||
enablePreviewReferenceBackground?: boolean;
|
||||
/** 生成 PDF / 打印 / 排版预览输出时是否绘制参考背景,默认 false */
|
||||
includeReferenceBackgroundInOutput?: boolean;
|
||||
/** 排版整页背景图(URL / data URI) */
|
||||
layoutPageBackground?: string;
|
||||
/** 排版整页背景不透明度 0–100,默认 100 */
|
||||
layoutPageBackgroundOpacity?: number;
|
||||
/** 排版整页背景适应方式,默认 cover */
|
||||
layoutPageBackgroundFit?: 'contain' | 'cover' | 'fill';
|
||||
/** 排版预览是否显示整页背景,默认 true */
|
||||
enableLayoutPageBackground?: boolean;
|
||||
/** PDF / 打印输出时是否绘制整页背景,默认 false */
|
||||
includeLayoutPageBackgroundInOutput?: boolean;
|
||||
/** 批量导出图片时的文件名模板,支持 {#SEQ}、{#INDEX}、{变量名};留空则按序号补零命名 */
|
||||
exportImageFileNamePattern?: string;
|
||||
/** 批量导出图片:all=全部导出,conditional=按条件过滤 */
|
||||
|
||||
Reference in New Issue
Block a user