/** * 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) => `${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 };