Discourse Base64 Helper

Base64编解码工具 for Discourse论坛

As of 2025-04-02. See the latest version.

// ==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;
    });
})();