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

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

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

652 lines
23 KiB
JavaScript

/**
* ViewerCore - 문서 뷰어 Alpine.js 컴포넌트
* 모든 모듈을 통합하고 Alpine.js 컴포넌트를 관리합니다.
*/
window.documentViewer = () => ({
// ==================== 기본 상태 ====================
loading: true,
error: null,
document: null,
documentId: null,
contentType: 'document', // 'document' 또는 'note'
navigation: null,
// ==================== 데이터 상태 ====================
highlights: [],
notes: [],
bookmarks: [],
documentLinks: [],
linkableDocuments: [],
backlinks: [],
// ==================== 선택 상태 ====================
selectedHighlightColor: '#FFFF00',
selectedText: '',
selectedRange: null,
// ==================== 폼 데이터 ====================
noteForm: {
content: '',
tags: ''
},
bookmarkForm: {
title: '',
description: ''
},
linkForm: {
target_document_id: '',
selected_text: '',
start_offset: 0,
end_offset: 0,
link_text: '',
description: '',
link_type: 'text_fragment', // 무조건 텍스트 선택만 지원
target_text: '',
target_start_offset: 0,
target_end_offset: 0,
book_scope: 'same', // 'same' 또는 'other'
target_book_id: ''
},
// ==================== 언어 및 기타 ====================
isKorean: false,
// ==================== UI 상태 (Alpine.js 바인딩용) ====================
searchQuery: '',
activeFeatureMenu: null,
showLinksModal: false,
showLinkModal: false,
showNotesModal: false,
showBookmarksModal: false,
showBacklinksModal: false,
showNoteInputModal: false,
availableBooks: [],
filteredDocuments: [],
// ==================== 모듈 인스턴스 ====================
documentLoader: null,
highlightManager: null,
bookmarkManager: null,
linkManager: null,
uiManager: null,
// ==================== 초기화 플래그 ====================
_initialized: false,
// ==================== 초기화 ====================
async init() {
// 중복 초기화 방지
if (this._initialized) {
console.log('⚠️ 이미 초기화됨, 중복 실행 방지');
return;
}
this._initialized = true;
console.log('🚀 DocumentViewer 초기화 시작');
// 전역 인스턴스 설정 (말풍선에서 함수 호출용)
window.documentViewerInstance = this;
try {
// 모듈 초기화
await this.initializeModules();
// URL 파라미터 처리
this.parseUrlParameters();
// 문서 로드
await this.loadDocument();
console.log('✅ DocumentViewer 초기화 완료');
} catch (error) {
console.error('❌ DocumentViewer 초기화 실패:', error);
this.error = error.message;
this.loading = false;
}
},
// ==================== 모듈 초기화 (지연 로딩 + 폴백) ====================
async initializeModules() {
console.log('🔧 모듈 초기화 시작 (지연 로딩)');
// API 및 캐시 초기화
this.api = new DocumentServerAPI();
// 토큰 설정 (인증 확인)
const token = localStorage.getItem('access_token');
if (token) {
this.api.setToken(token);
console.log('🔐 API 토큰 설정 완료');
} else {
console.error('❌ 인증 토큰이 없습니다!');
throw new Error('인증이 필요합니다');
}
this.cache = new CacheManager();
this.cachedApi = new CachedAPI(this.api, this.cache);
// 직접 모듈 인스턴스 생성 (모든 모듈이 HTML에서 로드됨)
if (window.DocumentLoader && window.UIManager && window.HighlightManager &&
window.LinkManager && window.BookmarkManager) {
this.documentLoader = new window.DocumentLoader(this.cachedApi);
this.uiManager = new window.UIManager();
this.highlightManager = new window.HighlightManager(this.cachedApi);
this.linkManager = new window.LinkManager(this.cachedApi);
this.bookmarkManager = new window.BookmarkManager(this.cachedApi);
console.log('✅ 모든 모듈 직접 로드 성공');
} else {
console.error('❌ 필수 모듈이 로드되지 않음');
console.log('사용 가능한 모듈:', {
DocumentLoader: !!window.DocumentLoader,
UIManager: !!window.UIManager,
HighlightManager: !!window.HighlightManager,
LinkManager: !!window.LinkManager,
BookmarkManager: !!window.BookmarkManager
});
throw new Error('필수 모듈을 로드할 수 없습니다.');
}
// UI 상태를 UIManager와 동기화
this.syncUIState();
// 나머지 모듈들은 백그라운드에서 프리로딩 (지연 로딩 가능한 경우만)
if (window.moduleLoader) {
window.moduleLoader.preloadModules(['HighlightManager', 'BookmarkManager', 'LinkManager']);
}
console.log('✅ 모듈 초기화 완료');
},
// ==================== UI 상태 동기화 ====================
syncUIState() {
// UIManager의 상태를 Alpine.js 컴포넌트와 동기화 (getter/setter 방식)
// 패널 상태 동기화
Object.defineProperty(this, 'showNotesPanel', {
get: () => this.uiManager.showNotesPanel,
set: (value) => { this.uiManager.showNotesPanel = value; }
});
Object.defineProperty(this, 'showBookmarksPanel', {
get: () => this.uiManager.showBookmarksPanel,
set: (value) => { this.uiManager.showBookmarksPanel = value; }
});
Object.defineProperty(this, 'showBacklinks', {
get: () => this.uiManager.showBacklinks,
set: (value) => { this.uiManager.showBacklinks = value; }
});
Object.defineProperty(this, 'activePanel', {
get: () => this.uiManager.activePanel,
set: (value) => { this.uiManager.activePanel = value; }
});
// 모달 상태 동기화 (UIManager와 실시간 연동)
this.updateModalStates();
// 검색 상태 동기화
Object.defineProperty(this, 'noteSearchQuery', {
get: () => this.uiManager.noteSearchQuery,
set: (value) => { this.uiManager.updateNoteSearchQuery(value); }
});
Object.defineProperty(this, 'filteredNotes', {
get: () => this.uiManager.filteredNotes,
set: (value) => { this.uiManager.filteredNotes = value; }
});
// 모드 및 핸들러 상태
this.activeMode = null;
this.textSelectionHandler = null;
this.editingNote = null;
this.editingBookmark = null;
this.editingLink = null;
this.noteLoading = false;
this.bookmarkLoading = false;
this.linkLoading = false;
},
// ==================== 모달 상태 업데이트 ====================
updateModalStates() {
// UIManager의 모달 상태를 ViewerCore의 속성에 반영
if (this.uiManager) {
this.showLinksModal = this.uiManager.showLinksModal;
this.showLinkModal = this.uiManager.showLinkModal;
this.showNotesModal = this.uiManager.showNotesModal;
this.showBookmarksModal = this.uiManager.showBookmarksModal;
this.showBacklinksModal = this.uiManager.showBacklinksModal;
this.activeFeatureMenu = this.uiManager.activeFeatureMenu;
this.searchQuery = this.uiManager.searchQuery;
}
},
// ==================== URL 파라미터 처리 ====================
parseUrlParameters() {
const urlParams = new URLSearchParams(window.location.search);
this.documentId = urlParams.get('id');
this.contentType = urlParams.get('type') || 'document';
console.log('🔍 URL 파싱 결과:', {
documentId: this.documentId,
contentType: this.contentType
});
if (!this.documentId) {
throw new Error('문서 ID가 필요합니다.');
}
},
// ==================== 문서 로드 ====================
async loadDocument() {
console.log('📄 문서 로드 시작');
this.loading = true;
try {
// 문서 데이터 로드
if (this.contentType === 'note') {
this.document = await this.documentLoader.loadNote(this.documentId);
this.navigation = null; // 노트는 네비게이션 없음
} else {
this.document = await this.documentLoader.loadDocument(this.documentId);
// 네비게이션 별도 로드
this.navigation = await this.documentLoader.loadNavigation(this.documentId);
}
// 관련 데이터 병렬 로드
await this.loadDocumentData();
// 데이터를 모듈에 전달
this.distributeDataToModules();
// 렌더링
await this.renderAllFeatures();
// URL 하이라이트 처리
await this.handleUrlHighlight();
this.loading = false;
console.log('✅ 문서 로드 완료');
} catch (error) {
console.error('❌ 문서 로드 실패:', error);
this.error = error.message;
this.loading = false;
}
},
// ==================== 문서 데이터 로드 (지연 로딩) ====================
async loadDocumentData() {
console.log('📊 문서 데이터 로드 시작');
const [highlights, notes, bookmarks, documentLinks, backlinks] = await Promise.all([
this.highlightManager.loadHighlights(this.documentId, this.contentType),
this.highlightManager.loadNotes(this.documentId, this.contentType),
this.bookmarkManager.loadBookmarks(this.documentId),
this.linkManager.loadDocumentLinks(this.documentId),
this.linkManager.loadBacklinks(this.documentId)
]);
// 데이터 저장
this.highlights = highlights;
this.notes = notes;
this.bookmarks = bookmarks;
this.documentLinks = documentLinks;
this.backlinks = backlinks;
console.log('📊 로드된 데이터:', {
highlights: highlights.length,
notes: notes.length,
bookmarks: bookmarks.length,
documentLinks: documentLinks.length,
backlinks: backlinks.length
});
},
// ==================== 모듈 지연 로딩 보장 (폴백 포함) ====================
async ensureModulesLoaded(moduleNames) {
const missingModules = [];
for (const moduleName of moduleNames) {
const propertyName = this.getModulePropertyName(moduleName);
if (!this[propertyName]) {
missingModules.push(moduleName);
}
}
if (missingModules.length > 0) {
console.log(`🔄 필요한 모듈 지연 로딩: ${missingModules.join(', ')}`);
// 각 모듈을 개별적으로 로드
for (const moduleName of missingModules) {
const propertyName = this.getModulePropertyName(moduleName);
try {
// 지연 로딩 시도
if (window.moduleLoader) {
const ModuleClass = await window.moduleLoader.loadModule(moduleName);
if (moduleName === 'UIManager') {
this[propertyName] = new ModuleClass();
} else {
this[propertyName] = new ModuleClass(this.cachedApi);
}
console.log(`✅ 지연 로딩 성공: ${moduleName}`);
} else {
throw new Error('ModuleLoader 없음');
}
} catch (error) {
console.warn(`⚠️ 지연 로딩 실패, 폴백 시도: ${moduleName}`, error);
// 폴백: 전역 클래스 직접 사용
if (window[moduleName]) {
if (moduleName === 'UIManager') {
this[propertyName] = new window[moduleName]();
} else {
this[propertyName] = new window[moduleName](this.cachedApi);
}
console.log(`✅ 폴백 성공: ${moduleName}`);
} else {
console.error(`❌ 폴백도 실패: ${moduleName} - 전역 클래스 없음`);
throw new Error(`모듈을 로드할 수 없습니다: ${moduleName}`);
}
}
}
}
},
// ==================== 모듈명 → 속성명 변환 ====================
getModulePropertyName(moduleName) {
const nameMap = {
'DocumentLoader': 'documentLoader',
'HighlightManager': 'highlightManager',
'BookmarkManager': 'bookmarkManager',
'LinkManager': 'linkManager',
'UIManager': 'uiManager'
};
return nameMap[moduleName];
},
// ==================== 모듈에 데이터 분배 ====================
distributeDataToModules() {
// HighlightManager에 데이터 전달
this.highlightManager.highlights = this.highlights;
this.highlightManager.notes = this.notes;
// BookmarkManager에 데이터 전달
this.bookmarkManager.bookmarks = this.bookmarks;
// LinkManager에 데이터 전달
this.linkManager.documentLinks = this.documentLinks;
this.linkManager.backlinks = this.backlinks;
},
// ==================== 모든 기능 렌더링 ====================
async renderAllFeatures() {
console.log('🎨 모든 기능 렌더링 시작');
// 하이라이트 렌더링
this.highlightManager.renderHighlights();
// 백링크 먼저 렌더링 (링크보다 먼저)
this.linkManager.renderBacklinks();
// 문서 링크 렌더링 (백링크 후에 렌더링)
this.linkManager.renderDocumentLinks();
console.log('✅ 모든 기능 렌더링 완료');
},
// ==================== URL 하이라이트 처리 ====================
async handleUrlHighlight() {
const urlParams = new URLSearchParams(window.location.search);
const highlightText = urlParams.get('highlight');
const startOffset = parseInt(urlParams.get('start_offset'));
const endOffset = parseInt(urlParams.get('end_offset'));
if (highlightText || (startOffset && endOffset)) {
console.log('🎯 URL에서 하이라이트 요청:', { highlightText, startOffset, endOffset });
await this.documentLoader.highlightAndScrollToText({
text: highlightText,
start_offset: startOffset,
end_offset: endOffset
});
}
},
// ==================== 기능 모드 활성화 ====================
activateLinkMode() {
console.log('🔗 링크 모드 활성화');
this.activeMode = 'link';
this.linkManager.createLinkFromSelection();
},
activateNoteMode() {
console.log('📝 메모 모드 활성화');
this.activeMode = 'memo';
this.highlightManager.activateNoteMode();
},
activateBookmarkMode() {
console.log('🔖 북마크 모드 활성화');
this.activeMode = 'bookmark';
this.bookmarkManager.activateBookmarkMode();
},
// ==================== 하이라이트 기능 위임 ====================
createHighlightWithColor(color) {
console.log('🎨 하이라이트 생성 요청:', color);
// ViewerCore의 selectedHighlightColor도 동기화
this.selectedHighlightColor = color;
console.log('🎨 ViewerCore 색상 동기화:', this.selectedHighlightColor);
return this.highlightManager.createHighlightWithColor(color);
},
// ==================== 메모 입력 모달 관련 ====================
openNoteInputModal() {
console.log('📝 메모 입력 모달 열기');
this.showNoteInputModal = true;
// 폼 초기화
this.noteForm.content = '';
this.noteForm.tags = '';
// 포커스를 textarea로 이동 (다음 틱에서)
this.$nextTick(() => {
const textarea = document.querySelector('textarea[x-model="noteForm.content"]');
if (textarea) textarea.focus();
});
},
closeNoteInputModal() {
console.log('📝 메모 입력 모달 닫기');
this.showNoteInputModal = false;
this.noteForm.content = '';
this.noteForm.tags = '';
// 선택된 텍스트 정리
this.selectedText = '';
this.selectedRange = null;
},
async createNoteForHighlight() {
console.log('📝 하이라이트에 메모 생성');
if (!this.noteForm.content.trim()) {
alert('메모 내용을 입력해주세요.');
return;
}
try {
// 현재 생성된 하이라이트 정보가 필요함
if (this.highlightManager.lastCreatedHighlight) {
await this.highlightManager.createNoteForHighlight(
this.highlightManager.lastCreatedHighlight,
this.noteForm.content.trim(),
this.noteForm.tags.trim()
);
this.closeNoteInputModal();
} else {
alert('하이라이트 정보를 찾을 수 없습니다.');
}
} catch (error) {
console.error('메모 생성 실패:', error);
alert('메모 생성에 실패했습니다: ' + error.message);
}
},
skipNoteForHighlight() {
console.log('📝 메모 입력 건너뛰기');
this.closeNoteInputModal();
},
// ==================== UI 메서드 위임 ====================
toggleFeatureMenu(feature) {
const result = this.uiManager.toggleFeatureMenu(feature);
this.updateModalStates(); // 상태 동기화
return result;
},
openNoteModal(highlight = null) {
const result = this.uiManager.openNoteModal(highlight);
this.updateModalStates(); // 상태 동기화
return result;
},
closeNoteModal() {
const result = this.uiManager.closeNoteModal();
this.updateModalStates(); // 상태 동기화
return result;
},
closeLinkModal() {
const result = this.uiManager.closeLinkModal();
this.updateModalStates(); // 상태 동기화
return result;
},
closeBookmarkModal() {
const result = this.uiManager.closeBookmarkModal();
this.updateModalStates(); // 상태 동기화
return result;
},
highlightSearchResults(element, searchText) {
return this.uiManager.highlightSearchResults(element, searchText);
},
showSuccessMessage(message) {
return this.uiManager.showSuccessMessage(message);
},
showErrorMessage(message) {
return this.uiManager.showErrorMessage(message);
},
// ==================== 언어 전환 ====================
toggleLanguage() {
this.isKorean = !this.isKorean;
console.log('🌐 언어 전환:', this.isKorean ? '한국어' : 'English');
// 언어 전환 로직 구현 필요
},
// ==================== 유틸리티 메서드 ====================
formatDate(dateString) {
return new Date(dateString).toLocaleString('ko-KR');
},
formatShortDate(dateString) {
return new Date(dateString).toLocaleDateString('ko-KR');
},
getColorName(color) {
const colorNames = {
'#FFFF00': '노란색',
'#00FF00': '초록색',
'#FF0000': '빨간색',
'#0000FF': '파란색',
'#FF00FF': '보라색',
'#00FFFF': '청록색',
'#FFA500': '주황색',
'#FFC0CB': '분홍색'
};
return colorNames[color] || '기타';
},
getSelectedBookTitle() {
if (this.linkForm.book_scope === 'same') {
return this.document?.book_title || '현재 서적';
} else {
const selectedBook = this.availableBooks.find(book => book.id === this.linkForm.target_book_id);
return selectedBook ? selectedBook.title : '서적을 선택하세요';
}
},
// ==================== 모듈 메서드 위임 ====================
// 하이라이트 관련
selectHighlight(highlightId) {
return this.highlightManager.selectHighlight(highlightId);
},
deleteHighlight(highlightId) {
return this.highlightManager.deleteHighlight(highlightId);
},
deleteHighlightsByColor(color, highlightIds) {
return this.highlightManager.deleteHighlightsByColor(color, highlightIds);
},
deleteAllOverlappingHighlights(highlightIds) {
return this.highlightManager.deleteAllOverlappingHighlights(highlightIds);
},
hideTooltip() {
return this.highlightManager.hideTooltip();
},
showAddNoteForm(highlightId) {
return this.highlightManager.showAddNoteForm(highlightId);
},
deleteNote(noteId) {
return this.highlightManager.deleteNote(noteId);
},
// 링크 관련
navigateToLinkedDocument(documentId, linkData) {
return this.linkManager.navigateToLinkedDocument(documentId, linkData);
},
navigateToBacklinkDocument(documentId, backlinkData) {
return this.linkManager.navigateToBacklinkDocument(documentId, backlinkData);
},
// 북마크 관련
scrollToBookmark(bookmark) {
return this.bookmarkManager.scrollToBookmark(bookmark);
},
deleteBookmark(bookmarkId) {
return this.bookmarkManager.deleteBookmark(bookmarkId);
}
});
// Alpine.js 컴포넌트 등록
document.addEventListener('alpine:init', () => {
console.log('🔧 Alpine.js 컴포넌트 로드됨');
// 전역 함수들 (말풍선에서 사용)
window.cancelTextSelection = () => {
if (window.documentViewerInstance && window.documentViewerInstance.linkManager) {
window.documentViewerInstance.linkManager.cancelTextSelection();
}
};
window.confirmTextSelection = (selectedText, startOffset, endOffset) => {
if (window.documentViewerInstance && window.documentViewerInstance.linkManager) {
window.documentViewerInstance.linkManager.confirmTextSelection(selectedText, startOffset, endOffset);
}
};
});