주요 수정사항: - 하이라이트 생성 시 color → highlight_color 필드명 수정으로 색상 전달 문제 해결 - 분홍색을 더 연하게 변경하여 글씨 가독성 향상 - 다중 하이라이트 렌더링을 위아래 균등 분할로 개선 - CSS highlight-span 클래스 추가 및 색상 적용 강화 - 하이라이트 생성/렌더링 과정에 상세한 디버깅 로그 추가 UI 개선: - 단일 하이라이트: 선택한 색상으로 정확히 표시 - 다중 하이라이트: 위아래로 균등하게 색상 분할 표시 - 메모 입력 모달에서 선택된 텍스트 표시 개선 버그 수정: - 프론트엔드-백엔드 API 스키마 불일치 해결 - CSS 스타일 우선순위 문제 해결 - 하이라이트 색상이 노랑색으로만 표시되던 문제 해결
1018 lines
37 KiB
JavaScript
1018 lines
37 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();
|
|
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;
|