Editio

Some Visual Studio Code's useful features ported to the web!

// ==UserScript==
// @name         Editio
// @name:zh-CN   Editio
// @namespace    http://tampermonkey.net/
// @version      0.2.3
// @description  Some Visual Studio Code's useful features ported to the web!
// @description:zh-CN 将 Visual Studio Code 的部分实用功能移植到 Web 上!
// @tag          productivity
// @author       PRO-2684
// @match        *://*/*
// @run-at       document-start
// @icon         https://github.com/PRO-2684/gadgets/raw/refs/heads/main/editio/editio.svg
// @license      gpl-3.0
// @grant        unsafeWindow
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @grant        GM_registerMenuCommand
// @grant        GM_unregisterMenuCommand
// @grant        GM_addValueChangeListener
// @require      https://github.com/PRO-2684/GM_config/releases/download/v1.2.1/config.min.js#md5=525526b8f0b6b8606cedf08c651163c2
// ==/UserScript==

(function () {
    const configDesc = {
        "$default": {
            autoClose: false
        },
        pairing: {
            name: "🖇️ Pairing",
            title: "Pairing brackets and quotes",
            type: "folder",
            items: {
                autoClose: {
                    name: "➕ Auto close",
                    title: "Autoclose brackets and quotes (Similar to `editor.autoClosingBrackets` in VSCode)",
                    type: "bool",
                    value: true
                },
                autoDelete: {
                    name: "➖ Auto delete",
                    title: "Remove adjacent closing quotes or brackets (Similar to `editor.autoClosingDelete` in VSCode)",
                    type: "bool",
                    value: true
                },
                autoOvertype: {
                    name: "🚫 Auto overtype",
                    title: "Type over closing brackets - won't work for pairs with the same opening and closing characters (Similar to `editor.autoClosingOvertype` in VSCode)",
                    type: "bool",
                    value: false
                },
                jumping: {
                    name: "🔁 Jumping",
                    title: "Jump between paired brackets - won't work for pairs with the same opening and closing characters",
                    type: "bool",
                    value: true
                },
                pairs: {
                    name: "📜 Pairs",
                    title: "A list of characters that should be paired",
                    type: "str",
                    value: "()[]{}<>\"\"''``",
                    processor: (prop, input, desc) => {
                        if (input.length % 2 !== 0) {
                            throw new TypeError(`The length should be even, but got ${input.length}`);
                        }
                        return input;
                    }
                }
            }
        },
        tabulator: {
            name: "↔️ Tabulator",
            title: "Tab-related features",
            type: "folder",
            items: {
                tabOut: {
                    name: "↪️ Tab out",
                    title: "Pressing (Shift+) Tab to move to the next (or previous) character specified",
                    type: "bool",
                    value: true
                },
                tabOutChars: {
                    name: "📜 Tab out chars",
                    title: "Characters to tab out of",
                    type: "str",
                    value: "()[]{}<>\"'`,:;.",
                }
            }
        },
        url: {
            name: "🔗 URL",
            title: "URL-related features",
            type: "folder",
            items: {
                pasteIntoSelection: {
                    name: "📋 Paste into selection",
                    title: "Paste the URL into the selection in Markdown format",
                    type: "bool",
                    value: true
                },
                recognizedSchemes: {
                    name: "🔍 Recognized schemes",
                    title: "Recognized URL schemes for the URL-related features",
                    value: ["http", "https", "ftp", "ws", "wss"],
                    input: (prop, orig) => {
                        return prompt("🤔 Enter the recognized schemes separated by spaces, or leave empty for any", orig.join(" "));
                    },
                    processor: (prop, input, desc) => {
                        if (input === null) throw new Error("User cancelled the operation");
                        return input.split(" ")
                            .map(s => s.trim())
                            .filter(s => s);
                    },
                    formatter: (prop, value, desc) => {
                        if (value.length === 0) {
                            return `${desc.name}: *ANY*`;
                        } else {
                            return `${desc.name}: ${value.join(" ")}`;
                        }
                    }
                }
            }
        },
        mouse: {
            name: "🖱️ Mouse",
            title: "Mouse-related features",
            type: "folder",
            items: {
                fastScroll: {
                    name: "🚀 Fast scroll",
                    title: "Scroll faster when holding the Alt key",
                    type: "bool",
                    value: false
                },
                fastScrollSensitivity: {
                    name: "🎚️ Fast scroll sensitivity",
                    title: "Scrolling speed multiplier when pressing `Alt`",
                    type: "int",
                    min: 1,
                    max: 10,
                    value: 5,
                },
                consecutiveScrollThreshold: {
                    name: "⏱️ Consecutive scroll threshold",
                    title: "The threshold of time difference for the scroll to be considered consecutive",
                    type: "int",
                    min: 1,
                    max: 1000,
                    value: 200,
                },
                detectionMethod: {
                    name: "🔍 Detection method",
                    title: "The method to detect whether an element can be scrolled",
                    type: "enum",
                    options: ["Normal", "Hacky", "Both"],
                    value: 2,
                }
            }
        },
        advanced: {
            name: "⚙️ Advanced",
            title: "Advanced options",
            type: "folder",
            items: {
                capture: {
                    name: "🔒 Capture",
                    title: "Set `capture` to true for the event listeners",
                    type: "bool",
                    value: false
                },
                defaultPrevented: {
                    name: "🚫 Default prevented",
                    title: "Don't handle the event if it's `defaultPrevented`",
                    type: "bool",
                    value: true
                },
                debug: {
                    name: "🐞 Debug",
                    title: "Enable debug mode",
                    type: "bool",
                    value: false
                }
            }
        }
    };
    const config = new GM_config(configDesc);
    const editio = {}; // Variables to expose if debug mode is enabled

    // Pairing
    // Input-related
    /**
     * Pairs of characters we should consider.
     * @type {Record<string, string>}
     */
    let pairs = {};
    /**
     * Reverse pairs of characters.
     * @type {Record<string, string>}
     */
    let reversePairs = {};
    /**
     * Handle the InputEvent of type "insertText", so as to auto close and overtype on brackets and quotes
     * @param {InputEvent} e The InputEvent.
     */
    function onInsertText(e) {
        /**
         * The input or textarea element that triggered the event.
         * @type {HTMLInputElement | HTMLTextAreaElement}
         */
        const el = e.composedPath()[0];
        const { selectionStart: start, selectionEnd: end, value } = el;
        if ((e.data in pairs) && config.get("pairing.autoClose")) { // The input character is paired and autoClose feature is enabled
            e.preventDefault();
            e.stopImmediatePropagation();
            const wrapped = `${e.data}${value.substring(start, end)}${pairs[e.data]}`;
            document.execCommand("insertText", false, wrapped); // Wrap the selected text with the pair
            el.setSelectionRange(start + 1, end + 1);
        } else if ((e.data in reversePairs) && (start === end) && config.get("pairing.autoOvertype")) { // The input character is a closing one, nothing selected and autoOvertype feature is enabled
            const charBefore = value.charAt(start - 1);
            const charAfter = value.charAt(start);
            if (charBefore === reversePairs[e.data] && charAfter === e.data) { // The character before the cursor is the respective opening one and the character after the cursor is the same as the input character
                e.preventDefault();
                e.stopImmediatePropagation();
                el.setSelectionRange(start + 1, start + 1); // Move the cursor to the right
            }
        }
    }
    /**
     * Handle the InputEvent of type "deleteContentBackward", so as to auto delete the adjacent right bracket or quote
     * @param {InputEvent} e The InputEvent.
     */
    function onBackspace(e) {
        const el = e.composedPath()[0];
        const { selectionStart: start, selectionEnd: end, value } = el;
        if (start === end && start > 0 && end < value.length) {
            const charBefore = value.charAt(start - 1);
            const charAfter = value.charAt(start);
            if (pairs[charBefore] === charAfter && config.get("pairing.autoDelete")) {
                e.preventDefault();
                e.stopImmediatePropagation();
                el.setSelectionRange(start - 1, start + 1);
                document.execCommand("delete");
            }
        }
    }
    // Jumping
    /**
     * Find the other character's index in the given text.
     * @param {string} text The text to search in.
     * @param {number} pos The position of the character.
     * @returns {number | null} The position of the other character in the pair, or null if not found.
     */
    function findOtherIndex(text, pos) {
        const char = text.charAt(pos);
        const [isPair, isReversePair] = [char in pairs, char in reversePairs];
        if (isPair === isReversePair) return null; // Either not a pair or with the same opening and closing characters
        const other = isPair ? pairs[char] : reversePairs[char];
        const direction = isPair ? 1 : -1; // Searches forwards for the closing character, or backwards for the opening character
        let count = 0;
        for (let i = pos + direction; i >= 0 && i < text.length; i += direction) {
            if (text.charAt(i) === char) {
                count++;
            } else if (text.charAt(i) === other) {
                if (count === 0) return i;
                count--;
            }
        }
        return null;
    }
    /**
     * Handle shortcuts for jumping between paired brackets.
     * @param {KeyboardEvent} e The KeyboardEvent.
     * @returns {boolean} Whether the event is handled.
     */
    function jumpingHandler(e) {
        // Ctrl + Q
        if (!e.ctrlKey || e.altKey || e.shiftKey || e.metaKey || e.key !== "q" || !config.get("pairing.jumping")) return;
        /**
         * The target element.
         * @type {HTMLInputElement | HTMLTextAreaElement}
         */
        const el = e.composedPath()[0];
        const { selectionStart: start, selectionEnd: end, value } = el;
        const diff = Math.abs(end - start);
        if (!(diff <= 1) || typeof start === "undefined") return; // Only handle the scenario where one or none character is selected and the cursor is inside the element
        const otherIndex = findOtherIndex(value, Math.min(start, end)) // Try pairing the character selected or the one after the cursor
            ?? (diff ? null : findOtherIndex(value, start - 1)); // If not found, try the character before the cursor
        if (otherIndex !== null) {
            e.preventDefault();
            e.stopImmediatePropagation();
            el.setSelectionRange(otherIndex, otherIndex + 1);
            return true;
        }
        return false;
    }

    // Tabulator
    /**
     * Characters to tab out of.
     * @type {Set<string>}
     */
    let tabOutChars = new Set();
    /**
     * Find the character as the destination of the tab out action.
     * @param {string} text The text to search in.
     * @param {number} pos The position of the cursor.
     * @param {number} direction The direction to search in.
     * @returns {number} The position of the character to tab out of, or -1 if not found.
     */
    function findNextPos(text, pos, direction) {
        // A position is valid if and only if the character at that position OR BEFORE that position is in the tabOutChars
        for (let i = pos + direction; i >= 0 && i <= text.length; i += direction) { // `i <= text.length` is intentional, so as to handle the scenario where the cursor should be moved to the end of the text
            if (tabOutChars.has(text.charAt(i)) || tabOutChars.has(text.charAt(i - 1))) return i;
        }
        return -1;
    }
    /**
     * Handle the tab out action.
     * @param {KeyboardEvent} e The KeyboardEvent.
     * @returns {boolean} Whether the event is handled.
     */
    function tabOutHandler(e) {
        if (e.ctrlKey || e.altKey || e.metaKey || e.key !== "Tab" || !config.get("tabulator.tabOut")) return;
        /**
         * The target element.
         * @type {HTMLInputElement | HTMLTextAreaElement}
         */
        const el = e.composedPath()[0];
        const { selectionStart: start, selectionEnd: end, value } = el;
        if (start !== end) return; // Only handle the scenario where no character is selected
        const direction = e.shiftKey ? -1 : 1;
        const nextPos = findNextPos(value, start, direction);
        if (nextPos !== -1) {
            e.preventDefault();
            e.stopImmediatePropagation();
            el.setSelectionRange(nextPos, nextPos);
            return true;
        }
        return false;
    }

    // URL
    /**
     * Handle the InputEvent of type "insertFromPaste", so as to paste the URL into the selection.
     * @param {InputEvent} e The InputEvent.
     */
    function onPaste(e) {
        /**
         * The input or textarea element that triggered the event.
         * @type {HTMLInputElement | HTMLTextAreaElement}
         */
        const el = e.composedPath()[0];
        const { selectionStart: start, selectionEnd: end, value } = el;
        if (start === end || !URL.canParse(e.data) || !config.get("url.pasteIntoSelection")) return;
        const url = new URL(e.data);
        const scheme = url.protocol.slice(0, -1);
        const allowedSchemes = config.get("url.recognizedSchemes");
        if (allowedSchemes.length > 0 && !allowedSchemes.includes(scheme)) return;
        e.preventDefault();
        e.stopImmediatePropagation();
        const selection = value.substring(start, end);
        const wrapped = `[${selection}](${e.data})`;
        document.execCommand("insertText", false, wrapped);
        // Select the `selection` part
        el.setSelectionRange(start + 1, start + 1 + selection.length);
    }

    // Mouse
    /**
     * Information about the last scroll event.
     * @type {{ time: number, el: HTMLElement, vertical: boolean, plus: boolean }}
     */
    const lastScroll = {
        time: 0,
        el: document.scrollingElement,
        vertical: true,
        plus: true
    };
    /**
     * Detect whether the element can be scrolled using the normal detection method.
     * @param {HTMLElement} el The element.
     * @param {boolean} vertical Whether the scroll is vertical.
     * @param {boolean} plus Whether the scroll is positive (down or right).
     * @returns {boolean} Whether the element can be scrolled.
     */
    function normalDetect(el, vertical = true, plus = true) {
        const style = window.getComputedStyle(el);
        const overflow = vertical ? style.overflowY : style.overflowX;
        const scrollSize = vertical ? el.scrollHeight : el.scrollWidth;
        const clientSize = vertical ? el.clientHeight : el.clientWidth;
        const scrollPos = vertical ? el.scrollTop : el.scrollLeft;
        const isScrollable = scrollSize > clientSize;
        const canScrollFurther = plus ? (scrollPos + clientSize < scrollSize) : (scrollPos > 0);
        return isScrollable && canScrollFurther && !overflow.includes('visible') && !overflow.includes('hidden');
    }
    /**
     * Detect whether the element can be scrolled using a hacky detection method.
     * @param {HTMLElement} el The element.
     * @param {boolean} vertical Whether the scroll is vertical.
     * @param {boolean} plus Whether the scroll is positive (down or right).
     * @returns {boolean} Whether the element can be scrolled.
     */
    function hackyDetect(el, vertical = true, plus = true) {
        const attrs = vertical ? ["top", "scrollTop"] : ["left", "scrollLeft"];
        const delta = plus ? 1 : -1;
        const before = el[attrs[1]]; // Determine `scrollTop`/`scrollLeft` before trying to scroll
        el.scrollBy({ [attrs[0]]: delta, behavior: "instant" }); // Try to scroll in the specified direction
        const after = el[attrs[1]]; // Determine `scrollTop`/`scrollLeft` after we've scrolled
        if (before === after) return false;
        else {
            el.scrollBy({ [attrs[0]]: -delta, behavior: "instant" }); // Scroll back if applicable
            return true;
        }
    }
    /**
     * Determine whether the element can be scrolled in the specified direction, respecting user settings.
     * @param {HTMLElement} el The element.
     * @param {boolean} vertical Whether the scroll is vertical.
     * @param {boolean} plus Whether the scroll is positive (down or right).
     * @returns {boolean} Whether the element can be scrolled.
     */
    function canScroll(el, vertical = true, plus = true) {
        const method = [normalDetect, hackyDetect, (...args) => normalDetect(...args) && hackyDetect(...args)][config.get("mouse.detectionMethod")];
        return method(el, vertical, plus);
    }
    /**
     * Find the scrollable element that should handle the event.
     * @param {WheelEvent} e The WheelEvent.
     * @param {boolean} vertical Whether the scroll is vertical.
     * @param {boolean} plus Whether the scroll is positive (down or right).
    */
    function findScrollableElement(e, vertical = true, plus = true) {
        // If the scroll is deemed consecutive, then return the previous scrollable element
        if (e.timeStamp - lastScroll.time < config.get("mouse.consecutiveScrollThreshold")
            && lastScroll.vertical === vertical
            && lastScroll.plus === plus) {
            return lastScroll.el;
        }
        // https://gist.github.com/oscarmarina/3a546cff4d106a49a5be417e238d9558
        const path = e.composedPath();
        for (const el of path) {
            if (!(el instanceof HTMLElement || el instanceof ShadowRoot)) {
                continue;
            }
            if (canScroll(el, vertical, plus)) {
                return el;
            }
        }
        return document.scrollingElement;
    }
    /**
     * Handle the mousewheel event.
     * @param {WheelEvent} e The WheelEvent.
     */
    function onWheel(e) {
        if (!e.altKey || e.deltaMode !== WheelEvent.DOM_DELTA_PIXEL) return;
        e.preventDefault();
        e.stopImmediatePropagation();
        const { deltaY } = e;
        const amplified = deltaY * config.get("mouse.fastScrollSensitivity");
        const [vertical, plus] = [!e.shiftKey, e.deltaY > 0];
        const el = findScrollableElement(e, vertical, plus);
        Object.assign(lastScroll, { time: e.timeStamp, el, vertical, plus });
        el.scrollBy({
            top: e.shiftKey ? 0 : amplified,
            left: e.shiftKey ? amplified : 0,
            behavior: "instant" // TODO: Smooth scrolling
        });
    }
    /**
     * Enable or disable the fast scroll feature.
     * @param {boolean} enabled Whether the fast scroll feature is enabled.
     */
    function fastScroll(enabled) {
        if (enabled) {
            document.addEventListener("wheel", onWheel, { capture: config.get("advanced.capture"), passive: false });
        } else {
            document.removeEventListener("wheel", onWheel, { capture: config.get("advanced.capture"), passive: false });
        }
    }

    // Set up
    /**
     * Whether we should handle the InputEvent on the target.
     * @param {HTMLElement} target The target element.
     */
    function validTarget(target) {
        // Only handle the InputEvent on input and textarea
        return target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement;
    }
    /**
     * Handlers for different types of InputEvent.
     * @type {Record<string, (e: InputEvent) => void>}
     */
    const inputHandlers = {
        "insertText": onInsertText,
        "deleteContentBackward": onBackspace,
        "insertFromPaste": onPaste
    }
    /**
     * Handle the InputEvent.
     * @param {InputEvent} e The InputEvent.
     */
    function onInput(e) {
        if (e.isComposing || (e.defaultPrevented && config.get("advanced.defaultPrevented")) || !validTarget(e.composedPath()[0])) return;
        const handler = inputHandlers[e.inputType];
        if (handler) handler(e);
    }
    /**
     * Handle the KeyboardEvent.
     * @param {KeyboardEvent} e The KeyboardEvent.
     */
    function onKeydown(e) {
        if ((e.defaultPrevented && config.get("advanced.defaultPrevented")) || !validTarget(e.composedPath()[0])) return; // Only handle the unhandled event on input and textarea
        jumpingHandler(e) || tabOutHandler(e); // Only handle once at most
    }
    document.addEventListener("beforeinput", onInput, { capture: config.get("advanced.capture"), passive: false });
    document.addEventListener("keydown", onKeydown, { capture: config.get("advanced.capture"), passive: false });
    /**
     * Prop-specific handlers for config changes.
     * @type {Record<string, (value: any) => void>}
     */
    const configChangeHandlers = {
        "pairing.pairs": (value) => {
            pairs = {};
            reversePairs = {};
            for (let i = 0; i < value.length; i += 2) {
                pairs[value.charAt(i)] = value.charAt(i + 1);
                reversePairs[value.charAt(i + 1)] = value.charAt(i);
            }
        },
        "tabulator.tabOutChars": (value) => {
            tabOutChars = new Set(value);
        },
        "advanced.debug": (value) => {
            config.debug = value;
            if (value) {
                unsafeWindow.editio = editio;
            } else {
                delete unsafeWindow.editio;
            }
        },
        "mouse.fastScroll": fastScroll,
    };
    config.addEventListener("set", e => {
        const handler = configChangeHandlers[e.detail.prop];
        if (handler) handler(e.detail.after);
    });
    for (const [prop, handler] of Object.entries(configChangeHandlers)) {
        handler(config.get(prop));
    }
    // Expose these variables if debug mode is enabled
    Object.assign(editio, { config, lastScroll });
})();