FEATURE: Introduces new emoji-picker

This commit is contained in:
Joffrey JAFFEUX 2017-07-11 17:51:53 +02:00
parent 87a1ff15fd
commit 6de258d4cf
19 changed files with 7068 additions and 1982 deletions

View File

@ -69,8 +69,6 @@
//= require ./discourse/components/notifications-button //= require ./discourse/components/notifications-button
//= require ./discourse/lib/link-mentions //= require ./discourse/lib/link-mentions
//= require ./discourse/components/site-header //= require ./discourse/components/site-header
//= require ./discourse/lib/emoji/groups
//= require ./discourse/lib/emoji/toolbar
//= require ./discourse/components/d-editor //= require ./discourse/components/d-editor
//= require ./discourse/lib/screen-track //= require ./discourse/lib/screen-track
//= require ./discourse/routes/discourse //= require ./discourse/routes/discourse

View File

@ -1,6 +1,5 @@
/*global Mousetrap:true */ /*global Mousetrap:true */
import { default as computed, on, observes } from 'ember-addons/ember-computed-decorators'; import { default as computed, on, observes } from 'ember-addons/ember-computed-decorators';
import { showSelector } from "discourse/lib/emoji/toolbar";
import Category from 'discourse/models/category'; import Category from 'discourse/models/category';
import { categoryHashtagTriggerRule } from 'discourse/lib/category-hashtags'; import { categoryHashtagTriggerRule } from 'discourse/lib/category-hashtags';
import { TAG_HASHTAG_POSTFIX } from 'discourse/lib/tag-hashtags'; import { TAG_HASHTAG_POSTFIX } from 'discourse/lib/tag-hashtags';
@ -346,20 +345,8 @@ export default Ember.Component.extend({
if (v.code) { if (v.code) {
return `${v.code}:`; return `${v.code}:`;
} else { } else {
showSelector({ $editorInput.autocomplete({cancel: true});
appendTo: self.$(), self.set('emojiPickerIsActive', true);
register,
onSelect: title => {
// Remove the previously type characters when a new emoji is selected from the selector.
let selected = self._getSelected();
let newPre = selected.pre.replace(/:[^:]+$/, ":");
let numOfRemovedChars = selected.pre.length - newPre.length;
selected.pre = newPre;
selected.start -= numOfRemovedChars;
selected.end -= numOfRemovedChars;
self._addText(selected, `${title}:`);
}
});
return ""; return "";
} }
}, },
@ -614,6 +601,21 @@ export default Ember.Component.extend({
}, },
actions: { actions: {
emojiSelected(code) {
let selected = this._getSelected();
const captures = selected.pre.match(/\B:(\w*)$/);
if(_.isEmpty(captures)) {
this._addText(selected, `:${code}:`);
} else {
let numOfRemovedChars = selected.pre.length - captures[1].length;
selected.pre = selected.pre.slice(0, selected.pre.length - captures[1].length);
selected.start -= numOfRemovedChars;
selected.end -= numOfRemovedChars;
this._addText(selected, `${code}:`);
}
},
toolbarButton(button) { toolbarButton(button) {
const selected = this._getSelected(button.trimLeading); const selected = this._getSelected(button.trimLeading);
const toolbarEvent = { const toolbarEvent = {
@ -692,11 +694,7 @@ export default Ember.Component.extend({
}, },
emoji() { emoji() {
showSelector({ this.set('emojiPickerIsActive', !this.get('emojiPickerIsActive'));
appendTo: this.$(),
register: this.register,
onSelect: title => this._addText(this._getSelected(), `:${title}:`)
});
} }
} }
}); });

View File

