✨ 주요 기능 - 노트 ↔ 서적 문서 간 양방향 링크 생성 및 이동 - 링크 대상 타입 선택 UI (서적 문서/노트북 노트) - 통합 백링크 시스템 (일반 문서에서 노트 백링크도 표시) - 링크 목록 UI 개선 (상세 정보 표시, 타입 구분) 🔧 백엔드 개선 - NoteLink 모델 및 API 추가 (/note-documents/{id}/links, /note-documents/{id}/backlinks) - 일반 문서 백링크 API에서 노트 링크도 함께 조회 - target_content_type, source_content_type 필드 추가 - 노트 문서 콘텐츠 API 추가 (/note-documents/{id}/content) 🎨 프론트엔드 개선 - text-selector.html에서 노트 문서 지원 - 링크 이동 시 contentType에 따른 올바른 URL 생성 - URL 파라미터 파싱 수정 (contentType 지원) - 링크 타입 자동 추론 로직 - 링크 목록 UI 대폭 개선 (출발점/도착점 텍스트, 타입 배지 등) 🐛 버그 수정 - 서적 목록 로드 실패 문제 해결 - 노트에서 링크 생성 시 대상 문서 열기 문제 해결 - 더미 문서로 이동하는 문제 해결 - 캐시 관련 문제 해결
520 lines
23 KiB
HTML
520 lines
23 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="ko">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>텍스트 선택 모드</title>
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
|
</head>
|
|
<body class="bg-gradient-to-br from-blue-50 to-indigo-100 min-h-screen">
|
|
<!-- 헤더 -->
|
|
<header class="bg-gradient-to-r from-blue-600 to-purple-600 text-white p-4 sticky top-0 z-50">
|
|
<div class="max-w-7xl mx-auto flex items-center justify-between">
|
|
<div class="flex items-center space-x-3">
|
|
<i class="fas fa-crosshairs text-2xl"></i>
|
|
<div>
|
|
<h1 class="text-lg font-bold">텍스트 선택 모드</h1>
|
|
<p class="text-sm opacity-90">연결하고 싶은 텍스트를 선택하세요</p>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center space-x-3">
|
|
<button id="language-toggle-btn"
|
|
class="bg-white bg-opacity-20 hover:bg-opacity-30 px-3 py-2 rounded-lg transition-colors">
|
|
<i class="fas fa-language mr-1"></i>언어전환
|
|
</button>
|
|
<button onclick="window.close()"
|
|
class="bg-white bg-opacity-20 hover:bg-opacity-30 px-4 py-2 rounded-lg transition-colors">
|
|
<i class="fas fa-times mr-2"></i>취소
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- 안내 메시지 -->
|
|
<div class="max-w-4xl mx-auto p-6">
|
|
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
|
<div class="flex items-start space-x-3">
|
|
<i class="fas fa-info-circle text-blue-500 mt-0.5"></i>
|
|
<div>
|
|
<h3 class="font-semibold text-blue-800 mb-1">텍스트 선택 방법</h3>
|
|
<p class="text-sm text-blue-700">
|
|
마우스로 연결하고 싶은 텍스트를 드래그하여 선택하세요.
|
|
선택이 완료되면 자동으로 부모 창으로 전달됩니다.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 메인 콘텐츠 -->
|
|
<main class="max-w-4xl mx-auto p-6">
|
|
<div id="document-content" class="bg-white rounded-lg shadow-lg p-8 border-2 border-dashed border-blue-300 hover:border-blue-500 transition-all duration-300 cursor-crosshair select-text">
|
|
<div id="loading" class="text-center py-8">
|
|
<i class="fas fa-spinner fa-spin text-2xl text-blue-500 mb-2"></i>
|
|
<p class="text-gray-600">문서를 불러오는 중...</p>
|
|
</div>
|
|
<div id="error" class="hidden text-center py-8">
|
|
<i class="fas fa-exclamation-triangle text-2xl text-red-500 mb-2"></i>
|
|
<p class="text-red-600">문서를 불러올 수 없습니다.</p>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
|
|
<!-- 스크립트 -->
|
|
<script src="/static/js/api.js?v=2025012415"></script>
|
|
|
|
<script>
|
|
// 텍스트 선택 모드 전용 스크립트
|
|
class TextSelectorApp {
|
|
constructor() {
|
|
this.documentId = null;
|
|
this.document = null;
|
|
this.isKorean = true;
|
|
this.init();
|
|
}
|
|
|
|
async init() {
|
|
// URL에서 문서 ID 추출
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
this.documentId = urlParams.get('id');
|
|
this.contentType = urlParams.get('contentType') || 'document'; // 기본값은 document
|
|
|
|
if (!this.documentId) {
|
|
this.showError('문서 ID가 없습니다');
|
|
return;
|
|
}
|
|
|
|
console.log('🔧 초기화:', { documentId: this.documentId, contentType: this.contentType });
|
|
|
|
// 인증 확인
|
|
if (!api.token) {
|
|
window.location.href = '/';
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await this.loadDocument();
|
|
this.setupEventListeners();
|
|
} catch (error) {
|
|
console.error('문서 로드 실패:', error);
|
|
this.showError('문서를 불러올 수 없습니다: ' + error.message);
|
|
}
|
|
}
|
|
|
|
async loadDocument() {
|
|
console.log('📄 문서 로드 중:', this.documentId, 'contentType:', this.contentType);
|
|
|
|
try {
|
|
// contentType에 따라 적절한 API 호출
|
|
let docResponse, contentEndpoint;
|
|
|
|
if (this.contentType === 'note') {
|
|
// 노트 문서 메타데이터 조회
|
|
docResponse = await api.getNoteDocument(this.documentId);
|
|
contentEndpoint = `/note-documents/${this.documentId}/content`;
|
|
console.log('📝 노트 메타데이터:', docResponse);
|
|
} else {
|
|
// 일반 문서 메타데이터 조회
|
|
docResponse = await api.getDocument(this.documentId);
|
|
contentEndpoint = `/documents/${this.documentId}/content`;
|
|
console.log('📋 문서 메타데이터:', docResponse);
|
|
}
|
|
|
|
this.document = docResponse;
|
|
|
|
// 문서 HTML 콘텐츠 조회
|
|
const contentResponse = await fetch(`${api.baseURL}${contentEndpoint}`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${api.token}`,
|
|
'Content-Type': 'application/json'
|
|
}
|
|
});
|
|
|
|
if (!contentResponse.ok) {
|
|
throw new Error(`HTTP ${contentResponse.status}: ${contentResponse.statusText}`);
|
|
}
|
|
|
|
const htmlContent = await contentResponse.text();
|
|
console.log('📝 HTML 콘텐츠 로드됨:', htmlContent.substring(0, 100) + '...');
|
|
|
|
const contentDiv = document.getElementById('document-content');
|
|
const loadingDiv = document.getElementById('loading');
|
|
|
|
loadingDiv.style.display = 'none';
|
|
contentDiv.innerHTML = htmlContent;
|
|
|
|
console.log('✅ 문서 로드 완료');
|
|
|
|
} catch (error) {
|
|
console.error('문서 로드 실패:', error);
|
|
this.showError('문서를 불러올 수 없습니다: ' + error.message);
|
|
}
|
|
}
|
|
|
|
setupEventListeners() {
|
|
const contentDiv = document.getElementById('document-content');
|
|
const langBtn = document.getElementById('language-toggle-btn');
|
|
|
|
// 텍스트 선택 이벤트
|
|
contentDiv.addEventListener('mouseup', (e) => {
|
|
this.handleTextSelection();
|
|
});
|
|
|
|
// 언어 전환 버튼
|
|
langBtn.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
console.log('🌐 언어전환 버튼 클릭됨');
|
|
this.toggleLanguage();
|
|
});
|
|
|
|
console.log('✅ 이벤트 리스너 설정 완료');
|
|
}
|
|
|
|
handleTextSelection() {
|
|
console.log('🖱️ handleTextSelection 함수 호출됨');
|
|
|
|
const selection = window.getSelection();
|
|
console.log('📋 Selection 객체:', selection);
|
|
console.log('📊 Selection 정보:', {
|
|
rangeCount: selection.rangeCount,
|
|
isCollapsed: selection.isCollapsed,
|
|
toString: selection.toString()
|
|
});
|
|
|
|
if (!selection.rangeCount || selection.isCollapsed) {
|
|
console.log('⚠️ 선택된 텍스트가 없습니다');
|
|
return;
|
|
}
|
|
|
|
const range = selection.getRangeAt(0);
|
|
const selectedText = selection.toString().trim();
|
|
|
|
console.log('✂️ 선택된 텍스트:', `"${selectedText}"`);
|
|
console.log('📏 텍스트 길이:', selectedText.length);
|
|
|
|
if (selectedText.length < 3) {
|
|
console.log('❌ 텍스트가 너무 짧음');
|
|
alert('최소 3글자 이상 선택해주세요.');
|
|
return;
|
|
}
|
|
|
|
if (selectedText.length > 500) {
|
|
console.log('❌ 텍스트가 너무 김');
|
|
alert('선택된 텍스트가 너무 깁니다. 500자 이하로 선택해주세요.');
|
|
return;
|
|
}
|
|
|
|
// 텍스트 오프셋 계산
|
|
const documentContent = document.getElementById('document-content');
|
|
console.log('📄 Document content:', documentContent);
|
|
|
|
try {
|
|
const { startOffset, endOffset } = this.getTextOffset(documentContent, range);
|
|
|
|
console.log('🎯 텍스트 선택 완료:', {
|
|
selectedText,
|
|
startOffset,
|
|
endOffset
|
|
});
|
|
|
|
// 선택 확인 UI 표시
|
|
console.log('🎨 확인 UI 표시 시작');
|
|
this.showTextSelectionConfirm(selectedText, startOffset, endOffset);
|
|
|
|
} catch (error) {
|
|
console.error('❌ 오프셋 계산 실패:', error);
|
|
alert('텍스트 선택 처리 중 오류가 발생했습니다.');
|
|
}
|
|
}
|
|
|
|
getTextOffset(container, range) {
|
|
const walker = document.createTreeWalker(
|
|
container,
|
|
NodeFilter.SHOW_TEXT,
|
|
null,
|
|
false
|
|
);
|
|
|
|
let startOffset = 0;
|
|
let endOffset = 0;
|
|
let currentOffset = 0;
|
|
let node;
|
|
|
|
while (node = walker.nextNode()) {
|
|
const nodeLength = node.textContent.length;
|
|
|
|
if (node === range.startContainer) {
|
|
startOffset = currentOffset + range.startOffset;
|
|
}
|
|
|
|
if (node === range.endContainer) {
|
|
endOffset = currentOffset + range.endOffset;
|
|
break;
|
|
}
|
|
|
|
currentOffset += nodeLength;
|
|
}
|
|
|
|
return { startOffset, endOffset };
|
|
}
|
|
|
|
showTextSelectionConfirm(selectedText, startOffset, endOffset) {
|
|
console.log('🎨 showTextSelectionConfirm 함수 호출됨');
|
|
console.log('📝 전달받은 데이터:', { selectedText, startOffset, endOffset });
|
|
|
|
// 기존 확인 UI 제거
|
|
const existingConfirm = document.querySelector('.text-selection-confirm');
|
|
if (existingConfirm) {
|
|
console.log('🗑️ 기존 확인 UI 제거');
|
|
existingConfirm.remove();
|
|
}
|
|
|
|
// 확인 UI 생성
|
|
console.log('🏗️ 새로운 확인 UI 생성 중');
|
|
const confirmDiv = document.createElement('div');
|
|
confirmDiv.className = 'text-selection-confirm';
|
|
|
|
// 강력한 인라인 스타일 적용
|
|
confirmDiv.style.cssText = `
|
|
position: fixed !important;
|
|
bottom: 20px !important;
|
|
left: 50% !important;
|
|
transform: translateX(-50%) !important;
|
|
background: white !important;
|
|
border-radius: 12px !important;
|
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25) !important;
|
|
border: 1px solid #e5e7eb !important;
|
|
padding: 24px !important;
|
|
max-width: 400px !important;
|
|
width: 90vw !important;
|
|
z-index: 9999 !important;
|
|
display: block !important;
|
|
visibility: visible !important;
|
|
opacity: 1 !important;
|
|
pointer-events: auto !important;
|
|
`;
|
|
|
|
const previewText = selectedText.length > 100 ? selectedText.substring(0, 100) + '...' : selectedText;
|
|
console.log('📄 미리보기 텍스트:', previewText);
|
|
|
|
confirmDiv.innerHTML = `
|
|
<div class="text-center">
|
|
<div class="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
|
<i class="fas fa-check text-green-600 text-xl"></i>
|
|
</div>
|
|
<h3 class="font-semibold text-gray-900 mb-2">텍스트가 선택되었습니다</h3>
|
|
<div class="bg-gray-50 rounded-md p-3 mb-4">
|
|
<p class="text-sm text-gray-700 italic">"${previewText}"</p>
|
|
</div>
|
|
<div class="flex space-x-3">
|
|
<button class="reselect-btn flex-1 bg-gray-200 text-gray-800 px-4 py-2 rounded-md hover:bg-gray-300 transition-colors">
|
|
다시 선택
|
|
</button>
|
|
<button class="confirm-btn flex-1 bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 transition-colors">
|
|
이 텍스트 사용
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// 이벤트 리스너 추가
|
|
const reselectBtn = confirmDiv.querySelector('.reselect-btn');
|
|
const confirmBtn = confirmDiv.querySelector('.confirm-btn');
|
|
|
|
reselectBtn.addEventListener('click', () => {
|
|
confirmDiv.remove();
|
|
});
|
|
|
|
confirmBtn.addEventListener('click', () => {
|
|
this.confirmTextSelection(selectedText, startOffset, endOffset);
|
|
});
|
|
|
|
console.log('📍 DOM에 확인 UI 추가 중');
|
|
document.body.appendChild(confirmDiv);
|
|
console.log('✅ 확인 UI가 DOM에 추가됨');
|
|
|
|
// 즉시 스타일 확인
|
|
console.log('🎨 추가 직후 스타일:', {
|
|
display: confirmDiv.style.display,
|
|
visibility: confirmDiv.style.visibility,
|
|
opacity: confirmDiv.style.opacity,
|
|
zIndex: confirmDiv.style.zIndex,
|
|
position: confirmDiv.style.position
|
|
});
|
|
|
|
// UI가 실제로 화면에 표시되는지 확인
|
|
setTimeout(() => {
|
|
const addedElement = document.querySelector('.text-selection-confirm');
|
|
console.log('🔍 추가된 UI 확인:', addedElement);
|
|
if (addedElement) {
|
|
console.log('✅ UI가 화면에 표시됨');
|
|
const rect = addedElement.getBoundingClientRect();
|
|
console.log('📐 UI 위치 정보:', rect);
|
|
console.log('🎨 계산된 스타일:', {
|
|
display: getComputedStyle(addedElement).display,
|
|
visibility: getComputedStyle(addedElement).visibility,
|
|
opacity: getComputedStyle(addedElement).opacity,
|
|
zIndex: getComputedStyle(addedElement).zIndex
|
|
});
|
|
|
|
// 화면에 실제로 보이는지 확인
|
|
if (rect.width > 0 && rect.height > 0) {
|
|
console.log('✅ UI가 실제로 화면에 렌더링됨');
|
|
} else {
|
|
console.error('❌ UI가 DOM에는 있지만 화면에 렌더링되지 않음');
|
|
}
|
|
} else {
|
|
console.error('❌ UI가 화면에 표시되지 않음');
|
|
}
|
|
}, 100);
|
|
|
|
// 10초 후 자동 제거
|
|
setTimeout(() => {
|
|
if (document.contains(confirmDiv)) {
|
|
console.log('⏰ 자동 제거 타이머 실행');
|
|
confirmDiv.remove();
|
|
}
|
|
}, 10000);
|
|
}
|
|
|
|
confirmTextSelection(selectedText, startOffset, endOffset) {
|
|
console.log('🎯 텍스트 선택 확정:', {
|
|
selectedText: selectedText,
|
|
startOffset: startOffset,
|
|
endOffset: endOffset
|
|
});
|
|
|
|
// 부모 창에 선택된 텍스트 정보 전달
|
|
if (window.opener) {
|
|
const messageData = {
|
|
type: 'TEXT_SELECTED',
|
|
selectedText: selectedText,
|
|
startOffset: startOffset,
|
|
endOffset: endOffset
|
|
};
|
|
|
|
console.log('📤 부모 창에 전송할 데이터:', messageData);
|
|
window.opener.postMessage(messageData, '*');
|
|
|
|
console.log('✅ 부모 창에 텍스트 선택 정보 전달됨');
|
|
|
|
// 성공 메시지 표시 후 창 닫기
|
|
const confirmDiv = document.querySelector('.text-selection-confirm');
|
|
if (confirmDiv) {
|
|
confirmDiv.innerHTML = `
|
|
<div class="text-center">
|
|
<div class="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
|
<i class="fas fa-check text-green-600 text-xl"></i>
|
|
</div>
|
|
<h3 class="font-semibold text-green-800 mb-2">선택 완료!</h3>
|
|
<p class="text-sm text-gray-600">창이 자동으로 닫힙니다...</p>
|
|
</div>
|
|
`;
|
|
|
|
setTimeout(() => {
|
|
window.close();
|
|
}, 1500);
|
|
}
|
|
} else {
|
|
alert('부모 창을 찾을 수 없습니다.');
|
|
}
|
|
}
|
|
|
|
toggleLanguage() {
|
|
console.log('🎯 toggleLanguage 함수 호출됨');
|
|
|
|
// 기존 viewer.js와 동일한 로직 사용
|
|
const koreanContent = document.getElementById('korean-content');
|
|
const englishContent = document.getElementById('english-content');
|
|
|
|
if (koreanContent && englishContent) {
|
|
// ID 기반 토글 (압력용기 매뉴얼 등)
|
|
console.log('📋 ID 기반 언어 전환 (korean-content, english-content)');
|
|
if (koreanContent.style.display === 'none') {
|
|
koreanContent.style.display = 'block';
|
|
englishContent.style.display = 'none';
|
|
console.log('🇰🇷 한국어로 전환됨');
|
|
} else {
|
|
koreanContent.style.display = 'none';
|
|
englishContent.style.display = 'block';
|
|
console.log('🇺🇸 영어로 전환됨');
|
|
}
|
|
} else {
|
|
// 클래스 기반 토글 (다른 문서들)
|
|
console.log('📋 클래스 기반 언어 전환');
|
|
const koreanElements = document.querySelectorAll('.korean, .ko, [lang="ko"]');
|
|
const englishElements = document.querySelectorAll('.english, .en, [lang="en"]');
|
|
|
|
console.log(`📊 언어별 요소: 한국어 ${koreanElements.length}개, 영어 ${englishElements.length}개`);
|
|
|
|
if (koreanElements.length === 0 && englishElements.length === 0) {
|
|
console.log('⚠️ 언어 전환 요소가 없습니다');
|
|
alert('이 문서는 언어 전환이 불가능합니다.');
|
|
return;
|
|
}
|
|
|
|
koreanElements.forEach(el => {
|
|
el.style.display = el.style.display === 'none' ? 'block' : 'none';
|
|
});
|
|
|
|
englishElements.forEach(el => {
|
|
el.style.display = el.style.display === 'none' ? 'block' : 'none';
|
|
});
|
|
|
|
console.log('✅ 클래스 기반 언어 전환 완료');
|
|
}
|
|
}
|
|
|
|
showError(message) {
|
|
const loadingDiv = document.getElementById('loading');
|
|
const errorDiv = document.getElementById('error');
|
|
|
|
loadingDiv.style.display = 'none';
|
|
errorDiv.style.display = 'block';
|
|
errorDiv.querySelector('p').textContent = message;
|
|
}
|
|
}
|
|
|
|
// 앱 초기화
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
new TextSelectorApp();
|
|
});
|
|
</script>
|
|
|
|
<style>
|
|
/* 기존 언어 전환 버튼 숨기기 (확인 UI는 제외) */
|
|
.language-toggle:not(.text-selection-confirm),
|
|
button[onclick*="toggleLanguage"]:not(.text-selection-confirm *),
|
|
*[class*="language"]:not(.text-selection-confirm):not(.text-selection-confirm *),
|
|
*[class*="translate"]:not(.text-selection-confirm):not(.text-selection-confirm *) {
|
|
display: none !important;
|
|
}
|
|
|
|
/* 텍스트 선택 확인 UI 강제 표시 */
|
|
.text-selection-confirm {
|
|
display: block !important;
|
|
visibility: visible !important;
|
|
opacity: 1 !important;
|
|
z-index: 9999 !important;
|
|
}
|
|
|
|
/* 텍스트 선택 확인 UI 애니메이션 */
|
|
.text-selection-confirm {
|
|
animation: slideUp 0.3s ease-out;
|
|
}
|
|
|
|
@keyframes slideUp {
|
|
from {
|
|
transform: translate(-50%, 100%);
|
|
opacity: 0;
|
|
}
|
|
to {
|
|
transform: translate(-50%, 0);
|
|
opacity: 1;
|
|
}
|
|
}
|
|
</style>
|
|
</body>
|
|
</html>
|