GitHub Code Folding

A userscript that adds code folding to GitHub files

// ==UserScript==
// @name        GitHub Code Folding
// @version     1.1.5
// @description A userscript that adds code folding to GitHub files
// @license     MIT
// @author      Rob Garrison
// @namespace   https://github.com/Mottie
// @match       https://github.com/*
// @match       https://gist.github.com/*
// @run-at      document-idle
// @grant       GM.addStyle
// @grant       GM_addStyle
// @require     https://greasemonkey.github.io/gm4-polyfill/gm4-polyfill.js?updated=20180103
// @require     https://greasyfork.org/scripts/28721-mutations/code/mutations.js?version=1108163
// @require     https://greasyfork.org/scripts/398877-utils-js/code/utilsjs.js?version=1079637
// @icon        https://github.githubassets.com/pinned-octocat.svg
// @supportURL  https://github.com/Mottie/GitHub-userscripts/issues
// ==/UserScript==

/* global $ $$ on */
/**
 * This userscript has been heavily modified from the "github-code-folding"
 * Chrome extension Copyright 2016 by Noam Lustiger; under an MIT license
 * https://github.com/noam3127/github-code-folding
 */
(() => {
	"use strict";

	GM.addStyle(`
		td.blob-code.blob-code-inner { position:relative; padding-left:10px; }
		.ghcf-collapser { position:absolute; left:2px; width:10px; cursor:pointer; }
		.ghcf-collapser:after { display: inline-block; vertical-align: middle;
			content:"\u25bc"; opacity:.5; transition:.15s; }
		.ghcf-collapser:hover:after { opacity:1; }
		.ghcf-collapsed.ghcf-collapser:after { transform:rotate(-90deg);
			opacity:.8; }
		.ghcf-hidden-line { display:none; }
		.ghcf-ellipsis { padding:1px 2px; margin-left:2px; cursor:pointer;
			background:rgba(255,235,59,.4); position:relative; z-index:1; }
		.ghcf-ellipsis:hover { background:rgba(255,235,59,.7); }
	`);

	const blocks = {};
	const ellipsis = document.createElement("span");
	const triangle = document.createElement("span");

	triangle.className = "ghcf-collapser";
	ellipsis.className = "pl-smi ghcf-ellipsis";
	ellipsis.innerHTML = "…";

	function countInitialWhiteSpace(arr) {
		const getWhiteSpaceIndex = i => {
			if (arr[i] !== " " && arr[i] !== "\t" && arr[i] !== "\xa0") {
				return i;
			}
			return getWhiteSpaceIndex(++i);
		};
		return getWhiteSpaceIndex(0);
	}

	function getPreviousSpaces(map, lineNum) {
		let prev = map.get(lineNum - 1);
		return prev === -1
			? getPreviousSpaces(map, lineNum - 1)
			: {
				lineNum: lineNum - 1,
				count: prev
			};
	}

	function getLineNumber(el) {
		let elm = el.closest("tr");
		if (elm) {
			elm = elm.querySelector("[data-line-number]");
			return elm ? parseInt(elm.dataset.lineNumber, 10) : "";
		}
		return "";
	}

	function getCodeLines(codeBlock) {
		return $$(".blob-code-inner", codeBlock);
	}

	function toggleCode({ action, codeBlock, index, depth }) {
		let els, lineNums;
		const codeLines = getCodeLines(codeBlock) || [];
		const pairs = blocks[codeBlock.dataset.blockIndex];
		if (!pairs || codeLines.length === 0) {
			return;
		}
		// depth is a string containing a specific depth number to toggle
		if (depth) {
			els = $$(`.ghcf-collapser[data-depth="${depth}"]`, codeBlock);
			lineNums = els.map(el => {
				el.classList.toggle("ghcf-collapsed", action === "hide");
				return getLineNumber(el);
			});
		} else {
			lineNums = [index];
		}

		if (action === "hide") {
			lineNums.forEach(start => {
				let elm;
				let end = pairs.get(start - 1);
				codeLines.slice(start, end).forEach(el => {
					elm = el.closest("tr");
					if (elm) {
						elm.classList.add("ghcf-hidden-line");
					}
				});
				if (!$(".ghcf-ellipsis", codeLines[start - 1])) {
					elm = $(".ghcf-collapser", codeLines[start - 1]);
					elm.parentNode.insertBefore(
						ellipsis.cloneNode(true),
						null
					);
				}
			});
		} else if (action === "show") {
			lineNums.forEach(start => {
				let end = pairs.get(start - 1);
				codeLines.slice(start, end).forEach(el => {
					let elm = el.closest("tr");
					if (elm) {
						elm.classList.remove("ghcf-hidden-line");
						removeEls(".ghcf-ellipsis", elm);
					}
					elm = $(".ghcf-collapsed", elm);
					if (elm) {
						elm.classList.remove("ghcf-collapsed");
					}
				});
				removeEls(".ghcf-ellipsis", codeLines[start - 1]);
			});
		}
		// shift ends up selecting text on the page, so clear it
		if (lineNums.length > 1) {
			removeSelection();
		}
	}

	function addBindings() {
		on(document, "click", event => {
			let index, elm, isCollapsed;
			const el = event.target;
			const codeBlock = el.closest(".highlight");

			// click on collapser
			if (el && el.classList.contains("ghcf-collapser")) {
				isCollapsed = el.classList.contains("ghcf-collapsed");
				index = getLineNumber(el);
				// Shift + click to toggle them all
				if (index && event.getModifierState("Shift")) {
					return toggleCode({
						action: isCollapsed ? "show" : "hide",
						codeBlock,
						index,
						depth: el.dataset.depth
					});
				}
				if (index) {
					if (isCollapsed) {
						el.classList.remove("ghcf-collapsed");
						toggleCode({ action: "show", codeBlock, index });
					} else {
						el.classList.add("ghcf-collapsed");
						toggleCode({ action: "hide", codeBlock, index });
					}
				}
				return;
			}

			// click on ellipsis
			if (el && el.classList.contains("ghcf-ellipsis")) {
				elm = $(".ghcf-collapsed", el.parentNode);
				if (elm) {
					elm.classList.remove("ghcf-collapsed");
				}
				index = getLineNumber(el);
				if (index) {
					toggleCode({ action: "show", codeBlock, index });
				}
			}
		});
	}

	function addCodeFolding() {
		// Keep .file in case someone needs this userscript for GitHub Enterprise
		if ($(".file table.highlight, .blob-wrapper table.highlight")) {
			$$("table.highlight").forEach((codeBlock, blockIndex) => {
				if (codeBlock && codeBlock.classList.contains("ghcf-processed")) {
					// Already processed
					return;
				}
				const codeLines = getCodeLines(codeBlock);
				removeEls("span.ghcf-collapser", codeBlock);
				if (codeLines) {
					// In case this script has already been run and modified the DOM on a
					// previous page in github, make sure to reset it.
					codeBlock.classList.add("ghcf-processed");
					codeBlock.dataset.blockIndex = blockIndex;

					const spaceMap = new Map();
					const stack = [];
					const pairs = blocks[blockIndex] = new Map();

					codeLines.forEach((el, lineNum) => {
						let prevSpaces;
						let line = el.textContent;
						let count = line.trim().length
							? countInitialWhiteSpace(line.split(""))
							: -1;
						spaceMap.set(lineNum, count);

						function tryPair() {
							let el;
							let top = stack[stack.length - 1];
							if (count !== -1 && count <= spaceMap.get(top)) {
								pairs.set(top, lineNum);
								// prepend triangle
								el = triangle.cloneNode();
								el.dataset.depth = count + 1;
								codeLines[top].insertBefore(el, codeLines[top].childNodes[0]);
								stack.pop();
								return tryPair();
							}
						}
						tryPair();

						prevSpaces = getPreviousSpaces(spaceMap, lineNum);
						if (count > prevSpaces.count) {
							stack.push(prevSpaces.lineNum);
						}
					});
				}
			});
		}
	}

	function removeEls(selector, el) {
		let els = $$(selector, el);
		let index = els.length;
		while (index--) {
			els[index].parentNode.removeChild(els[index]);
		}
	}

	function removeSelection() {
		// remove text selection - https://stackoverflow.com/a/3171348/145346
		const sel = window.getSelection
			? window.getSelection()
			: document.selection;
		if (sel) {
			if (sel.removeAllRanges) {
				sel.removeAllRanges();
			} else if (sel.empty) {
				sel.empty();
			}
		}
	}

	on(document, "ghmo:container", addCodeFolding);
	addCodeFolding();
	addBindings();

})();