LatexCopier

一键复制网页数学公式到Word/LaTeX | 智能悬停预览 | 支持维基百科/知乎/豆包/ChatGPT/stackexchange/DeepSeek等主流网站 | 自动识别并转换LaTeX/MathML格式 | 可视化反馈提示

  1. // ==UserScript==
  2. // @name LatexCopier
  3. // @name:zh-CN Latex公式复制助手 支持一键复制到Word文档/支持直接复制Latex代码(一键切换) 支持ChatGPT 维基百科 豆包 DeepSeek stackexchange 等主流网站
  4. // @namespace https://github.com/BakaDream/LatexCopier
  5. // @version 1.0.0
  6. // @license GPLv3
  7. // @description 一键复制网页数学公式到Word/LaTeX | 智能悬停预览 | 支持维基百科/知乎/豆包/ChatGPT/stackexchange/DeepSeek等主流网站 | 自动识别并转换LaTeX/MathML格式 | 可视化反馈提示
  8. // @author BakaDream
  9. // @match *://*.wikipedia.org/*
  10. // @match *://*.zhihu.com/*
  11. // @match *://*.chatgpt.com/*
  12. // @match *://*.stackexchange.com/*
  13. // @match *://*.doubao.com/*
  14. // @match *://*.deepseek.com/*
  15. // @require https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js
  16. // @grant GM_registerMenuCommand
  17. // @grant GM_setValue
  18. // @grant GM_getValue
  19. // ==/UserScript==
  20.  
  21. (function() {
  22. 'use strict';
  23.  
  24. // ========================
  25. // 配置中心
  26. // ========================
  27. const CONFIG = {
  28. STORAGE_KEY: 'latexCopyMode',
  29. MODES: {
  30. WORD: {
  31. id: 'word',
  32. name: 'Word公式模式',
  33. desc: '生成MathML,粘贴到Word自动转为公式',
  34. feedback: '公式已复制 ✓ 可粘贴到Word'
  35. },
  36. RAW: {
  37. id: 'raw',
  38. name: '原始LaTeX模式',
  39. desc: '直接复制LaTeX源代码',
  40. feedback: 'LaTeX代码已复制 ✓'
  41. }
  42. },
  43. DEFAULT_MODE: 'word',
  44. SITE_TARGETS: {
  45. 'wikipedia.org': {
  46. selector: 'span.mwe-math-element',
  47. extractor: el => el.querySelector('math')?.getAttribute('alttext')
  48. },
  49. 'zhihu.com': {
  50. selector: 'span.ztext-math',
  51. extractor: el => el.getAttribute('data-tex')
  52. },
  53. 'doubao.com': {
  54. selector: 'span.math-inline',
  55. extractor: el => el.getAttribute('data-custom-copy-text')
  56. },
  57. 'chatgpt.com': {
  58. selector: 'span.katex',
  59. extractor: el => el.querySelector('annotation')?.textContent
  60. },
  61. 'stackexchange.com': {
  62. selector: 'span.math-container',
  63. extractor: el => el.querySelector('script')?.textContent
  64. },
  65. 'deepseek.com': {
  66. selector: 'span.katex',
  67. extractor: el => el.querySelector('annotation')?.textContent
  68. }
  69.  
  70. }
  71. };
  72.  
  73. // ========================
  74. // 工具模块
  75. // ========================
  76. const Utils = {
  77. getSiteConfig(url) {
  78. for (const [domain, config] of Object.entries(CONFIG.SITE_TARGETS)) {
  79. if (url.includes(domain)) return config;
  80. }
  81. return null;
  82. },
  83.  
  84. copyToClipboard(text) {
  85. const textarea = document.createElement('textarea');
  86. textarea.style.position = 'fixed';
  87. textarea.style.left = '-9999px';
  88. textarea.style.opacity = 0;
  89. textarea.value = text;
  90. document.body.appendChild(textarea);
  91. textarea.select();
  92.  
  93. let success = false;
  94. try {
  95. success = document.execCommand('copy');
  96. } catch (err) {
  97. console.error('[LaTeX助手] 复制失败:', err);
  98. } finally {
  99. document.body.removeChild(textarea);
  100. }
  101. return success;
  102. }
  103. };
  104.  
  105. // ========================
  106. // 主功能模块
  107. // ========================
  108. const LaTeXCopyHelper = {
  109. currentMode: null,
  110. tooltip: null,
  111. feedback: null,
  112. activeElements: new Set(),
  113.  
  114. // ===== 初始化 =====
  115. init() {
  116. this.currentMode = this._loadMode();
  117. this._initStyles(); // 合并样式配置和注入
  118. this._initUIElements();
  119. this._setupEventListeners();
  120. this._registerMenuCommand();
  121. },
  122.  
  123. // ===== 模式管理 =====
  124. _loadMode() {
  125. const savedMode = GM_getValue(CONFIG.STORAGE_KEY);
  126. return Object.values(CONFIG.MODES).find(m => m.id === savedMode) || CONFIG.MODES.WORD;
  127. },
  128.  
  129. _registerMenuCommand() {
  130. GM_registerMenuCommand(
  131. `切换模式 | 当前: ${this.currentMode.name}`,
  132. () => this._toggleMode()
  133. );
  134. },
  135.  
  136. _toggleMode() {
  137. const newMode = this.currentMode === CONFIG.MODES.WORD
  138. ? CONFIG.MODES.RAW
  139. : CONFIG.MODES.WORD;
  140.  
  141. GM_setValue(CONFIG.STORAGE_KEY, newMode.id);
  142. this.currentMode = newMode;
  143.  
  144. this._showFeedback(`已切换为: ${newMode.name}\n${newMode.desc}`, true);
  145. setTimeout(() => location.reload(), 300);
  146. },
  147.  
  148. // ===== UI管理 =====
  149. _initStyles() {
  150. const STYLES = {
  151. // 公式悬停效果
  152. HOVER: {
  153. background: 'rgba(100, 180, 255, 0.15)',
  154. boxShadow: '0 0 8px rgba(0, 120, 215, 0.3)',
  155. transition: 'all 0.3s ease'
  156. },
  157. // 工具提示
  158. TOOLTIP: {
  159. background: 'rgba(0, 0, 0, 0.85)',
  160. color: 'white',
  161. padding: '8px 12px',
  162. borderRadius: '4px',
  163. maxWidth: '400px',
  164. fontSize: '13px',
  165. offset: 10,
  166. arrowSize: '5px'
  167. },
  168. // 操作反馈
  169. FEEDBACK: {
  170. background: '#4CAF50',
  171. errorBackground: '#f44336',
  172. color: 'white',
  173. duration: 1500,
  174. position: 'bottom: 20%; left: 50%'
  175. }
  176. };
  177.  
  178. const style = document.createElement('style');
  179. style.textContent = `
  180. /* 公式元素悬停效果 */
  181. [data-latex-copy] {
  182. cursor: pointer;
  183. transition: ${STYLES.HOVER.transition};
  184. border-radius: 3px;
  185. padding: 2px;
  186. position: relative;
  187. }
  188. [data-latex-copy]:hover {
  189. background: ${STYLES.HOVER.background} !important;
  190. box-shadow: ${STYLES.HOVER.boxShadow} !important;
  191. }
  192.  
  193. /* 智能工具提示 */
  194. .latex-helper-tooltip {
  195. position: fixed;
  196. background: ${STYLES.TOOLTIP.background};
  197. color: ${STYLES.TOOLTIP.color};
  198. padding: ${STYLES.TOOLTIP.padding};
  199. border-radius: ${STYLES.TOOLTIP.borderRadius};
  200. max-width: min(${STYLES.TOOLTIP.maxWidth}, 90vw);
  201. font-size: ${STYLES.TOOLTIP.fontSize};
  202. z-index: 9999;
  203. opacity: 0;
  204. transform: translateY(5px);
  205. transition: all 0.2s ease;
  206. pointer-events: none;
  207. word-break: break-word;
  208. }
  209. .latex-helper-tooltip.visible {
  210. opacity: 1;
  211. transform: translateY(0);
  212. }
  213. .latex-helper-tooltip::after {
  214. content: '';
  215. position: absolute;
  216. left: 10px;
  217. border-width: ${STYLES.TOOLTIP.arrowSize};
  218. border-style: solid;
  219. border-color: transparent;
  220. }
  221. .latex-helper-tooltip.top-direction::after {
  222. bottom: calc(-${STYLES.TOOLTIP.arrowSize} * 2);
  223. border-top-color: ${STYLES.TOOLTIP.background};
  224. }
  225. .latex-helper-tooltip.bottom-direction::after {
  226. top: calc(-${STYLES.TOOLTIP.arrowSize} * 2);
  227. border-bottom-color: ${STYLES.TOOLTIP.background};
  228. }
  229.  
  230. /* 操作反馈提示 */
  231. .latex-helper-feedback {
  232. position: fixed;
  233. ${STYLES.FEEDBACK.position};
  234. transform: translateX(-50%);
  235. background: ${STYLES.FEEDBACK.background};
  236. color: ${STYLES.FEEDBACK.color};
  237. padding: 10px 20px;
  238. border-radius: 4px;
  239. z-index: 10000;
  240. opacity: 0;
  241. transition: all 0.3s;
  242. text-align: center;
  243. white-space: pre-wrap;
  244. }
  245. .latex-helper-feedback.error {
  246. background: ${STYLES.FEEDBACK.errorBackground} !important;
  247. }
  248. .latex-helper-feedback.visible {
  249. opacity: 1;
  250. transform: translateX(-50%) translateY(-10px);
  251. }
  252. `;
  253. document.head.appendChild(style);
  254. },
  255.  
  256. _initUIElements() {
  257. this.tooltip = document.createElement('div');
  258. this.tooltip.className = 'latex-helper-tooltip';
  259. document.body.appendChild(this.tooltip);
  260.  
  261. this.feedback = document.createElement('div');
  262. this.feedback.className = 'latex-helper-feedback';
  263. document.body.appendChild(this.feedback);
  264. },
  265.  
  266. // ===== 事件处理 =====
  267. _setupEventListeners() {
  268. document.addEventListener('mouseover', (e) => this._handleHover(e));
  269. document.addEventListener('mouseout', (e) => this._handleMouseOut(e));
  270. document.addEventListener('dblclick', (e) => this._handleDoubleClick(e), true);
  271.  
  272. new MutationObserver((mutations) => this._handleMutations(mutations))
  273. .observe(document.body, { childList: true, subtree: true });
  274. },
  275.  
  276. _handleHover(e) {
  277. const siteConfig = Utils.getSiteConfig(window.location.href);
  278. const element = e.target.closest(siteConfig?.selector || '');
  279. if (!element) return this._hideTooltip();
  280.  
  281. element.setAttribute('data-latex-copy', 'true');
  282. this.activeElements.add(element);
  283.  
  284. const latex = siteConfig.extractor(element);
  285. if (latex) this._showSmartTooltip(latex, element);
  286. },
  287.  
  288. _handleMouseOut(e) {
  289. const element = e.target.closest('[data-latex-copy]');
  290. if (element) {
  291. element.style.background = '';
  292. element.style.boxShadow = '';
  293. }
  294. this._hideTooltip();
  295. },
  296.  
  297. _handleDoubleClick(e) {
  298. const siteConfig = Utils.getSiteConfig(window.location.href);
  299. const element = e.target.closest(siteConfig?.selector || '');
  300. if (!element) return;
  301.  
  302. const latex = siteConfig.extractor(element);
  303. if (!latex) return;
  304.  
  305. this._copyFormula(latex);
  306. e.preventDefault();
  307. e.stopPropagation();
  308. },
  309.  
  310. _handleMutations(mutations) {
  311. const siteConfig = Utils.getSiteConfig(window.location.href);
  312. if (!siteConfig) return;
  313.  
  314. mutations.forEach((mutation) => {
  315. mutation.addedNodes.forEach((node) => {
  316. if (node.nodeType === 1 && node.matches(siteConfig.selector)) {
  317. node.setAttribute('data-latex-copy', 'true');
  318. this.activeElements.add(node);
  319. }
  320. });
  321. });
  322. },
  323.  
  324. // ===== 核心功能 =====
  325. _showSmartTooltip(text, element) {
  326. this.tooltip.textContent = text;
  327.  
  328. const rect = element.getBoundingClientRect();
  329. const tooltipHeight = this.tooltip.offsetHeight;
  330. const viewportHeight = window.innerHeight;
  331.  
  332. // 优先显示在上方
  333. if (rect.top - tooltipHeight - 10 > 0) {
  334. this.tooltip.style.top = `${rect.top - tooltipHeight - 10}px`;
  335. this.tooltip.style.left = `${rect.left}px`;
  336. this.tooltip.className = 'latex-helper-tooltip top-direction visible';
  337. }
  338. // 上方空间不足时显示在下方
  339. else if (rect.bottom + tooltipHeight + 10 < viewportHeight) {
  340. this.tooltip.style.top = `${rect.bottom + 10}px`;
  341. this.tooltip.style.left = `${rect.left}px`;
  342. this.tooltip.className = 'latex-helper-tooltip bottom-direction visible';
  343. }
  344. // 极端情况:显示在元素旁边
  345. else {
  346. this.tooltip.style.top = `${rect.top}px`;
  347. this.tooltip.style.left = `${rect.right + 10}px`;
  348. this.tooltip.className = 'latex-helper-tooltip visible';
  349. }
  350. },
  351.  
  352. _hideTooltip() {
  353. this.tooltip.className = 'latex-helper-tooltip';
  354. },
  355.  
  356. async _copyFormula(latex) {
  357. if (this.currentMode === CONFIG.MODES.RAW) {
  358. this._copyRawLatex(latex);
  359. } else {
  360. await this._copyAsMathML(latex);
  361. }
  362. },
  363.  
  364. _copyRawLatex(latex) {
  365. const success = Utils.copyToClipboard(latex);
  366. this._showFeedback(
  367. success ? this.currentMode.feedback : '复制失败 ✗',
  368. success
  369. );
  370. },
  371.  
  372. async _copyAsMathML(latex) {
  373. try {
  374. MathJax.texReset();
  375. const mathML = await MathJax.tex2mmlPromise(latex);
  376. const success = Utils.copyToClipboard(mathML);
  377. this._showFeedback(
  378. success ? this.currentMode.feedback : '转换失败 ✗',
  379. success
  380. );
  381. } catch (error) {
  382. console.error('[LaTeX助手] MathJax转换错误:', error);
  383. this._showFeedback('公式转换失败,请尝试原始模式', false);
  384. }
  385. },
  386.  
  387. _showFeedback(message, isSuccess) {
  388. this.feedback.textContent = message;
  389. this.feedback.className = `latex-helper-feedback ${isSuccess ? '' : 'error'} visible`;
  390. setTimeout(() => {
  391. this.feedback.className = 'latex-helper-feedback';
  392. }, 1500);
  393. }
  394. };
  395.  
  396. // ========================
  397. // 启动脚本
  398. // ========================
  399. LaTeXCopyHelper.init();
  400. })();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址