浏览器下雪效果(可折叠/开关/配置保存)

在浏览器页面添加雪花飘落动画效果,并提供一个可折叠、带总开关的UI来控制雪花的颜色、数量和大小,支持配置保存。

// ==UserScript==
// @name         浏览器下雪效果(可折叠/开关/配置保存)
// @namespace    http://tampermonkey.net/
// @version      0.7
// @description  在浏览器页面添加雪花飘落动画效果,并提供一个可折叠、带总开关的UI来控制雪花的颜色、数量和大小,支持配置保存。
// @author       shenmi
// @match        *://*/*
// @grant        GM_setValue
// @grant        GM_getValue
// @license      MIT
// ==/UserScript==

(async function () {
    'use strict';

    // --- 配置管理 ---
    const CONFIG_KEY = 'snowflake_config';
    const defaultConfig = {
        snowEnabled: true,
        color: '#ffffff',
        count: 100,
        size: 20,
        collapsed: false,
        positionX: 20,
        positionY: 20
    };

    let currentX = defaultConfig.positionX; // 初始化为默认配置位置
    let currentY = defaultConfig.positionY; // 初始化为默认配置位置

    let currentConfig = {};

    // --- 动态注入CSS动画 ---
    const style = document.createElement('style');
    style.textContent = `
        @keyframes fall {
            from { transform: translateY(-10vh); }
            to { transform: translateY(110vh); }
        }

        @keyframes sway {
            0%, 100% {
                transform: translateX(0);
            }
            50% {
                transform: translateX(40px); /* 减小了摇摆幅度,效果更柔和 */
            }
        }

        .fall-container {
            position: fixed;
            top: 0;
            left: 0;
            pointer-events: none;
            z-index: 9999;
            will-change: transform;
            animation-name: fall;
            animation-timing-function: linear;
            animation-iteration-count: infinite;
        }

        .snowflake {
            /* 使用字符替代div */
            color: #fff;
            font-family: "Arial", "sans-serif"; /* 确保字符能渲染 */
            will-change: transform, opacity;
            animation-name: sway;
            animation-timing-function: ease-in-out;
            animation-iteration-count: infinite;
        }

        /* --- 控制器样式 --- */
        #snowflake-controls-container {
            position: fixed;
            top: 20px;
            left: 20px;
            z-index: 10000;
            background-color: rgba(0, 0, 0, 0.65);
            border-radius: 8px;
            color: white;
            font-family: sans-serif;
            box-shadow: 0 2px 10px rgba(0,0,0,0.2);
            will-change: transform;
            overflow: hidden; /* 配合折叠动画 */
        }
        #controls-header {
            display: flex;
            justify-content: space-between;
            align-items: center;
            padding: 8px 12px;
            cursor: move;
            user-select: none; /* 防止拖动时选中文本 */
        }
        #controls-header h3 { margin: 0; font-size: 15px; font-weight: 600; }
        #toggle-button { background: none; border: none; color: white; font-size: 16px; cursor: pointer; padding: 0 4px; line-height: 1; }
        #controls-body {
            display: flex;
            flex-direction: column;
            gap: 8px;
            padding: 4px 12px 12px 12px;
            max-height: 300px; /* 为动画提供初始高度 */
            transition: max-height 0.3s ease-in-out, padding 0.3s ease-in-out;
        }
        #snowflake-controls-container.collapsed #controls-body { max-height: 0; padding-top: 0; padding-bottom: 0; }
        .control-row { display: flex; align-items: center; justify-content: space-between; }
        .control-row label { width: 60px; text-align: right; margin-right: 10px; white-space: nowrap; font-size: 14px; }
        .control-row input[type="color"] { cursor: pointer; width: 40px; height: 25px; border: none; background: none; padding: 0; }
        .control-row input[type="range"] { cursor: pointer; width: 80px; }
        .control-row .value-display { min-width: 35px; text-align: right; font-size: 14px; }

        /* --- 开关样式 --- */
        .toggle-switch { position: relative; display: inline-block; width: 40px; height: 22px; }
        .toggle-switch input { opacity: 0; width: 0; height: 0; }
        .slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #ccc; transition: .3s; border-radius: 22px; }
        .slider:before { position: absolute; content: ""; height: 16px; width: 16px; left: 3px; bottom: 3px; background-color: white; transition: .3s; border-radius: 50%; }
        input:checked + .slider { background-color: #4a6bff; }
        input:checked + .slider:before { transform: translateX(37px); }
    `;
    document.head.appendChild(style);

    // --- 创建控制器UI ---
    const controlsContainer = document.createElement('div');
    controlsContainer.id = 'snowflake-controls-container';
    controlsContainer.innerHTML = `
        <div id="controls-header">
            <h3>❄️ 雪花控制</h3>
            <button id="toggle-button">▲</button>
        </div>
        <div id="controls-body">
            <div class="control-row">
                <label for="snow-toggle-input">显示雪花:</label>
                <label class="toggle-switch">
                    <input type="checkbox" id="snow-toggle-input">
                    <span class="slider"></span>
                </label>
            </div>
            <div class="control-row">
                <label for="snowflake-color-input">颜色:</label>
                <input type="color" id="snowflake-color-input">
            </div>
            <div class="control-row">
                <label for="snowflake-count-input">数量:</label>
                <input type="range" id="snowflake-count-input" min="10" max="500">
                <span id="count-value" class="value-display"></span>
            </div>
            <div class="control-row">
                <label for="snowflake-size-input">大小:</label>
                <input type="range" id="snowflake-size-input" min="10" max="40">
                <span id="size-value" class="value-display"></span>
            </div>
        </div>
    `;
    document.body.appendChild(controlsContainer);

    // --- 获取UI元素 ---
    const controlsHeader = document.getElementById('controls-header');
    const toggleButton = document.getElementById('toggle-button');
    const snowToggleInput = document.getElementById('snow-toggle-input');
    const colorInput = document.getElementById('snowflake-color-input');
    const countInput = document.getElementById('snowflake-count-input');
    const countValue = document.getElementById('count-value');
    const sizeInput = document.getElementById('snowflake-size-input');
    const sizeValue = document.getElementById('size-value');
    const snowContainer = document.body;

    const saveConfig = () => {
        currentConfig = {
            snowEnabled: snowToggleInput.checked,
            color: colorInput.value,
            count: parseInt(countInput.value, 10),
            size: parseInt(sizeInput.value, 10),
            collapsed: controlsContainer.classList.contains('collapsed'),
            positionX: currentX,
            positionY: currentY
        };
        GM_setValue(CONFIG_KEY, currentConfig);
    };

    const loadConfig = async () => {
        currentConfig = await GM_getValue(CONFIG_KEY, defaultConfig);

        // 应用配置到UI
        snowToggleInput.checked = currentConfig.snowEnabled;
        colorInput.value = currentConfig.color;
        countInput.value = currentConfig.count;
        countValue.textContent = currentConfig.count;
        sizeInput.value = currentConfig.size;
        sizeValue.textContent = `${currentConfig.size}px`;

        if (currentConfig.collapsed) {
            controlsContainer.classList.add('collapsed');
            toggleButton.textContent = '▼';
        } else {
            controlsContainer.classList.remove('collapsed');
            toggleButton.textContent = '▲';
        }

        // 应用位置
        currentX = currentConfig.positionX;
        currentY = currentConfig.positionY;
        controlsContainer.style.transform = `translate(${currentX}px, ${currentY}px)`;

        // 初始应用雪花效果
        applySnowflakeSettings();
    };

    // --- 折叠/展开逻辑 ---
    toggleButton.addEventListener('click', (e) => {
        e.stopPropagation();
        controlsContainer.classList.toggle('collapsed');
        toggleButton.textContent = controlsContainer.classList.contains('collapsed') ? '▼' : '▲';
        saveConfig(); // 保存折叠状态
    });

    // --- 让控制器可拖动 (仅限头部) ---
    let isDragging = false;
    let startX, startY;
    let initialX, initialY;
    controlsHeader.addEventListener('mousedown', (e) => {
        if (e.target === toggleButton) return;
        e.preventDefault();
        isDragging = true;
        startX = e.clientX; startY = e.clientY;
        initialX = currentX; // 直接使用 currentX 作为初始位移
        initialY = currentY; // 直接使用 currentY 作为初始位移

        controlsHeader.style.cursor = 'grabbing';
        document.body.style.cursor = 'grabbing';
    });
    document.addEventListener('mousemove', (e) => {
        if (isDragging) {
            const deltaX = e.clientX - startX;
            const deltaY = e.clientY - startY;
            currentX = initialX + deltaX;
            currentY = initialY + deltaY;
            controlsContainer.style.transform = `translate(${currentX}px, ${currentY}px)`;
        }
    });
    document.addEventListener('mouseup', () => {
        if (isDragging) {
            isDragging = false;
            controlsHeader.style.cursor = 'move';
            document.body.style.cursor = 'default';
            saveConfig(); // 保存位置
        }
    });

    // --- 核心功能函数 ---
    const createSnowflake = () => {
        const faller = document.createElement('div');
        faller.className = 'fall-container';
        const snowflake = document.createElement('div');
        snowflake.className = 'snowflake';
        snowflake.innerHTML = '&#10052;';
        snowflake.dataset.sizeFactor = Math.random();
        faller.appendChild(snowflake);
        snowContainer.appendChild(faller);
        const maxFallDuration = 18, minFallDuration = 8;
        const maxSwayDuration = 7, minSwayDuration = 3;
        const fallDuration = Math.random() * (maxFallDuration - minFallDuration) + minFallDuration;
        const swayDuration = Math.random() * (maxSwayDuration - minSwayDuration) + minSwayDuration;
        faller.style.left = `${Math.random() * 100}vw`;
        faller.style.animationDuration = `${fallDuration}s`;
        faller.style.animationDelay = `${Math.random() * 5}s`;
        snowflake.style.animationDuration = `${swayDuration}s`;
        snowflake.style.animationDelay = `${Math.random() * 3}s`;
        updateSnowflakeSize(snowflake, sizeInput.value);
        updateSnowflakeColor(snowflake, colorInput.value);
        return faller;
    };
    const updateSnowflakeColor = (flake, color) => {
        const r = parseInt(color.slice(1, 3), 16), g = parseInt(color.slice(3, 5), 16), b = parseInt(color.slice(5, 7), 16);
        flake.style.color = color;
        flake.style.textShadow = `0 0 3px rgba(${r}, ${g}, ${b}, 0.9)`;
    };
    const updateSnowflakeSize = (flake, maxSize) => {
        const minSize = maxSize / 2;
        const sizeFactor = parseFloat(flake.dataset.sizeFactor);
        const size = sizeFactor * (maxSize - minSize) + minSize;
        flake.style.fontSize = `${size}px`;
        flake.style.opacity = sizeFactor * 0.7 + 0.3;
    };

    // --- 应用雪花设置 (初始化和配置加载后调用) ---
    const applySnowflakeSettings = () => {
        // 应用总开关状态
        const displayStyle = snowToggleInput.checked ? '' : 'none';
        document.querySelectorAll('.fall-container').forEach(flake => {
            flake.style.display = displayStyle;
        });

        // 应用数量
        const newCount = parseInt(countInput.value, 10);
        countValue.textContent = newCount;
        let currentFlakes = document.querySelectorAll('.fall-container');
        if (newCount > currentFlakes.length) {
            for (let i = 0; i < newCount - currentFlakes.length; i++) createSnowflake();
        } else {
            for (let i = 0; i < currentFlakes.length - newCount; i++) currentFlakes[i].remove();
        }

        // 应用颜色和大小 (会由各自的update函数处理)
        document.querySelectorAll('.snowflake').forEach(flake => {
            updateSnowflakeColor(flake, colorInput.value);
            updateSnowflakeSize(flake, sizeInput.value);
        });
    };

    // --- 事件监听 ---
    snowToggleInput.addEventListener('change', (e) => { applySnowflakeSettings(); saveConfig(); });
    colorInput.addEventListener('input', (e) => { document.querySelectorAll('.snowflake').forEach(flake => updateSnowflakeColor(flake, e.target.value)); saveConfig(); });
    countInput.addEventListener('input', (e) => { countValue.textContent = e.target.value; applySnowflakeSettings(); saveConfig(); });
    sizeInput.addEventListener('input', (e) => { sizeValue.textContent = `${e.target.value}px`; document.querySelectorAll('.snowflake').forEach(flake => updateSnowflakeSize(flake, e.target.value)); saveConfig(); });

    // --- 脚本初始化 ---
    await loadConfig();
})();