되돌리기 기능 추가
This commit is contained in:
parent
2849f7e116
commit
8046c2a2e0
|
|
@ -60,11 +60,14 @@ function FlowEditorInner() {
|
||||||
onNodesChange,
|
onNodesChange,
|
||||||
onEdgesChange,
|
onEdgesChange,
|
||||||
onConnect,
|
onConnect,
|
||||||
|
onNodeDragStart,
|
||||||
addNode,
|
addNode,
|
||||||
showPropertiesPanel,
|
showPropertiesPanel,
|
||||||
selectNodes,
|
selectNodes,
|
||||||
selectedNodes,
|
selectedNodes,
|
||||||
removeNodes,
|
removeNodes,
|
||||||
|
undo,
|
||||||
|
redo,
|
||||||
} = useFlowEditorStore();
|
} = useFlowEditorStore();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -80,17 +83,37 @@ function FlowEditorInner() {
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 키보드 이벤트 핸들러 (Delete/Backspace 키로 노드 삭제)
|
* 키보드 이벤트 핸들러 (Delete/Backspace 키로 노드 삭제, Ctrl+Z/Y로 Undo/Redo)
|
||||||
*/
|
*/
|
||||||
const onKeyDown = useCallback(
|
const onKeyDown = useCallback(
|
||||||
(event: React.KeyboardEvent) => {
|
(event: React.KeyboardEvent) => {
|
||||||
|
// Undo: Ctrl+Z (Windows/Linux) or Cmd+Z (Mac)
|
||||||
|
if ((event.ctrlKey || event.metaKey) && event.key === "z" && !event.shiftKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
console.log("⏪ Undo");
|
||||||
|
undo();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redo: Ctrl+Y (Windows/Linux) or Cmd+Shift+Z (Mac) or Ctrl+Shift+Z
|
||||||
|
if (
|
||||||
|
((event.ctrlKey || event.metaKey) && event.key === "y") ||
|
||||||
|
((event.ctrlKey || event.metaKey) && event.shiftKey && event.key === "z")
|
||||||
|
) {
|
||||||
|
event.preventDefault();
|
||||||
|
console.log("⏩ Redo");
|
||||||
|
redo();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete: Delete/Backspace 키로 노드 삭제
|
||||||
if ((event.key === "Delete" || event.key === "Backspace") && selectedNodes.length > 0) {
|
if ((event.key === "Delete" || event.key === "Backspace") && selectedNodes.length > 0) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
console.log("🗑️ 선택된 노드 삭제:", selectedNodes);
|
console.log("🗑️ 선택된 노드 삭제:", selectedNodes);
|
||||||
removeNodes(selectedNodes);
|
removeNodes(selectedNodes);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[selectedNodes, removeNodes],
|
[selectedNodes, removeNodes, undo, redo],
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -170,6 +193,7 @@ function FlowEditorInner() {
|
||||||
onNodesChange={onNodesChange}
|
onNodesChange={onNodesChange}
|
||||||
onEdgesChange={onEdgesChange}
|
onEdgesChange={onEdgesChange}
|
||||||
onConnect={onConnect}
|
onConnect={onConnect}
|
||||||
|
onNodeDragStart={onNodeDragStart}
|
||||||
onSelectionChange={onSelectionChange}
|
onSelectionChange={onSelectionChange}
|
||||||
onDragOver={onDragOver}
|
onDragOver={onDragOver}
|
||||||
onDrop={onDrop}
|
onDrop={onDrop}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,10 @@ export function FlowToolbar() {
|
||||||
isSaving,
|
isSaving,
|
||||||
selectedNodes,
|
selectedNodes,
|
||||||
removeNodes,
|
removeNodes,
|
||||||
|
undo,
|
||||||
|
redo,
|
||||||
|
canUndo,
|
||||||
|
canRedo,
|
||||||
} = useFlowEditorStore();
|
} = useFlowEditorStore();
|
||||||
const [showLoadDialog, setShowLoadDialog] = useState(false);
|
const [showLoadDialog, setShowLoadDialog] = useState(false);
|
||||||
|
|
||||||
|
|
@ -108,10 +112,10 @@ export function FlowToolbar() {
|
||||||
<div className="h-6 w-px bg-gray-200" />
|
<div className="h-6 w-px bg-gray-200" />
|
||||||
|
|
||||||
{/* 실행 취소/다시 실행 */}
|
{/* 실행 취소/다시 실행 */}
|
||||||
<Button variant="ghost" size="sm" title="실행 취소" disabled>
|
<Button variant="ghost" size="sm" title="실행 취소 (Ctrl+Z)" disabled={!canUndo()} onClick={undo}>
|
||||||
<Undo2 className="h-4 w-4" />
|
<Undo2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" title="다시 실행" disabled>
|
<Button variant="ghost" size="sm" title="다시 실행 (Ctrl+Y)" disabled={!canRedo()} onClick={redo}>
|
||||||
<Redo2 className="h-4 w-4" />
|
<Redo2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,22 @@ interface ExternalConnectionCache {
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔥 히스토리 스냅샷 타입
|
||||||
|
interface HistorySnapshot {
|
||||||
|
nodes: FlowNode[];
|
||||||
|
edges: FlowEdge[];
|
||||||
|
}
|
||||||
|
|
||||||
interface FlowEditorState {
|
interface FlowEditorState {
|
||||||
// 노드 및 엣지
|
// 노드 및 엣지
|
||||||
nodes: FlowNode[];
|
nodes: FlowNode[];
|
||||||
edges: FlowEdge[];
|
edges: FlowEdge[];
|
||||||
|
|
||||||
|
// 🔥 히스토리 관리
|
||||||
|
history: HistorySnapshot[];
|
||||||
|
historyIndex: number;
|
||||||
|
maxHistorySize: number;
|
||||||
|
|
||||||
// 선택 상태
|
// 선택 상태
|
||||||
selectedNodes: string[];
|
selectedNodes: string[];
|
||||||
selectedEdges: string[];
|
selectedEdges: string[];
|
||||||
|
|
@ -39,12 +50,23 @@ interface FlowEditorState {
|
||||||
// 🔥 외부 커넥션 캐시 (전역 캐싱)
|
// 🔥 외부 커넥션 캐시 (전역 캐싱)
|
||||||
externalConnectionsCache: ExternalConnectionCache | null;
|
externalConnectionsCache: ExternalConnectionCache | null;
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 🔥 히스토리 관리 (Undo/Redo)
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
saveToHistory: () => void;
|
||||||
|
undo: () => void;
|
||||||
|
redo: () => void;
|
||||||
|
canUndo: () => boolean;
|
||||||
|
canRedo: () => boolean;
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
// 노드 관리
|
// 노드 관리
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|
||||||
setNodes: (nodes: FlowNode[]) => void;
|
setNodes: (nodes: FlowNode[]) => void;
|
||||||
onNodesChange: (changes: NodeChange[]) => void;
|
onNodesChange: (changes: NodeChange[]) => void;
|
||||||
|
onNodeDragStart: () => void; // 노드 드래그 시작 시 히스토리 저장 (변경 전 상태)
|
||||||
addNode: (node: FlowNode) => void;
|
addNode: (node: FlowNode) => void;
|
||||||
updateNode: (id: string, data: Partial<FlowNode["data"]>) => void;
|
updateNode: (id: string, data: Partial<FlowNode["data"]>) => void;
|
||||||
removeNode: (id: string) => void;
|
removeNode: (id: string) => void;
|
||||||
|
|
@ -117,6 +139,9 @@ export const useFlowEditorStore = create<FlowEditorState>((set, get) => ({
|
||||||
// 초기 상태
|
// 초기 상태
|
||||||
nodes: [],
|
nodes: [],
|
||||||
edges: [],
|
edges: [],
|
||||||
|
history: [{ nodes: [], edges: [] }], // 초기 빈 상태를 히스토리에 저장
|
||||||
|
historyIndex: 0,
|
||||||
|
maxHistorySize: 50,
|
||||||
selectedNodes: [],
|
selectedNodes: [],
|
||||||
selectedEdges: [],
|
selectedEdges: [],
|
||||||
flowId: null,
|
flowId: null,
|
||||||
|
|
@ -129,6 +154,103 @@ export const useFlowEditorStore = create<FlowEditorState>((set, get) => ({
|
||||||
validationResult: null,
|
validationResult: null,
|
||||||
externalConnectionsCache: null, // 🔥 캐시 초기화
|
externalConnectionsCache: null, // 🔥 캐시 초기화
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 🔥 히스토리 관리 (Undo/Redo)
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
saveToHistory: () => {
|
||||||
|
const { nodes, edges, history, historyIndex, maxHistorySize } = get();
|
||||||
|
|
||||||
|
// 현재 상태를 스냅샷으로 저장
|
||||||
|
const snapshot: HistorySnapshot = {
|
||||||
|
nodes: JSON.parse(JSON.stringify(nodes)),
|
||||||
|
edges: JSON.parse(JSON.stringify(edges)),
|
||||||
|
};
|
||||||
|
|
||||||
|
// historyIndex 이후의 히스토리 제거 (새로운 변경이 발생했으므로)
|
||||||
|
const newHistory = history.slice(0, historyIndex + 1);
|
||||||
|
newHistory.push(snapshot);
|
||||||
|
|
||||||
|
// 최대 크기 제한
|
||||||
|
if (newHistory.length > maxHistorySize) {
|
||||||
|
newHistory.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("📸 히스토리 저장:", {
|
||||||
|
노드수: nodes.length,
|
||||||
|
엣지수: edges.length,
|
||||||
|
히스토리크기: newHistory.length,
|
||||||
|
현재인덱스: newHistory.length - 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
set({
|
||||||
|
history: newHistory,
|
||||||
|
historyIndex: newHistory.length - 1,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
undo: () => {
|
||||||
|
const { history, historyIndex } = get();
|
||||||
|
|
||||||
|
console.log("⏪ Undo 시도:", { historyIndex, historyLength: history.length });
|
||||||
|
|
||||||
|
if (historyIndex > 0) {
|
||||||
|
const newIndex = historyIndex - 1;
|
||||||
|
const snapshot = history[newIndex];
|
||||||
|
|
||||||
|
console.log("✅ Undo 실행:", {
|
||||||
|
이전인덱스: historyIndex,
|
||||||
|
새인덱스: newIndex,
|
||||||
|
노드수: snapshot.nodes.length,
|
||||||
|
엣지수: snapshot.edges.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
set({
|
||||||
|
nodes: JSON.parse(JSON.stringify(snapshot.nodes)),
|
||||||
|
edges: JSON.parse(JSON.stringify(snapshot.edges)),
|
||||||
|
historyIndex: newIndex,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log("❌ Undo 불가: 히스토리가 없음");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
redo: () => {
|
||||||
|
const { history, historyIndex } = get();
|
||||||
|
|
||||||
|
console.log("⏩ Redo 시도:", { historyIndex, historyLength: history.length });
|
||||||
|
|
||||||
|
if (historyIndex < history.length - 1) {
|
||||||
|
const newIndex = historyIndex + 1;
|
||||||
|
const snapshot = history[newIndex];
|
||||||
|
|
||||||
|
console.log("✅ Redo 실행:", {
|
||||||
|
이전인덱스: historyIndex,
|
||||||
|
새인덱스: newIndex,
|
||||||
|
노드수: snapshot.nodes.length,
|
||||||
|
엣지수: snapshot.edges.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
set({
|
||||||
|
nodes: JSON.parse(JSON.stringify(snapshot.nodes)),
|
||||||
|
edges: JSON.parse(JSON.stringify(snapshot.edges)),
|
||||||
|
historyIndex: newIndex,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log("❌ Redo 불가: 되돌릴 히스토리가 없음");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
canUndo: () => {
|
||||||
|
const { historyIndex } = get();
|
||||||
|
return historyIndex > 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
canRedo: () => {
|
||||||
|
const { history, historyIndex } = get();
|
||||||
|
return historyIndex < history.length - 1;
|
||||||
|
},
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
// 노드 관리
|
// 노드 관리
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|
@ -141,13 +263,21 @@ export const useFlowEditorStore = create<FlowEditorState>((set, get) => ({
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
onNodeDragStart: () => {
|
||||||
|
// 노드 드래그 시작 시 히스토리 저장 (변경 전 상태)
|
||||||
|
get().saveToHistory();
|
||||||
|
console.log("🎯 노드 이동 시작, 변경 전 상태 히스토리 저장");
|
||||||
|
},
|
||||||
|
|
||||||
addNode: (node) => {
|
addNode: (node) => {
|
||||||
|
get().saveToHistory(); // 히스토리에 저장
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
nodes: [...state.nodes, node],
|
nodes: [...state.nodes, node],
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
updateNode: (id, data) => {
|
updateNode: (id, data) => {
|
||||||
|
get().saveToHistory(); // 히스토리에 저장
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
nodes: state.nodes.map((node) =>
|
nodes: state.nodes.map((node) =>
|
||||||
node.id === id
|
node.id === id
|
||||||
|
|
@ -161,6 +291,7 @@ export const useFlowEditorStore = create<FlowEditorState>((set, get) => ({
|
||||||
},
|
},
|
||||||
|
|
||||||
removeNode: (id) => {
|
removeNode: (id) => {
|
||||||
|
get().saveToHistory(); // 히스토리에 저장
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
nodes: state.nodes.filter((node) => node.id !== id),
|
nodes: state.nodes.filter((node) => node.id !== id),
|
||||||
edges: state.edges.filter((edge) => edge.source !== id && edge.target !== id),
|
edges: state.edges.filter((edge) => edge.source !== id && edge.target !== id),
|
||||||
|
|
@ -168,6 +299,7 @@ export const useFlowEditorStore = create<FlowEditorState>((set, get) => ({
|
||||||
},
|
},
|
||||||
|
|
||||||
removeNodes: (ids) => {
|
removeNodes: (ids) => {
|
||||||
|
get().saveToHistory(); // 히스토리에 저장
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
nodes: state.nodes.filter((node) => !ids.includes(node.id)),
|
nodes: state.nodes.filter((node) => !ids.includes(node.id)),
|
||||||
edges: state.edges.filter((edge) => !ids.includes(edge.source) && !ids.includes(edge.target)),
|
edges: state.edges.filter((edge) => !ids.includes(edge.source) && !ids.includes(edge.target)),
|
||||||
|
|
@ -181,12 +313,20 @@ export const useFlowEditorStore = create<FlowEditorState>((set, get) => ({
|
||||||
setEdges: (edges) => set({ edges }),
|
setEdges: (edges) => set({ edges }),
|
||||||
|
|
||||||
onEdgesChange: (changes) => {
|
onEdgesChange: (changes) => {
|
||||||
|
// 엣지 삭제(remove) 타입이 있으면 히스토리 저장
|
||||||
|
const hasRemove = changes.some((change) => change.type === "remove");
|
||||||
|
if (hasRemove) {
|
||||||
|
get().saveToHistory();
|
||||||
|
console.log("🔗 엣지 삭제, 변경 전 상태 히스토리 저장");
|
||||||
|
}
|
||||||
|
|
||||||
set({
|
set({
|
||||||
edges: applyEdgeChanges(changes, get().edges) as FlowEdge[],
|
edges: applyEdgeChanges(changes, get().edges) as FlowEdge[],
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
onConnect: (connection) => {
|
onConnect: (connection) => {
|
||||||
|
get().saveToHistory(); // 히스토리에 저장
|
||||||
// 연결 검증
|
// 연결 검증
|
||||||
const validation = validateConnection(connection, get().nodes);
|
const validation = validateConnection(connection, get().nodes);
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
|
|
@ -210,12 +350,14 @@ export const useFlowEditorStore = create<FlowEditorState>((set, get) => ({
|
||||||
},
|
},
|
||||||
|
|
||||||
removeEdge: (id) => {
|
removeEdge: (id) => {
|
||||||
|
get().saveToHistory(); // 히스토리에 저장
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
edges: state.edges.filter((edge) => edge.id !== id),
|
edges: state.edges.filter((edge) => edge.id !== id),
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
removeEdges: (ids) => {
|
removeEdges: (ids) => {
|
||||||
|
get().saveToHistory(); // 히스토리에 저장
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
edges: state.edges.filter((edge) => !ids.includes(edge.id)),
|
edges: state.edges.filter((edge) => !ids.includes(edge.id)),
|
||||||
}));
|
}));
|
||||||
|
|
@ -253,6 +395,7 @@ export const useFlowEditorStore = create<FlowEditorState>((set, get) => ({
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|
||||||
loadFlow: (id, name, description, nodes, edges) => {
|
loadFlow: (id, name, description, nodes, edges) => {
|
||||||
|
console.log("📂 플로우 로드:", { id, name, 노드수: nodes.length, 엣지수: edges.length });
|
||||||
set({
|
set({
|
||||||
flowId: id,
|
flowId: id,
|
||||||
flowName: name,
|
flowName: name,
|
||||||
|
|
@ -261,10 +404,14 @@ export const useFlowEditorStore = create<FlowEditorState>((set, get) => ({
|
||||||
edges,
|
edges,
|
||||||
selectedNodes: [],
|
selectedNodes: [],
|
||||||
selectedEdges: [],
|
selectedEdges: [],
|
||||||
|
// 로드된 상태를 히스토리의 첫 번째 스냅샷으로 저장
|
||||||
|
history: [{ nodes: JSON.parse(JSON.stringify(nodes)), edges: JSON.parse(JSON.stringify(edges)) }],
|
||||||
|
historyIndex: 0,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
clearFlow: () => {
|
clearFlow: () => {
|
||||||
|
console.log("🔄 플로우 초기화");
|
||||||
set({
|
set({
|
||||||
flowId: null,
|
flowId: null,
|
||||||
flowName: "새 제어 플로우",
|
flowName: "새 제어 플로우",
|
||||||
|
|
@ -274,6 +421,8 @@ export const useFlowEditorStore = create<FlowEditorState>((set, get) => ({
|
||||||
selectedNodes: [],
|
selectedNodes: [],
|
||||||
selectedEdges: [],
|
selectedEdges: [],
|
||||||
validationResult: null,
|
validationResult: null,
|
||||||
|
history: [{ nodes: [], edges: [] }], // 초기 빈 상태를 히스토리에 저장
|
||||||
|
historyIndex: 0,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue