/**
* emojiPicker.js β Monochrome icon picker (no colored emojis).
* Curated set of common icons as inline SVGs. The PICKER shows monochrome SVGs,
* and β crucially β every character it INSERTS is one with a real monochrome
* (text) presentation. On insert we append U+FE0E (VARIATION SELECTOR-15) so the
* glyph renders flat/text, not as a system color emoji β so the RECIPIENT of an
* email/message sees a non-colored symbol too, not just the sender. Pure-emoji
* faces (π, π, π) have no text form and are intentionally excluded.
*/
// Each entry: [char, label, svgPath OR svg]
// SVG icons matching Lucide style (24x24 viewBox, 2 stroke)
const I = (path) => ``;
// Text variation selector β appended to chars that might render as color emoji,
// asks the browser to use text (monochrome) presentation if available.
const VS15 = '\uFE0E';
const EMOJI_GROUPS = [
{
name: 'Faces & Hearts',
// Only chars with a genuine monochrome (text) presentation. VS15 is appended
// on insert (see _insertEmoji) so they render flat for the recipient too.
// Pure-emoji faces (grin/cry/sunglasses/thumbs) have no text form, so they're
// omitted β there is no way to send them non-colored as plain text.
items: [
['β»', 'grin', I('')],
['β‘', 'heart-outline', I('')],
['β
', 'star', I('')],
['β', 'star-outline', I('')],
['β¦', 'sparkle', I('')],
['β½', 'moon', I('')],
],
},
{
name: 'Checks & Marks',
items: [
['β', 'check', I('')],
['β', 'cross', I('')],
['β', 'cross-heavy', I('')],
['β
', 'star-filled', I('')],
['β', 'star-empty', I('')],
['β', 'dot', I('')],
['β', 'circle', I('')],
['β ', 'square-filled', I('')],
['β‘', 'square-empty', I('')],
['β', 'diamond', I('')],
['β', 'diamond-empty', I('')],
['β ', 'dagger', I('')],
],
},
{
name: 'Arrows',
items: [
['β', 'arrow-right', I('')],
['β', 'arrow-left', I('')],
['β', 'arrow-up', I('')],
['β', 'arrow-down', I('')],
['β', 'arrow-r-dbl', I('')],
['β', 'arrow-l-dbl', I('')],
],
},
{
name: 'Math & Punctuation',
items: [
['Β±', 'plus-minus', I('')],
['Γ', 'multiply', I('')],
['Γ·', 'divide', I('')],
['β', 'approx', I('')],
['β ', 'not-equal', I('')],
['β€', 'lte', I('')],
['β₯', 'gte', I('')],
['β', 'infinity', I('')],
['Ο', 'pi', I('')],
['Ξ£', 'sum', I('')],
['β', 'delta', I('')],
['β', 'root', I('')],
['Β°', 'degree', I('')],
['Β§', 'section', I('')],
['ΒΆ', 'pilcrow', I('')],
['β’', 'bullet', I('')],
['β¦', 'ellipsis', I('')],
['β', 'em-dash', I('')],
['Β«', 'quote-l', I('')],
['Β»', 'quote-r', I('')],
['"', 'quote-dbl', I('')],
],
},
{
name: 'Currency & Misc',
items: [
['β¬', 'euro', I('β¬')],
['Β£', 'pound', I('Β£')],
['Β₯', 'yen', I('Β₯')],
['$', 'dollar', I('$')],
['Β’', 'cent', I('Β’')],
['%', 'percent', I('%')],
['β°', 'per-mille', I('β°')],
['β', 'number', I('β')],
],
},
];
let _pickerEl = null;
let _pickerOpenedAt = 0;
let _targetEl = null;
let _closeOnOutsideClick = null;
let _closeOnEscape = null;
// For contenteditable targets we snapshot the caret/selection when the picker
// opens, since focusing the picker's search box collapses the live selection.
let _savedRange = null;
// `target` may be a textarea element id (string) or a resolver function that
// returns the live target element β the latter lets a caller switch between a
// textarea and a contenteditable (e.g. plain markdown vs. WYSIWYG email).
export function createEmojiButton(target) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'emoji-picker-btn';
btn.title = 'Insert icon';
btn.innerHTML = '';
// Don't steal focus from the editor on press β keeps the caret/selection so
// the emoji lands where the user was typing.
btn.addEventListener('mousedown', (e) => e.preventDefault());
btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const el = (typeof target === 'function') ? target() : document.getElementById(target);
if (!el) return;
togglePicker(btn, el);
});
return btn;
}
function togglePicker(anchor, target) {
const now = Date.now();
if (_pickerEl) {
// Ignore the duplicate/ghost click mobile fires right after opening, which
// would otherwise re-toggle the picker shut the instant it appears.
if (now - _pickerOpenedAt < 400) return;
_closePicker();
return;
}
_targetEl = target;
_savedRange = null;
if (target.isContentEditable) {
const sel = window.getSelection();
if (sel && sel.rangeCount) {
const r = sel.getRangeAt(0);
if (target.contains(r.commonAncestorContainer)) _savedRange = r.cloneRange();
}
}
_pickerEl = _buildPicker();
_pickerOpenedAt = now;
document.body.appendChild(_pickerEl);
const rect = anchor.getBoundingClientRect();
_pickerEl.style.position = 'fixed';
_pickerEl.style.top = (rect.bottom + 4) + 'px';
_pickerEl.style.left = rect.left + 'px';
_pickerEl.style.zIndex = '10000';
requestAnimationFrame(() => {
const pr = _pickerEl.getBoundingClientRect();
if (pr.right > window.innerWidth - 8) {
_pickerEl.style.left = Math.max(8, window.innerWidth - pr.width - 8) + 'px';
}
// Always open downward. If it would run past the bottom, cap its height so
// it scrolls internally instead of flipping up (which got cut off at top).
const avail = window.innerHeight - rect.bottom - 12;
if (pr.height > avail) {
_pickerEl.style.maxHeight = Math.max(160, avail) + 'px';
}
});
const close = (e) => {
// Ignore the ghost/duplicate click mobile fires right after opening.
if (e && e.type === 'click' && Date.now() - _pickerOpenedAt < 400) return;
if (_pickerEl && !_pickerEl.contains(e.target) && e.target !== anchor && !anchor.contains(e.target)) {
_closePicker();
}
};
_closeOnOutsideClick = close;
setTimeout(() => document.addEventListener('click', close, true), 10);
_closeOnEscape = (e) => {
if (e.key !== 'Escape' || !_pickerEl) return;
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation?.();
_closePicker();
};
document.addEventListener('keydown', _closeOnEscape, true);
}
function _closePicker() {
if (_pickerEl) {
_pickerEl.remove();
_pickerEl = null;
}
if (_closeOnOutsideClick) {
document.removeEventListener('click', _closeOnOutsideClick, true);
_closeOnOutsideClick = null;
}
if (_closeOnEscape) {
document.removeEventListener('keydown', _closeOnEscape, true);
_closeOnEscape = null;
}
}
function _buildPicker() {
const el = document.createElement('div');
el.className = 'emoji-picker';
const search = document.createElement('input');
search.type = 'text';
search.placeholder = 'Searchβ¦';
search.className = 'emoji-picker-search';
el.appendChild(search);
const groupsContainer = document.createElement('div');
groupsContainer.className = 'emoji-picker-groups';
el.appendChild(groupsContainer);
function render(filter = '') {
groupsContainer.innerHTML = '';
const f = filter.toLowerCase();
for (const group of EMOJI_GROUPS) {
const filtered = f
? group.items.filter(item => item[1].toLowerCase().includes(f) || item[0].includes(filter))
: group.items;
if (filtered.length === 0) continue;
const groupDiv = document.createElement('div');
groupDiv.className = 'emoji-picker-group';
const header = document.createElement('div');
header.className = 'emoji-picker-group-name';
header.textContent = group.name;
groupDiv.appendChild(header);
const grid = document.createElement('div');
grid.className = 'emoji-picker-grid';
for (const item of filtered) {
const [char, label, svg] = item;
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'emoji-picker-item';
btn.title = label;
btn.innerHTML = svg;
btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
_insertEmoji(char);
_closePicker();
});
grid.appendChild(btn);
}
groupDiv.appendChild(grid);
groupsContainer.appendChild(groupDiv);
}
}
render();
search.addEventListener('input', () => render(search.value.trim()));
setTimeout(() => search.focus(), 50);
return el;
}
function _insertEmoji(char) {
if (!_targetEl) return;
// Force monochrome (text) presentation for the recipient by appending the
// text variation selector U+FE0E. It only affects chars that *have* an emoji
// presentation (e.g. β₯ βΆ β€ β© β); for plain ASCII it's pointless, so we skip
// those. This is why the inserted glyph is non-colored on the other end too,
// not just in our own (already-SVG) picker UI.
const cp = char.codePointAt(0);
const ins = cp >= 0x80 ? char + VS15 : char;
// Contenteditable (e.g. WYSIWYG email body) β insert at the saved caret.
if (_targetEl.isContentEditable) {
_targetEl.focus();
let range = _savedRange;
if (!range) {
range = document.createRange();
range.selectNodeContents(_targetEl);
range.collapse(false);
}
range.deleteContents();
const node = document.createTextNode(ins);
range.insertNode(node);
range.setStartAfter(node);
range.collapse(true);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
_savedRange = range.cloneRange();
_targetEl.dispatchEvent(new Event('input', { bubbles: true }));
return;
}
const start = _targetEl.selectionStart || 0;
const end = _targetEl.selectionEnd || 0;
const before = _targetEl.value.substring(0, start);
const after = _targetEl.value.substring(end);
_targetEl.value = before + ins + after;
_targetEl.selectionStart = _targetEl.selectionEnd = start + ins.length;
_targetEl.focus();
_targetEl.dispatchEvent(new Event('input', { bubbles: true }));
}
export default { createEmojiButton };