GitHub RTL Comment Blocks

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

当前为 2016-12-29 提交的版本,查看 最新版本

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