User:Golden/common.js

From SRB2 Wiki
Jump to navigation Jump to search

Note: After publishing, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Internet Explorer / Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5
  • Opera: Press Ctrl-F5.
// "Here, you can see Golden's user configuration habits in action..."

// This particular script commits several henious crimes such as:
//    - Assuming your browser supports ES6
//    - Using the `let` keyword when defining most variables
//    - Sometimes using `==` for equality checking
//    - Using += on strings
//    - A try...catch block
//    - Promises
//    - Backtick strings
//    - Non-integer keys in Arrays
//    - Definition of variables in control structures
//    - Definition of variables through let [...] syntax
//    - Actually using the `null` keyword
//    - Modifying values in `window.location`
//    - Having an annoying pop up window.
//    - "keydown" and "keyup" event listeners
//    - DOM manipulation
//    - Hardcoded element attributes
//    - Inconsistent code comment density
//    - Inline function definitions
//    - Unbracketed if statements
//    - Implying object orientation, but instead being functional
//        - Defining classes only to use them as functions
//        - Placing class methods in Objects, and calling them
//        - Making no class methods private

const visibleMatched = 64;

const input_bg = "#f0e8c0";
const input_fg = "#483018";

const window_bg = "#483018";
const window_fg = "#f0e8c0";

// Executes a command as typed in the VWN <input> element.
class VWN_Command {
	constructor() {
		// Maps functions to particular commands.
		this.funcs = {
			's': this.searchPage,
			'#': this.goToSection
		};

		this.curFunc = null;
	}

	run(char, args)
	{
		// Get the function, then check if it exists (a valid command).
		let func = this.funcs[char];

		if (typeof func === "undefined")
			return;

		// If it's a valid command, call it with the args (and full string)
		return new Promise(async (resolve, reject) => {
			let ms = 5000;

			let timeout = setTimeout(() => {
				reject(`Command took too long (>${ms} ms)`);
			}, ms);

			let output = func.call(this, args, char + args);

			clearTimeout();
			resolve(output);
		});
	}

	// assumes you slapped a 'g' flag on the regex
	// if you don't it'll freeze the page
	doActualSearching(element, regex)
	{
		// Get the inner text of the actual wiki body.
		let text = element.innerText;
		let matches;

		if (regex.global) // Global regex?
		{
			matches = text.matchAll(regex);

			if (typeof matches === "undefined")
				matches = [];
		}
		else // Otherwise, polyfill in a matchAll with the behavior I want
		{
			let match; // current match
			let cutoff_text = text; // a substring to make sure matches don't happen multiple times
			let cutoff = 0; // the amount of text cut off.

			matches = []; // Define matches actually

			let iter = 0;

			// Iterate matches. If the text matches,
			while ((match = cutoff_text.match(regex)))
			{
				if (matches.length && match.index == matches[matches.length - 1].index)
				{
					iter += 1;

					if (iter >= 100)
					{
						matches.tooMany = true;
						break; // We're in a loop that goes for too long!
					}
				}
				else
					iter = 0;

				let index = match.index; // collect the matched index for later,

				match.index += cutoff; // give the match the true position,
				match.input = text; // and the true text,

				// and push the match to the array.
				matches.push(match);

				// Get the amount of text cut off from the string to make sure it doesn't match again,
				let curcutoff = index + match[0].length;

				// and cut the text off to just past the match to make sure it doesn't match again.
				cutoff_text = cutoff_text.slice(curcutoff);
				cutoff += curcutoff;
			}
		}

		// Both formats should be identical.
		return matches;
	}

