Discourse Base64 Helper

Base64编解码工具 for Discourse论坛

目前為 2025-04-03 提交的版本,檢視 最新版本

// ==UserScript==
// @name         Discourse Base64 Helper
// @icon         https://raw.githubusercontent.com/XavierBar/Discourse-Base64-Helper/refs/heads/main/discourse.svg
// @namespace    http://tampermonkey.net/
// @version      1.3.9
// @description  Base64编解码工具 for Discourse论坛
// @author       Xavier
// @match        *://linux.do/*
// @match        *://clochat.com/*
// @grant        GM_notification
// @grant        GM_setClipboard
// @grant        GM_addStyle
// @grant        GM_getValue
// @grant        GM_setValue
// @run-at       document-idle
// ==/UserScript==

(function () {
	'use strict';

	// 常量定义
	const Z_INDEX = 2147483647;
	const SELECTORS = {
		POST_CONTENT: '.cooked, .post-body',
		DECODED_TEXT: '.decoded-text',
	};
	const STORAGE_KEYS = {
		BUTTON_POSITION: 'btnPosition',
	};
	const BASE64_REGEX = /(?<!\w)([A-Za-z0-9+/]{6,}?={0,2})(?!\w)/g;
	// 样式常量
	const STYLES = {
		GLOBAL: `
            /* 基础内容样式 */
            .decoded-text {
                cursor: pointer;
                transition: all 0.2s;
                padding: 1px 3px;
                border-radius: 3px;
                background-color: #fff3cd !important;
                color: #664d03 !important;
            }
            .decoded-text:hover {
                background-color: #ffe69c !important;
            }
            /* 通知动画 */
            @keyframes slideIn {
                from {
                    transform: translate(-50%, -20px);
                    opacity: 0;
                }
                to {
                    transform: translate(-50%, 0);
                    opacity: 1;
                }
            }
            @keyframes fadeOut {
                from { opacity: 1; }
                to { opacity: 0; }
            }
            /* 暗色模式全局样式 */
            @media (prefers-color-scheme: dark) {
                .decoded-text {
                    background-color: #332100 !important;
                    color: #ffd54f !important;
                }
                .decoded-text:hover {
                    background-color: #664d03 !important;
                }
            }
        `,
		NOTIFICATION: `
            .base64-notification {
                position: fixed;
                top: 20px;
                left: 50%;
                transform: translateX(-50%);
                padding: 12px 24px;
                border-radius: 8px;
                z-index: ${Z_INDEX};
                animation: slideIn 0.3s forwards, fadeOut 0.3s 2s forwards;
                font-family: system-ui, -apple-system, sans-serif;
                pointer-events: none;
                backdrop-filter: blur(4px);
                border: 1px solid rgba(255, 255, 255, 0.1);
                max-width: 80vw;
                text-align: center;
                line-height: 1.5;
                background: rgba(255, 255, 255, 0.95);
                color: #2d3748;
                box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
            }
            .base64-notification[data-type="success"] {
                background: rgba(72, 187, 120, 0.95) !important;
                color: #f7fafc !important;
            }
            .base64-notification[data-type="error"] {
                background: rgba(245, 101, 101, 0.95) !important;
                color: #f8fafc !important;
            }
            .base64-notification[data-type="info"] {
                background: rgba(66, 153, 225, 0.95) !important;
                color: #f7fafc !important;
            }
            @media (prefers-color-scheme: dark) {
                .base64-notification {
                    background: rgba(26, 32, 44, 0.95) !important;
                    color: #e2e8f0 !important;
                    box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -1px rgba(0, 0, 0, 0.2);
                    border-color: rgba(255, 255, 255, 0.05);
                }
                .base64-notification[data-type="success"] {
                    background: rgba(22, 101, 52, 0.95) !important;
                }
                .base64-notification[data-type="error"] {
                    background: rgba(155, 28, 28, 0.95) !important;
                }
                .base64-notification[data-type="info"] {
                    background: rgba(29, 78, 216, 0.95) !important;
                }
            }
        `,
		SHADOW_DOM: `
            :host {
                all: initial !important;
                position: fixed !important;
                z-index: ${Z_INDEX} !important;
                pointer-events: none !important;
            }
            .base64-helper {
                position: fixed;
                z-index: ${Z_INDEX} !important;
                transform: translateZ(100px);
                cursor: move;
                font-family: system-ui, -apple-system, sans-serif;
                opacity: 0.5;
                transition: opacity 0.3s ease, transform 0.2s;
                pointer-events: auto !important;
                will-change: transform;
            }
            .base64-helper:hover {
                opacity: 1 !important;
            }
            .main-btn {
                background: #ffffff;
                color: #000000 !important;
                padding: 8px 16px;
                border-radius: 6px;
                box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
                font-weight: 500;
                user-select: none;
                transition: all 0.2s;
                font-size: 14px;
                cursor: pointer;
                border: none !important;
            }
            .menu {
                position: absolute;
                bottom: calc(100% + 5px);
                right: 0;
                background: #ffffff;
                border-radius: 6px;
                box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
                display: none;
                min-width: auto !important;
                width: max-content !important;
                overflow: hidden;
            }
            .menu-item {
                padding: 8px 12px !important;
                color: #333 !important;
                transition: all 0.2s;
                font-size: 13px;
                cursor: pointer;
                position: relative;
                border-radius: 0 !important;
                isolation: isolate;
                white-space: nowrap !important;
            }
            .menu-item:hover::before {
                content: '';
                position: absolute;
                top: 0;
                left: 0;
                right: 0;
                bottom: 0;
                background: currentColor;
                opacity: 0.1;
                z-index: -1;
            }
            @media (prefers-color-scheme: dark) {
                .main-btn {
                    background: #2d2d2d;
                    color: #fff !important;
                    box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
                }
                .menu {
                    background: #1a1a1a;
                    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
                }
                .menu-item {
                    color: #e0e0e0 !important;
                }
                .menu-item:hover::before {
                    opacity: 0.08;
                }
            }
        `,
	};

	// 样式初始化
	const initStyles = () => {
		GM_addStyle(STYLES.GLOBAL + STYLES.NOTIFICATION);
	};

	class Base64Helper {
		constructor() {
			this.originalContents = new Map();
			this.isDragging = false;
			this.menuVisible = false;
			this.resizeTimer = null;
			this.initUI();
			this.eventListeners = []; // 用于存储事件监听器以便后续清理
			this.initEventListeners();
			this.addRouteListeners();
		}

		// UI 初始化
		initUI() {
			if (document.getElementById('base64-helper-root')) return;

			this.container = document.createElement('div');
			this.container.id = 'base64-helper-root';
			document.body.append(this.container);

			this.shadowRoot = this.container.attachShadow({ mode: 'open' });
			this.shadowRoot.appendChild(this.createShadowStyles());
			this.shadowRoot.appendChild(this.createMainUI());

			this.initPosition();
		}

		createShadowStyles() {
			const style = document.createElement('style');
			style.textContent = STYLES.SHADOW_DOM;
			return style;
		}

		createMainUI() {
			const uiContainer = document.createElement('div');
			uiContainer.className = 'base64-helper';

			this.mainBtn = this.createButton('Base64', 'main-btn');
			this.menu = this.createMenu();

			uiContainer.append(this.mainBtn, this.menu);
			return uiContainer;
		}

		createButton(text, className) {
			const btn = document.createElement('button');
			btn.className = className;
			btn.textContent = text;
			return btn;
		}

		createMenu() {
			const menu = document.createElement('div');
			menu.className = 'menu';

			this.decodeBtn = this.createMenuItem('解析本页 Base64', 'decode');
			this.encodeBtn = this.createMenuItem('文本转 Base64');

			menu.append(this.decodeBtn, this.encodeBtn);
			return menu;
		}

		createMenuItem(text, mode) {
			const item = document.createElement('div');
			item.className = 'menu-item';
			item.textContent = text;
			if (mode) item.dataset.mode = mode;
			return item;
		}

		// 位置管理
		initPosition() {
			const pos = this.positionManager.get() || {
				x: window.innerWidth - 120,
				y: window.innerHeight - 80,
			};

			const ui = this.shadowRoot.querySelector('.base64-helper');
			ui.style.left = `${pos.x}px`;
			ui.style.top = `${pos.y}px`;
		}

		get positionManager() {
			return {
				get: () => {
					const saved = GM_getValue(STORAGE_KEYS.BUTTON_POSITION);
					if (!saved) return null;

					const ui = this.shadowRoot.querySelector('.base64-helper');
					const maxX = window.innerWidth - ui.offsetWidth - 20;
					const maxY = window.innerHeight - ui.offsetHeight - 20;

					return {
						x: Math.min(Math.max(saved.x, 20), maxX),
						y: Math.min(Math.max(saved.y, 20), maxY),
					};
				},
				set: (x, y) => {
					const ui = this.shadowRoot.querySelector('.base64-helper');
					const pos = {
						x: Math.max(
							20,
							Math.min(x, window.innerWidth - ui.offsetWidth - 20)
						),
						y: Math.max(
							20,
							Math.min(y, window.innerHeight - ui.offsetHeight - 20)
						),
					};

					GM_setValue(STORAGE_KEYS.BUTTON_POSITION, pos);
					return pos;
				},
			};
		}

		// 初始化事件监听器
		initEventListeners() {
			const listeners = [
				{
					element: this.mainBtn,
					event: 'click',
					handler: (e) => this.toggleMenu(e),
				},
				{
					element: document,
					event: 'click',
					handler: (e) => this.handleDocumentClick(e),
				},
				{
					element: this.mainBtn,
					event: 'mousedown',
					handler: (e) => this.startDrag(e),
				},
				{ element: document, event: 'mousemove', handler: (e) => this.drag(e) },
				{ element: document, event: 'mouseup', handler: () => this.stopDrag() },
				{
					element: this.decodeBtn,
					event: 'click',
					handler: () => this.handleDecode(),
				},
				{
					element: this.encodeBtn,
					event: 'click',
					handler: () => this.handleEncode(),
				},
				{
					element: window,
					event: 'resize',
					handler: () => this.handleResize(),
				},
			];

			listeners.forEach(({ element, event, handler }) => {
				element.addEventListener(event, handler);
				this.eventListeners.push({ element, event, handler });
			});
		}

		// 清理事件监听器和全局引用
		destroy() {
			// 清理所有事件监听器
			this.eventListeners.forEach(({ element, event, handler }) => {
				element.removeEventListener(event, handler);
			});
			this.eventListeners = [];

			// 清理全局引用
			if (window.__base64HelperInstance === this) {
				delete window.__base64HelperInstance;
			}

			// 清理 Shadow DOM 和其他 DOM 引用
			if (this.container?.parentNode) {
				this.container.parentNode.removeChild(this.container);
			}

			history.pushState = this.originalPushState; // 恢复原始方法
			history.replaceState = this.originalReplaceState; // 恢复原始方法

			//清理 resize 定时器
			clearTimeout(this.resizeTimer);
		}

		// 菜单切换
		toggleMenu(e) {
			e.stopPropagation();
			this.menuVisible = !this.menuVisible;
			this.menu.style.display = this.menuVisible ? 'block' : 'none';
		}

		handleDocumentClick(e) {
			if (this.menuVisible && !this.shadowRoot.contains(e.target)) {
				this.menuVisible = false;
				this.menu.style.display = 'none';
			}
		}

		// 拖拽功能
		startDrag(e) {
			this.isDragging = true;
			this.startX = e.clientX;
			this.startY = e.clientY;
			const rect = this.shadowRoot
				.querySelector('.base64-helper')
				.getBoundingClientRect();
			this.initialX = rect.left;
			this.initialY = rect.top;
			this.shadowRoot.querySelector('.base64-helper').style.transition = 'none';
		}

		drag(e) {
			if (!this.isDragging) return;
			const dx = e.clientX - this.startX;
			const dy = e.clientY - this.startY;

			const newX = this.initialX + dx;
			const newY = this.initialY + dy;

			const pos = this.positionManager.set(newX, newY);
			const ui = this.shadowRoot.querySelector('.base64-helper');
			ui.style.left = `${pos.x}px`;
			ui.style.top = `${pos.y}px`;
		}

		stopDrag() {
			this.isDragging = false;
			this.shadowRoot.querySelector('.base64-helper').style.transition =
				'opacity 0.3s ease';
		}

		// 窗口resize处理
		handleResize() {
			clearTimeout(this.resizeTimer);
			this.resizeTimer = setTimeout(() => {
				const pos = this.positionManager.get();
				if (pos) {
					const ui = this.shadowRoot.querySelector('.base64-helper');
					ui.style.left = `${pos.x}px`;
					ui.style.top = `${pos.y}px`;
				}
			}, 100);
		}
		// 路由监听
		addRouteListeners() {
			this.handleRouteChange = () => {
				this.resetState();
			};
			const routeEvents = [
				'popstate',
				'hashchange',
				'turbo:render',
				'discourse:before-auto-refresh',
				'page:changed',
			];
			routeEvents.forEach((event) => {
				window.addEventListener(event, this.handleRouteChange);
				this.eventListeners.push({
					element: window,
					event,
					handler: this.handleRouteChange,
				});
			});
			// 重写 history 方法
			this.originalPushState = history.pushState;
			this.originalReplaceState = history.replaceState;
			history.pushState = (...args) => {
				this.originalPushState.apply(history, args);
				this.handleRouteChange();
			};

			history.replaceState = (...args) => {
				this.originalReplaceState.apply(history, args);
				this.handleRouteChange();
			};
		}

		// 核心功能
		handleDecode() {
			if (this.decodeBtn.dataset.mode === 'restore') {
				this.restoreContent();
				return;
			}

			this.originalContents.clear();
			let hasValidBase64 = false;

			try {
				for (const element of document.querySelectorAll(
					SELECTORS.POST_CONTENT
				)) {
					let newHtml = element.innerHTML;
					let modified = false;

					for (const match of Array.from(
						newHtml.matchAll(BASE64_REGEX)
					).reverse()) {
						const original = match[0];
						if (!this.validateBase64(original)) continue;

						try {
							const decoded = this.decodeBase64(original);
							this.originalContents.set(element, element.innerHTML);

							newHtml = `${newHtml.substring(
								0,
								match.index
							)}<span class="decoded-text">${decoded}</span>${newHtml.substring(
								match.index + original.length
							)}`;

							hasValidBase64 = modified = true;
						} catch {}
					}

					if (modified) element.innerHTML = newHtml;
				}

				if (!hasValidBase64) {
					this.showNotification('本页未发现有效 Base64 内容', 'info');
					this.originalContents.clear();
					return;
				}

				for (const el of document.querySelectorAll(SELECTORS.DECODED_TEXT)) {
					el.addEventListener('click', (e) => this.copyToClipboard(e));
				}

				this.decodeBtn.textContent = '恢复本页 Base64';
				this.decodeBtn.dataset.mode = 'restore';
				this.showNotification('解析完成', 'success');
			} catch (e) {
				this.showNotification(`解析失败: ${e.message}`, 'error');
				this.originalContents.clear();
			}

			this.menuVisible = false;
			this.menu.style.display = 'none';
		}

		handleEncode() {
			const text = prompt('请输入要编码的文本:');
			if (text === null) return;

			try {
				const encoded = this.encodeBase64(text);
				GM_setClipboard(encoded);
				this.showNotification('Base64 已复制', 'success');
			} catch (e) {
				this.showNotification('编码失败: ' + e.message, 'error');
			}
			this.menu.style.display = 'none';
		}

		// 工具方法
		validateBase64(str) {
			return (
				typeof str === 'string' &&
				str.length >= 6 &&
				str.length % 4 === 0 &&
				/^[A-Za-z0-9+/]+={0,2}$/.test(str) &&
				str.replace(/=+$/, '').length >= 6
			);
		}

		decodeBase64(str) {
			return decodeURIComponent(
				atob(str)
					.split('')
					.map((c) => `%${c.charCodeAt(0).toString(16).padStart(2, '0')}`)
					.join('')
			);
		}

		encodeBase64(str) {
			return btoa(
				encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, (match, p1) =>
					String.fromCharCode(`0x${p1}`)
				)
			);
		}

		restoreContent() {
			this.originalContents.forEach((html, element) => {
				element.innerHTML = html;
			});
			this.originalContents.clear();
			this.decodeBtn.textContent = '解析本页 Base64';
			this.decodeBtn.dataset.mode = 'decode';
			this.showNotification('已恢复原始内容', 'success');
			this.menu.style.display = 'none';
		}

		copyToClipboard(e) {
			GM_setClipboard(e.target.innerText);
			this.showNotification('内容已复制', 'success');
			e.stopPropagation();
		}

		resetState() {
			if (this.decodeBtn.dataset.mode === 'restore') {
				this.restoreContent();
			}
		}

		showNotification(text, type) {
			const notification = document.createElement('div');
			notification.className = 'base64-notification';
			notification.setAttribute('data-type', type);
			notification.textContent = text;
			document.body.appendChild(notification);
			setTimeout(() => notification.remove(), 2300);
		}
	}

	// 初始化
	initStyles();
	const instance = new Base64Helper();

	// 防冲突处理和清理
	if (window.__base64HelperInstance) {
		window.__base64HelperInstance.destroy(); // 确保旧实例被清理
	}
	window.__base64HelperInstance = instance;

	// 页面卸载时清理
	window.addEventListener('unload', () => {
		instance.destroy();
		delete window.__base64HelperInstance;
	});
})();

QingJ © 2025

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