- Removed hierarchy view and integrated functionality into index.html - Added book-based document grouping with dedicated book-documents.html page - Implemented comprehensive multi-file upload system with drag-and-drop reordering - Added HTML-PDF matching functionality with download capability - Enhanced upload workflow with 3-step process (File Selection, Book Settings, Order & Match) - Added book conflict resolution (existing book vs new edition) - Improved document order adjustment with one-click sort options - Added modular header component system - Updated API connectivity for Docker environment - Enhanced viewer.html with PDF download functionality - Fixed browser caching issues with version management - Improved mobile responsiveness and modern UI design
1129 lines
40 KiB
JavaScript
1129 lines
40 KiB
JavaScript
/**
|
|
* 문서 뷰어 Alpine.js 컴포넌트
|
|
*/
|
|
window.documentViewer = () => ({
|
|
// 상태
|
|
loading: true,
|
|
error: null,
|
|
document: null,
|
|
documentId: null,
|
|
|
|
// 하이라이트 및 메모
|
|
highlights: [],
|
|
notes: [],
|
|
selectedHighlightColor: '#FFFF00',
|
|
selectedText: '',
|
|
selectedRange: null,
|
|
|
|
// 책갈피
|
|
bookmarks: [],
|
|
|
|
// UI 상태
|
|
showNotesPanel: false,
|
|
showBookmarksPanel: false,
|
|
activePanel: 'notes',
|
|
|
|
// 검색
|
|
searchQuery: '',
|
|
noteSearchQuery: '',
|
|
filteredNotes: [],
|
|
|
|
// 언어 전환
|
|
isKorean: false,
|
|
|
|
// 모달
|
|
showNoteModal: false,
|
|
showBookmarkModal: false,
|
|
editingNote: null,
|
|
editingBookmark: null,
|
|
noteLoading: false,
|
|
bookmarkLoading: false,
|
|
|
|
// 폼 데이터
|
|
noteForm: {
|
|
content: '',
|
|
tags: ''
|
|
},
|
|
bookmarkForm: {
|
|
title: '',
|
|
description: ''
|
|
},
|
|
|
|
// 초기화
|
|
async init() {
|
|
// 전역 인스턴스 설정 (말풍선에서 함수 호출용)
|
|
window.documentViewerInstance = this;
|
|
|
|
// URL에서 문서 ID 추출
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
this.documentId = urlParams.get('id');
|
|
|
|
if (!this.documentId) {
|
|
this.error = '문서 ID가 없습니다';
|
|
this.loading = false;
|
|
return;
|
|
}
|
|
|
|
// 인증 확인
|
|
if (!api.token) {
|
|
window.location.href = '/';
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await this.loadDocument();
|
|
await this.loadDocumentData();
|
|
} catch (error) {
|
|
console.error('Failed to load document:', error);
|
|
this.error = error.message;
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
|
|
// 초기 필터링
|
|
this.filterNotes();
|
|
},
|
|
|
|
// 문서 로드 (실제 API 연동)
|
|
async loadDocument() {
|
|
try {
|
|
// 백엔드에서 문서 정보 가져오기
|
|
this.document = await api.getDocument(this.documentId);
|
|
|
|
// HTML 파일 경로 구성 (백엔드 서버를 통해 접근)
|
|
const htmlPath = this.document.html_path;
|
|
const fileName = htmlPath.split('/').pop();
|
|
const response = await fetch(`http://localhost:24102/uploads/documents/${fileName}`);
|
|
|
|
if (!response.ok) {
|
|
throw new Error('문서 파일을 불러올 수 없습니다');
|
|
}
|
|
|
|
const htmlContent = await response.text();
|
|
document.getElementById('document-content').innerHTML = htmlContent;
|
|
|
|
// 페이지 제목 업데이트
|
|
document.title = `${this.document.title} - Document Server`;
|
|
|
|
// 문서 내 스크립트 오류 방지를 위한 전역 함수들 정의
|
|
this.setupDocumentScriptHandlers();
|
|
|
|
} catch (error) {
|
|
console.error('Document load error:', error);
|
|
|
|
// 백엔드 연결 실패시 목업 데이터로 폴백
|
|
console.warn('Using fallback mock data');
|
|
this.document = {
|
|
id: this.documentId,
|
|
title: 'Document Server 테스트 문서',
|
|
description: '하이라이트와 메모 기능을 테스트하기 위한 샘플 문서입니다.',
|
|
uploader_name: '관리자'
|
|
};
|
|
|
|
// 기본 HTML 내용 표시
|
|
document.getElementById('document-content').innerHTML = `
|
|
<h1>테스트 문서</h1>
|
|
<p>이 문서는 Document Server의 하이라이트 및 메모 기능을 테스트하기 위한 샘플입니다.</p>
|
|
<p>텍스트를 선택하면 하이라이트를 추가할 수 있습니다.</p>
|
|
<h2>주요 기능</h2>
|
|
<ul>
|
|
<li>텍스트 선택 후 하이라이트 생성</li>
|
|
<li>하이라이트에 메모 추가</li>
|
|
<li>메모 검색 및 관리</li>
|
|
<li>책갈피 기능</li>
|
|
</ul>
|
|
<h2>테스트 단락</h2>
|
|
<p>이것은 하이라이트 테스트를 위한 긴 단락입니다. 이 텍스트를 선택하여 하이라이트를 만들어보세요.
|
|
하이라이트를 만든 후에는 메모를 추가할 수 있습니다. 메모는 나중에 검색하고 편집할 수 있습니다.</p>
|
|
<p>또 다른 단락입니다. 여러 개의 하이라이트를 만들어서 메모 기능을 테스트해보세요.
|
|
각 하이라이트는 고유한 색상을 가질 수 있으며, 연결된 메모를 통해 중요한 정보를 기록할 수 있습니다.</p>
|
|
`;
|
|
|
|
// 폴백 모드에서도 스크립트 핸들러 설정
|
|
this.setupDocumentScriptHandlers();
|
|
|
|
// 디버깅을 위한 전역 함수 노출
|
|
window.testHighlight = () => {
|
|
console.log('Test highlight function called');
|
|
const selection = window.getSelection();
|
|
console.log('Current selection:', selection.toString());
|
|
this.handleTextSelection();
|
|
};
|
|
}
|
|
},
|
|
|
|
// 문서 내 스크립트 핸들러 설정
|
|
setupDocumentScriptHandlers() {
|
|
// 업로드된 HTML 문서에서 사용할 수 있는 전역 함수들 정의
|
|
|
|
// 언어 토글 함수 (많은 문서에서 사용)
|
|
window.toggleLanguage = function() {
|
|
const koreanContent = document.getElementById('korean-content');
|
|
const englishContent = document.getElementById('english-content');
|
|
|
|
if (koreanContent && englishContent) {
|
|
// ID 기반 토글 (압력용기 매뉴얼 등)
|
|
if (koreanContent.style.display === 'none') {
|
|
koreanContent.style.display = 'block';
|
|
englishContent.style.display = 'none';
|
|
} else {
|
|
koreanContent.style.display = 'none';
|
|
englishContent.style.display = 'block';
|
|
}
|
|
} else {
|
|
// 클래스 기반 토글 (다른 문서들)
|
|
const koreanElements = document.querySelectorAll('.korean, .ko');
|
|
const englishElements = document.querySelectorAll('.english, .en');
|
|
|
|
koreanElements.forEach(el => {
|
|
el.style.display = el.style.display === 'none' ? 'block' : 'none';
|
|
});
|
|
|
|
englishElements.forEach(el => {
|
|
el.style.display = el.style.display === 'none' ? 'block' : 'none';
|
|
});
|
|
}
|
|
|
|
// 토글 버튼 텍스트 업데이트
|
|
const toggleButton = document.querySelector('.language-toggle');
|
|
if (toggleButton && koreanContent) {
|
|
const isKoreanVisible = koreanContent.style.display !== 'none';
|
|
toggleButton.textContent = isKoreanVisible ? '🌐 English' : '🌐 한국어';
|
|
}
|
|
};
|
|
|
|
// 기타 공통 함수들 (필요시 추가)
|
|
window.showSection = function(sectionId) {
|
|
const section = document.getElementById(sectionId);
|
|
if (section) {
|
|
section.scrollIntoView({ behavior: 'smooth' });
|
|
}
|
|
};
|
|
|
|
// 인쇄 함수
|
|
window.printDocument = function() {
|
|
window.print();
|
|
};
|
|
|
|
// 문서 내 링크 클릭 시 새 창에서 열기 방지
|
|
const links = document.querySelectorAll('#document-content a[href^="http"]');
|
|
links.forEach(link => {
|
|
link.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
if (confirm('외부 링크로 이동하시겠습니까?\n' + link.href)) {
|
|
window.open(link.href, '_blank');
|
|
}
|
|
});
|
|
});
|
|
},
|
|
|
|
// 문서 관련 데이터 로드
|
|
async loadDocumentData() {
|
|
try {
|
|
console.log('Loading document data for:', this.documentId);
|
|
const [highlights, notes, bookmarks] = await Promise.all([
|
|
api.getDocumentHighlights(this.documentId).catch(() => []),
|
|
api.getDocumentNotes(this.documentId).catch(() => []),
|
|
api.getDocumentBookmarks(this.documentId).catch(() => [])
|
|
]);
|
|
|
|
this.highlights = highlights || [];
|
|
this.notes = notes || [];
|
|
this.bookmarks = bookmarks || [];
|
|
|
|
console.log('Loaded data:', { highlights: this.highlights.length, notes: this.notes.length, bookmarks: this.bookmarks.length });
|
|
|
|
// 하이라이트 렌더링
|
|
this.renderHighlights();
|
|
|
|
} catch (error) {
|
|
console.warn('Some document data failed to load, continuing with empty data:', error);
|
|
this.highlights = [];
|
|
this.notes = [];
|
|
this.bookmarks = [];
|
|
}
|
|
},
|
|
|
|
// 하이라이트 렌더링
|
|
renderHighlights() {
|
|
const content = document.getElementById('document-content');
|
|
|
|
// 기존 하이라이트 제거
|
|
content.querySelectorAll('.highlight').forEach(el => {
|
|
const parent = el.parentNode;
|
|
parent.replaceChild(document.createTextNode(el.textContent), el);
|
|
parent.normalize();
|
|
});
|
|
|
|
// 새 하이라이트 적용
|
|
this.highlights.forEach(highlight => {
|
|
this.applyHighlight(highlight);
|
|
});
|
|
},
|
|
|
|
// 개별 하이라이트 적용
|
|
applyHighlight(highlight) {
|
|
const content = document.getElementById('document-content');
|
|
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 (nodeStart < highlight.end_offset && nodeEnd > highlight.start_offset) {
|
|
const startInNode = Math.max(0, highlight.start_offset - nodeStart);
|
|
const endInNode = Math.min(nodeLength, highlight.end_offset - nodeStart);
|
|
|
|
if (startInNode < endInNode) {
|
|
// 텍스트 노드를 분할하고 하이라이트 적용
|
|
const beforeText = node.textContent.substring(0, startInNode);
|
|
const highlightText = node.textContent.substring(startInNode, endInNode);
|
|
const afterText = node.textContent.substring(endInNode);
|
|
|
|
const parent = node.parentNode;
|
|
|
|
// 하이라이트 요소 생성
|
|
const highlightEl = document.createElement('span');
|
|
highlightEl.className = 'highlight';
|
|
highlightEl.style.backgroundColor = highlight.highlight_color;
|
|
highlightEl.textContent = highlightText;
|
|
highlightEl.dataset.highlightId = highlight.id;
|
|
|
|
// 클릭 이벤트 추가 - 말풍선 표시
|
|
highlightEl.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
this.showHighlightTooltip(highlight, e.target);
|
|
});
|
|
|
|
// 노드 교체
|
|
if (beforeText) {
|
|
parent.insertBefore(document.createTextNode(beforeText), node);
|
|
}
|
|
parent.insertBefore(highlightEl, node);
|
|
if (afterText) {
|
|
parent.insertBefore(document.createTextNode(afterText), node);
|
|
}
|
|
parent.removeChild(node);
|
|
}
|
|
}
|
|
|
|
currentOffset = nodeEnd;
|
|
}
|
|
},
|
|
|
|
// 텍스트 선택 처리
|
|
handleTextSelection() {
|
|
console.log('handleTextSelection called');
|
|
const selection = window.getSelection();
|
|
|
|
if (selection.rangeCount === 0 || selection.isCollapsed) {
|
|
console.log('No selection or collapsed');
|
|
return;
|
|
}
|
|
|
|
const range = selection.getRangeAt(0);
|
|
const selectedText = selection.toString().trim();
|
|
console.log('Selected text:', selectedText);
|
|
|
|
if (selectedText.length < 2) {
|
|
console.log('Text too short');
|
|
return;
|
|
}
|
|
|
|
// 문서 컨텐츠 내부의 선택인지 확인
|
|
const content = document.getElementById('document-content');
|
|
if (!content.contains(range.commonAncestorContainer)) {
|
|
console.log('Selection not in document content');
|
|
return;
|
|
}
|
|
|
|
// 선택된 텍스트와 범위 저장
|
|
this.selectedText = selectedText;
|
|
this.selectedRange = range.cloneRange();
|
|
|
|
console.log('Showing highlight button');
|
|
// 컨텍스트 메뉴 표시 (간단한 버튼)
|
|
this.showHighlightButton(selection);
|
|
},
|
|
|
|
// 하이라이트 버튼 표시
|
|
showHighlightButton(selection) {
|
|
// 기존 버튼 제거
|
|
const existingButton = document.querySelector('.highlight-button');
|
|
if (existingButton) {
|
|
existingButton.remove();
|
|
}
|
|
|
|
const range = selection.getRangeAt(0);
|
|
const rect = range.getBoundingClientRect();
|
|
|
|
const button = document.createElement('button');
|
|
button.className = 'highlight-button fixed z-50 bg-blue-600 text-white px-4 py-2 rounded shadow-lg text-sm font-medium border-2 border-blue-700';
|
|
button.style.left = `${rect.left + window.scrollX}px`;
|
|
button.style.top = `${rect.bottom + window.scrollY + 10}px`;
|
|
button.innerHTML = '🖍️ 하이라이트';
|
|
|
|
console.log('Highlight button created at:', button.style.left, button.style.top);
|
|
|
|
button.addEventListener('click', () => {
|
|
this.createHighlight();
|
|
button.remove();
|
|
});
|
|
|
|
document.body.appendChild(button);
|
|
|
|
// 3초 후 자동 제거
|
|
setTimeout(() => {
|
|
if (button.parentNode) {
|
|
button.remove();
|
|
}
|
|
}, 3000);
|
|
},
|
|
|
|
// 색상 버튼으로 하이라이트 생성
|
|
createHighlightWithColor(color) {
|
|
console.log('createHighlightWithColor called with color:', color);
|
|
|
|
// 현재 선택된 텍스트가 있는지 확인
|
|
const selection = window.getSelection();
|
|
if (selection.rangeCount === 0 || selection.isCollapsed) {
|
|
alert('먼저 하이라이트할 텍스트를 선택해주세요.');
|
|
return;
|
|
}
|
|
|
|
// 색상 설정 후 하이라이트 생성
|
|
this.selectedHighlightColor = color;
|
|
this.handleTextSelection(); // 텍스트 선택 처리
|
|
|
|
// 바로 하이라이트 생성 (버튼 클릭 없이)
|
|
setTimeout(() => {
|
|
this.createHighlight();
|
|
}, 100);
|
|
},
|
|
|
|
// 하이라이트 생성
|
|
async createHighlight() {
|
|
console.log('createHighlight called');
|
|
console.log('selectedText:', this.selectedText);
|
|
console.log('selectedRange:', this.selectedRange);
|
|
|
|
if (!this.selectedText || !this.selectedRange) {
|
|
console.log('No selected text or range');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
console.log('Starting highlight creation...');
|
|
// 텍스트 오프셋 계산
|
|
const content = document.getElementById('document-content');
|
|
const { startOffset, endOffset } = this.calculateTextOffsets(this.selectedRange, content);
|
|
console.log('Text offsets:', startOffset, endOffset);
|
|
|
|
const highlightData = {
|
|
document_id: this.documentId,
|
|
start_offset: startOffset,
|
|
end_offset: endOffset,
|
|
selected_text: this.selectedText,
|
|
highlight_color: this.selectedHighlightColor,
|
|
highlight_type: 'highlight'
|
|
};
|
|
|
|
const highlight = await api.createHighlight(highlightData);
|
|
this.highlights.push(highlight);
|
|
|
|
// 하이라이트 렌더링
|
|
this.renderHighlights();
|
|
|
|
// 선택 해제
|
|
window.getSelection().removeAllRanges();
|
|
this.selectedText = '';
|
|
this.selectedRange = null;
|
|
|
|
// 메모 추가 여부 확인
|
|
if (confirm('이 하이라이트에 메모를 추가하시겠습니까?')) {
|
|
this.openNoteModal(highlight);
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('Failed to create highlight:', error);
|
|
alert('하이라이트 생성에 실패했습니다');
|
|
}
|
|
},
|
|
|
|
// 텍스트 오프셋 계산
|
|
calculateTextOffsets(range, container) {
|
|
const walker = document.createTreeWalker(
|
|
container,
|
|
NodeFilter.SHOW_TEXT,
|
|
null,
|
|
false
|
|
);
|
|
|
|
let currentOffset = 0;
|
|
let startOffset = -1;
|
|
let endOffset = -1;
|
|
let node;
|
|
|
|
while (node = walker.nextNode()) {
|
|
const nodeLength = node.textContent.length;
|
|
|
|
if (range.startContainer === node) {
|
|
startOffset = currentOffset + range.startOffset;
|
|
}
|
|
|
|
if (range.endContainer === node) {
|
|
endOffset = currentOffset + range.endOffset;
|
|
break;
|
|
}
|
|
|
|
currentOffset += nodeLength;
|
|
}
|
|
|
|
return { startOffset, endOffset };
|
|
},
|
|
|
|
// 하이라이트 선택
|
|
selectHighlight(highlightId) {
|
|
// 모든 하이라이트에서 selected 클래스 제거
|
|
document.querySelectorAll('.highlight').forEach(el => {
|
|
el.classList.remove('selected');
|
|
});
|
|
|
|
// 선택된 하이라이트에 selected 클래스 추가
|
|
const highlightEl = document.querySelector(`[data-highlight-id="${highlightId}"]`);
|
|
if (highlightEl) {
|
|
highlightEl.classList.add('selected');
|
|
}
|
|
|
|
// 해당 하이라이트의 메모 찾기
|
|
const note = this.notes.find(n => n.highlight.id === highlightId);
|
|
if (note) {
|
|
this.editNote(note);
|
|
} else {
|
|
// 메모가 없으면 새로 생성
|
|
const highlight = this.highlights.find(h => h.id === highlightId);
|
|
if (highlight) {
|
|
this.openNoteModal(highlight);
|
|
}
|
|
}
|
|
},
|
|
|
|
// 하이라이트로 스크롤
|
|
scrollToHighlight(highlightId) {
|
|
const highlightEl = document.querySelector(`[data-highlight-id="${highlightId}"]`);
|
|
if (highlightEl) {
|
|
highlightEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
highlightEl.classList.add('selected');
|
|
|
|
// 2초 후 선택 해제
|
|
setTimeout(() => {
|
|
highlightEl.classList.remove('selected');
|
|
}, 2000);
|
|
}
|
|
},
|
|
|
|
// 메모 모달 열기
|
|
openNoteModal(highlight = null) {
|
|
this.editingNote = null;
|
|
this.noteForm = {
|
|
content: '',
|
|
tags: ''
|
|
};
|
|
|
|
if (highlight) {
|
|
this.selectedHighlight = highlight;
|
|
this.selectedText = highlight.selected_text;
|
|
}
|
|
|
|
this.showNoteModal = true;
|
|
},
|
|
|
|
// 메모 편집
|
|
editNote(note) {
|
|
this.editingNote = note;
|
|
this.noteForm = {
|
|
content: note.content,
|
|
tags: note.tags ? note.tags.join(', ') : ''
|
|
};
|
|
this.selectedText = note.highlight.selected_text;
|
|
this.showNoteModal = true;
|
|
},
|
|
|
|
// 메모 저장
|
|
async saveNote() {
|
|
this.noteLoading = true;
|
|
|
|
try {
|
|
const noteData = {
|
|
content: this.noteForm.content,
|
|
tags: this.noteForm.tags ? this.noteForm.tags.split(',').map(t => t.trim()).filter(t => t) : []
|
|
};
|
|
|
|
if (this.editingNote) {
|
|
// 메모 수정
|
|
const updatedNote = await api.updateNote(this.editingNote.id, noteData);
|
|
const index = this.notes.findIndex(n => n.id === this.editingNote.id);
|
|
if (index !== -1) {
|
|
this.notes[index] = updatedNote;
|
|
}
|
|
} else {
|
|
// 새 메모 생성
|
|
noteData.highlight_id = this.selectedHighlight.id;
|
|
const newNote = await api.createNote(noteData);
|
|
this.notes.push(newNote);
|
|
}
|
|
|
|
this.filterNotes();
|
|
this.closeNoteModal();
|
|
|
|
} catch (error) {
|
|
console.error('Failed to save note:', error);
|
|
alert('메모 저장에 실패했습니다');
|
|
} finally {
|
|
this.noteLoading = false;
|
|
}
|
|
},
|
|
|
|
// 메모 삭제
|
|
async deleteNote(noteId) {
|
|
if (!confirm('이 메모를 삭제하시겠습니까?')) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await api.deleteNote(noteId);
|
|
this.notes = this.notes.filter(n => n.id !== noteId);
|
|
this.filterNotes();
|
|
} catch (error) {
|
|
console.error('Failed to delete note:', error);
|
|
alert('메모 삭제에 실패했습니다');
|
|
}
|
|
},
|
|
|
|
// 메모 모달 닫기
|
|
closeNoteModal() {
|
|
this.showNoteModal = false;
|
|
this.editingNote = null;
|
|
this.selectedHighlight = null;
|
|
this.selectedText = '';
|
|
this.noteForm = { content: '', tags: '' };
|
|
},
|
|
|
|
// 메모 필터링
|
|
filterNotes() {
|
|
if (!this.noteSearchQuery.trim()) {
|
|
this.filteredNotes = [...this.notes];
|
|
} else {
|
|
const query = this.noteSearchQuery.toLowerCase();
|
|
this.filteredNotes = this.notes.filter(note =>
|
|
note.content.toLowerCase().includes(query) ||
|
|
note.highlight.selected_text.toLowerCase().includes(query) ||
|
|
(note.tags && note.tags.some(tag => tag.toLowerCase().includes(query)))
|
|
);
|
|
}
|
|
},
|
|
|
|
// 책갈피 추가
|
|
async addBookmark() {
|
|
const scrollPosition = window.scrollY;
|
|
this.bookmarkForm = {
|
|
title: `${this.document.title} - ${new Date().toLocaleString()}`,
|
|
description: ''
|
|
};
|
|
this.currentScrollPosition = scrollPosition;
|
|
this.showBookmarkModal = true;
|
|
},
|
|
|
|
// 책갈피 편집
|
|
editBookmark(bookmark) {
|
|
this.editingBookmark = bookmark;
|
|
this.bookmarkForm = {
|
|
title: bookmark.title,
|
|
description: bookmark.description || ''
|
|
};
|
|
this.showBookmarkModal = true;
|
|
},
|
|
|
|
// 책갈피 저장
|
|
async saveBookmark() {
|
|
this.bookmarkLoading = true;
|
|
|
|
try {
|
|
const bookmarkData = {
|
|
title: this.bookmarkForm.title,
|
|
description: this.bookmarkForm.description,
|
|
scroll_position: this.currentScrollPosition || 0
|
|
};
|
|
|
|
if (this.editingBookmark) {
|
|
// 책갈피 수정
|
|
const updatedBookmark = await api.updateBookmark(this.editingBookmark.id, bookmarkData);
|
|
const index = this.bookmarks.findIndex(b => b.id === this.editingBookmark.id);
|
|
if (index !== -1) {
|
|
this.bookmarks[index] = updatedBookmark;
|
|
}
|
|
} else {
|
|
// 새 책갈피 생성
|
|
bookmarkData.document_id = this.documentId;
|
|
const newBookmark = await api.createBookmark(bookmarkData);
|
|
this.bookmarks.push(newBookmark);
|
|
}
|
|
|
|
this.closeBookmarkModal();
|
|
|
|
} catch (error) {
|
|
console.error('Failed to save bookmark:', error);
|
|
alert('책갈피 저장에 실패했습니다');
|
|
} finally {
|
|
this.bookmarkLoading = false;
|
|
}
|
|
},
|
|
|
|
// 책갈피 삭제
|
|
async deleteBookmark(bookmarkId) {
|
|
if (!confirm('이 책갈피를 삭제하시겠습니까?')) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await api.deleteBookmark(bookmarkId);
|
|
this.bookmarks = this.bookmarks.filter(b => b.id !== bookmarkId);
|
|
} catch (error) {
|
|
console.error('Failed to delete bookmark:', error);
|
|
alert('책갈피 삭제에 실패했습니다');
|
|
}
|
|
},
|
|
|
|
// 책갈피로 스크롤
|
|
scrollToBookmark(bookmark) {
|
|
window.scrollTo({
|
|
top: bookmark.scroll_position,
|
|
behavior: 'smooth'
|
|
});
|
|
},
|
|
|
|
// 책갈피 모달 닫기
|
|
closeBookmarkModal() {
|
|
this.showBookmarkModal = false;
|
|
this.editingBookmark = null;
|
|
this.bookmarkForm = { title: '', description: '' };
|
|
this.currentScrollPosition = null;
|
|
},
|
|
|
|
// 문서 내 검색
|
|
searchInDocument() {
|
|
// 기존 검색 하이라이트 제거
|
|
document.querySelectorAll('.search-highlight').forEach(el => {
|
|
const parent = el.parentNode;
|
|
parent.replaceChild(document.createTextNode(el.textContent), el);
|
|
parent.normalize();
|
|
});
|
|
|
|
if (!this.searchQuery.trim()) {
|
|
return;
|
|
}
|
|
|
|
// 새 검색 하이라이트 적용
|
|
const content = document.getElementById('document-content');
|
|
this.highlightSearchResults(content, this.searchQuery);
|
|
},
|
|
|
|
// 검색 결과 하이라이트
|
|
highlightSearchResults(element, searchText) {
|
|
const walker = document.createTreeWalker(
|
|
element,
|
|
NodeFilter.SHOW_TEXT,
|
|
null,
|
|
false
|
|
);
|
|
|
|
const textNodes = [];
|
|
let node;
|
|
while (node = walker.nextNode()) {
|
|
textNodes.push(node);
|
|
}
|
|
|
|
textNodes.forEach(textNode => {
|
|
const text = textNode.textContent;
|
|
const regex = new RegExp(`(${searchText})`, 'gi');
|
|
|
|
if (regex.test(text)) {
|
|
const parent = textNode.parentNode;
|
|
const highlightedHTML = text.replace(regex, '<span class="search-highlight">$1</span>');
|
|
|
|
const tempDiv = document.createElement('div');
|
|
tempDiv.innerHTML = highlightedHTML;
|
|
|
|
while (tempDiv.firstChild) {
|
|
parent.insertBefore(tempDiv.firstChild, textNode);
|
|
}
|
|
parent.removeChild(textNode);
|
|
}
|
|
});
|
|
},
|
|
|
|
// 문서 클릭 처리
|
|
handleDocumentClick(event) {
|
|
// 하이라이트 버튼 제거
|
|
const button = document.querySelector('.highlight-button');
|
|
if (button && !button.contains(event.target)) {
|
|
button.remove();
|
|
}
|
|
|
|
// 하이라이트 선택 해제
|
|
document.querySelectorAll('.highlight.selected').forEach(el => {
|
|
el.classList.remove('selected');
|
|
});
|
|
},
|
|
|
|
// 뒤로가기 - 문서 관리 페이지로 이동
|
|
goBack() {
|
|
// 1. URL 파라미터에서 from 확인
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
const fromPage = urlParams.get('from');
|
|
|
|
// 2. 세션 스토리지에서 이전 페이지 확인
|
|
const previousPage = sessionStorage.getItem('previousPage');
|
|
|
|
// 3. referrer 확인
|
|
const referrer = document.referrer;
|
|
|
|
let targetPage = 'index.html'; // 기본값: 그리드 뷰
|
|
|
|
// 우선순위: URL 파라미터 > 세션 스토리지 > referrer
|
|
if (fromPage === 'hierarchy') {
|
|
targetPage = 'hierarchy.html';
|
|
} else if (previousPage === 'hierarchy.html') {
|
|
targetPage = 'hierarchy.html';
|
|
} else if (referrer && referrer.includes('hierarchy.html')) {
|
|
targetPage = 'hierarchy.html';
|
|
}
|
|
|
|
console.log(`🔙 뒤로가기: ${targetPage}로 이동`);
|
|
window.location.href = targetPage;
|
|
},
|
|
|
|
// 날짜 포맷팅
|
|
formatDate(dateString) {
|
|
const date = new Date(dateString);
|
|
return date.toLocaleDateString('ko-KR', {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
});
|
|
},
|
|
|
|
// 하이라이트 말풍선 표시
|
|
showHighlightTooltip(highlight, element) {
|
|
// 기존 말풍선 제거
|
|
this.hideTooltip();
|
|
|
|
const tooltip = document.createElement('div');
|
|
tooltip.id = 'highlight-tooltip';
|
|
tooltip.className = 'absolute bg-white border border-gray-300 rounded-lg shadow-lg p-4 z-50 max-w-sm';
|
|
tooltip.style.minWidth = '300px';
|
|
|
|
// 하이라이트 정보와 메모 표시
|
|
const highlightNotes = this.notes.filter(note => note.highlight_id === highlight.id);
|
|
|
|
tooltip.innerHTML = `
|
|
<div class="mb-3">
|
|
<div class="text-sm text-gray-600 mb-1">선택된 텍스트</div>
|
|
<div class="font-medium text-gray-900 bg-yellow-100 px-2 py-1 rounded">
|
|
"${highlight.selected_text}"
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mb-3">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<span class="text-sm font-medium text-gray-700">메모 (${highlightNotes.length})</span>
|
|
<button onclick="window.documentViewerInstance.showAddNoteForm('${highlight.id}')"
|
|
class="text-xs bg-blue-500 text-white px-2 py-1 rounded hover:bg-blue-600">
|
|
+ 메모 추가
|
|
</button>
|
|
</div>
|
|
|
|
<div id="notes-list" class="space-y-2 max-h-40 overflow-y-auto">
|
|
${highlightNotes.length > 0 ?
|
|
highlightNotes.map(note => `
|
|
<div class="bg-gray-50 p-2 rounded text-sm">
|
|
<div class="text-gray-800 mb-1">${note.content}</div>
|
|
<div class="text-xs text-gray-500">
|
|
${this.formatShortDate(note.created_at)} · Administrator
|
|
</div>
|
|
</div>
|
|
`).join('') :
|
|
'<div class="text-sm text-gray-500 italic">메모가 없습니다</div>'
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex justify-between text-xs">
|
|
<button onclick="window.documentViewerInstance.deleteHighlight('${highlight.id}')"
|
|
class="text-red-500 hover:text-red-700">
|
|
하이라이트 삭제
|
|
</button>
|
|
<button onclick="window.documentViewerInstance.hideTooltip()"
|
|
class="text-gray-500 hover:text-gray-700">
|
|
닫기
|
|
</button>
|
|
</div>
|
|
`;
|
|
|
|
// 위치 계산
|
|
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 tooltip = document.getElementById('highlight-tooltip');
|
|
if (tooltip) {
|
|
tooltip.remove();
|
|
}
|
|
document.removeEventListener('click', this.handleTooltipOutsideClick.bind(this));
|
|
},
|
|
|
|
// 말풍선 외부 클릭 처리
|
|
handleTooltipOutsideClick(e) {
|
|
const tooltip = document.getElementById('highlight-tooltip');
|
|
if (tooltip && !tooltip.contains(e.target) && !e.target.classList.contains('highlight')) {
|
|
this.hideTooltip();
|
|
}
|
|
},
|
|
|
|
// 짧은 날짜 형식
|
|
formatShortDate(dateString) {
|
|
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 === 2) {
|
|
return '어제';
|
|
} else if (diffDays <= 7) {
|
|
return `${diffDays-1}일 전`;
|
|
} else {
|
|
return date.toLocaleDateString('ko-KR', {
|
|
year: '2-digit',
|
|
month: '2-digit',
|
|
day: '2-digit'
|
|
});
|
|
}
|
|
},
|
|
|
|
// 메모 추가 폼 표시
|
|
showAddNoteForm(highlightId) {
|
|
const tooltip = document.getElementById('highlight-tooltip');
|
|
if (!tooltip) return;
|
|
|
|
const notesList = tooltip.querySelector('#notes-list');
|
|
notesList.innerHTML = `
|
|
<div class="bg-blue-50 p-3 rounded border">
|
|
<textarea id="new-note-content"
|
|
placeholder="메모를 입력하세요..."
|
|
class="w-full p-2 border border-gray-300 rounded text-sm resize-none"
|
|
rows="3"></textarea>
|
|
<div class="flex justify-end mt-2 space-x-2">
|
|
<button onclick="window.documentViewerInstance.cancelAddNote('${highlightId}')"
|
|
class="text-xs bg-gray-500 text-white px-3 py-1 rounded hover:bg-gray-600">
|
|
취소
|
|
</button>
|
|
<button onclick="window.documentViewerInstance.saveNewNote('${highlightId}')"
|
|
class="text-xs bg-blue-500 text-white px-3 py-1 rounded hover:bg-blue-600">
|
|
저장
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// 텍스트 영역에 포커스
|
|
setTimeout(() => {
|
|
document.getElementById('new-note-content').focus();
|
|
}, 100);
|
|
},
|
|
|
|
// 메모 추가 취소
|
|
cancelAddNote(highlightId) {
|
|
// 말풍선 다시 표시
|
|
const highlight = this.highlights.find(h => h.id === highlightId);
|
|
if (highlight) {
|
|
const element = document.querySelector(`[data-highlight-id="${highlightId}"]`);
|
|
if (element) {
|
|
this.showHighlightTooltip(highlight, element);
|
|
}
|
|
}
|
|
},
|
|
|
|
// 새 메모 저장
|
|
async saveNewNote(highlightId) {
|
|
const content = document.getElementById('new-note-content').value.trim();
|
|
if (!content) {
|
|
alert('메모 내용을 입력해주세요');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const noteData = {
|
|
highlight_id: highlightId,
|
|
content: content,
|
|
is_private: false,
|
|
tags: []
|
|
};
|
|
|
|
const newNote = await api.createNote(noteData);
|
|
|
|
// 로컬 데이터 업데이트
|
|
this.notes.push(newNote);
|
|
|
|
// 말풍선 새로고침
|
|
const highlight = this.highlights.find(h => h.id === highlightId);
|
|
if (highlight) {
|
|
const element = document.querySelector(`[data-highlight-id="${highlightId}"]`);
|
|
if (element) {
|
|
this.showHighlightTooltip(highlight, element);
|
|
}
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('Failed to save note:', error);
|
|
alert('메모 저장에 실패했습니다');
|
|
}
|
|
},
|
|
|
|
// 하이라이트 삭제
|
|
async deleteHighlight(highlightId) {
|
|
if (!confirm('이 하이라이트를 삭제하시겠습니까? 연결된 메모도 함께 삭제됩니다.')) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await api.deleteHighlight(highlightId);
|
|
|
|
// 로컬 데이터에서 제거
|
|
this.highlights = this.highlights.filter(h => h.id !== highlightId);
|
|
this.notes = this.notes.filter(n => n.highlight_id !== highlightId);
|
|
|
|
// UI 업데이트
|
|
this.hideTooltip();
|
|
this.renderHighlights();
|
|
|
|
} catch (error) {
|
|
console.error('Failed to delete highlight:', error);
|
|
alert('하이라이트 삭제에 실패했습니다');
|
|
}
|
|
},
|
|
|
|
// 언어 전환 함수
|
|
toggleLanguage() {
|
|
this.isKorean = !this.isKorean;
|
|
|
|
// 문서 내 언어별 요소 토글 (더 범용적으로)
|
|
const primaryLangElements = document.querySelectorAll('[lang="ko"], .korean, .kr, .primary-lang');
|
|
const secondaryLangElements = document.querySelectorAll('[lang="en"], .english, .en, [lang="ja"], .japanese, .jp, [lang="zh"], .chinese, .cn, .secondary-lang');
|
|
|
|
primaryLangElements.forEach(el => {
|
|
el.style.display = this.isKorean ? 'block' : 'none';
|
|
});
|
|
|
|
secondaryLangElements.forEach(el => {
|
|
el.style.display = this.isKorean ? 'none' : 'block';
|
|
});
|
|
|
|
console.log(`🌐 언어 전환됨 (Primary: ${this.isKorean ? '표시' : '숨김'})`);
|
|
},
|
|
|
|
// 매칭된 PDF 다운로드
|
|
async downloadMatchedPDF() {
|
|
if (!this.document.matched_pdf_id) {
|
|
console.warn('매칭된 PDF가 없습니다');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
console.log('📕 PDF 다운로드 시작:', this.document.matched_pdf_id);
|
|
|
|
// PDF 문서 정보 가져오기
|
|
const pdfDocument = await window.api.getDocument(this.document.matched_pdf_id);
|
|
|
|
if (!pdfDocument) {
|
|
throw new Error('PDF 문서를 찾을 수 없습니다');
|
|
}
|
|
|
|
// PDF 파일 다운로드 URL 생성
|
|
const downloadUrl = `/api/documents/${this.document.matched_pdf_id}/download`;
|
|
|
|
// 인증 헤더 추가를 위해 fetch 사용
|
|
const response = await fetch(downloadUrl, {
|
|
method: 'GET',
|
|
headers: {
|
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
|
}
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('PDF 다운로드에 실패했습니다');
|
|
}
|
|
|
|
// Blob으로 변환하여 다운로드
|
|
const blob = await response.blob();
|
|
const url = window.URL.createObjectURL(blob);
|
|
|
|
// 다운로드 링크 생성 및 클릭
|
|
const link = document.createElement('a');
|
|
link.href = url;
|
|
link.download = pdfDocument.original_filename || `${pdfDocument.title}.pdf`;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
|
|
// URL 정리
|
|
window.URL.revokeObjectURL(url);
|
|
|
|
console.log('✅ PDF 다운로드 완료');
|
|
|
|
} catch (error) {
|
|
console.error('❌ PDF 다운로드 실패:', error);
|
|
alert('PDF 다운로드에 실패했습니다: ' + error.message);
|
|
}
|
|
}
|
|
});
|