Keybindings for Inkarnate

Adds keybindings to all mapping function in https://inkarnate.com/

// ==UserScript==
// @name         Keybindings for Inkarnate
// @description  Adds keybindings to all mapping function in https://inkarnate.com/
// @namespace    azzurite
// @version      1.2.0
// @locale       en
// @author       azzurite
// @match        https://inkarnate.com/maps
// @run-at       document-idle
// @noframes
// @grant        none
// ==/UserScript==


(function() {
    'use strict';

    const keybinds = {
        sculpt: {
            mode: {
                add: 'a',
                subtract: 's'
            },
            shape: {
                circle: 'c',
                block: 'b',
                hex: 'h'
            },
            size: {
                more: 'ArrowRight',
                less: 'ArrowLeft',
                moreMore: 'ArrowUp',
                lessLess: 'ArrowDown'
            }
        },
        brush: {
            chooseTexture: {
                open: 'q'
            },
            layer: {
                fg: 'f',
                bg: 'g'
            },
            shape: {
                circle: 'c',
                block: 'b',
                hex: 'h'
            },
            size: {
                more: 'ArrowRight',
                less: 'ArrowLeft',
                moreMore: 'ArrowUp',
                lessLess: 'ArrowDown'
            },
            softness: {
                more: 'o',
                less: 'i',
                moreMore: 'p',
                lessLess: 'u'
            },
            opacity: {
                moreMore: 'l',
                lessLess: 'k'
            }
        },
        object: {
            mode: {
                place: 'q',
                select: 'w'
            },
            layer: {
                fg: 'f',
                bg: 'g'
            },
            chooseObject: {
                open: 'e'
            },
            scale: {
                value: {
                    more: 'o',
                    less: 'i',
                    moreMore: 'p',
                    lessLess: 'u'
                }
            },
        },
        pattern: {
            mode: {
                place: 'q',
                select: 'w'
            },
            choosePattern: {
                open: 'e'
            },
            scale: {
                more: 'o',
                less: 'i',
                moreMore: 'p',
                lessLess: 'u'
            },
            cycleSelection: {
                cycle: 'c'
            }
        },
        text: {
            mode: {
                place: 'q',
                select: 'w'
            },
            size: {
                more: 'o',
                less: 'i',
                moreMore: 'p',
                lessLess: 'u'
            },
            bold: {
                toggle: 'b'
            }
        },
        note: {
            mode: {
                place: 'q',
                select: 'w'
            },
        },
        grid: {
            type: {
                hex: 'h',
                square: 's'
            },
            opacity: {
                more: 'o',
                less: 'i',
                moreMore: 'p',
                lessLess: 'u'
            },
            size: {
                more: 'ArrowRight',
                less: 'ArrowLeft',
                moreMore: 'ArrowUp',
                lessLess: 'ArrowDown'
            },
            width: {
                more: 'l',
                less: 'k'
            }
        },
        global: {
            tools: {
                sculpt: '1',
                brush: '2',
                object: '3',
                pattern: '4',
                text: '5',
                note: '6',
                grid: '7',
                zoom: '8'
            },
            zoom: {
                upleft: 103, // these are key codes for the numpad
                up: 104,
                upright: 105,
                right: 102,
                downright: 99,
                down: 98,
                downleft: 97,
                left: 100,
                zoomIn: 107,
                zoomOut: 109,
                reset: 101
            },
            delete: 'Delete'
        }
    };

    // from https://stackoverflow.com/a/53739792/1805439
    function flattenObject(ob) {
        var toReturn = {};

        for (var i in ob) {
            if (!ob.hasOwnProperty(i)) continue;

            if ((typeof ob[i]) == 'object' && ob[i] !== null) {
                var flatObject = flattenObject(ob[i]);
                for (var x in flatObject) {
                    if (!flatObject.hasOwnProperty(x)) continue;

                    toReturn[i + '.' + x] = flatObject[x];
                }
            } else {
                toReturn[i] = ob[i];
            }
        }
        return toReturn;
    }

    // inkarnate has jquery + angular
    let $ = window.jQuery;
    let angular = window.angular;

    const defaultSteps = {
        more: 1,
        less: -1,
        moreMore: 5,
        lessLess: -5
    };
    let configs = {
        sculpt: {
            size: {
                type: 'number',
                min: 3,
                max: 128
            }
        },
        brush: {
            chooseTexture: {
                type: 'button',
                selector: '#brush-texture-popup'
            },
            size: {
                type: 'number',
                min: 1,
                max: 128
            },
            softness: {
                type: 'number',
                min: 0,
                max: 1,
                more: 0.01,
                less: -0.01,
                moreMore: 0.05,
                lessLess: -0.05,
                decimalPlaces: 2
            },
            opacity: {
                type: 'number',
                min: 0,
                max: 1,
                more: 0.01,
                less: -0.01,
                moreMore: 0.05,
                lessLess: -0.05,
                decimalPlaces: 2
            }
        },
        object: {
            chooseObject: {
                type: 'button',
                selector: '#object-popup'
            },
            scale: {
                value: {
                    type: 'number',
                    min: 0.15,
                    max: 2,
                    more: 0.01,
                    less: -0.01,
                    moreMore: 0.05,
                    lessLess: -0.05,
                    decimalPlaces: 2
                }
            },
        },
        pattern: {
            choosePattern: {
                type: 'button',
                selector: '#pattern-popup'
            },
            scale: {
                type: 'number',
                alternateProperty: 'object.globalScale',
                min: 0.15,
                max: 2,
                more: 0.01,
                less: -0.01,
                moreMore: 0.05,
                lessLess: -0.05,
                decimalPlaces: 2
            },
            cycleSelection: {
                type: 'cycleSelection'
            }
        },
        text: {
            size: {
                type: 'number',
                min: 5,
                max: 128
            },
            bold: {
                type: 'toggle'
            },
            color: {
                type: 'button',
                selector: 'button.color-picker'
            },
            shadow: {
                type: 'button',
                selector: '#text-shadow-popup'
            }
        },
        grid: {
            opacity: {
                type: 'number',
                min: 0,
                max: 1,
                more: 0.01,
                less: -0.01,
                moreMore: 0.05,
                lessLess: -0.05,
                decimalPlaces: 2
            },
            size: {
                type: 'number',
                min: 16,
                max: 256
            },
            width: {
                type: 'number',
                min: 1,
                max: 5,
                more: 0.5,
                less: -0.5
            }
        },
    }

    function getScope() {
        return angular.element(document.getElementById('map-builder-view')).scope();
    }

    function getByPath(obj, path, separator = '.') {
        var parts = path.split('.');
        return parts.reduce((prev, curr) => prev && prev[curr], obj)
    }

    function setByPath(obj, path, value) {
        var parts = path.split('.');
        parts.reduce(function(prev, cur, idx) {
            if (idx == parts.length - 1) {
                prev[cur] = value;
            } else {
                return prev[cur] || {};
            }
        }, obj);
    }

    function changeScope(func) {
        let scope = getScope();
        scope.$apply(() => {
            func(scope);
        });
    }

    function getCurrentTool() {
        return getScope().tool;
    }

    function clampNumber(config, num) {
        const min = config.min || 0;
        const max = config.max || 128;
        return Math.min(Math.max(min, num), max);
    }

    function roundNumber(config, num) {
        const decimalPlaces = config.decimalPlaces || 1;
        const multiplier = Math.pow(10, decimalPlaces);
        return Math.round(num * multiplier) / multiplier;
    }

    function getStep(config, stepName) {
        return config[stepName] || defaultSteps[stepName];
    }

    let originalSelection = [];
    let curIdx = -1;
    function cycleSelection() {
        let scopeSelection = getScope().selected;

        if (scopeSelection.length <= 1 && curIdx === -1) {
            return;
        }

        if (curIdx === -1) {
            curIdx = 0;
            originalSelection = scopeSelection;
            changeScope(s => {
                s.selected = [originalSelection[curIdx]];
            });
            return;
        }

        if (originalSelection[curIdx] === scopeSelection[0]) {
            if (++curIdx >= originalSelection.length) {
                curIdx = -1;
                changeScope(s => {
                    s.selected = originalSelection;
                });
            } else {
                changeScope(s => {
                    s.selected = [originalSelection[curIdx]];
                });
            }
        } else {
            // user selected something different
            curIdx = -1;
            originalSelection = [];
            cycleSelection();
        }
    }

    function handleKeyForTool(tool, key) {
        const binds = keybinds[tool];

        const flatBinds = flattenObject(binds);
        for (const [path, bind] of Object.entries(flatBinds)) {
            if (key !== bind) {
                continue;
            }

            const pathArr = path.split('.');
            let propPath = tool + '.' + pathArr.slice(0, pathArr.length - 1).join('.');
            const val = pathArr[pathArr.length - 1];

            //console.log(`Found bind: ${propPath} with value ${val}`);

            const config = getByPath(configs, propPath) || {};
            if (config.alternateProperty) {
                propPath = config.alternateProperty;
            }
            switch (config.type) {
                case 'cycleSelection': {
                    cycleSelection();
                    break;
                }
                case 'toggle': {
                    changeScope(scope => {
                        const oldVal = getByPath(scope, propPath);
                        setByPath(scope, propPath, !oldVal);
                    });
                    break;
                }
                case 'button': {
                    //console.log(`Clicking button ${config.selector}`);
                    $(config.selector).click();
                    break;
                }
                case 'number': {
                    changeScope(scope => {
                        const oldVal = getByPath(scope, propPath);
                        const step = getStep(config, val);
                        const newVal = oldVal + step;
                        const newValClamped = clampNumber(config, newVal);
                        const rounded = roundNumber(config, newValClamped);
                        //console.log(`Set ${propPath} to ${rounded}`);
                        setByPath(scope, propPath, rounded);
                    });
                    break;
                }
                default:
                case 'select': {
                    changeScope(scope => {
                        setByPath(scope, propPath, val);
                    });
                    break;
                }
            }
        }
    }

    function handleZoom(action, altKey) {
        function move(direction, amount) {
            changeScope(s => { s.zoom.translate[direction] += amount * (altKey ? 3 : 1); });
        }
        function zoom(amount) {
            changeScope(s => {
                const newScale = s.zoom.scale * amount;
                s.zoom.scale = clampNumber({min: 1, max: 5}, roundNumber({decimalPlaces: 2}, newScale));
            });
        }

        const actions = {
            upleft: () => { move('y', 10); move('x', 10); },
            up: move.bind(null, 'y', 10),
            upright: () => { move('y', 10); move('x', -10); },
            right: move.bind(null, 'x', -10),
            downright: () => { move('y', -10); move('x', -10); },
            down: move.bind(null, 'y', -10),
            downleft: () => { move('y', -10); move('x', 10); },
            left: move.bind(null, 'x', 10),
            zoomIn: zoom.bind(null, 1.05),
            zoomOut: zoom.bind(null, 0.95),
            reset: () => {
                changeScope(s => {
                    s.zoom.scale = 1;
                    s.zoom.translate.x = 0;
                    s.zoom.translate.y = 0;
                });
            }
        };

        actions[action]();
    }

    function keyHandler(e) {
        if (document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA') {
            return;
        }

        if (keybinds.global.delete === e.key) {
            changeScope(s => {
                if (s.radialMenuActions().includes('delete')) {
                    s.performRadialMenuAction('delete');
                }
            });
            return
        }

        const isZoomKey = Object.values(keybinds.global.zoom).includes(e.keyCode);
        if (isZoomKey) {
            const [zoomAction, _] = Object.entries(keybinds.global.zoom).find(elem => elem[1] === e.keyCode);
            handleZoom(zoomAction, e.altKey);
            return;
        }
        const isToolKey = Object.values(keybinds.global.tools).includes(e.key);
        if (isToolKey) {
            const [tool, _] = Object.entries(keybinds.global.tools).find(elem => elem[1] === e.key);
            changeScope(scope => scope.selectTool(tool));
            return;
        }

        // console.log(`Keypress: ${e.key}`);
        let cur = getCurrentTool();
        // console.log(`Cur tool: ${cur}`);
        if (!keybinds[cur]) {
            // console.log(`No Keybinds for tool "${getCurrentTool()}" configured.`);
            return;
        }

        handleKeyForTool(cur, e.key);
    }


    $(document).keydown(keyHandler);
})();