// ==UserScript==
// @name Discourse Base64 Helper
// @namespace http://tampermonkey.net/
// @version 1.2.5
// @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 SELECTORS = {
POST_CONTENT: '.cooked, .post-body',
DECODED_TEXT: '.decoded-text'
};
const STORAGE_KEYS = {
BUTTON_POSITION: 'btnPosition'
};
const Z_INDEX = 2147483647;
const BASE64_REGEX = /(?<!\w)([A-Za-z0-9+/]{6,}?={0,2})(?!\w)/g;
// 样式初始化
const initStyles = () => {
GM_addStyle(`
.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;
}
@media (prefers-color-scheme: dark) {
.decoded-text {
background-color: #332100 !important;
color: #ffd54f !important;
}
.decoded-text:hover {
background-color: #664d03 !important;
}
}
.menu-item[data-mode="restore"] {
background: rgba(0, 123, 255, 0.1) !important;
}
`);
};
class Base64Helper {
constructor() {
this.originalContents = new Map();
this.isDragging = false;
this.menuVisible = false;
this.resizeTimer = null;
this.initUI();
this.initEventListeners();
this.addRouteListeners();
this.observeSPA();
}
// 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 = `
: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;
}
}
`;
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() {
this.mainBtn.addEventListener('click', (e) => this.toggleMenu(e));
document.addEventListener('click', (e) => this.handleDocumentClick(e));
// 拖拽事件
this.mainBtn.addEventListener('mousedown', (e) => this.startDrag(e));
document.addEventListener('mousemove', (e) => this.drag(e));
document.addEventListener('mouseup', () => this.stopDrag());
// 功能按钮
this.decodeBtn.addEventListener('click', () => this.handleDecode());
this.encodeBtn.addEventListener('click', () => this.handleEncode());
// 窗口resize
window.addEventListener('resize', () => this.handleResize());
}
// 菜单切换
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() {
const handleRouteChange = () => {
//GM_setValue(STORAGE_KEYS.BUTTON_POSITION, this.positionManager.get());
this.resetState();
};
// 重写history方法
const originalPushState = history.pushState;
const originalReplaceState = history.replaceState;
history.pushState = (...args) => {
originalPushState.apply(history, args);
handleRouteChange();
};
history.replaceState = (...args) => {
originalReplaceState.apply(history, args);
handleRouteChange();
};
// 事件监听
[
'popstate',
'hashchange',
'turbo:render',
'discourse:before-auto-refresh',
'page:changed'
].forEach(event => {
window.addEventListener(event, handleRouteChange);
});
}
// 核心功能
handleDecode() {
if (this.decodeBtn.dataset.mode === 'restore') {
this.restoreContent();
return;
}
this.originalContents.clear();
let hasValidBase64 = false;
try {
document.querySelectorAll(SELECTORS.POST_CONTENT).forEach(element => {
let newHtml = element.innerHTML;
let modified = false;
Array.from(newHtml.matchAll(BASE64_REGEX)).reverse().forEach(match => {
const original = match[0];
if (!this.validateBase64(original)) return;
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(e) {}
});
if (modified) element.innerHTML = newHtml;
});
if (!hasValidBase64) {
this.showNotification('本页未发现有效Base64内容', 'info');
this.originalContents.clear();
return;
}
document.querySelectorAll(SELECTORS.DECODED_TEXT).forEach(el => {
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(escape(atob(str)));
}
encodeBase64(str) {
return btoa(unescape(encodeURIComponent(str)));
}
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.style.cssText = `
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
padding: 12px 24px;
border-radius: 6px;
background: ${type === 'success' ? '#4CAF50' :
type === 'error' ? '#f44336' : '#2196F3'};
color: white;
z-index: ${Z_INDEX};
animation: slideIn 0.3s forwards, fadeOut 0.3s 2s forwards;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
font-family: system-ui, -apple-system, sans-serif;
pointer-events: none;
`;
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;
});
})();