Files
document-server/frontend/static/js/viewer/features/link-manager.js
Hyungi Ahn 5d4465b15c 하이라이트 색상 문제 해결 및 다중 하이라이트 렌더링 개선
주요 수정사항:
- 하이라이트 생성 시 color → highlight_color 필드명 수정으로 색상 전달 문제 해결
- 분홍색을 더 연하게 변경하여 글씨 가독성 향상
- 다중 하이라이트 렌더링을 위아래 균등 분할로 개선
- CSS highlight-span 클래스 추가 및 색상 적용 강화
- 하이라이트 생성/렌더링 과정에 상세한 디버깅 로그 추가

UI 개선:
- 단일 하이라이트: 선택한 색상으로 정확히 표시
- 다중 하이라이트: 위아래로 균등하게 색상 분할 표시
- 메모 입력 모달에서 선택된 텍스트 표시 개선

버그 수정:
- 프론트엔드-백엔드 API 스키마 불일치 해결
- CSS 스타일 우선순위 문제 해결
- 하이라이트 색상이 노랑색으로만 표시되던 문제 해결
2025-08-28 07:13:00 +09:00

592 lines
22 KiB
JavaScript

/**
* LinkManager 모듈
* 문서 링크 및 백링크 통합 관리
*/
class LinkManager {
constructor(api) {
console.log('🔗 LinkManager 초기화 시작');
this.api = api;
// 캐싱된 API 사용 (사용 가능한 경우)
this.cachedApi = window.cachedApi || api;
this.documentLinks = [];
this.backlinks = [];
this.selectedText = '';
this.selectedRange = null;
this.availableBooks = [];
this.filteredDocuments = [];
console.log('✅ LinkManager 초기화 완료');
}
/**
* 문서 링크 데이터 로드
*/
async loadDocumentLinks(documentId) {
try {
this.documentLinks = await this.cachedApi.get('/document-links', { document_id: documentId }, { category: 'links' }).catch(() => []);
return this.documentLinks || [];
} catch (error) {
console.error('문서 링크 로드 실패:', error);
return [];
}
}
/**
* 백링크 데이터 로드
*/
async loadBacklinks(documentId) {
try {
this.backlinks = await this.cachedApi.get('/document-links/backlinks', { target_document_id: documentId }, { category: 'links' }).catch(() => []);
return this.backlinks || [];
} catch (error) {
console.error('백링크 로드 실패:', error);
return [];
}
}
/**
* 문서 링크 렌더링
*/
renderDocumentLinks() {
const documentContent = document.getElementById('document-content');
if (!documentContent) return;
console.log('🔗 링크 렌더링 시작 - 총', this.documentLinks.length, '개');
// 기존 링크 제거
const existingLinks = documentContent.querySelectorAll('.document-link');
existingLinks.forEach(el => {
const parent = el.parentNode;
parent.replaceChild(document.createTextNode(el.textContent), el);
parent.normalize();
});
// 각 링크 렌더링
this.documentLinks.forEach(link => {
this.renderSingleLink(link);
});
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;
console.log('🔗 백링크 렌더링 시작 - 총', this.backlinks.length, '개');
// 기존 백링크는 제거하지 않고 중복 체크만 함
const existingBacklinks = documentContent.querySelectorAll('.backlink-highlight');
console.log(`🔍 기존 백링크 ${existingBacklinks.length}개 발견 (유지)`);
// 각 백링크 렌더링 (중복되지 않는 것만)
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}`);
}
});
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 = `
<div class="mb-4">
<div class="text-sm text-gray-600 mb-2">링크 정보</div>
<div class="font-medium text-purple-900 bg-purple-50 px-3 py-2 rounded border-l-4 border-purple-500">
"${link.selected_text}"
</div>
</div>
<div class="mb-4">
<div class="text-sm text-gray-600 mb-2">연결된 문서</div>
<div class="bg-gray-50 p-3 rounded">
<div class="font-medium text-gray-900">${link.target_document_title}</div>
${link.target_text ? `<div class="text-sm text-gray-600 mt-1">대상 텍스트: "${link.target_text}"</div>` : ''}
</div>
</div>
<div class="flex justify-between items-center">
<button onclick="window.documentViewerInstance.linkManager.navigateToLinkedDocument('${link.target_document_id}', ${JSON.stringify(link).replace(/"/g, '&quot;')})"
class="text-sm bg-purple-500 text-white px-3 py-2 rounded hover:bg-purple-600">
문서로 이동
</button>
<button onclick="window.documentViewerInstance.linkManager.deleteLink('${link.id}')"
class="text-sm bg-red-500 text-white px-3 py-2 rounded hover:bg-red-600">
링크 삭제
</button>
</div>
<div class="flex justify-end mt-3">
<button onclick="window.documentViewerInstance.linkManager.hideTooltip()"
class="text-xs bg-gray-500 text-white px-3 py-1 rounded hover:bg-gray-600">
닫기
</button>
</div>
`;
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 = `
<div class="mb-4">
<div class="text-sm text-gray-600 mb-2">백링크 정보</div>
<div class="font-medium text-orange-900 bg-orange-50 px-3 py-2 rounded border-l-4 border-orange-500">
"${backlink.target_text || backlink.selected_text}"
</div>
</div>
<div class="mb-4">
<div class="text-sm text-gray-600 mb-2">참조 문서</div>
<div class="bg-gray-50 p-3 rounded">
<div class="font-medium text-gray-900">${backlink.source_document_title}</div>
<div class="text-sm text-gray-600 mt-1">원본 텍스트: "${backlink.selected_text}"</div>
</div>
</div>
<div class="flex justify-between items-center">
<button onclick="window.documentViewerInstance.linkManager.navigateToSourceDocument('${backlink.source_document_id}', ${JSON.stringify(backlink).replace(/"/g, '&quot;')})"
class="text-sm bg-orange-500 text-white px-3 py-2 rounded hover:bg-orange-600">
원본 문서로 이동
</button>
</div>
<div class="flex justify-end mt-3">
<button onclick="window.documentViewerInstance.linkManager.hideTooltip()"
class="text-xs bg-gray-500 text-white px-3 py-1 rounded hover:bg-gray-600">
닫기
</button>
</div>
`;
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, selectedText, selectedRange) {
if (!selectedText || !selectedRange) return;
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;
// 텍스트 오프셋 계산
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;