From a9adaf6d2a6e923a03eb006420215973e03fde0a Mon Sep 17 00:00:00 2001 From: transbitch <> Date: Sun, 15 Oct 2023 14:26:11 -0400 Subject: [PATCH] Wrote the fucking thing --- files/assets/css/main.css | 22 +- files/assets/js/core.js | 231 +++++-- files/assets/js/cursormarsey.js | 389 +++++------ files/assets/js/emoji_modal.js | 951 ++++++++++++--------------- files/assets/js/emoji_modal2.js | 116 ---- files/routes/static.py | 74 ++- files/templates/comments.html | 4 +- files/templates/hole/settings.html | 2 +- files/templates/modals/award.html | 2 +- files/templates/modals/emoji.html | 12 +- files/templates/post.html | 4 +- files/templates/submit.html | 4 +- files/templates/userpage/banner.html | 12 +- files/templates/util/macros.html | 8 +- 14 files changed, 899 insertions(+), 932 deletions(-) delete mode 100644 files/assets/js/emoji_modal2.js diff --git a/files/assets/css/main.css b/files/assets/css/main.css index 5c8a30b72..6a9dcea73 100644 --- a/files/assets/css/main.css +++ b/files/assets/css/main.css @@ -6559,8 +6559,11 @@ g { padding-left: 10px; } -#speed-carot-modal +.quick-emoji-dropdown { + position: absolute; + left: 0; + top: 0; background-color: var(--gray-700); max-height: 500px; overflow-y: auto; @@ -6569,31 +6572,34 @@ g { border: 1px solid rgba(255, 255, 255, 0.3); box-shadow: 0px 2px 5px rgba(0, 0, 0, 0.2); z-index: 1000000001; + display: flex; + flex-direction: column; } -#speed-carot-modal .speed-modal-option +.quick-emoji-dropdown .quick-emoji-option { + text-align: left; border-bottom: 1px solid #606060; padding: 4px; cursor: pointer; } -#speed-carot-modal .speed-modal-option:hover, -#speed-carot-modal .speed-modal-option:focus, -#speed-carot-modal .speed-modal-option.selected +.quick-emoji-dropdown .quick-emoji-option:hover, +.quick-emoji-dropdown .quick-emoji-option:focus, +.quick-emoji-dropdown .quick-emoji-option.selected { background-color: rgba(255, 255, 255, 0.2); } -#speed-carot-modal .speed-modal-image +.quick-emoji-dropdown .quick-emoji-image { object-fit: contain; width: 30px; height: 30px; } -#speed-carot-modal .speed-modal-option span +.quick-emoji-dropdown .quick-emoji-option span { overflow: hidden; display: inline-block; @@ -6706,7 +6712,7 @@ div.markdown { } @media (min-width: 768px) { - #speed-carot-modal .speed-modal-image + .quick-emoji-dropdown .quick-emoji-image { width: 50px; height: 50px; diff --git a/files/assets/js/core.js b/files/assets/js/core.js index 58ac3cc4e..4f0d56aff 100644 --- a/files/assets/js/core.js +++ b/files/assets/js/core.js @@ -12,7 +12,7 @@ function getMessageFromJsonData(success, json) { } function showToast(success, message) { - const oldToast = bootstrap.Toast.getOrCreateInstance(document.getElementById('toast-post-' + (success ? 'error': 'success'))); // intentionally reversed here: this is the old toast + const oldToast = bootstrap.Toast.getOrCreateInstance(document.getElementById('toast-post-' + (success ? 'error' : 'success'))); // intentionally reversed here: this is the old toast oldToast.hide(); let element = success ? "toast-post-success" : "toast-post-error"; let textElement = element + "-text"; @@ -23,7 +23,7 @@ function showToast(success, message) { bootstrap.Toast.getOrCreateInstance(document.getElementById(element)).show(); } -function createXhrWithFormKey(url, form=new FormData(), method='POST') { +function createXhrWithFormKey(url, form = new FormData(), method = 'POST') { const xhr = new XMLHttpRequest(); xhr.open(method, url); xhr.setRequestHeader('xhr', 'xhr'); @@ -38,12 +38,12 @@ function postToast(t, url, data, extraActionsOnSuccess, extraActionsOnFailure) { let form = new FormData(); if (typeof data === 'object' && data !== null) { - for(let k of Object.keys(data)) { + for (let k of Object.keys(data)) { form.append(k, data[k]); } } const xhr = createXhrWithFormKey(url, form); - xhr[0].onload = function() { + xhr[0].onload = function () { const success = xhr[0].status >= 200 && xhr[0].status < 300; if (!(extraActionsOnSuccess == reload && success)) { @@ -88,19 +88,18 @@ function postToastSwitch(t, url, button1, button2, cls, extraActionsOnSuccess) { { }, (xhr) => { - if (button1) - { + if (button1) { if (typeof button1 == 'boolean') { location.reload() } else { try { document.getElementById(button1).classList.toggle(cls); } - catch (e) {} + catch (e) { } try { document.getElementById(button2).classList.toggle(cls); } - catch (e) {} + catch (e) { } } } if (typeof extraActionsOnSuccess == 'function') @@ -108,8 +107,7 @@ function postToastSwitch(t, url, button1, button2, cls, extraActionsOnSuccess) { }); } -if (!location.pathname.endsWith('/submit') && !location.pathname.endsWith('/chat')) -{ +if (!location.pathname.endsWith('/submit') && !location.pathname.endsWith('/chat')) { document.addEventListener('keydown', (e) => { if (!((e.ctrlKey || e.metaKey) && e.key === "Enter")) return; @@ -147,18 +145,17 @@ function autoExpand(field) { let computed = window.getComputedStyle(field); let height = parseInt(computed.getPropertyValue('border-top-width'), 10) - + parseInt(computed.getPropertyValue('padding-top'), 10) - + field.scrollHeight - + parseInt(computed.getPropertyValue('padding-bottom'), 10) - + parseInt(computed.getPropertyValue('border-bottom-width'), 10); + + parseInt(computed.getPropertyValue('padding-top'), 10) + + field.scrollHeight + + parseInt(computed.getPropertyValue('padding-bottom'), 10) + + parseInt(computed.getPropertyValue('border-bottom-width'), 10); field.style.height = height + 'px'; if (Math.abs(window.scrollX - xpos) < 1 && Math.abs(window.scrollY - ypos) < 1) return; - window.scrollTo(xpos,ypos); + window.scrollTo(xpos, ypos); }; -function smoothScrollTop() -{ +function smoothScrollTop() { window.scrollTo({ top: 0, behavior: 'smooth' }); } @@ -193,8 +190,7 @@ function expandImage(url) { document.getElementById("expanded-image").src = ''; document.getElementById("expanded-image-wrap-link").href = ''; - if (!url) - { + if (!url) { url = e.target.dataset.src if (!url) url = e.target.src } @@ -206,7 +202,7 @@ function expandImage(url) { function bs_trigger(e) { let tooltipTriggerList = [].slice.call(e.querySelectorAll('[data-bs-toggle="tooltip"]')); - tooltipTriggerList.map(function(element){ + tooltipTriggerList.map(function (element) { return bootstrap.Tooltip.getOrCreateInstance(element); }); @@ -231,18 +227,18 @@ function showmore(t) { } function formatDate(d) { - const options = {year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZoneName: 'short'}; + const options = { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZoneName: 'short' }; return d.toLocaleTimeString([], options) } const timestamps = document.querySelectorAll('[data-time]'); for (const e of timestamps) { - e.innerHTML = formatDate(new Date(e.dataset.time*1000)); + e.innerHTML = formatDate(new Date(e.dataset.time * 1000)); }; function timestamp(t, ti) { - const date = formatDate(new Date(ti*1000)); + const date = formatDate(new Date(ti * 1000)); t.setAttribute("data-bs-original-title", date); }; @@ -265,8 +261,7 @@ function areyousure(t) { } function prepare_to_pause(audio) { - for (const e of document.querySelectorAll('video,audio')) - { + for (const e of document.querySelectorAll('video,audio')) { e.addEventListener('play', () => { if (!audio.paused) audio.pause(); }); @@ -298,7 +293,7 @@ function sendFormXHR(form, extraActionsOnSuccess) { xhr.open("POST", actionPath); xhr.setRequestHeader('xhr', 'xhr'); - xhr.onload = function() { + xhr.onload = function () { const success = xhr.status >= 200 && xhr.status < 300; if (!(extraActionsOnSuccess == reload && success)) { @@ -358,7 +353,7 @@ function sort_table(t) { attr = parseInt(attr.replace(/,/g, '')) } } - items.push({ele, attr}); + items.push({ ele, attr }); } if (sortAscending[n]) { items.sort((a, b) => a.attr > b.attr ? 1 : -1); @@ -401,8 +396,7 @@ if (location.pathname != '/chat' && (gbrowser == 'iphone' || gbrowser == 'mac')) } const screen_width = (innerWidth > 0) ? innerWidth : screen.width; -function focusSearchBar(element) -{ +function focusSearchBar(element) { if (screen_width >= 768) { element.focus(); } @@ -428,13 +422,13 @@ function insertText(input, text) { input.setRangeText(text); if (window.chrome !== undefined) - setTimeout(function(){ + setTimeout(function () { input.focus(); - for(let i = 0; i < 2; i++) + for (let i = 0; i < 2; i++) input.setSelectionRange(newPos, newPos); input.focus(); - for(let i = 0; i < 2; i++) + for (let i = 0; i < 2; i++) input.setSelectionRange(newPos, newPos); }, 1); else @@ -445,6 +439,167 @@ function insertText(input, text) { handle_disabled(input) } +/** Returns a promise which can be used to await when the event loop is idle. */ +const idle = () => { + return new Promise(resolve => { + requestIdleCallback(resolve); + }); +} + +/** + * Shamelessly copied from https://github.com/component/textarea-caret-position/blob/master/index.js + * This code makes the assumption that the style of the textarea/input won't change. + * @returns {{top: number, left: number, height: number, bottom: number, right: number, x: number, y: number }} + */ +const getCaretPos = (() => { + // We'll copy the properties below into the mirror div. + // Note that some browsers, such as Firefox, do not concatenate properties + // into their shorthand (e.g. padding-top, padding-bottom etc. -> padding), + // so we have to list every single property explicitly. + const properties = [ + 'direction', // RTL support + 'boxSizing', + 'width', // on Chrome and IE, exclude the scrollbar, so the mirror div wraps exactly as the textarea does + 'height', + 'overflowX', + 'overflowY', // copy the scrollbar for IE + + 'borderTopWidth', + 'borderRightWidth', + 'borderBottomWidth', + 'borderLeftWidth', + 'borderStyle', + + 'paddingTop', + 'paddingRight', + 'paddingBottom', + 'paddingLeft', + + // https://developer.mozilla.org/en-US/docs/Web/CSS/font + 'fontStyle', + 'fontVariant', + 'fontWeight', + 'fontStretch', + 'fontSize', + 'fontSizeAdjust', + 'lineHeight', + 'fontFamily', + + 'textAlign', + 'textTransform', + 'textIndent', + 'textDecoration', // might not make a difference, but better be safe + + 'letterSpacing', + 'wordSpacing', + + 'tabSize', + 'MozTabSize' + + ]; + + const cache = new Map(); + + const isFirefox = window.mozInnerScreenX != null; + + /** @param {HTMLTextAreaElement} element */ + return (element) => { + const position = element.selectionEnd; + const computed = window.getComputedStyle ? window.getComputedStyle(element) : element.currentStyle; // currentStyle for IE < 9 + const isInput = element.nodeName === 'INPUT'; + + let div, style; + if (cache.has(element)) { + div = cache.get(element); + style = div.style; + } else { + // The mirror div will replicate the textarea's style + div = document.createElement('div'); + cache.set(element, div); + div.id = 'input-textarea-caret-position-mirror-div'; + document.body.appendChild(div); + + style = div.style; + + // Default textarea styles + style.whiteSpace = 'pre-wrap'; + if (!isInput) { + style.overflowWrap = 'break-word'; // only for textarea-s + } + + // Position off-screen + style.position = 'absolute'; // required to return coordinates properly + style.visibility = 'hidden'; // not 'display: none' because we want rendering + + // Transfer the element's properties to the div + properties.forEach(function (prop) { + if (isInput && prop === 'lineHeight') { + // Special case for s because text is rendered centered and line height may be != height + if (computed.boxSizing === "border-box") { + var height = parseInt(computed.height); + var outerHeight = + parseInt(computed.paddingTop) + + parseInt(computed.paddingBottom) + + parseInt(computed.borderTopWidth) + + parseInt(computed.borderBottomWidth); + var targetHeight = outerHeight + parseInt(computed.lineHeight); + if (height > targetHeight) { + style.lineHeight = height - outerHeight + "px"; + } else if (height === targetHeight) { + style.lineHeight = computed.lineHeight; + } else { + style.lineHeight = 0; + } + } else { + style.lineHeight = computed.height; + } + } else { + style[prop] = computed[prop]; + } + }); + } + + if (isFirefox) { + // Firefox lies about the overflow property for textareas: https://bugzilla.mozilla.org/show_bug.cgi?id=984275 + if (element.scrollHeight > parseInt(computed.height)) + style.overflowY = 'scroll'; + } else { + style.overflow = 'hidden'; // for Chrome to not render a scrollbar; IE keeps overflowY = 'scroll' + } + + div.textContent = element.value.substring(0, position); + // The second special handling for input type="text" vs textarea: + // spaces need to be replaced with non-breaking spaces - http://stackoverflow.com/a/13402035/1269037 + if (isInput) { + div.textContent = div.textContent.replace(/\s/g, '\u00a0'); + } + + const span = document.createElement('span'); + // Wrapping must be replicated *exactly*, including when a long word gets + // onto the next line, with whitespace at the end of the line before (#7). + // The *only* reliable way to do that is to copy the *entire* rest of the + // textarea's content into the created at the caret position. + // For inputs, just '.' would be enough, but no need to bother. + span.textContent = element.value.substring(position) || '.'; // || because a completely empty faux span doesn't render at all + div.appendChild(span); + + const rect = element.getClientRects()[0]; + const top = rect.top + window.scrollY + span.offsetTop; + const left = rect.left + window.scrollX + span.offsetLeft; + const height = parseInt(computed['lineHeight']); + const coordinates = { + x: left, + y: top, + top, + bottom: top + height, + left, + right: left + 1, + height, + }; + + return coordinates; + } +})(); //FILE SHIT @@ -510,8 +665,8 @@ function handle_files(input, newfiles) { for (let file of newfiles) { if (file.name == 'image.png') { const blob = file.slice(0, file.size, 'image/png'); - const new_name = Math.random().toString(32).substring(2,10) + '.png' - file = new File([blob], new_name, {type: 'image/png'}); + const new_name = Math.random().toString(32).substring(2, 10) + '.png' + file = new File([blob], new_name, { type: 'image/png' }); } oldfiles[ta.id].items.add(file); insertText(ta, `[${file.name}]`); @@ -519,8 +674,7 @@ function handle_files(input, newfiles) { input.files = oldfiles[ta.id].files; - if (input.files.length > 20) - { + if (input.files.length > 20) { window.alert("You can't upload more than 20 files at one time!") input.value = null oldfiles[ta.id] = new DataTransfer(); @@ -549,8 +703,7 @@ file_upload = document.getElementById('file-upload'); if (file_upload) { function display_url_image() { - if (file_upload.files) - { + if (file_upload.files) { const file = file_upload.files[0] const char_limit = screen_width >= 768 ? 50 : 10; file_upload.previousElementSibling.textContent = file.name.substr(0, char_limit); @@ -591,7 +744,7 @@ if (file_upload) { } -document.onpaste = function(event) { +document.onpaste = function (event) { const files = structuredClone(event.clipboardData.files); if (!files.length) return diff --git a/files/assets/js/cursormarsey.js b/files/assets/js/cursormarsey.js index e0fd44c10..3f700b9eb 100644 --- a/files/assets/js/cursormarsey.js +++ b/files/assets/js/cursormarsey.js @@ -1,203 +1,206 @@ -const cursormarseyEl = document.getElementById("cursormarsey"); -const heartEl = document.getElementById("cursormarsey-heart"); +// How about we don't dump everything into the global scope? +{ + const cursormarseyEl = document.getElementById("cursormarsey"); + const heartEl = document.getElementById("cursormarsey-heart"); -function getInitialPosition(max) { - return Math.max(32, Math.floor(Math.random() * max)); -} -let cursormarseyPosX = getInitialPosition(screen.availWidth - 20); -let cursormarseyPosY = getInitialPosition(screen.availHeight - 50); -cursormarseyEl.style.left = `${cursormarseyPosX}px`; -cursormarseyEl.style.top = `${cursormarseyPosY}px`; -heartEl.style.left = `${cursormarseyPosX+16}px`; -heartEl.style.top = `${cursormarseyPosY-16}px`; - -let mousePosX = cursormarseyPosX; -let mousePosY = cursormarseyPosY; - -let frameCount = 0; -let idleTime = 0; -let idleAnimation = null; -let idleAnimationFrame = 0; -const cursormarseySpeed = 10; -const spriteSets = { - idle: [[-3, -3]], - alert: [[-7, -3]], - scratchSelf: [ - [-5, 0], - [-6, 0], - [-7, 0], - ], - scratchWallN: [ - [0, 0], - [0, -1], - ], - scratchWallS: [ - [-7, -1], - [-6, -2], - ], - scratchWallE: [ - [-2, -2], - [-2, -3], - ], - scratchWallW: [ - [-4, 0], - [-4, -1], - ], - tired: [[-3, -2]], - sleeping: [ - [-2, 0], - [-2, -1], - ], - N: [ - [-1, -2], - [-1, -3], - ], - NE: [ - [0, -2], - [0, -3], - ], - E: [ - [-3, 0], - [-3, -1], - ], - SE: [ - [-5, -1], - [-5, -2], - ], - S: [ - [-6, -3], - [-7, -2], - ], - SW: [ - [-5, -3], - [-6, -1], - ], - W: [ - [-4, -2], - [-4, -3], - ], - NW: [ - [-1, 0], - [-1, -1], - ], -}; - -function setSprite(name, frame) { - const sprite = spriteSets[name][frame % spriteSets[name].length]; - cursormarseyEl.style.backgroundPosition = `${sprite[0] * 32}px ${sprite[1] * 32}px`; -} - -function resetIdleAnimation() { - idleAnimation = null; - idleAnimationFrame = 0; -} - -function idle() { - idleTime += 1; - - // every ~ 20 seconds - if (idleTime > 10 && true && idleAnimation == null) { - let avalibleIdleAnimations = ["sleeping", "scratchSelf"]; - if (cursormarseyPosX < 32) { - avalibleIdleAnimations.push("scratchWallW"); + function getInitialPosition(max) { + return Math.max(32, Math.floor(Math.random() * max)); } - if (cursormarseyPosY < 32) { - avalibleIdleAnimations.push("scratchWallN"); - } - if (cursormarseyPosX > innerWidth - 32) { - avalibleIdleAnimations.push("scratchWallE"); - } - if (cursormarseyPosY > innerHeight - 32) { - avalibleIdleAnimations.push("scratchWallS"); - } - idleAnimation = - avalibleIdleAnimations[ - Math.floor(Math.random() * avalibleIdleAnimations.length) - ]; - } - - switch (idleAnimation) { - case "sleeping": - if (idleAnimationFrame < 8) { - setSprite("tired", 0); - break; - } - setSprite("sleeping", Math.floor(idleAnimationFrame / 4)); - if (idleAnimationFrame > 192) { - resetIdleAnimation(); - } - break; - case "scratchWallN": - case "scratchWallS": - case "scratchWallE": - case "scratchWallW": - case "scratchSelf": - setSprite(idleAnimation, idleAnimationFrame); - if (idleAnimationFrame > 9) { - resetIdleAnimation(); - } - break; - default: - setSprite("idle", 0); - return; - } - idleAnimationFrame += 1; -} - -function frame() { - frameCount += 1; - const diffX = cursormarseyPosX - mousePosX; - const diffY = cursormarseyPosY - mousePosY; - const distance = Math.sqrt(diffX ** 2 + diffY ** 2); - - if (distance < cursormarseySpeed || distance < 100) { - idle(); - return; - } - - idleAnimation = null; - idleAnimationFrame = 0; - - if (idleTime > 1) { - setSprite("alert", 0); - // count down after being alerted before moving - idleTime = Math.min(idleTime, 7); - idleTime -= 1; - return; - } - - direction = diffY / distance > 0.5 ? "N" : ""; - direction += diffY / distance < -0.5 ? "S" : ""; - direction += diffX / distance > 0.5 ? "W" : ""; - direction += diffX / distance < -0.5 ? "E" : ""; - setSprite(direction, frameCount); - - cursormarseyPosX -= (diffX / distance) * cursormarseySpeed; - cursormarseyPosY -= (diffY / distance) * cursormarseySpeed; - - cursormarseyPosX = Math.min(Math.max(16, cursormarseyPosX), innerWidth - 16); - cursormarseyPosY = Math.min(Math.max(16, cursormarseyPosY), innerHeight - 16); - + let cursormarseyPosX = getInitialPosition(screen.availWidth - 20); + let cursormarseyPosY = getInitialPosition(screen.availHeight - 50); cursormarseyEl.style.left = `${cursormarseyPosX}px`; cursormarseyEl.style.top = `${cursormarseyPosY}px`; heartEl.style.left = `${cursormarseyPosX+16}px`; heartEl.style.top = `${cursormarseyPosY-16}px`; -} -document.onmousemove = (event) => { - mousePosX = event.clientX; - mousePosY = event.clientY; + let mousePosX = cursormarseyPosX; + let mousePosY = cursormarseyPosY; + + let frameCount = 0; + let idleTime = 0; + let idleAnimation = null; + let idleAnimationFrame = 0; + const cursormarseySpeed = 10; + const spriteSets = { + idle: [[-3, -3]], + alert: [[-7, -3]], + scratchSelf: [ + [-5, 0], + [-6, 0], + [-7, 0], + ], + scratchWallN: [ + [0, 0], + [0, -1], + ], + scratchWallS: [ + [-7, -1], + [-6, -2], + ], + scratchWallE: [ + [-2, -2], + [-2, -3], + ], + scratchWallW: [ + [-4, 0], + [-4, -1], + ], + tired: [[-3, -2]], + sleeping: [ + [-2, 0], + [-2, -1], + ], + N: [ + [-1, -2], + [-1, -3], + ], + NE: [ + [0, -2], + [0, -3], + ], + E: [ + [-3, 0], + [-3, -1], + ], + SE: [ + [-5, -1], + [-5, -2], + ], + S: [ + [-6, -3], + [-7, -2], + ], + SW: [ + [-5, -3], + [-6, -1], + ], + W: [ + [-4, -2], + [-4, -3], + ], + NW: [ + [-1, 0], + [-1, -1], + ], }; -window.marseykoInterval = setInterval(frame, 100); -document.addEventListener('click', (event) => { - cursormarseyEl.style.removeProperty("pointer-events"); - let elementClicked = document.elementFromPoint(event.clientX,event.clientY); - if (elementClicked.id === cursormarseyEl.id) { - heartEl.classList.remove("d-none"); - setTimeout(() => { - heartEl.classList.add("d-none"); - }, 2000); + function setSprite(name, frame) { + const sprite = spriteSets[name][frame % spriteSets[name].length]; + cursormarseyEl.style.backgroundPosition = `${sprite[0] * 32}px ${sprite[1] * 32}px`; } - cursormarseyEl.style.pointerEvents = "none"; -}); + + function resetIdleAnimation() { + idleAnimation = null; + idleAnimationFrame = 0; + } + + const idle = () => { + idleTime += 1; + + // every ~ 20 seconds + if (idleTime > 10 && true && idleAnimation == null) { + let avalibleIdleAnimations = ["sleeping", "scratchSelf"]; + if (cursormarseyPosX < 32) { + avalibleIdleAnimations.push("scratchWallW"); + } + if (cursormarseyPosY < 32) { + avalibleIdleAnimations.push("scratchWallN"); + } + if (cursormarseyPosX > innerWidth - 32) { + avalibleIdleAnimations.push("scratchWallE"); + } + if (cursormarseyPosY > innerHeight - 32) { + avalibleIdleAnimations.push("scratchWallS"); + } + idleAnimation = + avalibleIdleAnimations[ + Math.floor(Math.random() * avalibleIdleAnimations.length) + ]; + } + + switch (idleAnimation) { + case "sleeping": + if (idleAnimationFrame < 8) { + setSprite("tired", 0); + break; + } + setSprite("sleeping", Math.floor(idleAnimationFrame / 4)); + if (idleAnimationFrame > 192) { + resetIdleAnimation(); + } + break; + case "scratchWallN": + case "scratchWallS": + case "scratchWallE": + case "scratchWallW": + case "scratchSelf": + setSprite(idleAnimation, idleAnimationFrame); + if (idleAnimationFrame > 9) { + resetIdleAnimation(); + } + break; + default: + setSprite("idle", 0); + return; + } + idleAnimationFrame += 1; + } + + const frame = () => { + frameCount += 1; + const diffX = cursormarseyPosX - mousePosX; + const diffY = cursormarseyPosY - mousePosY; + const distance = Math.sqrt(diffX ** 2 + diffY ** 2); + + if (distance < cursormarseySpeed || distance < 100) { + idle(); + return; + } + + idleAnimation = null; + idleAnimationFrame = 0; + + if (idleTime > 1) { + setSprite("alert", 0); + // count down after being alerted before moving + idleTime = Math.min(idleTime, 7); + idleTime -= 1; + return; + } + + direction = diffY / distance > 0.5 ? "N" : ""; + direction += diffY / distance < -0.5 ? "S" : ""; + direction += diffX / distance > 0.5 ? "W" : ""; + direction += diffX / distance < -0.5 ? "E" : ""; + setSprite(direction, frameCount); + + cursormarseyPosX -= (diffX / distance) * cursormarseySpeed; + cursormarseyPosY -= (diffY / distance) * cursormarseySpeed; + + cursormarseyPosX = Math.min(Math.max(16, cursormarseyPosX), innerWidth - 16); + cursormarseyPosY = Math.min(Math.max(16, cursormarseyPosY), innerHeight - 16); + + cursormarseyEl.style.left = `${cursormarseyPosX}px`; + cursormarseyEl.style.top = `${cursormarseyPosY}px`; + heartEl.style.left = `${cursormarseyPosX+16}px`; + heartEl.style.top = `${cursormarseyPosY-16}px`; + } + + document.onmousemove = (event) => { + mousePosX = event.clientX; + mousePosY = event.clientY; + }; + window.marseykoInterval = setInterval(frame, 100); + + document.addEventListener('click', (event) => { + cursormarseyEl.style.removeProperty("pointer-events"); + let elementClicked = document.elementFromPoint(event.clientX,event.clientY); + if (elementClicked.id === cursormarseyEl.id) { + heartEl.classList.remove("d-none"); + setTimeout(() => { + heartEl.classList.add("d-none"); + }, 2000); + } + cursormarseyEl.style.pointerEvents = "none"; + }); +} \ No newline at end of file diff --git a/files/assets/js/emoji_modal.js b/files/assets/js/emoji_modal.js index 163b374f0..84c79386c 100644 --- a/files/assets/js/emoji_modal.js +++ b/files/assets/js/emoji_modal.js @@ -1,585 +1,444 @@ -/* -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as -published by the Free Software Foundation, either version 3 of the -License, or (at your option) any later version. -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . +// This code isn't for feeble minds, you might not understand it, Dr. Transmisia. +// Lappland, you are an absolute idiot and an embarrassment to the Rhodesian people. +// I have done the very thing that you decried impractical. +// The dainty hands of a trans goddess wrote this code. Watch the way her fingers +// dance across the keyboard and learn. -Copyright (C) 2022 Dr Steven Transmisia, anti-evil engineer, - 2022 Nekobit, king autist -*/ +// MIT License. Written by @transbitch -// Status /** - * inactive - user has not tried using an emoji - * loading - user has tried to use an emoji, and the engine is initializing itself - * ready - engine can handle all emoji usage - * @type {"inactive"|"loading"|"ready"} + * currently unused, the type of each emoji that https://rdrama.net/emojis.json returns. + * @typedef {object} EmojiDef + * @property {number} author_id + * @property {string} author_original_username + * @property {string} author_username + * @property {number} count + * @property {number} created_utc + * @property {string} kind + * @property {string} name + * @property {number | null} submitter_id + * @property {string[]} tags */ -let emojiEngineState = "inactive"; - -// DOM stuff -const classesSelectorDOM = document.getElementById("emoji-modal-tabs"); -const emojiButtonTemplateDOM = document.getElementById("emoji-button-template"); -const emojiResultsDOM = document.getElementById("tab-content"); - -const emojiSelectSuffixDOMs = document.getElementsByClassName("emoji-suffix"); -const emojiSelectPostfixDOMs= document.getElementsByClassName("emoji-postfix"); - -const emojiNotFoundDOM = document.getElementById("no-emojis-found"); -const emojiWorkingDOM = document.getElementById("emojis-work"); - -const emojiSearchBarDOM = document.getElementById('emoji_search'); - -let emojiInputTargetDOM = undefined; - -// Emojis usage stats. I don't really like this format but I'll keep it for backward comp. -const favorite_emojis = JSON.parse(localStorage.getItem("favorite_emojis")) || {}; - -/** Associative array of all the emojis' DOM */ -let emojiDOMs = {}; - -let globalEmojis; - -const EMOIJ_SEARCH_ENGINE_MIN_INTERVAL = 350; -let emojiSearcher = { - working: false, - queries: [], - - addQuery: function(query) - { - this.queries.push(query); - if (!this.working) - this.work(); - }, - - work: async function work() { - this.working = true; - - while(this.queries.length > 0) - { - const startTime = Date.now(); - - // Get last input - const query = this.queries[this.queries.length - 1].toLowerCase(); - this.queries = []; - - // To improve perf we avoid showing all emojis at the same time. - if (query === "") - { - await classesSelectorDOM.children[0].children[0].click(); - classesSelectorDOM.children[0].children[0].classList.add("active"); - continue; - } - - // Search - const resultSet = emojisSearchDictionary.completeSearch(query); - - // update stuff - for(const [emojiName, emojiDOM] of Object.entries(emojiDOMs)) - emojiDOM.hidden = !resultSet.has(emojiName); - - emojiNotFoundDOM.hidden = resultSet.size !== 0; - - let sleepTime = EMOIJ_SEARCH_ENGINE_MIN_INTERVAL - (Date.now() - startTime); - if (sleepTime > 0) - await new Promise(r => setTimeout(r, sleepTime)); - } - - this.working = false; - } -}; - -// tags dictionary. KEEP IT SORT -class EmoijsDictNode -{ - constructor(tag, name) { - this.tag = tag; - this.emojiNames = [name]; - } -} -const emojisSearchDictionary = { - dict: [], - - updateTag: function(tag, emojiName) { - if (tag === undefined || emojiName === undefined) - return; - - let low = 0; - let high = this.dict.length; - - while (low < high) { - let mid = (low + high) >>> 1; - if (this.dict[mid].tag < tag) - low = mid + 1; - else - high = mid; - } - - let target = low; - if (this.dict[target] !== undefined && this.dict[target].tag === tag) - this.dict[target].emojiNames.push(emojiName); - else - this.dict.splice(target ,0,new EmoijsDictNode(tag, emojiName)); - }, - - /** - * We also check for substrings! (sigh) - * @param {String} tag - * @returns {Set} - */ - completeSearch: function(query) { - query = query.toLowerCase() - const result = new Set(); - - for(let i = 0; i < this.dict.length; i++) - if (this.dict[i].tag.startsWith('@')) - { - if (this.dict[i].tag == query) - for(let j = 0; j < this.dict[i].emojiNames.length; j++) - result.add(this.dict[i].emojiNames[j]) - } - else if(this.dict[i].tag.includes(query)) - for(let j = 0; j < this.dict[i].emojiNames.length; j++) - result.add(this.dict[i].emojiNames[j]) - - return result; - } -}; - -// get public emojis list -function fetchEmojis() { - const headers = new Headers({xhr: "xhr"}) - return fetch("/emojis_json", { - headers, - }) - .then(res => res.json()) - .then(emojis => { - if (! (emojis instanceof Array )) - throw new TypeError("[EMOJI DIALOG] rDrama's server should have sent a JSON-coded Array!"); - - globalEmojis = emojis.map(({name, author, count}) => ({name, author, count})); - - let classes = ["Marsey", "Platy", "Wolf", "Donkey Kong", "Tay", "Capy", "Carp", "Marsey Flags", "Marsey Alphabet", "Classic", "Rage", "Wojak", "Misc"] - - const bussyDOM = document.createElement("div"); - - for(let i = 0; i < emojis.length; i++) - { - const emoji = emojis[i]; - - emojisSearchDictionary.updateTag(emoji.name, emoji.name); - - if (emoji.author_username !== undefined && emoji.author_username !== null) - emojisSearchDictionary.updateTag(`@${emoji.author_username.toLowerCase()}`, emoji.name); - - if (emoji.author_original_username !== undefined && emoji.author_original_username !== null) - emojisSearchDictionary.updateTag(`@${emoji.author_original_username.toLowerCase()}`, emoji.name); - - if (emoji.author_prelock_username !== undefined && emoji.author_prelock_username !== null) - emojisSearchDictionary.updateTag(`@${emoji.author_prelock_username.toLowerCase()}`, emoji.name); - - if (emoji.tags instanceof Array) - for(let i = 0; i < emoji.tags.length; i++) - emojisSearchDictionary.updateTag(emoji.tags[i], emoji.name); - - // Create emoji DOM - const emojiDOM = document.importNode(emojiButtonTemplateDOM.content, true).children[0]; - - emojiDOM.title = emoji.name - if (emoji.author_username !== undefined && emoji.author_username !== null) - emojiDOM.title += "\nauthor\t" + emoji.author_username - if (emoji.count !== undefined) - emojiDOM.title += "\nused\t" + emoji.count; - emojiDOM.dataset.className = emoji.kind; - emojiDOM.dataset.emojiName = emoji.name; - emojiDOM.onclick = emojiAddToInput; - emojiDOM.hidden = true; - - const emojiIMGDOM = emojiDOM.children[0]; - emojiIMGDOM.src = `${SITE_FULL_IMAGES}/e/${emoji.name}.webp` - emojiIMGDOM.alt = emoji.name; - /** Disableing lazy loading seems to reduce cpu usage somehow (?) - * idk it is difficult to benchmark */ - emojiIMGDOM.loading = "lazy"; - - // Save reference - emojiDOMs[emoji.name] = emojiDOM; - - // Add to the document! - bussyDOM.appendChild(emojiDOM); - } - - // Create header - for(let className of classes) - { - let classSelectorDOM = document.createElement("li"); - classSelectorDOM.classList.add("nav-item"); - - let classSelectorLinkDOM = document.createElement("button"); - classSelectorLinkDOM.type = "button"; - classSelectorLinkDOM.classList.add("nav-link", "emojitab"); - classSelectorLinkDOM.dataset.bsToggle = "tab"; - classSelectorLinkDOM.dataset.className = className; - classSelectorLinkDOM.textContent = className; - classSelectorLinkDOM.addEventListener('click', switchEmojiTab); - - classSelectorDOM.appendChild(classSelectorLinkDOM); - classesSelectorDOM.appendChild(classSelectorDOM); - } - - // Show favorite for start. - classesSelectorDOM.children[0].children[0].click(); - - // Send it to the render machine! - emojiResultsDOM.appendChild(bussyDOM); - - emojiResultsDOM.hidden = false; - emojiWorkingDOM.hidden = true; - emojiSearchBarDOM.disabled = false; - - emojiEngineState = "ready"; - }) -} /** -* -* @param {Event} e -*/ -function switchEmojiTab(e) + * @typedef {{[index: string]: [string, number][]}} EmojiTags + * @typedef {{[kind: string]: [string, number][]}} EmojiKinds + */ + +class EmojiEngine { + _res; + /** @type {Promise} */ + loaded = new Promise(res => this._res = res); + hasLoaded = false; + + /** @type {EmojiTags} */ + tags = {}; + + /** @type {EmojiKinds} */ + kinds = {}; + + // Memoize this value so we don't have to recompute it. + _tag_entries; + + /** @type {{[index: string]: HTMLDivElement}} */ + emojiDom = {}; + + /** @type {{[index: string]: number}} */ + emojiNameCount = {}; + + /** @type {(name: string) => void} */ + onInsert; + + init = async () => { + if (this.hasLoaded) { + return; + } + + await Promise.all([ + this.loadTags(), + this.loadKinds(), + ]); + + this._tag_entries = Object.entries(this.tags); + + this._res(); + this.hasLoaded = true; + } + + loadTags = async () => { + this.tags = await (await fetch('/emoji_tags.json')).json(); + } + + loadKinds = async () => { + this.kinds = await (await fetch('/emoji_kinds.json')).json(); + } + + search = async (query, maxLength = Infinity) => { + await this.loaded; + + const resultsSet = new Set(); + const results = []; + for (const [tag, entries] of this._tag_entries) { + if (!tag.includes(query)) { + continue; + } + + for (const [name, count] of entries) { + if (resultsSet.has(name)) { + continue; + } else if (count < results[maxLength - 1]?.[1]) { + // All the other emojis in this tag have less uses. We can stop here. + break; + } + + resultsSet.add(name); + // Insert into the array sorted. + let i = results.length; + while (i > 0 && count > results[i - 1][1]) { + i--; + } + results.splice(i, 0, [name, count]); + if (results.length >= maxLength) { + const [name] = results.pop(); + resultsSet.delete(name); + } + } + } + + return results.map(([name]) => name); + } + + /** + * Get a dom element for a list of emojis in quick dropdown. + * @param {string[]} emojiNames + */ + getQuickDoms = (emojiNames) => { + return emojiNames.map(this.getQuickDom); + } + + /** + * + * @param {*} emojiName + * @returns DOM element for an emoji quick dropdown. + */ + getQuickDom = (emojiName) => { + if (this.emojiDom[emojiName]) { + return this.emojiDom[emojiName]; + } + + const emojiEl = document.createElement('button'); + emojiEl.classList.add('quick-emoji-option', 'emoji-option'); + emojiEl.addEventListener('click', (e) => { + this.onInsert(emojiName); + }); + + const emojiImgEl = document.createElement('img'); + emojiImgEl.classList.add('quick-emoji-image', 'emoji-option-image'); + emojiImgEl.src = emojiEngine.src(emojiName); + emojiEl.appendChild(emojiImgEl); + + const emojiNameEl = document.createElement('span'); + emojiNameEl.textContent = emojiName; + emojiEl.appendChild(emojiNameEl); + + this.emojiDom[emojiName] = emojiEl; + return emojiEl; + } + + src = (name) => { + return `${SITE_FULL_IMAGES}/e/${name}.webp` + } +} + +const emojiEngine = new EmojiEngine(); + +// Quick emoji dropdown & emoji insertion { - const className = e.currentTarget.dataset.className; + const emojiDropdownEl = document.createElement('div'); + emojiDropdownEl.classList.add('quick-emoji-dropdown'); + /** @type {null | HTMLTextAreaElement} */ + let inputEl = null; + let visible = false; + let typingEmojiCanceled = false; + let firstDomEl = null; + let firstEmojiName = null; + let caretPos = 0; - emojiSearchBarDOM.value = ""; - focusSearchBar(emojiSearchBarDOM); - emojiNotFoundDOM.hidden = true; + // Used by onclick attrib of the smile button + window.openEmojiModal = (id) => { + inputEl = document.getElementById(id); + initEmojiModal(); + } - // Special case: favorites - if (className === "favorite") - { - for(const emojiDOM of Object.values(emojiDOMs)) - emojiDOM.hidden = true; + emojiEngine.onInsert = (name) => { + if (!inputEl) { + return; + } + const match = matchTypingEmoji(); + if (match) { + // We are inserting an emoji which we are typing. + inputEl.value = `${inputEl.value.slice(0, match.index)}:${name}:${inputEl.value.slice(match.index + name.length)} `; + // Draw the focus back to this element. + inputEl.focus(); + } else { + // We are inserting a new emoji. + const start = inputEl.value.slice(0, caretPos); + const end = inputEl.value.slice(caretPos); + const insert = `:${name}:${end.length === 0 ? ' ' : ''}`; + inputEl.value = `${start}${insert}${end}`; + caretPos += insert.length; + inputEl.setSelectionRange(caretPos, caretPos); + } - const favs = Object.keys(Object.fromEntries( - Object.entries(favorite_emojis).sort(([,a],[,b]) => b-a) - )).slice(0, 25); + typingEmojiCanceled = false; + update(); - for (const emoji of favs) - if (emojiDOMs[emoji] instanceof HTMLElement) - emojiDOMs[emoji].hidden = false; + // This updates the preview. + inputEl.dispatchEvent(new Event('input', { bubbles: true })); - return; - } + // Update the favorite count. + if (name in favoriteEmojis) { + favoriteEmojis[name]++; + } else { + favoriteEmojis[name] = 1; + } + localStorage.setItem("favorite_emojis", JSON.stringify(favoriteEmojis)); + } - for(const emojiDOM of Object.values(emojiDOMs)) - emojiDOM.hidden = emojiDOM.dataset.className !== className; + const inputCanTakeEmojis = (el = inputEl) => { + return el?.dataset && 'emojis' in el.dataset; + } - document.getElementById('emoji-container').scrollTop = 0; + const matchTypingEmoji = () => { + return inputEl?.value.substring(0, inputEl.selectionEnd).match(/:([\w!#]+)$/); + } + + const getTypingEmoji = () => { + return matchTypingEmoji()?.[1] ?? null; + } + + const isTypingEmoji = () => { + return inputCanTakeEmojis() && getTypingEmoji(); + } + + const endTypingEmoji = () => { + typingEmojiCanceled = false; + } + + const update = async () => { + const typing = isTypingEmoji(); + visible = typing && !typingEmojiCanceled; + if (!visible) { + emojiDropdownEl.parentElement?.removeChild(emojiDropdownEl); + return; + } + const oldFirst = firstDomEl; + document.body.appendChild(emojiDropdownEl); + const search = await emojiEngine.search(getTypingEmoji(), 15); + firstEmojiName = search[0]; + const domEls = emojiEngine.getQuickDoms(search); + firstDomEl = domEls[0]; + if (oldFirst !== firstDomEl) { + oldFirst?.classList.remove('selected'); + firstDomEl.classList.add('selected'); + } + emojiDropdownEl.replaceChildren(...domEls); + const { left, bottom } = getCaretPos(inputEl); + // Using transform instead of top/left is faster. + emojiDropdownEl.style.transform = `translate(${left}px, ${bottom}px)`; + } + + // Add a listener when we start typing. + /** + * @param {FocusEvent} e + */ + const onKeyStart = (e) => { + if (inputCanTakeEmojis(e.target)) { + inputEl = e.target; + emojiEngine.init(); + window.removeEventListener('keydown', onKeyStart); + } + } + + window.addEventListener('keydown', onKeyStart); + window.addEventListener('keydown', (e) => { + if (!visible) { + return; + } + const isFocused = document.activeElement === inputEl + if (e.key === 'Escape') { + typingEmojiCanceled = true; + update(); + } else if (e.key === 'Enter' && isFocused) { + emojiEngine.onInsert(firstEmojiName); + e.preventDefault(); + } else if (e.key === 'Tab' && isFocused) { + firstDomEl.focus(); + firstDomEl.classList.remove("selected"); + e.preventDefault(); + } + }); + + ['input', 'click', 'focus'].forEach((event) => { + window.addEventListener(event, (e) => { + if (inputCanTakeEmojis(e.target)) { + inputEl = e.target; + caretPos = inputEl.selectionEnd; + } + update(); + if (!isTypingEmoji()) { + endTypingEmoji(); + } + }); + }); } -for (const emojitab of document.getElementsByClassName('emojitab')) { - emojitab.addEventListener('click', (e)=>{switchEmojiTab(e)}) -} +/** @type {{ [name: string]: number }} */ +const favoriteEmojis = JSON.parse(localStorage.getItem("favorite_emojis")) || {}; -async function start_search() { - emojiSearcher.addQuery(emojiSearchBarDOM.value.trim()); +const initEmojiModal = (() => { + let hasInit = false; + + return async () => { + if (hasInit) { + return; + } + hasInit = true; - // Remove any selected tab, now it is meaningless - for(let i = 0; i < classesSelectorDOM.children.length; i++) - classesSelectorDOM.children[i].children[0].classList.remove("active"); -} + await emojiEngine.init(); -/** -* Add the selected emoji to the targeted text area -* @param {Event} event -*/ -function emojiAddToInput(event) -{ - // This should not happen if used properly but whatever - if (!(emojiInputTargetDOM instanceof HTMLTextAreaElement) && !(emojiInputTargetDOM instanceof HTMLInputElement)) - return; + document.getElementById('emojis-work').style.display = 'none'; - let strToInsert = event.currentTarget.dataset.emojiName; + /** @type {{ [tabName: string]: HTMLDivElement }} */ + const tabContentEls = {} - for(let i = 0; i < emojiSelectPostfixDOMs.length; i++) - if (emojiSelectPostfixDOMs[i].checked) - strToInsert = strToInsert + emojiSelectPostfixDOMs[i].value; + /** @type {(kind: string, el: HTMLButtonElement) => void} */ + const addTabClickListener = (kind, el) => { + el.addEventListener('click', (e) => { + setTab(kind); + }); + } - for(let i = 0; i < emojiSelectSuffixDOMs.length; i++) - if (emojiSelectSuffixDOMs[i].checked) - strToInsert = emojiSelectSuffixDOMs[i].value + strToInsert; + const favorites = Object.entries(favoriteEmojis).sort((a, b) => b[1] - a[1]); + /** @type {{ [name: string]: HTMLButtonElement }} */ + const favoriteClones = {}; - strToInsert = ":" + strToInsert + ":" - insertText(emojiInputTargetDOM, strToInsert) + const favoriteContentEl = (() => { + const content = document.createElement('div'); + tabContentEls['favorite'] = content; + return content; + })(); - // kick-start the preview - emojiInputTargetDOM.dispatchEvent(new Event('input')); + let currentTab = 'favorite'; + const setTab = (kind) => { + currentTab = kind; + tabContent.replaceChildren(tabContentEls[kind]); + } - // Update favs. from old code - if (favorite_emojis[event.currentTarget.dataset.emojiName]) - favorite_emojis[event.currentTarget.dataset.emojiName] += 1; - else - favorite_emojis[event.currentTarget.dataset.emojiName] = 1; - localStorage.setItem("favorite_emojis", JSON.stringify(favorite_emojis)); -} + const emojiModal = document.getElementById('emojiModal'); + const emojiTabsEl = document.getElementById('emoji-modal-tabs'); + const tabContent = document.getElementById('emoji-tab-content'); + /** @type {HTMLInputElement} */ + const searchInputEl = document.getElementById('emoji_search'); + searchInputEl.disabled = false; + const searchResultsContainerEl = document.createElement('div'); + let isSearching = false; + /** @type {{ [index: string ]: HTMLButtonElement }} */ + const searchResultsEl = {}; + const favoriteTabEl = document.getElementById('emoji-modal-tabs-favorite'); + addTabClickListener('favorite', favoriteTabEl); -let emoji_typing_state = false; + window.emojiSearch = async () => { + if (searchInputEl.value.length === 0 && isSearching) { + isSearching = false; + setTab(currentTab); + } else if (searchInputEl.value.length > 0 && !isSearching) { + isSearching = true; + tabContent.replaceChildren(searchResultsContainerEl); + } -function update_ghost_div_textarea(text) -{ - let ghostdiv + if (isSearching) { + const query = searchInputEl.value; + requestIdleCallback(() => { + emojiEngine.search(query).then((results) => { + requestIdleCallback(() => { + searchResultsContainerEl.replaceChildren(...results.map((name) => searchResultsEl[name])); + }, { timeout: 100 }); + }); + }, { timeout: 100 }); + } + } - if (location.pathname == '/chat') - ghostdiv = document.getElementById("ghostdiv-chat"); - else - ghostdiv = text.parentNode.getElementsByClassName("ghostdiv")[0]; + const promises = Object.entries(emojiEngine.kinds).map(([kind, emojis]) => new Promise((res) => { + const tabEl = (() => { + const tab = document.createElement('li'); + const button = document.createElement('button'); + button.type = 'button'; + button.classList.add('nav-link', 'emojitab'); + button.dataset.bsToggle = 'tab'; + button.textContent = kind; + tab.appendChild(button); + emojiTabsEl.appendChild(tab); + addTabClickListener(kind, tab); + return tab; + })(); - if (!ghostdiv) return; + const tabContentEl = (() => { + const tabContent = document.createElement('div'); + return tabContent; + })(); - ghostdiv.textContent = text.value.substring(0, text.selectionStart); + tabContentEls[kind] = tabContentEl; - ghostdiv.insertAdjacentHTML('beforeend', ""); + const tick = () => { + for (const [name, count] of emojis) { + const buttonEl = document.createElement('button'); + buttonEl.type = 'button'; + buttonEl.classList.add('btn', 'm-1', 'px-0', 'emoji2'); + buttonEl.title = `${name} (${count})`; - // Now lets get coordinates + const imgEl = document.createElement('img'); + imgEl.loading = 'lazy'; + imgEl.src = emojiEngine.src(name); + imgEl.alt = name; + buttonEl.appendChild(imgEl); - ghostdiv.style.display = "block"; - let end = ghostdiv.querySelector("span"); - const carot_coords = end.getBoundingClientRect(); - const ghostdiv_coords = ghostdiv.getBoundingClientRect(); - ghostdiv.style.display = "none"; - return { pos: text.selectionStart, x: carot_coords.x, y: carot_coords.y - ghostdiv_coords.y }; -} + const searchClone = buttonEl.cloneNode(true); + const els = [buttonEl, searchClone]; -// Used for anything where a user is typing, specifically for the emoji modal -// Just leave it global, I don't care -let speed_carot_modal = document.createElement("div"); -speed_carot_modal.id = "speed-carot-modal"; -speed_carot_modal.style.position = "absolute"; -speed_carot_modal.style.left = "0px"; -speed_carot_modal.style.top = "0px"; -speed_carot_modal.style.display = "none"; -document.body.appendChild(speed_carot_modal); + if (name in favoriteEmojis) { + const favoriteClone = buttonEl.cloneNode(true); + favoriteClone.title = `${name} (${favoriteEmojis[name]})`; + els.push(favoriteClone); + favoriteClones[name] = favoriteClone; + } + + els.forEach((el) => { + el.addEventListener('click', (e) => { + emojiEngine.onInsert(name); + }); + }); -let e + tabContentEl.appendChild(buttonEl); + searchResultsEl[name] = searchClone; + } -let current_word = ""; -let selecting; -let emoji_index = 0; + res(); -function curr_word_is_emoji() -{ - return current_word && current_word.charAt(0) == ":" && - current_word.charAt(current_word.length-1) != ":"; -} + } + requestIdleCallback(tick, { timeout: 250 }); + })); -function close_inline_speed_emoji_modal() { - selecting = false; - speed_carot_modal.style.display = "none"; -} + Promise.all(promises).then(() => { + for (const [name] of favorites) { + if (!(name in favoriteClones)) { + continue; + } + + favoriteContentEl.appendChild(favoriteClones[name]); + } + }); -function populate_speed_emoji_modal(results, textbox) -{ - selecting = true; - - if (!results || results.size === 0) - { - speed_carot_modal.style.display = "none"; - return -1; - } - - emoji_index = 0; - speed_carot_modal.scrollTop = 0; - speed_carot_modal.innerHTML = ""; - const MAXXX = 50; - // Not sure why the results is a Set... but oh well - let i = 0; - for (let emoji of results) - { - let name = emoji.name - - if (i++ > MAXXX) return i; - let emoji_option = document.createElement("div"); - emoji_option.className = "speed-modal-option emoji-option " + (i === 1 ? "selected" : ""); - emoji_option.tabIndex = 0; - let emoji_option_img = document.createElement("img"); - emoji_option_img.className = "speed-modal-image emoji-option-image"; - // This is a bit - emoji_option_img.src = `${SITE_FULL_IMAGES}/e/${name}.webp` - let emoji_option_text = document.createElement("span"); - - emoji_option_text.title = name; - - if (emoji.author_username !== undefined && emoji.author_username !== null) - emoji_option_text.title += "\nauthor\t" + emoji.author_username - - if (emoji.count !== undefined) - emoji_option_text.title += "\nused\t" + emoji.count; - - emoji_option_text.textContent = name; - - if (current_word.includes("#")) name = `#${name}` - if (current_word.includes("!")) name = `!${name}` - - emoji_option.addEventListener('click', () => { - close_inline_speed_emoji_modal() - textbox.value = textbox.value.replace(new RegExp(current_word+"(?=\\s|$)", "gi"), `:${name}: `) - textbox.focus() - if (typeof markdown === "function" && textbox.dataset.preview) { - markdown(textbox) - } - }); - // Pack - emoji_option.appendChild(emoji_option_img); - emoji_option.appendChild(emoji_option_text); - speed_carot_modal.appendChild(emoji_option); - } - if (i === 0) speed_carot_modal.style.display = "none"; - else speed_carot_modal.style.display = "initial"; - return i; -} - -function update_speed_emoji_modal(event) -{ - const box_coords = update_ghost_div_textarea(event.target); - - box_coords.x = Math.min(box_coords.x, screen_width - 150) - - let text = event.target.value; - - // Unused, but left incase anyone wants to use this more efficient method for emojos - switch (event.data) - { - case ':': - emoji_typing_state = true; - break; - case ' ': - emoji_typing_state = false; - break; - default: - break; - } - - // Get current word at string, such as ":marse" or "word" - let coords = text.indexOf(' ',box_coords.pos); - current_word = /:[!#a-zA-Z0-9_]+(?=\n|$)/.exec(text.slice(0, coords === -1 ? text.length : coords)); - if (current_word) current_word = current_word[0].toLowerCase(); - - /* We could also check emoji_typing_state here, which is less accurate but more efficient. I've - * kept it unless someone wants to provide an option to toggle it for performance */ - if (curr_word_is_emoji() && current_word != ":") - { - loadEmojis(null, null).then( () => { - let modal_pos = event.target.getBoundingClientRect(); - modal_pos.x += window.scrollX; - modal_pos.y += window.scrollY; - - speed_carot_modal.style.display = "initial"; - speed_carot_modal.style.left = box_coords.x - 30 + "px"; - speed_carot_modal.style.top = modal_pos.y + box_coords.y + 14 + "px"; - - // Do the search (and do something with it) - const resultSet = emojisSearchDictionary.completeSearch(current_word.substring(1).replace(/[#!]/g, "")); - - const found = globalEmojis.filter(i => resultSet.has(i.name)); - - populate_speed_emoji_modal(found, event.target); - }); - } - else { - speed_carot_modal.style.display = "none"; - } -} - -function speed_carot_navigate(event) -{ - if (!selecting) return; - - let select_items = speed_carot_modal.querySelectorAll(".speed-modal-option"); - if (!select_items || !curr_word_is_emoji()) return; - - const modal_keybinds = { - // go up one, wrapping around to the bottom if pressed at the top - ArrowUp: () => emoji_index = ((emoji_index - 1) + select_items.length) % select_items.length, - // go down one, wrapping around to the top if pressed at the bottom - ArrowDown: () => emoji_index = ((emoji_index + 1) + select_items.length) % select_items.length, - // select the emoji - Enter: () => select_items[emoji_index].click(), - } - if (event.key in modal_keybinds) - { - select_items[emoji_index].classList.remove("selected"); - modal_keybinds[event.key](); - select_items[emoji_index].classList.add("selected"); - select_items[emoji_index].scrollIntoView({inline: "end", block: "nearest"}); - event.preventDefault(); - } -} - -function insertGhostDivs(element) { - let forms = element.querySelectorAll("textarea, .allow-emojis"); - forms.forEach(i => { - let ghostdiv - if (i.id == 'input-text-chat') { - ghostdiv = document.getElementsByClassName("ghostdiv")[0]; - } - else { - ghostdiv = document.createElement("div"); - ghostdiv.className = "ghostdiv"; - ghostdiv.style.display = "none"; - i.after(ghostdiv); - } - i.addEventListener('input', update_speed_emoji_modal, false); - i.addEventListener('keydown', speed_carot_navigate, false); - }); -} - -const emojiModal = document.getElementById('emojiModal') - -function loadEmojis(t, inputTargetIDName) -{ - selecting = false; - speed_carot_modal.style.display = "none"; - - if (inputTargetIDName) { - emojiInputTargetDOM = document.getElementById(inputTargetIDName); - emojiModal.addEventListener('hide.bs.modal', () => { - setTimeout(() => { - emojiInputTargetDOM.focus(); - }, 200); - }, {once : true}); - } - - if (t && t.dataset.previousModal) { - emojiModal.addEventListener('hide.bs.modal', () => { - bootstrap.Modal.getOrCreateInstance(document.getElementById(t.dataset.previousModal)).show() - }, {once : true}); - } - - switch (emojiEngineState) { - case "inactive": - emojiEngineState = "loading" - return fetchEmojis(); - case "loading": - // this works because once the fetch completes, the first keystroke callback will fire and use the current value - return Promise.reject(); - case "ready": - return Promise.resolve(); - default: - throw Error("Unknown emoji engine state"); - } -} - -document.getElementById('emojiModal').addEventListener('shown.bs.modal', function () { - focusSearchBar(emojiSearchBarDOM); - setTimeout(() => { - focusSearchBar(emojiSearchBarDOM); - }, 200); - setTimeout(() => { - focusSearchBar(emojiSearchBarDOM); - }, 1000); -}); + setTab(currentTab); + } +})(); \ No newline at end of file diff --git a/files/assets/js/emoji_modal2.js b/files/assets/js/emoji_modal2.js deleted file mode 100644 index 5c694d4a9..000000000 --- a/files/assets/js/emoji_modal2.js +++ /dev/null @@ -1,116 +0,0 @@ -// This code isn't for feeble minds, you might not understand it Dr. Transmisia. -// The dainty hands of a trans goddess wrote this code. Watch the way her fingers -// dance across the keyboard and learn. - -/** - * @typedef {object} EmojiDef - * @property {number} author_id - * @property {string} author_original_username - * @property {string} author_username - * @property {number} count - * @property {number} created_utc - * @property {string} kind - * @property {string} name - * @property {number | null} submitter_id - * @property {string[]} tags - */ - -/** Returns a promise which can be used to await when the event loop is idle. */ -const idle = () => { - return new Promise(resolve => { - requestIdleCallback(resolve); - }); -} - -class TagDict { - /** - * @type {Map>} - * @private - */ - _map = new Map(); - - /** - * @param {string} tag - */ - get = (tag) => { - const set = this._map.get(tag); - - if (!set) { - const newSet = new Set(); - this._map.set(tag, newSet); - return newSet; - } else { - return set; - } - } - - /** - * @param {string} tag - * @param {string} emojiName - */ - add = (tag, emojiName) => { - this.get(tag).add(emojiName); - } - - /** - * @param {string} tag - * @param {string} emojiName - */ - delete = (tag, emojiName) => { - this.get(tag).delete(emojiName); - } - - /** - * If emojiName is not provided, returns whether the tag exists. - * Otherwise, returns whether the tag contains the emoji. - * @param {string} tag - * @param {string} [emojiName] - */ - has = (tag, emojiName) => { - if (emojiName) { - return this.get(tag).has(emojiName); - } else { - return this._map.has(tag); - } - } - - [Symbol.iterator] = () => this._map[Symbol.iterator](); -} - -class EmojiEngine { - tags = new TagDict(); - - /** - * @param {EmojiDef[]} emojis - */ - init = async (emojis) => { - for (const emoji of emojis) { - this.tags.add(emoji.name, emoji.name); - - for (const tag of emoji.tags) { - this.tags.add(tag, emoji.name); - } - - // I left out tagging by author... lets see if anyone complains... - } - } - - search = async(query) => { - if (query?.length < 2) { - return new Set(); - } - - const results = new Set(); - for (const [tag, names] of this.tags) { - if (tag.includes(query) || query.includes(tag)) { - for (const name of names) { - results.add(name); - } - } - } - return results; - } -} - -const emojiEngine = new EmojiEngine(); -emojiEngine.init(); \ No newline at end of file diff --git a/files/routes/static.py b/files/routes/static.py index ea9f163ad..95618d86f 100644 --- a/files/routes/static.py +++ b/files/routes/static.py @@ -87,7 +87,10 @@ def emoji_list(v, kind): @cache.cached(make_cache_key=lambda nsfw:f"emojis_{nsfw}") -def get_emojis(nsfw): +def get_emojis(nsfw = None): + if nsfw is None: + nsfw = g.show_nsfw + emojis = g.db.query(Emoji, User).join(User, Emoji.author_id == User.id).filter(Emoji.submitter_id == None) if not nsfw: @@ -106,14 +109,79 @@ def get_emojis(nsfw): collected.append(emoji.json()) return collected -@app.get("/emojis_json") +@app.get("/emojis.json") @limiter.limit(DEFAULT_RATELIMIT, deduct_when=lambda response: response.status_code < 400) @limiter.limit(DEFAULT_RATELIMIT, deduct_when=lambda response: response.status_code < 400, key_func=get_ID) @auth_required def emojis(v): - return get_emojis(g.show_nsfw) + return get_emojis() + +@cache.cached(make_cache_key=lambda nsfw:f"emoji_tags_{nsfw}") +@app.get("/emoji_tags.json") +@limiter.limit(DEFAULT_RATELIMIT, deduct_when=lambda response: response.status_code < 400) +@limiter.limit(DEFAULT_RATELIMIT, deduct_when=lambda response: response.status_code < 400, key_func=get_ID) +@auth_required +def emoji_tags(v): + emojis = get_emojis() + + tags = {} + + def add_to_tag(tag: str, emoji: Emoji): + #Do not add empty tags. + if not tag: + return + + if tag not in tags: + tags[tag] = [] + + tags[tag].append([emoji['name'], emoji['count']]) + + for emoji in emojis: + add_to_tag(emoji['name'], emoji) + add_to_tag(emoji['name'][len(emoji['kind'].replace(' ', '')):], emoji) + + for tag in emoji['tags']: + add_to_tag(tag, emoji) + + return tags + +@cache.cached(make_cache_key=lambda nsfw:f"emoji_tags_{nsfw}") +@app.get("/emoji_names_count.json") +@limiter.limit(DEFAULT_RATELIMIT, deduct_when=lambda response: response.status_code < 400) +@limiter.limit(DEFAULT_RATELIMIT, deduct_when=lambda response: response.status_code < 400, key_func=get_ID) +@auth_required +def emoji_names_count(v): + emojis = get_emojis() + + names = {} + + for emoji in emojis: + names[emoji['name']] = emoji['count'] + + return names +@cache.cached(make_cache_key=lambda nsfw:f"emoji_tags_{nsfw}") +@app.get("/emoji_kinds.json") +@limiter.limit(DEFAULT_RATELIMIT, deduct_when=lambda response: response.status_code < 400) +@limiter.limit(DEFAULT_RATELIMIT, deduct_when=lambda response: response.status_code < 400, key_func=get_ID) +@auth_required +def emoji_kinds(v): + order = ["Marsey", "Platy", "Wolf", "Donkey Kong", "Tay", "Capy", "Carp", "Marsey Flags", "Marsey Alphabet", "Classic", "Rage", "Wojak", "Misc"] + emoji_kinds = {} + + for kind in order: + emoji_kinds[kind] = [] + + for emoji in get_emojis(): + kind = emoji['kind'] + if kind not in emoji_kinds: + emoji_kinds[kind] = [] + + emoji_kinds[kind].append([emoji['name'], emoji['count']]) + + # Flask will sort the keys alphabetically, so we need to jsonify this manually. + return json.dumps(emoji_kinds) @app.get('/sidebar') @limiter.limit(DEFAULT_RATELIMIT, deduct_when=lambda response: response.status_code < 400) diff --git a/files/templates/comments.html b/files/templates/comments.html index a069436e2..ed50cbb2c 100644 --- a/files/templates/comments.html +++ b/files/templates/comments.html @@ -255,7 +255,7 @@ {% if v and (v.id == c.author_id or v.admin_level >= PERMS['POST_COMMENT_EDITING']) %} - +
diff --git a/files/templates/userpage/banner.html b/files/templates/userpage/banner.html index e9257b4bf..614b45f48 100644 --- a/files/templates/userpage/banner.html +++ b/files/templates/userpage/banner.html @@ -191,7 +191,7 @@ - +
{{macros.emoji_btn('input-message')}} @@ -208,7 +208,7 @@
- +
{{macros.emoji_btn('coin-transfer-reason')}} {{macros.gif_btn('coin-transfer-reason')}} @@ -221,7 +221,7 @@
- +
{{macros.emoji_btn('bux-transfer-reason')}} {{macros.gif_btn('bux-transfer-reason')}} @@ -508,7 +508,7 @@ {% if v and v.id != u.id %} - +
{{macros.emoji_btn('input-message-mobile')}} @@ -525,7 +525,7 @@
- +
{{macros.emoji_btn('coin-transfer-reason-mobile')}} {{macros.gif_btn('coin-transfer-reason-mobile')}} @@ -538,7 +538,7 @@
- +
{{macros.emoji_btn('bux-transfer-reason-mobile')}} {{macros.gif_btn('bux-transfer-reason-mobile')}} diff --git a/files/templates/util/macros.html b/files/templates/util/macros.html index 4b86af258..7ebec52e1 100644 --- a/files/templates/util/macros.html +++ b/files/templates/util/macros.html @@ -112,7 +112,7 @@ {% macro emoji_btn(textarea_id, previous_modal) %} - {% endmacro %} @@ -134,7 +134,7 @@
- +
@@ -160,7 +160,7 @@
{% else %}
- +
@@ -346,7 +346,7 @@ {{gif_btn('input-text-chat')}} {{file_btn('file', False, True)}} - +