PixivNavi

Pixiv 한손 키보드 탐색 스크립트. WASD 방향키 매핑, 그리드 키보드 탐색, 커스텀 단축키. SPA 대응.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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();
    }
})();