From 4167fb268399459f5fc32c4dd045b90c70570802 Mon Sep 17 00:00:00 2001 From: iqudoo Date: Fri, 12 Jun 2026 23:53:19 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=EF=BC=9A=E4=BF=AE=E6=94=B9?= =?UTF-8?q?=E6=92=A4=E5=9B=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 108 ++++++++++++++++++++++++++++- src/components/DataRangeFields.tsx | 10 +-- src/components/ExportSidebar.tsx | 4 +- src/hooks/useUndoRedo.ts | 65 +++++++++++++++++ 4 files changed, 177 insertions(+), 10 deletions(-) create mode 100644 src/hooks/useUndoRedo.ts diff --git a/src/App.tsx b/src/App.tsx index 5a35383..1280962 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo, useEffect, useLayoutEffect, useRef } from 'react'; +import React, { useState, useMemo, useEffect, useLayoutEffect, useRef, useCallback } from 'react'; import { LabelTemplate, PaperConfig, ImportData, RecordRow, ExportRangeConfig, CutLineConfig } from './types'; import { stripTemplateForStorage, @@ -22,6 +22,7 @@ import { CanvasLabelImage } from './components/CanvasLabelImage'; import { renderLabelToDataUrl } from './labelRenderer'; import { exportLabelsPdfAsync, type PdfExportProgress } from './pdfExport'; import { useIsMobile } from './hooks/useIsMobile'; +import { useUndoRedo } from './hooks/useUndoRedo'; import { useAppDialog } from './components/AppDialog'; import { FileDown, @@ -33,6 +34,8 @@ import { Pencil, Trash2, MoreHorizontal, + Undo2, + Redo2, X, } from 'lucide-react'; import logoUrl from './assets/logo.svg'; @@ -150,6 +153,12 @@ export default function App() { const [editTmplName, setEditTmplName] = useState(''); const [templateMenuId, setTemplateMenuId] = useState(null); const templateMenuRef = useRef(null); + const designHistory = useUndoRedo(); + const skipDesignHistoryRef = useRef(false); + const designHistorySessionRef = useRef<{ step: string; templateId: string | null }>({ + step: '', + templateId: null, + }); const paper = workingTemplate.paper ?? DEFAULT_PAPER_CONFIG; const cutLine = workingTemplate.cutLine ?? DEFAULT_CUT_LINE_CONFIG; @@ -169,6 +178,79 @@ export default function App() { saveTemplatesToStorage(updatedList); }; + const handleDesignTemplateChange = useCallback( + (updated: LabelTemplate) => { + if ( + !skipDesignHistoryRef.current && + workflowStep === 'template-design' && + activeTemplate && + updated.id === activeTemplate.id + ) { + designHistory.record(activeTemplate, updated); + } + skipDesignHistoryRef.current = false; + handleTemplateChange(updated); + }, + [workflowStep, activeTemplate, designHistory, templates] + ); + + const handleDesignUndo = useCallback(() => { + if (!activeTemplate) return; + const previous = designHistory.undo(activeTemplate); + if (!previous) return; + skipDesignHistoryRef.current = true; + handleTemplateChange(previous); + }, [activeTemplate, designHistory, templates]); + + const handleDesignRedo = useCallback(() => { + if (!activeTemplate) return; + const next = designHistory.redo(activeTemplate); + if (!next) return; + skipDesignHistoryRef.current = true; + handleTemplateChange(next); + }, [activeTemplate, designHistory, templates]); + + useEffect(() => { + if (workflowStep !== 'template-design' || !activeTemplate?.id) return; + const needReset = + designHistorySessionRef.current.step !== 'template-design' || + designHistorySessionRef.current.templateId !== activeTemplate.id; + if (needReset) { + designHistory.reset(); + designHistorySessionRef.current = { + step: workflowStep, + templateId: activeTemplate.id, + }; + } + }, [workflowStep, activeTemplate?.id, designHistory]); + + useEffect(() => { + if (workflowStep !== 'template-design') return; + const onKeyDown = (e: KeyboardEvent) => { + const target = e.target as HTMLElement | null; + if ( + target && + (target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + target.tagName === 'SELECT' || + target.isContentEditable) + ) { + return; + } + const mod = e.metaKey || e.ctrlKey; + if (!mod) return; + if (e.key === 'z' && !e.shiftKey) { + e.preventDefault(); + handleDesignUndo(); + } else if (e.key === 'y' || (e.key === 'z' && e.shiftKey) || (e.key === 'Z' && e.shiftKey)) { + e.preventDefault(); + handleDesignRedo(); + } + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [workflowStep, handleDesignUndo, handleDesignRedo]); + // Save active template selected state and sync its dataset const handleSelectTemplate = (id: string) => { setSelectedTemplateId(id); @@ -1026,6 +1108,26 @@ export default function App() { {activeTemplate.width} × {activeTemplate.height} mm
+
+ + +
Shift/Ctrl+点击多选 @@ -1042,13 +1144,13 @@ export default function App() {
= ({ {!isMobile &&
数据范围
}
{isMobile && } - {!isMobile && } + {/* {!isMobile && } */}
{exportRange.mode === 'custom' && ( diff --git a/src/components/ExportSidebar.tsx b/src/components/ExportSidebar.tsx index 7676df5..e4c2c26 100644 --- a/src/components/ExportSidebar.tsx +++ b/src/components/ExportSidebar.tsx @@ -130,7 +130,6 @@ export const ExportSidebar: React.FC = ({
{hasData && ( <> - {dataRangeSection}
= ({ variableDefaults={template.variableDefaults} />
+ {dataRangeSection} {applyDataSection} )} @@ -169,7 +169,6 @@ export const ExportSidebar: React.FC = ({ /> {hasData && ( <> - {dataRangeSection} = ({ onChangeMapping={onChangeMapping} variableDefaults={template.variableDefaults} /> + {dataRangeSection} {applyDataSection} )} diff --git a/src/hooks/useUndoRedo.ts b/src/hooks/useUndoRedo.ts new file mode 100644 index 0000000..af79857 --- /dev/null +++ b/src/hooks/useUndoRedo.ts @@ -0,0 +1,65 @@ +import { useCallback, useRef, useState } from 'react'; + +const MAX_HISTORY = 100; + +function cloneSnapshot(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +export function useUndoRedo() { + const pastRef = useRef([]); + const futureRef = useRef([]); + const [revision, setRevision] = useState(0); + + const bump = useCallback(() => setRevision((n) => n + 1), []); + + const reset = useCallback(() => { + pastRef.current = []; + futureRef.current = []; + bump(); + }, [bump]); + + const record = useCallback( + (previous: T, next: T) => { + if (JSON.stringify(previous) === JSON.stringify(next)) return; + pastRef.current = [...pastRef.current, cloneSnapshot(previous)].slice(-MAX_HISTORY); + futureRef.current = []; + bump(); + }, + [bump] + ); + + const undo = useCallback( + (current: T): T | null => { + if (pastRef.current.length === 0) return null; + const previous = pastRef.current[pastRef.current.length - 1]; + pastRef.current = pastRef.current.slice(0, -1); + futureRef.current = [cloneSnapshot(current), ...futureRef.current]; + bump(); + return cloneSnapshot(previous); + }, + [bump] + ); + + const redo = useCallback( + (current: T): T | null => { + if (futureRef.current.length === 0) return null; + const next = futureRef.current[0]; + futureRef.current = futureRef.current.slice(1); + pastRef.current = [...pastRef.current, cloneSnapshot(current)]; + bump(); + return cloneSnapshot(next); + }, + [bump] + ); + + return { + revision, + canUndo: pastRef.current.length > 0, + canRedo: futureRef.current.length > 0, + reset, + record, + undo, + redo, + }; +}