scroll-button (Draggable, Auto-Fade, Bright-on-Stop)
// ==UserScript==
// @name scroll-button
// @namespace https://github.com/livinginpurple
// @version 20251217.09
// @description scroll-button (Draggable, Auto-Fade, Bright-on-Stop)
// @license WTFPL
// @author livinginpurple
// @include *
// @run-at document-end
// @grant none
// ==/UserScript==
(() => {
'use strict';
const init = () => {
const btnId = 'gamma-scroll-btn';
if (document.getElementById(btnId)) return;
// SVG 圖示
const icons = {
up: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="white" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8 12a.5.5 0 0 0 .5-.5V5.707l2.146 2.147a.5.5 0 0 0 .708-.708l-3-3a.5.5 0 0 0-.708 0l-3 3a.5.5 0 1 0 .708.708L7.5 5.707V11.5a.5.5 0 0 0 .5.5z"/></svg>`,
down: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="white" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8 4a.5.5 0 0 1 .5.5v5.793l2.146-2.147a.5.5 0 0 1 .708.708l-3 3a.5.5 0 0 1-.708 0l-3-3a.5.5 0 1 1 .708-.708L7.5 10.293V4.5A.5.5 0 0 1 8 4z"/></svg>`
};
const btn = document.createElement('button');
btn.id = btnId;
btn.type = 'button';
btn.setAttribute('aria-label', 'Scroll navigation');
// 定義樣式
Object.assign(btn.style, {
position: 'fixed',
right: '20px',
bottom: '20px',
width: '40px',
height: '40px',
borderRadius: '50%',
backgroundColor: '#0d6efd',
border: 'none',
boxShadow: '0 0.2rem 0.5rem rgba(0,0,0,0.3)',
zIndex: '10000',
cursor: 'grab',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
transition: 'opacity 0.5s ease-in-out, transform 0.1s, background-color 0.2s',
touchAction: 'none',
padding: '0',
opacity: '0.3' // 預設 0.3
});
// 互動視覺效果 (Hover/Touch)
btn.onmouseenter = () => { btn.style.transform = 'scale(1.1)'; manualWakeUp(); };
btn.onmouseleave = () => { btn.style.transform = 'scale(1)'; };
btn.ontouchstart = () => { manualWakeUp(); };
document.body.appendChild(btn);
// --- 狀態管理 ---
const State = { TOP: 'top', SCROLLED: 'scrolled' };
// Timer 變數
let scrollStopTimer = null; // 用來偵測是否停止滑動
let fadeTimer = null; // 用來計算 3 秒後變暗
// 更新圖示 (不涉及透明度)
const updateIconState = () => {
const scrollTop = window.scrollY || document.documentElement.scrollTop;
const isAtTop = scrollTop < 50;
const nextState = isAtTop ? State.TOP : State.SCROLLED;
if (btn.dataset.state === nextState) return;
if (isAtTop) {
btn.innerHTML = icons.down;
btn.dataset.state = State.TOP;
} else {
btn.innerHTML = icons.up;
btn.dataset.state = State.SCROLLED;
}
};
// --- 核心邏輯:滑動停止偵測 ---
const handleScroll = () => {
// 1. 滑動中:確保是 0.3
// 為了避免頻繁操作 DOM,只在需要時設定
if (btn.style.opacity !== '0.3') {
btn.style.opacity = '0.3';
}
updateIconState();
// 2. 清除之前的計時器 (因為還在滑)
if (scrollStopTimer) clearTimeout(scrollStopTimer);
if (fadeTimer) clearTimeout(fadeTimer);
// 3. 設定新的偵測計時器
// 如果 150ms 內沒有新的 scroll 事件,就視為「停止滑動」
scrollStopTimer = setTimeout(() => {
// --- 滑動停止時執行 ---
btn.style.opacity = '1'; // 亮起
// 設定 3 秒後變暗
fadeTimer = setTimeout(() => {
btn.style.opacity = '0.3';
}, 3000);
}, 150);
};
// 手動操作 (點擊/拖曳) 的喚醒邏輯
const manualWakeUp = () => {
btn.style.opacity = '1';
if (scrollStopTimer) clearTimeout(scrollStopTimer);
if (fadeTimer) clearTimeout(fadeTimer);
// 操作後也是等 3 秒變暗
fadeTimer = setTimeout(() => {
btn.style.opacity = '0.3';
}, 3000);
};
// --- 拖曳邏輯 ---
let isPressed = false;
let isDragging = false;
let startX, startY, dragOffsetX, dragOffsetY;
const onDragStart = (e) => {
const clientX = e.type.includes('touch') ? e.touches[0].clientX : e.clientX;
const clientY = e.type.includes('touch') ? e.touches[0].clientY : e.clientY;
isPressed = true;
isDragging = false;
startX = clientX;
startY = clientY;
const rect = btn.getBoundingClientRect();
dragOffsetX = clientX - rect.left;
dragOffsetY = clientY - rect.top;
btn.style.backgroundColor = '#0b5ed7';
manualWakeUp(); // 拖曳開始,喚醒
};
const onDragMove = (e) => {
if (!isPressed) return;
const clientX = e.type.includes('touch') ? e.touches[0].clientX : e.clientX;
const clientY = e.type.includes('touch') ? e.touches[0].clientY : e.clientY;
const moveX = clientX - startX;
const moveY = clientY - startY;
const distance = Math.sqrt(moveX * moveX + moveY * moveY);
if (!isDragging && distance < 10) return;
if (!isDragging) {
isDragging = true;
btn.style.cursor = 'grabbing';
const rect = btn.getBoundingClientRect();
btn.style.left = rect.left + 'px';
btn.style.top = rect.top + 'px';
btn.style.right = 'auto';
btn.style.bottom = 'auto';
}
let newX = clientX - dragOffsetX;
let newY = clientY - dragOffsetY;
const maxX = window.innerWidth - 40;
const maxY = window.innerHeight - 40;
btn.style.left = Math.max(0, Math.min(newX, maxX)) + 'px';
btn.style.top = Math.max(0, Math.min(newY, maxY)) + 'px';
manualWakeUp(); // 拖曳中保持喚醒
};
const onDragEnd = () => {
isPressed = false;
btn.style.cursor = 'grab';
btn.style.backgroundColor = '#0d6efd';
};
btn.addEventListener('mousedown', onDragStart);
window.addEventListener('mousemove', onDragMove);
window.addEventListener('mouseup', onDragEnd);
btn.addEventListener('touchstart', onDragStart, { passive: false });
window.addEventListener('touchmove', onDragMove, { passive: false });
window.addEventListener('touchend', onDragEnd);
// --- 點擊邏輯 ---
btn.addEventListener('click', (e) => {
if (isDragging) {
e.preventDefault();
e.stopImmediatePropagation();
isDragging = false;
return;
}
e.preventDefault();
manualWakeUp();
if (btn.dataset.state === State.TOP) {
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
} else {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
});
// 綁定 Scroll 事件
window.addEventListener('scroll', handleScroll, { passive: true });
// 初始化
updateIconState();
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();