Files
PixelandClaude Opus 4.8 266a74ef79 WIP: эмодзи-пикер (Telegram-набор) — чекпоинт незавершённой работы
- Эмодзи-пикер: 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>
2026-07-15 21:28:45 +03:00

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;
}