PixivNavi

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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