Pixiv 한손 키보드 탐색 스크립트. WASD 방향키 매핑, 그리드 키보드 탐색, 커스텀 단축키. SPA 대응.
// ==UserScript==
// @name PixivNavi
// @namespace http://tampermonkey.net/
// @version 1.0
// @description Pixiv 한손 키보드 탐색 스크립트. WASD 방향키 매핑, 그리드 키보드 탐색, 커스텀 단축키. SPA 대응.
// @icon https://www.google.com/s2/favicons?sz=64&domain=pixiv.net
// @license MIT
// @author User
// @match https://www.pixiv.net/*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @run-at document-start
// ==/UserScript==
(function() {
'use strict';
// ═══════════════════════════════════════════
// ── [O2] 유저 ID 자동 감지 + GM 메뉴 설정 ──
// ═══════════════════════════════════════════
const GM_KEY_USER_ID = 'pixivnavi_user_id';
let _cachedUserId = null;
function detectUserId() {
if (_cachedUserId) return _cachedUserId;
const gmStored = GM_getValue(GM_KEY_USER_ID, null);
if (gmStored && /^\d+$/.test(gmStored)) {
_cachedUserId = gmStored;
return _cachedUserId;
}
try {
const session = document.cookie.split(';').map(c => c.trim())
.find(c => c.startsWith('PHPSESSID='));
if (session) {
const match = session.split('=')[1]?.match(/^(\d+)_/);
if (match) { _cachedUserId = match[1]; return _cachedUserId; }
}
} catch (e) {}
try {
const preload = document.getElementById('meta-preload-data');
if (preload) {
const data = JSON.parse(preload.getAttribute('content'));
if (data?.userData) {
const id = Object.keys(data.userData).find(k => /^\d+$/.test(k));
if (id) { _cachedUserId = id; return _cachedUserId; }
}
}
} catch (e) {}
const link = document.querySelector('header a[href*="/users/"]');
if (link) {
const match = link.getAttribute('href')?.match(/\/users\/(\d+)/);
if (match) { _cachedUserId = match[1]; return _cachedUserId; }
}
return null;
}
function getBookmarkURL() {
const userId = detectUserId();
if (!userId) {
showToast('유저 ID를 설정해주세요 (TM 메뉴)', 'error');
return null;
}
return `https://www.pixiv.net/users/${userId}/bookmarks/artworks`;
}
function setupGMMenu() {
GM_registerMenuCommand('북마크 유저 ID 설정', () => {
const current = GM_getValue(GM_KEY_USER_ID, '') || detectUserId();
const input = prompt(
'Pixiv 유저 ID를 입력하세요.\n' +
'(프로필 URL의 숫자: pixiv.net/users/12345678)\n\n' +
'현재: ' + current, current
);
if (input === null) return;
const id = input.trim();
if (/^\d+$/.test(id)) {
GM_setValue(GM_KEY_USER_ID, id);
_cachedUserId = id;
showToast('유저 ID 저장: ' + id, 'success');
} else if (id === '') {
GM_setValue(GM_KEY_USER_ID, '');
_cachedUserId = null;
showToast('유저 ID 초기화 (자동 감지)', 'success');
} else {
alert('숫자만 입력해주세요.');
}
});
GM_registerMenuCommand('현재 북마크 URL 확인', () => {
const url = getBookmarkURL();
const source = GM_getValue(GM_KEY_USER_ID, '') ? '수동 설정' : '자동 감지';
alert('북마크 URL (' + source + '):\n' + url);
});
GM_registerMenuCommand('단축키 설정', () => {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', showSettingsOverlay, { once: true });
} else {
showSettingsOverlay();
}
});
}
// ═══════════════════════════════════════════
// ── [KB1] 키 바인딩 시스템 ──
// ═══════════════════════════════════════════
const GM_KEY_BINDINGS = 'pixivnavi_keybindings';
const DEFAULT_BINDINGS = {
'nav.back': 'q', 'nav.forward': 'e', 'nav.bookmark': 't', 'help.toggle': '?',
'grid.toggle': 'Tab', 'grid.up': 'w', 'grid.down': 's', 'grid.left': 'a',
'grid.right': 'd', 'grid.open': ' ', 'grid.openNewTab': 'r', 'grid.exit': 'Escape',
'art.up': 'w', 'art.down': 's', 'art.left': 'a', 'art.right': 'd',
'art.profile': 'r', 'art.profileNewTab': 'Shift+r',
'art.scrollDown': ' ', 'art.scrollUp': 'Shift+ ',
};
const ACTION_LABELS = {
'nav.back': '뒤로가기', 'nav.forward': '앞으로가기', 'nav.bookmark': '내 북마크',
'help.toggle': '단축키 가이드',
'grid.toggle': '탐색 모드 토글', 'grid.up': '위로 이동', 'grid.down': '아래로 이동',
'grid.left': '왼쪽으로 이동', 'grid.right': '오른쪽으로 이동',
'grid.open': '작품 열기', 'grid.openNewTab': '새 탭으로 열기', 'grid.exit': '탐색 해제',
'art.up': '이전 이미지', 'art.down': '다음 이미지', 'art.left': '이전 이미지',
'art.right': '다음 이미지', 'art.profile': '작가 프로필',
'art.profileNewTab': '작가 프로필 (새 탭)',
'art.scrollDown': '스크롤 아래', 'art.scrollUp': '스크롤 위',
};
function getActionContext(actionId) {
if (actionId.startsWith('nav.') || actionId === 'help.toggle') return 'global';
if (actionId.startsWith('grid.')) return 'grid';
if (actionId.startsWith('art.')) return 'art';
return 'unknown';
}
let _activeBindings = null;
let _reverseMapCache = null;
function loadBindings() {
const base = { ...DEFAULT_BINDINGS };
try {
const stored = GM_getValue(GM_KEY_BINDINGS, null);
if (stored) {
const custom = JSON.parse(stored);
for (const actionId of Object.keys(custom)) {
if (actionId in base) base[actionId] = custom[actionId];
}
}
} catch (e) {
console.warn('[pixivnavi] 키 바인딩 로드 실패:', e);
}
_activeBindings = base;
_reverseMapCache = null;
return base;
}
function saveBindings(bindings) {
const custom = {};
for (const [actionId, key] of Object.entries(bindings)) {
if (DEFAULT_BINDINGS[actionId] !== key) custom[actionId] = key;
}
GM_setValue(GM_KEY_BINDINGS, JSON.stringify(custom));
_activeBindings = { ...bindings };
_reverseMapCache = null;
}
function resetBindings() {
GM_setValue(GM_KEY_BINDINGS, JSON.stringify({}));
_activeBindings = { ...DEFAULT_BINDINGS };
_reverseMapCache = null;
}
function getBinding(actionId) {
if (!_activeBindings) loadBindings();
return _activeBindings[actionId] || DEFAULT_BINDINGS[actionId] || null;
}
function normalizeKeyEvent(e) {
let key = e.key;
if (key.length === 1 && key !== ' ') key = key.toLowerCase();
if (e.shiftKey && key !== 'Shift') {
const isShiftedSymbol = (key.length === 1 && (key === key.toUpperCase()) && !/[a-z0-9 ]/.test(key));
if (!isShiftedSymbol) return 'Shift+' + key;
}
return key;
}
function findConflict(actionId, newKey, bindings) {
const targetCtx = getActionContext(actionId);
for (const [otherId, otherKey] of Object.entries(bindings)) {
if (otherId === actionId || otherKey !== newKey) continue;
const otherCtx = getActionContext(otherId);
if (otherCtx === targetCtx || otherCtx === 'global' || targetCtx === 'global') return otherId;
}
return null;
}
function keyDisplayName(key) {
const names = { ' ': 'Space', 'Tab': 'Tab', 'Enter': 'Enter', 'Escape': 'Esc' };
if (key.startsWith('Shift+')) {
const rest = key.slice(6);
return 'Shift+' + (names[rest] || rest.toUpperCase());
}
return names[key] || key.toUpperCase();
}
loadBindings();
function getReverseMap() {
if (_reverseMapCache) return _reverseMapCache;
if (!_activeBindings) loadBindings();
const map = { global: {}, grid: {}, art: {} };
for (const [actionId, key] of Object.entries(_activeBindings)) {
const ctx = getActionContext(actionId);
if (ctx in map) map[ctx][key] = actionId;
}
_reverseMapCache = map;
return map;
}
const ART_ARROW_MAP = {
'art.up': 'ArrowUp', 'art.down': 'ArrowDown',
'art.left': 'ArrowLeft', 'art.right': 'ArrowRight'
};
// ═══════════════════════════════════════════
// ── 유틸리티 ──
// ═══════════════════════════════════════════
function isArtworkPage() {
return /^\/([a-z]{2}\/)?artworks\/\d+/.test(location.pathname);
}
function isTyping() {
const el = document.activeElement;
if (!el) return false;
const tag = el.tagName.toUpperCase();
return ['INPUT', 'TEXTAREA', 'SELECT'].includes(tag) || el.isContentEditable;
}
const _pageWin = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;
// ═══════════════════════════════════════════
// ── WASD → 방향키 매핑 ──
// ═══════════════════════════════════════════
const keyCodeMap = { 'ArrowUp': 38, 'ArrowLeft': 37, 'ArrowDown': 40, 'ArrowRight': 39 };
// sandbox 모드에서는 페이지의 KeyboardEvent 생성자와 window를 사용해야
// Pixiv의 React 이벤트 핸들러가 인식함
function dispatchArrowKey(mappedKey) {
const KBEvent = _pageWin.KeyboardEvent || KeyboardEvent;
const opts = {
key: mappedKey, code: mappedKey,
keyCode: keyCodeMap[mappedKey], which: keyCodeMap[mappedKey],
bubbles: true, cancelable: true, composed: true, view: _pageWin
};
const target = document.activeElement || document.body;
target.dispatchEvent(new KBEvent('keydown', opts));
target.dispatchEvent(new KBEvent('keyup', opts));
}
function findArtistProfileLink() {
return document.querySelector('a[href*="/users/"][data-click-label]') ||
document.querySelector('aside a[href*="/users/"]') ||
document.querySelector('a[href*="/users/"]:not([href*="/artworks"])');
}
function goToArtistProfile() {
const link = findArtistProfileLink();
if (link) link.click();
}
function openArtistProfileNewTab() {
const link = findArtistProfileLink();
if (link) window.open(link.href, '_blank');
}
// ═══════════════════════════════════════════
// ── [S3] 포커스 스타일 (원본 저장/복원) ──
// ═══════════════════════════════════════════
let gridFocusIndex = -1;
const FOCUS_STYLE_PROPS = [
{ cssProp: 'outline', dataKey: 'Outline' },
{ cssProp: 'outline-offset', dataKey: 'OutlineOffset' },
{ cssProp: 'box-shadow', dataKey: 'BoxShadow' },
{ cssProp: 'position', dataKey: 'Position' },
{ cssProp: 'z-index', dataKey: 'ZIndex' }
];
const DATASET_PREFIX = 'pixivnaviOrig';
function applyFocusStyle(el) {
FOCUS_STYLE_PROPS.forEach(({ cssProp, dataKey }) => {
const v = el.style.getPropertyValue(cssProp);
el.dataset[DATASET_PREFIX + dataKey] = v !== '' ? v : '__none__';
});
el.dataset.pixivnaviFocus = '1';
el.style.outline = '3px solid #0096fa';
el.style.outlineOffset = '2px';
el.style.boxShadow = '0 0 12px rgba(0, 150, 250, 0.5)';
el.style.position = 'relative';
el.style.zIndex = '10';
}
function removeFocusStyle(el) {
FOCUS_STYLE_PROPS.forEach(({ cssProp, dataKey }) => {
const key = DATASET_PREFIX + dataKey;
const v = el.dataset[key];
if (v === undefined || v === '__none__') el.style.removeProperty(cssProp);
else el.style.setProperty(cssProp, v);
delete el.dataset[key];
});
delete el.dataset.pixivnaviFocus;
}
function clearAllFocusStyles() {
document.querySelectorAll('[data-pixivnavi-focus]').forEach(removeFocusStyle);
}
// ═══════════════════════════════════════════
// ── 토스트 알림 (type: info / success / error) ──
// ═══════════════════════════════════════════
let toastEl = null;
let toastTimer = null;
const TOAST_COLORS = {
info: 'rgba(0, 0, 0, 0.85)',
success: 'rgba(0, 150, 250, 0.9)',
error: 'rgba(220, 53, 69, 0.9)'
};
const TOAST_DURATIONS = { info: 2000, success: 2000, error: 3000 };
function showToast(msg, type = 'info') {
if (toastEl && !toastEl.isConnected) toastEl = null;
if (!toastEl) {
toastEl = document.createElement('div');
Object.assign(toastEl.style, {
position: 'fixed', top: '20px', left: '50%',
transform: 'translateX(-50%)', color: '#fff',
padding: '10px 24px', borderRadius: '8px',
fontSize: '14px', fontWeight: '500',
zIndex: '99999', pointerEvents: 'none',
opacity: '0', transition: 'opacity 0.2s ease', whiteSpace: 'nowrap'
});
document.body.appendChild(toastEl);
}
toastEl.style.background = TOAST_COLORS[type] || TOAST_COLORS.info;
toastEl.textContent = msg;
toastEl.style.opacity = '1';
clearTimeout(toastTimer);
toastTimer = setTimeout(() => { if (toastEl) toastEl.style.opacity = '0'; },
TOAST_DURATIONS[type] || TOAST_DURATIONS.info);
}
// ═══════════════════════════════════════════
// ── [S5] 상태 인디케이터 (탐색 N / M) ──
// ═══════════════════════════════════════════
let statusEl = null;
function _ensureStatusEl() {
if (statusEl && !statusEl.isConnected) statusEl = null;
if (!statusEl) {
statusEl = document.createElement('div');
Object.assign(statusEl.style, {
position: 'fixed', bottom: '20px', right: '20px',
background: 'rgba(0, 150, 250, 0.9)', color: '#fff',
padding: '8px 16px', borderRadius: '6px',
fontSize: '13px', fontWeight: '600',
zIndex: '99999', pointerEvents: 'none',
transition: 'opacity 0.2s ease', lineHeight: '1.4'
});
}
return statusEl;
}
function showStatusIndicator(currentIndex, totalCount) {
const el = _ensureStatusEl();
el.textContent = '탐색 ' + currentIndex + ' / ' + totalCount;
if (!el.parentNode) document.body.appendChild(el);
}
function hideStatusIndicator() {
if (statusEl && statusEl.parentNode) statusEl.remove();
}
// ═══════════════════════════════════════════
// ── [S6] Artworks 진입 배지 ──
// ═══════════════════════════════════════════
let artworksBadgeEl = null;
let artworksBadgeTimer = null;
let _artworksBadgeShown = false;
function showArtworksBadge() {
if (_artworksBadgeShown) return;
_artworksBadgeShown = true;
if (artworksBadgeEl && !artworksBadgeEl.isConnected) artworksBadgeEl = null;
if (!artworksBadgeEl) {
artworksBadgeEl = document.createElement('div');
Object.assign(artworksBadgeEl.style, {
position: 'fixed', bottom: '20px', right: '20px',
background: 'rgba(0, 0, 0, 0.6)', color: '#fff',
padding: '8px 16px', borderRadius: '6px',
fontSize: '13px', fontWeight: '600',
zIndex: '99999', pointerEvents: 'none',
opacity: '0', transition: 'opacity 0.3s ease', lineHeight: '1.4'
});
}
artworksBadgeEl.textContent = '이미지 탐색 | WASD';
artworksBadgeEl.style.opacity = '1';
if (!artworksBadgeEl.parentNode) document.body.appendChild(artworksBadgeEl);
clearTimeout(artworksBadgeTimer);
artworksBadgeTimer = setTimeout(() => {
if (artworksBadgeEl) {
artworksBadgeEl.style.opacity = '0';
setTimeout(() => { if (artworksBadgeEl?.parentNode) artworksBadgeEl.remove(); }, 300);
}
}, 2000);
}
function resetArtworksBadge() {
_artworksBadgeShown = false;
clearTimeout(artworksBadgeTimer);
if (artworksBadgeEl?.parentNode) artworksBadgeEl.remove();
}
// ═══════════════════════════════════════════
// ── [S1] 그리드 아이템 (캐시 + 무효화) ──
// ═══════════════════════════════════════════
const _gridCache = { items: null, cols: 1, timestamp: 0, TTL: 300 };
function invalidateGridCache() { _gridCache.items = null; _gridCache.cols = 1; _gridCache.timestamp = 0; }
window.addEventListener('resize', invalidateGridCache);
function _calcGridColumns(items) {
if (items.length < 2) return 1;
const firstY = items[0].getBoundingClientRect().top;
for (let i = 1; i < items.length; i++) {
if (Math.abs(items[i].getBoundingClientRect().top - firstY) > 10) return i;
}
return items.length;
}
function getGridItems() {
const now = Date.now();
if (_gridCache.items !== null && (now - _gridCache.timestamp) < _gridCache.TTL) return _gridCache.items;
const allLi = document.querySelectorAll('li[size]');
const items = [...allLi].filter(li => {
if (!li.querySelector('a[href*="/artworks/"]')) return false;
const rect = li.getBoundingClientRect();
return rect.width > 30 && rect.height > 30;
});
_gridCache.items = items;
_gridCache.cols = _calcGridColumns(items);
_gridCache.timestamp = now;
return items;
}
// ═══════════════════════════════════════════
// ── 그리드 탐색 헬퍼 ──
// ═══════════════════════════════════════════
function isSameGridSection(a, b) {
return a.closest('ul') === b.closest('ul');
}
function updateGridFocus(items) {
clearAllFocusStyles();
if (gridFocusIndex >= 0 && gridFocusIndex < items.length) {
const target = items[gridFocusIndex];
applyFocusStyle(target);
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
showStatusIndicator(gridFocusIndex + 1, items.length);
}
}
function exitGridFocus() {
gridFocusIndex = -1;
clearAllFocusStyles();
hideStatusIndicator();
}
function getArtworkFigures() {
return [...document.querySelectorAll('figure')].filter(f => !f.closest('li[size]'));
}
function scrollToLastArtworkImage() {
const figures = getArtworkFigures();
if (figures.length) {
exitGridFocus();
figures[figures.length - 1].scrollIntoView({ behavior: 'smooth', block: 'center' });
return true;
}
return false;
}
function isLastArtworkImageVisible() {
const figures = getArtworkFigures();
if (!figures.length) return false;
const rect = figures[figures.length - 1].getBoundingClientRect();
return rect.bottom <= window.innerHeight + 100;
}
function enterGridFromArtwork() {
invalidateGridCache();
const items = getGridItems();
if (items.length > 0) {
gridFocusIndex = 0;
updateGridFocus(items);
showToast('키보드 탐색 ON', 'success');
return true;
}
showToast('작품 목록 로딩 중...');
window.scrollBy({ top: window.innerHeight, behavior: 'smooth' });
waitForGridItems((loaded) => {
gridFocusIndex = 0;
updateGridFocus(loaded);
showToast('키보드 탐색 ON', 'success');
});
return true;
}
// ═══════════════════════════════════════════
// ── [S2] MutationObserver 기반 그리드 대기 ──
// ═══════════════════════════════════════════
let _gridWaitCleanup = null;
function waitForGridItems(callback, timeoutMs = 4000) {
cancelGridWait();
const observer = new MutationObserver(() => {
invalidateGridCache();
const items = getGridItems();
if (items.length > 0) { cancelGridWait(); callback(items); }
});
const timer = setTimeout(() => {
cancelGridWait();
showToast('작품 목록을 찾을 수 없습니다', 'error');
}, timeoutMs);
_gridWaitCleanup = () => { observer.disconnect(); clearTimeout(timer); _gridWaitCleanup = null; };
observer.observe(document.body, { childList: true, subtree: true });
}
function cancelGridWait() { if (_gridWaitCleanup) _gridWaitCleanup(); }
// ═══════════════════════════════════════════
// ── [S4] "모두 보기" / 페이지네이션 (다국어) ──
// ═══════════════════════════════════════════
const VIEW_ALL_TEXTS = ['모두 보기', 'See all', 'すべて見る', '查看全部'];
function isListingPage() {
return /\/users\/\d+\/(artworks|illustrations|manga)\b/.test(location.pathname);
}
function findViewAllLink(li) {
if (!li) return null;
const onListing = isListingPage();
let container = li.closest('ul')?.parentElement;
while (container) {
const next = container.nextElementSibling;
if (next) {
const link = next.querySelector('a') || (next.tagName === 'A' ? next : null);
if (link) {
const text = link.textContent.trim();
const href = link.getAttribute('href') || '';
const isViewAll = VIEW_ALL_TEXTS.some(t => text.includes(t));
const isListingLink = !onListing && /\/users\/\d+\/(artworks|illustrations|manga)/.test(href);
if (isViewAll || isListingLink) return link;
}
}
container = container.parentElement;
}
return null;
}
function clickViewAll(currentLi) {
const link = findViewAllLink(currentLi);
if (link) { exitGridFocus(); link.click(); return; }
const paginationNav = [...document.querySelectorAll('nav')].find(nav =>
nav.querySelector('a[href*="?p="]'));
if (paginationNav) {
const pageLinks = paginationNav.querySelectorAll('a');
const nextPageLink = pageLinks[pageLinks.length - 1];
if (nextPageLink && nextPageLink.getAttribute('aria-disabled') !== 'true') {
exitGridFocus(); nextPageLink.click();
} else { showToast('마지막 페이지입니다'); }
return;
}
showToast('더 이상 이동할 곳이 없습니다');
}
// ═══════════════════════════════════════════
// ── [O1] SPA 라우트 감지 ──
// ═══════════════════════════════════════════
let _lastKnownPath = location.pathname + location.search;
function onRouteChange(newPath) {
if (newPath === _lastKnownPath) return;
_lastKnownPath = newPath;
exitGridFocus();
cancelGridWait();
invalidateGridCache();
resetArtworksBadge();
if (toastEl) { toastEl.style.opacity = '0'; clearTimeout(toastTimer); }
setTimeout(() => { _cachedUserId = null; detectUserId(); }, 2000);
setTimeout(ensureFloatingHelpBtn, 500);
}
function setupSPARouteDetection() {
const realHistory = _pageWin.history;
const origPush = realHistory.pushState;
const origReplace = realHistory.replaceState;
const parseURL = (url) => { const u = new URL(url, location.origin); return u.pathname + u.search; };
realHistory.pushState = function(s, t, url) {
const r = origPush.apply(this, arguments);
if (url) onRouteChange(parseURL(url));
return r;
};
realHistory.replaceState = function(s, t, url) {
const r = origReplace.apply(this, arguments);
if (url) onRouteChange(parseURL(url));
return r;
};
window.addEventListener('popstate', () => onRouteChange(location.pathname + location.search));
function observeTitle() {
const titleEl = document.querySelector('title');
if (titleEl) {
new MutationObserver(() => {
const p = location.pathname + location.search;
if (p !== _lastKnownPath) onRouteChange(p);
}).observe(titleEl, { childList: true, characterData: true, subtree: true });
} else {
const ho = new MutationObserver(() => {
if (document.querySelector('title')) { ho.disconnect(); observeTitle(); }
});
const t = document.head || document.documentElement;
if (t) ho.observe(t, { childList: true, subtree: true });
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', observeTitle, { once: true });
} else { observeTitle(); }
}
// ═══════════════════════════════════════════
// ── 메인 키보드 핸들러 (바인딩 시스템 기반) ──
// ═══════════════════════════════════════════
window.addEventListener('keydown', function(e) {
if (isTyping()) return;
if (e.ctrlKey || e.altKey || e.metaKey) return;
// 오버레이가 열려 있을 때
if (_helpOverlayVisible) {
if (e.key === 'Escape' || normalizeKeyEvent(e) === getBinding('help.toggle')) {
e.preventDefault(); hideHelpOverlay();
}
return;
}
if (_settingsOverlayVisible) return;
const keyStr = normalizeKeyEvent(e);
const rmap = getReverseMap();
// ── 전역 액션 ──
const globalAction = rmap.global[keyStr];
if (globalAction) {
e.preventDefault();
switch (globalAction) {
case 'help.toggle': showHelpOverlay(); return;
case 'nav.back': exitGridFocus(); history.back(); return;
case 'nav.forward': exitGridFocus(); history.forward(); return;
case 'nav.bookmark': exitGridFocus(); const burl = getBookmarkURL(); if (burl) location.href = burl; return;
}
}
const gridAction = rmap.grid[keyStr];
// ── 그리드 탐색 모드 토글 ──
if (gridAction === 'grid.toggle') {
e.preventDefault();
if (gridFocusIndex >= 0) {
exitGridFocus();
showToast('키보드 탐색 OFF');
} else {
const items = getGridItems();
if (items.length > 0) {
gridFocusIndex = 0;
updateGridFocus(items);
showToast('키보드 탐색 ON', 'success');
} else {
showToast('작품 목록 로딩 중...');
window.scrollTo({ top: document.documentElement.scrollHeight, behavior: 'smooth' });
waitForGridItems((loaded) => {
gridFocusIndex = 0;
updateGridFocus(loaded);
showToast('키보드 탐색 ON', 'success');
});
}
}
return;
}
// ── artworks 외 페이지에서 WASD → 자동 그리드 탐색 진입 ──
if (gridFocusIndex < 0 && !isArtworkPage()) {
if (gridAction && gridAction.startsWith('grid.') && gridAction !== 'grid.toggle') {
const items = getGridItems();
if (items.length > 0) {
gridFocusIndex = 0;
updateGridFocus(items);
e.preventDefault(); e.stopPropagation();
return;
}
}
}
// ── 그리드 탐색 모드 활성 시 ──
if (gridFocusIndex >= 0) {
const items = getGridItems();
if (items.length === 0) { exitGridFocus(); return; }
const cols = _gridCache.cols;
if (gridAction) {
e.preventDefault(); e.stopPropagation();
switch (gridAction) {
case 'grid.up': {
const ni = gridFocusIndex - cols;
const atTop = ni < 0 || (!isSameGridSection(items[gridFocusIndex], items[ni]) && findViewAllLink(items[ni]));
if (atTop) {
if (isArtworkPage() && scrollToLastArtworkImage()) {}
else showToast('첫 번째 줄입니다');
}
else { gridFocusIndex = ni; updateGridFocus(items); }
return;
}
case 'grid.down': {
const ni = gridFocusIndex + cols;
if (ni >= items.length) { clickViewAll(items[gridFocusIndex]); }
else if (!isSameGridSection(items[gridFocusIndex], items[ni])) {
const vl = findViewAllLink(items[gridFocusIndex]);
if (vl) { exitGridFocus(); vl.click(); }
else { gridFocusIndex = ni; updateGridFocus(items); }
}
else { gridFocusIndex = ni; updateGridFocus(items); }
return;
}
case 'grid.left':
if (gridFocusIndex <= 0) { showToast('처음입니다'); }
else if (!isSameGridSection(items[gridFocusIndex], items[gridFocusIndex - 1]) && findViewAllLink(items[gridFocusIndex - 1]))
showToast('처음입니다');
else { gridFocusIndex--; updateGridFocus(items); }
return;
case 'grid.right':
if (gridFocusIndex >= items.length - 1) { clickViewAll(items[gridFocusIndex]); }
else if (!isSameGridSection(items[gridFocusIndex], items[gridFocusIndex + 1])) {
const vl = findViewAllLink(items[gridFocusIndex]);
if (vl) { exitGridFocus(); vl.click(); }
else { gridFocusIndex++; updateGridFocus(items); }
}
else { gridFocusIndex++; updateGridFocus(items); }
return;
case 'grid.open': {
const li = items[gridFocusIndex];
if (li) { const a = li.querySelector('a[href*="/artworks/"]'); exitGridFocus(); if (a) a.click(); }
return;
}
case 'grid.openNewTab': {
const li = items[gridFocusIndex];
if (li) { const a = li.querySelector('a[href*="/artworks/"]'); if (a) window.open(a.href, '_blank'); }
return;
}
case 'grid.exit':
exitGridFocus(); showToast('키보드 탐색 OFF'); return;
}
}
return;
}
// ── artworks 페이지 전용 ──
if (!isArtworkPage()) return;
const artAction = rmap.art[keyStr];
if (!artAction) return;
e.preventDefault();
// 마지막 이미지에서 S → 하단 그리드로 진입
if (artAction === 'art.down' && isLastArtworkImageVisible()) {
if (enterGridFromArtwork()) return;
}
const arrow = ART_ARROW_MAP[artAction];
if (arrow) {
e.stopPropagation();
showArtworksBadge();
dispatchArrowKey(arrow);
return;
}
switch (artAction) {
case 'art.profileNewTab': openArtistProfileNewTab(); return;
case 'art.profile': goToArtistProfile(); return;
case 'art.scrollDown': window.scrollBy({ top: window.innerHeight * 0.7, behavior: 'smooth' }); return;
case 'art.scrollUp': window.scrollBy({ top: -window.innerHeight * 0.7, behavior: 'smooth' }); return;
}
}, true);
// ═══════════════════════════════════════════
// ── [KB2] 단축키 가이드 오버레이 ──
// ═══════════════════════════════════════════
let _helpOverlayVisible = false;
let _helpOverlayEl = null;
function buildHelpOverlay() {
const b = (id) => keyDisplayName(getBinding(id));
const onArt = isArtworkPage();
const gridActive = gridFocusIndex >= 0;
const overlay = document.createElement('div');
overlay.id = 'pixivnavi-help-overlay';
Object.assign(overlay.style, {
position: 'fixed', top: 0, left: 0, width: '100%', height: '100%',
background: 'rgba(0,0,0,0.7)', display: 'flex',
alignItems: 'center', justifyContent: 'center',
zIndex: '100000', fontFamily: '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif',
cursor: 'pointer'
});
const panel = document.createElement('div');
Object.assign(panel.style, {
background: '#1e1e2e', color: '#cdd6f4', borderRadius: '16px',
padding: '32px 40px', maxWidth: '680px', width: '90%',
boxShadow: '0 20px 60px rgba(0,0,0,0.5)', cursor: 'default', position: 'relative'
});
panel.addEventListener('click', e => e.stopPropagation());
const title = document.createElement('h2');
title.textContent = 'PixivNavi 단축키 가이드';
Object.assign(title.style, {
textAlign: 'center', margin: '0 0 24px', fontSize: '20px',
fontWeight: '700', color: '#89b4fa'
});
panel.appendChild(title);
const closeBtn = document.createElement('button');
closeBtn.textContent = '\u00D7';
Object.assign(closeBtn.style, {
position: 'absolute', top: '12px', right: '16px',
background: 'none', border: 'none', color: '#6c7086',
fontSize: '24px', cursor: 'pointer', padding: '4px 8px'
});
closeBtn.addEventListener('click', hideHelpOverlay);
panel.appendChild(closeBtn);
const grid = document.createElement('div');
Object.assign(grid.style, { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '24px 32px' });
function section(titleText, items, active) {
const sec = document.createElement('div');
const h = document.createElement('div');
h.textContent = titleText;
Object.assign(h.style, {
fontSize: '13px', fontWeight: '700', marginBottom: '10px',
padding: '4px 10px', borderRadius: '6px', display: 'inline-block',
background: active ? 'rgba(137,180,250,0.2)' : 'rgba(108,112,134,0.15)',
color: active ? '#89b4fa' : '#a6adc8',
border: active ? '1px solid rgba(137,180,250,0.3)' : '1px solid transparent'
});
sec.appendChild(h);
items.forEach(([k, d]) => {
const row = document.createElement('div');
Object.assign(row.style, { display: 'flex', alignItems: 'center', gap: '10px', padding: '3px 0', fontSize: '13px' });
const kbd = document.createElement('kbd');
kbd.textContent = k;
Object.assign(kbd.style, {
display: 'inline-block', minWidth: '50px', textAlign: 'center',
padding: '2px 8px', borderRadius: '5px', fontSize: '12px',
fontFamily: 'monospace', fontWeight: '600',
background: '#313244', color: '#f5e0dc', border: '1px solid #45475a'
});
const lbl = document.createElement('span');
lbl.textContent = d;
lbl.style.color = '#bac2de';
row.appendChild(kbd); row.appendChild(lbl);
sec.appendChild(row);
});
return sec;
}
grid.appendChild(section('[전역]', [
[b('nav.back'), '뒤로가기'], [b('nav.forward'), '앞으로가기'],
[b('nav.bookmark'), '내 북마크'], [b('help.toggle'), '이 가이드']
], true));
grid.appendChild(section('[그리드 탐색]', [
[b('grid.toggle'), '탐색 모드 토글'],
[b('grid.up')+b('grid.left')+b('grid.down')+b('grid.right'), '이동'],
[b('grid.open'), '작품 열기'], [b('grid.openNewTab'), '새 탭으로 열기'],
[b('grid.exit'), '탐색 해제']
], gridActive));
grid.appendChild(section('[artworks]', [
[b('art.up')+b('art.left')+b('art.down')+b('art.right'), '이미지 넘기기'],
[b('art.profile'), '작가 프로필'],
[b('art.profileNewTab'), '작가 프로필 (새 탭)'],
[b('art.scrollDown'), '스크롤 아래'], [b('art.scrollUp'), '스크롤 위']
], onArt && !gridActive));
grid.appendChild(section('[Pixiv 기본 단축키]', [
['Z', '미리보기'], ['F', '팔로우'],
['B', '북마크 (하트)'], ['C', '댓글'],
['V', '확대'], ['Shift+B', '비공개 북마크']
], false));
const footer = document.createElement('div');
Object.assign(footer.style, {
textAlign: 'center', fontSize: '12px', color: '#6c7086',
marginTop: '16px', gridColumn: '1 / -1'
});
footer.textContent = '단축키 변경: Tampermonkey 메뉴 > 단축키 설정';
grid.appendChild(footer);
panel.appendChild(grid);
overlay.appendChild(panel);
overlay.addEventListener('click', hideHelpOverlay);
return overlay;
}
function showHelpOverlay() {
if (_helpOverlayVisible) return;
const old = document.getElementById('pixivnavi-help-overlay');
if (old) old.remove();
_helpOverlayEl = buildHelpOverlay();
document.body.appendChild(_helpOverlayEl);
_helpOverlayVisible = true;
}
function hideHelpOverlay() {
if (_helpOverlayEl?.isConnected) _helpOverlayEl.remove();
_helpOverlayEl = null;
_helpOverlayVisible = false;
}
// ═══════════════════════════════════════════
// ── [KB3] 단축키 설정 오버레이 ──
// ═══════════════════════════════════════════
let _settingsOverlayVisible = false;
let _settingsOverlayEl = null;
const CTX_LABELS = { global: '전역', grid: '그리드 탐색', art: 'artworks' };
function buildSettingsOverlay() {
if (!_activeBindings) loadBindings();
const edit = { ..._activeBindings };
const overlay = document.createElement('div');
overlay.id = 'pixivnavi-settings-overlay';
Object.assign(overlay.style, {
position: 'fixed', top: 0, left: 0, width: '100%', height: '100%',
background: 'rgba(0,0,0,0.75)', display: 'flex',
alignItems: 'center', justifyContent: 'center',
zIndex: '100001', fontFamily: '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif'
});
const panel = document.createElement('div');
Object.assign(panel.style, {
background: '#1e1e2e', color: '#cdd6f4', borderRadius: '16px',
padding: '28px 36px', maxWidth: '560px', width: '92%',
maxHeight: '85vh', overflowY: 'auto',
boxShadow: '0 20px 60px rgba(0,0,0,0.5)'
});
const title = document.createElement('h2');
title.textContent = 'PixivNavi 단축키 설정';
Object.assign(title.style, {
textAlign: 'center', margin: '0 0 20px', fontSize: '18px', fontWeight: '700', color: '#89b4fa'
});
panel.appendChild(title);
const statusMsg = document.createElement('div');
Object.assign(statusMsg.style, {
textAlign: 'center', fontSize: '13px', color: '#a6e3a1', minHeight: '20px', marginBottom: '12px'
});
panel.appendChild(statusMsg);
const groups = { global: [], grid: [], art: [] };
for (const id of Object.keys(DEFAULT_BINDINGS)) groups[getActionContext(id)].push(id);
const rowRefs = {};
for (const [ctx, ids] of Object.entries(groups)) {
if (!ids.length) continue;
const hdr = document.createElement('div');
hdr.textContent = CTX_LABELS[ctx] || ctx;
Object.assign(hdr.style, {
fontSize: '13px', fontWeight: '700', color: '#89b4fa',
margin: '16px 0 8px', paddingBottom: '4px', borderBottom: '1px solid #313244'
});
panel.appendChild(hdr);
for (const actionId of ids) {
const row = document.createElement('div');
Object.assign(row.style, {
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '6px 8px', borderRadius: '6px', marginBottom: '2px', transition: 'background 0.15s'
});
const lbl = document.createElement('span');
lbl.textContent = ACTION_LABELS[actionId] || actionId;
Object.assign(lbl.style, { fontSize: '13px', color: '#bac2de', flex: '1' });
const kbd = document.createElement('kbd');
kbd.textContent = keyDisplayName(edit[actionId]);
const isCustom = edit[actionId] !== DEFAULT_BINDINGS[actionId];
Object.assign(kbd.style, {
display: 'inline-block', minWidth: '55px', textAlign: 'center',
padding: '3px 10px', borderRadius: '5px', fontSize: '12px',
fontFamily: 'monospace', fontWeight: '600', marginRight: '8px',
background: '#313244',
color: isCustom ? '#f9e2af' : '#f5e0dc',
border: isCustom ? '1px solid #f9e2af' : '1px solid #45475a'
});
const btn = document.createElement('button');
btn.textContent = '변경';
Object.assign(btn.style, {
padding: '3px 12px', borderRadius: '5px', fontSize: '12px',
background: '#313244', color: '#89b4fa', border: '1px solid #45475a',
cursor: 'pointer', fontWeight: '600'
});
btn.addEventListener('mouseenter', () => btn.style.background = '#45475a');
btn.addEventListener('mouseleave', () => btn.style.background = '#313244');
btn.addEventListener('click', () => startCapture(actionId, row, kbd, btn, edit, statusMsg, rowRefs));
row.appendChild(lbl); row.appendChild(kbd); row.appendChild(btn);
panel.appendChild(row);
rowRefs[actionId] = { row, kbd, btn };
}
}
// 하단 버튼
const bar = document.createElement('div');
Object.assign(bar.style, {
display: 'flex', justifyContent: 'center', gap: '12px',
marginTop: '24px', paddingTop: '16px', borderTop: '1px solid #313244'
});
const resetBtn = document.createElement('button');
resetBtn.textContent = '모두 초기화';
Object.assign(resetBtn.style, {
padding: '8px 20px', borderRadius: '8px', fontSize: '13px',
background: '#45475a', color: '#f38ba8', border: 'none', cursor: 'pointer', fontWeight: '600'
});
resetBtn.addEventListener('click', () => {
if (!confirm('모든 단축키를 기본값으로 되돌리시겠습니까?')) return;
resetBindings();
for (const [id, refs] of Object.entries(rowRefs)) {
edit[id] = DEFAULT_BINDINGS[id];
refs.kbd.textContent = keyDisplayName(DEFAULT_BINDINGS[id]);
refs.kbd.style.border = '1px solid #45475a';
refs.kbd.style.color = '#f5e0dc';
}
statusMsg.textContent = '모든 단축키가 초기화되었습니다';
statusMsg.style.color = '#a6e3a1';
});
const closeBtn = document.createElement('button');
closeBtn.textContent = '닫기';
Object.assign(closeBtn.style, {
padding: '8px 20px', borderRadius: '8px', fontSize: '13px',
background: '#89b4fa', color: '#1e1e2e', border: 'none', cursor: 'pointer', fontWeight: '700'
});
closeBtn.addEventListener('click', hideSettingsOverlay);
bar.appendChild(resetBtn); bar.appendChild(closeBtn);
panel.appendChild(bar);
overlay.appendChild(panel);
return overlay;
}
function startCapture(actionId, row, kbd, btn, edit, statusMsg, rowRefs) {
for (const [, r] of Object.entries(rowRefs)) {
r.row.style.background = '';
r.btn.textContent = '변경';
}
row.style.background = 'rgba(137,180,250,0.15)';
kbd.textContent = '키를 누르세요...';
kbd.style.color = '#89b4fa';
kbd.style.border = '1px solid #89b4fa';
btn.textContent = '취소';
statusMsg.textContent = '새로운 키를 눌러주세요. Esc로 취소.';
statusMsg.style.color = '#89b4fa';
function restore() {
const c = edit[actionId] !== DEFAULT_BINDINGS[actionId];
kbd.textContent = keyDisplayName(edit[actionId]);
kbd.style.color = c ? '#f9e2af' : '#f5e0dc';
kbd.style.border = c ? '1px solid #f9e2af' : '1px solid #45475a';
row.style.background = '';
btn.textContent = '변경';
}
function cleanup() {
document.removeEventListener('keydown', handler, true);
}
function handler(e) {
e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation();
if (e.key === 'Escape') { cleanup(); restore(); statusMsg.textContent = '취소됨'; statusMsg.style.color = '#6c7086'; return; }
if (['Control', 'Alt', 'Meta', 'Shift'].includes(e.key)) return;
if (e.ctrlKey || e.altKey || e.metaKey) {
statusMsg.textContent = 'Ctrl/Alt/Meta 조합은 지원하지 않습니다';
statusMsg.style.color = '#f38ba8'; return;
}
const newKey = normalizeKeyEvent(e);
const conflict = findConflict(actionId, newKey, edit);
if (conflict) {
const cl = ACTION_LABELS[conflict] || conflict;
const cc = CTX_LABELS[getActionContext(conflict)] || '';
statusMsg.textContent = `"${keyDisplayName(newKey)}" 은(는) [${cc}] "${cl}"에서 사용 중`;
statusMsg.style.color = '#f38ba8'; return;
}
cleanup();
edit[actionId] = newKey;
saveBindings(edit);
restore();
statusMsg.textContent = `"${ACTION_LABELS[actionId]}" → ${keyDisplayName(newKey)} 저장`;
statusMsg.style.color = '#a6e3a1';
}
btn.onclick = () => { cleanup(); restore(); statusMsg.textContent = ''; };
document.addEventListener('keydown', handler, true);
}
function showSettingsOverlay() {
if (_settingsOverlayVisible) return;
if (_helpOverlayVisible) hideHelpOverlay();
const old = document.getElementById('pixivnavi-settings-overlay');
if (old) old.remove();
_settingsOverlayEl = buildSettingsOverlay();
document.body.appendChild(_settingsOverlayEl);
_settingsOverlayVisible = true;
}
function hideSettingsOverlay() {
if (_settingsOverlayEl?.isConnected) _settingsOverlayEl.remove();
_settingsOverlayEl = null;
_settingsOverlayVisible = false;
}
// ═══════════════════════════════════════════
// ── [UI] 플로팅 ? 버튼 ──
// ═══════════════════════════════════════════
let _floatingHelpBtn = null;
function ensureFloatingHelpBtn() {
if (_floatingHelpBtn && _floatingHelpBtn.isConnected) return;
_floatingHelpBtn = document.createElement('div');
_floatingHelpBtn.id = 'pixivnavi-floating-help';
_floatingHelpBtn.textContent = '?';
Object.assign(_floatingHelpBtn.style, {
position: 'fixed', top: '50%', right: '20px', transform: 'translateY(-50%)',
width: '32px', height: '32px', borderRadius: '50%',
background: '#0096fa', color: '#fff',
fontSize: '16px', fontWeight: '700', lineHeight: '32px',
textAlign: 'center', cursor: 'pointer', zIndex: '99998',
transition: 'opacity 0.2s ease, background 0.2s ease',
userSelect: 'none', fontFamily: 'monospace',
boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
});
_floatingHelpBtn.addEventListener('mouseenter', () => {
_floatingHelpBtn.style.background = '#0074c4';
});
_floatingHelpBtn.addEventListener('mouseleave', () => {
_floatingHelpBtn.style.background = '#0096fa';
});
_floatingHelpBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (_helpOverlayVisible) hideHelpOverlay();
else showHelpOverlay();
});
document.body.appendChild(_floatingHelpBtn);
}
// ═══════════════════════════════════════════
// ── 초기화 ──
// ═══════════════════════════════════════════
setupSPARouteDetection();
setupGMMenu();
function onReady() { ensureFloatingHelpBtn(); }
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', onReady, { once: true });
} else {
onReady();
}
})();