GitHub RTL Comment Blocks

A userscript that adds a button to insert RTL text blocks in comments

As of 2016-06-25. See the latest version.

  1. // ==UserScript==
  2. // @name GitHub RTL Comment Blocks
  3. // @version 1.1.1
  4. // @description A userscript that adds a button to insert RTL text blocks in comments
  5. // @license https://creativecommons.org/licenses/by-sa/4.0/
  6. // @namespace http://github.com/Mottie
  7. // @include https://github.com/*
  8. // @run-at document-idle
  9. // @grant GM_addStyle
  10. // @connect github.com
  11. // @author Rob Garrison
  12. // ==/UserScript==
  13. /*jshint unused:true, esnext:true */
  14. /* global GM_addStyle */
  15. (function() {
  16. "use strict";
  17.  
  18. let targets,
  19. busy = false;
  20.  
  21. const icon = `
  22. <svg class="octicon" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14">
  23. <path d="M14 3v8l-4-4m-7 7V6C1 6 0 5 0 3s1-3 3-3h7v2H9v12H7V2H5v12H3z"/>
  24. </svg>`,
  25.  
  26. // maybe using &#x2067; RTL text &#x2066; (isolates) is a better combo?
  27. openRTL = "&rlm;", // https://en.wikipedia.org/wiki/Right-to-left_mark
  28. closeRTL = "&lrm;", // https://en.wikipedia.org/wiki/Left-to-right_mark
  29.  
  30. regexOpen = /\u200f/ig,
  31. regexClose = /\u200e/ig,
  32. regexSplit = /(\u200f|\u200e)/ig;
  33.  
  34. GM_addStyle(`
  35. .ghu-rtl-css { direction:rtl; text-align:right; }
  36. /* delegated binding; ignore clicks on svg & path */
  37. .ghu-rtl > * { pointer-events:none; }
  38. /* override RTL on code blocks */
  39. .js-preview-body pre, .markdown-body pre, .js-preview-body code, .markdown-body code {
  40. direction:ltr;
  41. text-align:left;
  42. unicode-bidi:normal;
  43. }
  44. `);
  45.  
  46. // Add monospace font toggle
  47. function addRtlButton() {
  48. busy = true;
  49. let el, button,
  50. toolbars = $$(".toolbar-commenting"),
  51. indx = toolbars.length;
  52. if (indx) {
  53. button = document.createElement("button");
  54. button.type = "button";
  55. button.className = "ghu-rtl toolbar-item tooltipped tooltipped-n";
  56. button.setAttribute("aria-label", "RTL");
  57. button.setAttribute("tabindex", "-1");
  58. button.innerHTML = icon;
  59. while (indx--) {
  60. el = toolbars[indx];
  61. if (!$(".ghu-rtl", el)) {
  62. el.insertBefore(button.cloneNode(true), el.childNodes[0]);
  63. }
  64. }
  65. }
  66. checkRTL();
  67. busy = false;
  68. }
  69.  
  70. function checkContent(el) {
  71. // check the contents, and wrap in either a span or div
  72. let indx, // useDiv,
  73. html = el.innerHTML,
  74. parts = html.split(regexSplit),
  75. len = parts.length;
  76. for (indx = 0; indx < len; indx++) {
  77. if (regexOpen.test(parts[indx])) {
  78. // check if the content contains HTML
  79. // useDiv = regexTestHTML.test(parts[indx + 1]);
  80. // parts[indx] = (useDiv ? "<div" : "<span") + " class='ghu-rtl-css'>";
  81. parts[indx] = "<div class='ghu-rtl-css'>";
  82. } else if (regexClose.test(parts[indx])) {
  83. // parts[indx] = useDiv ? "</div>" : "</span>";
  84. parts[indx] = "</div>";
  85. }
  86. }
  87. el.innerHTML = parts.join("");
  88. // remove empty paragraph wrappers (may have previously contained the mark)
  89. return el.innerHTML.replace(/<p><\/p>/g, "");
  90. }
  91.  
  92. function checkRTL() {
  93. let clone,
  94. indx = 0,
  95. div = document.createElement("div"),
  96. containers = $$(".js-preview-body, .markdown-body"),
  97. len = containers.length,
  98. // main loop
  99. loop = function() {
  100. let el, tmp,
  101. max = 0;
  102. while (max < 10 && indx < len) {
  103. if (indx > len) {
  104. return;
  105. }
  106. el = containers[indx];
  107. tmp = el.innerHTML;
  108. if (regexOpen.test(tmp) || regexClose.test(tmp)) {
  109. clone = div.cloneNode();
  110. clone.innerHTML = tmp;
  111. // now we can replace all instances
  112. el.innerHTML = checkContent(clone);
  113. max++;
  114. }
  115. indx++;
  116. }
  117. if (indx < len) {
  118. setTimeout(function() {
  119. loop();
  120. }, 200);
  121. }
  122. };
  123. loop();
  124. }
  125.  
  126. function $(selector, el) {
  127. return (el || document).querySelector(selector);
  128. }
  129. function $$(selector, el) {
  130. return Array.from((el || document).querySelectorAll(selector));
  131. }
  132. function closest(el, selector) {
  133. while (el && el.nodeName !== "BODY" && !el.matches(selector)) {
  134. el = el.parentNode;
  135. }
  136. return el && el.matches(selector) ? el : [];
  137. }
  138.  
  139. function addBindings() {
  140. $("body").addEventListener("click", function(event) {
  141. let textarea,
  142. target = event.target;
  143. if (target && target.classList.contains("ghu-rtl")) {
  144. textarea = closest(target, ".previewable-comment-form");
  145. textarea = $(".comment-form-textarea", textarea);
  146. textarea.focus();
  147. // add extra white space around the tags
  148. surroundSelectedText(textarea, " " + openRTL + " ", " " + closeRTL + " ");
  149. return false;
  150. }
  151. });
  152. }
  153.  
  154. targets = $$("#js-repo-pjax-container, #js-pjax-container");
  155.  
  156. Array.prototype.forEach.call(targets, function(target) {
  157. new MutationObserver(function(mutations) {
  158. mutations.forEach(function(mutation) {
  159. let mtarget = mutation.target;
  160. // preform checks before adding code wrap to minimize function calls
  161. // update after comments are edited
  162. if (!busy && mtarget === target || target.matches(".js-comment-body, .js-preview-body")) {
  163. addRtlButton();
  164. }
  165. });
  166. }).observe(target, {
  167. childList: true,
  168. subtree: true
  169. });
  170. });
  171.  
  172. addBindings();
  173. addRtlButton();
  174.  
  175. /* HEAVILY MODIFIED from https://github.com/timdown/rangyinputs
  176. code was unwrapped & unneeded code was removed
  177. */
  178. /**
  179. * @license Rangy Inputs, a jQuery plug-in for selection and caret manipulation within textareas and text inputs.
  180. *
  181. * https://github.com/timdown/rangyinputs
  182. *
  183. * For range and selection features for contenteditable, see Rangy.
  184. * http://code.google.com/p/rangy/
  185. *
  186. * Depends on jQuery 1.0 or later.
  187. *
  188. * Copyright 2014, Tim Down
  189. * Licensed under the MIT license.
  190. * Version: 1.2.0
  191. * Build date: 30 November 2014
  192. */
  193. var UNDEF = "undefined";
  194. var getSelection, setSelection, surroundSelectedText;
  195.  
  196. // Trio of isHost* functions taken from Peter Michaux's article:
  197. // http://peter.michaux.ca/articles/feature-detection-state-of-the-art-browser-scripting
  198. function isHostMethod(object, property) {
  199. var t = typeof object[property];
  200. return t === "function" || (!!(t == "object" && object[property])) || t == "unknown";
  201. }
  202. function isHostProperty(object, property) {
  203. return typeof(object[property]) != UNDEF;
  204. }
  205. function isHostObject(object, property) {
  206. return !!(typeof(object[property]) == "object" && object[property]);
  207. }
  208. function fail(reason) {
  209. if (window.console && window.console.log) {
  210. window.console.log("RangyInputs not supported in your browser. Reason: " + reason);
  211. }
  212. }
  213.  
  214. function adjustOffsets(el, start, end) {
  215. if (start < 0) {
  216. start += el.value.length;
  217. }
  218. if (typeof end == UNDEF) {
  219. end = start;
  220. }
  221. if (end < 0) {
  222. end += el.value.length;
  223. }
  224. return { start: start, end: end };
  225. }
  226.  
  227. function makeSelection(el, start, end) {
  228. return {
  229. start: start,
  230. end: end,
  231. length: end - start,
  232. text: el.value.slice(start, end)
  233. };
  234. }
  235.  
  236. function getBody() {
  237. return isHostObject(document, "body") ? document.body : document.getElementsByTagName("body")[0];
  238. }
  239.  
  240. var testTextArea = document.createElement("textarea");
  241. getBody().appendChild(testTextArea);
  242.  
  243. if (isHostProperty(testTextArea, "selectionStart") && isHostProperty(testTextArea, "selectionEnd")) {
  244. getSelection = function(el) {
  245. var start = el.selectionStart, end = el.selectionEnd;
  246. return makeSelection(el, start, end);
  247. };
  248.  
  249. setSelection = function(el, startOffset, endOffset) {
  250. var offsets = adjustOffsets(el, startOffset, endOffset);
  251. el.selectionStart = offsets.start;
  252. el.selectionEnd = offsets.end;
  253. };
  254. } else if (isHostMethod(testTextArea, "createTextRange") && isHostObject(document, "selection") &&
  255. isHostMethod(document.selection, "createRange")) {
  256.  
  257. getSelection = function(el) {
  258. var start = 0, end = 0, normalizedValue, textInputRange, len, endRange;
  259. var range = document.selection.createRange();
  260.  
  261. if (range && range.parentElement() == el) {
  262. len = el.value.length;
  263.  
  264. normalizedValue = el.value.replace(/\r\n/g, "\n");
  265. textInputRange = el.createTextRange();
  266. textInputRange.moveToBookmark(range.getBookmark());
  267. endRange = el.createTextRange();
  268. endRange.collapse(false);
  269. if (textInputRange.compareEndPoints("StartToEnd", endRange) > -1) {
  270. start = end = len;
  271. } else {
  272. start = -textInputRange.moveStart("character", -len);
  273. start += normalizedValue.slice(0, start).split("\n").length - 1;
  274. if (textInputRange.compareEndPoints("EndToEnd", endRange) > -1) {
  275. end = len;
  276. } else {
  277. end = -textInputRange.moveEnd("character", -len);
  278. end += normalizedValue.slice(0, end).split("\n").length - 1;
  279. }
  280. }
  281. }
  282.  
  283. return makeSelection(el, start, end);
  284. };
  285.  
  286. // Moving across a line break only counts as moving one character in a TextRange, whereas a line break in
  287. // the textarea value is two characters. This function corrects for that by converting a text offset into a
  288. // range character offset by subtracting one character for every line break in the textarea prior to the
  289. // offset
  290. var offsetToRangeCharacterMove = function(el, offset) {
  291. return offset - (el.value.slice(0, offset).split("\r\n").length - 1);
  292. };
  293.  
  294. setSelection = function(el, startOffset, endOffset) {
  295. var offsets = adjustOffsets(el, startOffset, endOffset);
  296. var range = el.createTextRange();
  297. var startCharMove = offsetToRangeCharacterMove(el, offsets.start);
  298. range.collapse(true);
  299. if (offsets.start == offsets.end) {
  300. range.move("character", startCharMove);
  301. } else {
  302. range.moveEnd("character", offsetToRangeCharacterMove(el, offsets.end));
  303. range.moveStart("character", startCharMove);
  304. }
  305. range.select();
  306. };
  307. } else {
  308. getBody().removeChild(testTextArea);
  309. fail("No means of finding text input caret position");
  310. return;
  311. }
  312. // Clean up
  313. getBody().removeChild(testTextArea);
  314.  
  315. function getValueAfterPaste(el, text) {
  316. var val = el.value, sel = getSelection(el), selStart = sel.start;
  317. return {
  318. value: val.slice(0, selStart) + text + val.slice(sel.end),
  319. index: selStart,
  320. replaced: sel.text
  321. };
  322. }
  323.  
  324. function pasteTextWithCommand(el, text) {
  325. el.focus();
  326. var sel = getSelection(el);
  327.  
  328. // Hack to work around incorrect delete command when deleting the last word on a line
  329. setSelection(el, sel.start, sel.end);
  330. if (text === "") {
  331. document.execCommand("delete", false, null);
  332. } else {
  333. document.execCommand("insertText", false, text);
  334. }
  335.  
  336. return {
  337. replaced: sel.text,
  338. index: sel.start
  339. };
  340. }
  341.  
  342. function pasteTextWithValueChange(el, text) {
  343. el.focus();
  344. var valueAfterPaste = getValueAfterPaste(el, text);
  345. el.value = valueAfterPaste.value;
  346. return valueAfterPaste;
  347. }
  348.  
  349. var pasteText = function(el, text) {
  350. var valueAfterPaste = getValueAfterPaste(el, text);
  351. try {
  352. var pasteInfo = pasteTextWithCommand(el, text);
  353. if (el.value == valueAfterPaste.value) {
  354. pasteText = pasteTextWithCommand;
  355. return pasteInfo;
  356. }
  357. } catch (ex) {
  358. // Do nothing and fall back to changing the value manually
  359. }
  360. pasteText = pasteTextWithValueChange;
  361. el.value = valueAfterPaste.value;
  362. return valueAfterPaste;
  363. };
  364.  
  365. var updateSelectionAfterInsert = function(el, startIndex, text, selectionBehaviour) {
  366. var endIndex = startIndex + text.length;
  367.  
  368. selectionBehaviour = (typeof selectionBehaviour == "string") ?
  369. selectionBehaviour.toLowerCase() : "";
  370.  
  371. if ((selectionBehaviour == "collapsetoend" || selectionBehaviour == "select") && /[\r\n]/.test(text)) {
  372. // Find the length of the actual text inserted, which could vary
  373. // depending on how the browser deals with line breaks
  374. var normalizedText = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
  375. endIndex = startIndex + normalizedText.length;
  376. var firstLineBreakIndex = startIndex + normalizedText.indexOf("\n");
  377.  
  378. if (el.value.slice(firstLineBreakIndex, firstLineBreakIndex + 2) == "\r\n") {
  379. // Browser uses \r\n, so we need to account for extra \r characters
  380. endIndex += normalizedText.match(/\n/g).length;
  381. }
  382. }
  383.  
  384. switch (selectionBehaviour) {
  385. case "collapsetostart":
  386. setSelection(el, startIndex, startIndex);
  387. break;
  388. case "collapsetoend":
  389. setSelection(el, endIndex, endIndex);
  390. break;
  391. case "select":
  392. setSelection(el, startIndex, endIndex);
  393. break;
  394. }
  395. };
  396.  
  397. surroundSelectedText = function(el, before, after, selectionBehaviour) {
  398. if (typeof after == UNDEF) {
  399. after = before;
  400. }
  401. var sel = getSelection(el);
  402. var pasteInfo = pasteText(el, before + sel.text + after);
  403. updateSelectionAfterInsert(el, pasteInfo.index + before.length, sel.text, selectionBehaviour || "select");
  404. };
  405.  
  406. })();