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) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="preload" href="/assets/fonts/PlusJakartaSans-Regular.ttf" as="font" type="font/ttf" crossorigin>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/twemoji@14.0.2/dist/twemoji.min.js"></script>
|
||||
@vite(['resources/js/app.js'])
|
||||
<script>
|
||||
tailwind.config = {
|
||||
|
||||
@@ -7,8 +7,8 @@ android {
|
||||
applicationId "com.dewemoji.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 3
|
||||
versionName "1.0.2"
|
||||
versionCode 4
|
||||
versionName "1.0.3"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
Reference in New Issue
Block a user