	searchPage(query) {
		let test;

		// See if the regex starts correctly, if it doesnt, tell the user
		if (query.slice(0, 1) != '/')
			return `search: regex cannot start with '${test}'.`;

		// Append a / if there wasn't another one (supporting lazy 's/regex' format regex)
		if (query.slice(1).indexOf('/') == -1)
			query += '/';

		let regex_body = query.slice(1); // Slice off beginning slash.
		regex_body = regex_body.slice(0, regex_body.indexOf('/')); // Slice off ending slash too.

		// go after the regex body length (including first `/`), then skip the second `/`.
		let flags = query.slice((regex_body.length + 1) + 1);

		// Make sure it has global flag -- it's faster than manual iteration.
		if (flags.indexOf('g') != -1)
			flags += 'g';

		// Doing it any other way than try...catch would be manual,
		// and doing this manually would increase code complexity immensely.
		// Instead, I just attempt and see if it errors.

		let regex;

		try { // Try compiling the regex.
			regex = new RegExp(regex_body, flags);
		}
		catch (SyntaxError) { // If it fails, tell the user.
			return `search: invalid regex: /${regex_body}/${flags}`;
		}

		// Conveniently, MediaWiki places the body of the wiki page under a specific class.
		let elementSearched = document.body.getElementsByClassName("mw-body")[0];
		let matches = this.doActualSearching(elementSearched, regex);

		let count = matches.tooMany ? "too many" : matches.length;

		let str = `Regex ${regex.toString()} produced ${count} results`;

		if (!matches.length)
			return str + '.';
		else
		{
			str += ':';
			str += '\n';
			str += '\nMatches:';

			for (let [index, match] of Object.entries(matches))
			{
				if (isNaN(parseInt(index)))
					continue;

				let matchText = match[0]; // only interested in full text
				let matchInput = match.input; // the original text matched against

				let matchStart = match.index; // the index in the original text the matched text starts
				let matchEnd = matchStart + matchText.length; // where it ends

				let matchCenter = Math.round(matchStart + matchText.length/2); // the index the center is closest to

				// Define the window based on the visibleMatched constant.
				// Math.round here make sure the center index is consistent when floor'd and ceil'd,
				// The Math.floor and Math.ceil make sure the most accurate integer length is chosen.
				let visibleStart = Math.floor(matchCenter - visibleMatched/2);
				let visibleEnd = Math.ceil(matchCenter + visibleMatched/2);

				// Bounds enforcement
				visibleStart = (visibleStart < 0) ? 0 : visibleStart; // Don't go below the first character
				visibleEnd = (visibleEnd > matchInput.length-1) ? matchInput.length-1 : visibleEnd; // Don't go past the last character

				let n;

				// Find a previous newline if one exists. If it does, and it's in the visible range, clip it.
				if ((n = matchInput.lastIndexOf('\n', matchStart - 1)) != -1)
					if (n >= visibleStart)
						visibleStart = n + 1;

				// Find a next newline if one exists. If it does, and it's in the visible range, clip it.
				if ((n = matchInput.indexOf('\n', matchEnd)) != -1)
					if (n <= visibleEnd)
						visibleEnd = n;

				let matchShown = ""; // The shown portion of the text, which includes the match.

				// Add ellipsis to the beginning if there's more of this line to see.
				if (visibleStart != 0 && matchInput.charAt(visibleStart - 1) != '\n')
					matchShown += "...";

				// Append the visible section.
				matchShown += matchInput.substring(visibleStart, visibleEnd);

				// Add ellipsis to the end if there's more of this line to see.
				if (visibleEnd != matchInput.length - 1 && matchInput.charAt(visibleEnd) != '\n')
					matchShown += "...";

				// And print.
				str += `\n${parseInt(index) + 1}: ${matchShown}`
			}

			if (matches.tooMany)
				str += `\n... looks like infinite matches. Fix your regex!`
		}

		return str;
	}

	goToSection(section) {
		let ret = encodeURI(section);
		window.location.hash = ret;
	}
}

let vimmyWikiNav;

let autoopen_keys = {
	"?": "s/",
	"S": "s/",
	"#": "#"
}

function vwn_keyup(event)
{
	if (event.key == "Escape")
		return vimmyWikiNav.toggle("");

	if (event.key == "Enter")
		return vimmyWikiNav.closeWindow();

	let char = autoopen_keys[event.key];

	if (typeof char !== "undefined" && event.ctrlKey)
	{
		event.preventDefault();
		return vimmyWikiNav.open(char);
	}
}

class VimmyWikiNav {
	constructor() {
		this.opened = false;
		this.inputElement = null;
		this.windowElement = null;
		this.stringToParse = "";
		this.commandParser = new VWN_Command();
	}