@ -0,0 +1,448 @@
import { observes } from "ember-addons/ember-computed-decorators";
import { findRawTemplate } from "discourse/lib/raw-templates";
import { emojiUrlFor } from "discourse/lib/text";
import KeyValueStore from "discourse/lib/key-value-store";
import { emojis } from "pretty-text/emoji/data";
import { extendedEmojiList, isSkinTonableEmoji } from "pretty-text/emoji";
const recentTemplate = findRawTemplate("emoji-picker-recent");
const pickerTemplate = findRawTemplate("emoji-picker");
export const keyValueStore = new KeyValueStore("discourse_emojis_");
export const EMOJI_USAGE = "emojiUsage";
export const EMOJI_SCROLL_Y = "emojiScrollY";
export const EMOJI_SELECTED_DIVERSITY = "emojiSelectedDiversity";
const PER_ROW = 11;
export default Ember.Component.extend({
customEmojis: _.map(_.keys(extendedEmojiList()), function(code) {
return { code, src: emojiUrlFor(code) };
}),
$picker: Ember.computed("active", function() {
return this.$(".emoji-picker");
}),
$filter: Ember.computed("$picker", function() {
return this.get("$picker").find(".filter");
}),
$results: Ember.computed("$picker", function() {
return this.get("$picker").find(".results");
}),
$list: Ember.computed("$picker", function() {
return this.get("$picker").find(".list");
}),
willDestroyElement() {
this._super();
this._unbindEvents();
},
didInsertElement() {
this._super();
if (!keyValueStore.getObject(EMOJI_USAGE)) {
keyValueStore.setObject({ key: EMOJI_USAGE, value: {} });
}
this.set("selectedDiversity", keyValueStore.getObject(EMOJI_SELECTED_DIVERSITY) || 1);
},
didUpdateAttrs() {
this._super();
if (this.get("active")) {
this.show();
} else {
this.close();
}
},
@observes("filter")
filterChanged() {
this.get("$filter").find(".clear-filter").toggle(!_.isEmpty(this.get("filter")));
Ember.run.debounce(this, this._filterEmojisList, 250);
},
@observes("selectedDiversity")
selectedDiversityChanged() {
keyValueStore.setObject({key: EMOJI_SELECTED_DIVERSITY, value: this.get("selectedDiversity")});
$.each(this.get("$list").find(".emoji.diversity[src!='']"), (_, icon) => {
this._updateIconSrc(icon);
});
if(this.get("filter") !== "") {
$.each(this.get("$results").find(".emoji.diversity"), (_, icon) => {
this._updateIconSrc(icon);
});
}
},
@observes("recentEmojis")
recentEmojisChanged() {
const $recentSection = this.get("$list").find(".section[data-section='recent']");
const $recentSectionGroup = $recentSection.find(".section-group");
const $recentCategory = this.get("$picker").find(".category-icon a[title='recent']").parent();
if(_.isEmpty(this.get("recentEmojis"))) {
$recentCategory.hide();
$recentSection.css("height", 0).hide();
} else {
$recentCategory.show();
$recentSection.css("height", "auto").show();
}
const recentEmojis = _.map(this.get("recentEmojis"), function(emoji) {
return { code: emoji.title, src: emojiUrlFor(emoji.title) };
});
const model = { recentEmojis };
const template = recentTemplate(model);
$recentSectionGroup.html(template);
this._bindHover($recentSectionGroup.find("a"));
},
close() {
this.get("$picker")
.css({width: "", left: "", bottom: ""})
.empty();
this.$(".emoji-picker-modal").removeClass("fadeIn");
this._unbindEvents();
},
show() {
const model = { customEmojis: this.get("customEmojis") };
const template = pickerTemplate(model);
this.get("$picker").html(template);
this._bindEvents();
Ember.run.later(this, function() {
this._setDiversity();
this._positionPicker();
this._scrollTo();
this.recentEmojisChanged();
});
},
_bindEvents() {
this._bindDiversityClick();
this._bindSectionsScroll();
this._bindEmojiClick();
this._bindClearRecentEmojisGroup();
this._bindResizing();
this._bindHover();
this._bindCategoryClick();
this._bindModalClick();
this._bindFilterInput();
this._bindEscape();
},
_bindEscape() {
this.$().on("keydown", e => {
if (e.which === 27) {
this.set("active", false);
return false;
}
});
},
_bindModalClick() {
this.$(".emoji-picker-modal").on("click", () => {
this.set("active", false);
});
},
_unbindEvents() {
this.$(window).off("resize");
this.$(".emoji-picker-modal").off("click");
Ember.$("#reply-control").off("div-resized");
this.$().off("keydown");
},
_filterEmojisList() {
const $filter = this.get("$picker").find(".filter");
if (this.get("filter") === "") {
$filter.find("input[name='filter']").val("");
this.get("$results").empty().hide();
this.get("$list").show();
} else {
const regexp = new RegExp(this.get("filter"), "g");
const filteredCodes = _.filter(emojis, code => regexp.test(code)).slice(0, 30);
this.get("$results").empty().html(
_.map(filteredCodes, (code) => {
const hasDiversity = isSkinTonableEmoji(code);
const diversity = hasDiversity ? "diversity" : "";
const scaledCode = this._codeWithDiversity(code, hasDiversity);
return `<a title="${code}">
<img src="${emojiUrlFor(scaledCode)}" data-code="${code}" class="emoji ${diversity}" />
</a>`;
})
).show();
this._bindHover(this.get("$results").find("a"));
this._bindEmojiClick(this.get("$results"));
this.get("$list").hide();
}
},
_bindFilterInput() {
const $filter = this.get("$picker").find(".filter");
const $input = $filter.find("input");
$input.on("input", (event) => {
this.set("filter", event.currentTarget.value);
});
$filter.find(".clear-filter").on("click", () => {
$input.val("").focus();
this.set("filter", "");
return false;
});
},
_bindCategoryClick() {
this.get("$picker").find(".category-icon").on("click", "a", (event) => {
this.set("filter", "");
this.get("$results").empty();
this.get("$list").show();
const section = $(event.currentTarget).attr("title");
const $section = this.get("$list").find(`.section[data-section="${section}"]`);
const scrollTop = this.get("$list").scrollTop() +
( $section.offset().top - this.get("$list").offset().top );
this._scrollTo(scrollTop);
return false;
});
},
_bindHover(hoverables) {
const replaceInfoContent = (html) => {
this.get("$picker").find(".footer .info").html(html || "");
}
(hoverables || this.$(".section-group a")).hover(event => {
const $a = $(event.currentTarget);
const code = this._codeWithDiversity($a.attr("title"), $a.find("img").hasClass("diversity"));
const html = `<img src="${emojiUrlFor(code)}" class="emoji"> <span>:${code}:<span>`;
replaceInfoContent(html);
},
() => replaceInfoContent()
);
},
_bindResizing() {
this.$(window).on("resize", () => {
Ember.run.debounce(this, this._positionPicker, 100);
});
Ember.$("#reply-control").on("div-resized", () => {
Ember.run.debounce(this, this._positionPicker, 100);
});
},
_bindClearRecentEmojisGroup() {
const $recent = this.get("$picker").find(".section[data-section='recent'] .clear-recent");
$recent.on("click", () => {
keyValueStore.setObject({ key: EMOJI_USAGE, value: {} });
this.set("recentEmojis", {});
this._scrollTo(0);
return false;
});
},
_bindEmojiClick(emojisContainer) {
const $emojisContainer = emojisContainer || this.get("$list").find(".section-group");
$emojisContainer.off("click").on("click", "a", e => {
const $icon = $(e.currentTarget);
const title = $icon.attr("title");
const code = this._codeWithDiversity(title, $icon.find("img").hasClass("diversity"));
this._trackEmojiUsage(code);
if(this._isSmallViewport()) {
this.set("active", false);
}
return false;
});
},
_bindSectionsScroll() {
this.get("$list").on("scroll", () => {
Ember.run.debounce(this, this._checkVisibleSection, 150);
Ember.run.debounce(this, this._storeScrollPosition, 50);
});
},
_checkVisibleSection() {
const $sections = this.get("$list").find(".section");
const sections = [];
let cumulatedHeight = 0;
$.each($sections, (_, section) => {
const $section = $(section);
sections.push({$section, cumulatedHeight});
cumulatedHeight += $section.innerHeight();
});
let selectedSection;
const currentScrollTop = this.get("$list").scrollTop()
if (!_.isEmpty(this.get("recentEmojis")) && currentScrollTop === 0) {
selectedSection = _.first(sections);
} else if (!_.isEmpty(this.get("customEmojis")) &&
currentScrollTop === this.get("$list")[0].scrollHeight - this.get("$list").innerHeight())
{
selectedSection = _.last(sections);
} else {
selectedSection = _.last(_.reject(sections, (section) => {
return section.cumulatedHeight > currentScrollTop;
}));
}
if(selectedSection) {
this.get("$picker").find(".category-icon").removeClass("current");
this.get("$picker").find(`.category-icon a[title='${selectedSection.$section.data("section")}']`)
.parent()
.addClass("current");
if(!selectedSection.$section.hasClass("loaded")) {
selectedSection.$section.addClass("loaded");
this._loadVisibleEmojis(selectedSection.$section.find(".emoji[src='']"));
}
//preload surrounding sections
const selectedSectionIndex = sections.indexOf(selectedSection);
const preloadedSection = sections[selectedSectionIndex + 1] || sections[selectedSectionIndex - 1];
if(preloadedSection && !preloadedSection.$section.hasClass("loaded")) {
preloadedSection.$section.addClass("loaded");
const $visibleEmojis = preloadedSection.$section.find(".emoji[src='']");
Ember.run.later(() => { this._loadVisibleEmojis($visibleEmojis) }, 1500);
}
}
},
_bindDiversityClick() {
const $diversityScales = this.get("$picker").find(".diversity-picker .diversity-scale");
$diversityScales.on("click", (event) => {
const $selectedDiversity = $(event.currentTarget);
$diversityScales.removeClass("selected");
$selectedDiversity.addClass("selected");
this.set("selectedDiversity", parseInt($selectedDiversity.data("level")));
return false;
});
},
_setDiversity() {
this.get("$picker")
.find(`.diversity-picker .diversity-scale[data-level="${this.get("selectedDiversity")}"]`)
.addClass("selected");
},
_isSmallViewport() {
return this.site.isMobileDevice || this.$(window).width() <= 1024 || this.$(window).height() <= 768;
},
_positionPicker(){
if(!this.get("active")) { return; }
let isLargePreview = this.$(window).height() -
Ember.$(".d-header").height() -
Ember.$("#reply-control").height() <
this.get("$picker").height() + 16;
if(this._isSmallViewport()) {
this.$(".emoji-picker-modal").addClass("fadeIn");
this.get("$picker").css({
width: this.site.isMobileDevice ? this.$(window).width() - 10 : 340,
marginLeft: this.site.isMobileDevice ? -(this.$(window).width() - 10)/2 : -170,
marginTop: -150,
left: "50%",
top: "50%"
})
} else {
this.$(".emoji-picker-modal").removeClass("fadeIn");
let cssAttributes = { width: 400, marginLeft: "", marginTop: "", left: "", top: "" };
if(isLargePreview) {
cssAttributes.left = (Ember.$("#reply-control").width() - Ember.$(".d-editor").width() ) / 2 + Ember.$(".d-editor-preview-wrapper").position().left;
cssAttributes.bottom = 32;
} else {
cssAttributes.left = (Ember.$("#reply-control").width() - Ember.$(".d-editor").width() ) / 2 + Ember.$(".d-editor").position().left;
cssAttributes.bottom = Ember.$("#reply-control").height() - 48;
}
this.get("$picker").css(cssAttributes);
}
const infoMaxWidth = this.get("$picker").width() -
this.get("$picker").find(".categories-column").width() -
this.get("$picker").find(".diversity-picker").width() -
32;
this.get("$picker").find(".info").css("max-width", infoMaxWidth);
},
_loadVisibleEmojis($visibleEmojis) {
$.each($visibleEmojis, (_, icon) => {
const $icon = $(icon);
const code = this._codeWithDiversity($icon.parents("a").attr("title"), $icon.hasClass("diversity"))
$icon.attr("src", emojiUrlFor(code));
});
},
_codeWithDiversity(code, diversity) {
if(diversity && this.get("selectedDiversity") !== 1) {
return `${code}:t${this.get("selectedDiversity")}`;
} else {
return code;
}
},
_storeScrollPosition() {
keyValueStore.setObject({
key: EMOJI_SCROLL_Y,
value: this.get("$list").scrollTop()
});
},
_trackEmojiUsage(code) {
const recent = keyValueStore.getObject(EMOJI_USAGE) || {};
if (!recent[code]) {
// keeping title here for legacy reasons, might migrate later
recent[code] = { title: code, usage: 0 };
}
recent[code]["usage"]++;
keyValueStore.setObject({ key: EMOJI_USAGE, value: recent });
this.set("recentEmojis", _.map(recent).sort(this._sortByUsage).slice(0, PER_ROW));
this.sendAction("emojiSelected", code);
},
_sortByUsage(a, b) {
if (a.usage > b.usage) { return -1; }
if (b.usage > a.usage) { return 1; }
return a.title.localeCompare(b.title);
},
_scrollTo(y) {
const yPosition = _.isUndefined(y) ? keyValueStore.getObject(EMOJI_SCROLL_Y) : y;
this.get("$list").scrollTop(yPosition);
// if we dont actually scroll we need to force it
if(yPosition === 0) {
this.get("$list").scroll();
}
},
_updateIconSrc(icon) {
const $icon = $(icon);
const code = this._codeWithDiversity($icon.parents("a").attr("title"), true)
$icon.attr("src", emojiUrlFor(code));
},
});

