返回首頁 

Editio

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


Install this script?
// ==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.autoClosin###ertype` 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 enablede.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 pairel.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 enabledconst 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 charactere.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 charactersconst other = isPair ? pairs[char] : reversePairs[char];const direction = isPair ? 1 : -1; // Searches forwards for the closing character, or backwards for the opening characterlet 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 + Qif (!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 elementconst 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 cursorif (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 tabOutCharsfor (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 textif (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 selectedconst 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` partel.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 scrollel.scrollBy({ [attrs[0]]: delta, behavior: "instant" }); // Try to scroll in the specified directionconst after = el[attrs[1]]; // Determine `scrollTop`/`scrollLeft` after we've scrolledif (before === after) return false;else {el.scrollBy({ [attrs[0]]: -delta, behavior: "instant" }); // Scroll back if applicablereturn 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 elementif (e.timeStamp - lastScroll.time < config.get("mouse.consecutiveScrollThreshold")&& lastScroll.vertical === vertical&& lastScroll.plus === plus) {return lastScroll.el;}// https://gist.github.com/oscarmarina/3a546cff4d106a49a5be417e238d9558const 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 textareareturn 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 textareajumpingHandler(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 enabledObject.assign(editio, { config, lastScroll });})();