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;