On mobile browsers, add a semi-transparent scrollbar and enable dragging
// ==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();
})();