scroll-button

scroll-button (Draggable & Auto-Fade)

As of 17.12.2025. See апошняя версія.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         scroll-button
// @namespace    https://github.com/livinginpurple
// @version      20251217.06
// @description  scroll-button (Draggable & Auto-Fade)
// @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;

        // --- 1. 設定與記憶 ---
        const STORAGE_KEY = 'gamma-scroll-pos';
        let savedPos = null;
        try {
            savedPos = JSON.parse(localStorage.getItem(STORAGE_KEY));
        } catch (e) {}

        // 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');

        // --- 2. 樣式定義 ---
        // 判斷初始位置:如果有存檔用存檔的位置,否則預設右下角
        const initialStyles = savedPos 
            ? { left: savedPos.x + 'px', top: savedPos.y + 'px' } 
            : { right: '20px', bottom: '20px' };

        Object.assign(btn.style, {
            position: 'fixed',
            ...initialStyles,
            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',
            // 注意:不對 left/top 設 transition,避免拖曳時遲滯
            transition: 'opacity 0.5s ease-in-out, transform 0.1s, background-color 0.2s',
            touchAction: 'none', // 關鍵:防止拖曳時觸發網頁捲動
            padding: '0',
            opacity: '0.3'
        });

        // 互動視覺效果
        btn.onmouseenter = () => { btn.style.transform = 'scale(1.1)'; };
        btn.onmouseleave = () => { btn.style.transform = 'scale(1)'; };
        
        document.body.appendChild(btn);

        // --- 3. 狀態與透明度控制 ---
        const State = { TOP: 'top', SCROLLED: 'scrolled' };
        let idleTimer = null;

        const activeHandler = () => {
            btn.style.opacity = '1';
            if (idleTimer) clearTimeout(idleTimer);
            idleTimer = setTimeout(() => {
                btn.style.opacity = '0.3';
            }, 2000);
        };

        const updateButtonState = () => {
            activeHandler();
            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;
            }
        };

        // --- 4. 拖曳邏輯核心 (Drag Core) ---
        let isDragging = false;
        let hasMoved = false;
        let startX, startY, initialRect;

        // 處理開始 (Mouse & Touch)
        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;
            initialRect = btn.getBoundingClientRect(); // 取得當前絕對位置

            // 確保切換為絕對定位模式 (避免從 right/bottom 切換時跳動)
            btn.style.right = 'auto';
            btn.style.bottom = 'auto';
            btn.style.left = initialRect.left + 'px';
            btn.style.top = initialRect.top + 'px';
            
            btn.style.cursor = 'grabbing';
            btn.style.backgroundColor = '#0b5ed7'; // 按下變深色
            activeHandler();
        };

        // 處理移動
        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 newX = initialRect.left + deltaX;
            let newY = initialRect.top + deltaY;
            
            const maxX = window.innerWidth - 40; // 40 is btn width
            const maxY = window.innerHeight - 40;

            newX = Math.max(0, Math.min(newX, maxX));
            newY = Math.max(0, Math.min(newY, maxY));

            btn.style.left = newX + 'px';
            btn.style.top = newY + 'px';
        };

        // 處理結束
        const onDragEnd = () => {
            if (!isDragging) return;
            isDragging = false;
            btn.style.cursor = 'grab';
            btn.style.backgroundColor = '#0d6efd';

            if (hasMoved) {
                // 儲存位置
                localStorage.setItem(STORAGE_KEY, JSON.stringify({
                    x: parseFloat(btn.style.left),
                    y: parseFloat(btn.style.top)
                }));
            }
            activeHandler();
        };

        // 綁定事件 (支援 PC 與 Mobile)
        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);

        // --- 5. 點擊捲動邏輯 ---
        btn.addEventListener('click', (e) => {
            // 如果剛才發生了拖曳位移,則不執行捲動
            if (hasMoved) {
                e.preventDefault();
                e.stopImmediatePropagation();
                return;
            }
            
            e.preventDefault();
            activeHandler();
            
            if (btn.dataset.state === State.TOP) {
                window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
            } else {
                window.scrollTo({ top: 0, behavior: 'smooth' });
            }
        });

        window.addEventListener('scroll', updateButtonState, { passive: true });
        
        // 初始化狀態
        updateButtonState();
    };

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }
})();