diff --git a/assets/images/bg-nanotrasen.svg b/assets/images/bg-nanotrasen.svg new file mode 100644 index 0000000..d21b9f0 --- /dev/null +++ b/assets/images/bg-nanotrasen.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/index.html b/index.html index c5792d8..9f45efb 100644 --- a/index.html +++ b/index.html @@ -8,6 +8,9 @@ /tg/ Handbook +
+ +
diff --git a/lib/components/WikiPage.tsx b/lib/components/WikiPage.tsx index f829e85..cbeec33 100644 --- a/lib/components/WikiPage.tsx +++ b/lib/components/WikiPage.tsx @@ -1,7 +1,8 @@ import { getPageHTML } from "../wiki"; import { darken, ColorFmt, lighten } from "../darkmode"; import * as React from "react"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; +import userscript from "../userscript"; function fixup(html: string): string { // Convert relative links to absolute @@ -27,9 +28,13 @@ function fixup(html: string): string { }); node.querySelectorAll("*[style]").forEach((td: HTMLElement) => { const inlineCSS = td.getAttribute("style"); - let bgcolor = td.style.background; - if (inlineCSS.includes("background-color")) { + let bgcolor = null; + if (inlineCSS.includes("background-color:")) { bgcolor = td.style.backgroundColor; + } else if (inlineCSS.includes("background:")) { + bgcolor = td.style.background; + } else { + return; } td.setAttribute( "style", @@ -56,11 +61,42 @@ function fixup(html: string): string { td.setAttribute("width", "100%"); }); + // Group headers and content so stickies don't overlap + node.querySelectorAll("h3,h2").forEach((h3) => { + const parent = h3.parentNode; + const div = document.createElement("div"); + parent.insertBefore(div, h3); + while (h3.nextSibling && !h3.nextSibling.nodeName.startsWith("H")) { + const sibling = h3.nextSibling; + parent.removeChild(sibling); + div.appendChild(sibling); + } + h3.parentNode.removeChild(h3); + div.insertBefore(h3, div.firstChild); + div.className = "mw-headline-cont"; + }); + + node.querySelectorAll(".mw-headline").forEach((span: HTMLElement) => { + // Find nearest container + let parent = span.parentElement; + while (parent !== null) { + if (parent.classList.contains("mw-headline-cont")) { + parent.id = span.id; + span.id += "-span"; + parent.dataset.name = span.innerText; + } + parent = parent.parentElement; + } + }); + return node.innerHTML; } export default function WikiPage({ page }) { const [data, setData] = useState({ loaded: false, html: "" }); + const containerRef = useRef(null); + + // Fetch page useEffect(() => { (async () => { let html = await getPageHTML(page); @@ -68,11 +104,21 @@ export default function WikiPage({ page }) { setData({ loaded: true, html }); })(); }, []); + + // Page fetched, instance userscript + useEffect(() => { + if (data.loaded) { + console.log("Injecting userscript!"); + userscript(containerRef.current, page); + } + }, [data]); + if (!data.loaded) { return

You start skimming through the manual...

; } else { return (
diff --git a/lib/darkmode.ts b/lib/darkmode.ts index 8026e53..70a180f 100644 --- a/lib/darkmode.ts +++ b/lib/darkmode.ts @@ -144,7 +144,7 @@ export function darken(color: string, format: ColorFmt): string { const col = parseColor(color); const hsl = rgbToHsv(col); if (hsl.s < 0.15) { - hsl.h = 0.1; + hsl.h = 0.6; hsl.s = 0.5; hsl.v = Math.max(0.2, 1 - hsl.v); } else { diff --git a/lib/userscript.ts b/lib/userscript.ts new file mode 100644 index 0000000..a7fc8ba --- /dev/null +++ b/lib/userscript.ts @@ -0,0 +1,510 @@ +// ==UserScript== +// @name Better /tg/ Guides +// @namespace https://faulty.equipment +// @version 0.2.2 +// @description Make /tg/station guides better with extra features +// @author Hamcha +// @collaborator D +// @license ISC +// @copyright 2020, Hamcha (https://openuserjs.org/users/Hamcha), D +// @match https://tgstation13.org/wiki/Guide_to_* +// @grant GM_addStyle +// ==/UserScript== + +const DEFAULT_OPTS = { + alignment: "center", +}; + +export default function (root: HTMLElement, docname: string) { + function GM_addStyle(css) { + const style = document.createElement("style"); + style.innerHTML = css; + root.appendChild(style); + } + + // Tell user that better chemistry is loading + const postbody = root; + const statusMessage = document.createElement("div"); + statusMessage.innerHTML = ` + + +
+ Hang on... Better guides is loading. +
`; + postbody.insertBefore(statusMessage, postbody.firstChild); + + GM_addStyle(` + .bgus_hidden { display: none !important; } + .bgus_nobreak { white-space: nowrap; } + `); + + // TODO Refactor this mess + function searchBox(el, search_candidate, options = DEFAULT_OPTS) { + // Fuzzy search box + GM_addStyle( + ` + #bgus_fz_searchbox { + position: fixed; + top: 20px; + left: 30%; + right: 30%; + background: rgba(10,10,10,0.8); + display: flex; + flex-direction: column; + z-index: 9999; + color: #fff; + } + #bgus_fz_searchbox input { + font-size: 14pt; + padding: 5pt 8pt; + border: 1px solid #555; + margin: 5px; + margin-bottom: 0; + background-color: #111; + color: #fff; + } + #bgus_fz_searchbox ul { + list-style: none; + margin: 5px; + padding: 0; + } + #bgus_fz_searchbox li { + padding: 5px; + cursor: pointer; + } + #bgus_fz_searchbox li:hover { + background-color: rgba(100, 100, 100, 0.5); + } + #bgus_fz_searchbox li.selected { + border-left: 3px solid white; + } + ` + ); + + const resultList = document.createElement("ul"); + const searchBox = document.createElement("div"); + let selected_result = null; + let results = []; + + const jumpTo = function (id) { + el[id].scrollIntoView({ + block: options.alignment, + inline: "nearest", + behavior: "auto", + }); + document + .querySelectorAll("table.wikitable .bgus_fz_selected") + .forEach((el) => el.classList.remove("bgus_fz_selected")); + el[id].parentElement.classList.add("bgus_fz_selected"); + }; + + const setSelectedResult = function (i) { + selected_result = i; + resultList + .querySelectorAll(".selected") + .forEach((el) => el.classList.remove("selected")); + resultList.children[i].classList.add("selected"); + jumpTo(results[i].id); + }; + + const search = (str) => { + if (!str) { + return; + } + const regex = new RegExp( + "^(.*?)([" + + str + .split("") + .map((c) => (c.includes(["\\", "]", "^"]) ? "\\" + c : c)) + .join("])(.*?)([") + + "])(.*?)$", + "i" + ); + const arr = search_candidate + .map((o) => { + o.matches = (o.str.match(regex) || []) + .slice(1) + .reduce((list, group, i, or) => { + // Initialize first placeholder (always empty) and first matching "sections" + if (i < 2) { + list.push([group]); + } + // If group is second match in a row join to previous section + else if (or[i - 1] === "") { + list[list.length - 1].push(group); + } + // If group is a match create a new section + else if (group !== "") { + list.push([group]); + } + return list; + }, []) + .map((str) => str.join("")); + return o; + }) + // Strike non-matching rows + .filter((o) => o.matches.length > 0) + .sort((oA, oB) => { + const iA = oA.id, + a = oA.matches; + const iB = oB.id, + b = oB.matches; + + // Exact match + if (a.length === 1 && b.length !== 1) return -1; + if (a.length !== 1 && b.length === 1) return 1; + + // Most complete groups (alphanumeric) + const clean = (el) => !/[^a-zA-Z0-9]*$/.test(el); + const cLen = a.filter(clean).length - b.filter(clean).length; + if (cLen !== 0) return cLen; + + // Least distant first gropus + for (let i = 0; i < Math.min(a.length, b.length) - 1; i += 2) { + const gLen = a[i].length - b[i].length; + if (gLen !== 0) return gLen; + } + + // Most complete groups (raw) + const len = a.length - b.length; + if (len !== 0) return len; + + // Make the search stable since ECMAScript doesn't mandate it + return iA - iB; + }); + results = arr; + window.requestAnimationFrame(() => { + resultList.innerHTML = ""; + arr.forEach(({ matches, id }) => { + const li = document.createElement("li"); + li.innerHTML = matches + .map((c, i) => (i % 2 ? "" + c + "" : c)) + .join(""); + li.addEventListener("click", () => { + jumpTo(id); + searchBox.classList.add("bgus_hidden"); + }); + resultList.appendChild(li); + }); + if (results.length > 0) { + setSelectedResult(0); + } + }); + }; + + // Create fuzzy search box + const sel = document.createElement("input"); + searchBox.id = "bgus_fz_searchbox"; + searchBox.classList.add("bgus_hidden"); + searchBox.appendChild(sel); + searchBox.appendChild(resultList); + root.appendChild(searchBox); + + // Bind events + let oldValue = ""; + sel.addEventListener("keyup", function (event) { + switch (event.keyCode) { + case 27: // Escape - Hide bar + searchBox.classList.add("bgus_hidden"); + return; + case 13: // Enter - Jump to first result and hide bar + if (results.length > 0) { + jumpTo(results[selected_result].id); + } + searchBox.classList.add("bgus_hidden"); + return; + case 40: // Down arrow - Select next result + if (selected_result < results.length - 1) { + setSelectedResult(selected_result + 1); + } + return; + case 38: // Up arrow - Select previous result + if (selected_result > 0) { + setSelectedResult(selected_result - 1); + } + return; + default: + if (this.value != oldValue) { + search(this.value); + oldValue = this.value; + } + } + }); + + document.body.addEventListener("keyup", function (ev) { + if (ev.keyCode === 83) { + sel.focus(); + } + }); + + document.body.addEventListener("keydown", function (ev) { + if (ev.shiftKey) { + switch (ev.keyCode) { + // SHIFT+S = Fuzzy search + case 83: { + searchBox.classList.remove("bgus_hidden"); + sel.value = ""; + return; + } + } + } + }); + } + + function betterChemistry() { + // Chem styles + GM_addStyle( + ` + .bgus_twistie:after { + color: red; + display: inline-block; + font-weight: bold; + margin-left: .2em; + content: '⯆'; + } + .bgus_collapsed > .bgus_nested_element > .bgus_twistie:after{ + content: '⯈'; + } + :not(.bgus_collapsed) > .bgus_nested_element + .tooltiptext { + z-index: unset; + visibility: inherit; + opacity: 1; + position: relative; + width: auto; + border-left-width: 3px; + background: transparent; + margin: 5px; + margin-right: 0px; + font-size: 8pt; + padding: 5px 8px; + line-height: 10pt; + } + .bgus_collapsable:not(.bgus_collapsed) + br { + display: none; + } + table.wikitable > tbody > tr > td:nth-child(2) { + min-width: 30%; + padding: 10px; + } + .bgus_fz_selected { + background-color: #525242; + } + input[type="checkbox"] + span[data-src] { + font-weight: bold; + cursor: text; + } + input[type="checkbox"] + span[data-src]:before { + display: inline-block; + width: 1.5em; /* Prevent autoscroll with sudden line wraps when revealing checkboxes */ + text-align: center; + } + input[type="checkbox"] + span[data-src]:before { + content: '•'; + } + .bgus_cbox input[type="checkbox"] + span[data-src]:before { + content: '[_]'; + margin-right: 0.5em; + } + .bgus_cbox input[type="checkbox"]:checked + span[data-src]:before { + content: '[X]'; + margin-right: 0.5em; + } + .bgus_cbox input[type="checkbox"]:checked + span[data-src] { + text-decoration: line-through; + } + .bgus_cbox input[type="checkbox"] + span[data-src] { + cursor: pointer; + } + .bgus_cbox input[type="checkbox"] + span[data-src] { + color: orange; + } + .bgus_cbox input[type="checkbox"]:checked + span[data-src] { + color: green; + } + ` + ); + + // Fix inconsistencies with

on random parts + // Ideally I'd like a

or something on every part, wrapping it completely, but for now let's just kill 'em + new Set( + Array.from( + root.querySelectorAll( + "table.wikitable > tbody > tr:not(:first-child) > td:nth-child(2) p" + ) + ).map((p) => p.parentNode) + ).forEach((parent) => { + const tmp = parent.cloneNode(); + // The cast to Array is necessary because, while childNodes's NodeList technically has a forEach method, it's a live list and operations mess with its lenght in the middle of the loop + // Nodes can only have one parent so append removes them from the original NodeList and shifts the following one back into the wrong index + Array.from(parent.childNodes).forEach((el) => { + if (el.tagName === "P") { + tmp.append(...el.childNodes); + } else { + tmp.append(el); + } + }); + parent.parentNode.replaceChild(tmp, parent); + }); + + // Enrich "x part" with checkboxes and parts + Array.from(root.querySelectorAll("td")) + .filter((el) => el.innerText.indexOf(" part") >= 0) + .map((el) => [el, el.innerHTML]) + .forEach(([el, innerHTML]) => { + el.innerHTML = innerHTML.replace( + /((\d+)\s+(?:parts?|units?))(.*?(?:<\s*(\/\s*a|br\s*\/?)\s*>|\n|$))/gi, + (match, ...m) => + `${m[2].replace( + /()/gi, + '$1' + )}` + ); + }); + Array.from(root.querySelectorAll(".bgus_nested_element")).forEach((el) => { + el.parentElement.classList.add("bgus_collapsable"); + }); + // Add event to autofill child checkboxes + document + .querySelectorAll(".bgus_part_tooltip > .bgus_checkbox") + .forEach((box) => { + const tooltip = box.parentElement.nextElementSibling; + box.addEventListener("click", function () { + tooltip + .querySelectorAll(".bgus_checkbox") + .forEach((el: HTMLInputElement) => (el.checked = this.checked)); + }); + }); + + // Add event to collapse subsections + root.querySelectorAll(".bgus_nested_element").forEach((twistie) => { + twistie.addEventListener("click", function (evt) { + twistie.parentElement.classList.toggle("bgus_collapsed"); + }); + }); + + // Wrap every recipe with extra metadata + root.querySelectorAll(".bgus_part").forEach((el: HTMLElement) => { + if ("parts" in el.parentElement.dataset) { + el.parentElement.dataset.parts = ( + parseInt(el.parentElement.dataset.parts) + parseInt(el.dataset.amount) + ).toString(); + } else { + el.parentElement.dataset.parts = el.dataset.amount; + } + }); + + const setPartSize = function (labels, ml) { + labels.forEach((el) => { + const part = el.parentElement.dataset.amount; + const total = el.parentElement.parentElement.dataset.parts; + const amt = Math.ceil(ml * (part / total)); + el.innerHTML = `${amt} ml`; + // Lookup tooltips + let next = el.parentElement.nextElementSibling; + while (next) { + if (next.classList.contains("tooltip")) { + let sublabels = []; + next.querySelector(".tooltiptext").childNodes.forEach((ch) => { + if (ch.classList && ch.classList.contains("bgus_part")) { + sublabels.push(ch.querySelector(".bgus_part_label")); + } + }); + setPartSize(sublabels, amt); + } + if (next.classList.contains("bgus_part")) { + // Done searching + break; + } + next = next.nextElementSibling; + } + }); + }; + + // Init fuzzy search with elements + const el = Array.from( + root.querySelectorAll( + "table.wikitable > tbody > tr:not(:first-child) > th" + ) + ); + const name = el.map((elem) => { + let name = ""; + elem.childNodes.forEach((t) => { + if (t instanceof Text) { + name += t.textContent; + } + }); + return name.trim(); + }); + searchBox( + el, + name.map((e, i) => ({ id: i, str: e })) + ); + + document.body.addEventListener("keydown", function (ev) { + if (ev.shiftKey) { + switch (ev.keyCode) { + // SHIFT+C = Toggle checkboxes + case 67: { + root.classList.toggle("bgus_cbox"); + root + .querySelectorAll(".bgus_checkbox:checked") + .forEach((el: HTMLInputElement) => { + el.checked = false; + }); + return; + } + + // SHIFT+B = Set whole size (beaker?) for parts/units + case 66: { + let size = parseInt(prompt("Write target ml (0 to reset)", "90")); + if (isNaN(size) || size <= 0) { + // Reset to parts/unit + root + .querySelectorAll(".bgus_part_label") + .forEach((el: HTMLElement) => (el.innerHTML = el.dataset.src)); + return; + } + setPartSize( + root.querySelectorAll("td > .bgus_part > .bgus_part_label"), + +size + ); + return; + } + } + } + }); + } + + function betterGeneric() { + const el = Array.from( + root.querySelectorAll("div.mw-headline-cont[id][data-name]") + ); + const name = el.map((elem: HTMLDivElement) => elem.dataset.name.trim()); + + // Init fuzzy search with headlines + searchBox( + el, + name.map((e, i) => ({ id: i, str: e })), + { alignment: "start" } + ); + } + + window.requestAnimationFrame(() => { + switch (docname) { + case "Guide_to_chemistry": + betterChemistry(); + break; + default: + betterGeneric(); + break; + } + // Everything is loaded, remove loading bar + statusMessage.innerHTML = ""; + }); +} diff --git a/style/main.scss b/style/main.scss index 130b577..60b18a5 100644 --- a/style/main.scss +++ b/style/main.scss @@ -6,10 +6,31 @@ body { overflow: hidden; } +.bgimage { + position: fixed; + top: 0; + bottom: 0; + left: 0; + right: 0; + display: flex; + align-items: center; + justify-content: center; + z-index: 0; + img { + opacity: 0.4; + } +} + +$nanotrasen: #384e68; + #app { height: 100%; display: grid; - background: linear-gradient(to bottom, hsl(24, 40%, 16%), hsl(44, 63%, 33%)); + background: linear-gradient( + to bottom, + darken($nanotrasen, 20%), + darken($nanotrasen, 10%) + ); background-size: 100% 100%; background-attachment: fixed; color: #fff; @@ -23,6 +44,7 @@ body { grid-row: 2; padding: 10pt; overflow-y: scroll; + z-index: 1; .page { a[href] { color: white; @@ -33,8 +55,9 @@ body { h3 { position: sticky; top: -10pt; - background: hsl(43, 64%, 32%); + background: $nanotrasen; padding: 5px 10px; + z-index: 999; } #toctitle h2 { margin: 0;