// ==UserScript== // @name Discourse 话题预览按钮 (Topic Preview Button) // @namespace https://github.com/stevessr/bug-v3 // @version 1.2.1 // @description 为 Discourse 话题列表添加预览按钮功能 (Add preview button functionality 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 none // ==/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) // ===== 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 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: '关闭 ✖' }) 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(closeBtn) header.appendChild(title) header.appendChild(ctrls) iframeEl = createEl('iframe', { className: 'raw-preview-iframe', attrs: { sandbox: 'allow-same-origin allow-scripts' } }) modal.appendChild(header) modal.appendChild(iframeEl) overlay.appendChild(modal) overlay.addEventListener('click', (e) => { if (e.target === overlay) removeOverlay() }) window.addEventListener('keydown', handleKeydown) document.body.appendChild(overlay) 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() }) } } } 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, '