Hitomi page scroller

You can scroll up and down to turn the page and adjust the "Fit" mode image area.

// ==UserScript==
// @name:ko           Hitomi 페이지 스크롤러
// @name              Hitomi page scroller
// @name:ru           Hitomi прокрутка страниц
// @name:ja           Hitomiページスクローラー
// @name:zh-TW        Hitomi頁面滾動條
// @name:zh-CN        Hitomi页面滚动条

// @description:ko    위아래로 스크롤하여 페이지를 넘길 수 있으며, "Fit"모드 이미지 넓이를 조절 할 수 있습니다.
// @description       You can scroll up and down to turn the page and adjust the "Fit" mode image area.
// @description:ru    Вы можете прокручивать страницы вверх и вниз, регулируя ширину изображения в режиме "Fit".
// @description:ja    上下にスクロールしてページをめくることができ、「Fit」モードイメージの広さを調節することができます。
// @description:zh-TW 可上下滾動翻頁,並可調整"Fit"模式圖像寬度。
// @description:zh-CN 可上下滚动翻页,并可调整"Fit"模式图像宽度。

// @namespace         https://ndaesik.tistory.com/
// @version           2024.12.06.00.34
// @author            ndaesik
// @icon              https://t1.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=http://hitomi.la
// @match             https://*.la/reader/*

// @grant             GM_getValue
// @grant             GM_setValue
// ==/UserScript==

