From 87e947ee957f9b352b44f431a9f71221769f5cc0 Mon Sep 17 00:00:00 2001 From: Dwindi Ramadhana Date: Mon, 23 Feb 2026 05:33:44 +0700 Subject: [PATCH] Improve skin tone rendering and add site Twemoji fallback --- .../views/site/emoji-detail.blade.php | 282 ++++++++++++++++- app/resources/views/site/home.blade.php | 299 +++++++++++++++++- app/resources/views/site/layout.blade.php | 1 + dewemoji-capacitor/android/app/build.gradle | 4 +- 4 files changed, 571 insertions(+), 15 deletions(-) diff --git a/app/resources/views/site/emoji-detail.blade.php b/app/resources/views/site/emoji-detail.blade.php index e9e2cf6..2376366 100644 --- a/app/resources/views/site/emoji-detail.blade.php +++ b/app/resources/views/site/emoji-detail.blade.php @@ -161,7 +161,7 @@ @if($supportsTone) -
+
Skin tone
@@ -339,6 +339,151 @@ const DETAIL_SUBCATEGORY = @json($subcategory); const DETAIL_SUPPORTS_TONE = @json($supportsTone); const DETAIL_VARIANTS_LIST = @json(array_values($toneVariants)); const TONE_VARIANTS = @json($toneVariants); +const TONE_CHAR_BY_SLUG = { + light: '\u{1F3FB}', + 'medium-light': '\u{1F3FC}', + medium: '\u{1F3FD}', + 'medium-dark': '\u{1F3FE}', + dark: '\u{1F3FF}', +}; +const TONE_CPS = new Set([0x1F3FB, 0x1F3FC, 0x1F3FD, 0x1F3FE, 0x1F3FF]); +const MODIFIABLE_BASES = new Set([ + 0x1F9D1, 0x1F468, 0x1F469, 0x1F466, 0x1F467, 0x1F476, + 0x1F575, 0x1F93C, 0x26F9, 0x1F3CB, 0x1F93D, 0x1F93E, 0x1F926, +]); +const NOT_TONEABLE_BASES = new Set([ + 0x1F9BE, 0x1F9BF, 0x1FAC0, 0x1F9E0, 0x1FAC1, 0x1F9B7, 0x1F9B4, + 0x1F440, 0x1F441, 0x1F445, 0x1F444, 0x1FAE6, + 0x1F9DE, 0x1F9DF, 0x1F9CC, 0x26F7, 0x1F3C2, +]); +const NON_TONEABLE_NAME_PATTERNS = [ + /\bspeaking head\b/i, + /\bbust in silhouette\b/i, + /\bbusts in silhouette\b/i, + /\bfootprints?\b/i, + /\bfingerprint\b/i, + /\bfamily\b/i, + /\bpeople hugging\b/i, + /\bpeople with bunny ears\b/i, + /\bpeople wrestling\b/i, + /\bpeople fencing\b/i, + /\bperson fencing\b/i, + /\bmen wrestling\b/i, + /\bwomen wrestling\b/i, + /\bmerman\b/i, + /\bmermaid\b/i, + /\bdeaf man\b/i, + /\bdeaf woman\b/i, + /\bman: beard\b/i, + /\bwoman: beard\b/i, + /\bperson running facing right\b/i, + /\bperson walking facing right\b/i, + /\bperson kneeling facing right\b/i, +]; +const ROLE_TONEABLE_NAME_PATTERNS = [ + /\btechnologist\b/i, + /\bscientist\b/i, + /\boffice worker\b/i, + /\bfactory worker\b/i, + /\bmechanic\b/i, + /\bcook\b/i, + /\bfarmer\b/i, + /\bjudge\b/i, + /\bteacher\b/i, + /\bstudent\b/i, + /\bhealth worker\b/i, + /\bsinger\b/i, + /\bastronaut\b/i, + /\bfirefighter\b/i, + /\bfacepalming\b/i, + /\bdancing\b/i, + /\bdetective\b/i, + /\bfencing\b/i, + /\bbouncing ball\b/i, + /\blifting weights\b/i, + /\bwrestling\b/i, +]; +const TONE_CATEGORY_ALLOWLIST = new Set(['people & body', 'activities']); +const ANDROID_TONE_RENDER_BLOCKLIST_PATTERNS = [ + /\bperson fencing\b/i, + /\bmen wrestling\b/i, + /\bwomen wrestling\b/i, +]; +const ANDROID_TONE_RENDER_BLOCKLIST_SLUGS = new Set([ + 'person-fencing', + 'men-wrestling', + 'women-wrestling', +]); +const ANDROID_TONE_IMAGE_FALLBACK_SLUGS = new Set([ + 'person-fencing', + 'men-wrestling', + 'women-wrestling', +]); +const IS_ANDROID_RENDERER = (() => { + try { + if (/Android/i.test(navigator.userAgent || '')) return true; + const platform = window.Capacitor?.getPlatform?.(); + return platform === 'android'; + } catch (_) { + return /Android/i.test(navigator.userAgent || ''); + } +})(); + +function twemojiSvgUrlFromCodepoints(cp) { + return `https://twemoji.maxcdn.com/v/latest/svg/${cp}.svg`; +} + +function notoEmojiSvgUrlFromCodepoints(cp) { + return `https://cdn.jsdelivr.net/gh/googlefonts/noto-emoji@main/svg/emoji_u${String(cp || '').replace(/-/g, '_')}.svg`; +} + +function shouldUseDetailToneImageFallback(emojiStr, tone = 'off') { + if (!IS_ANDROID_RENDERER) return false; + if (!tone || tone === 'off') return false; + if (!ANDROID_TONE_IMAGE_FALLBACK_SLUGS.has(String(DETAIL_SLUG || '').toLowerCase())) return false; + return /\u200D/.test(String(emojiStr || '')); +} + +function renderDetailHeroEmoji(emojiStr, tone = 'off') { + const hero = document.getElementById('emoji-hero-symbol'); + if (!hero) return; + const glyph = String(emojiStr || ''); + if (!glyph) { + hero.textContent = ''; + return; + } + if (!shouldUseDetailToneImageFallback(glyph, tone) || !window.twemoji?.convert?.toCodePoint) { + hero.textContent = glyph; + return; + } + let cp = ''; + try { + cp = window.twemoji.convert.toCodePoint(glyph); + } catch (_) { + hero.textContent = glyph; + return; + } + if (!cp) { + hero.textContent = glyph; + return; + } + const img = new Image(); + img.alt = glyph; + img.draggable = false; + img.decoding = 'async'; + img.className = 'inline-block align-middle select-none'; + img.style.width = '1em'; + img.style.height = '1em'; + img.style.objectFit = 'contain'; + img.onerror = () => { + img.onerror = () => { + hero.textContent = glyph; + }; + img.src = twemojiSvgUrlFromCodepoints(cp); + }; + img.src = notoEmojiSvgUrlFromCodepoints(cp); + hero.replaceChildren(img); +} let currentDisplayEmoji = SYMBOL_DEFAULT; function getStoredTone() { @@ -351,13 +496,17 @@ function setStoredTone(value) { function emojiByTone(tone) { if (!tone || tone === 'off') return SYMBOL_DEFAULT; - return TONE_VARIANTS[tone] || SYMBOL_DEFAULT; + if (!isDetailToneAllowed()) return stripSkinTone(SYMBOL_DEFAULT) || SYMBOL_DEFAULT; + const toneChar = TONE_CHAR_BY_SLUG[tone]; + if (!toneChar) return TONE_VARIANTS[tone] || SYMBOL_DEFAULT; + const base = stripSkinTone(SYMBOL_DEFAULT); + if (!canApplyToneTo(base)) return base; + return applyToneSmart(base, toneChar) || TONE_VARIANTS[tone] || base || SYMBOL_DEFAULT; } function applyTone(tone) { currentDisplayEmoji = emojiByTone(tone); - const hero = document.getElementById('emoji-hero-symbol'); - if (hero) hero.textContent = currentDisplayEmoji; + renderDetailHeroEmoji(currentDisplayEmoji, tone); document.querySelectorAll('.tone-chip').forEach((chip) => { const active = chip.dataset.tone === tone; chip.classList.toggle('bg-brand-ocean/20', active); @@ -387,6 +536,125 @@ function isRecentEmojiToken(value) { } } +function isDetailNameNonToneable() { + return NON_TONEABLE_NAME_PATTERNS.some((rx) => rx.test(DETAIL_NAME || '')); +} + +function isDetailRoleToneable() { + return ROLE_TONEABLE_NAME_PATTERNS.some((rx) => rx.test(DETAIL_NAME || '')); +} + +function isDetailToneAllowed() { + if (!DETAIL_SUPPORTS_TONE) return false; + const category = String(DETAIL_CATEGORY || '').toLowerCase(); + if (!TONE_CATEGORY_ALLOWLIST.has(category)) return false; + if (isDetailNameNonToneable()) return false; + if (IS_ANDROID_RENDERER) { + const slug = String(DETAIL_SLUG || '').toLowerCase(); + const usesImageFallback = ANDROID_TONE_IMAGE_FALLBACK_SLUGS.has(slug); + if (!usesImageFallback && ANDROID_TONE_RENDER_BLOCKLIST_SLUGS.has(slug)) return false; + if (!usesImageFallback && ANDROID_TONE_RENDER_BLOCKLIST_PATTERNS.some((rx) => rx.test(DETAIL_NAME || ''))) return false; + } + const base = stripSkinTone(SYMBOL_DEFAULT); + if (!base || !canApplyToneTo(base)) return false; + + const name = String(DETAIL_NAME || '').toLowerCase(); + const looksGenderedRole = + category === 'people & body' && + (name.startsWith('man ') || name.startsWith('woman ') || /[:\-,]\s*(man|woman)\b/.test(name)); + if (looksGenderedRole && !isDetailRoleToneable()) return false; + + return true; +} + +function stripSkinTone(emojiChar) { + return String(emojiChar || '').replace(/[\u{1F3FB}-\u{1F3FF}]/gu, ''); +} + +function containsZWJ(s) { + return /\u200D/.test(String(s || '')); +} + +function countHumans(s) { + let n = 0; + for (const ch of Array.from(String(s || ''))) { + const cp = ch.codePointAt(0); + if (cp === 0x1F468 || cp === 0x1F469 || cp === 0x1F9D1) n++; + } + return n; +} + +function canApplyToneTo(s) { + if (!s) return false; + try { + const cp0 = String(s).codePointAt(0); + if (NOT_TONEABLE_BASES.has(cp0)) return false; + } catch (_) {} + if (!containsZWJ(s)) return true; + return countHumans(s) <= 2; +} + +function toCodePoints(str) { + const out = []; + for (const ch of String(str || '')) out.push(ch.codePointAt(0)); + return out; +} + +function fromCodePoints(arr) { + return String.fromCodePoint(...arr); +} + +function applyToneSmart(emojiChar, toneChar) { + if (!emojiChar || !toneChar) return emojiChar; + const toneCp = String(toneChar).codePointAt(0); + if (!TONE_CPS.has(toneCp)) return emojiChar; + + const cps = toCodePoints(emojiChar); + for (const cp of cps) { + if (TONE_CPS.has(cp)) return emojiChar; + } + + const VS16 = 0xFE0F; + const ZWJ = 0x200D; + const idxs = []; + for (let i = 0; i < cps.length; i += 1) { + if (MODIFIABLE_BASES.has(cps[i])) idxs.push(i); + } + + if (idxs.length === 0) { + if (cps.includes(ZWJ)) return emojiChar; + let pos = 1; + let rightStart = pos; + if (cps[1] === VS16) { + rightStart = 2; + } + const left = cps.slice(0, pos); + left.push(toneCp); + return fromCodePoints(left.concat(cps.slice(rightStart))); + } + + if (idxs.length === 2) { + const out = cps.slice(); + const insertAfter = (baseIdx) => { + let pos = baseIdx + 1; + if (out[pos] === VS16) out.splice(pos, 1); + out.splice(pos, 0, toneCp); + }; + insertAfter(idxs[1]); + insertAfter(idxs[0]); + return fromCodePoints(out); + } + + let pos = idxs[0] + 1; + let rightStart = pos; + if (cps[pos] === VS16) { + rightStart = pos + 1; + } + const left = cps.slice(0, pos); + left.push(toneCp); + return fromCodePoints(left.concat(cps.slice(rightStart))); +} + function saveRecent(items) { const clean = items.filter(isRecentEmojiToken); localStorage.setItem(RECENT_KEY, JSON.stringify(clean.slice(0, 8))); @@ -504,6 +772,12 @@ document.addEventListener('keydown', (e) => { }); (() => { + const tonePanel = document.getElementById('tone-panel'); + if (!isDetailToneAllowed()) { + if (tonePanel) tonePanel.classList.add('hidden'); + applyTone('off'); + return; + } const initialTone = getStoredTone(); applyTone(initialTone); document.querySelectorAll('.tone-chip').forEach((chip) => { diff --git a/app/resources/views/site/home.blade.php b/app/resources/views/site/home.blade.php index b15f9c9..b87b449 100644 --- a/app/resources/views/site/home.blade.php +++ b/app/resources/views/site/home.blade.php @@ -383,6 +383,158 @@ 'medium-dark': 3, dark: 4, }; + const toneCharBySlug = { + light: '\u{1F3FB}', + 'medium-light': '\u{1F3FC}', + medium: '\u{1F3FD}', + 'medium-dark': '\u{1F3FE}', + dark: '\u{1F3FF}', + }; + const TONE_CPS = new Set([0x1F3FB, 0x1F3FC, 0x1F3FD, 0x1F3FE, 0x1F3FF]); + const MODIFIABLE_BASES = new Set([ + 0x1F9D1, 0x1F468, 0x1F469, 0x1F466, 0x1F467, 0x1F476, + 0x1F575, 0x1F93C, 0x26F9, 0x1F3CB, 0x1F93D, 0x1F93E, 0x1F926, + ]); + const NOT_TONEABLE_BASES = new Set([ + 0x1F9BE, 0x1F9BF, 0x1FAC0, 0x1F9E0, 0x1FAC1, 0x1F9B7, 0x1F9B4, + 0x1F440, 0x1F441, 0x1F445, 0x1F444, 0x1FAE6, + 0x1F9DE, 0x1F9DF, 0x1F9CC, 0x26F7, 0x1F3C2, + ]); + const NON_TONEABLE_NAME_PATTERNS = [ + /\bspeaking head\b/i, + /\bbust in silhouette\b/i, + /\bbusts in silhouette\b/i, + /\bfootprints?\b/i, + /\bfingerprint\b/i, + /\bfamily\b/i, + /\bpeople hugging\b/i, + /\bpeople with bunny ears\b/i, + /\bpeople wrestling\b/i, + /\bpeople fencing\b/i, + /\bperson fencing\b/i, + /\bmen wrestling\b/i, + /\bwomen wrestling\b/i, + /\bmerman\b/i, + /\bmermaid\b/i, + /\bdeaf man\b/i, + /\bdeaf woman\b/i, + /\bman: beard\b/i, + /\bwoman: beard\b/i, + /\bperson running facing right\b/i, + /\bperson walking facing right\b/i, + /\bperson kneeling facing right\b/i, + ]; + const ROLE_TONEABLE_NAME_PATTERNS = [ + /\btechnologist\b/i, + /\bscientist\b/i, + /\boffice worker\b/i, + /\bfactory worker\b/i, + /\bmechanic\b/i, + /\bcook\b/i, + /\bfarmer\b/i, + /\bjudge\b/i, + /\bteacher\b/i, + /\bstudent\b/i, + /\bhealth worker\b/i, + /\bsinger\b/i, + /\bastronaut\b/i, + /\bfirefighter\b/i, + /\bfacepalming\b/i, + /\bdancing\b/i, + /\bdetective\b/i, + /\bfencing\b/i, + /\bbouncing ball\b/i, + /\blifting weights\b/i, + /\bwrestling\b/i, + ]; + const TONE_CATEGORY_ALLOWLIST = new Set(['people & body', 'activities']); + const ANDROID_TONE_RENDER_BLOCKLIST_PATTERNS = [ + /\bperson fencing\b/i, + /\bmen wrestling\b/i, + /\bwomen wrestling\b/i, + ]; + const ANDROID_TONE_RENDER_BLOCKLIST_SLUGS = new Set([ + 'person-fencing', + 'men-wrestling', + 'women-wrestling', + ]); + const ANDROID_TONE_IMAGE_FALLBACK_SLUGS = new Set([ + 'person-fencing', + 'men-wrestling', + 'women-wrestling', + ]); + const IS_ANDROID_RENDERER = (() => { + try { + if (/Android/i.test(navigator.userAgent || '')) return true; + const platform = window.Capacitor?.getPlatform?.(); + return platform === 'android'; + } catch (_) { + return /Android/i.test(navigator.userAgent || ''); + } + })(); + + function twemojiSvgUrlFromCodepoints(cp) { + return `https://twemoji.maxcdn.com/v/latest/svg/${cp}.svg`; + } + + function notoEmojiSvgUrlFromCodepoints(cp) { + return `https://cdn.jsdelivr.net/gh/googlefonts/noto-emoji@main/svg/emoji_u${String(cp || '').replace(/-/g, '_')}.svg`; + } + + function shouldUseToneImageFallback(item, emojiStr) { + if (!IS_ANDROID_RENDERER) return false; + const glyph = String(emojiStr || ''); + if (!glyph) return false; + const hasToneModifier = /[\u{1F3FB}-\u{1F3FF}]/u.test(glyph); + if (!hasToneModifier) return false; + if (selectedTone() === 'off' && item) return false; + const slug = String(item?.slug || '').toLowerCase(); + if (slug) { + if (!ANDROID_TONE_IMAGE_FALLBACK_SLUGS.has(slug)) return false; + } + return /\u200D/.test(glyph); + } + + function renderEmojiGlyph(container, emojiStr, { item = null } = {}) { + if (!container) return; + const glyph = String(emojiStr || ''); + if (!glyph) { + container.textContent = ''; + return; + } + if (!shouldUseToneImageFallback(item, glyph) || !window.twemoji?.convert?.toCodePoint) { + container.textContent = glyph; + return; + } + let cp = ''; + try { + cp = window.twemoji.convert.toCodePoint(glyph); + } catch (_) { + container.textContent = glyph; + return; + } + if (!cp) { + container.textContent = glyph; + return; + } + const img = new Image(); + img.alt = glyph; + img.draggable = false; + img.decoding = 'async'; + img.loading = 'lazy'; + img.className = 'inline-block align-middle select-none'; + img.style.width = '1em'; + img.style.height = '1em'; + img.style.objectFit = 'contain'; + img.onerror = () => { + img.onerror = () => { + container.textContent = glyph; + }; + img.src = twemojiSvgUrlFromCodepoints(cp); + }; + img.src = notoEmojiSvgUrlFromCodepoints(cp); + container.replaceChildren(img); + } function isLabelsEnabled() { return localStorage.getItem(labelsStorageKey) === '1'; @@ -520,11 +672,139 @@ function emojiWithTone(item) { const tone = selectedTone(); if (tone === 'off') return item.emoji; - if (!item?.supports_skin_tone) return item.emoji; - const variants = Array.isArray(item?.variants) ? item.variants : []; - const idx = toneIndexMap[tone]; - if (typeof idx === 'number' && variants[idx]) return variants[idx]; - return item.emoji_base || item.emoji; + if (!isToneAllowedForItem(item)) return item.emoji_base || item.emoji; + const toneChar = toneCharBySlug[tone]; + if (!toneChar) return item.emoji; + const base = stripSkinTone(item?.emoji_base || item?.emoji || ''); + if (!base) return item.emoji; + const toned = applyToneSmart(base, toneChar); + return toned || base; + } + + function isNameNonToneable(item) { + const name = String(item?.name || '').toLowerCase(); + return NON_TONEABLE_NAME_PATTERNS.some((rx) => rx.test(name)); + } + + function isRoleToneable(item) { + const name = String(item?.name || '').toLowerCase(); + return ROLE_TONEABLE_NAME_PATTERNS.some((rx) => rx.test(name)); + } + + function isToneAllowedForItem(item) { + if (!item?.supports_skin_tone) return false; + const category = String(item?.category || '').toLowerCase(); + if (!TONE_CATEGORY_ALLOWLIST.has(category)) return false; + if (isNameNonToneable(item)) return false; + if (IS_ANDROID_RENDERER) { + const name = String(item?.name || '').toLowerCase(); + const slug = String(item?.slug || '').toLowerCase(); + const usesImageFallback = ANDROID_TONE_IMAGE_FALLBACK_SLUGS.has(slug); + if (!usesImageFallback && ANDROID_TONE_RENDER_BLOCKLIST_SLUGS.has(slug)) return false; + if (!usesImageFallback && ANDROID_TONE_RENDER_BLOCKLIST_PATTERNS.some((rx) => rx.test(name))) return false; + } + + const base = stripSkinTone(item?.emoji_base || item?.emoji || ''); + if (!base || !canApplyToneTo(base)) return false; + + const name = String(item?.name || '').toLowerCase(); + const looksGenderedRole = + category === 'people & body' && + (name.startsWith('man ') || name.startsWith('woman ') || /[:\-,]\s*(man|woman)\b/.test(name)); + if (looksGenderedRole && !isRoleToneable(item)) return false; + + return true; + } + + function stripSkinTone(emojiChar) { + return String(emojiChar || '').replace(/[\u{1F3FB}-\u{1F3FF}]/gu, ''); + } + + function containsZWJ(s) { + return /\u200D/.test(String(s || '')); + } + + function countHumans(s) { + let n = 0; + for (const ch of Array.from(String(s || ''))) { + const cp = ch.codePointAt(0); + if (cp === 0x1F468 || cp === 0x1F469 || cp === 0x1F9D1) n++; + } + return n; + } + + function canApplyToneTo(s) { + if (!s) return false; + try { + const cp0 = String(s).codePointAt(0); + if (NOT_TONEABLE_BASES.has(cp0)) return false; + } catch (_) {} + if (!containsZWJ(s)) return true; + return countHumans(s) <= 2; + } + + function toCodePoints(str) { + const out = []; + for (const ch of String(str || '')) out.push(ch.codePointAt(0)); + return out; + } + + function fromCodePoints(arr) { + return String.fromCodePoint(...arr); + } + + function applyToneSmart(emojiChar, toneChar) { + if (!emojiChar || !toneChar) return emojiChar; + const toneCp = String(toneChar).codePointAt(0); + if (!TONE_CPS.has(toneCp)) return emojiChar; + + const cps = toCodePoints(emojiChar); + for (const cp of cps) { + if (TONE_CPS.has(cp)) return emojiChar; + } + + const VS16 = 0xFE0F; + const ZWJ = 0x200D; + + const idxs = []; + for (let i = 0; i < cps.length; i += 1) { + if (MODIFIABLE_BASES.has(cps[i])) idxs.push(i); + } + + if (idxs.length === 0) { + if (cps.includes(ZWJ)) return emojiChar; + let pos = 1; + let rightStart = pos; + if (cps[1] === VS16) { + // Android renders a tone swatch on some sequences when VS16 stays before the modifier. + // Build modifier sequence as base + tone (+ rest), dropping the immediate VS16. + rightStart = 2; + } + const left = cps.slice(0, pos); + left.push(toneCp); + return fromCodePoints(left.concat(cps.slice(rightStart))); + } + + if (idxs.length === 2) { + const out = cps.slice(); + const insertAfter = (baseIdx) => { + let pos = baseIdx + 1; + if (out[pos] === VS16) out.splice(pos, 1); + out.splice(pos, 0, toneCp); + }; + insertAfter(idxs[1]); + insertAfter(idxs[0]); + return fromCodePoints(out); + } + + let pos = idxs[0] + 1; + let rightStart = pos; + if (cps[pos] === VS16) { + rightStart = pos + 1; + } + const left = cps.slice(0, pos); + left.push(toneCp); + return fromCodePoints(left.concat(cps.slice(rightStart))); } function applyGridDensity(level) { @@ -650,7 +930,7 @@ source.forEach((emoji) => { const btn = document.createElement('button'); btn.className = 'w-8 h-8 rounded bg-white/5 hover:bg-white/10 flex items-center justify-center text-lg'; - btn.textContent = emoji; + renderEmojiGlyph(btn, emoji); btn.addEventListener('click', () => window.copyEmoji(emoji)); recentList.appendChild(btn); }); @@ -724,7 +1004,7 @@ a.href = `/emoji/${encodeURIComponent(fav.slug)}`; a.className = 'w-8 h-8 rounded bg-white/5 hover:bg-white/10 flex items-center justify-center text-lg'; a.title = fav.name || fav.slug; - a.textContent = fav.emoji; + renderEmojiGlyph(a, fav.emoji); favoritesList.appendChild(a); }); } @@ -884,7 +1164,7 @@ ? ` ${favoriteActive ? '' : ''} - ${esc(renderedEmoji)} + ${esc(renderedEmoji)}
${esc(item.name)} @@ -894,10 +1174,11 @@ : ` ${favoriteActive ? '' : ''} - ${esc(renderedEmoji)} + ${esc(renderedEmoji)} `; + renderEmojiGlyph(card.querySelector('.emoji-glyph'), renderedEmoji, { item }); const copyBtn = card.querySelector('.copy-btn'); if (copyBtn) { copyBtn.addEventListener('click', (e) => { diff --git a/app/resources/views/site/layout.blade.php b/app/resources/views/site/layout.blade.php index e24e51a..eed7293 100644 --- a/app/resources/views/site/layout.blade.php +++ b/app/resources/views/site/layout.blade.php @@ -71,6 +71,7 @@ + @vite(['resources/js/app.js'])