TexCopyer

Double click on a LaTeX formula on a webpage to copy it to the clipboard

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         TexCopyer
// @namespace    http://tampermonkey.net/
// @version      1.6
// @license      GPLv3
// @description  双击网页中的LaTex公式,将其复制到剪切板。支持主流AI网站、知乎、IEEE等等
// @description:en Double click on a LaTeX formula on a webpage to copy it to the clipboard
// @author       3plus10i
// @match        *://*.wikipedia.org/*
// @match        *://*.zhihu.com/*
// @match        *://*.chatgpt.com/*
// @match        *://*.stackexchange.com/*
// @match        *://oi-wiki.org/*
// @match        *://*.doubao.com/*
// @match        *://*.deepseek.com/*
// @match        *://*.chatboxai.app/*
// @match        *://ieeexplore.ieee.org/*
// @match        *://*.bohrium.com/*
// @match        *://*.gemini.google.com/*
// @match        *://aistudio.google.com/*
// @match        *://*.grok.com/*
// @match        *://x.com/*
// @match        *://*.z.ai/*
// @match        *://*.qianwen.com/*
// @match        *://*.metaso.cn/*
// @match        *://*.csdn.net/*
// ==/UserScript==

(function () {
    'use strict';

    // ---- 工具函数 ----

    function formatLatex(input) {
        while (input.endsWith(' ') || input.endsWith('\\')) {
            input = input.slice(0, -1);
        }
        return '$' + input + '$';
    }

    /** 从元素中用 querySelector 安全取值,失败返回空字符串 */
    function safeText(el, selector) {
        const found = el.querySelector(selector);
        return found ? found.textContent : '';
    }

    /** 从元素中安全读取属性,失败返回空字符串 */
    function safeAttr(el, attr) {
        const val = el.getAttribute(attr);
        return val || '';
    }

    // ---- 站点配置(按需扩展) ----

    const SITE_CONFIGS = [
        {
            match: u => u.includes('wikipedia.org'),
            selector: 'span.mwe-math-element',
            extract: el => formatLatex(safeAttr(el.querySelector('math'), 'alttext')),
        },
        {
            match: u => u.includes('zhihu.com'),
            selector: 'span.ztext-math',
            extract: el => formatLatex(safeAttr(el, 'data-tex')),
        },
        {
            match: u => u.includes('chatgpt.com'),
            selector: 'span.katex',
            extract: el => formatLatex(safeText(el, 'annotation')),
        },
        // Kimi (moonshot.cn) — Vue生产构建剥离了内部引用,
        // 且仅通过闭包持有LaTeX源码,暂无可靠提取路径。
        {
            match: u => u.includes('stackexchange.com'),
            selector: 'span.math-container',
            extract: el => formatLatex(safeText(el, 'script')),
        },
        {
            match: u => u.includes('oi-wiki.org'),
            selector: 'mjx-container.MathJax',
            extract: el => formatLatex(safeAttr(el.querySelector('mjx-math'), 'data-latex').trim()),
        },
        {
            match: u => u.includes('doubao.com'),
            selector: 'span.math-inline',
            extract: el => {
                const raw = safeAttr(el, 'copy-text');
                // 剥离 \( ... \) 包裹
                const inner = raw.replace(/^\\\(|\\\)$/g, '');
                return inner ? formatLatex(inner) : '';
            },
        },
        {
            match: u => u.includes('deepseek.com'),
            selector: 'span.katex',
            extract: el => formatLatex(safeText(el, 'annotation')),
        },
        {
            match: u => u.includes('chatboxai.app'),
            selector: 'span.katex',
            extract: el => formatLatex(safeText(el, 'annotation')),
        },
        {
            match: u => u.includes('ieeexplore.ieee.org'),
            selector: 'span[id^="MathJax-Element-"][id$="-Frame"]',
            extract(el) {
                const idMatch = el.id.match(/Element-(\w+)-Frame/);
                if (!idMatch) return '';

                const scriptEl = document.getElementById(`MathJax-Element-${idMatch[1]}`);
                if (!scriptEl) return '';

                let latex = scriptEl.textContent;
                latex = latex.replace(/\\begin\{equation\*\}/g, '\\[\\begin{array}{l}');
                latex = latex.replace(/\\end\{equation\*\}/g, '\\end{array}\\]');
                latex = latex.replace(/\\tag\{(\w+)\}/g, '\\\\');
                return formatLatex(latex);
            },
        },
        {
            match: u => u.includes('bohrium.com'),
            selector: '.math.math-inline, .math.math-display',
            extract: el => formatLatex(safeText(el, 'annotation[encoding="application/x-tex"]')),
        },
        {
            match: u => u.includes('gemini.google.com'),
            selector: '.math-inline, .math-block',
            extract: el => formatLatex(safeAttr(el, 'data-math')),
        },
        {
            match: u => u.includes('aistudio.google.com'),
            selector: 'ms-katex.inline, .katex-display, span.katex',
            extract: el => formatLatex(safeText(el, 'annotation[encoding="application/x-tex"]')),
        },
        {
            match: u => u.includes('grok.com') || u.includes('x.com'),
            selector: 'span.katex',
            extract: el => formatLatex(safeText(el, 'annotation')),
        },
        {
            match: u => u.includes('z.ai'),
            selector: 'span.katex',
            extract: el => formatLatex(safeText(el, 'annotation')),
        },
        {
            match: u => u.includes('qianwen.com'),
            selector: 'span.katex, .katex-display',
            extract: el => formatLatex(safeText(el, 'annotation')),
        },
        {
            match: u => u.includes('metaso.cn'),
            selector: 'span.katex',
            extract: el => formatLatex(safeText(el, 'annotation')),
        },
        {
            match: u => u.includes('csdn.net'),
            selector: 'span.katex',
            extract: el => {
                const ml = el.querySelector('.katex-mathml');
                if (!ml) return '';
                // 真正的 LaTeX 表达式始终在最后一行非空文本。
                const lines = ml.textContent.split('\n')
                    .map(s => s.trim())
                    .filter(s => s.length > 0);
                let tex = lines.pop() || '';
                tex = tex.replace(/^\\displaystyle\s+/, '');
                return formatLatex(tex);
            },
        },
    ];

    /** 返回当前 URL 匹配的第一个站点配置,无匹配返回 null */
    function resolveSite(url) {
        return SITE_CONFIGS.find(cfg => cfg.match(url)) || null;
    }

    // 模块级缓存,页面生命周期内不变
    const currentSite = resolveSite(window.location.href);

    // ---- DOM / UI ----

    const css = `
        .latex-tooltip { position: fixed; background-color: rgba(0, 0, 0, 0.85); color: #fff; padding: 5px 10px; border-radius: 5px; font-size: 11px; z-index: 1000; opacity: 0; transition: opacity 0.2s; pointer-events: none; }
    `;
    const styleSheet = document.createElement('style');
    styleSheet.type = 'text/css';
    styleSheet.innerText = css;
    document.head.appendChild(styleSheet);

    const tooltip = document.createElement('div');
    tooltip.classList.add('latex-tooltip');
    document.body.appendChild(tooltip);

    // ---- 事件绑定 ----

    const DATA_FLAG = 'data-texcopyer-processed';
    let successLock = false; // 复制成功1s内禁止 mouseleave 隐藏 tooltip

    function showTooltip(el, text) {
        tooltip.textContent = text;
        const rect = el.getBoundingClientRect();
        tooltip.style.display = 'block';
        tooltip.style.left = `${rect.left + rect.width / 2 - tooltip.offsetWidth / 2}px`;
        tooltip.style.top = `${rect.top - tooltip.offsetHeight - 5}px`;
        tooltip.style.opacity = '0.8';
    }

    function hideTooltip() {
        if (successLock) return;
        tooltip.style.display = 'none';
        tooltip.style.opacity = '0';
    }

    function showCopyResult(el) {
        successLock = true;
        showTooltip(el, '✔ 已复制');
        setTimeout(() => {
            successLock = false;
            hideTooltip();
        }, 1000);
    }

    function bindToNewElements() {
        if (!currentSite) return;

        document.querySelectorAll(currentSite.selector).forEach(el => {
            if (el.hasAttribute(DATA_FLAG)) return;
            el.setAttribute(DATA_FLAG, '');

            let bindTimer = null;
            let clickTimer = null;
            let clickCount = 0;

            el.addEventListener('mouseenter', function () {
                el.style.cursor = 'pointer';
                bindTimer = setTimeout(() => showTooltip(el, currentSite.extract(el)), 1000);
            });

            el.addEventListener('mouseleave', function () {
                el.style.cursor = 'auto';
                clearTimeout(bindTimer);
                clearTimeout(clickTimer);
                clickCount = 0;
                hideTooltip();
            });

            el.addEventListener('click', function () {
                clickCount++;

                if (clickCount === 1) {
                    clickTimer = setTimeout(() => {
                        clickCount = 0;
                        clearTimeout(bindTimer);
                        showTooltip(el, '💡 双击复制LaTeX公式');
                    }, 300);
                } else {
                    // click2 within 300ms → double-click
                    clearTimeout(clickTimer);
                    clearTimeout(bindTimer);
                    clickCount = 0;
                    const latex = currentSite.extract(el);
                    if (latex) {
                        console.log(`LaTeX copied: ${latex}`);
                        navigator.clipboard.writeText(latex).then(() => showCopyResult(el));
                    }
                    window.getSelection().removeAllRanges();
                }
            });
        });
    }

    // 初始绑定 & DOM 变化时重新绑定(防抖 300ms)
    document.addEventListener('DOMContentLoaded', bindToNewElements);
    let debounceId;
    new MutationObserver(() => {
        clearTimeout(debounceId);
        debounceId = setTimeout(bindToNewElements, 300);
    }).observe(document.documentElement, { childList: true, subtree: true });

})();