SHA256
- Пикер: анимированные webp грузились с raw.githubusercontent.com (в репо их нет) — у пользователей с VPN/операторскими блокировками тап по эмодзи давал сетевую ошибку и «моргание»/подмену картинки. Теперь всегда локальные статичные превью, без внешних запросов и pointer capture; мёртвая константа GitHub-URL удалена. Возврат анимаций — только локальным паком. - Чат (тач): открытие эмодзи-пикера прячет клавиатуру (blur), вставка эмодзи не фокусирует textarea на сенсорных устройствах — поле больше не перекрывается клавиатурой (на десктопе фокус как раньше). - Глобальный обработчик ошибок: кросс-ориджин «Script error.» без файла/стека больше не показывается алертом (лог и captureClientError остаются) — устраняет пугающую плашку на Xiaomi/Safari. - VERSION: client 1.2.327. Проверено в превью: тап по эмодзи — 0 запросов к githubusercontent, 😈 вставляется ровно как 😈, картинка кнопки стабильна; фильтр алерта: Script error. — тихо, реальная ошибка — алерт показывается. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
277 lines
8.9 KiB
JavaScript
277 lines
8.9 KiB
JavaScript
import {
|
|
TELEGRAM_EMOJI_GROUPS,
|
|
getTelegramEmojiAssetUrl,
|
|
getTelegramEmojiPreviewUrl,
|
|
} from './telegram-emoji-catalog.js?v=202607151545';
|
|
|
|
const RECENT_STORAGE_KEY = 'shine-ui-recent-emojis-v1';
|
|
const MAX_RECENT = 28;
|
|
const ALL_EMOJI_ITEMS = TELEGRAM_EMOJI_GROUPS.flatMap((group) => group.items);
|
|
const EMOJI_ASSET_BY_CHAR = new Map(ALL_EMOJI_ITEMS.map((item) => [item.emoji, item]));
|
|
const TELEGRAM_EMOJI_MATCHER = new RegExp(
|
|
[...EMOJI_ASSET_BY_CHAR.keys()]
|
|
.sort((left, right) => right.length - left.length)
|
|
.map((emoji) => emoji.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&'))
|
|
.join('|'),
|
|
'gu',
|
|
);
|
|
const DEFAULT_RECENT = ALL_EMOJI_ITEMS.slice(0, 7).map((item) => item.emoji);
|
|
let activeAnimation = null;
|
|
|
|
function loadRecent() {
|
|
try {
|
|
const parsed = JSON.parse(localStorage.getItem(RECENT_STORAGE_KEY) || '[]');
|
|
return Array.isArray(parsed)
|
|
? parsed.filter((item) => typeof item === 'string' && item).slice(0, MAX_RECENT)
|
|
: [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function saveRecent(items) {
|
|
try {
|
|
localStorage.setItem(RECENT_STORAGE_KEY, JSON.stringify(items.slice(0, MAX_RECENT)));
|
|
} catch {
|
|
// Selection remains usable when browser storage is unavailable.
|
|
}
|
|
}
|
|
|
|
function appendAnimatedAsset(root, item) {
|
|
const previewUrl = item.previewUrl || getTelegramEmojiPreviewUrl(item.file);
|
|
const animatedUrl = item.animatedUrl || getTelegramEmojiAssetUrl(item.file);
|
|
const hasAnimation = animatedUrl && animatedUrl !== previewUrl;
|
|
const image = document.createElement('img');
|
|
image.className = 'telegram-emoji-image';
|
|
image.alt = '';
|
|
image.decoding = 'async';
|
|
image.setAttribute('aria-hidden', 'true');
|
|
|
|
// Без отдельного анимационного ассета — просто статичная картинка,
|
|
// без hover/tap-подмены src и pointer capture (лишние запросы и «моргание»).
|
|
if (!hasAnimation) {
|
|
image.src = previewUrl;
|
|
image.onerror = () => {
|
|
root.classList.add('is-error');
|
|
image.removeAttribute('src');
|
|
};
|
|
root.append(image);
|
|
return;
|
|
}
|
|
|
|
let isActive = false;
|
|
let sourceVersion = 0;
|
|
|
|
const setSource = (source) => {
|
|
const requestedVersion = sourceVersion + 1;
|
|
sourceVersion = requestedVersion;
|
|
root.classList.remove('is-error');
|
|
image.onload = () => {
|
|
if (requestedVersion !== sourceVersion) return;
|
|
if (isActive) root.classList.remove('is-loading');
|
|
};
|
|
image.onerror = () => {
|
|
if (requestedVersion !== sourceVersion) return;
|
|
if (isActive) {
|
|
isActive = false;
|
|
root.classList.remove('is-active', 'is-loading');
|
|
if (activeAnimation?.root === root) activeAnimation = null;
|
|
setSource(previewUrl);
|
|
return;
|
|
}
|
|
root.classList.add('is-error');
|
|
image.removeAttribute('src');
|
|
};
|
|
image.src = source;
|
|
};
|
|
|
|
const deactivate = () => {
|
|
isActive = false;
|
|
root.classList.remove('is-active', 'is-loading', 'is-error');
|
|
if (activeAnimation?.root === root) activeAnimation = null;
|
|
setSource(previewUrl);
|
|
};
|
|
|
|
const activate = () => {
|
|
if (activeAnimation?.root && activeAnimation.root !== root) {
|
|
activeAnimation.deactivate();
|
|
}
|
|
activeAnimation = { root, deactivate };
|
|
isActive = true;
|
|
root.classList.add('is-active', 'is-loading');
|
|
setSource(animatedUrl);
|
|
};
|
|
|
|
const onPointerOver = (event) => {
|
|
if (event.relatedTarget && root.contains(event.relatedTarget)) return;
|
|
if (event.pointerType === 'mouse') activate();
|
|
};
|
|
|
|
const onPointerOut = (event) => {
|
|
if (event.relatedTarget && root.contains(event.relatedTarget)) return;
|
|
if (event.pointerType === 'mouse') deactivate();
|
|
};
|
|
|
|
const onPointerDown = (event) => {
|
|
if (event.pointerType === 'mouse') return;
|
|
root.setPointerCapture?.(event.pointerId);
|
|
activate();
|
|
};
|
|
|
|
const onPointerEnd = (event) => {
|
|
if (event.pointerType === 'mouse') return;
|
|
if (root.hasPointerCapture?.(event.pointerId)) root.releasePointerCapture?.(event.pointerId);
|
|
deactivate();
|
|
};
|
|
|
|
root.addEventListener('pointerover', onPointerOver);
|
|
root.addEventListener('pointerout', onPointerOut);
|
|
root.addEventListener('pointerdown', onPointerDown);
|
|
root.addEventListener('pointerup', onPointerEnd);
|
|
root.addEventListener('pointercancel', onPointerEnd);
|
|
root.addEventListener('lostpointercapture', deactivate);
|
|
setSource(previewUrl);
|
|
root.append(image);
|
|
}
|
|
|
|
export function stopAllTelegramEmojiAnimations() {
|
|
activeAnimation?.deactivate();
|
|
activeAnimation = null;
|
|
}
|
|
|
|
function createEmojiButton(item, onSelect) {
|
|
const button = document.createElement('button');
|
|
button.type = 'button';
|
|
button.className = 'emoji-picker-item';
|
|
button.setAttribute('aria-label', `Add ${item.emoji}`);
|
|
button.title = item.emoji;
|
|
button.addEventListener('pointerdown', (event) => event.preventDefault());
|
|
button.addEventListener('click', () => onSelect(item.emoji));
|
|
|
|
const preview = document.createElement('span');
|
|
preview.className = 'emoji-picker-preview';
|
|
appendAnimatedAsset(preview, item);
|
|
button.append(preview);
|
|
return button;
|
|
}
|
|
|
|
export function isTelegramAnimatedEmoji(emoji) {
|
|
return EMOJI_ASSET_BY_CHAR.has(emoji);
|
|
}
|
|
|
|
export function createTelegramAnimatedEmoji(emoji, { className = '' } = {}) {
|
|
const item = EMOJI_ASSET_BY_CHAR.get(emoji);
|
|
const root = document.createElement('span');
|
|
root.className = `telegram-emoji ${className}`.trim();
|
|
root.setAttribute('aria-label', emoji);
|
|
root.setAttribute('role', 'img');
|
|
|
|
if (!item) {
|
|
root.textContent = emoji;
|
|
return root;
|
|
}
|
|
|
|
appendAnimatedAsset(root, item);
|
|
return root;
|
|
}
|
|
|
|
export function appendTelegramAnimatedText(root, text, { className = '' } = {}) {
|
|
const source = String(text || '');
|
|
let cursor = 0;
|
|
let found = false;
|
|
|
|
TELEGRAM_EMOJI_MATCHER.lastIndex = 0;
|
|
for (const match of source.matchAll(TELEGRAM_EMOJI_MATCHER)) {
|
|
const index = Number(match.index || 0);
|
|
if (index > cursor) root.append(document.createTextNode(source.slice(cursor, index)));
|
|
root.append(createTelegramAnimatedEmoji(match[0], { className }));
|
|
cursor = index + match[0].length;
|
|
found = true;
|
|
}
|
|
|
|
if (!found) {
|
|
root.textContent = source;
|
|
return false;
|
|
}
|
|
|
|
if (cursor < source.length) root.append(document.createTextNode(source.slice(cursor)));
|
|
return true;
|
|
}
|
|
|
|
export function createEmojiPicker({ onSelect }) {
|
|
const root = document.createElement('section');
|
|
root.className = 'emoji-picker';
|
|
root.setAttribute('aria-label', 'Emoji picker');
|
|
|
|
const recentSection = document.createElement('section');
|
|
recentSection.className = 'emoji-picker-section emoji-picker-recent';
|
|
const recentTitle = document.createElement('strong');
|
|
recentTitle.className = 'emoji-picker-section-title';
|
|
recentTitle.textContent = 'Recent';
|
|
const recentGrid = document.createElement('div');
|
|
recentGrid.className = 'emoji-picker-grid';
|
|
recentSection.append(recentTitle, recentGrid);
|
|
|
|
const categoryTabs = document.createElement('div');
|
|
categoryTabs.className = 'emoji-picker-tabs';
|
|
categoryTabs.setAttribute('role', 'tablist');
|
|
|
|
const categorySection = document.createElement('section');
|
|
categorySection.className = 'emoji-picker-section';
|
|
const categoryTitle = document.createElement('strong');
|
|
categoryTitle.className = 'emoji-picker-section-title';
|
|
const categoryGrid = document.createElement('div');
|
|
categoryGrid.className = 'emoji-picker-grid';
|
|
categorySection.append(categoryTitle, categoryGrid);
|
|
|
|
let activeGroup = TELEGRAM_EMOJI_GROUPS[0];
|
|
const tabs = new Map();
|
|
|
|
const rememberAndSelect = (emoji) => {
|
|
const recent = [emoji, ...loadRecent().filter((item) => item !== emoji)];
|
|
saveRecent(recent);
|
|
renderRecent();
|
|
onSelect?.(emoji);
|
|
};
|
|
|
|
const renderRecent = () => {
|
|
const recentItems = (loadRecent().length ? loadRecent() : DEFAULT_RECENT)
|
|
.map((emoji) => EMOJI_ASSET_BY_CHAR.get(emoji))
|
|
.filter(Boolean);
|
|
stopAllTelegramEmojiAnimations();
|
|
recentGrid.replaceChildren(...recentItems.map((item) => createEmojiButton(item, rememberAndSelect)));
|
|
};
|
|
|
|
const renderGroup = () => {
|
|
categoryTitle.textContent = activeGroup.label;
|
|
stopAllTelegramEmojiAnimations();
|
|
categoryGrid.replaceChildren(...activeGroup.items.map((item) => createEmojiButton(item, rememberAndSelect)));
|
|
tabs.forEach((tab, groupId) => {
|
|
const active = groupId === activeGroup.id;
|
|
tab.classList.toggle('is-active', active);
|
|
tab.setAttribute('aria-selected', String(active));
|
|
});
|
|
};
|
|
|
|
TELEGRAM_EMOJI_GROUPS.forEach((group) => {
|
|
const tab = document.createElement('button');
|
|
tab.type = 'button';
|
|
tab.className = 'emoji-picker-tab';
|
|
tab.textContent = group.icon;
|
|
tab.title = group.label;
|
|
tab.setAttribute('aria-label', group.label);
|
|
tab.setAttribute('role', 'tab');
|
|
tab.addEventListener('click', () => {
|
|
activeGroup = group;
|
|
renderGroup();
|
|
});
|
|
tabs.set(group.id, tab);
|
|
categoryTabs.append(tab);
|
|
});
|
|
|
|
root.append(recentSection, categoryTabs, categorySection);
|
|
renderRecent();
|
|
renderGroup();
|
|
return root;
|
|
}
|