/** * LinkManager 모듈 * 문서 링크 및 백링크 통합 관리 */ class LinkManager { constructor(api) { console.log('🔗 LinkManager 초기화 시작'); this.api = api; // 캐싱된 API 사용 (사용 가능한 경우) this.cachedApi = window.cachedApi || api; this.documentLinks = []; this.backlinks = []; // 안전한 초기화 확인 console.log('🔧 LinkManager 초기화 - backlinks 타입:', typeof this.backlinks, Array.isArray(this.backlinks)); this.selectedText = ''; this.selectedRange = null; this.availableBooks = []; this.filteredDocuments = []; console.log('✅ LinkManager 초기화 완료'); } /** * 문서 링크 데이터 로드 */ async loadDocumentLinks(documentId) { try { console.log('📡 링크 API 호출:', `/documents/${documentId}/links`); const response = await this.cachedApi.get(`/documents/${documentId}/links`, {}, { category: 'links' }).catch(() => []); this.documentLinks = Array.isArray(response) ? response : []; console.log('📡 API 응답 링크 개수:', this.documentLinks.length); console.log('📡 API 응답 타입:', typeof response, response); return this.documentLinks; } catch (error) { console.error('문서 링크 로드 실패:', error); this.documentLinks = []; return []; } } /** * 백링크 데이터 로드 */ async loadBacklinks(documentId) { try { console.log('📡 백링크 API 호출:', `/documents/${documentId}/backlinks`); const response = await this.cachedApi.get(`/documents/${documentId}/backlinks`, {}, { category: 'links' }).catch(() => []); this.backlinks = Array.isArray(response) ? response : []; console.log('📡 API 응답 백링크 개수:', this.backlinks.length); console.log('📡 API 응답 타입:', typeof response, response); return this.backlinks; } catch (error) { console.error('백링크 로드 실패:', error); this.backlinks = []; return []; } } /** * 문서 링크 렌더링 */ renderDocumentLinks() { const documentContent = document.getElementById('document-content'); if (!documentContent) return; // 안전한 링크 초기화 if (!Array.isArray(this.documentLinks)) { console.warn('⚠️ this.documentLinks가 배열이 아닙니다. 빈 배열로 초기화합니다.'); console.log('🔍 기존 this.documentLinks:', typeof this.documentLinks, this.documentLinks); this.documentLinks = []; } console.log('🔗 링크 렌더링 시작 - 총', this.documentLinks.length, '개'); // 링크 데이터가 없으면 렌더링하지 않음 (기존 링크 유지) if (this.documentLinks.length === 0) { console.log('📝 링크 데이터가 없어서 기존 링크를 유지합니다.'); return; } // 기존 링크 제거 const existingLinks = documentContent.querySelectorAll('.document-link'); existingLinks.forEach(el => { const parent = el.parentNode; parent.replaceChild(document.createTextNode(el.textContent), el); parent.normalize(); }); // 각 링크 렌더링 if (Array.isArray(this.documentLinks)) { this.documentLinks.forEach(link => { this.renderSingleLink(link); }); } else { console.warn('⚠️ this.documentLinks가 배열이 아닙니다:', typeof this.documentLinks, this.documentLinks); } console.log('✅ 링크 렌더링 완료'); } /** * 개별 링크 렌더링 */ renderSingleLink(link) { const content = document.getElementById('document-content'); const textContent = content.textContent; if (link.start_offset >= textContent.length || link.end_offset > textContent.length) { console.warn('링크 위치가 텍스트 범위를 벗어남:', link); return; } const walker = document.createTreeWalker( content, NodeFilter.SHOW_TEXT, null, false ); let currentOffset = 0; let node; while (node = walker.nextNode()) { const nodeLength = node.textContent.length; const nodeStart = currentOffset; const nodeEnd = currentOffset + nodeLength; // 링크 범위와 겹치는지 확인 if (nodeEnd > link.start_offset && nodeStart < link.end_offset) { const linkStart = Math.max(0, link.start_offset - nodeStart); const linkEnd = Math.min(nodeLength, link.end_offset - nodeStart); if (linkStart < linkEnd) { this.applyLinkToNode(node, linkStart, linkEnd, link); break; } } currentOffset = nodeEnd; } } /** * 텍스트 노드에 링크 적용 */ applyLinkToNode(textNode, start, end, link) { const text = textNode.textContent; const beforeText = text.substring(0, start); const linkText = text.substring(start, end); const afterText = text.substring(end); // 링크 스팬 생성 const span = document.createElement('span'); span.className = 'document-link'; span.textContent = linkText; span.dataset.linkId = link.id; // 링크 스타일 (보라색) - 레이아웃 안전 span.style.cssText = ` color: #7C3AED !important; text-decoration: underline !important; cursor: pointer !important; background-color: rgba(124, 58, 237, 0.1) !important; border-radius: 2px !important; padding: 0 1px !important; display: inline !important; line-height: inherit !important; vertical-align: baseline !important; margin: 0 !important; box-sizing: border-box !important; `; // 클릭 이벤트 추가 span.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); this.showLinkTooltip(link, span); }); // DOM 교체 const parent = textNode.parentNode; const fragment = document.createDocumentFragment(); if (beforeText) fragment.appendChild(document.createTextNode(beforeText)); fragment.appendChild(span); if (afterText) fragment.appendChild(document.createTextNode(afterText)); parent.replaceChild(fragment, textNode); } /** * 백링크 렌더링 (링크와 동일한 방식) */ renderBacklinks() { const documentContent = document.getElementById('document-content'); if (!documentContent) return; // 안전한 백링크 초기화 if (!Array.isArray(this.backlinks)) { console.warn('⚠️ this.backlinks가 배열이 아닙니다. 빈 배열로 초기화합니다.'); console.log('🔍 기존 this.backlinks:', typeof this.backlinks, this.backlinks); this.backlinks = []; } console.log('🔗 백링크 렌더링 시작 - 총', this.backlinks.length, '개'); // 백링크 데이터가 없으면 렌더링하지 않음 (기존 백링크 유지) if (this.backlinks.length === 0) { console.log('📝 백링크 데이터가 없어서 기존 백링크를 유지합니다.'); return; } // 기존 백링크는 제거하지 않고 중복 체크만 함 const existingBacklinks = documentContent.querySelectorAll('.backlink-highlight'); console.log(`🔍 기존 백링크 ${existingBacklinks.length}개 발견 (유지)`); // 각 백링크 렌더링 (중복되지 않는 것만) if (Array.isArray(this.backlinks)) { this.backlinks.forEach(backlink => { // 이미 렌더링된 백링크인지 확인 const existingBacklink = Array.from(existingBacklinks).find(el => el.dataset.backlinkId === backlink.id.toString() ); if (!existingBacklink) { console.log(`🆕 새로운 백링크 렌더링: ${backlink.id}`); this.renderSingleBacklink(backlink); } else { console.log(`✅ 백링크 이미 존재: ${backlink.id}`); } }); } else { console.warn('⚠️ this.backlinks가 배열이 아닙니다:', typeof this.backlinks, this.backlinks); } console.log('✅ 백링크 렌더링 완료'); } /** * 개별 백링크 렌더링 */ renderSingleBacklink(backlink) { const content = document.getElementById('document-content'); if (!content) return; // 실제 문서 내용만 추출 (CSS, 스크립트 제외) const contentClone = content.cloneNode(true); // 스타일 태그와 스크립트 태그 제거 const styleTags = contentClone.querySelectorAll('style, script'); styleTags.forEach(tag => tag.remove()); const textContent = contentClone.textContent || contentClone.innerText || ''; // target_text가 있으면 사용, 없으면 selected_text 사용 const searchText = backlink.target_text || backlink.selected_text; if (!searchText) return; // 텍스트 검색 (대소문자 무시, 공백 정규화) const normalizedContent = textContent.replace(/\s+/g, ' ').trim(); const normalizedSearchText = searchText.replace(/\s+/g, ' ').trim(); let textIndex = normalizedContent.indexOf(normalizedSearchText); if (textIndex === -1) { // 부분 검색 시도 const words = normalizedSearchText.split(' '); if (words.length > 1) { // 첫 번째와 마지막 단어로 검색 const firstWord = words[0]; const lastWord = words[words.length - 1]; const partialPattern = firstWord + '.*' + lastWord; const regex = new RegExp(partialPattern, 'i'); const match = normalizedContent.match(regex); if (match) { textIndex = match.index; console.log('✅ 부분 매칭으로 백링크 텍스트 찾음:', searchText); } } } if (textIndex === -1) { console.warn('백링크 텍스트를 찾을 수 없음:', searchText); console.log('검색 대상 텍스트 미리보기:', normalizedContent.substring(0, 200)); return; } const walker = document.createTreeWalker( content, NodeFilter.SHOW_TEXT, null, false ); let currentOffset = 0; let node; while (node = walker.nextNode()) { const nodeLength = node.textContent.length; const nodeStart = currentOffset; const nodeEnd = currentOffset + nodeLength; // 백링크 범위와 겹치는지 확인 if (nodeEnd > textIndex && nodeStart < textIndex + searchText.length) { const backlinkStart = Math.max(0, textIndex - nodeStart); const backlinkEnd = Math.min(nodeLength, textIndex + searchText.length - nodeStart); if (backlinkStart < backlinkEnd) { this.applyBacklinkToNode(node, backlinkStart, backlinkEnd, backlink); break; } } currentOffset = nodeEnd; } } /** * 텍스트 노드에 백링크 적용 */ applyBacklinkToNode(textNode, start, end, backlink) { const text = textNode.textContent; const beforeText = text.substring(0, start); const backlinkText = text.substring(start, end); const afterText = text.substring(end); // 백링크 스팬 생성 const span = document.createElement('span'); span.className = 'backlink-highlight'; span.textContent = backlinkText; span.dataset.backlinkId = backlink.id; // 백링크 스타일 (주황색) - 레이아웃 안전 span.style.cssText = ` color: #EA580C !important; text-decoration: underline !important; cursor: pointer !important; background-color: rgba(234, 88, 12, 0.2) !important; border: 1px solid #EA580C !important; border-radius: 3px !important; padding: 0 2px !important; font-weight: bold !important; display: inline !important; line-height: inherit !important; vertical-align: baseline !important; margin: 0 !important; box-sizing: border-box !important; `; // 클릭 이벤트 추가 span.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); this.showBacklinkTooltip(backlink, span); }); // DOM 교체 const parent = textNode.parentNode; const fragment = document.createDocumentFragment(); if (beforeText) fragment.appendChild(document.createTextNode(beforeText)); fragment.appendChild(span); if (afterText) fragment.appendChild(document.createTextNode(afterText)); parent.replaceChild(fragment, textNode); } /** * 링크 툴팁 표시 */ showLinkTooltip(link, element) { 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'; const tooltipHTML = `
링크 정보
"${link.selected_text}"
연결된 문서
${link.target_document_title}
${link.target_text ? `
대상 텍스트: "${link.target_text}"
` : ''}
`; tooltip.innerHTML = tooltipHTML; this.positionTooltip(tooltip, element); } /** * 백링크 툴팁 표시 */ showBacklinkTooltip(backlink, element) { 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'; const tooltipHTML = `
백링크 정보
"${backlink.target_text || backlink.selected_text}"
참조 문서
${backlink.source_document_title}
원본 텍스트: "${backlink.selected_text}"
`; tooltip.innerHTML = tooltipHTML; this.positionTooltip(tooltip, element); } /** * 툴팁 위치 설정 */ positionTooltip(tooltip, element) { 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 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 linkTooltip = document.getElementById('link-tooltip'); const backlinkTooltip = document.getElementById('backlink-tooltip'); const isOutsideLinkTooltip = linkTooltip && !linkTooltip.contains(e.target); const isOutsideBacklinkTooltip = backlinkTooltip && !backlinkTooltip.contains(e.target); if (isOutsideLinkTooltip || isOutsideBacklinkTooltip) { this.hideTooltip(); } } /** * 링크된 문서로 이동 */ navigateToLinkedDocument(targetDocumentId, linkInfo) { let targetUrl = `/viewer.html?id=${targetDocumentId}`; // 특정 텍스트 위치가 있는 경우 URL에 추가 if (linkInfo.target_text && linkInfo.target_start_offset !== undefined) { targetUrl += `&highlight_text=${encodeURIComponent(linkInfo.target_text)}`; targetUrl += `&start_offset=${linkInfo.target_start_offset}`; targetUrl += `&end_offset=${linkInfo.target_end_offset}`; } window.location.href = targetUrl; } /** * 원본 문서로 이동 (백링크) */ navigateToSourceDocument(sourceDocumentId, backlinkInfo) { let targetUrl = `/viewer.html?id=${sourceDocumentId}`; // 원본 텍스트 위치가 있는 경우 URL에 추가 if (backlinkInfo.selected_text && backlinkInfo.start_offset !== undefined) { targetUrl += `&highlight_text=${encodeURIComponent(backlinkInfo.selected_text)}`; targetUrl += `&start_offset=${backlinkInfo.start_offset}`; targetUrl += `&end_offset=${backlinkInfo.end_offset}`; } window.location.href = targetUrl; } /** * 링크 삭제 */ async deleteLink(linkId) { if (!confirm('이 링크를 삭제하시겠습니까?')) { return; } try { await this.api.delete(`/document-links/${linkId}`); this.documentLinks = this.documentLinks.filter(l => l.id !== linkId); this.hideTooltip(); this.renderDocumentLinks(); console.log('링크 삭제 완료:', linkId); } catch (error) { console.error('링크 삭제 실패:', error); alert('링크 삭제에 실패했습니다'); } } /** * 선택된 텍스트로 링크 생성 */ async createLinkFromSelection(documentId = null, selectedText = null, selectedRange = null) { // 매개변수가 없으면 현재 선택된 텍스트 사용 if (!selectedText || !selectedRange) { selectedText = window.getSelection().toString().trim(); const selection = window.getSelection(); if (!selectedText || selection.rangeCount === 0) { alert('텍스트를 먼저 선택해주세요.'); return; } selectedRange = selection.getRangeAt(0); } if (!documentId && window.documentViewerInstance) { documentId = window.documentViewerInstance.documentId; } try { console.log('🔗 링크 생성 시작:', selectedText); // ViewerCore의 링크 생성 모달 표시 if (window.documentViewerInstance) { window.documentViewerInstance.selectedText = selectedText; window.documentViewerInstance.selectedRange = selectedRange; window.documentViewerInstance.showLinkModal = true; window.documentViewerInstance.linkForm.selected_text = selectedText; // 서적 목록 로드 await window.documentViewerInstance.loadAvailableBooks(); // 기본적으로 같은 서적 문서들 로드 await window.documentViewerInstance.loadSameBookDocuments(); // 텍스트 오프셋 계산 const documentContent = document.getElementById('document-content'); const fullText = documentContent.textContent; const startOffset = this.getTextOffset(documentContent, selectedRange.startContainer, selectedRange.startOffset); const endOffset = startOffset + selectedText.length; window.documentViewerInstance.linkForm.start_offset = startOffset; window.documentViewerInstance.linkForm.end_offset = endOffset; } } catch (error) { console.error('링크 생성 실패:', error); alert('링크 생성에 실패했습니다: ' + error.message); } } /** * 텍스트 오프셋 계산 */ getTextOffset(root, node, offset) { let textOffset = 0; const walker = document.createTreeWalker( root, NodeFilter.SHOW_TEXT, null, false ); let currentNode; while (currentNode = walker.nextNode()) { if (currentNode === node) { return textOffset + offset; } textOffset += currentNode.textContent.length; } return textOffset; } } // 전역으로 내보내기 window.LinkManager = LinkManager;