scroll-button

scroll-button (Draggable without memory, Stability fix)

La data de 17-12-2025. Vezi ultima versiune.

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.07
// @description  scroll-button (Draggable without memory, Stability fix)
// @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',
            // 不對 left/top/right/bottom 設 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);

        // --- 狀態與透明度控制 ---
        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;
            }
        };

        // --- 拖曳邏輯優化 (Drag Logic) ---
        let isPressed = false;    // 是否按住
        let isDragging = false;   // 是否真的開始拖曳 (超過閾值)
        let startX, startY;       // 點擊起始點
        let initialRect;          // 按鈕原始位置
        let 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;

            // 此時不改變 cursor,也不改變 position,直到移動超過閾值
            btn.style.backgroundColor = '#0b5ed7';
            activeHandler();
        };

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

            // 閾值檢查:如果還沒進入拖曳模式,且移動小於 10px,則視為手震/點擊,不動作
            if (!isDragging && distance < 10) {
                return; 
            }

            // --- 正式進入拖曳模式 ---
            if (!isDragging) {
                isDragging = true;
                btn.style.cursor = 'grabbing';
                // 鎖定當前視覺位置,轉為 left/top 定位,避免跳動
                const rect = btn.getBoundingClientRect();
                btn.style.right = 'auto';
                btn.style.bottom = 'auto';
                // 這裡是一個關鍵技巧:直接將 left/top 設為當前位置,確保無縫接軌
                btn.style.left = rect.left + 'px';
                btn.style.top = rect.top + 'px';
            }

            // 計算新位置:滑鼠位置 - 原始偏移量
            let newX = clientX - dragOffsetX;
            let newY = clientY - dragOffsetY;

            // 邊界限制
            const maxX = window.innerWidth - 40;
            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 = () => {
            isPressed = false;
            btn.style.cursor = 'grab';
            btn.style.backgroundColor = '#0d6efd';
            
            // 注意:這裡不重置 isDragging,因為 click 事件會在 mouseup/touchend 之後觸發
            // 我們需要在 click 事件中判斷 isDragging 的值
            
            activeHandler();
        };

        // 綁定事件
        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();
            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();
    }
})();