优化:修改撤回
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 { LabelTemplate, PaperConfig, ImportData, RecordRow, ExportRangeConfig, CutLineConfig } from './types';
|
||||||
import {
|
import {
|
||||||
stripTemplateForStorage,
|
stripTemplateForStorage,
|
||||||
@@ -22,6 +22,7 @@ import { CanvasLabelImage } from './components/CanvasLabelImage';
|
|||||||
import { renderLabelToDataUrl } from './labelRenderer';
|
import { renderLabelToDataUrl } from './labelRenderer';
|
||||||
import { exportLabelsPdfAsync, type PdfExportProgress } from './pdfExport';
|
import { exportLabelsPdfAsync, type PdfExportProgress } from './pdfExport';
|
||||||
import { useIsMobile } from './hooks/useIsMobile';
|
import { useIsMobile } from './hooks/useIsMobile';
|
||||||
|
import { useUndoRedo } from './hooks/useUndoRedo';
|
||||||
import { useAppDialog } from './components/AppDialog';
|
import { useAppDialog } from './components/AppDialog';
|
||||||
import {
|
import {
|
||||||
FileDown,
|
FileDown,
|
||||||
@@ -33,6 +34,8 @@ import {
|
|||||||
Pencil,
|
Pencil,
|
||||||
Trash2,
|
Trash2,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
|
Undo2,
|
||||||
|
Redo2,
|
||||||
X,
|
X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import logoUrl from './assets/logo.svg';
|
import logoUrl from './assets/logo.svg';
|
||||||
@@ -150,6 +153,12 @@ export default function App() {
|
|||||||
const [editTmplName, setEditTmplName] = useState<string>('');
|
const [editTmplName, setEditTmplName] = useState<string>('');
|
||||||
const [templateMenuId, setTemplateMenuId] = useState<string | null>(null);
|
const [templateMenuId, setTemplateMenuId] = useState<string | null>(null);
|
||||||
const templateMenuRef = useRef<HTMLDivElement | 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 paper = workingTemplate.paper ?? DEFAULT_PAPER_CONFIG;
|
||||||
const cutLine = workingTemplate.cutLine ?? DEFAULT_CUT_LINE_CONFIG;
|
const cutLine = workingTemplate.cutLine ?? DEFAULT_CUT_LINE_CONFIG;
|
||||||
@@ -169,6 +178,79 @@ export default function App() {
|
|||||||
saveTemplatesToStorage(updatedList);
|
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
|
// Save active template selected state and sync its dataset
|
||||||
const handleSelectTemplate = (id: string) => {
|
const handleSelectTemplate = (id: string) => {
|
||||||
setSelectedTemplateId(id);
|
setSelectedTemplateId(id);
|
||||||
@@ -1026,6 +1108,26 @@ export default function App() {
|
|||||||
{activeTemplate.width} × {activeTemplate.height} mm
|
{activeTemplate.width} × {activeTemplate.height} mm
|
||||||
</span>
|
</span>
|
||||||
<div className="flex-1" />
|
<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]">
|
<span className="hidden md:inline text-[10px] text-[#666]">
|
||||||
Shift/Ctrl+点击多选
|
Shift/Ctrl+点击多选
|
||||||
</span>
|
</span>
|
||||||
@@ -1042,13 +1144,13 @@ export default function App() {
|
|||||||
<div className="flex-1 flex min-h-0">
|
<div className="flex-1 flex min-h-0">
|
||||||
<LabelDesigner
|
<LabelDesigner
|
||||||
template={activeTemplate}
|
template={activeTemplate}
|
||||||
onChange={handleTemplateChange}
|
onChange={handleDesignTemplateChange}
|
||||||
selectedElementIds={selectedElementIds}
|
selectedElementIds={selectedElementIds}
|
||||||
onSelectElements={setSelectedElementIds}
|
onSelectElements={setSelectedElementIds}
|
||||||
/>
|
/>
|
||||||
<PropSidebar
|
<PropSidebar
|
||||||
template={activeTemplate}
|
template={activeTemplate}
|
||||||
onChangeTemplate={handleTemplateChange}
|
onChangeTemplate={handleDesignTemplateChange}
|
||||||
selectedElementIds={selectedElementIds}
|
selectedElementIds={selectedElementIds}
|
||||||
onSelectElements={setSelectedElementIds}
|
onSelectElements={setSelectedElementIds}
|
||||||
activeTab="element"
|
activeTab="element"
|
||||||
|
|||||||
@@ -35,17 +35,17 @@ export const DataRangeFields: React.FC<DataRangeFieldsProps> = ({
|
|||||||
{!isMobile && <div className="ps-section-title">数据范围</div>}
|
{!isMobile && <div className="ps-section-title">数据范围</div>}
|
||||||
<div>
|
<div>
|
||||||
{isMobile && <label className="mobile-field-label">范围</label>}
|
{isMobile && <label className="mobile-field-label">范围</label>}
|
||||||
{!isMobile && <label className="ps-field-label">范围</label>}
|
{/* {!isMobile && <label className="ps-field-label">范围</label>} */}
|
||||||
<select
|
<select
|
||||||
value={exportRange.mode}
|
value={exportRange.mode}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onChange={(e) => patchRange({ mode: e.target.value as ExportRangeConfig['mode'] })}
|
onChange={(e) => patchRange({ mode: e.target.value as ExportRangeConfig['mode'] })}
|
||||||
className={isMobile ? 'mobile-field' : 'ps-field text-[11px]'}
|
className={isMobile ? 'mobile-field' : 'ps-field text-[11px]'}
|
||||||
>
|
>
|
||||||
<option value="all">全部标签</option>
|
<option value="all">全部数据</option>
|
||||||
<option value="odd">仅限奇数序号的标签</option>
|
<option value="odd">仅限奇数序号的行</option>
|
||||||
<option value="even">仅限偶数序号的标签</option>
|
<option value="even">仅限偶数序号的行</option>
|
||||||
<option value="custom">自定义序号</option>
|
<option value="custom">自定义行序号</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
{exportRange.mode === 'custom' && (
|
{exportRange.mode === 'custom' && (
|
||||||
|
|||||||
@@ -130,7 +130,6 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
{hasData && (
|
{hasData && (
|
||||||
<>
|
<>
|
||||||
{dataRangeSection}
|
|
||||||
<div>
|
<div>
|
||||||
<VariableMappingPanel
|
<VariableMappingPanel
|
||||||
variant="ps"
|
variant="ps"
|
||||||
@@ -141,6 +140,7 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
|
|||||||
variableDefaults={template.variableDefaults}
|
variableDefaults={template.variableDefaults}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{dataRangeSection}
|
||||||
{applyDataSection}
|
{applyDataSection}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -169,7 +169,6 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
|
|||||||
/>
|
/>
|
||||||
{hasData && (
|
{hasData && (
|
||||||
<>
|
<>
|
||||||
{dataRangeSection}
|
|
||||||
<VariableMappingPanel
|
<VariableMappingPanel
|
||||||
variant="ps"
|
variant="ps"
|
||||||
variables={templateVariables}
|
variables={templateVariables}
|
||||||
@@ -178,6 +177,7 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
|
|||||||
onChangeMapping={onChangeMapping}
|
onChangeMapping={onChangeMapping}
|
||||||
variableDefaults={template.variableDefaults}
|
variableDefaults={template.variableDefaults}
|
||||||
/>
|
/>
|
||||||
|
{dataRangeSection}
|
||||||
{applyDataSection}
|
{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