// ==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/
*/