Skip to content

Instantly share code, notes, and snippets.

@mfaizsyahmi
Last active April 6, 2025 12:28
Show Gist options
  • Save mfaizsyahmi/0d1554a79e7726d40c1eb4d9b731bd5f to your computer and use it in GitHub Desktop.
Save mfaizsyahmi/0d1554a79e7726d40c1eb4d9b731bd5f to your computer and use it in GitHub Desktop.
TWHL+ – Enhancements for twhl.info

v0.2 – Editor Update

0.2.0

  • Updated for TWHL's April update
  • Retired enhancements implemented natively on TWHL on said update
  • Added enhancements for the wikicode editor

0.2.1

  • Fixed minor bug in wikicode editor's formatting insertion
  • [2025-04-05] updated Gist to add updated uBO filters and this changelog

0.2.2

  • Makes editor preview selectable in fullscreen mode
  • Add reading mode toggle to editor preview panel
  • [uBO] makes entity guide's multicolumn list items scale better within and without reading mode
// ==UserScript==
// @name TWHL+
// @namespace https://twhl.info/user/7776
// @version 0.2.2
// @description The Whole Half-Life made wholesome
// @author kimilil
// @match https://twhl.info/*
// @icon https://twhl.info/favicon.ico
// @homepageURL https://gist.github.com/mfaizsyahmi/0d1554a79e7726d40c1eb4d9b731bd5f
// @downloadURL https://gist.github.com/mfaizsyahmi/0d1554a79e7726d40c1eb4d9b731bd5f/raw/twhlplus.user.js
// @updateURL https://gist.github.com/mfaizsyahmi/0d1554a79e7726d40c1eb4d9b731bd5f/raw/twhlplus.user.js
// @grant none
// ==/UserScript==
(function() {
'use strict';
const root = document.documentElement;
// search bar additions (executed everywhere)
function searchAdditions() {
const searchMap = {
all: {action: "/search/index", method: "GET"},
vault: {action: "/vault/index", method: "GET"},
wikitext: {action: "/wiki-special/query-search", method: "GET"},
wikipagedirect: {pathname:s=>`/wiki/page/${encodeURI(s)}`}
},
doCustomSearch = (targetForm, {action, method, pathname}) => {
if (pathname) {
location.pathname = pathname(targetForm.elements["search"].value)
} else {
targetForm.action = action;
targetForm.method = method;
targetForm.requestSubmit();
}
};
for (const searchForm of [document.querySelector(".header-desktop form.navbar-search-inline")]) {
searchForm.querySelector(".input-group")
.insertAdjacentHTML('beforeend', `
<!-- BOOTSTRAP 5 (TWHL migrated 2025-04-01) -->
<button class="btn btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
<span class="visually-hidden">Search options</span>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><h6 class="dropdown-header">Search in:</h6></li>
<li><a class="dropdown-item" href="#" data-searchin="all">All of TWHL</a></li>
<li><a class="dropdown-item" href="#" data-searchin="vault">Vault</a></li>
<li><a class="dropdown-item" href="#" data-searchin="wikitext">Wiki text (exact)</a></li>
<li><a class="dropdown-item" href="#" data-searchin="wikipagedirect">Wiki page title (exact)</a></li>
</ul>
`);
for (const item in searchMap) {
searchForm.querySelector(`[data-searchin="${item}"]`)
.addEventListener('click', e => {
e.preventDefault();
doCustomSearch(searchForm, searchMap[item])
});
}
}
}
// additions to the wikicode editor
function wikiCodeInputAdditions(container) {
const buttons = [
// copied from source since we need to reset and re-add these
{ title: 'Bold text', template: '*CUR1*', cur1: 'bold text', cur2: '' },
{ title: 'Italic text', template: '/CUR1/', cur1: 'italic text', cur2: '' },
{ title: 'Underline text', template: '_CUR1_', cur1: 'underline text', cur2: '' },
{ title: 'Strikethrough text', template: '~CUR1~', cur1: 'strikethrough text', cur2: '' },
{ title: 'Code', template: '`CUR1`', cur1: 'code', cur2: '' },
{ title: 'Header 1', template: '= CUR1', cur1: 'Header', cur2: '' },
{ title: 'Header 2', template: '== CUR1', cur1: 'Header', cur2: '' },
{ title: 'Header 3', template: '=== CUR1', cur1: 'Header', cur2: '' },
{ title: 'Link', template: '[CUR2|CUR1]', cur1: 'link text', cur2: 'http://example.com/' },
{ title: 'Image', template: '[img:CUR2|CUR1]', cur1: 'caption text', cur2: 'http://example.com/image.jpg' },
{ title: 'Youtube', template: '[youtube:CUR2|CUR1]', cur1: 'caption text', cur2: 'youtube_id' },
{ title: 'Quote', template: '> CUR1', cur1: 'quoted text', cur2: '', force_newline: true },
{ title: 'Unsorted List', template: '- CUR1', cur1: 'Item', cur2: '', force_newline: true },
{ title: 'Sorted List', template: '# CUR1', cur1: 'Item', cur2: '', force_newline: true },
// new items
{ text: 'Single', template: "%%columns=12\nCUR1\n%%", cur1: 'content', cur2: 'content', force_newline: true },
{ text: 'Double', template: "%%columns=6:6\nCUR1\n%%\nCUR2\n%%", cur1: 'content', cur2: 'content', force_newline: true },
{ text: 'Triple', template: "%%columns=4:4:4\nCUR1\n%%\nCUR2\n%%\ncontent\n%%", cur1: 'content', cur2: 'content', force_newline: true },
{ text: '4 Columns',template: "%%columns=3:3:3:3\nCUR1\n%%\nCUR2\n%%\ncontent\n%%\ncontent\n%%", cur1: 'content', cur2: 'content', force_newline: true },
{ text: 'Centering',template: "%%columns=3:6:3\n%%\nCUR1\n%%\n%%", cur1: 'content', cur2: '', force_newline: true },
{ text: 'Normal', template: "~~~\n:CUR2\nCUR1\n~~~", cur1: 'content', cur2: 'Heading', force_newline: true },
{ text: 'Message', template: "~~~message\n:CUR2\nCUR1\n~~~", cur1: 'content', cur2: 'Heading', force_newline: true },
{ text: 'Info', template: "~~~info\n:CUR2\nCUR1\n~~~", cur1: 'content', cur2: 'Heading', force_newline: true },
{ text: 'Warning', template: "~~~warning\n:CUR2\nCUR1\n~~~", cur1: 'content', cur2: 'Heading', force_newline: true },
{ text: 'Error', template: "~~~error\n:CUR2\nCUR1\n~~~", cur1: 'content', cur2: 'Heading', force_newline: true },
{ title: 'Vault item', template: '[vault:CUR1]', cur1: 'vault_id', cur2: '' },
{ title: 'Category', template: '[cat:CUR1]', cur1: 'Category Name', cur2: '' },
{ title: 'Credit', template: '[credit:CUR2|user:CUR1]', cur1: 'ID#', cur2: 'Role' },
{ title: 'Vault item', template: '[vault:CUR1]', cur1: 'vault_id', cur2: '' },
{ title: 'Spoiler', template: '[spoiler=CUR2]CUR1[/spoiler]', cur1: 'content', cur2: 'Spoiler' }
]
// pressing shift key when pressing the editor toolbar button
// applies an alternate syntax, mostly the BBCode versions
const shiftButtons = [
{ title: 'Bold text', template: '[b]CUR1[/b]', cur1: 'bold text', cur2: '' },
{ title: 'Italic text', template: '[i]CUR1[/b]', cur1: 'italic text', cur2: '' },
{ title: 'Underline text', template: '[u]CUR1[/u]', cur1: 'underline text', cur2: '' },
{ title: 'Strikethrough text', template: '[s]CUR1[/s]', cur1: 'strikethrough text', cur2: '' },
{ title: 'Code', template: '[code]CUR1[/code]', cur1: 'code', cur2: '' },
{ title: 'Link', template: '[url=CUR2]CUR1[/ur]', cur1: 'link text', cur2: 'http://example.com/' },
{ title: 'Quote', template: '[quote]CUR1[/quote]', cur1: 'quoted text', cur2: '' },
{ title: 'Credit', template: '[credit:CUR2|name:CUR1]', cur1: 'Name', cur2: 'Role' },
];
const insertTextCommandSupported = document.queryCommandSupported("insertText");
// adapted from source
// https://github.com/LogicAndTrick/twhl/blob/369888f4c993fc6fa3a5f8789f6a5bb27ec4f643/resources/assets/js/boot/wikicode-preview.js#L3C10-L31C2
const insertIntoInput = (textarea, template, cursor, cursor2, force_newline) => {
let val = textarea.value || '',
st = textarea.selectionStart || 0,
end = textarea.selectionEnd || 0,
prev = val.substr(0, st),
is_newline = prev.length === 0 || prev[prev.length-1] === '\n',
before = (force_newline === true && !is_newline) ? prev + '\n' : prev,
between = val.substring(st, end),
curVal = between || cursor,
after = val.substr(end),
c1i = template.indexOf('CUR1'),
c2i = template.indexOf('CUR2'),
cur = template.replace('CUR1', curVal).replace('CUR2', cursor2);
// use the execCommand if supported. this preserves undo history.
if (insertTextCommandSupported) {
cur = (force_newline === true && !is_newline) ? '\n' + cur : cur;
cur = (force_newline === true && after[0] !== '\n') ? cur + '\n' : cur;
textarea.focus();
document.execCommand("insertText", false, cur);
} else {
after = (force_newline === true && after[0] !== '\n') ? '\n' + after : after;
textarea.value = before + cur + after;
textarea.focus();
}
if (c2i < 0) c2i = Number.MAX_VALUE;
let cstart = before.length + c1i + (c2i < c1i ? cursor2.length - 4 : 0),
cend = cstart + curVal.length;
if (between && c2i <= val.length) {
cstart = before.length + c2i + (c2i > c1i ? between.length - 4 : 0);
cend = cstart + cursor2.length;
}
textarea.setSelectionRange(cstart, cend);
textarea.dispatchEvent(new Event('change', { bubbles: true }))
}
const textarea = container.querySelector('textarea');
// reset behaviour of constantly refreshing preview on selection change
// instead only allowing it when the textarea is active
// this allows one to select the text from the preview (in case you just want the raw text)
window.addEventListener("selectionchange", e => {
if (document.activeElement !== textarea)
e.stopImmediatePropagation();
}, true)
// reset toolbar
container.querySelectorAll(".btn-toolbar > .btn-group")
.forEach((el, i, arr) => {
if (i === arr.length-1)
return; // skip the smiley button
el.insertAdjacentElement('afterend', el.cloneNode(true));
el.remove();
});
// apply new additions
container.querySelector('.btn-toolbar').lastElementChild.previousElementSibling
.insertAdjacentHTML('beforebegin', `
<div class="btn-group btn-group-xs me-2 twhlx-editor-add">
<button type="button" class="btn btn-outline-inverse btn-xs dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false" title="Font">
<span class="fa fa-font"></span>
</button>
<div class="dropdown-menu w-25 p-2 twhlx-font-dropdown">
<div class="input-group mb-2" title="Font color">
<span class="input-group-text">
<span class="fa fa-paint-brush"></span>
</span>
<input id="twhlx-font-color-2" type="color" class="form-control"
value="black" aria-label="Color picker">
</div>
<div class="input-group mb-2" title="Font size">
<span class="input-group-text">
<span class="fa fa-text-height"></span>
</span>
<input id="twhlx-font-size" type="number" class="form-control"
min=6 max=40 value=14 aria-label="Size">
</div>
<button class="btn btn-primary" type="button" id="twhlx-font-apply">Apply</button>
</div>
<button type="button" class="btn btn-outline-inverse btn-xs dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false" title="Columns">
<span class="fa fa-columns"></span>
</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="#">Single</a></li>
<li><a class="dropdown-item" href="#">Double</a></li>
<li><a class="dropdown-item" href="#">Triple</a></li>
<li><a class="dropdown-item" href="#">4 Columns</a></li>
<li><a class="dropdown-item" href="#">Centering</a></li>
</ul>
<button type="button" class="btn btn-outline-inverse btn-xs dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false" title="Panel">
<span class="fa fa-sticky-note-o"></span>
</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="#">Normal</a></li>
<li><a class="dropdown-item link-success" href="#">Message</a></li>
<li><a class="dropdown-item link-info" href="#">Info</a></li>
<li><a class="dropdown-item link-warning" href="#">Warning</a></li>
<li><a class="dropdown-item link-danger" href="#">Error</a></li>
</ul>
<button type="button" class="btn btn-outline-inverse btn-xs" title="Spoiler">
<span class="fa fa-eye-slash"></span>
</button>
<button type="button" class="btn btn-outline-inverse btn-xs" title="Category">
<span class="fa fa-tag"></span>
</button>
<button type="button" class="btn btn-outline-inverse btn-xs" title="Credit">
<span class="fa fa-user-plus"></span>
</button>
<button type="button" class="btn btn-outline-inverse btn-xs" title="Vault item">
<span class="fa fa-file-zip-o"></span>
</button>
</div>
<style>
.wikicode-input .btn-toolbar {margin-bottom: -0.5rem}
.wikicode-input .btn-toolbar .btn-group {margin-bottom: 0.5rem}
.twhlx-has-alt {position: relative}
.twhlx-has-alt::after {
position: absolute; bottom: 0; right: 0;
border-style: solid; border-width: 4px;
border-color: transparent gray gray transparent;
content: ' '; box-sizing: border-box;
}
</style>
`)
// re-apply toolbar button events
container.querySelectorAll('button, .dropdown-menu a')
.forEach(el => {
el.querySelector(".fa:not(.fa-header)")?.classList.add("fa-fw");
const btn1 = buttons.filter(btn => (btn.title === el.title || btn.text === el.innerText))?.[0];
const btn2 = shiftButtons.filter(btn => (btn.title === el.title || btn.text === el.innerText))?.[0];
if (btn1 && btn2)
el.classList.add("twhlx-has-alt")
el.addEventListener('click', e => {
let thisBtn = (e.shiftKey && btn2) ? btn2 : btn1;
if (thisBtn) {
insertIntoInput(textarea, thisBtn.template, thisBtn.cur1, thisBtn.cur2, thisBtn.force_newline);
}
});
});
// font button
const defaultFontColor = "#000000", defaultFontSize = 14;
container.querySelector('button[title="Font"]')
.addEventListener('click', e => {
// reset to defaults
container.querySelector('#twhlx-font-color-2').value = defaultFontColor;
container.querySelector('#twhlx-font-size').value = defaultFontSize;
})
// apply font formatting
const restOfArgs = ["content","",false];
container.querySelector('#twhlx-font-apply')
.addEventListener('click', e => {
let color = container.querySelector('#twhlx-font-color-2').value,
size = container.querySelector('#twhlx-font-size').value;
color = (color === defaultFontColor) ? null : color;
size = (size == defaultFontSize) ? null : size;
if (color == null && size == null) return;
else if (color && size == null)
insertIntoInput(textarea, `[font=${color}]CUR1[/font]`,...restOfArgs)
else if (size && color == null)
insertIntoInput(textarea, `[font size=${size}]CUR1[/font]`,...restOfArgs)
else
insertIntoInput(textarea, `[font color=${color} size=${size}]CUR1[/font]`,...restOfArgs)
})
// add reading mode toggle to preview panel
container.lastElementChild.firstElementChild.lastElementChild
.insertAdjacentHTML('beforebegin',`
<div class="twhlx-prev-options float-end">
<label class="d-none d-xl-inline-block align-middle fs-6 my-0">
<input type="checkbox" id="twhlx-preview-reading-mode">&nbsp;Reading mode
</label>
`);
container.querySelector(".twhlx-prev-options")
.prepend(container.querySelector("h4 button"));
container.querySelector(".bbcode").classList.add("wiki")
container.querySelector("#twhlx-preview-reading-mode")
.addEventListener("change", e => {
container.querySelector(".bbcode")
.classList.toggle("reading-mode",e.currentTarget.checked)
});
}
// wiki pages
function wikiPageAdditions() {
// everything in previous version has been implemented natively 2025-04-01
}
function wikiCategoryPageAdditions() {
Array.from(document.querySelectorAll("h4"))
?.filter(el => el.innerText == "Pages in this category")[0]
?.nextElementSibling.querySelectorAll(`a`)
.forEach(el => {
el.target = "_blank"
el.insertAdjacentHTML('afterend', `
<span class="editLink d-inline-block">
[<a target="_blank" href="${el.href.replace(/\/page\//,"/edit/")}">
<span class="fa fa-pencil"></span>Edit
</a>]
</span>
`)
}) ?? null
}
function generalAdditions() {
// make table scrollable
document.querySelectorAll("table").forEach(tbl => {
const wrapper = document.createElement("div");
//wrapper.style.overflowX = "auto";
wrapper.classList.add("overflow-auto");
tbl.insertAdjacentElement("beforebegin", wrapper);
wrapper.appendChild(tbl);
});
}
generalAdditions();
searchAdditions();
if (location.pathname.match(/\/wiki\/page\/category/)) {
try {
wikiCategoryPageAdditions()
} finally {}
}
else if (location.pathname.match(/\/wiki\//)) {
try {
wikiPageAdditions()
} finally {}
}
let wikiCodeInputTimeoutCounter = 0;
const wikiCodeInputTimeoutCB = _ => {
//if (!document.querySelectorAll(".wikicode-input .btn-toolbar").length && wikiCodeInputTimeoutCounter < 9) {
// setTimeout(wikiCodeInputTimeoutCB, 100)
// wikiCodeInputTimeoutCounter++;
//} else {
document.querySelectorAll(".wikicode-input")
.forEach(container => wikiCodeInputAdditions(container));
//}
};
if (document.querySelectorAll(".wikicode-input").length) {
setTimeout(wikiCodeInputTimeoutCB, 100)
}
})();
! 2025-04-01 twhl.info
!! twhl.info##textarea:style(font-family:monospace !important; tab-size:4)
twhl.info##:xpath(//h3[contains(text(),'Attributes')]/following-sibling::h3[1]/preceding-sibling::ul[preceding-sibling::h3[contains(text(),'Attributes')]]//ul):style(columns:16rem)
twhl.info##[id^="Attributes"] + .card + ul > li > ul:style(columns:16rem)
twhl.info##[id^="Attributes"] + .card + ul ~ ul > li > ul:style(columns:16rem)
twhl.info##[id^="Team_Fortress_Classic_only"]:upward(1):style(display:none !important)
twhl.info##code, kbd, pre, samp:style(font-family:Fira Code,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace !important)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment