為新浪微博網頁端發微博添加完善的 Markdown 快捷工具欄及實時預覽功能。
// ==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: '' } ]; 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); })();