FEATURE: custom emojis

This commit is contained in:
Régis Hanol
2014-12-23 01:12:26 +01:00
parent 6e1601c10d
commit 45dbdb6896
3536 changed files with 477 additions and 245 deletions

View File

@@ -0,0 +1,20 @@
export default Ember.ArrayController.extend({
sortProperties: ["name"],
actions: {
emojiUploaded: function (emoji) {
this.pushObject(emoji);
},
destroy: function(emoji) {
var self = this;
return bootbox.confirm(I18n.t("admin.emoji.delete_confirm", { name: emoji.name }), I18n.t("no_value"), I18n.t("yes_value"), function (destroy) {
if (destroy) {
return Discourse.ajax("/admin/customize/emojis/" + emoji.name, { type: "DELETE" }).then(function() {
self.removeObject(emoji);
});
}
});
}
}
});

View File

@@ -0,0 +1,7 @@
export default Discourse.Route.extend({
model: function() {
return Discourse.ajax("/admin/customize/emojis.json").then(function(emojis) {
return emojis.map(function (emoji) { return Ember.Object.create(emoji); });
});
}
});

View File

@@ -18,8 +18,8 @@ Discourse.Route.buildRoutes(function() {
this.resource('adminSiteText', { path: '/site_text' }, function() {
this.route('edit', {path: '/:text_type'});
});
this.resource('adminUserFields', { path: '/user_fields' }, function() {
});
this.resource('adminUserFields', { path: '/user_fields' });
this.resource('adminEmojis', { path: '/emojis' });
});
this.route('api');

View File

