From 8046c2a2e0058bbc9e0c227ba0bc62048d41626b Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 13 Oct 2025 13:28:20 +0900 Subject: [PATCH] =?UTF-8?q?=EB=90=98=EB=8F=8C=EB=A6=AC=EA=B8=B0=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dataflow/node-editor/FlowEditor.tsx | 28 +++- .../dataflow/node-editor/FlowToolbar.tsx | 8 +- frontend/lib/stores/flowEditorStore.ts | 149 ++++++++++++++++++ 3 files changed, 181 insertions(+), 4 deletions(-) diff --git a/frontend/components/dataflow/node-editor/FlowEditor.tsx b/frontend/components/dataflow/node-editor/FlowEditor.tsx index 62211179..43da1205 100644 --- a/frontend/components/dataflow/node-editor/FlowEditor.tsx +++ b/frontend/components/dataflow/node-editor/FlowEditor.tsx @@ -60,11 +60,14 @@ function FlowEditorInner() { onNodesChange, onEdgesChange, onConnect, + onNodeDragStart, addNode, showPropertiesPanel, selectNodes, selectedNodes, removeNodes, + undo, + redo, } = useFlowEditorStore(); /** @@ -80,17 +83,37 @@ function FlowEditorInner() { ); /** - * 키보드 이벤트 핸들러 (Delete/Backspace 키로 노드 삭제) + * 키보드 이벤트 핸들러 (Delete/Backspace 키로 노드 삭제, Ctrl+Z/Y로 Undo/Redo) */ const onKeyDown = useCallback( (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) { event.preventDefault(); console.log("🗑️ 선택된 노드 삭제:", selectedNodes); removeNodes(selectedNodes); } }, - [selectedNodes, removeNodes], + [selectedNodes, removeNodes, undo, redo], ); /** @@ -170,6 +193,7 @@ function FlowEditorInner() { onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} onConnect={onConnect} + onNodeDragStart={onNodeDragStart} onSelectionChange={onSelectionChange} onDragOver={onDragOver} onDrop={onDrop} diff --git a/frontend/components/dataflow/node-editor/FlowToolbar.tsx b/frontend/components/dataflow/node-editor/FlowToolbar.tsx index 183b7090..7bcb9443 100644 --- a/frontend/components/dataflow/node-editor/FlowToolbar.tsx +++ b/frontend/components/dataflow/node-editor/FlowToolbar.tsx @@ -25,6 +25,10 @@ export function FlowToolbar() { isSaving, selectedNodes, removeNodes, + undo, + redo, + canUndo, + canRedo, } = useFlowEditorStore(); const [showLoadDialog, setShowLoadDialog] = useState(false); @@ -108,10 +112,10 @@ export function FlowToolbar() {
{/* 실행 취소/다시 실행 */} - - diff --git a/frontend/lib/stores/flowEditorStore.ts b/frontend/lib/stores/flowEditorStore.ts index 7fb928fa..e5f66bd4 100644 --- a/frontend/lib/stores/flowEditorStore.ts +++ b/frontend/lib/stores/flowEditorStore.ts @@ -13,11 +13,22 @@ interface ExternalConnectionCache { timestamp: number; } +// 🔥 히스토리 스냅샷 타입 +interface HistorySnapshot { + nodes: FlowNode[]; + edges: FlowEdge[]; +} + interface FlowEditorState { // 노드 및 엣지 nodes: FlowNode[]; edges: FlowEdge[]; + // 🔥 히스토리 관리 + history: HistorySnapshot[]; + historyIndex: number; + maxHistorySize: number; + // 선택 상태 selectedNodes: string[]; selectedEdges: string[]; @@ -39,12 +50,23 @@ interface FlowEditorState { // 🔥 외부 커넥션 캐시 (전역 캐싱) externalConnectionsCache: ExternalConnectionCache | null; + // ======================================================================== + // 🔥 히스토리 관리 (Undo/Redo) + // ======================================================================== + + saveToHistory: () => void; + undo: () => void; + redo: () => void; + canUndo: () => boolean; + canRedo: () => boolean; + // ======================================================================== // 노드 관리 // ======================================================================== setNodes: (nodes: FlowNode[]) => void; onNodesChange: (changes: NodeChange[]) => void; + onNodeDragStart: () => void; // 노드 드래그 시작 시 히스토리 저장 (변경 전 상태) addNode: (node: FlowNode) => void; updateNode: (id: string, data: Partial) => void; removeNode: (id: string) => void; @@ -117,6 +139,9 @@ export const useFlowEditorStore = create((set, get) => ({ // 초기 상태 nodes: [], edges: [], + history: [{ nodes: [], edges: [] }], // 초기 빈 상태를 히스토리에 저장 + historyIndex: 0, + maxHistorySize: 50, selectedNodes: [], selectedEdges: [], flowId: null, @@ -129,6 +154,103 @@ export const useFlowEditorStore = create((set, get) => ({ validationResult: 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((set, get) => ({ }); }, + onNodeDragStart: () => { + // 노드 드래그 시작 시 히스토리 저장 (변경 전 상태) + get().saveToHistory(); + console.log("🎯 노드 이동 시작, 변경 전 상태 히스토리 저장"); + }, + addNode: (node) => { + get().saveToHistory(); // 히스토리에 저장 set((state) => ({ nodes: [...state.nodes, node], })); }, updateNode: (id, data) => { + get().saveToHistory(); // 히스토리에 저장 set((state) => ({ nodes: state.nodes.map((node) => node.id === id @@ -161,6 +291,7 @@ export const useFlowEditorStore = create((set, get) => ({ }, removeNode: (id) => { + get().saveToHistory(); // 히스토리에 저장 set((state) => ({ nodes: state.nodes.filter((node) => node.id !== id), edges: state.edges.filter((edge) => edge.source !== id && edge.target !== id), @@ -168,6 +299,7 @@ export const useFlowEditorStore = create((set, get) => ({ }, removeNodes: (ids) => { + get().saveToHistory(); // 히스토리에 저장 set((state) => ({ nodes: state.nodes.filter((node) => !ids.includes(node.id)), edges: state.edges.filter((edge) => !ids.includes(edge.source) && !ids.includes(edge.target)), @@ -181,12 +313,20 @@ export const useFlowEditorStore = create((set, get) => ({ setEdges: (edges) => set({ edges }), onEdgesChange: (changes) => { + // 엣지 삭제(remove) 타입이 있으면 히스토리 저장 + const hasRemove = changes.some((change) => change.type === "remove"); + if (hasRemove) { + get().saveToHistory(); + console.log("🔗 엣지 삭제, 변경 전 상태 히스토리 저장"); + } + set({ edges: applyEdgeChanges(changes, get().edges) as FlowEdge[], }); }, onConnect: (connection) => { + get().saveToHistory(); // 히스토리에 저장 // 연결 검증 const validation = validateConnection(connection, get().nodes); if (!validation.valid) { @@ -210,12 +350,14 @@ export const useFlowEditorStore = create((set, get) => ({ }, removeEdge: (id) => { + get().saveToHistory(); // 히스토리에 저장 set((state) => ({ edges: state.edges.filter((edge) => edge.id !== id), })); }, removeEdges: (ids) => { + get().saveToHistory(); // 히스토리에 저장 set((state) => ({ edges: state.edges.filter((edge) => !ids.includes(edge.id)), })); @@ -253,6 +395,7 @@ export const useFlowEditorStore = create((set, get) => ({ // ======================================================================== loadFlow: (id, name, description, nodes, edges) => { + console.log("📂 플로우 로드:", { id, name, 노드수: nodes.length, 엣지수: edges.length }); set({ flowId: id, flowName: name, @@ -261,10 +404,14 @@ export const useFlowEditorStore = create((set, get) => ({ edges, selectedNodes: [], selectedEdges: [], + // 로드된 상태를 히스토리의 첫 번째 스냅샷으로 저장 + history: [{ nodes: JSON.parse(JSON.stringify(nodes)), edges: JSON.parse(JSON.stringify(edges)) }], + historyIndex: 0, }); }, clearFlow: () => { + console.log("🔄 플로우 초기화"); set({ flowId: null, flowName: "새 제어 플로우", @@ -274,6 +421,8 @@ export const useFlowEditorStore = create((set, get) => ({ selectedNodes: [], selectedEdges: [], validationResult: null, + history: [{ nodes: [], edges: [] }], // 초기 빈 상태를 히스토리에 저장 + historyIndex: 0, }); },