// ==UserScript==
// @name LatexCopier
// @name:zh-CN Latex公式复制助手 支持一键复制到Word文档/支持直接复制Latex代码(一键切换) 支持ChatGPT 维基百科 豆包 DeepSeek stackexchange 等主流网站
// @namespace https://github.com/BakaDream/LatexCopier
// @version 1.0.0
// @license GPLv3
// @description 一键复制网页数学公式到Word/LaTeX | 智能悬停预览 | 支持维基百科/知乎/豆包/ChatGPT/stackexchange/DeepSeek等主流网站 | 自动识别并转换LaTeX/MathML格式 | 可视化反馈提示
// @author BakaDream
// @match *://*.wikipedia.org/*
// @match *://*.zhihu.com/*
// @match *://*.chatgpt.com/*
// @match *://*.stackexchange.com/*
// @match *://*.doubao.com/*
// @match *://*.deepseek.com/*
// @require https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js
// @grant GM_registerMenuCommand
// @grant GM_setValue
// @grant GM_getValue
// ==/UserScript==
(function() {
'use strict';
// ========================
// 配置中心
// ========================
const CONFIG = {
STORAGE_KEY: 'latexCopyMode',
MODES: {
WORD: {
id: 'word',
name: 'Word公式模式',
desc: '生成MathML,粘贴到Word自动转为公式',
feedback: '公式已复制 ✓ 可粘贴到Word'
},
RAW: {
id: 'raw',
name: '原始LaTeX模式',
desc: '直接复制LaTeX源代码',
feedback: 'LaTeX代码已复制 ✓'
}
},
DEFAULT_MODE: 'word',
SITE_TARGETS: {
'wikipedia.org': {
selector: 'span.mwe-math-element',
extractor: el => el.querySelector('math')?.getAttribute('alttext')
},
'zhihu.com': {
selector: 'span.ztext-math',
extractor: el => el.getAttribute('data-tex')
},
'doubao.com': {
selector: 'span.math-inline',
extractor: el => el.getAttribute('data-custom-copy-text')
},
'chatgpt.com': {
selector: 'span.katex',
extractor: el => el.querySelector('annotation')?.textContent
},
'stackexchange.com': {
selector: 'span.math-container',
extractor: el => el.querySelector('script')?.textContent
},
'deepseek.com': {
selector: 'span.katex',
extractor: el => el.querySelector('annotation')?.textContent
}
}
};
// ========================
// 工具模块
// ========================
const Utils = {
getSiteConfig(url) {
for (const [domain, config] of Object.entries(CONFIG.SITE_TARGETS)) {
if (url.includes(domain)) return config;
}
return null;
},
copyToClipboard(text) {
const textarea = document.createElement('textarea');
textarea.style.position = 'fixed';
textarea.style.left = '-9999px';
textarea.style.opacity = 0;
textarea.value = text;
document.body.appendChild(textarea);
textarea.select();
let success = false;
try {
success = document.execCommand('copy');
} catch (err) {
console.error('[LaTeX助手] 复制失败:', err);
} finally {
document.body.removeChild(textarea);
}
return success;
}
};
// ========================
// 主功能模块
// ========================
const LaTeXCopyHelper = {
currentMode: null,
tooltip: null,
feedback: null,
activeElements: new Set(),
// ===== 初始化 =====
init() {
this.currentMode = this._loadMode();
this._initStyles(); // 合并样式配置和注入
this._initUIElements();
this._setupEventListeners();
this._registerMenuCommand();
},
// ===== 模式管理 =====
_loadMode() {
const savedMode = GM_getValue(CONFIG.STORAGE_KEY);
return Object.values(CONFIG.MODES).find(m => m.id === savedMode) || CONFIG.MODES.WORD;
},
_registerMenuCommand() {
GM_registerMenuCommand(
`切换模式 | 当前: ${this.currentMode.name}`,
() => this._toggleMode()
);
},
_toggleMode() {
const newMode = this.currentMode === CONFIG.MODES.WORD
? CONFIG.MODES.RAW
: CONFIG.MODES.WORD;
GM_setValue(CONFIG.STORAGE_KEY, newMode.id);
this.currentMode = newMode;
this._showFeedback(`已切换为: ${newMode.name}\n${newMode.desc}`, true);
setTimeout(() => location.reload(), 300);
},
// ===== UI管理 =====
_initStyles() {
const STYLES = {
// 公式悬停效果
HOVER: {
background: 'rgba(100, 180, 255, 0.15)',
boxShadow: '0 0 8px rgba(0, 120, 215, 0.3)',
transition: 'all 0.3s ease'
},
// 工具提示
TOOLTIP: {
background: 'rgba(0, 0, 0, 0.85)',
color: 'white',
padding: '8px 12px',
borderRadius: '4px',
maxWidth: '400px',
fontSize: '13px',
offset: 10,
arrowSize: '5px'
},
// 操作反馈
FEEDBACK: {
background: '#4CAF50',
errorBackground: '#f44336',
color: 'white',
duration: 1500,
position: 'bottom: 20%; left: 50%'
}
};
const style = document.createElement('style');
style.textContent = `
/* 公式元素悬停效果 */
[data-latex-copy] {
cursor: pointer;
transition: ${STYLES.HOVER.transition};
border-radius: 3px;
padding: 2px;
position: relative;
}
[data-latex-copy]:hover {
background: ${STYLES.HOVER.background} !important;
box-shadow: ${STYLES.HOVER.boxShadow} !important;
}
/* 智能工具提示 */
.latex-helper-tooltip {
position: fixed;
background: ${STYLES.TOOLTIP.background};
color: ${STYLES.TOOLTIP.color};
padding: ${STYLES.TOOLTIP.padding};
border-radius: ${STYLES.TOOLTIP.borderRadius};
max-width: min(${STYLES.TOOLTIP.maxWidth}, 90vw);
font-size: ${STYLES.TOOLTIP.fontSize};
z-index: 9999;
opacity: 0;
transform: translateY(5px);
transition: all 0.2s ease;
pointer-events: none;
word-break: break-word;
}
.latex-helper-tooltip.visible {
opacity: 1;
transform: translateY(0);
}
.latex-helper-tooltip::after {
content: '';
position: absolute;
left: 10px;
border-width: ${STYLES.TOOLTIP.arrowSize};
border-style: solid;
border-color: transparent;
}
.latex-helper-tooltip.top-direction::after {
bottom: calc(-${STYLES.TOOLTIP.arrowSize} * 2);
border-top-color: ${STYLES.TOOLTIP.background};
}
.latex-helper-tooltip.bottom-direction::after {
top: calc(-${STYLES.TOOLTIP.arrowSize} * 2);
border-bottom-color: ${STYLES.TOOLTIP.background};
}
/* 操作反馈提示 */
.latex-helper-feedback {
position: fixed;
${STYLES.FEEDBACK.position};
transform: translateX(-50%);
background: ${STYLES.FEEDBACK.background};
color: ${STYLES.FEEDBACK.color};
padding: 10px 20px;
border-radius: 4px;
z-index: 10000;
opacity: 0;
transition: all 0.3s;
text-align: center;
white-space: pre-wrap;
}
.latex-helper-feedback.error {
background: ${STYLES.FEEDBACK.errorBackground} !important;
}
.latex-helper-feedback.visible {
opacity: 1;
transform: translateX(-50%) translateY(-10px);
}
`;
document.head.appendChild(style);
},
_initUIElements() {
this.tooltip = document.createElement('div');
this.tooltip.className = 'latex-helper-tooltip';
document.body.appendChild(this.tooltip);
this.feedback = document.createElement('div');
this.feedback.className = 'latex-helper-feedback';
document.body.appendChild(this.feedback);
},
// ===== 事件处理 =====
_setupEventListeners() {
document.addEventListener('mouseover', (e) => this._handleHover(e));
document.addEventListener('mouseout', (e) => this._handleMouseOut(e));
document.addEventListener('dblclick', (e) => this._handleDoubleClick(e), true);
new MutationObserver((mutations) => this._handleMutations(mutations))
.observe(document.body, { childList: true, subtree: true });
},
_handleHover(e) {
const siteConfig = Utils.getSiteConfig(window.location.href);
const element = e.target.closest(siteConfig?.selector || '');
if (!element) return this._hideTooltip();
element.setAttribute('data-latex-copy', 'true');
this.activeElements.add(element);
const latex = siteConfig.extractor(element);
if (latex) this._showSmartTooltip(latex, element);
},
_handleMouseOut(e) {
const element = e.target.closest('[data-latex-copy]');
if (element) {
element.style.background = '';
element.style.boxShadow = '';
}
this._hideTooltip();
},
_handleDoubleClick(e) {
const siteConfig = Utils.getSiteConfig(window.location.href);
const element = e.target.closest(siteConfig?.selector || '');
if (!element) return;
const latex = siteConfig.extractor(element);
if (!latex) return;
this._copyFormula(latex);
e.preventDefault();
e.stopPropagation();
},
_handleMutations(mutations) {
const siteConfig = Utils.getSiteConfig(window.location.href);
if (!siteConfig) return;
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === 1 && node.matches(siteConfig.selector)) {
node.setAttribute('data-latex-copy', 'true');
this.activeElements.add(node);
}
});
});
},
// ===== 核心功能 =====
_showSmartTooltip(text, element) {
this.tooltip.textContent = text;
const rect = element.getBoundingClientRect();
const tooltipHeight = this.tooltip.offsetHeight;
const viewportHeight = window.innerHeight;
// 优先显示在上方
if (rect.top - tooltipHeight - 10 > 0) {
this.tooltip.style.top = `${rect.top - tooltipHeight - 10}px`;
this.tooltip.style.left = `${rect.left}px`;
this.tooltip.className = 'latex-helper-tooltip top-direction visible';
}
// 上方空间不足时显示在下方
else if (rect.bottom + tooltipHeight + 10 < viewportHeight) {
this.tooltip.style.top = `${rect.bottom + 10}px`;
this.tooltip.style.left = `${rect.left}px`;
this.tooltip.className = 'latex-helper-tooltip bottom-direction visible';
}
// 极端情况:显示在元素旁边
else {
this.tooltip.style.top = `${rect.top}px`;
this.tooltip.style.left = `${rect.right + 10}px`;
this.tooltip.className = 'latex-helper-tooltip visible';
}
},
_hideTooltip() {
this.tooltip.className = 'latex-helper-tooltip';
},
async _copyFormula(latex) {
if (this.currentMode === CONFIG.MODES.RAW) {
this._copyRawLatex(latex);
} else {
await this._copyAsMathML(latex);
}
},
_copyRawLatex(latex) {
const success = Utils.copyToClipboard(latex);
this._showFeedback(
success ? this.currentMode.feedback : '复制失败 ✗',
success
);
},
async _copyAsMathML(latex) {
try {
MathJax.texReset();
const mathML = await MathJax.tex2mmlPromise(latex);
const success = Utils.copyToClipboard(mathML);
this._showFeedback(
success ? this.currentMode.feedback : '转换失败 ✗',
success
);
} catch (error) {
console.error('[LaTeX助手] MathJax转换错误:', error);
this._showFeedback('公式转换失败,请尝试原始模式', false);
}
},
_showFeedback(message, isSuccess) {
this.feedback.textContent = message;
this.feedback.className = `latex-helper-feedback ${isSuccess ? '' : 'error'} visible`;
setTimeout(() => {
this.feedback.className = 'latex-helper-feedback';
}, 1500);
}
};
// ========================
// 启动脚本
// ========================
LaTeXCopyHelper.init();
})();