diff --git a/src/components/PreviewGrid.tsx b/src/components/PreviewGrid.tsx index 25416cf..32bc33f 100644 --- a/src/components/PreviewGrid.tsx +++ b/src/components/PreviewGrid.tsx @@ -1,16 +1,21 @@ -import React, { useMemo, useState, useEffect } from 'react'; +import React, { useMemo, useState, useEffect, useRef } from 'react'; +import { createPortal } from 'react-dom'; import { PaperConfig, LabelTemplate, RecordRow, PaperType, CutLineConfig, ExportRangeConfig } from '../types'; import { + computeAutoLayoutPaper, DEFAULT_EXPORT_RANGE, DEFAULT_CUT_LINE_CONFIG, buildPrintablePages, collectGridCutLinePositions, getCutLineChannel, + loadAutoLayoutMinPageMarginMm, + saveAutoLayoutMinPageMarginMm, type PrintableLabel, } from '../utils'; import { CanvasLabelImage } from './CanvasLabelImage'; import { CommitInput } from './CommitInput'; -import { FileDown, RefreshCcw } from 'lucide-react'; +import { useAppDialog } from './AppDialog'; +import { FileDown, RefreshCcw, X } from 'lucide-react'; interface PageInputProps extends Omit, 'onChange'> { value: string | number; @@ -69,7 +74,16 @@ export const PaperSettingsPanel: React.FC = ({ theme = 'light', exportRange, }) => { + const { showAlert } = useAppDialog(); const [draftPaper, setDraftPaper] = useState(paper); + const [autoLayoutOpen, setAutoLayoutOpen] = useState(false); + const [lastAutoLayoutMinMargin, setLastAutoLayoutMinMargin] = useState( + () => loadAutoLayoutMinPageMarginMm() + ); + const [autoLayoutMinMargin, setAutoLayoutMinMargin] = useState( + () => String(loadAutoLayoutMinPageMarginMm()) + ); + const autoLayoutInputRef = useRef(null); const panelRef = React.useRef(null); useEffect(() => { @@ -93,7 +107,7 @@ export const PaperSettingsPanel: React.FC = ({ const fieldClass = isMobile ? 'mobile-field font-mono' : isPs - ? 'ps-field font-mono text-center' + ? 'ps-field font-mono' : 'w-full px-2.5 py-1.5 bg-gray-50 border border-gray-100 rounded-lg text-xs text-center font-mono'; const selectClass = isMobile ? 'mobile-field' @@ -129,32 +143,59 @@ export const PaperSettingsPanel: React.FC = ({ const selectedCount = layoutStats?.selectedCount ?? draftLayoutStats.selectedCount; const allRowCount = totalRowCount ?? rows.length; - const autoCenterMargins = () => { - const paperW = draftPaper.orientation === 'portrait' ? draftPaper.width : draftPaper.height; - const paperH = draftPaper.orientation === 'portrait' ? draftPaper.height : draftPaper.width; - const lw = template.width; - const lh = template.height; - const minMargin = 8; - const usableWInit = paperW - minMargin * 2; - const usableHInit = paperH - minMargin * 2; - const maxCols = Math.floor((usableWInit + draftPaper.columnGap) / (lw + draftPaper.columnGap)) || 1; - const maxRows = Math.floor((usableHInit + draftPaper.rowGap) / (lh + draftPaper.rowGap)) || 1; - const gridWidth = maxCols * lw + (maxCols - 1) * draftPaper.columnGap; - const gridHeight = maxRows * lh + (maxRows - 1) * draftPaper.rowGap; - const balancedLR = Math.max(minMargin, Math.round(((paperW - gridWidth) / 2) * 10) / 10); - const balancedTB = Math.max(minMargin, Math.round(((paperH - gridHeight) / 2) * 10) / 10); - - const next = { - ...draftPaper, - marginLeft: balancedLR, - marginRight: balancedLR, - marginTop: balancedTB, - marginBottom: balancedTB, - }; + const applyAutoLayoutWithMinMargin = (minMargin: number) => { + const next = computeAutoLayoutPaper(draftPaper, template, minMargin); setDraftPaper(next); onChangePaper(next); }; + const closeAutoLayoutDialog = () => setAutoLayoutOpen(false); + + const openAutoLayoutDialog = () => { + setAutoLayoutMinMargin(String(lastAutoLayoutMinMargin)); + setAutoLayoutOpen(true); + }; + + useEffect(() => { + if (!autoLayoutOpen) return; + const timer = window.setTimeout(() => autoLayoutInputRef.current?.focus(), 0); + return () => window.clearTimeout(timer); + }, [autoLayoutOpen]); + + useEffect(() => { + if (!autoLayoutOpen) return; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + closeAutoLayoutDialog(); + } + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [autoLayoutOpen]); + + const confirmAutoLayout = async () => { + const minMargin = Number(autoLayoutMinMargin); + if (!Number.isFinite(minMargin) || minMargin < 0) { + await showAlert('请输入不小于 0 的最小页边距(mm)。', { variant: 'warning' }); + return; + } + + const paperW = + draftPaper.orientation === 'portrait' ? draftPaper.width : draftPaper.height; + const paperH = + draftPaper.orientation === 'portrait' ? draftPaper.height : draftPaper.width; + if (minMargin * 2 >= paperW || minMargin * 2 >= paperH) { + await showAlert('最小页边距过大,当前纸张尺寸无法排版。', { variant: 'warning' }); + return; + } + + applyAutoLayoutWithMinMargin(minMargin); + saveAutoLayoutMinPageMarginMm(minMargin); + setLastAutoLayoutMinMargin(minMargin); + closeAutoLayoutDialog(); + }; + const applyPaper = (next: PaperConfig) => { setDraftPaper(next); onChangePaper(next); @@ -180,7 +221,78 @@ export const PaperSettingsPanel: React.FC = ({ applyPaper({ ...draftPaper, ...patch }); }; + const autoLayoutDialog = + autoLayoutOpen && typeof document !== 'undefined' + ? createPortal( +
+
e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-labelledby="auto-layout-dialog-title" + > +
+

+ 自动排版 +

+ +
+
{ + e.preventDefault(); + void confirmAutoLayout(); + }} + > +
+

+ 将根据当前纸张与标签尺寸,在不低于最小页边距的前提下,自动计算左右、上下对称边距并居中排版。 +

+
+ + setAutoLayoutMinMargin(e.target.value)} + className="ps-field font-mono" + /> +
+
+
+ + +
+
+
+
, + document.body + ) + : null; + return ( + <>
= ({ />
))} -
+
+ + 上次最小页边距 {lastAutoLayoutMinMargin} mm +
@@ -387,6 +511,8 @@ export const PaperSettingsPanel: React.FC = ({ + {autoLayoutDialog} + ); }; diff --git a/src/utils.ts b/src/utils.ts index 8c7e2fa..d064bca 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -296,15 +296,70 @@ export function buildPrintablePages( }; } +/** 自动排版时允许的最小页边距 (mm) */ +export const AUTO_LAYOUT_MIN_PAGE_MARGIN_MM = 10; + +const AUTO_LAYOUT_MIN_MARGIN_STORAGE_KEY = 'label_designer_auto_layout_min_margin_mm'; + +/** 读取上次自动排版使用的最小页边距 (mm) */ +export function loadAutoLayoutMinPageMarginMm(): number { + try { + const raw = localStorage.getItem(AUTO_LAYOUT_MIN_MARGIN_STORAGE_KEY); + if (raw === null) return AUTO_LAYOUT_MIN_PAGE_MARGIN_MM; + const n = Number(raw); + if (Number.isFinite(n) && n >= 0) return n; + } catch { + /* ignore */ + } + return AUTO_LAYOUT_MIN_PAGE_MARGIN_MM; +} + +/** 保存自动排版使用的最小页边距 (mm) */ +export function saveAutoLayoutMinPageMarginMm(value: number): void { + try { + localStorage.setItem(AUTO_LAYOUT_MIN_MARGIN_STORAGE_KEY, String(value)); + } catch { + /* ignore */ + } +} + +/** 按最小页边距居中计算四边页边距 */ +export function computeAutoLayoutPaper( + paper: PaperConfig, + template: Pick, + minMargin: number +): PaperConfig { + const paperW = paper.orientation === 'portrait' ? paper.width : paper.height; + const paperH = paper.orientation === 'portrait' ? paper.height : paper.width; + const lw = template.width; + const lh = template.height; + const usableWInit = paperW - minMargin * 2; + const usableHInit = paperH - minMargin * 2; + const maxCols = Math.floor((usableWInit + paper.columnGap) / (lw + paper.columnGap)) || 1; + const maxRows = Math.floor((usableHInit + paper.rowGap) / (lh + paper.rowGap)) || 1; + const gridWidth = maxCols * lw + (maxCols - 1) * paper.columnGap; + const gridHeight = maxRows * lh + (maxRows - 1) * paper.rowGap; + const balancedLR = Math.max(minMargin, Math.round(((paperW - gridWidth) / 2) * 10) / 10); + const balancedTB = Math.max(minMargin, Math.round(((paperH - gridHeight) / 2) * 10) / 10); + + return { + ...paper, + marginLeft: balancedLR, + marginRight: balancedLR, + marginTop: balancedTB, + marginBottom: balancedTB, + }; +} + export const DEFAULT_PAPER_CONFIG: PaperConfig = { type: 'A4', width: 210, height: 297, orientation: 'portrait', - marginTop: 10, - marginBottom: 10, - marginLeft: 10, - marginRight: 10, + marginTop: AUTO_LAYOUT_MIN_PAGE_MARGIN_MM, + marginBottom: AUTO_LAYOUT_MIN_PAGE_MARGIN_MM, + marginLeft: AUTO_LAYOUT_MIN_PAGE_MARGIN_MM, + marginRight: AUTO_LAYOUT_MIN_PAGE_MARGIN_MM, columnGap: 2, rowGap: 2, flow: 'left-right-top-bottom',