// ==UserScript==
// @name Discourse Base64 Helper
// @namespace http://tampermonkey.net/
// @version 1.2
// @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';
// 样式注入
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;
}
`);
// 初始化检测
if (document.getElementById('base64-helper-root')) return;
const container = document.createElement('div');
container.id = 'base64-helper-root';
document.body.append(container);
const shadowRoot = container.attachShadow({ mode: 'open' });
// Shadow DOM样式
const style = document.createElement('style');
style.textContent = `
:host {
all: initial !important;
position: fixed !important;
z-index: 2147483647 !important;
pointer-events: none !important;
}
.base64-helper {
position: fixed;
z-index: 2147483647;
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;
pointer-events: auto !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;
pointer-events: auto !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;
}
.menu-item:first-child:hover::before {
border-radius: 6px 6px 0 0;
}
.menu-item:last-child:hover::before {
border-radius: 0 0 6px 6px;
}
@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;
}
}
@keyframes slideIn {
from { top: -50px; opacity: 0; }
to { top: 20px; opacity: 1; }
}
@keyframes fadeOut {
from { opacity: 1; }
to { opacity: 0; }
}
`;
shadowRoot.appendChild(style);
// 界面元素
const uiContainer = document.createElement('div');
uiContainer.className = 'base64-helper';
const mainBtn = document.createElement('button');
mainBtn.className = 'main-btn';
mainBtn.textContent = 'Base64';
const menu = document.createElement('div');
menu.className = 'menu';
const decodeBtn = document.createElement('div');
decodeBtn.className = 'menu-item';
decodeBtn.textContent = '解析本页Base64';
decodeBtn.dataset.mode = 'decode';
const encodeBtn = document.createElement('div');
encodeBtn.className = 'menu-item';
encodeBtn.textContent = '文本转Base64';
menu.append(decodeBtn, encodeBtn);
uiContainer.append(mainBtn, menu);
shadowRoot.appendChild(uiContainer);
// 核心功能
let menuVisible = false;
let isDragging = false;
let startX, startY, initialX, initialY;
const originalContents = new Map();
// 位置管理
const positionManager = {
get: () => {
const saved = GM_getValue('btnPosition');
if (!saved) return null;
const maxX = window.innerWidth - uiContainer.offsetWidth - 20;
const maxY = window.innerHeight - uiContainer.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 pos = {
x: Math.max(20, Math.min(x, window.innerWidth - uiContainer.offsetWidth - 20)),
y: Math.max(20, Math.min(y, window.innerHeight - uiContainer.offsetHeight - 20))
};
GM_setValue('btnPosition', pos);
return pos;
}
};
// 初始化位置
const initPosition = () => {
const pos = positionManager.get() || {
x: window.innerWidth - 120,
y: window.innerHeight - 80
};
uiContainer.style.left = `${pos.x}px`;
uiContainer.style.top = `${pos.y}px`;
};
initPosition();
// 状态重置
function resetState() {
if (decodeBtn.dataset.mode === 'restore') {
restoreOriginalContent();
decodeBtn.textContent = '解析本页Base64';
decodeBtn.dataset.mode = 'decode';
originalContents.clear();
}
}
// 事件监听
mainBtn.addEventListener('click', function(e) {
e.stopPropagation();
menuVisible = !menuVisible;
menu.style.display = menuVisible ? 'block' : 'none';
});
document.addEventListener('click', function(e) {
if (menuVisible && !shadowRoot.contains(e.target)) {
menuVisible = false;
menu.style.display = 'none';
}
});
// 拖拽功能
mainBtn.addEventListener('mousedown', startDrag);
document.addEventListener('mousemove', drag);
document.addEventListener('mouseup', stopDrag);
function startDrag(e) {
isDragging = true;
startX = e.clientX;
startY = e.clientY;
const rect = uiContainer.getBoundingClientRect();
initialX = rect.left;
initialY = rect.top;
uiContainer.style.transition = 'none';
}
function drag(e) {
if (!isDragging) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
const newX = initialX + dx;
const newY = initialY + dy;
const pos = positionManager.set(newX, newY);
uiContainer.style.left = `${pos.x}px`;
uiContainer.style.top = `${pos.y}px`;
}
function stopDrag() {
isDragging = false;
uiContainer.style.transition = 'opacity 0.3s ease';
}
// 窗口resize处理
let resizeTimer;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
const pos = positionManager.get();
if (pos) {
uiContainer.style.left = `${pos.x}px`;
uiContainer.style.top = `${pos.y}px`;
}
}, 100);
});
// 页面导航事件
window.addEventListener('popstate', resetState);
window.addEventListener('turbo:render', resetState);
window.addEventListener('discourse:before-auto-refresh', () => {
GM_setValue('btnPosition', positionManager.get());
resetState();
});
// 解析功能
decodeBtn.addEventListener('click', function() {
if (this.dataset.mode === 'restore') {
restoreOriginalContent();
this.textContent = '解析本页Base64';
this.dataset.mode = 'decode';
showNotification('已恢复原始内容', 'success');
menu.style.display = 'none';
return;
}
originalContents.clear();
let hasValidBase64 = false;
try {
document.querySelectorAll('.cooked, .post-body').forEach(element => {
const regex = /(?<!\w)([A-Za-z0-9+/]{6,}?={0,2})(?!\w)/g;
let newHtml = element.innerHTML;
let modified = false;
Array.from(newHtml.matchAll(regex)).reverse().forEach(match => {
const original = match[0];
if (!validateBase64(original)) return;
try {
const decoded = decodeBase64(original);
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) {
showNotification('本页未发现有效Base64内容', 'info');
originalContents.clear();
return;
}
document.querySelectorAll('.decoded-text').forEach(el => {
el.addEventListener('click', copyToClipboard);
});
decodeBtn.textContent = '恢复本页Base64';
decodeBtn.dataset.mode = 'restore';
showNotification('解析完成', 'success');
} catch (e) {
showNotification('解析失败: ' + e.message, 'error');
originalContents.clear();
}
menuVisible = false;
menu.style.display = 'none';
});
// 编码功能
encodeBtn.addEventListener('click', function() {
const text = prompt('请输入要编码的文本:');
if (text === null) return;
try {
const encoded = encodeBase64(text);
GM_setClipboard(encoded);
showNotification('Base64已复制', 'success');
} catch (e) {
showNotification('编码失败: ' + e.message, 'error');
}
menu.style.display = 'none';
});
// 改进的校验函数
function validateBase64(str) {
// 基础校验
const validLength = str.length % 4 === 0;
const validChars = /^[A-Za-z0-9+/]+={0,2}$/.test(str);
const validPadding = !(str.includes('=') && !/==?$/.test(str));
if (!validLength || !validChars || !validPadding) return false;
// 移除填充后的校验
const baseStr = str.replace(/=+$/, '');
if (baseStr.length < 6) return false;
const hasSpecialChar = /[+/0-9]/.test(baseStr);
return hasSpecialChar;
}
// 工具函数
function decodeBase64(str) {
return decodeURIComponent(escape(atob(str)));
}
function encodeBase64(str) {
return btoa(unescape(encodeURIComponent(str)));
}
function restoreOriginalContent() {
originalContents.forEach((html, element) => {
element.innerHTML = html;
});
originalContents.clear();
}
function copyToClipboard(e) {
GM_setClipboard(e.target.innerText);
showNotification('内容已复制', 'success');
e.stopPropagation();
}
// 通知系统
function 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: 2147483647;
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);
}
// 防冲突处理
if (window.hasBase64Helper) return;
window.hasBase64Helper = true;
})();