commit 15274a6eb3477eab2601376e205a0efde37e6261 Author: mk Date: Sat Feb 21 13:57:47 2026 -0300 fist commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..f6cd667 --- /dev/null +++ b/README.md @@ -0,0 +1,9 @@ +# How to use + +On admin panel on owncast under configuration. + +1. Copy the contents of `emoji-autocomplete.css` under Appearance tab and into `Customize your page styling with css` +2. Copy the contents of `emoji-autocomplete.js` under Custom Scripting tab and into `Customize your page with Javascript` + +Example of what it looks like when you do press ':' +![Emoji autocomplete](./emoji-autocomplete.png) diff --git a/emoji-autocomplete.css b/emoji-autocomplete.css new file mode 100644 index 0000000..8173af7 --- /dev/null +++ b/emoji-autocomplete.css @@ -0,0 +1,46 @@ +/* Emoji Autocomplete Popup - paste into Owncast Admin > Custom CSS */ + +#emoji-autocomplete-popup { + position: absolute; + bottom: 100%; + left: 0; + right: 0; + display: flex; + flex-wrap: wrap; + align-content: end; + overflow: hidden; + background-color: var(--theme-color-components-chat-background); + border: 1px solid var(--theme-color-palette-3); + border-radius: var(--theme-rounded-corners); + z-index: 200; + list-style: none; + margin: 0 0 4px; + padding: 4px; + gap: 3px; +} + +#emoji-autocomplete-popup li { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 38px; + height: 38px; + padding: 4px; + cursor: pointer; + font-size: 24px; + border-radius: 4px; +} + +#emoji-autocomplete-popup li:hover { + background-color: var(--theme-color-palette-3); +} + +#emoji-autocomplete-popup li img { + width: 28px; + height: 28px; +} + +#emoji-autocomplete-popup li.selected { + background-color: var(--theme-color-palette-3); +} diff --git a/emoji-autocomplete.js b/emoji-autocomplete.js new file mode 100644 index 0000000..fd7441c --- /dev/null +++ b/emoji-autocomplete.js @@ -0,0 +1,409 @@ +/* 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(); + } +})(); diff --git a/emoji-autocomplete.png b/emoji-autocomplete.png new file mode 100644 index 0000000..591f2f8 Binary files /dev/null and b/emoji-autocomplete.png differ