From 397c63979dab7007bd601e594d008f4a58092cd2 Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Tue, 2 Sep 2025 13:42:01 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=A7=81=ED=81=AC=EC=99=80=20=EB=B0=B1?= =?UTF-8?q?=EB=A7=81=ED=81=AC=20=EA=B8=B0=EB=8A=A5=20=EA=B0=9C=EC=84=A0=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B2=B9=EC=B9=A8=20=EC=B2=98=EB=A6=AC=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ✨ 주요 기능 추가: - 링크 생성 후 즉시 렌더링 (캐시 무효화 수정) - 하이라이트, 링크, 백링크 겹침 시 선택 팝업 메뉴 - 백링크 오프셋 기반 정확한 렌더링 - 링크 렌더링 디버깅 로직 강화 🔧 기술적 개선: - 캐시 무효화 함수 수정 (invalidateRelatedCache 사용) - 텍스트 오프셋 불일치 시 자동 검색 대체 - 겹치는 요소 감지 및 상호작용 처리 - 상세한 디버깅 로그 추가 🎨 UI/UX 개선: - 겹침 메뉴에서 각 요소별 아이콘과 색상 구분 - 클릭된 요소 시각적 표시 - 툴팁과 메뉴 외부 클릭 처리 🐛 버그 수정: - 신규 링크 생성 후 표시되지 않는 문제 해결 - 백링크 렌더링 오프셋 정확성 개선 - 캐시 관련 JavaScript 오류 수정 --- frontend/book-documents.html | 49 --- .../js/viewer/features/highlight-manager.js | 12 +- .../static/js/viewer/features/link-manager.js | 377 +++++++++++++++--- frontend/static/js/viewer/viewer-core.js | 21 +- 4 files changed, 351 insertions(+), 108 deletions(-) diff --git a/frontend/book-documents.html b/frontend/book-documents.html index 5ff98ac..a2e10ce 100644 --- a/frontend/book-documents.html +++ b/frontend/book-documents.html @@ -127,55 +127,6 @@

이 서적에 등록된 문서가 없습니다

- - -
-
-

- - 사용 가능한 PDF 문서 - -

-

이 서적과 연결할 수 있는 PDF 문서들입니다

