Draggy

拖拽链接以在新标签页中打开,拖拽文本以在新标签页中搜索。

  1. // ==UserScript==
  2. // @name Draggy
  3. // @name:zh-CN Draggy
  4. // @namespace http://tampermonkey.net/
  5. // @version 0.2.7
  6. // @description Drag a link to open in a new tab; drag a piece of text to search in a new tab.
  7. // @description:zh-CN 拖拽链接以在新标签页中打开,拖拽文本以在新标签页中搜索。
  8. // @tag productivity
  9. // @author PRO-2684
  10. // @match *://*/*
  11. // @run-at document-start
  12. // @icon data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==
  13. // @license gpl-3.0
  14. // @grant GM_addElement
  15. // @grant GM_openInTab
  16. // @grant GM_setValue
  17. // @grant GM_getValue
  18. // @grant GM_deleteValue
  19. // @grant GM_registerMenuCommand
  20. // @grant GM_unregisterMenuCommand
  21. // @grant GM_addValueChangeListener
  22. // @require https://github.com/PRO-2684/GM_config/releases/download/v1.2.1/config.min.js#md5=525526b8f0b6b8606cedf08c651163c2
  23. // ==/UserScript==
  24.  
  25. (function () {
  26. "use strict";
  27. const { name, version } = GM.info.script;
  28. const configDesc = {
  29. $default: {
  30. autoClose: false,
  31. },
  32. appearance: {
  33. name: "🎨 Appearance settings",
  34. title: "Settings for the appearance of Draggy overlay.",
  35. type: "folder",
  36. items: {
  37. circleOverlay: {
  38. name: "Circle overlay",
  39. title: "When to show the circle overlay.",
  40. value: 1,
  41. input: (prop, orig) => (orig + 1) % 3,
  42. processor: "same",
  43. formatter: (prop, value, desc) => desc.name + ": " + ["Never", "Auto", "Always"][value],
  44. },
  45. },
  46. },
  47. operation: {
  48. name: "🛠️ Operation settings",
  49. title: "Settings for the operation of Draggy.",
  50. type: "folder",
  51. items: {
  52. openTabInBg: {
  53. name: "Open tab in background",
  54. title: "Whether to open new tabs in the background.",
  55. type: "bool",
  56. value: false,
  57. },
  58. openTabInsert: {
  59. name: "Open tab insert",
  60. title: "Whether to insert the new tab next to the current tab. If false, the new tab will be appended to the end.",
  61. type: "bool",
  62. value: true,
  63. },
  64. matchingUriInText: {
  65. name: "Matching URI in text",
  66. 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.",
  67. type: "bool",
  68. value: true,
  69. },
  70. minDistance: {
  71. name: "Minimum drag distance",
  72. title: "Minimum distance to trigger draggy.",
  73. type: "int", // 1-1000
  74. min: 1,
  75. max: 1000,
  76. value: 50,
  77. },
  78. },
  79. },
  80. searchEngine: {
  81. name: "🔎 Search engine settings",
  82. 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.",
  83. type: "folder",
  84. items: {
  85. default: {
  86. name: "Search engine (default)",
  87. title: "Default search engine used when dragging text.",
  88. type: "string",
  89. value: "https://www.google.com/search?q={50}",
  90. },
  91. left: {
  92. name: "Search engine (left)",
  93. title: "Search engine used when dragging text left. Leave it blank to use the default search engine.",
  94. type: "string",
  95. value: ""
  96. },
  97. right: {
  98. name: "Search engine (right)",
  99. title: "Search engine used when dragging text right. Leave it blank to use the default search engine.",
  100. type: "string",
  101. value: ""
  102. },
  103. up: {
  104. name: "Search engine (up)",
  105. title: "Search engine used when dragging text up. Leave it blank to use the default search engine.",
  106. type: "string",
  107. value: ""
  108. },
  109. down: {
  110. name: "Search engine (down)",
  111. title: "Search engine used when dragging text down. Leave it blank to use the default search engine.",
  112. type: "string",
  113. value: ""
  114. },
  115. },
  116. },
  117. advanced: {
  118. name: "⚙️ Advanced settings",
  119. title: "Settings for advanced users or debugging.",
  120. type: "folder",
  121. items: {
  122. allowedProtocols: {
  123. name: "Allowed protocols",
  124. title: "Comma-separated list of allowed protocols for matched URI in texts. Leave it blank to allow all protocols.",
  125. type: "string",
  126. value: "http,https,ftp,mailto,tel",
  127. },
  128. maxTimeDelta: {
  129. name: "Maximum time delta",
  130. 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.",
  131. type: "int", // 1-100
  132. min: 1,
  133. max: 100,
  134. value: 10,
  135. },
  136. processHandled: {
  137. name: "Process handled events",
  138. title: "Whether to process handled drag events. Note that this may lead to an event being handled multiple times.",
  139. type: "bool",
  140. value: false,
  141. },
  142. debug: {
  143. name: "Debug mode",
  144. title: "Enables debug mode.",
  145. type: "bool",
  146. value: false,
  147. },
  148. },
  149. },
  150. };
  151. const config = new GM_config(configDesc, { immediate: true });
  152. /**
  153. * Last time a drop event occurred.
  154. * @type {number}
  155. */
  156. let lastDrop = 0;
  157. /**
  158. * Start position of the drag event.
  159. * @type {{ x: number, y: number }}
  160. */
  161. let startPos = { x: 0, y: 0 };
  162. /**
  163. * Circle overlay.
  164. * @type {HTMLDivElement}
  165. */
  166. const circle = initOverlay();
  167. /**
  168. * Judging criteria for draggy.
  169. * @type {{ selection: (e: DragEvent) => string|HTMLAnchorElement|HTMLImageElement|null, handlers: (e: DragEvent) => boolean, dropEvent: (e: DragEvent) => boolean, }}
  170. */
  171. const judging = {
  172. selection: (e) => {
  173. const target = e.composedPath()[0];
  174. const img = target?.closest?.("img[src]");
  175. const src = img?.src;
  176. if (src) {
  177. return img;
  178. }
  179. const link = target?.closest?.("a[href]");
  180. const href = link?.getAttribute("href");
  181. if (href && !href.startsWith("javascript:") && href !== "#") {
  182. return link;
  183. }
  184. const selection = window.getSelection();
  185. const selectionAncestor = commonAncestor(selection.anchorNode, selection.focusNode);
  186. const selectedText = selection.toString();
  187. // Check if we're dragging the selected text (selectionAncestor is the ancestor of target, or target is the ancestor of selectionAncestor)
  188. if (selectedText && selectionAncestor && (isAncestorOf(selectionAncestor, target) || isAncestorOf(target, selectionAncestor))) {
  189. return selectedText;
  190. }
  191. },
  192. handlers: (e) => config.get("advanced.processHandled") || e.dataTransfer.dropEffect === "none" && e.dataTransfer.effectAllowed === "uninitialized" && !e.defaultPrevented,
  193. dropEvent: (e) => config.get("advanced.processHandled") || e.timeStamp - lastDrop > config.get("advanced.maxTimeDelta"),
  194. };
  195.  
  196. /**
  197. * Logs the given arguments if debug mode is enabled.
  198. * @param {...any} args The arguments to log.
  199. */
  200. function log(...args) {
  201. if (config.get("advanced.debug")) {
  202. console.log(`[${name}]`, ...args);
  203. }
  204. }
  205. /**
  206. * Finds the most recent common ancestor of two nodes.
  207. * @param {Node} node1 The first node.
  208. * @param {Node} node2 The second node.
  209. * @returns {Node|null} The common ancestor of the two nodes.
  210. */
  211. function commonAncestor(node1, node2) {
  212. const ancestors = new Set();
  213. for (let n = node1; n; n = n.parentNode) {
  214. ancestors.add(n);
  215. }
  216. for (let n = node2; n; n = n.parentNode) {
  217. if (ancestors.has(n)) {
  218. return n;
  219. }
  220. }
  221. return null;
  222. }
  223. /**
  224. * Checks if the given node is an ancestor of another node.
  225. * @param {Node} ancestor The ancestor node.
  226. * @param {Node} descendant The descendant node.
  227. * @returns {boolean} Whether the ancestor is an ancestor of the descendant.
  228. */
  229. function isAncestorOf(ancestor, descendant) {
  230. for (let n = descendant; n; n = n.parentNode) {
  231. if (n === ancestor) {
  232. return true;
  233. }
  234. }
  235. return false
  236. }
  237. /**
  238. * Opens the given URL in a new tab, respecting the user's preference.
  239. * @param {string} url The URL to open.
  240. */
  241. function open(url) {
  242. GM_openInTab(url, { active: !config.get("operation.openTabInBg"), insert: config.get("operation.openTabInsert") });
  243. }
  244. /**
  245. * 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.
  246. * @param {string} text The text to handle.
  247. * @param {string} direction The direction of the drag.
  248. */
  249. function handleText(text, direction) {
  250. if (URL.canParse(text)) {
  251. const url = new URL(text);
  252. const allowedProtocols = config.get("advanced.allowedProtocols").split(",").map(p => p.trim()).filter(Boolean);
  253. if (allowedProtocols.length === 0 || allowedProtocols.includes(url.protocol.slice(0, -1))) {
  254. open(text);
  255. return;
  256. }
  257. }
  258. search(text, direction);
  259. }
  260. /**
  261. * Searches for the given keyword.
  262. * @param {string} keyword The keyword to search for.
  263. * @param {string} direction The direction of the drag.
  264. */
  265. function search(keyword, direction) {
  266. const searchEngine = config.get(`searchEngine.${direction}`) || config.get("searchEngine.default");
  267. const maxLenMatch = searchEngine.match(/\{(\d*)\}/);
  268. const maxLenParsed = parseInt(maxLenMatch?.[1]);
  269. const maxLen = isNaN(maxLenParsed) ? +Infinity : maxLenParsed;
  270. const truncated = keyword.slice(0, maxLen);
  271. const url = searchEngine.replace(maxLenMatch[0], encodeURIComponent(truncated));
  272. log(`Searching for "${truncated}" using "${url}"`);
  273. open(url);
  274. }
  275. /**
  276. * Updates the circle overlay size.
  277. * @param {number} size The size of the circle overlay.
  278. */
  279. function onMinDistanceChange(size) {
  280. circle.style.setProperty("--size", size + "px");
  281. }
  282. /**
  283. * Creates a circle overlay.
  284. * @returns {HTMLDivElement} The circle overlay.
  285. */
  286. function initOverlay() {
  287. const circle = document.body.appendChild(document.createElement("div"));
  288. circle.id = "draggy-overlay";
  289. const textContent = `
  290. body > #draggy-overlay {
  291. --size: 50px; /* Circle radius */
  292. --center-x: calc(-1 * var(--size)); /* Hide the circle by default */
  293. --center-y: calc(-1 * var(--size));
  294. display: none;
  295. position: fixed;
  296. box-sizing: border-box;
  297. width: calc(var(--size) * 2);
  298. height: calc(var(--size) * 2);
  299. top: calc(var(--center-y) - var(--size));
  300. left: calc(var(--center-x) - var(--size));
  301. border-radius: 50%;
  302. border: 1px solid white; /* Circle border */
  303. padding: 0;
  304. margin: 0;
  305. mix-blend-mode: difference; /* Invert the background */
  306. background: transparent;
  307. z-index: 2147483647;
  308. pointer-events: none;
  309. &[data-draggy-overlay="0"] { }
  310. &[data-draggy-overlay="1"][data-draggy-selected] { display: block; }
  311. &[data-draggy-overlay="2"] { display: block; }
  312. }
  313. `;
  314. function addStyle() {
  315. if (document.getElementById("draggy-style")) {
  316. return;
  317. }
  318. GM_addElement(document.documentElement, "style", {
  319. id: "draggy-style",
  320. class: "darkreader", // Make Dark Reader ignore
  321. textContent
  322. });
  323. }
  324. addStyle();
  325. setTimeout(addStyle, 1000); // Dark Reader might remove the style
  326. return circle;
  327. }
  328. /**
  329. * Toggles the circle overlay.
  330. * @param {number} mode When to show the circle overlay.
  331. */
  332. function toggleOverlay(mode) {
  333. circle.setAttribute("data-draggy-overlay", mode);
  334. }
  335.  
  336. // Event listeners
  337. document.addEventListener("drop", (e) => {
  338. lastDrop = e.timeStamp;
  339. log("Drop event at", e.timeStamp);
  340. }, { passive: true });
  341. document.addEventListener("dragstart", (e) => {
  342. if (!judging.selection(e)) {
  343. circle.toggleAttribute("data-draggy-selected", false);
  344. } else {
  345. circle.toggleAttribute("data-draggy-selected", true);
  346. }
  347. const { x, y } = e;
  348. startPos = { x, y };
  349. circle.style.setProperty("--center-x", x + "px");
  350. circle.style.setProperty("--center-y", y + "px");
  351. log("Drag start at", startPos);
  352. }, { passive: true });
  353. document.addEventListener("dragend", (e) => {
  354. circle.style.removeProperty("--center-x");
  355. circle.style.removeProperty("--center-y");
  356. if (!judging.handlers(e)) {
  357. log("Draggy interrupted by other handler(s)");
  358. return;
  359. }
  360. if (!judging.dropEvent(e)) {
  361. log("Draggy interrupted by drop event");
  362. return;
  363. }
  364. const { x, y } = e;
  365. const [dx, dy] = [x - startPos.x, y - startPos.y];
  366. const distance = Math.hypot(dx, dy);
  367. if (distance < config.get("operation.minDistance")) {
  368. log("Draggy interrupted by short drag distance:", distance);
  369. return;
  370. }
  371. log("Draggy starts processing...");
  372. e.preventDefault();
  373. const data = judging.selection(e);
  374. if (data instanceof HTMLAnchorElement) {
  375. open(data.href);
  376. } else if (data instanceof HTMLImageElement) {
  377. open(data.src);
  378. } else if (typeof data === "string") {
  379. // Judge direction of the drag (Up, Down, Left, Right)
  380. const isVertical = Math.abs(dy) > Math.abs(dx);
  381. const isPositive = isVertical ? dy > 0 : dx > 0;
  382. const direction = isVertical ? (isPositive ? "down" : "up") : (isPositive ? "right" : "left");
  383. log("Draggy direction:", direction);
  384. handleText(data, direction);
  385. } else {
  386. log("Draggy can't find selected text or a valid link");
  387. }
  388. }, { passive: false });
  389.  
  390. // Dynamic configuration
  391. const callbacks = {
  392. "appearance.circleOverlay": toggleOverlay,
  393. "operation.minDistance": onMinDistanceChange,
  394. };
  395. for (const [prop, callback] of Object.entries(callbacks)) { // Initialize
  396. callback(config.get(prop));
  397. }
  398. config.addEventListener("set", (e) => { // Update
  399. const { prop, after } = e.detail;
  400. const callback = callbacks[prop];
  401. callback?.(after);
  402. });
  403.  
  404. log(`${version} initialized successfully 🎉`);
  405. })();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址