2027 lines
66 KiB
JavaScript
2027 lines
66 KiB
JavaScript
import Database from "./shx-npm-emoji-picker-element-database.js";
|
||
|
||
// via https://unpkg.com/browse/emojibase-data@6.0.0/meta/groups.json
|
||
const allGroups = [
|
||
[-1, "✨", "custom"],
|
||
[0, "😀", "smileys-emotion"],
|
||
[1, "👋", "people-body"],
|
||
[3, "🐱", "animals-nature"],
|
||
[4, "🍎", "food-drink"],
|
||
[5, "🏠️", "travel-places"],
|
||
[6, "⚽", "activities"],
|
||
[7, "📝", "objects"],
|
||
[8, "⛔️", "symbols"],
|
||
[9, "🏁", "flags"],
|
||
].map(([id, emoji, name]) => ({ id, emoji, name }));
|
||
|
||
const groups = allGroups.slice(1);
|
||
|
||
const MIN_SEARCH_TEXT_LENGTH = 2;
|
||
const NUM_SKIN_TONES = 6;
|
||
|
||
/* istanbul ignore next */
|
||
const rIC =
|
||
typeof requestIdleCallback === "function" ? requestIdleCallback : setTimeout;
|
||
|
||
// check for ZWJ (zero width joiner) character
|
||
function hasZwj(emoji) {
|
||
return emoji.unicode.includes("\u200d");
|
||
}
|
||
|
||
// Find one good representative emoji from each version to test by checking its color.
|
||
// Ideally it should have color in the center. For some inspiration, see:
|
||
// https://about.gitlab.com/blog/2018/05/30/journey-in-native-unicode-emoji/
|
||
//
|
||
// Note that for certain versions (12.1, 13.1), there is no point in testing them explicitly, because
|
||
// all the emoji from this version are compound-emoji from previous versions. So they would pass a color
|
||
// test, even in browsers that display them as double emoji. (E.g. "face in clouds" might render as
|
||
// "face without mouth" plus "fog".) These emoji can only be filtered using the width test,
|
||
// which happens in checkZwjSupport.js.
|
||
const versionsAndTestEmoji = {
|
||
"🫨": 15.1, // shaking head, technically from v15 but see note above
|
||
"🫠": 14,
|
||
"🥲": 13.1, // smiling face with tear, technically from v13 but see note above
|
||
"🥻": 12.1, // sari, technically from v12 but see note above
|
||
"🥰": 11,
|
||
"🤩": 5,
|
||
"👱♀️": 4,
|
||
"🤣": 3,
|
||
"👁️🗨️": 2,
|
||
"😀": 1,
|
||
"😐️": 0.7,
|
||
"😃": 0.6,
|
||
};
|
||
|
||
const TIMEOUT_BEFORE_LOADING_MESSAGE = 1000; // 1 second
|
||
const DEFAULT_SKIN_TONE_EMOJI = "🖐️";
|
||
const DEFAULT_NUM_COLUMNS = 8;
|
||
|
||
// Based on https://fivethirtyeight.com/features/the-100-most-used-emojis/ and
|
||
// https://blog.emojipedia.org/facebook-reveals-most-and-least-used-emojis/ with
|
||
// a bit of my own curation. (E.g. avoid the "OK" gesture because of connotations:
|
||
// https://emojipedia.org/ok-hand/)
|
||
const MOST_COMMONLY_USED_EMOJI = [
|
||
"😊",
|
||
"😒",
|
||
"❤️",
|
||
"👍️",
|
||
"😍",
|
||
"😂",
|
||
"😭",
|
||
"☺️",
|
||
"😔",
|
||
"😩",
|
||
"😏",
|
||
"💕",
|
||
"🙌",
|
||
"😘",
|
||
];
|
||
|
||
// It's important to list Twemoji Mozilla before everything else, because Mozilla bundles their
|
||
// own font on some platforms (notably Windows and Linux as of this writing). Typically, Mozilla
|
||
// updates faster than the underlying OS, and we don't want to render older emoji in one font and
|
||
// newer emoji in another font:
|
||
// https://github.com/nolanlawson/emoji-picker-element/pull/268#issuecomment-1073347283
|
||
const FONT_FAMILY =
|
||
'"Twemoji Mozilla","Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol",' +
|
||
'"Noto Color Emoji","EmojiOne Color","Android Emoji",sans-serif';
|
||
|
||
/* istanbul ignore next */
|
||
const DEFAULT_CATEGORY_SORTING = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
||
|
||
// Test if an emoji is supported by rendering it to canvas and checking that the color is not black
|
||
// See https://about.gitlab.com/blog/2018/05/30/journey-in-native-unicode-emoji/
|
||
// and https://www.npmjs.com/package/if-emoji for inspiration
|
||
// This implementation is largely borrowed from if-emoji, adding the font-family
|
||
|
||
const getTextFeature = (text, color) => {
|
||
const canvas = document.createElement("canvas");
|
||
canvas.width = canvas.height = 1;
|
||
|
||
const ctx = canvas.getContext("2d");
|
||
ctx.textBaseline = "top";
|
||
ctx.font = `100px ${FONT_FAMILY}`;
|
||
ctx.fillStyle = color;
|
||
ctx.scale(0.01, 0.01);
|
||
ctx.fillText(text, 0, 0);
|
||
|
||
return ctx.getImageData(0, 0, 1, 1).data;
|
||
};
|
||
|
||
const compareFeatures = (feature1, feature2) => {
|
||
const feature1Str = [...feature1].join(",");
|
||
const feature2Str = [...feature2].join(",");
|
||
// This is RGBA, so for 0,0,0, we are checking that the first RGB is not all zeroes.
|
||
// Most of the time when unsupported this is 0,0,0,0, but on Chrome on Mac it is
|
||
// 0,0,0,61 - there is a transparency here.
|
||
return feature1Str === feature2Str && !feature1Str.startsWith("0,0,0,");
|
||
};
|
||
|
||
function testColorEmojiSupported(text) {
|
||
// Render white and black and then compare them to each other and ensure they're the same
|
||
// color, and neither one is black. This shows that the emoji was rendered in color.
|
||
const feature1 = getTextFeature(text, "#000");
|
||
const feature2 = getTextFeature(text, "#fff");
|
||
return feature1 && feature2 && compareFeatures(feature1, feature2);
|
||
}
|
||
|
||
// rather than check every emoji ever, which would be expensive, just check some representatives from the
|
||
// different emoji releases to determine what the font supports
|
||
|
||
function determineEmojiSupportLevel() {
|
||
const entries = Object.entries(versionsAndTestEmoji);
|
||
try {
|
||
// start with latest emoji and work backwards
|
||
for (const [emoji, version] of entries) {
|
||
if (testColorEmojiSupported(emoji)) {
|
||
return version;
|
||
}
|
||
}
|
||
} catch (e) {
|
||
// canvas error
|
||
} finally {
|
||
}
|
||
// In case of an error, be generous and just assume all emoji are supported (e.g. for canvas errors
|
||
// due to anti-fingerprinting add-ons). Better to show some gray boxes than nothing at all.
|
||
return entries[0][1]; // first one in the list is the most recent version
|
||
}
|
||
|
||
// Check which emojis we know for sure aren't supported, based on Unicode version level
|
||
let promise;
|
||
const detectEmojiSupportLevel = () => {
|
||
if (!promise) {
|
||
// Delay so it can run while the IDB database is being created by the browser (on another thread).
|
||
// This helps especially with first load – we want to start pre-populating the database on the main thread,
|
||
// and then wait for IDB to commit everything, and while waiting we run this check.
|
||
promise = new Promise((resolve) =>
|
||
rIC(
|
||
() => resolve(determineEmojiSupportLevel()) // delay so ideally this can run while IDB is first populating
|
||
)
|
||
);
|
||
}
|
||
return promise;
|
||
};
|
||
// determine which emojis containing ZWJ (zero width joiner) characters
|
||
// are supported (rendered as one glyph) rather than unsupported (rendered as two or more glyphs)
|
||
const supportedZwjEmojis = new Map();
|
||
|
||
const VARIATION_SELECTOR = "\ufe0f";
|
||
const SKINTONE_MODIFIER = "\ud83c";
|
||
const ZWJ = "\u200d";
|
||
const LIGHT_SKIN_TONE = 0x1f3fb;
|
||
const LIGHT_SKIN_TONE_MODIFIER = 0xdffb;
|
||
|
||
// TODO: this is a naive implementation, we can improve it later
|
||
// It's only used for the skintone picker, so as long as people don't customize with
|
||
// really exotic emoji then it should work fine
|
||
function applySkinTone(str, skinTone) {
|
||
if (skinTone === 0) {
|
||
return str;
|
||
}
|
||
const zwjIndex = str.indexOf(ZWJ);
|
||
if (zwjIndex !== -1) {
|
||
return (
|
||
str.substring(0, zwjIndex) +
|
||
String.fromCodePoint(LIGHT_SKIN_TONE + skinTone - 1) +
|
||
str.substring(zwjIndex)
|
||
);
|
||
}
|
||
if (str.endsWith(VARIATION_SELECTOR)) {
|
||
str = str.substring(0, str.length - 1);
|
||
}
|
||
return (
|
||
str +
|
||
SKINTONE_MODIFIER +
|
||
String.fromCodePoint(LIGHT_SKIN_TONE_MODIFIER + skinTone - 1)
|
||
);
|
||
}
|
||
|
||
function halt(event) {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
}
|
||
|
||
// Implementation left/right or up/down navigation, circling back when you
|
||
// reach the start/end of the list
|
||
function incrementOrDecrement(decrement, val, arr) {
|
||
val += decrement ? -1 : 1;
|
||
if (val < 0) {
|
||
val = arr.length - 1;
|
||
} else if (val >= arr.length) {
|
||
val = 0;
|
||
}
|
||
return val;
|
||
}
|
||
|
||
// like lodash's uniqBy but much smaller
|
||
function uniqBy(arr, func) {
|
||
const set = new Set();
|
||
const res = [];
|
||
for (const item of arr) {
|
||
const key = func(item);
|
||
if (!set.has(key)) {
|
||
set.add(key);
|
||
res.push(item);
|
||
}
|
||
}
|
||
return res;
|
||
}
|
||
|
||
// We don't need all the data on every emoji, and there are specific things we need
|
||
// for the UI, so build a "view model" from the emoji object we got from the database
|
||
|
||
function summarizeEmojisForUI(emojis, emojiSupportLevel) {
|
||
const toSimpleSkinsMap = (skins) => {
|
||
const res = {};
|
||
for (const skin of skins) {
|
||
// ignore arrays like [1, 2] with multiple skin tones
|
||
// also ignore variants that are in an unsupported emoji version
|
||
// (these do exist - variants from a different version than their base emoji)
|
||
if (typeof skin.tone === "number" && skin.version <= emojiSupportLevel) {
|
||
res[skin.tone] = skin.unicode;
|
||
}
|
||
}
|
||
return res;
|
||
};
|
||
|
||
return emojis.map(
|
||
({ unicode, skins, shortcodes, url, name, category, annotation }) => ({
|
||
unicode,
|
||
name,
|
||
shortcodes,
|
||
url,
|
||
category,
|
||
annotation,
|
||
id: unicode || name,
|
||
skins: skins && toSimpleSkinsMap(skins),
|
||
})
|
||
);
|
||
}
|
||
|
||
// import rAF from one place so that the bundle size is a bit smaller
|
||
const rAF = requestAnimationFrame;
|
||
|
||
// Svelte action to calculate the width of an element and auto-update
|
||
// using ResizeObserver. If ResizeObserver is unsupported, we just use rAF once
|
||
// and don't bother to update.
|
||
|
||
let resizeObserverSupported = typeof ResizeObserver === "function";
|
||
|
||
function calculateWidth(node, abortSignal, onUpdate) {
|
||
let resizeObserver;
|
||
if (resizeObserverSupported) {
|
||
resizeObserver = new ResizeObserver((entries) =>
|
||
onUpdate(entries[0].contentRect.width)
|
||
);
|
||
resizeObserver.observe(node);
|
||
} else {
|
||
// just set the width once, don't bother trying to track it
|
||
rAF(() => onUpdate(node.getBoundingClientRect().width));
|
||
}
|
||
|
||
// cleanup function (called on destroy)
|
||
abortSignal.addEventListener("abort", () => {
|
||
if (resizeObserver) {
|
||
resizeObserver.disconnect();
|
||
}
|
||
});
|
||
}
|
||
|
||
// get the width of the text inside of a DOM node, via https://stackoverflow.com/a/59525891/680742
|
||
function calculateTextWidth(node) {
|
||
/* istanbul ignore else */
|
||
{
|
||
const range = document.createRange();
|
||
range.selectNode(node.firstChild);
|
||
return range.getBoundingClientRect().width;
|
||
}
|
||
}
|
||
|
||
let baselineEmojiWidth;
|
||
|
||
function checkZwjSupport(zwjEmojisToCheck, baselineEmoji, emojiToDomNode) {
|
||
for (const emoji of zwjEmojisToCheck) {
|
||
const domNode = emojiToDomNode(emoji);
|
||
const emojiWidth = calculateTextWidth(domNode);
|
||
if (typeof baselineEmojiWidth === "undefined") {
|
||
// calculate the baseline emoji width only once
|
||
baselineEmojiWidth = calculateTextWidth(baselineEmoji);
|
||
}
|
||
// On Windows, some supported emoji are ~50% bigger than the baseline emoji, but what we really want to guard
|
||
// against are the ones that are 2x the size, because those are truly broken (person with red hair = person with
|
||
// floating red wig, black cat = cat with black square, polar bear = bear with snowflake, etc.)
|
||
// So here we set the threshold at 1.8 times the size of the baseline emoji.
|
||
const supported = emojiWidth / 1.8 < baselineEmojiWidth;
|
||
supportedZwjEmojis.set(emoji.unicode, supported);
|
||
}
|
||
}
|
||
|
||
// like lodash's uniq
|
||
|
||
function uniq(arr) {
|
||
return uniqBy(arr, (_) => _);
|
||
}
|
||
|
||
// Note we put this in its own function outside Picker.js to avoid Svelte doing an invalidation on the "setter" here.
|
||
// At best the invalidation is useless, at worst it can cause infinite loops:
|
||
// https://github.com/nolanlawson/emoji-picker-element/pull/180
|
||
// https://github.com/sveltejs/svelte/issues/6521
|
||
// Also note tabpanelElement can be null if the element is disconnected immediately after connected
|
||
function resetScrollTopIfPossible(element) {
|
||
/* istanbul ignore else */
|
||
if (element) {
|
||
// Makes me nervous not to have this `if` guard
|
||
element.scrollTop = 0;
|
||
}
|
||
}
|
||
|
||
function getFromMap(cache, key, func) {
|
||
let cached = cache.get(key);
|
||
if (!cached) {
|
||
cached = func();
|
||
cache.set(key, cached);
|
||
}
|
||
return cached;
|
||
}
|
||
|
||
function toString(value) {
|
||
return "" + value;
|
||
}
|
||
|
||
function parseTemplate(htmlString) {
|
||
const template = document.createElement("template");
|
||
template.innerHTML = htmlString;
|
||
return template;
|
||
}
|
||
|
||
const parseCache = new WeakMap();
|
||
const domInstancesCache = new WeakMap();
|
||
// This needs to be a symbol because it needs to be different from any possible output of a key function
|
||
const unkeyedSymbol = Symbol("un-keyed");
|
||
|
||
// Not supported in Safari <=13
|
||
const hasReplaceChildren = "replaceChildren" in Element.prototype;
|
||
function replaceChildren(parentNode, newChildren) {
|
||
/* istanbul ignore else */
|
||
if (hasReplaceChildren) {
|
||
parentNode.replaceChildren(...newChildren);
|
||
} else {
|
||
// minimal polyfill for Element.prototype.replaceChildren
|
||
parentNode.innerHTML = "";
|
||
parentNode.append(...newChildren);
|
||
}
|
||
}
|
||
|
||
function doChildrenNeedRerender(parentNode, newChildren) {
|
||
let oldChild = parentNode.firstChild;
|
||
let oldChildrenCount = 0;
|
||
// iterate using firstChild/nextSibling because browsers use a linked list under the hood
|
||
while (oldChild) {
|
||
const newChild = newChildren[oldChildrenCount];
|
||
// check if the old child and new child are the same
|
||
if (newChild !== oldChild) {
|
||
return true;
|
||
}
|
||
oldChild = oldChild.nextSibling;
|
||
oldChildrenCount++;
|
||
}
|
||
// if new children length is different from old, we must re-render
|
||
return oldChildrenCount !== newChildren.length;
|
||
}
|
||
|
||
function patchChildren(newChildren, instanceBinding) {
|
||
const { targetNode } = instanceBinding;
|
||
let { targetParentNode } = instanceBinding;
|
||
|
||
let needsRerender = false;
|
||
|
||
if (targetParentNode) {
|
||
// already rendered once
|
||
needsRerender = doChildrenNeedRerender(targetParentNode, newChildren);
|
||
} else {
|
||
// first render of list
|
||
needsRerender = true;
|
||
instanceBinding.targetNode = undefined; // placeholder node not needed anymore, free memory
|
||
instanceBinding.targetParentNode = targetParentNode = targetNode.parentNode;
|
||
}
|
||
// avoid re-rendering list if the dom nodes are exactly the same before and after
|
||
if (needsRerender) {
|
||
replaceChildren(targetParentNode, newChildren);
|
||
}
|
||
}
|
||
|
||
function patch(expressions, instanceBindings) {
|
||
for (const instanceBinding of instanceBindings) {
|
||
const {
|
||
targetNode,
|
||
currentExpression,
|
||
binding: {
|
||
expressionIndex,
|
||
attributeName,
|
||
attributeValuePre,
|
||
attributeValuePost,
|
||
},
|
||
} = instanceBinding;
|
||
|
||
const expression = expressions[expressionIndex];
|
||
|
||
if (currentExpression === expression) {
|
||
// no need to update, same as before
|
||
continue;
|
||
}
|
||
|
||
instanceBinding.currentExpression = expression;
|
||
|
||
if (attributeName) {
|
||
// attribute replacement
|
||
targetNode.setAttribute(
|
||
attributeName,
|
||
attributeValuePre + toString(expression) + attributeValuePost
|
||
);
|
||
} else {
|
||
// text node / child element / children replacement
|
||
let newNode;
|
||
if (Array.isArray(expression)) {
|
||
// array of DOM elements produced by tag template literals
|
||
patchChildren(expression, instanceBinding);
|
||
} else if (expression instanceof Element) {
|
||
// html tag template returning a DOM element
|
||
newNode = expression;
|
||
targetNode.replaceWith(newNode);
|
||
} else {
|
||
// primitive - string, number, etc
|
||
// nodeValue is faster than textContent supposedly https://www.youtube.com/watch?v=LY6y3HbDVmg
|
||
// note we may be replacing the value in a placeholder text node
|
||
targetNode.nodeValue = toString(expression);
|
||
}
|
||
if (newNode) {
|
||
instanceBinding.targetNode = newNode;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function parse(tokens) {
|
||
let htmlString = "";
|
||
|
||
let withinTag = false;
|
||
let withinAttribute = false;
|
||
let elementIndexCounter = -1; // depth-first traversal order
|
||
|
||
const elementsToBindings = new Map();
|
||
const elementIndexes = [];
|
||
|
||
for (let i = 0, len = tokens.length; i < len; i++) {
|
||
const token = tokens[i];
|
||
htmlString += token;
|
||
|
||
if (i === len - 1) {
|
||
break; // no need to process characters - no more expressions to be found
|
||
}
|
||
|
||
for (let j = 0; j < token.length; j++) {
|
||
const char = token.charAt(j);
|
||
switch (char) {
|
||
case "<": {
|
||
const nextChar = token.charAt(j + 1);
|
||
if (nextChar === "/") {
|
||
// closing tag
|
||
// leaving an element
|
||
elementIndexes.pop();
|
||
} else {
|
||
// not a closing tag
|
||
withinTag = true;
|
||
elementIndexes.push(++elementIndexCounter);
|
||
}
|
||
break;
|
||
}
|
||
case ">": {
|
||
withinTag = false;
|
||
withinAttribute = false;
|
||
break;
|
||
}
|
||
case "=": {
|
||
withinAttribute = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
const elementIndex = elementIndexes[elementIndexes.length - 1];
|
||
const bindings = getFromMap(elementsToBindings, elementIndex, () => []);
|
||
|
||
let attributeName;
|
||
let attributeValuePre;
|
||
let attributeValuePost;
|
||
if (withinAttribute) {
|
||
// I never use single-quotes for attribute values in HTML, so just support double-quotes or no-quotes
|
||
const match = /(\S+)="?([^"=]*)$/.exec(token);
|
||
attributeName = match[1];
|
||
attributeValuePre = match[2];
|
||
attributeValuePost = /^[^">]*/.exec(tokens[i + 1])[0];
|
||
}
|
||
|
||
const binding = {
|
||
attributeName,
|
||
attributeValuePre,
|
||
attributeValuePost,
|
||
expressionIndex: i,
|
||
};
|
||
|
||
bindings.push(binding);
|
||
|
||
if (!withinTag && !withinAttribute) {
|
||
// Add a placeholder text node, so we can find it later. Note we only support one dynamic child text node
|
||
htmlString += " ";
|
||
}
|
||
}
|
||
|
||
const template = parseTemplate(htmlString);
|
||
|
||
return {
|
||
template,
|
||
elementsToBindings,
|
||
};
|
||
}
|
||
|
||
function traverseAndSetupBindings(dom, elementsToBindings) {
|
||
const instanceBindings = [];
|
||
// traverse dom
|
||
const treeWalker = document.createTreeWalker(dom, NodeFilter.SHOW_ELEMENT);
|
||
|
||
let element = dom;
|
||
let elementIndex = -1;
|
||
do {
|
||
const bindings = elementsToBindings.get(++elementIndex);
|
||
if (bindings) {
|
||
for (let i = 0; i < bindings.length; i++) {
|
||
const binding = bindings[i];
|
||
|
||
const targetNode = binding.attributeName
|
||
? element // attribute binding, just use the element itself
|
||
: element.firstChild; // not an attribute binding, so has a placeholder text node
|
||
|
||
const instanceBinding = {
|
||
binding,
|
||
targetNode,
|
||
targetParentNode: undefined,
|
||
currentExpression: undefined,
|
||
};
|
||
|
||
instanceBindings.push(instanceBinding);
|
||
}
|
||
}
|
||
} while ((element = treeWalker.nextNode()));
|
||
|
||
return instanceBindings;
|
||
}
|
||
|
||
function parseHtml(tokens) {
|
||
// All templates and bound expressions are unique per tokens array
|
||
const { template, elementsToBindings } = getFromMap(parseCache, tokens, () =>
|
||
parse(tokens)
|
||
);
|
||
|
||
// When we parseHtml, we always return a fresh DOM instance ready to be updated
|
||
const dom = template.cloneNode(true).content.firstElementChild;
|
||
const instanceBindings = traverseAndSetupBindings(dom, elementsToBindings);
|
||
|
||
return function updateDomInstance(expressions) {
|
||
patch(expressions, instanceBindings);
|
||
return dom;
|
||
};
|
||
}
|
||
|
||
function createFramework(state) {
|
||
const domInstances = getFromMap(domInstancesCache, state, () => new Map());
|
||
let domInstanceCacheKey = unkeyedSymbol;
|
||
|
||
function html(tokens, ...expressions) {
|
||
// Each unique lexical usage of map() is considered unique due to the html`` tagged template call it makes,
|
||
// which has lexically unique tokens. The unkeyed symbol is just used for html`` usage outside of a map().
|
||
const domInstancesForTokens = getFromMap(
|
||
domInstances,
|
||
tokens,
|
||
() => new Map()
|
||
);
|
||
const updateDomInstance = getFromMap(
|
||
domInstancesForTokens,
|
||
domInstanceCacheKey,
|
||
() => parseHtml(tokens)
|
||
);
|
||
|
||
return updateDomInstance(expressions); // update with expressions
|
||
}
|
||
|
||
function map(array, callback, keyFunction) {
|
||
return array.map((item, index) => {
|
||
const originalCacheKey = domInstanceCacheKey;
|
||
domInstanceCacheKey = keyFunction(item);
|
||
try {
|
||
return callback(item, index);
|
||
} finally {
|
||
domInstanceCacheKey = originalCacheKey;
|
||
}
|
||
});
|
||
}
|
||
|
||
return { map, html };
|
||
}
|
||
|
||
function render(
|
||
container,
|
||
state,
|
||
helpers,
|
||
events,
|
||
actions,
|
||
refs,
|
||
abortSignal,
|
||
firstRender
|
||
) {
|
||
const { labelWithSkin, titleForEmoji, unicodeWithSkin } = helpers;
|
||
const { html, map } = createFramework(state);
|
||
|
||
function emojiList(emojis, searchMode, prefix) {
|
||
return map(
|
||
emojis,
|
||
(emoji, i) => {
|
||
return html`<button
|
||
role="${searchMode ? "option" : "menuitem"}"
|
||
aria-selected="${state.searchMode
|
||
? i === state.activeSearchItem
|
||
: ""}"
|
||
aria-label="${labelWithSkin(emoji, state.currentSkinTone)}"
|
||
title="${titleForEmoji(emoji)}"
|
||
class="emoji ${searchMode && i === state.activeSearchItem
|
||
? "active"
|
||
: ""}"
|
||
id="${`${prefix}-${emoji.id}`}"
|
||
>
|
||
${emoji.unicode
|
||
? unicodeWithSkin(emoji, state.currentSkinTone)
|
||
: html`<img
|
||
class="custom-emoji"
|
||
src="${emoji.url}"
|
||
alt=""
|
||
loading="lazy"
|
||
/>`}
|
||
</button>`;
|
||
// It's important for the cache key to be unique based on the prefix, because the framework caches based on the
|
||
// unique tokens + cache key, and the same emoji may be used in the tab as well as in the fav bar
|
||
},
|
||
(emoji) => `${prefix}-${emoji.id}`
|
||
);
|
||
}
|
||
|
||
const section = () => {
|
||
return html`<section
|
||
data-ref="rootElement"
|
||
class="picker"
|
||
aria-label="${state.i18n.regionLabel}"
|
||
style="${state.pickerStyle}"
|
||
>
|
||
<div class="pad-top"></div>
|
||
<div class="search-row">
|
||
<div class="search-wrapper">
|
||
<input
|
||
id="search"
|
||
class="search"
|
||
type="search"
|
||
role="combobox"
|
||
enterkeyhint="search"
|
||
placeholder="${state.i18n.searchLabel}"
|
||
autocapitalize="none"
|
||
autocomplete="off"
|
||
spellcheck="true"
|
||
aria-expanded="${!!(
|
||
state.searchMode && state.currentEmojis.length
|
||
)}"
|
||
aria-controls="search-results"
|
||
aria-describedby="search-description"
|
||
aria-autocomplete="list"
|
||
aria-activedescendant="${state.activeSearchItemId
|
||
? `emo-${state.activeSearchItemId}`
|
||
: ""}"
|
||
data-ref="searchElement"
|
||
data-on-input="onSearchInput"
|
||
data-on-keydown="onSearchKeydown"
|
||
/><label class="sr-only" for="search"
|
||
>${state.i18n.searchLabel}</label
|
||
>
|
||
<span id="search-description" class="sr-only"
|
||
>${state.i18n.searchDescription}</span
|
||
>
|
||
</div>
|
||
<div
|
||
class="skintone-button-wrapper ${state.skinTonePickerExpandedAfterAnimation
|
||
? "expanded"
|
||
: ""}"
|
||
>
|
||
<button
|
||
id="skintone-button"
|
||
class="emoji ${state.skinTonePickerExpanded ? "hide-focus" : ""}"
|
||
aria-label="${state.skinToneButtonLabel}"
|
||
title="${state.skinToneButtonLabel}"
|
||
aria-describedby="skintone-description"
|
||
aria-haspopup="listbox"
|
||
aria-expanded="${state.skinTonePickerExpanded}"
|
||
aria-controls="skintone-list"
|
||
data-on-click="onClickSkinToneButton"
|
||
>
|
||
${state.skinToneButtonText}
|
||
</button>
|
||
</div>
|
||
<span id="skintone-description" class="sr-only"
|
||
>${state.i18n.skinToneDescription}</span
|
||
>
|
||
<div
|
||
data-ref="skinToneDropdown"
|
||
id="skintone-list"
|
||
class="skintone-list hide-focus ${state.skinTonePickerExpanded
|
||
? ""
|
||
: "hidden no-animate"}"
|
||
style="transform:translateY(${state.skinTonePickerExpanded
|
||
? 0
|
||
: "calc(-1 * var(--num-skintones) * var(--total-emoji-size))"})"
|
||
role="listbox"
|
||
aria-label="${state.i18n.skinTonesLabel}"
|
||
aria-activedescendant="skintone-${state.activeSkinTone}"
|
||
aria-hidden="${!state.skinTonePickerExpanded}"
|
||
tabindex="-1"
|
||
data-on-focusout="onSkinToneOptionsFocusOut"
|
||
data-on-click="onSkinToneOptionsClick"
|
||
data-on-keydown="onSkinToneOptionsKeydown"
|
||
data-on-keyup="onSkinToneOptionsKeyup"
|
||
>
|
||
${map(
|
||
state.skinTones,
|
||
(skinTone, i) => {
|
||
return html`<div
|
||
id="skintone-${i}"
|
||
class="emoji ${i === state.activeSkinTone ? "active" : ""}"
|
||
aria-selected="${i === state.activeSkinTone}"
|
||
role="option"
|
||
title="${state.i18n.skinTones[i]}"
|
||
aria-label="${state.i18n.skinTones[i]}"
|
||
>
|
||
${skinTone}
|
||
</div>`;
|
||
},
|
||
(skinTone) => skinTone
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div
|
||
class="nav"
|
||
role="tablist"
|
||
style="grid-template-columns:repeat(${state.groups.length},1fr)"
|
||
aria-label="${state.i18n.categoriesLabel}"
|
||
data-on-keydown="onNavKeydown"
|
||
data-on-click="onNavClick"
|
||
>
|
||
${map(
|
||
state.groups,
|
||
(group) => {
|
||
return html`<button
|
||
role="tab"
|
||
class="nav-button"
|
||
aria-controls="tab-${group.id}"
|
||
aria-label="${state.i18n.categories[group.name]}"
|
||
aria-selected="${!state.searchMode &&
|
||
state.currentGroup.id === group.id}"
|
||
title="${state.i18n.categories[group.name]}"
|
||
data-group-id="${group.id}"
|
||
>
|
||
<div class="nav-emoji emoji">${group.emoji}</div>
|
||
</button>`;
|
||
},
|
||
(group) => group.id
|
||
)}
|
||
</div>
|
||
<div class="indicator-wrapper">
|
||
<div
|
||
class="indicator"
|
||
style="transform:translateX(${
|
||
/* istanbul ignore next */ (state.isRtl ? -1 : 1) *
|
||
state.currentGroupIndex *
|
||
100
|
||
}%)"
|
||
></div>
|
||
</div>
|
||
<div
|
||
class="message ${state.message ? "" : "gone"}"
|
||
role="alert"
|
||
aria-live="polite"
|
||
>
|
||
${state.message}
|
||
</div>
|
||
<div
|
||
data-ref="tabpanelElement"
|
||
class="tabpanel ${!state.databaseLoaded || state.message ? "gone" : ""}"
|
||
role="${state.searchMode ? "region" : "tabpanel"}"
|
||
aria-label="${state.searchMode
|
||
? state.i18n.searchResultsLabel
|
||
: state.i18n.categories[state.currentGroup.name]}"
|
||
id="${state.searchMode ? "" : `tab-${state.currentGroup.id}`}"
|
||
tabindex="0"
|
||
data-on-click="onEmojiClick"
|
||
>
|
||
<div data-action="calculateEmojiGridStyle">
|
||
${map(
|
||
state.currentEmojisWithCategories,
|
||
(emojiWithCategory, i) => {
|
||
return html`<div>
|
||
<div
|
||
id="menu-label-${i}"
|
||
class="category ${state.currentEmojisWithCategories.length ===
|
||
1 && state.currentEmojisWithCategories[0].category === ""
|
||
? "gone"
|
||
: ""}"
|
||
aria-hidden="true"
|
||
>
|
||
${state.searchMode
|
||
? state.i18n.searchResultsLabel
|
||
: emojiWithCategory.category
|
||
? emojiWithCategory.category
|
||
: state.currentEmojisWithCategories.length > 1
|
||
? state.i18n.categories.custom
|
||
: state.i18n.categories[state.currentGroup.name]}
|
||
</div>
|
||
<div
|
||
class="emoji-menu"
|
||
role="${state.searchMode ? "listbox" : "menu"}"
|
||
aria-labelledby="menu-label-${i}"
|
||
id="${state.searchMode ? "search-results" : ""}"
|
||
>
|
||
${emojiList(
|
||
emojiWithCategory.emojis,
|
||
state.searchMode,
|
||
/* prefix */ "emo"
|
||
)}
|
||
</div>
|
||
</div>`;
|
||
},
|
||
(emojiWithCategory) => emojiWithCategory.category
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div
|
||
class="favorites emoji-menu ${state.message ? "gone" : ""}"
|
||
role="menu"
|
||
aria-label="${state.i18n.favoritesLabel}"
|
||
style="padding-inline-end:${`${state.scrollbarWidth}px`}"
|
||
data-on-click="onEmojiClick"
|
||
>
|
||
${emojiList(
|
||
state.currentFavorites,
|
||
/* searchMode */ false,
|
||
/* prefix */ "fav"
|
||
)}
|
||
</div>
|
||
<button
|
||
data-ref="baselineEmoji"
|
||
aria-hidden="true"
|
||
tabindex="-1"
|
||
class="abs-pos hidden emoji baseline-emoji"
|
||
>
|
||
😀
|
||
</button>
|
||
</section>`;
|
||
};
|
||
|
||
const rootDom = section();
|
||
|
||
if (firstRender) {
|
||
// not a re-render
|
||
container.appendChild(rootDom);
|
||
|
||
// we only bind events/refs/actions once - there is no need to find them again given this component structure
|
||
|
||
// helper for traversing the dom, finding elements by an attribute, and getting the attribute value
|
||
const forElementWithAttribute = (attributeName, callback) => {
|
||
for (const element of container.querySelectorAll(`[${attributeName}]`)) {
|
||
callback(element, element.getAttribute(attributeName));
|
||
}
|
||
};
|
||
|
||
// bind events
|
||
for (const eventName of [
|
||
"click",
|
||
"focusout",
|
||
"input",
|
||
"keydown",
|
||
"keyup",
|
||
]) {
|
||
forElementWithAttribute(
|
||
`data-on-${eventName}`,
|
||
(element, listenerName) => {
|
||
element.addEventListener(eventName, events[listenerName]);
|
||
}
|
||
);
|
||
}
|
||
|
||
// find refs
|
||
forElementWithAttribute("data-ref", (element, ref) => {
|
||
refs[ref] = element;
|
||
});
|
||
|
||
// set up actions
|
||
forElementWithAttribute("data-action", (element, action) => {
|
||
actions[action](element);
|
||
});
|
||
|
||
// destroy/abort logic
|
||
abortSignal.addEventListener("abort", () => {
|
||
container.removeChild(rootDom);
|
||
});
|
||
}
|
||
}
|
||
|
||
/* istanbul ignore next */
|
||
const qM =
|
||
typeof queueMicrotask === "function"
|
||
? queueMicrotask
|
||
: (callback) => Promise.resolve().then(callback);
|
||
|
||
function createState(abortSignal) {
|
||
let destroyed = false;
|
||
let currentObserver;
|
||
|
||
const propsToObservers = new Map();
|
||
const dirtyObservers = new Set();
|
||
|
||
let queued;
|
||
|
||
const flush = () => {
|
||
if (destroyed) {
|
||
return;
|
||
}
|
||
const observersToRun = [...dirtyObservers];
|
||
dirtyObservers.clear(); // clear before running to force any new updates to run in another tick of the loop
|
||
try {
|
||
for (const observer of observersToRun) {
|
||
observer();
|
||
}
|
||
} finally {
|
||
queued = false;
|
||
if (dirtyObservers.size) {
|
||
// new updates, queue another one
|
||
queued = true;
|
||
qM(flush);
|
||
}
|
||
}
|
||
};
|
||
|
||
const state = new Proxy(
|
||
{},
|
||
{
|
||
get(target, prop) {
|
||
if (currentObserver) {
|
||
let observers = propsToObservers.get(prop);
|
||
if (!observers) {
|
||
observers = new Set();
|
||
propsToObservers.set(prop, observers);
|
||
}
|
||
observers.add(currentObserver);
|
||
}
|
||
return target[prop];
|
||
},
|
||
set(target, prop, newValue) {
|
||
target[prop] = newValue;
|
||
const observers = propsToObservers.get(prop);
|
||
if (observers) {
|
||
for (const observer of observers) {
|
||
dirtyObservers.add(observer);
|
||
}
|
||
if (!queued) {
|
||
queued = true;
|
||
qM(flush);
|
||
}
|
||
}
|
||
return true;
|
||
},
|
||
}
|
||
);
|
||
|
||
const createEffect = (callback) => {
|
||
const runnable = () => {
|
||
const oldObserver = currentObserver;
|
||
currentObserver = runnable;
|
||
try {
|
||
return callback();
|
||
} finally {
|
||
currentObserver = oldObserver;
|
||
}
|
||
};
|
||
return runnable();
|
||
};
|
||
|
||
// destroy logic
|
||
abortSignal.addEventListener("abort", () => {
|
||
destroyed = true;
|
||
});
|
||
|
||
return {
|
||
state,
|
||
createEffect,
|
||
};
|
||
}
|
||
|
||
// Compare two arrays, with a function called on each item in the two arrays that returns true if the items are equal
|
||
function arraysAreEqualByFunction(left, right, areEqualFunc) {
|
||
if (left.length !== right.length) {
|
||
return false;
|
||
}
|
||
for (let i = 0; i < left.length; i++) {
|
||
if (!areEqualFunc(left[i], right[i])) {
|
||
return false;
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/* eslint-disable prefer-const,no-labels,no-inner-declarations */
|
||
|
||
// constants
|
||
const EMPTY_ARRAY = [];
|
||
|
||
const { assign } = Object;
|
||
|
||
function createRoot(shadowRoot, props) {
|
||
const refs = {};
|
||
const abortController = new AbortController();
|
||
const abortSignal = abortController.signal;
|
||
const { state, createEffect } = createState(abortSignal);
|
||
|
||
// initial state
|
||
assign(state, {
|
||
skinToneEmoji: undefined,
|
||
i18n: undefined,
|
||
database: undefined,
|
||
customEmoji: undefined,
|
||
customCategorySorting: undefined,
|
||
emojiVersion: undefined,
|
||
});
|
||
|
||
// public props
|
||
assign(state, props);
|
||
|
||
// private props
|
||
assign(state, {
|
||
initialLoad: true,
|
||
currentEmojis: [],
|
||
currentEmojisWithCategories: [],
|
||
rawSearchText: "",
|
||
searchText: "",
|
||
searchMode: false,
|
||
activeSearchItem: -1,
|
||
message: undefined,
|
||
skinTonePickerExpanded: false,
|
||
skinTonePickerExpandedAfterAnimation: false,
|
||
currentSkinTone: 0,
|
||
activeSkinTone: 0,
|
||
skinToneButtonText: undefined,
|
||
pickerStyle: undefined,
|
||
skinToneButtonLabel: "",
|
||
skinTones: [],
|
||
currentFavorites: [],
|
||
defaultFavoriteEmojis: undefined,
|
||
numColumns: DEFAULT_NUM_COLUMNS,
|
||
isRtl: false,
|
||
scrollbarWidth: 0,
|
||
currentGroupIndex: 0,
|
||
groups: groups,
|
||
databaseLoaded: false,
|
||
activeSearchItemId: undefined,
|
||
});
|
||
|
||
//
|
||
// Update the current group based on the currentGroupIndex
|
||
//
|
||
createEffect(() => {
|
||
if (state.currentGroup !== state.groups[state.currentGroupIndex]) {
|
||
state.currentGroup = state.groups[state.currentGroupIndex];
|
||
}
|
||
});
|
||
|
||
//
|
||
// Utils/helpers
|
||
//
|
||
|
||
const focus = (id) => {
|
||
shadowRoot.getElementById(id).focus();
|
||
};
|
||
|
||
const emojiToDomNode = (emoji) =>
|
||
shadowRoot.getElementById(`emo-${emoji.id}`);
|
||
|
||
// fire a custom event that crosses the shadow boundary
|
||
const fireEvent = (name, detail) => {
|
||
refs.rootElement.dispatchEvent(
|
||
new CustomEvent(name, {
|
||
detail,
|
||
bubbles: true,
|
||
composed: true,
|
||
})
|
||
);
|
||
};
|
||
|
||
//
|
||
// Comparison utils
|
||
//
|
||
|
||
const compareEmojiArrays = (a, b) => a.id === b.id;
|
||
|
||
const compareCurrentEmojisWithCategories = (a, b) => {
|
||
const { category: aCategory, emojis: aEmojis } = a;
|
||
const { category: bCategory, emojis: bEmojis } = b;
|
||
|
||
if (aCategory !== bCategory) {
|
||
return false;
|
||
}
|
||
|
||
return arraysAreEqualByFunction(aEmojis, bEmojis, compareEmojiArrays);
|
||
};
|
||
|
||
//
|
||
// Update utils to avoid excessive re-renders
|
||
//
|
||
|
||
// avoid excessive re-renders by checking the value before setting
|
||
const updateCurrentEmojis = (newEmojis) => {
|
||
if (
|
||
!arraysAreEqualByFunction(
|
||
state.currentEmojis,
|
||
newEmojis,
|
||
compareEmojiArrays
|
||
)
|
||
) {
|
||
state.currentEmojis = newEmojis;
|
||
}
|
||
};
|
||
|
||
// avoid excessive re-renders
|
||
const updateSearchMode = (newSearchMode) => {
|
||
if (state.searchMode !== newSearchMode) {
|
||
state.searchMode = newSearchMode;
|
||
}
|
||
};
|
||
|
||
// avoid excessive re-renders
|
||
const updateCurrentEmojisWithCategories = (newEmojisWithCategories) => {
|
||
if (
|
||
!arraysAreEqualByFunction(
|
||
state.currentEmojisWithCategories,
|
||
newEmojisWithCategories,
|
||
compareCurrentEmojisWithCategories
|
||
)
|
||
) {
|
||
state.currentEmojisWithCategories = newEmojisWithCategories;
|
||
}
|
||
};
|
||
|
||
// Helpers used by PickerTemplate
|
||
|
||
const unicodeWithSkin = (emoji, currentSkinTone) =>
|
||
(currentSkinTone && emoji.skins && emoji.skins[currentSkinTone]) ||
|
||
emoji.unicode;
|
||
|
||
const labelWithSkin = (emoji, currentSkinTone) =>
|
||
uniq(
|
||
[
|
||
emoji.name || unicodeWithSkin(emoji, currentSkinTone),
|
||
emoji.annotation,
|
||
...(emoji.shortcodes || EMPTY_ARRAY),
|
||
].filter(Boolean)
|
||
).join(", ");
|
||
|
||
const titleForEmoji = (emoji) =>
|
||
emoji.annotation || (emoji.shortcodes || EMPTY_ARRAY).join(", ");
|
||
|
||
const helpers = {
|
||
labelWithSkin,
|
||
titleForEmoji,
|
||
unicodeWithSkin,
|
||
};
|
||
const events = {
|
||
onClickSkinToneButton,
|
||
onEmojiClick,
|
||
onNavClick,
|
||
onNavKeydown,
|
||
onSearchKeydown,
|
||
onSkinToneOptionsClick,
|
||
onSkinToneOptionsFocusOut,
|
||
onSkinToneOptionsKeydown,
|
||
onSkinToneOptionsKeyup,
|
||
onSearchInput,
|
||
};
|
||
const actions = {
|
||
calculateEmojiGridStyle,
|
||
};
|
||
|
||
let firstRender = true;
|
||
createEffect(() => {
|
||
render(
|
||
shadowRoot,
|
||
state,
|
||
helpers,
|
||
events,
|
||
actions,
|
||
refs,
|
||
abortSignal,
|
||
firstRender
|
||
);
|
||
firstRender = false;
|
||
});
|
||
|
||
//
|
||
// Determine the emoji support level (in requestIdleCallback)
|
||
//
|
||
|
||
// mount logic
|
||
if (!state.emojiVersion) {
|
||
detectEmojiSupportLevel().then((level) => {
|
||
// Can't actually test emoji support in Jest/Vitest/JSDom, emoji never render in color in Cairo
|
||
/* istanbul ignore next */
|
||
if (!level) {
|
||
state.message = state.i18n.emojiUnsupportedMessage;
|
||
}
|
||
});
|
||
}
|
||
|
||
//
|
||
// Set or update the database object
|
||
//
|
||
|
||
createEffect(() => {
|
||
// show a Loading message if it takes a long time, or show an error if there's a network/IDB error
|
||
async function handleDatabaseLoading() {
|
||
let showingLoadingMessage = false;
|
||
const timeoutHandle = setTimeout(() => {
|
||
showingLoadingMessage = true;
|
||
state.message = state.i18n.loadingMessage;
|
||
}, TIMEOUT_BEFORE_LOADING_MESSAGE);
|
||
try {
|
||
await state.database.ready();
|
||
state.databaseLoaded = true; // eslint-disable-line no-unused-vars
|
||
} catch (err) {
|
||
console.error(err);
|
||
state.message = state.i18n.networkErrorMessage;
|
||
} finally {
|
||
clearTimeout(timeoutHandle);
|
||
if (showingLoadingMessage) {
|
||
// Seems safer than checking the i18n string, which may change
|
||
showingLoadingMessage = false;
|
||
state.message = ""; // eslint-disable-line no-unused-vars
|
||
}
|
||
}
|
||
}
|
||
|
||
if (state.database) {
|
||
/* no await */
|
||
handleDatabaseLoading();
|
||
}
|
||
});
|
||
|
||
//
|
||
// Global styles for the entire picker
|
||
//
|
||
|
||
createEffect(() => {
|
||
state.pickerStyle = `
|
||
--num-groups: ${state.groups.length};
|
||
--indicator-opacity: ${state.searchMode ? 0 : 1};
|
||
--num-skintones: ${NUM_SKIN_TONES};`;
|
||
});
|
||
|
||
//
|
||
// Set or update the customEmoji
|
||
//
|
||
|
||
createEffect(() => {
|
||
if (state.customEmoji && state.database) {
|
||
updateCustomEmoji(); // re-run whenever customEmoji change
|
||
}
|
||
});
|
||
|
||
createEffect(() => {
|
||
if (state.customEmoji && state.customEmoji.length) {
|
||
if (state.groups !== allGroups) {
|
||
// don't update unnecessarily
|
||
state.groups = allGroups;
|
||
}
|
||
} else if (state.groups !== groups) {
|
||
if (state.currentGroupIndex) {
|
||
// If the current group is anything other than "custom" (which is first), decrement.
|
||
// This fixes the odd case where you set customEmoji, then pick a category, then unset customEmoji
|
||
state.currentGroupIndex--;
|
||
}
|
||
state.groups = groups;
|
||
}
|
||
});
|
||
|
||
//
|
||
// Set or update the preferred skin tone
|
||
//
|
||
|
||
createEffect(() => {
|
||
async function updatePreferredSkinTone() {
|
||
if (state.databaseLoaded) {
|
||
state.currentSkinTone = await state.database.getPreferredSkinTone();
|
||
}
|
||
}
|
||
|
||
/* no await */ updatePreferredSkinTone();
|
||
});
|
||
|
||
createEffect(() => {
|
||
state.skinTones = Array(NUM_SKIN_TONES)
|
||
.fill()
|
||
.map((_, i) => applySkinTone(state.skinToneEmoji, i));
|
||
});
|
||
|
||
createEffect(() => {
|
||
state.skinToneButtonText = state.skinTones[state.currentSkinTone];
|
||
});
|
||
|
||
createEffect(() => {
|
||
state.skinToneButtonLabel = state.i18n.skinToneLabel.replace(
|
||
"{skinTone}",
|
||
state.i18n.skinTones[state.currentSkinTone]
|
||
);
|
||
});
|
||
|
||
//
|
||
// Set or update the favorites emojis
|
||
//
|
||
|
||
createEffect(() => {
|
||
async function updateDefaultFavoriteEmojis() {
|
||
const { database } = state;
|
||
const favs = (
|
||
await Promise.all(
|
||
MOST_COMMONLY_USED_EMOJI.map((unicode) =>
|
||
database.getEmojiByUnicodeOrName(unicode)
|
||
)
|
||
)
|
||
).filter(Boolean); // filter because in Jest/Vitest tests we don't have all the emoji in the DB
|
||
state.defaultFavoriteEmojis = favs;
|
||
}
|
||
|
||
if (state.databaseLoaded) {
|
||
/* no await */ updateDefaultFavoriteEmojis();
|
||
}
|
||
});
|
||
|
||
function updateCustomEmoji() {
|
||
// Certain effects have an implicit dependency on customEmoji since it affects the database
|
||
// Getting it here on the state ensures this effect re-runs when customEmoji change.
|
||
// Setting it on the database is pointless but prevents this code from being removed by a minifier.
|
||
state.database.customEmoji = state.customEmoji || EMPTY_ARRAY;
|
||
}
|
||
|
||
createEffect(() => {
|
||
async function updateFavorites() {
|
||
updateCustomEmoji(); // re-run whenever customEmoji change
|
||
const { database, defaultFavoriteEmojis, numColumns } = state;
|
||
const dbFavorites = await database.getTopFavoriteEmoji(numColumns);
|
||
const favorites = await summarizeEmojis(
|
||
uniqBy(
|
||
[...dbFavorites, ...defaultFavoriteEmojis],
|
||
(_) => _.unicode || _.name
|
||
).slice(0, numColumns)
|
||
);
|
||
state.currentFavorites = favorites;
|
||
}
|
||
|
||
if (state.databaseLoaded && state.defaultFavoriteEmojis) {
|
||
/* no await */ updateFavorites();
|
||
}
|
||
});
|
||
|
||
//
|
||
// Calculate the width of the emoji grid. This serves two purposes:
|
||
// 1) Re-calculate the --num-columns var because it may have changed
|
||
// 2) Re-calculate the scrollbar width because it may have changed
|
||
// (i.e. because the number of items changed)
|
||
// 3) Re-calculate whether we're in RTL mode or not.
|
||
//
|
||
// The benefit of doing this in one place is to align with rAF/ResizeObserver
|
||
// and do all the calculations in one go. RTL vs LTR is not strictly width-related,
|
||
// but since we're already reading the style here, and since it's already aligned with
|
||
// the rAF loop, this is the most appropriate place to do it perf-wise.
|
||
//
|
||
|
||
function calculateEmojiGridStyle(node) {
|
||
calculateWidth(node, abortSignal, (width) => {
|
||
/* istanbul ignore next */
|
||
{
|
||
// jsdom throws errors for this kind of fancy stuff
|
||
// read all the style/layout calculations we need to make
|
||
const style = getComputedStyle(refs.rootElement);
|
||
const newNumColumns = parseInt(
|
||
style.getPropertyValue("--num-columns"),
|
||
10
|
||
);
|
||
const newIsRtl = style.getPropertyValue("direction") === "rtl";
|
||
const parentWidth = node.parentElement.getBoundingClientRect().width;
|
||
const newScrollbarWidth = parentWidth - width;
|
||
|
||
// write to state variables
|
||
state.numColumns = newNumColumns;
|
||
state.scrollbarWidth = newScrollbarWidth; // eslint-disable-line no-unused-vars
|
||
state.isRtl = newIsRtl; // eslint-disable-line no-unused-vars
|
||
}
|
||
});
|
||
}
|
||
|
||
//
|
||
// Set or update the currentEmojis. Check for invalid ZWJ renderings
|
||
// (i.e. double emoji).
|
||
//
|
||
|
||
createEffect(() => {
|
||
async function updateEmojis() {
|
||
const { searchText, currentGroup, databaseLoaded, customEmoji } = state;
|
||
if (!databaseLoaded) {
|
||
state.currentEmojis = [];
|
||
state.searchMode = false;
|
||
} else if (searchText.length >= MIN_SEARCH_TEXT_LENGTH) {
|
||
const newEmojis = await getEmojisBySearchQuery(searchText);
|
||
if (state.searchText === searchText) {
|
||
// if the situation changes asynchronously, do not update
|
||
updateCurrentEmojis(newEmojis);
|
||
updateSearchMode(true);
|
||
}
|
||
} else {
|
||
// database is loaded and we're not in search mode, so we're in normal category mode
|
||
const { id: currentGroupId } = currentGroup;
|
||
// avoid race condition where currentGroupId is -1 and customEmoji is undefined/empty
|
||
if (currentGroupId !== -1 || (customEmoji && customEmoji.length)) {
|
||
const newEmojis = await getEmojisByGroup(currentGroupId);
|
||
if (state.currentGroup.id === currentGroupId) {
|
||
// if the situation changes asynchronously, do not update
|
||
updateCurrentEmojis(newEmojis);
|
||
updateSearchMode(false);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/* no await */ updateEmojis();
|
||
});
|
||
|
||
// Some emojis have their ligatures rendered as two or more consecutive emojis
|
||
// We want to treat these the same as unsupported emojis, so we compare their
|
||
// widths against the baseline widths and remove them as necessary
|
||
createEffect(() => {
|
||
const { currentEmojis, emojiVersion } = state;
|
||
const zwjEmojisToCheck = currentEmojis
|
||
.filter((emoji) => emoji.unicode) // filter custom emoji
|
||
.filter(
|
||
(emoji) => hasZwj(emoji) && !supportedZwjEmojis.has(emoji.unicode)
|
||
);
|
||
if (!emojiVersion && zwjEmojisToCheck.length) {
|
||
// render now, check their length later
|
||
updateCurrentEmojis(currentEmojis);
|
||
rAF(() => checkZwjSupportAndUpdate(zwjEmojisToCheck));
|
||
} else {
|
||
const newEmojis = emojiVersion
|
||
? currentEmojis
|
||
: currentEmojis.filter(isZwjSupported);
|
||
updateCurrentEmojis(newEmojis);
|
||
// Reset scroll top to 0 when emojis change
|
||
rAF(() => resetScrollTopIfPossible(refs.tabpanelElement));
|
||
}
|
||
});
|
||
|
||
function checkZwjSupportAndUpdate(zwjEmojisToCheck) {
|
||
checkZwjSupport(zwjEmojisToCheck, refs.baselineEmoji, emojiToDomNode);
|
||
// force update
|
||
// eslint-disable-next-line no-self-assign
|
||
state.currentEmojis = state.currentEmojis;
|
||
}
|
||
|
||
function isZwjSupported(emoji) {
|
||
return (
|
||
!emoji.unicode || !hasZwj(emoji) || supportedZwjEmojis.get(emoji.unicode)
|
||
);
|
||
}
|
||
|
||
async function filterEmojisByVersion(emojis) {
|
||
const emojiSupportLevel =
|
||
state.emojiVersion || (await detectEmojiSupportLevel());
|
||
// !version corresponds to custom emoji
|
||
return emojis.filter(
|
||
({ version }) => !version || version <= emojiSupportLevel
|
||
);
|
||
}
|
||
|
||
async function summarizeEmojis(emojis) {
|
||
return summarizeEmojisForUI(
|
||
emojis,
|
||
state.emojiVersion || (await detectEmojiSupportLevel())
|
||
);
|
||
}
|
||
|
||
async function getEmojisByGroup(group) {
|
||
// -1 is custom emoji
|
||
const emoji =
|
||
group === -1
|
||
? state.customEmoji
|
||
: await state.database.getEmojiByGroup(group);
|
||
return summarizeEmojis(await filterEmojisByVersion(emoji));
|
||
}
|
||
|
||
async function getEmojisBySearchQuery(query) {
|
||
return summarizeEmojis(
|
||
await filterEmojisByVersion(
|
||
await state.database.getEmojiBySearchQuery(query)
|
||
)
|
||
);
|
||
}
|
||
|
||
createEffect(() => {});
|
||
|
||
//
|
||
// Derive currentEmojisWithCategories from currentEmojis. This is always done even if there
|
||
// are no categories, because it's just easier to code the HTML this way.
|
||
//
|
||
|
||
createEffect(() => {
|
||
function calculateCurrentEmojisWithCategories() {
|
||
const { searchMode, currentEmojis } = state;
|
||
if (searchMode) {
|
||
return [
|
||
{
|
||
category: "",
|
||
emojis: currentEmojis,
|
||
},
|
||
];
|
||
}
|
||
const categoriesToEmoji = new Map();
|
||
for (const emoji of currentEmojis) {
|
||
const category = emoji.category || "";
|
||
let emojis = categoriesToEmoji.get(category);
|
||
if (!emojis) {
|
||
emojis = [];
|
||
categoriesToEmoji.set(category, emojis);
|
||
}
|
||
emojis.push(emoji);
|
||
}
|
||
return [...categoriesToEmoji.entries()]
|
||
.map(([category, emojis]) => ({ category, emojis }))
|
||
.sort((a, b) => state.customCategorySorting(a.category, b.category));
|
||
}
|
||
|
||
const newEmojisWithCategories = calculateCurrentEmojisWithCategories();
|
||
updateCurrentEmojisWithCategories(newEmojisWithCategories);
|
||
});
|
||
|
||
//
|
||
// Handle active search item (i.e. pressing up or down while searching)
|
||
//
|
||
|
||
createEffect(() => {
|
||
state.activeSearchItemId =
|
||
state.activeSearchItem !== -1 &&
|
||
state.currentEmojis[state.activeSearchItem].id;
|
||
});
|
||
|
||
//
|
||
// Handle user input on the search input
|
||
//
|
||
|
||
createEffect(() => {
|
||
const { rawSearchText } = state;
|
||
rIC(() => {
|
||
state.searchText = (rawSearchText || "").trim(); // defer to avoid input delays, plus we can trim here
|
||
state.activeSearchItem = -1;
|
||
});
|
||
});
|
||
|
||
function onSearchKeydown(event) {
|
||
if (!state.searchMode || !state.currentEmojis.length) {
|
||
return;
|
||
}
|
||
|
||
const goToNextOrPrevious = (previous) => {
|
||
halt(event);
|
||
state.activeSearchItem = incrementOrDecrement(
|
||
previous,
|
||
state.activeSearchItem,
|
||
state.currentEmojis
|
||
);
|
||
};
|
||
|
||
switch (event.key) {
|
||
case "ArrowDown":
|
||
return goToNextOrPrevious(false);
|
||
case "ArrowUp":
|
||
return goToNextOrPrevious(true);
|
||
case "Enter":
|
||
if (state.activeSearchItem === -1) {
|
||
// focus the first option in the list since the list must be non-empty at this point (it's verified above)
|
||
state.activeSearchItem = 0;
|
||
} else {
|
||
// there is already an active search item
|
||
halt(event);
|
||
return clickEmoji(state.currentEmojis[state.activeSearchItem].id);
|
||
}
|
||
}
|
||
}
|
||
|
||
//
|
||
// Handle user input on nav
|
||
//
|
||
|
||
function onNavClick(event) {
|
||
const { target } = event;
|
||
const closestTarget = target.closest(".nav-button");
|
||
/* istanbul ignore if */
|
||
if (!closestTarget) {
|
||
return; // This should never happen, but makes me nervous not to have it
|
||
}
|
||
const groupId = parseInt(closestTarget.dataset.groupId, 10);
|
||
refs.searchElement.value = ""; // clear search box input
|
||
state.rawSearchText = "";
|
||
state.searchText = "";
|
||
state.activeSearchItem = -1;
|
||
state.currentGroupIndex = state.groups.findIndex((_) => _.id === groupId);
|
||
}
|
||
|
||
function onNavKeydown(event) {
|
||
const { target, key } = event;
|
||
|
||
const doFocus = (el) => {
|
||
if (el) {
|
||
halt(event);
|
||
el.focus();
|
||
}
|
||
};
|
||
|
||
switch (key) {
|
||
case "ArrowLeft":
|
||
return doFocus(target.previousElementSibling);
|
||
case "ArrowRight":
|
||
return doFocus(target.nextElementSibling);
|
||
case "Home":
|
||
return doFocus(target.parentElement.firstElementChild);
|
||
case "End":
|
||
return doFocus(target.parentElement.lastElementChild);
|
||
}
|
||
}
|
||
|
||
//
|
||
// Handle user input on an emoji
|
||
//
|
||
|
||
async function clickEmoji(unicodeOrName) {
|
||
const emoji = await state.database.getEmojiByUnicodeOrName(unicodeOrName);
|
||
const emojiSummary = [
|
||
...state.currentEmojis,
|
||
...state.currentFavorites,
|
||
].find((_) => _.id === unicodeOrName);
|
||
const skinTonedUnicode =
|
||
emojiSummary.unicode &&
|
||
unicodeWithSkin(emojiSummary, state.currentSkinTone);
|
||
await state.database.incrementFavoriteEmojiCount(unicodeOrName);
|
||
fireEvent("emoji-click", {
|
||
emoji,
|
||
skinTone: state.currentSkinTone,
|
||
...(skinTonedUnicode && { unicode: skinTonedUnicode }),
|
||
...(emojiSummary.name && { name: emojiSummary.name }),
|
||
});
|
||
}
|
||
|
||
async function onEmojiClick(event) {
|
||
const { target } = event;
|
||
/* istanbul ignore if */
|
||
if (!target.classList.contains("emoji")) {
|
||
// This should never happen, but makes me nervous not to have it
|
||
return;
|
||
}
|
||
halt(event);
|
||
const id = target.id.substring(4); // replace 'emo-' or 'fav-' prefix
|
||
|
||
/* no await */ clickEmoji(id);
|
||
}
|
||
|
||
//
|
||
// Handle user input on the skintone picker
|
||
//
|
||
|
||
function changeSkinTone(skinTone) {
|
||
state.currentSkinTone = skinTone;
|
||
state.skinTonePickerExpanded = false;
|
||
focus("skintone-button");
|
||
fireEvent("skin-tone-change", { skinTone });
|
||
/* no await */ state.database.setPreferredSkinTone(skinTone);
|
||
}
|
||
|
||
function onSkinToneOptionsClick(event) {
|
||
const {
|
||
target: { id },
|
||
} = event;
|
||
const match = id && id.match(/^skintone-(\d)/); // skintone option format
|
||
/* istanbul ignore if */
|
||
if (!match) {
|
||
// not a skintone option
|
||
return; // This should never happen, but makes me nervous not to have it
|
||
}
|
||
halt(event);
|
||
const skinTone = parseInt(match[1], 10); // remove 'skintone-' prefix
|
||
changeSkinTone(skinTone);
|
||
}
|
||
|
||
function onClickSkinToneButton(event) {
|
||
state.skinTonePickerExpanded = !state.skinTonePickerExpanded;
|
||
state.activeSkinTone = state.currentSkinTone;
|
||
// this should always be true, since the button is obscured by the listbox, so this `if` is just to be sure
|
||
if (state.skinTonePickerExpanded) {
|
||
halt(event);
|
||
rAF(() => focus("skintone-list"));
|
||
}
|
||
}
|
||
|
||
// To make the animation nicer, change the z-index of the skintone picker button
|
||
// *after* the animation has played. This makes it appear that the picker box
|
||
// is expanding "below" the button
|
||
createEffect(() => {
|
||
if (state.skinTonePickerExpanded) {
|
||
refs.skinToneDropdown.addEventListener(
|
||
"transitionend",
|
||
() => {
|
||
state.skinTonePickerExpandedAfterAnimation = true; // eslint-disable-line no-unused-vars
|
||
},
|
||
{ once: true }
|
||
);
|
||
} else {
|
||
state.skinTonePickerExpandedAfterAnimation = false; // eslint-disable-line no-unused-vars
|
||
}
|
||
});
|
||
|
||
function onSkinToneOptionsKeydown(event) {
|
||
// this should never happen, but makes me nervous not to have it
|
||
/* istanbul ignore if */
|
||
if (!state.skinTonePickerExpanded) {
|
||
return;
|
||
}
|
||
const changeActiveSkinTone = async (nextSkinTone) => {
|
||
halt(event);
|
||
state.activeSkinTone = nextSkinTone;
|
||
};
|
||
|
||
switch (event.key) {
|
||
case "ArrowUp":
|
||
return changeActiveSkinTone(
|
||
incrementOrDecrement(true, state.activeSkinTone, state.skinTones)
|
||
);
|
||
case "ArrowDown":
|
||
return changeActiveSkinTone(
|
||
incrementOrDecrement(false, state.activeSkinTone, state.skinTones)
|
||
);
|
||
case "Home":
|
||
return changeActiveSkinTone(0);
|
||
case "End":
|
||
return changeActiveSkinTone(state.skinTones.length - 1);
|
||
case "Enter":
|
||
// enter on keydown, space on keyup. this is just how browsers work for buttons
|
||
// https://lists.w3.org/Archives/Public/w3c-wai-ig/2019JanMar/0086.html
|
||
halt(event);
|
||
return changeSkinTone(state.activeSkinTone);
|
||
case "Escape":
|
||
halt(event);
|
||
state.skinTonePickerExpanded = false;
|
||
return focus("skintone-button");
|
||
}
|
||
}
|
||
|
||
function onSkinToneOptionsKeyup(event) {
|
||
// this should never happen, but makes me nervous not to have it
|
||
/* istanbul ignore if */
|
||
if (!state.skinTonePickerExpanded) {
|
||
return;
|
||
}
|
||
switch (event.key) {
|
||
case " ":
|
||
// enter on keydown, space on keyup. this is just how browsers work for buttons
|
||
// https://lists.w3.org/Archives/Public/w3c-wai-ig/2019JanMar/0086.html
|
||
halt(event);
|
||
return changeSkinTone(state.activeSkinTone);
|
||
}
|
||
}
|
||
|
||
async function onSkinToneOptionsFocusOut(event) {
|
||
// On blur outside of the skintone listbox, collapse the skintone picker.
|
||
const { relatedTarget } = event;
|
||
// The `else` should never happen, but makes me nervous not to have it
|
||
/* istanbul ignore else */
|
||
if (!relatedTarget || relatedTarget.id !== "skintone-list") {
|
||
state.skinTonePickerExpanded = false;
|
||
}
|
||
}
|
||
|
||
function onSearchInput(event) {
|
||
state.rawSearchText = event.target.value;
|
||
}
|
||
|
||
return {
|
||
$set(newState) {
|
||
assign(state, newState);
|
||
},
|
||
$destroy() {
|
||
abortController.abort();
|
||
},
|
||
};
|
||
}
|
||
|
||
const DEFAULT_DATA_SOURCE =
|
||
"https://cdn.jsdelivr.net/npm/emoji-picker-element-data@^1/en/emojibase/data.json";
|
||
const DEFAULT_LOCALE = "en";
|
||
|
||
var enI18n = {
|
||
categoriesLabel: "Categories",
|
||
emojiUnsupportedMessage: "Your browser does not support color emoji.",
|
||
favoritesLabel: "Favorites",
|
||
loadingMessage: "Loading…",
|
||
networkErrorMessage: "Could not load emoji.",
|
||
regionLabel: "Emoji picker",
|
||
searchDescription:
|
||
"When search results are available, press up or down to select and enter to choose.",
|
||
searchLabel: "Search",
|
||
searchResultsLabel: "Search results",
|
||
skinToneDescription:
|
||
"When expanded, press up or down to select and enter to choose.",
|
||
skinToneLabel: "Choose a skin tone (currently {skinTone})",
|
||
skinTonesLabel: "Skin tones",
|
||
skinTones: [
|
||
"Default",
|
||
"Light",
|
||
"Medium-Light",
|
||
"Medium",
|
||
"Medium-Dark",
|
||
"Dark",
|
||
],
|
||
categories: {
|
||
custom: "Custom",
|
||
"smileys-emotion": "Smileys and emoticons",
|
||
"people-body": "People and body",
|
||
"animals-nature": "Animals and nature",
|
||
"food-drink": "Food and drink",
|
||
"travel-places": "Travel and places",
|
||
activities: "Activities",
|
||
objects: "Objects",
|
||
symbols: "Symbols",
|
||
flags: "Flags",
|
||
},
|
||
};
|
||
|
||
var baseStyles =
|
||
":host{--emoji-size:1.375rem;--emoji-padding:0.5rem;--category-emoji-size:var(--emoji-size);--category-emoji-padding:var(--emoji-padding);--indicator-height:3px;--input-border-radius:0.5rem;--input-border-size:1px;--input-font-size:1rem;--input-line-height:1.5;--input-padding:0.25rem;--num-columns:8;--outline-size:2px;--border-size:1px;--skintone-border-radius:1rem;--category-font-size:1rem;display:flex;width:min-content;height:400px}:host,:host(.light){color-scheme:light;--background:#fff;--border-color:#e0e0e0;--indicator-color:#385ac1;--input-border-color:#999;--input-font-color:#111;--input-placeholder-color:#999;--outline-color:#999;--category-font-color:#111;--button-active-background:#e6e6e6;--button-hover-background:#d9d9d9}:host(.dark){color-scheme:dark;--background:#222;--border-color:#444;--indicator-color:#5373ec;--input-border-color:#ccc;--input-font-color:#efefef;--input-placeholder-color:#ccc;--outline-color:#fff;--category-font-color:#efefef;--button-active-background:#555555;--button-hover-background:#484848}@media (prefers-color-scheme:dark){:host{color-scheme:dark;--background:#222;--border-color:#444;--indicator-color:#5373ec;--input-border-color:#ccc;--input-font-color:#efefef;--input-placeholder-color:#ccc;--outline-color:#fff;--category-font-color:#efefef;--button-active-background:#555555;--button-hover-background:#484848}}:host([hidden]){display:none}button{margin:0;padding:0;border:0;background:0 0;box-shadow:none;-webkit-tap-highlight-color:transparent}button::-moz-focus-inner{border:0}input{padding:0;margin:0;line-height:1.15;font-family:inherit}input[type=search]{-webkit-appearance:none}:focus{outline:var(--outline-color) solid var(--outline-size);outline-offset:calc(-1*var(--outline-size))}:host([data-js-focus-visible]) :focus:not([data-focus-visible-added]){outline:0}:focus:not(:focus-visible){outline:0}.hide-focus{outline:0}*{box-sizing:border-box}.picker{contain:content;display:flex;flex-direction:column;background:var(--background);border:var(--border-size) solid var(--border-color);width:100%;height:100%;overflow:hidden;--total-emoji-size:calc(var(--emoji-size) + (2 * var(--emoji-padding)));--total-category-emoji-size:calc(var(--category-emoji-size) + (2 * var(--category-emoji-padding)))}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0}.hidden{opacity:0;pointer-events:none}.abs-pos{position:absolute;left:0;top:0}.gone{display:none!important}.skintone-button-wrapper,.skintone-list{background:var(--background);z-index:3}.skintone-button-wrapper.expanded{z-index:1}.skintone-list{position:absolute;inset-inline-end:0;top:0;z-index:2;overflow:visible;border-bottom:var(--border-size) solid var(--border-color);border-radius:0 0 var(--skintone-border-radius) var(--skintone-border-radius);will-change:transform;transition:transform .2s ease-in-out;transform-origin:center 0}@media (prefers-reduced-motion:reduce){.skintone-list{transition-duration:.001s}}@supports not (inset-inline-end:0){.skintone-list{right:0}}.skintone-list.no-animate{transition:none}.tabpanel{overflow-y:auto;-webkit-overflow-scrolling:touch;will-change:transform;min-height:0;flex:1;contain:content}.emoji-menu{display:grid;grid-template-columns:repeat(var(--num-columns),var(--total-emoji-size));justify-content:space-around;align-items:flex-start;width:100%}.category{padding:var(--emoji-padding);font-size:var(--category-font-size);color:var(--category-font-color)}.custom-emoji,.emoji,button.emoji{height:var(--total-emoji-size);width:var(--total-emoji-size)}.emoji,button.emoji{font-size:var(--emoji-size);display:flex;align-items:center;justify-content:center;border-radius:100%;line-height:1;overflow:hidden;font-family:var(--emoji-font-family);cursor:pointer}@media (hover:hover) and (pointer:fine){.emoji:hover,button.emoji:hover{background:var(--button-hover-background)}}.emoji.active,.emoji:active,button.emoji.active,button.emoji:active{background:var(--button-active-background)}.custom-emoji{padding:var(--emoji-padding);object-fit:contain;pointer-events:none;background-repeat:no-repeat;background-position:center center;background-size:var(--emoji-size) var(--emoji-size)}.nav,.nav-button{align-items:center}.nav{display:grid;justify-content:space-between;contain:content}.nav-button{display:flex;justify-content:center}.nav-emoji{font-size:var(--category-emoji-size);width:var(--total-category-emoji-size);height:var(--total-category-emoji-size)}.indicator-wrapper{display:flex;border-bottom:1px solid var(--border-color)}.indicator{width:calc(100%/var(--num-groups));height:var(--indicator-height);opacity:var(--indicator-opacity);background-color:var(--indicator-color);will-change:transform,opacity;transition:opacity .1s linear,transform .25s ease-in-out}@media (prefers-reduced-motion:reduce){.indicator{will-change:opacity;transition:opacity .1s linear}}.pad-top,input.search{background:var(--background);width:100%}.pad-top{height:var(--emoji-padding);z-index:3}.search-row{display:flex;align-items:center;position:relative;padding-inline-start:var(--emoji-padding);padding-bottom:var(--emoji-padding)}.search-wrapper{flex:1;min-width:0}input.search{padding:var(--input-padding);border-radius:var(--input-border-radius);border:var(--input-border-size) solid var(--input-border-color);color:var(--input-font-color);font-size:var(--input-font-size);line-height:var(--input-line-height)}input.search::placeholder{color:var(--input-placeholder-color)}.favorites{display:flex;flex-direction:row;border-top:var(--border-size) solid var(--border-color);contain:content}.message{padding:var(--emoji-padding)}";
|
||
|
||
const PROPS = [
|
||
"customEmoji",
|
||
"customCategorySorting",
|
||
"database",
|
||
"dataSource",
|
||
"i18n",
|
||
"locale",
|
||
"skinToneEmoji",
|
||
"emojiVersion",
|
||
];
|
||
|
||
// Styles injected ourselves, so we can declare the FONT_FAMILY variable in one place
|
||
const EXTRA_STYLES = `:host{--emoji-font-family:${FONT_FAMILY}}`;
|
||
|
||
class PickerElement extends HTMLElement {
|
||
constructor(props) {
|
||
super();
|
||
this.attachShadow({ mode: "open" });
|
||
const style = document.createElement("style");
|
||
style.textContent = baseStyles + EXTRA_STYLES;
|
||
this.shadowRoot.appendChild(style);
|
||
this._ctx = {
|
||
// Set defaults
|
||
locale: DEFAULT_LOCALE,
|
||
dataSource: DEFAULT_DATA_SOURCE,
|
||
skinToneEmoji: DEFAULT_SKIN_TONE_EMOJI,
|
||
customCategorySorting: DEFAULT_CATEGORY_SORTING,
|
||
customEmoji: null,
|
||
i18n: enI18n,
|
||
emojiVersion: null,
|
||
...props,
|
||
};
|
||
// Handle properties set before the element was upgraded
|
||
for (const prop of PROPS) {
|
||
if (
|
||
prop !== "database" &&
|
||
Object.prototype.hasOwnProperty.call(this, prop)
|
||
) {
|
||
this._ctx[prop] = this[prop];
|
||
delete this[prop];
|
||
}
|
||
}
|
||
this._dbFlush(); // wait for a flush before creating the db, in case the user calls e.g. a setter or setAttribute
|
||
}
|
||
|
||
connectedCallback() {
|
||
// The _cmp may be defined if the component was immediately disconnected and then reconnected. In that case,
|
||
// do nothing (preserve the state)
|
||
if (!this._cmp) {
|
||
this._cmp = createRoot(this.shadowRoot, this._ctx);
|
||
}
|
||
}
|
||
|
||
disconnectedCallback() {
|
||
// Check in a microtask if the element is still connected. If so, treat this as a "move" rather than a disconnect
|
||
// Inspired by Vue: https://vuejs.org/guide/extras/web-components.html#building-custom-elements-with-vue
|
||
qM(() => {
|
||
// this._cmp may be defined if connect-disconnect-connect-disconnect occurs synchronously
|
||
if (!this.isConnected && this._cmp) {
|
||
this._cmp.$destroy();
|
||
this._cmp = undefined;
|
||
|
||
const { database } = this._ctx;
|
||
database
|
||
.close()
|
||
// only happens if the database failed to load in the first place, so we don't care
|
||
.catch((err) => console.error(err));
|
||
}
|
||
});
|
||
}
|
||
|
||
static get observedAttributes() {
|
||
return ["locale", "data-source", "skin-tone-emoji", "emoji-version"]; // complex objects aren't supported, also use kebab-case
|
||
}
|
||
|
||
attributeChangedCallback(attrName, oldValue, newValue) {
|
||
this._set(
|
||
// convert from kebab-case to camelcase
|
||
// see https://github.com/sveltejs/svelte/issues/3852#issuecomment-665037015
|
||
attrName.replace(/-([a-z])/g, (_, up) => up.toUpperCase()),
|
||
// convert string attribute to float if necessary
|
||
attrName === "emoji-version" ? parseFloat(newValue) : newValue
|
||
);
|
||
}
|
||
|
||
_set(prop, newValue) {
|
||
this._ctx[prop] = newValue;
|
||
if (this._cmp) {
|
||
this._cmp.$set({ [prop]: newValue });
|
||
}
|
||
if (["locale", "dataSource"].includes(prop)) {
|
||
this._dbFlush();
|
||
}
|
||
}
|
||
|
||
_dbCreate() {
|
||
const { locale, dataSource, database } = this._ctx;
|
||
// only create a new database if we really need to
|
||
if (
|
||
!database ||
|
||
database.locale !== locale ||
|
||
database.dataSource !== dataSource
|
||
) {
|
||
this._set("database", new Database({ locale, dataSource }));
|
||
}
|
||
}
|
||
|
||
// Update the Database in one microtask if the locale/dataSource change. We do one microtask
|
||
// so we don't create two Databases if e.g. both the locale and the dataSource change
|
||
_dbFlush() {
|
||
qM(() => this._dbCreate());
|
||
}
|
||
}
|
||
|
||
const definitions = {};
|
||
|
||
for (const prop of PROPS) {
|
||
definitions[prop] = {
|
||
get() {
|
||
if (prop === "database") {
|
||
// in rare cases, the microtask may not be flushed yet, so we need to instantiate the DB
|
||
// now if the user is asking for it
|
||
this._dbCreate();
|
||
}
|
||
return this._ctx[prop];
|
||
},
|
||
set(val) {
|
||
if (prop === "database") {
|
||
throw new Error("database is read-only");
|
||
}
|
||
this._set(prop, val);
|
||
},
|
||
};
|
||
}
|
||
|
||
Object.defineProperties(PickerElement.prototype, definitions);
|
||
|
||
/* istanbul ignore else */
|
||
if (!customElements.get("emoji-picker")) {
|
||
// if already defined, do nothing (e.g. same script imported twice)
|
||
customElements.define("emoji-picker", PickerElement);
|
||
}
|
||
|
||
export { PickerElement as default };
|