Hamcha / Better /tg/ Guides

// ==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==

(function () {
  "use strict";

  const VERSION = GM_info.script.version;
  const pxSMOOTH_SCROLL_MAX_DISTANCE = (() => {
    if (
      window.matchMedia(
        "@media (prefers-reduced-motion, prefers-reduced-motion: reduce)"
      ).matches
    ) {
      return -Infinity;
    }
    if (
      ["Firefox", "Iceweasel"].some((e) =>
        window.navigator.userAgent.includes(e)
      )
    ) {
      return Infinity;
    }
    return 3000;
  })();

  // Tell user that better chemistry is loading
  const postbody = document.getElementById("mw-content-text");
  const statusMessage = document.createElement("div");
  statusMessage.innerHTML = `
    <table style="background-color: rgb(255, 248, 219); margin-bottom:10px;" width="95%" align="center">
    <tbody><tr><td align="center">
    <b>Hang on...</b> Better guides is loading.
    </td></tr></tbody>
    </table>`;
  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) {
    // 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;
        }
        #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: "center",
        inline: "nearest",
        behavior:
          Math.abs(el[id].getBoundingClientRect().top) <
          pxSMOOTH_SCROLL_MAX_DISTANCE
            ? "smooth"
            : "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 ? "<strong>" + c + "</strong>" : 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);
    document.body.appendChild(searchBox);

    // Bind events
    let oldValue = "";
    sel.onkeyup = function (ev) {
      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_twistie:after{
          content: '⯈';
      }
      span.bgus_nested_element:not(.bgus_collapsed) + div.tooltiptext {
          z-index: unset;
          visibility: inherit;
          opacity: 1;
          position: relative;
          width: auto;
          border-left-width: 3px;
          background: transparent;
          margin-left: 5px;
          margin-top: 5px;
          font-size: 8pt;
          padding: 5px 8px;
          line-height: 10pt;
      }
      table.wikitable > tbody > tr > td:nth-child(2) {
          min-width: 30%;
          padding: 10px;
      }
      .bgus_fz_selected {
          border: 3px solid yellow;
      }
      body.bgus_cbox input[type="checkbox"] + span[data-src]:before {
          display: inline-block;
          width: 1.5em;
          content: '[_]';
      }
      body.bgus_cbox input[type="checkbox"]:checked + span[data-src]:before {
          content: '[X]';
      }
      body.bgus_cbox input[type="checkbox"]:checked + span[data-src] {
          text-decoration: line-through;
      }
      body.bgus_cbox input[type="checkbox"] + span[data-src] {
          cursor: pointer;
      }
      body.bgus_cbox input[type="checkbox"] + span[data-src]:before,
      body.bgus_cbox input[type="checkbox"] + span[data-src] {
          color: orange;
          font-weight: bold;
      }
      body.bgus_cbox input[type="checkbox"]:checked + span[data-src]:before,
      body.bgus_cbox input[type="checkbox"]:checked + span[data-src] {
          color: green;
      }
      `
    );

    // Fix inconsistencies with <p> on random parts
    // Ideally I'd like a <p> or something on every part, wrapping it completely, but for now let's just kill 'em
    document
      .querySelectorAll(
        "table.wikitable > tbody > tr:not(:first-child) > td:nth-child(2)"
      )
      .forEach((td) => {
        const tmp = td.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(td.childNodes).forEach((el) => {
          if (el.tagName === "P") {
            tmp.append(...el.childNodes);
          } else {
            tmp.append(el);
          }
        });
        td.parentNode.replaceChild(tmp, td);
      });

    // Enrich "x part" with checkboxes and parts
    Array.from(document.querySelectorAll("td"))
      .filter((el) => el.innerText.indexOf(" part") >= 0)
      .forEach((el) => {
        el.innerHTML = el.innerHTML.replace(
          /((\d+)\s+(?:parts?|units?))(.*?(?:<\/a>|\n|$))/gi,
          (match, ...m) =>
            `<label class="bgus_part ${
              m[2].includes("</a>") ? "bgus_part_tooltip" : ""
            }" data-amount="${
              m[1]
            }"><input type="checkbox" class='bgus_checkbox bgus_hidden'/> <span class="bgus_part_label" data-src="${
              m[0]
            }">${m[0]}</span></label>${m[2].replace(
              /(<a .+?<\/a>)/gi,
              '<span class="bgus_nobreak bgus_nested_element">$1<span class="bgus_twistie"></span></span>'
            )}`
        );
      });
    // 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) => (el.checked = this.checked));
        });
      });

    // Add event to collapse subsections
    document.querySelectorAll(".bgus_nested_element").forEach((twistie) => {
      twistie.addEventListener("click", function (evt) {
        twistie.classList.toggle("bgus_collapsed");
      });
    });

    // Wrap every recipe with extra metadata
    document.querySelectorAll(".bgus_part").forEach((el) => {
      if ("parts" in el.parentElement.dataset) {
        el.parentElement.dataset.parts =
          parseInt(el.parentElement.dataset.parts) +
          parseInt(el.dataset.amount);
      } 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(
      document.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: {
            document.body.classList.toggle("bgus_cbox");
            document
              .querySelectorAll(".bgus_checkbox:checked")
              .forEach((el) => {
                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
              document
                .querySelectorAll(".bgus_part_label")
                .forEach((el) => (el.innerHTML = el.dataset.src));
              return;
            }
            setPartSize(
              document.querySelectorAll("td > .bgus_part > .bgus_part_label"),
              size
            );
            return;
          }
        }
      }
    });

    // Everything is loaded, show welcome message
    statusMessage.innerHTML = `
      <table style="background-color: rgb(255, 248, 219); margin-bottom:10px;" width="95%" align="center">
      <tbody><tr><td style="padding:5px 10px;">
      <h3>Better Guides (Chemistry) <small>${VERSION}</small></h3>
      <p>These commands are available:</p>
      <ul>
        <li><b>SHIFT + S</b> Fuzzy search recipes (navigate with up/down arrow keys, hide with RETURN/ESC)</li>
        <li><b>SHIFT + C</b> Toggle checkboxes</li>
        <li><b>SHIFT + B</b> Convert parts/units to ml (given target/beaker size), beware: inaccurate</li>
      </ul>
      </td></tr></tbody>
      </table>
      `;
  }

  function betterGeneric() {
    // Everything is loaded, show welcome message
    statusMessage.innerHTML = `
    <table style="background-color: rgb(255, 248, 219); margin-bottom:10px;" width="95%" align="center">
    <tbody><tr><td style="padding:5px 10px;">
    <h3>Better Guides (generic) <small>${VERSION}</small></h3>
    <p>These commands are available:</p>
    <ul>
      <li><b>SHIFT + S</b> Fuzzy search (navigate with up/down arrow keys, hide with RETURN/ESC)</li>
    </ul>
    </td></tr></tbody>
    </table>
    `;

    const el = Array.from(document.querySelectorAll(".mw-headline"));
    const name = el.map((elem) => elem.innerText.trim());

    // Init fuzzy search with headlines
    searchBox(
      el,
      name.map((e, i) => ({ id: i, str: e }))
    );
  }

  window.requestAnimationFrame(() => {
    switch (location.pathname) {
      case "/wiki/Guide_to_chemistry":
        betterChemistry();
        break;
      default:
        betterGeneric();
        break;
    }
  });
})();