蒙版
This commit is contained in:
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`,
|
||||
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
|
||||
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={{
|
||||
gridTemplateColumns: `repeat(${gridInfo.cols}, ${labelWPx}px)`,
|
||||
gridAutoRows: `${labelHPx}px`,
|
||||
@@ -844,6 +869,7 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
|
||||
);
|
||||
})}
|
||||
</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}
|
||||
|
||||
Reference in New Issue
Block a user