Discourse Base64 Helper

Base64编解码工具 for Discourse论坛

当前为 2025-04-03 提交的版本,查看 最新版本

  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.12
  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. }
  399.  
  400. // 菜单切换
  401. toggleMenu(e) {
  402. e.stopPropagation();
  403. this.menuVisible = !this.menuVisible;
  404. this.menu.style.display = this.menuVisible ? 'block' : 'none';
  405. }
  406.  
  407. handleDocumentClick(e) {
  408. if (this.menuVisible && !this.shadowRoot.contains(e.target)) {
  409. this.menuVisible = false;
  410. this.menu.style.display = 'none';
  411. }
  412. }
  413.  
  414. // 拖拽功能
  415. startDrag(e) {
  416. this.isDragging = true;
  417. this.startX = e.clientX;
  418. this.startY = e.clientY;
  419. const rect = this.shadowRoot
  420. .querySelector('.base64-helper')
  421. .getBoundingClientRect();
  422. this.initialX = rect.left;
  423. this.initialY = rect.top;
  424. this.shadowRoot.querySelector('.base64-helper').style.transition = 'none';
  425. }
  426.  
  427. drag(e) {
  428. if (!this.isDragging) return;
  429. const dx = e.clientX - this.startX;
  430. const dy = e.clientY - this.startY;
  431.  
  432. const newX = this.initialX + dx;
  433. const newY = this.initialY + dy;
  434.  
  435. const pos = this.positionManager.set(newX, newY);
  436. const ui = this.shadowRoot.querySelector('.base64-helper');
  437. ui.style.left = `${pos.x}px`;
  438. ui.style.top = `${pos.y}px`;
  439. }
  440.  
  441. stopDrag() {
  442. this.isDragging = false;
  443. this.shadowRoot.querySelector('.base64-helper').style.transition =
  444. 'opacity 0.3s ease';
  445. }
  446.  
  447. // 窗口resize处理
  448. handleResize() {
  449. clearTimeout(this.resizeTimer);
  450. this.resizeTimer = setTimeout(() => {
  451. const pos = this.positionManager.get();
  452. if (pos) {
  453. const ui = this.shadowRoot.querySelector('.base64-helper');
  454. ui.style.left = `${pos.x}px`;
  455. ui.style.top = `${pos.y}px`;
  456. }
  457. }, 100);
  458. }
  459. // 路由监听
  460. addRouteListeners() {
  461. this.handleRouteChange = () => {
  462. setTimeout(() => this.resetState(), 100); // 延迟 100ms 确保 DOM 更新完成
  463. };
  464. const routeEvents = [
  465. // 原生事件必须绑定到 window
  466. { event: 'popstate', target: window },
  467. { event: 'hashchange', target: window },
  468.  
  469. // Discourse自定义事件必须绑定到 document
  470. { event: 'routeDidChange', target: document },
  471. { event: 'post:added', target: document },
  472. { event: 'posts:inserted', target: document },
  473. { event: 'post:highlighted', target: document },
  474. { event: 'topic:refreshed', target: document },
  475. { event: 'discourse:changed', target: document },
  476. { event: 'post-stream:posted', target: document },
  477. { event: 'post-stream:refresh', target: document },
  478. { event: 'composer:opened', target: document },
  479. ];
  480.  
  481. routeEvents.forEach(({ event, target }) => {
  482. target.addEventListener(event, this.handleRouteChange);
  483. this.eventListeners.push({
  484. element: target,
  485. event,
  486. handler: this.handleRouteChange,
  487. });
  488. });
  489.  
  490. // 重写 history 方法
  491. this.originalPushState = history.pushState;
  492. this.originalReplaceState = history.replaceState;
  493. history.pushState = (...args) => {
  494. this.originalPushState.apply(history, args);
  495. this.handleRouteChange();
  496. };
  497.  
  498. history.replaceState = (...args) => {
  499. this.originalReplaceState.apply(history, args);
  500. this.handleRouteChange();
  501. };
  502. }
  503.  
  504. // 核心功能
  505. handleDecode() {
  506. if (this.decodeBtn.dataset.mode === 'restore') {
  507. this.restoreContent();
  508. return;
  509. }
  510.  
  511. this.originalContents.clear();
  512. let hasValidBase64 = false;
  513.  
  514. try {
  515. document.querySelectorAll(SELECTORS.POST_CONTENT).forEach((element) => {
  516. let newHtml = element.innerHTML;
  517. let modified = false;
  518.  
  519. Array.from(newHtml.matchAll(BASE64_REGEX))
  520. .reverse()
  521. .forEach((match) => {
  522. const original = match[0];
  523. if (!this.validateBase64(original)) return;
  524.  
  525. try {
  526. const decoded = this.decodeBase64(original);
  527. this.originalContents.set(element, element.innerHTML);
  528.  
  529. newHtml = `${newHtml.substring(
  530. 0,
  531. match.index
  532. )}<span class="decoded-text">${decoded}</span>${newHtml.substring(
  533. match.index + original.length
  534. )}`;
  535.  
  536. hasValidBase64 = modified = true;
  537. } catch {}
  538. });
  539.  
  540. if (modified) element.innerHTML = newHtml;
  541. });
  542.  
  543. if (!hasValidBase64) {
  544. this.showNotification('本页未发现有效 Base64 内容', 'info');
  545. this.originalContents.clear();
  546. return;
  547. }
  548.  
  549. document.querySelectorAll(SELECTORS.DECODED_TEXT).forEach((el) => {
  550. el.addEventListener('click', (e) => this.copyToClipboard(e));
  551. });
  552.  
  553. this.decodeBtn.textContent = '恢复本页 Base64';
  554. this.decodeBtn.dataset.mode = 'restore';
  555. this.showNotification('解析完成', 'success');
  556. } catch (e) {
  557. this.showNotification(`解析失败: ${e.message}`, 'error');
  558. this.originalContents.clear();
  559. }
  560.  
  561. this.menuVisible = false;
  562. this.menu.style.display = 'none';
  563. }
  564.  
  565. handleEncode() {
  566. const text = prompt('请输入要编码的文本:');
  567. if (text === null) return;
  568.  
  569. try {
  570. const encoded = this.encodeBase64(text);
  571. GM_setClipboard(encoded);
  572. this.showNotification('Base64 已复制', 'success');
  573. } catch (e) {
  574. this.showNotification('编码失败: ' + e.message, 'error');
  575. }
  576. this.menu.style.display = 'none';
  577. }
  578.  
  579. // 工具方法
  580. validateBase64(str) {
  581. return (
  582. typeof str === 'string' &&
  583. str.length >= 6 &&
  584. str.length % 4 === 0 &&
  585. /^[A-Za-z0-9+/]+={0,2}$/.test(str) &&
  586. str.replace(/=+$/, '').length >= 6
  587. );
  588. }
  589.  
  590. decodeBase64(str) {
  591. return decodeURIComponent(
  592. atob(str)
  593. .split('')
  594. .map((c) => `%${c.charCodeAt(0).toString(16).padStart(2, '0')}`)
  595. .join('')
  596. );
  597. }
  598.  
  599. encodeBase64(str) {
  600. return btoa(
  601. encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, (match, p1) =>
  602. String.fromCharCode(`0x${p1}`)
  603. )
  604. );
  605. }
  606.  
  607. restoreContent() {
  608. this.originalContents.forEach((html, element) => {
  609. element.innerHTML = html;
  610. });
  611. this.originalContents.clear();
  612. this.decodeBtn.textContent = '解析本页 Base64';
  613. this.decodeBtn.dataset.mode = 'decode';
  614. this.showNotification('已恢复原始内容', 'success');
  615. this.menu.style.display = 'none';
  616. }
  617.  
  618. copyToClipboard(e) {
  619. GM_setClipboard(e.target.innerText);
  620. this.showNotification('内容已复制', 'success');
  621. e.stopPropagation();
  622. }
  623.  
  624. resetState() {
  625. if (this.decodeBtn.dataset.mode === 'restore') {
  626. this.restoreContent();
  627. }
  628. }
  629. showNotification(text, type) {
  630. const notification = document.createElement('div');
  631. notification.className = 'base64-notification';
  632. notification.setAttribute('data-type', type);
  633. notification.textContent = text;
  634. document.body.appendChild(notification);
  635. setTimeout(() => notification.remove(), 2300);
  636. }
  637. }
  638.  
  639. // 防冲突处理
  640. if (window.__base64HelperInstance) {
  641. return window.__base64HelperInstance;
  642. }
  643.  
  644. // 初始化
  645. initStyles();
  646. const instance = new Base64Helper();
  647. window.__base64HelperInstance = instance;
  648.  
  649. // 页面卸载时清理
  650. window.addEventListener('unload', () => {
  651. instance.destroy();
  652. delete window.__base64HelperInstance;
  653. });
  654. })();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址