Drag a link to open in a new tab; drag a piece of text to search in a new tab.
// ==UserScript== // @name Draggy // @name:zh-CN Draggy // @namespace http://tampermonkey.net/ // @version 0.2.7 // @description Drag a link to open in a new tab; drag a piece of text to search in a new tab. // @description:zh-CN 拖拽链接以在新标签页中打开,拖拽文本以在新标签页中搜索。 // @tag productivity // @author PRO-2684 // @match *://*/* // @run-at document-start // @icon data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw== // @license gpl-3.0 // @grant GM_addElement // @grant GM_openInTab // @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 () { "use strict"; const { name, version } = GM.info.script; const configDesc = { $default: { autoClose: false, }, appearance: { name: "🎨 Appearance settings", title: "Settings for the appearance of Draggy overlay.", type: "folder", items: { circleOverlay: { name: "Circle overlay", title: "When to show the circle overlay.", value: 1, input: (prop, orig) => (orig + 1) % 3, processor: "same", formatter: (prop, value, desc) => desc.name + ": " + ["Never", "Auto", "Always"][value], }, }, }, operation: { name: "🛠️ Operation settings", title: "Settings for the operation of Draggy.", type: "folder", items: { openTabInBg: { name: "Open tab in background", title: "Whether to open new tabs in the background.", type: "bool", value: false, }, openTabInsert: { name: "Open tab insert", title: "Whether to insert the new tab next to the current tab. If false, the new tab will be appended to the end.", type: "bool", value: true, }, matchingUriInText: { name: "Matching URI in text", title: "Whether to match URI in the selected text. If enabled AND the selected text is a valid URI AND its protocol is allowed, Draggy will open it directly instead of searching.", type: "bool", value: true, }, minDistance: { name: "Minimum drag distance", title: "Minimum distance to trigger draggy.", type: "int", // 1-1000 min: 1, max: 1000, value: 50, }, }, }, searchEngine: { name: "🔎 Search engine settings", title: "Configure search engines for different directions. Use `{<max-length>}` as a placeholder for the URL-encoded query, where `<max-length>` is the maximum text length. If `<max-length>` is not specified, the search term will not be truncated.", type: "folder", items: { default: { name: "Search engine (default)", title: "Default search engine used when dragging text.", type: "string", value: "https://www.google.com/search?q={50}", }, left: { name: "Search engine (left)", title: "Search engine used when dragging text left. Leave it blank to use the default search engine.", type: "string", value: "" }, right: { name: "Search engine (right)", title: "Search engine used when dragging text right. Leave it blank to use the default search engine.", type: "string", value: "" }, up: { name: "Search engine (up)", title: "Search engine used when dragging text up. Leave it blank to use the default search engine.", type: "string", value: "" }, down: { name: "Search engine (down)", title: "Search engine used when dragging text down. Leave it blank to use the default search engine.", type: "string", value: "" }, }, }, advanced: { name: "⚙️ Advanced settings", title: "Settings for advanced users or debugging.", type: "folder", items: { allowedProtocols: { name: "Allowed protocols", title: "Comma-separated list of allowed protocols for matched URI in texts. Leave it blank to allow all protocols.", type: "string", value: "http,https,ftp,mailto,tel", }, maxTimeDelta: { name: "Maximum time delta", title: "Maximum time difference between esc/drop and dragend events to consider them as separate user gesture. Usually there's no need to change this value.", type: "int", // 1-100 min: 1, max: 100, value: 10, }, processHandled: { name: "Process handled events", title: "Whether to process handled drag events. Note that this may lead to an event being handled multiple times.", type: "bool", value: false, }, debug: { name: "Debug mode", title: "Enables debug mode.", type: "bool", value: false, }, }, }, }; const config = new GM_config(configDesc, { immediate: true }); /** * Last time a drop event occurred. * @type {number} */ let lastDrop = 0; /** * Start position of the drag event. * @type {{ x: number, y: number }} */ let startPos = { x: 0, y: 0 }; /** * Circle overlay. * @type {HTMLDivElement} */ const circle = initOverlay(); /** * Judging criteria for draggy. * @type {{ selection: (e: DragEvent) => string|HTMLAnchorElement|HTMLImageElement|null, handlers: (e: DragEvent) => boolean, dropEvent: (e: DragEvent) => boolean, }} */ const judging = { selection: (e) => { const target = e.composedPath()[0]; const img = target?.closest?.("img[src]"); const src = img?.src; if (src) { return img; } const link = target?.closest?.("a[href]"); const href = link?.getAttribute("href"); if (href && !href.startsWith("javascript:") && href !== "#") { return link; } const selection = window.getSelection(); const selectionAncestor = commonAncestor(selection.anchorNode, selection.focusNode); const selectedText = selection.toString(); // Check if we're dragging the selected text (selectionAncestor is the ancestor of target, or target is the ancestor of selectionAncestor) if (selectedText && selectionAncestor && (isAncestorOf(selectionAncestor, target) || isAncestorOf(target, selectionAncestor))) { return selectedText; } }, handlers: (e) => config.get("advanced.processHandled") || e.dataTransfer.dropEffect === "none" && e.dataTransfer.effectAllowed === "uninitialized" && !e.defaultPrevented, dropEvent: (e) => config.get("advanced.processHandled") || e.timeStamp - lastDrop > config.get("advanced.maxTimeDelta"), }; /** * Logs the given arguments if debug mode is enabled. * @param {...any} args The arguments to log. */ function log(...args) { if (config.get("advanced.debug")) { console.log(`[${name}]`, ...args); } } /** * Finds the most recent common ancestor of two nodes. * @param {Node} node1 The first node. * @param {Node} node2 The second node. * @returns {Node|null} The common ancestor of the two nodes. */ function commonAncestor(node1, node2) { const ancestors = new Set(); for (let n = node1; n; n = n.parentNode) { ancestors.add(n); } for (let n = node2; n; n = n.parentNode) { if (ancestors.has(n)) { return n; } } return null; } /** * Checks if the given node is an ancestor of another node. * @param {Node} ancestor The ancestor node. * @param {Node} descendant The descendant node. * @returns {boolean} Whether the ancestor is an ancestor of the descendant. */ function isAncestorOf(ancestor, descendant) { for (let n = descendant; n; n = n.parentNode) { if (n === ancestor) { return true; } } return false } /** * Opens the given URL in a new tab, respecting the user's preference. * @param {string} url The URL to open. */ function open(url) { GM_openInTab(url, { active: !config.get("operation.openTabInBg"), insert: config.get("operation.openTabInsert") }); } /** * Handles the given text based on the drag direction. If the text is a valid URI and protocol is allowed, open the URI; otherwise, search for the text. * @param {string} text The text to handle. * @param {string} direction The direction of the drag. */ function handleText(text, direction) { if (URL.canParse(text)) { const url = new URL(text); const allowedProtocols = config.get("advanced.allowedProtocols").split(",").map(p => p.trim()).filter(Boolean); if (allowedProtocols.length === 0 || allowedProtocols.includes(url.protocol.slice(0, -1))) { open(text); return; } } search(text, direction); } /** * Searches for the given keyword. * @param {string} keyword The keyword to search for. * @param {string} direction The direction of the drag. */ function search(keyword, direction) { const searchEngine = config.get(`searchEngine.${direction}`) || config.get("searchEngine.default"); const maxLenMatch = searchEngine.match(/\{(\d*)\}/); const maxLenParsed = parseInt(maxLenMatch?.[1]); const maxLen = isNaN(maxLenParsed) ? +Infinity : maxLenParsed; const truncated = keyword.slice(0, maxLen); const url = searchEngine.replace(maxLenMatch[0], encodeURIComponent(truncated)); log(`Searching for "${truncated}" using "${url}"`); open(url); } /** * Updates the circle overlay size. * @param {number} size The size of the circle overlay. */ function onMinDistanceChange(size) { circle.style.setProperty("--size", size + "px"); } /** * Creates a circle overlay. * @returns {HTMLDivElement} The circle overlay. */ function initOverlay() { const circle = document.body.appendChild(document.createElement("div")); circle.id = "draggy-overlay"; const textContent = ` body > #draggy-overlay { --size: 50px; /* Circle radius */ --center-x: calc(-1 * var(--size)); /* Hide the circle by default */ --center-y: calc(-1 * var(--size)); display: none; position: fixed; box-sizing: border-box; width: calc(var(--size) * 2); height: calc(var(--size) * 2); top: calc(var(--center-y) - var(--size)); left: calc(var(--center-x) - var(--size)); border-radius: 50%; border: 1px solid white; /* Circle border */ padding: 0; margin: 0; mix-blend-mode: difference; /* Invert the background */ background: transparent; z-index: 2147483647; pointer-events: none; &[data-draggy-overlay="0"] { } &[data-draggy-overlay="1"][data-draggy-selected] { display: block; } &[data-draggy-overlay="2"] { display: block; } } `; function addStyle() { if (document.getElementById("draggy-style")) { return; } GM_addElement(document.documentElement, "style", { id: "draggy-style", class: "darkreader", // Make Dark Reader ignore textContent }); } addStyle(); setTimeout(addStyle, 1000); // Dark Reader might remove the style return circle; } /** * Toggles the circle overlay. * @param {number} mode When to show the circle overlay. */ function toggleOverlay(mode) { circle.setAttribute("data-draggy-overlay", mode); } // Event listeners document.addEventListener("drop", (e) => { lastDrop = e.timeStamp; log("Drop event at", e.timeStamp); }, { passive: true }); document.addEventListener("dragstart", (e) => { if (!judging.selection(e)) { circle.toggleAttribute("data-draggy-selected", false); } else { circle.toggleAttribute("data-draggy-selected", true); } const { x, y } = e; startPos = { x, y }; circle.style.setProperty("--center-x", x + "px"); circle.style.setProperty("--center-y", y + "px"); log("Drag start at", startPos); }, { passive: true }); document.addEventListener("dragend", (e) => { circle.style.removeProperty("--center-x"); circle.style.removeProperty("--center-y"); if (!judging.handlers(e)) { log("Draggy interrupted by other handler(s)"); return; } if (!judging.dropEvent(e)) { log("Draggy interrupted by drop event"); return; } const { x, y } = e; const [dx, dy] = [x - startPos.x, y - startPos.y]; const distance = Math.hypot(dx, dy); if (distance < config.get("operation.minDistance")) { log("Draggy interrupted by short drag distance:", distance); return; } log("Draggy starts processing..."); e.preventDefault(); const data = judging.selection(e); if (data instanceof HTMLAnchorElement) { open(data.href); } else if (data instanceof HTMLImageElement) { open(data.src); } else if (typeof data === "string") { // Judge direction of the drag (Up, Down, Left, Right) const isVertical = Math.abs(dy) > Math.abs(dx); const isPositive = isVertical ? dy > 0 : dx > 0; const direction = isVertical ? (isPositive ? "down" : "up") : (isPositive ? "right" : "left"); log("Draggy direction:", direction); handleText(data, direction); } else { log("Draggy can't find selected text or a valid link"); } }, { passive: false }); // Dynamic configuration const callbacks = { "appearance.circleOverlay": toggleOverlay, "operation.minDistance": onMinDistanceChange, }; for (const [prop, callback] of Object.entries(callbacks)) { // Initialize callback(config.get(prop)); } config.addEventListener("set", (e) => { // Update const { prop, after } = e.detail; const callback = callbacks[prop]; callback?.(after); }); log(`${version} initialized successfully 🎉`); })();