Steam 格式助手

在Steam游戏/讨论组中添加格式帮助工具。支持拖拽定位、边缘检测和动画,防止UI冲突。

// ==UserScript==
// @name         Steam 格式助手
// @namespace    http://tampermonkey.net/
// @version      1.0
// @description  在Steam游戏/讨论组中添加格式帮助工具。支持拖拽定位、边缘检测和动画,防止UI冲突。
// @author       Your name
// @match        https://store.steampowered.com/app/*
// @match        https://steamcommunity.com/app/*/discussions/*
// @match        https://steamcommunity.com/discussions/*
// @match        https://steamcommunity.com/groups/*/discussions/*
// @grant        GM_addStyle
// @grant        GM_setClipboard
// @grant        GM_setValue
// @grant        GM_getValue
// ==/UserScript==

(function() {
    'use strict';

    // 检查当前页面是否是指定类型的讨论页面
    function isDiscussionsPage() {
        const path = window.location.pathname;
        return path.includes('/discussions/') &&
               (path.includes('/app/') ||
                path.startsWith('/discussions/') ||
                path.includes('/groups/'));
    }

    // 检查当前页面是否是游戏商店页面
    function isGameStorePage() {
        return window.location.href.match(/https:\/\/store\.steampowered\.com\/app\/\d+/);
    }

    if (!isGameStorePage() && !isDiscussionsPage()) {
        return;
    }

    // 添加自定义样式
    GM_addStyle(`
        #formatHelperBtn {
            position: fixed; /* 改为fixed以支持拖拽定位 */
            z-index: 10000;
            background: linear-gradient( to bottom, #a4d007 5%, #536904 95%);
            color: #e5e4dc;
            border: none;
            border-radius: 2px;
            height: 28px;
            padding: 0 15px;
            font-size: 12px;
            font-family: "Motiva Sans", Arial, sans-serif;
            font-weight: normal;
            text-transform: none;
            text-shadow: 1px 1px 0px rgb(0 0 0 / 30%);
            cursor: grab; /* 初始光标为抓取手势 */
            display: flex;
            align-items: center;
            justify-content: center;
            transition: box-shadow 0.2s ease;
            box-shadow: 0 0 10px rgba(0,0,0,0.5);
            user-select: none; /* 防止拖拽时选中文字 */
        }

        #formatHelperBtn:hover {
            background: linear-gradient( to bottom, #b6d908 5%, #7a8b05 95%);
        }

        #formatHelperBtn:active {
            cursor: grabbing;
        }

        #formatHelperMenu {
            position: fixed; /* 改为fixed以支持动态定位 */
            width: 280px;
            background: #1b2838;
            border-radius: 3px;
            box-shadow: 0 0 12px #000000;
            z-index: 9999;
            overflow: hidden;
            border: 1px solid #000;
            /* === 动画与可见性 === */
            transition: opacity 0.2s ease, transform 0.2s ease;
            opacity: 0;
            transform: translateY(10px);
            pointer-events: none;
        }

        #formatHelperMenu.visible {
            opacity: 1;
            transform: translateY(0);
            pointer-events: auto;
        }

        .format-section {
            padding: 10px;
            border-bottom: 1px solid #2a3f5a;
        }
        .format-section:last-child {
            border-bottom: none;
        }

        .format-section-title {
            color: #67c1f5; font-weight: bold; margin-bottom: 8px; cursor: pointer;
            display: flex; justify-content: space-between; align-items: center;
            font-family: "Motiva Sans", Arial, sans-serif; font-size: 14px;
        }

        .format-section-content {
            display: none; flex-direction: column; gap: 5px;
        }

        .format-section.expanded .format-section-content {
            display: flex;
        }

        .format-option {
            padding: 8px; background: #2a3f5a; border-radius: 2px;
            color: #c6d4df; cursor: pointer; transition: all 0.2s;
            display: flex; align-items: center; gap: 8px;
            position: relative; overflow: hidden; font-family: Arial, sans-serif;
            font-size: 13px; border: 1px solid transparent;
        }

        .format-option:hover {
            background: #3d5775; border: 1px solid #6d8ba5;
        }

        .format-option-preview { flex-grow: 1; }

        .copy-feedback {
            position: absolute; top: 0; left: 0; right: 0; bottom: 0;
            background: rgba(103, 193, 245, 0.9); display: flex;
            align-items: center; justify-content: center; opacity: 0;
            transition: opacity 0.3s; color: white; font-weight: bold;
            font-size: 12px; pointer-events: none;
        }

        .copy-feedback.show { opacity: 1; }

        /* --- 预览样式 --- */
        .preview-h1 { font-size: 18px; font-weight: bold; color: #ffffff; font-family: "Motiva Sans", Arial, sans-serif; }
        .preview-h2 { font-size: 16px; font-weight: bold; color: #ffffff; font-family: "Motiva Sans", Arial, sans-serif; }
        .preview-h3 { font-size: 14px; font-weight: bold; color: #ffffff; font-family: "Motiva Sans", Arial, sans-serif; }
        .preview-bold { font-weight: bold; color: #ffffff; }
        .preview-underline { text-decoration: underline; color: #ffffff; }
        .preview-italic { font-style: italic; color: #ffffff; }
        .preview-strike { text-decoration: line-through; color: #ffffff; }
        .preview-spoiler { background: #000000; color: #000000; padding: 0 2px; }
        .preview-spoiler:hover { color: #ffffff; }
        .preview-link { color: #67c1f5; text-decoration: underline; }
        .preview-list-item { display: flex; align-items: center; gap: 5px; }
        .preview-list-bullet { display: inline-block; width: 5px; height: 5px; background: white; border-radius: 50%; }
        .preview-nlist-item { display: flex; align-items: center; gap: 5px; }
        .preview-quote { background: #2a3f5a; border-left: 3px solid #67c1f5; padding: 5px 8px; color: #c6d4df; }
        .preview-code { font-family: monospace; background: #2a3f5a; padding: 2px 4px; border-radius: 3px; color: #ffffff; }
    `);

    // 格式选项配置 (与v1.2相同)
    const formatOptions = [
        {
            title: "标题",
            options: [
                { name: "一级标题", format: "[h1]请输入标题文字[/h1]", preview: '<span class="preview-h1">一级标题</span>' },
                { name: "二级标题", format: "[h2]请输入标题文字[/h2]", preview: '<span class="preview-h2">二级标题</span>' },
                { name: "三级标题", format: "[h3]请输入标题文字[/h3]", preview: '<span class="preview-h3">三级标题</span>' }
            ]
        },
        {
            title: "文本样式",
            options: [
                { name: "粗体", format: "[b]请输入粗体文本[/b]", preview: '<span class="preview-bold">粗体文本</span>' },
                { name: "下划线", format: "[u]请输入下划线文本[/u]", preview: '<span class="preview-underline">下划线文本</span>' },
                { name: "斜体", format: "[i]请输入斜体文本[/i]", preview: '<span class="preview-italic">斜体文本</span>' },
                { name: "删除线", format: "[strike]请输入删除文本[/strike]", preview: '<span class="preview-strike">删除线文本</span>' },
                { name: "剧透", format: "[spoiler]请输入隐藏文本[/spoiler]", preview: '<span class="preview-spoiler">剧透(隐藏)文本</span>' }
            ]
        },
        {
            title: "列表",
            options: [
                { name: "无序列表", format: "[list]\n[*] 项目符号列表\n[*] 项目符号列表\n[/list]", preview: '<div class="preview-list-item"><span class="preview-list-bullet"></span><span>项目符号列表</span></div>' },
                { name: "有序列表", format: "[olist]\n[*] 有序列表\n[*] 有序列表\n[/olist]", preview: '<div class="preview-nlist-item"><span>1.</span><span>有序列表</span></div>' }
            ]
        },
        {
            title: "其他元素",
            options: [
                { name: "链接", format: "[url=请输入链接地址]请输入网站名称[/url]", preview: '<span class="preview-link">网站链接</span>' },
                { name: "引用", format: "[quote=请输入引用来源]请输入引用文本[/quote]", preview: '<div class="preview-quote">引用文本</div>' },
                { name: "代码", format: "[code]请输入代码或等宽文本[/code]", preview: '<span class="preview-code">等宽字体,保留空格</span>' }
            ]
        }
    ];

    /**
     * 更新菜单位置,实现边缘检测
     * @param {HTMLElement} btn - 按钮元素
     * @param {HTMLElement} menu - 菜单元素
     */
    function updateMenuPosition(btn, menu) {
        const btnRect = btn.getBoundingClientRect();
        const menuHeight = menu.offsetHeight;
        const menuWidth = menu.offsetWidth;
        const gap = 10; // 按钮和菜单间的距离

        let top, left;

        // 优先在按钮上方显示
        if (btnRect.top > menuHeight + gap) {
            top = btnRect.top - menuHeight - gap;
        } else {
            // 上方空间不足,则在下方显示
            top = btnRect.bottom + gap;
        }

        // 水平方向上,尽量让菜单右侧与按钮右侧对齐
        left = btnRect.right - menuWidth;

        // 边缘检测:防止菜单超出屏幕左右边界
        if (left < gap) {
            left = gap;
        }
        if (left + menuWidth > window.innerWidth - gap) {
            left = window.innerWidth - menuWidth - gap;
        }

        menu.style.top = `${top}px`;
        menu.style.left = `${left}px`;
    }

    /**
     * 使元素可拖拽并记忆位置
     * @param {HTMLElement} btn - 要拖拽的按钮元素
     */
    function makeDraggable(btn) {
        // 读取保存的位置
        const savedPos = GM_getValue('formatHelperBtnPos');
        if (savedPos) {
            btn.style.top = savedPos.top;
            btn.style.left = savedPos.left;
        } else {
            // 默认初始位置
            btn.style.bottom = '20px';
            btn.style.right = '20px';
        }

        let isDragging = false;
        let wasDragged = false;
        let offsetX, offsetY;

        btn.addEventListener('mousedown', (e) => {
            isDragging = true;
            wasDragged = false;
            // 如果是绝对定位,需要转换
            btn.style.right = 'auto';
            btn.style.bottom = 'auto';

            const rect = btn.getBoundingClientRect();
            offsetX = e.clientX - rect.left;
            offsetY = e.clientY - rect.top;

            document.addEventListener('mousemove', onMouseMove);
            document.addEventListener('mouseup', onMouseUp);
        });

        function onMouseMove(e) {
            if (!isDragging) return;
            wasDragged = true;

            let newLeft = e.clientX - offsetX;
            let newTop = e.clientY - offsetY;

            // 限制在视窗内
            const rect = btn.getBoundingClientRect();
            if (newLeft < 0) newLeft = 0;
            if (newTop < 0) newTop = 0;
            if (newLeft + rect.width > window.innerWidth) newLeft = window.innerWidth - rect.width;
            if (newTop + rect.height > window.innerHeight) newTop = window.innerHeight - rect.height;

            btn.style.left = newLeft + 'px';
            btn.style.top = newTop + 'px';
        }

        function onMouseUp() {
            isDragging = false;
            document.removeEventListener('mousemove', onMouseMove);
            document.removeEventListener('mouseup', onMouseUp);

            if (wasDragged) {
                 // 保存最终位置
                GM_setValue('formatHelperBtnPos', {
                    top: btn.style.top,
                    left: btn.style.left
                });
            }
        }

        // 返回一个检查是否被拖拽的函数,用于区分点击和拖拽
        return () => wasDragged;
    }


    // 创建帮助按钮和菜单
    function createFormatHelper() {
        const btn = document.createElement('button');
        btn.id = 'formatHelperBtn';
        btn.textContent = '格式助手';

        const menu = document.createElement('div');
        menu.id = 'formatHelperMenu';

        // 填充菜单内容...
        formatOptions.forEach(section => {
            const sectionEl = document.createElement('div');
            sectionEl.className = 'format-section';

            const titleEl = document.createElement('div');
            titleEl.className = 'format-section-title';
            titleEl.textContent = section.title;
            titleEl.innerHTML += '<span>▼</span>';

            const contentEl = document.createElement('div');
            contentEl.className = 'format-section-content';

            section.options.forEach(option => {
                const optionEl = document.createElement('div');
                optionEl.className = 'format-option';
                optionEl.dataset.format = option.format;
                const feedbackEl = document.createElement('div');
                feedbackEl.className = 'copy-feedback';
                feedbackEl.textContent = '已复制!';
                optionEl.innerHTML = `<div class="format-option-preview">${option.preview}</div>`;
                optionEl.appendChild(feedbackEl);
                optionEl.addEventListener('click', () => {
                    GM_setClipboard(option.format, 'text');
                    feedbackEl.classList.add('show');
                    setTimeout(() => feedbackEl.classList.remove('show'), 1000);
                });
                contentEl.appendChild(optionEl);
            });

            titleEl.addEventListener('click', () => {
                const isExpanded = sectionEl.classList.toggle('expanded');
                titleEl.querySelector('span').textContent = isExpanded ? '▲' : '▼';
            });

            sectionEl.appendChild(titleEl);
            sectionEl.appendChild(contentEl);
            menu.appendChild(sectionEl);
        });

        document.body.appendChild(btn);
        document.body.appendChild(menu);

        const wasDraggedCheck = makeDraggable(btn);

        btn.addEventListener('click', (e) => {
            if (wasDraggedCheck()) return; // 如果是拖拽结束,则不触发点击

            e.stopPropagation();
            const isVisible = menu.classList.contains('visible');

            if (!isVisible) {
                updateMenuPosition(btn, menu); // 显示前更新位置
                menu.classList.add('visible');
                // 默认展开第一个分类
                if (!menu.querySelector('.expanded')) {
                    const firstSection = menu.querySelector('.format-section');
                    if (firstSection) {
                        firstSection.classList.add('expanded');
                        firstSection.querySelector('.format-section-title span').textContent = '▲';
                    }
                }
            } else {
                menu.classList.remove('visible');
            }
        });

        document.addEventListener('click', (e) => {
            if (!menu.contains(e.target) && e.target !== btn) {
                menu.classList.remove('visible');
            }
        });
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', createFormatHelper);
    } else {
        createFormatHelper();
    }
})();