Improve skin tone rendering and add site Twemoji fallback
This commit is contained in:
@@ -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 ? '<span class="absolute top-1.5 left-1.5 z-10 text-yellow-300 text-sm leading-none select-none pointer-events-none" title="Favorited">★</span>' : ''}
|
||||
<a href="/emoji/${encodeURIComponent(item.slug)}" class="absolute inset-0 flex items-center justify-center pb-10">
|
||||
<span class="leading-none" style="font-size:var(--emoji-size)">${esc(renderedEmoji)}</span>
|
||||
<span class="emoji-glyph leading-none" style="font-size:var(--emoji-size)">${esc(renderedEmoji)}</span>
|
||||
</a>
|
||||
<div class="emoji-card-bar absolute bottom-0 left-0 right-0 border-t border-white/10 bg-black/20 px-2 py-1.5 flex items-start gap-1">
|
||||
<span class="emoji-name-clamp text-[10px] text-gray-300 text-left flex-1">${esc(item.name)}</span>
|
||||
@@ -894,10 +1174,11 @@
|
||||
: `
|
||||
${favoriteActive ? '<span class="absolute top-1.5 left-1.5 z-10 text-yellow-300 text-sm leading-none select-none pointer-events-none" title="Favorited">★</span>' : ''}
|
||||
<a href="/emoji/${encodeURIComponent(item.slug)}" class="absolute inset-0 flex items-center justify-center">
|
||||
<span class="leading-none" style="font-size:var(--emoji-size)">${esc(renderedEmoji)}</span>
|
||||
<span class="emoji-glyph leading-none" style="font-size:var(--emoji-size)">${esc(renderedEmoji)}</span>
|
||||
</a>
|
||||
<button type="button" class="copy-btn emoji-card-copy absolute top-1.5 right-1.5 w-7 h-7 rounded bg-black/35 hover:bg-brand-ocean/40 border border-white/20 text-[11px] text-gray-100 hover:text-white transition-colors" title="Copy emoji">⧉</button>
|
||||
`;
|
||||
renderEmojiGlyph(card.querySelector('.emoji-glyph'), renderedEmoji, { item });
|
||||
const copyBtn = card.querySelector('.copy-btn');
|
||||
if (copyBtn) {
|
||||
copyBtn.addEventListener('click', (e) => {
|
||||
|
||||
Reference in New Issue
Block a user