优化预览与导出排版

This commit is contained in:
iqudoo
2026-06-12 20:07:49 +08:00
parent 7ad1f59ae1
commit c9a187bf9b
7 changed files with 291 additions and 116 deletions

View 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}
/>
);
};

View File

@@ -3,7 +3,7 @@ import { Database, Layout, Settings } from 'lucide-react';
import { ImportData, PaperConfig, LabelTemplate, RecordRow, ExportRangeConfig, CutLineConfig } from '../types';
import { DataImporter } from './DataImporter';
import { VariableMappingPanel } from './VariableMappingPanel';
import { PaperSettingsPanel } from './PreviewGrid';
import { PaperSettingsPanel, PageInput } from './PreviewGrid';
import { CollapsiblePanelSection } from './CollapsiblePanelSection';
import { DataRangeFields } from './DataRangeFields';
@@ -236,19 +236,19 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
</div>
<div>
<label className="ps-field-label">线 (mm)</label>
<input
<PageInput
type="number"
min="0.02"
max="2"
step="0.02"
value={cutLine.width}
onChange={(e) =>
onCommit={(val) =>
onChangeCutLine({
...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
className="mt-2 h-8 rounded border border-[#3a3a3a] bg-[#1e1e1e] flex items-center justify-center"

View File

@@ -7,7 +7,7 @@ import {
PaperConfig,
RecordRow,
} from '../types';
import { PaperSettingsPanel } from './PreviewGrid';
import { PaperSettingsPanel, PageInput } from './PreviewGrid';
interface MobileExportPropsPanelProps {
template: LabelTemplate;
@@ -115,19 +115,19 @@ export const MobileExportPropsPanel: React.FC<MobileExportPropsPanelProps> = ({
</div>
<div>
<label className="mobile-field-label">线 (mm)</label>
<input
<PageInput
type="number"
min="0.02"
max="2"
step="0.02"
value={cutLine.width}
onChange={(e) =>
onCommit={(val) =>
onChangeCutLine({
...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>

View File

@@ -4,9 +4,12 @@ import {
DEFAULT_EXPORT_RANGE,
DEFAULT_CUT_LINE_CONFIG,
buildPrintablePages,
collectGridCutLinePositions,
getCutLineChannel,
type PrintableLabel,
} from '../utils';
import { CanvasLabelImage } from './CanvasLabelImage';
import { CommitInput } from './CommitInput';
import { FileDown, RefreshCcw } from 'lucide-react';
interface PageInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> {
@@ -15,36 +18,14 @@ interface PageInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement
inputClassName?: string;
}
export const PageInput: React.FC<PageInputProps> = ({ value, onCommit, inputClassName = '', ...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}
className={inputClassName}
/>
);
};
export const PageInput: React.FC<PageInputProps> = ({
value,
onCommit,
inputClassName = '',
...props
}) => (
<CommitInput value={value} onCommit={onCommit} className={inputClassName} {...props} />
);
interface LayoutStats {
gridCols: number;
@@ -176,7 +157,7 @@ export const PaperSettingsPanel: React.FC<PaperSettingsPanelProps> = ({
const applyPaper = (next: PaperConfig) => {
setDraftPaper(next);
if (isMobile) onChangePaper(next);
onChangePaper(next);
};
const handlePaperTypeChange = (type: PaperType) => {
@@ -213,7 +194,7 @@ export const PaperSettingsPanel: React.FC<PaperSettingsPanelProps> = ({
)}
{isPs && (
<p className="text-[10px] text-[#666] leading-relaxed">
</p>
)}
@@ -465,6 +446,31 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
const currentPaperH = paper.orientation === 'portrait' ? paper.height : paper.width;
const colGapPx = Math.max(0, paper.columnGap * 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 hasMorePages = printablePages.length > MAX_PREVIEW_PAGES;
@@ -587,15 +593,58 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
}}
>
<div
className="grid bg-white"
className="grid bg-white relative"
style={{
gridTemplateColumns: `repeat(${gridInfo.cols}, ${template.width * previewScale}px)`,
gridAutoRows: `${template.height * previewScale}px`,
gridTemplateColumns: `repeat(${gridInfo.cols}, ${labelWPx}px)`,
gridAutoRows: `${labelHPx}px`,
columnGap: `${colGapPx}px`,
rowGap: `${rowGapPx}px`,
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) => {
if (!item) {
return (
@@ -616,13 +665,16 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
<div
key={cellIdx}
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={{
width: `${template.width * previewScale}px`,
height: `${template.height * previewScale}px`,
width: `${labelWPx}px`,
height: `${labelHPx}px`,
padding: `${cutChannelYPx}px ${cutChannelXPx}px`,
boxSizing: 'border-box',
}}
>
<div className="w-full h-full overflow-hidden relative">
<CanvasLabelImage
widthMm={template.width}
heightMm={template.height}
@@ -632,14 +684,7 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
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>
);
})}

View File

@@ -19,6 +19,7 @@ import {
} from '../elementAlign';
import type { ElementSide } from '../types';
import { CollapsiblePanelSection } from './CollapsiblePanelSection';
import { CommitInput } from './CommitInput';
import {
Sliders,
LayoutGrid,
@@ -90,40 +91,7 @@ const AlignToolButton: React.FC<AlignToolButtonProps> = ({ icon, label, title, o
</button>
);
interface PropInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> {
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}
/>
);
};
const PropInput = CommitInput;
interface PropTextareaProps extends Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'onChange'> {
value: string;

View File

@@ -1,6 +1,6 @@
import { jsPDF } from 'jspdf';
import type { PaperConfig, LabelTemplate, CutLineConfig } from './types';
import type { PrintableLabel } from './utils';
import { collectGridCutLinePositions, getCutLineChannel, type PrintableLabel } from './utils';
export interface PdfExportProgress {
done: number;
@@ -48,24 +48,61 @@ function parseColorToRgb(color: string): [number, number, number] {
return [204, 204, 204];
}
function drawCutLineRect(
pdf: jsPDF,
x: number,
y: number,
w: number,
h: number,
cutLine: CutLineConfig
) {
function setupCutLinePen(pdf: jsPDF, cutLine: CutLineConfig) {
const [r, g, b] = parseColorToRgb(cutLine.color);
pdf.setDrawColor(r, g, b);
pdf.setLineWidth(Math.max(0.02, cutLine.width));
pdf.setLineWidth(cutLine.width);
if (cutLine.style === 'dashed') {
const dash = Math.max(0.5, cutLine.width * 4);
pdf.setLineDashPattern([dash, dash], 0);
} else {
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);
}
@@ -115,6 +152,8 @@ export async function exportLabelsPdfAsync(options: AsyncExportLabelsPdfOptions)
}
const pageRows = printablePages[pIdx];
const gridRows = Math.ceil(pageRows.length / gridCols);
const cutChannel = getCutLineChannel(paper.columnGap, paper.rowGap, cutLine);
report(pIdx + 1, 'render');
const pageJobs: { cellIdx: number; label: PrintableLabel }[] = [];
@@ -136,14 +175,12 @@ export async function exportLabelsPdfAsync(options: AsyncExportLabelsPdfOptions)
for (const { cellIdx, dataUrl } of rendered) {
const col = cellIdx % gridCols;
const row = Math.floor(cellIdx / gridCols);
const x = paper.marginLeft + col * (labelW + paper.columnGap);
const y = paper.marginTop + row * (labelH + paper.rowGap);
const x = paper.marginLeft + col * (labelW + paper.columnGap) + cutChannel.x;
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');
if (cutLine.enabled) {
drawCutLineRect(pdf, x, y, labelW, labelH, cutLine);
}
pdf.addImage(dataUrl, imageFormat, x, y, drawW, drawH, undefined, 'FAST');
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');
await yieldToMain();
}

View File

@@ -151,6 +151,60 @@ export const DEFAULT_CUT_LINE_CONFIG: CutLineConfig = {
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 {
row: RecordRow;
/** 原始数据行索引0-based用于变量解析 */