ERP-node/frontend/contexts/report-designer/useHistoryManager.ts

114 lines
4.0 KiB
TypeScript

/**
* @module useHistoryManager
* @description 컴포넌트 변경 이력을 관리하여 Undo/Redo 기능을 제공한다.
* - 최대 50개의 히스토리 스냅샷 유지 (메모리 제한)
* - 300ms 디바운스로 연속 변경을 하나의 히스토리 항목으로 묶음
* - Undo/Redo 실행 중에는 히스토리 저장을 건너뜀 (isUndoRedoing 플래그)
*/
import { useState, useCallback, useEffect } from "react";
import type { ComponentConfig } from "@/types/report";
import type { SetComponentsFn, ToastFunction } from "./internalTypes";
/** 히스토리에 유지할 최대 스냅샷 수 */
const MAX_HISTORY_SIZE = 50;
/** 컴포넌트 변경 후 히스토리 저장까지 대기 시간 (ms) */
const HISTORY_DEBOUNCE_MS = 300;
export interface HistoryManagerResult {
undo: () => void;
redo: () => void;
canUndo: boolean;
canRedo: boolean;
/** 외부에서 히스토리 초기화가 필요할 때 사용 (레이아웃 최초 로드 시) */
initHistory: (components: ComponentConfig[]) => void;
isUndoRedoing: boolean;
}
export function useHistoryManager(
components: ComponentConfig[],
setComponents: SetComponentsFn,
isLoading: boolean,
toast: ToastFunction,
): HistoryManagerResult {
const [history, setHistory] = useState<ComponentConfig[][]>([]);
const [historyIndex, setHistoryIndex] = useState(-1);
const [isUndoRedoing, setIsUndoRedoing] = useState(false);
/**
* 현재 컴포넌트 배열을 히스토리에 저장한다.
* 현재 인덱스 이후의 미래 이력은 제거하여 새 분기를 시작한다.
*/
const saveToHistory = useCallback(
(newComponents: ComponentConfig[]) => {
setHistory((prev) => {
const truncated = prev.slice(0, historyIndex + 1);
truncated.push(JSON.parse(JSON.stringify(newComponents)));
return truncated.slice(-MAX_HISTORY_SIZE);
});
setHistoryIndex((prev) => Math.min(prev + 1, MAX_HISTORY_SIZE - 1));
},
[historyIndex],
);
/** 최초 레이아웃 로드 후 히스토리를 초기 상태로 설정한다. */
const initHistory = useCallback((initialComponents: ComponentConfig[]) => {
setHistory([JSON.parse(JSON.stringify(initialComponents))]);
setHistoryIndex(0);
}, []);
/** 컴포넌트 변경 감지 → 300ms 디바운스 후 히스토리에 저장 */
useEffect(() => {
if (components.length === 0 || isLoading || isUndoRedoing) return;
const timeoutId = setTimeout(() => {
setHistory((prev) => {
const lastSnapshot = prev[historyIndex];
const isDifferent = !lastSnapshot || JSON.stringify(lastSnapshot) !== JSON.stringify(components);
if (isDifferent) {
saveToHistory(components);
}
return prev; // saveToHistory가 내부에서 setState를 호출함
});
}, HISTORY_DEBOUNCE_MS);
return () => clearTimeout(timeoutId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [components, isUndoRedoing, isLoading]);
/** 이전 상태로 되돌린다. */
const undo = useCallback(() => {
if (historyIndex <= 0) return;
setIsUndoRedoing(true);
const prevIndex = historyIndex - 1;
setHistoryIndex(prevIndex);
setComponents(JSON.parse(JSON.stringify(history[prevIndex])));
setTimeout(() => setIsUndoRedoing(false), 100);
toast({ title: "실행 취소", description: "이전 상태로 되돌렸습니다." });
}, [historyIndex, history, setComponents, toast]);
/** 취소한 작업을 다시 실행한다. */
const redo = useCallback(() => {
if (historyIndex >= history.length - 1) return;
setIsUndoRedoing(true);
const nextIndex = historyIndex + 1;
setHistoryIndex(nextIndex);
setComponents(JSON.parse(JSON.stringify(history[nextIndex])));
setTimeout(() => setIsUndoRedoing(false), 100);
toast({ title: "다시 실행", description: "다음 상태로 이동했습니다." });
}, [historyIndex, history, setComponents, toast]);
return {
undo,
redo,
canUndo: historyIndex > 0,
canRedo: historyIndex < history.length - 1,
initHistory,
isUndoRedoing,
};
}