Linux Do Translate

对回复进行翻译

目前为 2024-10-31 提交的版本。查看 最新版本

  1. // ==UserScript==
  2. // @name Linux Do Translate
  3. // @namespace linux-do-translate
  4. // @version 0.1.3
  5. // @author delph1s
  6. // @license MIT
  7. // @description 对回复进行翻译
  8. // @match https://linux.do/t/topic/*
  9. // @connect api.deeplx.org
  10. // @connect api.deepl.com
  11. // @icon https://cdn.linux.do/uploads/default/original/3X/9/d/9dd49731091ce8656e94433a26a3ef36062b3994.png
  12. // @grant unsafeWindow
  13. // @grant window.close
  14. // @grant window.focus
  15. // @grant GM_setValue
  16. // @grant GM_getValue
  17. // @grant GM_xmlhttpRequest
  18. // @require https://cdn.jsdelivr.net/npm/pinyin-pro@3.25.0/dist/index.min.js
  19. // @run-at document-end
  20. // ==/UserScript==
  21.  
  22. (function () {
  23. 'use strict';
  24. const REQUIRED_CHARS = 6;
  25. const SPACE_PRESS_COUNT = 3; // 连按次数
  26. const SPACE_PRESS_TIMEOUT = 1500; // 连续按键的最大时间间隔(毫秒)
  27. const DEFAULT_CONFIG = {
  28. maxRetryTimes: 5,
  29. deeplxUrl: '',
  30. deeplxAuthKey: '',
  31. authKeyPrefix: {
  32. deeplx: '',
  33. deeplOfficial: 'deepl-auth-key:',
  34. openAIOfficial: 'oai-at:',
  35. openAIProxy: 'oai-at-proxy:',
  36. },
  37. enableTranslate: false,
  38. translateTargetLang: {
  39. deeplx: 'EN',
  40. deeplOfficial: 'EN',
  41. openAIOfficial: 'English',
  42. openAIProxy: 'English',
  43. },
  44. translateModel: 'gpt-3.5-turbo',
  45. translateLayout: 'up',
  46. translateSize: 150,
  47. enablePinYin: false,
  48. closeConfigAfterSave: true,
  49. };
  50.  
  51. const uiIDs = {
  52. replyControl: 'reply-control',
  53. configButton: 'multi-lang-say-config-button',
  54. configPanel: 'multi-lang-say-config-panel',
  55. deeplxUrlInput: 'deeplx-url-input',
  56. deeplxAuthKeyInput: 'deeplx-authkey-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.data.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(
  476. `${line}\n[size=${config.translateSize}]${segmentTranslate}[/size]`
  477. );
  478. } else {
  479. translatedSegments.push(
  480. `[size=${config.translateSize}]<ruby><rb>${segment}</rb><rt>${segmentTranslate}</rt></ruby>[/size]`
  481. );
  482. }
  483. }
  484.  
  485. // 合并结果
  486. return translatedSegments.join('');
  487. };
  488.  
  489. const processTextArea = () => {
  490. let textarea = document.querySelector(`#${uiIDs.replyControl} textarea`);
  491. let text = textarea.value.trim();
  492. let originalLength = text.length;
  493.  
  494. if (text.length !== 0 && originalLength >= REQUIRED_CHARS) {
  495. // 检查是否已存在拼音
  496. const rubyRegex = /(<ruby>[\s\S]*?<\/ruby>)/g;
  497.  
  498. // 为中文加入翻译
  499. if (config.enableTranslate) {
  500. textarea.value = '开始翻译...';
  501.  
  502. processTranslateText(text).then(res => {
  503. textarea.value = res;
  504.  
  505. // 创建并触发 input 事件
  506. const inputEvent = new Event('input', {
  507. bubbles: true,
  508. cancelable: true,
  509. });
  510. // 触发事件
  511. textarea.dispatchEvent(inputEvent);
  512. });
  513. return;
  514. }
  515.  
  516. // 为中文加入拼音
  517. if (!config.enableTranslate && config.enablePinYin) {
  518. if (containsChinese(text)) {
  519. // 使用正则表达式将文本分割为已被 <ruby> 包裹的部分和未被包裹的部分
  520. const parts = text.split(rubyRegex);
  521.  
  522. // 处理每一部分
  523. text = parts
  524. .map(part => {
  525. if (rubyRegex.test(part)) {
  526. // 已被 <ruby> 包裹,保持原样
  527. return part;
  528. } else {
  529. // 未被包裹,进一步分割为中文和非中文
  530. if (containsChinese(part)) {
  531. const segments = part.split(/([\u4e00-\u9fa5]+)/g);
  532. return segments
  533. .map(segment => {
  534. if (containsChinese(segment)) {
  535. return convertToRuby(segment);
  536. } else {
  537. return segment;
  538. }
  539. })
  540. .join('');
  541. } else {
  542. return part;
  543. }
  544. }
  545. })
  546. .join('');
  547. }
  548. }
  549.  
  550. textarea.value = text;
  551.  
  552. // 创建并触发 input 事件
  553. const inputEvent = new Event('input', {
  554. bubbles: true,
  555. cancelable: true,
  556. });
  557. // 触发事件
  558. textarea.dispatchEvent(inputEvent);
  559. }
  560. };
  561.  
  562. const handleClick = event => {
  563. // 修复翻译两次的 BUG
  564. if (config.enableTranslate) {
  565. return;
  566. }
  567.  
  568. if (event.target && event.target.closest('button.create')) {
  569. processTextArea();
  570. }
  571. };
  572.  
  573. let spacePresses = 0;
  574. let lastKeyTime = 0;
  575. let timeoutHandle = null;
  576.  
  577. const handleKeydown = event => {
  578. // console.log(`KeyboardEvent: key='${event.key}' | code='${event.code}'`);
  579.  
  580. if (event.ctrlKey && event.key === 'Enter') {
  581. processTextArea();
  582. return;
  583. }
  584.  
  585. // 使用 Alt+D 触发翻译
  586. if (event.altKey && event.keyCode === 68) {
  587. event.preventDefault(); // 阻止默认行为
  588. processTextArea();
  589. return;
  590. }
  591.  
  592. const currentTime = Date.now();
  593. if (event.code === 'Space') {
  594. // 如果时间间隔太长,重置计数
  595. if (currentTime - lastKeyTime > SPACE_PRESS_TIMEOUT) {
  596. spacePresses = 1;
  597. } else {
  598. spacePresses += 1;
  599. }
  600.  
  601. lastKeyTime = currentTime;
  602.  
  603. // 清除之前的定时器
  604. if (timeoutHandle) {
  605. clearTimeout(timeoutHandle);
  606. }
  607.  
  608. // 设置新的定时器,如果在 SPACE_PRESS_TIMEOUT 毫秒内没有新的按键,则重置计数
  609. timeoutHandle = setTimeout(() => {
  610. spacePresses = 0;
  611. }, SPACE_PRESS_TIMEOUT);
  612.  
  613. // 检查是否达到了按键次数
  614. if (spacePresses === SPACE_PRESS_COUNT) {
  615. spacePresses = 0; // 重置计数
  616.  
  617. // 执行翻译操作
  618. processTextArea();
  619. }
  620. } else {
  621. // 如果按下了其他键,重置计数
  622. spacePresses = 0;
  623. if (timeoutHandle) {
  624. clearTimeout(timeoutHandle);
  625. timeoutHandle = null;
  626. }
  627. }
  628. };
  629.  
  630. const saveConfig = () => {
  631. const deeplxUrlInput = document.getElementById(uiIDs.deeplxUrlInput);
  632. config.deeplxUrl = deeplxUrlInput.value.trim();
  633. const deeplxAuthKeyInput = document.getElementById(uiIDs.deeplxAuthKeyInput);
  634. config.deeplxAuthKey = deeplxAuthKeyInput.value.trim();
  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('translateSize', config.translateSize);
  643. GM_setValue('enablePinYin', config.enablePinYin);
  644. GM_setValue('closeConfigAfterSave', config.closeConfigAfterSave);
  645.  
  646. if (config.closeConfigAfterSave) {
  647. document.getElementById(uiIDs.configPanel).style.display = 'none';
  648. }
  649. };
  650.  
  651. const restoreDefaults = () => {
  652. if (confirm('确定要将所有设置恢复为默认值吗?')) {
  653. config = JSON.parse(JSON.stringify(DEFAULT_CONFIG));
  654. GM_setValue('deeplxUrl', config.deeplxUrl);
  655. GM_setValue('deeplxAuthKey', config.deeplxAuthKey);
  656. GM_setValue('enableTranslate', config.enableTranslate);
  657. GM_setValue('translateSize', config.translateSize);
  658. GM_setValue('enablePinYin', config.enablePinYin);
  659. GM_setValue('closeConfigAfterSave', config.closeConfigAfterSave);
  660.  
  661. const panel = document.getElementById(uiIDs.configPanel);
  662. if (panel) {
  663. updateConfigPanelContent(panel);
  664. }
  665. }
  666. };
  667.  
  668. const createCheckbox = (id, text, checked, onChange = undefined) => {
  669. const label = document.createElement('label');
  670. label.style.display = 'flex';
  671. label.style.alignItems = 'center';
  672. label.style.marginBottom = '10px';
  673. label.style.cursor = 'pointer';
  674.  
  675. const checkbox = document.createElement('input');
  676. checkbox.type = 'checkbox';
  677. checkbox.id = id;
  678. checkbox.checked = checked;
  679. checkbox.style.marginRight = '10px';
  680. if (onChange !== undefined) {
  681. checkbox.addEventListener('change', e => onChange(e));
  682. } else {
  683. checkbox.addEventListener('change', e => {
  684. config[id] = e.target.checked;
  685. GM_setValue(id, config[id]);
  686. });
  687. }
  688.  
  689. label.appendChild(checkbox);
  690. label.appendChild(document.createTextNode(text));
  691.  
  692. return label;
  693. };
  694.  
  695. const createSelect = (id, text, options, defaultValue) => {
  696. const container = document.createElement('div');
  697. container.style.display = 'flex';
  698. container.style.alignItems = 'center';
  699. container.style.marginBottom = '10px';
  700.  
  701. const label = document.createElement('label');
  702. label.htmlFor = id;
  703. label.textContent = text;
  704. label.style.marginRight = '10px';
  705.  
  706. const select = document.createElement('select');
  707. select.id = id;
  708. select.style.flex = '1';
  709.  
  710. options.forEach(option => {
  711. const optionElement = document.createElement('option');
  712. optionElement.value = option.value;
  713. optionElement.textContent = option.text;
  714. select.appendChild(optionElement);
  715. });
  716.  
  717. select.value = defaultValue;
  718.  
  719. select.addEventListener('change', e => {
  720. config[id] = e.target.value;
  721. GM_setValue(id, config[id]);
  722. });
  723.  
  724. container.appendChild(select);
  725. container.appendChild(label);
  726.  
  727. return container;
  728. };
  729.  
  730. const createTextInput = (id, value, labelText, placeholder, type = 'text') => {
  731. const container = document.createElement('div');
  732. container.style.marginBottom = '15px';
  733.  
  734. const label = document.createElement('label');
  735. label.textContent = labelText;
  736. label.style.display = 'block';
  737. label.style.marginBottom = '5px';
  738. container.appendChild(label);
  739.  
  740. const inputPlace = document.createElement('input');
  741. inputPlace.id = id;
  742. inputPlace.type = type;
  743. inputPlace.value = value;
  744. inputPlace.placeholder = placeholder;
  745. inputPlace.style.width = '100%';
  746. inputPlace.style.padding = '5px';
  747. inputPlace.style.border = '1px solid var(--panel-border)';
  748. inputPlace.style.borderRadius = '4px';
  749. inputPlace.style.backgroundColor = 'var(--panel-bg)';
  750. inputPlace.style.color = 'var(--panel-text)';
  751. container.appendChild(inputPlace);
  752.  
  753. return [container, inputPlace];
  754. };
  755.  
  756. const createTextArea = (id, value, labelText, placeholder) => {
  757. const container = document.createElement('div');
  758. container.style.marginBottom = '15px';
  759.  
  760. const label = document.createElement('label');
  761. label.textContent = labelText;
  762. label.style.display = 'block';
  763. label.style.marginBottom = '5px';
  764. container.appendChild(label);
  765.  
  766. const textarea = document.createElement('textarea');
  767. textarea.id = id;
  768. if (typeof value === 'string') {
  769. textarea.value = value;
  770. } else {
  771. textarea.value = JSON.stringify(value, null, 2);
  772. }
  773. textarea.placeholder = placeholder;
  774. textarea.rows = 5;
  775. textarea.style.width = '100%';
  776. textarea.style.padding = '5px';
  777. textarea.style.border = '1px solid var(--panel-border)';
  778. textarea.style.borderRadius = '4px';
  779. textarea.style.backgroundColor = 'var(--panel-bg)';
  780. textarea.style.color = 'var(--panel-text)';
  781. container.appendChild(textarea);
  782.  
  783. return [container, textarea];
  784. };
  785.  
  786. const createButton = (text, onClick, primary = false) => {
  787. const button = document.createElement('button');
  788. button.textContent = text;
  789. button.style.padding = '8px 16px';
  790. button.style.border = 'none';
  791. button.style.borderRadius = '4px';
  792. button.style.cursor = 'pointer';
  793. button.style.backgroundColor = primary ? '#0078d7' : '#f0f0f0';
  794. button.style.color = primary ? '#ffffff' : '#333333';
  795. button.addEventListener('click', onClick);
  796. return button;
  797. };
  798.  
  799. const updateConfigPanelContent = panel => {
  800. panel.innerHTML = '';
  801.  
  802. const title = document.createElement('h3');
  803. title.textContent = '配置';
  804. title.style.marginTop = '0';
  805. title.style.marginBottom = '15px';
  806. panel.appendChild(title);
  807.  
  808. panel.appendChild(
  809. createTextInput(uiIDs.deeplxUrlInput, config.deeplxUrl, 'Deeplx URL', '填写 Deeplx 的请求地址')[0]
  810. );
  811. panel.appendChild(
  812. createTextInput(uiIDs.deeplxAuthKeyInput, config.deeplxAuthKey, 'Deeplx Auth Key', '填写 connect 的 key')[0]
  813. );
  814. panel.appendChild(
  815. createTextInput(
  816. uiIDs.translateSizeInput,
  817. config.translateSize,
  818. '翻译字体大小(百分比)',
  819. '默认值为150(字体大小为原始的150%)',
  820. 'number'
  821. )[0]
  822. );
  823. panel.appendChild(createCheckbox('enableTranslate', '启用翻译', config.enableTranslate));
  824. if (checkPinYinRequire()) {
  825. panel.appendChild(createCheckbox('enablePinYin', '启用拼音注音', config.enablePinYin));
  826. }
  827. panel.appendChild(createCheckbox('closeConfigAfterSave', '保存后自动关闭配置页面', config.closeConfigAfterSave));
  828.  
  829. const buttonContainer = document.createElement('div');
  830. buttonContainer.style.display = 'flex';
  831. buttonContainer.style.justifyContent = 'space-between';
  832. buttonContainer.style.marginTop = '20px';
  833.  
  834. const restoreDefaultsButton = createButton('恢复默认设置', restoreDefaults);
  835. restoreDefaultsButton.style.marginRight = '10px';
  836.  
  837. const saveButton = createButton('保存设置', saveConfig, true);
  838. const closeButton = createButton('关闭', () => {
  839. panel.style.display = 'none';
  840. });
  841.  
  842. buttonContainer.appendChild(restoreDefaultsButton);
  843. buttonContainer.appendChild(saveButton);
  844. buttonContainer.appendChild(closeButton);
  845. panel.appendChild(buttonContainer);
  846. };
  847.  
  848. const createConfigPanel = () => {
  849. // 获取页面的 <meta name="theme-color"> 标签
  850. const themeColorMeta = document.querySelector('meta[name="theme-color"]');
  851. let themeColor = '#ffffff'; // 默认白色背景
  852. let invertedColor = '#000000'; // 默认黑色字体
  853.  
  854. if (themeColorMeta) {
  855. themeColor = themeColorMeta.getAttribute('content');
  856. invertedColor = getInvertColor(themeColor); // 计算相反颜色
  857. }
  858.  
  859. // 设置样式变量
  860. const style = document.createElement('style');
  861. style.textContent = `
  862. :root {
  863. --panel-bg: ${themeColor};
  864. --panel-text: ${invertedColor};
  865. --panel-border: ${invertedColor};
  866. --button-bg: ${invertedColor};
  867. --button-text: ${themeColor};
  868. --button-hover-bg: ${getInvertColor(invertedColor)};
  869. }`;
  870. document.head.appendChild(style);
  871.  
  872. const panel = document.createElement('div');
  873. panel.id = uiIDs.configPanel;
  874. panel.style.position = 'fixed';
  875. panel.style.top = '80px';
  876. panel.style.right = '360px';
  877. panel.style.padding = '20px';
  878. panel.style.border = `1px solid var(--panel-border)`;
  879. panel.style.borderRadius = '8px';
  880. panel.style.zIndex = '10000';
  881. panel.style.width = '300px';
  882. panel.style.maxHeight = '80%';
  883. panel.style.overflowY = 'auto';
  884. panel.style.display = 'none'; // 默认隐藏面板
  885. panel.style.backgroundColor = 'var(--panel-bg)';
  886. panel.style.color = 'var(--panel-text)';
  887. panel.style.boxShadow = '0 4px 6px rgba(0, 0, 0, 0.1)';
  888.  
  889. updateConfigPanelContent(panel);
  890.  
  891. document.body.appendChild(panel);
  892. return panel;
  893. };
  894.  
  895. const toggleConfigPanel = () => {
  896. let panel = document.getElementById(uiIDs.configPanel);
  897. panel = panel || createConfigPanel();
  898. panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
  899. };
  900.  
  901. const createConfigButton = () => {
  902. const toolbar = document.querySelector('.d-editor-button-bar');
  903. if (!toolbar || document.getElementById(uiIDs.configButton)) return;
  904.  
  905. const configButton = document.createElement('button');
  906. configButton.id = uiIDs.configButton;
  907. configButton.className = 'btn btn-flat btn-icon no-text user-menu-tab active';
  908. configButton.title = '配置';
  909. configButton.innerHTML =
  910. '<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>';
  911. configButton.onclick = toggleConfigPanel;
  912.  
  913. toolbar.appendChild(configButton);
  914. };
  915.  
  916. const watchReplyControl = () => {
  917. const replyControl = document.getElementById(uiIDs.replyControl);
  918. if (!replyControl) return;
  919.  
  920. const observer = new MutationObserver(mutations => {
  921. mutations.forEach(mutation => {
  922. if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
  923. if (replyControl.classList.contains('closed')) {
  924. const panel = document.getElementById(uiIDs.configPanel);
  925. if (panel) {
  926. panel.style.display = 'none';
  927. }
  928. } else {
  929. // 当 reply-control 重新打开时,尝试添加配置按钮
  930. setTimeout(createConfigButton, 500); // 给予一些时间让编辑器完全加载
  931. }
  932. }
  933. });
  934. });
  935.  
  936. observer.observe(replyControl, { attributes: true });
  937. };
  938.  
  939. const watchForEditor = () => {
  940. const observer = new MutationObserver(mutations => {
  941. mutations.forEach(mutation => {
  942. if (mutation.type === 'childList') {
  943. const addedNodes = mutation.addedNodes;
  944. for (let node of addedNodes) {
  945. if (node.nodeType === Node.ELEMENT_NODE && node.classList.contains('d-editor')) {
  946. createConfigButton();
  947. return;
  948. }
  949. }
  950. }
  951. });
  952. });
  953.  
  954. observer.observe(document.body, { childList: true, subtree: true });
  955. };
  956.  
  957. const init = () => {
  958. const container = document.getElementById(uiIDs.replyControl);
  959. container.addEventListener('click', handleClick, true);
  960. document.addEventListener('keydown', handleKeydown, true);
  961. if (!document.getElementById(uiIDs.configButton)) {
  962. createConfigButton();
  963. }
  964. watchReplyControl();
  965. watchForEditor();
  966. };
  967.  
  968. // 初始化
  969. setTimeout(() => {
  970. init();
  971. }, 1000);
  972. })();