Render Whitespace on GitHub

Renders spaces as · and tabs as → in all the code on GitHub.

2017-09-28 기준 버전입니다. 최신 버전을 확인하세요.

  1. /**
  2. The MIT License (MIT)
  3.  
  4. Copyright (c) 2017 Gleb Mazovetskiy
  5.  
  6. Permission is hereby granted, free of charge, to any person obtaining a copy of
  7. this software and associated documentation files (the "Software"), to deal in
  8. the Software without restriction, including without limitation the rights to
  9. use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
  10. the Software, and to permit persons to whom the Software is furnished to do so,
  11. subject to the following conditions:
  12.  
  13. The above copyright notice and this permission notice shall be included in all
  14. copies or substantial portions of the Software.
  15.  
  16. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  17. IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
  18. FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
  19. COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
  20. IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
  21. CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  22. **/
  23. // ==UserScript==
  24. // @id RenderWhitespace
  25. // @name Render Whitespace on GitHub
  26. // @description Renders spaces as · and tabs as → in all the code on GitHub.
  27. // @namespace https://github.com/glebm
  28. // @version 1.3.6
  29. // @author Gleb Mazovetskiy <glex.spb@gmail.com>
  30. // @domain github.com
  31. // @domain gist.github.com
  32. // @match https://gist.github.com/*
  33. // @match https://github.com/*
  34. // @homepageUrl https://github.com/glebm/render-whitespace-on-github
  35. // @run-at document-end
  36. // @contributionURL https://etherchain.org/account/0x962644db6d8735446c1af84a2c1f16143f780184
  37. // ==/UserScript==
  38.  
  39. // Settings
  40. let settings;
  41. const DEFAULTS = {
  42. whitespaceOpacity: 0.4,
  43. copyableWhitespace: false,
  44. space: '·',
  45. tab: '→',
  46. };
  47.  
  48. // Constants
  49. const WS_CLASS = 'glebm-ws';
  50. const ROOT_SELECTOR = 'table[data-tab-size],div[data-tab-size]';
  51. const NODE_FILTER = {
  52. acceptNode(node) {
  53. let parent = node.parentNode;
  54. if (parent.classList.contains(WS_CLASS)) return NodeFilter.FILTER_SKIP;
  55. while (!(parent.dataset && parent.dataset.tabSize)) {
  56. if ( /* mobile code */
  57. parent.classList.contains('js-file-line') ||
  58. /* desktop code, diff; mobile diff */
  59. parent.classList.contains('blob-code-inner')) {
  60. return !(parent.firstChild === node && node.nodeValue === ' ') ?
  61. NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP;
  62. }
  63. parent = parent.parentNode;
  64. }
  65. return NodeFilter.FILTER_SKIP;
  66. }
  67. };
  68.  
  69. function main() {
  70. const styleNode = document.createElement('style');
  71. styleNode.textContent = settings.copyableWhitespaceIndicators ?
  72. `.${WS_CLASS} { opacity: ${settings.whitespaceOpacity}; }` :
  73. `.${WS_CLASS} { position: relative; }
  74. .${WS_CLASS}::before {
  75. opacity: ${settings.whitespaceOpacity};
  76. position: absolute;
  77. text-indent: 0;
  78. top: 0;
  79. line-height: normal;
  80. }`;
  81. document.head.appendChild(styleNode);
  82.  
  83. // github/legacy/pages/diffs/expander
  84. const diffTableObserver = new MutationObserver((records) => {
  85. for (const record of records) {
  86. showWhitespaceIn(record.target.parentElement);
  87. }
  88. });
  89. const initDiffExpanders = () => {
  90. for (const node of document.querySelectorAll('.diff-table > tbody')) {
  91. diffTableObserver.observe(node, { childList: true });
  92. }
  93. };
  94. document.addEventListener('pjax:success', () => {
  95. diffTableObserver.disconnect();
  96. });
  97.  
  98. // https://github.com/github/include-fragment-element
  99. const registeredFragments = new WeakSet();
  100. const onFragmentLoadEnd = (node) => {
  101. return () => {
  102. setTimeout(() => {
  103. for (const root of node.querySelectorAll(ROOT_SELECTOR)) {
  104. showWhitespaceIn(root);
  105. }
  106. }, 0);
  107. };
  108. }
  109. const initFragments = () => {
  110. for (const node of document.querySelectorAll('include-fragment')) {
  111. if (registeredFragments.has(node)) continue;
  112. registeredFragments.add(node);
  113. node.addEventListener('loadend', onFragmentLoadEnd(node.parentElement));
  114. }
  115. }
  116.  
  117. const initDOM = () => {
  118. for (const root of document.querySelectorAll(ROOT_SELECTOR)) {
  119. showWhitespaceIn(root);
  120. }
  121. initDiffExpanders();
  122. initFragments();
  123. };
  124. document.addEventListener('pjax:success', initDOM);
  125. initDOM();
  126. }
  127.  
  128. function showWhitespaceIn(root) {
  129. const tab = settings.tab.padEnd(+root.dataset.tabSize);
  130. const treeWalker =
  131. document.createTreeWalker(root, NodeFilter.SHOW_TEXT, NODE_FILTER);
  132. const nodes = [];
  133. while (treeWalker.nextNode()) nodes.push(treeWalker.currentNode);
  134.  
  135. const isDiff = /* desktop */ root.classList.contains('diff-table') ||
  136. /* mobile */ root.classList.contains('file-diff');
  137. for (const node of nodes) replaceWhitespace(node, tab, settings.space, isDiff);
  138. }
  139.  
  140. function isSpace(char) {
  141. return /* desktop */ char === ' ' ||
  142. /* mobile */ char === '\xa0' /* &nbsp; */;
  143. }
  144.  
  145. function replaceWhitespace(node, tab, space, isDiff) {
  146. let originalText = node.nodeValue;
  147. const parent = node.parentNode;
  148. const ignoreFirstSpace = isDiff &&
  149. isSpace(originalText.charAt(0)) &&
  150. parent.classList.contains('blob-code-inner') &&
  151. parent.firstChild === node;
  152. if (ignoreFirstSpace) {
  153. if (isSpace(originalText)) return;
  154. originalText = originalText.slice(1);
  155. parent.insertBefore(document.createTextNode(' '), node);
  156. }
  157. const tabParts = originalText.split('\t');
  158. const tabSpaceParts = tabParts.map(s => s.split(/[ \xa0]/));
  159. if (!ignoreFirstSpace && tabSpaceParts.length === 1 &&
  160. tabSpaceParts[0].length === 1) return;
  161. const insert = (newNode) => {
  162. parent.insertBefore(newNode, node);
  163. };
  164. insertParts(tabSpaceParts,
  165. spaceParts => spaceParts.length === 1 && spaceParts[0] === '',
  166. n => insert(createWhitespaceNode('t', '\t', tab, n)),
  167. spaceParts =>
  168. insertParts(spaceParts,
  169. text => text === '',
  170. n => insert(createWhitespaceNode('s', ' ', space, n)),
  171. text => insert(document.createTextNode(text))));
  172. parent.removeChild(node);
  173. }
  174.  
  175.  
  176. var WS_ADDED_STYLES = new Set();
  177. function createWhitespaceNode(type, originalText, text, n) {
  178. const node = document.createElement('span');
  179. node.classList.add(WS_CLASS);
  180. if (settings.copyableWhitespaceIndicators) {
  181. node.textContent = text.repeat(n);
  182. } else {
  183. const className = `${type}-${n}`;
  184. if (!WS_ADDED_STYLES.has(className)) {
  185. const styleNode = document.createElement('style');
  186. styleNode.textContent =
  187. `.${WS_CLASS}-${className}::before { content: '${text.repeat(n)}'; }`;
  188. document.head.appendChild(styleNode);
  189. WS_ADDED_STYLES.add(className);
  190. }
  191. node.classList.add(`${WS_CLASS}-${className}`);
  192. node.textContent = originalText.repeat(n);
  193. }
  194. return node;
  195. }
  196.  
  197. function insertParts(parts, isConsecutiveFn, addInterFn, addPartFn) {
  198. const n = parts.length;
  199. parts.reduce((consecutive, part, i) => {
  200. const isConsecutive = isConsecutiveFn(part);
  201. if (isConsecutive && i !== n - 1) return consecutive + 1;
  202. if (consecutive > 0) addInterFn(consecutive);
  203. if (!isConsecutive) addPartFn(part);
  204. return 1;
  205. }, 0);
  206. }
  207.  
  208. function onSettingsLoaded(result) {
  209. settings = result;
  210. main();
  211. }
  212.  
  213. if (typeof browser !== 'undefined' && typeof browser.storage !== 'undefined') {
  214. browser.storage.sync.get(DEFAULTS).then(onSettingsLoaded);
  215. } else if (typeof chrome !== 'undefined' && typeof chrome.storage !== 'undefined') {
  216. chrome.storage.sync.get(DEFAULTS, onSettingsLoaded);
  217. } else {
  218. onSettingsLoaded(DEFAULTS);
  219. }