Internet Roadtrip Keybinds

Adds keybinds to Internet Roadtrip

// ==UserScript==
// @name        Internet Roadtrip Keybinds
// @namespace   http://tampermonkey.net/
// @version     1.4
// @description Adds keybinds to Internet Roadtrip
// @author      LoG42
// @license     MIT
// @grant       GM.addStyle
// @grant       GM.info
// @grant       GM.getValue
// @grant       GM.setValue
// @match       https://neal.fun/internet-roadtrip/
// @run-at      document-start
// @icon        https://files.catbox.moe/o5iu1d.png
// @require     https://cdn.jsdelivr.net/npm/[email protected]
// @require     https://cdn.jsdelivr.net/npm/[email protected]/lodash.min.js
// @require     https://greasemonkey.github.io/gm4-polyfill/gm4-polyfill.js
// ==/UserScript==

// type hints
// import IRF from 'internet-roadtrip-framework';
// import _ from 'lodash';

(async () => { 
    if (!IRF.isInternetRoadtrip) return;

    const optionsBody = await IRF.dom.options;
    const chatVDOM = await IRF.vdom.chat;
    const containerVDOM = await IRF.vdom.container;
    const mapVDOM = await IRF.vdom.map;
    const optionsVDOM = await IRF.vdom.options;
    const radioVDOM = await IRF.vdom.radio;
    const wheelVDOM = await IRF.vdom.wheel;

    let optionsStyle = document.createElement('style');
    optionsStyle.textContent = `
    .option-number {
        top: -2vh;
        font-size: 6vh;
        -webkit-text-stroke: 0.2px white;
        position: relative;
        text-align: center;
    }
    `
    optionsBody.appendChild(optionsStyle);

    class OptionChoices {
        /**
         * 
         * @param {HTMLCollectionOf<Element>} options 
         */
        constructor(options) {
            this.options = _.sortBy(options,function (option) {return getRotation(option);});
            for (let i = 0; i < this.options.length; i++) {
                let item = this.options[i];
                let optionKey = i+1;

                if (item.querySelector('.option-number')) {
                    item.querySelector('.option-number').textContent = optionKey;
                } else {
                    let optionNumber = document.createElement('h1');
                    optionNumber.classList.add('option-number');
                    optionNumber.textContent = optionKey;
                    item.insertBefore(optionNumber,item.firstChild);
                }
            }
        }
        get firstOption() {
            if (this.options[0]) {
                return this.options[0];
            }
            return null;
        }

        get middleOption() {
            let middle = _.minBy(this.options,function(option) {return Math.abs(getRotation(option));})
            if (middle) {
                return middle;
            }
            return null;
        }

        get lastOption() {
            if (this.options.at(-1)) {
                return this.options.at(-1);
            }
            return null;
        }
        /**
         * 
         * @param {Number} n 
         */
        getNthElement(n) {
            if (!Number.isInteger(n)) {
                return null;
            }
            if (n >= 0) {
                if (this.options[n]) {
                    return this.options[n];
                }
            } else {
                if (this.options[this.options.length+n]) {
                    return this.options[this.options.length+n];
                }
            }
            return null;
        }
    }
    
    const defaultKeybinds = {
        option0: 'Digit1',
        option1: 'Digit2',
        option2: 'Digit3',
        option3: 'Digit4',
        option4: 'Digit5',
        option5: 'Digit6',
        option6: 'Digit7',
        option7: 'Digit8',
        option8: 'Digit9',
        option9: 'Digit0',
        option10: 'Minus',
        option11: 'Equal',
        typeOptionNum: 'KeyF',
        forward: 'KeyW',
        left: 'KeyA',
        right: 'KeyD',
        pathfinder: 'KeyQ',
        seek: 'KeyS',
        honk: 'Space',
        radioPower: 'KeyR',
        radioVolDown: 'Comma',
        radioVolUp: 'Period',
        mapExpand: 'KeyE',
        toggleChat: 'KeyT',
        openDiscord: 'Shift + Backquote',
        bandwagon: 'Shift + Slash'
    };

    const keybindNames = {
        option0: 'Option 1',
        option1: 'Option 2',
        option2: 'Option 3',
        option3: 'Option 4',
        option4: 'Option 5',
        option5: 'Option 6',
        option6: 'Option 7',
        option7: 'Option 8',
        option8: 'Option 9',
        option9: 'Option 10',
        option10: 'Option 11',
        option11: 'Option 12',
        typeOptionNum: 'Type Option Number',
        forward: 'Forwardmost Option',
        left: 'Leftmost Option',
        right: 'Rightmost Option',
        pathfinder: 'Pathfinder Option',
        seek: 'Seek',
        honk: 'Honk',
        radioPower: 'Power On/Off Radio',
        radioVolDown: 'Radio Volume Down',
        radioVolUp: 'Radio Volume Up',
        mapExpand: 'Expand/Contract Map',
        toggleChat: 'Open/Close Chat Window',
        openDiscord: 'Open Discord Server',
        bandwagon: 'Bandwagon'
    };
    /**
     * @type {Object.<string,HTMLInputElement>} keybindInputStorage
     */
    let keybindInputStorage = {}
    /**
     * @type {Object.<string,string>} settings
     */
    let settings = {}
    let optionSelection = new OptionChoices(optionsBody.getElementsByClassName('option'));

    for (const option in defaultKeybinds) {
        if (Object.hasOwnProperty.call(defaultKeybinds, option)) {
            settings[option] = await GM.getValue(option,defaultKeybinds[option]);
        }
    }
    document.addEventListener('keydown', handler);

    containerVDOM.state.updateData = new Proxy(containerVDOM.state.updateData, {
        apply: (target,thisArgs,args) => {
            let options = optionsBody.getElementsByClassName('option');
            optionSelection = new OptionChoices(options);
            return Reflect.apply(target, thisArgs, args);
        }
    })


    /** 
     * 
     * @param {KeyboardEvent} e 
     */

    function handler(e) {
        if (e.target !== document.body) {
            return;
        }
        let formattedKeyEvent = formatKeyEvent(e);
        switch (formattedKeyEvent) {
            case settings.radioPower:
                radioVDOM.methods.togglePower();
                break;
            case settings.seek:
                radioVDOM.methods.seek();
                break;
            case settings.honk:
                wheelVDOM.methods.onHonkClick();
                break;
            case settings.mapExpand:
                mapVDOM.methods.toggleExpand();
                break;
            case settings.radioVolDown:
                volSetter(radioVDOM.data.volume - 5);
                break;
            case settings.radioVolUp:
                volSetter(radioVDOM.data.volume + 5);
                break;
            case settings.bandwagon:
                bandwagon();
                break;
            case settings.toggleChat:
                chatVDOM.methods.toggleChat();
                break;
            case settings.openDiscord:
                chatVDOM.methods.openDiscord();
                break;
            default:
                chooseOption(e);
        }
    }

    function volSetter(wantedVol) {
        const newVol = Math.min(Math.max(wantedVol, 0),100);
        const rotation = (27/10*newVol-135)*(Math.PI/180);
        radioVDOM.methods.updateVolumeFromAngle(-Math.PI/2 + rotation);
    }

    /**
     * 
     * @param {KeyboardEvent} e 
     * @returns 
     */
    function chooseOption(e) {
        const formattedKey = formatKeyEvent(e)
        const optionSelect = [
            'option0',
            'option1',
            'option2',
            'option3',
            'option4',
            'option5',
            'option6',
            'option7',
            'option8',
            'option9',
            'option10',
            'option11'
        ];

        // og code
        // if (optionSelect.hasOwnProperty(e.code)){
        //     if (optionsSorted[optionSelect[e.code]]) {
        //         optionsSorted[optionSelect[e.code]].click();
        //         return;
        //     }
        // }

        for (let i = 0; i < optionSelect.length; i++) {
            const element = optionSelect[i];    
            if (settings[element] === formattedKey) {
                let option = optionSelection.getNthElement(i);
                if (option) {
                    option.click();
                    return;
                }
            }
        }

        switch (formattedKey) {
            case settings.left:
                if (optionSelection.firstOption) {
                    optionSelection.firstOption.click();
                }
                break;
            case settings.right:
                if (optionSelection.lastOption) {
                    optionSelection.lastOption.click();
                }
                break;
            case settings.forward: {
                let middle = optionSelection.middleOption;
                if (middle) {
                    middle.click();
                }
            }
                break;
            case settings.pathfinder: {
                let pathfinder = optionsBody.querySelector('.pathfinder-chosen-option');
                if (pathfinder) {
                    pathfinder.click();
                }
            }
                break;
            case settings.typeOptionNum: {
                let optionNum = Number(prompt('Type in the option number you would like to click (1-indexed, from left).\nUse negative numbers to selected an option from right.\nType during the same stop to prevent unexpected behavior.'));

                if (isNaN(optionNum) || !Number.isInteger(optionNum) || optionNum === 0) {
                    alert('Not a valid option number!');
                    break;
                }
                let optionChosen = optionNum < 0 ? optionSelection.getNthElement(optionNum) : optionSelection.getNthElement(optionNum-1);
                if (optionChosen) {
                    optionChosen.click();
                    break;
                } else {
                    alert('Option doesn\'t exist!')
                }
            }
            default:
                break;
        }
    }

    function getRotation(elem){
        if (elem.style.rotate) {
            const re = new RegExp('.*(?=deg)')
            return parseFloat(elem.style.rotate.match(re)[0]);
        }
        return null;
    }

    function bandwagon() {
        const curVotes = containerVDOM.data.voteCounts;
        const popOption = parseInt(_.maxBy(Object.keys(curVotes), (o) => curVotes[o]));
        try {
            switch (popOption) {
                case -2:
                    wheelVDOM.methods.onHonkClick();
                    break;
                case -1:
                    radioVDOM.methods.seek();
                default:
                    optionsVDOM.methods.vote(popOption);
                    break;
            }
            console.log(`Joined the bandwagon for option ${popOption}.`);
        } catch (error) {
            console.log("Couldn't bandwagon.");
        }
    }

    const tab = await IRF.ui.panel.createTabFor(GM.info, {
        tabName: 'Keybinds',
        className: 'keybinds-tab'
    });

    const tabStyle = document.createElement('style');
    tabStyle.textContent = `
    .keybinds-tab .row {
        display: flex;
        align-items: center;
        justify-content: space-between;
        margin-bottom: 0.5rem;
    
        input[type=text], button {
            padding: 0.35rem 1rem;
            cursor: pointer;
            border: 1px solid rgba(255, 255, 255, 0.2);
            border-radius: 999px;
            background: transparent;
            color: rgb(255, 255, 255);
            font-size: 0.9rem;
            font-family: inherit;
            transition: 0.2s;
            white-space: nowrap;
        }
    
        input[type=text]:hover, button:hover {
            background: rgba(68, 68, 170, 0.1);
        }
    
        input[type=text]:focus, button:active {
            border: 1px solid rgb(68, 68, 170);
            background: rgb(68, 68, 170);
        }
    
        input[type=text].conflict {
            color:red;
        }
    }
    `
    tab.container.appendChild(tabStyle);
    let instructionsRow = document.createElement('div');
    instructionsRow.classList.add('row');
    let instructionsLabel = document.createElement('ul');
    instructionsLabel.innerHTML = '<li>Set keybinds by clicking on their button and then typing the keybind</li><li>Reset keybinds by double clicking on their button</li><li>Reset All button at the bottom</li><li>Type Escape on a keybind button to unbind keybinds</li><li>Conflicts are marked in red text</li><li>The text on the button after you type in the keybind may be different from what you typed. This is due to keyboard differences. The keybind should still be what you typed.</li>';
    instructionsRow.appendChild(instructionsLabel);
    tab.container.appendChild(instructionsRow);
    tab.container.appendChild(document.createElement('hr'));
    for (const key in keybindNames) {
        if (Object.hasOwnProperty.call(keybindNames, key)) {
            let keybindRow = document.createElement('div');
            keybindRow.classList.add('row');
        
            let labelName = document.createElement('label');
            labelName.textContent = keybindNames[key];
        
            let keybindInput = document.createElement('input')
            keybindInput.type = 'text';
            keybindInput.readOnly = true;
            keybindInput.value = settings[key];
            keybindInput.addEventListener('keydown', async function(e){
                await updateKeybind(keybindInput,key,e);
            });

            keybindInput.addEventListener('dblclick', async function(){
                await resetKeybind(keybindInput,key);
            });
            
            tab.container.appendChild(keybindRow);
            keybindRow.appendChild(labelName);
            keybindRow.appendChild(keybindInput);
            keybindInputStorage[key] = keybindInput;
        }
    }
    checkForConflicts();
    let resetRow = document.createElement('div');
    resetRow.classList.add('row');
    let resetAllButton = document.createElement('button');
    resetAllButton.textContent = 'Reset All'
    resetAllButton.addEventListener('click', async function() {
        if(confirm('Are you sure you want to reset all keybinds? THIS IS IRREVERSIBLE!')) {
            await resetAll();
        }
    })

    resetRow.appendChild(resetAllButton);
    tab.container.appendChild(resetRow);

    /**
     * 
     * @param {HTMLInputElement} keybindInput 
     * @param {string} key 
     * @param {KeyboardEvent} e 
     */
    async function updateKeybind(keybindInput,key,e) {
        if (e.code === 'Escape') {
            settings[key] = 'Not Bound';
            await GM.setValue(key,settings[key])
            keybindInput.value = settings[key];
            checkForConflicts();
            return;
        }
        const formattedEvent = formatKeyEvent(e);
        settings[key] = formattedEvent;
        await GM.setValue(key,settings[key])
        keybindInput.value = settings[key];
        checkForConflicts();
    }

    /**
     * 
     * @param {HTMLInputElement} keybindInput 
     * @param {string} key 
     */
    async function resetKeybind(keybindInput,key) {
        const ogSetting = defaultKeybinds[key];
        settings[key] = ogSetting;
        await GM.setValue(key,ogSetting);
        keybindInput.value = ogSetting;
        checkForConflicts();
    }

    async function resetAll() {
        for (const key in keybindInputStorage) {
            if (Object.hasOwnProperty.call(keybindInputStorage, key)) {
                const keybindInput = keybindInputStorage[key];
                await resetKeybind(keybindInput,key)
            }
        }
    }

    /**
     * 
     * @param {KeyboardEvent} e 
     */
    function formatKeyEvent(e) {
        let shift = e.shiftKey ? 'Shift' : '';
        let meta = e.metaKey ? 'Meta' : '';
        let ctrl = e.ctrlKey ? 'Ctrl' : '';
        let alt = e.altKey ? 'Alt' : '';
        
        let arr = [ctrl,alt,shift,meta,(['Control','Shift','Alt','Meta'].some(sub => e.code.startsWith(sub)) ? '' : e.code)];
        
        return arr.filter((word) => word.length > 0).join(' + ');
    }

    function checkForConflicts() {
        /**
         * @type {Map<string,HTMLInputElement>} conflictMap
         */
        let conflictMap = new Map()

        for (const key in keybindInputStorage) {
            if (Object.hasOwnProperty.call(keybindInputStorage, key)) {
                const keybindInput = keybindInputStorage[key];
                keybindInput.classList.remove('conflict');
                if (keybindInput.value === 'Not Bound') {
                    continue;
                }
                if (conflictMap.has(keybindInput.value)) {
                    keybindInput.classList.add('conflict')
                    conflictMap.get(keybindInput.value).classList.add('conflict')
                } else {
                    conflictMap.set(keybindInput.value,keybindInput);
                }
            }
        }
    }
})();