您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
디시인사이드 갤러리 단축키: 글번호(1~100), ` or . + 숫자키 + ` or . 이동, ALT+숫자 즐겨찾기, W(글쓰기), C(댓글), D(새로고침), R(리로드), Q(최상단), E(목록), F(전체글), G(개념글), A/S(페이지), Z/X(글 이동)
// ==UserScript== // @name dcinside shortcut // @namespace http://tampermonkey.net/ // @version 1.2.0 // @description 디시인사이드 갤러리 단축키: 글번호(1~100), ` or . + 숫자키 + ` or . 이동, ALT+숫자 즐겨찾기, W(글쓰기), C(댓글), D(새로고침), R(리로드), Q(최상단), E(목록), F(전체글), G(개념글), A/S(페이지), Z/X(글 이동) // @author 노노하꼬 // @match *://gall.dcinside.com/* // @match *://www.dcinside.com/ // @icon https://www.google.com/s2/favicons?sz=64&domain=dcinside.com // @grant GM_setValue // @grant GM_getValue // @license CC BY-NC-SA 4.0 // @supportURL https://gallog.dcinside.com/nonohako/guestbook // ==/UserScript== (function() { 'use strict'; // Constants const FAVORITE_GALLERIES_KEY = 'dcinside_favorite_galleries'; const isTampermonkey = typeof GM_setValue !== 'undefined' && typeof GM_getValue !== 'undefined'; const PAGE_NAVIGATION_MODE_KEY = 'dcinside_page_navigation_mode'; const MACRO_Z_RUNNING_KEY = 'dcinside_macro_z_running'; const MACRO_X_RUNNING_KEY = 'dcinside_macro_x_running'; const MACRO_INTERVAL = 2500; // 2.5 seconds // Storage Module const Storage = { async getPageNavigationMode() { const defaultValue = 'ajax'; // 기본값은 AJAX 모드로 설정 if (isTampermonkey) { return GM_getValue(PAGE_NAVIGATION_MODE_KEY, defaultValue); } else { const data = localStorage.getItem(PAGE_NAVIGATION_MODE_KEY) || this.getCookie(PAGE_NAVIGATION_MODE_KEY); return data !== null ? data : defaultValue; // 저장된 값이 없으면 기본값 반환 } }, savePageNavigationMode(mode) { try { if (mode !== 'ajax' && mode !== 'full') { console.error('Invalid page navigation mode:', mode); return; } if (isTampermonkey) { GM_setValue(PAGE_NAVIGATION_MODE_KEY, mode); } else { localStorage.setItem(PAGE_NAVIGATION_MODE_KEY, mode); this.setCookie(PAGE_NAVIGATION_MODE_KEY, mode); } } catch (error) { console.error('Failed to save page navigation mode:', error); } }, async getFavorites() { let favorites = {}; try { if (isTampermonkey) { favorites = GM_getValue(FAVORITE_GALLERIES_KEY, {}); } else { const data = localStorage.getItem(FAVORITE_GALLERIES_KEY) || this.getCookie(FAVORITE_GALLERIES_KEY); favorites = data ? JSON.parse(data) : {}; } } catch (error) { console.error('Failed to retrieve favorites:', error); } return favorites; }, saveFavorites(favorites) { try { const data = JSON.stringify(favorites); if (isTampermonkey) { GM_setValue(FAVORITE_GALLERIES_KEY, favorites); } else { localStorage.setItem(FAVORITE_GALLERIES_KEY, data); this.setCookie(FAVORITE_GALLERIES_KEY, data); } } catch (error) { console.error('Failed to save favorites:', error); alert('즐겨찾기 저장에 실패했습니다. 브라우저의 저장소 설정을 확인해주세요.'); } }, getCookie(name) { const value = document.cookie.match(`(^|;)\\s*${name}=([^;]+)`); return value ? decodeURIComponent(value[2]) : null; }, setCookie(name, value) { const date = new Date(); date.setFullYear(date.getFullYear() + 1); document.cookie = `${name}=${encodeURIComponent(value)}; expires=${date.toUTCString()}; path=/; domain=.dcinside.com`; }, async getAltNumberEnabled() { if (isTampermonkey) { return GM_getValue('altNumberEnabled', true); // 기본값: 활성화 } else { const data = localStorage.getItem('altNumberEnabled') || this.getCookie('altNumberEnabled'); return data !== null ? JSON.parse(data) : true; } }, saveAltNumberEnabled(enabled) { try { const data = JSON.stringify(enabled); if (isTampermonkey) { GM_setValue('altNumberEnabled', enabled); } else { localStorage.setItem('altNumberEnabled', data); this.setCookie('altNumberEnabled', data); } } catch (error) { console.error('Failed to save altNumberEnabled:', error); } }, async getShortcutEnabled(key) { if (isTampermonkey) { return GM_getValue(key, true); } else { const data = localStorage.getItem(key) || this.getCookie(key); return data !== null ? JSON.parse(data) : true; } }, saveShortcutEnabled(key, enabled) { try { const data = JSON.stringify(enabled); if (isTampermonkey) { GM_setValue(key, enabled); } else { localStorage.setItem(key, data); this.setCookie(key, data); } } catch (error) { console.error(`Failed to save ${key}:`, error); } }, async getShortcutKey(key) { if (isTampermonkey) { return GM_getValue(key, null); } else { const data = localStorage.getItem(key) || this.getCookie(key); return data !== null ? data : null; } }, saveShortcutKey(key, value) { try { if (isTampermonkey) { GM_setValue(key, value); } else { localStorage.setItem(key, value); this.setCookie(key, value); } } catch (error) { console.error(`Failed to save ${key}:`, error); } } }; // UI Module const UI = { tooltipCSSInjected: false, // CSS 주입 여부 플래그 // CSS를 페이지에 주입하는 함수 injectTooltipCSS() { if (this.tooltipCSSInjected) return; // 이미 주입되었으면 실행 안 함 const css = ` /* 툴팁을 감싸는 컨테이너 (상대 위치 기준점) */ .footnote-container { position: relative; /* 자식 tooltip의 absolute 위치 기준 */ display: inline-block; /* span이면서 위치 기준이 되도록 */ cursor: help; } /* 실제 툴팁 요소 */ .footnote-tooltip { position: absolute; bottom: 115%; /* 트리거 요소 바로 위에 위치 */ left: 50%; /* 가로 중앙 정렬 시작점 */ transform: translateX(-50%); /* 가로 중앙 정렬 완료 */ background-color: rgba(0, 0, 0, 0.85); /* 반투명 검정 배경 */ color: white; /* 흰색 텍스트 */ padding: 6px 10px; /* 내부 여백 */ border-radius: 5px; /* 둥근 모서리 */ font-size: 12px; /* 작은 글씨 크기 */ white-space: nowrap; /* 줄바꿈 방지 */ z-index: 10001; /* 다른 요소 위에 표시되도록 */ visibility: hidden; /* 기본적으로 숨김 (visibility) */ opacity: 0; /* 기본적으로 투명 (opacity) */ transition: opacity 0.1s ease-in-out, visibility 0.1s ease-in-out; /* 부드러운 효과 */ pointer-events: none; /* 툴팁 자체가 마우스 이벤트 방해하지 않도록 */ } /* 컨테이너에 마우스 호버 시 툴팁 표시 */ .footnote-container:hover .footnote-tooltip { visibility: visible; /* 보이기 (visibility) */ opacity: 1; /* 불투명 (opacity) */ } `; // style 태그를 만들어 head에 추가 const styleElement = this.createElement('style', {}, { textContent: css }); document.head.appendChild(styleElement); this.tooltipCSSInjected = true; // 주입 완료 플래그 설정 }, createPageNavigationModeSelector() { this.injectTooltipCSS(); // <<< 함수 시작 시 CSS 주입 함수 호출 const container = this.createElement('div', { margin: '15px 0', padding: '10px', backgroundColor: '#f5f5f5', borderRadius: '10px', border: '1px solid #e0e0e0' }); const title = this.createElement('div', { fontSize: '14px', fontWeight: '500', color: '#424242', marginBottom: '10px' }, { textContent: '페이지 이동 방식 (A/S 키)' }); container.appendChild(title); const optionsContainer = this.createElement('div', { display: 'flex', justifyContent: 'space-around', alignItems: 'center' // alignItems 추가 }); const modes = [ { value: 'ajax', text: '⚡ 빠른 이동 (AJAX)' }, { value: 'full', text: '🔄 기본 이동 (새로고침)' } ]; const tooltipText = "Refresher의 새로고침 기능과 충돌합니다. 둘 중에 하나만 사용하세요."; // 툴팁 텍스트 Storage.getPageNavigationMode().then(currentMode => { modes.forEach(modeInfo => { const label = this.createElement('label', { display: 'flex', alignItems: 'center', cursor: 'pointer', fontSize: '13px', color: '#555', gap: '5px' // 라디오 버튼과 텍스트 사이 간격 }); const radio = this.createElement('input', { // marginRight: '5px' // gap으로 대체 }, { type: 'radio', name: 'pageNavMode', value: modeInfo.value }); if (modeInfo.value === currentMode) { radio.checked = true; } radio.addEventListener('change', async (e) => { if (e.target.checked) { await Storage.savePageNavigationMode(e.target.value); UI.showAlert(`페이지 이동 방식이 '${modeInfo.text.split(' ')[0]}' 모드로 변경되었습니다.`); // 텍스트 간소화 } }); label.appendChild(radio); label.appendChild(document.createTextNode(modeInfo.text)); // 라디오 버튼 텍스트 추가 // --- AJAX 옵션에만 각주 및 툴팁 추가 --- if (modeInfo.value === 'ajax') { // 1. 컨테이너 span 생성 (상대 위치 기준, 호버 타겟) const footnoteContainer = this.createElement('span', { // 스타일은 CSS 클래스로 이동 }, { className: 'footnote-container' }); // 2. 트리거 텍스트 '[주의]' span 생성 const footnoteTrigger = this.createElement('span', { fontSize: '10px', color: '#d32f2f', fontWeight: 'bold', verticalAlign: 'super', marginLeft: '3px' // title 속성 제거 }, { textContent: '[주의]' }); // 3. 실제 툴팁 내용 span 생성 const tooltipElement = this.createElement('span', { // 스타일은 CSS 클래스로 이동 }, { className: 'footnote-tooltip', textContent: tooltipText }); // 4. 컨테이너에 트리거와 툴팁 추가 footnoteContainer.appendChild(footnoteTrigger); footnoteContainer.appendChild(tooltipElement); // 5. 라벨에 최종 컨테이너 추가 label.appendChild(footnoteContainer); } // --- 커스텀 툴팁 적용 끝 --- optionsContainer.appendChild(label); }); }); container.appendChild(optionsContainer); return container; }, createElement(tag, styles, props = {}) { const el = document.createElement(tag); Object.assign(el.style, styles); Object.assign(el, props); return el; }, async showFavorites() { const container = this.createElement('div', { position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', backgroundColor: '#ffffff', padding: '20px', borderRadius: '16px', boxShadow: '0 8px 24px rgba(0,0,0,0.15)', zIndex: '10000', width: '360px', maxHeight: '80vh', overflowY: 'auto', fontFamily: "'Roboto', sans-serif", border: '1px solid #e0e0e0', transition: 'opacity 0.2s ease-in-out', opacity: '0' }); setTimeout(() => container.style.opacity = '1', 10); this.loadRobotoFont(); container.appendChild(this.createTitle()); const list = this.createList(); container.appendChild(list); container.appendChild(this.createAddContainer()); container.appendChild(this.createToggleAltNumber()); // 새로 추가: 토글 버튼 container.appendChild(this.createShortcutManagerButton()); container.appendChild(this.createCloseButton(container)); document.body.appendChild(container); await this.updateFavoritesList(list); }, loadRobotoFont() { if (!document.querySelector('link[href*="Roboto"]')) { document.head.appendChild(this.createElement('link', {}, { rel: 'stylesheet', href: 'https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap' })); } }, createToggleAltNumber() { const container = this.createElement('div', { display: 'flex', alignItems: 'center', justifyContent: 'space-between', margin: '15px 0', padding: '10px', backgroundColor: '#f5f5f5', borderRadius: '10px' }); const label = this.createElement('span', { fontSize: '14px', fontWeight: '500', color: '#424242' }, { textContent: 'ALT + 숫자 단축키 사용' }); const checkbox = this.createElement('input', { marginLeft: 'auto' }, { type: 'checkbox' }); Storage.getAltNumberEnabled().then(enabled => { checkbox.checked = enabled; }); checkbox.addEventListener('change', async () => { await Storage.saveAltNumberEnabled(checkbox.checked); UI.showAlert(`ALT + 숫자 단축키가 ${checkbox.checked ? '활성화' : '비활성화'}되었습니다.`); }); container.appendChild(label); container.appendChild(checkbox); return container; }, createShortcutManagerButton() { const button = this.createElement('button', { display: 'block', width: '100%', padding: '10px', marginTop: '15px', backgroundColor: '#4caf50', color: '#ffffff', border: 'none', borderRadius: '10px', fontSize: '15px', fontWeight: '500', cursor: 'pointer', transition: 'background-color 0.2s ease' }, { textContent: '단축키 관리' }); button.addEventListener('mouseenter', () => button.style.backgroundColor = '#388e3c'); button.addEventListener('mouseleave', () => button.style.backgroundColor = '#4caf50'); button.addEventListener('click', () => this.showShortcutManager()); return button; }, showShortcutManager() { const container = this.createElement('div', { position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', backgroundColor: '#ffffff', padding: '20px', borderRadius: '16px', boxShadow: '0 8px 24px rgba(0,0,0,0.15)', zIndex: '10000', width: '400px', maxHeight: '80vh', overflowY: 'auto', fontFamily: "'Roboto', sans-serif", border: '1px solid #e0e0e0', transition: 'opacity 0.2s ease-in-out', opacity: '0' }); setTimeout(() => container.style.opacity = '1', 10); this.loadRobotoFont(); container.appendChild(this.createTitle('단축키 관리')); container.appendChild(this.createPageNavigationModeSelector()); // 단축키 활성화/비활성화 토글 추가 container.appendChild(this.createShortcutToggle('W - 글쓰기', 'shortcutWEnabled')); container.appendChild(this.createShortcutToggle('C - 댓글 입력', 'shortcutCEnabled')); container.appendChild(this.createShortcutToggle('D - 댓글 새로고침', 'shortcutDEnabled')); container.appendChild(this.createShortcutToggle('R - 페이지 새로고침', 'shortcutREnabled')); container.appendChild(this.createShortcutToggle('Q - 최상단 스크롤', 'shortcutQEnabled')); container.appendChild(this.createShortcutToggle('E - 글 목록 스크롤', 'shortcutEEnabled')); container.appendChild(this.createShortcutToggle('F - 전체글 보기', 'shortcutFEnabled')); container.appendChild(this.createShortcutToggle('G - 개념글 보기', 'shortcutGEnabled')); container.appendChild(this.createShortcutToggle('A - 이전 페이지', 'shortcutAEnabled')); container.appendChild(this.createShortcutToggle('S - 다음 페이지', 'shortcutSEnabled')); container.appendChild(this.createShortcutToggle('Z - 이전 글', 'shortcutZEnabled')); container.appendChild(this.createShortcutToggle('X - 다음 글', 'shortcutXEnabled')); // --- Add Macro Toggles --- container.appendChild(this.createElement('div', { // 구분선 height: '1px', backgroundColor: '#e0e0e0', margin: '15px 0' })); container.appendChild(this.createElement('div', { // 섹션 제목 fontSize: '14px', fontWeight: '500', color: '#424242', marginBottom: '5px' }, { textContent: '자동 넘김 매크로 (ALT+키로 시작/중지)' })); // --- 레이블 수정 --- container.appendChild(this.createShortcutToggle('이전 글 자동 넘김', 'shortcutMacroZEnabled')); // "ALT+Z - " 제거 container.appendChild(this.createShortcutToggle('다음 글 자동 넘김', 'shortcutMacroXEnabled')); // "ALT+X - " 제거 // --- End Add Macro Toggles --- container.appendChild(this.createCloseButton(container)); document.body.appendChild(container); }, createShortcutToggle(label, enabledStorageKey) { const isMacroToggle = enabledStorageKey.startsWith('shortcutMacro'); let displayLabel = label; // 표시될 최종 레이블 // 매크로 토글인 경우, 레이블에서 단축키 정보 분리 let prefix = ''; if (isMacroToggle) { if (enabledStorageKey === 'shortcutMacroZEnabled') { prefix = 'ALT+Z - '; // displayLabel = "이전 글 자동 넘김"; // label 인수에서 분리된 부분 } else if (enabledStorageKey === 'shortcutMacroXEnabled') { prefix = 'ALT+X - '; // displayLabel = "다음 글 자동 넘김"; // label 인수에서 분리된 부분 } // label 인수 자체에 "ALT+Z - 이전 글..." 이 포함된 경우, prefix 제거 if (displayLabel.startsWith(prefix)) { displayLabel = displayLabel.substring(prefix.length).trim(); } } const container = this.createElement('div', { display: 'flex', alignItems: 'center', margin: '10px 0', padding: '10px', backgroundColor: '#f5f5f5', borderRadius: '10px', gap: '10px' // 요소 간 간격 }); // 접두사(ALT+Z/X)를 별도 span으로 처리하여 너비 고정 (선택적) if (prefix) { const prefixEl = this.createElement('span', { fontSize: '14px', fontWeight: '500', color: '#666', // 약간 연한 색상 // fontFamily: 'monospace', // 고정폭 글꼴 사용 가능 minWidth: '60px', // 최소 너비 확보 textAlign: 'right', // 오른쪽 정렬 (선택적) marginRight: '5px' // 레이블과의 간격 }, { textContent: prefix }); container.appendChild(prefixEl); } const labelEl = this.createElement('span', { fontSize: '14px', fontWeight: '500', color: '#424242', // width: '150px', // 고정 너비 제거 또는 조정 flexGrow: '1', // 남은 공간 차지하도록 설정 textOverflow: 'ellipsis', // 넘칠 때 ... 표시 whiteSpace: 'nowrap' // 한 줄로 표시 강제 }, { textContent: displayLabel }); // 수정된 displayLabel 사용 const checkbox = this.createElement('input', { // marginLeft: 'auto' // flexGrow 사용 시 불필요할 수 있음, gap으로 대체됨 flexShrink: 0 // 체크박스 크기 줄어들지 않도록 }, { type: 'checkbox' }); const keyInput = this.createElement('input', { width: '60px', padding: '5px', border: '1px solid #e0e0e0', borderRadius: '4px', fontSize: '12px', outline: 'none', textAlign: 'center', fontFamily: 'monospace', flexShrink: 0 // 키 입력 필드 크기 줄어들지 않도록 }, { type: 'text', placeholder: '키 변경', maxLength: '1' }); if (isMacroToggle) { keyInput.style.display = 'none'; // 매크로는 키 변경 불가 } // --- 각주(툴팁) 추가 로직 (ALT+Z 매크로에만) --- if (enabledStorageKey === 'shortcutMacroZEnabled') { this.injectTooltipCSS(); // 툴팁 CSS 주입 보장 const tooltipText = "AMD 아드레날린, 지포스 익스피리언스 단축키와 중복시 사용 불가"; const footnoteContainer = this.createElement('span', {}, { className: 'footnote-container' }); const footnoteTrigger = this.createElement('span', { fontSize: '10px', color: '#d32f2f', fontWeight: 'bold', verticalAlign: 'super', marginLeft: '3px', cursor: 'help' }, { textContent: '[주의]' }); const tooltipElement = this.createElement('span', {}, { className: 'footnote-tooltip', textContent: tooltipText }); footnoteContainer.appendChild(footnoteTrigger); footnoteContainer.appendChild(tooltipElement); // labelEl 옆에 각주 컨테이너 추가 labelEl.appendChild(footnoteContainer); } // --- 각주(툴팁) 추가 로직 끝 --- const keyStorageKey = enabledStorageKey.replace('Enabled', 'Key'); const allCustomizableShortcutKeyStorageKeys = [ /* ... */ ]; const defaultKey = isMacroToggle ? null : keyStorageKey.slice(-4, -3); if (!isMacroToggle) { Storage.getShortcutKey(keyStorageKey).then(savedKey => { keyInput.placeholder = savedKey || defaultKey; }); } // Checkbox logic (sessionStorage 클리어 포함) Storage.getShortcutEnabled(enabledStorageKey).then(enabled => { checkbox.checked = enabled; if (isMacroToggle) { const storageKey = enabledStorageKey === 'shortcutMacroZEnabled' ? MACRO_Z_RUNNING_KEY : MACRO_X_RUNNING_KEY; if (!enabled && sessionStorage.getItem(storageKey) === 'true') { sessionStorage.setItem(storageKey, 'false'); } } }); checkbox.addEventListener('change', async () => { await Storage.saveShortcutEnabled(enabledStorageKey, checkbox.checked); // 레이블 텍스트 조합하여 알림 메시지 생성 const fullLabelForAlert = prefix ? `${prefix}${displayLabel}` : displayLabel; UI.showAlert(`${fullLabelForAlert} 기능이 ${checkbox.checked ? '활성화' : '비활성화'}되었습니다.`); if (!checkbox.checked && isMacroToggle) { const storageKey = enabledStorageKey === 'shortcutMacroZEnabled' ? MACRO_Z_RUNNING_KEY : MACRO_X_RUNNING_KEY; sessionStorage.setItem(storageKey, 'false'); console.log(`${prefix}Macro state cleared via UI toggle.`); } }); // 키 입력 이벤트 리스너 (수정된 충돌 검사 로직 포함) if (!isMacroToggle) { keyInput.addEventListener('keydown', async (e) => { // async 추가 // 편집/탐색 키 허용 if (['Backspace', 'Delete', 'ArrowLeft', 'ArrowRight', 'Tab', 'Home', 'End'].includes(e.key)) { return; } // 다른 모든 키 입력 시 기본 동작 방지 e.preventDefault(); e.stopPropagation(); const newKey = e.key.toUpperCase(); if (newKey.length === 1 && /^[A-Z]$/.test(newKey)) { let isConflict = false; let conflictingActionLabel = ''; // 충돌 기능 레이블 저장 // 충돌 검사 로직 for (const otherStorageKey of allCustomizableShortcutKeyStorageKeys) { if (otherStorageKey === keyStorageKey) continue; // 자기 자신 제외 const otherDefault = otherStorageKey.slice(-4, -3); const otherSavedKey = await Storage.getShortcutKey(otherStorageKey); const currentlyAssignedKey = otherSavedKey || otherDefault; if (currentlyAssignedKey === newKey) { isConflict = true; // 충돌된 기능의 레이블 찾기 (더 사용자 친화적인 메시지 위해) // 이 부분은 실제 구현 시 단축키 목록과 레이블을 매핑하는 구조가 필요할 수 있음 // 여기서는 간단히 기본 키로 표시 conflictingActionLabel = `기본키 ${otherDefault}`; break; } } if (isConflict) { UI.showAlert(`'${newKey}' 단축키는 이미 다른 기능(${conflictingActionLabel})에 할당되어 있습니다.`); keyInput.value = ''; } else { await Storage.saveShortcutKey(keyStorageKey, newKey); UI.showAlert(`${label} 단축키가 '${newKey}'(으)로 변경되었습니다.`); keyInput.placeholder = newKey; keyInput.value = ''; } } else if (newKey.length === 1) { UI.showAlert("단축키는 영문 대문자(A-Z)만 가능합니다."); keyInput.value = ''; } }); // 포커스 이벤트 리스너 keyInput.addEventListener('focus', () => { /* 필요 시 플래그 설정 */ }); keyInput.addEventListener('blur', () => { }); } // 요소들을 컨테이너에 추가 container.appendChild(labelEl); container.appendChild(checkbox); if (!isMacroToggle) { // Only append input if not macro container.appendChild(keyInput); } return container; }, createTitle() { return this.createElement('h3', { fontSize: '18px', fontWeight: '700', color: '#212121', margin: '0 0 15px 0', paddingBottom: '10px', borderBottom: '1px solid #e0e0e0' }, { textContent: '즐겨찾는 갤러리' }); }, createList() { return this.createElement('ul', { listStyle: 'none', margin: '0', padding: '0', maxHeight: '50vh', overflowY: 'auto' }); }, async updateFavoritesList(list) { list.innerHTML = ''; const favorites = await Storage.getFavorites(); Object.entries(favorites).forEach(([key, gallery]) => { list.appendChild(this.createFavoriteItem(key, gallery)); }); }, createFavoriteItem(key, gallery) { const item = this.createElement('li', { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 15px', margin: '5px 0', backgroundColor: '#fafafa', borderRadius: '10px', transition: 'background-color 0.2s ease', cursor: 'pointer' }); item.addEventListener('mouseenter', () => item.style.backgroundColor = '#f0f0f0'); item.addEventListener('mouseleave', () => item.style.backgroundColor = '#fafafa'); item.addEventListener('click', () => this.navigateToGallery(gallery)); // Ensure we display the gallery name properly const name = gallery.name || gallery.galleryName || gallery.galleryId || 'Unknown Gallery'; item.appendChild(this.createElement('span', { fontSize: '15px', fontWeight: '400', color: '#424242', flexGrow: '1', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }, { textContent: `${key}: ${name}` })); item.appendChild(this.createRemoveButton(key)); return item; }, createRemoveButton(key) { const button = this.createElement('button', { backgroundColor: 'transparent', color: '#757575', border: 'none', borderRadius: '50%', width: '24px', height: '24px', fontSize: '16px', lineHeight: '1', cursor: 'pointer', transition: 'color 0.2s ease, background-color 0.2s ease' }, { textContent: '✕' }); button.addEventListener('mouseenter', () => { button.style.color = '#d32f2f'; button.style.backgroundColor = '#ffebee'; }); button.addEventListener('mouseleave', () => { button.style.color = '#757575'; button.style.backgroundColor = 'transparent'; }); button.addEventListener('click', async (e) => { e.stopPropagation(); const favorites = await Storage.getFavorites(); delete favorites[key]; Storage.saveFavorites(favorites); await this.updateFavoritesList(button.closest('ul')); }); return button; }, createAddContainer() { const container = this.createElement('div', { display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '8px', margin: '15px 0', padding: '15px', backgroundColor: '#f5f5f5', borderRadius: '10px' }); const input = this.createElement('input', { width: '45px', padding: '8px', border: '1px solid #e0e0e0', borderRadius: '8px', fontSize: '14px', textAlign: 'center', outline: 'none', transition: 'border-color 0.2s ease', backgroundColor: '#ffffff' }, { type: 'text', placeholder: '0-9' }); input.addEventListener('focus', () => input.style.borderColor = '#1976d2'); input.addEventListener('blur', () => input.style.borderColor = '#e0e0e0'); const button = this.createElement('button', { padding: '8px 16px', backgroundColor: '#1976d2', color: '#ffffff', border: 'none', borderRadius: '8px', fontSize: '14px', fontWeight: '500', cursor: 'pointer', transition: 'background-color 0.2s ease', flexGrow: '1' }, { textContent: '즐겨찾기 추가' }); button.addEventListener('mouseenter', () => button.style.backgroundColor = '#1565c0'); button.addEventListener('mouseleave', () => button.style.backgroundColor = '#1976d2'); button.addEventListener('click', (e) => { e.stopPropagation(); const digit = input.value.trim(); if (!/^[0-9]$/.test(digit)) { alert('0부터 9까지의 숫자를 입력해주세요.'); return; } Gallery.handleFavoriteKey(digit); input.value = ''; }); container.appendChild(input); container.appendChild(button); return container; }, createCloseButton(container) { const button = this.createElement('button', { display: 'block', width: '100%', padding: '10px', marginTop: '15px', backgroundColor: '#1976d2', color: '#ffffff', border: 'none', borderRadius: '10px', fontSize: '15px', fontWeight: '500', cursor: 'pointer', transition: 'background-color 0.2s ease' }, { textContent: 'Close' }); button.addEventListener('mouseenter', () => button.style.backgroundColor = '#1565c0'); button.addEventListener('mouseleave', () => button.style.backgroundColor = '#1976d2'); button.addEventListener('click', () => { container.style.opacity = '0'; setTimeout(() => document.body.removeChild(container), 200); }); return button; }, navigateToGallery(gallery) { const url = gallery.galleryType === 'board' ? `https://gall.dcinside.com/board/lists?id=${gallery.galleryId}` : `https://gall.dcinside.com/${gallery.galleryType}/board/lists?id=${gallery.galleryId}`; window.location.href = url; }, showAlert(message) { const alert = this.createElement('div', { position: 'fixed', top: '20px', left: '50%', transform: 'translateX(-50%)', backgroundColor: 'rgba(0, 0, 0, 0.8)', color: 'white', padding: '15px 20px', borderRadius: '8px', fontSize: '14px', zIndex: '10000', transition: 'opacity 0.3s ease' }, { textContent: message }); document.body.appendChild(alert); setTimeout(() => { alert.style.opacity = '0'; setTimeout(() => document.body.removeChild(alert), 300); }, 2000); } }; // Gallery Module const Gallery = { isMainPage() { const { href } = window.location; return href.includes('/lists') && href.includes('id='); }, getInfo() { if (!this.isMainPage()) return { galleryType: '', galleryId: '', galleryName: '' }; const { href } = window.location; const galleryType = href.includes('/person/') ? 'person' : href.includes('mgallery') ? 'mgallery' : href.includes('mini') ? 'mini' : 'board'; const galleryId = href.match(/id=([^&]+)/)?.[1] || ''; const nameEl = document.querySelector('div.fl.clear h2 a'); const galleryName = nameEl ? Array.from(nameEl.childNodes) .filter(node => node.nodeType === Node.TEXT_NODE) .map(node => node.textContent.trim()) .join('') || galleryId : galleryId; return { galleryType, galleryId, galleryName }; }, async handleFavoriteKey(key) { const favorites = await Storage.getFavorites(); const info = this.getInfo(); if (favorites[key]) { UI.navigateToGallery(favorites[key]); } else if (this.isMainPage()) { // Ensure galleryName is saved as 'name' for UI compatibility favorites[key] = { galleryType: info.galleryType, galleryId: info.galleryId, name: info.galleryName }; Storage.saveFavorites(favorites); UI.showAlert(`${info.galleryName}이(가) ${key}번에 등록되었습니다.`); const list = document.querySelector('ul[style*="max-height: 50vh"]'); if (list) await UI.updateFavoritesList(list); } else { alert('즐겨찾기 등록은 갤러리 메인 페이지에서만 가능합니다.'); } }, getPageInfo() { const { href } = window.location; const galleryType = href.includes('mgallery') ? 'mgallery' : href.includes('mini') ? 'mini' : href.includes('person') ? 'person' : 'board'; const galleryId = href.match(/id=([^&]+)/)?.[1] || ''; const currentPage = parseInt(href.match(/page=(\d+)/)?.[1] || '1', 10); const isRecommendMode = href.includes('exception_mode=recommend'); return { galleryType, galleryId, currentPage, isRecommendMode }; } }; // Post Navigation Module const Posts = { isValidPost(numCell, titleCell, subjectCell) { if (!numCell || !titleCell) return false; const row = numCell.closest('tr'); if (row?.classList.contains('block-disable') || row?.classList.contains('list_trend') || row?.style.display === 'none') return false; const numText = numCell.textContent.trim().replace(/\[\d+\]\s*|\[\+\d+\]\s*|\[\-\d+\]\s*/, ''); if (['AD', '공지', '설문', 'Notice'].includes(numText) || isNaN(numText)) return false; if (titleCell.querySelector('em.icon_notice')) return false; if (subjectCell?.textContent.trim().match(/AD|공지|설문|뉴스|고정|이슈/)) return false; return true; }, getValidPosts() { const rows = document.querySelectorAll('table.gall_list tbody tr'); const validPosts = []; let currentIndex = -1; rows.forEach((row, index) => { const numCell = row.querySelector('td.gall_num'); const titleCell = row.querySelector('td.gall_tit'); const subjectCell = row.querySelector('td.gall_subject'); if (!this.isValidPost(numCell, titleCell, subjectCell)) return; const link = titleCell.querySelector('a:first-child'); if (link) { validPosts.push({ row, link }); if (numCell.querySelector('.sp_img.crt_icon')) currentIndex = validPosts.length - 1; } }); return { validPosts, currentIndex }; }, addNumberLabels() { // 1. 페이지 전체에서 유효한 게시글 목록을 가져옵니다. // getValidPosts는 내부적으로 관련 테이블들을 찾아 처리해야 합니다. const { validPosts } = this.getValidPosts(); // console.log(`addNumberLabels: Found ${validPosts.length} total valid posts.`); // 디버깅 // 2. 찾은 모든 유효 게시글에 대해 라벨 추가 시도 (최대 100개) validPosts.slice(0, 100).forEach((post, i) => { const numCell = post.row.querySelector('td.gall_num'); if (!numCell) return; // 번호 셀 없으면 건너뛰기 // --- 중요: 중복 라벨 방지 체크 --- // 이 번호 셀('numCell') 내부에 이미 '.number-label'이 있는지 확인합니다. if (numCell.querySelector('span.number-label')) { // console.log("Skipping already labeled cell:", numCell.textContent); // 디버깅 return; // 이미 라벨이 있으면 이 셀에 대한 작업 중단 } // --- 체크 끝 --- // 현재 보고 있는 글 아이콘이 있으면 라벨 추가 안 함 if (numCell.querySelector('.sp_img.crt_icon')) return; // 라벨 생성 및 추가 const label = UI.createElement('span', { color: '#ff6600', fontWeight: 'bold', marginRight: '3px' }, { className: 'number-label', textContent: `[${i + 1}]` }); numCell.prepend(label); }); }, navigate(number) { const { validPosts } = this.getValidPosts(); const index = parseInt(number, 10) - 1; if (index >= 0 && index < validPosts.length) { validPosts[index].link.click(); return true; } return false; }, formatDates() { // 오늘 날짜를 'YYYY-MM-DD' 형식으로 가져옵니다. const today = new Date(); const todayDateString = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`; // 아직 포맷되지 않은 '.gall_date' 셀들을 선택합니다. const dateCells = document.querySelectorAll('td.gall_date:not(.date-formatted)'); dateCells.forEach(dateCell => { // title 속성에 전체 타임스탬프가 있는지 확인합니다. if (dateCell.title) { const fullTimestamp = dateCell.title; // 예: "2025-04-08 17:03:17" // 정규 표현식을 사용하여 년, 월, 일, 시, 분을 추출합니다. const match = fullTimestamp.match(/(\d{4})-(\d{2})-(\d{2})\s(\d{2}):(\d{2}):\d{2}/); // match[1]: YYYY, match[2]: MM, match[3]: DD, match[4]: HH, match[5]: mm if (match) { const postYear = match[1]; const postMonth = match[2]; const postDay = match[3]; const postHour = match[4]; const postMinute = match[5]; const postDateString = `${postYear}-${postMonth}-${postDay}`; // 게시글 작성일 (YYYY-MM-DD) let formattedDate = ''; // 최종 표시될 문자열 초기화 // 게시글 작성일과 오늘 날짜 비교 if (postDateString === todayDateString) { // 오늘 작성된 글이면 시간만 표시 (HH:MM) formattedDate = `${postHour}:${postMinute}`; } else { // 오늘 이전에 작성된 글이면 날짜와 시간 표시 (MM.DD HH:MM) formattedDate = `${postMonth}.${postDay} ${postHour}:${postMinute}`; } // 셀의 텍스트 내용을 결정된 형식으로 업데이트합니다. // 단, 이미 원하는 형식과 동일하다면 DOM 조작 최소화를 위해 변경하지 않습니다. if (dateCell.textContent !== formattedDate) { dateCell.textContent = formattedDate; } // 처리 완료 표시 클래스를 추가하여 중복 작업을 방지합니다. dateCell.classList.add('date-formatted'); } } else { // title 속성이 없는 경우에도 처리된 것으로 표시합니다. dateCell.classList.add('date-formatted'); } }); }, // == 레이아웃 조정 함수 == adjustColgroupWidths() { const colgroup = document.querySelector('table.gall_list colgroup'); if (!colgroup) return; const cols = colgroup.querySelectorAll('col'); let targetWidths = null; // 적용할 너비 배열 초기화 // col 개수에 따라 다른 너비 배열 설정 if (cols.length === 8) { // 8개인 경우 (예: 체크박스, 말머리, 아이콘, 제목, 글쓴이, 작성일, 조회, 추천) targetWidths = ['25px', '9%', '51px', null, '15%', '8%', '6%', '6%']; // console.log("말머리 + 아이콘 O (8 cols) 레이아웃 적용"); } else if (cols.length === 7) { // 7개인 경우 (예: 번호, 말머리, 제목, 글쓴이, 작성일, 조회, 추천) targetWidths = ['9%', '51px', null, '15%', '8%', '6%', '6%']; // console.log("말머리 O (7 cols) 레이아웃 적용"); } else if (cols.length === 6) { // 6개인 경우 (예: 번호, 제목, 글쓴이, 작성일, 조회, 추천) targetWidths = ['9%', null, '15%', '8%', '6%', '6%']; // console.log("말머리 X (6 cols) 레이아웃 적용"); } else { // 예상과 다른 개수일 경우 경고 로그 남기고 종료 console.warn("Colgroup 내 col 개수가 6, 7 또는 8이 아닙니다:", cols.length); return; } // 선택된 너비 배열(targetWidths)을 사용하여 스타일 적용 cols.forEach((col, index) => { // targetWidths 배열 길이를 초과하는 인덱스는 무시 (안전 장치) if (index >= targetWidths.length) return; const targetWidth = targetWidths[index]; if (targetWidth !== null) { // null이 아닌 경우 (너비 지정 필요) // 현재 너비와 목표 너비가 다를 경우에만 변경 if (col.style.width !== targetWidth) { col.style.width = targetWidth; } } else { // null인 경우 (너비 지정 불필요, 브라우저 자동 계산) // 기존에 width 스타일이 있었다면 제거 if (col.style.width) { col.style.width = ''; // 또는 col.style.removeProperty('width'); } } }); } }; // Event Handlers const Events = { async triggerMacroNavigation() { const shouldRunZ = sessionStorage.getItem(MACRO_Z_RUNNING_KEY) === 'true'; const shouldRunX = sessionStorage.getItem(MACRO_X_RUNNING_KEY) === 'true'; if (shouldRunZ) { const enabled = await Storage.getShortcutEnabled('shortcutMacroZEnabled'); if (enabled) { console.log('Z Macro: Triggering previous post navigation after delay.'); // Add a small visual indicator that the macro is about to run UI.showAlert('자동 이전 글 (2.5초 후)', 500); // Show brief alert setTimeout(async () => { // Double-check state *before* navigation, in case user cancelled if (sessionStorage.getItem(MACRO_Z_RUNNING_KEY) === 'true') { await this.navigatePrevPost(); } else { console.log('Z Macro: Cancelled before navigation.'); } }, MACRO_INTERVAL); } else { // Feature disabled in UI, stop the macro state console.log('Z Macro: Feature disabled, stopping.'); sessionStorage.setItem(MACRO_Z_RUNNING_KEY, 'false'); } } else if (shouldRunX) { const enabled = await Storage.getShortcutEnabled('shortcutMacroXEnabled'); if (enabled) { console.log('X Macro: Triggering next post navigation after delay.'); UI.showAlert('자동 다음 글 (2.5초 후)', 500); // Show brief alert setTimeout(async () => { // Double-check state *before* navigation if (sessionStorage.getItem(MACRO_X_RUNNING_KEY) === 'true') { await this.navigateNextPost(); } else { console.log('X Macro: Cancelled before navigation.'); } }, MACRO_INTERVAL); } else { // Feature disabled in UI, stop the macro state console.log('X Macro: Feature disabled, stopping.'); sessionStorage.setItem(MACRO_X_RUNNING_KEY, 'false'); } } }, async toggleZMacro() { const enabled = await Storage.getShortcutEnabled('shortcutMacroZEnabled'); if (!enabled) { UI.showAlert('이전 글 자동 넘김 기능이 비활성화 상태입니다.'); sessionStorage.setItem(MACRO_Z_RUNNING_KEY, 'false'); // Ensure state is off return; } const isCurrentlyRunning = sessionStorage.getItem(MACRO_Z_RUNNING_KEY) === 'true'; if (isCurrentlyRunning) { // Stop Z Macro sessionStorage.setItem(MACRO_Z_RUNNING_KEY, 'false'); console.log('Z Macro stopped via toggle.'); UI.showAlert('이전 글 자동 넘김 중지'); // Clear any pending navigation timeout if the user stops it quickly // (This requires storing the timeout ID, slightly more complex. Let's omit for now) } else { // Start Z Macro sessionStorage.setItem(MACRO_Z_RUNNING_KEY, 'true'); sessionStorage.setItem(MACRO_X_RUNNING_KEY, 'false'); // Ensure X is off console.log('Z Macro started via toggle. Navigating now...'); UI.showAlert('이전 글 자동 넘김 시작 (ALT+Z로 중지)'); await this.navigatePrevPost(); // Navigate *immediately* on start } }, async toggleXMacro() { const enabled = await Storage.getShortcutEnabled('shortcutMacroXEnabled'); if (!enabled) { UI.showAlert('다음 글 자동 넘김 기능이 비활성화 상태입니다.'); sessionStorage.setItem(MACRO_X_RUNNING_KEY, 'false'); // Ensure state is off return; } const isCurrentlyRunning = sessionStorage.getItem(MACRO_X_RUNNING_KEY) === 'true'; if (isCurrentlyRunning) { // Stop X Macro sessionStorage.setItem(MACRO_X_RUNNING_KEY, 'false'); console.log('X Macro stopped via toggle.'); UI.showAlert('다음 글 자동 넘김 중지'); // Clear any pending navigation timeout if the user stops it quickly (optional enhancement) } else { // Start X Macro sessionStorage.setItem(MACRO_X_RUNNING_KEY, 'true'); sessionStorage.setItem(MACRO_Z_RUNNING_KEY, 'false'); // Ensure Z is off console.log('X Macro started via toggle. Navigating now...'); UI.showAlert('다음 글 자동 넘김 시작 (ALT+X로 중지)'); await this.navigateNextPost(); // Navigate *immediately* on start } }, // --- End NEW Macro Control Functions --- getFirstValidPostLink(doc) { // 특정 갤러리 목록을 감싸는 컨테이너 찾기 const galleryListWrap = doc.querySelector('.gall_listwrap'); // <<< 범위 제한 추가 if (!galleryListWrap) { console.error("Could not find gallery list container (.gall_listwrap) in fetched document."); return null; } // 찾은 컨테이너 내부의 tbody 안의 tr 만을 대상으로 함 const rows = galleryListWrap.querySelectorAll('tbody tr'); // <<< 범위 제한 추가 for (const row of rows) { // isValidPost 검사는 동일 if (Posts.isValidPost(row.querySelector('td.gall_num'), row.querySelector('td.gall_tit'), row.querySelector('td.gall_subject'))) { const link = row.querySelector('td.gall_tit a:first-child'); if (link && link.href) { // 절대 URL 반환 로직은 동일 return new URL(link.getAttribute('href'), doc.baseURI).href; } } } return null; // 유효한 링크 못 찾음 }, findPaginationLink(direction = 'next') { // 'next' 또는 'prev' let targetLinkElement = null; let targetPagingBox = null; // 페이징 박스 찾기 (기존 A/S 로직과 동일) const exceptionPagingWrap = document.querySelector('.bottom_paging_wrapre'); if (exceptionPagingWrap) { targetPagingBox = exceptionPagingWrap.querySelector('.bottom_paging_box'); } else { const normalPagingWraps = document.querySelectorAll('.bottom_paging_wrap'); if (normalPagingWraps.length > 1) { targetPagingBox = normalPagingWraps[1]?.querySelector('.bottom_paging_box'); } else if (normalPagingWraps.length === 1) { targetPagingBox = normalPagingWraps[0]?.querySelector('.bottom_paging_box'); } } if (targetPagingBox) { const currentPageElement = targetPagingBox.querySelector('em'); if (direction === 'prev') { // 이전 링크 찾기 if (currentPageElement) { const prevSibling = currentPageElement.previousElementSibling; if (prevSibling?.tagName === 'A' && prevSibling.hasAttribute('href')) { targetLinkElement = prevSibling; } } else { // em 없는 경우 (검색 등) targetLinkElement = targetPagingBox.querySelector('a.search_prev[href]'); } } else { // direction === 'next' // 다음 링크 찾기 if (currentPageElement) { const nextSibling = currentPageElement.nextElementSibling; if (nextSibling?.tagName === 'A' && nextSibling.hasAttribute('href')) { targetLinkElement = nextSibling; } } else { // em 없는 경우 (검색 등) targetLinkElement = targetPagingBox.querySelector('a.search_next[href]'); } } } return targetLinkElement; // 찾은 링크 요소 또는 null 반환 }, saveScrollPosition() { sessionStorage.setItem('dcinsideShortcut_scrollPos', window.scrollY); }, numberInput: { mode: false, buffer: '', timeout: null, display: null }, async handleKeydown(event) { // --- 디버깅 로그 추가 --- // console.log(`Keydown: Key='${event.key}', Code='${event.code}', Alt=${event.altKey}`); // Check for Macro Toggles FIRST if (event.altKey && !event.ctrlKey && !event.shiftKey && !event.metaKey) { // --- 디버깅 로그 추가 --- // console.log(`Alt key pressed: Code='${event.code}'`); // --- event.code 사용으로 변경 --- if (event.code === 'KeyZ') { // 'KeyZ'는 대부분의 표준 키보드에서 Z키의 코드입니다. // console.log("Alt+Z (event.code) condition met!"); // 디버깅 확인용 event.preventDefault(); // 기본 동작 및 다른 리스너로의 전파 방지 event.stopPropagation(); // 이벤트 버블링 중단 (혹시 모를 상위 요소 리스너 방지) await this.toggleZMacro(); return; // 처리 완료 } if (event.code === 'KeyX') { // 'KeyX'는 X키의 코드입니다. // console.log("Alt+X (event.code) condition met!"); // 디버깅 확인용 event.preventDefault(); event.stopPropagation(); await this.toggleXMacro(); return; // 처리 완료 } // --- 변경 끝 --- // --- Existing Alt key logic --- if (event.key === 'w' || event.key === 'W') { event.preventDefault(); // Check if macro is running, if so, maybe prevent 글쓰기 등록? Or stop macro? // Decide on desired behavior. For now, it proceeds. const writeButton = document.querySelector('button.btn_lightpurple.btn_svc.write[type="image"]'); if (writeButton) writeButton.click(); } else if (event.key >= '0' && event.key <= '9') { event.preventDefault(); const enabled = await Storage.getAltNumberEnabled(); if (enabled) { Gallery.handleFavoriteKey(event.key); } } else if (event.key === '`') { event.preventDefault(); const ui = document.querySelector('div[style*="position: fixed; top: 50%"]'); ui ? ui.remove() : UI.showFavorites(); } // --- End Existing Alt key logic --- } else if (!event.ctrlKey && !event.altKey && !event.shiftKey && !event.metaKey) { // Prevent regular Z/X if the corresponding macro *should* be running (optional but good) if ((event.key.toUpperCase() === 'Z' && sessionStorage.getItem(MACRO_Z_RUNNING_KEY) === 'true') || (event.key.toUpperCase() === 'X' && sessionStorage.getItem(MACRO_X_RUNNING_KEY) === 'true')) { console.log(`Macro state is active, ignoring regular ${event.key.toUpperCase()} press.`); event.preventDefault(); // Prevent default Z/X navigation if macro is on return; } // Check standard shortcut enabled status (no change here) const stdShortcutEnabled = await Storage.getShortcutEnabled(`shortcut${event.key.toUpperCase()}Enabled`); if (stdShortcutEnabled) { this.handleNavigationKeys(event); } } }, async loadPageContentAjax(targetLinkUrl, isViewMode) { UI.showAlert('로딩 중...'); let finalUrlToPush = targetLinkUrl; let urlToFetch = targetLinkUrl; try { // Calculate urlToFetch and finalUrlToPush based on isViewMode if (isViewMode) { try { const currentUrl = new URL(window.location.href); const targetUrlObj = new URL(targetLinkUrl, window.location.origin); const targetParams = targetUrlObj.searchParams; const targetPage = targetParams.get('page'); if (targetPage) { currentUrl.searchParams.set('page', targetPage); ['search_pos', 's_type', 's_keyword', 'exception_mode'].forEach(param => { if (targetParams.has(param)) { currentUrl.searchParams.set(param, targetParams.get(param)); } }); urlToFetch = currentUrl.toString(); finalUrlToPush = urlToFetch; } else { throw new Error("Target link missing 'page' parameter"); } } catch (e) { console.error("Error constructing URL for AJAX fetch in view mode:", e); window.location.href = targetLinkUrl; // Fallback navigation return; } } // Fetch content const response = await fetch(urlToFetch); if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); const htmlText = await response.text(); const parser = new DOMParser(); const doc = parser.parseFromString(htmlText, 'text/html'); // Find NEW content const newTbody = doc.querySelector('table.gall_list tbody'); let newPagingWrapElement = null; const exceptionWrap = doc.querySelector('.bottom_paging_wrapre'); if (exceptionWrap) { newPagingWrapElement = exceptionWrap; } else { const normalWraps = doc.querySelectorAll('.bottom_paging_wrap'); if (normalWraps.length > 1) { newPagingWrapElement = normalWraps[1]; } else if (normalWraps.length === 1) { newPagingWrapElement = normalWraps[0]; } } // Find CURRENT elements const currentTbody = document.querySelector('table.gall_list tbody'); let currentPagingWrapElement = document.querySelector('.bottom_paging_wrapre'); if (!currentPagingWrapElement) { const currentNormalWraps = document.querySelectorAll('.bottom_paging_wrap'); if (currentNormalWraps.length > 1) { currentPagingWrapElement = currentNormalWraps[1]; } else if (currentNormalWraps.length === 1){ currentPagingWrapElement = currentNormalWraps[0]; } } // Validate and Replace if (!newTbody) throw new Error("Could not find 'tbody' in fetched content."); if (!currentTbody) throw new Error("Could not find current 'tbody' to replace."); currentTbody.innerHTML = newTbody.innerHTML; // Replace tbody if (newPagingWrapElement && currentPagingWrapElement) { currentPagingWrapElement.innerHTML = newPagingWrapElement.innerHTML; // Replace pagination } else if (!newPagingWrapElement && currentPagingWrapElement) { currentPagingWrapElement.innerHTML = ''; // Clear current pagination console.log("Fetched page has no pagination, clearing current."); } else if (newPagingWrapElement && !currentPagingWrapElement) { console.warn("Current page missing pagination wrap, cannot insert new pagination dynamically."); } // Re-run initializations Posts.adjustColgroupWidths(); Posts.addNumberLabels(); Posts.formatDates(); // Update Browser URL history.pushState(null, '', finalUrlToPush); // Scroll to top of list currentTbody.closest('table.gall_list')?.scrollIntoView({ behavior: 'auto', block: 'start' }); // Remove loading indicator const loadingAlert = Array.from(document.querySelectorAll('div[style*="position: fixed"]')).find(el => el.textContent === '로딩 중...'); if (loadingAlert) loadingAlert.remove(); // Update Prefetch Hints After AJAX Load addPrefetchHints(); // <<< AJAX 완료 후 프리페칭 힌트 업데이트 } catch (error) { console.error('Failed to load page content via AJAX:', error); UI.showAlert('오류 발생: 페이지 로딩 실패'); // Fallback to full page navigation console.log("Falling back to full page navigation."); // Fallback should use the original TARGET link URL, not the potentially modified urlToFetch window.location.href = targetLinkUrl; } }, // Add comma if needed handleNavigationKeys(event) { const active = document.activeElement; if (active && ['TEXTAREA', 'INPUT'].includes(active.tagName) || active.isContentEditable) return; if (['`', '.'].includes(event.key)) { event.preventDefault(); this.toggleNumberInput(event.key); return; } if (this.numberInput.mode) { this.handleNumberInput(event); return; } if (event.key >= '0' && event.key <= '9') { const index = event.key === '0' ? 9 : parseInt(event.key, 10) - 1; const { validPosts } = Posts.getValidPosts(); if (index < validPosts.length) validPosts[index].link.click(); return; } this.handleShortcuts(event.key.toUpperCase(), event); }, toggleNumberInput(key) { if (this.numberInput.mode && this.numberInput.buffer) { Posts.navigate(this.numberInput.buffer); this.exitNumberInput(); } else { this.numberInput.mode = true; this.numberInput.buffer = ''; this.updateNumberDisplay('Post number: '); this.resetNumberTimeout(); } }, handleNumberInput(event) { event.preventDefault(); if (event.key >= '0' && event.key <= '9') { this.numberInput.buffer += event.key; this.updateNumberDisplay(`Post number: ${this.numberInput.buffer}`); this.resetNumberTimeout(); } else if (event.key === 'Enter' && this.numberInput.buffer) { Posts.navigate(this.numberInput.buffer); this.exitNumberInput(); } else if (event.key === 'Escape') { this.exitNumberInput(); } }, updateNumberDisplay(text) { if (!this.numberInput.display) { this.numberInput.display = UI.createElement('div', { position: 'fixed', top: '10px', right: '10px', backgroundColor: 'rgba(0,0,0,0.7)', color: 'white', padding: '10px 15px', borderRadius: '5px', fontSize: '16px', fontWeight: 'bold', zIndex: '9999' }); document.body.appendChild(this.numberInput.display); } this.numberInput.display.textContent = text; }, resetNumberTimeout() { clearTimeout(this.numberInput.timeout); this.numberInput.timeout = setTimeout(() => this.exitNumberInput(), 3000); }, exitNumberInput() { this.numberInput.mode = false; this.numberInput.buffer = ''; clearTimeout(this.numberInput.timeout); this.numberInput.timeout = null; if (this.numberInput.display) { this.numberInput.display.remove(); this.numberInput.display = null; } }, async handleShortcuts(key, event) { const { galleryType, galleryId, currentPage, isRecommendMode } = Gallery.getPageInfo(); // isRecommendMode 추가 const isViewMode = window.location.pathname.includes('/board/view/'); // isViewMode 정의 // --- Simple navigation function (NO scroll saving) --- const navigate = url => { if (document.readyState === 'complete') { window.location.href = url; } else { window.addEventListener('load', () => window.location.href = url, { once: true }); } }; // Get saved shortcut keys const savedKeys = { 'W': await Storage.getShortcutKey('shortcutWKey') || 'W', 'C': await Storage.getShortcutKey('shortcutCKey') || 'C', 'D': await Storage.getShortcutKey('shortcutDKey') || 'D', 'R': await Storage.getShortcutKey('shortcutRKey') || 'R', 'Q': await Storage.getShortcutKey('shortcutQKey') || 'Q', 'E': await Storage.getShortcutKey('shortcutEKey') || 'E', 'F': await Storage.getShortcutKey('shortcutFKey') || 'F', 'G': await Storage.getShortcutKey('shortcutGKey') || 'G', 'A': await Storage.getShortcutKey('shortcutAKey') || 'A', 'S': await Storage.getShortcutKey('shortcutSKey') || 'S', 'Z': await Storage.getShortcutKey('shortcutZKey') || 'Z', 'X': await Storage.getShortcutKey('shortcutXKey') || 'X' }; // Determine base URLs for F and G keys let basePath = ''; if (galleryType !== 'board') { basePath = `/${galleryType}`; } const listPath = `/board/lists/`; const baseListUrl = `https://gall.dcinside.com${basePath}${listPath}?id=${galleryId}`; const recommendListUrl = `${baseListUrl}&exception_mode=recommend`; switch (key) { case savedKeys['W']: document.querySelector('button#btn_write')?.click(); break; case savedKeys['C']: event.preventDefault(); document.querySelector('textarea[id^="memo_"]')?.focus(); break; case savedKeys['D']: document.querySelector('button.btn_cmt_refresh')?.click(); break; case savedKeys['R']: location.reload(); break; // Full reload case savedKeys['Q']: window.scrollTo(0, 0); break; case savedKeys['E']: document.querySelector('table.gall_list')?.scrollIntoView({ block: 'start' }); break; // F and G now use the simple navigate without scroll saving case savedKeys['F']: navigate(baseListUrl); break; case savedKeys['G']: navigate(recommendListUrl); break; // --- REVISED A and S Logic (Mode Switching + View Mode Handling) --- case savedKeys['A']: // 이전 페이지 case savedKeys['S']: { // 다음 페이지 let targetLinkElement = null; let targetPagingBox = null; let emExistsInTarget = false; // 1. Determine the correct paging box (동일 로직) const exceptionPagingWrap = document.querySelector('.bottom_paging_wrapre'); if (exceptionPagingWrap) { targetPagingBox = exceptionPagingWrap.querySelector('.bottom_paging_box'); } else { const normalPagingWraps = document.querySelectorAll('.bottom_paging_wrap'); if (normalPagingWraps.length > 1) { targetPagingBox = normalPagingWraps[1]?.querySelector('.bottom_paging_box'); } else if (normalPagingWraps.length === 1) { targetPagingBox = normalPagingWraps[0]?.querySelector('.bottom_paging_box'); } } // 2. Find the target link element (동일 로직) if (targetPagingBox) { const currentPageElement = targetPagingBox.querySelector('em'); emExistsInTarget = !!currentPageElement; // em 존재 여부 확인 if (key === savedKeys['A']) { if (currentPageElement) { const prevSibling = currentPageElement.previousElementSibling; if (prevSibling?.tagName === 'A' && prevSibling.hasAttribute('href')) { targetLinkElement = prevSibling; } } else { // em이 없는 경우 (검색 결과 등) prev 버튼 찾기 targetLinkElement = targetPagingBox.querySelector('a.search_prev[href]'); } } else { // key === savedKeys['S'] if (currentPageElement) { const nextSibling = currentPageElement.nextElementSibling; if (nextSibling?.tagName === 'A' && nextSibling.hasAttribute('href')) { targetLinkElement = nextSibling; } } else { // em이 없는 경우 next 버튼 찾기 targetLinkElement = targetPagingBox.querySelector('a.search_next[href]'); } } } // end if(targetPagingBox) // 3. Get Navigation Mode and Execute if (targetLinkElement) { const targetUrlFromHref = targetLinkElement.href; // 링크의 href 속성값 const navMode = await Storage.getPageNavigationMode(); // 설정값 읽기 if (navMode === 'ajax') { // AJAX 모드: 기존 로직 실행 (isViewMode 전달) this.loadPageContentAjax(targetUrlFromHref, isViewMode); } else { // --- Full Load 모드 --- // <<< 스크롤 위치 저장 호출 추가 >>> this.saveScrollPosition(); if (isViewMode) { // 현재 페이지가 View 모드일 때: 현재 URL 기반으로 page와 search_pos 변경 try { // 1. 대상 링크(targetLinkElement)의 href에서 목표 page와 search_pos 추출 const linkUrl = new URL(targetUrlFromHref); const targetPage = linkUrl.searchParams.get('page'); const targetSearchPos = linkUrl.searchParams.get('search_pos'); // 대상 링크의 search_pos 값 if (targetPage) { // 2. 현재 페이지 URL을 가져와서 필요한 파라미터만 교체 const currentUrl = new URL(window.location.href); currentUrl.searchParams.set('page', targetPage); // page 파라미터 업데이트 // 3. search_pos 파라미터 처리 if (targetSearchPos) { // 대상 링크에 search_pos가 있으면 그 값으로 설정 currentUrl.searchParams.set('search_pos', targetSearchPos); } else { // 대상 링크에 search_pos가 없으면 현재 URL에서도 제거 currentUrl.searchParams.delete('search_pos'); } // 4. 변경된 URL로 이동 (페이지 새로고침 발생) window.location.href = currentUrl.toString(); } else { // 링크 URL에 page 파라미터가 없는 예외적인 경우 (Fallback) console.warn("Full Load (View Mode): Target link missing 'page' parameter. Navigating directly.", targetUrlFromHref); window.location.href = targetUrlFromHref; } } catch (e) { console.error("Full Load (View Mode): Error processing URL. Navigating directly.", e, targetUrlFromHref); // URL 처리 중 오류 발생 시 (Fallback) window.location.href = targetUrlFromHref; } } else { // 현재 페이지가 View 모드가 아닐 때 (List 모드): 그냥 대상 링크 URL로 이동 window.location.href = targetUrlFromHref; } // --- Full Load 모드 끝 --- } } else if (key === savedKeys['A']) { // 첫 페이지 알림 (em이 있을 때만) if (emExistsInTarget) { UI.showAlert('첫 페이지입니다.'); } } break; // End of A/S block } // --- END REVISED A and S Logic --- case savedKeys['Z']: await this.navigatePrevPost(galleryType, galleryId, currentPage); break; // Z/X still use full navigation case savedKeys['X']: await this.navigateNextPost(galleryType, galleryId, currentPage); break; // Z/X still use full navigation } }, async navigatePrevPost() { const crtIcon = document.querySelector('td.gall_num .sp_img.crt_icon'); if (!crtIcon) return; // Not on a post view or can't find current post icon // --- Try finding the previous post on the CURRENT page first --- let row = crtIcon.closest('tr')?.previousElementSibling; while (row && !Posts.isValidPost(row.querySelector('td.gall_num'), row.querySelector('td.gall_tit'), row.querySelector('td.gall_subject'))) { row = row.previousElementSibling; } if (row) { // --- Found previous post on the same page --- const prevLinkElement = row.querySelector('td.gall_tit a:first-child'); if (prevLinkElement) { window.location.href = prevLinkElement.href; // Navigate directly } return; // Done } // --- If no previous post on the current page, check for previous PAGE link --- const prevPageLinkElement = this.findPaginationLink('prev'); // Find '<' or 'prev search' link if (prevPageLinkElement && prevPageLinkElement.href) { // --- Previous PAGE link exists --- const isPrevSearchLink = prevPageLinkElement.classList.contains('search_prev'); if (isPrevSearchLink) { // --- Handle "Previous Search" link (special logic) --- console.log("[Z Nav] Detected 'Previous Search' link. Starting special logic..."); try { const prevSearchBlockFirstPageUrl = new URL(prevPageLinkElement.href); console.log("[Z Nav] Fetching previous search block page 1:", prevSearchBlockFirstPageUrl.toString()); const doc1 = await this.fetchPage(prevSearchBlockFirstPageUrl.toString()); const allPagingBoxes = doc1.querySelectorAll('.bottom_paging_box'); let pagingBox1 = null; console.log(`[Z Nav] Found ${allPagingBoxes.length} paging boxes in fetched page 1.`); if (allPagingBoxes.length > 1) { pagingBox1 = allPagingBoxes[1]; // Assume second is gallery list console.log("[Z Nav] Using the second paging box."); } else if (allPagingBoxes.length === 1) { pagingBox1 = allPagingBoxes[0]; // Use the only one found console.log("[Z Nav] Only one paging box found, using it."); } if (!pagingBox1) { throw new Error("Could not find the relevant pagination box on the first page of the previous search block."); } console.log("[Z Nav] Relevant paging box content:", pagingBox1.innerHTML); let lastPageNum = 1; const nextSearchLink1 = pagingBox1.querySelector('a.search_next'); if (nextSearchLink1) { const lastPageLinkElement = nextSearchLink1.previousElementSibling; if (lastPageLinkElement?.tagName === 'A' && lastPageLinkElement.href) { const pageNumStr = new URL(lastPageLinkElement.href).searchParams.get('page'); if (pageNumStr) lastPageNum = parseInt(pageNumStr, 10); } } else { const pageLinks = pagingBox1.querySelectorAll('a:not(.search_prev):not(.search_next)'); if (pageLinks.length > 0) { const lastLink = pageLinks[pageLinks.length - 1]; if(lastLink?.href){ const pageNumStr = new URL(lastLink.href).searchParams.get('page'); if (pageNumStr) lastPageNum = parseInt(pageNumStr, 10); } } } console.log("[Z Nav] Calculated lastPageNum for previous block:", lastPageNum); const prevSearchBlockLastPageUrl = new URL(prevSearchBlockFirstPageUrl); prevSearchBlockLastPageUrl.searchParams.set('page', lastPageNum.toString()); console.log("[Z Nav] Fetching previous search block last page:", prevSearchBlockLastPageUrl.toString()); const doc2 = await this.fetchPage(prevSearchBlockLastPageUrl.toString()); const finalPostLinkHref = this.getLastValidPostLink(doc2); console.log("[Z Nav] Found finalPostLinkHref on last page:", finalPostLinkHref); if (finalPostLinkHref) { const targetPostUrl = new URL(finalPostLinkHref); const targetNo = targetPostUrl.searchParams.get('no'); if (targetNo) { const currentUrl = new URL(window.location.href); currentUrl.searchParams.set('no', targetNo); currentUrl.searchParams.set('page', lastPageNum.toString()); const targetSearchPos = prevSearchBlockFirstPageUrl.searchParams.get('search_pos'); if (targetSearchPos) currentUrl.searchParams.set('search_pos', targetSearchPos); else currentUrl.searchParams.delete('search_pos'); console.log("[Z Nav] Final navigation URL:", currentUrl.toString()); window.location.href = currentUrl.toString(); } else { throw new Error("Could not extract 'no' from final post link."); } } else { throw new Error("Could not find the last valid post on the last page of the previous search block."); } } catch (error) { console.error("[Z Nav] Error processing 'Previous Search' navigation:", error); UI.showAlert('"이전 검색" 블록 이동 중 오류가 발생했습니다.'); window.location.href = prevPageLinkElement.href; // Fallback } } else { // --- Handle regular previous page link --- try { const doc = await this.fetchPage(prevPageLinkElement.href); const lastValidLinkHref = this.getLastValidPostLink(doc); if (lastValidLinkHref) { const targetLinkUrl = new URL(lastValidLinkHref); const targetNo = targetLinkUrl.searchParams.get('no'); if (targetNo) { const currentUrl = new URL(window.location.href); currentUrl.searchParams.set('no', targetNo); const prevPageListUrl = new URL(prevPageLinkElement.href); const targetPage = prevPageListUrl.searchParams.get('page'); if (targetPage) currentUrl.searchParams.set('page', targetPage); const targetSearchPos = prevPageListUrl.searchParams.get('search_pos'); if (targetSearchPos) currentUrl.searchParams.set('search_pos', targetSearchPos); else currentUrl.searchParams.delete('search_pos'); window.location.href = currentUrl.toString(); } else { throw new Error("Could not extract 'no' from last valid post link."); } } else { UI.showAlert('이전 페이지에 표시할 게시글이 없습니다.'); } } catch (error) { console.error("Error fetching/processing previous page for Z nav:", error); UI.showAlert('이전 페이지 로딩 중 오류가 발생했습니다.'); } } } else { // --- NO previous post on current page AND NO previous page link --- // --- This means we are on the first post of page 1. Check for newer posts. --- const currentUrl = new URL(window.location.href); const currentPostNoStr = currentUrl.searchParams.get('no'); if (!currentPostNoStr) { UI.showAlert('현재 글 번호를 찾을 수 없습니다. 페이지를 새로고침 해주세요.'); return; } const currentPostNo = parseInt(currentPostNoStr, 10); if (isNaN(currentPostNo)) { UI.showAlert('현재 글 번호가 유효하지 않습니다.'); return; } // Construct the URL for the list view of the current page (page 1) const listUrl = new URL(window.location.href); // Make sure path points to lists, not view listUrl.pathname = listUrl.pathname.replace(/(\/board)\/view\/?/, '$1/lists/'); listUrl.searchParams.set('page', '1'); // Explicitly page 1 listUrl.searchParams.delete('no'); // Remove post number UI.showAlert('최신 글 확인 중...'); // Feedback try { const doc = await this.fetchPage(listUrl.toString()); // Get all valid posts from the fetched page 1 list const allPostsOnPage1 = this.getValidPostsFromDoc(doc); // Uses existing helper // Check if any post on the fetched list is newer than the currently viewed post const newerPosts = allPostsOnPage1.filter(p => p.num > currentPostNo); if (newerPosts.length > 0) { // Newer posts exist! Navigate to the newest one found on page 1. const newestPost = allPostsOnPage1.sort((a, b) => b.num - a.num)[0]; if (newestPost && newestPost.link) { UI.showAlert('새로운 글을 발견하여 이동합니다.'); // Construct the VIEW url for the newest post const targetViewUrl = new URL(newestPost.link); // Base link from list item targetViewUrl.searchParams.set('page', '1'); // Set page=1 for context // Preserve relevant parameters (like exception_mode, search) from the current URL const currentSearchParams = new URLSearchParams(window.location.search); ['exception_mode', 'search_pos', 's_type', 's_keyword'].forEach(param => { if (currentSearchParams.has(param)) { targetViewUrl.searchParams.set(param, currentSearchParams.get(param)); } }); window.location.href = targetViewUrl.toString(); } else { // Should not happen if newerPosts.length > 0, but as a fallback: UI.showAlert('첫 게시글입니다.'); } } else { // No newer posts found after checking. UI.showAlert('첫 게시글입니다.'); } } catch (error) { console.error("Error checking for newer posts:", error); UI.showAlert('최신 글 확인 중 오류가 발생했습니다.'); // Optionally fall back to just showing the original alert // UI.showAlert('첫 게시글입니다.'); } } // --- End of Page Boundary / Newer Post Check --- }, // End navigatePrevPost async navigateNextPost() { const crtIcon = document.querySelector('td.gall_num .sp_img.crt_icon'); let nextValidRowLink = null; if (crtIcon) { let row = crtIcon.closest('tr')?.nextElementSibling; while (row) { if (Posts.isValidPost(row.querySelector('td.gall_num'), row.querySelector('td.gall_tit'), row.querySelector('td.gall_subject'))) { nextValidRowLink = row.querySelector('td.gall_tit a:first-child'); break; } row = row.nextElementSibling; } } if (nextValidRowLink) { // --- 현재 페이지 내 다음 글 처리 --- window.location.href = nextValidRowLink.href; // 스크롤 복원과 함께 직접 이동 } else { // --- 페이지 경계 처리 (Fetch 후 View URL 재구성) --- const nextPageLink = this.findPaginationLink('next'); // 다음 페이지 목록 링크 찾기 if (nextPageLink && nextPageLink.href) { try { // 1. 다음 페이지 목록 HTML 가져오기 const doc = await this.fetchPage(nextPageLink.href); // 2. 다음 페이지의 첫 번째 유효 글 링크 찾기 (절대 URL 반환) const firstValidLinkHref = this.getFirstValidPostLink(doc); if (firstValidLinkHref) { // 3. 첫 번째 글 링크에서 'no' 값 추출 const targetLinkUrl = new URL(firstValidLinkHref); const targetNo = targetLinkUrl.searchParams.get('no'); if (targetNo) { // 4. 현재 URL을 기준으로 최종 이동 URL 생성 const currentUrl = new URL(window.location.href); // - 'no' 업데이트 currentUrl.searchParams.set('no', targetNo); // - 'page' 업데이트 (다음 페이지 목록 링크에서 가져오기) const nextPageListUrl = new URL(nextPageLink.href); const targetPage = nextPageListUrl.searchParams.get('page'); if (targetPage) { currentUrl.searchParams.set('page', targetPage); } // - 'search_pos' 업데이트 (다음 페이지 목록 링크에서 가져오기) const targetSearchPos = nextPageListUrl.searchParams.get('search_pos'); if (targetSearchPos) { currentUrl.searchParams.set('search_pos', targetSearchPos); } else { currentUrl.searchParams.delete('search_pos'); } // - s_type, s_keyword 등은 현재 URL의 값 유지됨 // 5. 스크롤 위치 저장 및 최종 URL로 이동 window.location.href = currentUrl.toString(); } else { console.error("Could not extract 'no' from first valid post link:", firstValidLinkHref); UI.showAlert('다음 글 정보 로딩 중 오류가 발생했습니다.'); } } else { UI.showAlert('다음 페이지에 표시할 게시글이 없습니다.'); } } catch (error) { console.error("Error fetching/processing next page for X nav:", error); UI.showAlert('다음 페이지 로딩 중 오류가 발생했습니다.'); } } else { UI.showAlert('마지막 게시글입니다.'); } // --- 페이지 경계 처리 끝 --- } }, getNextValidLink() { const crtIcon = document.querySelector('td.gall_num .sp_img.crt_icon'); if (!crtIcon) return null; let row = crtIcon.closest('tr')?.nextElementSibling; while (row && !Posts.isValidPost(row.querySelector('td.gall_num'), row.querySelector('td.gall_tit'), row.querySelector('td.gall_subject'))) { row = row.nextElementSibling; } return row?.querySelector('td.gall_tit a:first-child'); }, async fetchPage(url) { const response = await fetch(url); const text = await response.text(); return new DOMParser().parseFromString(text, 'text/html'); }, getLastValidPostLink(doc) { // 특정 갤러리 목록을 감싸는 컨테이너를 먼저 찾음 // '.gall_listwrap' 클래스를 가진 첫 번째 요소를 대상으로 가정 // 만약 구조가 다르다면 이 선택자를 조정해야 할 수 있음 const galleryListWrap = doc.querySelector('.gall_listwrap'); // <<< 범위 제한 추가 if (!galleryListWrap) { console.error("Could not find gallery list container (.gall_listwrap) in fetched document."); return null; // 컨테이너 못 찾으면 null 반환 } // 찾은 컨테이너 내부의 tbody 안의 tr 만을 대상으로 함 const rows = Array.from(galleryListWrap.querySelectorAll('tbody tr')); // <<< 범위 제한 추가 for (let i = rows.length - 1; i >= 0; i--) { const row = rows[i]; // isValidPost 검사는 동일 if (Posts.isValidPost(row.querySelector('td.gall_num'), row.querySelector('td.gall_tit'), row.querySelector('td.gall_subject'))) { const link = row.querySelector('td.gall_tit a:first-child'); if (link && link.href) { // 절대 URL 반환 로직은 동일 return new URL(link.getAttribute('href'), doc.baseURI).href; } } } return null; // 유효한 링크 못 찾음 }, getNewerPosts(doc, currentNo) { const posts = this.getValidPostsFromDoc(doc); return posts.filter(p => p.num > currentNo).sort((a, b) => a.num - b.num); }, getValidPostsFromDoc(doc) { return Array.from(doc.querySelectorAll('table.gall_list tbody tr')) .filter(row => Posts.isValidPost(row.querySelector('td.gall_num'), row.querySelector('td.gall_tit'), row.querySelector('td.gall_subject'))) .map(row => { const num = parseInt(row.querySelector('td.gall_num').textContent.trim().replace(/\[\d+\]\s*/, ''), 10); return { num, link: row.querySelector('td.gall_tit a:first-child')?.href }; }); } }; // --- Prefetching Logic --- function addPrefetchHints() { // Check if prefetch is supported const isPrefetchSupported = (() => { const link = document.createElement('link'); return link.relList && link.relList.supports && link.relList.supports('prefetch'); })(); if (!isPrefetchSupported) return; // --- Remove previously added hints by this script --- document.querySelectorAll('link[data-dc-prefetch="true"]').forEach(link => link.remove()); // --- Function to add prefetch link to head --- const addHint = (href) => { if (!href) return; const fullHref = new URL(href, window.location.origin).toString(); if (document.querySelector(`link[rel="prefetch"][href="${fullHref}"]`)) return; try { const link = document.createElement('link'); link.rel = 'prefetch'; link.href = fullHref; link.as = 'document'; link.setAttribute('data-dc-prefetch', 'true'); document.head.appendChild(link); // console.log('Prefetch hint added:', fullHref); } catch (e) { console.error("Failed to add prefetch hint:", fullHref, e); } }; // --- 1. Prefetch Next/Previous PAGE Links --- let targetPagingBox = null; const exceptionPagingWrap = document.querySelector('.bottom_paging_wrapre'); if (exceptionPagingWrap) { targetPagingBox = exceptionPagingWrap.querySelector('.bottom_paging_box'); } else { const normalPagingWraps = document.querySelectorAll('.bottom_paging_wrap'); if (normalPagingWraps.length > 1) { targetPagingBox = normalPagingWraps[1]?.querySelector('.bottom_paging_box'); } else if (normalPagingWraps.length === 1) { targetPagingBox = normalPagingWraps[0]?.querySelector('.bottom_paging_box'); } } if (targetPagingBox) { const currentPageElement = targetPagingBox.querySelector('em'); let prevPageLinkHref = null; let nextPageLinkHref = null; if (currentPageElement) { const prevPageSibling = currentPageElement.previousElementSibling; if (prevPageSibling?.tagName === 'A' && prevPageSibling.hasAttribute('href')) { prevPageLinkHref = prevPageSibling.href; } const nextPageSibling = currentPageElement.nextElementSibling; if (nextPageSibling?.tagName === 'A' && nextPageSibling.hasAttribute('href')) { nextPageLinkHref = nextPageSibling.href; } } else { // No <em>, check for search prev/next prevPageLinkHref = targetPagingBox.querySelector('a.search_prev[href]')?.href; nextPageLinkHref = targetPagingBox.querySelector('a.search_next[href]')?.href; } addHint(prevPageLinkHref); addHint(nextPageLinkHref); } // --- 2. Prefetch Next/Previous POST Links (Z/X keys) --- const currentPostIcon = document.querySelector('td.gall_num .sp_img.crt_icon'); if (currentPostIcon) { const currentRow = currentPostIcon.closest('tr'); let prevPostLinkHref = null; let nextPostLinkHref = null; // Find Previous Valid Post Link let prevRow = currentRow?.previousElementSibling; while (prevRow) { if (Posts.isValidPost(prevRow.querySelector('td.gall_num'), prevRow.querySelector('td.gall_tit'), prevRow.querySelector('td.gall_subject'))) { prevPostLinkHref = prevRow.querySelector('td.gall_tit a:first-child')?.href; break; // Found the first valid previous post } prevRow = prevRow.previousElementSibling; } // Find Next Valid Post Link let nextRow = currentRow?.nextElementSibling; while (nextRow) { if (Posts.isValidPost(nextRow.querySelector('td.gall_num'), nextRow.querySelector('td.gall_tit'), nextRow.querySelector('td.gall_subject'))) { nextPostLinkHref = nextRow.querySelector('td.gall_tit a:first-child')?.href; break; // Found the first valid next post } nextRow = nextRow.nextElementSibling; } // Add hints for post links addHint(prevPostLinkHref); addHint(nextPostLinkHref); } } // --- End Prefetching Logic --- function restoreScrollPosition() { const savedScrollY = sessionStorage.getItem('dcinsideShortcut_scrollPos'); if (savedScrollY !== null) { // console.log('Found saved scroll position:', savedScrollY); // 디버깅용 로그 const scrollY = parseInt(savedScrollY, 10); if (!isNaN(scrollY)) { // 저장된 위치로 스크롤 이동 // 페이지 렌더링이 완료된 후 스크롤해야 정확하므로 약간의 지연(setTimeout)을 고려할 수 있음 // 하지만 우선 즉시 실행해보고 문제가 발생하면 setTimeout 추가 window.scrollTo(0, scrollY); // console.log('Scrolled to:', scrollY); // 디버깅용 로그 } // 사용 후에는 세션 스토리지에서 제거하여 일반적인 새로고침/페이지 이동 시 영향 없도록 함 sessionStorage.removeItem('dcinsideShortcut_scrollPos'); // console.log('Removed saved scroll position.'); // 디버깅용 로그 } else { // console.log('No saved scroll position found.'); // 디버깅용 로그 } } // Initialization function init() { // --- 글쓰기 페이지 관련 함수 --- // 제목 입력란의 플레이스홀더 라벨("제목을 입력해 주세요.")을 제거합니다. function removeTitlePlaceholder() { const subjectInput = document.getElementById('subject'); // 제목 입력 필드와 특정 상위 클래스 존재 여부로 글쓰기 페이지 확인 if (subjectInput && subjectInput.closest('.input_write_tit')) { const label = document.querySelector('label.txt_placeholder[for="subject"]'); if (label) { label.remove(); // 라벨 요소 제거 } } } // 제목 입력 필드에서 Tab 키를 누르면 본문 편집 영역으로 포커스를 이동시킵니다. function setupTabFocus() { const subjectInput = document.getElementById('subject'); // 제목 입력 필드 const contentEditable = document.querySelector('.note-editable'); // 본문 편집 영역 // 두 요소가 모두 존재하고, 아직 이벤트 리스너가 추가되지 않았을 때만 실행 if (subjectInput && contentEditable && !subjectInput.hasAttribute('data-tab-listener-added')) { subjectInput.addEventListener('keydown', function(event) { // Tab 키가 눌렸고 Shift 키는 눌리지 않았을 때 if (event.key === 'Tab' && !event.shiftKey) { event.preventDefault(); // 기본 Tab 동작 방지 contentEditable.focus(); // 본문 편집 영역으로 포커스 이동 } }); // 리스너가 추가되었음을 표시 (중복 추가 방지) subjectInput.setAttribute('data-tab-listener-added', 'true'); } } // --- 글쓰기 페이지 관련 함수 끝 --- // 키보드 이벤트 리스너 등록 document.addEventListener('keydown', e => Events.handleKeydown(e)); document.addEventListener('keydown', (e) => { if (e.key === 'Alt' && !e.ctrlKey && !e.shiftKey && !e.metaKey) { e.preventDefault(); } }); // --- Function to run after page load --- const onPageLoad = () => { Posts.adjustColgroupWidths(); Posts.addNumberLabels(); Posts.formatDates(); removeTitlePlaceholder(); setupTabFocus(); addPrefetchHints(); restoreScrollPosition(); Events.triggerMacroNavigation(); // <<< ADD THIS CALL }; // 페이지 로드 완료 시점 if (document.readyState === 'complete') { onPageLoad(); // Run immediately } else { window.addEventListener('load', onPageLoad, { once: true }); // Run on load } // --- MutationObserver 설정 --- // 1. 글 목록(tbody) 변경 감지 옵저버 const listObserver = new MutationObserver(() => { setTimeout(() => { Posts.adjustColgroupWidths();// 목록 내용 변경 시 너비 재조정은 불필요할 수 있으나, 혹시 모르니 유지 Posts.addNumberLabels(); Posts.formatDates(); }, 100); }); const listTbody = document.querySelector('table.gall_list tbody'); if (listTbody) { listObserver.observe(listTbody, { childList: true, subtree: false }); } // 2. 전체 문서(body) 변경 감지 옵저버 const bodyObserver = new MutationObserver(() => { // 페이지 전환 등으로 colgroup이 새로 생기거나 변경될 수 있으므로 여기서 호출 Posts.adjustColgroupWidths(); // <<< 너비 조정 함수 호출 추가 const currentListTbody = document.querySelector('table.gall_list tbody'); if (currentListTbody) { if (!currentListTbody.querySelector('.number-label')) { Posts.addNumberLabels(); } Posts.formatDates(); } removeTitlePlaceholder(); setupTabFocus(); }); bodyObserver.observe(document.body, { childList: true, subtree: true }); } init(); })(); /* Copyright (c) 2025 nonohako([email protected]) dcinside shortcut © 2025 by nonohako is licensed under Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International. To view a copy of this license, visit https://creativecommons.org/licenses/by-nc-sa/4.0/ */