// ==UserScript== // @name Draggy // @name:zh-CN Draggy // @namespace http://tampermonkey.net/ // @version 0.1.2 // @description Drag a link to open in a new tab; drag a piece of text to search in a new tab. // @description:zh-CN 拖拽链接以在新标签页中打开,拖拽文本以在新标签页中搜索。 // @author PRO-2684 // @match *://*/* // @run-at document-start // @icon data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw== // @license gpl-3.0 // @grant GM_setValue // @grant GM_getValue // @grant GM_deleteValue // @grant GM_registerMenuCommand // @grant GM_unregisterMenuCommand // @grant GM_addValueChangeListener // @require https://update.greasyfork.org/scripts/470224/1456932/Tampermonkey%20Config.js // @downloadURL none // ==/UserScript== (function () { "use strict"; const { name, version } = GM.info.script; const configDesc = { $default: { autoClose: false, }, circleOverlay: { name: "Circle overlay", title: "When to show the circle overlay.", value: 1, input: (prop, orig) => (orig + 1) % 3, processor: "same", formatter: (prop, value) => configDesc.circleOverlay.name + ": " + ["Never", "Auto", "Always"][value], }, searchEngine: { name: "Search engine", title: "Search engine used when dragging text. Use {} as a placeholder for the URL-encoded query.", type: "string", value: "https://www.google.com/search?q={}", }, maxLength: { name: "Maximum text length", title: "Maximum length of the search term. If the length of the search term exceeds this value, it will be truncated. Set to 0 to disable this feature.", type: "int_range-0-1000", value: 100, }, minDistance: { name: "Minimum drag distance", title: "Minimum distance to trigger draggy.", type: "int_range-1-1000", value: 50, }, 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_range-1-100", value: 10, }, debug: { name: "Debug mode", title: "Enables debug mode.", type: "bool", value: false, }, }; const config = new GM_config(configDesc, { immediate: false }); /** * 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|null, handlers: (e: DragEvent) => boolean, dropEvent: (e: DragEvent) => boolean, }} */ const judging = { selection: (e) => { 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 e.target, or e.target is the ancestor of selectionAncestor) if (selectedText && selectionAncestor && (isAncestorOf(selectionAncestor, e.target) || isAncestorOf(e.target, selectionAncestor))) { return selectedText; } const link = e.target.closest("a[href]"); const href = link?.getAttribute("href"); if (!href || href.startsWith("javascript:") || href === "#") { return null; } return link; }, handlers: (e) => e.dataTransfer.dropEffect === "none" && e.dataTransfer.effectAllowed === "uninitialized" && !e.defaultPrevented, dropEvent: (e) => e.timeStamp - lastDrop > config.get("maxTimeDelta"), }; /** * Logs the given arguments if debug mode is enabled. * @param {...any} args The arguments to log. */ function log(...args) { if (config.get("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 } /** * Searches for the given keyword. * @param {string} keyword The keyword to search for. */ function search(keyword) { const searchEngine = config.get("searchEngine"); const maxLength = config.get("maxLength"); const truncated = maxLength > 0 ? keyword.slice(0, maxLength) : keyword; const url = searchEngine.replace("{}", encodeURIComponent(truncated)); log(`Searching for "${truncated}" using "${url}"`); window.open(url, "_blank"); } /** * 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 style = document.head.appendChild(document.createElement("style")); style.id = "draggy-style"; style.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: 9999999999; pointer-events: none; } &[data-draggy-overlay="0"] > #draggy-overlay { } &[data-draggy-overlay="1"] > #draggy-overlay[data-draggy-selection] { display: block; } &[data-draggy-overlay="2"] > #draggy-overlay { display: block; } } `; return circle; } /** * Toggles the circle overlay. * @param {number} mode When to show the circle overlay. */ function toggleOverlay(mode) { document.body.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-selection", false); } else { circle.toggleAttribute("data-draggy-selection", 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("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) { window.open(data.href, "_blank"); } else if (typeof data === "string") { search(data); } else { log("Draggy can't find selected text or a valid link"); } }, { passive: false }); // Dynamic configuration const callbacks = { circleOverlay: toggleOverlay, 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 🎉`); })();