- // ==UserScript==
- // @name GitHub Custom Emojis
- // @version 0.2.7
- // @description Add custom emojis from json source
- // @license MIT
- // @author Rob Garrison
- // @namespace https://github.com/StylishThemes
- // @include https://github.com/*
- // @include https://gist.github.com/*
- // @grant GM_addStyle
- // @grant GM_getValue
- // @grant GM_setValue
- // @grant GM_xmlhttpRequest
- // @grant GM_info
- // @connect *
- // @run-at document-end
- // @require https://ajax.googleapis.com/ajax/libs/jquery/2.2.0/jquery.min.js
- // @require https://greasyfork.org/scripts/16936-ichord-caret-js/code/ichord-Caretjs.js?version=138639
- // @require https://greasyfork.org/scripts/16996-ichord-at-js-mod/code/ichord-Atjs-mod.js?version=138632
- // @require https://cdnjs.cloudflare.com/ajax/libs/ion-rangeslider/2.1.2/js/ion.rangeSlider.min.js
- // ==/UserScript==
- /* global jQuery */
- (function($) {
- 'use strict';
-
- const ghe = {
-
- version : GM_info.script.version,
-
- vars : {
- // delay until package.json allowed to load
- delay : 8.64e7, // 24 hours in milliseconds
-
- // base url to fetch package.json
- root : 'https://raw.githubusercontent.com/StylishThemes/GitHub-Custom-Emojis/master/',
- emojiClass : 'ghe-custom-emoji',
- emojiTxtTemplate : '~${name}',
- emojiImgTemplate : ':_${name}:',
- maxEmojiZoom : 3,
- maxEmojiHeight : 150,
-
- // Keyboard shortcut to open panel
- keyboardOpen : 'g+=',
- keyboardDelay : 1000
- },
-
- regex : {
- // nodes to skip while traversing the dom
- skipElm : /^(script|style|svg|iframe|br|meta|link|textarea|input|code|pre)$/i,
- // emoji template
- template : /\$\{name\}/,
- // character to escape in regex
- charsToEsc : /[-/\\^$*+?.()|[\]{}]/g
- },
-
- defaults : {
- activeZoom : 1.8,
- caseSensitive : false,
- rangeHeight : '20;40', // min;max as set by ion.rangeSlider
- insertAsImage : false,
- // emoji json sources
- sources : [
- 'https://raw.githubusercontent.com/StylishThemes/GitHub-Custom-Emojis/master/collections/emoji-custom.json',
- 'https://raw.githubusercontent.com/StylishThemes/GitHub-Custom-Emojis/master/collections/emoji-crazy-rabbit.json',
- 'https://raw.githubusercontent.com/StylishThemes/GitHub-Custom-Emojis/master/collections/emoji-onion-head.json',
- 'https://raw.githubusercontent.com/StylishThemes/GitHub-Custom-Emojis/master/collections/emoji-unicode.json',
- 'https://raw.githubusercontent.com/StylishThemes/GitHub-Custom-Emojis/master/collections/emoji-custom-text.json'
- ]
- },
-
- // emoji json stored here
- collections : {},
-
- // GitHub ajax containers
- containers : [
- '#js-pjax-container',
- '#js-repo-pjax-container',
- '.js-contribution-activity',
- '.more-repos', // loading "more" of "Your repositories"
- '#dashboard .news', // loading "more" news
- '.js-preview-body' // comment previews
- ],
-
- // promises used when loading JSON
- promises : {},
-
- getStoredValues : function() {
- const defaults = this.defaults;
- this.settings = {
- rangeHeight : GM_getValue('rangeHeight', defaults.rangeHeight),
- activeZoom : GM_getValue('activeZoom', defaults.activeZoom),
- caseSensitive : GM_getValue('caseSensitive', defaults.caseSensitive),
- insertAsImage : GM_getValue('insertAsImage', defaults.insertAsImage),
- sources : GM_getValue('sources', defaults.sources),
-
- date : GM_getValue('date', 0)
- };
-
- this.collections = GM_getValue('collections', {});
-
- debug('Retrieved stored values & collections', this.settings, this.collections);
- },
-
- storeVal : function(key, set, $el) {
- let tmp,
- val = set[key];
- GM_setValue(key, val);
- if (typeof val === 'boolean') {
- $el.prop('checked', val);
- } else {
- $el.val(val);
- }
- // update sliders
- if ($el.hasClass('ghe-height')) {
- tmp = val.split(';');
- $el.data('ionRangeSlider').update({
- from: tmp[0],
- to: tmp[1]
- });
- } else if ($el.hasClass('ghe-zoom')) {
- $el.data('ionRangeSlider').update({
- from: val
- });
- }
- },
-
- setStoredValues : function(reset) {
- let $el, tmp, len, indx;
- const s = ghe.settings,
- d = ghe.defaults,
- $panel = $('#ghe-settings-inner');
-
- ghe.busy = true;
- ghe.storeVal('caseSensitive', reset ? d : s, $panel.find('.ghe-case'));
- ghe.storeVal('insertAsImage', reset ? d : s, $panel.find('.ghe-image'));
- ghe.storeVal('activeZoom', reset ? d : s, $panel.find('.ghe-zoom'));
- ghe.storeVal('rangeHeight', reset ? d : s, $panel.find('.ghe-height'));
-
- GM_setValue('collections', this.collections);
- GM_setValue('date', s.date);
-
- if (reset) {
- // add defaults back into source list; but don't remove any new stuff
- len = d.sources.length;
- for (indx = 0; indx < len; indx++) {
- if (s.sources.indexOf(d.sources[indx]) < 0) {
- s.sources[s.sources.length] = d.sources[indx];
- }
- }
- } else if (reset === false) {
- // Refresh sources, so clear out collections
- this.collections = {};
- }
- tmp = s.sources;
- len = tmp.length;
- GM_setValue('sources', tmp);
- for (indx = 0; indx < len; indx++) {
- if ($panel.find('.ghe-source').eq(indx).length) {
- $el = $panel
- .find('.ghe-source-input')
- .eq(indx)
- .attr('data-url', tmp[indx]);
- } else {
- $el = $(ghe.sourceHTML)
- .appendTo($panel.find('.ghe-sources'))
- .find('.ghe-source-input')
- .attr('data-url', tmp[indx]);
- }
- // only show file name when not focused
- ghe.showFileName($el);
- }
- // remove extras
- $panel.find('.ghe-source').filter(':gt(' + len + ')').remove();
- if (reset) {
- this.updateSettings();
- }
- if (typeof reset === 'boolean') {
- // reset autocomplete after refresh or restore so we're using the
- // most up-to-date collection data
- $('.comment-form-textarea').atwho('destroy');
- }
- debug((reset ? 'Resetting' : 'Saving') + ' current values & updating panel', s);
- ghe.busy = false;
- },
-
- updateSettings : function() {
- this.isUpdating = true;
- const settings = this.settings,
- $panel = $('#ghe-settings-inner');
- settings.rangeHeight = $panel.find('.ghe-height').val();
- settings.activeZoom = $panel.find('.ghe-zoom').val();
- settings.insertAsImage = $panel.find('.ghe-image').is(':checked');
- settings.caseSensitive = $panel.find('.ghe-case').is(':checked');
- settings.sources = $panel.find('.ghe-source-input').map(function() {
- return $(this).attr('data-url');
- }).get();
-
- // update case-sensitive regex
- this.setRegex();
-
- debug('Updating user settings', settings);
- this.updateStyleSheet();
- this.isUpdating = false;
- },
-
- loadEmojiJson : function(update) {
- // only load emoji.json once a day, or after a forced update
- if (update || (new Date().getTime() > this.settings.date + this.vars.delay)) {
- let indx;
- const promises = [],
- sources = this.settings.sources,
- len = sources.length;
- for (indx = 0; indx < len; indx++) {
- promises[promises.length] = this.fetchCustomEmojis(sources[indx]);
- }
- $.when.apply(null, promises).done(function() {
- ghe.checkPage();
- ghe.promises = [];
- ghe.settings.date = new Date().getTime();
- GM_setValue('date', ghe.settings.date);
- GM_setValue('collections', ghe.collections);
- });
- }
- },
-
- fetchCustomEmojis : function(url) {
- if (!this.promises[url]) {
- this.promises[url] = $.Deferred(function(defer) {
- debug('Fetching custom emoji list', url);
- GM_xmlhttpRequest({
- method : 'GET',
- url : url,
- onload : response => {
- let json = false;
- try {
- json = JSON.parse(response.responseText);
- } catch (err) {
- debug('Invalid JSON', url);
- return defer.reject();
- }
- if (json && json[0].name) {
- // save url to make removing the entry easier
- json[0].url = url;
- ghe.collections[json[0].name] = json;
- debug('Adding "' + json[0].name + '" Emoji Collection');
- }
- return defer.resolve();
- }
- });
- }).promise();
- }
- return this.promises[url];
- },
-
- // Using: document.evaluate('//*[text()[contains(.,":_")]]', document.body, null,
- // XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null).snapshotItem(0);
- // to find matching content as it is much faster than scanning each node
- checkPage : function() {
- this.isUpdating = true;
- let node,
- indx = 0;
- const parts = this.vars.emojiImgTemplate.split('${name}'), // parts = [':_', ':']
- // adding "//" starts from document, so if node is defined, don't
- // include it so the search starts from the node
- path = '//*[text()[contains(.,"' + parts[0] + '")]]',
- nodes = document.evaluate(path, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null),
- len = nodes.snapshotLength;
- try {
- node = nodes.snapshotItem(indx);
- while (node && indx++ < len) {
- if (!ghe.regex.skipElm.test(node.nodeName)) {
- ghe.findEmoji(node);
- }
- node = nodes.snapshotItem(indx);
- }
- } catch (e) {
- debug('Nothing to replace!', e);
- }
- this.isUpdating = false;
- },
-
- findEmoji : function(node) {
- let indx, len, group, match, matchesLen, name;
- const regex = ghe.regex.nameRegex,
- matches = [],
- emojis = this.collections,
- str = node.textContent;
- while ((match = regex.exec(str)) !== null) {
- matches[matches.length] = match[1];
- }
- if (matches && matches[0]) {
- matchesLen = matches.length;
- for (group in emojis) {
- // cycle through the collections (except text type)
- if (emojis.hasOwnProperty(group) && emojis[group][0].type !== 'text') {
- len = emojis[group].length;
- for (indx = 1; indx < len; indx++) {
- name = emojis[group][indx].name;
- for (match = 0; match < matchesLen; match++) {
- if (name === matches[match]) {
- debug('found "' + matches[match] + '" in "' + node.textContent + '"');
- ghe.replaceText(node, emojis[group][indx]);
- }
- }
- }
- }
- }
- }
- },
-
- replaceText : function(node, emoji) {
- let i, data, pos, imgnode, middlebit,
- name = this.vars.emojiImgTemplate.replace(ghe.regex.template, emoji.name),
- skip = 0;
- const isCased = this.settings.caseSensitive;
- name = isCased ? name : name.toUpperCase();
- // Code modified from highlight-5 (MIT license)
- // http://johannburkard.de/blog/programming/javascript/highlight-javascript-text-higlighting-jquery-plugin.html
- if (node.nodeType === 3) {
- data = isCased ? node.data : node.data.toUpperCase();
- pos = data.indexOf(name);
- pos -= (data.substr(0, pos).length - node.data.substr(0, pos).length);
- if (pos >= 0) {
- imgnode = ghe.createEmoji(emoji);
- middlebit = node.splitText(pos);
- middlebit.parentNode.replaceChild(imgnode, middlebit);
- skip = 1;
- }
- } else if (node.nodeType === 1 && node.childNodes) {
- for (i = 0; i < node.childNodes.length; ++i) {
- i += ghe.replaceText(node.childNodes[i], emoji);
- }
- }
- return skip;
- },
-
- // This function does the surrounding for every matched piece of text
- // and can be customized to do what you like
- // <img class="emoji" title=":smile:" alt=":smile:" src="x.png" height="20" width="20" align="absmiddle">
- createEmoji : function(emoji) {
- const el = document.createElement('img');
- el.src = emoji.url;
- el.className = ghe.vars.emojiClass + ' emoji';
- el.title = el.alt = ghe.vars.emojiImgTemplate.replace(ghe.regex.template, emoji.name);
- // el.align = 'absmiddle'; // deprecated attribute
- return el;
- },
-
- // used by autocomplete (atwho) filter function
- matches : function(query, labels) {
- if (query === '') {
- return 1;
- }
- labels = labels || '';
- let i, partial,
- count = 0;
- const isCS = this.settings.caseSensitive,
- arry = (isCS ? labels : labels.toUpperCase()).split(/[\s,_]+/),
- parts = (isCS ? query : query.toUpperCase()).split(/[,_]/),
- len = parts.length;
- for (i = 0; i < len; i++) {
- // full match or partial
- partial = arry.join('_').indexOf(parts.join('_'));
- if (arry.indexOf(parts[i]) > -1 || partial > -1) {
- count++;
- }
- // give more weight to results with indexOf closer to zero
- if (partial > -1 && partial < len / 2) {
- count++;
- }
- }
- // return fraction of query matches
- return count / len;
- },
-
- emojiSort : function(a, b) {
- return a.name > b.name ? 1 : a.name < b.name ? -1 : 0;
- },
-
- // init when comment textarea is focused
- initAutocomplete : function($el) {
- if (!$el.data('atwho')) {
- let indx, imgLen, txtLen, name, group,
- text = [],
- data = [];
- // combine data
- for (name in ghe.collections) {
- if (ghe.collections.hasOwnProperty(name)) {
- group = ghe.collections[name].slice(1);
- if (ghe.collections[name][0].type === 'text') {
- text = text.concat(group);
- } else {
- data = data.concat(group);
- }
- }
- }
- imgLen = data.length;
- if (imgLen) {
- // alphabetic sort
- data = data.sort(ghe.emojiSort);
- // add prepend name to labels
- for (indx = 0; indx < imgLen; indx++) {
- data[indx].labels = data[indx].name.replace(/_/g, ' ') + ' ' + data[indx].labels;
- }
- // add emoji autocomplete to comment textareas
- $el.atwho({
- // first two characters from emojiImgTemplate
- at : ghe.vars.emojiImgTemplate.split('${name}')[0],
- data : data,
- searchKey: 'labels',
- displayTpl : '<li><span><img src="${url}" height="30" /></span>${name}</li>',
- insertTpl : ghe.vars.emojiImgTemplate,
- delay : 400,
- callbacks : {
- matcher: function(flag, subtext) {
- const regexp = ghe.regex.emojiImgFilter,
- match = regexp.exec(subtext);
- // this next line does some magic...
- // for some reason, without it, moving the caret from "p" to "r" in
- // ":_people,fear," opens & closes the popup with each letter typed
- subtext.match(regexp);
- if (match) {
- return match[2] || match[1];
- } else {
- return null;
- }
- },
- filter: function(query, data, searchKey) {
- let i, item;
- const len = data.length,
- _results = [];
- for (i = 0; i < len; i++) {
- item = data[i];
- item.atwho_order = ghe.matches(query, item[searchKey]);
- if (item.atwho_order > 0.9) {
- _results[_results.length] = item;
- }
- }
- return query === '' ? _results : _results.sort(function(a, b) {
- // descending sort
- return b.atwho_order - a.atwho_order;
- });
- },
- sorter: function(query, items) {
- // sorted by filter
- return items;
- },
- // event parameter adding in atwho.js mod
- beforeInsert: function(value, $li, event) {
- if (event.shiftKey || ghe.settings.insertAsImage) {
- // add image tag directly if shift is held
- return '<img title="' +
- ghe.vars.emojiImgTemplate.replace(ghe.regex.template, $li.text()) +
- '" src="' + $li.find('img').attr('src') + '">';
- }
- return value;
- }
- }
- });
- }
-
- txtLen = text.length;
- if (txtLen) {
- // alphabetic sort
- text = text.sort(ghe.emojiSort);
- $el.atwho({
- at : ghe.vars.emojiTxtTemplate.split('${name}')[0],
- data : text,
- searchKey: 'name',
- // add data-emoji because of Emoji-One Chrome extension adds
- // hidden text and an svg image inside the span
- displayTpl : '<li data-emoji="${text}"><span class="ghe-text">${text}</span>${name}</li>',
- insertTpl : ghe.vars.emojiTxtTemplate,
- delay : 400,
- callbacks : {
- matcher: function(flag, subtext) {
- const regexp = ghe.regex.emojiTxtFilter,
- match = regexp.exec(subtext);
- // this next line does some magic...
- subtext.match(regexp);
- if (match) {
- return match[2] || match[1];
- } else {
- return null;
- }
- },
- filter: function(query, data, searchKey) {
- let i, item;
- const len = data.length,
- _results = [];
- for (i = 0; i < len; i++) {
- item = data[i];
- item.atwho_order = ghe.matches(query, item[searchKey]);
- if (item.atwho_order > 0.9) {
- _results[_results.length] = item;
- }
- }
- return query === '' ? _results : _results.sort(function(a, b) {
- // descending sort
- return b.atwho_order - a.atwho_order;
- });
- },
- sorter: function(query, items) {
- // sorted by filter
- return items;
- },
- // event parameter adding in atwho.js mod
- beforeInsert: function(value, $li) {
- return $li.attr('data-emoji');
- }
- }
- });
- }
- // use classes from GitHub-Dark to make theme match GitHub-Dark
- $('.atwho-view').addClass('popover suggester');
- }
- },
-
- addToolbarIcon : function() {
- // add Emoji setting icons
- let indx, $el;
- const $toolbars = $('.toolbar-commenting'),
- len = $toolbars.length;
- for (indx = 0; indx < len; indx++) {
- $el = $toolbars.eq(indx);
- if (!$el.find('.ghe-settings-icon').length) {
- $el.prepend([
- '<button type="button" class="ghe-settings-open toolbar-item tooltipped tooltipped-n tooltipped-multiline" aria-label="Browse collections & Set Emojis Options" tabindex="-1">',
- '<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">',
- '<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"/>',
- '<path d="M.878 8.777s3.167 1.893 8.002 1.92c4.365.02 8.135-1.92 8.135-1.92"/>',
- '</svg>',
- '</button>'
- ].join(''));
- }
- }
- },
-
- // dynamic stylesheet
- updateStyleSheet : function() {
- const range = this.settings.rangeHeight.split(';');
- ghe.$style.text([
- // img styling - vertically center with set height range
- '.atwho-view li img, #ghe-popup .select-menu-item img, img[alt="ghe-emoji"], .' +
- this.vars.emojiClass + ' { ' +
- 'margin-bottom:.25em; vertical-align:middle; ' +
- 'min-height: ' + (range[0] || 'none') + 'px;' +
- 'max-height: ' + (range[1] || 'none') + 'px }',
- // click (make active) on image to zoom
- '.' + this.vars.emojiClass + ':active, a:active img[alt="ghe-emoji"] { zoom:' +
- this.settings.activeZoom + ' }'
- ].join(''));
- },
-
- addBindings : function() {
- let lastKey;
- const $popup = $('#ghe-popup'),
- $settings = $('#ghe-settings');
- // Delegated bindings
- $('body')
- .on('click', '.ghe-settings-open', function() {
- // open all collections panel
- ghe.openCollections($(this));
- return false;
- })
- .on('click', '.ghe-collection', function() {
- // open targeted collection
- const name = $(this).attr('data-group');
- ghe.showCollection(name);
- })
- .on('click', '.ghe-emoji', function(e) {
- // click on emoji in collection to add to textarea
- ghe.addEmoji(e, $(this));
- })
- .on('click keypress keydown', function(e) {
- clearTimeout(ghe.timer);
- const panelVisible = $popup.hasClass('in') || $settings.hasClass('in'),
- openPanel = ghe.vars.keyboardOpen.split('+'),
- key = String.fromCharCode(e.which).toLowerCase();
- // press escape or click outside to close the panel
- if (panelVisible && e.which === 27 || e.type === 'click' && !$(e.target).closest('#ghe-wrapper').length) {
- ghe.closePanels();
- return;
- }
- // keydown is only needed for escape key detection
- if (e.type === 'keydown' || /(input|textarea)/i.test(document.activeElement.nodeName)) {
- return;
- }
- // shortcut keys need keypress
- if (lastKey === openPanel[0] && key === openPanel[1]) {
- if ($settings.hasClass('in')) {
- ghe.closePanels();
- } else {
- ghe.openSettings();
- }
- }
- lastKey = key;
- ghe.timer = setTimeout(function() {
- lastKey = null;
- }, ghe.vars.keyboardDelay);
-
- // add shortcut to help menu
- if (key === '?') {
- // table doesn't exist until user presses "?"
- setTimeout(function() {
- if (!$('.ghe-shortcut').length) {
- $('.keyboard-mappings:eq(0) tbody:eq(0)').append([
- '<tr class="ghe-shortcut">',
- '<td class="keys">',
- '<kbd>' + openPanel[0] + '</kbd> <kbd>' + openPanel[1] + '</kbd>',
- '</td>',
- '<td>GitHub Emojis: open settings</td>',
- '</tr>'
- ].join(''));
- }
- }, 300);
- }
- });
-
- // popup & settings interactions
- $('#ghe-popup .octicon-gear').on('click keyup', function(e) {
- if (e.type === 'keyup' && e.which !== 13) {
- return;
- }
- ghe.openSettings();
- });
- $('#ghe-settings, #ghe-settings-close, #ghe-settings-inner').on('click', function(e) {
- if (this.id === 'ghe-settings-inner') {
- e.stopPropagation();
- } else {
- ghe.closePanels();
- }
- });
- // ghe-checkbox added to checkboxes
- $('.ghe-checkbox').on('change', function() {
- ghe.updateSettings();
- });
- // go back - switch from single collection to showing all collections
- $('#ghe-popup .ghe-back').on('click', function() {
- $('.ghe-single-collection, .ghe-back').hide();
- $('.ghe-all-collections').show();
- });
-
- // add new source input
- $('#ghe-add-source').on('click', function() {
- const $panel = $('#ghe-settings-inner');
- // lets not get crazy!
- if ($panel.find('.ghe-source').length < 20) {
- $(ghe.sourceHTML).appendTo($panel.find('.ghe-sources'));
- }
- return false;
- });
- $('#ghe-refresh-sources, #ghe-restore').on('click', function() {
- // update sources from settings panel
- ghe.setStoredValues(this.id === 'ghe-restore');
- // load json files
- ghe.loadEmojiJson(true);
- return false;
- });
-
- // Init range slider
- $('.ghe-height')
- .val(ghe.settings.rangeHeight)
- .ionRangeSlider({
- type : 'double',
- min : 0,
- max : ghe.vars.maxEmojiHeight,
- onChange : function() {
- ghe.updateSettings();
- },
- force_edges : true,
- hide_min_max : true
- });
- $('.ghe-zoom')
- .val(ghe.settings.activeZoom)
- .ionRangeSlider({
- min : 0,
- max : ghe.vars.maxEmojiZoom,
- step : 0.1,
- onChange : function() {
- ghe.updateSettings();
- },
- force_edges : true,
- hide_min_max : true
- });
-
- // Remove source input - delegated binding
- $('.ghe-settings-wrapper')
- .on('click', '.ghe-remove', function() {
- const $wrapper = $(this).closest('.ghe-source'),
- url = $wrapper.find('.ghe-source-input').attr('data-url');
- ghe.removeSource(url);
- $wrapper.remove();
- ghe.setStoredValues();
- return false;
- })
- .on('focus blur input change', '.ghe-source-input', function(e) {
- if (ghe.busy) { return; }
- ghe.busy = true;
- let val;
- const $this = $(this);
- switch (e.type) {
- case 'focus':
- case 'focusin':
- // show entire url when focused
- $this.val($this.attr('data-url'));
- break;
- case 'blur':
- case 'focusout':
- ghe.showFileName($this);
- break;
- default:
- $this.attr('data-url', $this.val());
- }
- if (e.type === 'change' || e.which === 13) {
- val = $this.val();
- $this.attr('data-url', val);
- ghe.fetchCustomEmojis(val);
- }
- ghe.busy = false;
- });
-
- // initialize autocomplete that add emojis, but only on focus
- // since every comment has a hidden textarea
- $('body').on('focus', '.comment-form-textarea', function() {
- ghe.initAutocomplete($(this));
- });
- },
-
- showFileName : function($el) {
- const str = $el.attr('data-url'),
- v = str.substring(str.lastIndexOf('/') + 1, str.length);
- // show only the file name in the input when blurred
- // unless there is no file name
- $el.val(v === '' ? str : '...' + v);
- },
-
- closePanels : function() {
- $('#ghe-popup').removeClass('in');
- $('#ghe-settings').removeClass('in');
- ghe.$currentInput = null;
- },
-
- openSettings : function() {
- $('.modal-backdrop').click();
- $('#ghe-settings').addClass('in');
- },
-
- openCollections : function($el) {
- ghe.addCollections();
- const pos = $el.offset();
- $('#ghe-settings').removeClass('in');
- $('#ghe-popup')
- .addClass('in')
- .css({
- left: pos.left + 25,
- top: pos.top
- });
- ghe.$currentInput = $el.closest('.previewable-comment-form').find('.comment-form-textarea');
- },
-
- addCollections : function() {
- let indx, len, key, group, item, emoji,
- list = [];
- const collections = ghe.collections,
- range = ghe.settings.rangeHeight.split(';'),
- items = [];
- // build collections list -
- for (key in collections) {
- if (collections.hasOwnProperty(key)) {
- list[list.length] = key;
- }
- }
- list = list.sort(function(a, b) {
- return a > b ? 1 : (a < b ? -1 : 0);
- });
- len = list.length;
- // add random image from group
- for (indx = 0; indx < len; indx++) {
- group = collections[list[indx]];
- // random image (skip first entry)
- item = Math.round(Math.random() * (group.length - 2)) + 1;
- emoji = group[item];
- items[items.length] = '<div class="select-menu-item js-navigation-item ghe-collection' +
- (emoji.url ? '' : ' ghe-text-collection') +
- '" data-group="' + list[indx] + '">' +
- // collection info stored in first entry
- group[0].name + ' <span class="ghe-right' +
- (emoji.url ?
- // images
- '"><img src="' + emoji.url + '" title="' +
- ghe.vars.emojiImgTemplate.replace(ghe.regex.template, emoji.name) + '" style="' +
- 'min-height:' + (range[0] || 'none') + 'px;' +
- 'max-height:' + (range[1] || 'none') + 'px;">' :
- // text
- ' ghe-text" title="' + emoji.name + '" style="font-size:' + group[0].previewSize +
- '">' + emoji.text
- ) + '</span></div>';
- }
- $('.ghe-single-collection, .ghe-back').hide();
- $('.ghe-all-collections').html(items.join('')).show();
- },
-
- showCollection : function(name) {
- let indx, emoji;
- const range = ghe.settings.rangeHeight.split(';'),
- group = ghe.collections[name].slice(1).sort(ghe.emojiSort),
- list = [],
- len = group.length;
- for (indx = 1; indx < len; indx++) {
- emoji = group[indx];
- list[indx - 1] = '<div class="select-menu-item js-navigation-item ghe-emoji' +
- (emoji.url ? '' : ' ghe-text-emoji') +
- '" data-name="' + emoji.name + '">' +
- emoji.name + '<span class="ghe-right' +
- (emoji.url ?
- // images
- '"><img src="' + emoji.url + '" style="' +
- 'min-height:' + (range[0] || 'none') + 'px;' +
- 'max-height:' + (range[1] || 'none') + 'px">' :
- // text type
- ' ghe-text" style="font-size:' + ghe.collections[name][0].previewSize +
- // data-emoji needed because Chrome emoji-one extension adds hidden
- // text inside the span when it replaces the text with an svg
- '" data-emoji="' + emoji.text + '">' + emoji.text
- ) + '</span></div>';
- }
- $('.ghe-all-collections').hide();
- $('.ghe-single-collection').html(list.join('')).show();
- $('.ghe-back').show();
- },
-
- // add emoji from collection
- addEmoji : function(e, $el) {
- let val, emoji;
- const $img = $el.find('img'),
- name = $el.attr('data-name'),
- caretPos = ghe.$currentInput.caret('pos');
- if ($img.length) {
- // insert into textarea
- if (e.shiftKey || ghe.settings.insertAsImage) {
- // add image tag directly if shift is held;
- // GitHub does NOT allow class names so we are forced to use alt
- emoji = '<img alt="ghe-emoji" title="' +
- ghe.vars.emojiImgTemplate.replace(ghe.regex.template, name) +
- '" src="' + $el.find('img').attr('src') + '">';
- } else {
- emoji = ghe.vars.emojiImgTemplate.replace(ghe.regex.template, name);
- }
- } else {
- // insert text emoji
- emoji = $el.find('span').attr('data-emoji');
- }
- val = ghe.$currentInput.val();
- ghe.$currentInput
- .val(val.slice(0, caretPos) + emoji + ' ' + val.slice(caretPos))
- .focus()
- .caret('pos', caretPos + emoji.length + 1);
- ghe.closePanels();
- },
-
- removeSource : function(url) {
- let indx;
- const list = [],
- collections = this.collections,
- sources = this.settings.sources,
- len = sources.length;
- // remove from source
- for (indx = 0; indx < len; indx++) {
- if (sources[indx] !== url) {
- list[list.length] = sources[indx];
- }
- }
- this.settings.sources = list;
- for (indx in collections) {
- if (collections.hasOwnProperty(indx) && collections[indx][0].url === url) {
- delete collections[indx];
- debug('Removing "' + indx + '" collection', collections);
- }
- }
- },
-
- update : function() {
- this.isUpdating = true;
- this.addToolbarIcon();
- // checkPage clears isUpdating flag
- this.checkPage();
- },
-
- addPanels : function() {
- /* https://github.com/ichord/At.js styles for autocomplete */
- GM_addStyle([
- // settings panel
- '#ghe-menu:hover { cursor:pointer }',
- '#ghe-settings { position:fixed; z-index:-1; top:0; bottom:0; left:0; right:0; opacity:0; visibility:hidden }',
- '#ghe-settings.in { opacity:1; visibility:visible; z-index:65535; background:rgba(0,0,0,.5) }',
- '#ghe-settings-inner { position:fixed; left:50%; top:50%; transform:translate(-50%,-50%); width:25rem; box-shadow:0 .5rem 1rem #111; color:#c0c0c0 }',
- '#ghe-settings label { margin-left:.5rem; position:relative; top:-1px }',
- '#ghe-settings .ghe-remove { float:right; margin-top:2px; padding:4px; cursor:pointer }',
- '#ghe-settings .ghe-remove-icon { position:relative; top:3px }',
- '#ghe-settings-close { fill:#666; float:right; cursor:pointer }',
- '#ghe-settings-close:hover { fill:#ccc }',
- '#ghe-settings .ghe-settings-wrapper { max-height:60vh; overflow-y:auto; padding:1px 10px; margin-top:6px }',
- '#ghe-settings .ghe-right, #ghe-popup .ghe-right { float:right }',
- '#ghe-settings p { line-height:25px; }',
- '#ghe-settings .checkbox input { margin-top:.35em }',
- '#ghe-settings input[type="checkbox"] { width:16px !important; height:16px !important; border-radius:3px !important }',
- '#ghe-settings .boxed-group-inner { padding:0; }',
- '#ghe-settings .ghe-footer { padding: 10px; border-top: #555 solid 1px; }',
- '#ghe-settings .ghe-min-height, #ghe-settings .ghe-max-height, .ghe-zoom { width: 5em; }',
- '#ghe-settings .ghe-source-input { width: 90%; padding:3px; margin:3px 0; border-style:solid; border-width:1px }',
- '#ghe-settings .ghe-slider-wrapper { height:40px; }',
- '#ghe-settings .ghe-slider-wrapper label { position:relative; top:22px }',
- '#ghe-settings .ghe-range-slider, #ghe-settings .ghe-zoom-slider { position:relative; height:40px; width:250px; float:right }',
-
- // show emoji collections
- '#ghe-popup { display:none }',
- '#ghe-popup .ghe-content, #ghe-popup .ghe-content > div { max-height: 200px }',
- '#ghe-popup .octicon-gear { margin-left:4px }',
- '#ghe-popup .ghe-back svg { height:20px; padding:4px 14px 4px 4px }',
- '#ghe-popup .select-menu-item { font-size:1.1em; font-weight:bold; line-height:40px; padding:8px }',
- '#ghe-popup .select-menu-item.ghe-text-emoji { line-height:inherit; position:relative; padding-right:45px }',
- '#ghe-popup .select-menu-item.ghe-text-emoji .ghe-text { position:absolute; right:10px; top:0 }',
- '#ghe-popup .select-menu-item .ghe-text, .atwho-view .ghe-text { font-size:1.6em }',
- '.ghe-settings-icon, #ghe-popup.in { display:inline-block; vertical-align:middle }',
-
- // autocomplete popup in comment
- '.atwho-view { position:absolute; top:0; left:0; display:none; margin-top:18px; 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 }',
- '.atwho-view .cur { background:#36f; color:#fff }',
- '.atwho-view .cur small { color:#fff }',
- '.atwho-view strong { color:#36F }',
- '.atwho-view .cur strong { color:#fff; font:700 }',
- '.atwho-view ul { list-style:none; padding:0; margin:auto; max-height:200px; overflow-y:auto; }',
- '.atwho-view ul li { display:block; padding:5px 10px; border-bottom:1px solid #ddd; cursor:pointer }',
- '.atwho-view li span { display:inline-block; min-width:60px; padding-right:4px }',
- '.atwho-view small { font-size:smaller; color:#777; font-weight:400 }',
-
- // rangeSlider
- '.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}',
- '.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}',
- '.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}',
- '.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}',
- '.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}',
- '.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}',
- '.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}',
- '.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}',
- '.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}',
- '.irs{height:40px}.irs-with-grid{height:60px}.irs-line{height:12px;top:25px}.irs-line-left{height:12px;background-position:0 -30px}',
- '.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}',
- '.irs-bar-edge{top:25px;height:12px;width:9px;background-position:0 -90px}.irs-shadow{height:3px;top:34px;background:#000;opacity:.25}',
- '.lt-ie9 .irs-shadow{filter:alpha(opacity=25)}.irs-slider{width:16px;height:18px;top:22px;background-position:0 -120px}',
- '.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}',
- '.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}',
- '.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}',
- '.irs-grid-pol{background:#e1e4e9}.irs-grid-text{color:#999}'
- ].join(''));
-
- // Settings panel markup
- $('body').append([
- '<div id="ghe-wrapper">',
- '<div id="ghe-popup" class="select-menu-modal-holder js-menu-content js-navigation-container js-active-navigation-container">',
- '<div class="select-menu-modal">',
- '<div class="select-menu-header">',
- '<span class="select-menu-title">',
- '<text>Emoji Collections</text>',
- '<span class="octicon tooltipped tooltipped-w" aria-label="Change GitHub Custom Emoji Settings">',
- '<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>',
- '</span>',
- '<span class="octicon tooltipped tooltipped-w ghe-back" aria-label="Go back to see all collections">',
- '<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>',
- '</span>',
- '</span>',
- '</div>',
- '<div class="js-select-menu-deferred-content ghe-content">',
- '<div class="select-menu-list ghe-all-collections"></div>',
- '<div class="select-menu-list ghe-single-collection"></div>',
- '</div>',
- '</div>',
- '</div>',
- '<div id="ghe-settings">',
- '<div id="ghe-settings-inner" class="boxed-group">',
- '<h3>GitHub Custom Emoji Settings',
- '<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>',
- '</h3>',
- '<div class="boxed-group-inner">',
- '<form>',
- '<div class="ghe-settings-wrapper">',
- '<p>',
- '<label>Insert as Image:',
- '<sup class="tooltipped tooltipped-e" aria-label="Or Shift + select the emoji">?</sup>',
- '<input class="ghe-image ghe-checkbox ghe-right" type="checkbox">',
- '</label>',
- '</p>',
- '<p class="checkbox">',
- '<label>Case Sensitive <input class="ghe-case ghe-checkbox ghe-right" type="checkbox"></label>',
- '</p>',
- '<div class="ghe-slider-wrapper">',
- '<div class="ghe-range-slider">',
- '<input type="text" class="ghe-height" value="" />',
- '</div>',
- '<label>Emoji Height',
- '<sup class="tooltipped tooltipped-e" aria-label="Set emoji minimum & maximum height in pixels">?</sup>',
- '</label>',
- '</div>',
- '<div class="ghe-slider-wrapper">',
- '<div class="ghe-zoom-slider">',
- '<input class="ghe-zoom ghe-right" type="text">',
- '</div>',
- '<label>Emoji Zoom',
- '<sup class="tooltipped tooltipped-e" aria-label="Set Emoji zoom factor while actively clicked">?</sup>',
- '</label>',
- '</div>',
- '<p>',
- '<hr>',
- '<h3>Sources',
- '<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">',
- '<sup>?</sup>',
- '</a>',
- '</h3>',
- '<div class="ghe-sources"></div>',
- '</p>',
- '</div>',
- '<div class="ghe-footer">',
- '<a href="#" id="ghe-restore" class="btn btn-sm btn-danger tooltipped tooltipped-n ghe-right" aria-label="Default sources are restored; other source will remain">Restore Defaults</a>',
- '<div class="btn-group">',
- '<a href="#" id="ghe-add-source" class="btn btn-sm">Add Source</a>',
- '<a href="#" id="ghe-refresh-sources" class="btn btn-sm">Refresh Sources</a> ',
- '</div>',
- '</div>',
- '</form>',
- '</div>',
- '</div>',
- '</div>',
- '</div>'
- ].join(''));
- },
-
- // JSON source inputs
- sourceHTML : [
- '<div class="ghe-source">',
- '<input class="ghe-source-input" type="text" value="" placeholder="Add JSON sources only">',
- '<a href="#" class="ghe-remove btn btn-sm btn-danger">',
- '<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>',
- '</a>',
- '</div>'
- ].join(''),
-
- setRegex : function() {
- const isCS = this.settings.caseSensitive,
- // parts = [':_', ':']
- imgParts = this.vars.emojiImgTemplate.split('${name}'),
- txtParts = this.vars.emojiTxtTemplate.split('${name}');
-
- // filter = /:_([a-zA-Z\u00c0-\u00ff0-9_,'.+-]*)$|:_([^\x00-\xff]*)$/gi
- // used by atwho.js autocomplete
- this.regex.emojiImgFilter = new RegExp(
- imgParts[0] + '([a-zA-Z\u00c0-\u00ff0-9_,\'.+-]*)$|' +
- imgParts[0] + '([^\\x00-\\xff]*)$',
- (isCS ? 'g' : 'gi')
- );
-
- this.regex.emojiTxtFilter = new RegExp(
- txtParts[0] + '([a-zA-Z\u00c0-\u00ff0-9_,\'.+-]*)$|' +
- txtParts[0] + '([^\\x00-\\xff]*)$',
- (isCS ? 'g' : 'gi')
- );
-
- // used by search & replace
- this.regex.nameRegex = new RegExp(
- imgParts[0] + '([\\w_]+)' + imgParts[1],
- (isCS ? 'g' : 'gi')
- );
- },
-
- init : function() {
- debug('GitHub-Emoji Script initializing!');
-
- // add style tag to head
- this.$style = $('<style class="ghe-style">').appendTo('head');
-
- this.getStoredValues();
- this.loadEmojiJson();
- this.updateStyleSheet();
- this.isUpdating = true;
- // regex based on case sensitive setting
- this.setRegex();
-
- const targets = document.querySelectorAll(this.containers.join(','));
- Array.prototype.forEach.call(targets, function(target) {
- new MutationObserver(function(mutations) {
- mutations.forEach(function(mutation) {
- // preform checks before adding code wrap to minimize function calls
- if (mutation.target === target && !$.isEmptyObject(ghe.collections) &&
- !(ghe.isUpdating || target.querySelector('.ghe-processed'))) {
- ghe.update();
- }
- });
- }).observe(target, {
- childList : true,
- subtree : true
- });
- });
-
- this.addPanels();
-
- // Add emoji autocomplete & watch for preview rendering
- this.addToolbarIcon();
- this.addBindings();
- // update panel values after bindings (rangeslider)
- this.setStoredValues();
-
- // checkPage clears isUpdating flag
- this.checkPage();
- }
- };
-
- // add style at document-start
- ghe.init();
-
- // include a "?debug" anywhere in the browser URL to enable debugging
- function debug() {
- if (/\?debug/.test(window.location.href)) {
- console.log.apply(console, arguments);
- }
- }
- })(jQuery.noConflict(true));