Переглянути джерело

emoji-picker: Move picker content into another component

to avoid re-rendering them when non-relevant properties change
JC Brand 5 роки тому
батько
коміт
22b2875b52
3 змінених файлів з 175 додано та 130 видалено
  1. 8 9
      spec/emojis.js
  2. 132 90
      src/components/emoji-picker.js
  3. 35 31
      src/templates/emoji_picker.js

+ 8 - 9
spec/emojis.js

@@ -54,11 +54,11 @@ describe("Emojis", function () {
             }
             view.onKeyDown(tab_event);
             await u.waitUntil(() => u.isVisible(view.el.querySelector('.emoji-picker__lists')));
-            let picker = await u.waitUntil(() => view.el.querySelector('.emoji-picker__container'));
+            const picker = await u.waitUntil(() => view.el.querySelector('.emoji-picker__container'));
             const input = picker.querySelector('.emoji-search');
             expect(input.value).toBe(':gri');
+            await u.waitUntil(() =>  sizzle('.emojis-lists__container--search .insert-emoji', picker).length === 3, 1000);
             let visible_emojis = sizzle('.emojis-lists__container--search .insert-emoji', picker);
-            expect(visible_emojis.length).toBe(3);
             expect(visible_emojis[0].getAttribute('data-emoji')).toBe(':grimacing:');
             expect(visible_emojis[1].getAttribute('data-emoji')).toBe(':grin:');
             expect(visible_emojis[2].getAttribute('data-emoji')).toBe(':grinning:');
@@ -66,8 +66,8 @@ describe("Emojis", function () {
             // Test that TAB autocompletes the to first match
             input.dispatchEvent(new KeyboardEvent('keydown', tab_event));
 
-            await u.waitUntil(() => sizzle('.emojis-lists__container--search .insert-emoji', picker).length === 1);
-            visible_emojis = sizzle('.emojis-lists__container--search .insert-emoji', picker);
+            await u.waitUntil(() => sizzle(".emojis-lists__container--search .insert-emoji:not('.hidden')", picker).length === 1, 1000);
+            visible_emojis = sizzle(".emojis-lists__container--search .insert-emoji:not('.hidden')", picker);
             expect(visible_emojis[0].getAttribute('data-emoji')).toBe(':grimacing:');
             expect(input.value).toBe(':grimacing:');
 
@@ -95,8 +95,7 @@ describe("Emojis", function () {
             textarea.value = ':use';
             view.onKeyDown(tab_event);
             await u.waitUntil(() => u.isVisible(view.el.querySelector('.emoji-picker__lists')));
-            picker = await u.waitUntil(() => view.el.querySelector('.emoji-picker__container'));
-            expect(input.value).toBe(':use');
+            await u.waitUntil(() => input.value === ':use');
             visible_emojis = sizzle('.insert-emoji:not(.hidden)', picker);
             expect(visible_emojis.length).toBe(0);
             done();
@@ -129,8 +128,8 @@ describe("Emojis", function () {
             input.dispatchEvent(new KeyboardEvent('keydown', event));
 
             await u.waitUntil(() => view.emoji_picker_view.model.get('query') === 'smiley', 1000);
+            await u.waitUntil(() => sizzle('.emojis-lists__container--search .insert-emoji', picker).length === 2, 1000);
             let visible_emojis = sizzle('.emojis-lists__container--search .insert-emoji', picker);
-            expect(visible_emojis.length).toBe(2);
             expect(visible_emojis[0].getAttribute('data-emoji')).toBe(':smiley:');
             expect(visible_emojis[1].getAttribute('data-emoji')).toBe(':smiley_cat:');
 
@@ -144,8 +143,8 @@ describe("Emojis", function () {
             input.dispatchEvent(new KeyboardEvent('keydown', tab_event));
 
             await u.waitUntil(() => input.value === ':smiley:');
-            visible_emojis = sizzle('.emojis-lists__container--search .insert-emoji', picker);
-            expect(visible_emojis.length).toBe(1);
+            await u.waitUntil(() => sizzle(".emojis-lists__container--search .insert-emoji:not('.hidden')", picker).length === 1);
+            visible_emojis = sizzle(".emojis-lists__container--search .insert-emoji:not('.hidden')", picker);
             expect(visible_emojis[0].getAttribute('data-emoji')).toBe(':smiley:');
 
             // Check that ENTER now inserts the match

+ 132 - 90
src/components/emoji-picker.js

@@ -1,13 +1,114 @@
 import DOMNavigator from "../dom-navigator";
 import sizzle from 'sizzle';
-import tpl_emoji_picker from "../templates/emoji_picker.js";
 import { CustomElement } from './element.js';
 import { _converse, converse } from "@converse/headless/converse-core";
 import { debounce, find } from "lodash-es";
+import { html } from "lit-element";
+import { tpl_all_emojis, tpl_emoji_picker, tpl_search_results } from "../templates/emoji_picker.js";
 
 const u = converse.env.utils;
 
 
+export class EmojiPickerContent extends CustomElement {
+    static get properties () {
+        return {
+            'chatview': { type: Object },
+            'search_results': { type: Array },
+            'current_skintone': { type: String },
+            'model': { type: Object },
+            'query': { type: String },
+        }
+    }
+
+    render () {
+        const props = {
+            'current_skintone': this.current_skintone,
+            'insertEmoji': ev => this.insertEmoji(ev),
+            'query': this.query,
+            'search_results': this.search_results,
+            'shouldBeHidden': shortname => this.shouldBeHidden(shortname),
+        }
+        return html`
+            <div class="emoji-picker__lists">
+                ${tpl_search_results(props)}
+                ${tpl_all_emojis(props)}
+            </div>
+        `;
+    }
+
+    firstUpdated () {
+        this.initIntersectionObserver();
+    }
+
+    initIntersectionObserver () {
+        if (!window.IntersectionObserver) {
+            return;
+        }
+        if (this.observer) {
+            this.observer.disconnect();
+        } else {
+            const options = {
+                root: this.querySelector('.emoji-picker__lists'),
+                threshold: [0.1]
+            }
+            const handler = ev => this.setCategoryOnVisibilityChange(ev);
+            this.observer = new IntersectionObserver(handler, options);
+        }
+        sizzle('.emoji-picker', this).forEach(a => this.observer.observe(a));
+    }
+
+    setCategoryOnVisibilityChange (ev) {
+        const selected = this.parentElement.navigator.selected;
+        const intersection_with_selected = ev.filter(i => i.target.contains(selected)).pop();
+        let current;
+        // Choose the intersection that contains the currently selected
+        // element, or otherwise the one with the largest ratio.
+        if (intersection_with_selected) {
+            current = intersection_with_selected;
+        } else {
+            current = ev.reduce((p, c) => c.intersectionRatio >= (p?.intersectionRatio || 0) ? c : p, null);
+        }
+        if (current && current.isIntersecting) {
+            const category = current.target.getAttribute('data-category');
+            if (category !== this.model.get('current_category')) {
+                this.parentElement.preserve_scroll = true;
+                this.model.save({'current_category': category});
+            }
+        }
+    }
+
+    insertEmoji (ev) {
+        ev.preventDefault();
+        ev.stopPropagation();
+        const target = ev.target.nodeName === 'IMG' ? ev.target.parentElement : ev.target;
+        const replace = this.model.get('autocompleting');
+        const position = this.model.get('position');
+        this.model.set({'autocompleting': null, 'position': null, 'query': ''});
+        this.chatview.insertIntoTextArea(target.getAttribute('data-emoji'), replace, false, position);
+        this.chatview.emoji_dropdown.toggle();
+    }
+
+    shouldBeHidden (shortname) {
+        // Helper method for the template which decides whether an
+        // emoji should be hidden, based on which skin tone is
+        // currently being applied.
+        if (shortname.includes('_tone')) {
+            if (!this.current_skintone || !shortname.includes(this.current_skintone)) {
+                return true;
+            }
+        } else {
+            if (this.current_skintone && _converse.emojis.toned.includes(shortname)) {
+                return true;
+            }
+        }
+        if (this.query && !_converse.FILTER_CONTAINS(shortname, this.query)) {
+            return true;
+        }
+        return false;
+    }
+}
+
+
 export class EmojiPicker extends CustomElement {
 
     static get properties () {
@@ -22,9 +123,8 @@ export class EmojiPicker extends CustomElement {
 
     constructor () {
         super();
-        this.debouncedFilter = debounce(input => this.model.set({'query': input.value}), 500);
-        this.preserve_scroll = false;
-        this._search_results = [];
+        this.search_results = [];
+        this.debouncedFilter = debounce(input => this.model.set({'query': input.value}), 250);
         this.onGlobalKeyDown = ev => this._onGlobalKeyDown(ev);
         const body = document.querySelector('body');
         body.addEventListener('keydown', this.onGlobalKeyDown);
@@ -32,47 +132,59 @@ export class EmojiPicker extends CustomElement {
 
     render () {
         return tpl_emoji_picker({
+            'chatview': this.chatview,
             'current_category': this.current_category,
             'current_skintone': this.current_skintone,
+            'model': this.model,
             'onCategoryPicked': ev => this.chooseCategory(ev),
-            'onEmojiPicked': ev => this.insertEmoji(ev),
             'onSearchInputBlurred': ev => this.chatview.emitFocused(ev),
             'onSearchInputFocus': ev => this.onSearchInputFocus(ev),
             'onSearchInputKeyDown': ev => this.onKeyDown(ev),
             'onSkintonePicked': ev => this.chooseSkinTone(ev),
             'query': this.query,
             'search_results': this.search_results,
-            'shouldBeHidden': shortname => this.shouldBeHidden(shortname),
-            'transformCategory': shortname => u.shortnamesToEmojis(this.getTonedShortname(shortname))
+            'sn2Emoji': shortname => u.shortnamesToEmojis(this.getTonedShortname(shortname))
         });
     }
 
     firstUpdated () {
         this.initArrowNavigation();
-        this.initIntersectionObserver();
     }
 
     updated (changed) {
-        if (changed.has('current_category') && !this.preserve_scroll) {
-            this.setScrollPosition();
+        changed.has('query') && this.updateSearchResults();
+        changed.has('current_category') && this.setScrollPosition();
+    }
+
+    setScrollPosition () {
+        if (this.preserve_scroll) {
+            this.preserve_scroll = false;
+            return;
+        }
+        const el = this.querySelector('.emoji-lists__container--browse');
+        const heading = this.querySelector(`#emoji-picker-${this.current_category}`);
+        if (heading) {
+            // +4 due to 2px padding on list elements
+            el.scrollTop = heading.offsetTop - heading.offsetHeight*3 + 4;
         }
     }
 
-    get search_results () {
+    updateSearchResults () {
         const contains = _converse.FILTER_CONTAINS;
         if (this.query) {
             if (this.query === this.old_query) {
-                return this._search_results;
+                return this.search_results;
             } else if (this.old_query && this.query.includes(this.old_query)) {
-                this._search_results = this._search_results.filter(e => contains(e.sn, this.query));
+                this.search_results = this.search_results.filter(e => contains(e.sn, this.query));
             } else {
-                this._search_results = _converse.emojis_list.filter(e => contains(e.sn, this.query));
+                this.search_results = _converse.emojis_list.filter(e => contains(e.sn, this.query));
             }
             this.old_query = this.query;
-        } else {
-            this._search_results = [];
+        } else if (this.search_results.length) {
+            // Avoid re-rendering by only setting to new empty array if it wasn't empty before
+            this.search_results = [];
         }
-        return this._search_results;
+        return this.search_results;
     }
 
     disconnectedCallback() {
@@ -96,29 +208,14 @@ export class EmojiPicker extends CustomElement {
         }
     }
 
-    setCategoryForElement (el, preserve_scroll=false) {
+    setCategoryForElement (el) {
         const old_category = this.current_category;
         const category = el.getAttribute('data-category') || old_category;
         if (old_category !== category) {
-            this.preserve_scroll = preserve_scroll;
             this.model.save({'current_category': category});
         }
     }
 
-    setCategoryOnVisibilityChange (ev) {
-        const selected = this.navigator.selected;
-        const intersection_with_selected = ev.filter(i => i.target.contains(selected)).pop();
-        let current;
-        // Choose the intersection that contains the currently selected
-        // element, or otherwise the one with the largest ratio.
-        if (intersection_with_selected) {
-            current = intersection_with_selected;
-        } else {
-            current = ev.reduce((p, c) => c.intersectionRatio >= (p?.intersectionRatio || 0) ? c : p, null);
-        }
-        current && current.isIntersecting && this.setCategoryForElement(current.target, true);
-    }
-
     insertIntoTextArea (value) {
         const replace = this.model.get('autocompleting');
         const position = this.model.get('position');
@@ -152,18 +249,6 @@ export class EmojiPicker extends CustomElement {
         !this.navigator.enabled && this.navigator.enable();
     }
 
-    insertEmoji (ev) {
-        ev.preventDefault();
-        ev.stopPropagation();
-        const target = ev.target.nodeName === 'IMG' ? ev.target.parentElement : ev.target;
-        const replace = this.model.get('autocompleting');
-        const position = this.model.get('position');
-        this.model.set({'autocompleting': null, 'position': null});
-        this.chatview.insertIntoTextArea(target.getAttribute('data-emoji'), replace, false, position);
-        this.chatview.emoji_dropdown.toggle();
-        this.model.set({'query': ''});
-    }
-
     onKeyDown (ev) {
         if (ev.keyCode === converse.keycodes.TAB) {
             if (ev.target.value) {
@@ -208,25 +293,6 @@ export class EmojiPicker extends CustomElement {
         this.disableArrowNavigation();
     }
 
-    shouldBeHidden (shortname) {
-        // Helper method for the template which decides whether an
-        // emoji should be hidden, based on which skin tone is
-        // currently being applied.
-        if (shortname.includes('_tone')) {
-            if (!this.current_skintone || !shortname.includes(this.current_skintone)) {
-                return true;
-            }
-        } else {
-            if (this.current_skintone && _converse.emojis.toned.includes(shortname)) {
-                return true;
-            }
-        }
-        if (this.query && !_converse.FILTER_CONTAINS(shortname, this.query)) {
-            return true;
-        }
-        return false;
-    }
-
     getTonedShortname (shortname) {
         if (_converse.emojis.toned.includes(shortname) && this.current_skintone) {
             return `${shortname.slice(0, shortname.length-1)}_${this.current_skintone}:`
@@ -234,23 +300,6 @@ export class EmojiPicker extends CustomElement {
         return shortname;
     }
 
-    initIntersectionObserver () {
-        if (!window.IntersectionObserver) {
-            return;
-        }
-        if (this.observer) {
-            this.observer.disconnect();
-        } else {
-            const options = {
-                root: this.querySelector('.emoji-picker__lists'),
-                threshold: [0.1]
-            }
-            const handler = ev => this.setCategoryOnVisibilityChange(ev);
-            this.observer = new IntersectionObserver(handler, options);
-        }
-        sizzle('.emoji-picker', this).forEach(a => this.observer.observe(a));
-    }
-
     initArrowNavigation () {
         if (!this.navigator) {
             const default_selector = 'li:not(.hidden):not(.emoji-skintone), .emoji-search';
@@ -291,15 +340,8 @@ export class EmojiPicker extends CustomElement {
         this.navigator.enable();
         this.navigator.handleKeydown(ev);
     }
-
-    setScrollPosition () {
-        const el = this.querySelector('.emoji-lists__container--browse');
-        const heading = this.querySelector(`#emoji-picker-${this.current_category}`);
-        if (heading) {
-            // +4 due to 2px padding on list elements
-            el.scrollTop = heading.offsetTop - heading.offsetHeight*3 + 4;
-        }
-    }
 }
 
+
 window.customElements.define('converse-emoji-picker', EmojiPicker);
+window.customElements.define('converse-emoji-picker-content', EmojiPickerContent);

+ 35 - 31
src/templates/emoji_picker.js

@@ -18,26 +18,26 @@ const emoji_category = (o) => {
             <a class="pick-category"
                @click=${o.onCategoryPicked}
                href="#emoji-picker-${o.category}"
-               data-category="${o.category}">${o.transformCategory(o.emoji_categories[o.category])} </a>
+               data-category="${o.category}">${o.emoji} </a>
         </li>
     `;
 }
 
-const emoji_picker_header = (o) => html`
-    <ul>
-        ${ Object.keys(o.emoji_categories).map(category => (o.emoji_categories[category] ? emoji_category(Object.assign({category}, o)) : '')) }
-    </ul>
-`;
+const emoji_picker_header = (o) => {
+    const cats = api.settings.get('emoji_categories');
+    const transform = c => cats[c] ? emoji_category(Object.assign({'category': c, 'emoji': o.sn2Emoji(cats[c])}, o)) : '';
+    return html`<ul>${ Object.keys(cats).map(transform) }</ul>`;
+}
 
 const emoji_item = (o) => {
     return html`
         <li class="emoji insert-emoji ${o.shouldBeHidden(o.emoji.sn) ? 'hidden' : ''}" data-emoji="${o.emoji.sn}" title="${o.emoji.sn}">
-            <a href="#" @click=${o.onEmojiPicked} data-emoji="${o.emoji.sn}">${u.shortnamesToEmojis(o.emoji.sn)}</a>
+            <a href="#" @click=${o.insertEmoji} data-emoji="${o.emoji.sn}">${u.shortnamesToEmojis(o.emoji.sn)}</a>
         </li>
     `;
 }
 
-const search_results = (o) => html`
+export const tpl_search_results = (o) => html`
     <span ?hidden=${!o.query} class="emoji-lists__container emojis-lists__container--search">
     <a id="emoji-picker-search-results" class="emoji-category__heading">${i18n_search_results}</a>
     <ul class="emoji-picker">
@@ -46,33 +46,33 @@ const search_results = (o) => html`
     </span>
 `;
 
-const emojis_for_category = (o) => html`
-    <a id="emoji-picker-${o.category}" class="emoji-category__heading" data-category="${o.category}">${ __(api.settings.get('emoji_category_labels')[o.category]) }</a>
-    <ul class="emoji-picker" data-category="${o.category}">
-        ${ Object.values(o.emojis_by_category[o.category]).map(emoji => emoji_item(Object.assign({emoji}, o))) }
-    </ul>
-`;
+const emojis_for_category = (o) => {
+    const emojis_by_category = _converse.emojis.json;
+    return html`
+        <a id="emoji-picker-${o.category}" class="emoji-category__heading" data-category="${o.category}">${ __(api.settings.get('emoji_category_labels')[o.category]) }</a>
+        <ul class="emoji-picker" data-category="${o.category}">
+            ${ Object.values(emojis_by_category[o.category]).map(emoji => emoji_item(Object.assign({emoji}, o))) }
+        </ul>`;
+}
+
+export const tpl_all_emojis = (o) => {
+    const cats = api.settings.get('emoji_categories');
+    return html`
+        <span ?hidden=${o.query} class="emoji-lists__container emoji-lists__container--browse">
+            ${Object.keys(cats).map(c => (cats[c] ? emojis_for_category(Object.assign({'category': c}, o)) : ''))}
+        </span>`;
+}
+
 
 const skintone_emoji = (o) => {
     return html`
         <li data-skintone="${o.skintone}" class="emoji-skintone ${(o.current_skintone === o.skintone) ? 'picked' : ''}">
             <a class="pick-skintone" href="#" data-skintone="${o.skintone}" @click=${o.onSkintonePicked}>${u.shortnamesToEmojis(':'+o.skintone+':')}</a>
-        </li>
-    `;
+        </li>`;
 }
 
-const all_emojis = (o) => html`
-    <span ?hidden=${o.query} class="emoji-lists__container emoji-lists__container--browse">
-        ${Object.keys(o.emoji_categories).map(category => (o.emoji_categories[category] ? emojis_for_category(Object.assign({category}, o)) : ''))}
-    </span>
-`;
-
-
-export default (o) => {
-    o.emoji_categories = api.settings.get('emoji_categories');
-    o.emojis_by_category = _converse.emojis.json;
-    o.toned_emojis = _converse.emojis.toned;
 
+export const tpl_emoji_picker = (o) => {
     return html`
         <div class="emoji-picker__header">
             <input class="form-control emoji-search" name="emoji-search" placeholder="${i18n_search}"
@@ -82,10 +82,14 @@ export default (o) => {
                 @focus=${o.onSearchInputFocus}>
             ${ o.query ? '' : emoji_picker_header(o) }
         </div>
-        <div class="emoji-picker__lists">
-            ${search_results(o)}
-            ${all_emojis(o)}
-        </div>
+        <converse-emoji-picker-content
+            .chatview=${o.chatview}
+            .model=${o.model}
+            .search_results="${o.search_results}"
+            current_skintone="${o.current_skintone}"
+            query="${o.query}"
+        ></converse-emoji-picker-content>
+
         <div class="emoji-skintone-picker">
             <label>Skin tone</label>
             <ul>${ skintones.map(skintone => skintone_emoji(Object.assign({skintone}, o))) }</ul>