优化:修改撤回
This commit is contained in:
108
src/App.tsx
108
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<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"
|
||||
|
||||
@@ -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' && (
|
||||
|
||||
@@ -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
65
src/hooks/useUndoRedo.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user