// ==UserScript== // @name Discourse 话题预览按钮 (Topic Preview Button) // @namespace https://github.com/stevessr/bug-v3 // @version 1.2.3 // @description 为 Discourse 话题列表添加预览按钮与快捷回复功能 (Add preview and quick-reply to Discourse topic lists) // @author stevessr // @match https://linux.do/* // @match https://meta.discourse.org/* // @match https://*.discourse.org/* // @match http://localhost:5173/* // @exclude https://linux.do/a/* // @match https://idcflare.com/* // @grant none // @license MIT // @homepageURL https://github.com/stevessr/bug-v3 // @supportURL https://github.com/stevessr/bug-v3/issues // @run-at document-end // @downloadURL https://update.greasyfork.icu/scripts/554978/Discourse%20%E8%AF%9D%E9%A2%98%E9%A2%84%E8%A7%88%E6%8C%89%E9%92%AE%20%28Topic%20Preview%20Button%29.user.js // @updateURL https://update.greasyfork.icu/scripts/554978/Discourse%20%E8%AF%9D%E9%A2%98%E9%A2%84%E8%A7%88%E6%8C%89%E9%92%AE%20%28Topic%20Preview%20Button%29.meta.js // ==/UserScript== (function () { 'use strict' // ===== Utility Functions ===== // createEl helper: safely create elements function createEl(tag, opts) { const el = document.createElement(tag) if (!opts) return el if (opts.width) el.style.width = opts.width if (opts.height) el.style.height = opts.height if (opts.className) el.className = opts.className if (opts.text) el.textContent = opts.text if (opts.placeholder && 'placeholder' in el) el.placeholder = opts.placeholder if (opts.type && 'type' in el) el.type = opts.type if (opts.value !== undefined && 'value' in el) el.value = opts.value if (opts.style) el.style.cssText = opts.style if (opts.src && 'src' in el) el.src = opts.src if (opts.attrs) for (const k in opts.attrs) el.setAttribute(k, opts.attrs[k]) if (opts.dataset) for (const k in opts.dataset) el.dataset[k] = opts.dataset[k] if (opts.innerHTML) el.innerHTML = opts.innerHTML if (opts.title) el.title = opts.title if (opts.alt && 'alt' in el) el.alt = opts.alt if (opts.id) el.id = opts.id if (opts.on) { for (const [evt, handler] of Object.entries(opts.on)) { el.addEventListener(evt, handler) } } return el } // ensureStyleInjected helper function ensureStyleInjected(id, css) { if (document.getElementById(id)) return const style = document.createElement('style') style.id = id style.textContent = css document.documentElement.appendChild(style) } // ===== Preview Styles ===== const RAW_PREVIEW_STYLES = ` .raw-preview-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 2147483647; } .raw-preview-modal { width: 80%; height: 80%; background: var(--color-bg, #fff); border-radius: 8px; box-shadow: 0 8px 32px rgba(0,0,0,0.5); display: flex; flex-direction: column; overflow: hidden; } .raw-preview-header { display: flex; align-items: center; gap: 8px; padding: 8px 12px; background: var(--secondary); border-bottom: 1px solid rgba(0,0,0,0.06); } .raw-preview-title { flex: 1; font-weight: 600; } .raw-preview-ctrls button { margin-left: 6px; } .raw-preview-iframe { border: none; width: 100%; height: 100%; flex: 1 1 auto; background: var(--secondary); color: var(--title-color); } .raw-preview-small-btn { display: inline-flex; align-items: center; justify-content: center; padding: 2px 8px; margin-left: 6px; font-size: 12px; border-radius: 4px; border: 1px solid rgba(0,0,0,0.08); background: var(--d-button-primary-bg-color); cursor: pointer; color: var(--d-button-primary-text-color); } .raw-preview-small-btn.md { background: linear-gradient(90deg,#fffbe6,#f0f7ff); border-color: rgba(3,102,214,0.12); } .raw-preview-small-btn.json { background: var(--d-button-default-bg-color); border-color: rgba(0,128,96,0.12); color: var(--d-button-default-text-color); } ` ensureStyleInjected('raw-preview-styles', RAW_PREVIEW_STYLES) // ===== Quick Reply Styles (overlay-level) ===== const QUICK_REPLY_STYLES = ` .qr-toggle-btn { display: inline-flex; align-items: center; justify-content: center; padding: 2px 8px; margin-left: 6px; font-size: 12px; border-radius: 4px; border: 1px solid rgba(0,0,0,0.08); background: linear-gradient(90deg,#fffbe6,#f0f7ff); cursor: pointer; } .quick-reply-panel { display: none; padding: 10px 12px; border-top: 1px solid rgba(0,0,0,0.06); background: var(--color-bg, #fff); } .quick-reply-list { max-height: 150px; overflow-y: auto; margin-bottom: 8px; } .quick-reply-item { display: flex; align-items: center; justify-content: space-between; padding: 6px 8px; border-bottom: 1px solid #eee; border-radius: 4px; } .quick-reply-item:hover { background: #f7f7f7; } .quick-reply-item .text { flex: 1; cursor: pointer; } .quick-reply-item .del { color: #d33; font-weight: 700; margin-left: 10px; cursor: pointer; } .quick-reply-input { width: 100%; box-sizing: border-box; padding: 6px 8px; border: 1px solid #ccc; border-radius: 4px; } .quick-reply-actions { display: flex; gap: 8px; margin-top: 8px; } .quick-reply-actions button { padding: 5px 10px; border: 1px solid #ccc; border-radius: 5px; cursor: pointer; background: var(--d-button-default-bg-color, #f7f7f7); } .quick-reply-status { font-size: 12px; color: #666; margin-left: auto; } ` ensureStyleInjected('quick-reply-styles', QUICK_REPLY_STYLES) // ===== Preview Logic ===== let overlay = null let iframeEl = null let currentTopicId = null let currentPage = 1 let renderMode = 'iframe' let currentTopicSlug = null let jsonScrollAttached = false let jsonIsLoading = false let jsonReachedEnd = false // ===== Quick Reply State & Helpers ===== const QUICK_REPLY_KEY = 'preview_quick_replies' const DEFAULT_REPLIES = [ '我再也吃不下了 :distorted_face:', '感谢分享!', '学到了,很有用。', '这个帖子太棒了!', '顶一下!' ] function getReplies() { try { const raw = localStorage.getItem(QUICK_REPLY_KEY) if (!raw) return DEFAULT_REPLIES.slice() const arr = JSON.parse(raw) if (Array.isArray(arr) && arr.every((x) => typeof x === 'string')) return arr return DEFAULT_REPLIES.slice() } catch { return DEFAULT_REPLIES.slice() } } function saveReplies(list) { try { if (Array.isArray(list)) localStorage.setItem(QUICK_REPLY_KEY, JSON.stringify(list)) } catch { // ignore } } function getCsrfToken() { const meta = document.querySelector('meta[name="csrf-token"]') return meta?.getAttribute('content') || '' } async function sendReply(topicId, raw, onStatus) { const token = getCsrfToken() if (!token) { onStatus && onStatus('未登录或缺少权限,无法发送。') return { ok: false, status: 0 } } const data = new URLSearchParams() data.append('raw', raw) data.append('unlist_topic', 'false') data.append('topic_id', String(topicId)) data.append('is_warning', 'false') data.append('whisper', 'false') data.append('archetype', 'regular') data.append('typing_duration_msecs', '1500') data.append('composer_open_duration_msecs', '3000') data.append('draft_key', `topic_${topicId}`) data.append('nested_post', 'true') try { onStatus && onStatus('发送中...') const res = await fetch('/posts', { method: 'POST', headers: { 'accept': 'application/json, text/javascript, */*; q=0.01', 'content-type': 'application/x-www-form-urlencoded; charset=UTF-8', 'x-csrf-token': token, 'x-requested-with': 'XMLHttpRequest' }, body: data.toString(), credentials: 'include' }) if (!res.ok) { onStatus && onStatus(`发送失败 (${res.status})`) return { ok: false, status: res.status } } onStatus && onStatus('发送成功!') return { ok: true, status: res.status } } catch (e) { onStatus && onStatus('网络错误,发送失败') return { ok: false, status: -1, error: e } } } function rawUrl(topicId, page) { return new URL(`/raw/${topicId}?page=${page}`, window.location.origin).toString() } function jsonUrl(topicId, page, slug) { const usedSlug = slug || currentTopicSlug || 'topic' return new URL(`/t/${usedSlug}/${topicId}.json?page=${page}`, window.location.origin).toString() } function createOverlay(topicId, startPage, mode, slug) { if (overlay) return currentTopicId = topicId currentPage = startPage || 1 renderMode = mode || 'iframe' currentTopicSlug = slug || null overlay = createEl('div', { className: 'raw-preview-overlay' }) const modal = createEl('div', { className: 'raw-preview-modal' }) const header = createEl('div', { className: 'raw-preview-header' }) const title = createEl('div', { className: 'raw-preview-title', text: `话题预览 ${topicId}` }) const ctrls = createEl('div', { className: 'raw-preview-ctrls' }) const modeLabel = createEl('span', { text: mode === 'markdown' ? '模式:Markdown' : '模式:原始' }) const prevBtn = createEl('button', { className: 'raw-preview-small-btn', text: '◀ 上一页' }) const nextBtn = createEl('button', { className: 'raw-preview-small-btn', text: '下一页 ▶' }) const closeBtn = createEl('button', { className: 'raw-preview-small-btn', text: '关闭 ✖' }) // Quick Reply toggle button const qrToggleBtn = createEl('button', { className: 'qr-toggle-btn', text: '快捷回复' }) const qrStatus = createEl('span', { className: 'quick-reply-status' }) prevBtn.addEventListener('click', () => { if (!currentTopicId) return if (currentPage > 1) { currentPage -= 1 updateIframeSrc() } }) nextBtn.addEventListener('click', () => { if (!currentTopicId) return currentPage += 1 updateIframeSrc() }) closeBtn.addEventListener('click', () => { removeOverlay() }) ctrls.appendChild(modeLabel) ctrls.appendChild(prevBtn) ctrls.appendChild(nextBtn) ctrls.appendChild(qrToggleBtn) ctrls.appendChild(qrStatus) ctrls.appendChild(closeBtn) header.appendChild(title) header.appendChild(ctrls) // Quick Reply Panel const quickPanel = createQuickReplyPanel(() => currentTopicId, (msg) => { qrStatus.textContent = msg || '' if (msg === '发送成功!') { setTimeout(() => { qrStatus.textContent = '' quickPanel.style.display = 'none' }, 800) } }) iframeEl = createEl('iframe', { className: 'raw-preview-iframe', attrs: { sandbox: 'allow-same-origin allow-scripts' } }) modal.appendChild(header) modal.appendChild(quickPanel) modal.appendChild(iframeEl) overlay.appendChild(modal) overlay.addEventListener('click', (e) => { if (e.target === overlay) removeOverlay() }) window.addEventListener('keydown', handleKeydown) document.body.appendChild(overlay) // toggle panel qrToggleBtn.addEventListener('click', (e) => { e.preventDefault() e.stopPropagation() if (quickPanel.style.display === 'none' || !quickPanel.style.display) { quickPanel.refresh && quickPanel.refresh() quickPanel.style.display = 'block' } else { quickPanel.style.display = 'none' } }) if (renderMode === 'iframe') { iframeEl.src = rawUrl(topicId, currentPage) } else { if (renderMode === 'markdown') { fetchAndRenderMarkdown(topicId, currentPage) } else if (renderMode === 'json') { fetchAndRenderJson(topicId, currentPage, currentTopicSlug).then(() => { attachJsonAutoPager() }) } } } // Build Quick Reply Panel function createQuickReplyPanel(getTopicId, setStatus) { const panel = createEl('div', { className: 'quick-reply-panel' }) const listDiv = createEl('div', { className: 'quick-reply-list' }) const input = createEl('input', { className: 'quick-reply-input', placeholder: '输入自定义回复...' }) const actions = createEl('div', { className: 'quick-reply-actions' }) const addBtn = createEl('button', { text: '添加到预设' }) const sendBtn = createEl('button', { text: '发送' }) function populate() { listDiv.innerHTML = '' const replies = getReplies() replies.forEach((text, idx) => { const item = createEl('div', { className: 'quick-reply-item' }) const span = createEl('span', { className: 'text', text }) const del = createEl('span', { className: 'del', text: '×', title: '删除此条预设' }) span.addEventListener('click', async () => { const topicId = getTopicId() if (!topicId) return await sendReply(topicId, text, setStatus) }) del.addEventListener('click', (e) => { e.stopPropagation() const list = getReplies() list.splice(idx, 1) saveReplies(list) populate() }) item.appendChild(span) item.appendChild(del) listDiv.appendChild(item) }) } addBtn.addEventListener('click', () => { const v = (input.value || '').trim() if (!v) return const list = getReplies() if (!list.includes(v)) { list.push(v) saveReplies(list) populate() input.value = '' } else { setStatus && setStatus('该回复已存在') setTimeout(() => setStatus && setStatus(''), 1200) } }) sendBtn.addEventListener('click', async () => { const v = (input.value || '').trim() if (!v) return const topicId = getTopicId() if (!topicId) return await sendReply(topicId, v, setStatus) input.value = '' }) actions.appendChild(addBtn) actions.appendChild(sendBtn) panel.appendChild(listDiv) panel.appendChild(input) panel.appendChild(actions) panel.refresh = populate return panel } async function updateIframeSrc() { if (!iframeEl || !currentTopicId) return if (renderMode === 'iframe') { iframeEl.src = rawUrl(currentTopicId, currentPage) } else { if (renderMode === 'markdown') { fetchAndRenderMarkdown(currentTopicId, currentPage) } else if (renderMode === 'json') { try { const doc = getIframeDoc() const targetId = `json-page-${currentPage}` if (doc.getElementById(targetId)) { scrollToJsonPage(currentPage) return } const nodes = Array.from(doc.querySelectorAll('[id^="json-page-"]')) let maxLoaded = 0 for (const n of nodes) { const m = n.id.match(/json-page-(\d+)/) if (m) maxLoaded = Math.max(maxLoaded, parseInt(m[1], 10)) } let start = Math.max(1, maxLoaded + 1) for (let p = start; p <= currentPage; p++) { const added = await fetchAndRenderJson(currentTopicId, p, currentTopicSlug) if (added === 0) break } scrollToJsonPage(currentPage) } catch { fetchAndRenderJson(currentTopicId, currentPage, currentTopicSlug) } } } } function removeOverlay() { if (!overlay) return window.removeEventListener('keydown', handleKeydown) overlay.remove() overlay = null iframeEl = null currentTopicId = null currentPage = 1 jsonScrollAttached = false jsonIsLoading = false jsonReachedEnd = false } function handleKeydown(e) { if (!overlay) return if (e.key === 'ArrowLeft') { if (currentPage > 1) { currentPage -= 1 updateIframeSrc() } } if (e.key === 'ArrowRight') { currentPage += 1 updateIframeSrc() } if (e.key === 'Escape') removeOverlay() } function createTriggerButtonFor(mode) { const text = mode === 'markdown' ? '预览 (MD)' : '预览' const btn = createEl('button', { className: `raw-preview-small-btn ${mode === 'markdown' ? 'md' : mode === 'json' ? 'json' : 'iframe'}`, text: mode === 'json' ? '预览 (JSON)' : text }) btn.dataset.previewMode = mode return btn } // ===== Markdown rendering ===== function escapeHtml(str) { return str.replace(/&/g, '&').replace(//g, '>') } function escapeHtmlAttr(str) { // For HTML attributes, we need to escape quotes as well return str .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, ''') } function simpleMarkdownToHtml(md) { const lines = md.replace(/\r\n/g, '\n').split('\n') let inCode = false const out = [] let listType = null for (let i = 0; i < lines.length; i++) { let line = lines[i] const fenceMatch = line.match(/^```\s*(\S*)/) if (fenceMatch) { if (!inCode) { inCode = true out.push('
')
} else {
inCode = false
out.push('')
}
continue
}
if (inCode) {
out.push(escapeHtml(line) + '\n')
continue
}
const h = line.match(/^(#{1,6})\s+(.*)/)
if (h) {
out.push(`${inlineFormat(line)}
`) } if (listType === 'ul') out.push('') if (listType === 'ol') out.push('') return out.join('\n') } function inlineFormat(text) { let t = escapeHtml(text) t = t.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_m, altRaw, urlRaw) => { const altParts = (altRaw || '').split('|') const alt = escapeHtmlAttr(altParts[0] || '') let widthAttr = '' let heightAttr = '' if (altParts[1]) { const dim = altParts[1].match(/(\d+)x(\d+)/) if (dim) { // Use escapeHtmlAttr for attribute values to prevent XSS const width = escapeHtmlAttr(dim[1]) const height = escapeHtmlAttr(dim[2]) widthAttr = ` width="${width}"` heightAttr = ` height="${height}"` } } const url = String(urlRaw || '') if (url.startsWith('upload://')) { const filename = url.replace(/^upload:\/\//, '') // Escape the entire URL to prevent XSS const src = escapeHtmlAttr(`${window.location.origin}/uploads/short-url/${filename}`) return `$1')
t = t.replace(/~~([\s\S]+?)~~/g, '