GitHub Custom Hotkeys

A userscript that allows you to add custom GitHub keyboard hotkeys

当前为 2020-03-29 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name GitHub Custom Hotkeys
  3. // @version 1.0.26
  4. // @description A userscript that allows you to add custom GitHub keyboard hotkeys
  5. // @license MIT
  6. // @author Rob Garrison
  7. // @namespace https://github.com/Mottie
  8. // @include https://github.com/*
  9. // @include https://*.github.com/*
  10. // @run-at document-idle
  11. // @grant GM_addStyle
  12. // @grant GM_getValue
  13. // @grant GM_setValue
  14. // @icon https://github.githubassets.com/pinned-octocat.svg
  15. // ==/UserScript==
  16. (() => {
  17. "use strict";
  18. /* "g p" here overrides the GitHub default "g p" which takes you to the Pull Requests page
  19. {
  20. "all": [
  21. { "f1" : "#hotkey-settings" },
  22. { "g g": "{repo}/graphs/code-frequency" },
  23. { "g p": "{repo}/pulse" },
  24. { "g u": "{user}" },
  25. { "g s": "{upstream}" }
  26. ],
  27. "{repo}/issues": [
  28. { "g right": "{issue+1}" },
  29. { "g left" : "{issue-1}" }
  30. ],
  31. "{root}/search": [
  32. { "g right": "{page+1}" },
  33. { "g left" : "{page-1}" }
  34. ]
  35. }
  36. */
  37. let data = GM_getValue("github-hotkeys", {
  38. all: [{
  39. f1: "#hotkey-settings"
  40. }]
  41. }),
  42. lastHref = window.location.href;
  43.  
  44. const openHash = "#hotkey-settings",
  45.  
  46. templates = {
  47. remove: "<svg class='ghch-remove 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>",
  48. hotkey: "Hotkey: <input type='text' class='ghch-hotkey form-control'>&nbsp; URL: <input type='text' class='ghch-url form-control'>",
  49. scope: "<ul><li class='ghch-hotkey-add'>+ Click to add a new hotkey</li></ul>"
  50. },
  51.  
  52. // https://github.com/{nonUser}
  53. // see https://github.com/Mottie/github-reserved-names
  54. nonUser = new RegExp("^(" + [
  55. /* BUILD:RESERVED-NAMES-START (v1.1.8) */
  56. "400", "401", "402", "403", "404", "405", "406", "407", "408", "409",
  57. "410", "411", "412", "413", "414", "415", "416", "417", "418", "419",
  58. "420", "421", "422", "423", "424", "425", "426", "427", "428", "429",
  59. "430", "431", "500", "501", "502", "503", "504", "505", "506", "507",
  60. "508", "509", "510", "511", "about", "access", "account", "admin",
  61. "anonymous", "any", "api", "apps", "attributes", "auth", "billing",
  62. "blob", "blog", "bounty", "branches", "business", "businesses", "c",
  63. "cache", "case-studies", "categories", "central", "certification",
  64. "changelog", "cla", "cloud", "codereview", "collection",
  65. "collections", "comments", "commit", "commits", "community",
  66. "companies", "compare", "contact", "contributing", "cookbook",
  67. "coupons", "customer", "customers", "dashboard", "dashboards",
  68. "design", "develop", "developer", "diff", "discover", "discussions",
  69. "docs", "downloads", "downtime", "editor", "editors", "edu",
  70. "enterprise", "events", "explore", "featured", "features", "files",
  71. "fixtures", "forked", "garage", "ghost", "gist", "gists", "graphs",
  72. "guide", "guides", "help", "help-wanted", "home", "hooks", "hosting",
  73. "hovercards", "identity", "images", "inbox", "individual", "info",
  74. "integration", "interfaces", "introduction", "invalid-email-address",
  75. "investors", "issues", "jobs", "join", "journal", "journals", "lab",
  76. "labs", "languages", "launch", "layouts", "learn", "legal", "library",
  77. "linux", "listings", "lists", "login", "logos", "logout", "mac",
  78. "maintenance", "malware", "man", "marketplace", "mention",
  79. "mentioned", "mentioning", "mentions", "migrating", "milestones",
  80. "mine", "mirrors", "mobile", "navigation", "network", "new", "news",
  81. "none", "nonprofit", "nonprofits", "notices", "notifications",
  82. "oauth", "offer", "open-source", "organisations", "organizations",
  83. "orgs", "pages", "partners", "payments", "personal", "plans",
  84. "plugins", "popular", "popularity", "posts", "press", "pricing",
  85. "professional", "projects", "pulls", "raw", "readme",
  86. "recommendations", "redeem", "releases", "render", "reply",
  87. "repositories", "resources", "restore", "revert",
  88. "save-net-neutrality", "saved", "scraping", "search", "security",
  89. "services", "sessions", "settings", "shareholders", "shop",
  90. "showcases", "signin", "signup", "site", "spam", "sponsors", "ssh",
  91. "staff", "starred", "stars", "static", "status", "statuses",
  92. "storage", "store", "stories", "styleguide", "subscriptions",
  93. "suggest", "suggestion", "suggestions", "support", "suspended",
  94. "talks", "teach", "teacher", "teachers", "teaching", "teams", "ten",
  95. "terms", "timeline", "topic", "topics", "tos", "tour", "train",
  96. "training", "translations", "tree", "trending", "updates", "username",
  97. "users", "visualization", "w", "watching", "wiki", "windows",
  98. "works-with", "www0", "www1", "www2", "www3", "www4", "www5", "www6",
  99. "www7", "www8", "www9"
  100. /* BUILD:RESERVED-NAMES-END */
  101. ].join("|") + ")$");
  102.  
  103. function getUrlParts() {
  104. const loc = window.location,
  105. root = "https://github.com",
  106. parts = {
  107. root,
  108. origin: loc.origin,
  109. page: ""
  110. };
  111. // me
  112. let tmp = $("meta[name='user-login']");
  113. parts.m = tmp && tmp.getAttribute("content") || "";
  114. parts.me = parts.m ? parts.root + "/" + parts.m : "";
  115.  
  116. // pathname "should" always start with a "/"
  117. tmp = loc.pathname.split("/");
  118.  
  119. // user name
  120. if (nonUser.test(tmp[1] || "")) {
  121. // invalid user! clear out the values
  122. tmp = [];
  123. }
  124. parts.u = tmp[1] || "";
  125. parts.user = tmp[1] ? root + "/" + tmp[1] : "";
  126. // repo name
  127. parts.r = tmp[2] || "";
  128. parts.repo = tmp[1] && tmp[2] ? parts.user + "/" + tmp[2] : "";
  129. // tab?
  130. parts.t = tmp[3] || "";
  131. parts.tab = tmp[3] ? parts.repo + "/" + tmp[3] : "";
  132. if (parts.t === "issues" || parts.t === "pulls") {
  133. // issue number
  134. parts.issue = tmp[4] || "";
  135. }
  136. // branch/tag?
  137. if (parts.t === "tree" || parts.t === "blob") {
  138. parts.branch = tmp[4] || "";
  139. } else if (parts.t === "releases" && tmp[4] === "tag") {
  140. parts.branch = tmp[5] || "";
  141. }
  142. // commit hash?
  143. if (parts.t === "commit") {
  144. parts.commit = tmp[4] || "";
  145. }
  146. // forked from
  147. tmp = $(".repohead .fork-flag a");
  148. parts.upstream = tmp ? tmp.getAttribute("href") : "";
  149. // current page
  150. tmp = loc.search.match(/[&?]p(?:age)?=(\d+)/);
  151. parts.page = tmp ? tmp[1] || "1" : "";
  152. return parts;
  153. }
  154.  
  155. // pass true to initialize; false to remove everything
  156. function checkScope() {
  157. removeElms($("body"), ".ghch-link");
  158. const parts = getUrlParts();
  159. Object.keys(data).forEach(key => {
  160. const url = fixUrl(parts, key === "all" ? "{root}" : key);
  161. if (window.location.href.indexOf(url) > -1) {
  162. debug("Checking custom hotkeys for " + key);
  163. addHotkeys(parts, url, data[key]);
  164. }
  165. });
  166. }
  167.  
  168. function fixUrl(parts, url) {
  169. let valid = true; // use true in case a full URL is used
  170. url = url
  171. // allow {issues+#} to go inc or desc
  172. .replace(/\{issue([\-+]\d+)?\}/, (s, n) => {
  173. const val = n ? parseInt(parts.issue || "", 10) + parseInt(n, 10) : "";
  174. valid = val !== "" && val > 0;
  175. return valid ? parts.tab + "/" + val : "";
  176. })
  177. // allow {page+#} to change results page
  178. .replace(/\{page([\-+]\d+)?\}/, (s, n) => {
  179. const loc = window.location,
  180. val = n ? parseInt(parts.page || "", 10) + parseInt(n, 10) : "";
  181. let search;
  182. valid = val !== "" && val > 0;
  183. if (valid) {
  184. search = loc.origin + loc.pathname;
  185. if (loc.search.match(/[&?]p?=\d+/)) {
  186. search += loc.search.replace(/([&?]p=)\d+/, (s, n) => {
  187. return n + val;
  188. });
  189. } else {
  190. // started on page 1 (no &p=1) available to replace
  191. search += loc.search + "&p=" + val;
  192. }
  193. }
  194. return valid ? search : "";
  195. })
  196. // replace placeholders
  197. .replace(/\{\w+\}/gi, matches => {
  198. const val = parts[matches.replace(/[{}]/g, "")] || "";
  199. valid = val !== "";
  200. return val;
  201. });
  202. return valid ? url : "";
  203. }
  204.  
  205. function removeElms(src, selector) {
  206. const links = $$(selector, src);
  207. let len = links.length;
  208. while (len--) {
  209. src.removeChild(links[len]);
  210. }
  211. }
  212.  
  213. function addHotkeys(parts, scope, hotkeys) {
  214. // Shhh, don't tell anyone, but GitHub checks the data-hotkey attribute
  215. // of any link on the page, so we only need to add dummy links :P
  216. let indx, url, key, link;
  217. const len = hotkeys.length,
  218. body = $("body");
  219. for (indx = 0; indx < len; indx++) {
  220. key = Object.keys(hotkeys[indx])[0];
  221. url = fixUrl(parts, hotkeys[indx][key]);
  222. if (url) {
  223. link = document.createElement("a");
  224. link.className = "ghch-link";
  225. link.href = url;
  226. link.setAttribute("data-hotkey", key);
  227. body.appendChild(link);
  228. debug("Adding '" + key + "' keyboard hotkey linked to: " + url);
  229. }
  230. }
  231. }
  232.  
  233. function addHotkey(el) {
  234. const li = document.createElement("li");
  235. li.className = "ghch-hotkey-set";
  236. li.innerHTML = templates.hotkey + templates.remove;
  237. el.parentNode.insertBefore(li, el);
  238. return li;
  239. }
  240.  
  241. function addScope(el) {
  242. const scope = document.createElement("fieldset");
  243. scope.className = "ghch-scope-custom";
  244. scope.innerHTML = `
  245. <legend>
  246. <span class="simple-box" contenteditable>Enter Scope</span>&nbsp;
  247. ${templates.remove}
  248. </legend>
  249. ${templates.scope}
  250. `;
  251. el.parentNode.insertBefore(scope, el);
  252. return scope;
  253. }
  254.  
  255. function addMenu() {
  256. GM_addStyle(`
  257. #ghch-open-menu { cursor:pointer; }
  258. #ghch-menu { position:fixed; z-index: 65535; top:0; bottom:0; left:0; right:0; opacity:0; visibility:hidden; }
  259. #ghch-menu.ghch-open { opacity:1; visibility:visible; background:rgba(0,0,0,.5); }
  260. #ghch-settings-inner { position:fixed; left:50%; top:50%; transform:translate(-50%,-50%); width:25rem; box-shadow:0 .5rem 1rem #111; }
  261. #ghch-settings-inner h3 .btn { float:right; font-size:.8em; padding:0 6px 2px 6px; margin-left:3px; }
  262. .ghch-remove, .ghch-remove svg, #ghch-settings-inner .ghch-close svg { vertical-align:middle; cursor:pointer; }
  263. .ghch-menu-inner { max-height:60vh; overflow-y:auto; }
  264. .ghch-menu-inner ul { list-style:none; }
  265. .ghch-menu-inner li { white-space:pre; margin-bottom:4px; }
  266. .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; }
  267. .ghch-scope-add, .ghch-hotkey-add { border:2px dashed #555; border-radius:4px; opacity:0.6; text-align:center; cursor:pointer; margin-top:10px; }
  268. .ghch-scope-add:hover, .ghch-hotkey-add:hover { opacity:1; }
  269. .ghch-menu-inner legend span { padding:0 6px; min-width:30px; border:0; }
  270. .ghch-hotkey { width:60px; }
  271. .ghch-menu-inner li .ghch-remove { margin-left:10px; }
  272. .ghch-menu-inner li .ghch-remove:hover, .ghch-menu-inner legend .ghch-remove:hover { color:#800; }
  273. .ghch-json-code { display:none; font-family:Menlo, Inconsolata, 'Droid Mono', monospace; font-size:1em; }
  274. .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; }
  275. `);
  276.  
  277. // add menu
  278. let menu = document.createElement("div");
  279. menu.id = "ghch-menu";
  280. menu.innerHTML = `
  281. <div id="ghch-settings-inner" class="boxed-group">
  282. <h3>
  283. GitHub Custom Hotkey Settings
  284. <button type="button" class="btn btn-sm ghch-close tooltipped tooltipped-n" aria-label="Close";>
  285. ${templates.remove}
  286. </button>
  287. <button type="button" class="ghch-code btn btn-sm tooltipped tooltipped-n" aria-label="Toggle JSON data view">{ }</button>
  288. <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>
  289. </h3>
  290. <div class="ghch-menu-inner boxed-group-inner">
  291. <fieldset class="ghch-scope-all">
  292. <legend>
  293. <span class="simple-box" data-scope="all">All of GitHub &amp; subdomains</span>
  294. </legend>
  295. ${templates.scope}
  296. </fieldset>
  297. <div class="ghch-scope-add">+ Click to add a new scope</div>
  298. <textarea class="ghch-json-code"></textarea>
  299. </div>
  300. </div>
  301. `;
  302. $("body").appendChild(menu);
  303. // Create our menu entry
  304. menu = document.createElement("a");
  305. menu.id = "ghch-open-menu";
  306. menu.className = "dropdown-item";
  307. menu.innerHTML = "GitHub Hotkey Settings";
  308.  
  309. const els = $$(`
  310. .header .dropdown-item[href="/settings/profile"],
  311. .header .dropdown-item[data-ga-click*="go to profile"],
  312. .Header .dropdown-item[href="/settings/profile"],
  313. .Header .dropdown-item[data-ga-click*="go to profile"],
  314. .js-header-wrapper .dropdown-item[href="/settings/profile"],
  315. .js-header-wrapper .dropdown-item[data-ga-click*="go to profile"]
  316. `);
  317. if (els.length) {
  318. els[els.length - 1].parentNode.insertBefore(menu, els[els.length - 1].nextSibling);
  319. }
  320. addBindings();
  321. }
  322.  
  323. function openPanel() {
  324. updateMenu();
  325. $("#ghch-menu").classList.add("ghch-open");
  326. return false;
  327. }
  328.  
  329. function closePanel() {
  330. const menu = $("#ghch-menu");
  331. if (menu.classList.contains("ghch-open")) {
  332. // update data in case a "change" event didn't fire
  333. refreshData();
  334. checkScope();
  335. menu.classList.remove("ghch-open");
  336. $(".ghch-json-code", menu).classList.remove("ghch-open");
  337. window.location.hash = "";
  338. return false;
  339. }
  340. }
  341.  
  342. function addJSON() {
  343. const textarea = $(".ghch-json-code");
  344. textarea.value = JSON
  345. .stringify(data, null, 2)
  346. // compress JSON a little
  347. .replace(/\n\s{4}\}/g, " }")
  348. .replace(/\{\n\s{6}/g, "{ ");
  349. }
  350.  
  351. function processJSON() {
  352. let val;
  353. const textarea = $(".ghch-json-code");
  354. try {
  355. val = JSON.parse(textarea.value);
  356. data = val;
  357. } catch (err) {}
  358. }
  359.  
  360. function updateMenu() {
  361. const menu = $(".ghch-menu-inner");
  362. removeElms(menu, ".ghch-scope-custom");
  363. removeElms($(".ghch-scope-all ul", menu), ".ghch-hotkey-set");
  364. let scope, selector;
  365. // Add scopes
  366. Object.keys(data).forEach(key => {
  367. if (key === "all") {
  368. selector = "all";
  369. scope = $(".ghch-scope-all .ghch-hotkey-add", menu);
  370. } else if (key !== selector) {
  371. selector = key;
  372. scope = addScope($(".ghch-scope-add"));
  373. $("legend span", scope).innerHTML = key;
  374. scope = $(".ghch-hotkey-add", scope);
  375. }
  376. // add hotkey entries
  377. // eslint-disable-next-line no-loop-func
  378. data[key].forEach(val => {
  379. const target = addHotkey(scope),
  380. tmp = Object.keys(val)[0];
  381. $(".ghch-hotkey", target).value = tmp;
  382. $(".ghch-url", target).value = val[tmp];
  383. });
  384. });
  385. }
  386.  
  387. function refreshData() {
  388. data = {};
  389. let tmp, scope, sIndx, hotkeys, scIndx, scLen, val;
  390. const menu = $(".ghch-menu-inner"),
  391. scopes = $$("fieldset", menu),
  392. sLen = scopes.length;
  393. for (sIndx = 0; sIndx < sLen; sIndx++) {
  394. tmp = $("legend span", scopes[sIndx]);
  395. if (tmp) {
  396. scope = tmp.getAttribute("data-scope") || tmp.textContent.trim();
  397. hotkeys = $$(".ghch-hotkey-set", scopes[sIndx]);
  398. scLen = hotkeys.length;
  399. data[scope] = [];
  400. for (scIndx = 0; scIndx < scLen; scIndx++) {
  401. tmp = $$("input", hotkeys[scIndx]);
  402. val = (tmp[0] && tmp[0].value) || "";
  403. if (val) {
  404. data[scope][scIndx] = {};
  405. data[scope][scIndx][val] = tmp[1].value || "";
  406. }
  407. }
  408. }
  409. }
  410. GM_setValue("github-hotkeys", data);
  411. debug("Data refreshed", data);
  412. }
  413.  
  414. function addBindings() {
  415. let tmp;
  416. const menu = $("#ghch-menu");
  417.  
  418. // open menu
  419. on($("#ghch-open-menu"), "click", openPanel);
  420. // close menu
  421. on(menu, "click", closePanel);
  422. on($("body"), "keydown", event => {
  423. if (event.which === 27) {
  424. closePanel();
  425. }
  426. });
  427. // stop propagation
  428. on($("#ghch-settings-inner", menu), "keydown", event => {
  429. event.stopPropagation();
  430. });
  431. on($("#ghch-settings-inner", menu), "click", event => {
  432. event.stopPropagation();
  433. let target = event.target;
  434. // add hotkey
  435. if (target.classList.contains("ghch-hotkey-add")) {
  436. addHotkey(target);
  437. } else if (target.classList.contains("ghch-scope-add")) {
  438. addScope(target);
  439. }
  440. // svg & path nodeName may be lowercase
  441. tmp = target.nodeName.toLowerCase();
  442. if (tmp === "path") {
  443. target = target.parentNode;
  444. }
  445. // target should now point at svg
  446. if (target.classList.contains("ghch-remove")) {
  447. tmp = target.parentNode;
  448. // remove fieldset
  449. if (tmp.nodeName === "LEGEND") {
  450. tmp = tmp.parentNode;
  451. }
  452. // remove li; but not the button in the header
  453. if (tmp.nodeName !== "BUTTON") {
  454. tmp.parentNode.removeChild(tmp);
  455. refreshData();
  456. }
  457. }
  458. });
  459. on(menu, "change", refreshData);
  460. // contenteditable scope title
  461. on(menu, "input", event => {
  462. if (event.target.classList.contains("simple-box")) {
  463. refreshData();
  464. }
  465. });
  466. on($("button.ghch-close", menu), "click", closePanel);
  467. // open JSON code textarea
  468. on($(".ghch-code", menu), "click", () => {
  469. $(".ghch-json-code", menu).classList.toggle("ghch-open");
  470. addJSON();
  471. });
  472. // close JSON code textarea
  473. tmp = $(".ghch-json-code", menu);
  474. on(tmp, "focus", function () {
  475. this.select();
  476. });
  477. on(tmp, "paste", () => {
  478. setTimeout(() => {
  479. processJSON();
  480. updateMenu();
  481. $(".ghch-json-code").classList.remove("ghch-open");
  482. }, 200);
  483. });
  484.  
  485. // This is crazy! But window.location.search changes do not fire the
  486. // "popstate" or "hashchange" event, so we're stuck with a setInterval
  487. setInterval(() => {
  488. const loc = window.location;
  489. if (lastHref !== loc.href) {
  490. lastHref = loc.href;
  491. checkScope();
  492. // open panel via hash
  493. if (loc.hash === openHash) {
  494. openPanel();
  495. }
  496. }
  497. }, 1000);
  498. }
  499.  
  500. function $(str, el) {
  501. return (el || document).querySelector(str);
  502. }
  503.  
  504. function $$(str, el) {
  505. return Array.from((el || document).querySelectorAll(str));
  506. }
  507.  
  508. function on(els, name, callback) {
  509. els = Array.isArray(els) ? els : [els];
  510. const events = name.split(/\s+/);
  511. els.forEach(el => {
  512. if (el) {
  513. events.forEach(ev => {
  514. el.addEventListener(ev, callback);
  515. });
  516. }
  517. });
  518. }
  519.  
  520. // include a "debug" anywhere in the browser URL (search parameter) to enable debugging
  521. function debug() {
  522. if (/debug/.test(window.location.search)) {
  523. console.log.apply(console, arguments);
  524. }
  525. }
  526.  
  527. // initialize
  528. checkScope();
  529. addMenu();
  530. })();