🐛 버그 수정: - InstrumentedList 오류 해결: highlight.notes 리스트 접근 방식 수정 - this.filterMemos 함수 스코프 오류 해결: this 컨텍스트 안전 처리 - 하이라이트 API에서 메모 관계 로딩 추가 (selectinload) - 플로팅 메모창 showFloatingMemo 변수 인식 문제 해결 🛠️ 개선사항: - 안전한 함수 호출: typeof 체크 및 대체 로직 추가 - 하이라이트 응답에 연관된 메모 데이터 포함 - 캐시 버스팅 버전 업데이트 (v2025012227) - 디버깅 로그 추가로 문제 진단 개선 ✅ 결과: - 하이라이트와 메모가 정상적으로 표시됨 - 플로팅 메모창이 올바르게 작동함 - API 500 오류 해결로 데이터 안전성 확보
1251 lines
48 KiB
JavaScript
1251 lines
48 KiB
JavaScript
// 계층구조 뷰 전용 JavaScript
|
|
|
|
// 메인 계층구조 앱
|
|
window.hierarchyApp = function() {
|
|
return {
|
|
// 상태 관리
|
|
currentUser: null,
|
|
hierarchy: { books: {}, uncategorized: [] },
|
|
loading: false,
|
|
searchQuery: '',
|
|
|
|
// UI 상태
|
|
expandedBooks: [],
|
|
expandedCategories: [],
|
|
expandedUncategorizedInBooks: [],
|
|
showUncategorized: false,
|
|
showUploadModal: false,
|
|
showLoginModal: false,
|
|
|
|
// 선택된 문서
|
|
selectedDocument: null,
|
|
documentContent: null,
|
|
loadingDocument: false,
|
|
|
|
// 하이라이트 & 메모
|
|
highlights: [],
|
|
notes: [],
|
|
selectedText: '',
|
|
showHighlightMenu: false,
|
|
highlightMenuPosition: { x: 0, y: 0 },
|
|
highlightMode: false, // 하이라이트 모드 토글
|
|
|
|
// 메모 사이드바
|
|
memoSearchQuery: '',
|
|
filteredNotes: [],
|
|
|
|
// 플로팅 메모창
|
|
showFloatingMemo: false,
|
|
floatingMemoPosition: { x: 100, y: 100 },
|
|
floatingMemoSize: { width: 400, height: 500 },
|
|
isDragging: false,
|
|
isResizing: false,
|
|
dragOffset: { x: 0, y: 0 },
|
|
|
|
// 드래그 앤 드롭
|
|
draggedDocument: null,
|
|
|
|
// 초기화
|
|
async init() {
|
|
console.log('🚀 계층구조 앱 초기화 중...');
|
|
// 전역 인스턴스 등록
|
|
window.hierarchyInstance = this;
|
|
await this.checkAuthStatus();
|
|
if (this.currentUser) {
|
|
await this.loadHierarchy();
|
|
}
|
|
this.setupEventListeners();
|
|
},
|
|
|
|
// 이벤트 리스너 설정
|
|
setupEventListeners() {
|
|
// 인증 상태 변경 이벤트
|
|
window.addEventListener('auth-changed', (event) => {
|
|
this.currentUser = event.detail.user;
|
|
if (this.currentUser) {
|
|
this.loadHierarchy();
|
|
}
|
|
});
|
|
|
|
// 업로드 완료 이벤트
|
|
window.addEventListener('upload-complete', () => {
|
|
this.loadHierarchy();
|
|
});
|
|
},
|
|
|
|
// 인증 상태 확인
|
|
async checkAuthStatus() {
|
|
try {
|
|
const user = await window.api.getCurrentUser();
|
|
this.currentUser = user;
|
|
console.log('✅ 사용자 인증됨:', user.email);
|
|
} catch (error) {
|
|
console.log('❌ 인증되지 않음');
|
|
this.currentUser = null;
|
|
}
|
|
},
|
|
|
|
// 계층구조 데이터 로드
|
|
async loadHierarchy() {
|
|
if (!this.currentUser) return;
|
|
|
|
this.loading = true;
|
|
try {
|
|
console.log('📊 계층구조 데이터 로딩...');
|
|
const data = await window.api.getDocumentsHierarchy();
|
|
this.hierarchy = data;
|
|
console.log('✅ 계층구조 로드 완료:', data);
|
|
|
|
// 첫 번째 서적과 미분류를 기본으로 펼치기
|
|
if (Object.keys(data.books).length > 0) {
|
|
const firstBookId = Object.keys(data.books)[0];
|
|
this.expandedBooks = [firstBookId];
|
|
}
|
|
if (data.uncategorized.length > 0) {
|
|
this.showUncategorized = true;
|
|
}
|
|
} catch (error) {
|
|
console.error('❌ 계층구조 로드 실패:', error);
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
},
|
|
|
|
// 계층구조 새로고침
|
|
async refreshHierarchy() {
|
|
await this.loadHierarchy();
|
|
},
|
|
|
|
// 서적 토글
|
|
toggleBook(bookId) {
|
|
const index = this.expandedBooks.indexOf(bookId);
|
|
if (index > -1) {
|
|
this.expandedBooks.splice(index, 1);
|
|
} else {
|
|
this.expandedBooks.push(bookId);
|
|
}
|
|
},
|
|
|
|
// 소분류 토글
|
|
toggleCategory(categoryId) {
|
|
const index = this.expandedCategories.indexOf(categoryId);
|
|
if (index > -1) {
|
|
this.expandedCategories.splice(index, 1);
|
|
} else {
|
|
this.expandedCategories.push(categoryId);
|
|
}
|
|
},
|
|
|
|
// 서적 내 미분류 토글
|
|
toggleUncategorizedInBook(bookId) {
|
|
const index = this.expandedUncategorizedInBooks.indexOf(bookId);
|
|
if (index > -1) {
|
|
this.expandedUncategorizedInBooks.splice(index, 1);
|
|
} else {
|
|
this.expandedUncategorizedInBooks.push(bookId);
|
|
}
|
|
},
|
|
|
|
// 전체 미분류 토글
|
|
toggleUncategorized() {
|
|
this.showUncategorized = !this.showUncategorized;
|
|
},
|
|
|
|
// 문서 열기
|
|
async openDocument(documentId) {
|
|
console.log('📄 문서 열기:', documentId);
|
|
this.loadingDocument = true;
|
|
this.documentContent = null;
|
|
this.highlights = [];
|
|
this.notes = [];
|
|
|
|
try {
|
|
// 문서 정보 가져오기
|
|
const docInfo = await window.api.getDocument(documentId);
|
|
this.selectedDocument = docInfo;
|
|
|
|
// HTML 콘텐츠 가져오기 (인증된 API 사용)
|
|
const content = await window.api.getDocumentContent(documentId);
|
|
this.documentContent = content;
|
|
|
|
// 하이라이트 및 메모 로드
|
|
await this.loadHighlightsAndNotes(documentId);
|
|
|
|
// 문서 로드 후 하이라이트 적용 (DOM이 준비될 때까지 대기)
|
|
setTimeout(async () => {
|
|
console.log(`🎨 하이라이트 적용 시도: ${this.highlights.length}개`);
|
|
this.applyHighlights();
|
|
this.setupTextSelection();
|
|
}, 200);
|
|
|
|
console.log('✅ 문서 로드 완료');
|
|
} catch (error) {
|
|
console.error('❌ 문서 로드 실패:', error);
|
|
alert('문서를 불러오는 중 오류가 발생했습니다.');
|
|
} finally {
|
|
this.loadingDocument = false;
|
|
}
|
|
},
|
|
|
|
// 하이라이트 및 메모 로드
|
|
async loadHighlightsAndNotes(documentId) {
|
|
const self = this; // this 컨텍스트 저장
|
|
try {
|
|
console.log(`📊 하이라이트/메모 로드 시작: ${documentId}`);
|
|
|
|
// 하이라이트 로드
|
|
console.log('🔍 하이라이트 API 호출 중...');
|
|
const highlights = await window.api.getDocumentHighlights(documentId);
|
|
console.log('📥 하이라이트 API 응답:', highlights);
|
|
|
|
// 메모 로드
|
|
console.log('🔍 메모 API 호출 중...');
|
|
const notes = await window.api.getDocumentNotes(documentId);
|
|
console.log('📥 메모 API 응답:', notes);
|
|
|
|
self.highlights = highlights || [];
|
|
self.notes = notes || [];
|
|
|
|
// 메모 사이드바용 필터링된 노트 초기화
|
|
if (typeof self.filterMemos === 'function') {
|
|
self.filterMemos();
|
|
} else {
|
|
console.error('❌ filterMemos 함수를 찾을 수 없습니다');
|
|
// 수동으로 필터링
|
|
self.filteredNotes = self.notes.filter(note => !note.highlight_id);
|
|
}
|
|
|
|
console.log(`📝 최종 저장: 하이라이트 ${self.highlights.length}개, 메모 ${self.notes.length}개`);
|
|
|
|
if (self.highlights.length > 0) {
|
|
console.log('📝 하이라이트 상세:', self.highlights.map(h => ({
|
|
id: h.id,
|
|
text: h.selected_text,
|
|
color: h.color || h.highlight_color
|
|
})));
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('❌ 하이라이트/메모 로드 실패:', error);
|
|
console.error('❌ 오류 상세:', error.message, error.stack);
|
|
this.highlights = [];
|
|
this.notes = [];
|
|
}
|
|
},
|
|
|
|
// 하이라이트 적용 (viewer.js와 동일한 로직)
|
|
applyHighlights() {
|
|
if (!this.selectedDocument || !this.highlights.length) {
|
|
console.log('📝 하이라이트 적용 건너뜀: 문서 없음 또는 하이라이트 없음');
|
|
return;
|
|
}
|
|
|
|
const contentElement = document.querySelector('.prose');
|
|
if (!contentElement) {
|
|
console.log('📝 하이라이트 적용 건너뜀: .prose 요소 없음');
|
|
return;
|
|
}
|
|
|
|
console.log(`📝 하이라이트 적용 시작: ${this.highlights.length}개`);
|
|
|
|
// 기존 하이라이트 제거
|
|
contentElement.querySelectorAll('.highlight').forEach(el => {
|
|
const parent = el.parentNode;
|
|
parent.replaceChild(document.createTextNode(el.textContent), el);
|
|
parent.normalize();
|
|
});
|
|
|
|
// 새 하이라이트 적용
|
|
this.highlights.forEach(highlight => {
|
|
this.applyHighlight(highlight);
|
|
});
|
|
},
|
|
|
|
// 개별 하이라이트 적용 (viewer.js와 동일한 로직)
|
|
applyHighlight(highlight) {
|
|
const content = document.querySelector('.prose');
|
|
if (!content) return;
|
|
|
|
const walker = document.createTreeWalker(
|
|
content,
|
|
NodeFilter.SHOW_TEXT,
|
|
null,
|
|
false
|
|
);
|
|
|
|
let currentOffset = 0;
|
|
let node;
|
|
|
|
while (node = walker.nextNode()) {
|
|
const nodeLength = node.textContent.length;
|
|
|
|
if (currentOffset + nodeLength > highlight.start_offset) {
|
|
const startInNode = Math.max(0, highlight.start_offset - currentOffset);
|
|
const endInNode = Math.min(nodeLength, highlight.end_offset - currentOffset);
|
|
|
|
if (startInNode < endInNode) {
|
|
try {
|
|
const range = document.createRange();
|
|
range.setStart(node, startInNode);
|
|
range.setEnd(node, endInNode);
|
|
|
|
const span = document.createElement('span');
|
|
span.className = `highlight cursor-pointer`;
|
|
span.style.backgroundColor = highlight.color || '#FFFF00';
|
|
span.dataset.highlightId = highlight.id;
|
|
span.title = highlight.note || '하이라이트';
|
|
|
|
// 하이라이트 클릭 이벤트
|
|
span.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
this.showHighlightDetails(highlight, e.target);
|
|
});
|
|
|
|
range.surroundContents(span);
|
|
console.log(`✅ 하이라이트 적용됨: ${highlight.selected_text}`);
|
|
break;
|
|
} catch (error) {
|
|
console.warn('하이라이트 적용 실패:', error);
|
|
}
|
|
}
|
|
}
|
|
|
|
currentOffset += nodeLength;
|
|
|
|
if (currentOffset >= highlight.end_offset) {
|
|
break;
|
|
}
|
|
}
|
|
},
|
|
|
|
// 텍스트 선택 설정
|
|
setupTextSelection() {
|
|
console.log('🎯 텍스트 선택 이벤트 설정 중...');
|
|
const contentElement = document.querySelector('.prose');
|
|
if (!contentElement) {
|
|
console.warn('❌ .prose 요소를 찾을 수 없습니다');
|
|
return;
|
|
}
|
|
|
|
contentElement.addEventListener('mouseup', (e) => {
|
|
console.log('🖱️ 마우스업 이벤트 발생');
|
|
const selection = window.getSelection();
|
|
if (selection.rangeCount > 0 && !selection.isCollapsed) {
|
|
const range = selection.getRangeAt(0);
|
|
const text = selection.toString().trim();
|
|
|
|
console.log('📝 선택된 텍스트:', text);
|
|
|
|
if (text.length > 0 && this.highlightMode) {
|
|
this.selectedText = text;
|
|
// 직접 DOM 조작으로 메뉴 표시
|
|
const menu = document.getElementById('highlight-menu');
|
|
if (menu) {
|
|
menu.style.display = 'block';
|
|
console.log('✅ 하이라이트 메뉴 표시됨');
|
|
} else {
|
|
console.error('❌ 하이라이트 메뉴 DOM 요소를 찾을 수 없음');
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// 다른 곳 클릭 시 메뉴 숨기기 (메뉴 자체 클릭은 제외)
|
|
document.addEventListener('click', (e) => {
|
|
if (!e.target.closest('#highlight-menu') && !e.target.closest('.prose')) {
|
|
const menu = document.getElementById('highlight-menu');
|
|
if (menu) {
|
|
menu.style.display = 'none';
|
|
}
|
|
}
|
|
});
|
|
},
|
|
|
|
// 하이라이트 생성 (viewer.js와 동일한 로직)
|
|
async createHighlight(color = 'yellow') {
|
|
if (!this.selectedText || !this.selectedDocument) return;
|
|
|
|
const selection = window.getSelection();
|
|
if (selection.rangeCount === 0) return;
|
|
|
|
try {
|
|
const range = selection.getRangeAt(0);
|
|
const content = document.querySelector('.prose');
|
|
|
|
// 텍스트 오프셋 계산
|
|
const offsets = this.calculateTextOffsets(range, content);
|
|
|
|
// 색상 매핑
|
|
const colorMap = {
|
|
'yellow': '#FFFF00',
|
|
'blue': '#87CEEB',
|
|
'green': '#90EE90',
|
|
'red': '#FFB6C1'
|
|
};
|
|
|
|
const highlightData = {
|
|
document_id: this.selectedDocument.id,
|
|
start_offset: offsets.start,
|
|
end_offset: offsets.end,
|
|
selected_text: this.selectedText,
|
|
color: colorMap[color] || '#FFFF00',
|
|
note: null
|
|
};
|
|
|
|
console.log('📝 하이라이트 생성 데이터:', highlightData);
|
|
|
|
const newHighlight = await window.api.createHighlight(highlightData);
|
|
this.highlights.push(newHighlight);
|
|
|
|
// 하이라이트 다시 렌더링
|
|
this.applyHighlights();
|
|
|
|
// 선택 해제
|
|
selection.removeAllRanges();
|
|
const menu = document.getElementById('highlight-menu');
|
|
if (menu) {
|
|
menu.style.display = 'none';
|
|
}
|
|
this.selectedText = '';
|
|
|
|
console.log('✅ 하이라이트 생성 완료');
|
|
|
|
// 성공 메시지
|
|
alert(`하이라이트가 생성되었습니다: "${this.selectedText}"`);
|
|
|
|
// 메모 추가는 하이라이트 클릭으로만 가능하도록 변경
|
|
|
|
} catch (error) {
|
|
console.error('❌ 하이라이트 생성 실패:', error);
|
|
alert('하이라이트 생성 중 오류가 발생했습니다.');
|
|
}
|
|
},
|
|
|
|
// 텍스트 오프셋 계산 (viewer.js와 동일한 로직)
|
|
calculateTextOffsets(range, container) {
|
|
const walker = document.createTreeWalker(
|
|
container,
|
|
NodeFilter.SHOW_TEXT,
|
|
null,
|
|
false
|
|
);
|
|
|
|
let currentOffset = 0;
|
|
let startOffset = -1;
|
|
let endOffset = -1;
|
|
let node;
|
|
|
|
while (node = walker.nextNode()) {
|
|
const nodeLength = node.textContent.length;
|
|
|
|
if (startOffset === -1 && node === range.startContainer) {
|
|
startOffset = currentOffset + range.startOffset;
|
|
}
|
|
|
|
if (endOffset === -1 && node === range.endContainer) {
|
|
endOffset = currentOffset + range.endOffset;
|
|
break;
|
|
}
|
|
|
|
currentOffset += nodeLength;
|
|
}
|
|
|
|
return {
|
|
start: startOffset,
|
|
end: endOffset
|
|
};
|
|
},
|
|
|
|
// 텍스트 오프셋 계산 (간단한 버전)
|
|
getTextOffset(container, offset) {
|
|
const contentElement = document.querySelector('.prose');
|
|
if (!contentElement) return 0;
|
|
|
|
const walker = document.createTreeWalker(
|
|
contentElement,
|
|
NodeFilter.SHOW_TEXT,
|
|
null,
|
|
false
|
|
);
|
|
|
|
let textOffset = 0;
|
|
let node;
|
|
|
|
while (node = walker.nextNode()) {
|
|
if (node === container) {
|
|
return textOffset + offset;
|
|
}
|
|
textOffset += node.textContent.length;
|
|
}
|
|
|
|
return textOffset;
|
|
},
|
|
|
|
// 하이라이트에서 Range 생성 (간단한 버전)
|
|
createRangeFromHighlight(highlight) {
|
|
const contentElement = document.querySelector('.prose');
|
|
if (!contentElement) return null;
|
|
|
|
const walker = document.createTreeWalker(
|
|
contentElement,
|
|
NodeFilter.SHOW_TEXT,
|
|
null,
|
|
false
|
|
);
|
|
|
|
let currentOffset = 0;
|
|
let startNode = null, startOffset = 0;
|
|
let endNode = null, endOffset = 0;
|
|
let node;
|
|
|
|
while (node = walker.nextNode()) {
|
|
const nodeLength = node.textContent.length;
|
|
|
|
if (!startNode && currentOffset + nodeLength > highlight.start_offset) {
|
|
startNode = node;
|
|
startOffset = highlight.start_offset - currentOffset;
|
|
}
|
|
|
|
if (!endNode && currentOffset + nodeLength >= highlight.end_offset) {
|
|
endNode = node;
|
|
endOffset = highlight.end_offset - currentOffset;
|
|
break;
|
|
}
|
|
|
|
currentOffset += nodeLength;
|
|
}
|
|
|
|
if (startNode && endNode) {
|
|
const range = document.createRange();
|
|
range.setStart(startNode, startOffset);
|
|
range.setEnd(endNode, endOffset);
|
|
return range;
|
|
}
|
|
|
|
return null;
|
|
},
|
|
|
|
// 하이라이트 상세 정보 표시 (viewer.js와 동일한 툴팁 방식)
|
|
showHighlightDetails(highlight, element) {
|
|
// 기존 툴팁 제거
|
|
this.hideTooltip();
|
|
|
|
const tooltip = document.createElement('div');
|
|
tooltip.id = 'highlight-tooltip';
|
|
tooltip.className = 'absolute bg-white border border-gray-300 rounded-lg shadow-lg p-4 z-50 max-w-sm';
|
|
tooltip.style.minWidth = '300px';
|
|
|
|
// 하이라이트 정보와 메모 표시
|
|
const highlightNotes = this.notes.filter(note => note.highlight_id === highlight.id);
|
|
|
|
tooltip.innerHTML = `
|
|
<div class="mb-3">
|
|
<div class="text-sm text-gray-600 mb-1">선택된 텍스트</div>
|
|
<div class="font-medium text-gray-900 bg-yellow-100 px-2 py-1 rounded">
|
|
"${highlight.selected_text}"
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mb-3">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<span class="text-sm font-medium text-gray-700">메모 (${highlightNotes.length})</span>
|
|
<button onclick="window.hierarchyInstance.showAddNoteForm('${highlight.id}')"
|
|
class="text-xs bg-blue-500 text-white px-2 py-1 rounded hover:bg-blue-600">
|
|
+ 메모 추가
|
|
</button>
|
|
</div>
|
|
|
|
<div id="notes-list" class="space-y-2 max-h-40 overflow-y-auto">
|
|
${highlightNotes.length > 0 ?
|
|
highlightNotes.map(note => `
|
|
<div class="bg-gray-50 p-2 rounded text-sm">
|
|
<div class="text-gray-800">${note.content}</div>
|
|
<div class="flex justify-between items-center mt-1">
|
|
<span class="text-xs text-gray-500">${new Date(note.created_at).toLocaleDateString()}</span>
|
|
<div class="space-x-1">
|
|
<button onclick="window.hierarchyInstance.editNote('${note.id}', '${note.content.replace(/'/g, "\\'")}')"
|
|
class="text-xs text-blue-600 hover:text-blue-800">수정</button>
|
|
<button onclick="window.hierarchyInstance.deleteNote('${note.id}')"
|
|
class="text-xs text-red-600 hover:text-red-800">삭제</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`).join('')
|
|
: '<div class="text-gray-500 text-sm text-center py-2">메모가 없습니다</div>'
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex justify-between pt-2 border-t">
|
|
<button onclick="window.hierarchyInstance.deleteHighlight('${highlight.id}')"
|
|
class="text-xs bg-red-500 text-white px-3 py-1 rounded hover:bg-red-600">
|
|
하이라이트 삭제
|
|
</button>
|
|
<button onclick="window.hierarchyInstance.hideTooltip()"
|
|
class="text-xs bg-gray-500 text-white px-3 py-1 rounded hover:bg-gray-600">
|
|
닫기
|
|
</button>
|
|
</div>
|
|
`;
|
|
|
|
document.body.appendChild(tooltip);
|
|
|
|
// 위치 계산
|
|
const rect = element.getBoundingClientRect();
|
|
const tooltipRect = tooltip.getBoundingClientRect();
|
|
|
|
let top = rect.bottom + window.scrollY + 10;
|
|
let left = rect.left + window.scrollX;
|
|
|
|
// 화면 경계 체크
|
|
if (left + tooltipRect.width > window.innerWidth) {
|
|
left = window.innerWidth - tooltipRect.width - 10;
|
|
}
|
|
if (top + tooltipRect.height > window.innerHeight + window.scrollY) {
|
|
top = rect.top + window.scrollY - tooltipRect.height - 10;
|
|
}
|
|
|
|
tooltip.style.top = top + 'px';
|
|
tooltip.style.left = left + 'px';
|
|
|
|
// 외부 클릭 시 닫기
|
|
setTimeout(() => {
|
|
document.addEventListener('click', this.handleTooltipOutsideClick.bind(this));
|
|
}, 100);
|
|
},
|
|
|
|
// 툴팁 숨기기
|
|
hideTooltip() {
|
|
const tooltip = document.getElementById('highlight-tooltip');
|
|
if (tooltip) {
|
|
tooltip.remove();
|
|
}
|
|
document.removeEventListener('click', this.handleTooltipOutsideClick.bind(this));
|
|
},
|
|
|
|
// 툴팁 외부 클릭 처리
|
|
handleTooltipOutsideClick(e) {
|
|
if (!e.target.closest('#highlight-tooltip')) {
|
|
this.hideTooltip();
|
|
}
|
|
},
|
|
|
|
// 메모 추가 폼 표시
|
|
showAddNoteForm(highlightId) {
|
|
const tooltip = document.getElementById('highlight-tooltip');
|
|
if (!tooltip) return;
|
|
|
|
const notesList = tooltip.querySelector('#notes-list');
|
|
notesList.innerHTML = `
|
|
<div class="bg-blue-50 p-3 rounded">
|
|
<div class="mb-2">
|
|
<label class="block text-sm font-medium text-gray-700 mb-1">새 메모</label>
|
|
<textarea id="new-note-content"
|
|
class="w-full px-2 py-1 border border-gray-300 rounded text-sm"
|
|
rows="3"
|
|
placeholder="메모를 입력하세요..."></textarea>
|
|
</div>
|
|
<div class="flex justify-end space-x-2">
|
|
<button onclick="window.hierarchyInstance.cancelAddNote('${highlightId}')"
|
|
class="text-xs bg-gray-500 text-white px-2 py-1 rounded hover:bg-gray-600">
|
|
취소
|
|
</button>
|
|
<button onclick="window.hierarchyInstance.saveNewNote('${highlightId}')"
|
|
class="text-xs bg-blue-500 text-white px-2 py-1 rounded hover:bg-blue-600">
|
|
저장
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
},
|
|
|
|
// 새 메모 저장
|
|
async saveNewNote(highlightId) {
|
|
const content = document.getElementById('new-note-content').value.trim();
|
|
if (!content) {
|
|
alert('메모 내용을 입력해주세요');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const noteData = {
|
|
highlight_id: highlightId,
|
|
content: content,
|
|
is_private: false,
|
|
tags: []
|
|
};
|
|
|
|
const newNote = await window.api.createNote(noteData);
|
|
|
|
// 로컬 데이터 업데이트
|
|
this.notes.push(newNote);
|
|
|
|
// 툴팁 새로고침
|
|
const highlight = this.highlights.find(h => h.id === highlightId);
|
|
if (highlight) {
|
|
const element = document.querySelector(`[data-highlight-id="${highlightId}"]`);
|
|
if (element) {
|
|
this.showHighlightDetails(highlight, element);
|
|
}
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('Failed to save note:', error);
|
|
alert('메모 저장에 실패했습니다');
|
|
}
|
|
},
|
|
|
|
// 메모 추가 취소
|
|
cancelAddNote(highlightId) {
|
|
const highlight = this.highlights.find(h => h.id === highlightId);
|
|
if (highlight) {
|
|
const element = document.querySelector(`[data-highlight-id="${highlightId}"]`);
|
|
if (element) {
|
|
this.showHighlightDetails(highlight, element);
|
|
}
|
|
}
|
|
},
|
|
|
|
// 메모 수정
|
|
editNote(noteId, currentContent) {
|
|
const newContent = prompt('메모를 수정하세요:', currentContent);
|
|
if (newContent !== null && newContent.trim() !== currentContent) {
|
|
this.updateNote(noteId, newContent.trim());
|
|
}
|
|
},
|
|
|
|
// 메모 업데이트
|
|
async updateNote(noteId, content) {
|
|
try {
|
|
await window.api.updateNote(noteId, { content });
|
|
|
|
// 로컬 데이터 업데이트
|
|
const note = this.notes.find(n => n.id === noteId);
|
|
if (note) {
|
|
note.content = content;
|
|
}
|
|
|
|
// 툴팁 새로고침
|
|
const highlight = this.highlights.find(h =>
|
|
this.notes.some(n => n.id === noteId && n.highlight_id === h.id)
|
|
);
|
|
if (highlight) {
|
|
const element = document.querySelector(`[data-highlight-id="${highlight.id}"]`);
|
|
if (element) {
|
|
this.showHighlightDetails(highlight, element);
|
|
}
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('Failed to update note:', error);
|
|
alert('메모 수정에 실패했습니다');
|
|
}
|
|
},
|
|
|
|
// 메모 삭제
|
|
async deleteNote(noteId) {
|
|
if (!confirm('이 메모를 삭제하시겠습니까?')) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await window.api.deleteNote(noteId);
|
|
|
|
// 로컬 데이터에서 제거
|
|
this.notes = this.notes.filter(n => n.id !== noteId);
|
|
|
|
// 툴팁 새로고침
|
|
const highlight = this.highlights.find(h =>
|
|
this.notes.some(n => n.highlight_id === h.id) ||
|
|
document.querySelector(`[data-highlight-id="${h.id}"]`)
|
|
);
|
|
if (highlight) {
|
|
const element = document.querySelector(`[data-highlight-id="${highlight.id}"]`);
|
|
if (element) {
|
|
this.showHighlightDetails(highlight, element);
|
|
}
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('Failed to delete note:', error);
|
|
alert('메모 삭제에 실패했습니다');
|
|
}
|
|
},
|
|
|
|
// 하이라이트 삭제
|
|
async deleteHighlight(highlightId) {
|
|
if (!confirm('이 하이라이트를 삭제하시겠습니까? 연결된 메모도 함께 삭제됩니다.')) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await window.api.deleteHighlight(highlightId);
|
|
|
|
// 로컬 데이터에서 제거
|
|
this.highlights = this.highlights.filter(h => h.id !== highlightId);
|
|
this.notes = this.notes.filter(n => n.highlight_id !== highlightId);
|
|
|
|
// UI 업데이트
|
|
this.hideTooltip();
|
|
this.applyHighlights();
|
|
|
|
} catch (error) {
|
|
console.error('Failed to delete highlight:', error);
|
|
alert('하이라이트 삭제에 실패했습니다');
|
|
}
|
|
},
|
|
|
|
// 하이라이트 메모 업데이트
|
|
async updateHighlightNote(highlightId, note) {
|
|
try {
|
|
await window.api.updateHighlight(highlightId, { note });
|
|
const highlight = this.highlights.find(h => h.id === highlightId);
|
|
if (highlight) {
|
|
highlight.note = note;
|
|
}
|
|
console.log('✅ 하이라이트 메모 업데이트 완료');
|
|
} catch (error) {
|
|
console.error('❌ 하이라이트 메모 업데이트 실패:', error);
|
|
}
|
|
},
|
|
|
|
// 하이라이트 모드 토글
|
|
toggleHighlightMode() {
|
|
this.highlightMode = !this.highlightMode;
|
|
if (!this.highlightMode) {
|
|
this.showHighlightMenu = false;
|
|
}
|
|
console.log('🎨 하이라이트 모드:', this.highlightMode ? '활성화' : '비활성화');
|
|
},
|
|
|
|
// 문서 편집
|
|
editDocument(document) {
|
|
console.log('✏️ 문서 편집:', document.title);
|
|
// TODO: 편집 모달 구현
|
|
alert('문서 편집 기능은 곧 구현될 예정입니다.');
|
|
},
|
|
|
|
// 문서 삭제
|
|
async deleteDocument(documentId) {
|
|
if (!confirm('정말로 이 문서를 삭제하시겠습니까?')) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await window.api.deleteDocument(documentId);
|
|
console.log('✅ 문서 삭제 완료');
|
|
|
|
// 선택된 문서가 삭제된 경우 선택 해제
|
|
if (this.selectedDocument && this.selectedDocument.id === documentId) {
|
|
this.selectedDocument = null;
|
|
this.documentContent = null;
|
|
}
|
|
|
|
// 계층구조 새로고침
|
|
await this.loadHierarchy();
|
|
} catch (error) {
|
|
console.error('❌ 문서 삭제 실패:', error);
|
|
alert('문서 삭제 중 오류가 발생했습니다.');
|
|
}
|
|
},
|
|
|
|
// 검색
|
|
async searchDocuments() {
|
|
// TODO: 검색 기능 구현
|
|
console.log('🔍 검색:', this.searchQuery);
|
|
},
|
|
|
|
// 드래그 앤 드롭 처리
|
|
handleDragStart(event, document) {
|
|
this.draggedDocument = document;
|
|
event.dataTransfer.effectAllowed = 'move';
|
|
event.dataTransfer.setData('text/html', document.id);
|
|
|
|
// 드래그 중인 요소 스타일
|
|
event.target.style.opacity = '0.5';
|
|
},
|
|
|
|
handleDrop(event, targetCategoryId) {
|
|
event.preventDefault();
|
|
|
|
if (!this.draggedDocument) return;
|
|
|
|
console.log('📦 드롭:', this.draggedDocument.title, '→', targetCategoryId);
|
|
|
|
// TODO: 문서 순서 변경 API 호출
|
|
// await window.api.updateDocumentOrder({
|
|
// document_id: this.draggedDocument.id,
|
|
// category_id: targetCategoryId,
|
|
// new_order: targetOrder
|
|
// });
|
|
|
|
// 드래그 상태 초기화
|
|
this.draggedDocument = null;
|
|
|
|
// UI 새로고침
|
|
this.loadHierarchy();
|
|
},
|
|
|
|
// 로그인 모달 열기
|
|
openLoginModal() {
|
|
this.showLoginModal = true;
|
|
},
|
|
|
|
// 로그아웃
|
|
async logout() {
|
|
try {
|
|
await window.api.logout();
|
|
this.currentUser = null;
|
|
this.hierarchy = { books: {}, uncategorized: [] };
|
|
this.selectedDocument = null;
|
|
this.documentContent = null;
|
|
console.log('✅ 로그아웃 완료');
|
|
} catch (error) {
|
|
console.error('❌ 로그아웃 실패:', error);
|
|
}
|
|
}
|
|
};
|
|
};
|
|
|
|
// 업로드 모달 (기존 로직 재사용)
|
|
window.uploadModal = function() {
|
|
return {
|
|
// 폼 데이터
|
|
uploadForm: {
|
|
html_file: null,
|
|
pdf_file: null,
|
|
title: '',
|
|
description: '',
|
|
document_date: '',
|
|
tags: '',
|
|
is_public: false
|
|
},
|
|
|
|
// 서적 관련
|
|
bookSelectionMode: 'none',
|
|
bookSearchQuery: '',
|
|
searchedBooks: [],
|
|
selectedBook: null,
|
|
newBook: {
|
|
title: '',
|
|
author: ''
|
|
},
|
|
suggestions: [],
|
|
searchTimeout: null,
|
|
|
|
// 상태
|
|
uploading: false,
|
|
uploadError: null,
|
|
|
|
// 파일 선택 처리
|
|
handleFileSelect(event, fileType) {
|
|
const file = event.target.files[0];
|
|
if (file) {
|
|
this.uploadForm[fileType] = file;
|
|
console.log(`📎 ${fileType} 선택:`, file.name);
|
|
}
|
|
},
|
|
|
|
// 서적 검색
|
|
async searchBooks() {
|
|
if (!this.bookSearchQuery.trim()) {
|
|
this.searchedBooks = [];
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const books = await window.api.searchBooks(this.bookSearchQuery);
|
|
this.searchedBooks = books;
|
|
} catch (error) {
|
|
console.error('❌ 서적 검색 실패:', error);
|
|
}
|
|
},
|
|
|
|
// 서적 선택
|
|
selectBook(book) {
|
|
this.selectedBook = book;
|
|
this.searchedBooks = [];
|
|
this.bookSearchQuery = book.title;
|
|
},
|
|
|
|
// 추천 서적 가져오기
|
|
async getSuggestions() {
|
|
if (!this.newBook.title.trim()) {
|
|
this.suggestions = [];
|
|
return;
|
|
}
|
|
|
|
// 디바운스
|
|
if (this.searchTimeout) {
|
|
clearTimeout(this.searchTimeout);
|
|
}
|
|
|
|
this.searchTimeout = setTimeout(async () => {
|
|
try {
|
|
const suggestions = await window.api.getBookSuggestions(this.newBook.title);
|
|
this.suggestions = suggestions;
|
|
} catch (error) {
|
|
console.error('❌ 추천 가져오기 실패:', error);
|
|
}
|
|
}, 300);
|
|
},
|
|
|
|
// 추천에서 기존 서적 선택
|
|
selectExistingFromSuggestion(suggestion) {
|
|
this.bookSelectionMode = 'existing';
|
|
this.selectedBook = suggestion;
|
|
this.suggestions = [];
|
|
this.newBook = { title: '', author: '' };
|
|
},
|
|
|
|
// 업로드 실행
|
|
async upload() {
|
|
this.uploading = true;
|
|
this.uploadError = null;
|
|
|
|
try {
|
|
const formData = new FormData();
|
|
|
|
// 파일 추가
|
|
formData.append('html_file', this.uploadForm.html_file);
|
|
if (this.uploadForm.pdf_file) {
|
|
formData.append('pdf_file', this.uploadForm.pdf_file);
|
|
}
|
|
|
|
// 문서 정보 추가
|
|
formData.append('title', this.uploadForm.title);
|
|
formData.append('description', this.uploadForm.description || '');
|
|
formData.append('document_date', this.uploadForm.document_date || '');
|
|
formData.append('tags', this.uploadForm.tags || '');
|
|
formData.append('is_public', this.uploadForm.is_public);
|
|
|
|
// 서적 정보 처리
|
|
let bookId = null;
|
|
|
|
if (this.bookSelectionMode === 'existing' && this.selectedBook) {
|
|
bookId = this.selectedBook.id;
|
|
} else if (this.bookSelectionMode === 'new' && this.newBook.title) {
|
|
// 새 서적 생성
|
|
const newBook = await window.api.createBook({
|
|
title: this.newBook.title,
|
|
author: this.newBook.author || null
|
|
});
|
|
bookId = newBook.id;
|
|
}
|
|
|
|
if (bookId) {
|
|
formData.append('book_id', bookId);
|
|
}
|
|
|
|
// 업로드 실행
|
|
await window.api.uploadDocument(formData);
|
|
|
|
console.log('✅ 문서 업로드 완료');
|
|
|
|
// 성공 후 처리
|
|
this.closeUploadModal();
|
|
window.dispatchEvent(new CustomEvent('upload-complete'));
|
|
|
|
} catch (error) {
|
|
console.error('❌ 업로드 실패:', error);
|
|
this.uploadError = error.message || '업로드 중 오류가 발생했습니다.';
|
|
} finally {
|
|
this.uploading = false;
|
|
}
|
|
},
|
|
|
|
// 모달 닫기
|
|
closeUploadModal() {
|
|
this.showUploadModal = false;
|
|
this.resetForm();
|
|
},
|
|
|
|
// 폼 초기화
|
|
resetForm() {
|
|
this.uploadForm = {
|
|
html_file: null,
|
|
pdf_file: null,
|
|
title: '',
|
|
description: '',
|
|
document_date: '',
|
|
tags: '',
|
|
is_public: false
|
|
};
|
|
this.bookSelectionMode = 'none';
|
|
this.bookSearchQuery = '';
|
|
this.searchedBooks = [];
|
|
this.selectedBook = null;
|
|
this.newBook = { title: '', author: '' };
|
|
this.suggestions = [];
|
|
this.uploadError = null;
|
|
}
|
|
};
|
|
};
|
|
|
|
// 로그인 모달 (기존 로직 재사용)
|
|
window.authModal = function() {
|
|
return {
|
|
loginForm: {
|
|
email: '',
|
|
password: ''
|
|
},
|
|
loggingIn: false,
|
|
loginError: null,
|
|
|
|
async login() {
|
|
this.loggingIn = true;
|
|
this.loginError = null;
|
|
|
|
try {
|
|
const response = await window.api.login(this.loginForm.email, this.loginForm.password);
|
|
|
|
// 토큰 저장
|
|
localStorage.setItem('access_token', response.access_token);
|
|
if (response.refresh_token) {
|
|
localStorage.setItem('refresh_token', response.refresh_token);
|
|
}
|
|
|
|
// 사용자 정보 가져오기
|
|
const user = await window.api.getCurrentUser();
|
|
|
|
console.log('✅ 로그인 성공:', user.email);
|
|
|
|
// 전역 이벤트 발생
|
|
window.dispatchEvent(new CustomEvent('auth-changed', {
|
|
detail: { user }
|
|
}));
|
|
|
|
// 모달 닫기
|
|
this.showLoginModal = false;
|
|
this.resetLoginForm();
|
|
|
|
} catch (error) {
|
|
console.error('❌ 로그인 실패:', error);
|
|
this.loginError = error.message || '로그인에 실패했습니다.';
|
|
} finally {
|
|
this.loggingIn = false;
|
|
}
|
|
},
|
|
|
|
resetLoginForm() {
|
|
this.loginForm = { email: '', password: '' };
|
|
this.loginError = null;
|
|
},
|
|
|
|
// 메모 필터링
|
|
filterMemos() {
|
|
console.log('🔍 메모 필터링 시작:', {
|
|
totalNotes: this.notes.length,
|
|
searchQuery: this.memoSearchQuery,
|
|
notes: this.notes
|
|
});
|
|
|
|
if (!this.memoSearchQuery.trim()) {
|
|
this.filteredNotes = this.notes.filter(note => !note.highlight_id);
|
|
console.log('📝 독립 메모 필터링 결과:', this.filteredNotes.length, this.filteredNotes);
|
|
return;
|
|
}
|
|
|
|
const query = this.memoSearchQuery.toLowerCase();
|
|
this.filteredNotes = this.notes
|
|
.filter(note => !note.highlight_id) // 독립 메모만
|
|
.filter(note => note.content.toLowerCase().includes(query));
|
|
console.log('🔍 검색 결과:', this.filteredNotes.length, this.filteredNotes);
|
|
},
|
|
|
|
// 하이라이트로 스크롤
|
|
scrollToHighlight(highlightId) {
|
|
const highlightElement = document.querySelector(`[data-highlight-id="${highlightId}"]`);
|
|
if (highlightElement) {
|
|
highlightElement.scrollIntoView({
|
|
behavior: 'smooth',
|
|
block: 'center'
|
|
});
|
|
|
|
// 잠시 강조 효과
|
|
highlightElement.style.boxShadow = '0 0 10px rgba(59, 130, 246, 0.5)';
|
|
setTimeout(() => {
|
|
highlightElement.style.boxShadow = '';
|
|
}, 2000);
|
|
}
|
|
},
|
|
|
|
// 플로팅 윈도우 드래그 시작
|
|
startDragging(event) {
|
|
this.isDragging = true;
|
|
this.dragOffset.x = event.clientX - this.floatingMemoPosition.x;
|
|
this.dragOffset.y = event.clientY - this.floatingMemoPosition.y;
|
|
|
|
document.addEventListener('mousemove', this.handleDragging.bind(this));
|
|
document.addEventListener('mouseup', this.stopDragging.bind(this));
|
|
event.preventDefault();
|
|
},
|
|
|
|
// 드래그 중
|
|
handleDragging(event) {
|
|
if (!this.isDragging) return;
|
|
|
|
const newX = event.clientX - this.dragOffset.x;
|
|
const newY = event.clientY - this.dragOffset.y;
|
|
|
|
// 화면 경계 제한
|
|
const maxX = window.innerWidth - this.floatingMemoSize.width;
|
|
const maxY = window.innerHeight - this.floatingMemoSize.height;
|
|
|
|
this.floatingMemoPosition.x = Math.max(0, Math.min(newX, maxX));
|
|
this.floatingMemoPosition.y = Math.max(0, Math.min(newY, maxY));
|
|
},
|
|
|
|
// 드래그 종료
|
|
stopDragging() {
|
|
this.isDragging = false;
|
|
document.removeEventListener('mousemove', this.handleDragging.bind(this));
|
|
document.removeEventListener('mouseup', this.stopDragging.bind(this));
|
|
},
|
|
|
|
// 리사이즈 시작
|
|
startResizing(event) {
|
|
this.isResizing = true;
|
|
document.addEventListener('mousemove', this.handleResizing.bind(this));
|
|
document.addEventListener('mouseup', this.stopResizing.bind(this));
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
},
|
|
|
|
// 리사이즈 중
|
|
handleResizing(event) {
|
|
if (!this.isResizing) return;
|
|
|
|
const rect = event.target.closest('.fixed').getBoundingClientRect();
|
|
const newWidth = event.clientX - rect.left;
|
|
const newHeight = event.clientY - rect.top;
|
|
|
|
// 최소/최대 크기 제한
|
|
this.floatingMemoSize.width = Math.max(300, Math.min(newWidth, 800));
|
|
this.floatingMemoSize.height = Math.max(200, Math.min(newHeight, 600));
|
|
},
|
|
|
|
// 리사이즈 종료
|
|
stopResizing() {
|
|
this.isResizing = false;
|
|
document.removeEventListener('mousemove', this.handleResizing.bind(this));
|
|
document.removeEventListener('mouseup', this.stopResizing.bind(this));
|
|
},
|
|
|
|
// 플로팅 메모창 크기 토글
|
|
toggleFloatingMemoSize() {
|
|
if (this.floatingMemoSize.width === 400) {
|
|
// 큰 크기로
|
|
this.floatingMemoSize.width = 600;
|
|
this.floatingMemoSize.height = 700;
|
|
} else {
|
|
// 기본 크기로
|
|
this.floatingMemoSize.width = 400;
|
|
this.floatingMemoSize.height = 500;
|
|
}
|
|
}
|
|
};
|
|
};
|
|
|
|
console.log('📚 계층구조 JavaScript 로드 완료');
|