vendor: 7,012 bytes, lines 1-220
1function assertNonEmptyString (str) { 2 if (typeof str !== 'string' || !str) { 3 throw new Error('expected a non-empty string, got: ' + str) 4 } 5} 6 7function assertNumber (number) { 8 if (typeof number !== 'number') { 9 throw new Error('expected a number, got: ' + number) 10 } 11} 12 13const DB_VERSION_CURRENT = 1; 14const DB_VERSION_INITIAL = 1; 15const STORE_EMOJI = 'emoji'; 16const STORE_KEYVALUE = 'keyvalue'; 17const STORE_FAVORITES = 'favorites'; 18const FIELD_TOKENS = 'tokens'; 19const INDEX_TOKENS = 'tokens'; 20const FIELD_UNICODE = 'unicode'; 21const INDEX_COUNT = 'count'; 22const FIELD_GROUP = 'group'; 23const FIELD_ORDER = 'order'; 24const INDEX_GROUP_AND_ORDER = 'group-order'; 25const KEY_ETAG = 'eTag'; 26const KEY_URL = 'url'; 27const KEY_PREFERRED_SKINTONE = 'skinTone'; 28const MODE_READONLY = 'readonly'; 29const MODE_READWRITE = 'readwrite'; 30const INDEX_SKIN_UNICODE = 'skinUnicodes'; 31const FIELD_SKIN_UNICODE = 'skinUnicodes'; 32 33const DEFAULT_DATA_SOURCE$1 = 'https://cdn.jsdelivr.net/npm/emoji-picker-element-data@^1/en/emojibase/data.json'; 34const DEFAULT_LOCALE$1 = 'en'; 35 36// like lodash's uniqBy but much smaller 37function uniqBy$1 (arr, func) { 38 const set = new Set(); 39 const res = []; 40 for (const item of arr) { 41 const key = func(item); 42 if (!set.has(key)) { 43 set.add(key); 44 res.push(item); 45 } 46 } 47 return res 48} 49 50function uniqEmoji (emojis) { 51 return uniqBy$1(emojis, _ => _.unicode) 52} 53 54function initialMigration (db) { 55 function createObjectStore (name, keyPath, indexes) { 56 const store = keyPath 57 ? db.createObjectStore(name, { keyPath }) 58 : db.createObjectStore(name); 59 if (indexes) { 60 for (const [indexName, [keyPath, multiEntry]] of Object.entries(indexes)) { 61 store.createIndex(indexName, keyPath, { multiEntry }); 62 } 63 } 64 return store 65 } 66 67 createObjectStore(STORE_KEYVALUE); 68 createObjectStore(STORE_EMOJI, /* keyPath */ FIELD_UNICODE, { 69 [INDEX_TOKENS]: [FIELD_TOKENS, /* multiEntry */ true], 70 [INDEX_GROUP_AND_ORDER]: [[FIELD_GROUP, FIELD_ORDER]], 71 [INDEX_SKIN_UNICODE]: [FIELD_SKIN_UNICODE, /* multiEntry */ true] 72 }); 73 createObjectStore(STORE_FAVORITES, undefined, { 74 [INDEX_COUNT]: [''] 75 }); 76} 77 78const openIndexedDBRequests = {}; 79const databaseCache = {}; 80const onCloseListeners = {}; 81 82function handleOpenOrDeleteReq (resolve, reject, req) { 83 // These things are almost impossible to test with fakeIndexedDB sadly 84 /* istanbul ignore next */ 85 req.onerror = () => reject(req.error); 86 /* istanbul ignore next */ 87 req.onblocked = () => reject(new Error('IDB blocked')); 88 req.onsuccess = () => resolve(req.result); 89} 90 91async function createDatabase (dbName) { 92 const db = await new Promise((resolve, reject) => { 93 const req = indexedDB.open(dbName, DB_VERSION_CURRENT); 94 openIndexedDBRequests[dbName] = req; 95 req.onupgradeneeded = e => { 96 // Technically there is only one version, so we don't need this `if` check 97 // But if an old version of the JS is in another browser tab 98 // and it gets upgraded in the future and we have a new DB version, well... 99 // better safe than sorry. 100 /* istanbul ignore else */ 101 if (e.oldVersion < DB_VERSION_INITIAL) { 102 initialMigration(req.result); 103 } 104 }; 105 handleOpenOrDeleteReq(resolve, reject, req); 106 }); 107 // Handle abnormal closes, e.g. "delete database" in chrome dev tools. 108 // No need for removeEventListener, because once the DB can no longer 109 // fire "close" events, it will auto-GC. 110 db.onclose = () => closeDatabase(dbName); 111 return db 112} 113 114function openDatabase (dbName) { 115 if (!databaseCache[dbName]) { 116 databaseCache[dbName] = createDatabase(dbName); 117 } 118 return databaseCache[dbName] 119} 120 121function dbPromise (db, storeName, readOnlyOrReadWrite, cb) { 122 return new Promise((resolve, reject) => { 123 // Use relaxed durability because neither the emoji data nor the favorites/preferred skin tone 124 // are really irreplaceable data. IndexedDB is just a cache in this case. 125 const txn = db.transaction(storeName, readOnlyOrReadWrite, { durability: 'relaxed' }); 126 const store = typeof storeName === 'string' 127 ? txn.objectStore(storeName) 128 : storeName.map(name => txn.objectStore(name)); 129 let res; 130 cb(store, txn, (result) => { 131 res = result; 132 }); 133 134 txn.oncomplete = () => resolve(res); 135 /* istanbul ignore next */ 136 txn.onerror = () => reject(txn.error); 137 }) 138} 139 140function closeDatabase (dbName) { 141 // close any open requests 142 const req = openIndexedDBRequests[dbName]; 143 const db = req && req.result; 144 if (db) { 145 db.close(); 146 const listeners = onCloseListeners[dbName]; 147 /* istanbul ignore else */ 148 if (listeners) { 149 for (const listener of listeners) { 150 listener(); 151 } 152 } 153 } 154 delete openIndexedDBRequests[dbName]; 155 delete databaseCache[dbName]; 156 delete onCloseListeners[dbName]; 157} 158 159function deleteDatabase (dbName) { 160 return new Promise((resolve, reject) => { 161 // close any open requests 162 closeDatabase(dbName); 163 const req = indexedDB.deleteDatabase(dbName); 164 handleOpenOrDeleteReq(resolve, reject, req); 165 }) 166} 167 168// The "close" event occurs during an abnormal shutdown, e.g. a user clearing their browser data. 169// However, it doesn't occur with the normal "close" event, so we handle that separately. 170// https://www.w3.org/TR/IndexedDB/#close-a-database-connection 171function addOnCloseListener (dbName, listener) { 172 let listeners = onCloseListeners[dbName]; 173 if (!listeners) { 174 listeners = onCloseListeners[dbName] = []; 175 } 176 listeners.push(listener); 177} 178 179// list of emoticons that don't match a simple \W+ regex 180// extracted using: 181// require('emoji-picker-element-data/en/emojibase/data.json').map(_ => _.emoticon).filter(Boolean).filter(_ => !/^\W+$/.test(_)) 182const irregularEmoticons = new Set([ 183 ':D', 'XD', ":'D", 'O:)', 184 ':X', ':P', ';P', 'XP', 185 ':L', ':Z', ':j', '8D', 186 'XO', '8)', ':B', ':O', 187 ':S', ":'o", 'Dx', 'X(', 188 'D:', ':C', '>0)', ':3', 189 '</3', '<3', '\\M/', ':E', 190 '8#' 191]); 192 193function extractTokens (str) { 194 return str 195 .split(/[\s_]+/) 196 .map(word => { 197 if (!word.match(/\w/) || irregularEmoticons.has(word)) { 198 // for pure emoticons like :) or :-), just leave them as-is 199 return word.toLowerCase() 200 } 201 202 return word 203 .replace(/[)(:,]/g, '') 204 .replace(/â/g, "'") 205 .toLowerCase() 206 }).filter(Boolean) 207} 208 209const MIN_SEARCH_TEXT_LENGTH$1 = 2; 210 211// This is an extra step in addition to extractTokens(). The difference here is that we expect 212// the input to have already been run through extractTokens(). This is useful for cases like 213// emoticons, where we don't want to do any tokenization (because it makes no sense to split up 214// ">:)" by the colon) but we do want to lowercase it to have consistent search results, so that 215// the user can type ':P' or ':p' and still get the same result. 216function normalizeTokens (str) { 217 return str 218 .filter(Boolean) 219 .map(_ => _.toLowerCase()) 220 .filter(_ => _.length >= MIN_SEARCH_TEXT_LENGTH$1
vendor: 19,087 bytes, lines 220-867
220) 221} 222 223// Transform emoji data for storage in IDB 224function transformEmojiData (emojiData) { 225 const res = emojiData.map(({ annotation, emoticon, group, order, shortcodes, skins, tags, emoji, version }) => { 226 const tokens = [...new Set( 227 normalizeTokens([ 228 ...(shortcodes || []).map(extractTokens).flat(), 229 ...(tags || []).map(extractTokens).flat(), 230 ...extractTokens(annotation), 231 emoticon 232 ]) 233 )].sort(); 234 const res = { 235 annotation, 236 group, 237 order, 238 tags, 239 tokens, 240 unicode: emoji, 241 version 242 }; 243 if (emoticon) { 244 res.emoticon = emoticon; 245 } 246 if (shortcodes) { 247 res.shortcodes = shortcodes; 248 } 249 if (skins) { 250 res.skinTones = []; 251 res.skinUnicodes = []; 252 res.skinVersions = []; 253 for (const { tone, emoji, version } of skins) { 254 res.skinTones.push(tone); 255 res.skinUnicodes.push(emoji); 256 res.skinVersions.push(version); 257 } 258 } 259 return res 260 }); 261 return res 262} 263 264// helper functions that help compress the code better 265 266function callStore (store, method, key, cb) { 267 store[method](key).onsuccess = e => (cb && cb(e.target.result)); 268} 269 270function getIDB (store, key, cb) { 271 callStore(store, 'get', key, cb); 272} 273 274function getAllIDB (store, key, cb) { 275 callStore(store, 'getAll', key, cb); 276} 277 278function commit (txn) { 279 /* istanbul ignore else */ 280 if (txn.commit) { 281 txn.commit(); 282 } 283} 284 285// like lodash's minBy 286function minBy (array, func) { 287 let minItem = array[0]; 288 for (let i = 1; i < array.length; i++) { 289 const item = array[i]; 290 if (func(minItem) > func(item)) { 291 minItem = item; 292 } 293 } 294 return minItem 295} 296 297// return an array of results representing all items that are found in each one of the arrays 298// 299 300function findCommonMembers (arrays, uniqByFunc) { 301 const shortestArray = minBy(arrays, _ => _.length); 302 const results = []; 303 for (const item of shortestArray) { 304 // if this item is included in every array in the intermediate results, add it to the final results 305 if (!arrays.some(array => array.findIndex(_ => uniqByFunc(_) === uniqByFunc(item)) === -1)) { 306 results.push(item); 307 } 308 } 309 return results 310} 311 312async function isEmpty (db) { 313 return !(await get(db, STORE_KEYVALUE, KEY_URL)) 314} 315 316async function hasData (db, url, eTag) { 317 const [oldETag, oldUrl] = await Promise.all([KEY_ETAG, KEY_URL] 318 .map(key => get(db, STORE_KEYVALUE, key))); 319 return (oldETag === eTag && oldUrl === url) 320} 321 322async function doFullDatabaseScanForSingleResult (db, predicate) { 323 // This batching algorithm is just a perf improvement over a basic 324 // cursor. The BATCH_SIZE is an estimate of what would give the best 325 // perf for doing a full DB scan (worst case). 326 // 327 // Mini-benchmark for determining the best batch size: 328 // 329 // PERF=1 pnpm build:rollup && pnpm test:adhoc 330 // 331 // (async () => { 332 // performance.mark('start') 333 // await $('emoji-picker').database.getEmojiByShortcode('doesnotexist') 334 // performance.measure('total', 'start') 335 // console.log(performance.getEntriesByName('total').slice(-1)[0].duration) 336 // })() 337 const BATCH_SIZE = 50; // Typically around 150ms for 6x slowdown in Chrome for above benchmark 338 return dbPromise(db, STORE_EMOJI, MODE_READONLY, (emojiStore, txn, cb) => { 339 let lastKey; 340 341 const processNextBatch = () => { 342 emojiStore.getAll(lastKey && IDBKeyRange.lowerBound(lastKey, true), BATCH_SIZE).onsuccess = e => { 343 const results = e.target.result; 344 for (const result of results) { 345 lastKey = result.unicode; 346 if (predicate(result)) { 347 return cb(result) 348 } 349 } 350 if (results.length < BATCH_SIZE) { 351 return cb() 352 } 353 processNextBatch(); 354 }; 355 }; 356 processNextBatch(); 357 }) 358} 359 360async function loadData (db, emojiData, url, eTag) { 361 try { 362 const transformedData = transformEmojiData(emojiData); 363 await dbPromise(db, [STORE_EMOJI, STORE_KEYVALUE], MODE_READWRITE, ([emojiStore, metaStore], txn) => { 364 let oldETag; 365 let oldUrl; 366 let todo = 0; 367 368 function checkFetched () { 369 if (++todo === 2) { // 2 requests made 370 onFetched(); 371 } 372 } 373 374 function onFetched () { 375 if (oldETag === eTag && oldUrl === url) { 376 // check again within the transaction to guard against concurrency, e.g. multiple browser tabs 377 return 378 } 379 // delete old data 380 emojiStore.clear(); 381 // insert new data 382 for (const data of transformedData) { 383 emojiStore.put(data); 384 } 385 metaStore.put(eTag, KEY_ETAG); 386 metaStore.put(url, KEY_URL); 387 commit(txn); 388 } 389 390 getIDB(metaStore, KEY_ETAG, result => { 391 oldETag = result; 392 checkFetched(); 393 }); 394 395 getIDB(metaStore, KEY_URL, result => { 396 oldUrl = result; 397 checkFetched(); 398 }); 399 }); 400 } finally { 401 } 402} 403 404async function getEmojiByGroup (db, group) { 405 return dbPromise(db, STORE_EMOJI, MODE_READONLY, (emojiStore, txn, cb) => { 406 const range = IDBKeyRange.bound([group, 0], [group + 1, 0], false, true); 407 getAllIDB(emojiStore.index(INDEX_GROUP_AND_ORDER), range, cb); 408 }) 409} 410 411async function getEmojiBySearchQuery (db, query) { 412 const tokens = normalizeTokens(extractTokens(query)); 413 414 if (!tokens.length) { 415 return [] 416 } 417 418 return dbPromise(db, STORE_EMOJI, MODE_READONLY, (emojiStore, txn, cb) => { 419 // get all results that contain all tokens (i.e. an AND query) 420 const intermediateResults = []; 421 422 const checkDone = () => { 423 if (intermediateResults.length === tokens.length) { 424 onDone(); 425 } 426 }; 427 428 const onDone = () => { 429 const results = findCommonMembers(intermediateResults, _ => _.unicode); 430 cb(results.sort((a, b) => a.order < b.order ? -1 : 1)); 431 }; 432 433 for (let i = 0; i < tokens.length; i++) { 434 const token = tokens[i]; 435 const range = i === tokens.length - 1 436 ? IDBKeyRange.bound(token, token + '\uffff', false, true) // treat last token as a prefix search 437 : IDBKeyRange.only(token); // treat all other tokens as an exact match 438 getAllIDB(emojiStore.index(INDEX_TOKENS), range, result => { 439 intermediateResults.push(result); 440 checkDone(); 441 }); 442 } 443 }) 444} 445 446// This could have been implemented as an IDB index on shortcodes, but it seemed wasteful to do that 447// when we can already query by tokens and this will give us what we're looking for 99.9% of the time 448async function getEmojiByShortcode (db, shortcode) { 449 const emojis = await getEmojiBySearchQuery(db, shortcode); 450 451 // In very rare cases (e.g. the shortcode "v" as in "v for victory"), we cannot search because 452 // there are no usable tokens (too short in this case). In that case, we have to do an inefficient 453 // full-database scan, which I believe is an acceptable tradeoff for not having to have an extra 454 // index on shortcodes. 455 456 if (!emojis.length) { 457 const predicate = _ => ((_.shortcodes || []).includes(shortcode.toLowerCase())); 458 return (await doFullDatabaseScanForSingleResult(db, predicate)) || null 459 } 460 461 return emojis.filter(_ => { 462 const lowerShortcodes = (_.shortcodes || []).map(_ => _.toLowerCase()); 463 return lowerShortcodes.includes(shortcode.toLowerCase()) 464 })[0] || null 465} 466 467async function getEmojiByUnicode (db, unicode) { 468 return dbPromise(db, STORE_EMOJI, MODE_READONLY, (emojiStore, txn, cb) => ( 469 getIDB(emojiStore, unicode, result => { 470 if (result) { 471 return cb(result) 472 } 473 getIDB(emojiStore.index(INDEX_SKIN_UNICODE), unicode, result => cb(result || null)); 474 }) 475 )) 476} 477 478function get (db, storeName, key) { 479 return dbPromise(db, storeName, MODE_READONLY, (store, txn, cb) => ( 480 getIDB(store, key, cb) 481 )) 482} 483 484function set (db, storeName, key, value) { 485 return dbPromise(db, storeName, MODE_READWRITE, (store, txn) => { 486 store.put(value, key); 487 commit(txn); 488 }) 489} 490 491function incrementFavoriteEmojiCount (db, unicode) { 492 return dbPromise(db, STORE_FAVORITES, MODE_READWRITE, (store, txn) => ( 493 getIDB(store, unicode, result => { 494 store.put((result || 0) + 1, unicode); 495 commit(txn); 496 }) 497 )) 498} 499 500function getTopFavoriteEmoji (db, customEmojiIndex, limit) { 501 if (limit === 0) { 502 return [] 503 } 504 return dbPromise(db, [STORE_FAVORITES, STORE_EMOJI], MODE_READONLY, ([favoritesStore, emojiStore], txn, cb) => { 505 const results = []; 506 favoritesStore.index(INDEX_COUNT).openCursor(undefined, 'prev').onsuccess = e => { 507 const cursor = e.target.result; 508 if (!cursor) { // no more results 509 return cb(results) 510 } 511 512 function addResult (result) { 513 results.push(result); 514 if (results.length === limit) { 515 return cb(results) // done, reached the limit 516 } 517 cursor.continue(); 518 } 519 520 const unicodeOrName = cursor.primaryKey; 521 const custom = customEmojiIndex.byName(unicodeOrName); 522 if (custom) { 523 return addResult(custom) 524 } 525 // This could be done in parallel (i.e. make the cursor and the get()s parallelized), 526 // but my testing suggests it's not actually faster. 527 getIDB(emojiStore, unicodeOrName, emoji => { 528 if (emoji) { 529 return addResult(emoji) 530 } 531 // emoji not found somehow, ignore (may happen if custom emoji change) 532 cursor.continue(); 533 }); 534 }; 535 }) 536} 537 538// trie data structure for prefix searches 539// loosely based on https://github.com/nolanlawson/substring-trie 540 541const CODA_MARKER = ''; // marks the end of the string 542 543function trie (arr, itemToTokens) { 544 const map = new Map(); 545 for (const item of arr) { 546 const tokens = itemToTokens(item); 547 for (const token of tokens) { 548 let currentMap = map; 549 for (let i = 0; i < token.length; i++) { 550 const char = token.charAt(i); 551 let nextMap = currentMap.get(char); 552 if (!nextMap) { 553 nextMap = new Map(); 554 currentMap.set(char, nextMap); 555 } 556 currentMap = nextMap; 557 } 558 let valuesAtCoda = currentMap.get(CODA_MARKER); 559 if (!valuesAtCoda) { 560 valuesAtCoda = []; 561 currentMap.set(CODA_MARKER, valuesAtCoda); 562 } 563 valuesAtCoda.push(item); 564 } 565 } 566 567 const search = (query, exact) => { 568 let currentMap = map; 569 for (let i = 0; i < query.length; i++) { 570 const char = query.charAt(i); 571 const nextMap = currentMap.get(char); 572 if (nextMap) { 573 currentMap = nextMap; 574 } else { 575 return [] 576 } 577 } 578 579 if (exact) { 580 const results = currentMap.get(CODA_MARKER); 581 return results || [] 582 } 583 584 const results = []; 585 // traverse 586 const queue = [currentMap]; 587 while (queue.length) { 588 const currentMap = queue.shift(); 589 const entriesSortedByKey = [...currentMap.entries()].sort((a, b) => a[0] < b[0] ? -1 : 1); 590 for (const [key, value] of entriesSortedByKey) { 591 if (key === CODA_MARKER) { // CODA_MARKER always comes first; it's the empty string 592 results.push(...value); 593 } else { 594 queue.push(value); 595 } 596 } 597 } 598 return results 599 }; 600 601 return search 602} 603 604const requiredKeys$1 = [ 605 'name', 606 'url' 607]; 608 609function assertCustomEmojis (customEmojis) { 610 const isArray = customEmojis && Array.isArray(customEmojis); 611 const firstItemIsFaulty = isArray && 612 customEmojis.length && 613 (!customEmojis[0] || requiredKeys$1.some(key => !(key in customEmojis[0]))); 614 if (!isArray || firstItemIsFaulty) { 615 throw new Error('Custom emojis are in the wrong format') 616 } 617} 618 619function customEmojiIndex (customEmojis) { 620 assertCustomEmojis(customEmojis); 621 622 const sortByName = (a, b) => a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1; 623 624 // 625 // all() 626 // 627 const all = customEmojis.sort(sortByName); 628 629 // 630 // search() 631 // 632 const emojiToTokens = emoji => { 633 const set = new Set(); 634 if (emoji.shortcodes) { 635 for (const shortcode of emoji.shortcodes) { 636 for (const token of extractTokens(shortcode)) { 637 set.add(token); 638 } 639 } 640 } 641 return set 642 }; 643 const searchTrie = trie(customEmojis, emojiToTokens); 644 const searchByExactMatch = _ => searchTrie(_, true); 645 const searchByPrefix = _ => searchTrie(_, false); 646 647 // Search by query for custom emoji. Similar to how we do this in IDB, the last token 648 // is treated as a prefix search, but every other one is treated as an exact match. 649 // Then we AND the results together 650 const search = query => { 651 const tokens = extractTokens(query); 652 const intermediateResults = tokens.map((token, i) => ( 653 (i < tokens.length - 1 ? searchByExactMatch : searchByPrefix)(token) 654 )); 655 return findCommonMembers(intermediateResults, _ => _.name).sort(sortByName) 656 }; 657 658 // 659 // byShortcode, byName 660 // 661 const shortcodeToEmoji = new Map(); 662 const nameToEmoji = new Map(); 663 for (const customEmoji of customEmojis) { 664 nameToEmoji.set(customEmoji.name.toLowerCase(), customEmoji); 665 for (const shortcode of (customEmoji.shortcodes || [])) { 666 shortcodeToEmoji.set(shortcode.toLowerCase(), customEmoji); 667 } 668 } 669 670 const byShortcode = shortcode => shortcodeToEmoji.get(shortcode.toLowerCase()); 671 const byName = name => nameToEmoji.get(name.toLowerCase()); 672 673 return { 674 all, 675 search, 676 byShortcode, 677 byName 678 } 679} 680 681const isFirefoxContentScript = typeof wrappedJSObject !== 'undefined'; 682 683// remove some internal implementation details, i.e. the "tokens" array on the emoji object 684// essentially, convert the emoji from the version stored in IDB to the version used in-memory 685function cleanEmoji (emoji) { 686 if (!emoji) { 687 return emoji 688 } 689 // if inside a Firefox content script, need to clone the emoji object to prevent Firefox from complaining about 690 // cross-origin object. See: https://github.com/nolanlawson/emoji-picker-element/issues/356 691 /* istanbul ignore if */ 692 if (isFirefoxContentScript) { 693 emoji = structuredClone(emoji); 694 } 695 delete emoji.tokens; 696 if (emoji.skinTones) { 697 const len = emoji.skinTones.length; 698 emoji.skins = Array(len); 699 for (let i = 0; i < len; i++) { 700 emoji.skins[i] = { 701 tone: emoji.skinTones[i], 702 unicode: emoji.skinUnicodes[i], 703 version: emoji.skinVersions[i] 704 }; 705 } 706 delete emoji.skinTones; 707 delete emoji.skinUnicodes; 708 delete emoji.skinVersions; 709 } 710 return emoji 711} 712 713function warnETag (eTag) { 714 if (!eTag) { 715 console.warn('emoji-picker-element is more efficient if the dataSource server exposes an ETag header.'); 716 } 717} 718 719const requiredKeys = [ 720 'annotation', 721 'emoji', 722 'group', 723 'order', 724 'version' 725]; 726 727function assertEmojiData (emojiData) { 728 if (!emojiData || 729 !Array.isArray(emojiData) || 730 !emojiData[0] || 731 (typeof emojiData[0] !== 'object') || 732 requiredKeys.some(key => (!(key in emojiData[0])))) { 733 throw new Error('Emoji data is in the wrong format') 734 } 735} 736 737function assertStatus (response, dataSource) { 738 if (Math.floor(response.status / 100) !== 2) { 739 throw new Error('Failed to fetch: ' + dataSource + ': ' + response.status) 740 } 741} 742 743async function getETag (dataSource) { 744 const response = await fetch(dataSource, { method: 'HEAD' }); 745 assertStatus(response, dataSource); 746 const eTag = response.headers.get('etag'); 747 warnETag(eTag); 748 return eTag 749} 750 751async function getETagAndData (dataSource) { 752 const response = await fetch(dataSource); 753 assertStatus(response, dataSource); 754 const eTag = response.headers.get('etag'); 755 warnETag(eTag); 756 const emojiData = await response.json(); 757 assertEmojiData(emojiData); 758 return [eTag, emojiData] 759} 760 761// TODO: including these in blob-util.ts causes typedoc to generate docs for them, 762// even with --excludePrivate ¯\_(ã)_/¯ 763/** @private */ 764/** 765 * Convert an `ArrayBuffer` to a binary string. 766 * 767 * Example: 768 * 769 * ```js 770 * var myString = blobUtil.arrayBufferToBinaryString(arrayBuff) 771 * ``` 772 * 773 * @param buffer - array buffer 774 * @returns binary string 775 */ 776function arrayBufferToBinaryString(buffer) { 777 var binary = ''; 778 var bytes = new Uint8Array(buffer); 779 var length = bytes.byteLength; 780 var i = -1; 781 while (++i < length) { 782 binary += String.fromCharCode(bytes[i]); 783 } 784 return binary; 785} 786/** 787 * Convert a binary string to an `ArrayBuffer`. 788 * 789 * ```js 790 * var myBuffer = blobUtil.binaryStringToArrayBuffer(binaryString) 791 * ``` 792 * 793 * @param binary - binary string 794 * @returns array buffer 795 */ 796function binaryStringToArrayBuffer(binary) { 797 var length = binary.length; 798 var buf = new ArrayBuffer(length); 799 var arr = new Uint8Array(buf); 800 var i = -1; 801 while (++i < length) { 802 arr[i] = binary.charCodeAt(i); 803 } 804 return buf; 805} 806 807// generate a checksum based on the stringified JSON 808async function jsonChecksum (object) { 809 const inString = JSON.stringify(object); 810 let inBuffer = binaryStringToArrayBuffer(inString); 811 812 // this does not need to be cryptographically secure, SHA-1 is fine 813 const outBuffer = await crypto.subtle.digest('SHA-1', inBuffer); 814 const outBinString = arrayBufferToBinaryString(outBuffer); 815 const res = btoa(outBinString); 816 return res 817} 818 819async function doCheckForUpdates (db, dataSource) { 820 // just do a simple HEAD request first to see if the eTags match 821 let emojiData; 822 let eTag = await getETag(dataSource); 823 if (!eTag) { // work around lack of ETag/Access-Control-Expose-Headers 824 const eTagAndData = await getETagAndData(dataSource); 825 eTag = eTagAndData[0]; 826 emojiData = eTagAndData[1]; 827 if (!eTag) { 828 eTag = await jsonChecksum(emojiData); 829 } 830 } 831 if (await hasData(db, dataSource, eTag)) ; else { 832 if (!emojiData) { 833 const eTagAndData = await getETagAndData(dataSource); 834 emojiData = eTagAndData[1]; 835 } 836 await loadData(db, emojiData, dataSource, eTag); 837 } 838} 839 840async function loadDataForFirstTime (db, dataSource) { 841 let [eTag, emojiData] = await getETagAndData(dataSource); 842 if (!eTag) { 843 // Handle lack of support for ETag or Access-Control-Expose-Headers 844 // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Expose-Headers#Browser_compatibility 845 eTag = await jsonChecksum(emojiData); 846 } 847 848 await loadData(db, emojiData, dataSource, eTag); 849} 850 851async function checkForUpdates (db, dataSource) { 852 try { 853 await doCheckForUpdates(db, dataSource); 854 } catch (err) { 855 // Checking for updates is not a critical operation, and it can fail if e.g. the picker is quickly removed and 856 // re-added to the DOM. In those cases, we may get an IndexedDB InvalidStateError because we are attempting to close 857 // the database connection, possibly while another request is inflight. So there's effectively no way to prevent 858 // InvalidStateErrors unless we were to carefully sequence our IndexedDB operations. Much more simply, we can just 859 // ignore IndexedDB InvalidStateErrors here and give users one less useless error message in their console. 860 if (err.name !== 'InvalidStateError') { 861 throw err 862 } 863 } 864} 865 866class Database { 867 constructor ({ dataSource = DEFAULT_DATA_SOURCE
vendor: 4,054 bytes, lines 867-1002
867$1, locale = DEFAULT_LOCALE$1, customEmoji = [] } = {}) { 868 this.dataSource = dataSource; 869 this.locale = locale; 870 this._dbName = `emoji-picker-element-${this.locale}`; 871 this._db = undefined; 872 this._lazyUpdate = undefined; 873 this._custom = customEmojiIndex(customEmoji); 874 875 this._clear = this._clear.bind(this); 876 this._ready = this._init(); 877 } 878 879 async _init () { 880 const db = this._db = await openDatabase(this._dbName); 881 882 addOnCloseListener(this._dbName, this._clear); 883 const dataSource = this.dataSource; 884 const empty = await isEmpty(db); 885 886 if (empty) { 887 await loadDataForFirstTime(db, dataSource); 888 } else { // offline-first - do an update asynchronously 889 this._lazyUpdate = checkForUpdates(db, dataSource); 890 } 891 } 892 893 async ready () { 894 const checkReady = async () => { 895 if (!this._ready) { 896 this._ready = this._init(); 897 } 898 return this._ready 899 }; 900 await checkReady(); 901 // There's a possibility of a race condition where the element gets added, removed, and then added again 902 // with a particular timing, which would set the _db to undefined. 903 // We *could* do a while loop here, but that seems excessive and could lead to an infinite loop. 904 if (!this._db) { 905 await checkReady(); 906 } 907 } 908 909 async getEmojiByGroup (group) { 910 assertNumber(group); 911 await this.ready(); 912 return uniqEmoji(await getEmojiByGroup(this._db, group)).map(cleanEmoji) 913 } 914 915 async getEmojiBySearchQuery (query) { 916 assertNonEmptyString(query); 917 await this.ready(); 918 const customs = this._custom.search(query); 919 const natives = uniqEmoji(await getEmojiBySearchQuery(this._db, query)).map(cleanEmoji); 920 return [ 921 ...customs, 922 ...natives 923 ] 924 } 925 926 async getEmojiByShortcode (shortcode) { 927 assertNonEmptyString(shortcode); 928 await this.ready(); 929 const custom = this._custom.byShortcode(shortcode); 930 if (custom) { 931 return custom 932 } 933 return cleanEmoji(await getEmojiByShortcode(this._db, shortcode)) 934 } 935 936 async getEmojiByUnicodeOrName (unicodeOrName) { 937 assertNonEmptyString(unicodeOrName); 938 await this.ready(); 939 const custom = this._custom.byName(unicodeOrName); 940 if (custom) { 941 return custom 942 } 943 return cleanEmoji(await getEmojiByUnicode(this._db, unicodeOrName)) 944 } 945 946 async getPreferredSkinTone () { 947 await this.ready(); 948 return (await get(this._db, STORE_KEYVALUE, KEY_PREFERRED_SKINTONE)) || 0 949 } 950 951 async setPreferredSkinTone (skinTone) { 952 assertNumber(skinTone); 953 await this.ready(); 954 return set(this._db, STORE_KEYVALUE, KEY_PREFERRED_SKINTONE, skinTone) 955 } 956 957 async incrementFavoriteEmojiCount (unicodeOrName) { 958 assertNonEmptyString(unicodeOrName); 959 await this.ready(); 960 return incrementFavoriteEmojiCount(this._db, unicodeOrName) 961 } 962 963 async getTopFavoriteEmoji (limit) { 964 assertNumber(limit); 965 await this.ready(); 966 return (await getTopFavoriteEmoji(this._db, this._custom, limit)).map(cleanEmoji) 967 } 968 969 set customEmoji (customEmojis) { 970 this._custom = customEmojiIndex(customEmojis); 971 } 972 973 get customEmoji () { 974 return this._custom.all 975 } 976 977 async _shutdown () { 978 await this.ready(); // reopen if we've already been closed/deleted 979 try { 980 await this._lazyUpdate; // allow any lazy updates to process before closing/deleting 981 } catch (err) { /* ignore network errors (offline-first) */ } 982 } 983 984 // clear references to IDB, e.g. during a close event 985 _clear () { 986 // We don't need to call removeEventListener or remove the manual "close" listeners. 987 // The memory leak tests prove this is unnecessary. It's because: 988 // 1) IDBDatabases that can no longer fire "close" automatically have listeners GCed 989 // 2) we clear the manual close listeners in databaseLifecycle.js. 990 this._db = this._ready = this._lazyUpdate = undefined; 991 } 992 993 async close () { 994 await this._shutdown(); 995 await closeDatabase(this._dbName); 996 } 997 998 async delete () { 999 await this._shutdown(); 1000 await deleteDatabase(this._dbName); 1001 } 1002}
vendor: 71,931 bytes, lines 1002-2835
1002 1003 1004// via https://unpkg.com/browse/[email protected]/meta/groups.json 1005const allGroups = [ 1006 [-1, 'â¨', 'custom'], 1007 [0, 'ð', 'smileys-emotion'], 1008 [1, 'ð', 'people-body'], 1009 [3, 'ð±', 'animals-nature'], 1010 [4, 'ð', 'food-drink'], 1011 [5, 'ð ï¸', 'travel-places'], 1012 [6, 'â½', 'activities'], 1013 [7, 'ð', 'objects'], 1014 [8, 'âï¸', 'symbols'], 1015 [9, 'ð', 'flags'] 1016].map(([id, emoji, name]) => ({ id, emoji, name })); 1017 1018const groups = allGroups.slice(1); 1019 1020const MIN_SEARCH_TEXT_LENGTH = 2; 1021const NUM_SKIN_TONES = 6; 1022 1023/* istanbul ignore next */ 1024const rIC = typeof requestIdleCallback === 'function' ? requestIdleCallback : setTimeout; 1025 1026// check for ZWJ (zero width joiner) character 1027function hasZwj (emoji) { 1028 return emoji.unicode.includes('\u200d') 1029} 1030 1031// Find one good representative emoji from each version to test by checking its color. 1032// Ideally it should have color in the center. For some inspiration, see: 1033// https://about.gitlab.com/blog/2018/05/30/journey-in-native-unicode-emoji/ 1034// 1035// Note that for certain versions (12.1, 13.1), there is no point in testing them explicitly, because 1036// all the emoji from this version are compound-emoji from previous versions. So they would pass a color 1037// test, even in browsers that display them as double emoji. (E.g. "face in clouds" might render as 1038// "face without mouth" plus "fog".) These emoji can only be filtered using the width test, 1039// which happens in checkZwjSupport.js. 1040const versionsAndTestEmoji = { 1041 'ð«ª': 17, // distorted face 1042 'ð«©': 16, // face with bags under eyes 1043 'ð«¨': 15.1, // shaking head, technically from v15 but see note above 1044 'ð« ': 14, 1045 'ð¥²': 13.1, // smiling face with tear, technically from v13 but see note above 1046 'ð¥»': 12.1, // sari, technically from v12 but see note above 1047 'ð¥°': 11, 1048 'ð¤©': 5, 1049 'ð±ââï¸': 4, 1050 'ð¤£': 3, 1051 'ðï¸âð¨ï¸': 2, 1052 'ð': 1, 1053 'ðï¸': 0.7, 1054 'ð': 0.6 1055}; 1056 1057const TIMEOUT_BEFORE_LOADING_MESSAGE = 1000; // 1 second 1058const DEFAULT_SKIN_TONE_EMOJI = 'ðï¸'; 1059const DEFAULT_NUM_COLUMNS = 8; 1060 1061// Based on https://fivethirtyeight.com/features/the-100-most-used-emojis/ and 1062// https://blog.emojipedia.org/facebook-reveals-most-and-least-used-emojis/ with 1063// a bit of my own curation. (E.g. avoid the "OK" gesture because of connotations: 1064// https://emojipedia.org/ok-hand/) 1065const MOST_COMMONLY_USED_EMOJI = [ 1066 'ð', 1067 'ð', 1068 'â¤ï¸', 1069 'ðï¸', 1070 'ð', 1071 'ð', 1072 'ð', 1073 'âºï¸', 1074 'ð', 1075 'ð©', 1076 'ð', 1077 'ð', 1078 'ð', 1079 'ð' 1080]; 1081 1082// It's important to list Twemoji Mozilla before everything else, because Mozilla bundles their 1083// own font on some platforms (notably Windows and Linux as of this writing). Typically, Mozilla 1084// updates faster than the underlying OS, and we don't want to render older emoji in one font and 1085// newer emoji in another font: 1086// https://github.com/nolanlawson/emoji-picker-element/pull/268#issuecomment-1073347283 1087const FONT_FAMILY = '"Twemoji Mozilla","Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol",' + 1088 '"Noto Color Emoji","EmojiOne Color","Android Emoji",sans-serif'; 1089 1090/* istanbul ignore next */ 1091const DEFAULT_CATEGORY_SORTING = (a, b) => a < b ? -1 : a > b ? 1 : 0; 1092 1093// Test if an emoji is supported by rendering it to canvas and checking that the color is not black 1094// See https://about.gitlab.com/blog/2018/05/30/journey-in-native-unicode-emoji/ 1095// and https://www.npmjs.com/package/if-emoji for inspiration 1096// This implementation is largely borrowed from if-emoji, adding the font-family 1097 1098 1099const getTextFeature = (text, color) => { 1100 const canvas = document.createElement('canvas'); 1101 canvas.width = canvas.height = 1; 1102 1103 const ctx = canvas.getContext('2d', { 1104 // Improves the performance of `getImageData()` 1105 // https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/getContextAttributes#willreadfrequently 1106 willReadFrequently: true 1107 }); 1108 ctx.textBaseline = 'top'; 1109 ctx.font = `100px ${FONT_FAMILY}`; 1110 ctx.fillStyle = color; 1111 ctx.scale(0.01, 0.01); 1112 ctx.fillText(text, 0, 0); 1113 1114 return ctx.getImageData(0, 0, 1, 1).data 1115}; 1116 1117const compareFeatures = (feature1, feature2) => { 1118 const feature1Str = [...feature1].join(','); 1119 const feature2Str = [...feature2].join(','); 1120 // This is RGBA, so for 0,0,0, we are checking that the first RGB is not all zeroes. 1121 // Most of the time when unsupported this is 0,0,0,0, but on Chrome on Mac it is 1122 // 0,0,0,61 - there is a transparency here. 1123 return feature1Str === feature2Str && !feature1Str.startsWith('0,0,0,') 1124}; 1125 1126function testColorEmojiSupported (text) { 1127 // Render white and black and then compare them to each other and ensure they're the same 1128 // color, and neither one is black. This shows that the emoji was rendered in color. 1129 const feature1 = getTextFeature(text, '#000'); 1130 const feature2 = getTextFeature(text, '#fff'); 1131 return feature1 && feature2 && compareFeatures(feature1, feature2) 1132} 1133 1134// rather than check every emoji ever, which would be expensive, just check some representatives from the 1135// different emoji releases to determine what the font supports 1136 1137function determineEmojiSupportLevel () { 1138 const entries = Object.entries(versionsAndTestEmoji); 1139 try { 1140 // start with latest emoji and work backwards 1141 for (const [emoji, version] of entries) { 1142 if (testColorEmojiSupported(emoji)) { 1143 return version 1144 } 1145 } 1146 } catch (e) { // canvas error 1147 } finally { 1148 } 1149 // In case of an error, be generous and just assume all emoji are supported (e.g. for canvas errors 1150 // due to anti-fingerprinting add-ons). Better to show some gray boxes than nothing at all. 1151 return entries[0][1] // first one in the list is the most recent version 1152} 1153 1154// Check which emojis we know for sure aren't supported, based on Unicode version level 1155let promise; 1156const detectEmojiSupportLevel = () => { 1157 if (!promise) { 1158 // Delay so it can run while the IDB database is being created by the browser (on another thread). 1159 // This helps especially with first load â we want to start pre-populating the database on the main thread, 1160 // and then wait for IDB to commit everything, and while waiting we run this check. 1161 promise = new Promise(resolve => ( 1162 rIC(() => ( 1163 resolve(determineEmojiSupportLevel()) // delay so ideally this can run while IDB is first populating 1164 )) 1165 )); 1166 } 1167 return promise 1168}; 1169// determine which emojis containing ZWJ (zero width joiner) characters 1170// are supported (rendered as one glyph) rather than unsupported (rendered as two or more glyphs) 1171const supportedZwjEmojis = new Map(); 1172 1173const VARIATION_SELECTOR = '\ufe0f'; 1174const SKINTONE_MODIFIER = '\ud83c'; 1175const ZWJ = '\u200d'; 1176const LIGHT_SKIN_TONE = 0x1F3FB; 1177const LIGHT_SKIN_TONE_MODIFIER = 0xdffb; 1178 1179// TODO: this is a naive implementation, we can improve it later 1180// It's only used for the skintone picker, so as long as people don't customize with 1181// really exotic emoji then it should work fine 1182function applySkinTone (str, skinTone) { 1183 if (skinTone === 0) { 1184 return str 1185 } 1186 const zwjIndex = str.indexOf(ZWJ); 1187 if (zwjIndex !== -1) { 1188 return str.substring(0, zwjIndex) + 1189 String.fromCodePoint(LIGHT_SKIN_TONE + skinTone - 1) + 1190 str.substring(zwjIndex) 1191 } 1192 if (str.endsWith(VARIATION_SELECTOR)) { 1193 str = str.substring(0, str.length - 1); 1194 } 1195 return str + SKINTONE_MODIFIER + String.fromCodePoint(LIGHT_SKIN_TONE_MODIFIER + skinTone - 1) 1196} 1197 1198function halt (event) { 1199 event.preventDefault(); 1200 event.stopPropagation(); 1201} 1202 1203// Implementation left/right or up/down navigation, circling back when you 1204// reach the start/end of the list 1205function incrementOrDecrement (decrement, val, arr) { 1206 val += (decrement ? -1 : 1); 1207 if (val < 0) { 1208 val = arr.length - 1; 1209 } else if (val >= arr.length) { 1210 val = 0; 1211 } 1212 return val 1213} 1214 1215// like lodash's uniqBy but much smaller 1216function uniqBy (arr, func) { 1217 const set = new Set(); 1218 const res = []; 1219 for (const item of arr) { 1220 const key = func(item); 1221 if (!set.has(key)) { 1222 set.add(key); 1223 res.push(item); 1224 } 1225 } 1226 return res 1227} 1228 1229// We don't need all the data on every emoji, and there are specific things we need 1230// for the UI, so build a "view model" from the emoji object we got from the database 1231 1232function summarizeEmojisForUI (emojis, emojiSupportLevel) { 1233 const toSimpleSkinsMap = skins => { 1234 const res = {}; 1235 for (const skin of skins) { 1236 // ignore arrays like [1, 2] with multiple skin tones 1237 // also ignore variants that are in an unsupported emoji version 1238 // (these do exist - variants from a different version than their base emoji) 1239 if (typeof skin.tone === 'number' && skin.version <= emojiSupportLevel) { 1240 res[skin.tone] = skin.unicode; 1241 } 1242 } 1243 return res 1244 }; 1245 1246 return emojis.map(({ unicode, skins, shortcodes, url, name, category, annotation }) => ({ 1247 unicode, 1248 name, 1249 shortcodes, 1250 url, 1251 category, 1252 annotation, 1253 id: unicode || name, 1254 skins: skins && toSimpleSkinsMap(skins) 1255 })) 1256} 1257 1258// import rAF from one place so that the bundle size is a bit smaller 1259const rAF = requestAnimationFrame; 1260 1261// "Svelte action"-like utility to detect layout changes via ResizeObserver. 1262// If ResizeObserver is unsupported, we just use rAF once and don't bother to update. 1263 1264 1265let resizeObserverSupported = typeof ResizeObserver === 'function'; 1266 1267function resizeObserverAction (node, abortSignal, onUpdate) { 1268 let resizeObserver; 1269 if (resizeObserverSupported) { 1270 resizeObserver = new ResizeObserver(onUpdate); 1271 resizeObserver.observe(node); 1272 } else { // just run once, don't bother trying to track it 1273 rAF(onUpdate); 1274 } 1275 1276 // cleanup function (called on destroy) 1277 abortSignal.addEventListener('abort', () => { 1278 if (resizeObserver) { 1279 resizeObserver.disconnect(); 1280 } 1281 }); 1282} 1283 1284// get the width of the text inside of a DOM node, via https://stackoverflow.com/a/59525891/680742 1285function calculateTextWidth (node) { 1286 // skip running this in jest/vitest because we don't need to check for emoji support in that environment 1287 /* istanbul ignore else */ 1288 { 1289 const range = document.createRange(); 1290 range.selectNode(node.firstChild); 1291 return range.getBoundingClientRect().width 1292 } 1293} 1294 1295const BASELINE_EMOJI = 'ð'; 1296 1297let baselineEmojiWidth; 1298let fallbackNode; 1299 1300function calculateTextWidthWithFallback (unicode, domNode, baselineEmojiNode) { 1301 const result = calculateTextWidth(domNode); 1302 /* istanbul ignore if */ 1303 if (!result) { 1304 // If result is 0 then very likely the emoji-picker has `display:none` or equivalent. In that case, we fall back to 1305 // cloning the baseline emoji, putting that in the `document.body`, and measuring that instead. This is a perf hit, 1306 // but it's better than mistakenly filtering emoji: https://github.com/nolanlawson/emoji-picker-element/issues/514 1307 if (!fallbackNode) { 1308 fallbackNode = baselineEmojiNode.cloneNode(true); 1309 // We have to copy styles because we're copying from an element in the shadow DOM to the light DOM 1310 // We can't use the shadow DOM because it's likely the entire picker is `display:none` 1311 const styles = getComputedStyle(baselineEmojiNode); 1312 // probably don't need display/align-items/justify-content but let's play it safe 1313 for (const prop of ['font-family', 'line-height', 'width', 'height', 'font-size', 'display', 'align-items', 'justify-content']) { 1314 // set `!important` just in case some global styles might try to overwrite this 1315 fallbackNode.style.setProperty(prop, styles.getPropertyValue(prop), 'important'); 1316 } 1317 } 1318 try { 1319 document.body.appendChild(fallbackNode); 1320 fallbackNode.firstChild.nodeValue = unicode; 1321 return calculateTextWidth(fallbackNode) 1322 } finally { 1323 // avoid actually rendering the test emoji 1324 fallbackNode.remove(); 1325 } 1326 } 1327 return result 1328} 1329 1330/** 1331 * Check if the given emojis containing ZWJ characters are supported by the current browser (don't render 1332 * as double characters) and return true if all are supported. 1333 * @param zwjEmojisToCheck 1334 * @param baselineEmojiNode 1335 * @param emojiToDomNode 1336 */ 1337function checkZwjSupport (zwjEmojisToCheck, baselineEmojiNode, emojiToDomNode) { 1338 let allSupported = true; 1339 for (const emoji of zwjEmojisToCheck) { 1340 const domNode = emojiToDomNode(emoji); 1341 // sanity check to make sure the node is defined properly 1342 /* istanbul ignore if */ 1343 if (!domNode) { 1344 // This is a race condition that can occur when the component is unmounted/remounted 1345 // It doesn't really matter what we do here since the old context is not going to render anymore. 1346 // Just bail out of emoji support detection and return `allSupported=true` since the rendering context is gone 1347 continue 1348 } 1349 if (typeof baselineEmojiWidth === 'undefined') { // calculate the baseline emoji width only once 1350 baselineEmojiWidth = calculateTextWidthWithFallback(BASELINE_EMOJI, baselineEmojiNode, baselineEmojiNode); 1351 } 1352 const emojiWidth = calculateTextWidthWithFallback(emoji.unicode, domNode, baselineEmojiNode); 1353 // On Windows, some supported emoji are ~50% bigger than the baseline emoji, but what we really want to guard 1354 // against are the ones that are 2x the size, because those are truly broken (person with red hair = person with 1355 // floating red wig, black cat = cat with black square, polar bear = bear with snowflake, etc.) 1356 // So here we set the threshold at 1.8 times the size of the baseline emoji. 1357 const supported = emojiWidth / 1.8 < baselineEmojiWidth; 1358 supportedZwjEmojis.set(emoji.unicode, supported); 1359 1360 if (!supported) { 1361 allSupported = false; 1362 } 1363 } 1364 return allSupported 1365} 1366 1367// like lodash's uniq 1368 1369function uniq (arr) { 1370 return uniqBy(arr, _ => _) 1371} 1372 1373// Note we put this in its own function outside Picker.js to avoid Svelte doing an invalidation on the "setter" here. 1374// At best the invalidation is useless, at worst it can cause infinite loops: 1375// https://github.com/nolanlawson/emoji-picker-element/pull/180 1376// https://github.com/sveltejs/svelte/issues/6521 1377// Also note tabpanelElement can be null if the element is disconnected immediately after connected 1378function resetScrollTopIfPossible (element) { 1379 /* istanbul ignore else */ 1380 if (element) { // Makes me nervous not to have this `if` guard 1381 element.scrollTop = 0; 1382 } 1383} 1384 1385function getFromMap (cache, key, func) { 1386 let cached = cache.get(key); 1387 if (!cached) { 1388 cached = func(); 1389 cache.set(key, cached); 1390 } 1391 return cached 1392} 1393 1394function toString (value) { 1395 return '' + value 1396} 1397 1398function parseTemplate (htmlString) { 1399 const template = document.createElement('template'); 1400 template.innerHTML = htmlString; 1401 return template 1402} 1403 1404const parseCache = new WeakMap(); 1405const domInstancesCache = new WeakMap(); 1406// This needs to be a symbol because it needs to be different from any possible output of a key function 1407const unkeyedSymbol = Symbol('un-keyed'); 1408 1409// Not supported in Safari <=13 1410const hasReplaceChildren = 'replaceChildren' in Element.prototype; 1411function replaceChildren (parentNode, newChildren) { 1412 /* istanbul ignore else */ 1413 if (hasReplaceChildren) { 1414 parentNode.replaceChildren(...newChildren); 1415 } else { // minimal polyfill for Element.prototype.replaceChildren 1416 parentNode.innerHTML = ''; 1417 parentNode.append(...newChildren); 1418 } 1419} 1420 1421function doChildrenNeedRerender (parentNode, newChildren) { 1422 let oldChild = parentNode.firstChild; 1423 let oldChildrenCount = 0; 1424 // iterate using firstChild/nextSibling because browsers use a linked list under the hood 1425 while (oldChild) { 1426 const newChild = newChildren[oldChildrenCount]; 1427 // check if the old child and new child are the same 1428 if (newChild !== oldChild) { 1429 return true 1430 } 1431 oldChild = oldChild.nextSibling; 1432 oldChildrenCount++; 1433 } 1434 // if new children length is different from old, we must re-render 1435 return oldChildrenCount !== newChildren.length 1436} 1437 1438function patchChildren (newChildren, instanceBinding) { 1439 const { targetNode } = instanceBinding; 1440 let { targetParentNode } = instanceBinding; 1441 1442 let needsRerender = false; 1443 1444 if (targetParentNode) { // already rendered once 1445 needsRerender = doChildrenNeedRerender(targetParentNode, newChildren); 1446 } else { // first render of list 1447 needsRerender = true; 1448 instanceBinding.targetNode = undefined; // placeholder node not needed anymore, free memory 1449 instanceBinding.targetParentNode = targetParentNode = targetNode.parentNode; 1450 } 1451 // avoid re-rendering list if the dom nodes are exactly the same before and after 1452 if (needsRerender) { 1453 replaceChildren(targetParentNode, newChildren); 1454 } 1455} 1456 1457function patch (expressions, instanceBindings) { 1458 for (const instanceBinding of instanceBindings) { 1459 const { 1460 targetNode, 1461 currentExpression, 1462 binding: { 1463 expressionIndex, 1464 attributeName, 1465 attributeValuePre, 1466 attributeValuePost 1467 } 1468 } = instanceBinding; 1469 1470 const expression = expressions[expressionIndex]; 1471 1472 if (currentExpression === expression) { 1473 // no need to update, same as before 1474 continue 1475 } 1476 1477 instanceBinding.currentExpression = expression; 1478 1479 if (attributeName) { // attribute replacement 1480 if (expression === null) { 1481 // null is treated as a special case by the framework - we don't render an attribute at all in this case 1482 targetNode.removeAttribute(attributeName); 1483 } else { 1484 // attribute value is not null; set a new attribute 1485 const newValue = attributeValuePre + toString(expression) + attributeValuePost; 1486 targetNode.setAttribute(attributeName, newValue); 1487 } 1488 } else { // text node / child element / children replacement 1489 let newNode; 1490 if (Array.isArray(expression)) { // array of DOM elements produced by tag template literals 1491 patchChildren(expression, instanceBinding); 1492 } else if (expression instanceof Element) { // html tag template returning a DOM element 1493 newNode = expression; 1494 targetNode.replaceWith(newNode); 1495 } else { // primitive - string, number, etc 1496 // nodeValue is faster than textContent supposedly https://www.youtube.com/watch?v=LY6y3HbDVmg 1497 // note we may be replacing the value in a placeholder text node 1498 targetNode.nodeValue = toString(expression); 1499 } 1500 if (newNode) { 1501 instanceBinding.targetNode = newNode; 1502 } 1503 } 1504 } 1505} 1506 1507function parse (tokens) { 1508 let htmlString = ''; 1509 1510 let withinTag = false; 1511 let withinAttribute = false; 1512 let elementIndexCounter = -1; // depth-first traversal order 1513 1514 const elementsToBindings = new Map(); 1515 const elementIndexes = []; 1516 1517 let skipTokenChars = 0; 1518 for (let i = 0, len = tokens.length; i < len; i++) { 1519 const token = tokens[i]; 1520 htmlString += token.slice(skipTokenChars); 1521 1522 if (i === len - 1) { 1523 break // no need to process characters - no more expressions to be found 1524 } 1525 1526 for (let j = 0; j < token.length; j++) { 1527 const char = token.charAt(j); 1528 switch (char) { 1529 case '<': { 1530 const nextChar = token.charAt(j + 1); 1531 if (nextChar === '/') { // closing tag 1532 // leaving an element 1533 elementIndexes.pop(); 1534 } else { // not a closing tag 1535 withinTag = true; 1536 elementIndexes.push(++elementIndexCounter); 1537 } 1538 break 1539 } 1540 case '>': { 1541 withinTag = false; 1542 withinAttribute = false; 1543 break 1544 } 1545 case '=': { 1546 withinAttribute = true; 1547 break 1548 } 1549 } 1550 } 1551 1552 const elementIndex = elementIndexes[elementIndexes.length - 1]; 1553 const bindings = getFromMap(elementsToBindings, elementIndex, () => []); 1554 1555 let attributeName; 1556 let attributeValuePre; 1557 let attributeValuePost; 1558 if (withinAttribute) { 1559 // I never use single-quotes for attribute values in HTML, so just support double-quotes or no-quotes 1560 const attributePreMatch = /(\S+)="?([^"=]*)$/.exec(token); 1561 attributeName = attributePreMatch[1]; 1562 attributeValuePre = attributePreMatch[2]; 1563 const attributePostMatch = /^([^">]*)("?)/.exec(tokens[i + 1]); 1564 attributeValuePost = attributePostMatch[1]; 1565 // Optimization: remove the attribute itself, so we don't create a default attribute which is either empty or just 1566 // the "pre" text, e.g. `<div foo>` or `<div foo="prefix">`. It will be replaced by the expression anyway. 1567 htmlString = htmlString.slice(0, -1 * attributePreMatch[0].length); 1568 skipTokenChars = attributePostMatch[0].length; 1569 } else { 1570 skipTokenChars = 0; 1571 } 1572 1573 const binding = { 1574 attributeName, 1575 attributeValuePre, 1576 attributeValuePost, 1577 expressionIndex: i 1578 }; 1579 1580 bindings.push(binding); 1581 1582 if (!withinTag && !withinAttribute) { 1583 // Add a placeholder text node, so we can find it later. Note we only support one dynamic child text node 1584 htmlString += ' '; 1585 } 1586 } 1587 1588 const template = parseTemplate(htmlString); 1589 1590 return { 1591 template, 1592 elementsToBindings 1593 } 1594} 1595 1596function applyBindings (bindings, element, instanceBindings) { 1597 for (let i = 0; i < bindings.length; i++) { 1598 const binding = bindings[i]; 1599 1600 const targetNode = binding.attributeName 1601 ? element // attribute binding, just use the element itself 1602 : element.firstChild; // not an attribute binding, so has a placeholder text node 1603 1604 const instanceBinding = { 1605 binding, 1606 targetNode, 1607 targetParentNode: undefined, 1608 currentExpression: undefined 1609 }; 1610 1611 instanceBindings.push(instanceBinding); 1612 } 1613} 1614 1615function traverseAndSetupBindings (rootElement, elementsToBindings) { 1616 const instanceBindings = []; 1617 1618 let topLevelBindings; 1619 if (elementsToBindings.size === 1 && (topLevelBindings = elementsToBindings.get(0))) { 1620 // Optimization for the common case where there's only one element and one binding 1621 // Skip creating a TreeWalker entirely and just handle the root DOM element 1622 applyBindings(topLevelBindings, rootElement, instanceBindings); 1623 } else { 1624 // traverse dom 1625 const treeWalker = document.createTreeWalker(rootElement, NodeFilter.SHOW_ELEMENT); 1626 1627 let element = rootElement; 1628 let elementIndex = -1; 1629 do { 1630 const bindings = elementsToBindings.get(++elementIndex); 1631 if (bindings) { 1632 applyBindings(bindings, element, instanceBindings); 1633 } 1634 } while ((element = treeWalker.nextNode())) 1635 } 1636 1637 return instanceBindings 1638} 1639 1640function parseHtml (tokens) { 1641 // All templates and bound expressions are unique per tokens array 1642 const { template, elementsToBindings } = getFromMap(parseCache, tokens, () => parse(tokens)); 1643 1644 // When we parseHtml, we always return a fresh DOM instance ready to be updated 1645 const dom = template.cloneNode(true).content.firstElementChild; 1646 const instanceBindings = traverseAndSetupBindings(dom, elementsToBindings); 1647 1648 return function updateDomInstance (expressions) { 1649 patch(expressions, instanceBindings); 1650 return dom 1651 } 1652} 1653 1654function createFramework (state) { 1655 const domInstances = getFromMap(domInstancesCache, state, () => new Map()); 1656 let domInstanceCacheKey = unkeyedSymbol; 1657 1658 function html (tokens, ...expressions) { 1659 // Each unique lexical usage of map() is considered unique due to the html`` tagged template call it makes, 1660 // which has lexically unique tokens. The unkeyed symbol is just used for html`` usage outside of a map(). 1661 const domInstancesForTokens = getFromMap(domInstances, tokens, () => new Map()); 1662 const updateDomInstance = getFromMap(domInstancesForTokens, domInstanceCacheKey, () => parseHtml(tokens)); 1663 1664 return updateDomInstance(expressions) // update with expressions 1665 } 1666 1667 function map (array, callback, keyFunction) { 1668 return array.map((item, index) => { 1669 const originalCacheKey = domInstanceCacheKey; 1670 domInstanceCacheKey = keyFunction(item); 1671 try { 1672 return callback(item, index) 1673 } finally { 1674 domInstanceCacheKey = originalCacheKey; 1675 } 1676 }) 1677 } 1678 1679 return { map, html } 1680} 1681 1682function render (container, state, helpers, events, actions, refs, abortSignal, actionContext, firstRender) { 1683 const { labelWithSkin, titleForEmoji, unicodeWithSkin } = helpers; 1684 const { html, map } = createFramework(state); 1685 1686 function emojiList (emojis, searchMode, prefix) { 1687 return map(emojis, (emoji, i) => { 1688 return html`<button role="${searchMode ? 'option' : 'menuitem'}" aria-selected="${searchMode ? i === state.activeSearchItem : null}" aria-label="${labelWithSkin(emoji, state.currentSkinTone)}" title="${titleForEmoji(emoji)}" class="${ 1689 'emoji' + 1690 (searchMode && i === state.activeSearchItem ? ' active' : '') + 1691 (emoji.unicode ? '' : ' custom-emoji') 1692 }" id="${`${prefix}-${emoji.id}`}" style="${emoji.unicode ? null : `--custom-emoji-background: url(${JSON.stringify(emoji.url)})`}">${ 1693 emoji.unicode 1694 ? unicodeWithSkin(emoji, state.currentSkinTone) 1695 : '' 1696 }</button>` 1697 // It's important for the cache key to be unique based on the prefix, because the framework caches based on the 1698 // unique tokens + cache key, and the same emoji may be used in the tab as well as in the fav bar 1699 }, emoji => `${prefix}-${emoji.id}`) 1700 } 1701 1702 const section = () => { 1703 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}` : null}" 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">${ 1704 map(state.skinTones, (skinTone, i) => { 1705 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>` 1706 }, skinTone => skinTone) 1707 }</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">${ 1708 map(state.groups, (group) => { 1709 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>` 1710 }, group => group.id) 1711 }</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 ? null : `tab-${state.currentGroup.id}`}" tabIndex="0" data-on-click="onEmojiClick"><div data-action="calculateEmojiGridStyle">${ 1712 map(state.currentEmojisWithCategories, (emojiWithCategory, i) => { 1713 return html`<div><div id="menu-label-${i}" class="category ${state.currentEmojisWithCategories.length === 1 && state.currentEmojisWithCategories[0].category === '' ? 'gone' : ''}" aria-hidden="true">${ 1714 state.searchMode 1715 ? state.i18n.searchResultsLabel 1716 : ( 1717 emojiWithCategory.category 1718 ? emojiWithCategory.category 1719 : ( 1720 state.currentEmojisWithCategories.length > 1 1721 ? state.i18n.categories.custom 1722 : state.i18n.categories[state.currentGroup.name] 1723 ) 1724 ) 1725 }</div><div class="emoji-menu ${i !== 0 && !state.searchMode && state.currentGroup.id === -1 ? 'visibility-auto' : ''}" style="${`--num-rows: ${Math.ceil(emojiWithCategory.emojis.length / state.numColumns)}`}" data-action="updateOnIntersection" role="${state.searchMode ? 'listbox' : 'menu'}" aria-labelledby="menu-label-${i}" id="${state.searchMode ? 'search-results' : null}">${ 1726 emojiList(emojiWithCategory.emojis, state.searchMode, /* prefix */ 'emo') 1727 }</div></div>` 1728 }, emojiWithCategory => emojiWithCategory.category) 1729 }</div></div><div class="favorites onscreen emoji-menu ${state.message ? 'gone' : ''}" role="menu" aria-label="${state.i18n.favoritesLabel}" data-on-click="onEmojiClick">${ 1730 emojiList(state.currentFavorites, /* searchMode */ false, /* prefix */ 'fav') 1731 }</div><button data-ref="baselineEmoji" aria-hidden="true" tabindex="-1" class="abs-pos hidden emoji baseline-emoji">ð</button></section>` 1732 }; 1733 1734 const rootDom = section(); 1735 1736 // helper for traversing the dom, finding elements by an attribute, and getting the attribute value 1737 const forElementWithAttribute = (attributeName, callback) => { 1738 for (const element of container.querySelectorAll(`[${attributeName}]`)) { 1739 callback(element, element.getAttribute(attributeName)); 1740 } 1741 }; 1742 1743 if (firstRender) { // not a re-render 1744 container.appendChild(rootDom); 1745 1746 // we only bind events/refs once - there is no need to find them again given this component structure 1747 1748 // bind events 1749 for (const eventName of ['click', 'focusout', 'input', 'keydown', 'keyup']) { 1750 forElementWithAttribute(`data-on-${eventName}`, (element, listenerName) => { 1751 element.addEventListener(eventName, events[listenerName]); 1752 }); 1753 } 1754 1755 // find refs 1756 forElementWithAttribute('data-ref', (element, ref) => { 1757 refs[ref] = element; 1758 }); 1759 1760 // destroy/abort logic 1761 abortSignal.addEventListener('abort', () => { 1762 container.removeChild(rootDom); 1763 }); 1764 } 1765 1766 // set up actions - these are re-bound on every render 1767 forElementWithAttribute('data-action', (element, action) => { 1768 let boundActions = actionContext.get(action); 1769 if (!boundActions) { 1770 actionContext.set(action, (boundActions = new WeakSet())); 1771 } 1772 1773 // avoid applying the same action to the same element multiple times 1774 if (!boundActions.has(element)) { 1775 boundActions.add(element); 1776 actions[action](element); 1777 } 1778 }); 1779} 1780 1781/* istanbul ignore next */ 1782const qM = typeof queueMicrotask === 'function' ? queueMicrotask : callback => Promise.resolve().then(callback); 1783 1784function createState (abortSignal) { 1785 let destroyed = false; 1786 let currentObserver; 1787 1788 const propsToObservers = new Map(); 1789 const dirtyObservers = new Set(); 1790 1791 let queued; 1792 1793 const flush = () => { 1794 if (destroyed) { 1795 return 1796 } 1797 const observersToRun = [...dirtyObservers]; 1798 dirtyObservers.clear(); // clear before running to force any new updates to run in another tick of the loop 1799 try { 1800 for (const observer of observersToRun) { 1801 observer(); 1802 } 1803 } finally { 1804 queued = false; 1805 if (dirtyObservers.size) { // new updates, queue another one 1806 queued = true; 1807 qM(flush); 1808 } 1809 } 1810 }; 1811 1812 const state = new Proxy({}, { 1813 get (target, prop) { 1814 if (currentObserver) { 1815 let observers = propsToObservers.get(prop); 1816 if (!observers) { 1817 observers = new Set(); 1818 propsToObservers.set(prop, observers); 1819 } 1820 observers.add(currentObserver); 1821 } 1822 return target[prop] 1823 }, 1824 set (target, prop, newValue) { 1825 if (target[prop] !== newValue) { 1826 target[prop] = newValue; 1827 const observers = propsToObservers.get(prop); 1828 if (observers) { 1829 for (const observer of observers) { 1830 dirtyObservers.add(observer); 1831 } 1832 if (!queued) { 1833 queued = true; 1834 qM(flush); 1835 } 1836 } 1837 } 1838 return true 1839 } 1840 }); 1841 1842 const createEffect = (callback) => { 1843 const runnable = () => { 1844 const oldObserver = currentObserver; 1845 currentObserver = runnable; 1846 try { 1847 return callback() 1848 } finally { 1849 currentObserver = oldObserver; 1850 } 1851 }; 1852 return runnable() 1853 }; 1854 1855 // destroy logic 1856 abortSignal.addEventListener('abort', () => { 1857 destroyed = true; 1858 }); 1859 1860 return { 1861 state, 1862 createEffect 1863 } 1864} 1865 1866// Compare two arrays, with a function called on each item in the two arrays that returns true if the items are equal 1867function arraysAreEqualByFunction (left, right, areEqualFunc) { 1868 if (left.length !== right.length) { 1869 return false 1870 } 1871 for (let i = 0; i < left.length; i++) { 1872 if (!areEqualFunc(left[i], right[i])) { 1873 return false 1874 } 1875 } 1876 return true 1877} 1878 1879const intersectionObserverCache = new WeakMap(); 1880 1881function intersectionObserverAction (node, abortSignal, listener) { 1882 /* istanbul ignore else */ 1883 { 1884 // The scroll root is always `.tabpanel` 1885 const root = node.closest('.tabpanel'); 1886 1887 let observer = intersectionObserverCache.get(root); 1888 if (!observer) { 1889 // TODO: replace this with the contentvisibilityautostatechange event when all supported browsers support it. 1890 // For now we use IntersectionObserver because it has better cross-browser support, and it would be bad for 1891 // old Safari versions if they eagerly downloaded all custom emoji all at once. 1892 observer = new IntersectionObserver(listener, { 1893 root, 1894 // trigger if we are 1/2 scroll container height away so that the images load a bit quicker while scrolling 1895 rootMargin: '50% 0px 50% 0px', 1896 // trigger if any part of the emoji grid is intersecting 1897 threshold: 0 1898 }); 1899 1900 // avoid creating a new IntersectionObserver for every category; just use one for the whole root 1901 intersectionObserverCache.set(root, observer); 1902 1903 // assume that the abortSignal is always the same for this root node; just add one event listener 1904 abortSignal.addEventListener('abort', () => { 1905 observer.disconnect(); 1906 }); 1907 } 1908 1909 observer.observe(node); 1910 } 1911} 1912 1913/* eslint-disable prefer-const,no-labels,no-inner-declarations */ 1914 1915// constants 1916const EMPTY_ARRAY = []; 1917 1918const { assign } = Object; 1919 1920function createRoot (shadowRoot, props) { 1921 const refs = {}; 1922 const abortController = new AbortController(); 1923 const abortSignal = abortController.signal; 1924 const { state, createEffect } = createState(abortSignal); 1925 const actionContext = new Map(); 1926 1927 // initial state 1928 assign(state, { 1929 skinToneEmoji: undefined, 1930 i18n: undefined, 1931 database: undefined, 1932 customEmoji: undefined, 1933 customCategorySorting: undefined, 1934 emojiVersion: undefined 1935 }); 1936 1937 // public props 1938 assign(state, props); 1939 1940 // private props 1941 assign(state, { 1942 initialLoad: true, 1943 currentEmojis: [], 1944 currentEmojisWithCategories: [], 1945 rawSearchText: '', 1946 searchText: '', 1947 searchMode: false, 1948 activeSearchItem: -1, 1949 message: undefined, 1950 skinTonePickerExpanded: false, 1951 skinTonePickerExpandedAfterAnimation: false, 1952 currentSkinTone: 0, 1953 activeSkinTone: 0, 1954 skinToneButtonText: undefined, 1955 pickerStyle: undefined, 1956 skinToneButtonLabel: '', 1957 skinTones: [], 1958 currentFavorites: [], 1959 defaultFavoriteEmojis: undefined, 1960 numColumns: DEFAULT_NUM_COLUMNS, 1961 isRtl: false, 1962 currentGroupIndex: 0, 1963 groups: groups, 1964 databaseLoaded: false, 1965 activeSearchItemId: undefined 1966 }); 1967 1968 // 1969 // Update the current group based on the currentGroupIndex 1970 // 1971 createEffect(() => { 1972 if (state.currentGroup !== state.groups[state.currentGroupIndex]) { 1973 state.currentGroup = state.groups[state.currentGroupIndex]; 1974 } 1975 }); 1976 1977 // 1978 // Utils/helpers 1979 // 1980 1981 const focus = id => { 1982 shadowRoot.getElementById(id).focus(); 1983 }; 1984 1985 const emojiToDomNode = emoji => shadowRoot.getElementById(`emo-${emoji.id}`); 1986 1987 // fire a custom event that crosses the shadow boundary 1988 const fireEvent = (name, detail) => { 1989 refs.rootElement.dispatchEvent(new CustomEvent(name, { 1990 detail, 1991 bubbles: true, 1992 composed: true 1993 })); 1994 }; 1995 1996 // 1997 // Comparison utils 1998 // 1999 2000 const compareEmojiArrays = (a, b) => a.id === b.id; 2001 2002 const compareCurrentEmojisWithCategories = (a, b) => { 2003 const { category: aCategory, emojis: aEmojis } = a; 2004 const { category: bCategory, emojis: bEmojis } = b; 2005 2006 if (aCategory !== bCategory) { 2007 return false 2008 } 2009 2010 return arraysAreEqualByFunction(aEmojis, bEmojis, compareEmojiArrays) 2011 }; 2012 2013 // 2014 // Update utils to avoid excessive re-renders 2015 // 2016 2017 // avoid excessive re-renders by checking the value before setting 2018 const updateCurrentEmojis = (newEmojis) => { 2019 if (!arraysAreEqualByFunction(state.currentEmojis, newEmojis, compareEmojiArrays)) { 2020 state.currentEmojis = newEmojis; 2021 } 2022 }; 2023 2024 // avoid excessive re-renders 2025 const updateSearchMode = (newSearchMode) => { 2026 if (state.searchMode !== newSearchMode) { 2027 state.searchMode = newSearchMode; 2028 } 2029 }; 2030 2031 // avoid excessive re-renders 2032 const updateCurrentEmojisWithCategories = (newEmojisWithCategories) => { 2033 if (!arraysAreEqualByFunction(state.currentEmojisWithCategories, newEmojisWithCategories, compareCurrentEmojisWithCategories)) { 2034 state.currentEmojisWithCategories = newEmojisWithCategories; 2035 } 2036 }; 2037 2038 // Helpers used by PickerTemplate 2039 2040 const unicodeWithSkin = (emoji, currentSkinTone) => ( 2041 (currentSkinTone && emoji.skins && emoji.skins[currentSkinTone]) || emoji.unicode 2042 ); 2043 2044 const labelWithSkin = (emoji, currentSkinTone) => ( 2045 uniq([ 2046 (emoji.name || unicodeWithSkin(emoji, currentSkinTone)), 2047 emoji.annotation, 2048 ...(emoji.shortcodes || EMPTY_ARRAY) 2049 ].filter(Boolean)).join(', ') 2050 ); 2051 2052 const titleForEmoji = (emoji) => ( 2053 emoji.annotation || (emoji.shortcodes || EMPTY_ARRAY).join(', ') 2054 ); 2055 2056 const helpers = { 2057 labelWithSkin, titleForEmoji, unicodeWithSkin 2058 }; 2059 const events = { 2060 onClickSkinToneButton, 2061 onEmojiClick, 2062 onNavClick, 2063 onNavKeydown, 2064 onSearchKeydown, 2065 onSkinToneOptionsClick, 2066 onSkinToneOptionsFocusOut, 2067 onSkinToneOptionsKeydown, 2068 onSkinToneOptionsKeyup, 2069 onSearchInput 2070 }; 2071 const actions = { 2072 calculateEmojiGridStyle, 2073 updateOnIntersection 2074 }; 2075 2076 let firstRender = true; 2077 createEffect(() => { 2078 render(shadowRoot, state, helpers, events, actions, refs, abortSignal, actionContext, firstRender); 2079 firstRender = false; 2080 }); 2081 2082 // 2083 // Determine the emoji support level (in requestIdleCallback) 2084 // 2085 2086 // mount logic 2087 if (!state.emojiVersion) { 2088 detectEmojiSupportLevel().then(level => { 2089 // Can't actually test emoji support in Jest/Vitest/JSDom, emoji never render in color in Cairo 2090 /* istanbul ignore next */ 2091 if (!level) { 2092 state.message = state.i18n.emojiUnsupportedMessage; 2093 } 2094 }); 2095 } 2096 2097 // 2098 // Set or update the database object 2099 // 2100 2101 createEffect(() => { 2102 // show a Loading message if it takes a long time, or show an error if there's a network/IDB error 2103 async function handleDatabaseLoading () { 2104 let showingLoadingMessage = false; 2105 const timeoutHandle = setTimeout(() => { 2106 showingLoadingMessage = true; 2107 state.message = state.i18n.loadingMessage; 2108 }, TIMEOUT_BEFORE_LOADING_MESSAGE); 2109 try { 2110 await state.database.ready(); 2111 state.databaseLoaded = true; // eslint-disable-line no-unused-vars 2112 } catch (err) { 2113 console.error(err); 2114 state.message = state.i18n.networkErrorMessage; 2115 } finally { 2116 clearTimeout(timeoutHandle); 2117 if (showingLoadingMessage) { // Seems safer than checking the i18n string, which may change 2118 showingLoadingMessage = false; 2119 state.message = ''; // eslint-disable-line no-unused-vars 2120 } 2121 } 2122 } 2123 2124 if (state.database) { 2125 /* no await */ 2126 handleDatabaseLoading(); 2127 } 2128 }); 2129 2130 // 2131 // Global styles for the entire picker 2132 // 2133 2134 createEffect(() => { 2135 state.pickerStyle = ` 2136 --num-groups: ${state.groups.length}; 2137 --indicator-opacity: ${state.searchMode ? 0 : 1}; 2138 --num-skintones: ${NUM_SKIN_TONES};`; 2139 }); 2140 2141 // 2142 // Set or update the customEmoji 2143 // 2144 2145 createEffect(() => { 2146 if (state.customEmoji && state.database) { 2147 updateCustomEmoji(); // re-run whenever customEmoji change 2148 } 2149 }); 2150 2151 createEffect(() => { 2152 if (state.customEmoji && state.customEmoji.length) { 2153 if (state.groups !== allGroups) { // don't update unnecessarily 2154 state.groups = allGroups; 2155 } 2156 } else if (state.groups !== groups) { 2157 if (state.currentGroupIndex) { 2158 // If the current group is anything other than "custom" (which is first), decrement. 2159 // This fixes the odd case where you set customEmoji, then pick a category, then unset customEmoji 2160 state.currentGroupIndex--; 2161 } 2162 state.groups = groups; 2163 } 2164 }); 2165 2166 // 2167 // Set or update the preferred skin tone 2168 // 2169 2170 createEffect(() => { 2171 async function updatePreferredSkinTone () { 2172 if (state.databaseLoaded) { 2173 state.currentSkinTone = await state.database.getPreferredSkinTone(); 2174 } 2175 } 2176 2177 /* no await */ updatePreferredSkinTone(); 2178 }); 2179 2180 createEffect(() => { 2181 state.skinTones = Array(NUM_SKIN_TONES).fill().map((_, i) => applySkinTone(state.skinToneEmoji, i)); 2182 }); 2183 2184 createEffect(() => { 2185 state.skinToneButtonText = state.skinTones[state.currentSkinTone]; 2186 }); 2187 2188 createEffect(() => { 2189 state.skinToneButtonLabel = state.i18n.skinToneLabel.replace('{skinTone}', state.i18n.skinTones[state.currentSkinTone]); 2190 }); 2191 2192 // 2193 // Set or update the favorites emojis 2194 // 2195 2196 createEffect(() => { 2197 async function updateDefaultFavoriteEmojis () { 2198 const { database } = state; 2199 const favs = (await Promise.all(MOST_COMMONLY_USED_EMOJI.map(unicode => ( 2200 database.getEmojiByUnicodeOrName(unicode) 2201 )))).filter(Boolean); // filter because in Jest/Vitest tests we don't have all the emoji in the DB 2202 state.defaultFavoriteEmojis = favs; 2203 } 2204 2205 if (state.databaseLoaded) { 2206 /* no await */ updateDefaultFavoriteEmojis(); 2207 } 2208 }); 2209 2210 function updateCustomEmoji () { 2211 // Certain effects have an implicit dependency on customEmoji since it affects the database 2212 // Getting it here on the state ensures this effect re-runs when customEmoji change. 2213 const { customEmoji, database } = state; 2214 const databaseCustomEmoji = customEmoji || EMPTY_ARRAY; 2215 if (database.customEmoji !== databaseCustomEmoji) { 2216 // Avoid setting this if the customEmoji have _not_ changed, because the setter triggers a re-computation of the 2217 // `customEmojiIndex`. Note we don't bother with deep object changes. 2218 database.customEmoji = databaseCustomEmoji; 2219 } 2220 } 2221 2222 createEffect(() => { 2223 async function updateFavorites () { 2224 updateCustomEmoji(); // re-run whenever customEmoji change 2225 const { database, defaultFavoriteEmojis, numColumns } = state; 2226 const dbFavorites = await database.getTopFavoriteEmoji(numColumns); 2227 const favorites = await summarizeEmojis(uniqBy([ 2228 ...dbFavorites, 2229 ...defaultFavoriteEmojis 2230 ], _ => (_.unicode || _.name)).slice(0, numColumns)); 2231 state.currentFavorites = favorites; 2232 } 2233 2234 if (state.databaseLoaded && state.defaultFavoriteEmojis) { 2235 /* no await */ updateFavorites(); 2236 } 2237 }); 2238 2239 // 2240 // Re-run whenever the emoji grid changes size, and re-calc style/layout-related state variables: 2241 // 1) Re-calculate the --num-columns var because it may have changed 2242 // 2) Re-calculate whether we're in RTL mode or not. 2243 // 2244 // The benefit of doing this in one place is to align with rAF/ResizeObserver 2245 // and do all the calculations in one go. RTL vs LTR is not strictly layout-related, 2246 // but since we're already reading the style here, and since it's already aligned with 2247 // the rAF loop, this is the most appropriate place to do it perf-wise. 2248 // 2249 2250 function calculateEmojiGridStyle (node) { 2251 resizeObserverAction(node, abortSignal, () => { 2252 /* istanbul ignore next */ 2253 { // jsdom throws errors for this kind of fancy stuff 2254 // read all the style/layout calculations we need to make 2255 const style = getComputedStyle(refs.rootElement); 2256 const newNumColumns = parseInt(style.getPropertyValue('--num-columns'), 10); 2257 const newIsRtl = style.getPropertyValue('direction') === 'rtl'; 2258 2259 // write to state variables 2260 state.numColumns = newNumColumns; 2261 state.isRtl = newIsRtl; 2262 } 2263 }); 2264 } 2265 2266 // Re-run whenever the custom emoji in a category are shown/hidden. This is an optimization that simulates 2267 // what we'd get from `<img loading=lazy>` but without rendering an `<img>`. 2268 function updateOnIntersection (node) { 2269 intersectionObserverAction(node, abortSignal, (entries) => { 2270 for (const { target, isIntersecting } of entries) { 2271 target.classList.toggle('onscreen', isIntersecting); 2272 } 2273 }); 2274 } 2275 2276 // 2277 // Set or update the currentEmojis. Check for invalid ZWJ renderings 2278 // (i.e. double emoji). 2279 // 2280 2281 createEffect(() => { 2282 async function updateEmojis () { 2283 const { searchText, currentGroup, databaseLoaded, customEmoji } = state; 2284 if (!databaseLoaded) { 2285 state.currentEmojis = []; 2286 state.searchMode = false; 2287 } else if (searchText.length >= MIN_SEARCH_TEXT_LENGTH) { 2288 const newEmojis = await getEmojisBySearchQuery(searchText); 2289 if (state.searchText === searchText) { // if the situation changes asynchronously, do not update 2290 updateCurrentEmojis(newEmojis); 2291 updateSearchMode(true); 2292 } 2293 } else { // database is loaded and we're not in search mode, so we're in normal category mode 2294 const { id: currentGroupId } = currentGroup; 2295 // avoid race condition where currentGroupId is -1 and customEmoji is undefined/empty 2296 if (currentGroupId !== -1 || (customEmoji && customEmoji.length)) { 2297 const newEmojis = await getEmojisByGroup(currentGroupId); 2298 if (state.currentGroup.id === currentGroupId) { // if the situation changes asynchronously, do not update 2299 updateCurrentEmojis(newEmojis); 2300 updateSearchMode(false); 2301 } 2302 } 2303 } 2304 } 2305 2306 /* no await */ updateEmojis(); 2307 }); 2308 2309 const resetScrollTopInRaf = () => { 2310 rAF(() => resetScrollTopIfPossible(refs.tabpanelElement)); 2311 }; 2312 2313 // Some emojis have their ligatures rendered as two or more consecutive emojis 2314 // We want to treat these the same as unsupported emojis, so we compare their 2315 // widths against the baseline widths and remove them as necessary 2316 createEffect(() => { 2317 const { currentEmojis, emojiVersion } = state; 2318 const zwjEmojisToCheck = currentEmojis 2319 .filter(emoji => emoji.unicode) // filter custom emoji 2320 .filter(emoji => hasZwj(emoji) && !supportedZwjEmojis.has(emoji.unicode)); 2321 if (!emojiVersion && zwjEmojisToCheck.length) { 2322 // render now, check their length later 2323 updateCurrentEmojis(currentEmojis); 2324 rAF(() => checkZwjSupportAndUpdate(zwjEmojisToCheck)); 2325 } else { 2326 const newEmojis = emojiVersion ? currentEmojis : currentEmojis.filter(isZwjSupported); 2327 updateCurrentEmojis(newEmojis); 2328 // Reset scroll top to 0 when emojis change 2329 resetScrollTopInRaf(); 2330 } 2331 }); 2332 2333 function checkZwjSupportAndUpdate (zwjEmojisToCheck) { 2334 const allSupported = checkZwjSupport(zwjEmojisToCheck, refs.baselineEmoji, emojiToDomNode); 2335 if (allSupported) { 2336 // Even if all emoji are supported, we still need to reset the scroll top to 0 when emojis change 2337 resetScrollTopInRaf(); 2338 } else { 2339 // Force update. We only do this if there are any unsupported ZWJ characters since otherwise, 2340 // for browsers that support all emoji, it would be an unnecessary extra re-render. 2341 state.currentEmojis = [...state.currentEmojis]; 2342 } 2343 } 2344 2345 function isZwjSupported (emoji) { 2346 return !emoji.unicode || !hasZwj(emoji) || supportedZwjEmojis.get(emoji.unicode) 2347 } 2348 2349 async function filterEmojisByVersion (emojis) { 2350 const emojiSupportLevel = state.emojiVersion || await detectEmojiSupportLevel(); 2351 // !version corresponds to custom emoji 2352 return emojis.filter(({ version }) => !version || version <= emojiSupportLevel) 2353 } 2354 2355 async function summarizeEmojis (emojis) { 2356 return summarizeEmojisForUI(emojis, state.emojiVersion || await detectEmojiSupportLevel()) 2357 } 2358 2359 async function getEmojisByGroup (group) { 2360 // -1 is custom emoji 2361 const emoji = group === -1 ? state.customEmoji : await state.database.getEmojiByGroup(group); 2362 return summarizeEmojis(await filterEmojisByVersion(emoji)) 2363 } 2364 2365 async function getEmojisBySearchQuery (query) { 2366 return summarizeEmojis(await filterEmojisByVersion(await state.database.getEmojiBySearchQuery(query))) 2367 } 2368 2369 createEffect(() => { 2370 }); 2371 2372 // 2373 // Derive currentEmojisWithCategories from currentEmojis. This is always done even if there 2374 // are no categories, because it's just easier to code the HTML this way. 2375 // 2376 2377 createEffect(() => { 2378 function calculateCurrentEmojisWithCategories () { 2379 const { searchMode, currentEmojis } = state; 2380 if (searchMode) { 2381 return [ 2382 { 2383 category: '', 2384 emojis: currentEmojis 2385 } 2386 ] 2387 } 2388 const categoriesToEmoji = new Map(); 2389 for (const emoji of currentEmojis) { 2390 const category = emoji.category || ''; 2391 let emojis = categoriesToEmoji.get(category); 2392 if (!emojis) { 2393 emojis = []; 2394 categoriesToEmoji.set(category, emojis); 2395 } 2396 emojis.push(emoji); 2397 } 2398 return [...categoriesToEmoji.entries()] 2399 .map(([category, emojis]) => ({ category, emojis })) 2400 .sort((a, b) => state.customCategorySorting(a.category, b.category)) 2401 } 2402 2403 const newEmojisWithCategories = calculateCurrentEmojisWithCategories(); 2404 updateCurrentEmojisWithCategories(newEmojisWithCategories); 2405 }); 2406 2407 // 2408 // Handle active search item (i.e. pressing up or down while searching) 2409 // 2410 2411 createEffect(() => { 2412 state.activeSearchItemId = state.activeSearchItem !== -1 && state.currentEmojis[state.activeSearchItem].id; 2413 }); 2414 2415 // 2416 // Handle user input on the search input 2417 // 2418 2419 createEffect(() => { 2420 const { rawSearchText } = state; 2421 rIC(() => { 2422 state.searchText = (rawSearchText || '').trim(); // defer to avoid input delays, plus we can trim here 2423 state.activeSearchItem = -1; 2424 }); 2425 }); 2426 2427 function onSearchKeydown (event) { 2428 if (!state.searchMode || !state.currentEmojis.length) { 2429 return 2430 } 2431 2432 const goToNextOrPrevious = (previous) => { 2433 halt(event); 2434 state.activeSearchItem = incrementOrDecrement(previous, state.activeSearchItem, state.currentEmojis); 2435 }; 2436 2437 switch (event.key) { 2438 case 'ArrowDown': 2439 return goToNextOrPrevious(false) 2440 case 'ArrowUp': 2441 return goToNextOrPrevious(true) 2442 case 'Enter': 2443 if (state.activeSearchItem === -1) { 2444 // focus the first option in the list since the list must be non-empty at this point (it's verified above) 2445 state.activeSearchItem = 0; 2446 } else { // there is already an active search item 2447 halt(event); 2448 return clickEmoji(state.currentEmojis[state.activeSearchItem].id) 2449 } 2450 } 2451 } 2452 2453 // 2454 // Handle user input on nav 2455 // 2456 2457 function onNavClick (event) { 2458 const { target } = event; 2459 const closestTarget = target.closest('.nav-button'); 2460 /* istanbul ignore if */ 2461 if (!closestTarget) { 2462 return // This should never happen, but makes me nervous not to have it 2463 } 2464 const groupId = parseInt(closestTarget.dataset.groupId, 10); 2465 refs.searchElement.value = ''; // clear search box input 2466 state.rawSearchText = ''; 2467 state.searchText = ''; 2468 state.activeSearchItem = -1; 2469 state.currentGroupIndex = state.groups.findIndex(_ => _.id === groupId); 2470 } 2471 2472 function onNavKeydown (event) { 2473 const { target, key } = event; 2474 2475 const doFocus = el => { 2476 if (el) { 2477 halt(event); 2478 el.focus(); 2479 } 2480 }; 2481 2482 switch (key) { 2483 case 'ArrowLeft': 2484 return doFocus(target.previousElementSibling) 2485 case 'ArrowRight': 2486 return doFocus(target.nextElementSibling) 2487 case 'Home': 2488 return doFocus(target.parentElement.firstElementChild) 2489 case 'End': 2490 return doFocus(target.parentElement.lastElementChild) 2491 } 2492 } 2493 2494 async function getDetailForClickEvent (unicodeOrName) { 2495 const emoji = await state.database.getEmojiByUnicodeOrName(unicodeOrName); 2496 const emojiSummary = [...state.currentEmojis, ...state.currentFavorites] 2497 .find(_ => (_.id === unicodeOrName)); 2498 const skinTonedUnicode = emojiSummary.unicode && unicodeWithSkin(emojiSummary, state.currentSkinTone); 2499 await state.database.incrementFavoriteEmojiCount(unicodeOrName); 2500 return { 2501 emoji, 2502 skinTone: state.currentSkinTone, 2503 ...(skinTonedUnicode && { unicode: skinTonedUnicode }), 2504 ...(emojiSummary.name && { name: emojiSummary.name }) 2505 } 2506 } 2507 2508 // 2509 // Handle user input on an emoji 2510 // 2511 async function clickEmoji (unicodeOrName) { 2512 const promiseForDetail = getDetailForClickEvent(unicodeOrName); 2513 // sync event to work around a safari bug: https://bugs.webkit.org/show_bug.cgi?id=222262 2514 fireEvent('emoji-click-sync', promiseForDetail); 2515 // async event for most normal use cases that don't need to work around the safari bug 2516 fireEvent('emoji-click', await promiseForDetail); 2517 } 2518 2519 function onEmojiClick (event) { 2520 const { target } = event; 2521 /* istanbul ignore if */ 2522 if (!target.classList.contains('emoji')) { 2523 // This should never happen, but makes me nervous not to have it 2524 return 2525 } 2526 halt(event); 2527 const id = target.id.substring(4); // replace 'emo-' or 'fav-' prefix 2528 2529 /* no await */ clickEmoji(id); 2530 } 2531 2532 // 2533 // Handle user input on the skintone picker 2534 // 2535 2536 function changeSkinTone (skinTone) { 2537 state.currentSkinTone = skinTone; 2538 state.skinTonePickerExpanded = false; 2539 focus('skintone-button'); 2540 fireEvent('skin-tone-change', { skinTone }); 2541 /* no await */ state.database.setPreferredSkinTone(skinTone); 2542 } 2543 2544 function onSkinToneOptionsClick (event) { 2545 const { target: { id } } = event; 2546 const match = id && id.match(/^skintone-(\d)/); // skintone option format 2547 /* istanbul ignore if */ 2548 if (!match) { // not a skintone option 2549 return // This should never happen, but makes me nervous not to have it 2550 } 2551 halt(event); 2552 const skinTone = parseInt(match[1], 10); // remove 'skintone-' prefix 2553 changeSkinTone(skinTone); 2554 } 2555 2556 function onClickSkinToneButton (event) { 2557 state.skinTonePickerExpanded = !state.skinTonePickerExpanded; 2558 state.activeSkinTone = state.currentSkinTone; 2559 // this should always be true, since the button is obscured by the listbox, so this `if` is just to be sure 2560 if (state.skinTonePickerExpanded) { 2561 halt(event); 2562 rAF(() => focus('skintone-list')); 2563 } 2564 } 2565 2566 // To make the animation nicer, change the z-index of the skintone picker button 2567 // *after* the animation has played. This makes it appear that the picker box 2568 // is expanding "below" the button 2569 createEffect(() => { 2570 if (state.skinTonePickerExpanded) { 2571 refs.skinToneDropdown.addEventListener('transitionend', () => { 2572 state.skinTonePickerExpandedAfterAnimation = true; // eslint-disable-line no-unused-vars 2573 }, { once: true }); 2574 } else { 2575 state.skinTonePickerExpandedAfterAnimation = false; // eslint-disable-line no-unused-vars 2576 } 2577 }); 2578 2579 function onSkinToneOptionsKeydown (event) { 2580 // this should never happen, but makes me nervous not to have it 2581 /* istanbul ignore if */ 2582 if (!state.skinTonePickerExpanded) { 2583 return 2584 } 2585 const changeActiveSkinTone = async nextSkinTone => { 2586 halt(event); 2587 state.activeSkinTone = nextSkinTone; 2588 }; 2589 2590 switch (event.key) { 2591 case 'ArrowUp': 2592 return changeActiveSkinTone(incrementOrDecrement(true, state.activeSkinTone, state.skinTones)) 2593 case 'ArrowDown': 2594 return changeActiveSkinTone(incrementOrDecrement(false, state.activeSkinTone, state.skinTones)) 2595 case 'Home': 2596 return changeActiveSkinTone(0) 2597 case 'End': 2598 return changeActiveSkinTone(state.skinTones.length - 1) 2599 case 'Enter': 2600 // enter on keydown, space on keyup. this is just how browsers work for buttons 2601 // https://lists.w3.org/Archives/Public/w3c-wai-ig/2019JanMar/0086.html 2602 halt(event); 2603 return changeSkinTone(state.activeSkinTone) 2604 case 'Escape': 2605 halt(event); 2606 state.skinTonePickerExpanded = false; 2607 return focus('skintone-button') 2608 } 2609 } 2610 2611 function onSkinToneOptionsKeyup (event) { 2612 // this should never happen, but makes me nervous not to have it 2613 /* istanbul ignore if */ 2614 if (!state.skinTonePickerExpanded) { 2615 return 2616 } 2617 switch (event.key) { 2618 case ' ': 2619 // enter on keydown, space on keyup. this is just how browsers work for buttons 2620 // https://lists.w3.org/Archives/Public/w3c-wai-ig/2019JanMar/0086.html 2621 halt(event); 2622 return changeSkinTone(state.activeSkinTone) 2623 } 2624 } 2625 2626 async function onSkinToneOptionsFocusOut (event) { 2627 // On blur outside of the skintone listbox, collapse the skintone picker. 2628 const { relatedTarget } = event; 2629 // The `else` should never happen, but makes me nervous not to have it 2630 /* istanbul ignore else */ 2631 if (!relatedTarget || relatedTarget.id !== 'skintone-list') { 2632 state.skinTonePickerExpanded = false; 2633 } 2634 } 2635 2636 function onSearchInput (event) { 2637 state.rawSearchText = event.target.value; 2638 } 2639 2640 return { 2641 $set (newState) { 2642 assign(state, newState); 2643 }, 2644 $destroy () { 2645 abortController.abort(); 2646 } 2647 } 2648} 2649 2650const DEFAULT_DATA_SOURCE = 'https://cdn.jsdelivr.net/npm/emoji-picker-element-data@^1/en/emojibase/data.json'; 2651const DEFAULT_LOCALE = 'en'; 2652 2653var enI18n = { 2654 categoriesLabel: 'Categories', 2655 emojiUnsupportedMessage: 'Your browser does not support color emoji.', 2656 favoritesLabel: 'Favorites', 2657 loadingMessage: 'Loadingâ¦', 2658 networkErrorMessage: 'Could not load emoji.', 2659 regionLabel: 'Emoji picker', 2660 searchDescription: 'When search results are available, press up or down to select and enter to choose.', 2661 searchLabel: 'Search', 2662 searchResultsLabel: 'Search results', 2663 skinToneDescription: 'When expanded, press up or down to select and enter to choose.', 2664 skinToneLabel: 'Choose a skin tone (currently {skinTone})', 2665 skinTonesLabel: 'Skin tones', 2666 skinTones: [ 2667 'Default', 2668 'Light', 2669 'Medium-Light', 2670 'Medium', 2671 'Medium-Dark', 2672 'Dark' 2673 ], 2674 categories: { 2675 custom: 'Custom', 2676 'smileys-emotion': 'Smileys and emoticons', 2677 'people-body': 'People and body', 2678 'animals-nature': 'Animals and nature', 2679 'food-drink': 'Food and drink', 2680 'travel-places': 'Travel and places', 2681 activities: 'Activities', 2682 objects: 'Objects', 2683 symbols: 'Symbols', 2684 flags: 'Flags' 2685 } 2686}; 2687 2688var 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;--border-radius:0;--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);border-radius:var(--border-radius);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;scrollbar-gutter:stable;-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%}.emoji-menu.visibility-auto{content-visibility:auto;contain-intrinsic-size:calc(var(--num-columns)*var(--total-emoji-size)) calc(var(--num-rows)*var(--total-emoji-size))}.category{padding:var(--emoji-padding);font-size:var(--category-font-size);color:var(--category-font-color)}.emoji,button.emoji{font-size:var(--emoji-size);display:flex;align-items:center;justify-content:center;border-radius:100%;height:var(--total-emoji-size);width:var(--total-emoji-size);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)}.onscreen .custom-emoji::after{content:\"\";width:var(--emoji-size);height:var(--emoji-size);background-repeat:no-repeat;background-position:center center;background-size:contain;background-image:var(--custom-emoji-background)}.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{overflow-y:auto;scrollbar-gutter:stable;display:flex;flex-direction:row;border-top:var(--border-size) solid var(--border-color);contain:content}.message{padding:var(--emoji-padding)}"; 2689 2690const PROPS = [ 2691 'customEmoji', 2692 'customCategorySorting', 2693 'database', 2694 'dataSource', 2695 'i18n', 2696 'locale', 2697 'skinToneEmoji', 2698 'emojiVersion' 2699]; 2700 2701// Styles injected ourselves, so we can declare the FONT_FAMILY variable in one place 2702const EXTRA_STYLES = `:host{--emoji-font-family:${FONT_FAMILY}}`; 2703 2704class PickerElement extends HTMLElement { 2705 constructor (props) { 2706 super(); 2707 this.attachShadow({ mode: 'open' }); 2708 const style = document.createElement('style'); 2709 style.textContent = baseStyles + EXTRA_STYLES; 2710 this.shadowRoot.appendChild(style); 2711 this._ctx = { 2712 // Set defaults 2713 locale: DEFAULT_LOCALE, 2714 dataSource: DEFAULT_DATA_SOURCE, 2715 skinToneEmoji: DEFAULT_SKIN_TONE_EMOJI, 2716 customCategorySorting: DEFAULT_CATEGORY_SORTING, 2717 customEmoji: null, 2718 i18n: enI18n, 2719 emojiVersion: null, 2720 ...props 2721 }; 2722 // Handle properties set before the element was upgraded 2723 for (const prop of PROPS) { 2724 if (prop !== 'database' && Object.prototype.hasOwnProperty.call(this, prop)) { 2725 this._ctx[prop] = this[prop]; 2726 delete this[prop]; 2727 } 2728 } 2729 this._dbFlush(); // wait for a flush before creating the db, in case the user calls e.g. a setter or setAttribute 2730 } 2731 2732 connectedCallback () { 2733 rescueElementPrototype(this); 2734 // The _cmp may be defined if the component was immediately disconnected and then reconnected. In that case, 2735 // do nothing (preserve the state) 2736 if (!this._cmp) { 2737 this._cmp = createRoot(this.shadowRoot, this._ctx); 2738 } 2739 } 2740 2741 disconnectedCallback () { 2742 rescueElementPrototype(this); 2743 // Check in a microtask if the element is still connected. If so, treat this as a "move" rather than a disconnect 2744 // Inspired by Vue: https://vuejs.org/guide/extras/web-components.html#building-custom-elements-with-vue 2745 qM(() => { 2746 // this._cmp may be defined if connect-disconnect-connect-disconnect occurs synchronously 2747 if (!this.isConnected && this._cmp) { 2748 this._cmp.$destroy(); 2749 this._cmp = undefined; 2750 2751 const { database } = this._ctx; 2752 database.close() 2753 // only happens if the database failed to load in the first place, so we don't care 2754 .catch(err => console.error(err)); 2755 } 2756 }); 2757 } 2758 2759 static get observedAttributes () { 2760 return ['locale', 'data-source', 'skin-tone-emoji', 'emoji-version'] // complex objects aren't supported, also use kebab-case 2761 } 2762 2763 attributeChangedCallback (attrName, oldValue, newValue) { 2764 this._set( 2765 // convert from kebab-case to camelcase 2766 // see https://github.com/sveltejs/svelte/issues/3852#issuecomment-665037015 2767 attrName.replace(/-([a-z])/g, (_, up) => up.toUpperCase()), 2768 // convert string attribute to float if necessary 2769 attrName === 'emoji-version' ? parseFloat(newValue) : newValue 2770 ); 2771 } 2772 2773 _set (prop, newValue) { 2774 this._ctx[prop] = newValue; 2775 if (this._cmp) { 2776 this._cmp.$set({ [prop]: newValue }); 2777 } 2778 if (['locale', 'dataSource'].includes(prop)) { 2779 this._dbFlush(); 2780 } 2781 } 2782 2783 _dbCreate () { 2784 const { locale, dataSource, database } = this._ctx; 2785 // only create a new database if we really need to 2786 if (!database || database.locale !== locale || database.dataSource !== dataSource) { 2787 this._set('database', new Database({ locale, dataSource })); 2788 } 2789 } 2790 2791 // Update the Database in one microtask if the locale/dataSource change. We do one microtask 2792 // so we don't create two Databases if e.g. both the locale and the dataSource change 2793 _dbFlush () { 2794 qM(() => ( 2795 this._dbCreate() 2796 )); 2797 } 2798} 2799 2800const definitions = {}; 2801 2802for (const prop of PROPS) { 2803 definitions[prop] = { 2804 get () { 2805 if (prop === 'database') { 2806 // in rare cases, the microtask may not be flushed yet, so we need to instantiate the DB 2807 // now if the user is asking for it 2808 this._dbCreate(); 2809 } 2810 return this._ctx[prop] 2811 }, 2812 set (val) { 2813 if (prop === 'database') { 2814 throw new Error('database is read-only') 2815 } 2816 this._set(prop, val); 2817 } 2818 }; 2819} 2820 2821Object.defineProperties(PickerElement.prototype, definitions); 2822 2823// See https://jakearchibald.com/2025/firefox-custom-elements-iframes-bug/ 2824// TODO: remove when the Firefox bug is fixed: https://bugzilla.mozilla.org/show_bug.cgi?id=1502814 2825function rescueElementPrototype (element) { 2826 /* istanbul ignore if */ 2827 if (!(element instanceof PickerElement)) { 2828 Object.setPrototypeOf(element, customElements.get(element.tagName.toLowerCase()).prototype); 2829 } 2830} 2831 2832/* istanbul ignore else */ 2833if (!customElements.get('emoji-picker')) { // if already defined, do nothing (e.g. same script imported twice) 2834 customElements.define('emoji-picker', PickerElement); 2835}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.