Linux Do Translate

对回复进行翻译

目前为 2024-11-01 提交的版本。查看 最新版本

  1. // ==UserScript==
  2. // @name Linux Do Translate
  3. // @namespace linux-do-translate
  4. // @version 0.1.5
  5. // @author delph1s
  6. // @license MIT
  7. // @description 对回复进行翻译
  8. // @match https://linux.do/t/topic/*
  9. // @connect *
  10. // @icon https://cdn.linux.do/uploads/default/original/3X/9/d/9dd49731091ce8656e94433a26a3ef36062b3994.png
  11. // @grant unsafeWindow
  12. // @grant window.close
  13. // @grant window.focus
  14. // @grant GM_setValue
  15. // @grant GM_getValue
  16. // @grant GM_xmlhttpRequest
  17. // @require https://cdn.jsdelivr.net/npm/pinyin-pro@3.25.0/dist/index.min.js
  18. // @run-at document-end
  19. // ==/UserScript==
  20.  
  21. (function () {
  22. 'use strict';
  23. const REQUIRED_CHARS = 6;
  24. const SPACE_PRESS_COUNT = 3; // 连按次数
  25. const SPACE_PRESS_TIMEOUT = 1500; // 连续按键的最大时间间隔(毫秒)
  26. const DEFAULT_CONFIG = {
  27. maxRetryTimes: 5,
  28. deeplxUrl: '',
  29. deeplxAuthKey: '',
  30. authKeyPrefix: {
  31. deeplx: '',
  32. deeplOfficial: 'deepl-auth-key:',
  33. openAIOfficial: 'oai-at:',
  34. openAIProxy: 'oai-at-proxy:',
  35. },
  36. enableTranslate: false,
  37. translateTargetLang: {
  38. deeplx: 'EN',
  39. deeplOfficial: 'EN',
  40. openAIOfficial: 'English',
  41. openAIProxy: 'English',
  42. },
  43. translateModel: 'gpt-3.5-turbo',
  44. translateLayout: 'up',
  45. translateSize: 150,
  46. enablePinYin: false,
  47. closeConfigAfterSave: true,
  48. };
  49.  
  50. const uiIDs = {
  51. replyControl: 'reply-control',
  52. configButton: 'multi-lang-say-config-button',
  53. configPanel: 'multi-lang-say-config-panel',
  54. deeplxUrlInput: 'deeplx-url-input',
  55. deeplxAuthKeyInput: 'deeplx-authkey-input',
  56. translateModelInput: 'translate-model-input',
  57. translateSizeInput: 'translate-size-input',
  58. };
  59.  
  60. let config = {
  61. maxRetryTimes: GM_getValue('maxRetryTimes', DEFAULT_CONFIG.maxRetryTimes),
  62. deeplxUrl: GM_getValue('deeplxUrl', DEFAULT_CONFIG.deeplxUrl),
  63. deeplxAuthKey: GM_getValue('deeplxAuthKey', DEFAULT_CONFIG.deeplxAuthKey),
  64. authKeyPrefix: GM_getValue('authKeyPrefix', DEFAULT_CONFIG.authKeyPrefix),
  65. enableTranslate: GM_getValue('enableTranslate', DEFAULT_CONFIG.enableTranslate),
  66. translateTargetLang: GM_getValue('translateTargetLang', DEFAULT_CONFIG.translateTargetLang),
  67. translateModel: GM_getValue('translateModel', DEFAULT_CONFIG.translateModel),
  68. translateLayout: GM_getValue('translateLayout', DEFAULT_CONFIG.translateLayout),
  69. translateSize: GM_getValue('translateSize', DEFAULT_CONFIG.translateSize),
  70. enablePinYin: GM_getValue('enablePinYin', DEFAULT_CONFIG.enablePinYin),
  71. closeConfigAfterSave: GM_getValue('closeConfigAfterSave', DEFAULT_CONFIG.closeConfigAfterSave),
  72. };
  73.  
  74. const checkPinYinRequire = () => {
  75. // 检查是否加载了 pinyin 库
  76. if (typeof pinyinPro === 'undefined') {
  77. console.error('pinyin 库未正确加载!');
  78. config.enablePinYin = false;
  79. GM_setValue('enablePinYin', false);
  80. return false;
  81. }
  82. return true;
  83. };
  84.  
  85. const genFormatDateTime = d => {
  86. return d.toLocaleString('zh-CN', {
  87. year: 'numeric',
  88. month: '2-digit',
  89. day: '2-digit',
  90. hour: '2-digit',
  91. minute: '2-digit',
  92. second: '2-digit',
  93. hour12: false,
  94. });
  95. };
  96.  
  97. const genFormatNow = () => {
  98. return genFormatDateTime(new Date());
  99. };
  100.  
  101. /**
  102. * 获取随机整数
  103. *
  104. * @param {number} start 范围开始
  105. * @param {number} end 范围结束
  106. * @returns
  107. */
  108. const randInt = (start, end) => {
  109. return Math.floor(Math.random() * (end - start + 1)) + start;
  110. };
  111.  
  112. /**
  113. * 随机睡眠(毫秒)
  114. *
  115. * @param {number} start 范围开始
  116. * @param {number} end 范围结束
  117. */
  118. const randSleep = async (start = 2000, end = 3000) => {
  119. // 生成随机整数 randSleepTime,范围在 start 到 end 之间
  120. const randSleepTime = getRandomInt(start, end);
  121. // 睡眠时间
  122. return await new Promise(resolve => setTimeout(resolve, randSleepTime));
  123. };
  124.  
  125. /**
  126. * 是否相同
  127. *
  128. * @param a
  129. * @param b
  130. * @returns
  131. */
  132. const isEqual = (a, b) => {
  133. if (a === null || a === undefined || b === null || b === undefined) {
  134. return a === b;
  135. }
  136.  
  137. if (typeof a !== typeof b) {
  138. return false;
  139. }
  140.  
  141. if (typeof a === 'string' || typeof a === 'number' || typeof a === 'boolean') {
  142. return a === b;
  143. }
  144.  
  145. if (Array.isArray(a) && Array.isArray(b)) {
  146. if (a.length !== b.length) {
  147. return false;
  148. }
  149.  
  150. return a.every((item, index) => isEqual(item, b[index]));
  151. }
  152.  
  153. if (typeof a === 'object' && typeof b === 'object') {
  154. const keysA = Object.keys(a || {});
  155. const keysB = Object.keys(b || {});
  156.  
  157. if (keysA.length !== keysB.length) {
  158. return false;
  159. }
  160.  
  161. return keysA.every(key => isEqual(a[key], b[key]));
  162. }
  163.  
  164. return false;
  165. };
  166.  
  167. /**
  168. * 判断字符串中是否包含中文字符
  169. * @param {string} text
  170. * @returns {boolean}
  171. */
  172. const containsChinese = text => {
  173. return /[\u4e00-\u9fa5]/.test(text);
  174. };
  175.  
  176. /**
  177. * 将中文字符串转换为带拼音的ruby标签字符串
  178. * @param {string} text
  179. * @returns {string}
  180. */
  181. const convertToRuby = text => {
  182. // 使用tiny-pinyin将中文转换为拼音,拼音之间用空格分隔
  183. const pinyin = pinyinPro.pinyin(text);
  184. // 构建ruby标签
  185. return `<ruby><rb>${text}</rb><rt>${pinyin}</rt></ruby>`;
  186. };
  187.  
  188. const getInvertColor = hex => {
  189. // 去掉前面的“#”字符
  190. hex = hex.replace('#', '');
  191.  
  192. // 如果输入的是3位的hex值,转换为6位的
  193. if (hex.length === 3) {
  194. hex = hex
  195. .split('')
  196. .map(c => c + c)
  197. .join('');
  198. }
  199.  
  200. // 计算相反的颜色
  201. const r = (255 - parseInt(hex.slice(0, 2), 16)).toString(16).padStart(2, '0');
  202. const g = (255 - parseInt(hex.slice(2, 4), 16)).toString(16).padStart(2, '0');
  203. const b = (255 - parseInt(hex.slice(4, 6), 16)).toString(16).padStart(2, '0');
  204.  
  205. return `#${r}${g}${b}`;
  206. };
  207.  
  208. const deeplxReq = text => {
  209. return {
  210. url: config.deeplxUrl,
  211. headers: {
  212. 'Content-Type': 'application/json',
  213. },
  214. data: JSON.stringify({
  215. text: text,
  216. target_lang: 'EN',
  217. source_lang: 'auto',
  218. }),
  219. responseType: 'json',
  220. };
  221. };
  222.  
  223. const deeplxLinuxdoReq = text => {
  224. return {
  225. url: `https://api.deeplx.org/${config.deeplxAuthKey}/translate`,
  226. headers: {
  227. 'Content-Type': 'application/json',
  228. },
  229. data: JSON.stringify({
  230. text: text,
  231. target_lang: 'EN',
  232. source_lang: 'auto',
  233. }),
  234. responseType: 'json',
  235. };
  236. };
  237.  
  238. const deeplOfficialReq = text => {
  239. const authKey = config.deeplxAuthKey.replace(config.authKeyPrefix.deeplOfficial, '');
  240. const params = new URLSearchParams();
  241. params.append('text', text);
  242. params.append('target_lang', 'EN');
  243. params.append('source_lang', 'ZH');
  244. return {
  245. url: 'https://api.deepl.com/v2/translate', // DeepL Pro API
  246. headers: {
  247. Authorization: `DeepL-Auth-Key ${authKey}`,
  248. 'Content-Type': 'application/x-www-form-urlencoded',
  249. },
  250. data: params.toString(),
  251. responseType: 'json',
  252. };
  253. };
  254.  
  255. const deeplOfficialRes = res => {
  256. return res?.translations?.[0]?.text;
  257. };
  258.  
  259. const oaiOffcialReq = (
  260. text,
  261. model = 'gpt-3.5-turbo',
  262. url = 'https://api.openai.com/v1/chat/completions',
  263. temperature = 0.5,
  264. maxTokens = 32000
  265. ) => {
  266. const authKey = config.deeplxAuthKey.replace(config.authKeyPrefix.openAIOfficial, '');
  267. return {
  268. url: url,
  269. headers: {
  270. 'Content-Type': 'application/json',
  271. Authorization: `Bearer ${authKey}`,
  272. },
  273. data: JSON.stringify({
  274. model: model, // 或者您订阅的其他模型,例如 'gpt-4'
  275. messages: [
  276. {
  277. role: 'system',
  278. content:
  279. 'You are a highly skilled translation engine. Your function is to translate texts accurately into the target {{to}}, maintaining the original format, technical terms, and abbreviations. Do not add any explanations or annotations to the translated text.',
  280. },
  281. {
  282. role: 'user',
  283. content: `Translate the following source text to ${config.translateTargetLang.openAIOfficial}, Output translation directly without any additional text.\nSource Text: ${text}\nTranslated Text:`,
  284. },
  285. ],
  286. temperature: temperature, // 控制生成内容的随机性,范围是 0 到 1
  287. max_tokens: maxTokens, // 响应的最大标记数
  288. }),
  289. responseType: 'json',
  290. };
  291. };
  292.  
  293. const oaiOfficalRes = res => {
  294. return res.choices[0].message.content.trim();
  295. };
  296.  
  297. const translateText = text => {
  298. const isDeeplOfficial = config.deeplxAuthKey.startsWith(config.authKeyPrefix.deeplOfficial);
  299. const isOpenAIOfficial = config.deeplxAuthKey.startsWith(config.authKeyPrefix.openAIOfficial);
  300.  
  301. let reqData;
  302.  
  303. if (!config.deeplxAuthKey) {
  304. if (!config.deeplxUrl) return '';
  305. reqData = deeplxReq(text);
  306. } else if (isDeeplOfficial) {
  307. reqData = deeplOfficialReq(text);
  308. } else if (isOpenAIOfficial) {
  309. reqData = oaiOffcialReq(
  310. text,
  311. config.translateModel,
  312. config.deeplxUrl || 'https://api.openai.com/v1/chat/completions',
  313. 0.5,
  314. 1600
  315. );
  316. } else {
  317. reqData = deeplxLinuxdoReq(text);
  318. }
  319.  
  320. return new Promise((resolve, reject) => {
  321. GM_xmlhttpRequest({
  322. method: 'POST',
  323. url: reqData.url,
  324. headers: reqData.headers,
  325. data: reqData.data,
  326. responseType: reqData.responseType,
  327. onload: function (res) {
  328. console.log('Translation response:', res);
  329. console.log('Request details:', reqData);
  330.  
  331. if (res.status === 200) {
  332. try {
  333. const response = typeof res.response === 'string' ? JSON.parse(res.response) : res.response;
  334. console.log('Parsed response:', response);
  335.  
  336. let translation;
  337.  
  338. if (isDeeplOfficial) {
  339. // Pro API 返回格式
  340. translation = deeplOfficialRes(response);
  341. console.log('DeepL translation:', translation);
  342. } else if (isOpenAIOfficial) {
  343. translation = oaiOfficalRes(response);
  344. console.log('OAI translation:', translation);
  345. } else {
  346. translation = response?.data;
  347. console.log('DeepLX translation:', translation);
  348. }
  349.  
  350. resolve(translation || '');
  351. } catch (error) {
  352. console.error('Error parsing response:', error);
  353. resolve('');
  354. }
  355. } else {
  356. console.error('Translation failed:', {
  357. status: res.status,
  358. statusText: res.statusText,
  359. response: res.response,
  360. responseText: res.responseText,
  361. finalUrl: res.finalUrl,
  362. headers: res.responseHeaders,
  363. });
  364. resolve('');
  365. }
  366. },
  367. onerror: function (err) {
  368. console.error('Translation error details:', {
  369. error: err,
  370. errorText: err.toString(),
  371. status: err.status,
  372. statusText: err.statusText,
  373. responseText: err.responseText,
  374. });
  375. resolve('');
  376. },
  377. });
  378. });
  379.  
  380. // return GM_xmlhttpRequest({
  381. // method: 'POST',
  382. // url,
  383. // data: JSON.stringify(data),
  384. // responseType: 'json',
  385. // onload: res => {
  386. // console.log(res);
  387. // return res.response;
  388. // },
  389. // onerror: err => {
  390. // console.log(err);
  391. // return '';
  392. // },
  393. // });
  394.  
  395. // return GM.xmlHttpRequest({
  396. // method: 'POST',
  397. // url,
  398. // data: JSON.stringify(data),
  399. // responseType: 'json',
  400. // })
  401. // .then(res => {
  402. // console.log(res);
  403. // console.log(`[翻译结果] ${res.response.data}`)
  404. // return res.response.data;
  405. // })
  406. // .catch(err => {
  407. // console.log(err);
  408. // return '';
  409. // });
  410. };
  411.  
  412. const processTranslateText = async text => {
  413. // 定义需要保护的块的正则表达式
  414. const protectedBlocks = [
  415. // Markdown 代码块
  416. {
  417. regex: /```[\s\S]*?```/g,
  418. type: 'code',
  419. },
  420. // BBCode 标签块 (处理嵌套标签)
  421. {
  422. regex: /\[(size|spoiler|center|color|grid).*?\][\s\S]*?\[\/\1\]/g,
  423. type: 'bbcode',
  424. },
  425. // 已有的 ruby 标签
  426. {
  427. regex: /<ruby>[\s\S]*?<\/ruby>/g,
  428. type: 'ruby',
  429. },
  430. // HTML 标签块
  431. {
  432. regex: /<[^>]+>[\s\S]*?<\/[^>]+>/g,
  433. type: 'html',
  434. },
  435. // 图片标签
  436. {
  437. regex: /\[image\]\(.*?\)/g,
  438. type: 'image',
  439. },
  440. ];
  441.  
  442. // 创建占位符映射
  443. let placeholders = new Map();
  444. let placeholderCounter = 0;
  445.  
  446. // 保护特殊块
  447. let processedText = text;
  448. for (const block of protectedBlocks) {
  449. processedText = processedText.replace(block.regex, match => {
  450. const placeholder = `__PROTECTED_${block.type}_${placeholderCounter++}__`;
  451. placeholders.set(placeholder, match);
  452. return placeholder;
  453. });
  454. }
  455.  
  456. // 处理剩余文本
  457. const segments = processedText.split(/(\n)/);
  458. let translatedSegments = [];
  459.  
  460. for (const segment of segments) {
  461. if (!segment.trim() || segment === '\n') {
  462. translatedSegments.push(segment);
  463. continue;
  464. }
  465.  
  466. // 检查是否是占位符
  467. if (segment.startsWith('__PROTECTED_')) {
  468. translatedSegments.push(placeholders.get(segment));
  469. continue;
  470. }
  471.  
  472. // 翻译普通文本
  473. const segmentTranslate = await translateText(segment);
  474. if (config.translateLayout === 'down') {
  475. translatedSegments.push(`${segment}\n[size=${config.translateSize}]${segmentTranslate}[/size]`);
  476. } else {
  477. translatedSegments.push(
  478. `[size=${config.translateSize}]<ruby><rb>${segment}</rb><rt>${segmentTranslate}</rt></ruby>[/size]`
  479. );
  480. }
  481. }
  482.  
  483. // 合并结果
  484. return translatedSegments.join('');
  485. };
  486.  
  487. const processTextArea = () => {
  488. let textarea = document.querySelector(`#${uiIDs.replyControl} textarea`);
  489. let text = textarea.value.trim();
  490. let originalLength = text.length;
  491.  
  492. if (text.length !== 0 && originalLength >= REQUIRED_CHARS) {
  493. // 检查是否已存在拼音
  494. const rubyRegex = /(<ruby>[\s\S]*?<\/ruby>)/g;
  495.  
  496. // 为中文加入翻译
  497. if (config.enableTranslate) {
  498. textarea.value = '开始翻译...';
  499.  
  500. processTranslateText(text).then(res => {
  501. textarea.value = res;
  502.  
  503. // 创建并触发 input 事件
  504. const inputEvent = new Event('input', {
  505. bubbles: true,
  506. cancelable: true,
  507. });
  508. // 触发事件
  509. textarea.dispatchEvent(inputEvent);
  510. });
  511. return;
  512. }
  513.  
  514. // 为中文加入拼音
  515. if (!config.enableTranslate && config.enablePinYin) {
  516. if (containsChinese(text)) {
  517. // 使用正则表达式将文本分割为已被 <ruby> 包裹的部分和未被包裹的部分
  518. const parts = text.split(rubyRegex);
  519.  
  520. // 处理每一部分
  521. text = parts
  522. .map(part => {
  523. if (rubyRegex.test(part)) {
  524. // 已被 <ruby> 包裹,保持原样
  525. return part;
  526. } else {
  527. // 未被包裹,进一步分割为中文和非中文
  528. if (containsChinese(part)) {
  529. const segments = part.split(/([\u4e00-\u9fa5]+)/g);
  530. return segments
  531. .map(segment => {
  532. if (containsChinese(segment)) {
  533. return convertToRuby(segment);
  534. } else {
  535. return segment;
  536. }
  537. })
  538. .join('');
  539. } else {
  540. return part;
  541. }
  542. }
  543. })
  544. .join('');
  545. }
  546. }
  547.  
  548. textarea.value = text;
  549.  
  550. // 创建并触发 input 事件
  551. const inputEvent = new Event('input', {
  552. bubbles: true,
  553. cancelable: true,
  554. });
  555. // 触发事件
  556. textarea.dispatchEvent(inputEvent);
  557. }
  558. };
  559.  
  560. const handleClick = event => {
  561. // 修复翻译两次的 BUG
  562. if (config.enableTranslate) {
  563. return;
  564. }
  565.  
  566. if (event.target && event.target.closest('button.create')) {
  567. processTextArea();
  568. }
  569. };
  570.  
  571. let spacePresses = 0;
  572. let lastKeyTime = 0;
  573. let timeoutHandle = null;
  574.  
  575. const handleKeydown = event => {
  576. // console.log(`KeyboardEvent: key='${event.key}' | code='${event.code}'`);
  577.  
  578. if (event.ctrlKey && event.key === 'Enter') {
  579. processTextArea();
  580. return;
  581. }
  582.  
  583. // 使用 Alt+D 触发翻译
  584. if (event.altKey && event.keyCode === 68) {
  585. event.preventDefault(); // 阻止默认行为
  586. processTextArea();
  587. return;
  588. }
  589.  
  590. const currentTime = Date.now();
  591. if (event.code === 'Space') {
  592. // 如果时间间隔太长,重置计数
  593. if (currentTime - lastKeyTime > SPACE_PRESS_TIMEOUT) {
  594. spacePresses = 1;
  595. } else {
  596. spacePresses += 1;
  597. }
  598.  
  599. lastKeyTime = currentTime;
  600.  
  601. // 清除之前的定时器
  602. if (timeoutHandle) {
  603. clearTimeout(timeoutHandle);
  604. }
  605.  
  606. // 设置新的定时器,如果在 SPACE_PRESS_TIMEOUT 毫秒内没有新的按键,则重置计数
  607. timeoutHandle = setTimeout(() => {
  608. spacePresses = 0;
  609. }, SPACE_PRESS_TIMEOUT);
  610.  
  611. // 检查是否达到了按键次数
  612. if (spacePresses === SPACE_PRESS_COUNT) {
  613. spacePresses = 0; // 重置计数
  614.  
  615. // 执行翻译操作
  616. processTextArea();
  617. }
  618. } else {
  619. // 如果按下了其他键,重置计数
  620. spacePresses = 0;
  621. if (timeoutHandle) {
  622. clearTimeout(timeoutHandle);
  623. timeoutHandle = null;
  624. }
  625. }
  626. };
  627.  
  628. const saveConfig = () => {
  629. const deeplxUrlInput = document.getElementById(uiIDs.deeplxUrlInput);
  630. config.deeplxUrl = deeplxUrlInput.value.trim();
  631. const deeplxAuthKeyInput = document.getElementById(uiIDs.deeplxAuthKeyInput);
  632. config.deeplxAuthKey = deeplxAuthKeyInput.value.trim();
  633. const translateModelInput = document.getElementById(uiIDs.translateModelInput);
  634. config.translateModel = translateModelInput.value;
  635. const transalteSizeInput = document.getElementById(uiIDs.translateSizeInput);
  636. config.translateSize = transalteSizeInput.value;
  637. console.log(config);
  638.  
  639. GM_setValue('deeplxUrl', config.deeplxUrl);
  640. GM_setValue('deeplxAuthKey', config.deeplxAuthKey);
  641. GM_setValue('enableTranslate', config.enableTranslate);
  642. GM_setValue('translateModel', config.translateModel);
  643. GM_setValue('translateSize', config.translateSize);
  644. GM_setValue('enablePinYin', config.enablePinYin);
  645. GM_setValue('closeConfigAfterSave', config.closeConfigAfterSave);
  646.  
  647. if (config.closeConfigAfterSave) {
  648. document.getElementById(uiIDs.configPanel).style.display = 'none';
  649. }
  650. };
  651.  
  652. const restoreDefaults = () => {
  653. if (confirm('确定要将所有设置恢复为默认值吗?')) {
  654. config = JSON.parse(JSON.stringify(DEFAULT_CONFIG));
  655. GM_setValue('maxRetryTimes', config.maxRetryTimes);
  656. GM_setValue('deeplxUrl', config.deeplxUrl);
  657. GM_setValue('deeplxAuthKey', config.deeplxAuthKey);
  658. GM_setValue('authKeyPrefix', config.authKeyPrefix);
  659. GM_setValue('enableTranslate', config.enableTranslate);
  660. GM_setValue('translateTargetLang', config.translateTargetLang);
  661. GM_setValue('translateModel', config.translateModel);
  662. GM_setValue('translateLayout', config.translateLayout);
  663. GM_setValue('translateSize', config.translateSize);
  664. GM_setValue('enablePinYin', config.enablePinYin);
  665. GM_setValue('closeConfigAfterSave', config.closeConfigAfterSave);
  666.  
  667. const panel = document.getElementById(uiIDs.configPanel);
  668. if (panel) {
  669. updateConfigPanelContent(panel);
  670. }
  671. }
  672. };
  673.  
  674. const createCheckbox = (id, text, checked, onChange = undefined) => {
  675. const label = document.createElement('label');
  676. label.style.display = 'flex';
  677. label.style.alignItems = 'center';
  678. label.style.marginBottom = '10px';
  679. label.style.cursor = 'pointer';
  680.  
  681. const checkbox = document.createElement('input');
  682. checkbox.type = 'checkbox';
  683. checkbox.id = id;
  684. checkbox.checked = checked;
  685. checkbox.style.marginRight = '10px';
  686. if (onChange !== undefined) {
  687. checkbox.addEventListener('change', e => onChange(e));
  688. } else {
  689. checkbox.addEventListener('change', e => {
  690. config[id] = e.target.checked;
  691. GM_setValue(id, config[id]);
  692. });
  693. }
  694.  
  695. label.appendChild(checkbox);
  696. label.appendChild(document.createTextNode(text));
  697.  
  698. return label;
  699. };
  700.  
  701. const createSelect = (id, labelText, options, defaultValue) => {
  702. const container = document.createElement('div');
  703. container.style.display = 'flex';
  704. container.style.alignItems = 'center';
  705. container.style.marginBottom = '10px';
  706.  
  707. const label = document.createElement('label');
  708. label.htmlFor = id;
  709. label.textContent = labelText;
  710. label.style.marginRight = '10px';
  711.  
  712. const select = document.createElement('select');
  713. select.id = id;
  714. select.style.flex = '1';
  715.  
  716. options.forEach(option => {
  717. const optionElement = document.createElement('option');
  718. optionElement.value = option.value;
  719. optionElement.textContent = option.text;
  720. select.appendChild(optionElement);
  721. });
  722.  
  723. select.value = defaultValue;
  724.  
  725. select.addEventListener('change', e => {
  726. config[id] = e.target.value;
  727. GM_setValue(id, config[id]);
  728. });
  729.  
  730. container.appendChild(select);
  731. container.appendChild(label);
  732.  
  733. return container;
  734. };
  735.  
  736. const createTextInput = (id, value, labelText, placeholder, type = 'text') => {
  737. const container = document.createElement('div');
  738. container.style.marginBottom = '15px';
  739.  
  740. const label = document.createElement('label');
  741. label.textContent = labelText;
  742. label.style.display = 'block';
  743. label.style.marginBottom = '5px';
  744. container.appendChild(label);
  745.  
  746. const inputPlace = document.createElement('input');
  747. inputPlace.id = id;
  748. inputPlace.type = type;
  749. inputPlace.value = value;
  750. inputPlace.placeholder = placeholder;
  751. inputPlace.style.width = '100%';
  752. inputPlace.style.padding = '5px';
  753. inputPlace.style.border = '1px solid var(--panel-border)';
  754. inputPlace.style.borderRadius = '4px';
  755. inputPlace.style.backgroundColor = 'var(--panel-bg)';
  756. inputPlace.style.color = 'var(--panel-text)';
  757. container.appendChild(inputPlace);
  758.  
  759. return [container, inputPlace];
  760. };
  761.  
  762. const createTextArea = (id, value, labelText, placeholder) => {
  763. const container = document.createElement('div');
  764. container.style.marginBottom = '15px';
  765.  
  766. const label = document.createElement('label');
  767. label.textContent = labelText;
  768. label.style.display = 'block';
  769. label.style.marginBottom = '5px';
  770. container.appendChild(label);
  771.  
  772. const textarea = document.createElement('textarea');
  773. textarea.id = id;
  774. if (typeof value === 'string') {
  775. textarea.value = value;
  776. } else {
  777. textarea.value = JSON.stringify(value, null, 2);
  778. }
  779. textarea.placeholder = placeholder;
  780. textarea.rows = 5;
  781. textarea.style.width = '100%';
  782. textarea.style.padding = '5px';
  783. textarea.style.border = '1px solid var(--panel-border)';
  784. textarea.style.borderRadius = '4px';
  785. textarea.style.backgroundColor = 'var(--panel-bg)';
  786. textarea.style.color = 'var(--panel-text)';
  787. container.appendChild(textarea);
  788.  
  789. return [container, textarea];
  790. };
  791.  
  792. const createButton = (text, onClick, primary = false) => {
  793. const button = document.createElement('button');
  794. button.textContent = text;
  795. button.style.padding = '8px 16px';
  796. button.style.border = 'none';
  797. button.style.borderRadius = '4px';
  798. button.style.cursor = 'pointer';
  799. button.style.backgroundColor = primary ? '#0078d7' : '#f0f0f0';
  800. button.style.color = primary ? '#ffffff' : '#333333';
  801. button.addEventListener('click', onClick);
  802. return button;
  803. };
  804.  
  805. const updateConfigPanelContent = panel => {
  806. panel.innerHTML = '';
  807.  
  808. const title = document.createElement('h3');
  809. title.textContent = '配置';
  810. title.style.marginTop = '0';
  811. title.style.marginBottom = '15px';
  812. panel.appendChild(title);
  813.  
  814. panel.appendChild(createTextInput(uiIDs.deeplxUrlInput, config.deeplxUrl, 'URL', '填写自定义请求地址')[0]);
  815. panel.appendChild(
  816. createTextInput(uiIDs.deeplxAuthKeyInput, config.deeplxAuthKey, 'Auth Key', 'connect的key/deepl官key/oai的key')[0]
  817. );
  818. panel.appendChild(createTextInput(uiIDs.translateModelInput, config.translateModel, 'Model', '填写可用模型')[0]);
  819. panel.appendChild(
  820. createTextInput(
  821. uiIDs.translateSizeInput,
  822. config.translateSize,
  823. '翻译字体大小(百分比)',
  824. '默认值为150(字体大小为原始的150%)',
  825. 'number'
  826. )[0]
  827. );
  828. panel.appendChild(
  829. createSelect(
  830. 'translateLayout',
  831. '翻译布局(上/下)',
  832. [
  833. { text: '上', value: 'up' },
  834. { text: '下', value: 'down' },
  835. ],
  836. config.translateLayout
  837. )
  838. );
  839. panel.appendChild(createCheckbox('enableTranslate', '启用翻译', config.enableTranslate));
  840. if (checkPinYinRequire()) {
  841. panel.appendChild(createCheckbox('enablePinYin', '启用拼音注音', config.enablePinYin));
  842. }
  843. panel.appendChild(createCheckbox('closeConfigAfterSave', '保存后自动关闭配置页面', config.closeConfigAfterSave));
  844.  
  845. const buttonContainer = document.createElement('div');
  846. buttonContainer.style.display = 'flex';
  847. buttonContainer.style.justifyContent = 'space-between';
  848. buttonContainer.style.marginTop = '20px';
  849.  
  850. const restoreDefaultsButton = createButton('恢复默认设置', restoreDefaults);
  851. restoreDefaultsButton.style.marginRight = '10px';
  852.  
  853. const saveButton = createButton('保存设置', saveConfig, true);
  854. const closeButton = createButton('关闭', () => {
  855. panel.style.display = 'none';
  856. });
  857.  
  858. buttonContainer.appendChild(restoreDefaultsButton);
  859. buttonContainer.appendChild(saveButton);
  860. buttonContainer.appendChild(closeButton);
  861. panel.appendChild(buttonContainer);
  862. };
  863.  
  864. const createConfigPanel = () => {
  865. // 获取页面的 <meta name="theme-color"> 标签
  866. const themeColorMeta = document.querySelector('meta[name="theme-color"]');
  867. let themeColor = '#ffffff'; // 默认白色背景
  868. let invertedColor = '#000000'; // 默认黑色字体
  869.  
  870. if (themeColorMeta) {
  871. themeColor = themeColorMeta.getAttribute('content');
  872. invertedColor = getInvertColor(themeColor); // 计算相反颜色
  873. }
  874.  
  875. // 设置样式变量
  876. const style = document.createElement('style');
  877. style.textContent = `
  878. :root {
  879. --panel-bg: ${themeColor};
  880. --panel-text: ${invertedColor};
  881. --panel-border: ${invertedColor};
  882. --button-bg: ${invertedColor};
  883. --button-text: ${themeColor};
  884. --button-hover-bg: ${getInvertColor(invertedColor)};
  885. }`;
  886. document.head.appendChild(style);
  887.  
  888. const panel = document.createElement('div');
  889. panel.id = uiIDs.configPanel;
  890. panel.style.position = 'fixed';
  891. panel.style.top = '80px';
  892. panel.style.right = '360px';
  893. panel.style.padding = '20px';
  894. panel.style.border = `1px solid var(--panel-border)`;
  895. panel.style.borderRadius = '8px';
  896. panel.style.zIndex = '10000';
  897. panel.style.width = '300px';
  898. panel.style.maxHeight = '80%';
  899. panel.style.overflowY = 'auto';
  900. panel.style.display = 'none'; // 默认隐藏面板
  901. panel.style.backgroundColor = 'var(--panel-bg)';
  902. panel.style.color = 'var(--panel-text)';
  903. panel.style.boxShadow = '0 4px 6px rgba(0, 0, 0, 0.1)';
  904.  
  905. updateConfigPanelContent(panel);
  906.  
  907. document.body.appendChild(panel);
  908. return panel;
  909. };
  910.  
  911. const toggleConfigPanel = () => {
  912. let panel = document.getElementById(uiIDs.configPanel);
  913. panel = panel || createConfigPanel();
  914. panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
  915. };
  916.  
  917. const createConfigButton = () => {
  918. const toolbar = document.querySelector('.d-editor-button-bar');
  919. if (!toolbar || document.getElementById(uiIDs.configButton)) return;
  920.  
  921. const configButton = document.createElement('button');
  922. configButton.id = uiIDs.configButton;
  923. configButton.className = 'btn btn-flat btn-icon no-text user-menu-tab active';
  924. configButton.title = '配置';
  925. configButton.innerHTML =
  926. '<svg class="fa d-icon d-icon-discourse-other-tab svg-icon svg-string" xmlns="http://www.w3.org/2000/svg"><use href="#discourse-other-tab"></use></svg>';
  927. configButton.onclick = toggleConfigPanel;
  928.  
  929. toolbar.appendChild(configButton);
  930. };
  931.  
  932. const watchReplyControl = () => {
  933. const replyControl = document.getElementById(uiIDs.replyControl);
  934. if (!replyControl) return;
  935.  
  936. const observer = new MutationObserver(mutations => {
  937. mutations.forEach(mutation => {
  938. if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
  939. if (replyControl.classList.contains('closed')) {
  940. const panel = document.getElementById(uiIDs.configPanel);
  941. if (panel) {
  942. panel.style.display = 'none';
  943. }
  944. } else {
  945. // 当 reply-control 重新打开时,尝试添加配置按钮
  946. setTimeout(createConfigButton, 500); // 给予一些时间让编辑器完全加载
  947. }
  948. }
  949. });
  950. });
  951.  
  952. observer.observe(replyControl, { attributes: true });
  953. };
  954.  
  955. const watchForEditor = () => {
  956. const observer = new MutationObserver(mutations => {
  957. mutations.forEach(mutation => {
  958. if (mutation.type === 'childList') {
  959. const addedNodes = mutation.addedNodes;
  960. for (let node of addedNodes) {
  961. if (node.nodeType === Node.ELEMENT_NODE && node.classList.contains('d-editor')) {
  962. createConfigButton();
  963. return;
  964. }
  965. }
  966. }
  967. });
  968. });
  969.  
  970. observer.observe(document.body, { childList: true, subtree: true });
  971. };
  972.  
  973. const init = () => {
  974. const container = document.getElementById(uiIDs.replyControl);
  975. container.addEventListener('click', handleClick, true);
  976. document.addEventListener('keydown', handleKeydown, true);
  977. if (!document.getElementById(uiIDs.configButton)) {
  978. createConfigButton();
  979. }
  980. watchReplyControl();
  981. watchForEditor();
  982. };
  983.  
  984. // 初始化
  985. setTimeout(() => {
  986. init();
  987. }, 1000);
  988. })();