GitHub Custom Hotkeys

A userscript that allows you to add custom GitHub keyboard hotkeys

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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();
})();