GitHub Custom Hotkeys

A userscript that allows you to add custom GitHub keyboard hotkeys

当前为 2022-10-24 提交的版本,查看 最新版本

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