Files
owncast-addon-emoji-autocom…/emoji-autocomplete.js
2026-02-21 13:57:47 -03:00

410 lines
14 KiB
JavaScript

/* 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();
}
})();