Files
document-server/frontend/static/js/memo-tree.js
Hyungi Ahn c5d09ed948 메모 트리 시스템 드래그 앤 드롭 기능 완성
�� 주요 기능 추가:
- 노드 드래그 앤 드롭으로 부모-자식 관계 변경
- 드래그 중 시각적 피드백 (투명도, 크기 변화)
- 드롭 대상 하이라이트 효과
- 실시간 노드 위치 추적 및 이동

🔧 기술적 구현:
- startDragNode, handleNodeDrag, endNodeDrag 함수 구현
- data-node-id 속성으로 노드 식별
- document.elementsFromPoint로 드롭 대상 감지
- moveNodeToParent API 호출로 실제 데이터 업데이트

🎨 UI/UX 개선:
- 드래그 중 노드 스타일 변경 (opacity, scale, z-index)
- 드롭 대상 하이라이트 CSS (.drop-target-highlight)
- 토스트 알림 시스템 추가
- 성공/실패 피드백 제공

📱 사용자 경험:
- 직관적인 드래그 앤 드롭 인터페이스
- 실시간 시각적 피드백
- 자동 트리 구조 업데이트
- 에러 처리 및 사용자 알림
2025-09-02 16:45:31 +09:00

1344 lines
52 KiB
JavaScript

/**
* 트리 구조 메모장 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,
showMobileEditModal: false,
// 알림 시스템
notification: {
show: false,
message: '',
type: 'info' // 'success', 'error', 'info'
},
// 로그인 폼 상태
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 },
// 로그인 관련 함수들
openLoginModal() {
this.showLoginModal = true;
},
async login() {
this.loginLoading = true;
this.loginError = '';
try {
// 실제 API 호출
const response = await window.api.login(this.loginForm.email, this.loginForm.password);
// 토큰 저장
window.api.setToken(response.access_token);
localStorage.setItem('refresh_token', response.refresh_token);
// 사용자 정보 가져오기
const userResponse = await window.api.getCurrentUser();
this.currentUser = userResponse;
// 헤더 사용자 메뉴 업데이트
if (typeof window.updateUserMenu === 'function') {
window.updateUserMenu(userResponse);
}
// 사용자 트리 목록 로드
await this.loadUserTrees();
// 모달 닫기
this.showLoginModal = false;
this.loginForm = { email: '', password: '' };
console.log('✅ 로그인 성공');
} catch (error) {
this.loginError = error.message || '로그인에 실패했습니다';
console.error('❌ 로그인 오류:', error);
} finally {
this.loginLoading = false;
}
},
async logout() {
try {
await window.api.logout();
} catch (error) {
console.error('Logout error:', error);
} finally {
// 로컬 스토리지 정리
localStorage.removeItem('refresh_token');
window.api.setToken(null);
// 상태 초기화
this.currentUser = null;
this.userTrees = [];
this.selectedTreeId = '';
this.selectedTree = null;
this.treeNodes = [];
this.selectedNode = null;
// 헤더 사용자 메뉴 업데이트
if (typeof window.updateUserMenu === 'function') {
window.updateUserMenu(null);
}
console.log('✅ 로그아웃 완료');
}
},
// 초기화
async init() {
console.log('🌳 트리 메모장 초기화 중...');
// 커스텀 이벤트 리스너 등록
document.addEventListener('open-login-modal', () => {
console.log('📨 open-login-modal 이벤트 수신');
this.openLoginModal();
});
// 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();
// 위치 계산 완료 후 중앙 정렬
setTimeout(() => {
this.centerTree();
}, 50);
}, 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) {
monacoEditor.setValue(node.content || '');
this.isEditorDirty = false;
}
// 모바일에서만 편집 모달 열기 (기존 동작에 추가)
if (window.innerWidth < 1024) { // lg 브레이크포인트
this.showMobileEditModal = true;
// 모바일 에디터 초기화 (약간의 지연 후)
setTimeout(() => {
this.initMobileEditor();
}, 100);
}
console.log('📝 노드 선택:', node.title);
},
// 모바일 에디터 초기화
initMobileEditor() {
if (!window.mobileMonacoEditor && this.selectedNode) {
const container = document.getElementById('mobile-editor-container');
if (container) {
window.mobileMonacoEditor = monaco.editor.create(container, {
value: this.selectedNode.content || '',
language: 'markdown',
theme: 'vs',
automaticLayout: true,
wordWrap: 'on',
minimap: { enabled: false },
scrollBeyondLastLine: false,
fontSize: 14,
lineNumbers: 'off',
folding: false,
lineDecorationsWidth: 0,
lineNumbersMinChars: 0,
glyphMargin: false
});
// 모바일 에디터 변경 감지
window.mobileMonacoEditor.onDidChangeModelContent(() => {
if (this.selectedNode) {
this.selectedNode.content = window.mobileMonacoEditor.getValue();
this.isEditorDirty = true;
}
});
console.log('📱 모바일 에디터 초기화 완료');
}
} else if (window.mobileMonacoEditor && this.selectedNode) {
// 기존 에디터가 있으면 내용만 업데이트
window.mobileMonacoEditor.setValue(this.selectedNode.content || '');
}
},
// 노드 저장
async saveNode() {
if (!this.selectedNode) return;
try {
// 에디터 내용 가져오기 (데스크톱 또는 모바일)
if (monacoEditor) {
this.selectedNode.content = monacoEditor.getValue();
} else if (window.mobileMonacoEditor) {
this.selectedNode.content = window.mobileMonacoEditor.getValue();
}
if (this.selectedNode.content !== undefined) {
// 단어 수 계산 (간단한 방식)
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] || '📝';
},
getNodeTypeLabel(nodeType) {
const labels = {
'memo': '메모',
'folder': '폴더',
'chapter': '챕터',
'character': '캐릭터',
'plot': '플롯'
};
return labels[nodeType] || '메모';
},
getStatusIcon(status) {
const icons = {
'draft': '📝',
'writing': '✍️',
'review': '👀',
'complete': '✅'
};
return icons[status] || '📝';
},
getStatusLabel(status) {
const labels = {
'draft': '초안',
'writing': '작성중',
'review': '검토중',
'complete': '완료'
};
return labels[status] || '초안';
},
// 상태별 색상 클래스 가져오기
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';
},
// 트리 타입별 아이콘 가져오기
// 알림 표시
showNotification(message, type = 'info') {
this.notification = {
show: true,
message: message,
type: type
};
// 3초 후 자동으로 숨김
setTimeout(() => {
this.notification.show = false;
}, 3000);
},
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();
event.preventDefault();
console.log('🎯 노드 드래그 시작:', node.title);
this.isDragging = true;
this.dragNode = node;
// 드래그 시작 위치 계산
const rect = event.target.getBoundingClientRect();
this.dragOffset = {
x: event.clientX - rect.left,
y: event.clientY - rect.top
};
// 드래그 중인 노드 스타일 적용
event.target.style.opacity = '0.7';
event.target.style.transform = 'scale(1.05)';
event.target.style.zIndex = '1000';
// 이벤트 리스너 등록
document.addEventListener('mousemove', this.handleNodeDrag.bind(this));
document.addEventListener('mouseup', this.endNodeDrag.bind(this));
},
// 노드 드래그 처리
handleNodeDrag(event) {
if (!this.isDragging || !this.dragNode) return;
event.preventDefault();
// 드래그 중인 노드 위치 업데이트
const dragElement = document.querySelector(`[data-node-id="${this.dragNode.id}"]`);
if (dragElement) {
const newX = event.clientX - this.dragOffset.x;
const newY = event.clientY - this.dragOffset.y;
dragElement.style.position = 'fixed';
dragElement.style.left = `${newX}px`;
dragElement.style.top = `${newY}px`;
dragElement.style.pointerEvents = 'none';
}
// 드롭 대상 하이라이트
this.highlightDropTarget(event);
},
// 드롭 대상 하이라이트
highlightDropTarget(event) {
// 모든 노드에서 하이라이트 제거
document.querySelectorAll('.tree-diagram-node').forEach(el => {
el.classList.remove('drop-target-highlight');
});
// 현재 마우스 위치의 노드 찾기
const elements = document.elementsFromPoint(event.clientX, event.clientY);
const targetNode = elements.find(el =>
el.classList.contains('tree-diagram-node') &&
el.getAttribute('data-node-id') !== this.dragNode.id
);
if (targetNode) {
targetNode.classList.add('drop-target-highlight');
}
},
// 노드 드래그 종료
endNodeDrag(event) {
if (!this.isDragging || !this.dragNode) return;
console.log('🎯 노드 드래그 종료');
// 드롭 대상 찾기
const elements = document.elementsFromPoint(event.clientX, event.clientY);
const targetElement = elements.find(el =>
el.classList.contains('tree-diagram-node') &&
el.getAttribute('data-node-id') !== this.dragNode.id
);
let targetNodeId = null;
if (targetElement) {
targetNodeId = targetElement.getAttribute('data-node-id');
}
// 드래그 중인 노드 스타일 복원
const dragElement = document.querySelector(`[data-node-id="${this.dragNode.id}"]`);
if (dragElement) {
dragElement.style.opacity = '';
dragElement.style.transform = '';
dragElement.style.zIndex = '';
dragElement.style.position = '';
dragElement.style.left = '';
dragElement.style.top = '';
dragElement.style.pointerEvents = '';
}
// 모든 하이라이트 제거
document.querySelectorAll('.tree-diagram-node').forEach(el => {
el.classList.remove('drop-target-highlight');
});
// 실제 노드 이동 처리
if (targetNodeId && targetNodeId !== this.dragNode.id) {
this.moveNodeToParent(this.dragNode.id, targetNodeId);
}
// 상태 초기화
this.isDragging = false;
this.dragNode = null;
this.dragOffset = { x: 0, y: 0 };
// 이벤트 리스너 제거
document.removeEventListener('mousemove', this.handleNodeDrag);
document.removeEventListener('mouseup', this.endNodeDrag);
},
// 노드를 다른 부모로 이동
async moveNodeToParent(nodeId, newParentId) {
try {
console.log(`📦 노드 이동: ${nodeId} -> 부모: ${newParentId}`);
const moveData = {
parent_id: newParentId,
sort_order: 0 // 새 부모의 첫 번째 자식으로
};
await window.api.moveMemoNode(nodeId, moveData);
// 트리 다시 로드
await this.loadTreeNodes();
console.log('✅ 노드 이동 완료');
this.showNotification('노드가 이동되었습니다.', 'success');
} catch (error) {
console.error('❌ 노드 이동 실패:', error);
this.showNotification('노드 이동에 실패했습니다.', 'error');
}
},
// 노드 인라인 편집
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 margin = 100; // 여백
// 레벨별 노드 그룹화
const levels = new Map();
// 루트 노드들 찾기
const rootNodes = this.treeNodes.filter(node => !node.parent_id);
if (rootNodes.length === 0) return;
// 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 });
});
}
// 트리 전체 크기 계산
const maxLevel = Math.max(...levels.keys());
const maxNodesInLevel = Math.max(...Array.from(levels.values()).map(nodes => nodes.length));
const treeWidth = maxNodesInLevel * nodeWidth + (maxNodesInLevel - 1) * nodeSpacing;
const treeHeight = (maxLevel + 1) * levelHeight;
// 캔버스 중앙에 트리 배치하기 위한 오프셋 계산
const offsetX = Math.max(margin, (canvasWidth - treeWidth) / 2);
const offsetY = Math.max(margin, (canvasHeight - treeHeight) / 2);
// 각 레벨의 노드들 위치 계산
levels.forEach((nodes, level) => {
const y = offsetY + level * levelHeight;
const levelWidth = nodes.length * nodeWidth + (nodes.length - 1) * nodeSpacing;
const startX = offsetX + (treeWidth - levelWidth) / 2;
nodes.forEach((node, index) => {
const x = startX + index * (nodeWidth + nodeSpacing);
this.nodePositions.set(node.id, { x, y });
});
});
// 트리 크기 저장 (centerTree에서 사용)
this.treeBounds = {
width: treeWidth + 2 * margin,
height: treeHeight + 2 * margin,
offsetX: offsetX - margin,
offsetY: offsetY - margin
};
// 연결선 다시 그리기
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() {
const canvas = document.getElementById('tree-canvas');
if (!canvas || !this.treeBounds) {
this.treePanX = 0;
this.treePanY = 0;
this.treeZoom = 1;
return;
}
const canvasWidth = canvas.clientWidth;
const canvasHeight = canvas.clientHeight;
// 트리가 캔버스보다 큰 경우 적절한 줌 레벨 계산
const scaleX = canvasWidth / this.treeBounds.width;
const scaleY = canvasHeight / this.treeBounds.height;
const optimalZoom = Math.min(scaleX, scaleY, 1) * 0.9; // 90%로 여유 공간 확보
// 줌 적용
this.treeZoom = Math.max(0.1, Math.min(optimalZoom, 2));
// 중앙 정렬을 위한 팬 값 계산
const scaledTreeWidth = this.treeBounds.width * this.treeZoom;
const scaledTreeHeight = this.treeBounds.height * this.treeZoom;
this.treePanX = (canvasWidth - scaledTreeWidth) / 2 - this.treeBounds.offsetX * this.treeZoom;
this.treePanY = (canvasHeight - scaledTreeHeight) / 2 - this.treeBounds.offsetY * this.treeZoom;
console.log('🎯 트리 중앙 정렬:', {
zoom: this.treeZoom,
panX: this.treePanX,
panY: this.treePanY,
treeBounds: this.treeBounds
});
},
// 확대
zoomIn() {
this.treeZoom = Math.min(this.treeZoom * 1.2, 3);
},
// 축소
zoomOut() {
this.treeZoom = Math.max(this.treeZoom / 1.2, 0.3);
},
// 패닝 시작
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 += '<span class="tree-line vertical"></span>';
} else {
// 빈 공간
treeLines += '<span class="tree-line empty"></span>';
}
}
// 현재 노드의 연결선
let nodeConnector = '';
if (!isRoot) {
if (isLast) {
nodeConnector = '<span class="tree-line corner"></span>'; // └─
} else {
nodeConnector = '<span class="tree-line branch"></span>'; // ├─
}
}
let html = `
<div class="tree-node-item ${isRoot ? 'root' : ''} ${isLast ? 'last-child' : ''}">
<div
class="tree-node py-1 cursor-pointer flex items-center hover:bg-gray-50 group ${isSelected ? 'bg-blue-50 border-r-2 border-blue-500' : ''} ${statusClass}"
onclick="window.memoTreeInstance.selectNode(${JSON.stringify(node).replace(/"/g, '&quot;')})"
oncontextmenu="event.preventDefault(); window.memoTreeInstance.showContextMenu(event, ${JSON.stringify(node).replace(/"/g, '&quot;')})"
>
<!-- 트리 라인들 -->
<div class="flex items-center">
${treeLines}
${nodeConnector}
<!-- 펼치기/접기 버튼 -->
${hasChildren ?
`<button
onclick="event.stopPropagation(); window.memoTreeInstance.toggleNode('${node.id}')"
class="w-4 h-4 flex items-center justify-center text-gray-400 hover:text-gray-600 mr-1 ml-1"
>
<i class="fas fa-${isExpanded ? 'minus' : 'plus'}-square text-xs"></i>
</button>` :
'<span class="w-4 mr-1 ml-1"></span>'
}
<!-- 아이콘 -->
<span class="mr-2 text-sm">${this.getNodeIcon(node.node_type)}</span>
<!-- 제목 -->
<span class="flex-1 truncate font-medium">${node.title}</span>
</div>
<!-- 액션 버튼들 (호버 시 표시) -->
<div class="opacity-0 group-hover:opacity-100 flex space-x-1 ml-2">
<button
onclick="event.stopPropagation(); window.memoTreeInstance.addChildNode(${JSON.stringify(node).replace(/"/g, '&quot;')})"
class="w-6 h-6 flex items-center justify-center text-gray-400 hover:text-green-600 hover:bg-green-50 rounded"
title="자식 노드 추가"
>
<i class="fas fa-plus text-xs"></i>
</button>
<button
onclick="event.stopPropagation(); window.memoTreeInstance.showNodeMenu(event, ${JSON.stringify(node).replace(/"/g, '&quot;')})"
class="w-6 h-6 flex items-center justify-center text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded"
title="더보기"
>
<i class="fas fa-ellipsis-h text-xs"></i>
</button>
</div>
<!-- 단어 수 -->
${node.word_count > 0 ?
`<span class="text-xs text-gray-400 ml-2">${node.word_count}w</span>` :
''
}
</div>
`;
// 자식 노드들 재귀적으로 렌더링
if (hasChildren && isExpanded) {
const children = this.getChildNodes(node.id);
const newParentPath = [...parentPath, !isLast];
children.forEach((child, index) => {
const isChildLast = index === children.length - 1;
html += this.renderTreeNodeRecursive(child, depth + 1, isChildLast, newParentPath);
});
}
html += '</div>';
return html;
},
// 노드 토글
toggleNode(nodeId) {
if (this.expandedNodes.has(nodeId)) {
this.expandedNodes.delete(nodeId);
} else {
this.expandedNodes.add(nodeId);
}
// 트리 다시 렌더링을 위해 상태 업데이트
this.$nextTick(() => {
this.rerenderTree();
});
},
// 트리 다시 렌더링
rerenderTree() {
// Alpine.js의 반응성을 트리거하기 위해 배열을 새로 할당
this.treeNodes = [...this.treeNodes];
// 강제로 DOM 업데이트
this.$nextTick(() => {
// 트리 컨테이너 찾아서 다시 렌더링
const treeContainer = document.querySelector('.tree-container');
if (treeContainer) {
// Alpine.js가 자동으로 다시 렌더링함
}
});
},
// 모두 펼치기
expandAll() {
this.treeNodes.forEach(node => {
if (this.getChildNodes(node.id).length > 0) {
this.expandedNodes.add(node.id);
}
});
this.rerenderTree();
},
// 모두 접기
collapseAll() {
this.expandedNodes.clear();
this.rerenderTree();
},
// Monaco Editor 초기화
async initMonacoEditor() {
console.log('🎨 Monaco Editor 초기화 시작...');
// Monaco Editor 로더가 로드될 때까지 대기
let retries = 0;
while (typeof require === 'undefined' && retries < 30) {
console.log(`⏳ Monaco Editor 로더 대기 중... (${retries + 1}/30)`);
await new Promise(resolve => setTimeout(resolve, 200));
retries++;
}
if (typeof require === 'undefined') {
console.warn('❌ Monaco Editor 로더를 찾을 수 없습니다. 기본 textarea를 사용합니다.');
this.setupFallbackEditor();
return;
}
try {
require.config({
paths: {
vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.44.0/min/vs'
}
});
require(['vs/editor/editor.main'], () => {
// 에디터 컨테이너가 생성될 때까지 대기
const waitForEditor = () => {
const editorElement = document.getElementById('monaco-editor');
if (!editorElement) {
console.log('⏳ Monaco Editor 컨테이너 대기 중...');
setTimeout(waitForEditor, 100);
return;
}
// 이미 Monaco Editor가 생성되어 있다면 제거
if (monacoEditor) {
monacoEditor.dispose();
monacoEditor = null;
}
monacoEditor = monaco.editor.create(editorElement, {
value: '',
language: 'markdown',
theme: 'vs-light',
automaticLayout: true,
wordWrap: 'on',
minimap: { enabled: false },
scrollBeyondLastLine: false,
fontSize: 14,
lineNumbers: 'on',
folding: true,
renderWhitespace: 'boundary'
});
// 내용 변경 감지
monacoEditor.onDidChangeModelContent(() => {
this.isEditorDirty = true;
});
console.log('✅ Monaco Editor 초기화 완료');
};
// 에디터 컨테이너 대기 시작
waitForEditor();
});
} catch (error) {
console.error('❌ Monaco Editor 초기화 실패:', error);
this.setupFallbackEditor();
}
},
// 폴백 에디터 설정 (Monaco가 실패했을 때)
setupFallbackEditor() {
console.log('📝 폴백 textarea 에디터 설정 중...');
const editorElement = document.getElementById('monaco-editor');
if (editorElement) {
editorElement.innerHTML = `
<textarea
id="fallback-editor"
class="w-full h-full p-4 border-none resize-none focus:outline-none"
placeholder="여기에 내용을 입력하세요..."
></textarea>
`;
const textarea = document.getElementById('fallback-editor');
if (textarea) {
textarea.addEventListener('input', () => {
this.isEditorDirty = true;
});
console.log('✅ 폴백 에디터 설정 완료');
}
}
},
// 로그인 모달 열기
openLoginModal() {
this.showLoginModal = true;
this.loginForm = { email: '', password: '' };
this.loginError = '';
},
// 로그인 처리
async handleLogin() {
this.loginLoading = true;
this.loginError = '';
try {
const response = await window.api.login(this.loginForm.email, this.loginForm.password);
if (response.success) {
this.currentUser = response.user;
this.showLoginModal = false;
// 트리 목록 다시 로드
await this.loadUserTrees();
} else {
this.loginError = response.message || '로그인에 실패했습니다.';
}
} catch (error) {
console.error('로그인 오류:', error);
this.loginError = '로그인 중 오류가 발생했습니다.';
} finally {
this.loginLoading = false;
}
},
// 로그아웃
async logout() {
try {
await window.api.logout();
this.currentUser = null;
this.userTrees = [];
this.selectedTree = null;
this.treeNodes = [];
this.selectedNode = null;
console.log('✅ 로그아웃 완료');
} catch (error) {
console.error('❌ 로그아웃 실패:', error);
}
}
};
};
// 전역 인스턴스 등록 (HTML에서 접근하기 위해)
window.memoTreeInstance = null;
// Alpine.js 초기화 후 전역 인스턴스 설정
document.addEventListener('alpine:init', () => {
// 페이지 로드 후 인스턴스 등록
setTimeout(() => {
const appElement = document.querySelector('[x-data="memoTreeApp()"]');
if (appElement && appElement._x_dataStack) {
window.memoTreeInstance = appElement._x_dataStack[0];
}
}, 100);
});
// 트리 노드 컴포넌트
window.treeNodeComponent = function(node) {
return {
node: node,
expanded: true,
get hasChildren() {
return window.memoTreeInstance?.getChildNodes(this.node.id).length > 0;
},
toggleExpanded() {
this.expanded = !this.expanded;
if (this.expanded) {
window.memoTreeInstance?.expandedNodes.add(this.node.id);
} else {
window.memoTreeInstance?.expandedNodes.delete(this.node.id);
}
}
};
};
console.log('🌳 트리 메모장 JavaScript 로드 완료');