Copy-as-LaTeX for ChatGPT

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

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