scroll-button

scroll-button (Draggable & Auto-Fade)

2025/12/17のページです。最新版はこちら

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==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();
    }
})();