scroll-button (Draggable, Auto-Fade, Fluent 2 Design)
// ==UserScript==
// @name scroll-button
// @namespace https://github.com/livinginpurple
// @version 20260211.16
// @description scroll-button (Draggable, Auto-Fade, Fluent 2 Design)
// @license WTFPL
// @author livinginpurple
// @include *
// @exclude *://www.plurk.com/*
// @exclude *://www.google.com/maps/*
// @exclude *://www.google.com.tw/maps/*
// @exclude *://mail.google.com/*
// @grant GM_addStyle
// @run-at document-end
// ==/UserScript==
(() => {
'use strict';
const btnId = 'gamma-scroll-btn';
if (document.getElementById(btnId)) return;
// --- Helpers ---
const ns = 'http://www.w3.org/2000/svg';
const createSVG = (pathData, fillRule) => {
const svg = document.createElementNS(ns, 'svg');
svg.setAttribute('viewBox', '0 0 16 16');
const path = document.createElementNS(ns, 'path');
path.setAttribute('d', pathData);
if (fillRule) path.setAttribute('fill-rule', fillRule);
svg.appendChild(path);
return svg;
};
const getScrollDetails = (scroller) => {
const isWin = scroller === window || scroller === document || scroller === document.documentElement || scroller === document.body;
return {
top: isWin ? window.scrollY : scroller.scrollTop,
height: isWin ? document.documentElement.scrollHeight : scroller.scrollHeight,
target: isWin ? window : scroller
};
};
// --- Dynamic Style Sheet (for CSP-safe dynamic positioning) ---
const dynSheet = new CSSStyleSheet();
dynSheet.insertRule(`#${btnId} {}`, 0);
document.adoptedStyleSheets = [...document.adoptedStyleSheets, dynSheet];
const setDynStyle = (selector, css) => {
const idx = [...dynSheet.cssRules].findIndex(r => r.selectorText === selector);
if (idx >= 0) dynSheet.deleteRule(idx);
dynSheet.insertRule(`${selector} { ${css} }`, dynSheet.cssRules.length);
};
// --- Fluent 2 Styles (injected via GM_addStyle to bypass CSP) ---
GM_addStyle(`
#${btnId} {
position: fixed; right: 20px; bottom: 20px;
width: 40px; height: 40px;
border-radius: 8px;
border: 1px solid rgba(0, 0, 0, 0.0578);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.14), 0 0 2px rgba(0, 0, 0, 0.12);
z-index: 10000; cursor: grab;
display: flex; justify-content: center; align-items: center;
transition: opacity 0.25s cubic-bezier(0.1, 0.9, 0.2, 1),
transform 0.15s cubic-bezier(0.1, 0.9, 0.2, 1),
background-color 0.1s cubic-bezier(0.1, 0.9, 0.2, 1),
box-shadow 0.25s cubic-bezier(0.1, 0.9, 0.2, 1);
touch-action: none; padding: 0;
background-color: rgba(255, 255, 255, 0.7);
-webkit-backdrop-filter: saturate(150%) blur(20px);
backdrop-filter: saturate(150%) blur(20px);
color: #242424;
opacity: 0.3;
}
#${btnId} svg { width: 18px; height: 18px; fill: currentColor; }
#${btnId}:hover {
background-color: rgba(249, 249, 249, 0.85);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.14), 0 0 2px rgba(0, 0, 0, 0.12);
transform: translateY(-1px);
}
#${btnId}:active, #${btnId}.is-dragging {
background-color: rgba(249, 249, 249, 0.65);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.14), 0 0 1px rgba(0, 0, 0, 0.12);
transform: scale(0.96);
}
#${btnId}.opacity-full { opacity: 1 !important; }
#${btnId}.opacity-dim { opacity: 0.3 !important; }
#${btnId}.opacity-half { opacity: 0.5 !important; }
#${btnId}.pos-manual { right: auto; bottom: auto; }
@media (prefers-color-scheme: dark) {
#${btnId} {
background-color: rgba(44, 44, 44, 0.7);
border: 1px solid rgba(255, 255, 255, 0.0837);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.28), 0 0 2px rgba(0, 0, 0, 0.24);
color: #fff;
}
#${btnId}:hover {
background-color: rgba(50, 50, 50, 0.85);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.28), 0 0 2px rgba(0, 0, 0, 0.24);
}
#${btnId}:active, #${btnId}.is-dragging {
background-color: rgba(40, 40, 40, 0.65);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.28), 0 0 1px rgba(0, 0, 0, 0.24);
}
}
#gamma-trash-zone {
position: fixed; top: 50%; left: 50%;
transform: translate(-50%, -50%);
width: 56px; height: 56px;
border-radius: 12px;
background-color: rgba(0, 0, 0, 0.4);
-webkit-backdrop-filter: saturate(150%) blur(20px);
backdrop-filter: saturate(150%) blur(20px);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #fff;
display: flex; justify-content: center; align-items: center;
z-index: 9999; opacity: 0; pointer-events: none;
transition: opacity 0.25s cubic-bezier(0.1, 0.9, 0.2, 1),
transform 0.2s cubic-bezier(0.1, 0.9, 0.2, 1),
background-color 0.15s cubic-bezier(0.1, 0.9, 0.2, 1);
}
#gamma-trash-zone.visible { opacity: 1; }
#gamma-trash-zone.active {
transform: translate(-50%, -50%) scale(1.15);
background-color: rgba(196, 43, 28, 0.85);
border-color: rgba(255, 255, 255, 0.2);
box-shadow: 0 8px 16px rgba(196, 43, 28, 0.3);
}
#gamma-trash-zone svg { width: 22px; height: 22px; fill: currentColor; }
`);
const icons = {
up: createSVG('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', 'evenodd'),
down: createSVG('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', 'evenodd'),
trash: createSVG('M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z')
};
const btn = document.createElement('button');
btn.id = btnId;
btn.type = 'button';
btn.setAttribute('aria-label', 'Scroll navigation');
document.body.appendChild(btn);
const trashZone = document.createElement('div');
trashZone.id = 'gamma-trash-zone';
trashZone.appendChild(icons.trash.cloneNode(true));
document.body.appendChild(trashZone);
// --- State ---
let activeScroller = window;
let stopTimer, fadeTimer;
let isPressed = false, isDragging = false;
let startX, startY, offsetX, offsetY;
const setOpacity = (cls) => {
btn.classList.remove('opacity-full', 'opacity-dim', 'opacity-half');
btn.classList.add(cls);
};
const wakeUp = (permanent = false) => {
setOpacity('opacity-full');
clearTimeout(stopTimer);
clearTimeout(fadeTimer);
if (!permanent) fadeTimer = setTimeout(() => setOpacity('opacity-dim'), 3000);
};
const updateIcon = () => {
const { top } = getScrollDetails(activeScroller);
const state = top < 50 ? 'top' : 'scrolled';
if (btn.dataset.state === state) return;
btn.dataset.state = state;
btn.replaceChildren(icons[state === 'top' ? 'down' : 'up'].cloneNode(true));
};
// --- Handlers ---
const handleScroll = (e) => {
activeScroller = (e.target && e.target !== document) ? e.target : window;
setOpacity('opacity-dim');
updateIcon();
clearTimeout(stopTimer);
clearTimeout(fadeTimer);
stopTimer = setTimeout(wakeUp, 150);
};
const onMove = (e) => {
if (!isPressed) return;
const pointer = e.type.includes('touch') ? e.touches[0] : e;
const dist = Math.hypot(pointer.clientX - startX, pointer.clientY - startY);
if (!isDragging && dist > 10) {
isDragging = true;
const rect = btn.getBoundingClientRect();
btn.classList.add('pos-manual');
setDynStyle(`#${btnId}.pos-manual`, `left: ${rect.left}px; top: ${rect.top}px;`);
trashZone.classList.add('visible');
}
if (isDragging) {
const x = Math.max(0, Math.min(pointer.clientX - offsetX, window.innerWidth - 40));
const y = Math.max(0, Math.min(pointer.clientY - offsetY, window.innerHeight - 40));
setDynStyle(`#${btnId}.pos-manual`, `left: ${x}px; top: ${y}px;`);
const inTrash = Math.hypot(window.innerWidth / 2 - (x + 20), window.innerHeight / 2 - (y + 20)) < 80;
trashZone.classList.toggle('active', inTrash);
setOpacity(inTrash ? 'opacity-half' : 'opacity-full');
wakeUp(true);
}
};
const onEnd = () => {
if (trashZone.classList.contains('active')) {
window.removeEventListener('scroll', handleScroll, { capture: true });
btn.remove();
trashZone.remove();
} else {
isPressed = isDragging = false;
btn.classList.remove('is-dragging');
trashZone.classList.remove('visible', 'active');
wakeUp();
}
};
// --- Init ---
btn.addEventListener('mousedown', (e) => {
isPressed = true; startX = e.clientX; startY = e.clientY;
const rect = btn.getBoundingClientRect();
offsetX = e.clientX - rect.left; offsetY = e.clientY - rect.top;
btn.classList.add('is-dragging');
wakeUp();
});
btn.addEventListener('touchstart', (e) => {
isPressed = true; startX = e.touches[0].clientX; startY = e.touches[0].clientY;
const rect = btn.getBoundingClientRect();
offsetX = e.touches[0].clientX - rect.left; offsetY = e.touches[0].clientY - rect.top;
btn.classList.add('is-dragging');
wakeUp();
}, { passive: false });
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onEnd);
window.addEventListener('touchmove', onMove, { passive: false });
window.addEventListener('touchend', onEnd);
btn.addEventListener('click', (e) => {
if (isDragging) return (isDragging = false);
const { height, target } = getScrollDetails(activeScroller);
target.scrollTo({ top: btn.dataset.state === 'top' ? height : 0, behavior: 'smooth' });
wakeUp();
btn.blur();
});
btn.addEventListener('keydown', (e) => {
if (e.key === ' ' || e.code === 'Space') {
e.preventDefault();
}
});
btn.onmouseenter = () => wakeUp();
window.addEventListener('scroll', handleScroll, { capture: true, passive: true });
updateIcon();
})();