/** * 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 = `