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

@@ -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 { 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" />

View File

@@ -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">

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';
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" />

View File

@@ -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}

View File

@@ -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>
))}

View File

@@ -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">
XY
</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}