PageSourceSearch

https://jayreddinjr-jr-odysseus-hf.hf.space/static/js/emojiPicker.js

js jayreddinjr-jr-odysseus-hf.hf.space collected 2026-10-04 16:59:50 UTC 15,440 bytes, 311 lines download raw bytes

1/**
2 * emojiPicker.js — Monochrome icon picker (no colored emojis).
3 * Curated set of common icons as inline SVGs. The PICKER shows monochrome SVGs,
4 * and — crucially — every character it INSERTS is one with a real monochrome
5 * (text) presentation. On insert we append U+FE0E (VARIATION SELECTOR-15) so the
6 * glyph renders flat/text, not as a system color emoji — so the RECIPIENT of an
7 * email/message sees a non-colored symbol too, not just the sender. Pure-emoji
8 * faces (😂, 👍, 😎) have no text form and are intentionally excluded.
9 */
10
11// Each entry: [char, label, svgPath OR svg]
12// SVG icons matching Lucide style (24x24 viewBox, 2 stroke)
13const I = (path) => `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${path}</svg>`;
14
15// Text variation selector — appended to chars that might render as color emoji,
16// asks the browser to use text (monochrome) presentation if available.
17const VS15 = '\uFE0E';
18
19const EMOJI_GROUPS = [
20  {
21    name: 'Faces & Hearts',
22    // Only chars with a genuine monochrome (text) presentation. VS15 is appended
23    // on insert (see _insertEmoji) so they render flat for the recipient too.
24    // Pure-emoji faces (grin/cry/sunglasses/thumbs) have no text form, so they're
25    // omitted — there is no way to send them non-colored as plain text.
26    items: [
27      ['☻', 'grin', I('<circle cx="12" cy="12" r="10"/><path d="M7 14 C 7 18, 17 18, 17 14 Z"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/>')],
28      ['♡', 'heart-outline', I('<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"/>')],
29      ['★', 'star', I('<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" fill="currentColor" stroke="none"/>')],
30      ['☆', 'star-outline', I('<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>')],
31      ['✦', 'sparkle', I('<polygon points="12 2 14 10 22 12 14 14 12 22 10 14 2 12 10 10" fill="currentColor" stroke="none"/>')],
32      ['☽', 'moon', I('<path d="M21 12.8A9 9 0 1 1 11.2 3 7 7 0 0 0 21 12.8z"/>')],
33    ],
34  },
35  {
36    name: 'Checks & Marks',
37    items: [
38      ['✓', 'check', I('<polyline points="20 6 9 17 4 12"/>')],
39      ['✗', 'cross', I('<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>')],
40      ['✘', 'cross-heavy', I('<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>')],
41      ['★', 'star-filled', I('<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>')],
42      ['☆', 'star-empty', I('<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>')],
43      ['●', 'dot', I('<circle cx="12" cy="12" r="6" fill="currentColor" stroke="none"/>')],
44      ['○', 'circle', I('<circle cx="12" cy="12" r="8"/>')],
45      ['■', 'square-filled', I('<rect x="6" y="6" width="12" height="12" fill="currentColor" stroke="none"/>')],
46      ['□', 'square-empty', I('<rect x="5" y="5" width="14" height="14"/>')],
47      ['◆', 'diamond', I('<polygon points="12 3 21 12 12 21 3 12"/>')],
48      ['◇', 'diamond-empty', I('<polygon points="12 3 21 12 12 21 3 12"/>')],
49      ['†', 'dagger', I('<line x1="12" y1="4" x2="12" y2="20"/><line x1="8" y1="8" x2="16" y2="8"/>')],
50    ],
51  },
52  {
53    name: 'Arrows',
54    items: [
55      ['→', 'arrow-right', I('<line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/>')],
56      ['←', 'arrow-left', I('<line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/>')],
57      ['↑', 'arrow-up', I('<line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/>')],
58      ['↓', 'arrow-down', I('<line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/>')],
59      ['⇒', 'arrow-r-dbl', I('<polyline points="10 5 17 12 10 19"/><polyline points="6 5 13 12 6 19"/>')],
60      ['⇐', 'arrow-l-dbl', I('<polyline points="14 5 7 12 14 19"/><polyline points="18 5 11 12 18 19"/>')],
61    ],
62  },
63  {
64    name: 'Math & Punctuation',
65    items: [
66      ['±', 'plus-minus', I('<line x1="4" y1="10" x2="20" y2="10"/><line x1="12" y1="2" x2="12" y2="18"/><line x1="4" y1="20" x2="20" y2="20"/>')],
67      ['×', 'multiply', I('<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>')],
68      ['÷', 'divide', I('<circle cx="12" cy="6" r="1.5" fill="currentColor" stroke="none"/><line x1="5" y1="12" x2="19" y2="12"/><circle cx="12" cy="18" r="1.5" fill="currentColor" stroke="none"/>')],
69      ['≈', 'approx', I('<path d="M4 9 C 6 6, 8 12, 10 9 S 14 6, 16 9 S 20 12, 22 9"/><path d="M4 15 C 6 12, 8 18, 10 15 S 14 12, 16 15 S 20 18, 22 15"/>')],
70      ['≠', 'not-equal', I('<line x1="5" y1="9" x2="19" y2="9"/><line x1="5" y1="15" x2="19" y2="15"/><line x1="16" y1="5" x2="8" y2="19"/>')],
71      ['≤', 'lte', I('<polyline points="17 5 7 11 17 17"/><line x1="7" y1="20" x2="17" y2="20"/>')],
72      ['≥', 'gte', I('<polyline points="7 5 17 11 7 17"/><line x1="7" y1="20" x2="17" y2="20"/>')],
73      ['∞', 'infinity', I('<path d="M18.178 8c5.096 0 5.096 8 0 8-5.095 0-7.133-8-12.739-8-4.585 0-4.585 8 0 8 5.606 0 7.644-8 12.739-8z"/>')],
74      ['π', 'pi', I('<line x1="4" y1="8" x2="20" y2="8"/><line x1="9" y1="8" x2="9" y2="20"/><line x1="15" y1="8" x2="15" y2="20"/>')],
75      ['Σ', 'sum', I('<polyline points="6 4 18 4 10 12 18 20 6 20"/>')],
76      ['∆', 'delta', I('<polygon points="12 4 20 20 4 20"/>')],
77      ['√', 'root', I('<polyline points="4 14 8 20 14 4 22 4"/>')],
78      ['°', 'degree', I('<circle cx="12" cy="8" r="3"/>')],
79      ['§', 'section', I('<path d="M14 6 a4 3 0 1 0 -4 4 q-3 0 -3 3 t3 3 q3 0 3 -3"/>')],
80      ['¶', 'pilcrow', I('<path d="M16 4 H 9 a4 4 0 0 0 0 8 H 12 V 20"/><line x1="16" y1="4" x2="16" y2="20"/>')],
81      ['•', 'bullet', I('<circle cx="12" cy="12" r="3" fill="currentColor" stroke="none"/>')],
82      ['…', 'ellipsis', I('<circle cx="6" cy="12" r="1.5" fill="currentColor" stroke="none"/><circle cx="12" cy="12" r="1.5" fill="currentColor" stroke="none"/><circle cx="18" cy="12" r="1.5" fill="currentColor" stroke="none"/>')],
83      ['—', 'em-dash', I('<line x1="4" y1="12" x2="20" y2="12"/>')],
84      ['«', 'quote-l', I('<polyline points="12 5 6 12 12 19"/><polyline points="18 5 12 12 18 19"/>')],
85      ['»', 'quote-r', I('<polyline points="6 5 12 12 6 19"/><polyline points="12 5 18 12 12 19"/>')],
86      ['"', 'quote-dbl', I('<line x1="8" y1="5" x2="8" y2="11"/><line x1="11" y1="5" x2="11" y2="11"/><line x1="13" y1="5" x2="13" y2="11"/><line x1="16" y1="5" x2="16" y2="11"/>')],
87    ],
88  },
89  {
90    name: 'Currency & Misc',
91    items: [
92      ['€', 'euro', I('<text x="12" y="16" font-size="16" text-anchor="middle" fill="currentColor" stroke="none">€</text>')],
93      ['£', 'pound', I('<text x="12" y="16" font-size="16" text-anchor="middle" fill="currentColor" stroke="none">£</text>')],
94      ['Â¥', 'yen', I('<text x="12" y="16" font-size="16" text-anchor="middle" fill="currentColor" stroke="none">Â¥</text>')],
95      ['$', 'dollar', I('<text x="12" y="16" font-size="16" text-anchor="middle" fill="currentColor" stroke="none">$</text>')],
96      ['¢', 'cent', I('<text x="12" y="16" font-size="16" text-anchor="middle" fill="currentColor" stroke="none">¢</text>')],
97      ['%', 'percent', I('<text x="12" y="16" font-size="16" text-anchor="middle" fill="currentColor" stroke="none">%</text>')],
98      ['‰', 'per-mille', I('<text x="12" y="16" font-size="13" text-anchor="middle" fill="currentColor" stroke="none">‰</text>')],
99      ['№', 'number', I('<text x="12" y="16" font-size="12" text-anchor="middle" fill="currentColor" stroke="none">№</text>')],
100    ],
101  },
102];
103
104let _pickerEl = null;
105let _pickerOpenedAt = 0;
106let _targetEl = null;
107let _closeOnOutsideClick = null;
108let _closeOnEscape = null;
109// For contenteditable targets we snapshot the caret/selection when the picker
110// opens, since focusing the picker's search box collapses the live selection.
111let _savedRange = null;
112
113// `target` may be a textarea element id (string) or a resolver function that
114// returns the live target element — the latter lets a caller switch between a
115// textarea and a contenteditable (e.g. plain markdown vs. WYSIWYG email).
116export function createEmojiButton(target) {
117  const btn = document.createElement('button');
118  btn.type = 'button';
119  btn.className = 'emoji-picker-btn';
120  btn.title = 'Insert icon';
121  btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/></svg>';
122  // Don't steal focus from the editor on press — keeps the caret/selection so
123  // the emoji lands where the user was typing.
124  btn.addEventListener('mousedown', (e) => e.preventDefault());
125  btn.addEventListener('click', (e) => {
126    e.preventDefault();
127    e.stopPropagation();
128    const el = (typeof target === 'function') ? target() : document.getElementById(target);
129    if (!el) return;
130    togglePicker(btn, el);
131  });
132  return btn;
133}
134
135function togglePicker(anchor, target) {
136  const now = Date.now();
137  if (_pickerEl) {
138    // Ignore the duplicate/ghost click mobile fires right after opening, which
139    // would otherwise re-toggle the picker shut the instant it appears.
140    if (now - _pickerOpenedAt < 400) return;
141    _closePicker();
142    return;
143  }
144  _targetEl = target;
145  _savedRange = null;
146  if (target.isContentEditable) {
147    const sel = window.getSelection();
148    if (sel && sel.rangeCount) {
149      const r = sel.getRangeAt(0);
150      if (target.contains(r.commonAncestorContainer)) _savedRange = r.cloneRange();
151    }
152  }
153  _pickerEl = _buildPicker();
154  _pickerOpenedAt = now;
155  document.body.appendChild(_pickerEl);
156
157  const rect = anchor.getBoundingClientRect();
158  _pickerEl.style.position = 'fixed';
159  _pickerEl.style.top = (rect.bottom + 4) + 'px';
160  _pickerEl.style.left = rect.left + 'px';
161  _pickerEl.style.zIndex = '10000';
162
163  requestAnimationFrame(() => {
164    const pr = _pickerEl.getBoundingClientRect();
165    if (pr.right > window.innerWidth - 8) {
166      _pickerEl.style.left = Math.max(8, window.innerWidth - pr.width - 8) + 'px';
167    }
168    // Always open downward. If it would run past the bottom, cap its height so
169    // it scrolls internally instead of flipping up (which got cut off at top).
170    const avail = window.innerHeight - rect.bottom - 12;
171    if (pr.height > avail) {
172      _pickerEl.style.maxHeight = Math.max(160, avail) + 'px';
173    }
174  });
175
176  const close = (e) => {
177    // Ignore the ghost/duplicate click mobile fires right after opening.
178    if (e && e.type === 'click' && Date.now() - _pickerOpenedAt < 400) return;
179    if (_pickerEl && !_pickerEl.contains(e.target) && e.target !== anchor && !anchor.contains(e.target)) {
180      _closePicker();
181    }
182  };
183  _closeOnOutsideClick = close;
184  setTimeout(() => document.addEventListener('click', close, true), 10);
185
186  _closeOnEscape = (e) => {
187    if (e.key !== 'Escape' || !_pickerEl) return;
188    e.preventDefault();
189    e.stopPropagation();
190    e.stopImmediatePropagation?.();
191    _closePicker();
192  };
193  document.addEventListener('keydown', _closeOnEscape, true);
194}
195
196function _closePicker() {
197  if (_pickerEl) {
198    _pickerEl.remove();
199    _pickerEl = null;
200  }
201  if (_closeOnOutsideClick) {
202    document.removeEventListener('click', _closeOnOutsideClick, true);
203    _closeOnOutsideClick = null;
204  }
205  if (_closeOnEscape) {
206    document.removeEventListener('keydown', _closeOnEscape, true);
207    _closeOnEscape = null;
208  }
209}
210
211function _buildPicker() {
212  const el = document.createElement('div');
213  el.className = 'emoji-picker';
214
215  const search = document.createElement('input');
216  search.type = 'text';
217  search.placeholder = 'Search…';
218  search.className = 'emoji-picker-search';
219  el.appendChild(search);
220
221  const groupsContainer = document.createElement('div');
222  groupsContainer.className = 'emoji-picker-groups';
223  el.appendChild(groupsContainer);
224
225  function render(filter = '') {
226    groupsContainer.innerHTML = '';
227    const f = filter.toLowerCase();
228    for (const group of EMOJI_GROUPS) {
229      const filtered = f
230        ? group.items.filter(item => item[1].toLowerCase().includes(f) || item[0].includes(filter))
231        : group.items;
232      if (filtered.length === 0) continue;
233
234      const groupDiv = document.createElement('div');
235      groupDiv.className = 'emoji-picker-group';
236      const header = document.createElement('div');
237      header.className = 'emoji-picker-group-name';
238      header.textContent = group.name;
239      groupDiv.appendChild(header);
240
241      const grid = document.createElement('div');
242      grid.className = 'emoji-picker-grid';
243      for (const item of filtered) {
244        const [char, label, svg] = item;
245        const btn = document.createElement('button');
246        btn.type = 'button';
247        btn.className = 'emoji-picker-item';
248        btn.title = label;
249        btn.innerHTML = svg;
250        btn.addEventListener('click', (e) => {
251          e.preventDefault();
252          e.stopPropagation();
253          _insertEmoji(char);
254          _closePicker();
255        });
256        grid.appendChild(btn);
257      }
258      groupDiv.appendChild(grid);
259      groupsContainer.appendChild(groupDiv);
260    }
261  }
262
263  render();
264
265  search.addEventListener('input', () => render(search.value.trim()));
266  setTimeout(() => search.focus(), 50);
267
268  return el;
269}
270
271function _insertEmoji(char) {
272  if (!_targetEl) return;
273  // Force monochrome (text) presentation for the recipient by appending the
274  // text variation selector U+FE0E. It only affects chars that *have* an emoji
275  // presentation (e.g. ♥ ▶ ❤ ↩ ☀); for plain ASCII it's pointless, so we skip
276  // those. This is why the inserted glyph is non-colored on the other end too,
277  // not just in our own (already-SVG) picker UI.
278  const cp = char.codePointAt(0);
279  const ins = cp >= 0x80 ? char + VS15 : char;
280  // Contenteditable (e.g. WYSIWYG email body) — insert at the saved caret.
281  if (_targetEl.isContentEditable) {
282    _targetEl.focus();
283    let range = _savedRange;
284    if (!range) {
285      range = document.createRange();
286      range.selectNodeContents(_targetEl);
287      range.collapse(false);
288    }
289    range.deleteContents();
290    const node = document.createTextNode(ins);
291    range.insertNode(node);
292    range.setStartAfter(node);
293    range.collapse(true);
294    const sel = window.getSelection();
295    sel.removeAllRanges();
296    sel.addRange(range);
297    _savedRange = range.cloneRange();
298    _targetEl.dispatchEvent(new Event('input', { bubbles: true }));
299    return;
300  }
301  const start = _targetEl.selectionStart || 0;
302  const end = _targetEl.selectionEnd || 0;
303  const before = _targetEl.value.substring(0, start);
304  const after = _targetEl.value.substring(end);
305  _targetEl.value = before + ins + after;
306  _targetEl.selectionStart = _targetEl.selectionEnd = start + ins.length;
307  _targetEl.focus();
308  _targetEl.dispatchEvent(new Event('input', { bubbles: true }));
309}
310
311export default { createEmojiButton };

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.