Files
SHiNE-server/shine-UI/js/components/emoji-picker.js

277 lines
8.8 KiB
JavaScript

import {
TWEMOJI_GROUPS,
getTwemojiAssetUrl,
getTwemojiPreviewUrl,
} from './twemoji-catalog.js?v=202607151545';
const RECENT_STORAGE_KEY = 'shine-ui-recent-emojis-v1';
const MAX_RECENT = 28;
const ALL_EMOJI_ITEMS = TWEMOJI_GROUPS.flatMap((group) => group.items);
const EMOJI_ASSET_BY_CHAR = new Map(ALL_EMOJI_ITEMS.map((item) => [item.emoji, item]));
const TWEMOJI_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 || getTwemojiPreviewUrl(item.emoji);
const animatedUrl = item.animatedUrl || getTwemojiAssetUrl(item.emoji);
const hasAnimation = animatedUrl && animatedUrl !== previewUrl;
const image = document.createElement('img');
image.className = 'twemoji-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 stopAllTwemojiAnimations() {
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 hasTwemojiAsset(emoji) {
return EMOJI_ASSET_BY_CHAR.has(emoji);
}
export function createTwemojiAsset(emoji, { className = '' } = {}) {
const item = EMOJI_ASSET_BY_CHAR.get(emoji);
const root = document.createElement('span');
root.className = `twemoji ${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 appendTwemojiText(root, text, { className = '' } = {}) {
const source = String(text || '');
let cursor = 0;
let found = false;
TWEMOJI_MATCHER.lastIndex = 0;
for (const match of source.matchAll(TWEMOJI_MATCHER)) {
const index = Number(match.index || 0);
if (index > cursor) root.append(document.createTextNode(source.slice(cursor, index)));
root.append(createTwemojiAsset(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 = TWEMOJI_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);
stopAllTwemojiAnimations();
recentGrid.replaceChildren(...recentItems.map((item) => createEmojiButton(item, rememberAndSelect)));
};
const renderGroup = () => {
categoryTitle.textContent = activeGroup.label;
stopAllTwemojiAnimations();
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));
});
};
TWEMOJI_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;
}