Greasy Fork is available in English.

Editio

Some Visual Studio Code's useful features ported to the web, including pairing brackets and quotes, tabbing out, pasting URLs into selections, and fast scrolling etc

// ==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, including pairing brackets and quotes, tabbing out, pasting URLs into selections, and fast scrolling etc
// @description:zh-CN 将 Visual Studio Code 的部分实用功能移植到 Web 上,包括匹配括号和引号,tab out,将 URL 粘贴至选区以及快速滚动等
// @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 });
})();