Files
document-server/frontend/static/js/viewer/features/highlight-manager.js
Hyungi Ahn 397c63979d feat: 링크와 백링크 기능 개선 및 겹침 처리 구현
 주요 기능 추가:
- 링크 생성 후 즉시 렌더링 (캐시 무효화 수정)
- 하이라이트, 링크, 백링크 겹침 시 선택 팝업 메뉴
- 백링크 오프셋 기반 정확한 렌더링
- 링크 렌더링 디버깅 로직 강화

🔧 기술적 개선:
- 캐시 무효화 함수 수정 (invalidateRelatedCache 사용)
- 텍스트 오프셋 불일치 시 자동 검색 대체
- 겹치는 요소 감지 및 상호작용 처리
- 상세한 디버깅 로그 추가

🎨 UI/UX 개선:
- 겹침 메뉴에서 각 요소별 아이콘과 색상 구분
- 클릭된 요소 시각적 표시
- 툴팁과 메뉴 외부 클릭 처리

🐛 버그 수정:
- 신규 링크 생성 후 표시되지 않는 문제 해결
- 백링크 렌더링 오프셋 정확성 개선
- 캐시 관련 JavaScript 오류 수정
2025-09-02 13:42:01 +09:00

1028 lines
38 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 {
if (contentType === 'note') {
this.notes = await this.api.get(`/note/${documentId}/notes`).catch(() => []);
} else {
this.notes = await this.cachedApi.get('/notes', { document_id: documentId, content_type: contentType }, { category: 'notes' }).catch(() => []);
}
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', (e) => {
e.preventDefault();
e.stopPropagation();
// 겹치는 링크나 백링크가 있는지 확인
if (window.documentViewerInstance && window.documentViewerInstance.linkManager) {
const overlappingElements = window.documentViewerInstance.linkManager.findOverlappingElements(span);
if (overlappingElements.length > 0) {
window.documentViewerInstance.linkManager.showOverlapMenu(e, span, overlappingElements, 'highlight');
return;
}
}
this.showHighlightModal(highlights);
});
// 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;
}
}
/**
* 텍스트 오프셋 계산
*/
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.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] || '기타';
}
/**
* 하이라이트 툴팁 표시
*/
showHighlightTooltip(clickedHighlight, element) {
// 기존 말풍선 제거
this.hideTooltip();
// 동일한 범위의 모든 하이라이트 찾기
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="text-sm text-gray-600 mb-2">선택된 텍스트</div>
<div class="font-medium text-gray-900 bg-gray-100 px-3 py-2 rounded border-l-4 border-blue-500">
"${longestText}"
</div>
</div>
`;
// 색상별로 메모 표시
tooltipHTML += '<div class="space-y-4">';
Object.entries(colorGroups).forEach(([color, highlights]) => {
const colorName = this.getColorName(color);
const allNotes = highlights.flatMap(h =>
this.notes.filter(note => note.highlight_id === h.id)
);
tooltipHTML += `
<div class="border rounded-lg p-3" style="border-left: 4px solid ${color}">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center space-x-2">
<div class="w-3 h-3 rounded" style="background-color: ${color}"></div>
<span class="text-sm font-medium text-gray-700">${colorName} 메모 (${allNotes.length})</span>
</div>
<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">
+ 추가
</button>
</div>
<div id="notes-list-${highlights[0].id}" class="space-y-2 max-h-32 overflow-y-auto">
${allNotes.length > 0 ?
allNotes.map(note => `
<div class="bg-gray-50 p-2 rounded text-sm">
<div class="text-gray-800">${note.content}</div>
<div class="text-xs text-gray-500 mt-1 flex justify-between items-center">
<span>${this.formatShortDate(note.created_at)} · Administrator</span>
<button onclick="window.documentViewerInstance.highlightManager.deleteNote('${note.id}')"
class="text-red-600 hover:text-red-800">
삭제
</button>
</div>
</div>
`).join('') :
'<div class="text-sm text-gray-500 italic">메모가 없습니다</div>'
}
</div>
</div>
`;
});
tooltipHTML += '</div>';
// 하이라이트 삭제 버튼
tooltipHTML += `
<div class="mt-4 pt-3 border-t">
<button onclick="window.documentViewerInstance.highlightManager.deleteHighlight('${clickedHighlight.id}')"
class="text-xs bg-red-500 text-white px-3 py-1 rounded hover:bg-red-600">
하이라이트 삭제
</button>
</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;