Copy-as-LaTeX for ChatGPT

Copies any selection containing KaTeX or MathJax as clean LaTeX (plain-text + HTML). Shortcut: ⌃/⌘+C or floating "Copy" button.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Copy-as-LaTeX for ChatGPT
// @namespace    http://tampermonkey.net/
// @version      1.2.0
// @description  Copies any selection containing KaTeX or MathJax as clean LaTeX (plain-text + HTML). Shortcut: ⌃/⌘+C or floating "Copy" button.
// @author       yazanzaid00
// @match        *://*.chatgpt.com/*
// @match        *://chatgpt.com/*
// @match        *://chat.openai.com/*
// @grant        none
// @license      MIT
// ==/UserScript==

function decodeHTMLEntities(text) {
    const parser = new DOMParser();
    return parser.parseFromString(text, 'text/html').documentElement.textContent;
}

function isEditable(node) {
    if (!node) return false;
    for (let n = node; n; n = n.parentNode) {
        if (n.nodeType === Node.ELEMENT_NODE &&
            n.matches('input, textarea, [contenteditable]')) {
            return true;
        }
    }
    return false;
}

const defaultCopyDelimiters = { inline: ['\\(', '\\)'], display: ['\\[', '\\]'] }; // alternative: inline: ['$', '$'], display: ['$$', '$$']
function katexReplaceWithTex(fragment, copyDelimiters = defaultCopyDelimiters) {
    fragment.querySelectorAll('.katex-mathml + .katex-html')
        .forEach(node => node.remove?.() || node.parentNode?.removeChild(node));
    fragment.querySelectorAll('.katex-mathml').forEach(el => {
        const ann = el.querySelector('annotation');
        if (!ann) return;
        el.replaceWith?.(ann) || el.parentNode?.replaceChild(ann, el);
        ann.innerHTML = copyDelimiters.inline[0] + ann.innerHTML + copyDelimiters.inline[1];
    });
    fragment.querySelectorAll('.katex-display annotation').forEach(ann => {
        const { inline, display } = copyDelimiters;
        const body = ann.innerHTML.slice(inline[0].length, -inline[1].length);
        ann.innerHTML = display[0] + body + display[1];
    });
    return fragment;
}

function closestKatex(node) {
    const el = node instanceof Element ? node : node.parentElement;
    return el?.closest('.katex') || null;
}

function mathjaxReplaceWithTex(fragment) {
    // Remove preview & Assistive MathML duplicates
    fragment.querySelectorAll('.MathJax_Preview, mjx-assistive-mathml')
        .forEach(n => n.remove());

    // Replace rendered MathJax blocks with their LaTeX annotation
    fragment.querySelectorAll('annotation[encoding="application/x-tex"], annotation[encoding="LaTeX"]')
        .forEach(ann => {
            const tex = ann.textContent.trim();
            const math = ann.closest('math');
            if (!math) return;
            // display="block" is set either on <math> or on its outer container
            const isDisplay = math.getAttribute('display') === 'block' ||
                ann.closest('mjx-container')?.getAttribute('display') === 'block';
            const node = document.createTextNode(
                (isDisplay ? defaultCopyDelimiters.display[0] : defaultCopyDelimiters.inline[0]) + tex + (isDisplay ? defaultCopyDelimiters.display[1] : defaultCopyDelimiters.inline[1])
            );
            math.replaceWith(node);
        });

    // Remove leftover rendered output
    fragment.querySelectorAll('mjx-container, .MathJax').forEach(el => el.remove());
    return fragment;
}

function replaceMathWithTex(fragment) {
    katexReplaceWithTex(fragment);
    mathjaxReplaceWithTex(fragment);
    return fragment;
}

function getTextContentWithReplacements(node) {
    let text = '';
    if (node && node.childNodes) {
        node.childNodes.forEach(child => {
            let replaced = false;
            if (child.nodeType === Node.TEXT_NODE) text += child.textContent;
            if (child.nodeType === Node.ELEMENT_NODE) {
                const nodeName = child.nodeName.toLowerCase();
                if (nodeName === 'span' &&
                    child.getElementsByTagName('annotation').length > 0) {
                    replaced = true;
                    text += defaultCopyDelimiters.inline[0] + child.getElementsByTagName('annotation')[0].textContent + defaultCopyDelimiters.inline[1];
                }
            }
            if (!replaced &&
                child.nodeType === Node.ELEMENT_NODE &&
                !['script', 'math', 'img'].includes(child.nodeName.toLowerCase())) {
                text += getTextContentWithReplacements(child);
            }
        });
    }
    return text.replace(/\n+/g, '\n').trim();
}

