A Simple Web-Comic Navigation Enhancer

You can quickly access the previous and next episodes, perform smooth scrolling up or down, and even enable or disable full-screen mode. This script is designed to enhance the reading experience of web content in a more convenient and customizable.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         A Simple Web-Comic Navigation Enhancer
// @namespace    http://tampermonkey.net/
// @version      2.3.0
// @description  You can quickly access the previous and next episodes, perform smooth scrolling up or down, and even enable or disable full-screen mode. This script is designed to enhance the reading experience of web content in a more convenient and customizable.
// @match        https://westmanga.me/*
// @match        https://v1.komikcast.fit/*
// @match        https://aquareader.net/*
// @match        https://www.webtoons.com/*
// @match        https://kiryuu03.com/*
// @match        https://mangaku.lat/*
// @match        https://manhwatop.com/*
// @match        https://komiku.org/*
// @match        https://www.mikoroku.com/*
// @grant        none
// @require      https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js
// @license      MIT
// ==/UserScript==

(function () {
    'use strict';

    // ========================
    // CONFIGURABLE KEY BINDINGS
    // ========================
    // Change these to customize your keybinds.
    // Each action accepts an array of key names (case-insensitive, matched against event.key).
    const KEY_BINDINGS = {
        scrollUp: ['w'],
        scrollDown: ['s'],
        prevChapter: ['a', 'ArrowLeft'],
        nextChapter: ['d', 'ArrowRight'],
        fullscreen: ['f'],
        allChapters: ['q'],
    };

    // ========================
    // SCROLL SETTINGS
    // ========================
    // All values are time-based (per second), so scroll speed is consistent
    // regardless of frame rate or page rendering load.
    const SCROLL_CONFIG = {
        maxSpeed: 2400,    // Maximum scroll speed (px per second)
        decayRate: 18,     // Momentum decay rate — higher = stops faster (per second, exponential)
        accelRate: 1980,   // Acceleration when key is held (px per second²)

        // Jump scroll — triggered by a quick tap (press + release under tapThreshold)
        tapThreshold: 120, // Max milliseconds a press can last to count as a "tap"
        jumpDistance: 230, // Jump distance in pixels
        jumpDuration: 180, // Jump animation duration in milliseconds
    };

    // ========================
    // SITE CONFIGURATIONS
    // ========================
    // Per-site options:
    //   next / prev        — CSS selector for chapter navigation buttons
    //   allChapters        — CSS selector for the "all chapters" / series page link
    //   scrollSpeed        — Speed multiplier for this site (default: 1.0). Increase if scrolling feels slow.
    //   scrollContainer    — CSS selector for a nested scrollable element. If omitted, scrolls the window.
    const HOSTS = {
        'westmanga.me': {
            next: 'div.max-w-screen-xl:nth-child(2) > div:nth-child(2) > div:nth-child(1) > div:nth-child(2) > button:nth-child(2)',
            prev: 'div.max-w-screen-xl:nth-child(2) > div:nth-child(2) > div:nth-child(1) > div:nth-child(2) > button:nth-child(1)',
            allChapters: '.text-primary'
        },
        'v1.komikcast.fit': {
            next: 'button.border:nth-child(2)',
            prev: 'button.flex-1:nth-child(1)',
            allChapters: 'a.text-foreground',
            scrollContainer: '.overflow-x-hidden'
        },
        'www.webtoons.com': {
            next: '.paginate .pg_next',
            prev: '.paginate .pg_prev',
            allChapters: '.subj_info .subj'
        },
        'aquareader.net': {
            next: 'a.btn.next_page',
            prev: 'a.btn.prev_page',
            allChapters: '.breadcrumb > li:nth-child(2) > a:nth-child(1)'
        },
        'kiryuu03.com': {
            next: 'a.justify-center:nth-child(3)',
            prev: 'a.px-4:nth-child(1)',
            allChapters: 'button.ring-offset-accent'
        },
        'mangaku.lat': {
            prev: 'button.glho.glkp_1:-soup-contains("PREV")',
            next: 'button.glho.glkn_1:-soup-contains("NEXT")'
        },
        'manhwatop.com': {
            prev: '.prev_page',
            next: '.next_page',
            allChapters: 'ol.breadcrumb li:nth-child(2) a'
        },
        'komiku.org': {
            prev: 'div.nxpr > a.rl:first-of-type',
            next: 'div.nxpr > a.rl:last-of-type',
            allChapters: 'div.perapih:nth-child(3) > div:nth-child(1) > div:nth-child(1) > a:nth-child(1)'
        },
        'www.mikoroku.com': {
            prev: 'a[rel="prev"][type="button"]',
            next: 'a[rel="next"][type="button"]',
            allChapters: 'a[rel="home"][type="button"]'
        }
    };

    // ========================
    // INITIALIZATION
    // ========================
    const host = window.location.host;
    const siteConfig = HOSTS[host];

    if (!siteConfig) {
        console.warn(`[NavEnhancer] No configuration found for host: "${host}". Script will not run.`);
        return;
    }

    const btnNext = siteConfig.next;
    const btnPrev = siteConfig.prev;
    const btnAllChapters = siteConfig.allChapters || null;
    const scrollSpeed = siteConfig.scrollSpeed || 1.0;
    const scrollContainerSelector = siteConfig.scrollContainer || null;

    // Force scroll-behavior: auto on the page to prevent the browser's
    // built-in smooth scrolling from interfering with our scroll engine.
    const styleOverride = document.createElement('style');
    styleOverride.textContent = 'html, body { scroll-behavior: auto !important; }';
    document.head.appendChild(styleOverride);

    let isFullscreen = false;
    let scrollingUp = false;
    let scrollingDown = false;
    let speedUp = 0;          // Current upward scroll speed (px/s)
    let speedDown = 0;        // Current downward scroll speed (px/s)
    let scrollRAF = null;     // requestAnimationFrame ID
    let lastFrameTime = null; // Timestamp of the last animation frame

    let keyDownTimeUp = null;   // Timestamp when scroll-up key was pressed
    let keyDownTimeDown = null; // Timestamp when scroll-down key was pressed

    // ========================
    // HELPER FUNCTIONS
    // ========================

    /**
     * Checks if the user is currently focused on a text input field.
     * Prevents keybinds from firing while typing.
     */
    function isUserTyping() {
        const el = document.activeElement;
        if (!el) return false;
        const tag = el.tagName;
        return tag === 'INPUT' || tag === 'TEXTAREA' || el.isContentEditable;
    }

    /**
     * Checks if a key matches one of the configured bindings for an action.
     * Comparison is case-insensitive for letter keys.
     */
    function isKeyMatch(pressedKey, actionKeys) {
        const lower = pressedKey.toLowerCase();
        return actionKeys.some(k => k.toLowerCase() === lower || k === pressedKey);
    }

    /**
     * Safely queries a DOM element by selector and clicks it.
     * Logs a warning if the selector is defined but no element is found.
     */
    function safeClick(selector, actionName) {
        if (!selector) {
            console.warn(`[NavEnhancer] No selector configured for "${actionName}" on ${host}.`);
            return;
        }
        const el = document.querySelector(selector);
        if (el) {
            el.click();
        } else {
            console.warn(`[NavEnhancer] "${actionName}" button not found with selector: "${selector}"`);
        }
    }

    // ========================
    // SCROLLING ENGINE (requestAnimationFrame + delta-time)
    // ========================
    // Uses real elapsed time to calculate scroll distance, making
    // speed consistent regardless of frame rate or rendering load.

    /**
     * Returns the scroll target element.
     * If a scrollContainer selector is configured for this site, returns that element.
     * Otherwise returns null (meaning we scroll the window).
     */
    function getScrollTarget() {
        if (scrollContainerSelector) {
            const container = document.querySelector(scrollContainerSelector);
            if (container) return container;
            console.warn(`[NavEnhancer] Scroll container "${scrollContainerSelector}" not found, falling back to window.`);
        }
        return null;
    }

    function scrollLoop(timestamp) {
        if (lastFrameTime === null) lastFrameTime = timestamp;
        const dt = (timestamp - lastFrameTime) / 1000; // Delta time in seconds
        lastFrameTime = timestamp;

        // Build or decay speed for each direction
        if (scrollingUp) {
            speedUp = Math.min(speedUp + SCROLL_CONFIG.accelRate * dt, SCROLL_CONFIG.maxSpeed);
        } else {
            speedUp *= Math.exp(-SCROLL_CONFIG.decayRate * dt);
        }

        if (scrollingDown) {
            speedDown = Math.min(speedDown + SCROLL_CONFIG.accelRate * dt, SCROLL_CONFIG.maxSpeed);
        } else {
            speedDown *= Math.exp(-SCROLL_CONFIG.decayRate * dt);
        }

        // Stop the loop when both speeds are negligible
        if (speedUp < 1 && speedDown < 1 && !scrollingUp && !scrollingDown) {
            scrollRAF = null;
            lastFrameTime = null;
            speedUp = 0;
            speedDown = 0;
            return;
        }

        // Apply net scroll (distance = speed × time × site multiplier)
        const netSpeed = speedDown - speedUp;
        const scrollDelta = netSpeed * dt * scrollSpeed;

        const target = getScrollTarget();
        if (target) {
            target.scrollTop += scrollDelta;
        } else {
            window.scrollBy(0, scrollDelta);
        }

        scrollRAF = requestAnimationFrame(scrollLoop);
    }

    function startScrolling(direction) {
        if (direction === 'up' && scrollingUp) return;
        if (direction === 'down' && scrollingDown) return;

        if (direction === 'up') scrollingUp = true;
        if (direction === 'down') scrollingDown = true;

        // Only start a new loop if one isn't already running
        if (!scrollRAF) {
            lastFrameTime = null;
            scrollRAF = requestAnimationFrame(scrollLoop);
        }
    }

    function stopScrolling(direction) {
        if (direction === 'up') scrollingUp = false;
        if (direction === 'down') scrollingDown = false;
    }

    /**
     * Performs a smooth, short jump scroll using eased animation.
     * Triggered by a quick tap (press duration < tapThreshold).
     * Uses easeOutCubic for a natural deceleration feel.
     */
    function jumpScroll(direction) {
        // Cancel any ongoing momentum scroll
        if (scrollRAF) {
            cancelAnimationFrame(scrollRAF);
            scrollRAF = null;
            lastFrameTime = null;
            speedUp = 0;
            speedDown = 0;
            scrollingUp = false;
            scrollingDown = false;
        }

        const distance = SCROLL_CONFIG.jumpDistance * scrollSpeed * (direction === 'up' ? -1 : 1);
        const duration = SCROLL_CONFIG.jumpDuration;
        let startTime = null;
        let scrolled = 0;

        function easeOutCubic(t) {
            return 1 - Math.pow(1 - t, 3);
        }

        function jumpFrame(timestamp) {
            if (startTime === null) startTime = timestamp;
            const elapsed = timestamp - startTime;
            const progress = Math.min(elapsed / duration, 1);
            const eased = easeOutCubic(progress);

            const targetScroll = distance * eased;
            const frameDelta = targetScroll - scrolled;
            scrolled = targetScroll;

            const target = getScrollTarget();
            if (target) {
                target.scrollTop += frameDelta;
            } else {
                window.scrollBy(0, frameDelta);
            }

            if (progress < 1) {
                requestAnimationFrame(jumpFrame);
            }
        }

        requestAnimationFrame(jumpFrame);
    }

    // ========================
    // FULLSCREEN
    // ========================

    function toggleFullscreen() {
        if (!isFullscreen) {
            const elem = document.documentElement;
            (elem.requestFullscreen || elem.webkitRequestFullscreen || elem.msRequestFullscreen || (() => { })).call(elem);
        } else {
            (document.exitFullscreen || document.webkitExitFullscreen || document.msExitFullscreen || (() => { })).call(document);
        }
        isFullscreen = !isFullscreen;
    }

    // ========================
    // EVENT HANDLERS
    // ========================

    $(document).on('keydown', function (m_event) {
        if (m_event.ctrlKey || m_event.altKey || isUserTyping()) return;

        const key = m_event.key;

        // Scroll up
        if (isKeyMatch(key, KEY_BINDINGS.scrollUp) && !scrollingUp) {
            m_event.preventDefault();
            keyDownTimeUp = performance.now();
            startScrolling('up');
            return;
        }

        // Scroll down
        if (isKeyMatch(key, KEY_BINDINGS.scrollDown) && !scrollingDown) {
            m_event.preventDefault();
            keyDownTimeDown = performance.now();
            startScrolling('down');
            return;
        }

        // Previous chapter
        if (isKeyMatch(key, KEY_BINDINGS.prevChapter)) {
            safeClick(btnPrev, 'Previous Chapter');
            return;
        }

        // Next chapter
        if (isKeyMatch(key, KEY_BINDINGS.nextChapter)) {
            safeClick(btnNext, 'Next Chapter');
            return;
        }

        // Toggle fullscreen
        if (isKeyMatch(key, KEY_BINDINGS.fullscreen)) {
            m_event.preventDefault();
            toggleFullscreen();
            return;
        }

        // All chapters / go back to series page
        if (isKeyMatch(key, KEY_BINDINGS.allChapters)) {
            safeClick(btnAllChapters, 'All Chapters');
            return;
        }
    });

    $(document).on('keyup', function (m_event) {
        if (m_event.ctrlKey || m_event.altKey || isUserTyping()) return;

        const key = m_event.key;

        if (isKeyMatch(key, KEY_BINDINGS.scrollUp)) {
            const held = keyDownTimeUp ? performance.now() - keyDownTimeUp : Infinity;
            keyDownTimeUp = null;
            if (held < SCROLL_CONFIG.tapThreshold) {
                jumpScroll('up');
            } else {
                stopScrolling('up');
            }
        }
        if (isKeyMatch(key, KEY_BINDINGS.scrollDown)) {
            const held = keyDownTimeDown ? performance.now() - keyDownTimeDown : Infinity;
            keyDownTimeDown = null;
            if (held < SCROLL_CONFIG.tapThreshold) {
                jumpScroll('down');
            } else {
                stopScrolling('down');
            }
        }
    });

})();