Files
document-server/frontend/static/js/viewer.js
Hyungi Ahn 3e0a03f149 🐛 Fix Alpine.js SyntaxError and backlink visibility issues
- Fix SyntaxError in viewer.js line 2868 (.bind(this) issue in setTimeout)
- Resolve Alpine.js 'Can't find variable' errors (documentViewer, goBack, etc.)
- Fix backlink rendering and persistence during temporary highlights
- Add backlink protection and restoration mechanism in highlightAndScrollToText
- Implement Note Management System with hierarchical notebooks
- Add note highlights and memos functionality
- Update cache version to force browser refresh (v=2025012641)
- Add comprehensive logging for debugging backlink issues
2025-08-26 23:50:48 +09:00

3713 lines
152 KiB
JavaScript

/**
* 문서 뷰어 Alpine.js 컴포넌트
*/
window.documentViewer = () => ({
// 상태
loading: true,
error: null,
document: null,
documentId: null,
contentType: 'document', // 'document' 또는 'note'
navigation: null, // 네비게이션 정보
// 하이라이트 및 메모
highlights: [],
notes: [],
selectedHighlightColor: '#FFFF00',
selectedText: '',
selectedRange: null,
// 책갈피
bookmarks: [],
// 문서 링크
documentLinks: [],
linkableDocuments: [],
backlinks: [],
// 텍스트 선택 모드 플래그
textSelectorUISetup: false,
// UI 상태
showNotesPanel: false,
showBookmarksPanel: false,
showBacklinks: false,
activePanel: 'notes',
// 검색
searchQuery: '',
noteSearchQuery: '',
filteredNotes: [],
// 언어 전환
isKorean: false,
// 모달
showNoteModal: false,
showBookmarkModal: false,
showLinkModal: false,
showNotesModal: false,
showBookmarksModal: false,
showBacklinksModal: false,
showLinksModal: false,
activeFeatureMenu: null,
activeMode: null, // 'link', 'memo', 'bookmark' 등
textSelectionHandler: null,
availableBooks: [], // 사용 가능한 서적 목록
filteredDocuments: [], // 필터링된 문서 목록
editingNote: null,
editingBookmark: null,
editingLink: null,
noteLoading: false,
bookmarkLoading: false,
linkLoading: false,
// 폼 데이터
noteForm: {
content: '',
tags: ''
},
bookmarkForm: {
title: '',
description: ''
},
linkForm: {
target_document_id: '',
selected_text: '',
start_offset: 0,
end_offset: 0,
link_text: '',
description: '',
// 고급 링크 기능
link_type: 'document',
target_text: '',
target_start_offset: 0,
target_end_offset: 0
},
// 초기화
async init() {
// 전역 인스턴스 설정 (말풍선에서 함수 호출용)
window.documentViewerInstance = this;
// URL에서 문서 ID 추출
const urlParams = new URLSearchParams(window.location.search);
this.documentId = urlParams.get('id');
this.contentType = urlParams.get('type') || 'document'; // 'document' 또는 'note'
const mode = urlParams.get('mode');
const isParentWindow = urlParams.get('parent_window') === 'true';
console.log('🔍 URL 파싱 결과:', {
documentId: this.documentId,
mode: mode,
parent_window: urlParams.get('parent_window'),
isParentWindow: isParentWindow,
fullUrl: window.location.href
});
// 함수들이 제대로 바인딩되었는지 확인
console.log('🔧 Alpine.js 컴포넌트 로드됨');
console.log('🔗 activateLinkMode 함수:', typeof this.activateLinkMode);
console.log('📝 activateNoteMode 함수:', typeof this.activateNoteMode);
console.log('🔖 activateBookmarkMode 함수:', typeof this.activateBookmarkMode);
console.log('🎯 toggleFeatureMenu 함수:', typeof this.toggleFeatureMenu);
if (!this.documentId) {
this.error = '문서 ID가 없습니다';
this.loading = false;
return;
}
// 텍스트 선택 모드인 경우 특별 처리
console.log('🔍 URL 파라미터 확인:', { mode, isParentWindow, documentId: this.documentId });
if (mode === 'text_selector') {
console.log('🎯 텍스트 선택 모드로 진입');
await this.initTextSelectorMode();
return;
}
// 인증 확인
if (!api.token) {
window.location.href = '/';
return;
}
try {
if (this.contentType === 'note') {
await this.loadNote();
} else {
await this.loadDocument();
await this.loadNavigation();
}
await this.loadDocumentData();
// URL 파라미터 확인해서 특정 텍스트로 스크롤
this.checkForTextHighlight();
} catch (error) {
console.error('Failed to load document:', error);
this.error = error.message;
} finally {
this.loading = false;
}
// 초기 필터링
this.filterNotes();
},
// 노트 로드
async loadNote() {
try {
console.log('📝 노트 로드 시작:', this.documentId);
// 백엔드에서 노트 정보 가져오기
this.document = await api.get(`/note-documents/${this.documentId}`);
// 노트 제목 설정
document.title = `${this.document.title} - Document Server`;
// 노트 내용을 HTML로 설정
const contentElement = document.getElementById('document-content');
if (contentElement && this.document.content) {
contentElement.innerHTML = this.document.content;
}
console.log('📝 노트 로드 완료:', this.document.title);
} catch (error) {
console.error('노트 로드 실패:', error);
throw new Error('노트를 불러올 수 없습니다');
}
},
// 문서 로드 (실제 API 연동)
async loadDocument() {
try {
// 백엔드에서 문서 정보 가져오기
this.document = await api.getDocument(this.documentId);
// HTML 파일 경로 구성 (백엔드 서버를 통해 접근)
const htmlPath = this.document.html_path;
const fileName = htmlPath.split('/').pop();
const response = await fetch(`http://localhost:24102/uploads/documents/${fileName}`);
if (!response.ok) {
throw new Error('문서 파일을 불러올 수 없습니다');
}
const htmlContent = await response.text();
document.getElementById('document-content').innerHTML = htmlContent;
// 페이지 제목 업데이트
document.title = `${this.document.title} - Document Server`;
// 문서 내 스크립트 오류 방지를 위한 전역 함수들 정의
this.setupDocumentScriptHandlers();
} catch (error) {
console.error('Document load error:', error);
// 백엔드 연결 실패시 목업 데이터로 폴백
console.warn('Using fallback mock data');
this.document = {
id: this.documentId,
title: 'Document Server 테스트 문서',
description: '하이라이트와 메모 기능을 테스트하기 위한 샘플 문서입니다.',
uploader_name: '관리자'
};
// 기본 HTML 내용 표시
document.getElementById('document-content').innerHTML = `
<h1>테스트 문서</h1>
<p>이 문서는 Document Server의 하이라이트 및 메모 기능을 테스트하기 위한 샘플입니다.</p>
<p>텍스트를 선택하면 하이라이트를 추가할 수 있습니다.</p>
<h2>주요 기능</h2>
<ul>
<li>텍스트 선택 후 하이라이트 생성</li>
<li>하이라이트에 메모 추가</li>
<li>메모 검색 및 관리</li>
<li>책갈피 기능</li>
</ul>
<h2>테스트 단락</h2>
<p>이것은 하이라이트 테스트를 위한 긴 단락입니다. 이 텍스트를 선택하여 하이라이트를 만들어보세요.
하이라이트를 만든 후에는 메모를 추가할 수 있습니다. 메모는 나중에 검색하고 편집할 수 있습니다.</p>
<p>또 다른 단락입니다. 여러 개의 하이라이트를 만들어서 메모 기능을 테스트해보세요.
각 하이라이트는 고유한 색상을 가질 수 있으며, 연결된 메모를 통해 중요한 정보를 기록할 수 있습니다.</p>
`;
// 폴백 모드에서도 스크립트 핸들러 설정
this.setupDocumentScriptHandlers();
// 디버깅을 위한 전역 함수 노출
window.testHighlight = () => {
console.log('Test highlight function called');
const selection = window.getSelection();
console.log('Current selection:', selection.toString());
this.handleTextSelection();
};
}
},
// 문서 내 스크립트 핸들러 설정
setupDocumentScriptHandlers() {
// 업로드된 HTML 문서에서 사용할 수 있는 전역 함수들 정의
// 언어 토글 함수 (많은 문서에서 사용)
window.toggleLanguage = function() {
const koreanContent = document.getElementById('korean-content');
const englishContent = document.getElementById('english-content');
if (koreanContent && englishContent) {
// ID 기반 토글 (압력용기 매뉴얼 등)
if (koreanContent.style.display === 'none') {
koreanContent.style.display = 'block';
englishContent.style.display = 'none';
} else {
koreanContent.style.display = 'none';
englishContent.style.display = 'block';
}
} else {
// 클래스 기반 토글 (다른 문서들)
const koreanElements = document.querySelectorAll('.korean, .ko');
const englishElements = document.querySelectorAll('.english, .en');
koreanElements.forEach(el => {
el.style.display = el.style.display === 'none' ? 'block' : 'none';
});
englishElements.forEach(el => {
el.style.display = el.style.display === 'none' ? 'block' : 'none';
});
}
// 토글 버튼 텍스트 업데이트
const toggleButton = document.querySelector('.language-toggle');
if (toggleButton && koreanContent) {
const isKoreanVisible = koreanContent.style.display !== 'none';
toggleButton.textContent = isKoreanVisible ? '🌐 English' : '🌐 한국어';
}
};
// 기타 공통 함수들 (필요시 추가)
window.showSection = function(sectionId) {
const section = document.getElementById(sectionId);
if (section) {
section.scrollIntoView({ behavior: 'smooth' });
}
};
// 인쇄 함수
window.printDocument = function() {
window.print();
};
// 문서 내 링크 클릭 시 새 창에서 열기 방지
const links = document.querySelectorAll('#document-content a[href^="http"]');
links.forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
if (confirm('외부 링크로 이동하시겠습니까?\n' + link.href)) {
window.open(link.href, '_blank');
}
});
});
},
// 문서 관련 데이터 로드
async loadDocumentData() {
try {
console.log('Loading document data for:', this.documentId, 'type:', this.contentType);
if (this.contentType === 'note') {
// 노트의 경우: 노트용 API 호출
console.log('📝 노트 데이터 로드 중...');
const [highlights, notes] = await Promise.all([
api.get(`/note/${this.documentId}/highlights`).catch(() => []),
api.get(`/note/${this.documentId}/notes`).catch(() => [])
]);
this.highlights = highlights || [];
this.notes = notes || [];
this.bookmarks = []; // 노트에서는 북마크 미지원
this.documentLinks = []; // 노트에서는 링크 미지원 (향후 구현 예정)
this.backlinks = [];
console.log('📝 노트 데이터 로드됨:', { highlights: this.highlights.length, notes: this.notes.length });
// 하이라이트 렌더링
this.renderHighlights();
return;
}
// 문서의 경우: 기존 로직
const [highlights, notes, bookmarks, documentLinks] = await Promise.all([
api.getDocumentHighlights(this.documentId).catch(() => []),
api.getDocumentNotes(this.documentId).catch(() => []),
api.getDocumentBookmarks(this.documentId).catch(() => []),
api.getDocumentLinks(this.documentId).catch(() => [])
]);
this.highlights = highlights || [];
this.notes = notes || [];
this.bookmarks = bookmarks || [];
this.documentLinks = documentLinks || [];
console.log('Loaded data:', { highlights: this.highlights.length, notes: this.notes.length, bookmarks: this.bookmarks.length });
// 하이라이트 렌더링
this.renderHighlights();
// 백링크 렌더링 (이 문서를 참조하는 링크들) - 먼저 렌더링
this.renderBacklinkHighlights();
// 문서 링크 렌더링 - 백링크 후에 렌더링 (백링크 보호)
this.renderDocumentLinks();
// 백링크 데이터 로딩 (배너 숫자 표시용)
this.loadBacklinks();
} catch (error) {
console.warn('Some document data failed to load, continuing with empty data:', error);
this.highlights = [];
this.notes = [];
this.bookmarks = [];
}
},
// 하이라이트 렌더링 (개선된 버전)
renderHighlights() {
const content = document.getElementById('document-content');
// 기존 하이라이트 제거
content.querySelectorAll('.highlight, .multi-highlight').forEach(el => {
const parent = el.parentNode;
parent.replaceChild(document.createTextNode(el.textContent), el);
parent.normalize();
});
// 텍스트 위치별로 하이라이트 그룹화
const positionGroups = this.groupHighlightsByPosition();
// 각 위치 그룹에 대해 하이라이트 적용
Object.values(positionGroups).forEach(group => {
this.applyHighlightGroup(group);
});
},
// 위치별로 하이라이트 그룹화
groupHighlightsByPosition() {
const groups = {};
this.highlights.forEach(highlight => {
// 겹치는 하이라이트들을 찾아서 그룹화
let foundGroup = null;
for (const [key, group] of Object.entries(groups)) {
const hasOverlap = group.some(h =>
(highlight.start_offset < h.end_offset && highlight.end_offset > h.start_offset)
);
if (hasOverlap) {
foundGroup = key;
break;
}
}
if (foundGroup) {
groups[foundGroup].push(highlight);
} else {
// 새 그룹 생성
const groupKey = `${highlight.start_offset}-${highlight.end_offset}`;
groups[groupKey] = [highlight];
}
});
return groups;
},
// 하이라이트 그룹 적용 (여러 색상 지원)
applyHighlightGroup(highlightGroup) {
if (highlightGroup.length === 0) return;
// 그룹의 전체 범위 계산
const minStart = Math.min(...highlightGroup.map(h => h.start_offset));
const maxEnd = Math.max(...highlightGroup.map(h => h.end_offset));
const content = document.getElementById('document-content');
const walker = document.createTreeWalker(
content,
NodeFilter.SHOW_TEXT,
null,
false
);
let currentOffset = 0;
let node;
while (node = walker.nextNode()) {
const nodeLength = node.textContent.length;
const nodeStart = currentOffset;
const nodeEnd = currentOffset + nodeLength;
// 그룹 범위와 겹치는지 확인
if (nodeStart < maxEnd && nodeEnd > minStart) {
const startInNode = Math.max(0, minStart - nodeStart);
const endInNode = Math.min(nodeLength, maxEnd - nodeStart);
if (startInNode < endInNode) {
// 텍스트 노드를 분할하고 하이라이트 적용
const beforeText = node.textContent.substring(0, startInNode);
const highlightText = node.textContent.substring(startInNode, endInNode);
const afterText = node.textContent.substring(endInNode);
const parent = node.parentNode;
// 하이라이트 요소 생성
const highlightEl = this.createMultiColorHighlight(highlightGroup, highlightText, minStart + startInNode);
// 노드 교체
if (beforeText) {
parent.insertBefore(document.createTextNode(beforeText), node);
}
parent.insertBefore(highlightEl, node);
if (afterText) {
parent.insertBefore(document.createTextNode(afterText), node);
}
parent.removeChild(node);
}
}
currentOffset = nodeEnd;
}
},
// 다중 색상 하이라이트 요소 생성
createMultiColorHighlight(highlightGroup, text, textOffset) {
const container = document.createElement('span');
if (highlightGroup.length === 1) {
// 단일 색상
const highlight = highlightGroup[0];
container.className = 'highlight';
container.style.backgroundColor = highlight.highlight_color;
container.textContent = text;
container.dataset.highlightId = highlight.id;
// 클릭 이벤트
container.addEventListener('click', (e) => {
e.stopPropagation();
this.showHighlightTooltip(highlight, e.target);
});
} else {
// 다중 색상 - 그라데이션 또는 스트라이프 효과
container.className = 'multi-highlight';
container.textContent = text;
// 색상들 수집
const colors = highlightGroup.map(h => h.highlight_color);
const uniqueColors = [...new Set(colors)];
if (uniqueColors.length === 2) {
// 2색상: 위아래 분할
container.style.background = `linear-gradient(to bottom, ${uniqueColors[0]} 50%, ${uniqueColors[1]} 50%)`;
} else if (uniqueColors.length === 3) {
// 3색상: 3등분
container.style.background = `linear-gradient(to bottom, ${uniqueColors[0]} 33%, ${uniqueColors[1]} 33% 66%, ${uniqueColors[2]} 66%)`;
} else {
// 4색상 이상: 스트라이프 패턴
const stripeSize = 100 / uniqueColors.length;
const gradientStops = uniqueColors.map((color, index) => {
const start = index * stripeSize;
const end = (index + 1) * stripeSize;
return `${color} ${start}% ${end}%`;
}).join(', ');
container.style.background = `linear-gradient(to bottom, ${gradientStops})`;
}
// 테두리 추가로 더 명확하게
container.style.border = '1px solid rgba(0,0,0,0.2)';
container.style.borderRadius = '2px';
// 모든 하이라이트 ID 저장
container.dataset.highlightIds = JSON.stringify(highlightGroup.map(h => h.id));
// 클릭 이벤트 - 첫 번째 하이라이트로 툴팁 표시
container.addEventListener('click', (e) => {
e.stopPropagation();
this.showHighlightTooltip(highlightGroup[0], e.target);
});
}
return container;
},
// 개별 하이라이트 적용
applyHighlight(highlight) {
const content = document.getElementById('document-content');
const walker = document.createTreeWalker(
content,
NodeFilter.SHOW_TEXT,
null,
false
);
let currentOffset = 0;
let node;
while (node = walker.nextNode()) {
const nodeLength = node.textContent.length;
const nodeStart = currentOffset;
const nodeEnd = currentOffset + nodeLength;
// 하이라이트 범위와 겹치는지 확인
if (nodeStart < highlight.end_offset && nodeEnd > highlight.start_offset) {
const startInNode = Math.max(0, highlight.start_offset - nodeStart);
const endInNode = Math.min(nodeLength, highlight.end_offset - nodeStart);
if (startInNode < endInNode) {
// 텍스트 노드를 분할하고 하이라이트 적용
const beforeText = node.textContent.substring(0, startInNode);
const highlightText = node.textContent.substring(startInNode, endInNode);
const afterText = node.textContent.substring(endInNode);
const parent = node.parentNode;
// 하이라이트 요소 생성
const highlightEl = document.createElement('span');
highlightEl.className = 'highlight';
highlightEl.style.backgroundColor = highlight.highlight_color;
highlightEl.textContent = highlightText;
highlightEl.dataset.highlightId = highlight.id;
// 클릭 이벤트 추가 - 말풍선 표시
highlightEl.addEventListener('click', (e) => {
e.stopPropagation();
this.showHighlightTooltip(highlight, e.target);
});
// 노드 교체
if (beforeText) {
parent.insertBefore(document.createTextNode(beforeText), node);
}
parent.insertBefore(highlightEl, node);
if (afterText) {
parent.insertBefore(document.createTextNode(afterText), node);
}
parent.removeChild(node);
}
}
currentOffset = nodeEnd;
}
},
// 텍스트 선택 처리
handleTextSelection() {
console.log('handleTextSelection called');
const selection = window.getSelection();
if (selection.rangeCount === 0 || selection.isCollapsed) {
console.log('No selection or collapsed');
return;
}
const range = selection.getRangeAt(0);
const selectedText = selection.toString().trim();
console.log('Selected text:', selectedText);
if (selectedText.length < 2) {
console.log('Text too short');
return;
}
// 문서 컨텐츠 내부의 선택인지 확인
const content = document.getElementById('document-content');
if (!content.contains(range.commonAncestorContainer)) {
console.log('Selection not in document content');
return;
}
// 선택된 텍스트와 범위 저장
this.selectedText = selectedText;
this.selectedRange = range.cloneRange();
console.log('Showing highlight button');
// 컨텍스트 메뉴 표시 (간단한 버튼)
this.showHighlightButton(selection);
},
// 하이라이트 버튼 표시
showHighlightButton(selection) {
// 기존 버튼 제거
const existingButton = document.querySelector('.highlight-button');
if (existingButton) {
existingButton.remove();
}
const range = selection.getRangeAt(0);
const rect = range.getBoundingClientRect();
const button = document.createElement('button');
button.className = 'highlight-button fixed z-50 bg-blue-600 text-white px-4 py-2 rounded shadow-lg text-sm font-medium border-2 border-blue-700';
button.style.left = `${rect.left + window.scrollX}px`;
button.style.top = `${rect.bottom + window.scrollY + 10}px`;
button.innerHTML = '🖍️ 하이라이트';
console.log('Highlight button created at:', button.style.left, button.style.top);
button.addEventListener('click', () => {
this.createHighlight();
button.remove();
});
document.body.appendChild(button);
// 3초 후 자동 제거
setTimeout(() => {
if (button.parentNode) {
button.remove();
}
}, 3000);
},
// 색상 버튼으로 하이라이트 생성
createHighlightWithColor(color) {
console.log('createHighlightWithColor called with color:', color);
// 현재 선택된 텍스트가 있는지 확인
const selection = window.getSelection();
if (selection.rangeCount === 0 || selection.isCollapsed) {
alert('먼저 하이라이트할 텍스트를 선택해주세요.');
return;
}
// 색상 설정 후 하이라이트 생성
this.selectedHighlightColor = color;
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('No selected text or range');
return;
}
try {
console.log('Starting highlight creation...');
// 텍스트 오프셋 계산
const content = document.getElementById('document-content');
const { startOffset, endOffset } = this.calculateTextOffsets(this.selectedRange, content);
console.log('Text offsets:', startOffset, endOffset);
const highlightData = {
document_id: this.documentId,
start_offset: startOffset,
end_offset: endOffset,
selected_text: this.selectedText,
highlight_color: this.selectedHighlightColor,
highlight_type: 'highlight'
};
// 노트와 문서에 따라 다른 API 호출
let highlight;
if (this.contentType === 'note') {
// 노트용 하이라이트 API 호출 (document_id를 note_id로 변경)
const noteHighlightData = {
...highlightData,
note_id: highlightData.document_id
};
delete noteHighlightData.document_id;
console.log('📝 노트 하이라이트 데이터:', noteHighlightData);
highlight = await api.post('/note-highlights/', noteHighlightData);
} else {
highlight = await api.createHighlight(highlightData);
}
this.highlights.push(highlight);
// 하이라이트 렌더링
this.renderHighlights();
// 선택 해제
window.getSelection().removeAllRanges();
this.selectedText = '';
this.selectedRange = null;
// 메모 추가 여부 확인
if (confirm('이 하이라이트에 메모를 추가하시겠습니까?')) {
// 노트와 문서 모두 동일한 방식으로 처리
this.createMemoForHighlight(highlight);
}
} catch (error) {
console.error('Failed to create highlight:', error);
alert('하이라이트 생성에 실패했습니다');
}
},
// 하이라이트 메모 생성 (노트/문서 통합)
async createMemoForHighlight(highlight) {
try {
// 메모 내용 입력받기
const content = prompt('메모 내용을 입력하세요:', '');
if (content === null || content.trim() === '') {
return; // 취소하거나 빈 내용인 경우
}
// 메모 생성 데이터
const noteData = {
highlight_id: highlight.id,
content: content.trim()
};
let note;
if (this.contentType === 'note') {
// 노트용 메모 API
noteData.note_id = this.documentId;
console.log('📝 노트 메모 생성 데이터:', noteData);
note = await api.post('/note-notes/', noteData);
} else {
// 문서용 메모 API
noteData.is_private = false;
noteData.tags = [];
console.log('📝 문서 메모 생성 데이터:', noteData);
note = await api.createNote(noteData);
}
// 메모 목록에 추가
this.notes.push(note);
console.log('✅ 메모 생성 완료:', note);
} catch (error) {
console.error('❌ 메모 생성 실패:', error);
alert('메모 생성에 실패했습니다.');
}
},
// 텍스트 오프셋 계산
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 (range.startContainer === node) {
startOffset = currentOffset + range.startOffset;
}
if (range.endContainer === node) {
endOffset = currentOffset + range.endOffset;
break;
}
currentOffset += nodeLength;
}
return { startOffset, endOffset };
},
// 하이라이트 선택
selectHighlight(highlightId) {
// 모든 하이라이트에서 selected 클래스 제거
document.querySelectorAll('.highlight').forEach(el => {
el.classList.remove('selected');
});
// 선택된 하이라이트에 selected 클래스 추가
const highlightEl = document.querySelector(`[data-highlight-id="${highlightId}"]`);
if (highlightEl) {
highlightEl.classList.add('selected');
}
// 해당 하이라이트의 메모 찾기
const note = this.notes.find(n => n.highlight.id === highlightId);
if (note) {
this.editNote(note);
} else {
// 메모가 없으면 새로 생성
const highlight = this.highlights.find(h => h.id === highlightId);
if (highlight) {
this.openNoteModal(highlight);
}
}
},
// 하이라이트로 스크롤
scrollToHighlight(highlightId) {
const highlightEl = document.querySelector(`[data-highlight-id="${highlightId}"]`);
if (highlightEl) {
highlightEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
highlightEl.classList.add('selected');
// 2초 후 선택 해제
setTimeout(() => {
highlightEl.classList.remove('selected');
}, 2000);
}
},
// 메모 모달 열기
openNoteModal(highlight = null) {
this.editingNote = null;
this.noteForm = {
content: '',
tags: ''
};
if (highlight) {
this.selectedHighlight = highlight;
this.selectedText = highlight.selected_text;
}
this.showNoteModal = true;
},
// 메모 편집
editNote(note) {
this.editingNote = note;
this.noteForm = {
content: note.content,
tags: note.tags ? note.tags.join(', ') : ''
};
this.selectedText = note.highlight.selected_text;
this.showNoteModal = true;
},
// 메모 저장
async saveNote() {
this.noteLoading = true;
try {
const noteData = {
content: this.noteForm.content,
tags: this.noteForm.tags ? this.noteForm.tags.split(',').map(t => t.trim()).filter(t => t) : []
};
if (this.editingNote) {
// 메모 수정
const updatedNote = await api.updateNote(this.editingNote.id, noteData);
const index = this.notes.findIndex(n => n.id === this.editingNote.id);
if (index !== -1) {
this.notes[index] = updatedNote;
}
} else {
// 새 메모 생성
noteData.highlight_id = this.selectedHighlight.id;
const newNote = await api.createNote(noteData);
this.notes.push(newNote);
}
this.filterNotes();
this.closeNoteModal();
} catch (error) {
console.error('Failed to save note:', error);
alert('메모 저장에 실패했습니다');
} finally {
this.noteLoading = false;
}
},
// 메모 삭제
async deleteNote(noteId) {
if (!confirm('이 메모를 삭제하시겠습니까?')) {
return;
}
try {
await api.deleteNote(noteId);
this.notes = this.notes.filter(n => n.id !== noteId);
this.filterNotes();
} catch (error) {
console.error('Failed to delete note:', error);
alert('메모 삭제에 실패했습니다');
}
},
// 메모 모달 닫기
closeNoteModal() {
this.showNoteModal = false;
this.editingNote = null;
this.selectedHighlight = null;
this.selectedText = '';
this.noteForm = { content: '', tags: '' };
},
// 메모 필터링
filterNotes() {
if (!this.noteSearchQuery.trim()) {
this.filteredNotes = [...this.notes];
} else {
const query = this.noteSearchQuery.toLowerCase();
this.filteredNotes = this.notes.filter(note =>
note.content.toLowerCase().includes(query) ||
note.highlight.selected_text.toLowerCase().includes(query) ||
(note.tags && note.tags.some(tag => tag.toLowerCase().includes(query)))
);
}
},
// 책갈피 추가
async addBookmark() {
const scrollPosition = window.scrollY;
this.bookmarkForm = {
title: `${this.document.title} - ${new Date().toLocaleString()}`,
description: ''
};
this.currentScrollPosition = scrollPosition;
this.showBookmarkModal = true;
},
// 책갈피 편집
editBookmark(bookmark) {
this.editingBookmark = bookmark;
this.bookmarkForm = {
title: bookmark.title,
description: bookmark.description || ''
};
this.showBookmarkModal = true;
},
// 책갈피 저장
async saveBookmark() {
this.bookmarkLoading = true;
try {
const bookmarkData = {
title: this.bookmarkForm.title,
description: this.bookmarkForm.description,
scroll_position: this.currentScrollPosition || 0
};
if (this.editingBookmark) {
// 책갈피 수정
const updatedBookmark = await api.updateBookmark(this.editingBookmark.id, bookmarkData);
const index = this.bookmarks.findIndex(b => b.id === this.editingBookmark.id);
if (index !== -1) {
this.bookmarks[index] = updatedBookmark;
}
} else {
// 새 책갈피 생성
bookmarkData.document_id = this.documentId;
const newBookmark = await api.createBookmark(bookmarkData);
this.bookmarks.push(newBookmark);
}
this.closeBookmarkModal();
} catch (error) {
console.error('Failed to save bookmark:', error);
alert('책갈피 저장에 실패했습니다');
} finally {
this.bookmarkLoading = false;
}
},
// 책갈피 삭제
async deleteBookmark(bookmarkId) {
if (!confirm('이 책갈피를 삭제하시겠습니까?')) {
return;
}
try {
await api.deleteBookmark(bookmarkId);
this.bookmarks = this.bookmarks.filter(b => b.id !== bookmarkId);
} catch (error) {
console.error('Failed to delete bookmark:', error);
alert('책갈피 삭제에 실패했습니다');
}
},
// 책갈피로 스크롤
scrollToBookmark(bookmark) {
window.scrollTo({
top: bookmark.scroll_position,
behavior: 'smooth'
});
},
// 책갈피 모달 닫기
closeBookmarkModal() {
this.showBookmarkModal = false;
this.editingBookmark = null;
this.bookmarkForm = { title: '', description: '' };
this.currentScrollPosition = null;
},
// 문서 내 검색
searchInDocument() {
// 기존 검색 하이라이트 제거
document.querySelectorAll('.search-highlight').forEach(el => {
const parent = el.parentNode;
parent.replaceChild(document.createTextNode(el.textContent), el);
parent.normalize();
});
if (!this.searchQuery.trim()) {
return;
}
// 새 검색 하이라이트 적용
const content = document.getElementById('document-content');
this.highlightSearchResults(content, this.searchQuery);
},
// 검색 결과 하이라이트
highlightSearchResults(element, searchText) {
const walker = document.createTreeWalker(
element,
NodeFilter.SHOW_TEXT,
null,
false
);
const textNodes = [];
let node;
while (node = walker.nextNode()) {
textNodes.push(node);
}
textNodes.forEach(textNode => {
const text = textNode.textContent;
const regex = new RegExp(`(${searchText})`, 'gi');
if (regex.test(text)) {
const parent = textNode.parentNode;
const highlightedHTML = text.replace(regex, '<span class="search-highlight">$1</span>');
const tempDiv = document.createElement('div');
tempDiv.innerHTML = highlightedHTML;
while (tempDiv.firstChild) {
parent.insertBefore(tempDiv.firstChild, textNode);
}
parent.removeChild(textNode);
}
});
},
// 문서 클릭 처리
handleDocumentClick(event) {
// 하이라이트 버튼 제거
const button = document.querySelector('.highlight-button');
if (button && !button.contains(event.target)) {
button.remove();
}
// 하이라이트 선택 해제
document.querySelectorAll('.highlight.selected').forEach(el => {
el.classList.remove('selected');
});
},
// 뒤로가기 - 문서 관리 페이지로 이동
goBack() {
// 1. URL 파라미터에서 type과 from 확인
const urlParams = new URLSearchParams(window.location.search);
const fromPage = urlParams.get('from');
// 노트인 경우 노트 목록으로 이동
if (this.contentType === 'note') {
window.location.href = 'notes.html';
return;
}
// 2. 세션 스토리지에서 이전 페이지 확인
const previousPage = sessionStorage.getItem('previousPage');
// 3. referrer 확인
const referrer = document.referrer;
let targetPage = 'index.html'; // 기본값: 그리드 뷰
// 우선순위: URL 파라미터 > 세션 스토리지 > referrer
if (fromPage === 'hierarchy') {
targetPage = 'hierarchy.html';
} else if (previousPage === 'hierarchy.html') {
targetPage = 'hierarchy.html';
} else if (referrer && referrer.includes('hierarchy.html')) {
targetPage = 'hierarchy.html';
}
console.log(`🔙 뒤로가기: ${targetPage}로 이동`);
window.location.href = targetPage;
},
// 날짜 포맷팅
formatDate(dateString) {
const date = new Date(dateString);
return date.toLocaleDateString('ko-KR', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
},
// 동일한 텍스트 범위의 모든 하이라이트 찾기
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 || '#FFB6C1';
if (!colorGroups[color]) {
colorGroups[color] = [];
}
colorGroups[color].push(highlight);
});
return colorGroups;
},
// 색상 이름 매핑 (더 많은 색상 지원)
getColorName(color) {
const colorNames = {
// 기본 색상들
'#FFB6C1': '핑크',
'#FFFF99': '노랑',
'#FFFF00': '노랑',
'#YELLOW': '노랑',
'#98FB98': '연두',
'#90EE90': '연두',
'#LIGHTGREEN': '연두',
'#87CEEB': '하늘',
'#ADD8E6': '하늘',
'#LIGHTBLUE': '하늘',
'#DDA0DD': '보라',
'#DA70D6': '보라',
'#ORCHID': '보라',
'#FFA500': '주황',
'#ORANGE': '주황',
// RGB 형식
'rgb(255, 255, 0)': '노랑',
'rgb(255, 255, 153)': '노랑',
'rgb(152, 251, 152)': '연두',
'rgb(144, 238, 144)': '연두',
'rgb(135, 206, 235)': '하늘',
'rgb(173, 216, 230)': '하늘',
'rgb(255, 182, 193)': '핑크',
'rgb(255, 165, 0)': '주황'
};
// 대소문자 구분 없이 매칭
const normalizedColor = color?.toUpperCase();
const exactMatch = colorNames[color] || colorNames[normalizedColor];
if (exactMatch) {
return exactMatch;
}
// RGB 값으로 색상 추정
if (color?.includes('rgb')) {
const rgbMatch = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
if (rgbMatch) {
const [, r, g, b] = rgbMatch.map(Number);
// 색상 범위로 판단
if (r > 200 && g > 200 && b < 100) return '노랑';
if (r < 200 && g > 200 && b < 200) return '연두';
if (r < 200 && g < 200 && b > 200) return '하늘';
if (r > 200 && g < 200 && b > 200) return '핑크';
if (r > 200 && g > 100 && b < 100) return '주황';
if (r > 150 && g < 150 && b > 150) return '보라';
}
}
console.log('🎨 알 수 없는 색상:', color);
return '기타';
},
// 하이라이트 말풍선 표시
showHighlightTooltip(clickedHighlight, element) {
// 기존 말풍선 제거
this.hideTooltip();
// 동일한 범위의 모든 하이라이트 찾기
const overlappingHighlights = this.findOverlappingHighlights(clickedHighlight);
const colorGroups = this.groupHighlightsByColor(overlappingHighlights);
console.log('🎨 겹치는 하이라이트:', overlappingHighlights.length, '개');
console.log('🎨 하이라이트 상세:', overlappingHighlights.map(h => ({
id: h.id,
color: h.highlight_color,
colorName: this.getColorName(h.highlight_color),
text: h.selected_text
})));
console.log('🎨 색상 그룹:', Object.keys(colorGroups));
// 각 색상별 메모 개수 디버깅
Object.entries(colorGroups).forEach(([color, highlights]) => {
const noteCount = highlights.flatMap(h =>
this.notes.filter(note => note.highlight_id === h.id)
).length;
console.log(`🎨 ${this.getColorName(color)} (${color}): ${highlights.length}개 하이라이트, ${noteCount}개 메모`);
});
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>
`;
// 하이라이트가 여러 개인 경우 색상별로 표시
if (overlappingHighlights.length > 1) {
tooltipHTML += `
<div class="mb-4">
<div class="text-sm font-medium text-gray-700 mb-2">
하이라이트 색상 (${overlappingHighlights.length}개)
</div>
<div class="flex flex-wrap gap-2">
${Object.keys(colorGroups).map(color => `
<div class="flex items-center space-x-1">
<div class="w-4 h-4 rounded border border-gray-300" style="background-color: ${color}"></div>
<span class="text-xs text-gray-600">${this.getColorName(color)} (${colorGroups[color].length})</span>
</div>
`).join('')}
</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="console.log('버튼 클릭됨!'); window.documentViewerInstance.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.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>';
// 하이라이트 삭제 버튼들
if (overlappingHighlights.length > 1) {
tooltipHTML += `
<div class="mt-4 pt-3 border-t">
<div class="text-sm text-gray-600 mb-2">하이라이트 삭제</div>
<div class="flex flex-wrap gap-2">
${Object.entries(colorGroups).map(([color, highlights]) => `
<button onclick="window.documentViewerInstance.deleteHighlightsByColor('${color}', ${JSON.stringify(highlights.map(h => h.id))})"
class="text-xs px-2 py-1 rounded border hover:bg-gray-50"
style="border-color: ${color}; color: ${color}">
${this.getColorName(color)} 삭제
</button>
`).join('')}
<button onclick="window.documentViewerInstance.deleteAllOverlappingHighlights(${JSON.stringify(overlappingHighlights.map(h => h.id))})"
class="text-xs bg-red-500 text-white px-2 py-1 rounded hover:bg-red-600">
전체 삭제
</button>
</div>
</div>
`;
} else {
tooltipHTML += `
<div class="mt-4 pt-3 border-t">
<button onclick="window.documentViewerInstance.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.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();
}
const linkTooltip = document.getElementById('link-tooltip');
if (linkTooltip) {
linkTooltip.remove();
}
const backlinkTooltip = document.getElementById('backlink-tooltip');
if (backlinkTooltip) {
backlinkTooltip.remove();
}
document.removeEventListener('click', this.handleTooltipOutsideClick.bind(this));
},
// 말풍선 외부 클릭 처리
handleTooltipOutsideClick(e) {
const highlightTooltip = document.getElementById('highlight-tooltip');
const linkTooltip = document.getElementById('link-tooltip');
const backlinkTooltip = document.getElementById('backlink-tooltip');
const isOutsideHighlightTooltip = highlightTooltip && !highlightTooltip.contains(e.target) && !e.target.classList.contains('highlight');
const isOutsideLinkTooltip = linkTooltip && !linkTooltip.contains(e.target) && !e.target.classList.contains('document-link');
const isOutsideBacklinkTooltip = backlinkTooltip && !backlinkTooltip.contains(e.target) && !e.target.classList.contains('backlink-highlight');
if (isOutsideHighlightTooltip || isOutsideLinkTooltip || isOutsideBacklinkTooltip) {
this.hideTooltip();
}
},
// 짧은 날짜 형식
formatShortDate(dateString) {
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', {
year: '2-digit',
month: '2-digit',
day: '2-digit'
});
}
},
// 메모 추가 폼 표시
showAddNoteForm(highlightId) {
console.log('🔍 showAddNoteForm 호출됨, highlightId:', highlightId);
const tooltip = document.getElementById('highlight-tooltip');
if (!tooltip) {
console.error('❌ 툴팁을 찾을 수 없습니다');
return;
}
console.log('✅ 툴팁 찾음:', tooltip);
console.log('🔍 찾고 있는 ID:', `#notes-list-${highlightId}`);
// 툴팁 내의 모든 ID 요소 확인
const allIds = tooltip.querySelectorAll('[id]');
console.log('📋 툴팁 내 모든 ID 요소들:', Array.from(allIds).map(el => el.id));
// 툴팁 HTML 전체 구조 확인
console.log('🔍 툴팁 HTML 구조:', tooltip.innerHTML.substring(0, 500) + '...');
// 해당 하이라이트의 메모 섹션을 찾기
const notesList = tooltip.querySelector(`#notes-list-${highlightId}`);
if (!notesList) {
console.error('❌ 메모 리스트 요소를 찾을 수 없습니다:', highlightId);
console.log('🔍 툴팁 HTML:', tooltip.innerHTML);
return;
}
console.log('✅ 메모 리스트 찾음:', notesList);
notesList.innerHTML = `
<div class="bg-blue-50 p-3 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.cancelAddNote('${highlightId}')"
class="text-xs bg-gray-500 text-white px-3 py-1 rounded hover:bg-gray-600">
취소
</button>
<button onclick="window.documentViewerInstance.saveNewNote('${highlightId}')"
class="text-xs bg-blue-500 text-white px-3 py-1 rounded hover:bg-blue-600">
저장
</button>
</div>
</div>
`;
// 텍스트 영역에 포커스
setTimeout(() => {
document.getElementById('new-note-content').focus();
}, 100);
},
// 메모 추가 취소
cancelAddNote(highlightId) {
// 말풍선 다시 표시
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 newNote;
if (this.contentType === 'note') {
noteData.note_id = this.documentId; // 노트 메모는 note_id 필요
newNote = await api.post('/note-notes/', noteData);
} else {
noteData.is_private = false;
noteData.tags = [];
newNote = await 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.showHighlightTooltip(highlight, element);
}
}
} catch (error) {
console.error('Failed to save note:', error);
alert('메모 저장에 실패했습니다');
}
},
// 하이라이트 삭제
async deleteHighlight(highlightId) {
if (!confirm('이 하이라이트를 삭제하시겠습니까? 연결된 메모도 함께 삭제됩니다.')) {
return;
}
try {
await 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.renderHighlights();
} catch (error) {
console.error('Failed to delete highlight:', error);
alert('하이라이트 삭제에 실패했습니다');
}
},
// 특정 색상의 하이라이트들 삭제
async deleteHighlightsByColor(color, highlightIds) {
const colorName = this.getColorName(color);
if (!confirm(`${colorName} 색상의 하이라이트를 모두 삭제하시겠습니까? 연결된 메모도 함께 삭제됩니다.`)) {
return;
}
try {
// 각 하이라이트를 개별적으로 삭제
for (const highlightId of highlightIds) {
await api.deleteHighlight(highlightId);
}
// 로컬 데이터에서 제거
this.highlights = this.highlights.filter(h => !highlightIds.includes(h.id));
this.notes = this.notes.filter(n => !highlightIds.includes(n.highlight_id));
// UI 업데이트
this.hideTooltip();
this.renderHighlights();
} catch (error) {
console.error('Failed to delete highlights:', error);
alert('하이라이트 삭제에 실패했습니다');
}
},
// 겹치는 모든 하이라이트 삭제
async deleteAllOverlappingHighlights(highlightIds) {
if (!confirm(`겹치는 모든 하이라이트를 삭제하시겠습니까? (${highlightIds.length}개) 연결된 메모도 함께 삭제됩니다.`)) {
return;
}
try {
// 각 하이라이트를 개별적으로 삭제
for (const highlightId of highlightIds) {
await api.deleteHighlight(highlightId);
}
// 로컬 데이터에서 제거
this.highlights = this.highlights.filter(h => !highlightIds.includes(h.id));
this.notes = this.notes.filter(n => !highlightIds.includes(n.highlight_id));
// UI 업데이트
this.hideTooltip();
this.renderHighlights();
} catch (error) {
console.error('Failed to delete highlights:', error);
alert('하이라이트 삭제에 실패했습니다');
}
},
// 언어 전환 함수
toggleLanguage() {
this.isKorean = !this.isKorean;
console.log(`🌐 언어 전환 시작 (isKorean: ${this.isKorean})`);
// 문서 내 언어별 요소 토글 (HTML, HEAD, BODY 태그 제외)
const primaryLangElements = document.querySelectorAll('[lang="ko"]:not(html):not(head):not(body), .korean, .kr, .primary-lang');
const secondaryLangElements = document.querySelectorAll('[lang="en"]:not(html):not(head):not(body), .english, .en, [lang="ja"]:not(html):not(head):not(body), .japanese, .jp, [lang="zh"]:not(html):not(head):not(body), .chinese, .cn, .secondary-lang');
// 디버깅: 찾은 요소 수 출력
console.log(`🔍 Primary 요소 수: ${primaryLangElements.length}`);
console.log(`🔍 Secondary 요소 수: ${secondaryLangElements.length}`);
// 언어별 요소가 있는 경우에만 토글 적용
if (primaryLangElements.length > 0 || secondaryLangElements.length > 0) {
console.log('✅ 언어별 요소 발견, 토글 적용 중...');
primaryLangElements.forEach((el, index) => {
const oldDisplay = el.style.display || getComputedStyle(el).display;
const newDisplay = this.isKorean ? 'block' : 'none';
console.log(`Primary 요소 ${index + 1}: ${el.tagName}#${el.id || 'no-id'}.${el.className || 'no-class'}`);
console.log(` - 이전 display: ${oldDisplay}`);
console.log(` - 새로운 display: ${newDisplay}`);
console.log(` - 요소 내용 미리보기: "${el.textContent?.substring(0, 50) || 'no-text'}..."`);
console.log(` - 요소 위치:`, el.getBoundingClientRect());
el.style.display = newDisplay;
console.log(` - 적용 후 display: ${el.style.display}`);
});
secondaryLangElements.forEach((el, index) => {
const oldDisplay = el.style.display || getComputedStyle(el).display;
const newDisplay = this.isKorean ? 'none' : 'block';
console.log(`Secondary 요소 ${index + 1}: ${el.tagName}#${el.id || 'no-id'}.${el.className || 'no-class'}`);
console.log(` - 이전 display: ${oldDisplay}`);
console.log(` - 새로운 display: ${newDisplay}`);
console.log(` - 요소 내용 미리보기: "${el.textContent?.substring(0, 50) || 'no-text'}..."`);
console.log(` - 요소 위치:`, el.getBoundingClientRect());
el.style.display = newDisplay;
console.log(` - 적용 후 display: ${el.style.display}`);
});
} else {
// 문서 내 콘텐츠에서 언어별 요소를 더 광범위하게 찾기
console.log('⚠️ 기본 언어별 요소를 찾을 수 없습니다. 문서 내 콘텐츠를 분석합니다.');
// 문서 콘텐츠 영역에서 언어별 요소 찾기
const contentArea = document.querySelector('#document-content, .document-content, main, .content, #content');
if (contentArea) {
console.log('📄 문서 콘텐츠 영역 발견:', contentArea.tagName, contentArea.id || contentArea.className);
// 콘텐츠 영역의 구조 분석
console.log('📋 콘텐츠 영역 내 모든 자식 요소들:');
const allChildren = contentArea.querySelectorAll('*');
const childrenInfo = Array.from(allChildren).slice(0, 10).map(el => {
return `${el.tagName}${el.id ? '#' + el.id : ''}${el.className ? '.' + el.className.split(' ').join('.') : ''} [lang="${el.lang || 'none'}"]`;
});
console.log(childrenInfo);
// 콘텐츠 영역 내에서 언어별 요소 재검색
const contentPrimary = contentArea.querySelectorAll('[lang="ko"], .korean, .kr, .primary-lang');
const contentSecondary = contentArea.querySelectorAll('[lang="en"], .english, .en, [lang="ja"], .japanese, .jp, [lang="zh"], .chinese, .cn, .secondary-lang');
console.log(`📄 콘텐츠 내 Primary 요소: ${contentPrimary.length}`);
console.log(`📄 콘텐츠 내 Secondary 요소: ${contentSecondary.length}`);
if (contentPrimary.length > 0 || contentSecondary.length > 0) {
// 콘텐츠 영역 내 요소들에 토글 적용
contentPrimary.forEach(el => {
el.style.display = this.isKorean ? 'block' : 'none';
});
contentSecondary.forEach(el => {
el.style.display = this.isKorean ? 'none' : 'block';
});
console.log('✅ 콘텐츠 영역 내 언어 토글 적용됨');
return;
} else {
// 실제 문서 내용에서 언어 패턴 찾기
console.log('🔍 문서 내용에서 언어 패턴을 찾습니다...');
// 문서의 실제 텍스트 내용 확인
const textContent = contentArea.textContent || '';
const hasKorean = /[가-힣]/.test(textContent);
const hasEnglish = /[a-zA-Z]/.test(textContent);
console.log(`📝 문서 언어 분석: 한국어=${hasKorean}, 영어=${hasEnglish}`);
console.log(`📝 문서 내용 미리보기: "${textContent.substring(0, 100)}..."`);
if (!hasKorean && !hasEnglish) {
console.log('❌ 텍스트 콘텐츠를 찾을 수 없습니다.');
}
}
}
// ID 기반 토글 시도
console.log('🔍 ID 기반 토글을 시도합니다.');
const koreanContent = document.getElementById('korean-content');
const englishContent = document.getElementById('english-content');
if (koreanContent && englishContent) {
koreanContent.style.display = this.isKorean ? 'block' : 'none';
englishContent.style.display = this.isKorean ? 'none' : 'block';
console.log('✅ ID 기반 토글 적용됨');
} else {
console.log('❌ 언어 전환 가능한 요소를 찾을 수 없습니다.');
console.log('📋 이 문서는 단일 언어 문서이거나 언어 구분이 없습니다.');
// 단일 언어 문서의 경우 아무것도 하지 않음 (흰색 페이지 방지)
console.log('🔄 언어 전환을 되돌립니다.');
this.isKorean = !this.isKorean; // 상태를 원래대로 되돌림
return;
}
}
console.log(`🌐 언어 전환 완료 (Primary: ${this.isKorean ? '표시' : '숨김'})`);
},
// 매칭된 PDF 다운로드
async downloadMatchedPDF() {
if (!this.document.matched_pdf_id) {
console.warn('매칭된 PDF가 없습니다');
return;
}
try {
console.log('📕 PDF 다운로드 시작:', this.document.matched_pdf_id);
// PDF 문서 정보 가져오기
const pdfDocument = await window.api.getDocument(this.document.matched_pdf_id);
if (!pdfDocument) {
throw new Error('PDF 문서를 찾을 수 없습니다');
}
// PDF 파일 다운로드 URL 생성
const downloadUrl = `/api/documents/${this.document.matched_pdf_id}/download`;
// 인증 헤더 추가를 위해 fetch 사용
const response = await fetch(downloadUrl, {
method: 'GET',
headers: {
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
}
});
if (!response.ok) {
throw new Error('PDF 다운로드에 실패했습니다');
}
// Blob으로 변환하여 다운로드
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
// 다운로드 링크 생성 및 클릭
const link = document.createElement('a');
link.href = url;
link.download = pdfDocument.original_filename || `${pdfDocument.title}.pdf`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// URL 정리
window.URL.revokeObjectURL(url);
console.log('✅ PDF 다운로드 완료');
} catch (error) {
console.error('❌ PDF 다운로드 실패:', error);
alert('PDF 다운로드에 실패했습니다: ' + error.message);
}
},
// 원본 파일 다운로드 (연결된 PDF 파일)
async downloadOriginalFile() {
if (!this.document || !this.document.id) {
console.warn('문서 정보가 없습니다');
return;
}
// 연결된 PDF가 있는지 확인
if (!this.document.matched_pdf_id) {
alert('연결된 원본 PDF 파일이 없습니다.\n\n서적 편집 페이지에서 PDF 파일을 연결해주세요.');
return;
}
try {
console.log('📕 연결된 PDF 다운로드 시작:', this.document.matched_pdf_id);
// 연결된 PDF 문서 정보 가져오기
const pdfDocument = await window.api.getDocument(this.document.matched_pdf_id);
if (!pdfDocument) {
throw new Error('연결된 PDF 문서를 찾을 수 없습니다');
}
// PDF 파일 다운로드 URL 생성
const downloadUrl = `/api/documents/${this.document.matched_pdf_id}/download`;
// 인증 헤더 추가를 위해 fetch 사용
const response = await fetch(downloadUrl, {
method: 'GET',
headers: {
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
}
});
if (!response.ok) {
throw new Error('연결된 PDF 다운로드에 실패했습니다');
}
// Blob으로 변환하여 다운로드
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
// 다운로드 링크 생성 및 클릭
const link = document.createElement('a');
link.href = url;
link.download = pdfDocument.original_filename || `${pdfDocument.title}.pdf`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// URL 정리
window.URL.revokeObjectURL(url);
console.log('✅ 연결된 PDF 다운로드 완료');
} catch (error) {
console.error('❌ 연결된 PDF 다운로드 실패:', error);
alert('연결된 PDF 다운로드에 실패했습니다: ' + error.message);
}
},
// 네비게이션 정보 로드
async loadNavigation() {
try {
this.navigation = await window.api.getDocumentNavigation(this.documentId);
console.log('📍 네비게이션 정보 로드됨:', this.navigation);
} catch (error) {
console.error('❌ 네비게이션 정보 로드 실패:', error);
// 네비게이션 정보는 필수가 아니므로 에러를 던지지 않음
}
},
// 다른 문서로 네비게이션
navigateToDocument(documentId) {
if (!documentId) return;
const currentUrl = new URL(window.location);
currentUrl.searchParams.set('id', documentId);
window.location.href = currentUrl.toString();
},
// 서적 목차로 이동
goToBookContents() {
if (!this.navigation?.book_info) return;
window.location.href = `/book-documents.html?bookId=${this.navigation.book_info.id}`;
},
// === 문서 링크 관련 함수들 ===
// 문서 링크 생성
async createDocumentLink() {
console.log('🔗 createDocumentLink 함수 실행');
// 이미 설정된 selectedText와 selectedRange 사용
let selectedText = this.selectedText;
let range = this.selectedRange;
// 설정되지 않은 경우 현재 선택 확인
if (!selectedText || !range) {
console.log('📝 기존 선택 없음, 현재 선택 확인');
const selection = window.getSelection();
if (!selection.rangeCount || selection.isCollapsed) {
alert('텍스트를 선택한 후 링크를 생성해주세요.');
return;
}
range = selection.getRangeAt(0);
selectedText = selection.toString().trim();
}
console.log('✅ 선택된 텍스트:', selectedText);
if (selectedText.length === 0) {
alert('텍스트를 선택한 후 링크를 생성해주세요.');
return;
}
// 선택된 텍스트의 위치 계산
const documentContent = document.getElementById('document-content');
const startOffset = this.getTextOffset(documentContent, range.startContainer, range.startOffset);
const endOffset = startOffset + selectedText.length;
console.log('📍 텍스트 위치:', { startOffset, endOffset });
// 폼 데이터 설정
this.linkForm = {
target_document_id: '',
selected_text: selectedText,
start_offset: startOffset,
end_offset: endOffset,
link_text: '',
description: '',
link_type: 'document', // 기본값: 문서 전체 링크
target_text: '',
target_start_offset: null,
target_end_offset: null,
book_scope: 'same', // 기본값: 같은 서적
target_book_id: ''
};
console.log('📋 링크 폼 데이터:', this.linkForm);
// 링크 가능한 문서 목록 로드
await this.loadLinkableDocuments();
// 모달 열기
console.log('🔗 링크 모달 열기');
console.log('🔗 showLinksModal 설정 전:', this.showLinksModal);
this.showLinksModal = true;
this.showLinkModal = true; // 기존 호환성
console.log('🔗 showLinksModal 설정 후:', this.showLinksModal);
this.editingLink = null;
},
// 링크 가능한 문서 목록 로드
async loadLinkableDocuments() {
try {
if (this.contentType === 'note') {
// 노트의 경우: 다른 노트들과 문서들 모두 로드
console.log('📝 노트 링크 대상 로드 중...');
// 임시: 빈 배열로 설정 (나중에 노트-문서 간 링크 API 구현 시 수정)
this.linkableDocuments = [];
this.availableBooks = [];
this.filteredDocuments = [];
console.warn('📝 노트 간 링크는 아직 지원되지 않습니다.');
return;
} else {
// 문서의 경우: 기존 로직
this.linkableDocuments = await api.getLinkableDocuments(this.documentId);
console.log('🔗 링크 가능한 문서들:', this.linkableDocuments);
// 서적 목록도 함께 로드
await this.loadAvailableBooks();
// 기본적으로 같은 서적 문서들 로드
await this.loadSameBookDocuments();
}
} catch (error) {
console.error('❌ 링크 가능한 문서 로드 실패:', error);
this.linkableDocuments = [];
}
},
// 문서 링크 저장 (고급 기능 포함)
async saveDocumentLink() {
if (!this.linkForm.target_document_id || !this.linkForm.selected_text) {
alert('필수 정보가 누락되었습니다.');
return;
}
// text_fragment 타입인데 target_text가 없으면 경고
if (this.linkForm.link_type === 'text_fragment' && !this.linkForm.target_text) {
const confirm = window.confirm('특정 부분 링크를 선택했지만 대상 텍스트가 선택되지 않았습니다. 전체 문서 링크로 생성하시겠습니까?');
if (confirm) {
this.linkForm.link_type = 'document';
} else {
return;
}
}
this.linkLoading = true;
try {
const linkData = {
target_document_id: this.linkForm.target_document_id,
selected_text: this.linkForm.selected_text,
start_offset: this.linkForm.start_offset,
end_offset: this.linkForm.end_offset,
link_text: this.linkForm.link_text || null,
description: this.linkForm.description || null,
link_type: this.linkForm.link_type,
target_text: this.linkForm.target_text || null,
target_start_offset: this.linkForm.target_start_offset || null,
target_end_offset: this.linkForm.target_end_offset || null
};
if (this.editingLink) {
await window.api.updateDocumentLink(this.editingLink.id, linkData);
console.log('✅ 링크 수정됨');
} else {
await window.api.createDocumentLink(this.documentId, linkData);
console.log('✅ 링크 생성됨');
}
// 데이터 새로고침
await this.loadDocumentData();
// 백링크 먼저 렌더링
this.renderBacklinkHighlights();
// 일반 링크 렌더링 (백링크 보호)
this.renderDocumentLinks();
this.closeLinkModal();
} catch (error) {
console.error('❌ 링크 저장 실패:', error);
alert('링크 저장에 실패했습니다: ' + error.message);
} finally {
this.linkLoading = false;
}
},
// 링크 모달 닫기 (고급 기능 포함)
closeLinkModal() {
this.showLinksModal = false;
this.showLinkModal = false;
this.editingLink = null;
this.linkForm = {
target_document_id: '',
selected_text: '',
start_offset: 0,
end_offset: 0,
link_text: '',
description: '',
link_type: 'document',
target_text: '',
target_start_offset: 0,
target_end_offset: 0,
book_scope: 'same',
target_book_id: ''
};
// 필터링된 문서 목록 초기화
this.filteredDocuments = [];
},
// 문서 링크 렌더링
renderDocumentLinks() {
const documentContent = document.getElementById('document-content');
if (!documentContent) return;
// 기존 링크 스타일 제거 (백링크는 보호)
const existingLinks = documentContent.querySelectorAll('.document-link');
existingLinks.forEach(link => {
// 백링크는 제거하지 않음
if (!link.classList.contains('backlink-highlight')) {
const parent = link.parentNode;
parent.replaceChild(document.createTextNode(link.textContent), link);
parent.normalize();
}
});
// 백링크도 보호 (별도 클래스)
const existingBacklinks = documentContent.querySelectorAll('.backlink-highlight');
console.log(`🔒 백링크 보호: ${existingBacklinks.length}개 백링크 발견`);
// 백링크는 건드리지 않음 (보호만 함)
// 새 링크 적용
console.log(`🔗 링크 렌더링 시작 - 총 ${this.documentLinks.length}`);
this.documentLinks.forEach((link, index) => {
console.log(`🔗 링크 ${index + 1}:`, link);
console.log(` - selected_text: "${link.selected_text}"`);
console.log(` - start_offset: ${link.start_offset}`);
console.log(` - end_offset: ${link.end_offset}`);
const span = this.highlightTextRange(
documentContent,
link.start_offset,
link.end_offset,
'document-link',
{
'data-link-id': link.id,
'data-target-document': link.target_document_id,
'data-target-title': link.target_document_title,
'title': `링크: ${link.target_document_title}${link.description ? '\n' + link.description : ''}`,
'style': 'color: #7C3AED; text-decoration: underline; cursor: pointer; background-color: rgba(124, 58, 237, 0.1);'
}
);
if (span) {
console.log(`✅ 링크 렌더링 성공: "${link.selected_text}"`);
} else {
console.log(`❌ 링크 렌더링 실패: "${link.selected_text}"`);
}
});
// 링크 클릭 이벤트 추가
const linkElements = documentContent.querySelectorAll('.document-link');
linkElements.forEach(linkEl => {
linkEl.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
// 클릭된 위치의 모든 링크 찾기
const clickedText = linkEl.textContent;
const overlappingLinks = this.getOverlappingLinks(linkEl);
// 링크 툴팁 표시
this.showLinkTooltip(overlappingLinks, linkEl, clickedText);
});
});
},
// 겹치는 링크들 찾기
getOverlappingLinks(clickedElement) {
const clickedLinkId = clickedElement.getAttribute('data-link-id');
const clickedText = clickedElement.textContent;
// 동일한 텍스트 범위에 있는 모든 링크 찾기
const overlappingLinks = this.documentLinks.filter(link => {
// 클릭된 링크와 텍스트가 겹치는지 확인
const linkElement = document.querySelector(`[data-link-id="${link.id}"]`);
if (!linkElement) return false;
// 텍스트가 겹치는지 확인 (간단한 방법: 텍스트 내용 비교)
return linkElement.textContent === clickedText;
});
return overlappingLinks;
},
// 링크 툴팁 표시
showLinkTooltip(links, element, selectedText) {
// 기존 툴팁 제거
this.hideTooltip();
const tooltip = document.createElement('div');
tooltip.id = 'link-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';
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-purple-500">
"${selectedText}"
</div>
</div>
`;
if (links.length > 1) {
tooltipHTML += `
<div class="mb-4">
<div class="text-sm font-medium text-gray-700 mb-2">
연결된 링크 (${links.length}개)
</div>
</div>
`;
}
tooltipHTML += '<div class="space-y-2">';
links.forEach(link => {
tooltipHTML += `
<div class="border rounded-lg p-3 hover:bg-gray-50 cursor-pointer"
onclick="window.documentViewerInstance.navigateToLinkedDocument('${link.target_document_id}', ${JSON.stringify(link).replace(/"/g, '&quot;')})">
<div class="flex items-center justify-between">
<div class="flex-1">
<div class="font-medium text-purple-700">${link.target_document_title}</div>
${link.description ? `<div class="text-sm text-gray-600 mt-1">${link.description}</div>` : ''}
<div class="text-xs text-gray-500 mt-1">
${link.link_type === 'text_fragment' ? '텍스트 조각 링크' : '문서 링크'}
</div>
</div>
<div class="ml-3">
<svg class="w-5 h-5 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
</svg>
</div>
</div>
</div>
`;
});
tooltipHTML += '</div>';
tooltip.innerHTML = tooltipHTML;
// 위치 계산 및 표시
const rect = element.getBoundingClientRect();
tooltip.style.top = (rect.bottom + window.scrollY + 10) + 'px';
tooltip.style.left = Math.max(10, rect.left + window.scrollX - 175) + 'px';
document.body.appendChild(tooltip);
// 외부 클릭 시 툴팁 숨기기
setTimeout(() => {
document.addEventListener('click', this.handleTooltipOutsideClick.bind(this));
}, 100);
},
// 백링크 하이라이트 렌더링 (이 문서를 참조하는 다른 문서의 링크들)
async renderBacklinkHighlights() {
if (!this.documentId) return;
try {
// 백링크 정보 가져오기
console.log('🔍 백링크 API 호출 시작 - 문서 ID:', this.documentId);
const backlinks = await api.getDocumentBacklinks(this.documentId);
console.log('🔗 백링크 API 응답:', backlinks);
console.log('🔗 백링크 개수:', backlinks.length);
if (backlinks.length === 0) {
console.log('⚠️ 백링크가 없습니다. 이 문서를 참조하는 링크가 없거나 권한이 없을 수 있습니다.');
}
const documentContent = document.getElementById('document-content');
if (!documentContent) return;
// 기존 백링크 하이라이트 제거
const existingBacklinks = documentContent.querySelectorAll('.backlink-highlight');
existingBacklinks.forEach(el => {
const parent = el.parentNode;
parent.replaceChild(document.createTextNode(el.textContent), el);
parent.normalize();
});
// 백링크 하이라이트 적용 (간단한 방법)
console.log(`🔗 백링크 렌더링 시작 - 총 ${backlinks.length}`);
console.log(`📄 현재 문서 내용 (처음 200자):`, documentContent.textContent.substring(0, 200));
console.log(`📄 현재 문서 전체 길이:`, documentContent.textContent.length);
// 백링크 렌더링 전략 결정
console.log(`🎯 백링크 렌더링 전략:`);
backlinks.forEach((backlink, index) => {
console.log(`🔍 백링크 ${index + 1}:`);
console.log(` - 타입: ${backlink.link_type}`);
console.log(` - target_text: "${backlink.target_text || 'null'}"`);
console.log(` - selected_text: "${backlink.selected_text}"`);
if (backlink.link_type === 'document') {
// 문서 레벨 백링크: 문서 제목이나 첫 번째 헤딩 찾기
const titleElement = documentContent.querySelector('h1, h2, .title, title');
if (titleElement) {
console.log(`✅ 문서 레벨 백링크 - 제목 요소 발견: "${titleElement.textContent.trim()}"`);
} else {
console.log(`⚠️ 문서 레벨 백링크 - 제목 요소 없음`);
}
} else if (backlink.link_type === 'text_fragment') {
// 텍스트 프래그먼트 백링크: selected_text가 현재 문서에 있는지 확인
const searchText = backlink.selected_text;
const found = documentContent.textContent.includes(searchText);
console.log(`${found ? '✅' : '❌'} 텍스트 프래그먼트 백링크 - "${searchText}" 존재: ${found}`);
}
});
if (backlinks.length === 0) {
console.log(`⚠️ 백링크가 없어서 렌더링하지 않음`);
// 테스트용: 강제로 백링크 표시 (디버깅용)
console.log(`🧪 테스트용 백링크 강제 생성...`);
const testText = "pressure vessel";
if (documentContent.textContent.includes(testText)) {
console.log(`🎯 테스트 텍스트 발견: "${testText}"`);
// 간단한 텍스트 하이라이트
const walker = document.createTreeWalker(
documentContent,
NodeFilter.SHOW_TEXT,
null,
false
);
let node;
while (node = walker.nextNode()) {
const text = node.textContent;
const index = text.indexOf(testText);
if (index !== -1) {
console.log(`🎯 테스트 텍스트 노드에서 발견!`);
try {
const beforeText = text.substring(0, index);
const matchText = text.substring(index, index + testText.length);
const afterText = text.substring(index + testText.length);
const parent = node.parentNode;
const fragment = document.createDocumentFragment();
if (beforeText) {
fragment.appendChild(document.createTextNode(beforeText));
}
const span = document.createElement('span');
span.className = 'backlink-highlight';
span.textContent = matchText;
span.style.cssText = 'color: #EA580C !important; text-decoration: underline !important; cursor: pointer !important; background-color: rgba(234, 88, 12, 0.2) !important; border: 2px solid #EA580C !important; border-radius: 4px !important; padding: 4px 6px !important; font-weight: bold !important; box-shadow: 0 2px 4px rgba(234, 88, 12, 0.3) !important;';
span.setAttribute('title', '테스트 백링크');
fragment.appendChild(span);
if (afterText) {
fragment.appendChild(document.createTextNode(afterText));
}
parent.replaceChild(fragment, node);
console.log(`✅ 테스트 백링크 렌더링 성공: "${matchText}"`);
break;
} catch (error) {
console.error(`❌ 테스트 백링크 렌더링 실패:`, error);
}
}
}
}
return;
}
backlinks.forEach((backlink, index) => {
console.log(`🔗 백링크 ${index + 1}:`, backlink);
let searchText = null;
let renderStrategy = null;
if (backlink.link_type === 'document') {
// 문서 레벨 백링크: 제목 요소 찾기
const titleElement = documentContent.querySelector('h1, h2, .title, title');
if (titleElement) {
searchText = titleElement.textContent.trim();
renderStrategy = 'title';
console.log(`📋 문서 레벨 백링크 - 제목으로 렌더링: "${searchText}"`);
}
} else if (backlink.link_type === 'text_fragment') {
// 텍스트 프래그먼트 백링크: selected_text 사용
searchText = backlink.selected_text;
renderStrategy = 'text';
console.log(`📋 텍스트 프래그먼트 백링크 - 텍스트로 렌더링: "${searchText}"`);
}
if (!searchText) {
console.log(`❌ selected_text가 없음`);
return;
}
console.log(`🔍 텍스트 검색: "${searchText}"`);
console.log(`📊 문서에 해당 텍스트 포함 여부:`, documentContent.textContent.includes(searchText));
// 기존 링크 요소들 확인
const existingLinks = documentContent.querySelectorAll('.document-link');
console.log(`🔗 기존 링크 요소 개수:`, existingLinks.length);
existingLinks.forEach((link, i) => {
console.log(` 링크 ${i + 1}: "${link.textContent}"`);
if (link.textContent.includes(searchText)) {
console.log(` ⚠️ 이 링크가 백링크 텍스트를 포함하고 있음!`);
}
});
// DOM에서 직접 텍스트 찾기 (링크 요소 포함)
let found = false;
// 1. 일반 텍스트 노드에서 찾기
const walker = document.createTreeWalker(
documentContent,
NodeFilter.SHOW_TEXT,
null,
false
);
let node;
while (node = walker.nextNode()) {
const text = node.textContent;
const index = text.indexOf(searchText);
if (index !== -1) {
console.log(`🎯 일반 텍스트에서 발견! 노드: "${text.substring(0, 50)}..."`);
try {
// 텍스트를 3부분으로 나누기
const beforeText = text.substring(0, index);
const matchText = text.substring(index, index + searchText.length);
const afterText = text.substring(index + searchText.length);
// 새로운 요소들 생성
const parent = node.parentNode;
const fragment = document.createDocumentFragment();
if (beforeText) {
fragment.appendChild(document.createTextNode(beforeText));
}
// 백링크 스팬 생성
const span = document.createElement('span');
span.className = 'backlink-highlight';
span.textContent = matchText;
span.style.cssText = 'color: #EA580C !important; text-decoration: underline !important; cursor: pointer !important; background-color: rgba(234, 88, 12, 0.2) !important; border: 2px solid #EA580C !important; border-radius: 4px !important; padding: 4px 6px !important; font-weight: bold !important; box-shadow: 0 2px 4px rgba(234, 88, 12, 0.3) !important;';
span.setAttribute('data-backlink-id', backlink.id);
span.setAttribute('data-source-document', backlink.source_document_id);
span.setAttribute('data-source-title', backlink.source_document_title);
span.setAttribute('title', `백링크: ${backlink.source_document_title}에서 참조`);
// 백링크 클릭 이벤트 추가
span.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
console.log(`🔗 백링크 클릭됨: ${backlink.source_document_title}`);
this.showBacklinkTooltip(e, [backlink]);
});
fragment.appendChild(span);
if (afterText) {
fragment.appendChild(document.createTextNode(afterText));
}
// DOM에 교체
parent.replaceChild(fragment, node);
console.log(`✅ 백링크 렌더링 성공: "${matchText}"`);
// 강제로 스타일 확인
setTimeout(() => {
const renderedBacklink = documentContent.querySelector('.backlink-highlight');
if (renderedBacklink) {
console.log(`🎯 백링크 DOM 확인됨:`, renderedBacklink);
console.log(`🎯 백링크 스타일:`, renderedBacklink.style.cssText);
console.log(`🎯 백링크 클래스:`, renderedBacklink.className);
} else {
console.error(`❌ 백링크 DOM에서 사라짐!`);
}
}, 100);
found = true;
break;
} catch (error) {
console.error(`❌ 백링크 렌더링 실패:`, error);
}
}
}
// 2. 링크 요소 내부에서 찾기 (일반 텍스트에서 못 찾은 경우)
if (!found) {
console.log(`🔍 링크 요소 내부에서 검색 시도...`);
existingLinks.forEach((linkEl, i) => {
if (linkEl.textContent.includes(searchText) && !found) {
console.log(`🎯 링크 ${i + 1} 내부에서 발견: "${linkEl.textContent}"`);
// 이미 백링크인지 확인
if (linkEl.classList.contains('backlink-highlight')) {
console.log(`⚠️ 이미 백링크로 렌더링됨: "${searchText}"`);
found = true;
return;
}
// 링크 요소를 백링크로 변경
linkEl.className = 'backlink-highlight document-link'; // 두 클래스 모두 유지
linkEl.style.cssText = 'color: #EA580C; text-decoration: underline; cursor: pointer; background-color: rgba(234, 88, 12, 0.1); border-left: 3px solid #EA580C; padding-left: 2px;';
linkEl.setAttribute('data-backlink-id', backlink.id);
linkEl.setAttribute('data-source-document', backlink.source_document_id);
linkEl.setAttribute('data-source-title', backlink.source_document_title);
linkEl.setAttribute('title', `백링크: ${backlink.source_document_title}에서 참조`);
console.log(`✅ 링크를 백링크로 변경 성공: "${searchText}"`);
found = true;
}
});
}
if (!found) {
console.log(`❌ 텍스트를 찾을 수 없음: "${searchText}"`);
}
});
// 백링크 클릭 이벤트 추가
const backlinkElements = documentContent.querySelectorAll('.backlink-highlight');
backlinkElements.forEach(backlinkEl => {
backlinkEl.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
// 클릭된 위치의 모든 백링크 찾기
const clickedText = backlinkEl.textContent;
const overlappingBacklinks = this.getOverlappingBacklinks(backlinkEl);
// 백링크 툴팁 표시
this.showBacklinkTooltip(overlappingBacklinks, backlinkEl, clickedText);
});
});
} catch (error) {
console.warn('백링크 하이라이트 렌더링 실패:', error);
}
},
// 문서에서 특정 텍스트 찾기
findTextInDocument(container, searchText) {
const textNodes = [];
const walker = document.createTreeWalker(
container,
NodeFilter.SHOW_TEXT,
null,
false
);
let node;
while (node = walker.nextNode()) {
if (node.textContent.includes(searchText)) {
textNodes.push(node);
}
}
return textNodes;
},
// 텍스트 노드를 스타일로 감싸기 (간단 버전)
wrapTextNode(textNode, className, attributes = {}) {
try {
const parent = textNode.parentNode;
const span = document.createElement('span');
// span 설정
span.className = className;
span.textContent = textNode.textContent;
// 속성 설정
Object.keys(attributes).forEach(key => {
if (key === 'style') {
span.style.cssText = attributes[key];
} else {
span.setAttribute(key, attributes[key]);
}
});
// DOM에 교체
parent.replaceChild(span, textNode);
return span;
} catch (error) {
console.error('텍스트 노드 감싸기 실패:', error);
return null;
}
},
// 겹치는 백링크들 찾기
getOverlappingBacklinks(clickedElement) {
const clickedBacklinkId = clickedElement.getAttribute('data-backlink-id');
const clickedText = clickedElement.textContent;
// 동일한 텍스트 범위에 있는 모든 백링크 찾기
const overlappingBacklinks = this.backlinks.filter(backlink => {
// 클릭된 백링크와 텍스트가 겹치는지 확인
const backlinkElement = document.querySelector(`[data-backlink-id="${backlink.id}"]`);
if (!backlinkElement) return false;
// 텍스트가 겹치는지 확인 (간단한 방법: 텍스트 내용 비교)
return backlinkElement.textContent === clickedText;
});
return overlappingBacklinks;
},
// 백링크 툴팁 표시
showBacklinkTooltip(backlinks, element, selectedText) {
// 기존 툴팁 제거
this.hideTooltip();
const tooltip = document.createElement('div');
tooltip.id = 'backlink-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';
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-orange-500">
"${selectedText}"
</div>
</div>
`;
if (backlinks.length > 1) {
tooltipHTML += `
<div class="mb-4">
<div class="text-sm font-medium text-gray-700 mb-2">
이 텍스트를 참조하는 문서 (${backlinks.length}개)
</div>
</div>
`;
} else {
tooltipHTML += `
<div class="mb-4">
<div class="text-sm font-medium text-gray-700 mb-2">
이 텍스트를 참조하는 문서
</div>
</div>
`;
}
tooltipHTML += '<div class="space-y-2">';
backlinks.forEach(backlink => {
tooltipHTML += `
<div class="border rounded-lg p-3 hover:bg-orange-50 cursor-pointer"
onclick="window.documentViewerInstance.navigateToBacklinkDocument('${backlink.source_document_id}', ${JSON.stringify(backlink).replace(/"/g, '&quot;')})">
<div class="flex items-center justify-between">
<div class="flex-1">
<div class="font-medium text-orange-700">${backlink.source_document_title}</div>
${backlink.description ? `<div class="text-sm text-gray-600 mt-1">${backlink.description}</div>` : ''}
<div class="text-xs text-gray-500 mt-1">
원본 텍스트: "${backlink.selected_text}"
</div>
</div>
<div class="ml-3">
<svg class="w-5 h-5 text-orange-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16l-4-4m0 0l4-4m-4 4h18"></path>
</svg>
</div>
</div>
</div>
`;
});
tooltipHTML += '</div>';
tooltip.innerHTML = tooltipHTML;
// 위치 계산 및 표시
const rect = element.getBoundingClientRect();
tooltip.style.top = (rect.bottom + window.scrollY + 10) + 'px';
tooltip.style.left = Math.max(10, rect.left + window.scrollX - 175) + 'px';
document.body.appendChild(tooltip);
// 외부 클릭 시 툴팁 숨기기
setTimeout(() => {
document.addEventListener('click', this.handleTooltipOutsideClick.bind(this));
}, 100);
},
// 백링크 문서로 이동
navigateToBacklinkDocument(sourceDocumentId, backlinkInfo) {
console.log('🔗 백링크로 이동:', sourceDocumentId, backlinkInfo);
// 툴팁 숨기기
this.hideTooltip();
// 백링크 문서로 이동
window.location.href = `/viewer.html?id=${sourceDocumentId}`;
},
// 링크된 문서로 이동 (특정 텍스트 위치 포함)
navigateToLinkedDocument(targetDocumentId, linkInfo) {
let targetUrl = `/viewer.html?id=${targetDocumentId}`;
// 특정 텍스트 위치가 있는 경우 URL에 추가
if (linkInfo && linkInfo.link_type === 'text_fragment' && linkInfo.target_text) {
const params = new URLSearchParams({
highlight_text: linkInfo.target_text,
start_offset: linkInfo.target_start_offset,
end_offset: linkInfo.target_end_offset
});
targetUrl += `&${params.toString()}`;
}
console.log('🔗 링크된 문서로 이동:', targetUrl);
window.location.href = targetUrl;
},
// 기존 navigateToDocument 함수 (백워드 호환성)
navigateToDocument(documentId) {
window.location.href = `/viewer.html?id=${documentId}`;
},
// URL 파라미터에서 특정 텍스트 하이라이트 확인
checkForTextHighlight() {
const urlParams = new URLSearchParams(window.location.search);
const highlightText = urlParams.get('highlight_text');
const startOffset = parseInt(urlParams.get('start_offset'));
const endOffset = parseInt(urlParams.get('end_offset'));
if (highlightText && !isNaN(startOffset) && !isNaN(endOffset)) {
console.log('🎯 링크된 텍스트로 이동:', { highlightText, startOffset, endOffset });
// 약간의 지연 후 하이라이트 및 스크롤 (DOM이 완전히 로드된 후)
setTimeout(() => {
this.highlightAndScrollToText(highlightText, startOffset, endOffset);
}, 500);
}
},
// 특정 텍스트를 하이라이트하고 스크롤
highlightAndScrollToText(targetText, startOffset, endOffset) {
console.log('🎯 highlightAndScrollToText 호출됨:', { targetText, startOffset, endOffset });
const documentContent = document.getElementById('document-content');
if (!documentContent) {
console.error('❌ document-content 요소를 찾을 수 없습니다');
return;
}
// 백링크 보호: 기존 백링크 저장
const existingBacklinks = Array.from(documentContent.querySelectorAll('.backlink-highlight'));
console.log(`🔒 백링크 보호: ${existingBacklinks.length}개 백링크 저장`);
const backlinkData = existingBacklinks.map(bl => ({
element: bl,
outerHTML: bl.outerHTML,
parentNode: bl.parentNode,
nextSibling: bl.nextSibling
}));
console.log('📄 문서 내용 길이:', documentContent.textContent.length);
try {
// 임시 하이라이트 적용
console.log('🎨 하이라이트 적용 시작...');
const highlightElement = this.highlightTextRange(
documentContent,
startOffset,
endOffset,
'linked-text-highlight',
{
'style': 'background-color: #FEF3C7 !important; border: 2px solid #F59E0B; border-radius: 4px; padding: 2px;'
}
);
console.log('🔍 하이라이트 요소 결과:', highlightElement);
if (highlightElement) {
console.log('📐 하이라이트 요소 위치:', highlightElement.getBoundingClientRect());
// 해당 요소로 스크롤
highlightElement.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
console.log('✅ 링크된 텍스트로 스크롤 완료');
} else {
console.warn('⚠️ 링크된 텍스트를 찾을 수 없습니다');
console.log('🔍 전체 텍스트 미리보기:', documentContent.textContent.substring(startOffset - 50, endOffset + 50));
}
// 5초 후 하이라이트 제거 및 백링크 복원 (하이라이트 성공 여부와 관계없이)
const self = this;
setTimeout(() => {
const tempHighlight = document.querySelector('.linked-text-highlight');
if (tempHighlight) {
const parent = tempHighlight.parentNode;
parent.replaceChild(document.createTextNode(tempHighlight.textContent), tempHighlight);
parent.normalize();
console.log('🗑️ 임시 하이라이트 제거됨');
}
// 백링크 복원
console.log(`🔄 백링크 복원 시작: ${backlinkData ? backlinkData.length : 0}`);
if (backlinkData && backlinkData.length > 0) {
backlinkData.forEach((data, index) => {
try {
if (data.parentNode && data.parentNode.isConnected) {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = data.outerHTML;
const restoredBacklink = tempDiv.firstChild;
// 클릭 이벤트 다시 추가
restoredBacklink.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
console.log(`🔗 복원된 백링크 클릭됨`);
});
if (data.nextSibling && data.nextSibling.parentNode) {
data.parentNode.insertBefore(restoredBacklink, data.nextSibling);
} else {
data.parentNode.appendChild(restoredBacklink);
}
console.log(`✅ 백링크 ${index + 1} 복원됨`);
} else {
console.warn(`⚠️ 백링크 ${index + 1} 부모 노드 없음`);
}
} catch (error) {
console.error(`❌ 백링크 ${index + 1} 복원 실패:`, error);
}
});
} else {
console.warn('⚠️ 복원할 백링크 데이터가 없음');
// 백링크 재렌더링
console.log('🔄 백링크 재렌더링 시도...');
self.renderBacklinkHighlights();
}
}, 5000);
} catch (error) {
console.error('❌ 텍스트 하이라이트 실패:', error);
}
},
// 텍스트 오프셋 계산 (하이라이트와 동일한 로직)
getTextOffset(container, node, offset) {
let textOffset = 0;
const walker = document.createTreeWalker(
container,
NodeFilter.SHOW_TEXT,
null,
false
);
let currentNode;
while (currentNode = walker.nextNode()) {
if (currentNode === node) {
return textOffset + offset;
}
textOffset += currentNode.textContent.length;
}
return textOffset;
},
// 텍스트 범위 하이라이트 (하이라이트와 동일한 로직, 클래스명만 다름)
highlightTextRange(container, startOffset, endOffset, className, attributes = {}) {
console.log(`🎯 highlightTextRange 호출: ${startOffset}-${endOffset}, 클래스: ${className}`);
const walker = document.createTreeWalker(
container,
NodeFilter.SHOW_TEXT,
null,
false
);
let currentOffset = 0;
let startNode = null;
let startNodeOffset = 0;
let endNode = null;
let endNodeOffset = 0;
// 시작과 끝 노드 찾기
let node;
while (node = walker.nextNode()) {
const nodeLength = node.textContent.length;
if (!startNode && currentOffset + nodeLength > startOffset) {
startNode = node;
startNodeOffset = startOffset - currentOffset;
}
if (currentOffset + nodeLength >= endOffset) {
endNode = node;
endNodeOffset = endOffset - currentOffset;
break;
}
currentOffset += nodeLength;
}
if (!startNode || !endNode) return;
try {
const range = document.createRange();
range.setStart(startNode, startNodeOffset);
range.setEnd(endNode, endNodeOffset);
const span = document.createElement('span');
span.className = className;
// 속성 추가
Object.entries(attributes).forEach(([key, value]) => {
span.setAttribute(key, value);
});
range.surroundContents(span);
console.log(`✅ highlightTextRange 성공: "${span.textContent}" (${className})`);
return span; // 생성된 요소 반환
} catch (error) {
console.warn(`❌ highlightTextRange 실패 (${className}):`, error);
return null;
}
},
// 백링크 관련 메서드들
async loadBacklinks() {
if (!this.documentId) return;
try {
console.log('🔗 백링크 로드 중...');
console.log('📋 현재 문서 ID:', this.documentId);
console.log('📋 현재 문서 제목:', this.documentTitle);
this.backlinks = await window.api.getDocumentBacklinks(this.documentId);
console.log(`✅ 백링크 ${this.backlinks.length}개 로드됨:`, this.backlinks);
// 각 백링크의 상세 정보 출력
this.backlinks.forEach((backlink, index) => {
console.log(`🔗 백링크 ${index + 1}:`);
console.log(` - 소스 문서: ${backlink.source_document_title}`);
console.log(` - 타겟 문서: ${backlink.target_document_title} (현재 문서와 일치해야 함)`);
console.log(` - 선택된 텍스트: "${backlink.selected_text}"`);
console.log(` - 링크 타입: ${backlink.link_type}`);
// 현재 문서와 일치하는지 확인
if (backlink.target_document_id !== this.documentId) {
console.warn(`⚠️ 백링크 타겟 문서 ID 불일치!`);
console.warn(` - 백링크 타겟: ${backlink.target_document_id}`);
console.warn(` - 현재 문서: ${this.documentId}`);
}
});
// Alpine.js 상태 업데이트 강제
if (window.Alpine && window.Alpine.store) {
console.log('🔄 Alpine.js 상태 업데이트 시도...');
}
} catch (error) {
console.error('백링크 로드 실패:', error);
this.backlinks = [];
}
},
navigateToBacklink(backlink) {
console.log('🔗 배너에서 백링크로 이동:', backlink);
// 백링크의 출발 문서로 이동
const url = `/viewer.html?id=${backlink.source_document_id}`;
console.log('🔗 이동할 URL:', url);
window.location.href = url;
},
// 링크 배너에서 링크로 이동
navigateToLink(link) {
console.log('🔗 배너에서 링크로 이동:', link);
// 링크의 대상 문서로 이동
const url = `/viewer.html?id=${link.target_document_id}`;
console.log('🔗 이동할 URL:', url);
// 텍스트 조각 링크인 경우 해당 위치로 스크롤
if (link.link_type === 'text_fragment' && link.target_text) {
const urlWithFragment = `${url}&highlight=${link.target_start_offset}-${link.target_end_offset}&text=${encodeURIComponent(link.target_text)}`;
window.location.href = urlWithFragment;
} else {
window.location.href = url;
}
},
// 고급 링크 기능 메서드들
onTargetDocumentChange() {
// 대상 문서가 변경되면 target_text 초기화
this.linkForm.target_text = '';
this.linkForm.target_start_offset = 0;
this.linkForm.target_end_offset = 0;
},
openTargetDocumentSelector() {
console.log('🎯 openTargetDocumentSelector 함수 호출됨!');
console.log('📋 현재 linkForm.target_document_id:', this.linkForm.target_document_id);
if (!this.linkForm.target_document_id) {
alert('먼저 대상 문서를 선택해주세요.');
return;
}
// 새 창에서 대상 문서 열기 (텍스트 선택 모드 전용 페이지)
const targetUrl = `/text-selector.html?id=${this.linkForm.target_document_id}`;
console.log('🚀 텍스트 선택 창 열기:', targetUrl);
const popup = window.open(targetUrl, 'targetDocumentSelector', 'width=1200,height=800,scrollbars=yes,resizable=yes');
if (!popup) {
console.error('❌ 팝업 창이 차단되었습니다!');
alert('팝업 창이 차단되었습니다. 브라우저 설정에서 팝업을 허용해주세요.');
} else {
console.log('✅ 팝업 창이 성공적으로 열렸습니다');
}
// 팝업에서 텍스트 선택 완료 시 메시지 수신
window.addEventListener('message', (event) => {
if (event.data.type === 'TEXT_SELECTED') {
this.linkForm.target_text = event.data.selectedText;
this.linkForm.target_start_offset = event.data.startOffset;
this.linkForm.target_end_offset = event.data.endOffset;
console.log('🎯 대상 텍스트 선택됨:', event.data);
popup.close();
}
}, { once: true });
},
// 텍스트 선택 모드 초기화
async initTextSelectorMode() {
console.log('🎯 텍스트 선택 모드로 초기화 중...');
// Alpine.js 완전 차단
window.Alpine = {
start: () => console.log('Alpine.js 초기화 차단됨'),
data: () => ({}),
directive: () => {},
magic: () => {},
store: () => ({}),
version: '3.0.0'
};
// 기존 Alpine 인스턴스 제거
if (window.Alpine && window.Alpine.stop) {
window.Alpine.stop();
}
// 인증 확인
if (!api.token) {
window.location.href = '/';
return;
}
try {
// 문서만 로드 (다른 데이터는 불필요)
await this.loadDocument();
// UI 설정
console.log('🔧 텍스트 선택 모드 UI 설정 시작');
this.setupTextSelectorUI();
console.log('✅ 텍스트 선택 모드 UI 설정 완료');
} catch (error) {
console.error('텍스트 선택 모드 초기화 실패:', error);
this.error = '문서를 불러올 수 없습니다: ' + error.message;
} finally {
this.loading = false;
}
},
// 텍스트 선택 모드 UI 설정
setupTextSelectorUI() {
console.log('🔧 setupTextSelectorUI 함수 실행됨');
// 이미 설정되었는지 확인
if (this.textSelectorUISetup) {
console.log('⚠️ 텍스트 선택 모드 UI가 이미 설정됨 - 중복 실행 방지');
return;
}
// 헤더를 텍스트 선택 모드용으로 변경
const header = document.querySelector('header');
console.log('📋 헤더 요소 찾기:', header);
if (header) {
console.log('🎨 헤더 HTML 교체 중...');
// 기존 Alpine 속성 제거
header.removeAttribute('x-data');
header.removeAttribute('x-init');
header.innerHTML = `
<div class="bg-gradient-to-r from-blue-600 to-purple-600 text-white p-4">
<div class="max-w-7xl mx-auto flex items-center justify-between">
<div class="flex items-center space-x-3">
<i class="fas fa-crosshairs text-2xl"></i>
<div>
<h1 class="text-lg font-bold">텍스트 선택 모드</h1>
<p class="text-sm opacity-90">연결하고 싶은 텍스트를 선택하세요</p>
</div>
</div>
<div class="flex items-center space-x-3">
<button id="language-toggle-selector" onclick="window.documentViewerInstance.toggleLanguage()"
class="bg-white bg-opacity-20 hover:bg-opacity-30 px-3 py-2 rounded-lg transition-colors">
<i class="fas fa-language mr-1"></i>언어전환
</button>
<button onclick="window.close()"
class="bg-white bg-opacity-20 hover:bg-opacity-30 px-4 py-2 rounded-lg transition-colors">
<i class="fas fa-times mr-2"></i>취소
</button>
</div>
</div>
</div>
`;
// 헤더가 다시 변경되지 않도록 보호
header.setAttribute('data-text-selector-mode', 'true');
console.log('🔒 헤더 보호 설정 완료');
// 실제 헤더 내용 확인
console.log('📄 헤더 HTML 확인:', header.innerHTML.substring(0, 200) + '...');
// 언어전환 버튼 확인
const langBtn = header.querySelector('#language-toggle-selector');
console.log('🌐 언어전환 버튼 찾기:', langBtn);
// 취소 버튼 확인
const closeBtn = header.querySelector('button[onclick*="window.close"]');
console.log('❌ 취소 버튼 찾기:', closeBtn);
// 헤더 변경 감지
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'childList' || mutation.type === 'attributes') {
console.log('⚠️ 헤더가 변경되었습니다!', mutation);
console.log('🔍 변경 후 헤더 내용:', header.innerHTML.substring(0, 100) + '...');
}
});
});
observer.observe(header, {
childList: true,
subtree: true,
attributes: true,
attributeOldValue: true
});
}
// 사이드 패널 숨기기
const aside = document.querySelector('aside');
if (aside) {
aside.style.display = 'none';
}
// 메인 컨텐츠 영역 조정
const main = document.querySelector('main');
if (main) {
main.style.marginRight = '0';
main.classList.add('text-selector-mode');
}
// 문서 콘텐츠에 텍스트 선택 이벤트 추가
const documentContent = document.getElementById('document-content');
if (documentContent) {
documentContent.addEventListener('mouseup', this.handleTextSelectionForLinking.bind(this));
// 선택 가능한 영역임을 시각적으로 표시
documentContent.style.cursor = 'crosshair';
documentContent.style.userSelect = 'text';
// 안내 메시지 추가
const guideDiv = document.createElement('div');
guideDiv.className = 'bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6';
guideDiv.innerHTML = `
<div class="flex items-start space-x-3">
<i class="fas fa-info-circle text-blue-500 mt-0.5"></i>
<div>
<h3 class="font-semibold text-blue-800 mb-1">텍스트 선택 방법</h3>
<p class="text-sm text-blue-700">
마우스로 연결하고 싶은 텍스트를 드래그하여 선택하세요.
선택이 완료되면 자동으로 부모 창으로 전달됩니다.
</p>
</div>
</div>
`;
documentContent.parentNode.insertBefore(guideDiv, documentContent);
}
// Alpine.js 컴포넌트 비활성화 (텍스트 선택 모드에서는 불필요)
const alpineElements = document.querySelectorAll('[x-data]');
alpineElements.forEach(el => {
el.removeAttribute('x-data');
});
// 설정 완료 플래그
this.textSelectorUISetup = true;
console.log('✅ 텍스트 선택 모드 UI 설정 완료');
},
// 텍스트 선택 모드에서의 텍스트 선택 처리
handleTextSelectionForLinking() {
const selection = window.getSelection();
if (!selection.rangeCount || selection.isCollapsed) return;
const range = selection.getRangeAt(0);
const selectedText = selection.toString().trim();
if (selectedText.length < 3) {
alert('최소 3글자 이상 선택해주세요.');
return;
}
if (selectedText.length > 500) {
alert('선택된 텍스트가 너무 깁니다. 500자 이하로 선택해주세요.');
return;
}
// 텍스트 오프셋 계산
const documentContent = document.getElementById('document-content');
const { startOffset, endOffset } = this.getTextOffset(documentContent, range);
console.log('🎯 텍스트 선택됨:', {
selectedText,
startOffset,
endOffset
});
// 선택 확인 UI 표시
this.showTextSelectionConfirm(selectedText, startOffset, endOffset);
},
// 텍스트 선택 확인 UI
showTextSelectionConfirm(selectedText, startOffset, endOffset) {
// 기존 확인 UI 제거
const existingConfirm = document.querySelector('.text-selection-confirm');
if (existingConfirm) {
existingConfirm.remove();
}
// 확인 UI 생성
const confirmDiv = document.createElement('div');
confirmDiv.className = 'text-selection-confirm fixed bottom-6 left-1/2 transform -translate-x-1/2 bg-white rounded-lg shadow-2xl border p-6 max-w-md z-50';
// 텍스트 미리보기 (안전하게 처리)
const previewText = selectedText.length > 100 ? selectedText.substring(0, 100) + '...' : selectedText;
confirmDiv.innerHTML = `
<div class="text-center">
<div class="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<i class="fas fa-check text-green-600 text-xl"></i>
</div>
<h3 class="font-semibold text-gray-900 mb-2">텍스트가 선택되었습니다</h3>
<div class="bg-gray-50 rounded-md p-3 mb-4">
<p class="text-sm text-gray-700 italic" id="selected-text-preview"></p>
</div>
<div class="flex space-x-3">
<button id="reselect-btn"
class="flex-1 bg-gray-200 text-gray-800 px-4 py-2 rounded-md hover:bg-gray-300 transition-colors">
다시 선택
</button>
<button id="confirm-selection-btn"
class="flex-1 bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 transition-colors">
이 텍스트 사용
</button>
</div>
</div>
`;
// 텍스트 안전하게 설정
const previewElement = confirmDiv.querySelector('#selected-text-preview');
previewElement.textContent = `"${previewText}"`;
// 이벤트 리스너 추가
const reselectBtn = confirmDiv.querySelector('#reselect-btn');
const confirmBtn = confirmDiv.querySelector('#confirm-selection-btn');
reselectBtn.addEventListener('click', () => {
confirmDiv.remove();
});
confirmBtn.addEventListener('click', () => {
this.confirmTextSelection(selectedText, startOffset, endOffset);
});
document.body.appendChild(confirmDiv);
// 10초 후 자동 제거 (사용자가 선택하지 않은 경우)
setTimeout(() => {
if (document.contains(confirmDiv)) {
confirmDiv.remove();
}
}, 10000);
},
// 텍스트 선택 확정
confirmTextSelection(selectedText, startOffset, endOffset) {
// 부모 창에 선택된 텍스트 정보 전달
if (window.opener) {
window.opener.postMessage({
type: 'TEXT_SELECTED',
selectedText: selectedText,
startOffset: startOffset,
endOffset: endOffset
}, '*');
console.log('✅ 부모 창에 텍스트 선택 정보 전달됨');
// 성공 메시지 표시 후 창 닫기
const confirmDiv = document.querySelector('.text-selection-confirm');
if (confirmDiv) {
confirmDiv.innerHTML = `
<div class="text-center">
<div class="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<i class="fas fa-check text-green-600 text-xl"></i>
</div>
<h3 class="font-semibold text-green-800 mb-2">선택 완료!</h3>
<p class="text-sm text-gray-600">창이 자동으로 닫힙니다...</p>
</div>
`;
setTimeout(() => {
window.close();
}, 1500);
}
} else {
alert('부모 창을 찾을 수 없습니다.');
}
},
// 기능 메뉴 토글
toggleFeatureMenu(feature) {
if (this.activeFeatureMenu === feature) {
this.activeFeatureMenu = null;
} else {
this.activeFeatureMenu = feature;
}
},
// 링크 모드 활성화
activateLinkMode() {
if (this.contentType === 'note') {
alert('📝 노트에서는 링크 기능이 향후 지원 예정입니다.');
return;
}
console.log('🔗 링크 모드 활성화 - activateLinkMode 함수 실행됨');
// 이미 선택된 텍스트가 있는지 확인
const selection = window.getSelection();
console.log('📝 현재 선택 상태:', {
rangeCount: selection.rangeCount,
isCollapsed: selection.isCollapsed,
selectedText: selection.toString()
});
if (selection.rangeCount > 0 && !selection.isCollapsed) {
const selectedText = selection.toString().trim();
if (selectedText.length > 0) {
console.log('✅ 선택된 텍스트 발견:', selectedText);
this.selectedText = selectedText;
this.selectedRange = selection.getRangeAt(0);
console.log('🔗 createDocumentLink 함수 호출 예정');
this.createDocumentLink();
return;
}
}
// 선택된 텍스트가 없으면 선택 모드 활성화
console.log('📝 텍스트 선택 모드 활성화');
this.activeMode = 'link';
this.showSelectionMessage('텍스트를 선택하세요.');
// 기존 리스너 제거 후 새로 추가
this.removeTextSelectionListener();
this.textSelectionHandler = this.handleTextSelection.bind(this);
document.addEventListener('mouseup', this.textSelectionHandler);
},
// 메모 모드 활성화
activateNoteMode() {
console.log('📝 메모 모드 활성화');
// 이미 선택된 텍스트가 있는지 확인
const selection = window.getSelection();
if (selection.rangeCount > 0 && !selection.isCollapsed) {
const selectedText = selection.toString().trim();
if (selectedText.length > 0) {
console.log('✅ 선택된 텍스트 발견:', selectedText);
this.selectedText = selectedText;
this.selectedRange = selection.getRangeAt(0);
this.createNoteFromSelection();
return;
}
}
// 선택된 텍스트가 없으면 선택 모드 활성화
console.log('📝 텍스트 선택 모드 활성화');
this.activeMode = 'memo';
this.showSelectionMessage('텍스트를 선택하세요.');
// 기존 리스너 제거 후 새로 추가
this.removeTextSelectionListener();
this.textSelectionHandler = this.handleTextSelection.bind(this);
document.addEventListener('mouseup', this.textSelectionHandler);
},
// 책갈피 모드 활성화
activateBookmarkMode() {
console.log('🔖 책갈피 모드 활성화');
// 이미 선택된 텍스트가 있는지 확인
const selection = window.getSelection();
if (selection.rangeCount > 0 && !selection.isCollapsed) {
const selectedText = selection.toString().trim();
if (selectedText.length > 0) {
console.log('✅ 선택된 텍스트 발견:', selectedText);
this.selectedText = selectedText;
this.selectedRange = selection.getRangeAt(0);
this.createBookmarkFromSelection();
return;
}
}
// 선택된 텍스트가 없으면 선택 모드 활성화
console.log('📝 텍스트 선택 모드 활성화');
this.activeMode = 'bookmark';
this.showSelectionMessage('텍스트를 선택하세요.');
// 기존 리스너 제거 후 새로 추가
this.removeTextSelectionListener();
this.textSelectionHandler = this.handleTextSelection.bind(this);
document.addEventListener('mouseup', this.textSelectionHandler);
},
// 텍스트 선택 리스너 제거
removeTextSelectionListener() {
if (this.textSelectionHandler) {
document.removeEventListener('mouseup', this.textSelectionHandler);
this.textSelectionHandler = null;
}
},
// 텍스트 선택 처리
handleTextSelection(event) {
console.log('🎯 텍스트 선택 이벤트 발생');
const selection = window.getSelection();
if (selection.rangeCount > 0 && !selection.isCollapsed) {
const range = selection.getRangeAt(0);
const selectedText = selection.toString().trim();
console.log('📝 선택된 텍스트:', selectedText);
if (selectedText.length > 0) {
this.selectedText = selectedText;
this.selectedRange = range;
// 선택 모드에 따라 다른 동작
console.log('🎯 현재 모드:', this.activeMode);
if (this.activeMode === 'link') {
console.log('🔗 링크 생성 실행');
this.createDocumentLink();
} else if (this.activeMode === 'memo') {
console.log('📝 메모 생성 실행');
this.createNoteFromSelection();
} else if (this.activeMode === 'bookmark') {
console.log('🔖 책갈피 생성 실행');
this.createBookmarkFromSelection();
}
// 모드 해제
this.activeMode = null;
this.hideSelectionMessage();
this.removeTextSelectionListener();
}
}
},
// 선택 메시지 표시
showSelectionMessage(message) {
// 기존 메시지 제거
const existingMessage = document.querySelector('.selection-message');
if (existingMessage) {
existingMessage.remove();
}
// 새 메시지 생성
const messageDiv = document.createElement('div');
messageDiv.className = 'selection-message fixed top-20 left-1/2 transform -translate-x-1/2 bg-blue-500 text-white px-4 py-2 rounded-lg shadow-lg z-50';
messageDiv.textContent = message;
document.body.appendChild(messageDiv);
},
// 선택 메시지 숨기기
hideSelectionMessage() {
const existingMessage = document.querySelector('.selection-message');
if (existingMessage) {
existingMessage.remove();
}
},
// 링크 생성 UI 표시
showLinkCreationUI() {
this.createDocumentLink();
},
// 선택된 텍스트로 메모 생성
async createNoteFromSelection() {
if (!this.selectedText || !this.selectedRange) return;
try {
// 하이라이트 생성
const highlightData = await this.createHighlight(this.selectedText, this.selectedRange, '#FFFF00');
// 메모 내용 입력받기
const content = prompt('메모 내용을 입력하세요:', '');
if (content === null) {
// 취소한 경우 하이라이트 제거
const highlightElement = document.querySelector(`[data-highlight-id="${highlightData.id}"]`);
if (highlightElement) {
const parent = highlightElement.parentNode;
parent.replaceChild(document.createTextNode(highlightElement.textContent), highlightElement);
parent.normalize();
}
return;
}
// 메모 생성
const noteData = {
highlight_id: highlightData.id,
content: content
};
// 노트와 문서에 따라 다른 API 호출
let note;
if (this.contentType === 'note') {
noteData.note_id = this.documentId; // 노트 메모는 note_id 필요
note = await api.post('/note-notes/', noteData);
} else {
note = await api.createNote(this.documentId, noteData);
}
// 데이터 새로고침
await this.loadNotes();
alert('메모가 생성되었습니다.');
} catch (error) {
console.error('메모 생성 실패:', error);
alert('메모 생성에 실패했습니다.');
}
},
// 선택된 텍스트로 책갈피 생성
async createBookmarkFromSelection() {
if (!this.selectedText || !this.selectedRange) return;
try {
// 하이라이트 생성 (책갈피는 주황색)
const highlightData = await this.createHighlight(this.selectedText, this.selectedRange, '#FFA500');
// 책갈피 생성
const bookmarkData = {
highlight_id: highlightData.id,
title: this.selectedText.substring(0, 50) + (this.selectedText.length > 50 ? '...' : '')
};
const bookmark = await api.createBookmark(this.documentId, bookmarkData);
// 데이터 새로고침
await this.loadBookmarks();
alert('책갈피가 생성되었습니다.');
} catch (error) {
console.error('책갈피 생성 실패:', error);
alert('책갈피 생성에 실패했습니다.');
}
},
// 대상 선택 초기화
resetTargetSelection() {
this.linkForm.target_book_id = '';
this.linkForm.target_document_id = '';
this.linkForm.target_text = '';
this.linkForm.target_start_offset = null;
this.linkForm.target_end_offset = null;
this.filteredDocuments = [];
// 같은 서적인 경우 현재 서적의 문서들 로드
if (this.linkForm.book_scope === 'same') {
this.loadSameBookDocuments();
}
},
// 같은 서적의 문서들 로드
async loadSameBookDocuments() {
try {
if (this.navigation?.book_info?.id) {
// 현재 서적의 문서들만 가져오기
const allDocuments = await api.getLinkableDocuments(this.documentId);
this.filteredDocuments = allDocuments.filter(doc =>
doc.book_id === this.navigation.book_info.id && doc.id !== this.documentId
);
console.log('📚 같은 서적 문서들:', this.filteredDocuments);
} else {
// 서적 정보가 없으면 모든 문서
this.filteredDocuments = await api.getLinkableDocuments(this.documentId);
}
} catch (error) {
console.error('같은 서적 문서 로드 실패:', error);
this.filteredDocuments = [];
}
},
// 서적별 문서 로드
async loadDocumentsFromBook() {
try {
if (this.linkForm.target_book_id) {
// 선택된 서적의 문서들만 가져오기
const allDocuments = await api.getLinkableDocuments(this.documentId);
this.filteredDocuments = allDocuments.filter(doc =>
doc.book_id === this.linkForm.target_book_id
);
console.log('📚 선택된 서적 문서들:', this.filteredDocuments);
} else {
this.filteredDocuments = [];
}
// 문서 선택 초기화
this.linkForm.target_document_id = '';
} catch (error) {
console.error('서적별 문서 로드 실패:', error);
this.filteredDocuments = [];
}
},
// 사용 가능한 서적 목록 로드
async loadAvailableBooks() {
try {
console.log('📚 서적 목록 로딩 시작...');
// 문서 목록에서 서적 정보 추출
const allDocuments = await api.getLinkableDocuments(this.documentId);
console.log('📄 모든 문서들:', allDocuments);
// 서적별로 그룹화
const bookMap = new Map();
allDocuments.forEach(doc => {
if (doc.book_id && doc.book_title) {
bookMap.set(doc.book_id, {
id: doc.book_id,
title: doc.book_title
});
}
});
// 현재 서적 제외
const currentBookId = this.navigation?.book_info?.id;
if (currentBookId) {
bookMap.delete(currentBookId);
}
this.availableBooks = Array.from(bookMap.values());
console.log('📚 사용 가능한 서적들:', this.availableBooks);
console.log('🔍 현재 서적 ID:', currentBookId);
} catch (error) {
console.error('서적 목록 로드 실패:', error);
this.availableBooks = [];
}
},
// 선택된 서적 제목 가져오기
getSelectedBookTitle() {
const selectedBook = this.availableBooks.find(book => book.id === this.linkForm.target_book_id);
return selectedBook ? selectedBook.title : '';
}
});