fist commit
This commit is contained in:
9
README.md
Normal file
9
README.md
Normal file
@@ -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 ':'
|
||||

|
||||
46
emoji-autocomplete.css
Normal file
46
emoji-autocomplete.css
Normal file
@@ -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);
|
||||
}
|
||||
409
emoji-autocomplete.js
Normal file
409
emoji-autocomplete.js
Normal file
@@ -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();
|
||||
}
|
||||
})();
|
||||
BIN
emoji-autocomplete.png
Normal file
BIN
emoji-autocomplete.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
Reference in New Issue
Block a user