Universal DeepSeek Text Selection

通用型选中文本翻译/解释工具,支持复杂动态网页

  1. // ==UserScript==
  2. // @name Universal DeepSeek Text Selection
  3. // @namespace http://tampermonkey.net/
  4. // @version 3.7
  5. // @description 通用型选中文本翻译/解释工具,支持复杂动态网页
  6. // @author You
  7. // @match *://*/*
  8. // @grant GM_xmlhttpRequest
  9. // @grant GM_addStyle
  10. // @grant GM_setValue
  11. // @grant GM_getValue
  12. // @grant GM_deleteValue
  13. // @connect api.deepseek.com
  14. // @connect api.deepseek.ai
  15. // @connect *
  16. // @run-at document-start
  17. // @license MIT
  18. // ==/UserScript==
  19.  
  20. (function () {
  21. 'use strict';
  22.  
  23. const CONFIG = {
  24. API_KEY: '',
  25. API_URL: 'https://api.deepseek.com/v1/chat/completions',
  26. MAX_RETRIES: 3,
  27. RETRY_DELAY: 1000,
  28. RETRY_BACKOFF_FACTOR: 1.5,
  29. DEBOUNCE_DELAY: 200,
  30. SHORTCUTS: {
  31. translate: 'Alt+T',
  32. explain: 'Alt+E',
  33. summarize: 'Alt+S'
  34. },
  35. MAX_TEXT_LENGTH: 5000,
  36. MIN_TEXT_LENGTH: 1,
  37. ERROR_DISPLAY_TIME: 3000,
  38. ANIMATION_DURATION: 200,
  39. MENU_FADE_DELAY: 150,
  40. CACHE_DURATION: 3600000, // 1小时
  41. MAX_CACHE_ITEMS: 50,
  42. LOADING_MESSAGES: [
  43. '正在思考中...',
  44. '处理中,请稍候...',
  45. '马上就好...',
  46. '正在分析文本...'
  47. ],
  48. LOADING_INTERVAL: 2000,
  49. MAX_RESULT_HEIGHT: 400,
  50. SCROLLBAR_WIDTH: 15,
  51. };
  52.  
  53. // 样式注入
  54. GM_addStyle(`
  55. #ai-floating-menu {
  56. all: initial;
  57. position: fixed;
  58. z-index: 2147483647;
  59. background: white;
  60. border-radius: 8px;
  61. box-shadow: 0 2px 10px rgba(0,0,0,0.2);
  62. padding: 5px;
  63. opacity: 1;
  64. visibility: visible;
  65. transition: opacity ${CONFIG.ANIMATION_DURATION}ms ease,
  66. visibility ${CONFIG.ANIMATION_DURATION}ms ease;
  67. font-family: system-ui, -apple-system, sans-serif;
  68. animation: fadeIn 0.3s ease;
  69. }
  70.  
  71. @keyframes fadeIn {
  72. 0% { opacity: 0; transform: scale(0.9); }
  73. 100% { opacity: 1; transform: scale(1); }
  74. }
  75.  
  76. #ai-floating-menu.hiding {
  77. opacity: 0;
  78. visibility: hidden;
  79. }
  80.  
  81. #ai-floating-menu button {
  82. all: initial;
  83. display: block;
  84. width: 120px;
  85. margin: 3px;
  86. padding: 8px 12px;
  87. background: #2c3e50;
  88. color: white;
  89. border: none;
  90. border-radius: 4px;
  91. cursor: pointer;
  92. font-family: inherit;
  93. font-size: 14px;
  94. text-align: center;
  95. transition: all 0.2s;
  96. position: relative;
  97. overflow: hidden;
  98. }
  99.  
  100. #ai-floating-menu button:hover {
  101. background: #34495e;
  102. transform: translateY(-1px);
  103. }
  104.  
  105. #ai-floating-menu button:active {
  106. transform: translateY(1px);
  107. }
  108.  
  109. #ai-floating-menu button.processing {
  110. pointer-events: none;
  111. opacity: 0.7;
  112. }
  113.  
  114. #ai-floating-menu button.processing::after {
  115. content: '';
  116. position: absolute;
  117. bottom: 0;
  118. left: 0;
  119. height: 2px;
  120. width: 100%;
  121. background: linear-gradient(to right, #3498db, #2ecc71);
  122. animation: loading-bar 2s infinite linear;
  123. }
  124.  
  125. #ai-floating-menu .shortcut {
  126. float: right;
  127. font-size: 12px;
  128. opacity: 0.7;
  129. }
  130.  
  131. #ai-result-box {
  132. all: initial;
  133. position: fixed;
  134. z-index: 2147483648;
  135. background: white;
  136. border-radius: 8px;
  137. box-shadow: 0 3px 15px rgba(0,0,0,0.2);
  138. padding: 15px;
  139. min-width: 200px;
  140. max-width: 500px;
  141. max-height: ${CONFIG.MAX_RESULT_HEIGHT}px;
  142. opacity: 1;
  143. visibility: visible;
  144. transition: opacity ${CONFIG.ANIMATION_DURATION}ms ease,
  145. visibility ${CONFIG.ANIMATION_DURATION}ms ease,
  146. transform 0.2s ease;
  147. font-family: system-ui, -apple-system, sans-serif;
  148. font-size: 14px;
  149. line-height: 1.6;
  150. color: #333;
  151. overflow: auto;
  152. transform: translateY(0);
  153. animation: fadeIn 0.3s ease;
  154. cursor: grab;
  155. user-select: none;
  156. }
  157.  
  158. #ai-result-box .content {
  159. cursor: default;
  160. user-select: text;
  161. }
  162.  
  163. #ai-result-box.hiding {
  164. opacity: 0;
  165. visibility: hidden;
  166. transform: translateY(10px);
  167. }
  168.  
  169. #ai-result-box .close-btn {
  170. all: initial;
  171. position: absolute;
  172. top: 8px;
  173. right: 8px;
  174. width: 20px;
  175. height: 20px;
  176. line-height: 20px;
  177. text-align: center;
  178. background: #f0f0f0;
  179. border: none;
  180. border-radius: 50%;
  181. cursor: pointer;
  182. font-family: inherit;
  183. font-size: 14px;
  184. color: #666;
  185. transition: all 0.2s;
  186. }
  187.  
  188. #ai-result-box .close-btn:hover {
  189. background: #e0e0e0;
  190. transform: rotate(90deg);
  191. }
  192.  
  193. #ai-result-box .content {
  194. margin-top: 5px;
  195. white-space: pre-wrap;
  196. word-break: break-word;
  197. line-height: 1.6;
  198. font-size: 14px;
  199. color: #2c3e50;
  200. }
  201.  
  202. #ai-result-box .error {
  203. color: #e74c3c;
  204. background: #fde8e7;
  205. padding: 10px;
  206. border-radius: 4px;
  207. margin-bottom: 10px;
  208. animation: shake 0.5s ease-in-out;
  209. }
  210.  
  211. #ai-result-box .loading-container {
  212. display: flex;
  213. flex-direction: column;
  214. align-items: center;
  215. justify-content: center;
  216. padding: 20px;
  217. text-align: center;
  218. }
  219.  
  220. .loading-spinner {
  221. display: inline-block;
  222. width: 30px;
  223. height: 30px;
  224. border: 3px solid #f3f3f3;
  225. border-top: 3px solid #3498db;
  226. border-radius: 50%;
  227. animation: spin 1s linear infinite;
  228. margin-bottom: 10px;
  229. }
  230.  
  231. .loading-text {
  232. color: #666;
  233. font-size: 14px;
  234. margin-top: 10px;
  235. min-height: 20px;
  236. transition: opacity 0.3s;
  237. }
  238.  
  239. @keyframes spin {
  240. 0% { transform: rotate(0deg); }
  241. 100% { transform: rotate(360deg); }
  242. }
  243.  
  244. @keyframes loading-bar {
  245. 0% { transform: translateX(-100%); }
  246. 100% { transform: translateX(100%); }
  247. }
  248.  
  249. @keyframes shake {
  250. 0%, 100% { transform: translateX(0); }
  251. 25% { transform: translateX(-5px); }
  252. 75% { transform: translateX(5px); }
  253. }
  254.  
  255. @media (prefers-color-scheme: dark) {
  256. #ai-floating-menu,
  257. #ai-result-box {
  258. background: #2c3e50;
  259. color: #ecf0f1;
  260. }
  261.  
  262. #ai-result-box .content {
  263. color: #ecf0f1;
  264. }
  265.  
  266. #ai-result-box .error {
  267. background: #4a1c17;
  268. }
  269.  
  270. .loading-text {
  271. color: #ecf0f1;
  272. }
  273. }
  274. `);
  275. // 工具函数
  276. const utils = {
  277. debounce(func, wait) {
  278. let timeout;
  279. return function (...args) {
  280. clearTimeout(timeout);
  281. timeout = setTimeout(() => func.apply(this, args), wait);
  282. };
  283. },
  284.  
  285. async retry(fn, retries = CONFIG.MAX_RETRIES, delay = CONFIG.RETRY_DELAY) {
  286. try {
  287. return await fn();
  288. } catch (error) {
  289. if (retries === 0) throw error;
  290. await new Promise(resolve => setTimeout(resolve, delay));
  291. return this.retry(fn, retries - 1, delay * CONFIG.RETRY_BACKOFF_FACTOR);
  292. }
  293. },
  294.  
  295. createLoadingSpinner() {
  296. return `
  297. <div class="loading-container">
  298. <div class="loading-spinner"></div>
  299. <div class="loading-text">${CONFIG.LOADING_MESSAGES[0]}</div>
  300. </div>
  301. `;
  302. },
  303.  
  304. isValidText(text) {
  305. return text &&
  306. text.length >= CONFIG.MIN_TEXT_LENGTH &&
  307. text.length <= CONFIG.MAX_TEXT_LENGTH;
  308. },
  309.  
  310. rotateLoadingMessage() {
  311. const loadingText = document.querySelector('.loading-text');
  312. if (!loadingText) return;
  313.  
  314. let currentIndex = 0;
  315. return setInterval(() => {
  316. currentIndex = (currentIndex + 1) % CONFIG.LOADING_MESSAGES.length;
  317. loadingText.style.opacity = '0';
  318. setTimeout(() => {
  319. loadingText.textContent = CONFIG.LOADING_MESSAGES[currentIndex];
  320. loadingText.style.opacity = '1';
  321. }, 300);
  322. }, CONFIG.LOADING_INTERVAL);
  323. }
  324. };
  325.  
  326. // 缓存管理类
  327. class CacheManager {
  328. static getKey(text, action) {
  329. return `${action}_${text}`;
  330. }
  331.  
  332. static async get(text, action) {
  333. const key = this.getKey(text, action);
  334. const cached = GM_getValue(key);
  335. if (cached && Date.now() - cached.timestamp < CONFIG.CACHE_DURATION) {
  336. return cached.data;
  337. }
  338. return null;
  339. }
  340.  
  341. static async set(text, action, data) {
  342. const key = this.getKey(text, action);
  343. const cache = {
  344. data,
  345. timestamp: Date.now()
  346. };
  347.  
  348. const keys = Object.keys(GM_getValue('cache_keys', {}));
  349. if (keys.length >= CONFIG.MAX_CACHE_ITEMS) {
  350. const oldestKey = keys[0];
  351. GM_deleteValue(oldestKey);
  352. keys.shift();
  353. }
  354.  
  355. keys.push(key);
  356. GM_setValue('cache_keys', keys);
  357. GM_setValue(key, cache);
  358. }
  359. }
  360.  
  361. // API调用类
  362. class APIClient {
  363. static async call(text, action) {
  364. const cached = await CacheManager.get(text, action);
  365. if (cached) return cached;
  366.  
  367. if (!utils.isValidText(text)) {
  368. throw new Error(`文本长度应在${CONFIG.MIN_TEXT_LENGTH}至${CONFIG.MAX_TEXT_LENGTH}字符之间`);
  369. }
  370.  
  371. const prompts = {
  372. translate: '将以下内容翻译成中文,保持专业性和准确性:',
  373. explain: '请详细解释以下内容,如果包含专业术语请着重说明:',
  374. summarize: '请提炼以下内容的关键要点,以简洁的要点形式列出:'
  375. };
  376.  
  377. let retryCount = 0;
  378. const maxRetries = CONFIG.MAX_RETRIES;
  379.  
  380. while (retryCount < maxRetries) {
  381. try {
  382. const response = await this.makeRequest(text, prompts[action]);
  383. const result = this.processResponse(response);
  384. await CacheManager.set(text, action, result);
  385. return result;
  386. } catch (error) {
  387. retryCount++;
  388. if (retryCount === maxRetries) throw error;
  389.  
  390. await new Promise(resolve =>
  391. setTimeout(resolve, CONFIG.RETRY_DELAY * Math.pow(CONFIG.RETRY_BACKOFF_FACTOR, retryCount))
  392. );
  393. }
  394. }
  395. }
  396.  
  397. static async makeRequest(text, prompt) {
  398. return new Promise((resolve, reject) => {
  399. GM_xmlhttpRequest({
  400. method: 'POST',
  401. url: CONFIG.API_URL,
  402. headers: {
  403. 'Content-Type': 'application/json',
  404. 'Authorization': `Bearer ${CONFIG.API_KEY}`
  405. },
  406. data: JSON.stringify({
  407. model: 'deepseek-chat',
  408. messages: [{
  409. role: 'user',
  410. content: `${prompt}\n\n${text}`
  411. }],
  412. temperature: 0.7,
  413. max_tokens: 2000,
  414. presence_penalty: 0.6,
  415. frequency_penalty: 0.5
  416. }),
  417. timeout: 30000,
  418. onload: resolve,
  419. onerror: reject,
  420. ontimeout: () => reject(new Error('请求超时'))
  421. });
  422. });
  423. }
  424.  
  425. static processResponse(res) {
  426. if (res.status !== 200) {
  427. throw new Error(`API错误: ${res.status}`);
  428. }
  429.  
  430. try {
  431. const data = JSON.parse(res.responseText);
  432. if (!data.choices?.[0]?.message?.content) {
  433. throw new Error('API返回格式错误');
  434. }
  435. return data.choices[0].message.content;
  436. } catch (e) {
  437. throw new Error('解析响应失败');
  438. }
  439. }
  440.  
  441. static getErrorMessage(error) {
  442. const errorMessages = {
  443. 'Network Error': '网络连接失败',
  444. 'Timeout': '请求超时',
  445. 'API错误: 429': '请求过于频繁,请稍后再试',
  446. 'API错误: 401': 'API密钥无效',
  447. 'API错误: 403': '没有访问权限'
  448. };
  449. return errorMessages[error.message] || error.message;
  450. }
  451. }
  452. // UI管理类
  453. class UIManager {
  454. static ensureElementsExist() {
  455. if (!document.getElementById('ai-floating-menu')) {
  456. const menu = document.createElement('div');
  457. menu.id = 'ai-floating-menu';
  458. menu.style.display = 'none';
  459. menu.innerHTML = `
  460. <button data-action="translate">翻译为中文 <span class="shortcut">Alt+T</span></button>
  461. <button data-action="explain">解释内容 <span class="shortcut">Alt+E</span></button>
  462. <button data-action="summarize">总结要点 <span class="shortcut">Alt+S</span></button>
  463. `;
  464. document.body.appendChild(menu);
  465. }
  466.  
  467. if (!document.getElementById('ai-result-box')) {
  468. const resultBox = document.createElement('div');
  469. resultBox.id = 'ai-result-box';
  470. resultBox.style.display = 'none';
  471. resultBox.innerHTML = `
  472. <button class="close-btn">×</button>
  473. <div class="content"></div>
  474. `;
  475. document.body.appendChild(resultBox);
  476. }
  477. }
  478.  
  479. static async showMenu(x, y) {
  480. this.ensureElementsExist();
  481. await this.hideAll();
  482.  
  483. const menu = document.getElementById('ai-floating-menu');
  484. const { left, top } = this.calculateOptimalPosition(x, y, menu);
  485.  
  486. menu.style.left = `${left}px`;
  487. menu.style.top = `${top}px`;
  488. menu.style.display = 'block';
  489. menu.offsetHeight; // 触发重排
  490. menu.classList.remove('hiding');
  491. }
  492.  
  493. static async showResult(content, x, y) {
  494. this.ensureElementsExist();
  495. await this.hideMenu();
  496.  
  497. const resultBox = document.getElementById('ai-result-box');
  498. const contentDiv = resultBox.querySelector('.content');
  499.  
  500. if (content.startsWith('错误:')) {
  501. contentDiv.classList.add('error');
  502. setTimeout(() => {
  503. this.hideAll();
  504. contentDiv.classList.remove('error');
  505. }, CONFIG.ERROR_DISPLAY_TIME);
  506. } else {
  507. contentDiv.classList.remove('error');
  508. }
  509.  
  510. contentDiv.innerHTML = content;
  511.  
  512. const { left, top } = this.calculateResultPosition(x, y, resultBox);
  513. resultBox.style.left = `${left}px`;
  514. resultBox.style.top = `${top}px`;
  515. resultBox.style.display = 'block';
  516. resultBox.offsetHeight; // 触发重排
  517. resultBox.classList.remove('hiding');
  518.  
  519. return content.includes('loading-container') ? utils.rotateLoadingMessage() : null;
  520. }
  521.  
  522. static calculateOptimalPosition(x, y, element) {
  523. const margin = 10;
  524. const maxWidth = Math.min(500, window.innerWidth - 2 * margin);
  525. element.style.maxWidth = `${maxWidth}px`;
  526.  
  527. let left = Math.max(margin, Math.min(x, window.innerWidth - element.offsetWidth - margin));
  528. let top = Math.max(margin, Math.min(y, window.innerHeight - element.offsetHeight - margin));
  529.  
  530. return { left, top };
  531. }
  532.  
  533. static calculateResultPosition(x, y, element) {
  534. const margin = 20;
  535. const maxWidth = Math.min(500, window.innerWidth - 2 * margin);
  536. element.style.maxWidth = `${maxWidth}px`;
  537.  
  538. const selection = window.getSelection();
  539. let selectionRect = null;
  540. if (selection.rangeCount > 0) {
  541. selectionRect = selection.getRangeAt(0).getBoundingClientRect();
  542. }
  543.  
  544. let left, top;
  545.  
  546. if (selectionRect) {
  547. // 优先显示在选区下方
  548. left = selectionRect.left;
  549. top = selectionRect.bottom + margin;
  550.  
  551. // 如果底部空间不足,则显示在选区上方
  552. if (top + element.offsetHeight > window.innerHeight - margin) {
  553. top = Math.max(margin, selectionRect.top - element.offsetHeight - margin);
  554. }
  555.  
  556. // 如果水平方向超出屏幕,进行调整
  557. if (left + maxWidth > window.innerWidth - margin) {
  558. left = Math.max(margin, window.innerWidth - maxWidth - margin);
  559. }
  560. } else {
  561. // 如果没有选区,则根据鼠标位置
  562. left = Math.max(margin, Math.min(x, window.innerWidth - maxWidth - margin));
  563. top = Math.max(margin, Math.min(y, window.innerHeight - element.offsetHeight - margin));
  564. }
  565.  
  566. return { left, top };
  567. }
  568.  
  569. static async hideMenu() {
  570. const menu = document.getElementById('ai-floating-menu');
  571. if (menu && menu.style.display !== 'none') {
  572. menu.classList.add('hiding');
  573. await new Promise(resolve => setTimeout(resolve, CONFIG.ANIMATION_DURATION));
  574. menu.style.display = 'none';
  575. }
  576. }
  577.  
  578. static async hideAll() {
  579. const menu = document.getElementById('ai-floating-menu');
  580. const resultBox = document.getElementById('ai-result-box');
  581.  
  582. const promises = [];
  583.  
  584. if (menu && menu.style.display !== 'none') {
  585. menu.classList.add('hiding');
  586. promises.push(new Promise(resolve => setTimeout(resolve, CONFIG.ANIMATION_DURATION)));
  587. }
  588.  
  589. if (resultBox && resultBox.style.display !== 'none') {
  590. resultBox.classList.add('hiding');
  591. promises.push(new Promise(resolve => setTimeout(resolve, CONFIG.ANIMATION_DURATION)));
  592. }
  593.  
  594. await Promise.all(promises);
  595.  
  596. if (menu) menu.style.display = 'none';
  597. if (resultBox) resultBox.style.display = 'none';
  598. }
  599. }
  600.  
  601. // 文本选择管理类
  602. class SelectionManager {
  603. static getSelectedText() {
  604. let text = '';
  605. let range = null;
  606.  
  607. const selection = window.getSelection();
  608. text = selection.toString().trim();
  609. if (text && selection.rangeCount > 0) {
  610. range = selection.getRangeAt(0);
  611. return { text, range };
  612. }
  613.  
  614. try {
  615. const iframes = document.getElementsByTagName('iframe');
  616. for (const iframe of iframes) {
  617. try {
  618. const iframeSelection = iframe.contentWindow.getSelection();
  619. const iframeText = iframeSelection.toString().trim();
  620. if (iframeText) {
  621. return {
  622. text: iframeText,
  623. range: iframeSelection.rangeCount > 0 ? iframeSelection.getRangeAt(0) : null
  624. };
  625. }
  626. } catch (e) {
  627. console.debug('无法访问iframe内容:', e);
  628. }
  629. }
  630. } catch (e) {
  631. console.debug('处理iframe时出错:', e);
  632. }
  633.  
  634. const activeElement = document.activeElement;
  635. if (activeElement && (activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA')) {
  636. const start = activeElement.selectionStart;
  637. const end = activeElement.selectionEnd;
  638. if (start !== end) {
  639. text = activeElement.value.substring(start, end).trim();
  640. return { text, range: null };
  641. }
  642. }
  643.  
  644. return { text: '', range: null };
  645. }
  646. }
  647.  
  648. // 事件处理类
  649. class EventHandler {
  650. static init() {
  651. UIManager.ensureElementsExist();
  652. this.setupEventListeners();
  653. this.setupIntersectionObserver();
  654. this.setupResizeObserver();
  655. this.setupDraggable();
  656. }
  657.  
  658. static setupEventListeners() {
  659. const menu = document.getElementById('ai-floating-menu');
  660. const resultBox = document.getElementById('ai-result-box');
  661.  
  662. // 使用事件委托处理按钮点击
  663. document.addEventListener('click', async (e) => {
  664. const button = e.target.closest('#ai-floating-menu button');
  665. if (!button) return;
  666.  
  667. const action = button.dataset.action;
  668. const { text } = SelectionManager.getSelectedText();
  669. if (!text) return;
  670.  
  671. button.classList.add('processing');
  672. await this.handleAction(action, text, e.clientX, e.clientY);
  673. button.classList.remove('processing');
  674. });
  675.  
  676. // 关闭按钮
  677. resultBox.querySelector('.close-btn').addEventListener('click', () => {
  678. UIManager.hideAll();
  679. });
  680.  
  681. // 点击外部隐藏菜单和结果框
  682. document.addEventListener('mousedown', (e) => {
  683. if (!menu.contains(e.target) && !resultBox.contains(e.target)) {
  684. UIManager.hideAll();
  685. }
  686. }, true);
  687.  
  688. // 快捷键支持
  689. document.addEventListener('keydown', (e) => {
  690. for (const [action, shortcut] of Object.entries(CONFIG.SHORTCUTS)) {
  691. const [modifier, key] = shortcut.split('+');
  692. if (e[`${modifier.toLowerCase()}Key`] && e.key.toUpperCase() === key) {
  693. e.preventDefault();
  694. const { text } = SelectionManager.getSelectedText();
  695. if (text) {
  696. this.handleAction(action, text, e.clientX, e.clientY);
  697. }
  698. }
  699. }
  700. });
  701.  
  702. // 文本选择监听
  703. this.addSelectionListeners();
  704.  
  705. // 触摸屏支持
  706. document.addEventListener('touchend', (e) => {
  707. const { text } = SelectionManager.getSelectedText();
  708. if (text) {
  709. const touch = e.changedTouches[0];
  710. UIManager.showMenu(touch.clientX, touch.clientY);
  711. }
  712. });
  713. }
  714.  
  715. static setupDraggable() {
  716. const resultBox = document.getElementById('ai-result-box');
  717. let isDragging = false;
  718. let currentX;
  719. let currentY;
  720. let initialX;
  721. let initialY;
  722.  
  723. resultBox.addEventListener('mousedown', (e) => {
  724. if (e.target.classList.contains('close-btn') ||
  725. e.target.closest('.content')) return;
  726.  
  727. isDragging = true;
  728. initialX = e.clientX - resultBox.offsetLeft;
  729. initialY = e.clientY - resultBox.offsetTop;
  730.  
  731. resultBox.style.cursor = 'grabbing';
  732. });
  733.  
  734. document.addEventListener('mousemove', (e) => {
  735. if (!isDragging) return;
  736.  
  737. e.preventDefault();
  738. currentX = e.clientX - initialX;
  739. currentY = e.clientY - initialY;
  740.  
  741. // 限制在可视区域内
  742. currentX = Math.max(0, Math.min(currentX, window.innerWidth - resultBox.offsetWidth));
  743. currentY = Math.max(0, Math.min(currentY, window.innerHeight - resultBox.offsetHeight));
  744.  
  745. resultBox.style.left = `${currentX}px`;
  746. resultBox.style.top = `${currentY}px`;
  747. });
  748.  
  749. document.addEventListener('mouseup', () => {
  750. isDragging = false;
  751. resultBox.style.cursor = 'grab';
  752. });
  753. }
  754.  
  755. static addSelectionListeners(target = document) {
  756. const handleSelection = utils.debounce(async (e) => {
  757. const { text, range } = SelectionManager.getSelectedText();
  758. if (!text) {
  759. await UIManager.hideAll();
  760. return;
  761. }
  762.  
  763. let x = e?.clientX || 0;
  764. let y = e?.clientY || 0;
  765.  
  766. if (range) {
  767. try {
  768. const rect = range.getBoundingClientRect();
  769. if (rect.width > 0 && rect.height > 0) {
  770. x = rect.right;
  771. y = rect.bottom + 5;
  772. }
  773. } catch (e) {
  774. console.debug('获取选区位置失败:', e);
  775. }
  776. }
  777.  
  778. await UIManager.showMenu(x, y);
  779. }, CONFIG.DEBOUNCE_DELAY);
  780.  
  781. target.addEventListener('mouseup', handleSelection);
  782. target.addEventListener('keyup', handleSelection);
  783. target.addEventListener('selectionchange', handleSelection);
  784. }
  785.  
  786. static setupIntersectionObserver() {
  787. const observer = new IntersectionObserver((entries) => {
  788. entries.forEach(entry => {
  789. if (!entry.isIntersecting) {
  790. UIManager.hideAll();
  791. }
  792. });
  793. });
  794.  
  795. const menu = document.getElementById('ai-floating-menu');
  796. const resultBox = document.getElementById('ai-result-box');
  797. observer.observe(menu);
  798. observer.observe(resultBox);
  799. }
  800.  
  801. static setupResizeObserver() {
  802. const observer = new ResizeObserver(utils.debounce(() => {
  803. const menu = document.getElementById('ai-floating-menu');
  804. const resultBox = document.getElementById('ai-result-box');
  805. if (menu.style.display === 'block' || resultBox.style.display === 'block') {
  806. UIManager.hideAll();
  807. }
  808. }, 100));
  809.  
  810. observer.observe(document.body);
  811. }
  812.  
  813. static async handleAction(action, text, x, y) {
  814. let loadingMessageInterval;
  815. try {
  816. await UIManager.hideAll();
  817. loadingMessageInterval = await UIManager.showResult(utils.createLoadingSpinner(), x, y);
  818.  
  819. const response = await APIClient.call(text, action);
  820. clearInterval(loadingMessageInterval);
  821. UIManager.showResult(response, x, y);
  822. } catch (error) {
  823. if (loadingMessageInterval) clearInterval(loadingMessageInterval);
  824. UIManager.showResult(`错误: ${error.message}`, x, y);
  825. }
  826. }
  827. }
  828.  
  829. // 初始化
  830. if (document.readyState === 'loading') {
  831. document.addEventListener('DOMContentLoaded', () => EventHandler.init());
  832. } else {
  833. EventHandler.init();
  834. }
  835. })();