/**
* 문서 뷰어 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: [],
// 모달
showNoteModal: false,
showBookmarkModal: false,
editingNote: null,
editingBookmark: null,
noteLoading: false,
bookmarkLoading: false,
// 폼 데이터
noteForm: {
content: '',
tags: ''
},
bookmarkForm: {
title: '',
description: ''
},
// 초기화
async init() {
// 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();
},
// 문서 로드
async loadDocument() {
this.document = await api.getDocument(this.documentId);
// HTML 내용 로드
const response = await fetch(`/uploads/documents/${this.documentId}.html`);
if (!response.ok) {
throw new Error('문서를 불러올 수 없습니다');
}
const htmlContent = await response.text();
document.getElementById('document-content').innerHTML = htmlContent;
// 페이지 제목 업데이트
document.title = `${this.document.title} - Document Server`;
},
// 문서 관련 데이터 로드
async loadDocumentData() {
try {
const [highlights, notes, bookmarks] = await Promise.all([
api.getDocumentHighlights(this.documentId),
api.getDocumentNotes(this.documentId),
api.getDocumentBookmarks(this.documentId)
]);
this.highlights = highlights;
this.notes = notes;
this.bookmarks = bookmarks;
// 하이라이트 렌더링
this.renderHighlights();
} catch (error) {
console.error('Failed to load document data:', error);
}
},
// 하이라이트 렌더링
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.selectHighlight(highlight.id);
});
// 노드 교체
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() {
const selection = window.getSelection();
if (selection.rangeCount === 0 || selection.isCollapsed) {
return;
}
const range = selection.getRangeAt(0);
const selectedText = selection.toString().trim();
if (selectedText.length < 2) {
return;
}
// 문서 컨텐츠 내부의 선택인지 확인
const content = document.getElementById('document-content');
if (!content.contains(range.commonAncestorContainer)) {
return;
}
// 선택된 텍스트와 범위 저장
this.selectedText = selectedText;
this.selectedRange = range.cloneRange();
// 컨텍스트 메뉴 표시 (간단한 버튼)
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-3 py-1 rounded shadow-lg text-sm';
button.style.left = `${rect.left + window.scrollX}px`;
button.style.top = `${rect.bottom + window.scrollY + 5}px`;
button.innerHTML = '하이라이트';
button.addEventListener('click', () => {
this.createHighlight();
button.remove();
});
document.body.appendChild(button);
// 3초 후 자동 제거
setTimeout(() => {
if (button.parentNode) {
button.remove();
}
}, 3000);
},
// 하이라이트 생성
async createHighlight() {
if (!this.selectedText || !this.selectedRange) {
return;
}
try {
// 텍스트 오프셋 계산
const content = document.getElementById('document-content');
const { startOffset, endOffset } = this.calculateTextOffsets(this.selectedRange, content);
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, '$1');
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() {
window.history.back();
},
// 날짜 포맷팅
formatDate(dateString) {
const date = new Date(dateString);
return date.toLocaleDateString('ko-KR', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
});