From c9a187bf9bfdcb9675c263928bab95183c798c12 Mon Sep 17 00:00:00 2001 From: iqudoo Date: Fri, 12 Jun 2026 20:07:49 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=A2=84=E8=A7=88=E4=B8=8E?= =?UTF-8?q?=E5=AF=BC=E5=87=BA=E6=8E=92=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/CommitInput.tsx | 67 ++++++++++ src/components/ExportSidebar.tsx | 10 +- src/components/MobileExportPropsPanel.tsx | 10 +- src/components/PreviewGrid.tsx | 153 ++++++++++++++-------- src/components/PropSidebar.tsx | 36 +---- src/pdfExport.ts | 77 ++++++++--- src/utils.ts | 54 ++++++++ 7 files changed, 291 insertions(+), 116 deletions(-) create mode 100644 src/components/CommitInput.tsx diff --git a/src/components/CommitInput.tsx b/src/components/CommitInput.tsx new file mode 100644 index 0000000..b9ed422 --- /dev/null +++ b/src/components/CommitInput.tsx @@ -0,0 +1,67 @@ +import React, { useState, useEffect, useRef } from 'react'; + +export interface CommitInputProps + extends Omit, 'onChange' | 'value' | 'type'> { + value: string | number; + onCommit: (val: string) => void; + /** number 类型在编辑时用文本框,提交时由外部校验 min/max */ + type?: 'text' | 'number'; +} + +/** + * 失焦/回车提交型输入框:编辑过程中不应用 HTML min/max 限制,便于清空后重输。 + */ +export const CommitInput: React.FC = ({ + value, + onCommit, + type = 'text', + min: _min, + max: _max, + step: _step, + onFocus, + onBlur, + onKeyDown, + ...props +}) => { + const [localVal, setLocalVal] = useState(String(value)); + const focusedRef = useRef(false); + + useEffect(() => { + if (!focusedRef.current) { + setLocalVal(String(value)); + } + }, [value]); + + const handleBlur = (e: React.FocusEvent) => { + focusedRef.current = false; + onBlur?.(e); + if (localVal !== String(value)) { + onCommit(localVal); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + (e.target as HTMLInputElement).blur(); + } + onKeyDown?.(e); + }; + + const inputType = type === 'number' ? 'text' : type; + + return ( + setLocalVal(e.target.value)} + onFocus={(e) => { + focusedRef.current = true; + onFocus?.(e); + }} + onBlur={handleBlur} + onKeyDown={handleKeyDown} + /> + ); +}; diff --git a/src/components/ExportSidebar.tsx b/src/components/ExportSidebar.tsx index b16b3e4..7676df5 100644 --- a/src/components/ExportSidebar.tsx +++ b/src/components/ExportSidebar.tsx @@ -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 = ({
- + 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]" />
= ({
- + 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" />
diff --git a/src/components/PreviewGrid.tsx b/src/components/PreviewGrid.tsx index 3f522c9..18a86e7 100644 --- a/src/components/PreviewGrid.tsx +++ b/src/components/PreviewGrid.tsx @@ -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, 'onChange'> { @@ -15,36 +18,14 @@ interface PageInputProps extends Omit = ({ value, onCommit, inputClassName = '', ...props }) => { - const [localVal, setLocalVal] = useState(String(value)); - - useEffect(() => { - setLocalVal(String(value)); - }, [value]); - - const handleBlur = () => { - if (localVal !== String(value)) { - onCommit(localVal); - } - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - (e.target as HTMLInputElement).blur(); - } - }; - - return ( - setLocalVal(e.target.value)} - onBlur={handleBlur} - onKeyDown={handleKeyDown} - className={inputClassName} - /> - ); -}; +export const PageInput: React.FC = ({ + value, + onCommit, + inputClassName = '', + ...props +}) => ( + +); interface LayoutStats { gridCols: number; @@ -176,7 +157,7 @@ export const PaperSettingsPanel: React.FC = ({ 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 = ({ )} {isPs && (

- 修改后点击面板外区域或切换焦点,预览将自动更新。 + 排版参数修改后将自动更新预览。

)} @@ -465,6 +446,31 @@ export const LayoutPreview: React.FC = ({ 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 = ({ }} >
+ {cutLine.enabled && ( + + {pageCutLines.ys.map((y) => ( + + ))} + {pageCutLines.xs.map((x) => ( + + ))} + + )} {pageRows.map((item, cellIdx) => { if (!item) { return ( @@ -616,30 +665,26 @@ export const LayoutPreview: React.FC = ({
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', }} > - - {cutLine.enabled && ( -
+ - )} +
); })} diff --git a/src/components/PropSidebar.tsx b/src/components/PropSidebar.tsx index afdc2cf..5766697 100644 --- a/src/components/PropSidebar.tsx +++ b/src/components/PropSidebar.tsx @@ -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 = ({ icon, label, title, o ); -interface PropInputProps extends Omit, 'onChange'> { - value: string | number; - onCommit: (val: string) => void; -} - -const PropInput: React.FC = ({ value, onCommit, ...props }) => { - const [localVal, setLocalVal] = useState(String(value)); - - useEffect(() => { - setLocalVal(String(value)); - }, [value]); - - const handleBlur = () => { - if (localVal !== String(value)) { - onCommit(localVal); - } - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - (e.target as HTMLInputElement).blur(); - } - }; - - return ( - setLocalVal(e.target.value)} - onBlur={handleBlur} - onKeyDown={handleKeyDown} - /> - ); -}; +const PropInput = CommitInput; interface PropTextareaProps extends Omit, 'onChange'> { value: string; diff --git a/src/pdfExport.ts b/src/pdfExport.ts index 406d198..f570130 100644 --- a/src/pdfExport.ts +++ b/src/pdfExport.ts @@ -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(); } diff --git a/src/utils.ts b/src/utils.ts index 5c81ebc..3f0ed33 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -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(); + const ys = new Set(); + + 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),用于变量解析 */