Mobile Draggable Scrollbar

On mobile browsers, add a semi-transparent scrollbar and enable dragging

Terá de instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

Terá de instalar uma extensão como Tampermonkey para instalar este script.

Terá de instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Terá de instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Terá de instalar uma extensão como Tampermonkey para instalar este script.

Terá de instalar uma extensão de gestão de scripts de utilizador para instalar este script.

(Já tenho um gestor de scripts de utilizador, deixe-me instalá-lo!)

Terá de instalar uma extensão como Stylus para instalar este estilo.

Terá de instalar uma extensão como Stylus para instalar este estilo.

Terá de instalar uma extensão como Stylus para instalar este estilo.

Terá de instalar uma extensão de gestão de estilos de utilizador para instalar este estilo.

Terá de instalar uma extensão de gestão de estilos de utilizador para instalar este estilo.

Terá de instalar uma extensão de gestão de estilos de utilizador para instalar este estilo.

(Já tenho um gestor de estilos de utilizador, deixe-me instalá-lo!)

// ==UserScript==
// @name         Mobile Draggable Scrollbar
// @namespace    http://tampermonkey.net/
// @version      5.3
// @description  On mobile browsers, add a semi-transparent scrollbar and enable dragging
// @author       AI
// @license      MIT
// @match        *://*/*
// @grant        none
// ==/UserScript==

