Weibo Markdown Editor

为新浪微博网页端发微博添加完善的 Markdown 快捷工具栏及实时预览功能。

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

// ==UserScript==
// @name         Weibo Markdown Editor
// @name:zh-CN   微博 Markdown 编辑器
// @name:zh-TW   微博 Markdown 編輯器
// @name:zh-HK   微博 Markdown 編輯器
// @namespace    https://weibo.com/u/1353143215
// @version      0.1
// @description  为新浪微博网页端发微博添加完善的 Markdown 快捷工具栏及实时预览功能。
// @description:zh-CN 为新浪微博网页端发微博添加完善的 Markdown 快捷工具栏及实时预览功能。
// @description:zh-TW 為新浪微博網頁端發微博添加完善的 Markdown 快捷工具欄及實時預覽功能。
// @description:zh-HK 為新浪微博網頁端發微博添加完善的 Markdown 快捷工具欄及實時預覽功能。
// @author       大王昭君来了
// @license      MIT
// @match        https://weibo.com/*
// @icon         https://weibo.com/favicon.ico
// @require      https://cdn.jsdelivr.net/npm/[email protected]/marked.min.js
// @resource     githubCSS https://cdn.staticfile.net/github-markdown-css/5.5.1/github-markdown.min.css
// @grant        GM_addStyle
// @grant        GM_getResourceText
// @run-at       document-idle
// ==/UserScript==

