Compare commits
15 Commits
ccb5b119de
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33d3e5537c | ||
|
|
1b04e34ff0 | ||
|
|
3a50e7e33b | ||
|
|
43900a8b52 | ||
|
|
25fa186d02 | ||
|
|
455946c51c | ||
|
|
fcd587bd50 | ||
|
|
1d506e0c71 | ||
|
|
ac117425d7 | ||
|
|
8c9b53b1b6 | ||
|
|
b4c2cf07d0 | ||
|
|
2bd89c517c | ||
|
|
4955b1f2e2 | ||
|
|
55867ae12d | ||
|
|
846061ab48 |
161
src/App.tsx
161
src/App.tsx
@@ -21,6 +21,7 @@ import { MobileExportView } from './components/MobileExportView';
|
|||||||
import { MobileDesignView } from './components/MobileDesignView';
|
import { MobileDesignView } from './components/MobileDesignView';
|
||||||
import type { TableCellCoord } from './tableUtils';
|
import type { TableCellCoord } from './tableUtils';
|
||||||
import { LayoutPreview } from './components/PreviewGrid';
|
import { LayoutPreview } from './components/PreviewGrid';
|
||||||
|
import { LabelLayoutSizeCalculator } from './components/LabelLayoutSizeCalculator';
|
||||||
import { CanvasLabelImage } from './components/CanvasLabelImage';
|
import { CanvasLabelImage } from './components/CanvasLabelImage';
|
||||||
import { renderLabelToDataUrl, resolveReferenceBackgroundForOutput, resolveReferenceBackgroundForPreview } from './labelRenderer';
|
import { renderLabelToDataUrl, resolveReferenceBackgroundForOutput, resolveReferenceBackgroundForPreview } from './labelRenderer';
|
||||||
import { exportLabelsPdfAsync, exportLabelsPdfBlobAsync, type PdfExportProgress } from './pdfExport';
|
import { exportLabelsPdfAsync, exportLabelsPdfBlobAsync, type PdfExportProgress } from './pdfExport';
|
||||||
@@ -35,6 +36,7 @@ import {
|
|||||||
} from './labelPrint';
|
} from './labelPrint';
|
||||||
import { useIsMobile } from './hooks/useIsMobile';
|
import { useIsMobile } from './hooks/useIsMobile';
|
||||||
import { useUndoRedo } from './hooks/useUndoRedo';
|
import { useUndoRedo } from './hooks/useUndoRedo';
|
||||||
|
import { useElementClipboard } from './hooks/useElementClipboard';
|
||||||
import { useAppDialog } from './components/AppDialog';
|
import { useAppDialog } from './components/AppDialog';
|
||||||
import { AnchoredMenu } from './components/AnchoredMenu';
|
import { AnchoredMenu } from './components/AnchoredMenu';
|
||||||
import {
|
import {
|
||||||
@@ -78,7 +80,7 @@ function fitTemplatePreviewSize(widthMm: number, heightMm: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { showAlert, showConfirm } = useAppDialog();
|
const { showAlert, showConfirm, showToast } = useAppDialog();
|
||||||
|
|
||||||
// --- 1. Templates Storage & Initial States ---
|
// --- 1. Templates Storage & Initial States ---
|
||||||
const [templates, setTemplates] = useState<LabelTemplate[]>(() => {
|
const [templates, setTemplates] = useState<LabelTemplate[]>(() => {
|
||||||
@@ -168,8 +170,9 @@ export default function App() {
|
|||||||
// Modal / Inline input values for template creations
|
// Modal / Inline input values for template creations
|
||||||
const [showAddTmplForm, setShowAddTmplForm] = useState<boolean>(false);
|
const [showAddTmplForm, setShowAddTmplForm] = useState<boolean>(false);
|
||||||
const [newTmplName, setNewTmplName] = useState<string>('');
|
const [newTmplName, setNewTmplName] = useState<string>('');
|
||||||
const [newTmplW, setNewTmplW] = useState<number>(75);
|
const [newTmplW, setNewTmplW] = useState('75');
|
||||||
const [newTmplH, setNewTmplH] = useState<number>(45);
|
const [newTmplH, setNewTmplH] = useState('45');
|
||||||
|
const [newTmplPaper, setNewTmplPaper] = useState<PaperConfig | null>(null);
|
||||||
const [editingTemplateId, setEditingTemplateId] = useState<string | null>(null);
|
const [editingTemplateId, setEditingTemplateId] = useState<string | null>(null);
|
||||||
const [editTmplName, setEditTmplName] = useState<string>('');
|
const [editTmplName, setEditTmplName] = useState<string>('');
|
||||||
const [templateMenuId, setTemplateMenuId] = useState<string | null>(null);
|
const [templateMenuId, setTemplateMenuId] = useState<string | null>(null);
|
||||||
@@ -197,25 +200,35 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Modify active template elements
|
// Modify active template elements
|
||||||
const handleTemplateChange = (updated: LabelTemplate) => {
|
const activeTemplateRef = useRef(activeTemplate);
|
||||||
const updatedList = templates.map(t => t.id === updated.id ? updated : t);
|
activeTemplateRef.current = activeTemplate;
|
||||||
saveTemplatesToStorage(updatedList);
|
|
||||||
};
|
const handleTemplateChange = useCallback((updated: LabelTemplate) => {
|
||||||
|
setTemplates((prev) => {
|
||||||
|
const updatedList = prev.map((t) => (t.id === updated.id ? updated : t));
|
||||||
|
try {
|
||||||
|
const stripped = updatedList.map(stripTemplateForStorage);
|
||||||
|
localStorage.setItem('label_templates_list_v2', JSON.stringify(stripped));
|
||||||
|
} catch (e) {}
|
||||||
|
return updatedList;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleDesignTemplateChange = useCallback(
|
const handleDesignTemplateChange = useCallback(
|
||||||
(updated: LabelTemplate) => {
|
(updated: LabelTemplate) => {
|
||||||
|
const current = activeTemplateRef.current;
|
||||||
if (
|
if (
|
||||||
!skipDesignHistoryRef.current &&
|
!skipDesignHistoryRef.current &&
|
||||||
workflowStep === 'template-design' &&
|
workflowStep === 'template-design' &&
|
||||||
activeTemplate &&
|
current &&
|
||||||
updated.id === activeTemplate.id
|
updated.id === current.id
|
||||||
) {
|
) {
|
||||||
designHistory.record(activeTemplate, updated);
|
designHistory.record(current, updated);
|
||||||
}
|
}
|
||||||
skipDesignHistoryRef.current = false;
|
skipDesignHistoryRef.current = false;
|
||||||
handleTemplateChange(updated);
|
handleTemplateChange(updated);
|
||||||
},
|
},
|
||||||
[workflowStep, activeTemplate, designHistory, templates]
|
[workflowStep, designHistory, handleTemplateChange]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDesignUndo = useCallback(() => {
|
const handleDesignUndo = useCallback(() => {
|
||||||
@@ -249,6 +262,30 @@ export default function App() {
|
|||||||
[activeTemplate]
|
[activeTemplate]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleCopyElementsSuccess = useCallback(
|
||||||
|
(count: number) => {
|
||||||
|
showToast(`已复制 ${count} 个元素`, { variant: 'success' });
|
||||||
|
},
|
||||||
|
[showToast]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDuplicateElementsSuccess = useCallback(
|
||||||
|
(count: number) => {
|
||||||
|
showToast(`已创建 ${count} 个副本`, { variant: 'success' });
|
||||||
|
},
|
||||||
|
[showToast]
|
||||||
|
);
|
||||||
|
|
||||||
|
const elementClipboard = useElementClipboard({
|
||||||
|
template: activeTemplate ?? EMPTY_TEMPLATE_PLACEHOLDER,
|
||||||
|
onChange: handleDesignTemplateChange,
|
||||||
|
selectedElementIds,
|
||||||
|
onSelectElements: handleSelectElements,
|
||||||
|
onSelectTableCells: setSelectedTableCells,
|
||||||
|
onCopySuccess: handleCopyElementsSuccess,
|
||||||
|
onDuplicateSuccess: handleDuplicateElementsSuccess,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (workflowStep !== 'template-design' || !activeTemplate?.id) return;
|
if (workflowStep !== 'template-design' || !activeTemplate?.id) return;
|
||||||
const needReset =
|
const needReset =
|
||||||
@@ -370,31 +407,24 @@ export default function App() {
|
|||||||
await showAlert('请先输入合法的模板名', { variant: 'warning' });
|
await showAlert('请先输入合法的模板名', { variant: 'warning' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const width = Number(newTmplW);
|
||||||
|
const height = Number(newTmplH);
|
||||||
|
if (!newTmplW.trim() || Number.isNaN(width) || width < 10) {
|
||||||
|
await showAlert('请输入合法的标签宽度(>10 mm)', { variant: 'warning' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!newTmplH.trim() || Number.isNaN(height) || height < 10) {
|
||||||
|
await showAlert('请输入合法的标签高度(>10 mm)', { variant: 'warning' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const newId = `custom_tmpl_${Date.now()}`;
|
const newId = `custom_tmpl_${Date.now()}`;
|
||||||
const newTmpl: LabelTemplate = {
|
const newTmpl: LabelTemplate = {
|
||||||
id: newId,
|
id: newId,
|
||||||
name: `${newTmplName}`,
|
name: `${newTmplName}`,
|
||||||
width: Math.max(10, Number(newTmplW) || 70),
|
width,
|
||||||
height: Math.max(10, Number(newTmplH) || 40),
|
height,
|
||||||
elements: [
|
elements: [],
|
||||||
{
|
paper: newTmplPaper ?? DEFAULT_PAPER_CONFIG,
|
||||||
id: `desc_welcome`,
|
|
||||||
type: 'text',
|
|
||||||
name: '主文字内容',
|
|
||||||
x: 5,
|
|
||||||
y: 5,
|
|
||||||
width: Math.max(10, newTmplW - 10),
|
|
||||||
height: 10,
|
|
||||||
content: '{变量}',
|
|
||||||
fontSize: 12,
|
|
||||||
textAlign: 'center',
|
|
||||||
fontWeight: 'bold',
|
|
||||||
barcodeFormat: 'CODE128',
|
|
||||||
showText: false,
|
|
||||||
fontFamily: 'sans'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
paper: DEFAULT_PAPER_CONFIG,
|
|
||||||
cutLine: DEFAULT_CUT_LINE_CONFIG,
|
cutLine: DEFAULT_CUT_LINE_CONFIG,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -402,6 +432,7 @@ export default function App() {
|
|||||||
saveTemplatesToStorage(list);
|
saveTemplatesToStorage(list);
|
||||||
handleSelectTemplate(newId);
|
handleSelectTemplate(newId);
|
||||||
setNewTmplName('');
|
setNewTmplName('');
|
||||||
|
setNewTmplPaper(null);
|
||||||
setShowAddTmplForm(false);
|
setShowAddTmplForm(false);
|
||||||
setWorkflowStep('template-design');
|
setWorkflowStep('template-design');
|
||||||
};
|
};
|
||||||
@@ -409,8 +440,9 @@ export default function App() {
|
|||||||
const closeAddTemplateDialog = () => {
|
const closeAddTemplateDialog = () => {
|
||||||
setShowAddTmplForm(false);
|
setShowAddTmplForm(false);
|
||||||
setNewTmplName('');
|
setNewTmplName('');
|
||||||
setNewTmplW(75);
|
setNewTmplW('75');
|
||||||
setNewTmplH(45);
|
setNewTmplH('45');
|
||||||
|
setNewTmplPaper(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEditTemplateDialog = (tmpl: LabelTemplate) => {
|
const openEditTemplateDialog = (tmpl: LabelTemplate) => {
|
||||||
@@ -738,13 +770,14 @@ export default function App() {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const pdfExportImage = useMemo(() => {
|
const pdfExportImage = useMemo(() => {
|
||||||
const hasCode = workingTemplate.elements.some(
|
const printMode = workingTemplate.paper?.printColorMode ?? 'cmyk';
|
||||||
(e) => e.type === 'barcode' || e.type === 'qrcode'
|
return {
|
||||||
);
|
imageFormat: 'png' as const,
|
||||||
return hasCode
|
jsPdfFormat: 'PNG' as const,
|
||||||
? { imageFormat: 'png' as const, jsPdfFormat: 'PNG' as const, scale: 10 }
|
scale: printMode === 'cmyk' ? 14 : 12,
|
||||||
: { imageFormat: 'jpeg' as const, jsPdfFormat: 'JPEG' as const, scale: 10 };
|
jpegQuality: 0.95,
|
||||||
}, [workingTemplate.elements]);
|
};
|
||||||
|
}, [workingTemplate.paper?.printColorMode]);
|
||||||
|
|
||||||
const handleExportImageFileNamePatternChange = (pattern: string) => {
|
const handleExportImageFileNamePatternChange = (pattern: string) => {
|
||||||
if (!activeTemplate) return;
|
if (!activeTemplate) return;
|
||||||
@@ -791,7 +824,8 @@ export default function App() {
|
|||||||
mode: 'output',
|
mode: 'output',
|
||||||
scale: pdfExportImage.scale,
|
scale: pdfExportImage.scale,
|
||||||
imageFormat: pdfExportImage.imageFormat,
|
imageFormat: pdfExportImage.imageFormat,
|
||||||
jpegQuality: 0.9,
|
jpegQuality: pdfExportImage.jpegQuality,
|
||||||
|
printColorMode: activeTemplate!.paper?.printColorMode ?? 'cmyk',
|
||||||
...resolveReferenceBackgroundForOutput(activeTemplate!),
|
...resolveReferenceBackgroundForOutput(activeTemplate!),
|
||||||
}),
|
}),
|
||||||
[activeTemplate, pdfExportImage]
|
[activeTemplate, pdfExportImage]
|
||||||
@@ -952,15 +986,6 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const printBlockedReason =
|
|
||||||
!appliedExportData
|
|
||||||
? '请先应用数据'
|
|
||||||
: appliedLayoutResult.selectedCount === 0
|
|
||||||
? '当前数据范围内无标签'
|
|
||||||
: previewLayoutStale
|
|
||||||
? '排版已变更,请重新绘制预览'
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const isDarkWorkspace =
|
const isDarkWorkspace =
|
||||||
workflowStep === 'template-design' || workflowStep === 'print-view';
|
workflowStep === 'template-design' || workflowStep === 'print-view';
|
||||||
const isMobileDarkShell = isMobile && isDarkWorkspace;
|
const isMobileDarkShell = isMobile && isDarkWorkspace;
|
||||||
@@ -1352,6 +1377,7 @@ export default function App() {
|
|||||||
onRedo={handleDesignRedo}
|
onRedo={handleDesignRedo}
|
||||||
onBack={() => setWorkflowStep('template-select')}
|
onBack={() => setWorkflowStep('template-select')}
|
||||||
onExport={() => openPrintView()}
|
onExport={() => openPrintView()}
|
||||||
|
elementClipboard={elementClipboard}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1394,7 +1420,7 @@ export default function App() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<span className="hidden md:inline text-[10px] text-[#666]">
|
<span className="hidden md:inline text-[10px] text-[#666]">
|
||||||
Shift/Ctrl+点击多选
|
Shift/Ctrl+点击多选 · Ctrl+C/V 复制粘贴
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => openPrintView()}
|
onClick={() => openPrintView()}
|
||||||
@@ -1414,6 +1440,7 @@ export default function App() {
|
|||||||
onSelectElements={handleSelectElements}
|
onSelectElements={handleSelectElements}
|
||||||
selectedTableCells={selectedTableCells}
|
selectedTableCells={selectedTableCells}
|
||||||
onSelectTableCells={setSelectedTableCells}
|
onSelectTableCells={setSelectedTableCells}
|
||||||
|
elementClipboard={elementClipboard}
|
||||||
/>
|
/>
|
||||||
<PropSidebar
|
<PropSidebar
|
||||||
template={activeTemplate}
|
template={activeTemplate}
|
||||||
@@ -1425,6 +1452,7 @@ export default function App() {
|
|||||||
activeTab="element"
|
activeTab="element"
|
||||||
onChangeTab={() => { }}
|
onChangeTab={() => { }}
|
||||||
paper={paper}
|
paper={paper}
|
||||||
|
elementClipboard={elementClipboard}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1436,6 +1464,7 @@ export default function App() {
|
|||||||
{workflowStep === 'print-view' && isMobile && hasTemplates && activeTemplate && (
|
{workflowStep === 'print-view' && isMobile && hasTemplates && activeTemplate && (
|
||||||
<MobileExportView
|
<MobileExportView
|
||||||
template={activeTemplate}
|
template={activeTemplate}
|
||||||
|
onChangeTemplate={handleTemplateChange}
|
||||||
templateName={activeTemplate.name ?? '标签'}
|
templateName={activeTemplate.name ?? '标签'}
|
||||||
templateVariables={templateVariables}
|
templateVariables={templateVariables}
|
||||||
importData={importData}
|
importData={importData}
|
||||||
@@ -1478,7 +1507,6 @@ export default function App() {
|
|||||||
pdfGenerating ||
|
pdfGenerating ||
|
||||||
imagesZipGenerating
|
imagesZipGenerating
|
||||||
}
|
}
|
||||||
printDisabledReason={printBlockedReason}
|
|
||||||
onExportImages={exportLabelsImagesZip}
|
onExportImages={exportLabelsImagesZip}
|
||||||
imagesExporting={imagesZipGenerating}
|
imagesExporting={imagesZipGenerating}
|
||||||
onChangeExportImageFileNamePattern={handleExportImageFileNamePatternChange}
|
onChangeExportImageFileNamePattern={handleExportImageFileNamePatternChange}
|
||||||
@@ -1568,6 +1596,7 @@ export default function App() {
|
|||||||
paper={paper}
|
paper={paper}
|
||||||
onChangePaper={handlePaperChange}
|
onChangePaper={handlePaperChange}
|
||||||
template={activeTemplate}
|
template={activeTemplate}
|
||||||
|
onChangeTemplate={handleTemplateChange}
|
||||||
rows={mappedRows}
|
rows={mappedRows}
|
||||||
draftExportRange={draftExportRange}
|
draftExportRange={draftExportRange}
|
||||||
onChangeDraftExportRange={setDraftExportRange}
|
onChangeDraftExportRange={setDraftExportRange}
|
||||||
@@ -1599,7 +1628,6 @@ export default function App() {
|
|||||||
pdfGenerating ||
|
pdfGenerating ||
|
||||||
imagesZipGenerating
|
imagesZipGenerating
|
||||||
}
|
}
|
||||||
printDisabledReason={printBlockedReason}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1609,7 +1637,6 @@ export default function App() {
|
|||||||
{editingTemplateId && (
|
{editingTemplateId && (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[9000] flex items-center justify-center bg-slate-950/60 backdrop-blur-sm p-4"
|
className="fixed inset-0 z-[9000] flex items-center justify-center bg-slate-950/60 backdrop-blur-sm p-4"
|
||||||
onClick={closeEditTemplateDialog}
|
|
||||||
role="presentation"
|
role="presentation"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -1657,7 +1684,6 @@ export default function App() {
|
|||||||
{showAddTmplForm && (
|
{showAddTmplForm && (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[9000] flex items-center justify-center bg-slate-950/60 backdrop-blur-sm p-4"
|
className="fixed inset-0 z-[9000] flex items-center justify-center bg-slate-950/60 backdrop-blur-sm p-4"
|
||||||
onClick={closeAddTemplateDialog}
|
|
||||||
role="presentation"
|
role="presentation"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -1698,18 +1724,25 @@ export default function App() {
|
|||||||
className="w-full px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg text-sm focus:border-indigo-400 focus:outline-none"
|
className="w-full px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg text-sm focus:border-indigo-400 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<LabelLayoutSizeCalculator
|
||||||
|
onCalculated={({ width, height, paper }) => {
|
||||||
|
setNewTmplW(String(width));
|
||||||
|
setNewTmplH(String(height));
|
||||||
|
setNewTmplPaper(paper);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-[11px] font-semibold text-gray-500 mb-1.5">
|
<label className="block text-[11px] font-semibold text-gray-500 mb-1.5">
|
||||||
标签宽度 (mm)
|
标签宽度 (mm)
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
required
|
required
|
||||||
min="10"
|
placeholder="75"
|
||||||
max="150"
|
|
||||||
value={newTmplW}
|
value={newTmplW}
|
||||||
onChange={(e) => setNewTmplW(Number(e.target.value))}
|
onChange={(e) => setNewTmplW(e.target.value)}
|
||||||
className="w-full px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg text-sm font-mono focus:border-indigo-400 focus:outline-none"
|
className="w-full px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg text-sm font-mono focus:border-indigo-400 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1718,12 +1751,12 @@ export default function App() {
|
|||||||
标签高度 (mm)
|
标签高度 (mm)
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
required
|
required
|
||||||
min="10"
|
placeholder="45"
|
||||||
max="150"
|
|
||||||
value={newTmplH}
|
value={newTmplH}
|
||||||
onChange={(e) => setNewTmplH(Number(e.target.value))}
|
onChange={(e) => setNewTmplH(e.target.value)}
|
||||||
className="w-full px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg text-sm font-mono focus:border-indigo-400 focus:outline-none"
|
className="w-full px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg text-sm font-mono focus:border-indigo-400 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
258
src/colorUtils.ts
Normal file
258
src/colorUtils.ts
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
import type { PrintColorMode, RecordRow, TemplateElement } from './types';
|
||||||
|
import { resolveContent, type ContentResolveMode } from './utils';
|
||||||
|
|
||||||
|
export interface RenderColorContext {
|
||||||
|
printColorMode?: PrintColorMode;
|
||||||
|
row: RecordRow | null;
|
||||||
|
variableDefaults?: Record<string, string> | null;
|
||||||
|
mode?: ContentResolveMode;
|
||||||
|
rowIndex?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function colorValueUsesVariable(value?: string): boolean {
|
||||||
|
return !!value?.includes('{');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTransparentColorValue(value?: string): boolean {
|
||||||
|
if (!value) return true;
|
||||||
|
const trimmed = value.trim().toLowerCase();
|
||||||
|
return trimmed === '' || trimmed === 'transparent';
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampByte(n: number): number {
|
||||||
|
return Math.max(0, Math.min(255, Math.round(n)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHexColor(color: string): { r: number; g: number; b: number; a: number } | null {
|
||||||
|
const trimmed = color.trim();
|
||||||
|
if (!trimmed.startsWith('#')) return null;
|
||||||
|
let hex = trimmed.slice(1);
|
||||||
|
if (hex.length === 3) {
|
||||||
|
hex = hex.split('').map((c) => c + c).join('');
|
||||||
|
}
|
||||||
|
if (hex.length === 6) {
|
||||||
|
return {
|
||||||
|
r: parseInt(hex.slice(0, 2), 16),
|
||||||
|
g: parseInt(hex.slice(2, 4), 16),
|
||||||
|
b: parseInt(hex.slice(4, 6), 16),
|
||||||
|
a: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (hex.length === 8) {
|
||||||
|
return {
|
||||||
|
r: parseInt(hex.slice(0, 2), 16),
|
||||||
|
g: parseInt(hex.slice(2, 4), 16),
|
||||||
|
b: parseInt(hex.slice(4, 6), 16),
|
||||||
|
a: parseInt(hex.slice(6, 8), 16) / 255,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRgbColor(color: string): { r: number; g: number; b: number; a: number } | null {
|
||||||
|
const rgba = color.match(
|
||||||
|
/rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)/i
|
||||||
|
);
|
||||||
|
if (!rgba) return null;
|
||||||
|
return {
|
||||||
|
r: Math.round(Number(rgba[1])),
|
||||||
|
g: Math.round(Number(rgba[2])),
|
||||||
|
b: Math.round(Number(rgba[3])),
|
||||||
|
a: rgba[4] !== undefined ? Math.min(1, Math.max(0, Number(rgba[4]))) : 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCssColor(color: string): { r: number; g: number; b: number; a: number } | null {
|
||||||
|
const trimmed = color.trim();
|
||||||
|
if (!trimmed || trimmed.toLowerCase() === 'transparent') return null;
|
||||||
|
return parseHexColor(trimmed) ?? parseRgbColor(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRgba(r: number, g: number, b: number, a = 1): string {
|
||||||
|
if (a >= 1) return `rgb(${r},${g},${b})`;
|
||||||
|
return `rgba(${r},${g},${b},${Math.round(a * 1000) / 1000})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rgbToCmyk(r: number, g: number, b: number) {
|
||||||
|
const rn = r / 255;
|
||||||
|
const gn = g / 255;
|
||||||
|
const bn = b / 255;
|
||||||
|
const k = 1 - Math.max(rn, gn, bn);
|
||||||
|
if (k >= 1) return { c: 0, m: 0, y: 0, k: 1 };
|
||||||
|
const c = (1 - rn - k) / (1 - k);
|
||||||
|
const m = (1 - gn - k) / (1 - k);
|
||||||
|
const y = (1 - bn - k) / (1 - k);
|
||||||
|
return { c, m, y, k };
|
||||||
|
}
|
||||||
|
|
||||||
|
function cmykToRgb(c: number, m: number, y: number, k: number) {
|
||||||
|
return {
|
||||||
|
r: 255 * (1 - c) * (1 - k),
|
||||||
|
g: 255 * (1 - m) * (1 - k),
|
||||||
|
b: 255 * (1 - y) * (1 - k),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 灰成分替换(GCR),中性色更多走 K 通道,印刷更稳定 */
|
||||||
|
function applyGrayComponentReplacement(c: number, m: number, y: number, k: number) {
|
||||||
|
const gray = Math.min(c, m, y);
|
||||||
|
if (gray <= 0.01) return { c, m, y, k };
|
||||||
|
const gcr = gray * 0.85;
|
||||||
|
return {
|
||||||
|
c: c - gcr,
|
||||||
|
m: m - gcr,
|
||||||
|
y: y - gcr,
|
||||||
|
k: Math.min(1, k + gcr),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 总墨量限制(TAC),典型 280–300% */
|
||||||
|
function limitTotalInk(c: number, m: number, y: number, k: number, maxInk = 2.8) {
|
||||||
|
const total = c + m + y + k;
|
||||||
|
if (total <= maxInk || total <= 0) return { c, m, y, k };
|
||||||
|
const scale = maxInk / total;
|
||||||
|
return { c: c * scale, m: m * scale, y: y * scale, k: k * scale };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 网点扩大 / 纸张吸墨:印刷比屏幕略深 */
|
||||||
|
function applyDotGain(r: number, g: number, b: number, amount: number): [number, number, number] {
|
||||||
|
const factor = 1 - amount;
|
||||||
|
return [clampByte(r * factor), clampByte(g * factor), clampByte(b * factor)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 轻度去饱和,逼近油墨叠色效果 */
|
||||||
|
function applyDesaturation(r: number, g: number, b: number, saturation: number): [number, number, number] {
|
||||||
|
const lum = 0.299 * r + 0.587 * g + 0.114 * b;
|
||||||
|
return [
|
||||||
|
clampByte(lum + (r - lum) * saturation),
|
||||||
|
clampByte(lum + (g - lum) * saturation),
|
||||||
|
clampByte(lum + (b - lum) * saturation),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformRgbPixel(r: number, g: number, b: number, mode: PrintColorMode): [number, number, number] {
|
||||||
|
if (mode === 'grayscale') {
|
||||||
|
const lum = Math.round(0.299 * r + 0.587 * g + 0.114 * b);
|
||||||
|
return [lum, lum, lum];
|
||||||
|
}
|
||||||
|
|
||||||
|
let nr = r;
|
||||||
|
let ng = g;
|
||||||
|
let nb = b;
|
||||||
|
|
||||||
|
if (mode === 'cmyk') {
|
||||||
|
let { c, m, y, k } = rgbToCmyk(nr, ng, nb);
|
||||||
|
({ c, m, y, k } = applyGrayComponentReplacement(c, m, y, k));
|
||||||
|
({ c, m, y, k } = limitTotalInk(c, m, y, k));
|
||||||
|
const rgb = cmykToRgb(c, m, y, k);
|
||||||
|
nr = rgb.r;
|
||||||
|
ng = rgb.g;
|
||||||
|
nb = rgb.b;
|
||||||
|
[nr, ng, nb] = applyDotGain(nr, ng, nb, 0.07);
|
||||||
|
return [nr, ng, nb];
|
||||||
|
}
|
||||||
|
|
||||||
|
// rgb:轻度印刷补偿(设计预览与导出一致,缩小与实机色差)
|
||||||
|
[nr, ng, nb] = applyDesaturation(nr, ng, nb, 0.9);
|
||||||
|
[nr, ng, nb] = applyDotGain(nr, ng, nb, 0.04);
|
||||||
|
return [nr, ng, nb];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyPrintColorModeToImageData(
|
||||||
|
imageData: ImageData,
|
||||||
|
mode: PrintColorMode = 'rgb'
|
||||||
|
): void {
|
||||||
|
const { data } = imageData;
|
||||||
|
for (let i = 0; i < data.length; i += 4) {
|
||||||
|
const alpha = data[i + 3];
|
||||||
|
if (alpha === 0) continue;
|
||||||
|
const [nr, ng, nb] = transformRgbPixel(data[i], data[i + 1], data[i + 2], mode);
|
||||||
|
data[i] = nr;
|
||||||
|
data[i + 1] = ng;
|
||||||
|
data[i + 2] = nb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyPrintColorModeToCanvas(
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
mode: PrintColorMode = 'rgb'
|
||||||
|
): void {
|
||||||
|
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||||
|
if (!ctx) return;
|
||||||
|
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||||
|
applyPrintColorModeToImageData(imageData, mode);
|
||||||
|
ctx.putImageData(imageData, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyPrintColorMode(color: string, mode: PrintColorMode = 'rgb'): string {
|
||||||
|
if (isTransparentColorValue(color)) return color;
|
||||||
|
const parsed = parseCssColor(color);
|
||||||
|
if (!parsed) return color;
|
||||||
|
const [r, g, b] = transformRgbPixel(parsed.r, parsed.g, parsed.b, mode);
|
||||||
|
return formatRgba(r, g, b, parsed.a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析颜色字面量或 {变量} */
|
||||||
|
export function resolveRenderableColor(
|
||||||
|
raw: string | undefined,
|
||||||
|
ctx: RenderColorContext,
|
||||||
|
fallback?: string
|
||||||
|
): string | undefined {
|
||||||
|
const source = (raw ?? fallback ?? '').trim();
|
||||||
|
if (!source) return undefined;
|
||||||
|
if (isTransparentColorValue(source)) return 'transparent';
|
||||||
|
|
||||||
|
let resolved = source;
|
||||||
|
if (colorValueUsesVariable(source)) {
|
||||||
|
resolved = resolveContent(
|
||||||
|
source,
|
||||||
|
ctx.row,
|
||||||
|
ctx.rowIndex ?? 0,
|
||||||
|
ctx.variableDefaults,
|
||||||
|
{ mode: ctx.mode ?? 'design' }
|
||||||
|
).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resolved || isTransparentColorValue(resolved)) return 'transparent';
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 为渲染解析元素上的颜色字段(含变量) */
|
||||||
|
export function resolveElementColorsForRender(
|
||||||
|
elem: TemplateElement,
|
||||||
|
ctx: RenderColorContext
|
||||||
|
): TemplateElement {
|
||||||
|
const textColor = resolveRenderableColor(elem.textColor, ctx, '#111827');
|
||||||
|
const backgroundColor = resolveRenderableColor(elem.backgroundColor, ctx);
|
||||||
|
const borderColor = resolveRenderableColor(elem.borderColor, ctx, '#111827');
|
||||||
|
const tableBorderColor = resolveRenderableColor(elem.tableBorderColor, ctx, '#374151');
|
||||||
|
|
||||||
|
const patchBorderSide = (value?: string) => {
|
||||||
|
if (!value?.trim()) return value;
|
||||||
|
return resolveRenderableColor(value, ctx, borderColor ?? '#111827');
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...elem,
|
||||||
|
textColor,
|
||||||
|
backgroundColor,
|
||||||
|
borderColor,
|
||||||
|
tableBorderColor,
|
||||||
|
borderColorTop: patchBorderSide(elem.borderColorTop),
|
||||||
|
borderColorRight: patchBorderSide(elem.borderColorRight),
|
||||||
|
borderColorBottom: patchBorderSide(elem.borderColorBottom),
|
||||||
|
borderColorLeft: patchBorderSide(elem.borderColorLeft),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 颜色选择器可用的预览色(变量色时回退默认色) */
|
||||||
|
export function colorPickerPreviewValue(value: string | undefined, fallback: string): string {
|
||||||
|
if (!value || isTransparentColorValue(value) || colorValueUsesVariable(value)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
const parsed = parseCssColor(value);
|
||||||
|
if (!parsed) return fallback;
|
||||||
|
if (parsed.a < 1) return fallback;
|
||||||
|
const hex = (n: number) => n.toString(16).padStart(2, '0');
|
||||||
|
return `#${hex(parsed.r)}${hex(parsed.g)}${hex(parsed.b)}`;
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ interface DialogRequest {
|
|||||||
interface AppDialogContextValue {
|
interface AppDialogContextValue {
|
||||||
showAlert: (message: string, options?: AlertOptions) => Promise<void>;
|
showAlert: (message: string, options?: AlertOptions) => Promise<void>;
|
||||||
showConfirm: (message: string, options?: ConfirmOptions) => Promise<boolean>;
|
showConfirm: (message: string, options?: ConfirmOptions) => Promise<boolean>;
|
||||||
|
showToast: (message: string, options?: { variant?: AppDialogVariant; duration?: number }) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AppDialogContext = createContext<AppDialogContextValue | null>(null);
|
const AppDialogContext = createContext<AppDialogContextValue | null>(null);
|
||||||
@@ -147,8 +148,40 @@ function AppDialogModal({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AppToast({
|
||||||
|
message,
|
||||||
|
variant = 'success',
|
||||||
|
onDismiss,
|
||||||
|
}: {
|
||||||
|
message: string;
|
||||||
|
variant: AppDialogVariant;
|
||||||
|
onDismiss: () => void;
|
||||||
|
}) {
|
||||||
|
const meta = VARIANT_META[variant];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = window.setTimeout(onDismiss, 2000);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [onDismiss]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed left-1/2 bottom-[max(1.25rem,env(safe-area-inset-bottom))] z-[10001] -translate-x-1/2 pointer-events-none"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2.5 rounded-xl shadow-lg border border-black/10 bg-[#2a2a2a] text-[#f0f0f0] text-[12px] font-medium max-w-[min(90vw,20rem)]">
|
||||||
|
<span className="shrink-0">{meta.icon}</span>
|
||||||
|
<span className="truncate">{message}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function AppDialogProvider({ children }: { children: React.ReactNode }) {
|
export function AppDialogProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [dialog, setDialog] = useState<DialogRequest | null>(null);
|
const [dialog, setDialog] = useState<DialogRequest | null>(null);
|
||||||
|
const [toast, setToast] = useState<{ message: string; variant: AppDialogVariant } | null>(null);
|
||||||
|
const toastTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
const showAlert = useCallback((message: string, options?: AlertOptions) => {
|
const showAlert = useCallback((message: string, options?: AlertOptions) => {
|
||||||
const variant = options?.variant ?? 'info';
|
const variant = options?.variant ?? 'info';
|
||||||
@@ -180,6 +213,28 @@ export function AppDialogProvider({ children }: { children: React.ReactNode }) {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const dismissToast = useCallback(() => {
|
||||||
|
if (toastTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(toastTimerRef.current);
|
||||||
|
toastTimerRef.current = null;
|
||||||
|
}
|
||||||
|
setToast(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const showToast = useCallback(
|
||||||
|
(message: string, options?: { variant?: AppDialogVariant; duration?: number }) => {
|
||||||
|
dismissToast();
|
||||||
|
const variant = options?.variant ?? 'success';
|
||||||
|
setToast({ message, variant });
|
||||||
|
const duration = options?.duration ?? 2000;
|
||||||
|
toastTimerRef.current = window.setTimeout(() => {
|
||||||
|
toastTimerRef.current = null;
|
||||||
|
setToast(null);
|
||||||
|
}, duration);
|
||||||
|
},
|
||||||
|
[dismissToast]
|
||||||
|
);
|
||||||
|
|
||||||
const closeDialog = useCallback((confirmed: boolean) => {
|
const closeDialog = useCallback((confirmed: boolean) => {
|
||||||
setDialog((current) => {
|
setDialog((current) => {
|
||||||
current?.resolve(confirmed);
|
current?.resolve(confirmed);
|
||||||
@@ -188,9 +243,12 @@ export function AppDialogProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppDialogContext.Provider value={{ showAlert, showConfirm }}>
|
<AppDialogContext.Provider value={{ showAlert, showConfirm, showToast }}>
|
||||||
{children}
|
{children}
|
||||||
{dialog && <AppDialogModal dialog={dialog} onClose={closeDialog} />}
|
{dialog && <AppDialogModal dialog={dialog} onClose={closeDialog} />}
|
||||||
|
{toast && (
|
||||||
|
<AppToast message={toast.message} variant={toast.variant} onDismiss={dismissToast} />
|
||||||
|
)}
|
||||||
</AppDialogContext.Provider>
|
</AppDialogContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useState, useEffect, useMemo } from 'react';
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
import { TemplateElement } from '../types';
|
import { TemplateElement } from '../types';
|
||||||
|
import type { PrintColorMode } from '../types';
|
||||||
import { ContentResolveMode } from '../utils';
|
import { ContentResolveMode } from '../utils';
|
||||||
import { renderLabelToDataUrl } from '../labelRenderer';
|
import { renderLabelToDataUrl } from '../labelRenderer';
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ interface CanvasLabelImageProps {
|
|||||||
referenceBackgroundFit?: 'contain' | 'cover' | 'fill';
|
referenceBackgroundFit?: 'contain' | 'cover' | 'fill';
|
||||||
/** 渲染倍率,设计画布应传 MM_TO_PX * zoom 与显示尺寸 1:1 对齐 */
|
/** 渲染倍率,设计画布应传 MM_TO_PX * zoom 与显示尺寸 1:1 对齐 */
|
||||||
renderScale?: number;
|
renderScale?: number;
|
||||||
|
printColorMode?: PrintColorMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CanvasLabelImage: React.FC<CanvasLabelImageProps> = ({
|
export const CanvasLabelImage: React.FC<CanvasLabelImageProps> = ({
|
||||||
@@ -35,6 +37,7 @@ export const CanvasLabelImage: React.FC<CanvasLabelImageProps> = ({
|
|||||||
referenceBackgroundOpacity,
|
referenceBackgroundOpacity,
|
||||||
referenceBackgroundFit,
|
referenceBackgroundFit,
|
||||||
renderScale = 16,
|
renderScale = 16,
|
||||||
|
printColorMode = 'cmyk',
|
||||||
}) => {
|
}) => {
|
||||||
const [dataUrl, setDataUrl] = useState<string>('');
|
const [dataUrl, setDataUrl] = useState<string>('');
|
||||||
const [initialLoading, setInitialLoading] = useState<boolean>(true);
|
const [initialLoading, setInitialLoading] = useState<boolean>(true);
|
||||||
@@ -63,6 +66,7 @@ export const CanvasLabelImage: React.FC<CanvasLabelImageProps> = ({
|
|||||||
referenceBackground,
|
referenceBackground,
|
||||||
referenceBackgroundOpacity,
|
referenceBackgroundOpacity,
|
||||||
referenceBackgroundFit,
|
referenceBackgroundFit,
|
||||||
|
printColorMode,
|
||||||
})
|
})
|
||||||
.then((url) => {
|
.then((url) => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
@@ -100,6 +104,7 @@ export const CanvasLabelImage: React.FC<CanvasLabelImageProps> = ({
|
|||||||
referenceBackground,
|
referenceBackground,
|
||||||
referenceBackgroundOpacity,
|
referenceBackgroundOpacity,
|
||||||
referenceBackgroundFit,
|
referenceBackgroundFit,
|
||||||
|
printColorMode,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (initialLoading && !dataUrl) {
|
if (initialLoading && !dataUrl) {
|
||||||
|
|||||||
74
src/components/ColorValueField.tsx
Normal file
74
src/components/ColorValueField.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { colorPickerPreviewValue, colorValueUsesVariable } from '../colorUtils';
|
||||||
|
import { CommitInput } from './CommitInput';
|
||||||
|
|
||||||
|
interface ColorValueFieldProps {
|
||||||
|
label?: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
variableOptions?: string[];
|
||||||
|
defaultColor?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
hint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ColorValueField: React.FC<ColorValueFieldProps> = ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
variableOptions = [],
|
||||||
|
defaultColor = '#111827',
|
||||||
|
placeholder = '#111827',
|
||||||
|
className = '',
|
||||||
|
hint,
|
||||||
|
}) => {
|
||||||
|
const usesVariable = colorValueUsesVariable(value);
|
||||||
|
const pickerValue = colorPickerPreviewValue(value, defaultColor);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
{label && <label className="ps-field-label">{label}</label>}
|
||||||
|
<div className="flex gap-1 items-center">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={pickerValue}
|
||||||
|
disabled={usesVariable}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className="color-input shrink-0 disabled:opacity-40"
|
||||||
|
title={usesVariable ? '当前为变量颜色,请使用文本框编辑' : undefined}
|
||||||
|
/>
|
||||||
|
<CommitInput
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
placeholder={placeholder}
|
||||||
|
onCommit={onChange}
|
||||||
|
className="ps-field font-mono text-[11px] flex-1 min-w-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{variableOptions.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||||
|
{variableOptions.map((v) => (
|
||||||
|
<button
|
||||||
|
key={v}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const token = `{${v}}`;
|
||||||
|
onChange(value.includes(token) ? value : value ? `${value} ${token}` : token);
|
||||||
|
}}
|
||||||
|
className="ps-btn text-[9px] font-mono"
|
||||||
|
>
|
||||||
|
{`{${v}}`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hint && <p className="text-[9px] text-[#666] mt-1 leading-relaxed">{hint}</p>}
|
||||||
|
{!hint && variableOptions.length > 0 && (
|
||||||
|
<p className="text-[9px] text-[#666] mt-1 leading-relaxed">
|
||||||
|
支持 hex/rgb/transparent 或 {`{变量名}`};排版印刷色彩模式下按行数据解析
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -3,6 +3,12 @@ import { TemplateElement } from '../types';
|
|||||||
import { mmToPx } from '../utils';
|
import { mmToPx } from '../utils';
|
||||||
import { renderLabelToDataUrl } from '../labelRenderer';
|
import { renderLabelToDataUrl } from '../labelRenderer';
|
||||||
|
|
||||||
|
/** 离屏单元素渲染时预留描边空间,避免画布边缘裁切表格线 */
|
||||||
|
export function getFloatPreviewPaddingMm(element: TemplateElement): number {
|
||||||
|
if (element.type !== 'table') return 0;
|
||||||
|
return (element.tableBorderWidth ?? 0.2) / 2 + 0.1;
|
||||||
|
}
|
||||||
|
|
||||||
interface ElementFloatPreviewProps {
|
interface ElementFloatPreviewProps {
|
||||||
element: TemplateElement;
|
element: TemplateElement;
|
||||||
renderScale: number;
|
renderScale: number;
|
||||||
@@ -17,10 +23,11 @@ export async function renderElementFloatPreview(
|
|||||||
renderScale: number,
|
renderScale: number,
|
||||||
variableDefaults?: Record<string, string> | null
|
variableDefaults?: Record<string, string> | null
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
|
const padMm = getFloatPreviewPaddingMm(element);
|
||||||
return renderLabelToDataUrl({
|
return renderLabelToDataUrl({
|
||||||
widthMm: element.width,
|
widthMm: element.width + padMm * 2,
|
||||||
heightMm: element.height,
|
heightMm: element.height + padMm * 2,
|
||||||
elements: [{ ...element, x: 0, y: 0 }],
|
elements: [{ ...element, x: padMm, y: padMm }],
|
||||||
rowData: null,
|
rowData: null,
|
||||||
rowIndex: 0,
|
rowIndex: 0,
|
||||||
showBorder: false,
|
showBorder: false,
|
||||||
@@ -67,10 +74,10 @@ export const ElementFloatPreview: React.FC<ElementFloatPreviewProps> = ({
|
|||||||
};
|
};
|
||||||
}, [elementVisualKey, element, renderScale, variableDefaults, dataUrlProp]);
|
}, [elementVisualKey, element, renderScale, variableDefaults, dataUrlProp]);
|
||||||
|
|
||||||
const left = mmToPx(element.x, displayZoom);
|
const padMm = getFloatPreviewPaddingMm(element);
|
||||||
const top = mmToPx(element.y, displayZoom);
|
const padPx = mmToPx(padMm, displayZoom);
|
||||||
const width = mmToPx(element.width, displayZoom);
|
const boxW = mmToPx(element.width, displayZoom);
|
||||||
const height = mmToPx(element.height, displayZoom);
|
const boxH = mmToPx(element.height, displayZoom);
|
||||||
|
|
||||||
if (!dataUrl) return null;
|
if (!dataUrl) return null;
|
||||||
|
|
||||||
@@ -80,18 +87,12 @@ export const ElementFloatPreview: React.FC<ElementFloatPreviewProps> = ({
|
|||||||
alt=""
|
alt=""
|
||||||
className="absolute pointer-events-none select-none"
|
className="absolute pointer-events-none select-none"
|
||||||
style={{
|
style={{
|
||||||
left: `${left}px`,
|
left: `${-padPx}px`,
|
||||||
top: `${top}px`,
|
top: `${-padPx}px`,
|
||||||
width: `${width}px`,
|
width: `${boxW + padPx * 2}px`,
|
||||||
height: `${height}px`,
|
height: `${boxH + padPx * 2}px`,
|
||||||
imageRendering: 'auto',
|
imageRendering: 'auto',
|
||||||
}}
|
}}
|
||||||
onLoad={(e) => {
|
|
||||||
const placeholder = e.currentTarget.previousElementSibling as HTMLElement | null;
|
|
||||||
if (placeholder?.classList.contains('resize-float-placeholder')) {
|
|
||||||
placeholder.style.display = 'none';
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
339
src/components/ElementMaskDialog.tsx
Normal file
339
src/components/ElementMaskDialog.tsx
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
import React, { useEffect, useRef, 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' });
|
||||||
|
const draftRef = useRef(draft);
|
||||||
|
draftRef.current = draft;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !element) return;
|
||||||
|
const initial = element.mask?.enabled
|
||||||
|
? { ...createDefaultElementMask(element), ...element.mask, enabled: true }
|
||||||
|
: createDefaultElementMask(element);
|
||||||
|
draftRef.current = initial;
|
||||||
|
setDraft(initial);
|
||||||
|
}, [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) => {
|
||||||
|
const next = { ...prev, ...patch };
|
||||||
|
draftRef.current = next;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const patchDraftCorner = (key: keyof ElementMaskConfig, val: string) => {
|
||||||
|
if (val.trim() === '') {
|
||||||
|
setDraft((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[key];
|
||||||
|
draftRef.current = next;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
patchDraft({ [key]: Math.max(0, round1(Number(val) || 0)) });
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeDraft = (source: ElementMaskConfig): ElementMaskConfig => ({
|
||||||
|
...source,
|
||||||
|
enabled: true,
|
||||||
|
x: round1(Number(source.x) || 0),
|
||||||
|
y: round1(Number(source.y) || 0),
|
||||||
|
width: Math.max(0.5, round1(Number(source.width) || element.width)),
|
||||||
|
height: Math.max(0.5, round1(Number(source.height) || element.height)),
|
||||||
|
borderRadius: Math.max(0, round1(Number(source.borderRadius) || 0)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const form = e.currentTarget as HTMLFormElement;
|
||||||
|
const active = document.activeElement;
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
if (!canConfirm) return;
|
||||||
|
onConfirm(normalizeDraft(draftRef.current));
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (active instanceof HTMLElement && form.contains(active)) {
|
||||||
|
active.blur();
|
||||||
|
setTimeout(submit, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
submit();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
onConfirm(undefined);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="ps-modal-overlay" 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={draft.mode ?? 'include'}
|
||||||
|
onChange={(e) =>
|
||||||
|
patchDraft({
|
||||||
|
mode: e.target.value as ElementMaskConfig['mode'],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="ps-field text-[11px]"
|
||||||
|
>
|
||||||
|
<option value="include">正向(保留区域内)</option>
|
||||||
|
<option value="exclude">反向(保留区域外)</option>
|
||||||
|
</FieldSelect>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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) => patchDraftCorner(key, val)}
|
||||||
|
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">
|
||||||
|
按图片亮度控制蒙版:白色区域保留内容,黑色区域裁剪;反向模式下黑白互换。
|
||||||
|
</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 && element.mask.enabled !== false && (
|
||||||
|
<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 { DataRangeFields } from './DataRangeFields';
|
||||||
import { FieldSelect } from './PsSelect';
|
import { FieldSelect } from './PsSelect';
|
||||||
import { PrintModule } from './PrintModule';
|
import { PrintModule } from './PrintModule';
|
||||||
|
import { LayoutPageBackgroundFields } from './LayoutPageBackgroundFields';
|
||||||
|
|
||||||
interface ExportSidebarProps {
|
interface ExportSidebarProps {
|
||||||
importData: ImportData | null;
|
importData: ImportData | null;
|
||||||
@@ -21,6 +22,7 @@ interface ExportSidebarProps {
|
|||||||
paper: PaperConfig;
|
paper: PaperConfig;
|
||||||
onChangePaper: (updated: PaperConfig) => void;
|
onChangePaper: (updated: PaperConfig) => void;
|
||||||
template: LabelTemplate;
|
template: LabelTemplate;
|
||||||
|
onChangeTemplate: (updated: LabelTemplate) => void;
|
||||||
rows: RecordRow[];
|
rows: RecordRow[];
|
||||||
draftExportRange: ExportRangeConfig;
|
draftExportRange: ExportRangeConfig;
|
||||||
onChangeDraftExportRange: (range: ExportRangeConfig) => void;
|
onChangeDraftExportRange: (range: ExportRangeConfig) => void;
|
||||||
@@ -45,7 +47,6 @@ interface ExportSidebarProps {
|
|||||||
onChangeExportImageFilterMode?: (mode: ElementVisibilityMode) => void;
|
onChangeExportImageFilterMode?: (mode: ElementVisibilityMode) => void;
|
||||||
onChangeExportImageFilterRules?: (rules: VisibilityRule[]) => void;
|
onChangeExportImageFilterRules?: (rules: VisibilityRule[]) => void;
|
||||||
printDisabled: boolean;
|
printDisabled: boolean;
|
||||||
printDisabledReason?: string | null;
|
|
||||||
/** 移动端精简:仅数据、映射、数据范围 */
|
/** 移动端精简:仅数据、映射、数据范围 */
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
}
|
}
|
||||||
@@ -62,6 +63,7 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
|
|||||||
paper,
|
paper,
|
||||||
onChangePaper,
|
onChangePaper,
|
||||||
template,
|
template,
|
||||||
|
onChangeTemplate,
|
||||||
rows,
|
rows,
|
||||||
draftExportRange,
|
draftExportRange,
|
||||||
onChangeDraftExportRange,
|
onChangeDraftExportRange,
|
||||||
@@ -86,7 +88,6 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
|
|||||||
onChangeExportImageFilterMode,
|
onChangeExportImageFilterMode,
|
||||||
onChangeExportImageFilterRules,
|
onChangeExportImageFilterRules,
|
||||||
printDisabled,
|
printDisabled,
|
||||||
printDisabledReason = null,
|
|
||||||
compact = false,
|
compact = false,
|
||||||
}) => {
|
}) => {
|
||||||
type ExportPanelKey = 'data' | 'layout' | 'print';
|
type ExportPanelKey = 'data' | 'layout' | 'print';
|
||||||
@@ -230,6 +231,9 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
|
|||||||
exportRange={draftExportRange}
|
exportRange={draftExportRange}
|
||||||
totalRowCount={rows.length}
|
totalRowCount={rows.length}
|
||||||
/>
|
/>
|
||||||
|
<div className="ps-section-divider">
|
||||||
|
<LayoutPageBackgroundFields template={template} onChangeTemplate={onChangeTemplate} />
|
||||||
|
</div>
|
||||||
<div className="ps-section-divider space-y-2">
|
<div className="ps-section-divider space-y-2">
|
||||||
<div className="ps-section-title">
|
<div className="ps-section-title">
|
||||||
<Settings className="w-3 h-3" />
|
<Settings className="w-3 h-3" />
|
||||||
@@ -323,7 +327,6 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
|
|||||||
>
|
>
|
||||||
<PrintModule
|
<PrintModule
|
||||||
disabled={printDisabled}
|
disabled={printDisabled}
|
||||||
disabledReason={printDisabledReason}
|
|
||||||
printing={printing}
|
printing={printing}
|
||||||
onPrint={onPrint}
|
onPrint={onPrint}
|
||||||
imagesExporting={imagesExporting}
|
imagesExporting={imagesExporting}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { LabelTemplate, TemplateElement } from '../types';
|
import { LabelTemplate, TemplateElement } from '../types';
|
||||||
import { mmToPx, MM_TO_PX, shouldRenderElement } from '../utils';
|
import { mmToPx, MM_TO_PX, shouldRenderElement, centerElementOnLabel } from '../utils';
|
||||||
|
import { getSelectionBounds } from '../elementAlign';
|
||||||
|
import type { ElementClipboardActions } from '../hooks/useElementClipboard';
|
||||||
import {
|
import {
|
||||||
createDefaultTableElement,
|
createDefaultTableElement,
|
||||||
getTableColWidths,
|
getTableColWidths,
|
||||||
@@ -15,9 +17,10 @@ import {
|
|||||||
} from '../tableUtils';
|
} from '../tableUtils';
|
||||||
import { useIsNarrowScreen } from '../hooks/useIsMobile';
|
import { useIsNarrowScreen } from '../hooks/useIsMobile';
|
||||||
import { CanvasLabelImage } from './CanvasLabelImage';
|
import { CanvasLabelImage } from './CanvasLabelImage';
|
||||||
import { ElementFloatPreview, renderElementFloatPreview } from './ElementFloatPreview';
|
import { ElementFloatPreview, getFloatPreviewPaddingMm, renderElementFloatPreview } from './ElementFloatPreview';
|
||||||
import { renderLabelToDataUrl } from '../labelRenderer';
|
import { renderLabelToDataUrl } from '../labelRenderer';
|
||||||
import { useAppDialog } from './AppDialog';
|
import { useAppDialog } from './AppDialog';
|
||||||
|
import { MobileMainToolButtons, type MobileToolButtonItem } from './MobileMainToolButtons';
|
||||||
import {
|
import {
|
||||||
ZoomIn,
|
ZoomIn,
|
||||||
ZoomOut,
|
ZoomOut,
|
||||||
@@ -40,6 +43,10 @@ interface LabelDesignerProps {
|
|||||||
variant?: 'desktop' | 'mobile';
|
variant?: 'desktop' | 'mobile';
|
||||||
/** 微调等场景下隐藏选区框与缩放手柄 */
|
/** 微调等场景下隐藏选区框与缩放手柄 */
|
||||||
suppressSelectionChrome?: boolean;
|
suppressSelectionChrome?: boolean;
|
||||||
|
elementClipboard?: Pick<
|
||||||
|
ElementClipboardActions,
|
||||||
|
'copySelectedElements' | 'pasteClipboardElements' | 'canCopy' | 'canPaste' | 'clipboardCount'
|
||||||
|
>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ActiveTool = 'move' | 'text' | 'barcode' | 'qrcode' | 'table';
|
type ActiveTool = 'move' | 'text' | 'barcode' | 'qrcode' | 'table';
|
||||||
@@ -212,6 +219,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
onSelectTableCells,
|
onSelectTableCells,
|
||||||
variant = 'desktop',
|
variant = 'desktop',
|
||||||
suppressSelectionChrome = false,
|
suppressSelectionChrome = false,
|
||||||
|
elementClipboard,
|
||||||
}) => {
|
}) => {
|
||||||
const { showConfirm } = useAppDialog();
|
const { showConfirm } = useAppDialog();
|
||||||
const selectedElementId = selectedElementIds[0] || null;
|
const selectedElementId = selectedElementIds[0] || null;
|
||||||
@@ -228,6 +236,14 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
() => template.variableDefaults ?? null,
|
() => template.variableDefaults ?? null,
|
||||||
[template.variableDefaults]
|
[template.variableDefaults]
|
||||||
);
|
);
|
||||||
|
const printColorMode = template.paper?.printColorMode ?? 'cmyk';
|
||||||
|
|
||||||
|
const showReferencePreview = useMemo(
|
||||||
|
() =>
|
||||||
|
!!template.previewReferenceBackground &&
|
||||||
|
template.enablePreviewReferenceBackground !== false,
|
||||||
|
[template.previewReferenceBackground, template.enablePreviewReferenceBackground]
|
||||||
|
);
|
||||||
|
|
||||||
const localElementsRef = useRef<TemplateElement[] | null>(null);
|
const localElementsRef = useRef<TemplateElement[] | null>(null);
|
||||||
/** 交互开始时的元素快照:拖动底图分层、缩放节流重绘 */
|
/** 交互开始时的元素快照:拖动底图分层、缩放节流重绘 */
|
||||||
@@ -304,17 +320,19 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
selectedIdsRef.current = selectedElementIds;
|
selectedIdsRef.current = selectedElementIds;
|
||||||
const templateElementsRef = useRef(template.elements);
|
const templateElementsRef = useRef(template.elements);
|
||||||
templateElementsRef.current = template.elements;
|
templateElementsRef.current = template.elements;
|
||||||
|
const templateRef = useRef(template);
|
||||||
|
templateRef.current = template;
|
||||||
|
|
||||||
const addImageElement = useCallback(
|
const addImageElement = useCallback(
|
||||||
(src: string = '') => {
|
(src: string = '') => {
|
||||||
|
const current = templateRef.current;
|
||||||
const id = `image_${Date.now()}`;
|
const id = `image_${Date.now()}`;
|
||||||
const n = template.elements.length + 1;
|
const n = current.elements.length + 1;
|
||||||
const newElement: TemplateElement = {
|
const newElement: TemplateElement = {
|
||||||
id,
|
id,
|
||||||
type: 'image',
|
type: 'image',
|
||||||
name: `图片 ${n}`,
|
name: `图片 ${n}`,
|
||||||
x: 5,
|
...centerElementOnLabel(current.width, current.height, 30, 30),
|
||||||
y: 5,
|
|
||||||
width: 30,
|
width: 30,
|
||||||
height: 30,
|
height: 30,
|
||||||
content: src,
|
content: src,
|
||||||
@@ -329,20 +347,21 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
imageFit: 'contain',
|
imageFit: 'contain',
|
||||||
};
|
};
|
||||||
onChange({
|
onChange({
|
||||||
...template,
|
...current,
|
||||||
elements: [...template.elements, newElement],
|
elements: [...current.elements, newElement],
|
||||||
});
|
});
|
||||||
onSelectElements([id]);
|
onSelectElements([id]);
|
||||||
setActiveTool('move');
|
setActiveTool('move');
|
||||||
},
|
},
|
||||||
[template, onChange, onSelectElements]
|
[onChange, onSelectElements]
|
||||||
);
|
);
|
||||||
|
|
||||||
const addElement = useCallback(
|
const addElement = useCallback(
|
||||||
(type: 'text' | 'barcode' | 'qrcode' | 'table') => {
|
(type: 'text' | 'barcode' | 'qrcode' | 'table') => {
|
||||||
|
const current = templateRef.current;
|
||||||
let newElement: TemplateElement;
|
let newElement: TemplateElement;
|
||||||
const id = `${type}_${Date.now()}`;
|
const id = `${type}_${Date.now()}`;
|
||||||
const n = template.elements.length + 1;
|
const n = current.elements.length + 1;
|
||||||
|
|
||||||
if (type === 'table') {
|
if (type === 'table') {
|
||||||
newElement = createDefaultTableElement(id, n);
|
newElement = createDefaultTableElement(id, n);
|
||||||
@@ -407,17 +426,26 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const centered = centerElementOnLabel(
|
||||||
|
current.width,
|
||||||
|
current.height,
|
||||||
|
newElement.width,
|
||||||
|
newElement.height
|
||||||
|
);
|
||||||
|
newElement = { ...newElement, ...centered };
|
||||||
|
|
||||||
onChange({
|
onChange({
|
||||||
...template,
|
...current,
|
||||||
elements: [...template.elements, newElement],
|
elements: [...current.elements, newElement],
|
||||||
});
|
});
|
||||||
onSelectElements([id]);
|
onSelectElements([id]);
|
||||||
setActiveTool('move');
|
setActiveTool('move');
|
||||||
},
|
},
|
||||||
[template, onChange, onSelectElements]
|
[onChange, onSelectElements]
|
||||||
);
|
);
|
||||||
|
|
||||||
const deleteSelectedElements = useCallback(async () => {
|
const deleteSelectedElements = useCallback(async () => {
|
||||||
|
const current = templateRef.current;
|
||||||
const ids =
|
const ids =
|
||||||
selectedElementIds.length > 0
|
selectedElementIds.length > 0
|
||||||
? selectedElementIds
|
? selectedElementIds
|
||||||
@@ -425,14 +453,14 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
? [selectedElementId]
|
? [selectedElementId]
|
||||||
: [];
|
: [];
|
||||||
const deletable = ids.filter((id) => {
|
const deletable = ids.filter((id) => {
|
||||||
const el = template.elements.find((item) => item.id === id);
|
const el = current.elements.find((item) => item.id === id);
|
||||||
return el && !el.locked;
|
return el && !el.locked;
|
||||||
});
|
});
|
||||||
if (deletable.length === 0) return;
|
if (deletable.length === 0) return;
|
||||||
|
|
||||||
const message =
|
const message =
|
||||||
deletable.length === 1
|
deletable.length === 1
|
||||||
? `确定删除「${template.elements.find((item) => item.id === deletable[0])?.name ?? '未命名'}」吗?此操作无法撤销。`
|
? `确定删除「${current.elements.find((item) => item.id === deletable[0])?.name ?? '未命名'}」吗?此操作无法撤销。`
|
||||||
: `确定删除选中的 ${deletable.length} 个元素吗?此操作无法撤销。`;
|
: `确定删除选中的 ${deletable.length} 个元素吗?此操作无法撤销。`;
|
||||||
|
|
||||||
const confirmed = await showConfirm(message, {
|
const confirmed = await showConfirm(message, {
|
||||||
@@ -443,10 +471,10 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
|
|
||||||
const idSet = new Set(deletable);
|
const idSet = new Set(deletable);
|
||||||
const remaining = template.elements.filter((el) => !idSet.has(el.id));
|
const remaining = current.elements.filter((el) => !idSet.has(el.id));
|
||||||
onChange({ ...template, elements: remaining });
|
onChange({ ...templateRef.current, elements: remaining });
|
||||||
onSelectElements(selectedElementIds.filter((id) => remaining.some((el) => el.id === id)));
|
onSelectElements(selectedElementIds.filter((id) => remaining.some((el) => el.id === id)));
|
||||||
}, [selectedElementIds, selectedElementId, template, onChange, onSelectElements, showConfirm]);
|
}, [selectedElementIds, selectedElementId, onChange, onSelectElements, showConfirm]);
|
||||||
|
|
||||||
const handleToolClick = (tool: ActiveTool) => {
|
const handleToolClick = (tool: ActiveTool) => {
|
||||||
if (tool === 'move') {
|
if (tool === 'move') {
|
||||||
@@ -484,7 +512,18 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'a') {
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'a') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setActiveTool('move');
|
setActiveTool('move');
|
||||||
onSelectElements(template.elements.filter((el) => !el.locked).map((el) => el.id));
|
onSelectElements(templateRef.current.elements.filter((el) => !el.locked).map((el) => el.id));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'c') {
|
||||||
|
e.preventDefault();
|
||||||
|
elementClipboard?.copySelectedElements();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'v') {
|
||||||
|
e.preventDefault();
|
||||||
|
elementClipboard?.pasteClipboardElements();
|
||||||
|
setActiveTool('move');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||||
@@ -509,9 +548,10 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
|
|
||||||
const idSet = new Set(moveIds);
|
const idSet = new Set(moveIds);
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
const current = templateRef.current;
|
||||||
onChange({
|
onChange({
|
||||||
...template,
|
...current,
|
||||||
elements: template.elements.map((el) => {
|
elements: current.elements.map((el) => {
|
||||||
if (!idSet.has(el.id) || el.locked) return el;
|
if (!idSet.has(el.id) || el.locked) return el;
|
||||||
return {
|
return {
|
||||||
...el,
|
...el,
|
||||||
@@ -532,6 +572,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
addElement,
|
addElement,
|
||||||
onSelectElements,
|
onSelectElements,
|
||||||
deleteSelectedElements,
|
deleteSelectedElements,
|
||||||
|
elementClipboard,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const clearInteractionCanvasState = useCallback(() => {
|
const clearInteractionCanvasState = useCallback(() => {
|
||||||
@@ -1003,7 +1044,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
const shouldCommit =
|
const shouldCommit =
|
||||||
dragState.type === 'resize' || dragMovedRef.current;
|
dragState.type === 'resize' || dragMovedRef.current;
|
||||||
if (shouldCommit) {
|
if (shouldCommit) {
|
||||||
onChange({ ...template, elements: localElementsRef.current });
|
onChange({ ...templateRef.current, elements: localElementsRef.current });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
localElementsRef.current = null;
|
localElementsRef.current = null;
|
||||||
@@ -1408,6 +1449,33 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
suppressSelectionChrome || (dragState?.type === 'drag' && dragInteractionVisualActive);
|
suppressSelectionChrome || (dragState?.type === 'drag' && dragInteractionVisualActive);
|
||||||
const showUnselectedDimLayer = activeTool === 'move';
|
const showUnselectedDimLayer = activeTool === 'move';
|
||||||
const showUnselectedDim = showUnselectedDimLayer && selectedElementIds.length > 0;
|
const showUnselectedDim = showUnselectedDimLayer && selectedElementIds.length > 0;
|
||||||
|
|
||||||
|
const multiSelectionBounds = useMemo(() => {
|
||||||
|
if (selectedElementIds.length < 2 || hideSelectionChrome) return null;
|
||||||
|
const selected = template.elements.filter(
|
||||||
|
(el) => selectedElementIds.includes(el.id) && !el.locked
|
||||||
|
);
|
||||||
|
if (selected.length < 2) return null;
|
||||||
|
return getSelectionBounds(selected);
|
||||||
|
}, [selectedElementIds, template.elements, hideSelectionChrome]);
|
||||||
|
|
||||||
|
const dragMultiSelectionBounds = useMemo(() => {
|
||||||
|
if (
|
||||||
|
selectedElementIds.length < 2 ||
|
||||||
|
dragState?.type !== 'drag' ||
|
||||||
|
!dragInteractionVisualActive ||
|
||||||
|
dragFloatElements.length < 2
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return getSelectionBounds(dragFloatElements);
|
||||||
|
}, [
|
||||||
|
selectedElementIds.length,
|
||||||
|
dragState?.type,
|
||||||
|
dragInteractionVisualActive,
|
||||||
|
dragFloatElements,
|
||||||
|
]);
|
||||||
|
|
||||||
const resizingPreviewElement =
|
const resizingPreviewElement =
|
||||||
dragState?.type === 'resize'
|
dragState?.type === 'resize'
|
||||||
? interactionFloatElements?.find((el) => el.id === dragState.elementId)
|
? interactionFloatElements?.find((el) => el.id === dragState.elementId)
|
||||||
@@ -1423,6 +1491,28 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
{ id: 'qrcode', icon: <QrCode className="w-[18px] h-[18px]" />, label: '二维码' },
|
{ id: 'qrcode', icon: <QrCode className="w-[18px] h-[18px]" />, label: '二维码' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const mobileToolItems = useMemo<MobileToolButtonItem[]>(
|
||||||
|
() => [
|
||||||
|
...tools.map((tool) => ({
|
||||||
|
id: tool.id,
|
||||||
|
icon: tool.icon,
|
||||||
|
label: tool.label,
|
||||||
|
shortcut: tool.shortcut,
|
||||||
|
active: activeTool === tool.id,
|
||||||
|
onClick: () => handleToolClick(tool.id),
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
id: 'image',
|
||||||
|
icon: <ImageIcon className="w-[18px] h-[18px]" />,
|
||||||
|
label: '图片',
|
||||||
|
active: false,
|
||||||
|
onClick: handleImageToolClick,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- tools 为静态配置
|
||||||
|
[activeTool, handleToolClick, handleImageToolClick]
|
||||||
|
);
|
||||||
|
|
||||||
const mainToolButtons = (
|
const mainToolButtons = (
|
||||||
<>
|
<>
|
||||||
{tools.map((tool) => (
|
{tools.map((tool) => (
|
||||||
@@ -1527,20 +1617,27 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
willChange: 'transform',
|
willChange: 'transform',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{dragFloatElements.map((el) => (
|
{dragFloatElements.map((el) => {
|
||||||
|
const padMm = getFloatPreviewPaddingMm(el);
|
||||||
|
const padPx = mmToPx(padMm, displayZoom);
|
||||||
|
const boxW = mmToPx(el.width, displayZoom);
|
||||||
|
const boxH = mmToPx(el.height, displayZoom);
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={el.id}
|
key={el.id}
|
||||||
className="absolute pointer-events-none"
|
className="absolute pointer-events-none"
|
||||||
style={{
|
style={{
|
||||||
left: `${mmToPx(el.x, displayZoom)}px`,
|
left: `${mmToPx(el.x, displayZoom)}px`,
|
||||||
top: `${mmToPx(el.y, displayZoom)}px`,
|
top: `${mmToPx(el.y, displayZoom)}px`,
|
||||||
width: `${mmToPx(el.width, displayZoom)}px`,
|
width: `${boxW}px`,
|
||||||
height: `${mmToPx(el.height, displayZoom)}px`,
|
height: `${boxH}px`,
|
||||||
|
transform: el.rotation ? `rotate(${el.rotation}deg)` : undefined,
|
||||||
|
transformOrigin: 'center center',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
data-drag-float-placeholder={el.id}
|
data-drag-float-placeholder={el.id}
|
||||||
className="absolute inset-0 bg-white border border-[#31a8ff]/40 shadow-[0_1px_4px_rgba(0,0,0,0.12)]"
|
className="absolute inset-0 bg-white shadow-[0_1px_4px_rgba(0,0,0,0.12)]"
|
||||||
/>
|
/>
|
||||||
<img
|
<img
|
||||||
ref={(node) => {
|
ref={(node) => {
|
||||||
@@ -1560,7 +1657,13 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
alt=""
|
alt=""
|
||||||
className="absolute inset-0 w-full h-full pointer-events-none select-none opacity-0"
|
className="absolute pointer-events-none select-none opacity-0"
|
||||||
|
style={{
|
||||||
|
left: `${-padPx}px`,
|
||||||
|
top: `${-padPx}px`,
|
||||||
|
width: `${boxW + padPx * 2}px`,
|
||||||
|
height: `${boxH + padPx * 2}px`,
|
||||||
|
}}
|
||||||
onLoad={(e) => {
|
onLoad={(e) => {
|
||||||
const img = e.currentTarget;
|
const img = e.currentTarget;
|
||||||
img.style.opacity = '1';
|
img.style.opacity = '1';
|
||||||
@@ -1568,8 +1671,33 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
if (placeholder) placeholder.style.display = 'none';
|
if (placeholder) placeholder.style.display = 'none';
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{selectedElementIds.length === 1 && (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 ps-resize-outline pointer-events-none z-[3]"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
|
{dragMultiSelectionBounds && (
|
||||||
|
<div
|
||||||
|
className="absolute ps-resize-outline pointer-events-none z-[5]"
|
||||||
|
style={{
|
||||||
|
left: `${mmToPx(dragMultiSelectionBounds.minX, displayZoom)}px`,
|
||||||
|
top: `${mmToPx(dragMultiSelectionBounds.minY, displayZoom)}px`,
|
||||||
|
width: `${mmToPx(
|
||||||
|
dragMultiSelectionBounds.maxX - dragMultiSelectionBounds.minX,
|
||||||
|
displayZoom
|
||||||
|
)}px`,
|
||||||
|
height: `${mmToPx(
|
||||||
|
dragMultiSelectionBounds.maxY - dragMultiSelectionBounds.minY,
|
||||||
|
displayZoom
|
||||||
|
)}px`,
|
||||||
|
}}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{resizeFloatElements && resizeFloatElements.length > 0 && (
|
{resizeFloatElements && resizeFloatElements.length > 0 && (
|
||||||
@@ -1577,27 +1705,34 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
className="absolute inset-0 pointer-events-none"
|
className="absolute inset-0 pointer-events-none"
|
||||||
style={{ overflow: 'visible' }}
|
style={{ overflow: 'visible' }}
|
||||||
>
|
>
|
||||||
{resizeFloatElements.map((el) => (
|
{resizeFloatElements.map((el) => {
|
||||||
<React.Fragment key={el.id}>
|
const left = mmToPx(el.x, displayZoom);
|
||||||
|
const top = mmToPx(el.y, displayZoom);
|
||||||
|
const width = mmToPx(el.width, displayZoom);
|
||||||
|
const height = mmToPx(el.height, displayZoom);
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
className="absolute pointer-events-none border border-dashed border-[#31a8ff]/40 resize-float-placeholder"
|
key={el.id}
|
||||||
|
className="absolute pointer-events-none"
|
||||||
style={{
|
style={{
|
||||||
left: `${mmToPx(el.x, displayZoom)}px`,
|
left: `${left}px`,
|
||||||
top: `${mmToPx(el.y, displayZoom)}px`,
|
top: `${top}px`,
|
||||||
width: `${mmToPx(el.width, displayZoom)}px`,
|
width: `${width}px`,
|
||||||
height: `${mmToPx(el.height, displayZoom)}px`,
|
height: `${height}px`,
|
||||||
transform: el.rotation ? `rotate(${el.rotation}deg)` : undefined,
|
transform: el.rotation ? `rotate(${el.rotation}deg)` : undefined,
|
||||||
transformOrigin: 'center center',
|
transformOrigin: 'center center',
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
<ElementFloatPreview
|
<ElementFloatPreview
|
||||||
element={el}
|
element={el}
|
||||||
renderScale={canvasRenderScale}
|
renderScale={canvasRenderScale}
|
||||||
displayZoom={displayZoom}
|
displayZoom={displayZoom}
|
||||||
variableDefaults={previewDefaults}
|
variableDefaults={previewDefaults}
|
||||||
/>
|
/>
|
||||||
</React.Fragment>
|
<div className="absolute inset-0 ps-resize-outline z-[2]" aria-hidden />
|
||||||
))}
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>,
|
</div>,
|
||||||
@@ -1629,7 +1764,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
<div
|
<div
|
||||||
ref={canvasAreaRef}
|
ref={canvasAreaRef}
|
||||||
className={`flex-1 ps-workspace-bg ps-design-canvas-touch flex items-center justify-center min-h-0 min-w-0 p-4 ${
|
className={`flex-1 ps-workspace-bg ps-design-canvas-touch flex items-center justify-center min-h-0 min-w-0 p-4 ${
|
||||||
allowInteractionOverflow ? 'overflow-visible' : 'overflow-auto'
|
allowInteractionOverflow ? 'overflow-visible' : 'overflow-auto scrollbar-hide overscroll-contain'
|
||||||
}`}
|
}`}
|
||||||
onPointerDown={handleWorkspacePointerDown}
|
onPointerDown={handleWorkspacePointerDown}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -1654,10 +1789,10 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
<div
|
<div
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
className={`absolute inset-0 shadow-[0_2px_20px_rgba(0,0,0,0.5)] select-none overflow-hidden ${
|
className={`absolute inset-0 shadow-[0_2px_20px_rgba(0,0,0,0.5)] select-none overflow-hidden ${
|
||||||
template.previewReferenceBackground ? '' : 'bg-white'
|
showReferencePreview ? '' : 'bg-white'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{template.previewReferenceBackground && (
|
{showReferencePreview && (
|
||||||
<img
|
<img
|
||||||
src={template.previewReferenceBackground}
|
src={template.previewReferenceBackground}
|
||||||
alt=""
|
alt=""
|
||||||
@@ -1681,8 +1816,9 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
rowData={null}
|
rowData={null}
|
||||||
rowIndex={0}
|
rowIndex={0}
|
||||||
showBorder={false}
|
showBorder={false}
|
||||||
transparentBackground={!!template.previewReferenceBackground}
|
transparentBackground={showReferencePreview}
|
||||||
variableDefaults={previewDefaults}
|
variableDefaults={previewDefaults}
|
||||||
|
printColorMode={printColorMode}
|
||||||
renderScale={canvasRenderScale}
|
renderScale={canvasRenderScale}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1693,32 +1829,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
showUnselectedDim ? ' ps-unselected-dim-layer--visible' : ''
|
showUnselectedDim ? ' ps-unselected-dim-layer--visible' : ''
|
||||||
}`}
|
}`}
|
||||||
aria-hidden={!showUnselectedDim}
|
aria-hidden={!showUnselectedDim}
|
||||||
>
|
|
||||||
{overlayElements.map((element) => {
|
|
||||||
if (selectedElementIds.includes(element.id)) return null;
|
|
||||||
if (dragState?.type === 'drag' && dragMovingIdSet?.has(element.id)) return null;
|
|
||||||
const elemX = mmToPx(element.x, displayZoom);
|
|
||||||
const elemY = mmToPx(element.y, displayZoom);
|
|
||||||
const elemW = mmToPx(element.width, displayZoom);
|
|
||||||
const elemH = mmToPx(element.height, displayZoom);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={`dim-${element.id}`}
|
|
||||||
className="ps-unselected-dim absolute"
|
|
||||||
style={{
|
|
||||||
left: `${elemX}px`,
|
|
||||||
top: `${elemY}px`,
|
|
||||||
width: `${elemW}px`,
|
|
||||||
height: `${elemH}px`,
|
|
||||||
transform: element.rotation
|
|
||||||
? `rotate(${element.rotation}deg)`
|
|
||||||
: undefined,
|
|
||||||
transformOrigin: 'center center',
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedLiftElements && (
|
{selectedLiftElements && (
|
||||||
@@ -1732,6 +1843,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
showBorder={false}
|
showBorder={false}
|
||||||
transparentBackground
|
transparentBackground
|
||||||
variableDefaults={previewDefaults}
|
variableDefaults={previewDefaults}
|
||||||
|
printColorMode={printColorMode}
|
||||||
renderScale={canvasRenderScale}
|
renderScale={canvasRenderScale}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1770,6 +1882,9 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
if (isBeingDragged) {
|
if (isBeingDragged) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (isBeingResized && interactionCanvasReady && resizingPreviewElement) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const overlayElement =
|
const overlayElement =
|
||||||
isBeingResized && resizingPreviewElement ? resizingPreviewElement : element;
|
isBeingResized && resizingPreviewElement ? resizingPreviewElement : element;
|
||||||
@@ -1829,6 +1944,21 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isBeingResized && (
|
||||||
|
<div className="absolute inset-0 ps-resize-outline pointer-events-none z-[60]" aria-hidden />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isSelected &&
|
||||||
|
!hideSelectionChrome &&
|
||||||
|
!isLocked &&
|
||||||
|
selectedElementIds.length === 1 &&
|
||||||
|
!isBeingResized && (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 ps-resize-outline pointer-events-none z-[60]"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{showResizeHandles && (
|
{showResizeHandles && (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
@@ -1908,6 +2038,25 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{multiSelectionBounds && (
|
||||||
|
<div
|
||||||
|
className="absolute ps-resize-outline pointer-events-none z-[1005]"
|
||||||
|
style={{
|
||||||
|
left: `${mmToPx(multiSelectionBounds.minX, displayZoom)}px`,
|
||||||
|
top: `${mmToPx(multiSelectionBounds.minY, displayZoom)}px`,
|
||||||
|
width: `${mmToPx(
|
||||||
|
multiSelectionBounds.maxX - multiSelectionBounds.minX,
|
||||||
|
displayZoom
|
||||||
|
)}px`,
|
||||||
|
height: `${mmToPx(
|
||||||
|
multiSelectionBounds.maxY - multiSelectionBounds.minY,
|
||||||
|
displayZoom
|
||||||
|
)}px`,
|
||||||
|
}}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -1915,9 +2064,14 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
|
|
||||||
{isMobileLayout ? (
|
{isMobileLayout ? (
|
||||||
<div className="mobile-design-toolstrip">
|
<div className="mobile-design-toolstrip">
|
||||||
<div className="mobile-design-toolstrip-tools">
|
<MobileMainToolButtons items={mobileToolItems} />
|
||||||
{mainToolButtons}
|
<input
|
||||||
</div>
|
ref={imageInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleImageFile}
|
||||||
|
/>
|
||||||
{zoomControls}
|
{zoomControls}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -1934,18 +2088,21 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
<span>
|
<span>
|
||||||
文档: {template.width} × {template.height} mm
|
文档: {template.width} × {template.height} mm
|
||||||
</span>
|
</span>
|
||||||
{selectedElemObj && (
|
{selectedElemObj && selectedElementIds.length === 1 && (
|
||||||
<span className="text-[#31a8ff]">
|
<span className="text-[#31a8ff]">
|
||||||
| {selectedElemObj.name} — X:{selectedElemObj.x} Y:{selectedElemObj.y} W:
|
| {selectedElemObj.name} — X:{selectedElemObj.x} Y:{selectedElemObj.y} W:
|
||||||
{selectedElemObj.width} H:{selectedElemObj.height} mm
|
{selectedElemObj.width} H:{selectedElemObj.height} mm
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{selectedElementIds.length > 1 && (
|
{multiSelectionBounds && (
|
||||||
<span className="text-emerald-400">| 已选 {selectedElementIds.length} 个</span>
|
<span className="text-emerald-400">
|
||||||
|
| 已选 {selectedElementIds.length} 个 — X:{multiSelectionBounds.minX} Y:
|
||||||
|
{multiSelectionBounds.minY} mm
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
{activeTool === 'move' && (
|
{activeTool === 'move' && (
|
||||||
<span className="text-[#888] hidden sm:inline">
|
<span className="text-[#888] hidden sm:inline">
|
||||||
| 框选 · Shift/Ctrl+点选 · Ctrl+A 全选
|
| 框选 · Shift/Ctrl+点选 · Ctrl+A 全选 · Ctrl+C/V 复制粘贴
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
240
src/components/LabelLayoutSizeCalculator.tsx
Normal file
240
src/components/LabelLayoutSizeCalculator.tsx
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Calculator, ChevronDown, ChevronUp } from 'lucide-react';
|
||||||
|
import type { PaperConfig, PaperType } from '../types';
|
||||||
|
import { computeLabelSizeFromLayout, DEFAULT_PAPER_CONFIG } from '../utils';
|
||||||
|
import { CommitInput } from './CommitInput';
|
||||||
|
import { FieldSelect } from './PsSelect';
|
||||||
|
import { useAppDialog } from './AppDialog';
|
||||||
|
|
||||||
|
const fieldClass =
|
||||||
|
'w-full px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg text-sm font-mono focus:border-indigo-400 focus:outline-none';
|
||||||
|
const labelClass = 'block text-[11px] font-semibold text-gray-500 mb-1.5';
|
||||||
|
|
||||||
|
interface LabelLayoutSizeCalculatorProps {
|
||||||
|
onCalculated: (result: { width: number; height: number; paper: PaperConfig }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePaperTypeChange(paper: PaperConfig, type: PaperType): PaperConfig {
|
||||||
|
let width = 210;
|
||||||
|
let height = 297;
|
||||||
|
if (type === 'A5') {
|
||||||
|
width = 148;
|
||||||
|
height = 210;
|
||||||
|
} else if (type === 'A6') {
|
||||||
|
width = 105;
|
||||||
|
height = 148;
|
||||||
|
} else if (type === 'custom') {
|
||||||
|
width = paper.width;
|
||||||
|
height = paper.height;
|
||||||
|
}
|
||||||
|
return { ...paper, type, width, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LabelLayoutSizeCalculator: React.FC<LabelLayoutSizeCalculatorProps> = ({
|
||||||
|
onCalculated,
|
||||||
|
}) => {
|
||||||
|
const { showAlert } = useAppDialog();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [paper, setPaper] = useState<PaperConfig>(DEFAULT_PAPER_CONFIG);
|
||||||
|
const [cols, setCols] = useState('3');
|
||||||
|
const [rows, setRows] = useState('8');
|
||||||
|
|
||||||
|
const patchPaper = (patch: Partial<PaperConfig>) => {
|
||||||
|
setPaper((prev) => ({ ...prev, ...patch }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCalculate = async () => {
|
||||||
|
const colCount = Math.max(1, Math.min(99, Number(cols) || 0));
|
||||||
|
const rowCount = Math.max(1, Math.min(99, Number(rows) || 0));
|
||||||
|
if (!cols.trim() || !rows.trim() || colCount < 1 || rowCount < 1) {
|
||||||
|
await showAlert('请输入有效的排版列数与行数。', { variant: 'warning' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const size = computeLabelSizeFromLayout(paper, colCount, rowCount);
|
||||||
|
if (!size) {
|
||||||
|
await showAlert(
|
||||||
|
'无法根据当前纸张、边距与间距计算出有效标签尺寸(单边至少 10 mm)。请调整参数后重试。',
|
||||||
|
{ variant: 'warning' }
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onCalculated({ width: size.width, height: size.height, paper });
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className="w-full flex items-center justify-between gap-2 px-3 py-2.5 bg-gray-50 hover:bg-gray-100 text-left transition cursor-pointer"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2 text-[12px] font-medium text-gray-700">
|
||||||
|
<Calculator className="w-4 h-4 text-indigo-500" />
|
||||||
|
根据页面排版计算标签尺寸
|
||||||
|
</span>
|
||||||
|
{open ? (
|
||||||
|
<ChevronUp className="w-4 h-4 text-gray-400 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-4 h-4 text-gray-400 shrink-0" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="px-3 py-3 space-y-3 border-t border-gray-200 bg-white">
|
||||||
|
<p className="text-[11px] text-gray-500 leading-relaxed">
|
||||||
|
设置纸张、页边距与 n×m 排版后,将自动反算单个标签的宽高并填入上方表单。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className={labelClass}>纸张类型</label>
|
||||||
|
<FieldSelect
|
||||||
|
value={paper.type}
|
||||||
|
onChange={(e) => patchPaper(handlePaperTypeChange(paper, e.target.value as PaperType))}
|
||||||
|
className={fieldClass}
|
||||||
|
>
|
||||||
|
<option value="A4">A4 (210×297 mm)</option>
|
||||||
|
<option value="A5">A5 (148×210 mm)</option>
|
||||||
|
<option value="A6">A6 (105×148 mm)</option>
|
||||||
|
<option value="custom">自定义尺寸</option>
|
||||||
|
</FieldSelect>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{paper.type === 'custom' && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>纸张宽 (mm)</label>
|
||||||
|
<CommitInput
|
||||||
|
type="number"
|
||||||
|
min="10"
|
||||||
|
value={paper.width}
|
||||||
|
onCommit={(val) => patchPaper({ width: Math.max(10, Number(val) || 10) })}
|
||||||
|
className={fieldClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>纸张高 (mm)</label>
|
||||||
|
<CommitInput
|
||||||
|
type="number"
|
||||||
|
min="10"
|
||||||
|
value={paper.height}
|
||||||
|
onCommit={(val) => patchPaper({ height: Math.max(10, Number(val) || 10) })}
|
||||||
|
className={fieldClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className={labelClass}>进纸方向</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => patchPaper({ orientation: 'portrait' })}
|
||||||
|
className={`flex-1 px-3 py-2 rounded-lg text-xs font-medium border cursor-pointer ${
|
||||||
|
paper.orientation === 'portrait'
|
||||||
|
? 'bg-indigo-50 border-indigo-300 text-indigo-700'
|
||||||
|
: 'bg-gray-50 border-gray-200 text-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
纵向
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => patchPaper({ orientation: 'landscape' })}
|
||||||
|
className={`flex-1 px-3 py-2 rounded-lg text-xs font-medium border cursor-pointer ${
|
||||||
|
paper.orientation === 'landscape'
|
||||||
|
? 'bg-indigo-50 border-indigo-300 text-indigo-700'
|
||||||
|
: 'bg-gray-50 border-gray-200 text-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
横向
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>排版列数 n</label>
|
||||||
|
<CommitInput
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="99"
|
||||||
|
value={cols}
|
||||||
|
onCommit={(val) => setCols(val)}
|
||||||
|
className={fieldClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>排版行数 m</label>
|
||||||
|
<CommitInput
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="99"
|
||||||
|
value={rows}
|
||||||
|
onCommit={(val) => setRows(val)}
|
||||||
|
className={fieldClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
['上边距', 'marginTop', paper.marginTop],
|
||||||
|
['下边距', 'marginBottom', paper.marginBottom],
|
||||||
|
['左边距', 'marginLeft', paper.marginLeft],
|
||||||
|
['右边距', 'marginRight', paper.marginRight],
|
||||||
|
] as const
|
||||||
|
).map(([label, key, val]) => (
|
||||||
|
<div key={key}>
|
||||||
|
<label className={labelClass}>{label} (mm)</label>
|
||||||
|
<CommitInput
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
value={val}
|
||||||
|
onCommit={(v) => patchPaper({ [key]: Math.max(0, Number(v) || 0) })}
|
||||||
|
className={fieldClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>列间距 (mm)</label>
|
||||||
|
<CommitInput
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="50"
|
||||||
|
value={paper.columnGap}
|
||||||
|
onCommit={(val) => patchPaper({ columnGap: Math.max(0, Number(val) || 0) })}
|
||||||
|
className={fieldClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>行间距 (mm)</label>
|
||||||
|
<CommitInput
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="50"
|
||||||
|
value={paper.rowGap}
|
||||||
|
onCommit={(val) => patchPaper({ rowGap: Math.max(0, Number(val) || 0) })}
|
||||||
|
className={fieldClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleCalculate()}
|
||||||
|
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 text-xs font-semibold cursor-pointer"
|
||||||
|
>
|
||||||
|
确认计算并填入
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
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>
|
||||||
|
);
|
||||||
@@ -2,6 +2,7 @@ import React, { useState } from 'react';
|
|||||||
import { ChevronLeft, Printer, Undo2, Redo2 } from 'lucide-react';
|
import { ChevronLeft, Printer, Undo2, Redo2 } from 'lucide-react';
|
||||||
import { LabelTemplate, PaperConfig } from '../types';
|
import { LabelTemplate, PaperConfig } from '../types';
|
||||||
import type { TableCellCoord } from '../tableUtils';
|
import type { TableCellCoord } from '../tableUtils';
|
||||||
|
import type { ElementClipboardActions } from '../hooks/useElementClipboard';
|
||||||
import { LabelDesigner } from './LabelDesigner';
|
import { LabelDesigner } from './LabelDesigner';
|
||||||
import { PropSidebar, MobilePropModule } from './PropSidebar';
|
import { PropSidebar, MobilePropModule } from './PropSidebar';
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ interface MobileDesignViewProps {
|
|||||||
onRedo: () => void;
|
onRedo: () => void;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
onExport: () => void;
|
onExport: () => void;
|
||||||
|
elementClipboard: ElementClipboardActions;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MobileDesignView: React.FC<MobileDesignViewProps> = ({
|
export const MobileDesignView: React.FC<MobileDesignViewProps> = ({
|
||||||
@@ -35,6 +37,7 @@ export const MobileDesignView: React.FC<MobileDesignViewProps> = ({
|
|||||||
onRedo,
|
onRedo,
|
||||||
onBack,
|
onBack,
|
||||||
onExport,
|
onExport,
|
||||||
|
elementClipboard,
|
||||||
}) => {
|
}) => {
|
||||||
const [mobileModule, setMobileModule] = useState<MobilePropModule>('properties');
|
const [mobileModule, setMobileModule] = useState<MobilePropModule>('properties');
|
||||||
|
|
||||||
@@ -90,6 +93,7 @@ export const MobileDesignView: React.FC<MobileDesignViewProps> = ({
|
|||||||
selectedTableCells={selectedTableCells}
|
selectedTableCells={selectedTableCells}
|
||||||
onSelectTableCells={onSelectTableCells}
|
onSelectTableCells={onSelectTableCells}
|
||||||
suppressSelectionChrome={mobileModule === 'nudge'}
|
suppressSelectionChrome={mobileModule === 'nudge'}
|
||||||
|
elementClipboard={elementClipboard}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -107,6 +111,7 @@ export const MobileDesignView: React.FC<MobileDesignViewProps> = ({
|
|||||||
paper={paper}
|
paper={paper}
|
||||||
mobileModule={mobileModule}
|
mobileModule={mobileModule}
|
||||||
onMobileModuleChange={setMobileModule}
|
onMobileModuleChange={setMobileModule}
|
||||||
|
elementClipboard={elementClipboard}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ import {
|
|||||||
} from '../types';
|
} from '../types';
|
||||||
import { PaperSettingsPanel, PageInput } from './PreviewGrid';
|
import { PaperSettingsPanel, PageInput } from './PreviewGrid';
|
||||||
import { FieldSelect } from './PsSelect';
|
import { FieldSelect } from './PsSelect';
|
||||||
|
import { LayoutPageBackgroundFields } from './LayoutPageBackgroundFields';
|
||||||
|
|
||||||
interface MobileExportLayoutPanelProps {
|
interface MobileExportLayoutPanelProps {
|
||||||
template: LabelTemplate;
|
template: LabelTemplate;
|
||||||
|
onChangeTemplate: (updated: LabelTemplate) => void;
|
||||||
paper: PaperConfig;
|
paper: PaperConfig;
|
||||||
onChangePaper: (paper: PaperConfig) => void;
|
onChangePaper: (paper: PaperConfig) => void;
|
||||||
rows: RecordRow[];
|
rows: RecordRow[];
|
||||||
@@ -22,6 +24,7 @@ interface MobileExportLayoutPanelProps {
|
|||||||
|
|
||||||
export const MobileExportLayoutPanel: React.FC<MobileExportLayoutPanelProps> = ({
|
export const MobileExportLayoutPanel: React.FC<MobileExportLayoutPanelProps> = ({
|
||||||
template,
|
template,
|
||||||
|
onChangeTemplate,
|
||||||
paper,
|
paper,
|
||||||
onChangePaper,
|
onChangePaper,
|
||||||
rows,
|
rows,
|
||||||
@@ -40,6 +43,9 @@ export const MobileExportLayoutPanel: React.FC<MobileExportLayoutPanelProps> = (
|
|||||||
exportRange={exportRange}
|
exportRange={exportRange}
|
||||||
totalRowCount={rows.length}
|
totalRowCount={rows.length}
|
||||||
/>
|
/>
|
||||||
|
<div className="ps-section-divider">
|
||||||
|
<LayoutPageBackgroundFields template={template} onChangeTemplate={onChangeTemplate} />
|
||||||
|
</div>
|
||||||
<div className="ps-section-divider space-y-2">
|
<div className="ps-section-divider space-y-2">
|
||||||
<div className="ps-section-title">
|
<div className="ps-section-title">
|
||||||
<Settings className="w-3 h-3" />
|
<Settings className="w-3 h-3" />
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ const MOBILE_EXPORT_MODULES: {
|
|||||||
|
|
||||||
interface MobileExportViewProps {
|
interface MobileExportViewProps {
|
||||||
template: LabelTemplate;
|
template: LabelTemplate;
|
||||||
|
onChangeTemplate: (updated: LabelTemplate) => void;
|
||||||
templateName: string;
|
templateName: string;
|
||||||
templateVariables: string[];
|
templateVariables: string[];
|
||||||
importData: ImportData | null;
|
importData: ImportData | null;
|
||||||
@@ -79,12 +80,12 @@ interface MobileExportViewProps {
|
|||||||
onChangeExportImageFilterMode?: (mode: ElementVisibilityMode) => void;
|
onChangeExportImageFilterMode?: (mode: ElementVisibilityMode) => void;
|
||||||
onChangeExportImageFilterRules?: (rules: VisibilityRule[]) => void;
|
onChangeExportImageFilterRules?: (rules: VisibilityRule[]) => void;
|
||||||
printDisabled: boolean;
|
printDisabled: boolean;
|
||||||
printDisabledReason?: string | null;
|
|
||||||
imagesExporting?: boolean;
|
imagesExporting?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MobileExportView: React.FC<MobileExportViewProps> = ({
|
export const MobileExportView: React.FC<MobileExportViewProps> = ({
|
||||||
template,
|
template,
|
||||||
|
onChangeTemplate,
|
||||||
templateName,
|
templateName,
|
||||||
templateVariables,
|
templateVariables,
|
||||||
importData,
|
importData,
|
||||||
@@ -120,7 +121,6 @@ export const MobileExportView: React.FC<MobileExportViewProps> = ({
|
|||||||
onExportPdf,
|
onExportPdf,
|
||||||
onPrint,
|
onPrint,
|
||||||
printDisabled,
|
printDisabled,
|
||||||
printDisabledReason = null,
|
|
||||||
onExportImages,
|
onExportImages,
|
||||||
imagesExporting = false,
|
imagesExporting = false,
|
||||||
onChangeExportImageFileNamePattern,
|
onChangeExportImageFileNamePattern,
|
||||||
@@ -279,6 +279,7 @@ export const MobileExportView: React.FC<MobileExportViewProps> = ({
|
|||||||
<div className="ps-panel-body p-3 min-h-0">
|
<div className="ps-panel-body p-3 min-h-0">
|
||||||
<MobileExportLayoutPanel
|
<MobileExportLayoutPanel
|
||||||
template={template}
|
template={template}
|
||||||
|
onChangeTemplate={onChangeTemplate}
|
||||||
paper={paper}
|
paper={paper}
|
||||||
onChangePaper={onChangePaper}
|
onChangePaper={onChangePaper}
|
||||||
rows={rows}
|
rows={rows}
|
||||||
@@ -293,7 +294,6 @@ export const MobileExportView: React.FC<MobileExportViewProps> = ({
|
|||||||
<PrintModule
|
<PrintModule
|
||||||
variant="mobile"
|
variant="mobile"
|
||||||
disabled={printDisabled}
|
disabled={printDisabled}
|
||||||
disabledReason={printDisabledReason}
|
|
||||||
printing={printing}
|
printing={printing}
|
||||||
onPrint={onPrint}
|
onPrint={onPrint}
|
||||||
imagesExporting={imagesExporting}
|
imagesExporting={imagesExporting}
|
||||||
|
|||||||
123
src/components/MobileMainToolButtons.tsx
Normal file
123
src/components/MobileMainToolButtons.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import React, { useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { MoreHorizontal } from 'lucide-react';
|
||||||
|
import { AnchoredMenu } from './AnchoredMenu';
|
||||||
|
|
||||||
|
const TOOL_BTN_SIZE_PX = 40;
|
||||||
|
const TOOL_BTN_GAP_PX = 4;
|
||||||
|
const TOOL_BTN_SLOT_PX = TOOL_BTN_SIZE_PX + TOOL_BTN_GAP_PX;
|
||||||
|
|
||||||
|
export interface MobileToolButtonItem {
|
||||||
|
id: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
shortcut?: string;
|
||||||
|
active?: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MobileMainToolButtonsProps {
|
||||||
|
items: MobileToolButtonItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeVisibleCount(containerWidth: number, total: number): number {
|
||||||
|
if (total <= 0 || containerWidth <= 0) return total;
|
||||||
|
if (containerWidth >= total * TOOL_BTN_SLOT_PX) return total;
|
||||||
|
const withMore = Math.floor((containerWidth - TOOL_BTN_SLOT_PX) / TOOL_BTN_SLOT_PX);
|
||||||
|
return Math.max(1, Math.min(withMore, total - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
function reorderItemsForActive(items: MobileToolButtonItem[], visibleCount: number): MobileToolButtonItem[] {
|
||||||
|
if (visibleCount >= items.length) return items;
|
||||||
|
const activeIndex = items.findIndex((item) => item.active);
|
||||||
|
if (activeIndex < 0 || activeIndex < visibleCount) return items;
|
||||||
|
const next = [...items];
|
||||||
|
const [activeItem] = next.splice(activeIndex, 1);
|
||||||
|
next.splice(visibleCount - 1, 0, activeItem);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MobileMainToolButtons: React.FC<MobileMainToolButtonsProps> = ({ items }) => {
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const moreButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
const [visibleCount, setVisibleCount] = useState(items.length);
|
||||||
|
const [moreOpen, setMoreOpen] = useState(false);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const measure = () => {
|
||||||
|
setVisibleCount(computeVisibleCount(container.clientWidth, items.length));
|
||||||
|
};
|
||||||
|
|
||||||
|
measure();
|
||||||
|
const observer = new ResizeObserver(measure);
|
||||||
|
observer.observe(container);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [items.length]);
|
||||||
|
|
||||||
|
const orderedItems = useMemo(
|
||||||
|
() => reorderItemsForActive(items, visibleCount),
|
||||||
|
[items, visibleCount]
|
||||||
|
);
|
||||||
|
|
||||||
|
const visibleItems = orderedItems.slice(0, visibleCount);
|
||||||
|
const overflowItems = orderedItems.slice(visibleCount);
|
||||||
|
const overflowHasActive = overflowItems.some((item) => item.active);
|
||||||
|
|
||||||
|
const renderToolButton = (item: MobileToolButtonItem) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
className={`ps-tool-btn mobile-design-tool-btn ${item.active ? 'active' : ''}`}
|
||||||
|
onClick={item.onClick}
|
||||||
|
title={`${item.label}${item.shortcut ? ` (${item.shortcut})` : ''}`}
|
||||||
|
aria-label={item.label}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="mobile-design-toolstrip-tools">
|
||||||
|
{visibleItems.map((item) => renderToolButton(item))}
|
||||||
|
{overflowItems.length > 0 && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
ref={moreButtonRef}
|
||||||
|
type="button"
|
||||||
|
className={`ps-tool-btn mobile-design-tool-btn`}
|
||||||
|
onClick={() => setMoreOpen((open) => !open)}
|
||||||
|
title="更多工具"
|
||||||
|
aria-label="更多工具"
|
||||||
|
aria-expanded={moreOpen}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="w-[18px] h-[18px]" />
|
||||||
|
</button>
|
||||||
|
<AnchoredMenu
|
||||||
|
open={moreOpen}
|
||||||
|
onClose={() => setMoreOpen(false)}
|
||||||
|
anchorRef={moreButtonRef}
|
||||||
|
className="mobile-toolstrip-overflow-menu"
|
||||||
|
>
|
||||||
|
{overflowItems.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
className={`mobile-toolstrip-overflow-menu-item${item.active ? ' active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
item.onClick();
|
||||||
|
setMoreOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="mobile-toolstrip-overflow-menu-icon">{item.icon}</span>
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</AnchoredMenu>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useMemo, useState, useEffect, useRef } from 'react';
|
import React, { useMemo, useState, useEffect, useRef } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { PaperConfig, LabelTemplate, RecordRow, PaperType, CutLineConfig, ExportRangeConfig } from '../types';
|
import { PaperConfig, LabelTemplate, RecordRow, PaperType, CutLineConfig, ExportRangeConfig, PrintColorMode, PRINT_COLOR_MODE_LABELS } from '../types';
|
||||||
import {
|
import {
|
||||||
computeAutoLayoutPaper,
|
computeAutoLayoutPaper,
|
||||||
DEFAULT_EXPORT_RANGE,
|
DEFAULT_EXPORT_RANGE,
|
||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
type PrintableLabel,
|
type PrintableLabel,
|
||||||
} from '../utils';
|
} from '../utils';
|
||||||
import { CanvasLabelImage } from './CanvasLabelImage';
|
import { CanvasLabelImage } from './CanvasLabelImage';
|
||||||
import { resolveReferenceBackgroundForOutput } from '../labelRenderer';
|
import { resolveReferenceBackgroundForOutput, resolveLayoutPageBackgroundForPreview } from '../labelRenderer';
|
||||||
import { CommitInput } from './CommitInput';
|
import { CommitInput } from './CommitInput';
|
||||||
import { useAppDialog } from './AppDialog';
|
import { useAppDialog } from './AppDialog';
|
||||||
import { FieldSelect } from './PsSelect';
|
import { FieldSelect } from './PsSelect';
|
||||||
@@ -66,7 +66,8 @@ const paperEquals = (a: PaperConfig, b: PaperConfig) =>
|
|||||||
a.marginLeft === b.marginLeft &&
|
a.marginLeft === b.marginLeft &&
|
||||||
a.columnGap === b.columnGap &&
|
a.columnGap === b.columnGap &&
|
||||||
a.rowGap === b.rowGap &&
|
a.rowGap === b.rowGap &&
|
||||||
a.flow === b.flow;
|
a.flow === b.flow &&
|
||||||
|
(a.printColorMode ?? 'cmyk') === (b.printColorMode ?? 'cmyk');
|
||||||
|
|
||||||
export const PaperSettingsPanel: React.FC<PaperSettingsPanelProps> = ({
|
export const PaperSettingsPanel: React.FC<PaperSettingsPanelProps> = ({
|
||||||
paper,
|
paper,
|
||||||
@@ -519,6 +520,29 @@ export const PaperSettingsPanel: React.FC<PaperSettingsPanelProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className={sectionTitleClass}>印刷色彩</h4>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>色彩模式</label>
|
||||||
|
<FieldSelect
|
||||||
|
value={draftPaper.printColorMode ?? 'cmyk'}
|
||||||
|
onChange={(e) =>
|
||||||
|
patchDraftPaper({ printColorMode: e.target.value as PrintColorMode })
|
||||||
|
}
|
||||||
|
className={selectClass}
|
||||||
|
>
|
||||||
|
{(Object.keys(PRINT_COLOR_MODE_LABELS) as PrintColorMode[]).map((mode) => (
|
||||||
|
<option key={mode} value={mode}>
|
||||||
|
{PRINT_COLOR_MODE_LABELS[mode]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</FieldSelect>
|
||||||
|
<p className={`${isPs || isMobile ? 'text-[9px] text-[#666]' : 'text-[10px] text-gray-500'} mt-1 leading-relaxed`}>
|
||||||
|
设计预览、排版预览与导出/打印使用同一套色彩处理;推荐 CMYK 模式。实机仍受打印机驱动影响,请以打样为准
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{autoLayoutDialog}
|
{autoLayoutDialog}
|
||||||
@@ -605,6 +629,15 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
|
|||||||
template.includeReferenceBackgroundInOutput,
|
template.includeReferenceBackgroundInOutput,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
const layoutPageBackground = useMemo(
|
||||||
|
() => resolveLayoutPageBackgroundForPreview(template),
|
||||||
|
[
|
||||||
|
template.layoutPageBackground,
|
||||||
|
template.layoutPageBackgroundOpacity,
|
||||||
|
template.layoutPageBackgroundFit,
|
||||||
|
template.enableLayoutPageBackground,
|
||||||
|
]
|
||||||
|
);
|
||||||
const pageCutLines = useMemo(
|
const pageCutLines = useMemo(
|
||||||
() =>
|
() =>
|
||||||
collectGridCutLinePositions(
|
collectGridCutLinePositions(
|
||||||
@@ -705,7 +738,7 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`flex-1 overflow-auto ps-workspace-bg min-h-0 ps-preview-workspace ${isMobileVariant ? 'p-3 mobile-preview-workspace' : 'p-6'
|
className={`flex-1 overflow-auto scrollbar-hide overscroll-contain ps-workspace-bg min-h-0 ps-preview-workspace ${isMobileVariant ? 'p-3 mobile-preview-workspace' : 'p-6'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{!previewReady ? (
|
{!previewReady ? (
|
||||||
@@ -721,8 +754,8 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
|
|||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
isMobileVariant
|
isMobileVariant
|
||||||
? 'flex flex-col items-center space-y-4 pb-4'
|
? 'flex flex-col items-center justify-center min-h-full w-full space-y-4 pb-4'
|
||||||
: 'flex flex-wrap justify-center content-start w-full pb-8'
|
: 'flex flex-wrap justify-center content-center w-full min-h-full pb-8'
|
||||||
}
|
}
|
||||||
style={isMobileVariant ? undefined : { gap: `${previewPageGapPx}px` }}
|
style={isMobileVariant ? undefined : { gap: `${previewPageGapPx}px` }}
|
||||||
>
|
>
|
||||||
@@ -741,16 +774,32 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
|
|||||||
</span> */}
|
</span> */}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="bg-white flex items-start justify-start relative select-none overflow-hidden shrink-0"
|
className="bg-white relative select-none overflow-hidden shrink-0"
|
||||||
style={{
|
style={{
|
||||||
width: `${pageCardWidthPx}px`,
|
width: `${pageCardWidthPx}px`,
|
||||||
height: `${currentPaperH * previewScale}px`,
|
height: `${currentPaperH * previewScale}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{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`,
|
padding: `${paper.marginTop * previewScale}px ${paper.marginRight * previewScale}px ${paper.marginBottom * previewScale}px ${paper.marginLeft * previewScale}px`,
|
||||||
boxSizing: 'border-box',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="grid bg-white relative"
|
className="grid bg-transparent relative"
|
||||||
style={{
|
style={{
|
||||||
gridTemplateColumns: `repeat(${gridInfo.cols}, ${labelWPx}px)`,
|
gridTemplateColumns: `repeat(${gridInfo.cols}, ${labelWPx}px)`,
|
||||||
gridAutoRows: `${labelHPx}px`,
|
gridAutoRows: `${labelHPx}px`,
|
||||||
@@ -820,8 +869,7 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
|
|||||||
<div
|
<div
|
||||||
key={cellIdx}
|
key={cellIdx}
|
||||||
onClick={() => onSelectRowIndex(item.sourceIndex)}
|
onClick={() => onSelectRowIndex(item.sourceIndex)}
|
||||||
className={`bg-white text-center relative flex flex-col justify-center cursor-pointer box-border ${isActive ? 'ring-2 ring-inset ring-[#31a8ff] z-10' : ''
|
className={`bg-white text-center relative flex flex-col justify-center cursor-pointer box-border`}
|
||||||
}`}
|
|
||||||
style={{
|
style={{
|
||||||
width: `${labelWPx}px`,
|
width: `${labelWPx}px`,
|
||||||
height: `${labelHPx}px`,
|
height: `${labelHPx}px`,
|
||||||
@@ -838,6 +886,7 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
|
|||||||
rowIndex={item.sourceIndex}
|
rowIndex={item.sourceIndex}
|
||||||
showBorder={false}
|
showBorder={false}
|
||||||
mode="output"
|
mode="output"
|
||||||
|
printColorMode={paper.printColorMode ?? 'cmyk'}
|
||||||
{...referenceBackgroundRender}
|
{...referenceBackgroundRender}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -847,6 +896,7 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
|
|
||||||
interface PrintModuleProps {
|
interface PrintModuleProps {
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
disabledReason?: string | null;
|
|
||||||
printing: boolean;
|
printing: boolean;
|
||||||
onPrint: (printerId: string) => void;
|
onPrint: (printerId: string) => void;
|
||||||
imagesExporting?: boolean;
|
imagesExporting?: boolean;
|
||||||
@@ -33,7 +32,6 @@ interface PrintModuleProps {
|
|||||||
|
|
||||||
export const PrintModule: React.FC<PrintModuleProps> = ({
|
export const PrintModule: React.FC<PrintModuleProps> = ({
|
||||||
disabled,
|
disabled,
|
||||||
disabledReason,
|
|
||||||
printing,
|
printing,
|
||||||
onPrint,
|
onPrint,
|
||||||
imagesExporting = false,
|
imagesExporting = false,
|
||||||
@@ -163,10 +161,6 @@ export const PrintModule: React.FC<PrintModuleProps> = ({
|
|||||||
: '当前浏览器不支持枚举打印机;打印时将打开系统对话框,请在对话框中选择对应打印机。已保存的名称便于下次快速识别。'}
|
: '当前浏览器不支持枚举打印机;打印时将打开系统对话框,请在对话框中选择对应打印机。已保存的名称便于下次快速识别。'}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{disabledReason && disabled && (
|
|
||||||
<p className="text-[10px] text-amber-500/90">{disabledReason}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handlePrint}
|
onClick={handlePrint}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||||
import { LabelTemplate, TemplateElement, PaperConfig, VisibilityRule } from '../types';
|
import { LabelTemplate, TemplateElement, PaperConfig, VisibilityRule, ElementMaskConfig } from '../types';
|
||||||
import {
|
import {
|
||||||
extractTemplateVariables,
|
extractTemplateVariables,
|
||||||
extractVariablesFromContent,
|
extractVariablesFromContent,
|
||||||
@@ -8,14 +8,19 @@ import {
|
|||||||
removeUnusedTemplateVariable,
|
removeUnusedTemplateVariable,
|
||||||
resolveVisibilityRules,
|
resolveVisibilityRules,
|
||||||
toggleVisibilityRuleEnabled,
|
toggleVisibilityRuleEnabled,
|
||||||
|
withContentAndSyncedName,
|
||||||
} from '../utils';
|
} from '../utils';
|
||||||
import { VisibilityRuleDialog } from './VisibilityRuleDialog';
|
import { VisibilityRuleDialog } from './VisibilityRuleDialog';
|
||||||
|
import { ElementMaskDialog } from './ElementMaskDialog';
|
||||||
import { VisibilityRuleSummary } from './VisibilityRuleSummary';
|
import { VisibilityRuleSummary } from './VisibilityRuleSummary';
|
||||||
|
import { ColorValueField } from './ColorValueField';
|
||||||
import { ValueExtractFields } from './ValueExtractFields';
|
import { ValueExtractFields } from './ValueExtractFields';
|
||||||
import { TextDisplayAffixFields } from './TextDisplayAffixFields';
|
import { TextDisplayAffixFields } from './TextDisplayAffixFields';
|
||||||
import {
|
import {
|
||||||
alignToSelectionBounds,
|
alignToSelectionBounds,
|
||||||
alignSelectionToCanvas,
|
alignSelectionToCanvas,
|
||||||
|
getSelectionBounds,
|
||||||
|
setSelectionOrigin,
|
||||||
tileSelectedEvenly,
|
tileSelectedEvenly,
|
||||||
tileSelectedFlush,
|
tileSelectedFlush,
|
||||||
type AlignMode,
|
type AlignMode,
|
||||||
@@ -25,6 +30,7 @@ import { FieldSelect } from './PsSelect';
|
|||||||
import { CommitInput } from './CommitInput';
|
import { CommitInput } from './CommitInput';
|
||||||
import { useAppDialog } from './AppDialog';
|
import { useAppDialog } from './AppDialog';
|
||||||
import { useIsNarrowScreen } from '../hooks/useIsMobile';
|
import { useIsNarrowScreen } from '../hooks/useIsMobile';
|
||||||
|
import type { ElementClipboardActions } from '../hooks/useElementClipboard';
|
||||||
import {
|
import {
|
||||||
TablePropertyFields,
|
TablePropertyFields,
|
||||||
TableCellContentFields,
|
TableCellContentFields,
|
||||||
@@ -32,6 +38,7 @@ import {
|
|||||||
} from './TablePropertyFields';
|
} from './TablePropertyFields';
|
||||||
import type { TableCellCoord } from '../tableUtils';
|
import type { TableCellCoord } from '../tableUtils';
|
||||||
import { expandTableForPadding } from '../tableUtils';
|
import { expandTableForPadding } from '../tableUtils';
|
||||||
|
import { isElementMaskActive } from '../elementMask';
|
||||||
import {
|
import {
|
||||||
Sliders,
|
Sliders,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
@@ -46,6 +53,8 @@ import {
|
|||||||
HelpCircle,
|
HelpCircle,
|
||||||
Lock,
|
Lock,
|
||||||
Unlock,
|
Unlock,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
@@ -53,6 +62,7 @@ import {
|
|||||||
Move,
|
Move,
|
||||||
Layers,
|
Layers,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
Copy,
|
||||||
AlignHorizontalJustifyStart,
|
AlignHorizontalJustifyStart,
|
||||||
AlignHorizontalJustifyCenter,
|
AlignHorizontalJustifyCenter,
|
||||||
AlignHorizontalJustifyEnd,
|
AlignHorizontalJustifyEnd,
|
||||||
@@ -72,6 +82,7 @@ import {
|
|||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
Upload,
|
Upload,
|
||||||
Table2,
|
Table2,
|
||||||
|
Blend,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
const PANEL_COLLAPSE_STORAGE_KEY = 'label_designer_panel_collapse_v2';
|
const PANEL_COLLAPSE_STORAGE_KEY = 'label_designer_panel_collapse_v2';
|
||||||
@@ -180,13 +191,14 @@ interface PropSidebarProps {
|
|||||||
variant?: 'desktop' | 'mobile';
|
variant?: 'desktop' | 'mobile';
|
||||||
mobileModule?: MobilePropModule;
|
mobileModule?: MobilePropModule;
|
||||||
onMobileModuleChange?: (module: MobilePropModule) => void;
|
onMobileModuleChange?: (module: MobilePropModule) => void;
|
||||||
|
elementClipboard?: ElementClipboardActions;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MOBILE_PROP_MODULES: { id: MobilePropModule; label: string; icon: React.ReactNode }[] = [
|
const MOBILE_PROP_MODULES: { id: MobilePropModule; label: string; icon: React.ReactNode }[] = [
|
||||||
{ id: 'properties', label: '属性', icon: <Sliders className="w-4 h-4" /> },
|
{ id: 'properties', label: '属性', icon: <Sliders className="w-4 h-4" /> },
|
||||||
{ id: 'nudge', label: '微调', icon: <Move className="w-4 h-4" /> },
|
|
||||||
{ id: 'content', label: '内容', icon: <Link2 className="w-4 h-4" /> },
|
{ id: 'content', label: '内容', icon: <Link2 className="w-4 h-4" /> },
|
||||||
{ id: 'layers', label: '图层', icon: <Layers className="w-4 h-4" /> },
|
{ id: 'layers', label: '图层', icon: <Layers className="w-4 h-4" /> },
|
||||||
|
{ id: 'nudge', label: '微调', icon: <Move className="w-4 h-4" /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const PropSidebar: React.FC<PropSidebarProps> = ({
|
export const PropSidebar: React.FC<PropSidebarProps> = ({
|
||||||
@@ -202,8 +214,11 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
variant = 'desktop',
|
variant = 'desktop',
|
||||||
mobileModule: mobileModuleProp,
|
mobileModule: mobileModuleProp,
|
||||||
onMobileModuleChange,
|
onMobileModuleChange,
|
||||||
|
elementClipboard,
|
||||||
}) => {
|
}) => {
|
||||||
const { showConfirm } = useAppDialog();
|
const { showConfirm } = useAppDialog();
|
||||||
|
const templateRef = useRef(template);
|
||||||
|
templateRef.current = template;
|
||||||
const isNarrowScreen = useIsNarrowScreen();
|
const isNarrowScreen = useIsNarrowScreen();
|
||||||
const selectedElementId = selectedElementIds[0] || null;
|
const selectedElementId = selectedElementIds[0] || null;
|
||||||
const selectedElem = template.elements.find((el) => el.id === selectedElementId);
|
const selectedElem = template.elements.find((el) => el.id === selectedElementId);
|
||||||
@@ -211,6 +226,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
const [visibilityRuleDialog, setVisibilityRuleDialog] = useState<
|
const [visibilityRuleDialog, setVisibilityRuleDialog] = useState<
|
||||||
{ mode: 'add' } | { mode: 'edit'; index: number } | null
|
{ mode: 'add' } | { mode: 'edit'; index: number } | null
|
||||||
>(null);
|
>(null);
|
||||||
|
const [maskDialogElementId, setMaskDialogElementId] = useState<string | null>(null);
|
||||||
const [panelCollapsed, setPanelCollapsed] = useState<PanelCollapseState>(loadPanelCollapseState);
|
const [panelCollapsed, setPanelCollapsed] = useState<PanelCollapseState>(loadPanelCollapseState);
|
||||||
const [internalMobileModule, setInternalMobileModule] =
|
const [internalMobileModule, setInternalMobileModule] =
|
||||||
useState<MobilePropModule>('properties');
|
useState<MobilePropModule>('properties');
|
||||||
@@ -239,27 +255,6 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
|
|
||||||
const templateVariables = useMemo(() => extractTemplateVariables(template), [template]);
|
const templateVariables = useMemo(() => extractTemplateVariables(template), [template]);
|
||||||
|
|
||||||
const addableVisibilityVariables = useMemo(() => {
|
|
||||||
if (!selectedElem || !isConditionalVisibility(selectedElem)) return [];
|
|
||||||
const rules = resolveVisibilityRules(selectedElem);
|
|
||||||
return templateVariables.filter(
|
|
||||||
(v) => !rules.some((r) => r.target !== 'content' && r.variable === v)
|
|
||||||
);
|
|
||||||
}, [selectedElem, templateVariables]);
|
|
||||||
|
|
||||||
const visibilityRuleDialogVariableOptions = useMemo(() => {
|
|
||||||
if (!selectedElem || !visibilityRuleDialog) return [];
|
|
||||||
const rules = resolveVisibilityRules(selectedElem);
|
|
||||||
if (visibilityRuleDialog.mode === 'add') {
|
|
||||||
return addableVisibilityVariables;
|
|
||||||
}
|
|
||||||
const usedByOthers = rules
|
|
||||||
.filter((_, i) => i !== visibilityRuleDialog.index)
|
|
||||||
.filter((r) => r.target !== 'content')
|
|
||||||
.map((r) => r.variable);
|
|
||||||
return templateVariables.filter((v) => !usedByOthers.includes(v));
|
|
||||||
}, [selectedElem, visibilityRuleDialog, addableVisibilityVariables, templateVariables]);
|
|
||||||
|
|
||||||
const editingVisibilityRule = useMemo(() => {
|
const editingVisibilityRule = useMemo(() => {
|
||||||
if (!selectedElem || visibilityRuleDialog?.mode !== 'edit') return undefined;
|
if (!selectedElem || visibilityRuleDialog?.mode !== 'edit') return undefined;
|
||||||
return resolveVisibilityRules(selectedElem)[visibilityRuleDialog.index];
|
return resolveVisibilityRules(selectedElem)[visibilityRuleDialog.index];
|
||||||
@@ -273,9 +268,10 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
const maxLabelH = Math.max(10, paperH - paper.marginTop - paper.marginBottom);
|
const maxLabelH = Math.max(10, paperH - paper.marginTop - paper.marginBottom);
|
||||||
|
|
||||||
const handleElemChange = (updatedElem: TemplateElement) => {
|
const handleElemChange = (updatedElem: TemplateElement) => {
|
||||||
|
const current = templateRef.current;
|
||||||
onChangeTemplate({
|
onChangeTemplate({
|
||||||
...template,
|
...current,
|
||||||
elements: template.elements.map((el) => (el.id === updatedElem.id ? updatedElem : el)),
|
elements: current.elements.map((el) => (el.id === updatedElem.id ? updatedElem : el)),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -287,6 +283,11 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleContentChange = (content: string) => {
|
||||||
|
if (!selectedElem || selectedElem.type === 'table') return;
|
||||||
|
handleElemChange(withContentAndSyncedName(selectedElem, content));
|
||||||
|
};
|
||||||
|
|
||||||
const nudgeableElementIds = useMemo(() => {
|
const nudgeableElementIds = useMemo(() => {
|
||||||
const idSet = new Set(selectedElementIds);
|
const idSet = new Set(selectedElementIds);
|
||||||
return template.elements
|
return template.elements
|
||||||
@@ -294,6 +295,24 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
.map((el) => el.id);
|
.map((el) => el.id);
|
||||||
}, [selectedElementIds, template.elements]);
|
}, [selectedElementIds, template.elements]);
|
||||||
|
|
||||||
|
const isMultiSelect = selectedElementIds.length >= 2;
|
||||||
|
|
||||||
|
const multiSelectionBounds = useMemo(() => {
|
||||||
|
if (!isMultiSelect) return null;
|
||||||
|
const selected = template.elements.filter(
|
||||||
|
(el) => selectedElementIds.includes(el.id) && !el.locked
|
||||||
|
);
|
||||||
|
if (selected.length < 2) return null;
|
||||||
|
return getSelectionBounds(selected);
|
||||||
|
}, [isMultiSelect, selectedElementIds, template.elements]);
|
||||||
|
|
||||||
|
const moveSelectedGroupTo = (newMinX: number, newMinY: number) => {
|
||||||
|
onChangeTemplate({
|
||||||
|
...template,
|
||||||
|
elements: setSelectionOrigin(template.elements, selectedElementIds, newMinX, newMinY),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const nudgeSelectedElements = (deltaX: number, deltaY: number) => {
|
const nudgeSelectedElements = (deltaX: number, deltaY: number) => {
|
||||||
if (nudgeableElementIds.length === 0) return;
|
if (nudgeableElementIds.length === 0) return;
|
||||||
const idSet = new Set(nudgeableElementIds);
|
const idSet = new Set(nudgeableElementIds);
|
||||||
@@ -310,6 +329,19 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const maskDialogElement = maskDialogElementId
|
||||||
|
? template.elements.find((el) => el.id === maskDialogElementId) ?? null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const updateElementMask = (elementId: string, mask: ElementMaskConfig | undefined) => {
|
||||||
|
onChangeTemplate({
|
||||||
|
...template,
|
||||||
|
elements: template.elements.map((el) =>
|
||||||
|
el.id === elementId ? { ...el, mask: mask || undefined } : el
|
||||||
|
),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const applyVisibilityRules = (next: VisibilityRule[]) => {
|
const applyVisibilityRules = (next: VisibilityRule[]) => {
|
||||||
if (!selectedElem) return;
|
if (!selectedElem) return;
|
||||||
handleElemChange({
|
handleElemChange({
|
||||||
@@ -419,7 +451,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
: selectedElem.content.trim()
|
: selectedElem.content.trim()
|
||||||
? `${selectedElem.content}${selectedElem.content.endsWith(' ') ? '' : ' '}${token}`
|
? `${selectedElem.content}${selectedElem.content.endsWith(' ') ? '' : ' '}${token}`
|
||||||
: token;
|
: token;
|
||||||
handleElemChange({ ...selectedElem, content });
|
handleContentChange(content);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddTemplateVariable = () => {
|
const handleAddTemplateVariable = () => {
|
||||||
@@ -480,10 +512,10 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
<p className="text-[10px] text-[#888]">暂无变量</p>
|
<p className="text-[10px] text-[#888]">暂无变量</p>
|
||||||
) : (
|
) : (
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-2 pt-2">
|
||||||
<div className="text-[10px] font-bold text-[#31a8ff]">变量列表</div>
|
<div className="text-[10px] font-bold text-[#31a8ff]">变量列表</div>
|
||||||
<p className="text-[10px] text-[#666] leading-relaxed mb-3">
|
<p className="text-[10px] text-[#666] leading-relaxed">
|
||||||
可编辑变量预览值,未被内容或显示条件引用的变量可删除。
|
未被内容或显示条件引用的变量可删除
|
||||||
</p>
|
</p>
|
||||||
{templateVariables.map((v) => {
|
{templateVariables.map((v) => {
|
||||||
const inUse = isTemplateVariableInUse(template, v);
|
const inUse = isTemplateVariableInUse(template, v);
|
||||||
@@ -560,6 +592,9 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const toggleLockLayer = (idx: number) => {
|
const toggleLockLayer = (idx: number) => {
|
||||||
|
const target = template.elements[idx];
|
||||||
|
if (!target) return;
|
||||||
|
const willLock = !target.locked;
|
||||||
const list = template.elements.map((el, i) => {
|
const list = template.elements.map((el, i) => {
|
||||||
if (i === idx) {
|
if (i === idx) {
|
||||||
return { ...el, locked: !el.locked };
|
return { ...el, locked: !el.locked };
|
||||||
@@ -567,6 +602,20 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
return el;
|
return el;
|
||||||
});
|
});
|
||||||
onChangeTemplate({ ...template, elements: list });
|
onChangeTemplate({ ...template, elements: list });
|
||||||
|
if (willLock && onSelectElements) {
|
||||||
|
onSelectElements(selectedElementIds.filter((id) => id !== target.id));
|
||||||
|
if (target.type === 'table') {
|
||||||
|
onSelectTableCells?.([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleLayerVisibility = (idx: number) => {
|
||||||
|
const list = template.elements.map((el, i) => {
|
||||||
|
if (i !== idx) return el;
|
||||||
|
return { ...el, hidden: el.hidden ? undefined : true };
|
||||||
|
});
|
||||||
|
onChangeTemplate({ ...template, elements: list });
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteLayer = async (id: string) => {
|
const deleteLayer = async (id: string) => {
|
||||||
@@ -584,8 +633,8 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
|
|
||||||
onChangeTemplate({
|
onChangeTemplate({
|
||||||
...template,
|
...templateRef.current,
|
||||||
elements: template.elements.filter((item) => item.id !== id),
|
elements: templateRef.current.elements.filter((item) => item.id !== id),
|
||||||
});
|
});
|
||||||
if (onSelectElements && selectedElementIds.includes(id)) {
|
if (onSelectElements && selectedElementIds.includes(id)) {
|
||||||
onSelectElements(selectedElementIds.filter((selectedId) => selectedId !== id));
|
onSelectElements(selectedElementIds.filter((selectedId) => selectedId !== id));
|
||||||
@@ -616,9 +665,10 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
});
|
});
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
const current = templateRef.current;
|
||||||
const idSet = new Set(deletableSelectedIds);
|
const idSet = new Set(deletableSelectedIds);
|
||||||
const remaining = template.elements.filter((el) => !idSet.has(el.id));
|
const remaining = current.elements.filter((el) => !idSet.has(el.id));
|
||||||
onChangeTemplate({ ...template, elements: remaining });
|
onChangeTemplate({ ...current, elements: remaining });
|
||||||
onSelectElements?.(selectedElementIds.filter((id) => remaining.some((el) => el.id === id)));
|
onSelectElements?.(selectedElementIds.filter((id) => remaining.some((el) => el.id === id)));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -651,8 +701,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = () =>
|
reader.onload = () => handleContentChange(reader.result as string);
|
||||||
handleElemChange({ ...selectedElem, content: reader.result as string });
|
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
}}
|
}}
|
||||||
@@ -667,7 +716,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!selectedElem.content}
|
disabled={!selectedElem.content}
|
||||||
onClick={() => handleElemChange({ ...selectedElem, content: '' })}
|
onClick={() => handleContentChange('')}
|
||||||
className="text-[9px] text-[#31a8ff] hover:underline cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed disabled:no-underline shrink-0"
|
className="text-[9px] text-[#31a8ff] hover:underline cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed disabled:no-underline shrink-0"
|
||||||
>
|
>
|
||||||
清空内容
|
清空内容
|
||||||
@@ -675,7 +724,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
<PropTextarea
|
<PropTextarea
|
||||||
value={selectedElem.content}
|
value={selectedElem.content}
|
||||||
onCommit={(val) => handleElemChange({ ...selectedElem, content: val })}
|
onCommit={(val) => handleContentChange(val)}
|
||||||
rows={selectedElem.type === 'image' ? 3 : 2}
|
rows={selectedElem.type === 'image' ? 3 : 2}
|
||||||
className="ps-field resize-none"
|
className="ps-field resize-none"
|
||||||
placeholder={
|
placeholder={
|
||||||
@@ -748,7 +797,10 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ValueExtractFields elem={selectedElem} onChange={handleElemChange} />
|
<ValueExtractFields
|
||||||
|
config={selectedElem}
|
||||||
|
onChange={(patch) => handleElemChange({ ...selectedElem, ...patch })}
|
||||||
|
/>
|
||||||
{selectedElem.type === 'text' && (
|
{selectedElem.type === 'text' && (
|
||||||
<TextDisplayAffixFields elem={selectedElem} onChange={handleElemChange} />
|
<TextDisplayAffixFields elem={selectedElem} onChange={handleElemChange} />
|
||||||
)}
|
)}
|
||||||
@@ -858,7 +910,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
预览参考背景
|
预览参考背景
|
||||||
</h5>
|
</h5>
|
||||||
<p className="text-[9px] text-[#777] leading-relaxed">
|
<p className="text-[9px] text-[#777] leading-relaxed">
|
||||||
上传底图作为设计参考,便于对照条件显示的背景元素;默认仅设计画布可见,可按需开启生成与打印输出。
|
上传底图作为设计参考;可分别控制设计预览与生成/打印输出是否显示。
|
||||||
</p>
|
</p>
|
||||||
<label className="ps-btn text-[10px] cursor-pointer inline-flex">
|
<label className="ps-btn text-[10px] cursor-pointer inline-flex">
|
||||||
<Upload className="w-3.5 h-3.5" />
|
<Upload className="w-3.5 h-3.5" />
|
||||||
@@ -879,6 +931,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
template.previewReferenceBackgroundOpacity ?? 100,
|
template.previewReferenceBackgroundOpacity ?? 100,
|
||||||
previewReferenceBackgroundFit:
|
previewReferenceBackgroundFit:
|
||||||
template.previewReferenceBackgroundFit ?? 'cover',
|
template.previewReferenceBackgroundFit ?? 'cover',
|
||||||
|
enablePreviewReferenceBackground: true,
|
||||||
});
|
});
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
@@ -930,6 +983,25 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
className="w-full accent-[#31a8ff]"
|
className="w-full accent-[#31a8ff]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<label className="flex items-start gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={template.enablePreviewReferenceBackground !== false}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChangeTemplate({
|
||||||
|
...template,
|
||||||
|
enablePreviewReferenceBackground: 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">
|
<label className="flex items-start gap-2">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -957,6 +1029,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
previewReferenceBackground: undefined,
|
previewReferenceBackground: undefined,
|
||||||
previewReferenceBackgroundOpacity: undefined,
|
previewReferenceBackgroundOpacity: undefined,
|
||||||
previewReferenceBackgroundFit: undefined,
|
previewReferenceBackgroundFit: undefined,
|
||||||
|
enablePreviewReferenceBackground: undefined,
|
||||||
includeReferenceBackgroundInOutput: undefined,
|
includeReferenceBackgroundInOutput: undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -971,7 +1044,9 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
|
|
||||||
const renderPropertiesPanelBody = () => (
|
const renderPropertiesPanelBody = () => (
|
||||||
<>
|
<>
|
||||||
|
{!selectedElem && (
|
||||||
<div className="pb-3 border-b border-[#3a3a3a]">{renderPreviewReferenceBackgroundFields()}</div>
|
<div className="pb-3 border-b border-[#3a3a3a]">{renderPreviewReferenceBackgroundFields()}</div>
|
||||||
|
)}
|
||||||
{/* Label Canvas Dimensions */}
|
{/* Label Canvas Dimensions */}
|
||||||
{!selectedElem && (
|
{!selectedElem && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -1095,7 +1170,13 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
<PropInput
|
<PropInput
|
||||||
type="text"
|
type="text"
|
||||||
value={selectedElem.name}
|
value={selectedElem.name}
|
||||||
onCommit={(val) => handleElemChange({ ...selectedElem, name: val || '未命名元素' })}
|
onCommit={(val) =>
|
||||||
|
handleElemChange({
|
||||||
|
...selectedElem,
|
||||||
|
name: val || '未命名元素',
|
||||||
|
nameCustomized: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
className="ps-field mt-2"
|
className="ps-field mt-2"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1278,23 +1359,20 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
|
|
||||||
{/* Text color */}
|
{/* Text color */}
|
||||||
<div className="border-t border-[#3a3a3a] pt-2.5">
|
<div className="border-t border-[#3a3a3a] pt-2.5">
|
||||||
<label className="block text-[10px] font-semibold text-[#777] mb-1">文本颜色</label>
|
<ColorValueField
|
||||||
<div className="flex gap-1 items-center">
|
label="文本颜色"
|
||||||
<input
|
|
||||||
type="color"
|
|
||||||
value={selectedElem.textColor || '#111827'}
|
|
||||||
onChange={(e) => handleElemChange({ ...selectedElem, textColor: e.target.value })}
|
|
||||||
className="color-input shrink-0"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="#111827"
|
|
||||||
value={selectedElem.textColor || ''}
|
value={selectedElem.textColor || ''}
|
||||||
onChange={(e) => handleElemChange({ ...selectedElem, textColor: e.target.value })}
|
onChange={(val) =>
|
||||||
className="ps-field font-mono text-[11px] flex-1 min-w-0"
|
handleElemChange({
|
||||||
|
...selectedElem,
|
||||||
|
textColor: val || undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
variableOptions={templateVariables}
|
||||||
|
defaultColor="#111827"
|
||||||
|
placeholder="#111827"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="ps-field-label mb-0">文本样式</label>
|
<label className="ps-field-label mb-0">文本样式</label>
|
||||||
@@ -1505,6 +1583,60 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
/>
|
/>
|
||||||
<span className="text-[12px] text-[#ccc]">自动换行</span>
|
<span className="text-[12px] text-[#ccc]">自动换行</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="ps-field-label">最大行数</label>
|
||||||
|
<PropInput
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="99"
|
||||||
|
step="1"
|
||||||
|
value={selectedElem.textMaxLines ?? ''}
|
||||||
|
onCommit={(val) => {
|
||||||
|
const raw = String(val).trim();
|
||||||
|
if (!raw) {
|
||||||
|
handleElemChange({
|
||||||
|
...selectedElem,
|
||||||
|
textMaxLines: undefined,
|
||||||
|
textEllipsis: undefined,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const n = Math.max(0, Math.min(99, Math.floor(Number(val) || 0)));
|
||||||
|
handleElemChange({
|
||||||
|
...selectedElem,
|
||||||
|
textMaxLines: n > 0 ? n : undefined,
|
||||||
|
textEllipsis: n > 0 ? selectedElem.textEllipsis : undefined,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="ps-field font-mono"
|
||||||
|
placeholder="不限"
|
||||||
|
/>
|
||||||
|
<p className="text-[9px] text-[#666] mt-0.5">留空表示不限制</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end pb-5">
|
||||||
|
<label
|
||||||
|
className={`flex items-center gap-2 cursor-pointer ${
|
||||||
|
!selectedElem.textMaxLines ? 'opacity-40 cursor-not-allowed' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedElem.textEllipsis === true}
|
||||||
|
disabled={!selectedElem.textMaxLines || selectedElem.textMaxLines <= 0}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleElemChange({
|
||||||
|
...selectedElem,
|
||||||
|
textEllipsis: e.target.checked ? true : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="ps-checkbox"
|
||||||
|
/>
|
||||||
|
<span className="text-[12px] text-[#ccc]">超出显示省略号</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1616,39 +1748,60 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h5 className="ps-section-title">
|
<h5 className="ps-section-title">
|
||||||
<Ruler className="w-3.5 h-3.5" />
|
<Ruler className="w-3.5 h-3.5" />
|
||||||
位置与尺寸 (mm)
|
{isMultiSelect && multiSelectionBounds ? '选区位置 (mm)' : '位置与尺寸 (mm)'}
|
||||||
</h5>
|
</h5>
|
||||||
|
{isMultiSelect && multiSelectionBounds && (
|
||||||
|
<p className="text-[10px] text-[#666] leading-snug">
|
||||||
|
X、Y 为选中元素整体包围框左上角,修改后将整体平移
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="ps-field-label">X 坐标 (左边距)</label>
|
<label className="ps-field-label">
|
||||||
|
{isMultiSelect ? 'X 坐标 (选区左边距)' : 'X 坐标 (左边距)'}
|
||||||
|
</label>
|
||||||
<PropInput
|
<PropInput
|
||||||
type="number"
|
type="number"
|
||||||
step="0.5"
|
step="0.5"
|
||||||
value={selectedElem.x}
|
value={isMultiSelect && multiSelectionBounds ? multiSelectionBounds.minX : selectedElem.x}
|
||||||
onCommit={(val) => {
|
onCommit={(val) => {
|
||||||
|
const xVal = Math.round((Number(val) || 0) * 10) / 10;
|
||||||
|
if (isMultiSelect && multiSelectionBounds) {
|
||||||
|
moveSelectedGroupTo(xVal, multiSelectionBounds.minY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
handleElemChange({
|
handleElemChange({
|
||||||
...selectedElem,
|
...selectedElem,
|
||||||
x: Math.round((Number(val) || 0) * 10) / 10,
|
x: xVal,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
className="ps-field font-mono"
|
className="ps-field font-mono"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="ps-field-label">Y 坐标 (顶边距)</label>
|
<label className="ps-field-label">
|
||||||
|
{isMultiSelect ? 'Y 坐标 (选区顶边距)' : 'Y 坐标 (顶边距)'}
|
||||||
|
</label>
|
||||||
<PropInput
|
<PropInput
|
||||||
type="number"
|
type="number"
|
||||||
step="0.5"
|
step="0.5"
|
||||||
value={selectedElem.y}
|
value={isMultiSelect && multiSelectionBounds ? multiSelectionBounds.minY : selectedElem.y}
|
||||||
onCommit={(val) => {
|
onCommit={(val) => {
|
||||||
|
const yVal = Math.round((Number(val) || 0) * 10) / 10;
|
||||||
|
if (isMultiSelect && multiSelectionBounds) {
|
||||||
|
moveSelectedGroupTo(multiSelectionBounds.minX, yVal);
|
||||||
|
return;
|
||||||
|
}
|
||||||
handleElemChange({
|
handleElemChange({
|
||||||
...selectedElem,
|
...selectedElem,
|
||||||
y: Math.round((Number(val) || 0) * 10) / 10,
|
y: yVal,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
className="ps-field font-mono"
|
className="ps-field font-mono"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{!isMultiSelect && (
|
||||||
|
<>
|
||||||
<div>
|
<div>
|
||||||
<label className="ps-field-label">元素宽度</label>
|
<label className="ps-field-label">元素宽度</label>
|
||||||
<PropInput
|
<PropInput
|
||||||
@@ -1658,13 +1811,15 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
value={selectedElem.width}
|
value={selectedElem.width}
|
||||||
onCommit={(val) => {
|
onCommit={(val) => {
|
||||||
const wVal = Math.max(2, Number(val) || 2);
|
const wVal = Math.max(2, Number(val) || 2);
|
||||||
let updated = { ...selectedElem, width: wVal };
|
|
||||||
if (selectedElem.type === 'qrcode') {
|
if (selectedElem.type === 'qrcode') {
|
||||||
updated.height = wVal;
|
handleElemChange({ ...selectedElem, width: wVal, height: wVal });
|
||||||
} else if (selectedElem.type === 'table') {
|
} else if (selectedElem.type === 'table') {
|
||||||
updated = scaleTableElementSize(updated, wVal, updated.height);
|
handleElemChange(
|
||||||
|
scaleTableElementSize(selectedElem, wVal, selectedElem.height)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
handleElemChange({ ...selectedElem, width: wVal });
|
||||||
}
|
}
|
||||||
handleElemChange(updated);
|
|
||||||
}}
|
}}
|
||||||
className="ps-field font-mono"
|
className="ps-field font-mono"
|
||||||
/>
|
/>
|
||||||
@@ -1709,6 +1864,8 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1780,6 +1937,35 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
className="ps-field font-mono text-[11px] flex-1 min-w-0"
|
className="ps-field font-mono text-[11px] flex-1 min-w-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{templateVariables.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||||
|
{templateVariables.map((v) => (
|
||||||
|
<button
|
||||||
|
key={v}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const token = `{${v}}`;
|
||||||
|
const current =
|
||||||
|
selectedElem.backgroundColor === 'transparent'
|
||||||
|
? ''
|
||||||
|
: selectedElem.backgroundColor || '';
|
||||||
|
handleElemChange({
|
||||||
|
...selectedElem,
|
||||||
|
backgroundColor: current.includes(token)
|
||||||
|
? current
|
||||||
|
: current
|
||||||
|
? `${current} ${token}`
|
||||||
|
: token,
|
||||||
|
backgroundOpacity: 100,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="ps-btn text-[9px] font-mono"
|
||||||
|
>
|
||||||
|
{`{${v}}`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex justify-between items-center mb-1">
|
<div className="flex justify-between items-center mb-1">
|
||||||
<span className="text-[10px] text-[#777]">不透明度</span>
|
<span className="text-[10px] text-[#777]">不透明度</span>
|
||||||
@@ -1918,35 +2104,20 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<ColorValueField
|
||||||
<label className="ps-field-label">边框颜色</label>
|
label="边框颜色"
|
||||||
<div className="flex gap-1 items-center">
|
value={selectedElem.borderColor || ''}
|
||||||
<input
|
onChange={(val) =>
|
||||||
type="color"
|
|
||||||
value={selectedElem.borderColor || '#111827'}
|
|
||||||
onChange={(e) =>
|
|
||||||
handleElemChange({
|
handleElemChange({
|
||||||
...selectedElem,
|
...selectedElem,
|
||||||
borderColor: e.target.value,
|
borderColor: val || undefined,
|
||||||
borderWidth: selectedElem.borderWidth || 0.3,
|
borderWidth: selectedElem.borderWidth || 0.3,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="color-input shrink-0"
|
variableOptions={templateVariables}
|
||||||
/>
|
defaultColor="#111827"
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={selectedElem.borderColor || ''}
|
|
||||||
placeholder="#111827"
|
placeholder="#111827"
|
||||||
onChange={(e) =>
|
|
||||||
handleElemChange({
|
|
||||||
...selectedElem,
|
|
||||||
borderColor: e.target.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="ps-field font-mono text-[11px] flex-1 min-w-0"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="ps-field-label">各边独立边框宽度 (mm,留空用统一值,0 为不显示)</label>
|
<label className="ps-field-label">各边独立边框宽度 (mm,留空用统一值,0 为不显示)</label>
|
||||||
@@ -2084,6 +2255,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
const actualIdx = template.elements.length - 1 - revIdx;
|
const actualIdx = template.elements.length - 1 - revIdx;
|
||||||
const isSelected = selectedElementIds.includes(elem.id);
|
const isSelected = selectedElementIds.includes(elem.id);
|
||||||
const isLocked = !!elem.locked;
|
const isLocked = !!elem.locked;
|
||||||
|
const isHidden = elem.hidden === true;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -2113,15 +2285,15 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
className={`ps-layer-item group ${isSelected && !isLocked ? 'selected' : ''} ${isLocked ? 'ps-layer-item-locked' : ''
|
className={`ps-layer-item group ${isSelected && !isLocked ? 'selected' : ''} ${isLocked ? 'ps-layer-item-locked' : ''
|
||||||
}`}
|
}${isHidden ? ' ps-layer-item-hidden' : ''}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 min-w-0 flex-1 pointer-events-none">
|
<div className="flex items-center gap-2 min-w-0 flex-1 pointer-events-none">
|
||||||
<span className="shrink-0 w-3.5 flex items-center justify-center">
|
<span className="ps-layer-item-icon">
|
||||||
{elem.type === 'text' && <Type className="w-3 h-3 text-[#666]" />}
|
{elem.type === 'text' && <Type />}
|
||||||
{elem.type === 'barcode' && <Barcode className="w-3 h-3 text-[#666]" />}
|
{elem.type === 'barcode' && <Barcode />}
|
||||||
{elem.type === 'qrcode' && <QrCode className="w-3 h-3 text-[#666]" />}
|
{elem.type === 'qrcode' && <QrCode />}
|
||||||
{elem.type === 'image' && <ImageIcon className="w-3 h-3 text-[#666]" />}
|
{elem.type === 'image' && <ImageIcon />}
|
||||||
{elem.type === 'table' && <Table2 className="w-3 h-3 text-[#666]" />}
|
{elem.type === 'table' && <Table2 />}
|
||||||
</span>
|
</span>
|
||||||
<span className="truncate" title={elem.name}>
|
<span className="truncate" title={elem.name}>
|
||||||
{elem.name}
|
{elem.name}
|
||||||
@@ -2129,9 +2301,14 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`flex items-center gap-0.5 shrink-0 pointer-events-auto ${isNarrowScreen || variant === 'mobile' || isLocked
|
className={`ps-layer-item-actions pointer-events-auto ${
|
||||||
|
isNarrowScreen || variant === 'mobile' || isLocked
|
||||||
|
? ''
|
||||||
|
: 'ps-layer-item-actions--float'
|
||||||
|
} ${
|
||||||
|
isNarrowScreen || variant === 'mobile' || isLocked
|
||||||
? 'opacity-100'
|
? 'opacity-100'
|
||||||
: 'opacity-0 group-hover:opacity-100 transition-opacity'
|
: ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{!isLocked && (
|
{!isLocked && (
|
||||||
@@ -2141,37 +2318,60 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
onClick={(e) => { e.stopPropagation(); moveLayer(actualIdx, 'up'); }}
|
onClick={(e) => { e.stopPropagation(); moveLayer(actualIdx, 'up'); }}
|
||||||
disabled={actualIdx === template.elements.length - 1}
|
disabled={actualIdx === template.elements.length - 1}
|
||||||
title="上移"
|
title="上移"
|
||||||
className="p-0.5 hover:bg-[#555] disabled:opacity-20 cursor-pointer text-[#aaa]"
|
|
||||||
>
|
>
|
||||||
<ArrowUp className="w-3 h-3" />
|
<ArrowUp />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => { e.stopPropagation(); moveLayer(actualIdx, 'down'); }}
|
onClick={(e) => { e.stopPropagation(); moveLayer(actualIdx, 'down'); }}
|
||||||
disabled={actualIdx === 0}
|
disabled={actualIdx === 0}
|
||||||
title="下移"
|
title="下移"
|
||||||
className="p-0.5 hover:bg-[#555] disabled:opacity-20 cursor-pointer text-[#aaa]"
|
|
||||||
>
|
>
|
||||||
<ArrowDown className="w-3 h-3" />
|
<ArrowDown />
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{!isLocked && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setMaskDialogElementId(elem.id);
|
||||||
|
}}
|
||||||
|
title={isElementMaskActive(elem.mask) ? '编辑蒙版' : '添加蒙版'}
|
||||||
|
className={
|
||||||
|
isElementMaskActive(elem.mask)
|
||||||
|
? 'text-[#31a8ff]'
|
||||||
|
: 'text-[#666] hover:text-[#ccc]'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Blend />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => { e.stopPropagation(); toggleLayerVisibility(actualIdx); }}
|
||||||
|
title={isHidden ? '显示图层' : '隐藏图层'}
|
||||||
|
className={isHidden ? 'text-[#666]' : 'text-[#ccc] hover:text-white'}
|
||||||
|
>
|
||||||
|
{isHidden ? <EyeOff /> : <Eye />}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => { e.stopPropagation(); toggleLockLayer(actualIdx); }}
|
onClick={(e) => { e.stopPropagation(); toggleLockLayer(actualIdx); }}
|
||||||
title={isLocked ? '解锁' : '锁定'}
|
title={isLocked ? '解锁' : '锁定'}
|
||||||
className={`p-0.5 cursor-pointer ${isLocked ? 'text-amber-400' : 'text-[#666] hover:text-[#ccc]'}`}
|
className={isLocked ? 'text-amber-400' : 'text-[#666] hover:text-[#ccc]'}
|
||||||
>
|
>
|
||||||
{isLocked ? <Lock className="w-3 h-3" /> : <Unlock className="w-3 h-3" />}
|
{isLocked ? <Lock /> : <Unlock />}
|
||||||
</button>
|
</button>
|
||||||
{!isLocked && (
|
{!isLocked && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => { e.stopPropagation(); void deleteLayer(elem.id); }}
|
onClick={(e) => { e.stopPropagation(); void deleteLayer(elem.id); }}
|
||||||
title="删除"
|
title="删除"
|
||||||
className="p-0.5 hover:text-red-400 text-[#666] cursor-pointer"
|
className="hover:text-red-400 text-[#666]"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3 h-3" />
|
<Trash2 />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -2241,14 +2441,16 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
|
|
||||||
{primaryElem && (
|
{primaryElem && (
|
||||||
<div className="mobile-nudge-info">
|
<div className="mobile-nudge-info">
|
||||||
<p className="text-[12px] font-medium text-[#ddd] truncate">{primaryElem.name}</p>
|
<p className="text-[12px] font-medium text-[#ddd] truncate">
|
||||||
|
{nudgeableElementIds.length > 1 ? `已选 ${nudgeableElementIds.length} 个元素` : primaryElem.name}
|
||||||
|
</p>
|
||||||
<p className="text-[11px] font-mono text-[#31a8ff] mt-1">
|
<p className="text-[11px] font-mono text-[#31a8ff] mt-1">
|
||||||
X {primaryElem.x} · Y {primaryElem.y} mm
|
{nudgeableElementIds.length > 1 && multiSelectionBounds
|
||||||
|
? `X ${multiSelectionBounds.minX} · Y ${multiSelectionBounds.minY} mm`
|
||||||
|
: `X ${primaryElem.x} · Y ${primaryElem.y} mm`}
|
||||||
</p>
|
</p>
|
||||||
{nudgeableElementIds.length > 1 && (
|
{nudgeableElementIds.length > 1 && (
|
||||||
<p className="text-[10px] text-[#888] mt-1">
|
<p className="text-[10px] text-[#888] mt-1">将一起移动</p>
|
||||||
已选 {nudgeableElementIds.length} 个元素,将一起移动
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -2263,16 +2465,29 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
open={visibilityRuleDialog !== null}
|
open={visibilityRuleDialog !== null}
|
||||||
mode={visibilityRuleDialog?.mode ?? 'add'}
|
mode={visibilityRuleDialog?.mode ?? 'add'}
|
||||||
initialRule={editingVisibilityRule}
|
initialRule={editingVisibilityRule}
|
||||||
variableOptions={visibilityRuleDialogVariableOptions}
|
variableOptions={templateVariables}
|
||||||
onClose={() => setVisibilityRuleDialog(null)}
|
onClose={() => setVisibilityRuleDialog(null)}
|
||||||
onConfirm={handleConfirmVisibilityRule}
|
onConfirm={handleConfirmVisibilityRule}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const elementMaskDialogNode = (
|
||||||
|
<ElementMaskDialog
|
||||||
|
open={maskDialogElementId !== null}
|
||||||
|
element={maskDialogElement}
|
||||||
|
onClose={() => setMaskDialogElementId(null)}
|
||||||
|
onConfirm={(mask) => {
|
||||||
|
if (!maskDialogElementId) return;
|
||||||
|
updateElementMask(maskDialogElementId, mask);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
if (variant === 'mobile') {
|
if (variant === 'mobile') {
|
||||||
return (
|
return (
|
||||||
<div className="mobile-design-props">
|
<div className="mobile-design-props">
|
||||||
{visibilityRuleDialogNode}
|
{visibilityRuleDialogNode}
|
||||||
|
{elementMaskDialogNode}
|
||||||
<nav className="mobile-design-props-nav" aria-label="属性模块">
|
<nav className="mobile-design-props-nav" aria-label="属性模块">
|
||||||
{MOBILE_PROP_MODULES.map((item) => (
|
{MOBILE_PROP_MODULES.map((item) => (
|
||||||
<button
|
<button
|
||||||
@@ -2286,6 +2501,18 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<div className="mobile-design-props-nav-spacer" aria-hidden />
|
<div className="mobile-design-props-nav-spacer" aria-hidden />
|
||||||
|
{elementClipboard?.canCopy && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => elementClipboard.copyAndPasteSelectedElements()}
|
||||||
|
className="mobile-design-props-nav-btn"
|
||||||
|
aria-label="复制选中元素"
|
||||||
|
title="复制选中元素并创建副本"
|
||||||
|
>
|
||||||
|
<Copy className="w-4 h-4" />
|
||||||
|
<span>复制</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void deleteSelectedElements()}
|
onClick={() => void deleteSelectedElements()}
|
||||||
@@ -2319,6 +2546,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div className="ps-sidebar-stack">
|
<div className="ps-sidebar-stack">
|
||||||
{visibilityRuleDialogNode}
|
{visibilityRuleDialogNode}
|
||||||
|
{elementMaskDialogNode}
|
||||||
<CollapsiblePanelSection
|
<CollapsiblePanelSection
|
||||||
sectionClassName="properties-panel flex flex-col"
|
sectionClassName="properties-panel flex flex-col"
|
||||||
collapsed={panelCollapsed.properties}
|
collapsed={panelCollapsed.properties}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { TemplateElement } from '../types';
|
|||||||
import { CommitInput } from './CommitInput';
|
import { CommitInput } from './CommitInput';
|
||||||
import { FieldSelect } from './PsSelect';
|
import { FieldSelect } from './PsSelect';
|
||||||
import { ValueExtractFields } from './ValueExtractFields';
|
import { ValueExtractFields } from './ValueExtractFields';
|
||||||
|
import { ColorValueField } from './ColorValueField';
|
||||||
import { TextDisplayAffixFields } from './TextDisplayAffixFields';
|
import { TextDisplayAffixFields } from './TextDisplayAffixFields';
|
||||||
import {
|
import {
|
||||||
applyCellTextElementChange,
|
applyCellTextElementChange,
|
||||||
@@ -173,23 +174,15 @@ export const TableCellTextFields: React.FC<TableCellTextFieldsProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-[#3a3a3a] pt-2.5">
|
<div className="border-t border-[#3a3a3a] pt-2.5">
|
||||||
<label className="block text-[10px] font-semibold text-[#777] mb-1">文本颜色</label>
|
<ColorValueField
|
||||||
<div className="flex gap-1 items-center">
|
label="文本颜色"
|
||||||
<input
|
|
||||||
type="color"
|
|
||||||
value={textElem.textColor || '#111827'}
|
|
||||||
onChange={(e) => patchText({ ...textElem, textColor: e.target.value })}
|
|
||||||
className="color-input shrink-0"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="#111827"
|
|
||||||
value={textElem.textColor || ''}
|
value={textElem.textColor || ''}
|
||||||
onChange={(e) => patchText({ ...textElem, textColor: e.target.value })}
|
onChange={(val) => patchText({ ...textElem, textColor: val || undefined })}
|
||||||
className="ps-field font-mono text-[11px] flex-1 min-w-0"
|
variableOptions={templateVariables}
|
||||||
|
defaultColor="#111827"
|
||||||
|
placeholder="#111827"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="ps-field-label mb-0">文本样式</label>
|
<label className="ps-field-label mb-0">文本样式</label>
|
||||||
@@ -384,6 +377,53 @@ export const TableCellTextFields: React.FC<TableCellTextFieldsProps> = ({
|
|||||||
<span className="text-[12px] text-[#ccc]">自动换行</span>
|
<span className="text-[12px] text-[#ccc]">自动换行</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="ps-field-label">最大行数</label>
|
||||||
|
<CommitInput
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="99"
|
||||||
|
step="1"
|
||||||
|
value={textElem.textMaxLines ?? ''}
|
||||||
|
onCommit={(val) => {
|
||||||
|
const raw = String(val).trim();
|
||||||
|
if (!raw) {
|
||||||
|
patchText({ ...textElem, textMaxLines: undefined, textEllipsis: undefined });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const n = Math.max(0, Math.min(99, Math.floor(Number(val) || 0)));
|
||||||
|
patchText({
|
||||||
|
...textElem,
|
||||||
|
textMaxLines: n > 0 ? n : undefined,
|
||||||
|
textEllipsis: n > 0 ? textElem.textEllipsis : undefined,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="ps-field font-mono"
|
||||||
|
placeholder="不限"
|
||||||
|
/>
|
||||||
|
<p className="text-[9px] text-[#666] mt-0.5">留空表示不限制</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end pb-5">
|
||||||
|
<label
|
||||||
|
className={`flex items-center gap-2 cursor-pointer ${
|
||||||
|
!textElem.textMaxLines ? 'opacity-40 cursor-not-allowed' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={textElem.textEllipsis === true}
|
||||||
|
disabled={!textElem.textMaxLines || textElem.textMaxLines <= 0}
|
||||||
|
onChange={(e) =>
|
||||||
|
patchText({ ...textElem, textEllipsis: e.target.checked ? true : undefined })
|
||||||
|
}
|
||||||
|
className="ps-checkbox"
|
||||||
|
/>
|
||||||
|
<span className="text-[12px] text-[#ccc]">超出显示省略号</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2 border-t border-[#3a3a3a] pt-2">
|
<div className="grid grid-cols-2 gap-2 border-t border-[#3a3a3a] pt-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="ps-field-label">单元格背景</label>
|
<label className="ps-field-label">单元格背景</label>
|
||||||
@@ -409,7 +449,10 @@ export const TableCellTextFields: React.FC<TableCellTextFieldsProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ValueExtractFields elem={textElem} onChange={patchText} />
|
<ValueExtractFields
|
||||||
|
config={textElem}
|
||||||
|
onChange={(patch) => patchText({ ...textElem, ...patch })}
|
||||||
|
/>
|
||||||
<TextDisplayAffixFields elem={textElem} onChange={patchText} />
|
<TextDisplayAffixFields elem={textElem} onChange={patchText} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
updateTableCells,
|
updateTableCells,
|
||||||
updateTableColWidth,
|
updateTableColWidth,
|
||||||
updateTableRowHeight,
|
updateTableRowHeight,
|
||||||
|
equalizeTableColWidths,
|
||||||
|
equalizeTableRowHeights,
|
||||||
getTableColWidths,
|
getTableColWidths,
|
||||||
getTableRowHeights,
|
getTableRowHeights,
|
||||||
getCellRawContent,
|
getCellRawContent,
|
||||||
@@ -21,6 +23,7 @@ import {
|
|||||||
} from '../tableUtils';
|
} from '../tableUtils';
|
||||||
import { Table2, Merge, SplitSquareHorizontal } from 'lucide-react';
|
import { Table2, Merge, SplitSquareHorizontal } from 'lucide-react';
|
||||||
import { TableCellTextFields } from './TableCellTextFields';
|
import { TableCellTextFields } from './TableCellTextFields';
|
||||||
|
import { ColorValueField } from './ColorValueField';
|
||||||
|
|
||||||
const PropInput = CommitInput;
|
const PropInput = CommitInput;
|
||||||
|
|
||||||
@@ -90,7 +93,17 @@ export const TablePropertyFields: React.FC<TablePropertyFieldsProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span className="text-[10px] font-semibold text-[#aaa]">行高 (mm)</span>
|
<span className="text-[10px] font-semibold text-[#aaa]">行高 (mm)</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(equalizeTableRowHeights(elem))}
|
||||||
|
className="text-[9px] text-[#31a8ff] hover:underline cursor-pointer shrink-0"
|
||||||
|
title="将各行高度恢复为相等"
|
||||||
|
>
|
||||||
|
等分行高
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-3 gap-1.5">
|
<div className="grid grid-cols-3 gap-1.5">
|
||||||
{rowHeights.map((h, i) => (
|
{rowHeights.map((h, i) => (
|
||||||
<div key={`rh-${i}`}>
|
<div key={`rh-${i}`}>
|
||||||
@@ -111,7 +124,17 @@ export const TablePropertyFields: React.FC<TablePropertyFieldsProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span className="text-[10px] font-semibold text-[#aaa]">列宽 (mm)</span>
|
<span className="text-[10px] font-semibold text-[#aaa]">列宽 (mm)</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(equalizeTableColWidths(elem))}
|
||||||
|
className="text-[9px] text-[#31a8ff] hover:underline cursor-pointer shrink-0"
|
||||||
|
title="将各列宽度恢复为相等"
|
||||||
|
>
|
||||||
|
等分列宽
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-3 gap-1.5">
|
<div className="grid grid-cols-3 gap-1.5">
|
||||||
{colWidths.map((w, i) => (
|
{colWidths.map((w, i) => (
|
||||||
<div key={`cw-${i}`}>
|
<div key={`cw-${i}`}>
|
||||||
@@ -145,16 +168,15 @@ export const TablePropertyFields: React.FC<TablePropertyFieldsProps> = ({
|
|||||||
className="ps-field font-mono"
|
className="ps-field font-mono"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<ColorValueField
|
||||||
<label className="ps-field-label">网格线颜色</label>
|
label="网格线颜色"
|
||||||
<input
|
value={elem.tableBorderColor ?? ''}
|
||||||
type="color"
|
onChange={(val) => onChange({ ...elem, tableBorderColor: val || undefined })}
|
||||||
value={elem.tableBorderColor ?? '#374151'}
|
variableOptions={templateVariables}
|
||||||
onChange={(e) => onChange({ ...elem, tableBorderColor: e.target.value })}
|
defaultColor="#374151"
|
||||||
className="color-input w-full"
|
placeholder="#374151"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{normalizedCells.length > 0 && (
|
{normalizedCells.length > 0 && (
|
||||||
<div className="bg-[#1e1e1e] border border-[#3a3a3a] p-3 space-y-3">
|
<div className="bg-[#1e1e1e] border border-[#3a3a3a] p-3 space-y-3">
|
||||||
@@ -287,7 +309,9 @@ export const TableCellContentFields: React.FC<TableCellContentFieldsProps> = ({
|
|||||||
export function scaleTableElementSize(
|
export function scaleTableElementSize(
|
||||||
elem: TemplateElement,
|
elem: TemplateElement,
|
||||||
newWidth: number,
|
newWidth: number,
|
||||||
newHeight: number
|
newHeight: number,
|
||||||
|
startWidth = elem.width,
|
||||||
|
startHeight = elem.height
|
||||||
): TemplateElement {
|
): TemplateElement {
|
||||||
if (elem.type !== 'table') {
|
if (elem.type !== 'table') {
|
||||||
return { ...elem, width: newWidth, height: newHeight };
|
return { ...elem, width: newWidth, height: newHeight };
|
||||||
@@ -296,8 +320,8 @@ export function scaleTableElementSize(
|
|||||||
elem,
|
elem,
|
||||||
newWidth,
|
newWidth,
|
||||||
newHeight,
|
newHeight,
|
||||||
elem.width,
|
startWidth,
|
||||||
elem.height,
|
startHeight,
|
||||||
getTableRowHeights(elem),
|
getTableRowHeights(elem),
|
||||||
getTableColWidths(elem)
|
getTableColWidths(elem)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export const TextDisplayAffixFields: React.FC<TextDisplayAffixFieldsProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="col-span-2 text-[9px] text-[#666] leading-relaxed">
|
<p className="col-span-2 text-[9px] text-[#666] leading-relaxed">
|
||||||
在值提取完成后追加,仅影响显示;不参与「内容值」显示条件判断
|
在值提取完成后追加,仅影响显示;反选模式下会一并参与从原值中去除;不参与「内容值」显示条件判断
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,34 +1,39 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { TemplateElement } from '../types';
|
import type { ValueExtractConfig } from '../types';
|
||||||
import { CommitInput } from './CommitInput';
|
import { CommitInput } from './CommitInput';
|
||||||
import { FieldSelect } from './PsSelect';
|
import { FieldSelect } from './PsSelect';
|
||||||
|
|
||||||
interface ValueExtractFieldsProps {
|
interface ValueExtractFieldsProps {
|
||||||
elem: TemplateElement;
|
config: ValueExtractConfig;
|
||||||
onChange: (elem: TemplateElement) => void;
|
onChange: (patch: Partial<ValueExtractConfig>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PropInput = CommitInput;
|
const PropInput = CommitInput;
|
||||||
|
|
||||||
function getEffectiveExtractMode(elem: TemplateElement) {
|
function getEffectiveExtractMode(config: ValueExtractConfig) {
|
||||||
if (elem.textExtractMode !== undefined) return elem.textExtractMode;
|
if (config.textExtractMode !== undefined) return config.textExtractMode;
|
||||||
return elem.textSplitDelimiter?.trim() ? 'split' : 'none';
|
return config.textSplitDelimiter?.trim() ? 'split' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ValueExtractFields: React.FC<ValueExtractFieldsProps> = ({ elem, onChange }) => {
|
type ExtractSelectValue = 'none' | 'split' | 'prefix' | 'suffix' | 'inverse';
|
||||||
const extractMode = getEffectiveExtractMode(elem);
|
|
||||||
|
export const ValueExtractFields: React.FC<ValueExtractFieldsProps> = ({ config, onChange }) => {
|
||||||
|
const isInverse = config.textExtractInverse === true;
|
||||||
|
const extractMode = getEffectiveExtractMode(config);
|
||||||
|
const selectValue: ExtractSelectValue = isInverse ? 'inverse' : extractMode;
|
||||||
|
const showExtractFields = selectValue !== 'none';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="ps-field-label">值提取</label>
|
<label className="ps-field-label">值提取</label>
|
||||||
<FieldSelect
|
<FieldSelect
|
||||||
value={extractMode}
|
value={selectValue}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const mode = e.target.value as 'none' | 'split' | 'prefix' | 'suffix';
|
const mode = e.target.value as ExtractSelectValue;
|
||||||
if (mode === 'none') {
|
if (mode === 'none') {
|
||||||
onChange({
|
onChange({
|
||||||
...elem,
|
textExtractInverse: false,
|
||||||
textExtractMode: 'none',
|
textExtractMode: 'none',
|
||||||
textSplitDelimiter: undefined,
|
textSplitDelimiter: undefined,
|
||||||
textSplitIndex: undefined,
|
textSplitIndex: undefined,
|
||||||
@@ -36,14 +41,23 @@ export const ValueExtractFields: React.FC<ValueExtractFieldsProps> = ({ elem, on
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (mode === 'inverse') {
|
||||||
onChange({
|
onChange({
|
||||||
...elem,
|
textExtractInverse: true,
|
||||||
|
textExtractMode: extractMode === 'none' ? 'prefix' : extractMode,
|
||||||
|
textSplitIndex: config.textSplitIndex ?? 1,
|
||||||
|
textExtractLength: config.textExtractLength ?? 1,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onChange({
|
||||||
|
textExtractInverse: false,
|
||||||
textExtractMode: mode,
|
textExtractMode: mode,
|
||||||
textSplitIndex: mode === 'split' ? elem.textSplitIndex ?? 1 : elem.textSplitIndex,
|
textSplitIndex: mode === 'split' ? config.textSplitIndex ?? 1 : config.textSplitIndex,
|
||||||
textExtractLength:
|
textExtractLength:
|
||||||
mode === 'prefix' || mode === 'suffix'
|
mode === 'prefix' || mode === 'suffix'
|
||||||
? elem.textExtractLength ?? 1
|
? config.textExtractLength ?? 1
|
||||||
: elem.textExtractLength,
|
: config.textExtractLength,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
className="ps-field"
|
className="ps-field"
|
||||||
@@ -52,48 +66,76 @@ export const ValueExtractFields: React.FC<ValueExtractFieldsProps> = ({ elem, on
|
|||||||
<option value="split">按分隔符</option>
|
<option value="split">按分隔符</option>
|
||||||
<option value="prefix">前 N 位</option>
|
<option value="prefix">前 N 位</option>
|
||||||
<option value="suffix">后 N 位</option>
|
<option value="suffix">后 N 位</option>
|
||||||
|
<option value="inverse">反选</option>
|
||||||
</FieldSelect>
|
</FieldSelect>
|
||||||
</div>
|
</div>
|
||||||
{extractMode === 'split' && (
|
{isInverse && (
|
||||||
|
<div>
|
||||||
|
<label className="ps-field-label">去除段</label>
|
||||||
|
<FieldSelect
|
||||||
|
value={extractMode === 'none' ? 'prefix' : extractMode}
|
||||||
|
onChange={(e) => {
|
||||||
|
const mode = e.target.value as 'split' | 'prefix' | 'suffix';
|
||||||
|
onChange({
|
||||||
|
textExtractInverse: true,
|
||||||
|
textExtractMode: mode,
|
||||||
|
textSplitIndex: mode === 'split' ? config.textSplitIndex ?? 1 : config.textSplitIndex,
|
||||||
|
textExtractLength:
|
||||||
|
mode === 'prefix' || mode === 'suffix'
|
||||||
|
? config.textExtractLength ?? 1
|
||||||
|
: config.textExtractLength,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="ps-field"
|
||||||
|
>
|
||||||
|
<option value="prefix">前 N 位</option>
|
||||||
|
<option value="split">按分隔符</option>
|
||||||
|
<option value="suffix">后 N 位</option>
|
||||||
|
</FieldSelect>
|
||||||
|
<p className="text-[9px] text-[#666] mt-1 leading-relaxed">
|
||||||
|
从原值中去掉「显示前缀 + 提取段 + 显示后缀」。例如原值 A-1-1-1,提取前 1 位并追加后缀
|
||||||
|
「-」,则得到 1-1-1
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{showExtractFields && extractMode === 'split' && (
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="ps-field-label">分隔符</label>
|
<label className="ps-field-label">分隔符</label>
|
||||||
<PropInput
|
<PropInput
|
||||||
type="text"
|
type="text"
|
||||||
value={elem.textSplitDelimiter ?? ''}
|
value={config.textSplitDelimiter ?? ''}
|
||||||
placeholder="如 -"
|
placeholder="如 -"
|
||||||
onCommit={(val) => {
|
onCommit={(val) => {
|
||||||
const trimmed = val.trim();
|
const trimmed = val.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
onChange({
|
onChange({
|
||||||
...elem,
|
textExtractMode: isInverse ? 'split' : 'none',
|
||||||
textExtractMode: 'none',
|
textExtractInverse: isInverse ? true : false,
|
||||||
textSplitDelimiter: undefined,
|
textSplitDelimiter: undefined,
|
||||||
textSplitIndex: undefined,
|
textSplitIndex: undefined,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onChange({
|
onChange({
|
||||||
...elem,
|
|
||||||
textExtractMode: 'split',
|
textExtractMode: 'split',
|
||||||
textSplitDelimiter: trimmed,
|
textSplitDelimiter: trimmed,
|
||||||
textSplitIndex: elem.textSplitIndex ?? 1,
|
textSplitIndex: config.textSplitIndex ?? 1,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
className="ps-field font-mono"
|
className="ps-field font-mono"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="ps-field-label">显示第几位</label>
|
<label className="ps-field-label">{isInverse ? '去除第几位' : '显示第几位'}</label>
|
||||||
<PropInput
|
<PropInput
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
max="99"
|
max="99"
|
||||||
value={elem.textSplitIndex ?? 1}
|
value={config.textSplitIndex ?? 1}
|
||||||
disabled={!elem.textSplitDelimiter?.trim()}
|
disabled={!config.textSplitDelimiter?.trim()}
|
||||||
onCommit={(val) =>
|
onCommit={(val) =>
|
||||||
onChange({
|
onChange({
|
||||||
...elem,
|
|
||||||
textExtractMode: 'split',
|
textExtractMode: 'split',
|
||||||
textSplitIndex: Math.max(1, Math.min(99, Number(val) || 1)),
|
textSplitIndex: Math.max(1, Math.min(99, Number(val) || 1)),
|
||||||
})
|
})
|
||||||
@@ -101,34 +143,37 @@ export const ValueExtractFields: React.FC<ValueExtractFieldsProps> = ({ elem, on
|
|||||||
className="ps-field font-mono"
|
className="ps-field font-mono"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{!isInverse && (
|
||||||
<p className="col-span-2 text-[9px] text-[#666] leading-relaxed">
|
<p className="col-span-2 text-[9px] text-[#666] leading-relaxed">
|
||||||
变量替换为完整内容后,按分隔符拆分并取第 N 段(从 1 开始);段数不足时为空
|
变量替换为完整内容后,按分隔符拆分并取第 N 段(从 1 开始);段数不足时为空
|
||||||
</p>
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(extractMode === 'prefix' || extractMode === 'suffix') && (
|
{showExtractFields && (extractMode === 'prefix' || extractMode === 'suffix') && (
|
||||||
<div>
|
<div>
|
||||||
<label className="ps-field-label">
|
<label className="ps-field-label">
|
||||||
{extractMode === 'prefix' ? '前' : '后'} N 位
|
{isInverse ? '去除' : extractMode === 'prefix' ? '前' : '后'} N 位
|
||||||
</label>
|
</label>
|
||||||
<PropInput
|
<PropInput
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
max="999"
|
max="999"
|
||||||
value={elem.textExtractLength ?? 1}
|
value={config.textExtractLength ?? 1}
|
||||||
onCommit={(val) =>
|
onCommit={(val) =>
|
||||||
onChange({
|
onChange({
|
||||||
...elem,
|
|
||||||
textExtractLength: Math.max(1, Math.min(999, Number(val) || 1)),
|
textExtractLength: Math.max(1, Math.min(999, Number(val) || 1)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="ps-field font-mono"
|
className="ps-field font-mono"
|
||||||
/>
|
/>
|
||||||
|
{!isInverse && (
|
||||||
<p className="text-[9px] text-[#666] mt-1 leading-relaxed">
|
<p className="text-[9px] text-[#666] mt-1 leading-relaxed">
|
||||||
{extractMode === 'prefix'
|
{extractMode === 'prefix'
|
||||||
? '变量替换为完整内容后,取最前面的 N 个字符'
|
? '变量替换为完整内容后,取最前面的 N 个字符'
|
||||||
: '变量替换为完整内容后,取最后面的 N 个字符'}
|
: '变量替换为完整内容后,取最后面的 N 个字符'}
|
||||||
</p>
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useMemo, useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
import type { VisibilityRule } from '../types';
|
import type { VisibilityRule } from '../types';
|
||||||
import { resolveVariableFilterRules, toggleVisibilityRuleEnabled } from '../utils';
|
import { resolveVariableFilterRules, toggleVisibilityRuleEnabled } from '../utils';
|
||||||
@@ -32,17 +32,6 @@ export const VariableFilterRulesEditor: React.FC<VariableFilterRulesEditorProps>
|
|||||||
|
|
||||||
const normalizedRules = resolveVariableFilterRules(rules);
|
const normalizedRules = resolveVariableFilterRules(rules);
|
||||||
|
|
||||||
const ruleDialogVariableOptions = useMemo(() => {
|
|
||||||
if (!ruleDialog) return templateVariables;
|
|
||||||
if (ruleDialog.mode === 'add') {
|
|
||||||
return templateVariables.filter((name) => !normalizedRules.some((rule) => rule.variable === name));
|
|
||||||
}
|
|
||||||
const usedByOthers = normalizedRules
|
|
||||||
.filter((_, index) => index !== ruleDialog.index)
|
|
||||||
.map((rule) => rule.variable);
|
|
||||||
return templateVariables.filter((name) => !usedByOthers.includes(name));
|
|
||||||
}, [ruleDialog, normalizedRules, templateVariables]);
|
|
||||||
|
|
||||||
const editingRule =
|
const editingRule =
|
||||||
ruleDialog?.mode === 'edit' && ruleDialog.index !== undefined
|
ruleDialog?.mode === 'edit' && ruleDialog.index !== undefined
|
||||||
? normalizedRules[ruleDialog.index]
|
? normalizedRules[ruleDialog.index]
|
||||||
@@ -97,7 +86,7 @@ export const VariableFilterRulesEditor: React.FC<VariableFilterRulesEditorProps>
|
|||||||
open={ruleDialog !== null}
|
open={ruleDialog !== null}
|
||||||
mode={ruleDialog?.mode ?? 'add'}
|
mode={ruleDialog?.mode ?? 'add'}
|
||||||
initialRule={editingRule}
|
initialRule={editingRule}
|
||||||
variableOptions={ruleDialogVariableOptions}
|
variableOptions={templateVariables}
|
||||||
variableOnly
|
variableOnly
|
||||||
addTitle={addTitle}
|
addTitle={addTitle}
|
||||||
editTitle={editTitle}
|
editTitle={editTitle}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
import { VisibilityRule } from '../types';
|
import { VisibilityRule } from '../types';
|
||||||
import { visibilityOperatorNeedsValue } from '../utils';
|
import { visibilityOperatorNeedsValue, valueExtractConfigHasExtract } from '../utils';
|
||||||
import { VisibilityRuleForm, type VisibilityRuleDraft } from './VisibilityRuleForm';
|
import { VisibilityRuleForm, type VisibilityRuleDraft } from './VisibilityRuleForm';
|
||||||
|
|
||||||
const EMPTY_DRAFT: VisibilityRuleDraft = {
|
const EMPTY_DRAFT: VisibilityRuleDraft = {
|
||||||
@@ -11,6 +12,46 @@ const EMPTY_DRAFT: VisibilityRuleDraft = {
|
|||||||
value: '',
|
value: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function pickRuleExtractFields(draft: VisibilityRuleDraft): Partial<VisibilityRule> {
|
||||||
|
if (!valueExtractConfigHasExtract(draft)) {
|
||||||
|
return {
|
||||||
|
textExtractMode: undefined,
|
||||||
|
textSplitDelimiter: undefined,
|
||||||
|
textSplitIndex: undefined,
|
||||||
|
textExtractLength: undefined,
|
||||||
|
textExtractInverse: undefined,
|
||||||
|
textDisplayPrefix: undefined,
|
||||||
|
textDisplaySuffix: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
textExtractMode: draft.textExtractMode,
|
||||||
|
textSplitDelimiter: draft.textSplitDelimiter,
|
||||||
|
textSplitIndex: draft.textSplitIndex,
|
||||||
|
textExtractLength: draft.textExtractLength,
|
||||||
|
textExtractInverse: draft.textExtractInverse || undefined,
|
||||||
|
textDisplayPrefix: draft.textDisplayPrefix || undefined,
|
||||||
|
textDisplaySuffix: draft.textDisplaySuffix || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function ruleToDraft(rule: VisibilityRule): VisibilityRuleDraft {
|
||||||
|
return {
|
||||||
|
target: rule.target ?? 'variable',
|
||||||
|
variable: rule.variable,
|
||||||
|
operator: rule.operator,
|
||||||
|
value: rule.value ?? '',
|
||||||
|
textExtractMode: rule.textExtractMode,
|
||||||
|
textSplitDelimiter: rule.textSplitDelimiter,
|
||||||
|
textSplitIndex: rule.textSplitIndex,
|
||||||
|
textExtractLength: rule.textExtractLength,
|
||||||
|
textExtractInverse: rule.textExtractInverse,
|
||||||
|
textDisplayPrefix: rule.textDisplayPrefix,
|
||||||
|
textDisplaySuffix: rule.textDisplaySuffix,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
interface VisibilityRuleDialogProps {
|
interface VisibilityRuleDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
mode: 'add' | 'edit';
|
mode: 'add' | 'edit';
|
||||||
@@ -39,12 +80,7 @@ export const VisibilityRuleDialog: React.FC<VisibilityRuleDialogProps> = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
if (mode === 'edit' && initialRule) {
|
if (mode === 'edit' && initialRule) {
|
||||||
setDraft({
|
setDraft(ruleToDraft(initialRule));
|
||||||
target: initialRule.target ?? 'variable',
|
|
||||||
variable: initialRule.variable,
|
|
||||||
operator: initialRule.operator,
|
|
||||||
value: initialRule.value ?? '',
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
setDraft({
|
setDraft({
|
||||||
...EMPTY_DRAFT,
|
...EMPTY_DRAFT,
|
||||||
@@ -65,7 +101,7 @@ export const VisibilityRuleDialog: React.FC<VisibilityRuleDialogProps> = ({
|
|||||||
return () => window.removeEventListener('keydown', onKeyDown);
|
return () => window.removeEventListener('keydown', onKeyDown);
|
||||||
}, [open, onClose]);
|
}, [open, onClose]);
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open || typeof document === 'undefined') return null;
|
||||||
|
|
||||||
const target = draft.target ?? 'variable';
|
const target = draft.target ?? 'variable';
|
||||||
const canConfirm = target === 'content' || draft.variable.trim().length > 0;
|
const canConfirm = target === 'content' || draft.variable.trim().length > 0;
|
||||||
@@ -82,12 +118,13 @@ export const VisibilityRuleDialog: React.FC<VisibilityRuleDialogProps> = ({
|
|||||||
value: visibilityOperatorNeedsValue(draft.operator)
|
value: visibilityOperatorNeedsValue(draft.operator)
|
||||||
? draft.value?.trim() || undefined
|
? draft.value?.trim() || undefined
|
||||||
: undefined,
|
: undefined,
|
||||||
|
...pickRuleExtractFields(draft),
|
||||||
});
|
});
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return createPortal(
|
||||||
<div className="ps-modal-overlay" onClick={onClose} role="presentation">
|
<div className="ps-modal-overlay" role="presentation">
|
||||||
<div
|
<div
|
||||||
className="ps-modal"
|
className="ps-modal"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
@@ -131,6 +168,7 @@ export const VisibilityRuleDialog: React.FC<VisibilityRuleDialogProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
|
document.body
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,13 +3,16 @@ import {
|
|||||||
VisibilityRule,
|
VisibilityRule,
|
||||||
VisibilityOperator,
|
VisibilityOperator,
|
||||||
VisibilityRuleTarget,
|
VisibilityRuleTarget,
|
||||||
|
ValueExtractConfig,
|
||||||
VISIBILITY_OPERATOR_LABELS,
|
VISIBILITY_OPERATOR_LABELS,
|
||||||
VISIBILITY_RULE_TARGET_LABELS,
|
VISIBILITY_RULE_TARGET_LABELS,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { visibilityOperatorNeedsValue } from '../utils';
|
import { visibilityOperatorNeedsValue, valueExtractConfigHasExtract } from '../utils';
|
||||||
import { FieldSelect } from './PsSelect';
|
import { FieldSelect } from './PsSelect';
|
||||||
|
import { ValueExtractFields } from './ValueExtractFields';
|
||||||
|
import { TextDisplayAffixFields } from './TextDisplayAffixFields';
|
||||||
|
|
||||||
export interface VisibilityRuleDraft {
|
export interface VisibilityRuleDraft extends ValueExtractConfig {
|
||||||
target: VisibilityRuleTarget;
|
target: VisibilityRuleTarget;
|
||||||
variable: string;
|
variable: string;
|
||||||
operator: VisibilityOperator;
|
operator: VisibilityOperator;
|
||||||
@@ -31,6 +34,7 @@ export const VisibilityRuleForm: React.FC<VisibilityRuleFormProps> = ({
|
|||||||
variableOnly = false,
|
variableOnly = false,
|
||||||
}) => {
|
}) => {
|
||||||
const target = variableOnly ? 'variable' : (rule.target ?? 'variable');
|
const target = variableOnly ? 'variable' : (rule.target ?? 'variable');
|
||||||
|
const hasExtract = valueExtractConfigHasExtract(rule);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -82,10 +86,47 @@ export const VisibilityRuleForm: React.FC<VisibilityRuleFormProps> = ({
|
|||||||
) : (
|
) : (
|
||||||
!variableOnly && (
|
!variableOnly && (
|
||||||
<p className="text-[9px] text-[#666] leading-relaxed">
|
<p className="text-[9px] text-[#666] leading-relaxed">
|
||||||
使用本元素绑定内容解析并提取后的显示文本(含数值格式等;不含前缀/后缀字符)
|
使用本元素绑定内容的变量替换与数值格式化结果(不含元素自身的值提取与显示前后缀)
|
||||||
</p>
|
</p>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
<div className="pt-2 border-t border-[#3a3a3a] space-y-2">
|
||||||
|
<div className="text-[10px] font-bold text-[#31a8ff]">值提取</div>
|
||||||
|
<ValueExtractFields config={rule} onChange={onChange} />
|
||||||
|
{hasExtract && rule.textExtractInverse && (
|
||||||
|
<TextDisplayAffixFields
|
||||||
|
elem={{
|
||||||
|
id: '',
|
||||||
|
type: 'text',
|
||||||
|
name: '',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
content: '',
|
||||||
|
fontSize: 10,
|
||||||
|
textAlign: 'left',
|
||||||
|
fontWeight: 'normal',
|
||||||
|
barcodeFormat: 'CODE128',
|
||||||
|
showText: true,
|
||||||
|
fontFamily: 'sans',
|
||||||
|
textDisplayPrefix: rule.textDisplayPrefix,
|
||||||
|
textDisplaySuffix: rule.textDisplaySuffix,
|
||||||
|
}}
|
||||||
|
onChange={(patch) =>
|
||||||
|
onChange({
|
||||||
|
textDisplayPrefix: patch.textDisplayPrefix,
|
||||||
|
textDisplaySuffix: patch.textDisplaySuffix,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{hasExtract && !rule.textExtractInverse && (
|
||||||
|
<p className="text-[9px] text-[#666] leading-relaxed">
|
||||||
|
条件判断前先对{target === 'content' ? '内容值' : '变量值'}做提取,再与目标值比较
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="ps-field-label">条件</label>
|
<label className="ps-field-label">条件</label>
|
||||||
<FieldSelect
|
<FieldSelect
|
||||||
@@ -108,14 +149,51 @@ export const VisibilityRuleForm: React.FC<VisibilityRuleFormProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
{visibilityOperatorNeedsValue(rule.operator) && (
|
{visibilityOperatorNeedsValue(rule.operator) && (
|
||||||
<div>
|
<div>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
<label className="ps-field-label">目标值</label>
|
<label className="ps-field-label">目标值</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!rule.value}
|
||||||
|
onClick={() => onChange({ value: '' })}
|
||||||
|
className="text-[9px] text-[#31a8ff] hover:underline cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed disabled:no-underline shrink-0"
|
||||||
|
>
|
||||||
|
清空
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={rule.value ?? ''}
|
value={rule.value ?? ''}
|
||||||
onChange={(e) => onChange({ value: e.target.value })}
|
onChange={(e) => onChange({ value: e.target.value })}
|
||||||
placeholder="比较目标值"
|
placeholder="比较目标值,可使用 {变量名}"
|
||||||
className="ps-field font-mono text-[11px] w-full mt-0.5"
|
className="ps-field font-mono text-[11px] w-full mt-0.5"
|
||||||
/>
|
/>
|
||||||
|
{variableOptions.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||||
|
{variableOptions.map((v) => (
|
||||||
|
<button
|
||||||
|
key={v}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const token = `{${v}}`;
|
||||||
|
const current = rule.value ?? '';
|
||||||
|
onChange({
|
||||||
|
value: current.includes(token)
|
||||||
|
? current
|
||||||
|
: current
|
||||||
|
? `${current}${current.endsWith(' ') ? '' : ' '}${token}`
|
||||||
|
: token,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="ps-btn text-[9px] font-mono"
|
||||||
|
>
|
||||||
|
{`{${v}}`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-[9px] text-[#666] mt-1 leading-relaxed">
|
||||||
|
支持字面量或 {`{变量名}`};导出时按数据行解析变量后再比较
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,6 +28,30 @@ export function getSelectionBounds(selected: TemplateElement[]) {
|
|||||||
return { minX, minY, maxX, maxY };
|
return { minX, minY, maxX, maxY };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 将选中元素整体平移,使选区左上角移至 (newMinX, newMinY) */
|
||||||
|
export function setSelectionOrigin(
|
||||||
|
elements: TemplateElement[],
|
||||||
|
selectedIds: string[],
|
||||||
|
newMinX: number,
|
||||||
|
newMinY: number
|
||||||
|
): TemplateElement[] {
|
||||||
|
const selected = elements.filter((e) => selectedIds.includes(e.id) && !e.locked);
|
||||||
|
if (selected.length === 0) return elements;
|
||||||
|
const { minX, minY } = getSelectionBounds(selected);
|
||||||
|
const deltaX = round1(newMinX - minX);
|
||||||
|
const deltaY = round1(newMinY - minY);
|
||||||
|
if (deltaX === 0 && deltaY === 0) return elements;
|
||||||
|
const idSet = new Set(selected.map((e) => e.id));
|
||||||
|
return elements.map((el) => {
|
||||||
|
if (!idSet.has(el.id)) return el;
|
||||||
|
return {
|
||||||
|
...el,
|
||||||
|
x: round1(el.x + deltaX),
|
||||||
|
y: round1(el.y + deltaY),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** 选中元素对齐到共同选区边界 */
|
/** 选中元素对齐到共同选区边界 */
|
||||||
export function alignToSelectionBounds(
|
export function alignToSelectionBounds(
|
||||||
elements: TemplateElement[],
|
elements: TemplateElement[],
|
||||||
|
|||||||
278
src/elementMask.ts
Normal file
278
src/elementMask.ts
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
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) return false;
|
||||||
|
if (mask.enabled === false) return false;
|
||||||
|
if (mask.type === 'image') return !!mask.image?.trim();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDefaultElementMask(elem: TemplateElement): ElementMaskConfig {
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
type: 'rect',
|
||||||
|
mode: 'include',
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveMaskRegionPx(
|
||||||
|
elem: TemplateElement,
|
||||||
|
mask: ElementMaskConfig,
|
||||||
|
scale: number
|
||||||
|
) {
|
||||||
|
const region = resolveMaskRegionMm(elem, mask);
|
||||||
|
return {
|
||||||
|
x: Math.round(region.x * scale),
|
||||||
|
y: Math.round(region.y * scale),
|
||||||
|
w: Math.max(1, Math.round(region.width * scale)),
|
||||||
|
h: Math.max(1, Math.round(region.height * scale)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillRectMaskAlpha(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
mx: number,
|
||||||
|
my: number,
|
||||||
|
mw: number,
|
||||||
|
mh: number,
|
||||||
|
mask: ElementMaskConfig,
|
||||||
|
scale: number
|
||||||
|
) {
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
traceRoundedRect(ctx, mx, my, mw, mh, resolveMaskCornerRadiiPx(mask, scale, mw, mh));
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将图片按亮度转为 alpha 蒙版(白=不透明,黑=透明) */
|
||||||
|
function buildImageLuminanceMaskCanvas(
|
||||||
|
img: HTMLImageElement,
|
||||||
|
w: number,
|
||||||
|
h: number,
|
||||||
|
fit: 'contain' | 'cover' | 'fill',
|
||||||
|
invert: boolean
|
||||||
|
): HTMLCanvasElement {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = w;
|
||||||
|
canvas.height = h;
|
||||||
|
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||||
|
if (!ctx) return canvas;
|
||||||
|
|
||||||
|
drawImageWithFit(ctx, img, 0, 0, w, h, fit);
|
||||||
|
const imageData = ctx.getImageData(0, 0, w, h);
|
||||||
|
const data = imageData.data;
|
||||||
|
for (let i = 0; i < data.length; i += 4) {
|
||||||
|
const luminance = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];
|
||||||
|
const alpha = invert ? 255 - luminance : luminance;
|
||||||
|
data[i] = 255;
|
||||||
|
data[i + 1] = 255;
|
||||||
|
data[i + 2] = 255;
|
||||||
|
data[i + 3] = alpha;
|
||||||
|
}
|
||||||
|
ctx.putImageData(imageData, 0, 0);
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生成与元素同尺寸的蒙版 alpha 图(不透明区域保留图层像素) */
|
||||||
|
async function buildMaskAlphaCanvas(
|
||||||
|
elem: TemplateElement,
|
||||||
|
mask: ElementMaskConfig,
|
||||||
|
ew: number,
|
||||||
|
eh: number,
|
||||||
|
scale: number
|
||||||
|
): Promise<HTMLCanvasElement> {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = Math.max(1, ew);
|
||||||
|
canvas.height = Math.max(1, eh);
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return canvas;
|
||||||
|
|
||||||
|
const { x: mx, y: my, w: mw, h: mh } = resolveMaskRegionPx(elem, mask, scale);
|
||||||
|
const include = (mask.mode ?? 'include') === 'include';
|
||||||
|
|
||||||
|
if (mask.type === 'image' && mask.image) {
|
||||||
|
const img = await loadImageOrNull(mask.image);
|
||||||
|
if (img) {
|
||||||
|
const maskCanvas = buildImageLuminanceMaskCanvas(
|
||||||
|
img,
|
||||||
|
mw,
|
||||||
|
mh,
|
||||||
|
mask.imageFit ?? 'fill',
|
||||||
|
!include
|
||||||
|
);
|
||||||
|
ctx.drawImage(maskCanvas, mx, my);
|
||||||
|
} else if (include) {
|
||||||
|
fillRectMaskAlpha(ctx, mx, my, mw, mh, mask, scale);
|
||||||
|
}
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (include) {
|
||||||
|
fillRectMaskAlpha(ctx, mx, my, mw, mh, mask, scale);
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.fillRect(0, 0, ew, eh);
|
||||||
|
ctx.globalCompositeOperation = 'destination-out';
|
||||||
|
fillRectMaskAlpha(ctx, mx, my, mw, mh, mask, scale);
|
||||||
|
ctx.globalCompositeOperation = 'source-over';
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 对已绘制的图层离屏画布应用蒙版(destination-in) */
|
||||||
|
async function applyMaskToLayerCanvas(
|
||||||
|
layerCtx: CanvasRenderingContext2D,
|
||||||
|
elem: TemplateElement,
|
||||||
|
mask: ElementMaskConfig,
|
||||||
|
ew: number,
|
||||||
|
eh: number,
|
||||||
|
scale: number
|
||||||
|
) {
|
||||||
|
const alphaCanvas = await buildMaskAlphaCanvas(elem, mask, ew, eh, scale);
|
||||||
|
layerCtx.save();
|
||||||
|
layerCtx.globalCompositeOperation = 'destination-in';
|
||||||
|
layerCtx.drawImage(alphaCanvas, 0, 0);
|
||||||
|
layerCtx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
type DrawLayerFn = (
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
w: number,
|
||||||
|
h: number
|
||||||
|
) => void | Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绘制元素图层:无蒙版时直接绘制;有蒙版时先在离屏画布绘制整层(背景、内容、边框),应用蒙版后再合成到主画布。
|
||||||
|
*/
|
||||||
|
export async function composeElementLayer(
|
||||||
|
mainCtx: CanvasRenderingContext2D,
|
||||||
|
elem: TemplateElement,
|
||||||
|
ex: number,
|
||||||
|
ey: number,
|
||||||
|
ew: number,
|
||||||
|
eh: number,
|
||||||
|
scale: number,
|
||||||
|
drawLayer: DrawLayerFn
|
||||||
|
) {
|
||||||
|
const mask = isElementMaskActive(elem.mask) ? elem.mask : undefined;
|
||||||
|
|
||||||
|
if (!mask) {
|
||||||
|
await drawLayer(mainCtx, ex, ey, ew, eh);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const layerCanvas = document.createElement('canvas');
|
||||||
|
layerCanvas.width = Math.max(1, ew);
|
||||||
|
layerCanvas.height = Math.max(1, eh);
|
||||||
|
const layerCtx = layerCanvas.getContext('2d');
|
||||||
|
if (!layerCtx) {
|
||||||
|
await drawLayer(mainCtx, ex, ey, ew, eh);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await drawLayer(layerCtx, 0, 0, ew, eh);
|
||||||
|
await applyMaskToLayerCanvas(layerCtx, elem, mask, ew, eh, scale);
|
||||||
|
mainCtx.drawImage(layerCanvas, ex, ey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated 使用 composeElementLayer */
|
||||||
|
export const drawElementContentWithMask = composeElementLayer;
|
||||||
113
src/hooks/useElementClipboard.ts
Normal file
113
src/hooks/useElementClipboard.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { useCallback, useRef, useState } from 'react';
|
||||||
|
import type { LabelTemplate, TemplateElement } from '../types';
|
||||||
|
import type { TableCellCoord } from '../tableUtils';
|
||||||
|
import { cloneTemplateElementsForPaste } from '../utils';
|
||||||
|
|
||||||
|
/** 应用内元素剪贴板(内存,跨模板/跨页面模块共享) */
|
||||||
|
const globalElementClipboard: { elements: TemplateElement[] | null } = { elements: null };
|
||||||
|
|
||||||
|
function getClipboardCount() {
|
||||||
|
return globalElementClipboard.elements?.length ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setGlobalClipboard(elements: TemplateElement[] | null) {
|
||||||
|
globalElementClipboard.elements = elements;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseElementClipboardOptions {
|
||||||
|
template: LabelTemplate;
|
||||||
|
onChange: (updated: LabelTemplate) => void;
|
||||||
|
selectedElementIds: string[];
|
||||||
|
onSelectElements: (ids: string[]) => void;
|
||||||
|
onSelectTableCells?: (cells: TableCellCoord[]) => void;
|
||||||
|
onCopySuccess?: (count: number) => void;
|
||||||
|
onDuplicateSuccess?: (count: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ElementClipboardActions {
|
||||||
|
copySelectedElements: () => void;
|
||||||
|
pasteClipboardElements: () => void;
|
||||||
|
/** 复制并立即粘贴(移动端快捷复制) */
|
||||||
|
copyAndPasteSelectedElements: () => void;
|
||||||
|
canCopy: boolean;
|
||||||
|
canPaste: boolean;
|
||||||
|
clipboardCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useElementClipboard({
|
||||||
|
template,
|
||||||
|
onChange,
|
||||||
|
selectedElementIds,
|
||||||
|
onSelectElements,
|
||||||
|
onSelectTableCells,
|
||||||
|
onCopySuccess,
|
||||||
|
onDuplicateSuccess,
|
||||||
|
}: UseElementClipboardOptions): ElementClipboardActions {
|
||||||
|
const templateRef = useRef(template);
|
||||||
|
templateRef.current = template;
|
||||||
|
const [clipboardCount, setClipboardCount] = useState(getClipboardCount);
|
||||||
|
|
||||||
|
const syncClipboardCount = useCallback((elements: TemplateElement[] | null) => {
|
||||||
|
setGlobalClipboard(elements);
|
||||||
|
setClipboardCount(elements?.length ?? 0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const canCopy = selectedElementIds.length > 0;
|
||||||
|
const canPaste = clipboardCount > 0;
|
||||||
|
|
||||||
|
const copySelectedElements = useCallback(() => {
|
||||||
|
if (selectedElementIds.length === 0) return;
|
||||||
|
const current = templateRef.current;
|
||||||
|
const idSet = new Set(selectedElementIds);
|
||||||
|
const toCopy = current.elements.filter((el) => idSet.has(el.id));
|
||||||
|
if (toCopy.length === 0) return;
|
||||||
|
const cloned = JSON.parse(JSON.stringify(toCopy)) as TemplateElement[];
|
||||||
|
syncClipboardCount(cloned);
|
||||||
|
onCopySuccess?.(toCopy.length);
|
||||||
|
}, [selectedElementIds, syncClipboardCount, onCopySuccess]);
|
||||||
|
|
||||||
|
const pasteClipboardElements = useCallback(() => {
|
||||||
|
const source = globalElementClipboard.elements;
|
||||||
|
if (!source || source.length === 0) return;
|
||||||
|
const current = templateRef.current;
|
||||||
|
const pasted = cloneTemplateElementsForPaste(source, 1);
|
||||||
|
onChange({
|
||||||
|
...current,
|
||||||
|
elements: [...current.elements, ...pasted],
|
||||||
|
});
|
||||||
|
onSelectElements(pasted.map((el) => el.id));
|
||||||
|
onSelectTableCells?.([]);
|
||||||
|
syncClipboardCount(null);
|
||||||
|
}, [onChange, onSelectElements, onSelectTableCells, syncClipboardCount]);
|
||||||
|
|
||||||
|
const copyAndPasteSelectedElements = useCallback(() => {
|
||||||
|
if (selectedElementIds.length === 0) return;
|
||||||
|
const current = templateRef.current;
|
||||||
|
const idSet = new Set(selectedElementIds);
|
||||||
|
const toCopy = current.elements.filter((el) => idSet.has(el.id));
|
||||||
|
if (toCopy.length === 0) return;
|
||||||
|
const pasted = cloneTemplateElementsForPaste(toCopy, 1);
|
||||||
|
onChange({
|
||||||
|
...current,
|
||||||
|
elements: [...current.elements, ...pasted],
|
||||||
|
});
|
||||||
|
onSelectElements(pasted.map((el) => el.id));
|
||||||
|
onSelectTableCells?.([]);
|
||||||
|
onDuplicateSuccess?.(toCopy.length);
|
||||||
|
}, [
|
||||||
|
selectedElementIds,
|
||||||
|
onChange,
|
||||||
|
onSelectElements,
|
||||||
|
onSelectTableCells,
|
||||||
|
onDuplicateSuccess,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
copySelectedElements,
|
||||||
|
pasteClipboardElements,
|
||||||
|
copyAndPasteSelectedElements,
|
||||||
|
canCopy,
|
||||||
|
canPaste,
|
||||||
|
clipboardCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -17,9 +17,18 @@ export function useMediaQuery(query: string): boolean {
|
|||||||
return matches;
|
return matches;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 与 Tailwind `md` 断点一致:< 768px 视为移动端 */
|
/** 移动端上下布局:手机窄屏,或平板竖屏(宽度 < 1024 且竖屏) */
|
||||||
|
export const MOBILE_LAYOUT_MEDIA_QUERY =
|
||||||
|
'(max-width: 767px), (max-width: 1023px) and (orientation: portrait)';
|
||||||
|
|
||||||
|
/** 移动端上下布局:手机窄屏,或平板竖屏(宽度 < 1024 且竖屏) */
|
||||||
export function useIsMobile(): boolean {
|
export function useIsMobile(): boolean {
|
||||||
return useMediaQuery('(max-width: 767px)');
|
return useMediaQuery(MOBILE_LAYOUT_MEDIA_QUERY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 语义别名:是否使用移动端上下布局 */
|
||||||
|
export function useMobileLayout(): boolean {
|
||||||
|
return useIsMobile();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 平板等窄屏:< 1024px */
|
/** 平板等窄屏:< 1024px */
|
||||||
|
|||||||
176
src/index.css
176
src/index.css
@@ -590,7 +590,7 @@ html.app-dark-shell body {
|
|||||||
.ps-modal-overlay {
|
.ps-modal-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 9000;
|
z-index: 10000;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -753,11 +753,14 @@ html.app-dark-shell body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ps-layer-item {
|
.ps-layer-item {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 4px 8px;
|
gap: 8px;
|
||||||
font-size: 11px;
|
min-height: 40px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
border-bottom: 1px solid #222;
|
border-bottom: 1px solid #222;
|
||||||
@@ -783,6 +786,83 @@ html.app-dark-shell body {
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ps-layer-item-hidden {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ps-layer-item-hidden .truncate {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ps-layer-item-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ps-layer-item-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* PC:未 hover 时不占位,悬停时浮于右侧 */
|
||||||
|
.ps-layer-item-actions--float {
|
||||||
|
position: absolute;
|
||||||
|
right: 4px;
|
||||||
|
top: 50%;
|
||||||
|
z-index: 1;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
padding-left: 16px;
|
||||||
|
background: linear-gradient(to left, #2d2d2d 180px, transparent);
|
||||||
|
transition: opacity 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ps-layer-item:hover .ps-layer-item-actions--float {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
background: linear-gradient(to left, #383838 180px, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ps-layer-item.selected:hover .ps-layer-item-actions--float {
|
||||||
|
background: linear-gradient(to left, #3d4f5f 180px, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ps-layer-item-actions button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 2px;
|
||||||
|
color: #aaa;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* .ps-layer-item-actions button:hover:not(:disabled) {
|
||||||
|
background: #555;
|
||||||
|
} */
|
||||||
|
|
||||||
|
.ps-layer-item-actions button:disabled {
|
||||||
|
opacity: 0.2;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ps-layer-item-actions button svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.ps-marquee-select {
|
.ps-marquee-select {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
border: 1px solid #31a8ff;
|
border: 1px solid #31a8ff;
|
||||||
@@ -797,6 +877,12 @@ html.app-dark-shell body {
|
|||||||
outline-offset: 0;
|
outline-offset: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ps-resize-outline {
|
||||||
|
outline: 1px dashed var(--color-ps-accent);
|
||||||
|
outline-offset: 0;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(49, 168, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
.ps-unselected-dim-layer {
|
.ps-unselected-dim-layer {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -809,10 +895,6 @@ html.app-dark-shell body {
|
|||||||
|
|
||||||
.ps-unselected-dim-layer--visible {
|
.ps-unselected-dim-layer--visible {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
|
||||||
|
|
||||||
.ps-unselected-dim {
|
|
||||||
position: absolute;
|
|
||||||
background: rgba(255, 255, 255, 0.3);
|
background: rgba(255, 255, 255, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -896,8 +978,8 @@ html.app-dark-shell body {
|
|||||||
background-size: 24px 24px;
|
background-size: 24px 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Mobile layout (< 768px) ── */
|
/* ── Mobile layout:手机窄屏,或平板竖屏 ── */
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px), (max-width: 1023px) and (orientation: portrait) {
|
||||||
|
|
||||||
/* Mobile export flow — shares mobile-design-view shell */
|
/* Mobile export flow — shares mobile-design-view shell */
|
||||||
.mobile-export-view {
|
.mobile-export-view {
|
||||||
@@ -1506,13 +1588,25 @@ html.app-dark-shell body {
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
width: 64px;
|
width: 64px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
min-height: 0;
|
||||||
|
align-self: stretch;
|
||||||
padding: 8px 6px;
|
padding: 8px 6px;
|
||||||
background: #252525;
|
background: #252525;
|
||||||
border-right: 1px solid #1a1a1a;
|
border-right: 1px solid #1a1a1a;
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-design-props-nav::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-design-props-nav-spacer {
|
.mobile-design-props-nav-spacer {
|
||||||
flex: 1;
|
flex: 1 1 auto;
|
||||||
min-height: 8px;
|
min-height: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1585,21 +1679,66 @@ html.app-dark-shell body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
overflow-x: auto;
|
overflow: hidden;
|
||||||
scrollbar-width: none;
|
|
||||||
-ms-overflow-style: none;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-design-toolstrip-tools::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-design-tool-btn {
|
.mobile-design-tool-btn {
|
||||||
width: 40px !important;
|
width: 40px !important;
|
||||||
height: 40px !important;
|
height: 40px !important;
|
||||||
min-height: 40px !important;
|
min-height: 40px !important;
|
||||||
|
border-radius: 10px !important;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-design-tool-btn.active:hover {
|
||||||
|
background: #1a1a1a;
|
||||||
|
color: var(--color-ps-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-design-tool-btn:hover {
|
||||||
|
background: transparent;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-toolstrip-overflow-menu {
|
||||||
|
min-width: 148px;
|
||||||
|
padding: 4px;
|
||||||
|
background: #2a2a2a;
|
||||||
|
border: 1px solid #3a3a3a;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-toolstrip-overflow-menu-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: #ccc;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-toolstrip-overflow-menu-item:hover,
|
||||||
|
.mobile-toolstrip-overflow-menu-item.active {
|
||||||
|
background: #1e3a4f;
|
||||||
|
color: #31a8ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-toolstrip-overflow-menu-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1683,7 +1822,8 @@ html.app-dark-shell body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
/* 桌面宽屏 / 横屏平板:隐藏仅移动端渲染的视图 */
|
||||||
|
@media (min-width: 768px) and (orientation: landscape), (min-width: 1024px) {
|
||||||
.mobile-only {
|
.mobile-only {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,14 @@ import {
|
|||||||
buildTableGridBoundariesPx,
|
buildTableGridBoundariesPx,
|
||||||
getCellRectPx,
|
getCellRectPx,
|
||||||
} from './tableUtils';
|
} from './tableUtils';
|
||||||
|
import { composeElementLayer, isElementMaskActive } from './elementMask';
|
||||||
|
import {
|
||||||
|
resolveElementColorsForRender,
|
||||||
|
resolveRenderableColor,
|
||||||
|
applyPrintColorModeToCanvas,
|
||||||
|
type RenderColorContext,
|
||||||
|
} from './colorUtils';
|
||||||
|
import type { PrintColorMode } from './types';
|
||||||
|
|
||||||
const GENERATOR_TRANSPARENT_BG = '#00000000';
|
const GENERATOR_TRANSPARENT_BG = '#00000000';
|
||||||
|
|
||||||
@@ -212,6 +220,58 @@ function layoutHorizontalTextLines(
|
|||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TEXT_ELLIPSIS = '…';
|
||||||
|
|
||||||
|
function truncateLineWithEllipsis(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
line: string,
|
||||||
|
maxWidth: number,
|
||||||
|
ellipsis = TEXT_ELLIPSIS
|
||||||
|
): string {
|
||||||
|
if (!line) return ellipsis;
|
||||||
|
if (ctx.measureText(line).width <= maxWidth) return line;
|
||||||
|
let truncated = line;
|
||||||
|
while (truncated.length > 0 && ctx.measureText(truncated + ellipsis).width > maxWidth) {
|
||||||
|
truncated = truncated.slice(0, -1);
|
||||||
|
}
|
||||||
|
return truncated.length > 0 ? truncated + ellipsis : ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyHorizontalLineLimit(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
lines: string[],
|
||||||
|
maxLines: number | undefined,
|
||||||
|
ellipsis: boolean,
|
||||||
|
writableW: number
|
||||||
|
): string[] {
|
||||||
|
if (!maxLines || maxLines <= 0 || lines.length <= maxLines) return lines;
|
||||||
|
const visible = lines.slice(0, maxLines);
|
||||||
|
if (ellipsis) {
|
||||||
|
const lastIdx = visible.length - 1;
|
||||||
|
visible[lastIdx] = truncateLineWithEllipsis(ctx, visible[lastIdx], writableW - 4);
|
||||||
|
}
|
||||||
|
return visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyVerticalColumnLimit(
|
||||||
|
columns: string[][],
|
||||||
|
maxLines: number | undefined,
|
||||||
|
ellipsis: boolean
|
||||||
|
): string[][] {
|
||||||
|
if (!maxLines || maxLines <= 0 || columns.length <= maxLines) return columns;
|
||||||
|
const visible = columns.slice(0, maxLines);
|
||||||
|
if (ellipsis && columns.length > maxLines) {
|
||||||
|
const lastCol = [...visible[visible.length - 1]];
|
||||||
|
if (lastCol.length > 0) {
|
||||||
|
lastCol[lastCol.length - 1] = TEXT_ELLIPSIS;
|
||||||
|
visible[visible.length - 1] = lastCol;
|
||||||
|
} else {
|
||||||
|
visible[visible.length - 1] = [TEXT_ELLIPSIS];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return visible;
|
||||||
|
}
|
||||||
|
|
||||||
/** 竖排:按列组织字符,列高受限时换列 */
|
/** 竖排:按列组织字符,列高受限时换列 */
|
||||||
function layoutVerticalTextColumns(
|
function layoutVerticalTextColumns(
|
||||||
value: string,
|
value: string,
|
||||||
@@ -313,7 +373,8 @@ function drawTextContentInBox(
|
|||||||
const vAlign = elem.verticalAlign ?? 'middle';
|
const vAlign = elem.verticalAlign ?? 'middle';
|
||||||
|
|
||||||
if (isVertical) {
|
if (isVertical) {
|
||||||
const columns = layoutVerticalTextColumns(String(value), writableH, lineHeight, wordWrap);
|
let columns = layoutVerticalTextColumns(String(value), writableH, lineHeight, wordWrap);
|
||||||
|
columns = applyVerticalColumnLimit(columns, elem.textMaxLines, elem.textEllipsis === true);
|
||||||
const totalColumnsWidth = columns.length * columnWidth;
|
const totalColumnsWidth = columns.length * columnWidth;
|
||||||
|
|
||||||
let startX: number;
|
let startX: number;
|
||||||
@@ -348,7 +409,14 @@ function drawTextContentInBox(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const lines = layoutHorizontalTextLines(ctx, String(value), writableW, wordWrap);
|
let lines = layoutHorizontalTextLines(ctx, String(value), writableW, wordWrap);
|
||||||
|
lines = applyHorizontalLineLimit(
|
||||||
|
ctx,
|
||||||
|
lines,
|
||||||
|
elem.textMaxLines,
|
||||||
|
elem.textEllipsis === true,
|
||||||
|
writableW
|
||||||
|
);
|
||||||
const totalTextHeight = lines.length * lineHeight;
|
const totalTextHeight = lines.length * lineHeight;
|
||||||
|
|
||||||
let textX = boxX + padPx.left;
|
let textX = boxX + padPx.left;
|
||||||
@@ -422,12 +490,17 @@ function drawTableGridLines(
|
|||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
|
|
||||||
const inset = borderW / 2;
|
const inset = borderW / 2;
|
||||||
ctx.rect(
|
const right = left + width;
|
||||||
alignStrokeCoord(left + inset),
|
const bottom = top + height;
|
||||||
alignStrokeCoord(top + inset),
|
// 外框:描边完全落在网格区域内,避免 clip / 画布边缘裁切右、下边
|
||||||
Math.max(0, width - borderW),
|
ctx.moveTo(alignStrokeCoord(left + inset), alignStrokeCoord(top + inset));
|
||||||
Math.max(0, height - borderW)
|
ctx.lineTo(alignStrokeCoord(right - inset), alignStrokeCoord(top + inset));
|
||||||
);
|
ctx.moveTo(alignStrokeCoord(right - inset), alignStrokeCoord(top + inset));
|
||||||
|
ctx.lineTo(alignStrokeCoord(right - inset), alignStrokeCoord(bottom - inset));
|
||||||
|
ctx.moveTo(alignStrokeCoord(right - inset), alignStrokeCoord(bottom - inset));
|
||||||
|
ctx.lineTo(alignStrokeCoord(left + inset), alignStrokeCoord(bottom - inset));
|
||||||
|
ctx.moveTo(alignStrokeCoord(left + inset), alignStrokeCoord(bottom - inset));
|
||||||
|
ctx.lineTo(alignStrokeCoord(left + inset), alignStrokeCoord(top + inset));
|
||||||
|
|
||||||
for (let r = 1; r < rows; r++) {
|
for (let r = 1; r < rows; r++) {
|
||||||
const y = alignStrokeCoord(rowBounds[r]);
|
const y = alignStrokeCoord(rowBounds[r]);
|
||||||
@@ -471,23 +544,27 @@ async function drawTableElement(
|
|||||||
rowData: RecordRow | null,
|
rowData: RecordRow | null,
|
||||||
rowIndex: number,
|
rowIndex: number,
|
||||||
variableDefaults: Record<string, string> | null | undefined,
|
variableDefaults: Record<string, string> | null | undefined,
|
||||||
mode: ContentResolveMode
|
mode: ContentResolveMode,
|
||||||
|
colorCtx: RenderColorContext
|
||||||
) {
|
) {
|
||||||
const radii = resolveCornerRadiiPx(elem, scale, ew, eh);
|
const renderElem = resolveElementColorsForRender(elem, colorCtx);
|
||||||
const sides = resolveBorderSides(elem);
|
const radii = resolveCornerRadiiPx(renderElem, scale, ew, eh);
|
||||||
|
const sides = resolveBorderSides(renderElem);
|
||||||
|
|
||||||
drawElementFill(ctx, ex, ey, ew, eh, elem, radii);
|
const drawCellContent = async (
|
||||||
drawElementBorders(ctx, ex, ey, ew, eh, radii, sides, elem, scale);
|
targetCtx: CanvasRenderingContext2D,
|
||||||
|
ox: number,
|
||||||
const { rowBounds, colBounds } = buildTableGridBoundariesPx(elem, scale, ex, ey);
|
oy: number
|
||||||
|
) => {
|
||||||
|
const { rowBounds, colBounds } = buildTableGridBoundariesPx(elem, scale, ox, oy);
|
||||||
const gridX = colBounds[0];
|
const gridX = colBounds[0];
|
||||||
const gridY = rowBounds[0];
|
const gridY = rowBounds[0];
|
||||||
const gridW = colBounds[colBounds.length - 1] - gridX;
|
const gridW = colBounds[colBounds.length - 1] - gridX;
|
||||||
const gridH = rowBounds[rowBounds.length - 1] - gridY;
|
const gridH = rowBounds[rowBounds.length - 1] - gridY;
|
||||||
ctx.save();
|
targetCtx.save();
|
||||||
ctx.beginPath();
|
targetCtx.beginPath();
|
||||||
ctx.rect(gridX, gridY, gridW, gridH);
|
targetCtx.rect(gridX, gridY, gridW, gridH);
|
||||||
ctx.clip();
|
targetCtx.clip();
|
||||||
|
|
||||||
const anchors = iterTableCells(elem);
|
const anchors = iterTableCells(elem);
|
||||||
for (const { row, col } of anchors) {
|
for (const { row, col } of anchors) {
|
||||||
@@ -496,17 +573,21 @@ async function drawTableElement(
|
|||||||
row,
|
row,
|
||||||
col,
|
col,
|
||||||
scale,
|
scale,
|
||||||
ex,
|
ox,
|
||||||
ey
|
oy
|
||||||
);
|
);
|
||||||
|
|
||||||
const cellData = getCellData(elem, row, col);
|
const cellData = getCellData(elem, row, col);
|
||||||
if (cellData?.backgroundColor && cellData.backgroundColor !== 'transparent') {
|
const cellBg = resolveRenderableColor(cellData?.backgroundColor, colorCtx);
|
||||||
ctx.fillStyle = cellData.backgroundColor;
|
if (cellBg && cellBg !== 'transparent') {
|
||||||
ctx.fillRect(cellX, cellY, cellW, cellH);
|
targetCtx.fillStyle = cellBg;
|
||||||
|
targetCtx.fillRect(cellX, cellY, cellW, cellH);
|
||||||
}
|
}
|
||||||
|
|
||||||
const textElem = getEffectiveCellStyle(elem, row, col);
|
const textElem = resolveElementColorsForRender(
|
||||||
|
getEffectiveCellStyle(elem, row, col),
|
||||||
|
colorCtx
|
||||||
|
);
|
||||||
if (!textElem.content.trim()) continue;
|
if (!textElem.content.trim()) continue;
|
||||||
|
|
||||||
const cellValue = resolveElementValue(textElem, rowData, rowIndex, variableDefaults, mode);
|
const cellValue = resolveElementValue(textElem, rowData, rowIndex, variableDefaults, mode);
|
||||||
@@ -514,7 +595,7 @@ async function drawTableElement(
|
|||||||
if (!displayText) continue;
|
if (!displayText) continue;
|
||||||
|
|
||||||
drawTextContentInBox(
|
drawTextContentInBox(
|
||||||
ctx,
|
targetCtx,
|
||||||
{
|
{
|
||||||
...textElem,
|
...textElem,
|
||||||
padding: 0,
|
padding: 0,
|
||||||
@@ -532,8 +613,16 @@ async function drawTableElement(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
drawTableGridLines(ctx, elem, ex, ey, scale);
|
targetCtx.restore();
|
||||||
ctx.restore();
|
};
|
||||||
|
|
||||||
|
await composeElementLayer(ctx, renderElem, ex, ey, ew, eh, scale, async (layerCtx, ox, oy, ow, oh) => {
|
||||||
|
drawElementFill(layerCtx, ox, oy, ow, oh, renderElem, radii);
|
||||||
|
await drawCellContent(layerCtx, ox, oy);
|
||||||
|
drawElementBorders(layerCtx, ox, oy, ow, oh, radii, sides, renderElem, scale);
|
||||||
|
});
|
||||||
|
|
||||||
|
drawTableGridLines(ctx, renderElem, ex, ey, scale);
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawImagePlaceholder(
|
function drawImagePlaceholder(
|
||||||
@@ -579,6 +668,8 @@ export interface RenderLabelOptions {
|
|||||||
jpegQuality?: number;
|
jpegQuality?: number;
|
||||||
/** 为 false 时不裁剪到画布边界(用于拖动浮层完整显示元素) */
|
/** 为 false 时不裁剪到画布边界(用于拖动浮层完整显示元素) */
|
||||||
clipToCanvas?: boolean;
|
clipToCanvas?: boolean;
|
||||||
|
/** 排版/印刷色彩模式 */
|
||||||
|
printColorMode?: PrintColorMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ReferenceBackgroundRenderOptions = Pick<
|
export type ReferenceBackgroundRenderOptions = Pick<
|
||||||
@@ -588,7 +679,10 @@ export type ReferenceBackgroundRenderOptions = Pick<
|
|||||||
|
|
||||||
type ReferenceBackgroundTemplate = Pick<
|
type ReferenceBackgroundTemplate = Pick<
|
||||||
LabelTemplate,
|
LabelTemplate,
|
||||||
'previewReferenceBackground' | 'previewReferenceBackgroundOpacity' | 'previewReferenceBackgroundFit'
|
| 'previewReferenceBackground'
|
||||||
|
| 'previewReferenceBackgroundOpacity'
|
||||||
|
| 'previewReferenceBackgroundFit'
|
||||||
|
| 'enablePreviewReferenceBackground'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
function buildReferenceBackgroundRenderOptions(
|
function buildReferenceBackgroundRenderOptions(
|
||||||
@@ -597,6 +691,9 @@ function buildReferenceBackgroundRenderOptions(
|
|||||||
if (!template.previewReferenceBackground) {
|
if (!template.previewReferenceBackground) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
if (template.enablePreviewReferenceBackground === false) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
referenceBackground: template.previewReferenceBackground,
|
referenceBackground: template.previewReferenceBackground,
|
||||||
referenceBackgroundOpacity: template.previewReferenceBackgroundOpacity ?? 100,
|
referenceBackgroundOpacity: template.previewReferenceBackgroundOpacity ?? 100,
|
||||||
@@ -604,7 +701,7 @@ function buildReferenceBackgroundRenderOptions(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 模板列表 / 设计预览等:有参考图即绘制 */
|
/** 模板列表 / 设计预览等:参考图且启用预览时绘制 */
|
||||||
export function resolveReferenceBackgroundForPreview(
|
export function resolveReferenceBackgroundForPreview(
|
||||||
template: ReferenceBackgroundTemplate
|
template: ReferenceBackgroundTemplate
|
||||||
): ReferenceBackgroundRenderOptions {
|
): ReferenceBackgroundRenderOptions {
|
||||||
@@ -619,7 +716,90 @@ export function resolveReferenceBackgroundForOutput(
|
|||||||
if (!template.includeReferenceBackgroundInOutput) {
|
if (!template.includeReferenceBackgroundInOutput) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
return buildReferenceBackgroundRenderOptions(template);
|
if (!template.previewReferenceBackground) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
referenceBackground: template.previewReferenceBackground,
|
||||||
|
referenceBackgroundOpacity: template.previewReferenceBackgroundOpacity ?? 100,
|
||||||
|
referenceBackgroundFit: template.previewReferenceBackgroundFit ?? 'cover',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
printColorMode: PrintColorMode = 'rgb'
|
||||||
|
): 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();
|
||||||
|
|
||||||
|
applyPrintColorModeToCanvas(canvas, printColorMode);
|
||||||
|
|
||||||
|
return imageFormat === 'jpeg'
|
||||||
|
? canvas.toDataURL('image/jpeg', 0.95)
|
||||||
|
: canvas.toDataURL('image/png');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 离屏渲染标签为 PNG data URL */
|
/** 离屏渲染标签为 PNG data URL */
|
||||||
@@ -641,8 +821,17 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
|
|||||||
imageFormat = 'png',
|
imageFormat = 'png',
|
||||||
jpegQuality = 0.92,
|
jpegQuality = 0.92,
|
||||||
clipToCanvas = true,
|
clipToCanvas = true,
|
||||||
|
printColorMode = 'rgb',
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
|
const colorCtx: RenderColorContext = {
|
||||||
|
printColorMode,
|
||||||
|
row: rowData,
|
||||||
|
variableDefaults,
|
||||||
|
mode,
|
||||||
|
rowIndex,
|
||||||
|
};
|
||||||
|
|
||||||
const scale = scaleOption;
|
const scale = scaleOption;
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
canvas.width = Math.max(1, Math.round(widthMm * scale));
|
canvas.width = Math.max(1, Math.round(widthMm * scale));
|
||||||
@@ -699,31 +888,34 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const renderElem = resolveElementColorsForRender(elem, colorCtx);
|
||||||
|
|
||||||
ctx.save();
|
ctx.save();
|
||||||
applyElementRotation(ctx, ex, ey, ew, eh, elem.rotation);
|
applyElementRotation(ctx, ex, ey, ew, eh, elem.rotation);
|
||||||
|
|
||||||
if (elem.type === 'text') {
|
if (elem.type === 'text') {
|
||||||
const radii = resolveCornerRadiiPx(elem, scale, ew, eh);
|
const radii = resolveCornerRadiiPx(renderElem, scale, ew, eh);
|
||||||
const sides = resolveBorderSides(elem);
|
const sides = resolveBorderSides(renderElem);
|
||||||
|
|
||||||
ctx.save();
|
await composeElementLayer(ctx, renderElem, ex, ey, ew, eh, scale, async (layerCtx, ox, oy, ow, oh) => {
|
||||||
traceRoundedRect(ctx, ex, ey, ew, eh, radii);
|
drawElementFill(layerCtx, ox, oy, ow, oh, renderElem, radii);
|
||||||
ctx.clip();
|
drawTextContentInBox(layerCtx, renderElem, ox, oy, ow, oh, String(value), scale);
|
||||||
|
drawElementBorders(layerCtx, ox, oy, ow, oh, radii, sides, renderElem, scale);
|
||||||
drawElementFill(ctx, ex, ey, ew, eh, elem, radii);
|
});
|
||||||
|
|
||||||
drawTextContentInBox(ctx, elem, ex, ey, ew, eh, String(value), scale);
|
|
||||||
|
|
||||||
ctx.restore();
|
|
||||||
drawElementBorders(ctx, ex, ey, ew, eh, radii, sides, elem, scale);
|
|
||||||
} else if (elem.type === 'barcode') {
|
} else if (elem.type === 'barcode') {
|
||||||
try {
|
try {
|
||||||
drawElementChrome(ctx, ex, ey, ew, eh, elem, scale);
|
const radii = resolveCornerRadiiPx(renderElem, scale, ew, eh);
|
||||||
const inner = getPaddedRectPx(ex, ey, ew, eh, elem, scale);
|
const sides = resolveBorderSides(renderElem);
|
||||||
const tempCanvas = document.createElement('canvas');
|
|
||||||
const barcodeValue = value ? String(value).trim() : mode === 'design' ? 'SAMPLE' : '';
|
const barcodeValue = value ? String(value).trim() : mode === 'design' ? 'SAMPLE' : '';
|
||||||
|
|
||||||
if (barcodeValue) {
|
await composeElementLayer(ctx, renderElem, ex, ey, ew, eh, scale, async (layerCtx, ox, oy, ow, oh) => {
|
||||||
|
drawElementFill(layerCtx, ox, oy, ow, oh, renderElem, radii);
|
||||||
|
if (!barcodeValue) {
|
||||||
|
drawElementBorders(layerCtx, ox, oy, ow, oh, radii, sides, renderElem, scale);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const inner = getPaddedRectPx(ox, oy, ow, oh, renderElem, scale);
|
||||||
|
const tempCanvas = document.createElement('canvas');
|
||||||
JsBarcode(tempCanvas, barcodeValue, {
|
JsBarcode(tempCanvas, barcodeValue, {
|
||||||
format: elem.barcodeFormat || 'CODE128',
|
format: elem.barcodeFormat || 'CODE128',
|
||||||
width: 2,
|
width: 2,
|
||||||
@@ -733,13 +925,13 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
|
|||||||
margin: 2,
|
margin: 2,
|
||||||
background: GENERATOR_TRANSPARENT_BG,
|
background: GENERATOR_TRANSPARENT_BG,
|
||||||
});
|
});
|
||||||
|
layerCtx.drawImage(tempCanvas, inner.x, inner.y, inner.w, inner.h);
|
||||||
ctx.drawImage(tempCanvas, inner.x, inner.y, inner.w, inner.h);
|
drawElementBorders(layerCtx, ox, oy, ow, oh, radii, sides, renderElem, scale);
|
||||||
}
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Barcode drawing failed', error);
|
console.warn('Barcode drawing failed', error);
|
||||||
if (mode === 'design') {
|
if (mode === 'design') {
|
||||||
const inner = getPaddedRectPx(ex, ey, ew, eh, elem, scale);
|
const inner = getPaddedRectPx(ex, ey, ew, eh, renderElem, scale);
|
||||||
ctx.fillStyle = '#f3f4f6';
|
ctx.fillStyle = '#f3f4f6';
|
||||||
ctx.fillRect(inner.x, inner.y, inner.w, inner.h);
|
ctx.fillRect(inner.x, inner.y, inner.w, inner.h);
|
||||||
ctx.strokeStyle = '#ef4444';
|
ctx.strokeStyle = '#ef4444';
|
||||||
@@ -753,11 +945,17 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
|
|||||||
}
|
}
|
||||||
} else if (elem.type === 'qrcode') {
|
} else if (elem.type === 'qrcode') {
|
||||||
try {
|
try {
|
||||||
drawElementChrome(ctx, ex, ey, ew, eh, elem, scale);
|
const radii = resolveCornerRadiiPx(renderElem, scale, ew, eh);
|
||||||
const inner = getPaddedRectPx(ex, ey, ew, eh, elem, scale);
|
const sides = resolveBorderSides(renderElem);
|
||||||
const qrValue = value ? String(value).trim() : mode === 'design' ? 'SAMPLE QR' : '';
|
const qrValue = value ? String(value).trim() : mode === 'design' ? 'SAMPLE QR' : '';
|
||||||
|
|
||||||
if (qrValue) {
|
await composeElementLayer(ctx, renderElem, ex, ey, ew, eh, scale, async (layerCtx, ox, oy, ow, oh) => {
|
||||||
|
drawElementFill(layerCtx, ox, oy, ow, oh, renderElem, radii);
|
||||||
|
if (!qrValue) {
|
||||||
|
drawElementBorders(layerCtx, ox, oy, ow, oh, radii, sides, renderElem, scale);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const inner = getPaddedRectPx(ox, oy, ow, oh, renderElem, scale);
|
||||||
const tempCanvas = document.createElement('canvas');
|
const tempCanvas = document.createElement('canvas');
|
||||||
await QRCode.toCanvas(tempCanvas, qrValue, {
|
await QRCode.toCanvas(tempCanvas, qrValue, {
|
||||||
width: Math.max(64, inner.w),
|
width: Math.max(64, inner.w),
|
||||||
@@ -767,13 +965,13 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
|
|||||||
light: GENERATOR_TRANSPARENT_BG,
|
light: GENERATOR_TRANSPARENT_BG,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
layerCtx.drawImage(tempCanvas, inner.x, inner.y, inner.w, inner.h);
|
||||||
ctx.drawImage(tempCanvas, inner.x, inner.y, inner.w, inner.h);
|
drawElementBorders(layerCtx, ox, oy, ow, oh, radii, sides, renderElem, scale);
|
||||||
}
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('QR code drawing failed', error);
|
console.warn('QR code drawing failed', error);
|
||||||
if (mode === 'design') {
|
if (mode === 'design') {
|
||||||
const inner = getPaddedRectPx(ex, ey, ew, eh, elem, scale);
|
const inner = getPaddedRectPx(ex, ey, ew, eh, renderElem, scale);
|
||||||
ctx.fillStyle = '#f3f4f6';
|
ctx.fillStyle = '#f3f4f6';
|
||||||
ctx.fillRect(inner.x, inner.y, inner.w, inner.h);
|
ctx.fillRect(inner.x, inner.y, inner.w, inner.h);
|
||||||
ctx.strokeStyle = '#ef4444';
|
ctx.strokeStyle = '#ef4444';
|
||||||
@@ -790,13 +988,38 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
drawElementChrome(ctx, ex, ey, ew, eh, elem, scale);
|
const radii = resolveCornerRadiiPx(renderElem, scale, ew, eh);
|
||||||
const inner = getPaddedRectPx(ex, ey, ew, eh, elem, scale);
|
const sides = resolveBorderSides(renderElem);
|
||||||
|
const hasMask = isElementMaskActive(elem.mask);
|
||||||
|
|
||||||
|
if (hasMask) {
|
||||||
|
await composeElementLayer(ctx, renderElem, ex, ey, ew, eh, scale, async (layerCtx, ox, oy, ow, oh) => {
|
||||||
|
drawElementFill(layerCtx, ox, oy, ow, oh, renderElem, radii);
|
||||||
|
const inner = getPaddedRectPx(ox, oy, ow, oh, renderElem, scale);
|
||||||
|
if (img) {
|
||||||
|
drawImageWithFit(
|
||||||
|
layerCtx,
|
||||||
|
img,
|
||||||
|
inner.x,
|
||||||
|
inner.y,
|
||||||
|
inner.w,
|
||||||
|
inner.h,
|
||||||
|
elem.imageFit ?? 'contain'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
drawImagePlaceholder(layerCtx, inner.x, inner.y, inner.w, inner.h, '图片');
|
||||||
|
}
|
||||||
|
drawElementBorders(layerCtx, ox, oy, ow, oh, radii, sides, renderElem, scale);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
drawElementChrome(ctx, ex, ey, ew, eh, renderElem, scale);
|
||||||
|
const inner = getPaddedRectPx(ex, ey, ew, eh, renderElem, scale);
|
||||||
if (img) {
|
if (img) {
|
||||||
drawImageWithFit(ctx, img, inner.x, inner.y, inner.w, inner.h, elem.imageFit ?? 'contain');
|
drawImageWithFit(ctx, img, inner.x, inner.y, inner.w, inner.h, elem.imageFit ?? 'contain');
|
||||||
} else {
|
} else {
|
||||||
drawImagePlaceholder(ctx, inner.x, inner.y, inner.w, inner.h, '图片');
|
drawImagePlaceholder(ctx, inner.x, inner.y, inner.w, inner.h, '图片');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else if (elem.type === 'table') {
|
} else if (elem.type === 'table') {
|
||||||
await drawTableElement(
|
await drawTableElement(
|
||||||
ctx,
|
ctx,
|
||||||
@@ -809,7 +1032,8 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
|
|||||||
rowData,
|
rowData,
|
||||||
rowIndex,
|
rowIndex,
|
||||||
variableDefaults,
|
variableDefaults,
|
||||||
mode
|
mode,
|
||||||
|
colorCtx
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -818,6 +1042,8 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
|
|||||||
|
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
|
|
||||||
|
applyPrintColorModeToCanvas(canvas, printColorMode);
|
||||||
|
|
||||||
return imageFormat === 'jpeg'
|
return imageFormat === 'jpeg'
|
||||||
? canvas.toDataURL('image/jpeg', jpegQuality)
|
? canvas.toDataURL('image/jpeg', jpegQuality)
|
||||||
: canvas.toDataURL('image/png');
|
: canvas.toDataURL('image/png');
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { jsPDF } from 'jspdf';
|
import { jsPDF } from 'jspdf';
|
||||||
import type { PaperConfig, LabelTemplate, CutLineConfig } from './types';
|
import type { PaperConfig, LabelTemplate, CutLineConfig } from './types';
|
||||||
import { collectGridCutLinePositions, getCutLineChannel, getCutLineDashSegmentMm, getCutLineStrokeWidthMm, type PrintableLabel } from './utils';
|
import { collectGridCutLinePositions, getCutLineChannel, getCutLineDashSegmentMm, getCutLineStrokeWidthMm, type PrintableLabel } from './utils';
|
||||||
|
import {
|
||||||
|
resolveLayoutPageBackgroundForOutput,
|
||||||
|
renderLayoutPageBackgroundDataUrl,
|
||||||
|
} from './labelRenderer';
|
||||||
|
|
||||||
export interface PdfExportProgress {
|
export interface PdfExportProgress {
|
||||||
done: number;
|
done: number;
|
||||||
@@ -26,6 +30,39 @@ const yieldToMain = () => new Promise<void>((resolve) => setTimeout(resolve, 0))
|
|||||||
|
|
||||||
const PAGE_RENDER_CONCURRENCY = 3;
|
const PAGE_RENDER_CONCURRENCY = 3;
|
||||||
|
|
||||||
|
type PageBackgroundCache = { dataUrl: string | null; key: string | null };
|
||||||
|
|
||||||
|
async function addPageBackgroundIfNeeded(
|
||||||
|
pdf: jsPDF,
|
||||||
|
template: LabelTemplate,
|
||||||
|
paper: PaperConfig,
|
||||||
|
paperW: number,
|
||||||
|
paperH: number,
|
||||||
|
imageFormat: 'PNG' | 'JPEG',
|
||||||
|
cache: PageBackgroundCache
|
||||||
|
) {
|
||||||
|
const options = resolveLayoutPageBackgroundForOutput(template);
|
||||||
|
if (!options) return;
|
||||||
|
|
||||||
|
const printColorMode = paper.printColorMode ?? 'cmyk';
|
||||||
|
const key = `${options.background}|${options.opacity}|${options.fit}|${paperW}|${paperH}|${imageFormat}|${printColorMode}`;
|
||||||
|
if (cache.key !== key) {
|
||||||
|
cache.key = key;
|
||||||
|
cache.dataUrl = await renderLayoutPageBackgroundDataUrl(
|
||||||
|
paperW,
|
||||||
|
paperH,
|
||||||
|
options,
|
||||||
|
4,
|
||||||
|
imageFormat === 'JPEG' ? 'jpeg' : 'png',
|
||||||
|
printColorMode
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cache.dataUrl) {
|
||||||
|
pdf.addImage(cache.dataUrl, imageFormat, 0, 0, paperW, paperH, undefined, 'FAST');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function parseColorToRgb(color: string): [number, number, number] {
|
function parseColorToRgb(color: string): [number, number, number] {
|
||||||
const c = color.trim();
|
const c = color.trim();
|
||||||
if (c.startsWith('#')) {
|
if (c.startsWith('#')) {
|
||||||
@@ -141,6 +178,7 @@ export async function exportLabelsPdfAsync(options: AsyncExportLabelsPdfOptions)
|
|||||||
|
|
||||||
let done = 0;
|
let done = 0;
|
||||||
let progressTick = 0;
|
let progressTick = 0;
|
||||||
|
const pageBgCache: PageBackgroundCache = { dataUrl: null, key: null };
|
||||||
|
|
||||||
const report = (page: number, phase: PdfExportProgress['phase']) => {
|
const report = (page: number, phase: PdfExportProgress['phase']) => {
|
||||||
onProgress?.({ done, total: totalLabels, page, totalPages, phase });
|
onProgress?.({ done, total: totalLabels, page, totalPages, phase });
|
||||||
@@ -151,6 +189,8 @@ export async function exportLabelsPdfAsync(options: AsyncExportLabelsPdfOptions)
|
|||||||
pdf.addPage([paperW, paperH], orientation);
|
pdf.addPage([paperW, paperH], orientation);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await addPageBackgroundIfNeeded(pdf, template, paper, paperW, paperH, imageFormat, pageBgCache);
|
||||||
|
|
||||||
const pageRows = printablePages[pIdx];
|
const pageRows = printablePages[pIdx];
|
||||||
const gridRows = Math.ceil(pageRows.length / gridCols);
|
const gridRows = Math.ceil(pageRows.length / gridCols);
|
||||||
const cutChannel = getCutLineChannel(paper.columnGap, paper.rowGap, cutLine);
|
const cutChannel = getCutLineChannel(paper.columnGap, paper.rowGap, cutLine);
|
||||||
@@ -242,6 +282,7 @@ export async function exportLabelsPdfBlobAsync(
|
|||||||
|
|
||||||
let done = 0;
|
let done = 0;
|
||||||
let progressTick = 0;
|
let progressTick = 0;
|
||||||
|
const pageBgCache: PageBackgroundCache = { dataUrl: null, key: null };
|
||||||
|
|
||||||
const report = (page: number, phase: PdfExportProgress['phase']) => {
|
const report = (page: number, phase: PdfExportProgress['phase']) => {
|
||||||
onProgress?.({ done, total: totalLabels, page, totalPages, phase });
|
onProgress?.({ done, total: totalLabels, page, totalPages, phase });
|
||||||
@@ -252,6 +293,8 @@ export async function exportLabelsPdfBlobAsync(
|
|||||||
pdf.addPage([paperW, paperH], orientation);
|
pdf.addPage([paperW, paperH], orientation);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await addPageBackgroundIfNeeded(pdf, template, paper, paperW, paperH, imageFormat, pageBgCache);
|
||||||
|
|
||||||
const pageRows = printablePages[pIdx];
|
const pageRows = printablePages[pIdx];
|
||||||
const gridRows = Math.ceil(pageRows.length / gridCols);
|
const gridRows = Math.ceil(pageRows.length / gridCols);
|
||||||
const cutChannel = getCutLineChannel(paper.columnGap, paper.rowGap, cutLine);
|
const cutChannel = getCutLineChannel(paper.columnGap, paper.rowGap, cutLine);
|
||||||
|
|||||||
@@ -56,6 +56,36 @@ export function getTableColWidths(elem: TemplateElement): number[] {
|
|||||||
return Array.from({ length: cols }, () => even);
|
return Array.from({ length: cols }, () => even);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 调整末项(必要时向前)使轨道总和精确等于 targetTotal */
|
||||||
|
function absorbTrackSumDiff(tracks: number[], targetTotal: number, min = 0.5): number[] {
|
||||||
|
const result = tracks.map((t) => round1(Math.max(min, t)));
|
||||||
|
let diff = round1(targetTotal - result.reduce((a, b) => a + b, 0));
|
||||||
|
for (let i = result.length - 1; i >= 0 && diff !== 0; i--) {
|
||||||
|
const adjusted = round1(Math.max(min, result[i] + diff));
|
||||||
|
const applied = round1(adjusted - result[i]);
|
||||||
|
result[i] = adjusted;
|
||||||
|
diff = round1(diff - applied);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按比例缩放轨道,严格使总和等于 targetTotal(不放大目标总量) */
|
||||||
|
function scaleTracksToExactTotal(tracks: number[], targetTotal: number, min = 0.5): number[] {
|
||||||
|
if (tracks.length === 0) return [];
|
||||||
|
const count = tracks.length;
|
||||||
|
const floor = count * min;
|
||||||
|
if (targetTotal <= floor + 0.01) {
|
||||||
|
return absorbTrackSumDiff(Array.from({ length: count }, () => min), targetTotal, min);
|
||||||
|
}
|
||||||
|
const current = tracks.reduce((a, b) => a + b, 0);
|
||||||
|
if (current <= 0) {
|
||||||
|
const even = round1(targetTotal / count);
|
||||||
|
return absorbTrackSumDiff(Array.from({ length: count }, () => even), targetTotal, min);
|
||||||
|
}
|
||||||
|
const scaled = tracks.map((t) => round1(Math.max(min, (t / current) * targetTotal)));
|
||||||
|
return absorbTrackSumDiff(scaled, targetTotal, min);
|
||||||
|
}
|
||||||
|
|
||||||
/** 将轨道尺寸总和调整为 targetTotal(保持相对比例,末项吸收取整误差) */
|
/** 将轨道尺寸总和调整为 targetTotal(保持相对比例,末项吸收取整误差) */
|
||||||
function scaleTracksToTotal(tracks: number[], targetTotal: number, min = 0.5): number[] {
|
function scaleTracksToTotal(tracks: number[], targetTotal: number, min = 0.5): number[] {
|
||||||
if (tracks.length === 0) return [];
|
if (tracks.length === 0) return [];
|
||||||
@@ -73,6 +103,48 @@ function scaleTracksToTotal(tracks: number[], targetTotal: number, min = 0.5): n
|
|||||||
return scaled;
|
return scaled;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 调整单条轨道尺寸,保持轨道总和不变;优先由 index 之后的轨道吸收差值 */
|
||||||
|
function resizeTrackKeepingTotal(
|
||||||
|
tracks: number[],
|
||||||
|
index: number,
|
||||||
|
sizeMm: number,
|
||||||
|
min = 0.5
|
||||||
|
): number[] {
|
||||||
|
if (index < 0 || index >= tracks.length) return tracks;
|
||||||
|
const total = round1(tracks.reduce((a, b) => a + b, 0));
|
||||||
|
if (tracks.length === 1) {
|
||||||
|
return [round1(Math.max(min, Math.min(total, sizeMm)))];
|
||||||
|
}
|
||||||
|
|
||||||
|
const beforeSum = round1(tracks.slice(0, index).reduce((a, b) => a + b, 0));
|
||||||
|
const afterCount = tracks.length - index - 1;
|
||||||
|
const beforeCount = index;
|
||||||
|
|
||||||
|
const maxForIndex =
|
||||||
|
afterCount > 0
|
||||||
|
? round1(total - beforeSum - afterCount * min)
|
||||||
|
: round1(total - beforeCount * min);
|
||||||
|
const newSize = round1(Math.max(min, Math.min(maxForIndex, sizeMm)));
|
||||||
|
const result = [...tracks];
|
||||||
|
result[index] = newSize;
|
||||||
|
|
||||||
|
if (afterCount > 0) {
|
||||||
|
const afterRemaining = round1(total - beforeSum - newSize);
|
||||||
|
const scaledAfter = scaleTracksToExactTotal(tracks.slice(index + 1), afterRemaining, min);
|
||||||
|
for (let i = 0; i < scaledAfter.length; i++) {
|
||||||
|
result[index + 1 + i] = scaledAfter[i];
|
||||||
|
}
|
||||||
|
} else if (beforeCount > 0) {
|
||||||
|
const beforeRemaining = round1(total - newSize);
|
||||||
|
const scaledBefore = scaleTracksToExactTotal(tracks.slice(0, index), beforeRemaining, min);
|
||||||
|
for (let i = 0; i < scaledBefore.length; i++) {
|
||||||
|
result[i] = scaledBefore[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return absorbTrackSumDiff(result, total, min);
|
||||||
|
}
|
||||||
|
|
||||||
/** 将 inner 区域均分给 count 条轨道(末项吸收取整误差) */
|
/** 将 inner 区域均分给 count 条轨道(末项吸收取整误差) */
|
||||||
function distributeTracks(total: number, count: number, min = 0.5): number[] {
|
function distributeTracks(total: number, count: number, min = 0.5): number[] {
|
||||||
if (count <= 0) return [];
|
if (count <= 0) return [];
|
||||||
@@ -132,8 +204,14 @@ export function scaleTableTracks(
|
|||||||
const scaleW = newInnerW / startInnerW;
|
const scaleW = newInnerW / startInnerW;
|
||||||
const scaleH = newInnerH / startInnerH;
|
const scaleH = newInnerH / startInnerH;
|
||||||
|
|
||||||
const tableRowHeights = startRowHeights.map((h) => round1(Math.max(0.5, h * scaleH)));
|
const tableRowHeights = scaleTracksToExactTotal(
|
||||||
const tableColWidths = startColWidths.map((w) => round1(Math.max(0.5, w * scaleW)));
|
startRowHeights.map((h) => h * scaleH),
|
||||||
|
newInnerH
|
||||||
|
);
|
||||||
|
const tableColWidths = scaleTracksToExactTotal(
|
||||||
|
startColWidths.map((w) => w * scaleW),
|
||||||
|
newInnerW
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...elem,
|
...elem,
|
||||||
@@ -456,14 +534,27 @@ export function updateTableRowHeight(
|
|||||||
rowIndex: number,
|
rowIndex: number,
|
||||||
heightMm: number
|
heightMm: number
|
||||||
): TemplateElement {
|
): TemplateElement {
|
||||||
const rowHeights = [...getTableRowHeights(elem)];
|
if (rowIndex < 0 || rowIndex >= getTableRows(elem)) return elem;
|
||||||
if (rowIndex < 0 || rowIndex >= rowHeights.length) return elem;
|
|
||||||
rowHeights[rowIndex] = Math.max(0.5, round1(heightMm));
|
|
||||||
const pad = resolvePaddingMm(elem);
|
const pad = resolvePaddingMm(elem);
|
||||||
|
const innerH = getTableInnerSizeMm(elem).height;
|
||||||
|
const rowHeights = scaleTracksToExactTotal(getTableRowHeights(elem), innerH);
|
||||||
|
const isLast = rowIndex === rowHeights.length - 1;
|
||||||
|
|
||||||
|
if (isLast) {
|
||||||
|
const nextHeights = [...rowHeights];
|
||||||
|
nextHeights[rowIndex] = round1(Math.max(0.5, heightMm));
|
||||||
|
const newInnerH = round1(nextHeights.reduce((a, b) => a + b, 0));
|
||||||
return {
|
return {
|
||||||
...elem,
|
...elem,
|
||||||
tableRowHeights: rowHeights,
|
tableRowHeights: nextHeights,
|
||||||
height: round1(rowHeights.reduce((a, b) => a + b, 0) + pad.top + pad.bottom),
|
height: round1(newInnerH + pad.top + pad.bottom),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...elem,
|
||||||
|
tableRowHeights: resizeTrackKeepingTotal(rowHeights, rowIndex, heightMm),
|
||||||
|
height: elem.height,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -472,14 +563,55 @@ export function updateTableColWidth(
|
|||||||
colIndex: number,
|
colIndex: number,
|
||||||
widthMm: number
|
widthMm: number
|
||||||
): TemplateElement {
|
): TemplateElement {
|
||||||
const colWidths = [...getTableColWidths(elem)];
|
if (colIndex < 0 || colIndex >= getTableCols(elem)) return elem;
|
||||||
if (colIndex < 0 || colIndex >= colWidths.length) return elem;
|
|
||||||
colWidths[colIndex] = Math.max(0.5, round1(widthMm));
|
|
||||||
const pad = resolvePaddingMm(elem);
|
const pad = resolvePaddingMm(elem);
|
||||||
|
const innerW = getTableInnerSizeMm(elem).width;
|
||||||
|
const colWidths = scaleTracksToExactTotal(getTableColWidths(elem), innerW);
|
||||||
|
const isLast = colIndex === colWidths.length - 1;
|
||||||
|
|
||||||
|
if (isLast) {
|
||||||
|
const nextWidths = [...colWidths];
|
||||||
|
nextWidths[colIndex] = round1(Math.max(0.5, widthMm));
|
||||||
|
const newInnerW = round1(nextWidths.reduce((a, b) => a + b, 0));
|
||||||
return {
|
return {
|
||||||
...elem,
|
...elem,
|
||||||
tableColWidths: colWidths,
|
tableColWidths: nextWidths,
|
||||||
width: round1(colWidths.reduce((a, b) => a + b, 0) + pad.left + pad.right),
|
width: round1(newInnerW + pad.left + pad.right),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...elem,
|
||||||
|
tableColWidths: resizeTrackKeepingTotal(colWidths, colIndex, widthMm),
|
||||||
|
width: elem.width,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 在现有元素高度内均分行高 */
|
||||||
|
export function equalizeTableRowHeights(elem: TemplateElement): TemplateElement {
|
||||||
|
const rows = getTableRows(elem);
|
||||||
|
const innerH = getTableInnerSizeMm(elem).height;
|
||||||
|
return {
|
||||||
|
...elem,
|
||||||
|
tableRowHeights: scaleTracksToExactTotal(
|
||||||
|
Array.from({ length: rows }, () => innerH / rows),
|
||||||
|
innerH
|
||||||
|
),
|
||||||
|
height: elem.height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 在现有元素宽度内均分列宽 */
|
||||||
|
export function equalizeTableColWidths(elem: TemplateElement): TemplateElement {
|
||||||
|
const cols = getTableCols(elem);
|
||||||
|
const innerW = getTableInnerSizeMm(elem).width;
|
||||||
|
return {
|
||||||
|
...elem,
|
||||||
|
tableColWidths: scaleTracksToExactTotal(
|
||||||
|
Array.from({ length: cols }, () => innerW / cols),
|
||||||
|
innerW
|
||||||
|
),
|
||||||
|
width: elem.width,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -531,6 +663,8 @@ export function getEffectiveCellStyle(
|
|||||||
textWritingMode: pick('textWritingMode', elem.textWritingMode ?? 'horizontal'),
|
textWritingMode: pick('textWritingMode', elem.textWritingMode ?? 'horizontal'),
|
||||||
textColor: pick('textColor', elem.textColor ?? '#111827'),
|
textColor: pick('textColor', elem.textColor ?? '#111827'),
|
||||||
wordWrap: cell?.wordWrap ?? elem.wordWrap !== false,
|
wordWrap: cell?.wordWrap ?? elem.wordWrap !== false,
|
||||||
|
textMaxLines: pick('textMaxLines', elem.textMaxLines),
|
||||||
|
textEllipsis: pick('textEllipsis', elem.textEllipsis ?? false),
|
||||||
fontFamily: pick('fontFamily', elem.fontFamily),
|
fontFamily: pick('fontFamily', elem.fontFamily),
|
||||||
textFormat: pick('textFormat', elem.textFormat ?? 'text'),
|
textFormat: pick('textFormat', elem.textFormat ?? 'text'),
|
||||||
decimalPlaces: pick('decimalPlaces', elem.decimalPlaces ?? 2),
|
decimalPlaces: pick('decimalPlaces', elem.decimalPlaces ?? 2),
|
||||||
@@ -539,6 +673,7 @@ export function getEffectiveCellStyle(
|
|||||||
textSplitDelimiter: cell?.textSplitDelimiter ?? elem.textSplitDelimiter,
|
textSplitDelimiter: cell?.textSplitDelimiter ?? elem.textSplitDelimiter,
|
||||||
textSplitIndex: cell?.textSplitIndex ?? elem.textSplitIndex,
|
textSplitIndex: cell?.textSplitIndex ?? elem.textSplitIndex,
|
||||||
textExtractLength: cell?.textExtractLength ?? elem.textExtractLength,
|
textExtractLength: cell?.textExtractLength ?? elem.textExtractLength,
|
||||||
|
textExtractInverse: cell?.textExtractInverse ?? elem.textExtractInverse,
|
||||||
textDisplayPrefix: cell?.textDisplayPrefix ?? elem.textDisplayPrefix,
|
textDisplayPrefix: cell?.textDisplayPrefix ?? elem.textDisplayPrefix,
|
||||||
textDisplaySuffix: cell?.textDisplaySuffix ?? elem.textDisplaySuffix,
|
textDisplaySuffix: cell?.textDisplaySuffix ?? elem.textDisplaySuffix,
|
||||||
backgroundColor: cell?.backgroundColor,
|
backgroundColor: cell?.backgroundColor,
|
||||||
@@ -561,6 +696,8 @@ const CELL_TEXT_PATCH_KEYS: (keyof TableCellData)[] = [
|
|||||||
'textColor',
|
'textColor',
|
||||||
'backgroundColor',
|
'backgroundColor',
|
||||||
'wordWrap',
|
'wordWrap',
|
||||||
|
'textMaxLines',
|
||||||
|
'textEllipsis',
|
||||||
'fontFamily',
|
'fontFamily',
|
||||||
'textFormat',
|
'textFormat',
|
||||||
'decimalPlaces',
|
'decimalPlaces',
|
||||||
@@ -569,6 +706,7 @@ const CELL_TEXT_PATCH_KEYS: (keyof TableCellData)[] = [
|
|||||||
'textSplitDelimiter',
|
'textSplitDelimiter',
|
||||||
'textSplitIndex',
|
'textSplitIndex',
|
||||||
'textExtractLength',
|
'textExtractLength',
|
||||||
|
'textExtractInverse',
|
||||||
'textDisplayPrefix',
|
'textDisplayPrefix',
|
||||||
'textDisplaySuffix',
|
'textDisplaySuffix',
|
||||||
];
|
];
|
||||||
|
|||||||
87
src/types.ts
87
src/types.ts
@@ -10,6 +10,19 @@ export const TEXT_EXTRACT_MODE_LABELS: Record<TextExtractMode, string> = {
|
|||||||
prefix: '前 N 位',
|
prefix: '前 N 位',
|
||||||
suffix: '后 N 位',
|
suffix: '后 N 位',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 变量/内容值提取配置(元素、表格单元格、显示条件等共用) */
|
||||||
|
export interface ValueExtractConfig {
|
||||||
|
textExtractMode?: TextExtractMode;
|
||||||
|
textSplitDelimiter?: string;
|
||||||
|
textSplitIndex?: number;
|
||||||
|
textExtractLength?: number;
|
||||||
|
/** 反选:从原值中去掉「显示前缀 + 提取段 + 显示后缀」 */
|
||||||
|
textExtractInverse?: boolean;
|
||||||
|
textDisplayPrefix?: string;
|
||||||
|
textDisplaySuffix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type ElementVisibilityMode = 'always' | 'conditional';
|
export type ElementVisibilityMode = 'always' | 'conditional';
|
||||||
export type VisibilityOperator =
|
export type VisibilityOperator =
|
||||||
| 'empty'
|
| 'empty'
|
||||||
@@ -23,12 +36,12 @@ export type VisibilityOperator =
|
|||||||
/** 显示条件判断对象:变量值 或 本元素解析后的内容值 */
|
/** 显示条件判断对象:变量值 或 本元素解析后的内容值 */
|
||||||
export type VisibilityRuleTarget = 'variable' | 'content';
|
export type VisibilityRuleTarget = 'variable' | 'content';
|
||||||
|
|
||||||
export interface VisibilityRule {
|
export interface VisibilityRule extends ValueExtractConfig {
|
||||||
/** 条件对象,默认 variable */
|
/** 条件对象,默认 variable */
|
||||||
target?: VisibilityRuleTarget;
|
target?: VisibilityRuleTarget;
|
||||||
variable: string;
|
variable: string;
|
||||||
operator: VisibilityOperator;
|
operator: VisibilityOperator;
|
||||||
/** gt / lt / eq / neq / contains / not_contains 时的比较目标值 */
|
/** gt / lt / eq / neq / contains / not_contains 时的比较目标值,支持 {变量名} */
|
||||||
value?: string;
|
value?: string;
|
||||||
/** 为 false 时跳过该条件;默认启用 */
|
/** 为 false 时跳过该条件;默认启用 */
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
@@ -75,6 +88,10 @@ export interface TableCellData {
|
|||||||
textColor?: string;
|
textColor?: string;
|
||||||
backgroundColor?: string;
|
backgroundColor?: string;
|
||||||
wordWrap?: boolean;
|
wordWrap?: boolean;
|
||||||
|
/** 最大显示行数(横排为行、竖排为列);未设置或 0 表示不限制 */
|
||||||
|
textMaxLines?: number;
|
||||||
|
/** 超出最大行数时末行/末列显示省略号 */
|
||||||
|
textEllipsis?: boolean;
|
||||||
fontFamily?: 'sans' | 'serif' | 'mono';
|
fontFamily?: 'sans' | 'serif' | 'mono';
|
||||||
textFormat?: TextFormat;
|
textFormat?: TextFormat;
|
||||||
decimalPlaces?: number;
|
decimalPlaces?: number;
|
||||||
@@ -83,6 +100,8 @@ export interface TableCellData {
|
|||||||
textSplitDelimiter?: string;
|
textSplitDelimiter?: string;
|
||||||
textSplitIndex?: number;
|
textSplitIndex?: number;
|
||||||
textExtractLength?: number;
|
textExtractLength?: number;
|
||||||
|
/** 反选:从原值中去掉「显示前缀 + 提取段 + 显示后缀」 */
|
||||||
|
textExtractInverse?: boolean;
|
||||||
textDisplayPrefix?: string;
|
textDisplayPrefix?: string;
|
||||||
textDisplaySuffix?: string;
|
textDisplaySuffix?: string;
|
||||||
}
|
}
|
||||||
@@ -91,6 +110,8 @@ export interface TemplateElement {
|
|||||||
id: string;
|
id: string;
|
||||||
type: 'text' | 'barcode' | 'qrcode' | 'image' | 'table';
|
type: 'text' | 'barcode' | 'qrcode' | 'image' | 'table';
|
||||||
name: string;
|
name: string;
|
||||||
|
/** 用户是否手动修改过图层名称;为 true 时不再根据内容自动同步名称 */
|
||||||
|
nameCustomized?: boolean;
|
||||||
x: number; // in mm
|
x: number; // in mm
|
||||||
y: number; // in mm
|
y: number; // in mm
|
||||||
width: number; // in mm
|
width: number; // in mm
|
||||||
@@ -114,6 +135,10 @@ export interface TemplateElement {
|
|||||||
verticalAlign?: 'top' | 'middle' | 'bottom';
|
verticalAlign?: 'top' | 'middle' | 'bottom';
|
||||||
/** 是否自动换行,仅 type=text,默认 true */
|
/** 是否自动换行,仅 type=text,默认 true */
|
||||||
wordWrap?: boolean;
|
wordWrap?: boolean;
|
||||||
|
/** 最大显示行数(横排为行、竖排为列);未设置或 0 表示不限制,仅 type=text */
|
||||||
|
textMaxLines?: number;
|
||||||
|
/** 超出最大行数时显示省略号,仅 type=text */
|
||||||
|
textEllipsis?: boolean;
|
||||||
/** 文本排列方向,仅 type=text,默认 horizontal */
|
/** 文本排列方向,仅 type=text,默认 horizontal */
|
||||||
textWritingMode?: 'horizontal' | 'vertical';
|
textWritingMode?: 'horizontal' | 'vertical';
|
||||||
fontWeight: 'normal' | 'bold';
|
fontWeight: 'normal' | 'bold';
|
||||||
@@ -143,6 +168,8 @@ export interface TemplateElement {
|
|||||||
paddingBottom?: number;
|
paddingBottom?: number;
|
||||||
paddingLeft?: number;
|
paddingLeft?: number;
|
||||||
locked?: boolean;
|
locked?: boolean;
|
||||||
|
/** 图层是否隐藏;隐藏后在预览、导出、打印及画布中均不显示 */
|
||||||
|
hidden?: boolean;
|
||||||
/** 图片缩放方式,仅 type=image 时有效 */
|
/** 图片缩放方式,仅 type=image 时有效 */
|
||||||
imageFit?: 'contain' | 'cover' | 'fill';
|
imageFit?: 'contain' | 'cover' | 'fill';
|
||||||
/** 默认图片 URL 或 data URI;主图加载失败时使用,仅 type=image 时有效 */
|
/** 默认图片 URL 或 data URI;主图加载失败时使用,仅 type=image 时有效 */
|
||||||
@@ -163,6 +190,8 @@ export interface TemplateElement {
|
|||||||
textSplitIndex?: number;
|
textSplitIndex?: number;
|
||||||
/** 前 N / 后 N 位提取时的位数 */
|
/** 前 N / 后 N 位提取时的位数 */
|
||||||
textExtractLength?: number;
|
textExtractLength?: number;
|
||||||
|
/** 反选:从原值中去掉「显示前缀 + 提取段 + 显示后缀」 */
|
||||||
|
textExtractInverse?: boolean;
|
||||||
/** 提取后在显示文本前追加的前缀字符,仅 type=text;不参与内容值条件 */
|
/** 提取后在显示文本前追加的前缀字符,仅 type=text;不参与内容值条件 */
|
||||||
textDisplayPrefix?: string;
|
textDisplayPrefix?: string;
|
||||||
/** 提取后在显示文本后追加的后缀字符,仅 type=text;不参与内容值条件 */
|
/** 提取后在显示文本后追加的后缀字符,仅 type=text;不参与内容值条件 */
|
||||||
@@ -202,6 +231,37 @@ export interface TemplateElement {
|
|||||||
tableBorderWidth?: number;
|
tableBorderWidth?: number;
|
||||||
/** 单元格网格线颜色,仅 type=table */
|
/** 单元格网格线颜色,仅 type=table */
|
||||||
tableBorderColor?: string;
|
tableBorderColor?: string;
|
||||||
|
/** 图层蒙版;在预览与导出时裁剪元素可见区域 */
|
||||||
|
mask?: ElementMaskConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ElementMaskShape = 'rect' | 'image';
|
||||||
|
|
||||||
|
export interface ElementMaskConfig {
|
||||||
|
/** 是否启用蒙版 */
|
||||||
|
enabled?: boolean;
|
||||||
|
/** 蒙版形状:矩形矢量区域或图片 alpha */
|
||||||
|
type?: ElementMaskShape;
|
||||||
|
/** 相对元素左上角的 X 偏移 (mm) */
|
||||||
|
x?: number;
|
||||||
|
/** 相对元素左上角的 Y 偏移 (mm) */
|
||||||
|
y?: number;
|
||||||
|
/** 蒙版区域宽度 (mm),默认与元素同宽 */
|
||||||
|
width?: number;
|
||||||
|
/** 蒙版区域高度 (mm),默认与元素同高 */
|
||||||
|
height?: number;
|
||||||
|
/** 圆角半径 (mm) */
|
||||||
|
borderRadius?: number;
|
||||||
|
borderRadiusTL?: number;
|
||||||
|
borderRadiusTR?: number;
|
||||||
|
borderRadiusBR?: number;
|
||||||
|
borderRadiusBL?: number;
|
||||||
|
/** 图片蒙版(data URI / URL),type=image 时使用 */
|
||||||
|
image?: string;
|
||||||
|
/** 图片蒙版在区域内的适应方式 */
|
||||||
|
imageFit?: 'contain' | 'cover' | 'fill';
|
||||||
|
/** include=保留蒙版区域内,exclude=保留区域外;默认 include */
|
||||||
|
mode?: 'include' | 'exclude';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DataSourceMeta {
|
export interface DataSourceMeta {
|
||||||
@@ -252,8 +312,20 @@ export interface LabelTemplate {
|
|||||||
previewReferenceBackgroundOpacity?: number;
|
previewReferenceBackgroundOpacity?: number;
|
||||||
/** 预览参考背景适应方式,默认 cover */
|
/** 预览参考背景适应方式,默认 cover */
|
||||||
previewReferenceBackgroundFit?: 'contain' | 'cover' | 'fill';
|
previewReferenceBackgroundFit?: 'contain' | 'cover' | 'fill';
|
||||||
|
/** 设计预览(画布、模板列表缩略图)是否显示参考背景,默认 true */
|
||||||
|
enablePreviewReferenceBackground?: boolean;
|
||||||
/** 生成 PDF / 打印 / 排版预览输出时是否绘制参考背景,默认 false */
|
/** 生成 PDF / 打印 / 排版预览输出时是否绘制参考背景,默认 false */
|
||||||
includeReferenceBackgroundInOutput?: boolean;
|
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}、{变量名};留空则按序号补零命名 */
|
/** 批量导出图片时的文件名模板,支持 {#SEQ}、{#INDEX}、{变量名};留空则按序号补零命名 */
|
||||||
exportImageFileNamePattern?: string;
|
exportImageFileNamePattern?: string;
|
||||||
/** 批量导出图片:all=全部导出,conditional=按条件过滤 */
|
/** 批量导出图片:all=全部导出,conditional=按条件过滤 */
|
||||||
@@ -264,6 +336,15 @@ export interface LabelTemplate {
|
|||||||
|
|
||||||
export type PaperType = 'A4' | 'A5' | 'A6' | 'custom';
|
export type PaperType = 'A4' | 'A5' | 'A6' | 'custom';
|
||||||
|
|
||||||
|
/** 排版/印刷输出色彩模式 */
|
||||||
|
export type PrintColorMode = 'rgb' | 'grayscale' | 'cmyk';
|
||||||
|
|
||||||
|
export const PRINT_COLOR_MODE_LABELS: Record<PrintColorMode, string> = {
|
||||||
|
rgb: '标准(轻度印刷补偿)',
|
||||||
|
grayscale: '黑白(灰度)',
|
||||||
|
cmyk: 'CMYK 印刷模拟(推荐)',
|
||||||
|
};
|
||||||
|
|
||||||
export interface PaperConfig {
|
export interface PaperConfig {
|
||||||
type: PaperType;
|
type: PaperType;
|
||||||
width: number; // in mm
|
width: number; // in mm
|
||||||
@@ -276,6 +357,8 @@ export interface PaperConfig {
|
|||||||
columnGap: number; // in mm (horizontal spacing between labels)
|
columnGap: number; // in mm (horizontal spacing between labels)
|
||||||
rowGap: number; // in mm (vertical spacing between labels)
|
rowGap: number; // in mm (vertical spacing between labels)
|
||||||
flow: 'top-bottom-left-right' | 'left-right-top-bottom'; // how cells flow
|
flow: 'top-bottom-left-right' | 'left-right-top-bottom'; // how cells flow
|
||||||
|
/** 排版预览与导出时的色彩模式,默认 rgb */
|
||||||
|
printColorMode?: PrintColorMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RecordRow {
|
export interface RecordRow {
|
||||||
|
|||||||
383
src/utils.ts
383
src/utils.ts
@@ -13,6 +13,7 @@ import {
|
|||||||
VisibilityRule,
|
VisibilityRule,
|
||||||
VisibilityOperator,
|
VisibilityOperator,
|
||||||
VISIBILITY_OPERATOR_LABELS,
|
VISIBILITY_OPERATOR_LABELS,
|
||||||
|
ValueExtractConfig,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
const IMPORT_DATA_STORAGE_PREFIX = 'label_import_data_v2_';
|
const IMPORT_DATA_STORAGE_PREFIX = 'label_import_data_v2_';
|
||||||
@@ -27,6 +28,105 @@ export const VARIABLE_MAPPING_USE_ROW_INDEX = '__use_row_index__';
|
|||||||
|
|
||||||
export const VARIABLE_MAPPING_ROW_INDEX_LABEL = '数据序号';
|
export const VARIABLE_MAPPING_ROW_INDEX_LABEL = '数据序号';
|
||||||
|
|
||||||
|
/** 粘贴副本时相对原位置的偏移 (mm) */
|
||||||
|
export const PASTE_ELEMENT_OFFSET_MM = 2;
|
||||||
|
|
||||||
|
/** 在标签画布上居中放置元素(mm,保留 1 位小数) */
|
||||||
|
export function centerElementOnLabel(
|
||||||
|
labelWidth: number,
|
||||||
|
labelHeight: number,
|
||||||
|
elementWidth: number,
|
||||||
|
elementHeight: number
|
||||||
|
): { x: number; y: number } {
|
||||||
|
const x = Math.round(((labelWidth - elementWidth) / 2) * 10) / 10;
|
||||||
|
const y = Math.round(((labelHeight - elementHeight) / 2) * 10) / 10;
|
||||||
|
return {
|
||||||
|
x: Math.max(0, x),
|
||||||
|
y: Math.max(0, y),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const AUTO_ELEMENT_NAME_RE = /^(文本|条形码|二维码|图片|表格) \d+$/;
|
||||||
|
const MAX_LAYER_NAME_FROM_CONTENT = 40;
|
||||||
|
|
||||||
|
/** 是否为新建时的默认图层名(含粘贴副本后缀) */
|
||||||
|
export function isAutoGeneratedElementName(name: string): boolean {
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!trimmed || trimmed === '未命名元素') return true;
|
||||||
|
if (AUTO_ELEMENT_NAME_RE.test(trimmed)) return true;
|
||||||
|
if (trimmed.endsWith(' 副本')) {
|
||||||
|
const base = trimmed.slice(0, -3).trim();
|
||||||
|
if (AUTO_ELEMENT_NAME_RE.test(base) || base === '未命名元素') return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否应根据内容自动同步图层名称 */
|
||||||
|
export function shouldSyncElementNameFromContent(elem: TemplateElement): boolean {
|
||||||
|
if (elem.nameCustomized) return false;
|
||||||
|
return isAutoGeneratedElementName(elem.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从元素内容推导图层显示名称 */
|
||||||
|
export function deriveElementNameFromContent(
|
||||||
|
content: string,
|
||||||
|
type: TemplateElement['type']
|
||||||
|
): string {
|
||||||
|
const trimmed = content.trim();
|
||||||
|
if (!trimmed) return '未命名元素';
|
||||||
|
|
||||||
|
if (type === 'image') {
|
||||||
|
if (trimmed.startsWith('data:')) return '图片';
|
||||||
|
const withoutVars = trimmed.replace(/\{[^}]+\}/g, '').trim();
|
||||||
|
if (withoutVars && /^https?:\/\//i.test(withoutVars)) {
|
||||||
|
try {
|
||||||
|
const path = withoutVars.split(/[?#]/)[0];
|
||||||
|
const segment = path.split('/').filter(Boolean).pop();
|
||||||
|
if (segment) {
|
||||||
|
return decodeURIComponent(segment).slice(0, MAX_LAYER_NAME_FROM_CONTENT);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const singleLine = trimmed.split(/\r?\n/)[0].trim();
|
||||||
|
if (!singleLine) return '未命名元素';
|
||||||
|
if (singleLine.startsWith('data:')) return type === 'image' ? '图片' : '未命名元素';
|
||||||
|
return singleLine.slice(0, MAX_LAYER_NAME_FROM_CONTENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新内容并在需要时同步图层名称 */
|
||||||
|
export function withContentAndSyncedName(
|
||||||
|
elem: TemplateElement,
|
||||||
|
content: string
|
||||||
|
): TemplateElement {
|
||||||
|
const updated = { ...elem, content };
|
||||||
|
if (!shouldSyncElementNameFromContent(elem)) return updated;
|
||||||
|
return {
|
||||||
|
...updated,
|
||||||
|
name: deriveElementNameFromContent(content, elem.type),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 深拷贝元素并生成新 id,用于复制/粘贴 */
|
||||||
|
export function cloneTemplateElementsForPaste(
|
||||||
|
elements: TemplateElement[],
|
||||||
|
pasteRepeat: number
|
||||||
|
): TemplateElement[] {
|
||||||
|
const offset = PASTE_ELEMENT_OFFSET_MM * Math.max(1, pasteRepeat);
|
||||||
|
const stamp = Date.now();
|
||||||
|
return elements.map((element, index) => {
|
||||||
|
const cloned = JSON.parse(JSON.stringify(element)) as TemplateElement;
|
||||||
|
cloned.id = `${element.type}_${stamp}_${index}`;
|
||||||
|
cloned.name = `${element.name} 副本`;
|
||||||
|
cloned.x = Math.round((element.x + offset) * 10) / 10;
|
||||||
|
cloned.y = Math.round((element.y + offset) * 10) / 10;
|
||||||
|
return cloned;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function toDataSourceMeta(data: ImportData): DataSourceMeta {
|
export function toDataSourceMeta(data: ImportData): DataSourceMeta {
|
||||||
return {
|
return {
|
||||||
fileName: data.fileName,
|
fileName: data.fileName,
|
||||||
@@ -216,8 +316,8 @@ export function getExportRangeIndices(
|
|||||||
export const DEFAULT_CUT_LINE_CONFIG: CutLineConfig = {
|
export const DEFAULT_CUT_LINE_CONFIG: CutLineConfig = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
style: 'dashed',
|
style: 'dashed',
|
||||||
width: 0.1,
|
width: 0.2,
|
||||||
color: '#cccccc',
|
color: '#333333',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 裁切线有效线宽 (mm),预览与 PDF 共用 */
|
/** 裁切线有效线宽 (mm),预览与 PDF 共用 */
|
||||||
@@ -434,11 +534,36 @@ export const DEFAULT_PAPER_CONFIG: PaperConfig = {
|
|||||||
marginBottom: AUTO_LAYOUT_MIN_PAGE_MARGIN_MM,
|
marginBottom: AUTO_LAYOUT_MIN_PAGE_MARGIN_MM,
|
||||||
marginLeft: AUTO_LAYOUT_MIN_PAGE_MARGIN_MM,
|
marginLeft: AUTO_LAYOUT_MIN_PAGE_MARGIN_MM,
|
||||||
marginRight: AUTO_LAYOUT_MIN_PAGE_MARGIN_MM,
|
marginRight: AUTO_LAYOUT_MIN_PAGE_MARGIN_MM,
|
||||||
columnGap: 2,
|
columnGap: 0,
|
||||||
rowGap: 2,
|
rowGap: 0,
|
||||||
flow: 'left-right-top-bottom',
|
flow: 'left-right-top-bottom',
|
||||||
|
printColorMode: 'cmyk',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 根据纸张排版参数反算单个标签尺寸 (mm) */
|
||||||
|
export function computeLabelSizeFromLayout(
|
||||||
|
paper: PaperConfig,
|
||||||
|
cols: number,
|
||||||
|
rows: number
|
||||||
|
): { width: number; height: number } | null {
|
||||||
|
if (cols < 1 || rows < 1) return null;
|
||||||
|
|
||||||
|
const paperW = paper.orientation === 'portrait' ? paper.width : paper.height;
|
||||||
|
const paperH = paper.orientation === 'portrait' ? paper.height : paper.width;
|
||||||
|
const usableW = paperW - paper.marginLeft - paper.marginRight;
|
||||||
|
const usableH = paperH - paper.marginTop - paper.marginBottom;
|
||||||
|
if (usableW <= 0 || usableH <= 0) return null;
|
||||||
|
|
||||||
|
const width = (usableW - (cols - 1) * paper.columnGap) / cols;
|
||||||
|
const height = (usableH - (rows - 1) * paper.rowGap) / rows;
|
||||||
|
if (width < 10 || height < 10) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: Math.round(width * 10) / 10,
|
||||||
|
height: Math.round(height * 10) / 10,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** 补全模板默认排版字段,剥离已废弃的数据源字段 */
|
/** 补全模板默认排版字段,剥离已废弃的数据源字段 */
|
||||||
export function normalizeTemplate(tmpl: LabelTemplate): LabelTemplate {
|
export function normalizeTemplate(tmpl: LabelTemplate): LabelTemplate {
|
||||||
const { ...rest } = tmpl;
|
const { ...rest } = tmpl;
|
||||||
@@ -536,6 +661,24 @@ export function resolveElementBackgroundFill(
|
|||||||
return color;
|
return color;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 从显示/过滤条件中收集引用的变量名 */
|
||||||
|
function collectVariablesFromVisibilityRule(rule: VisibilityRule, vars: Set<string>) {
|
||||||
|
const name = (rule.variable ?? '').trim();
|
||||||
|
if (name) vars.add(name);
|
||||||
|
if (rule.value) {
|
||||||
|
for (const v of extractVariablesFromContent(rule.value)) {
|
||||||
|
vars.add(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectVariablesFromVisibilityRules(rules: VisibilityRule[] | undefined, vars: Set<string>) {
|
||||||
|
if (!rules) return;
|
||||||
|
for (const rule of rules) {
|
||||||
|
collectVariablesFromVisibilityRule(rule, vars);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 从元素内容(含表格单元格)提取变量名 */
|
/** 从元素内容(含表格单元格)提取变量名 */
|
||||||
export function extractVariablesFromElement(elem: TemplateElement): string[] {
|
export function extractVariablesFromElement(elem: TemplateElement): string[] {
|
||||||
const vars = new Set<string>();
|
const vars = new Set<string>();
|
||||||
@@ -558,32 +701,36 @@ export function extractVariablesFromElement(elem: TemplateElement): string[] {
|
|||||||
export function extractTemplateVariables(template: {
|
export function extractTemplateVariables(template: {
|
||||||
variableList?: string[];
|
variableList?: string[];
|
||||||
elements: TemplateElement[];
|
elements: TemplateElement[];
|
||||||
|
dataFilterRules?: VisibilityRule[];
|
||||||
|
exportImageFilterRules?: VisibilityRule[];
|
||||||
}): string[] {
|
}): string[] {
|
||||||
const vars = new Set<string>(template.variableList ?? []);
|
const vars = new Set<string>(template.variableList ?? []);
|
||||||
for (const elem of template.elements) {
|
for (const elem of template.elements) {
|
||||||
for (const v of extractVariablesFromElement(elem)) {
|
for (const v of extractVariablesFromElement(elem)) {
|
||||||
vars.add(v);
|
vars.add(v);
|
||||||
}
|
}
|
||||||
for (const rule of resolveVisibilityRules(elem)) {
|
collectVariablesFromVisibilityRules(resolveVisibilityRules(elem), vars);
|
||||||
const name = (rule.variable ?? '').trim();
|
|
||||||
if (name) vars.add(name);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
collectVariablesFromVisibilityRules(template.dataFilterRules, vars);
|
||||||
|
collectVariablesFromVisibilityRules(template.exportImageFilterRules, vars);
|
||||||
return Array.from(vars).sort((a, b) => a.localeCompare(b, 'zh-CN'));
|
return Array.from(vars).sort((a, b) => a.localeCompare(b, 'zh-CN'));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 被元素内容或显示条件引用的变量 */
|
/** 被元素内容或显示条件引用的变量 */
|
||||||
export function getUsedTemplateVariables(template: { elements: TemplateElement[] }): string[] {
|
export function getUsedTemplateVariables(template: {
|
||||||
|
elements: TemplateElement[];
|
||||||
|
dataFilterRules?: VisibilityRule[];
|
||||||
|
exportImageFilterRules?: VisibilityRule[];
|
||||||
|
}): string[] {
|
||||||
const vars = new Set<string>();
|
const vars = new Set<string>();
|
||||||
for (const elem of template.elements) {
|
for (const elem of template.elements) {
|
||||||
for (const v of extractVariablesFromElement(elem)) {
|
for (const v of extractVariablesFromElement(elem)) {
|
||||||
vars.add(v);
|
vars.add(v);
|
||||||
}
|
}
|
||||||
for (const rule of resolveVisibilityRules(elem)) {
|
collectVariablesFromVisibilityRules(resolveVisibilityRules(elem), vars);
|
||||||
const name = (rule.variable ?? '').trim();
|
|
||||||
if (name) vars.add(name);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
collectVariablesFromVisibilityRules(template.dataFilterRules, vars);
|
||||||
|
collectVariablesFromVisibilityRules(template.exportImageFilterRules, vars);
|
||||||
return Array.from(vars);
|
return Array.from(vars);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -643,27 +790,29 @@ export function toggleVisibilityRuleEnabled(rule: VisibilityRule): VisibilityRul
|
|||||||
|
|
||||||
/** 将显示条件格式化为可读说明 */
|
/** 将显示条件格式化为可读说明 */
|
||||||
export function formatVisibilityRuleDescription(rule: VisibilityRule): string {
|
export function formatVisibilityRuleDescription(rule: VisibilityRule): string {
|
||||||
|
const extractNote = formatValueExtractSummary(rule);
|
||||||
const subjectLabel =
|
const subjectLabel =
|
||||||
rule.target === 'content' ? '内容值' : `{${rule.variable}}`;
|
rule.target === 'content' ? '内容值' : `{${rule.variable}}`;
|
||||||
|
const subject = extractNote ? `${subjectLabel}(${extractNote})` : subjectLabel;
|
||||||
switch (rule.operator) {
|
switch (rule.operator) {
|
||||||
case 'empty':
|
case 'empty':
|
||||||
return `${subjectLabel} 为空`;
|
return `${subject} 为空`;
|
||||||
case 'not_empty':
|
case 'not_empty':
|
||||||
return `${subjectLabel} 不为空`;
|
return `${subject} 不为空`;
|
||||||
case 'gt':
|
case 'gt':
|
||||||
return `${subjectLabel} 大于 ${rule.value ?? '—'}`;
|
return `${subject} 大于 ${rule.value ?? '—'}`;
|
||||||
case 'lt':
|
case 'lt':
|
||||||
return `${subjectLabel} 小于 ${rule.value ?? '—'}`;
|
return `${subject} 小于 ${rule.value ?? '—'}`;
|
||||||
case 'eq':
|
case 'eq':
|
||||||
return `${subjectLabel} 等于 ${rule.value ?? '—'}`;
|
return `${subject} 等于 ${rule.value ?? '—'}`;
|
||||||
case 'neq':
|
case 'neq':
|
||||||
return `${subjectLabel} 不等于 ${rule.value ?? '—'}`;
|
return `${subject} 不等于 ${rule.value ?? '—'}`;
|
||||||
case 'contains':
|
case 'contains':
|
||||||
return `${subjectLabel} 包含 ${rule.value ?? '—'}`;
|
return `${subject} 包含 ${rule.value ?? '—'}`;
|
||||||
case 'not_contains':
|
case 'not_contains':
|
||||||
return `${subjectLabel} 不包含 ${rule.value ?? '—'}`;
|
return `${subject} 不包含 ${rule.value ?? '—'}`;
|
||||||
default:
|
default:
|
||||||
return `${subjectLabel} ${VISIBILITY_OPERATOR_LABELS[rule.operator]}`;
|
return `${subject} ${VISIBILITY_OPERATOR_LABELS[rule.operator]}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -681,6 +830,13 @@ export function resolveVisibilityRules(elem: TemplateElement): VisibilityRule[]
|
|||||||
operator: r.operator,
|
operator: r.operator,
|
||||||
value: r.value,
|
value: r.value,
|
||||||
enabled: r.enabled,
|
enabled: r.enabled,
|
||||||
|
textExtractMode: r.textExtractMode,
|
||||||
|
textSplitDelimiter: r.textSplitDelimiter,
|
||||||
|
textSplitIndex: r.textSplitIndex,
|
||||||
|
textExtractLength: r.textExtractLength,
|
||||||
|
textExtractInverse: r.textExtractInverse,
|
||||||
|
textDisplayPrefix: r.textDisplayPrefix,
|
||||||
|
textDisplaySuffix: r.textDisplaySuffix,
|
||||||
}))
|
}))
|
||||||
.filter((r) => r.target === 'content' || r.variable);
|
.filter((r) => r.target === 'content' || r.variable);
|
||||||
}
|
}
|
||||||
@@ -726,7 +882,7 @@ function compareVariableToTarget(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 读取显示条件所判断的原始值 */
|
/** 读取显示条件所判断的原始值(含条件专用值提取) */
|
||||||
function getVisibilityRuleRawValue(
|
function getVisibilityRuleRawValue(
|
||||||
rule: VisibilityRule,
|
rule: VisibilityRule,
|
||||||
elem: TemplateElement | undefined,
|
elem: TemplateElement | undefined,
|
||||||
@@ -735,15 +891,43 @@ function getVisibilityRuleRawValue(
|
|||||||
defaults?: Record<string, string> | null,
|
defaults?: Record<string, string> | null,
|
||||||
mode: ContentResolveMode = 'output'
|
mode: ContentResolveMode = 'output'
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
|
let resolved: string | undefined;
|
||||||
|
|
||||||
if (isVisibilityContentTarget(rule)) {
|
if (isVisibilityContentTarget(rule)) {
|
||||||
if (!elem) return undefined;
|
if (!elem) return undefined;
|
||||||
const effectiveRow =
|
const effectiveRow =
|
||||||
row ?? (mode === 'design' && defaults ? (defaults as RecordRow) : null);
|
row ?? (mode === 'design' && defaults ? (defaults as RecordRow) : null);
|
||||||
const resolved = resolveElementCoreValue(elem, effectiveRow, rowIndex, defaults, 'output');
|
const formatSubst = textElementNeedsNumberFormatSubst(elem)
|
||||||
const trimmed = resolved.trim();
|
? (raw: string) => formatTextNumberSubstValue(elem, raw)
|
||||||
|
: undefined;
|
||||||
|
const trimmed = resolveContent(elem.content, effectiveRow, rowIndex, defaults, {
|
||||||
|
mode: 'output',
|
||||||
|
formatSubst,
|
||||||
|
}).trim();
|
||||||
|
resolved = trimmed || undefined;
|
||||||
|
} else {
|
||||||
|
resolved = getVariableRawValue(rule.variable, row, defaults, mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolved === undefined) return undefined;
|
||||||
|
if (!valueExtractConfigHasExtract(rule)) return resolved;
|
||||||
|
const extracted = applyValueExtractConfig(resolved, rule);
|
||||||
|
const trimmed = extracted.trim();
|
||||||
return trimmed || undefined;
|
return trimmed || undefined;
|
||||||
}
|
}
|
||||||
return getVariableRawValue(rule.variable, row, defaults, mode);
|
|
||||||
|
/** 解析条件目标值(支持 {变量名}) */
|
||||||
|
export function resolveVisibilityRuleCompareTarget(
|
||||||
|
targetTemplate: string | undefined,
|
||||||
|
row: RecordRow | null,
|
||||||
|
defaults: Record<string, string> | null | undefined,
|
||||||
|
mode: ContentResolveMode,
|
||||||
|
rowIndex: number = 0
|
||||||
|
): string {
|
||||||
|
const trimmed = (targetTemplate ?? '').trim();
|
||||||
|
if (!trimmed) return '';
|
||||||
|
if (!trimmed.includes('{')) return trimmed;
|
||||||
|
return resolveContent(trimmed, row, rowIndex, defaults, { mode }).trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 单条显示条件是否满足 */
|
/** 单条显示条件是否满足 */
|
||||||
@@ -757,6 +941,13 @@ export function evaluateVisibilityRule(
|
|||||||
): boolean {
|
): boolean {
|
||||||
const raw = getVisibilityRuleRawValue(rule, elem, row, rowIndex, defaults, mode);
|
const raw = getVisibilityRuleRawValue(rule, elem, row, rowIndex, defaults, mode);
|
||||||
const isEmpty = raw === undefined;
|
const isEmpty = raw === undefined;
|
||||||
|
const compareTarget = resolveVisibilityRuleCompareTarget(
|
||||||
|
rule.value,
|
||||||
|
row,
|
||||||
|
defaults,
|
||||||
|
mode,
|
||||||
|
rowIndex
|
||||||
|
);
|
||||||
|
|
||||||
switch (rule.operator) {
|
switch (rule.operator) {
|
||||||
case 'empty':
|
case 'empty':
|
||||||
@@ -767,23 +958,20 @@ export function evaluateVisibilityRule(
|
|||||||
case 'lt':
|
case 'lt':
|
||||||
case 'eq': {
|
case 'eq': {
|
||||||
if (isEmpty) return false;
|
if (isEmpty) return false;
|
||||||
return compareVariableToTarget(raw!, (rule.value ?? '').trim(), rule.operator);
|
return compareVariableToTarget(raw!, compareTarget, rule.operator);
|
||||||
}
|
}
|
||||||
case 'neq': {
|
case 'neq': {
|
||||||
const target = (rule.value ?? '').trim();
|
if (isEmpty) return compareTarget !== '';
|
||||||
if (isEmpty) return target !== '';
|
return compareVariableToTarget(raw!, compareTarget, 'neq');
|
||||||
return compareVariableToTarget(raw!, target, 'neq');
|
|
||||||
}
|
}
|
||||||
case 'contains': {
|
case 'contains': {
|
||||||
const target = (rule.value ?? '').trim();
|
if (!compareTarget || isEmpty) return false;
|
||||||
if (!target || isEmpty) return false;
|
return raw!.includes(compareTarget);
|
||||||
return raw!.includes(target);
|
|
||||||
}
|
}
|
||||||
case 'not_contains': {
|
case 'not_contains': {
|
||||||
const target = (rule.value ?? '').trim();
|
if (!compareTarget) return true;
|
||||||
if (!target) return true;
|
|
||||||
if (isEmpty) return true;
|
if (isEmpty) return true;
|
||||||
return !raw!.includes(target);
|
return !raw!.includes(compareTarget);
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return true;
|
return true;
|
||||||
@@ -1044,41 +1232,65 @@ export function applyTextSplitSegment(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 解析元素有效的文本提取方式(兼容旧版仅配置分隔符) */
|
/** 解析元素有效的文本提取方式(兼容旧版仅配置分隔符) */
|
||||||
export function getTextExtractMode(elem: Pick<
|
export function getTextExtractMode(
|
||||||
TemplateElement,
|
config: Pick<ValueExtractConfig, 'textExtractMode' | 'textSplitDelimiter' | 'textSplitIndex'>
|
||||||
'textExtractMode' | 'textSplitDelimiter' | 'textSplitIndex'
|
): TextExtractMode {
|
||||||
>): TextExtractMode {
|
if (config.textExtractMode !== undefined) return config.textExtractMode;
|
||||||
if (elem.textExtractMode !== undefined) return elem.textExtractMode;
|
const delimiter = config.textSplitDelimiter?.trim();
|
||||||
const delimiter = elem.textSplitDelimiter?.trim();
|
const splitIndex = config.textSplitIndex ?? 0;
|
||||||
const splitIndex = elem.textSplitIndex ?? 0;
|
|
||||||
if (delimiter && splitIndex >= 1) return 'split';
|
if (delimiter && splitIndex >= 1) return 'split';
|
||||||
return 'none';
|
return 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 值提取配置是否生效 */
|
||||||
|
export function valueExtractConfigHasExtract(config: ValueExtractConfig): boolean {
|
||||||
|
const mode = getTextExtractMode(config);
|
||||||
|
if (mode === 'split') {
|
||||||
|
const delimiter = config.textSplitDelimiter?.trim();
|
||||||
|
const index = config.textSplitIndex ?? 0;
|
||||||
|
return !!(delimiter && index >= 1);
|
||||||
|
}
|
||||||
|
if (mode === 'prefix' || mode === 'suffix') {
|
||||||
|
return (config.textExtractLength ?? 0) >= 1;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 值提取配置的可读摘要 */
|
||||||
|
export function formatValueExtractSummary(config: ValueExtractConfig): string | null {
|
||||||
|
if (!valueExtractConfigHasExtract(config)) return null;
|
||||||
|
const mode = getTextExtractMode(config);
|
||||||
|
let base: string;
|
||||||
|
if (mode === 'split') {
|
||||||
|
const delimiter = config.textSplitDelimiter?.trim() ?? '';
|
||||||
|
const index = config.textSplitIndex ?? 1;
|
||||||
|
base = `按「${delimiter}」第 ${index} 段`;
|
||||||
|
} else if (mode === 'prefix') {
|
||||||
|
base = `前 ${config.textExtractLength ?? 1} 位`;
|
||||||
|
} else {
|
||||||
|
base = `后 ${config.textExtractLength ?? 1} 位`;
|
||||||
|
}
|
||||||
|
return config.textExtractInverse ? `反选 ${base}` : `提取 ${base}`;
|
||||||
|
}
|
||||||
|
|
||||||
/** 按配置提取变量值片段 */
|
/** 按配置提取变量值片段 */
|
||||||
export function applyTextExtract(
|
export function applyTextExtract(value: string, config: ValueExtractConfig): string {
|
||||||
value: string,
|
|
||||||
elem: Pick<
|
|
||||||
TemplateElement,
|
|
||||||
'textExtractMode' | 'textSplitDelimiter' | 'textSplitIndex' | 'textExtractLength'
|
|
||||||
>
|
|
||||||
): string {
|
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
const mode = getTextExtractMode(elem);
|
const mode = getTextExtractMode(config);
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
case 'split': {
|
case 'split': {
|
||||||
const delimiter = elem.textSplitDelimiter?.trim();
|
const delimiter = config.textSplitDelimiter?.trim();
|
||||||
const index = elem.textSplitIndex ?? 0;
|
const index = config.textSplitIndex ?? 0;
|
||||||
if (!delimiter || index < 1) return trimmed;
|
if (!delimiter || index < 1) return trimmed;
|
||||||
return applyTextSplitSegment(trimmed, delimiter, index);
|
return applyTextSplitSegment(trimmed, delimiter, index);
|
||||||
}
|
}
|
||||||
case 'prefix': {
|
case 'prefix': {
|
||||||
const n = elem.textExtractLength ?? 0;
|
const n = config.textExtractLength ?? 0;
|
||||||
if (n < 1) return trimmed;
|
if (n < 1) return trimmed;
|
||||||
return trimmed.slice(0, n);
|
return trimmed.slice(0, n);
|
||||||
}
|
}
|
||||||
case 'suffix': {
|
case 'suffix': {
|
||||||
const n = elem.textExtractLength ?? 0;
|
const n = config.textExtractLength ?? 0;
|
||||||
if (n < 1) return trimmed;
|
if (n < 1) return trimmed;
|
||||||
return trimmed.slice(-n);
|
return trimmed.slice(-n);
|
||||||
}
|
}
|
||||||
@@ -1087,6 +1299,44 @@ export function applyTextExtract(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 按配置应用值提取(含反选) */
|
||||||
|
export function applyValueExtractConfig(value: string, config: ValueExtractConfig): string {
|
||||||
|
if (!valueExtractConfigHasExtract(config)) return value;
|
||||||
|
if (config.textExtractInverse) {
|
||||||
|
return applyTextExtractInverse(value, { type: 'text', ...config });
|
||||||
|
}
|
||||||
|
return applyTextExtract(value, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFirstOccurrence(value: string, needle: string): string {
|
||||||
|
if (!needle) return value;
|
||||||
|
const index = value.indexOf(needle);
|
||||||
|
if (index === -1) return value;
|
||||||
|
return value.slice(0, index) + value.slice(index + needle.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 反选:从原值中去掉「显示前缀 + 提取段 + 显示后缀」 */
|
||||||
|
export function applyTextExtractInverse(
|
||||||
|
value: string,
|
||||||
|
elem: Pick<
|
||||||
|
ValueExtractConfig,
|
||||||
|
| 'textExtractMode'
|
||||||
|
| 'textSplitDelimiter'
|
||||||
|
| 'textSplitIndex'
|
||||||
|
| 'textExtractLength'
|
||||||
|
| 'textDisplayPrefix'
|
||||||
|
| 'textDisplaySuffix'
|
||||||
|
> & { type?: TemplateElement['type'] }
|
||||||
|
): string {
|
||||||
|
const full = value.trim();
|
||||||
|
const extracted = applyTextExtract(full, elem);
|
||||||
|
const needle =
|
||||||
|
elem.type === 'text' || elem.textDisplayPrefix || elem.textDisplaySuffix
|
||||||
|
? applyTextDisplayAffix(extracted, elem)
|
||||||
|
: extracted;
|
||||||
|
return removeFirstOccurrence(full, needle);
|
||||||
|
}
|
||||||
|
|
||||||
/** 元素是否启用了变量值提取(文本/条码/二维码/图片内容均有效) */
|
/** 元素是否启用了变量值提取(文本/条码/二维码/图片内容均有效) */
|
||||||
export function elementHasValueExtract(
|
export function elementHasValueExtract(
|
||||||
elem: Pick<
|
elem: Pick<
|
||||||
@@ -1094,16 +1344,7 @@ export function elementHasValueExtract(
|
|||||||
'textExtractMode' | 'textSplitDelimiter' | 'textSplitIndex' | 'textExtractLength'
|
'textExtractMode' | 'textSplitDelimiter' | 'textSplitIndex' | 'textExtractLength'
|
||||||
>
|
>
|
||||||
): boolean {
|
): boolean {
|
||||||
const mode = getTextExtractMode(elem);
|
return valueExtractConfigHasExtract(elem);
|
||||||
if (mode === 'split') {
|
|
||||||
const delimiter = elem.textSplitDelimiter?.trim();
|
|
||||||
const index = elem.textSplitIndex ?? 0;
|
|
||||||
return !!(delimiter && index >= 1);
|
|
||||||
}
|
|
||||||
if (mode === 'prefix' || mode === 'suffix') {
|
|
||||||
return (elem.textExtractLength ?? 0) >= 1;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @deprecated 使用 elementHasValueExtract */
|
/** @deprecated 使用 elementHasValueExtract */
|
||||||
@@ -1170,6 +1411,11 @@ export function getVariableRawValue(
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 图层是否在画布与输出中显示 */
|
||||||
|
export function isElementLayerVisible(elem: TemplateElement): boolean {
|
||||||
|
return elem.hidden !== true;
|
||||||
|
}
|
||||||
|
|
||||||
/** 是否应渲染元素(显示控制为 conditional 时按关联变量判断;设计模式使用变量默认值) */
|
/** 是否应渲染元素(显示控制为 conditional 时按关联变量判断;设计模式使用变量默认值) */
|
||||||
export function shouldRenderElement(
|
export function shouldRenderElement(
|
||||||
elem: TemplateElement,
|
elem: TemplateElement,
|
||||||
@@ -1178,6 +1424,8 @@ export function shouldRenderElement(
|
|||||||
defaults?: Record<string, string> | null,
|
defaults?: Record<string, string> | null,
|
||||||
mode: ContentResolveMode = 'output'
|
mode: ContentResolveMode = 'output'
|
||||||
): boolean {
|
): boolean {
|
||||||
|
if (!isElementLayerVisible(elem)) return false;
|
||||||
|
|
||||||
if (!isConditionalVisibility(elem)) return true;
|
if (!isConditionalVisibility(elem)) return true;
|
||||||
|
|
||||||
const rules = resolveVisibilityRules(elem);
|
const rules = resolveVisibilityRules(elem);
|
||||||
@@ -1212,7 +1460,9 @@ export function resolveElementCoreValue(
|
|||||||
let resolved = resolveContent(elem.content, row, rowIndex, defaults, { mode, formatSubst });
|
let resolved = resolveContent(elem.content, row, rowIndex, defaults, { mode, formatSubst });
|
||||||
|
|
||||||
if (elementHasValueExtract(elem)) {
|
if (elementHasValueExtract(elem)) {
|
||||||
resolved = applyTextExtract(resolved, elem);
|
resolved = elem.textExtractInverse
|
||||||
|
? applyTextExtractInverse(resolved, elem)
|
||||||
|
: applyTextExtract(resolved, elem);
|
||||||
}
|
}
|
||||||
|
|
||||||
return resolved;
|
return resolved;
|
||||||
@@ -1228,6 +1478,7 @@ export function resolveElementValue(
|
|||||||
): string {
|
): string {
|
||||||
const core = resolveElementCoreValue(elem, row, rowIndex, defaults, mode);
|
const core = resolveElementCoreValue(elem, row, rowIndex, defaults, mode);
|
||||||
if (elem.type !== 'text') return core;
|
if (elem.type !== 'text') return core;
|
||||||
|
if (elem.textExtractInverse && elementHasValueExtract(elem)) return core;
|
||||||
return applyTextDisplayAffix(core, elem);
|
return applyTextDisplayAffix(core, elem);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user