SHA256
- Эмодзи-пикер: js/components/emoji-picker.js + каталог telegram-emoji-*.js (smileys/people/animals_nature/food_drink/travel_places/activity/objects/ symbols/flags) + 631 ассет в assets/emoji (7.2 МБ). - Интеграция пикера: chat-view.js, app.js, index.html, styles/components.css. - Дев-скрипты генерации превью + THIRD_PARTY_NOTICES. - Работа НЕ завершена (WIP), сохранено как чекпоинт. С origin (+56) пока НЕ мержится — большой мерж будет после завершения, отдельным шагом. - VERSION: client 1.2.263. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
264 lines
8.5 KiB
JavaScript
264 lines
8.5 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 image = document.createElement('img');
|
|
image.className = 'telegram-emoji-image';
|
|
image.alt = '';
|
|
image.decoding = 'async';
|
|
image.setAttribute('aria-hidden', 'true');
|
|
|
|
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;
|
|
}
|