Greasy Fork

Greasy Fork is available in English.

[VA]奶牛便签

带详细注释的便签系统,支持全局布局同步和独立内容存储,修复移动端拖动问题

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name        [VA]奶牛便签
// @namespace   http://tampermonkey.net/
// @version     7.4
// @description 带详细注释的便签系统,支持全局布局同步和独立内容存储,修复移动端拖动问题
// @match       https://www.milkywayidle.com/game*
// @author      VerdantAether
// @grant       GM_setValue
// @grant       GM_getValue
// @grant       GM_addStyle
// ==/UserScript==

/* 核心功能说明:
   - 全局布局同步:所有页面共享按钮位置、文本框尺寸和可见性状态
   - 独立内容存储:每个角色(根据characterId)拥有独立的文本内容
   - 操作方式:
     1. 拖动按钮移动整个便签系统
     2. 拖动文本框右下角调整大小
     3. 单击按钮切换显示/隐藏文本框
     4. 所有修改自动保存
   - 移动端支持:添加触摸事件处理
*/

(function() {
    'use strict';

    // =====================
    // 配置参数
    // =====================
    const config = {
        buttonSize: 36, // 按钮直径(像素)
        textBoxOffset: 3, // 文本框相对按钮的垂直偏移量
        minSize: { // 文本框最小尺寸限制
            width: 300,
            height: 36
        },
        defaultPosition: { // 默认初始位置
            x: 20,
            y: 20
        },
        dragThreshold: 3, // 拖动判定的最小移动距离(像素)
        globalLayoutKey: 'Global_Note_Layout', // 全局布局存储键
        initializationDelay: 3000 // 页面加载后的初始化延迟(毫秒)
    };

    // =====================
    // 样式注入
    // =====================
    GM_addStyle(`
        /* 按钮样式 */
        .sticky-button {
            position: fixed;
            width: ${config.buttonSize}px;
            height: ${config.buttonSize}px;
            border-radius: 50%;
            background: #4CAF50;
            cursor: move;
            box-shadow: 0 2px 8px rgba(0,0,0,0.3);
            z-index: 99999;
            display: flex;
            align-items: center;
            justify-content: center;
            color: white;
            font-size: 20px;
            user-select: none;       /* 防止文字被选中 */
            transition: transform 0.2s;
            touch-action: none;     /* 防止触摸默认行为 */
        }
        /* 按钮悬停效果 */
        .sticky-button:hover {
            transform: scale(1.1);
        }

        /* 文本框样式 */
        .sticky-textbox {
            position: fixed;
            z-index: 99998;         /* 略低于按钮 */
            background: rgba(48,59,110,0.9);
            border-radius: 8px;
            box-shadow: 0 4px 12px rgba(48,59,110,0.5);
            padding: 6px;
            font-size: 14px;
            resize: both;           /* 允许双向调整大小 */
            overflow: auto;         /* 内容过多时显示滚动条 */
            min-width: ${config.minSize.width}px;
            min-height: ${config.minSize.height}px;
            backdrop-filter: blur(2px); /* 背景模糊效果 */
        }
    `);

    // =====================
    // 工具函数
    // =====================

    /**
     * 生成内容存储键(基于URL中的characterId参数)
     * @returns {string} 当前角色的唯一存储键
     */
    function getContentKey() {
        const urlParams = new URLSearchParams(window.location.search);
        return `MW_Note_${urlParams.get('characterId')}`;
    }

    // =====================
    // 核心功能
    // =====================

    /**
     * 创建并初始化整个便签系统
     */
    function createStickySystem() {
        // 从存储加载数据 -------------------------------------------------
        const layoutData = GM_getValue(config.globalLayoutKey, {
            x: config.defaultPosition.x,
            y: config.defaultPosition.y,
            textWidth: config.minSize.width,
            textHeight: config.minSize.height,
            visible: true
        });

        // 独立内容加载(当前角色)
        const contentData = GM_getValue(getContentKey(), '');

        // 创建界面元素 ---------------------------------------------------
        const button = createButton(layoutData);
        const textBox = createTextBox(layoutData, contentData);

        // 状态管理变量
        let isDragging = false; // 当前是否正在拖动
        let dragStartTime = 0; // 拖动开始时间戳
        let startX = 0; // 拖动开始X坐标
        let startY = 0; // 拖动开始Y坐标
        let initialX = 0; // 初始X位置
        let initialY = 0; // 初始Y位置

        // =====================
        // 事件绑定
        // =====================

        /**
         * 处理拖动开始事件(支持鼠标和触摸)
         */
        function handleDragStart(e) {
            // 阻止默认行为防止页面滚动
            if (e.cancelable) e.preventDefault();
            
            // 获取正确的坐标(支持触摸事件)
            const clientX = e.clientX || (e.touches && e.touches[0].clientX);
            const clientY = e.clientY || (e.touches && e.touches[0].clientY);
            
            dragStartTime = Date.now();
            startX = clientX;
            startY = clientY;
            initialX = parseFloat(button.style.left);
            initialY = parseFloat(button.style.top);

            // 添加事件监听器
            document.addEventListener(isTouchEvent(e) ? 'touchmove' : 'mousemove', handleDragMove);
            document.addEventListener(isTouchEvent(e) ? 'touchend' : 'mouseup', handleDragEnd);
        }

        /**
         * 判断是否是触摸事件
         */
        function isTouchEvent(e) {
            return e.touches !== undefined;
        }

        /**
         * 按钮拖动事件 - 开始拖动/点击判断
         */
        button.addEventListener('mousedown', handleDragStart);
        button.addEventListener('touchstart', handleDragStart);

        /**
         * 拖动移动处理
         */
        function handleDragMove(e) {
            // 阻止默认行为防止页面滚动
            if (e.cancelable) e.preventDefault();
            
            // 获取正确的坐标(支持触摸事件)
            const clientX = e.clientX || (e.touches && e.touches[0].clientX);
            const clientY = e.clientY || (e.touches && e.touches[0].clientY);
            
            const dx = Math.abs(clientX - startX);
            const dy = Math.abs(clientY - startY);

            // 超过阈值开始拖动
            if (dx > config.dragThreshold || dy > config.dragThreshold) {
                isDragging = true;
                updateButtonPosition(
                    button,
                    initialX + clientX - startX,
                    initialY + clientY - startY
                );
                updateTextBoxPosition(button, textBox);
            }
        }

        /**
         * 拖动结束处理
         */
        function handleDragEnd(e) {
            // 阻止默认行为防止页面滚动
            if (e.cancelable) e.preventDefault();
            
            // 移除事件监听器
            document.removeEventListener(isTouchEvent(e) ? 'touchmove' : 'mousemove', handleDragMove);
            document.removeEventListener(isTouchEvent(e) ? 'touchend' : 'mouseup', handleDragEnd);

            if (isDragging) {
                // 拖动操作:保存布局
                saveGlobalLayout(button, textBox);
            } else if (Date.now() - dragStartTime < 200) {
                // 短时间点击:切换显示状态
                toggleTextBoxVisibility(textBox);
                saveGlobalLayout(button, textBox);
            }

            isDragging = false;
        }

        /**
         * 文本框大小调整事件
         */
        textBox.addEventListener('mousedown', e => {
            // 检查是否点击了调整手柄区域
            if (isResizeHandle(e, textBox)) {
                startResize(e);
            }
        });

        // 添加触摸事件支持
        textBox.addEventListener('touchstart', e => {
            // 检查是否点击了调整手柄区域
            if (isResizeHandle(e, textBox)) {
                startResize(e.touches[0]);
            }
        });

        function startResize(e) {
            // 阻止默认行为防止页面滚动
            if (e.cancelable) e.preventDefault();
            
            const startX = e.clientX || e.touches[0].clientX;
            const startY = e.clientY || e.touches[0].clientY;
            const startWidth = textBox.offsetWidth;
            const startHeight = textBox.offsetHeight;

            function resizeHandler(e) {
                // 阻止默认行为防止页面滚动
                if (e.cancelable) e.preventDefault();
                
                const clientX = e.clientX || (e.touches && e.touches[0].clientX);
                const clientY = e.clientY || (e.touches && e.touches[0].clientY);
                
                // 计算新尺寸(带最小值限制)
                const newWidth = Math.max(
                    config.minSize.width,
                    startWidth + (clientX - startX)
                );
                const newHeight = Math.max(
                    config.minSize.height,
                    startHeight + (clientY - startY)
                );
                textBox.style.width = `${newWidth}px`;
                textBox.style.height = `${newHeight}px`;
            }

            function stopResizeHandler() {
                document.removeEventListener('mousemove', resizeHandler);
                document.removeEventListener('mouseup', stopResizeHandler);
                document.removeEventListener('touchmove', resizeHandler);
                document.removeEventListener('touchend', stopResizeHandler);
                saveGlobalLayout(button, textBox);
            }

            document.addEventListener('mousemove', resizeHandler);
            document.addEventListener('mouseup', stopResizeHandler);
            document.addEventListener('touchmove', resizeHandler);
            document.addEventListener('touchend', stopResizeHandler);
        }

        /**
         * 文本框内容输入事件 - 实时保存
         */
        textBox.addEventListener('input', () => {
            GM_setValue(getContentKey(), textBox.value);
        });

        // 初始化界面
        document.body.append(button, textBox);
        updateTextBoxPosition(button, textBox);
    }

    // =====================
    // DOM操作辅助函数
    // =====================

    /**
     * 创建按钮元素
     * @param {object} layoutData - 布局数据
     */
    function createButton(layoutData) {
        const button = document.createElement('div');
        button.className = 'sticky-button';
        button.textContent = '📝';
        button.style.left = `${layoutData.x}px`;
        button.style.top = `${layoutData.y}px`;
        return button;
    }

    /**
     * 创建文本框元素
     * @param {object} layoutData - 布局数据
     * @param {string} content - 初始内容
     */
    function createTextBox(layoutData, content) {
        const textBox = document.createElement('textarea');
        textBox.className = 'sticky-textbox';
        textBox.style.width = `${layoutData.textWidth}px`;
        textBox.style.height = `${layoutData.textHeight}px`;
        textBox.value = content;
        textBox.style.display = layoutData.visible ? 'block' : 'none';
        return textBox;
    }

    // =====================
    // 功能逻辑函数
    // =====================

    /**
     * 更新按钮位置
     * @param {HTMLElement} button - 按钮元素
     * @param {number} x - 新的X坐标
     * @param {number} y - 新的Y坐标
     */
    function updateButtonPosition(button, x, y) {
        button.style.left = `${x}px`;
        button.style.top = `${y}px`;
    }

    /**
     * 更新文本框位置(跟随按钮)
     */
    function updateTextBoxPosition(button, textBox) {
        const btnRect = button.getBoundingClientRect();
        textBox.style.left = `${btnRect.right + config.textBoxOffset}px`; // 按钮右侧+间距
        textBox.style.top = `${btnRect.top}px`; // 与按钮顶部对齐
    }

    /**
     * 切换文本框可见性
     */
    function toggleTextBoxVisibility(textBox) {
        textBox.style.display = textBox.style.display === 'none' ? 'block' : 'none';
    }

    /**
     * 保存全局布局到存储
     */
    function saveGlobalLayout(button, textBox) {
        const btnRect = button.getBoundingClientRect();
        GM_setValue(config.globalLayoutKey, {
            x: btnRect.left,
            y: btnRect.top,
            textWidth: textBox.offsetWidth,
            textHeight: textBox.offsetHeight,
            visible: textBox.style.display !== 'none'
        });
    }

    /**
     * 判断是否点击了调整大小手柄区域
     */
    function isResizeHandle(e, element) {
        const rect = element.getBoundingClientRect();
        return (
            e.clientX > rect.right - 16 && // 右侧16px区域
            e.clientY > rect.bottom - 16 // 底部16px区域
        );
    }

    // =====================
    // 初始化入口
    // =====================
    window.addEventListener('load', () => {
        // 延迟初始化以确保游戏主界面加载完成
        setTimeout(() => {
            if (!document.querySelector('.sticky-button')) {
                createStickySystem();
            }
        }, config.initializationDelay);
    });
})();