Draggy

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

目前为 2024-10-05 提交的版本。查看 最新版本

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

QingJ © 2025

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