优化预览与导出排版

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,30 +665,26 @@ 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',
}}
>
<CanvasLabelImage
widthMm={template.width}
heightMm={template.height}
elements={template.elements}
rowData={item.row}
rowIndex={item.sourceIndex}
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 className="w-full h-full overflow-hidden relative">
<CanvasLabelImage
widthMm={template.width}
heightMm={template.height}
elements={template.elements}
rowData={item.row}
rowIndex={item.sourceIndex}
showBorder={false}
mode="output"
/>
)}
</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;