// ==UserScript==
// @name Discourse Base64 Helper
// @icon https://raw.githubusercontent.com/XavierBar/Discourse-Base64-Helper/refs/heads/main/discourse-brands-solid.svg
// @namespace http://tampermonkey.net/
// @version 1.3.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 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.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() {
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.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
})
})()