// ==UserScript== // @name Steam Guide: Live Side Preview // @namespace https://github.com/ceeprus/userscript // @version 1.04 // @description Docks a live rendered preview to the right of the guide section editor, updating as you type. Reuses Steam's own bb_* styles and never touches the Edit/Preview/Changes tabs or the form fields. // @author ceeprus // @match https://steamcommunity.com/sharedfiles/editguidesubsection/* // @match https://steamcommunity.com/sharedfiles/editguide/* // @icon https://steamcommunity.com/favicon.ico // @grant GM_setValue // @grant GM_getValue // @grant GM_addStyle // @run-at document-idle // @homepageURL https://github.com/ceeprus/userscript // @supportURL https://github.com/ceeprus/userscript/issues // @updateURL https://raw.githubusercontent.com/ceeprus/userscript/main/steam/guide-preview.user.js // @downloadURL https://raw.githubusercontent.com/ceeprus/userscript/main/steam/guide-preview.user.js // ==/UserScript== (function () { 'use strict'; // ---- GM shims (work across managers) ----------------------------------- var store = { get: function (k, d) { try { return GM_getValue(k, d); } catch (e) { var v = localStorage.getItem('gp_' + k); return v === null ? d : JSON.parse(v); } }, set: function (k, v) { try { GM_setValue(k, v); } catch (e) { localStorage.setItem('gp_' + k, JSON.stringify(v)); } } }; function addStyle(css) { try { GM_addStyle(css); } catch (e) { var s = document.createElement('style'); s.textContent = css; document.head.appendChild(s); } } // ======================================================================== // BBCode -> HTML renderer (models Steam's guide output; validated against // a real guide's source/preview pair). Pure, no DOM deps. // ======================================================================== var BB = (function () { function esc(s) { return s.replace(/&/g, '&').replace(//g, '>'); } var RAW_TAGS = { code: 1, noparse: 1 }; var KNOWN = { b:1,i:1,u:1,s:1,strike:1,spoiler:1,url:1,img:1,previewimg:1,h1:1,h2:1,h3:1,quote:1,code:1,noparse:1,hr:1,list:1,olist:1,table:1,tr:1,th:1,td:1 }; function tokenize(src) { var toks = [], re = /\[(\/?)([a-z0-9]+)(=[^\]]*)?\]|\[\*\]/gi, last = 0, m; while ((m = re.exec(src)) !== null) { if (m.index > last) toks.push({ t: 'text', v: src.slice(last, m.index) }); if (m[0] === '[*]') toks.push({ t: 'star' }); else toks.push({ t: m[1] ? 'close' : 'open', name: m[2].toLowerCase(), arg: m[3] ? m[3].slice(1) : null, rawTag: m[0] }); last = re.lastIndex; } if (last < src.length) toks.push({ t: 'text', v: src.slice(last) }); return toks; } function textToHtml(v) { return esc(v).replace(/\r\n?|\n/g, '
'); } function autoLink(htmlText) { return htmlText.replace(/(https?:\/\/[^\s<]+)|(\bwww\.[^\s<]+)|(\b[a-z0-9-]+\.(?:com|net|org|io|gg|tv)\/[^\s<]+)/gi, function (url) { var href = /^https?:\/\//i.test(url) ? url : 'http://' + url; return '' + url + ''; }); } function resolvePreviewImg(arg, imgResolver) { var parts = arg.split(';'); var id = (parts[0] || '').trim(); var flags = (parts[1] || '').toLowerCase(); var float = /floatleft/.test(flags) ? 'left' : /floatright/.test(flags) ? 'right' : ''; var url = imgResolver ? imgResolver(id) : null; var style = 'max-width:220px;max-height:220px;border-radius:2px;margin:4px;'; if (float) style += 'float:' + float + ';'; if (url) return ''; return '📷 image #' + esc(id) + ''; } function stripTags(s) { return s.replace(/<[^>]*>/g, ''); } function wrap(name, arg, inner) { switch (name) { case 'b': return '' + inner + ''; case 'i': return '' + inner + ''; case 'u': return '' + inner + ''; case 's': case 'strike': return '' + inner + ''; case 'spoiler': return '' + inner + ''; case 'h1': return '
' + inner + '
'; case 'h2': return '
' + inner + '
'; case 'h3': return '
' + inner + '
'; case 'quote': { var who = arg ? '
' + esc(arg) + ' posted:
' : ''; return '
' + who + inner + '
'; } case 'url': { var href = arg || stripTags(inner); if (!/^[a-z]+:\/\//i.test(href) && !/^\//.test(href)) href = 'http://' + href; return '' + inner + ''; } case 'img': return ''; default: return inner; } } function parse(toks, i, opts) { var out = '', guard = 0; for (; i.p < toks.length;) { if (++guard > toks.length * 4 + 100) break; var tk = toks[i.p]; if (tk.t === 'text') { out += autoLink(textToHtml(tk.v)); i.p++; continue; } if (tk.t === 'star') { i.p++; continue; } if (tk.t === 'close') return out; // bubble up var name = tk.name; if (!KNOWN[name]) { out += esc(tk.rawTag); i.p++; continue; } i.p++; if (RAW_TAGS[name]) { out += pullRawBody(toks, i, name); continue; } if (name === 'hr') { if (toks[i.p] && toks[i.p].t === 'close' && toks[i.p].name === 'hr') i.p++; out += '
'; continue; } if (name === 'previewimg') { consumeTo(toks, i, 'previewimg'); out += resolvePreviewImg(tk.arg || '', opts.imgResolver); continue; } if (name === 'list' || name === 'olist') { out += parseList(toks, i, name, opts); continue; } if (name === 'table') { out += parseTable(toks, i, opts); continue; } var inner = parseUntilClose(toks, i, name, opts); out += wrap(name, tk.arg, inner); } return out; } function parseUntilClose(toks, i, name, opts) { var sub = parse(toks, i, opts); if (toks[i.p] && toks[i.p].t === 'close' && toks[i.p].name === name) i.p++; return sub; } function consumeTo(toks, i, name) { // skip anything until this close (previewimg body) var g = 0; while (i.p < toks.length && !(toks[i.p].t === 'close' && toks[i.p].name === name)) { if (++g > toks.length) break; i.p++; } if (i.p < toks.length) i.p++; } function pullRawBody(toks, i, name) { var buf = '', g = 0; while (i.p < toks.length && !(toks[i.p].t === 'close' && toks[i.p].name === name)) { if (++g > toks.length) break; var t2 = toks[i.p]; buf += t2.t === 'text' ? t2.v : (t2.t === 'star' ? '[*]' : t2.rawTag); i.p++; } if (i.p < toks.length) i.p++; var html = esc(buf).replace(/\r\n?|\n/g, '
'); return name === 'code' ? '
' + html + '
' : html; } function parseList(toks, i, name, opts) { var tag = name === 'olist' ? 'ol' : 'ul', cls = name === 'olist' ? 'bb_ol' : 'bb_ul'; var items = [], cur = null, pre = '', g = 0; function flush() { if (cur !== null) items.push('
  • ' + cur + '
  • '); } while (i.p < toks.length) { if (++g > toks.length * 4 + 100) break; var tk = toks[i.p]; if (tk.t === 'close' && (tk.name === name || tk.name === 'list' || tk.name === 'olist')) { i.p++; break; } if (tk.t === 'star') { flush(); cur = ''; i.p++; continue; } var chunk; if (tk.t === 'text') { chunk = autoLink(textToHtml(tk.v)); i.p++; } else if (tk.t === 'open') { if (!KNOWN[tk.name]) { chunk = esc(tk.rawTag); i.p++; } else { var nm = tk.name; i.p++; if (nm === 'list' || nm === 'olist') chunk = parseList(toks, i, nm, opts); else if (nm === 'table') chunk = parseTable(toks, i, opts); else if (RAW_TAGS[nm]) chunk = pullRawBody(toks, i, nm); else if (nm === 'previewimg') { consumeTo(toks, i, 'previewimg'); chunk = resolvePreviewImg(tk.arg || '', opts.imgResolver); } else if (nm === 'hr') { if (toks[i.p] && toks[i.p].t === 'close' && toks[i.p].name === 'hr') i.p++; chunk = '
    '; } else chunk = wrap(nm, tk.arg, parseUntilClose(toks, i, nm, opts)); } } else { i.p++; continue; } if (cur === null) pre += chunk; else cur += chunk; } flush(); return (pre ? pre : '') + '<' + tag + ' class="' + cls + '">' + items.join('') + ''; } function parseTable(toks, i, opts) { var rows = '', g = 0; while (i.p < toks.length) { if (++g > toks.length * 4 + 100) break; var tk = toks[i.p]; if (tk.t === 'close' && tk.name === 'table') { i.p++; break; } if (tk.t === 'open' && tk.name === 'tr') { i.p++; var cells = '', g2 = 0; while (i.p < toks.length) { if (++g2 > toks.length * 4 + 100) break; var c = toks[i.p]; if (c.t === 'close' && c.name === 'tr') { i.p++; break; } if (c.t === 'open' && (c.name === 'th' || c.name === 'td')) { var cell = c.name; i.p++; cells += '<' + cell + '>' + parseUntilClose(toks, i, cell, opts).replace(/^(
    )+|(
    )+$/g, '') + ''; } else i.p++; } rows += '' + cells + ''; } else i.p++; } return '' + rows + '
    '; } return { render: function (bbcode, opts) { var toks = tokenize(bbcode || ''), i = { p: 0 }, out = '', guard = 0; // Loop so a stray/unmatched close tag at top level renders as literal // text instead of truncating everything after it. while (i.p < toks.length && ++guard < toks.length + 10) { var before = i.p; out += parse(toks, i, opts || {}); if (i.p < toks.length && toks[i.p].t === 'close') { out += esc(toks[i.p].rawTag); i.p++; } if (i.p === before) break; } return out; } }; })(); // ======================================================================== // UI // ======================================================================== var K_W = 'panelWidth', K_COLLAPSED = 'collapsed', K_REVEAL = 'reveal'; var MIN_W = 280, MAX_W = 900, DEFAULT_W = 440; function q(id) { return document.getElementById(id); } function buildImgResolver() { // Map preview-image element ids -> usable CDN url, read live from the DOM. var panel = q('PreviewImages'); var map = {}; if (panel) { panel.querySelectorAll('img[id]').forEach(function (img) { var src = img.currentSrc || img.src || img.getAttribute('data-src') || ''; if (src && src.indexOf('data:') !== 0) map[img.id] = src; }); } return function (id) { return map[id] || null; }; } var panel, body, renderTarget, titleTarget, charCount, collapsedTab, lastValue = null, lastTitle = null; function css() { addStyle([ '.gp-panel{position:fixed;top:0;right:0;height:100vh;z-index:99998;display:flex;flex-direction:column;', ' background:#1b2838;border-left:1px solid #000;box-shadow:-6px 0 22px rgba(0,0,0,.55);', ' font-family:"Motiva Sans",Arial,Helvetica,sans-serif;color:#c6d4df;text-align:left;transition:transform .15s ease;}', '.gp-panel.gp-hidden{display:none;}', '.gp-head{flex:0 0 auto;display:flex;align-items:center;gap:8px;padding:8px 10px;', ' background:linear-gradient(90deg,#2a475e,#1b2838);border-bottom:1px solid #000;}', '.gp-title{font-size:12px;font-weight:700;letter-spacing:.03em;color:#67c1f5;text-transform:uppercase;white-space:nowrap;}', '.gp-count{margin-left:auto;font-size:11px;color:#8f98a0;white-space:nowrap;}', '.gp-count.gp-over{color:#e8734f;font-weight:700;}', '.gp-btn{cursor:pointer;font-size:11px;line-height:1;padding:4px 7px;border-radius:2px;border:1px solid #000;', ' background:#3a4b5c;color:#c6d4df;user-select:none;white-space:nowrap;}', '.gp-btn:hover{background:#4b6178;color:#fff;}', '.gp-btn.gp-on{background:#5c7e10;color:#d2e885;}', '.gp-body{flex:1 1 auto;overflow:auto;padding:16px 18px;}', '.gp-body .subSectionTitle{font-size:22px;color:#e1e7ea;font-weight:300;margin:0 0 10px;padding-bottom:8px;border-bottom:1px solid #32404e;}', '.gp-grip{position:absolute;top:0;left:-3px;width:7px;height:100%;cursor:col-resize;z-index:2;}', '.gp-grip:hover{background:rgba(103,193,245,.25);}', '.gp-tab{position:fixed;top:120px;right:0;z-index:99998;background:#2a475e;color:#67c1f5;border:1px solid #000;border-right:none;', ' border-radius:3px 0 0 3px;padding:10px 6px;cursor:pointer;writing-mode:vertical-rl;font-size:11px;font-weight:700;', ' letter-spacing:.08em;text-transform:uppercase;box-shadow:-3px 0 10px rgba(0,0,0,.4);user-select:none;}', '.gp-tab:hover{background:#356189;color:#fff;}', '.gp-img-chip{display:inline-block;padding:2px 8px;margin:4px;border:1px dashed #4b5b6b;border-radius:3px;color:#8f98a0;font-size:11px;}', // reveal-spoilers override: Steam hides the inner span via visibility, so flip that (not just colors) '.gp-panel.gp-reveal .bb_spoiler{color:#c6d4df !important;background-color:#39566e !important;}', '.gp-panel.gp-reveal .bb_spoiler>span{visibility:visible !important;}', '.gp-empty{color:#66707b;font-style:italic;font-size:13px;}' ].join('\n')); } function build() { css(); panel = document.createElement('div'); panel.className = 'gp-panel'; if (store.get(K_REVEAL, false)) panel.classList.add('gp-reveal'); var grip = document.createElement('div'); grip.className = 'gp-grip'; var head = document.createElement('div'); head.className = 'gp-head'; var t = document.createElement('span'); t.className = 'gp-title'; t.textContent = 'Live Preview'; charCount = document.createElement('span'); charCount.className = 'gp-count'; var revealBtn = document.createElement('span'); revealBtn.className = 'gp-btn'; function revealLabel(on) { revealBtn.textContent = on ? 'spoiler \u25BE' : 'spoiler \u25B8'; } // ▾ open / ▸ closed var revealOn = store.get(K_REVEAL, false); revealLabel(revealOn); if (revealOn) revealBtn.classList.add('gp-on'); revealBtn.title = 'Reveal spoiler contents in this preview'; revealBtn.onclick = function () { var on = panel.classList.toggle('gp-reveal'); revealBtn.classList.toggle('gp-on', on); revealLabel(on); store.set(K_REVEAL, on); }; head.appendChild(t); head.appendChild(charCount); head.appendChild(revealBtn); body = document.createElement('div'); body.className = 'gp-body'; // Steam guide wrapper so bb_* classes inherit the real guide styling. var wrap = document.createElement('div'); wrap.className = 'guide'; var subs = document.createElement('div'); subs.className = 'subSections'; titleTarget = document.createElement('div'); titleTarget.className = 'subSectionTitle'; renderTarget = document.createElement('div'); renderTarget.className = 'subSectionDesc gp-render'; subs.appendChild(titleTarget); subs.appendChild(renderTarget); wrap.appendChild(subs); body.appendChild(wrap); panel.appendChild(grip); panel.appendChild(head); panel.appendChild(body); document.body.appendChild(panel); collapsedTab = document.createElement('div'); collapsedTab.className = 'gp-tab gp-hidden'; collapsedTab.textContent = 'Preview'; collapsedTab.title = 'Show/hide live preview (Alt+P)'; collapsedTab.onclick = function () { setCollapsed(!panel.classList.contains('gp-hidden')); }; document.body.appendChild(collapsedTab); setWidth(store.get(K_W, DEFAULT_W)); wireResize(grip); if (store.get(K_COLLAPSED, false)) setCollapsed(true, true); positionTab(); } function setWidth(w) { w = Math.max(MIN_W, Math.min(MAX_W, w | 0)); panel.style.width = w + 'px'; return w; } function wireResize(grip) { var dragging = false; grip.addEventListener('mousedown', function (e) { dragging = true; e.preventDefault(); document.body.style.userSelect = 'none'; }); window.addEventListener('mousemove', function (e) { if (dragging) { setWidth(window.innerWidth - e.clientX); positionTab(); } }); window.addEventListener('mouseup', function () { if (!dragging) return; dragging = false; document.body.style.userSelect = ''; store.set(K_W, parseInt(panel.style.width, 10) || DEFAULT_W); }); } var editorVisible = true; // gets corrected by observer // Toggle handle sits at the panel's left edge when open, at the screen edge when // collapsed, so one control both hides and shows the panel. function positionTab() { if (!collapsedTab) return; var collapsed = panel.classList.contains('gp-hidden'); collapsedTab.style.right = collapsed ? '0px' : (panel.style.width || (DEFAULT_W + 'px')); } function setCollapsed(on, skipSave) { panel.classList.toggle('gp-hidden', on); positionTab(); if (!skipSave) store.set(K_COLLAPSED, on); } // ---- render ------------------------------------------------------------ var desc, titleInput; function doRender() { if (!desc) return; var val = desc.value; var tv = titleInput ? titleInput.value : ''; if (val === lastValue && tv === lastTitle) return; lastValue = val; lastTitle = tv; titleTarget.textContent = tv || ''; titleTarget.style.display = tv ? '' : 'none'; if (!val.trim()) { renderTarget.innerHTML = '
    Nothing to preview yet \u2014 start typing in the editor.
    '; } else { renderTarget.innerHTML = BB.render(val, { imgResolver: buildImgResolver() }); } var n = val.length; charCount.textContent = n + ' / 8000'; charCount.classList.toggle('gp-over', n > 8000); } var rTimer = null; function scheduleRender() { clearTimeout(rTimer); rTimer = setTimeout(doRender, 120); } // ---- editor-tab visibility -------------------------------------------- function editorPaneVisible() { var ep = q('EditorPane'); if (!ep) return true; return getComputedStyle(ep).display !== 'none'; } function syncVisibility() { editorVisible = editorPaneVisible(); if (!editorVisible) { // On Preview/Changes tabs: hide our overlay + handle (Steam shows its own). panel.classList.add('gp-hidden'); collapsedTab.classList.add('gp-hidden'); } else { // Back on the Edit tab: show the toggle handle, restore the chosen state. collapsedTab.classList.remove('gp-hidden'); setCollapsed(store.get(K_COLLAPSED, false), true); scheduleRender(); } } function watchTabs() { ['EditorPane', 'PreviewPane', 'ChangesPane'].forEach(function (id) { var el = q(id); if (el) new MutationObserver(syncVisibility).observe(el, { attributes: true, attributeFilter: ['style', 'class'] }); }); ['EditorTab', 'PreviewTab', 'ChangesTab'].forEach(function (id) { var el = q(id); if (el) el.addEventListener('click', function () { setTimeout(syncVisibility, 30); }); }); } // ---- init -------------------------------------------------------------- function init() { if (panel) return true; // already built desc = q('description'); titleInput = q('title'); if (!desc || !q('EditorPane')) return false; // not the section editor build(); ['input', 'keyup', 'paste', 'cut'].forEach(function (ev) { desc.addEventListener(ev, scheduleRender); }); if (titleInput) ['input', 'keyup'].forEach(function (ev) { titleInput.addEventListener(ev, scheduleRender); }); // Toolbar buttons write into the textarea without firing 'input' in some // managers/browsers; a light poll guarantees those inserts show up too. setInterval(function () { if (desc.value !== lastValue) scheduleRender(); }, 600); watchTabs(); document.addEventListener('keydown', function (e) { if (e.altKey && !e.ctrlKey && !e.metaKey && (e.key === 'p' || e.key === 'P')) { if (!editorVisible) return; e.preventDefault(); setCollapsed(!panel.classList.contains('gp-hidden')); } }); syncVisibility(); doRender(); return true; } // The editor is server-rendered, but be defensive about timing. if (!init()) { var tries = 0; var iv = setInterval(function () { if (init() || ++tries > 40) clearInterval(iv); }, 250); } })();