REFACTOR: rewrite the emoji-picker (#10464)

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
diff --git a/app/assets/javascripts/discourse-shims.js b/app/assets/javascripts/discourse-shims.js
index e359e05..9a86440 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 9b6896e..735d4fe 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 3acf181..3811526 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 = [];
-  },
-
-  @on("willDestroyElement")
-  _unbindGlobalEvents() {
-    this.appEvents.off("emoji-picker:close", this, "_closeEmojiPicker");
-  },
-
-  _closeEmojiPicker() {
-    this.set("active", false);
-  },
-
-  @on("didInsertElement")
-  _setup() {
-    this.appEvents.on("emoji-picker:close", this, "_closeEmojiPicker");
-  },
-
-  @on("didUpdateAttrs")
-  _setState() {
-    schedule("afterRender", () => {
-      if (!this.element) {
-        return;
-      }
-
-      this.$picker = $(this.element.querySelector(".emoji-picker"));
-      this.$modal = $(this.element.querySelector(".emoji-picker-modal"));
+    this.set("recentEmojis", this.emojiStore.favorites);
+    this.set("selectedDiversity", this.emojiStore.diversity);
 
-      this.active ? this.show() : this.close();
-    });
+    this._sectionObserver = this._setupSectionObserver();
   },
 
-  @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)
-      );
-    }
+  didInsertElement() {
+    this._super(...arguments);
 
-    this._updateSelectedDiversity();
-    this._checkVisibleSection(true);
+    this.appEvents.on("emoji-picker:close", this, "onClose");
   },
 
-  @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();
+  // 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() {
+    if (this.isActive) {
+      this.onShow();
     } else {
-      $recentCategory.show();
-      $recentSection.css("height", "auto").show();
+      this.onClose();
     }
-
-    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");
+  willDestroyElement() {
+    this._super(...arguments);
 
-    $diversityPicker.find(".diversity-scale").removeClass("selected");
-    $diversityPicker
-      .find(`.diversity-scale[data-level="${this.selectedDiversity}"]`)

[... diff too long, it was truncated ...]

GitHub sha: 226be994

1 Like

This commit appears in #10464 which was approved by eviltrout. It was merged by jjaffeux.

This commit has been mentioned on Discourse Meta. There might be relevant details there:

https://meta.discourse.org/t/retort-a-reaction-style-plugin-for-discourse/35903/373