Gallery Scroll Navigator (custom setting)

Automatically navigate next/previous pages for gallery sites by vertical scrolling, with scroll buffer for turning pages. Forked from [Gallery Scroll Navigator] by [Yukiteru]. User can now apply this script to any custom websites.

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                 Gallery Scroll Navigator (custom setting)
// @namespace            https://sleazyfork.org/
// @version              2.0
// @description          Automatically navigate next/previous pages for gallery sites by vertical scrolling, with scroll buffer for turning pages. Forked from [Gallery Scroll Navigator] by [Yukiteru]. User can now apply this script to any custom websites.
// @author               php
// @match                https://hitomi.la/*
// @match                https://www.pixiv.net/*
// @match                https://nhentai.net/*
// @match                https://exhentai.org/*
// @match                https://imhentai.xxx/*
// @grant                GM_setValue
// @grant                GM_getValue
// @grant                GM_deleteValue
// @grant                GM_registerMenuCommand
// @run-at               document-end
// @license              MIT
// ==/UserScript==

(function() {
    'use strict';

    /* ==========================================================================
       0. Config 模組:集中管理所有可自訂參數、設定檔與 UI 樣式
       ========================================================================== */
    const Config = {
        DefaultSites: [
            { name: "Hitomi", host: "hitomi.la", code: "try { const pageContainer = [...document.querySelectorAll('.page-container li')]; const currentPage = pageContainer.filter(i => i.textContent !== '...').find(i => i.children.length === 0); const targetLi = direction === 'next' ? currentPage.nextElementSibling : currentPage.previousElementSibling; return targetLi ? targetLi.querySelector('a') : null; } catch(e) { return null; }" },
            { name: "Pixiv", host: "www.pixiv.net", code: "const selector = `nav:has(button) > a:${direction === 'next' ? 'last' : 'first'}-child`; const pageButton = document.querySelector(selector); return pageButton && !pageButton.hasAttribute('hidden') ? pageButton : null;" },
            { name: "Nhentai", host: "nhentai.net", code: "const selector = direction === 'next' ? '.next' : '.previous'; const pageButton = document.querySelector(selector); return pageButton || null;" },
            { name: "Exhentai", host: "exhentai.org", code: "const selector = direction === 'next' ? 'a#dnext' : 'a#dprev'; const pageButton = document.querySelector(selector); return pageButton || null;" },
            { name: "Imhentai", host: "imhentai.xxx", code: "const selector = direction === 'next' ? 'ul.pagination > li.page-item:last-child > a' : 'ul.pagination > li.page-item:first-child > a'; const pageButton = document.querySelector(selector); return pageButton && !pageButton.href.includes('#') ? pageButton : null;" }
        ],

        DefaultSettings: {
            mouseScrolls: 8,
            touchSensitivity: 5,
            disableMobileRefresh: true,
            disablePrev: false,
            accumulateMode: true  // 預設開啟累加法
        },

        System: {
            progressBarColor: 'red',
            progressBarHeight: '3px',
            progressBarZIndex: '2147483647',
            touchDistanceBaseline: 600,
            touchDistanceStep: 50,
            bottomTolerance: 5,
            observerSelectors: ['nav', '.pagination', '.pager', '.page-nav', '#pagination', 'footer'],
            safetyIntervalMs: 5000,
            menuName: "Configuration",
            wheelResetDelay: 300
        },

        UI: {
            CSS: `
                /* 動態類別:只有當使用者勾選停用重新整理時,才會套用此類別到 html/body 上 */
                html.gsn-no-refresh,
                body.gsn-no-refresh {
                    overscroll-behavior-y: contain !important;
                }

                /* --- 進度條核心樣式 --- */
                .gsn-progress-line {
                    position: fixed;
                    left: 0; right: 0;
                    pointer-events: none;
                    transform-origin: center;
                    transform: scaleX(0);
                    transition: transform 0.1s cubic-bezier(0, 0, 0.2, 1);
                }
                .gsn-progress-line.top { top: 0; }
                .gsn-progress-line.bottom { bottom: 0; }
                .gsn-progress-line.no-transition { transition: none !important; }
                .gsn-progress-line.complete {
                    filter: brightness(1.8) saturate(1.5) !important;
                    box-shadow: 0 0 12px 1px rgba(255, 255, 255, 0.9), 0 0 5px rgba(255, 0, 0, 0.8) !important;
                }
                /* --- 設定面板樣式 --- */
                #gsn-settings-overlay {
                    position: fixed; top: 0; left: 0; width: 100%; height: 100%;
                    background: rgba(0,0,0,0.5); z-index: 10000;
                    display: flex; align-items: center; justify-content: center;
                    font-family: system-ui, -apple-system, sans-serif;
                }
                .gsn-container {
                    background: rgba(255, 255, 255, 0.7) !important;
                    backdrop-filter: blur(15px) saturate(180%) !important;
                    -webkit-backdrop-filter: blur(15px) saturate(180%) !important;
                    padding: 30px !important; border-radius: 20px; width: 90%; max-width: 600px;
                    box-shadow: 0 10px 40px rgba(0,0,0,0.2) !important;
                    border: 1px solid rgba(255, 255, 255, 0.5) !important; border-top: 5px solid #007bff;
                    max-height: 90vh; overflow-y: auto;
                }
                .gsn-title { margin: 0 0 20px 0; text-align: center; color: #222; font-size: 1.5rem; border-bottom: 1px solid #eee; padding-bottom: 12px; }
                .gsn-section { margin-bottom: 20px; }
                .gsn-section-title { color: #007bff; font-weight: bold; font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px; }
                .gsn-label { display: block; margin-bottom: 8px; font-size: 0.95rem; color: #444 !important; }
                .gsn-input-num {
                    width: 100% !important; padding: 10px !important; border: 1.5px solid #ddd !important;
                    border-radius: 6px !important; box-sizing: border-box !important; font-size: 1rem !important;
                    color: #333 !important; background: #ffffff !important; color-scheme: light !important;
                    box-shadow: none !important; outline: none !important;
                }
                .gsn-input-num:focus { border-color: #007bff !important; box-shadow: 0 0 0 2px rgba(0,123,255,0.25) !important; }
                .gsn-input-range { width: 100%; cursor: pointer; margin-top: 8px; }
                .gsn-display-val { text-align: right; font-size: 0.85rem; color: #666; margin-top: 4px; }
                .gsn-checkbox-group { display: flex; align-items: center; margin-bottom: 10px; }
                .gsn-checkbox-group input[type="checkbox"] { margin-right: 10px; width: 18px; height: 18px; cursor: pointer; }
                .gsn-checkbox-group label { cursor: pointer; font-size: 0.95rem; color: #444 !important; margin: 0; }
                .gsn-btn-group { display: flex; gap: 12px; margin-top: 25px; justify-content: flex-end; }
                .gsn-btn { flex: 0 0 100px; padding: 12px; border: none; border-radius: 6px; font-weight: bold; cursor: pointer; transition: filter 0.2s; }
                .gsn-btn:active { opacity: 0.8; }
                .gsn-btn-save { background: #007bff; color: white; }
                .gsn-btn-cancel { background: #f0f0f0; color: #444; }
                textarea { color: #333 !important; background: #fff !important; font-family: monospace !important; font-size: 0.85rem !important; padding: 10px !important; min-height: 120px; }
                #gsn-del-site, #gsn-reset { background-color: #dc3545; color: white; padding: 8px 16px; border-radius: 4px; cursor: pointer; font-size: 14px; }
                #gsn-del-site:hover, #gsn-reset:hover, #gsn-save-all:hover, #gsn-cancel:hover { filter: brightness(0.9); }
            `,
            getHTML: (settings) => `
                <div class="gsn-container">
                    <h2 class="gsn-title">Configuration</h2>

                    <div class="gsn-section">
                        <p class="gsn-section-title">Sensitivity</p>
                        <label class="gsn-label">(Desktop) Mouse Wheel Scrolls (1-20):</label>
                        <input type="number" id="gsn-mouse-val" class="gsn-input-num" value="${settings.mouseScrolls}">
                        <label class="gsn-label" style="margin-top:10px;">(Mobile) Touch Sensitivity (1-10):</label>
                        <input type="range" id="gsn-touch-val" class="gsn-input-range" min="1" max="10" value="${settings.touchSensitivity}">
                        <div class="gsn-display-val">Value: <span id="gsn-touch-display">${settings.touchSensitivity}</span></div>
                    </div>

                    <div class="gsn-section">
                        <p class="gsn-section-title">Scroller Behaviour</p>
                        <div class="gsn-checkbox-group">
                            <input type="checkbox" id="gsn-disable-refresh" ${settings.disableMobileRefresh ? 'checked' : ''}>
                            <label for="gsn-disable-refresh">Disable Pull-to-Refresh (Mobile)</label>
                        </div>
                        <div class="gsn-checkbox-group">
                            <input type="checkbox" id="gsn-disable-prev" ${settings.disablePrev ? 'checked' : ''}>
                            <label for="gsn-disable-prev">Disable Previous Page Navigation (Only allow Next Page)</label>
                        </div>
                        <div class="gsn-checkbox-group">
                            <input type="checkbox" id="gsn-accumulate-mode" ${settings.accumulateMode ? 'checked' : ''}>
                            <label for="gsn-accumulate-mode">Enable Page Turning Progress Accumulate Mode <br>
                            (won't reset progress bar until screen position leaving trigger zone)</label>
                        </div>
                    </div>

                    <div class="gsn-section">
                        <p class="gsn-section-title">Site Manager</p>
                        <div style="display: flex; gap: 5px; margin-bottom: 10px;">
                            <select id="gsn-site-select" class="gsn-input-num"><option value="">-- Add New Site --</option></select>
                            <button id="gsn-del-site" class="gsn-btn gsn-btn-cancel" style="flex: 0 0 60px;">Del</button>
                        </div>
                        <input type="text" id="gsn-site-name" placeholder="Site Name" class="gsn-input-num" style="margin-bottom: 5px;">
                        <input type="text" id="gsn-site-host" placeholder="Host (e.g. example.com)" class="gsn-input-num" style="margin-bottom: 5px;">
                        <textarea id="gsn-site-code" placeholder="/* Function Body of Page Turning Function: getPageButton(direction) {...} */&#10;&#10; Example: return document.querySelector('.next-btn');&#10;&#10; * @param {string} direction: 'next' or 'prev', indicate the page turning direction.&#10; * @returns {HTMLElement}: clickable element (next/previous page button) depending on direction.&#10; * Note: Return null if clickable element doesn't exist. (e.g., no previous page in the 1st page)" class="gsn-input-num" rows="11"></textarea>
                    </div>

                    <div class="gsn-btn-group">
                        <button id="gsn-reset" class="gsn-btn gsn-btn-cancel">Reset</button>
                        <button id="gsn-cancel" class="gsn-btn gsn-btn-cancel">Close</button>
                        <button id="gsn-save-all" class="gsn-btn gsn-btn-save">Save</button>
                    </div>
                </div>
            `
        }
    };

    /* ==========================================================================
       1. Logger 模組:負責統一處理終端機日誌輸出
       ========================================================================== */
    const Logger = {
        info: (message) => console.log(`[Scroll Pager]: ${message}`),
        error: (message, err) => console.error(`[Scroll Pager Error]: ${message}`, err)
    };

    /* ==========================================================================
       2. Store 模組:負責與 Tampermonkey 的存儲 API (GM_setValue/getValue) 溝通
       ========================================================================== */
    const Store = {
        getSetting: (key) => GM_getValue(key, Config.DefaultSettings[key]),
        setSetting: (key, value) => GM_setValue(key, value),

        getSites: () => {
            const stored = GM_getValue('customSites', null);
            return stored ? JSON.parse(stored) : Config.DefaultSites;
        },
        saveSites: (sites) => GM_setValue('customSites', JSON.stringify(sites)),

        resetAll: () => {
            GM_deleteValue('customSites');
            GM_deleteValue('mouseScrolls');
            GM_deleteValue('touchSensitivity');
            GM_deleteValue('disableMobileRefresh');
            GM_deleteValue('disablePrev');
            GM_deleteValue('accumulateMode');
        }
    };

    /* ==========================================================================
       3. Progress 模組:負責在畫面上建立、更新及重置頂部/底部的紅色進度條
       ========================================================================== */
    const Progress = {
        bars: { top: null, bottom: null },

        init: () => {
            Progress.bars.bottom = Progress.createBar('bottom');
            Progress.bars.top = Progress.createBar('top');
        },

        createBar: (position) => {
            const bar = document.createElement('div');
            // 只依賴 CSS class,不再 hardcode 寫入 style
            bar.classList.add('gsn-progress-line', position);
            document.body.appendChild(bar);
            return bar;
        },

        update: (bar, currentProgress, maxProgress) => {
            if (!bar) return;
            const scale = Math.min(currentProgress / maxProgress, 1);
            bar.style.transform = `scaleX(${scale})`;

            // 當進度達到 100% 時加上 complete 類別,否則移除
            if (scale >= 1) bar.classList.add('complete');
            else bar.classList.remove('complete');
        },

        reset: () => {
            [Progress.bars.top, Progress.bars.bottom].forEach(bar => {
                if (!bar) return;
                bar.classList.add('no-transition');
                bar.classList.remove('complete'); // 重置時移除發光效果
                bar.style.transform = 'scaleX(0)';
                void bar.offsetWidth;
                bar.classList.remove('no-transition');
            });
        }
    };

    /* ==========================================================================
       4. Navigator 模組:負責尋找當前網站的目標按鈕,並執行點擊翻頁動作
       ========================================================================== */
    const Navigator = {
        getCurrentSite: () => {
            const site = Store.getSites().find(s => location.host === s.host);
            if (!site) return null;
            return {
                host: site.host,
                getPageButton: new Function('direction', site.code)
            };
        },

        triggerPage: (direction) => {
            const site = Navigator.getCurrentSite();
            const btn = site?.getPageButton(direction);
            if (btn) btn.click();
        },

        checkIsBottom: () => {
            const scrollHeight = document.documentElement.scrollHeight;
            const scrollTop = window.scrollY || document.documentElement.scrollTop;
            return (scrollTop + window.innerHeight) >= (scrollHeight - Config.System.bottomTolerance);
        },

        checkIsTop: () => window.scrollY === 0
    };

    /* ==========================================================================
       5. EventController 模組:負責監聽滑鼠滾輪與觸控事件,計算滾動進度
       ========================================================================== */
    const EventController = {
        state: {
            scrollCounter: 0,
            disablePaging: false,
            touchStartY: 0,
            lastTouchY: 0,           // [修正] 初始化 lastTouchY
            touchAccumulated: 0,     // [修正] 初始化累計距離變數
            wheelTimer: null,
            isBound: false
        },

        resetState: () => {
            // 清除計時器防止重複執行
            if (EventController.state.wheelTimer) {
                clearTimeout(EventController.state.wheelTimer);
                EventController.state.wheelTimer = null;
            }

            EventController.state.scrollCounter = 0;
            EventController.state.touchAccumulated = 0;
            EventController.state.disablePaging = false; // 離開區域後,允許再次觸發翻頁
            Progress.reset();
        },
        handleScrollIntent: (isScrollingDown) => {
            if (EventController.state.disablePaging) return { valid: false };

            const isBottom = Navigator.checkIsBottom();
            const isTop = Navigator.checkIsTop();
            const site = Navigator.getCurrentSite();

            const intentPrev = isTop && !isScrollingDown && site?.getPageButton('prev');
            const intentNext = isBottom && isScrollingDown && site?.getPageButton('next');

            if (intentPrev || intentNext) {
                return {
                    valid: true,
                    direction: intentPrev ? 'prev' : 'next',
                    bar: intentPrev ? Progress.bars.top : Progress.bars.bottom
                };
            }
            EventController.resetState();
            return { valid: false };
        },

        bindMouse: () => {
            document.addEventListener("wheel", event => {
                const isScrollingDown = (event.wheelDelta) ? event.wheelDelta < 0 : event.deltaY > 0;
                const intent = EventController.handleScrollIntent(isScrollingDown);

                // 1. 清除上一次的計時器 (如果有的話)
                if (EventController.state.wheelTimer) {
                    clearTimeout(EventController.state.wheelTimer);
                }

                if (intent.valid) {
                    if (intent.direction === 'prev' && Store.getSetting('disablePrev')) return;

                    EventController.state.scrollCounter++;
                    const maxScrolls = Store.getSetting('mouseScrolls');
                    Progress.update(intent.bar, EventController.state.scrollCounter, maxScrolls);

                    if (EventController.state.scrollCounter >= maxScrolls) {
                        EventController.state.disablePaging = true;
                        Navigator.triggerPage(intent.direction);
                    }

                    // 2. [核心邏輯] 如果關閉累加模式,設定一個計時器
                    // 當使用者停止捲動 500 毫秒後,自動重置進度
                    if (!Store.getSetting('accumulateMode')) {
                        EventController.state.wheelTimer = setTimeout(() => {
                            // 再次確認是否仍未觸發翻頁,才執行重置
                            if (!EventController.state.disablePaging) {
                                EventController.resetState();
                            }
                        }, Config.System.wheelResetDelay);
                    }
                } else {
                    // 如果不在觸發區,且非累加模式,立即重置
                    if (!Store.getSetting('accumulateMode')) {
                        EventController.resetState();
                    }
                }
            });
        },

        bindTouch: () => {
            document.addEventListener("touchstart", e => {
                EventController.state.touchStartY = e.touches[0].clientY;
                EventController.state.lastTouchY = e.touches[0].clientY;
            }, { passive: true });

            document.addEventListener("touchmove", e => {
                const touchCurrentY = e.touches[0].clientY;
                const deltaY = touchCurrentY - EventController.state.lastTouchY;
                const isScrollingDown = touchCurrentY < EventController.state.touchStartY;
                const intent = EventController.handleScrollIntent(isScrollingDown);

                if (intent.valid) {
                    if (intent.direction === 'prev' && Store.getSetting('disablePrev')) return;
                    if (intent.direction === 'prev' && Store.getSetting('disableMobileRefresh') && e.cancelable) {
                        e.preventDefault();
                    }

                    // 累加滑動位移
                    EventController.state.touchAccumulated += Math.abs(deltaY);
                    const sensitivity = Store.getSetting('touchSensitivity');
                    const maxDistance = Config.System.touchDistanceBaseline - (sensitivity * Config.System.touchDistanceStep);

                    Progress.update(intent.bar, EventController.state.touchAccumulated, maxDistance);

                    if (EventController.state.touchAccumulated >= maxDistance) {
                        EventController.state.disablePaging = true;
                        Navigator.triggerPage(intent.direction);
                    }
                }
                EventController.state.lastTouchY = touchCurrentY;
            }, { passive: false });

            // [新增] 監聽手指放開
            document.addEventListener("touchend", () => {
                // 如果使用者關閉了「累加模式」,手指放開時進度條歸零
                if (!Store.getSetting('accumulateMode')) {
                    EventController.resetState();
                }
            });
        }
    };

    /* ==========================================================================
       6. UI 模組:負責產生、操作設定面板,以及動態注入 CSS
       ========================================================================== */
    const UI = {
        injectStyles: () => {
            if (!document.getElementById('gsn-global-styles')) {
                const styleEl = document.createElement('style');
                styleEl.id = 'gsn-global-styles';

                // 動態將 Config.System 的變數轉換為 CSS,並合併到原本的 CSS 模板中
                const dynamicSystemCSS = `
                    .gsn-progress-line {
                        height: ${Config.System.progressBarHeight};
                        background-color: ${Config.System.progressBarColor};
                        z-index: ${Config.System.progressBarZIndex} !important;
                    }
                `;
                styleEl.textContent = Config.UI.CSS + dynamicSystemCSS;
                document.head.appendChild(styleEl);
            }
            UI.applyDynamicClasses();
        },

        applyDynamicClasses: () => {
            const disableRefresh = Store.getSetting('disableMobileRefresh');
            if (disableRefresh) {
                document.documentElement.classList.add('gsn-no-refresh');
                document.body.classList.add('gsn-no-refresh');
            } else {
                document.documentElement.classList.remove('gsn-no-refresh');
                document.body.classList.remove('gsn-no-refresh');
            }
        },

        open: () => {
            if (document.getElementById('gsn-settings-overlay')) return;

            const overlay = document.createElement('div');
            overlay.id = 'gsn-settings-overlay';

            const currentSettings = {
                mouseScrolls: Store.getSetting('mouseScrolls'),
                touchSensitivity: Store.getSetting('touchSensitivity'),
                disableMobileRefresh: Store.getSetting('disableMobileRefresh'),
                disablePrev: Store.getSetting('disablePrev'),
                accumulateMode: Store.getSetting('accumulateMode')
            };
            overlay.innerHTML = Config.UI.getHTML(currentSettings);
            document.body.appendChild(overlay);

            const touchInput = document.getElementById('gsn-touch-val');
            const touchDisplay = document.getElementById('gsn-touch-display');
            const select = document.getElementById('gsn-site-select');
            const nameInput = document.getElementById('gsn-site-name');
            const hostInput = document.getElementById('gsn-site-host');
            const codeInput = document.getElementById('gsn-site-code');

            touchInput.oninput = (e) => touchDisplay.innerText = e.target.value;

            const refreshDropdown = () => {
                select.innerHTML = '<option value="">-- Add New Site --</option>';
                Store.getSites().forEach((s, i) => {
                    const opt = document.createElement('option');
                    opt.value = i;
                    opt.textContent = s.name;
                    select.appendChild(opt);
                });
            };
            refreshDropdown();

            select.onchange = (e) => {
                const site = Store.getSites()[e.target.value];
                nameInput.value = site?.name || '';
                hostInput.value = site?.host || '';
                codeInput.value = site?.code || '';
            };

            document.getElementById('gsn-del-site').onclick = () => {
                if (select.value === "") return alert("Please select a site first.");
                if (confirm(`Delete site profile: "${select.options[select.selectedIndex].text}"?`)) {
                    let sites = Store.getSites();
                    sites.splice(select.value, 1);
                    Store.saveSites(sites);
                    refreshDropdown();
                    nameInput.value = hostInput.value = codeInput.value = '';
                }
            };

            document.getElementById('gsn-reset').onclick = () => {
                if (confirm("Reset all settings to factory default?")) {
                    Store.resetAll();
                    location.reload();
                }
            };

            document.getElementById('gsn-save-all').onclick = () => {
                Store.setSetting('mouseScrolls', parseInt(document.getElementById('gsn-mouse-val').value));
                Store.setSetting('touchSensitivity', parseInt(document.getElementById('gsn-touch-val').value));
                Store.setSetting('disableMobileRefresh', document.getElementById('gsn-disable-refresh').checked);
                Store.setSetting('disablePrev', document.getElementById('gsn-disable-prev').checked);
                Store.setSetting('accumulateMode', document.getElementById('gsn-accumulate-mode').checked);

                UI.applyDynamicClasses();

                if (nameInput.value && hostInput.value && codeInput.value) {
                    let sites = Store.getSites();
                    if (select.value !== "") sites[select.value] = { name: nameInput.value, host: hostInput.value, code: codeInput.value };
                    else sites.push({ name: nameInput.value, host: hostInput.value, code: codeInput.value });
                    Store.saveSites(sites);
                }
                overlay.remove();
                Logger.info('Settings saved!');
            };

            document.getElementById('gsn-cancel').onclick = () => { overlay.remove(); };
        }
    };

    /* ==========================================================================
       7. App 主程式:管理腳本啟動生命週期與動態觀察者
       ========================================================================== */
    const App = {
        tryBindEvents: () => {
            if (EventController.state.isBound) return;
            const site = Navigator.getCurrentSite();
            if (!site) return;

            if (site.getPageButton('next') || site.getPageButton('prev')) {
                EventController.bindMouse();
                EventController.bindTouch();
                EventController.state.isBound = true;
                Logger.info('Events bound successfully.');
            }
        },

        startObserver: () => {
            if (EventController.state.isBound) return;
            const selectors = Config.System.observerSelectors;
            let targetNode = document.body;

            for (const selector of selectors) {
                const found = document.querySelector(selector);
                if (found) { targetNode = found; break; }
            }

            const observer = new MutationObserver((mutations, obs) => {
                App.tryBindEvents();
                if (EventController.state.isBound) {
                    obs.disconnect();
                    Logger.info('Observer disconnected: Buttons found.');
                }
            });
            observer.observe(targetNode, { childList: true, subtree: true });
        },

        init: () => {
            GM_registerMenuCommand(Config.System.menuName, UI.open);
            UI.injectStyles();

            Progress.init();
            App.tryBindEvents();
            App.startObserver();

            window.addEventListener('load', App.tryBindEvents);
            window.addEventListener('popstate', () => {
                EventController.state.isBound = false;
                App.tryBindEvents();
                App.startObserver();
            });
            window.navigation?.addEventListener("navigate", () => {
                EventController.state.disablePaging = false;
            });

            const safetyInterval = setInterval(() => {
                if (EventController.state.isBound) return clearInterval(safetyInterval);
                App.tryBindEvents();
            }, Config.System.safetyIntervalMs);
        }
    };

    App.init();

})();