(function() {
    'use strict';
    const startPage = parseInt(window.location.hash.slice(1)) || 1;
    let currentPage = startPage;
    let isLoading = false;
    let loadQueue = 0;
    let upScrollCount = 0;
    let lastScrollTime = 0;
    let initialLoad = true;
    const baseUrl = window.location.href.split('#')[0];
    let paddingSize = GM_getValue('paddingSize', 20);

    const style = document.createElement('style');
    style.textContent = `
        #comicImages {
            padding: 0 ${paddingSize}vw !important;
            width: 100vw !important;
            box-sizing: border-box !important;
            user-select: none !important;
            display: block !important;
            position: relative !important;
        }
        #comicImages picture {
            pointer-events: none !important;
            display: block !important;
            padding: 3px 0 !important
        }
        #comicImages img {
            width: 100% !important;
            display: block !important;
        }
        #comicImages.fitVertical img {
            max-height: unset !important;
        }
        .width-control-container {
            display: flex !important;
            align-items: center !important;
            gap: 10px !important;
            padding: 12px !important;
        }
        .width-range {
            width: 100px !important;
        }
        #comicImages picture:first-child {
            min-height: 100vh !important;
        }
    `;
    document.head.appendChild(style);

    function createPaddingControl() {
        const navbarNav = document.querySelector('.navbar-nav');
        if (!navbarNav) return;

        const container = document.createElement('div');
        container.className = 'width-control-container';
        const range = document.createElement('input');
        range.type = 'range';
        range.className = 'width-range';
        range.min = '0';
        range.max = '45';
        range.value = 45 - paddingSize;

        range.addEventListener('input', (e) => {
            paddingSize = 45 - e.target.value;
            document.querySelector('#comicImages').style.cssText += `padding: 0 ${paddingSize}vw !important;`;
            GM_setValue('paddingSize', paddingSize);
        });

        container.appendChild(range);
        navbarNav.appendChild(container);
    }

    function updateCurrentPage() {
        const container = document.querySelector('#comicImages');
        if (!container) return;
        const pictures = container.querySelectorAll('picture');
        if (!pictures.length) return;

        const pageSelect = document.querySelector('#single-page-select');
        if (!pageSelect) return;

        // 현재 뷰포트의 중앙 위치 계산
        const viewportHeight = window.innerHeight;
        const viewportCenter = window.scrollY + (viewportHeight / 2);

        // 각 picture 요소의 위치 확인
        let closestPicture = null;
        let closestDistance = Infinity;

        pictures.forEach((picture, index) => {
            const rect = picture.getBoundingClientRect();
            // picture 요소의 절대 위치 계산
            const pictureTop = rect.top + window.scrollY;
            const pictureCenter = pictureTop + (rect.height / 2);
            const distance = Math.abs(viewportCenter - pictureCenter);

            if (distance < closestDistance) {
                closestDistance = distance;
                closestPicture = index;
            }
        });

        // URL의 해시값을 기준으로 현재 페이지 계산
        if (closestPicture !== null) {
            const totalValue = parseInt(window.location.hash.slice(1)) + closestPicture;
            if (totalValue !== parseInt(pageSelect.value)) {
                pageSelect.value = totalValue;
                console.log(`Hash: ${window.location.hash}, Index: ${closestPicture}, Total: ${totalValue}`);
            }
        }
    }

    function handleScrollWheel(e) {
        const container = document.querySelector('#comicImages');
        if (!container) return;

        if (container.scrollTop === 0 && e.deltaY < 0) {
            const currentTime = Date.now();
            if (currentTime - lastScrollTime < 500) {
                upScrollCount++;
                if (upScrollCount >= 2) {
                    const prevPanel = document.querySelector('#prevPanel');
                    if (prevPanel) prevPanel.click();
                    upScrollCount = 0;
                }
            } else {
                upScrollCount = 1;
            }
            lastScrollTime = currentTime;
        } else {
            upScrollCount = 0;
        }
    }

    function initScrollListener() {
        const container = document.querySelector('#comicImages');
        if (!container) {
            setTimeout(initScrollListener, 100);
            return;
        }

        let scrollTimeout;
        container.addEventListener('scroll', () => {
            if (scrollTimeout) return;
            scrollTimeout = setTimeout(() => {
                checkScrollAndLoad();
                updateCurrentPage();
                scrollTimeout = null;
            }, 50);
        });

        container.addEventListener('wheel', handleScrollWheel);
        container.style.cssText += `padding: 0 ${paddingSize}vw !important;`;

        document.querySelector('#single-page-select').value = startPage;

        if (initialLoad) {
            loadNextImage();
            initialLoad = false;
        }

        checkScrollAndLoad();
        updateCurrentPage();
    }

    function getMaxPage() {
        const options = document.querySelectorAll('#single-page-select option');
        let maxPage = 0;
        options.forEach(option => {
            const value = parseInt(option.value);
            if (value > maxPage) maxPage = value;
        });
        return maxPage;
    }

    async function loadNextImage() {
        if (isLoading) {
            loadQueue++;
            return;
        }

        const maxPage = getMaxPage();
        if (currentPage >= maxPage) {
            loadQueue = 0;
            return;
        }

        isLoading = true;

        try {
            currentPage++;
            const iframe = document.createElement('iframe');
            iframe.style.display = 'none';
            document.body.appendChild(iframe);
            iframe.src = `${baseUrl}#${currentPage}`;

            await new Promise(resolve => iframe.onload = resolve);
            const imgElement = await waitForElement(iframe, '#comicImages > picture > img');

            if (!imgElement?.src) throw new Error('Image not found');

            const pictureElement = document.createElement('picture');
            const newImage = document.createElement('img');
            newImage.src = imgElement.src;
            newImage.style.cssText = 'width: 100% !important; display: block !important;';

            await new Promise((resolve, reject) => {
                newImage.onload = resolve;
                newImage.onerror = reject;
            });

            pictureElement.appendChild(newImage);
            const container = document.querySelector('#comicImages');
            if (!container) throw new Error('Container not found');

            container.appendChild(pictureElement);
            iframe.remove();

            if (loadQueue > 0) {
                loadQueue--;
                loadNextImage();
            }
            checkScrollAndLoad();
            updateCurrentPage();
        } catch (error) {
            currentPage--;
            loadQueue = 0;
        } finally {
            isLoading = false;
        }
    }

    function checkScrollAndLoad() {
        const container = document.querySelector('#comicImages');
        if (!container) return;
        const scrollPosition = container.scrollTop + container.clientHeight;
        const remainingHeight = container.scrollHeight - scrollPosition;
        if (remainingHeight < container.clientHeight * 2.5) loadNextImage();
    }

    function waitForElement(iframe, selector, timeout = 5000) {
        return new Promise((resolve, reject) => {
            const startTime = Date.now();
            const check = () => {
                const element = iframe.contentDocument.querySelector(selector);
                if (element) return resolve(element);
                if (Date.now() - startTime > timeout) return reject(new Error(`Timeout`));
                setTimeout(check, 100);
            };
            check();
        });
    }

    createPaddingControl();
    initScrollListener();
})();