-
- -
-
- -
-
-
diff --git a/frontend/static/js/viewer/features/highlight-manager.js b/frontend/static/js/viewer/features/highlight-manager.js index 7378406..a223beb 100644 --- a/frontend/static/js/viewer/features/highlight-manager.js +++ b/frontend/static/js/viewer/features/highlight-manager.js @@ -235,11 +235,21 @@ class HighlightManager { span.style.cursor = 'help'; } - // 하이라이트 클릭 이벤트 추가 + // 하이라이트 클릭 이벤트 추가 (겹치는 요소 감지) span.style.cursor = 'pointer'; span.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); + + // 겹치는 링크나 백링크가 있는지 확인 + if (window.documentViewerInstance && window.documentViewerInstance.linkManager) { + const overlappingElements = window.documentViewerInstance.linkManager.findOverlappingElements(span); + if (overlappingElements.length > 0) { + window.documentViewerInstance.linkManager.showOverlapMenu(e, span, overlappingElements, 'highlight'); + return; + } + } + this.showHighlightModal(highlights); }); diff --git a/frontend/static/js/viewer/features/link-manager.js b/frontend/static/js/viewer/features/link-manager.js index c6d3622..b6c2036 100644 --- a/frontend/static/js/viewer/features/link-manager.js +++ b/frontend/static/js/viewer/features/link-manager.js @@ -108,7 +108,10 @@ class LinkManager { */ renderDocumentLinks() { const documentContent = document.getElementById('document-content'); - if (!documentContent) return; + if (!documentContent) { + console.error('❌ document-content 요소를 찾을 수 없습니다'); + return; + } // 안전한 링크 초기화 if (!Array.isArray(this.documentLinks)) { @@ -118,20 +121,22 @@ class LinkManager { } console.log('🔗 링크 렌더링 시작 - 총', this.documentLinks.length, '개'); + console.log('🔗 링크 데이터 상세:', this.documentLinks); - // 링크 데이터가 없으면 렌더링하지 않음 (기존 링크 유지) - if (this.documentLinks.length === 0) { - console.log('📝 링크 데이터가 없어서 기존 링크를 유지합니다.'); - return; - } - - // 기존 링크 제거 + // 기존 링크 제거 (항상 실행) 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)) { @@ -149,13 +154,39 @@ class LinkManager { * 개별 링크 렌더링 */ 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.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, @@ -178,6 +209,7 @@ class LinkManager { 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; } @@ -191,11 +223,14 @@ class LinkManager { * 텍스트 노드에 링크 적용 */ 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'; @@ -217,11 +252,18 @@ class LinkManager { box-sizing: border-box !important; `; - // 클릭 이벤트 추가 + // 클릭 이벤트 추가 (겹치는 요소 감지) span.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); - this.showLinkTooltip(link, span); + + // 겹치는 하이라이트나 백링크가 있는지 확인 + const overlappingElements = this.findOverlappingElements(span); + if (overlappingElements.length > 0) { + this.showOverlapMenu(e, span, overlappingElements, 'link'); + } else { + this.showLinkTooltip(link, span); + } }); // DOM 교체 @@ -233,6 +275,7 @@ class LinkManager { if (afterText) fragment.appendChild(document.createTextNode(afterText)); parent.replaceChild(fragment, textNode); + console.log('✅ 링크 DOM 교체 완료:', linkText); } /** @@ -240,7 +283,10 @@ class LinkManager { */ renderBacklinks() { const documentContent = document.getElementById('document-content'); - if (!documentContent) return; + if (!documentContent) { + console.error('❌ document-content 요소를 찾을 수 없습니다'); + return; + } // 안전한 백링크 초기화 if (!Array.isArray(this.backlinks)) { @@ -250,17 +296,18 @@ class LinkManager { } 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('📝 백링크 데이터가 없어서 기존 백링크를 유지합니다.'); + console.log('📝 백링크 데이터가 없습니다.'); return; } - // 기존 백링크는 제거하지 않고 중복 체크만 함 - const existingBacklinks = documentContent.querySelectorAll('.backlink-highlight'); - console.log(`🔍 기존 백링크 ${existingBacklinks.length}개 발견 (유지)`); - // 각 백링크 렌더링 (중복되지 않는 것만) if (Array.isArray(this.backlinks)) { this.backlinks.forEach(backlink => { @@ -294,44 +341,57 @@ class LinkManager { return; } - // 실제 문서 내용만 추출 (CSS, 스크립트 제외) - const contentClone = content.cloneNode(true); - // 스타일 태그와 스크립트 태그 제거 - const styleTags = contentClone.querySelectorAll('style, script'); - styleTags.forEach(tag => tag.remove()); + // 원본 문서 내용 사용 (오프셋 정확성을 위해) + const textContent = content.textContent || content.innerText || ''; + console.log('📝 문서 텍스트 길이:', textContent.length); - const textContent = contentClone.textContent || contentClone.innerText || ''; + // target_start_offset과 target_end_offset이 있으면 직접 사용 + let textIndex, searchText, searchLength; - // 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 (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)); - return; + + 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( @@ -350,11 +410,12 @@ class LinkManager { const nodeEnd = currentOffset + nodeLength; // 백링크 범위와 겹치는지 확인 - if (nodeEnd > textIndex && nodeStart < textIndex + searchText.length) { + if (nodeEnd > textIndex && nodeStart < textIndex + searchLength) { const backlinkStart = Math.max(0, textIndex - nodeStart); - const backlinkEnd = Math.min(nodeLength, textIndex + searchText.length - 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; } @@ -368,11 +429,14 @@ class LinkManager { * 텍스트 노드에 백링크 적용 */ 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'; @@ -396,11 +460,18 @@ class LinkManager { box-sizing: border-box !important; `; - // 클릭 이벤트 추가 + // 클릭 이벤트 추가 (겹치는 요소 감지) span.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); - this.showBacklinkTooltip(backlink, span); + + // 겹치는 하이라이트나 링크가 있는지 확인 + const overlappingElements = this.findOverlappingElements(span); + if (overlappingElements.length > 0) { + this.showOverlapMenu(e, span, overlappingElements, 'backlink'); + } else { + this.showBacklinkTooltip(backlink, span); + } }); // DOM 교체 @@ -412,6 +483,7 @@ class LinkManager { if (afterText) fragment.appendChild(document.createTextNode(afterText)); parent.replaceChild(fragment, textNode); + console.log('✅ 백링크 DOM 교체 완료:', backlinkText); } /** @@ -556,6 +628,11 @@ class LinkManager { backlinkTooltip.remove(); } + const overlapMenu = document.getElementById('overlap-menu'); + if (overlapMenu) { + overlapMenu.remove(); + } + document.removeEventListener('click', this.handleTooltipOutsideClick.bind(this)); } @@ -565,11 +642,13 @@ class LinkManager { 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) { + if (isOutsideLinkTooltip || isOutsideBacklinkTooltip || isOutsideOverlapMenu) { this.hideTooltip(); } } @@ -683,6 +762,190 @@ class LinkManager { + /** + * 겹치는 요소 찾기 (하이라이트, 링크, 백링크) + */ + 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-2 z-50'; + menu.style.minWidth = '200px'; + + 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 || ''; + + return ` + + `; + } + + /** + * 겹침 메뉴 클릭 처리 + */ + 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 highlightGroups = window.documentViewerInstance.highlightManager.highlightGroups || []; + const targetGroup = highlightGroups.find(group => + group.some(h => h.id === highlightId) + ); + + if (targetGroup) { + window.documentViewerInstance.highlightManager.showHighlightModal(targetGroup); + } 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); + } + } + } + } + /** * 텍스트 오프셋 계산 */ diff --git a/frontend/static/js/viewer/viewer-core.js b/frontend/static/js/viewer/viewer-core.js index 78e2a5a..9af4386 100644 --- a/frontend/static/js/viewer/viewer-core.js +++ b/frontend/static/js/viewer/viewer-core.js @@ -1154,6 +1154,13 @@ window.documentViewer = () => ({ // 모달 닫기 this.showLinkModal = false; + // 캐시 무효화 (새 링크가 반영되도록) + console.log('🗑️ 링크 캐시 무효화 시작...'); + if (window.cachedApi && window.cachedApi.invalidateRelatedCache) { + window.cachedApi.invalidateRelatedCache(`/documents/${this.documentId}/links`, ['links']); + console.log('✅ 링크 캐시 무효화 완료'); + } + // 링크 목록 새로고침 console.log('🔄 링크 목록 새로고침 시작...'); await this.linkManager.loadDocumentLinks(this.documentId); @@ -1163,9 +1170,21 @@ window.documentViewer = () => ({ // 링크 렌더링 console.log('🎨 링크 렌더링 시작...'); - await this.linkManager.renderDocumentLinks(); + this.linkManager.renderDocumentLinks(); console.log('✅ 링크 렌더링 완료'); + // 백링크도 다시 로드하고 렌더링 (새 링크가 다른 문서의 백링크가 될 수 있음) + console.log('🔄 백링크 새로고침 시작...'); + // 백링크 캐시도 무효화 + if (window.cachedApi && window.cachedApi.invalidateRelatedCache) { + window.cachedApi.invalidateRelatedCache(`/documents/${this.documentId}/backlinks`, ['links']); + console.log('✅ 백링크 캐시도 무효화 완료'); + } + await this.linkManager.loadBacklinks(this.documentId); + this.backlinks = this.linkManager.backlinks || []; + this.linkManager.renderBacklinks(); + console.log('✅ 백링크 새로고침 완료'); + } catch (error) { console.error('링크 생성 실패:', error); console.error('에러 상세:', {