Discourse Base64 Helper

Base64编解码工具 for Discourse论坛

  1. // ==UserScript==
  2. // @name Discourse Base64 Helper
  3. // @icon https://raw.githubusercontent.com/XavierBar/Discourse-Base64-Helper/refs/heads/main/discourse.svg
  4. // @namespace http://tampermonkey.net/
  5. // @version 1.3.13
  6. // @description Base64编解码工具 for Discourse论坛
  7. // @author Xavier
  8. // @match *://linux.do/*
  9. // @match *://clochat.com/*
  10. // @grant GM_notification
  11. // @grant GM_setClipboard
  12. // @grant GM_addStyle
  13. // @grant GM_getValue
  14. // @grant GM_setValue
  15. // @run-at document-idle
  16. // ==/UserScript==
  17.  
  18. (function () {
  19. 'use strict';
  20.  
  21. // 常量定义
  22. const Z_INDEX = 2147483647;
  23. const SELECTORS = {
  24. POST_CONTENT: '.cooked, .post-body',
  25. DECODED_TEXT: '.decoded-text',
  26. };
  27. const STORAGE_KEYS = {
  28. BUTTON_POSITION: 'btnPosition',
  29. };
  30. const BASE64_REGEX = /(?<!\w)([A-Za-z0-9+/]{6,}?={0,2})(?!\w)/g;
  31. // 样式常量
  32. const STYLES = {
  33. GLOBAL: `
  34. /* 基础内容样式 */
  35. .decoded-text {
  36. cursor: pointer;
  37. transition: all 0.2s;
  38. padding: 1px 3px;
  39. border-radius: 3px;
  40. background-color: #fff3cd !important;
  41. color: #664d03 !important;
  42. }
  43. .decoded-text:hover {
  44. background-color: #ffe69c !important;
  45. }
  46. /* 通知动画 */
  47. @keyframes slideIn {
  48. from {
  49. transform: translate(-50%, -20px);
  50. opacity: 0;
  51. }
  52. to {
  53. transform: translate(-50%, 0);
  54. opacity: 1;
  55. }
  56. }
  57. @keyframes fadeOut {
  58. from { opacity: 1; }
  59. to { opacity: 0; }
  60. }
  61. /* 暗色模式全局样式 */
  62. @media (prefers-color-scheme: dark) {
  63. .decoded-text {
  64. background-color: #332100 !important;
  65. color: #ffd54f !important;
  66. }
  67. .decoded-text:hover {
  68. background-color: #664d03 !important;
  69. }
  70. }
  71. `,
  72. NOTIFICATION: `
  73. .base64-notification {
  74. position: fixed;
  75. top: 20px;
  76. left: 50%;
  77. transform: translateX(-50%);
  78. padding: 12px 24px;
  79. border-radius: 8px;
  80. z-index: ${Z_INDEX};
  81. animation: slideIn 0.3s forwards, fadeOut 0.3s 2s forwards;
  82. font-family: system-ui, -apple-system, sans-serif;
  83. pointer-events: none;
  84. backdrop-filter: blur(4px);
  85. border: 1px solid rgba(255, 255, 255, 0.1);
  86. max-width: 80vw;
  87. text-align: center;
  88. line-height: 1.5;
  89. background: rgba(255, 255, 255, 0.95);
  90. color: #2d3748;
  91. box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
  92. }
  93. .base64-notification[data-type="success"] {
  94. background: rgba(72, 187, 120, 0.95) !important;
  95. color: #f7fafc !important;
  96. }
  97. .base64-notification[data-type="error"] {
  98. background: rgba(245, 101, 101, 0.95) !important;
  99. color: #f8fafc !important;
  100. }
  101. .base64-notification[data-type="info"] {
  102. background: rgba(66, 153, 225, 0.95) !important;
  103. color: #f7fafc !important;
  104. }
  105. @media (prefers-color-scheme: dark) {
  106. .base64-notification {
  107. background: rgba(26, 32, 44, 0.95) !important;
  108. color: #e2e8f0 !important;
  109. box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -1px rgba(0, 0, 0, 0.2);
  110. border-color: rgba(255, 255, 255, 0.05);
  111. }
  112. .base64-notification[data-type="success"] {
  113. background: rgba(22, 101, 52, 0.95) !important;
  114. }
  115. .base64-notification[data-type="error"] {
  116. background: rgba(155, 28, 28, 0.95) !important;
  117. }
  118. .base64-notification[data-type="info"] {
  119. background: rgba(29, 78, 216, 0.95) !important;
  120. }
  121. }
  122. `,
  123. SHADOW_DOM: `
  124. :host {
  125. all: initial !important;
  126. position: fixed !important;
  127. z-index: ${Z_INDEX} !important;
  128. pointer-events: none !important;
  129. }
  130. .base64-helper {
  131. position: fixed;
  132. z-index: ${Z_INDEX} !important;
  133. transform: translateZ(100px);
  134. cursor: move;
  135. font-family: system-ui, -apple-system, sans-serif;
  136. opacity: 0.5;
  137. transition: opacity 0.3s ease, transform 0.2s;
  138. pointer-events: auto !important;
  139. will-change: transform;
  140. }
  141. .base64-helper:hover {
  142. opacity: 1 !important;
  143. }
  144. .main-btn {
  145. background: #ffffff;
  146. color: #000000 !important;
  147. padding: 8px 16px;
  148. border-radius: 6px;
  149. box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
  150. font-weight: 500;
  151. user-select: none;
  152. transition: all 0.2s;
  153. font-size: 14px;
  154. cursor: pointer;
  155. border: none !important;
  156. }
  157. .menu {
  158. position: absolute;
  159. bottom: calc(100% + 5px);
  160. right: 0;
  161. background: #ffffff;
  162. border-radius: 6px;
  163. box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
  164. display: none;
  165. min-width: auto !important;
  166. width: max-content !important;
  167. overflow: hidden;
  168. }
  169. .menu-item {
  170. padding: 8px 12px !important;
  171. color: #333 !important;
  172. transition: all 0.2s;
  173. font-size: 13px;
  174. cursor: pointer;
  175. position: relative;
  176. border-radius: 0 !important;
  177. isolation: isolate;
  178. white-space: nowrap !important;
  179. }
  180. .menu-item:hover::before {
  181. content: '';
  182. position: absolute;
  183. top: 0;
  184. left: 0;
  185. right: 0;
  186. bottom: 0;
  187. background: currentColor;
  188. opacity: 0.1;
  189. z-index: -1;
  190. }
  191. @media (prefers-color-scheme: dark) {
  192. .main-btn {
  193. background: #2d2d2d;
  194. color: #fff !important;
  195. box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
  196. }
  197. .menu {
  198. background: #1a1a1a;
  199. box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
  200. }
  201. .menu-item {
  202. color: #e0e0e0 !important;
  203. }
  204. .menu-item:hover::before {
  205. opacity: 0.08;
  206. }
  207. }
  208. `,
  209. };
  210.  
  211. // 样式初始化
  212. const initStyles = () => {
  213. GM_addStyle(STYLES.GLOBAL + STYLES.NOTIFICATION);
  214. };
  215.  
  216. class Base64Helper {
  217. constructor() {
  218. this.originalContents = new Map();
  219. this.isDragging = false;
  220. this.menuVisible = false;
  221. this.resizeTimer = null;
  222. this.initUI();
  223. this.eventListeners = []; // 用于存储事件监听器以便后续清理
  224. this.initEventListeners();
  225. this.addRouteListeners();
  226. }
  227.  
  228. // UI 初始化
  229. initUI() {
  230. if (document.getElementById('base64-helper-root')) return;
  231.  
  232. this.container = document.createElement('div');
  233. this.container.id = 'base64-helper-root';
  234. document.body.append(this.container);
  235.  
  236. this.shadowRoot = this.container.attachShadow({ mode: 'open' });
  237. this.shadowRoot.appendChild(this.createShadowStyles());
  238. this.shadowRoot.appendChild(this.createMainUI());
  239.  
  240. this.initPosition();
  241. }
  242.  
  243. createShadowStyles() {
  244. const style = document.createElement('style');
  245. style.textContent = STYLES.SHADOW_DOM;
  246. return style;
  247. }
  248.  
  249. createMainUI() {
  250. const uiContainer = document.createElement('div');
  251. uiContainer.className = 'base64-helper';
  252.  
  253. this.mainBtn = this.createButton('Base64', 'main-btn');
  254. this.menu = this.createMenu();
  255.  
  256. uiContainer.append(this.mainBtn, this.menu);
  257. return uiContainer;
  258. }
  259.  
  260. createButton(text, className) {
  261. const btn = document.createElement('button');
  262. btn.className = className;
  263. btn.textContent = text;
  264. return btn;
  265. }
  266.  
  267. createMenu() {
  268. const menu = document.createElement('div');
  269. menu.className = 'menu';
  270.  
  271. this.decodeBtn = this.createMenuItem('解析本页 Base64', 'decode');
  272. this.encodeBtn = this.createMenuItem('文本转 Base64');
  273.  
  274. menu.append(this.decodeBtn, this.encodeBtn);
  275. return menu;
  276. }
  277.  
  278. createMenuItem(text, mode) {
  279. const item = document.createElement('div');
  280. item.className = 'menu-item';
  281. item.textContent = text;
  282. if (mode) item.dataset.mode = mode;
  283. return item;
  284. }
  285.  
  286. // 位置管理
  287. initPosition() {
  288. const pos = this.positionManager.get() || {
  289. x: window.innerWidth - 120,
  290. y: window.innerHeight - 80,
  291. };
  292.  
  293. const ui = this.shadowRoot.querySelector('.base64-helper');
  294. ui.style.left = `${pos.x}px`;
  295. ui.style.top = `${pos.y}px`;
  296. }
  297.  
  298. get positionManager() {
  299. return {
  300. get: () => {
  301. const saved = GM_getValue(STORAGE_KEYS.BUTTON_POSITION);
  302. if (!saved) return null;
  303.  
  304. const ui = this.shadowRoot.querySelector('.base64-helper');
  305. const maxX = window.innerWidth - ui.offsetWidth - 20;
  306. const maxY = window.innerHeight - ui.offsetHeight - 20;
  307.  
  308. return {
  309. x: Math.min(Math.max(saved.x, 20), maxX),
  310. y: Math.min(Math.max(saved.y, 20), maxY),
  311. };
  312. },
  313. set: (x, y) => {
  314. const ui = this.shadowRoot.querySelector('.base64-helper');
  315. const pos = {
  316. x: Math.max(
  317. 20,
  318. Math.min(x, window.innerWidth - ui.offsetWidth - 20)
  319. ),
  320. y: Math.max(
  321. 20,
  322. Math.min(y, window.innerHeight - ui.offsetHeight - 20)
  323. ),
  324. };
  325.  
  326. GM_setValue(STORAGE_KEYS.BUTTON_POSITION, pos);
  327. return pos;
  328. },
  329. };
  330. }
  331.  
  332. // 初始化事件监听器
  333. initEventListeners() {
  334. const listeners = [
  335. {
  336. element: this.mainBtn,
  337. event: 'click',
  338. handler: (e) => this.toggleMenu(e),
  339. },
  340. {
  341. element: document,
  342. event: 'click',
  343. handler: (e) => this.handleDocumentClick(e),
  344. },
  345. {
  346. element: this.mainBtn,
  347. event: 'mousedown',
  348. handler: (e) => this.startDrag(e),
  349. },
  350. { element: document, event: 'mousemove', handler: (e) => this.drag(e) },
  351. { element: document, event: 'mouseup', handler: () => this.stopDrag() },
  352. {
  353. element: this.decodeBtn,
  354. event: 'click',
  355. handler: () => this.handleDecode(),
  356. },
  357. {
  358. element: this.encodeBtn,
  359. event: 'click',
  360. handler: () => this.handleEncode(),
  361. },
  362. {
  363. element: window,
  364. event: 'resize',
  365. handler: () => this.handleResize(),
  366. },
  367. ];
  368.  
  369. listeners.forEach(({ element, event, handler }) => {
  370. element.addEventListener(event, handler);
  371. this.eventListeners.push({ element, event, handler });
  372. });
  373. }
  374.  
  375. // 清理事件监听器和全局引用
  376. destroy() {
  377. // 清理所有事件监听器
  378. this.eventListeners.forEach(({ element, event, handler }) => {
  379. element.removeEventListener(event, handler);
  380. });
  381. this.eventListeners = [];
  382.  
  383. // 清理全局引用
  384. if (window.__base64HelperInstance === this) {
  385. delete window.__base64HelperInstance;
  386. }
  387.  
  388. // 清理 Shadow DOM 和其他 DOM 引用
  389. if (this.container?.parentNode) {
  390. this.container.parentNode.removeChild(this.container);
  391. }
  392.  
  393. history.pushState = this.originalPushState; // 恢复原始方法
  394. history.replaceState = this.originalReplaceState; // 恢复原始方法
  395.  
  396. //清理 resize 定时器
  397. clearTimeout(this.resizeTimer);
  398. clearTimeout(this.notificationTimer); // 清理通知定时器
  399. clearTimeout(this.routeTimer); // 清理路由定时器
  400. }
  401.  
  402. // 菜单切换
  403. toggleMenu(e) {
  404. if (this.clickDebounce) return;
  405. this.clickDebounce = true;
  406. setTimeout(() => (this.clickDebounce = false), 200); // 防抖
  407. e.stopPropagation();
  408. this.menuVisible = !this.menuVisible;
  409. this.menu.style.display = this.menuVisible ? 'block' : 'none';
  410. }
  411.  
  412. handleDocumentClick(e) {
  413. if (this.menuVisible && !this.shadowRoot.contains(e.target)) {
  414. this.menuVisible = false;
  415. this.menu.style.display = 'none';
  416. }
  417. }
  418.  
  419. // 拖拽功能
  420. startDrag(e) {
  421. this.isDragging = true;
  422. this.startX = e.clientX;
  423. this.startY = e.clientY;
  424. const rect = this.shadowRoot
  425. .querySelector('.base64-helper')
  426. .getBoundingClientRect();
  427. this.initialX = rect.left;
  428. this.initialY = rect.top;
  429. this.shadowRoot.querySelector('.base64-helper').style.transition = 'none';
  430. }
  431.  
  432. drag(e) {
  433. if (!this.isDragging) return;
  434. requestAnimationFrame(() => {
  435. // 🎯 使用动画帧优化
  436. // 位置计算逻辑
  437. const dx = e.clientX - this.startX;
  438. const dy = e.clientY - this.startY;
  439.  
  440. const newX = this.initialX + dx;
  441. const newY = this.initialY + dy;
  442.  
  443. const pos = this.positionManager.set(newX, newY);
  444. const ui = this.shadowRoot.querySelector('.base64-helper');
  445. ui.style.left = `${pos.x}px`;
  446. ui.style.top = `${pos.y}px`;
  447. });
  448. }
  449.  
  450. stopDrag() {
  451. this.isDragging = false;
  452. this.shadowRoot.querySelector('.base64-helper').style.transition =
  453. 'opacity 0.3s ease';
  454. }
  455.  
  456. // 窗口resize处理
  457. handleResize() {
  458. clearTimeout(this.resizeTimer);
  459. this.resizeTimer = setTimeout(() => {
  460. const pos = this.positionManager.get();
  461. if (pos) {
  462. const ui = this.shadowRoot.querySelector('.base64-helper');
  463. ui.style.left = `${pos.x}px`;
  464. ui.style.top = `${pos.y}px`;
  465. }
  466. }, 100);
  467. }
  468. // 路由监听
  469. addRouteListeners() {
  470. this.handleRouteChange = () => {
  471. clearTimeout(this.routeTimer);
  472. this.routeTimer = setTimeout(() => this.resetState(), 100); // 延迟 100ms 确保 DOM 更新完成
  473. };
  474. const routeEvents = [
  475. // 原生事件必须绑定到 window
  476. { event: 'popstate', target: window },
  477. { event: 'hashchange', target: window },
  478.  
  479. // Discourse自定义事件必须绑定到 document
  480. { event: 'routeDidChange', target: document },
  481. { event: 'post:added', target: document },
  482. { event: 'posts:inserted', target: document },
  483. { event: 'post:highlighted', target: document },
  484. { event: 'topic:refreshed', target: document },
  485. { event: 'discourse:changed', target: document },
  486. { event: 'post-stream:posted', target: document },
  487. { event: 'post-stream:refresh', target: document },
  488. { event: 'composer:opened', target: document },
  489. ];
  490.  
  491. routeEvents.forEach(({ event, target }) => {
  492. target.addEventListener(event, this.handleRouteChange);
  493. this.eventListeners.push({
  494. element: target,
  495. event,
  496. handler: this.handleRouteChange,
  497. });
  498. });
  499.  
  500. // 重写 history 方法
  501. this.originalPushState = history.pushState;
  502. this.originalReplaceState = history.replaceState;
  503. history.pushState = (...args) => {
  504. this.originalPushState.apply(history, args);
  505. this.handleRouteChange();
  506. };
  507.  
  508. history.replaceState = (...args) => {
  509. this.originalReplaceState.apply(history, args);
  510. this.handleRouteChange();
  511. };
  512. }
  513.  
  514. // 核心功能
  515. handleDecode() {
  516. if (this.decodeBtn.dataset.mode === 'restore') {
  517. this.restoreContent();
  518. return;
  519. }
  520.  
  521. this.originalContents.clear();
  522. let hasValidBase64 = false;
  523.  
  524. try {
  525. document.querySelectorAll(SELECTORS.POST_CONTENT).forEach((element) => {
  526. let newHtml = element.innerHTML;
  527. let modified = false;
  528.  
  529. Array.from(newHtml.matchAll(BASE64_REGEX))
  530. .reverse()
  531. .forEach((match) => {
  532. const original = match[0];
  533. if (!this.validateBase64(original)) return;
  534.  
  535. try {
  536. const decoded = this.decodeBase64(original);
  537. this.originalContents.set(element, element.innerHTML);
  538.  
  539. newHtml = `${newHtml.substring(
  540. 0,
  541. match.index
  542. )}<span class="decoded-text">${decoded}</span>${newHtml.substring(
  543. match.index + original.length
  544. )}`;
  545.  
  546. hasValidBase64 = modified = true;
  547. } catch {}
  548. });
  549.  
  550. if (modified) element.innerHTML = newHtml;
  551. });
  552.  
  553. if (!hasValidBase64) {
  554. this.showNotification('本页未发现有效 Base64 内容', 'info');
  555. this.originalContents.clear();
  556. return;
  557. }
  558.  
  559. document.querySelectorAll(SELECTORS.DECODED_TEXT).forEach((el) => {
  560. el.addEventListener('click', (e) => this.copyToClipboard(e));
  561. });
  562.  
  563. this.decodeBtn.textContent = '恢复本页 Base64';
  564. this.decodeBtn.dataset.mode = 'restore';
  565. this.showNotification('解析完成', 'success');
  566. } catch (e) {
  567. this.showNotification(`解析失败: ${e.message}`, 'error');
  568. this.originalContents.clear();
  569. }
  570.  
  571. this.menuVisible = false;
  572. this.menu.style.display = 'none';
  573. }
  574.  
  575. handleEncode() {
  576. const text = prompt('请输入要编码的文本:');
  577. if (text === null) return;
  578.  
  579. try {
  580. const encoded = this.encodeBase64(text);
  581. GM_setClipboard(encoded);
  582. this.showNotification('Base64 已复制', 'success');
  583. } catch (e) {
  584. this.showNotification('编码失败: ' + e.message, 'error');
  585. }
  586. this.menu.style.display = 'none';
  587. }
  588.  
  589. // 工具方法
  590. validateBase64(str) {
  591. return (
  592. typeof str === 'string' &&
  593. str.length >= 6 &&
  594. str.length % 4 === 0 &&
  595. /^[A-Za-z0-9+/]+={0,2}$/.test(str) &&
  596. str.replace(/=+$/, '').length >= 6
  597. );
  598. }
  599.  
  600. decodeBase64(str) {
  601. return decodeURIComponent(
  602. atob(str)
  603. .split('')
  604. .map((c) => `%${c.charCodeAt(0).toString(16).padStart(2, '0')}`)
  605. .join('')
  606. );
  607. }
  608.  
  609. encodeBase64(str) {
  610. return btoa(
  611. encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, (match, p1) =>
  612. String.fromCharCode(`0x${p1}`)
  613. )
  614. );
  615. }
  616.  
  617. restoreContent() {
  618. this.originalContents.forEach((html, element) => {
  619. element.innerHTML = html;
  620. });
  621. this.originalContents.clear();
  622. this.decodeBtn.textContent = '解析本页 Base64';
  623. this.decodeBtn.dataset.mode = 'decode';
  624. this.showNotification('已恢复原始内容', 'success');
  625. this.menu.style.display = 'none';
  626. }
  627.  
  628. copyToClipboard(e) {
  629. GM_setClipboard(e.target.innerText);
  630. this.showNotification('内容已复制', 'success');
  631. e.stopPropagation();
  632. }
  633.  
  634. resetState() {
  635. if (this.decodeBtn.dataset.mode === 'restore') {
  636. this.restoreContent();
  637. }
  638. }
  639. showNotification(text, type) {
  640. const notification = document.createElement('div');
  641. notification.className = 'base64-notification';
  642. notification.setAttribute('data-type', type);
  643. notification.textContent = text;
  644. document.body.appendChild(notification);
  645. this.notificationTimer = setTimeout(() => notification.remove(), 2300);
  646. }
  647. }
  648.  
  649. // 防冲突处理
  650. if (window.__base64HelperInstance) {
  651. return window.__base64HelperInstance;
  652. }
  653.  
  654. // 初始化
  655. initStyles();
  656. const instance = new Base64Helper();
  657. window.__base64HelperInstance = instance;
  658.  
  659. // 页面卸载时清理
  660. window.addEventListener('unload', () => {
  661. instance.destroy();
  662. delete window.__base64HelperInstance;
  663. });
  664. })();