Wanikani Forums: Emoter

Custom emote handler

// ==UserScript==
// @name         Wanikani Forums: Emoter
// @namespace    http://tampermonkey.net/
// @version      1.2.5
// @description  Custom emote handler
// @author       Kumirei
// @include      https://community.wanikani.com/*
// @grant        none
// @run-at       document-end
// ==/UserScript==

;(function () {
    const COMMAND_TEMPLATE = /!emote\s+(\w+)\s+(\w+)\s+(["“„](\S+)["”])?/i
    const EMOTE_TEMPLATE = /<abbr title="\w+">!\[(\w+)\|\d+x\d+\]\([^)]+\)<\/abbr>/g

    let prettyTextEmoji

    waitForRequire().then(() => {
        registerEmotes()
        setInterval(prepareEditor, 1000)
    })

    // Waits for window.require to be available
    function waitForRequire() {
        return new Promise((res, rej) => {
            const interval = setInterval(() => {
                try {
                    if (!window.require) return
                    prettyTextEmoji = window.require('pretty-text/emoji')
                    if (!prettyTextEmoji?.registerEmoji) return // Wait until we can register emotes, too
                    clearInterval(interval)
                } catch (error) {}
                res()
            }, 100)
        })
    }

    // Register emotes in Discord
    function registerEmotes() {
        const cache = get_local()
        const register = prettyTextEmoji?.registerEmoji
        for (let [name, { url }] of Object.entries(cache.emotes)) register?.(name, url, 'Emoter')
    }

    // Fetch local storage cache
    function get_local() {
        const cache = JSON.parse(localStorage.getItem('Emoter') || '{}')
        return Object.assign({ size: 40, emotes: {} }, cache)
    }

    // Set up the editor to show the input and the preview to render the real content
    function prepareEditor() {
        const editor = document.querySelector('.d-editor-input')
        if (!editor || editor.emoter?.ready) return

        // Sets or wraps getters and setters of textarea element's "value" property
        let set = (text) => {
            text = unModifyText(text)
            return Object.getOwnPropertyDescriptor(Object.getPrototypeOf(editor), 'value').set.call(editor, text)
        }
        let get = () => {
            const text = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(editor), 'value').get.call(editor)
            return modifyText(text)
        }
        const oldSet = Object.getOwnPropertyDescriptor(editor, 'value')?.set
        const oldGet = Object.getOwnPropertyDescriptor(editor, 'value')?.get
        if (oldSet) set = (text) => oldSet(unModifyText(text))
        if (oldGet) get = () => modifyText(oldGet())
        Object.defineProperty(editor, 'value', {
            set(text) {
                return set(text)
            },
            get() {
                return get()
            },
            configurable: true,
        })

        editor.value = editor.value // Trigger unModify and modify

        editor.emoter = { ready: true }
    }

    // Replaces cooked emotes with :name:
    function unModifyText(text) {
        return text.replace(EMOTE_TEMPLATE, ':$1:')
    }

    // Replaces :emotes: and !emotelist and other !commands
    function modifyText(text) {
        const cache = get_local()
        text = process_command(text, cache) // Just need to process one command at a time
        text = replace_emotes(text, cache)
        text = replace_list(text, cache)
        return text
    }

    // Handles commands
    function process_command(text, cache) {
        const command = text.match(COMMAND_TEMPLATE)
        if (!command) return text
        const emotes = cache.emotes
        let [_, word, name, __, value] = command
        word = word.toLowerCase()
        switch (word) {
            case 'new': // !emote new NAME "URL"
                if (value) {
                    emotes[name] = { url: value }
                    prettyTextEmoji?.registerEmoji(name, value, 'Emoter')
                }
                break
            case 'size': // !emote size NAME "SIZE"
                if (value && !value.match(/\d+(x\d+)?/i)) break
                if (name === 'default') cache.size = value
            case 'url': // !emote url NAME "URL"
                if (value && emotes[name]) {
                    emotes[name][word] = value
                    prettyTextEmoji?.extendedEmojiList().set(name, value)
                }
                break
            case 'remove': // !emote remove NAME
                prettyTextEmoji?.extendedEmojiList().delete(name)
                delete emotes[name]
                break
            case 'rename': // !emote rename NAME "NAME"
                if (value && emotes[name]) {
                    delete Object.assign(emotes, { [value]: emotes[name] })[name]
                    prettyTextEmoji?.extendedEmojiList().delete(name)
                    prettyTextEmoji?.registerEmoji(value, emotes[name].url, 'Emoter')
                }
                break
        }
        set_local(cache)

        if (value || word === 'remove') {
            return text.replace(COMMAND_TEMPLATE, ':$2:')
        }

        return text
    }

    // Replaces :emotes: with images
    function replace_emotes(text, cache) {
        return text.replace(/:(\w+):/g, (original, word) => {
            const emote = cache.emotes[word]
            return emote ? get_image(emote.url, word, emote?.size || cache.size) : original
        })
    }

    // Create a table of the available emotes
    function replace_list(raw, cache) {
        const list = Object.entries(cache.emotes)
            .map((e) => get_image(e[1].url, e[0], e[1].size || cache.size).replace('|', `\\|`) + `|` + e[0])
            .join('\n')
        const table = `Image|Name\n-|-\n${list}</table>`
        return raw.replace(/!emotelist/i, table)
    }

    // Creates an image for the emote
    function get_image(url, name, size) {
        let w = size,
            h = size
        if (size.match && size.match(/\d+x\d+/i)) [w, h] = size.split('x')
        return `<abbr title="${name}">![${name}|${w}x${h}](${url})</abbr>`
    }

    // Saves to local storage
    function set_local(cache) {
        localStorage.setItem('Emoter', JSON.stringify(cache))
    }
})()