Files
rustpad/static/js/emoji-picker.js
T

121 lines
3.9 KiB
JavaScript

import { EMOJI_GROUPS } from "@rustpad/emoji-data";
const RECENTS_KEY = "rustpad:recent-emojis";
const MAX_RECENTS = 24;
function loadRecents() {
try {
const values = JSON.parse(localStorage.getItem(RECENTS_KEY) || "[]");
return Array.isArray(values) ? values.filter(value => typeof value === "string").slice(0, MAX_RECENTS) : [];
} catch {
return [];
}
}
function saveRecent(emoji) {
const next = [emoji, ...loadRecents().filter(value => value !== emoji)].slice(0, MAX_RECENTS);
localStorage.setItem(RECENTS_KEY, JSON.stringify(next));
return next;
}
function normalize(value) {
return String(value || "").toLocaleLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
}
function insertAtSelection(editor, text) {
const start = editor.selectionStart;
editor.setRangeText(text, start, editor.selectionEnd, "end");
editor.focus();
editor.dispatchEvent(new Event("input", { bubbles: true }));
}
export function bindEmojiPicker({ editor, details, search, categories, grid, empty }) {
if (!editor || !details || !search || !categories || !grid) return;
let activeGroup = EMOJI_GROUPS[0]?.name || "";
let recents = loadRecents();
const groups = () => {
const recentItems = recents.map(value => {
for (const group of EMOJI_GROUPS) {
const found = group.items.find(item => item.emoji === value);
if (found) return found;
}
return { emoji: value, name: "Recent emoji", keywords: "recent" };
});
return recentItems.length ? [{ name: "Recently Used", items: recentItems }, ...EMOJI_GROUPS] : EMOJI_GROUPS;
};
function renderCategories() {
const available = groups();
if (!available.some(group => group.name === activeGroup)) activeGroup = available[0]?.name || "";
categories.replaceChildren(...available.map(group => {
const button = document.createElement("button");
button.type = "button";
button.className = "emoji-category";
button.dataset.emojiGroup = group.name;
button.textContent = group.items[0]?.emoji || "•";
button.title = group.name;
button.setAttribute("aria-label", group.name);
button.setAttribute("aria-pressed", String(group.name === activeGroup));
return button;
}));
}
function renderGrid() {
const query = normalize(search.value.trim());
let items;
if (query) {
items = EMOJI_GROUPS.flatMap(group => group.items).filter(item =>
normalize(`${item.name} ${item.keywords}`).includes(query)
);
} else {
items = groups().find(group => group.name === activeGroup)?.items || [];
}
const fragment = document.createDocumentFragment();
for (const item of items) {
const button = document.createElement("button");
button.type = "button";
button.className = "emoji-item";
button.dataset.emoji = item.emoji;
button.title = item.name;
button.setAttribute("aria-label", item.name);
button.textContent = item.emoji;
fragment.append(button);
}
grid.replaceChildren(fragment);
if (empty) empty.hidden = items.length > 0;
}
function render() {
renderCategories();
renderGrid();
}
details.addEventListener("toggle", () => {
if (!details.open) return;
render();
requestAnimationFrame(() => search.focus());
});
search.addEventListener("input", renderGrid);
categories.addEventListener("click", event => {
const button = event.target.closest("[data-emoji-group]");
if (!button) return;
activeGroup = button.dataset.emojiGroup;
search.value = "";
render();
});
grid.addEventListener("click", event => {
const button = event.target.closest("[data-emoji]");
if (!button) return;
insertAtSelection(editor, button.dataset.emoji);
recents = saveRecent(button.dataset.emoji);
renderCategories();
});
document.addEventListener("pointerdown", event => {
if (details.open && !details.contains(event.target)) details.removeAttribute("open");
});
}