Improve skin tone rendering and add site Twemoji fallback
This commit is contained in:
@@ -161,7 +161,7 @@
|
||||
</div>
|
||||
|
||||
@if($supportsTone)
|
||||
<div class="glass-card rounded-xl p-3">
|
||||
<div id="tone-panel" class="glass-card rounded-xl p-3">
|
||||
<div class="text-xs font-mono text-gray-500 mb-2">Skin tone</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button type="button" data-tone="off" class="tone-chip px-3 py-1.5 rounded-lg border border-white/10 text-sm text-gray-200 bg-white/5 hover:bg-white/10">Default</button>
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user