From 226be994dab13e6358ed977877d2c27788dc73af Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Mon, 24 Aug 2020 14:20:51 +0200 Subject: [PATCH] REFACTOR: rewrite the emoji-picker (#10464) The emoji-picker is a specific piece of code as it has very strong performance requirements which are almost not found anywhere else in the app, as a result it was using various hacks to make it work decently even on old browsers. Following our drop of Internet Explorer, and various new features in Ember and recent browsers we can now take advantage of this to reduce the amount of code needed, this rewrite most importantly does the following: - use loading="lazy" preventing the full list of emojis to be loaded on opening - uses InterserctionObserver to find the active section - limits the use of native event listentes only for hover/click emojis (for performance reason we track click on the whole emoji area and delegate events), everything else is using ember events - uses popper to position the emoji picker - no jquery code --- app/assets/javascripts/discourse-shims.js | 7 + .../discourse/app/components/d-editor.js | 4 +- .../discourse/app/components/emoji-picker.js | 824 +++++------------- .../discourse/app/helpers/replace-emoji.js | 4 +- .../app/initializers/enable-emoji.js | 3 +- .../app/templates/components/d-editor.hbs | 7 +- .../templates/components/emoji-picker.hbs.erb | 160 ++-- .../app/templates/emoji-picker-recent.hbr | 3 - .../javascripts/pretty-text/addon/emoji.js | 12 +- app/assets/stylesheets/common/base/emoji.scss | 371 ++++---- app/assets/stylesheets/mobile/emoji.scss | 14 +- .../acceptance/emoji-picker-test.js | 110 +-- test/javascripts/components/d-editor-test.js | 2 +- 13 files changed, 545 insertions(+), 976 deletions(-) delete mode 100644 app/assets/javascripts/discourse/app/templates/emoji-picker-recent.hbr diff --git a/app/assets/javascripts/discourse-shims.js b/app/assets/javascripts/discourse-shims.js index e359e051ec8..9a86440123e 100644 --- a/app/assets/javascripts/discourse-shims.js +++ b/app/assets/javascripts/discourse-shims.js @@ -20,3 +20,10 @@ define("message-bus-client", ["exports"], function(__exports__) { define("ember-buffered-proxy/proxy", ["exports"], function(__exports__) { __exports__.default = window.BufferedProxy; }); + +define("@popperjs/core", ["exports"], function(__exports__) { + __exports__.default = window.Popper; + __exports__.createPopper = window.Popper.createPopper; + __exports__.defaultModifiers = window.Popper.defaultModifiers; + __exports__.popperGenerator = window.Popper.popperGenerator; +}); diff --git a/app/assets/javascripts/discourse/app/components/d-editor.js b/app/assets/javascripts/discourse/app/components/d-editor.js index 9b6896eee7e..735d4fe5457 100644 --- a/app/assets/javascripts/discourse/app/components/d-editor.js +++ b/app/assets/javascripts/discourse/app/components/d-editor.js @@ -459,9 +459,7 @@ export default Component.extend({ return `${v.code}:`; } else { $editorInput.autocomplete({ cancel: true }); - this.setProperties({ - emojiPickerIsActive: true - }); + this.set("emojiPickerIsActive", true); schedule("afterRender", () => { const filterInput = document.querySelector( diff --git a/app/assets/javascripts/discourse/app/components/emoji-picker.js b/app/assets/javascripts/discourse/app/components/emoji-picker.js index 3acf1816222..3811526cbe0 100644 --- a/app/assets/javascripts/discourse/app/components/emoji-picker.js +++ b/app/assets/javascripts/discourse/app/components/emoji-picker.js @@ -1,18 +1,21 @@ +import { observes } from "discourse-common/utils/decorators"; +import { bind } from "discourse-common/utils/decorators"; +import { htmlSafe } from "@ember/template"; +import { emojiUnescape } from "discourse/lib/text"; +import { escapeExpression } from "discourse/lib/utilities"; +import { action, computed } from "@ember/object"; import { inject as service } from "@ember/service"; -import { throttle, debounce, schedule, later } from "@ember/runloop"; +import { schedule, later } from "@ember/runloop"; import Component from "@ember/component"; -import { on, observes } from "discourse-common/utils/decorators"; -import { findRawTemplate } from "discourse-common/lib/raw-templates"; import { emojiUrlFor } from "discourse/lib/text"; +import { createPopper } from "@popperjs/core"; import { extendedEmojiList, isSkinTonableEmoji, emojiSearch } from "pretty-text/emoji"; import { safariHacksDisabled } from "discourse/lib/utilities"; -import { isTesting, INPUT_DELAY } from "discourse-common/config/environment"; -const PER_ROW = 11; function customEmojis() { const list = extendedEmojiList(); const groups = []; @@ -28,626 +31,261 @@ function customEmojis() { } export default Component.extend({ - automaticPositioning: true, emojiStore: service("emoji-store"), + tagName: "", + customEmojis: null, + selectedDiversity: null, + recentEmojis: null, + hoveredEmoji: null, + isActive: false, + isLoading: true, - close() { - this._unbindEvents(); + init() { + this._super(...arguments); - this.$picker && - this.$picker.css({ width: "", left: "", bottom: "", display: "none" }); - - this.$modal.removeClass("fadeIn"); - }, - - show() { - this.$filter = this.$picker.find(".filter"); - this.$results = this.$picker.find(".results"); - this.$list = this.$picker.find(".list"); - - this.setProperties({ - selectedDiversity: this.emojiStore.diversity, - recentEmojis: this.emojiStore.favorites - }); - - schedule("afterRender", this, function() { - this._bindEvents(); - this._loadCategoriesEmojis(); - this._positionPicker(); - this._scrollTo(); - this._updateSelectedDiversity(); - this._checkVisibleSection(true); - - if ( - (!this.site.isMobileDevice || this.isEditorFocused) && - !safariHacksDisabled() - ) - this.$filter.find("input[name='filter']").focus(); - }); - }, - - @on("init") - _setInitialValues() { this.set("customEmojis", customEmojis()); - this.scrollPosition = 0; - this.$visibleSections = []; + this.set("recentEmojis", this.emojiStore.favorites); + this.set("selectedDiversity", this.emojiStore.diversity); + + this._sectionObserver = this._setupSectionObserver(); }, - @on("willDestroyElement") - _unbindGlobalEvents() { - this.appEvents.off("emoji-picker:close", this, "_closeEmojiPicker"); + didInsertElement() { + this._super(...arguments); + + this.appEvents.on("emoji-picker:close", this, "onClose"); }, - _closeEmojiPicker() { - this.set("active", false); - }, - - @on("didInsertElement") + // didReceiveAttrs would be a better choice here, but this is sadly causing + // too many unexpected reloads as it's triggered for other reasons than a mutation + // of isActive + @observes("isActive") _setup() { - this.appEvents.on("emoji-picker:close", this, "_closeEmojiPicker"); + if (this.isActive) { + this.onShow(); + } else { + this.onClose(); + } }, - @on("didUpdateAttrs") - _setState() { + willDestroyElement() { + this._super(...arguments); + + this._sectionObserver && this._sectionObserver.disconnect(); + + this.appEvents.off("emoji-picker:close", this, "onClose"); + }, + + @action + onShow() { + this.set("isLoading", true); + schedule("afterRender", () => { - if (!this.element) { - return; - } + document.addEventListener("click", this.handleOutsideClick); - this.$picker = $(this.element.querySelector(".emoji-picker")); - this.$modal = $(this.element.querySelector(".emoji-picker-modal")); + const emojiPicker = document.querySelector(".emoji-picker"); + if (!emojiPicker) return; - this.active ? this.show() : this.close(); - }); - }, - - @observes("filter") - filterChanged() { - this.$filter.find(".clear-filter").toggle(!_.isEmpty(this.filter)); - const filterDelay = this.site.isMobileDevice ? 400 : INPUT_DELAY; - debounce(this, this._filterEmojisList, filterDelay); - }, - - @observes("selectedDiversity") - selectedDiversityChanged() { - this.emojiStore.diversity = this.selectedDiversity; - - $.each( - this.$list.find(".emoji[data-loaded='1'].diversity"), - (_, button) => { - $(button) - .css("background-image", "") - .removeAttr("data-loaded"); - } - ); - - if (this.filter !== "") { - $.each(this.$results.find(".emoji.diversity"), (_, button) => - this._setButtonBackground(button, true) - ); - } - - this._updateSelectedDiversity(); - this._checkVisibleSection(true); - }, - - @observes("recentEmojis") - _recentEmojisChanged() { - const previousScrollTop = this.scrollPosition; - const $recentSection = this.$list.find(".section[data-section='recent']"); - const $recentSectionGroup = $recentSection.find(".section-group"); - const $recentCategory = this.$picker - .find(".category-icon button[data-section='recent']") - .parent(); - let persistScrollPosition = !$recentCategory.is(":visible") ? true : false; - - // we set height to 0 to avoid it being taken into account for scroll position - if (_.isEmpty(this.recentEmojis)) { - $recentCategory.hide(); - $recentSection.css("height", 0).hide(); - } else { - $recentCategory.show(); - $recentSection.css("height", "auto").show(); - } - - const recentEmojis = this.recentEmojis.map(code => { - return { code, src: emojiUrlFor(code) }; - }); - const template = findRawTemplate("emoji-picker-recent")({ recentEmojis }); - $recentSectionGroup.html(template); - - if (persistScrollPosition) { - this.$list.scrollTop(previousScrollTop + $recentSection.outerHeight()); - } - - this._bindHover($recentSectionGroup); - }, - - _updateSelectedDiversity() { - const $diversityPicker = this.$picker.find(".diversity-picker"); - - $diversityPicker.find(".diversity-scale").removeClass("selected"); - $diversityPicker - .find(`.diversity-scale[data-level="${this.selectedDiversity}"]`) - .addClass("selected"); - }, - - _loadCategoriesEmojis() { - $.each( - this.$picker.find(".categories-column button.emoji"), - (_, button) => { - const $button = $(button); - const code = this._codeWithDiversity($button.data("tabicon"), false); - $button.css("background-image", `url("${emojiUrlFor(code)}")`); - } - ); - }, - - _bindEvents() { - this._bindDiversityClick(); - this._bindSectionsScroll(); - this._bindEmojiClick(this.$list.find(".section-group")); - this._bindClearRecentEmojisGroup(); - this._bindResizing(); - this._bindCategoryClick(); - this._bindModalClick(); - this._bindFilterInput(); - - if (!this.site.isMobileDevice) { - this._bindHover(); - } - - later(this, this._onScroll, 100); - }, - - _bindModalClick() { - this.$modal.on("click", () => this.set("active", false)); - - $("html").on("mouseup.emoji-picker", event => { - let $target = $(event.target); - if ( - $target.closest(".emoji-picker").length || - $target.closest(".emoji.btn").length || - $target.hasClass("grippie") - ) { - return; - } - - // Close the popup if clicked outside - this.set("active", false); - return false; - }); - }, - - @on("willDestroyElement") - _unbindEvents() { - $(this.element).off(); - $(window).off("resize"); - $("#reply-control").off("div-resizing"); - $("html").off("mouseup.emoji-picker"); - }, - - _filterEmojisList() { - if (this.filter === "") { - this.$filter.find("input[name='filter']").val(""); - this.$results.empty().hide(); - this.$list.css("visibility", "visible"); - } else { - const lowerCaseFilter = this.filter.toLowerCase(); - const filteredCodes = emojiSearch(lowerCaseFilter, { maxResults: 30 }); - this.$results - .empty() - .html( - filteredCodes.map(code => { - const hasDiversity = isSkinTonableEmoji(code); - const diversity = hasDiversity ? "diversity" : ""; - const scaledCode = this._codeWithDiversity(code, hasDiversity); - return ``; - }) - ) - .show(); - this._bindHover(this.$results); - this._bindEmojiClick(this.$results); - this.$list.css("visibility", "hidden"); - } - }, - - _bindFilterInput() { - const $input = this.$filter.find("input"); - - $input.on("input", event => { - this.set("filter", event.currentTarget.value); - }); - - this.$filter.find(".clear-filter").on("click", () => { - $input.val("").focus(); - this.set("filter", ""); - return false; - }); - }, - - _bindCategoryClick() { - this.$picker.find(".category-icon").on("click", "button.emoji", event => { - this.set("filter", ""); - this.$results.empty(); - this.$list.css("visibility", "visible"); - - const section = $(event.currentTarget).data("section"); - const $section = this.$list.find(`.section[data-section="${section}"]`); - const scrollTop = - this.$list.scrollTop() + - ($section.offset().top - this.$list.offset().top); - this._scrollTo(scrollTop); - return false; - }); - }, - - _bindHover($hoverables) { - const replaceInfoContent = html => - this.$picker.find(".footer .info").html(html || ""); - - ($hoverables || this.$list.find(".section-group")).on( - { - mouseover: event => { - const code = this._codeForEmojiButton($(event.currentTarget)); - const html = ` :${code}:`; - replaceInfoContent(html); - }, - mouseleave: () => replaceInfoContent() - }, - "button.emoji" - ); - }, - - _bindResizing() { - $(window).on("resize", () => { - throttle(this, this._positionPicker, 16); - }); - - $("#reply-control").on("div-resizing", () => { - throttle(this, this._positionPicker, 16); - }); - }, - - _bindClearRecentEmojisGroup() { - const $recent = this.$picker.find( - ".section[data-section='recent'] .clear-recent" - ); - $recent.on("click", () => { - this.emojiStore.favorites = []; - this.set("recentEmojis", []); - this._scrollTo(0); - return false; - }); - }, - - _bindEmojiClick($emojisContainer) { - const handler = event => { - const code = this._codeForEmojiButton($(event.currentTarget)); - - if ( - $(event.currentTarget).parents(".section[data-section='recent']") - .length === 0 - ) { - this._trackEmojiUsage(code); - } - - this.emojiSelected(code); - - if (this.$modal.hasClass("fadeIn")) { - this.set("active", false); - } - - return false; - }; - - if (this.site.isMobileDevice) { - const self = this; - - $emojisContainer - .off("touchstart") - .on("touchstart", "button.emoji", touchStartEvent => { - const $this = $(touchStartEvent.currentTarget); - - $this.on("touchend", touchEndEvent => { - touchEndEvent.preventDefault(); - touchEndEvent.stopPropagation(); - - handler.bind(self)(touchEndEvent); - $this.off("touchend"); - }); - - $this.on("touchmove", () => $this.off("touchend")); - }); - } else { - $emojisContainer - .off("click") - .on("click", "button.emoji", e => handler.bind(this)(e)); - } - }, - - _bindSectionsScroll() { - this.$list.on("scroll", this._onScroll.bind(this)); - }, - - _onScroll() { - debounce(this, this._checkVisibleSection, 50); - }, - - _checkVisibleSection(force) { - // make sure we stop loading if picker has been removed - if (!this.$picker) { - return; - } - - const newPosition = this.$list.scrollTop(); - if (newPosition === this.scrollPosition && !force) { - return; - } - - this.scrollPosition = newPosition; - - const $sections = this.$list.find(".section"); - const listHeight = this.$list.innerHeight(); - let $selectedSection; - - this.$visibleSections = _.filter($sections, section => { - const $section = $(section); - const sectionTop = $section.position().top; - return sectionTop + $section.height() > 0 && sectionTop < listHeight; - }); - - if (!_.isEmpty(this.recentEmojis) && this.scrollPosition === 0) { - $selectedSection = $(_.first(this.$visibleSections)); - } else { - $selectedSection = $(_.last(this.$visibleSections)); - } - - if ($selectedSection) { - this.$picker.find(".category-icon").removeClass("current"); - this.$picker - .find( - `.category-icon button[data-section='${$selectedSection.data( - "section" - )}']` - ) - .parent() - .addClass("current"); - - this._loadVisibleSections(); - } - - later(this, this._checkVisibleSection, 100); - }, - - _loadVisibleSections() { - if (!this.$visibleSections) { - return; - } - - const listHeight = this.$list.innerHeight(); - - this.$visibleSections.forEach(visibleSection => { - const $unloadedEmojis = $(visibleSection).find( - "button.emoji:not(.custom)[data-loaded!='1']" - ); - $.each($unloadedEmojis, (_, button) => { - let offsetTop = button.offsetTop; - - if (offsetTop < this.scrollPosition + listHeight + 200) { - if (offsetTop + 200 > this.scrollPosition) { - const $button = $(button); - this._setButtonBackground($button); + if (!this.site.isMobileDevice) { + this._popper = createPopper( + document.querySelector(".d-editor-textarea-wrapper"), + emojiPicker, + { + placement: "auto", + modifiers: [ + { + name: "preventOverflow" + }, + { + name: "offset", + options: { + offset: [5, 5] + } + } + ] } - } - }); - }); - }, + ); + } - _bindDiversityClick() { - const $diversityScales = this.$picker.find( - ".diversity-picker .diversity-scale" - ); - $diversityScales.on("click", event => { - const $selectedDiversity = $(event.currentTarget); - this.set( - "selectedDiversity", - parseInt($selectedDiversity.data("level"), 10) - ); - return false; - }); - }, + emojiPicker + .querySelectorAll(".emojis-container .section .section-header") + .forEach(p => this._sectionObserver.observe(p)); - _isReplyControlExpanded() { - const verticalSpace = - $(window).height() - - $(".d-header").height() - - $("#reply-control").height(); - - return verticalSpace < this.$picker.height() - 48; - }, - - _positionPicker() { - if (!this.active) { - return; - } - - let windowWidth = $(window).width(); - - const desktopModalePositioning = options => { - let attributes = { - width: Math.min(windowWidth, 400) - 12, - marginLeft: -(Math.min(windowWidth, 400) / 2) + 6, - marginTop: -130, - left: "50%", - bottom: "", - top: "50%", - display: "flex" - }; - - this.$modal.addClass("fadeIn"); - this.$picker.css(_.merge(attributes, options)); - }; - - const mobilePositioning = options => { - let attributes = { - width: windowWidth, - marginLeft: 0, - marginTop: "auto", - left: 0, - bottom: "", - top: 0, - display: "flex" - }; - - this.$modal.addClass("fadeIn"); - this.$picker.css(_.merge(attributes, options)); - }; - - const desktopPositioning = options => { - let attributes = { - position: "fixed", - width: windowWidth < 485 ? windowWidth - 12 : 400, - marginLeft: "", - marginTop: "", - right: "", - left: "", - bottom: 32, - top: "", - display: "flex" - }; - - this.$modal.removeClass("fadeIn"); - this.$picker.css(_.merge(attributes, options)); - }; - - if (isTesting() || !this.automaticPositioning) { - desktopPositioning(); - return; - } - - if (this.site.isMobileDevice) { - mobilePositioning(); - } else { - if (this._isReplyControlExpanded()) { - let $editorWrapper = $(".d-editor-preview-wrapper"); - if ( - ($editorWrapper.is(":visible") && $editorWrapper.width() < 400) || - windowWidth < 485 - ) { - desktopModalePositioning(); - } else { - if ($editorWrapper.is(":visible")) { - let previewOffset = $(".d-editor-preview-wrapper").offset(); - let replyControlOffset = $("#reply-control").offset(); - let left = previewOffset.left - replyControlOffset.left; - desktopPositioning({ left }); - } else { - desktopPositioning({ - right: - ($("#reply-control").width() - - $(".d-editor-container").width()) / - 2 - }); - } - } - } else { - if (windowWidth < 485) { - desktopModalePositioning(); - } else { - const previewInputOffset = $(".d-editor-input").offset(); - - const pickerHeight = $(".d-editor .emoji-picker").height(); - const editorHeight = $(".d-editor-input").height(); - const windowBottom = $(window).scrollTop() + $(window).height(); + // this is a low-tech trick to prevent appending hundreds of emojis + // of blocking the rendering of the picker + later(() => { + this.set("isLoading", false); + schedule("afterRender", () => { if ( - previewInputOffset.top + editorHeight + pickerHeight < - windowBottom + (!this.site.isMobileDevice || this.isEditorFocused) && + !safariHacksDisabled() ) { - // position it below editor if there is enough space - desktopPositioning({ - position: "absolute", - top: previewInputOffset.top + editorHeight, - left: previewInputOffset.left - }); - } else { - // try positioning it above - desktopPositioning({ - position: "absolute", - top: -pickerHeight, - left: previewInputOffset.left - }); + const filter = emojiPicker.querySelector("input.filter"); + filter && filter.focus(); } - } - } - } - const infoMaxWidth = - this.$picker.width() - - this.$picker.find(".categories-column").width() - - this.$picker.find(".diversity-picker").width() - - 60; - this.$picker.find(".info").css("max-width", infoMaxWidth); + if (this.selectedDiversity !== 0) { + this._applyDiversity(this.selectedDiversity); + } + }); + }, 50); + }); }, - _codeWithDiversity(code, diversity) { - if (diversity && this.selectedDiversity !== 1) { - return `${code}:t${this.selectedDiversity}`; + @action + onClose() { + document.removeEventListener("click", this.handleOutsideClick); + this.onEmojiPickerClose && this.onEmojiPickerClose(); + }, + + diversityScales: computed("selectedDiversity", function() { + return [ + "default", + "light", + "medium-light", + "medium", + "medium-dark", + "dark" + ].map((name, index) => { + return { + name, + icon: index === this.selectedDiversity ? "check" : "" + }; + }); + }), + + @action + onClearRecents() { + this.emojiStore.favorites = []; + this.set("recentEmojis", []); + }, + + @action + onDiversitySelection(scale) { + this.emojiStore.diversity = scale; + this.set("selectedDiversity", scale); + + this._applyDiversity(scale); + }, + + @action + onEmojiHover(event) { + const img = event.target; + if (!img.classList.contains("emoji") || img.tagName !== "IMG") { + return false; + } + + this.set("hoveredEmoji", event.target.title); + }, + + @action + onEmojiSelection(event) { + const img = event.target; + + if (!img.classList.contains("emoji") || img.tagName !== "IMG") { + return false; + } + + let code = event.target.title; + code = this._codeWithDiversity(code, this.selectedDiversity); + + this.emojiSelected(code); + + if (!img.parentNode.parentNode.classList.contains("recent")) { + this._trackEmojiUsage(code); + } + }, + + @action + onCategorySelection(sectionName) { + const section = document.querySelector( + `.emoji-picker-emoji-area .section[data-section="${sectionName}"]` + ); + section && section.scrollIntoView(); + }, + + @action + onFilter(event) { + const emojiPickerArea = document.querySelector(".emoji-picker-emoji-area"); + const emojisContainer = emojiPickerArea.querySelector(".emojis-container"); + const results = emojiPickerArea.querySelector(".results"); + results.innerHTML = ""; + + if (event.target.value) { + results.innerHTML = emojiSearch(event.target.value, { maxResults: 10 }) + .map(this._replaceEmoji) + .join(""); + + emojisContainer.style.visibility = "hidden"; + results.scrollIntoView(); } else { - return code; + emojisContainer.style.visibility = "visible"; } }, _trackEmojiUsage(code) { this.emojiStore.track(code); - this.set("recentEmojis", this.emojiStore.favorites.slice(0, PER_ROW)); + this.set("recentEmojis", this.emojiStore.favorites.slice(0, 10)); }, - _scrollTo(y) { - const yPosition = typeof y === "undefined" ? this.scrollPosition : y; - - this.$list.scrollTop(yPosition); - - // if we don’t actually scroll we need to force it - if (yPosition === 0) { - this.$list.scroll(); - } - }, - - _codeForEmojiButton($button) { - const title = $button.attr("title"); - return this._codeWithDiversity(title, $button.hasClass("diversity")); - }, - - _setButtonBackground(button, diversity) { - if (!button) { - return; - } - - const $button = $(button); - button = $button[0]; - - // changing style can force layout events - // this could slow down timers and lead to - // chrome delaying the request - window.requestAnimationFrame(() => { - const code = this._codeWithDiversity( - $button.attr("title"), - diversity || $button.hasClass("diversity") - ); - - // // force visual reloading if needed - if (button.style.backgroundImage !== "none") { - button.style.backgroundImage = ""; - } - - button.style.backgroundImage = `url("${emojiUrlFor(code)}")`; - $button.attr("data-loaded", 1); + _replaceEmoji(code) { + const escaped = emojiUnescape(`:${escapeExpression(code)}:`, { + lazy: true }); + return htmlSafe(`${escaped}`); + }, + + _codeWithDiversity(code, selectedDiversity) { + if (selectedDiversity !== 0 && isSkinTonableEmoji(code)) { + return `${code}:t${selectedDiversity + 1}`; + } else { + return code; + } + }, + + _applyDiversity(diversity) { + const emojiPickerArea = document.querySelector(".emoji-picker-emoji-area"); + + emojiPickerArea && + emojiPickerArea.querySelectorAll(".emoji.diversity").forEach(img => { + const code = this._codeWithDiversity(img.title, diversity); + img.src = emojiUrlFor(code); + }); + }, + + _setupSectionObserver() { + return new IntersectionObserver( + entries => { + entries.forEach(entry => { + if (entry.isIntersecting) { + const sectionName = entry.target.parentNode.dataset.section; + const categoryButtons = document.querySelector( + ".emoji-picker .emoji-picker-category-buttons" + ); + + if (!categoryButtons) return; + + const button = categoryButtons.querySelector( + `.category-button[data-section="${sectionName}"]` + ); + + categoryButtons + .querySelectorAll(".category-button") + .forEach(b => b.classList.remove("current")); + button && button.classList.add("current"); + } + }); + }, + { threshold: 1 } + ); + }, + + @bind + handleOutsideClick(event) { + const emojiPicker = document.querySelector(".emoji-picker"); + if (emojiPicker && !emojiPicker.contains(event.target)) { + this.onClose(); + } } }); diff --git a/app/assets/javascripts/discourse/app/helpers/replace-emoji.js b/app/assets/javascripts/discourse/app/helpers/replace-emoji.js index 80a5dd42100..56f8c6e2f8f 100644 --- a/app/assets/javascripts/discourse/app/helpers/replace-emoji.js +++ b/app/assets/javascripts/discourse/app/helpers/replace-emoji.js @@ -2,6 +2,6 @@ import { registerUnbound } from "discourse-common/lib/helpers"; import { emojiUnescape } from "discourse/lib/text"; import { htmlSafe } from "@ember/template"; -registerUnbound("replace-emoji", text => { - return htmlSafe(emojiUnescape(text)); +registerUnbound("replace-emoji", (text, options) => { + return htmlSafe(emojiUnescape(text, options)); }); diff --git a/app/assets/javascripts/discourse/app/initializers/enable-emoji.js b/app/assets/javascripts/discourse/app/initializers/enable-emoji.js index fd91b69aec8..2ea45ccb6a9 100644 --- a/app/assets/javascripts/discourse/app/initializers/enable-emoji.js +++ b/app/assets/javascripts/discourse/app/initializers/enable-emoji.js @@ -18,7 +18,8 @@ export default { group: "extras", icon: "far-smile", action: () => toolbar.context.send("emoji"), - title: "composer.emoji" + title: "composer.emoji", + className: "emoji insert-emoji" }); }); }); diff --git a/app/assets/javascripts/discourse/app/templates/components/d-editor.hbs b/app/assets/javascripts/discourse/app/templates/components/d-editor.hbs index 7b23ed1d9e0..eb54feb5cd3 100644 --- a/app/assets/javascripts/discourse/app/templates/components/d-editor.hbs +++ b/app/assets/javascripts/discourse/app/templates/components/d-editor.hbs @@ -54,4 +54,9 @@ -{{emoji-picker active=emojiPickerIsActive isEditorFocused=isEditorFocused emojiSelected=(action "emojiSelected")}} +{{emoji-picker + isActive=emojiPickerIsActive + isEditorFocused=isEditorFocused + emojiSelected=(action "emojiSelected") + onEmojiPickerClose=(action (mut emojiPickerIsActive) false) +}} diff --git a/app/assets/javascripts/discourse/app/templates/components/emoji-picker.hbs.erb b/app/assets/javascripts/discourse/app/templates/components/emoji-picker.hbs.erb index 8ddebaf77c9..61d0ca2dd9b 100644 --- a/app/assets/javascripts/discourse/app/templates/components/emoji-picker.hbs.erb +++ b/app/assets/javascripts/discourse/app/templates/components/emoji-picker.hbs.erb @@ -1,88 +1,112 @@ -
- {{#if active}} -
-
- -
+{{#if isActive}} +
+
+ {{#if recentEmojis.length}} + + {{/if}} <% JSON.parse(File.read("lib/emoji/groups.json")).each.with_index do |group, group_index| %> -
- -
+ <% end %> {{#each-in customEmojis as |group emojis|}} -
- -
+ {{/each-in}}
-
-
- {{d-icon 'search'}} - - +
+
+ {{input + class="filter" + name="filter" + placeholder=(i18n "emoji_picker.filter_placeholder") + autocomplete="discourse" + input=(action "onFilter") + }} + + {{d-icon "search"}}
-
+
+
-
-
-
- {{i18n 'emoji_picker.recent'}} - {{d-icon "trash-alt"}} -
-
-
- - <% JSON.parse(File.read("lib/emoji/groups.json")).each.with_index do |group, group_index| %> -
-
- {{i18n 'emoji_picker.<%= group["name"] %>'}} -
-
- <% group["icons"].each do |icon| %> - - <% end %> -
-
- <% end %> - - {{#each-in customEmojis as |group emojis|}} -
-
- - {{i18n (concat 'emoji_picker.' group)}} - -
- {{#if emojis.length}} -
- {{#each emojis as |emoji|}} - - {{/each}} + {{#conditional-loading-spinner condition=isLoading}} +
+ {{#if recentEmojis.length}} +
+
+ {{i18n 'emoji_picker.recent'}} + {{d-button icon="trash-alt" action=(action "onClearRecents") class="trash-recent"}} +
+
+ {{#each recentEmojis as |emoji|}} + {{replace-emoji (concat ":" emoji ":") (hash lazy=true)}} + {{/each}} +
{{/if}} + + <% JSON.parse(File.read("lib/emoji/groups.json")).each.with_index do |group, group_index| %> +
+
+ {{i18n 'emoji_picker.<%= group["name"] %>'}} +
+
+ <% group["icons"].each do |icon| %> + {{replace-emoji ":<%= icon['name'] %>:" (hash lazy=true class="<%= "diversity" if icon["diversity"] %>")}} + <% end %> +
+
+ <% end %> + + {{#each-in customEmojis as |group emojis|}} +
+
+ + {{i18n (concat 'emoji_picker.' group)}} + +
+ {{#if emojis.length}} +
+ {{#each emojis as |emoji|}} + + {{/each}} +
+ {{/if}} +
+ {{/each-in}}
- {{/each-in}} + {{/conditional-loading-spinner}}
- +
-
+ {{#if site.mobileView}} +
+ {{/if}} +{{/if}} diff --git a/app/assets/javascripts/discourse/app/templates/emoji-picker-recent.hbr b/app/assets/javascripts/discourse/app/templates/emoji-picker-recent.hbr deleted file mode 100644 index 0ead5e5aee5..00000000000 --- a/app/assets/javascripts/discourse/app/templates/emoji-picker-recent.hbr +++ /dev/null @@ -1,3 +0,0 @@ -{{#each recentEmojis as |emoji|}} - -{{/each}} diff --git a/app/assets/javascripts/pretty-text/addon/emoji.js b/app/assets/javascripts/pretty-text/addon/emoji.js index 5d9fb762c01..2264d970e44 100644 --- a/app/assets/javascripts/pretty-text/addon/emoji.js +++ b/app/assets/javascripts/pretty-text/addon/emoji.js @@ -117,18 +117,22 @@ export function performEmojiUnescape(string, opts) { } const hasEndingColon = m.lastIndexOf(":") === m.length - 1; const url = buildEmojiUrl(emojiVal, opts); - const classes = isCustomEmoji(emojiVal, opts) + let classes = isCustomEmoji(emojiVal, opts) ? "emoji emoji-custom" : "emoji"; + if (opts.class) { + classes = `${classes} ${opts.class}`; + } + const isReplacable = (isEmoticon || hasEndingColon || isUnicodeEmoticon) && isReplacableInlineEmoji(string, index, inlineEmoji); return url && isReplacable - ? `${emojiVal}` + ? `` : m; }); } diff --git a/app/assets/stylesheets/common/base/emoji.scss b/app/assets/stylesheets/common/base/emoji.scss index a9772c5c00c..e480b5aff07 100644 --- a/app/assets/stylesheets/common/base/emoji.scss +++ b/app/assets/stylesheets/common/base/emoji.scss @@ -28,15 +28,18 @@ sup img.emoji { } .emoji-picker { - background-clip: padding-box; - z-index: z("modal", "content"); - position: fixed; - display: none; - flex-direction: row; - height: 320px; + width: 100%; color: var(--primary); background-color: var(--secondary); border: 1px solid var(--primary-low); + display: flex; + box-sizing: border-box; + background-clip: padding-box; + z-index: z("modal", "content"); + flex-direction: row; + height: 320px; + max-height: 50vh; + max-width: 420px; img.emoji { // custom emojis might import images of various sizes @@ -44,175 +47,176 @@ sup img.emoji { width: 20px !important; height: 20px !important; } + + .emoji-picker-content { + display: flex; + flex-direction: column; + flex: 20; + } + + .emoji-picker-emoji-area { + overflow-y: scroll; + -webkit-overflow-scrolling: touch; + width: 100%; + box-sizing: border-box; + padding: 0.25em; + height: 100%; + background: $secondary; + + .section { + margin-bottom: 1em; + content-visibility: auto; + + .trash-recent { + background: none; + font-size: $font-down-1; + } + } + + .section-header { + font-weight: 900; + padding: 0.25em 0; + display: flex; + justify-content: space-between; + align-items: center; + } + + .section-group, + .results { + img.emoji { + padding: 0.25em; + cursor: pointer; + + &:hover { + background: $tertiary-low; + } + } + } + + .results { + padding: 0.25em 0; + img.emoji { + padding: 0.5em; + } + } + } + + .emoji-picker-category-buttons { + overflow-y: scroll; + width: 60px; + display: flex; + justify-content: center; + flex-wrap: wrap; + border-right: 1px solid $primary-low; + + .category-button { + background: none; + border: none; + padding: 0.5em; + outline: none; + + .emoji { + pointer-events: none; + filter: grayscale(100%); + } + + &:hover .emoji, + &.current .emoji { + filter: grayscale(0); + } + } + } } -.emoji-picker .categories-column { +.emoji-picker-search-container { display: flex; - flex-direction: column; - flex: 1 0 0px; + width: 100%; + position: relative; + padding: 0.25em; + border-bottom: 1px solid $primary-low; + box-sizing: border-box; align-items: center; - justify-content: space-between; - border-right: 1px solid var(--primary-low); - min-width: 36px; - overflow-y: auto; - padding: 0.5em; -} -.emoji-picker .category-icon { - display: block; - margin: 4px auto; - -webkit-filter: grayscale(100%); - filter: grayscale(100%); - - button.emoji { + .filter { + flex: 1 0 auto; margin: 0; + border: 0; padding: 0; + + &:focus { + box-shadow: none; + } + } + + .d-icon { + color: $primary-medium; + margin-right: 0.5em; } } -.emoji-picker .category-icon.current, -.emoji-picker .category-icon:hover { - -webkit-filter: grayscale(0%); - filter: grayscale(0%); -} - -.emoji-picker .main-column { +.emoji-picker-footer { display: flex; - flex-direction: column; - flex: 20; -} - -.emoji-picker .list { - overflow-y: scroll; - -webkit-overflow-scrolling: touch; - padding: 0; - flex: 1 0 0px; - flex-direction: column; -} - -.emoji-picker .section-header { - padding: 8px; - margin-top: 2px; - margin-bottom: 0px; - padding-bottom: 0px; justify-content: space-between; + align-items: center; + border-top: 1px solid $primary-low; +} + +.emoji-picker-emoji-info { display: flex; align-items: center; - font-weight: bold; -} + padding-left: 0.5em; -.emoji-picker .section-header .title { - color: var(--primary); -} - -.emoji-picker .section-header .clear-recent .fa { - margin: 0; - padding: 0; - color: var(--primary-medium); - - &:hover { - color: var(--primary-high); + img.emoji { + height: 32px !important; + width: 32px !important; } } -.emoji-picker .section-group { - flex-wrap: wrap; - display: flex; - align-items: center; - justify-content: flex-start; - padding: 4px; -} - -.emoji-picker .footer { - align-items: center; - display: flex; - justify-content: space-between; - border-top: 1px solid var(--primary-low); -} - -.emoji-picker .info { - @include ellipsis; - padding-left: 8px; - font-weight: 700; - max-width: 125px; -} - -.emoji-picker .diversity-picker { - display: flex; - justify-content: flex-end; - padding: 8px; -} - -.emoji-picker .diversity-picker .diversity-scale { - width: 20px; - height: 20px; - margin-left: 5px; +.emoji-picker-diversity-picker { border: 0; - border-radius: 3px; display: flex; align-items: center; justify-content: center; cursor: pointer; -} -.emoji-picker .diversity-picker .diversity-scale.default { - background: #ffcc4d; -} -.emoji-picker .diversity-picker .diversity-scale.light { - background: #f7dece; -} -.emoji-picker .diversity-picker .diversity-scale.medium-light { - background: #f3d2a2; -} -.emoji-picker .diversity-picker .diversity-scale.medium { - background: #d5ab88; -} -.emoji-picker .diversity-picker .diversity-scale.medium-dark { - background: #af7e57; -} -.emoji-picker .diversity-picker .diversity-scale.dark { - background: #7c533e; + padding: 0.5em; + + .diversity-scale { + display: flex; + align-items: center; + justify-content: center; + min-height: auto; + border-radius: 3px; + margin: 0.15em; + height: 24px; + width: 24px; + + .d-icon { + color: #fff; + filter: drop-shadow(0.5px 1.5px 0 rgba(0, 0, 0, 0.3)); + } + } + + .diversity-scale.default { + background: #ffcc4d; + } + .diversity-scale.light { + background: #f7dece; + } + .diversity-scale.medium-light { + background: #f3d2a2; + } + .diversity-scale.medium { + background: #d5ab88; + } + .diversity-scale.medium-dark { + background: #af7e57; + } + .diversity-scale.dark { + background: #7c533e; + } } -.emoji-picker .diversity-picker .diversity-scale.selected .d-icon { - display: block; -} - -.emoji-picker .diversity-picker .d-icon { - display: none; -} - -.emoji-picker .diversity-picker .d-icon { - color: #fff; - font-size: $font-0; - filter: drop-shadow(0.5px 1.5px 0 rgba(0, 0, 0, 0.3)); -} - -.emoji-picker button.emoji { - background: transparent; - background-position: center; - background-repeat: no-repeat; - border-radius: 0; - background-size: 20px 20px; - -moz-box-sizing: content-box; - box-sizing: content-box; - height: 20px; - border: 0; - vertical-align: top; - width: 20px; - outline: none; - padding: 3px; - margin: 2px; -} - -.emoji-picker .section-group button.emoji:hover, -.emoji-picker .results button.emoji:hover { - display: inline-block; - vertical-align: top; - border-radius: 2px; - background-color: var(--tertiary-low); -} - -.emoji-picker-modal.fadeIn { +.emoji-picker-modal-overlay { z-index: z("modal", "overlay"); position: fixed; left: 0; @@ -220,70 +224,5 @@ sup img.emoji { width: 100%; height: 100%; opacity: 0.8; - background-color: black; -} - -.emoji-picker .filter { - background-color: none; - border-bottom: 1px solid var(--primary-low); - padding: 5px; - display: flex; - position: relative; - align-items: center; - - input[type="text"] { - width: auto !important; - } -} - -.emoji-picker .filter .d-icon-search { - color: var(--primary-medium); - font-size: $font-up-1; - margin-left: 5px; - margin-right: 5px; -} - -.emoji-picker .filter input { - height: 24px; - margin: 0; - flex: 1 0 0px; - border: none; - box-shadow: none; - padding-right: 24px; - outline: none; - color: var(--primary); - background: var(--secondary); - - &:focus { - border: none; - box-shadow: none; - } -} - -.emoji-picker .filter input::-ms-clear { - display: none; -} - -.emoji-picker .results { - display: none; - flex-wrap: wrap; - align-items: center; - justify-content: flex-start; - padding: 4px; - flex: 1 0 0px; -} - -.emoji-picker .filter .clear-filter { - position: absolute; - right: 5px; - top: 12px; - border: 0; - background: none; - color: var(--primary-high); - outline: none; - display: none; - - &:hover { - color: var(--primary); - } + background-color: $primary; } diff --git a/app/assets/stylesheets/mobile/emoji.scss b/app/assets/stylesheets/mobile/emoji.scss index d130186a7e7..b8351f24e25 100644 --- a/app/assets/stylesheets/mobile/emoji.scss +++ b/app/assets/stylesheets/mobile/emoji.scss @@ -1,12 +1,8 @@ .emoji-picker { - max-height: 280px; border: none; -} - -.emoji-picker .category-icon { - margin: 2px; -} - -.emoji-picker .categories-column { - padding: 0; + position: fixed; + width: 100%; + max-width: 100vh; + top: 0; + left: 0; } diff --git a/test/javascripts/acceptance/emoji-picker-test.js b/test/javascripts/acceptance/emoji-picker-test.js index 31af7561e0b..6857dc99af0 100644 --- a/test/javascripts/acceptance/emoji-picker-test.js +++ b/test/javascripts/acceptance/emoji-picker-test.js @@ -1,5 +1,4 @@ import { acceptance } from "helpers/qunit-helpers"; -import { IMAGE_VERSION as v } from "pretty-text/emoji/version"; acceptance("EmojiPicker", { loggedIn: true, @@ -17,45 +16,18 @@ QUnit.test("emoji picker can be opened/closed", async assert => { await click("#topic-footer-buttons .btn.create"); await click("button.emoji.btn"); - assert.notEqual( - find(".emoji-picker") - .html() - .trim(), - "", - "it opens the picker" - ); + assert.ok(exists(".emoji-picker.opened"), "it opens the picker"); await click("button.emoji.btn"); - assert.equal( - find(".emoji-picker") - .html() - .trim(), - "", - "it closes the picker" - ); -}); - -QUnit.test("emojis can be hovered to display info", async assert => { - await visit("/t/internationalization-localization/280"); - await click("#topic-footer-buttons .btn.create"); - - await click("button.emoji.btn"); - $(".emoji-picker button[title='grinning']").trigger("mouseover"); - assert.equal( - find(".emoji-picker .info") - .html() - .trim(), - ` :grinning:`, - "it displays emoji info when hovering emoji" - ); + assert.notOk(exists(".emoji-picker.opened"), "it closes the picker"); }); QUnit.test("emoji picker triggers event when picking emoji", async assert => { await visit("/t/internationalization-localization/280"); await click("#topic-footer-buttons .btn.create"); await click("button.emoji.btn"); + await click(".emoji-picker-emoji-area img.emoji[title='grinning']"); - await click(".emoji-picker button[title='grinning']"); assert.equal( find(".d-editor-input").val(), ":grinning:", @@ -72,24 +44,22 @@ QUnit.test( // Whitespace should be added on text await fillIn(".d-editor-input", "This is a test input"); await click("button.emoji.btn"); - await click(".emoji-picker button[title='grinning']"); + await click(".emoji-picker-emoji-area img.emoji[title='grinning']"); assert.equal( find(".d-editor-input").val(), "This is a test input :grinning:", "it adds the emoji code and a leading whitespace when there is text" ); - await click("button.emoji.btn"); // Whitespace should not be added on whitespace await fillIn(".d-editor-input", "This is a test input "); - await click("button.emoji.btn"); - await click(".emoji-picker button[title='grinning']"); + await click(".emoji-picker-emoji-area img.emoji[title='grinning']"); + assert.equal( find(".d-editor-input").val(), "This is a test input :grinning:", "it adds the emoji code and no leading whitespace when user already entered whitespace" ); - await click("button.emoji.btn"); } ); @@ -97,44 +67,36 @@ QUnit.test("emoji picker has a list of recently used emojis", async assert => { await visit("/t/internationalization-localization/280"); await click("#topic-footer-buttons .btn.create"); await click("button.emoji.btn"); + await click(".emoji-picker-emoji-area img.emoji[title='grinning']"); - await click( - ".emoji-picker .section[data-section='smileys_&_emotion'] button.emoji[title='grinning']" - ); - assert.equal( - find('.emoji-picker .section[data-section="recent"]').css("display"), - "block", - "it shows recent section" + assert.ok( + exists( + ".emoji-picker .section.recent .section-group img.emoji[title='grinning']" + ), + "it shows recent selected emoji" ); - assert.equal( - find( - '.emoji-picker .section[data-section="recent"] .section-group button.emoji' - ).length, - 1, - "it adds the emoji code to the recently used emojis list" + assert.ok( + exists('.emoji-picker .category-button[data-section="recent"]'), + "it shows recent category icon" ); - await click(".emoji-picker .clear-recent"); - assert.equal( - find( - '.emoji-picker .section[data-section="recent"] .section-group button.emoji' - ).length, - 0, + await click(".emoji-picker .trash-recent"); + + assert.notOk( + exists( + ".emoji-picker .section.recent .section-group img.emoji[title='grinning']" + ), "it has cleared recent emojis" ); - assert.equal( - find('.emoji-picker .section[data-section="recent"]').css("display"), - "none", + assert.notOk( + exists('.emoji-picker .section[data-section="recent"]'), "it hides recent section" ); - assert.equal( - find('.emoji-picker .category-icon button.emoji[data-section="recent"]') - .parent() - .css("display"), - "none", + assert.notOk( + exists('.emoji-picker .category-button[data-section="recent"]'), "it hides recent category icon" ); }); @@ -144,22 +106,21 @@ QUnit.test( async assert => { await visit("/t/internationalization-localization/280"); await click("#topic-footer-buttons .btn.create"); - await click("button.emoji.btn"); - await click(".emoji-picker button[title='sunglasses']"); - await click(".emoji-picker button[title='grinning']"); + await click(".emoji-picker-emoji-area img.emoji[title='sunglasses']"); + await click(".emoji-picker-emoji-area img.emoji[title='grinning']"); + assert.equal( - find('.section[data-section="recent"] .section-group button.emoji') - .length, + find('.section[data-section="recent"] .section-group img.emoji').length, 2, "it has multiple recent emojis" ); assert.equal( /grinning/.test( - find('.section[data-section="recent"] .section-group button.emoji') + find(".section.recent .section-group img.emoji") .first() - .css("background-image") + .attr("src") ), true, "it puts the last used emoji in first" @@ -170,14 +131,13 @@ QUnit.test( QUnit.test("emoji picker persists state", async assert => { await visit("/t/internationalization-localization/280"); await click("#topic-footer-buttons .btn.create"); - await click("button.emoji.btn"); - await click(".emoji-picker a.diversity-scale.medium-dark"); + await click(".emoji-picker button.diversity-scale.medium-dark"); + await click("button.emoji.btn"); await click("button.emoji.btn"); - await click("button.emoji.btn"); - assert.equal( - find(".emoji-picker .diversity-scale.medium-dark").hasClass("selected"), + assert.ok( + exists(".emoji-picker button.diversity-scale.medium-dark .d-icon"), true, "it stores diversity scale" ); diff --git a/test/javascripts/components/d-editor-test.js b/test/javascripts/components/d-editor-test.js index 480de56928a..afe281bd597 100644 --- a/test/javascripts/components/d-editor-test.js +++ b/test/javascripts/components/d-editor-test.js @@ -658,7 +658,7 @@ componentTest("emoji", { await click("button.emoji"); await click( - '.emoji-picker .section[data-section="smileys_&_emotion"] button.emoji[title="grinning"]' + '.emoji-picker .section[data-section="smileys_&_emotion"] img.emoji[title="grinning"]' ); assert.equal(this.value, "hello world. :grinning:"); }