// ==UserScript==
// @name Copy LaTeX in Gemini
// @namespace http://tampermonkey.net/
// @version 1.0.0
// @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';
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);
}
/**
* @typedef {Object} CopyDelimiters
* @property {[string, string]} inline - Inline delimiter pair
* @property {[string, string]} display - Display delimiter pair
*/
// Set these to how you want inline and display math to be delimited.
/**
* @type {CopyDelimiters}
*/
const defaultCopyDelimiters = {
inline: ['$', '$'], // alternative: ['\(', '\)']
display: ['$$', '$$'], // alternative: ['\[', '\]']
};
/**
* @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();
});