Copy LaTeX in Gemini

Features: 1. Click formula to copy LaTeX 2. Copy text with formula converted to LaTeX

// ==UserScript==
// @name         Copy LaTeX in Gemini
// @namespace    http://tampermonkey.net/
// @version      1.0.1
// @description  Features: 1. Click formula to copy LaTeX 2. Copy text with formula converted to LaTeX
// @author       Cesar
// @match        https://gemini.google.com/app/*
// @grant        none
// @license      MIT
// @run-at       document-start
// ==/UserScript==

'use strict';

/**
 * @description Set these to how you want inline and display math to be delimited.
 */
const defaultCopyDelimiters = {
    inline: ['$', '$'],    // alternative: ['\(', '\)']
    display: ['\\[ ', ' \\]'], // alternative: ['\[', '\]']
};

const allKatex = {};

// 1. 先尝试 Hook window.katex 的赋值
let originalKatex = window.katex;
// 2. 如果 katex 已存在,直接 Hook
if (originalKatex) {
    hookKatexRender(originalKatex);
} else {
    Object.defineProperty(window, 'katex', {
        set: function(newKatex) {
            console.log('Detected katex assignment, hooking render...');
            originalKatex = newKatex;
            hookKatexRender(originalKatex); // 对新 katex 对象进行 Hook
            return originalKatex;
        },
        get: function() {
            return originalKatex;
        },
        configurable: true
    });
}

// 核心 Hook 函数
function hookKatexRender(katexObj) {
    if (!katexObj || typeof katexObj.render !== 'function') {
        console.warn('katex.render not found, skipping hook');
        return;
    }

    const originalRender = katexObj.render;
    katexObj.render = new Proxy(originalRender, {
        apply: function(target, thisArg, args) {
            let result = target.apply(thisArg, args);
            if (args.length >= 2) {
                const latexStr = args[0];
                const element = args[1];
                const katexHtml = element.querySelector('.katex-html');
                if (element instanceof Element && katexHtml !== null) {
                    allKatex[katexHtml.outerHTML] = latexStr;
                } else {
                    console.warn('katex.render: 2nd arg is not a DOM element');
                }
            }
            return result;
        }
    });
    console.log('Successfully hooked katex.render');
}

// 添加点击事件监听器
function handleKatexClick(event) {
    const katexHtmlElement = event.target.closest('.katex-html');
    if (katexHtmlElement) {
        const latexFormula = allKatex[katexHtmlElement.outerHTML];
        if (latexFormula) {
            navigator.clipboard.writeText(latexFormula).then(() => {
                console.log('LaTeX formula copied to clipboard:', latexFormula);
                // 可选:添加视觉反馈
                const originalNone = katexHtmlElement.cloneNode(true);
                katexHtmlElement.textContent = 'Copied!';
                setTimeout(() => {
                    katexHtmlElement.replaceWith(originalNone);
                }, 700);
            }).catch(err => {
                console.error('Failed to copy LaTeX formula:', err);
            });
        } else {
            console.warn('No LaTeX formula found for this element');
        }
    }
}

// 监听文档加载完成后添加点击事件
if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', () => {
        document.addEventListener('click', handleKatexClick);
    });
} else {
    document.addEventListener('click', handleKatexClick);
}


/**
 * @description Replace .katex elements with their TeX source (<annotation> element).
 * @param {DocumentFragment} fragment
 * @param {CopyDelimiters} copyDelimiters
 * @returns {DocumentFragment}
 */
function katexReplaceWithTex(
    fragment,
    copyDelimiters = defaultCopyDelimiters
) {
    // Replace .katex-html elements with their latex (by creating a new annotation element)
    // descendant, with inline delimiters.
    const katexHtml = fragment.querySelectorAll('.katex-html');
    for (let i = 0; i < katexHtml.length; i++) {
        const element = katexHtml[i];
        const texSource = document.createElement('annotation');
        if (element.outerHTML && allKatex[element.outerHTML]) {
            texSource.textContent = allKatex[element.outerHTML];
        } else {
            continue;
        }
        if (texSource) {
            if (element.replaceWith) {
                element.replaceWith(texSource);
            } else if (element.parentNode) {
                element.parentNode.replaceChild(texSource, element);
            }
            if (texSource.closest('.katex-display')) {
                texSource.textContent = `\n${copyDelimiters.display[0]}${texSource.textContent}${copyDelimiters.display[1]}\n`;
            } else {
                texSource.textContent = `${copyDelimiters.inline[0]}${texSource.textContent}${copyDelimiters.inline[1]}`;
            }
        }
    }

    return fragment;
}


/**
 * @description Return <div class="katex"> element containing node, or null if not found.
 * @param {Node} node
 * @returns {Element|null}
 */
function closestKatex(node) {
    // If node is a Text Node, for example, go up to containing Element,
    // where we can apply the `closest` method.
    const element =
        (node instanceof Element ? node : node.parentElement);
    return element && element.closest('.katex');
}

/**
 * @description Global copy handler to modify behavior on/within .katex elements.
 * @param {ClipboardEvent} event
 */
document.addEventListener('copy', function(event) {
    const selection = window.getSelection();
    if (!selection || selection.isCollapsed || !event.clipboardData) {
        return; // default action OK if selection is empty or unchangeable
    }
    const clipboardData = event.clipboardData;
    const range = selection.getRangeAt(0);

    // When start point is within a formula, expand to entire formula.
    const startKatex = closestKatex(range.startContainer);
    if (startKatex) {
        range.setStartBefore(startKatex);
    }

    // Similarly, when end point is within a formula, expand to entire formula.
    const endKatex = closestKatex(range.endContainer);
    if (endKatex) {
        range.setEndAfter(endKatex);
    }

    const fragment = range.cloneContents();
    if (!fragment.querySelector('.katex-html')) {
        return; // default action OK if no .katex-mathml elements
    }

    const htmlContents = Array.prototype.map.call(fragment.childNodes,
        (el) => (el instanceof Text ? el.textContent : el.outerHTML)
    ).join('');

    // Preserve usual HTML copy/paste behavior.
    clipboardData.setData('text/html', htmlContents);
    // Rewrite plain-text version.
    const textContent = katexReplaceWithTex(fragment).textContent;
    if (textContent) {
        clipboardData.setData('text/plain', textContent);
    }

    // 用于 debug 哪里的代码导致 setData 被覆盖
    // clipboardData.setData = new Proxy(clipboardData.setData, {
    //     apply: function(target, thisArg, args) {
    //         console.log('clipboardData.setData', args);
    //         return target.apply(thisArg, args);
    //     }
    // });
    // Prevent normal copy handling.
    event.preventDefault();
    // Gemini 的 copy 事件中会 setData,导致这里的 setData 被覆盖,所以需要 stopImmediatePropagation
    event.stopImmediatePropagation();
});