157 lines
5.2 KiB
JavaScript
157 lines
5.2 KiB
JavaScript
/*
|
|
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
|
* Source-Available Code / Dual-Licensed.
|
|
*
|
|
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
|
* Commercial or production use requires a valid paid license.
|
|
* See LICENSE file in repository root for details.
|
|
*/
|
|
|
|
import { EMOJI_GROUPS } from "@rustpad/emoji-data";
|
|
import { t } from "@rustpad/i18n";
|
|
|
|
const RECENTS_KEY = "rustpad:recent-emojis";
|
|
const MAX_RECENTS = 24;
|
|
const GROUP_KEYS = new Map([
|
|
["Smileys & Emotion", "emoji.group.smileysEmotion"],
|
|
["People & Body", "emoji.group.peopleBody"],
|
|
["Animals & Nature", "emoji.group.animalsNature"],
|
|
["Food & Drink", "emoji.group.foodDrink"],
|
|
["Travel & Places", "emoji.group.travelPlaces"],
|
|
["Activities", "emoji.group.activities"],
|
|
["Objects", "emoji.group.objects"],
|
|
["Symbols", "emoji.group.symbols"],
|
|
["Flags", "emoji.group.flags"],
|
|
]);
|
|
|
|
function groupLabel(name) {
|
|
if (name === "Recently Used") return t("emoji.recentGroup", {}, "Recently Used");
|
|
const key = GROUP_KEYS.get(name);
|
|
return key ? t(key, {}, name) : name;
|
|
}
|
|
|
|
function itemLabel(item) {
|
|
return t("emoji.itemLabel", { emoji: item.emoji }, `Emoji ${item.emoji}`);
|
|
}
|
|
|
|
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: t("emoji.recentItem", {}, "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 || "•";
|
|
const label = groupLabel(group.name);
|
|
button.title = label;
|
|
button.setAttribute("aria-label", label);
|
|
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.map(item => ({ item, group })))
|
|
.filter(({ item, group }) => normalize(`${item.name} ${item.keywords} ${groupLabel(group.name)}`).includes(query))
|
|
.map(({ item }) => item);
|
|
} 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;
|
|
const label = itemLabel(item);
|
|
button.title = label;
|
|
button.setAttribute("aria-label", label);
|
|
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");
|
|
});
|
|
document.addEventListener("rustpad:languagechange", () => {
|
|
if (details.open) render();
|
|
});
|
|
}
|