LatexCopier

一键复制网页数学公式到Word/LaTeX | 智能悬停预览 | 支持维基百科/知乎/豆包/ChatGPT/stackexchange/DeepSeek等主流网站 | 自动识别并转换LaTeX/MathML格式 | 可视化反馈提示

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

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

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         LatexCopier
// @name:zh-CN   Latex公式复制助手  支持一键复制到Word文档/支持直接复制Latex代码(一键切换) 支持ChatGPT 维基百科 豆包 DeepSeek stackexchange 等主流网站
// @namespace    https://github.com/BakaDream/LatexCopier
// @version      1.0.0
// @license      GPLv3
// @description  一键复制网页数学公式到Word/LaTeX | 智能悬停预览 | 支持维基百科/知乎/豆包/ChatGPT/stackexchange/DeepSeek等主流网站 | 自动识别并转换LaTeX/MathML格式 | 可视化反馈提示
// @author       BakaDream
// @match        *://*.wikipedia.org/*
// @match        *://*.zhihu.com/*
// @match        *://*.chatgpt.com/*
// @match        *://*.stackexchange.com/*
// @match        *://*.doubao.com/*
// @match        *://*.deepseek.com/*
// @require      https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js
// @grant        GM_registerMenuCommand
// @grant        GM_setValue
// @grant        GM_getValue
// ==/UserScript==

