/** * 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, contentType = 'document') { try { console.log('🔍 loadDocumentLinks 호출됨 - documentId:', documentId, 'contentType:', contentType); let apiEndpoint; if (contentType === 'note') { // 노트 문서의 경우 노트 전용 링크 API 사용 apiEndpoint = `/note-documents/${documentId}/links`; console.log('✅ 노트 API 엔드포인트 선택:', apiEndpoint); } else { // 일반 문서의 경우 기존 API 사용 apiEndpoint = `/documents/${documentId}/links`; console.log('✅ 문서 API 엔드포인트 선택:', apiEndpoint); } console.log('📡 링크 API 호출:', apiEndpoint); console.log('📡 사용 중인 documentId:', documentId, 'contentType:', contentType); console.log('📡 cachedApi 객체:', this.cachedApi); const response = await this.cachedApi.get(apiEndpoint, {}, { category: 'links' }); console.log('📡 원본 API 응답:', response); console.log('📡 응답 타입:', typeof response); console.log('📡 응답이 배열인가?', Array.isArray(response)); console.log('📡 응답 JSON 문자열:', JSON.stringify(response, null, 2)); console.log('📡 응답 키들:', Object.keys(response || {})); // API 응답이 객체일 경우 처리 if (Array.isArray(response)) { this.documentLinks = response; } else if (response && typeof response === 'object') { // 객체에서 배열 추출 시도 if (response.data && Array.isArray(response.data)) { this.documentLinks = response.data; } else if (response.links && Array.isArray(response.links)) { this.documentLinks = response.links; } else { console.warn('⚠️ 예상치 못한 API 응답 구조:', response); this.documentLinks = []; } } else { this.documentLinks = []; } // target_content_type이 없는 링크들에 대해 추론 로직 적용 this.documentLinks = this.documentLinks.map(link => { if (!link.target_content_type) { if (link.target_note_id) { link.target_content_type = 'note'; console.log('🔍 링크 타입 추론: note -', link.id); } else if (link.target_document_id) { link.target_content_type = 'document'; console.log('🔍 링크 타입 추론: document -', link.id); } } return link; }); console.log('📡 최종 링크 데이터 (타입 추론 완료):', this.documentLinks); console.log('📡 최종 링크 개수:', this.documentLinks.length); return this.documentLinks; } catch (error) { console.error('❌ 문서 링크 로드 실패:', error); this.documentLinks = []; return []; } } /** * 백링크 데이터 로드 */ async loadBacklinks(documentId, contentType = 'document') { try { console.log('🔍 loadBacklinks 호출됨 - documentId:', documentId, 'contentType:', contentType); let apiEndpoint; if (contentType === 'note') { // 노트 문서의 경우 노트 전용 백링크 API 사용 apiEndpoint = `/note-documents/${documentId}/backlinks`; console.log('✅ 노트 백링크 API 엔드포인트 선택:', apiEndpoint); } else { // 일반 문서의 경우 기존 API 사용 apiEndpoint = `/documents/${documentId}/backlinks`; console.log('✅ 문서 백링크 API 엔드포인트 선택:', apiEndpoint); } console.log('📡 백링크 API 호출:', apiEndpoint); console.log('📡 사용 중인 documentId:', documentId, 'contentType:', contentType); const response = await this.cachedApi.get(apiEndpoint, {}, { category: 'links' }); console.log('📡 원본 백링크 응답:', response); console.log('📡 백링크 응답 타입:', typeof response); console.log('📡 백링크 응답이 배열인가?', Array.isArray(response)); console.log('📡 백링크 응답 JSON 문자열:', JSON.stringify(response, null, 2)); console.log('📡 백링크 응답 키들:', Object.keys(response || {})); // API 응답이 객체일 경우 처리 if (Array.isArray(response)) { this.backlinks = response; } else if (response && typeof response === 'object') { // 객체에서 배열 추출 시도 if (response.data && Array.isArray(response.data)) { this.backlinks = response.data; } else if (response.backlinks && Array.isArray(response.backlinks)) { this.backlinks = response.backlinks; } else { console.warn('⚠️ 예상치 못한 백링크 API 응답 구조:', response); this.backlinks = []; } } else { this.backlinks = []; } console.log('📡 최종 백링크 데이터:', this.backlinks); console.log('📡 최종 백링크 개수:', this.backlinks.length); return this.backlinks; } catch (error) { console.error('❌ 백링크 로드 실패:', error); this.backlinks = []; return []; } } /** * 문서 링크 렌더링 */ renderDocumentLinks() { const documentContent = document.getElementById('document-content'); if (!documentContent) { console.error('❌ document-content 요소를 찾을 수 없습니다'); 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, '개'); console.log('🔗 링크 데이터 상세:', this.documentLinks); // 기존 링크 제거 (항상 실행) const existingLinks = documentContent.querySelectorAll('.document-link'); console.log('🔍 기존 링크 제거:', existingLinks.length, '개'); existingLinks.forEach(el => { const parent = el.parentNode; parent.replaceChild(document.createTextNode(el.textContent), el); parent.normalize(); }); // 링크 데이터가 없으면 여기서 종료 if (this.documentLinks.length === 0) { console.log('📝 링크 데이터가 없습니다.'); return; } // 각 링크 렌더링 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) { console.log('🔗 renderSingleLink 시작:', link.id, link.selected_text); const content = document.getElementById('document-content'); const textContent = content.textContent; console.log('📝 문서 텍스트 길이:', textContent.length); console.log('📍 링크 위치:', link.start_offset, '-', link.end_offset); console.log('📝 예상 텍스트:', textContent.substring(link.start_offset, link.end_offset)); if (link.start_offset >= textContent.length || link.end_offset > textContent.length) { console.warn('❌ 링크 위치가 텍스트 범위를 벗어남:', link); console.log('📝 텍스트 미리보기:', textContent.substring(0, 200)); console.log('📝 링크 주변 텍스트:', textContent.substring(Math.max(0, link.start_offset - 50), link.start_offset + 50)); return; } // 실제 텍스트와 선택된 텍스트가 일치하는지 확인 const actualText = textContent.substring(link.start_offset, link.end_offset); if (actualText !== link.selected_text) { console.warn('⚠️ 오프셋 위치의 텍스트가 선택된 텍스트와 다름'); console.log('📍 예상:', link.selected_text); console.log('📍 실제:', actualText); // 텍스트 검색으로 대체 시도 const searchIndex = textContent.indexOf(link.selected_text); if (searchIndex !== -1) { console.log('✅ 텍스트 검색으로 위치 찾음:', searchIndex); link.start_offset = searchIndex; link.end_offset = searchIndex + link.selected_text.length; } else { console.error('❌ 링크 텍스트를 찾을 수 없음:', link.selected_text); 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) { console.log('✅ 링크 적용:', linkStart, '-', linkEnd, '텍스트:', node.textContent.substring(linkStart, linkEnd)); this.applyLinkToNode(node, linkStart, linkEnd, link); break; } } currentOffset = nodeEnd; } } /** * 텍스트 노드에 링크 적용 */ applyLinkToNode(textNode, start, end, link) { console.log('🎨 applyLinkToNode 시작:', start, end, link.id); const text = textNode.textContent; const beforeText = text.substring(0, start); const linkText = text.substring(start, end); const afterText = text.substring(end); console.log('📝 링크 텍스트:', linkText); // 링크 스팬 생성 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(); // 겹치는 하이라이트나 백링크가 있는지 확인 const overlappingElements = this.findOverlappingElements(span); if (overlappingElements.length > 0) { this.showOverlapMenu(e, span, overlappingElements, 'link'); } else { 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); console.log('✅ 링크 DOM 교체 완료:', linkText); } /** * 백링크 렌더링 (링크와 동일한 방식) */ renderBacklinks() { const documentContent = document.getElementById('document-content'); if (!documentContent) { console.error('❌ document-content 요소를 찾을 수 없습니다'); 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, '개'); console.log('🔗 백링크 데이터 상세:', this.backlinks); // 기존 백링크 확인 (제거하지 않고 중복 체크만) const existingBacklinks = documentContent.querySelectorAll('.backlink-highlight'); console.log(`🔍 기존 백링크 ${existingBacklinks.length}개 발견`); // 백링크 데이터가 없으면 여기서 종료 if (this.backlinks.length === 0) { console.log('📝 백링크 데이터가 없습니다.'); return; } // 각 백링크 렌더링 (중복되지 않는 것만) 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) { console.log('🔗 renderSingleBacklink 시작:', backlink.id, backlink.target_text); const content = document.getElementById('document-content'); if (!content) { console.error('❌ document-content 요소를 찾을 수 없습니다'); return; } // 원본 문서 내용 사용 (오프셋 정확성을 위해) const textContent = content.textContent || content.innerText || ''; console.log('📝 문서 텍스트 길이:', textContent.length); // target_start_offset과 target_end_offset이 있으면 직접 사용 let textIndex, searchText, searchLength; if (backlink.target_start_offset !== undefined && backlink.target_end_offset !== undefined) { // 오프셋 정보가 있으면 직접 사용 (더 정확함) textIndex = backlink.target_start_offset; searchLength = backlink.target_end_offset - backlink.target_start_offset; searchText = textContent.substring(textIndex, textIndex + searchLength); console.log('✅ 오프셋으로 백링크 텍스트 찾음:', searchText, '위치:', textIndex); } else { // 오프셋 정보가 없으면 텍스트 검색 사용 searchText = backlink.target_text || backlink.selected_text; if (!searchText) { console.warn('❌ 백링크 텍스트 정보가 없음:', backlink); return; } // 텍스트 검색 (대소문자 무시, 공백 정규화) const normalizedContent = textContent.replace(/\s+/g, ' ').trim(); const normalizedSearchText = searchText.replace(/\s+/g, ' ').trim(); 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)); console.log('📝 전체 텍스트 길이:', normalizedContent.length); return; } searchLength = searchText.length; console.log('✅ 텍스트 검색으로 백링크 텍스트 찾음:', searchText, '위치:', textIndex); } 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 + searchLength) { const backlinkStart = Math.max(0, textIndex - nodeStart); const backlinkEnd = Math.min(nodeLength, textIndex + searchLength - nodeStart); if (backlinkStart < backlinkEnd) { console.log('✅ 백링크 적용:', backlinkStart, '-', backlinkEnd, '텍스트:', node.textContent.substring(backlinkStart, backlinkEnd)); this.applyBacklinkToNode(node, backlinkStart, backlinkEnd, backlink); break; } } currentOffset = nodeEnd; } } /** * 텍스트 노드에 백링크 적용 */ applyBacklinkToNode(textNode, start, end, backlink) { console.log('🎨 applyBacklinkToNode 시작:', start, end, backlink.id); const text = textNode.textContent; const beforeText = text.substring(0, start); const backlinkText = text.substring(start, end); const afterText = text.substring(end); console.log('📝 백링크 텍스트:', backlinkText); // 백링크 스팬 생성 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(); // 겹치는 하이라이트나 링크가 있는지 확인 const overlappingElements = this.findOverlappingElements(span); if (overlappingElements.length > 0) { this.showOverlapMenu(e, span, overlappingElements, 'backlink'); } else { 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); console.log('✅ 백링크 DOM 교체 완료:', backlinkText); } /** * 링크 툴팁 표시 */ 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-5 z-50 max-w-2xl'; tooltip.style.minWidth = '450px'; tooltip.style.maxHeight = '80vh'; tooltip.style.overflowY = 'auto'; // 생성 날짜 포맷팅 const createdDate = link.created_at ? this.formatDate(link.created_at) : '알 수 없음'; const tooltipHTML = `
링크 정보
${createdDate}
선택된 텍스트
"${link.selected_text}"
연결된 문서
${link.target_document_title}
${link.target_text ? `
대상 텍스트
"${link.target_text}"
` : ''} ${link.description ? `
설명
${link.description}
` : ''}
`; 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-5 z-50 max-w-2xl'; tooltip.style.minWidth = '450px'; tooltip.style.maxHeight = '80vh'; tooltip.style.overflowY = 'auto'; // 생성 날짜 포맷팅 const createdDate = backlink.created_at ? this.formatDate(backlink.created_at) : '알 수 없음'; const tooltipHTML = `
백링크 정보
${createdDate}
현재 문서의 텍스트
"${backlink.target_text || backlink.selected_text}"
참조하는 문서
${backlink.source_document_title}
원본 텍스트
"${backlink.selected_text}"
${backlink.description ? `
설명
${backlink.description}
` : ''}
`; tooltip.innerHTML = tooltipHTML; this.positionTooltip(tooltip, element); } /** * 날짜 포맷팅 */ formatDate(dateString) { if (!dateString) return '알 수 없음'; const date = new Date(dateString); const now = new Date(); const diffTime = Math.abs(now - date); const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); if (diffDays === 1) { return '오늘'; } else if (diffDays <= 7) { return `${diffDays}일 전`; } else if (diffDays <= 30) { return `${Math.ceil(diffDays / 7)}주 전`; } else { return date.toLocaleDateString('ko-KR', { year: 'numeric', month: 'short', day: 'numeric' }); } } /** * 툴팁 위치 설정 */ 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(); } const overlapMenu = document.getElementById('overlap-menu'); if (overlapMenu) { overlapMenu.remove(); } document.removeEventListener('click', this.handleTooltipOutsideClick.bind(this)); } /** * 툴팁 외부 클릭 처리 */ handleTooltipOutsideClick(e) { const linkTooltip = document.getElementById('link-tooltip'); const backlinkTooltip = document.getElementById('backlink-tooltip'); const overlapMenu = document.getElementById('overlap-menu'); const isOutsideLinkTooltip = linkTooltip && !linkTooltip.contains(e.target); const isOutsideBacklinkTooltip = backlinkTooltip && !backlinkTooltip.contains(e.target); const isOutsideOverlapMenu = overlapMenu && !overlapMenu.contains(e.target); if (isOutsideLinkTooltip || isOutsideBacklinkTooltip || isOutsideOverlapMenu) { this.hideTooltip(); } } /** * 링크된 문서로 이동 */ navigateToLinkedDocument(targetDocumentId, linkInfo) { console.log('🔗 navigateToLinkedDocument 호출됨'); console.log('📋 전달받은 파라미터:', { targetDocumentId: targetDocumentId, linkInfo: linkInfo }); if (!targetDocumentId) { console.error('❌ targetDocumentId가 없습니다!'); alert('대상 문서 ID가 없습니다.'); return; } // contentType에 따라 적절한 URL 생성 let targetUrl; if (linkInfo.target_content_type === 'note') { // 노트 문서로 이동 targetUrl = `/viewer.html?id=${targetDocumentId}&contentType=note`; console.log('📝 노트 문서로 이동:', targetDocumentId); } else { // 일반 문서로 이동 targetUrl = `/viewer.html?id=${targetDocumentId}`; console.log('📄 일반 문서로 이동:', 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}`; console.log('🎯 텍스트 하이라이트 추가:', linkInfo.target_text); } console.log('🚀 최종 이동할 URL:', targetUrl); window.location.href = targetUrl; } /** * 원본 문서로 이동 (백링크) */ navigateToSourceDocument(sourceDocumentId, backlinkInfo) { console.log('🔙 navigateToSourceDocument 호출됨'); console.log('📋 전달받은 파라미터:', { sourceDocumentId: sourceDocumentId, backlinkInfo: backlinkInfo }); if (!sourceDocumentId) { console.error('❌ sourceDocumentId가 없습니다!'); alert('소스 문서 ID가 없습니다.'); return; } // source_content_type에 따라 적절한 URL 생성 let targetUrl; if (backlinkInfo.source_content_type === 'note') { // 노트 문서로 이동 targetUrl = `/viewer.html?id=${sourceDocumentId}&contentType=note`; console.log('📝 노트 문서로 이동 (백링크):', sourceDocumentId); } else { // 일반 문서로 이동 targetUrl = `/viewer.html?id=${sourceDocumentId}`; console.log('📄 일반 문서로 이동 (백링크):', 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}`; console.log('🎯 텍스트 하이라이트 추가 (백링크):', backlinkInfo.selected_text); } console.log('🚀 최종 이동할 URL (백링크):', targetUrl); 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); } } /** * 겹치는 요소 찾기 (하이라이트, 링크, 백링크) */ findOverlappingElements(element) { const overlapping = []; const rect = element.getBoundingClientRect(); // 같은 위치에 있는 모든 요소 찾기 const elementsAtPoint = document.elementsFromPoint( rect.left + rect.width / 2, rect.top + rect.height / 2 ); elementsAtPoint.forEach(el => { if (el !== element) { // 하이라이트 요소 if (el.classList.contains('highlight')) { overlapping.push({ type: 'highlight', element: el }); } // 링크 요소 if (el.classList.contains('document-link')) { overlapping.push({ type: 'link', element: el }); } // 백링크 요소 if (el.classList.contains('backlink-highlight')) { overlapping.push({ type: 'backlink', element: el }); } } }); return overlapping; } /** * 겹침 메뉴 표시 */ showOverlapMenu(event, clickedElement, overlappingElements, clickedType) { this.hideTooltip(); const menu = document.createElement('div'); menu.id = 'overlap-menu'; menu.className = 'absolute bg-white border border-gray-300 rounded-lg shadow-lg p-4 z-50'; menu.style.minWidth = '320px'; menu.style.maxWidth = '400px'; let menuHTML = `
겹치는 요소들
`; // 클릭된 요소 추가 menuHTML += this.getMenuItemHTML(clickedType, clickedElement, true); // 겹치는 요소들 추가 overlappingElements.forEach(item => { menuHTML += this.getMenuItemHTML(item.type, item.element, false); }); menuHTML += `
`; menu.innerHTML = menuHTML; // 메뉴 위치 설정 const rect = clickedElement.getBoundingClientRect(); const scrollTop = window.pageYOffset || document.documentElement.scrollTop; const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft; document.body.appendChild(menu); let top = rect.bottom + scrollTop + 5; let left = rect.left + scrollLeft; // 화면 경계 체크 const menuRect = menu.getBoundingClientRect(); if (left + menuRect.width > window.innerWidth) { left = window.innerWidth - menuRect.width - 10; } if (top + menuRect.height > window.innerHeight + scrollTop) { top = rect.top + scrollTop - menuRect.height - 5; } menu.style.top = top + 'px'; menu.style.left = left + 'px'; // 외부 클릭 시 닫기 setTimeout(() => { document.addEventListener('click', this.handleTooltipOutsideClick.bind(this)); }, 100); } /** * 메뉴 아이템 HTML 생성 */ getMenuItemHTML(type, element, isClicked) { const icons = { highlight: '🖍️', link: '🔗', backlink: '🔙' }; const labels = { highlight: '하이라이트', link: '링크', backlink: '백링크' }; const colors = { highlight: 'bg-yellow-50 border-yellow-200 text-yellow-800', link: 'bg-purple-50 border-purple-200 text-purple-800', backlink: 'bg-orange-50 border-orange-200 text-orange-800' }; const clickedClass = isClicked ? 'ring-2 ring-blue-300' : ''; const elementId = element.dataset.highlightId || element.dataset.linkId || element.dataset.backlinkId || ''; // 각 타입별 액션 버튼들 let actionButtons = ''; if (type === 'highlight') { actionButtons = `
`; } else if (type === 'link') { actionButtons = `
`; } else if (type === 'backlink') { actionButtons = `
`; } return `
${icons[type]}
${labels[type]}
${element.textContent.substring(0, 40)}${element.textContent.length > 40 ? '...' : ''}
${isClicked ? '클릭됨' : ''}
${actionButtons}
`; } /** * 메뉴에서 바로 링크된 문서로 이동 */ navigateToLinkedDocumentFromMenu(elementId) { this.hideTooltip(); const link = this.documentLinks.find(l => l.id === elementId); if (link) { console.log('🔗 메뉴에서 링크 클릭:', link); // target_content_type이 없으면 ID로 추론 let targetContentType = link.target_content_type; if (!targetContentType) { if (link.target_note_id) { targetContentType = 'note'; } else if (link.target_document_id) { targetContentType = 'document'; } console.log('🔍 메뉴에서 target_content_type 추론됨:', targetContentType); } const targetId = link.target_document_id || link.target_note_id; if (!targetId) { console.error('❌ 메뉴에서 대상 ID가 없습니다!', link); alert('링크 대상을 찾을 수 없습니다.'); return; } // 링크 객체에 추론된 타입 추가 const linkWithType = { ...link, target_content_type: targetContentType }; console.log('🚀 메뉴에서 최종 링크 데이터:', linkWithType); this.navigateToLinkedDocument(targetId, linkWithType); } else { console.warn('링크 데이터를 찾을 수 없음:', elementId); } } /** * 메뉴에서 바로 소스 문서로 이동 */ navigateToSourceDocumentFromMenu(elementId) { this.hideTooltip(); const backlink = this.backlinks.find(b => b.id === elementId); if (backlink) { this.navigateToSourceDocument(backlink.source_document_id, backlink); } else { console.warn('백링크 데이터를 찾을 수 없음:', elementId); } } /** * 겹침 메뉴 클릭 처리 */ handleOverlapMenuClick(type, elementId) { this.hideTooltip(); // 해당 요소 찾기 let element; if (type === 'highlight') { element = document.querySelector(`[data-highlight-id="${elementId}"]`); if (element && window.documentViewerInstance.highlightManager) { // 하이라이트 클릭 처리 (HighlightManager에 위임) const highlightId = elementId; // 해당 하이라이트 찾기 const highlight = window.documentViewerInstance.highlightManager.highlights.find(h => h.id === highlightId); if (highlight) { // 하이라이트 툴팁 표시 (메모 작성/편집 기능 포함) window.documentViewerInstance.highlightManager.showHighlightTooltip(highlight, element); } else { console.warn('하이라이트를 찾을 수 없음:', highlightId); } } } else if (type === 'link') { element = document.querySelector(`[data-link-id="${elementId}"]`); if (element) { // 링크 데이터 찾기 const link = this.documentLinks.find(l => l.id === elementId); if (link) { this.showLinkTooltip(link, element); } } } else if (type === 'backlink') { element = document.querySelector(`[data-backlink-id="${elementId}"]`); if (element) { // 백링크 데이터 찾기 const backlink = this.backlinks.find(b => b.id === elementId); if (backlink) { this.showBacklinkTooltip(backlink, element); } } } } /** * 텍스트 오프셋 계산 */ 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;