scroll-button

scroll-button (Draggable without memory, Stability fix)

От 17.12.2025. Виж последната версия.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey, Greasemonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да инсталирате разширение, като например Tampermonkey .

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Userscripts.

За да инсталирате скрипта, трябва да инсталирате разширение като Tampermonkey.

За да инсталирате този скрипт, трябва да имате инсталиран скриптов мениджър.

(Вече имам скриптов мениджър, искам да го инсталирам!)

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

(Вече имам инсталиран мениджър на стиловете, искам да го инсталирам!)

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