	execute() {
		this.stringToParse = this.inputElement.value;
		this.close();

		let char = this.stringToParse.slice(0, 1);
		let args = this.stringToParse.slice(1);

		this.commandParser.run(char, args).then((output) => {
			if (typeof output !== "undefined")
			{
				this.constructWindowElement(output);

				document.body.insertBefore(this.windowElement, document.body.firstChild);
				this.windowElement.focus();
			}
		});
	}

	constructInputElement() {
		if (this.inputElement)
			return;

		this.inputElement = document.createElement("input");

		this.inputElement.parentVimmyWikiNav = this;
		this.inputElement.addEventListener("keyup", function(event)
		{
			let element = event.target;

			if (event.key == "Enter" && element.parentVimmyWikiNav)
				element.parentVimmyWikiNav.execute();
		})

		this.inputElement.id = "vim-textbox";

		this.inputElement.style.position = "fixed";
		this.inputElement.style.top = "20px";
		this.inputElement.style.left = "10%";
		this.inputElement.style.width = "80%";
		this.inputElement.style.zIndex = "1";

		this.inputElement.style.backgroundColor = input_bg;
		this.inputElement.style.color = input_fg;

		this.inputElement.style.fontSize = "24px";
		this.inputElement.style.fontFamily = "monospace";
	}

	constructWindowElement(text) {
		if (this.windowElement)
			return;

		this.windowElement = document.createElement("div");

		this.windowElement.parentVimmyWikiNav = this;
		this.windowElement.id = "vim-window";

		this.windowElement.style.backgroundColor = window_bg;
		this.windowElement.style.position = "fixed";
		this.windowElement.style.top = "10%";
		this.windowElement.style.left = "10%";
		this.windowElement.style.width = "80%";
		this.windowElement.style.height = "80%";
		this.windowElement.style.zIndex = "1";

		this.windowElement.style.boxShadow = "10px 10px 10px grey";
		this.windowElement.style.borderRadius = "12px";

		this.windowElement.onblur = () => {
			document.body.removeChild(this.windowElement);
			this.windowElement = null;
		}

		let closeButton = document.createElement("button");
		closeButton.style.top = "3%";
		closeButton.style.left = "92%";
		closeButton.style.width = "4%";
		closeButton.style.height = "6%";
		closeButton.style.position = "absolute";

		closeButton.style.backgroundColor = window_fg;
		closeButton.style.color = window_bg;

		closeButton.innerText = "×";

		closeButton.onclick = () => {
			document.body.removeChild(this.windowElement);
			this.windowElement = null;
		}

		closeButton.addEventListener("keyup", (event) => {
			if (event.key == "Enter")
				event.target.click();
		});

		this.windowElement.appendChild(closeButton);

		closeButton.focus();

		let innerDiv = document.createElement("div");
		innerDiv.style.top = "5%";
		innerDiv.style.height = "100%";
		innerDiv.style.zIndex = "-1";

		innerDiv.style.overflow = "auto";

		this.windowElement.appendChild(innerDiv);

		let code = document.createElement("p");
		code.innerText = text;

		code.style.fontFamily = "monospace";
		code.style.fontSize = "16px";
		code.style.margin = "8px 8px 8px 8px";
		code.style.color = window_fg;

		innerDiv.appendChild(code);

		code.focus();
	}

	closeWindow()
	{
		if (this.windowElement)
		{
			document.body.removeChild(this.windowElement);
			this.windowElement = null;
			return;
		}
	}

	toggle(start) {
		this.closeWindow();

		this.opened = !this.opened;
		this.constructInputElement();

		this.inputElement.value = start ? start : "";

		if (this.opened)
		{
			document.body.insertBefore(this.inputElement, document.body.firstChild);
			this.inputElement.focus();
		}
		else
		{
			document.body.removeChild(this.inputElement);
			document.body.focus();
		}
	}

	open(start) {
		if (this.opened)
			return;

		this.toggle(start);
	}

	close(start) {
		if (!this.opened)
			return;

		this.toggle(start);
	}
}

vimmyWikiNav = new VimmyWikiNav();

document.body.addEventListener("keydown", vwn_keyup);