优化预览与导出排版
This commit is contained in:
67
src/components/CommitInput.tsx
Normal file
67
src/components/CommitInput.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
export interface CommitInputProps
|
||||||
|
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'value' | 'type'> {
|
||||||
|
value: string | number;
|
||||||
|
onCommit: (val: string) => void;
|
||||||
|
/** number 类型在编辑时用文本框,提交时由外部校验 min/max */
|
||||||
|
type?: 'text' | 'number';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 失焦/回车提交型输入框:编辑过程中不应用 HTML min/max 限制,便于清空后重输。
|
||||||
|
*/
|
||||||
|
export const CommitInput: React.FC<CommitInputProps> = ({
|
||||||
|
value,
|
||||||
|
onCommit,
|
||||||
|
type = 'text',
|
||||||
|
min: _min,
|
||||||
|
max: _max,
|
||||||
|
step: _step,
|
||||||
|
onFocus,
|
||||||
|
onBlur,
|
||||||
|
onKeyDown,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const [localVal, setLocalVal] = useState<string>(String(value));
|
||||||
|
const focusedRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!focusedRef.current) {
|
||||||
|
setLocalVal(String(value));
|
||||||
|
}
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
|
||||||
|
focusedRef.current = false;
|
||||||
|
onBlur?.(e);
|
||||||
|
if (localVal !== String(value)) {
|
||||||
|
onCommit(localVal);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
(e.target as HTMLInputElement).blur();
|
||||||
|
}
|
||||||
|
onKeyDown?.(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputType = type === 'number' ? 'text' : type;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
{...props}
|
||||||
|
type={inputType}
|
||||||
|
inputMode={type === 'number' ? 'decimal' : props.inputMode}
|
||||||
|
value={localVal}
|
||||||
|
onChange={(e) => setLocalVal(e.target.value)}
|
||||||
|
onFocus={(e) => {
|
||||||
|
focusedRef.current = true;
|
||||||
|
onFocus?.(e);
|
||||||
|
}}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -3,7 +3,7 @@ import { Database, Layout, Settings } from 'lucide-react';
|
|||||||
import { ImportData, PaperConfig, LabelTemplate, RecordRow, ExportRangeConfig, CutLineConfig } from '../types';
|
import { ImportData, PaperConfig, LabelTemplate, RecordRow, ExportRangeConfig, CutLineConfig } from '../types';
|
||||||
import { DataImporter } from './DataImporter';
|
import { DataImporter } from './DataImporter';
|
||||||
import { VariableMappingPanel } from './VariableMappingPanel';
|
import { VariableMappingPanel } from './VariableMappingPanel';
|
||||||
import { PaperSettingsPanel } from './PreviewGrid';
|
import { PaperSettingsPanel, PageInput } from './PreviewGrid';
|
||||||
import { CollapsiblePanelSection } from './CollapsiblePanelSection';
|
import { CollapsiblePanelSection } from './CollapsiblePanelSection';
|
||||||
import { DataRangeFields } from './DataRangeFields';
|
import { DataRangeFields } from './DataRangeFields';
|
||||||
|
|
||||||
@@ -236,19 +236,19 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="ps-field-label">线粗细 (mm)</label>
|
<label className="ps-field-label">线粗细 (mm)</label>
|
||||||
<input
|
<PageInput
|
||||||
type="number"
|
type="number"
|
||||||
min="0.02"
|
min="0.02"
|
||||||
max="2"
|
max="2"
|
||||||
step="0.02"
|
step="0.02"
|
||||||
value={cutLine.width}
|
value={cutLine.width}
|
||||||
onChange={(e) =>
|
onCommit={(val) =>
|
||||||
onChangeCutLine({
|
onChangeCutLine({
|
||||||
...cutLine,
|
...cutLine,
|
||||||
width: Math.max(0.02, Math.min(2, Number(e.target.value) || 0.1)),
|
width: Math.max(0.02, Math.min(2, Number(val) || 0.1)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="ps-field font-mono text-[11px]"
|
inputClassName="ps-field font-mono text-[11px]"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className="mt-2 h-8 rounded border border-[#3a3a3a] bg-[#1e1e1e] flex items-center justify-center"
|
className="mt-2 h-8 rounded border border-[#3a3a3a] bg-[#1e1e1e] flex items-center justify-center"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
PaperConfig,
|
PaperConfig,
|
||||||
RecordRow,
|
RecordRow,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { PaperSettingsPanel } from './PreviewGrid';
|
import { PaperSettingsPanel, PageInput } from './PreviewGrid';
|
||||||
|
|
||||||
interface MobileExportPropsPanelProps {
|
interface MobileExportPropsPanelProps {
|
||||||
template: LabelTemplate;
|
template: LabelTemplate;
|
||||||
@@ -115,19 +115,19 @@ export const MobileExportPropsPanel: React.FC<MobileExportPropsPanelProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="mobile-field-label">线粗细 (mm)</label>
|
<label className="mobile-field-label">线粗细 (mm)</label>
|
||||||
<input
|
<PageInput
|
||||||
type="number"
|
type="number"
|
||||||
min="0.02"
|
min="0.02"
|
||||||
max="2"
|
max="2"
|
||||||
step="0.02"
|
step="0.02"
|
||||||
value={cutLine.width}
|
value={cutLine.width}
|
||||||
onChange={(e) =>
|
onCommit={(val) =>
|
||||||
onChangeCutLine({
|
onChangeCutLine({
|
||||||
...cutLine,
|
...cutLine,
|
||||||
width: Math.max(0.02, Math.min(2, Number(e.target.value) || 0.1)),
|
width: Math.max(0.02, Math.min(2, Number(val) || 0.1)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="mobile-field font-mono"
|
inputClassName="mobile-field font-mono"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ import {
|
|||||||
DEFAULT_EXPORT_RANGE,
|
DEFAULT_EXPORT_RANGE,
|
||||||
DEFAULT_CUT_LINE_CONFIG,
|
DEFAULT_CUT_LINE_CONFIG,
|
||||||
buildPrintablePages,
|
buildPrintablePages,
|
||||||
|
collectGridCutLinePositions,
|
||||||
|
getCutLineChannel,
|
||||||
type PrintableLabel,
|
type PrintableLabel,
|
||||||
} from '../utils';
|
} from '../utils';
|
||||||
import { CanvasLabelImage } from './CanvasLabelImage';
|
import { CanvasLabelImage } from './CanvasLabelImage';
|
||||||
|
import { CommitInput } from './CommitInput';
|
||||||
import { FileDown, RefreshCcw } from 'lucide-react';
|
import { FileDown, RefreshCcw } from 'lucide-react';
|
||||||
|
|
||||||
interface PageInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> {
|
interface PageInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> {
|
||||||
@@ -15,36 +18,14 @@ interface PageInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement
|
|||||||
inputClassName?: string;
|
inputClassName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PageInput: React.FC<PageInputProps> = ({ value, onCommit, inputClassName = '', ...props }) => {
|
export const PageInput: React.FC<PageInputProps> = ({
|
||||||
const [localVal, setLocalVal] = useState<string>(String(value));
|
value,
|
||||||
|
onCommit,
|
||||||
useEffect(() => {
|
inputClassName = '',
|
||||||
setLocalVal(String(value));
|
...props
|
||||||
}, [value]);
|
}) => (
|
||||||
|
<CommitInput value={value} onCommit={onCommit} className={inputClassName} {...props} />
|
||||||
const handleBlur = () => {
|
);
|
||||||
if (localVal !== String(value)) {
|
|
||||||
onCommit(localVal);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
(e.target as HTMLInputElement).blur();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
{...props}
|
|
||||||
value={localVal}
|
|
||||||
onChange={(e) => setLocalVal(e.target.value)}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
className={inputClassName}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface LayoutStats {
|
interface LayoutStats {
|
||||||
gridCols: number;
|
gridCols: number;
|
||||||
@@ -176,7 +157,7 @@ export const PaperSettingsPanel: React.FC<PaperSettingsPanelProps> = ({
|
|||||||
|
|
||||||
const applyPaper = (next: PaperConfig) => {
|
const applyPaper = (next: PaperConfig) => {
|
||||||
setDraftPaper(next);
|
setDraftPaper(next);
|
||||||
if (isMobile) onChangePaper(next);
|
onChangePaper(next);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePaperTypeChange = (type: PaperType) => {
|
const handlePaperTypeChange = (type: PaperType) => {
|
||||||
@@ -213,7 +194,7 @@ export const PaperSettingsPanel: React.FC<PaperSettingsPanelProps> = ({
|
|||||||
)}
|
)}
|
||||||
{isPs && (
|
{isPs && (
|
||||||
<p className="text-[10px] text-[#666] leading-relaxed">
|
<p className="text-[10px] text-[#666] leading-relaxed">
|
||||||
修改后点击面板外区域或切换焦点,预览将自动更新。
|
排版参数修改后将自动更新预览。
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -465,6 +446,31 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
|
|||||||
const currentPaperH = paper.orientation === 'portrait' ? paper.height : paper.width;
|
const currentPaperH = paper.orientation === 'portrait' ? paper.height : paper.width;
|
||||||
const colGapPx = Math.max(0, paper.columnGap * previewScale);
|
const colGapPx = Math.max(0, paper.columnGap * previewScale);
|
||||||
const rowGapPx = Math.max(0, paper.rowGap * previewScale);
|
const rowGapPx = Math.max(0, paper.rowGap * previewScale);
|
||||||
|
const labelWPx = template.width * previewScale;
|
||||||
|
const labelHPx = template.height * previewScale;
|
||||||
|
const cutStrokeWidth = Math.max(0.5, cutLine.width * previewScale);
|
||||||
|
const cutHalfStroke = cutStrokeWidth / 2;
|
||||||
|
const cutChannel = getCutLineChannel(paper.columnGap, paper.rowGap, cutLine);
|
||||||
|
const cutChannelXPx = cutChannel.x * previewScale;
|
||||||
|
const cutChannelYPx = cutChannel.y * previewScale;
|
||||||
|
const cutDash =
|
||||||
|
cutLine.style === 'dashed'
|
||||||
|
? `${Math.max(2, cutStrokeWidth * 4)} ${Math.max(2, cutStrokeWidth * 4)}`
|
||||||
|
: undefined;
|
||||||
|
const pageCutLines = useMemo(
|
||||||
|
() =>
|
||||||
|
collectGridCutLinePositions(
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
labelWPx,
|
||||||
|
labelHPx,
|
||||||
|
gridInfo.cols,
|
||||||
|
gridInfo.rows,
|
||||||
|
colGapPx,
|
||||||
|
rowGapPx
|
||||||
|
),
|
||||||
|
[labelWPx, labelHPx, gridInfo.cols, gridInfo.rows, colGapPx, rowGapPx]
|
||||||
|
);
|
||||||
const visiblePages = showAllPages ? printablePages : printablePages.slice(0, MAX_PREVIEW_PAGES);
|
const visiblePages = showAllPages ? printablePages : printablePages.slice(0, MAX_PREVIEW_PAGES);
|
||||||
const hasMorePages = printablePages.length > MAX_PREVIEW_PAGES;
|
const hasMorePages = printablePages.length > MAX_PREVIEW_PAGES;
|
||||||
|
|
||||||
@@ -587,15 +593,58 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="grid bg-white"
|
className="grid bg-white relative"
|
||||||
style={{
|
style={{
|
||||||
gridTemplateColumns: `repeat(${gridInfo.cols}, ${template.width * previewScale}px)`,
|
gridTemplateColumns: `repeat(${gridInfo.cols}, ${labelWPx}px)`,
|
||||||
gridAutoRows: `${template.height * previewScale}px`,
|
gridAutoRows: `${labelHPx}px`,
|
||||||
columnGap: `${colGapPx}px`,
|
columnGap: `${colGapPx}px`,
|
||||||
rowGap: `${rowGapPx}px`,
|
rowGap: `${rowGapPx}px`,
|
||||||
width: 'max-content',
|
width: 'max-content',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{cutLine.enabled && (
|
||||||
|
<svg
|
||||||
|
className="absolute pointer-events-none z-20"
|
||||||
|
style={{
|
||||||
|
left: -cutHalfStroke,
|
||||||
|
top: -cutHalfStroke,
|
||||||
|
width: pageCutLines.gridW + cutStrokeWidth,
|
||||||
|
height: pageCutLines.gridH + cutStrokeWidth,
|
||||||
|
overflow: 'visible',
|
||||||
|
}}
|
||||||
|
viewBox={`${-cutHalfStroke} ${-cutHalfStroke} ${pageCutLines.gridW + cutStrokeWidth} ${pageCutLines.gridH + cutStrokeWidth}`}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
{pageCutLines.ys.map((y) => (
|
||||||
|
<line
|
||||||
|
key={`h-${y}`}
|
||||||
|
x1={0}
|
||||||
|
y1={y}
|
||||||
|
x2={pageCutLines.gridW}
|
||||||
|
y2={y}
|
||||||
|
stroke={cutLine.color}
|
||||||
|
strokeWidth={cutStrokeWidth}
|
||||||
|
strokeDasharray={cutDash}
|
||||||
|
strokeLinecap="butt"
|
||||||
|
shapeRendering="crispEdges"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{pageCutLines.xs.map((x) => (
|
||||||
|
<line
|
||||||
|
key={`v-${x}`}
|
||||||
|
x1={x}
|
||||||
|
y1={0}
|
||||||
|
x2={x}
|
||||||
|
y2={pageCutLines.gridH}
|
||||||
|
stroke={cutLine.color}
|
||||||
|
strokeWidth={cutStrokeWidth}
|
||||||
|
strokeDasharray={cutDash}
|
||||||
|
strokeLinecap="butt"
|
||||||
|
shapeRendering="crispEdges"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
{pageRows.map((item, cellIdx) => {
|
{pageRows.map((item, cellIdx) => {
|
||||||
if (!item) {
|
if (!item) {
|
||||||
return (
|
return (
|
||||||
@@ -616,30 +665,26 @@ 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 overflow-hidden 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 ${isActive ? 'ring-2 ring-inset ring-[#31a8ff] z-10' : ''
|
||||||
}`}
|
}`}
|
||||||
style={{
|
style={{
|
||||||
width: `${template.width * previewScale}px`,
|
width: `${labelWPx}px`,
|
||||||
height: `${template.height * previewScale}px`,
|
height: `${labelHPx}px`,
|
||||||
|
padding: `${cutChannelYPx}px ${cutChannelXPx}px`,
|
||||||
|
boxSizing: 'border-box',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CanvasLabelImage
|
<div className="w-full h-full overflow-hidden relative">
|
||||||
widthMm={template.width}
|
<CanvasLabelImage
|
||||||
heightMm={template.height}
|
widthMm={template.width}
|
||||||
elements={template.elements}
|
heightMm={template.height}
|
||||||
rowData={item.row}
|
elements={template.elements}
|
||||||
rowIndex={item.sourceIndex}
|
rowData={item.row}
|
||||||
showBorder={false}
|
rowIndex={item.sourceIndex}
|
||||||
mode="output"
|
showBorder={false}
|
||||||
/>
|
mode="output"
|
||||||
{cutLine.enabled && (
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 pointer-events-none box-border"
|
|
||||||
style={{
|
|
||||||
border: `${Math.max(1, cutLine.width * previewScale)}px ${cutLine.style} ${cutLine.color}`,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
} from '../elementAlign';
|
} from '../elementAlign';
|
||||||
import type { ElementSide } from '../types';
|
import type { ElementSide } from '../types';
|
||||||
import { CollapsiblePanelSection } from './CollapsiblePanelSection';
|
import { CollapsiblePanelSection } from './CollapsiblePanelSection';
|
||||||
|
import { CommitInput } from './CommitInput';
|
||||||
import {
|
import {
|
||||||
Sliders,
|
Sliders,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
@@ -90,40 +91,7 @@ const AlignToolButton: React.FC<AlignToolButtonProps> = ({ icon, label, title, o
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
||||||
interface PropInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> {
|
const PropInput = CommitInput;
|
||||||
value: string | number;
|
|
||||||
onCommit: (val: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PropInput: React.FC<PropInputProps> = ({ value, onCommit, ...props }) => {
|
|
||||||
const [localVal, setLocalVal] = useState<string>(String(value));
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setLocalVal(String(value));
|
|
||||||
}, [value]);
|
|
||||||
|
|
||||||
const handleBlur = () => {
|
|
||||||
if (localVal !== String(value)) {
|
|
||||||
onCommit(localVal);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
(e.target as HTMLInputElement).blur();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
{...props}
|
|
||||||
value={localVal}
|
|
||||||
onChange={(e) => setLocalVal(e.target.value)}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface PropTextareaProps extends Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'onChange'> {
|
interface PropTextareaProps extends Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'onChange'> {
|
||||||
value: string;
|
value: string;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { jsPDF } from 'jspdf';
|
import { jsPDF } from 'jspdf';
|
||||||
import type { PaperConfig, LabelTemplate, CutLineConfig } from './types';
|
import type { PaperConfig, LabelTemplate, CutLineConfig } from './types';
|
||||||
import type { PrintableLabel } from './utils';
|
import { collectGridCutLinePositions, getCutLineChannel, type PrintableLabel } from './utils';
|
||||||
|
|
||||||
export interface PdfExportProgress {
|
export interface PdfExportProgress {
|
||||||
done: number;
|
done: number;
|
||||||
@@ -48,24 +48,61 @@ function parseColorToRgb(color: string): [number, number, number] {
|
|||||||
return [204, 204, 204];
|
return [204, 204, 204];
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawCutLineRect(
|
function setupCutLinePen(pdf: jsPDF, cutLine: CutLineConfig) {
|
||||||
pdf: jsPDF,
|
|
||||||
x: number,
|
|
||||||
y: number,
|
|
||||||
w: number,
|
|
||||||
h: number,
|
|
||||||
cutLine: CutLineConfig
|
|
||||||
) {
|
|
||||||
const [r, g, b] = parseColorToRgb(cutLine.color);
|
const [r, g, b] = parseColorToRgb(cutLine.color);
|
||||||
pdf.setDrawColor(r, g, b);
|
pdf.setDrawColor(r, g, b);
|
||||||
pdf.setLineWidth(Math.max(0.02, cutLine.width));
|
pdf.setLineWidth(cutLine.width);
|
||||||
if (cutLine.style === 'dashed') {
|
if (cutLine.style === 'dashed') {
|
||||||
const dash = Math.max(0.5, cutLine.width * 4);
|
const dash = Math.max(0.5, cutLine.width * 4);
|
||||||
pdf.setLineDashPattern([dash, dash], 0);
|
pdf.setLineDashPattern([dash, dash], 0);
|
||||||
} else {
|
} else {
|
||||||
pdf.setLineDashPattern([], 0);
|
pdf.setLineDashPattern([], 0);
|
||||||
}
|
}
|
||||||
pdf.rect(x, y, w, h, 'S');
|
}
|
||||||
|
|
||||||
|
function drawPageGridCutLines(
|
||||||
|
pdf: jsPDF,
|
||||||
|
paper: PaperConfig,
|
||||||
|
labelW: number,
|
||||||
|
labelH: number,
|
||||||
|
gridCols: number,
|
||||||
|
gridRows: number,
|
||||||
|
cutLine: CutLineConfig
|
||||||
|
) {
|
||||||
|
const originX = paper.marginLeft;
|
||||||
|
const originY = paper.marginTop;
|
||||||
|
const lineW = Math.max(0.02, cutLine.width);
|
||||||
|
const { xs, ys, gridW, gridH } = collectGridCutLinePositions(
|
||||||
|
originX,
|
||||||
|
originY,
|
||||||
|
labelW,
|
||||||
|
labelH,
|
||||||
|
gridCols,
|
||||||
|
gridRows,
|
||||||
|
paper.columnGap,
|
||||||
|
paper.rowGap
|
||||||
|
);
|
||||||
|
|
||||||
|
const [r, g, b] = parseColorToRgb(cutLine.color);
|
||||||
|
pdf.setFillColor(r, g, b);
|
||||||
|
|
||||||
|
if (cutLine.style === 'solid') {
|
||||||
|
for (const y of ys) {
|
||||||
|
pdf.rect(originX, y - lineW / 2, gridW, lineW, 'F');
|
||||||
|
}
|
||||||
|
for (const x of xs) {
|
||||||
|
pdf.rect(x - lineW / 2, originY, lineW, gridH, 'F');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setupCutLinePen(pdf, cutLine);
|
||||||
|
for (const y of ys) {
|
||||||
|
pdf.line(originX, y, originX + gridW, y);
|
||||||
|
}
|
||||||
|
for (const x of xs) {
|
||||||
|
pdf.line(x, originY, x, originY + gridH);
|
||||||
|
}
|
||||||
pdf.setLineDashPattern([], 0);
|
pdf.setLineDashPattern([], 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,6 +152,8 @@ export async function exportLabelsPdfAsync(options: AsyncExportLabelsPdfOptions)
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pageRows = printablePages[pIdx];
|
const pageRows = printablePages[pIdx];
|
||||||
|
const gridRows = Math.ceil(pageRows.length / gridCols);
|
||||||
|
const cutChannel = getCutLineChannel(paper.columnGap, paper.rowGap, cutLine);
|
||||||
report(pIdx + 1, 'render');
|
report(pIdx + 1, 'render');
|
||||||
|
|
||||||
const pageJobs: { cellIdx: number; label: PrintableLabel }[] = [];
|
const pageJobs: { cellIdx: number; label: PrintableLabel }[] = [];
|
||||||
@@ -136,14 +175,12 @@ export async function exportLabelsPdfAsync(options: AsyncExportLabelsPdfOptions)
|
|||||||
for (const { cellIdx, dataUrl } of rendered) {
|
for (const { cellIdx, dataUrl } of rendered) {
|
||||||
const col = cellIdx % gridCols;
|
const col = cellIdx % gridCols;
|
||||||
const row = Math.floor(cellIdx / gridCols);
|
const row = Math.floor(cellIdx / gridCols);
|
||||||
const x = paper.marginLeft + col * (labelW + paper.columnGap);
|
const x = paper.marginLeft + col * (labelW + paper.columnGap) + cutChannel.x;
|
||||||
const y = paper.marginTop + row * (labelH + paper.rowGap);
|
const y = paper.marginTop + row * (labelH + paper.rowGap) + cutChannel.y;
|
||||||
|
const drawW = labelW - 2 * cutChannel.x;
|
||||||
|
const drawH = labelH - 2 * cutChannel.y;
|
||||||
|
|
||||||
pdf.addImage(dataUrl, imageFormat, x, y, labelW, labelH, undefined, 'FAST');
|
pdf.addImage(dataUrl, imageFormat, x, y, drawW, drawH, undefined, 'FAST');
|
||||||
|
|
||||||
if (cutLine.enabled) {
|
|
||||||
drawCutLineRect(pdf, x, y, labelW, labelH, cutLine);
|
|
||||||
}
|
|
||||||
|
|
||||||
done++;
|
done++;
|
||||||
}
|
}
|
||||||
@@ -156,6 +193,10 @@ export async function exportLabelsPdfAsync(options: AsyncExportLabelsPdfOptions)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (cutLine.enabled && pageRows.length > 0) {
|
||||||
|
drawPageGridCutLines(pdf, paper, labelW, labelH, gridCols, gridRows, cutLine);
|
||||||
|
}
|
||||||
|
|
||||||
report(pIdx + 1, 'render');
|
report(pIdx + 1, 'render');
|
||||||
await yieldToMain();
|
await yieldToMain();
|
||||||
}
|
}
|
||||||
|
|||||||
54
src/utils.ts
54
src/utils.ts
@@ -151,6 +151,60 @@ export const DEFAULT_CUT_LINE_CONFIG: CutLineConfig = {
|
|||||||
color: '#cccccc',
|
color: '#cccccc',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface GridCutLinePositions {
|
||||||
|
xs: number[];
|
||||||
|
ys: number[];
|
||||||
|
gridW: number;
|
||||||
|
gridH: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 收集网格裁切线坐标(去重),每条线只绘制一次,避免转角/外缘叠线变粗 */
|
||||||
|
export function collectGridCutLinePositions(
|
||||||
|
originX: number,
|
||||||
|
originY: number,
|
||||||
|
labelW: number,
|
||||||
|
labelH: number,
|
||||||
|
gridCols: number,
|
||||||
|
gridRows: number,
|
||||||
|
columnGap: number,
|
||||||
|
rowGap: number
|
||||||
|
): GridCutLinePositions {
|
||||||
|
const gridW = gridCols * labelW + Math.max(0, gridCols - 1) * columnGap;
|
||||||
|
const gridH = gridRows * labelH + Math.max(0, gridRows - 1) * rowGap;
|
||||||
|
const xs = new Set<number>();
|
||||||
|
const ys = new Set<number>();
|
||||||
|
|
||||||
|
for (let c = 0; c < gridCols; c++) {
|
||||||
|
xs.add(originX + c * (labelW + columnGap));
|
||||||
|
xs.add(originX + c * (labelW + columnGap) + labelW);
|
||||||
|
}
|
||||||
|
for (let r = 0; r < gridRows; r++) {
|
||||||
|
ys.add(originY + r * (labelH + rowGap));
|
||||||
|
ys.add(originY + r * (labelH + rowGap) + labelH);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
xs: [...xs].sort((a, b) => a - b),
|
||||||
|
ys: [...ys].sort((a, b) => a - b),
|
||||||
|
gridW,
|
||||||
|
gridH,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 间距为 0 且启用裁切线时,为裁切线预留半线宽通道,避免与标签图重叠导致内外粗细不一 */
|
||||||
|
export function getCutLineChannel(
|
||||||
|
columnGap: number,
|
||||||
|
rowGap: number,
|
||||||
|
cutLine: { enabled: boolean; width: number }
|
||||||
|
): { x: number; y: number } {
|
||||||
|
if (!cutLine.enabled) return { x: 0, y: 0 };
|
||||||
|
const half = cutLine.width / 2;
|
||||||
|
return {
|
||||||
|
x: columnGap === 0 ? half : 0,
|
||||||
|
y: rowGap === 0 ? half : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface PrintableLabel {
|
export interface PrintableLabel {
|
||||||
row: RecordRow;
|
row: RecordRow;
|
||||||
/** 原始数据行索引(0-based),用于变量解析 */
|
/** 原始数据行索引(0-based),用于变量解析 */
|
||||||
|
|||||||
Reference in New Issue
Block a user