scroll-button

scroll-button (Draggable, Customizable Size/Opacity)

Per 17-12-2025. Zie de nieuwste versie.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey, Greasemonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Userscripts.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een gebruikersscriptbeheerder nodig.

(Ik heb al een user script manager, laat me het downloaden!)

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

(Ik heb al een beheerder - laat me doorgaan met de installatie!)

// ==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();
    }
})();