File diff suppressed because it is too large Load Diff

View File

@ -1,208 +0,0 @@
import groups from 'discourse/lib/emoji/groups';
import KeyValueStore from "discourse/lib/key-value-store";
import { emojiList, isSkinTonableEmoji } from 'pretty-text/emoji';
import { emojiUrlFor } from 'discourse/lib/text';
import { findRawTemplate } from 'discourse/lib/raw-templates';
const keyValueStore = new KeyValueStore("discourse_emojis_");
const EMOJI_USAGE = "emojiUsage";
let PER_ROW = 12;
const PER_PAGE = 60;
let ungroupedIcons, recentlyUsedIcons;
let selectedSkinTone = keyValueStore.getObject('selectedSkinTone') || 1;
if (!keyValueStore.getObject(EMOJI_USAGE)) {
keyValueStore.setObject({key: EMOJI_USAGE, value: {}});
}
function closeSelector() {
$('.emoji-modal, .emoji-modal-wrapper').remove();
$('body, textarea').off('keydown.emoji');
}
function initializeUngroupedIcons() {
const groupedIcons = {};
groups.forEach(group => {
group.icons.forEach(icon => groupedIcons[icon] = true);
});
ungroupedIcons = [];
const emojis = emojiList();
emojis.forEach(emoji => {
if (groupedIcons[emoji] !== true) {
ungroupedIcons.push(emoji);
}
});
if (ungroupedIcons.length) {
groups.push({name: 'ungrouped', icons: ungroupedIcons});
}
}
function trackEmojiUsage(title) {
const recent = keyValueStore.getObject(EMOJI_USAGE) || {};
if (!recent[title]) { recent[title] = { title: title, usage: 0 }; }
recent[title]["usage"]++;
keyValueStore.setObject({key: EMOJI_USAGE, value: recent});
// clear the cache
recentlyUsedIcons = null;
}
function sortByUsage(a, b) {
if (a.usage > b.usage) { return -1; }
if (b.usage > a.usage) { return 1; }
return a.title.localeCompare(b.title);
}
function initializeRecentlyUsedIcons() {
recentlyUsedIcons = [];
const usage = _.map(keyValueStore.getObject(EMOJI_USAGE)).sort(sortByUsage);
const recent = usage.slice(0, PER_ROW);
if (recent.length > 0) {
recent.forEach(emoji => recentlyUsedIcons.push(emoji.title));
const recentGroup = groups.findBy('name', 'recent');
if (recentGroup) {
recentGroup.icons = recentlyUsedIcons;
} else {
groups.push({ name: 'recent', icons: recentlyUsedIcons });
}
}
}
function toolbar(selected) {
if (!ungroupedIcons) { initializeUngroupedIcons(); }
if (!recentlyUsedIcons) { initializeRecentlyUsedIcons(); }
return groups.map((g, i) => {
let icon = g.tabicon;
let title = g.fullname;
if (g.name === "recent") {
icon = "star";
title = "Recent";
} else if (g.name === "ungrouped") {
icon = g.icons[0];
title = "Custom";
}
return { src: emojiUrlFor(icon),
title,
groupId: i,
selected: i === selected };
});
}
function bindEvents(page, offset, options) {
$('.emoji-page a').click(e => {
const title = $(e.currentTarget).attr('title');
trackEmojiUsage(title);
options.onSelect(title);
closeSelector();
return false;
}).hover(e => {
const title = $(e.currentTarget).attr('title');
const html = "<img src='" + emojiUrlFor(title) + "' class='emoji'> <span>:" + title + ":<span>";
$('.emoji-modal .info').html(html);
}, () => $('.emoji-modal .info').html(""));
$('.emoji-modal .nav .next a').click(() => render(page, offset+PER_PAGE, options));
$('.emoji-modal .nav .prev a').click(() => render(page, offset-PER_PAGE, options));
$('.emoji-modal .toolbar a').click(function(){
const p = parseInt($(this).data('group-id'));
render(p, 0, options);
return false;
});
$('.emoji-modal .tones-button').click(function(){
selectedSkinTone = parseInt($(this).data('skin-tone'));
keyValueStore.setObject({key: 'selectedSkinTone', value: selectedSkinTone});
render(page, offset, options);
return false;
});
}
function render(page, offset, options) {
keyValueStore.set({key: "emojiPage", value: page});
keyValueStore.set({key: "emojiOffset", value: offset});
const toolbarItems = toolbar(page);
const rows = [];
let row = [];
const icons = groups[page].icons;
const max = offset + PER_PAGE;
for(let i=offset; i<max; i++){
if(!icons[i]){ break; }
if(row.length === (options.perRow || PER_ROW)){
rows.push(row);
row = [];
}
let code = icons[i];
if(selectedSkinTone !== 1 && isSkinTonableEmoji(code)) {
code = `${code}:t${selectedSkinTone}`;
}
row.push({src: emojiUrlFor(code), title: code});
}
rows.push(row);
const skinTones = [];
const skinToneNames = ['default', 'light', 'medium-light', 'medium', 'medium-dark', 'dark'];
for(let i=1; i<skinToneNames.length+1; i++){
skinTones.push({
selected: selectedSkinTone === i,
level: i,
className: skinToneNames[i-1]
});
}
const model = {
toolbarItems,
skinTones,
rows,
prevDisabled: offset === 0,
nextDisabled: (max + 1) > icons.length,
modalClass: options.modalClass
};
$('.emoji-modal', options.appendTo).remove();
const template = findRawTemplate('emoji-toolbar');
options.appendTo.append(template(model));
bindEvents(page, offset, options);
}
function showSelector(options) {
options = options || {};
options.appendTo = options.appendTo || $('body');
options.appendTo.append('<div class="emoji-modal-wrapper"></div>');
$('.emoji-modal-wrapper').click(() => closeSelector());
if (Discourse.Site.currentProp('mobileView')) { PER_ROW = 9; }
const page = options.page ? _.findIndex(groups, (g) => { return g.name === options.page; })
: keyValueStore.getInt("emojiPage", 0);
const offset = keyValueStore.getInt("emojiOffset", 0);
render(page, offset, options);
$('body, textarea').on('keydown.emoji', e => {
if (e.which === 27) {
closeSelector();
return false;
}
});
}
export { showSelector };

