优化:修改撤回

This commit is contained in:
iqudoo
2026-06-12 23:53:19 +08:00
parent 5b4f9de364
commit 4167fb2683
4 changed files with 177 additions and 10 deletions

View File

@@ -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<string>('');
const [templateMenuId, setTemplateMenuId] = useState<string | null>(null);
const templateMenuRef = useRef<HTMLDivElement | null>(null);
const designHistory = useUndoRedo<LabelTemplate>();
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
</span>
<div className="flex-1" />
<div className="flex items-center gap-1">
<button
type="button"
onClick={handleDesignUndo}
disabled={!designHistory.canUndo}
title="撤销 (Ctrl+Z)"
className="inline-flex items-center justify-center w-7 h-7 text-[#ccc] hover:text-white hover:bg-[#444] rounded transition cursor-pointer disabled:opacity-35 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-[#ccc]"
>
<Undo2 className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={handleDesignRedo}
disabled={!designHistory.canRedo}
title="恢复 (Ctrl+Shift+Z / Ctrl+Y)"
className="inline-flex items-center justify-center w-7 h-7 text-[#ccc] hover:text-white hover:bg-[#444] rounded transition cursor-pointer disabled:opacity-35 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-[#ccc]"
>
<Redo2 className="w-3.5 h-3.5" />
</button>
</div>
<span className="hidden md:inline text-[10px] text-[#666]">
Shift/Ctrl+
</span>
@@ -1042,13 +1144,13 @@ export default function App() {
<div className="flex-1 flex min-h-0">
<LabelDesigner
template={activeTemplate}
onChange={handleTemplateChange}
onChange={handleDesignTemplateChange}
selectedElementIds={selectedElementIds}
onSelectElements={setSelectedElementIds}
/>
<PropSidebar
template={activeTemplate}
onChangeTemplate={handleTemplateChange}
onChangeTemplate={handleDesignTemplateChange}
selectedElementIds={selectedElementIds}
onSelectElements={setSelectedElementIds}
activeTab="element"

View File

@@ -35,17 +35,17 @@ export const DataRangeFields: React.FC<DataRangeFieldsProps> = ({
{!isMobile && <div className="ps-section-title"></div>}
<div>
{isMobile && <label className="mobile-field-label"></label>}
{!isMobile && <label className="ps-field-label"></label>}
{/* {!isMobile && <label className="ps-field-label">范围</label>} */}
<select
value={exportRange.mode}
disabled={disabled}
onChange={(e) => patchRange({ mode: e.target.value as ExportRangeConfig['mode'] })}
className={isMobile ? 'mobile-field' : 'ps-field text-[11px]'}
>
<option value="all"></option>
<option value="odd"></option>
<option value="even"></option>
<option value="custom"></option>
<option value="all"></option>
<option value="odd"></option>
<option value="even"></option>
<option value="custom"></option>
</select>
</div>
{exportRange.mode === 'custom' && (

View File

@@ -130,7 +130,6 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
</div>
{hasData && (
<>
{dataRangeSection}
<div>
<VariableMappingPanel
variant="ps"
@@ -141,6 +140,7 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
variableDefaults={template.variableDefaults}
/>
</div>
{dataRangeSection}
{applyDataSection}
</>
)}
@@ -169,7 +169,6 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
/>
{hasData && (
<>
{dataRangeSection}
<VariableMappingPanel
variant="ps"
variables={templateVariables}
@@ -178,6 +177,7 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
onChangeMapping={onChangeMapping}
variableDefaults={template.variableDefaults}
/>
{dataRangeSection}
{applyDataSection}
</>
)}

65
src/hooks/useUndoRedo.ts Normal file
View File

@@ -0,0 +1,65 @@
import { useCallback, useRef, useState } from 'react';
const MAX_HISTORY = 100;
function cloneSnapshot<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
export function useUndoRedo<T>() {
const pastRef = useRef<T[]>([]);
const futureRef = useRef<T[]>([]);
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,
};
}