Text Explainer

Explain selected text using LLM

当前为 2025-03-05 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Text Explainer
  3. // @namespace http://tampermonkey.net/
  4. // @version 0.2.6
  5. // @description Explain selected text using LLM
  6. // @author RoCry
  7. // @icon data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iODAwcHgiIGhlaWdodD0iODAwcHgiIHZpZXdCb3g9IjAgMCAxOTIgMTkyIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9Im5vbmUiPjxjaXJjbGUgY3g9IjExNiIgY3k9Ijc2IiByPSI1NCIgc3Ryb2tlPSIjMDAwMDAwIiBzdHJva2Utd2lkdGg9IjEyIi8+PHBhdGggc3Ryb2tlPSIjMDAwMDAwIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIHN0cm9rZS13aWR0aD0iMTIiIGQ9Ik04Ni41IDEyMS41IDQxIDE2N2MtNC40MTggNC40MTgtMTEuNTgyIDQuNDE4LTE2IDB2MGMtNC40MTgtNC40MTgtNC40MTgtMTEuNTgyIDAtMTZsNDQuNS00NC41TTkyIDYybDEyIDMyIDEyLTMyIDEyIDMyIDEyLTMyIi8+PC9zdmc+
  8. // @match *://*/*
  9. // @grant GM_setValue
  10. // @grant GM_getValue
  11. // @grant GM_addStyle
  12. // @grant GM_xmlhttpRequest
  13. // @grant GM_registerMenuCommand
  14. // @connect generativelanguage.googleapis.com
  15. // @connect *
  16. // @run-at document-end
  17. // @inject-into content
  18. // @require https://update.greasyfork.org/scripts/528704/1547031/SmolLLM.js
  19. // @require https://update.greasyfork.org/scripts/528703/1546610/SimpleBalancer.js
  20. // @require https://update.greasyfork.org/scripts/528763/1547460/Text%20Explainer%20Settings.js
  21. // @require https://update.greasyfork.org/scripts/528822/1547803/Selection%20Context.js
  22. // @license MIT
  23. // ==/UserScript==
  24.  
  25. (function () {
  26. 'use strict';
  27.  
  28. // Initialize settings manager with extended default config
  29. const settingsManager = new TextExplainerSettings({
  30. model: "gemini-2.0-flash",
  31. apiKey: null,
  32. baseUrl: "https://generativelanguage.googleapis.com",
  33. provider: "gemini",
  34. language: "Chinese", // Default language
  35. shortcut: {
  36. key: "d",
  37. ctrlKey: false,
  38. altKey: true,
  39. shiftKey: false,
  40. metaKey: false
  41. },
  42. floatingButton: {
  43. enabled: true,
  44. size: "medium",
  45. position: "bottom-right"
  46. },
  47. });
  48.  
  49. // Get current configuration
  50. let config = settingsManager.getAll();
  51.  
  52. // Initialize SmolLLM
  53. let llm;
  54. try {
  55. llm = new SmolLLM();
  56. } catch (error) {
  57. console.error('Failed to initialize SmolLLM:', error);
  58. llm = null;
  59. }
  60.  
  61. // Check if device is touch-enabled
  62. const isTouchDevice = () => {
  63. return ('ontouchstart' in window) ||
  64. (navigator.maxTouchPoints > 0) ||
  65. (navigator.msMaxTouchPoints > 0);
  66. };
  67.  
  68. const isIOS = () => {
  69. return /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
  70. };
  71.  
  72. // Create and manage floating button
  73. let floatingButton = null;
  74.  
  75. function createFloatingButton() {
  76. if (floatingButton) return;
  77.  
  78. floatingButton = document.createElement('div');
  79. floatingButton.id = 'explainer-floating-button';
  80.  
  81. // Determine size based on settings
  82. let buttonSize;
  83. switch (config.floatingButton.size) {
  84. case 'small': buttonSize = '40px'; break;
  85. case 'large': buttonSize = '60px'; break;
  86. default: buttonSize = '50px'; // medium
  87. }
  88.  
  89. // Position based on settings
  90. let positionCSS;
  91. switch (config.floatingButton.position) {
  92. case 'top-left': positionCSS = 'top: 20px; left: 20px;'; break;
  93. case 'top-right': positionCSS = 'top: 20px; right: 20px;'; break;
  94. case 'bottom-left': positionCSS = 'bottom: 20px; left: 20px;'; break;
  95. default: positionCSS = 'bottom: 20px; right: 20px;'; // bottom-right
  96. }
  97.  
  98. floatingButton.style.cssText = `
  99. width: ${buttonSize};
  100. height: ${buttonSize};
  101. border-radius: 50%;
  102. background-color: rgba(33, 150, 243, 0.8);
  103. color: white;
  104. display: flex;
  105. align-items: center;
  106. justify-content: center;
  107. position: fixed;
  108. ${positionCSS}
  109. z-index: 9999;
  110. box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
  111. cursor: pointer;
  112. font-weight: bold;
  113. font-size: ${parseInt(buttonSize) * 0.4}px;
  114. opacity: 0;
  115. transition: opacity 0.3s ease, transform 0.2s ease;
  116. pointer-events: none;
  117. touch-action: manipulation;
  118. -webkit-tap-highlight-color: transparent;
  119. `;
  120.  
  121. // Add icon or text
  122. floatingButton.innerHTML = '✓';
  123.  
  124. // Add to DOM
  125. document.body.appendChild(floatingButton);
  126.  
  127. // Add click event
  128. floatingButton.addEventListener('click', (e) => {
  129. e.preventDefault();
  130. processSelectedText();
  131. });
  132.  
  133. // Prevent text selection on button
  134. floatingButton.addEventListener('mousedown', (e) => {
  135. e.preventDefault();
  136. });
  137. }
  138.  
  139. function showFloatingButton() {
  140. if (!floatingButton || !config.floatingButton.enabled) return;
  141.  
  142. // Make visible and enable pointer events
  143. floatingButton.style.opacity = '1';
  144. floatingButton.style.pointerEvents = 'auto';
  145.  
  146. // Add active effect for touch
  147. floatingButton.addEventListener('touchstart', () => {
  148. floatingButton.style.transform = 'scale(0.95)';
  149. });
  150.  
  151. floatingButton.addEventListener('touchend', () => {
  152. floatingButton.style.transform = 'scale(1)';
  153. });
  154. }
  155.  
  156. function hideFloatingButton() {
  157. if (!floatingButton) return;
  158. floatingButton.style.opacity = '0';
  159. floatingButton.style.pointerEvents = 'none';
  160. }
  161.  
  162. // Add minimal styles for UI components
  163. GM_addStyle(`
  164. #explainer-popup {
  165. position: absolute;
  166. width: 450px;
  167. max-width: 90vw;
  168. max-height: 80vh;
  169. background: rgba(255, 255, 255, 0.3);
  170. backdrop-filter: blur(15px);
  171. -webkit-backdrop-filter: blur(15px);
  172. border-radius: 8px;
  173. box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
  174. z-index: 10000;
  175. overflow: auto;
  176. padding: 20px;
  177. transition: top 0.3s, left 0.3s, bottom 0.3s, right 0.3s;
  178. }
  179. @keyframes slideInFromTop {
  180. from { transform: translateY(-20px); opacity: 0; }
  181. to { transform: translateY(0); opacity: 1; }
  182. }
  183. @keyframes slideInFromBottom {
  184. from { transform: translateY(20px); opacity: 0; }
  185. to { transform: translateY(0); opacity: 1; }
  186. }
  187. @keyframes slideInFromLeft {
  188. from { transform: translateX(-20px); opacity: 0; }
  189. to { transform: translateX(0); opacity: 1; }
  190. }
  191. @keyframes slideInFromRight {
  192. from { transform: translateX(20px); opacity: 0; }
  193. to { transform: translateX(0); opacity: 1; }
  194. }
  195. @keyframes fadeIn {
  196. from { opacity: 0; }
  197. to { opacity: 1; }
  198. }
  199. #explainer-loading {
  200. text-align: center;
  201. padding: 20px 0;
  202. display: flex;
  203. align-items: center;
  204. justify-content: center;
  205. }
  206. #explainer-loading:after {
  207. content: "";
  208. width: 24px;
  209. height: 24px;
  210. border: 3px solid #ddd;
  211. border-top: 3px solid #2196F3;
  212. border-radius: 50%;
  213. animation: spin 1s linear infinite;
  214. display: inline-block;
  215. }
  216. @keyframes spin {
  217. 0% { transform: rotate(0deg); }
  218. 100% { transform: rotate(360deg); }
  219. }
  220. #explainer-error {
  221. color: #d32f2f;
  222. padding: 8px;
  223. border-radius: 4px;
  224. margin-bottom: 10px;
  225. font-size: 14px;
  226. display: none;
  227. }
  228. /* Dark mode support - minimal */
  229. @media (prefers-color-scheme: dark) {
  230. #explainer-popup {
  231. background: rgba(35, 35, 40, 0.7);
  232. color: #e0e0e0;
  233. }
  234. #explainer-error {
  235. background-color: rgba(100, 25, 25, 0.4);
  236. color: #ff8a8a;
  237. }
  238. #explainer-floating-button {
  239. background-color: rgba(33, 150, 243, 0.9);
  240. }
  241. }
  242. `);
  243.  
  244. // Function to close the popup
  245. function closePopup() {
  246. const popup = document.getElementById('explainer-popup');
  247. if (popup) {
  248. popup.remove();
  249. }
  250. }
  251.  
  252. // Calculate optimal popup position based on selection
  253. function calculatePopupPosition() {
  254. const selection = window.getSelection();
  255. if (!selection || selection.rangeCount === 0) return null;
  256.  
  257. // Get selection position
  258. const range = selection.getRangeAt(0);
  259. const selectionRect = range.getBoundingClientRect();
  260.  
  261. // Get scroll position to convert viewport coordinates to absolute
  262. const scrollLeft = window.scrollX || document.documentElement.scrollLeft;
  263. const scrollTop = window.scrollY || document.documentElement.scrollTop;
  264.  
  265. // Get document dimensions
  266. const viewportWidth = window.innerWidth;
  267. const viewportHeight = window.innerHeight;
  268.  
  269. // Estimate popup dimensions (will be adjusted once created)
  270. const popupWidth = 450;
  271. const popupHeight = Math.min(500, viewportHeight * 0.8);
  272.  
  273. // Calculate optimal position
  274. let position = {};
  275.  
  276. // Default margin from selection
  277. const margin = 20;
  278.  
  279. // Try to position below the selection
  280. if (selectionRect.bottom + margin + popupHeight <= viewportHeight) {
  281. position.top = selectionRect.bottom + scrollTop + margin;
  282. position.left = Math.min(
  283. Math.max(10 + scrollLeft, selectionRect.left + scrollLeft + (selectionRect.width / 2) - (popupWidth / 2)),
  284. viewportWidth + scrollLeft - popupWidth - 10
  285. );
  286. position.placement = 'below';
  287. }
  288. // Try to position above the selection
  289. else if (selectionRect.top - margin - popupHeight >= 0) {
  290. position.top = selectionRect.top + scrollTop - margin - popupHeight;
  291. position.left = Math.min(
  292. Math.max(10 + scrollLeft, selectionRect.left + scrollLeft + (selectionRect.width / 2) - (popupWidth / 2)),
  293. viewportWidth + scrollLeft - popupWidth - 10
  294. );
  295. position.placement = 'above';
  296. }
  297. // Try to position to the right
  298. else if (selectionRect.right + margin + popupWidth <= viewportWidth) {
  299. position.top = Math.max(10 + scrollTop, Math.min(
  300. selectionRect.top + scrollTop,
  301. viewportHeight + scrollTop - popupHeight - 10
  302. ));
  303. position.left = selectionRect.right + scrollLeft + margin;
  304. position.placement = 'right';
  305. }
  306. // Try to position to the left
  307. else if (selectionRect.left - margin - popupWidth >= 0) {
  308. position.top = Math.max(10 + scrollTop, Math.min(
  309. selectionRect.top + scrollTop,
  310. viewportHeight + scrollTop - popupHeight - 10
  311. ));
  312. position.left = selectionRect.left + scrollLeft - margin - popupWidth;
  313. position.placement = 'left';
  314. }
  315. // Fallback to centered position if no good placement found
  316. else {
  317. position.top = Math.max(10 + scrollTop, Math.min(
  318. selectionRect.top + selectionRect.height + scrollTop + margin,
  319. viewportHeight / 2 + scrollTop - popupHeight / 2
  320. ));
  321. position.left = Math.max(10 + scrollLeft, Math.min(
  322. selectionRect.left + selectionRect.width / 2 + scrollLeft - popupWidth / 2,
  323. viewportWidth + scrollLeft - popupWidth - 10
  324. ));
  325. position.placement = 'center';
  326. }
  327.  
  328. return position;
  329. }
  330.  
  331. // Create popup
  332. function createPopup() {
  333. // Remove existing popup if any
  334. closePopup();
  335.  
  336. const popup = document.createElement('div');
  337. popup.id = 'explainer-popup';
  338.  
  339. popup.innerHTML = `
  340. <div id="explainer-error"></div>
  341. <div id="explainer-loading"></div>
  342. <div id="explainer-content"></div>
  343. `;
  344.  
  345. document.body.appendChild(popup);
  346.  
  347. // Get optimal position for the popup
  348. const position = calculatePopupPosition();
  349.  
  350. // Apply dynamic positioning
  351. if (position) {
  352. popup.style.transform = 'none'; // Remove the default transform
  353.  
  354. // Apply positions based on the calculated values
  355. if (position.top !== undefined) popup.style.top = `${position.top}px`;
  356. if (position.bottom !== undefined) popup.style.bottom = `${position.bottom}px`;
  357. if (position.left !== undefined) popup.style.left = `${position.left}px`;
  358. if (position.right !== undefined) popup.style.right = `${position.right}px`;
  359.  
  360. // Add animation based on placement
  361. switch (position.placement) {
  362. case 'above':
  363. popup.style.animation = 'slideInFromTop 0.3s ease';
  364. break;
  365. case 'below':
  366. popup.style.animation = 'slideInFromBottom 0.3s ease';
  367. break;
  368. case 'left':
  369. popup.style.animation = 'slideInFromLeft 0.3s ease';
  370. break;
  371. case 'right':
  372. popup.style.animation = 'slideInFromRight 0.3s ease';
  373. break;
  374. default:
  375. popup.style.animation = 'fadeIn 0.3s ease';
  376. }
  377. } else {
  378. // Fallback to center positioning if we couldn't determine a good position
  379. popup.style.top = '50%';
  380. popup.style.left = '50%';
  381. popup.style.transform = 'translate(-50%, -50%)';
  382. }
  383.  
  384. // Add event listener for Escape key
  385. document.addEventListener('keydown', handleEscKey);
  386.  
  387. // Add event listener for clicking outside popup
  388. document.addEventListener('click', handleOutsideClick);
  389.  
  390. return popup;
  391. }
  392.  
  393. // Handle Escape key to close popup
  394. function handleEscKey(e) {
  395. if (e.key === 'Escape') {
  396. closePopup();
  397. document.removeEventListener('keydown', handleEscKey);
  398. document.removeEventListener('click', handleOutsideClick);
  399. }
  400. }
  401.  
  402. // Handle clicks outside popup to close it
  403. function handleOutsideClick(e) {
  404. const popup = document.getElementById('explainer-popup');
  405. if (popup && !popup.contains(e.target)) {
  406. closePopup();
  407. document.removeEventListener('keydown', handleEscKey);
  408. document.removeEventListener('click', handleOutsideClick);
  409. }
  410. }
  411.  
  412. // Function to show an error in the popup
  413. function showError(message) {
  414. const errorDiv = document.getElementById('explainer-error');
  415. if (errorDiv) {
  416. errorDiv.textContent = message;
  417. errorDiv.style.display = 'block';
  418. document.getElementById('explainer-loading').style.display = 'none';
  419. }
  420. }
  421.  
  422. // Function to call the LLM using SmolLLM
  423. async function callLLM(prompt, systemPrompt, progressCallback) {
  424. if (!config.apiKey) {
  425. throw new Error("Please set up your API key in the settings.");
  426. }
  427.  
  428. if (!llm) {
  429. throw new Error("SmolLLM library not initialized. Please check console for errors.");
  430. }
  431.  
  432. console.log(`prompt: ${prompt}`);
  433. console.log(`systemPrompt: ${systemPrompt}`);
  434. try {
  435. return await llm.askLLM({
  436. prompt: prompt,
  437. systemPrompt: systemPrompt,
  438. model: config.model,
  439. apiKey: config.apiKey,
  440. baseUrl: config.baseUrl,
  441. providerName: config.provider,
  442. handler: progressCallback,
  443. timeout: 60000
  444. });
  445. } catch (error) {
  446. console.error('LLM API error:', error);
  447. throw error;
  448. }
  449. }
  450.  
  451. function getPrompt(selectedText, paragraphText, textBefore, textAfter) {
  452. const wordsCount = selectedText.split(' ').length;
  453. const systemPrompt = `Respond in ${config.language} using ONLY these HTML tags: <p>, <b>, <i>, <ul>, <ol>, <li>, <a>.
  454. - Maintain clean formatting without code blocks
  455. - Prioritize clarity and conciseness
  456. - Use bullet points when appropriate`;
  457.  
  458. if (wordsCount >= 500) {
  459. return {
  460. prompt: `Create a structured summary in ${config.language}:
  461. - Identify key themes and concepts
  462. - Extract 3-5 main points
  463. - Use nested <ul> lists for hierarchy
  464. - Keep bullets concise
  465.  
  466. for the following selected text:
  467. \n\n${selectedText}
  468. `,
  469. systemPrompt
  470. };
  471. }
  472.  
  473. // For short text that looks like a sentence, offer translation
  474. if (wordsCount >= 5) {
  475. return {
  476. prompt: `Translate exactly to ${config.language} without commentary:
  477. - Preserve technical terms and names
  478. - Maintain original punctuation
  479. - Match formal/informal tone of source
  480.  
  481. for the following selected text:
  482. \n\n${selectedText}
  483. `,
  484. systemPrompt
  485. };
  486. }
  487.  
  488. const pinYinExtraPrompt = config.language === "Chinese" ? ' DO NOT add Pinyin for it.' : '';
  489. const ipaExtraPrompt = config.language === "Chinese" ? '(with IPA if necessary)' : '';
  490. const asciiChars = selectedText.replace(/[\s\.,\-_'"!?()]/g, '')
  491. .split('')
  492. .filter(char => char.charCodeAt(0) <= 127).length;
  493. const sampleSentenceLanguage = selectedText.length === asciiChars ? "English" : config.language;
  494.  
  495. // If we have context before/after, include it in the prompt
  496. const contextPrompt = textBefore || textAfter ?
  497. `# Context:
  498. ## Before selected text:
  499. ${textBefore || 'None'}
  500. ## Selected text:
  501. ${selectedText}
  502. ## After selected text:
  503. ${textAfter || 'None'}` : paragraphText;
  504.  
  505.  
  506. // Explain words prompt
  507. return {
  508. prompt: `Provide an explanation for the word: "${selectedText}${ipaExtraPrompt}" in ${config.language} without commentary.${pinYinExtraPrompt}
  509.  
  510. Use the context from the surrounding paragraph to inform your explanation when relevant:
  511.  
  512. ${contextPrompt}
  513.  
  514. # Consider these scenarios:
  515.  
  516. ## Names
  517. If "${selectedText}" is a person's name, company name, or organization name, provide a brief description (e.g., who they are or what they do).
  518. e.g.
  519. Alan Turing was a British mathematician and computer scientist. He is widely considered to be the father of theoretical computer science and artificial intelligence.
  520. His work was crucial to:
  521. Formalizing the concepts of algorithm and computation with the Turing machine.
  522. Breaking the German Enigma code during World War II, significantly contributing to the Allied victory.
  523. Developing the Turing test, a benchmark for artificial intelligence.
  524.  
  525.  
  526. ## Technical Terms
  527. If "${selectedText}" is a technical term or jargon
  528. - give a concise definition and explain.
  529. - Some best practice of using it
  530. - Explain how it works.
  531. - No need example sentence for the technical term.
  532. e.g. GAN 生成对抗网络
  533. 生成对抗网络(Generative Adversarial Network),是一种深度学习框架,由Ian Goodfellow2014年提出。GAN包含两个神经网络:生成器(Generator)和判别器(Discriminator),它们相互对抗训练。生成器尝试创建看起来真实的数据,而判别器则尝试区分真实数据和生成的假数据。通过这种"博弈"过程,生成器逐渐学会创建越来越逼真的数据。
  534.  
  535. ## Normal Words
  536. - For any other word, explain its meaning and provide 1-2 example sentences with the word in ${sampleSentenceLanguage}.
  537. e.g. jargon \\ˈdʒɑrɡən\\ 行话,专业术语,特定领域内使用的专业词汇。在计算机科学和编程领域,指那些对外行人难以理解的专业术语和缩写。
  538. 例句: "When explaining code to beginners, try to avoid using too much technical jargon that might confuse them."(向初学者解释代码时,尽量避免使用太多可能让他们困惑的技术行话。)
  539.  
  540. # Format
  541.  
  542. - Output the words first, then the explanation, and then the example sentences in ${sampleSentenceLanguage} if necessary.
  543. - No extra explanation
  544. - Remember to using proper html format like <p> <b> <i> <a> <li> <ol> <ul> to improve readability.
  545. `,
  546. systemPrompt
  547. };
  548. }
  549.  
  550. // Main function to process selected text
  551. async function processSelectedText() {
  552. // Use the utility function instead of the local getSelectedText
  553. const { selectedText, textBefore, textAfter, paragraphText } = GetSelectionContext();
  554.  
  555. if (!selectedText) {
  556. showError('No text selected');
  557. return;
  558. }
  559.  
  560. console.log(`Selected text: '${selectedText}', Paragraph text:\n${paragraphText}`);
  561. // Create popup
  562. createPopup();
  563. const contentDiv = document.getElementById('explainer-content');
  564. const loadingDiv = document.getElementById('explainer-loading');
  565. const errorDiv = document.getElementById('explainer-error');
  566.  
  567. // Reset display
  568. errorDiv.style.display = 'none';
  569. loadingDiv.style.display = 'block';
  570.  
  571. // Assemble prompt with language preference
  572. const { prompt, systemPrompt } = getPrompt(selectedText, paragraphText, textBefore, textAfter);
  573.  
  574. // Variable to store ongoing response text
  575. let responseText = '';
  576.  
  577. try {
  578. // Call LLM with progress callback and await the full response
  579. const fullResponse = await callLLM(prompt, systemPrompt, (textChunk, currentFullText) => {
  580. // Update response text with new chunk
  581. responseText = currentFullText || (responseText + textChunk);
  582.  
  583. // Hide loading message if this is the first chunk
  584. if (loadingDiv.style.display !== 'none') {
  585. loadingDiv.style.display = 'none';
  586. }
  587.  
  588. // Update content with either HTML or markdown
  589. updateContentDisplay(contentDiv, responseText);
  590. });
  591.  
  592. // If we got a response
  593. if (fullResponse && fullResponse.length > 0) {
  594. responseText = fullResponse;
  595. loadingDiv.style.display = 'none';
  596. updateContentDisplay(contentDiv, fullResponse);
  597. }
  598. // If no response was received at all
  599. else if (!fullResponse || fullResponse.length === 0) {
  600. // If we've received chunks but the final response is empty, use the accumulated text
  601. if (responseText && responseText.length > 0) {
  602. updateContentDisplay(contentDiv, responseText);
  603. } else {
  604. showError("No response received from the model. Please try again.");
  605. }
  606. }
  607.  
  608. // Hide loading indicator if it's still visible
  609. if (loadingDiv.style.display !== 'none') {
  610. loadingDiv.style.display = 'none';
  611. }
  612. } catch (error) {
  613. console.error('Error:', error);
  614. // Display error in popup
  615. showError(`Error: ${error.message}`);
  616. }
  617. }
  618.  
  619. // Main function to handle keyboard shortcuts
  620. function handleKeyPress(e) {
  621. // Get shortcut configuration from settings
  622. const shortcut = config.shortcut || { key: 'd', ctrlKey: false, altKey: true, shiftKey: false, metaKey: false };
  623.  
  624. // More robust shortcut detection using both key and code properties
  625. if (isShortcutMatch(e, shortcut)) {
  626. e.preventDefault();
  627. processSelectedText();
  628. }
  629. }
  630.  
  631. // Helper function for more robust shortcut detection
  632. function isShortcutMatch(event, shortcutConfig) {
  633. // Check all modifier keys first
  634. if (event.ctrlKey !== !!shortcutConfig.ctrlKey ||
  635. event.altKey !== !!shortcutConfig.altKey ||
  636. event.shiftKey !== !!shortcutConfig.shiftKey ||
  637. event.metaKey !== !!shortcutConfig.metaKey) {
  638. return false;
  639. }
  640.  
  641. const key = shortcutConfig.key.toLowerCase();
  642.  
  643. // Method 1: Direct key match (works for most standard keys)
  644. if (event.key.toLowerCase() === key) {
  645. return true;
  646. }
  647.  
  648. // Method 2: Key code match (more reliable for letter keys)
  649. // This handles the physical key position regardless of keyboard layout
  650. if (key.length === 1 && /^[a-z]$/.test(key) &&
  651. event.code === `Key${key.toUpperCase()}`) {
  652. return true;
  653. }
  654.  
  655. // Method 3: Handle known special characters from Option/Alt key combinations
  656. // These are the most common mappings on macOS when using Option+key
  657. const macOptionKeyMap = {
  658. 'a': 'å', 'b': '∫', 'c': 'ç', 'd': '∂', 'e': '´', 'f': 'ƒ',
  659. 'g': '©', 'h': '˙', 'i': 'ˆ', 'j': '∆', 'k': '˚', 'l': '¬',
  660. 'm': 'µ', 'n': '˜', 'o': 'ø', 'p': 'π', 'q': 'œ', 'r': '®',
  661. 's': 'ß', 't': '†', 'u': '¨', 'v': '√', 'w': '∑', 'x': '≈',
  662. 'y': '¥', 'z': 'Ω'
  663. };
  664.  
  665. if (shortcutConfig.altKey && macOptionKeyMap[key] === event.key) {
  666. return true;
  667. }
  668.  
  669. return false;
  670. }
  671.  
  672. // Helper function to update content display
  673. function updateContentDisplay(contentDiv, text) {
  674. if (!text) return;
  675.  
  676. try {
  677. if (!text.trim().startsWith('<')) {
  678. // fallback
  679. console.log(`Seems like the response is not HTML: ${text}`);
  680. text = `<p>${text.replace(/\n/g, '<br>')}</p>`;
  681. }
  682. contentDiv.innerHTML = text;
  683. } catch (e) {
  684. // Fallback if parsing fails
  685. console.error(`Error parsing content: ${e.message}`);
  686. contentDiv.innerHTML = `<p>${text.replace(/\n/g, '<br>')}</p>`;
  687. }
  688. }
  689.  
  690. // Monitor selection changes for floating button
  691. function handleSelectionChange() {
  692. const selection = window.getSelection();
  693. const hasSelection = selection && selection.toString().trim() !== '';
  694.  
  695. if (hasSelection && isTouchDevice() && config.floatingButton.enabled) {
  696. showFloatingButton();
  697. } else {
  698. hideFloatingButton();
  699. }
  700. }
  701.  
  702. // Settings update callback
  703. function onSettingsChanged(updatedConfig) {
  704. config = updatedConfig;
  705. console.log('Settings updated:', config);
  706.  
  707. // Recreate floating button if settings changed
  708. if (floatingButton) {
  709. floatingButton.remove();
  710. floatingButton = null;
  711.  
  712. if (isTouchDevice() && config.floatingButton.enabled) {
  713. createFloatingButton();
  714. handleSelectionChange(); // Check if there's already a selection
  715. }
  716. }
  717. }
  718.  
  719. // Initialize the script
  720. function init() {
  721. // Register settings menu in Tampermonkey
  722. GM_registerMenuCommand("Text Explainer Settings", () => {
  723. settingsManager.openDialog(onSettingsChanged);
  724. });
  725.  
  726. // Add keyboard shortcut listener
  727. document.addEventListener('keydown', handleKeyPress);
  728.  
  729. // For touch devices, create floating button
  730. if (isTouchDevice() && config.floatingButton.enabled) {
  731. createFloatingButton();
  732.  
  733. // Monitor text selection
  734. document.addEventListener('selectionchange', handleSelectionChange);
  735.  
  736. // Add touchend handler to show button after selection
  737. document.addEventListener('touchend', () => {
  738. // Small delay to ensure selection is updated
  739. setTimeout(handleSelectionChange, 100);
  740. });
  741. }
  742.  
  743. console.log('Text Explainer script initialized with language: ' + config.language);
  744. console.log('Touch device detected: ' + isTouchDevice());
  745. }
  746.  
  747. // Run initialization
  748. init();
  749. })();