nHentai Infinite Reader

Full-gallery overlay reader for nHentai with full image loading, webtoon and manga modes, fit/zoom, page gap control, jump-to-page, theme picker, document scrolling, mobile browser toolbar compatibility, and low-memory lazy loading.

Bu betiği kurabilmeniz için Tampermonkey, Greasemonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği yüklemek için Tampermonkey gibi bir uzantı yüklemeniz gerekir.

Bu betiği kurabilmeniz için Tampermonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği kurabilmeniz için Tampermonkey ya da Userscripts gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği indirebilmeniz için ayrıca Tampermonkey gibi bir eklenti kurmanız gerekmektedir.

Bu komut dosyasını yüklemek için bir kullanıcı komut dosyası yöneticisi uzantısı yüklemeniz gerekecek.

(Zaten bir kullanıcı komut dosyası yöneticim var, kurmama izin verin!)

Advertisement:

Bu stili yüklemek için Stylus gibi bir uzantı yüklemeniz gerekir.

Bu stili yüklemek için Stylus gibi bir uzantı kurmanız gerekir.

Bu stili yükleyebilmek için Stylus gibi bir uzantı yüklemeniz gerekir.

Bu stili yüklemek için bir kullanıcı stili yöneticisi uzantısı yüklemeniz gerekir.

Bu stili yüklemek için bir kullanıcı stili yöneticisi uzantısı kurmanız gerekir.

Bu stili yükleyebilmek için bir kullanıcı stili yöneticisi uzantısı yüklemeniz gerekir.

(Zateb bir user-style yöneticim var, yükleyeyim!)

Advertisement:

// ==UserScript==
// @name         nHentai Infinite Reader
// @name:ja      nHentai 無限スクロールリーダー
// @name:zh-CN   nHentai 无限滚动阅读器
// @namespace    https://nhentai.net/
// @version      1.0.0
// @author       L1Z4RD
// @license      MIT
// @match        https://nhentai.net/g/*
// @run-at       document-idle
// @grant        none
// @description  Full-gallery overlay reader for nHentai with full image loading, webtoon and manga modes, fit/zoom, page gap control, jump-to-page, theme picker, document scrolling, mobile browser toolbar compatibility, and low-memory lazy loading.
// @description:ja nHentai向けのギャラリー用オーバーレイリーダー。フル画像読み込み、縦スクロール・漫画表示、フィット/ズーム、ページ間隔、ページジャンプ、テーマカラー、ドキュメントスクロール、低メモリ読み込みに対応。
// @description:zh-CN nHentai 画廊覆盖式阅读器,支持完整图片加载、条漫/漫画模式、适应/缩放、页面间距、跳页、主题颜色、文档滚动、移动浏览器工具栏兼容和低内存懒加载。
// ==/UserScript==

