blob: 0bd46ece5b9ce131ce3f96616d64cf8af493bffd [file] [log] [blame]
// Copyright 2020 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import './icons.js';
import './emoji_group.js';
import './emoji_group_button.js';
import './emoji_search.js';
import 'chrome://resources/cr_elements/cr_icons_css.m.js';
import {assert} from 'chrome://resources/js/assert.m.js';
import {afterNextRender, html, PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {EMOJI_GROUP_SIZE_PX, EMOJI_ICON_SIZE, EMOJI_PER_ROW, EMOJI_PICKER_HEIGHT_PX, EMOJI_PICKER_SIDE_PADDING_PX, EMOJI_PICKER_TOP_PADDING_PX, EMOJI_PICKER_WIDTH_PX, EMOJI_SIZE_PX, GROUP_ICON_SIZE, GROUP_PER_ROW} from './constants.js';
import {EmojiButton} from './emoji_button.js';
import {EmojiPickerApiProxy, EmojiPickerApiProxyImpl} from './emoji_picker_api_proxy.js';
import {createCustomEvent, EMOJI_BUTTON_CLICK, EMOJI_CLEAR_RECENTS_CLICK, EMOJI_DATA_LOADED, EMOJI_VARIANTS_SHOWN, EmojiVariantsShownEvent, GROUP_BUTTON_CLICK} from './events.js';
import {RecentEmojiStore} from './store.js';
import {Emoji, EmojiGroup, EmojiGroupData, EmojiVariants, StoredEmoji} from './types.js';
const EMOJI_ORDERING_JSON = '/emoji_13_1_ordering.json';
// the name attributes below are used to label the group buttons.
// the ordering group names are used for the group headings in the emoji picker.
const GROUP_TABS = [
{
name: 'Recently Used',
icon: 'emoji_picker:schedule',
groupId: 'history',
active: true
},
{
name: 'Smileys & Emotion',
icon: 'emoji_picker:insert_emoticon',
groupId: '0',
active: false
},
{
name: 'People',
icon: 'emoji_picker:emoji_people',
groupId: '1',
active: false
},
{
name: 'Animals & Nature',
icon: 'emoji_picker:emoji_nature',
groupId: '2',
active: false
},
{
name: 'Food & Drink',
icon: 'emoji_picker:emoji_food_beverage',
groupId: '3',
active: false
},
{
name: 'Travel & Places',
icon: 'emoji_picker:emoji_transportation',
groupId: '4',
active: false
},
{
name: 'Activities',
icon: 'emoji_picker:emoji_events',
groupId: '5',
active: false
},
{
name: 'Objects',
icon: 'emoji_picker:emoji_objects',
groupId: '6',
active: false
},
{
name: 'Symbols',
icon: 'emoji_picker:emoji_symbols',
groupId: '7',
active: false
},
{name: 'Flags', icon: 'emoji_picker:flag', groupId: '8', active: false},
];
/**
* Constructs the emoji group data structure from a given list of recent emoji
* data from localstorage.
*
* @param {!Array<StoredEmoji>} recentEmoji list of recently used emoji strings.
* @return {!Array<EmojiVariants>} list of emoji data structures
*/
function makeRecentlyUsed(recentEmoji) {
return recentEmoji.map(emoji => ({
base: {string: emoji.base, name: '', keywords: []},
alternates: emoji.alternates
}));
}
export class EmojiPicker extends PolymerElement {
static get is() {
return 'emoji-picker';
}
static get template() {
return html`{__html_template__}`;
}
static get properties() {
return {
/** @type {!string} */
emojiDataUrl: {type: String, value: EMOJI_ORDERING_JSON},
emojiGroupTabs: {type: Array},
/** @private {?EmojiGroupData} */
emojiData: {
type: Object,
observer: 'onEmojiDataChanged',
},
/** @private {Object<string,string>} */
preferenceMapping: {type: Object},
/** @private {!EmojiGroup} */
history: {type: Object},
/** @private {!string} */
search: {type: String, value: '', observer: 'onSearchChanged'},
};
}
constructor() {
super();
/** @type {!RecentEmojiStore} */
this.recentEmojiStore = new RecentEmojiStore();
this.emojiGroupTabs = GROUP_TABS;
this.emojiData = [];
this.history = {'group': 'Recently Used', 'emoji': []};
this.preferenceMapping = {};
/** @private {?number} */
this.scrollTimeout = null;
/** @private {?number} */
this.groupScrollTimeout = null;
/** @private {?number} */
this.groupButtonScrollTimeout = null;
/** @private {?EmojiButton} */
this.activeVariant = null;
/** @private {!EmojiPickerApiProxy} */
this.apiProxy_ = EmojiPickerApiProxyImpl.getInstance();
/** @private {boolean} */
this.autoScrollingToGroup = false;
/** @private {boolean} */
this.highlightBarMoving = false;
/** @private {boolean} */
this.groupTabsMoving = false;
this.addEventListener(
GROUP_BUTTON_CLICK, ev => this.selectGroup(ev.detail.group));
this.addEventListener(
EMOJI_BUTTON_CLICK,
ev => this.insertEmoji(
ev.detail.emoji, ev.detail.isVariant, ev.detail.baseEmoji,
ev.detail.allVariants));
this.addEventListener(
EMOJI_CLEAR_RECENTS_CLICK, ev => this.clearRecentEmoji());
// variant popup related handlers
this.addEventListener(
EMOJI_VARIANTS_SHOWN,
ev => this.onShowEmojiVariants(
/** @type {!EmojiVariantsShownEvent} */ (ev)));
this.addEventListener('click', () => this.hideDialogs());
this.getHistory();
}
async getHistory() {
const incognito = (await this.apiProxy_.isIncognitoTextField()).incognito;
if (incognito) {
this.set(['history', 'emoji'], makeRecentlyUsed([]));
} else {
this.set(
['history', 'emoji'],
makeRecentlyUsed(this.recentEmojiStore.data.history || []));
this.set(
['preferenceMapping'], this.recentEmojiStore.getPreferenceMapping());
}
}
ready() {
super.ready();
const xhr = new XMLHttpRequest();
xhr.onloadend = () => this.onEmojiDataLoaded(xhr.responseText);
xhr.open('GET', this.emojiDataUrl);
xhr.send();
this.updateStyles({
'--emoji-group-button-size': EMOJI_GROUP_SIZE_PX,
'--emoji-picker-width': EMOJI_PICKER_WIDTH_PX,
'--emoji-picker-height': EMOJI_PICKER_HEIGHT_PX,
'--emoji-size': EMOJI_SIZE_PX,
'--emoji-per-row': EMOJI_PER_ROW,
'--emoji-picker-side-padding': EMOJI_PICKER_SIDE_PADDING_PX,
'--emoji-picker-top-padding': EMOJI_PICKER_TOP_PADDING_PX,
});
}
onSearchChanged(newValue) {
this.$['list-container'].style.display = newValue ? 'none' : '';
}
onBarTransitionStart() {
this.highlightBarMoving = true;
}
onBarTransitionEnd() {
this.highlightBarMoving = false;
}
/**
* @param {!string} emoji
* @param {boolean} isVariant
* @param {!string} baseEmoji
* @param {!Array<!string>} allVariants
*/
async insertEmoji(emoji, isVariant, baseEmoji, allVariants) {
this.$.message.textContent = emoji + ' inserted.';
const incognito = (await this.apiProxy_.isIncognitoTextField()).incognito;
if (!incognito) {
this.recentEmojiStore.bumpEmoji({base: emoji, alternates: allVariants});
this.recentEmojiStore.savePreferredVariant(baseEmoji, emoji);
this.set(
['history', 'emoji'],
makeRecentlyUsed(this.recentEmojiStore.data.history));
}
this.apiProxy_.insertEmoji(emoji, isVariant);
}
clearRecentEmoji() {
this.set(['history', 'emoji'], makeRecentlyUsed([]));
this.recentEmojiStore.clearRecents();
afterNextRender(this, () => this.updateActiveGroup());
}
/**
* @param {string} newGroup
*/
selectGroup(newGroup) {
// focus and scroll to selected group's first emoji.
const group =
this.shadowRoot.querySelector(`div[data-group="${newGroup}"]`);
group.querySelector('emoji-group')
.shadowRoot.querySelector('emoji-button')
.focusButton();
group.scrollIntoView();
}
onEmojiScroll(ev) {
// the scroll event is fired very frequently while scrolling.
// only update active tab 100ms after last scroll event by setting
// a timeout.
if (this.scrollTimeout) {
clearTimeout(this.scrollTimeout);
}
this.scrollTimeout = setTimeout(this.updateActiveGroup.bind(this), 100);
}
onRightChevronClick() {
this.$.tabs.scrollLeft = GROUP_ICON_SIZE * 6;
this.scrollToGroup(GROUP_TABS[GROUP_PER_ROW - 3].groupId);
this.groupTabsMoving = true;
this.$.bar.style.left = EMOJI_GROUP_SIZE_PX;
}
onLeftChevronClick() {
this.$.tabs.scrollLeft = 0;
this.scrollToGroup(GROUP_TABS[0].groupId);
this.groupTabsMoving = true;
if (this.history.emoji.length > 0) {
this.$.bar.style.left = '0';
} else {
this.$.bar.style.left = '36px';
}
}
/**
* @param {string} newGroup The group ID to scroll to
*/
scrollToGroup(newGroup) {
// TODO(crbug/1152237): This should use behaviour:'smooth', but when you do
// that it doesn't scroll.
this.shadowRoot.querySelector(`div[data-group="${newGroup}"]`)
.scrollIntoView();
}
/**
* @private
*/
onGroupsScroll() {
this.updateChevrons();
this.groupTabsMoving = true;
if (this.groupButtonScrollTimeout) {
clearTimeout(this.groupButtonScrollTimeout);
}
this.groupButtonScrollTimeout =
setTimeout(this.groupTabScrollFinished.bind(this), 100);
}
/**
* @private
*/
groupTabScrollFinished() {
this.groupTabsMoving = false;
this.updateActiveGroup();
}
/**
* @private
*/
updateChevrons() {
if (this.$.tabs.scrollLeft > GROUP_ICON_SIZE) {
this.$['left-chevron'].style.display = 'flex';
} else {
this.$['left-chevron'].style.display = 'none';
}
// 1 less because we need to allow room for the chevrons
if (this.$.tabs.scrollLeft + GROUP_ICON_SIZE * GROUP_PER_ROW <
GROUP_ICON_SIZE * (GROUP_TABS.length + 1)) {
this.$['right-chevron'].style.display = 'flex';
} else {
this.$['right-chevron'].style.display = 'none';
}
}
updateActiveGroup() {
// no need to update scroll state if search is showing.
if (this.search)
return;
// get bounding rect of scrollable emoji region.
const thisRect = this.$.groups.getBoundingClientRect();
const groupElements =
Array.from(this.$.groups.querySelectorAll('[data-group]'));
// activate the first group which is visible for at least 10 pixels,
// i.e. whose bottom edge is at least 10px below the top edge of the
// scrollable region.
const activeGroup = groupElements.find(
el => el.getBoundingClientRect().bottom - thisRect.top >= 10);
const activeGroupId = activeGroup ? activeGroup.dataset.group : 'history';
let index = 0;
// set active to true for selected group and false for others.
this.emojiGroupTabs.forEach((g, i) => {
const isActive = g.groupId === activeGroupId;
if (isActive) {
index = i;
}
this.set(['emojiGroupTabs', i, 'active'], isActive);
});
// Once tab scroll is updated, update the position of the highlight bar.
if (!this.highlightBarMoving && !this.groupTabsMoving) {
// Update the scroll position of the emoji groups so that active group is
// visible.
let tabscrollLeft = this.$.tabs.scrollLeft;
if (tabscrollLeft > GROUP_ICON_SIZE * (index - 0.5)) {
tabscrollLeft = 0;
}
if (tabscrollLeft + GROUP_ICON_SIZE * (GROUP_PER_ROW - 2) <
GROUP_ICON_SIZE * index) {
// 5 = We want the seventh icon to be first. Then -1 for chevron, -1 for
// 1 based indexing.
tabscrollLeft = GROUP_ICON_SIZE * (5);
}
this.$.tabs.scrollLeft = tabscrollLeft;
this.$.bar.style.left =
((index * GROUP_ICON_SIZE - tabscrollLeft)) + 'px';
}
}
hideDialogs() {
this.hideEmojiVariants();
if (this.history.emoji.length > 0) {
this.shadowRoot.querySelector(`div[data-group="history"]`)
.querySelector('emoji-group')
.showClearRecents = false;
}
}
hideEmojiVariants() {
if (this.activeVariant) {
this.activeVariant.variantsVisible = false;
this.activeVariant = null;
}
}
/**
* @param {!EmojiVariantsShownEvent} ev
*/
onShowEmojiVariants(ev) {
this.hideEmojiVariants();
this.activeVariant = /** @type {EmojiButton} */ (ev.detail.button);
if (this.activeVariant) {
this.$.message.textContent = this.activeVariant + ' variants shown.';
this.positionEmojiVariants(ev.detail.variants);
}
}
positionEmojiVariants(variants) {
// TODO(crbug.com/1174311): currently positions horizontally within page.
// ideal UI would be overflowing the bounds of the page.
// also need to account for vertical positioning.
// compute width required for the variant popup as: SIZE * columns + 10.
// SIZE is emoji width in pixels. number of columns is determined by width
// of variantRows, then one column each for the base emoji and skin tone
// indicators if present. 10 pixels are added for padding and the shadow.
// Reset any existing left margin before calculating a new position.
variants.style.marginLeft = 0;
// get size of emoji picker
const pickerRect = this.getBoundingClientRect();
// determine how much overflows the right edge of the window.
const rect = variants.getBoundingClientRect();
const overflowWidth = rect.x + rect.width - pickerRect.width;
// shift left by overflowWidth rounded up to next multiple of EMOJI_SIZE.
const shift = EMOJI_ICON_SIZE * Math.ceil(overflowWidth / EMOJI_ICON_SIZE);
// negative value means we are already within bounds, so no shift needed.
variants.style.marginLeft = `-${Math.max(shift, 0)}px`;
// Now, examine vertical scrolling and scroll if needed. Not quire sure why
// we need listcontainer.offsetTop, but it makes things work.
const groups = this.$.groups;
const scrollTop = groups.scrollTop;
const variantTop = variants.offsetTop;
const variantBottom = variantTop + variants.offsetHeight;
const listTop = this.$['list-container'].offsetTop;
if (variantBottom > scrollTop + groups.offsetHeight + listTop) {
groups.scrollTo({
top: variantBottom - groups.offsetHeight - listTop,
left: 0,
behavior: 'smooth',
});
}
}
onEmojiDataLoaded(data) {
const emojidata = /** @type {!EmojiGroupData} */ (JSON.parse(data));
// There is quite a lot of emoji data to load which causes slow rendering.
// Just load the first emoji category immediately, and defer loading of the
// other categories (which will be off screen).
this.emojiData = [emojidata[0]];
afterNextRender(this, () => {
this.apiProxy_.showUI();
this.emojiData = emojidata;
this.updateActiveGroup();
});
}
/**
* Fires DATA_LOADED_EVENT when emoji data is loaded and the emoji picker
* is ready to use.
*/
onEmojiDataChanged(newValue, oldValue) {
// This is separate from onEmojiDataLoaded because we need to ensure
// Polymer has created the components for the emoji after setting
// this.emojiData. This is an observer, so will run after the component
// tree has been updated.
// see:
// https://polymer-library.polymer-project.org/3.0/docs/devguide/data-system#property-effects
if (newValue && newValue.length) {
this.dispatchEvent(createCustomEvent(EMOJI_DATA_LOADED));
}
}
}
customElements.define(EmojiPicker.is, EmojiPicker);