UNPKG

converse.js

Version:
310 lines (278 loc) 11.3 kB
/** * @module emoji-picker * @typedef {module:dom-navigator.DOMNavigatorOptions} DOMNavigatorOptions * @typedef {module:dom-navigator.DOMNavigatorDirection} DOMNavigatorDirection */ import debounce from "lodash-es/debounce"; import { api, converse, u, constants } from "@converse/headless"; import { DOMNavigator } from "shared/dom-navigator"; import { CustomElement } from "shared/components/element.js"; import { FILTER_CONTAINS } from "shared/autocomplete/utils.js"; import { getTonedEmojis } from "./utils.js"; import { tplEmojiPicker } from "./templates/emoji-picker.js"; import "./emoji-picker-content.js"; import "./emoji-dropdown.js"; import "./styles/emoji.scss"; const { KEYCODES } = constants; export default class EmojiPicker extends CustomElement { static get properties() { return { current_category: { type: String, "reflect": true }, current_skintone: { type: String, "reflect": true }, model: { type: Object }, query: { type: String, "reflect": true }, state: { type: Object }, // This is an optimization to lazily render the emoji picker render_emojis: { type: Boolean }, }; } constructor() { super(); this.state = null; this.model = null; this.query = ""; this.render_emojis = null; this._search_results = []; this.debouncedFilter = debounce( /** @param {HTMLInputElement} input */ (input) => this.state.set({ "query": input.value }), 250 ); } initialize() { super.initialize(); this.dropdown = this.closest("converse-emoji-dropdown"); } firstUpdated(changed) { super.firstUpdated(changed); this.listenTo(this.state, "change", (o) => this.onModelChanged(o.changed)); this.initArrowNavigation(); } get search_results() { return this._search_results; } set search_results(value) { this._search_results = value; this.requestUpdate(); } render() { return tplEmojiPicker(this, { current_category: this.current_category, current_skintone: this.current_skintone, onCategoryPicked: (ev) => this.chooseCategory(ev), onSearchInputFocus: () => this.disableArrowNavigation(), onSearchInputKeyDown: (ev) => this.onSearchInputKeyDown(ev), onSkintonePicked: (ev) => this.chooseSkinTone(ev), query: this.query, search_results: this.search_results, render_emojis: this.render_emojis, sn2Emoji: /** @param {string} sn */ (sn) => u.shortnamesToEmojis(this.getTonedShortname(sn)), }); } updated(changed) { changed.has("query") && this.updateSearchResults(changed); changed.has("current_category") && this.setScrollPosition(); } onModelChanged(changed) { if ("current_category" in changed) this.current_category = changed.current_category; if ("current_skintone" in changed) this.current_skintone = changed.current_skintone; if ("query" in changed) this.query = changed.query; } 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 instanceof HTMLElement) { // +4 due to 2px padding on list elements el.scrollTop = heading.offsetTop - heading.offsetHeight * 3 + 4; } } updateSearchResults(changed) { const old_query = changed.get("query"); const contains = FILTER_CONTAINS; if (this.query) { if (this.query === old_query) { return this.search_results; } else if (old_query && this.query.includes(old_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)); } } 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 = []; } } registerEvents() { this.onKeyDown = (ev) => this.#onKeyDown(ev); this.dropdown.addEventListener("hide.bs.dropdown", () => this.onDropdownHide()); this.addEventListener("keydown", this.onKeyDown); } connectedCallback() { super.connectedCallback(); this.registerEvents(); } disconnectedCallback() { this.removeEventListener("keydown", this.onKeyDown); this.disableArrowNavigation(); super.disconnectedCallback(); } /** * @param {KeyboardEvent} ev */ #onKeyDown(ev) { if (!this.navigator || !u.isVisible(this)) return; if (ev.key === KEYCODES.ENTER) { this.onEnterPressed(ev); } else if (ev.key === KEYCODES.DOWN_ARROW && !this.navigator.enabled) { this.enableArrowNavigation(ev); } } onDropdownHide() { this.disableArrowNavigation(); this.dispatchEvent(new CustomEvent("emojipickerblur", { bubbles: true })); } /** * @param {HTMLElement} el */ setCategoryForElement(el) { const old_category = this.current_category; const category = el?.getAttribute("data-category") || old_category; if (old_category !== category) { this.state.save({ "current_category": category }); } } /** * @param {string} value */ selectEmoji(value) { const autocompleting = this.state.get("autocompleting"); const ac_position = this.state.get("ac_position"); this.state.set({ "autocompleting": null, "query": "", "ac_position": null }); this.disableArrowNavigation(); const jid = this.model.get("jid"); const options = { "bubbles": true, "detail": { value, autocompleting, ac_position, jid }, }; this.dispatchEvent(new CustomEvent("emojiSelected", options)); } /** * @param {MouseEvent} ev */ chooseSkinTone(ev) { ev.preventDefault(); ev.stopPropagation(); const target = /** @type {Element} */ (ev.target); const el = target.nodeName === "IMG" ? target.parentElement : target; const skintone = el.getAttribute("data-skintone").trim(); if (this.current_skintone === skintone) { this.state.save({ "current_skintone": "" }); } else { this.state.save({ "current_skintone": skintone }); } } /** * @param {MouseEvent} ev */ chooseCategory(ev) { ev.preventDefault && ev.preventDefault(); ev.stopPropagation && ev.stopPropagation(); const target = /** @type {Element} */ (ev.target ?? ev.relatedTarget); const el = target.matches("li") ? target : u.ancestor(target, "li"); this.setCategoryForElement(el); this.navigator.select(el); !this.navigator.enabled && this.navigator.enable(); } /** * @param {KeyboardEvent} ev */ onSearchInputKeyDown(ev) { const target = /** @type {HTMLInputElement} */ (ev.target); if (ev.key === KEYCODES.TAB) { if (target.value) { ev.preventDefault(); const match = converse.emojis.shortnames.find((sn) => FILTER_CONTAINS(sn, target.value)); match && this.state.set({ "query": match }); } else if (!this.navigator.enabled) { this.enableArrowNavigation(ev); } } else if (ev.key === KEYCODES.DOWN_ARROW && !this.navigator.enabled) { this.enableArrowNavigation(ev); } else if (ev.key !== KEYCODES.ENTER && ev.key !== KEYCODES.DOWN_ARROW) { this.debouncedFilter(target); } } /** * @param {KeyboardEvent} ev */ onEnterPressed(ev) { ev.preventDefault(); ev.stopPropagation(); const target = /** @type {HTMLInputElement} */ (ev.target); if (converse.emojis.shortnames.includes(target.value)) { this.selectEmoji(target.value); } else if (this.search_results.length === 1) { this.selectEmoji(this.search_results[0].sn); } else if (this.navigator.selected && this.navigator.selected.matches(".insert-emoji")) { this.selectEmoji(this.navigator.selected.getAttribute("data-emoji")); } else if (this.navigator.selected && this.navigator.selected.matches(".emoji-category")) { this.chooseCategory(new MouseEvent("click", { relatedTarget: this.navigator.selected })); } } /** * @param {string} shortname */ getTonedShortname(shortname) { if (getTonedEmojis().includes(shortname) && this.current_skintone) { return `${shortname.slice(0, shortname.length - 1)}_${this.current_skintone}:`; } return shortname; } initArrowNavigation() { if (!this.navigator) { const default_selector = "li:not(.hidden):not(.emoji-skintone), .emoji-search"; const options = /** @type DOMNavigatorOptions */ ({ jump_to_picked: ".emoji-category", jump_to_picked_selector: ".emoji-category.picked", jump_to_picked_direction: DOMNavigator.DIRECTION.down, picked_selector: ".picked", scroll_container: this.querySelector(".emoji-picker__lists"), getSelector: /** @param {keyof(DOMNavigatorDirection)} dir */ (dir) => { if (dir === DOMNavigator.DIRECTION.down) { const c = this.navigator.selected && this.navigator.selected.getAttribute("data-category"); return c ? `ul[data-category="${c}"] li:not(.hidden):not(.emoji-skintone), .emoji-search` : default_selector; } else { return default_selector; } }, onSelected: /** @param {HTMLElement} el */ (el) => { if (el.matches(".insert-emoji")) this.setCategoryForElement(el.parentElement); if (el.matches(".insert-emoji, .emoji-category")) { /** @type {HTMLInputElement} */ (el.firstElementChild).focus(); } if (el.matches(".emoji-search")) el.focus(); }, }); this.navigator = new DOMNavigator(this, options); } } disableArrowNavigation() { this.navigator?.disable(); } /** * @param {KeyboardEvent} ev */ enableArrowNavigation(ev) { ev?.preventDefault?.(); ev?.stopPropagation?.(); this.disableArrowNavigation(); this.navigator.enable(); this.navigator.handleKeydown(ev); } } api.elements.define("converse-emoji-picker", EmojiPicker);