Files
document-server/frontend/static/js/viewer/features/highlight-manager.js
Hyungi Ahn 3ba804276c Fix: 노트 링크 관련 모든 기능 완전 수정
주요 수정사항:
- 노트 간 링크 네비게이션 수정 (target_note_id 우선 사용)
- 노트 백링크 네비게이션 수정 (source_note_id 우선 사용)
- 노트 링크 삭제 API 분기 처리 (/note-links vs /document-links)
- 하이라이트 삭제 시 메모 캐시 무효화 추가
- 하이라이트 메모 삭제 API 엔드포인트 추가 (DELETE /highlight-notes/{note_id})
- URL 파싱 개선 (null/undefined ID 감지 및 오류 처리)
- 노트 링크 생성 응답에 source_content_type, target_content_type 추가
- 통합 툴팁에서 노트 링크 제목 표시 수정 (target_note_title 사용)
- 링크 삭제 버튼에서 null 참조 오류 수정

수정된 파일:
- frontend: viewer-core.js, link-manager.js, highlight-manager.js, api.js, cached-api.js
- backend: note_links.py, notes.py
- 브라우저 캐시 무효화: 버전 v=2025012623
2025-09-04 08:42:12 +09:00

1310 lines
52 KiB
JavaScript

/**
* HighlightManager 모듈
* 하이라이트 및 메모 관리
*/
class HighlightManager {
constructor(api) {
console.log('🎨 HighlightManager 초기화 시작');
this.api = api;
// 캐싱된 API 사용 (사용 가능한 경우)
this.cachedApi = window.cachedApi || api;
this.highlights = [];
this.notes = [];
this.selectedHighlightColor = '#FFFF00';
this.selectedText = '';
this.selectedRange = null;
// 텍스트 선택 이벤트 리스너 등록
this.textSelectionHandler = this.handleTextSelection.bind(this);
document.addEventListener('mouseup', this.textSelectionHandler);
console.log('✅ HighlightManager 텍스트 선택 이벤트 리스너 등록 완료');
}
/**
* 하이라이트 데이터 로드
*/
async loadHighlights(documentId, contentType) {
try {
if (contentType === 'note') {
this.highlights = await this.api.get(`/note/${documentId}/highlights`).catch(() => []);
} else {
this.highlights = await this.cachedApi.get('/highlights', { document_id: documentId, content_type: contentType }, { category: 'highlights' }).catch(() => []);
}
return this.highlights || [];
} catch (error) {
console.error('하이라이트 로드 실패:', error);
return [];
}
}
/**
* 메모 데이터 로드
*/
async loadNotes(documentId, contentType) {
try {
console.log('📝 메모 로드 시작:', { documentId, contentType });
if (contentType === 'note') {
// 노트 문서의 하이라이트 메모
this.notes = await this.api.get(`/highlight-notes/`, { note_document_id: documentId }).catch(() => []);
} else {
// 일반 문서의 하이라이트 메모
this.notes = await this.api.get(`/highlight-notes/`, { document_id: documentId }).catch(() => []);
}
console.log('📝 메모 로드 완료:', this.notes.length, '개');
return this.notes || [];
} catch (error) {
console.error('❌ 메모 로드 실패:', error);
return [];
}
}
/**
* 하이라이트 렌더링 (개선된 버전)
*/
renderHighlights() {
const content = document.getElementById('document-content');
console.log('🎨 하이라이트 렌더링 호출됨');
console.log('📄 document-content 요소:', content ? '존재' : '없음');
console.log('📊 this.highlights:', this.highlights ? this.highlights.length + '개' : 'null/undefined');
if (!content || !this.highlights || this.highlights.length === 0) {
console.log('❌ 하이라이트 렌더링 조건 미충족:', {
content: !!content,
highlights: !!this.highlights,
length: this.highlights ? this.highlights.length : 0
});
return;
}
console.log('🎨 하이라이트 렌더링 시작:', this.highlights.length + '개');
// 기존 하이라이트 제거
const existingHighlights = content.querySelectorAll('.highlight-span');
existingHighlights.forEach(el => {
const parent = el.parentNode;
parent.replaceChild(document.createTextNode(el.textContent), el);
parent.normalize();
});
// 위치별로 하이라이트 그룹화
const positionGroups = this.groupHighlightsByPosition();
// 각 그룹별로 하이라이트 적용
Object.keys(positionGroups).forEach(key => {
const group = positionGroups[key];
this.applyHighlightGroup(group);
});
console.log('✅ 하이라이트 렌더링 완료');
}
/**
* 위치별로 하이라이트 그룹화
*/
groupHighlightsByPosition() {
const groups = {};
console.log('📊 하이라이트 그룹화 시작:', this.highlights.length + '개');
console.log('📊 하이라이트 데이터:', this.highlights);
this.highlights.forEach(highlight => {
const key = `${highlight.start_offset}-${highlight.end_offset}`;
if (!groups[key]) {
groups[key] = {
start_offset: highlight.start_offset,
end_offset: highlight.end_offset,
highlights: []
};
}
groups[key].highlights.push(highlight);
});
console.log('📊 그룹화 결과:', Object.keys(groups).length + '개 그룹');
console.log('📊 그룹 상세:', groups);
return groups;
}
/**
* 하이라이트 그룹 적용
*/
applyHighlightGroup(group) {
const content = document.getElementById('document-content');
const textContent = content.textContent;
console.log('🎯 하이라이트 그룹 적용:', {
start: group.start_offset,
end: group.end_offset,
text: textContent.substring(group.start_offset, group.end_offset),
colors: group.highlights.map(h => h.highlight_color || h.color)
});
if (group.start_offset >= textContent.length || group.end_offset > textContent.length) {
console.warn('하이라이트 위치가 텍스트 범위를 벗어남:', group);
return;
}
const targetText = textContent.substring(group.start_offset, group.end_offset);
// 텍스트 노드 찾기 및 하이라이트 적용
const walker = document.createTreeWalker(
content,
NodeFilter.SHOW_TEXT,
null,
false
);
let currentOffset = 0;
let node;
let found = false;
while (node = walker.nextNode()) {
const nodeLength = node.textContent.length;
const nodeStart = currentOffset;
const nodeEnd = currentOffset + nodeLength;
// 하이라이트 범위와 겹치는지 확인
if (nodeEnd > group.start_offset && nodeStart < group.end_offset) {
const highlightStart = Math.max(0, group.start_offset - nodeStart);
const highlightEnd = Math.min(nodeLength, group.end_offset - nodeStart);
if (highlightStart < highlightEnd) {
console.log('✅ 하이라이트 적용 중:', {
nodeText: node.textContent.substring(0, 50) + '...',
highlightStart,
highlightEnd,
highlightText: node.textContent.substring(highlightStart, highlightEnd)
});
this.highlightTextInNode(node, highlightStart, highlightEnd, group.highlights);
found = true;
break;
}
}
currentOffset = nodeEnd;
}
if (!found) {
console.warn('❌ 하이라이트 적용할 텍스트 노드를 찾지 못함:', targetText);
}
}
/**
* 텍스트 노드에 하이라이트 적용
*/
highlightTextInNode(textNode, start, end, highlights) {
const text = textNode.textContent;
const beforeText = text.substring(0, start);
const highlightText = text.substring(start, end);
const afterText = text.substring(end);
// 하이라이트 스팬 생성
const span = document.createElement('span');
span.className = 'highlight-span';
span.textContent = highlightText;
// 첫 번째 하이라이트의 ID를 data 속성으로 설정
if (highlights.length > 0) {
span.dataset.highlightId = highlights[0].id;
}
// 다중 색상 처리
if (highlights.length === 1) {
console.log('🔍 하이라이트 데이터 구조:', highlights[0]);
const color = highlights[0].highlight_color || highlights[0].color || '#FFFF00';
span.style.setProperty('background', color, 'important');
span.style.setProperty('background-color', color, 'important');
console.log('🎨 단일 하이라이트 색상 적용 (!important):', color);
} else {
// 여러 색상이 겹치는 경우 줄무늬(스트라이프) 적용
const colors = highlights.map(h => h.highlight_color || h.color || '#FFFF00');
const stripeSize = 100 / colors.length; // 각 색상의 비율
// 색상별로 동일한 크기의 줄무늬 생성
const stripes = colors.map((color, index) => {
const start = index * stripeSize;
const end = (index + 1) * stripeSize;
return `${color} ${start}%, ${color} ${end}%`;
}).join(', ');
span.style.background = `linear-gradient(180deg, ${stripes})`;
console.log('🎨 다중 하이라이트 색상 적용 (위아래 절반씩):', colors);
}
// 메모 툴팁 설정
const notesForHighlight = highlights.filter(h => h.note_content);
if (notesForHighlight.length > 0) {
span.title = notesForHighlight.map(h => h.note_content).join('\n---\n');
span.style.cursor = 'help';
}
// 하이라이트 클릭 이벤트 추가 (통합 툴팁 사용)
span.style.cursor = 'pointer';
span.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
console.log('🎨 하이라이트 클릭됨:', {
text: span.textContent,
highlightId: span.dataset.highlightId,
classList: Array.from(span.classList)
});
// 링크, 백링크, 하이라이트 모두 찾기
const overlappingElements = window.documentViewerInstance.getOverlappingElements(span);
const totalElements = overlappingElements.links.length + overlappingElements.backlinks.length + overlappingElements.highlights.length;
console.log('🎨 하이라이트 클릭 분석:', {
links: overlappingElements.links.length,
backlinks: overlappingElements.backlinks.length,
highlights: overlappingElements.highlights.length,
total: totalElements,
selectedText: overlappingElements.selectedText
});
if (totalElements > 1) {
// 통합 툴팁 표시 (링크 + 백링크 + 하이라이트)
console.log('🎯 통합 툴팁 표시 시작 (하이라이트에서)');
await window.documentViewerInstance.showUnifiedTooltip(overlappingElements, span);
} else {
// 단일 하이라이트 툴팁
console.log('🎨 단일 하이라이트 툴팁 표시');
// 클릭된 하이라이트 찾기
const clickedHighlightId = span.dataset.highlightId;
const clickedHighlight = this.highlights.find(h => h.id === clickedHighlightId);
if (clickedHighlight) {
await this.showHighlightTooltip(clickedHighlight, span);
} else {
console.error('❌ 클릭된 하이라이트를 찾을 수 없음:', clickedHighlightId);
}
}
});
// DOM 교체
const parent = textNode.parentNode;
const fragment = document.createDocumentFragment();
if (beforeText) fragment.appendChild(document.createTextNode(beforeText));
fragment.appendChild(span);
if (afterText) fragment.appendChild(document.createTextNode(afterText));
parent.replaceChild(fragment, textNode);
}
/**
* 텍스트 선택 처리
*/
handleTextSelection() {
console.log('handleTextSelection called');
const selection = window.getSelection();
if (!selection.rangeCount || selection.isCollapsed) {
return;
}
const range = selection.getRangeAt(0);
const selectedText = selection.toString().trim();
if (!selectedText) {
return;
}
console.log('Selected text:', selectedText);
// 선택된 텍스트와 범위 저장
this.selectedText = selectedText;
this.selectedRange = range.cloneRange();
// ViewerCore의 selectedText도 동기화
if (window.documentViewerInstance) {
window.documentViewerInstance.selectedText = selectedText;
window.documentViewerInstance.selectedRange = range.cloneRange();
}
// 하이라이트 버튼 표시
this.showHighlightButton(range);
}
/**
* 하이라이트 버튼 표시
*/
showHighlightButton(range) {
// 기존 버튼 제거
const existingButton = document.querySelector('.highlight-button');
if (existingButton) {
existingButton.remove();
}
const rect = range.getBoundingClientRect();
const button = document.createElement('button');
button.className = 'highlight-button';
button.innerHTML = '🖍️ 하이라이트';
button.style.cssText = `
position: fixed;
top: ${rect.top - 40}px;
left: ${rect.left}px;
z-index: 1000;
background: #4F46E5;
color: white;
border: none;
padding: 8px 12px;
border-radius: 6px;
font-size: 12px;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
`;
document.body.appendChild(button);
button.addEventListener('click', () => {
this.createHighlight();
button.remove();
});
// 3초 후 자동 제거
setTimeout(() => {
if (button.parentNode) {
button.remove();
}
}, 3000);
}
/**
* 색상 버튼으로 하이라이트 생성
*/
createHighlightWithColor(color) {
console.log('🎨 createHighlightWithColor called with color:', color);
console.log('🎨 이전 색상:', this.selectedHighlightColor);
// 현재 선택된 텍스트가 있는지 확인
const selection = window.getSelection();
if (!selection.rangeCount || selection.isCollapsed) {
console.log('선택된 텍스트가 없습니다');
return;
}
// 색상 설정 후 하이라이트 생성
this.selectedHighlightColor = color;
console.log('🎨 색상 설정 완료:', this.selectedHighlightColor);
this.handleTextSelection(); // 텍스트 선택 처리
// 바로 하이라이트 생성 (버튼 클릭 없이)
setTimeout(() => {
this.createHighlight();
}, 100);
}
/**
* 하이라이트 생성
*/
async createHighlight() {
console.log('createHighlight called');
console.log('selectedText:', this.selectedText);
console.log('selectedRange:', this.selectedRange);
if (!this.selectedText || !this.selectedRange) {
console.log('선택된 텍스트나 범위가 없습니다');
return;
}
try {
// 문서 전체 텍스트에서 선택된 텍스트의 위치 계산
const documentContent = document.getElementById('document-content');
const fullText = documentContent.textContent;
// 선택된 범위의 시작점을 문서 전체에서의 오프셋으로 변환
const startOffset = this.getTextOffset(documentContent, this.selectedRange.startContainer, this.selectedRange.startOffset);
const endOffset = startOffset + this.selectedText.length;
console.log('Calculated offsets:', { startOffset, endOffset, text: this.selectedText });
const highlightData = {
selected_text: this.selectedText,
start_offset: startOffset,
end_offset: endOffset,
highlight_color: this.selectedHighlightColor // 백엔드 API 스키마에 맞게 수정
};
console.log('🎨 하이라이트 데이터 전송:', highlightData);
console.log('🎨 현재 선택된 색상:', this.selectedHighlightColor);
let highlight;
if (window.documentViewerInstance.contentType === 'note') {
const noteHighlightData = {
note_document_id: window.documentViewerInstance.documentId,
...highlightData
};
highlight = await this.api.post('/note-highlights/', noteHighlightData);
} else {
// 문서 하이라이트의 경우 document_id 추가
const documentHighlightData = {
document_id: window.documentViewerInstance.documentId,
...highlightData
};
console.log('🔍 최종 전송 데이터:', documentHighlightData);
highlight = await this.api.createHighlight(documentHighlightData);
}
console.log('🔍 생성된 하이라이트 응답:', highlight);
console.log('🎨 응답에서 받은 색상:', highlight.highlight_color);
this.highlights.push(highlight);
// 마지막 생성된 하이라이트 저장 (메모 생성용)
this.lastCreatedHighlight = highlight;
// 하이라이트 렌더링
this.renderHighlights();
// 선택 해제
window.getSelection().removeAllRanges();
this.selectedText = '';
this.selectedRange = null;
// ViewerCore의 selectedText도 동기화 (메모 모달에서 사용하기 전에는 유지)
// 메모 모달이 열리기 전에는 selectedText를 유지해야 함
// 하이라이트 버튼 제거
const button = document.querySelector('.highlight-button');
if (button) {
button.remove();
}
console.log('✅ 하이라이트 생성 완료:', highlight);
console.log('🔍 생성된 하이라이트 데이터 구조:', JSON.stringify(highlight, null, 2));
console.log('🔍 생성된 하이라이트 색상 필드들:', {
color: highlight.color,
highlight_color: highlight.highlight_color,
background_color: highlight.background_color
});
// 메모 입력 모달 열기
if (window.documentViewerInstance) {
window.documentViewerInstance.openNoteInputModal();
}
} catch (error) {
console.error('하이라이트 생성 실패:', error);
alert('하이라이트 생성에 실패했습니다: ' + error.message);
}
}
/**
* 하이라이트에 메모 생성
*/
async createNoteForHighlight(highlight, content, tags = '') {
try {
console.log('📝 하이라이트에 메모 생성:', highlight.id, content);
const noteData = {
highlight_id: highlight.id,
content: content,
tags: tags
};
// 노트 타입에 따라 다른 API 호출
if (window.documentViewerInstance.contentType === 'note') {
noteData.note_document_id = window.documentViewerInstance.documentId;
} else {
noteData.document_id = window.documentViewerInstance.documentId;
}
const note = await this.api.createNote(noteData);
// 메모 목록에 추가
if (!this.notes) this.notes = [];
this.notes.push(note);
console.log('✅ 메모 생성 완료:', note);
// 메모 데이터 새로고침 (캐시 무효화)
await this.loadNotes(window.documentViewerInstance.documentId, window.documentViewerInstance.contentType);
} catch (error) {
console.error('❌ 메모 생성 실패:', error);
throw error;
}
}
/**
* 하이라이트 색상 변경
*/
async updateHighlightColor(highlightId, newColor) {
try {
console.log('🎨 하이라이트 색상 업데이트:', highlightId, newColor);
// API 호출 (구현 필요)
await this.api.updateHighlight(highlightId, { highlight_color: newColor });
// 로컬 데이터 업데이트
const highlight = this.highlights.find(h => h.id === highlightId);
if (highlight) {
highlight.highlight_color = newColor;
}
// 하이라이트 다시 렌더링
this.renderHighlights();
this.hideTooltip();
console.log('✅ 하이라이트 색상 변경 완료');
} catch (error) {
console.error('❌ 하이라이트 색상 변경 실패:', error);
throw error;
}
}
/**
* 하이라이트 복사
*/
async duplicateHighlight(highlightId) {
try {
console.log('📋 하이라이트 복사:', highlightId);
const originalHighlight = this.highlights.find(h => h.id === highlightId);
if (!originalHighlight) {
throw new Error('원본 하이라이트를 찾을 수 없습니다.');
}
// 새 하이라이트 데이터 생성 (약간 다른 위치에)
const duplicateData = {
document_id: originalHighlight.document_id,
start_offset: originalHighlight.start_offset,
end_offset: originalHighlight.end_offset,
selected_text: originalHighlight.selected_text,
highlight_color: originalHighlight.highlight_color,
highlight_type: originalHighlight.highlight_type
};
// API 호출
const newHighlight = await this.api.createHighlight(duplicateData);
// 로컬 데이터에 추가
this.highlights.push(newHighlight);
// 하이라이트 다시 렌더링
this.renderHighlights();
this.hideTooltip();
console.log('✅ 하이라이트 복사 완료:', newHighlight);
} catch (error) {
console.error('❌ 하이라이트 복사 실패:', error);
throw error;
}
}
/**
* 메모 업데이트
*/
async updateNote(noteId, newContent) {
try {
console.log('✏️ 메모 업데이트:', noteId, newContent);
// API 호출
const apiToUse = this.cachedApi || this.api;
await apiToUse.updateNote(noteId, { content: newContent });
// 로컬 데이터 업데이트
const note = this.notes.find(n => n.id === noteId);
if (note) {
note.content = newContent;
}
// 툴팁 새로고침 (현재 표시 중인 경우)
const tooltip = document.getElementById('highlight-tooltip');
if (tooltip) {
// 간단히 툴팁을 다시 로드하는 대신 텍스트만 업데이트
const noteElement = document.querySelector(`[data-note-id="${noteId}"] .text-gray-800`);
if (noteElement) {
noteElement.textContent = newContent;
}
}
console.log('✅ 메모 업데이트 완료');
} catch (error) {
console.error('❌ 메모 업데이트 실패:', error);
throw error;
}
}
/**
* 텍스트 오프셋 계산
*/
getTextOffset(root, node, offset) {
let textOffset = 0;
const walker = document.createTreeWalker(
root,
NodeFilter.SHOW_TEXT,
null,
false
);
let currentNode;
while (currentNode = walker.nextNode()) {
if (currentNode === node) {
return textOffset + offset;
}
textOffset += currentNode.textContent.length;
}
return textOffset;
}
/**
* 메모 저장
*/
async saveNote() {
const noteContent = window.documentViewerInstance.noteForm.content;
const tags = window.documentViewerInstance.noteForm.tags;
if (!noteContent.trim()) {
alert('메모 내용을 입력해주세요.');
return;
}
try {
window.documentViewerInstance.noteLoading = true;
const noteData = {
content: noteContent,
tags: tags
};
let savedNote;
if (window.documentViewerInstance.contentType === 'note') {
noteData.note_document_id = window.documentViewerInstance.documentId;
savedNote = await this.api.post('/note-notes/', noteData);
} else {
savedNote = await this.api.createNote(noteData);
}
this.notes.push(savedNote);
// 폼 초기화
window.documentViewerInstance.noteForm.content = '';
window.documentViewerInstance.noteForm.tags = '';
window.documentViewerInstance.showNoteModal = false;
console.log('메모 저장 완료:', savedNote);
} catch (error) {
console.error('메모 저장 실패:', error);
alert('메모 저장에 실패했습니다: ' + error.message);
} finally {
window.documentViewerInstance.noteLoading = false;
}
}
/**
* 메모 삭제
*/
async deleteNote(noteId) {
if (!confirm('이 메모를 삭제하시겠습니까?')) {
return;
}
try {
await this.api.deleteNote(noteId);
this.notes = this.notes.filter(n => n.id !== noteId);
// ViewerCore의 filterNotes 호출
if (window.documentViewerInstance.filterNotes) {
window.documentViewerInstance.filterNotes();
}
console.log('메모 삭제 완료:', noteId);
} catch (error) {
console.error('메모 삭제 실패:', error);
alert('메모 삭제에 실패했습니다: ' + error.message);
}
}
/**
* 하이라이트 삭제
*/
async deleteHighlight(highlightId) {
try {
await this.api.delete(`/highlights/${highlightId}`);
// 하이라이트 배열에서 제거
this.highlights = this.highlights.filter(h => h.id !== highlightId);
// 메모 배열에서도 해당 하이라이트의 메모들 제거
this.notes = this.notes.filter(note => note.highlight_id !== highlightId);
// 캐시 무효화 (하이라이트와 메모 모두)
if (window.documentViewerInstance && window.documentViewerInstance.cacheManager) {
window.documentViewerInstance.cacheManager.invalidateCategory('highlights');
window.documentViewerInstance.cacheManager.invalidateCategory('notes');
console.log('🗑️ 하이라이트 삭제 후 캐시 무효화 완료');
}
// 화면 다시 렌더링
this.renderHighlights();
console.log('하이라이트 삭제 완료:', highlightId);
} catch (error) {
console.error('하이라이트 삭제 실패:', error);
alert('하이라이트 삭제에 실패했습니다: ' + error.message);
}
}
/**
* 선택된 텍스트로 메모 생성
*/
async createNoteFromSelection(documentId, contentType) {
if (!this.selectedText || !this.selectedRange) return;
try {
console.log('📝 메모 생성 시작:', this.selectedText);
// 하이라이트 생성
await this.createHighlight();
// 생성된 하이라이트 찾기 (가장 최근 생성된 것)
const highlightData = this.highlights[this.highlights.length - 1];
// 메모 내용 입력받기
const content = prompt('메모 내용을 입력하세요:', '');
if (content === null) {
// 취소한 경우 하이라이트 제거
if (highlightData && highlightData.id) {
await this.api.deleteHighlight(highlightData.id);
this.highlights = this.highlights.filter(h => h.id !== highlightData.id);
this.renderHighlights();
}
return;
}
// 메모 생성
const noteData = {
highlight_id: highlightData.id,
content: content
};
// 노트와 문서에 따라 다른 API 호출
let note;
if (contentType === 'note') {
noteData.note_id = documentId; // 노트 메모는 note_id 필요
note = await this.api.post('/note-notes/', noteData);
} else {
// 문서 메모는 document_id 필요
noteData.document_id = documentId;
note = await this.api.createNote(noteData);
}
this.notes.push(note);
console.log('✅ 메모 생성 완료:', note);
alert('메모가 생성되었습니다.');
} catch (error) {
console.error('메모 생성 실패:', error);
alert('메모 생성에 실패했습니다: ' + error.message);
}
}
/**
* 하이라이트 클릭 시 모달 표시
*/
showHighlightModal(highlights) {
console.log('🔍 하이라이트 모달 표시:', highlights);
// 첫 번째 하이라이트로 툴팁 표시
const firstHighlight = highlights[0];
const element = document.querySelector(`[data-highlight-id="${firstHighlight.id}"]`);
if (element) {
this.showHighlightTooltip(firstHighlight, element);
}
}
/**
* 동일한 텍스트 범위의 모든 하이라이트 찾기
*/
findOverlappingHighlights(clickedHighlight) {
const overlapping = [];
this.highlights.forEach(highlight => {
// 텍스트 범위가 겹치는지 확인
const isOverlapping = (
(highlight.start_offset <= clickedHighlight.end_offset &&
highlight.end_offset >= clickedHighlight.start_offset) ||
(clickedHighlight.start_offset <= highlight.end_offset &&
clickedHighlight.end_offset >= highlight.start_offset)
);
if (isOverlapping) {
overlapping.push(highlight);
}
});
// 시작 위치 순으로 정렬
return overlapping.sort((a, b) => a.start_offset - b.start_offset);
}
/**
* 색상별로 하이라이트 그룹화
*/
groupHighlightsByColor(highlights) {
const colorGroups = {};
highlights.forEach(highlight => {
const color = highlight.highlight_color || highlight.color || '#FFFF00';
if (!colorGroups[color]) {
colorGroups[color] = [];
}
colorGroups[color].push(highlight);
});
return colorGroups;
}
/**
* 색상 이름 반환
*/
getColorName(color) {
const colorNames = {
'#FFFF00': '노란색',
'#90EE90': '초록색',
'#FFCCCB': '분홍색',
'#87CEEB': '파란색'
};
return colorNames[color] || '기타';
}
/**
* 날짜 포맷팅 (상세)
*/
formatDate(dateString) {
if (!dateString) return '알 수 없음';
const date = new Date(dateString);
return date.toLocaleDateString('ko-KR', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
/**
* 날짜 포맷팅 (간단)
*/
formatShortDate(dateString) {
if (!dateString) return '';
const date = new Date(dateString);
const now = new Date();
const diffTime = Math.abs(now - date);
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays === 1) {
return '오늘';
} else if (diffDays === 2) {
return '어제';
} else if (diffDays <= 7) {
return `${diffDays - 1}일 전`;
} else {
return date.toLocaleDateString('ko-KR', {
month: 'short',
day: 'numeric'
});
}
}
/**
* 하이라이트 툴팁 표시
*/
async showHighlightTooltip(clickedHighlight, element) {
// 기존 말풍선 제거
this.hideTooltip();
// 메모 데이터 다시 로드 (최신 상태 보장)
console.log('📝 하이라이트 툴팁용 메모 로드 시작...');
const documentId = window.documentViewerInstance.documentId;
const contentType = window.documentViewerInstance.contentType;
console.log('📝 메모 로드 파라미터:', { documentId, contentType });
console.log('📝 기존 메모 개수:', this.notes ? this.notes.length : 'undefined');
await this.loadNotes(documentId, contentType);
console.log('📝 메모 로드 완료:', this.notes.length, '개');
console.log('📝 로드된 메모 상세:', this.notes.map(n => ({
id: n.id,
highlight_id: n.highlight_id,
content: n.content,
created_at: n.created_at
})));
// 동일한 범위의 모든 하이라이트 찾기
const overlappingHighlights = this.findOverlappingHighlights(clickedHighlight);
const colorGroups = this.groupHighlightsByColor(overlappingHighlights);
console.log('🎨 겹치는 하이라이트:', overlappingHighlights.length, '개');
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-lg';
tooltip.style.minWidth = '350px';
// 선택된 텍스트 표시 (가장 긴 텍스트 사용)
const longestText = overlappingHighlights.reduce((longest, current) =>
current.selected_text.length > longest.length ? current.selected_text : longest, ''
);
let tooltipHTML = `
<div class="mb-4">
<div class="flex items-center justify-between mb-3">
<div class="text-lg font-semibold text-blue-800 flex items-center">
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path>
</svg>
하이라이트 정보
</div>
<div class="text-xs text-gray-500">${overlappingHighlights.length}개 하이라이트</div>
</div>
<div class="font-medium text-gray-900 bg-blue-50 px-4 py-3 rounded-lg border-l-4 border-blue-500">
<div class="text-sm text-blue-700 mb-1">선택된 텍스트</div>
<div class="text-base">"${longestText}"</div>
</div>
</div>
`;
// 색상별로 메모 표시
tooltipHTML += '<div class="space-y-4">';
Object.entries(colorGroups).forEach(([color, highlights]) => {
const colorName = this.getColorName(color);
// 각 하이라이트에 대한 메모 찾기 (디버깅 로그 추가)
const allNotes = highlights.flatMap(h => {
const notesForHighlight = this.notes.filter(note => note.highlight_id === h.id);
console.log(`📝 하이라이트 ${h.id}에 대한 메모:`, notesForHighlight.length, '개');
if (notesForHighlight.length > 0) {
console.log('📝 메모 내용:', notesForHighlight.map(n => n.content));
}
return notesForHighlight;
});
console.log(`🎨 ${colorName} 하이라이트의 총 메모:`, allNotes.length, '개');
const createdDate = highlights[0].created_at ? this.formatDate(highlights[0].created_at) : '알 수 없음';
tooltipHTML += `
<div class="border rounded-lg p-4 bg-gradient-to-r from-gray-50 to-gray-100" style="border-left: 4px solid ${color}">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center space-x-3">
<div class="w-4 h-4 rounded-full shadow-sm" style="background-color: ${color}"></div>
<div>
<span class="text-sm font-semibold text-gray-800">${colorName} 하이라이트</span>
<div class="text-xs text-gray-600">${createdDate} 생성</div>
</div>
</div>
<div class="flex items-center space-x-2">
<button onclick="window.documentViewerInstance.highlightManager.changeHighlightColor('${highlights[0].id}')"
class="text-xs bg-gray-500 text-white px-2 py-1 rounded hover:bg-gray-600 transition-colors"
title="색상 변경">
🎨
</button>
<button onclick="window.documentViewerInstance.highlightManager.showAddNoteForm('${highlights[0].id}')"
class="text-xs bg-blue-500 text-white px-2 py-1 rounded hover:bg-blue-600 transition-colors">
📝 메모 추가
</button>
</div>
</div>
<!-- 메모 목록 -->
<div class="mb-3">
<div class="text-sm font-medium text-gray-700 mb-2 flex items-center">
<svg class="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z"></path>
<path fill-rule="evenodd" d="M4 5a2 2 0 012-2v1a1 1 0 102 0V3a2 2 0 012 0v1a1 1 0 102 0V3a2 2 0 012 2v6.586A2 2 0 0115.414 13L13 15.586A2 2 0 0111.586 16H6a2 2 0 01-2-2V5zm8 4a1 1 0 10-2 0v2a1 1 0 102 0V9z" clip-rule="evenodd"></path>
</svg>
메모 (${allNotes.length}개)
</div>
<div id="notes-list-${highlights[0].id}" class="space-y-2 max-h-40 overflow-y-auto">
${allNotes.length > 0 ?
allNotes.map(note => `
<div class="bg-white p-3 rounded-lg border shadow-sm group">
<div class="text-gray-800 text-sm leading-relaxed">${note.content}</div>
<div class="text-xs text-gray-500 mt-2 flex justify-between items-center">
<span class="flex items-center">
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" clip-rule="evenodd"></path>
</svg>
${this.formatShortDate(note.created_at)}
</span>
<div class="opacity-0 group-hover:opacity-100 transition-opacity">
<button onclick="window.documentViewerInstance.highlightManager.editNote('${note.id}', '${note.content.replace(/'/g, "\\'")}');"
class="text-blue-600 hover:text-blue-800 mr-2 text-xs"
title="메모 편집">
✏️
</button>
<button onclick="window.documentViewerInstance.highlightManager.deleteNote('${note.id}')"
class="text-red-600 hover:text-red-800 text-xs"
title="메모 삭제">
🗑️
</button>
</div>
</div>
</div>
`).join('') :
'<div class="text-sm text-gray-500 italic bg-white p-3 rounded-lg border">메모가 없습니다. 위의 "📝 메모 추가" 버튼을 클릭해보세요!</div>'
}
</div>
</div>
</div>
`;
});
tooltipHTML += '</div>';
// 하이라이트 관리 버튼들
tooltipHTML += `
<div class="mt-4 pt-4 border-t border-gray-200">
<div class="flex items-center justify-between">
<div class="text-sm font-medium text-gray-700 flex items-center">
<svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd"></path>
</svg>
하이라이트 관리
</div>
<div class="flex items-center space-x-2">
<button onclick="window.documentViewerInstance.highlightManager.duplicateHighlight('${clickedHighlight.id}')"
class="text-xs bg-green-500 text-white px-3 py-1 rounded hover:bg-green-600 transition-colors flex items-center"
title="하이라이트 복사">
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path d="M8 3a1 1 0 011-1h2a1 1 0 110 2H9a1 1 0 01-1-1z"></path>
<path d="M6 3a2 2 0 00-2 2v11a2 2 0 002 2h8a2 2 0 002-2V5a2 2 0 00-2-2 3 3 0 01-3 3H9a3 3 0 01-3-3z"></path>
</svg>
복사
</button>
<button onclick="window.documentViewerInstance.deleteHighlightWithConfirm('${clickedHighlight.id}')"
class="text-xs bg-red-500 text-white px-3 py-1 rounded hover:bg-red-600 transition-colors flex items-center"
title="하이라이트 삭제">
<svg class="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
삭제
</button>
</div>
</div>
</div>
`;
tooltipHTML += `
<div class="flex justify-end mt-3">
<button onclick="window.documentViewerInstance.highlightManager.hideTooltip()"
class="text-xs bg-gray-500 text-white px-3 py-1 rounded hover:bg-gray-600">
닫기
</button>
</div>
`;
tooltip.innerHTML = tooltipHTML;
// 위치 계산
const rect = element.getBoundingClientRect();
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft;
document.body.appendChild(tooltip);
// 말풍선 위치 조정
const tooltipRect = tooltip.getBoundingClientRect();
let top = rect.bottom + scrollTop + 5;
let left = rect.left + scrollLeft;
// 화면 경계 체크
if (left + tooltipRect.width > window.innerWidth) {
left = window.innerWidth - tooltipRect.width - 10;
}
if (top + tooltipRect.height > window.innerHeight + scrollTop) {
top = rect.top + scrollTop - tooltipRect.height - 5;
}
tooltip.style.top = top + 'px';
tooltip.style.left = left + 'px';
// 외부 클릭 시 닫기
setTimeout(() => {
document.addEventListener('click', this.handleTooltipOutsideClick.bind(this));
}, 100);
}
/**
* 말풍선 숨기기
*/
hideTooltip() {
const highlightTooltip = document.getElementById('highlight-tooltip');
if (highlightTooltip) {
highlightTooltip.remove();
}
document.removeEventListener('click', this.handleTooltipOutsideClick.bind(this));
}
/**
* 말풍선 외부 클릭 처리
*/
handleTooltipOutsideClick(e) {
const highlightTooltip = document.getElementById('highlight-tooltip');
const isOutsideHighlightTooltip = highlightTooltip && !highlightTooltip.contains(e.target);
if (isOutsideHighlightTooltip) {
this.hideTooltip();
}
}
/**
* 메모 추가 폼 표시
*/
showAddNoteForm(highlightId) {
console.log('🔍 showAddNoteForm 호출됨, highlightId:', highlightId);
const tooltip = document.getElementById('highlight-tooltip');
if (!tooltip) return;
const notesList = document.getElementById(`notes-list-${highlightId}`);
if (!notesList) return;
// 기존 폼이 있으면 제거
const existingForm = document.getElementById('add-note-form');
if (existingForm) {
existingForm.remove();
}
const formHTML = `
<div id="add-note-form" class="mt-2 p-2 bg-blue-50 rounded border">
<textarea id="new-note-content"
placeholder="메모를 입력하세요..."
class="w-full p-2 border border-gray-300 rounded text-sm resize-none"
rows="3"></textarea>
<div class="flex justify-end mt-2 space-x-2">
<button onclick="window.documentViewerInstance.highlightManager.cancelAddNote('${highlightId}')"
class="text-xs bg-gray-500 text-white px-3 py-1 rounded hover:bg-gray-600">
취소
</button>
<button onclick="window.documentViewerInstance.highlightManager.saveNewNote('${highlightId}')"
class="text-xs bg-blue-500 text-white px-3 py-1 rounded hover:bg-blue-600">
저장
</button>
</div>
</div>
`;
notesList.insertAdjacentHTML('afterend', formHTML);
// 텍스트 영역에 포커스
setTimeout(() => {
document.getElementById('new-note-content').focus();
}, 100);
}
/**
* 메모 추가 취소
*/
cancelAddNote(highlightId) {
const form = document.getElementById('add-note-form');
if (form) {
form.remove();
}
// 툴팁 다시 표시
const highlight = this.highlights.find(h => h.id === highlightId);
if (highlight) {
const element = document.querySelector(`[data-highlight-id="${highlightId}"]`);
if (element) {
this.showHighlightTooltip(highlight, element);
}
}
}
/**
* 새 메모 저장
*/
async saveNewNote(highlightId) {
const content = document.getElementById('new-note-content').value.trim();
if (!content) {
alert('메모 내용을 입력해주세요');
return;
}
try {
const noteData = {
highlight_id: highlightId,
content: content
};
// 문서 타입에 따라 다른 API 호출
let note;
if (window.documentViewerInstance.contentType === 'note') {
noteData.note_id = window.documentViewerInstance.documentId;
note = await this.api.post('/note-notes/', noteData);
} else {
noteData.document_id = window.documentViewerInstance.documentId;
note = await this.api.createNote(noteData);
}
this.notes.push(note);
// 폼 제거
const form = document.getElementById('add-note-form');
if (form) {
form.remove();
}
// 툴팁 다시 표시
const highlight = this.highlights.find(h => h.id === highlightId);
if (highlight) {
const element = document.querySelector(`[data-highlight-id="${highlightId}"]`);
if (element) {
this.showHighlightTooltip(highlight, element);
}
}
} catch (error) {
console.error('Failed to save note:', error);
alert('메모 저장에 실패했습니다');
}
}
/**
* 날짜 포맷팅 (짧은 형식)
*/
formatShortDate(dateString) {
if (!dateString) return '';
const date = new Date(dateString);
const now = new Date();
const diffTime = Math.abs(now - date);
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays === 1) {
return '오늘';
} else if (diffDays <= 7) {
return `${diffDays}일 전`;
} else {
return date.toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' });
}
}
/**
* 텍스트 선택 리스너 제거 (정리용)
*/
removeTextSelectionListener() {
if (this.textSelectionHandler) {
document.removeEventListener('mouseup', this.textSelectionHandler);
this.textSelectionHandler = null;
}
}
}
// 전역으로 내보내기
window.HighlightManager = HighlightManager;