|
// ==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"> 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) |
|
} |
|
})(); |