// ==UserScript==
// @name GitHub Custom Navigation
// @version 1.0.14
// @description A userscript that allows you to customize GitHub's main navigation bar
// @license MIT
// @author Rob Garrison
// @namespace https://github.com/Mottie
// @include https://github.com/*
// @include https://gist.github.com/*
// @run-at document-end
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_setValue
// @icon https://github.com/fluidicon.png
// @require https://cdnjs.cloudflare.com/ajax/libs/dragula/3.7.2/dragula.js
// @downloadURL none
// ==/UserScript==
(() => {
"use strict";
// open menu via hash
const panelHash = "#github-custom-nav-settings",
// get user name; or empty string if not logged in
user = $("meta[name='user-login']") &&
$("meta[name='user-login']").getAttribute("content") || "",
defaults = {
github: [
"pr", "issues", "gist", "separator", "stars", "watching", "separator",
"profile", "blog", "menu"
],
gists: [
"gistall", "giststars", "github", "separator", "pr", "issues", "stars",
"watching", "separator", "profile", "blog", "menu"
],
currentLink: "pr",
// using full length url so the links work from any subdomain (e.g. gist pages)
items: {
"advsearch": {
url: "https://github.com/search/advanced",
tooltip: "Advanced Search",
hotkey: "",
content: ""
},
"blog": {
url: "https://github.com/blog",
tooltip: "Blog",
hotkey: "",
content: ""
},
"explore": {
url: "https://github.com/explore",
tooltip: "Explore",
hotkey: "",
content: ""
},
"gist": {
url: "https://gist.github.com/",
tooltip: "Gist",
hotkey: "",
content: ""
},
"gistall": {
url: "https://gist.github.com/discover",
tooltip: "Discover Gists",
hotkey: "",
content: ""
},
"giststars": {
url: "https://gist.github.com/${me}/starred",
tooltip: "Starred Gists",
hotkey: "",
content: ""
},
"github": {
url: "https://github.com",
tooltip: "GitHub",
hotkey: "",
content: ""
},
"integrations": {
url: "https://github.com/integrations",
tooltip: "Integrations",
hotkey: "",
content: ""
},
"issues": {
url: "https://github.com/issues",
tooltip: "Issues",
hotkey: "g i",
content: ""
},
"menu": {
url: panelHash,
tooltip: "Open Custom Navigation Settings",
hotkey: "",
content: ""
},
"pr": {
url: "https://github.com/pulls",
tooltip: "Pull Requests",
hotkey: "g p",
content: ""
},
"profile": {
url: "https://github.com/${me}",
tooltip: "Profile",
hotkey: "",
content: ""
},
"settings": {
url: "https://github.com/settings/profile",
tooltip: "Settings",
hotkey: "",
content: ""
},
"stars": {
url: "https://github.com/stars",
tooltip: "Stars",
hotkey: "",
content: ""
},
"trending": {
url: "https://github.com/trending",
tooltip: "Trending",
hotkey: "",
content: ""
},
"watching": {
url: "https://github.com/watching",
tooltip: "Watching",
hotkey: "",
content: ""
},
"zenhub": {
url: "#todo",
tooltip: "ZenHub ToDo",
hotkey: "",
content: ""
}
}
},
icons = {
add: "",
close: "",
info: "",
separator: ""
};
let drake,
editMode = false,
panelHashTriggered = false,
// remember scrollTop when settings panel opens (if using sticky nav header
// style)
scrollTop = 0,
settings = GM_getValue("custom-links", defaults);
function addPanel() {
GM_addStyle(`
/* Use border right when a vertical bar is added */
.header-navlink.ghcn-separator { border-right:#777 1px solid;
padding:4px 0; }
/* settings panel */
#ghcn-overlay { position:fixed; top:50px; left:0; right:0; bottom:0;
z-index:45; background:rgba(0,0,0,.5); display:none; }
#ghcn-menu { cursor:pointer; }
.ghcn-close, .ghcn-code { float:right; cursor:pointer; font-size:.8em;
margin-left:3px; padding:0 6px 2px 6px; }
.ghcn-close .octicon { vertical-align:middle; fill:currentColor; }
#ghcn-settings-inner { position:fixed; left:50%; top:60px; z-index:50;
width:30rem; transform:translate(-50%,0); box-shadow:0 .5rem 1rem #111;
color:#c0c0c0; display:none; }
#ghcn-settings-inner input { width:85%; float:right; border-style:solid;
border-width:1px; max-height:35px; }
.ghcn-settings-wrapper div { line-height:38px; }
#ghcn-nav-items { min-height: 38px; }
#ghcn-nav-items .header-nav-item { margin-bottom:4px; }
.ghcn-settings-wrapper hr { margin: 10px 0; }
.ghcn-footer { margin-top:4px; border-top:#555 solid 1px; }
.header-navlink { height:28px; }
ul.header-nav .header-navlink svg,
ul.header-nav .header-navlink img,
#ghcn-nav-items .header-navlink svg,
#ghcn-nav-items .header-navlink img, .gu-mirror svg, .gu-mirror img {
max-height:16px; fill:currentColor; vertical-align:middle;
overflow:visible; }
/* override white text when settings panel is open*/
body.ghcn-settings-open #ghcn-nav-items .text-emphasized {
color: #24292e; }
/* panel open */
body.ghcn-settings-open {
overflow:hidden !important; /* !important overrides wiki style */ }
/* hide other header elements while settings is open (overflow issues) */
body.ghcn-settings-open .header-search,
body.ghcn-settings-open #user-links.d-flex,
body.ghcn-settings-open .header-logo-invertocat,
body.ghcn-settings-open .header-logo-wordmark,
.gist-header .octicon-logo-github, /* hide GitHub logo on Gist page */
.zh-todo-link { display:none !important; }
body.ghcn-settings-open ul.header-nav { width:100%; }
body.ghcn-settings-open .header-navlink > * { pointer-events:none; }
body.ghcn-settings-open #ghcn-overlay,
body.ghcn-settings-open #ghcn-settings-inner,
#ghcn-nav-items { display:block; }
body.ghcn-settings-open ul.header-nav .header-nav-item,
.ghcn-settings-wrapper .header-nav-item { cursor:move;
border:#555 1px solid; border-radius:4px; margin-left: 2px;
display:inline-block; }
body.ghcn-settings-open .header-navlink,
.ghcn-settings-wrapper .header-navlink { min-height:auto;
min-width:16px; padding-top:1px; }
body.ghcn-settings-open .header .header-navlink.form-control {
background-color: transparent; border: 1px solid #444; }
/* JSON code block */
.ghcn-json-code { display:none; font-family:Menlo, Inconsolata,
"Droid Mono", monospace; font-size:1em; }
.ghcn-visible { display:block; position:absolute; top:38px; bottom:0;
left:2px; right:2px; z-index:1;
width:476px; max-width:476px; }
/* Dragula.min.css v3.7.2 (Microsoft definitions removed) */
.gu-mirror { position:fixed !important; margin:0 !important;
z-index:9999 !important; opacity:.8; list-style:none; }
.gu-hide { display:none !important; }
.gu-unselectable { -webkit-user-select:none !important;
-moz-user-select:none !important; user-select:none !important; }
.gu-transit { opacity:.2; }
`);
make({
el: "div",
appendTo: "body",
attr: {
id: "ghcn-settings"
},
html: `
GitHub Custom Navigation Settings
`
});
}
function updatePanel() {
let indx, item, inNav, inSettings,
panelStr = "#ghcn-nav-items",
panel = $(panelStr),
setItems = settings[getLocation()],
keys = Object.keys(settings.items),
len = keys.length;
for (indx = 0; indx < len; indx++) {
item = keys[indx];
inNav = setItems.indexOf(item) > -1;
inSettings = $(panelStr + ` .header-nav-item[data-ghcn="${item}"]`);
// customize adds stuff to main nav
if (inNav && inSettings) {
panel.removeChild(inSettings);
} else if (!inNav && !inSettings) {
addToMenu(item, panelStr);
}
}
if (!$(panelStr + " .header-nav-item[data-ghcn='separator']")) {
addToMenu("separator", panelStr);
}
selectItem();
}
function openPanel() {
scrollTop = document.documentElement.scrollTop;
window.scrollTo(0, 0);
$("body").classList.add("ghcn-settings-open");
editMode = true;
customize();
$(".modal-backdrop").click();
$(".ghcn-json-code").classList.remove("ghcn-visible");
}
function openPanelOnHash() {
if (!panelHashTriggered && window.location.hash === panelHash) {
panelHashTriggered = true;
openPanel();
// immediately remove the hash because I noticed issues where the "#" was
// removed; and upon reload, a 404 page is shown because
// "https://github.com/github-custom-navigation-settings" does not exist
history.pushState("", document.title, window.location.pathname);
panelHashTriggered = false;
}
}
function closePanel() {
if (editMode) {
window.scrollTo(0, scrollTop);
$("body").classList.remove("ghcn-settings-open");
editMode = false;
customize();
$(".ghcn-json-code").classList.remove("ghcn-visible");
}
}
function getLocation() {
// used by "settings" object
return window.location.hostname === "gist.github.com" ? "gists" : "github";
}
// continually destroying & reapplying Dragula sometimes ignores elements;
// so just leave it always applied
function addDragula() {
let topNav = $(".header-nav");
drake = dragula($$(".header-nav, #ghcn-nav-items"), {
invalid: () => {
return !editMode;
}
});
drake.on("drop", () => {
let indx, link,
temp = [],
list = topNav.childNodes,
len = list.length;
for (indx = 0; indx < len; indx++) {
link = list[indx].getAttribute("data-ghcn");
if (link) {
temp[temp.length] = link;
}
}
settings[getLocation()] = temp;
GM_setValue("custom-links", settings);
updatePanel();
});
}
// Clicked item; show selection
function selectItem() {
// highlight current link
let temp = $$(".header-navlink.focus");
removeClass(temp, "focus");
temp = $$(".header-nav-item[data-ghcn='" + (settings.currentLink || "") +
"'] .header-navlink");
if (temp[0]) {
addClass(temp, "focus");
updateLink(temp[0].parentNode);
}
}
// New Link button pressed
function createLink() {
let name = findUniqueId("custom");
settings.items[name] = {
url: "",
tooltip: "",
hotkey: "",
content: "*"
};
addToMenu(name, "#ghcn-nav-items");
settings.currentLink = name;
selectItem();
}
// append named list item to menu
function addToMenu(name, target) {
let html,
item = settings.items[name] || {},
url = (item.url || "").replace(/\$\{me\}/g, user),
linkClass = "text-emphasized header-navlink " +
(editMode ? "" : "js-selected-navigation-item");
// only show tooltip if defined
if (item.tooltip) {
linkClass += " tooltipped tooltipped-s";
if (/(
|
)/g.test(item.tooltip)) {
linkClass += " tooltipped-multiline";
}
}
if (name === "separator") {
html = editMode ?
// *** Separator (icon in editMode; zero-width-space when not)
`${icons.separator}` :
``;
} else {
html = editMode ?
`${item.content}` :
// GitHub might get upset, but we're not going to bother with analytics;
// not including "data-ga-click" nor "data-selected-links" attributes
`
${item.content}
`;
}
make({
el: "li",
appendTo: target,
attr: {
"data-ghcn": name
},
cl4ss: "header-nav-item",
html: html
});
}
// Destroy button pressed
function destroyLink(item) {
if (item) {
delete settings.items[item];
GM_setValue("custom-links", settings);
let el,
indx = settings.github.indexOf(item);
if (indx >= 0) {
settings.github.splice(indx, 1);
}
indx = settings.gists.indexOf(item);
if (indx >= 0) {
settings.gists.splice(indx, 1);
}
el = $(`.header-nav-item[data-ghcn="${item}"]`);
el.parentNode.removeChild(el);
if ((settings.currentLink || "") === item) {
settings.currentLink = "";
}
updateLink();
}
}
// Reset button pressed or new JSON added
function resetLinks(newSettings) {
if (newSettings) {
settings = newSettings;
} else {
// quick n'dirty deep merge
let str = JSON.stringify(defaults);
settings = JSON.parse(str);
}
GM_setValue("custom-links", settings);
// remove extra items individually; dragula doesn't seem to like it when we
// use innerHTML = ""
let item,
els = $$(".header-nav-item"),
indx = els.length;
while (indx--) {
item = els[indx].getAttribute("data-ghcn");
if (item !== "separator" && !settings.items.hasOwnProperty(item)) {
destroyLink(item);
}
}
customize();
}
// Clicked item; update input values
function updateLink(el) {
let item = el && el.getAttribute("data-ghcn") || "",
link = settings.items[item] || {};
settings.currentLink = item;
$(".ghcn-url").value = link.url || "";
$(".ghcn-tooltip").value = link.tooltip || "";
$(".ghcn-hotkey").value = link.hotkey || "";
$(".ghcn-content").value = link.content || "";
// "separator" shouldn't show options
$(".ghcn-settings-wrapper form").style.visibility = item === "separator" ?
"hidden" :
"visible";
}
// save changes on-the-fly
function saveLink() {
let name = settings.currentLink || "",
item = $(`.header-nav-item[data-ghcn="${name}"] .header-navlink`);
if (name) {
settings.items[name] = {
url: $(".ghcn-url").value,
tooltip: $(".ghcn-tooltip").value,
hotkey: $(".ghcn-hotkey").value,
content: $(".ghcn-content").value
};
GM_setValue("custom-links", settings);
// update item (should be unique)
if (item) {
// "\n" is the only thing that works as a carriage return for
// javascript's setAttribute; see
// http://wowmotty.blogspot.com/2014/04/methods-to-add-multi-line-css-content.html
item.setAttribute(
"aria-label",
settings.items[name].tooltip.replace(/(
|
)/g, "\n")
);
item.innerHTML = settings.items[name].content;
}
}
}
function addJSON() {
$(".ghcn-json-code").value = JSON.stringify(settings, null, 2);
}
function processJSON() {
let val,
txt = $(".ghcn-json-code").value;
try {
val = JSON.parse(txt);
} catch (err) {
console.error("GitHub Custom Navigation: Invalid JSON!");
}
return val;
}
function addBindings() {
// Create a menu entry
let el,
menu = make({
el: "a",
cl4ss: "dropdown-item",
html: "Custom Nav Settings",
attr: {
id: "ghcn-menu"
}
});
el = $$(`
.header .dropdown-item[href='/settings/profile'],
.header .dropdown-item[data-ga-click*='go to profile']`
);
// get last found item - gists only have the "go to profile" item; GitHub
// has both
el = el[el.length - 1];
if (el) {
// insert after
el.parentNode.insertBefore(menu, el.nextSibling);
on($("#ghcn-menu"), "click", () => {
openPanel();
});
}
on(window, "hashchange", () => {
openPanelOnHash();
});
on($("#ghcn-overlay"), "click", event => {
// ignore bubbled up events
if (event.target.id === "ghcn-overlay") {
closePanel();
}
});
on($("body"), "keyup", event => {
// using F2 key for testing
if (editMode && event.keyCode === 27) {
closePanel();
}
});
on($("body"), "click", event => {
const target = event.target;
if (editMode && target.classList.contains("header-navlink")) {
// header-navlink is a child of header-nav-item, but is the same size
settings.currentLink = target.parentNode.getAttribute("data-ghcn");
selectItem();
}
});
on($$(".ghcn-settings-wrapper input"), "input change", () => {
saveLink();
});
on($(".ghcn-add"), "click", () => {
createLink();
});
on($(".ghcn-destroy"), "click", () => {
destroyLink(settings.currentLink);
});
on($(".ghcn-reset"), "click", () => {
resetLinks();
});
// close panel when hotkey link is clicked or the page scrolls on the
// documentation wiki
on($$(".ghcn-close, .ghcn-hotkey-link"), "click", () => {
closePanel();
});
// Code
on($(".ghcn-code"), "click", () => {
// open JSON code textarea
$(".ghcn-json-code").classList.toggle("ghcn-visible");
addJSON();
});
// close JSON code textarea
on($(".ghcn-json-code"), "focus", function() {
this.select();
});
on($(".ghcn-json-code"), "paste", () => {
setTimeout(() => {
checkJSON(processJSON());
}, 200);
});
}
function checkJSON(val, init) {
let hasGitHub = false,
hasGists = false,
hasItems = false;
if (val) {
hasGitHub = val.hasOwnProperty("github");
hasGists = val.hasOwnProperty("gists");
hasItems = val.hasOwnProperty("items");
// simple validation
if (hasGitHub && hasGists && hasItems) {
if (!init) {
resetLinks(val);
$(".ghcn-json-code").classList.remove("ghcn-visible");
selectItem();
}
return true;
}
}
let msg = [];
if (!hasGitHub) {
msg.push(`"github"`);
}
if (!hasGists) {
msg.push(`"gists"`);
}
if (!hasItems) {
msg.push(`"items"`);
}
msg = msg.length ? "JSON is missing " + msg.join(" & ") : "Invalid JSON";
console.error("GitHub Custom Navigation: " + msg, val);
return false;
}
// add new link; needs a unique ID
function findUniqueId(prefix) {
let indx = 0,
id = prefix + indx;
if (settings.items[id]) {
while (settings.items[id]) {
id = prefix + indx++;
}
}
return id;
}
// Main process - adds links to header navigation
function customize() {
let nav = $(".header ul[role='navigation']");
if (nav) {
nav.classList.add("header-nav");
let indx, els,
navStr = ".header-nav",
setItems = settings[getLocation()],
len = setItems.length;
if (!len) {
return;
}
els = nav.childNodes;
indx = els.length;
while (indx--) {
nav.removeChild(els[indx]);
}
for (indx = 0; indx < len; indx++) {
addToMenu(setItems[indx], navStr);
}
// make sure all svg's have an "octicon" class name
addClass($$(navStr + " svg"), "octicon");
if (editMode) {
updatePanel();
}
}
}
function $(selector, el) {
return (el || document).querySelector(selector);
}
function $$(selector, el) {
return Array.from((el || document).querySelectorAll(selector));
}
function addClass(els, name) {
let indx = els.length;
while (indx--) {
els[indx].classList.add(name);
}
}
function removeClass(els, name) {
let indx = els.length;
while (indx--) {
els[indx].classList.remove(name);
}
}
function on(els, name, callback) {
els = Array.isArray(els) ? els : [els];
let events = name.split(/\s+/);
els.forEach(el => {
events.forEach(ev => {
el.addEventListener(ev, callback);
});
});
}
function make(obj) {
let key,
el = document.createElement(obj.el);
if (obj.cl4ss) {
el.className = obj.cl4ss;
}
if (obj.html) {
el.innerHTML = obj.html;
}
if (obj.attr) {
for (key in obj.attr) {
if (obj.attr.hasOwnProperty(key)) {
el.setAttribute(key, obj.attr[key]);
}
}
}
if (obj.appendTo) {
$(obj.appendTo).appendChild(el);
}
return el;
}
let isValid = checkJSON(settings, "init");
if (!isValid) {
resetLinks();
}
customize();
addPanel();
addBindings();
addDragula();
openPanelOnHash();
})();