scroll-button (Draggable, Customizable Size/Opacity)
Per
// ==UserScript==
// @name scroll-button
// @namespace https://github.com/livinginpurple
// @version 20251217.04
// @description scroll-button (Draggable, Customizable Size/Opacity)
// @license WTFPL
// @author livinginpurple
// @include *
// @run-at document-end
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_registerMenuCommand
// ==/UserScript==
(() => {
'use strict';
// ==========================================
// 1. 設定與狀態管理 (Config & State)
// ==========================================
const CONFIG_KEY = 'gamma_scroll_config';
// 預設設定
const defaultConfig = {
size: 40, // px
idleOpacity: 0.3, // 0.0 ~ 1.0
posX: null, // left (px) - null 代表預設右下
posY: null // top (px)
};
// 讀取設定
let config = { ...defaultConfig, ...GM_getValue(CONFIG_KEY, defaultConfig) };
const saveConfig = () => {
GM_setValue(CONFIG_KEY, config);
};
// ==========================================
// 2. UI 建構 (UI Construction)
// ==========================================
const init = () => {
const btnId = 'gamma-scroll-btn';
if (document.getElementById(btnId)) return;
// SVG 圖示
const icons = {
up: `<svg viewBox="0 0 16 16" width="100%" height="100%" fill="white"><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 viewBox="0 0 16 16" width="100%" height="100%" fill="white"><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';
// 基礎樣式
const initialSize = `${config.size}px`;
const initialLeft = config.posX !== null ? `${config.posX}px` : 'auto';
const initialTop = config.posY !== null ? `${config.posY}px` : 'auto';
const initialRight = config.posX !== null ? 'auto' : '20px'; // 預設靠右
const initialBottom = config.posY !== null ? 'auto' : '20px'; // 預設靠底
Object.assign(btn.style, {
position: 'fixed',
left: initialLeft,
top: initialTop,
right: initialRight,
bottom: initialBottom,
width: initialSize,
height: initialSize,
borderRadius: '50%',
backgroundColor: '#0d6efd',
border: 'none',
boxShadow: '0 2px 5px rgba(0,0,0,0.4)',
zIndex: '2147483647', // Max Z-Index
cursor: 'grab', // 提示可拖曳
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
padding: '15%', // SVG 內距
transition: 'opacity 0.3s ease-in-out, background-color 0.2s, transform 0.1s', // 移除 width/height/top/left transition 以避免拖曳延遲
touchAction: 'none', // 關鍵:禁止瀏覽器預設手勢,完全交由 JS 控制拖曳
opacity: config.idleOpacity.toString()
});
document.body.appendChild(btn);
// ==========================================
// 3. 邏輯處理 (Logic Handlers)
// ==========================================
// --- 狀態更新 ---
const State = { TOP: 'top', SCROLLED: 'scrolled' };
const updateIcon = () => {
const scrollTop = window.scrollY || document.documentElement.scrollTop;
const isAtTop = scrollTop < 50;
const nextState = isAtTop ? State.TOP : State.SCROLLED;
if (btn.dataset.state === nextState) return;
btn.innerHTML = isAtTop ? icons.down : icons.up;
btn.dataset.state = nextState;
};
// --- 透明度控制 ---
let idleTimer = null;
const wakeUp = () => {
btn.style.opacity = '1';
if (idleTimer) clearTimeout(idleTimer);
idleTimer = setTimeout(() => {
btn.style.opacity = config.idleOpacity.toString();
}, 2000);
};
// --- 拖曳邏輯 (Drag Logic) ---
let isDragging = false;
let hasMoved = false; // 用來區分點擊還是拖曳
let startX, startY, initialBtnX, initialBtnY;
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;
isDragging = true;
hasMoved = false;
startX = clientX;
startY = clientY;
const rect = btn.getBoundingClientRect();
initialBtnX = rect.left;
initialBtnY = rect.top;
btn.style.cursor = 'grabbing';
btn.style.transition = 'none'; // 拖曳時移除過渡,避免遲滯感
wakeUp();
};
const onDragMove = (e) => {
if (!isDragging) 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 deltaX = clientX - startX;
const deltaY = clientY - startY;
// 如果移動超過 5px,則視為拖曳,而不是點擊
if (Math.abs(deltaX) > 5 || Math.abs(deltaY) > 5) {
hasMoved = true;
}
// 計算新位置
let newLeft = initialBtnX + deltaX;
let newTop = initialBtnY + deltaY;
// 邊界檢查 (防止拖出螢幕)
const winWidth = window.innerWidth;
const winHeight = window.innerHeight;
const btnSize = config.size;
newLeft = Math.max(0, Math.min(newLeft, winWidth - btnSize));
newTop = Math.max(0, Math.min(newTop, winHeight - btnSize));
btn.style.left = `${newLeft}px`;
btn.style.top = `${newTop}px`;
btn.style.right = 'auto'; // 清除這兩個屬性以改用 left/top 定位
btn.style.bottom = 'auto';
};
const onDragEnd = () => {
if (!isDragging) return;
isDragging = false;
btn.style.cursor = 'grab';
btn.style.transition = 'opacity 0.3s ease-in-out, background-color 0.2s'; // 恢復過渡
// 儲存位置
if (hasMoved) {
config.posX = parseFloat(btn.style.left);
config.posY = parseFloat(btn.style.top);
saveConfig();
}
wakeUp();
};
// --- 事件綁定 ---
// Mouse
btn.addEventListener('mousedown', onDragStart);
window.addEventListener('mousemove', onDragMove);
window.addEventListener('mouseup', onDragEnd);
// Touch (Mobile)
btn.addEventListener('touchstart', onDragStart, { passive: false });
window.addEventListener('touchmove', onDragMove, { passive: false });
window.addEventListener('touchend', onDragEnd);
// Click (Scroll Action)
btn.addEventListener('click', (e) => {
// 如果剛剛發生過拖曳移動,則不執行捲動
if (hasMoved) {
e.preventDefault();
e.stopPropagation();
return;
}
e.preventDefault();
wakeUp();
if (btn.dataset.state === State.TOP) {
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
} else {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
});
// 滾動監聽
window.addEventListener('scroll', () => {
updateIcon();
wakeUp();
}, { passive: true });
updateIcon();
wakeUp();
// ==========================================
// 4. 選單註冊 (Menu Registration)
// ==========================================
// 更新按鈕樣式的 helper
const refreshStyle = () => {
btn.style.width = `${config.size}px`;
btn.style.height = `${config.size}px`;
wakeUp(); // 會讀取新的 config.idleOpacity
saveConfig();
};
// 1. 調整大小 (循環切換)
GM_registerMenuCommand(`📏 切換大小 (目前: ${config.size}px)`, () => {
const sizes = [30, 40, 50, 60];
const currentIndex = sizes.indexOf(config.size);
const nextIndex = (currentIndex + 1) % sizes.length;
config.size = sizes[nextIndex];
refreshStyle();
location.reload(); // 簡單重整以更新選單文字 (Tampermonkey 限制)
});
// 2. 調整透明度 (循環切換)
GM_registerMenuCommand(`👻 切換閒置透明度 (目前: ${config.idleOpacity})`, () => {
// JS 浮點數處理小技巧: 字串化處理
const opacities = [0.1, 0.3, 0.5, 0.8];
const current = config.idleOpacity;
let nextIndex = 0;
// 尋找最接近的 index
for(let i=0; i<opacities.length; i++) {
if (opacities[i] > current) {
nextIndex = i;
break;
}
}
if (current >= 0.8) nextIndex = 0;
config.idleOpacity = opacities[nextIndex];
refreshStyle();
location.reload();
});
// 3. 重置位置
GM_registerMenuCommand(`🔄 重置按鈕位置`, () => {
config.posX = null;
config.posY = null;
btn.style.left = 'auto';
btn.style.top = 'auto';
btn.style.right = '20px';
btn.style.bottom = '20px';
saveConfig();
});
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();