(function () {
    'use strict';

    const CONFIG = {
        fullMaxWidth: 1800,
        loadBuffer: 5,
        unloadBuffer: 9,
        rootMargin: '1200px',
        maxRetries: 3,
        menuCollapseMs: 5000,
        longPressMs: 450,
        scrollHideThreshold: 8,
        defaultThemeColor: '#353a45',
        defaultReadingMode: 'webtoon',
        defaultGap: 5,
        defaultZoom: 0
    };

    const IDS = {
        style: 'nhir-overlay-style',
        launcher: 'nhir-open-book-launcher',
        overlay: 'nhir-overlay',
        reader: 'nhir-overlay-reader',
        uiRoot: 'nhir-overlay-ui-root',
        progressBar: 'nhir-progress-bar',
        pageIndicator: 'nhir-page-indicator'
    };

    const STORAGE = {
        settings: 'nhir-overlay-settings-v1'
    };

    const ICONS = {
        openBook: '<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true"><path d="M4 5.5c2.8 0 5 .7 8 2.5v12c-3-1.8-5.2-2.5-8-2.5z"></path><path d="M20 5.5c-2.8 0-5 .7-8 2.5v12c3-1.8 5.2-2.5 8-2.5z"></path><path d="M12 8v12"></path></svg>',
        closedBook: '<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path d="M6.5 4.5h10A2.5 2.5 0 0 1 19 7v13H8a3 3 0 0 1-3-3V6a1.5 1.5 0 0 1 1.5-1.5z"></path><path d="M8 4.5V20"></path><path d="M8 17h11"></path></svg>',
        menu: '<svg viewBox="0 0 24 24" width="21" height="21" aria-hidden="true"><path d="M5 7h14"></path><path d="M5 12h14"></path><path d="M5 17h14"></path></svg>',
        up: '<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path d="M12 19V5"></path><path d="M5 12l7-7 7 7"></path></svg>',
        mode: '<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path d="M4 5h16"></path><path d="M4 12h16"></path><path d="M4 19h16"></path><path d="M8 3v18"></path><path d="M16 3v18"></path></svg>',
        fit: '<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path d="M9 4H4v5"></path><path d="M15 4h5v5"></path><path d="M9 20H4v-5"></path><path d="M15 20h5v-5"></path></svg>',
        gap: '<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path d="M6 5h12"></path><path d="M6 12h12"></path><path d="M6 19h12"></path><path d="M12 7v3"></path><path d="M12 14v3"></path></svg>',
        reader: '<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path d="M7 4.5h6.75A3.25 3.25 0 0 1 17 7.75V19a2.5 2.5 0 0 0-2.5-2.5H7z"></path></svg>',
        theme: '<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><circle cx="12" cy="12" r="4.5"></circle><path d="M12 2.5v2.5"></path><path d="M12 19v2.5"></path><path d="M4.9 4.9l1.8 1.8"></path><path d="M17.3 17.3l1.8 1.8"></path><path d="M2.5 12H5"></path><path d="M19 12h2.5"></path><path d="M4.9 19.1l1.8-1.8"></path><path d="M17.3 6.7l1.8-1.8"></path></svg>'
    };

    let imagePages = [];
    let totalPages = 0;
    let scrollTicking = false;
    let state = loadSettings();

    const startupWait = setInterval(() => {
        refreshGalleryImagePages();

        if (!totalPages) return;

        clearInterval(startupWait);
        ensureStyle();
        mountLauncher();
    }, 100);

    setTimeout(() => {
        if (!totalPages) clearInterval(startupWait);
    }, 15000);

    function getCurrentGalleryId() {
        const match = location.pathname.match(/^\/g\/(\d+)(?:\/|$)/i);
        return match ? match[1] : '';
    }

    function refreshGalleryImagePages() {
        imagePages = extractGalleryImagePages(document);
        totalPages = imagePages.length;
    }

    function extractGalleryImagePages(root) {
        const galleryId = getCurrentGalleryId();
        if (!galleryId) return [];

        const pagesByNo = new Map();
        const anchors = Array.from(root.querySelectorAll(`a.gallerythumb[href*="/g/${galleryId}/"]`));

        anchors.forEach((anchor, index) => {
            const parsed = parseNhentaiPageLink(anchor.getAttribute('href'), galleryId);
            if (!parsed) return;

            const img = anchor.querySelector('img');
            const thumb = getBestImageSource(img, location.href);
            if (!thumb) return;

            const ratio = getImageRatio(img);
            const directFull = deriveFullImageFromThumbnail(thumb);

            if (pagesByNo.has(parsed.pageNo)) return;

            pagesByNo.set(parsed.pageNo, {
                pageNo: parsed.pageNo,
                pageLabel: String(parsed.pageNo),
                readUrl: parsed.href,
                thumb,
                fullImage: directFull,
                fullImageResolvedBy: directFull ? 'thumbnail-map' : '',
                directFullFailed: false,
                nativeTried: false,
                resolvePromise: null,
                originalIndex: index,
                ratio
            });
        });

        return Array.from(pagesByNo.values()).sort((a, b) => a.pageNo - b.pageNo);
    }

    function parseNhentaiPageLink(href, expectedGalleryId) {
        if (!href) return null;

        let url;
        try {
            url = new URL(href, location.origin);
        } catch {
            return null;
        }

        const match = url.pathname.match(/^\/g\/(\d+)\/(\d+)\/?$/i);
        if (!match) return null;

        const galleryId = match[1];
        const pageNo = parseInt(match[2], 10);

        if (expectedGalleryId && galleryId !== expectedGalleryId) return null;
        if (!Number.isFinite(pageNo) || pageNo < 1) return null;

        return {
            href: url.href,
            galleryId,
            pageNo
        };
    }

    function getBestImageSource(img, baseUrl = location.href) {
        if (!img) return '';

        const candidates = [
            img.getAttribute('data-src'),
            img.getAttribute('data-original'),
            img.getAttribute('data-lazy-src'),
            img.currentSrc,
            img.getAttribute('src')
        ].filter(Boolean);

        const srcset = img.getAttribute('srcset') || img.getAttribute('data-srcset') || '';
        if (srcset) {
            srcset.split(',').forEach(part => {
                const candidate = part.trim().split(/\s+/)[0];
                if (candidate) candidates.push(candidate);
            });
        }

        const picked = candidates.find(src => src && !/^data:/i.test(src)) || '';

        try {
            return picked ? new URL(picked, baseUrl).href : '';
        } catch {
            return picked;
        }
    }

    function getImageRatio(img) {
        if (!img) return 1.414;

        const width = parseFloat(img.getAttribute('width') || img.naturalWidth || '0');
        const height = parseFloat(img.getAttribute('height') || img.naturalHeight || '0');

        if (width > 0 && height > 0) {
            return Math.max(0.3, Math.min(3.5, height / width));
        }

        return 1.414;
    }

    function toAbsoluteUrl(value, base = location.href) {
        if (!value) return '';

        try {
            return new URL(value, base).href;
        } catch {
            return String(value || '').trim();
        }
    }

    function deriveFullImageFromThumbnail(thumbUrl) {
        if (!thumbUrl) return '';

        try {
            const url = new URL(thumbUrl, location.href);

            url.hostname = url.hostname.replace(/^t(\d*)\./i, (_, shard) => `i${shard || ''}.`);
            url.pathname = url.pathname.replace(/\/(\d+)t\.(jpe?g|png|webp|gif)$/i, '/$1.$2');

            const result = url.href;
            return result !== thumbUrl ? result : '';
        } catch {
            return String(thumbUrl)
                .replace(/^https?:\/\/t(\d*)\./i, (_, shard) => `https://i${shard || ''}.`)
                .replace(/\/(\d+)t\.(jpe?g|png|webp|gif)$/i, '/$1.$2');
        }
    }

    function isThumbnailUrl(src) {
        return /\/\d+t\.(?:jpe?g|png|webp|gif)(?:[?#].*)?$/i.test(String(src || '')) || /\/t\d*\.nhentai\.net\//i.test(String(src || ''));
    }

    function isUsableFullImageUrl(src) {
        if (!src) return false;

        const clean = String(src).split('#')[0].split('?')[0];
        if (isThumbnailUrl(clean)) return false;

        return /\/galleries\/\d+\/\d+\.(?:jpe?g|png|webp|gif)$/i.test(clean) && /\/\/i\d*\.nhentai\.net\//i.test(clean);
    }

    function extractFullImageSrcFromReaderDocument(doc, baseUrl) {
        if (!doc) return '';

        const candidates = [];
        const selectors = [
            'a[aria-label="Click to navigate"] img',
            '#image-container img',
            'section#image-container img',
            'img[alt^="Page "]',
            'img[src*="/galleries/"]'
        ];

        selectors.forEach(selector => {
            doc.querySelectorAll(selector).forEach(img => {
                const picked = getBestImageSource(img, baseUrl);
                if (picked) candidates.push(picked);
            });
        });

        return candidates.map(src => toAbsoluteUrl(src, baseUrl)).find(isUsableFullImageUrl) || '';
    }

    function extractFullImageSrcFromReaderHtml(html, baseUrl) {
        if (!html) return '';

        const candidates = [];

        try {
            const doc = new DOMParser().parseFromString(html, 'text/html');
            const fromDoc = extractFullImageSrcFromReaderDocument(doc, baseUrl);
            if (fromDoc) candidates.push(fromDoc);
        } catch (error) {
            console.warn('[NHIR] DOMParser failed while resolving native reader image:', error);
        }

        const regexes = [
            /https?:\/\/i\d*\.nhentai\.net\/galleries\/\d+\/\d+\.(?:jpe?g|png|webp|gif)/gi,
            /https?:\\?\/\\?\/i\d*\.nhentai\.net\\?\/galleries\\?\/\d+\\?\/\d+\.(?:jpe?g|png|webp|gif)/gi
        ];

        regexes.forEach(regex => {
            let match;
            while ((match = regex.exec(html))) {
                candidates.push(match[0].replace(/\\\//g, '/'));
            }
        });

        return candidates.map(src => toAbsoluteUrl(src, baseUrl)).find(isUsableFullImageUrl) || '';
    }

    async function resolveFullImageByReaderPage(page) {
        page.nativeTried = true;

        const response = await fetch(page.readUrl, {
            credentials: 'include',
            cache: 'no-store',
            headers: {
                Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
            }
        });

        if (!response.ok) {
            throw new Error(`HTTP ${response.status}`);
        }

        const html = await response.text();
        return extractFullImageSrcFromReaderHtml(html, page.readUrl);
    }

    async function resolveFullImageSrcForPage(pageNo, forceNative = false) {
        const page = imagePages[clampPage(pageNo) - 1];
        if (!page) return '';

        if (!forceNative && page.fullImage && isUsableFullImageUrl(page.fullImage) && !page.directFullFailed) {
            return page.fullImage;
        }

        if (page.resolvePromise) return page.resolvePromise;

        page.resolvePromise = (async () => {
            if (!forceNative && !page.directFullFailed) {
                const direct = page.fullImage || deriveFullImageFromThumbnail(page.thumb);

                if (isUsableFullImageUrl(direct)) {
                    page.fullImage = direct;
                    page.fullImageResolvedBy = 'thumbnail-map';
                    return direct;
                }
            }

            try {
                const native = await resolveFullImageByReaderPage(page);

                if (isUsableFullImageUrl(native)) {
                    page.fullImage = native;
                    page.fullImageResolvedBy = 'native-reader';
                    page.directFullFailed = false;
                    return native;
                }
            } catch (error) {
                page.lastResolveError = error?.message || String(error);
            }

            return '';
        })();

        try {
            return await page.resolvePromise;
        } finally {
            page.resolvePromise = null;
        }
    }

    function loadSettings() {
        const fallback = {
            themeColor: CONFIG.defaultThemeColor,
            readingMode: CONFIG.defaultReadingMode,
            gap: CONFIG.defaultGap,
            zoom: CONFIG.defaultZoom,

            overlay: null,
            reader: null,
            ui: null,
            observer: null,

            currentPageNo: 1,
            suppressPageTrackingUntil: 0,

            menuOpen: false,
            openPanelName: null,
            pointerInsideUI: false,
            menuCollapseTimer: 0,

            oldScrollY: 0,
            oldBodyOverflow: '',
            oldHtmlOverflow: '',
            hiddenNativeNodes: [],
            lastScrollY: window.scrollY || 0,

            picker: null
        };

        try {
            return Object.assign(fallback, JSON.parse(localStorage.getItem(STORAGE.settings) || '{}'));
        } catch {
            return fallback;
        }
    }

    function saveSettings() {
        localStorage.setItem(STORAGE.settings, JSON.stringify({
            themeColor: state.themeColor,
            readingMode: state.readingMode,
            gap: state.gap,
            zoom: state.zoom
        }));
    }

    function mountLauncher() {
        if (document.getElementById(IDS.launcher)) return;

        const button = document.createElement('button');
        button.id = IDS.launcher;
        button.type = 'button';
        button.innerHTML = ICONS.openBook;
        button.title = 'Open overlay reader';
        button.setAttribute('aria-label', 'Open overlay reader');

        button.addEventListener('click', event => {
            event.preventDefault();
            event.stopPropagation();
            openOverlayFromGallery();
        });

        document.body.appendChild(button);
    }

    function openOverlayFromGallery() {
        refreshGalleryImagePages();

        if (!totalPages) {
            alert('NHIR: Could not find nHentai gallery thumbnails on this page. Open the gallery overview page, not a reader page.');
            return;
        }

        openOverlay(1);
    }

    function openOverlay(startPageNo) {
        if (state.overlay?.isConnected) return;

        state.currentPageNo = clampPage(startPageNo || 1);
        state.oldScrollY = window.scrollY || window.pageYOffset || 0;
        state.oldBodyOverflow = document.body.style.overflow;
        state.oldHtmlOverflow = document.documentElement.style.overflow;

        document.body.style.overflow = '';
        document.documentElement.style.overflow = '';

        const launcher = document.getElementById(IDS.launcher);
        if (launcher) launcher.style.display = 'none';

        const overlay = document.createElement('div');
        overlay.id = IDS.overlay;

        const reader = document.createElement('div');
        reader.id = IDS.reader;
        reader.className = 'nhir-reader';

        overlay.appendChild(reader);
        document.body.appendChild(overlay);

        state.overlay = overlay;
        state.reader = reader;

        hideNativePageExceptOverlay();
        document.body.classList.add('nhir-body-active');

        createProgressBar(overlay);
        createPageIndicator(overlay);
        mountOverlayUI(overlay);

        applyAllSettings();
        window.scrollTo({ top: 0, behavior: 'auto' });
        renderReader(state.currentPageNo);
        setupOverlayEvents();
    }

    function hideNativePageExceptOverlay() {
        state.hiddenNativeNodes = [];

        Array.from(document.body.children).forEach(node => {
            if (node.id === IDS.overlay || node.id === IDS.launcher) return;
            if (node.tagName === 'SCRIPT' || node.tagName === 'STYLE') return;

            state.hiddenNativeNodes.push({
                node,
                display: node.style.display,
                visibility: node.style.visibility
            });

            node.style.display = 'none';
        });
    }

    function restoreNativePage() {
        state.hiddenNativeNodes.forEach(entry => {
            if (!entry.node?.isConnected) return;
            entry.node.style.display = entry.display || '';
            entry.node.style.visibility = entry.visibility || '';
        });

        state.hiddenNativeNodes = [];
        document.body.classList.remove('nhir-body-active');
    }

    function closeOverlay() {
        if (!state.overlay) return;

        clearMenuCollapse();

        if (state.observer) {
            state.observer.disconnect();
            state.observer = null;
        }

        removeOverlayEvents();

        if (state.overlay.isConnected) {
            state.overlay.remove();
        }

        state.overlay = null;
        state.reader = null;
        state.ui = null;
        state.menuOpen = false;
        state.openPanelName = null;
        state.pointerInsideUI = false;

        restoreNativePage();

        document.body.style.overflow = state.oldBodyOverflow || '';
        document.documentElement.style.overflow = state.oldHtmlOverflow || '';

        const launcher = document.getElementById(IDS.launcher);
        if (launcher) launcher.style.display = '';

        window.scrollTo({ top: state.oldScrollY || 0, behavior: 'auto' });
    }

    function renderReader(targetPageNo = state.currentPageNo) {
        if (!state.reader) return;

        if (state.observer) {
            state.observer.disconnect();
            state.observer = null;
        }

        state.reader.innerHTML = '';
        state.reader.className = `nhir-reader nhir-mode-${state.readingMode}`;

        if (state.readingMode === 'manga') {
            renderMangaReader();
        } else {
            renderWebtoonReader();
        }

        updatePlaceholders();
        applyGap();
        applyZoom();
        setupObserver();

        stabilizedJumpToPage(targetPageNo, 'auto');
    }

    function renderWebtoonReader() {
        imagePages.forEach((page, index) => {
            const pageNo = index + 1;

            const wrapper = document.createElement('div');
            wrapper.className = 'nhir-page';
            wrapper.dataset.pageNo = pageNo;
            wrapper.dataset.observePage = pageNo;
            wrapper.style.setProperty('--nhir-placeholder-height', `${getPlaceholderHeight(page)}px`);

            const img = createImage(page, pageNo);
            const errorBox = createErrorBox(img);

            wrapper.append(img, errorBox);
            state.reader.appendChild(wrapper);
        });
    }

    function renderMangaReader() {
        for (let i = 0; i < imagePages.length; i += 2) {
            const firstPageNo = i + 1;
            const secondPageNo = i + 2;

            const spread = document.createElement('div');
            spread.className = 'nhir-spread';
            spread.dataset.spreadFirst = firstPageNo;
            spread.dataset.observePage = firstPageNo;

            const leftSlot = createMangaSlot(secondPageNo);
            const rightSlot = createMangaSlot(firstPageNo);

            spread.append(leftSlot, rightSlot);
            state.reader.appendChild(spread);
        }
    }

    function createMangaSlot(pageNo) {
        const slot = document.createElement('div');
        slot.className = 'nhir-manga-slot';

        if (pageNo > totalPages) {
            slot.classList.add('is-empty');
            return slot;
        }

        const page = imagePages[pageNo - 1];
        const img = createImage(page, pageNo);
        const errorBox = createErrorBox(img);

        slot.dataset.pageNo = pageNo;
        slot.style.setProperty('--nhir-placeholder-height', `${getPlaceholderHeight(page) * 0.72}px`);
        slot.append(img, errorBox);

        return slot;
    }

    function createImage(page, pageNo) {
        const img = document.createElement('img');
        img.className = 'nhir-image';
        img.dataset.pageNo = pageNo;
        img.dataset.thumb = page.thumb || '';
        img.dataset.readUrl = page.readUrl || '';
        img.dataset.retryCount = '0';
        img.dataset.loading = '0';
        img.alt = `Page ${pageNo}`;
        img.decoding = 'async';
        img.loading = 'lazy';
        return img;
    }

    function createErrorBox(img) {
        const errorBox = document.createElement('div');
        errorBox.className = 'nhir-error';
        errorBox.innerHTML = `
            <div>Image failed to load.</div>
            <button type="button">Retry</button>
        `;

        errorBox.querySelector('button').addEventListener('click', event => {
            event.preventDefault();
            event.stopPropagation();

            const page = imagePages[clampPage(parseInt(img.dataset.pageNo || '1', 10)) - 1];
            if (page) {
                page.directFullFailed = true;
                page.fullImage = '';
                page.fullImageResolvedBy = '';
            }

            img.dataset.retryCount = '0';
            img.removeAttribute('data-resolve-error');
            errorBox.classList.remove('visible');
            loadImage(img, true);
        });

        return errorBox;
    }

    function setupObserver() {
        if (!state.reader) return;

        state.observer = new IntersectionObserver(entries => {
            entries.forEach(entry => {
                if (!entry.isIntersecting) return;

                const target = entry.target;

                target.querySelectorAll('img.nhir-image').forEach(img => {
                    loadImage(img);
                });

                const pageNo = parseInt(
                    target.dataset.pageNo ||
                    target.dataset.spreadFirst ||
                    target.dataset.observePage ||
                    state.currentPageNo,
                    10
                );

                maintainMemory(pageNo);
            });
        }, {
            root: null,
            rootMargin: CONFIG.rootMargin
        });

        state.reader.querySelectorAll('[data-observe-page]').forEach(el => {
            state.observer.observe(el);
        });
    }

    function setupOverlayEvents() {
        window.__NHIR_KEY_HANDLER__ = handleKeydown;
        window.__NHIR_RESIZE_HANDLER__ = handleResize;
        window.__NHIR_SCROLL_HANDLER__ = handleWindowScroll;

        window.addEventListener('keydown', window.__NHIR_KEY_HANDLER__);
        window.addEventListener('resize', window.__NHIR_RESIZE_HANDLER__, { passive: true });
        window.addEventListener('scroll', window.__NHIR_SCROLL_HANDLER__, { passive: true });
    }

    function removeOverlayEvents() {
        if (window.__NHIR_KEY_HANDLER__) {
            window.removeEventListener('keydown', window.__NHIR_KEY_HANDLER__);
            window.__NHIR_KEY_HANDLER__ = null;
        }

        if (window.__NHIR_RESIZE_HANDLER__) {
            window.removeEventListener('resize', window.__NHIR_RESIZE_HANDLER__);
            window.__NHIR_RESIZE_HANDLER__ = null;
        }

        if (window.__NHIR_SCROLL_HANDLER__) {
            window.removeEventListener('scroll', window.__NHIR_SCROLL_HANDLER__);
            window.__NHIR_SCROLL_HANDLER__ = null;
        }
    }

    function handleWindowScroll() {
        if (scrollTicking) return;

        scrollTicking = true;

        requestAnimationFrame(() => {
            updateCurrentPage();
            updateProgressBar();
            updateAutoHideControls();
            scrollTicking = false;
        });
    }

    function updateAutoHideControls() {
        if (!state.ui?.root) return;

        const currentY = window.scrollY || window.pageYOffset || 0;
        const delta = currentY - (state.lastScrollY || 0);
        state.lastScrollY = currentY;

        if (Math.abs(delta) < CONFIG.scrollHideThreshold) return;
        if (state.pointerInsideUI || state.menuOpen) return;

        state.ui.root.classList.toggle('is-ui-hidden', delta > 0);
    }

    function handleKeydown(event) {
        if (!state.overlay) return;

        if (event.key === 'Escape') {
            event.preventDefault();
            closeOverlay();
            return;
        }

        const tag = document.activeElement?.tagName?.toLowerCase();

        if (tag === 'input' || tag === 'select' || tag === 'textarea') {
            return;
        }

        if (event.key.toLowerCase() === 't') {
            event.preventDefault();
            openPanel('theme');
            return;
        }

        if (event.key.toLowerCase() === 'f') {
            event.preventDefault();
            updateZoom(state.zoom === 0 ? 100 : 0, true);
            return;
        }

        if (event.key === '+' || event.key === '=') {
            event.preventDefault();
            updateZoom(state.zoom + 5, true);
            return;
        }

        if (event.key === '-' || event.key === '_') {
            event.preventDefault();
            updateZoom(state.zoom - 5, true);
            return;
        }

        if (event.key === '0') {
            event.preventDefault();
            updateZoom(0, true);
            return;
        }

        if (state.readingMode === 'manga') {
            if (event.key === 'ArrowLeft' || event.key === 'PageDown' || event.key === ' ') {
                event.preventDefault();
                goNextUnit();
            }

            if (event.key === 'ArrowRight' || event.key === 'PageUp') {
                event.preventDefault();
                goPreviousUnit();
            }

            return;
        }

        if (event.key === 'ArrowDown' || event.key === 'PageDown') {
            event.preventDefault();
            goNextUnit();
        }

        if (event.key === 'ArrowUp' || event.key === 'PageUp') {
            event.preventDefault();
            goPreviousUnit();
        }
    }

    function handleResize() {
        const anchor = captureViewportAnchor();

        updatePlaceholders();
        applyZoom();

        requestAnimationFrame(() => {
            restoreViewportAnchor(anchor, 'auto');
            updateProgressBar();
            updateCurrentPage(true);
        });
    }

    function goNextUnit() {
        if (state.readingMode === 'manga') {
            const spreadFirst = getSpreadFirstFromPage(state.currentPageNo);
            jumpToPage(Math.min(totalPages, spreadFirst + 2), 'smooth');
        } else {
            jumpToPage(Math.min(totalPages, state.currentPageNo + 1), 'smooth');
        }
    }

    function goPreviousUnit() {
        if (state.readingMode === 'manga') {
            const spreadFirst = getSpreadFirstFromPage(state.currentPageNo);
            jumpToPage(Math.max(1, spreadFirst - 2), 'smooth');
        } else {
            jumpToPage(Math.max(1, state.currentPageNo - 1), 'smooth');
        }
    }

    function getSpreadFirstFromPage(pageNo) {
        const safe = clampPage(pageNo);
        return safe % 2 === 0 ? safe - 1 : safe;
    }

    function getTargetElementForPage(pageNo) {
        const safePageNo = clampPage(pageNo);

        if (!state.reader) return null;

        if (state.readingMode === 'manga') {
            const spreadFirst = getSpreadFirstFromPage(safePageNo);
            return state.reader.querySelector(`.nhir-spread[data-spread-first="${spreadFirst}"]`);
        }

        return state.reader.querySelector(`.nhir-page[data-page-no="${safePageNo}"]`);
    }

    function preloadImagesAroundPage(pageNo) {
        if (!state.reader) return [];

        const safePageNo = clampPage(pageNo);
        const start = Math.max(1, safePageNo - 2);
        const end = Math.min(totalPages, safePageNo + 2);
        const promises = [];

        state.reader.querySelectorAll('img[data-page-no]').forEach(img => {
            const imgPageNo = parseInt(img.dataset.pageNo, 10);

            if (imgPageNo >= start && imgPageNo <= end) {
                promises.push(loadImage(img));
            }
        });

        return promises;
    }

    async function ensurePageReady(pageNo, timeoutMs = 1400) {
        const target = getTargetElementForPage(pageNo);
        if (!target) return;

        const imgs = Array.from(target.querySelectorAll('img.nhir-image'));
        if (!imgs.length) return;

        const loads = imgs.map(img => loadImage(img));
        await Promise.race([
            Promise.allSettled(loads),
            wait(timeoutMs)
        ]);
    }

    async function stabilizedJumpToPage(pageNo, behavior = 'auto') {
        const safePageNo = clampPage(pageNo);

        state.suppressPageTrackingUntil = Date.now() + 800;
        preloadImagesAroundPage(safePageNo);
        maintainMemory(safePageNo);

        await nextFrame();
        await ensurePageReady(safePageNo, 1400);
        await nextFrame();

        forceJumpToPage(safePageNo, behavior);

        await nextFrame();
        forceJumpToPage(safePageNo, 'auto');

        setTimeout(() => {
            updateCurrentPage(true);
            updatePageIndicator();
            updatePageInput();
            updateProgressBar();
            maintainMemory(safePageNo);
        }, 220);
    }

    function forceJumpToPage(pageNo, behavior = 'auto') {
        const target = getTargetElementForPage(pageNo);
        if (!target) return;

        const safePageNo = clampPage(pageNo);
        const rect = target.getBoundingClientRect();
        const targetTop = window.scrollY + rect.top;
        const centerOffset = Math.max(0, (window.innerHeight - rect.height) / 2);
        const top = Math.max(0, targetTop - centerOffset);

        state.suppressPageTrackingUntil = Math.max(state.suppressPageTrackingUntil || 0, Date.now() + 350);

        window.scrollTo({ top, behavior });

        state.currentPageNo = state.readingMode === 'manga'
            ? Math.min(totalPages, getSpreadFirstFromPage(safePageNo) + 1)
            : safePageNo;

        updatePageIndicator();
        updatePageInput();
        maintainMemory(state.currentPageNo);
    }

    function jumpToPage(pageNo, behavior = 'smooth') {
        stabilizedJumpToPage(pageNo, behavior);
    }

    function updateCurrentPage(force = false) {
        if (!state.reader) return;

        if (!force && state.suppressPageTrackingUntil && Date.now() < state.suppressPageTrackingUntil) {
            return;
        }

        const selector = state.readingMode === 'manga' ? '.nhir-spread' : '.nhir-page';
        const elements = state.reader.querySelectorAll(selector);
        const viewportCenter = window.innerHeight / 2;

        let closest = null;
        let closestDistance = Infinity;

        elements.forEach(el => {
            const rect = el.getBoundingClientRect();
            if (rect.bottom < 0 || rect.top > window.innerHeight) return;

            const center = rect.top + rect.height / 2;
            const distance = Math.abs(viewportCenter - center);

            if (distance < closestDistance) {
                closestDistance = distance;
                closest = el;
            }
        });

        if (!closest) {
            elements.forEach(el => {
                const rect = el.getBoundingClientRect();
                const distance = Math.abs(rect.top);

                if (distance < closestDistance) {
                    closestDistance = distance;
                    closest = el;
                }
            });
        }

        if (!closest) return;

        if (state.readingMode === 'manga') {
            const spreadFirst = parseInt(closest.dataset.spreadFirst, 10);
            state.currentPageNo = Math.min(totalPages, spreadFirst + 1);
        } else {
            state.currentPageNo = parseInt(closest.dataset.pageNo, 10);
        }

        updatePageIndicator();
        updatePageInput();
        maintainMemory(state.currentPageNo);
    }

    function captureViewportAnchor() {
        updateCurrentPage(true);

        const pageNo = clampPage(state.currentPageNo || 1);
        const target = getTargetElementForPage(pageNo);

        if (!target) {
            return { pageNo, ratio: 0.5 };
        }

        const rect = target.getBoundingClientRect();
        const ratio = rect.height > 0
            ? clamp((window.innerHeight / 2 - rect.top) / rect.height, 0, 1)
            : 0.5;

        return { pageNo, ratio };
    }

    function restoreViewportAnchor(anchor, behavior = 'auto') {
        if (!anchor) return;

        const target = getTargetElementForPage(anchor.pageNo);
        if (!target) return;

        const rect = target.getBoundingClientRect();
        const targetTop = window.scrollY + rect.top;
        const top = Math.max(0, targetTop + rect.height * anchor.ratio - window.innerHeight / 2);

        window.scrollTo({ top, behavior });

        state.currentPageNo = clampPage(anchor.pageNo);
        updatePageIndicator();
        updatePageInput();
        maintainMemory(state.currentPageNo);
    }

    function maintainMemory(currentPage) {
        if (!state.reader) return;

        const safeCurrent = clampPage(currentPage);

        state.reader.querySelectorAll('img[data-page-no]').forEach(img => {
            const pageNo = parseInt(img.dataset.pageNo, 10);
            const distance = Math.abs(pageNo - safeCurrent);

            if (distance <= CONFIG.loadBuffer) {
                loadImage(img);
            }

            if (distance > CONFIG.unloadBuffer) {
                unloadImage(img);
            }
        });
    }

    async function loadImage(img, force = false) {
        if (!img) return '';

        if (!force && img.src) return img.src;
        if (!force && img.dataset.loading === '1' && img.__nhirLoadPromise) return img.__nhirLoadPromise;

        const pageNo = parseInt(img.dataset.pageNo || '0', 10);
        const page = imagePages[clampPage(pageNo) - 1];
        const errorBox = img.parentElement?.querySelector('.nhir-error');

        if (!page) return '';

        if (force) {
            img.classList.remove('loaded');
            img.removeAttribute('src');
            img.removeAttribute('data-resolve-error');
            markPageLoaded(img, false);
        }

        errorBox?.classList.remove('visible');
        img.dataset.loading = '1';

        const src = await resolveFullImageSrcForPage(pageNo, force || page.directFullFailed);

        if (!src) {
            img.dataset.loading = '0';
            img.dataset.resolveError = page.lastResolveError || 'full-image-src-not-resolved';
            errorBox?.classList.add('visible');
            return '';
        }

        img.dataset.fullSrc = src;

        img.__nhirLoadPromise = new Promise(resolve => {
            img.onload = () => {
                img.classList.add('loaded');
                img.dataset.retryCount = '0';
                img.dataset.loading = '0';
                img.removeAttribute('data-resolve-error');

                if (img.naturalWidth && img.naturalHeight) {
                    page.ratio = img.naturalHeight / img.naturalWidth;
                    img.style.setProperty('--nhir-natural-width', `${img.naturalWidth}px`);
                    updatePagePlaceholder(img, page);
                }

                markPageLoaded(img, true);
                errorBox?.classList.remove('visible');
                resolve(src);
            };

            img.onerror = () => {
                img.classList.remove('loaded');
                img.dataset.loading = '0';
                markPageLoaded(img, false);
                img.removeAttribute('src');

                if (page.fullImageResolvedBy === 'thumbnail-map' && !page.nativeTried) {
                    page.directFullFailed = true;
                    page.fullImage = '';
                    page.fullImageResolvedBy = '';
                    setTimeout(() => resolve(loadImage(img, true)), 350);
                    return;
                }

                let retryCount = parseInt(img.dataset.retryCount || '0', 10);
                retryCount += 1;
                img.dataset.retryCount = String(retryCount);

                if (retryCount <= CONFIG.maxRetries) {
                    setTimeout(() => resolve(loadImage(img, true)), 850 * retryCount);
                } else {
                    img.dataset.resolveError = 'image-load-failed';
                    errorBox?.classList.add('visible');
                    resolve('');
                }
            };

            img.src = src;
        });

        return img.__nhirLoadPromise;
    }

    function unloadImage(img) {
        if (!img || !img.src || img.dataset.loading === '1') return;

        img.classList.remove('loaded');
        markPageLoaded(img, false);

        setTimeout(() => {
            if (img.dataset.loading !== '1') {
                img.removeAttribute('src');
            }
        }, 160);
    }

    function markPageLoaded(img, isLoaded) {
        const pageWrap = img.closest('.nhir-page, .nhir-manga-slot');
        pageWrap?.classList.toggle('is-loaded', Boolean(isLoaded));
    }

    function updatePlaceholders() {
        if (!state.reader) return;

        state.reader.querySelectorAll('.nhir-page[data-page-no]').forEach(wrapper => {
            const page = imagePages[parseInt(wrapper.dataset.pageNo, 10) - 1];
            if (page) wrapper.style.setProperty('--nhir-placeholder-height', `${getPlaceholderHeight(page)}px`);
        });

        state.reader.querySelectorAll('.nhir-manga-slot[data-page-no]').forEach(slot => {
            const page = imagePages[parseInt(slot.dataset.pageNo, 10) - 1];
            if (page) slot.style.setProperty('--nhir-placeholder-height', `${Math.round(getPlaceholderHeight(page) * 0.72)}px`);
        });
    }

    function updatePagePlaceholder(img, page) {
        const wrapper = img.closest('.nhir-page, .nhir-manga-slot');
        if (!wrapper || !page) return;

        const multiplier = wrapper.classList.contains('nhir-manga-slot') ? 0.72 : 1;
        wrapper.style.setProperty('--nhir-placeholder-height', `${Math.round(getPlaceholderHeight(page) * multiplier)}px`);
    }

    function getPlaceholderHeight(page) {
        const availableWidth = Math.max(260, Math.min(window.innerWidth - 16, CONFIG.fullMaxWidth));
        const rawHeight = availableWidth * (page?.ratio || 1.414);
        return Math.round(clamp(rawHeight, 300, Math.max(620, window.innerHeight * 1.4)));
    }

    function mountOverlayUI(overlay) {
        const root = document.createElement('div');
        root.id = IDS.uiRoot;

        const topButtonWrap = document.createElement('div');
        topButtonWrap.className = 'nhir-top-button';

        const topButton = document.createElement('button');
        topButton.type = 'button';
        topButton.className = 'nhir-btn';
        topButton.innerHTML = ICONS.up;
        topButton.title = 'Click: top. Hold: bottom.';
        topButton.setAttribute('aria-label', 'Click to scroll top. Hold to scroll bottom.');

        topButtonWrap.appendChild(topButton);

        const actionStack = document.createElement('div');
        actionStack.className = 'nhir-action-stack';

        const modeItem = createStackItem('mode', ICONS.mode, 'Reading mode');
        const fitItem = createStackItem('fit', ICONS.fit, 'Fit and zoom');
        const gapItem = createStackItem('gap', ICONS.gap, 'Page gap');
        const pageItem = createStackItem('page', ICONS.reader, 'Jump to page');
        const themeItem = createStackItem('theme', ICONS.theme, 'Theme color');

        actionStack.append(
            modeItem.item,
            fitItem.item,
            gapItem.item,
            pageItem.item,
            themeItem.item
        );

        const menuButtonWrap = document.createElement('div');
        menuButtonWrap.className = 'nhir-menu-button';

        const menuButton = document.createElement('button');
        menuButton.type = 'button';
        menuButton.className = 'nhir-btn';
        menuButton.innerHTML = ICONS.menu;
        menuButton.title = 'Menu';
        menuButton.setAttribute('aria-label', 'Menu');

        menuButtonWrap.appendChild(menuButton);

        const closeButtonWrap = document.createElement('div');
        closeButtonWrap.className = 'nhir-close-button';

        const closeButton = document.createElement('button');
        closeButton.type = 'button';
        closeButton.className = 'nhir-btn nhir-close-btn';
        closeButton.innerHTML = ICONS.closedBook;
        closeButton.title = 'Close reader';
        closeButton.setAttribute('aria-label', 'Close reader');

        closeButtonWrap.appendChild(closeButton);

        const bottomControls = document.createElement('div');
        bottomControls.className = 'nhir-bottom-controls';
        bottomControls.append(closeButtonWrap, menuButtonWrap);

        root.append(topButtonWrap, actionStack, bottomControls);
        overlay.appendChild(root);

        const modeToggles = createToggleGroup([
            {
                label: 'Webtoon',
                active: state.readingMode === 'webtoon',
                onSelect() {
                    updateReadingMode('webtoon');
                    armMenuCollapse();
                }
            },
            {
                label: 'Manga',
                active: state.readingMode === 'manga',
                onSelect() {
                    updateReadingMode('manga');
                    armMenuCollapse();
                }
            }
        ]);

        modeItem.panel.appendChild(modeToggles.root);

        fitItem.panel.classList.add('nhir-panel-vertical');

        const fitToggles = createToggleGroup([
            {
                label: 'Fit',
                active: state.zoom === 0,
                onSelect() {
                    updateZoom(0, true);
                    armMenuCollapse();
                }
            },
            {
                label: 'Fullscreen',
                active: state.zoom === 100,
                onSelect() {
                    updateZoom(100, true);
                    armMenuCollapse();
                }
            }
        ]);

        const zoomWrap = document.createElement('div');
        zoomWrap.className = 'nhir-slider-wrap';

        const zoomTop = document.createElement('div');
        zoomTop.className = 'nhir-slider-top';

        const zoomLabel = document.createElement('span');
        zoomLabel.textContent = 'Zoom';

        const zoomValue = document.createElement('span');
        zoomValue.className = 'nhir-slider-value';
        zoomValue.textContent = `${state.zoom}%`;

        zoomTop.append(zoomLabel, zoomValue);

        const zoomRow = document.createElement('div');
        zoomRow.className = 'nhir-slider-row';

        const zoomSlider = document.createElement('input');
        zoomSlider.type = 'range';
        zoomSlider.min = '0';
        zoomSlider.max = '100';
        zoomSlider.step = '1';
        zoomSlider.value = String(state.zoom);

        const zoomReset = document.createElement('button');
        zoomReset.type = 'button';
        zoomReset.textContent = 'Fit';

        zoomRow.append(zoomSlider, zoomReset);
        zoomWrap.append(zoomTop, zoomRow);

        fitItem.panel.append(fitToggles.root, zoomWrap);

        const gapToggles = createToggleGroup([0, 5, 10, 15].map(gap => ({
            label: `${gap}px`,
            active: state.gap === gap,
            onSelect() {
                updateGap(gap);
                armMenuCollapse();
            }
        })));

        gapItem.panel.appendChild(gapToggles.root);

        const pagePanel = document.createElement('div');
        pagePanel.className = 'nhir-jump';

        const pageInput = document.createElement('input');
        pageInput.type = 'number';
        pageInput.min = '1';
        pageInput.max = String(totalPages);
        pageInput.placeholder = `1-${totalPages}`;

        const pageButton = document.createElement('button');
        pageButton.type = 'button';
        pageButton.textContent = 'Go';

        pagePanel.append(pageInput, pageButton);
        pageItem.panel.appendChild(pagePanel);

        themeItem.panel.classList.add('nhir-panel-vertical');
        const colorPicker = createCustomColorPicker();
        themeItem.panel.appendChild(colorPicker.root);

        root.addEventListener('pointerenter', () => {
            state.pointerInsideUI = true;
            root.classList.remove('is-ui-hidden');
            clearMenuCollapse();
        });

        root.addEventListener('pointerleave', () => {
            state.pointerInsideUI = false;
            armMenuCollapse();
        });

        menuButton.addEventListener('click', event => {
            event.preventDefault();
            event.stopPropagation();

            if (state.menuOpen) {
                closeMenu();
            } else {
                openMenu();
            }
        });

        closeButton.addEventListener('click', event => {
            event.preventDefault();
            event.stopPropagation();
            closeOverlay();
        });

        wirePanelToggle(modeItem, 'mode');
        wirePanelToggle(fitItem, 'fit');
        wirePanelToggle(gapItem, 'gap');
        wirePanelToggle(pageItem, 'page', { noAutoCollapse: true });
        wirePanelToggle(themeItem, 'theme');

        pageButton.addEventListener('click', () => {
            jumpToPage(pageInput.value, 'smooth');
        });

        pageInput.addEventListener('keydown', event => {
            if (event.key === 'Enter') {
                jumpToPage(pageInput.value, 'smooth');
            }
        });

        zoomSlider.addEventListener('input', () => {
            updateZoom(parseInt(zoomSlider.value, 10), false);
        });

        zoomSlider.addEventListener('change', () => {
            saveSettings();
            armMenuCollapse();
        });

        zoomReset.addEventListener('click', () => {
            updateZoom(0, true);
            armMenuCollapse();
        });

        setupTopButton(topButton);

        state.ui = {
            root,
            menuButton,
            topButton,
            items: {
                mode: modeItem,
                fit: fitItem,
                gap: gapItem,
                page: pageItem,
                theme: themeItem
            },
            closeButton,
            modeButtons: modeToggles.buttons,
            fitButtons: fitToggles.buttons,
            gapButtons: gapToggles.buttons,
            pageInput,
            zoomSlider,
            zoomValue,
            color: colorPicker
        };

        updatePageInput();
        syncUI();
    }

    function createCustomColorPicker() {
        const root = document.createElement('div');
        root.className = 'nhir-color-picker';

        const square = document.createElement('div');
        square.className = 'nhir-color-square';

        const knob = document.createElement('div');
        knob.className = 'nhir-color-knob';
        square.appendChild(knob);

        const hueRow = document.createElement('div');
        hueRow.className = 'nhir-hue-row';

        const hueSlider = document.createElement('input');
        hueSlider.className = 'nhir-hue-slider';
        hueSlider.type = 'range';
        hueSlider.min = '0';
        hueSlider.max = '360';
        hueSlider.step = '1';

        const resetButton = document.createElement('button');
        resetButton.type = 'button';
        resetButton.textContent = 'Reset';

        hueRow.append(hueSlider, resetButton);
        root.append(square, hueRow);

        state.picker = hexToHsv(state.themeColor);

        const updateSquareFromPointer = event => {
            const rect = square.getBoundingClientRect();
            const x = Math.max(0, Math.min(rect.width, event.clientX - rect.left));
            const y = Math.max(0, Math.min(rect.height, event.clientY - rect.top));

            state.picker.s = x / rect.width;
            state.picker.v = 1 - (y / rect.height);

            updateThemeColorFromPicker();
        };

        square.addEventListener('pointerdown', event => {
            event.preventDefault();
            square.setPointerCapture(event.pointerId);
            updateSquareFromPointer(event);
        });

        square.addEventListener('pointermove', event => {
            if (event.buttons !== 1) return;
            updateSquareFromPointer(event);
        });

        square.addEventListener('pointerup', () => {
            saveSettings();
        });

        hueSlider.addEventListener('input', () => {
            state.picker.h = parseInt(hueSlider.value, 10);
            updateThemeColorFromPicker();
        });

        hueSlider.addEventListener('change', () => {
            saveSettings();
            armMenuCollapse();
        });

        resetButton.addEventListener('click', () => {
            state.themeColor = CONFIG.defaultThemeColor;
            state.picker = hexToHsv(state.themeColor);
            applyThemeColor();
            updateColorPickerUI();
            saveSettings();
            armMenuCollapse();
        });

        return {
            root,
            square,
            knob,
            hueSlider
        };
    }

    function updateThemeColorFromPicker() {
        state.themeColor = hsvToHex(state.picker.h, state.picker.s, state.picker.v);
        applyThemeColor();
        updateColorPickerUI();
    }

    function updateColorPickerUI() {
        if (!state.ui?.color || !state.picker) return;

        const { square, knob, hueSlider } = state.ui.color;

        hueSlider.value = String(Math.round(state.picker.h));
        square.style.setProperty('--nhir-picker-hue', String(Math.round(state.picker.h)));

        knob.style.left = `${state.picker.s * 100}%`;
        knob.style.top = `${(1 - state.picker.v) * 100}%`;
    }

    function setupTopButton(topButton) {
        let navPressTimer = 0;
        let navLongPressFired = false;

        const clearNavTimer = () => {
            if (navPressTimer) {
                clearTimeout(navPressTimer);
                navPressTimer = 0;
            }
        };

        topButton.addEventListener('pointerdown', event => {
            event.preventDefault();
            event.stopPropagation();

            navLongPressFired = false;
            clearNavTimer();

            navPressTimer = window.setTimeout(() => {
                navLongPressFired = true;

                window.scrollTo({
                    top: document.documentElement.scrollHeight,
                    behavior: 'smooth'
                });

                navPressTimer = 0;
            }, CONFIG.longPressMs);
        });

        const releaseNav = event => {
            event.preventDefault();
            event.stopPropagation();

            if (navPressTimer) {
                clearNavTimer();

                if (!navLongPressFired) {
                    window.scrollTo({ top: 0, behavior: 'smooth' });
                }
            }
        };

        topButton.addEventListener('pointerup', releaseNav);
        topButton.addEventListener('pointerleave', clearNavTimer);
        topButton.addEventListener('pointercancel', clearNavTimer);
    }

    function createStackItem(name, icon, label) {
        const item = document.createElement('div');
        item.className = 'nhir-stack-item';
        item.dataset.item = name;

        const panel = document.createElement('div');
        panel.className = 'nhir-panel';

        const button = document.createElement('button');
        button.type = 'button';
        button.className = 'nhir-btn';
        button.innerHTML = icon;
        button.setAttribute('aria-label', label);
        button.title = label;

        item.append(panel, button);

        return { item, panel, button };
    }

    function createToggleGroup(items) {
        const root = document.createElement('div');
        root.className = 'nhir-segmented';

        const buttons = items.map(item => {
            const button = document.createElement('button');
            button.type = 'button';
            button.className = `nhir-chip${item.active ? ' is-active' : ''}`;
            button.textContent = item.label;
            button.addEventListener('click', item.onSelect);
            root.appendChild(button);
            return button;
        });

        return { root, buttons };
    }

    function wirePanelToggle(entry, name, options = {}) {
        entry.button.addEventListener('click', event => {
            event.preventDefault();
            event.stopPropagation();

            const isOpen = entry.item.classList.contains('is-open');

            if (isOpen) {
                closePanels();

                if (!options.noAutoCollapse) {
                    armMenuCollapse();
                }

                return;
            }

            openPanel(name, options);
        });
    }

    function openMenu() {
        if (!state.ui) return;

        state.menuOpen = true;
        state.ui.root.classList.add('is-menu-open');
        state.ui.root.classList.remove('is-ui-hidden');

        armMenuCollapse();
    }

    function closeMenu() {
        if (!state.ui) return;

        clearMenuCollapse();

        state.menuOpen = false;
        state.openPanelName = null;

        state.ui.root.classList.remove('is-menu-open');
        closePanels();
    }

    function openPanel(name, options = {}) {
        if (!state.ui) return;

        state.menuOpen = true;
        state.openPanelName = name;
        state.ui.root.classList.add('is-menu-open');
        state.ui.root.classList.remove('is-ui-hidden');

        Object.entries(state.ui.items).forEach(([key, item]) => {
            item.item.classList.toggle('is-open', key === name);
        });

        if (name === 'theme') {
            updateColorPickerUI();
        }

        if (options.noAutoCollapse) {
            clearMenuCollapse();
            return;
        }

        armMenuCollapse();
    }

    function closePanels() {
        if (!state.ui) return;

        Object.values(state.ui.items).forEach(item => {
            item.item.classList.remove('is-open');
        });

        state.openPanelName = null;
    }

    function clearMenuCollapse() {
        clearTimeout(state.menuCollapseTimer);
        state.menuCollapseTimer = 0;
    }

    function armMenuCollapse() {
        clearMenuCollapse();

        if (!state.menuOpen) return;
        if (state.pointerInsideUI) return;
        if (state.openPanelName === 'page') return;

        state.menuCollapseTimer = window.setTimeout(() => {
            closeMenu();
        }, CONFIG.menuCollapseMs);
    }

    function updateReadingMode(mode) {
        if (state.readingMode === mode) return;

        const anchor = captureViewportAnchor();

        state.readingMode = mode;
        saveSettings();
        renderReader(anchor.pageNo);
        syncUI();
    }

    function updateGap(gap) {
        const anchor = captureViewportAnchor();

        state.gap = gap;
        saveSettings();
        applyGap();
        syncUI();

        requestAnimationFrame(() => restoreViewportAnchor(anchor, 'auto'));
    }

    function updateZoom(zoom, shouldSave) {
        const anchor = captureViewportAnchor();

        state.zoom = Math.max(0, Math.min(100, zoom));

        applyZoom();
        syncUI();

        requestAnimationFrame(() => {
            requestAnimationFrame(() => {
                restoreViewportAnchor(anchor, 'auto');
                updateCurrentPage(true);
            });
        });

        if (shouldSave) {
            saveSettings();
        }
    }

    function applyAllSettings() {
        applyThemeColor();
        applyGap();
        applyZoom();
        syncUI();
    }

    function applyThemeColor() {
        document.documentElement.style.setProperty('--nhir-theme-bg', state.themeColor);
        if (state.overlay) {
            state.overlay.style.backgroundColor = state.themeColor;
        }
    }

    function applyGap() {
        if (!state.reader) return;
        state.reader.style.gap = `${state.gap}px`;
        document.documentElement.style.setProperty('--nhir-reader-gap', `${state.gap}px`);
    }

    function applyZoom() {
        const viewportBase = Math.max(240, window.innerWidth - 16);
        const minFactor = 0.55;
        const factor = minFactor + (state.zoom / 100) * (1 - minFactor);

        const zoomWidth = Math.round(viewportBase * factor);
        const mangaPageWidth = Math.max(160, Math.floor((zoomWidth - state.gap - 16) / 2));

        document.documentElement.style.setProperty('--nhir-zoom-width', `${zoomWidth}px`);
        document.documentElement.style.setProperty('--nhir-manga-page-width', `${mangaPageWidth}px`);

        if (state.overlay) {
            state.overlay.classList.toggle('nhir-zoom-over', state.zoom > 0);
            state.overlay.classList.toggle('nhir-fit-mode', state.zoom === 0);
        }
    }

    function syncUI() {
        if (!state.ui) return;

        state.ui.modeButtons[0].classList.toggle('is-active', state.readingMode === 'webtoon');
        state.ui.modeButtons[1].classList.toggle('is-active', state.readingMode === 'manga');

        state.ui.fitButtons[0].classList.toggle('is-active', state.zoom === 0);
        state.ui.fitButtons[1].classList.toggle('is-active', state.zoom === 100);

        state.ui.gapButtons.forEach((button, index) => {
            button.classList.toggle('is-active', state.gap === [0, 5, 10, 15][index]);
        });

        state.ui.zoomSlider.value = String(state.zoom);
        state.ui.zoomValue.textContent = `${state.zoom}%`;

        updateColorPickerUI();
        updatePageInput();
    }

    function createProgressBar(parent) {
        const bar = document.createElement('div');
        bar.id = IDS.progressBar;
        parent.appendChild(bar);
    }

    function createPageIndicator(parent) {
        const indicator = document.createElement('div');
        indicator.id = IDS.pageIndicator;
        indicator.textContent = `Page 1 / ${totalPages}`;
        parent.appendChild(indicator);
    }

    function updateProgressBar() {
        const bar = document.getElementById(IDS.progressBar);
        if (!bar) return;

        const doc = document.documentElement;
        const maxScroll = doc.scrollHeight - window.innerHeight;
        const progress = maxScroll <= 0 ? 0 : (window.scrollY / maxScroll) * 100;

        bar.style.width = `${Math.max(0, Math.min(100, progress))}%`;
    }

    function updatePageIndicator() {
        const indicator = document.getElementById(IDS.pageIndicator);
        if (!indicator) return;

        if (state.readingMode === 'manga') {
            const first = getSpreadFirstFromPage(state.currentPageNo);
            const second = Math.min(totalPages, first + 1);
            indicator.textContent = `Pages ${first}${second !== first ? '–' + second : ''} / ${totalPages}`;
            return;
        }

        indicator.textContent = `Page ${state.currentPageNo} / ${totalPages}`;
    }

    function updatePageInput() {
        if (!state.ui?.pageInput) return;

        if (document.activeElement !== state.ui.pageInput) {
            state.ui.pageInput.value = String(state.currentPageNo);
        }
    }

    function ensureStyle() {
        if (document.getElementById(IDS.style)) return;

        const style = document.createElement('style');
        style.id = IDS.style;

        style.textContent = `
            :root {
                --nhir-theme-bg: ${state.themeColor};
                --nhir-ui-surface: rgba(245, 241, 232, 0.96);
                --nhir-ui-stroke: rgba(0, 0, 0, 0.08);
                --nhir-ui-text: #2d3137;
                --nhir-ui-track: rgba(0, 0, 0, 0.12);
                --nhir-ui-fill: #303843;
                --nhir-reader-gap: 5px;
                --nhir-zoom-width: calc(100vw - 16px);
                --nhir-manga-page-width: calc((100vw - 32px) / 2);
            }

            body.nhir-body-active {
                margin: 0 !important;
                background: var(--nhir-theme-bg) !important;
                overscroll-behavior-y: contain;
            }

            #${IDS.launcher} {
                position: fixed;
                right: 20px;
                bottom: 20px;
                z-index: 2147483646;
                width: 50px;
                height: 50px;
                border: 1px solid var(--nhir-ui-stroke);
                border-radius: 50%;
                background: var(--nhir-ui-surface);
                color: var(--nhir-ui-text);
                display: inline-flex;
                align-items: center;
                justify-content: center;
                cursor: pointer;
                box-shadow: 0 10px 28px rgba(0, 0, 0, 0.18);
                transition: transform 150ms ease, background-color 150ms ease, color 150ms ease;
            }

            #${IDS.launcher}:hover {
                transform: scale(1.05);
            }

            #${IDS.launcher} svg,
            .nhir-btn svg {
                display: block;
                fill: none;
                stroke: currentColor;
                stroke-width: 2.2;
                stroke-linecap: round;
                stroke-linejoin: round;
                flex: none;
            }

            #${IDS.overlay} {
                position: relative;
                z-index: 2147483645;
                width: 100%;
                min-height: 100dvh;
                background: var(--nhir-theme-bg);
                color: white;
                transition: background-color 180ms ease;
                overscroll-behavior: contain;
            }

            .nhir-reader {
                min-height: 100dvh;
                display: flex;
                flex-direction: column;
                align-items: center;
                gap: var(--nhir-reader-gap);
                padding: 8px 0 120px;
                box-sizing: border-box;
            }

            .nhir-page {
                width: min(100%, ${CONFIG.fullMaxWidth}px);
                min-height: var(--nhir-placeholder-height, 520px);
                display: flex;
                justify-content: center;
                align-items: center;
                position: relative;
                scroll-margin-top: 8px;
                scroll-margin-bottom: 8px;
                box-sizing: border-box;
            }

            .nhir-mode-webtoon .nhir-page.is-loaded {
                min-height: 0;
            }

            .nhir-image {
                display: block;
                width: auto;
                height: auto;
                max-width: min(calc(100vw - 16px), ${CONFIG.fullMaxWidth}px);
                max-height: calc(100dvh - 16px);
                border-radius: 10px;
                background: rgba(0, 0, 0, 0.07);
                box-shadow: 0 12px 32px rgba(0, 0, 0, 0.16);
                object-fit: contain;
                image-rendering: auto;
                opacity: 0;
                transition:
                    opacity 220ms ease,
                    width 180ms ease,
                    max-width 180ms ease,
                    max-height 180ms ease,
                    border-radius 180ms ease,
                    box-shadow 180ms ease;
            }

            .nhir-image.loaded {
                opacity: 1;
            }

            #${IDS.overlay}.nhir-zoom-over .nhir-mode-webtoon .nhir-image {
                width: min(var(--nhir-zoom-width), ${CONFIG.fullMaxWidth}px);
                max-width: none;
                max-height: none;
                object-fit: initial;
            }

            .nhir-mode-manga {
                justify-content: flex-start;
            }

            .nhir-spread {
                min-height: calc(100dvh - 16px);
                width: 100%;
                display: flex;
                flex-direction: row;
                justify-content: center;
                align-items: center;
                gap: var(--nhir-reader-gap);
                position: relative;
                scroll-margin-top: 8px;
                scroll-margin-bottom: 8px;
                box-sizing: border-box;
            }

            .nhir-manga-slot {
                position: relative;
                display: flex;
                justify-content: center;
                align-items: center;
                min-width: 0;
                min-height: var(--nhir-placeholder-height, 360px);
            }

            .nhir-manga-slot.is-loaded {
                min-height: 0;
            }

            .nhir-manga-slot.is-empty {
                width: min(calc((100vw - 32px) / 2), 820px);
                min-height: 40dvh;
            }

            .nhir-mode-manga .nhir-image {
                max-width: min(calc((100vw - 32px) / 2), 900px);
                max-height: calc(100dvh - 32px);
            }

            #${IDS.overlay}.nhir-zoom-over .nhir-mode-manga .nhir-image {
                width: var(--nhir-manga-page-width);
                max-width: none;
                max-height: none;
                object-fit: initial;
            }

            .nhir-error {
                display: none;
                position: absolute;
                inset: 0;
                min-height: 220px;
                align-items: center;
                justify-content: center;
                flex-direction: column;
                gap: 12px;
                background: rgba(30, 30, 30, 0.82);
                color: white;
                font-family: "Segoe UI", Arial, sans-serif;
                font-size: 14px;
                text-align: center;
                border-radius: 10px;
            }

            .nhir-error.visible {
                display: flex;
            }

            .nhir-error button,
            .nhir-slider-row button,
            .nhir-jump button,
            .nhir-color-picker button {
                min-width: 58px;
                height: 32px;
                padding: 0 12px;
                border: 0;
                border-radius: 999px;
                background: #303843;
                color: #f7f8fa;
                cursor: pointer;
                font-size: 13px;
                font-weight: 700;
            }

            #${IDS.uiRoot} {
                position: fixed;
                right: 20px;
                bottom: 20px;
                z-index: 2147483647;
                display: flex;
                flex-direction: column;
                align-items: flex-end;
                gap: 12px;
                pointer-events: none;
                font-family: "Segoe UI", sans-serif;
                user-select: none;
                transition: opacity 180ms ease, transform 180ms ease;
            }

            #${IDS.uiRoot}.is-ui-hidden:not(.is-menu-open) {
                opacity: 0;
                transform: translate3d(0, 70px, 0);
                pointer-events: none;
            }

            .nhir-top-button,
            .nhir-menu-button,
            .nhir-close-button {
                pointer-events: auto;
            }

            .nhir-bottom-controls {
                display: flex;
                flex-direction: row;
                align-items: center;
                justify-content: flex-end;
                gap: 10px;
                pointer-events: auto;
            }

            .nhir-action-stack {
                display: flex;
                flex-direction: column;
                align-items: flex-end;
                gap: 12px;
                max-height: 0;
                opacity: 0;
                overflow: visible;
                pointer-events: none;
                transition:
                    max-height 260ms cubic-bezier(0.2, 0.8, 0.2, 1),
                    opacity 180ms ease;
            }

            #${IDS.uiRoot}.is-menu-open .nhir-action-stack {
                max-height: 440px;
                opacity: 1;
                pointer-events: auto;
            }

            .nhir-stack-item {
                position: relative;
                display: flex;
                align-items: center;
                justify-content: flex-end;
                min-height: 48px;
                pointer-events: none;
                opacity: 0;
                transform: translate3d(0, 14px, 0) scale(0.96);
                transition:
                    opacity 180ms ease,
                    transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
            }

            #${IDS.uiRoot}.is-menu-open .nhir-stack-item {
                opacity: 1;
                transform: translate3d(0, 0, 0) scale(1);
                pointer-events: auto;
            }

            #${IDS.uiRoot}.is-menu-open .nhir-stack-item:nth-child(1) { transition-delay: 20ms; }
            #${IDS.uiRoot}.is-menu-open .nhir-stack-item:nth-child(2) { transition-delay: 45ms; }
            #${IDS.uiRoot}.is-menu-open .nhir-stack-item:nth-child(3) { transition-delay: 70ms; }
            #${IDS.uiRoot}.is-menu-open .nhir-stack-item:nth-child(4) { transition-delay: 95ms; }
            #${IDS.uiRoot}.is-menu-open .nhir-stack-item:nth-child(5) { transition-delay: 120ms; }

            .nhir-panel {
                position: absolute;
                right: 56px;
                top: 50%;
                transform: translate3d(12px, -50%, 0);
                display: flex;
                align-items: center;
                gap: 10px;
                height: 46px;
                padding: 0;
                max-width: 0;
                overflow: hidden;
                white-space: nowrap;
                opacity: 0;
                pointer-events: none;
                border: 1px solid var(--nhir-ui-stroke);
                border-radius: 999px;
                background: var(--nhir-ui-surface);
                backdrop-filter: blur(14px);
                box-shadow: 0 10px 28px rgba(0, 0, 0, 0.18);
                transition:
                    max-width 220ms ease,
                    opacity 180ms ease,
                    transform 220ms ease,
                    padding 180ms ease;
            }

            .nhir-panel.nhir-panel-vertical {
                height: auto;
                min-height: 46px;
                flex-direction: column;
                align-items: stretch;
                justify-content: center;
                border-radius: 24px;
                white-space: normal;
            }

            .nhir-stack-item.is-open .nhir-panel {
                max-width: 440px;
                padding: 0 12px 0 16px;
                opacity: 1;
                transform: translate3d(0, -50%, 0);
                pointer-events: auto;
            }

            .nhir-stack-item.is-open .nhir-panel.nhir-panel-vertical {
                padding: 12px 14px;
                min-width: 270px;
            }

            .nhir-btn {
                width: 46px;
                height: 46px;
                border: 1px solid var(--nhir-ui-stroke);
                border-radius: 50%;
                background: var(--nhir-ui-surface);
                color: var(--nhir-ui-text);
                display: inline-flex;
                align-items: center;
                justify-content: center;
                cursor: pointer;
                box-shadow: 0 10px 28px rgba(0, 0, 0, 0.18);
                transition:
                    transform 150ms ease,
                    background-color 150ms ease,
                    color 150ms ease;
            }

            .nhir-btn:hover {
                transform: scale(1.04);
            }

            .nhir-close-btn:hover {
                transform: scale(1.06);
            }

            #${IDS.uiRoot}.is-menu-open .nhir-menu-button .nhir-btn {
                transform: rotate(90deg);
            }

            #${IDS.uiRoot}.is-menu-open .nhir-menu-button .nhir-btn:hover {
                transform: rotate(90deg) scale(1.04);
            }

            .nhir-segmented {
                display: flex;
                align-items: center;
                gap: 8px;
            }

            .nhir-chip {
                min-width: 74px;
                height: 32px;
                padding: 0 12px;
                border: 0;
                border-radius: 999px;
                background: rgba(0, 0, 0, 0.08);
                color: var(--nhir-ui-text);
                cursor: pointer;
                font-weight: 700;
                font-size: 13px;
                transition: background-color 150ms ease, color 150ms ease;
            }

            .nhir-chip.is-active {
                background: #303843;
                color: #f7f8fa;
            }

            .nhir-slider-wrap {
                display: flex;
                flex-direction: column;
                gap: 10px;
                min-width: 240px;
                color: var(--nhir-ui-text);
                font-size: 13px;
                font-weight: 700;
            }

            .nhir-slider-top,
            .nhir-slider-row {
                display: flex;
                align-items: center;
                justify-content: space-between;
                gap: 10px;
            }

            .nhir-slider-value {
                min-width: 40px;
                text-align: right;
            }

            .nhir-slider-row input[type="range"] {
                width: 160px;
                accent-color: #303843;
            }

            .nhir-jump {
                display: flex;
                align-items: center;
                gap: 8px;
            }

            .nhir-jump input {
                width: 88px;
                height: 32px;
                border: 0;
                border-radius: 999px;
                padding: 0 12px;
                background: rgba(255, 255, 255, 0.82);
                color: #111 !important;
                -webkit-text-fill-color: #111;
                caret-color: #111;
                outline: none;
                font-size: 13px;
                font-weight: 800;
            }

            .nhir-color-picker {
                width: 250px;
                display: flex;
                flex-direction: column;
                gap: 12px;
            }

            .nhir-color-square {
                position: relative;
                width: 100%;
                height: 145px;
                border-radius: 18px 18px 8px 8px;
                overflow: hidden;
                cursor: pointer;
                background:
                    linear-gradient(to top, #000, transparent),
                    linear-gradient(to right, #fff, hsla(var(--nhir-picker-hue, 220), 100%, 50%, 1));
                box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.10);
            }

            .nhir-color-knob {
                position: absolute;
                width: 16px;
                height: 16px;
                border: 2px solid white;
                border-radius: 50%;
                box-shadow: 0 1px 6px rgba(0, 0, 0, 0.45);
                transform: translate(-50%, -50%);
                pointer-events: none;
            }

            .nhir-hue-row {
                display: flex;
                align-items: center;
                gap: 10px;
            }

            .nhir-hue-slider {
                flex: 1;
                accent-color: #303843;
                cursor: pointer;
            }

            #${IDS.progressBar} {
                position: fixed;
                top: 0;
                left: 0;
                height: 3px;
                width: 0;
                z-index: 2147483647;
                background: rgba(255, 255, 255, 0.75);
                box-shadow: 0 0 10px rgba(255, 255, 255, 0.55);
                pointer-events: none;
            }

            #${IDS.pageIndicator} {
                position: fixed;
                left: 20px;
                bottom: 20px;
                transform: none;
                z-index: 2147483647;
                padding: 8px 14px;
                border-radius: 999px;
                background: rgba(245, 241, 232, 0.92);
                color: #2d3137;
                font-family: "Segoe UI", sans-serif;
                font-size: 13px;
                font-weight: 800;
                box-shadow: 0 10px 28px rgba(0, 0, 0, 0.18);
                pointer-events: none;
            }

            @media (max-width: 760px) {
                #${IDS.launcher},
                #${IDS.uiRoot} {
                    right: 12px;
                    bottom: 12px;
                }

                #${IDS.pageIndicator} {
                    left: 12px;
                    bottom: 14px;
                    font-size: 12px;
                    padding: 7px 11px;
                }

                .nhir-reader {
                    padding-top: 4px;
                }

                .nhir-image {
                    max-width: 100vw;
                    border-radius: 0;
                    box-shadow: none;
                }

                #${IDS.overlay}.nhir-zoom-over .nhir-mode-webtoon .nhir-image {
                    width: var(--nhir-zoom-width);
                }

                .nhir-panel {
                    right: 54px;
                }

                .nhir-stack-item.is-open .nhir-panel.nhir-panel-vertical {
                    min-width: min(270px, calc(100vw - 92px));
                }

                .nhir-color-picker {
                    width: min(248px, calc(100vw - 114px));
                }
            }
        `;

        document.head.appendChild(style);
    }

    function hexToHsv(hex) {
        const fixed = normalizeHexColor(hex) || CONFIG.defaultThemeColor;
        const r = parseInt(fixed.slice(1, 3), 16) / 255;
        const g = parseInt(fixed.slice(3, 5), 16) / 255;
        const b = parseInt(fixed.slice(5, 7), 16) / 255;

        const max = Math.max(r, g, b);
        const min = Math.min(r, g, b);
        const delta = max - min;

        let h = 0;

        if (delta !== 0) {
            if (max === r) {
                h = 60 * (((g - b) / delta) % 6);
            } else if (max === g) {
                h = 60 * ((b - r) / delta + 2);
            } else {
                h = 60 * ((r - g) / delta + 4);
            }
        }

        if (h < 0) h += 360;

        const s = max === 0 ? 0 : delta / max;
        const v = max;

        return { h, s, v };
    }

    function hsvToHex(h, s, v) {
        const safeS = clamp(s, 0.04, 0.94);
        const safeV = clamp(v, 0.10, 0.94);
        const c = safeV * safeS;
        const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
        const m = safeV - c;

        let r = 0;
        let g = 0;
        let b = 0;

        if (h < 60) [r, g, b] = [c, x, 0];
        else if (h < 120) [r, g, b] = [x, c, 0];
        else if (h < 180) [r, g, b] = [0, c, x];
        else if (h < 240) [r, g, b] = [0, x, c];
        else if (h < 300) [r, g, b] = [x, 0, c];
        else [r, g, b] = [c, 0, x];

        const toHex = value => Math.round((value + m) * 255).toString(16).padStart(2, '0');

        return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
    }

    function normalizeHexColor(value) {
        const text = String(value || '').trim();

        if (/^#[0-9a-f]{6}$/i.test(text)) return text;

        if (/^#[0-9a-f]{3}$/i.test(text)) {
            return `#${text[1]}${text[1]}${text[2]}${text[2]}${text[3]}${text[3]}`;
        }

        return null;
    }

    function clampPage(pageNo) {
        const parsed = parseInt(pageNo, 10);
        if (!Number.isFinite(parsed)) return 1;
        return Math.max(1, Math.min(totalPages || 1, parsed));
    }

    function clamp(value, min, max) {
        return Math.max(min, Math.min(max, value));
    }

    function wait(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
    }

    function nextFrame() {
        return new Promise(resolve => requestAnimationFrame(() => resolve()));
    }
})();