(function() {
    'use strict';

    // Inject GitHub Markdown CSS
    try {
        const css = GM_getResourceText("githubCSS");
        GM_addStyle(css);
    } catch(e) {
        console.warn("Failed to load GitHub Markdown CSS via GM_getResourceText. Attempting fallback.", e);
        const link = document.createElement('link');
        link.rel = 'stylesheet';
        link.href = 'https://cdn.staticfile.net/github-markdown-css/5.5.1/github-markdown.min.css';
        document.head.appendChild(link);
    }

    // Inject Custom UI Styles
    GM_addStyle(`
        .wb-md-container {
            display: flex;
            flex-direction: column;
            width: 100%;
            margin-bottom: 5px;
            border: 1px solid transparent;
            transition: border 0.3s;
        }
        .wb-md-container:hover {
            border-color: #f0f0f0;
            border-radius: 6px;
        }
        .wb-md-toolbar {
            display: flex;
            flex-wrap: wrap;
            gap: 2px;
            padding: 4px;
            background: #fdfdfd;
            border-bottom: 1px solid #f0f0f0;
            border-radius: 6px 6px 0 0;
            user-select: none;
        }
        .wb-md-toolbar button {
            cursor: pointer;
            background: transparent;
            border: 1px solid transparent;
            border-radius: 4px;
            padding: 4px 6px;
            font-size: 13px;
            display: inline-flex;
            align-items: center;
            justify-content: center;
            min-width: 28px;
            height: 28px;
            color: #666;
            transition: all 0.2s;
            font-family: inherit;
        }
        .wb-md-toolbar button:hover {
            background: #f0f0f0;
            color: #ff8200; /* Weibo Orange */
        }
        .wb-md-toolbar button svg {
            width: 16px;
            height: 16px;
            fill: currentColor;
        }
        .wb-md-divider {
            width: 1px;
            background: #e8e8e8;
            margin: 4px 2px;
        }
        .wb-md-preview {
            padding: 10px 14px;
            background: #fff;
            color: #333;
            box-sizing: border-box;
            width: 100%;
            min-height: 100px;
            max-height: 400px;
            overflow-y: auto;
            border-radius: 0 0 6px 6px;
            display: none; /* Hidden by default */
        }
        /* Custom tweak for Github CSS inside Weibo */
        .markdown-body {
            font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji;
            font-size: 14px;
        }
        .wb-md-btn-right {
            margin-left: auto !important;
            font-weight: bold;
            color: #ff8200 !important;
            border: 1px solid #f0f0f0 !important;
        }
        .wb-md-btn-right:hover {
            background: #fffafa !important;
        }
        
        /* Custom Modal UI */
        .wb-md-modal-overlay {
            position: fixed; top: 0; left: 0; width: 100%; height: 100%;
            background: rgba(0,0,0,0.3); z-index: 99999;
            display: flex; align-items: center; justify-content: center;
        }
        .wb-md-modal {
            background: #fff; border-radius: 8px; padding: 20px; box-shadow: 0 4px 12px rgba(0,0,0,0.15);
            width: 250px; font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif;
            color: #333;
        }
        .wb-md-modal h3 { margin: 0 0 15px 0; font-size: 16px; color: #333; font-weight: 600; text-align: center; }
        .wb-md-modal label { display: block; margin-bottom: 10px; font-size: 14px; color: #666; }
        .wb-md-modal input { width: 100%; box-sizing: border-box; padding: 6px 8px; border: 1px solid #ccc; border-radius: 4px; margin-top: 4px; outline: none; }
        .wb-md-modal input:focus { border-color: #ff8200; }
        .wb-md-modal .actions { margin-top: 18px; display: flex; justify-content: space-between; gap: 10px; }
        .wb-md-modal button { flex: 1; border: none; padding: 8px 0; border-radius: 4px; cursor: pointer; font-size: 14px; transition: background 0.2s; }
        .wb-md-modal button.cancel { background: #f0f0f0; color: #333; }
        .wb-md-modal button.cancel:hover { background: #e0e0e0; }
        .wb-md-modal button.submit { background: #ff8200; color: #fff; }
        .wb-md-modal button.submit:hover { background: #e67600; }
    `);

    // --- SVGs for Icons ---
    const ICONS = {
        bold: '<svg viewBox="0 0 24 24"><path d="M15.6 10.79c.97-.67 1.65-1.77 1.65-2.79 0-2.26-1.75-4-4-4H7v14h7.04c2.09 0 3.71-1.7 3.71-3.79 0-1.52-.86-2.82-2.15-3.42zM10 6.5h3c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5h-3v-3zm3.5 9H10v-3h3.5c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5z"/></svg>',
        italic: '<svg viewBox="0 0 24 24"><path d="M10 4v3h2.21l-3.42 8H6v3h8v-3h-2.21l3.42-8H18V4h-8z"/></svg>',
        strike: '<svg viewBox="0 0 24 24"><path d="M7.24 8.75c-.26-.48-.39-1.03-.39-1.67 0-.61.13-1.16.39-1.67.26-.51.63-.93 1.11-1.25.48-.32 1.05-.56 1.7-.75.65-.19 1.39-.28 2.19-.28.81 0 1.54.1 2.21.28.66.19 1.23.46 1.69.81.47.35.82.77 1.07 1.27s.38 1.07.38 1.71h-3.01c0-.43-.1-.8-.3-1.11-.2-.31-.48-.55-.84-.71-.36-.16-.78-.24-1.25-.24-.46 0-.85.08-1.18.24-.33.16-.57.37-.73.66-.16.29-.24.62-.24 1.01 0 .34.07.61.22.84.15.22.36.4.63.54.27.13.59.24.97.31L12 8.49V11h5.16c1.37-.18 2.53-.61 3.49-1.3l.03.01v4.89l-.02.01c-.11-.06-.23-.11-.35-.16-.13-.05-.26-.1-.41-.15h.02c-.85-.36-1.85-.56-2.92-.56-1.17 0-2.26.24-3.18.66-.91.41-1.63.98-2.14 1.68-.51.68-.78 1.48-.78 2.37 0 .54.11 1.03.32 1.49.22.45.54.83.94 1.13.4.3.89.53 1.46.68.57.15 1.21.23 1.91.23 1.03 0 1.94-.17 2.72-.51.78-.34 1.4-.79 1.87-1.35.47-.56.76-1.18.89-1.85h3.02c-.15 1.17-.6 2.18-1.34 3.03-.74.85-1.72 1.51-2.92 1.96-1.21.45-2.58.68-4.11.68-1.52 0-2.88-.22-4.07-.66-1.2-.44-2.18-1.07-2.93-1.88-.75-.81-1.13-1.78-1.13-2.91 0-.96.22-1.81.65-2.53.43-.73 1.03-1.33 1.78-1.82.74-.48 1.6-.84 2.58-1.09l-1.3-.35c-.88-.23-1.6-.57-2.14-1.03-.54-.46-.81-1.06-.81-1.81zm9.32 8.74c0-.52-.16-.94-.48-1.26-.32-.32-.78-.47-1.37-.47-.57 0-1.02.16-1.35.48-.33.32-.49.74-.49 1.25 0 .52.16.94.49 1.25.33.31.78.47 1.35.47.58 0 1.04-.15 1.37-.47.32-.32.48-.73.48-1.25zM3 11h18v2H3z"/></svg>',
        h1: '<svg viewBox="0 0 24 24"><path d="M5 4v3h5.5v12h3V7H19V4z"/></svg>',
        h2: '<svg viewBox="0 0 24 24"><path d="M2.5 4v3h5.5v12h3V7h5.5l-1.88 5h2.09l1.88-5H21.5V4z"/></svg>', // simplified
        quote: '<svg viewBox="0 0 24 24"><path d="M6 17h3l2-4V7H5v6h3zm8 0h3l2-4V7h-6v6h3z"/></svg>',
        code: '<svg viewBox="0 0 24 24"><path d="M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z"/></svg>',
        list: '<svg viewBox="0 0 24 24"><path d="M3 13h2v-2H3v2zm0 4h2v-2H3v2zm0-8h2V7H3v2zm4 4h14v-2H7v2zm0 4h14v-2H7v2zM7 7v2h14V7H7z"/></svg>',
        numlist: '<svg viewBox="0 0 24 24"><path d="M2 17h2v.5H3v1h1v.5H2v1h3v-4H2v1zm1-9h1V4H2v1h1v3zm-1 3h1.8L2 13.1v.9h3v-1H3.2L5 10.9V10H2v1zm5-6v2h14V5H7zm0 14h14v-2H7v2zm0-6h14v-2H7v2z"/></svg>',
        link: '<svg viewBox="0 0 24 24"><path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>',
        image: '<svg viewBox="0 0 24 24"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></svg>',
        table: '<svg viewBox="0 0 24 24"><path d="M20 3H5C3.9 3 3 3.9 3 5v14c0 1.1.9 2 2 2h15c1.1 0 2-.9 2-2V5C22 3.9 21.1 3 20 3zM8 19H5v-6h3V19zM8 11H5V5h3V11zM14 19h-4v-6h4V19zM14 11h-4V5h4V11zM20 19h-4v-6h4V19zM20 11h-4V5h4V11z"/></svg>',
    };

    // Tools Configuration
    const TOOLBAR_BTNS = [
        { label: 'B', title: '加粗 (Ctrl+B)', icon: ICONS.bold, template: '**{}**' },
        { label: 'I', title: '斜体 (Ctrl+I)', icon: ICONS.italic, template: '*{}*' },
        { label: 'S', title: '删除线', icon: ICONS.strike, template: '~~{}~~' },
        { type: 'divider' },
        { label: 'H1', title: '一级标题', icon: ICONS.h1, template: '# {}', block: true },
        { label: 'H2', title: '二级标题', icon: ICONS.h2, template: '## {}', block: true },
        { label: 'Q', title: '引用', icon: ICONS.quote, template: '> {}', block: true },
        { type: 'divider' },
        { label: 'Code', title: '行内代码', icon: ICONS.code, template: '\`{}\`' },
        { label: 'CodeBlock', title: '代码块', icon: ICONS.code, template: '\n```\n{}\n```\n', block: true, text: 'CB' },
        { label: 'Table', title: '表格', icon: ICONS.table, action: insertTable },
        { type: 'divider' },
        { label: 'UL', title: '无序列表', icon: ICONS.list, template: '- {}', block: true, isList: true },
        { label: 'OL', title: '有序列表', icon: ICONS.numlist, template: '1. {}', block: true, isList: true },
        { type: 'divider' },
        { label: 'Link', title: '链接', icon: ICONS.link, template: '[{}](url)' },
        { label: 'Img', title: '图片', icon: ICONS.image, template: '![alt]( {})' }
    ];

    function insertTable(textarea) {
        // Save current selection before modal takes focus away
        const savedStart = textarea.selectionStart;
        const savedEnd = textarea.selectionEnd;
        
        const overlay = document.createElement('div');
        overlay.className = 'wb-md-modal-overlay';
        
        const modal = document.createElement('div');
        modal.className = 'wb-md-modal';
        
        modal.innerHTML = `
            <h3>插入表格</h3>
            <label>列数 (Columns):<input type="number" id="wb-md-cols" value="3" min="1" max="20"></label>
            <label>行数 (Rows):<input type="number" id="wb-md-rows" value="2" min="1" max="50"></label>
            <div class="actions">
                <button class="cancel">取消</button>
                <button class="submit">确定</button>
            </div>
        `;
        
        overlay.appendChild(modal);
        document.body.appendChild(overlay);
        
        const colsInput = modal.querySelector('#wb-md-cols');
        const rowsInput = modal.querySelector('#wb-md-rows');
        const btnCancel = modal.querySelector('.cancel');
        const btnSubmit = modal.querySelector('.submit');
        
        colsInput.focus();
        colsInput.select();
        
        const close = () => {
            if (document.body.contains(overlay)) {
                document.body.removeChild(overlay);
            }
        };
        
        const submitColsRows = () => {
            let cols = parseInt(colsInput.value);
            let rows = parseInt(rowsInput.value);
            if (isNaN(cols) || cols < 1) cols = 3;
            if (isNaN(rows) || rows < 1) rows = 2;
            
            let header = "\n|";
            let sep = "|";
            for (let i = 1; i <= cols; i++) {
                 header += ` 列 ${i} |`;
                 sep += " --- |";
            }
            let body = "";
            for (let r = 1; r <= rows; r++) {
                 body += "\n|";
                 for (let c = 1; c <= cols; c++) {
                     body += " 内容 |";
                 }
            }
            
            const tableTemplate = header + "\n" + sep + body + "\n";
            // Restore selection before inserting
            textarea.setSelectionRange(savedStart, savedEnd);
            insertTextAtCursor(textarea, tableTemplate, 0);
            close();
        };

        btnCancel.addEventListener('click', close);
        btnSubmit.addEventListener('click', submitColsRows);
        overlay.addEventListener('click', (e) => {
            if (e.target === overlay) close();
        });
        modal.addEventListener('keydown', (e) => {
            if (e.key === 'Escape') close();
            if (e.key === 'Enter') submitColsRows();
        });
    }

    // --- React State Sync Hack ---
    function setReactInputValue(input, value) {
        let nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value").set;
        // In latest React, keeping it robust:
        if (!nativeInputValueSetter) {
            nativeInputValueSetter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(input), "value").set;
        }
        if (nativeInputValueSetter) {
             nativeInputValueSetter.call(input, value);
        } else {
             input.value = value;
        }
        
        input.dispatchEvent(new Event('input', { bubbles: true }));
        // Some systems might need change event too
        input.dispatchEvent(new Event('change', { bubbles: true })); 
    }

    function insertTextAtCursor(textarea, template, selectionOffset = 0, isBlock = false, isList = false) {
        textarea.focus();
        const startPos = textarea.selectionStart;
        const endPos = textarea.selectionEnd;
        const selectedText = textarea.value.substring(startPos, endPos) || (selectionOffset === 0 ? "" : "Text");

        let textToInsert;
        if (isList && selectedText.includes('\n')) {
            const isOrdered = template.startsWith('1.');
            const lines = selectedText.split('\n');
            const processedLines = lines.map((line, idx) => {
                const prefix = isOrdered ? `${idx + 1}. ` : '- ';
                // Skip prefixing pure empty lines for better formatting
                if (line.trim() === "") return line;
                return prefix + line;
            });
            textToInsert = processedLines.join('\n');
        } else {
            textToInsert = template.replace('{}', selectedText);
        }
        
        // Handle block level insertions (ensure newlines)
        if (isBlock && startPos > 0 && textarea.value.charAt(startPos - 1) !== '\n') {
            textToInsert = '\n' + textToInsert;
        }

        const newText = textarea.value.substring(0, startPos) + textToInsert + textarea.value.substring(endPos);
        setReactInputValue(textarea, newText);

        const replacementOffset = template.indexOf('{}');
        if (replacementOffset > -1 && selectedText.length === 0 && selectionOffset !== 0) {
           // Put cursor in the middle of the brackets if nothing was selected
            const isPrependedNewline = (isBlock && startPos > 0 && textarea.value.charAt(startPos - 1) !== '\n') ? 1 : 0;
            const newCursorPos = startPos + replacementOffset + isPrependedNewline;
            textarea.setSelectionRange(newCursorPos, newCursorPos);
        } else {
            // Put cursor at the end of the insertion
            const endPlace = startPos + textToInsert.length;
            textarea.setSelectionRange(endPlace, endPlace);
        }
    }

    // --- Core Injection Logic ---
    function buildToolbar(textarea, previewContainer) {
        const toolbar = document.createElement('div');
        toolbar.className = 'wb-md-toolbar';

        TOOLBAR_BTNS.forEach(btnConfig => {
            if (btnConfig.type === 'divider') {
                const divider = document.createElement('div');
                divider.className = 'wb-md-divider';
                toolbar.appendChild(divider);
                return;
            }

            const btn = document.createElement('button');
            btn.title = btnConfig.title;
            if (btnConfig.icon) {
                btn.innerHTML = btnConfig.icon;
                if (btnConfig.text) {
                    btn.innerHTML += `<span style="font-size:10px; margin-left: 2px">${btnConfig.text}</span>`;
                }
            } else {
                btn.innerText = btnConfig.label;
            }

            btn.addEventListener('mousedown', (e) => {
                // Prevent textarea from losing focus preventing cursor jump
                e.preventDefault(); 
            });

            btn.addEventListener('click', (e) => {
                e.preventDefault();
                e.stopPropagation();
                if (btnConfig.action) {
                    btnConfig.action(textarea);
                } else if (btnConfig.template) {
                    insertTextAtCursor(textarea, btnConfig.template, btnConfig.template.length > 2 ? 1 : 0, btnConfig.block, btnConfig.isList);
                }
            });

            toolbar.appendChild(btn);
        });

        // Add Toggle Preview Button
        let isPreviewing = false;
        const toggleBtn = document.createElement('button');
        toggleBtn.className = 'wb-md-btn-right';
        toggleBtn.innerText = 'Preview 概览';
        toggleBtn.title = '切换 Markdown 预览';
        
        toggleBtn.addEventListener('click', (e) => {
            e.preventDefault();
            e.stopPropagation();
            isPreviewing = !isPreviewing;
            
            if (isPreviewing) {
                // Switch to Preview
                toggleBtn.innerText = 'Edit 编辑';
                textarea.style.display = 'none';
                previewContainer.style.display = 'block';
                // Render Markdown
                if (window.marked && textarea.value.trim() !== '') {
                    // Safe basic marked parsing
                    previewContainer.innerHTML = window.marked.parse(textarea.value);
                } else {
                    previewContainer.innerHTML = '<span style="color:#aaa;">(No content 无文本)</span>';
                }
            } else {
                // Switch to Edit
                toggleBtn.innerText = 'Preview 概览';
                textarea.style.display = 'block';
                previewContainer.style.display = 'none';
                textarea.focus();
            }
        });
        
        toolbar.appendChild(toggleBtn);
        return toolbar;
    }

    function injectForTextarea(textarea) {
        if (textarea.dataset.wbmdInjected) return;
        textarea.dataset.wbmdInjected = "true";

        // Find a suitable wrapper or create one
        // Note: Weibo textarea usually sits inside a form/div container. 
        // We will insert our toolbar directly before the textarea within its parent.
        const parent = textarea.parentElement;
        
        const wrapper = document.createElement('div');
        wrapper.className = 'wb-md-container';
        
        // Weibo's layout might break if we mess up the DOM structure too much, 
        // so we wrap the textarea in our flexible container.
        parent.insertBefore(wrapper, textarea);
        
        const previewContainer = document.createElement('div');
        previewContainer.className = 'wb-md-preview markdown-body';
        
        const toolbar = buildToolbar(textarea, previewContainer);
        
        wrapper.appendChild(toolbar);
        wrapper.appendChild(textarea);
        wrapper.appendChild(previewContainer);
        
        // Also add keyboard shortcuts (Ctrl+B, Ctrl+I)
        textarea.addEventListener('keydown', function(event) {
            if (event.ctrlKey || event.metaKey) {
                switch (event.key.toLowerCase()) {
                    case 'b':
                        event.preventDefault();
                        insertTextAtCursor(textarea, '**{}**', 1);
                        break;
                    case 'i':
                        event.preventDefault();
                        insertTextAtCursor(textarea, '*{}*', 1);
                        break;
                }
            }
        });
    }

    function scanAndInject() {
        // Weibo's main publish textbox and modal textbox often have placeholders.
        const textareas = document.querySelectorAll('textarea[placeholder*="有什么新鲜事"], textarea[placeholder*="分享给大家"], .Form_input_3JT2Q');
        textareas.forEach(textarea => {
            if (textarea.offsetParent !== null) { // is visible
                injectForTextarea(textarea);
            }
        });
    }

    // Set up MutationObserver to detect dynamic UI loads
    const observer = new MutationObserver((mutations) => {
        let shouldScan = false;
        for (const mutation of mutations) {
            if (mutation.addedNodes.length) {
                shouldScan = true;
                break;
            }
        }
        if (shouldScan) {
            scanAndInject();
        }
    });

    observer.observe(document.body, { childList: true, subtree: true });
    
    // Initial scan
    setTimeout(scanAndInject, 1000);

})();