Draggy

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

目前为 2024-10-02 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Draggy
  3. // @name:zh-CN Draggy
  4. // @namespace http://tampermonkey.net/
  5. // @version 0.1.2
  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. // @author PRO-2684
  9. // @match *://*/*
  10. // @run-at document-start
  11. // @icon data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==
  12. // @license gpl-3.0
  13. // @grant GM_setValue
  14. // @grant GM_getValue
  15. // @grant GM_deleteValue
  16. // @grant GM_registerMenuCommand
  17. // @grant GM_unregisterMenuCommand
  18. // @grant GM_addValueChangeListener
  19. // @require https://update.greasyfork.org/scripts/470224/1456932/Tampermonkey%20Config.js
  20. // ==/UserScript==
  21. (function () {
  22. "use strict";
  23. const { name, version } = GM.info.script;
  24. const configDesc = {
  25. $default: {
  26. autoClose: false,
  27. },
  28. circleOverlay: {
  29. name: "Circle overlay",
  30. title: "When to show the circle overlay.",
  31. value: 1,
  32. input: (prop, orig) => (orig + 1) % 3,
  33. processor: "same",
  34. formatter: (prop, value) => configDesc.circleOverlay.name + ": " + ["Never", "Auto", "Always"][value],
  35. },
  36. searchEngine: {
  37. name: "Search engine",
  38. title: "Search engine used when dragging text. Use {} as a placeholder for the URL-encoded query.",
  39. type: "string",
  40. value: "https://www.google.com/search?q={}",
  41. },
  42. maxLength: {
  43. name: "Maximum text length",
  44. 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.",
  45. type: "int_range-0-1000",
  46. value: 100,
  47. },
  48. minDistance: {
  49. name: "Minimum drag distance",
  50. title: "Minimum distance to trigger draggy.",
  51. type: "int_range-1-1000",
  52. value: 50,
  53. },
  54. maxTimeDelta: {
  55. name: "Maximum time delta",
  56. 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.",
  57. type: "int_range-1-100",
  58. value: 10,
  59. },
  60. debug: {
  61. name: "Debug mode",
  62. title: "Enables debug mode.",
  63. type: "bool",
  64. value: false,
  65. },
  66. };
  67. const config = new GM_config(configDesc, { immediate: false });
  68. /**
  69. * Last time a drop event occurred.
  70. * @type {number}
  71. */
  72. let lastDrop = 0;
  73. /**
  74. * Start position of the drag event.
  75. * @type {{ x: number, y: number }}
  76. */
  77. let startPos = { x: 0, y: 0 };
  78. /**
  79. * Circle overlay.
  80. * @type {HTMLDivElement}
  81. */
  82. const circle = initOverlay();
  83. /**
  84. * Judging criteria for draggy.
  85. * @type {{ selection: (e: DragEvent) => string|HTMLAnchorElement|null, handlers: (e: DragEvent) => boolean, dropEvent: (e: DragEvent) => boolean, }}
  86. */
  87. const judging = {
  88. selection: (e) => {
  89. const selection = window.getSelection();
  90. const selectionAncestor = commonAncestor(selection.anchorNode, selection.focusNode);
  91. const selectedText = selection.toString();
  92. // Check if we're dragging the selected text (selectionAncestor is the ancestor of e.target, or e.target is the ancestor of selectionAncestor)
  93. if (selectedText && selectionAncestor && (isAncestorOf(selectionAncestor, e.target) || isAncestorOf(e.target, selectionAncestor))) {
  94. return selectedText;
  95. }
  96. const link = e.target.closest("a[href]");
  97. const href = link?.getAttribute("href");
  98. if (!href || href.startsWith("javascript:") || href === "#") {
  99. return null;
  100. }
  101. return link;
  102. },
  103. handlers: (e) => e.dataTransfer.dropEffect === "none" && e.dataTransfer.effectAllowed === "uninitialized" && !e.defaultPrevented,
  104. dropEvent: (e) => e.timeStamp - lastDrop > config.get("maxTimeDelta"),
  105. };
  106.  
  107. /**
  108. * Logs the given arguments if debug mode is enabled.
  109. * @param {...any} args The arguments to log.
  110. */
  111. function log(...args) {
  112. if (config.get("debug")) {
  113. console.log(`[${name}]`, ...args);
  114. }
  115. }
  116. /**
  117. * Finds the most recent common ancestor of two nodes.
  118. * @param {Node} node1 The first node.
  119. * @param {Node} node2 The second node.
  120. * @returns {Node|null} The common ancestor of the two nodes.
  121. */
  122. function commonAncestor(node1, node2) {
  123. const ancestors = new Set();
  124. for (let n = node1; n; n = n.parentNode) {
  125. ancestors.add(n);
  126. }
  127. for (let n = node2; n; n = n.parentNode) {
  128. if (ancestors.has(n)) {
  129. return n;
  130. }
  131. }
  132. return null;
  133. }
  134. /**
  135. * Checks if the given node is an ancestor of another node.
  136. * @param {Node} ancestor The ancestor node.
  137. * @param {Node} descendant The descendant node.
  138. * @returns {boolean} Whether the ancestor is an ancestor of the descendant.
  139. */
  140. function isAncestorOf(ancestor, descendant) {
  141. for (let n = descendant; n; n = n.parentNode) {
  142. if (n === ancestor) {
  143. return true;
  144. }
  145. }
  146. return false
  147. }
  148. /**
  149. * Searches for the given keyword.
  150. * @param {string} keyword The keyword to search for.
  151. */
  152. function search(keyword) {
  153. const searchEngine = config.get("searchEngine");
  154. const maxLength = config.get("maxLength");
  155. const truncated = maxLength > 0 ? keyword.slice(0, maxLength) : keyword;
  156. const url = searchEngine.replace("{}", encodeURIComponent(truncated));
  157. log(`Searching for "${truncated}" using "${url}"`);
  158. window.open(url, "_blank");
  159. }
  160. /**
  161. * Updates the circle overlay size.
  162. * @param {number} size The size of the circle overlay.
  163. */
  164. function onMinDistanceChange(size) {
  165. circle.style.setProperty("--size", size + "px");
  166. }
  167. /**
  168. * Creates a circle overlay.
  169. * @returns {HTMLDivElement} The circle overlay.
  170. */
  171. function initOverlay() {
  172. const circle = document.body.appendChild(document.createElement("div"));
  173. circle.id = "draggy-overlay";
  174. const style = document.head.appendChild(document.createElement("style"));
  175. style.id = "draggy-style";
  176. style.textContent = `
  177. body {
  178. > #draggy-overlay {
  179. --size: 50px; /* Circle radius */
  180. --center-x: calc(-1 * var(--size)); /* Hide the circle by default */
  181. --center-y: calc(-1 * var(--size));
  182. display: none;
  183. position: fixed;
  184. box-sizing: border-box;
  185. width: calc(var(--size) * 2);
  186. height: calc(var(--size) * 2);
  187. top: calc(var(--center-y) - var(--size));
  188. left: calc(var(--center-x) - var(--size));
  189. border-radius: 50%;
  190. border: 1px solid white; /* Circle border */
  191. padding: 0;
  192. margin: 0;
  193. mix-blend-mode: difference; /* Invert the background */
  194. background: transparent;
  195. z-index: 9999999999;
  196. pointer-events: none;
  197. }
  198. &[data-draggy-overlay="0"] > #draggy-overlay { }
  199. &[data-draggy-overlay="1"] > #draggy-overlay[data-draggy-selection] { display: block; }
  200. &[data-draggy-overlay="2"] > #draggy-overlay { display: block; }
  201. }
  202. `;
  203. return circle;
  204. }
  205. /**
  206. * Toggles the circle overlay.
  207. * @param {number} mode When to show the circle overlay.
  208. */
  209. function toggleOverlay(mode) {
  210. document.body.setAttribute("data-draggy-overlay", mode);
  211. }
  212.  
  213. // Event listeners
  214. document.addEventListener("drop", (e) => {
  215. lastDrop = e.timeStamp;
  216. log("Drop event at", e.timeStamp);
  217. }, { passive: true });
  218. document.addEventListener("dragstart", (e) => {
  219. if (!judging.selection(e)) {
  220. circle.toggleAttribute("data-draggy-selection", false);
  221. } else {
  222. circle.toggleAttribute("data-draggy-selection", true);
  223. }
  224. const { x, y } = e;
  225. startPos = { x, y };
  226. circle.style.setProperty("--center-x", x + "px");
  227. circle.style.setProperty("--center-y", y + "px");
  228. log("Drag start at", startPos);
  229. }, { passive: true });
  230. document.addEventListener("dragend", (e) => {
  231. circle.style.removeProperty("--center-x");
  232. circle.style.removeProperty("--center-y");
  233. if (!judging.handlers(e)) {
  234. log("Draggy interrupted by other handler(s)");
  235. return;
  236. }
  237. if (!judging.dropEvent(e)) {
  238. log("Draggy interrupted by drop event");
  239. return;
  240. }
  241. const { x, y } = e;
  242. const [dx, dy] = [x - startPos.x, y - startPos.y];
  243. const distance = Math.hypot(dx, dy);
  244. if (distance < config.get("minDistance")) {
  245. log("Draggy interrupted by short drag distance:", distance);
  246. return;
  247. }
  248. log("Draggy starts processing...");
  249. e.preventDefault();
  250. const data = judging.selection(e);
  251. if (data instanceof HTMLAnchorElement) {
  252. window.open(data.href, "_blank");
  253. } else if (typeof data === "string") {
  254. search(data);
  255. } else {
  256. log("Draggy can't find selected text or a valid link");
  257. }
  258. }, { passive: false });
  259.  
  260. // Dynamic configuration
  261. const callbacks = {
  262. circleOverlay: toggleOverlay,
  263. minDistance: onMinDistanceChange,
  264. };
  265. for (const [prop, callback] of Object.entries(callbacks)) { // Initialize
  266. callback(config.get(prop));
  267. }
  268. config.addEventListener("set", (e) => { // Update
  269. const { prop, after } = e.detail;
  270. const callback = callbacks[prop];
  271. callback?.(after);
  272. });
  273.  
  274. log(`${version} initialized successfully 🎉`);
  275. })();