GitHub Custom Emojis

Add custom emojis from json source

目前为 2016-03-04 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name GitHub Custom Emojis
  3. // @version 0.1.0
  4. // @description Add custom emojis from json source
  5. // @namespace https://github.com/StylishThemes
  6. // @include /https?://((gist)\.)?github\.com/
  7. // @grant GM_addStyle
  8. // @grant GM_getValue
  9. // @grant GM_setValue
  10. // @grant GM_xmlhttpRequest
  11. // @grant GM_info
  12. // @run-at document-end
  13. // @require https://ajax.googleapis.com/ajax/libs/jquery/2.2.0/jquery.min.js
  14. // @require https://greasyfork.org/scripts/16936-ichord-caret-js/code/ichord-Caretjs.js?version=106431
  15. // @require https://greasyfork.org/scripts/16996-ichord-at-js-mod/code/ichord-Atjs-mod.js?version=109194
  16. // @require https://cdnjs.cloudflare.com/ajax/libs/ion-rangeslider/2.1.2/js/ion.rangeSlider.min.js
  17. // ==/UserScript==
  18. /* global jQuery, GM_addStyle, GM_getValue, GM_setValue, GM_xmlhttpRequest, GM_info */
  19. /* eslint-disable indent, quotes */
  20. (function($) {
  21. 'use strict';
  22.  
  23. var ghe = {
  24.  
  25. version : GM_info.script.version,
  26.  
  27. vars : {
  28. // delay until package.json allowed to load
  29. delay : 8.64e7, // 24 hours in milliseconds
  30.  
  31. // base url to fetch package.json
  32. root : 'https://raw.githubusercontent.com/StylishThemes/GitHub-Custom-Emojis/master/',
  33. emojiClass : 'ghe-custom-emoji',
  34. emojiTemplate : ':_${name}:',
  35. maxEmojiZoom : 3,
  36. maxEmojiHeight : 150,
  37.  
  38. // Keyboard shortcut to open panel
  39. keyboardOpen : 'g+=',
  40. keyboardDelay : 1000
  41. },
  42.  
  43. regex : {
  44. // nodes to skip while traversing the dom
  45. skipElm : /^(script|style|svg|iframe|br|meta|link|textarea|input|code|pre)$/i,
  46. // emoji template
  47. template : /\$\{name\}/,
  48. // character to escape in regex
  49. charsToEsc : /[-\/\\^$*+?.()|[\]{}]/g
  50. },
  51.  
  52. defaults : {
  53. activeZoom : 1.8,
  54. caseSensitive : false,
  55. rangeHeight : '30;40', // min;max as set by ion.rangeSlider
  56. insertAsImage : false,
  57. // emoji json sources
  58. sources : [
  59. 'https://raw.githubusercontent.com/StylishThemes/GitHub-Custom-Emojis/master/collections/emoji-custom.json',
  60. 'https://raw.githubusercontent.com/StylishThemes/GitHub-Custom-Emojis/master/collections/emoji-crazy-rabbit.json',
  61. 'https://raw.githubusercontent.com/StylishThemes/GitHub-Custom-Emojis/master/collections/emoji-onion-head.json'
  62. ]
  63. },
  64.  
  65. // emoji json stored here
  66. collections : {},
  67.  
  68. // GitHub ajax containers
  69. containers : [
  70. '#js-pjax-container',
  71. '#js-repo-pjax-container',
  72. '.js-contribution-activity',
  73. '.more-repos'
  74. ],
  75.  
  76. // mutant observers to disconnect after ajax load
  77. previewObserver : [],
  78.  
  79. getStoredValues : function() {
  80. var defaults = this.defaults;
  81. this.settings = {
  82. rangeHeight : GM_getValue('rangeHeight', defaults.rangeHeight),
  83. activeZoom : GM_getValue('activeZoom', defaults.activeZoom),
  84. caseSensitive : GM_getValue('caseSensitive', defaults.caseSensitive),
  85. insertAsImage : GM_getValue('insertAsImage', defaults.insertAsImage),
  86. sources : GM_getValue('sources', defaults.sources),
  87.  
  88. date : GM_getValue('date', 0)
  89. };
  90. debug('Retrieved stored values', this.settings);
  91. },
  92.  
  93. storeVal : function(key, set, $el) {
  94. var tmp,
  95. val = set[key];
  96. GM_setValue(key, val);
  97. if (typeof val === 'boolean') {
  98. $el.prop('checked', val);
  99. } else {
  100. $el.val(val);
  101. }
  102. // update sliders
  103. if ($el.hasClass('ghe-height')) {
  104. tmp = val.split(';');
  105. $el.data('ionRangeSlider').update({
  106. from: tmp[0],
  107. to: tmp[1]
  108. });
  109. } else if ($el.hasClass('ghe-zoom')) {
  110. $el.data('ionRangeSlider').update({
  111. from: val
  112. });
  113. }
  114. },
  115.  
  116. setStoredValues : function(reset) {
  117. var $el, tmp, len, indx,
  118. s = ghe.settings,
  119. d = ghe.defaults,
  120. $panel = $('#ghe-settings-inner');
  121.  
  122. ghe.busy = true;
  123. ghe.storeVal('caseSensitive', reset ? d : s, $panel.find('.ghe-case'));
  124. ghe.storeVal('insertAsImage', reset ? d : s, $panel.find('.ghe-image'));
  125. ghe.storeVal('activeZoom', reset ? d : s, $panel.find('.ghe-zoom'));
  126. ghe.storeVal('rangeHeight', reset ? d : s, $panel.find('.ghe-height'));
  127.  
  128. if (reset) {
  129. // add defaults back into source list; but don't remove any new stuff
  130. len = d.sources.length;
  131. for (indx = 0; indx < len; indx++) {
  132. if (s.sources.indexOf(d.sources[indx]) < 0) {
  133. s.sources[s.sources.length] = d.sources[indx];
  134. }
  135. }
  136. }
  137. tmp = s.sources;
  138. len = tmp.length;
  139. GM_setValue('sources', tmp);
  140. for (indx = 0; indx < len; indx++) {
  141. if ($panel.find('.ghe-source').eq(indx).length) {
  142. $el = $panel
  143. .find('.ghe-source-input')
  144. .eq(indx)
  145. .attr('data-url', tmp[indx]);
  146. } else {
  147. $el = $(ghe.sourceHTML)
  148. .appendTo($panel.find('.ghe-sources'))
  149. .find('.ghe-source-input')
  150. .attr('data-url', tmp[indx]);
  151. }
  152. // only show file name when not focused
  153. ghe.showFileName($el);
  154. }
  155. // remove extras
  156. $panel.find('.ghe-source').filter(':gt(' + len + ')').remove();
  157. if (reset) {
  158. this.updateSettings();
  159. }
  160.  
  161. debug((reset ? 'Resetting' : 'Saving') + ' current values & updating panel', s);
  162. ghe.busy = false;
  163. },
  164.  
  165. updateSettings : function() {
  166. this.isUpdating = true;
  167. var settings = this.settings,
  168. $panel = $('#ghe-settings-inner');
  169. settings.rangeHeight = $panel.find('.ghe-height').val();
  170. settings.activeZoom = $panel.find('.ghe-zoom').val();
  171. settings.insertAsImage = $panel.find('.ghe-image').is(':checked');
  172. settings.caseSensitive = $panel.find('.ghe-case').is(':checked');
  173. settings.sources = $panel.find('.ghe-source-input').map(function(){
  174. return $(this).attr('data-url');
  175. }).get();
  176.  
  177. debug('Updating user settings', settings);
  178. this.updateStyleSheet();
  179. this.isUpdating = false;
  180. },
  181.  
  182. loadEmojiJson : function(update) {
  183. // only load emoji.json once a day, or after a forced update
  184. if (update || (new Date().getTime() > this.settings.date + this.vars.delay)) {
  185. var indx,
  186. sources = this.settings.sources,
  187. len = sources.length;
  188. for (indx = 0; indx < len; indx++) {
  189. this.fetchCustomEmojis(sources[indx]);
  190. }
  191. this.settings.date = new Date().getTime();
  192. }
  193. },
  194.  
  195. fetchCustomEmojis : function(url) {
  196. debug('Fetching custom emoji list', url);
  197. GM_xmlhttpRequest({
  198. method : 'GET',
  199. url : url,
  200. onload : function(response) {
  201. var json = false;
  202. try {
  203. json = JSON.parse(response.responseText);
  204. } catch (err) {
  205. debug('Invalid JSON', url);
  206. }
  207. if (json && json[0].name) {
  208. // save url to make removing the entry easier
  209. json[0].url = url;
  210. ghe.collections[json[0].name] = json;
  211. debug('Adding "' + json[0].name + '" Emoji Collection');
  212. }
  213. }
  214. });
  215. },
  216.  
  217. // Using: document.evaluate('//*[text()="tuzki"]', document.body, null,
  218. // XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null).snapshotItem(0);
  219. // to find matching content as it is much faster than scanning each node
  220. // sometimes it misses things...
  221. checkPage : function(node) {
  222. this.isUpdating = true;
  223. // not sure why but document evaluate doesn't work on wiki page
  224. // preview tab, so we need to search the entire document
  225. if ($('#wiki-wrapper').length) {
  226. node = null;
  227. }
  228. var indx = 0,
  229. parts = this.vars.emojiTemplate.split('${name}'), // parts = [':_', ':']
  230. // adding "//" starts from document, so if node is defined, don't
  231. // include it so the search starts from the node
  232. path = (node ? '' : '//') + '*[contains(text(),"' + parts[0] + '")]',
  233. nodes = document.evaluate(path, node || document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null),
  234. len = nodes.snapshotLength;
  235. try {
  236. node = nodes.snapshotItem(indx);
  237. while (node && indx++ < len) {
  238. if (!ghe.regex.skipElm.test(node.nodeName)) {
  239. ghe.findEmoji(node);
  240. }
  241. node = nodes.snapshotItem(indx);
  242. }
  243. } catch (e) {
  244. debug('Nothing to replace!', e);
  245. }
  246. this.isUpdating = false;
  247. },
  248.  
  249. findEmoji : function(node) {
  250. var indx, len, group, match, matchesLen,
  251. regex = ghe.regex.nameRegex,
  252. matches = [],
  253. emojis = this.collections,
  254. str = node.textContent;
  255. while ((match = regex.exec(str)) !== null) {
  256. matches[matches.length] = match[1];
  257. }
  258. if (matches && matches[0]) {
  259. matchesLen = matches.length;
  260. for (group in emojis) {
  261. if (emojis.hasOwnProperty(group)) {
  262. len = emojis[group].length;
  263. for (indx = 0; indx < len; indx++) {
  264. for (match = 0; match < matchesLen; match++) {
  265. if (matches[match] === emojis[group][indx].name) {
  266. debug('found "' + matches[match] + '" in "' + node.textContent + '"');
  267. ghe.replaceText(node, emojis[group][indx]);
  268. }
  269. }
  270. }
  271. }
  272. }
  273. }
  274. },
  275.  
  276. replaceText : function(node, emoji) {
  277. var data, pos, imgnode, middlebit, endbit,
  278. isCased = this.settings.caseSensitive,
  279. name = this.vars.emojiTemplate.replace(ghe.regex.template, emoji.name),
  280. skip = 0;
  281. name = isCased ? name : name.toUpperCase();
  282. // Code modified from highlight-5 (MIT license)
  283. // http://johannburkard.de/blog/programming/javascript/highlight-javascript-text-higlighting-jquery-plugin.html
  284. if (node.nodeType === 3) {
  285. data = isCased ? node.data : node.data.toUpperCase();
  286. pos = data.indexOf(name);
  287. pos -= (data.substr(0, pos).length - node.data.substr(0, pos).length);
  288. if (pos >= 0) {
  289. imgnode = ghe.createEmoji(emoji);
  290. middlebit = node.splitText(pos);
  291. endbit = middlebit.splitText(name.length);
  292. middlebit.parentNode.replaceChild(imgnode, middlebit);
  293. skip = 1;
  294. }
  295. } else if (node.nodeType === 1 && node.childNodes) {
  296. for (var i = 0; i < node.childNodes.length; ++i) {
  297. i += ghe.replaceText(node.childNodes[i], emoji);
  298. }
  299. }
  300. return skip;
  301. },
  302.  
  303. // This function does the surrounding for every matched piece of text
  304. // and can be customized to do what you like
  305. // <img class="emoji" title=":smile:" alt=":smile:" src="x.png" height="20" width="20" align="absmiddle">
  306. createEmoji : function(emoji) {
  307. var el = document.createElement('img');
  308. el.src = emoji.url;
  309. el.className = ghe.vars.emojiClass + ' emoji';
  310. el.title = el.alt = ghe.vars.emojiTemplate.replace(ghe.regex.template, emoji.name);
  311. // el.align = 'absmiddle'; // deprecated attribute
  312. return el;
  313. },
  314.  
  315. // used by autocomplete (atwho) filter function
  316. matches : function(query, labels) {
  317. var i,
  318. count = 0,
  319. arry = (labels || '').split(/[\s,_]+/),
  320. parts = query.split(/[,_]/),
  321. len = parts.length;
  322. for (i = 0; i < len; i++) {
  323. // full match or partial
  324. if (arry.indexOf(parts[i]) > -1 || (labels || '').indexOf(parts[i]) > -1) {
  325. count++;
  326. }
  327. }
  328. if (len) {
  329. // return fraction of query matches
  330. return count / len;
  331. } else {
  332. count = (labels || '').match(query);
  333. // partial match
  334. return count ? count[0].length / query.length : 0;
  335. }
  336. },
  337.  
  338. // init when comment textarea is focused
  339. initAutocomplete : function($el) {
  340. var name, group,
  341. data = [];
  342. // combine data
  343. for (name in ghe.collections) {
  344. if (ghe.collections.hasOwnProperty(name)) {
  345. group = ghe.collections[name].slice(1);
  346. data = data.concat(group);
  347. }
  348. }
  349. // add emoji autocomplete to comment textareas
  350. if (!$el.data('atwho')) {
  351. $el.atwho({
  352. at : ':_', // first two characters from emojiTemplate
  353. data : data,
  354. searchKey: 'labels',
  355. displayTpl : '<li><span><img src="${url}" height="30" /></span>${name}</li>',
  356. insertTpl : ghe.vars.emojiTemplate,
  357. delay : 400,
  358. callbacks : {
  359. matcher: function(flag, subtext) {
  360. var regexp = ghe.regex.emojiFilter,
  361. match = regexp.exec(subtext);
  362. // this next line does some magic...
  363. // for some reason, without it, moving the caret from "p" to "r" in
  364. // ":_people,fear," opens & closes the popup with each letter typed
  365. subtext.match(regexp);
  366. if (match) {
  367. return match[2] || match[1];
  368. } else {
  369. return null;
  370. }
  371. },
  372. filter: function(query, data, searchKey) {
  373. var i, item,
  374. len = data.length,
  375. _results = [];
  376. for (i = 0; i < len; i++) {
  377. item = data[i];
  378. item.atwho_order = ghe.matches(query, item[searchKey]);
  379. if (item.atwho_order > 0.9) {
  380. _results[_results.length] = item;
  381. }
  382. }
  383. return _results.sort(function(a, b) {
  384. // descending sort
  385. return b.atwho_order - a.atwho_order;
  386. });
  387. },
  388. sorter: function(query, items) {
  389. // sorted by filter
  390. return items;
  391. },
  392. // event parameter adding in atwho.js mod
  393. beforeInsert: function(value, $li, event) {
  394. if (event.shiftKey || ghe.settings.insertAsImage) {
  395. // add image tag directly if shift is held
  396. return '<img title="' +
  397. ghe.vars.emojiTemplate.replace(ghe.regex.template, $li.text()) +
  398. '" src="' + $li.find('img').attr('src') + '">';
  399. }
  400. return value;
  401. }
  402. }
  403. });
  404. // use classes from GitHub-Dark to make theme match GitHub-Dark
  405. $('.atwho-view').addClass('popover suggester');
  406. }
  407. },
  408.  
  409. setupPreviews : function() {
  410. // Add mutant observer to previews
  411. var previews = document.querySelectorAll('.preview-content .comment-body');
  412. if (ghe.previewObserver.length) {
  413. // disconnect previous observers
  414. $.each(ghe.previewObserver, function() {
  415. this.disconnect();
  416. });
  417. ghe.previewObserver = [];
  418. }
  419. Array.prototype.forEach.call(previews, function(target) {
  420. var obs = new MutationObserver(function(mutations) {
  421. mutations.forEach(function(mutation) {
  422. // preform checks before adding code wrap to minimize function calls
  423. if (mutation.target === target && !ghe.isUpdating) {
  424. ghe.checkPage(target);
  425. }
  426. });
  427. });
  428. obs.observe(target, {
  429. childList : true,
  430. subtree : false
  431. });
  432. ghe.previewObserver[ghe.previewObserver.length] = obs;
  433. });
  434. },
  435.  
  436. addToolbarIcon : function() {
  437. // add Emoji setting icons
  438. var indx, $el,
  439. $toolbars = $('.toolbar-commenting'),
  440. len = $toolbars.length;
  441. for (indx = 0; indx < len; indx++) {
  442. $el = $toolbars.eq(indx);
  443. if (!$el.find('.ghe-settings-icon').length) {
  444. $el.prepend([
  445. '<button type="button" class="ghe-settings-open toolbar-item tooltipped tooltipped-n tooltipped-multiline" aria-label="Browse collections & Set Emojis Options" tabindex="-1">',
  446. '<svg class="ghe-settings-icon" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor">',
  447. '<path d="M7.205 3.233c0 .952-.753 1.73-1.722 1.73-.953 0-1.707-.793-1.707-1.73 0-.937.762-1.73 1.707-1.73.97 0 1.73.793 1.73 1.73h-.008zm6.904 0c0 .952-.794 1.73-1.747 1.73-.95 0-1.722-.793-1.722-1.73 0-.937.795-1.73 1.73-1.73.938 0 1.747.793 1.747 1.73h-.008zM7.204 10.1v5.19c0 1.728 6.904 1.728 6.904 0V10.1M10.642 10.1v3.46"/>',
  448. '<path d="M.878 8.777s3.167 1.893 8.002 1.92c4.365.02 8.135-1.92 8.135-1.92"/>',
  449. '</svg>',
  450. '</button>'
  451. ].join(''));
  452. }
  453. }
  454. },
  455.  
  456. // dynamic stylesheet
  457. updateStyleSheet : function() {
  458. var range = this.settings.rangeHeight.split(';');
  459. ghe.$style.text([
  460. // img styling - vertically center with set height range
  461. '.atwho-view li img, #ghe-popup .select-menu-item img, img[alt="ghe-emoji"], .' +
  462. this.vars.emojiClass + ' { ' +
  463. 'margin-bottom:.25em; vertical-align:middle; ' +
  464. 'min-height: ' + (range[0] || 'none') + 'px;' +
  465. 'max-height: ' + (range[1] || 'none') + 'px }',
  466. // click (make active) on image to zoom
  467. '.' + this.vars.emojiClass + ':active, a:active img[alt="ghe-emoji"] { zoom:' +
  468. this.settings.activeZoom + ' }'
  469. ].join(''));
  470. },
  471.  
  472. addBindings : function() {
  473. var lastKey,
  474. $popup = $('#ghe-popup'),
  475. $settings = $('#ghe-settings');
  476. // Delegated bindings
  477. $('body')
  478. .on('click', '.ghe-settings-open', function() {
  479. // open all collections panel
  480. ghe.openCollections($(this));
  481. return false;
  482. })
  483. .on('click', '.ghe-collection', function() {
  484. // open targeted collection
  485. var name = $(this).attr('data-group');
  486. ghe.showCollection(name);
  487. })
  488. .on('click', '.ghe-emoji', function(e) {
  489. // click on emoji in collection to add to textarea
  490. ghe.addEmoji(e, $(this));
  491. })
  492. .on('click keypress keydown', function(e) {
  493. clearTimeout(ghe.timer);
  494. var panelVisible = $popup.hasClass('in') || $settings.hasClass('in'),
  495. openPanel = ghe.vars.keyboardOpen.split('+'),
  496. key = String.fromCharCode(e.which).toLowerCase();
  497. // press escape or click outside to close the panel
  498. if (panelVisible && e.which === 27 || e.type === 'click' && !$(e.target).closest('#ghe-wrapper').length) {
  499. ghe.closePanels();
  500. return;
  501. }
  502. // keydown is only needed for escape key detection
  503. if (e.type === 'keydown' || /(input|textarea)/i.test(document.activeElement.nodeName)) {
  504. return;
  505. }
  506. // shortcut keys need keypress
  507. if (lastKey === openPanel[0] && key === openPanel[1]) {
  508. if ($settings.hasClass('in')) {
  509. ghe.closePanels();
  510. } else {
  511. ghe.openSettings();
  512. }
  513. }
  514. lastKey = key;
  515. ghe.timer = setTimeout(function() {
  516. lastKey = null;
  517. }, ghe.vars.keyboardDelay);
  518.  
  519. // add shortcut to help menu
  520. if (key === '?') {
  521. // table doesn't exist until user presses "?"
  522. setTimeout(function() {
  523. if (!$('.ghe-shortcut').length) {
  524. $('.keyboard-mappings:eq(0) tbody:eq(0)').append([
  525. '<tr class="ghe-shortcut">',
  526. '<td class="keys">',
  527. '<kbd>' + openPanel[0] + '</kbd> <kbd>' + openPanel[1] + '</kbd>',
  528. '</td>',
  529. '<td>GitHub Emojis: open settings</td>',
  530. '</tr>'
  531. ].join(''));
  532. }
  533. }, 300);
  534. }
  535. });
  536.  
  537. // popup & settings interactions
  538. $('#ghe-popup .octicon-gear').on('click keyup', function(e) {
  539. if (e.type === 'keyup' && e.which !== 13) {
  540. return;
  541. }
  542. ghe.openSettings();
  543. });
  544. $('#ghe-settings, #ghe-settings-close, #ghe-settings-inner').on('click', function(e) {
  545. if (this.id === 'ghe-settings-inner') {
  546. e.stopPropagation();
  547. } else {
  548. ghe.closePanels();
  549. }
  550. });
  551. // ghe-checkbox added to checkboxes
  552. $('.ghe-checkbox').on('change', function() {
  553. ghe.updateSettings();
  554. });
  555. // go back - switch from single collection to showing all collections
  556. $('#ghe-popup .ghe-back').on('click', function(){
  557. $('.ghe-single-collection, .ghe-back').hide();
  558. $('.ghe-all-collections').show();
  559. });
  560.  
  561. // add new source input
  562. $('#ghe-add-source').on('click', function() {
  563. var $panel = $('#ghe-settings-inner');
  564. // lets not get crazy!
  565. if ($panel.find('.ghe-source').length < 20) {
  566. $(ghe.sourceHTML).appendTo($panel.find('.ghe-sources'));
  567. }
  568. return false;
  569. });
  570. $('#ghe-refresh-sources, #ghe-restore').on('click', function() {
  571. // update sources from settings panel
  572. ghe.setStoredValues(this.id === 'ghe-restore');
  573. // load json files
  574. ghe.loadEmojiJson(true);
  575. return false;
  576. });
  577.  
  578. // Init range slider
  579. $('.ghe-height')
  580. .val(ghe.settings.rangeHeight)
  581. .ionRangeSlider({
  582. type : 'double',
  583. min : 0,
  584. max : ghe.vars.maxEmojiHeight,
  585. onChange : function() {
  586. ghe.updateSettings();
  587. },
  588. force_edges : true,
  589. hide_min_max : true
  590. });
  591. $('.ghe-zoom')
  592. .val(ghe.settings.activeZoom)
  593. .ionRangeSlider({
  594. min : 0,
  595. max : ghe.vars.maxEmojiZoom,
  596. step : 0.1,
  597. onChange : function() {
  598. ghe.updateSettings();
  599. },
  600. force_edges : true,
  601. hide_min_max : true
  602. });
  603.  
  604. // Remove source input - delegated binding
  605. $('.ghe-settings-wrapper')
  606. .on('click', '.ghe-remove', function(e) {
  607. var $wrapper = $(this).closest('.ghe-source'),
  608. url = $wrapper.find('.ghe-source-input').attr('data-url');
  609. ghe.removeSource(url);
  610. $wrapper.remove();
  611. ghe.setStoredValues();
  612. return false;
  613. })
  614. .on('focus blur input change', '.ghe-source-input', function(e) {
  615. if (ghe.busy) { return; }
  616. ghe.busy = true;
  617. var val,
  618. $this = $(this);
  619. switch (e.type) {
  620. case 'focus':
  621. case 'focusin':
  622. // show entire url when focused
  623. $this.val( $this.attr('data-url') );
  624. break;
  625. case 'blur':
  626. case 'focusout':
  627. ghe.showFileName($this);
  628. break;
  629. default:
  630. $this.attr('data-url', $this.val());
  631. }
  632. if (e.type === 'change' || e.which === 13) {
  633. val = $this.val();
  634. $this.attr('data-url', val);
  635. ghe.fetchCustomEmojis(val);
  636. }
  637. ghe.busy = false;
  638. });
  639.  
  640. // initialize autocomplete that add emojis, but only on focus
  641. // since every comment has a hidden textarea
  642. $('body').on('focus', '.comment-form-textarea', function() {
  643. ghe.initAutocomplete($(this));
  644. });
  645. },
  646.  
  647. showFileName : function($el) {
  648. var str = $el.attr('data-url'),
  649. v = str.substring( str.lastIndexOf('/') + 1, str.length );
  650. // show only the file name in the input when blurred
  651. // unless there is no file name
  652. $el.val(v === '' ? str : '...' + v);
  653. },
  654.  
  655. closePanels : function() {
  656. $('#ghe-popup').removeClass('in');
  657. $('#ghe-settings').removeClass('in');
  658. ghe.$currentInput = null;
  659. },
  660.  
  661. openSettings : function() {
  662. $('.modal-backdrop').click();
  663. $('#ghe-settings').addClass('in');
  664. },
  665.  
  666. openCollections : function($el) {
  667. ghe.addCollections();
  668. var pos = $el.offset();
  669. $('#ghe-settings').removeClass('in');
  670. $('#ghe-popup')
  671. .addClass('in')
  672. .css({
  673. left: pos.left + 25,
  674. top: pos.top
  675. });
  676. ghe.$currentInput = $el.closest('.previewable-comment-form').find('.comment-form-textarea');
  677. },
  678.  
  679. addCollections : function() {
  680. var indx, len, key, group, img,
  681. collections = ghe.collections,
  682. range = ghe.settings.rangeHeight.split(';'),
  683. list = [],
  684. items = [];
  685. // build collections list -
  686. for (key in collections) {
  687. if (collections.hasOwnProperty(key)) {
  688. list[list.length] = key;
  689. }
  690. }
  691. list = list.sort(function(a, b) {
  692. return a > b ? 1 : (a < b ? -1 : 0);
  693. });
  694. len = list.length;
  695. // add random image from group
  696. for (indx = 0; indx < len; indx++) {
  697. group = collections[list[indx]];
  698. // random image (skip first entry)
  699. img = Math.round(Math.random() * (group.length - 2)) + 1;
  700. items[items.length] = '<div class="select-menu-item js-navigation-item ghe-collection" ' +
  701. 'data-group="' + list[indx] + '">' +
  702. // collection info stored in first entry
  703. group[0].name + ' <span class="ghe-right"><img src="' +
  704. group[img].url + '" title="' +
  705. ghe.vars.emojiTemplate.replace(ghe.regex.template, group[img].name) + '" style="' +
  706. 'min-height:' + (range[0] || 'none') + 'px;' +
  707. 'max-height:' + (range[1] || 'none') + 'px;">' +
  708. '</span></div>';
  709. }
  710. $('.ghe-single-collection, .ghe-back').hide();
  711. $('.ghe-all-collections').html(items.join('')).show();
  712. },
  713.  
  714. showCollection : function(name) {
  715. var indx,
  716. range = ghe.settings.rangeHeight.split(';'),
  717. group = ghe.collections[name].sort(function(a, b) {
  718. return a.name > b.name ? 1 : ( a.name < b.name ? -1 : 0 );
  719. }),
  720. list = [],
  721. len = group.length;
  722. for (indx = 1; indx < len; indx++) {
  723. list[indx - 1] = '<div class="select-menu-item js-navigation-item ghe-emoji" ' +
  724. 'data-name="' + group[indx].name + '">' +
  725. group[indx].name + '<span class="ghe-right"><img src="' +
  726. group[indx].url + '" style="min-height:' + (range[0] || 'none') + 'px;' +
  727. 'max-height:' + (range[1] || 'none') + 'px"></span></div>';
  728. }
  729. $('.ghe-all-collections').hide();
  730. $('.ghe-single-collection').html(list.join('')).show();
  731. $('.ghe-back').show();
  732. },
  733.  
  734. // add emoji from collection
  735. addEmoji : function(e, $el) {
  736. var val, emoji,
  737. name = $el.attr('data-name'),
  738. caretPos = ghe.$currentInput.caret('pos');
  739. // insert into textarea
  740. if (e.shiftKey || ghe.settings.insertAsImage) {
  741. // add image tag directly if shift is held;
  742. // GitHub does NOT allow class names so we are forced to use alt
  743. emoji = '<img alt="ghe-emoji" title="' +
  744. ghe.vars.emojiTemplate.replace(ghe.regex.template, name) +
  745. '" src="' + $el.find('img').attr('src') + '">';
  746. } else {
  747. emoji = ghe.vars.emojiTemplate.replace(ghe.regex.template, name);
  748. }
  749. val = ghe.$currentInput.val();
  750. ghe.$currentInput
  751. .val(val.slice(0, caretPos) + emoji + ' ' + val.slice(caretPos))
  752. .focus()
  753. .caret('pos', caretPos + emoji.length + 1);
  754. ghe.closePanels();
  755. },
  756.  
  757. removeSource : function(url) {
  758. var indx,
  759. list = [],
  760. collections = this.collections,
  761. sources = this.settings.sources,
  762. len = sources.length;
  763. // remove from source
  764. for (indx = 0; indx < len; indx++) {
  765. if (sources[indx] !== url) {
  766. list[list.length] = sources[indx];
  767. }
  768. }
  769. this.settings.sources = list;
  770. for (indx in collections) {
  771. if (collections.hasOwnProperty(indx) && collections[indx][0].url === url) {
  772. delete collections[indx];
  773. debug('Removing "' + indx + '" collection', collections);
  774. }
  775. }
  776. },
  777.  
  778. update : function(target) {
  779. this.isUpdating = true;
  780. this.setupPreviews();
  781. this.addToolbarIcon();
  782. // checkPage clears isUpdating flag
  783. this.checkPage(target);
  784. },
  785.  
  786. addPanels : function() {
  787. /* https://github.com/ichord/At.js styles for autocomplete */
  788. GM_addStyle([
  789. // settings panel
  790. '#ghe-menu:hover { cursor:pointer }',
  791. '#ghe-settings { position:fixed; z-index:65535; top:0; bottom:0; left:0; right:0; opacity:0; visibility:hidden }',
  792. '#ghe-settings.in { opacity:1; visibility:visible; background:rgba(0,0,0,.5) }',
  793. '#ghe-settings-inner { position:fixed; left:50%; top:50%; transform:translate(-50%,-50%); width:25rem; box-shadow:0 .5rem 1rem #111; color:#c0c0c0 }',
  794. '#ghe-settings label { margin-left:.5rem; position:relative; top:-1px }',
  795. '#ghe-settings .ghe-remove { float:right; margin-top:2px; padding:4px; cursor:pointer }',
  796. '#ghe-settings .ghe-remove-icon { position:relative; top:3px }',
  797. '#ghe-settings-close { fill:#666; float:right; cursor:pointer }',
  798. '#ghe-settings-close:hover { fill:#ccc }',
  799. '#ghe-settings .ghe-settings-wrapper { max-height:60vh; overflow-y:auto; padding: 1px 10px }',
  800. '#ghe-settings .ghe-right, #ghe-popup .ghe-right { float:right }',
  801. '#ghe-settings p { line-height:25px; }',
  802. '#ghe-settings .checkbox input { margin-top:.35em }',
  803. '#ghe-settings input[type="checkbox"] { width:16px !important; height:16px !important; border-radius:3px !important }',
  804. '#ghe-settings .boxed-group-inner { padding:0; }',
  805. '#ghe-settings .ghe-footer { padding: 10px; border-top: #555 solid 1px; }',
  806. '#ghe-settings .ghe-min-height, #ghe-settings .ghe-max-height, .ghe-zoom { width: 5em; }',
  807. '#ghe-settings .ghe-source-input { width: 90%; }',
  808. '#ghe-settings .ghe-slider-wrapper { height:40px; }',
  809. '#ghe-settings .ghe-slider-wrapper label { position:relative; top:22px }',
  810. '#ghe-settings .ghe-range-slider, #ghe-settings .ghe-zoom-slider { position:relative; height:40px; width:250px; float:right }',
  811.  
  812. // show emoji collections
  813. '#ghe-popup { display:none }',
  814. '#ghe-popup .ghe-content, #ghe-popup .ghe-content > div { max-height: 200px }',
  815. '#ghe-popup .octicon-gear { margin-left:4px }',
  816. '#ghe-popup .ghe-back svg { height:20px; padding:4px 14px 4px 4px }',
  817. '#ghe-popup .select-menu-item { font-size:1.1em; font-weight:bold; line-height:40px; padding:8px }',
  818. '.ghe-settings-icon, #ghe-popup.in { display:inline-block }',
  819.  
  820. // autocomplete popup in comment
  821. '.atwho-view { position:absolute; top:0; left:0; display:none; margin-top:18px; background:#fff; color:#000; border:1px solid #ddd; border-radius:3px; box-shadow:0 0 5px rgba(0,0,0,.1); min-width:300px; max-width:none!important; max-height:225px; overflow:auto; z-index:11110!important }',
  822. '.atwho-view .navigation-focus { background:#36f; color:#fff }',
  823. '.atwho-view .navigation-focus small { color:#fff }',
  824. '.atwho-view strong { color:#36F }',
  825. '.atwho-view .navigation-focus strong { color:#fff; font:700 }',
  826. '.atwho-view ul { list-style:none; padding:0; margin:auto }',
  827. '.atwho-view ul li { display:block; padding:5px 10px; border-bottom:1px solid #ddd; cursor:pointer }',
  828. '.atwho-view li span { display:inline-block; min-width:60px; padding-right:4px }',
  829. '.atwho-view small { font-size:smaller; color:#777; font-weight:400 }',
  830.  
  831. // rangeSlider
  832. '.irs{position:relative;display:block;-webkit-touch-callout:none;-webkit-user-select:none;-khtml-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}',
  833. '.irs-line{position:relative;display:block;overflow:hidden;outline:none !important}.irs-line-left,.irs-line-mid,.irs-line-right{position:absolute;display:block;top:0}',
  834. '.irs-line-left{left:0;width:9%}.irs-line-mid{left:9%;width:82%}.irs-line-right{right:0;width:9%}.irs-bar{position:absolute;display:block;left:0;width:0}.irs-bar-edge{position:absolute;display:block;top:0;left:0}',
  835. '.irs-shadow{position:absolute;display:none;left:0;width:0}.irs-slider{position:absolute;display:block;cursor:default;z-index:1}.irs-slider.type_last{z-index:2}.irs-min{position:absolute;display:block;left:0;cursor:default}',
  836. '.irs-max{position:absolute;display:block;right:0;cursor:default}.irs-from,.irs-to,.irs-single{position:absolute;display:block;top:0;left:0;cursor:default;white-space:nowrap}.irs-grid{position:absolute;display:none;bottom:0;left:0;width:100%;height:20px}',
  837. '.irs-with-grid .irs-grid{display:block}.irs-grid-pol{position:absolute;top:0;left:0;width:1px;height:8px;background:#000}.irs-grid-pol.small{height:4px}.irs-grid-text{position:absolute;bottom:0;left:0;white-space:nowrap;text-align:center;font-size:9px;line-height:9px;padding:0 3px;color:#000}',
  838. '.irs-disable-mask{position:absolute;display:block;top:0;left:-1%;width:102%;height:100%;cursor:default;background:rgba(0,0,0,0.0);z-index:2}.lt-ie9 .irs-disable-mask{background:#000;filter:alpha(opacity=0);cursor:not-allowed}.irs-disabled{opacity:0.4}',
  839. '.irs-hidden-input{position:absolute !important;display:block !important;top:0 !important;left:0 !important;width:0 !important;height:0 !important;font-size:0 !important;line-height:0 !important;padding:0 !important;margin:0 !important;outline:none !important;z-index:-9999 !important;background:none !important;border-style:solid !important;border-color:transparent !important}',
  840. '.irs-line-mid,.irs-line-left,.irs-line-right,.irs-bar,.irs-bar-edge,.irs-slider{background:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAQQAAAC0BAMAAACAm0/4AAAAHlBMVEUAAADh5OlIPakuJnU8MZzh5Onh5Onlt8BIPamDg6ND+SBkAAAACnRSTlMAgMzMzHlXE4oe0nCEQQAAAMJJREFUeNrt1qENAkEQhtENkBDkCtCECiiBFjCgEXgMDVwJVEzGQ7KnZnJ5r4JP7E7+1tNJkCBBgoSqCQAAjNg+e6rbq717snt79GSHdu3J9hVGfE8nIVR4jgU+ZYHTVOBAAwAw4pROggQJEiRUTQAAYMRuSl9Rn/whN+UnFJizEiQUSijwKQucpgIHGgCAQatjy7a5tJkkBAlBQpAQJAQJQUJYYkKByQIA/6zPbSYJQUKQECQECUFCkBAkhCUmAMAvX+TSxQIIIKq9AAAAAElFTkSuQmCC") repeat-x}',
  841. '.irs{height:40px}.irs-with-grid{height:60px}.irs-line{height:12px;top:25px}.irs-line-left{height:12px;background-position:0 -30px}',
  842. '.irs-line-mid{height:12px;background-position:0 0}.irs-line-right{height:12px;background-position:100% -30px}.irs-bar{height:12px;top:25px;background-position:0 -60px}',
  843. '.irs-bar-edge{top:25px;height:12px;width:9px;background-position:0 -90px}.irs-shadow{height:3px;top:34px;background:#000;opacity:.25}',
  844. '.lt-ie9 .irs-shadow{filter:alpha(opacity=25)}.irs-slider{width:16px;height:18px;top:22px;background-position:0 -120px}',
  845. '.irs-slider.state_hover,.irs-slider:hover{background-position:0 -150px}.irs-min,.irs-max{color:#fff;font-size:10px;line-height:1.333;text-shadow:none;top:0;padding:1px 3px;background:#7D7E81;-moz-border-radius:4px;border-radius:4px}',
  846. '.irs-from,.irs-to,.irs-single{color:#fff;font-size:10px;line-height:1.333;text-shadow:none;padding:1px 5px;background:#534AA1;-moz-border-radius:4px;border-radius:4px}',
  847. '.irs-from:after,.irs-to:after,.irs-single:after{position:absolute;display:block;content:"";bottom:-6px;left:50%;width:0;height:0;margin-left:-3px;overflow:hidden;border:3px solid transparent;border-top-color:#534AA1}',
  848. '.irs-grid-pol{background:#e1e4e9}.irs-grid-text{color:#999}'
  849. ].join(''));
  850.  
  851. // Settings panel markup
  852. $('body').append([
  853. '<div id="ghe-wrapper">',
  854. '<div id="ghe-popup" class="select-menu-modal-holder js-menu-content js-navigation-container js-active-navigation-container">',
  855. '<div class="select-menu-modal">',
  856. '<div class="select-menu-header">',
  857. '<span class="select-menu-title">',
  858. '<text>Emoji Collections</text>',
  859. '<span class="octicon tooltipped tooltipped-w" aria-label="Change GitHub Custom Emoji Settings">',
  860. '<svg class="octicon-gear" viewBox="0 0 16 14" style="height: 16px; width: 14px;"><path d="M14 8.77V7.17l-1.94-0.64-0.45-1.09 0.88-1.84-1.13-1.13-1.81 0.91-1.09-0.45-0.69-1.92H6.17l-0.63 1.94-1.11 0.45-1.84-0.88-1.13 1.13 0.91 1.81-0.45 1.09L0 7.23v1.59l1.94 0.64 0.45 1.09-0.88 1.84 1.13 1.13 1.81-0.91 1.09 0.45 0.69 1.92h1.59l0.63-1.94 1.11-0.45 1.84 0.88 1.13-1.13-0.92-1.81 0.47-1.09 1.92-0.69zM7 11c-1.66 0-3-1.34-3-3s1.34-3 3-3 3 1.34 3 3-1.34 3-3 3z"/></svg>',
  861. '</span>',
  862. '<span class="octicon tooltipped tooltipped-w ghe-back" aria-label="Go back to see all collections">',
  863. '<svg xmlns="http://www.w3.org/2000/svg" width="6.5" height="10" viewBox="0 0 6.5 10"><path d="M5.008 0l1.497 1.504-3.76 3.49 3.743 3.51L4.984 10l-4.99-5.013L5.01 0z"/></svg>',
  864. '</span>',
  865. '</span>',
  866. '</div>',
  867. '<div class="js-select-menu-deferred-content ghe-content">',
  868. '<div class="select-menu-list ghe-all-collections"></div>',
  869. '<div class="select-menu-list ghe-single-collection"></div>',
  870. '</div>',
  871. '</div>',
  872. '</div>',
  873. '<div id="ghe-settings">',
  874. '<div id="ghe-settings-inner" class="boxed-group">',
  875. '<h3>GitHub Custom Emoji Settings',
  876. '<svg id="ghe-settings-close" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="160 160 608 608"><path d="M686.2 286.8L507.7 465.3l178.5 178.5-45 45-178.5-178.5-178.5 178.5-45-45 178.5-178.5-178.5-178.5 45-45 178.5 178.5 178.5-178.5z"/></svg>',
  877. '</h3>',
  878. '<div class="boxed-group-inner">',
  879. '<form>',
  880. '<div class="ghe-settings-wrapper">',
  881. '<p>',
  882. '<label>Insert as Image:',
  883. '<sup class="tooltipped tooltipped-e" aria-label="Or Shift + select the emoji">?</sup>',
  884. '<input class="ghe-image ghe-checkbox ghe-right" type="checkbox">',
  885. '</label>',
  886. '</p>',
  887. '<p class="checkbox">',
  888. '<label>Case Sensitive <input class="ghe-case ghe-checkbox ghe-right" type="checkbox"></label>',
  889. '</p>',
  890. '<div class="ghe-slider-wrapper">',
  891. '<div class="ghe-range-slider">',
  892. '<input type="text" class="ghe-height" value="" />',
  893. '</div>',
  894. '<label>Emoji Height',
  895. '<sup class="tooltipped tooltipped-e" aria-label="Set emoji minimum & maximum&#10;height in pixels">?</sup>',
  896. '</label>',
  897. '</div>',
  898. '<div class="ghe-slider-wrapper">',
  899. '<div class="ghe-zoom-slider">',
  900. '<input class="ghe-zoom ghe-right" type="text">',
  901. '</div>',
  902. '<label>Emoji Zoom',
  903. '<sup class="tooltipped tooltipped-e" aria-label="Set Emoji zoom factor&#10;while actively clicked">?</sup>',
  904. '</label>',
  905. '</div>',
  906. '<p>',
  907. '<hr>',
  908. '<h3>Sources',
  909. '<a href="https://github.com/StylishThemes/GitHub-Custom-Emojis/wiki/Add-Emojis" class="tooltipped tooltipped-e tooltipped-multiline" aria-label="Click to get more details on how to set up an Emoji source JSON file">',
  910. '<sup>?</sup>',
  911. '</a>',
  912. '</h3>',
  913. '<div class="ghe-sources"></div>',
  914. '</p>',
  915. '</div>',
  916. '<div class="ghe-footer">',
  917. '<div class="btn-group">',
  918. '<a href="#" id="ghe-add-source" class="btn btn-sm">Add Source</a>',
  919. '<a href="#" id="ghe-refresh-sources" class="btn btn-sm">Refresh Sources</a>&nbsp;',
  920. '</div>',
  921. '<a href="#" id="ghe-restore" class="btn btn-sm btn-danger tooltipped tooltipped-n ghe-right" aria-label="Default sources are restored;&#10;other source will remain">Restore Defaults</a>',
  922. '</div>',
  923. '</form>',
  924. '</div>',
  925. '</div>',
  926. '</div>',
  927. '</div>'
  928. ].join(''));
  929. },
  930.  
  931. // JSON source inputs
  932. sourceHTML : [
  933. '<div class="ghe-source">',
  934. '<input class="ghe-source-input" type="text" value="" placeholder="Add JSON sources only">',
  935. '<a href="#" class="ghe-remove btn btn-sm btn-danger">',
  936. '<svg class="ghe-remove-icon" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="160 160 608 608" fill="currentColor"><path d="M686.2 286.8L507.7 465.3l178.5 178.5-45 45-178.5-178.5-178.5 178.5-45-45 178.5-178.5-178.5-178.5 45-45 178.5 178.5 178.5-178.5z"/></svg>',
  937. '</a>',
  938. '</div>'
  939. ].join(''),
  940.  
  941. init : function() {
  942. debug('GitHub-Emoji Script initializing!');
  943.  
  944. // add style tag to head
  945. this.$style = $('<style class="ghe-style">').appendTo('head');
  946.  
  947. this.getStoredValues();
  948. this.loadEmojiJson();
  949. this.updateStyleSheet();
  950. this.isUpdating = true;
  951.  
  952. var targets = document.querySelectorAll(this.containers.join(',')),
  953. // parts = [':_', ':']
  954. parts = this.vars.emojiTemplate.split('${name}');
  955.  
  956. // emojiFilter = /:_([a-z\u00c0-\u00ff0-9_,'.+-]*)$|:_([^\x00-\xff]*)$/gi
  957. // used by atwho.js autocomplete
  958. this.regex.emojiFilter = new RegExp(
  959. parts[0] + '([a-z\u00c0-\u00ff0-9_,\'\.\+\-]*)$|' +
  960. parts[0] + '([^\\x00-\\xff]*)$', 'gi'
  961. );
  962.  
  963. // used by
  964. this.regex.nameRegex = new RegExp(
  965. parts[0].replace(ghe.regex.charsToEsc, '\\$&') +
  966. '([\\w_]+)' +
  967. parts[1].replace(ghe.regex.charsToEsc, '\\$&'), 'g' +
  968. (ghe.settings.caseSensitive ? 'i' : '')
  969. );
  970.  
  971. Array.prototype.forEach.call(targets, function(target) {
  972. new MutationObserver(function(mutations) {
  973. mutations.forEach(function(mutation) {
  974. // preform checks before adding code wrap to minimize function calls
  975. if (mutation.target === target && !$.isEmptyObject(ghe.collections) &&
  976. !(ghe.isUpdating || target.querySelector('.ghe-processed'))) {
  977. ghe.update(target);
  978. }
  979. });
  980. }).observe(target, {
  981. childList : true,
  982. subtree : true
  983. });
  984. });
  985.  
  986. this.addPanels();
  987.  
  988. // Add emoji autocomplete & watch for preview rendering
  989. this.setupPreviews();
  990. this.addToolbarIcon();
  991. this.addBindings();
  992. // update panel values after bindings (rangeslider)
  993. this.setStoredValues();
  994.  
  995. // checkPage clears isUpdating flag
  996. this.checkPage();
  997. }
  998. };
  999.  
  1000. // add style at document-start
  1001. ghe.init();
  1002.  
  1003. // include a "?debug" anywhere in the browser URL to enable debugging
  1004. function debug() {
  1005. if (/\?debug/.test(window.location.href)) {
  1006. console.log.apply(console, arguments);
  1007. }
  1008. }
  1009. })(jQuery.noConflict(true));