View File

@ -33,3 +33,5 @@
{{plugin-outlet name="editor-preview"}} {{plugin-outlet name="editor-preview"}}
</div> </div>
</div> </div>
{{emoji-picker active=emojiPickerIsActive emojiSelected=(action 'emojiSelected')}}

View File

@ -0,0 +1,2 @@
<div class='emoji-picker'></div>
<div class='emoji-picker-modal'></div>

View File

@ -0,0 +1,5 @@
{{#each recentEmojis as |emoji|}}
<a title='{{emoji.code}}'>
<img src='{{emoji.src}}' class='emoji'>
</a>
{{/each}}

View File

@ -0,0 +1,88 @@
<div class='categories-column'>
<div class='category-icon'>
<a href='#' title='recent'>
<img src='<%= Emoji.url_for("star") %>' class='emoji'>
</a>
</div>
<% JSON.parse(File.read("lib/emoji/groups.json")).each.with_index do |group, group_index| %>
<div class='category-icon'>
<a href='#' title='<%= group["name"] %>'>
<img src='<%= Emoji.url_for(group["tabicon"]) %>' class='emoji'>
</a>
</div>
<% end %>
<% if !Emoji.custom.blank? %>
<div class='category-icon'>
<a href='#' title='ungrouped'>
<img src='<%= Emoji.custom.first.url %>' class='emoji'>
</a>
</div>
<% end %>
</div>
<div class='main-column'>
<div class='filter'>
<input type='text' name="filter" placeholder="{{i18n 'emoji_picker.filter_placeholder'}}" autocomplete="off" autofocus/>
<button class='clear-filter'>
{{fa-icon 'times'}}
</button>
</div>
<div class='results'></div>
<div class='list'>
<div class='section' data-section='recent'>
<div class='section-header'>
<span class="title">{{i18n 'emoji_picker.recent'}}</span>
<a href='#' class='clear-recent btn btn-default'>{{fa-icon 'trash'}}</a>
</div>
<div class='section-group'></div>
</div>
<% JSON.parse(File.read("lib/emoji/groups.json")).each.with_index do |group, group_index| %>
<div class='section' data-section='<%= group["name"] %>'>
<div class='section-header'>
<span class="title">{{i18n 'emoji_picker.<%= group["name"] %>'}}</span>
</div>
<div class='section-group'>
<% group["icons"].each.with_index do |icon, icon_index| %>
<a title='<%= icon["name"] %>'>
<% if group_index == 0 && icon_index <= 77 %>
<img src='<%= Emoji.url_for(icon['name']) %>' class='emoji <%= "diversity" if icon["diversity"] %>'>
<% else %>
<img src='' class='emoji <%= "diversity" if icon["diversity"] %>'>
<% end %>
</a>
<% end %>
</div>
</div>
<% end %>
{{#if customEmojis.length}}
<div class='section' data-section='ungrouped'>
<div class='section-header'>
<span class="title">{{i18n 'emoji_picker.custom'}}</span>
</div>
<div class='section-group'>
{{#each customEmojis as |emoji|}}
<a title='{{emoji.code}}'>
<img src='{{emoji.src}}' class='emoji'>
</a>
{{/each}}
</div>
</div>
{{/if}}
</div>
<div class='footer'>
<div class='info'></div>
<div class='diversity-picker'>
<% ['default', 'light', 'medium-light', 'medium', 'medium-dark', 'dark'].each.with_index do |diversity, index| %>
<a href='#' class='diversity-scale <%= diversity %>' data-level="<%= index + 1 %>">
{{fa-icon "check"}}
</a>
<% end %>
</div>
</div>
</div>

View File

@ -1,45 +0,0 @@
<div class='emoji-modal {{modalClass}}'>
<ul class='toolbar'>
{{#each toolbarItems as |item|}}<li><a title='{{item.title}}' {{#if item.selected}}class='selected'{{/if}} data-group-id='{{item.groupId}}'><img src='{{item.src}}' class='emoji'></a></li>{{/each}}
</ul>
<div class='emoji-table-wrapper'>
<table class='emoji-page'>
{{#each rows as |row|}}
<tr>
{{#each row as |item|}}
<td><a title='{{item.title}}'><img src='{{item.src}}' class='emoji'></a></td>
{{/each}}
</tr>
{{/each}}
</table>
</div>
<div class='footer'>
<div class='info'></div>
<div class='tones'>
{{#each skinTones as |skinTone|}}
<a href='#' class='tones-button {{skinTone.className}}' data-skin-tone="{{skinTone.level}}">
{{#if skinTone.selected}}{{fa-icon "check"}}{{/if}}
</a>
{{/each}}
</div>
<div class='nav'>
<span class='prev'>
{{#if prevDisabled}}
{{fa-icon "fast-backward"}}
{{else}}
<a>{{fa-icon "fast-backward"}}</a>
{{/if}}
</span>
<span class='next'>
{{#if nextDisabled}}
{{fa-icon "fast-forward"}}
{{else}}
<a>{{fa-icon "fast-forward"}}</a>
{{/if}}
</span>
</div>
</div>
<div class='clearfix'></div>
</div>

View File

@ -1,7 +1,7 @@
import { emojis, aliases, translations, tonableEmojis } from 'pretty-text/emoji/data'; import { emojis, aliases, translations, tonableEmojis } from 'pretty-text/emoji/data';
// bump up this number to expire all emojis // bump up this number to expire all emojis
export const IMAGE_VERSION = "5"; export const IMAGE_VERSION = "<%= Emoji::EMOJI_VERSION %>";
const extendedEmoji = {}; const extendedEmoji = {};
@ -10,10 +10,8 @@ export function registerEmoji(code, url) {
extendedEmoji[code] = url; extendedEmoji[code] = url;
} }
export function emojiList() { export function extendedEmojiList() {
const result = emojis.slice(0); return extendedEmoji;
_.each(extendedEmoji, (v,k) => result.push(k));
return result;
} }
const emojiHash = {}; const emojiHash = {};
@ -54,8 +52,7 @@ export function isCustomEmoji(code, opts) {
export function buildEmojiUrl(code, opts) { export function buildEmojiUrl(code, opts) {
let url; let url;
code = code.toLowerCase(); code = String(code).toLowerCase();
if (extendedEmoji.hasOwnProperty(code)) { if (extendedEmoji.hasOwnProperty(code)) {
url = extendedEmoji[code]; url = extendedEmoji[code];
} }
@ -116,10 +113,9 @@ export function emojiSearch(term, options) {
}; };
export function isSkinTonableEmoji(term) { export function isSkinTonableEmoji(term) {
let match = term.match(/^:?(.*?):?$/); const match = _.compact(term.split(":"))[0];
if (match) { if (match) {
return tonableEmojis.indexOf(match[1]) !== -1; return tonableEmojis.indexOf(match) !== -1;
} else {
return tonableEmojis.indexOf(term) !== -1;
} }
return false;
} }

View File

@ -1,128 +1,115 @@
body img.emoji { .emoji-picker {
width: 20px; background-color: #fff;
height: 20px; border: 1px solid #e9e9e9;
vertical-align: middle; box-shadow: 0 1px 5px rgba(0,0,0,0.4);
} background-clip: padding-box;
.wmd-emoji-button:before {
content: "\f118";
}
.emoji-modal {
z-index: 10000; z-index: 10000;
position: fixed; position: fixed;
left: 50%;
top: 50%;
width: 445px;
min-height: 264px;
margin-top: -132px;
margin-left: -222px;
background-color: dark-light-choose(#dadada, blend-primary-secondary(5%)); background-color: dark-light-choose(#dadada, blend-primary-secondary(5%));
display: flex; display: flex;
flex-direction: row;
height: 300px;
border-radius: 3px;
}
.emoji-picker .categories-column {
display: flex;
flex-direction: column;
background: white;
flex: 1;
align-items: center;
justify-content: space-between;
border-right: 1px solid #e9e9e9;
min-width: 36px;
}
.emoji-picker .category-icon {
display: block;
margin: 4px auto;
-webkit-filter: grayscale(100%);
filter: grayscale(100%);
}
.emoji-picker .category-icon.current, .emoji-picker .category-icon:hover {
-webkit-filter: grayscale(0%);
filter: grayscale(0%);
}
.emoji-picker .main-column {
display: flex;
flex-direction: column;
background: white;
flex: 20;
}
.emoji-picker .list {
overflow-y: scroll;
-webkit-overflow-scrolling: touch;
padding: 0px;
flex: 1;
flex-direction: column; flex-direction: column;
} }
table.emoji-page td { .emoji-picker .list .emoji {
border: 1px solid transparent; margin: 5px;
background-color: dark-light-choose(white, $secondary);
padding: 0 !important;
} }
.emoji-page a { .emoji-picker .section-header {
padding: 8px; padding: 8px;
display: block; margin-bottom: 4px;
border-radius: 20px; padding-bottom: 4px;
border-bottom: 1px solid #e9e9e9;
justify-content: space-between;
display: flex;
align-items: center;
} }
.emoji-page a:hover { .emoji-picker .section-header .title {
background-color: dark-light-choose(rgb(210, 236, 252), rgb(45, 19, 3));
} }
.emoji-table-wrapper { .emoji-picker .section-header .clear-recent .fa{
min-width: 442px; margin: 0;
min-height: 185px;
background-color: $secondary;
} }
.emoji-modal-wrapper { .section-group {
z-index: 9999; flex-wrap: wrap;
position: fixed; display: flex;
left: 0; align-items: center;
top: 0; justify-content: flex-start;
width: 100%; padding: 4px;
height: 100%;
opacity: dark-light-choose(0.8, 0.5);
background-color: black;
} }
.emoji-modal .toolbar { .section-group a:hover, .results a:hover {
margin: 8px 0 5px;
}
.emoji-modal .toolbar li {
display: inline;
padding-right: 1px;
}
.emoji-modal .toolbar li a {
padding: 8px;
background-color: dark-light-choose(#dadada, blend-primary-secondary(5%));
}
.emoji-modal .toolbar li a.selected {
background-color: $secondary;
}
.emoji-modal .nav span {
color: dark-light-choose(#aaa, #555);
}
.emoji-modal .nav span.next {
margin-left: 10px;
}
.emoji-modal .nav a {
color: dark-light-choose(#333, #ccc);
}
.emoji-shortname {
display: inline-block; display: inline-block;
max-width: 200px; vertical-align: top;
text-overflow: ellipsis; border-radius: 50%;
overflow: hidden; background-color: #d1f0ff;
vertical-align: middle;
} }
.emoji-modal .footer { .emoji-picker .footer {
display: flex;
align-items: center; align-items: center;
display: flex;
justify-content: space-between; justify-content: space-between;
flex-direction: row; border-top: 1px solid #e9e9e9;
flex-grow: 2; }
.emoji-picker .info {
text-overflow: ellipsis;
max-width: 232px;
padding-left: 8px;
white-space: nowrap;
overflow: hidden;
font-weight: 700;
max-width: 125px;
}
.emoji-picker .diversity-picker {
display: flex;
justify-content: flex-end;
padding: 8px; padding: 8px;
} }
.emoji-modal .info { .emoji-picker .diversity-picker .diversity-scale {
flex: 10;
}
.emoji-modal .info span {
margin-left: 5px;
font-weight: bold;
color: $primary;
}
.emoji-modal .nav {
margin-left: 10px;
}
.emoji-modal .tones {
display: flex;
align-items: center;
justify-content: space-between;
}
.emoji-modal .tones-button {
width: 20px; width: 20px;
height: 20px; height: 20px;
margin-left: 5px; margin-left: 5px;
@ -133,14 +120,88 @@ table.emoji-page td {
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
} }
.emoji-modal .tones-button.default { background: #ffcc4d; } .emoji-picker .diversity-picker .diversity-scale.default { background: #ffcc4d; }
.emoji-modal .tones-button.light { background: #f7dece; } .emoji-picker .diversity-picker .diversity-scale.light { background: #f7dece; }
.emoji-modal .tones-button.medium-light { background: #f3d2a2; } .emoji-picker .diversity-picker .diversity-scale.medium-light { background: #f3d2a2; }
.emoji-modal .tones-button.medium { background: #d5ab88; } .emoji-picker .diversity-picker .diversity-scale.medium { background: #d5ab88; }
.emoji-modal .tones-button.medium-dark { background: #af7e57; } .emoji-picker .diversity-picker .diversity-scale.medium-dark { background: #af7e57; }
.emoji-modal .tones-button.dark { background: #7c533e; } .emoji-picker .diversity-picker .diversity-scale.dark { background: #7c533e; }
.emoji-modal .tones-button i.fa { .emoji-picker .diversity-picker .diversity-scale.selected i {
display: block;
}
.emoji-picker .diversity-picker i {
display: none;
}
.emoji-picker .diversity-picker i.fa {
color: #fff; color: #fff;
font-size: 12px;
text-shadow: 0.5px 1.5px 0 rgba(0,0,0,0.3); text-shadow: 0.5px 1.5px 0 rgba(0,0,0,0.3);
} }
body img.emoji {
width: 20px;
height: 20px;
vertical-align: middle;
}
.wmd-emoji-button:before {
content: "\f118";
}
.emoji-picker-modal.fadeIn {
z-index: 9999;
position: fixed;
left: 0;
top: 0;
width: 100%;
height: 100%;
opacity: .8;
background-color: black;
}
.emoji-picker .filter {
background-color: #e9e9e9;
border-bottom: 1px solid #e9e9e9;
padding: 5px;
display: flex;
position: relative;
}
.emoji-picker .filter input {
height: 24px;
margin: 0;
flex: 1;
border: 1px solid #e9e9e9;
padding-right: 24px;
}
.emoji-picker .results {
display: none;
flex-wrap: wrap;
align-items: center;
justify-content: flex-start;
padding: 4px;
flex: 1;
}
.emoji-picker .results .emoji {
margin: 5px;
}
.emoji-picker .filter .clear-filter {
position: absolute;
right: 10px;
top: 12px;
border: 0;
background: none;
color: $primary;
outline: none;
display: none;
&:hover {
color: $tertiary;
}
}

View File

@ -1,10 +0,0 @@
.emoji-table-wrapper {
min-width: 320px;
}
.emoji-modal {
width: 340px;
margin-top: -132px;
margin-left: -170px;
background-color: dark-light-choose(#dadada, blend-primary-secondary(5%));
}

View File

@ -1,6 +1,6 @@
class Emoji class Emoji
# update this to clear the cache # update this to clear the cache
EMOJI_VERSION = "v5" EMOJI_VERSION = "5"
FITZPATRICK_SCALE ||= [ "1f3fb", "1f3fc", "1f3fd", "1f3fe", "1f3ff" ] FITZPATRICK_SCALE ||= [ "1f3fb", "1f3fc", "1f3fd", "1f3fe", "1f3ff" ]
@ -46,15 +46,19 @@ class Emoji
def self.create_from_db_item(emoji) def self.create_from_db_item(emoji)
name = emoji["name"] name = emoji["name"]
filename = "#{emoji['filename'] || name}.png" filename = emoji['filename'] || name
Emoji.new.tap do |e| Emoji.new.tap do |e|
e.name = name e.name = name
e.url = "#{Discourse.base_uri}/images/emoji/#{SiteSetting.emoji_set}/#{filename}" e.url = Emoji.url_for(filename)
end end
end end
def self.url_for(name)
"#{Discourse.base_uri}/images/emoji/#{SiteSetting.emoji_set}/#{name}.png?v=5"
end
def self.cache_key(name) def self.cache_key(name)
"#{name}:#{EMOJI_VERSION}:#{Plugin::CustomEmoji.cache_key}" "#{name}:v#{EMOJI_VERSION}:#{Plugin::CustomEmoji.cache_key}"
end end
def self.clear_cache def self.clear_cache

View File

@ -1134,6 +1134,18 @@ en:
ctrl: 'Ctrl' ctrl: 'Ctrl'
alt: 'Alt' alt: 'Alt'
emoji_picker:
filter_placeholder: Search an emoji
people: People
nature: Nature
food: Food
activity: Activity
travel: Travel
objects: Objects
celebration: Celebration
custom: Custom emojis
recent: Recently used emojis
composer: composer:
emoji: "Emoji :)" emoji: "Emoji :)"
more_emoji: "more..." more_emoji: "more..."

6047
lib/emoji/groups.json Normal file

File diff suppressed because it is too large Load Diff

View File

@ -4,7 +4,7 @@ require "json"
require "nokogiri" require "nokogiri"
require "open-uri" require "open-uri"
EMOJI_GROUPS_PATH ||= "app/assets/javascripts/discourse/lib/emoji/groups.js.es6" EMOJI_GROUPS_PATH ||= "lib/emoji/groups.json"
EMOJI_DB_PATH ||= "lib/emoji/db.json" EMOJI_DB_PATH ||= "lib/emoji/db.json"
@ -432,7 +432,7 @@ def fix_incomplete_sets(emojis)
end end
end end
def generate_emoji_groups(emojis) def generate_emoji_groups(keywords)
puts "Generating groups..." puts "Generating groups..."
list = open(EMOJI_ORDERING_URL).read list = open(EMOJI_ORDERING_URL).read
@ -453,8 +453,8 @@ def generate_emoji_groups(emojis)
emoji_char = code_to_emoji(emoji_code) emoji_char = code_to_emoji(emoji_code)
if emoji = emojis[emoji_char] if emoji = keywords[emoji_char]
group["icons"] << emoji["name"] group["icons"] << { name: emoji["name"], diversity: emoji["fitzpatrick_scale"] }
end end
end end
end end
@ -518,15 +518,7 @@ def write_js_groups(emojis, groups)
confirm_overwrite(EMOJI_GROUPS_PATH) confirm_overwrite(EMOJI_GROUPS_PATH)
template = <<TEMPLATE template = JSON.pretty_generate(groups)
// This file is generated by emoji.rake do not modify directly
// note that these categories are copied from Slack
const groups = #{JSON.pretty_generate(groups)};
export default groups;
TEMPLATE
FileUtils.mkdir_p(File.expand_path("..", EMOJI_GROUPS_PATH)) FileUtils.mkdir_p(File.expand_path("..", EMOJI_GROUPS_PATH))
File.write(EMOJI_GROUPS_PATH, template) File.write(EMOJI_GROUPS_PATH, template)
end end

View File

@ -0,0 +1,259 @@
import { acceptance } from "helpers/qunit-helpers";
import { IMAGE_VERSION as v } from 'pretty-text/emoji';
import {
keyValueStore,
EMOJI_USAGE,
EMOJI_SCROLL_Y,
EMOJI_SELECTED_DIVERSITY
} from 'discourse/components/emoji-picker';
acceptance("EmojiPicker", {
loggedIn: true,
beforeEach() {
keyValueStore.setObject({ key: EMOJI_USAGE, value: {} });
keyValueStore.setObject({ key: EMOJI_SCROLL_Y, value: 0 });
keyValueStore.setObject({ key: EMOJI_SELECTED_DIVERSITY, value: 1 });
}
});
QUnit.test("emoji picker can be opened/closed", assert => {
visit("/t/internationalization-localization/280");
click("#topic-footer-buttons .btn.create");
click("button.emoji.btn");
andThen(() => {
assert.notEqual(
find('.emoji-picker').html().trim(),
"",
"it opens the picker"
);
});
click("button.emoji.btn");
andThen(() => {
assert.equal(
find('.emoji-picker').html().trim(),
"",
"it closes the picker"
);
});
});
QUnit.test("emojis can be hovered to display info", assert => {
visit("/t/internationalization-localization/280");
click("#topic-footer-buttons .btn.create");
click("button.emoji.btn");
andThen(() => {
$(".emoji-picker a[title='grinning']").trigger('mouseover');
andThen(() => {
assert.equal(
find('.emoji-picker .info').html().trim(),
`<img src=\"/images/emoji/emoji_one/grinning.png?v=${v}\" class=\"emoji\"> <span>:grinning:<span></span></span>`,
"it displays emoji info when hovering emoji"
);
});
});
});
QUnit.test("emoji picker has sections", assert => {
visit("/t/internationalization-localization/280");
click("#topic-footer-buttons .btn.create");
click("button.emoji.btn");
click(".emoji-picker .categories-column a[title='travel']");
andThen(() => {
assert.notEqual(
find('.emoji-picker .list').scrollTop(),
0,
"it scrolls to section"
);
assert.equal(
find(".emoji-picker .categories-column a[title='travel']").parent().hasClass('current'),
true,
"it highlights section icon"
);
});
});
QUnit.test("emoji picker triggers event when picking emoji", assert => {
visit("/t/internationalization-localization/280");
click("#topic-footer-buttons .btn.create");
click("button.emoji.btn");
click(".emoji-picker a[title='grinning']");
andThen(() => {
assert.equal(
find('.d-editor-input').val(),
":grinning:",
"it adds the emoji code in the editor when selected"
);
});
});
QUnit.test("emoji picker has a list of recently used emojis", assert => {
visit("/t/internationalization-localization/280");
click("#topic-footer-buttons .btn.create");
click("button.emoji.btn");
click(".emoji-picker .clear-recent");
click(".emoji-picker a[title='grinning']");
andThen(() => {
assert.equal(
find('.section[data-section="recent"]').css("display"),
"block",
"it shows recent section"
);
assert.equal(
find('.section[data-section="recent"] .section-group img.emoji').length,
1,
"it adds the emoji code to the recently used emojis list"
);
});
});
QUnit.test("emoji picker can clear recently used emojis", assert => {
visit("/t/internationalization-localization/280");
click("#topic-footer-buttons .btn.create");
click("button.emoji.btn");
click(".emoji-picker a[title='grinning']");
click(".emoji-picker a[title='sunglasses']");
click(".emoji-picker a[title='sunglasses']");
andThen(() => {
assert.equal(
find('.section[data-section="recent"] .section-group img.emoji').length,
2
);
click(".emoji-picker .clear-recent");
andThen(() => {
assert.equal(
find('.section[data-section="recent"] .section-group img.emoji').length,
0,
"it has cleared recent emojis"
);
assert.equal(
find('.section[data-section="recent"]').css("display"),
"none",
"it hides recent section"
);
assert.equal(
find('.category-icon a[title="recent"]').parent().css("display"),
"none",
"it hides recent category icon"
);
});
});
});
QUnit.test("emoji picker correctly orders recently used emojis", assert => {
visit("/t/internationalization-localization/280");
click("#topic-footer-buttons .btn.create");
click("button.emoji.btn");
click(".emoji-picker .clear-recent");
click(".emoji-picker a[title='grinning']");
click(".emoji-picker a[title='sunglasses']");
click(".emoji-picker a[title='sunglasses']");
andThen(() => {
assert.equal(
find('.section[data-section="recent"] .section-group img.emoji').length,
2,
"it has multiple recent emojis"
);
assert.equal(
find('.section[data-section="recent"] .section-group img.emoji').first().attr('src'),
`/images/emoji/emoji_one/sunglasses.png?v=${v}`,
"it puts the most used emoji in first"
);
});
});
QUnit.test("emoji picker lazy loads emojis", assert => {
visit("/t/internationalization-localization/280");
click("#topic-footer-buttons .btn.create");
click("button.emoji.btn");
andThen(() => {
const $emoji = $('.emoji-picker a[title="massage_woman"] img');
assert.equal(
$emoji.attr('src'),
"",
"it doesn't load invisible emojis"
);
});
andThen(() => {
const done = assert.async();
setTimeout(() => {
$('.emoji-picker .list').scrollTop(2600);
setTimeout(() => {
const $emoji = $('a[title="massage_woman"] img');
assert.equal(
$emoji.attr('src'),
`/images/emoji/emoji_one/massage_woman.png?v=${v}`,
"it loads visible emojis"
);
done();
}, 50);
}, 50);
});
});
QUnit.test("emoji picker supports diversity scale", assert => {
visit("/t/internationalization-localization/280");
click("#topic-footer-buttons .btn.create");
click("button.emoji.btn");
click('.emoji-picker a.diversity-scale.dark');
andThen(() => {
const done = assert.async();
setTimeout(() => {
$('.emoji-picker .list').scrollTop(2900);
setTimeout(() => {
const $emoji = $('a[title="massage_woman"] img');
assert.equal(
$emoji.attr('src'),
`/images/emoji/emoji_one/massage_woman/6.png?v=${v}`,
"it applies diversity scale on emoji"
);
done();
}, 250);
}, 250);
});
});
QUnit.test("emoji picker persists state", assert => {
visit("/t/internationalization-localization/280");
click("#topic-footer-buttons .btn.create");
click("button.emoji.btn");
andThen(() => {
$('.emoji-picker .list').scrollTop(2600);
click('.emoji-picker a.diversity-scale.medium-dark');
});
click("button.emoji.btn");
click("button.emoji.btn");
andThen(() => {
assert.equal(
find('.emoji-picker .list').scrollTop() > 2500,
true,
"it stores scroll position"
);
assert.equal(
find('.emoji-picker .diversity-scale.medium-dark').hasClass('selected'),
true,
"it stores diversity scale"
);
});
});

View File

@ -3,11 +3,3 @@
.modal-backdrop { .modal-backdrop {
display: none; display: none;
} }
.emoji-modal-wrapper {
display: none;
}
.emoji-modal {
position: relative;
}