feat: 링크와 백링크 기능 개선 및 겹침 처리 구현
✨ 주요 기능 추가: - 링크 생성 후 즉시 렌더링 (캐시 무효화 수정) - 하이라이트, 링크, 백링크 겹침 시 선택 팝업 메뉴 - 백링크 오프셋 기반 정확한 렌더링 - 링크 렌더링 디버깅 로직 강화 🔧 기술적 개선: - 캐시 무효화 함수 수정 (invalidateRelatedCache 사용) - 텍스트 오프셋 불일치 시 자동 검색 대체 - 겹치는 요소 감지 및 상호작용 처리 - 상세한 디버깅 로그 추가 🎨 UI/UX 개선: - 겹침 메뉴에서 각 요소별 아이콘과 색상 구분 - 클릭된 요소 시각적 표시 - 툴팁과 메뉴 외부 클릭 처리 🐛 버그 수정: - 신규 링크 생성 후 표시되지 않는 문제 해결 - 백링크 렌더링 오프셋 정확성 개선 - 캐시 관련 JavaScript 오류 수정
This commit is contained in:
@@ -127,55 +127,6 @@
|
||||
<p class="text-gray-500">이 서적에 등록된 문서가 없습니다</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PDF 매칭 섹션 -->
|
||||
<div x-show="availablePDFs.length > 0" class="bg-white rounded-lg shadow-sm border">
|
||||
<div class="p-6 border-b border-gray-200">
|
||||
<h2 class="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<i class="fas fa-file-pdf mr-2 text-red-600"></i>
|
||||
사용 가능한 PDF 문서
|
||||
<span class="ml-2 px-2 py-1 bg-red-100 text-red-800 text-xs rounded-full" x-text="availablePDFs.length"></span>
|
||||
</h2>
|
||||
<p class="text-gray-600 text-sm mt-1">이 서적과 연결할 수 있는 PDF 문서들입니다</p>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<div class="grid gap-4">
|
||||
<template x-for="pdf in availablePDFs" :key="pdf.id">
|
||||
<div class="border border-gray-200 rounded-lg p-4 hover:bg-gray-50 transition-colors">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-md font-medium text-gray-900 mb-1" x-text="pdf.title"></h3>
|
||||
<p class="text-gray-600 text-sm mb-2" x-text="pdf.description || '설명이 없습니다'"></p>
|
||||
<div class="flex items-center text-sm text-gray-500 space-x-4">
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-file-pdf mr-1 text-red-500"></i>
|
||||
<span x-text="pdf.original_filename"></span>
|
||||
</span>
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-calendar mr-1"></i>
|
||||
<span x-text="formatDate(pdf.created_at)"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2 ml-4">
|
||||
<button @click="matchPDFToBook(pdf.id)"
|
||||
class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors text-sm">
|
||||
<i class="fas fa-link mr-1"></i>서적에 연결
|
||||
</button>
|
||||
<button @click="openPDF(pdf)"
|
||||
class="p-2 text-gray-400 hover:text-blue-600 transition-colors"
|
||||
title="PDF 열기">
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- JavaScript 파일들 -->
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 = `
|
||||
<div class="text-sm font-medium text-gray-700 mb-2">여러 요소가 겹쳐있습니다</div>
|
||||
<div class="space-y-1">
|
||||
`;
|
||||
|
||||
// 클릭된 요소 추가
|
||||
menuHTML += this.getMenuItemHTML(clickedType, clickedElement, true);
|
||||
|
||||
// 겹치는 요소들 추가
|
||||
overlappingElements.forEach(item => {
|
||||
menuHTML += this.getMenuItemHTML(item.type, item.element, false);
|
||||
});
|
||||
|
||||
menuHTML += `
|
||||
</div>
|
||||
<div class="mt-2 pt-2 border-t border-gray-200">
|
||||
<button onclick="window.documentViewerInstance.linkManager.hideTooltip()"
|
||||
class="text-xs bg-gray-500 text-white px-2 py-1 rounded hover:bg-gray-600 w-full">
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 `
|
||||
<button onclick="window.documentViewerInstance.linkManager.handleOverlapMenuClick('${type}', '${elementId}')"
|
||||
class="w-full text-left px-3 py-2 rounded border ${colors[type]} ${clickedClass} hover:opacity-80 transition-opacity">
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-lg">${icons[type]}</span>
|
||||
<div class="flex-1">
|
||||
<div class="font-medium">${labels[type]}</div>
|
||||
<div class="text-xs opacity-75">${element.textContent.substring(0, 30)}${element.textContent.length > 30 ? '...' : ''}</div>
|
||||
</div>
|
||||
${isClicked ? '<span class="text-xs bg-blue-500 text-white px-1 rounded">클릭됨</span>' : ''}
|
||||
</div>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 겹침 메뉴 클릭 처리
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 텍스트 오프셋 계산
|
||||
*/
|
||||
|
||||
@@ -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('에러 상세:', {
|
||||
|
||||
Reference in New Issue
Block a user