scroll-button

scroll-button (Draggable without memory, Stability fix)

Versão de: 17/12/2025. Veja: a última versão.

Você precisará instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

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

Você precisará instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Você precisará instalar uma extensão como o Tampermonkey para instalar este script.

Você precisará instalar um gerenciador de scripts de usuário para instalar este script.

(Eu já tenho um gerenciador de scripts de usuário, me deixe instalá-lo!)

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

(Eu já possuo um gerenciador de estilos de usuário, me deixar fazer a instalação!)

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