Enhanced data explorer text pad

Adds shift + enter, tab, shift + tab, ctrl+k/c, ctrl+k/u support to cosmos data explorer textarea.

// ==UserScript==
// @name         Enhanced data explorer text pad
// @namespace    http://tampermonkey.net/
// @version      0.1
// @description  Adds shift + enter, tab, shift + tab, ctrl+k/c, ctrl+k/u support to cosmos data explorer textarea.
// @author       You
// @match        https://cosmos.azure.com/explorer.html*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=azure.com
// @grant        none
// @license MIT
// ==/UserScript==

(function() {
    'use strict';

    class LineCollection {
        constructor(multilineString) {
            let segments = multilineString.split(newLine);
            let lines = [];
            let startIndex = 0;
            let endIndex = 0;
            for(let i=0; i<segments.length; i++) {
                let segment = segments[i];
                endIndex = startIndex + segment.length;
                lines.push(new Line(segment, startIndex, endIndex, this)); //NOTE: new line character \n is not included in line..-=

                startIndex = endIndex + newLine.length;
            }
            this.lines = lines;
        }
        getLineByIndex(index){
            return this.lines.find(function(line){ return line.startIndex <= index && line.endIndex >= index; });
        }
        toString(addLineNumbers) {
            return this.lines.map(function(line, i){
                return addLineNumbers ?
                    `${i.toString().padStart(2, " ")}: ${line.value} [${line.startIndex.toString().padStart(3," ")}-${line.endIndex.toString().padEnd(3," ")}]` :
                    line.value;
            }).join(newLine);
        }
    }
    class Line {
        constructor(lineText, startIndex, endIndex, collection) {
            this.value = lineText;
            this.startIndex = startIndex;
            this.endIndex = endIndex;
            this.collection = collection;
        }
        isContainedBy(spanStart, spanEnd) {
            return this.startIndex >= spanStart && this.endIndex <= spanEnd;
        }
        isInterceptedBy(spanStart, spanEnd) {
            return (spanStart > this.startIndex && spanStart <= this.endIndex) ||
                (spanEnd >= this.startIndex && spanEnd < this.endIndex);
        }
        charsBeforeIndexMatchRegex(index, regex) {
            if(index < this.startIndex) throw new Error("Index must be >= startIndex.");
            if(index > this.endIndex) throw new Error("Index must be <= endIndex.");
            let piece = this.value.substring(0, index - this.startIndex);
            return piece.length ? piece.match(regex) : true;
        }
        insert(index, value) {
            if(index < this.startIndex) throw new Error("Index must be >= startIndex.");
            if(index > this.endIndex) throw new Error("Index must be <= endIndex.");
            if(this.collection) {
                let lineFound = false;
                for(let i=0; i< this.collection.lines.length; i++){
                    let line = this.collection.lines[i];
                    if(lineFound) {
                        line.startIndex += value.length;
                        line.endIndex += value.length;
                    }
                    else if(line == this) {
                        lineFound = true;
                        let piece1 = line.value.substring(0, index - line.startIndex);
                        let piece2 = line.value.substring(index - line.startIndex);
                        line.value = `${piece1}${value}${piece2}`;
                        line.endIndex += value.length;
                    }
                }
            }
            else throw new Error("Line is not part of a LineCollection. This can result in unexpected behavior.");
        }
        extract(index, length) {
            if(index < this.startIndex) throw new Error("Index must be >= startIndex.");
            if(index > this.endIndex) throw new Error("Index must be <= endIndex.");
            if(index + length > this.endIndex) throw new Error("Index + length must be <= endIndex.");
            if(this.collection) {
                let lineFound = false;
                for(let i=0; i< this.collection.lines.length; i++){
                    let line = this.collection.lines[i];
                    if(lineFound) {
                        line.startIndex -= length;
                        line.endIndex -= length;
                    }
                    else if(line == this) {
                        lineFound = true;
                        let piece1 = line.value.substring(0, index - this.startIndex);
                        let piece2 = line.value.substring((index - this.startIndex) + length);
                        line.value = `${piece1}${piece2}`;
                        line.endIndex -= length;
                    }
                }
            }
            else throw new Error("Line is not part of a LineCollection. This can result in unexpected behavior.");
        }
        substr(index, length) {
            if(index < this.startIndex) throw new Error("Index must be >= startIndex.");
            if(index > this.endIndex) throw new Error("Index must be <= endIndex.");
            if(index + length > this.endIndex) throw new Error("Index + length must be <= endIndex.");
            return this.value.substr(index - this.startIndex, length);
        }
    }

    let comment = "//";
    let newLine = "\n";
    let tab = "    ";
    let textarea = null;
    let previousKeyDownEvent = null;
    (function registerKeyDownHandler (){
        try{
            textarea = document.querySelector("textarea#input:not(.registered-keyDownHandler)");
        }catch(ex){}
        if(textarea) {
            console.log("found textarea..");
            textarea.classList.add("registered-keyDownHandler");
            textarea.addEventListener("keydown", keyDownHandler);
        }
        else setTimeout(registerKeyDownHandler, 50);
    })();

    function keyDownHandler(e){
        //printEvent(previousKeyDownEvent, e);
        let haultEvent = false;
        let start = e.target.selectionStart;
        let end = e.target.selectionEnd;
        let value = e.target.value;
        if(
            e.key == "Control" || //Ctrl
            e.keyCode == 75 && e.ctrlKey //Ctrl+K
        ) {
            haultEvent = true;
        }
        else if(e.keyCode == 8 && start == end) { //backspace and no selected text
            let lineStartIndex = indexOfLineStart(value, start);
            if(lineStartIndex > 0 && lineStartIndex < start){ //line starts with spaces
                haultEvent = true;
                let spaceCountToRemove = (start - lineStartIndex) % tab.length;
                if(spaceCountToRemove == 0) spaceCountToRemove = tab.length;
                let text = value.substring(0, start - spaceCountToRemove) + value.substring(end, value.length);
                setReactBoundElementValue(e.target, text);
                e.target.selectionStart = start + tab.length;
                e.target.selectionEnd = start + tab.length;
            }
        }
        else if(e.keyCode == 13 && e.shiftKey) { //Shft+Enter
            haultEvent = true;
            let text = value.substring(0, start) + newLine + value.substring(end, value.length);
            setReactBoundElementValue(e.target, text);
            e.target.selectionStart = start + newLine.length;
            e.target.selectionEnd = start + newLine.length;
        }
        else if(e.keyCode == 9 && !e.shiftKey) { // [Tab]
            haultEvent = true;
            if(start == end){
                let collection = new LineCollection(value);
                let line = collection.getLineByIndex(start);
                line.insert(start, tab)
                let text = collection.toString();
                setReactBoundElementValue(textarea, text);
                    textarea.selectionStart = start + tab.length;
                    textarea.selectionEnd = end + tab.length;
            }
            else tryAddLinePrefix(e.target, tab);
        }
        else if(e.keyCode == 9 && e.shiftKey) { // [Shft] + [Tab]
            haultEvent = true;
            if(start == end){
                let collection = new LineCollection(value);
                let line = collection.getLineByIndex(start);
                if(line.substr(start - tab.length, tab.length) == tab) { //chars preceding cursor index are tab
                    line.extract(start - tab.length, tab.length);
                    let text = collection.toString();
                    setReactBoundElementValue(textarea, text);
                    textarea.selectionStart = start - tab.length;
                    textarea.selectionEnd = end - tab.length;
                }
            }
            else tryRemoveLinePrefix(e.target, tab);
        }
        else if( //Ctrl+K -> Ctrl+C
            e.keyCode == 67 && e.ctrlKey && //Ctrl+C
            previousKeyDownEvent.keyCode == 75 && previousKeyDownEvent.ctrlKey //Ctrl+K
        ) {
            haultEvent = true;
            tryAddLinePrefix(e.target, comment);
        }
        else if( //Ctrl+K -> Ctrl+U
            e.keyCode == 85 && e.ctrlKey && //Ctrl+U
            previousKeyDownEvent.keyCode == 75 && previousKeyDownEvent.ctrlKey //Ctrl+K
        ) {
            haultEvent = true;
            tryRemoveLinePrefix(e.target, comment);
        }
        if(haultEvent){
            printEvent(previousKeyDownEvent, e);
            e.preventDefault();
            e.stopPropagation();
        }
        previousKeyDownEvent = e;
    }

    function tryAddLinePrefix(textarea, prefix){
        let start = textarea.selectionStart;
        let end = textarea.selectionEnd;
        let value = textarea.value;
        let firstSelectedLine = true;
        let collection = new LineCollection(value);
        for(let i = 0; i<collection.lines.length; i++) {
            let line = collection.lines[i];
            let intercepted = line.isInterceptedBy(start, end);
            if(intercepted || line.isContainedBy(start, end)) {
                line.insert(line.startIndex, prefix);
                if(start == end || (intercepted && firstSelectedLine)) start += prefix.length;
                end += prefix.length;
                firstSelectedLine = false;
            }
        }
        let text = collection.toString();
        setReactBoundElementValue(textarea, text);
        textarea.selectionStart = start;
        textarea.selectionEnd = end;
    }

    function tryRemoveLinePrefix(textarea, prefix){
        let start = textarea.selectionStart;
        let end = textarea.selectionEnd;
        let value = textarea.value;
        let firstSelectedLine = true;
        let collection = new LineCollection(value);
        for(let i = 0; i<collection.lines.length; i++) {
            let line = collection.lines[i];
            let intercepted = line.isInterceptedBy(start, end);
            if(intercepted || line.isContainedBy(start, end)) {
                if(line.value.indexOf(prefix) == 0) { //starts with prefix characters e.g. //, <spaces>, etc
                    line.extract(line.startIndex, prefix.length);
                    if(start == end || (intercepted && firstSelectedLine)) start -= prefix.length;
                    end -= prefix.length;
                }
                firstSelectedLine = false;
            }
        }
        let text = collection.toString();
        setReactBoundElementValue(textarea, text);
        textarea.selectionStart = start;
        textarea.selectionEnd = end;
    }

    //index of the begining of the line with the cursor (assuming only whitespace characters exist prior to the cursor), else -1
    function indexOfLineStart(line, selectionStartIndex){
        let preSelectionText = line.substring(0, selectionStartIndex);
        let lineStartIndex = preSelectionText.lastIndexOf(newLine);
        if(lineStartIndex < 0) lineStartIndex = 0;
        let lineStartText = preSelectionText.substring(lineStartIndex, preSelectionText.length);
        return lineStartText.match(/^ *$/g) ? lineStartIndex : -1
    }

    function setReactBoundElementValue(element, value) {
        const valueSetter = Object.getOwnPropertyDescriptor(element, 'value').set;
        const prototype = Object.getPrototypeOf(element);
        const prototypeValueSetter = Object.getOwnPropertyDescriptor(prototype, 'value').set;
        if (valueSetter && valueSetter !== prototypeValueSetter) {
            prototypeValueSetter.call(element, value);
        } else {
            valueSetter.call(element, value);
        }
        element.dispatchEvent(new Event('input', { bubbles: true }));
    }

    function printEvent(/*params*/ events) {
        let sb = [];
        for(let i = 0; i < arguments.length; i++) {
            let e = arguments[i];
            if(e) {
                let ctrl = e.ctrlKey ? "Ctrl+" : "";
                let alt = e.altKey ? "Alt+" : "";
                let shift = e.shiftKey ? "Shft+": "";
                let char = e.key ?? e.keyCode;
                if(char == "Control") char = "..";
                sb.push(`${e.type}: ${ctrl}${alt}${shift}${char}`);
            }
            else sb.push(`event #${i} is null`);
        }
        console.log(sb.join(", "));
    }
})();