GitHub Custom Hotkeys

A userscript that allows you to add custom GitHub keyboard hotkeys

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, Greasemonkey alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, % alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, % alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey alebo Userscripts.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie správcu používateľských skriptov.

(Už mám správcu používateľských skriptov, nechajte ma ho nainštalovať!)

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

(Už mám správcu používateľských štýlov, nechajte ma ho nainštalovať!)

// ==UserScript==
// @name        GitHub Custom Hotkeys
// @version     1.1.5
// @description A userscript that allows you to add custom GitHub keyboard hotkeys
// @license     MIT
// @author      Rob Garrison
// @namespace   https://github.com/Mottie
// @match       https://github.com/*
// @match       https://*.github.com/*
// @run-at      document-idle
// @grant       GM_addStyle
// @grant       GM_getValue
// @grant       GM_setValue
// @require     https://greasyfork.org/scripts/398877-utils-js/code/utilsjs.js?version=1079637
// @require     https://greasyfork.org/scripts/28721-mutations/code/mutations.js?version=1108163
// @icon        https://github.githubassets.com/pinned-octocat.svg
// @supportURL  https://github.com/Mottie/GitHub-userscripts/issues
// ==/UserScript==

/* global $ $$ on */
(() => {
	"use strict";
	/* "g p" here overrides the GitHub default "g p" which takes you to the Pull Requests page
	{
		"all": [
			{ "f1" : "#hotkey-settings" },
			{ "g g": "{repo}/graphs/code-frequency" },
			{ "g p": "{repo}/pulse" },
			{ "g u": [ "{user}", true ] },
			{ "g s": "{upstream}" }
		],
		"{repo}/issues": [
			{ "g right": "{issue+1}" },
			{ "g left" : "{issue-1}" }
		],
		"{root}/search": [
			{ "g right": "{page+1}" },
			{ "g left" : "{page-1}" }
		]
	}
	*/
	let data = GM_getValue("github-hotkeys", {
		all: [{
			f1: "#hotkey-settings"
		}]
	});
	let lastHref = window.location.href;

	const openHash = "#hotkey-settings";

	const templates = {
		remove: `<svg class="octicon" fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="9" height="9" viewBox="0 0 9 9"><path d="M9 1L5.4 4.4 9 8 8 9 4.6 5.4 1 9 0 8l3.6-3.5L0 1l1-1 3.5 3.6L8 0l1 1z"/></svg>`,
		hotkey: `
			<label class="tooltipped tooltipped-n" aria-label="hotkey"><input type="text" class="ghch-hotkey form-control"></label>
			<label class="tooltipped tooltipped-n" aria-label="URL"><input type="text" class="ghch-url form-control"></label>
			<label class="tooltipped tooltipped-w" aria-label="Open in a new tab?"><input type="checkbox" class="ghch-new-tab"></label>`,
		scope: "<ul><li><button class='ghch-hotkey-add'>+ Click to add a new hotkey</button></li></ul>"
	};

	// https://github.com/{nonUser}
	// see https://github.com/Mottie/github-reserved-names
	const nonUser = new RegExp("^(" + [
		/* BUILD:RESERVED-NAMES-START (v2.0.4) */
		"400", "401", "402", "403", "404", "405", "406", "407", "408", "409",
		"410", "411", "412", "413", "414", "415", "416", "417", "418", "419",
		"420", "421", "422", "423", "424", "425", "426", "427", "428", "429",
		"430", "431", "500", "501", "502", "503", "504", "505", "506", "507",
		"508", "509", "510", "511", "about", "access", "account", "admin",
		"advisories", "anonymous", "any", "api", "apps", "attributes", "auth",
		"billing", "blob", "blog", "bounty", "branches", "business", "businesses",
		"c", "cache", "case-studies", "categories", "central", "certification",
		"changelog", "cla", "cloud", "codereview", "collection", "collections",
		"comments", "commit", "commits", "community", "companies", "compare",
		"contact", "contributing", "cookbook", "coupons", "customer-stories",
		"customer", "customers", "dashboard", "dashboards", "design", "develop",
		"developer", "diff", "discover", "discussions", "docs", "downloads",
		"downtime", "editor", "editors", "edu", "enterprise", "events", "explore",
		"featured", "features", "files", "fixtures", "forked", "garage", "ghost",
		"gist", "gists", "graphs", "guide", "guides", "help", "help-wanted",
		"home", "hooks", "hosting", "hovercards", "identity", "images", "inbox",
		"individual", "info", "integration", "interfaces", "introduction",
		"invalid-email-address", "investors", "issues", "jobs", "join", "journal",
		"journals", "lab", "labs", "languages", "launch", "layouts", "learn",
		"legal", "library", "linux", "listings", "lists", "login", "logos",
		"logout", "mac", "maintenance", "malware", "man", "marketplace", "mention",
		"mentioned", "mentioning", "mentions", "migrating", "milestones", "mine",
		"mirrors", "mobile", "navigation", "network", "new", "news", "none",
		"nonprofit", "nonprofits", "notices", "notifications", "oauth", "offer",
		"open-source", "organisations", "organizations", "orgs", "pages",
		"partners", "payments", "personal", "plans", "plugins", "popular",
		"popularity", "posts", "press", "pricing", "professional", "projects",
		"pulls", "raw", "readme", "recommendations", "redeem", "releases",
		"render", "reply", "repositories", "resources", "restore", "revert",
		"save-net-neutrality", "saved", "scraping", "search", "security",
		"services", "sessions", "settings", "shareholders", "shop", "showcases",
		"signin", "signup", "site", "spam", "sponsors", "ssh", "staff", "starred",
		"stars", "static", "status", "statuses", "storage", "store", "stories",
		"styleguide", "subscriptions", "suggest", "suggestion", "suggestions",
		"support", "suspended", "talks", "teach", "teacher", "teachers",
		"teaching", "team", "teams", "ten", "terms", "timeline", "topic", "topics",
		"tos", "tour", "train", "training", "translations", "tree", "trending",
		"updates", "username", "users", "visualization", "w", "watching", "wiki",
		"windows", "works-with", "www0", "www1", "www2", "www3", "www4", "www5",
		"www6", "www7", "www8", "www9"
		/* BUILD:RESERVED-NAMES-END */
	].join("|") + ")$");

	function getUrlParts() {
		const loc = window.location;
		const root = "https://github.com";
		const parts = {
			root,
			origin: loc.origin,
			page: ""
		};
		// me
		let tmp = $("meta[name='user-login']");
		parts.m = tmp && tmp.getAttribute("content") || "";
		parts.me = parts.m ? parts.root + "/" + parts.m : "";

		// pathname "should" always start with a "/"
		tmp = loc.pathname.split("/");

		// user name
		if (nonUser.test(tmp[1] || "")) {
			// invalid user! clear out the values
			tmp = [];
		}
		parts.u = tmp[1] || "";
		parts.user = tmp[1] ? root + "/" + tmp[1] : "";
		// repo name
		parts.r = tmp[2] || "";
		parts.repo = tmp[1] && tmp[2] ? parts.user + "/" + tmp[2] : "";
		// tab?
		parts.t = tmp[3] || "";
		parts.tab = tmp[3] ? parts.repo + "/" + tmp[3] : "";
		if (parts.t === "issues" || parts.t === "pulls") {
			// issue number
			parts.issue = tmp[4] || "";
		}
		// branch/tag?
		if (parts.t === "tree" || parts.t === "blob") {
			parts.branch = tmp[4] || "";
		} else if (parts.t === "releases" && tmp[4] === "tag") {
			parts.branch = tmp[5] || "";
		}
		// commit hash?
		if (parts.t === "commit") {
			parts.commit = tmp[4] || "";
		}
		// forked from
		tmp = $(".repohead .fork-flag a");
		parts.upstream = tmp ? tmp.getAttribute("href") : "";
		// current page
		tmp = loc.search.match(/[&?]p(?:age)?=(\d+)/);
		parts.page = tmp ? tmp[1] || "1" : "";
		return parts;
	}

	// pass true to initialize; false to remove everything
	function checkScope() {
		removeElms($("body"), ".ghch-link");
		const parts = getUrlParts();
		Object.keys(data).forEach(key => {
			const url = fixUrl(parts, key === "all" ? "{root}" : key);
			if (window.location.href.indexOf(url) > -1) {
				debug("Checking custom hotkeys for " + key);
				addHotkeys(parts, url, data[key]);
			}
		});
	}

	function fixUrl(parts, url = "") {
		let valid = true; // use true in case a full URL is used
		url = url
			// allow {issues+#} to go inc or desc
			.replace(/\{issue([\-+]\d+)?\}/, (s, n) => {
				const val = n ? parseInt(parts.issue || "", 10) + parseInt(n, 10) : "";
				valid = val !== "" && val > 0;
				return valid ? parts.tab + "/" + val : "";
			})
			// allow {page+#} to change results page
			.replace(/\{page([\-+]\d+)?\}/, (s, n) => {
				const loc = window.location,
					val = n ? parseInt(parts.page || "", 10) + parseInt(n, 10) : "";
				let search = "";
				valid = val !== "" && val > 0;
				if (valid) {
					search = loc.origin + loc.pathname;
					if (loc.search.match(/[&?]p?=\d+/)) {
						search += loc.search.replace(/([&?]p=)\d+/, (s, n) => {
							return n + val;
						});
					} else {
						// started on page 1 (no &p=1) available to replace
						search += loc.search + "&p=" + val;
					}
				}
				return valid ? search : "";
			})
			// replace placeholders
			.replace(/\{\w+\}/gi, matches => {
				const val = parts[matches.replace(/[{}]/g, "")] || "";
				valid = val !== "";
				return val;
			});
		return valid ? url : "";
	}

	function removeElms(src, selector) {
		const links = $$(selector, src);
		let len = links.length;
		while (len-- > 0) {
			src.removeChild(links[len]);
		}
	}

	function addHotkeys(parts, scope, hotkeys) {
		// Shhh, don't tell anyone, but GitHub checks the data-hotkey attribute
		// of any link on the page, so we only need to add dummy links :P
		let indx, url, key, entry, link, isArray;
		const len = hotkeys.length;
		const body = $("body");
		for (indx = 0; indx < len; indx++) {
			key = Object.keys(hotkeys[indx])[0];
			entry = hotkeys[indx][key];
			isArray = Array.isArray(entry);
			url = fixUrl(parts, isArray ? entry[0] : entry);
			if (url) {
				link = document.createElement("a");
				link.className = "ghch-link";
				link.href = url;
				if (isArray) {
					link.target = "_blank";
				}
				link.setAttribute("data-hotkey", key);
				body.appendChild(link);
				debug(`Adding "${key}" keyboard hotkey linked to "${url}"`);
			}
		}
	}

	function addHotkey(el) {
		const li = document.createElement("li");
		li.className = "ghch-hotkey-set";
		li.innerHTML = `
			<div class="ghch-hotkey-wrap">
				${templates.hotkey}
				<button class="ghch-remove">${templates.remove}</button>
			</div>`;
		el.parentElement.before(li);
		return li;
	}

	function addScope(el) {
		const scope = document.createElement("fieldset");
		scope.className = "ghch-scope-custom";
		scope.innerHTML = `
			<legend>
				<span class="simple-box" contenteditable>Enter Scope</span>&nbsp;
				<button class="ghch-remove">${templates.remove}</button>
			</legend>
			${templates.scope}
		`;
		el.parentNode.insertBefore(scope, el);
		return scope;
	}

	function addMenu() {
		GM_addStyle(`
			#ghch-open-menu { cursor:pointer; }
			#ghch-menu { position:fixed; z-index:65535; top:0; bottom:0; left:0; right:0; opacity:0; display:none; }
			#ghch-menu.ghch-open { opacity:1; display:block; background:rgba(0,0,0,.5); }
			#ghch-settings-inner { position:fixed; left:50%; top:50%; transform:translate(-50%,-50%); width:25rem; box-shadow:0 .5rem 1rem #111; }
			#ghch-settings-inner h3 .btn { float:right; font-size:.8em; padding:0 6px 2px 6px; margin-left:3px; }
			.ghch-remove { background:transparent; border:0; white-space:initial; margin-bottom:6px; }
			.ghch-remove svg, #ghch-settings-inner .ghch-close svg { vertical-align:middle; pointer-events:none; }
			.ghch-menu-inner li .ghch-remove { margin-left:0; padding:0; }
			.ghch-menu-inner li .ghch-remove:hover, .ghch-menu-inner legend .ghch-remove:hover { color:#800; }
			.ghch-menu-inner { max-height:60vh; overflow-y:auto; }
			.ghch-menu-inner ul { list-style:none; }
			.ghch-hotkey-wrap, .ghch-hotkey-add { width:100%; display:flex; align-items:center; justify-content:space-evenly; white-space:pre; margin-bottom:4px; }
			.ghch-scope-all, .ghch-scope-add, .ghch-scope-custom { width:100%; border:2px solid rgba(85,85,85,0.5); border-radius:4px; padding:10px; margin:0; }
			.ghch-scope-add, .ghch-hotkey-add { background:transparent; border:2px dashed #555; border-radius:4px; opacity:0.6; text-align:center; cursor:pointer; margin-top:10px; }
			.ghch-scope-add:hover, .ghch-hotkey-add:hover { opacity:1;  }
			.ghch-menu-inner legend span { padding:0 6px; min-width:30px; border:0; }
			.ghch-hotkey { width:80px; }
			.ghch-json-code { display:none; font-family:Menlo, Inconsolata, "Droid Mono", monospace; font-size:1em; }
			.ghch-json-code.ghch-open { position:absolute; top:37px; bottom:0; left:2px; right:2px; z-index:0; width:396px; max-width:396px; max-height:calc(100% - 37px); display:block; }
			.ghch-menu-inner textarea { resize:none; }
		`);

		// add menu
		const menu = document.createElement("div");
		menu.id = "ghch-menu";
		menu.innerHTML = `
			<div id="ghch-settings-inner" class="boxed-group">
				<h3>
					GitHub Custom Hotkey Settings
					<button type="button" class="btn btn-sm ghch-close tooltipped tooltipped-n" aria-label="Close">
						${templates.remove}
					</button>
					<button type="button" class="ghch-code btn btn-sm tooltipped tooltipped-n" aria-label="Toggle JSON data view">{ }</button>
					<a href="https://github.com/Mottie/GitHub-userscripts/wiki/GitHub-custom-hotkeys" class="ghch-help btn btn-sm tooltipped tooltipped-n" aria-label="Get Help">?</a>
				</h3>
				<div class="ghch-menu-inner boxed-group-inner">
					<fieldset class="ghch-scope-all">
						<legend>
							<span class="simple-box" data-scope="all">All of GitHub &amp; subdomains</span>
						</legend>
						${templates.scope}
					</fieldset>
					<button class="ghch-scope-add">+ Click to add a new scope</button>
					<textarea class="ghch-json-code form-control"></textarea>
				</div>
			</div>
		`;
		$("body").appendChild(menu);
		addBindings();
	}

	function openPanel() {
		updateMenu();
		$("#ghch-menu").classList.add("ghch-open");
		return false;
	}

	function closePanel() {
		const menu = $("#ghch-menu");
		if (menu?.classList.contains("ghch-open")) {
			// update data in case a "change" event didn't fire
			refreshData();
			checkScope();
			menu.classList.remove("ghch-open");
			$(".ghch-json-code", menu).classList.remove("ghch-open");
			window.location.hash = "";
			return false;
		}
	}

	function addJSON() {
		const textarea = $(".ghch-json-code");
		textarea.value = JSON
			.stringify(data, null, 2)
			// compress JSON a little
			.replace(/\n\s{4}\}/g, " }")
			.replace(/\{\n\s{6}/g, "{ ")
			.replace(/\[\s{9}/g, "[ ")
			.replace(/\,\s{9}/g, ", ")
			.replace(/\s{7}\]/g, " ]");
	}

	function processJSON() {
		let val;
		const textarea = $(".ghch-json-code");
		try {
			val = JSON.parse(textarea.value);
			data = val;
		} catch (err) {}
	}

	function updateMenu() {
		const menu = $(".ghch-menu-inner");
		if (menu) {
			removeElms(menu, ".ghch-scope-custom");
			removeElms($(".ghch-scope-all ul", menu), ".ghch-hotkey-set");
			let scope, selector;
			// Add scopes
			Object.keys(data).forEach(key => {
				if (key === "all") {
					selector = "all";
					scope = $(".ghch-scope-all .ghch-hotkey-add", menu);
				} else if (key !== selector) {
					selector = key;
					scope = addScope($(".ghch-scope-add"));
					$("legend span", scope).innerHTML = key;
					scope = $(".ghch-hotkey-add", scope);
				}
				// add hotkey entries
				// eslint-disable-next-line no-loop-func
				data[key].forEach(val => {
					const target = addHotkey(scope);
					const tmp = Object.keys(val)[0];
					const entry = val[tmp];
					$(".ghch-hotkey", target).value = tmp;
					if (Array.isArray(entry)) {
						$(".ghch-url", target).value = entry[0];
						$(".ghch-new-tab", target).checked = entry[1]
					} else {
						$(".ghch-url", target).value = entry;
					}
				});
			});
		}
	}

	function refreshData() {
		data = {};
		let tmp, scope, sIndx, hotkeys, scIndx, scLen, val;
		const menu = $(".ghch-menu-inner");
		const scopes = $$("fieldset", menu);
		const sLen = scopes.length;
		for (sIndx = 0; sIndx < sLen; sIndx++) {
			tmp = $("legend span", scopes[sIndx]);
			if (tmp) {
				scope = tmp.getAttribute("data-scope") || tmp.textContent.trim();
				hotkeys = $$(".ghch-hotkey-set", scopes[sIndx]);
				scLen = hotkeys.length;
				data[scope] = [];
				for (scIndx = 0; scIndx < scLen; scIndx++) {
					tmp = $$("input", hotkeys[scIndx]);
					val = (tmp[0] && tmp[0].value) || "";
					if (val) {
						data[scope][scIndx] = {};
						if (tmp[2].checked) {
							data[scope][scIndx][val] = [tmp[1].value || "", true];
						} else {
							data[scope][scIndx][val] = tmp[1].value || "";
						}
					}
				}
			}
		}
		GM_setValue("github-hotkeys", data);
		debug("Data refreshed", data);
	}

	function addDropdownLink() {
		if (!$("#ghch-open-menu")) {
			// Create our menu entry
			const menu = document.createElement("a");
			menu.id = "ghch-open-menu";
			menu.role = "menuitem";
			menu.className = "dropdown-item";
			menu.innerHTML = "GitHub Hotkey Settings";
			menu.onclick = openPanel;

			const els =  $$(".Header-item .dropdown-item[href='/settings/profile']");
			if (els.length) {
				els[els.length - 1].after(menu);
			}
		}
	}

	function addBindings() {
		let tmp;
		const menu = $("#ghch-menu");
		if (!menu) {
			return;
		}

		// close menu
		on(menu, "click", closePanel);
		on($("body"), "keydown", event => {
			if (event.which === 27) {
				closePanel();
			}
		});
		// stop propagation
		on($("#ghch-settings-inner", menu), "keydown", event => {
			event.stopPropagation();
		});
		on($("#ghch-settings-inner", menu), "click", event => {
			event.stopPropagation();
			let target = event.target;
			// add hotkey
			if (target.classList.contains("ghch-hotkey-add")) {
				addHotkey(target);
			} else if (target.classList.contains("ghch-scope-add")) {
				addScope(target);
			}
			// svg & path nodeName may be lowercase
			tmp = target.nodeName.toLowerCase();
			if (tmp === "path") {
				target = target.parentNode;
			}
			// target should now point at svg
			if (target.classList.contains("ghch-remove")) {
				tmp = target.parentNode;
				// remove fieldset
				if (tmp.nodeName === "LEGEND") {
					tmp = tmp.parentNode;
				}
				// remove li; but not the button in the header
				if (tmp.nodeName !== "BUTTON") {
					tmp.parentNode.removeChild(tmp);
					refreshData();
				}
			}
		});
		on(menu, "change", refreshData);
		// contenteditable scope title
		on(menu, "input", event => {
			if (event.target.classList.contains("simple-box")) {
				refreshData();
			}
		});
		on($("button.ghch-close", menu), "click", closePanel);
		// open JSON code textarea
		on($(".ghch-code", menu), "click", () => {
			$(".ghch-json-code", menu).classList.toggle("ghch-open");
			addJSON();
		});
		// close JSON code textarea
		tmp = $(".ghch-json-code", menu);
		on(tmp, "focus", function () {
			this.select();
		});
		on(tmp, "paste", () => {
			setTimeout(() => {
				processJSON();
				updateMenu();
				$(".ghch-json-code").classList.remove("ghch-open");
			}, 200);
		});

		// This is crazy! But window.location.search changes do not fire the
		// "popstate" or "hashchange" event, so we're stuck with a setInterval
		setInterval(() => {
			const loc = window.location;
			if (lastHref !== loc.href) {
				lastHref = loc.href;
				checkScope();
				// open panel via hash
				if (loc.hash === openHash) {
					openPanel();
				}
			}
		}, 1000);
	}

	// include a "debug" anywhere in the browser URL search parameter to enable
	// debugging
	function debug() {
		if (/debug/.test(window.location.search)) {
			console.log.apply(console, arguments);
		}
	}

	on(document, "ghmo:menu", () => {
		// user menu needs to call an API now
		addDropdownLink();
	});

	// initialize
	checkScope();
	addMenu();
})();