GitHub Custom Hotkeys

A userscript that allows you to add custom GitHub keyboard hotkeys

当前为 2016-07-22 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name GitHub Custom Hotkeys
  3. // @version 0.2.5
  4. // @description A userscript that allows you to add custom GitHub keyboard hotkeys
  5. // @license https://creativecommons.org/licenses/by-sa/4.0/
  6. // @namespace http://github.com/Mottie
  7. // @include https://github.com/*
  8. // @include https://*.github.com/*
  9. // @grant GM_addStyle
  10. // @grant GM_getValue
  11. // @grant GM_setValue
  12. // @run-at document-idle
  13. // @author Rob Garrison
  14. // ==/UserScript==
  15. /* global GM_addStyle, GM_getValue, GM_setValue */
  16. /*jshint unused:true */
  17. (function() {
  18. "use strict";
  19. /* "g p" here overrides the GitHub default "g p" which takes you to the Pull Requests page
  20. {
  21. "all": [
  22. { "f1" : "#hotkey-settings" },
  23. { "g g": "{repo}/graphs" },
  24. { "g p": "{repo}/pulse" },
  25. { "g u": "{user}" },
  26. { "g s": "{upstream}" }
  27. ],
  28. "{repo}/issues": [
  29. { "g right": "{issue+1}" },
  30. { "g left" : "{issue-1}" }
  31. ],
  32. "{root}/search": [
  33. { "g right": "{page+1}" },
  34. { "g left" : "{page-1}" }
  35. ]
  36. }
  37. */
  38. var data = GM_getValue("github-hotkeys", {
  39. "all": [
  40. { "f1" : "#hotkey-settings" }
  41. ]
  42. }),
  43.  
  44. 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/buunguyen/octotree/blob/master/src/adapters/github.js#L1-L10
  54. // and https://github.com/buunguyen/octotree/issues/304
  55. nonUser = new RegExp("(" + [
  56. "about",
  57. "account",
  58. "blog",
  59. "business",
  60. "contact",
  61. "dashboard",
  62. "developer",
  63. "explore",
  64. "features",
  65. "integrations",
  66. "issues",
  67. "join",
  68. "mirrors",
  69. "new",
  70. "notifications",
  71. "organizations",
  72. "open-source",
  73. "personal",
  74. "pricing",
  75. "pulls",
  76. "search",
  77. "security",
  78. "settings",
  79. "showcases",
  80. "site",
  81. "stars",
  82. "styleguide",
  83. "trending",
  84. "watching",
  85. ].join("|") + ")"),
  86.  
  87. getUrlParts = function() {
  88. var loc = window.location,
  89. root = "https://github.com",
  90. parts = {
  91. root : root,
  92. origin : loc.origin,
  93. page : ""
  94. },
  95. // pathname "should" always start with a "/"
  96. tmp = loc.pathname.split("/");
  97. // me
  98. parts.m = document.querySelector("meta[name='user-login']").getAttribute("content") || "";
  99. parts.me = parts.me ? parts.root + "/" + parts.m : "";
  100. // user name
  101. if (nonUser.test(tmp[1] || "")) {
  102. // invalid user! clear out the values
  103. tmp = [];
  104. }
  105. parts.u = tmp[1] || "";
  106. parts.user = tmp[1] ? root + "/" + tmp[1] : "";
  107. // repo name
  108. parts.r = tmp[2] || "";
  109. parts.repo = tmp[1] && tmp[2] ? parts.user + "/" + tmp[2] : "";
  110. // tab?
  111. parts.t = tmp[3] || "";
  112. parts.tab = tmp[3] ? parts.repo + "/" + tmp[3] : "";
  113. if (parts.t === "issues") {
  114. // issue number
  115. parts.issue = tmp[4] || "";
  116. }
  117. // forked from
  118. tmp = document.querySelector(".repohead .fork-flag a");
  119. parts.upstream = tmp ? tmp.getAttribute("href") : "";
  120. // current page
  121. if (loc.search.match(/[&?]q=/)) {
  122. tmp = loc.search.match(/[&?]p=(\d+)/);
  123. parts.page = tmp ? tmp[1] || "1" : "1";
  124. }
  125. return parts;
  126. },
  127.  
  128. // pass true to initialize; false to remove everything
  129. checkScope = function() {
  130. var key, url,
  131. parts = getUrlParts();
  132. removeElms(document.querySelector("body"), ".ghch-link");
  133. for (key in data) {
  134. if (data.hasOwnProperty(key)) {
  135. url = fixUrl(parts, key === "all" ? "{root}" : key);
  136. if (window.location.href.indexOf(url) > -1) {
  137. debug("Checking custom hotkeys for " + key);
  138. addHotkeys(parts, url, data[key]);
  139. }
  140. }
  141. }
  142. },
  143.  
  144. fixUrl = function(parts, url) {
  145. var valid = true; // use true in case a full URL is used
  146. url = url
  147. // allow {issues+#} to go inc or desc
  148. .replace(/\{issue([\-+]\d+)?\}/, function(s, n) {
  149. var val = n ? parseInt(parts.issue || "", 10) + parseInt(n, 10) : "";
  150. valid = val !== "" && val > 0;
  151. return valid ? parts.tab + "/" + val : "";
  152. })
  153. // allow {page+#} to change results page
  154. .replace(/\{page([\-+]\d+)?\}/, function(s, n) {
  155. var search,
  156. loc = window.location,
  157. val = n ? parseInt(parts.page || "", 10) + parseInt(n, 10) : "";
  158. valid = val !== "" && val > 0;
  159. if (valid) {
  160. search = loc.origin + loc.pathname;
  161. if (loc.search.match(/[&?]p?=\d+/)) {
  162. search += loc.search.replace(/([&?]p=)\d+/, function(s, n) {
  163. return n + val;
  164. });
  165. } else {
  166. // started on page 1 (no &p=1) available to replace
  167. search += loc.search + "&p=" + val;
  168. }
  169. }
  170. return valid ? search : "";
  171. })
  172. // replace placeholders
  173. .replace(/\{\w+\}/gi, function(matches) {
  174. var val = parts[matches.replace(/[{}]/g, "")] || "";
  175. valid = val !== "";
  176. return val;
  177. });
  178. return valid ? url : "";
  179. },
  180.  
  181. removeElms = function(src, selector) {
  182. var links = src.querySelectorAll(selector),
  183. len = links.length;
  184. while(len--) {
  185. src.removeChild(links[len]);
  186. }
  187. },
  188.  
  189. addHotkeys = function(parts, scope, hotkeys) {
  190. // Shhh, don't tell anyone, but GitHub checks the data-hotkey attribute
  191. // of any link on the page, so we only need to add dummy links :P
  192. var indx, url, key, link,
  193. len = hotkeys.length,
  194. body = document.querySelector("body");
  195. for (indx = 0; indx < len; indx++) {
  196. key = Object.keys(hotkeys[indx])[0];
  197. url = fixUrl(parts, hotkeys[indx][key]);
  198. if (url) {
  199. link = document.createElement("a");
  200. link.className = "ghch-link";
  201. link.href = url;
  202. link.setAttribute("data-hotkey", key);
  203. body.appendChild(link);
  204. debug("Adding '" + key + "' keyboard hotkey linked to: " + url);
  205. }
  206. }
  207. },
  208.  
  209. addHotkey = function(el) {
  210. var li = document.createElement("li");
  211. li.className = "ghch-hotkey-set";
  212. li.innerHTML = templates.hotkey + templates.remove;
  213. el.parentNode.insertBefore(li, el);
  214. return li;
  215. },
  216.  
  217. addScope = function(el) {
  218. var scope = document.createElement("fieldset");
  219. scope.className = "ghch-scope-custom";
  220. scope.innerHTML = "<legend><span class='simple-box' contenteditable>Enter Scope</span>&nbsp;" +
  221. templates.remove +
  222. "</legend>" +
  223. templates.scope;
  224. el.parentNode.insertBefore(scope, el);
  225. return scope;
  226. },
  227.  
  228. addMenu = function() {
  229. GM_addStyle([
  230. "#ghch-open-menu { cursor:pointer; }",
  231. "#ghch-menu { position:fixed; z-index: 65535; top:0; bottom:0; left:0; right:0; opacity:0; visibility:hidden; }",
  232. "#ghch-menu.in { opacity:1; visibility:visible; background:rgba(0,0,0,.5); }",
  233. "#ghch-settings-inner { position:fixed; left:50%; top:50%; transform:translate(-50%,-50%); width:25rem; box-shadow:0 .5rem 1rem #111; }",
  234. "#ghch-settings-inner h3 .btn { float:right; font-size:.8em; padding:0 6px 2px 6px; margin-left:3px; }",
  235. ".ghch-remove, .ghch-remove svg, #ghch-settings-inner .ghch-close svg { vertical-align:middle; cursor:pointer; }",
  236. ".ghch-menu-inner { max-height:60vh; overflow-y:auto; }",
  237. ".ghch-menu-inner ul { list-style:none; }",
  238. ".ghch-menu-inner li { margin-bottom:4px; }",
  239. ".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; }",
  240. ".ghch-scope-add, .ghch-hotkey-add { border:2px dashed #555; border-radius:4px; opacity:0.6; text-align:center; cursor:pointer; margin-top:10px; }",
  241. ".ghch-scope-add:hover, .ghch-hotkey-add:hover { opacity:1; }",
  242. ".ghch-menu-inner legend span { padding:0 6px; min-width:30px; border:0; }",
  243. ".ghch-hotkey { width:60px; }",
  244. ".ghch-menu-inner li .ghch-remove { margin-left:10px; }",
  245. ".ghch-menu-inner li .ghch-remove:hover, .ghch-menu-inner legend .ghch-remove:hover { color:#800; }",
  246. ".ghch-json-code { display:none; font-family:Menlo, Inconsolata, 'Droid Mono', monospace; font-size:1em; }",
  247. ".ghch-json-code.in { 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; }"
  248. ].join(""));
  249.  
  250. // add menu
  251. var tmp,
  252. inner = [
  253. "<div id='ghch-settings-inner' class='boxed-group'>",
  254. "<h3>",
  255. "GitHub Custom Hotkey Settings",
  256. "<button type='button' class='btn btn-sm ghch-close tooltipped tooltipped-n' aria-label='Close';>" + templates.remove + "</button>",
  257. "<button type='button' class='ghch-code btn btn-sm tooltipped tooltipped-n' aria-label='Toggle JSON data view'>{ }</button>",
  258. "<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>",
  259. "</h3>",
  260. "<div class='ghch-menu-inner boxed-group-inner'>",
  261. "<fieldset class='ghch-scope-all'>",
  262. "<legend><span class='simple-box' data-scope='all'>All of GitHub &amp; subdomains</span></legend>",
  263. templates.scope,
  264. "</fieldset>",
  265. "<div class='ghch-scope-add'>+ Click to add a new scope</div>",
  266. "<textarea class='ghch-json-code'></textarea>",
  267. "</div>",
  268. "</div>"
  269. ].join(""),
  270.  
  271. menu = document.createElement("div");
  272. menu.id = "ghch-menu";
  273. menu.innerHTML = inner;
  274. document.querySelector("body").appendChild(menu);
  275. // Create our menu entry
  276. menu = document.createElement("a");
  277. menu.id = "ghch-open-menu";
  278. menu.className = "dropdown-item";
  279. menu.innerHTML = "GitHub Hotkey Settings";
  280.  
  281. tmp = document.querySelectorAll(".header .dropdown-item[href='/settings/profile'], .header .dropdown-item[data-ga-click*='go to profile']");
  282. if (tmp) {
  283. tmp[tmp.length - 1].parentNode.insertBefore(menu, tmp[tmp.length - 1].nextSibling);
  284. }
  285. addBindings();
  286. },
  287.  
  288. openPanel = function() {
  289. updateMenu();
  290. document.querySelector("#ghch-menu").classList.add("in");
  291. return false;
  292. },
  293.  
  294. closePanel = function() {
  295. var menu = document.querySelector("#ghch-menu");
  296. if (menu.classList.contains("in")) {
  297. // update data in case a "change" event didn't fire
  298. refreshData();
  299. checkScope();
  300. menu.classList.remove("in");
  301. menu.querySelector(".ghch-json-code").classList.remove("in");
  302. window.location.hash = "";
  303. return false;
  304. }
  305. },
  306.  
  307. addJSON = function() {
  308. var textarea = document.querySelector(".ghch-json-code");
  309. textarea.value = JSON
  310. .stringify(data, null, 2)
  311. // compress JSON a little
  312. .replace(/\n \}/g, " }")
  313. .replace(/\{\n /g, "{ ");
  314. },
  315.  
  316. processJSON = function() {
  317. var val,
  318. textarea = document.querySelector(".ghch-json-code"),
  319. txt = textarea.value;
  320. try {
  321. val = JSON.parse(txt);
  322. data = val;
  323. } catch (err) {}
  324. },
  325.  
  326. updateMenu = function() {
  327. var indx, len, hotkeys, key, scope, tmp, target, selector,
  328. menu = document.querySelector(".ghch-menu-inner");
  329. removeElms(menu, ".ghch-scope-custom");
  330. removeElms(menu.querySelector(".ghch-scope-all ul"), ".ghch-hotkey-set");
  331. // Add scopes
  332. for (key in data) {
  333. if (data.hasOwnProperty(key)) {
  334. if (key === "all") {
  335. selector = "all";
  336. scope = menu.querySelector(".ghch-scope-all .ghch-hotkey-add");
  337. } else if (key !== selector) {
  338. selector = key;
  339. scope = addScope(document.querySelector(".ghch-scope-add"));
  340. scope.querySelector("legend span").innerHTML = key;
  341. scope = scope.querySelector(".ghch-hotkey-add");
  342. }
  343. // add hotkey entries
  344. hotkeys = data[key];
  345. len = hotkeys.length;
  346. for (indx = 0; indx < len; indx++) {
  347. target = addHotkey(scope);
  348. tmp = Object.keys(hotkeys[indx])[0];
  349. target.querySelector(".ghch-hotkey").value = tmp;
  350. target.querySelector(".ghch-url").value = hotkeys[indx][tmp];
  351. }
  352. }
  353. }
  354. },
  355.  
  356. refreshData = function() {
  357. var tmp, scope, scopes, sIndx, sLen, hotkeys, scIndx, scLen, val,
  358. menu = document.querySelector(".ghch-menu-inner");
  359. data = {};
  360. scopes = menu.querySelectorAll("fieldset");
  361. sLen = scopes.length;
  362. for (sIndx = 0; sIndx < sLen; sIndx++) {
  363. tmp = scopes[sIndx].querySelector("legend span");
  364. if (tmp) {
  365. scope = tmp.getAttribute("data-scope") || tmp.textContent.trim();
  366. hotkeys = scopes[sIndx].querySelectorAll(".ghch-hotkey-set");
  367. scLen = hotkeys.length;
  368. data[scope] = [];
  369. for (scIndx = 0; scIndx < scLen; scIndx++) {
  370. tmp = hotkeys[scIndx].querySelectorAll("input");
  371. val = tmp[0] && tmp[0].value || "";
  372. if (val) {
  373. data[scope][scIndx] = {};
  374. data[scope][scIndx][val] = tmp[1].value || "";
  375. }
  376. }
  377. }
  378. }
  379. GM_setValue("github-hotkeys", data);
  380. debug("Data refreshed", data);
  381. },
  382.  
  383. lastHref = window.location.href,
  384. addBindings = function() {
  385. var tmp,
  386. menu = document.querySelector("#ghch-menu");
  387.  
  388. // open menu
  389. document.querySelector("#ghch-open-menu").addEventListener("click", openPanel);
  390. // close menu
  391. menu.addEventListener("click", closePanel);
  392. document.querySelector("body").addEventListener("keydown", function(event) {
  393. if (event.which === 27) {
  394. closePanel();
  395. }
  396. });
  397. // stop propagation
  398. menu.querySelector("#ghch-settings-inner").addEventListener("keydown", function(event) {
  399. event.stopPropagation();
  400. });
  401. menu.querySelector("#ghch-settings-inner").addEventListener("click", function(event) {
  402. event.stopPropagation();
  403. var target = event.target;
  404. // add hotkey
  405. if (target.classList.contains("ghch-hotkey-add")) {
  406. addHotkey(target);
  407. } else if (target.classList.contains("ghch-scope-add")) {
  408. addScope(target);
  409. }
  410. // svg & path nodeName may be lowercase
  411. tmp = target.nodeName.toLowerCase();
  412. if (tmp === "path") {
  413. target = target.parentNode;
  414. }
  415. // target should now point at svg
  416. if (target.classList.contains("ghch-remove")) {
  417. tmp = target.parentNode;
  418. // remove fieldset
  419. if (tmp.nodeName === "LEGEND") {
  420. tmp = tmp.parentNode;
  421. }
  422. // remove li; but not the button in the header
  423. if (tmp.nodeName !== "BUTTON") {
  424. tmp.parentNode.removeChild(tmp);
  425. refreshData();
  426. }
  427. }
  428. });
  429. menu.addEventListener("change", refreshData);
  430. // contenteditable scope title
  431. menu.addEventListener("input", function(event) {
  432. if (event.target.classList.contains("simple-box")) {
  433. refreshData();
  434. }
  435. });
  436. menu.querySelector("button.ghch-close").addEventListener("click", closePanel);
  437. // open JSON code textarea
  438. tmp = menu.querySelector(".ghch-code");
  439. tmp.addEventListener("click", function() {
  440. menu.querySelector(".ghch-json-code").classList.toggle("in");
  441. addJSON();
  442. });
  443. // close JSON code textarea
  444. tmp = menu.querySelector(".ghch-json-code");
  445. tmp.addEventListener("focus", function() {
  446. this.select();
  447. });
  448. tmp.addEventListener("paste", function() {
  449. setTimeout(function() {
  450. processJSON();
  451. updateMenu();
  452. document.querySelector(".ghch-json-code").classList.remove("in");
  453. }, 200);
  454. });
  455.  
  456. // This is crazy! But window.location.search changes do not fire the
  457. // "popstate" or "hashchange" event, so we're stuck with a setInterval
  458. setInterval(function() {
  459. var loc = window.location;
  460. if (lastHref !== loc.href) {
  461. lastHref = loc.href;
  462. checkScope();
  463. // open panel via hash
  464. if (loc.hash === openHash) {
  465. openPanel();
  466. }
  467. }
  468. }, 1000);
  469. },
  470.  
  471. // include a "debug" anywhere in the browser URL (search parameter) to enable debugging
  472. debug = function() {
  473. if (/debug/.test(window.location.search)) {
  474. console.log.apply(console, arguments);
  475. }
  476. };
  477.  
  478. // initialize
  479. checkScope();
  480. addMenu();
  481.  
  482. })();