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.