TexCopyer

双击网页中的LaTex公式,将其复制到剪切板。支持主流AI网站、知乎、IEEE等等

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==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 });

})();