Skip to content
This repository was archived by the owner on Mar 18, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/app/(canvas)/assets/Node.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,12 @@
.paramKey {
color: #6b7280;
margin-right: 8px;

&.required::after {
content: " *";
color: #ef4444;
font-weight: bold;
}
}

.paramValue {
Expand Down
73 changes: 70 additions & 3 deletions src/app/(canvas)/components/Canvas.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ const Canvas = forwardRef(({ onStateChange }, ref) => {
const [portPositions, setPortPositions] = useState({});
const [snappedPortKey, setSnappedPortKey] = useState(null);
const [isSnapTargetValid, setIsSnapTargetValid] = useState(true);
const [copiedNode, setCopiedNode] = useState(null); // 복사된 노드 저장

const nodesRef = useRef(nodes);
const edgePreviewRef = useRef(edgePreview);
Expand All @@ -81,13 +82,17 @@ const Canvas = forwardRef(({ onStateChange }, ref) => {
setPortPositions(newPortPositions);
}, [nodes, view.scale]);

// 상태 변경 감지 및 콜백 호출


useEffect(() => {
if (onStateChange && (nodes.length > 0 || edges.length > 0)) {
if (onStateChange) {
const currentState = { view, nodes, edges };
onStateChange(currentState);
}
}, [nodes, edges, onStateChange]);
}, [nodes, edges, view, onStateChange]);

// 키보드 이벤트 리스너 등록


const registerPortRef = useCallback((nodeId, portId, portType, el) => {
const key = `${nodeId}__PORTKEYDELIM__${portId}__PORTKEYDELIM__${portType}`;
Expand Down Expand Up @@ -147,6 +152,36 @@ const Canvas = forwardRef(({ onStateChange }, ref) => {
return Math.sqrt(Math.pow(pos1.x - pos2.x, 2) + Math.pow(pos1.y - pos2.y, 2));
};

const copySelectedNode = () => {
if (selectedNodeId) {
const nodeToCopy = nodes.find(node => node.id === selectedNodeId);
if (nodeToCopy) {
setCopiedNode(nodeToCopy);
console.log('Node copied:', nodeToCopy.data.nodeName);
}
}
};

const pasteNode = () => {
if (copiedNode) {
const newNode = {
...copiedNode,
id: `${copiedNode.data.id}-${Date.now()}`, // 새로운 고유 ID 생성
position: {
x: copiedNode.position.x + 50, // 약간 오프셋을 주어 겹치지 않게
y: copiedNode.position.y + 50
}
};

setNodes(prev => [...prev, newNode]);
setSelectedNodeId(newNode.id); // 새로 생성된 노드를 선택
console.log('Node pasted:', newNode.data.nodeName);
}
};

// 키보드 이벤트 핸들러


const handleParameterChange = useCallback((nodeId, paramId, value) => {
setNodes(prevNodes =>
prevNodes.map(node => {
Expand Down Expand Up @@ -239,6 +274,22 @@ const Canvas = forwardRef(({ onStateChange }, ref) => {
}
};

const handleKeyDown = useCallback((e) => {
if (e.ctrlKey && e.key === 'c') {
e.preventDefault();
copySelectedNode();
}
else if (e.ctrlKey && e.key === 'v') {
e.preventDefault();
pasteNode();
}
else if (e.key === 'Delete' && selectedNodeId) {
e.preventDefault();
setNodes(prev => prev.filter(node => node.id !== selectedNodeId));
setSelectedNodeId(null);
}
}, [selectedNodeId, copiedNode, nodes]);

const handleNodeMouseDown = useCallback((e, nodeId) => {
if (e.button !== 0) return;
setSelectedNodeId(nodeId);
Expand Down Expand Up @@ -413,6 +464,19 @@ const Canvas = forwardRef(({ onStateChange }, ref) => {
return () => container.removeEventListener('wheel', handleWheel);
}, []);

useEffect(() => {
const container = containerRef.current;
if (container) {
container.addEventListener('keydown', handleKeyDown);
// 포커스를 받을 수 있도록 tabindex 설정
container.setAttribute('tabindex', '0');

return () => {
container.removeEventListener('keydown', handleKeyDown);
};
}
}, [handleKeyDown]);

useEffect(() => {
const container = containerRef.current;
const content = contentRef.current;
Expand Down Expand Up @@ -450,6 +514,9 @@ const Canvas = forwardRef(({ onStateChange }, ref) => {
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onClick={() => containerRef.current?.focus()} // 클릭 시 포커스
tabIndex={0} // 키보드 포커스 가능하도록
style={{ outline: 'none' }} // 포커스 아웃라인 제거
>
<div
ref={contentRef}
Expand Down
18 changes: 14 additions & 4 deletions src/app/(canvas)/components/Header.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,20 @@ import styles from '@/app/(canvas)/assets/Header.module.scss';
import { LuPanelRightOpen, LuSave, LuFolderOpen, LuCheck, LuX, LuPencil, LuDownload } from "react-icons/lu";
import { getWorkflowName, saveWorkflowName } from '@/app/services/workflowStorage';

const Header = ({ onMenuClick, onSave, onLoad, onExport }) => {
const Header = ({ onMenuClick, onSave, onLoad, onExport, workflowName: externalWorkflowName, onWorkflowNameChange }) => {
const [workflowName, setWorkflowName] = useState('Workflow');
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState('');
const inputRef = useRef(null);

useEffect(() => {
const savedName = getWorkflowName();
setWorkflowName(savedName);
}, []);
if (externalWorkflowName) {
setWorkflowName(externalWorkflowName);
} else {
const savedName = getWorkflowName();
setWorkflowName(savedName);
}
}, [externalWorkflowName]);

useEffect(() => {
if (isEditing && inputRef.current) {
Expand All @@ -31,6 +35,12 @@ const Header = ({ onMenuClick, onSave, onLoad, onExport }) => {
const finalValue = trimmedValue || 'Workflow';
setWorkflowName(finalValue);
saveWorkflowName(finalValue);

// 부모 컴포넌트에 변경사항 알림
if (onWorkflowNameChange) {
onWorkflowNameChange(finalValue);
}

setIsEditing(false);
};

Expand Down
4 changes: 3 additions & 1 deletion src/app/(canvas)/components/Node.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,9 @@ const Node = ({ id, data, position, onNodeMouseDown, isSelected, onPortMouseDown
<div className={styles.sectionHeader}>PARAMETER</div>
{parameters.map(param => (
<div key={param.id} className={styles.param}>
<span className={styles.paramKey}>{param.name}</span>
<span className={`${styles.paramKey} ${param.required ? styles.required : ''}`}>
{param.name}
</span>
{param.options && param.options.length > 0 ? (
<select
value={param.value}
Expand Down
3 changes: 2 additions & 1 deletion src/app/(canvas)/components/WorkflowPanel.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ const WorkflowPanel = ({ onBack, onLoad, onExport, onLoadWorkflow }) => {
const workflowData = await loadWorkflow(workflowId);

if (onLoadWorkflow) {
onLoadWorkflow(workflowData);
// 워크플로우 데이터와 함께 워크플로우 이름도 전달
onLoadWorkflow(workflowData, workflowId);
}
} catch (error) {
console.error("Failed to load workflow:", error);
Expand Down
58 changes: 47 additions & 11 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import ExecutionPanel from '@/app/(canvas)/components/ExecutionPanel';
import styles from '@/app/(canvas)/assets/PlateeRAG.module.scss';

import { executeWorkflow, saveWorkflow, listWorkflows } from '@/app/api/components/nodeApi';
import { getWorkflowName, getWorkflowState, saveWorkflowState, clearWorkflowState, isValidWorkflowState } from '@/app/services/workflowStorage';
import { getWorkflowName, getWorkflowState, saveWorkflowState, clearWorkflowState, isValidWorkflowState, ensureValidWorkflowState, saveWorkflowName } from '@/app/services/workflowStorage';

export default function Home() {
const [isMenuOpen, setIsMenuOpen] = useState(false);
Expand All @@ -20,15 +20,24 @@ export default function Home() {

const [executionOutput, setExecutionOutput] = useState<any>(null);
const [isExecuting, setIsExecuting] = useState(false);
const [currentWorkflowName, setCurrentWorkflowName] = useState('Workflow');

// 컴포넌트 마운트 시 저장된 워크플로우 상태 복원
// 컴포넌트 마운트 시 워크플로우 이름과 상태 복원
useEffect(() => {
// 저장된 워크플로우 이름 복원
const savedName = getWorkflowName();
setCurrentWorkflowName(savedName);

// 저장된 워크플로우 상태 복원
const restoreWorkflowState = () => {
const savedState = getWorkflowState();
if (savedState && isValidWorkflowState(savedState) && canvasRef.current) {
if (savedState && canvasRef.current) {
try {
(canvasRef.current as any).loadWorkflowState(savedState);
console.log('Workflow state restored from localStorage');
const validState = ensureValidWorkflowState(savedState);
if (validState) {
(canvasRef.current as any).loadWorkflowState(validState);
console.log('Workflow state restored from localStorage', validState);
}
} catch (error) {
console.warn('Failed to restore workflow state:', error);
}
Expand All @@ -43,14 +52,27 @@ export default function Home() {
// 워크플로우 상태 변경 시 자동 저장
const handleCanvasStateChange = (state: any) => {
try {
if (state && (state.nodes?.length > 0 || state.edges?.length > 0)) {
// 상태가 있으면 저장 (view 정보도 포함)
if (state) {
saveWorkflowState(state);
}
} catch (error) {
console.warn('Failed to auto-save workflow state:', error);
}
};

// 워크플로우 이름 업데이트 헬퍼 함수
const updateWorkflowName = (newName: string) => {
setCurrentWorkflowName(newName);
saveWorkflowName(newName);
};

// Header에서 워크플로우 이름 직접 편집 시 호출될 핸들러
const handleWorkflowNameChange = (newName: string) => {
setCurrentWorkflowName(newName);
// localStorage 저장은 Header에서 이미 처리하므로 중복 저장 방지
};

useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !(menuRef.current as any).contains(event.target)) {
Expand Down Expand Up @@ -221,12 +243,19 @@ export default function Home() {
fileInputRef.current?.click();
};

const handleLoadWorkflow = async (workflowData: any) => {
const handleLoadWorkflow = async (workflowData: any, workflowName?: string) => {
try {
if (canvasRef.current) {
(canvasRef.current as any).loadCanvasState(workflowData);
const validState = ensureValidWorkflowState(workflowData);
(canvasRef.current as any).loadCanvasState(validState);
// 새로운 워크플로우 로드 시 로컬 스토리지 상태 업데이트
saveWorkflowState(workflowData);
saveWorkflowState(validState);

// 워크플로우 이름이 제공된 경우 업데이트
if (workflowName) {
updateWorkflowName(workflowName);
}

toast.success('Workflow loaded successfully!');
}
} catch (error: any) {
Expand All @@ -245,9 +274,14 @@ export default function Home() {
const json = event.target?.result as string;
const savedState = JSON.parse(json);
if (canvasRef.current) {
(canvasRef.current as any).loadCanvasState(savedState);
const validState = ensureValidWorkflowState(savedState);
(canvasRef.current as any).loadCanvasState(validState);
// 파일에서 로드 시 로컬 스토리지 상태 업데이트
saveWorkflowState(savedState);
saveWorkflowState(validState);

// 파일명에서 워크플로우 이름 추출 (.json 확장자 제거)
const workflowName = file.name.replace(/\.json$/i, '');
updateWorkflowName(workflowName);
}
} catch (error) {
console.error("Error parsing JSON file:", error);
Expand Down Expand Up @@ -323,6 +357,8 @@ export default function Home() {
onSave={handleSave}
onLoad={handleLoadClick}
onExport={handleExport}
workflowName={currentWorkflowName}
onWorkflowNameChange={handleWorkflowNameChange}
/>
<main className={styles.mainContent}>
<Canvas ref={canvasRef} onStateChange={handleCanvasStateChange} />
Expand Down
35 changes: 28 additions & 7 deletions src/app/services/workflowStorage.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
// 워크플로우 이름 관련 유틸리티 함수들

const WORKFLOW_NAME_KEY = 'plateerag_workflow_name';
const WORKFLOW_STATE_KEY = 'plateerag_workflow_state';
const DEFAULT_WORKFLOW_NAME = 'Workflow';
Expand Down Expand Up @@ -73,12 +71,12 @@ export const saveWorkflowState = (state) => {
if (typeof window === 'undefined') return;

try {
// 상태가 비어있으면 저장하지 않음
if (!state || (!state.nodes && !state.edges)) {
// 상태가 없으면 저장하지 않음
if (!state) {
return;
}

// 상태를 JSON 문자열로 저장
// 상태를 JSON 문자열로 저장 (view 정보도 포함)
localStorage.setItem(WORKFLOW_STATE_KEY, JSON.stringify(state));
} catch (error) {
console.warn('Failed to save workflow state to localStorage:', error);
Expand Down Expand Up @@ -114,9 +112,32 @@ export const startNewWorkflow = () => {
export const isValidWorkflowState = (state) => {
if (!state || typeof state !== 'object') return false;

// 필수 속성들이 있는지 확인
// 필수 속성들이 있는지 확인 (view 정보도 포함)
return (
Array.isArray(state.nodes) &&
Array.isArray(state.edges)
Array.isArray(state.edges) &&
state.view &&
typeof state.view === 'object'
);
};

/**
* 워크플로우 상태에 기본 view 정보를 추가합니다
* @param {object} state - 워크플로우 상태
* @returns {object} view 정보가 포함된 완전한 상태
*/
export const ensureValidWorkflowState = (state) => {
if (!state) return null;

const defaultView = { x: 0, y: 0, scale: 1 };

return {
nodes: Array.isArray(state.nodes) ? state.nodes : [],
edges: Array.isArray(state.edges) ? state.edges : [],
view: (state.view && typeof state.view === 'object') ? {
x: typeof state.view.x === 'number' ? state.view.x : defaultView.x,
y: typeof state.view.y === 'number' ? state.view.y : defaultView.y,
scale: typeof state.view.scale === 'number' ? state.view.scale : defaultView.scale
} : defaultView
};
};
Loading