Greasy Fork 增强

增进 Greasyfork 浏览体验。

当前为 2024-12-13 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Greasy Fork Enhance
  3. // @name:zh-CN Greasy Fork 增强
  4. // @namespace http://tampermonkey.net/
  5. // @version 0.9.0
  6. // @description Enhance your experience at Greasyfork.
  7. // @description:zh-CN 增进 Greasyfork 浏览体验。
  8. // @match https://greasyfork.org/*
  9. // @author PRO
  10. // @grant GM_setValue
  11. // @grant GM_getValue
  12. // @grant GM_deleteValue
  13. // @grant GM_registerMenuCommand
  14. // @grant GM_unregisterMenuCommand
  15. // @grant GM_addValueChangeListener
  16. // @require https://github.com/PRO-2684/GM_config/releases/download/v1.2.1/config.min.js#md5=525526b8f0b6b8606cedf08c651163c2
  17. // @icon https://raw.githubusercontent.com/greasyfork-org/greasyfork/main/public/images/blacklogo16.png
  18. // @icon64 https://raw.githubusercontent.com/greasyfork-org/greasyfork/main/public/images/blacklogo96.png
  19. // @license gpl-3.0
  20. // ==/UserScript==
  21.  
  22. (function () {
  23. 'use strict';
  24. // Judge if the script should run
  25. const { contentType } = document;
  26. if (contentType !== "text/html") return;
  27.  
  28. const idPrefix = "greasyfork-enhance-";
  29. const name = GM_info.script.name;
  30.  
  31. // Config
  32. const configDesc = {
  33. $default: {
  34. autoClose: false,
  35. },
  36. filterAndSearch: {
  37. name: "🔎 Filter and Search",
  38. type: "folder",
  39. items: {
  40. shortcut: {
  41. name: "Shortcut",
  42. title: "Enable keyboard shortcuts",
  43. type: "bool",
  44. value: true,
  45. },
  46. regexFilter: {
  47. name: "Regex filter",
  48. title: "Use regex to filter out matching scripts",
  49. value: "",
  50. },
  51. searchSyntax: {
  52. name: "*Search syntax",
  53. title: "Enable partial search syntax for Greasy Fork search bar",
  54. type: "bool",
  55. value: true,
  56. },
  57. },
  58. },
  59. codeblocks: {
  60. name: "📝 Code blocks",
  61. type: "folder",
  62. items: {
  63. autoHideCode: {
  64. name: "Auto hide code",
  65. title: "Hide long code blocks by default",
  66. type: "bool",
  67. value: true,
  68. },
  69. autoHideRows: {
  70. name: "Min rows to hide",
  71. title: "Minimum number of rows to hide",
  72. type: "int",
  73. min: 1,
  74. value: 10,
  75. },
  76. tabSize: {
  77. name: "Tab size",
  78. title: "Set Tab indentation size",
  79. type: "int",
  80. min: 0,
  81. value: 4,
  82. },
  83. animation: {
  84. name: "Animation",
  85. title: "Enable animation for toggling code blocks",
  86. type: "bool",
  87. value: true,
  88. },
  89. metadata: {
  90. name: "Metadata",
  91. title: "Parses certain script metadata and displays it on the script code page",
  92. type: "bool",
  93. value: false,
  94. },
  95. }
  96. },
  97. display: {
  98. name: "🎨 Display",
  99. type: "folder",
  100. items: {
  101. hideButtons: {
  102. name: "Hide buttons",
  103. title: "Hide floating buttons added by this script",
  104. type: "bool",
  105. value: false,
  106. },
  107. stickyPagination: {
  108. name: "Sticky pagination",
  109. title: "Make pagination bar sticky",
  110. type: "bool",
  111. value: true,
  112. },
  113. flatLayout: {
  114. name: "Flat layout",
  115. title: "Use flat layout for script list and descriptions",
  116. type: "bool",
  117. value: false,
  118. },
  119. showVersion: {
  120. name: "Show version",
  121. title: "Show version number in script list",
  122. type: "bool",
  123. value: false,
  124. },
  125. navigationBar: {
  126. name: "Navigation bar",
  127. title: "Override navigation bar style",
  128. type: "enum",
  129. options: ["default", "desktop", "mobile"],
  130. value: 0,
  131. },
  132. alwaysShowNotification: {
  133. name: "Always show notification",
  134. title: "Always show the notification widget",
  135. type: "bool",
  136. value: false,
  137. },
  138. },
  139. },
  140. other: {
  141. name: "🔧 Other",
  142. type: "folder",
  143. items: {
  144. shortLink: {
  145. name: "Short link",
  146. title: "Display a shortened link to current script",
  147. type: "bool",
  148. value: true,
  149. },
  150. libAlternativeUrl: {
  151. name: "Alternative URLs for library",
  152. title: "Show a list of alternative URLs for a given library",
  153. type: "bool",
  154. value: false,
  155. },
  156. imageProxy: {
  157. name: "*Image proxy",
  158. title: "Use `wsrv.nl` as proxy for user-uploaded images",
  159. type: "bool",
  160. value: false,
  161. },
  162. lazyImage: {
  163. name: "*Lazy image",
  164. title: "Load user images lazily",
  165. type: "bool",
  166. value: false,
  167. },
  168. debug: {
  169. name: "Debug",
  170. title: "Enable debug mode",
  171. type: "bool",
  172. value: false,
  173. },
  174. }
  175. }
  176. };
  177. const config = new GM_config(configDesc);
  178. // CSS
  179. /**
  180. * Dynamic styles for the bool type.
  181. * @type {Object<string, string>}
  182. */
  183. const dynamicStyles = {
  184. "codeblocks.animation": `
  185. /* Toggle code animation */
  186. pre > code { transition: height 0.5s ease-in-out 0s; }
  187. /* Adapted from animate.css - https://animate.style/ */
  188. :root { --animate-duration: 1s; --animate-delay: 1s; --animate-repeat: 1; }
  189. .animate__animated { animation-duration: var(--animate-duration); animation-fill-mode: both; }
  190. .animate__animated.animate__fastest { animation-duration: calc(var(--animate-duration) / 3); }
  191. @keyframes tada {
  192. from { transform: scale3d(1, 1, 1); }
  193. 10%, 20% { transform: scale3d(0.9, 0.9, 0.9) rotate3d(0, 0, 1, -3deg); }
  194. 30%, 50%, 70%, 90% { transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, 3deg); }
  195. 40%, 60%, 80% { transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, -3deg); }
  196. to { transform: scale3d(1, 1, 1); }
  197. }
  198. .animate__tada { animation-name: tada; }
  199. @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
  200. .animate__fadeIn { animation-name: fadeIn; }
  201. @keyframes fadeOut { from { opacity: 1; } to { opacity: 0; } }
  202. .animate__fadeOut { -webkit-animation-name: fadeOut; animation-name: fadeOut; }
  203. `,
  204. "display.hideButtons": `div#float-buttons { display: none; }`,
  205. "display.stickyPagination": `.sidebarred-main-content > .pagination { position: sticky; bottom: 0; backdrop-filter: blur(5px); padding: 0.5em; }`,
  206. "display.flatLayout": `
  207. .script-list > li {
  208. &:not(.ad-entry) { padding-right: 0; }
  209. article {
  210. display: flex; flex-direction: row; justify-content: space-between; align-items: center;
  211. > .script-meta-block {
  212. width: 40%; column-gap: 0;
  213. > .inline-script-stats {
  214. margin: 0;
  215. > dd { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
  216. }
  217. }
  218. > h2 {
  219. width: 60%; overflow: hidden; text-overflow: ellipsis; margin-right: 0.5em; padding-right: 0.5em; border-right: 1px solid #88888888;
  220. > .script-link { white-space: nowrap; }
  221. > .script-description { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
  222. }
  223. }
  224. &[data-script-type="library"] > article {
  225. > h2 { width: 80%; }
  226. > .script-meta-block { width: 20%; column-count: 1; }
  227. }
  228. }
  229. @media (max-width: 600px) {
  230. .script-list > li {
  231. &[data-script-type="library"] > article > div.script-meta-block { width: 40%; }
  232. &:not([data-script-type="library"]) > article {
  233. display: block;
  234. > h2 { width: unset; border-right: none; }
  235. > .script-meta-block { column-count: 2; }
  236. }
  237. > article > div.script-meta-block { width: unset; column-gap: 0; }
  238. }
  239. }
  240. .showing-all-languages .badge-js, .showing-all-languages .badge-css, .script-type { display: none; }
  241. #script-info .script-meta-block { float: right; column-count: 1; max-width: 300px; border-left: 1px solid #DDDDDD; margin-left: 1em; padding-left: 1em; }
  242. #additional-info { width: calc(100% - 2em - 2px); }
  243. `,
  244. "display.showVersion": `.script-list > li[data-script-version]::before { content: "@" attr(data-script-version); position: absolute; translate: 0 -1em; color: grey; font-size: smaller; }`,
  245. };
  246. /**
  247. * Dynamic styles for the enum type.
  248. * @type {Object<string, Array<string>>}
  249. */
  250. const enumStyles = {
  251. "display.navigationBar": [
  252. "/* Default */",
  253. "/* Desktop */ #main-header { #site-nav { display: block; } #mobile-nav { display: none; } }",
  254. "/* Mobile */ #main-header { #site-nav { display: none; } #mobile-nav { display: block; } }",
  255. ]
  256. };
  257. // Functions
  258. const $ = document.querySelector.bind(document);
  259. const $$ = document.querySelectorAll.bind(document);
  260. const body = $("body");
  261. function log(...args) {
  262. if (config.get("other.debug")) {
  263. console.log(`[${name}]`, ...args);
  264. }
  265. }
  266. function sanitify(s) {
  267. // Remove emojis (such a headache)
  268. s = s.replaceAll(/([\uE000-\uF8FF]|\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDFFF]|[\u2580-\u27BF]|\uD83E[\uDD10-\uDEFF]|\uFE0F)/g, "");
  269. // Trim spaces and newlines
  270. s = s.trim();
  271. // Replace spaces
  272. s = s.replaceAll(" ", "-");
  273. s = s.replaceAll("%20", "-");
  274. // No more multiple "-"
  275. s = s.replaceAll(/-+/g, "-");
  276. return s;
  277. }
  278. function process(outline, node) { // Add anchor and assign id to given node; Add to outline. Return true if node is actually processed.
  279. if (node.childElementCount > 1 || node.classList.length > 0) return false; // Ignore complex nodes
  280. const text = node.textContent;
  281. if (!node.id) { // If the node has no id
  282. node.id = sanitify(text); // Then assign id
  283. }
  284. // Add anchors
  285. const anchor = node.appendChild(document.createElement('a'));
  286. anchor.className = 'anchor';
  287. anchor.href = '#' + node.id;
  288. const link = outline.appendChild(document.createElement("li"))
  289. .appendChild(document.createElement("a"));
  290. link.href = "#" + node.id;
  291. link.text = text;
  292. return true;
  293. }
  294. async function animate(node, animation) {
  295. return new Promise((resolve, reject) => {
  296. node.classList.add("animate__animated", "animate__" + animation);
  297. if (node.getAnimations().length == 0) {
  298. node.classList.remove("animate__animated", "animate__" + animation);
  299. reject("No animation available");
  300. }
  301. node.addEventListener('animationend', e => {
  302. e.stopPropagation();
  303. node.classList.remove("animate__animated", "animate__" + animation);
  304. resolve("Animation ended");
  305. }, { once: true });
  306. });
  307. }
  308. async function transition(node, height) {
  309. return new Promise((resolve, reject) => {
  310. node.style.height = height;
  311. if (node.getAnimations().length == 0) {
  312. resolve("No transition available");
  313. }
  314. node.addEventListener('transitionend', e => {
  315. e.stopPropagation();
  316. resolve("Transition ended");
  317. }, { once: true });
  318. });
  319. }
  320. function copyCode() {
  321. const code = this.parentNode.nextElementSibling;
  322. const text = code.textContent;
  323. navigator.clipboard.writeText(text).then(() => {
  324. this.textContent = "Copied!";
  325. animate(this, "tada").then(() => {
  326. this.textContent = "Copy code";
  327. }, () => {
  328. window.setTimeout(() => {
  329. this.textContent = "Copy code";
  330. }, 1000);
  331. });
  332. });
  333. }
  334. function toggleCode() {
  335. const code = this.parentNode.nextElementSibling;
  336. if (code.style.height == "0px") {
  337. code.style.willChange = "height";
  338. transition(code, code.getAttribute("data-height")).then(() => {
  339. code.style.willChange = "";
  340. });
  341. animate(this, "fadeOut").then(() => {
  342. this.textContent = "Hide code";
  343. animate(this, "fadeIn");
  344. }, () => {
  345. this.textContent = "Hide code";
  346. });
  347. } else {
  348. code.style.willChange = "height";
  349. transition(code, "0px").then(() => {
  350. code.style.willChange = "";
  351. });
  352. animate(this, "fadeOut").then(() => {
  353. this.textContent = "Show code";
  354. animate(this, "fadeIn");
  355. }, () => {
  356. this.textContent = "Show code";
  357. });
  358. }
  359. }
  360. function createToolbar() {
  361. const toolbar = document.createElement("div");
  362. const copy = toolbar.appendChild(document.createElement("a"));
  363. const toggle = toolbar.appendChild(document.createElement("a"));
  364. copy.textContent = "Copy code";
  365. copy.className = "code-operation";
  366. copy.title = "Copy code to clipboard";
  367. copy.addEventListener("click", copyCode);
  368. toggle.textContent = "Hide code";
  369. toggle.classList.add("code-operation", "animate__fastest");
  370. toggle.title = "Toggle code display";
  371. toggle.addEventListener("click", toggleCode);
  372. // Css
  373. toolbar.className = "code-toolbar";
  374. return toolbar;
  375. }
  376. function injectCSS(id, css) {
  377. const style = document.head.appendChild(document.createElement("style"));
  378. style.id = idPrefix + id;
  379. style.textContent = css;
  380. return style;
  381. }
  382. function cssHelper(id, enable) {
  383. const current = document.getElementById(idPrefix + id);
  384. if (current) {
  385. current.disabled = !enable;
  386. } else if (enable) {
  387. injectCSS(id, dynamicStyles[id]);
  388. }
  389. }
  390. /**
  391. * Helper function to configure enum styles.
  392. * @param {string} id The ID of the style.
  393. * @param {string} mode The mode to set.
  394. */
  395. function enumStyleHelper(id, mode) {
  396. const style = document.getElementById(idPrefix + id) ?? injectCSS(id, "");
  397. style.textContent = enumStyles[id][mode];
  398. }
  399. for (const prop in enumStyles) {
  400. enumStyleHelper(prop, config.get(prop));
  401. }
  402. // Basic css
  403. injectCSS("basic", `
  404. html { scroll-behavior: smooth; }
  405. a.anchor::before { content: "#"; }
  406. a.anchor { opacity: 0; text-decoration: none; padding: 0px 0.5em; transition: all 0.25s ease-in-out; }
  407. h1:hover>a.anchor, h2:hover>a.anchor, h3:hover>a.anchor,
  408. h4:hover>a.anchor, h5:hover>a.anchor, h6:hover>a.anchor { opacity: 1; transition: all 0.25s ease-in-out; }
  409. a.button { margin: 0.5em 0 0 0; display: flex; align-items: center; justify-content: center; text-decoration: none; color: black; background-color: #a42121ab; border-radius: 50%; width: 2em; height: 2em; font-size: 1.8em; font-weight: bold; }
  410. div.code-toolbar { display: flex; gap: 1em; }
  411. a.code-operation { cursor: pointer; font-style: italic; }
  412. div.lum-lightbox { z-index: 2; }
  413. #float-buttons { position: fixed; bottom: 1em; right: 1em; display: flex; flex-direction: column; user-select: none; z-index: 1; }
  414. aside.panel { display: none; }
  415. .dynamic-opacity { transition: opacity 0.2s ease-in-out; opacity: 0.2; }
  416. .dynamic-opacity:hover { opacity: 0.8; }
  417. input[type=file] { border-style: dashed; border-radius: 0.5em; border-color: gray; padding: 0.5em; background: rgba(169, 169, 169, 0.4); transition-property: border-color, background; transition-duration: 0.25s; transition-timing-function: ease-in-out; }
  418. input[type=file]:hover { border-color: black; background: rgba(169, 169, 169, 0.6); }
  419. input[type=file]::file-selector-button { border: 1px solid; border-radius: 0.3em; transition: background 0.25s ease-in-out; background: rgba(169, 169, 169, 0.7); }
  420. input[type=file]::file-selector-button:hover { background: rgba(169, 169, 169, 1); }
  421. table { border: 1px solid #8d8d8d; border-collapse: collapse; width: auto; }
  422. table td, table th { padding: 0.5em 0.75em; vertical-align: middle; border: 1px solid #8d8d8d; }
  423. @media (any-hover: none) { .dynamic-opacity { opacity: 0.8; } .dynamic-opacity:hover { opacity: 0.8; } }
  424. @media screen and (min-width: 767px) {
  425. aside.panel { display: contents; line-height: 1.5; }
  426. ul.outline { position: sticky; float: right; padding: 0 0 0 0.5em; margin: 0 0.5em -99vh; max-height: 80vh; border: 1px solid #BBBBBB; border-left: 2px solid #F2E5E5; box-shadow: 0 0 5px #ddd; background: linear-gradient(to right, #fcf1f1, #FFF 1em); list-style: none; width: 10.5%; color: gray; border-radius: 5px; overflow-y: scroll; z-index: 1; }
  427. ul.outline > li { overflow: hidden; text-overflow: ellipsis; }
  428. ul.outline > li > a { color: gray; white-space: nowrap; text-decoration: none; }
  429. }
  430. pre > code { overflow: hidden; display: block; }
  431. ul { padding-left: 1.5em; }
  432. .script-list > .regex-filtered { display: none; }
  433. #greasyfork-enhance-regex-filter-tip { float: right; color: grey; }
  434. @media screen and (max-width: 800px) { #greasyfork-enhance-regex-filter-tip { display: none; } }`);
  435. // Aside panel & Anchors
  436. const isScript = /^\/[^\/]+\/scripts/;
  437. const isSpecificScript = /^\/[^\/]+\/scripts\/\d+/;
  438. const isDisccussion = /^\/[^\/]+\/discussions/;
  439. const path = window.location.pathname;
  440. if ((!isScript.test(path) && !isDisccussion.test(path)) || isSpecificScript.test(path)) {
  441. const panel = body.insertBefore(document.createElement("aside"), $("body > div.width-constraint"));
  442. panel.className = "panel";
  443. const reference_node = $("body > div.width-constraint > section");
  444. const outline = panel.appendChild(document.createElement("ul"));
  445. outline.classList.add("outline");
  446. outline.classList.add("dynamic-opacity");
  447. outline.style.top = reference_node ? getComputedStyle(reference_node).marginTop : "1em";
  448. outline.style.marginTop = outline.style.top;
  449. let flag = false;
  450. $$("body > div.width-constraint h1, h2, h3, h4, h5, h6").forEach((node) => {
  451. flag = process(outline, node) || flag; // Not `flag || process(node)`!
  452. });
  453. if (!flag) {
  454. panel.remove();
  455. }
  456. }
  457. // Navigate to hash
  458. const hash = window.location.hash.slice(1);
  459. if (hash) {
  460. const ele = document.getElementById(decodeURIComponent(hash));
  461. if (ele) {
  462. ele.scrollIntoView();
  463. }
  464. }
  465. // Buttons
  466. const buttons = body.appendChild(document.createElement("div"));
  467. buttons.id = "float-buttons";
  468. const goToTop = buttons.appendChild(document.createElement("a"));
  469. goToTop.classList.add("button");
  470. goToTop.classList.add("dynamic-opacity");
  471. goToTop.href = "#top";
  472. goToTop.text = "↑";
  473. // Double click to get to top
  474. body.addEventListener("dblclick", (e) => {
  475. if (e.target === body) {
  476. goToTop.click();
  477. }
  478. });
  479. // Fix current tab link
  480. const tab = $("ul#script-links > li.current");
  481. if (tab) {
  482. const link = tab.appendChild(document.createElement("a"));
  483. link.href = window.location.pathname;
  484. link.appendChild(tab.firstChild);
  485. }
  486. const parts = window.location.pathname.split("/");
  487. if (parts.length <= 2 || (parts.length == 3 && parts[2] === '')) {
  488. const banner = $("header#main-header div#site-name");
  489. const img = banner.querySelector("img");
  490. const text = banner.querySelector("#site-name-text > h1");
  491. const link1 = document.createElement("a");
  492. link1.href = window.location.pathname;
  493. img.parentNode.replaceChild(link1, img);
  494. link1.appendChild(img);
  495. const link2 = document.createElement("a");
  496. link2.href = window.location.pathname;
  497. link2.textContent = text.textContent;
  498. text.textContent = "";
  499. text.appendChild(link2);
  500. }
  501. // Toolbar for code blocks
  502. const codeBlocks = document.getElementsByTagName("pre");
  503. for (const codeBlock of codeBlocks) {
  504. if (codeBlock.firstChild.tagName === "CODE") {
  505. const height = getComputedStyle(codeBlock.firstChild).getPropertyValue("height");
  506. codeBlock.firstChild.style.height = height;
  507. codeBlock.firstChild.setAttribute("data-height", height);
  508. codeBlock.insertAdjacentElement("afterbegin", createToolbar());
  509. }
  510. }
  511.  
  512. // Filter and Search
  513. // Shortcut
  514. function submitOnCtrlEnter(e) {
  515. const form = this.form;
  516. if (!form) return;
  517. // Ctrl + Enter to submit
  518. if (e.ctrlKey && e.key === "Enter") {
  519. form.submit();
  520. }
  521. }
  522. function handleShortcut(e) {
  523. const ele = document.activeElement;
  524. // Ignore key combinations
  525. if (e.ctrlKey || e.altKey || e.metaKey || e.shiftKey) {
  526. return;
  527. }
  528. // Do not interfere with input elements
  529. if (ele.tagName === "INPUT" || ele.tagName === "TEXTAREA" || ele.getAttribute("contenteditable") === "true") {
  530. if (e.key === "Escape") {
  531. e.preventDefault();
  532. ele.blur(); // Escape to blur
  533. }
  534. return;
  535. }
  536. // Do not interfere with input methods
  537. if (e.isComposing || e.keyCode === 229) {
  538. return;
  539. }
  540. // Focus on search bar
  541. switch (e.key) {
  542. case "Enter": {
  543. const input = $("input[type=search]") || $("input[type=text]") || $("textarea");
  544. if (input) {
  545. e.preventDefault();
  546. input.focus();
  547. }
  548. break;
  549. }
  550. case "ArrowLeft":
  551. $("a.previous_page")?.click();
  552. break;
  553. case "ArrowRight":
  554. $("a.next_page")?.click();
  555. break;
  556. }
  557. }
  558. let shortcutEnabled = false;
  559. function shortcut(enable) {
  560. const textAreas = $$("textarea");
  561. if (!shortcutEnabled && enable) {
  562. for (const textarea of textAreas) {
  563. textarea.addEventListener("keyup", submitOnCtrlEnter);
  564. }
  565. document.addEventListener("keydown", handleShortcut);
  566. shortcutEnabled = true;
  567. } else if (shortcutEnabled && !enable) {
  568. for (const textarea of textAreas) {
  569. textarea.removeEventListener("keyup", submitOnCtrlEnter);
  570. }
  571. document.removeEventListener("keydown", handleShortcut);
  572. shortcutEnabled = false;
  573. }
  574. }
  575. shortcut(config.get("filterAndSearch.shortcut"));
  576. // Regex filter
  577. const regexFilterTip = $(".sidebarred > .sidebarred-main-content > .script-list#browse-script-list")
  578. ?.previousElementSibling?.appendChild?.(document.createElement("span"));
  579. if (regexFilterTip) {
  580. regexFilterTip.id = idPrefix + "regex-filter-tip";
  581. regexFilterTip.title = `[${name}] Number of scripts filtered by regex`;
  582. }
  583. function setRegexFilterTip(content) {
  584. if (regexFilterTip) {
  585. regexFilterTip.textContent = content;
  586. }
  587. }
  588. function regexFilterOne(regex, script) {
  589. const info = script.querySelector("article > h2");
  590. if (!info) return;
  591. const name = info.querySelector(".script-link").textContent;
  592. const result = regex.test(name);
  593. script.classList.toggle("regex-filtered", result);
  594. if (result) {
  595. log("Filtered:", name);
  596. }
  597. return result;
  598. }
  599. function regexFilter(regexStr) {
  600. const debug = config.get("other.debug");
  601. const scripts = $$(".script-list > li");
  602. if (regexStr === "" || scripts.length === 0) {
  603. scripts.forEach(script => script.classList.remove("regex-filtered"));
  604. setRegexFilterTip("");
  605. return;
  606. }
  607. const regex = new RegExp(regexStr, "i");
  608. let count = 0;
  609. debug && console.groupCollapsed(`[${name}] Regex filtered scripts`);
  610. scripts.forEach(script => {
  611. if (regexFilterOne(regex, script)) {
  612. count++;
  613. }
  614. });
  615. setRegexFilterTip(`Filtered: ${count}/${scripts.length}`);
  616. debug && console.groupEnd();
  617. }
  618. regexFilter(config.get("filterAndSearch.regexFilter"));
  619. // Search syntax
  620. const types = {
  621. "script": "scripts",
  622. "lib": "scripts/libraries",
  623. "library": "scripts/libraries",
  624. // "code": "scripts/code-search", // It uses a different search parameter `c` instead of `q`
  625. "user": "users"
  626. };
  627. const langs = {
  628. "js": "",
  629. "javascript": "",
  630. "css": "css",
  631. "any": "all",
  632. "all": "all"
  633. };
  634. const sorts = {
  635. "rel": "",
  636. "relevant": "",
  637. "relevance": "",
  638. "day": "daily_installs",
  639. "daily": "daily_installs",
  640. "daily_install": "daily_installs",
  641. "daily_installs": "daily_installs",
  642. "total": "total_installs",
  643. "total_install": "total_installs",
  644. "total_installs": "total_installs",
  645. "score": "ratings",
  646. "rate": "ratings",
  647. "rating": "ratings",
  648. "ratings": "ratings",
  649. "created": "created",
  650. "created_at": "created",
  651. "updated": "updated",
  652. "updated_at": "updated",
  653. "name": "name",
  654. "title": "name",
  655. };
  656. if (config.get("filterAndSearch.searchSyntax")) {
  657. function parseString(input) {
  658. // Regular expression to match key:value pairs, allowing for non-word characters in values
  659. const regex = /\b(\w+:[^\s]+)\b/g;
  660. // Extract all key:value pairs
  661. const pairs = input.match(regex) || [];
  662. // Remove the pairs from the input string
  663. const cleanedString = input.replace(regex, '').replace(/\s{2,}/g, ' ').trim();
  664.  
  665. // Convert pairs to an object
  666. const parsedPairs = pairs.reduce((acc, pair) => {
  667. const [key, value] = pair.split(':');
  668. acc[key.toLowerCase()] = value.toLowerCase(); // Case-insensitive
  669. return acc;
  670. }, {});
  671.  
  672. return { cleanedString, parsedPairs };
  673. }
  674. function processSearch(search) {
  675. const form = search.form;
  676. if (form.method !== "get") {
  677. return;
  678. }
  679. form.addEventListener("submit", (e) => {
  680. const { cleanedString, parsedPairs } = parseString(search.value);
  681. if (cleanedString === search.value) return;
  682. search.value = cleanedString;
  683. if (!parsedPairs) return;
  684. e.preventDefault();
  685. const url = new URL(form.action, window.location.href);
  686. url.searchParams.set("q", cleanedString);
  687. if (parsedPairs["site"]) { // site:site-name
  688. url.pathname = `/scripts/by-site/${parsedPairs["site"]}`;
  689. } else if (parsedPairs["type"]) { // type:type, including "script", "lib"/"library", "code", "user"
  690. const typeUrl = types[parsedPairs["type"]];
  691. if (typeUrl) {
  692. url.pathname = `/${typeUrl}`;
  693. }
  694. }
  695. if (parsedPairs["lang"]) { // lang:language
  696. const lang = langs[parsedPairs["lang"]];
  697. if (lang === "") {
  698. url.searchParams.delete("language");
  699. } else if (lang) {
  700. url.searchParams.set("language", lang);
  701. }
  702. }
  703. if (parsedPairs["sort"]) { // sort:sort-by
  704. const sort = sorts[parsedPairs["sort"]];
  705. if (sort === "" || sort === "daily_installs" && cleanedString === "") {
  706. url.searchParams.delete("sort");
  707. } else if (sort) {
  708. url.searchParams.set("sort", sort);
  709. }
  710. }
  711. window.location.href = url.href;
  712. });
  713. }
  714. const searches = $$("input[type=search][name=q]");
  715. for (const search of searches) {
  716. processSearch(search);
  717. }
  718. }
  719.  
  720. // Code blocks
  721. // Auto hide code blocks
  722. function autoHide() {
  723. if (!config.get("codeblocks.autoHideCode")) {
  724. for (const code_block of codeBlocks) {
  725. const toggle = code_block.firstChild.lastChild;
  726. if (!toggle) continue;
  727. if (toggle.textContent === "Show code") {
  728. toggle.click(); // Click the toggle button
  729. }
  730. }
  731. } else {
  732. for (const codeBlock of codeBlocks) {
  733. const m = codeBlock.lastChild.textContent.match(/\n/g);
  734. const rows = m ? m.length : 0;
  735. const toggle = codeBlock.firstChild.lastChild;
  736. if (!toggle) continue;
  737. const hidden = toggle.textContent === "Show code";
  738. if (rows >= config.get("codeblocks.autoHideRows") && !hidden || rows < config.get("codeblocks.autoHideRows") && hidden) {
  739. codeBlock.firstChild.lastChild.click(); // Click the toggle button
  740. }
  741. }
  742. }
  743. }
  744. document.addEventListener("readystatechange", (e) => {
  745. if (e.target.readyState === "complete") {
  746. autoHide();
  747. }
  748. }, { once: true });
  749. // Tab size
  750. function tabSize(value) {
  751. const style = $("style#" + idPrefix + "tab-size") ?? document.head.appendChild(document.createElement("style"));
  752. style.id = idPrefix + "tab-size";
  753. style.textContent = `pre { tab-size: ${value}; }`;
  754. }
  755. tabSize(config.get("codeblocks.tabSize"));
  756. // Metadata
  757. function extractUserScriptMetadata(code) {
  758. const result = {};
  759. const userScriptRegex = /\/\/\s*=+\s*UserScript\s*=+\s*([\s\S]*?)\s*=+\s*\/UserScript\s*=+\s*/;
  760. const match = code.match(userScriptRegex);
  761. if (match) {// If the UserScript block is found
  762. const content = match[1];// Extract the content within the UserScript block
  763. const lines = content.split('\n'); // Split the content by newline
  764.  
  765. lines.forEach(line => {
  766. // Regular expression to match "// @name value" pattern
  767. const matchLine = line.trim().match(/^\/\/\s*@(\S+)\s+(.+)$/);
  768. if (matchLine) {
  769. const name = matchLine[1]; // Extract the name
  770. const value = matchLine[2]; // Extract the value
  771. switch (typeof result[name]) {
  772. case "undefined": // First occurrence
  773. result[name] = value;
  774. break;
  775. case "string": // Second occurrence
  776. result[name] = [result[name], value];
  777. break;
  778. case "object": // Third or more occurrence
  779. result[name].push(value);
  780. break;
  781. }
  782. }
  783. });
  784. }
  785. return result;
  786. }
  787. function metadata(enable) {
  788. const id = idPrefix + "metadata";
  789. const current = document.getElementById(id);
  790. if (current && !enable) {
  791. current.remove();
  792. } else if (!current && enable) {
  793. const scriptCodeBlock = document.querySelector(".code-container > pre.prettyprint.lang-js");
  794. const description = $("div#script-content");
  795. if (!window.location.pathname.endsWith("/code") || !scriptCodeBlock || !description) return;
  796. const metaBlock = document.createElement("ul");
  797. description.prepend(metaBlock);
  798. metaBlock.id = id;
  799. const script = scriptCodeBlock.querySelector("ol") ? Array.from(scriptCodeBlock.querySelectorAll("ol > li")).map(li => li.textContent).join("\n") : scriptCodeBlock.textContent;
  800. const metadata = extractUserScriptMetadata(script);
  801. const commonHosts = {
  802. GreasyFork: /^https?:\/\/update\.greasyfork\.org\/scripts\/\d+\/(?<ver>\d+)\/(?<name>.+?)\.js$/,
  803. JsDelivr: /^https?:\/\/cdn\.jsdelivr\.net\/(?<reg>\w+)\/(@[^/]+\/)?(?<name>[^@]+)@(?<ver>[^/]+)/,
  804. Cloudflare: /^https?:\/\/cdnjs\.cloudflare\.com\/ajax\/libs\/(?<name>[^/]+)\/(?<ver>[^/]+)/,
  805. };
  806. const commonRegistries = {
  807. npm: "NPM",
  808. gh: "GitHub",
  809. };
  810. // We're interested in `@grant`, `@connect`, `@require`, `@resource`
  811. const interestedMetadata = {};
  812. const interestedKeys = {
  813. grant: {
  814. brief: "Required permissions",
  815. display: (value) => {
  816. const valueCode = document.createElement("code");
  817. valueCode.textContent = value;
  818. if (value !== "none") {
  819. const valueLink = document.createElement("a");
  820. valueLink.appendChild(valueCode);
  821. valueLink.href = `https://www.tampermonkey.net/documentation.php#api:${valueCode.textContent}`;
  822. valueLink.title = `See documentation about ${valueCode.textContent}`;
  823. return valueLink;
  824. } else {
  825. return valueCode;
  826. }
  827. }
  828. },
  829. connect: {
  830. brief: "Allowed URLs to connect",
  831. display: (value) => {
  832. const valueCode = document.createElement("code");
  833. valueCode.textContent = value;
  834. return valueCode;
  835. }
  836. },
  837. require: {
  838. brief: "External libraries",
  839. display: (value) => {
  840. const valueLink = document.createElement("a");
  841. valueLink.href = value;
  842. valueLink.textContent = value;
  843. for (const [host, regex] of Object.entries(commonHosts)) {
  844. const match = value.match(regex);
  845. if (match) {
  846. const { name, ver, reg } = match.groups;
  847. const optionalRegistry = commonRegistries[reg] ? `${commonRegistries[reg]} on ` : "";
  848. valueLink.textContent = `${decodeURIComponent(name)}@${ver} (${optionalRegistry}${host})`;
  849. break;
  850. }
  851. }
  852. return valueLink;
  853. }
  854. },
  855. resource: {
  856. brief: "External resources",
  857. display: (value) => {
  858. const valueCode = document.createElement("code");
  859. const [name, link] = value.split(" ");
  860. const valueLink = document.createElement("a");
  861. valueLink.appendChild(valueCode);
  862. valueLink.href = link.trim();
  863. valueCode.textContent = name.trim();
  864. return valueLink;
  865. }
  866. }
  867. };
  868. for (const key in interestedKeys) {
  869. const values = metadata[key] ?? [];
  870. interestedMetadata[key] = Array.isArray(values) ? values : [values];
  871. }
  872. log("Interested Metadata:", interestedMetadata);
  873. // Display
  874. for (const [key, values] of Object.entries(interestedMetadata)) {
  875. const keyInfo = interestedKeys[key];
  876. const li = metaBlock.appendChild(document.createElement("li"));
  877. const keyLink = li.appendChild(document.createElement("a"));
  878. keyLink.href = `https://www.tampermonkey.net/documentation.php#meta:${key}`;
  879. keyLink.title = keyInfo.brief;
  880. keyLink.textContent = `@${key}`;
  881. const separator = li.appendChild(document.createElement("span"));
  882. separator.textContent = ": ";
  883. for (const value of values) {
  884. li.appendChild(keyInfo.display(value));
  885. const separator = li.appendChild(document.createElement("span"));
  886. separator.textContent = ", ";
  887. }
  888. if (values.length > 0) {
  889. li.lastChild.remove(); // Remove the last separator
  890. } else {
  891. li.appendChild(document.createTextNode("none"));
  892. }
  893. }
  894. }
  895. }
  896. metadata(config.get("codeblocks.metadata"));
  897.  
  898. // Display
  899. // Flat layout
  900. function flatLayout(enable) {
  901. const meta_orig = $("#script-info > #script-content .script-meta-block");
  902. const meta_mod = $("#script-info > .script-meta-block");
  903. if (enable && meta_orig) {
  904. const header = $("#script-info > header");
  905. header.before(meta_orig);
  906. } else if (!enable && meta_mod) {
  907. const additional = $("#script-info > #script-content #additional-info");
  908. additional.before(meta_mod);
  909. }
  910. }
  911. flatLayout(config.get("display.flatLayout"));
  912. // Always show notification
  913. function alwaysShowNotification(enable) {
  914. const nav = $("#nav-user-info");
  915. const profile = nav?.querySelector(".user-profile-link");
  916. const existing = nav.querySelector(".notification-widget");
  917. if (!nav || !profile || existing && existing.textContent !== "0") return; // There's unread notification or user is not logged in
  918. if (enable && !existing) {
  919. const notification = nav.insertBefore(document.createElement("a"), profile);
  920. notification.className = "notification-widget";
  921. notification.textContent = "0";
  922. notification.href = profile.querySelector("a").href + "/notifications";
  923. } else if (!enable && existing) {
  924. existing.remove();
  925. }
  926. }
  927. alwaysShowNotification(config.get("display.alwaysShowNotification"));
  928.  
  929. // Other
  930. // Short link
  931. function shortLink(enable) {
  932. const description = $("div#script-content");
  933. const url = window.location.href;
  934. const scriptId = url.match(/\/scripts\/(\d+)/)?.[1];
  935. if (!scriptId || !description) return;
  936. const id = idPrefix + "short-link";
  937. const current = document.getElementById(id);
  938. if (current && !enable) {
  939. current.remove();
  940. } else if (!current && enable) {
  941. const short = `https://greasyfork.org/scripts/${scriptId}`;
  942. const p = description.insertAdjacentElement("beforebegin", document.createElement("p"));
  943. p.id = id;
  944. p.textContent = "Short link: ";
  945. const link = p.appendChild(document.createElement("a"));
  946. link.href = short;
  947. link.textContent = short;
  948. const copy = p.appendChild(document.createElement("a"));
  949. copy.textContent = "(Copy)";
  950. copy.style.marginLeft = "1em";
  951. copy.style.cursor = "pointer";
  952. copy.title = "Copy short link to clipboard";
  953. copy.addEventListener("click", () => {
  954. if (copy.textContent === "(Copied!)") return;
  955. navigator.clipboard.writeText(short).then(() => {
  956. copy.textContent = "(Copied!)";
  957. window.setTimeout(() => {
  958. copy.textContent = "(Copy)";
  959. }, 1000);
  960. });
  961. });
  962. }
  963. }
  964. shortLink(config.get("other.shortLink"));
  965. // Alternative URLs for library
  966. function alternativeURLs(enable) {
  967. if ($(".remove-attachments") || !$("div#script-content") || $("div#script-content > div#install-area")) return; // Not a library
  968. const id = idPrefix + "lib-alternative-url";
  969. const current = document.getElementById(id);
  970. if (current && !enable) {
  971. current.remove();
  972. } else if (!current && enable) {
  973. const description = $("div#script-content > p");
  974. const trim = "// @require ";
  975. const text = description?.querySelector("code")?.textContent;
  976. if (!text || !text.startsWith(trim)) return; // Found no URL
  977. const url = text.slice(trim.length);
  978. const parts = url.split("/");
  979. const scriptId = parts[4];
  980. const scriptVersion = parts[5];
  981. const fileName = parts[6];
  982. const URLs = [
  983. [`// @require https://update.greasyfork.org/scripts/${scriptId}/${fileName}`, "Latest version"],
  984. [`// @require https://greasyfork.org/scripts/${scriptId}/code/${fileName}?version=${scriptVersion}`, "Current version (Legacy)"],
  985. [`// @require https://greasyfork.org/scripts/${scriptId}/code/${fileName}`, "Latest version (Legacy)"],
  986. ];
  987.  
  988. const detail = document.createElement("p").appendChild(document.createElement("details"));
  989. description.after(detail.parentElement);
  990. detail.parentElement.id = id;
  991. detail.appendChild(document.createElement("summary")).textContent = "Alternative URLs";
  992. const list = detail.appendChild(document.createElement("ul"));
  993. for (const [url, text] of URLs) {
  994. const link = list.appendChild(document.createElement("li")).appendChild(document.createElement("code"));
  995. link.textContent = url;
  996. link.title = text;
  997. }
  998. }
  999. }
  1000. alternativeURLs(config.get("other.libAlternativeUrl"));
  1001. // Image proxy
  1002. if (config.get("other.imageProxy")) {
  1003. const PROXY = "https://wsrv.nl/?url=";
  1004. const images = $$("a[href^='/rails/active_storage/blobs/redirect/'] > img[src^='https://greasyfork.']");
  1005. for (const img of images) {
  1006. img.src = PROXY + img.src;
  1007. const link = img.parentElement;
  1008. link.href = PROXY + link.href;
  1009. }
  1010. }
  1011. // Lazy image
  1012. if (config.get("other.lazyImage")) {
  1013. const images = $$(".user-content img");
  1014. for (const image of images) {
  1015. image.loading = "lazy";
  1016. }
  1017. }
  1018.  
  1019. // Initialize css
  1020. for (const prop in dynamicStyles) {
  1021. cssHelper(prop, config.get(prop));
  1022. }
  1023. // Dynamically respond to config changes
  1024. const callbacks = {
  1025. "filterAndSearch.shortcut": shortcut,
  1026. "filterAndSearch.regexFilter": regexFilter,
  1027. "codeblocks.autoHideCode": autoHide,
  1028. "codeblocks.autoHideRows": autoHide,
  1029. "codeblocks.tabSize": tabSize,
  1030. "codeblocks.metadata": metadata,
  1031. "display.flatLayout": flatLayout,
  1032. "display.alwaysShowNotification": alwaysShowNotification,
  1033. "other.shortLink": shortLink,
  1034. "other.libAlternativeUrl": alternativeURLs,
  1035. };
  1036. config.addEventListener("set", e => {
  1037. if (e.detail.prop in dynamicStyles) {
  1038. cssHelper(e.detail.prop, e.detail.after);
  1039. }
  1040. if (e.detail.prop in enumStyles) {
  1041. enumStyleHelper(e.detail.prop, e.detail.after);
  1042. }
  1043. const callback = callbacks[e.detail.prop];
  1044. if (callback && (e.detail.before !== e.detail.after)) {
  1045. callback(e.detail.after);
  1046. }
  1047. });
  1048. })();