Discourse Base64 Helper

Base64编解码工具 for Discourse论坛

As of 02.04.2025. See апошняя версія.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Discourse Base64 Helper
// @namespace    http://tampermonkey.net/
// @version      1.2.7
// @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';
        }

        async copyToClipboard(e) {
            const text = e.target.innerText;

            // 方案1: 尝试使用原生execCommand
            const fallbackCopy = () => {
                const textarea = document.createElement('textarea');
                textarea.value = text;
                textarea.style.position = 'fixed'; // 避免滚动
                document.body.appendChild(textarea);
                textarea.select();

                try {
                    document.execCommand('copy');
                    this.showNotification('内容已复制', 'success');
                } catch (err) {
                    this.showNotification('复制失败,请手动复制', 'error');
                } finally {
                    document.body.removeChild(textarea);
                }
            };
            // 方案2: 优先使用GM_setClipboard
            try {
                // Safari需要包裹在同步用户事件中
                if (typeof GM_setClipboard === 'function') {
                    GM_setClipboard(text, 'text');
                    this.showNotification('内容已复制', 'success');
                } else {
                    fallbackCopy();
                }
            } catch (err) {
                fallbackCopy();
            }

            // 确保事件冒泡 (Safari Shadow DOM特殊处理)
            e.stopPropagation();
            e.preventDefault();
        }

        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();
    // 修改事件监听方式(确保Safari识别为用户操作)
    document.querySelectorAll(SELECTORS.DECODED_TEXT).forEach(el => {
        el.addEventListener('click', (e) => {
            e.stopPropagation();
            this.copyToClipboard(e);
        }, {
            capture: true, // 捕获阶段处理
            passive: false
        });
    });
    // 防冲突处理和清理
    if (window.__base64HelperInstance) {
        window.__base64HelperInstance.destroy();
    }
    window.__base64HelperInstance = instance;
    // 页面卸载时清理
    window.addEventListener('unload', () => {
        instance.destroy();
        delete window.__base64HelperInstance;
    });
})();