Greasy Fork 还支持 简体中文。

GitHub Custom Hotkeys

A userscript that allows you to add custom GitHub keyboard hotkeys

目前為 2017-03-13 提交的版本,檢視 最新版本

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