您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
아카라이브의 이미지를 만화처럼 볼 수 있는 뷰어 모드를 추가합니다
// ==UserScript== // @name 아카라이브 만화 뷰어 모드 // @namespace https://arca.live/ // @version 1.0 // @description 아카라이브의 이미지를 만화처럼 볼 수 있는 뷰어 모드를 추가합니다 // @author ㅇㅇ // @match https://arca.live/b/* // @grant GM_setValue // @grant GM_getValue // @run-at document-end // ==/UserScript== (function () { 'use strict'; // 중복 실행 방지 변수 let isInitialized = false; // 스타일 추가 - 아카라이브 디자인에 맞춘 개선된 GUI const style = document.createElement('style'); style.textContent = ` #manga-viewer-container { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: #1a1a1a; z-index: 10000; display: none; flex-direction: column; color: #ffffff; font-family: -apple-system, BlinkMacSystemFont, "Apple SD Gothic Neo", "Pretendard Variable", Pretendard, Roboto, "Noto Sans", "Segoe UI", "Malgun Gothic", "Apple Color Emoji", "Segoe UI Emoji", sans-serif; } #manga-viewer-header { height: 60px; display: flex; justify-content: space-between; align-items: center; padding: 0 20px; background: rgba(26, 26, 26, 0.95); backdrop-filter: blur(15px); border-bottom: 1px solid rgba(255, 255, 255, 0.08); box-shadow: 0 1px 8px rgba(0, 0, 0, 0.2); z-index: 10001; position: relative; } #manga-viewer-title { font-size: 14px; font-weight: 500; max-width: 40%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: default; transition: color 0.2s; display: flex; align-items: center; color: #f8f9fa; } #manga-viewer-title.has-series { cursor: pointer; } #manga-viewer-title.has-series:hover { color: #4facfe; } #manga-viewer-title.has-series:after { content: ""; display: inline-block; margin-left: 8px; border: solid #f8f9fa; border-width: 0 2px 2px 0; padding: 2px; transform: rotate(45deg); -webkit-transform: rotate(45deg); transition: transform 0.2s, border-color 0.2s; } #manga-viewer-title.has-series:hover:after { border-color: #4facfe; } #manga-viewer-title.has-series.open:after { transform: rotate(-135deg); -webkit-transform: rotate(-135deg); margin-top: 2px; } #manga-viewer-counter { font-size: 14px; font-weight: 500; position: absolute; left: 50%; transform: translateX(-50%); color: #f8f9fa; background: rgba(0, 0, 0, 0.4); padding: 4px 12px; border-radius: 12px; backdrop-filter: blur(8px); } #manga-viewer-close { cursor: pointer; padding: 8px 16px; background: rgba(248, 249, 250, 0.1); border: 1px solid rgba(248, 249, 250, 0.2); border-radius: 8px; font-size: 12px; font-weight: 500; color: #f8f9fa; transition: all 0.2s ease; backdrop-filter: blur(8px); } #manga-viewer-close:hover { background: rgba(248, 249, 250, 0.15); border-color: rgba(248, 249, 250, 0.3); transform: translateY(-1px); } #manga-viewer-content { flex: 1; display: flex; justify-content: center; align-items: center; overflow: hidden; position: relative; } #manga-viewer-image { max-width: 100%; max-height: 100%; object-fit: contain; transition: transform 0.2s ease-out; } .manga-viewer-nav { position: absolute; top: 0; height: 100%; width: 50%; cursor: pointer; display: flex; align-items: center; justify-content: center; opacity: 0; transition: opacity 0.3s, background 0.3s; color: rgba(255, 255, 255, 0.8); font-size: 2rem; font-weight: bold; } .manga-viewer-nav:hover { opacity: 0.15; background: radial-gradient(circle at center, rgba(79, 172, 254, 0.1) 0%, transparent 70%); } #manga-viewer-prev { left: 0; background: linear-gradient(to right, rgba(79, 172, 254, 0.05), transparent); } #manga-viewer-next { right: 0; background: linear-gradient(to left, rgba(79, 172, 254, 0.05), transparent); } .manga-nav-hint { opacity: 0; transition: opacity 0.5s; text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.8); } #manga-drawer-toggle { position: absolute; bottom: 20px; right: 20px; width: 50px; height: 50px; background: linear-gradient(135deg, rgba(79, 172, 254, 0.9) 0%, rgba(0, 242, 254, 0.9) 100%); border-radius: 50%; cursor: pointer; z-index: 10002; display: flex; justify-content: center; align-items: center; font-size: 20px; color: white; box-shadow: 0 4px 20px rgba(79, 172, 254, 0.4); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); border: 2px solid rgba(255, 255, 255, 0.1); backdrop-filter: blur(10px); } #manga-drawer-toggle:hover { background: linear-gradient(135deg, rgba(67, 163, 245, 0.95) 0%, rgba(0, 212, 230, 0.95) 100%); transform: scale(1.1); box-shadow: 0 6px 25px rgba(79, 172, 254, 0.6); border-color: rgba(255, 255, 255, 0.2); } #manga-drawer-toggle.active { transform: rotate(180deg) scale(1.05); background: linear-gradient(135deg, rgba(255, 100, 100, 0.9) 0%, rgba(255, 50, 150, 0.9) 100%); box-shadow: 0 4px 20px rgba(255, 100, 100, 0.4); } #manga-drawer { position: fixed; bottom: 0; left: 0; width: 100%; height: 0; background: linear-gradient(135deg, rgba(30, 30, 50, 0.95) 0%, rgba(40, 40, 60, 0.95) 100%); backdrop-filter: blur(15px); border-top: 2px solid rgba(79, 172, 254, 0.3); z-index: 10001; overflow-y: hidden; transition: height 0.3s cubic-bezier(0.4, 0, 0.2, 1); display: flex; flex-direction: row; flex-wrap: nowrap; padding: 0; overflow-x: auto; scrollbar-width: thin; scrollbar-color: rgba(79, 172, 254, 0.5) rgba(30, 30, 50, 0.3); justify-content: flex-start; align-items: center; box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.3); } scrollbar-width: thin; scrollbar-color: rgba(79, 172, 254, 0.5) rgba(30, 30, 50, 0.3); justify-content: flex-start; align-items: center; box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.3); } #manga-drawer.active { height: 120px; padding: 10px; } .manga-drawer-item { height: calc(100% - 20px); min-width: auto; max-height: 100px; margin-right: 10px; cursor: pointer; border: 2px solid transparent; transition: border-color 0.3s; position: relative; display: flex; align-items: center; } .manga-drawer-item.active { border-color: #f5f5f5; } .manga-drawer-item:hover { border-color: #666; } .manga-drawer-img { height: 100%; width: auto; object-fit: contain; max-height: 100%; } .manga-drawer-number { position: absolute; bottom: 0; left: 0; background-color: rgba(0, 0, 0, 0.7); padding: 2px 5px; font-size: 10px; } /* 스크롤바 스타일 */ #manga-drawer::-webkit-scrollbar { height: 6px; } #manga-drawer::-webkit-scrollbar-thumb { background-color: #666; border-radius: 3px; } #manga-drawer::-webkit-scrollbar-track { background-color: #333; } /* 뷰어 모드에서 불필요한 텍스트 제거 */ body.manga-viewer-active .notification-text, body.manga-viewer-active #removeAllBtn, body.manga-viewer-active .noti-text, body.manga-viewer-active .article-info, body.manga-viewer-active .btn-text { display: none !important; visibility: hidden !important; } /* 뷰어 모드 안에서 보여줄 요소만 남기기 */ body.manga-viewer-active * { visibility: hidden; } body.manga-viewer-active #manga-viewer-container, body.manga-viewer-active #manga-viewer-container * { visibility: visible; } /* 모바일 대응 */ @media (max-width: 768px) { #manga-drawer.active { height: 90px; padding-top: 18px; } .manga-drawer-item { height: calc(100% - 20px); max-height: 70px; min-width: auto; } #manga-viewer-counter { font-size: 12px; } #manga-drawer-toggle { width: 36px; height: 36px; font-size: 16px; bottom: 15px; right: 15px; } } /* 시리즈 목록 스타일 */ #manga-series-popup { position: absolute; top: 45px; left: 20px; background-color: rgba(30, 30, 30, 0.95); border-radius: 6px; padding: 15px; z-index: 10004; max-width: 85%; max-height: 80vh; overflow-y: auto; box-shadow: 0 5px 15px rgba(0, 0, 0, 0.5); display: none; min-width: 300px; } #manga-series-popup.active { display: block; } #manga-series-title { font-size: 16px; font-weight: bold; margin-bottom: 10px; padding-bottom: 5px; border-bottom: 1px solid #555; color: #fff; } .manga-series-item { padding: 10px; margin-bottom: 8px; border-radius: 4px; cursor: pointer; transition: background-color 0.2s; color: #ddd; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 14px; } .manga-series-item:hover { background-color: rgba(80, 80, 80, 0.6); } .manga-series-item.current { background-color: rgba(90, 90, 150, 0.4); color: #fff; font-weight: bold; } #manga-viewer-title { cursor: default; transition: color 0.2s; display: flex; align-items: center; } #manga-viewer-title.has-series { cursor: pointer; } #manga-viewer-title.has-series:hover { color: #a9d7ff; } #manga-viewer-title.has-series:after { content: ""; display: inline-block; margin-left: 5px; border: solid #fff; border-width: 0 2px 2px 0; padding: 3px; transform: rotate(45deg); -webkit-transform: rotate(45deg); transition: transform 0.2s, border-color 0.2s; } #manga-viewer-title.has-series:hover:after { border-color: #a9d7ff; } #manga-viewer-title.has-series.open:after { transform: rotate(-135deg); -webkit-transform: rotate(-135deg); margin-top: 5px; } } #manga-viewer-title.has-series:after { content: ""; display: inline-block; margin-left: 5px; border: solid #fff; border-width: 0 2px 2px 0; padding: 3px; transform: rotate(45deg); -webkit-transform: rotate(45deg); transition: transform 0.2s, border-color 0.2s; } #manga-viewer-title.has-series:hover:after { border-color: #a9d7ff; } #manga-viewer-title.has-series.open:after { transform: rotate(-135deg); -webkit-transform: rotate(-135deg); margin-top: 5px; } /* 모바일 대응 추가 */ @media (max-width: 768px) { #manga-series-popup { max-width: 90%; max-height: 70vh; } .manga-series-item { padding: 10px 8px; font-size: 13px; } #manga-series-title { font-size: 14px; } } /* 키보드 이벤트 감지용 투명 입력 요소 */ #manga-keyboard-catcher { position: fixed; top: -1000px; left: -1000px; width: 0; height: 0; opacity: 0; pointer-events: none; } /* 네비게이션 피드백 스타일 */ #manga-nav-feedback { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 3rem; color: rgba(255, 255, 255, 0.8); z-index: 10005; pointer-events: none; animation: navFeedbackAnim 0.3s ease-out; } @keyframes navFeedbackAnim { 0% { opacity: 0; transform: translate(-50%, -50%) scale(0.5); } 50% { opacity: 1; transform: translate(-50%, -50%) scale(1.2); } 100% { opacity: 0; transform: translate(-50%, -50%) scale(1); } } /* 키보드 단축키 도움말 스타일 */ #manga-help-popup { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: rgba(26, 26, 26, 0.98); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 12px; padding: 20px 30px; z-index: 10006; max-width: 400px; backdrop-filter: blur(15px); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); display: none; color: #f8f9fa; } #manga-help-popup.active { display: block; animation: helpFadeIn 0.3s ease-out; } @keyframes helpFadeIn { from { opacity: 0; transform: translate(-50%, -50%) scale(0.9); } to { opacity: 1; transform: translate(-50%, -50%) scale(1); } } #manga-help-title { font-size: 18px; font-weight: 600; margin-bottom: 15px; text-align: center; color: #4facfe; border-bottom: 1px solid rgba(255, 255, 255, 0.1); padding-bottom: 10px; } .manga-help-section { margin-bottom: 15px; } .manga-help-section h4 { font-size: 14px; font-weight: 600; margin-bottom: 8px; color: #a9d7ff; } .manga-help-item { display: flex; justify-content: space-between; align-items: center; margin-bottom: 5px; font-size: 13px; gap: 10px; } .manga-help-keys { display: flex; gap: 5px; flex-shrink: 0; } .manga-help-key { background: rgba(79, 172, 254, 0.2); border: 1px solid rgba(79, 172, 254, 0.3); border-radius: 4px; padding: 2px 8px; font-family: 'Courier New', monospace; font-size: 12px; color: #4facfe; min-width: 24px; text-align: center; flex-shrink: 0; } .manga-help-desc { color: #d0d0d0; margin-left: 15px; flex: 1; } #manga-help-close { position: absolute; top: 10px; right: 15px; background: none; border: none; color: #999; font-size: 20px; cursor: pointer; padding: 5px; transition: color 0.2s; } #manga-help-close:hover { color: #f8f9fa; } // ...existing code... `; document.head.appendChild(style); // 현재 보고 있는 이미지 인덱스와 이미지 배열 let currentImageIndex = 0; let images = []; let viewerActive = false; let drawerActive = false; let drawerHeight = 120; // 기본 서랍 높이 let articleTitle = ''; // 글 제목 저장용 변수 let seriesItems = []; // 시리즈 아이템 저장용 let seriesPopupOpen = false; // 시리즈 팝업 상태 let hasSeries = false; // 시리즈 존재 여부 확인 변수 let autoOpenFromSeries = false; // 시리즈 링크를 통해 진입했는지 확인하는 플래그 let isDrawerLoading = false; // 서랍 로딩 상태 let drawerImagesLoaded = {}; // 이미지 로딩 상태 추적 // 마지막으로 본 페이지 정보 저장용 키 생성 function getStorageKey() { const path = window.location.pathname; return `arcalive_manga_viewer_${path}`; } // 뷰어 상태 저장 function saveViewerState() { // 유효성 검사 추가 const validIndex = typeof currentImageIndex === 'number' && !isNaN(currentImageIndex) && currentImageIndex >= 0 && currentImageIndex < images.length ? currentImageIndex : 0; GM_setValue(getStorageKey(), { index: validIndex, drawerHeight: drawerHeight, title: articleTitle }); } // 뷰어 상태 로드 - 안정성 추가 function loadViewerState() { try { const savedState = GM_getValue(getStorageKey()); return savedState && typeof savedState === 'object' ? savedState : { index: 0, drawerHeight: 120, title: '' }; } catch (e) { console.log("상태 로드 오류:", e); return { index: 0, drawerHeight: 120, title: '' }; } } // 아카라이브 게시물인지 확인 function isArticlePage() { return location.pathname.includes('/b/') && location.pathname.split('/').length >= 4; } // 뷰어 요소 생성 function createViewerElements() { // 이미 존재하는 뷰어 컨테이너 확인 if (document.getElementById('manga-viewer-container')) { return; } const container = document.createElement('div'); container.id = 'manga-viewer-container'; container.innerHTML = ` <div id="manga-viewer-header"> <div id="manga-viewer-title"></div> <div id="manga-viewer-counter"></div> <div id="manga-viewer-close">닫기 (ESC)</div> <div id="manga-series-popup"> <div id="manga-series-title">시리즈</div> <div id="manga-series-list"></div> </div> </div> <div id="manga-viewer-content"> <img id="manga-viewer-image" src="" alt="만화 이미지"> <div id="manga-viewer-prev" class="manga-viewer-nav"> <div id="manga-nav-prev-hint" class="manga-nav-hint"><</div> </div> <div id="manga-viewer-next" class="manga-viewer-nav"> <div id="manga-nav-next-hint" class="manga-nav-hint">></div> </div> <div id="manga-drawer-toggle">≡</div> <div id="manga-drawer"></div> <input type="text" id="manga-keyboard-catcher" autocomplete="off"> </div> `; document.body.appendChild(container); // 이벤트 리스너 추가 document.getElementById('manga-viewer-close').addEventListener('click', closeViewer); document.getElementById('manga-viewer-prev').addEventListener('click', prevImage); document.getElementById('manga-viewer-next').addEventListener('click', nextImage); document.getElementById('manga-viewer-image').addEventListener('wheel', handleZoom); document.getElementById('manga-drawer-toggle').addEventListener('click', toggleDrawer); // 시리즈 팝업 기능 이벤트 추가 - 시리즈가 있을 때만 활성화됨 document.getElementById('manga-viewer-title').addEventListener('click', toggleSeriesPopup); // 키보드 이벤트용 숨겨진 입력 요소 설정 const keyboardCatcher = document.getElementById('manga-keyboard-catcher'); keyboardCatcher.addEventListener('keydown', handleKeyDown); // 전역 키보드 이벤트 캡처 (뷰어 활성화 시 모든 키보드 입력을 우선 처리) document.addEventListener('keydown', handleGlobalKeyDown, true); // capture phase document.addEventListener('keyup', handleGlobalKeyUp, true); // capture phase // 시리즈 팝업 외부 클릭시 닫기 document.addEventListener('click', function (event) { const seriesPopup = document.getElementById('manga-series-popup'); const viewerTitle = document.getElementById('manga-viewer-title'); if (seriesPopupOpen && !seriesPopup.contains(event.target) && event.target !== viewerTitle) { closeSeriesPopup(); } }); } // 키보드 포커스 강제 적용 함수 function focusKeyboardCatcher() { const keyboardCatcher = document.getElementById('manga-keyboard-catcher'); if (keyboardCatcher) { keyboardCatcher.focus(); // 포커스가 제대로 적용되도록 쓰레드 분리 setTimeout(() => { keyboardCatcher.focus(); }, 100); } } // 시리즈 목록 토글 function toggleSeriesPopup(event) { // 시리즈가 없으면 동작하지 않음 if (!hasSeries) return; event.stopPropagation(); const seriesPopup = document.getElementById('manga-series-popup'); const viewerTitle = document.getElementById('manga-viewer-title'); if (seriesPopupOpen) { closeSeriesPopup(); } else { // 시리즈 목록이 없으면 가져오기 시도 if (seriesItems.length === 0) { fetchSeriesItems(); } seriesPopupOpen = true; seriesPopup.classList.add('active'); viewerTitle.classList.add('open'); } } // 시리즈 팝업 닫기 function closeSeriesPopup() { const seriesPopup = document.getElementById('manga-series-popup'); const viewerTitle = document.getElementById('manga-viewer-title'); seriesPopupOpen = false; seriesPopup.classList.remove('active'); viewerTitle.classList.remove('open'); // 팝업 닫을 때 키보드 캐처에 포커스 focusKeyboardCatcher(); } // 시리즈 목록 가져오기 - HTML 구조에 맞게 완전히 개선된 버전 function fetchSeriesItems() { // 현재 페이지에서 시리즈 정보 추출 - 두 가지 구조 모두 확인 const seriesElement = document.querySelector('.article-series') || document.querySelector('.article-series.extend'); if (!seriesElement) { hasSeries = false; document.getElementById('manga-viewer-title').classList.remove('has-series'); return; } hasSeries = true; document.getElementById('manga-viewer-title').classList.add('has-series'); // 시리즈 제목 설정 const seriesNameElement = seriesElement.querySelector('.series-name'); let seriesTitle = '시리즈'; if (seriesNameElement) { seriesTitle = seriesNameElement.textContent.trim(); } else { // 시리즈 이름 요소가 없는 경우, 첫 번째 항목의 공통 부분을 추출 const firstSeriesItem = seriesElement.querySelector('.series-link a'); if (firstSeriesItem) { const text = firstSeriesItem.textContent.trim(); const match = text.match(/^(\d+)\.\s+(.+?)\s+Chapter/i); if (match && match[2]) { seriesTitle = match[2].trim(); } } } document.getElementById('manga-series-title').textContent = seriesTitle; // 시리즈 항목들 가져오기 - 두 가지 가능한 선택자를 모두 검사 let seriesLinks = Array.from(seriesElement.querySelectorAll('.series-link a')); if (!seriesLinks || seriesLinks.length === 0) { seriesLinks = Array.from(seriesElement.querySelectorAll('.vrow')); } if (!seriesLinks || seriesLinks.length === 0) return; // 현재 경로를 가져와서 현재 페이지를 찾기 위해 사용 const currentPath = window.location.pathname; // 시리즈 정보 정리 seriesItems = seriesLinks.map(item => { let link, title, isCurrent; if (item.tagName.toLowerCase() === 'a') { // series-link > a 타입인 경우 link = item.getAttribute('href'); title = item.textContent.trim().replace(/^\s*\d+\.\s*/, ''); // 앞의 번호와 점 제거 isCurrent = link === currentPath || link === window.location.href; } else { // vrow 타입인 경우 link = item.getAttribute('href'); title = item.textContent.trim().replace(/^\s*\d+\s*/, ''); // 앞의 번호 제거 isCurrent = item.classList.contains('active'); } // 현재 URL과 비교해서 현재 페이지 여부 추가 확인 if (!isCurrent && link) { isCurrent = currentPath === link || currentPath.endsWith(link); } return { link, title, isCurrent }; }); // 시리즈 목록 UI 생성 createSeriesList(); // 로그로 확인 console.log("시리즈 항목 추출 완료:", seriesItems); } // 시리즈 목록 UI 생성 function createSeriesList() { const seriesList = document.getElementById('manga-series-list'); seriesList.innerHTML = ''; seriesItems.forEach(item => { const itemElement = document.createElement('div'); itemElement.className = 'manga-series-item'; if (item.isCurrent) { itemElement.classList.add('current'); } itemElement.textContent = item.title; // 클릭 이벤트 - 새로운 시리즈 아이템으로 이동 시 자동 열기 파라미터 추가 // 단, 첫 페이지부터 보기 위한 파라미터로 수정 itemElement.addEventListener('click', function () { // 첫 페이지부터 보기 위한 파라미터로 수정 (이어서보기가 아닌 첫페이지부터) const url = new URL(item.link, window.location.origin); url.searchParams.set('manga_viewer_open', '1'); window.location.href = url.toString(); }); seriesList.appendChild(itemElement); }); } // 이미지 서랍 토글 - 지연 로딩 최적화 버전 function toggleDrawer() { const drawer = document.getElementById('manga-drawer'); const drawerToggle = document.getElementById('manga-drawer-toggle'); // 로딩 중이면 중복 실행 방지 if (isDrawerLoading) return; drawerActive = !drawerActive; // 성능 통계 업데이트 updatePerformanceStats('drawer'); if (drawerActive) { // 로딩 상태로 설정 isDrawerLoading = true; drawer.classList.add('active'); drawerToggle.classList.add('active'); drawer.style.height = `${drawerHeight}px`; // 서랍이 활성화될 때만 이미지 미리보기 로드 (최적화) loadDrawerPreviews(); // 로딩 완료 후 액션 setTimeout(() => { // 현재 이미지가 서랍의 중앙에 오도록 스크롤 scrollToCurrentPage(true); isDrawerLoading = false; // 로딩 상태 해제 }, 100); } else { drawer.classList.remove('active'); drawerToggle.classList.remove('active'); drawer.style.height = '0'; // 로딩 상태 해제 isDrawerLoading = false; } // 서랍이 활성화되면 토글 버튼 위치 조정 updateDrawerTogglePosition(); // 서랍 토글 시 키보드 포커스 복원 focusKeyboardCatcher(); }// 서랍 미리보기 이미지 로드 최적화 함수 function loadDrawerPreviews() { const drawer = document.getElementById('manga-drawer'); const drawerItems = drawer.querySelectorAll('.manga-drawer-item'); // 현재 표시되는 아이템만 이미지 로드 drawerItems.forEach((item, index) => { const img = item.querySelector('img'); if (img && !drawerImagesLoaded[index]) { // 현재 이미지 주변의 이미지만 로드 (±10) if (Math.abs(index - currentImageIndex) < 10) { img.src = images[index]; drawerImagesLoaded[index] = true; } } }); // 현재 스크롤 위치 주변 이미지 지연 로드 setTimeout(() => { const visibleStart = Math.max(0, currentImageIndex - 20); const visibleEnd = Math.min(images.length - 1, currentImageIndex + 20); for (let i = visibleStart; i <= visibleEnd; i++) { if (!drawerImagesLoaded[i]) { const img = drawerItems[i]?.querySelector('img'); if (img) { img.src = images[i]; drawerImagesLoaded[i] = true; } } } }, 500); } // 서랍 토글 버튼 위치 업데이트 function updateDrawerTogglePosition() { const drawerToggle = document.getElementById('manga-drawer-toggle'); const drawer = document.getElementById('manga-drawer'); if (drawerActive) { // 서랍이 열렸을 때는 서랍 위에 위치 (간격 유지) const drawerHeight = parseInt(drawer.style.height) || 120; drawerToggle.style.bottom = `${drawerHeight + 20}px`; } else { // 서랍이 닫혔을 때는 기본 위치 drawerToggle.style.bottom = '20px'; } }// 서랍 아이템 높이 업데이트 function updateDrawerItemsHeight() { const items = document.querySelectorAll('.manga-drawer-item'); const drawer = document.getElementById('manga-drawer'); const drawerHeight = drawer.clientHeight; // 패딩 고려 const itemHeight = Math.min(100, drawerHeight - 30); items.forEach(item => { item.style.height = `${itemHeight}px`; }); }// 현재 페이지가 가운데 오도록 스크롤 - 개선된 버전 function scrollToCurrentPage(smooth = false) { const activeItem = document.querySelector('.manga-drawer-item.active'); const drawer = document.getElementById('manga-drawer'); if (!activeItem || !drawer || !drawer.classList.contains('active')) return; // requestAnimationFrame을 이용한 더 부드러운 스크롤 requestAnimationFrame(() => { // 서랍의 너비와 스크롤 위치 계산 const drawerWidth = drawer.clientWidth; const itemRect = activeItem.getBoundingClientRect(); // 아이템 위치 계산 (중앙에 오도록) - 성능 최적화 const itemLeft = activeItem.offsetLeft; const scrollLeft = itemLeft - (drawerWidth / 2) + (itemRect.width / 2); // 스크롤 적용 drawer.scrollTo({ left: scrollLeft, behavior: smooth ? 'smooth' : 'auto' }); }); } // 뷰어 버튼 추가 function addViewerButton() { // article-link 요소 내부에 버튼 추가 const articleLinkContainer = document.querySelector('.article-link'); if (!articleLinkContainer) return; // 이미 버튼이 추가되었는지 확인 if (document.getElementById('manga-viewer-button') || document.getElementById('manga-viewer-continue')) { return; } // 만화모드 버튼 생성 const viewerButton = document.createElement('button'); viewerButton.id = 'manga-viewer-button'; viewerButton.className = 'btn btn-arca btn-sm'; viewerButton.textContent = '만화모드'; viewerButton.addEventListener('click', openViewer); // 이어서보기 버튼 생성 const continueButton = document.createElement('button'); continueButton.id = 'manga-viewer-continue'; continueButton.className = 'btn btn-arca btn-sm'; continueButton.textContent = '이어서보기'; continueButton.addEventListener('click', continueViewing); // 마지막으로 본 페이지가 없으면 이어서보기 버튼 비활성화 const savedState = loadViewerState(); if (!savedState || savedState.index === undefined) { continueButton.disabled = true; continueButton.style.opacity = '0.5'; continueButton.title = '이전에 본 기록이 없습니다'; } else { continueButton.title = `마지막으로 본 페이지: ${savedState.index + 1}`; } // 버튼들을 article-link 요소 내부의 첫 번째 위치에 삽입 articleLinkContainer.insertBefore(continueButton, articleLinkContainer.firstChild); articleLinkContainer.insertBefore(viewerButton, articleLinkContainer.firstChild); // 기존 아카라이브 스타일로 버튼 스타일 조정 const additionalStyle = document.createElement('style'); additionalStyle.textContent = ` #manga-viewer-button, #manga-viewer-continue { margin-right: 8px; margin-left: 0; } #manga-viewer-continue:disabled { opacity: 0.5; cursor: not-allowed; } .article-link { display: flex; align-items: center; } .article-link a { margin-left: auto; } `; document.head.appendChild(additionalStyle); } // 이미지 수집 function collectImages() { const articleContent = document.querySelector('.article-content'); if (!articleContent) return []; // 본문의 모든 이미지 수집 return Array.from(articleContent.querySelectorAll('img:not(.emoticon)')) .filter(img => { // 이모티콘 등의 작은 이미지 제외 const rect = img.getBoundingClientRect(); return rect.width > 100 || rect.height > 100; }) .map(img => img.src); } // 게시물 제목 가져오기 function getArticleTitle() { // 게시물 제목 요소 선택 const titleElement = document.querySelector('.article-head .title'); // 제목 요소가 있으면 내용을 반환, 없으면 기본값 반환 return titleElement ? titleElement.textContent.trim() : '만화 뷰어'; } // 이미지 서랍 생성 (성능 최적화 버전) function createImageDrawer() { const startTime = performance.now(); const drawer = document.getElementById('manga-drawer'); // 기존 항목 모두 제거 while (drawer.firstChild) { drawer.removeChild(drawer.firstChild); } // IntersectionObserver 초기화 initLazyLoading(); // 각 이미지에 대한 미리보기 추가 (지연 로딩 적용) images.forEach((src, index) => { const item = document.createElement('div'); item.className = 'manga-drawer-item'; if (index === currentImageIndex) { item.classList.add('active'); } const img = document.createElement('img'); img.className = 'manga-drawer-img'; img.alt = `Image ${index + 1}`; // 현재 이미지와 주변 5개 이미지만 즉시 로드, 나머지는 지연 로딩 if (Math.abs(index - currentImageIndex) <= 5) { img.src = src; loadedImages.add(src); } else { img.dataset.src = src; img.src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMSIgaGVpZ2h0PSIxIiB2aWV3Qm94PSIwIDAgMSAxIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxyZWN0IHdpZHRoPSIxIiBoZWlnaHQ9IjEiIGZpbGw9IiMzMzMiLz48L3N2Zz4='; if (drawerObserver) { drawerObserver.observe(img); } } const number = document.createElement('div'); number.className = 'manga-drawer-number'; number.textContent = index + 1; item.appendChild(img); item.appendChild(number); // 클릭 시 해당 이미지로 이동 item.addEventListener('click', () => { currentImageIndex = index; updateViewer(); }); drawer.appendChild(item); }); // 열린 상태에서는 아이템 높이 조정 if (drawerActive) { updateDrawerItemsHeight(); } // 성능 로그 performanceLog('서랍 생성', startTime); // 메모리 정리 cleanupImageCache(); } // 이어서보기 기능 function continueViewing() { const savedState = loadViewerState(); if (!savedState || savedState.index === undefined) { alert('이전에 본 기록이 없습니다.'); return; } images = collectImages(); if (images.length === 0) { alert('표시할 이미지가 없습니다.'); return; } // 저장된 인덱스 또는 첫 페이지로 설정 currentImageIndex = (savedState.index >= 0 && savedState.index < images.length) ? savedState.index : 0; // 저장된 서랍 높이 복원 if (savedState.drawerHeight) { drawerHeight = savedState.drawerHeight; } // 저장된 제목 복원 또는 현재 제목 가져오기 articleTitle = savedState.title || getArticleTitle(); const container = document.getElementById('manga-viewer-container'); container.style.display = 'flex'; document.body.style.overflow = 'hidden'; // 스크롤 방지 // 제목 설정 document.getElementById('manga-viewer-title').textContent = articleTitle; // 이미지 서랍 생성 createImageDrawer(); // 네비게이션 힌트 표시 showNavigationHints(); updateViewer(); viewerActive = true; // 전역 키보드 이벤트를 추가하는 방식을 변경 window.addEventListener('keydown', handleKeyDown); // 뷰어 활성화 시 body에 클래스 추가 document.body.classList.add('manga-viewer-active'); // 서랍 핸들 위치 업데이트 const drawerHandle = document.getElementById('manga-drawer-handle'); if (drawerHandle) { if (drawerActive) { drawerHandle.style.bottom = `${drawerHeight}px`; } else { drawerHandle.style.bottom = "0px"; } } // 제목 요소 초기 상태 설정 const viewerTitle = document.getElementById('manga-viewer-title'); if (viewerTitle) { viewerTitle.classList.remove('has-series'); viewerTitle.classList.remove('open'); } // 시리즈 정보 확인 - DOM에서 직접 검색 setTimeout(() => { fetchSeriesItems(); }, 200); // DOM이 완전히 준비된 후 실행하기 위해 약간의 지연 추가 } // 뷰어 열기 function openViewer() { images = collectImages(); if (images.length === 0) { alert('표시할 이미지가 없습니다.'); return; } // 시리즈 정보 초기화 seriesItems = []; seriesPopupOpen = false; hasSeries = false; // 제목 요소 초기 상태 설정 const viewerTitle = document.getElementById('manga-viewer-title'); if (viewerTitle) { viewerTitle.classList.remove('has-series'); viewerTitle.classList.remove('open'); } // 저장된 상태 불러오기 const savedState = loadViewerState(); // 이전에 저장된 서랍 높이 복원 if (savedState && savedState.drawerHeight) { drawerHeight = savedState.drawerHeight; } currentImageIndex = 0; // 현재 게시물 제목 가져오기 articleTitle = getArticleTitle(); const container = document.getElementById('manga-viewer-container'); container.style.display = 'flex'; document.body.style.overflow = 'hidden'; // 스크롤 방지 // 제목 설정 document.getElementById('manga-viewer-title').textContent = articleTitle; // 이미지 서랍 생성 createImageDrawer(); // 네비게이션 힌트 요소 보였다가 숨기기 showNavigationHints(); updateViewer(); viewerActive = true; // 전역 키보드 이벤트를 추가하는 방식을 변경 window.addEventListener('keydown', handleKeyDown); // 뷰어 활성화 시 body에 클래스 추가 document.body.classList.add('manga-viewer-active'); // 서랍 핸들 위치 업데이트 const drawerHandle = document.getElementById('manga-drawer-handle'); if (drawerHandle) { if (drawerActive) { drawerHandle.style.bottom = `${drawerHeight}px`; } else { drawerHandle.style.bottom = "0px"; } } // 시리즈 정보 확인 - DOM에서 직접 검색 setTimeout(() => { fetchSeriesItems(); }, 200); // DOM이 완전히 준비된 후 실행하기 위해 약간의 지연 추가 } // 네비게이션 힌트 표시 후 숨기기 function showNavigationHints() { const prevHint = document.getElementById('manga-nav-prev-hint'); const nextHint = document.getElementById('manga-nav-next-hint'); prevHint.style.opacity = '1'; nextHint.style.opacity = '1'; setTimeout(() => { prevHint.style.opacity = '0'; nextHint.style.opacity = '0'; // 트랜지션 추가 prevHint.style.transition = 'opacity 0.5s'; nextHint.style.transition = 'opacity 0.5s'; }, 1500); } // 네비게이션 피드백 표시 function showNavigationFeedback(direction) { const existingFeedback = document.getElementById('manga-nav-feedback'); if (existingFeedback) { existingFeedback.remove(); } const feedback = document.createElement('div'); feedback.id = 'manga-nav-feedback'; feedback.textContent = direction === 'prev' ? '◀' : '▶'; feedback.style.cssText = ` position: fixed; top: 50%; left: ${direction === 'prev' ? '20%' : '80%'}; transform: translate(-50%, -50%); font-size: 3rem; color: rgba(255, 255, 255, 0.8); z-index: 10005; pointer-events: none; animation: navFeedbackAnim 0.3s ease-out; `; // 애니메이션 CSS 추가 (한 번만) if (!document.getElementById('nav-feedback-style')) { const style = document.createElement('style'); style.id = 'nav-feedback-style'; style.textContent = ` @keyframes navFeedbackAnim { 0% { opacity: 0; transform: translate(-50%, -50%) scale(0.5); } 50% { opacity: 1; transform: translate(-50%, -50%) scale(1.2); } 100% { opacity: 0; transform: translate(-50%, -50%) scale(1); } } `; document.head.appendChild(style); } document.body.appendChild(feedback); // 300ms 후 제거 setTimeout(() => { if (feedback.parentNode) { feedback.remove(); } }, 300); } // 뷰어 닫기 function closeViewer() { // 현재 상태 저장 saveViewerState(); // 이어서보기 버튼 업데이트 const continueButton = document.getElementById('manga-viewer-continue'); if (continueButton) { continueButton.disabled = false; continueButton.style.opacity = '1'; continueButton.title = `마지막으로 본 페이지: ${currentImageIndex + 1}`; } const container = document.getElementById('manga-viewer-container'); container.style.display = 'none'; document.body.style.overflow = ''; // 스크롤 복원 viewerActive = false; drawerActive = false; // 서랍 토글 버튼 위치 초기화 const drawerToggle = document.getElementById('manga-drawer-toggle'); if (drawerToggle) { drawerToggle.style.bottom = '20px'; } // 전역 키보드 이벤트 제거 document.removeEventListener('keydown', handleGlobalKeyDown, true); document.removeEventListener('keyup', handleGlobalKeyUp, true); window.removeEventListener('keydown', handleKeyDown); // 도움말 팝업이 열려있으면 닫기 hideHelpPopup(); // body에서 뷰어 활성화 클래스 제거 document.body.classList.remove('manga-viewer-active'); // 서랍 핸들 위치 초기화 const drawerHandle = document.getElementById('manga-drawer-handle'); if (drawerHandle) { drawerHandle.style.bottom = "0px"; } } // 이전 이미지로 이동 function prevImage() { if (currentImageIndex > 0) { currentImageIndex--; updateViewer(); } } // 다음 이미지로 이동 function nextImage() { if (currentImageIndex < images.length - 1) { currentImageIndex++; updateViewer(); } } // 이미지 프리로딩 및 캐싱 시스템 let imageCache = new Map(); let preloadQueue = []; let isPreloading = false; // 이미지 프리로드 함수 function preloadImage(src) { return new Promise((resolve, reject) => { if (imageCache.has(src)) { resolve(imageCache.get(src)); return; } const img = new Image(); img.onload = () => { imageCache.set(src, img); resolve(img); }; img.onerror = reject; img.src = src; }); } // 주변 이미지들을 백그라운드에서 프리로드 function preloadAdjacentImages(centerIndex, radius = 3) { if (isPreloading) return; isPreloading = true; const startIndex = Math.max(0, centerIndex - radius); const endIndex = Math.min(images.length - 1, centerIndex + radius); // 프리로드 큐 생성 (현재 이미지 제외) preloadQueue = []; for (let i = startIndex; i <= endIndex; i++) { if (i !== centerIndex && !imageCache.has(images[i])) { preloadQueue.push(images[i]); } } // 순차적으로 프리로드 (브라우저 부담 최소화) processPreloadQueue(); } function processPreloadQueue() { if (preloadQueue.length === 0) { isPreloading = false; return; } const imageSrc = preloadQueue.shift(); preloadImage(imageSrc) .then(() => { // 100ms 간격으로 다음 이미지 프리로드 setTimeout(() => processPreloadQueue(), 100); }) .catch(() => { // 에러 시 다음 이미지로 진행 setTimeout(() => processPreloadQueue(), 100); }); } // 뷰어 업데이트 (성능 최적화 버전) function updateViewer() { const img = document.getElementById('manga-viewer-image'); const currentImageSrc = images[currentImageIndex]; // 캐시된 이미지가 있으면 즉시 표시, 없으면 로드 if (imageCache.has(currentImageSrc)) { img.src = currentImageSrc; } else { // 로딩 인디케이터 표시 showLoadingIndicator(); preloadImage(currentImageSrc) .then(() => { img.src = currentImageSrc; hideLoadingIndicator(); }) .catch(() => { hideLoadingIndicator(); console.error('이미지 로드 실패:', currentImageSrc); }); } img.style.transform = 'scale(1)'; // 이미지 변경 시 줌 리셋 currentScale = 1; // 카운터 업데이트 document.getElementById('manga-viewer-counter').textContent = `${currentImageIndex + 1} / ${images.length}`; // 주변 이미지 프리로드 시작 preloadAdjacentImages(currentImageIndex); // 이미지 서랍 활성 항목 업데이트 updateDrawerActiveItem(); // 상태 저장 (디바운싱) clearTimeout(saveStateTimeout); saveStateTimeout = setTimeout(saveViewerState, 1000); } let saveStateTimeout = null; // 로딩 인디케이터 관련 함수들 function showLoadingIndicator() { let indicator = document.getElementById('manga-loading-indicator'); if (!indicator) { indicator = document.createElement('div'); indicator.id = 'manga-loading-indicator'; indicator.innerHTML = '로딩 중...'; indicator.style.cssText = ` position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); background: rgba(0, 0, 0, 0.8); color: white; padding: 10px 20px; border-radius: 5px; z-index: 10003; font-size: 14px; `; document.getElementById('manga-viewer-content').appendChild(indicator); } indicator.style.display = 'block'; } function hideLoadingIndicator() { const indicator = document.getElementById('manga-loading-indicator'); if (indicator) { indicator.style.display = 'none'; } } // 서랍 활성 항목 업데이트 (별도 함수로 분리하여 성능 최적화) function updateDrawerActiveItem() { const drawerItems = document.querySelectorAll('.manga-drawer-item'); // 서랍에 아이템이 없으면 작업 건너뛰기 if (drawerItems.length === 0) return; drawerItems.forEach((item, index) => { if (index === currentImageIndex) { item.classList.add('active'); // 서랍이 활성화된 경우에만 스크롤 if (drawerActive) { // 페이지 변경 시 현재 이미지가 중앙에 오도록 스크롤 scrollToCurrentPage(true); } } else { item.classList.remove('active'); } }); } // 전역 키보드 이벤트 핸들러 - 뷰어 활성화 시 사이트 기본 키보드 동작 차단 function handleGlobalKeyDown(e) { // 뷰어가 비활성화되어 있으면 기본 동작 허용 if (!viewerActive) return; // 입력 필드나 편집 가능한 요소에서는 뷰어 키보드 처리 건너뛰기 const activeElement = document.activeElement; const isEditableElement = activeElement && ( activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA' || activeElement.contentEditable === 'true' || activeElement.isContentEditable ); if (isEditableElement) return; // 뷰어에서 처리하는 키들 정의 const viewerKeys = [ 'arrowleft', 'arrowright', 'arrowup', 'arrowdown', 'a', 'd', 'w', 's', 'q', 'f', 'r', 'h', 'home', 'end', 'pageup', 'pagedown', 'escape', 'tab', ' ' ]; const keyLower = e.key.toLowerCase(); // 뷰어가 처리하는 키인 경우 기본 동작과 전파를 차단 if (viewerKeys.includes(keyLower)) { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); // 뷰어 키보드 핸들러로 이벤트 전달 handleKeyDown(e); return false; } // Ctrl 조합키 차단 (F11 제외) if (e.ctrlKey && e.key !== 'F11') { const ctrlKeys = ['a', 'c', 'v', 'x', 'z', 'y', 'f', 's']; if (ctrlKeys.includes(keyLower)) { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); return false; } } } // 전역 키업 이벤트 핸들러 function handleGlobalKeyUp(e) { if (!viewerActive) return; const activeElement = document.activeElement; const isEditableElement = activeElement && ( activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA' || activeElement.contentEditable === 'true' || activeElement.isContentEditable ); if (isEditableElement) return; // 뷰어 키들에 대해서는 keyup도 차단 const viewerKeys = [ 'arrowleft', 'arrowright', 'arrowup', 'arrowdown', 'a', 'd', 'w', 's', 'q', 'f', 'r', 'h', 'home', 'end', 'pageup', 'pagedown', 'escape', 'tab', ' ' ]; const keyLower = e.key.toLowerCase(); if (viewerKeys.includes(keyLower)) { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); return false; } } // 키보드 이벤트 처리 최적화 let keyPressTimeout = null; let lastKeyPressTime = 0; const KEY_DEBOUNCE_DELAY = 30; // 30ms 디바운싱으로 더 반응성 개선 let keyRepeatInterval = null; function handleKeyDown(e) { // 뷰어가 활성화되어 있을 때만 처리 if (!viewerActive) return; const currentTime = Date.now(); // 키 입력이 너무 빠르게 연속으로 들어오는 것을 방지 (단, 화살표 키는 예외) const isNavigationKey = ['arrowleft', 'arrowright', 'a', 'd'].includes(e.key.toLowerCase()); if (!isNavigationKey && currentTime - lastKeyPressTime < KEY_DEBOUNCE_DELAY) { return; } lastKeyPressTime = currentTime; // 입력 필드에서 키를 누른 경우 무시 (보안 강화) const activeElement = document.activeElement; const isEditableElement = activeElement && ( activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA' || activeElement.contentEditable === 'true' || activeElement.isContentEditable ); if (isEditableElement) return; // 모든 뷰어 키에 대해 기본 동작 차단 const viewerKeys = [ 'arrowleft', 'arrowright', 'arrowup', 'arrowdown', 'a', 'd', 'w', 's', 'q', 'f', 'r', 'h', 'home', 'end', 'pageup', 'pagedown', 'escape', 'tab', ' ' ]; const keyLower = e.key.toLowerCase(); if (viewerKeys.includes(keyLower)) { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); } // 시리즈 팝업이 열려있으면 특정 키만 처리 if (seriesPopupOpen) { if (e.key === 'Escape') { closeSeriesPopup(); e.preventDefault(); } return; } switch (keyLower) { case 'arrowleft': case 'a': // 이전 페이지로 이동 (향상된 반응성) if (currentImageIndex > 0) { prevImage(); showNavigationFeedback('prev'); } break; case 'arrowright': case 'd': // 다음 페이지로 이동 (향상된 반응성) if (currentImageIndex < images.length - 1) { nextImage(); showNavigationFeedback('next'); } break; case 'w': case 'pageup': prevImage(); break; case 's': case 'pagedown': case ' ': // 스페이스바로도 다음 이미지 nextImage(); break; case 'arrowup': // 위쪽 화살표로 첫 번째 이미지로 이동 if (currentImageIndex > 0) { currentImageIndex = 0; updateViewer(); } break; case 'arrowdown': // 아래쪽 화살표로 마지막 이미지로 이동 if (currentImageIndex < images.length - 1) { currentImageIndex = images.length - 1; updateViewer(); } break; case 'home': // Home 키로 첫 번째 이미지 if (currentImageIndex > 0) { currentImageIndex = 0; updateViewer(); } break; case 'end': // End 키로 마지막 이미지 if (currentImageIndex < images.length - 1) { currentImageIndex = images.length - 1; updateViewer(); } break; case 'escape': // 시리즈 팝업이 열려있으면 팝업만 닫기 if (seriesPopupOpen) { closeSeriesPopup(); } else { closeViewer(); } break; case 'q': case 'tab': toggleDrawer(); break; case 'f': // F키로 전체화면 토글 toggleFullscreen(); break; case 'r': // R키로 이미지 새로고침 refreshCurrentImage(); break; case 'h': // H키로 도움말 표시 showHelpPopup(); break; } // 성능 통계 업데이트 updatePerformanceStats('keypress'); } // 전체화면 토글 기능 function toggleFullscreen() { if (!document.fullscreenElement) { document.documentElement.requestFullscreen().catch(err => { console.log(`전체화면 전환 실패: ${err.message}`); }); } else { if (document.exitFullscreen) { document.exitFullscreen(); } } } // 현재 이미지 새로고침 function refreshCurrentImage() { if (images.length > 0 && currentImageIndex >= 0 && currentImageIndex < images.length) { const img = document.getElementById('manga-viewer-image'); const currentSrc = img.src; img.src = ''; setTimeout(() => { img.src = currentSrc + '?refresh=' + Date.now(); }, 100); } } // 도움말 팝업 표시/숨기기 function showHelpPopup() { // 기존 도움말 팝업이 있으면 제거 const existingPopup = document.getElementById('manga-help-popup'); if (existingPopup) { hideHelpPopup(); return; } // 도움말 팝업 HTML 생성 const helpPopup = document.createElement('div'); helpPopup.id = 'manga-help-popup'; helpPopup.className = 'active'; helpPopup.innerHTML = ` <button id="manga-help-close">×</button> <div id="manga-help-title">⌨️ 키보드 단축키 도움말</div> <div class="manga-help-section"> <h4>📖 페이지 네비게이션</h4> <div class="manga-help-item"> <span class="manga-help-desc">이전 페이지</span> <div class="manga-help-keys"> <span class="manga-help-key">A</span> <span class="manga-help-key">←</span> </div> </div> <div class="manga-help-item"> <span class="manga-help-desc">다음 페이지</span> <div class="manga-help-keys"> <span class="manga-help-key">D</span> <span class="manga-help-key">→</span> </div> </div> <div class="manga-help-item"> <span class="manga-help-desc">이전 페이지 (대안)</span> <div class="manga-help-keys"> <span class="manga-help-key">W</span> <span class="manga-help-key">PgUp</span> </div> </div> <div class="manga-help-item"> <span class="manga-help-desc">다음 페이지 (대안)</span> <div class="manga-help-keys"> <span class="manga-help-key">S</span> <span class="manga-help-key">PgDn</span> <span class="manga-help-key">Space</span> </div> </div> </div> <div class="manga-help-section"> <h4>🎯 빠른 이동</h4> <div class="manga-help-item"> <span class="manga-help-desc">첫 번째 페이지</span> <div class="manga-help-keys"> <span class="manga-help-key">↑</span> <span class="manga-help-key">Home</span> </div> </div> <div class="manga-help-item"> <span class="manga-help-desc">마지막 페이지</span> <div class="manga-help-keys"> <span class="manga-help-key">↓</span> <span class="manga-help-key">End</span> </div> </div> </div> <div class="manga-help-section"> <h4>🔧 뷰어 기능</h4> <div class="manga-help-item"> <span class="manga-help-desc">서랍 토글</span> <div class="manga-help-keys"> <span class="manga-help-key">Q</span> <span class="manga-help-key">Tab</span> </div> </div> <div class="manga-help-item"> <span class="manga-help-desc">전체화면</span> <div class="manga-help-keys"> <span class="manga-help-key">F</span> </div> </div> <div class="manga-help-item"> <span class="manga-help-desc">이미지 새로고침</span> <div class="manga-help-keys"> <span class="manga-help-key">R</span> </div> </div> <div class="manga-help-item"> <span class="manga-help-desc">뷰어 닫기</span> <div class="manga-help-keys"> <span class="manga-help-key">ESC</span> </div> </div> <div class="manga-help-item"> <span class="manga-help-desc">도움말 토글</span> <div class="manga-help-keys"> <span class="manga-help-key">H</span> </div> </div> </div> <div class="manga-help-section"> <h4>🖱️ 마우스 기능</h4> <div class="manga-help-item"> <span class="manga-help-desc">페이지 네비게이션</span> <div class="manga-help-keys"> <span class="manga-help-key">휠 스크롤</span> </div> </div> <div class="manga-help-item"> <span class="manga-help-desc">확대/축소</span> <div class="manga-help-keys"> <span class="manga-help-key">Ctrl + 휠</span> </div> </div> </div> `; // 뷰어 컨테이너에 추가 document.getElementById('manga-viewer-container').appendChild(helpPopup); // 닫기 버튼 이벤트 document.getElementById('manga-help-close').addEventListener('click', hideHelpPopup); // 전역 키보드 이벤트 - ESC나 H로 도움말 닫기 document.addEventListener('keydown', handleHelpKeyDown); } // 도움말 팝업 숨기기 function hideHelpPopup() { const helpPopup = document.getElementById('manga-help-popup'); if (helpPopup) { helpPopup.remove(); document.removeEventListener('keydown', handleHelpKeyDown); // 키보드 캐처에 포커스 복원 focusKeyboardCatcher(); } } // 도움말 팝업이 열려있을 때의 키보드 이벤트 처리 function handleHelpKeyDown(e) { if (e.key === 'Escape' || e.key.toLowerCase() === 'h') { hideHelpPopup(); e.preventDefault(); e.stopPropagation(); } } // 확대/축소 기능 let currentScale = 1; const scaleStep = 0.1; const minScale = 0.5; const maxScale = 3; // 마우스 휠 이벤트 처리 (페이지 네비게이션 + 줌) - 개선된 버전 let wheelTimeouts = new Map(); let wheelSensitivity = 100; // 휠 감도 임계값 function handleZoom(e) { e.preventDefault(); // Ctrl 키를 누른 상태에서는 줌 기능 if (e.ctrlKey) { const zoomIn = e.deltaY < 0; if (zoomIn && currentScale < maxScale) { currentScale = Math.min(currentScale + scaleStep, maxScale); } else if (!zoomIn && currentScale > minScale) { currentScale = Math.max(currentScale - scaleStep, minScale); } document.getElementById('manga-viewer-image').style.transform = `scale(${currentScale})`; } else { // 일반 휠 스크롤은 페이지 네비게이션 (디바운싱 적용) const direction = e.deltaY < 0 ? 'up' : 'down'; // 이전 타임아웃 제거 if (wheelTimeouts.has(direction)) { clearTimeout(wheelTimeouts.get(direction)); } // 짧은 지연 후 네비게이션 실행 (빠른 연속 스크롤 방지) const timeout = setTimeout(() => { if (Math.abs(e.deltaY) > wheelSensitivity) { if (direction === 'up') { // 위로 스크롤 -> 이전 페이지 if (currentImageIndex > 0) { prevImage(); showNavigationFeedback('prev'); } } else { // 아래로 스크롤 -> 다음 페이지 if (currentImageIndex < images.length - 1) { nextImage(); showNavigationFeedback('next'); } } } wheelTimeouts.delete(direction); }, 50); wheelTimeouts.set(direction, timeout); } } // 로컬 스토리지에서 시리즈 자동 전환 상태 관리 function getSeriesAutoOpenKey() { return 'arcalive_manga_viewer_series_auto_open'; } function checkAndAutoOpenFromSeries() { // URL 파라미터에서 시리즈 이동 여부 확인 const urlParams = new URLSearchParams(window.location.search); if (urlParams.has('manga_from_series')) { autoOpenFromSeries = true; // 페이지 로드 후 약간 지연시켜서 만화 뷰어 모드를 실행 setTimeout(() => { // 페이지가 완전히 로드된 후 이어서보기 실행 continueViewing(); }, 500); // URL에서 파라미터 제거 (히스토리는 유지) window.history.replaceState({}, document.title, window.location.pathname + window.location.hash); } } // 스크립트 초기화 function init() { // 이미 초기화되었으면 중복 실행 방지 if (isInitialized) return; if (!isArticlePage()) return; isInitialized = true; console.log('아카라이브 만화 뷰어 초기화'); // 시리즈 링크를 통한 자동 열기 기능 확인 checkAndAutoOpenFromSeries(); // DOM이 완전히 로드된 후에 실행 if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', function () { createViewerElements(); addViewerButton(); }); } else { createViewerElements(); addViewerButton(); } // 모바일 환경 감지 및 터치 이벤트 추가 if ('ontouchstart' in window) { console.log('모바일 환경 감지: 터치 이벤트 추가'); // 뷰어가 생성된 후에 터치 이벤트 추가 setTimeout(() => { const viewerContent = document.getElementById('manga-viewer-content'); if (viewerContent) { viewerContent.addEventListener('touchstart', handleTouchStart, { passive: false }); viewerContent.addEventListener('touchmove', handleTouchMove, { passive: false }); viewerContent.addEventListener('touchend', handleTouchEnd, { passive: false }); } }, 100); } }// 터치 이벤트 처리 (모바일용) - 개선된 버전 let touchStartX = 0; let touchStartY = 0; let touchStartTime = 0; let touchMoved = false; let isDoubleTap = false; let lastTouchTime = 0; function handleTouchStart(e) { const touch = e.changedTouches[0]; touchStartX = touch.screenX; touchStartY = touch.screenY; touchStartTime = Date.now(); touchMoved = false; // 더블탭 감지 const currentTime = Date.now(); if (currentTime - lastTouchTime < 300) { isDoubleTap = true; e.preventDefault(); // 더블탭 시 줌 방지 } else { isDoubleTap = false; } lastTouchTime = currentTime; } function handleTouchMove(e) { const touch = e.changedTouches[0]; const moveX = Math.abs(touch.screenX - touchStartX); const moveY = Math.abs(touch.screenY - touchStartY); // 5px 이상 움직였다면 스와이프로 간주 if (moveX > 5 || moveY > 5) { touchMoved = true; } } function handleTouchEnd(e) { if (!touchMoved && isDoubleTap) { // 더블탭으로 서랍 토글 toggleDrawer(); return; } if (!touchMoved || Date.now() - touchStartTime > 800) { // 탭이나 너무 긴 터치는 무시 return; } const touch = e.changedTouches[0]; const touchEndX = touch.screenX; const touchEndY = touch.screenY; const diffX = touchEndX - touchStartX; const diffY = touchEndY - touchStartY; // 세로 스와이프가 더 크면 무시 (세로 스크롤) if (Math.abs(diffY) > Math.abs(diffX)) { return; } const minSwipeDistance = 80; const fastSwipeTime = 300; const isFastSwipe = Date.now() - touchStartTime < fastSwipeTime; // 빠른 스와이프는 더 짧은 거리로도 인정 const effectiveMinDistance = isFastSwipe ? minSwipeDistance * 0.6 : minSwipeDistance; if (Math.abs(diffX) > effectiveMinDistance) { if (diffX > 0) { prevImage(); // 오른쪽으로 스와이프 -> 이전 이미지 } else { nextImage(); // 왼쪽으로 스와이프 -> 다음 이미지 } } } // 페이지 변경을 감지하여 스크립트 재초기화 (SPA 대응) let lastUrl = location.href; new MutationObserver(() => { if (location.href !== lastUrl) { lastUrl = location.href; isInitialized = false; setTimeout(init, 1000); // 페이지 전환 후 1초 뒤에 다시 초기화 } }).observe(document, { subtree: true, childList: true }); init(); // IntersectionObserver를 이용한 지연 로딩 최적화 let drawerObserver = null; let loadedImages = new Set(); // 이미지 지연 로딩 초기화 function initLazyLoading() { if ('IntersectionObserver' in window) { drawerObserver = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { const img = entry.target; const realSrc = img.dataset.src; if (realSrc && !loadedImages.has(realSrc)) { img.src = realSrc; loadedImages.add(realSrc); drawerObserver.unobserve(img); } } }); }, { rootMargin: '100px' // 100px 전에 미리 로드 }); } } // 메모리 관리 - 사용하지 않는 이미지 캐시 정리 function cleanupImageCache() { if (imageCache.size > 50) { // 50개 이상 캐시되면 정리 const entries = Array.from(imageCache.entries()); // 오래된 순서로 절반 정리 (LRU 방식) const toDelete = entries.slice(0, entries.length / 2); toDelete.forEach(([key]) => { imageCache.delete(key); }); } } // 성능 모니터링 function performanceLog(action, startTime) { if (console.time && performance.now) { const duration = performance.now() - startTime; console.log(`[만화뷰어] ${action}: ${duration.toFixed(2)}ms`); } } // 성능 모니터링 시스템 추가 let performanceStats = { keyPressCount: 0, imageLoadCount: 0, drawerToggleCount: 0, startTime: Date.now() }; // 성능 통계 업데이트 function updatePerformanceStats(action) { switch (action) { case 'keypress': performanceStats.keyPressCount++; break; case 'imageload': performanceStats.imageLoadCount++; break; case 'drawer': performanceStats.drawerToggleCount++; break; } } // 성능 통계 출력 (디버그용) function logPerformanceStats() { const runTime = Date.now() - performanceStats.startTime; console.log(`[만화뷰어 성능 통계] 실행시간: ${Math.floor(runTime / 1000)}초`); console.log(`키 입력: ${performanceStats.keyPressCount}회`); console.log(`이미지 로드: ${performanceStats.imageLoadCount}회`); console.log(`서랍 토글: ${performanceStats.drawerToggleCount}회`); } })();