// ==UserScript==
// @name GitHub Custom Emojis
// @version 0.2.5
// @description Add custom emojis from json source
// @namespace https://github.com/StylishThemes
// @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
// @downloadURL none
// ==/UserScript==
/* global jQuery, GM_addStyle, GM_getValue, GM_setValue, GM_xmlhttpRequest, GM_info */
/* eslint-disable indent, quotes */
(function($) {
'use strict';
var 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() {
var 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) {
var 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) {
var $el, tmp, len, indx,
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;
var 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)) {
var indx,
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 : function(response) {
var 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;
var node,
indx = 0,
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) {
var indx, len, group, match, matchesLen, name,
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) {
var data, pos, imgnode, middlebit, endbit,
isCased = this.settings.caseSensitive,
name = this.vars.emojiImgTemplate.replace(ghe.regex.template, emoji.name),
skip = 0;
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);
endbit = middlebit.splitText(name.length);
middlebit.parentNode.replaceChild(imgnode, middlebit);
skip = 1;
}
} else if (node.nodeType === 1 && node.childNodes) {
for (var 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
//
createEmoji : function(emoji) {
var 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 || '';
var i, partial,
count = 0,
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')) {
var 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 : '
${name}
',
insertTpl : ghe.vars.emojiImgTemplate,
delay : 400,
callbacks : {
matcher: function(flag, subtext) {
var 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) {
var i, item,
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 '';
}
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 : '
${text}${name}
',
insertTpl : ghe.vars.emojiTxtTemplate,
delay : 400,
callbacks : {
matcher: function(flag, subtext) {
var 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) {
var i, item,
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) {
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
var indx, $el,
$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([
''
].join(''));
}
}
},
// dynamic stylesheet
updateStyleSheet : function() {
var 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() {
var lastKey,
$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
var 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);
var 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([
'
',
'
',
'' + openPanel[0] + '' + openPanel[1] + '',
'
',
'
GitHub Emojis: open settings
',
'
'
].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() {
var $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() {
var $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;
var val,
$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) {
var 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();
var 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() {
var indx, len, key, group, item, emoji,
collections = ghe.collections,
range = ghe.settings.rangeHeight.split(';'),
list = [],
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] = '
' +
// collection info stored in first entry
group[0].name + ' ' :
// text
' ghe-text" title="' + emoji.name + '" style="font-size:' + group[0].previewSize +
'">' + emoji.text
) + '
';
}
$('.ghe-single-collection, .ghe-back').hide();
$('.ghe-all-collections').html(items.join('')).show();
},
showCollection : function(name) {
var indx, emoji,
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] = '
' +
emoji.name + '' :
// 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
) + '