@@ -5,6 +5,7 @@
<li>{{#link-to 'adminCustomize.css_html'}}{{i18n 'admin.customize.css_html.title'}}{{/link-to}}</li>
<li>{{#link-to 'adminSiteText'}}{{i18n 'admin.site_text.title'}}{{/link-to}}</li>
<li>{{#link-to 'adminUserFields'}}{{i18n 'admin.user_fields.title'}}{{/link-to}}</li>
<li>{{#link-to 'adminEmojis'}}{{i18n 'admin.emoji.title'}}{{/link-to}}</li>
</ul>
</div>
</div>

View File

@@ -0,0 +1,30 @@
<div class='emoji'>
<h2>{{i18n 'admin.emoji.title'}}</h2>
<p class="desc">{{i18n 'admin.emoji.help'}}</p>
<p>{{emoji-uploader done="emojiUploaded"}}</p>
{{#if controller}}
<div class="span8">
<table id="custom_emoji">
<thead>
<tr>
<th>{{i18n "admin.emoji.image"}}</th>
<th>{{i18n "admin.emoji.name"}}</th>
<th></th>
</tr>
</thead>
<tbody>
{{#each e in controller}}
<tr>
<th><img class="emoji" src="{{unbound e.url}}" title="{{unbound e.name}}"></th>
<th>:{{e.name}}:</th>
<th><button {{action "destroy" e}} class='btn btn-danger no-text pull-right'>{{fa-icon 'trash-o'}} </button></th>
</tr>
{{/each}}
</tbody>
</table>
</div>
{{/if}}
</div>

View File

@@ -38,8 +38,7 @@
<div class='form-display'><strong>{{f.name}}</strong></div>
<div class='form-display'>{{{f.description}}}</div>
<div class='form-display'>{{f.fieldName}}</div>
<div class='form-display'>
</div>
<div class='form-display'></div>
<div class='form-element controls'>
<button {{action "edit"}}class='btn btn-default'>{{fa-icon 'pencil'}} {{i18n 'admin.user_fields.edit'}}</button>
<button {{action "destroy"}}class='btn btn-danger'>{{fa-icon 'trash-o'}} {{i18n 'admin.user_fields.delete'}}</button>

View File

@@ -0,0 +1,19 @@
import UploadMixin from 'discourse/mixins/upload';
export default Em.Component.extend(UploadMixin, {
type: "emoji",
uploadUrl: "/admin/customize/emojis",
hasName: Em.computed.notEmpty("name"),
addDisabled: Em.computed.not("hasName"),
data: function() {
return Ember.isBlank(this.get("name")) ? {} : { name: this.get("name") };
}.property("name"),
uploadDone: function (data) {
this.set("name", null);
this.sendAction("done", data.result);
}
});

View File

@@ -0,0 +1,46 @@
// TODO: Make this a proper ES6 import
var ComposerView = require('discourse/views/composer').default;
ComposerView.on("initWmdEditor", function(){
if (!Discourse.SiteSettings.enable_emoji) { return; }
var template = Handlebars.compile(
"<div class='autocomplete'>" +
"<ul>" +
"{{#each options}}" +
"<li>" +
"<a href='#'><img src='{{src}}' class='emoji'> {{code}}</a>" +
"</li>" +
"{{/each}}" +
"</ul>" +
"</div>"
);
$('#wmd-input').autocomplete({
template: template,
key: ":",
transformComplete: function(v){ return v.code + ":"; },
dataSource: function(term){
return new Ember.RSVP.Promise(function(resolve) {
var full = ":" + term;
term = term.toLowerCase();
if (term === "") {
return resolve(["smile", "smiley", "wink", "sunny", "blush"]);
}
if (Discourse.Emoji.translations[full]) {
return resolve([Discourse.Emoji.translations[full]]);
}
var options = Discourse.Emoji.search(term, {maxResults: 5});
return resolve(options);
}).then(function(list) {
return list.map(function(i) {
return {code: i, src: Discourse.Emoji.urlFor(i)};
});
});
}
});
});

View File

@@ -0,0 +1,161 @@
var groups = [
{
name: "emoticons",
icons: ["smile","smiley","grinning","blush","relaxed","wink","heart_eyes","kissing_heart","kissing_closed_eyes","kissing","kissing_smiling_eyes","stuck_out_tongue_winking_eye","stuck_out_tongue_closed_eyes","stuck_out_tongue","flushed","grin","pensive","relieved","unamused","disappointed","persevere","cry","joy","sob","sleepy","disappointed_relieved","cold_sweat","sweat_smile","sweat","weary","tired_face","fearful","scream","angry","rage","triumph","confounded","laughing","yum","mask","sunglasses","sleeping","dizzy_face","astonished","worried","frowning","anguished","smiling_imp","imp","open_mouth","grimacing","neutral_face","confused","hushed","no_mouth","innocent","smirk","expressionless","man_with_gua_pi_mao","man_with_turban","cop","construction_worker","guardsman","baby","boy","girl","man","woman","older_man","older_woman","person_with_blond_hair","angel","princess","smiley_cat","smile_cat","heart_eyes_cat","kissing_cat","smirk_cat","scream_cat","crying_cat_face","joy_cat","pouting_cat","japanese_ogre","japanese_goblin","see_no_evil","hear_no_evil","speak_no_evil","skull","alien","poop","fire","sparkles","star2","dizzy","boom","anger","sweat_drops","droplet","zzz","dash","ear","eyes","nose","tongue","lips","thumbsup","thumbsdown","ok_hand","punch","fist","v","wave","raised_hand","open_hands","point_up_2","point_down","point_right","point_left","raised_hands","pray","point_up","clap","muscle","walking","runner","dancer","couple","family","two_men_holding_hands","two_women_holding_hands","couplekiss","couple_with_heart","dancers","ok_woman","no_good","information_desk_person","raising_hand","massage","haircut","nail_care","bride_with_veil","person_with_pouting_face","person_frowning","bow","tophat","crown","womans_hat","athletic_shoe","mans_shoe","sandal","high_heel","boot","shirt","necktie","womans_clothes","dress","running_shirt_with_sash","jeans","kimono","bikini","briefcase","handbag","pouch","purse","eyeglasses","ribbon","closed_umbrella","lipstick","yellow_heart","blue_heart","purple_heart","green_heart","heart","broken_heart","heartpulse","heartbeat","two_hearts","sparkling_heart","revolving_hearts","cupid","love_letter","kiss","ring","gem","bust_in_silhouette","busts_in_silhouette","speech_balloon","footprints","thought_balloon"]
},
{
name: "nature",
icons: ["dog","wolf","cat","mouse","hamster","rabbit","frog","tiger","koala","bear","pig","pig_nose","cow","boar","monkey_face","monkey","horse","sheep","elephant","panda_face","penguin","bird","baby_chick","hatched_chick","hatching_chick","chicken","snake","turtle","bug","bee","ant","beetle","snail","octopus","shell","tropical_fish","fish","dolphin","whale","whale2","cow2","ram","rat","water_buffalo","tiger2","rabbit2","dragon","racehorse","goat","rooster","dog2","pig2","mouse2","ox","dragon_face","blowfish","crocodile","camel","dromedary_camel","leopard","cat2","poodle","feet","bouquet","cherry_blossom","tulip","four_leaf_clover","rose","sunflower","hibiscus","maple_leaf","leaves","fallen_leaf","herb","ear_of_rice","mushroom","cactus","palm_tree","evergreen_tree","deciduous_tree","chestnut","seedling","blossom","globe_with_meridians","sun_with_face","full_moon_with_face","new_moon_with_face","new_moon","waxing_crescent_moon","first_quarter_moon","waxing_gibbous_moon","full_moon","waning_gibbous_moon","last_quarter_moon","waning_crescent_moon","last_quarter_moon_with_face","first_quarter_moon_with_face","crescent_moon","earth_africa","earth_americas","earth_asia","volcano","milky_way","stars","star","sunny","partly_sunny","cloud","zap","umbrella","snowflake","snowman","cyclone","foggy","rainbow","ocean"]
},
{
name: "objects",
icons: ["bamboo","gift_heart","dolls","school_satchel","mortar_board","flags","fireworks","sparkler","wind_chime","rice_scene","jack_o_lantern","ghost","santa","christmas_tree","gift","tanabata_tree","tada","confetti_ball","balloon","crossed_flags","crystal_ball","movie_camera","camera","video_camera","vhs","cd","dvd","minidisc","floppy_disk","computer","iphone","telephone","telephone_receiver","pager","fax","satellite","tv","radio","loud_sound","sound","speaker","mute","bell","no_bell","loudspeaker","mega","hourglass_flowing_sand","hourglass","alarm_clock","watch","unlock","lock","lock_with_ink_pen","closed_lock_with_key","key","mag_right","bulb","flashlight","high_brightness","low_brightness","electric_plug","battery","mag","bathtub","bath","shower","toilet","wrench","nut_and_bolt","hammer","door","smoking","bomb","gun","knife","pill","syringe","moneybag","yen","dollar","pound","euro","credit_card","money_with_wings","calling","e-mail","inbox_tray","outbox_tray","envelope","envelope_with_arrow","incoming_envelope","postal_horn","mailbox","mailbox_closed","mailbox_with_mail","mailbox_with_no_mail","postbox","package","pencil","page_facing_up","page_with_curl","bookmark_tabs","bar_chart","chart_with_upwards_trend","chart_with_downwards_trend","scroll","clipboard","date","calendar","card_index","file_folder","open_file_folder","scissors","pushpin","paperclip","black_nib","pencil2","straight_ruler","triangular_ruler","closed_book","green_book","blue_book","orange_book","notebook","notebook_with_decorative_cover","ledger","books","book","bookmark","name_badge","microscope","telescope","newspaper","art","clapper","microphone","headphones","musical_score","musical_note","notes","musical_keyboard","violin","trumpet","saxophone","guitar","space_invader","video_game","black_joker","flower_playing_cards","mahjong","game_die","dart","football","basketball","soccer","baseball","tennis","8ball","rugby_football","bowling","golf","mountain_bicyclist","bicyclist","checkered_flag","horse_racing","trophy","ski","snowboarder","swimmer","surfer","fishing_pole_and_fish"]
},
{
name: "foods",
icons: ["coffee","tea","sake","baby_bottle","beer","beers","cocktail","tropical_drink","wine_glass","fork_and_knife","pizza","hamburger","fries","poultry_leg","meat_on_bone","spaghetti","curry","fried_shrimp","bento","sushi","fish_cake","rice_ball","rice_cracker","rice","ramen","stew","oden","dango","egg","bread","doughnut","custard","icecream","ice_cream","shaved_ice","birthday","cake","cookie","chocolate_bar","candy","lollipop","honey_pot","apple","green_apple","tangerine","lemon","cherries","grapes","watermelon","strawberry","peach","melon","banana","pear","pineapple","sweet_potato","eggplant","tomato","corn"]
},
{
name: "places",
icons: ["house","house_with_garden","school","office","post_office","hospital","bank","convenience_store","love_hotel","hotel","wedding","church","department_store","european_post_office","city_sunset","city_dusk","japanese_castle","european_castle","tent","factory","tokyo_tower","japan","mount_fuji","sunrise_over_mountains","sunrise","night_with_stars","statue_of_liberty","bridge_at_night","carousel_horse","ferris_wheel","fountain","roller_coaster","ship","sailboat","speedboat","rowboat","anchor","rocket","airplane","seat","helicopter","steam_locomotive","tram","station","mountain_railway","train2","bullettrain_side","bullettrain_front","light_rail","metro","monorail","train","railway_car","trolleybus","bus","oncoming_bus","blue_car","oncoming_automobile","red_car","taxi","oncoming_taxi","articulated_lorry","truck","rotating_light","police_car","oncoming_police_car","fire_engine","ambulance","minibus","bike","aerial_tramway","suspension_railway","mountain_cableway","tractor","barber","busstop","ticket","vertical_traffic_light","traffic_light","warning","construction","beginner","fuelpump","izakaya_lantern","slot_machine","hotsprings","moyai","circus_tent","performing_arts","round_pushpin","triangular_flag_on_post","cn","us","in","jp","br","ru","de","ng","gb","fr","mx","kr","id","ph","eg","vn","tr","it","es","ca","pl","ar","co","ir","za","my","pk","au","th","ma","tw","nl","ua","sa","ke","ve","pe","ro","cl","uz","bd","kz","be","se","cz","sd","hu","pt","ch","at","tz"]
},
{
name: "symbols",
icons: ["hash","one","two","three","four","five","six","seven","eight","nine","zero","keycap_ten","1234","symbols","arrow_up","arrow_down","arrow_left","arrow_right","capital_abcd","abcd","abc","arrow_upper_right","arrow_upper_left","arrow_lower_right","arrow_lower_left","left_right_arrow","arrow_up_down","arrows_counterclockwise","arrow_backward","arrow_forward","arrow_up_small","arrow_down_small","leftwards_arrow_with_hook","arrow_right_hook","information_source","rewind","fast_forward","arrow_double_up","arrow_double_down","arrow_heading_down","arrow_heading_up","ok","twisted_rightwards_arrows","repeat","repeat_one","new","up","cool","free","ng","signal_strength","cinema","koko","u6307","u7a7a","u6e80","u5408","u7981","ideograph_advantage","u5272","u55b6","u6709","u7121","restroom","mens","womens","baby_symbol","wc","potable_water","put_litter_in_its_place","parking","wheelchair","no_smoking","u6708","u7533","sa","m","passport_control","baggage_claim","left_luggage","customs","accept","secret","congratulations","cl","sos","id","no_entry_sign","underage","no_mobile_phones","do_not_litter","non-potable_water","no_bicycles","no_pedestrians","children_crossing","no_entry","eight_spoked_asterisk","sparkle","negative_squared_cross_mark","white_check_mark","eight_pointed_black_star","heart_decoration","vs","vibration_mode","mobile_phone_off","a","b","ab","o2","diamond_shape_with_a_dot_inside","loop","recycle","aries","taurus","gemini","cancer","leo","virgo","libra","scorpius","sagittarius","capricorn","aquarius","pisces","ophiuchus","six_pointed_star","atm","chart","heavy_dollar_sign","currency_exchange","copyright","registered","tm","part_alternation_mark","wavy_dash","top","end","back","on","soon","x","o","exclamation","question","grey_exclamation","grey_question","bangbang","interrobang","arrows_clockwise","clock12","clock1230","clock1","clock130","clock2","clock230","clock3","clock330","clock4","clock430","clock5","clock530","clock6","clock7","clock8","clock9","clock10","clock11","clock630","clock730","clock830","clock930","clock1030","clock1130","heavy_multiplication_x","heavy_plus_sign","heavy_minus_sign","heavy_division_sign","spades","hearts","clubs","diamonds","white_flower","100","heavy_check_mark","ballot_box_with_check","radio_button","link","curly_loop","trident","black_square_button","white_square_button","black_medium_square","white_medium_square","black_medium_small_square","white_medium_small_square","black_small_square","white_small_square","small_red_triangle","white_large_square","black_large_square","black_circle","white_circle","red_circle","large_blue_circle","small_red_triangle_down","large_orange_diamond","large_blue_diamond","small_orange_diamond","small_blue_diamond"]
}
];
// scrub groups
groups.forEach(function(group){
group.icons = _.reject(group.icons, function(obj){
return !Discourse.Emoji.exists(obj);
});
});
// export so others can modify
Discourse.Emoji.groups = groups;
var closeSelector = function(){
$('.emoji-modal, .emoji-modal-wrapper').remove();
$('body, textarea').off('keydown.emoji');
};
var ungroupedIcons;
var toolbar = function(selected){
if(!ungroupedIcons){
ungroupedIcons = [];
var groupedIcons = {};
_.each(groups, function(group){
_.each(group.icons, function(icon){
groupedIcons[icon] = true;
});
});
var emojis = Discourse.Emoji.list();
_.each(emojis,function(emoji){
if(groupedIcons[emoji] !== true){
ungroupedIcons.push(emoji);
}
});
if(ungroupedIcons.length > 0){
groups.push({name: 'ungrouped', icons: ungroupedIcons});
}
}
return _.map(groups, function(g, i){
var row = {src: Discourse.Emoji.urlFor(g.icons[0]), groupId: i};
if(i===selected){
row.selected = true;
}
return row;
});
};
var PER_ROW = 12, PER_PAGE = 60;
var bindEvents = function(page,offset){
var composerController = Discourse.__container__.lookup('controller:composer');
$('.emoji-page a').click(function(){
composerController.appendTextAtCursor(":" + $(this).attr('title') + ":", {space: true});
closeSelector();
return false;
}).hover(function(){
var title = $(this).attr('title');
var html = "<img src='" + Discourse.Emoji.urlFor(title) + "' class='emoji'> <span>:" + title + ":<span>";
$('.emoji-modal .info').html(html);
},function(){
$('.emoji-modal .info').html("");
});
$('.emoji-modal .nav .next a').click(function(){
render(page, offset+PER_PAGE);
});
$('.emoji-modal .nav .prev a').click(function(){
render(page, offset-PER_PAGE);
});
$('.emoji-modal .toolbar a').click(function(){
var page = parseInt($(this).data('group-id'));
render(page,0);
return false;
});
};
var render = function(page, offset){
var rows = [];
var row = [];
var icons = groups[page].icons;
var max = offset + PER_PAGE;
for(var i=offset; i<max; i++){
if(!icons[i]){ break; }
if(row.length === PER_ROW){
rows.push(row);
row = [];
}
row.push({src: Discourse.Emoji.urlFor(icons[i]), title: icons[i]});
}
rows.push(row);
var model = {
toolbarItems: toolbar(page),
rows: rows,
prevDisabled: offset === 0,
nextDisabled: (max + 1) > icons.length
};
$('body .emoji-modal').remove();
var rendered = Ember.TEMPLATES["emoji-toolbar.raw"](model);
$('body').append(rendered);
bindEvents(page, offset);
};
var showSelector = function(){
$('body').append('<div class="emoji-modal-wrapper"></div>');
$('.emoji-modal-wrapper').click(function(){
closeSelector();
});
render(0,0);
$('body, textarea').on('keydown.emoji', function(e){
if(e.which === 27){
closeSelector();
return false;
}
});
};
window.PagedownCustom.appendButtons.push({
id: 'wmd-emoji-button',
description: I18n.t("composer.emoji"),
execute: showSelector
});

View File

@@ -0,0 +1,189 @@
// TODO @robin to move this whole thing to es6
Discourse.Emoji = {};
// bump up this number to expire all emojis
Discourse.Emoji.ImageVersion = "0"
var emoji = <%= Emoji.standard.map(&:name).flatten.inspect %>;
var extendedEmoji = {};
Discourse.Dialect.registerEmoji = function(code, url) {
extendedEmoji[code] = url;
};
Discourse.Emoji.list = function(){
var list = emoji.slice(0);
_.each(extendedEmoji, function(v,k){ list.push(k); });
return list;
};
var toSearch;
var search = function(term, options) {
var maxResults = (options && options["maxResults"]) || -1;
toSearch = toSearch || emoji.concat(Object.keys(extendedEmoji));
if (maxResults === 0) { return []; }
var i, results = [];
var done = function() {
return maxResults > 0 && results.length >= maxResults;
}
for (i=0; i < toSearch.length; i++) {
if (toSearch[i].indexOf(term) === 0) {
results.push(toSearch[i]);
if(done()) { break; }
}
}
if(!done()){
for (i=0; i < toSearch.length; i++) {
if (toSearch[i].indexOf(term) > 0) {
results.push(toSearch[i]);
if(done()) { break; }
}
}
}
return results;
}
Discourse.Emoji.search = search;
var emojiHash = {};
emoji.forEach(function(code){
emojiHash[code] = true;
});
var urlFor = function(code) {
var url, set = Discourse.SiteSettings.emoji_set;
if(extendedEmoji.hasOwnProperty(code)) {
url = extendedEmoji[code];
}
if(!url && emojiHash.hasOwnProperty(code)) {
url = Discourse.getURL('/images/emoji/' + set + '/' + code + '.png');
}
if(url && url[0] !== 'h' && Discourse.CDN) {
url = Discourse.CDN + url;
}
if(url){
url = url + "?v=" + Discourse.Emoji.ImageVersion;
}
return url;
}
Discourse.Emoji.urlFor = urlFor;
Discourse.Emoji.exists = function(code){
return !!(extendedEmoji.hasOwnProperty(code) || emojiHash.hasOwnProperty(code));
}
function imageFor(code) {
var url = urlFor(code);
if (url) {
return ['img', { href: url, title: ':' + code + ':', 'class': 'emoji', alt: code }];
}
}
// Also support default emotions
var translations = {
':)' : 'smile',
':-)' : 'smile',
':(' : 'frowning',
':-(' : 'frowning',
';)' : 'wink',
';-)' : 'wink',
':\'(' : 'cry',
':\'-(' : 'cry',
':-\'(' : 'cry',
':p' : 'stuck_out_tongue',
':P' : 'stuck_out_tongue',
':-P' : 'stuck_out_tongue',
':O' : 'open_mouth',
':-O' : 'open_mouth',
':D' : 'smiley',
':-D' : 'smiley',
':|' : 'expressionless',
':-|' : 'expressionless',
";P" : 'stuck_out_tongue_winking_eye',
";-P" : 'stuck_out_tongue_winking_eye',
":$" : 'blush',
":-$" : 'blush'
};
Discourse.Emoji.translations = translations;
function checkPrev(prev) {
if (prev && prev.length) {
var lastToken = prev[prev.length-1];
if (lastToken && lastToken.charAt) {
var lastChar = lastToken.charAt(lastToken.length-1);
if (lastChar !== ' ' && lastChar !== "\n") return false;
}
}
return true;
}
var translationsWithColon = {};
Object.keys(translations).forEach(function (t) {
if (t[0] === ':') {
translationsWithColon[t] = translations[t];
} else {
var replacement = translations[t];
Discourse.Dialect.inlineReplace(t, function (token, match, prev) {
if (!Discourse.SiteSettings.enable_emoji) { return token; }
return checkPrev(prev) ? imageFor(replacement) : token;
});
}
});
function escapeRegExp(s) {
return s.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
}
var translationColonRegexp = new RegExp(Object.keys(translationsWithColon).map(function (t) {
return "(" + escapeRegExp(t) + ")";
}).join("|"));
Discourse.Dialect.registerInline(':', function(text, match, prev) {
if (!Discourse.SiteSettings.enable_emoji) { return; }
var endPos = text.indexOf(':', 1),
firstSpace = text.search(/\s/),
contents;
if (!checkPrev(prev)) { return; }
// If there is no trailing colon, check our translations that begin with colons
if (endPos === -1 || (firstSpace !== -1 && endPos > firstSpace)) {
translationColonRegexp.lastIndex = 0;
var m = translationColonRegexp.exec(text);
if (m && m[0] && text.indexOf(m[0]) === 0) {
// Check outer edge
var lastChar = text.charAt(m[0].length);
if (lastChar && (lastChar !== ' ' && lastChar !== "\n")) return;
contents = imageFor(translationsWithColon[m[0]]);
if (contents) {
return [m[0].length, contents];
}
}
return;
}
// Simple find and replace from our array
var between = text.slice(1, endPos);
contents = imageFor(between);
if (contents) {
return [endPos+1, contents];
}
});
Discourse.Markdown.whiteListTag('img', 'class', 'emoji');

View File

@@ -297,7 +297,11 @@ Discourse.Utilities = {
// the error message is provided by the server
case 422:
bootbox.alert(data.jqXHR.responseJSON.join("\n"));
if (data.jqXHR.responseJSON.message) {
bootbox.alert(data.jqXHR.responseJSON.message);
} else {
bootbox.alert(data.jqXHR.responseJSON.join("\n"));
}
return;
}
}

View File

@@ -20,15 +20,17 @@ export default Em.Mixin.create({
url: this.get('uploadUrl'),
dataType: "json",
fileInput: $upload,
formData: { image_type: this.get('type') },
dropZone: this.$(),
pasteZone: this.$()
});
$upload.on('fileuploadsubmit', function (e, data) {
var result = Discourse.Utilities.validateUploadedFiles(data.files, true);
self.setProperties({ uploadProgress: 0, uploading: result });
return result;
var isValid = Discourse.Utilities.validateUploadedFiles(data.files, true);
var form = { image_type: self.get('type') };
if (self.get("data")) { form = $.extend(form, self.get("data")); }
data.formData = form;
self.setProperties({ uploadProgress: 0, uploading: isValid });
return isValid;
});
$upload.on("fileuploadprogressall", function(e, data) {
@@ -40,7 +42,11 @@ export default Em.Mixin.create({
if(data.result.url) {
self.uploadDone(data);
} else {
bootbox.alert(I18n.t('post.errors.upload'));
if (data.result.message) {
bootbox.alert(data.result.message);
} else {
bootbox.alert(I18n.t('post.errors.upload'));
}
}
});

View File

@@ -0,0 +1,6 @@
{{text-field name="name" placeholderKey="admin.emoji.name" value=name}}
<input type="file" accept=".png,.gif" style="display:none" />
<button {{bind-attr disabled="addDisabled"}} {{action "selectFile"}} class='btn btn-primary'>
{{fa-icon "plus"}}
{{i18n 'admin.emoji.add'}}
</button>

View File

@@ -1,10 +1,10 @@
<input type="file" accept="image/*" style="display:none" />
<div class="uploaded-image-preview" class="input-xxlarge" {{bind-attr style="backgroundStyle"}}>
<div class="image-upload-controls">
<button {{action "selectFile"}} class="btn pad-left no-text"><i class="fa fa-picture-o"></i></button>
{{#if backgroundStyle}}
<button {{action "trash"}} class="btn btn-danger pad-left no-text"><i class="fa fa-trash-o"></i></button>
{{/if}}
<span {{bind-attr class=":btn uploading::hidden"}}>{{i18n 'upload_selector.uploading'}} {{uploadProgress}}%</span>
<button {{action "selectFile"}} class="btn pad-left no-text">{{fa-icon "picture-o"}}</button>
{{#if backgroundStyle}}
<button {{action "trash"}} class="btn btn-danger pad-left no-text">{{fa-icon "trash-o"}}</button>
{{/if}}
<span {{bind-attr class=":btn uploading::hidden"}}>{{i18n 'upload_selector.uploading'}} {{uploadProgress}}%</span>
</div>
</div>

View File

@@ -0,0 +1,35 @@
<div class='emoji-modal'>
<ul class='toolbar'>
{{#each toolbarItems}}<li><a {{#if selected}}class='selected'{{/if}} data-group-id='{{groupId}}'><img src='{{src}}' class='emoji'></a></li>{{/each}}
</ul>
<div class='emoji-table-wrapper'>
<table class='emoji-page'>
{{#each rows}}
<tr>
{{#each this}}
<td><a title='{{title}}'><img src='{{src}}' class='emoji'></a></td>
{{/each}}
</tr>
{{/each}}
</table>
</div>
<div class='info'></div>
<div class='nav'>
<span class='prev'>
{{#if prevDisabled}}
<i class='fa fa-fast-backward'></i>
{{else}}
<a><i class='fa fa-fast-backward'></i></a>
{{/if}}
</span>
<span class='next'>
{{#if nextDisabled}}
<i class='fa fa-fast-forward'></i>
{{else}}
<a><i class='fa fa-fast-forward'></i></a>
{{/if}}
</span>
</div>
<div class='clearfix'></div>
</div>

View File

@@ -12,14 +12,15 @@
// Stuff we need to load first
//= require ./discourse/helpers/i18n
//= require ./discourse/lib/ember_compat_handlebars
//= require ./discourse/helpers/register-unbound
//= require ./discourse/lib/computed
//= require ./discourse/helpers/register-unbound
//= require ./discourse/mixins/scrolling
//= require_tree ./discourse/mixins
//= require ./discourse/lib/markdown
//= require ./discourse/lib/search-for-term
//= require ./discourse/views/view
//= require ./discourse/views/container
//= require ./discourse/lib/user-search
//= require ./discourse/lib/autocomplete
//= require ./discourse/lib/after-transition
//= require ./discourse/lib/debounce
//= require ./discourse/models/model
//= require ./discourse/models/user_action
@@ -30,6 +31,8 @@
//= require ./discourse/controllers/discovery-sortable
//= require ./discourse/controllers/object
//= require ./discourse/controllers/navigation/default
//= require ./discourse/views/view
//= require ./discourse/views/container
//= require ./discourse/views/modal_body_view
//= require ./discourse/views/flag
//= require ./discourse/views/combo-box
@@ -38,6 +41,7 @@
//= require ./discourse/views/notifications-button
//= require ./discourse/views/topic-notifications-button
//= require ./discourse/views/pagedown-preview
//= require ./discourse/views/composer
//= require ./discourse/routes/discourse_route
//= require ./discourse/routes/build-topic-route
//= require ./discourse/routes/restricted-user
@@ -52,8 +56,9 @@
//= require ./discourse/helpers/cold-age-class
//= require ./discourse/helpers/loading-spinner
//= require ./discourse/helpers/category-link
//= require ./discourse/dialects/dialect
//= require ./discourse/lib/emoji/emoji
//= require_tree ./discourse/dialects
//= require_tree ./discourse/controllers
//= require_tree ./discourse/lib

View File

@@ -0,0 +1,106 @@
body img.emoji {
width: 20px;
height: 20px;
vertical-align: middle;
}
#wmd-emoji-button:before {
content: "\f118";
}
.emoji-modal {
z-index: 10000;
position: fixed;
margin-left: -195px;
margin-top: -100px;
left: 50%;
top: 50%;
background-color: white;
}
.emoji-page td {
border: 1px solid #eee;
}
.emoji-page a {
padding: 8px;
display: block;
}
.emoji-page a:hover {
background-color: rgb(210, 236, 252);
}
.emoji-table-wrapper {
min-width: 444px;
min-height: 185px;
}
.emoji-page {
border-collapse: collapse;
margin: 3px;
}
.emoji-modal-wrapper {
z-index: 9999;
position: fixed;
left: 0;
top: 0;
width: 100%;
height: 100%;
opacity: 0.8;
background-color: black;
}
.emoji-modal .toolbar {
margin: 0;
padding: 0;
margin-top: 10px;
margin-left: 5px;
margin-bottom: 5px
}
.emoji-modal .toolbar li {
display: inline;
margin: 0;
padding: 0;
}
.emoji-modal .toolbar li a {
padding: 8px;
background-color: #dadada;
}
.emoji-modal .toolbar li a.selected {
background-color: #fff;
}
.emoji-modal .info {
height: 30px;
margin-left: 8px;
margin-top: 15px;
margin-bottom: 0px;
}
.emoji-modal .info span {
margin-left: 5px;
font-weight: bold;
}
.emoji-modal .info {
float: left;
}
.emoji-modal .nav {
float: right;
margin-top: 15px;
}
.emoji-modal .nav span {
color: #aaa;
margin-right: 10px;
}
.emoji-modal .nav a {
color: #333;
}

View File

@@ -0,0 +1,36 @@
class Admin::EmojisController < Admin::AdminController
def index
render_serialized(Emoji.custom, EmojiSerializer, root: false)
end
def create
file = params[:file] || params[:files].first
name = params[:name] || File.basename(file.original_filename, ".*")
# fix the name
name = name.gsub(/[^a-z0-9]+/i, '_')
.gsub(/_{2,}/, '_')
.downcase
# check the name doesn't already exist
if Emoji.all.detect { |e| e.name == name }
render json: failed_json.merge(message: I18n.t("emoji.errors.name_already_exists", name: name)), status: 422
else
if emoji = Emoji.create_for(file, name)
render_serialized(emoji, EmojiSerializer, root: false)
else
render json: failed_json.merge(message: I18n.t("emoji.errors.error_while_storing_emoji")), status: 422
end
end
end
def destroy
name = params.require(:id)
Emoji.custom.detect { |e| e.name == name }.try(:remove)
render nothing: true
end
end

View File

@@ -255,6 +255,7 @@ class ApplicationController < ActionController::Base
store_preloaded("siteSettings", SiteSetting.client_settings_json)
store_preloaded("customHTML", custom_html_json)
store_preloaded("banner", banner_json)
store_preloaded("customEmoji", custom_emoji)
end
def preload_current_user_data
@@ -281,7 +282,6 @@ class ApplicationController < ActionController::Base
end
def banner_json
json = ApplicationController.banner_json_cache["json"]
unless json
@@ -293,6 +293,11 @@ class ApplicationController < ActionController::Base
json
end
def custom_emoji
serializer = ActiveModel::ArraySerializer.new(Emoji.custom, each_serializer: EmojiSerializer)
MultiJson.dump(serializer)
end
def render_json_error(obj)
render json: MultiJson.dump(create_errors_json(obj)), status: 422
end

92
app/models/emoji.rb Normal file
View File

@@ -0,0 +1,92 @@
class Emoji
include ActiveModel::SerializerSupport
attr_reader :path
attr_accessor :name, :url
# whitelist emojis so that new user can post emojis
Post::white_listed_image_classes << "emoji"
def initialize(path = nil)
@path = path
end
def remove
return if path.blank?
if File.exists?(path)
File.delete(path) rescue nil
Emoji.clear_cache
end
end
def self.all
@all ||= standard | custom
end
def self.standard
@standard ||= load_standard
end
def self.custom
@custom ||= load_custom
end
def self.create_from_path(path)
extension = File.extname(path)
Emoji.new(path).tap do |e|
e.name = File.basename(path, ".*")
e.url = "/#{base_url}/#{e.name}#{extension}"
end
end
def self.create_from_db_item(emoji)
name = emoji["aliases"].first
filename = "#{name}.png"
Emoji.new.tap do |e|
e.name = name
e.url = "/images/emoji/#{SiteSetting.emoji_set}/#{filename}"
end
end
def self.create_for(file, name)
extension = File.extname(file.original_filename)
path = "#{Emoji.base_directory}/#{name}#{extension}"
# store the emoji
FileUtils.mkdir_p(Pathname.new(path).dirname)
File.open(path, "wb") { |f| f << file.tempfile.read }
# clear the cache
Emoji.clear_cache
# return created emoji
Emoji.custom.detect { |e| e.name == name }
end
def self.clear_cache
@custom = nil
@all = nil
end
def self.db_file
"lib/emoji/db.json"
end
def self.load_standard
File.open(db_file, "r:UTF-8") { |f| JSON.parse(f.read) }
.map { |emoji| Emoji.create_from_db_item(emoji) }
end
def self.load_custom
Dir.glob(File.join(Emoji.base_directory, "*.{png,gif}"))
.sort
.map { |emoji| Emoji.create_from_path(emoji) }
end
def self.base_directory
"public/#{base_url}"
end
def self.base_url
db = RailsMultisite::ConnectionManagement.current_db
"uploads/#{db}/_emoji"
end
end

View File

@@ -0,0 +1,38 @@
require 'enum_site_setting'
class EmojiSetSiteSetting < EnumSiteSetting
# fix the URLs when changing the site setting
DiscourseEvent.on(:site_setting_saved) do |site_setting|
if site_setting.name.to_s == "emoji_set" && site_setting.value_changed?
before = "/images/emoji/#{site_setting.value_was}/"
after = "/images/emoji/#{site_setting.value}/"
Scheduler::Defer.later("Fix Emoji Links") do
Post.exec_sql("UPDATE posts SET cooked = REPLACE(cooked, :before, :after) WHERE cooked LIKE :like",
before: before,
after: after,
like: "%#{before}%"
)
end
end
end
def self.valid_value?(val)
values.any? { |v| v[:value] == val.to_s }
end
def self.values
@values ||= [
{ name: 'apple_international', value: 'apple' },
{ name: 'google', value: 'google' },
{ name: 'twitter', value: 'twitter' },
{ name: 'emoji_one', value: 'emoji_one' },
]
end
def self.translate_names?
true
end
end

View File

@@ -0,0 +1,3 @@
class EmojiSerializer < ApplicationSerializer
attributes :name, :url
end

View File

@@ -36,6 +36,9 @@
Discourse.Environment = '<%= Rails.env %>';
Discourse.SiteSettings = PreloadStore.get('siteSettings');
Discourse.LetterAvatarVersion = <%= LetterAvatar::VERSION %>;
PreloadStore.get("customEmoji").forEach(function(emoji) {
Discourse.Dialect.registerEmoji(emoji.name, emoji.url);
});
Discourse.Router = Ember.Router.extend({ location: 'discourse-location' });
Discourse.Route.mapRoutes();
Discourse.start();