// ==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 });
})();