Text Explainer

Explain selected text using LLM

  1. // ==UserScript==
  2. // @name Text Explainer
  3. // @namespace http://tampermonkey.net/
  4. // @version 0.2.14
  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.greatest.deepsurf.us/scripts/528704/1549030/SmolLLM.js
  19. // @require https://update.greatest.deepsurf.us/scripts/528703/1546610/SimpleBalancer.js
  20. // @require https://update.greatest.deepsurf.us/scripts/528763/1549028/Text%20Explainer%20Settings.js
  21. // @require https://update.greatest.deepsurf.us/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. // Create and manage floating button
  69. let floatingButton = null;
  70. let isProcessingText = false;
  71.  
  72. function createFloatingButton() {
  73. if (floatingButton) return;
  74.  
  75. floatingButton = document.createElement('div');
  76. floatingButton.id = 'explainer-floating-button';
  77.  
  78. // Determine size based on settings
  79. let buttonSize;
  80. switch (config.floatingButton.size) {
  81. case 'small': buttonSize = '40px'; break;
  82. case 'large': buttonSize = '60px'; break;
  83. default: buttonSize = '50px'; // medium
  84. }
  85.  
  86. floatingButton.style.cssText = `
  87. width: ${buttonSize};
  88. height: ${buttonSize};
  89. border-radius: 50%;
  90. background-color: rgba(33, 150, 243, 0.8);
  91. color: white;
  92. display: flex;
  93. align-items: center;
  94. justify-content: center;
  95. position: fixed;
  96. z-index: 9999;
  97. box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
  98. cursor: pointer;
  99. font-weight: bold;
  100. font-size: ${parseInt(buttonSize) * 0.4}px;
  101. opacity: 0;
  102. transition: opacity 0.3s ease, transform 0.2s ease;
  103. pointer-events: none;
  104. touch-action: manipulation;
  105. -webkit-tap-highlight-color: transparent;
  106. `;
  107.  
  108. // Add icon or text
  109. floatingButton.innerHTML = '💬';
  110.  
  111. // Add to DOM
  112. document.body.appendChild(floatingButton);
  113.  
  114. // Handle button click/tap
  115. function handleButtonAction(e) {
  116. e.preventDefault();
  117. e.stopPropagation();
  118.  
  119. // Prevent multiple clicks while processing
  120. if (isProcessingText) return;
  121.  
  122. // Get selection context before clearing selection
  123. const selectionContext = GetSelectionContext();
  124.  
  125. if (!selectionContext.selectedText) {
  126. console.log('No valid selection to process');
  127. return;
  128. }
  129.  
  130. // Set processing flag
  131. isProcessingText = true;
  132.  
  133. // Hide the floating button
  134. hideFloatingButton();
  135.  
  136. // Blur selection to dismiss iOS menu
  137. window.getSelection().removeAllRanges();
  138.  
  139. // Now trigger the explainer with the stored selection
  140. // Create popup
  141. createPopup();
  142. const contentDiv = document.getElementById('explainer-content');
  143. const loadingDiv = document.getElementById('explainer-loading');
  144. const errorDiv = document.getElementById('explainer-error');
  145.  
  146. // Reset display
  147. errorDiv.style.display = 'none';
  148. loadingDiv.style.display = 'block';
  149.  
  150. // Assemble prompt with language preference
  151. const { prompt, systemPrompt } = getPrompt(
  152. selectionContext.selectedText,
  153. selectionContext.paragraphText,
  154. selectionContext.textBefore,
  155. selectionContext.textAfter
  156. );
  157.  
  158. // Variable to store ongoing response text
  159. let responseText = '';
  160.  
  161. // Call LLM with progress callback
  162. callLLM(prompt, systemPrompt, (textChunk, currentFullText) => {
  163. // Update response text with new chunk
  164. responseText = currentFullText || (responseText + textChunk);
  165.  
  166. // Hide loading message if this is the first chunk
  167. if (loadingDiv.style.display !== 'none') {
  168. loadingDiv.style.display = 'none';
  169. }
  170.  
  171. // Update content with either HTML or markdown
  172. updateContentDisplay(contentDiv, responseText);
  173. })
  174. .catch(error => {
  175. console.error('Error in LLM call:', error);
  176. errorDiv.textContent = error.message || 'Error processing request';
  177. errorDiv.style.display = 'block';
  178. loadingDiv.style.display = 'none';
  179. })
  180. .finally(() => {
  181. // Reset processing flag
  182. setTimeout(() => {
  183. isProcessingText = false;
  184. }, 1000);
  185. });
  186. }
  187.  
  188. // Add click event
  189. floatingButton.addEventListener('click', handleButtonAction);
  190.  
  191. // Add touch events
  192. floatingButton.addEventListener('touchstart', (e) => {
  193. e.preventDefault();
  194. e.stopPropagation();
  195. floatingButton.style.transform = 'scale(0.95)';
  196. }, { passive: false });
  197.  
  198. floatingButton.addEventListener('touchend', (e) => {
  199. e.preventDefault();
  200. e.stopPropagation();
  201. floatingButton.style.transform = 'scale(1)';
  202. handleButtonAction(e);
  203. }, { passive: false });
  204.  
  205. // Prevent text selection on button
  206. floatingButton.addEventListener('mousedown', (e) => {
  207. e.preventDefault();
  208. e.stopPropagation();
  209. });
  210. }
  211.  
  212. function showFloatingButton() {
  213. if (!floatingButton || !config.floatingButton.enabled || isProcessingText) return;
  214.  
  215. const selection = window.getSelection();
  216. if (!selection || selection.rangeCount === 0) {
  217. hideFloatingButton();
  218. return;
  219. }
  220.  
  221. const range = selection.getRangeAt(0);
  222. const rect = range.getBoundingClientRect();
  223.  
  224. // Calculate position near the selection
  225. const buttonSize = parseInt(floatingButton.style.width);
  226. const margin = 10; // Distance from selection
  227.  
  228. // Calculate position in viewport coordinates
  229. let top = rect.bottom + margin;
  230. let left = rect.left + (rect.width / 2) - (buttonSize / 2);
  231.  
  232. // If button would go off screen, try positioning above
  233. if (top + buttonSize > window.innerHeight) {
  234. top = rect.top - buttonSize - margin;
  235. }
  236.  
  237. // Ensure button stays within viewport horizontally
  238. left = Math.max(10, Math.min(left, window.innerWidth - buttonSize - 10));
  239.  
  240. // Apply position (using viewport coordinates for fixed positioning)
  241. floatingButton.style.top = `${top}px`;
  242. floatingButton.style.left = `${left}px`;
  243.  
  244. // Make visible and enable pointer events
  245. floatingButton.style.opacity = '1';
  246. floatingButton.style.pointerEvents = 'auto';
  247. }
  248.  
  249. function hideFloatingButton() {
  250. if (!floatingButton) return;
  251. floatingButton.style.opacity = '0';
  252. floatingButton.style.pointerEvents = 'none';
  253. }
  254.  
  255. // Add minimal styles for UI components
  256. GM_addStyle(`
  257. /* Base popup styles */
  258. #explainer-popup {
  259. position: absolute;
  260. width: 450px;
  261. max-width: 90vw;
  262. max-height: 80vh;
  263. padding: 20px;
  264. z-index: 2147483647;
  265. overflow: auto;
  266. overscroll-behavior: contain;
  267. -webkit-overflow-scrolling: touch;
  268. /* Visual styles */
  269. background: rgba(255, 255, 255, 0.85);
  270. backdrop-filter: blur(10px);
  271. -webkit-backdrop-filter: blur(10px);
  272. border-radius: 8px;
  273. box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
  274. border: 1px solid rgba(0, 0, 0, 0.15);
  275. /* Text styles */
  276. color: #111;
  277. text-shadow: 0 0 1px rgba(255, 255, 255, 0.3);
  278. /* Animations */
  279. transition: all 0.3s ease;
  280. }
  281. /* Dark theme */
  282. #explainer-popup.dark-theme {
  283. background: rgba(45, 45, 50, 0.85);
  284. backdrop-filter: blur(10px);
  285. -webkit-backdrop-filter: blur(10px);
  286. color: #e0e0e0;
  287. border: 1px solid rgba(255, 255, 255, 0.15);
  288. box-shadow: 0 5px 15px rgba(0, 0, 0, 0.4);
  289. text-shadow: 0 0 1px rgba(0, 0, 0, 0.3);
  290. }
  291. /* iOS-specific overrides */
  292. @supports (-webkit-touch-callout: none) {
  293. #explainer-popup {
  294. background: rgba(255, 255, 255, 0.98);
  295. /* Disable backdrop-filter on iOS for better performance */
  296. backdrop-filter: none;
  297. -webkit-backdrop-filter: none;
  298. }
  299. #explainer-popup.dark-theme {
  300. background: rgba(35, 35, 40, 0.98);
  301. }
  302. }
  303.  
  304. @keyframes slideInFromTop {
  305. from { transform: translateY(-20px); opacity: 0; }
  306. to { transform: translateY(0); opacity: 1; }
  307. }
  308. @keyframes slideInFromBottom {
  309. from { transform: translateY(20px); opacity: 0; }
  310. to { transform: translateY(0); opacity: 1; }
  311. }
  312. @keyframes slideInFromLeft {
  313. from { transform: translateX(-20px); opacity: 0; }
  314. to { transform: translateX(0); opacity: 1; }
  315. }
  316. @keyframes slideInFromRight {
  317. from { transform: translateX(20px); opacity: 0; }
  318. to { transform: translateX(0); opacity: 1; }
  319. }
  320. @keyframes fadeIn {
  321. from { opacity: 0; }
  322. to { opacity: 1; }
  323. }
  324. #explainer-loading {
  325. text-align: center;
  326. padding: 20px 0;
  327. display: flex;
  328. align-items: center;
  329. justify-content: center;
  330. }
  331. #explainer-loading:after {
  332. content: "";
  333. width: 24px;
  334. height: 24px;
  335. border: 3px solid #ddd;
  336. border-top: 3px solid #2196F3;
  337. border-radius: 50%;
  338. animation: spin 1s linear infinite;
  339. display: inline-block;
  340. }
  341. @keyframes spin {
  342. 0% { transform: rotate(0deg); }
  343. 100% { transform: rotate(360deg); }
  344. }
  345. #explainer-error {
  346. color: #d32f2f;
  347. padding: 8px;
  348. border-radius: 4px;
  349. margin-bottom: 10px;
  350. font-size: 14px;
  351. display: none;
  352. }
  353. /* iOS-specific styles */
  354. @supports (-webkit-touch-callout: none) {
  355. #explainer-popup {
  356. background: rgba(255, 255, 255, 0.98);
  357. box-shadow: 0 5px 25px rgba(0, 0, 0, 0.3);
  358. border: 1px solid rgba(0, 0, 0, 0.1);
  359. }
  360. /* Dark mode for iOS */
  361. @media (prefers-color-scheme: dark) {
  362. #explainer-popup {
  363. background: rgba(35, 35, 40, 0.98);
  364. border: 1px solid rgba(255, 255, 255, 0.1);
  365. }
  366. }
  367. }
  368. /* Dark mode support - minimal */
  369. @media (prefers-color-scheme: dark) {
  370. #explainer-popup {
  371. background: rgba(35, 35, 40, 0.85);
  372. color: #e0e0e0;
  373. }
  374. #explainer-error {
  375. background-color: rgba(100, 25, 25, 0.4);
  376. color: #ff8a8a;
  377. }
  378. #explainer-floating-button {
  379. background-color: rgba(33, 150, 243, 0.9);
  380. }
  381. }
  382. /* Add touch-specific styles */
  383. @media (hover: none) and (pointer: coarse) {
  384. #explainer-popup {
  385. width: 95vw;
  386. max-height: 90vh;
  387. padding: 15px;
  388. font-size: 16px;
  389. }
  390. #explainer-popup p,
  391. #explainer-popup li {
  392. line-height: 1.6;
  393. margin-bottom: 12px;
  394. }
  395. #explainer-popup a {
  396. padding: 8px 0;
  397. }
  398. }
  399. `);
  400.  
  401. // Function to detect if the page has a dark background
  402. function isPageDarkMode() {
  403. // Try to get the background color of the body or html element
  404. const bodyEl = document.body;
  405. const htmlEl = document.documentElement;
  406.  
  407. // Get computed style
  408. const bodyStyle = window.getComputedStyle(bodyEl);
  409. const htmlStyle = window.getComputedStyle(htmlEl);
  410.  
  411. // Extract background color
  412. const bodyBg = bodyStyle.backgroundColor;
  413. const htmlBg = htmlStyle.backgroundColor;
  414.  
  415. // Parse RGB values
  416. function getRGBValues(color) {
  417. const match = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*[\d.]+)?\)/);
  418. if (!match) return null;
  419.  
  420. const r = parseInt(match[1], 10);
  421. const g = parseInt(match[2], 10);
  422. const b = parseInt(match[3], 10);
  423.  
  424. return { r, g, b };
  425. }
  426.  
  427. // Calculate luminance (brightness) - higher values are brighter
  428. function getLuminance(color) {
  429. const rgb = getRGBValues(color);
  430. if (!rgb) return 128; // Default to middle gray if can't parse
  431.  
  432. // Perceived brightness formula
  433. return (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b);
  434. }
  435.  
  436. const bodyLuminance = getLuminance(bodyBg);
  437. const htmlLuminance = getLuminance(htmlBg);
  438.  
  439. // If either background is dark, consider the page dark
  440. const threshold = 128; // Middle of 0-255 range
  441.  
  442. // Check system preference as a fallback
  443. const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
  444.  
  445. // Page is dark if:
  446. // 1. Body background is dark, or
  447. // 2. HTML background is dark and body has no background set, or
  448. // 3. Both have no background set but system prefers dark
  449. if (bodyLuminance < threshold) {
  450. return true;
  451. } else if (bodyBg === 'rgba(0, 0, 0, 0)' && htmlLuminance < threshold) {
  452. return true;
  453. } else if (bodyBg === 'rgba(0, 0, 0, 0)' && htmlBg === 'rgba(0, 0, 0, 0)') {
  454. return prefersDark;
  455. }
  456.  
  457. return false;
  458. }
  459.  
  460. // Function to close the popup
  461. function closePopup() {
  462. const popup = document.getElementById('explainer-popup');
  463. if (popup) {
  464. popup.style.animation = 'fadeOut 0.3s ease';
  465. setTimeout(() => {
  466. popup.remove();
  467. const overlay = document.getElementById('explainer-overlay');
  468. if (overlay) {
  469. overlay.remove();
  470. }
  471. }, 300);
  472. }
  473.  
  474. // Always clean up the global variables from previous implementation
  475. if (window.explainerTouchTracker) {
  476. window.explainerTouchTracker = null;
  477. }
  478. }
  479.  
  480. // Calculate optimal popup position based on selection
  481. function calculatePopupPosition() {
  482. const selection = window.getSelection();
  483. if (!selection || selection.rangeCount === 0) return null;
  484.  
  485. // Get selection position
  486. const range = selection.getRangeAt(0);
  487. const selectionRect = range.getBoundingClientRect();
  488.  
  489. // Get scroll position to convert viewport coordinates to absolute
  490. const scrollLeft = window.scrollX || document.documentElement.scrollLeft;
  491. const scrollTop = window.scrollY || document.documentElement.scrollTop;
  492.  
  493. // Get document dimensions
  494. const viewportWidth = window.innerWidth;
  495. const viewportHeight = window.innerHeight;
  496.  
  497. // Estimate popup dimensions (will be adjusted once created)
  498. const popupWidth = 450;
  499. const popupHeight = Math.min(500, viewportHeight * 0.8);
  500.  
  501. // Calculate optimal position
  502. let position = {};
  503.  
  504. // Default margin from selection
  505. const margin = 20;
  506.  
  507. // Try to position below the selection
  508. if (selectionRect.bottom + margin + popupHeight <= viewportHeight) {
  509. position.top = selectionRect.bottom + scrollTop + margin;
  510. position.left = Math.min(
  511. Math.max(10 + scrollLeft, selectionRect.left + scrollLeft + (selectionRect.width / 2) - (popupWidth / 2)),
  512. viewportWidth + scrollLeft - popupWidth - 10
  513. );
  514. position.placement = 'below';
  515. }
  516. // Try to position above the selection
  517. else if (selectionRect.top - margin - popupHeight >= 0) {
  518. position.top = selectionRect.top + scrollTop - margin - popupHeight;
  519. position.left = Math.min(
  520. Math.max(10 + scrollLeft, selectionRect.left + scrollLeft + (selectionRect.width / 2) - (popupWidth / 2)),
  521. viewportWidth + scrollLeft - popupWidth - 10
  522. );
  523. position.placement = 'above';
  524. }
  525. // Try to position to the right
  526. else if (selectionRect.right + margin + popupWidth <= viewportWidth) {
  527. position.top = Math.max(10 + scrollTop, Math.min(
  528. selectionRect.top + scrollTop,
  529. viewportHeight + scrollTop - popupHeight - 10
  530. ));
  531. position.left = selectionRect.right + scrollLeft + margin;
  532. position.placement = 'right';
  533. }
  534. // Try to position to the left
  535. else if (selectionRect.left - margin - popupWidth >= 0) {
  536. position.top = Math.max(10 + scrollTop, Math.min(
  537. selectionRect.top + scrollTop,
  538. viewportHeight + scrollTop - popupHeight - 10
  539. ));
  540. position.left = selectionRect.left + scrollLeft - margin - popupWidth;
  541. position.placement = 'left';
  542. }
  543. // Fallback to centered position if no good placement found
  544. else {
  545. position.top = Math.max(10 + scrollTop, Math.min(
  546. selectionRect.top + selectionRect.height + scrollTop + margin,
  547. viewportHeight / 2 + scrollTop - popupHeight / 2
  548. ));
  549. position.left = Math.max(10 + scrollLeft, Math.min(
  550. selectionRect.left + selectionRect.width / 2 + scrollLeft - popupWidth / 2,
  551. viewportWidth + scrollLeft - popupWidth - 10
  552. ));
  553. position.placement = 'center';
  554. }
  555.  
  556. return position;
  557. }
  558.  
  559. // Create popup
  560. function createPopup() {
  561. // Remove existing popup if any
  562. closePopup();
  563.  
  564. const popup = document.createElement('div');
  565. popup.id = 'explainer-popup';
  566.  
  567. // Add dark-theme class if the page has a dark background
  568. if (isPageDarkMode()) {
  569. popup.classList.add('dark-theme');
  570. }
  571.  
  572. popup.innerHTML = `
  573. <div id="explainer-error"></div>
  574. <div id="explainer-loading"></div>
  575. <div id="explainer-content"></div>
  576. `;
  577.  
  578. document.body.appendChild(popup);
  579.  
  580. // For touch devices, use fixed positioning with transform
  581. if (isTouchDevice()) {
  582. popup.style.position = 'fixed';
  583. popup.style.top = '50%';
  584. popup.style.left = '50%';
  585. popup.style.transform = 'translate(-50%, -50%)';
  586. popup.style.width = '90vw';
  587. popup.style.maxHeight = '85vh';
  588. } else {
  589. // Desktop positioning logic
  590. const position = calculatePopupPosition();
  591. if (position) {
  592. popup.style.transform = 'none';
  593. if (position.top !== undefined) popup.style.top = `${position.top}px`;
  594. if (position.bottom !== undefined) popup.style.bottom = `${position.bottom}px`;
  595. if (position.left !== undefined) popup.style.left = `${position.left}px`;
  596. if (position.right !== undefined) popup.style.right = `${position.right}px`;
  597. } else {
  598. popup.style.top = '50%';
  599. popup.style.left = '50%';
  600. popup.style.transform = 'translate(-50%, -50%)';
  601. }
  602. }
  603.  
  604. // Add animation
  605. popup.style.animation = 'fadeIn 0.3s ease';
  606.  
  607. // Add event listeners
  608. document.addEventListener('keydown', handleEscKey);
  609.  
  610. // Use a simpler approach for touch devices - attach a click/touch handler directly
  611. if (isTouchDevice()) {
  612. // Create an overlay for capturing outside touches
  613. const overlay = document.createElement('div');
  614. overlay.id = 'explainer-overlay';
  615. overlay.style.cssText = `
  616. position: fixed;
  617. top: 0;
  618. left: 0;
  619. right: 0;
  620. bottom: 0;
  621. z-index: ${parseInt(popup.style.zIndex || 2147483647) - 1};
  622. background: transparent;
  623. `;
  624. document.body.appendChild(overlay);
  625.  
  626. // Handle iPad-specific touch behavior
  627. let touchStarted = false;
  628. let startX = 0;
  629. let startY = 0;
  630.  
  631. // Higher threshold for iPad - more forgiving for slight movements
  632. const moveThreshold = 30; // pixels
  633.  
  634. overlay.addEventListener('touchstart', (e) => {
  635. touchStarted = true;
  636. startX = e.touches[0].clientX;
  637. startY = e.touches[0].clientY;
  638. }, { passive: true });
  639.  
  640. overlay.addEventListener('touchmove', () => {
  641. // Just having a touchmove listener prevents default behavior on iOS
  642. }, { passive: true });
  643.  
  644. overlay.addEventListener('touchend', (e) => {
  645. if (!touchStarted) return;
  646.  
  647. const touch = e.changedTouches[0];
  648. const moveX = Math.abs(touch.clientX - startX);
  649. const moveY = Math.abs(touch.clientY - startY);
  650.  
  651. // If user didn't move much, consider it a tap to dismiss
  652. if (moveX < moveThreshold && moveY < moveThreshold) {
  653. closePopup();
  654. removeAllPopupListeners();
  655. }
  656.  
  657. touchStarted = false;
  658. }, { passive: true });
  659.  
  660. // Prevent popup from capturing overlay events
  661. popup.addEventListener('touchstart', (e) => {
  662. e.stopPropagation();
  663. }, { passive: false });
  664.  
  665. } else {
  666. document.addEventListener('click', handleOutsideClick);
  667. }
  668.  
  669. return popup;
  670. }
  671.  
  672. // Handle Escape key to close popup
  673. function handleEscKey(e) {
  674. if (e.key === 'Escape') {
  675. closePopup();
  676. removeAllPopupListeners();
  677. }
  678. }
  679.  
  680. // For desktop - more straightforward approach
  681. function handleOutsideClick(e) {
  682. const popup = document.getElementById('explainer-popup');
  683. if (!popup || popup.contains(e.target)) return;
  684.  
  685. closePopup();
  686. removeAllPopupListeners();
  687. }
  688.  
  689. // Clean up all event listeners
  690. function removeAllPopupListeners() {
  691. document.removeEventListener('keydown', handleEscKey);
  692.  
  693. // Only remove click listener if we're not on a touch device
  694. if (!isTouchDevice()) {
  695. document.removeEventListener('click', handleOutsideClick);
  696. }
  697.  
  698. const overlay = document.getElementById('explainer-overlay');
  699. if (overlay) {
  700. overlay.remove();
  701. }
  702. }
  703.  
  704. // Function to show an error in the popup
  705. function showError(message) {
  706. const errorDiv = document.getElementById('explainer-error');
  707. if (errorDiv) {
  708. errorDiv.textContent = message;
  709. errorDiv.style.display = 'block';
  710. document.getElementById('explainer-loading').style.display = 'none';
  711. }
  712. }
  713.  
  714. // Function to call the LLM using SmolLLM
  715. async function callLLM(prompt, systemPrompt, progressCallback) {
  716. if (!config.apiKey) {
  717. throw new Error("Please set up your API key in the settings.");
  718. }
  719.  
  720. if (!llm) {
  721. throw new Error("SmolLLM library not initialized. Please check console for errors.");
  722. }
  723.  
  724. console.log(`prompt: ${prompt}`);
  725. console.log(`systemPrompt: ${systemPrompt}`);
  726. try {
  727. return await llm.askLLM({
  728. prompt: prompt,
  729. systemPrompt: systemPrompt,
  730. model: config.model,
  731. apiKey: config.apiKey,
  732. baseUrl: config.baseUrl,
  733. providerName: config.provider,
  734. handler: progressCallback,
  735. timeout: 60000
  736. });
  737. } catch (error) {
  738. console.error('LLM API error:', error);
  739. throw error;
  740. }
  741. }
  742.  
  743. function getPrompt(selectedText, paragraphText, textBefore, textAfter) {
  744. const wordsCount = selectedText.split(' ').length;
  745. const systemPrompt = `Respond in ${config.language} with HTML tags to improve readability.
  746. - Prioritize clarity and conciseness
  747. - Use bullet points when appropriate`;
  748.  
  749. if (wordsCount >= 500) {
  750. return {
  751. prompt: `Create a structured summary in ${config.language}:
  752. - Identify key themes and concepts
  753. - Extract 3-5 main points
  754. - Use nested <ul> lists for hierarchy
  755. - Keep bullets concise
  756.  
  757. for the following selected text:
  758. \n\n${selectedText}
  759. `,
  760. systemPrompt
  761. };
  762. }
  763.  
  764. // For short text that looks like a sentence, offer translation
  765. if (wordsCount >= 5) {
  766. return {
  767. prompt: `Translate exactly to ${config.language} without commentary:
  768. - Preserve technical terms and names
  769. - Maintain original punctuation
  770. - Match formal/informal tone of source
  771.  
  772. for the following selected text:
  773. \n\n${selectedText}
  774. `,
  775. systemPrompt
  776. };
  777. }
  778.  
  779. const pinYinExtraPrompt = config.language === "Chinese" ? ' DO NOT add Pinyin for it.' : '';
  780. const ipaExtraPrompt = config.language === "Chinese" ? '(with IPA if necessary)' : '';
  781. const asciiChars = selectedText.replace(/[\s\.,\-_'"!?()]/g, '')
  782. .split('')
  783. .filter(char => char.charCodeAt(0) <= 127).length;
  784. const sampleSentenceLanguage = selectedText.length === asciiChars ? "English" : config.language;
  785.  
  786. // If we have context before/after, include it in the prompt
  787. const contextPrompt = textBefore || textAfter ?
  788. `# Context:
  789. ## Before selected text:
  790. ${textBefore || 'None'}
  791. ## Selected text:
  792. ${selectedText}
  793. ## After selected text:
  794. ${textAfter || 'None'}` : paragraphText;
  795.  
  796.  
  797. // Explain words prompt
  798. return {
  799. prompt: `Provide an explanation for the word: "${selectedText}${ipaExtraPrompt}" in ${config.language} without commentary.${pinYinExtraPrompt}
  800.  
  801. Use the context from the surrounding paragraph to inform your explanation when relevant:
  802.  
  803. ${contextPrompt}
  804.  
  805. # Consider these scenarios:
  806.  
  807. ## Names
  808. 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).
  809. e.g.
  810. Alan Turing was a British mathematician and computer scientist. He is widely considered to be the father of theoretical computer science and artificial intelligence.
  811. His work was crucial to:
  812. Formalizing the concepts of algorithm and computation with the Turing machine.
  813. Breaking the German Enigma code during World War II, significantly contributing to the Allied victory.
  814. Developing the Turing test, a benchmark for artificial intelligence.
  815.  
  816.  
  817. ## Technical Terms
  818. If "${selectedText}" is a technical term or jargon
  819. - give a concise definition and explain.
  820. - Some best practice of using it
  821. - Explain how it works.
  822. - No need example sentence for the technical term.
  823. e.g. GAN 生成对抗网络
  824. 生成对抗网络(Generative Adversarial Network),是一种深度学习框架,由Ian Goodfellow2014年提出。GAN包含两个神经网络:生成器(Generator)和判别器(Discriminator),它们相互对抗训练。生成器尝试创建看起来真实的数据,而判别器则尝试区分真实数据和生成的假数据。通过这种"博弈"过程,生成器逐渐学会创建越来越逼真的数据。
  825.  
  826. ## Normal Words
  827. - For any other word, explain its meaning and provide 1-2 example sentences with the word in ${sampleSentenceLanguage}.
  828. e.g. jargon \\ˈdʒɑrɡən\\ 行话,专业术语,特定领域内使用的专业词汇。在计算机科学和编程领域,指那些对外行人难以理解的专业术语和缩写。
  829. 例句: "When explaining code to beginners, try to avoid using too much technical jargon that might confuse them."(向初学者解释代码时,尽量避免使用太多可能让他们困惑的技术行话。)
  830.  
  831. # Format
  832.  
  833. - Output the words first, then the explanation, and then the example sentences in ${sampleSentenceLanguage} if necessary.
  834. - No extra explanation
  835. - Remember to using proper html format like <p> <b> <i> <a> <li> <ol> <ul> to improve readability.
  836. `,
  837. systemPrompt
  838. };
  839. }
  840.  
  841. // Main function to process selected text
  842. async function processSelectedText() {
  843. // Use the utility function instead of the local getSelectedText
  844. const { selectedText, textBefore, textAfter, paragraphText } = GetSelectionContext();
  845.  
  846. if (!selectedText) {
  847. showError('No text selected');
  848. return;
  849. }
  850.  
  851. console.log(`Selected text: '${selectedText}', Paragraph text:\n${paragraphText}`);
  852. // Create popup
  853. createPopup();
  854. const contentDiv = document.getElementById('explainer-content');
  855. const loadingDiv = document.getElementById('explainer-loading');
  856. const errorDiv = document.getElementById('explainer-error');
  857.  
  858. // Reset display
  859. errorDiv.style.display = 'none';
  860. loadingDiv.style.display = 'block';
  861.  
  862. // Assemble prompt with language preference
  863. const { prompt, systemPrompt } = getPrompt(selectedText, paragraphText, textBefore, textAfter);
  864.  
  865. // Variable to store ongoing response text
  866. let responseText = '';
  867.  
  868. try {
  869. // Call LLM with progress callback and await the full response
  870. const fullResponse = await callLLM(prompt, systemPrompt, (textChunk, currentFullText) => {
  871. // Update response text with new chunk
  872. responseText = currentFullText || (responseText + textChunk);
  873.  
  874. // Hide loading message if this is the first chunk
  875. if (loadingDiv.style.display !== 'none') {
  876. loadingDiv.style.display = 'none';
  877. }
  878.  
  879. // Update content with either HTML or markdown
  880. updateContentDisplay(contentDiv, responseText);
  881. });
  882.  
  883. console.log('fullResponse\n', fullResponse);
  884.  
  885. // If we got a response
  886. if (fullResponse && fullResponse.length > 0) {
  887. responseText = fullResponse;
  888. loadingDiv.style.display = 'none';
  889. updateContentDisplay(contentDiv, fullResponse);
  890. }
  891. // If no response was received at all
  892. else if (!fullResponse || fullResponse.length === 0) {
  893. // If we've received chunks but the final response is empty, use the accumulated text
  894. if (responseText && responseText.length > 0) {
  895. updateContentDisplay(contentDiv, responseText);
  896. } else {
  897. showError("No response received from the model. Please try again.");
  898. }
  899. }
  900.  
  901. // Hide loading indicator if it's still visible
  902. if (loadingDiv.style.display !== 'none') {
  903. loadingDiv.style.display = 'none';
  904. }
  905. } catch (error) {
  906. console.error('Error:', error);
  907. // Display error in popup
  908. showError(`Error: ${error.message}`);
  909. }
  910. }
  911.  
  912. // Main function to handle keyboard shortcuts
  913. function handleKeyPress(e) {
  914. // Get shortcut configuration from settings
  915. const shortcut = config.shortcut || { key: 'd', ctrlKey: false, altKey: true, shiftKey: false, metaKey: false };
  916.  
  917. // More robust shortcut detection using both key and code properties
  918. if (isShortcutMatch(e, shortcut)) {
  919. e.preventDefault();
  920. processSelectedText();
  921. }
  922. }
  923.  
  924. // Helper function for more robust shortcut detection
  925. function isShortcutMatch(event, shortcutConfig) {
  926. // Check all modifier keys first
  927. if (event.ctrlKey !== !!shortcutConfig.ctrlKey ||
  928. event.altKey !== !!shortcutConfig.altKey ||
  929. event.shiftKey !== !!shortcutConfig.shiftKey ||
  930. event.metaKey !== !!shortcutConfig.metaKey) {
  931. return false;
  932. }
  933.  
  934. const key = shortcutConfig.key.toLowerCase();
  935.  
  936. // Method 1: Direct key match (works for most standard keys)
  937. if (event.key.toLowerCase() === key) {
  938. return true;
  939. }
  940.  
  941. // Method 2: Key code match (more reliable for letter keys)
  942. // This handles the physical key position regardless of keyboard layout
  943. if (key.length === 1 && /^[a-z]$/.test(key) &&
  944. event.code === `Key${key.toUpperCase()}`) {
  945. return true;
  946. }
  947.  
  948. // Method 3: Handle known special characters from Option/Alt key combinations
  949. // These are the most common mappings on macOS when using Option+key
  950. const macOptionKeyMap = {
  951. 'a': 'å', 'b': '∫', 'c': 'ç', 'd': '∂', 'e': '´', 'f': 'ƒ',
  952. 'g': '©', 'h': '˙', 'i': 'ˆ', 'j': '∆', 'k': '˚', 'l': '¬',
  953. 'm': 'µ', 'n': '˜', 'o': 'ø', 'p': 'π', 'q': 'œ', 'r': '®',
  954. 's': 'ß', 't': '†', 'u': '¨', 'v': '√', 'w': '∑', 'x': '≈',
  955. 'y': '¥', 'z': 'Ω'
  956. };
  957.  
  958. if (shortcutConfig.altKey && macOptionKeyMap[key] === event.key) {
  959. return true;
  960. }
  961.  
  962. return false;
  963. }
  964.  
  965. // Helper function to update content display
  966. function updateContentDisplay(contentDiv, text) {
  967. if (!text) return;
  968.  
  969. text = text.trim();
  970. if (text.length === 0) {
  971. return;
  972. }
  973.  
  974. try {
  975. // drop first line if it's a code block
  976. if (text.startsWith('```')) {
  977. if (text.endsWith('```')) {
  978. text = text.split('\n').slice(1, -1).join('\n');
  979. } else {
  980. text = text.split('\n').slice(1).join('\n');
  981. }
  982. }
  983. if (!text.startsWith('<')) {
  984. // fallback
  985. console.log(`Seems like the response is not HTML: ${text}`);
  986. text = `<p>${text.replace(/\n/g, '<br>')}</p>`;
  987. }
  988. contentDiv.innerHTML = text;
  989. } catch (e) {
  990. // Fallback if parsing fails
  991. console.error(`Error parsing content: ${e.message}`);
  992. contentDiv.innerHTML = `<p>${text.replace(/\n/g, '<br>')}</p>`;
  993. }
  994. }
  995.  
  996. // Monitor selection changes for floating button
  997. function handleSelectionChange() {
  998. // Don't update button visibility if we're processing text
  999. if (isProcessingText) return;
  1000.  
  1001. const selection = window.getSelection();
  1002. const hasSelection = selection && selection.toString().trim() !== '';
  1003.  
  1004. if (hasSelection && isTouchDevice() && config.floatingButton.enabled) {
  1005. // Small delay to ensure selection is fully updated
  1006. setTimeout(showFloatingButton, 100);
  1007. } else {
  1008. hideFloatingButton();
  1009. }
  1010. }
  1011.  
  1012. // Settings update callback
  1013. function onSettingsChanged(updatedConfig) {
  1014. config = updatedConfig;
  1015. console.log('Settings updated:', config);
  1016.  
  1017. // Recreate floating button if settings changed
  1018. if (floatingButton) {
  1019. floatingButton.remove();
  1020. floatingButton = null;
  1021.  
  1022. if (isTouchDevice() && config.floatingButton.enabled) {
  1023. createFloatingButton();
  1024. handleSelectionChange(); // Check if there's already a selection
  1025. }
  1026. }
  1027. }
  1028.  
  1029. // Initialize the script
  1030. function init() {
  1031. // Register settings menu in Tampermonkey
  1032. GM_registerMenuCommand("Text Explainer Settings", () => {
  1033. settingsManager.openDialog(onSettingsChanged);
  1034. });
  1035.  
  1036. // Add keyboard shortcut listener
  1037. document.addEventListener('keydown', handleKeyPress);
  1038.  
  1039. // For touch devices, create floating button
  1040. if (isTouchDevice() && config.floatingButton.enabled) {
  1041. createFloatingButton();
  1042.  
  1043. // Monitor text selection
  1044. document.addEventListener('selectionchange', handleSelectionChange);
  1045.  
  1046. // Add touchend handler to show button after selection
  1047. document.addEventListener('touchend', () => {
  1048. // Small delay to ensure selection is updated
  1049. setTimeout(handleSelectionChange, 100);
  1050. });
  1051. }
  1052.  
  1053. console.log('Text Explainer script initialized with language: ' + config.language);
  1054. console.log('Touch device detected: ' + isTouchDevice());
  1055. }
  1056.  
  1057. // Run initialization
  1058. init();
  1059. })();