/** * 트리 구조 메모장 JavaScript */ // Monaco Editor 인스턴스 let monacoEditor = null; // 트리 메모장 Alpine.js 컴포넌트 window.memoTreeApp = function() { return { // 상태 관리 currentUser: null, userTrees: [], selectedTreeId: '', selectedTree: null, treeNodes: [], selectedNode: null, // UI 상태 showNewTreeModal: false, showNewNodeModal: false, showTreeSettings: false, showLoginModal: false, // 로그인 폼 상태 loginForm: { email: '', password: '' }, loginError: '', loginLoading: false, // 폼 데이터 newTree: { title: '', description: '', tree_type: 'general' }, newNode: { title: '', node_type: 'memo', parent_id: null }, // 에디터 상태 editorContent: '', isEditorDirty: false, // 트리 상태 expandedNodes: new Set(), // 트리 다이어그램 상태 treeZoom: 1, treePanX: 0, treePanY: 0, nodePositions: new Map(), // 노드 ID -> {x, y} 위치 매핑 isDragging: false, dragNode: null, dragOffset: { x: 0, y: 0 }, // 초기화 async init() { console.log('🌳 트리 메모장 초기화 중...'); // API 객체가 로드될 때까지 대기 (더 긴 시간) let retries = 0; while ((!window.api || typeof window.api.getUserMemoTrees !== 'function') && retries < 50) { console.log(`⏳ API 객체 로딩 대기 중... (${retries + 1}/50)`); await new Promise(resolve => setTimeout(resolve, 100)); retries++; } if (!window.api || typeof window.api.getUserMemoTrees !== 'function') { console.error('❌ API 객체 또는 getUserMemoTrees 함수를 로드할 수 없습니다.'); console.log('현재 window.api:', window.api); if (window.api) { console.log('API 객체의 메서드들:', Object.getOwnPropertyNames(window.api)); } return; } try { await this.checkAuthStatus(); if (this.currentUser) { await this.loadUserTrees(); await this.initMonacoEditor(); } } catch (error) { console.error('❌ 초기화 실패:', error); } }, // 인증 상태 확인 async checkAuthStatus() { try { const user = await window.api.getCurrentUser(); this.currentUser = user; console.log('✅ 사용자 인증됨:', user.email); } catch (error) { console.log('❌ 인증되지 않음:', error.message); this.currentUser = null; // 토큰이 있지만 만료된 경우 제거 if (localStorage.getItem('access_token')) { console.log('🗑️ 만료된 토큰 제거'); localStorage.removeItem('access_token'); localStorage.removeItem('refresh_token'); window.api.clearToken(); } } }, // 사용자 트리 목록 로드 async loadUserTrees() { try { console.log('📊 사용자 트리 목록 로딩...'); const trees = await window.api.getUserMemoTrees(); this.userTrees = trees || []; console.log(`✅ ${this.userTrees.length}개 트리 로드 완료`); } catch (error) { console.error('❌ 트리 목록 로드 실패:', error); this.userTrees = []; } }, // 트리 로드 async loadTree(treeId) { if (!treeId) { this.selectedTree = null; this.treeNodes = []; this.selectedNode = null; return; } try { console.log('🌳 트리 로딩:', treeId); // 트리 정보 로드 const tree = this.userTrees.find(t => t.id === treeId); this.selectedTree = tree; // 트리 노드들 로드 const nodes = await window.api.getMemoTreeNodes(treeId); this.treeNodes = nodes || []; // 첫 번째 노드 선택 (있다면) if (this.treeNodes.length > 0) { this.selectNode(this.treeNodes[0]); } // 트리 다이어그램 위치 계산 this.$nextTick(() => { setTimeout(() => { this.calculateNodePositions(); }, 100); }); console.log(`✅ 트리 로드 완료: ${this.treeNodes.length}개 노드`); } catch (error) { console.error('❌ 트리 로드 실패:', error); alert('트리를 불러오는 중 오류가 발생했습니다.'); } }, // 트리 생성 async createTree() { try { console.log('🌳 새 트리 생성:', this.newTree); const tree = await window.api.createMemoTree(this.newTree); // 트리 목록에 추가 this.userTrees.push(tree); // 새 트리 선택 this.selectedTreeId = tree.id; await this.loadTree(tree.id); // 모달 닫기 및 폼 리셋 this.showNewTreeModal = false; this.newTree = { title: '', description: '', tree_type: 'general' }; console.log('✅ 트리 생성 완료'); } catch (error) { console.error('❌ 트리 생성 실패:', error); alert('트리 생성 중 오류가 발생했습니다.'); } }, // 루트 노드 생성 async createRootNode() { if (!this.selectedTree) return; try { const nodeData = { tree_id: this.selectedTree.id, title: '새 노드', node_type: 'memo', parent_id: null }; const node = await window.api.createMemoNode(nodeData); this.treeNodes.push(node); this.selectNode(node); console.log('✅ 루트 노드 생성 완료'); } catch (error) { console.error('❌ 루트 노드 생성 실패:', error); alert('노드 생성 중 오류가 발생했습니다.'); } }, // 노드 선택 selectNode(node) { // 이전 노드 저장 if (this.selectedNode && this.isEditorDirty) { this.saveNode(); } this.selectedNode = node; // 에디터에 내용 로드 if (monacoEditor && node.content) { monacoEditor.setValue(node.content || ''); this.isEditorDirty = false; } console.log('📝 노드 선택:', node.title); }, // 노드 저장 async saveNode() { if (!this.selectedNode) return; try { // 에디터 내용 가져오기 if (monacoEditor) { this.selectedNode.content = monacoEditor.getValue(); // 단어 수 계산 (간단한 방식) const wordCount = this.selectedNode.content .replace(/\s+/g, ' ') .trim() .split(' ') .filter(word => word.length > 0).length; this.selectedNode.word_count = wordCount; } await window.api.updateMemoNode(this.selectedNode.id, this.selectedNode); this.isEditorDirty = false; console.log('✅ 노드 저장 완료'); } catch (error) { console.error('❌ 노드 저장 실패:', error); alert('노드 저장 중 오류가 발생했습니다.'); } }, // 노드 제목 저장 async saveNodeTitle() { if (!this.selectedNode) return; await this.saveNode(); }, // 노드 타입 저장 async saveNodeType() { if (!this.selectedNode) return; await this.saveNode(); }, // 노드 상태 저장 async saveNodeStatus() { if (!this.selectedNode) return; await this.saveNode(); }, // 노드 삭제 async deleteNode(nodeId) { if (!confirm('정말로 이 노드를 삭제하시겠습니까?')) return; try { await window.api.deleteMemoNode(nodeId); // 트리에서 제거 this.treeNodes = this.treeNodes.filter(node => node.id !== nodeId); // 선택된 노드였다면 선택 해제 if (this.selectedNode && this.selectedNode.id === nodeId) { this.selectedNode = null; if (monacoEditor) { monacoEditor.setValue(''); } } console.log('✅ 노드 삭제 완료'); } catch (error) { console.error('❌ 노드 삭제 실패:', error); alert('노드 삭제 중 오류가 발생했습니다.'); } }, // 자식 노드 가져오기 getChildNodes(parentId) { return this.treeNodes .filter(node => node.parent_id === parentId) .sort((a, b) => a.sort_order - b.sort_order); }, // 루트 노드들 가져오기 get rootNodes() { return this.treeNodes .filter(node => !node.parent_id) .sort((a, b) => a.sort_order - b.sort_order); }, // 노드 타입별 아이콘 가져오기 getNodeIcon(nodeType) { const icons = { folder: '📁', memo: '📝', chapter: '📖', character: '👤', plot: '📋' }; return icons[nodeType] || '📝'; }, // 상태별 색상 클래스 가져오기 getStatusColor(status) { const colors = { draft: 'text-gray-500', writing: 'text-yellow-600', review: 'text-blue-600', complete: 'text-green-600' }; return colors[status] || 'text-gray-700'; }, // 트리 타입별 아이콘 가져오기 getTreeIcon(treeType) { const icons = { novel: '📚', research: '🔬', project: '💼', general: '📂' }; return icons[treeType] || '📂'; }, // 빠른 자식 노드 추가 async addChildNode(parentNode) { if (!this.selectedTree) return; try { const nodeData = { tree_id: this.selectedTree.id, title: '새 노드', node_type: 'memo', parent_id: parentNode.id }; const node = await window.api.createMemoNode(nodeData); this.treeNodes.push(node); // 부모 노드 펼치기 this.expandedNodes.add(parentNode.id); // 새 노드 선택 this.selectNode(node); console.log('✅ 자식 노드 생성 완료'); } catch (error) { console.error('❌ 자식 노드 생성 실패:', error); alert('노드 생성 중 오류가 발생했습니다.'); } }, // 컨텍스트 메뉴 표시 showContextMenu(event, node) { // TODO: 우클릭 컨텍스트 메뉴 구현 console.log('컨텍스트 메뉴:', node.title); }, // 노드 메뉴 표시 showNodeMenu(event, node) { // TODO: 노드 옵션 메뉴 구현 console.log('노드 메뉴:', node.title); }, // 정사 경로 토글 async toggleCanonical(node) { try { const newCanonicalState = !node.is_canonical; console.log(`🌟 정사 경로 토글: ${node.title} (${newCanonicalState ? '설정' : '해제'})`); const updatedData = { is_canonical: newCanonicalState }; const updatedNode = await window.api.updateMemoNode(node.id, updatedData); // 로컬 상태 업데이트 (Alpine.js 반응성 보장) const nodeIndex = this.treeNodes.findIndex(n => n.id === node.id); if (nodeIndex !== -1) { // 배열 전체를 새로 생성하여 Alpine.js 반응성 트리거 this.treeNodes = this.treeNodes.map(n => n.id === node.id ? { ...n, ...updatedNode } : n ); } // 선택된 노드도 업데이트 if (this.selectedNode && this.selectedNode.id === node.id) { this.selectedNode = { ...this.selectedNode, ...updatedNode }; } // 강제 리렌더링을 위한 더미 업데이트 this.treeNodes = [...this.treeNodes]; // 트리 다시 그리기 (연결선 업데이트) this.$nextTick(() => { this.calculateNodePositions(); }); console.log(`✅ 정사 경로 ${newCanonicalState ? '설정' : '해제'} 완료`); console.log('📊 업데이트된 노드:', updatedNode); console.log('🔍 is_canonical 값:', updatedNode.is_canonical); console.log('🔍 canonical_order 값:', updatedNode.canonical_order); console.log('🔄 현재 treeNodes 개수:', this.treeNodes.length); // 업데이트된 노드 찾기 const updatedNodeInArray = this.treeNodes.find(n => n.id === node.id); console.log('🔍 배열 내 업데이트된 노드:', updatedNodeInArray?.is_canonical); } catch (error) { console.error('❌ 정사 경로 토글 실패:', error); alert('정사 경로 설정 중 오류가 발생했습니다.'); } }, // 노드 드래그 시작 startDragNode(event, node) { // 현재는 드래그 기능 비활성화 (패닝과 충돌 방지) event.stopPropagation(); console.log('드래그 시작:', node.title); // TODO: 노드 드래그 앤 드롭 구현 }, // 노드 인라인 편집 editNodeInline(node) { // 더블클릭 시 노드 선택하고 에디터로 포커스 this.selectNode(node); // 에디터가 있다면 포커스 this.$nextTick(() => { const editorContainer = document.getElementById('editor-container'); if (editorContainer && this.monacoEditor) { this.monacoEditor.focus(); } }); console.log('인라인 편집:', node.title); }, // 노드 위치 계산 및 반환 getNodePosition(node) { if (!this.nodePositions.has(node.id)) { this.calculateNodePositions(); } const pos = this.nodePositions.get(node.id) || { x: 0, y: 0 }; return `left: ${pos.x}px; top: ${pos.y}px;`; }, // 트리 노드 위치 자동 계산 calculateNodePositions() { const canvas = document.getElementById('tree-canvas'); if (!canvas) return; const canvasWidth = canvas.clientWidth; const canvasHeight = canvas.clientHeight; // 노드 크기 설정 const nodeWidth = 200; const nodeHeight = 80; const levelHeight = 150; // 레벨 간 간격 const nodeSpacing = 50; // 노드 간 간격 // 레벨별 노드 그룹화 const levels = new Map(); // 루트 노드들 찾기 const rootNodes = this.treeNodes.filter(node => !node.parent_id); // BFS로 레벨별 노드 배치 const queue = []; rootNodes.forEach(node => { queue.push({ node, level: 0 }); }); while (queue.length > 0) { const { node, level } = queue.shift(); if (!levels.has(level)) { levels.set(level, []); } levels.get(level).push(node); // 자식 노드들을 다음 레벨에 추가 const children = this.getChildNodes(node.id); children.forEach(child => { queue.push({ node: child, level: level + 1 }); }); } // 각 레벨의 노드들 위치 계산 levels.forEach((nodes, level) => { const y = 100 + level * levelHeight; // 상단 여백 + 레벨 * 높이 const totalWidth = nodes.length * nodeWidth + (nodes.length - 1) * nodeSpacing; const startX = (canvasWidth - totalWidth) / 2; nodes.forEach((node, index) => { const x = startX + index * (nodeWidth + nodeSpacing); this.nodePositions.set(node.id, { x, y }); }); }); // 연결선 다시 그리기 this.drawConnections(); }, // SVG 연결선 그리기 drawConnections() { const svg = document.getElementById('tree-connections'); if (!svg) return; // 기존 연결선 제거 svg.innerHTML = ''; // 각 노드의 자식들과 연결선 그리기 this.treeNodes.forEach(node => { const children = this.getChildNodes(node.id); if (children.length === 0) return; const parentPos = this.nodePositions.get(node.id); if (!parentPos) return; children.forEach(child => { const childPos = this.nodePositions.get(child.id); if (!childPos) return; // 연결선 생성 const line = document.createElementNS('http://www.w3.org/2000/svg', 'path'); // 부모 노드 하단 중앙에서 시작 const startX = parentPos.x + 100; // 노드 중앙 const startY = parentPos.y + 80; // 노드 하단 // 자식 노드 상단 중앙으로 연결 const endX = childPos.x + 100; // 노드 중앙 const endY = childPos.y; // 노드 상단 // 곡선 경로 생성 (베지어 곡선) const midY = startY + (endY - startY) / 2; const path = `M ${startX} ${startY} C ${startX} ${midY} ${endX} ${midY} ${endX} ${endY}`; line.setAttribute('d', path); line.setAttribute('stroke', '#9CA3AF'); line.setAttribute('stroke-width', '2'); line.setAttribute('fill', 'none'); line.setAttribute('marker-end', 'url(#arrowhead)'); svg.appendChild(line); }); }); // 화살표 마커 추가 (한 번만) if (!svg.querySelector('#arrowhead')) { const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs'); const marker = document.createElementNS('http://www.w3.org/2000/svg', 'marker'); marker.setAttribute('id', 'arrowhead'); marker.setAttribute('markerWidth', '10'); marker.setAttribute('markerHeight', '7'); marker.setAttribute('refX', '9'); marker.setAttribute('refY', '3.5'); marker.setAttribute('orient', 'auto'); const polygon = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); polygon.setAttribute('points', '0 0, 10 3.5, 0 7'); polygon.setAttribute('fill', '#9CA3AF'); marker.appendChild(polygon); defs.appendChild(marker); svg.appendChild(defs); } }, // 트리 중앙 정렬 centerTree() { this.treePanX = 0; this.treePanY = 0; this.treeZoom = 1; }, // 확대 zoomIn() { this.treeZoom = Math.min(this.treeZoom * 1.2, 3); }, // 축소 zoomOut() { this.treeZoom = Math.max(this.treeZoom / 1.2, 0.3); }, // 휠 이벤트 처리 (확대/축소) handleWheel(event) { event.preventDefault(); if (event.deltaY < 0) { this.zoomIn(); } else { this.zoomOut(); } }, // 패닝 시작 startPan(event) { if (event.target.closest('.tree-diagram-node')) return; // 노드 클릭 시 패닝 방지 this.isPanning = true; this.panStartX = event.clientX - this.treePanX; this.panStartY = event.clientY - this.treePanY; document.addEventListener('mousemove', this.handlePan.bind(this)); document.addEventListener('mouseup', this.stopPan.bind(this)); }, // 패닝 처리 handlePan(event) { if (!this.isPanning) return; this.treePanX = event.clientX - this.panStartX; this.treePanY = event.clientY - this.panStartY; }, // 패닝 종료 stopPan() { this.isPanning = false; document.removeEventListener('mousemove', this.handlePan); document.removeEventListener('mouseup', this.stopPan); }, // 재귀적 트리 노드 렌더링 renderTreeNodeRecursive(node, depth, isLast = false, parentPath = []) { const hasChildren = this.getChildNodes(node.id).length > 0; const isExpanded = this.expandedNodes.has(node.id); const isSelected = this.selectedNode && this.selectedNode.id === node.id; const isRoot = depth === 0; // 상태별 색상 let statusClass = 'text-gray-700'; switch(node.status) { case 'draft': statusClass = 'text-gray-500'; break; case 'writing': statusClass = 'text-yellow-600'; break; case 'review': statusClass = 'text-blue-600'; break; case 'complete': statusClass = 'text-green-600'; break; } // 트리 라인 생성 let treeLines = ''; for (let i = 0; i < depth; i++) { if (i < parentPath.length && parentPath[i]) { // 부모가 마지막이 아니면 세로선 표시 treeLines += ''; } else { // 빈 공간 treeLines += ''; } } // 현재 노드의 연결선 let nodeConnector = ''; if (!isRoot) { if (isLast) { nodeConnector = ''; // └─ } else { nodeConnector = ''; // ├─ } } let html = `