function onCopy(event) {
    /* Skip everything when the focus is in an editable control */
    if (isEditable(event.target || document.activeElement)) return;

    const sel = window.getSelection();
    if (sel.isCollapsed || !event.clipboardData) return;
    const range = sel.getRangeAt(0);
    // Expand selection to whole KaTeX block
    const sK = closestKatex(range.startContainer);
    if (sK) range.setStartBefore(sK);
    const eK = closestKatex(range.endContainer);
    if (eK) range.setEndAfter(eK);

    const frag = range.cloneContents();
    if (!frag.querySelector('.katex-mathml') &&
        !frag.querySelector('annotation[encoding="application/x-tex"], annotation[encoding="LaTeX"]')) {
        return;
    }

    /* HTML clipboard data – remove hidden math markup to avoid duplicates */
    const htmlClone = frag.cloneNode(true);
    htmlClone.querySelectorAll('.katex-mathml, .MathJax_MathML, mjx-assistive-mathml')
        .forEach(el => el.remove());
    htmlClone.querySelectorAll('.MathJax_Preview, script[type*="math/tex"]')
        .forEach(el => el.remove());
    const tmp = document.createElement('div');
    tmp.appendChild(htmlClone);
    event.clipboardData.setData('text/html', tmp.innerHTML);

    /* Plain-text clipboard data – KaTeX + MathJax → TeX */
    const plain = decodeHTMLEntities(replaceMathWithTex(frag).textContent)
        .replace(/\u00A0/g, ' ');
    event.clipboardData.setData('text/plain', plain);

    event.preventDefault();
    event.stopImmediatePropagation();
}

(function () {
    'use strict';

    if (!window.__LaTeXCopierInstalled) {
        // light DOM first
        document.addEventListener('copy', onCopy, true);

        // monkey-patch attachShadow so future roots inherit the listener
        const orig = Element.prototype.attachShadow;
        Element.prototype.attachShadow = function (init = {}) {
            const root = orig.call(this, { ...init, mode: 'open' });
            root.addEventListener('copy', onCopy, true);
            return root;
        };

        // hook all *existing* open shadow roots (rare on ChatGPT but safe)
        document.querySelectorAll('*').forEach(el => {
            if (el.shadowRoot) el.shadowRoot.addEventListener('copy', onCopy, true);
        });

        window.__LaTeXCopierInstalled = true;
    }

    if (!document.getElementById('__latexCopyStyle')) {
        const css = `
      :root{--latex-bg:var(--background-secondary,#fff);--latex-fg:var(--text-primary,#000);--latex-hover:rgba(0,0,0,.05)}
      html.dark{--latex-bg:var(--background-secondary,#2c2c2e);--latex-fg:var(--text-primary,#eee);--latex-hover:rgba(255,255,255,.10)}
      .latex-copy-btn{all:unset;position:absolute;z-index:2147483647;display:none;visibility:visible;padding:4px 10px;border-radius:8px;cursor:pointer;background:var(--latex-bg);color:var(--latex-fg);backdrop-filter:blur(8px);border:1px solid rgba(0,0,0,.14);box-shadow:0 1px 3px rgba(0,0,0,.08);transition:background .15s}
      .latex-copy-btn:hover{background:var(--latex-hover)}`;
        const style = Object.assign(document.createElement('style'), { id: '__latexCopyStyle', textContent: css });
        document.head.appendChild(style);
    }

    const button = Object.assign(document.createElement('button'), {
        textContent: 'Copy', className: 'latex-copy-btn'
    });
    document.body.appendChild(button);

    button.addEventListener('click', () => {
        const sel = window.getSelection();
        if (sel) copySelection(sel);
        button.style.display = 'none';
    });

    let last = '';
    document.addEventListener('mouseup', e => {
        const s = window.getSelection().toString().trim();
        /* Do NOT show the button if the mouse-up happened in an editable area */
        if (s && s !== last && !isEditable(e.target)) {
            button.style.left = `${e.pageX + 5}px`;
            button.style.top = `${e.pageY + 5}px`;
            button.style.display = 'block';
            last = s;
        } else {
            button.style.display = 'none';
            last = '';
        }
    });

    function copySelection(selection) {
        const range = selection.getRangeAt(0);
        const sK = closestKatex(range.startContainer);
        if (sK) range.setStartBefore(sK);
        const eK = closestKatex(range.endContainer);
        if (eK) range.setEndAfter(eK);

        const frag = range.cloneContents();
        let text;
        if (frag.querySelector('.katex-mathml') ||
            frag.querySelector('annotation[encoding="application/x-tex"], annotation[encoding="LaTeX"]')) {
            text = replaceMathWithTex(frag).textContent;
        } else {
            text = getTextContentWithReplacements(frag);
        }

        text = text.replace(/\\bm\{([^}]+)\}/g, '\\mathbf{$1}')
                   .replace(/\\bigg\{\\\|\}/g, '\\Bigl|')
                   .replace(/\\big\{\\\|\}/g, '\\big|')
                   .replace(/\u00A0/g, ' ');
        navigator.clipboard.writeText(decodeHTMLEntities(text));
    }

    document.addEventListener('keydown', e => {
        if (!(e.ctrlKey || e.metaKey) || e.key.toLowerCase() !== 'c') return;

        const sel = window.getSelection();
        if (!sel || sel.isCollapsed || isEditable(sel.anchorNode)) return;

        /* Intercept only when the fragment actually contains math */
        const frag = sel.getRangeAt(0).cloneContents();
        const hasMath = frag.querySelector('.katex-mathml') ||
                        frag.querySelector('annotation[encoding="application/x-tex"], annotation[encoding="LaTeX"]');
        if (!hasMath) return;            // plain text → let the browser handle it

        e.preventDefault();              // math present → use custom copier
        copySelection(sel);
    }, true);
})();