(function () {
    'use strict';

    // 스타일 적용
    const style = document.createElement("style");
    style.textContent = `
        .custom-scrollbar-container {
            position: fixed;
            width: 20px; /* 터치 영역 확대 (기존 14px -> 20px) */
            z-index: 9999;
            pointer-events: none;
            transition: top 0.15s ease-out, left 0.15s ease-out, height 0.15s ease-out;
        }

        .custom-scrollbar {
            position: absolute;
            right: 3px;
            top: 0;
            width: 10px; /* 기존 8px -> 10px, 정확한 터치를 위해 확대 */
            background: rgba(0, 0, 0, 0.4);
            border-radius: 5px;
            opacity: 0;
            transition: opacity 0.3s ease-in-out;
            touch-action: none; /* 터치 드래그 활성화 */
            pointer-events: auto;
            will-change: transform;
        }

        .custom-scrollbar.active {
            background: rgba(0, 0, 0, 0.6); /* 터치 중일 때 색상 강조 */
        }
    `;
    document.head.appendChild(style);

    // 스크롤바 컨테이너 생성 (터치 영역 확대용)
    const scrollbarContainer = document.createElement("div");
    scrollbarContainer.classList.add("custom-scrollbar-container");
    document.body.appendChild(scrollbarContainer);

    // 스크롤바 요소 생성
    const scrollbar = document.createElement("div");
    scrollbar.classList.add("custom-scrollbar");
    scrollbarContainer.appendChild(scrollbar);

    const MIN_SCROLLBAR_HEIGHT = 44; // 터치하기 편하도록 최소 높이 확대 (iOS 권장 터치 타겟 기준)
    const REVERT_DELAY = 1500;       // 중첩 영역 스크롤이 멈춘 뒤 window 기준으로 복귀하기까지의 시간(ms)
    const HIDE_DELAY = 800;

    let hideTimeoutId = null;
    let revertTimeoutId = null;
    let rafId = null;
    let pendingUpdate = false;

    let isDragging = false;
    let isScrollbarScrolling = false;
    let startY = 0;
    let startScrollPos = 0;
    let currentTarget = window; // 현재 스크롤바가 추적 중인 대상. window 또는 중첩 overflow 요소

    // 대상 요소의 스크롤 정보를 통일된 형태로 반환
    function getScrollInfo(target) {
        if (target === window) {
            return {
                scrollHeight: document.documentElement.scrollHeight,
                clientHeight: window.innerHeight,
                scrollTop: document.documentElement.scrollTop || document.body.scrollTop,
                rectTop: 0,
                rectRight: window.innerWidth
            };
        }
        const rect = target.getBoundingClientRect();
        return {
            scrollHeight: target.scrollHeight,
            clientHeight: rect.height,
            scrollTop: target.scrollTop,
            rectTop: rect.top,
            rectRight: rect.right
        };
    }

    function setScrollPos(target, pos) {
        if (target === window) {
            document.documentElement.scrollTop = pos;
            document.body.scrollTop = pos;
        } else {
            target.scrollTop = pos;
        }
    }

    // 실제로 화면에 그려지는 스크롤바 갱신 (위치, 크기, 핸들 이동)
    function updateScrollbar() {
        const { scrollHeight, clientHeight, scrollTop, rectTop, rectRight } = getScrollInfo(currentTarget);

        // 컨테이너 위치/높이를 현재 대상 요소에 맞춤 (드래그 중이 아닐 때만 부드럽게 전환됨 - CSS transition)
        scrollbarContainer.style.top = rectTop + "px";
        scrollbarContainer.style.left = (rectRight - 20) + "px";
        scrollbarContainer.style.height = clientHeight + "px";

        if (scrollHeight <= clientHeight) {
            scrollbar.style.opacity = "0"; // 스크롤이 필요 없는 경우 숨김
            return;
        }

        const scrollbarHeight = Math.max((clientHeight / scrollHeight) * clientHeight, MIN_SCROLLBAR_HEIGHT);
        const maxScrollTop = scrollHeight - clientHeight;
        const maxScrollbarTop = clientHeight - scrollbarHeight;
        const scrollbarTop = maxScrollTop > 0 ? (scrollTop / maxScrollTop) * maxScrollbarTop : 0;

        scrollbar.style.height = `${scrollbarHeight}px`;
        scrollbar.style.transform = `translateY(${scrollbarTop}px)`; // GPU 가속, 리플로우 회피
        scrollbar.style.opacity = "1";

        clearTimeout(hideTimeoutId);
        hideTimeoutId = setTimeout(() => {
            if (!isDragging) {
                scrollbar.style.opacity = "0";
            }
        }, HIDE_DELAY);
    }

    // requestAnimationFrame으로 묶어서 과도한 갱신으로 인한 떨림/버벅임 방지
    function scheduleUpdate() {
        if (pendingUpdate) return;
        pendingUpdate = true;
        rafId = requestAnimationFrame(() => {
            pendingUpdate = false;
            updateScrollbar();
        });
    }

    function startDrag(event) {
        isDragging = true;
        clearTimeout(revertTimeoutId);
        scrollbar.classList.add("active");
        scrollbarContainer.style.transition = "none"; // 드래그 중엔 전환 애니메이션 끔 (지연 없이 반응)

        startY = event.touches ? event.touches[0].clientY : event.clientY;
        startScrollPos = getScrollInfo(currentTarget).scrollTop;

        event.preventDefault();
    }

    function onDrag(event) {
        if (!isDragging) return;

        const currentY = event.touches ? event.touches[0].clientY : event.clientY;
        const deltaY = currentY - startY;

        const { scrollHeight, clientHeight } = getScrollInfo(currentTarget);
        const maxScrollTop = scrollHeight - clientHeight;

        const scrollbarHeight = Math.max((clientHeight / scrollHeight) * clientHeight, MIN_SCROLLBAR_HEIGHT);
        const maxScrollbarTop = clientHeight - scrollbarHeight;
        const scrollRatio = maxScrollbarTop > 0 ? maxScrollTop / maxScrollbarTop : 0;

        // 스크롤바 드래그도 scroll 이벤트로 처리
        isScrollbarScrolling = true;
        
        setScrollPos(currentTarget, startScrollPos + deltaY * scrollRatio);

        requestAnimationFrame(() => {
            isScrollbarScrolling = false;
        });
        
        updateScrollbar(); // 드래그 중엔 즉시 반영 (지연 없이 손가락과 동기화)
        event.preventDefault();
    }

    function endDrag() {
        isDragging = false;
        
        if (currentTarget !== window) {
            resetRevertTimer(currentTarget);
        }
        
        scrollbar.classList.remove("active");
        scrollbarContainer.style.transition = "top 0.15s ease-out, left 0.15s ease-out, height 0.15s ease-out";
        hideTimeoutId = setTimeout(() => {
            scrollbar.style.opacity = "0";
        }, HIDE_DELAY);
    }
    
    function resetRevertTimer(target) {
        clearTimeout(revertTimeoutId);
        
        if (target === window || isDragging) return;

        // 일정 시간 이 영역에서 스크롤이 없으면 window 기준으로 자동 복귀
        revertTimeoutId = setTimeout(() => {
            if (currentTarget === target && !isDragging) {
                currentTarget = window;
                scheduleUpdate();
            }
        }, REVERT_DELAY);
    }

    // 중첩 영역(모달/채팅창)에서 실제 스크롤이 발생했을 때만 타겟 전환
    // -> 페이지의 무관한 탭/클릭으로는 더 이상 위치가 튀지 않음
    function onAnyScroll(e) {
        if (isDragging || isScrollbarScrolling) return; // 드래그 중엔 사용자가 직접 컨트롤하므로 개입하지 않음

        const target = (e.target === document || e.target === window) ? window : e.target;

        if (target === window) {
            clearTimeout(revertTimeoutId);
            currentTarget = window;
        } else {
            currentTarget = target;
            resetRevertTimer(target);
        }

        scheduleUpdate();
    }

    scrollbarContainer.addEventListener("touchstart", startDrag, { passive: false });
    window.addEventListener("touchmove", onDrag, { passive: false });
    window.addEventListener("touchend", endDrag);

    scrollbarContainer.addEventListener("mousedown", startDrag);
    window.addEventListener("mousemove", onDrag);
    window.addEventListener("mouseup", endDrag);

    // capture 단계 등록 -> 중첩된 요소(모달, 채팅창 등) 내부의 실제 스크롤도 감지 가능
    window.addEventListener("scroll", onAnyScroll, true);

    window.addEventListener("resize", () => {
        if (currentTarget === window) scheduleUpdate();
    });

    updateScrollbar();
})();