114 lines
4.0 KiB
TypeScript
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,
|
|
};
|
|
}
|