/* * 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-groups"; 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(); }); if (details.open) { render(); requestAnimationFrame(() => search.focus()); } }