Copy-as-LaTeX for ChatGPT

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

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 or Violentmonkey 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         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);
})();