/* Emoji Autocomplete - paste into Owncast Admin > Custom JavaScript */ (function () { "use strict"; var MAX_ROWS = 3; var ITEM_SIZE = 38; // px, matches CSS width/height of li var ITEM_GAP = 3; // px, matches CSS gap var POPUP_PADDING = 4; // px, matches CSS padding var allEmojis = []; // {name, native?, url?, keywords?} var autocompleteActive = false; var autocompleteQuery = ""; var autocompleteIndex = 0; var colonNode = null; // the Text node containing the ':' var colonOffset = 0; // character offset of ':' within that node var popupEl = null; // ── Load emoji data ────────────────────────────────────────────────── async function loadEmojis() { var customList = []; try { var customResponse = await fetch("/api/emoji"); var custom = await customResponse.json(); customList = (custom || []).map(function (e) { return { name: e.name, url: e.url }; }); } catch (ex) { /* no custom emoji */ return; } try { var cdnResponse = await fetch( "https://cdn.jsdelivr.net/npm/@emoji-mart/data@1/sets/1/native.json", ); var data = await cdnResponse.json(); var standardList = []; var emojis = data.emojis || {}; Object.keys(emojis).forEach(function (key) { var e = emojis[key]; var native = e.skins && e.skins[0] && e.skins[0].native; if (native) { standardList.push({ name: e.id || key, native: native, keywords: e.keywords || [], }); } }); // Read emoji-mart frequency data from localStorage var freqMap = {}; try { var stored = localStorage.getItem("emoji-mart.frequently"); if (stored) freqMap = JSON.parse(stored); } catch (ex) { /* ignore */ } var merged = customList.concat(standardList); merged.sort(function (a, b) { var aCustom = a.url ? 0 : 1; var bCustom = b.url ? 0 : 1; if (aCustom !== bCustom) return aCustom - bCustom; var fa = freqMap[a.name] || 0; var fb = freqMap[b.name] || 0; if (fb !== fa) return fb - fa; return a.name.localeCompare(b.name); }); allEmojis = merged; } catch (ex) { // If CDN fails, at least use custom emoji allEmojis = customList; } } // ── Filtering ──────────────────────────────────────────────────────── function matchesQuery(emoji, q) { var lower = q.toLowerCase(); if (emoji.name.toLowerCase().startsWith(lower)) return true; if (emoji.keywords) { for (var i = 0; i < emoji.keywords.length; i++) { if (emoji.keywords[i].toLowerCase().startsWith(lower)) return true; } } return false; } function filterEmojis(query) { if (!query) return allEmojis; return allEmojis.filter(function (e) { return matchesQuery(e, query); }); } // ── Popup rendering ───────────────────────────────────────────────── function getOrCreatePopup() { if (popupEl) return popupEl; popupEl = document.createElement("ul"); popupEl.id = "emoji-autocomplete-popup"; return popupEl; } function getMaxVisible(container) { var width = container.clientWidth; var usable = width - POPUP_PADDING * 2; var cols = Math.max( 1, Math.floor((usable + ITEM_GAP) / (ITEM_SIZE + ITEM_GAP)), ); return cols * MAX_ROWS; } function showPopup(filtered) { var chatInput = document.getElementById("chat-input"); if (!chatInput) return; var popup = getOrCreatePopup(); popup.innerHTML = ""; var count = Math.min(filtered.length, getMaxVisible(chatInput)); for (let i = 0; i < count; i++) { var li = document.createElement("li"); li.setAttribute("title", ":" + filtered[i].name + ":"); if (i === autocompleteIndex) li.className = "selected"; if (filtered[i].native) { li.textContent = filtered[i].native; } else if (filtered[i].url) { var img = document.createElement("img"); img.src = filtered[i].url; img.alt = filtered[i].name; img.className = "emoji"; li.appendChild(img); } li.addEventListener("mousedown", function (ev) { ev.preventDefault(); insertEmoji(filtered[i]); }); popup.appendChild(li); } if (!popup.parentNode) { chatInput.insertBefore(popup, chatInput.firstChild); } // Scroll selected item into view var selectedItem = popup.children[autocompleteIndex]; if (selectedItem) { selectedItem.scrollIntoView({ inline: "nearest", block: "nearest", }); } } function hidePopup() { autocompleteActive = false; autocompleteQuery = ""; autocompleteIndex = 0; colonNode = null; colonOffset = 0; if (popupEl && popupEl.parentNode) { popupEl.parentNode.removeChild(popupEl); } } // ── Emoji insertion ───────────────────────────────────────────────── function insertNodeAndMoveCursor(range, node) { var sel = window.getSelection(); range.insertNode(node); sel.removeAllRanges(); var newRange = document.createRange(); newRange.setStartAfter(node); newRange.collapse(true); sel.addRange(newRange); } function insertEmoji(emoji) { var sel = window.getSelection(); if (!sel || !colonNode) { hidePopup(); return; } var range = document.createRange(); range.setStart(colonNode, colonOffset); // End at current cursor position var curRange = sel.getRangeAt(0); range.setEnd(curRange.startContainer, curRange.startOffset); range.deleteContents(); if (emoji.native) { insertNodeAndMoveCursor( range, document.createTextNode(emoji.native), ); } else if (emoji.url) { var img = document.createElement("img"); img.src = emoji.url; img.alt = ":" + emoji.name + ":"; img.title = ":" + emoji.name + ":"; img.className = "emoji"; insertNodeAndMoveCursor(range, img); } hidePopup(); } // ── Colon detection ───────────────────────────────────────────────── function detectAutocomplete() { var sel = window.getSelection(); if (!sel || sel.rangeCount === 0) { hidePopup(); return; } var range = sel.getRangeAt(0); if (!range.collapsed) { hidePopup(); return; } var container = range.startContainer; var offset = range.startOffset; if (container.nodeType !== Node.TEXT_NODE) { hidePopup(); return; } var text = container.textContent || ""; var beforeCursor = text.slice(0, offset); var colonIdx = beforeCursor.lastIndexOf(":"); if (colonIdx === -1) { hidePopup(); return; } // Only trigger if ':' is at start, or preceded by whitespace / non-alnum if (colonIdx > 0 && /[a-zA-Z0-9]/.test(beforeCursor[colonIdx - 1])) { hidePopup(); return; } var query = beforeCursor.slice(colonIdx + 1); if (/\s/.test(query)) { hidePopup(); return; } colonNode = container; colonOffset = colonIdx; autocompleteQuery = query; autocompleteIndex = 0; autocompleteActive = true; var filtered = filterEmojis(query); if (filtered.length === 0) { hidePopup(); return; } showPopup(filtered); } // ── Grid helpers ──────────────────────────────────────────────────── function getColumnsPerRow() { if (!popupEl || !popupEl.children.length) return 1; var firstTop = popupEl.children[0].getBoundingClientRect().top; var cols = 0; for (var i = 0; i < popupEl.children.length; i++) { if (popupEl.children[i].getBoundingClientRect().top === firstTop) { cols++; } else { break; } } return cols || 1; } // ── Event listeners ───────────────────────────────────────────────── function waitForChatInput() { var input = document.getElementById("chat-input-content-editable"); if (input) { attachListeners(input); return; } // Poll until the chat input appears (it may load async) var observer = new MutationObserver(function () { var el = document.getElementById("chat-input-content-editable"); if (el) { observer.disconnect(); attachListeners(el); } }); observer.observe(document.body, { childList: true, subtree: true }); } function attachListeners(input) { // Use capturing phase so we fire BEFORE React's delegated handlers input.addEventListener("keydown", onKeyDown, true); input.addEventListener("input", onInput, true); // Also detect on selection changes (e.g., clicking after a colon) document.addEventListener("selectionchange", function () { if (document.activeElement === input) { detectAutocomplete(); } }); } function onInput() { detectAutocomplete(); } function onKeyDown(e) { if (!autocompleteActive) return; // let everything pass through to React var filtered = filterEmojis(autocompleteQuery); if (filtered.length === 0) { hidePopup(); return; } switch (e.key) { case "Enter": e.preventDefault(); e.stopPropagation(); insertEmoji(filtered[autocompleteIndex] || filtered[0]); return; case "Tab": e.preventDefault(); e.stopPropagation(); if (e.shiftKey) { autocompleteIndex = (autocompleteIndex - 1 + filtered.length) % filtered.length; } else { autocompleteIndex = (autocompleteIndex + 1) % filtered.length; } showPopup(filtered); return; case "ArrowRight": e.preventDefault(); e.stopPropagation(); autocompleteIndex = Math.min( autocompleteIndex + 1, filtered.length - 1, ); showPopup(filtered); return; case "ArrowLeft": e.preventDefault(); e.stopPropagation(); autocompleteIndex = Math.max(autocompleteIndex - 1, 0); showPopup(filtered); return; case "ArrowDown": e.preventDefault(); e.stopPropagation(); var cols = getColumnsPerRow(); autocompleteIndex = Math.min( autocompleteIndex + cols, filtered.length - 1, ); showPopup(filtered); return; case "ArrowUp": e.preventDefault(); e.stopPropagation(); var colsUp = getColumnsPerRow(); autocompleteIndex = Math.max(autocompleteIndex - colsUp, 0); showPopup(filtered); return; case "Escape": e.preventDefault(); e.stopPropagation(); hidePopup(); return; } } // ── Init ───────────────────────────────────────────────────────────── loadEmojis(); if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", waitForChatInput); } else { waitForChatInput(); } })();