(function() {
    'use strict';

    // ========================
    // 配置中心
    // ========================
    const CONFIG = {
        STORAGE_KEY: 'latexCopyMode',
        MODES: {
            WORD: {
                id: 'word',
                name: 'Word公式模式',
                desc: '生成MathML,粘贴到Word自动转为公式',
                feedback: '公式已复制 ✓ 可粘贴到Word'
            },
            RAW: {
                id: 'raw',
                name: '原始LaTeX模式',
                desc: '直接复制LaTeX源代码',
                feedback: 'LaTeX代码已复制 ✓'
            }
        },
        DEFAULT_MODE: 'word',
        SITE_TARGETS: {
            'wikipedia.org': {
                selector: 'span.mwe-math-element',
                extractor: el => el.querySelector('math')?.getAttribute('alttext')
            },
            'zhihu.com': {
                selector: 'span.ztext-math',
                extractor: el => el.getAttribute('data-tex')
            },
            'doubao.com': {
                selector: 'span.math-inline',
                extractor: el => el.getAttribute('data-custom-copy-text')
            },
            'chatgpt.com': {
                selector: 'span.katex',
                extractor: el => el.querySelector('annotation')?.textContent
            },
            'stackexchange.com': {
                selector: 'span.math-container',
                extractor: el => el.querySelector('script')?.textContent
            },
            'deepseek.com': {
                selector: 'span.katex',
                extractor: el => el.querySelector('annotation')?.textContent
            }

        }
    };

    // ========================
    // 工具模块
    // ========================
    const Utils = {
        getSiteConfig(url) {
            for (const [domain, config] of Object.entries(CONFIG.SITE_TARGETS)) {
                if (url.includes(domain)) return config;
            }
            return null;
        },

        copyToClipboard(text) {
            const textarea = document.createElement('textarea');
            textarea.style.position = 'fixed';
            textarea.style.left = '-9999px';
            textarea.style.opacity = 0;
            textarea.value = text;
            document.body.appendChild(textarea);
            textarea.select();

            let success = false;
            try {
                success = document.execCommand('copy');
            } catch (err) {
                console.error('[LaTeX助手] 复制失败:', err);
            } finally {
                document.body.removeChild(textarea);
            }
            return success;
        }
    };

    // ========================
    // 主功能模块
    // ========================
    const LaTeXCopyHelper = {
        currentMode: null,
        tooltip: null,
        feedback: null,
        activeElements: new Set(),

        // ===== 初始化 =====
        init() {
            this.currentMode = this._loadMode();
            this._initStyles(); // 合并样式配置和注入
            this._initUIElements();
            this._setupEventListeners();
            this._registerMenuCommand();
        },

        // ===== 模式管理 =====
        _loadMode() {
            const savedMode = GM_getValue(CONFIG.STORAGE_KEY);
            return Object.values(CONFIG.MODES).find(m => m.id === savedMode) || CONFIG.MODES.WORD;
        },

        _registerMenuCommand() {
            GM_registerMenuCommand(
                `切换模式 | 当前: ${this.currentMode.name}`,
                () => this._toggleMode()
            );
        },

        _toggleMode() {
            const newMode = this.currentMode === CONFIG.MODES.WORD
                ? CONFIG.MODES.RAW
                : CONFIG.MODES.WORD;

            GM_setValue(CONFIG.STORAGE_KEY, newMode.id);
            this.currentMode = newMode;

            this._showFeedback(`已切换为: ${newMode.name}\n${newMode.desc}`, true);
            setTimeout(() => location.reload(), 300);
        },

        // ===== UI管理 =====
        _initStyles() {
            const STYLES = {
                // 公式悬停效果
                HOVER: {
                    background: 'rgba(100, 180, 255, 0.15)',
                    boxShadow: '0 0 8px rgba(0, 120, 215, 0.3)',
                    transition: 'all 0.3s ease'
                },
                // 工具提示
                TOOLTIP: {
                    background: 'rgba(0, 0, 0, 0.85)',
                    color: 'white',
                    padding: '8px 12px',
                    borderRadius: '4px',
                    maxWidth: '400px',
                    fontSize: '13px',
                    offset: 10,
                    arrowSize: '5px'
                },
                // 操作反馈
                FEEDBACK: {
                    background: '#4CAF50',
                    errorBackground: '#f44336',
                    color: 'white',
                    duration: 1500,
                    position: 'bottom: 20%; left: 50%'
                }
            };

            const style = document.createElement('style');
            style.textContent = `
                /* 公式元素悬停效果 */
                [data-latex-copy] {
                    cursor: pointer;
                    transition: ${STYLES.HOVER.transition};
                    border-radius: 3px;
                    padding: 2px;
                    position: relative;
                }
                [data-latex-copy]:hover {
                    background: ${STYLES.HOVER.background} !important;
                    box-shadow: ${STYLES.HOVER.boxShadow} !important;
                }

                /* 智能工具提示 */
                .latex-helper-tooltip {
                    position: fixed;
                    background: ${STYLES.TOOLTIP.background};
                    color: ${STYLES.TOOLTIP.color};
                    padding: ${STYLES.TOOLTIP.padding};
                    border-radius: ${STYLES.TOOLTIP.borderRadius};
                    max-width: min(${STYLES.TOOLTIP.maxWidth}, 90vw);
                    font-size: ${STYLES.TOOLTIP.fontSize};
                    z-index: 9999;
                    opacity: 0;
                    transform: translateY(5px);
                    transition: all 0.2s ease;
                    pointer-events: none;
                    word-break: break-word;
                }
                .latex-helper-tooltip.visible {
                    opacity: 1;
                    transform: translateY(0);
                }
                .latex-helper-tooltip::after {
                    content: '';
                    position: absolute;
                    left: 10px;
                    border-width: ${STYLES.TOOLTIP.arrowSize};
                    border-style: solid;
                    border-color: transparent;
                }
                .latex-helper-tooltip.top-direction::after {
                    bottom: calc(-${STYLES.TOOLTIP.arrowSize} * 2);
                    border-top-color: ${STYLES.TOOLTIP.background};
                }
                .latex-helper-tooltip.bottom-direction::after {
                    top: calc(-${STYLES.TOOLTIP.arrowSize} * 2);
                    border-bottom-color: ${STYLES.TOOLTIP.background};
                }

                /* 操作反馈提示 */
                .latex-helper-feedback {
                    position: fixed;
                    ${STYLES.FEEDBACK.position};
                    transform: translateX(-50%);
                    background: ${STYLES.FEEDBACK.background};
                    color: ${STYLES.FEEDBACK.color};
                    padding: 10px 20px;
                    border-radius: 4px;
                    z-index: 10000;
                    opacity: 0;
                    transition: all 0.3s;
                    text-align: center;
                    white-space: pre-wrap;
                }
                .latex-helper-feedback.error {
                    background: ${STYLES.FEEDBACK.errorBackground} !important;
                }
                .latex-helper-feedback.visible {
                    opacity: 1;
                    transform: translateX(-50%) translateY(-10px);
                }
            `;
            document.head.appendChild(style);
        },

        _initUIElements() {
            this.tooltip = document.createElement('div');
            this.tooltip.className = 'latex-helper-tooltip';
            document.body.appendChild(this.tooltip);

            this.feedback = document.createElement('div');
            this.feedback.className = 'latex-helper-feedback';
            document.body.appendChild(this.feedback);
        },

        // ===== 事件处理 =====
        _setupEventListeners() {
            document.addEventListener('mouseover', (e) => this._handleHover(e));
            document.addEventListener('mouseout', (e) => this._handleMouseOut(e));
            document.addEventListener('dblclick', (e) => this._handleDoubleClick(e), true);

            new MutationObserver((mutations) => this._handleMutations(mutations))
                .observe(document.body, { childList: true, subtree: true });
        },

        _handleHover(e) {
            const siteConfig = Utils.getSiteConfig(window.location.href);
            const element = e.target.closest(siteConfig?.selector || '');
            if (!element) return this._hideTooltip();

            element.setAttribute('data-latex-copy', 'true');
            this.activeElements.add(element);

            const latex = siteConfig.extractor(element);
            if (latex) this._showSmartTooltip(latex, element);
        },

        _handleMouseOut(e) {
            const element = e.target.closest('[data-latex-copy]');
            if (element) {
                element.style.background = '';
                element.style.boxShadow = '';
            }
            this._hideTooltip();
        },

        _handleDoubleClick(e) {
            const siteConfig = Utils.getSiteConfig(window.location.href);
            const element = e.target.closest(siteConfig?.selector || '');
            if (!element) return;

            const latex = siteConfig.extractor(element);
            if (!latex) return;

            this._copyFormula(latex);
            e.preventDefault();
            e.stopPropagation();
        },

        _handleMutations(mutations) {
            const siteConfig = Utils.getSiteConfig(window.location.href);
            if (!siteConfig) return;

            mutations.forEach((mutation) => {
                mutation.addedNodes.forEach((node) => {
                    if (node.nodeType === 1 && node.matches(siteConfig.selector)) {
                        node.setAttribute('data-latex-copy', 'true');
                        this.activeElements.add(node);
                    }
                });
            });
        },

        // ===== 核心功能 =====
        _showSmartTooltip(text, element) {
            this.tooltip.textContent = text;

            const rect = element.getBoundingClientRect();
            const tooltipHeight = this.tooltip.offsetHeight;
            const viewportHeight = window.innerHeight;

            // 优先显示在上方
            if (rect.top - tooltipHeight - 10 > 0) {
                this.tooltip.style.top = `${rect.top - tooltipHeight - 10}px`;
                this.tooltip.style.left = `${rect.left}px`;
                this.tooltip.className = 'latex-helper-tooltip top-direction visible';
            }
            // 上方空间不足时显示在下方
            else if (rect.bottom + tooltipHeight + 10 < viewportHeight) {
                this.tooltip.style.top = `${rect.bottom + 10}px`;
                this.tooltip.style.left = `${rect.left}px`;
                this.tooltip.className = 'latex-helper-tooltip bottom-direction visible';
            }
            // 极端情况:显示在元素旁边
            else {
                this.tooltip.style.top = `${rect.top}px`;
                this.tooltip.style.left = `${rect.right + 10}px`;
                this.tooltip.className = 'latex-helper-tooltip visible';
            }
        },

        _hideTooltip() {
            this.tooltip.className = 'latex-helper-tooltip';
        },

        async _copyFormula(latex) {
            if (this.currentMode === CONFIG.MODES.RAW) {
                this._copyRawLatex(latex);
            } else {
                await this._copyAsMathML(latex);
            }
        },

        _copyRawLatex(latex) {
            const success = Utils.copyToClipboard(latex);
            this._showFeedback(
                success ? this.currentMode.feedback : '复制失败 ✗',
                success
            );
        },

        async _copyAsMathML(latex) {
            try {
                MathJax.texReset();
                const mathML = await MathJax.tex2mmlPromise(latex);
                const success = Utils.copyToClipboard(mathML);
                this._showFeedback(
                    success ? this.currentMode.feedback : '转换失败 ✗',
                    success
                );
            } catch (error) {
                console.error('[LaTeX助手] MathJax转换错误:', error);
                this._showFeedback('公式转换失败,请尝试原始模式', false);
            }
        },

        _showFeedback(message, isSuccess) {
            this.feedback.textContent = message;
            this.feedback.className = `latex-helper-feedback ${isSuccess ? '' : 'error'} visible`;
            setTimeout(() => {
                this.feedback.className = 'latex-helper-feedback';
            }, 1500);
        }
    };

    // ========================
    // 启动脚本
    // ========================
    LaTeXCopyHelper.init();
})();