@@ -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'])