diff --git a/app/assets/javascripts/discourse-shims.js b/app/assets/javascripts/discourse-shims.js index e359e051ec..9a86440123 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 9b6896eee7..735d4fe545 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 3acf181622..3811526cbe 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 80a5dd4210..56f8c6e2f8 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 fd91b69aec..2ea45ccb6a 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 7b23ed1d9e..eb54feb5cd 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 8ddebaf77c..61d0ca2dd9 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 0ead5e5aee..0000000000 --- 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 5d9fb762c0..2264d970e4 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 a9772c5c00..e480b5aff0 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 d130186a7e..b8351f24e2 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 31af7561e0..6857dc99af 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 480de56928..afe281bd59 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:"); }