Rule34 Favorites Search Gallery

Search, View, and Play Rule34 Favorites (Desktop/Androiod/iOS)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください。
  1. // ==UserScript==
  2. // @name Rule34 Favorites Search Gallery
  3. // @namespace bruh3396
  4. // @version 1.18.2
  5. // @description Search, View, and Play Rule34 Favorites (Desktop/Androiod/iOS)
  6. // @author bruh3396
  7. // @compatible Chrome
  8. // @compatible Edge
  9. // @compatible Firefox
  10. // @compatible Safari
  11. // @compatible Opera
  12. // @match https://rule34.xxx/index.php?page=favorites&s=view&id=*
  13. // @match https://rule34.xxx/index.php?page=post&s=list*
  14. // ==/UserScript==
  15.  
  16. class Utils {
  17. static utilitiesHTML = `
  18. <style>
  19. .light-green-gradient {
  20. background: linear-gradient(to bottom, #aae5a4, #89e180);
  21. color: black;
  22. }
  23.  
  24. .dark-green-gradient {
  25. background: linear-gradient(to bottom, #5e715e, #293129);
  26. color: white;
  27. }
  28.  
  29. img {
  30. border: none !important;
  31. }
  32.  
  33. .not-highlightable {
  34. -webkit-touch-callout: none;
  35. -webkit-user-select: none;
  36. -khtml-user-select: none;
  37. -moz-user-select: none;
  38. -ms-user-select: none;
  39. user-select: none;
  40. }
  41.  
  42. input[type=number] {
  43. border: 1px solid #767676;
  44. border-radius: 2px;
  45. }
  46.  
  47. .size-calculation-div {
  48. position: absolute !important;
  49. top: 0;
  50. left: 0;
  51. width: 100%;
  52. height: 100%;
  53. visibility: hidden;
  54. transition: none !important;
  55. transform: scale(1.05, 1.05);
  56. }
  57.  
  58. .number {
  59. white-space: nowrap;
  60. position: relative;
  61. margin-top: 5px;
  62. border: 1px solid;
  63. padding: 0;
  64. border-radius: 20px;
  65. background-color: white;
  66.  
  67. >hold-button,
  68. button {
  69. position: relative;
  70. top: 0;
  71. left: 0;
  72. font-size: inherit;
  73. outline: none;
  74. background: none;
  75. cursor: pointer;
  76. border: none;
  77. margin: 0px 8px;
  78. padding: 0;
  79.  
  80. &::after {
  81. content: '';
  82. position: absolute;
  83. top: 50%;
  84. left: 50%;
  85. transform: translate(-50%, -50%);
  86. width: 200%;
  87. height: 100%;
  88. /* outline: 1px solid greenyellow; */
  89. /* background-color: hotpink; */
  90. }
  91.  
  92. &:hover {
  93. >span {
  94. color: #0075FF;
  95. }
  96.  
  97. }
  98.  
  99. >span {
  100. font-weight: bold;
  101. font-family: Verdana, Geneva, Tahoma, sans-serif;
  102. position: relative;
  103. pointer-events: none;
  104. border: none;
  105. outline: none;
  106. top: 0;
  107. z-index: 5;
  108. font-size: 1.2em !important;
  109. }
  110.  
  111. &.number-arrow-up {
  112. >span {
  113. transition: left .1s ease;
  114. left: 0;
  115. }
  116.  
  117. &:hover>span {
  118. left: 3px;
  119. }
  120. }
  121.  
  122. &.number-arrow-down {
  123. >span {
  124. transition: right .1s ease;
  125. right: 0;
  126. }
  127.  
  128. &:hover>span {
  129. right: 3px;
  130. }
  131. }
  132. }
  133.  
  134. >input[type="number"] {
  135. font-size: inherit;
  136. text-align: center;
  137. width: 2ch;
  138. padding: 0;
  139. margin: 0;
  140. font-weight: bold;
  141. padding: 3px;
  142. background: none;
  143. border: none;
  144.  
  145. &:focus {
  146. outline: none;
  147. }
  148. }
  149.  
  150. >input[type="number"]::-webkit-outer-spin-button,
  151. >input[type="number"]::-webkit-inner-spin-button {
  152. -webkit-appearance: none;
  153. appearance: none;
  154. margin: 0;
  155. }
  156.  
  157. input[type=number] {
  158. appearance: textfield;
  159. -moz-appearance: textfield;
  160. }
  161. }
  162.  
  163. .fullscreen-icon {
  164. position: fixed;
  165. top: 50%;
  166. left: 50%;
  167. transform: translate(-50%, -50%);
  168. z-index: 10010;
  169. pointer-events: none;
  170. width: 30vw;
  171. }
  172.  
  173. input[type="checkbox"] {
  174. accent-color: #0075FF;
  175. }
  176.  
  177. .thumb {
  178. >a {
  179. pointer-events: none;
  180.  
  181. >img {
  182. pointer-events: all;
  183. }
  184. }
  185. }
  186.  
  187. .blink {
  188. animation: blink 0.35s step-start infinite;
  189. }
  190.  
  191. @keyframes blink {
  192. 0% {
  193. opacity: 1;
  194. }
  195.  
  196. 50% {
  197. opacity: 0;
  198. }
  199.  
  200. 100% {
  201. opacity: 1;
  202. }
  203. }
  204. </style>
  205. `;
  206. static localStorageKeys = {
  207. imageExtensions: "imageExtensions"
  208. };
  209. static settings = {
  210. extensionsFoundBeforeSavingCount: 100
  211. };
  212. static favoritesSearchGalleryContainer = Utils.createFavoritesSearchGalleryContainer();
  213. static idsToRemoveOnReloadLocalStorageKey = "recentlyRemovedIds";
  214. static tagBlacklist = Utils.getTagBlacklist();
  215. static preferencesLocalStorageKey = "preferences";
  216. static flags = {
  217. set: false,
  218. onSearchPage: {
  219. set: false,
  220. value: undefined
  221. },
  222. onFavoritesPage: {
  223. set: false,
  224. value: undefined
  225. },
  226. onPostPage: {
  227. set: false,
  228. value: undefined
  229. },
  230. usingFirefox: {
  231. set: false,
  232. value: undefined
  233. },
  234. onMobileDevice: {
  235. set: false,
  236. value: undefined
  237. },
  238. userIsOnTheirOwnFavoritesPage: {
  239. set: false,
  240. value: undefined
  241. },
  242. galleryEnabled: {
  243. set: false,
  244. value: undefined
  245. }
  246. };
  247. static icons = {
  248. delete: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"feather feather-trash\"><polyline points=\"3 6 5 6 21 6\"></polyline><path d=\"M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2\"></path></svg>",
  249. edit: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"feather feather-edit\"><path d=\"M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7\"></path><path d=\"M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z\"></path></svg>",
  250. upArrow: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"feather feather-arrow-up\"><line x1=\"12\" y1=\"19\" x2=\"12\" y2=\"5\"></line><polyline points=\"5 12 12 5 19 12\"></polyline></svg>",
  251. heartPlus: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 -960 960 960\" fill=\"#FF69B4\"><path d=\"M440-501Zm0 381L313-234q-72-65-123.5-116t-85-96q-33.5-45-49-87T40-621q0-94 63-156.5T260-840q52 0 99 22t81 62q34-40 81-62t99-22q81 0 136 45.5T831-680h-85q-18-40-53-60t-73-20q-51 0-88 27.5T463-660h-46q-31-45-70.5-72.5T260-760q-57 0-98.5 39.5T120-621q0 33 14 67t50 78.5q36 44.5 98 104T440-228q26-23 61-53t56-50l9 9 19.5 19.5L605-283l9 9q-22 20-56 49.5T498-172l-58 52Zm280-160v-120H600v-80h120v-120h80v120h120v80H800v120h-80Z\"/></svg>",
  252. heartMinus: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 -960 960 960\" fill=\"#FF0000\"><path d=\"M440-501Zm0 381L313-234q-72-65-123.5-116t-85-96q-33.5-45-49-87T40-621q0-94 63-156.5T260-840q52 0 99 22t81 62q34-40 81-62t99-22q84 0 153 59t69 160q0 14-2 29.5t-6 31.5h-85q5-18 8-34t3-30q0-75-50-105.5T620-760q-51 0-88 27.5T463-660h-46q-31-45-70.5-72.5T260-760q-57 0-98.5 39.5T120-621q0 33 14 67t50 78.5q36 44.5 98 104T440-228q26-23 61-53t56-50l9 9 19.5 19.5L605-283l9 9q-22 20-56 49.5T498-172l-58 52Zm160-280v-80h320v80H600Z\"/></svg>",
  253. heartCheck: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 -960 960 960\" fill=\"#51b330\"><path d=\"M718-313 604-426l57-56 57 56 141-141 57 56-198 198ZM440-501Zm0 381L313-234q-72-65-123.5-116t-85-96q-33.5-45-49-87T40-621q0-94 63-156.5T260-840q52 0 99 22t81 62q34-40 81-62t99-22q81 0 136 45.5T831-680h-85q-18-40-53-60t-73-20q-51 0-88 27.5T463-660h-46q-31-45-70.5-72.5T260-760q-57 0-98.5 39.5T120-621q0 33 14 67t50 78.5q36 44.5 98 104T440-228q26-23 61-53t56-50l9 9 19.5 19.5L605-283l9 9q-22 20-56 49.5T498-172l-58 52Z\"/></svg>",
  254. error: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 -960 960 960\" fill=\"#FF0000\"><path d=\"M480-280q17 0 28.5-11.5T520-320q0-17-11.5-28.5T480-360q-17 0-28.5 11.5T440-320q0 17 11.5 28.5T480-280Zm-40-160h80v-240h-80v240Zm40 360q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z\"/></svg>",
  255. warning: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 -960 960 960\" fill=\"#DAB600\"><path d=\"m40-120 440-760 440 760H40Zm138-80h604L480-720 178-200Zm302-40q17 0 28.5-11.5T520-280q0-17-11.5-28.5T480-320q-17 0-28.5 11.5T440-280q0 17 11.5 28.5T480-240Zm-40-120h80v-200h-80v200Zm40-100Z\"/></svg>",
  256. empty: "<button>123</button>",
  257. play: "<svg id=\"autoplay-play-button\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 -960 960 960\" fill=\"white\"><path d=\"M320-200v-560l440 280-440 280Zm80-280Zm0 134 210-134-210-134v268Z\" /></svg>",
  258. pause: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 -960 960 960\" fill=\"white\"><path d=\"M520-200v-560h240v560H520Zm-320 0v-560h240v560H200Zm400-80h80v-400h-80v400Zm-320 0h80v-400h-80v400Zm0-400v400-400Zm320 0v400-400Z\"/></svg>",
  259. changeDirection: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 -960 960 960\" fill=\"white\"><path d=\"M280-160 80-360l200-200 56 57-103 103h287v80H233l103 103-56 57Zm400-240-56-57 103-103H440v-80h287L624-743l56-57 200 200-200 200Z\"/></svg>",
  260. changeDirectionAlt: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 -960 960 960\" fill=\"#0075FF\"><path d=\"M280-160 80-360l200-200 56 57-103 103h287v80H233l103 103-56 57Zm400-240-56-57 103-103H440v-80h287L624-743l56-57 200 200-200 200Z\"/></svg>",
  261. tune: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 -960 960 960\" fill=\"white\"><path d=\"M440-120v-240h80v80h320v80H520v80h-80Zm-320-80v-80h240v80H120Zm160-160v-80H120v-80h160v-80h80v240h-80Zm160-80v-80h400v80H440Zm160-160v-240h80v80h160v80H680v80h-80Zm-480-80v-80h400v80H120Z\"/></svg>",
  262. settings: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 -960 960 960\" fill=\"white\"><path d=\"m370-80-16-128q-13-5-24.5-12T307-235l-119 50L78-375l103-78q-1-7-1-13.5v-27q0-6.5 1-13.5L78-585l110-190 119 50q11-8 23-15t24-12l16-128h220l16 128q13 5 24.5 12t22.5 15l119-50 110 190-103 78q1 7 1 13.5v27q0 6.5-2 13.5l103 78-110 190-118-50q-11 8-23 15t-24 12L590-80H370Zm70-80h79l14-106q31-8 57.5-23.5T639-327l99 41 39-68-86-65q5-14 7-29.5t2-31.5q0-16-2-31.5t-7-29.5l86-65-39-68-99 42q-22-23-48.5-38.5T533-694l-13-106h-79l-14 106q-31 8-57.5 23.5T321-633l-99-41-39 68 86 64q-5 15-7 30t-2 32q0 16 2 31t7 30l-86 65 39 68 99-42q22 23 48.5 38.5T427-266l13 106Zm42-180q58 0 99-41t41-99q0-58-41-99t-99-41q-59 0-99.5 41T342-480q0 58 40.5 99t99.5 41Zm-2-140Z\"/></svg>"
  263. };
  264. static defaults = {
  265. columnCount: 6,
  266. resultsPerPage: 200
  267. };
  268. static addedFavoriteStatuses = {
  269. error: 0,
  270. alreadyAdded: 1,
  271. notLoggedIn: 2,
  272. success: 3
  273. };
  274. static styles = {
  275. thumbHoverOutline: `
  276. .favorite,
  277. .thumb {
  278. >a,
  279. >span,
  280. >div {
  281. &:hover {
  282. outline: 3px solid #0075FF !important;
  283. }
  284. }
  285. }`,
  286. thumbHoverOutlineDisabled: `
  287. .favorite,
  288. .thumb {
  289. >a,
  290. >span,
  291. >div:not(:has(img.video)) {
  292. &:hover {
  293. outline: none;
  294. }
  295. }
  296. }`,
  297. darkTheme: `
  298. input[type=number] {
  299. background-color: #303030;
  300. color: white;
  301. }
  302.  
  303. .number {
  304. background-color: #303030;
  305.  
  306. >hold-button,
  307. button {
  308. color: white;
  309. }
  310. }
  311.  
  312. #favorites-pagination-container {
  313. >button {
  314. border: 1px solid white !important;
  315. color: white !important;
  316. }
  317. }
  318. `
  319. };
  320. static typeableInputs = new Set([
  321. "color",
  322. "email",
  323. "number",
  324. "password",
  325. "search",
  326. "tel",
  327. "text",
  328. "url",
  329. "datetime"
  330. ]);
  331. static clickCodes = {
  332. left: 0,
  333. middle: 1,
  334. right: 2
  335. };
  336. static customTags = Utils.loadCustomTags();
  337. static favoriteItemClassName = "favorite";
  338. static imageExtensions = Utils.loadDiscoveredImageExtensions();
  339. /**
  340. * @type {Cooldown}
  341. */
  342. static imageExtensionAssignmentCooldown;
  343. static recentlyDiscoveredImageExtensionCount = 0;
  344. static extensionDecodings = {
  345. 0: "jpg",
  346. 1: "png",
  347. 2: "jpeg",
  348. 3: "gif"
  349. };
  350. static extensionEncodings = {
  351. "jpg": 0,
  352. "png": 1,
  353. "jpeg": 2,
  354. "gif": 3
  355. };
  356. /**
  357. * @type {Function[]}
  358. */
  359. static staticInitializers = [];
  360.  
  361. /**
  362. * @type {Boolean}
  363. */
  364. static get disabled() {
  365. if (Utils.onPostPage()) {
  366. return true;
  367. }
  368.  
  369. if (Utils.onFavoritesPage()) {
  370. return false;
  371. }
  372. const enabledOnSearchPages = Utils.getPreference("enableOnSearchPages", false);
  373. return !enabledOnSearchPages;
  374. }
  375.  
  376. /**
  377. * @type {Boolean}
  378. */
  379. static get enabled() {
  380. return !Utils.disabled;
  381. }
  382.  
  383. static initialize() {
  384. if (Utils.disabled) {
  385. throw new Error("Favorites Search Gallery disabled");
  386. }
  387. Utils.invokeStaticInitializers();
  388. Utils.removeUnusedScripts();
  389. Utils.insertCommonStyleHTML();
  390. Utils.setupCustomWebComponents();
  391. Utils.toggleFancyImageHovering(true);
  392. Utils.setTheme();
  393. Utils.prepareSearchPage();
  394. Utils.prefetchAdjacentSearchPages();
  395. Utils.setupOriginalImageLinksOnSearchPage();
  396. Utils.initializeImageExtensionAssignmentCooldown();
  397. }
  398.  
  399. static postProcess() {
  400. dispatchEvent(new Event("postProcess"));
  401. }
  402.  
  403. /**
  404. * @param {String} key
  405. * @param {any} value
  406. */
  407. static setCookie(key, value) {
  408. let cookieString = `${key}=${value || ""}`;
  409. const expirationDate = new Date();
  410.  
  411. expirationDate.setFullYear(expirationDate.getFullYear() + 1);
  412. cookieString += `; expires=${expirationDate.toUTCString()}`;
  413. cookieString += "; path=/";
  414. document.cookie = cookieString;
  415. }
  416.  
  417. /**
  418. * @param {String} key
  419. * @param {any} defaultValue
  420. * @returns {String | null}
  421. */
  422. static getCookie(key, defaultValue) {
  423. const nameEquation = `${key}=`;
  424. const cookies = document.cookie.split(";").map(cookie => cookie.trimStart());
  425.  
  426. for (const cookie of cookies) {
  427. if (cookie.startsWith(nameEquation)) {
  428. return cookie.substring(nameEquation.length, cookie.length);
  429. }
  430. }
  431. return defaultValue === undefined ? null : defaultValue;
  432. }
  433.  
  434. /**
  435. * @param {String} key
  436. * @param {any} value
  437. */
  438. static setPreference(key, value) {
  439. const preferences = JSON.parse(localStorage.getItem(Utils.preferencesLocalStorageKey) || "{}");
  440.  
  441. preferences[key] = value;
  442. localStorage.setItem(Utils.preferencesLocalStorageKey, JSON.stringify(preferences));
  443. }
  444.  
  445. /**
  446. * @param {String} key
  447. * @param {any} defaultValue
  448. * @returns {String | null}
  449. */
  450. static getPreference(key, defaultValue) {
  451. const preferences = JSON.parse(localStorage.getItem(Utils.preferencesLocalStorageKey) || "{}");
  452. const preference = preferences[key];
  453.  
  454. if (preference === undefined) {
  455. return defaultValue === undefined ? null : defaultValue;
  456. }
  457. return preference;
  458. }
  459.  
  460. /**
  461. * @returns {String | null}
  462. */
  463. static getUserId() {
  464. return Utils.getCookie("user_id");
  465. }
  466.  
  467. /**
  468. * @returns {String | null}
  469. */
  470. static getFavoritesPageId() {
  471. const match = (/(?:&|\?)id=(\d+)/).exec(window.location.href);
  472. return match ? match[1] : null;
  473. }
  474.  
  475. /**
  476. * @returns {Boolean}
  477. */
  478. static userIsOnTheirOwnFavoritesPage() {
  479. if (!Utils.flags.userIsOnTheirOwnFavoritesPage.set) {
  480. Utils.flags.userIsOnTheirOwnFavoritesPage.value = Utils.getUserId() === Utils.getFavoritesPageId();
  481. Utils.flags.userIsOnTheirOwnFavoritesPage.set = true;
  482. }
  483. return Utils.flags.userIsOnTheirOwnFavoritesPage.value;
  484. }
  485.  
  486. /**
  487. * @param {Number} value
  488. * @param {Number} min
  489. * @param {Number} max
  490. * @returns {Number}
  491. */
  492. static clamp(value, min, max) {
  493. if (value <= min) {
  494. return min;
  495. } else if (value >= max) {
  496. return max;
  497. }
  498. return value;
  499. }
  500.  
  501. /**
  502. * @param {Number} milliseconds
  503. * @returns
  504. */
  505. static sleep(milliseconds) {
  506. return new Promise(resolve => setTimeout(resolve, milliseconds));
  507. }
  508.  
  509. /**
  510. * @param {Boolean} value
  511. */
  512. static forceHideCaptions(value) {
  513. for (const caption of document.getElementsByClassName("caption")) {
  514. if (value) {
  515. caption.classList.add("remove");
  516. caption.classList.add("inactive");
  517. } else {
  518. caption.classList.remove("remove");
  519. }
  520. }
  521. }
  522.  
  523. /**
  524. * @param {HTMLElement} thumb
  525. * @returns {String | null}
  526. */
  527. static getRemoveFavoriteButtonFromThumb(thumb) {
  528. return thumb.querySelector(".remove-favorite-button");
  529. }
  530.  
  531. /**
  532. * @param {HTMLElement} thumb
  533. * @returns {String | null}
  534. */
  535. static getAddFavoriteButtonFromThumb(thumb) {
  536. return thumb.querySelector(".add-favorite-button");
  537. }
  538.  
  539. /**
  540. * @param {HTMLImageElement} image
  541. */
  542. static removeTitleFromImage(image) {
  543. if (image.hasAttribute("title")) {
  544. image.setAttribute("tags", image.title);
  545. image.removeAttribute("title");
  546. }
  547. }
  548.  
  549. /**
  550. * @param {HTMLImageElement} image
  551. * @returns {HTMLElement}
  552. */
  553. static getThumbFromImage(image) {
  554. const className = Utils.onSearchPage() ? "thumb" : Utils.favoriteItemClassName;
  555. return image.closest(`.${className}`);
  556. }
  557.  
  558. /**
  559. * @param {HTMLElement} thumb
  560. * @returns {HTMLImageElement}
  561. */
  562. static getImageFromThumb(thumb) {
  563. return thumb.querySelector("img");
  564. }
  565.  
  566. /**
  567. * @returns {HTMLElement[]}
  568. */
  569. static getAllThumbs() {
  570. const className = Utils.onSearchPage() ? "thumb" : Utils.favoriteItemClassName;
  571. return Array.from(document.getElementsByClassName(className));
  572. }
  573.  
  574. /**
  575. * @param {HTMLElement} thumb
  576. * @returns {String}
  577. */
  578. static getOriginalImageURLFromThumb(thumb) {
  579. return Utils.getOriginalImageURL(Utils.getImageFromThumb(thumb).src);
  580. }
  581.  
  582. /**
  583. * @param {String} thumbURL
  584. * @returns {String}
  585. */
  586. static getOriginalImageURL(thumbURL) {
  587. return thumbURL
  588. .replace("thumbnails", "/images")
  589. .replace("thumbnail_", "")
  590. .replace("us.rule34", "rule34");
  591. }
  592.  
  593. /**
  594. * @param {String} imageURL
  595. * @returns {String}
  596. */
  597. static getExtensionFromImageURL(imageURL) {
  598. try {
  599. return (/\.(png|jpg|jpeg|gif|mp4)/g).exec(imageURL)[1];
  600.  
  601. } catch (error) {
  602. return "jpg";
  603. }
  604. }
  605.  
  606. /**
  607. * @param {String} originalImageURL
  608. * @returns {String}
  609. */
  610. static getThumbURL(originalImageURL) {
  611. return originalImageURL
  612. .replace(/\/images\/\/(\d+)\//, "thumbnails/$1/thumbnail_")
  613. .replace(/(?:gif|jpeg|png)/, "jpg")
  614. .replace("us.rule34", "rule34");
  615. }
  616.  
  617. /**
  618. * @param {HTMLElement | Post} thumb
  619. * @returns {Set.<String>}
  620. */
  621. static getTagsFromThumb(thumb) {
  622. if (Utils.onSearchPage()) {
  623. const image = Utils.getImageFromThumb(thumb);
  624. const tags = image.hasAttribute("tags") ? image.getAttribute("tags") : image.title;
  625. return Utils.convertToTagSet(tags);
  626. }
  627. const post = Post.allPosts.get(thumb.id);
  628. return post === undefined ? new Set() : new Set(post.tagSet);
  629. }
  630.  
  631. /**
  632. * @param {String} tag
  633. * @param {Set.<String>} tags
  634. * @returns
  635. */
  636. static includesTag(tag, tags) {
  637. return tags.has(tag);
  638. }
  639.  
  640. /**
  641. * @param {HTMLElement | Post} thumb
  642. * @returns {Boolean}
  643. */
  644. static isVideo(thumb) {
  645. const tags = Utils.getTagsFromThumb(thumb);
  646. return tags.has("video") || tags.has("mp4");
  647. }
  648.  
  649. /**
  650. * @param {HTMLElement | Post} thumb
  651. * @returns {Boolean}
  652. */
  653. static isGif(thumb) {
  654. if (Utils.isVideo(thumb)) {
  655. return false;
  656. }
  657. const tags = Utils.getTagsFromThumb(thumb);
  658. return tags.has("gif") || tags.has("animated") || tags.has("animated_png") || Utils.hasGifAttribute(thumb);
  659. }
  660.  
  661. /**
  662. * @param {HTMLElement | Post} thumb
  663. * @returns {Boolean}
  664. */
  665. static hasGifAttribute(thumb) {
  666. if (thumb instanceof Post) {
  667. return false;
  668. }
  669. return Utils.getImageFromThumb(thumb).hasAttribute("gif");
  670. }
  671.  
  672. /**
  673. * @param {HTMLElement | Post} thumb
  674. * @returns {Boolean}
  675. */
  676. static isImage(thumb) {
  677. return !Utils.isVideo(thumb) && !Utils.isGif(thumb);
  678. }
  679.  
  680. /**
  681. * @param {Number} maximum
  682. * @returns {Number}
  683. */
  684. static getRandomInteger(maximum) {
  685. return Math.floor(Math.random() * maximum);
  686. }
  687.  
  688. /**
  689. * @param {any[]} array
  690. * @returns {any[]}
  691. */
  692. static shuffleArray(array) {
  693. let maxIndex = array.length;
  694. let randomIndex;
  695.  
  696. while (maxIndex > 0) {
  697. randomIndex = Utils.getRandomInteger(maxIndex);
  698. maxIndex -= 1;
  699. [
  700. array[maxIndex],
  701. array[randomIndex]
  702. ] = [
  703. array[randomIndex],
  704. array[maxIndex]
  705. ];
  706. }
  707. return array;
  708. }
  709.  
  710. /**
  711. * @param {String} tags
  712. * @returns {String}
  713. */
  714. static negateTags(tags) {
  715. return tags.replace(/(\S+)/g, "-$1");
  716. }
  717.  
  718. /**
  719. * @param {HTMLInputElement | HTMLTextAreaElement} input
  720. * @returns {HTMLDivElement | null}
  721. */
  722. static getAwesompleteFromInput(input) {
  723. const awesomplete = input.parentElement;
  724.  
  725. if (awesomplete === null || awesomplete.className !== "awesomplete") {
  726. return null;
  727. }
  728. return awesomplete;
  729. }
  730.  
  731. /**
  732. * @param {HTMLInputElement | HTMLTextAreaElement} input
  733. * @returns {Boolean}
  734. */
  735. static awesompleteIsVisible(input) {
  736. const awesomplete = Utils.getAwesompleteFromInput(input);
  737.  
  738. if (awesomplete === null) {
  739. return false;
  740. }
  741. const awesompleteSuggestions = awesomplete.querySelector("ul");
  742. return awesompleteSuggestions !== null && !awesompleteSuggestions.hasAttribute("hidden");
  743. }
  744.  
  745. /**
  746. *
  747. * @param {HTMLInputElement | HTMLTextAreaElement} input
  748. * @returns
  749. */
  750. static awesompleteIsUnselected(input) {
  751. const awesomplete = Utils.getAwesompleteFromInput(input);
  752.  
  753. if (awesomplete === null) {
  754. return true;
  755. }
  756.  
  757. if (!Utils.awesompleteIsVisible(input)) {
  758. return true;
  759. }
  760. const searchSuggestions = Array.from(awesomplete.querySelectorAll("li"));
  761.  
  762. if (searchSuggestions.length === 0) {
  763. return true;
  764. }
  765. const somethingIsSelected = searchSuggestions.map(li => li.getAttribute("aria-selected"))
  766. .some(element => element === true || element === "true");
  767. return !somethingIsSelected;
  768. }
  769.  
  770. /**
  771. * @param {HTMLInputElement | HTMLTextAreaElement} input
  772. * @returns
  773. */
  774. static clearAwesompleteSelection(input) {
  775. const awesomplete = input.parentElement;
  776.  
  777. if (awesomplete === null) {
  778. return;
  779. }
  780. const searchSuggestions = Array.from(awesomplete.querySelectorAll("li"));
  781.  
  782. if (searchSuggestions.length === 0) {
  783. return;
  784. }
  785.  
  786. for (const li of searchSuggestions) {
  787. li.setAttribute("aria-selected", false);
  788. }
  789. }
  790.  
  791. /**
  792. * @param {String} optionId
  793. * @param {String} optionText
  794. * @param {String} optionTitle
  795. * @param {Boolean} optionIsChecked
  796. * @param {Function} onOptionChanged
  797. * @param {Boolean} optionIsVisible
  798. * @param {String} optionHint
  799. * @returns {HTMLElement | null}
  800. */
  801. static createFavoritesOption(optionId, optionText, optionTitle, optionIsChecked, onOptionChanged, optionIsVisible, optionHint = "") {
  802. const id = Utils.onMobileDevice() ? "favorite-options" : "dynamic-favorite-options";
  803. const placeToInsert = document.getElementById(id);
  804. const checkboxId = `${optionId}-checkbox`;
  805.  
  806. if (placeToInsert === null) {
  807. return null;
  808. }
  809.  
  810. if (optionIsVisible === undefined || optionIsVisible) {
  811. optionIsVisible = "block";
  812. } else {
  813. optionIsVisible = "none";
  814. }
  815. placeToInsert.insertAdjacentHTML("beforeend", `
  816. <div id="${optionId}" style="display: ${optionIsVisible}">
  817. <label class="checkbox" title="${optionTitle}">
  818. <input id="${checkboxId}" type="checkbox"><span> ${optionText}</span><span class="option-hint"> ${optionHint}</span></label>
  819. </div>
  820. `);
  821. const newOptionsCheckbox = document.getElementById(checkboxId);
  822.  
  823. newOptionsCheckbox.checked = optionIsChecked;
  824. newOptionsCheckbox.onchange = onOptionChanged;
  825. return document.getElementById(optionId);
  826. }
  827.  
  828. /**
  829. * @returns {Boolean}
  830. */
  831. static onSearchPage() {
  832. if (!Utils.flags.onSearchPage.set) {
  833. Utils.flags.onSearchPage.value = location.href.includes("page=post&s=list");
  834. Utils.flags.onSearchPage.set = true;
  835. }
  836. return Utils.flags.onSearchPage.value;
  837. }
  838.  
  839. /**
  840. * @returns {Boolean}
  841. */
  842. static onFavoritesPage() {
  843. if (!Utils.flags.onFavoritesPage.set) {
  844. Utils.flags.onFavoritesPage.value = location.href.includes("page=favorites");
  845. Utils.flags.onFavoritesPage.set = true;
  846. }
  847. return Utils.flags.onFavoritesPage.value;
  848. }
  849.  
  850. /**
  851. * @returns {Boolean}
  852. */
  853. static onPostPage() {
  854. if (!Utils.flags.onPostPage.set) {
  855. Utils.flags.onPostPage.value = location.href.includes("page=post&s=view");
  856. Utils.flags.onPostPage.set = true;
  857. }
  858. return Utils.flags.onPostPage.value;
  859. }
  860.  
  861. /**
  862. * @returns {String[]}
  863. */
  864. static getIdsToDeleteOnReload() {
  865. return JSON.parse(localStorage.getItem(Utils.idsToRemoveOnReloadLocalStorageKey)) || [];
  866. }
  867.  
  868. /**
  869. * @param {String} postId
  870. */
  871. static setIdToBeRemovedOnReload(postId) {
  872. const idsToRemoveOnReload = Utils.getIdsToDeleteOnReload();
  873.  
  874. idsToRemoveOnReload.push(postId);
  875. localStorage.setItem(Utils.idsToRemoveOnReloadLocalStorageKey, JSON.stringify(idsToRemoveOnReload));
  876. }
  877.  
  878. static clearIdsToDeleteOnReload() {
  879. localStorage.removeItem(Utils.idsToRemoveOnReloadLocalStorageKey);
  880. }
  881.  
  882. /**
  883. * @param {String} html
  884. * @param {String} id
  885. */
  886. static insertStyleHTML(html, id) {
  887. const style = document.createElement("style");
  888.  
  889. style.textContent = html.replace("<style>", "").replace("</style>", "");
  890.  
  891. if (id !== undefined) {
  892. id += "-fsg-style";
  893. const oldStyle = document.getElementById(id);
  894.  
  895. if (oldStyle !== null) {
  896. oldStyle.remove();
  897. }
  898. style.id = id;
  899. }
  900. document.head.appendChild(style);
  901. }
  902.  
  903. static getTagDistribution() {
  904. const images = Utils.getAllThumbs().map(thumb => Utils.getImageFromThumb(thumb));
  905. const tagOccurrences = {};
  906.  
  907. images.forEach((image) => {
  908. const tags = image.getAttribute("tags").replace(/ \d+$/, "").split(" ");
  909.  
  910. tags.forEach((tag) => {
  911. const occurrences = tagOccurrences[tag];
  912.  
  913. tagOccurrences[tag] = occurrences === undefined ? 1 : occurrences + 1;
  914. });
  915. });
  916. const sortedTagOccurrences = Utils.sortObjectByValues(tagOccurrences);
  917. let result = "";
  918. let i = 0;
  919. const max = 50;
  920.  
  921. sortedTagOccurrences.forEach(item => {
  922. if (i < max) {
  923. result += `${item.key}: ${item.value}\n`;
  924. }
  925. i += 1;
  926. });
  927. }
  928.  
  929. /**
  930. * @param {{key: any, value: any}} obj
  931. * @returns {{key: any, value: any}}
  932. */
  933. static sortObjectByValues(obj) {
  934. const sortable = Object.entries(obj);
  935.  
  936. sortable.sort((a, b) => b[1] - a[1]);
  937. return sortable.map(item => ({
  938. key: item[0],
  939. value: item[1]
  940. }));
  941. }
  942.  
  943. static insertCommonStyleHTML() {
  944. Utils.insertStyleHTML(Utils.utilitiesHTML, "common");
  945. Utils.toggleThumbHoverOutlines(false);
  946. setTimeout(() => {
  947. if (Utils.onSearchPage()) {
  948. Utils.removeInlineImgStyles();
  949. }
  950. Utils.configureVideoOutlines();
  951. }, 100);
  952. }
  953.  
  954. /**
  955. * @param {Boolean} value
  956. */
  957. static toggleFancyImageHovering(value) {
  958. if (Utils.onMobileDevice() || Utils.onSearchPage()) {
  959. value = false;
  960. }
  961. let html = "";
  962.  
  963. if (value) {
  964. html = `
  965. #favorites-search-gallery-content {
  966. padding: 40px 40px 30px !important;
  967. grid-gap: 1cqw !important;
  968. }
  969.  
  970. .favorite,
  971. .thumb {
  972. >a,
  973. >span,
  974. >div {
  975. box-shadow: 0 1px 2px rgba(0,0,0,0.15);
  976. transition: transform 0.2s ease-in-out;
  977. position: relative;
  978.  
  979. &::after {
  980. content: '';
  981. position: absolute;
  982. z-index: -1;
  983. width: 100%;
  984. height: 100%;
  985. opacity: 0;
  986. top: 0;
  987. left: 0;
  988. border-radius: 5px;
  989. box-shadow: 5px 10px 15px rgba(0,0,0,0.45);
  990. transition: opacity 0.3s ease-in-out;
  991. }
  992.  
  993. &:hover {
  994. outline: none !important;
  995. transform: scale(1.05, 1.05);
  996. z-index: 10;
  997.  
  998. img {
  999. outline: none !important;
  1000. }
  1001.  
  1002. &::after {
  1003. opacity: 1;
  1004. }
  1005. }
  1006. }
  1007. }
  1008. `;
  1009. }
  1010. Utils.insertStyleHTML(html, "fancy-image-hovering");
  1011. }
  1012.  
  1013. static configureVideoOutlines() {
  1014. const size = Utils.onMobileDevice() ? 2 : 3;
  1015. const videoSelector = Utils.onFavoritesPage() ? "&:has(img.video)" : ">img.video";
  1016. const gifSelector = Utils.onFavoritesPage() ? "&:has(img.gif)" : ">img.gif";
  1017.  
  1018. Utils.insertStyleHTML(`
  1019. .favorite, .thumb {
  1020.  
  1021. >a,
  1022. >div {
  1023. ${videoSelector} {
  1024. outline: ${size}px solid blue;
  1025. }
  1026.  
  1027. ${gifSelector} {
  1028. outline: 2px solid hotpink;
  1029. }
  1030. }
  1031. }
  1032. `, "video-gif-borders");
  1033. }
  1034.  
  1035. static removeInlineImgStyles() {
  1036. for (const image of document.getElementsByTagName("img")) {
  1037. image.removeAttribute("style");
  1038. }
  1039. }
  1040.  
  1041. static setTheme() {
  1042. window.addEventListener("postProcess", () => {
  1043. Utils.toggleDarkTheme(Utils.usingDarkTheme());
  1044. });
  1045. }
  1046.  
  1047. /**
  1048. * @param {Boolean} value
  1049. */
  1050. static toggleDarkTheme(value) {
  1051. Utils.insertStyleHTML(value ? Utils.styles.darkTheme : "", "dark-theme");
  1052. Utils.toggleDarkStyleSheet(value);
  1053. const currentTheme = value ? "light-green-gradient" : "dark-green-gradient";
  1054. const targetTheme = value ? "dark-green-gradient" : "light-green-gradient";
  1055.  
  1056. for (const element of document.querySelectorAll(`.${currentTheme}`)) {
  1057. element.classList.remove(currentTheme);
  1058. element.classList.add(targetTheme);
  1059. }
  1060. this.setCookie("theme", value ? "dark" : "light");
  1061. }
  1062.  
  1063. static toggleDarkStyleSheet(value) {
  1064. const platform = Utils.onMobileDevice() ? "mobile" : "desktop";
  1065. const darkSuffix = value ? "-dark" : "";
  1066.  
  1067. Utils.setStyleSheet(`https://rule34.xxx//css/${platform}${darkSuffix}.css?44`);
  1068. }
  1069.  
  1070. /**
  1071. * @param {String} url
  1072. */
  1073. static setStyleSheet(url) {
  1074. const styleSheet = this.getMainStyleSheet();
  1075.  
  1076. if (styleSheet !== null && styleSheet !== undefined) {
  1077. styleSheet.href = url;
  1078. }
  1079. }
  1080.  
  1081. /**
  1082. * @returns {HTMLLinkElement}
  1083. */
  1084. static getMainStyleSheet() {
  1085. return Array.from(document.querySelectorAll("link")).filter(link => link.rel === "stylesheet")[0];
  1086. }
  1087.  
  1088. /**
  1089. * @param {String} content
  1090. * @returns {Blob | MediaSource}
  1091. */
  1092. static getWorkerURL(content) {
  1093. return URL.createObjectURL(new Blob([content], {
  1094. type: "text/javascript"
  1095. }));
  1096. }
  1097.  
  1098. static prefetchAdjacentSearchPages() {
  1099. if (!Utils.onSearchPage()) {
  1100. return;
  1101. }
  1102. const id = "search-page-prefetch";
  1103. const alreadyPrefetched = document.getElementById(id) !== null;
  1104.  
  1105. if (alreadyPrefetched) {
  1106. return;
  1107. }
  1108. const container = document.createElement("div");
  1109.  
  1110. try {
  1111. const currentPage = document.getElementById("paginator").children[0].querySelector("b");
  1112.  
  1113. for (const sibling of [currentPage.previousElementSibling, currentPage.nextElementSibling]) {
  1114. if (sibling !== null && sibling.tagName.toLowerCase() === "a") {
  1115. container.appendChild(Utils.createPrefetchLink(sibling.href));
  1116. }
  1117. }
  1118. container.id = "search-page-prefetch";
  1119. document.head.appendChild(container);
  1120. } catch (error) {
  1121. console.error(error);
  1122. }
  1123. }
  1124.  
  1125. /**
  1126. * @param {String} url
  1127. * @returns {HTMLLinkElement}
  1128. */
  1129. static createPrefetchLink(url) {
  1130. const link = document.createElement("link");
  1131.  
  1132. link.rel = "prefetch";
  1133. link.href = url;
  1134. return link;
  1135.  
  1136. }
  1137.  
  1138. /**
  1139. * @returns {String}
  1140. */
  1141. static getTagBlacklist() {
  1142. let tags = Utils.getCookie("tag_blacklist", "");
  1143.  
  1144. for (let i = 0; i < 3; i += 1) {
  1145. tags = decodeURIComponent(tags).replace(/(?:^| )-/, "");
  1146. }
  1147. return tags;
  1148. }
  1149.  
  1150. /**
  1151. * @returns {Boolean}
  1152. */
  1153. static galleryEnabled() {
  1154. if (!Utils.flags.galleryEnabled.set) {
  1155. Utils.flags.galleryEnabled.value = document.getElementById("gallery-container") !== null;
  1156. Utils.flags.galleryEnabled.set = true;
  1157. }
  1158. return Utils.flags.galleryEnabled.value;
  1159. }
  1160.  
  1161. /**
  1162. * @param {String} word
  1163. * @returns {String}
  1164. */
  1165. static capitalize(word) {
  1166. return word.charAt(0).toUpperCase() + word.slice(1);
  1167. }
  1168.  
  1169. /**
  1170. * @param {Number} number
  1171. * @returns {Number}
  1172. */
  1173. static roundToTwoDecimalPlaces(number) {
  1174. return Math.round((number + Number.EPSILON) * 100) / 100;
  1175. }
  1176.  
  1177. /**
  1178. * @param {Number} n
  1179. * @param {Number} number
  1180. */
  1181. static roundToNDecimalPlaces(n, number) {
  1182. const x = 10 ** n;
  1183. return Math.round((number + Number.EPSILON) * x) / x;
  1184. }
  1185.  
  1186. /**
  1187. * @returns {Boolean}
  1188. */
  1189. static usingDarkTheme() {
  1190. return Utils.getCookie("theme", "") === "dark";
  1191. }
  1192.  
  1193. /**
  1194. * @param {Event} event
  1195. * @returns {Boolean}
  1196. */
  1197. static enteredOverCaptionTag(event) {
  1198. return event.relatedTarget !== null && event.relatedTarget.classList.contains("caption-tag");
  1199. }
  1200.  
  1201. /**
  1202. * @param {String[]} postId
  1203. * @param {Boolean} endingAnimation
  1204. * @param {Boolean} smoothTransition
  1205. */
  1206. static scrollToThumb(postId, endingAnimation, smoothTransition) {
  1207. const element = document.getElementById(postId);
  1208. const elementIsNotAThumb = element === null || (!element.classList.contains("thumb") && !element.classList.contains(Utils.favoriteItemClassName));
  1209.  
  1210. if (elementIsNotAThumb) {
  1211. return;
  1212. }
  1213. const rect = element.getBoundingClientRect();
  1214. const menu = document.getElementById("favorites-search-gallery-menu");
  1215. const favoritesSearchHeight = menu === null ? 0 : menu.getBoundingClientRect().height;
  1216. let top = rect.top + window.scrollY + (rect.height / 2) - (window.innerHeight / 2) - (favoritesSearchHeight / 2);
  1217.  
  1218. if (Utils.onMobileDevice()) {
  1219. top = Math.max(1, top);
  1220. }
  1221. window.scroll({
  1222. top,
  1223. behavior: smoothTransition ? "smooth" : "instant"
  1224. });
  1225.  
  1226. if (!endingAnimation) {
  1227. return;
  1228. }
  1229. const image = Utils.getImageFromThumb(element);
  1230.  
  1231. image.classList.add("found");
  1232. setTimeout(() => {
  1233. image.classList.remove("found");
  1234. }, 2000);
  1235. }
  1236.  
  1237. /**
  1238. * @param {HTMLElement} thumb
  1239. */
  1240. static assignContentType(thumb) {
  1241. const image = Utils.getImageFromThumb(thumb);
  1242. const tagAttribute = image.hasAttribute("tags") ? "tags" : "title";
  1243. const tags = image.getAttribute(tagAttribute);
  1244.  
  1245. Utils.setContentType(image, Utils.getContentType(tags));
  1246. }
  1247.  
  1248. /**
  1249. * @param {HTMLImageElement} image
  1250. * @param {String} type
  1251. */
  1252. static setContentType(image, type) {
  1253. image.classList.remove("image");
  1254. image.classList.remove("gif");
  1255. image.classList.remove("video");
  1256. image.classList.add(type);
  1257. }
  1258.  
  1259. /**
  1260. * @param {String} tags
  1261. * @returns {String}
  1262. */
  1263. static getContentType(tags) {
  1264. tags += " ";
  1265. const hasVideoTag = (/(?:^|\s)video(?:$|\s)/).test(tags);
  1266. const hasAnimatedTag = (/(?:^|\s)animated(?:$|\s)/).test(tags);
  1267. const isAnimated = hasAnimatedTag || hasVideoTag;
  1268. const isAGif = hasAnimatedTag && !hasVideoTag;
  1269. return isAGif ? "gif" : isAnimated ? "video" : "image";
  1270. }
  1271.  
  1272. static correctMisspelledTags(tags) {
  1273. if ((/vide(?:\s|$)/).test(tags)) {
  1274. tags += " video";
  1275. }
  1276. return tags;
  1277. }
  1278.  
  1279. /**
  1280. * @param {String} searchQuery
  1281. * @returns {{orGroups: String[][], remainingSearchTags: String[]}}
  1282. */
  1283. static extractTagGroups(searchQuery) {
  1284. searchQuery = searchQuery.toLowerCase();
  1285. const orRegex = /(?:^|\s+)\(\s+((?:\S+)(?:(?:\s+~\s+)\S+)*)\s+\)/g;
  1286. const orGroups = Array.from(Utils.removeExtraWhiteSpace(searchQuery)
  1287. .matchAll(orRegex))
  1288. .map((orGroup) => orGroup[1].split(" ~ "));
  1289. const remainingSearchTags = Utils.removeExtraWhiteSpace(searchQuery
  1290. .replace(orRegex, ""))
  1291. .split(" ")
  1292. .filter((searchTag) => searchTag !== "");
  1293. return {
  1294. orGroups,
  1295. remainingSearchTags
  1296. };
  1297. }
  1298.  
  1299. /**
  1300. * @param {String} string
  1301. * @returns {String}
  1302. */
  1303. static removeExtraWhiteSpace(string) {
  1304. return string.trim().replace(/\s\s+/g, " ");
  1305. }
  1306.  
  1307. /**
  1308. * @param {String} string
  1309. * @param {String} replacement
  1310. * @returns {String}
  1311. */
  1312. static replaceLineBreaks(string, replacement = "") {
  1313. return string.replace(/(\r\n|\n|\r)/gm, replacement);
  1314. }
  1315.  
  1316. /**
  1317. *
  1318. * @param {HTMLImageElement} image
  1319. * @returns {Boolean}
  1320. */
  1321. static imageIsLoaded(image) {
  1322. return image.complete || image.naturalWidth !== 0;
  1323. }
  1324.  
  1325. /**
  1326. * @returns {Boolean}
  1327. */
  1328. static usingFirefox() {
  1329. if (!Utils.flags.usingFirefox.set) {
  1330. Utils.flags.usingFirefox.value = navigator.userAgent.toLowerCase().includes("firefox");
  1331. Utils.flags.usingFirefox.set = true;
  1332. }
  1333. return Utils.flags.usingFirefox.value;
  1334. }
  1335.  
  1336. /**
  1337. * @returns {Boolean}
  1338. */
  1339. static onMobileDevice() {
  1340. if (!Utils.flags.onMobileDevice.set) {
  1341. Utils.flags.onMobileDevice.value = (/iPhone|iPad|iPod|Android/i).test(navigator.userAgent);
  1342. Utils.flags.onMobileDevice.set = true;
  1343. }
  1344. return Utils.flags.onMobileDevice.value;
  1345. }
  1346.  
  1347. /**
  1348. * @returns {Number}
  1349. */
  1350. static getPerformanceProfile() {
  1351. return parseInt(Utils.getPreference("performanceProfile", 0));
  1352. }
  1353.  
  1354. /**
  1355. * @param {String} tagName
  1356. * @returns {Promise.<Boolean>}
  1357. */
  1358. static isOfficialTag(tagName) {
  1359. const tagPageURL = `https://rule34.xxx/index.php?page=tags&s=list&tags=${tagName}`;
  1360. return fetch(tagPageURL)
  1361. .then((response) => {
  1362. if (response.ok) {
  1363. return response.text();
  1364. }
  1365. throw new Error(response.statusText);
  1366. })
  1367. .then((html) => {
  1368. const dom = new DOMParser().parseFromString(html, "text/html");
  1369. const columnOfFirstRow = dom.getElementsByClassName("highlightable")[0].getElementsByTagName("td");
  1370. return columnOfFirstRow.length === 3;
  1371. })
  1372. .catch((error) => {
  1373. console.error(error);
  1374. return true;
  1375. });
  1376. }
  1377.  
  1378. /**
  1379. * @param {String} searchQuery
  1380. */
  1381. static openSearchPage(searchQuery) {
  1382. window.open(`https://rule34.xxx/index.php?page=post&s=list&tags=${encodeURIComponent(searchQuery)}`);
  1383. }
  1384.  
  1385. /**
  1386. * @param {Map} map
  1387. * @returns {Object}
  1388. */
  1389. static mapToObject(map) {
  1390. return Array.from(map).reduce((object, [key, value]) => {
  1391. object[key] = value;
  1392. return object;
  1393. }, {});
  1394. }
  1395.  
  1396. /**
  1397. * @param {Object} object
  1398. * @returns {Map}
  1399. */
  1400. static objectToMap(object) {
  1401. return new Map(Object.entries(object));
  1402. }
  1403.  
  1404. /**
  1405. * @param {String} string
  1406. * @returns {Boolean}
  1407. */
  1408. static isNumber(string) {
  1409. return (/^\d+$/).test(string);
  1410. }
  1411.  
  1412. /**
  1413. * @param {String} id
  1414. * @returns {Promise.<Number>}
  1415. */
  1416. static addFavorite(id) {
  1417. fetch(`https://rule34.xxx/index.php?page=post&s=vote&id=${id}&type=up`);
  1418. return fetch(`https://rule34.xxx/public/addfav.php?id=${id}`)
  1419. .then((response) => {
  1420. return response.text();
  1421. })
  1422. .then((html) => {
  1423. return parseInt(html);
  1424. })
  1425. .catch(() => {
  1426. return Utils.addedFavoriteStatuses.error;
  1427. });
  1428. }
  1429.  
  1430. /**
  1431. * @param {String} id
  1432. */
  1433. static removeFavorite(id) {
  1434. Utils.setIdToBeRemovedOnReload(id);
  1435. fetch(`https://rule34.xxx/index.php?page=favorites&s=delete&id=${id}`);
  1436. }
  1437.  
  1438. /**
  1439. * @param {HTMLInputElement | HTMLTextAreaElement} input
  1440. * @param {String} suggestion
  1441. */
  1442. static insertSuggestion(input, suggestion) {
  1443. const cursorAtEnd = input.selectionStart === input.value.length;
  1444. const firstHalf = input.value.slice(0, input.selectionStart);
  1445. const secondHalf = input.value.slice(input.selectionStart);
  1446. const firstHalfWithPrefixRemoved = firstHalf.replace(/(\s?)(-?)\S+$/, "$1$2");
  1447. const combinedHalves = Utils.removeExtraWhiteSpace(`${firstHalfWithPrefixRemoved}${suggestion} ${secondHalf}`);
  1448. const result = cursorAtEnd ? `${combinedHalves} ` : combinedHalves;
  1449. const selectionStart = firstHalfWithPrefixRemoved.length + suggestion.length + 1;
  1450.  
  1451. input.value = result;
  1452. input.selectionStart = selectionStart;
  1453. input.selectionEnd = selectionStart;
  1454. }
  1455.  
  1456. /**
  1457. * @param {HTMLInputElement | HTMLTextAreaElement} input
  1458. */
  1459. static hideAwesomplete(input) {
  1460. const awesomplete = Utils.getAwesompleteFromInput(input);
  1461.  
  1462. if (awesomplete !== null) {
  1463. awesomplete.querySelector("ul").setAttribute("hidden", "");
  1464. }
  1465. }
  1466.  
  1467. /**
  1468. * @param {String} svg
  1469. * @param {Number} duration
  1470. */
  1471. static showFullscreenIcon(svg, duration = 500) {
  1472. const svgDocument = new DOMParser().parseFromString(svg, "image/svg+xml");
  1473. const svgElement = svgDocument.documentElement;
  1474. const svgOverlay = document.createElement("div");
  1475.  
  1476. svgOverlay.classList.add("fullscreen-icon");
  1477. svgOverlay.innerHTML = new XMLSerializer().serializeToString(svgElement);
  1478. document.body.appendChild(svgOverlay);
  1479. setTimeout(() => {
  1480. svgOverlay.remove();
  1481. }, duration);
  1482. }
  1483.  
  1484. /**
  1485. * @param {String} svg
  1486. * @returns {String}
  1487. */
  1488. static createObjectURLFromSvg(svg) {
  1489. const blob = new Blob([svg], {
  1490. type: "image/svg+xml"
  1491. });
  1492. return URL.createObjectURL(blob);
  1493. }
  1494.  
  1495. /**
  1496. * @param {HTMLElement} element
  1497. * @returns {Boolean}
  1498. */
  1499. static isTypeableInput(element) {
  1500. const tagName = element.tagName.toLowerCase();
  1501.  
  1502. if (tagName === "textarea") {
  1503. return true;
  1504. }
  1505.  
  1506. if (tagName === "input") {
  1507. return Utils.typeableInputs.has(element.getAttribute("type"));
  1508. }
  1509. return false;
  1510. }
  1511.  
  1512. /**
  1513. * @param {KeyboardEvent} event
  1514. * @returns {Boolean}
  1515. */
  1516. static isHotkeyEvent(event) {
  1517. return !event.repeat && !Utils.isTypeableInput(event.target);
  1518. }
  1519.  
  1520. /**
  1521. * @param {Set} a
  1522. * @param {Set} b
  1523. * @returns {Set}
  1524. */
  1525. static union(a, b) {
  1526. const c = new Set(a);
  1527.  
  1528. for (const element of b.values()) {
  1529. c.add(element);
  1530. }
  1531. return c;
  1532. }
  1533.  
  1534. /**
  1535. * @param {Set} a
  1536. * @param {Set} b
  1537. * @returns {Set}
  1538. */
  1539. static difference(a, b) {
  1540. const c = new Set(a);
  1541.  
  1542. for (const element of b.values()) {
  1543. c.delete(element);
  1544. }
  1545. return c;
  1546. }
  1547.  
  1548. static removeUnusedScripts() {
  1549. if (!Utils.onFavoritesPage()) {
  1550. return;
  1551. }
  1552. const scripts = Array.from(document.querySelectorAll("script"));
  1553.  
  1554. for (const script of scripts) {
  1555. if ((/(?:fluidplayer|awesomplete)/).test(script.src || "")) {
  1556. script.remove();
  1557. }
  1558. }
  1559. }
  1560.  
  1561. /**
  1562. * @param {String} tagString
  1563. * @returns {Set.<String>}
  1564. */
  1565. static convertToTagSet(tagString) {
  1566. tagString = Utils.removeExtraWhiteSpace(tagString);
  1567.  
  1568. if (tagString === "") {
  1569. return new Set();
  1570. }
  1571. return new Set(tagString.split(" ").sort());
  1572. }
  1573.  
  1574. /**
  1575. * @param {Set.<String>} tagSet
  1576. * @returns {String}
  1577. */
  1578. static convertToTagString(tagSet) {
  1579. if (tagSet.size === 0) {
  1580. return "";
  1581. }
  1582. return Array.from(tagSet).join(" ");
  1583. }
  1584.  
  1585. /**
  1586. * @returns {String | null}
  1587. */
  1588. static getPostPageId() {
  1589. const match = (/id=(\d+)/).exec(window.location.href);
  1590. return match === null ? null : match[1];
  1591. }
  1592.  
  1593. /**
  1594. * @param {String} searchTag
  1595. * @param {String[]} tags
  1596. * @returns {Boolean}
  1597. */
  1598. static tagsMatchWildcardSearchTag(searchTag, tags) {
  1599. try {
  1600. const wildcardRegex = new RegExp(`^${searchTag.replaceAll(/\*/g, ".*")}$`);
  1601. return tags.some(tag => wildcardRegex.test(tag));
  1602. } catch {
  1603. return false;
  1604. }
  1605. }
  1606.  
  1607. static setupCustomWebComponents() {
  1608. Utils.setupCustomNumberWebComponents();
  1609. }
  1610.  
  1611. static async setupCustomNumberWebComponents() {
  1612. await Utils.sleep(400);
  1613. const numberComponents = Array.from(document.querySelectorAll(".number"));
  1614.  
  1615. for (const element of numberComponents) {
  1616. const numberComponent = new NumberComponent(element);
  1617. }
  1618. }
  1619.  
  1620. /**
  1621. * @param {Number} milliseconds
  1622. * @returns {Number}
  1623. */
  1624. static millisecondsToSeconds(milliseconds) {
  1625. return Utils.roundToTwoDecimalPlaces(milliseconds / 1000);
  1626. }
  1627.  
  1628. /**
  1629. * @returns {Set.<String>}
  1630. */
  1631. static loadCustomTags() {
  1632. return new Set(JSON.parse(localStorage.getItem("customTags")) || []);
  1633. }
  1634.  
  1635. /**
  1636. * @param {String} tags
  1637. */
  1638. static async setCustomTags(tags) {
  1639. for (const tag of Utils.removeExtraWhiteSpace(tags).split(" ")) {
  1640. if (tag === "" || Utils.customTags.has(tag)) {
  1641. continue;
  1642. }
  1643. const isAnOfficialTag = await Utils.isOfficialTag(tag);
  1644.  
  1645. if (!isAnOfficialTag) {
  1646. Utils.customTags.add(tag);
  1647. }
  1648. }
  1649. localStorage.setItem("customTags", JSON.stringify(Array.from(Utils.customTags)));
  1650. }
  1651.  
  1652. /**
  1653. * @returns {String[]}
  1654. */
  1655. static getSavedSearchValues() {
  1656. return Array.from(document.getElementsByClassName("save-search-label"))
  1657. .map(element => element.innerText);
  1658. }
  1659.  
  1660. /**
  1661. * @param {{label: String, value: String, type: String}[]} officialTags
  1662. * @param {String} searchQuery
  1663. * @returns {{label: String, value: String, type: String}[]}
  1664. */
  1665. static addCustomTagsToAutocompleteList(officialTags, searchQuery) {
  1666. const customTags = Array.from(Utils.customTags);
  1667. const officialTagValues = new Set(officialTags.map(officialTag => officialTag.value));
  1668. const mergedTags = officialTags;
  1669.  
  1670. for (const customTag of customTags) {
  1671. if (!officialTagValues.has(customTag) && customTag.startsWith(searchQuery)) {
  1672. mergedTags.unshift({
  1673. label: `${customTag} (custom)`,
  1674. value: customTag,
  1675. type: "custom"
  1676. });
  1677. }
  1678. }
  1679. return mergedTags;
  1680. }
  1681.  
  1682. /**
  1683. * @param {String} searchTag
  1684. * @param {String} savedSearch
  1685. * @returns {Boolean}
  1686. */
  1687. static savedSearchMatchesSearchTag(searchTag, savedSearch) {
  1688. const sanitizedSavedSearch = Utils.removeExtraWhiteSpace(savedSearch.replace(/[~())]/g, ""));
  1689. const savedSearchTagList = sanitizedSavedSearch.split(" ");
  1690.  
  1691. for (const savedSearchTag of savedSearchTagList) {
  1692. if (savedSearchTag.startsWith(searchTag)) {
  1693. return true;
  1694. }
  1695. }
  1696. return false;
  1697. }
  1698.  
  1699. /**
  1700. * @param {String} tag
  1701. * @returns {String}
  1702. */
  1703. static removeStartingHyphen(tag) {
  1704. return tag.replace(/^-/, "");
  1705. }
  1706.  
  1707. /**
  1708. * @param {String} searchTag
  1709. * @returns {{label: String, value: String, type: String}[]}
  1710. */
  1711. static getSavedSearchesForAutocompleteList(searchTag) {
  1712. const minimumSearchTagLength = 3;
  1713.  
  1714. if (searchTag.length < minimumSearchTagLength) {
  1715. return [];
  1716. }
  1717. const maxMatchedSavedSearches = 5;
  1718. const matchedSavedSearches = [];
  1719. let i = 0;
  1720.  
  1721. for (const savedSearch of Utils.getSavedSearchValues()) {
  1722. if (Utils.savedSearchMatchesSearchTag(searchTag, savedSearch)) {
  1723. matchedSavedSearches.push({
  1724. label: `${savedSearch}`,
  1725. value: `${searchTag}_saved_search ${savedSearch}`,
  1726. type: "saved"
  1727. });
  1728. i += 1;
  1729. }
  1730.  
  1731. if (matchedSavedSearches.length > maxMatchedSavedSearches) {
  1732. break;
  1733. }
  1734. }
  1735. return matchedSavedSearches;
  1736. }
  1737.  
  1738. static removeSavedSearchPrefix(suggestion) {
  1739. return suggestion.replace(/^\S+_saved_search /, "");
  1740. }
  1741.  
  1742. /**
  1743. * @param {Boolean} value
  1744. */
  1745. static toggleThumbHoverOutlines(value) {
  1746. // insertStyleHTML(value ? STYLES.thumbHoverOutlineDisabled : STYLES.thumbHoverOutline, "thumb-hover-outlines");
  1747. }
  1748.  
  1749. /**
  1750. * @param {Number} timestamp
  1751. * @returns {String}
  1752. */
  1753. static convertTimestampToDate(timestamp) {
  1754. const date = new Date(timestamp);
  1755. const day = date.getDate();
  1756. const month = date.getMonth() + 1;
  1757. const year = date.getFullYear();
  1758. return `${year}-${month}-${day}`;
  1759. }
  1760.  
  1761. /**
  1762. * @returns {String}
  1763. */
  1764. static getSortingMethod() {
  1765. const sortingMethodSelect = document.getElementById("sorting-method");
  1766. return sortingMethodSelect === null ? "default" : sortingMethodSelect.value;
  1767. }
  1768.  
  1769. /**
  1770. * @returns {HTMLDivElement}
  1771. */
  1772. static createFavoritesSearchGalleryContainer() {
  1773. const container = document.createElement("div");
  1774.  
  1775. container.id = "favorites-search-gallery";
  1776. document.body.appendChild(container);
  1777. return container;
  1778. }
  1779.  
  1780. /**
  1781. * @param {HTMLElement} element
  1782. * @param {InsertPosition} position
  1783. * @param {String} html
  1784. */
  1785. static insertHTMLAndExtractStyle(element, position, html) {
  1786. const dom = new DOMParser().parseFromString(html, "text/html");
  1787. const styles = Array.from(dom.querySelectorAll("style"));
  1788.  
  1789. for (const style of styles) {
  1790. Utils.insertStyleHTML(style.innerHTML);
  1791. style.remove();
  1792. }
  1793. element.insertAdjacentHTML(position, dom.body.innerHTML);
  1794. }
  1795.  
  1796. /**
  1797. * @param {InsertPosition} position
  1798. * @param {String} html
  1799. */
  1800. static insertFavoritesSearchGalleryHTML(position, html) {
  1801. Utils.insertHTMLAndExtractStyle(Utils.favoritesSearchGalleryContainer, position, html);
  1802. }
  1803.  
  1804. /**
  1805. * @param {String} str
  1806. * @returns {String}
  1807. */
  1808. static removeNonNumericCharacters(str) {
  1809. return str.replaceAll(/\D/g, "");
  1810. }
  1811.  
  1812. /**
  1813. * @param {HTMLElement} thumb
  1814. * @returns {String}
  1815. */
  1816. static getIdFromThumb(thumb) {
  1817. const id = thumb.getAttribute("id");
  1818.  
  1819. if (id !== null) {
  1820. return Utils.removeNonNumericCharacters(id);
  1821. }
  1822. const anchor = thumb.querySelector("a");
  1823.  
  1824. if (anchor !== null && anchor.hasAttribute("id")) {
  1825. return Utils.removeNonNumericCharacters(anchor.id);
  1826. }
  1827.  
  1828. if (anchor !== null && anchor.hasAttribute("href")) {
  1829. const match = (/id=(\d+)$/).exec(anchor.href);
  1830.  
  1831. if (match !== null) {
  1832. return match[1];
  1833. }
  1834. }
  1835. const image = thumb.querySelector("img");
  1836. const match = (/\?(\d+)$/).exec(image.src);
  1837. return match[1];
  1838. }
  1839.  
  1840. static deletePersistentData() {
  1841. const desktopSuffix = Utils.onMobileDevice() ? "" : " Tag modifications and saved searches will be preserved.";
  1842.  
  1843. const message = `Are you sure you want to reset? This will delete all cached favorites, and preferences.${desktopSuffix}`;
  1844.  
  1845. if (confirm(message)) {
  1846. const persistentLocalStorageKeys = new Set(["customTags", "savedSearches"]);
  1847.  
  1848. Object.keys(localStorage).forEach((key) => {
  1849. if (!persistentLocalStorageKeys.has(key)) {
  1850. localStorage.removeItem(key);
  1851. }
  1852. });
  1853. indexedDB.deleteDatabase(FavoritesDatabaseWrapper.databaseName);
  1854. }
  1855. }
  1856.  
  1857. /**
  1858. * @param {String} id
  1859. * @returns {String}
  1860. */
  1861. static getPostPageURL(id) {
  1862. return `https://rule34.xxx/index.php?page=post&s=view&id=${id}`;
  1863. }
  1864.  
  1865. /**
  1866. * @param {String} id
  1867. */
  1868. static openPostInNewTab(id) {
  1869. window.open(Utils.getPostPageURL(id), "_blank");
  1870. }
  1871.  
  1872. /**
  1873. * @param {Function} initializer
  1874. */
  1875. static addStaticInitializer(initializer) {
  1876. Utils.staticInitializers.push(initializer);
  1877. }
  1878.  
  1879. static invokeStaticInitializers() {
  1880. for (const initializer of Utils.staticInitializers) {
  1881. initializer();
  1882. }
  1883. Utils.staticInitializers = null;
  1884. }
  1885.  
  1886. /**
  1887. * @returns {Number}
  1888. */
  1889. static loadAllowedRatings() {
  1890. return parseInt(Utils.getPreference("allowedRatings", 7));
  1891. }
  1892.  
  1893. /**
  1894. * @param {Set} a
  1895. * @param {Set} b
  1896. * @returns {Set}
  1897. */
  1898. static symmetricDifference(a, b) {
  1899. return Utils.union(Utils.difference(a, b), Utils.difference(b, a));
  1900. }
  1901.  
  1902. static clearOriginalFavoritesPage() {
  1903. const thumbs = Array.from(document.getElementsByClassName("thumb"));
  1904. let content = document.getElementById("content");
  1905.  
  1906. if (content === null && thumbs.length > 0) {
  1907. content = thumbs[0].closest("body>div");
  1908. }
  1909.  
  1910. if (content !== null) {
  1911. content.remove();
  1912. }
  1913. setTimeout(() => {
  1914. dispatchEvent(new CustomEvent("originalFavoritesCleared", {
  1915. detail: thumbs
  1916. }));
  1917. }, 1000);
  1918. }
  1919.  
  1920. /**
  1921. * @param {String} id
  1922. * @returns {String}
  1923. */
  1924. static getPostAPIURL(id) {
  1925. return `https://api.rule34.xxx//index.php?page=dapi&s=post&q=index&id=${id}`;
  1926. }
  1927.  
  1928. /**
  1929. * @returns {Promise<String>}
  1930. */
  1931. static getImageExtensionFromThumb(thumb) {
  1932. if (Utils.isVideo(thumb)) {
  1933. return "mp4";
  1934. }
  1935.  
  1936. if (Utils.isGif(thumb)) {
  1937. return "gif";
  1938. }
  1939.  
  1940. if (Utils.extensionIsKnown(thumb.id)) {
  1941. return Utils.getImageExtension(thumb.id);
  1942. }
  1943. return Utils.fetchImageExtension(thumb);
  1944. }
  1945.  
  1946. /**
  1947. * @param {HTMLElement} thumb
  1948. * @returns {Promise<String>}
  1949. */
  1950. static fetchImageExtension(thumb) {
  1951. return fetch(Utils.getPostAPIURL(thumb.id))
  1952. .then((response) => {
  1953. return response.text();
  1954. })
  1955. .then((html) => {
  1956. const dom = new DOMParser().parseFromString(html, "text/html");
  1957. const metadata = dom.querySelector("post");
  1958. const extension = Utils.getExtensionFromImageURL(metadata.getAttribute("file_url"));
  1959.  
  1960. Utils.assignImageExtension(thumb.id, extension);
  1961. return extension;
  1962. })
  1963. .catch((error) => {
  1964. console.error(error);
  1965. return "jpg";
  1966. });
  1967. }
  1968.  
  1969. /**
  1970. * @param {HTMLElement} thumb
  1971. * @returns {Promise<String>}
  1972. */
  1973. static async getOriginalImageURLWithExtension(thumb) {
  1974. const extension = await Utils.getImageExtensionFromThumb(thumb);
  1975. return Utils.getOriginalImageURL(thumb.querySelector("img").src).replace(".jpg", `.${extension}`);
  1976. }
  1977.  
  1978. /**
  1979. * @param {HTMLElement} thumb
  1980. */
  1981. static async openOriginalImageInNewTab(thumb) {
  1982. try {
  1983. const imageURL = await Utils.getOriginalImageURLWithExtension(thumb);
  1984.  
  1985. window.open(imageURL);
  1986. } catch (error) {
  1987. console.error(error);
  1988. }
  1989. }
  1990.  
  1991. /**
  1992. * @returns {String}
  1993. */
  1994. static getSearchPageAPIURL() {
  1995. const postsPerPage = 42;
  1996. const apiURL = `https://api.rule34.xxx/index.php?page=dapi&s=post&q=index&limit=${postsPerPage}`;
  1997. let blacklistedTags = ` ${Utils.negateTags(Utils.tagBlacklist)}`.replace(/\s-/g, "+-");
  1998. let pageNumber = (/&pid=(\d+)/).exec(location.href);
  1999. let searchTags = (/&tags=([^&]*)/).exec(location.href);
  2000.  
  2001. pageNumber = pageNumber === null ? 0 : Math.floor(parseInt(pageNumber[1]) / postsPerPage);
  2002. searchTags = searchTags === null ? "" : searchTags[1];
  2003.  
  2004. if (searchTags === "all") {
  2005. searchTags = "";
  2006. blacklistedTags = "";
  2007. }
  2008. return `${apiURL}&tags=${searchTags}${blacklistedTags}&pid=${pageNumber}`;
  2009. }
  2010.  
  2011. static findImageExtensionsOnSearchPage() {
  2012. const searchPageAPIURL = Utils.getSearchPageAPIURL();
  2013. return fetch(searchPageAPIURL)
  2014. .then((response) => {
  2015. if (response.ok) {
  2016. return response.text();
  2017. }
  2018. return null;
  2019. })
  2020. .then((html) => {
  2021. if (html === null) {
  2022. console.error(`Failed to fetch: ${searchPageAPIURL}`);
  2023. }
  2024. const dom = new DOMParser().parseFromString(`<div>${html}</div>`, "text/html");
  2025. const posts = Array.from(dom.getElementsByTagName("post"));
  2026.  
  2027. for (const post of posts) {
  2028. const tags = post.getAttribute("tags");
  2029. const id = post.getAttribute("id");
  2030. const originalImageURL = post.getAttribute("file_url");
  2031. const tagSet = Utils.convertToTagSet(tags);
  2032. const thumb = document.getElementById(id);
  2033.  
  2034. if (!tagSet.has("video") && originalImageURL.endsWith("mp4") && thumb !== null) {
  2035. const image = Utils.getImageFromThumb(thumb);
  2036.  
  2037. image.setAttribute("tags", `${image.getAttribute("tags")} video`);
  2038. Utils.setContentType(image, "video");
  2039. } else if (!tagSet.has("gif") && originalImageURL.endsWith("gif") && thumb !== null) {
  2040. const image = Utils.getImageFromThumb(thumb);
  2041.  
  2042. image.setAttribute("tags", `${image.getAttribute("tags")} gif`);
  2043. Utils.setContentType(image, "gif");
  2044. }
  2045. const isAnImage = Utils.getContentType(tags) === "image";
  2046. const isBlacklisted = originalImageURL === "https://api-cdn.rule34.xxx/images//";
  2047.  
  2048. if (!isAnImage || isBlacklisted) {
  2049. continue;
  2050. }
  2051. const extension = Utils.getExtensionFromImageURL(originalImageURL);
  2052.  
  2053. Utils.assignImageExtension(id, extension);
  2054. }
  2055. })
  2056. .catch((error) => {
  2057. console.error(error);
  2058. });
  2059. }
  2060.  
  2061. static async setupOriginalImageLinksOnSearchPage() {
  2062. if (!Utils.onSearchPage()) {
  2063. return;
  2064. }
  2065.  
  2066. if (Gallery.disabled) {
  2067. await Utils.findImageExtensionsOnSearchPage();
  2068. Utils.setupOriginalImageLinksOnSearchPageHelper();
  2069. } else {
  2070. window.addEventListener("foundExtensionsOnSearchPage", () => {
  2071. Utils.setupOriginalImageLinksOnSearchPageHelper();
  2072. }, {
  2073. once: true
  2074. });
  2075. }
  2076. }
  2077.  
  2078. static async setupOriginalImageLinksOnSearchPageHelper() {
  2079. try {
  2080. for (const thumb of Utils.getAllThumbs()) {
  2081. await Utils.setupOriginalImageLinkOnSearchPage(thumb);
  2082. }
  2083. } catch (error) {
  2084. console.error(error);
  2085. }
  2086. }
  2087.  
  2088. /**
  2089. * @param {HTMLElement} thumb
  2090. */
  2091. static async setupOriginalImageLinkOnSearchPage(thumb) {
  2092. const anchor = thumb.querySelector("a");
  2093. const imageURL = await Utils.getOriginalImageURLWithExtension(thumb);
  2094. const thumbURL = anchor.href;
  2095.  
  2096. anchor.href = imageURL;
  2097. anchor.onclick = (event) => {
  2098. if (!event.ctrlKey) {
  2099. event.preventDefault();
  2100. }
  2101. };
  2102. anchor.onmousedown = (event) => {
  2103. if (event.ctrlKey) {
  2104. return;
  2105. }
  2106. event.preventDefault();
  2107. const middleClick = event.button === Utils.clickCodes.middle;
  2108. const leftClick = event.button === Utils.clickCodes.left;
  2109. const shiftClick = leftClick && event.shiftKey;
  2110.  
  2111. if (leftClick && Gallery.disabled) {
  2112. document.location = thumbURL;
  2113. } else if (middleClick || shiftClick) {
  2114. window.open(thumbURL);
  2115. }
  2116. };
  2117. }
  2118.  
  2119. static prepareSearchPage() {
  2120. if (!Utils.onSearchPage()) {
  2121. return;
  2122. }
  2123.  
  2124. for (const thumb of Utils.getAllThumbs()) {
  2125. Utils.removeTitleFromImage(Utils.getImageFromThumb(thumb));
  2126. Utils.assignContentType(thumb);
  2127. thumb.id = Utils.removeNonNumericCharacters(Utils.getIdFromThumb(thumb));
  2128. }
  2129. }
  2130.  
  2131. /**
  2132. * @returns {Object.<String, Number>}
  2133. */
  2134. static loadDiscoveredImageExtensions() {
  2135. return JSON.parse(localStorage.getItem(Utils.localStorageKeys.imageExtensions)) || {};
  2136. }
  2137.  
  2138. /**
  2139. * @param {String | Number} id
  2140. * @returns {String}
  2141. */
  2142. static getImageExtension(id) {
  2143. return Utils.extensionDecodings[Utils.imageExtensions[parseInt(id)]];
  2144. }
  2145.  
  2146. /**
  2147. * @param {String | Number} id
  2148. * @param {String} extension
  2149. */
  2150. static setImageExtension(id, extension) {
  2151. Utils.imageExtensions[parseInt(id)] = Utils.extensionEncodings[extension];
  2152. }
  2153.  
  2154. /**
  2155. * @param {String} id
  2156. * @returns {Boolean}
  2157. */
  2158. static extensionIsKnown(id) {
  2159. return Utils.getImageExtension(id) !== undefined;
  2160. }
  2161.  
  2162. static updateStoredImageExtensions() {
  2163. Utils.recentlyDiscoveredImageExtensionCount += 1;
  2164.  
  2165. if (Utils.recentlyDiscoveredImageExtensionCount >= Utils.settings.extensionsFoundBeforeSavingCount) {
  2166. this.storeAllImageExtensions();
  2167. }
  2168. }
  2169.  
  2170. static storeAllImageExtensions() {
  2171. if (!Utils.onFavoritesPage()) {
  2172. return;
  2173. }
  2174. Utils.recentlyDiscoveredImageExtensionCount = 0;
  2175. localStorage.setItem(Utils.localStorageKeys.imageExtensions, JSON.stringify(Utils.imageExtensions));
  2176. }
  2177.  
  2178. static isAnAnimatedExtension(extension) {
  2179. return extension === "mp4" || extension === "gif";
  2180. }
  2181.  
  2182. /**
  2183. * @param {String} id
  2184. * @param {String} extension
  2185. */
  2186. static assignImageExtension(id, extension) {
  2187. if (Utils.extensionIsKnown(id) || Utils.isAnAnimatedExtension(extension)) {
  2188. return;
  2189. }
  2190. Utils.imageExtensionAssignmentCooldown.restart();
  2191. Utils.setImageExtension(id, extension);
  2192. Utils.updateStoredImageExtensions();
  2193. }
  2194.  
  2195. static initializeImageExtensionAssignmentCooldown() {
  2196. Utils.imageExtensionAssignmentCooldown = new Cooldown(1000);
  2197. Utils.imageExtensionAssignmentCooldown.onCooldownEnd = () => {
  2198. if (Utils.recentlyDiscoveredImageExtensionCount > 0) {
  2199. Utils.storeAllImageExtensions();
  2200. }
  2201. };
  2202. }
  2203.  
  2204. /**
  2205. * @returns {Boolean}
  2206. */
  2207. static usingIOS() {
  2208. return (/iPhone|iPad|iPod/).test(navigator.userAgent);
  2209. }
  2210. }
  2211.  
  2212. class HoldButton extends HTMLElement {
  2213. static {
  2214. Utils.addStaticInitializer(() => {
  2215. customElements.define("hold-button", HoldButton);
  2216. });
  2217. }
  2218.  
  2219. /**
  2220. * @type {Number}
  2221. */
  2222. static defaultPollingTime = 100;
  2223. /**
  2224. * @type {Number}
  2225. */
  2226. static minPollingTime = 40;
  2227. /**
  2228. * @type {Number}
  2229. */
  2230. static maxPollingTime = 500;
  2231.  
  2232. /**
  2233. * @type {Number}
  2234. */
  2235. intervalId;
  2236. /**
  2237. * @type {Number}
  2238. */
  2239. timeoutId;
  2240. /**
  2241. * @type {Number}
  2242. */
  2243. pollingTime = HoldButton.defaultPollingTime;
  2244. /**
  2245. * @type {Boolean}
  2246. */
  2247. holdingDown = false;
  2248.  
  2249. connectedCallback() {
  2250. if (Utils.onMobileDevice()) {
  2251. return;
  2252. }
  2253. this.addEventListeners();
  2254. this.setPollingTime(this.getAttribute("pollingtime"));
  2255. }
  2256.  
  2257. attributeChangedCallback(name, oldValue, newValue) {
  2258. switch (name) {
  2259. case "pollingtime":
  2260. this.setPollingTime(newValue);
  2261. break;
  2262.  
  2263. default:
  2264. break;
  2265. }
  2266. }
  2267.  
  2268. /**
  2269. * @param {String} newValue
  2270. */
  2271. setPollingTime(newValue) {
  2272. this.stopPolling();
  2273. const pollingTime = parseFloat(newValue) || HoldButton.defaultPollingTime;
  2274.  
  2275. this.pollingTime = Utils.clamp(Math.round(pollingTime), HoldButton.minPollingTime, HoldButton.maxPollingTime);
  2276. }
  2277.  
  2278. addEventListeners() {
  2279. this.addEventListener("mousedown", (event) => {
  2280. if (event.button === 0) {
  2281. this.holdingDown = true;
  2282. this.startPolling();
  2283. }
  2284. }, {
  2285. passive: true
  2286. });
  2287.  
  2288. this.addEventListener("mouseup", (event) => {
  2289. if (event.button === 0) {
  2290. this.holdingDown = false;
  2291. this.stopPolling();
  2292. }
  2293. }, {
  2294. passive: true
  2295. });
  2296.  
  2297. this.addEventListener("mouseleave", () => {
  2298. if (this.holdingDown) {
  2299. this.onMouseLeaveWhileHoldingDown();
  2300. this.holdingDown = false;
  2301. }
  2302. this.stopPolling();
  2303. }, {
  2304. passive: true
  2305. });
  2306. }
  2307.  
  2308. startPolling() {
  2309. this.timeoutId = setTimeout(() => {
  2310. this.intervalId = setInterval(() => {
  2311. this.onmousehold();
  2312. }, this.pollingTime);
  2313. }, this.pollingTime);
  2314. }
  2315.  
  2316. stopPolling() {
  2317. clearTimeout(this.timeoutId);
  2318. clearInterval(this.intervalId);
  2319. }
  2320.  
  2321. onmousehold() {
  2322. }
  2323.  
  2324. onMouseLeaveWhileHoldingDown() {
  2325. }
  2326. }
  2327.  
  2328. class NumberComponent {
  2329. /**
  2330. * @type {HTMLInputElement}
  2331. */
  2332. input;
  2333. /**
  2334. * @type {HoldButton}
  2335. */
  2336. upArrow;
  2337. /**
  2338. * @type {HoldButton}
  2339. */
  2340. downArrow;
  2341. /**
  2342. * @type {Number}
  2343. */
  2344. increment;
  2345.  
  2346. /**
  2347. * @type {Boolean}
  2348. */
  2349. get allSubComponentsConnected() {
  2350. return this.input !== null && this.upArrow !== null && this.downArrow !== null;
  2351. }
  2352.  
  2353. /**
  2354. * @param {HTMLDivElement} element
  2355. */
  2356. constructor(element) {
  2357. this.connectSubElements(element);
  2358. this.initializeFields();
  2359. this.addEventListeners();
  2360. }
  2361.  
  2362. initializeFields() {
  2363. if (!this.allSubComponentsConnected) {
  2364. return;
  2365. }
  2366. this.increment = Utils.roundToTwoDecimalPlaces(parseFloat(this.input.getAttribute("step")) || 1);
  2367.  
  2368. if (this.input.onchange === null) {
  2369. this.input.onchange = () => { };
  2370. }
  2371. }
  2372.  
  2373. /**
  2374. * @param {HTMLDivElement} element
  2375. */
  2376. connectSubElements(element) {
  2377. this.input = element.querySelector("input");
  2378. this.upArrow = element.querySelector(".number-arrow-up");
  2379. this.downArrow = element.querySelector(".number-arrow-down");
  2380. }
  2381.  
  2382. addEventListeners() {
  2383. if (!this.allSubComponentsConnected) {
  2384. return;
  2385. }
  2386. this.upArrow.onmousehold = () => {
  2387. this.incrementInput(true);
  2388. };
  2389. this.downArrow.onmousehold = () => {
  2390. this.incrementInput(false);
  2391. };
  2392. this.upArrow.addEventListener("mousedown", (event) => {
  2393. if (event.button === 0) {
  2394. this.incrementInput(true);
  2395. }
  2396. }, {
  2397. passive: true
  2398. });
  2399. this.downArrow.addEventListener("mousedown", (event) => {
  2400. if (event.button === 0) {
  2401. this.incrementInput(false);
  2402. }
  2403. }, {
  2404. passive: true
  2405. });
  2406. this.upArrow.addEventListener("mouseup", () => {
  2407. this.input.onchange();
  2408. }, {
  2409. passive: true
  2410. });
  2411. this.downArrow.addEventListener("mouseup", () => {
  2412. this.input.onchange();
  2413. }, {
  2414. passive: true
  2415. });
  2416. this.upArrow.onMouseLeaveWhileHoldingDown = () => {
  2417. this.input.onchange();
  2418. };
  2419. this.downArrow.onMouseLeaveWhileHoldingDown = () => {
  2420. this.input.onchange();
  2421. };
  2422. }
  2423.  
  2424. /**
  2425. * @param {Boolean} add
  2426. */
  2427. incrementInput(add) {
  2428. const currentValue = parseFloat(this.input.value) || 1;
  2429. const incrementedValue = add ? currentValue + this.increment : currentValue - this.increment;
  2430.  
  2431. this.input.value = Utils.clamp(incrementedValue, 0, 9999);
  2432. }
  2433. }
  2434.  
  2435. class Cooldown {
  2436. /**
  2437. * @type {Number}
  2438. */
  2439. timeout;
  2440. /**
  2441. * @type {Number}
  2442. */
  2443. waitTime;
  2444. /**
  2445. * @type {Boolean}
  2446. */
  2447. skipCooldown;
  2448. /**
  2449. * @type {Boolean}
  2450. */
  2451. debounce;
  2452. /**
  2453. * @type {Boolean}
  2454. */
  2455. debouncing;
  2456. /**
  2457. * @type {Function}
  2458. */
  2459. onDebounceEnd;
  2460. /**
  2461. * @type {Function}
  2462. */
  2463. onCooldownEnd;
  2464.  
  2465. get ready() {
  2466. if (this.skipCooldown) {
  2467. return true;
  2468. }
  2469.  
  2470. if (this.timeout === null) {
  2471. this.start();
  2472. return true;
  2473. }
  2474.  
  2475. if (this.debounce) {
  2476. this.startDebounce();
  2477. }
  2478. return false;
  2479. }
  2480.  
  2481. /**
  2482. * @param {Number} waitTime
  2483. * @param {Boolean} debounce
  2484. */
  2485. constructor(waitTime, debounce = false) {
  2486. this.timeout = null;
  2487. this.waitTime = waitTime;
  2488. this.skipCooldown = false;
  2489. this.debounce = debounce;
  2490. this.debouncing = false;
  2491. this.onDebounceEnd = () => { };
  2492. this.onCooldownEnd = () => { };
  2493. }
  2494.  
  2495. startDebounce() {
  2496. this.debouncing = true;
  2497. clearTimeout(this.timeout);
  2498. this.start();
  2499. }
  2500.  
  2501. start() {
  2502. this.timeout = setTimeout(() => {
  2503. this.timeout = null;
  2504.  
  2505. if (this.debouncing) {
  2506. this.onDebounceEnd();
  2507. this.debouncing = false;
  2508. }
  2509. this.onCooldownEnd();
  2510. }, this.waitTime);
  2511. }
  2512.  
  2513. stop() {
  2514. if (this.timeout === null) {
  2515. return;
  2516. }
  2517. clearTimeout(this.timeout);
  2518. this.timeout = null;
  2519. }
  2520.  
  2521. restart() {
  2522. this.stop();
  2523. this.start();
  2524. }
  2525. }
  2526.  
  2527. class MetadataSearchExpression {
  2528. /**
  2529. * @type {String}
  2530. */
  2531. metric;
  2532. /**
  2533. * @type {String}
  2534. */
  2535. operator;
  2536. /**
  2537. * @type {String | Number}
  2538. */
  2539. value;
  2540.  
  2541. /**
  2542. * @param {String} metric
  2543. * @param {String} operator
  2544. * @param {String} value
  2545. */
  2546. constructor(metric, operator, value) {
  2547. this.metric = metric;
  2548. this.operator = operator;
  2549. this.value = this.setValue(value);
  2550. }
  2551.  
  2552. /**
  2553. * @param {String} value
  2554. * @returns {String | Number}
  2555. */
  2556. setValue(value) {
  2557. if (!Utils.isNumber(value)) {
  2558. return value;
  2559. }
  2560.  
  2561. if (this.metric === "id" && this.operator === ":") {
  2562. return value;
  2563. }
  2564. return parseInt(value);
  2565. }
  2566. }
  2567.  
  2568. class PostMetadata {
  2569. /**
  2570. * @type {Map.<String, PostMetadata>}
  2571. */
  2572. static allMetadata = new Map();
  2573. static parser = new DOMParser();
  2574. /**
  2575. * @type {PostMetadata[]}
  2576. */
  2577. static missingMetadataFetchQueue = [];
  2578. /**
  2579. * @type {PostMetadata[]}
  2580. */
  2581. static deletedPostFetchQueue = [];
  2582. static currentlyFetchingFromQueue = false;
  2583. static allFavoritesLoaded = false;
  2584. static fetchDelay = {
  2585. normal: 10,
  2586. deleted: 300
  2587. };
  2588. static postStatisticsRegex = /Posted:\s*(\S+\s\S+).*Size:\s*(\d+)x(\d+).*Rating:\s*(\S+).*Score:\s*(\d+)/gm;
  2589. static settings = {
  2590. verifyTags: true
  2591. };
  2592. /**
  2593. * @param {PostMetadata} missingMetadata
  2594. */
  2595. static async fetchMissingMetadata(missingMetadata) {
  2596. if (missingMetadata !== undefined) {
  2597. PostMetadata.missingMetadataFetchQueue.push(missingMetadata);
  2598. }
  2599.  
  2600. if (PostMetadata.currentlyFetchingFromQueue) {
  2601. return;
  2602. }
  2603. PostMetadata.currentlyFetchingFromQueue = true;
  2604.  
  2605. while (PostMetadata.missingMetadataFetchQueue.length > 0) {
  2606. const metadata = this.missingMetadataFetchQueue.pop();
  2607.  
  2608. if (metadata.postIsDeleted) {
  2609. metadata.populateMetadataFromPost();
  2610. } else {
  2611. metadata.populateMetadataFromAPI(true);
  2612. }
  2613. await Utils.sleep(metadata.fetchDelay);
  2614. }
  2615. PostMetadata.currentlyFetchingFromQueue = false;
  2616. }
  2617.  
  2618. /**
  2619. * @param {String} rating
  2620. * @returns {Number}
  2621. */
  2622. static encodeRating(rating) {
  2623. return {
  2624. "Explicit": 4,
  2625. "E": 4,
  2626. "e": 4,
  2627. "Questionable": 2,
  2628. "Q": 2,
  2629. "q": 2,
  2630. "Safe": 1,
  2631. "S": 1,
  2632. "s": 1
  2633. }[rating] || 4;
  2634. }
  2635.  
  2636. static {
  2637. Utils.addStaticInitializer(() => {
  2638. if (Utils.onFavoritesPage()) {
  2639. window.addEventListener("favoritesLoaded", () => {
  2640. PostMetadata.allFavoritesLoaded = true;
  2641. PostMetadata.missingMetadataFetchQueue = PostMetadata.missingMetadataFetchQueue.concat(PostMetadata.deletedPostFetchQueue);
  2642. PostMetadata.fetchMissingMetadata();
  2643. }, {
  2644. once: true
  2645. });
  2646. }
  2647. });
  2648. }
  2649. /**
  2650. * @type {String}
  2651. */
  2652. id;
  2653. /**
  2654. * @type {Number}
  2655. */
  2656. width;
  2657. /**
  2658. * @type {Number}
  2659. */
  2660. height;
  2661. /**
  2662. * @type {Number}
  2663. */
  2664. score;
  2665. /**
  2666. * @type {Number}
  2667. */
  2668. rating;
  2669. /**
  2670. * @type {Number}
  2671. */
  2672. creationTimestamp;
  2673. /**
  2674. * @type {Number}
  2675. */
  2676. lastChangedTimestamp;
  2677. /**
  2678. * @type {Boolean}
  2679. */
  2680. postIsDeleted;
  2681.  
  2682. /**
  2683. * @type {String}
  2684. */
  2685. get apiURL() {
  2686. return Utils.getPostAPIURL(this.id);
  2687. }
  2688.  
  2689. /**
  2690. * @type {String}
  2691. */
  2692. get postURL() {
  2693. return Utils.getPostPageURL(this.id);
  2694. }
  2695.  
  2696. /**
  2697. * @type {Number}
  2698. */
  2699. get fetchDelay() {
  2700. return this.postIsDeleted ? PostMetadata.fetchDelay.deleted : PostMetadata.fetchDelay.normal;
  2701. }
  2702.  
  2703. /**
  2704. * @type {Boolean}
  2705. */
  2706. get isEmpty() {
  2707. return this.width === 0 && this.height === 0;
  2708. }
  2709.  
  2710. /**
  2711. * @type {String}
  2712. */
  2713. get json() {
  2714. return JSON.stringify({
  2715. width: this.width,
  2716. height: this.height,
  2717. score: this.score,
  2718. rating: this.rating,
  2719. create: this.creationTimestamp,
  2720. change: this.lastChangedTimestamp,
  2721. deleted: this.postIsDeleted
  2722. });
  2723. }
  2724.  
  2725. /**
  2726. * @type {Number}
  2727. */
  2728. get pixelCount() {
  2729. return this.width * this.height;
  2730. }
  2731.  
  2732. /**
  2733. * @param {String} id
  2734. * @param {Object.<String, String>} record
  2735. */
  2736. constructor(id, record) {
  2737. this.id = id;
  2738. this.setDefaults();
  2739. this.populateMetadata(record);
  2740. this.addInstanceToAllMetadata();
  2741. }
  2742.  
  2743. setDefaults() {
  2744. this.width = 0;
  2745. this.height = 0;
  2746. this.score = 0;
  2747. this.creationTimestamp = 0;
  2748. this.lastChangedTimestamp = 0;
  2749. this.rating = 4;
  2750. this.postIsDeleted = false;
  2751. }
  2752.  
  2753. /**
  2754. * @param {Object.<String, String>} record
  2755. */
  2756. populateMetadata(record) {
  2757. if (record === undefined) {
  2758. this.populateMetadataFromAPI();
  2759. } else if (record === null) {
  2760. PostMetadata.fetchMissingMetadata(this, true);
  2761. } else {
  2762. this.populateMetadataFromRecord(JSON.parse(record));
  2763.  
  2764. if (this.isEmpty) {
  2765. PostMetadata.fetchMissingMetadata(this, true);
  2766. }
  2767. }
  2768. }
  2769.  
  2770. /**
  2771. * @param {Boolean} missingInDatabase
  2772. */
  2773. populateMetadataFromAPI(missingInDatabase = false) {
  2774. fetch(this.apiURL)
  2775. .then((response) => {
  2776. return response.text();
  2777. })
  2778. .then((html) => {
  2779. const dom = PostMetadata.parser.parseFromString(html, "text/html");
  2780. const metadata = dom.querySelector("post");
  2781.  
  2782. if (metadata === null) {
  2783. throw new Error(`metadata is null - ${this.apiURL}`, {
  2784. cause: "DeletedMetadata"
  2785. });
  2786. }
  2787. this.width = parseInt(metadata.getAttribute("width"));
  2788. this.height = parseInt(metadata.getAttribute("height"));
  2789. this.score = parseInt(metadata.getAttribute("score"));
  2790. this.rating = PostMetadata.encodeRating(metadata.getAttribute("rating"));
  2791. this.creationTimestamp = Date.parse(metadata.getAttribute("created_at"));
  2792. this.lastChangedTimestamp = parseInt(metadata.getAttribute("change"));
  2793.  
  2794. if (PostMetadata.settings.verifyTags) {
  2795. Post.verifyTags(this.id, metadata.getAttribute("tags"), metadata.getAttribute("file_url"));
  2796. }
  2797. const extension = Utils.getExtensionFromImageURL(metadata.getAttribute("file_url"));
  2798.  
  2799. if (extension !== "mp4") {
  2800. Utils.assignImageExtension(this.id, extension);
  2801. }
  2802.  
  2803. if (missingInDatabase) {
  2804. dispatchEvent(new CustomEvent("missingMetadata", {
  2805. detail: this.id
  2806. }));
  2807. }
  2808. })
  2809. .catch((error) => {
  2810. if (error.cause === "DeletedMetadata") {
  2811. this.postIsDeleted = true;
  2812. PostMetadata.deletedPostFetchQueue.push(this);
  2813. } else if (error.message === "Failed to fetch") {
  2814. PostMetadata.missingMetadataFetchQueue.push(this);
  2815. } else {
  2816. console.error(error);
  2817. }
  2818. });
  2819. }
  2820.  
  2821. /**
  2822. * @param {Object.<String, String>} record
  2823. */
  2824. populateMetadataFromRecord(record) {
  2825. this.width = record.width;
  2826. this.height = record.height;
  2827. this.score = record.score;
  2828. this.rating = record.rating;
  2829. this.creationTimestamp = record.create;
  2830. this.lastChangedTimestamp = record.change;
  2831. this.postIsDeleted = record.deleted;
  2832. }
  2833.  
  2834. populateMetadataFromPost() {
  2835. fetch(this.postURL)
  2836. .then((response) => {
  2837. return response.text();
  2838. })
  2839. .then((html) => {
  2840. const dom = PostMetadata.parser.parseFromString(html, "text/html");
  2841. const statistics = dom.getElementById("stats");
  2842.  
  2843. if (statistics === null) {
  2844. return;
  2845. }
  2846. const textContent = Utils.replaceLineBreaks(statistics.textContent.trim(), " ");
  2847. const match = PostMetadata.postStatisticsRegex.exec(textContent);
  2848.  
  2849. PostMetadata.postStatisticsRegex.lastIndex = 0;
  2850.  
  2851. if (!match) {
  2852. return;
  2853. }
  2854. this.width = parseInt(match[2]);
  2855. this.height = parseInt(match[3]);
  2856. this.score = parseInt(match[5]);
  2857. this.rating = PostMetadata.encodeRating(match[4]);
  2858. this.creationTimestamp = Date.parse(match[1]);
  2859. this.lastChangedTimestamp = this.creationTimestamp / 1000;
  2860.  
  2861. if (PostMetadata.allFavoritesLoaded) {
  2862. dispatchEvent(new CustomEvent("missingMetadata", {
  2863. detail: this.id
  2864. }));
  2865. }
  2866. })
  2867. .catch((error) => {
  2868. console.error(error);
  2869. });
  2870. }
  2871.  
  2872. /**
  2873. * @param {{metric: String, operator: String, value: String, negated: Boolean}[]} filters
  2874. * @returns {Boolean}
  2875. */
  2876. satisfiesAllFilters(filters) {
  2877. for (const expression of filters) {
  2878. if (!this.satisfiesExpression(expression)) {
  2879. return false;
  2880. }
  2881. }
  2882. return true;
  2883. }
  2884.  
  2885. /**
  2886. * @param {MetadataSearchExpression} expression
  2887. * @returns {Boolean}
  2888. */
  2889. satisfiesExpression(expression) {
  2890. const metricMap = {
  2891. "id": this.id,
  2892. "width": this.width,
  2893. "height": this.height,
  2894. "score": this.score
  2895. };
  2896. const metricValue = metricMap[expression.metric] || 0;
  2897. const value = metricMap[expression.value] || expression.value;
  2898. return this.evaluateExpression(metricValue, expression.operator, value);
  2899. }
  2900.  
  2901. /**
  2902. * @param {Number} metricValue
  2903. * @param {String} operator
  2904. * @param {Number} value
  2905. * @returns {Boolean}
  2906. */
  2907. evaluateExpression(metricValue, operator, value) {
  2908. let result = false;
  2909.  
  2910. switch (operator) {
  2911. case ":":
  2912. result = metricValue === value;
  2913. break;
  2914.  
  2915. case ":<":
  2916. result = metricValue < value;
  2917. break;
  2918.  
  2919. case ":>":
  2920. result = metricValue > value;
  2921. break;
  2922.  
  2923. default:
  2924. break;
  2925. }
  2926. return result;
  2927. }
  2928.  
  2929. addInstanceToAllMetadata() {
  2930. if (!PostMetadata.allMetadata.has(this.id)) {
  2931. PostMetadata.allMetadata.set(this.id, this);
  2932. }
  2933. }
  2934. }
  2935.  
  2936. class InactivePost {
  2937. /**
  2938. * @param {String} compressedSource
  2939. * @param {String} id
  2940. * @returns {String}
  2941. */
  2942. static decompressThumbSource(compressedSource, id) {
  2943. compressedSource = compressedSource.split("_");
  2944. return `https://us.rule34.xxx/thumbnails//${compressedSource[0]}/thumbnail_${compressedSource[1]}.jpg?${id}`;
  2945. }
  2946.  
  2947. /**
  2948. * @type {String}
  2949. */
  2950. id;
  2951. /**
  2952. * @type {String}
  2953. */
  2954. tags;
  2955. /**
  2956. * @type {String}
  2957. */
  2958. src;
  2959. /**
  2960. * @type {String}
  2961. */
  2962. metadata;
  2963. /**
  2964. * @type {Boolean}
  2965. */
  2966. fromRecord;
  2967.  
  2968. /**
  2969. * @param {HTMLElement | Object} favorite
  2970. */
  2971. constructor(favorite, fromRecord) {
  2972. this.fromRecord = fromRecord;
  2973.  
  2974. if (fromRecord) {
  2975. this.populateAttributesFromDatabaseRecord(favorite);
  2976. } else {
  2977. this.populateAttributesFromHTMLElement(favorite);
  2978. }
  2979. }
  2980.  
  2981. /**
  2982. * @param {{id: String, tags: String, src: String, metadata: String}} record
  2983. */
  2984. populateAttributesFromDatabaseRecord(record) {
  2985. this.id = record.id;
  2986. this.tags = record.tags;
  2987. this.src = InactivePost.decompressThumbSource(record.src, record.id);
  2988. this.metadata = record.metadata;
  2989. }
  2990.  
  2991. /**
  2992. * @param {HTMLElement} element
  2993. */
  2994. populateAttributesFromHTMLElement(element) {
  2995. this.id = Utils.getIdFromThumb(element);
  2996. const image = Utils.getImageFromThumb(element);
  2997.  
  2998. this.src = image.src || image.getAttribute("data-cfsrc");
  2999. this.tags = this.preprocessTags(image);
  3000. }
  3001.  
  3002. /**
  3003. * @param {HTMLImageElement} image
  3004. * @returns {String}
  3005. */
  3006. preprocessTags(image) {
  3007. const tags = Utils.correctMisspelledTags(image.title || image.getAttribute("tags"));
  3008. return Utils.removeExtraWhiteSpace(tags).split(" ").sort().join(" ");
  3009. }
  3010.  
  3011. instantiateMetadata() {
  3012. if (this.fromRecord) {
  3013. return new PostMetadata(this.id, this.metadata || null);
  3014. }
  3015. const favoritesMetadata = new PostMetadata(this.id);
  3016. return favoritesMetadata;
  3017. }
  3018.  
  3019. clear() {
  3020. this.id = null;
  3021. this.tags = null;
  3022. this.src = null;
  3023. this.metadata = null;
  3024. }
  3025. }
  3026.  
  3027. class Post {
  3028. /**
  3029. * @type {Map.<String, Post>}
  3030. */
  3031. static allPosts = new Map();
  3032. /**
  3033. * @type {RegExp}
  3034. */
  3035. static thumbSourceCompressionRegex = /thumbnails\/\/([0-9]+)\/thumbnail_([0-9a-f]+)/;
  3036. /**
  3037. * @type {HTMLElement}
  3038. */
  3039. static template;
  3040. /**
  3041. * @type {String}
  3042. */
  3043. static removeFavoriteButtonHTML;
  3044. /**
  3045. * @type {String}
  3046. */
  3047. static addFavoriteButtonHTML;
  3048. static currentSortingMethod = Utils.getPreference("sortingMethod", "default");
  3049. static settings = {
  3050. deferHTMLElementCreation: true
  3051. };
  3052.  
  3053. static {
  3054. Utils.addStaticInitializer(() => {
  3055. if (Utils.onFavoritesPage()) {
  3056. Post.createTemplates();
  3057. Post.addEventListeners();
  3058. }
  3059. });
  3060. }
  3061.  
  3062. static createTemplates() {
  3063. Post.removeFavoriteButtonHTML = `<img class="remove-favorite-button add-or-remove-button" src=${Utils.createObjectURLFromSvg(Utils.icons.heartMinus)}>`;
  3064. Post.addFavoriteButtonHTML = `<img class="add-favorite-button add-or-remove-button" src=${Utils.createObjectURLFromSvg(Utils.icons.heartPlus)}>`;
  3065. const buttonHTML = Utils.userIsOnTheirOwnFavoritesPage() ? Post.removeFavoriteButtonHTML : Post.addFavoriteButtonHTML;
  3066. const canvasHTML = Utils.getPerformanceProfile() > 0 ? "" : "<canvas></canvas>";
  3067. const containerTagName = "a";
  3068.  
  3069. Post.template = new DOMParser().parseFromString("", "text/html").createElement("div");
  3070. Post.template.className = Utils.favoriteItemClassName;
  3071. Post.template.innerHTML = `
  3072. <${containerTagName}>
  3073. <img>
  3074. ${buttonHTML}
  3075. ${canvasHTML}
  3076. </${containerTagName}>
  3077. `;
  3078. }
  3079.  
  3080. static addEventListeners() {
  3081. window.addEventListener("favoriteAddedOrDeleted", (event) => {
  3082. const id = event.detail;
  3083. const post = this.allPosts.get(id);
  3084.  
  3085. if (post !== undefined) {
  3086. post.swapAddOrRemoveButton();
  3087. }
  3088. });
  3089. window.addEventListener("sortingParametersChanged", () => {
  3090. Post.currentSortingMethod = Utils.getSortingMethod();
  3091. const posts = Utils.getAllThumbs().map(thumb => Post.allPosts.get(thumb.id));
  3092.  
  3093. for (const post of posts) {
  3094. post.createMetadataHint();
  3095. }
  3096. });
  3097. }
  3098.  
  3099. /**
  3100. * @param {String} id
  3101. * @returns {Number}
  3102. */
  3103. static getPixelCount(id) {
  3104. const post = Post.allPosts.get(id);
  3105.  
  3106. if (post === undefined || post.metadata === undefined) {
  3107. return 0;
  3108. }
  3109. return post.metadata.pixelCount;
  3110. }
  3111.  
  3112. /**
  3113. * @param {String} id
  3114. * @returns {String}
  3115. */
  3116. static getExtensionFromPost(id) {
  3117. const post = Post.allPosts.get(id);
  3118.  
  3119. if (post === undefined) {
  3120. return undefined;
  3121. }
  3122.  
  3123. if (post.metadata.isEmpty()) {
  3124. return undefined;
  3125. }
  3126. return post.metadata.extension;
  3127. }
  3128.  
  3129. /**
  3130. * @param {String} id
  3131. * @param {String} apiTags
  3132. * @param {String} fileURL
  3133. */
  3134. static verifyTags(id, apiTags, fileURL) {
  3135. const post = Post.allPosts.get(id);
  3136.  
  3137. if (post === undefined) {
  3138. return;
  3139. }
  3140. const postTagSet = new Set(post.originalTagSet);
  3141. const apiTagSet = Utils.convertToTagSet(apiTags);
  3142.  
  3143. if (fileURL.endsWith("mp4")) {
  3144. apiTagSet.add("video");
  3145. } else if (fileURL.endsWith("gif")) {
  3146. apiTagSet.add("gif");
  3147. } else if (!apiTagSet.has("animated_png")) {
  3148. if (apiTagSet.has("video")) {
  3149. apiTagSet.delete("video");
  3150. }
  3151.  
  3152. if (apiTagSet.has("animated")) {
  3153. apiTagSet.delete("animated");
  3154. }
  3155. }
  3156. postTagSet.delete(id);
  3157.  
  3158. if (Utils.symmetricDifference(apiTagSet, postTagSet).size > 0) {
  3159. post.initializeTags(Utils.convertToTagString(apiTagSet));
  3160. }
  3161. }
  3162.  
  3163. /**
  3164. * @type {Map.<String, Post>}
  3165. */
  3166. static get postsMatchedBySearch() {
  3167. const posts = new Map();
  3168.  
  3169. for (const [id, post] of Post.allPosts.entries()) {
  3170. if (post.matchedByMostRecentSearch) {
  3171. posts.set(id, post);
  3172. }
  3173. }
  3174. return posts;
  3175. }
  3176.  
  3177. /**
  3178. * @type {String}
  3179. */
  3180. id;
  3181. /**
  3182. * @type {HTMLDivElement}
  3183. */
  3184. root;
  3185. /**
  3186. * @type {HTMLAnchorElement}
  3187. */
  3188. container;
  3189. /**
  3190. * @type {HTMLImageElement}
  3191. */
  3192. image;
  3193. /**
  3194. * @type {HTMLImageElement}
  3195. */
  3196. addOrRemoveButton;
  3197. /**
  3198. * @type {HTMLDivElement}
  3199. */
  3200. statisticHint;
  3201. /**
  3202. * @type {InactivePost}
  3203. */
  3204. inactivePost;
  3205. /**
  3206. * @type {Boolean}
  3207. */
  3208. essentialAttributesPopulated;
  3209. /**
  3210. * @type {Boolean}
  3211. */
  3212. htmlElementCreated;
  3213. /**
  3214. * @type {Set.<String>}
  3215. */
  3216. tagSet;
  3217. /**
  3218. * @type {Set.<String>}
  3219. */
  3220. additionalTags;
  3221. /**
  3222. * @type {Number}
  3223. */
  3224. originalTagsLength;
  3225. /**
  3226. * @type {Boolean}
  3227. */
  3228. matchedByMostRecentSearch;
  3229. /**
  3230. * @type {PostMetadata}
  3231. */
  3232. metadata;
  3233.  
  3234. /**
  3235. * @type {String}
  3236. */
  3237. get href() {
  3238. return Utils.getPostPageURL(this.id);
  3239. }
  3240.  
  3241. /**
  3242. * @type {String}
  3243. */
  3244. get compressedThumbSource() {
  3245. const source = this.inactivePost === null ? this.image.src : this.inactivePost.src;
  3246. return source.match(Post.thumbSourceCompressionRegex).splice(1).join("_");
  3247. }
  3248.  
  3249. /**
  3250. * @type {{id: String, tags: String, src: String, metadata: String}}
  3251. */
  3252. get databaseRecord() {
  3253. return {
  3254. id: this.id,
  3255. tags: this.originalTagsString,
  3256. src: this.compressedThumbSource,
  3257. metadata: this.metadata.json
  3258. };
  3259. }
  3260.  
  3261. /**
  3262. * @type {Set.<String>}
  3263. */
  3264. get originalTagSet() {
  3265. const originalTags = new Set();
  3266. let count = 0;
  3267.  
  3268. for (const tag of this.tagSet.values()) {
  3269. if (count >= this.originalTagsLength) {
  3270. break;
  3271. }
  3272. count += 1;
  3273. originalTags.add(tag);
  3274. }
  3275. return originalTags;
  3276. }
  3277.  
  3278. /**
  3279. * @type {Set.<String>}
  3280. */
  3281. get originalTagsString() {
  3282. return Utils.convertToTagString(this.originalTagSet);
  3283. }
  3284.  
  3285. /**
  3286. * @type {String}
  3287. */
  3288. get additionalTagsString() {
  3289. return Utils.convertToTagString(this.additionalTags);
  3290. }
  3291.  
  3292. /**
  3293. * @param {HTMLElement | Object} thumb
  3294. * @param {Boolean} fromRecord
  3295. */
  3296. constructor(thumb, fromRecord) {
  3297. this.initializeFields();
  3298. this.initialize(new InactivePost(thumb, fromRecord));
  3299. this.setMatched(true);
  3300. this.addInstanceToAllPosts();
  3301. }
  3302.  
  3303. initializeFields() {
  3304. this.inactivePost = null;
  3305. this.essentialAttributesPopulated = false;
  3306. this.htmlElementCreated = false;
  3307. }
  3308.  
  3309. /**
  3310. * @param {InactivePost} inactivePost
  3311. */
  3312. initialize(inactivePost) {
  3313. if (Post.settings.deferHTMLElementCreation) {
  3314. this.inactivePost = inactivePost;
  3315. this.populateEssentialAttributes(inactivePost);
  3316. } else {
  3317. this.createHTMLElement(inactivePost);
  3318. }
  3319. }
  3320.  
  3321. /**
  3322. * @param {InactivePost} inactivePost
  3323. */
  3324. populateEssentialAttributes(inactivePost) {
  3325. if (this.essentialAttributesPopulated) {
  3326. return;
  3327. }
  3328. this.essentialAttributesPopulated = true;
  3329. this.id = inactivePost.id;
  3330. this.metadata = inactivePost.instantiateMetadata();
  3331. this.initializeTags(inactivePost.tags);
  3332. this.deleteConsumedProperties(inactivePost);
  3333. }
  3334.  
  3335. /**
  3336. * @param {InactivePost} inactivePost
  3337. */
  3338. createHTMLElement(inactivePost) {
  3339. if (this.htmlElementCreated) {
  3340. return;
  3341. }
  3342. this.htmlElementCreated = true;
  3343. this.instantiateTemplate();
  3344. this.populateEssentialAttributes(inactivePost);
  3345. this.populateHTMLAttributes(inactivePost);
  3346. this.setupAddOrRemoveButton(Utils.userIsOnTheirOwnFavoritesPage());
  3347. this.setupClickLink();
  3348. this.deleteInactivePost();
  3349. }
  3350.  
  3351. activateHTMLElement() {
  3352. if (this.inactivePost !== null) {
  3353. this.createHTMLElement(this.inactivePost);
  3354. }
  3355. }
  3356.  
  3357. instantiateTemplate() {
  3358. this.root = Post.template.cloneNode(true);
  3359. this.container = this.root.children[0];
  3360. this.image = this.root.children[0].children[0];
  3361. this.addOrRemoveButton = this.root.children[0].children[1];
  3362. }
  3363.  
  3364. /**
  3365. * @param {Boolean} isRemoveButton
  3366. */
  3367. setupAddOrRemoveButton(isRemoveButton) {
  3368. if (isRemoveButton) {
  3369. this.addOrRemoveButton.onmousedown = (event) => {
  3370. event.stopPropagation();
  3371.  
  3372. if (event.button === Utils.clickCodes.left) {
  3373. this.removeFavorite();
  3374. }
  3375. };
  3376. } else {
  3377. this.addOrRemoveButton.onmousedown = (event) => {
  3378. event.stopPropagation();
  3379.  
  3380. if (event.button === Utils.clickCodes.left) {
  3381. this.addFavorite();
  3382. }
  3383. };
  3384. }
  3385. }
  3386.  
  3387. removeFavorite() {
  3388. Utils.removeFavorite(this.id);
  3389. this.swapAddOrRemoveButton();
  3390. }
  3391.  
  3392. addFavorite() {
  3393. Utils.addFavorite(this.id);
  3394. this.swapAddOrRemoveButton();
  3395. }
  3396.  
  3397. swapAddOrRemoveButton() {
  3398. const isRemoveButton = this.addOrRemoveButton.classList.contains("remove-favorite-button");
  3399.  
  3400. this.addOrRemoveButton.outerHTML = isRemoveButton ? Post.addFavoriteButtonHTML : Post.removeFavoriteButtonHTML;
  3401. this.addOrRemoveButton = this.root.children[0].children[1];
  3402. this.setupAddOrRemoveButton(!isRemoveButton);
  3403. }
  3404.  
  3405. /**
  3406. * @param {InactivePost} inactivePost
  3407. */
  3408. async populateHTMLAttributes(inactivePost) {
  3409. this.image.src = inactivePost.src;
  3410. this.image.classList.add(Utils.getContentType(inactivePost.tags || Utils.convertToTagString(this.tagSet)));
  3411. this.root.id = inactivePost.id;
  3412.  
  3413. if (!Utils.onMobileDevice()) {
  3414. this.container.href = await Utils.getOriginalImageURLWithExtension(this.root);
  3415. }
  3416. }
  3417.  
  3418. /**
  3419. * @param {String} tags
  3420. */
  3421. initializeTags(tags) {
  3422. this.tagSet = Utils.convertToTagSet(`${this.id} ${tags}`);
  3423. this.originalTagsLength = this.tagSet.size;
  3424. this.initializeAdditionalTags();
  3425. }
  3426.  
  3427. initializeAdditionalTags() {
  3428. this.additionalTags = Utils.convertToTagSet(TagModifier.tagModifications.get(this.id) || "");
  3429.  
  3430. if (this.additionalTags.size !== 0) {
  3431. this.combineOriginalAndAdditionalTags();
  3432. }
  3433. }
  3434.  
  3435. /**
  3436. * @param {InactivePost} inactivePost
  3437. */
  3438. deleteConsumedProperties(inactivePost) {
  3439. inactivePost.metadata = null;
  3440. inactivePost.tags = null;
  3441. }
  3442.  
  3443. setupClickLink() {
  3444. if (!Utils.onFavoritesPage()) {
  3445. return;
  3446. }
  3447. this.container.addEventListener("mousedown", (event) => {
  3448. if (event.ctrlKey) {
  3449. return;
  3450. }
  3451. const middleClick = event.button === Utils.clickCodes.middle;
  3452. const leftClick = event.button === Utils.clickCodes.left;
  3453. const shiftClick = leftClick && event.shiftKey;
  3454.  
  3455. if (middleClick || shiftClick || (leftClick && !Utils.galleryEnabled())) {
  3456. Utils.openPostInNewTab(this.id);
  3457. }
  3458. });
  3459. }
  3460.  
  3461. deleteInactivePost() {
  3462. if (this.inactivePost !== null) {
  3463. this.inactivePost.clear();
  3464. this.inactivePost = null;
  3465. }
  3466. }
  3467.  
  3468. /**
  3469. * @param {HTMLElement} content
  3470. */
  3471. insertAtEndOfContent(content) {
  3472. if (this.inactivePost !== null) {
  3473. this.createHTMLElement(this.inactivePost, true);
  3474. }
  3475. this.createMetadataHint();
  3476. content.appendChild(this.root);
  3477. }
  3478.  
  3479. /**
  3480. * @param {HTMLElement} content
  3481. */
  3482. insertAtBeginningOfContent(content) {
  3483. if (this.inactivePost !== null) {
  3484. this.createHTMLElement(this.inactivePost, true);
  3485. }
  3486. this.createMetadataHint();
  3487. content.insertAdjacentElement("afterbegin", this.root);
  3488. }
  3489.  
  3490. addInstanceToAllPosts() {
  3491. if (!Post.allPosts.has(this.id)) {
  3492. Post.allPosts.set(this.id, this);
  3493. }
  3494. }
  3495.  
  3496. toggleMatched() {
  3497. this.matchedByMostRecentSearch = !this.matchedByMostRecentSearch;
  3498. }
  3499.  
  3500. /**
  3501. * @param {Boolean} value
  3502. */
  3503. setMatched(value) {
  3504. this.matchedByMostRecentSearch = value;
  3505. }
  3506.  
  3507. combineOriginalAndAdditionalTags() {
  3508. this.tagSet = this.originalTagSet;
  3509. this.tagSet = Utils.union(this.tagSet, this.additionalTags);
  3510. }
  3511.  
  3512. /**
  3513. * @param {String} newTags
  3514. * @returns {String}
  3515. */
  3516. addAdditionalTags(newTags) {
  3517. const newTagsSet = Utils.convertToTagSet(newTags);
  3518.  
  3519. if (newTagsSet.size > 0) {
  3520. this.additionalTags = Utils.union(this.additionalTags, newTagsSet);
  3521. this.combineOriginalAndAdditionalTags();
  3522. }
  3523. return this.additionalTagsString;
  3524. }
  3525.  
  3526. /**
  3527. * @param {String} tagsToRemove
  3528. * @returns {String}
  3529. */
  3530. removeAdditionalTags(tagsToRemove) {
  3531. const tagsToRemoveSet = Utils.convertToTagSet(tagsToRemove);
  3532.  
  3533. if (tagsToRemoveSet.size > 0) {
  3534. this.additionalTags = Utils.difference(this.additionalTags, tagsToRemoveSet);
  3535. this.combineOriginalAndAdditionalTags();
  3536. }
  3537. return this.additionalTagsString;
  3538. }
  3539.  
  3540. resetAdditionalTags() {
  3541. if (this.additionalTags.size === 0) {
  3542. return;
  3543. }
  3544. this.additionalTags = new Set();
  3545. this.combineOriginalAndAdditionalTags();
  3546. }
  3547.  
  3548. /**
  3549. * @returns {HTMLDivElement}
  3550. */
  3551. getMetadataHintElement() {
  3552. return this.container.querySelector(".statistic-hint");
  3553. }
  3554.  
  3555. /**
  3556. * @returns {Boolean}
  3557. */
  3558. hasStatisticHint() {
  3559. return this.getMetadataHintElement() !== null;
  3560. }
  3561.  
  3562. /**
  3563. * @returns {String}
  3564. */
  3565. getMetadataHintValue() {
  3566. switch (Post.currentSortingMethod) {
  3567. case "score":
  3568. return this.metadata.score;
  3569.  
  3570. case "width":
  3571. return this.metadata.width;
  3572.  
  3573. case "height":
  3574. return this.metadata.height;
  3575.  
  3576. case "create":
  3577. return Utils.convertTimestampToDate(this.metadata.creationTimestamp);
  3578.  
  3579. case "change":
  3580. return Utils.convertTimestampToDate(this.metadata.lastChangedTimestamp * 1000);
  3581.  
  3582. default:
  3583. return this.id;
  3584. }
  3585. }
  3586.  
  3587. async createMetadataHint() {
  3588. // await sleep(200);
  3589. // let hint = this.getMetadataHintElement();
  3590.  
  3591. // if (hint === null) {
  3592. // hint = document.createElement("div");
  3593. // hint.className = "statistic-hint";
  3594. // this.container.appendChild(hint);
  3595. // }
  3596. // hint.textContent = this.getMetadataHintValue();
  3597. }
  3598. }
  3599.  
  3600. class SearchTag {
  3601. /**
  3602. * @type {String}
  3603. */
  3604. value;
  3605. /**
  3606. * @type {Boolean}
  3607. */
  3608. negated;
  3609.  
  3610. /**
  3611. * @type {Number}
  3612. */
  3613. get cost() {
  3614. return 0;
  3615. }
  3616.  
  3617. /**
  3618. * @type {Number}
  3619. */
  3620. get finalCost() {
  3621. return this.negated ? this.cost + 1 : this.cost;
  3622. }
  3623.  
  3624. /**
  3625. * @param {String} searchTag
  3626. */
  3627. constructor(searchTag) {
  3628. this.negated = searchTag.startsWith("-");
  3629. this.value = this.negated ? searchTag.substring(1) : searchTag;
  3630. }
  3631.  
  3632. /**
  3633. * @param {Post} post
  3634. * @returns {Boolean}
  3635. */
  3636. matches(post) {
  3637. if (post.tagSet.has(this.value)) {
  3638. return !this.negated;
  3639. }
  3640. return this.negated;
  3641. }
  3642. }
  3643.  
  3644. class WildcardSearchTag extends SearchTag {
  3645. static unmatchableRegex = /^\b$/;
  3646. static startsWithRegex = /^[^*]*\*$/;
  3647.  
  3648. /**
  3649. * @type {RegExp}
  3650. */
  3651. matchRegex;
  3652. /**
  3653. * @type {Boolean}
  3654. */
  3655. equivalentToStartsWith;
  3656. /**
  3657. * @type {String}
  3658. */
  3659. startsWithPrefix;
  3660.  
  3661. /**
  3662. * @type {Number}
  3663. */
  3664. get cost() {
  3665. return this.equivalentToStartsWith ? 10 : 20;
  3666. }
  3667.  
  3668. /**
  3669. * @param {String} searchTag
  3670. */
  3671. constructor(searchTag) {
  3672. super(searchTag);
  3673. this.matchRegex = this.createWildcardRegex();
  3674. this.startsWithPrefix = this.value.slice(0, -1);
  3675. this.equivalentToStartsWith = WildcardSearchTag.startsWithRegex.test(searchTag);
  3676. this.matches = this.equivalentToStartsWith ? this.matchesPrefix : this.matchesWildcard;
  3677. }
  3678.  
  3679. /**
  3680. * @returns {RegExp}
  3681. */
  3682. createWildcardRegex() {
  3683. try {
  3684. return new RegExp(`^${this.value.replaceAll(/\*/g, ".*")}$`);
  3685. } catch {
  3686. return WildcardSearchTag.unmatchableRegex;
  3687. }
  3688. }
  3689.  
  3690. /**
  3691. * @param {Post} post
  3692. * @returns {Boolean}
  3693. */
  3694. matchesPrefix(post) {
  3695. for (const tag of post.tagSet.values()) {
  3696. if (tag.startsWith(this.startsWithPrefix)) {
  3697. return !this.negated;
  3698. }
  3699.  
  3700. if (this.startsWithPrefix < tag) {
  3701. break;
  3702. }
  3703. }
  3704. return this.negated;
  3705. }
  3706.  
  3707. /**
  3708. * @param {Post} post
  3709. * @returns {Boolean}
  3710. */
  3711. matchesWildcard(post) {
  3712. for (const tag of post.tagSet.values()) {
  3713. if (this.matchRegex.test(tag)) {
  3714. return !this.negated;
  3715. }
  3716. }
  3717. return this.negated;
  3718. }
  3719. }
  3720.  
  3721. class MetadataSearchTag extends SearchTag {
  3722. static regex = /^-?(score|width|height|id)(:[<>]?)(\d+|score|width|height|id)$/;
  3723.  
  3724. /**
  3725. * @type {MetadataSearchExpression}
  3726. */
  3727. expression;
  3728.  
  3729. /**
  3730. * @type {Number}
  3731. */
  3732. get cost() {
  3733. return 0;
  3734. }
  3735.  
  3736. /**
  3737. * @param {String} searchTag
  3738. * @param {Boolean} inOrGroup
  3739. */
  3740. constructor(searchTag) {
  3741. super(searchTag);
  3742. this.expression = this.createExpression(searchTag);
  3743. }
  3744.  
  3745. /**
  3746. * @param {String} searchTag
  3747. * @returns {MetadataSearchExpression}
  3748. */
  3749. createExpression(searchTag) {
  3750. const extractedExpression = MetadataSearchTag.regex.exec(searchTag);
  3751. return new MetadataSearchExpression(
  3752. extractedExpression[1],
  3753. extractedExpression[2],
  3754. extractedExpression[3]
  3755. );
  3756. }
  3757.  
  3758. /**
  3759. * @param {Post} post
  3760. * @returns {Boolean}
  3761. */
  3762. matches(post) {
  3763. if (post.metadata.satisfiesExpression(this.expression)) {
  3764. return !this.negated;
  3765. }
  3766. return this.negated;
  3767. }
  3768. }
  3769.  
  3770. class SearchCommand {
  3771. /**
  3772. * @param {String} tag
  3773. * @returns {SearchTag}
  3774. */
  3775. static createSearchTag(tag) {
  3776. if (MetadataSearchTag.regex.test(tag)) {
  3777. return new MetadataSearchTag(tag);
  3778. }
  3779.  
  3780. if (tag.includes("*")) {
  3781. return new WildcardSearchTag(tag);
  3782. }
  3783. return new SearchTag(tag);
  3784. }
  3785.  
  3786. /**
  3787. * @param {String[]} tags
  3788. * @param {Boolean} isOrGroup
  3789. * @returns {SearchTag[]}
  3790. */
  3791. static createSearchTagGroup(tags) {
  3792. const uniqueTags = new Set();
  3793. const searchTags = [];
  3794.  
  3795. for (const tag of tags) {
  3796. if (!uniqueTags.has(tag)) {
  3797. uniqueTags.add(tag);
  3798. searchTags.push(SearchCommand.createSearchTag(tag));
  3799. }
  3800. }
  3801. return searchTags;
  3802. }
  3803.  
  3804. /**
  3805. * @param {SearchTag[]} searchTags
  3806. */
  3807. static sortByLeastExpensive(searchTags) {
  3808. searchTags.sort((a, b) => {
  3809. return a.finalCost - b.finalCost;
  3810. });
  3811. }
  3812.  
  3813. /**
  3814. * @type {SearchTag[][]}
  3815. */
  3816. orGroups;
  3817. /**
  3818. * @type {SearchTag[]}
  3819. */
  3820. remainingSearchTags;
  3821. /**
  3822. * @type {Boolean}
  3823. */
  3824. isEmpty;
  3825.  
  3826. /**
  3827. * @param {String} searchQuery
  3828. */
  3829. constructor(searchQuery) {
  3830. this.isEmpty = searchQuery.trim() === "";
  3831.  
  3832. if (this.isEmpty) {
  3833. return;
  3834. }
  3835. const {orGroups, remainingSearchTags} = Utils.extractTagGroups(searchQuery);
  3836.  
  3837. this.orGroups = orGroups.map(orGroup => SearchCommand.createSearchTagGroup(orGroup));
  3838. this.remainingSearchTags = SearchCommand.createSearchTagGroup(remainingSearchTags);
  3839. this.optimizeSearchCommand();
  3840. }
  3841.  
  3842. optimizeSearchCommand() {
  3843. for (const orGroup of this.orGroups) {
  3844. SearchCommand.sortByLeastExpensive(orGroup);
  3845. }
  3846. SearchCommand.sortByLeastExpensive(this.remainingSearchTags);
  3847. this.orGroups.sort((a, b) => {
  3848. return a.length - b.length;
  3849. });
  3850. }
  3851.  
  3852. /**
  3853. * @param {Post} post
  3854. * @returns {Boolean}
  3855. */
  3856. matches(post) {
  3857. if (this.isEmpty) {
  3858. return true;
  3859. }
  3860.  
  3861. if (!this.matchesAllRemainingSearchTags(post)) {
  3862. return false;
  3863. }
  3864. return this.matchesAllOrGroups(post);
  3865. }
  3866.  
  3867. /**
  3868. * @param {Post} post
  3869. * @returns {Boolean}
  3870. */
  3871. matchesAllRemainingSearchTags(post) {
  3872. for (const searchTag of this.remainingSearchTags) {
  3873. if (!searchTag.matches(post)) {
  3874. return false;
  3875. }
  3876. }
  3877. return true;
  3878. }
  3879.  
  3880. /**
  3881. * @param {Post} post
  3882. * @returns {Boolean}
  3883. */
  3884. matchesAllOrGroups(post) {
  3885. for (const orGroup of this.orGroups) {
  3886. if (!this.atLeastOnePostTagIsInOrGroup(orGroup, post)) {
  3887. return false;
  3888. }
  3889. }
  3890. return true;
  3891. }
  3892.  
  3893. /**
  3894. * @param {SearchTag[]} orGroup
  3895. * @param {Post} post
  3896. * @returns {Boolean}
  3897. */
  3898. atLeastOnePostTagIsInOrGroup(orGroup, post) {
  3899. for (const orTag of orGroup) {
  3900. if (orTag.matches(post)) {
  3901. return true;
  3902. }
  3903. }
  3904. return false;
  3905. }
  3906. }
  3907.  
  3908. class FavoritesPageRequest {
  3909. /**
  3910. * @type {Number}
  3911. */
  3912. pageNumber;
  3913. /**
  3914. * @type {Number}
  3915. */
  3916. retryCount;
  3917. /**
  3918. * @type {Post[]}
  3919. */
  3920. fetchedFavorites;
  3921.  
  3922. /**
  3923. * @type {String}
  3924. */
  3925. get url() {
  3926. return `${document.location.href}&pid=${this.pageNumber * 50}`;
  3927. }
  3928.  
  3929. /**
  3930. * @type {Number}
  3931. */
  3932. get retryDelay() {
  3933. return (7 ** (this.retryCount)) + 200;
  3934. }
  3935.  
  3936. /**
  3937. * @param {Number} pageNumber
  3938. */
  3939. constructor(pageNumber) {
  3940. this.pageNumber = pageNumber;
  3941. this.retryCount = 0;
  3942. this.fetchedFavorites = [];
  3943. }
  3944.  
  3945. onFail() {
  3946. this.retryCount += 1;
  3947. }
  3948. }
  3949.  
  3950. class FavoritesParser {
  3951. static parser = new DOMParser();
  3952.  
  3953. /**
  3954. * @param {String} favoritesPageHTML
  3955. * @returns {Post[]}
  3956. */
  3957. static extractFavorites(favoritesPageHTML) {
  3958. const elements = FavoritesParser.extractFavoriteElements(favoritesPageHTML);
  3959. return elements.map(element => new Post(element, false));
  3960. }
  3961.  
  3962. /**
  3963. * @param {String} favoritesPageHTML
  3964. * @returns {HTMLElement[]}
  3965. */
  3966. static extractFavoriteElements(favoritesPageHTML) {
  3967. const dom = FavoritesParser.parser.parseFromString(favoritesPageHTML, "text/html");
  3968. const thumbs = Array.from(dom.getElementsByClassName("thumb"));
  3969.  
  3970. if (thumbs.length > 0) {
  3971. return thumbs;
  3972. }
  3973. return Array.from(dom.getElementsByTagName("img"))
  3974. .filter(image => image.src.includes("thumbnail_"))
  3975. .map(image => image.parentElement);
  3976. }
  3977. }
  3978.  
  3979. class FetchedFavoritesQueue {
  3980. /**
  3981. * @type {FavoritesPageRequest[]}
  3982. */
  3983. queue;
  3984. /**
  3985. * @type {Function}
  3986. */
  3987. onDequeue;
  3988. /**
  3989. * @type {Number}
  3990. */
  3991. lastDequeuedPageNumber;
  3992. /**
  3993. * @type {Boolean}
  3994. */
  3995. dequeuing;
  3996.  
  3997. /**
  3998. * @type {Number}
  3999. */
  4000. get lowestEnqueuedPageNumber() {
  4001. return this.queue[0].pageNumber;
  4002. }
  4003.  
  4004. /**
  4005. * @type {Number}
  4006. */
  4007. get nextPageNumberToDequeue() {
  4008. return this.lastDequeuedPageNumber + 1;
  4009. }
  4010.  
  4011. /**
  4012. * @type {Boolean}
  4013. */
  4014. get allPreviousPagesWereDequeued() {
  4015. return this.nextPageNumberToDequeue === this.lowestEnqueuedPageNumber;
  4016. }
  4017.  
  4018. /**
  4019. * @type {Boolean}
  4020. */
  4021. get isEmpty() {
  4022. return this.queue.length === 0;
  4023. }
  4024.  
  4025. /**
  4026. * @type {Boolean}
  4027. */
  4028. get canDequeue() {
  4029. return !this.isEmpty && this.allPreviousPagesWereDequeued;
  4030. }
  4031.  
  4032. /**
  4033. * @param {Function}
  4034. */
  4035. constructor(onDequeue) {
  4036. this.onDequeue = onDequeue;
  4037. this.lastDequeuedPageNumber = -1;
  4038. this.queue = [];
  4039. }
  4040.  
  4041. /**
  4042. * @param {FavoritesPageRequest} request
  4043. */
  4044. enqueue(request) {
  4045. this.queue.push(request);
  4046. this.sortByLowestPageNumber();
  4047. this.dequeueAll();
  4048. }
  4049.  
  4050. sortByLowestPageNumber() {
  4051. this.queue.sort((request1, request2) => request1.pageNumber - request2.pageNumber);
  4052. }
  4053.  
  4054. dequeueAll() {
  4055. if (this.dequeuing) {
  4056. return;
  4057. }
  4058. this.dequeuing = true;
  4059.  
  4060. while (this.canDequeue) {
  4061. this.dequeue();
  4062. }
  4063. this.dequeuing = false;
  4064. }
  4065.  
  4066. dequeue() {
  4067. this.lastDequeuedPageNumber += 1;
  4068. this.onDequeue(this.queue.shift());
  4069. }
  4070. }
  4071.  
  4072. class FavoritesFetcher {
  4073. /**
  4074. * @type {Function}
  4075. */
  4076. onAllRequestsCompleted;
  4077. /**
  4078. * @type {Function}
  4079. */
  4080. onRequestCompleted;
  4081. /**
  4082. * @type {Set.<Number>}
  4083. */
  4084. pendingRequestPageNumbers;
  4085. /**
  4086. * @type {FavoritesPageRequest[]}
  4087. */
  4088. failedRequests;
  4089. /**
  4090. * @type {Set.<String>}
  4091. */
  4092. storedFavoriteIds;
  4093. /**
  4094. * @type {Number}
  4095. */
  4096. currentPageNumber;
  4097. /**
  4098. * @type {Boolean}
  4099. */
  4100. fetchedAnEmptyPage;
  4101.  
  4102. /**
  4103. * @type {Boolean}
  4104. */
  4105. get hasFailedRequests() {
  4106. return this.failedRequests.length > 0;
  4107. }
  4108.  
  4109. /**
  4110. * @type {Boolean}
  4111. */
  4112. get allRequestsHaveStarted() {
  4113. return this.fetchedAnEmptyPage;
  4114. }
  4115.  
  4116. /**
  4117. * @type {Boolean}
  4118. */
  4119. get someRequestsArePending() {
  4120. return this.pendingRequestPageNumbers.size > 0 || this.hasFailedRequests;
  4121. }
  4122.  
  4123. /**
  4124. * @type {Boolean}
  4125. */
  4126. get allRequestsHaveCompleted() {
  4127. return this.allRequestsHaveStarted && !this.someRequestsArePending;
  4128. }
  4129.  
  4130. /**
  4131. * @type {FavoritesPageRequest}
  4132. */
  4133. get oldestFailedFetchRequest() {
  4134. return this.failedRequests.shift();
  4135. }
  4136.  
  4137. /**
  4138. * @type {FavoritesPageRequest}
  4139. */
  4140. get newFetchRequest() {
  4141. const request = new FavoritesPageRequest(this.currentPageNumber);
  4142.  
  4143. this.pendingRequestPageNumbers.add(request.pageNumber);
  4144. this.currentPageNumber += 1;
  4145. return request;
  4146. }
  4147.  
  4148. /**
  4149. * @type {FavoritesPageRequest | null}
  4150. */
  4151. get nextFetchRequest() {
  4152. if (this.hasFailedRequests) {
  4153. return this.oldestFailedFetchRequest;
  4154. }
  4155.  
  4156. if (!this.allRequestsHaveStarted) {
  4157. return this.newFetchRequest;
  4158. }
  4159. return null;
  4160. }
  4161.  
  4162. /**
  4163. * @param {Function} onAllRequestsCompleted
  4164. * @param {Function} onRequestCompleted
  4165. */
  4166. constructor(onAllRequestsCompleted, onRequestCompleted) {
  4167. this.onAllRequestsCompleted = onAllRequestsCompleted;
  4168. this.onRequestCompleted = onRequestCompleted;
  4169. this.storedFavoriteIds = new Set();
  4170. this.pendingRequestPageNumbers = new Set();
  4171. this.failedRequests = [];
  4172. this.currentPageNumber = 0;
  4173. this.fetchedAnEmptyPage = false;
  4174. }
  4175.  
  4176. async fetchAllFavorites() {
  4177. while (!this.allRequestsHaveCompleted) {
  4178. await this.fetchFavoritesPage(this.nextFetchRequest);
  4179. }
  4180. this.onAllRequestsCompleted();
  4181. }
  4182.  
  4183. /**
  4184. * @param {Set.<String>} storedFavoriteIds
  4185. */
  4186. async fetchAllNewFavoritesOnReload(storedFavoriteIds) {
  4187. this.storedFavoriteIds = storedFavoriteIds;
  4188. let favorites = [];
  4189.  
  4190. while (true) {
  4191. const {allNewFavoritesFound, newFavorites} = await this.fetchNewFavoritesOnReload();
  4192.  
  4193. favorites = favorites.concat(newFavorites);
  4194.  
  4195. if (allNewFavoritesFound) {
  4196. this.storedFavoriteIds = null;
  4197. this.onAllRequestsCompleted(favorites);
  4198. return;
  4199. }
  4200. }
  4201. }
  4202.  
  4203. /**
  4204. * @returns {Promise.<{allNewFavoritesFound: Boolean, newFavorites: Post[]}>}
  4205. */
  4206. fetchNewFavoritesOnReload() {
  4207. return fetch(this.newFetchRequest.url)
  4208. .then((response) => {
  4209. return response.text();
  4210. })
  4211. .then((html) => {
  4212. return this.extractNewFavorites(html);
  4213. });
  4214. }
  4215.  
  4216. /**
  4217. * @param {String} html
  4218. * @returns {{allNewFavoritesFound: Boolean, newFavorites: Post[]}}
  4219. */
  4220. extractNewFavorites(html) {
  4221. const newFavorites = [];
  4222. const fetchedFavorites = FavoritesParser.extractFavorites(html);
  4223. let allNewFavoritesFound = fetchedFavorites.length === 0;
  4224.  
  4225. for (const favorite of fetchedFavorites) {
  4226. if (this.storedFavoriteIds.has(favorite.id)) {
  4227. allNewFavoritesFound = true;
  4228. break;
  4229. }
  4230. newFavorites.push(favorite);
  4231. }
  4232. return {
  4233. allNewFavoritesFound,
  4234. newFavorites
  4235. };
  4236. }
  4237.  
  4238. /**
  4239. * @param {FavoritesPageRequest} request
  4240. */
  4241. async fetchFavoritesPage(request) {
  4242. if (request === null) {
  4243. await Utils.sleep(200);
  4244. return;
  4245. }
  4246. fetch(request.url)
  4247. .then((response) => {
  4248. return this.onFavoritesPageRequestResponse(response);
  4249. })
  4250. .then((html) => {
  4251. this.onFavoritesPageRequestSuccess(request, html);
  4252. })
  4253. .catch((error) => {
  4254. this.onFavoritesPageRequestFail(request, error);
  4255. });
  4256. await Utils.sleep(request.retryDelay);
  4257. }
  4258.  
  4259. /**
  4260. * @param {Response} response
  4261. * @returns {Promise.<String>}
  4262. */
  4263. onFavoritesPageRequestResponse(response) {
  4264. if (response.ok) {
  4265. return response.text();
  4266. }
  4267. throw new Error(`${response.status}: Failed to fetch, ${response.url}`);
  4268. }
  4269.  
  4270. /**
  4271. * @param {FavoritesPageRequest} request
  4272. * @param {String} html
  4273. */
  4274. onFavoritesPageRequestSuccess(request, html) {
  4275. request.fetchedFavorites = FavoritesParser.extractFavorites(html);
  4276. this.pendingRequestPageNumbers.delete(request.pageNumber);
  4277. const favoritesPageIsEmpty = request.fetchedFavorites.length === 0;
  4278.  
  4279. this.fetchedAnEmptyPage = this.fetchedAnEmptyPage || favoritesPageIsEmpty;
  4280.  
  4281. if (!favoritesPageIsEmpty) {
  4282. this.onRequestCompleted(request);
  4283. }
  4284. }
  4285.  
  4286. /**
  4287. * @param {FavoritesPageRequest} request
  4288. * @param {Error} error
  4289. */
  4290. onFavoritesPageRequestFail(request, error) {
  4291. console.error(error);
  4292. request.onFail();
  4293. this.failedRequests.push(request);
  4294. }
  4295. }
  4296.  
  4297. class FavoritesPaginator {
  4298. /**
  4299. * @type {HTMLDivElement}
  4300. */
  4301. content;
  4302. /**
  4303. * @type {HTMLElement}
  4304. */
  4305. paginationMenu;
  4306. /**
  4307. * @type {HTMLLabelElement}
  4308. */
  4309. paginationLabel;
  4310. /**
  4311. * @type {Number}
  4312. */
  4313. currentPageNumber;
  4314. /**
  4315. * @type {Number}
  4316. */
  4317. maxFavoritesPerPage;
  4318. /**
  4319. * @type {Number}
  4320. */
  4321. maxPageNumberButtons;
  4322.  
  4323. constructor() {
  4324. this.content = this.createContentContainer();
  4325. this.paginationMenu = this.createPaginationMenuContainer();
  4326. this.currentPageNumber = 1;
  4327. this.favoritesPerPage = Utils.getPreference("resultsPerPage", Utils.defaults.resultsPerPage);
  4328. this.maxPageNumberButtons = Utils.onMobileDevice() ? 4 : 5;
  4329. }
  4330.  
  4331. /**
  4332. * @returns {HTMLDivElement}
  4333. */
  4334. createContentContainer() {
  4335. const content = document.createElement("div");
  4336.  
  4337. content.id = "favorites-search-gallery-content";
  4338. Utils.favoritesSearchGalleryContainer.appendChild(content);
  4339. return content;
  4340. }
  4341.  
  4342. /**
  4343. * @returns {HTMLDivElement}
  4344. */
  4345. createPaginationMenuContainer() {
  4346. const container = document.createElement("span");
  4347.  
  4348. container.id = "favorites-pagination-container";
  4349. return container;
  4350. }
  4351.  
  4352. insertPaginationMenuContainer() {
  4353. if (document.getElementById(this.paginationMenu.id) === null) {
  4354. const placeToInsertPagination = document.getElementById("favorites-pagination-placeholder");
  4355.  
  4356. placeToInsertPagination.insertAdjacentElement("afterend", this.paginationMenu);
  4357. placeToInsertPagination.remove();
  4358. }
  4359.  
  4360. }
  4361.  
  4362. /**
  4363. * @param {Post[]} favorites
  4364. */
  4365. paginate(favorites) {
  4366. this.insertPaginationMenuContainer();
  4367. this.changePage(1, favorites);
  4368. }
  4369.  
  4370. /**
  4371. * @param {Post[]} favorites
  4372. */
  4373. paginateWhileFetching(favorites) {
  4374. const pageNumberButtons = document.getElementsByClassName("pagination-number");
  4375. const lastPageButtonNumber = pageNumberButtons.length > 0 ? parseInt(pageNumberButtons[pageNumberButtons.length - 1].textContent) : 1;
  4376. const pageCount = this.getPageCount(favorites.length);
  4377. const needsToCreateNewPage = pageCount > lastPageButtonNumber;
  4378. const nextPageButton = document.getElementById("next-page");
  4379. const alreadyAtMaxPageNumberButtons = document.getElementsByClassName("pagination-number").length >= this.maxPageNumberButtons &&
  4380. nextPageButton !== null && nextPageButton.style.display !== "none" &&
  4381. nextPageButton.style.visibility !== "hidden" && !nextPageButton.disabled;
  4382.  
  4383. if (needsToCreateNewPage && !alreadyAtMaxPageNumberButtons) {
  4384. this.createPaginationMenu(this.currentPageNumber, favorites);
  4385. } else {
  4386. this.updateTraversalButtonEventListeners(favorites);
  4387. this.updatePageNumberButtonEventListeners(favorites);
  4388. }
  4389. const onLastPage = (pageCount === this.currentPageNumber);
  4390.  
  4391. if (!onLastPage) {
  4392. return;
  4393. }
  4394. const range = this.getPaginationRange(this.currentPageNumber);
  4395. const favoritesToAdd = favorites.slice(range.start, range.end)
  4396. .filter(favorite => document.getElementById(favorite.id) === null);
  4397.  
  4398. for (const favorite of favoritesToAdd) {
  4399. favorite.insertAtEndOfContent(this.content);
  4400. }
  4401. this.setPaginationLabel(this.currentPageNumber, favorites.length);
  4402. }
  4403.  
  4404. /**
  4405. * @param {Number} pageNumber
  4406. * @param {Post[]} favorites
  4407. */
  4408. changePage(pageNumber, favorites) {
  4409. this.currentPageNumber = pageNumber;
  4410. this.createPaginationMenu(pageNumber, favorites);
  4411. this.showFavorites(pageNumber, favorites);
  4412.  
  4413. if (FavoritesLoader.currentState !== FavoritesLoader.states.loadingFavoritesFromDatabase) {
  4414. dispatchEvent(new Event("changedPage"));
  4415. }
  4416.  
  4417. if (Utils.onMobileDevice()) {
  4418. this.paginationMenu.blur();
  4419. }
  4420. }
  4421.  
  4422. /**
  4423. * @param {Number} pageNumber
  4424. * @param {Post[]} favorites
  4425. */
  4426. createPaginationMenu(pageNumber, favorites) {
  4427. this.paginationMenu.innerHTML = "";
  4428. this.setPaginationLabel(pageNumber, favorites.length);
  4429. this.createPageNumberButtons(pageNumber, favorites);
  4430. this.createPageTraversalButtons(favorites);
  4431. this.createGotoSpecificPageInputs(favorites);
  4432. }
  4433.  
  4434. /**
  4435. * @param {Number} pageNumber
  4436. * @param {Number} favoriteCount
  4437. */
  4438. setPaginationLabel(pageNumber, favoriteCount) {
  4439. const range = this.getPaginationRange(pageNumber);
  4440. const start = range.start;
  4441. const end = Math.min(range.end, favoriteCount);
  4442.  
  4443. if (this.paginationLabel === undefined) {
  4444. this.paginationLabel = document.getElementById("pagination-label");
  4445. }
  4446.  
  4447. if (favoriteCount <= this.maxFavoritesPerPage || isNaN(start) || isNaN(end)) {
  4448. this.paginationLabel.textContent = "";
  4449. return;
  4450. }
  4451. this.paginationLabel.textContent = `${start + 1} - ${end}`;
  4452. }
  4453.  
  4454. /**
  4455. * @param {Number} pageNumber
  4456. * @returns {{start: Number, end: Number}}
  4457. */
  4458. getPaginationRange(pageNumber) {
  4459. return {
  4460. start: this.maxFavoritesPerPage * (pageNumber - 1),
  4461. end: this.maxFavoritesPerPage * pageNumber
  4462. };
  4463. }
  4464.  
  4465. /**
  4466. * @param {Number} favoriteCount
  4467. * @returns {Number}
  4468. */
  4469. getPageCount(favoriteCount) {
  4470. if (favoriteCount === 0) {
  4471. return 1;
  4472. }
  4473. const pageCount = favoriteCount / this.maxFavoritesPerPage;
  4474.  
  4475. if (favoriteCount % this.maxFavoritesPerPage === 0) {
  4476. return pageCount;
  4477. }
  4478. return Math.floor(pageCount) + 1;
  4479. }
  4480.  
  4481. /**
  4482. * @param {Number} pageNumber
  4483. * @param {Post[]} favorites
  4484. */
  4485. createPageNumberButtons(pageNumber, favorites) {
  4486. const pageCount = this.getPageCount(favorites.length);
  4487. let numberOfButtonsCreated = 0;
  4488.  
  4489. for (let i = pageNumber; i <= pageCount && numberOfButtonsCreated < this.maxPageNumberButtons; i += 1) {
  4490. numberOfButtonsCreated += 1;
  4491. this.createPageNumberButton(pageNumber, i, favorites);
  4492. }
  4493.  
  4494. if (numberOfButtonsCreated >= this.maxPageNumberButtons) {
  4495. return;
  4496. }
  4497.  
  4498. for (let j = pageNumber - 1; j >= 1 && numberOfButtonsCreated < this.maxPageNumberButtons; j -= 1) {
  4499. numberOfButtonsCreated += 1;
  4500. this.createPageNumberButton(pageNumber, j, favorites, "afterbegin");
  4501. }
  4502. }
  4503.  
  4504. /**
  4505. * @param {Number} currentPageNumber
  4506. * @param {Number} pageNumber
  4507. * @param {Post[]} favorites
  4508. * @param {InsertPosition} position
  4509. */
  4510. createPageNumberButton(currentPageNumber, pageNumber, favorites, position = "beforeend") {
  4511. const pageNumberButton = document.createElement("button");
  4512. const selected = currentPageNumber === pageNumber;
  4513.  
  4514. pageNumberButton.id = `favorites-page-${pageNumber}`;
  4515. pageNumberButton.title = `Goto page ${pageNumber}`;
  4516. pageNumberButton.className = "pagination-number";
  4517. pageNumberButton.classList.toggle("selected", selected);
  4518. pageNumberButton.onclick = () => {
  4519. this.changePage(pageNumber, favorites);
  4520. };
  4521. this.paginationMenu.insertAdjacentElement(position, pageNumberButton);
  4522. pageNumberButton.textContent = pageNumber;
  4523. }
  4524.  
  4525. /**
  4526. * @param {Post[]} favorites
  4527. */
  4528. updatePageNumberButtonEventListeners(favorites) {
  4529. const pageNumberButtons = document.getElementsByClassName("pagination-number");
  4530.  
  4531. for (const pageNumberButton of pageNumberButtons) {
  4532. const pageNumber = parseInt(Utils.removeNonNumericCharacters(pageNumberButton.id));
  4533.  
  4534. pageNumberButton.onclick = () => {
  4535. this.changePage(pageNumber, favorites);
  4536. };
  4537. }
  4538. }
  4539.  
  4540. /**
  4541. * @param {Post[]} favorites
  4542. */
  4543. createPageTraversalButtons(favorites) {
  4544. const pageCount = this.getPageCount(favorites.length);
  4545. const previousPage = document.createElement("button");
  4546. const firstPage = document.createElement("button");
  4547. const nextPage = document.createElement("button");
  4548. const finalPage = document.createElement("button");
  4549.  
  4550. previousPage.textContent = "<";
  4551. firstPage.textContent = "<<";
  4552. nextPage.textContent = ">";
  4553. finalPage.textContent = ">>";
  4554.  
  4555. previousPage.id = "previous-page";
  4556. firstPage.id = "first-page";
  4557. nextPage.id = "next-page";
  4558. finalPage.id = "final-page";
  4559.  
  4560. previousPage.title = "Goto previous page";
  4561. firstPage.title = "Goto first page";
  4562. nextPage.title = "Goto next page";
  4563. finalPage.title = "Goto last page";
  4564.  
  4565. previousPage.onclick = () => {
  4566. if (this.currentPageNumber - 1 >= 1) {
  4567. this.changePage(this.currentPageNumber - 1, favorites);
  4568. }
  4569. };
  4570. firstPage.onclick = () => {
  4571. this.changePage(1, favorites);
  4572. };
  4573. nextPage.onclick = () => {
  4574. if (this.currentPageNumber + 1 <= pageCount) {
  4575. this.changePage(this.currentPageNumber + 1, favorites);
  4576. }
  4577. };
  4578. finalPage.onclick = () => {
  4579. this.changePage(pageCount, favorites);
  4580. };
  4581. this.paginationMenu.insertAdjacentElement("afterbegin", previousPage);
  4582. this.paginationMenu.insertAdjacentElement("afterbegin", firstPage);
  4583. this.paginationMenu.appendChild(nextPage);
  4584. this.paginationMenu.appendChild(finalPage);
  4585.  
  4586. this.updateVisibilityOfPageTraversalButtons(previousPage, firstPage, nextPage, finalPage, this.getPageCount(favorites.length));
  4587. }
  4588.  
  4589. /**
  4590. * @param {Post[]} favorites
  4591. */
  4592. createGotoSpecificPageInputs(favorites) {
  4593. if (this.firstPageNumberButtonExists() && this.lastPageNumberButtonExists(this.getPageCount(favorites.length))) {
  4594. return;
  4595. }
  4596. const html = `
  4597. <input type="number" placeholder="#" style="width: 4em;" id="goto-page-input">
  4598. <button id="goto-page-button">Go</button>
  4599. `;
  4600. const container = document.createElement("span");
  4601.  
  4602. container.title = "Goto specific page";
  4603. container.innerHTML = html;
  4604. const input = container.children[0];
  4605. const button = container.children[1];
  4606.  
  4607. input.onkeydown = (event) => {
  4608. if (event.key === "Enter") {
  4609. button.click();
  4610. }
  4611. };
  4612. this.paginationMenu.appendChild(container);
  4613. this.updateTraversalButtonEventListeners(favorites);
  4614. }
  4615.  
  4616. /**
  4617. * @param {Post[]} favorites
  4618. */
  4619. updateTraversalButtonEventListeners(favorites) {
  4620. const gotoPageButton = document.getElementById("goto-page-button");
  4621. const finalPageButton = document.getElementById("final-page");
  4622. const input = document.getElementById("goto-page-input");
  4623. const pageCount = this.getPageCount(favorites.length);
  4624.  
  4625. if (gotoPageButton === null || finalPageButton === null || input === null) {
  4626. return;
  4627. }
  4628.  
  4629. gotoPageButton.onclick = () => {
  4630. let pageNumber = parseInt(input.value);
  4631.  
  4632. if (!Utils.isNumber(pageNumber)) {
  4633. return;
  4634. }
  4635. pageNumber = Utils.clamp(pageNumber, 1, pageCount);
  4636. this.changePage(pageNumber, favorites);
  4637.  
  4638. };
  4639. finalPageButton.onclick = () => {
  4640. this.changePage(pageCount, favorites);
  4641. };
  4642. }
  4643.  
  4644. /**
  4645. * @param {Number} pageNumber
  4646. * @param {Post[]} favorites
  4647. */
  4648. showFavorites(pageNumber, favorites) {
  4649. const {start, end} = this.getPaginationRange(pageNumber);
  4650. const newContent = document.createDocumentFragment();
  4651.  
  4652. for (const favorite of favorites.slice(start, end)) {
  4653. favorite.insertAtEndOfContent(newContent);
  4654. }
  4655. this.content.innerHTML = "";
  4656. this.content.appendChild(newContent);
  4657. window.scrollTo(0, Utils.onMobileDevice() ? 10 : 0);
  4658. }
  4659.  
  4660. /**
  4661. * @returns {Boolean}
  4662. */
  4663. firstPageNumberButtonExists() {
  4664. return document.getElementById("favorites-page-1") !== null;
  4665. }
  4666.  
  4667. /**
  4668. * @param {Number} pageCount
  4669. * @returns {Boolean}
  4670. */
  4671. lastPageNumberButtonExists(pageCount) {
  4672. return document.getElementById(`favorites-page-${pageCount}`) !== null;
  4673. }
  4674.  
  4675. /**
  4676. * @param {HTMLButtonElement} previousPage
  4677. * @param {HTMLButtonElement} firstPage
  4678. * @param {HTMLButtonElement} nextPage
  4679. * @param {HTMLButtonElement} finalPage
  4680. * @param {Number} pageCount
  4681. */
  4682. updateVisibilityOfPageTraversalButtons(previousPage, firstPage, nextPage, finalPage, pageCount) {
  4683. const firstNumberExists = this.firstPageNumberButtonExists();
  4684. const lastNumberExists = this.lastPageNumberButtonExists(pageCount);
  4685.  
  4686. if (firstNumberExists && lastNumberExists) {
  4687. previousPage.disabled = true;
  4688. firstPage.disabled = true;
  4689. nextPage.disabled = true;
  4690. finalPage.disabled = true;
  4691. } else {
  4692. if (firstNumberExists) {
  4693. previousPage.disabled = true;
  4694. firstPage.disabled = true;
  4695. }
  4696.  
  4697. if (lastNumberExists) {
  4698. nextPage.disabled = true;
  4699. finalPage.disabled = true;
  4700. }
  4701. }
  4702. }
  4703.  
  4704. /**
  4705. * @param {String} direction
  4706. * @param {Post[]} favorites
  4707. */
  4708. changePageWhileInGallery(direction, favorites) {
  4709. const pageCount = this.getPageCount(favorites.length);
  4710. const onLastPage = this.currentPageNumber === pageCount;
  4711. const onFirstPage = this.currentPageNumber === 1;
  4712. const onlyOnePage = onFirstPage && onLastPage;
  4713.  
  4714. if (onlyOnePage) {
  4715. dispatchEvent(new CustomEvent("didNotChangePageInGallery", {
  4716. detail: direction
  4717. }));
  4718. return;
  4719. }
  4720.  
  4721. if (onLastPage && direction === "ArrowRight") {
  4722. this.changePage(1, favorites);
  4723. return;
  4724. }
  4725.  
  4726. if (onFirstPage && direction === "ArrowLeft") {
  4727. this.changePage(pageCount, favorites);
  4728. return;
  4729. }
  4730. const newPageNumber = direction === "ArrowRight" ? this.currentPageNumber + 1 : this.currentPageNumber - 1;
  4731.  
  4732. this.changePage(newPageNumber, favorites);
  4733. }
  4734.  
  4735. /**
  4736. * @param {Boolean} value
  4737. */
  4738. toggleContentVisibility(value) {
  4739. this.content.style.display = value ? "" : "none";
  4740. }
  4741.  
  4742. /**
  4743. * @param {Post} favorite
  4744. */
  4745. insertNewFavorite(favorite) {
  4746. favorite.insertAtBeginningOfContent(this.content);
  4747. }
  4748.  
  4749. /**
  4750. * @param {Number} id
  4751. * @param {Post[]} favorites
  4752. */
  4753. async findFavorite(id, favorites) {
  4754. const favoriteIds = favorites.map(favorite => favorite.id);
  4755. const index = favoriteIds.indexOf(id);
  4756. const favoriteNotFound = index === -1;
  4757.  
  4758. if (favoriteNotFound) {
  4759. return;
  4760. }
  4761. const pageNumber = Math.floor(index / this.favoritesPerPage) + 1;
  4762.  
  4763. dispatchEvent(new CustomEvent("foundFavorite", {
  4764. detail: id
  4765. }));
  4766.  
  4767. if (this.currentPageNumber !== pageNumber) {
  4768. this.changePage(pageNumber, favorites);
  4769. }
  4770.  
  4771. await Utils.sleep(150);
  4772. Utils.scrollToThumb(id, false, false);
  4773. await Utils.sleep(50);
  4774. Utils.scrollToThumb(id, false, false);
  4775. const thumb = document.getElementById(id);
  4776.  
  4777. if (thumb === null || thumb.classList.contains("blink")) {
  4778. return;
  4779. }
  4780. thumb.classList.add("blink");
  4781. await Utils.sleep(1500);
  4782. thumb.classList.remove("blink");
  4783. }
  4784. }
  4785.  
  4786. class FavoritesSearchFlags {
  4787. /**
  4788. * @type {Boolean}
  4789. */
  4790. searchResultsAreShuffled;
  4791. /**
  4792. * @type {Boolean}
  4793. */
  4794. searchResultsAreInverted;
  4795. /**
  4796. * @type {Boolean}
  4797. */
  4798. searchResultsWereShuffled;
  4799. /**
  4800. * @type {Boolean}
  4801. */
  4802. searchResultsWereInverted;
  4803. /**
  4804. * @type {Boolean}
  4805. */
  4806. recentlyChangedResultsPerPage;
  4807. /**
  4808. * @type {Boolean}
  4809. */
  4810. tagsWereModified;
  4811. /**
  4812. * @type {Boolean}
  4813. */
  4814. excludeBlacklistWasClicked;
  4815. /**
  4816. * @type {Boolean}
  4817. */
  4818. sortingParametersWereChanged;
  4819. /**
  4820. * @type {Boolean}
  4821. */
  4822. allowedRatingsWereChanged;
  4823. /**
  4824. * @type {String}
  4825. */
  4826. searchQuery;
  4827. /**
  4828. * @type {String}
  4829. */
  4830. previousSearchQuery;
  4831.  
  4832. /**
  4833. * @type {Boolean}
  4834. */
  4835. get onFirstPage() {
  4836. const firstPageNumberButton = document.getElementById("favorites-page-1");
  4837. return firstPageNumberButton !== null && firstPageNumberButton.classList.contains("selected");
  4838. }
  4839.  
  4840. /**
  4841. * @type {Boolean}
  4842. */
  4843. get notOnFirstPage() {
  4844. return !this.onFirstPage;
  4845. }
  4846.  
  4847. /**
  4848. * @type {Boolean}
  4849. */
  4850. get aNewSearchCouldProduceDifferentResults() {
  4851. return this.searchQuery !== this.previousSearchQuery ||
  4852. FavoritesLoader.currentState !== FavoritesLoader.states.allFavoritesLoaded ||
  4853. this.searchResultsAreShuffled ||
  4854. this.searchResultsAreInverted ||
  4855. this.searchResultsWereShuffled ||
  4856. this.searchResultsWereInverted ||
  4857. this.recentlyChangedResultsPerPage ||
  4858. this.tagsWereModified ||
  4859. this.excludeBlacklistWasClicked ||
  4860. this.sortingParametersWereChanged ||
  4861. this.allowedRatingsWereChanged ||
  4862. this.notOnFirstPage;
  4863. }
  4864.  
  4865. constructor() {
  4866. this.searchResultsAreShuffled = false;
  4867. this.searchResultsAreInverted = false;
  4868. this.searchResultsWereShuffled = false;
  4869. this.searchResultsWereInverted = false;
  4870. this.recentlyChangedResultsPerPage = false;
  4871. this.tagsWereModified = false;
  4872. this.excludeBlacklistWasClicked = false;
  4873. this.sortingParametersWereChanged = false;
  4874. this.allowedRatingsWereChanged = false;
  4875. this.searchQuery = "";
  4876. this.previousSearchQuery = "";
  4877. }
  4878.  
  4879. resetFlagsImplyingDifferentSearchResults() {
  4880. this.searchResultsWereShuffled = this.searchResultsAreShuffled;
  4881. this.searchResultsWereInverted = this.searchResultsAreInverted;
  4882. this.tagsWereModified = false;
  4883. this.excludeBlacklistWasClicked = false;
  4884. this.sortingParametersWereChanged = false;
  4885. this.allowedRatingsWereChanged = false;
  4886. this.searchResultsAreShuffled = false;
  4887. this.searchResultsAreInverted = false;
  4888. this.recentlyChangedResultsPerPage = false;
  4889. this.previousSearchQuery = this.searchQuery;
  4890. }
  4891. }
  4892.  
  4893. class FavoritesDatabaseWrapper {
  4894. static databaseName = "Favorites";
  4895. static objectStoreName = `user${Utils.getFavoritesPageId()}`;
  4896. static webWorkers = {
  4897. database:
  4898. `
  4899. /* eslint-disable prefer-template */
  4900. /**
  4901. * @param {Number} milliseconds
  4902. * @returns {Promise}
  4903. */
  4904. function sleep(milliseconds) {
  4905. return new Promise(resolve => setTimeout(resolve, milliseconds));
  4906. }
  4907.  
  4908. class FavoritesDatabase {
  4909. /**
  4910. * @type {String}
  4911. */
  4912. name = "Favorites";
  4913. /**
  4914. * @type {String}
  4915. */
  4916. objectStoreName;
  4917. /**
  4918. * @type {Number}
  4919. */
  4920. version;
  4921.  
  4922. /**
  4923. * @param {String} objectStoreName
  4924. * @param {Number | String} version
  4925. */
  4926. constructor(objectStoreName, version) {
  4927. this.objectStoreName = objectStoreName;
  4928. this.version = version;
  4929. }
  4930.  
  4931. createObjectStore() {
  4932. return this.openConnection((event) => {
  4933. /**
  4934. * @type {IDBDatabase}
  4935. */
  4936. const database = event.target.result;
  4937. const objectStore = database
  4938. .createObjectStore(this.objectStoreName, {
  4939. autoIncrement: true
  4940. });
  4941.  
  4942. objectStore.createIndex("id", "id", {
  4943. unique: true
  4944. });
  4945. }).then((event) => {
  4946. event.target.result.close();
  4947. });
  4948. }
  4949.  
  4950. /**
  4951. * @param {Function} onUpgradeNeeded
  4952. * @returns {Promise}
  4953. */
  4954. openConnection(onUpgradeNeeded) {
  4955. return new Promise((resolve, reject) => {
  4956. const request = indexedDB.open(this.name, this.version);
  4957.  
  4958. request.onsuccess = resolve;
  4959. request.onerror = reject;
  4960. request.onupgradeneeded = onUpgradeNeeded;
  4961. });
  4962. }
  4963.  
  4964. /**
  4965. * @param {[{id: String, tags: String, src: String, metadata: String}]} favorites
  4966. */
  4967. storeFavorites(favorites) {
  4968. this.openConnection()
  4969. .then((connectionEvent) => {
  4970. /**
  4971. * @type {IDBDatabase}
  4972. */
  4973. const database = connectionEvent.target.result;
  4974. const transaction = database.transaction(this.objectStoreName, "readwrite");
  4975. const objectStore = transaction.objectStore(this.objectStoreName);
  4976.  
  4977. transaction.oncomplete = () => {
  4978. postMessage({
  4979. response: "finishedStoring"
  4980. });
  4981. database.close();
  4982. };
  4983.  
  4984. transaction.onerror = (event) => {
  4985. console.error(event);
  4986. };
  4987.  
  4988. favorites.forEach(favorite => {
  4989. this.addContentTypeToFavorite(favorite);
  4990. objectStore.put(favorite);
  4991. });
  4992.  
  4993. })
  4994. .catch((event) => {
  4995. const error = event.target.error;
  4996.  
  4997. if (error.name === "VersionError") {
  4998. this.version += 1;
  4999. this.storeFavorites(favorites);
  5000. } else {
  5001. console.error(error);
  5002. }
  5003. });
  5004. }
  5005.  
  5006. /**
  5007. * @param {String[]} idsToDelete
  5008. */
  5009. loadFavorites(idsToDelete) {
  5010. let loadedFavorites = {};
  5011. let database;
  5012.  
  5013. this.openConnection()
  5014. .then(async(connectionEvent) => {
  5015. /**
  5016. * @type {IDBDatabase}
  5017. */
  5018. database = connectionEvent.target.result;
  5019. const transaction = database.transaction(this.objectStoreName, "readwrite");
  5020. const objectStore = transaction.objectStore(this.objectStoreName);
  5021. const index = objectStore.index("id");
  5022.  
  5023. transaction.onerror = (event) => {
  5024. console.error(event);
  5025. };
  5026. transaction.oncomplete = () => {
  5027. postMessage({
  5028. response: "finishedLoading",
  5029. favorites: loadedFavorites
  5030. });
  5031. database.close();
  5032. };
  5033.  
  5034. for (const id of idsToDelete) {
  5035. const deleteRequest = index.getKey(id);
  5036.  
  5037. await new Promise((resolve, reject) => {
  5038. deleteRequest.onsuccess = resolve;
  5039. deleteRequest.onerror = reject;
  5040. }).then((indexEvent) => {
  5041. const primaryKey = indexEvent.target.result;
  5042.  
  5043. if (primaryKey !== undefined) {
  5044. objectStore.delete(primaryKey);
  5045. }
  5046. }).catch((error) => {
  5047. console.error(error);
  5048. });
  5049. }
  5050. const getAllRequest = objectStore.getAll();
  5051.  
  5052. getAllRequest.onsuccess = (event) => {
  5053. loadedFavorites = event.target.result.reverse();
  5054. };
  5055. getAllRequest.onerror = (event) => {
  5056. console.error(event);
  5057. };
  5058. }).catch(async(error) => {
  5059. this.version += 1;
  5060.  
  5061. if (error.name === "NotFoundError") {
  5062. database.close();
  5063. await this.createObjectStore();
  5064. }
  5065. this.loadFavorites(idsToDelete);
  5066. });
  5067. }
  5068.  
  5069. /**
  5070. * @param {[{id: String, tags: String, src: String, metadata: String}]} favorites
  5071. */
  5072. updateFavorites(favorites) {
  5073. this.openConnection()
  5074. .then((event) => {
  5075. /**
  5076. * @type {IDBDatabase}
  5077. */
  5078. const database = event.target.result;
  5079. const favoritesObjectStore = database
  5080. .transaction(this.objectStoreName, "readwrite")
  5081. .objectStore(this.objectStoreName);
  5082. const objectStoreIndex = favoritesObjectStore.index("id");
  5083. let updatedCount = 0;
  5084.  
  5085. favorites.forEach(favorite => {
  5086. const index = objectStoreIndex.getKey(favorite.id);
  5087.  
  5088. this.addContentTypeToFavorite(favorite);
  5089. index.onsuccess = (indexEvent) => {
  5090. const primaryKey = indexEvent.target.result;
  5091.  
  5092. favoritesObjectStore.put(favorite, primaryKey);
  5093. updatedCount += 1;
  5094.  
  5095. if (updatedCount >= favorites.length) {
  5096. database.close();
  5097. }
  5098. };
  5099. });
  5100. })
  5101. .catch((event) => {
  5102. const error = event.target.error;
  5103.  
  5104. if (error.name === "VersionError") {
  5105. this.version += 1;
  5106. this.updateFavorites(favorites);
  5107. } else {
  5108. console.error(error);
  5109. }
  5110. });
  5111. }
  5112.  
  5113. /**
  5114. * @param {{id: String, tags: String, src: String, metadata: String}} favorite
  5115. */
  5116. addContentTypeToFavorite(favorite) {
  5117. const tags = favorite.tags + " ";
  5118. const isAnimated = tags.includes("animated ") || tags.includes("video ");
  5119. const isGif = isAnimated && !tags.includes("video ");
  5120.  
  5121. favorite.type = isGif ? "gif" : isAnimated ? "video" : "image";
  5122. }
  5123. }
  5124.  
  5125. /**
  5126. * @type {FavoritesDatabase}
  5127. */
  5128. const favoritesDatabase = new FavoritesDatabase(null, 1);
  5129.  
  5130. onmessage = (message) => {
  5131. const request = message.data;
  5132.  
  5133. switch (request.command) {
  5134. case "create":
  5135. favoritesDatabase.objectStoreName = request.objectStoreName;
  5136. favoritesDatabase.version = request.version;
  5137. break;
  5138.  
  5139. case "store":
  5140. favoritesDatabase.storeFavorites(request.favorites);
  5141. break;
  5142.  
  5143. case "load":
  5144. favoritesDatabase.loadFavorites(request.idsToDelete);
  5145. break;
  5146.  
  5147. case "update":
  5148. favoritesDatabase.updateFavorites(request.favorites);
  5149. break;
  5150.  
  5151. default:
  5152. break;
  5153. }
  5154. };
  5155.  
  5156. `
  5157. };
  5158.  
  5159. /**
  5160. * @type {Function}
  5161. */
  5162. onFavoritesStored;
  5163. /**
  5164. * @type {Function}
  5165. */
  5166. onFavoritesLoaded;
  5167. /**
  5168. * @type {Worker}
  5169. */
  5170. databaseWorker;
  5171. /**
  5172. * @type {String[]}
  5173. */
  5174. favoriteIdsRequiringMetadataDatabaseUpdate;
  5175. /**
  5176. * @type {Number}
  5177. */
  5178. newMetadataReceivedTimeout;
  5179.  
  5180. /**
  5181. * @param {Function} onFavoritesStored
  5182. * @param {Function} onFavoritesLoaded
  5183. */
  5184. constructor(onFavoritesStored, onFavoritesLoaded) {
  5185. this.onFavoritesStored = onFavoritesStored;
  5186. this.onFavoritesLoaded = onFavoritesLoaded;
  5187. this.favoriteIdsRequiringMetadataDatabaseUpdate = [];
  5188. this.addEventListeners();
  5189. this.initializeDatabase();
  5190. }
  5191.  
  5192. addEventListeners() {
  5193. window.addEventListener("missingMetadata", (event) => {
  5194. this.addNewMetadata(event.detail);
  5195. });
  5196. }
  5197.  
  5198. initializeDatabase() {
  5199. this.databaseWorker = new Worker(Utils.getWorkerURL(FavoritesDatabaseWrapper.webWorkers.database));
  5200. this.databaseWorker.onmessage = (message) => {
  5201. switch (message.data.response) {
  5202. case "finishedLoading":
  5203. this.onFavoritesLoaded(message.data.favorites);
  5204. break;
  5205.  
  5206. case "finishedStoring":
  5207. this.onFavoritesStored();
  5208. break;
  5209.  
  5210. default:
  5211. break;
  5212. }
  5213. };
  5214. this.databaseWorker.postMessage({
  5215. command: "create",
  5216. objectStoreName: FavoritesDatabaseWrapper.objectStoreName,
  5217. version: 1
  5218. });
  5219. }
  5220.  
  5221. /**
  5222. * @returns {String[]}
  5223. */
  5224. getIdsToDeleteOnReload() {
  5225. if (Utils.userIsOnTheirOwnFavoritesPage()) {
  5226. const idsToDelete = Utils.getIdsToDeleteOnReload();
  5227.  
  5228. Utils.clearIdsToDeleteOnReload();
  5229. return idsToDelete;
  5230. }
  5231. return [];
  5232. }
  5233.  
  5234. /**
  5235. * @param {Post[]} favorites
  5236. */
  5237. storeAllFavorites(favorites) {
  5238. this.storeFavorites(favorites.slice().reverse());
  5239. }
  5240.  
  5241. /**
  5242. * @param {Post[]} favorites
  5243. */
  5244. async storeFavorites(favorites) {
  5245. await Utils.sleep(500);
  5246.  
  5247. this.databaseWorker.postMessage({
  5248. command: "store",
  5249. favorites: favorites.map(post => post.databaseRecord)
  5250. });
  5251. }
  5252.  
  5253. loadAllFavorites() {
  5254. this.databaseWorker.postMessage({
  5255. command: "load",
  5256. idsToDelete: this.getIdsToDeleteOnReload()
  5257. });
  5258. }
  5259.  
  5260. /**
  5261. * @param {String} postId
  5262. */
  5263. addNewMetadata(postId) {
  5264. if (!Post.allPosts.has(postId)) {
  5265. return;
  5266. }
  5267. const batchSize = 500;
  5268. const waitTime = 1000;
  5269.  
  5270. clearTimeout(this.newMetadataReceivedTimeout);
  5271. this.favoriteIdsRequiringMetadataDatabaseUpdate.push(postId);
  5272.  
  5273. if (this.favoriteIdsRequiringMetadataDatabaseUpdate.length >= batchSize) {
  5274. this.updateMetadataInDatabase();
  5275. return;
  5276. }
  5277. this.newMetadataReceivedTimeout = setTimeout(() => {
  5278. this.updateMetadataInDatabase();
  5279. }, waitTime);
  5280. }
  5281.  
  5282. updateMetadataInDatabase() {
  5283. this.updateFavorites(this.favoriteIdsRequiringMetadataDatabaseUpdate.map(id => Post.allPosts.get(id)));
  5284. this.favoriteIdsRequiringMetadataDatabaseUpdate = [];
  5285. }
  5286.  
  5287. /**
  5288. * @param {Post[]} posts
  5289. */
  5290. updateFavorites(posts) {
  5291. this.databaseWorker.postMessage({
  5292. command: "update",
  5293. favorites: posts.map(post => post.databaseRecord)
  5294. });
  5295. }
  5296. }
  5297.  
  5298. class FavoritesLoader {
  5299. static states = {
  5300. initial: 0,
  5301. fetchingFavorites: 1,
  5302. loadingFavoritesFromDatabase: 2,
  5303. allFavoritesLoaded: 3
  5304. };
  5305. static currentState = FavoritesLoader.states.initial;
  5306. static tagNegation = {
  5307. useTagBlacklist: true,
  5308. negatedTagBlacklist: Utils.negateTags(Utils.tagBlacklist)
  5309. };
  5310.  
  5311. static get disabled() {
  5312. return !Utils.onFavoritesPage();
  5313. }
  5314.  
  5315. /**
  5316. * @type {Post[]}
  5317. */
  5318. allFavorites;
  5319. /**
  5320. * @type {Post[]}
  5321. */
  5322. latestSearchResults;
  5323. /**
  5324. * @type {HTMLLabelElement}
  5325. */
  5326. matchCountLabel;
  5327. /**
  5328. * @type {Number}
  5329. */
  5330. searchResultCount;
  5331. /**
  5332. * @type {Number | null}
  5333. */
  5334. expectedTotalFavoritesCount;
  5335. /**
  5336. * @type {String}
  5337. */
  5338. searchQuery;
  5339. /**
  5340. * @type {Post[]}
  5341. */
  5342. searchResultsWhileFetching;
  5343. /**
  5344. * @type {Number}
  5345. */
  5346. allowedRatings;
  5347. /**
  5348. * @type {FavoritesFetcher}
  5349. */
  5350. fetcher;
  5351. /**
  5352. * @type {FetchedFavoritesQueue}
  5353. */
  5354. fetchedQueue;
  5355. /**
  5356. * @type {FavoritesPaginator}
  5357. */
  5358. paginator;
  5359. /**
  5360. * @type {FavoritesSearchFlags}
  5361. */
  5362. searchFlags;
  5363. /**
  5364. * @type {FavoritesDatabaseWrapper}
  5365. */
  5366. database;
  5367.  
  5368. /**
  5369. * @type {String}
  5370. */
  5371. get finalSearchQuery() {
  5372. if (FavoritesLoader.tagNegation.useTagBlacklist) {
  5373. return `${this.searchQuery} ${FavoritesLoader.tagNegation.negatedTagBlacklist}`;
  5374. }
  5375. return this.searchQuery;
  5376. }
  5377.  
  5378. /**
  5379. * @type {Boolean}
  5380. */
  5381. get matchCountLabelExists() {
  5382. if (this.matchCountLabel === null || !document.contains(this.matchCountLabel)) {
  5383. this.matchCountLabel = document.getElementById("match-count-label");
  5384.  
  5385. if (this.matchCountLabel === null) {
  5386. return false;
  5387. }
  5388. }
  5389. return true;
  5390. }
  5391.  
  5392. /**
  5393. * @type {Set.<String>}
  5394. */
  5395. get allFavoriteIds() {
  5396. return new Set(Array.from(this.allFavorites.values()).map(post => post.id));
  5397. }
  5398.  
  5399. /**
  5400. * @type {Post[]}
  5401. */
  5402. get getFavoritesMatchedByLastSearch() {
  5403. return this.allFavorites.filter(post => post.matchedByMostRecentSearch);
  5404. }
  5405.  
  5406. constructor() {
  5407. if (FavoritesLoader.disabled) {
  5408. return;
  5409. }
  5410. this.initializeFields();
  5411. this.initializeComponents();
  5412. this.addEventListeners();
  5413. this.setExpectedFavoritesCount();
  5414. Utils.clearOriginalFavoritesPage();
  5415. this.searchFavorites();
  5416. }
  5417.  
  5418. initializeFields() {
  5419. this.allFavorites = [];
  5420. this.latestSearchResults = [];
  5421. this.searchResultsWhileFetching = [];
  5422. this.matchCountLabel = document.getElementById("match-count-label");
  5423. this.allowedRatings = Utils.loadAllowedRatings();
  5424. this.expectedTotalFavoritesCount = null;
  5425. this.searchResultCount = 0;
  5426. this.searchQuery = "";
  5427. }
  5428.  
  5429. initializeComponents() {
  5430. this.fetchedQueue = new FetchedFavoritesQueue((request) => {
  5431. this.processFetchedFavorites(request.fetchedFavorites);
  5432. });
  5433. this.fetcher = new FavoritesFetcher(() => {
  5434. this.onAllFavoritesFetched();
  5435. }, (request) => {
  5436. this.fetchedQueue.enqueue(request);
  5437. });
  5438. this.paginator = new FavoritesPaginator();
  5439. this.searchFlags = new FavoritesSearchFlags();
  5440. this.database = new FavoritesDatabaseWrapper(() => {
  5441. this.onFavoritesStoredToDatabase();
  5442. }, (favorites) => {
  5443. this.onAllFavoritesLoadedFromDatabase(favorites);
  5444. });
  5445. }
  5446.  
  5447. addEventListeners() {
  5448. window.addEventListener("modifiedTags", () => {
  5449. this.searchFlags.tagsWereModified = true;
  5450. });
  5451. window.addEventListener("reachedEndOfGallery", (event) => {
  5452. this.paginator.changePageWhileInGallery(event.detail, this.latestSearchResults);
  5453. });
  5454. }
  5455.  
  5456. setExpectedFavoritesCount() {
  5457. const profileURL = `https://rule34.xxx/index.php?page=account&s=profile&id=${Utils.getFavoritesPageId()}`;
  5458.  
  5459. fetch(profileURL)
  5460. .then((response) => {
  5461. if (response.ok) {
  5462. return response.text();
  5463. }
  5464. throw new Error(response.status);
  5465. })
  5466. .then((html) => {
  5467. const favoritesURL = Array.from(new DOMParser().parseFromString(html, "text/html").querySelectorAll("a"))
  5468. .find(a => a.href.includes("page=favorites&s=view"));
  5469. const favoritesCount = parseInt(favoritesURL.textContent);
  5470.  
  5471. this.expectedTotalFavoritesCount = Math.max(favoritesCount - 2, 0);
  5472. })
  5473. .catch(() => {
  5474. console.error(`Could not find total favorites count from ${profileURL}, are you logged in?`);
  5475. });
  5476. }
  5477.  
  5478. /**
  5479. * @param {String} searchQuery
  5480. */
  5481. searchFavorites(searchQuery) {
  5482. this.setSearchQuery(searchQuery);
  5483. dispatchEvent(new Event("searchStarted"));
  5484. this.showSearchResults();
  5485. }
  5486.  
  5487. /**
  5488. * @param {String} searchQuery
  5489. */
  5490. setSearchQuery(searchQuery) {
  5491. if (searchQuery !== undefined) {
  5492. this.searchQuery = searchQuery;
  5493. this.searchFlags.searchQuery = searchQuery;
  5494. }
  5495. }
  5496.  
  5497. showSearchResults() {
  5498. switch (FavoritesLoader.currentState) {
  5499. case FavoritesLoader.states.initial:
  5500. this.loadAllFavoritesFromDatabase();
  5501. break;
  5502.  
  5503. case FavoritesLoader.states.fetchingFavorites:
  5504. this.showSearchResultsWhileFetchingFavorites();
  5505. break;
  5506.  
  5507. case FavoritesLoader.states.loadingFavoritesFromDatabase:
  5508. break;
  5509.  
  5510. case FavoritesLoader.states.allFavoritesLoaded:
  5511. this.showSearchResultsAfterAllFavoritesLoaded();
  5512. break;
  5513.  
  5514. default:
  5515. console.error(`Invalid FavoritesLoader state: ${FavoritesLoader.currentState}`);
  5516. break;
  5517. }
  5518. }
  5519.  
  5520. showSearchResultsWhileFetchingFavorites() {
  5521. this.searchResultsWhileFetching = this.getSearchResults(this.allFavorites);
  5522. this.paginateSearchResults(this.searchResultsWhileFetching);
  5523. }
  5524.  
  5525. showSearchResultsAfterAllFavoritesLoaded() {
  5526. this.paginateSearchResults(this.getSearchResults(this.allFavorites));
  5527. }
  5528.  
  5529. /**
  5530. * @param {Post[]} posts
  5531. * @returns {Post[]}
  5532. */
  5533. getSearchResults(posts) {
  5534. const searchCommand = new SearchCommand(this.finalSearchQuery);
  5535. const results = [];
  5536.  
  5537. for (const post of posts) {
  5538. if (searchCommand.matches(post)) {
  5539. results.push(post);
  5540. post.setMatched(true);
  5541. } else {
  5542. post.setMatched(false);
  5543. }
  5544. }
  5545. return results;
  5546. }
  5547.  
  5548. fetchNewFavoritesOnReload() {
  5549. this.fetcher.onAllRequestsCompleted = (newFavorites) => {
  5550. this.addNewFavoritesOnReload(newFavorites);
  5551. };
  5552. this.fetcher.fetchAllNewFavoritesOnReload(this.allFavoriteIds);
  5553. }
  5554.  
  5555. /**
  5556. * @param {Post[]} newFavorites
  5557. */
  5558. addNewFavoritesOnReload(newFavorites) {
  5559. this.allFavorites = newFavorites.concat(this.allFavorites);
  5560. this.latestSearchResults = newFavorites.concat(this.latestSearchResults);
  5561.  
  5562. if (newFavorites.length === 0) {
  5563. dispatchEvent(new CustomEvent("newFavoritesFetchedOnReload", {
  5564. detail: {
  5565. empty: true,
  5566. thumbs: []
  5567. }
  5568. }));
  5569. this.toggleStatusText(false);
  5570. return;
  5571. }
  5572. this.setStatusText(`Found ${newFavorites.length} new favorite${newFavorites.length === 1 ? "" : "s"}`);
  5573. this.toggleStatusText(false, 1000);
  5574. this.database.storeFavorites(newFavorites);
  5575. this.insertNewFavorites(newFavorites);
  5576. }
  5577.  
  5578. fetchAllFavorites() {
  5579. FavoritesLoader.currentState = FavoritesLoader.states.fetchingFavorites;
  5580. this.paginator.toggleContentVisibility(true);
  5581. this.paginator.insertPaginationMenuContainer();
  5582. this.paginator.createPaginationMenu(1, []);
  5583. this.fetcher.fetchAllFavorites();
  5584. dispatchEvent(new Event("readyToSearch"));
  5585. setTimeout(() => {
  5586. dispatchEvent(new Event("startedFetchingFavorites"));
  5587. }, 50);
  5588. }
  5589.  
  5590. updateStatusWhileFetching() {
  5591. const prefix = Utils.onMobileDevice() ? "" : "Favorites ";
  5592. let statusText = `Fetching ${prefix}${this.allFavorites.length}`;
  5593.  
  5594. if (this.expectedTotalFavoritesCount !== null) {
  5595. statusText = `${statusText} / ${this.expectedTotalFavoritesCount}`;
  5596. }
  5597. this.setStatusText(statusText);
  5598. }
  5599.  
  5600. /**
  5601. * @param {Post[]} favorites
  5602. */
  5603. processFetchedFavorites(favorites) {
  5604. const matchedFavorites = this.getSearchResults(favorites);
  5605.  
  5606. this.searchResultsWhileFetching = this.searchResultsWhileFetching.concat(matchedFavorites);
  5607. const searchResultsWhileFetchingWithAllowedRatings = this.getResultsWithAllowedRatings(this.searchResultsWhileFetching);
  5608.  
  5609. this.updateMatchCount(searchResultsWhileFetchingWithAllowedRatings.length);
  5610. this.allFavorites = this.allFavorites.concat(favorites);
  5611. this.addFetchedFavoritesToContent(searchResultsWhileFetchingWithAllowedRatings);
  5612. this.updateStatusWhileFetching();
  5613. dispatchEvent(new CustomEvent("favoritesFetched", {
  5614. detail: favorites.map(post => post.root)
  5615. }));
  5616. }
  5617.  
  5618. invertSearchResults() {
  5619. this.resetMatchCount();
  5620. this.allFavorites.forEach((post) => {
  5621. post.toggleMatched();
  5622. });
  5623. const invertedSearchResults = this.getFavoritesMatchedByLastSearch;
  5624.  
  5625. this.searchFlags.searchResultsAreInverted = true;
  5626. this.paginateSearchResults(invertedSearchResults);
  5627. window.scrollTo(0, 0);
  5628. }
  5629.  
  5630. shuffleSearchResults() {
  5631. const matchedPosts = this.getFavoritesMatchedByLastSearch;
  5632.  
  5633. Utils.shuffleArray(matchedPosts);
  5634. this.searchFlags.searchResultsAreShuffled = true;
  5635. this.paginateSearchResults(matchedPosts);
  5636. }
  5637.  
  5638. onAllFavoritesFetched() {
  5639. this.latestSearchResults = this.getResultsWithAllowedRatings(this.searchResultsWhileFetching);
  5640. dispatchEvent(new CustomEvent("newSearchResults", {
  5641. detail: this.latestSearchResults
  5642. }));
  5643. this.onAllFavoritesLoaded();
  5644. this.database.storeAllFavorites(this.allFavorites);
  5645. this.setStatusText("Saving favorites");
  5646. }
  5647.  
  5648. /**
  5649. * @param {Object[]} records
  5650. */
  5651. onAllFavoritesLoadedFromDatabase(records) {
  5652. this.toggleLoadingUI(false);
  5653.  
  5654. if (records.length === 0) {
  5655. this.fetchAllFavorites();
  5656. return;
  5657. }
  5658. this.setStatusText("All favorites loaded");
  5659. this.paginateSearchResults(this.deserializeFavorites(records));
  5660. dispatchEvent(new Event("favoritesLoadedFromDatabase"));
  5661. this.onAllFavoritesLoaded();
  5662. setTimeout(() => {
  5663. this.fetchNewFavoritesOnReload();
  5664. }, 100);
  5665. }
  5666.  
  5667. onFavoritesStoredToDatabase() {
  5668. this.setStatusText("All favorites saved");
  5669. this.toggleStatusText(false, 1000);
  5670. }
  5671.  
  5672. onAllFavoritesLoaded() {
  5673. dispatchEvent(new Event("readyToSearch"));
  5674. dispatchEvent(new Event("favoritesLoaded"));
  5675. FavoritesLoader.currentState = FavoritesLoader.states.allFavoritesLoaded;
  5676. }
  5677.  
  5678. /**
  5679. * @param {Boolean} value
  5680. */
  5681. toggleLoadingUI(value) {
  5682. this.showLoadingWheel(value);
  5683. this.paginator.toggleContentVisibility(!value);
  5684. }
  5685.  
  5686. /**
  5687. * @param {Object[]} records
  5688. * @returns {Post[]}}
  5689. */
  5690. deserializeFavorites(records) {
  5691. const searchCommand = new SearchCommand(this.finalSearchQuery);
  5692. const searchResults = [];
  5693.  
  5694. for (const record of records) {
  5695. const post = new Post(record, true);
  5696. const isBlacklisted = !searchCommand.matches(post);
  5697.  
  5698. if (isBlacklisted) {
  5699. if (!Utils.userIsOnTheirOwnFavoritesPage()) {
  5700. continue;
  5701. }
  5702. post.setMatched(false);
  5703. } else {
  5704. searchResults.push(post);
  5705. }
  5706. this.allFavorites.push(post);
  5707. }
  5708. return searchResults;
  5709. }
  5710.  
  5711. loadAllFavoritesFromDatabase() {
  5712. FavoritesLoader.currentState = FavoritesLoader.states.loadingFavoritesFromDatabase;
  5713. this.toggleLoadingUI(true);
  5714. this.setStatusText("Loading favorites");
  5715. this.database.loadAllFavorites();
  5716. }
  5717.  
  5718. /**
  5719. * @param {Boolean} value
  5720. */
  5721. showLoadingWheel(value) {
  5722. document.getElementById("loading-wheel").style.display = value ? "flex" : "none";
  5723. }
  5724.  
  5725. /**
  5726. * @param {Boolean} value
  5727. * @param {Number} delay
  5728. */
  5729. async toggleStatusText(value, delay) {
  5730. if (delay !== undefined && delay > 0) {
  5731. await Utils.sleep(delay);
  5732. }
  5733. document.getElementById("favorites-load-status-label").style.display = value ? "inline-block" : "none";
  5734. }
  5735.  
  5736. /**
  5737. * @param {String} text
  5738. * @param {Number} delay
  5739. */
  5740. async setStatusText(text, delay) {
  5741. if (delay !== undefined && delay > 0) {
  5742. await Utils.sleep(delay);
  5743. }
  5744. document.getElementById("favorites-load-status-label").textContent = text;
  5745. }
  5746.  
  5747. resetMatchCount() {
  5748. this.updateMatchCount(0);
  5749. }
  5750.  
  5751. /**
  5752. * @param {Number} value
  5753. */
  5754. updateMatchCount(value) {
  5755. if (!this.matchCountLabelExists) {
  5756. return;
  5757. }
  5758. this.searchResultCount = value === undefined ? this.getSearchResults(this.allFavorites).length : value;
  5759. const suffix = this.searchResultCount === 1 ? "Match" : "Matches";
  5760.  
  5761. this.matchCountLabel.textContent = `${this.searchResultCount} ${suffix}`;
  5762. }
  5763.  
  5764. /**
  5765. * @param {Number} value
  5766. */
  5767. incrementMatchCount(value) {
  5768. if (!this.matchCountLabelExists) {
  5769. return;
  5770. }
  5771. this.searchResultCount += value === undefined ? 1 : value;
  5772. this.matchCountLabel.textContent = `${this.searchResultCount} Matches`;
  5773. }
  5774.  
  5775. /**
  5776. * @param {Post[]} newPosts
  5777. */
  5778. async insertNewFavorites(newPosts) {
  5779. const searchCommand = new SearchCommand(this.finalSearchQuery);
  5780. const insertedPosts = [];
  5781. const metadataPopulateWaitTime = 1000;
  5782.  
  5783. newPosts.reverse();
  5784.  
  5785. if (this.allowedRatings !== 7) {
  5786. await Utils.sleep(metadataPopulateWaitTime);
  5787. }
  5788.  
  5789. for (const post of newPosts) {
  5790. if (this.matchesSearchAndRating(searchCommand, post)) {
  5791. this.paginator.insertNewFavorite(post);
  5792. insertedPosts.push(post);
  5793. }
  5794. }
  5795. this.paginator.createPaginationMenu(this.paginator.currentPageNumber, this.getFavoritesMatchedByLastSearch);
  5796. setTimeout(() => {
  5797. dispatchEvent(new CustomEvent("newFavoritesFetchedOnReload", {
  5798. detail: {
  5799. empty: false,
  5800. thumbs: insertedPosts.map(post => post.root)
  5801. }
  5802. }));
  5803. }, 250);
  5804. dispatchEvent(new CustomEvent("newSearchResults", {
  5805. detail: this.latestSearchResults
  5806. }));
  5807. }
  5808.  
  5809. /**
  5810. * @param {Post[]} favorites
  5811. */
  5812. addFetchedFavoritesToContent(favorites) {
  5813. this.paginator.paginateWhileFetching(favorites);
  5814. }
  5815.  
  5816. /**
  5817. * @param {Post[]} searchResults
  5818. */
  5819. paginateSearchResults(searchResults) {
  5820. if (!this.searchFlags.aNewSearchCouldProduceDifferentResults) {
  5821. return;
  5822. }
  5823. searchResults = this.sortPosts(searchResults);
  5824. searchResults = this.getResultsWithAllowedRatings(searchResults);
  5825. this.latestSearchResults = searchResults;
  5826. this.updateMatchCount(searchResults.length);
  5827. this.paginator.paginate(searchResults);
  5828. this.searchFlags.resetFlagsImplyingDifferentSearchResults();
  5829. dispatchEvent(new CustomEvent("newSearchResults", {
  5830. detail: searchResults
  5831. }));
  5832. }
  5833.  
  5834. /**
  5835. * @param {Boolean} value
  5836. */
  5837. toggleTagBlacklistExclusion(value) {
  5838. FavoritesLoader.tagNegation.useTagBlacklist = value;
  5839. this.searchFlags.excludeBlacklistWasClicked = true;
  5840. }
  5841.  
  5842. /**
  5843. * @param {Number} value
  5844. */
  5845. updateResultsPerPage(value) {
  5846. this.paginator.maxFavoritesPerPage = value;
  5847. this.searchFlags.recentlyChangedResultsPerPage = true;
  5848. this.searchFavorites();
  5849. }
  5850.  
  5851. /**
  5852. * @param {Post[]} posts
  5853. * @returns {Post[]}
  5854. */
  5855. sortPosts(posts) {
  5856. if (this.searchFlags.searchResultsAreShuffled) {
  5857. return posts;
  5858. }
  5859. const sortedPosts = posts.slice();
  5860. const sortingMethod = Utils.getSortingMethod();
  5861.  
  5862. if (sortingMethod === "random") {
  5863. return Utils.shuffleArray(sortedPosts);
  5864. }
  5865.  
  5866. if (sortingMethod !== "default") {
  5867. sortedPosts.sort((b, a) => {
  5868. switch (sortingMethod) {
  5869. case "score":
  5870. return a.metadata.score - b.metadata.score;
  5871.  
  5872. case "width":
  5873. return a.metadata.width - b.metadata.width;
  5874.  
  5875. case "height":
  5876. return a.metadata.height - b.metadata.height;
  5877.  
  5878. case "create":
  5879. return a.metadata.creationTimestamp - b.metadata.creationTimestamp;
  5880.  
  5881. case "change":
  5882. return a.metadata.lastChangedTimestamp - b.metadata.lastChangedTimestamp;
  5883.  
  5884. case "id":
  5885. return a.metadata.id - b.metadata.id;
  5886.  
  5887. default:
  5888. return 0;
  5889. }
  5890. });
  5891. }
  5892.  
  5893. if (this.sortAscending()) {
  5894. sortedPosts.reverse();
  5895. }
  5896. return sortedPosts;
  5897. }
  5898.  
  5899. /**
  5900. * @returns {Boolean}
  5901. */
  5902. sortAscending() {
  5903. const sortFavoritesAscending = document.getElementById("sort-ascending");
  5904. return sortFavoritesAscending === null ? false : sortFavoritesAscending.checked;
  5905. }
  5906.  
  5907. onSortingParametersChanged() {
  5908. this.searchFlags.sortingParametersWereChanged = true;
  5909. const matchedPosts = this.getFavoritesMatchedByLastSearch;
  5910.  
  5911. this.paginateSearchResults(matchedPosts);
  5912. dispatchEvent(new Event("sortingParametersChanged"));
  5913. }
  5914.  
  5915. /**
  5916. * @param {Number} allowedRatings
  5917. */
  5918. onAllowedRatingsChanged(allowedRatings) {
  5919. this.allowedRatings = allowedRatings;
  5920. this.searchFlags.allowedRatingsWereChanged = true;
  5921. const matchedPosts = this.getFavoritesMatchedByLastSearch;
  5922.  
  5923. this.paginateSearchResults(matchedPosts);
  5924. }
  5925.  
  5926. /**
  5927. * @returns {Boolean}
  5928. */
  5929. allRatingsAreAllowed() {
  5930. return this.allowedRatings === 7;
  5931. }
  5932.  
  5933. /**
  5934. * @param {Post} post
  5935. * @returns {Boolean}
  5936. */
  5937. ratingIsAllowed(post) {
  5938. if (this.allRatingsAreAllowed()) {
  5939. return true;
  5940. }
  5941. // eslint-disable-next-line no-bitwise
  5942. return (post.metadata.rating & this.allowedRatings) > 0;
  5943. }
  5944.  
  5945. /**
  5946. * @param {Post[]} searchResults
  5947. * @returns {Post[]}
  5948. */
  5949. getResultsWithAllowedRatings(searchResults) {
  5950. if (this.allRatingsAreAllowed()) {
  5951. return searchResults;
  5952. }
  5953. return searchResults.filter(post => this.ratingIsAllowed(post));
  5954. }
  5955.  
  5956. /**
  5957. * @param {SearchCommand} searchCommand
  5958. * @param {Post} post
  5959. * @returns {Boolean}
  5960. */
  5961. matchesSearchAndRating(searchCommand, post) {
  5962. return this.ratingIsAllowed(post) && searchCommand.matches(post);
  5963. }
  5964.  
  5965. /**
  5966. * @param {String} id
  5967. */
  5968. findFavorite(id) {
  5969. this.paginator.findFavorite(id, this.latestSearchResults);
  5970. }
  5971. }
  5972.  
  5973. class FavoritesMenu {
  5974. static uiHTML = `
  5975. <div id="favorites-search-gallery-menu" class="light-green-gradient not-highlightable">
  5976. <style>
  5977. #favorites-search-gallery-menu {
  5978. position: sticky;
  5979. top: 0;
  5980. padding: 10px;
  5981. z-index: 30;
  5982. margin-bottom: 10px;
  5983.  
  5984. input::-webkit-outer-spin-button,
  5985. input::-webkit-inner-spin-button {
  5986. -webkit-appearance: none;
  5987. appearance: none;
  5988. margin: 0;
  5989. }
  5990. }
  5991.  
  5992. #favorites-search-gallery-menu-panels {
  5993. >div {
  5994. flex: 1;
  5995. }
  5996. }
  5997.  
  5998. #left-favorites-panel {
  5999. flex: 10 !important;
  6000.  
  6001. >div:first-of-type {
  6002. margin-bottom: 5px;
  6003.  
  6004. >label {
  6005. align-content: center;
  6006. margin-right: 5px;
  6007. margin-top: 4px;
  6008. }
  6009.  
  6010. >button {
  6011. height: 35px;
  6012. border: none;
  6013. border-radius: 4px;
  6014.  
  6015. &:hover {
  6016. filter: brightness(140%);
  6017. }
  6018. }
  6019.  
  6020. >button[disabled] {
  6021. filter: none !important;
  6022. cursor: wait !important;
  6023. }
  6024. }
  6025. }
  6026.  
  6027. #right-favorites-panel {
  6028. flex: 9 !important;
  6029. margin-left: 30px;
  6030. display: none;
  6031. }
  6032.  
  6033. textarea {
  6034. max-width: 100%;
  6035. height: 50px;
  6036. width: 99%;
  6037. padding: 10px;
  6038. border-radius: 6px;
  6039. resize: vertical;
  6040. }
  6041.  
  6042. button,
  6043. input[type="checkbox"] {
  6044. cursor: pointer;
  6045. }
  6046.  
  6047. .checkbox {
  6048. display: block;
  6049. padding: 2px 6px 2px 0px;
  6050. border-radius: 4px;
  6051. margin-left: -3px;
  6052. height: 27px;
  6053.  
  6054. >input {
  6055. vertical-align: -5px;
  6056. }
  6057. }
  6058.  
  6059. .loading-wheel {
  6060. border: 16px solid #f3f3f3;
  6061. border-top: 16px solid #3498db;
  6062. border-radius: 50%;
  6063. width: 120px;
  6064. height: 120px;
  6065. animation: spin 1s ease-in-out infinite;
  6066. pointer-events: none;
  6067. z-index: 9990;
  6068. position: fixed;
  6069. max-height: 100vh;
  6070. margin: 0;
  6071. top: 50%;
  6072. left: 50%;
  6073. transform: translate(-50%, -50%);
  6074. }
  6075.  
  6076. @keyframes spin {
  6077. 0% {
  6078. transform: rotate(0deg);
  6079. }
  6080.  
  6081. 100% {
  6082. transform: rotate(360deg);
  6083. }
  6084. }
  6085.  
  6086. .add-or-remove-button {
  6087. position: absolute;
  6088. left: 0;
  6089. top: 0;
  6090. width: 40%;
  6091. font-weight: bold;
  6092. background: none;
  6093. border: none;
  6094. z-index: 2;
  6095. filter: grayscale(70%);
  6096.  
  6097. &:active,
  6098. &:hover {
  6099. filter: none !important;
  6100. }
  6101. }
  6102.  
  6103. .remove-favorite-button {
  6104. color: red;
  6105. }
  6106.  
  6107. .add-favorite-button {
  6108. >svg {
  6109. fill: hotpink;
  6110. }
  6111. }
  6112.  
  6113. .statistic-hint {
  6114. position: absolute;
  6115. z-index: 3;
  6116. text-align: center;
  6117. right: 0;
  6118. top: 0;
  6119. background: white;
  6120. color: #0075FF;
  6121. font-weight: bold;
  6122. /* font-size: 18px; */
  6123. pointer-events: none;
  6124. font-size: calc(8px + (20 - 8) * ((100vw - 300px) / (3840 - 300)));
  6125. width: 55%;
  6126. padding: 2px 0px;
  6127. border-bottom-left-radius: 4px;
  6128. }
  6129.  
  6130. img {
  6131. -webkit-user-drag: none;
  6132. -khtml-user-drag: none;
  6133. -moz-user-drag: none;
  6134. -o-user-drag: none;
  6135. }
  6136.  
  6137. .favorite {
  6138. position: relative;
  6139. -webkit-touch-callout: none;
  6140. -webkit-user-select: none;
  6141. -khtml-user-select: none;
  6142. -moz-user-select: none;
  6143. -ms-user-select: none;
  6144. user-select: none;
  6145.  
  6146. >a,
  6147. >div {
  6148. display: block;
  6149. overflow: hidden;
  6150. position: relative;
  6151. cursor: default;
  6152.  
  6153. >img:first-child {
  6154. width: 100%;
  6155. z-index: 1;
  6156. }
  6157.  
  6158. >a>div {
  6159. height: 100%;
  6160. }
  6161.  
  6162. >canvas {
  6163. width: 100%;
  6164. position: absolute;
  6165. top: 0;
  6166. left: 0;
  6167. pointer-events: none;
  6168. z-index: 1;
  6169. }
  6170. }
  6171.  
  6172. &.hidden {
  6173. display: none;
  6174. }
  6175. }
  6176.  
  6177. .found {
  6178. opacity: 1;
  6179. animation: wiggle 2s;
  6180. }
  6181.  
  6182. @keyframes wiggle {
  6183.  
  6184. 10%,
  6185. 90% {
  6186. transform: translate3d(-2px, 0, 0);
  6187. }
  6188.  
  6189. 20%,
  6190. 80% {
  6191. transform: translate3d(4px, 0, 0);
  6192. }
  6193.  
  6194. 30%,
  6195. 50%,
  6196. 70% {
  6197. transform: translate3d(-8px, 0, 0);
  6198. }
  6199.  
  6200. 40%,
  6201. 60% {
  6202. transform: translate3d(8px, 0, 0);
  6203. }
  6204. }
  6205.  
  6206. #column-resize-container {
  6207. >div {
  6208. align-content: center;
  6209. }
  6210. }
  6211.  
  6212. #find-favorite {
  6213. display: none;
  6214. margin-top: 7px;
  6215.  
  6216. >input {
  6217. width: 75px;
  6218. /* border-radius: 6px;
  6219. height: 35px;
  6220. border: 1px solid; */
  6221. }
  6222. }
  6223.  
  6224. #favorites-pagination-container {
  6225. padding: 0px 10px 0px 10px;
  6226.  
  6227. >button {
  6228. background: transparent;
  6229. margin: 0px 2px;
  6230. padding: 2px 6px;
  6231. border: 1px solid black;
  6232. font-size: 14px;
  6233. color: black;
  6234. font-weight: normal;
  6235.  
  6236. &:hover {
  6237. background-color: #93b393;
  6238. }
  6239.  
  6240. &.selected {
  6241. border: none !important;
  6242. font-weight: bold;
  6243. pointer-events: none;
  6244. }
  6245. }
  6246. }
  6247.  
  6248. #favorites-search-gallery-content {
  6249. padding: 0px 20px 30px 20px;
  6250. display: grid !important;
  6251. grid-template-columns: repeat(10, 1fr);
  6252. grid-gap: 0.5cqw;
  6253. }
  6254.  
  6255. #help-links-container {
  6256. >a:not(:last-child)::after {
  6257. content: " |";
  6258. }
  6259. margin-top: 17px;
  6260. }
  6261.  
  6262. #whats-new-link {
  6263. cursor: pointer;
  6264. padding: 0;
  6265. position: relative;
  6266. font-weight: bolder;
  6267. font-style: italic;
  6268. background: none;
  6269. text-decoration: none !important;
  6270.  
  6271. &.hidden:not(.persistent)>div {
  6272. display: none;
  6273. }
  6274.  
  6275. &.persistent,
  6276. &:hover {
  6277. &.light-green-gradient {
  6278. color: black;
  6279. }
  6280.  
  6281. &:not(.light-green-gradient) {
  6282. color: white;
  6283. }
  6284. }
  6285. }
  6286.  
  6287. #whats-new-container {
  6288. z-index: 10;
  6289. top: 20px;
  6290. right: 0;
  6291. transform: translateX(25%);
  6292. font-style: normal;
  6293. font-weight: normal;
  6294. white-space: nowrap;
  6295. max-width: 100vw;
  6296. padding: 5px 20px;
  6297. position: absolute;
  6298. pointer-events: none;
  6299. text-shadow: none;
  6300. border-radius: 2px;
  6301.  
  6302. &.light-green-gradient {
  6303. outline: 2px solid black;
  6304.  
  6305. }
  6306.  
  6307. &:not(.light-green-gradient) {
  6308. outline: 1.5px solid white;
  6309. }
  6310.  
  6311. ul {
  6312. padding-left: 20px;
  6313. }
  6314.  
  6315. h5,
  6316. h6 {
  6317. color: rgb(255, 0, 255);
  6318. }
  6319. }
  6320.  
  6321. .hotkey {
  6322. font-weight: bolder;
  6323. color: orange;
  6324. }
  6325.  
  6326. #left-favorites-panel-bottom-row {
  6327. display: flex;
  6328. margin-top: 10px;
  6329. flex-wrap: nowrap;
  6330.  
  6331. >div {
  6332. flex: 1;
  6333. }
  6334.  
  6335. .number {
  6336. font-size: 16px;
  6337.  
  6338. >input {
  6339. width: 5ch;
  6340. }
  6341. }
  6342. }
  6343.  
  6344. #additional-favorite-options {
  6345. >div:not(:last-child) {
  6346. margin-bottom: 10px;
  6347. }
  6348.  
  6349. select {
  6350. cursor: pointer;
  6351. min-height: 25px;
  6352. width: 150px;
  6353. }
  6354. }
  6355.  
  6356. .number-label-container {
  6357. display: inline-block;
  6358. min-width: 130px;
  6359. }
  6360.  
  6361. #show-ui-div {
  6362. &.ui-hidden {
  6363. max-width: 100vw;
  6364. text-align: center;
  6365. align-content: center;
  6366. }
  6367. }
  6368.  
  6369. #rating-container {
  6370. white-space: nowrap;
  6371. }
  6372.  
  6373. #allowed-ratings {
  6374. margin-top: 5px;
  6375. font-size: 12px;
  6376.  
  6377. >label {
  6378. outline: 1px solid;
  6379. padding: 3px;
  6380. cursor: pointer;
  6381. opacity: 0.5;
  6382. position: relative;
  6383. }
  6384.  
  6385. >label[for="explicit-rating-checkbox"] {
  6386. border-radius: 7px 0px 0px 7px;
  6387. }
  6388.  
  6389. >label[for="questionable-rating-checkbox"] {
  6390. margin-left: -3px;
  6391. }
  6392.  
  6393. >label[for="safe-rating-checkbox"] {
  6394. margin-left: -3px;
  6395. border-radius: 0px 7px 7px 0px;
  6396. }
  6397.  
  6398. >input[type="checkbox"] {
  6399. display: none;
  6400.  
  6401. &:checked+label {
  6402. background-color: #0075FF;
  6403. color: white;
  6404. opacity: 1;
  6405. }
  6406. }
  6407. }
  6408.  
  6409. .add-or-remove-button {
  6410. visibility: hidden;
  6411. cursor: pointer;
  6412. }
  6413.  
  6414. #favorites-load-status {
  6415. >label {
  6416. width: 140px;
  6417. }
  6418. }
  6419.  
  6420. #favorites-load-status-label {
  6421. /* color: #3498db; */
  6422. padding-left: 20px;
  6423. }
  6424.  
  6425. #main-favorite-options-container {
  6426. display: flex;
  6427. flex-wrap: wrap;
  6428. flex-direction: row;
  6429.  
  6430. >div {
  6431. flex-basis: 45%;
  6432. }
  6433. }
  6434.  
  6435. #sort-ascending {
  6436. position: absolute;
  6437. top: -2px;
  6438. left: 150px;
  6439. }
  6440.  
  6441. #find-favorite-input {
  6442. border: none !important;
  6443. }
  6444.  
  6445. div#header {
  6446. margin-bottom: 0 !important;
  6447. }
  6448.  
  6449. body {
  6450.  
  6451. &:fullscreen,
  6452. &::backdrop {
  6453. background-color: var(--c-bg);
  6454. }
  6455. }
  6456. </style>
  6457. <div id="favorites-search-gallery-menu-panels" style="display: flex;">
  6458. <div id="left-favorites-panel">
  6459. <h2 style="display: inline;" id="search-header">Search Favorites</h2>
  6460. <span id="favorites-load-status" style="margin-left: 5px;">
  6461. <label id="match-count-label"></label>
  6462. <label id="pagination-label" style="margin-left: 10px;"></label>
  6463. <label id="favorites-load-status-label"></label>
  6464. </span>
  6465. <div id="left-favorites-panel-top-row">
  6466. <button title="Search favorites
  6467. ctrl+click/right-click: Search all of rule34 in a new tab"
  6468. id="search-button">Search</button>
  6469. <button title="Randomize order of search results" id="shuffle-button">Shuffle</button>
  6470. <button title="Show results not matched by search" id="invert-button">Invert</button>
  6471. <button title="Empty the search box" id="clear-button">Clear</button>
  6472. <button title="Delete cached favorites and reset preferences" id="reset-button">Reset</button>
  6473. <span id="favorites-pagination-placeholder"></span>
  6474. <span id="help-links-container">
  6475. <a href="https://github.com/bruh3396/favorites-search-gallery/#controls" target="_blank">Help</a>
  6476. <a href="https://sleazyfork.org/en/scripts/504184-rule34-favorites-search-gallery/feedback"
  6477. target="_blank">Feedback</a>
  6478. <a href="https://github.com/bruh3396/favorites-search-gallery/issues" target="_blank">Report
  6479. Issue</a>
  6480. <a id="whats-new-link" href="" class="hidden light-green-gradient">What's new?
  6481.  
  6482. <div id="whats-new-container" class="light-green-gradient">
  6483. <h4>1.18:</h4>
  6484. <h5>Features:</h5>
  6485. <ul>
  6486. <li>Improved/fixed mobile UI</li>
  6487. <li>Improved mobile controls</li>
  6488. <li>Added gallery autoplay for mobile</li>
  6489. <li>Added sort by radom (auto shuffle)</li>
  6490. <li>Added dark theme option</li>
  6491. <li>Minor UI fixes</li>
  6492. <li>Minor gallery fixes</li>
  6493. </ul>
  6494. </div>
  6495. </a>
  6496. </span>
  6497. </div>
  6498. <div>
  6499. <textarea name="tags" id="favorites-search-box" placeholder="Search favorites"
  6500. spellcheck="false"></textarea>
  6501. </div>
  6502. <div id="left-favorites-panel-bottom-row">
  6503. <div id="bottom-panel-1">
  6504. <label class="checkbox" title="Show more options">
  6505. <input type="checkbox" id="options-checkbox">
  6506. <span id="more-options-label"> More Options</span>
  6507. <span class="option-hint"> (O)</span>
  6508. </label>
  6509. <div class="options-container">
  6510. <div id="main-favorite-options-container">
  6511. <div id="favorite-options">
  6512. <div>
  6513. <label class="checkbox" title="Enable gallery and other features on search pages">
  6514. <input type="checkbox" id="enable-on-search-pages">
  6515. <span> Enhance Search Pages</span>
  6516. </label>
  6517. </div>
  6518. <div style="display: none;">
  6519. <label class="checkbox" title="Toggle remove buttons">
  6520. <input type="checkbox" id="show-remove-favorite-buttons">
  6521. <span> Remove Buttons</span>
  6522. <span class="option-hint"> (R)</span>
  6523. </label>
  6524. </div>
  6525. <div style="display: none;">
  6526. <label class="checkbox" title="Toggle add favorite buttons">
  6527. <input type="checkbox" id="show-add-favorite-buttons">
  6528. <span> Add Favorite Buttons</span>
  6529. <span class="option-hint"> (R)</span>
  6530. </label>
  6531. </div>
  6532. <div>
  6533. <label class="checkbox" title="Exclude favorites with blacklisted tags from search">
  6534. <input type="checkbox" id="filter-blacklist-checkbox">
  6535. <span> Exclude Blacklist</span>
  6536. </label>
  6537. </div>
  6538. <div>
  6539. <label class="checkbox" title="Enable fancy image hovering (experimental)">
  6540. <input type="checkbox" id="fancy-image-hovering-checkbox">
  6541. <span> Fancy Hovering</span>
  6542. </label>
  6543. </div>
  6544. <div style="display: none;">
  6545. <label class="checkbox" title="Enable fancy image hovering (experimental)">
  6546. <input type="checkbox" id="statistic-hint-checkbox">
  6547. <span> Statistics</span>
  6548. <span class="option-hint"> (S)</span>
  6549. </label>
  6550. </div>
  6551. <div id="show-hints-container">
  6552. <label class="checkbox" title="Show hotkeys and shortcuts">
  6553. <input type="checkbox" id="show-hints-checkbox">
  6554. <span> Hotkey Hints</span>
  6555. <span class="option-hint"> (H)</span>
  6556. </label>
  6557. </div>
  6558. <div>
  6559. <label class="checkbox" title="Toggle dark theme">
  6560. <input type="checkbox" id="dark-theme-checkbox">
  6561. <span> Dark Theme</span>
  6562. </label>
  6563. </div>
  6564. </div>
  6565. <div id="dynamic-favorite-options">
  6566. </div>
  6567. </div>
  6568. </div>
  6569. </div>
  6570.  
  6571. <div id="bottom-panel-2">
  6572. <div id="additional-favorite-options-container" class="options-container">
  6573. <div id="additional-favorite-options">
  6574. <div id="sort-container" title="Change sorting order of search results">
  6575. <label style="margin-right: 22px;" for="sorting-method">Sort By</label>
  6576. <label style="margin-left: 22px;" for="sort-ascending">Ascending</label>
  6577. <div style="position: relative;">
  6578. <select id="sorting-method">
  6579. <option value="default">Default</option>
  6580. <option value="score">Score</option>
  6581. <option value="width">Width</option>
  6582. <option value="height">Height</option>
  6583. <option value="create">Date Uploaded</option>
  6584. <option value="change">Date Changed</option>
  6585. <option value="random">Random</option>
  6586. </select>
  6587. <input type="checkbox" id="sort-ascending">
  6588. </div>
  6589. </div>
  6590. <div id="results-columns-container">
  6591. <div id="results-per-page-container" style="display: inline-block;"
  6592. title="Set the maximum number of search results to display on each page
  6593. Lower numbers improve responsiveness">
  6594. <span class="number-label-container">
  6595. <label id="results-per-page-label" for="results-per-page-input">Results per Page</label>
  6596. </span>
  6597. <br>
  6598. <span class="number">
  6599. <hold-button class="number-arrow-down" pollingtime="50">
  6600. <span>&lt;</span>
  6601. </hold-button>
  6602. <input type="number" id="results-per-page-input" min="100" max="10000" step="50">
  6603. <hold-button class="number-arrow-up" pollingtime="50">
  6604. <span>&gt;</span>
  6605. </hold-button>
  6606. </span>
  6607. </div>
  6608. <div id="column-resize-container" title="Set the number of favorites per row"
  6609. style="display: inline-block;">
  6610. <div>
  6611. <span class="number-label-container">
  6612. <label>Columns</label>
  6613. </span>
  6614. <br>
  6615. <span class="number">
  6616. <hold-button class="number-arrow-down" pollingtime="50">
  6617. <span>&lt;</span>
  6618. </hold-button>
  6619. <input type="number" id="column-resize-input" min="2" max="20">
  6620. <hold-button class="number-arrow-up" pollingtime="50">
  6621. <span>&gt;</span>
  6622. </hold-button>
  6623. </span>
  6624. </div>
  6625. </div>
  6626. </div>
  6627. <div id="rating-container" title="Filter search results by rating">
  6628. <label>Rating</label>
  6629. <br>
  6630. <div id="allowed-ratings" class="not-highlightable">
  6631. <input type="checkbox" id="explicit-rating-checkbox" checked>
  6632. <label for="explicit-rating-checkbox">Explicit</label>
  6633. <input type="checkbox" id="questionable-rating-checkbox" checked>
  6634. <label for="questionable-rating-checkbox">Questionable</label>
  6635. <input type="checkbox" id="safe-rating-checkbox" checked>
  6636. <label for="safe-rating-checkbox" style="margin: -3px;">Safe</label>
  6637. </div>
  6638. </div>
  6639. <div id="performance-profile-container" title="Improve performance by disabling features">
  6640. <label for="performance-profile">Performance Profile</label>
  6641. <br>
  6642. <select id="performance-profile">
  6643. <option value="0">Normal</option>
  6644. <option value="1">Low (no gallery)</option>
  6645. <option value="2">Potato (only search)</option>
  6646. </select>
  6647. </div>
  6648. </div>
  6649. </div>
  6650. </div>
  6651.  
  6652. <div id="bottom-panel-3">
  6653. <div id="show-ui-div">
  6654. <label class="checkbox" title="Toggle UI">
  6655. <input type="checkbox" id="show-ui">UI
  6656. <span class="option-hint"> (U)</span>
  6657. </label>
  6658. </div>
  6659. <div class="options-container">
  6660. <span id="find-favorite">
  6661. <button title="Find favorite favorite using its ID" id="find-favorite-button"
  6662. style="white-space: nowrap;">Find</button>
  6663. <input type="number" id="find-favorite-input" placeholder="ID">
  6664. </span>
  6665. </div>
  6666. </div>
  6667.  
  6668. <div id="bottom-panel-4">
  6669.  
  6670. </div>
  6671. </div>
  6672. </div>
  6673. <div id="right-favorites-panel"></div>
  6674. </div>
  6675. <div class="loading-wheel" id="loading-wheel" style="display: none;"></div>
  6676. </div>
  6677. `;
  6678.  
  6679. static get disabled() {
  6680. return !Utils.onFavoritesPage();
  6681. }
  6682.  
  6683. static {
  6684. Utils.addStaticInitializer(() => {
  6685. if (Utils.onFavoritesPage()) {
  6686. Utils.insertFavoritesSearchGalleryHTML("afterbegin", FavoritesMenu.uiHTML);
  6687. }
  6688. });
  6689. }
  6690.  
  6691. static settings = {
  6692. mobileMenuExpandedHeight: 170,
  6693. mobileMenuBaseHeight: 56
  6694. };
  6695.  
  6696. /**
  6697. * @type {Number}
  6698. */
  6699. maxSearchHistoryLength;
  6700. /**
  6701. * @type {Object.<PropertyKey, String>}
  6702. */
  6703. preferences;
  6704. /**
  6705. * @type {Object.<PropertyKey, String>}
  6706. */
  6707. localStorageKeys;
  6708. /**
  6709. * @type {Object.<PropertyKey, HTMLButtonElement>}
  6710. */
  6711. buttons;
  6712. /**
  6713. * @type {Object.<PropertyKey, HTMLInputElement}
  6714. */
  6715. checkboxes;
  6716. /**
  6717. * @type {Object.<PropertyKey, HTMLInputElement}
  6718. */
  6719. inputs;
  6720. /**
  6721. * @type {Cooldown}
  6722. */
  6723. columnWheelResizeCaptionCooldown;
  6724. /**
  6725. * @type {String[]}
  6726. */
  6727. searchHistory;
  6728. /**
  6729. * @type {Number}
  6730. */
  6731. searchHistoryIndex;
  6732. /**
  6733. * @type {String}
  6734. */
  6735. lastSearchQuery;
  6736.  
  6737. constructor() {
  6738. if (FavoritesMenu.disabled) {
  6739. return;
  6740. }
  6741. this.initializeFields();
  6742. this.configureMobileUI();
  6743. this.extractUIElements();
  6744. this.setMainButtonInteractability(false);
  6745. this.addEventListenersToFavoritesPage();
  6746. this.loadFavoritesPagePreferences();
  6747. this.removePaginatorFromFavoritesPage();
  6748. this.configureAddOrRemoveButtonOptionVisibility();
  6749. this.configureDesktopUI();
  6750. this.addEventListenersToWhatsNewMenu();
  6751. this.addHintsOption();
  6752. }
  6753.  
  6754. initializeFields() {
  6755. this.maxSearchHistoryLength = 100;
  6756. this.searchHistory = [];
  6757. this.searchHistoryIndex = 0;
  6758. this.lastSearchQuery = "";
  6759. this.preferences = {
  6760. showAddOrRemoveButtons: Utils.userIsOnTheirOwnFavoritesPage() ? "showRemoveButtons" : "showAddFavoriteButtons",
  6761. showOptions: "showOptions",
  6762. excludeBlacklist: "excludeBlacklist",
  6763. searchHistory: "favoritesSearchHistory",
  6764. findFavorite: "findFavorite",
  6765. thumbSize: "thumbSize",
  6766. columnCount: "columnCount",
  6767. showUI: "showUI",
  6768. performanceProfile: "performanceProfile",
  6769. resultsPerPage: "resultsPerPage",
  6770. fancyImageHovering: "fancyImageHovering",
  6771. enableOnSearchPages: "enableOnSearchPages",
  6772. sortAscending: "sortAscending",
  6773. sortingMethod: "sortingMethod",
  6774. allowedRatings: "allowedRatings",
  6775. showHotkeyHints: "showHotkeyHints",
  6776. showStatisticHints: "showStatisticHints"
  6777. };
  6778. this.localStorageKeys = {
  6779. searchHistory: "favoritesSearchHistory"
  6780. };
  6781. this.columnWheelResizeCaptionCooldown = new Cooldown(500, true);
  6782. }
  6783.  
  6784. extractUIElements() {
  6785. this.buttons = {
  6786. search: document.getElementById("search-button"),
  6787. shuffle: document.getElementById("shuffle-button"),
  6788. clear: document.getElementById("clear-button"),
  6789. invert: document.getElementById("invert-button"),
  6790. reset: document.getElementById("reset-button"),
  6791. findFavorite: document.getElementById("find-favorite-button")
  6792. };
  6793. this.checkboxes = {
  6794. showOptions: document.getElementById("options-checkbox"),
  6795. showAddOrRemoveButtons: Utils.userIsOnTheirOwnFavoritesPage() ? document.getElementById("show-remove-favorite-buttons") : document.getElementById("show-add-favorite-buttons"),
  6796. filterBlacklist: document.getElementById("filter-blacklist-checkbox"),
  6797. showUI: document.getElementById("show-ui"),
  6798. fancyImageHovering: document.getElementById("fancy-image-hovering-checkbox"),
  6799. enableOnSearchPages: document.getElementById("enable-on-search-pages"),
  6800. sortAscending: document.getElementById("sort-ascending"),
  6801. explicitRating: document.getElementById("explicit-rating-checkbox"),
  6802. questionableRating: document.getElementById("questionable-rating-checkbox"),
  6803. safeRating: document.getElementById("safe-rating-checkbox"),
  6804. showHotkeyHints: document.getElementById("show-hints-checkbox"),
  6805. showStatisticHints: document.getElementById("statistic-hint-checkbox"),
  6806. darkTheme: document.getElementById("dark-theme-checkbox")
  6807. };
  6808. this.inputs = {
  6809. searchBox: document.getElementById("favorites-search-box"),
  6810. findFavorite: document.getElementById("find-favorite-input"),
  6811. columnCount: document.getElementById("column-resize-input"),
  6812. performanceProfile: document.getElementById("performance-profile"),
  6813. resultsPerPage: document.getElementById("results-per-page-input"),
  6814. sortingMethod: document.getElementById("sorting-method"),
  6815. allowedRatings: document.getElementById("allowed-ratings")
  6816. };
  6817. }
  6818.  
  6819. loadFavoritesPagePreferences() {
  6820. const userIsLoggedIn = Utils.getUserId() !== null;
  6821. const showAddOrRemoveButtonsDefault = !Utils.userIsOnTheirOwnFavoritesPage() && userIsLoggedIn;
  6822. const addOrRemoveFavoriteButtonsAreVisible = Utils.getPreference(this.preferences.showAddOrRemoveButtons, showAddOrRemoveButtonsDefault);
  6823.  
  6824. this.checkboxes.showAddOrRemoveButtons.checked = addOrRemoveFavoriteButtonsAreVisible;
  6825. setTimeout(() => {
  6826. this.toggleAddOrRemoveButtons();
  6827. }, 100);
  6828.  
  6829. const showOptions = Utils.getPreference(this.preferences.showOptions, false);
  6830.  
  6831. this.checkboxes.showOptions.checked = showOptions;
  6832. this.toggleFavoritesOptions(showOptions);
  6833.  
  6834. if (Utils.userIsOnTheirOwnFavoritesPage()) {
  6835. this.checkboxes.filterBlacklist.checked = Utils.getPreference(this.preferences.excludeBlacklist, false);
  6836. favoritesLoader.toggleTagBlacklistExclusion(this.checkboxes.filterBlacklist.checked);
  6837. } else {
  6838. this.checkboxes.filterBlacklist.checked = true;
  6839. this.checkboxes.filterBlacklist.parentElement.style.display = "none";
  6840. }
  6841. this.searchHistory = JSON.parse(localStorage.getItem(this.localStorageKeys.searchHistory)) || [];
  6842.  
  6843. if (this.searchHistory.length > 0) {
  6844. this.inputs.searchBox.value = this.searchHistory[0];
  6845. }
  6846. this.updateVisibilityOfSearchClearButton();
  6847. this.inputs.findFavorite.value = Utils.getPreference(this.preferences.findFavorite, "");
  6848. this.inputs.columnCount.value = Utils.getPreference(this.preferences.columnCount, Utils.defaults.columnCount);
  6849. this.changeColumnCount(this.inputs.columnCount.value);
  6850.  
  6851. const showUI = Utils.getPreference(this.preferences.showUI, true);
  6852.  
  6853. this.checkboxes.showUI.checked = showUI;
  6854. this.toggleUI(showUI);
  6855.  
  6856. const performanceProfile = Utils.getPerformanceProfile();
  6857.  
  6858. for (const option of this.inputs.performanceProfile.children) {
  6859. if (parseInt(option.value) === performanceProfile) {
  6860. option.selected = "selected";
  6861. }
  6862. }
  6863.  
  6864. const resultsPerPage = parseInt(Utils.getPreference(this.preferences.resultsPerPage, Utils.defaults.resultsPerPage));
  6865.  
  6866. this.changeResultsPerPage(resultsPerPage);
  6867.  
  6868. if (Utils.onMobileDevice()) {
  6869. Utils.toggleFancyImageHovering(false);
  6870. this.checkboxes.fancyImageHovering.parentElement.style.display = "none";
  6871. this.checkboxes.enableOnSearchPages.parentElement.style.display = "none";
  6872. } else {
  6873. const fancyImageHovering = Utils.getPreference(this.preferences.fancyImageHovering, false);
  6874.  
  6875. this.checkboxes.fancyImageHovering.checked = fancyImageHovering;
  6876. Utils.toggleFancyImageHovering(fancyImageHovering);
  6877. }
  6878.  
  6879. this.checkboxes.enableOnSearchPages.checked = Utils.getPreference(this.preferences.enableOnSearchPages, false);
  6880. this.checkboxes.sortAscending.checked = Utils.getPreference(this.preferences.sortAscending, false);
  6881.  
  6882. const sortingMethod = Utils.getPreference(this.preferences.sortingMethod, "default");
  6883.  
  6884. for (const option of this.inputs.sortingMethod) {
  6885. if (option.value === sortingMethod) {
  6886. option.selected = "selected";
  6887. }
  6888. }
  6889. const allowedRatings = Utils.loadAllowedRatings();
  6890.  
  6891. // eslint-disable-next-line no-bitwise
  6892. this.checkboxes.explicitRating.checked = (allowedRatings & 4) === 4;
  6893. // eslint-disable-next-line no-bitwise
  6894. this.checkboxes.questionableRating.checked = (allowedRatings & 2) === 2;
  6895. // eslint-disable-next-line no-bitwise
  6896. this.checkboxes.safeRating.checked = (allowedRatings & 1) === 1;
  6897. this.preventUserFromUncheckingAllRatings(allowedRatings);
  6898.  
  6899. const showStatisticHints = Utils.getPreference(this.preferences.showStatisticHints, false);
  6900.  
  6901. this.checkboxes.showStatisticHints.checked = showStatisticHints;
  6902. this.toggleStatisticHints(showStatisticHints);
  6903.  
  6904. this.checkboxes.darkTheme.checked = Utils.usingDarkTheme();
  6905. }
  6906.  
  6907. removePaginatorFromFavoritesPage() {
  6908. if (!Utils.onFavoritesPage()) {
  6909. return;
  6910. }
  6911. const paginator = document.getElementById("paginator");
  6912. const pi = document.getElementById("pi");
  6913.  
  6914. if (paginator !== null) {
  6915. paginator.remove();
  6916. }
  6917.  
  6918. if (pi !== null) {
  6919. pi.remove();
  6920. }
  6921. }
  6922.  
  6923. addEventListenersToFavoritesPage() {
  6924. this.buttons.search.onclick = (event) => {
  6925. const query = this.inputs.searchBox.value;
  6926.  
  6927. if (event.ctrlKey) {
  6928. const queryWithFormattedIds = query.replace(/(?:^|\s)(\d+)(?:$|\s)/g, " id:$1 ");
  6929.  
  6930. Utils.openSearchPage(queryWithFormattedIds);
  6931. } else {
  6932. Utils.hideAwesomplete(this.inputs.searchBox);
  6933. favoritesLoader.searchFavorites(query);
  6934. this.addToFavoritesSearchHistory(query);
  6935. }
  6936. };
  6937. this.buttons.search.addEventListener("contextmenu", (event) => {
  6938. const queryWithFormattedIds = this.inputs.searchBox.value.replace(/(?:^|\s)(\d+)(?:$|\s)/g, " id:$1 ");
  6939.  
  6940. Utils.openSearchPage(queryWithFormattedIds);
  6941. event.preventDefault();
  6942. });
  6943. this.inputs.searchBox.addEventListener("keydown", (event) => {
  6944. switch (event.key) {
  6945. case "Enter":
  6946. if (Utils.awesompleteIsUnselected(this.inputs.searchBox)) {
  6947. event.preventDefault();
  6948. this.buttons.search.dispatchEvent(new Event("click"));
  6949. } else {
  6950. Utils.clearAwesompleteSelection(this.inputs.searchBox);
  6951. }
  6952. break;
  6953.  
  6954. case "ArrowUp":
  6955.  
  6956. case "ArrowDown":
  6957. if (Utils.awesompleteIsVisible(this.inputs.searchBox)) {
  6958. this.updateLastSearchQuery();
  6959. } else {
  6960. event.preventDefault();
  6961. this.traverseFavoritesSearchHistory(event.key);
  6962. }
  6963. break;
  6964.  
  6965. default:
  6966. this.updateLastSearchQuery();
  6967. break;
  6968. }
  6969. });
  6970. this.inputs.searchBox.addEventListener("wheel", (event) => {
  6971. if (event.shiftKey || event.ctrlKey) {
  6972. return;
  6973. }
  6974. const direction = event.deltaY > 0 ? "ArrowDown" : "ArrowUp";
  6975.  
  6976. this.traverseFavoritesSearchHistory(direction);
  6977. event.preventDefault();
  6978. });
  6979. this.checkboxes.showOptions.onchange = () => {
  6980. this.toggleFavoritesOptions(this.checkboxes.showOptions.checked);
  6981. Utils.setPreference(this.preferences.showOptions, this.checkboxes.showOptions.checked);
  6982. };
  6983. this.checkboxes.showAddOrRemoveButtons.onchange = () => {
  6984. this.toggleAddOrRemoveButtons();
  6985. Utils.setPreference(this.preferences.showAddOrRemoveButtons, this.checkboxes.showAddOrRemoveButtons.checked);
  6986. };
  6987. this.buttons.shuffle.onclick = () => {
  6988. favoritesLoader.shuffleSearchResults();
  6989. };
  6990. this.buttons.clear.onclick = () => {
  6991. this.inputs.searchBox.value = "";
  6992.  
  6993. if (Utils.onMobileDevice()) {
  6994. this.inputs.searchBox.focus();
  6995. }
  6996. this.updateVisibilityOfSearchClearButton();
  6997. };
  6998. this.checkboxes.filterBlacklist.onchange = () => {
  6999. Utils.setPreference(this.preferences.excludeBlacklist, this.checkboxes.filterBlacklist.checked);
  7000. favoritesLoader.toggleTagBlacklistExclusion(this.checkboxes.filterBlacklist.checked);
  7001. favoritesLoader.searchFavorites();
  7002. };
  7003. this.buttons.invert.onclick = () => {
  7004. favoritesLoader.invertSearchResults();
  7005. };
  7006. this.buttons.reset.onclick = () => {
  7007. if (Utils.onMobileDevice()) {
  7008. setTimeout(() => {
  7009. Utils.deletePersistentData();
  7010. }, 10);
  7011. } else {
  7012. Utils.deletePersistentData();
  7013. }
  7014. };
  7015. this.inputs.findFavorite.addEventListener("keydown", (event) => {
  7016. if (event.key === "Enter") {
  7017. this.buttons.findFavorite.click();
  7018. }
  7019. });
  7020. this.buttons.findFavorite.onclick = () => {
  7021. favoritesLoader.findFavorite(this.inputs.findFavorite.value);
  7022. Utils.setPreference(this.preferences.findFavorite, this.inputs.findFavorite.value);
  7023. };
  7024. this.inputs.columnCount.onchange = () => {
  7025. this.changeColumnCount(parseInt(this.inputs.columnCount.value));
  7026. };
  7027. this.checkboxes.showUI.onchange = () => {
  7028. this.toggleUI(this.checkboxes.showUI.checked);
  7029. };
  7030. this.inputs.performanceProfile.onchange = () => {
  7031. Utils.setPreference(this.preferences.performanceProfile, parseInt(this.inputs.performanceProfile.value));
  7032. window.location.reload();
  7033. };
  7034. this.inputs.resultsPerPage.onchange = () => {
  7035. this.changeResultsPerPage(parseInt(this.inputs.resultsPerPage.value));
  7036. };
  7037.  
  7038. if (!Utils.onMobileDevice()) {
  7039. this.checkboxes.fancyImageHovering.onchange = () => {
  7040. Utils.toggleFancyImageHovering(this.checkboxes.fancyImageHovering.checked);
  7041. Utils.setPreference(this.preferences.fancyImageHovering, this.checkboxes.fancyImageHovering.checked);
  7042. };
  7043. }
  7044. this.checkboxes.enableOnSearchPages.onchange = () => {
  7045. Utils.setPreference(this.preferences.enableOnSearchPages, this.checkboxes.enableOnSearchPages.checked);
  7046. };
  7047. this.checkboxes.sortAscending.onchange = () => {
  7048. Utils.setPreference(this.preferences.sortAscending, this.checkboxes.sortAscending.checked);
  7049. favoritesLoader.onSortingParametersChanged();
  7050. };
  7051. this.inputs.sortingMethod.onchange = () => {
  7052. Utils.setPreference(this.preferences.sortingMethod, this.inputs.sortingMethod.value);
  7053. favoritesLoader.onSortingParametersChanged();
  7054. };
  7055. this.inputs.allowedRatings.onchange = () => {
  7056. this.changeAllowedRatings();
  7057. };
  7058. window.addEventListener("wheel", (event) => {
  7059. if (!event.shiftKey) {
  7060. return;
  7061. }
  7062. const delta = (event.wheelDelta ? event.wheelDelta : -event.deltaY);
  7063. const columnAddend = delta > 0 ? -1 : 1;
  7064.  
  7065. if (this.columnWheelResizeCaptionCooldown.ready) {
  7066. Utils.forceHideCaptions(true);
  7067. }
  7068. this.changeColumnCount(parseInt(this.inputs.columnCount.value) + columnAddend);
  7069. }, {
  7070. passive: true
  7071. });
  7072. this.columnWheelResizeCaptionCooldown.onDebounceEnd = () => {
  7073. Utils.forceHideCaptions(false);
  7074. };
  7075. this.columnWheelResizeCaptionCooldown.onCooldownEnd = () => {
  7076. if (!this.columnWheelResizeCaptionCooldown.debouncing) {
  7077. Utils.forceHideCaptions(false);
  7078. }
  7079. };
  7080. window.addEventListener("readyToSearch", () => {
  7081. this.setMainButtonInteractability(true);
  7082. }, {
  7083. once: true
  7084. });
  7085. document.addEventListener("keydown", (event) => {
  7086. if (!Utils.isHotkeyEvent(event)) {
  7087. return;
  7088. }
  7089.  
  7090. switch (event.key.toLowerCase()) {
  7091. case "r":
  7092. this.checkboxes.showAddOrRemoveButtons.click();
  7093. break;
  7094.  
  7095. case "u":
  7096. this.checkboxes.showUI.click();
  7097. break;
  7098.  
  7099. case "o":
  7100. this.checkboxes.showOptions.click();
  7101. break;
  7102.  
  7103. case "h":
  7104. this.checkboxes.showHotkeyHints.click();
  7105. break;
  7106.  
  7107. case "s":
  7108. // this.FAVORITE_CHECKBOXES.showStatisticHints.click();
  7109. break;
  7110.  
  7111. default:
  7112. break;
  7113. }
  7114. }, {
  7115. passive: true
  7116. });
  7117. window.addEventListener("load", () => {
  7118. if (!Utils.onMobileDevice()) {
  7119. this.inputs.searchBox.focus();
  7120. }
  7121. }, {
  7122. once: true
  7123. });
  7124. this.checkboxes.showStatisticHints.onchange = () => {
  7125. this.toggleStatisticHints(this.checkboxes.showStatisticHints.checked);
  7126. Utils.setPreference(this.preferences.showStatisticHints, this.checkboxes.showStatisticHints.checked);
  7127. };
  7128. window.addEventListener("searchForTag", (event) => {
  7129. this.inputs.searchBox.value = event.detail;
  7130. this.buttons.search.click();
  7131. });
  7132. this.checkboxes.darkTheme.onchange = () => {
  7133. Utils.toggleDarkTheme(this.checkboxes.darkTheme.checked);
  7134. };
  7135. }
  7136.  
  7137. configureAddOrRemoveButtonOptionVisibility() {
  7138. this.checkboxes.showAddOrRemoveButtons.parentElement.parentElement.style.display = "block";
  7139. }
  7140.  
  7141. updateLastSearchQuery() {
  7142. if (this.inputs.searchBox.value !== this.lastSearchQuery) {
  7143. this.lastSearchQuery = this.inputs.searchBox.value;
  7144. }
  7145. this.searchHistoryIndex = -1;
  7146. }
  7147.  
  7148. /**
  7149. * @param {String} newSearch
  7150. */
  7151. addToFavoritesSearchHistory(newSearch) {
  7152. newSearch = newSearch.trim();
  7153. this.searchHistory = this.searchHistory.filter(search => search !== newSearch);
  7154. this.searchHistory.unshift(newSearch);
  7155. this.searchHistory.length = Math.min(this.searchHistory.length, this.maxSearchHistoryLength);
  7156. localStorage.setItem(this.localStorageKeys.searchHistory, JSON.stringify(this.searchHistory));
  7157. }
  7158.  
  7159. /**
  7160. * @param {String} direction
  7161. */
  7162. traverseFavoritesSearchHistory(direction) {
  7163. if (this.searchHistory.length > 0) {
  7164. if (direction === "ArrowUp") {
  7165. this.searchHistoryIndex = Math.min(this.searchHistoryIndex + 1, this.searchHistory.length - 1);
  7166. } else {
  7167. this.searchHistoryIndex = Math.max(this.searchHistoryIndex - 1, -1);
  7168. }
  7169.  
  7170. if (this.searchHistoryIndex === -1) {
  7171. this.inputs.searchBox.value = this.lastSearchQuery;
  7172. } else {
  7173. this.inputs.searchBox.value = this.searchHistory[this.searchHistoryIndex];
  7174. }
  7175. }
  7176. }
  7177.  
  7178. /**
  7179. * @param {Boolean} value
  7180. */
  7181. toggleFavoritesOptions(value) {
  7182. if (Utils.onMobileDevice()) {
  7183. document.getElementById("left-favorites-panel-bottom-row").classList.toggle("hidden", !value);
  7184.  
  7185. const mobileButtonRow = document.getElementById("mobile-button-row");
  7186.  
  7187. if (mobileButtonRow !== null) {
  7188. mobileButtonRow.style.display = value ? "" : "none";
  7189. }
  7190. } else {
  7191. document.querySelectorAll(".options-container").forEach((option) => {
  7192. option.style.display = value ? "block" : "none";
  7193. });
  7194. }
  7195. }
  7196.  
  7197. toggleAddOrRemoveButtons() {
  7198. const value = this.checkboxes.showAddOrRemoveButtons.checked;
  7199.  
  7200. this.toggleAddOrRemoveButtonVisibility(value);
  7201. Utils.toggleThumbHoverOutlines(value);
  7202. Utils.forceHideCaptions(value);
  7203.  
  7204. if (!value) {
  7205. dispatchEvent(new Event("captionOverrideEnd"));
  7206. }
  7207. }
  7208.  
  7209. /**
  7210. * @param {Boolean} value
  7211. */
  7212. toggleAddOrRemoveButtonVisibility(value) {
  7213. const visibility = value ? "visible" : "hidden";
  7214.  
  7215. Utils.insertStyleHTML(`
  7216. .add-or-remove-button {
  7217. visibility: ${visibility} !important;
  7218. }
  7219. `, "add-or-remove-button-visibility");
  7220. }
  7221.  
  7222. /**
  7223. * @param {Number} count
  7224. */
  7225. changeColumnCount(count) {
  7226. count = parseInt(count);
  7227.  
  7228. if (isNaN(count)) {
  7229. this.inputs.columnCount.value = Utils.getPreference(this.preferences.columnCount, Utils.defaults.columnCount);
  7230. return;
  7231. }
  7232. const minimumColumns = Utils.onMobileDevice() ? 1 : 4;
  7233.  
  7234. count = Utils.clamp(parseInt(count), minimumColumns, 20);
  7235. Utils.insertStyleHTML(`
  7236. #favorites-search-gallery-content {
  7237. grid-template-columns: repeat(${count}, 1fr) !important;
  7238. }
  7239. `, "column-count");
  7240. this.inputs.columnCount.value = count;
  7241. Utils.setPreference(this.preferences.columnCount, count);
  7242. }
  7243.  
  7244. /**
  7245. * @param {Number} resultsPerPage
  7246. */
  7247. changeResultsPerPage(resultsPerPage) {
  7248. resultsPerPage = parseInt(resultsPerPage);
  7249.  
  7250. if (isNaN(resultsPerPage)) {
  7251. this.inputs.resultsPerPage.value = Utils.getPreference(this.preferences.resultsPerPage, Utils.defaults.resultsPerPage);
  7252. return;
  7253. }
  7254. resultsPerPage = Utils.clamp(resultsPerPage, 50, 5000);
  7255. this.inputs.resultsPerPage.value = resultsPerPage;
  7256. Utils.setPreference(this.preferences.resultsPerPage, resultsPerPage);
  7257. favoritesLoader.updateResultsPerPage(resultsPerPage);
  7258. }
  7259.  
  7260. /**
  7261. * @param {Boolean} value
  7262. */
  7263. toggleUI(value) {
  7264. const menu = document.getElementById("favorites-search-gallery-menu");
  7265. const menuPanels = document.getElementById("favorites-search-gallery-menu-panels");
  7266. const header = document.getElementById("header");
  7267. const showUIDiv = document.getElementById("show-ui-div");
  7268. const showUIContainer = document.getElementById("bottom-panel-3");
  7269.  
  7270. if (value) {
  7271. if (header !== null) {
  7272. header.style.display = "";
  7273. }
  7274. showUIContainer.insertAdjacentElement("afterbegin", showUIDiv);
  7275. menuPanels.style.display = "flex";
  7276. menu.removeAttribute("style");
  7277. } else {
  7278. menu.appendChild(showUIDiv);
  7279.  
  7280. if (header !== null) {
  7281. header.style.display = "none";
  7282. }
  7283. menuPanels.style.display = "none";
  7284. menu.style.background = getComputedStyle(document.body).background;
  7285. }
  7286. showUIDiv.classList.toggle("ui-hidden", !value);
  7287. Utils.setPreference(this.preferences.showUI, value);
  7288. }
  7289.  
  7290. configureMobileUI() {
  7291. if (!Utils.onMobileDevice()) {
  7292. return;
  7293. }
  7294. this.configureMobileStyle();
  7295. this.setupStickyMenu();
  7296. this.createMobileUIContainer();
  7297. this.createResultsPerPageSelect();
  7298. this.createColumnResizeSelect();
  7299. this.createMobileSearchBar();
  7300. this.createControlsGuide();
  7301. this.createPaginationFooter();
  7302. this.createMobileToggleSwitches();
  7303. // this.createMobileButtonRow();
  7304. this.createMobileSymbolRow();
  7305. }
  7306.  
  7307. configureMobileStyle() {
  7308. Utils.insertStyleHTML(`
  7309. #performance-profile-container,
  7310. #show-hints-container,
  7311. #whats-new-link,
  7312. #show-ui-div,
  7313. #search-header,
  7314. #left-favorites-panel-top-row {
  7315. display: none !important;
  7316. }
  7317.  
  7318. #favorites-pagination-container>button {
  7319. &:active, &:focus {
  7320. background-color: slategray;
  7321. }
  7322.  
  7323. &:hover {
  7324. background-color: transparent;
  7325. }
  7326. }
  7327.  
  7328. .thumb,
  7329. .favorite {
  7330. >div>canvas {
  7331. display: none;
  7332. }
  7333. }
  7334.  
  7335. #more-options-label {
  7336. margin-left: 6px;
  7337. }
  7338.  
  7339. .checkbox {
  7340. margin-bottom: 8px;
  7341.  
  7342. input[type="checkbox"] {
  7343. margin-right: 10px;
  7344. }
  7345. }
  7346.  
  7347. #mobile-container {
  7348. position: fixed !important;
  7349. z-index: 30;
  7350. width: 100vw;
  7351. top: 0px;
  7352. left: 0px;
  7353. }
  7354.  
  7355. #favorites-search-gallery-menu-panels {
  7356. display: block !important;
  7357. }
  7358.  
  7359. #right-favorites-panel {
  7360. margin-left: 0px !important;
  7361. }
  7362.  
  7363. #left-favorites-panel-bottom-row {
  7364. margin: 4px 0px 0px 0px !important;
  7365. }
  7366.  
  7367. #additional-favorite-options-container {
  7368. margin-right: 5px;
  7369. }
  7370.  
  7371. #favorites-search-gallery-content {
  7372. grid-gap: 1.2cqw;
  7373. }
  7374.  
  7375. #favorites-search-gallery-menu {
  7376. padding: 7px 5px 5px 5px;
  7377. top: 0;
  7378. left: 0;
  7379. width: 100vw;
  7380.  
  7381.  
  7382. &.fixed {
  7383. position: fixed;
  7384. margin-top: 0;
  7385. }
  7386. }
  7387.  
  7388. #favorites-load-status-label {
  7389. display: inline;
  7390. }
  7391.  
  7392. textarea {
  7393. border-radius: 0px;
  7394. height: 50px;
  7395. padding: 8px 0px 8px 10px !important;
  7396. }
  7397.  
  7398. body {
  7399. width: 100% !important;
  7400. }
  7401.  
  7402. #favorites-pagination-container>button {
  7403. text-align: center;
  7404. font-size: 16px;
  7405. height: 30px;
  7406. min-width: 30px;
  7407. }
  7408.  
  7409. #goto-page-input {
  7410. top: -1px;
  7411. position: relative;
  7412. height: 25px;
  7413. width: 1em !important;
  7414. text-align: center;
  7415. font-size: 16px;
  7416. }
  7417.  
  7418. #goto-page-button {
  7419. display: none;
  7420. height: 36px;
  7421. position: absolute;
  7422. margin-left: 5px;
  7423. }
  7424.  
  7425. #additional-favorite-options {
  7426. .number {
  7427. display: none;
  7428. }
  7429. }
  7430.  
  7431. #results-per-page-container {
  7432. margin-bottom: 10px;
  7433. }
  7434.  
  7435. #bottom-panel-3,
  7436. #bottom-panel-4 {
  7437. flex: none !important;
  7438. }
  7439.  
  7440. #bottom-panel-2 {
  7441. padding-top: 8px;
  7442. }
  7443.  
  7444. #rating-container {
  7445. position: relative;
  7446. left: -5px;
  7447. top: -2px;
  7448. display: none;
  7449. }
  7450.  
  7451. #favorites-pagination-container>button {
  7452. &[disabled] {
  7453. opacity: 0.25;
  7454. pointer-events: none;
  7455. }
  7456. }
  7457.  
  7458. html {
  7459. -webkit-tap-highlight-color: transparent;
  7460. -webkit-text-size-adjust: 100%;
  7461. }
  7462.  
  7463. #additional-favorite-options {
  7464. select {
  7465. width: 120px;
  7466. }
  7467. }
  7468.  
  7469. .add-or-remove-button {
  7470. filter: none;
  7471. width: 60%;
  7472. }
  7473.  
  7474. #left-favorites-panel-bottom-row {
  7475. height: ${FavoritesMenu.settings.mobileMenuExpandedHeight}px;
  7476. overflow: hidden;
  7477. transition: height 0.2s ease;
  7478. -webkit-transition: height 0.2s ease;
  7479. -moz-transition: height 0.2s ease;
  7480. -ms-transition: height 0.2s ease;
  7481. -o-transition: height 0.2s ease;
  7482. transition: height 0.2s ease;
  7483.  
  7484. &.hidden {
  7485. height: 0px;
  7486. }
  7487. }
  7488.  
  7489. #favorites-search-gallery-content.sticky {
  7490. transition: margin 0.2s ease;
  7491. }
  7492.  
  7493. #autoplay-settings-menu {
  7494. >div {
  7495. font-size: 14px !important;
  7496. }
  7497. }
  7498.  
  7499. #results-columns-container {
  7500. margin-top: -6px;
  7501. }
  7502. `, "mobile");
  7503. document.getElementById("sorting-method").parentElement.style.marginTop = "-5px";
  7504. document.getElementById("more-options-label").textContent = " Options";
  7505. document.getElementById("options-checkbox").parentElement.style.display = "none";
  7506. const experimentalLayoutEnabled = Utils.getCookie("experiment-mobile-layout", "true");
  7507.  
  7508. if (experimentalLayoutEnabled === "true") {
  7509. Utils.insertStyleHTML(`
  7510. input[type="checkbox"] {
  7511. height: 18px;
  7512. }
  7513. `, "experimental-mobile");
  7514. } else {
  7515. Utils.insertStyleHTML(`
  7516. input[type="checkbox"] {
  7517. width: 25px;
  7518. height: 25px;
  7519. }
  7520. `, "non-experimental-mobile");
  7521. }
  7522.  
  7523. if (Utils.usingIOS) {
  7524. const viewportMeta = Array.from(document.getElementsByName("viewport"))[0];
  7525.  
  7526. if (viewportMeta !== undefined) {
  7527. viewportMeta.setAttribute("content", `${viewportMeta.getAttribute("content")}, maximum-scale:1.0, user-scalable=0`);
  7528. }
  7529. }
  7530. }
  7531.  
  7532. createMobileUIContainer() {
  7533. const mobileUIContainer = document.createElement("div");
  7534. const header = document.getElementById("header");
  7535. const menu = document.getElementById("favorites-search-gallery-menu");
  7536.  
  7537. mobileUIContainer.id = "mobile-header";
  7538. Utils.favoritesSearchGalleryContainer.insertAdjacentElement("afterbegin", mobileUIContainer);
  7539.  
  7540. if (header !== null) {
  7541. mobileUIContainer.appendChild(header);
  7542. }
  7543. mobileUIContainer.appendChild(menu);
  7544. }
  7545.  
  7546. setupStickyMenu() {
  7547. const header = document.getElementById("header");
  7548. const headerHeight = header === null ? 0 : header.getBoundingClientRect().height;
  7549.  
  7550. window.addEventListener("scroll", async() => {
  7551. if (window.scrollY > headerHeight && document.getElementById("sticky-header-fsg-style") === null) {
  7552. Utils.insertStyleHTML(
  7553. `
  7554. #favorites-search-gallery-menu {
  7555. position: fixed;
  7556. margin-top: 0;
  7557. }
  7558. `,
  7559. "sticky-header"
  7560. );
  7561. this.updateOptionContentMargin();
  7562. await Utils.sleep(1);
  7563. document.getElementById("favorites-search-gallery-content").classList.add("sticky");
  7564.  
  7565. } else if (window.scrollY <= headerHeight && document.getElementById("sticky-header-fsg-style") !== null) {
  7566. document.getElementById("sticky-header-fsg-style").remove();
  7567. document.getElementById("favorites-search-gallery-content").classList.remove("sticky");
  7568. this.removeOptionContentMargin();
  7569. }
  7570. }, {
  7571. passive: true
  7572. });
  7573. }
  7574.  
  7575. createResultsPerPageSelect() {
  7576. const resultsPerPageSelectHTML = `
  7577. <select id="results-per-page-select">
  7578. <option value="50">50</option>
  7579. <option value="100">100</option>
  7580. <option value="200">200</option>
  7581. <option value="500">500</option>
  7582. <option value="1000">1000</option>
  7583. <option value="2000">2000</option>
  7584. <option value="5000">5000</option>
  7585. </select>
  7586. `;
  7587.  
  7588. document.getElementById("results-per-page-container").querySelector(".number")
  7589. .insertAdjacentHTML("afterend", resultsPerPageSelectHTML);
  7590. const resultsPerPageSelect = document.getElementById("results-per-page-select");
  7591.  
  7592. resultsPerPageSelect.value = Utils.getPreference(this.preferences.resultsPerPage, Utils.defaults.resultsPerPage);
  7593. resultsPerPageSelect.onchange = () => {
  7594. this.changeResultsPerPage(parseInt(resultsPerPageSelect.value));
  7595. };
  7596. }
  7597.  
  7598. createColumnResizeSelect() {
  7599. const columnResizeSelect = document.createElement("select");
  7600. const columnResizeNumberInput = document.getElementById("column-resize-container").querySelector(".number");
  7601.  
  7602. for (let i = 1; i <= 10; i += 1) {
  7603. const option = document.createElement("option");
  7604.  
  7605. option.value = i;
  7606. option.textContent = i;
  7607. columnResizeSelect.appendChild(option);
  7608. }
  7609. columnResizeSelect.value = Utils.getPreference(this.preferences.columnCount, Utils.defaults.columnCount);
  7610. columnResizeSelect.onchange = () => {
  7611. this.changeColumnCount(parseInt(columnResizeSelect.value));
  7612. };
  7613. columnResizeNumberInput.insertAdjacentElement("afterend", columnResizeSelect);
  7614. }
  7615.  
  7616. createMobileSearchBar() {
  7617. document.getElementById("clear-button").remove();
  7618. document.getElementById("search-button").remove();
  7619. document.getElementById("options-checkbox").remove();
  7620. document.getElementById("reset-button").remove();
  7621.  
  7622. Utils.insertStyleHTML(`
  7623. #mobile-toolbar-row {
  7624. display: flex;
  7625. align-items: center;
  7626. background: none;
  7627.  
  7628. svg {
  7629. fill: black;
  7630. -webkit-transition: none;
  7631. transition: none;
  7632. transform: scale(0.85);
  7633. }
  7634.  
  7635. input[type="checkbox"]:checked + label {
  7636. svg {
  7637. fill: #0075FF;
  7638. }
  7639. color: #0075FF;
  7640. }
  7641.  
  7642. .dark-green-gradient {
  7643. svg {
  7644. fill: white;
  7645. }
  7646. }
  7647. }
  7648. .search-bar-container {
  7649. align-content: center;
  7650. width: 100%;
  7651. height: 40px;
  7652. border-radius: 50px;
  7653. padding-left: 10px;
  7654. padding-right: 10px;
  7655. flex: 1;
  7656. background: white;
  7657.  
  7658. &.dark-green-gradient {
  7659. background: #303030;
  7660. }
  7661. }
  7662.  
  7663. .search-bar-items {
  7664. display: flex;
  7665. align-items: center;
  7666. height: 100%;
  7667. width: 100%;
  7668.  
  7669. > div {
  7670. flex: 0;
  7671. min-width: 40px;
  7672. width: 100%;
  7673. height: 100%;
  7674. display: block;
  7675. align-content: center;
  7676. }
  7677. }
  7678.  
  7679. .search-icon-container {
  7680. flex: 0;
  7681. min-width: 40px;
  7682. }
  7683.  
  7684. .search-bar-input-container {
  7685. flex: 1 !important;
  7686. display: flex;
  7687. width: 100%;
  7688. height: 100%;
  7689. }
  7690.  
  7691. .search-bar-input {
  7692. flex: 1;
  7693. border: none;
  7694. box-sizing: content-box;
  7695. height: 100%;
  7696. padding: 0;
  7697. margin: 0;
  7698. outline: none !important;
  7699. border: none !important;
  7700. font-size: 14px !important;
  7701. width: 100%;
  7702.  
  7703. &:focus, &:focus-visible {
  7704. background: none !important;
  7705. border: none !important;
  7706. outline: none !important;
  7707. }
  7708. }
  7709.  
  7710. .search-clear-container {
  7711. visibility: hidden;
  7712.  
  7713. svg {
  7714. transition: none !important;
  7715. transform: scale(0.6) !important;
  7716. }
  7717. }
  7718.  
  7719. .circle-icon-container {
  7720. padding: 0;
  7721. margin: 0;
  7722. align-content: center;
  7723. border-radius: 50%;
  7724.  
  7725. &:active {
  7726. background-color: #0075FF;
  7727. }
  7728. }
  7729.  
  7730. #options-checkbox {
  7731. display: none;
  7732. }
  7733.  
  7734. .mobile-toolbar-checkbox-label {
  7735. width: 100%;
  7736. height: 100%;
  7737. display: block;
  7738. }
  7739.  
  7740. #reset-button {
  7741. transition: none !important;
  7742. height: 100%;
  7743.  
  7744. >svg {
  7745. transition: none !important;
  7746. transform: scale(0.65);
  7747. }
  7748.  
  7749. &:active {
  7750. svg {
  7751. fill: #0075FF;
  7752. }
  7753. }
  7754. }
  7755.  
  7756. #help-button {
  7757. height: 100%;
  7758.  
  7759. >svg {
  7760. transform: scale(0.75);
  7761. }
  7762. }
  7763.  
  7764. .
  7765. `, "mobile-toolbar");
  7766.  
  7767. const searchBar = document.getElementById("favorites-search-box");
  7768. const mobileSearchBarHTML = `
  7769. <div id="mobile-toolbar-row" class="light-green-gradient">
  7770. <div class="search-bar-container light-green-gradient">
  7771. <div class="search-bar-items">
  7772. <div>
  7773. <div class="circle-icon-container">
  7774. <svg class="search-icon" id="search-button" xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="M784-120 532-372q-30 24-69 38t-83 14q-109 0-184.5-75.5T120-580q0-109 75.5-184.5T380-840q109 0 184.5 75.5T640-580q0 44-14 83t-38 69l252 252-56 56ZM380-400q75 0 127.5-52.5T560-580q0-75-52.5-127.5T380-760q-75 0-127.5 52.5T200-580q0 75 52.5 127.5T380-400Z"/>
  7775. </svg>
  7776. </div>
  7777. </div>
  7778. <div class="search-bar-input-container">
  7779. <input type="text" id="favorites-search-box" class="search-bar-input" needs-autocomplete placeholder="Search favorites">
  7780. </div>
  7781. <div class="toolbar-button search-clear-container">
  7782. <div class="circle-icon-container">
  7783. <svg id="clear-button" fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="m256-200-56-56 224-224-224-224 56-56 224 224 224-224 56 56-224 224 224 224-56 56-224-224-224 224Z"/>
  7784. </svg>
  7785. </div>
  7786. </div>
  7787. <div>
  7788. <input type="checkbox" id="options-checkbox">
  7789. <label for="options-checkbox" class="mobile-toolbar-checkbox-label"><svg id="options-menu-icon" fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="#5f6368"><path d="M120-240v-80h720v80H120Zm0-200v-80h720v80H120Zm0-200v-80h720v80H120Z"/></svg></label>
  7790. </div>
  7791. <div>
  7792. <div id="reset-button">
  7793. <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="currentColor"><path d="M480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-84 31.5-156.5T197-763l56 56q-44 44-68.5 102T160-480q0 134 93 227t227 93q134 0 227-93t93-227q0-67-24.5-125T707-707l56-56q54 54 85.5 126.5T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm-40-360v-440h80v440h-80Z"/></svg>
  7794. </div>
  7795. </div>
  7796. <div style="display: none;">
  7797. <div id="">
  7798. <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="currentColor"><path d="M424-320q0-81 14.5-116.5T500-514q41-36 62.5-62.5T584-637q0-41-27.5-68T480-732q-51 0-77.5 31T365-638l-103-44q21-64 77-111t141-47q105 0 161.5 58.5T698-641q0 50-21.5 85.5T609-475q-49 47-59.5 71.5T539-320H424Zm56 240q-33 0-56.5-23.5T400-160q0-33 23.5-56.5T480-240q33 0 56.5 23.5T560-160q0 33-23.5 56.5T480-80Z"/></svg>
  7799. </div>
  7800. </div>
  7801. </div>
  7802. </div>
  7803. </div>
  7804. `;
  7805.  
  7806. searchBar.insertAdjacentHTML("afterend", mobileSearchBarHTML);
  7807. searchBar.remove();
  7808. document.getElementById("favorites-search-box").addEventListener("input", () => {
  7809. this.updateVisibilityOfSearchClearButton();
  7810. });
  7811. document.getElementById("options-checkbox").addEventListener("change", (event) => {
  7812. const menuIsSticky = document.getElementById("favorites-search-gallery-content").classList.contains("sticky");
  7813. const margin = event.target.checked ? FavoritesMenu.settings.mobileMenuBaseHeight + FavoritesMenu.settings.mobileMenuExpandedHeight : FavoritesMenu.settings.mobileMenuBaseHeight;
  7814.  
  7815. if (menuIsSticky) {
  7816. Utils.sleep(1);
  7817. this.updateOptionContentMargin(margin);
  7818. }
  7819. });
  7820. }
  7821.  
  7822. createPaginationFooter() {
  7823. Utils.insertStyleHTML(`
  7824. #mobile-footer {
  7825. position: fixed;
  7826. width: 100%;
  7827. bottom: 0;
  7828. left: 0;
  7829. padding: 4px 0px;
  7830. > div {
  7831. text-align: center;
  7832. }
  7833.  
  7834. &.light-green-gradient {
  7835. background: linear-gradient(to top, #aae5a4, #89e180);
  7836. }
  7837. &.dark-green-gradient {
  7838. background: linear-gradient(to top, #5e715e, #293129);
  7839.  
  7840. }
  7841. }
  7842.  
  7843. #mobile-footer-top {
  7844. margin-bottom: 4px;
  7845. }
  7846.  
  7847. #favorites-search-gallery-content {
  7848. margin-bottom: 20px;
  7849. }
  7850.  
  7851. #favorites-load-status {
  7852. font-size: 12px !important;
  7853. >span {
  7854. margin-right: 10px;
  7855. }
  7856.  
  7857. >span:nth-child(odd) {
  7858. font-weight: bold;
  7859. }
  7860. }
  7861.  
  7862. #favorites-load-status-label {
  7863. padding-left: 0 !important;
  7864. }
  7865.  
  7866. #pagination-number:active {
  7867. opacity: 0.5;
  7868. filter: none !important;
  7869. }
  7870. `, "mobile-footer");
  7871. const footerHTML = `
  7872. <div id="mobile-footer" class="light-green-gradient">
  7873. <div id="mobile-footer-header"></div>
  7874. <div id="mobile-footer-top"></div>
  7875. <div id="mobile-footer-bottom"></div>
  7876. </div>
  7877. `;
  7878. const loadStatus = document.getElementById("favorites-load-status");
  7879.  
  7880. for (const label of Array.from(loadStatus.querySelectorAll("label"))) {
  7881. const span = document.createElement("span");
  7882.  
  7883. span.id = label.id;
  7884. span.className = label.className;
  7885. span.innerHTML = label.innerHTML;
  7886. label.remove();
  7887. loadStatus.appendChild(span);
  7888. }
  7889. Utils.insertFavoritesSearchGalleryHTML("beforeend", footerHTML);
  7890. const footerHeader = document.getElementById("mobile-footer-header");
  7891. const footerTop = document.getElementById("mobile-footer-top");
  7892. const footerBottom = document.getElementById("mobile-footer-bottom");
  7893.  
  7894. footerHeader.appendChild(document.getElementById("help-links-container"));
  7895. footerTop.appendChild(document.getElementById("favorites-load-status"));
  7896. footerBottom.appendChild(document.getElementById("favorites-pagination-placeholder"));
  7897. document.getElementById("whats-new-link").remove();
  7898. }
  7899.  
  7900. createControlsGuide() {
  7901. Utils.insertStyleHTML(`
  7902. #controls-guide {
  7903. display: none;
  7904. z-index: 99999;
  7905. --tap-control: blue;
  7906. --swipe-down: red;
  7907. --swipe-up: green;
  7908. top: 0;
  7909. left: 0;
  7910. background: lightblue;
  7911. width: 100%;
  7912. height: 100%;
  7913. padding: 0;
  7914. margin: 0;
  7915. flex-direction: column;
  7916. position: fixed;
  7917.  
  7918. &.active {
  7919. display: flex;
  7920. }
  7921. }
  7922.  
  7923. #controls-guide-image-container {
  7924. background: black;
  7925. width: 100%;
  7926. height: 100%;
  7927. }
  7928.  
  7929. #controls-guide-sample-image {
  7930. background: lightblue;
  7931. position: relative;
  7932. top: 50%;
  7933. left: 0;
  7934. width: 100%;
  7935. transform: translateY(-50%);
  7936. }
  7937.  
  7938. #controls-guide-top {
  7939. position: relative;
  7940. flex: 3;
  7941. }
  7942.  
  7943. #controls-guide-bottom {
  7944. flex: 1;
  7945. min-height: 25%;
  7946. padding: 10px;
  7947. font-size: 20px;
  7948. align-content: center;
  7949. }
  7950.  
  7951. #controls-guide-tap-container {
  7952. width: 100%;
  7953. height: 100%;
  7954. position: absolute;
  7955. }
  7956. .controls-guide-tap {
  7957. color: white;
  7958. font-size: 50px;
  7959. position: absolute;
  7960. top: 50%;
  7961. height: 65%;
  7962. width: 15%;
  7963. background: var(--tap-control);
  7964. z-index: 9999;
  7965. transform: translateY(-50%);
  7966. writing-mode: vertical-lr;
  7967. text-align: center;
  7968. opacity: 0.8;
  7969. }
  7970.  
  7971. #controls-guide-tap-right {
  7972. right: 0;
  7973. }
  7974. #controls-guide-tap-left {
  7975. left: 0;
  7976. }
  7977. #controls-guide-swipe-container {
  7978. position: absolute;
  7979. top: 0;
  7980. left: 0;
  7981. width: 100%;
  7982. height: 100%;
  7983.  
  7984. svg {
  7985. position: absolute;
  7986. left: 50%;
  7987. transform: translateX(-50%);
  7988. width: 25%;
  7989. }
  7990. }
  7991.  
  7992. #controls-guide-swipe-down {
  7993. top: 0;
  7994. color: var(--swipe-down);
  7995. fill: var(--swipe-down);
  7996. }
  7997.  
  7998. #controls-guide-swipe-up {
  7999. bottom: 0;
  8000. color: var(--swipe-up);
  8001. fill: var(--swipe-up);
  8002. }
  8003. `, "controls-guide");
  8004. Utils.insertFavoritesSearchGalleryHTML("beforeend", `
  8005. <div id="controls-guide">
  8006. <div id="controls-guide-top">
  8007. <div id="controls-guide-tap-container">
  8008. <div id="controls-guide-tap-left" class="controls-guide-tap">
  8009. Previous
  8010. </div>
  8011. <div id="controls-guide-tap-right" class="controls-guide-tap">
  8012. Next
  8013. </div>
  8014. </div>
  8015. <div id="controls-guide-image-container">
  8016. <img id="controls-guide-sample-image" src="https://rule34.xxx/images/header2.png">
  8017. </div>
  8018. <div id="controls-guide-swipe-container">
  8019. <svg id="controls-guide-swipe-down" xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="M180-360 40-500l42-42 70 70q-6-27-9-54t-3-54q0-82 27-159t78-141l43 43q-43 56-65.5 121.5T200-580q0 26 3 51.5t10 50.5l65-64 42 42-140 140Zm478 233q-23 8-46.5 7.5T566-131L304-253l18-40q10-20 28-32.5t40-14.5l68-5-112-307q-6-16 1-30.5t23-20.5q16-6 30.5 1t20.5 23l148 407-100 7 131 61q7 3 15 3.5t15-1.5l157-57q31-11 45-41.5t3-61.5l-55-150q-6-16 1-30.5t23-20.5q16-6 30.5 1t20.5 23l55 150q23 63-4.5 122.5T815-184l-157 57Zm-90-265-54-151q-6-16 1-30.5t23-20.5q16-6 30.5 1t20.5 23l55 150-76 28Zm113-41-41-113q-6-16 1-30.5t23-20.5q16-6 30.5 1t20.5 23l41 112-75 28Zm8 78Z"/></svg>
  8020. <svg id="controls-guide-swipe-up" xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="M245-400q-51-64-78-141t-27-159q0-27 3-54t9-54l-70 70-42-42 140-140 140 140-42 42-65-64q-7 25-10 50.5t-3 51.5q0 70 22.5 135.5T288-443l-43 43Zm413 273q-23 8-46.5 7.5T566-131L304-253l18-40q10-20 28-32.5t40-14.5l68-5-112-307q-6-16 1-30.5t23-20.5q16-6 30.5 1t20.5 23l148 407-100 7 131 61q7 3 15 3.5t15-1.5l157-57q31-11 45-41.5t3-61.5l-55-150q-6-16 1-30.5t23-20.5q16-6 30.5 1t20.5 23l55 150q23 63-4.5 122.5T815-184l-157 57Zm-90-265-54-151q-6-16 1-30.5t23-20.5q16-6 30.5 1t20.5 23l55 150-76 28Zm113-41-41-113q-6-16 1-30.5t23-20.5q16-6 30.5 1t20.5 23l41 112-75 28Zm8 78Z"/></svg>
  8021. </div>
  8022. </div>
  8023. <div id="controls-guide-bottom">
  8024. <ul style="text-align: center; list-style: none;">
  8025. <li style="color: var(--tap-control);">Tap edges to traverse gallery</li>
  8026. <li style="color: var(--swipe-down);">Swipe down to exit gallery</li>
  8027. <li style="color: var(--swipe-up);">Swipe up to open autoplay menu</li>
  8028. </ul>
  8029. </div>
  8030. </div>
  8031. `);
  8032. const controlGuide = document.getElementById("controls-guide");
  8033. const anchor = document.createElement("a");
  8034.  
  8035. anchor.textContent = "Controls";
  8036. anchor.href = "#";
  8037. anchor.onmousedown = (event) => {
  8038. event.preventDefault();
  8039. event.stopPropagation();
  8040. controlGuide.classList.toggle("active", true);
  8041. };
  8042. controlGuide.ontouchstart = (event) => {
  8043. event.preventDefault();
  8044. event.stopPropagation();
  8045. controlGuide.classList.toggle("active", false);
  8046. };
  8047.  
  8048. document.getElementById("help-links-container").insertAdjacentElement("afterbegin", anchor);
  8049. controlGuide.onmousedown = () => {
  8050. controlGuide.classList.toggle("active", false);
  8051. };
  8052. }
  8053.  
  8054. createMobileToggleSwitches() {
  8055. window.addEventListener("postProcess", () => {
  8056. setTimeout(() => {
  8057. this.createMobileToggleSwitchesHelper();
  8058. }, 10);
  8059. }, {
  8060. once: true
  8061. });
  8062. }
  8063.  
  8064. createMobileToggleSwitchesHelper() {
  8065. Utils.insertStyleHTML(`
  8066. .toggle-switch {
  8067. position: relative;
  8068. display: inline-block;
  8069. width: 60px;
  8070. height: 34px;
  8071. transform: scale(.75);
  8072. align-content: center;
  8073. }
  8074.  
  8075. .toggle-switch input {
  8076. opacity: 0;
  8077. width: 0;
  8078. height: 0;
  8079. }
  8080.  
  8081. .slider {
  8082. position: absolute;
  8083. cursor: pointer;
  8084. top: 0;
  8085. left: 0;
  8086. right: 0;
  8087. bottom: 0;
  8088. background-color: #ccc;
  8089. -webkit-transition: .4s;
  8090. transition: .4s;
  8091. }
  8092.  
  8093. .slider:before {
  8094. position: absolute;
  8095. content: "";
  8096. height: 26px;
  8097. width: 26px;
  8098. left: 4px;
  8099. bottom: 4px;
  8100. background-color: white;
  8101. -webkit-transition: .4s;
  8102. transition: .4s;
  8103. }
  8104.  
  8105. input:checked + .slider {
  8106. background-color: #0075FF;
  8107. }
  8108.  
  8109. input:focus + .slider {
  8110. box-shadow: 0 0 1px #0075FF;
  8111. }
  8112.  
  8113. input:checked + .slider:before {
  8114. -webkit-transform: translateX(26px);
  8115. -ms-transform: translateX(26px);
  8116. transform: translateX(26px);
  8117. }
  8118.  
  8119. .slider.round {
  8120. border-radius: 34px;
  8121. }
  8122.  
  8123. .slider.round:before {
  8124. border-radius: 50%;
  8125. }
  8126.  
  8127. .toggle-switch-label {
  8128. margin-left: 60px;
  8129. margin-top: 20px;
  8130. font-size: 16px;
  8131. }
  8132.  
  8133. #sort-ascending {
  8134. width: 0 !important;
  8135. height: 0 !important;
  8136. position: static !important;
  8137. }
  8138.  
  8139. `, "mobile-toggle-switch");
  8140. const checkboxes = Array.from(document.querySelectorAll(".checkbox"))
  8141. .filter(checkbox => checkbox.getBoundingClientRect().width > 0);
  8142.  
  8143. for (const hint of Array.from(document.querySelectorAll(".option-hint"))) {
  8144. hint.remove();
  8145. }
  8146.  
  8147. for (const checkbox of checkboxes) {
  8148. const label = checkbox.querySelector("span");
  8149. const input = checkbox.querySelector("input");
  8150. const slider = document.createElement("span");
  8151.  
  8152. if (input === null) {
  8153. continue;
  8154. }
  8155. slider.className = "slider round";
  8156. checkbox.className = "toggle-switch";
  8157. input.insertAdjacentElement("afterend", slider);
  8158.  
  8159. if (label !== null) {
  8160. label.className = "toggle-switch-label";
  8161. }
  8162. }
  8163. const sortAscendingCheckbox = document.getElementById("sort-ascending");
  8164.  
  8165. if (sortAscendingCheckbox !== null) {
  8166. const container = document.createElement("span");
  8167. const toggleSwitch = document.createElement("label");
  8168. const slider = document.createElement("span");
  8169.  
  8170. toggleSwitch.className = "toggle-switch";
  8171. toggleSwitch.style.transform = "scale(0.6)";
  8172. toggleSwitch.style.marginLeft = "-12px";
  8173. slider.className = "slider round";
  8174. sortAscendingCheckbox.insertAdjacentElement("beforebegin", container);
  8175. container.appendChild(toggleSwitch);
  8176. toggleSwitch.appendChild(sortAscendingCheckbox);
  8177. toggleSwitch.appendChild(slider);
  8178. sortAscendingCheckbox.insertAdjacentElement("afterend", slider);
  8179. }
  8180. }
  8181.  
  8182. createMobileButtonRow() {
  8183. const buttonHeight = 30;
  8184.  
  8185. Utils.insertStyleHTML(`
  8186. #mobile-button-row {
  8187. padding: 0;
  8188. position: absolute;
  8189. width: 98%;
  8190. display: flex;
  8191. gap: 10px;
  8192. padding: 0px 20px;
  8193.  
  8194. >button, >div {
  8195. font-size: 20px;
  8196. flex: 1;
  8197. height: ${buttonHeight}px;
  8198. border-radius: 30px;
  8199. }
  8200. }
  8201.  
  8202. #left-favorites-panel-bottom-row>div:not(:first-child) {
  8203. margin-top:${buttonHeight}px
  8204. }
  8205. `, "mobile-button");
  8206.  
  8207. const html = `
  8208. <div id="mobile-button-row">
  8209. <button>Reset</button>
  8210. <button>Help</button>
  8211. <button>Shuffle</button>
  8212. </div>
  8213. `;
  8214.  
  8215. document.getElementById("left-favorites-panel-bottom-row").insertAdjacentHTML("afterbegin", html);
  8216. }
  8217.  
  8218. createMobileSymbolRow() {
  8219. Utils.insertStyleHTML(`
  8220. #mobile-symbol-container {
  8221. display: flex;
  8222. gap: 10px;
  8223. text-align: center;
  8224. height: 0;
  8225. overflow: hidden;
  8226. width: 100%;
  8227. transition: height .2s ease;
  8228.  
  8229. >button {
  8230. font-size: 20px;
  8231. padding: 0;
  8232. margin: 0;
  8233. font-weight: bold;
  8234. text-align: center;
  8235. flex: 1;
  8236. height: 100% !important;
  8237. }
  8238.  
  8239. &.active {
  8240. height: 30px;
  8241. }
  8242. }
  8243. `);
  8244. document.getElementById("left-favorites-panel")
  8245. .insertAdjacentHTML("afterbegin", `
  8246. <div id="mobile-symbol-container">
  8247. <button>-</button>
  8248. <button>*</button>
  8249. <button>_</button>
  8250. <button>(</button>
  8251. <button>)</button>
  8252. <button>~</button>
  8253. </div>
  8254. `);
  8255. const mobileSymbolContainer = document.getElementById("mobile-symbol-container");
  8256. /**
  8257. * @type {HTMLInputElement}
  8258. */
  8259.  
  8260. const searchBar = document.getElementById("favorites-search-box");
  8261.  
  8262. for (const button of Array.from(document.getElementById("mobile-symbol-container").querySelectorAll("button"))) {
  8263. button.addEventListener("blur", async(event) => {
  8264. await Utils.sleep(0);
  8265.  
  8266. if (document.activeElement.id !== "favorites-search-box" && !mobileSymbolContainer.contains(document.activeElement)) {
  8267. mobileSymbolContainer.classList.toggle("active", false);
  8268. }
  8269. });
  8270.  
  8271. button.addEventListener("click", () => {
  8272. const value = searchBar.value;
  8273. const selectionStart = searchBar.selectionStart;
  8274.  
  8275. searchBar.value = value.slice(0, selectionStart) + button.textContent + value.slice(selectionStart);
  8276. this.updateVisibilityOfSearchClearButton();
  8277. searchBar.selectionStart = selectionStart + 1;
  8278. searchBar.selectionEnd = selectionStart + 1;
  8279. searchBar.focus();
  8280. }, {
  8281. passive: true
  8282. });
  8283. }
  8284.  
  8285. window.addEventListener("postProcess", () => {
  8286.  
  8287. searchBar.addEventListener("focus", () => {
  8288. document.getElementById("mobile-symbol-container").classList.toggle("active", true);
  8289. }, {
  8290. passive: true
  8291. });
  8292.  
  8293. searchBar.addEventListener("blur", async(event) => {
  8294. await Utils.sleep(10);
  8295.  
  8296. if (document.activeElement.id !== "favorites-search-box" && !mobileSymbolContainer.contains(document.activeElement)) {
  8297. mobileSymbolContainer.classList.toggle("active", false);
  8298. }
  8299. });
  8300. }, {
  8301. once: true
  8302. });
  8303. }
  8304.  
  8305. clickedOnSearchItem(event) {
  8306.  
  8307. }
  8308.  
  8309. updateVisibilityOfSearchClearButton() {
  8310. if (!Utils.onMobileDevice()) {
  8311. return;
  8312. }
  8313. const clearButtonContainer = document.querySelector(".search-clear-container");
  8314.  
  8315. if (clearButtonContainer === null) {
  8316. return;
  8317. }
  8318.  
  8319. const clearButtonIsHidden = getComputedStyle(clearButtonContainer).visibility === "hidden";
  8320. const searchBarIsEmpty = this.inputs.searchBox.value === "";
  8321. const styleId = "search-clear-button-visibility";
  8322.  
  8323. if (searchBarIsEmpty && !clearButtonIsHidden) {
  8324. Utils.insertStyleHTML(".search-clear-container {visibility: hidden}", styleId);
  8325. } else if (!searchBarIsEmpty && clearButtonIsHidden) {
  8326. Utils.insertStyleHTML(".search-clear-container {visibility: visible}", styleId);
  8327. }
  8328. }
  8329.  
  8330. /**
  8331. * @param {Number} margin
  8332. */
  8333. updateOptionContentMargin(margin) {
  8334. margin = margin === undefined ? document.getElementById("favorites-search-gallery-menu").getBoundingClientRect().height + 11 : margin;
  8335. Utils.insertStyleHTML(`
  8336. #favorites-search-gallery-content {
  8337. margin-top: ${margin}px;
  8338. }`, "options-content-margin");
  8339. }
  8340.  
  8341. removeOptionContentMargin() {
  8342. const optionsContentMargin = document.getElementById("options-content-margin-fsg-style");
  8343.  
  8344. if (optionsContentMargin !== null) {
  8345. optionsContentMargin.remove();
  8346. }
  8347. }
  8348.  
  8349. configureDesktopUI() {
  8350. if (Utils.onMobileDevice()) {
  8351. return;
  8352. }
  8353. Utils.insertStyleHTML(`
  8354. .checkbox {
  8355. &:hover {
  8356. color: #000;
  8357. background: #93b393;
  8358. text-shadow: none;
  8359. cursor: pointer;
  8360. }
  8361.  
  8362. input[type="checkbox"] {
  8363. width: 20px;
  8364. height: 20px;
  8365. }
  8366. }
  8367.  
  8368. #sort-ascending {
  8369. width: 20px;
  8370. height: 20px;
  8371. }
  8372. `, "desktop");
  8373. }
  8374.  
  8375. addEventListenersToWhatsNewMenu() {
  8376. if (Utils.onMobileDevice()) {
  8377. return;
  8378. }
  8379. const whatsNew = document.getElementById("whats-new-link");
  8380.  
  8381. if (whatsNew === null) {
  8382. return;
  8383. }
  8384. whatsNew.onclick = () => {
  8385. if (whatsNew.classList.contains("persistent")) {
  8386. whatsNew.classList.remove("persistent");
  8387. whatsNew.classList.add("hidden");
  8388. } else {
  8389. whatsNew.classList.add("persistent");
  8390. }
  8391. return false;
  8392. };
  8393.  
  8394. whatsNew.onblur = () => {
  8395. whatsNew.classList.remove("persistent");
  8396. whatsNew.classList.add("hidden");
  8397. };
  8398.  
  8399. whatsNew.onmouseenter = () => {
  8400. whatsNew.classList.remove("hidden");
  8401. };
  8402.  
  8403. whatsNew.onmouseleave = () => {
  8404. whatsNew.classList.add("hidden");
  8405. };
  8406. }
  8407.  
  8408. changeAllowedRatings() {
  8409. let allowedRatings = 0;
  8410.  
  8411. if (this.checkboxes.explicitRating.checked) {
  8412. allowedRatings += 4;
  8413. }
  8414.  
  8415. if (this.checkboxes.questionableRating.checked) {
  8416. allowedRatings += 2;
  8417. }
  8418.  
  8419. if (this.checkboxes.safeRating.checked) {
  8420. allowedRatings += 1;
  8421. }
  8422.  
  8423. Utils.setPreference(this.preferences.allowedRatings, allowedRatings);
  8424. favoritesLoader.onAllowedRatingsChanged(allowedRatings);
  8425. this.preventUserFromUncheckingAllRatings(allowedRatings);
  8426. }
  8427.  
  8428. /**
  8429. * @param {Number} allowedRatings
  8430. */
  8431. preventUserFromUncheckingAllRatings(allowedRatings) {
  8432. if (allowedRatings === 4) {
  8433. this.checkboxes.explicitRating.nextElementSibling.style.pointerEvents = "none";
  8434. } else if (allowedRatings === 2) {
  8435. this.checkboxes.questionableRating.nextElementSibling.style.pointerEvents = "none";
  8436. } else if (allowedRatings === 1) {
  8437. this.checkboxes.safeRating.nextElementSibling.style.pointerEvents = "none";
  8438. } else {
  8439. this.checkboxes.explicitRating.nextElementSibling.removeAttribute("style");
  8440. this.checkboxes.questionableRating.nextElementSibling.removeAttribute("style");
  8441. this.checkboxes.safeRating.nextElementSibling.removeAttribute("style");
  8442. }
  8443. }
  8444.  
  8445. setMainButtonInteractability(value) {
  8446. const container = document.getElementById("left-favorites-panel-top-row");
  8447.  
  8448. if (container === null) {
  8449. return;
  8450. }
  8451. const mainButtons = Array.from(container.children).filter(child => child.tagName.toLowerCase() === "button" && child.textContent !== "Reset");
  8452.  
  8453. for (const button of mainButtons) {
  8454. button.disabled = !value;
  8455. }
  8456. }
  8457.  
  8458. /**
  8459. * @param {Boolean} value
  8460. */
  8461. toggleOptionHints(value) {
  8462. const html = value ? "" : ".option-hint {display:none;}";
  8463.  
  8464. Utils.insertStyleHTML(html, "option-hint-visibility");
  8465. }
  8466.  
  8467. async addHintsOption() {
  8468. this.toggleOptionHints(false);
  8469.  
  8470. await Utils.sleep(50);
  8471.  
  8472. if (Utils.onMobileDevice()) {
  8473. return;
  8474. }
  8475. const optionHintsEnabled = Utils.getPreference(this.preferences.showHotkeyHints, false);
  8476.  
  8477. this.checkboxes.showHotkeyHints.checked = optionHintsEnabled;
  8478. this.checkboxes.showHotkeyHints.onchange = () => {
  8479. this.toggleOptionHints(this.checkboxes.showHotkeyHints.checked);
  8480. Utils.setPreference(this.preferences.showHotkeyHints, this.checkboxes.showHotkeyHints.checked);
  8481. };
  8482. this.toggleOptionHints(optionHintsEnabled);
  8483. }
  8484.  
  8485. /**
  8486. * @param {Boolean} value
  8487. */
  8488. toggleStatisticHints(value) {
  8489. const html = value ? "" : ".statistic-hint {display:none;}";
  8490.  
  8491. Utils.insertStyleHTML(html, "statistic-hint-visibility");
  8492. }
  8493. }
  8494.  
  8495. class AutoplayListenerList {
  8496. /**
  8497. * @type {Function}
  8498. */
  8499. onEnable;
  8500. /**
  8501. * @type {Function}
  8502. */
  8503. onDisable;
  8504. /**
  8505. * @type {Function}
  8506. */
  8507. onPause;
  8508. /**
  8509. * @type {Function}
  8510. */
  8511. onResume;
  8512. /**
  8513. * @type {Function}
  8514. */
  8515. onComplete;
  8516. /**
  8517. * @type {Function}
  8518. */
  8519. onVideoEndedBeforeMinimumViewTime;
  8520.  
  8521. /**
  8522. * @param {Function} onEnable
  8523. * @param {Function} onDisable
  8524. * @param {Function} onPause
  8525. * @param {Function} onResume
  8526. * @param {Function} onComplete
  8527. * @param {Function} onVideoEndedEarly
  8528. */
  8529. constructor(onEnable, onDisable, onPause, onResume, onComplete, onVideoEndedEarly) {
  8530. this.onEnable = onEnable;
  8531. this.onDisable = onDisable;
  8532. this.onPause = onPause;
  8533. this.onResume = onResume;
  8534. this.onComplete = onComplete;
  8535. this.onVideoEndedBeforeMinimumViewTime = onVideoEndedEarly;
  8536. }
  8537. }
  8538.  
  8539. class Autoplay {
  8540. static autoplayHTML = `
  8541. <div id="autoplay-container">
  8542. <style>
  8543. #autoplay-container {
  8544. visibility: hidden;
  8545. }
  8546.  
  8547. #autoplay-menu {
  8548. position: fixed;
  8549. left: 50%;
  8550. transform: translate(-50%);
  8551. bottom: 5%;
  8552. padding: 0;
  8553. margin: 0;
  8554. background: rgba(40, 40, 40, 1);
  8555. border-radius: 4px;
  8556. white-space: nowrap;
  8557. z-index: 10000;
  8558. opacity: 0;
  8559. transition: opacity .25s ease-in-out;
  8560.  
  8561. &.visible {
  8562. opacity: 1;
  8563. }
  8564.  
  8565. &.persistent {
  8566. opacity: 1 !important;
  8567. visibility: visible !important;
  8568. }
  8569.  
  8570. >div>img {
  8571. color: red;
  8572. position: relative;
  8573. height: 75px;
  8574. cursor: pointer;
  8575. background-color: rgba(128, 128, 128, 0);
  8576. margin: 5px;
  8577. background-size: 10%;
  8578. z-index: 3;
  8579. border-radius: 4px;
  8580.  
  8581.  
  8582. &:hover {
  8583. background-color: rgba(200, 200, 200, .5);
  8584. }
  8585. }
  8586. }
  8587.  
  8588. .autoplay-progress-bar {
  8589. position: absolute;
  8590. top: 0;
  8591. left: 0;
  8592. width: 0%;
  8593. height: 100%;
  8594. background-color: steelblue;
  8595. z-index: 1;
  8596. }
  8597.  
  8598. #autoplay-video-progress-bar {
  8599. background-color: royalblue;
  8600. }
  8601.  
  8602. #autoplay-settings-menu {
  8603. visibility: hidden;
  8604. position: absolute;
  8605. top: 0;
  8606. left: 50%;
  8607. transform: translate(-50%, -105%);
  8608. border-radius: 4px;
  8609. font-size: 10px !important;
  8610. background: rgba(40, 40, 40, 1);
  8611.  
  8612. &.visible {
  8613. visibility: visible;
  8614. }
  8615.  
  8616. >div {
  8617. font-size: 30px;
  8618. display: flex;
  8619. justify-content: space-between;
  8620. align-items: center;
  8621. padding: 5px 10px;
  8622. color: white;
  8623.  
  8624.  
  8625. >label {
  8626. padding-right: 20px;
  8627. }
  8628.  
  8629. >.number {
  8630. background: none;
  8631. outline: 2px solid white;
  8632.  
  8633. >hold-button,
  8634. >button {
  8635. &::after {
  8636. width: 200%;
  8637. height: 130%;
  8638. }
  8639. }
  8640.  
  8641. >input[type="number"] {
  8642. color: white;
  8643. width: 7ch;
  8644. }
  8645. }
  8646. }
  8647.  
  8648. select {
  8649. /* height: 25px; */
  8650. font-size: larger;
  8651. width: 10ch;
  8652. }
  8653. }
  8654.  
  8655. #autoplay-settings-button.settings-menu-opened {
  8656. filter: drop-shadow(6px 6px 3px #0075FF);
  8657. }
  8658.  
  8659.  
  8660. #autoplay-change-direction-mask {
  8661. filter: drop-shadow(2px 2px 3px #0075FF);
  8662. }
  8663.  
  8664. #autoplay-play-button:active {
  8665. filter: drop-shadow(2px 2px 10px #0075FF);
  8666. }
  8667.  
  8668. #autoplay-change-direction-mask-container {
  8669. pointer-events: none;
  8670. opacity: 0.75;
  8671. height: 75px;
  8672. width: 75px;
  8673. margin: 5px;
  8674. border-radius: 4px;
  8675. right: 0;
  8676. bottom: 0;
  8677. z-index: 4;
  8678. position: absolute;
  8679. clip-path: polygon(0% 0%, 0% 100%, 100% 100%);
  8680.  
  8681. &.upper-right {
  8682. clip-path: polygon(0% 0%, 100% 0%, 100% 100%);
  8683. }
  8684. }
  8685.  
  8686. .autoplay-settings-menu-label {
  8687. pointer-events: none;
  8688. }
  8689. </style>
  8690. <div id="autoplay-menu" class="not-highlightable">
  8691. <div id="autoplay-buttons">
  8692. <img id="autoplay-settings-button" title="Autoplay settings">
  8693. <img id="autoplay-play-button" title="Pause autoplay">
  8694. <img id="autoplay-change-direction-button" title="Change autoplay direction">
  8695. <div id="autoplay-change-direction-mask-container">
  8696. <img id="autoplay-change-direction-mask" title="Change autoplay direction">
  8697. </div>
  8698. </div>
  8699. <div id="autoplay-image-progress-bar" class="autoplay-progress-bar"></div>
  8700. <div id="autoplay-video-progress-bar" class="autoplay-progress-bar"></div>
  8701. <div id="autoplay-settings-menu">
  8702. <div>
  8703. <label for="autoplay-image-duration-input">Image/GIF Duration</label>
  8704. <span class="number">
  8705. <hold-button class="number-arrow-down" pollingtime="100"><span>&lt;</span></hold-button>
  8706. <input type="number" id="autoplay-image-duration-input" min="1" max="60" step="1">
  8707. <hold-button class="number-arrow-up" pollingtime="100"><span>&gt;</span></hold-button>
  8708. </span>
  8709. </div>
  8710. <div>
  8711. <label for="autoplay-minimum-video-duration-input">Minimum Video Duration</label>
  8712. <span class="number">
  8713. <hold-button class="number-arrow-down" pollingtime="100"><span>&lt;</span></hold-button>
  8714. <input type="number" id="autoplay-minimum-animated-duration-input" min="1" max="60" step="1">
  8715. <hold-button class="number-arrow-up" pollingtime="100"><span>&gt;</span></hold-button>
  8716. </span>
  8717. </div>
  8718. </div>
  8719. </div>
  8720. </div>
  8721. `;
  8722. static preferences = {
  8723. active: "autoplayActive",
  8724. paused: "autoplayPaused",
  8725. imageDuration: "autoplayImageDuration",
  8726. minimumVideoDuration: "autoplayMinimumVideoDuration",
  8727. direction: "autoplayForward"
  8728. };
  8729. static menuIconImageURLs = {
  8730. play: Utils.createObjectURLFromSvg(Utils.icons.play),
  8731. pause: Utils.createObjectURLFromSvg(Utils.icons.pause),
  8732. changeDirection: Utils.createObjectURLFromSvg(Utils.icons.changeDirection),
  8733. changeDirectionAlt: Utils.createObjectURLFromSvg(Utils.icons.changeDirectionAlt),
  8734. tune: Utils.createObjectURLFromSvg(Utils.icons.tune)
  8735. };
  8736. static settings = {
  8737. imageViewDuration: Utils.getPreference(Autoplay.preferences.imageDuration, 3000),
  8738. minimumVideoDuration: Utils.getPreference(Autoplay.preferences.minimumVideoDuration, 5000),
  8739. menuVisibilityDuration: Utils.onMobileDevice() ? 1500 : 500,
  8740. moveForward: Utils.getPreference(Autoplay.preferences.direction, true),
  8741.  
  8742. get imageViewDurationInSeconds() {
  8743. return Utils.millisecondsToSeconds(this.imageViewDuration);
  8744. },
  8745.  
  8746. get minimumVideoDurationInSeconds() {
  8747. return Utils.millisecondsToSeconds(this.minimumVideoDuration);
  8748. }
  8749. };
  8750.  
  8751. /**
  8752. * @type {Boolean}
  8753. */
  8754. static get disabled() {
  8755. return false;
  8756. // return Utils.onMobileDevice();
  8757. }
  8758.  
  8759. /**
  8760. * @type {{
  8761. * container: HTMLDivElement,
  8762. * menu: HTMLDivElement,
  8763. * settingsButton: HTMLImageElement,
  8764. * settingsMenu: {
  8765. * container: HTMLDivElement
  8766. * imageDurationInput: HTMLInputElement,
  8767. * minimumVideoDurationInput: HTMLInputElement,
  8768. * }
  8769. * playButton: HTMLImageElement,
  8770. * changeDirectionButton: HTMLImageElement,
  8771. * changeDirectionMask: {
  8772. * container: HTMLDivElement,
  8773. * image: HTMLImageElement
  8774. * },
  8775. * imageProgressBar: HTMLDivElement
  8776. * videoProgressBar: HTMLDivElement
  8777. * }}
  8778. */
  8779. ui;
  8780. /**
  8781. * @type {AutoplayListenerList}
  8782. */
  8783. events;
  8784. /**
  8785. * @type {AbortController}
  8786. */
  8787. eventListenersAbortController;
  8788. /**
  8789. * @type {HTMLElement}
  8790. */
  8791. currentThumb;
  8792. /**
  8793. * @type {Cooldown}
  8794. */
  8795. imageViewTimer;
  8796. /**
  8797. * @type {Cooldown}
  8798. */
  8799. menuVisibilityTimer;
  8800. /**
  8801. * @type {Cooldown}
  8802. */
  8803. videoViewTimer;
  8804. /**
  8805. * @type {Boolean}
  8806. */
  8807. active;
  8808. /**
  8809. * @type {Boolean}
  8810. */
  8811. paused;
  8812. /**
  8813. * @type {Boolean}
  8814. */
  8815. menuIsPersistent;
  8816. /**
  8817. * @type {Boolean}
  8818. */
  8819. menuIsVisible;
  8820.  
  8821. /**
  8822. * @param {AutoplayListenerList} events
  8823. */
  8824. constructor(events) {
  8825. if (Autoplay.disabled) {
  8826. return;
  8827. }
  8828. this.initializeEvents(events);
  8829. this.initializeFields();
  8830. this.initializeTimers();
  8831. this.insertHTML();
  8832. this.configureMobileUi();
  8833. this.extractUiElements();
  8834. this.setMenuIconImageSources();
  8835. this.loadAutoplaySettingsIntoUI();
  8836. this.addEventListeners();
  8837. }
  8838.  
  8839. /**
  8840. * @param {AutoplayListenerList} events
  8841. */
  8842. initializeEvents(events) {
  8843. this.events = events;
  8844.  
  8845. const onComplete = events.onComplete;
  8846.  
  8847. this.events.onComplete = () => {
  8848. if (this.active && !this.paused) {
  8849. onComplete();
  8850. }
  8851. };
  8852. }
  8853.  
  8854. initializeFields() {
  8855. this.ui = {
  8856. settingsMenu: {},
  8857. changeDirectionMask: {}
  8858. };
  8859. this.eventListenersAbortController = new AbortController();
  8860. this.currentThumb = null;
  8861. this.active = Utils.getPreference(Autoplay.preferences.active, Utils.onMobileDevice());
  8862. this.paused = Utils.getPreference(Autoplay.preferences.paused, false);
  8863. this.menuIsPersistent = false;
  8864. this.menuIsVisible = false;
  8865. }
  8866.  
  8867. initializeTimers() {
  8868. this.imageViewTimer = new Cooldown(Autoplay.settings.imageViewDuration);
  8869. this.menuVisibilityTimer = new Cooldown(Autoplay.settings.menuVisibilityDuration);
  8870. this.videoViewTimer = new Cooldown(Autoplay.settings.minimumVideoDuration);
  8871.  
  8872. this.imageViewTimer.onCooldownEnd = () => { };
  8873. this.menuVisibilityTimer.onCooldownEnd = () => {
  8874. this.hideMenu();
  8875. setTimeout(() => {
  8876. if (!this.menuIsPersistent && !this.menuIsVisible) {
  8877. this.toggleSettingMenu(false);
  8878. }
  8879. }, 100);
  8880. };
  8881. }
  8882.  
  8883. insertHTML() {
  8884. this.insertMenuHTML();
  8885. this.insertOptionHTML();
  8886. this.insertImageProgressHTML();
  8887. this.insertVideoProgressHTML();
  8888. }
  8889.  
  8890. insertMenuHTML() {
  8891. Utils.insertFavoritesSearchGalleryHTML("afterbegin", Autoplay.autoplayHTML);
  8892. }
  8893.  
  8894. insertOptionHTML() {
  8895. Utils.createFavoritesOption(
  8896. "autoplay",
  8897. "Autoplay",
  8898. "Enable autoplay in gallery",
  8899. this.active,
  8900. (event) => {
  8901. this.toggle(event.target.checked);
  8902. },
  8903. true
  8904. );
  8905. }
  8906.  
  8907. insertImageProgressHTML() {
  8908. Utils.insertStyleHTML(`
  8909. #autoplay-image-progress-bar.animated {
  8910. transition: width ${Autoplay.settings.imageViewDurationInSeconds}s linear;
  8911. width: 100%;
  8912. }
  8913. `, "autoplay-image-progress-bar-animation");
  8914. }
  8915.  
  8916. insertVideoProgressHTML() {
  8917. Utils.insertStyleHTML(`
  8918. #autoplay-video-progress-bar.animated {
  8919. transition: width ${Autoplay.settings.minimumVideoDurationInSeconds}s linear;
  8920. width: 100%;
  8921. }
  8922. `, "autoplay-video-progress-bar-animation");
  8923. }
  8924.  
  8925. extractUiElements() {
  8926. this.ui.container = document.getElementById("autoplay-container");
  8927. this.ui.menu = document.getElementById("autoplay-menu");
  8928. this.ui.settingsButton = document.getElementById("autoplay-settings-button");
  8929. this.ui.settingsMenu.container = document.getElementById("autoplay-settings-menu");
  8930. this.ui.settingsMenu.imageDurationInput = document.getElementById("autoplay-image-duration-input");
  8931. this.ui.settingsMenu.minimumVideoDurationInput = document.getElementById("autoplay-minimum-animated-duration-input");
  8932. this.ui.playButton = document.getElementById("autoplay-play-button");
  8933. this.ui.changeDirectionButton = document.getElementById("autoplay-change-direction-button");
  8934. this.ui.changeDirectionMask.container = document.getElementById("autoplay-change-direction-mask-container");
  8935. this.ui.changeDirectionMask.image = document.getElementById("autoplay-change-direction-mask");
  8936. this.ui.imageProgressBar = document.getElementById("autoplay-image-progress-bar");
  8937. this.ui.videoProgressBar = document.getElementById("autoplay-video-progress-bar");
  8938. }
  8939.  
  8940. configureMobileUi() {
  8941. this.createViewDurationSelects();
  8942. }
  8943.  
  8944. createViewDurationSelects() {
  8945. const imageViewDurationSelect = this.createDurationSelect(1, 60);
  8946. const videoViewDurationSelect = this.createDurationSelect(0, 60);
  8947. const imageViewDurationInput = document.getElementById("autoplay-image-duration-input").parentElement;
  8948. const videoViewDurationInput = document.getElementById("autoplay-minimum-animated-duration-input").parentElement;
  8949.  
  8950. imageViewDurationSelect.value = Autoplay.settings.imageViewDurationInSeconds;
  8951. videoViewDurationSelect.value = Autoplay.settings.minimumVideoDurationInSeconds;
  8952. imageViewDurationInput.insertAdjacentElement("afterend", imageViewDurationSelect);
  8953. videoViewDurationInput.insertAdjacentElement("afterend", videoViewDurationSelect);
  8954. imageViewDurationInput.remove();
  8955. videoViewDurationInput.remove();
  8956. imageViewDurationSelect.id = "autoplay-image-duration-input";
  8957. videoViewDurationSelect.id = "autoplay-minimum-animated-duration-input";
  8958. }
  8959.  
  8960. /**
  8961. * @param {Number} minimum
  8962. * @param {Number} maximum
  8963. * @returns {HTMLSelectElement}
  8964. */
  8965. createDurationSelect(minimum, maximum) {
  8966. const select = document.createElement("select");
  8967.  
  8968. for (let i = minimum; i <= maximum; i += 1) {
  8969. const option = document.createElement("option");
  8970.  
  8971. switch (true) {
  8972. case i <= 5:
  8973. break;
  8974.  
  8975. case i <= 20:
  8976. i += 4;
  8977. break;
  8978.  
  8979. case i <= 30:
  8980. i += 9;
  8981. break;
  8982.  
  8983. default:
  8984. i += 29;
  8985. break;
  8986. }
  8987. option.value = i;
  8988. option.innerText = i;
  8989. select.append(option);
  8990. }
  8991. select.ontouchstart = () => {
  8992. select.dispatchEvent(new Event("mousedown"));
  8993. };
  8994. return select;
  8995. }
  8996.  
  8997. setMenuIconImageSources() {
  8998. this.ui.playButton.src = this.paused ? Autoplay.menuIconImageURLs.play : Autoplay.menuIconImageURLs.pause;
  8999. this.ui.settingsButton.src = Autoplay.menuIconImageURLs.tune;
  9000. this.ui.changeDirectionButton.src = Autoplay.menuIconImageURLs.changeDirection;
  9001. this.ui.changeDirectionMask.image.src = Autoplay.menuIconImageURLs.changeDirectionAlt;
  9002. this.ui.changeDirectionMask.container.classList.toggle("upper-right", Autoplay.settings.moveForward);
  9003. }
  9004.  
  9005. loadAutoplaySettingsIntoUI() {
  9006. this.ui.settingsMenu.imageDurationInput.value = Autoplay.settings.imageViewDurationInSeconds;
  9007. this.ui.settingsMenu.minimumVideoDurationInput.value = Autoplay.settings.minimumVideoDurationInSeconds;
  9008. }
  9009.  
  9010. addEventListeners() {
  9011. this.addMenuEventListeners();
  9012. this.addSettingsMenuEventListeners();
  9013. }
  9014.  
  9015. addMenuEventListeners() {
  9016. this.addDesktopMenuEventListeners();
  9017. this.addMobileMenuEventListeners();
  9018. }
  9019.  
  9020. addDesktopMenuEventListeners() {
  9021. if (Utils.onMobileDevice()) {
  9022. return;
  9023. }
  9024. this.ui.settingsButton.onclick = () => {
  9025. this.toggleSettingMenu();
  9026. };
  9027. this.ui.playButton.onclick = () => {
  9028. this.pause();
  9029. };
  9030. this.ui.changeDirectionButton.onclick = () => {
  9031. this.toggleDirection();
  9032. };
  9033. this.ui.menu.onmouseenter = () => {
  9034. this.toggleMenuPersistence(true);
  9035. };
  9036. this.ui.menu.onmouseleave = () => {
  9037. this.toggleMenuPersistence(false);
  9038. };
  9039. }
  9040.  
  9041. addMobileMenuEventListeners() {
  9042. if (!Utils.onMobileDevice()) {
  9043. return;
  9044. }
  9045. this.ui.settingsButton.ontouchstart = () => {
  9046. this.toggleSettingMenu();
  9047. const settingsMenuIsVisible = this.ui.settingsMenu.container.classList.contains("visible");
  9048.  
  9049. this.toggleMenuPersistence(settingsMenuIsVisible);
  9050. this.menuVisibilityTimer.restart();
  9051. };
  9052. this.ui.playButton.ontouchstart = () => {
  9053. this.pause();
  9054. this.menuVisibilityTimer.restart();
  9055. };
  9056. this.ui.changeDirectionButton.ontouchstart = () => {
  9057. this.toggleDirection();
  9058. this.menuVisibilityTimer.restart();
  9059. };
  9060. }
  9061.  
  9062. addSettingsMenuEventListeners() {
  9063. this.ui.settingsMenu.imageDurationInput.onchange = () => {
  9064. this.setImageViewDuration();
  9065.  
  9066. if (this.currentThumb !== null && Utils.isImage(this.currentThumb)) {
  9067. this.startViewTimer(this.currentThumb);
  9068. }
  9069. };
  9070. this.ui.settingsMenu.minimumVideoDurationInput.onchange = () => {
  9071. this.setMinimumVideoViewDuration();
  9072.  
  9073. if (this.currentThumb !== null && !Utils.isImage(this.currentThumb)) {
  9074. this.startViewTimer(this.currentThumb);
  9075. }
  9076. };
  9077. }
  9078.  
  9079. /**
  9080. * @param {Boolean} forward
  9081. */
  9082. toggleDirection(forward) {
  9083. const directionHasNotChanged = forward === Autoplay.settings.moveForward;
  9084.  
  9085. if (directionHasNotChanged) {
  9086. return;
  9087. }
  9088. Autoplay.settings.moveForward = !Autoplay.settings.moveForward;
  9089. this.ui.changeDirectionMask.container.classList.toggle("upper-right", Autoplay.settings.moveForward);
  9090. Utils.setPreference(Autoplay.preferences.direction, Autoplay.settings.moveForward);
  9091. }
  9092.  
  9093. /**
  9094. * @param {Boolean} value
  9095. */
  9096. toggleMenuPersistence(value) {
  9097. this.menuIsPersistent = value;
  9098. this.ui.menu.classList.toggle("persistent", value);
  9099. }
  9100.  
  9101. /**
  9102. * @param {Boolean} value
  9103. */
  9104. toggleMenuVisibility(value) {
  9105. this.menuIsVisible = value;
  9106. this.ui.menu.classList.toggle("visible", value);
  9107. }
  9108.  
  9109. /**
  9110. * @param {Boolean} value
  9111. */
  9112. toggleSettingMenu(value) {
  9113. if (value === undefined) {
  9114. this.ui.settingsMenu.container.classList.toggle("visible");
  9115. this.ui.settingsButton.classList.toggle("settings-menu-opened");
  9116. } else {
  9117. this.ui.settingsMenu.container.classList.toggle("visible", value);
  9118. this.ui.settingsButton.classList.toggle("settings-menu-opened", value);
  9119. }
  9120. }
  9121.  
  9122. /**
  9123. * @param {Boolean} value
  9124. */
  9125. toggle(value) {
  9126. Utils.setPreference(Autoplay.preferences.active, value);
  9127. this.active = value;
  9128.  
  9129. if (value) {
  9130. this.events.onEnable();
  9131. } else {
  9132. this.events.onDisable();
  9133. }
  9134. }
  9135.  
  9136. setImageViewDuration() {
  9137. let durationInSeconds = parseFloat(this.ui.settingsMenu.imageDurationInput.value);
  9138.  
  9139. if (isNaN(durationInSeconds)) {
  9140. durationInSeconds = Autoplay.settings.imageViewDurationInSeconds;
  9141. }
  9142. const duration = Math.round(Utils.clamp(durationInSeconds * 1000, 1000, 60000));
  9143.  
  9144. Utils.setPreference(Autoplay.preferences.imageDuration, duration);
  9145. Autoplay.settings.imageViewDuration = duration;
  9146. this.imageViewTimer.waitTime = duration;
  9147. this.ui.settingsMenu.imageDurationInput.value = Autoplay.settings.imageViewDurationInSeconds;
  9148. this.insertImageProgressHTML();
  9149. }
  9150.  
  9151. setMinimumVideoViewDuration() {
  9152. let durationInSeconds = parseFloat(this.ui.settingsMenu.minimumVideoDurationInput.value);
  9153.  
  9154. if (isNaN(durationInSeconds)) {
  9155. durationInSeconds = Autoplay.settings.minimumVideoDurationInSeconds;
  9156. }
  9157. const duration = Math.round(Utils.clamp(durationInSeconds * 1000, 0, 60000));
  9158.  
  9159. Utils.setPreference(Autoplay.preferences.minimumVideoDuration, duration);
  9160. Autoplay.settings.minimumVideoDuration = duration;
  9161. this.videoViewTimer.waitTime = duration;
  9162. this.ui.settingsMenu.minimumVideoDurationInput.value = Autoplay.settings.minimumVideoDurationInSeconds;
  9163. this.insertVideoProgressHTML();
  9164. }
  9165.  
  9166. /**
  9167. * @param {HTMLElement} thumb
  9168. */
  9169. startViewTimer(thumb) {
  9170. if (thumb === null) {
  9171. return;
  9172. }
  9173. this.currentThumb = thumb;
  9174.  
  9175. if (!this.active || Autoplay.disabled || this.paused) {
  9176. return;
  9177. }
  9178.  
  9179. if (Utils.isVideo(thumb)) {
  9180. this.startVideoViewTimer();
  9181. } else {
  9182. this.startImageViewTimer();
  9183. }
  9184. }
  9185.  
  9186. startImageViewTimer() {
  9187. this.stopVideoProgressBar();
  9188. this.stopVideoViewTimer();
  9189. this.startImageProgressBar();
  9190. this.imageViewTimer.restart();
  9191. }
  9192.  
  9193. stopImageViewTimer() {
  9194. this.imageViewTimer.stop();
  9195. this.stopImageProgressBar();
  9196. }
  9197.  
  9198. startVideoViewTimer() {
  9199. this.stopImageViewTimer();
  9200. this.stopImageProgressBar();
  9201. this.startVideoProgressBar();
  9202. this.videoViewTimer.restart();
  9203. }
  9204.  
  9205. stopVideoViewTimer() {
  9206. this.videoViewTimer.stop();
  9207. this.stopVideoProgressBar();
  9208. }
  9209.  
  9210. /**
  9211. * @param {HTMLElement} thumb
  9212. */
  9213. start(thumb) {
  9214. if (!this.active || Autoplay.disabled) {
  9215. return;
  9216. }
  9217. this.addAutoplayEventListeners();
  9218. this.ui.container.style.visibility = "visible";
  9219. this.showMenu();
  9220. this.startViewTimer(thumb);
  9221. }
  9222.  
  9223. stop() {
  9224. if (Autoplay.disabled) {
  9225. return;
  9226. }
  9227. this.ui.container.style.visibility = "hidden";
  9228. this.removeAutoplayEventListeners();
  9229. this.stopImageViewTimer();
  9230. this.stopVideoViewTimer();
  9231. this.forceHideMenu();
  9232. }
  9233.  
  9234. pause() {
  9235. this.paused = !this.paused;
  9236. Utils.setPreference(Autoplay.preferences.paused, this.paused);
  9237.  
  9238. if (this.paused) {
  9239. this.ui.playButton.src = Autoplay.menuIconImageURLs.play;
  9240. this.ui.playButton.title = "Resume Autoplay";
  9241. this.stopImageViewTimer();
  9242. this.stopVideoViewTimer();
  9243. this.events.onPause();
  9244. } else {
  9245. this.ui.playButton.src = Autoplay.menuIconImageURLs.pause;
  9246. this.ui.playButton.title = "Pause Autoplay";
  9247. this.startViewTimer(this.currentThumb);
  9248. this.events.onResume();
  9249. }
  9250. }
  9251.  
  9252. onVideoEnded() {
  9253. if (this.videoViewTimer.timeout === null) {
  9254. this.events.onComplete();
  9255. } else {
  9256. this.events.onVideoEndedBeforeMinimumViewTime();
  9257. }
  9258. }
  9259.  
  9260. addAutoplayEventListeners() {
  9261. this.imageViewTimer.onCooldownEnd = () => {
  9262. this.events.onComplete();
  9263. };
  9264. document.addEventListener("mousemove", () => {
  9265. this.showMenu();
  9266. }, {
  9267. signal: this.eventListenersAbortController.signal
  9268. });
  9269. document.addEventListener("keydown", (event) => {
  9270. if (!Utils.isHotkeyEvent(event)) {
  9271. return;
  9272. }
  9273.  
  9274. switch (event.key.toLowerCase()) {
  9275. case "p":
  9276. this.showMenu();
  9277. this.pause();
  9278. break;
  9279.  
  9280. case " ":
  9281. if (this.currentThumb !== null && !Utils.isVideo(this.currentThumb)) {
  9282. this.showMenu();
  9283. this.pause();
  9284. }
  9285. break;
  9286.  
  9287. default:
  9288. break;
  9289. }
  9290. }, {
  9291. signal: this.eventListenersAbortController.signal
  9292. });
  9293. }
  9294.  
  9295. removeAutoplayEventListeners() {
  9296. this.imageViewTimer.onCooldownEnd = () => { };
  9297. this.eventListenersAbortController.abort();
  9298. this.eventListenersAbortController = new AbortController();
  9299. }
  9300.  
  9301. showMenu() {
  9302. this.toggleMenuVisibility(true);
  9303. this.menuVisibilityTimer.restart();
  9304. }
  9305.  
  9306. hideMenu() {
  9307. this.toggleMenuVisibility(false);
  9308. }
  9309.  
  9310. forceHideMenu() {
  9311. this.toggleMenuPersistence(false);
  9312. this.toggleMenuVisibility(false);
  9313. this.toggleSettingMenu(false);
  9314. }
  9315.  
  9316. startImageProgressBar() {
  9317. this.stopImageProgressBar();
  9318. setTimeout(() => {
  9319. this.ui.imageProgressBar.classList.add("animated");
  9320. }, 10);
  9321. }
  9322.  
  9323. stopImageProgressBar() {
  9324. this.ui.imageProgressBar.classList.remove("animated");
  9325. }
  9326.  
  9327. startVideoProgressBar() {
  9328. this.stopVideoProgressBar();
  9329. setTimeout(() => {
  9330. this.ui.videoProgressBar.classList.add("animated");
  9331. }, 10);
  9332. }
  9333.  
  9334. stopVideoProgressBar() {
  9335. this.ui.videoProgressBar.classList.remove("animated");
  9336. }
  9337. }
  9338.  
  9339. class VideoClip {
  9340. /**
  9341. * @type {Number}
  9342. */
  9343. start;
  9344. /**
  9345. * @type {Number}
  9346. */
  9347. end;
  9348.  
  9349. /**
  9350. * @param {{start: Number, end: Number}} videoClip
  9351. */
  9352. constructor(videoClip) {
  9353. this.start = videoClip.start;
  9354. this.end = videoClip.end;
  9355. }
  9356. }
  9357.  
  9358. class Gallery {
  9359. static galleryHTML = `
  9360. <style>
  9361. body {
  9362. width: 99.5vw;
  9363. overflow-x: hidden;
  9364. }
  9365.  
  9366. .focused {
  9367. transition: none;
  9368. float: left;
  9369. overflow: hidden;
  9370. z-index: 9997;
  9371. pointer-events: none;
  9372. position: fixed;
  9373. height: 100vh;
  9374. margin: 0;
  9375. top: 50%;
  9376. left: 50%;
  9377. transform: translate(-50%, -50%);
  9378. }
  9379.  
  9380. #gallery-container {
  9381.  
  9382. >canvas,
  9383. img {
  9384. float: left;
  9385. overflow: hidden;
  9386. pointer-events: none;
  9387. position: fixed;
  9388. height: 100vh;
  9389. margin: 0;
  9390. top: 50%;
  9391. left: 50%;
  9392. transform: translate(-50%, -50%);
  9393. }
  9394. }
  9395.  
  9396. #original-video-container {
  9397. cursor: default;
  9398.  
  9399. video {
  9400. top: 0;
  9401. left: 0;
  9402. display: none;
  9403. position: fixed;
  9404. z-index: 9998;
  9405. }
  9406. }
  9407.  
  9408. #low-resolution-canvas {
  9409. z-index: 9996;
  9410. }
  9411.  
  9412. #main-canvas {
  9413. z-index: 9997;
  9414. }
  9415.  
  9416. a.hide {
  9417. cursor: default;
  9418. }
  9419.  
  9420. option {
  9421. font-size: 15px;
  9422. }
  9423.  
  9424. #resolution-dropdown {
  9425. text-align: center;
  9426. width: 160px;
  9427. height: 25px;
  9428. cursor: pointer;
  9429. }
  9430.  
  9431. #original-content-background {
  9432. position: fixed;
  9433. top: 0;
  9434. left: 0;
  9435. width: 100%;
  9436. height: 100%;
  9437. background: black;
  9438. z-index: 999;
  9439. display: none;
  9440. pointer-events: none;
  9441. cursor: default;
  9442. -webkit-user-drag: none;
  9443. -khtml-user-drag: none;
  9444. -moz-user-drag: none;
  9445. -o-user-drag: none;
  9446. }
  9447.  
  9448. #original-content-background-link-mask {
  9449. position: fixed;
  9450. top: 0;
  9451. left: 0;
  9452. width: 100%;
  9453. height: 100%;
  9454. background: red;
  9455. z-index: 10001;
  9456. pointer-events: none;
  9457. cursor: default;
  9458. display: none;
  9459. opacity: 0;
  9460. -webkit-user-drag: none;
  9461. -khtml-user-drag: none;
  9462. -moz-user-drag: none;
  9463. -o-user-drag: none;
  9464.  
  9465. &.active {
  9466. /* opacity: 0.2; */
  9467. pointer-events: all;
  9468. }
  9469. }
  9470.  
  9471. #original-gif-container {
  9472. z-index: 9995;
  9473. }
  9474. </style>
  9475. `;
  9476. static galleryDebugHTML = `
  9477. .thumb,
  9478. .favorite {
  9479. &.debug-selected {
  9480. outline: 3px solid #0075FF !important;
  9481. }
  9482.  
  9483. &.loaded {
  9484.  
  9485. div, a {
  9486. outline: 2px solid transparent;
  9487. animation: outlineGlow 1s forwards;
  9488. }
  9489.  
  9490. .image {
  9491. opacity: 1;
  9492. }
  9493. }
  9494.  
  9495. >a
  9496. >canvas {
  9497. position: absolute;
  9498. top: 0;
  9499. left: 0;
  9500. pointer-events: none;
  9501. z-index: 1;
  9502. visibility: hidden;
  9503. }
  9504.  
  9505. .image {
  9506. opacity: 0.4;
  9507. transition: transform 0.1s ease-in-out, opacity 0.5s ease;
  9508. }
  9509.  
  9510. }
  9511.  
  9512. .image.loaded {
  9513. animation: outlineGlow 1s forwards;
  9514. opacity: 1;
  9515. }
  9516.  
  9517. @keyframes outlineGlow {
  9518. 0% {
  9519. outline-color: transparent;
  9520. }
  9521.  
  9522. 100% {
  9523. outline-color: turquoise;
  9524. }
  9525. }
  9526.  
  9527. #main-canvas, #low-resolution-canvas {
  9528. opacity: 0.25;
  9529. }
  9530.  
  9531. #original-video-container {
  9532. video {
  9533. opacity: 0.15;
  9534. }
  9535. }
  9536.  
  9537. `;
  9538. static directions = {
  9539. d: "d",
  9540. a: "a",
  9541. right: "ArrowRight",
  9542. left: "ArrowLeft"
  9543. };
  9544. static preferences = {
  9545. showOnHover: "showImagesWhenHovering",
  9546. backgroundOpacity: "galleryBackgroundOpacity",
  9547. resolution: "galleryResolution",
  9548. enlargeOnClick: "enlargeOnClick",
  9549. videoVolume: "videoVolume",
  9550. videoMuted: "videoMuted"
  9551. };
  9552. static webWorkers = {
  9553. renderer:
  9554. `
  9555. /* eslint-disable prefer-template */
  9556. /**
  9557. * @param {Number} milliseconds
  9558. * @returns {Promise}
  9559. */
  9560. function sleep(milliseconds) {
  9561. return new Promise(resolve => setTimeout(resolve, milliseconds));
  9562. }
  9563.  
  9564. class RenderRequest {
  9565. /**
  9566. * @type {String}
  9567. */
  9568. id;
  9569. /**
  9570. * @type {String}
  9571. */
  9572. imageURL;
  9573. /**
  9574. * @type {String}
  9575. */
  9576. extension;
  9577. /**
  9578. * @type {String}
  9579. */
  9580. thumbURL;
  9581. /**
  9582. * @type {String}
  9583. */
  9584. fetchDelay;
  9585. /**
  9586. * @type {Number}
  9587. */
  9588. pixelCount;
  9589. /**
  9590. * @type {OffscreenCanvas}
  9591. */
  9592. canvas;
  9593. /**
  9594. * @type {Number}
  9595. */
  9596. resolutionFraction;
  9597. /**
  9598. * @type {AbortController}
  9599. */
  9600. abortController;
  9601. /**
  9602. * @type {Number}
  9603. */
  9604. get estimatedMegabyteSize() {
  9605. const rgb = 3;
  9606. const bytes = rgb * this.pixelCount;
  9607. const numberOfBytesInMegabyte = 1048576;
  9608. return bytes / numberOfBytesInMegabyte;
  9609. }
  9610.  
  9611. /**
  9612. * @param {{
  9613. * id: String,
  9614. * imageURL: String,
  9615. * extension: String,
  9616. * thumbURL: String,
  9617. * fetchDelay: String,
  9618. * pixelCount: Number,
  9619. * canvas: OffscreenCanvas,
  9620. * resolutionFraction: Number
  9621. * }} request
  9622. */
  9623. constructor(request) {
  9624. this.id = request.id;
  9625. this.imageURL = request.imageURL;
  9626. this.extension = request.extension;
  9627. this.thumbURL = request.thumbURL;
  9628. this.fetchDelay = request.fetchDelay;
  9629. this.pixelCount = request.pixelCount;
  9630. this.canvas = request.canvas;
  9631. this.resolutionFraction = request.resolutionFraction;
  9632. this.abortController = new AbortController();
  9633. }
  9634. }
  9635.  
  9636. class BatchRenderRequest {
  9637. static settings = {
  9638. megabyteMemoryLimit: 1000,
  9639. minimumRequestCount: 10
  9640. };
  9641.  
  9642. /**
  9643. * @type {String}
  9644. */
  9645. id;
  9646. /**
  9647. * @type {String}
  9648. */
  9649. requestType;
  9650. /**
  9651. * @type {RenderRequest[]}
  9652. */
  9653. renderRequests;
  9654. /**
  9655. * @type {RenderRequest[]}
  9656. */
  9657. originalRenderRequests;
  9658.  
  9659. get renderRequestIds() {
  9660. return new Set(this.renderRequests.map(request => request.id));
  9661. }
  9662.  
  9663. /**
  9664. * @param {{
  9665. * id: String,
  9666. * requestType: String,
  9667. * renderRequests: {
  9668. * id: String,
  9669. * imageURL: String,
  9670. * extension: String,
  9671. * thumbURL: String,
  9672. * fetchDelay: String,
  9673. * pixelCount: Number,
  9674. * canvas: OffscreenCanvas,
  9675. * resolutionFraction: Number
  9676. * }[]
  9677. * }} batchRequest
  9678. */
  9679. constructor(batchRequest) {
  9680. this.id = batchRequest.id;
  9681. this.requestType = batchRequest.requestType;
  9682. this.renderRequests = batchRequest.renderRequests.map(r => new RenderRequest(r));
  9683. this.originalRenderRequests = this.renderRequests;
  9684. this.truncateRenderRequestsExceedingMemoryLimit();
  9685. }
  9686.  
  9687. truncateRenderRequestsExceedingMemoryLimit() {
  9688. const truncatedRequests = [];
  9689. let currentMegabyteSize = 0;
  9690.  
  9691. for (const request of this.renderRequests) {
  9692. const overMemoryLimit = currentMegabyteSize < BatchRenderRequest.settings.megabyteMemoryLimit;
  9693. const underMinimumRequestCount = truncatedRequests.length < BatchRenderRequest.settings.minimumRequestCount;
  9694.  
  9695. if (overMemoryLimit || underMinimumRequestCount) {
  9696. truncatedRequests.push(request);
  9697. currentMegabyteSize += request.estimatedMegabyteSize;
  9698. } else {
  9699. postMessage({
  9700. action: "renderDeleted",
  9701. id: request.id
  9702. });
  9703. }
  9704. }
  9705. this.renderRequests = truncatedRequests;
  9706. }
  9707. }
  9708.  
  9709. class ImageFetcher {
  9710. /**
  9711. * @type {Set.<String>}
  9712. */
  9713. static idsToFetchFromPostPages = new Set();
  9714.  
  9715. /**
  9716. * @type {Number}
  9717. */
  9718. static get postPageFetchDelay() {
  9719. return ImageFetcher.idsToFetchFromPostPages.size * 250;
  9720. }
  9721.  
  9722. /**
  9723. * @param {RenderRequest} request
  9724. */
  9725. static async setOriginalImageURLAndExtension(request) {
  9726. if (request.extension !== null && request.extension !== undefined) {
  9727. request.imageURL = request.imageURL.replace("jpg", request.extension);
  9728. } else {
  9729. // eslint-disable-next-line require-atomic-updates
  9730. request.imageURL = await ImageFetcher.getOriginalImageURL(request.id);
  9731. request.extension = ImageFetcher.getExtensionFromImageURL(request.imageURL);
  9732. }
  9733. }
  9734.  
  9735. /**
  9736. * @param {String} id
  9737. * @returns {String}
  9738. */
  9739. static getOriginalImageURL(id) {
  9740. const apiURL = "https://api.rule34.xxx//index.php?page=dapi&s=post&q=index&id=" + id;
  9741. return fetch(apiURL)
  9742. .then((response) => {
  9743. if (response.ok) {
  9744. return response.text();
  9745. }
  9746. throw new Error(response.status + ": " + id);
  9747. })
  9748. .then((html) => {
  9749. return (/ file_url="(.*?)"/).exec(html)[1].replace("api-cdn.", "");
  9750. }).catch(() => {
  9751. return ImageFetcher.getOriginalImageURLFromPostPage(id);
  9752. });
  9753. }
  9754.  
  9755. /**
  9756. * @param {String} id
  9757. * @returns {String}
  9758. */
  9759. static async getOriginalImageURLFromPostPage(id) {
  9760. const postPageURL = "https://rule34.xxx/index.php?page=post&s=view&id=" + id;
  9761.  
  9762. ImageFetcher.idsToFetchFromPostPages.add(id);
  9763. await sleep(ImageFetcher.postPageFetchDelay);
  9764. return fetch(postPageURL)
  9765. .then((response) => {
  9766. if (response.ok) {
  9767. return response.text();
  9768. }
  9769. throw new Error(response.status + ": " + postPageURL);
  9770. })
  9771. .then((html) => {
  9772. ImageFetcher.idsToFetchFromPostPages.delete(id);
  9773. return (/itemprop="image" content="(.*)"/g).exec(html)[1].replace("us.rule34", "rule34");
  9774. }).catch((error) => {
  9775. if (error.message.includes("503")) {
  9776. return ImageFetcher.getOriginalImageURLFromPostPage(id);
  9777. }
  9778. console.error({
  9779. error,
  9780. url: postPageURL
  9781. });
  9782. return "https://rule34.xxx/images/r34chibi.png";
  9783. });
  9784. }
  9785.  
  9786. /**
  9787. * @param {String} imageURL
  9788. * @returns {String}
  9789. */
  9790. static getExtensionFromImageURL(imageURL) {
  9791. try {
  9792. return (/\.(png|jpg|jpeg|gif)/g).exec(imageURL)[1];
  9793. } catch (error) {
  9794. return "jpg";
  9795. }
  9796. }
  9797.  
  9798. /**
  9799. * @param {RenderRequest} request
  9800. * @returns {Promise}
  9801. */
  9802. static fetchImage(request) {
  9803. return fetch(request.imageURL, {
  9804. signal: request.abortController.signal
  9805. });
  9806. }
  9807.  
  9808. /**
  9809. * @param {RenderRequest} request
  9810. * @returns {Blob}
  9811. */
  9812. static async fetchImageBlob(request) {
  9813. const response = await ImageFetcher.fetchImage(request);
  9814. return response.blob();
  9815. }
  9816.  
  9817. /**
  9818. * @param {String} id
  9819. * @returns {String}
  9820. */
  9821. static async findImageExtensionFromId(id) {
  9822. const imageURL = await ImageFetcher.getOriginalImageURL(id);
  9823. const extension = ImageFetcher.getExtensionFromImageURL(imageURL);
  9824.  
  9825. postMessage({
  9826. action: "extensionFound",
  9827. id,
  9828. extension
  9829. });
  9830. }
  9831. }
  9832.  
  9833. class ThumbUpscaler {
  9834. static settings = {
  9835. maxCanvasHeight: 16000
  9836. };
  9837. /**
  9838. * @type {Map.<String, OffscreenCanvas>}
  9839. */
  9840. canvases = new Map();
  9841. /**
  9842. * @type {Number}
  9843. */
  9844. screenWidth;
  9845. /**
  9846. * @type {Boolean}
  9847. */
  9848. onSearchPage;
  9849.  
  9850. /**
  9851. * @param {Number} screenWidth
  9852. * @param {Boolean} onSearchPage
  9853. */
  9854. constructor(screenWidth, onSearchPage) {
  9855. this.screenWidth = screenWidth;
  9856. this.onSearchPage = onSearchPage;
  9857. }
  9858.  
  9859. /**
  9860. * @param {{id: String, imageURL: String, canvas: OffscreenCanvas, resolutionFraction: Number}[]} message
  9861. */
  9862. async upscaleMultipleAnimatedCanvases(message) {
  9863. const requests = message.map(r => new RenderRequest(r));
  9864.  
  9865. requests.forEach((request) => {
  9866. this.collectCanvas(request);
  9867. });
  9868.  
  9869. for (const request of requests) {
  9870. ImageFetcher.fetchImage(request)
  9871. .then((response) => {
  9872. return response.blob();
  9873. })
  9874. .then((blob) => {
  9875. createImageBitmap(blob)
  9876. .then((imageBitmap) => {
  9877. this.upscale(request, imageBitmap);
  9878. });
  9879. });
  9880. await sleep(50);
  9881. }
  9882. }
  9883.  
  9884. /**
  9885. * @param {RenderRequest} request
  9886. * @param {ImageBitmap} imageBitmap
  9887. */
  9888. upscale(request, imageBitmap) {
  9889. if (this.onSearchPage || imageBitmap === undefined || !this.canvases.has(request.id)) {
  9890. return;
  9891. }
  9892. this.setCanvasDimensions(request, imageBitmap);
  9893. this.drawCanvas(request.id, imageBitmap);
  9894. }
  9895.  
  9896. /**
  9897. * @param {RenderRequest} request
  9898. * @param {ImageBitmap} imageBitmap
  9899. */
  9900. setCanvasDimensions(request, imageBitmap) {
  9901. const canvas = this.canvases.get(request.id);
  9902. let width = this.screenWidth / request.resolutionFraction;
  9903. let height = (width / imageBitmap.width) * imageBitmap.height;
  9904.  
  9905. if (width > imageBitmap.width) {
  9906. width = imageBitmap.width;
  9907. height = imageBitmap.height;
  9908. }
  9909.  
  9910. if (height > ThumbUpscaler.settings.maxCanvasHeight) {
  9911. width *= (ThumbUpscaler.settings.maxCanvasHeight / height);
  9912. height = ThumbUpscaler.settings.maxCanvasHeight;
  9913. }
  9914. canvas.width = width;
  9915. canvas.height = height;
  9916. }
  9917.  
  9918. /**
  9919. * @param {String} id
  9920. * @param {ImageBitmap} imageBitmap
  9921. */
  9922. drawCanvas(id, imageBitmap) {
  9923. const canvas = this.canvases.get(id);
  9924. const context = canvas.getContext("2d");
  9925.  
  9926. context.clearRect(0, 0, canvas.width, canvas.height);
  9927. context.drawImage(
  9928. imageBitmap, 0, 0, imageBitmap.width, imageBitmap.height,
  9929. 0, 0, canvas.width, canvas.height
  9930. );
  9931. }
  9932.  
  9933. deleteAllCanvases() {
  9934. for (const [id, canvas] of this.canvases.entries()) {
  9935. this.deleteCanvas(id, canvas);
  9936. }
  9937. this.canvases.clear();
  9938. }
  9939.  
  9940. /**
  9941. * @param {String} id
  9942. * @param {OffscreenCanvas} canvas
  9943. */
  9944. deleteCanvas(id, canvas) {
  9945. const context = canvas.getContext("2d");
  9946.  
  9947. context.clearRect(0, 0, canvas.width, canvas.height);
  9948. canvas.width = 0;
  9949. canvas.height = 0;
  9950. canvas = null;
  9951. this.canvases.set(id, canvas);
  9952. this.canvases.delete(id);
  9953. }
  9954.  
  9955. /**
  9956. * @param {RenderRequest} request
  9957. */
  9958. collectCanvas(request) {
  9959. if (request.canvas === undefined) {
  9960. return;
  9961. }
  9962.  
  9963. if (!this.canvases.has(request.id)) {
  9964. this.canvases.set(request.id, request.canvas);
  9965. }
  9966. }
  9967.  
  9968. /**
  9969. * @param {BatchRenderRequest} batchRequest
  9970. */
  9971. collectCanvases(batchRequest) {
  9972. batchRequest.originalRenderRequests.forEach((request) => {
  9973. this.collectCanvas(request);
  9974. });
  9975. }
  9976. }
  9977.  
  9978. class ImageRenderer {
  9979. /**
  9980. * @type {OffscreenCanvas}
  9981. */
  9982. canvas;
  9983. /**
  9984. * @type {CanvasRenderingContext2D}
  9985. */
  9986. context;
  9987. /**
  9988. * @type {ThumbUpscaler}
  9989. */
  9990. thumbUpscaler;
  9991. /**
  9992. * @type {RenderRequest}
  9993. */
  9994. renderRequest;
  9995. /**
  9996. * @type {BatchRenderRequest}
  9997. */
  9998. batchRenderRequest;
  9999. /**
  10000. * @type {Map.<String, RenderRequest>}
  10001. */
  10002. incompleteRenderRequests;
  10003. /**
  10004. * @type {Map.<String, {completed: Boolean, imageBitmap: ImageBitmap, request: RenderRequest}>}
  10005. */
  10006. renders;
  10007. /**
  10008. * @type {String}
  10009. */
  10010. lastRequestedDrawId;
  10011. /**
  10012. * @type {String}
  10013. */
  10014. currentlyDrawnId;
  10015. /**
  10016. * @type {Boolean}
  10017. */
  10018. onMobileDevice;
  10019. /**
  10020. * @type {Boolean}
  10021. */
  10022. onSearchPage;
  10023. /**
  10024. * @type {Boolean}
  10025. */
  10026. usingLandscapeOrientation;
  10027.  
  10028. /**
  10029. * @type {Boolean}
  10030. */
  10031. get hasRenderRequest() {
  10032. return this.renderRequest !== undefined &&
  10033. this.renderRequest !== null;
  10034. }
  10035.  
  10036. /**
  10037. * @type {Boolean}
  10038. */
  10039. get hasBatchRenderRequest() {
  10040. return this.batchRenderRequest !== undefined &&
  10041. this.batchRenderRequest !== null;
  10042. }
  10043.  
  10044. /**
  10045. * @param {{canvas: OffscreenCanvas, screenWidth: Number, onMobileDevice: Boolean, onSearchPage: Boolean }} message
  10046. */
  10047. constructor(message) {
  10048. this.canvas = message.canvas;
  10049. this.context = this.canvas.getContext("2d");
  10050. this.thumbUpscaler = new ThumbUpscaler(message.screenWidth, message.onSearchPage);
  10051. this.renders = new Map();
  10052. this.incompleteRenderRequests = new Map();
  10053. this.lastRequestedDrawId = "";
  10054. this.currentlyDrawnId = "";
  10055. this.onMobileDevice = message.onMobileDevice;
  10056. this.onSearchPage = message.onSearchPage;
  10057. this.usingLandscapeOrientation = true;
  10058. this.configureCanvasQuality();
  10059. }
  10060.  
  10061. configureCanvasQuality() {
  10062. this.context.imageSmoothingEnabled = true;
  10063. this.context.imageSmoothingQuality = "high";
  10064. this.context.lineJoin = "miter";
  10065. }
  10066.  
  10067. renderMultipleImages(message) {
  10068. const batchRenderRequest = new BatchRenderRequest(message);
  10069.  
  10070. this.thumbUpscaler.collectCanvases(batchRenderRequest);
  10071. this.abortOutdatedFetchRequests(batchRenderRequest);
  10072. this.deleteRendersNotInNewRequests(batchRenderRequest);
  10073. this.removeStartedRenderRequests(batchRenderRequest);
  10074. this.batchRenderRequest = batchRenderRequest;
  10075. this.renderMultipleImagesHelper(batchRenderRequest);
  10076. }
  10077.  
  10078. /**
  10079. * @param {BatchRenderRequest} batchRenderRequest
  10080. */
  10081. async renderMultipleImagesHelper(batchRenderRequest) {
  10082. for (const request of batchRenderRequest.renderRequests) {
  10083. if (this.renders.has(request.id)) {
  10084. continue;
  10085. }
  10086. this.renders.set(request.id, {
  10087. completed: false,
  10088. imageBitmap: undefined,
  10089. request
  10090. });
  10091. }
  10092.  
  10093. for (const request of batchRenderRequest.renderRequests) {
  10094. this.renderImage(request);
  10095. await sleep(request.fetchDelay);
  10096. }
  10097. }
  10098.  
  10099. /**
  10100. * @param {RenderRequest} request
  10101. * @param {Number} batchRequestId
  10102. */
  10103. async renderImage(request) {
  10104. this.incompleteRenderRequests.set(request.id, request);
  10105. await ImageFetcher.setOriginalImageURLAndExtension(request);
  10106. let blob;
  10107.  
  10108. try {
  10109. blob = await ImageFetcher.fetchImageBlob(request);
  10110. } catch (error) {
  10111. if (error.name === "AbortError") {
  10112. this.deleteRender(request.id);
  10113. } else {
  10114. console.error({
  10115. error,
  10116. request
  10117. });
  10118. }
  10119. return;
  10120. }
  10121. const imageBitmap = await createImageBitmap(blob);
  10122.  
  10123. this.renders.set(request.id, {
  10124. completed: true,
  10125. imageBitmap,
  10126. request
  10127. });
  10128. this.incompleteRenderRequests.delete(request.id);
  10129. this.thumbUpscaler.upscale(request, imageBitmap);
  10130. postMessage({
  10131. action: "renderCompleted",
  10132. extension: request.extension,
  10133. id: request.id
  10134. });
  10135.  
  10136. if (this.lastRequestedDrawId === request.id) {
  10137. this.drawCanvas(request.id);
  10138. }
  10139. }
  10140.  
  10141. /**
  10142. * @param {String} id
  10143. * @returns {Boolean}
  10144. */
  10145. renderHasCompleted(id) {
  10146. const render = this.renders.get(id);
  10147. return render !== undefined && render.completed;
  10148. }
  10149.  
  10150. /**
  10151. * @param {String} id
  10152. */
  10153. drawCanvas(id) {
  10154. const render = this.renders.get(id);
  10155.  
  10156. if (render === undefined || render.imageBitmap === undefined) {
  10157. this.clearCanvas();
  10158. return;
  10159. }
  10160.  
  10161. if (this.currentlyDrawnId === id) {
  10162. return;
  10163. }
  10164.  
  10165. if (render.completed) {
  10166. this.currentlyDrawnCanvasId = id;
  10167. }
  10168. const ratio = Math.min(this.canvas.width / render.imageBitmap.width, this.canvas.height / render.imageBitmap.height);
  10169. const centerShiftX = (this.canvas.width - (render.imageBitmap.width * ratio)) / 2;
  10170. const centerShiftY = (this.canvas.height - (render.imageBitmap.height * ratio)) / 2;
  10171.  
  10172. this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
  10173. this.context.drawImage(
  10174. render.imageBitmap, 0, 0, render.imageBitmap.width, render.imageBitmap.height,
  10175. centerShiftX, centerShiftY, render.imageBitmap.width * ratio, render.imageBitmap.height * ratio
  10176. );
  10177. }
  10178.  
  10179. /**
  10180. * @param {Boolean} usingLandscapeOrientation
  10181. */
  10182. changeCanvasOrientation(usingLandscapeOrientation) {
  10183. if (usingLandscapeOrientation !== this.usingLandscapeOrientation) {
  10184. this.swapCanvasOrientation();
  10185. }
  10186. }
  10187.  
  10188. swapCanvasOrientation() {
  10189. const temp = this.canvas.width;
  10190.  
  10191. this.canvas.width = this.canvas.height;
  10192. this.canvas.height = temp;
  10193. this.usingLandscapeOrientation = !this.usingLandscapeOrientation;
  10194. }
  10195.  
  10196. clearCanvas() {
  10197. this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
  10198. }
  10199.  
  10200. deleteAllRenders() {
  10201. this.thumbUpscaler.deleteAllCanvases();
  10202. this.abortAllFetchRequests();
  10203.  
  10204. for (const id of this.renders.keys()) {
  10205. this.deleteRender(id, true);
  10206. }
  10207. this.batchRenderRequest = undefined;
  10208. this.renderRequest = undefined;
  10209. this.renders.clear();
  10210. }
  10211.  
  10212. /**
  10213. * @param {BatchRenderRequest} newBatchRenderRequest
  10214. */
  10215. deleteRendersNotInNewRequests(newBatchRenderRequest) {
  10216. const idsToRender = newBatchRenderRequest.renderRequestIds;
  10217.  
  10218. for (const id of this.renders.keys()) {
  10219. if (!idsToRender.has(id)) {
  10220. this.deleteRender(id);
  10221. }
  10222. }
  10223. }
  10224.  
  10225. /**
  10226. * @param {String} id
  10227. * @param {Boolean} initiatedByMainThread
  10228. */
  10229. deleteRender(id, initiatedByMainThread = false) {
  10230. if (!this.renders.has(id)) {
  10231. return;
  10232. }
  10233. const imageBitmap = this.renders.get(id).imageBitmap;
  10234.  
  10235. if (imageBitmap !== null && imageBitmap !== undefined) {
  10236. imageBitmap.close();
  10237. }
  10238. this.renders.set(id, null);
  10239. this.renders.delete(id);
  10240.  
  10241. if (initiatedByMainThread) {
  10242. return;
  10243. }
  10244. postMessage({
  10245. action: "renderDeleted",
  10246. id
  10247. });
  10248. }
  10249.  
  10250. /**
  10251. * @param {BatchRenderRequest} newBatchRenderRequest
  10252. */
  10253. abortOutdatedFetchRequests(newBatchRenderRequest) {
  10254. const newIds = newBatchRenderRequest.renderRequestIds;
  10255.  
  10256. for (const [id, request] of this.incompleteRenderRequests.entries()) {
  10257. if (!newIds.has(id)) {
  10258. request.abortController.abort();
  10259. this.incompleteRenderRequests.delete(id);
  10260. }
  10261. }
  10262. }
  10263.  
  10264. abortAllFetchRequests() {
  10265. for (const request of this.incompleteRenderRequests.values()) {
  10266. request.abortController.abort();
  10267. }
  10268. this.incompleteRenderRequests.clear();
  10269. }
  10270.  
  10271. /**
  10272. * @param {BatchRenderRequest} batchRenderRequest
  10273. */
  10274. removeStartedRenderRequests(batchRenderRequest) {
  10275. batchRenderRequest.renderRequests = batchRenderRequest.renderRequests
  10276. .filter(request => !this.renders.has(request.id));
  10277. }
  10278. /**
  10279. * @param {BatchRenderRequest} batchRenderRequest
  10280. */
  10281. removeCompletedRenderRequests(batchRenderRequest) {
  10282. batchRenderRequest.renderRequests = batchRenderRequest.renderRequests
  10283. .filter(request => !this.renderHasCompleted(request.id));
  10284. }
  10285.  
  10286. upscaleAllRenderedThumbs() {
  10287. for (const render of this.renders.values()) {
  10288. this.thumbUpscaler.upscale(render.request, render.imageBitmap);
  10289. }
  10290. }
  10291.  
  10292. onmessage(message) {
  10293. switch (message.action) {
  10294. case "render":
  10295. this.renderRequest = new RenderRequest(message);
  10296. this.lastRequestedDrawId = message.id;
  10297. this.thumbUpscaler.collectCanvas(this.renderRequest);
  10298. this.renderImage(this.renderRequest);
  10299. break;
  10300.  
  10301. case "renderMultiple":
  10302. this.renderMultipleImages(message);
  10303. break;
  10304.  
  10305. case "deleteAllRenders":
  10306. this.deleteAllRenders();
  10307. break;
  10308.  
  10309. case "drawMainCanvas":
  10310. this.lastRequestedDrawId = message.id;
  10311. this.drawCanvas(message.id);
  10312. break;
  10313.  
  10314. case "clearMainCanvas":
  10315. this.clearCanvas();
  10316. break;
  10317.  
  10318. case "upscaleAnimatedThumbs":
  10319. this.thumbUpscaler.upscaleMultipleAnimatedCanvases(message.upscaleRequests);
  10320. break;
  10321.  
  10322. case "changeCanvasOrientation":
  10323. this.changeCanvasOrientation(message.usingLandscapeOrientation);
  10324. break;
  10325.  
  10326. case "upscaleAllRenderedThumbs":
  10327. this.upscaleAllRenderedThumbs();
  10328. break;
  10329.  
  10330. default:
  10331. break;
  10332. }
  10333. }
  10334. }
  10335.  
  10336. /**
  10337. * @type {ImageRenderer}
  10338. */
  10339. let imageRenderer;
  10340.  
  10341. onmessage = (message) => {
  10342. switch (message.data.action) {
  10343. case "initialize":
  10344. BatchRenderRequest.settings.megabyteMemoryLimit = message.data.megabyteLimit;
  10345. BatchRenderRequest.settings.minimumRequestCount = message.data.minimumImagesToRender;
  10346. imageRenderer = new ImageRenderer(message.data);
  10347. break;
  10348.  
  10349. case "findExtension":
  10350. ImageFetcher.findImageExtensionFromId(message.data.id);
  10351. break;
  10352.  
  10353. default:
  10354. imageRenderer.onmessage(message.data);
  10355. break;
  10356. }
  10357. };
  10358.  
  10359. `
  10360. };
  10361. static canvasResolutions = {
  10362. search: "3840x2160",
  10363. favorites: Utils.onMobileDevice() ? "1920x1080" : "7680x4320",
  10364. low: Utils.onMobileDevice() ? "640x360" : "1280:720"
  10365. };
  10366. static swipeControls = {
  10367. threshold: 60,
  10368. touchStart: {
  10369. x: 0,
  10370. y: 0
  10371. },
  10372. touchEnd: {
  10373. x: 0,
  10374. y: 0
  10375. },
  10376. get deltaX() {
  10377. return this.touchStart.x - this.touchEnd.x;
  10378. },
  10379. get deltaY() {
  10380. return this.touchStart.y - this.touchEnd.y;
  10381. },
  10382. get right() {
  10383. return this.deltaX < -this.threshold;
  10384. },
  10385. get left() {
  10386. return this.deltaX > this.threshold;
  10387. },
  10388. get up() {
  10389. return this.deltaY > this.threshold;
  10390. },
  10391. get down() {
  10392. return this.deltaY < -this.threshold;
  10393. },
  10394. /**
  10395. * @param {TouchEvent} touchEvent
  10396. * @param {Boolean} atStart
  10397. */
  10398. set(touchEvent, atStart) {
  10399. if (atStart) {
  10400. this.touchStart.x = touchEvent.changedTouches[0].screenX;
  10401. this.touchStart.y = touchEvent.changedTouches[0].screenY;
  10402. } else {
  10403. this.touchEnd.x = touchEvent.changedTouches[0].screenX;
  10404. this.touchEnd.y = touchEvent.changedTouches[0].screenY;
  10405. }
  10406. }
  10407. };
  10408. static commonVideoAttributes = "width=\"100%\" height=\"100%\" autoplay muted loop controlsList=\"nofullscreen\" webkit-playsinline playsinline";
  10409. static settings = {
  10410. maxImagesToRenderInBackground: 50,
  10411. maxImagesToRenderAround: Utils.onMobileDevice() ? 3 : 50,
  10412. megabyteLimit: Utils.onMobileDevice() ? 0 : 400,
  10413. minImagesToRender: Utils.onMobileDevice() ? 3 : 8,
  10414. imageFetchDelay: 250,
  10415. throttledImageFetchDelay: 400,
  10416. imageFetchDelayWhenExtensionKnown: Utils.onMobileDevice() ? 50 : 25,
  10417. upscaledThumbResolutionFraction: 4,
  10418. upscaledAnimatedThumbResolutionFraction: 6,
  10419. animatedThumbsToUpscaleRange: 20,
  10420. animatedThumbsToUpscaleDiscrete: 20,
  10421. traversalCooldownTime: 300,
  10422. renderOnPageChangeCooldownTime: 2000,
  10423. addFavoriteCooldownTime: 250,
  10424. cursorVisibilityCooldownTime: 500,
  10425. imageExtensionAssignmentCooldownTime: 1000,
  10426. additionalVideoPlayerCount: Utils.onMobileDevice() ? 2 : 2,
  10427. renderAroundAggressively: true,
  10428. loopAtEndOfGalleryValue: false,
  10429. get loopAtEndOfGallery() {
  10430. if (!Utils.onFavoritesPage() || !Gallery.finishedLoading) {
  10431. return true;
  10432. }
  10433. return this.loopAtEndOfGalleryValue;
  10434. },
  10435. debugEnabled: false
  10436. };
  10437. static keyHeldDownTraversalCooldown = new Cooldown(Gallery.settings.traversalCooldownTime);
  10438. static backgroundRenderingOnPageChangeCooldown = new Cooldown(Gallery.settings.renderOnPageChangeCooldownTime, true);
  10439. static addOrRemoveFavoriteCooldown = new Cooldown(Gallery.settings.addFavoriteCooldownTime, true);
  10440. static cursorVisibilityCooldown = new Cooldown(Gallery.settings.cursorVisibilityCooldownTime);
  10441. static finishedLoading = Utils.onSearchPage();
  10442. /**
  10443. * @returns {Boolean}
  10444. */
  10445. static get disabled() {
  10446. return (Utils.onMobileDevice() && Utils.onSearchPage()) || Utils.getPerformanceProfile() > 0 || Utils.onPostPage();
  10447. }
  10448.  
  10449. /**
  10450. * @type {Autoplay}
  10451. */
  10452. autoplayController;
  10453. /**
  10454. * @type {HTMLDivElement}
  10455. */
  10456. originalContentContainer;
  10457. /**
  10458. * @type {HTMLCanvasElement}
  10459. */
  10460. mainCanvas;
  10461. /**
  10462. * @type {HTMLCanvasElement}
  10463. */
  10464. lowResolutionCanvas;
  10465. /**
  10466. * @type {CanvasRenderingContext2D}
  10467. */
  10468. lowResolutionContext;
  10469. /**
  10470. * @type {HTMLAnchorElement}
  10471. */
  10472. videoContainer;
  10473. /**
  10474. * @type {HTMLVideoElement[]}
  10475. */
  10476. videoPlayers;
  10477. /**
  10478. * @type {HTMLImageElement}
  10479. */
  10480. gifContainer;
  10481. /**
  10482. * @type {HTMLDivElement}
  10483. */
  10484. originalImageLinkMask;
  10485. /**
  10486. * @type {HTMLAnchorElement}
  10487. */
  10488. background;
  10489. /**
  10490. * @type {HTMLElement}
  10491. */
  10492. thumbUnderCursor;
  10493. /**
  10494. * @type {HTMLElement}
  10495. */
  10496. lastEnteredThumb;
  10497. /**
  10498. * @type {Worker}
  10499. */
  10500. imageRenderer;
  10501. /**
  10502. * @type {Set.<String>}
  10503. */
  10504. startedRenders;
  10505. /**
  10506. * @type {Set.<String>}
  10507. */
  10508. completedRenders;
  10509. /**
  10510. * @type {Map.<String, HTMLCanvasElement>}
  10511. */
  10512. transferredCanvases;
  10513. /**
  10514. * @type {Map.<String, VideoClip>}
  10515. */
  10516. videoClips;
  10517. /**
  10518. * @type {Map.<String, String>}
  10519. */
  10520. enumeratedThumbs;
  10521. /**
  10522. * @type {HTMLElement[]}
  10523. */
  10524. visibleThumbs;
  10525. /**
  10526. * @type {Post[]}
  10527. */
  10528. latestSearchResults;
  10529. /**
  10530. * @type {Object.<Number, String>}
  10531. */
  10532. imageExtensions;
  10533. /**
  10534. * @type {String}
  10535. */
  10536. foundFavoriteId;
  10537. /**
  10538. * @type {String}
  10539. */
  10540. changedPageInGalleryDirection;
  10541. /**
  10542. * @type {Number}
  10543. */
  10544. recentlyDiscoveredImageExtensionCount;
  10545. /**
  10546. * @type {Number}
  10547. */
  10548. currentlySelectedThumbIndex;
  10549. /**
  10550. * @type {Number}
  10551. */
  10552. lastSelectedThumbIndexBeforeEnteringGallery;
  10553. /**
  10554. * @type {Number}
  10555. */
  10556. currentBatchRenderRequestId;
  10557. /**
  10558. * @type {Boolean}
  10559. */
  10560. inGallery;
  10561. /**
  10562. * @type {Boolean}
  10563. */
  10564. recentlyEnteredGallery;
  10565. /**
  10566. * @type {Boolean}
  10567. */
  10568. recentlyExitedGallery;
  10569. /**
  10570. * @type {Boolean}
  10571. */
  10572. leftPage;
  10573. /**
  10574. * @type {Boolean}
  10575. */
  10576. favoritesWereFetched;
  10577. /**
  10578. * @type {Boolean}
  10579. */
  10580. showOriginalContentOnHover;
  10581. /**
  10582. * @type {Boolean}
  10583. */
  10584. enlargeOnClickOnMobile;
  10585.  
  10586. /**
  10587. * @type {Boolean}
  10588. */
  10589. get changedPageWhileInGallery() {
  10590. return this.changedPageInGalleryDirection !== null;
  10591. }
  10592.  
  10593. constructor() {
  10594. if (Gallery.disabled) {
  10595. return;
  10596. }
  10597. this.createAutoplayController();
  10598. this.initializeFields();
  10599. this.initializeTimers();
  10600. this.setMainCanvasResolution();
  10601. this.createWebWorkers();
  10602. this.createVideoBackgrounds();
  10603. this.addEventListeners();
  10604. this.createImageRendererMessageHandler();
  10605. this.prepareSearchPage();
  10606. this.insertHTML();
  10607. this.updateBackgroundOpacity(Utils.getPreference(Gallery.preferences.backgroundOpacity, 1));
  10608. this.loadVideoClips();
  10609. this.setOrientation();
  10610. this.createMobileTapControls();
  10611. }
  10612.  
  10613. initializeFields() {
  10614. this.mainCanvas = document.createElement("canvas");
  10615. this.lowResolutionCanvas = document.createElement("canvas");
  10616. this.lowResolutionContext = this.lowResolutionCanvas.getContext("2d");
  10617. this.thumbUnderCursor = null;
  10618. this.lastEnteredThumb = null;
  10619. this.startedRenders = new Set();
  10620. this.completedRenders = new Set();
  10621. this.transferredCanvases = new Map();
  10622. this.videoClips = new Map();
  10623. this.enumeratedThumbs = new Map();
  10624. this.visibleThumbs = [];
  10625. this.latestSearchResults = [];
  10626. this.imageExtensions = {};
  10627. this.foundFavoriteId = null;
  10628. this.changedPageInGalleryDirection = null;
  10629. this.recentlyDiscoveredImageExtensionCount = 0;
  10630. this.currentlySelectedThumbIndex = 0;
  10631. this.lastSelectedThumbIndexBeforeEnteringGallery = 0;
  10632. this.currentBatchRenderRequestId = 0;
  10633. this.inGallery = false;
  10634. this.recentlyEnteredGallery = false;
  10635. this.recentlyExitedGallery = false;
  10636. this.leftPage = false;
  10637. this.favoritesWereFetched = false;
  10638. this.showOriginalContentOnHover = Utils.getPreference(Gallery.preferences.showOnHover, true);
  10639. this.enlargeOnClickOnMobile = Utils.getPreference(Gallery.preferences.enlargeOnClick, true);
  10640. }
  10641.  
  10642. initializeTimers() {
  10643. Gallery.backgroundRenderingOnPageChangeCooldown.onDebounceEnd = () => {
  10644. this.onPageChange();
  10645. };
  10646. }
  10647.  
  10648. setMainCanvasResolution() {
  10649. const resolution = Utils.onSearchPage() ? Gallery.canvasResolutions.search : Gallery.canvasResolutions.favorites;
  10650. const dimensions = resolution.split("x").map(dimension => parseFloat(dimension));
  10651.  
  10652. this.mainCanvas.width = dimensions[0];
  10653. this.mainCanvas.height = dimensions[1];
  10654. }
  10655.  
  10656. createWebWorkers() {
  10657. const offscreenCanvas = this.mainCanvas.transferControlToOffscreen();
  10658.  
  10659. this.imageRenderer = new Worker(Utils.getWorkerURL(Gallery.webWorkers.renderer));
  10660. this.imageRenderer.postMessage({
  10661. action: "initialize",
  10662. canvas: offscreenCanvas,
  10663. onMobileDevice: Utils.onMobileDevice(),
  10664. screenWidth: window.screen.width,
  10665. megabyteLimit: Gallery.settings.megabyteLimit,
  10666. minimumImagesToRender: Gallery.settings.minImagesToRender,
  10667. onSearchPage: Utils.onSearchPage()
  10668. }, [offscreenCanvas]);
  10669. }
  10670.  
  10671. createVideoBackgrounds() {
  10672. document.createElement("canvas").toBlob((blob) => {
  10673. const videoBackgroundURL = URL.createObjectURL(blob);
  10674.  
  10675. for (const video of this.videoPlayers) {
  10676. video.setAttribute("poster", videoBackgroundURL);
  10677. }
  10678. });
  10679. }
  10680.  
  10681. addEventListeners() {
  10682. this.addGalleryEventListeners();
  10683. this.addFavoritesLoaderEventListeners();
  10684. this.addMobileEventListeners();
  10685. this.addMemoryManagementEventListeners();
  10686. }
  10687.  
  10688. addGalleryEventListeners() {
  10689. window.addEventListener("load", () => {
  10690. if (Utils.onSearchPage()) {
  10691. this.initializeThumbsForHovering.bind(this)();
  10692. this.enumerateThumbs();
  10693. }
  10694. this.hideCaptionsWhenShowingOriginalContent();
  10695. }, {
  10696. once: true,
  10697. passive: true
  10698. });
  10699.  
  10700. // eslint-disable-next-line complexity
  10701. document.addEventListener("mousedown", (event) => {
  10702. if (this.clickedOnAutoplayMenu(event)) {
  10703. return;
  10704. }
  10705. const clickedOnTapControls = event.target.classList.contains("mobile-tap-control");
  10706.  
  10707. if (clickedOnTapControls) {
  10708. return;
  10709. }
  10710. const clickedOnAnImage = event.target.tagName.toLowerCase() === "img" && !event.target.parentElement.classList.contains("add-or-remove-button");
  10711. const clickedOnAThumb = clickedOnAnImage && (Utils.getThumbFromImage(event.target).className.includes("thumb") || Utils.getThumbFromImage(event.target).className.includes(Utils.favoriteItemClassName));
  10712. const clickedOnACaptionTag = event.target.classList.contains("caption-tag");
  10713. const thumb = clickedOnAThumb ? Utils.getThumbFromImage(event.target) : null;
  10714.  
  10715. if (clickedOnAThumb) {
  10716. this.currentlySelectedThumbIndex = this.getIndexFromThumb(thumb);
  10717. }
  10718.  
  10719. if (event.ctrlKey && event.button === Utils.clickCodes.left) {
  10720. return;
  10721. }
  10722.  
  10723. switch (event.button) {
  10724. case Utils.clickCodes.left:
  10725. if (event.shiftKey && (this.inGallery || clickedOnAThumb)) {
  10726. this.openPostInNewPage();
  10727. return;
  10728. }
  10729.  
  10730. if (this.inGallery) {
  10731. if (Utils.isVideo(this.getSelectedThumb()) && !Utils.onMobileDevice()) {
  10732. return;
  10733. }
  10734. this.exitGallery();
  10735. this.toggleAllVisibility(false);
  10736. return;
  10737. }
  10738.  
  10739. if (!clickedOnAThumb) {
  10740. return;
  10741. }
  10742.  
  10743. if (Utils.onMobileDevice()) {
  10744. if (!this.enlargeOnClickOnMobile) {
  10745. this.openPostInNewPage(thumb);
  10746. return;
  10747. }
  10748. this.deleteAllRenders();
  10749. }
  10750.  
  10751. if (Utils.onMobileDevice()) {
  10752. this.renderImagesAround(thumb);
  10753. }
  10754.  
  10755. this.toggleAllVisibility(true);
  10756. this.enterGallery();
  10757. this.showOriginalContent(thumb);
  10758. break;
  10759.  
  10760. case Utils.clickCodes.middle:
  10761. event.preventDefault();
  10762.  
  10763. if (this.inGallery || (clickedOnAThumb && Utils.onSearchPage())) {
  10764. this.openPostInNewPage();
  10765. return;
  10766. }
  10767.  
  10768. if (!clickedOnAThumb && !clickedOnACaptionTag) {
  10769. this.toggleAllVisibility();
  10770. Utils.setPreference(Gallery.preferences.showOnHover, this.showOriginalContentOnHover);
  10771. }
  10772. break;
  10773.  
  10774. default:
  10775. break;
  10776. }
  10777. });
  10778. window.addEventListener("auxclick", (event) => {
  10779. if (event.button === Utils.clickCodes.middle) {
  10780. event.preventDefault();
  10781. }
  10782. });
  10783. document.addEventListener("wheel", (event) => {
  10784. if (event.shiftKey) {
  10785. return;
  10786. }
  10787.  
  10788. if (this.inGallery) {
  10789. if (event.ctrlKey) {
  10790. return;
  10791. }
  10792. const delta = (event.wheelDelta ? event.wheelDelta : -event.deltaY);
  10793. const direction = delta > 0 ? Gallery.directions.left : Gallery.directions.right;
  10794.  
  10795. this.traverseGallery.bind(this)(direction, false);
  10796. } else if (this.thumbUnderCursor !== null && this.showOriginalContentOnHover) {
  10797. let opacity = parseFloat(Utils.getPreference(Gallery.preferences.backgroundOpacity, 1));
  10798.  
  10799. opacity -= event.deltaY * 0.0005;
  10800. opacity = Utils.clamp(opacity, "0", "1");
  10801. this.updateBackgroundOpacity(opacity);
  10802. }
  10803. }, {
  10804. passive: true
  10805. });
  10806. document.addEventListener("contextmenu", (event) => {
  10807. if (this.inGallery) {
  10808. event.preventDefault();
  10809. this.exitGallery();
  10810. }
  10811. });
  10812. document.addEventListener("keydown", (event) => {
  10813. if (!this.inGallery) {
  10814. return;
  10815. }
  10816.  
  10817. switch (event.key) {
  10818. case Gallery.directions.a:
  10819.  
  10820. case Gallery.directions.d:
  10821.  
  10822. case Gallery.directions.left:
  10823.  
  10824. case Gallery.directions.right:
  10825. this.traverseGallery(event.key, event.repeat);
  10826. break;
  10827.  
  10828. case "X":
  10829.  
  10830. case "x":
  10831. this.unFavoriteSelectedContent();
  10832. break;
  10833.  
  10834. case " ":
  10835. if (Utils.isVideo(this.getSelectedThumb())) {
  10836. const video = this.getActiveVideoPlayer();
  10837.  
  10838. if (video === document.activeElement) {
  10839. return;
  10840. }
  10841.  
  10842. if (video.paused) {
  10843. video.play().catch(() => { });
  10844. } else {
  10845. video.pause();
  10846. }
  10847. }
  10848. break;
  10849.  
  10850. case "Control":
  10851. if (!event.repeat) {
  10852. this.toggleCtrlClickOpenMediaInNewTab(true);
  10853. }
  10854. break;
  10855.  
  10856. default:
  10857. break;
  10858. }
  10859. }, {
  10860. passive: true
  10861. });
  10862. window.addEventListener("keydown", async(event) => {
  10863. if (!this.inGallery) {
  10864. return;
  10865. }
  10866. const zoomedIn = document.getElementById("main-canvas-zoom") !== null;
  10867.  
  10868. switch (event.key) {
  10869. case "F":
  10870.  
  10871. case "f":
  10872. await this.addFavoriteInGallery(event);
  10873. break;
  10874.  
  10875. case "M":
  10876.  
  10877. case "m":
  10878. if (Utils.isVideo(this.getSelectedThumb())) {
  10879. this.getActiveVideoPlayer().muted = !this.getActiveVideoPlayer().muted;
  10880. }
  10881. break;
  10882.  
  10883. case "B":
  10884.  
  10885. case "b":
  10886. this.toggleBackgroundOpacity();
  10887. break;
  10888.  
  10889. case "n":
  10890. this.toggleCursorVisibility(true);
  10891. Gallery.cursorVisibilityCooldown.restart();
  10892. break;
  10893.  
  10894. case "Escape":
  10895. this.exitGallery();
  10896. this.toggleAllVisibility(false);
  10897. break;
  10898.  
  10899. default:
  10900. break;
  10901. }
  10902. }, {
  10903. passive: true
  10904. });
  10905. window.addEventListener("keyup", (event) => {
  10906. if (!this.inGallery) {
  10907. return;
  10908. }
  10909.  
  10910. switch (event.key) {
  10911. case "Control":
  10912. this.toggleCtrlClickOpenMediaInNewTab(false);
  10913. break;
  10914.  
  10915. default:
  10916. break;
  10917. }
  10918. });
  10919. window.addEventListener("blur", () => {
  10920. this.toggleCtrlClickOpenMediaInNewTab(false);
  10921. });
  10922. }
  10923.  
  10924. /**
  10925. * @param {MouseEvent | TouchEvent} event
  10926. */
  10927. clickedOnAutoplayMenu(event) {
  10928. const autoplayMenu = document.getElementById("autoplay-menu");
  10929. return autoplayMenu !== null && autoplayMenu.contains(event.target);
  10930. }
  10931.  
  10932. addFavoritesLoaderEventListeners() {
  10933. if (Utils.onSearchPage()) {
  10934. return;
  10935. }
  10936. window.addEventListener("favoritesFetched", () => {
  10937. this.initializeThumbsForHovering.bind(this)();
  10938. this.enumerateThumbs();
  10939. });
  10940. window.addEventListener("newFavoritesFetchedOnReload", (event) => {
  10941. if (event.detail.empty) {
  10942. return;
  10943. }
  10944. this.initializeThumbsForHovering.bind(this)(event.detail.thumbs);
  10945. this.enumerateThumbs();
  10946. /**
  10947. * @type {HTMLElement[]}
  10948. */
  10949. const thumbs = event.detail.thumbs.reverse();
  10950.  
  10951. if (thumbs.length > 0) {
  10952. const thumb = thumbs[0];
  10953.  
  10954. this.upscaleAnimatedThumbsAround(thumb);
  10955. this.renderImages(thumbs
  10956. .filter(t => Utils.isImage(t))
  10957. .slice(0, 20));
  10958. }
  10959. }, {
  10960. once: true
  10961. });
  10962. window.addEventListener("startedFetchingFavorites", () => {
  10963. this.favoritesWereFetched = true;
  10964. setTimeout(() => {
  10965. const thumb = document.querySelector(`.${Utils.favoriteItemClassName}`);
  10966.  
  10967. this.renderImagesInTheBackground();
  10968.  
  10969. if (thumb !== null && !Gallery.finishedLoading) {
  10970. this.upscaleAnimatedThumbsAround(thumb);
  10971. }
  10972. }, 650);
  10973. }, {
  10974. once: true
  10975. });
  10976. window.addEventListener("favoritesLoaded", () => {
  10977. Gallery.backgroundRenderingOnPageChangeCooldown.waitTime = 1000;
  10978. Gallery.finishedLoading = true;
  10979. this.initializeThumbsForHovering.bind(this)();
  10980. this.enumerateThumbs();
  10981. this.findImageExtensionsInTheBackground();
  10982.  
  10983. if (!this.favoritesWereFetched) {
  10984. this.renderImagesInTheBackground();
  10985. }
  10986. }, {
  10987. once: true
  10988. });
  10989. window.addEventListener("newSearchResults", (event) => {
  10990. this.latestSearchResults = event.detail;
  10991. });
  10992. window.addEventListener("changedPage", () => {
  10993. this.initializeThumbsForHovering.bind(this)();
  10994. this.enumerateThumbs();
  10995.  
  10996. if (this.changedPageWhileInGallery) {
  10997. setTimeout(() => {
  10998. this.imageRenderer.postMessage({
  10999. action: "upscaleAllRenderedThumbs"
  11000. });
  11001. }, 100);
  11002. } else {
  11003. this.clearMainCanvas();
  11004. this.clearVideoSources();
  11005. this.toggleOriginalContentVisibility(false);
  11006. this.deleteAllRenders();
  11007.  
  11008. if (Gallery.settings.debugEnabled) {
  11009. Utils.getAllThumbs().forEach((thumb) => {
  11010. thumb.classList.remove("loaded");
  11011. thumb.classList.remove("debug-selected");
  11012. });
  11013. }
  11014. }
  11015. this.onPageChange();
  11016. });
  11017. window.addEventListener("foundFavorite", (event) => {
  11018. this.foundFavoriteId = event.detail;
  11019. });
  11020. window.addEventListener("shuffle", () => {
  11021. this.enumerateThumbs();
  11022. this.deleteAllRenders();
  11023. this.renderImagesInTheBackground();
  11024. });
  11025. window.addEventListener("didNotChangePageInGallery", (event) => {
  11026. if (this.inGallery) {
  11027. this.setNextSelectedThumbIndex(event.detail);
  11028. this.traverseGalleryHelper();
  11029. }
  11030. });
  11031. }
  11032.  
  11033. createImageRendererMessageHandler() {
  11034. this.imageRenderer.onmessage = (message) => {
  11035. message = message.data;
  11036.  
  11037. switch (message.action) {
  11038. case "renderCompleted":
  11039. this.onRenderCompleted(message);
  11040. break;
  11041.  
  11042. case "renderDeleted":
  11043. this.onRenderDeleted(message);
  11044. break;
  11045.  
  11046. case "extensionFound":
  11047. Utils.assignImageExtension(message.id, message.extension);
  11048. break;
  11049.  
  11050. default:
  11051. break;
  11052. }
  11053. };
  11054. }
  11055.  
  11056. addMobileEventListeners() {
  11057. if (!Utils.onMobileDevice()) {
  11058. return;
  11059. }
  11060. window.addEventListener("blur", () => {
  11061. this.deleteAllRenders();
  11062. });
  11063. document.addEventListener("touchstart", (event) => {
  11064. if (!this.inGallery) {
  11065. return;
  11066. }
  11067.  
  11068. if (!this.clickedOnAutoplayMenu(event)) {
  11069. event.preventDefault();
  11070. }
  11071. Gallery.swipeControls.set(event, true);
  11072. }, {
  11073. passive: false
  11074. });
  11075. document.addEventListener("touchend", (event) => {
  11076. if (!this.inGallery ||
  11077. // event.target.classList.contains("mobile-tap-control") ||
  11078. this.clickedOnAutoplayMenu(event)
  11079. ) {
  11080. return;
  11081. }
  11082. event.preventDefault();
  11083. Gallery.swipeControls.set(event, false);
  11084.  
  11085. if (Gallery.swipeControls.up) {
  11086. this.autoplayController.showMenu();
  11087. return;
  11088. }
  11089.  
  11090. if (Gallery.swipeControls.down) {
  11091. this.exitGallery();
  11092. this.toggleAllVisibility(false);
  11093. return;
  11094. }
  11095.  
  11096. if (Utils.isVideo(this.getSelectedThumb())) {
  11097. return;
  11098. }
  11099.  
  11100. if (Gallery.swipeControls.left) {
  11101. this.traverseGallery(Gallery.directions.right, false);
  11102. return;
  11103. }
  11104.  
  11105. if (Gallery.swipeControls.right) {
  11106. this.traverseGallery(Gallery.directions.left, false);
  11107.  
  11108. }
  11109. // this.exitGallery();
  11110. // this.toggleAllVisibility(false);
  11111.  
  11112. }, {
  11113. passive: false
  11114. });
  11115.  
  11116. window.addEventListener("orientationchange", () => {
  11117. if (this.imageRenderer !== null && this.imageRenderer !== undefined) {
  11118. this.setOrientation();
  11119. }
  11120. }, {
  11121. passive: true
  11122. });
  11123. }
  11124.  
  11125. setOrientation() {
  11126. if (!Utils.onMobileDevice()) {
  11127. return;
  11128. }
  11129. const usingLandscapeOrientation = window.screen.orientation.angle === 90;
  11130.  
  11131. this.setGifOrientation(usingLandscapeOrientation);
  11132. this.swapMainCanvasDimensions(usingLandscapeOrientation);
  11133. this.swapLowResolutionCanvasDimensions(usingLandscapeOrientation);
  11134. this.redrawCanvasesOnOrientationChange();
  11135. }
  11136.  
  11137. /**
  11138. * @param {Boolean} usingLandscapeOrientation
  11139. */
  11140. swapMainCanvasDimensions(usingLandscapeOrientation) {
  11141. this.imageRenderer.postMessage({
  11142. action: "changeCanvasOrientation",
  11143. usingLandscapeOrientation
  11144. });
  11145. }
  11146.  
  11147. /**
  11148. * @param {Boolean} usingLandscapeOrientation
  11149. */
  11150. setGifOrientation(usingLandscapeOrientation) {
  11151. const orientationId = "main-orientation";
  11152.  
  11153. if (usingLandscapeOrientation) {
  11154. Utils.insertStyleHTML(`
  11155. #original-gif-container, #main-canvas, #low-resolution-canvas {
  11156. height: 100vh !important;
  11157. width: auto !important;
  11158. }
  11159. `, orientationId);
  11160. } else {
  11161. Utils.insertStyleHTML(`
  11162. #original-gif-container, #main-canvas, #low-resolution-canvas {
  11163. width: 100vw !important;
  11164. height: auto !important;
  11165. }
  11166. `, orientationId);
  11167. }
  11168. }
  11169.  
  11170. /**
  11171. * @param {Boolean} usingLandscapeOrientation
  11172. */
  11173. swapLowResolutionCanvasDimensions(usingLandscapeOrientation) {
  11174. if (usingLandscapeOrientation === (this.lowResolutionCanvas.width > this.lowResolutionCanvas.height)) {
  11175. return;
  11176. }
  11177. const temp = this.lowResolutionCanvas.height;
  11178.  
  11179. this.lowResolutionCanvas.height = this.lowResolutionCanvas.width;
  11180. this.lowResolutionCanvas.width = temp;
  11181. }
  11182.  
  11183. redrawCanvasesOnOrientationChange() {
  11184. if (!this.inGallery) {
  11185. return;
  11186. }
  11187. const thumb = this.getSelectedThumb();
  11188.  
  11189. if (thumb === undefined || thumb === null) {
  11190. return;
  11191. }
  11192. this.drawLowResolutionCanvas(thumb);
  11193. this.imageRenderer.postMessage(this.getRenderRequest(thumb));
  11194. }
  11195.  
  11196. createMobileTapControls() {
  11197. if (!Utils.onMobileDevice()) {
  11198. return;
  11199. }
  11200. const tapControlContainer = document.createElement("div");
  11201. const leftTap = document.createElement("div");
  11202. const rightTap = document.createElement("div");
  11203.  
  11204. leftTap.className = "mobile-tap-control";
  11205. rightTap.className = "mobile-tap-control";
  11206. leftTap.id = "left-mobile-tap-control";
  11207. rightTap.id = "right-mobile-tap-control";
  11208. tapControlContainer.appendChild(leftTap);
  11209. tapControlContainer.appendChild(rightTap);
  11210. this.originalContentContainer.appendChild(tapControlContainer);
  11211. Utils.insertStyleHTML(`
  11212. .mobile-tap-control {
  11213. position: fixed;
  11214. top: 50%;
  11215. height: 65vh;
  11216. width: 25vw;
  11217. opacity: 0;
  11218. background: red;
  11219. z-index: 9999;
  11220. color: red;
  11221. transform: translateY(-50%);
  11222. }
  11223.  
  11224. #left-mobile-tap-control {
  11225. left: 0;
  11226. }
  11227.  
  11228. #right-mobile-tap-control {
  11229. right: 0;
  11230. }
  11231. `);
  11232. this.toggleTapTraversal(false);
  11233. leftTap.ontouchend = () => {
  11234. if (this.inGallery) {
  11235. this.traverseGallery(Gallery.directions.left, false);
  11236. }
  11237. };
  11238. rightTap.ontouchend = () => {
  11239. if (this.inGallery) {
  11240. this.traverseGallery(Gallery.directions.right, false);
  11241. }
  11242. };
  11243. }
  11244.  
  11245. /**
  11246. * @param {Boolean} value
  11247. */
  11248. toggleTapTraversal(value) {
  11249. Utils.insertStyleHTML(`
  11250. .mobile-tap-control {
  11251. pointer-events: ${value ? "auto" : "none"};
  11252. }
  11253. `, "tap-traversal");
  11254. }
  11255.  
  11256. addMemoryManagementEventListeners() {
  11257. if (Utils.onFavoritesPage()) {
  11258. return;
  11259. }
  11260. window.addEventListener("blur", () => {
  11261. this.leftPage = true;
  11262. this.deleteAllRenders();
  11263. this.clearInactiveVideoSources();
  11264. });
  11265. window.addEventListener("focus", () => {
  11266. if (this.leftPage) {
  11267. this.renderImagesInTheBackground();
  11268. this.leftPage = false;
  11269. }
  11270. });
  11271. }
  11272.  
  11273. async prepareSearchPage() {
  11274. if (!Utils.onSearchPage()) {
  11275. return;
  11276. }
  11277. await Utils.findImageExtensionsOnSearchPage();
  11278. dispatchEvent(new Event("foundExtensionsOnSearchPage"));
  11279. this.renderImagesInTheBackground();
  11280. }
  11281.  
  11282. insertHTML() {
  11283. this.insertStyleHTML();
  11284. this.insertDebugHTML();
  11285. this.insertOptionsHTML();
  11286. this.insertOriginalContentContainerHTML();
  11287.  
  11288. }
  11289.  
  11290. insertStyleHTML() {
  11291. Utils.insertStyleHTML(Gallery.galleryHTML, "gallery");
  11292. }
  11293.  
  11294. insertDebugHTML() {
  11295. if (Gallery.settings.debugEnabled) {
  11296. Utils.insertStyleHTML(Gallery.galleryDebugHTML, "gallery-debug");
  11297. }
  11298. }
  11299.  
  11300. insertOptionsHTML() {
  11301. this.insertShowOnHoverOption();
  11302. }
  11303.  
  11304. insertShowOnHoverOption() {
  11305. let optionId = "show-content-on-hover";
  11306. let optionText = "Fullscreen on Hover";
  11307. let optionTitle = "View full resolution images or play videos and GIFs when hovering over a thumbnail";
  11308. let optionIsChecked = this.showOriginalContentOnHover;
  11309. let onOptionChanged = (event) => {
  11310. Utils.setPreference(Gallery.preferences.showOnHover, event.target.checked);
  11311. this.toggleAllVisibility(event.target.checked);
  11312. };
  11313.  
  11314. if (Utils.onMobileDevice()) {
  11315. optionId = "mobile-gallery-checkbox";
  11316. optionText = "Gallery";
  11317. optionTitle = "View full resolution images/play videos when a thumbnail is clicked";
  11318. optionIsChecked = this.enlargeOnClickOnMobile;
  11319. onOptionChanged = (event) => {
  11320. Utils.setPreference(Gallery.preferences.enlargeOnClick, event.target.checked);
  11321. this.enlargeOnClickOnMobile = event.target.checked;
  11322. };
  11323. }
  11324. Utils.createFavoritesOption(
  11325. optionId,
  11326. optionText,
  11327. optionTitle,
  11328. optionIsChecked,
  11329. onOptionChanged,
  11330. true
  11331. // "(Middle Click)"
  11332. );
  11333. }
  11334.  
  11335. insertOriginalContentContainerHTML() {
  11336. const originalContentContainerHTML = `
  11337. <div id="gallery-container">
  11338. <a id="original-video-container">
  11339. <video ${Gallery.commonVideoAttributes} active></video>
  11340. </a>
  11341. <img id="original-gif-container"></img>
  11342. <a id="original-content-background-link-mask"></a>
  11343. <a id="original-content-background"></a>
  11344. </div>
  11345. `;
  11346.  
  11347. Utils.insertFavoritesSearchGalleryHTML("afterbegin", originalContentContainerHTML);
  11348. this.originalContentContainer = document.getElementById("gallery-container");
  11349. this.originalContentContainer.insertBefore(this.lowResolutionCanvas, this.originalContentContainer.firstChild);
  11350. this.originalContentContainer.insertBefore(this.mainCanvas, this.originalContentContainer.firstChild);
  11351. this.background = document.getElementById("original-content-background");
  11352.  
  11353. this.originalImageLinkMask = document.getElementById("original-content-background-link-mask");
  11354. this.videoContainer = document.getElementById("original-video-container");
  11355. this.addAdditionalVideoPlayers();
  11356. this.videoPlayers = Array.from(this.videoContainer.querySelectorAll("video"));
  11357. this.addVideoPlayerEventListeners();
  11358. this.loadVideoVolume();
  11359. this.gifContainer = document.getElementById("original-gif-container");
  11360. this.mainCanvas.id = "main-canvas";
  11361. this.lowResolutionCanvas.id = "low-resolution-canvas";
  11362. this.lowResolutionCanvas.width = Utils.onMobileDevice() ? 320 : 1280;
  11363. this.lowResolutionCanvas.height = Utils.onMobileDevice() ? 180 : 720;
  11364. this.toggleOriginalContentVisibility(false);
  11365. this.addBackgroundEventListeners();
  11366.  
  11367. if (Autoplay.disabled || !this.autoplayController.active || this.autoplayController.paused) {
  11368. this.toggleVideoLooping(true);
  11369. } else {
  11370. this.toggleVideoLooping(false);
  11371. }
  11372. }
  11373.  
  11374. addAdditionalVideoPlayers() {
  11375. const videoPlayerHTML = `<video ${Gallery.commonVideoAttributes}></video>`;
  11376.  
  11377. for (let i = 0; i < Gallery.settings.additionalVideoPlayerCount; i += 1) {
  11378. this.videoContainer.insertAdjacentHTML("beforeend", videoPlayerHTML);
  11379. }
  11380. }
  11381.  
  11382. addVideoPlayerEventListeners() {
  11383. this.videoContainer.onclick = (event) => {
  11384. if (!event.ctrlKey) {
  11385. event.preventDefault();
  11386. }
  11387. };
  11388.  
  11389. for (const video of this.videoPlayers) {
  11390. video.addEventListener("mousemove", () => {
  11391. if (!video.hasAttribute("controls")) {
  11392. video.setAttribute("controls", "");
  11393. }
  11394. }, {
  11395. passive: true
  11396. });
  11397. video.addEventListener("click", (event) => {
  11398. if (event.ctrlKey) {
  11399. return;
  11400. }
  11401.  
  11402. if (video.paused) {
  11403. video.play().catch(() => { });
  11404. } else {
  11405. video.pause();
  11406. }
  11407. }, {
  11408. passive: true
  11409. });
  11410. video.addEventListener("volumechange", (event) => {
  11411. if (!event.target.hasAttribute("active")) {
  11412. return;
  11413. }
  11414. Utils.setPreference(Gallery.preferences.videoVolume, video.volume);
  11415. Utils.setPreference(Gallery.preferences.videoMuted, video.muted);
  11416.  
  11417. for (const v of this.getInactiveVideoPlayers()) {
  11418. v.volume = video.volume;
  11419. v.muted = video.muted;
  11420. }
  11421. }, {
  11422. passive: true
  11423. });
  11424. video.addEventListener("ended", () => {
  11425. this.autoplayController.onVideoEnded();
  11426. }, {
  11427. passive: true
  11428. });
  11429. video.addEventListener("dblclick", () => {
  11430. if (this.inGallery && !this.recentlyEnteredGallery) {
  11431. this.exitGallery();
  11432. this.toggleAllVisibility(false);
  11433. }
  11434. });
  11435.  
  11436. if (Utils.onMobileDevice()) {
  11437. video.addEventListener("touchend", () => {
  11438. this.toggleVideoControls(true);
  11439. }, {
  11440. passive: true
  11441. });
  11442. }
  11443. }
  11444. }
  11445.  
  11446. addBackgroundEventListeners() {
  11447. if (Utils.onMobileDevice()) {
  11448. return;
  11449. }
  11450. this.background.addEventListener("mousemove", () => {
  11451. Gallery.cursorVisibilityCooldown.restart();
  11452. this.toggleCursorVisibility(true);
  11453. }, {
  11454. passive: true
  11455. });
  11456. Gallery.cursorVisibilityCooldown.onCooldownEnd = () => {
  11457. if (this.inGallery) {
  11458. this.toggleCursorVisibility(false);
  11459. }
  11460. };
  11461. }
  11462.  
  11463. loadVideoVolume() {
  11464. const video = this.getActiveVideoPlayer();
  11465.  
  11466. video.volume = parseFloat(Utils.getPreference(Gallery.preferences.videoVolume, 1));
  11467. video.muted = Utils.getPreference(Gallery.preferences.videoMuted, true);
  11468. }
  11469.  
  11470. /**
  11471. * @param {Number} opacity
  11472. */
  11473. updateBackgroundOpacity(opacity) {
  11474. this.background.style.opacity = opacity;
  11475. Utils.setPreference(Gallery.preferences.backgroundOpacity, opacity);
  11476. }
  11477.  
  11478. createAutoplayController() {
  11479. const subscribers = new AutoplayListenerList(
  11480. () => {
  11481. this.toggleVideoLooping(false);
  11482. },
  11483. () => {
  11484. this.toggleVideoLooping(true);
  11485. },
  11486. () => {
  11487. this.toggleVideoLooping(true);
  11488. },
  11489. () => {
  11490. this.toggleVideoLooping(false);
  11491. },
  11492. () => {
  11493. if (this.inGallery) {
  11494. const direction = Autoplay.settings.moveForward ? Gallery.directions.right : Gallery.directions.left;
  11495.  
  11496. this.traverseGallery(direction, false);
  11497. }
  11498. },
  11499. () => {
  11500. if (this.inGallery && Utils.isVideo(this.getSelectedThumb())) {
  11501. this.playOriginalVideo(this.getSelectedThumb());
  11502. }
  11503. }
  11504. );
  11505.  
  11506. this.autoplayController = new Autoplay(subscribers);
  11507. }
  11508.  
  11509. /**
  11510. * @param {HTMLElement[]} thumbs
  11511. */
  11512. initializeThumbsForHovering(thumbs) {
  11513. const thumbElements = thumbs === undefined ? Utils.getAllThumbs() : thumbs;
  11514.  
  11515. for (const thumbElement of thumbElements) {
  11516. this.addEventListenersToThumb(thumbElement);
  11517. }
  11518. }
  11519.  
  11520. renderImagesInTheBackground() {
  11521. if (Utils.onMobileDevice()) {
  11522. return;
  11523. }
  11524. const thumbs = Utils.getAllThumbs();
  11525.  
  11526. if (Utils.onSearchPage()) {
  11527. this.renderImages(thumbs.filter(thumb => Utils.isImage(thumb)).slice(0, 50));
  11528. return;
  11529. }
  11530. const animatedThumbs = thumbs
  11531. .slice(0, Gallery.settings.animatedThumbsToUpscaleDiscrete)
  11532. .filter(thumb => !Utils.isImage(thumb));
  11533.  
  11534. if (thumbs.length > 0) {
  11535. this.upscaleAnimatedThumbs(animatedThumbs);
  11536. this.renderImagesAround(thumbs[0]);
  11537. }
  11538. }
  11539.  
  11540. onPageChange() {
  11541. this.onPageChangeHelper();
  11542. this.foundFavoriteId = null;
  11543. this.changedPageInGalleryDirection = null;
  11544. }
  11545.  
  11546. onPageChangeHelper() {
  11547. if (this.visibleThumbs.length <= 0) {
  11548. return;
  11549. }
  11550.  
  11551. if (this.changedPageInGalleryDirection !== null) {
  11552. this.onPageChangedInGallery();
  11553. return;
  11554. }
  11555.  
  11556. if (this.foundFavoriteId !== null) {
  11557. this.onFavoriteFound();
  11558. return;
  11559. }
  11560. setTimeout(() => {
  11561. if (Gallery.backgroundRenderingOnPageChangeCooldown.ready) {
  11562. this.renderImagesInTheBackground();
  11563. }
  11564. }, 100);
  11565. }
  11566.  
  11567. onPageChangedInGallery() {
  11568. if (this.changedPageInGalleryDirection === "ArrowRight") {
  11569. this.currentlySelectedThumbIndex = 0;
  11570. } else {
  11571. this.currentlySelectedThumbIndex = this.visibleThumbs.length - 1;
  11572. }
  11573. this.traverseGalleryHelper();
  11574. }
  11575.  
  11576. onFavoriteFound() {
  11577. const thumb = document.getElementById(this.foundFavoriteId);
  11578.  
  11579. if (thumb !== null) {
  11580. this.renderImagesAround(thumb);
  11581. }
  11582. }
  11583.  
  11584. /**
  11585. * @param {HTMLElement[]} imagesToRender
  11586. */
  11587. renderImages(imagesToRender) {
  11588. const renderRequests = imagesToRender.map(image => this.getRenderRequest(image));
  11589. const canvases = Utils.onSearchPage() ? [] : renderRequests
  11590. .filter(request => request.canvas !== undefined)
  11591. .map(request => request.canvas);
  11592.  
  11593. this.imageRenderer.postMessage({
  11594. action: "renderMultiple",
  11595. id: this.currentBatchRenderRequestId,
  11596. renderRequests,
  11597. requestType: "none"
  11598. }, canvases);
  11599. this.currentBatchRenderRequestId += 1;
  11600.  
  11601. if (this.currentBatchRenderRequestId >= 1000) {
  11602. this.currentBatchRenderRequestId = 0;
  11603. }
  11604. }
  11605.  
  11606. /**
  11607. * @param {Object} message
  11608. */
  11609. onRenderCompleted(message) {
  11610. const thumb = document.getElementById(message.id);
  11611.  
  11612. this.completedRenders.add(message.id);
  11613.  
  11614. if (Gallery.settings.debugEnabled) {
  11615.  
  11616. if (Gallery.settings.loopAtEndOfGallery) {
  11617. if (thumb !== null) {
  11618. thumb.classList.add("loaded");
  11619. }
  11620. } else {
  11621. const post = Post.allPosts.get(message.id);
  11622.  
  11623. if (post !== undefined && post.root !== undefined) {
  11624. post.root.classList.add("loaded");
  11625. }
  11626. }
  11627. }
  11628.  
  11629. if (thumb !== null && message.extension === "gif") {
  11630. Utils.getImageFromThumb(thumb).setAttribute("gif", true);
  11631. return;
  11632. }
  11633. Utils.assignImageExtension(message.id, message.extension);
  11634. this.drawMainCanvasOnRenderCompleted(thumb);
  11635. }
  11636.  
  11637. /**
  11638. * @param {HTMLElement} thumb
  11639. */
  11640. drawMainCanvasOnRenderCompleted(thumb) {
  11641. if (thumb === null) {
  11642. return;
  11643. }
  11644. const mainCanvasIsVisible = this.showOriginalContentOnHover || this.inGallery;
  11645.  
  11646. if (!mainCanvasIsVisible) {
  11647. return;
  11648. }
  11649. const selectedThumb = this.getSelectedThumb();
  11650. const selectedThumbIsImage = selectedThumb !== undefined && Utils.isImage(selectedThumb);
  11651.  
  11652. if (!selectedThumbIsImage) {
  11653. return;
  11654. }
  11655.  
  11656. if (selectedThumb.id === thumb.id) {
  11657. this.drawMainCanvas(thumb);
  11658. }
  11659. }
  11660.  
  11661. onRenderDeleted(message) {
  11662. const thumb = document.getElementById(message.id);
  11663.  
  11664. if (thumb !== null) {
  11665. if (Gallery.settings.debugEnabled) {
  11666. thumb.classList.remove("loaded");
  11667. }
  11668. }
  11669. this.startedRenders.delete(message.id);
  11670. this.completedRenders.delete(message.id);
  11671. }
  11672.  
  11673. deleteAllRenders() {
  11674. this.startedRenders.clear();
  11675. this.completedRenders.clear();
  11676. this.deleteAllTransferredCanvases();
  11677. this.imageRenderer.postMessage({
  11678. action: "deleteAllRenders"
  11679. });
  11680.  
  11681. if (Gallery.settings.debugEnabled) {
  11682. if (Gallery.settings.loopAtEndOfGallery) {
  11683. for (const thumb of this.visibleThumbs) {
  11684. thumb.classList.remove("loaded");
  11685. }
  11686. } else {
  11687. for (const post of Post.allPosts.values()) {
  11688. if (post.root !== undefined) {
  11689. post.root.classList.remove("loaded");
  11690. }
  11691. }
  11692. }
  11693. }
  11694. }
  11695.  
  11696. deleteAllTransferredCanvases() {
  11697. if (Utils.onSearchPage()) {
  11698. return;
  11699. }
  11700.  
  11701. for (const id of this.transferredCanvases.keys()) {
  11702. this.transferredCanvases.get(id).remove();
  11703. this.transferredCanvases.delete(id);
  11704. }
  11705. this.transferredCanvases.clear();
  11706. }
  11707.  
  11708. /**
  11709. * @param {HTMLElement} thumb
  11710. * @returns {HTMLCanvasElement}
  11711. */
  11712. getCanvasFromThumb(thumb) {
  11713. let canvas = thumb.querySelector("canvas");
  11714.  
  11715. if (canvas === null) {
  11716. canvas = document.createElement("canvas");
  11717. thumb.children[0].appendChild(canvas);
  11718. }
  11719. return canvas;
  11720. }
  11721.  
  11722. /**
  11723. * @param {HTMLElement} thumb
  11724. * @returns {HTMLCanvasElement}
  11725. */
  11726. getOffscreenCanvasFromThumb(thumb) {
  11727. const canvas = this.getCanvasFromThumb(thumb);
  11728.  
  11729. this.transferredCanvases.set(thumb.id, canvas);
  11730. return canvas.transferControlToOffscreen();
  11731. }
  11732.  
  11733. hideCaptionsWhenShowingOriginalContent() {
  11734. for (const caption of document.getElementsByClassName("caption")) {
  11735. if (this.showOriginalContentOnHover) {
  11736. caption.classList.add("hide");
  11737. } else {
  11738. caption.classList.remove("hide");
  11739. }
  11740. }
  11741. }
  11742.  
  11743. async findImageExtensionsInTheBackground() {
  11744. await Utils.sleep(1000);
  11745. const idsWithUnknownExtensions = this.getIdsWithUnknownExtensions(Array.from(Post.allPosts.values()));
  11746.  
  11747. while (idsWithUnknownExtensions.length > 0) {
  11748. await Utils.sleep(3000);
  11749.  
  11750. while (idsWithUnknownExtensions.length > 0 && Gallery.finishedLoading) {
  11751. const id = idsWithUnknownExtensions.pop();
  11752.  
  11753. if (id !== undefined && id !== null && !Utils.extensionIsKnown(id)) {
  11754. this.imageRenderer.postMessage({
  11755. action: "findExtension",
  11756. id
  11757. });
  11758. await Utils.sleep(10);
  11759. }
  11760. }
  11761. }
  11762. Gallery.settings.extensionsFoundBeforeSavingCount = 0;
  11763. }
  11764.  
  11765. enumerateThumbs() {
  11766. this.visibleThumbs = Utils.getAllThumbs();
  11767. this.enumeratedThumbs.clear();
  11768.  
  11769. for (let i = 0; i < this.visibleThumbs.length; i += 1) {
  11770. this.enumerateThumb(this.visibleThumbs[i], i);
  11771. }
  11772. }
  11773.  
  11774. /**
  11775. * @param {HTMLElement} thumb
  11776. * @param {Number} index
  11777. */
  11778. enumerateThumb(thumb, index) {
  11779. this.enumeratedThumbs.set(thumb.id, index);
  11780. }
  11781.  
  11782. /**
  11783. * @param {HTMLElement} thumb
  11784. * @returns {Number | null}
  11785. */
  11786. getIndexFromThumb(thumb) {
  11787. return this.enumeratedThumbs.get(thumb.id) || 0;
  11788. }
  11789.  
  11790. /**
  11791. * @param {HTMLElement} thumb
  11792. */
  11793. addEventListenersToThumb(thumb) {
  11794. if (Utils.onMobileDevice()) {
  11795. return;
  11796. }
  11797. const image = Utils.getImageFromThumb(thumb);
  11798.  
  11799. if (image.onmouseover !== null) {
  11800. return;
  11801. }
  11802. image.onmouseover = (event) => {
  11803. if (this.inGallery || this.recentlyExitedGallery || Utils.enteredOverCaptionTag(event)) {
  11804. return;
  11805. }
  11806. this.thumbUnderCursor = thumb;
  11807. this.lastEnteredThumb = thumb;
  11808. this.showOriginalContent(thumb);
  11809. };
  11810. image.onmouseout = (event) => {
  11811. this.thumbUnderCursor = null;
  11812.  
  11813. if (this.inGallery || Utils.enteredOverCaptionTag(event)) {
  11814. return;
  11815. }
  11816. this.stopAllVideos();
  11817. this.hideOriginalContent();
  11818. };
  11819. }
  11820.  
  11821. /**
  11822. * @param {HTMLElement} thumb
  11823. */
  11824. openPostInNewPage(thumb) {
  11825. thumb = thumb === undefined || thumb === null ? this.getSelectedThumb() : thumb;
  11826. Utils.openPostInNewTab(Utils.getIdFromThumb(thumb));
  11827. }
  11828.  
  11829. unFavoriteSelectedContent() {
  11830. if (!Utils.userIsOnTheirOwnFavoritesPage()) {
  11831. return;
  11832. }
  11833. const selectedThumb = this.getSelectedThumb();
  11834.  
  11835. if (selectedThumb === null) {
  11836. return;
  11837. }
  11838. const removeFavoriteButton = Utils.getRemoveFavoriteButtonFromThumb(selectedThumb);
  11839.  
  11840. if (removeFavoriteButton === null) {
  11841. return;
  11842. }
  11843. const showRemoveFavoriteButtons = document.getElementById("show-remove-favorite-buttons");
  11844.  
  11845. if (showRemoveFavoriteButtons === null) {
  11846. return;
  11847. }
  11848.  
  11849. if (!Gallery.addOrRemoveFavoriteCooldown.ready) {
  11850. return;
  11851. }
  11852.  
  11853. if (!showRemoveFavoriteButtons.checked) {
  11854. Utils.showFullscreenIcon(Utils.icons.warning, 1000);
  11855. setTimeout(() => {
  11856. alert("The \"Remove Buttons\" option must be checked to use this hotkey");
  11857. }, 20);
  11858. return;
  11859. }
  11860. Utils.showFullscreenIcon(Utils.icons.heartMinus);
  11861. this.onFavoriteAddedOrDeleted(selectedThumb.id);
  11862. Utils.removeFavorite(selectedThumb.id);
  11863. }
  11864.  
  11865. enterGallery() {
  11866. const selectedThumb = this.getSelectedThumb();
  11867.  
  11868. this.toggleTapTraversal(true);
  11869. this.lastSelectedThumbIndexBeforeEnteringGallery = this.currentlySelectedThumbIndex;
  11870. this.background.style.pointerEvents = "auto";
  11871.  
  11872. if (Utils.isVideo(selectedThumb)) {
  11873. this.toggleVideoControls(true);
  11874. }
  11875. this.inGallery = true;
  11876. dispatchEvent(new CustomEvent("showOriginalContent", {
  11877. detail: true
  11878. }));
  11879. this.autoplayController.start(selectedThumb);
  11880. Gallery.cursorVisibilityCooldown.restart();
  11881. this.recentlyEnteredGallery = true;
  11882. setTimeout(() => {
  11883. this.recentlyEnteredGallery = false;
  11884. }, 300);
  11885. this.setupOriginalImageLinkInGallery();
  11886. }
  11887.  
  11888. exitGallery() {
  11889. if (Gallery.settings.debugEnabled) {
  11890. Utils.getAllThumbs().forEach(thumb => thumb.classList.remove("debug-selected"));
  11891. }
  11892. this.toggleTapTraversal(false);
  11893. this.toggleCursorVisibility(true);
  11894. this.toggleVideoControls(false);
  11895. this.background.style.pointerEvents = "none";
  11896. this.toggleCtrlClickOpenMediaInNewTab(false);
  11897. const thumbIndex = this.getIndexOfThumbUnderCursor();
  11898.  
  11899. if (Utils.onMobileDevice()) {
  11900. this.hideOriginalContent();
  11901. this.deleteAllRenders();
  11902. }
  11903.  
  11904. if (!Utils.onMobileDevice() && thumbIndex !== this.lastSelectedThumbIndexBeforeEnteringGallery) {
  11905. this.hideOriginalContent();
  11906.  
  11907. if (thumbIndex !== null && this.showOriginalContentOnHover) {
  11908. this.showOriginalContent(this.visibleThumbs[thumbIndex]);
  11909. }
  11910. }
  11911.  
  11912. this.recentlyExitedGallery = true;
  11913. setTimeout(() => {
  11914. this.recentlyExitedGallery = false;
  11915. }, 300);
  11916. this.inGallery = false;
  11917. this.autoplayController.stop();
  11918. document.dispatchEvent(new Event("mousemove"));
  11919. }
  11920.  
  11921. /**
  11922. * @param {String} direction
  11923. * @param {Boolean} keyIsHeldDown
  11924. */
  11925. traverseGallery(direction, keyIsHeldDown) {
  11926. if (Gallery.settings.debugEnabled) {
  11927. this.getSelectedThumb().classList.remove("debug-selected");
  11928. }
  11929.  
  11930. if (keyIsHeldDown && !Gallery.keyHeldDownTraversalCooldown.ready) {
  11931. return;
  11932. }
  11933.  
  11934. if (!Gallery.settings.loopAtEndOfGallery && this.reachedEndOfGallery(direction) && Gallery.finishedLoading) {
  11935. this.changedPageInGalleryDirection = direction;
  11936. dispatchEvent(new CustomEvent("reachedEndOfGallery", {
  11937. detail: direction
  11938. }));
  11939. return;
  11940. }
  11941. this.setNextSelectedThumbIndex(direction);
  11942. this.traverseGalleryHelper();
  11943. }
  11944.  
  11945. traverseGalleryHelper() {
  11946. const selectedThumb = this.getSelectedThumb();
  11947.  
  11948. this.autoplayController.startViewTimer(selectedThumb);
  11949. this.clearOriginalContentSources();
  11950. this.stopAllVideos();
  11951.  
  11952. if (Gallery.settings.debugEnabled) {
  11953. selectedThumb.classList.add("debug-selected");
  11954. }
  11955. this.upscaleAnimatedThumbsAround(selectedThumb);
  11956. this.renderImagesAround(selectedThumb);
  11957. this.preloadInactiveVideoPlayers(selectedThumb);
  11958.  
  11959. if (!Utils.usingFirefox()) {
  11960. Utils.scrollToThumb(selectedThumb.id, false, true);
  11961. }
  11962.  
  11963. if (Utils.isVideo(selectedThumb)) {
  11964. this.toggleVideoControls(true);
  11965. this.showOriginalVideo(selectedThumb);
  11966. } else if (Utils.isGif(selectedThumb)) {
  11967. this.toggleVideoControls(false);
  11968. this.toggleOriginalVideoContainer(false);
  11969. this.showOriginalGIF(selectedThumb);
  11970. } else {
  11971. this.toggleVideoControls(false);
  11972. this.toggleOriginalVideoContainer(false);
  11973. this.showOriginalImage(selectedThumb);
  11974. }
  11975. this.setupOriginalImageLinkInGallery();
  11976.  
  11977. if (Utils.onMobileDevice()) {
  11978. this.toggleVideoControls(false);
  11979. }
  11980. }
  11981.  
  11982. /**
  11983. * @param {String} direction
  11984. * @returns {Boolean}
  11985. */
  11986. reachedEndOfGallery(direction) {
  11987. if (direction === Gallery.directions.right && this.currentlySelectedThumbIndex >= this.visibleThumbs.length - 1) {
  11988. return true;
  11989. }
  11990.  
  11991. if (direction === Gallery.directions.left && this.currentlySelectedThumbIndex <= 0) {
  11992. return true;
  11993. }
  11994. return false;
  11995. }
  11996.  
  11997. /**
  11998. * @param {String} direction
  11999. * @returns {Boolean}
  12000. */
  12001. setNextSelectedThumbIndex(direction) {
  12002. if (direction === Gallery.directions.left || direction === Gallery.directions.a) {
  12003. this.currentlySelectedThumbIndex -= 1;
  12004. this.currentlySelectedThumbIndex = this.currentlySelectedThumbIndex < 0 ? this.visibleThumbs.length - 1 : this.currentlySelectedThumbIndex;
  12005. } else {
  12006. this.currentlySelectedThumbIndex += 1;
  12007. this.currentlySelectedThumbIndex = this.currentlySelectedThumbIndex >= this.visibleThumbs.length ? 0 : this.currentlySelectedThumbIndex;
  12008. }
  12009. return false;
  12010. }
  12011.  
  12012. /**
  12013. * @param {Boolean} value
  12014. */
  12015. toggleAllVisibility(value) {
  12016. this.showOriginalContentOnHover = value === undefined ? !this.showOriginalContentOnHover : value;
  12017. this.toggleOriginalContentVisibility(this.showOriginalContentOnHover);
  12018.  
  12019. if (this.thumbUnderCursor !== null) {
  12020. this.toggleBackgroundVisibility();
  12021. this.toggleScrollbarVisibility();
  12022. }
  12023. dispatchEvent(new CustomEvent("showOriginalContent", {
  12024. detail: this.showOriginalContentOnHover
  12025. }));
  12026. Utils.setPreference(Gallery.preferences.showOnHover, this.showOriginalContentOnHover);
  12027.  
  12028. const showOnHoverCheckbox = document.getElementById("show-content-on-hover-checkbox");
  12029.  
  12030. if (showOnHoverCheckbox !== null) {
  12031. showOnHoverCheckbox.checked = this.showOriginalContentOnHover;
  12032. }
  12033. }
  12034.  
  12035. hideOriginalContent() {
  12036. this.toggleBackgroundVisibility(false);
  12037. this.toggleScrollbarVisibility(true);
  12038. this.clearOriginalContentSources();
  12039. this.stopAllVideos();
  12040. this.clearMainCanvas();
  12041. this.toggleOriginalVideoContainer(false);
  12042. this.toggleOriginalGIF(false);
  12043. }
  12044.  
  12045. clearOriginalContentSources() {
  12046. this.mainCanvas.style.visibility = "hidden";
  12047. this.lowResolutionCanvas.style.visibility = "hidden";
  12048. this.gifContainer.src = "";
  12049. this.gifContainer.style.visibility = "hidden";
  12050. }
  12051.  
  12052. /**
  12053. * @returns {Boolean}
  12054. */
  12055. currentlyHoveringOverVideoThumb() {
  12056. if (this.thumbUnderCursor === null) {
  12057. return false;
  12058. }
  12059. return Utils.isVideo(this.thumbUnderCursor);
  12060. }
  12061.  
  12062. /**
  12063. * @param {HTMLElement} thumb
  12064. */
  12065. showOriginalContent(thumb) {
  12066. this.currentlySelectedThumbIndex = this.getIndexFromThumb(thumb);
  12067. this.upscaleAnimatedThumbsAroundDiscrete(thumb);
  12068.  
  12069. if (!this.inGallery && Gallery.settings.renderAroundAggressively) {
  12070. this.renderImagesAround(thumb);
  12071. }
  12072.  
  12073. if (Utils.isVideo(thumb)) {
  12074. this.showOriginalVideo(thumb);
  12075. } else if (Utils.isGif(thumb)) {
  12076. this.showOriginalGIF(thumb);
  12077. } else {
  12078. this.showOriginalImage(thumb);
  12079. }
  12080.  
  12081. if (this.showOriginalContentOnHover) {
  12082. this.toggleBackgroundVisibility(true);
  12083. this.toggleScrollbarVisibility(false);
  12084. }
  12085. }
  12086.  
  12087. /**
  12088. * @param {HTMLElement} thumb
  12089. */
  12090. showOriginalVideo(thumb) {
  12091. if (!this.showOriginalContentOnHover) {
  12092. return;
  12093. }
  12094. this.toggleMainCanvas(false);
  12095. this.videoContainer.style.display = "block";
  12096. this.playOriginalVideo(thumb);
  12097.  
  12098. if (!this.inGallery) {
  12099. this.toggleVideoControls(false);
  12100. }
  12101. }
  12102.  
  12103. /**
  12104. * @param {HTMLElement} initialThumb
  12105. */
  12106. preloadInactiveVideoPlayers(initialThumb) {
  12107. if (!this.inGallery || Gallery.settings.additionalVideoPlayerCount < 1) {
  12108. return;
  12109. }
  12110. this.setActiveVideoPlayer(initialThumb);
  12111. const inactiveVideoPlayers = this.getInactiveVideoPlayers();
  12112. const videoThumbsAroundInitialThumb = this.getAdjacentVideoThumbs(initialThumb, inactiveVideoPlayers.length);
  12113. const loadedVideoSources = new Set(inactiveVideoPlayers
  12114. .map(video => video.src)
  12115. .filter(src => src !== ""));
  12116. const videoSourcesAroundInitialThumb = new Set(videoThumbsAroundInitialThumb.map(thumb => this.getVideoSource(thumb)));
  12117. const videoThumbsNotLoaded = videoThumbsAroundInitialThumb.filter(thumb => !loadedVideoSources.has(this.getVideoSource(thumb)));
  12118. const freeInactiveVideoPlayers = inactiveVideoPlayers.filter(video => !videoSourcesAroundInitialThumb.has(video.src));
  12119.  
  12120. for (let i = 0; i < freeInactiveVideoPlayers.length && i < videoThumbsNotLoaded.length; i += 1) {
  12121. this.setVideoSource(freeInactiveVideoPlayers[i], videoThumbsNotLoaded[i]);
  12122. }
  12123. this.stopAllVideos();
  12124. }
  12125.  
  12126. /**
  12127. * @param {HTMLElement} initialThumb
  12128. * @param {Number} limit
  12129. * @returns {HTMLElement[]}
  12130. */
  12131. getAdjacentVideoThumbs(initialThumb, limit) {
  12132. if (Gallery.settings.loopAtEndOfGallery) {
  12133. return this.getAdjacentVideoThumbsOnCurrentPage(initialThumb, limit);
  12134. }
  12135. return this.getAdjacentVideoThumbsThroughoutAllPages(initialThumb, limit);
  12136. }
  12137.  
  12138. /**
  12139. * @param {HTMLElement} initialThumb
  12140. * @param {Number} limit
  12141. * @returns {HTMLElement[]}
  12142. */
  12143. getAdjacentVideoThumbsOnCurrentPage(initialThumb, limit) {
  12144. return this.getAdjacentThumbsLooped(
  12145. initialThumb,
  12146. limit,
  12147. (t) => {
  12148. return Utils.isVideo(t) && t.id !== initialThumb.id;
  12149. }
  12150. );
  12151.  
  12152. }
  12153.  
  12154. /**
  12155. * @param {HTMLElement} initialThumb
  12156. * @param {Number} limit
  12157. * @returns {HTMLElement[]}
  12158. */
  12159. getAdjacentVideoThumbsThroughoutAllPages(initialThumb, limit) {
  12160. return this.getAdjacentSearchResults(
  12161. initialThumb,
  12162. limit,
  12163. (t) => {
  12164. return Utils.isVideo(t) && t.id !== initialThumb.id;
  12165. }
  12166. );
  12167. }
  12168.  
  12169. /**
  12170. * @param {HTMLElement} thumb
  12171. * @returns {String}
  12172. */
  12173. getVideoSource(thumb) {
  12174. return Utils.getOriginalImageURLFromThumb(thumb).replace("jpg", "mp4");
  12175. }
  12176.  
  12177. /**
  12178. * @param {HTMLVideoElement} video
  12179. * @param {HTMLElement} thumb
  12180. */
  12181. setVideoSource(video, thumb) {
  12182. if (this.videoPlayerHasSource(video, thumb)) {
  12183. return;
  12184. }
  12185. this.createVideoClip(video, thumb);
  12186. video.src = this.getVideoSource(thumb);
  12187. }
  12188.  
  12189. /**
  12190. * @param {HTMLVideoElement} video
  12191. * @param {HTMLElement} thumb
  12192. */
  12193. createVideoClip(video, thumb) {
  12194. const videoClip = this.videoClips.get(thumb.id);
  12195.  
  12196. if (videoClip === undefined) {
  12197. video.ontimeupdate = null;
  12198. return;
  12199. }
  12200. video.ontimeupdate = () => {
  12201. if (video.currentTime < videoClip.start || video.currentTime > videoClip.end) {
  12202. video.removeAttribute("controls");
  12203. video.currentTime = videoClip.start;
  12204. }
  12205. };
  12206. }
  12207.  
  12208. clearVideoSources() {
  12209. for (const video of this.videoPlayers) {
  12210. video.src = "";
  12211. }
  12212. }
  12213.  
  12214. clearInactiveVideoSources() {
  12215. const videoPlayers = this.inGallery ? this.getInactiveVideoPlayers() : this.videoPlayers;
  12216.  
  12217. for (const video of videoPlayers) {
  12218. video.src = "";
  12219. }
  12220. }
  12221.  
  12222. /**
  12223. * @param {HTMLVideoElement} video
  12224. * @returns {String | null}
  12225. */
  12226. getSourceIdFromVideo(video) {
  12227. const regex = /\.mp4\?(\d+)/;
  12228. const match = regex.exec(video.src);
  12229.  
  12230. if (match === null) {
  12231. return null;
  12232. }
  12233. return match[1];
  12234. }
  12235.  
  12236. /**
  12237. * @param {HTMLElement} thumb
  12238. */
  12239. playOriginalVideo(thumb) {
  12240. this.stopAllVideos();
  12241. const video = this.getActiveVideoPlayer();
  12242.  
  12243. this.setVideoSource(video, thumb);
  12244. video.style.display = "block";
  12245. video.play().catch(() => { });
  12246. this.toggleVideoControls(true);
  12247. }
  12248.  
  12249. stopAllVideos() {
  12250. for (const video of this.videoPlayers) {
  12251. this.stopVideo(video);
  12252. }
  12253. }
  12254.  
  12255. stopAllInactiveVideos() {
  12256. for (const video of this.getInactiveVideoPlayers()) {
  12257. this.stopVideo(video);
  12258. }
  12259. }
  12260.  
  12261. /**
  12262. * @param {HTMLVideoElement} video
  12263. */
  12264. stopVideo(video) {
  12265. video.style.display = "none";
  12266. video.pause();
  12267. video.removeAttribute("controls");
  12268. }
  12269.  
  12270. /**
  12271. * @param {HTMLElement} thumb
  12272. */
  12273. showOriginalGIF(thumb) {
  12274. const tags = Utils.getTagsFromThumb(thumb);
  12275. const extension = tags.has("animated_png") ? "png" : "gif";
  12276. const originalSource = Utils.getOriginalImageURLFromThumb(thumb).replace("jpg", extension);
  12277.  
  12278. this.gifContainer.src = originalSource;
  12279.  
  12280. if (this.showOriginalContentOnHover) {
  12281. this.toggleOriginalGIF(true);
  12282. this.lowResolutionCanvas.style.visibility = "hidden";
  12283. this.mainCanvas.style.visibility = "hidden";
  12284. this.gifContainer.style.visibility = "visible";
  12285. }
  12286. }
  12287.  
  12288. /**
  12289. * @param {HTMLElement} thumb
  12290. */
  12291. showOriginalImage(thumb) {
  12292. if (this.renderIsCompleted(thumb)) {
  12293. this.clearLowResolutionCanvas();
  12294. this.drawMainCanvas(thumb);
  12295. } else if (this.renderHasStarted(thumb)) {
  12296. this.drawLowResolutionCanvas(thumb);
  12297. this.clearMainCanvas();
  12298. this.drawMainCanvas(thumb);
  12299. } else {
  12300. this.drawLowResolutionCanvas(thumb);
  12301. this.renderOriginalImage(thumb);
  12302.  
  12303. if (!this.inGallery && !Gallery.settings.renderAroundAggressively) {
  12304. this.renderImagesAround(thumb);
  12305. }
  12306. }
  12307. this.toggleOriginalContentVisibility(this.showOriginalContentOnHover);
  12308. this.toggleOriginalGIF(false);
  12309. }
  12310.  
  12311. /**
  12312. * @param {HTMLElement} initialThumb
  12313. */
  12314. renderImagesAround(initialThumb) {
  12315. if (Utils.onSearchPage() || (Utils.onMobileDevice() && !this.enlargeOnClickOnMobile)) {
  12316. return;
  12317. }
  12318. this.renderImages(this.getAdjacentImageThumbs(initialThumb));
  12319. }
  12320.  
  12321. /**
  12322. * @param {HTMLElement} initialThumb
  12323. * @returns {HTMLElement[]}
  12324. */
  12325. getAdjacentImageThumbs(initialThumb) {
  12326. const adjacentImageThumbs = Utils.isImage(initialThumb) ? [initialThumb] : [];
  12327.  
  12328. if (Gallery.settings.loopAtEndOfGallery || this.latestSearchResults.length === 0) {
  12329. return adjacentImageThumbs.concat(this.getAdjacentImageThumbsOnCurrentPage(initialThumb));
  12330. }
  12331. return adjacentImageThumbs.concat(this.getAdjacentImageThumbsThroughoutAllPages(initialThumb));
  12332. }
  12333.  
  12334. /**
  12335. * @param {HTMLElement} initialThumb
  12336. * @returns {HTMLElement[]}
  12337. */
  12338. getAdjacentImageThumbsOnCurrentPage(initialThumb) {
  12339. return this.getAdjacentThumbsLooped(
  12340. initialThumb,
  12341. Gallery.settings.maxImagesToRenderAround,
  12342. (thumb) => {
  12343. return Utils.isImage(thumb);
  12344. }
  12345. );
  12346. }
  12347.  
  12348. /**
  12349. * @param {HTMLElement} initialThumb
  12350. * @returns {HTMLElement[]}
  12351. */
  12352. getAdjacentImageThumbsThroughoutAllPages(initialThumb) {
  12353. return this.getAdjacentSearchResults(
  12354. initialThumb,
  12355. Gallery.settings.maxImagesToRenderAround,
  12356. (post) => {
  12357. return Utils.isImage(post);
  12358. }
  12359. );
  12360. }
  12361.  
  12362. /**
  12363. * @param {HTMLElement} initialThumb
  12364. * @param {Number} limit
  12365. * @param {Function} qualifier
  12366. * @returns {HTMLElement[]}
  12367. */
  12368. getAdjacentThumbs(initialThumb, limit, qualifier) {
  12369. const adjacentThumbs = [];
  12370. let currentThumb = initialThumb;
  12371. let previousThumb = initialThumb;
  12372. let nextThumb = initialThumb;
  12373. let traverseForward = true;
  12374.  
  12375. while (currentThumb !== null && adjacentThumbs.length < limit) {
  12376. if (traverseForward) {
  12377. nextThumb = this.getAdjacentThumb(nextThumb, true);
  12378. } else {
  12379. previousThumb = this.getAdjacentThumb(previousThumb, false);
  12380. }
  12381. traverseForward = this.getTraversalDirection(previousThumb, traverseForward, nextThumb);
  12382. currentThumb = traverseForward ? nextThumb : previousThumb;
  12383.  
  12384. if (currentThumb !== null) {
  12385. if (qualifier(currentThumb)) {
  12386. adjacentThumbs.push(currentThumb);
  12387. }
  12388. }
  12389. }
  12390. return adjacentThumbs;
  12391. }
  12392.  
  12393. /**
  12394. * @param {HTMLElement} initialThumb
  12395. * @param {Number} limit
  12396. * @param {Function} additionalQualifier
  12397. * @returns {HTMLElement[]}
  12398. */
  12399. getAdjacentThumbsLooped(initialThumb, limit, additionalQualifier) {
  12400. const adjacentThumbs = [];
  12401. const discoveredIds = new Set();
  12402. let currentThumb = initialThumb;
  12403. let previousThumb = initialThumb;
  12404. let nextThumb = initialThumb;
  12405. let traverseForward = true;
  12406.  
  12407. while (currentThumb !== null && adjacentThumbs.length < limit) {
  12408. if (traverseForward) {
  12409. nextThumb = this.getAdjacentThumbLooped(nextThumb, true);
  12410. } else {
  12411. previousThumb = this.getAdjacentThumbLooped(previousThumb, false);
  12412. }
  12413. currentThumb = traverseForward ? nextThumb : previousThumb;
  12414. traverseForward = !traverseForward;
  12415.  
  12416. if (currentThumb === undefined || discoveredIds.has(currentThumb.id)) {
  12417. break;
  12418. }
  12419. discoveredIds.add(currentThumb.id);
  12420.  
  12421. if (additionalQualifier(currentThumb)) {
  12422. adjacentThumbs.push(currentThumb);
  12423. }
  12424. }
  12425. return adjacentThumbs;
  12426. }
  12427.  
  12428. /**
  12429. * @param {HTMLElement} previousThumb
  12430. * @param {HTMLElement} traverseForward
  12431. * @param {HTMLElement} nextThumb
  12432. * @returns {Boolean}
  12433. */
  12434. getTraversalDirection(previousThumb, traverseForward, nextThumb) {
  12435. if (previousThumb === null) {
  12436. traverseForward = true;
  12437. } else if (nextThumb === null) {
  12438. traverseForward = false;
  12439. }
  12440. return !traverseForward;
  12441. }
  12442.  
  12443. /**
  12444. * @param {HTMLElement} thumb
  12445. * @param {Boolean} forward
  12446. * @returns {HTMLElement}
  12447. */
  12448. getAdjacentThumbLooped(thumb, forward) {
  12449. let adjacentThumb = this.getAdjacentThumb(thumb, forward);
  12450.  
  12451. if (adjacentThumb === null) {
  12452. adjacentThumb = forward ? this.visibleThumbs[0] : this.visibleThumbs[this.visibleThumbs.length - 1];
  12453. }
  12454. return adjacentThumb;
  12455. }
  12456.  
  12457. /**
  12458. * @param {HTMLElement} thumb
  12459. * @param {Boolean} forward
  12460. * @returns {HTMLElement}
  12461. */
  12462. getAdjacentThumb(thumb, forward) {
  12463. return forward ? thumb.nextElementSibling : thumb.previousElementSibling;
  12464. }
  12465.  
  12466. /**
  12467. * @param {HTMLElement} initialThumb
  12468. * @param {Number} limit
  12469. * @param {Function} additionalQualifier
  12470. * @returns {HTMLElement[]}
  12471. */
  12472. getAdjacentSearchResults(initialThumb, limit, additionalQualifier) {
  12473. const initialSearchResultIndex = this.latestSearchResults.findIndex(post => post.id === initialThumb.id);
  12474.  
  12475. if (initialSearchResultIndex === -1) {
  12476. return [];
  12477. }
  12478. const adjacentSearchResults = [];
  12479. const discoveredIds = new Set();
  12480.  
  12481. let currentSearchResult;
  12482. let currentIndex;
  12483. let forward = true;
  12484. let previousIndex = initialSearchResultIndex;
  12485. let nextIndex = initialSearchResultIndex;
  12486.  
  12487. while (adjacentSearchResults.length < limit) {
  12488. if (forward) {
  12489. nextIndex = this.getAdjacentSearchResultIndex(nextIndex, true);
  12490. currentIndex = nextIndex;
  12491. forward = false;
  12492. } else {
  12493. previousIndex = this.getAdjacentSearchResultIndex(previousIndex, false);
  12494. currentIndex = previousIndex;
  12495. forward = true;
  12496. }
  12497. currentSearchResult = this.latestSearchResults[currentIndex];
  12498.  
  12499. if (discoveredIds.has(currentSearchResult.id)) {
  12500. break;
  12501. }
  12502. discoveredIds.add(currentSearchResult.id);
  12503.  
  12504. if (additionalQualifier(currentSearchResult)) {
  12505. adjacentSearchResults.push(currentSearchResult);
  12506. }
  12507. }
  12508.  
  12509. for (const searchResult of adjacentSearchResults) {
  12510. searchResult.activateHTMLElement();
  12511. }
  12512. return adjacentSearchResults.map(post => post.root);
  12513. }
  12514.  
  12515. /**
  12516. * @param {Number} i
  12517. * @param {Boolean} forward
  12518. * @returns {Number}
  12519. */
  12520. getAdjacentSearchResultIndex(i, forward) {
  12521. if (forward) {
  12522. i += 1;
  12523. i = i >= this.latestSearchResults.length ? 0 : i;
  12524. } else {
  12525. i -= 1;
  12526. i = i < 0 ? this.latestSearchResults.length - 1 : i;
  12527. }
  12528. return i;
  12529. }
  12530.  
  12531. /**
  12532. * @param {HTMLElement} thumb
  12533. * @returns {Boolean}
  12534. */
  12535. renderHasStarted(thumb) {
  12536. return this.startedRenders.has(thumb.id);
  12537. }
  12538.  
  12539. /**
  12540. * @param {HTMLElement} thumb
  12541. * @returns {Boolean}
  12542. */
  12543. renderIsCompleted(thumb) {
  12544. return this.completedRenders.has(thumb.id);
  12545. }
  12546.  
  12547. /**
  12548. * @param {HTMLElement} thumb
  12549. * @returns {Boolean}
  12550. */
  12551. canvasIsTransferrable(thumb) {
  12552. return !Utils.onMobileDevice() && !Utils.onSearchPage() && !this.transferredCanvases.has(thumb.id) && document.getElementById(thumb.id) !== null;
  12553. }
  12554.  
  12555. /**
  12556. * @param {HTMLElement} thumb
  12557. * @returns {{
  12558. * action: String,
  12559. * imageURL: String,
  12560. * id: String,
  12561. * extension: String,
  12562. * fetchDelay: Number,
  12563. * thumbURL: String,
  12564. * pixelCount: Number,
  12565. * canvas: OffscreenCanvas
  12566. * resolutionFraction: Number
  12567. * windowDimensions: {width: Number, height:Number}
  12568. * }}
  12569. */
  12570. getRenderRequest(thumb) {
  12571. const request = {
  12572. action: "render",
  12573. imageURL: Utils.getOriginalImageURLFromThumb(thumb),
  12574. id: thumb.id,
  12575. extension: Utils.getImageExtension(thumb.id),
  12576. fetchDelay: this.getBaseImageFetchDelay(thumb.id),
  12577. thumbURL: Utils.getImageFromThumb(thumb).src.replace("us.rule", "rule"),
  12578. pixelCount: this.getPixelCount(thumb),
  12579. resolutionFraction: Gallery.settings.upscaledThumbResolutionFraction
  12580. };
  12581.  
  12582. this.startedRenders.add(thumb.id);
  12583.  
  12584. if (this.canvasIsTransferrable(thumb)) {
  12585. request.canvas = this.getOffscreenCanvasFromThumb(thumb);
  12586. }
  12587.  
  12588. if (Utils.onMobileDevice()) {
  12589. request.windowDimensions = {
  12590. width: window.innerWidth,
  12591. height: window.innerHeight
  12592. };
  12593. }
  12594. return request;
  12595. }
  12596.  
  12597. /**
  12598. * @param {HTMLElement} thumb
  12599. * @returns {Number}
  12600. */
  12601. getPixelCount(thumb) {
  12602. if (Utils.onSearchPage()) {
  12603. return 0;
  12604. }
  12605. const defaultPixelCount = 2073600;
  12606. const pixelCount = Post.getPixelCount(thumb.id);
  12607. return pixelCount === 0 ? defaultPixelCount : pixelCount;
  12608. }
  12609.  
  12610. /**
  12611. * @param {HTMLElement} thumb
  12612. */
  12613. renderOriginalImage(thumb) {
  12614. if (Utils.onSearchPage()) {
  12615. return;
  12616. }
  12617.  
  12618. if (this.canvasIsTransferrable(thumb)) {
  12619. const request = this.getRenderRequest(thumb);
  12620.  
  12621. this.imageRenderer.postMessage(request, [request.canvas]);
  12622. } else {
  12623. this.imageRenderer.postMessage(this.getRenderRequest(thumb));
  12624. }
  12625. }
  12626.  
  12627. /**
  12628. * @param {HTMLElement} thumb
  12629. */
  12630. drawMainCanvas(thumb) {
  12631. this.imageRenderer.postMessage({
  12632. action: "drawMainCanvas",
  12633. id: thumb.id
  12634. });
  12635. }
  12636.  
  12637. clearMainCanvas() {
  12638. this.imageRenderer.postMessage({
  12639. action: "clearMainCanvas"
  12640. });
  12641. }
  12642.  
  12643. /**
  12644. * @param {Boolean} value
  12645. */
  12646. toggleOriginalContentVisibility(value) {
  12647. this.toggleMainCanvas(value);
  12648. this.toggleOriginalGIF(value);
  12649.  
  12650. if (!value) {
  12651. this.toggleOriginalVideoContainer(false);
  12652. }
  12653. }
  12654.  
  12655. /**
  12656. * @param {Boolean} value
  12657. */
  12658. toggleBackgroundVisibility(value) {
  12659. if (value === undefined) {
  12660. value = this.background.style.display === "block";
  12661. this.background.style.display = value ? "none" : "block";
  12662. this.originalImageLinkMask.style.display = value ? "none" : "block";
  12663. return;
  12664. }
  12665. this.background.style.display = value ? "block" : "none";
  12666. this.originalImageLinkMask.style.display = value ? "block" : "none";
  12667. }
  12668.  
  12669. /**
  12670. * @param {Boolean} value
  12671. */
  12672. toggleBackgroundOpacity(value) {
  12673. if (value !== undefined) {
  12674. if (value) {
  12675. this.updateBackgroundOpacity(1);
  12676. } else {
  12677. this.updateBackgroundOpacity(0);
  12678. }
  12679. return;
  12680. }
  12681. const opacity = parseFloat(this.background.style.opacity);
  12682.  
  12683. if (opacity < 1) {
  12684. this.updateBackgroundOpacity(1);
  12685. } else {
  12686. this.updateBackgroundOpacity(0);
  12687. }
  12688. }
  12689.  
  12690. /**
  12691. * @param {Boolean} value
  12692. */
  12693. toggleScrollbarVisibility(value) {
  12694. if (value === undefined) {
  12695. document.body.style.overflowY = document.body.style.overflowY === "auto" ? "hidden" : "auto";
  12696. return;
  12697. }
  12698. document.body.style.overflowY = value ? "auto" : "hidden";
  12699. }
  12700.  
  12701. /**
  12702. * @param {Boolean} value
  12703. */
  12704. toggleCursorVisibility(value) {
  12705. // const html = `
  12706. // #original-content-background {
  12707. // cursor: ${value ? "auto" : "none"};
  12708. // }
  12709. // `;
  12710.  
  12711. // insertStyleHTML(html, "gallery-cursor-visibility");
  12712. }
  12713.  
  12714. /**
  12715. * @param {Boolean} value
  12716. */
  12717. toggleVideoControls(value) {
  12718. const video = this.getActiveVideoPlayer();
  12719.  
  12720. if (Utils.onMobileDevice()) {
  12721. if (value) {
  12722. video.setAttribute("controls", "");
  12723. }
  12724. } else {
  12725. video.style.pointerEvents = value ? "auto" : "none";
  12726. }
  12727.  
  12728. if (!value) {
  12729. video.removeAttribute("controls");
  12730. }
  12731. }
  12732.  
  12733. /**
  12734. * @param {Boolean} value
  12735. */
  12736. toggleMainCanvas(value) {
  12737. if (value === undefined) {
  12738. this.mainCanvas.style.visibility = this.mainCanvas.style.visibility === "visible" ? "hidden" : "visible";
  12739. this.lowResolutionCanvas.style.visibility = this.mainCanvas.style.visibility === "visible" ? "hidden" : "visible";
  12740. } else {
  12741. this.mainCanvas.style.visibility = value ? "visible" : "hidden";
  12742. this.lowResolutionCanvas.style.visibility = value ? "visible" : "hidden";
  12743. }
  12744. }
  12745.  
  12746. /**
  12747. * @param {Boolean} value
  12748. */
  12749. toggleOriginalVideoContainer(value) {
  12750. if (value !== undefined) {
  12751. this.videoContainer.style.display = value ? "block" : "none";
  12752. return;
  12753. }
  12754.  
  12755. if (!this.currentlyHoveringOverVideoThumb() || this.videoContainer.style.display === "block") {
  12756. this.videoContainer.style.display = "none";
  12757. } else {
  12758. this.videoContainer.style.display = "block";
  12759. }
  12760. }
  12761.  
  12762. /**
  12763. * @param {HTMLElement} thumb
  12764. */
  12765. setActiveVideoPlayer(thumb) {
  12766. for (const video of this.videoPlayers) {
  12767. video.removeAttribute("active");
  12768. }
  12769.  
  12770. for (const video of this.videoPlayers) {
  12771. if (this.videoPlayerHasSource(video, thumb)) {
  12772. video.setAttribute("active", "");
  12773. return;
  12774. }
  12775. }
  12776. this.videoPlayers[0].setAttribute("active", "");
  12777. }
  12778.  
  12779. /**
  12780. * @returns {HTMLVideoElement}
  12781. */
  12782. getActiveVideoPlayer() {
  12783. return this.videoPlayers.find(video => video.hasAttribute("active")) || this.videoPlayers[0];
  12784. }
  12785.  
  12786. /**
  12787. * @param {HTMLVideoElement} video
  12788. * @param {HTMLElement} thumb
  12789. * @returns {Boolean}
  12790. */
  12791. videoPlayerHasSource(video, thumb) {
  12792. return video.src === this.getVideoSource(thumb);
  12793. }
  12794.  
  12795. /**
  12796. * @returns {HTMLVideoElement[]}
  12797. */
  12798. getInactiveVideoPlayers() {
  12799. return this.videoPlayers.filter(video => !video.hasAttribute("active"));
  12800. }
  12801.  
  12802. /**
  12803. * @param {Boolean} value
  12804. */
  12805. toggleOriginalGIF(value) {
  12806. if (value === undefined) {
  12807. value = this.gifContainer.style.visibility !== "visible";
  12808. }
  12809. this.gifContainer.style.visibility = value ? "visible" : "hidden";
  12810.  
  12811. if (Utils.onMobileDevice()) {
  12812. this.gifContainer.style.zIndex = value ? "9995" : "0";
  12813. }
  12814. }
  12815.  
  12816. /**
  12817. * @returns {Number}
  12818. */
  12819. getIndexOfThumbUnderCursor() {
  12820. return this.thumbUnderCursor === null ? null : this.getIndexFromThumb(this.thumbUnderCursor);
  12821. }
  12822.  
  12823. /**
  12824. * @returns {HTMLElement}
  12825. */
  12826. getSelectedThumb() {
  12827. return this.visibleThumbs[this.currentlySelectedThumbIndex];
  12828. }
  12829.  
  12830. /**
  12831. * @param {HTMLElement[]} animatedThumbs
  12832. */
  12833. upscaleAnimatedThumbs(animatedThumbs) {
  12834. if (Utils.onMobileDevice()) {
  12835. return;
  12836. }
  12837. const upscaleRequests = [];
  12838.  
  12839. for (const thumb of animatedThumbs) {
  12840. if (!this.canvasIsTransferrable(thumb)) {
  12841. continue;
  12842. }
  12843. let imageURL = Utils.getOriginalImageURL(Utils.getImageFromThumb(thumb).src);
  12844.  
  12845. if (Utils.isGif(thumb)) {
  12846. imageURL = imageURL.replace("jpg", "gif");
  12847. }
  12848. upscaleRequests.push({
  12849. id: thumb.id,
  12850. imageURL,
  12851. canvas: this.getOffscreenCanvasFromThumb(thumb),
  12852. resolutionFraction: Gallery.settings.upscaledAnimatedThumbResolutionFraction
  12853. });
  12854. }
  12855.  
  12856. this.imageRenderer.postMessage({
  12857. action: "upscaleAnimatedThumbs",
  12858. upscaleRequests
  12859. }, upscaleRequests.map(request => request.canvas));
  12860. }
  12861.  
  12862. /**
  12863. * @param {String} id
  12864. * @returns {Number}
  12865. */
  12866. getBaseImageFetchDelay(id) {
  12867. if (Utils.onFavoritesPage() && !Gallery.finishedLoading) {
  12868. return Gallery.settings.throttledImageFetchDelay;
  12869. }
  12870.  
  12871. if (Utils.extensionIsKnown(id)) {
  12872. return Gallery.settings.imageFetchDelayWhenExtensionKnown;
  12873. }
  12874. return Gallery.settings.imageFetchDelay;
  12875. }
  12876.  
  12877. /**
  12878. * @param {HTMLElement} thumb
  12879. */
  12880. upscaleAnimatedThumbsAround(thumb) {
  12881. if (!Utils.onFavoritesPage() || Utils.onMobileDevice()) {
  12882. return;
  12883. }
  12884. const animatedThumbsToUpscale = this.getAdjacentThumbs(thumb, Gallery.settings.animatedThumbsToUpscaleRange, (t) => {
  12885. return !Utils.isImage(t) && !this.transferredCanvases.has(t.id);
  12886. });
  12887.  
  12888. this.upscaleAnimatedThumbs(animatedThumbsToUpscale);
  12889. }
  12890.  
  12891. /**
  12892. * @param {HTMLElement} thumb
  12893. */
  12894. upscaleAnimatedThumbsAroundDiscrete(thumb) {
  12895. if (!Utils.onFavoritesPage() || Utils.onMobileDevice()) {
  12896. return;
  12897. }
  12898. const animatedThumbsToUpscale = this.getAdjacentThumbs(thumb, Gallery.settings.animatedThumbsToUpscaleDiscrete, (_) => {
  12899. return true;
  12900. }).filter(t => !Utils.isImage(t) && !this.transferredCanvases.has(t.id));
  12901.  
  12902. this.upscaleAnimatedThumbs(animatedThumbsToUpscale);
  12903. }
  12904.  
  12905. /**
  12906. * @param {Post[]} thumbs
  12907. * @returns {String[]}
  12908. */
  12909. getIdsWithUnknownExtensions(thumbs) {
  12910. return thumbs
  12911. .filter(thumb => Utils.isImage(thumb) && !Utils.extensionIsKnown(thumb.id))
  12912. .map(thumb => thumb.id);
  12913. }
  12914.  
  12915. /**
  12916. * @param {String} id
  12917. */
  12918. drawLowResolutionCanvas(thumb) {
  12919. const image = Utils.getImageFromThumb(thumb);
  12920.  
  12921. if (!Utils.imageIsLoaded(image)) {
  12922. return;
  12923. }
  12924. const ratio = Math.min(this.lowResolutionCanvas.width / image.naturalWidth, this.lowResolutionCanvas.height / image.naturalHeight);
  12925. const centerShiftX = (this.lowResolutionCanvas.width - (image.naturalWidth * ratio)) / 2;
  12926. const centerShiftY = (this.lowResolutionCanvas.height - (image.naturalHeight * ratio)) / 2;
  12927.  
  12928. this.clearLowResolutionCanvas();
  12929. this.lowResolutionContext.drawImage(
  12930. image, 0, 0, image.naturalWidth, image.naturalHeight,
  12931. centerShiftX, centerShiftY, image.naturalWidth * ratio, image.naturalHeight * ratio
  12932. );
  12933. }
  12934.  
  12935. clearLowResolutionCanvas() {
  12936. this.lowResolutionContext.clearRect(0, 0, this.lowResolutionCanvas.width, this.lowResolutionCanvas.height);
  12937. }
  12938.  
  12939. /**
  12940. * @param {Boolean} value
  12941. */
  12942. toggleVideoLooping(value) {
  12943. for (const video of this.videoPlayers) {
  12944. video.toggleAttribute("loop", value);
  12945. }
  12946. }
  12947.  
  12948. loadVideoClips() {
  12949. window.addEventListener("postProcess", () => {
  12950. setTimeout(() => {
  12951. let storedVideoClips;
  12952.  
  12953. try {
  12954. storedVideoClips = JSON.parse(localStorage.getItem("storedVideoClips") || "{}");
  12955.  
  12956. for (const [id, videoClip] of Object.entries(storedVideoClips)) {
  12957. this.videoClips.set(id, new VideoClip(videoClip));
  12958. }
  12959. } catch (error) {
  12960. console.error(error);
  12961. }
  12962. }, 50);
  12963. });
  12964. }
  12965.  
  12966. /**
  12967. * @param {KeyboardEvent} event
  12968. */
  12969. async addFavoriteInGallery(event) {
  12970. if (!this.inGallery || event.repeat || !Gallery.addOrRemoveFavoriteCooldown.ready) {
  12971. return;
  12972. }
  12973. const selectedThumb = this.getSelectedThumb();
  12974.  
  12975. if (selectedThumb === undefined || selectedThumb === null) {
  12976. Utils.showFullscreenIcon(Utils.icons.error);
  12977. return;
  12978. }
  12979. const addedFavoriteStatus = await Utils.addFavorite(selectedThumb.id);
  12980. let svg = Utils.icons.error;
  12981.  
  12982. switch (addedFavoriteStatus) {
  12983. case Utils.addedFavoriteStatuses.alreadyAdded:
  12984. svg = Utils.icons.heartCheck;
  12985. break;
  12986.  
  12987. case Utils.addedFavoriteStatuses.success:
  12988. svg = Utils.icons.heartPlus;
  12989. this.onFavoriteAddedOrDeleted(selectedThumb.id);
  12990. break;
  12991.  
  12992. default:
  12993. break;
  12994. }
  12995. Utils.showFullscreenIcon(svg);
  12996. }
  12997.  
  12998. /**
  12999. * @param {String} id
  13000. */
  13001. onFavoriteAddedOrDeleted(id) {
  13002. dispatchEvent(new CustomEvent("favoriteAddedOrDeleted", {
  13003. detail: id
  13004. }));
  13005. }
  13006.  
  13007. async setupOriginalImageLinkInGallery() {
  13008. const thumb = this.getSelectedThumb();
  13009.  
  13010. if (thumb === null || thumb === undefined) {
  13011. return;
  13012. }
  13013. const imageURL = await Utils.getOriginalImageURLWithExtension(thumb);
  13014.  
  13015. this.toggleCtrlClickOpenMediaInNewTab(false);
  13016. this.originalImageLinkMask.setAttribute("href", imageURL);
  13017. }
  13018.  
  13019. /**
  13020. * @param {Boolean} value
  13021. */
  13022. toggleCtrlClickOpenMediaInNewTab(value) {
  13023. if (!this.inGallery && value) {
  13024. return;
  13025. }
  13026. this.originalImageLinkMask.classList.toggle("active", value);
  13027. }
  13028. }
  13029.  
  13030. class Tooltip {
  13031. static tooltipHTML = `
  13032. <div id="tooltip-container">
  13033. <style>
  13034. #tooltip {
  13035. max-width: 750px;
  13036. border: 1px solid black;
  13037. padding: 0.25em;
  13038. position: absolute;
  13039. box-sizing: border-box;
  13040. z-index: 25;
  13041. pointer-events: none;
  13042. visibility: hidden;
  13043. opacity: 0;
  13044. transition: visibility 0s, opacity 0.25s linear;
  13045. font-size: 1.05em;
  13046. }
  13047.  
  13048. #tooltip.visible {
  13049. visibility: visible;
  13050. opacity: 1;
  13051. }
  13052. </style>
  13053. <span id="tooltip" class="light-green-gradient"></span>
  13054. </div>
  13055. `;
  13056. /**
  13057. * @type {Boolean}
  13058. */
  13059. static get disabled() {
  13060. return Utils.onMobileDevice() || Utils.getPerformanceProfile() > 1 || Utils.onPostPage();
  13061. }
  13062.  
  13063. /**
  13064. * @type {HTMLDivElement}
  13065. */
  13066. tooltip;
  13067. /**
  13068. * @type {String}
  13069. */
  13070. defaultTransition;
  13071. /**
  13072. * @type {Boolean}
  13073. */
  13074. visible;
  13075. /**
  13076. * @type {Object.<String,String>}
  13077. */
  13078. searchTagColorCodes;
  13079. /**
  13080. * @type {HTMLTextAreaElement}
  13081. */
  13082. searchBox;
  13083. /**
  13084. * @type {String}
  13085. */
  13086. previousSearch;
  13087. /**
  13088. * @type {HTMLImageElement}
  13089. */
  13090. currentImage;
  13091.  
  13092. constructor() {
  13093. if (Tooltip.disabled) {
  13094. return;
  13095. }
  13096. this.visible = Utils.getPreference("showTooltip", true);
  13097. Utils.insertFavoritesSearchGalleryHTML("afterbegin", Tooltip.tooltipHTML);
  13098. this.tooltip = document.getElementById("tooltip");
  13099. this.defaultTransition = this.tooltip.style.transition;
  13100. this.searchTagColorCodes = {};
  13101. this.currentImage = null;
  13102. this.addEventListeners();
  13103. this.addFavoritesOptions();
  13104. this.assignColorsToMatchedTags();
  13105. }
  13106.  
  13107. addEventListeners() {
  13108. this.addAllPageEventListeners();
  13109. this.addSearchPageEventListeners();
  13110. this.addFavoritesPageEventListeners();
  13111. }
  13112.  
  13113. addAllPageEventListeners() {
  13114. document.addEventListener("keydown", (event) => {
  13115. if (event.key.toLowerCase() !== "t" || !Utils.isHotkeyEvent(event)) {
  13116. return;
  13117. }
  13118.  
  13119. if (Utils.onFavoritesPage()) {
  13120. const showTooltipsCheckbox = document.getElementById("show-tooltips-checkbox");
  13121.  
  13122. if (showTooltipsCheckbox !== null) {
  13123. showTooltipsCheckbox.click();
  13124.  
  13125. if (this.currentImage !== null) {
  13126. if (this.visible) {
  13127. this.show(this.currentImage);
  13128. } else {
  13129. this.hide();
  13130. }
  13131. }
  13132. }
  13133. } else if (Utils.onSearchPage()) {
  13134. this.toggleVisibility();
  13135.  
  13136. if (this.currentImage !== null) {
  13137. this.hide();
  13138. }
  13139. }
  13140. }, {
  13141. passive: true
  13142. });
  13143. }
  13144.  
  13145. addSearchPageEventListeners() {
  13146. if (!Utils.onSearchPage()) {
  13147. return;
  13148. }
  13149. window.addEventListener("load", () => {
  13150. this.addEventListenersToThumbs.bind(this)();
  13151. }, {
  13152. once: true,
  13153. passive: true
  13154. });
  13155. }
  13156.  
  13157. addFavoritesPageEventListeners() {
  13158. if (!Utils.onFavoritesPage()) {
  13159. return;
  13160. }
  13161. window.addEventListener("favoritesFetched", () => {
  13162. this.addEventListenersToThumbs.bind(this)();
  13163. });
  13164. window.addEventListener("favoritesLoaded", () => {
  13165. this.addEventListenersToThumbs.bind(this)();
  13166. }, {
  13167. once: true
  13168. });
  13169. window.addEventListener("changedPage", () => {
  13170. this.currentImage = null;
  13171. this.addEventListenersToThumbs.bind(this)();
  13172. });
  13173. window.addEventListener("newFavoritesFetchedOnReload", (event) => {
  13174. if (!event.detail.empty) {
  13175. this.addEventListenersToThumbs.bind(this)(event.detail.thumbs);
  13176. }
  13177. }, {
  13178. once: true
  13179. });
  13180. }
  13181.  
  13182. assignColorsToMatchedTags() {
  13183. if (Utils.onSearchPage()) {
  13184. this.assignColorsToMatchedTagsOnSearchPage();
  13185. } else {
  13186. this.searchBox = document.getElementById("favorites-search-box");
  13187. this.assignColorsToMatchedTagsOnFavoritesPage();
  13188. this.searchBox.addEventListener("input", () => {
  13189. this.assignColorsToMatchedTagsOnFavoritesPage();
  13190. });
  13191. window.addEventListener("searchStarted", () => {
  13192. this.assignColorsToMatchedTagsOnFavoritesPage();
  13193. });
  13194.  
  13195. }
  13196. }
  13197.  
  13198. /**
  13199. * @param {HTMLCollectionOf.<Element>} thumbs
  13200. */
  13201. addEventListenersToThumbs(thumbs) {
  13202. thumbs = thumbs === undefined ? Utils.getAllThumbs() : thumbs;
  13203.  
  13204. for (const thumb of thumbs) {
  13205. const image = Utils.getImageFromThumb(thumb);
  13206.  
  13207. if (image.onmouseenter !== null) {
  13208. continue;
  13209. }
  13210.  
  13211. image.onmouseenter = (event) => {
  13212. if (Utils.enteredOverCaptionTag(event)) {
  13213. return;
  13214. }
  13215. this.currentImage = image;
  13216.  
  13217. if (this.visible) {
  13218. this.show(image);
  13219. }
  13220. };
  13221. image.onmouseleave = (event) => {
  13222. if (!Utils.enteredOverCaptionTag(event)) {
  13223. this.currentImage = null;
  13224. this.hide();
  13225. }
  13226. };
  13227. }
  13228. }
  13229.  
  13230. /**
  13231. * @param {HTMLImageElement} image
  13232. */
  13233. setPosition(image) {
  13234. const fancyHoveringStyle = document.getElementById("fancy-image-hovering-fsg-style");
  13235. const imageChangesSizeOnHover = fancyHoveringStyle !== null && fancyHoveringStyle.textContent !== "";
  13236. let rect;
  13237.  
  13238. if (imageChangesSizeOnHover) {
  13239. const imageContainer = image.parentElement;
  13240. const sizeCalculationDiv = document.createElement("div");
  13241.  
  13242. sizeCalculationDiv.className = "size-calculation-div";
  13243. imageContainer.appendChild(sizeCalculationDiv);
  13244. rect = sizeCalculationDiv.getBoundingClientRect();
  13245. sizeCalculationDiv.remove();
  13246. } else {
  13247. rect = image.getBoundingClientRect();
  13248. }
  13249. const offset = 7;
  13250. let tooltipRect;
  13251.  
  13252. this.tooltip.style.top = `${rect.bottom + offset + window.scrollY}px`;
  13253. this.tooltip.style.left = `${rect.x - 3}px`;
  13254. this.tooltip.classList.toggle("visible", true);
  13255. tooltipRect = this.tooltip.getBoundingClientRect();
  13256. const toolTipIsClippedAtBottom = tooltipRect.bottom > window.innerHeight;
  13257.  
  13258. if (!toolTipIsClippedAtBottom) {
  13259. return;
  13260. }
  13261. this.tooltip.style.top = `${rect.top - tooltipRect.height + window.scrollY - offset}px`;
  13262. tooltipRect = this.tooltip.getBoundingClientRect();
  13263. const menu = document.getElementById("favorites-search-gallery-menu");
  13264. const elementAboveTooltip = menu === null ? document.getElementById("header") : menu;
  13265. const elementAboveTooltipRect = elementAboveTooltip.getBoundingClientRect();
  13266. const toolTipIsClippedAtTop = tooltipRect.top < elementAboveTooltipRect.bottom;
  13267.  
  13268. if (!toolTipIsClippedAtTop) {
  13269. return;
  13270. }
  13271. const tooltipIsLeftOfCenter = tooltipRect.left < (window.innerWidth / 2);
  13272.  
  13273. this.tooltip.style.top = `${rect.top + window.scrollY + (rect.height / 2) - offset}px`;
  13274.  
  13275. if (tooltipIsLeftOfCenter) {
  13276. this.tooltip.style.left = `${rect.right + offset}px`;
  13277. } else {
  13278. this.tooltip.style.left = `${rect.left - 750 - offset}px`;
  13279. }
  13280. }
  13281.  
  13282. /**
  13283. * @param {HTMLImageElement} image
  13284. */
  13285. show(image) {
  13286. this.tooltip.innerHTML = this.formatHTML(this.getTags(image));
  13287. this.setPosition(image);
  13288. }
  13289.  
  13290. hide() {
  13291. this.tooltip.style.transition = "none";
  13292. this.tooltip.classList.toggle("visible", false);
  13293. setTimeout(() => {
  13294. this.tooltip.style.transition = this.defaultTransition;
  13295. }, 5);
  13296. }
  13297.  
  13298. /**
  13299. * @param {HTMLImageElement} image
  13300. * @returns {String}
  13301. */
  13302. getTags(image) {
  13303. const thumb = Utils.getThumbFromImage(image);
  13304. const tags = Utils.getTagsFromThumb(thumb);
  13305.  
  13306. if (this.searchTagColorCodes[thumb.id] === undefined) {
  13307. tags.delete(thumb.id);
  13308. }
  13309. return Array.from(tags).sort().join(" ");
  13310. }
  13311.  
  13312. /**
  13313. * @returns {String}
  13314. */
  13315. getRandomColor() {
  13316. const letters = "0123456789ABCDEF";
  13317. let color = "#";
  13318.  
  13319. for (let i = 0; i < 6; i += 1) {
  13320. if (i === 2 || i === 3) {
  13321. color += "0";
  13322. } else {
  13323. color += letters[Math.floor(Math.random() * letters.length)];
  13324. }
  13325. }
  13326. return color;
  13327. }
  13328.  
  13329. /**
  13330. * @param {String} tags
  13331. */
  13332. formatHTML(tags) {
  13333. let unmatchedTagsHTML = "";
  13334. let matchedTagsHTML = "";
  13335.  
  13336. const tagList = Utils.removeExtraWhiteSpace(tags).split(" ");
  13337.  
  13338. for (let i = 0; i < tagList.length; i += 1) {
  13339. const tag = tagList[i];
  13340. const tagColor = this.getColorCode(tag);
  13341. const tagWithSpace = `${tag} `;
  13342.  
  13343. if (tagColor !== undefined) {
  13344. matchedTagsHTML += `<span style="color:${tagColor}"><b>${tagWithSpace}</b></span>`;
  13345. } else if (Utils.includesTag(tag, new Set(Utils.tagBlacklist.split(" ")))) {
  13346. unmatchedTagsHTML += `<span style="color:red"><s><b>${tagWithSpace}</b></s></span>`;
  13347. } else {
  13348. unmatchedTagsHTML += tagWithSpace;
  13349. }
  13350. }
  13351. const html = matchedTagsHTML + unmatchedTagsHTML;
  13352.  
  13353. if (html === "") {
  13354. return tags;
  13355. }
  13356. return html;
  13357. }
  13358.  
  13359. /**
  13360. * @param {String} searchQuery
  13361. */
  13362. assignTagColors(searchQuery) {
  13363. searchQuery = this.removeNotTags(searchQuery);
  13364. const {orGroups, remainingSearchTags} = Utils.extractTagGroups(searchQuery);
  13365.  
  13366. this.searchTagColorCodes = {};
  13367. this.assignColorsToOrGroupTags(orGroups);
  13368. this.assignColorsToRemainingTags(remainingSearchTags);
  13369. }
  13370.  
  13371. /**
  13372. * @param {String[][]} orGroups
  13373. */
  13374. assignColorsToOrGroupTags(orGroups) {
  13375.  
  13376. for (const orGroup of orGroups) {
  13377. const color = this.getRandomColor();
  13378.  
  13379. for (const tag of orGroup) {
  13380. this.addColorCodedTag(tag, color);
  13381. }
  13382. }
  13383. }
  13384.  
  13385. /**
  13386. * @param {String[]} remainingTags
  13387. */
  13388. assignColorsToRemainingTags(remainingTags) {
  13389. for (const tag of remainingTags) {
  13390. this.addColorCodedTag(tag, this.getRandomColor());
  13391. }
  13392. }
  13393.  
  13394. /**
  13395. * @param {String} tags
  13396. * @returns {String}
  13397. */
  13398. removeNotTags(tags) {
  13399. return tags.replace(/(?:^| )-\S+/gm, "");
  13400. }
  13401.  
  13402. sanitizeTags(tags) {
  13403. return tags.toLowerCase().trim();
  13404. }
  13405.  
  13406. addColorCodedTag(tag, color) {
  13407. tag = this.sanitizeTags(tag);
  13408.  
  13409. if (this.searchTagColorCodes[tag] === undefined) {
  13410. this.searchTagColorCodes[tag] = color;
  13411. }
  13412. }
  13413.  
  13414. /**
  13415. * @param {String} tag
  13416. * @returns {String | null}
  13417. */
  13418. getColorCode(tag) {
  13419. if (this.searchTagColorCodes[tag] !== undefined) {
  13420. return this.searchTagColorCodes[tag];
  13421. }
  13422.  
  13423. for (const searchTag of Object.keys(this.searchTagColorCodes)) {
  13424. if (Utils.tagsMatchWildcardSearchTag(searchTag, [tag])) {
  13425. return this.searchTagColorCodes[searchTag];
  13426. }
  13427. }
  13428. return undefined;
  13429. }
  13430.  
  13431. addFavoritesOptions() {
  13432. Utils.createFavoritesOption(
  13433. "show-tooltips",
  13434. " Tooltips",
  13435. "Show tags when hovering over a thumbnail and see which ones were matched by a search",
  13436. this.visible, (event) => {
  13437. this.toggleVisibility(event.target.checked);
  13438. },
  13439. true,
  13440. "(T)"
  13441. );
  13442. }
  13443.  
  13444. /**
  13445. * @param {Boolean} value
  13446. */
  13447. toggleVisibility(value) {
  13448. if (value === undefined) {
  13449. value = !this.visible;
  13450. }
  13451. Utils.setPreference("showTooltip", value);
  13452. this.visible = value;
  13453. }
  13454.  
  13455. /**
  13456. * @param {HTMLElement | null} thumb
  13457. */
  13458. showOnLoadIfHoveringOverThumb(thumb) {
  13459. if (thumb !== null) {
  13460. this.show(Utils.getImageFromThumb(thumb));
  13461. }
  13462. }
  13463.  
  13464. assignColorsToMatchedTagsOnSearchPage() {
  13465. const searchQuery = document.getElementsByName("tags")[0].getAttribute("value");
  13466.  
  13467. this.assignTagColors(searchQuery);
  13468. }
  13469.  
  13470. assignColorsToMatchedTagsOnFavoritesPage() {
  13471. if (this.searchBox.value === this.previousSearch) {
  13472. return;
  13473. }
  13474. this.previousSearch = this.searchBox.value;
  13475. this.assignTagColors(this.searchBox.value);
  13476. }
  13477. }
  13478.  
  13479. class SavedSearches {
  13480. static savedSearchesHTML = `
  13481. <div id="saved-searches">
  13482. <style>
  13483. #saved-searches-container {
  13484. margin: 0;
  13485. display: flex;
  13486. flex-direction: column;
  13487. padding: 0;
  13488. }
  13489.  
  13490. #saved-searches-input-container {
  13491. margin-bottom: 10px;
  13492. }
  13493.  
  13494. #saved-searches-input {
  13495. flex: 15 1 auto;
  13496. margin-right: 10px;
  13497. }
  13498.  
  13499. #savedSearches {
  13500. max-width: 100%;
  13501.  
  13502. button {
  13503. flex: 1 1 auto;
  13504. cursor: pointer;
  13505. }
  13506. }
  13507.  
  13508. #saved-searches-buttons button {
  13509. margin-right: 1px;
  13510. margin-bottom: 5px;
  13511. border: none;
  13512. border-radius: 4px;
  13513. cursor: pointer;
  13514. height: 35px;
  13515.  
  13516. &:hover {
  13517. filter: brightness(140%);
  13518. }
  13519. }
  13520.  
  13521. #saved-search-list-container {
  13522. direction: rtl;
  13523. max-height: 200px;
  13524. overflow-y: auto;
  13525. overflow-x: hidden;
  13526. margin: 0;
  13527. padding: 0;
  13528. }
  13529.  
  13530. #saved-search-list {
  13531. direction: ltr;
  13532. >li {
  13533. display: flex;
  13534. flex-direction: row;
  13535. cursor: pointer;
  13536. background: rgba(0, 0, 0, .1);
  13537.  
  13538. &:nth-child(odd) {
  13539. background: rgba(0, 0, 0, 0.2);
  13540. }
  13541.  
  13542. >div {
  13543. padding: 4px;
  13544. align-content: center;
  13545.  
  13546. svg {
  13547. height: 20px;
  13548. width: 20px;
  13549. }
  13550. }
  13551. }
  13552. }
  13553.  
  13554. .save-search-label {
  13555. flex: 1000 30px;
  13556. text-align: left;
  13557.  
  13558. &:hover {
  13559. color: white;
  13560. background: #0075FF;
  13561. }
  13562. }
  13563.  
  13564. .edit-saved-search-button {
  13565. text-align: center;
  13566. flex: 1 20px;
  13567.  
  13568. &:hover {
  13569. color: white;
  13570. background: slategray;
  13571. }
  13572. }
  13573.  
  13574. .remove-saved-search-button {
  13575. text-align: center;
  13576. flex: 1 20px;
  13577.  
  13578. &:hover {
  13579. color: white;
  13580. background: #f44336;
  13581. }
  13582. }
  13583.  
  13584. .move-saved-search-to-top-button {
  13585. text-align: center;
  13586.  
  13587. &:hover {
  13588. color: white;
  13589. background: steelblue;
  13590. }
  13591. }
  13592.  
  13593. /* .tag-type-saved>a,
  13594. .tag-type-saved {
  13595. color: lightblue;
  13596. } */
  13597. </style>
  13598. <h2>Saved Searches</h2>
  13599. <div id="saved-searches-buttons">
  13600. <button title="Save custom search" id="save-custom-search-button">Save</button>
  13601. <button id="stop-editing-saved-search-button" style="display: none;">Cancel</button>
  13602. <span>
  13603. <button title="Export all saved searches" id="export-saved-search-button">Export</button>
  13604. <button title="Import saved searches" id="import-saved-search-button">Import</button>
  13605. </span>
  13606. <button title="Save result ids as search" id="save-results-button">Save Results</button>
  13607. </div>
  13608. <div id="saved-searches-container">
  13609. <div id="saved-searches-input-container">
  13610. <textarea id="saved-searches-input" spellcheck="false" style="width: 97%;"
  13611. placeholder="Save Custom Search"></textarea>
  13612. </div>
  13613. <div id="saved-search-list-container">
  13614. <ul id="saved-search-list"></ul>
  13615. </div>
  13616. </div>
  13617. </div>
  13618. <script>
  13619. </script>
  13620. `;
  13621. static preferences = {
  13622. textareaWidth: "savedSearchesTextAreaWidth",
  13623. textareaHeight: "savedSearchesTextAreaHeight",
  13624. savedSearches: "savedSearches",
  13625. visibility: "savedSearchVisibility",
  13626. tutorial: "savedSearchesTutorial"
  13627. };
  13628. static localStorageKeys = {
  13629. savedSearches: "savedSearches"
  13630. };
  13631. /**
  13632. * @type {Boolean}
  13633. */
  13634. static get disabled() {
  13635. return !Utils.onFavoritesPage() || Utils.onMobileDevice();
  13636. }
  13637. /**
  13638. * @type {HTMLTextAreaElement}
  13639. */
  13640. textarea;
  13641. /**
  13642. * @type {HTMLElement}
  13643. */
  13644. savedSearchesList;
  13645. /**
  13646. * @type {HTMLButtonElement}
  13647. */
  13648. stopEditingButton;
  13649. /**
  13650. * @type {HTMLButtonElement}
  13651. */
  13652. saveButton;
  13653. /**
  13654. * @type {HTMLButtonElement}
  13655. */
  13656. importButton;
  13657. /**
  13658. * @type {HTMLButtonElement}
  13659. */
  13660. exportButton;
  13661. /**
  13662. * @type {HTMLButtonElement}
  13663. */
  13664. saveSearchResultsButton;
  13665.  
  13666. constructor() {
  13667. if (SavedSearches.disabled) {
  13668. return;
  13669. }
  13670. this.insertHTML();
  13671. this.extractHTMLElements();
  13672. this.addEventListeners();
  13673. this.loadSavedSearches();
  13674. }
  13675.  
  13676. insertHTML() {
  13677. const showSavedSearches = Utils.getPreference(SavedSearches.preferences.visibility, false);
  13678. const savedSearchesContainer = document.getElementById("right-favorites-panel");
  13679.  
  13680. Utils.insertHTMLAndExtractStyle(savedSearchesContainer, "beforeend", SavedSearches.savedSearchesHTML);
  13681. document.getElementById("right-favorites-panel").style.display = showSavedSearches ? "block" : "none";
  13682. const options = Utils.createFavoritesOption(
  13683. "show-saved-searches",
  13684. "Saved Searches",
  13685. "Toggle saved searches",
  13686. showSavedSearches,
  13687. (e) => {
  13688. savedSearchesContainer.style.display = e.target.checked ? "block" : "none";
  13689. Utils.setPreference(SavedSearches.preferences.visibility, e.target.checked);
  13690. },
  13691. true
  13692. );
  13693.  
  13694. document.getElementById("bottom-panel-2").insertAdjacentElement("afterbegin", options);
  13695. }
  13696.  
  13697. extractHTMLElements() {
  13698. this.saveButton = document.getElementById("save-custom-search-button");
  13699. this.textarea = document.getElementById("saved-searches-input");
  13700. this.savedSearchesList = document.getElementById("saved-search-list");
  13701. this.stopEditingButton = document.getElementById("stop-editing-saved-search-button");
  13702. this.importButton = document.getElementById("import-saved-search-button");
  13703. this.exportButton = document.getElementById("export-saved-search-button");
  13704. this.saveSearchResultsButton = document.getElementById("save-results-button");
  13705. }
  13706.  
  13707. addEventListeners() {
  13708. this.saveButton.onclick = () => {
  13709. this.saveSearch(this.textarea.value.trim());
  13710. };
  13711. this.textarea.addEventListener("keydown", (event) => {
  13712. switch (event.key) {
  13713. case "Enter":
  13714. if (Utils.awesompleteIsUnselected(this.textarea)) {
  13715. event.preventDefault();
  13716. this.saveButton.click();
  13717. this.textarea.blur();
  13718. setTimeout(() => {
  13719. this.textarea.focus();
  13720. }, 100);
  13721. }
  13722. break;
  13723.  
  13724. case "Escape":
  13725. if (Utils.awesompleteIsUnselected(this.textarea) && this.stopEditingButton.style.display === "block") {
  13726. this.stopEditingButton.click();
  13727. }
  13728. break;
  13729.  
  13730. default:
  13731. break;
  13732. }
  13733. }, {
  13734. passive: true
  13735. });
  13736. this.exportButton.onclick = () => {
  13737. this.exportSavedSearches();
  13738. };
  13739. this.importButton.onclick = () => {
  13740. this.importSavedSearches();
  13741. };
  13742. this.saveSearchResultsButton.onclick = () => {
  13743. this.saveSearchResultsAsCustomSearch();
  13744. };
  13745. }
  13746.  
  13747. /**
  13748. * @param {String} newSavedSearch
  13749. * @param {Boolean} updateLocalStorage
  13750. */
  13751. saveSearch(newSavedSearch, updateLocalStorage = true) {
  13752. if (newSavedSearch === "" || newSavedSearch === undefined) {
  13753. return;
  13754. }
  13755. const newListItem = document.createElement("li");
  13756. const savedSearchLabel = document.createElement("div");
  13757. const editButton = document.createElement("div");
  13758. const removeButton = document.createElement("div");
  13759. const moveToTopButton = document.createElement("div");
  13760.  
  13761. savedSearchLabel.innerText = newSavedSearch;
  13762. editButton.innerHTML = Utils.icons.edit;
  13763. removeButton.innerHTML = Utils.icons.delete;
  13764. moveToTopButton.innerHTML = Utils.icons.upArrow;
  13765. editButton.title = "Edit";
  13766. removeButton.title = "Delete";
  13767. moveToTopButton.title = "Move to top";
  13768. savedSearchLabel.className = "save-search-label";
  13769. editButton.className = "edit-saved-search-button";
  13770. removeButton.className = "remove-saved-search-button";
  13771. moveToTopButton.className = "move-saved-search-to-top-button";
  13772. newListItem.appendChild(removeButton);
  13773. newListItem.appendChild(editButton);
  13774. newListItem.appendChild(moveToTopButton);
  13775. newListItem.appendChild(savedSearchLabel);
  13776. this.savedSearchesList.insertBefore(newListItem, this.savedSearchesList.firstChild);
  13777. savedSearchLabel.onclick = () => {
  13778. const searchBox = document.getElementById("favorites-search-box");
  13779.  
  13780. navigator.clipboard.writeText(savedSearchLabel.innerText);
  13781.  
  13782. if (searchBox === null) {
  13783. return;
  13784. }
  13785.  
  13786. if (searchBox.value !== "") {
  13787. searchBox.value += " ";
  13788. }
  13789. searchBox.value += savedSearchLabel.innerText;
  13790. };
  13791. removeButton.onclick = () => {
  13792. if (this.inEditMode()) {
  13793. alert("Cancel current edit before removing another search");
  13794. return;
  13795. }
  13796.  
  13797. if (confirm(`Remove saved search: ${savedSearchLabel.innerText} ?`)) {
  13798. this.savedSearchesList.removeChild(newListItem);
  13799. this.storeSavedSearches();
  13800. }
  13801. };
  13802. editButton.onclick = () => {
  13803. if (this.inEditMode()) {
  13804. alert("Cancel current edit before editing another search");
  13805. } else {
  13806. this.editSavedSearches(savedSearchLabel, newListItem);
  13807. }
  13808. };
  13809. moveToTopButton.onclick = () => {
  13810. if (this.inEditMode()) {
  13811. alert("Cancel current edit before moving this search to the top");
  13812. return;
  13813. }
  13814. newListItem.parentElement.insertAdjacentElement("afterbegin", newListItem);
  13815. this.storeSavedSearches();
  13816. };
  13817. this.stopEditingButton.onclick = () => {
  13818. this.stopEditingSavedSearches(newListItem);
  13819. };
  13820. this.textarea.value = "";
  13821.  
  13822. if (updateLocalStorage) {
  13823. this.storeSavedSearches();
  13824. }
  13825. }
  13826.  
  13827. /**
  13828. * @param {HTMLLabelElement} savedSearchLabel
  13829. */
  13830. editSavedSearches(savedSearchLabel) {
  13831. this.textarea.value = savedSearchLabel.innerText;
  13832. this.saveButton.textContent = "Save Changes";
  13833. this.textarea.focus();
  13834. this.exportButton.style.display = "none";
  13835. this.importButton.style.display = "none";
  13836. this.stopEditingButton.style.display = "";
  13837. this.saveButton.onclick = () => {
  13838. savedSearchLabel.innerText = this.textarea.value.trim();
  13839. this.storeSavedSearches();
  13840. this.stopEditingButton.click();
  13841. };
  13842. }
  13843.  
  13844. /**
  13845. * @param {HTMLElement} newListItem
  13846. */
  13847. stopEditingSavedSearches(newListItem) {
  13848. this.saveButton.textContent = "Save";
  13849. this.saveButton.onclick = () => {
  13850. this.saveSearch(this.textarea.value.trim());
  13851. };
  13852. this.textarea.value = "";
  13853. this.exportButton.style.display = "";
  13854. this.importButton.style.display = "";
  13855. this.stopEditingButton.style.display = "none";
  13856. newListItem.style.border = "";
  13857. }
  13858.  
  13859. storeSavedSearches() {
  13860. localStorage.setItem(SavedSearches.localStorageKeys.savedSearches, JSON.stringify(Utils.getSavedSearchValues()));
  13861. }
  13862.  
  13863. loadSavedSearches() {
  13864. const savedSearches = JSON.parse(localStorage.getItem(SavedSearches.localStorageKeys.savedSearches)) || [];
  13865. const firstUse = Utils.getPreference(SavedSearches.preferences.tutorial, true);
  13866.  
  13867. Utils.setPreference(SavedSearches.preferences.tutorial, false);
  13868.  
  13869. if (firstUse && savedSearches.length === 0) {
  13870. this.createTutorialSearches();
  13871. return;
  13872. }
  13873.  
  13874. for (let i = savedSearches.length - 1; i >= 0; i -= 1) {
  13875. this.saveSearch(savedSearches[i], false);
  13876. }
  13877. }
  13878.  
  13879. createTutorialSearches() {
  13880. const searches = [];
  13881.  
  13882. window.addEventListener("startedFetchingFavorites", async() => {
  13883. await Utils.sleep(1000);
  13884. const postIds = Utils.getAllThumbs().map(thumb => thumb.id);
  13885.  
  13886. Utils.shuffleArray(postIds);
  13887.  
  13888. const exampleSearch = `( EXAMPLE: ~ ${postIds.slice(0, 9).join(" ~ ")} ) ( male* ~ female* ~ 1boy ~ 1girls )`;
  13889.  
  13890. searches.push(exampleSearch);
  13891.  
  13892. for (let i = searches.length - 1; i >= 0; i -= 1) {
  13893. this.saveSearch(searches[i]);
  13894. }
  13895. }, {
  13896. once: true
  13897. });
  13898. }
  13899.  
  13900. /**
  13901. * @returns {Boolean}
  13902. */
  13903. inEditMode() {
  13904. return this.stopEditingButton.style.display !== "none";
  13905. }
  13906.  
  13907. exportSavedSearches() {
  13908. const savedSearchString = Array.from(document.getElementsByClassName("save-search-label")).map(search => search.innerText).join("\n");
  13909.  
  13910. navigator.clipboard.writeText(savedSearchString);
  13911. alert("Copied saved searches to clipboard");
  13912. }
  13913.  
  13914. importSavedSearches() {
  13915. const doesNotHaveSavedSearches = this.savedSearchesList.querySelectorAll("li").length === 0;
  13916.  
  13917. if (doesNotHaveSavedSearches || confirm("Are you sure you want to import saved searches? This will overwrite current saved searches.")) {
  13918. const savedSearches = this.textarea.value.split("\n");
  13919.  
  13920. this.savedSearchesList.innerHTML = "";
  13921.  
  13922. for (let i = savedSearches.length - 1; i >= 0; i -= 1) {
  13923. this.saveSearch(savedSearches[i]);
  13924. }
  13925. this.storeSavedSearches();
  13926. }
  13927. }
  13928.  
  13929. saveSearchResultsAsCustomSearch() {
  13930. const searchResultIds = Array.from(Post.allPosts.values())
  13931. .filter(post => post.matchedByMostRecentSearch)
  13932. .map(post => post.id);
  13933.  
  13934. if (searchResultIds.length === 0) {
  13935. return;
  13936. }
  13937.  
  13938. if (searchResultIds.length > 300) {
  13939. if (!confirm(`Are you sure you want to save ${searchResultIds.length} ids as one search?`)) {
  13940. return;
  13941. }
  13942. }
  13943. const customSearch = `( ${searchResultIds.join(" ~ ")} )`;
  13944.  
  13945. this.saveSearch(customSearch);
  13946. }
  13947. }
  13948.  
  13949. class Caption {
  13950. static captionHTML = `
  13951. <style>
  13952. .caption {
  13953. overflow: hidden;
  13954. pointer-events: none;
  13955. background: rgba(0, 0, 0, .75);
  13956. z-index: 15;
  13957. position: absolute;
  13958. width: 100%;
  13959. height: 100%;
  13960. top: -100%;
  13961. left: 0px;
  13962. top: 0px;
  13963. text-align: left;
  13964. transform: translateX(-100%);
  13965. /* transition: transform .3s cubic-bezier(.26,.28,.2,.82); */
  13966. transition: transform .35s ease;
  13967. padding-top: 0.5ch;
  13968. padding-left: 7px;
  13969.  
  13970. h6 {
  13971. display: block;
  13972. color: white;
  13973. padding-top: 0px;
  13974. }
  13975.  
  13976. li {
  13977. width: fit-content;
  13978. list-style-type: none;
  13979. display: inline-block;
  13980. }
  13981.  
  13982. &.active {
  13983. transform: translateX(0%);
  13984. }
  13985.  
  13986. &.transition-completed {
  13987. .caption-tag {
  13988. pointer-events: all;
  13989. }
  13990. }
  13991. }
  13992.  
  13993. .caption.hide {
  13994. display: none;
  13995. }
  13996.  
  13997. .caption.inactive {
  13998. display: none;
  13999. }
  14000.  
  14001. .caption-tag {
  14002. pointer-events: none;
  14003. color: #6cb0ff;
  14004. word-wrap: break-word;
  14005.  
  14006. &:hover {
  14007. text-decoration-line: underline;
  14008. cursor: pointer;
  14009. }
  14010. }
  14011.  
  14012. .artist-tag {
  14013. color: #f0a0a0;
  14014. }
  14015.  
  14016. .character-tag {
  14017. color: #f0f0a0;
  14018. }
  14019.  
  14020. .copyright-tag {
  14021. color: #EFA1CF;
  14022. }
  14023.  
  14024. .metadata-tag {
  14025. color: #8FD9ED;
  14026. }
  14027.  
  14028. .caption-wrapper {
  14029. pointer-events: none;
  14030. position: absolute !important;
  14031. overflow: hidden;
  14032. top: -1px;
  14033. left: -1px;
  14034. width: 102%;
  14035. height: 102%;
  14036. display: block !important;
  14037. }
  14038. </style>
  14039. `;
  14040. static preferences = {
  14041. visibility: "showCaptions"
  14042. };
  14043. static localStorageKeys = {
  14044. tagCategories: "tagCategories"
  14045. };
  14046. static importantTagCategories = new Set([
  14047. "copyright",
  14048. "character",
  14049. "artist",
  14050. "metadata"
  14051. ]);
  14052. static tagCategoryEncodings = {
  14053. 0: "general",
  14054. 1: "artist",
  14055. 2: "unknown",
  14056. 3: "copyright",
  14057. 4: "character",
  14058. 5: "metadata"
  14059. };
  14060. static template = `
  14061. <ul id="caption-list">
  14062. <li id="caption-id" style="display: block;"><h6>ID</h6></li>
  14063. ${Caption.getCategoryHeaderHTML()}
  14064. </ul>
  14065. `;
  14066. static findCategoriesOnPageChangeCooldown = new Cooldown(3000, true);
  14067. static saveTagCategoriesCooldown = new Cooldown(1000);
  14068. /**
  14069. * @type {Object.<String, Number>}
  14070. */
  14071. static tagCategoryAssociations;
  14072. static settings = {
  14073. tagFetchDelayAfterFinishedLoading: 10,
  14074. tagFetchDelayBeforeFinishedLoading: 100
  14075. };
  14076. static flags = {
  14077. finishedLoading: false
  14078. };
  14079.  
  14080. /**
  14081. * @returns {String}
  14082. */
  14083. static getCategoryHeaderHTML() {
  14084. let html = "";
  14085.  
  14086. for (const category of Caption.importantTagCategories) {
  14087. const capitalizedCategory = Utils.capitalize(category);
  14088. const header = capitalizedCategory === "Metadata" ? "Meta" : capitalizedCategory;
  14089.  
  14090. html += `<li id="caption${capitalizedCategory}" style="display: none;"><h6>${header}</h6></li>`;
  14091. }
  14092. return html;
  14093. }
  14094.  
  14095. /**
  14096. * @param {String} tagCategory
  14097. * @returns {Number}
  14098. */
  14099. static encodeTagCategory(tagCategory) {
  14100. for (const [encoding, category] of Object.entries(Caption.tagCategoryEncodings)) {
  14101. if (category === tagCategory) {
  14102. return parseInt(encoding);
  14103. }
  14104. }
  14105. return 0;
  14106. }
  14107.  
  14108. /**
  14109. * @type {Boolean}
  14110. */
  14111. static get disabled() {
  14112. return !Utils.onFavoritesPage() || Utils.onMobileDevice() || Utils.getPerformanceProfile() > 1;
  14113. }
  14114.  
  14115. /**
  14116. * @type {Boolean}
  14117. */
  14118. get hidden() {
  14119. return this.caption.classList.contains("hide") || this.caption.classList.contains("disabled") || this.caption.classList.contains("remove");
  14120. }
  14121.  
  14122. /**
  14123. * @type {Number}
  14124. */
  14125. static get tagFetchDelay() {
  14126. if (Caption.flags.finishedLoading) {
  14127. return Caption.settings.tagFetchDelayAfterFinishedLoading;
  14128. }
  14129. return Caption.settings.tagFetchDelayBeforeFinishedLoading;
  14130. }
  14131.  
  14132. /**
  14133. * @type {HTMLDivElement}
  14134. */
  14135. captionWrapper;
  14136. /**
  14137. * @type {HTMLDivElement}
  14138. */
  14139. caption;
  14140. /**
  14141. * @type {HTMLElement}
  14142. */
  14143. currentThumb;
  14144. /**
  14145. * @type {Set.<String>}
  14146. */
  14147. problematicTags;
  14148. /**
  14149. * @type {String}
  14150. */
  14151. currentThumbId;
  14152. /**
  14153. * @type {AbortController}
  14154. */
  14155. abortController;
  14156.  
  14157. constructor() {
  14158. if (Caption.disabled) {
  14159. return;
  14160. }
  14161. this.initializeFields();
  14162. this.createHTMLElement();
  14163. this.insertHTML();
  14164. this.toggleVisibility(this.getVisibilityPreference());
  14165. this.addEventListeners();
  14166. }
  14167.  
  14168. initializeFields() {
  14169. Caption.tagCategoryAssociations = this.loadSavedTags();
  14170. Caption.findCategoriesOnPageChangeCooldown.onDebounceEnd = () => {
  14171. this.findTagCategoriesOnPageChange();
  14172. };
  14173. Caption.saveTagCategoriesCooldown.onCooldownEnd = () => {
  14174. this.saveTagCategories();
  14175. };
  14176. this.currentThumb = null;
  14177. this.problematicTags = new Set();
  14178. this.currentThumbId = null;
  14179. this.abortController = new AbortController();
  14180. }
  14181.  
  14182. createHTMLElement() {
  14183. this.captionWrapper = document.createElement("div");
  14184. this.captionWrapper.className = "caption-wrapper";
  14185. this.caption = document.createElement("div");
  14186. this.caption.className = "caption inactive not-highlightable";
  14187. this.captionWrapper.appendChild(this.caption);
  14188. document.head.appendChild(this.captionWrapper);
  14189. this.caption.innerHTML = Caption.template;
  14190. }
  14191.  
  14192. insertHTML() {
  14193. Utils.insertStyleHTML(Caption.captionHTML, "caption");
  14194. Utils.createFavoritesOption(
  14195. "show-captions",
  14196. "Details",
  14197. "Show details when hovering over thumbnail",
  14198. this.getVisibilityPreference(),
  14199. (event) => {
  14200. this.toggleVisibility(event.target.checked);
  14201. },
  14202. true,
  14203. "(D)"
  14204. );
  14205. }
  14206.  
  14207. /**
  14208. * @param {Boolean} value
  14209. */
  14210. toggleVisibility(value) {
  14211. if (value === undefined) {
  14212. value = this.caption.classList.contains("disabled");
  14213. }
  14214.  
  14215. if (value) {
  14216. this.caption.classList.remove("disabled");
  14217. } else if (!this.caption.classList.contains("disabled")) {
  14218. this.caption.classList.add("disabled");
  14219. }
  14220. Utils.setPreference(Caption.preferences.visibility, value);
  14221. }
  14222.  
  14223. addEventListeners() {
  14224. this.addAllPageEventListeners();
  14225. this.addSearchPageEventListeners();
  14226. this.addFavoritesPageEventListeners();
  14227. }
  14228.  
  14229. addAllPageEventListeners() {
  14230. this.caption.addEventListener("transitionend", () => {
  14231. if (this.caption.classList.contains("active")) {
  14232. this.caption.classList.add("transition-completed");
  14233. }
  14234. this.caption.classList.remove("transitioning");
  14235. });
  14236. this.caption.addEventListener("transitionstart", () => {
  14237. this.caption.classList.add("transitioning");
  14238. });
  14239. window.addEventListener("showOriginalContent", (event) => {
  14240. const thumb = this.caption.parentElement;
  14241.  
  14242. if (event.detail) {
  14243. this.removeFromThumb(thumb);
  14244.  
  14245. this.caption.classList.add("hide");
  14246. } else {
  14247. this.caption.classList.remove("hide");
  14248. }
  14249. });
  14250.  
  14251. document.addEventListener("keydown", (event) => {
  14252. if (event.key.toLowerCase() !== "d" || !Utils.isHotkeyEvent(event)) {
  14253. return;
  14254. }
  14255.  
  14256. if (Utils.onFavoritesPage()) {
  14257. const showCaptionsCheckbox = document.getElementById("show-captions-checkbox");
  14258.  
  14259. if (showCaptionsCheckbox !== null) {
  14260. showCaptionsCheckbox.click();
  14261.  
  14262. if (this.currentThumb !== null && !this.caption.classList.contains("remove")) {
  14263. if (showCaptionsCheckbox.checked) {
  14264. this.attachToThumbHelper(this.currentThumb);
  14265. } else {
  14266. this.removeFromThumbHelper(this.currentThumb);
  14267. }
  14268. }
  14269. }
  14270. } else if (Utils.onSearchPage()) {
  14271. // this.toggleVisibility();
  14272. }
  14273. }, {
  14274. passive: true
  14275. });
  14276. }
  14277.  
  14278. addSearchPageEventListeners() {
  14279. if (!Utils.onSearchPage()) {
  14280. return;
  14281. }
  14282. window.addEventListener("load", () => {
  14283. this.addEventListenersToThumbs.bind(this)();
  14284. }, {
  14285. once: true,
  14286. passive: true
  14287. });
  14288. }
  14289.  
  14290. addFavoritesPageEventListeners() {
  14291. window.addEventListener("favoritesLoaded", () => {
  14292. this.addEventListenersToThumbs.bind(this)();
  14293. Caption.flags.finishedLoading = true;
  14294. Caption.findCategoriesOnPageChangeCooldown.waitTime = 1000;
  14295. }, {
  14296. once: true
  14297. });
  14298. window.addEventListener("favoritesLoadedFromDatabase", () => {
  14299. this.findTagCategoriesOnPageChange();
  14300. }, {
  14301. once: true
  14302. });
  14303. window.addEventListener("favoritesFetched", () => {
  14304. this.addEventListenersToThumbs.bind(this)();
  14305. });
  14306. window.addEventListener("changedPage", () => {
  14307. this.addEventListenersToThumbs.bind(this)();
  14308. this.abortController.abort("ChangedPage");
  14309. this.abortController = new AbortController();
  14310.  
  14311. if (Caption.findCategoriesOnPageChangeCooldown.ready) {
  14312. setTimeout(() => {
  14313. this.findTagCategoriesOnPageChange();
  14314. }, 100);
  14315. }
  14316. });
  14317. window.addEventListener("originalFavoritesCleared", (event) => {
  14318. const thumbs = event.detail;
  14319. const tagNames = Array.from(thumbs)
  14320. .map(thumb => Utils.getImageFromThumb(thumb).title)
  14321. .join(" ")
  14322. .split(" ")
  14323. .filter(tagName => !Utils.isNumber(tagName) && Caption.tagCategoryAssociations[tagName] === undefined);
  14324.  
  14325. this.findTagCategories(tagNames, () => {
  14326. Caption.saveTagCategoriesCooldown.restart();
  14327. });
  14328. }, {
  14329. once: true
  14330. });
  14331. window.addEventListener("newFavoritesFetchedOnReload", (event) => {
  14332. if (!event.detail.empty) {
  14333. this.addEventListenersToThumbs.bind(this)(event.detail.thumbs);
  14334. }
  14335. }, {
  14336. once: true
  14337. });
  14338. window.addEventListener("captionOverrideEnd", () => {
  14339. if (this.currentThumb !== null) {
  14340. this.attachToThumb(this.currentThumb);
  14341. }
  14342. });
  14343. }
  14344.  
  14345. /**
  14346. * @param {HTMLElement[]} thumbs
  14347. */
  14348. async addEventListenersToThumbs(thumbs) {
  14349. await Utils.sleep(500);
  14350. thumbs = thumbs === undefined ? Utils.getAllThumbs() : thumbs;
  14351.  
  14352. for (const thumb of thumbs) {
  14353. const imageContainer = Utils.getImageFromThumb(thumb).parentElement;
  14354.  
  14355. if (imageContainer.onmouseenter !== null) {
  14356. continue;
  14357. }
  14358. imageContainer.onmouseenter = () => {
  14359. this.currentThumb = thumb;
  14360. this.attachToThumb(thumb);
  14361. };
  14362.  
  14363. imageContainer.onmouseleave = () => {
  14364. this.currentThumb = null;
  14365. this.removeFromThumb(thumb);
  14366. };
  14367. }
  14368. }
  14369.  
  14370. /**
  14371. * @param {HTMLElement} thumb
  14372. */
  14373. attachToThumb(thumb) {
  14374. if (this.hidden || thumb === null) {
  14375. return;
  14376. }
  14377. this.attachToThumbHelper(thumb);
  14378. }
  14379.  
  14380. attachToThumbHelper(thumb) {
  14381. thumb.querySelectorAll(".caption-wrapper-clone").forEach(element => element.remove());
  14382. this.caption.classList.remove("inactive");
  14383. this.caption.innerHTML = Caption.template;
  14384. this.captionWrapper.removeAttribute("style");
  14385. const captionIdHeader = this.caption.querySelector("#caption-id");
  14386. const captionIdTag = document.createElement("li");
  14387.  
  14388. captionIdTag.className = "caption-tag";
  14389. captionIdTag.textContent = thumb.id;
  14390. captionIdTag.onclick = (event) => {
  14391. event.preventDefault();
  14392. event.stopPropagation();
  14393. };
  14394. captionIdTag.addEventListener("contextmenu", (event) => {
  14395. event.preventDefault();
  14396. event.stopPropagation();
  14397. });
  14398.  
  14399. captionIdTag.onmousedown = (event) => {
  14400. event.preventDefault();
  14401. event.stopPropagation();
  14402. this.tagOnClick(thumb.id, event);
  14403. };
  14404. captionIdHeader.insertAdjacentElement("afterend", captionIdTag);
  14405. thumb.children[0].appendChild(this.captionWrapper);
  14406. this.populateTags(thumb);
  14407. }
  14408.  
  14409. /**
  14410. * @param {HTMLElement} thumb
  14411. */
  14412. removeFromThumb(thumb) {
  14413. if (this.hidden) {
  14414. return;
  14415. }
  14416.  
  14417. this.removeFromThumbHelper(thumb);
  14418. }
  14419.  
  14420. /**
  14421. * @param {HTMLElement} thumb
  14422. */
  14423. removeFromThumbHelper(thumb) {
  14424. if (thumb !== null && thumb !== undefined) {
  14425. this.animateRemoval(thumb);
  14426. }
  14427. this.animate(false);
  14428. this.caption.classList.add("inactive");
  14429. this.caption.classList.remove("transition-completed");
  14430. }
  14431.  
  14432. /**
  14433. * @param {HTMLElement} thumb
  14434. */
  14435. animateRemoval(thumb) {
  14436. const captionWrapperClone = this.captionWrapper.cloneNode(true);
  14437. const captionClone = captionWrapperClone.children[0];
  14438.  
  14439. thumb.querySelectorAll(".caption-wrapper-clone").forEach(element => element.remove());
  14440. captionWrapperClone.classList.add("caption-wrapper-clone");
  14441. captionWrapperClone.querySelectorAll("*").forEach(element => element.removeAttribute("id"));
  14442. captionClone.ontransitionend = () => {
  14443. captionWrapperClone.remove();
  14444. };
  14445. thumb.children[0].appendChild(captionWrapperClone);
  14446. setTimeout(() => {
  14447. captionClone.classList.remove("active");
  14448. }, 4);
  14449. }
  14450.  
  14451. /**
  14452. * @param {HTMLElement} thumb
  14453. */
  14454. resizeFont(thumb) {
  14455. const columnInput = document.getElementById("column-resize-input");
  14456. const heightCanBeDerivedWithoutRect = this.thumbMetadataExists(thumb) && columnInput !== null;
  14457. let height;
  14458.  
  14459. if (heightCanBeDerivedWithoutRect) {
  14460. height = this.estimateThumbHeightFromMetadata(thumb, columnInput);
  14461. } else {
  14462. height = Utils.getImageFromThumb(thumb).getBoundingClientRect().height;
  14463. }
  14464. const captionListRect = this.caption.children[0].getBoundingClientRect();
  14465. const ratio = height / captionListRect.height;
  14466. const scale = ratio > 1 ? Math.sqrt(ratio) : ratio * 0.85;
  14467.  
  14468. this.caption.parentElement.style.fontSize = `${Utils.roundToTwoDecimalPlaces(scale)}em`;
  14469. }
  14470.  
  14471. /**
  14472. * @param {HTMLElement} thumb
  14473. * @returns {Boolean}
  14474. */
  14475. thumbMetadataExists(thumb) {
  14476. if (Utils.onSearchPage()) {
  14477. return false;
  14478. }
  14479. const post = Post.allPosts.get(thumb.id);
  14480.  
  14481. if (post === undefined) {
  14482. return false;
  14483. }
  14484.  
  14485. if (post.metadata === undefined) {
  14486. return false;
  14487. }
  14488.  
  14489. if (post.metadata.width <= 0 || post.metadata.width <= 0) {
  14490. return false;
  14491. }
  14492. return true;
  14493. }
  14494.  
  14495. /**
  14496. * @param {HTMLElement} thumb
  14497. * @param {HTMLInputElement} columnInput
  14498. * @returns {Number}
  14499. */
  14500. estimateThumbHeightFromMetadata(thumb, columnInput) {
  14501. const post = Post.allPosts.get(thumb.id);
  14502. const gridGap = 16;
  14503. const columnCount = Math.max(1, parseInt(columnInput.value));
  14504. const thumbWidthEstimate = (window.innerWidth - (columnCount * gridGap)) / columnCount;
  14505. const thumbWidthScale = post.metadata.width / thumbWidthEstimate;
  14506. return post.metadata.height / thumbWidthScale;
  14507. }
  14508.  
  14509. /**
  14510. * @param {String} tagCategory
  14511. * @param {String} tagName
  14512. */
  14513. addTag(tagCategory, tagName) {
  14514. if (!Caption.importantTagCategories.has(tagCategory)) {
  14515. return;
  14516. }
  14517. const header = document.getElementById(this.getCategoryHeaderId(tagCategory));
  14518. const tag = document.createElement("li");
  14519.  
  14520. tag.className = `${tagCategory}-tag caption-tag`;
  14521. tag.textContent = this.replaceUnderscoresWithSpaces(tagName);
  14522. header.insertAdjacentElement("afterend", tag);
  14523. header.style.display = "block";
  14524. tag.onmouseover = (event) => {
  14525. event.stopPropagation();
  14526. };
  14527. tag.onclick = (event) => {
  14528. event.stopPropagation();
  14529. event.preventDefault();
  14530. };
  14531. tag.addEventListener("contextmenu", (event) => {
  14532. event.preventDefault();
  14533. event.stopPropagation();
  14534. });
  14535. tag.onmousedown = (event) => {
  14536. event.preventDefault();
  14537. event.stopPropagation();
  14538. this.tagOnClick(tagName, event);
  14539. };
  14540. }
  14541.  
  14542. /**
  14543. * @returns {Object.<String, Number>}
  14544. */
  14545. loadSavedTags() {
  14546. return JSON.parse(localStorage.getItem(Caption.localStorageKeys.tagCategories) || "{}");
  14547. }
  14548.  
  14549. saveTagCategories() {
  14550. localStorage.setItem(Caption.localStorageKeys.tagCategories, JSON.stringify(Caption.tagCategoryAssociations));
  14551. }
  14552.  
  14553. /**
  14554. * @param {String} tagName
  14555. * @param {MouseEvent} event
  14556. */
  14557. tagOnClick(tagName, event) {
  14558. switch (event.button) {
  14559. case Utils.clickCodes.left:
  14560. if (event.shiftKey) {
  14561. this.searchForTag(tagName);
  14562. } else {
  14563. this.tagOnClickHelper(tagName, event);
  14564. }
  14565. break;
  14566.  
  14567. case Utils.clickCodes.middle:
  14568. this.searchForTag(tagName);
  14569. break;
  14570.  
  14571. case Utils.clickCodes.right:
  14572. this.tagOnClickHelper(`-${tagName}`, event);
  14573. break;
  14574.  
  14575. default:
  14576. break;
  14577. }
  14578. }
  14579.  
  14580. /**
  14581. * @param {String} tagName
  14582. */
  14583. searchForTag(tagName) {
  14584. dispatchEvent(new CustomEvent("searchForTag", {
  14585. detail: tagName
  14586. }));
  14587. }
  14588.  
  14589. /**
  14590. * @param {String} value
  14591. * @param {MouseEvent} mouseEvent
  14592. */
  14593. tagOnClickHelper(value, mouseEvent) {
  14594. if (mouseEvent.ctrlKey) {
  14595. Utils.openSearchPage(value);
  14596. return;
  14597. }
  14598. const searchBox = Utils.onSearchPage() ? document.getElementsByName("tags")[0] : document.getElementById("favorites-search-box");
  14599. const searchBoxDoesNotIncludeTag = true;
  14600.  
  14601. navigator.clipboard.writeText(value);
  14602.  
  14603. if (searchBoxDoesNotIncludeTag) {
  14604. searchBox.value += ` ${value}`;
  14605. searchBox.focus();
  14606. value = searchBox.value;
  14607. searchBox.value = "";
  14608. searchBox.value = value;
  14609. }
  14610. }
  14611.  
  14612. /**
  14613. * @param {String} tagName
  14614. * @returns {String}
  14615. */
  14616. replaceUnderscoresWithSpaces(tagName) {
  14617. return tagName.replaceAll(/_/gm, " ");
  14618. }
  14619.  
  14620. /**
  14621. * @param {String} tagName
  14622. * @returns {String}
  14623. */
  14624. replaceSpacesWithUnderscores(tagName) {
  14625. return tagName.replaceAll(/\s/gm, "_");
  14626. }
  14627.  
  14628. /**
  14629. * @returns {Boolean}
  14630. */
  14631. getVisibilityPreference() {
  14632. return Utils.getPreference(Caption.preferences.visibility, true);
  14633. }
  14634.  
  14635. /**
  14636. * @param {Boolean} value
  14637. */
  14638. animate(value) {
  14639. this.caption.classList.toggle("active", value);
  14640. }
  14641.  
  14642. /**
  14643. * @param {String} tagCategory
  14644. * @returns {String}
  14645. */
  14646. getCategoryHeaderId(tagCategory) {
  14647. return `caption${Utils.capitalize(tagCategory)}`;
  14648. }
  14649.  
  14650. /**
  14651. * @param {HTMLElement} thumb
  14652. */
  14653. populateTags(thumb) {
  14654. const tagNames = Utils.getTagsFromThumb(thumb);
  14655.  
  14656. tagNames.delete(thumb.id);
  14657. const unknownThumbTags = Array.from(tagNames)
  14658. .filter(tagName => this.tagCategoryIsUnknown(thumb, tagName));
  14659.  
  14660. this.currentThumbId = thumb.id;
  14661.  
  14662. if (this.allTagsAreProblematic(unknownThumbTags)) {
  14663. this.correctAllProblematicTagsFromThumb(thumb, () => {
  14664. this.addTags(tagNames, thumb);
  14665. });
  14666. return;
  14667. }
  14668.  
  14669. if (unknownThumbTags.length > 0) {
  14670. this.findTagCategories(unknownThumbTags, () => {
  14671. this.addTags(tagNames, thumb);
  14672. }, 3);
  14673. return;
  14674. }
  14675. this.addTags(tagNames, thumb);
  14676. }
  14677.  
  14678. /**
  14679. * @param {String[]} tagNames
  14680. * @param {HTMLElement} thumb
  14681. */
  14682. addTags(tagNames, thumb) {
  14683. Caption.saveTagCategoriesCooldown.restart();
  14684.  
  14685. if (this.currentThumbId !== thumb.id) {
  14686. return;
  14687. }
  14688.  
  14689. if (thumb.getElementsByClassName("caption-tag").length > 1) {
  14690. return;
  14691. }
  14692.  
  14693. for (const tagName of tagNames) {
  14694. const category = this.getTagCategory(tagName);
  14695.  
  14696. this.addTag(category, tagName);
  14697. }
  14698. this.resizeFont(thumb);
  14699. this.animate(true);
  14700. }
  14701.  
  14702. /**
  14703. * @param {String} tagName
  14704. * @returns {String}
  14705. */
  14706. getTagCategory(tagName) {
  14707. const encoding = Caption.tagCategoryAssociations[tagName];
  14708.  
  14709. if (encoding === undefined) {
  14710. return "general";
  14711. }
  14712. return Caption.tagCategoryEncodings[encoding];
  14713. }
  14714.  
  14715. /**
  14716. * @param {String[]} tags
  14717. * @returns {Boolean}
  14718. */
  14719. allTagsAreProblematic(tags) {
  14720. for (const tag of tags) {
  14721. if (!this.problematicTags.has(tag)) {
  14722. return false;
  14723. }
  14724. }
  14725. return tags.length > 0;
  14726. }
  14727.  
  14728. /**
  14729. * @param {HTMLElement} thumb
  14730. * @param {Function} onProblematicTagsCorrected
  14731. */
  14732. correctAllProblematicTagsFromThumb(thumb, onProblematicTagsCorrected) {
  14733. fetch(Utils.getPostPageURL(thumb.id))
  14734. .then((response) => {
  14735. return response.text();
  14736. })
  14737. .then((html) => {
  14738. const tagCategoryMap = this.getTagCategoryMapFromPostPage(html);
  14739.  
  14740. for (const [tagName, tagCategory] of tagCategoryMap.entries()) {
  14741. Caption.tagCategoryAssociations[tagName] = Caption.encodeTagCategory(tagCategory);
  14742. this.problematicTags.delete(tagName);
  14743. }
  14744. onProblematicTagsCorrected();
  14745. })
  14746. .catch((error) => {
  14747. console.error(error);
  14748. });
  14749. }
  14750.  
  14751. /**
  14752. * @param {String} html
  14753. * @returns {Map.<String, String>}
  14754. */
  14755. getTagCategoryMapFromPostPage(html) {
  14756. const dom = new DOMParser().parseFromString(html, "text/html");
  14757. return Array.from(dom.querySelectorAll(".tag"))
  14758. .reduce((map, element) => {
  14759. const tagCategory = element.classList[0].replace("tag-type-", "");
  14760. const tagName = this.replaceSpacesWithUnderscores(element.children[1].textContent);
  14761.  
  14762. map.set(tagName, tagCategory);
  14763. return map;
  14764. }, new Map());
  14765. }
  14766.  
  14767. /**
  14768. * @param {String} tag
  14769. */
  14770. setAsProblematic(tag) {
  14771. if (Caption.tagCategoryAssociations[tag] === undefined && !Utils.customTags.has(tag)) {
  14772. this.problematicTags.add(tag);
  14773. }
  14774. }
  14775.  
  14776. findTagCategoriesOnPageChange() {
  14777. const tagNames = this.getTagNamesWithUnknownCategories(Utils.getAllThumbs().slice(0, 200));
  14778.  
  14779. this.findTagCategories(tagNames, () => {
  14780. Caption.saveTagCategoriesCooldown.restart();
  14781. });
  14782. }
  14783.  
  14784. /**
  14785. * @param {String[]} tagNames
  14786. * @param {Function} onAllCategoriesFound
  14787. * @param {Number} fetchDelay
  14788. */
  14789. async findTagCategories(tagNames, onAllCategoriesFound, fetchDelay) {
  14790. const parser = new DOMParser();
  14791. const lastTagName = tagNames[tagNames.length - 1];
  14792. const uniqueTagNames = new Set(tagNames);
  14793.  
  14794. for (const tagName of uniqueTagNames) {
  14795. if (Utils.isNumber(tagName) && tagName.length > 5) {
  14796. Caption.tagCategoryAssociations[tagName] = 0;
  14797. continue;
  14798. }
  14799.  
  14800. if (tagName.includes("'")) {
  14801. this.setAsProblematic(tagName);
  14802. }
  14803.  
  14804. if (this.problematicTags.has(tagName)) {
  14805. if (tagName === lastTagName) {
  14806. onAllCategoriesFound();
  14807. }
  14808. continue;
  14809. }
  14810.  
  14811. const apiURL = `https://api.rule34.xxx//index.php?page=dapi&s=tag&q=index&name=${encodeURIComponent(tagName)}`;
  14812.  
  14813. try {
  14814. fetch(apiURL, {
  14815. signal: this.abortController.signal
  14816. })
  14817. .then((response) => {
  14818. if (response.ok) {
  14819. return response.text();
  14820. }
  14821. throw new Error(response.statusText);
  14822. })
  14823. .then((html) => {
  14824. const dom = parser.parseFromString(html, "text/html");
  14825. const encoding = dom.getElementsByTagName("tag")[0].getAttribute("type");
  14826.  
  14827. if (encoding === "array") {
  14828. this.setAsProblematic(tagName);
  14829. return;
  14830. }
  14831. Caption.tagCategoryAssociations[tagName] = parseInt(encoding);
  14832.  
  14833. if (tagName === lastTagName) {
  14834. onAllCategoriesFound();
  14835. }
  14836. }).catch(() => {
  14837. onAllCategoriesFound();
  14838. });
  14839. } catch (error) {
  14840. console.error(error);
  14841. }
  14842. await Utils.sleep(fetchDelay || Caption.tagFetchDelay);
  14843. }
  14844. }
  14845.  
  14846. /**
  14847. * @param {HTMLElement[]} thumbs
  14848. * @returns {String[]}
  14849. */
  14850. getTagNamesWithUnknownCategories(thumbs) {
  14851. const tagNamesWithUnknownCategories = new Set();
  14852.  
  14853. for (const thumb of thumbs) {
  14854. const tagNames = Array.from(Utils.getTagsFromThumb(thumb));
  14855.  
  14856. for (const tagName of tagNames) {
  14857. if (this.tagCategoryIsUnknown(thumb, tagName)) {
  14858. tagNamesWithUnknownCategories.add(tagName);
  14859. }
  14860. }
  14861. }
  14862. return Array.from(tagNamesWithUnknownCategories);
  14863. }
  14864.  
  14865. /**
  14866. * @param {HTMLElement} thumb
  14867. * @param {String} tagName
  14868. * @returns
  14869. */
  14870. tagCategoryIsUnknown(thumb, tagName) {
  14871. return tagName !== thumb.id && Caption.tagCategoryAssociations[tagName] === undefined && !Utils.customTags.has(tagName);
  14872. }
  14873. }
  14874.  
  14875. class TagModifier {
  14876. static tagModifierHTML = `
  14877. <div id="tag-modifier-container">
  14878. <style>
  14879. #tag-modifier-ui-container {
  14880. display: none;
  14881.  
  14882. >* {
  14883. margin-top: 10px;
  14884. }
  14885. }
  14886.  
  14887. #tag-modifier-ui-textarea {
  14888. width: 80%;
  14889. }
  14890.  
  14891. .favorite.tag-modifier-selected {
  14892. outline: 2px dashed white !important;
  14893.  
  14894. >div, >a {
  14895. opacity: 1;
  14896. filter: grayscale(0%);
  14897. }
  14898. }
  14899.  
  14900. #tag-modifier-ui-status-label {
  14901. visibility: hidden;
  14902. }
  14903.  
  14904. .tag-type-custom>a,
  14905. .tag-type-custom {
  14906. color: hotpink;
  14907. }
  14908. </style>
  14909. <div id="tag-modifier-option-container">
  14910. <label class="checkbox" title="Add or remove custom or official tags to favorites">
  14911. <input type="checkbox" id="tag-modifier-option-checkbox">Modify Tags<span class="option-hint"></span>
  14912. </label>
  14913. </div>
  14914. <div id="tag-modifier-ui-container">
  14915. <label id="tag-modifier-ui-status-label">No Status</label>
  14916. <textarea id="tag-modifier-ui-textarea" placeholder="tags" spellcheck="false"></textarea>
  14917. <div id="tag-modifier-buttons">
  14918. <span id="tag-modifier-ui-modification-buttons">
  14919. <button id="tag-modifier-ui-add" title="Add tags to selected favorites">Add</button>
  14920. <button id="tag-modifier-remove" title="Remove tags from selected favorites">Remove</button>
  14921. </span>
  14922. <span id="tag-modifier-ui-selection-buttons">
  14923. <button id="tag-modifier-ui-select-all" title="Select all favorites for tag modification">Select all</button>
  14924. <button id="tag-modifier-ui-un-select-all" title="Unselect all favorites for tag modification">Unselect
  14925. all</button>
  14926. </span>
  14927. </div>
  14928. <div id="tag-modifier-ui-reset-button-container">
  14929. <button id="tag-modifier-reset" title="Reset tag modifications">Reset</button>
  14930. </div>
  14931. <div id="tag-modifier-ui-configuration" style="display: none;">
  14932. <button id="tag-modifier-import" title="Import modified tags">Import</button>
  14933. <button id="tag-modifier-export" title="Export modified tags">Export</button>
  14934. </div>
  14935. </div>
  14936. </div>
  14937. `;
  14938. /**
  14939. * @type {String}
  14940. */
  14941. static databaseName = "AdditionalTags";
  14942. /**
  14943. * @type {String}
  14944. */
  14945. static objectStoreName = "additionalTags";
  14946. /**
  14947. * @type {Map.<String, String>}
  14948. */
  14949. static tagModifications = new Map();
  14950. static preferences = {
  14951. modifyTagsOutsideFavoritesPage: "modifyTagsOutsideFavoritesPage"
  14952. };
  14953.  
  14954. /**
  14955. * @type {Boolean}
  14956. */
  14957. static get currentlyModifyingTags() {
  14958. return document.getElementById("tag-edit-mode") !== null;
  14959. }
  14960.  
  14961. /**
  14962. * @type {Boolean}
  14963. */
  14964. static get disabled() {
  14965. if (Utils.onMobileDevice()) {
  14966. return true;
  14967. }
  14968.  
  14969. if (Utils.onFavoritesPage()) {
  14970. return false;
  14971. }
  14972. return Utils.getPreference(TagModifier.preferences.modifyTagsOutsideFavoritesPage, false);
  14973. }
  14974.  
  14975. /**
  14976. * @type {AbortController}
  14977. */
  14978. tagEditModeAbortController;
  14979. /**
  14980. * @type {{container: HTMLDivElement, checkbox: HTMLInputElement}}
  14981. */
  14982. favoritesOption;
  14983. /**
  14984. * @type { {container: HTMLDivElement,
  14985. * textarea: HTMLTextAreaElement,
  14986. * statusLabel: HTMLLabelElement,
  14987. * add: HTMLButtonElement,
  14988. * remove: HTMLButtonElement,
  14989. * reset: HTMLButtonElement,
  14990. * selectAll: HTMLButtonElement,
  14991. * unSelectAll: HTMLButtonElement,
  14992. * import: HTMLButtonElement,
  14993. * export: HTMLButtonElement}}
  14994. */
  14995. favoritesUI;
  14996. /**
  14997. * @type {Post[]}
  14998. */
  14999. selectedPosts;
  15000. /**
  15001. * @type {Boolean}
  15002. */
  15003. atLeastOneFavoriteIsSelected;
  15004.  
  15005. constructor() {
  15006. if (TagModifier.disabled) {
  15007. return;
  15008. }
  15009. this.tagEditModeAbortController = new AbortController();
  15010. this.favoritesOption = {};
  15011. this.favoritesUI = {};
  15012. this.selectedPosts = [];
  15013. this.atLeastOneFavoriteIsSelected = false;
  15014. this.loadTagModifications();
  15015. this.insertHTML();
  15016. this.addEventListeners();
  15017. }
  15018.  
  15019. insertHTML() {
  15020. this.insertFavoritesPageHTML();
  15021. this.insertSearchPageHTML();
  15022. this.insertPostPageHTML();
  15023. }
  15024.  
  15025. insertFavoritesPageHTML() {
  15026. if (!Utils.onFavoritesPage()) {
  15027. return;
  15028. }
  15029. Utils.insertHTMLAndExtractStyle(document.getElementById("bottom-panel-4"), "beforeend", TagModifier.tagModifierHTML);
  15030. this.favoritesOption.container = document.getElementById("tag-modifier-container");
  15031. this.favoritesOption.checkbox = document.getElementById("tag-modifier-option-checkbox");
  15032. this.favoritesUI.container = document.getElementById("tag-modifier-ui-container");
  15033. this.favoritesUI.statusLabel = document.getElementById("tag-modifier-ui-status-label");
  15034. this.favoritesUI.textarea = document.getElementById("tag-modifier-ui-textarea");
  15035. this.favoritesUI.add = document.getElementById("tag-modifier-ui-add");
  15036. this.favoritesUI.remove = document.getElementById("tag-modifier-remove");
  15037. this.favoritesUI.reset = document.getElementById("tag-modifier-reset");
  15038. this.favoritesUI.selectAll = document.getElementById("tag-modifier-ui-select-all");
  15039. this.favoritesUI.unSelectAll = document.getElementById("tag-modifier-ui-un-select-all");
  15040. this.favoritesUI.import = document.getElementById("tag-modifier-import");
  15041. this.favoritesUI.export = document.getElementById("tag-modifier-export");
  15042. }
  15043.  
  15044. insertSearchPageHTML() {
  15045. if (!Utils.onSearchPage()) {
  15046. return;
  15047. }
  15048. 1;
  15049. }
  15050.  
  15051. insertPostPageHTML() {
  15052. if (!Utils.onPostPage()) {
  15053. return;
  15054. }
  15055. const contentContainer = document.querySelector(".flexi");
  15056. const originalAddToFavoritesLink = Array.from(document.querySelectorAll("a")).find(a => a.textContent === "Add to favorites");
  15057.  
  15058. const html = `
  15059. <div style="margin-bottom: 1em;">
  15060. <h4 class="image-sublinks">
  15061. <a href="#" id="add-to-favorites">Add to favorites</a>
  15062. |
  15063. <a href="#" id="add-custom-tags">Add custom tag</a>
  15064. <select id="custom-tags-list"></select>
  15065. </h4>
  15066. </div>
  15067. `;
  15068.  
  15069. if (contentContainer === null || originalAddToFavoritesLink === undefined) {
  15070. return;
  15071. }
  15072. contentContainer.insertAdjacentHTML("beforebegin", html);
  15073.  
  15074. const addToFavorites = document.getElementById("add-to-favorites");
  15075. const addCustomTags = document.getElementById("add-custom-tags");
  15076. const customTagsList = document.getElementById("custom-tags-list");
  15077.  
  15078. for (const customTag of Utils.customTags.values()) {
  15079. const option = document.createElement("option");
  15080.  
  15081. option.value = customTag;
  15082. option.textContent = customTag;
  15083. customTagsList.appendChild(option);
  15084. }
  15085. addToFavorites.onclick = () => {
  15086. originalAddToFavoritesLink.click();
  15087. return false;
  15088. };
  15089.  
  15090. addCustomTags.onclick = () => {
  15091. return false;
  15092. };
  15093. }
  15094.  
  15095. addEventListeners() {
  15096. this.addFavoritesPageEventListeners();
  15097. this.addSearchPageEventListeners();
  15098. this.addPostPageEventListeners();
  15099. }
  15100.  
  15101. addFavoritesPageEventListeners() {
  15102. if (!Utils.onFavoritesPage()) {
  15103. return;
  15104. }
  15105. this.favoritesOption.checkbox.onchange = (event) => {
  15106. this.toggleTagEditMode(event.target.checked);
  15107. };
  15108. this.favoritesUI.selectAll.onclick = this.selectAll.bind(this);
  15109. this.favoritesUI.unSelectAll.onclick = this.unSelectAll.bind(this);
  15110. this.favoritesUI.add.onclick = this.addTagsToSelected.bind(this);
  15111. this.favoritesUI.remove.onclick = this.removeTagsFromSelected.bind(this);
  15112. this.favoritesUI.reset.onclick = this.resetTagModifications.bind(this);
  15113. this.favoritesUI.import.onclick = this.importTagModifications.bind(this);
  15114. this.favoritesUI.export.onclick = this.exportTagModifications.bind(this);
  15115. window.addEventListener("searchStarted", () => {
  15116. this.unSelectAll();
  15117. });
  15118. window.addEventListener("changedPage", () => {
  15119. this.highlightSelectedThumbsOnPageChange();
  15120. });
  15121. }
  15122.  
  15123. addSearchPageEventListeners() {
  15124. if (!Utils.onSearchPage()) {
  15125. return;
  15126. }
  15127. 1;
  15128. }
  15129.  
  15130. addPostPageEventListeners() {
  15131. if (!Utils.onPostPage()) {
  15132. return;
  15133. }
  15134. 1;
  15135. }
  15136.  
  15137. highlightSelectedThumbsOnPageChange() {
  15138. if (!this.atLeastOneFavoriteIsSelected) {
  15139. return;
  15140. }
  15141. const posts = Utils.getAllThumbs()
  15142. .map(thumb => Post.allPosts.get(thumb.id));
  15143.  
  15144. for (const post of posts) {
  15145. if (post === undefined) {
  15146. return;
  15147. }
  15148.  
  15149. if (this.isSelectedForModification(post)) {
  15150. this.highlightPost(post, true);
  15151. }
  15152. }
  15153. }
  15154.  
  15155. /**
  15156. * @param {Boolean} value
  15157. */
  15158. toggleTagEditMode(value) {
  15159. this.toggleThumbInteraction(value);
  15160. this.toggleUI(value);
  15161. this.toggleTagEditModeEventListeners(value);
  15162. this.favoritesUI.unSelectAll.click();
  15163. }
  15164.  
  15165. /**
  15166. * @param {Boolean} value
  15167. */
  15168. toggleThumbInteraction(value) {
  15169. let html = "";
  15170.  
  15171. if (value) {
  15172. html =
  15173. `
  15174. .favorite {
  15175. cursor: pointer;
  15176. outline: 1px solid black;
  15177.  
  15178. > div,
  15179. >a
  15180. {
  15181. outline: none !important;
  15182.  
  15183. > img {
  15184. outline: none !important;
  15185. }
  15186.  
  15187. pointer-events:none;
  15188. opacity: 0.6;
  15189. filter: grayscale(40%);
  15190. transition: none !important;
  15191. }
  15192. }
  15193. `;
  15194. }
  15195. Utils.insertStyleHTML(html, "tag-edit-mode");
  15196. }
  15197.  
  15198. /**
  15199. * @param {Boolean} value
  15200. */
  15201. toggleUI(value) {
  15202. this.favoritesUI.container.style.display = value ? "block" : "none";
  15203. }
  15204.  
  15205. /**
  15206. * @param {Boolean} value
  15207. */
  15208. toggleTagEditModeEventListeners(value) {
  15209. if (!value) {
  15210. this.tagEditModeAbortController.abort();
  15211. this.tagEditModeAbortController = new AbortController();
  15212. return;
  15213. }
  15214.  
  15215. document.addEventListener("click", (event) => {
  15216. if (!event.target.classList.contains(Utils.favoriteItemClassName)) {
  15217. return;
  15218. }
  15219. const post = Post.allPosts.get(event.target.id);
  15220.  
  15221. if (post !== undefined) {
  15222. this.toggleThumbSelection(post);
  15223. }
  15224. }, {
  15225. signal: this.tagEditModeAbortController.signal
  15226. });
  15227.  
  15228. }
  15229.  
  15230. /**
  15231. * @param {String} text
  15232. */
  15233. showStatus(text) {
  15234. this.favoritesUI.statusLabel.style.visibility = "visible";
  15235. this.favoritesUI.statusLabel.textContent = text;
  15236. setTimeout(() => {
  15237. const statusHasNotChanged = this.favoritesUI.statusLabel.textContent === text;
  15238.  
  15239. if (statusHasNotChanged) {
  15240. this.favoritesUI.statusLabel.style.visibility = "hidden";
  15241. }
  15242. }, 1000);
  15243. }
  15244.  
  15245. unSelectAll() {
  15246. if (!this.atLeastOneFavoriteIsSelected) {
  15247. return;
  15248. }
  15249.  
  15250. for (const post of Post.allPosts.values()) {
  15251. this.toggleThumbSelection(post, false);
  15252. }
  15253. this.atLeastOneFavoriteIsSelected = false;
  15254. }
  15255.  
  15256. selectAll() {
  15257. for (const post of Post.postsMatchedBySearch.values()) {
  15258. this.toggleThumbSelection(post, true);
  15259. }
  15260. }
  15261.  
  15262. /**
  15263. * @param {Post} post
  15264. * @param {Boolean} value
  15265. */
  15266. toggleThumbSelection(post, value) {
  15267. this.atLeastOneFavoriteIsSelected = true;
  15268.  
  15269. if (value === undefined) {
  15270. value = !this.isSelectedForModification(post);
  15271. }
  15272. post.selectedForTagModification = value ? true : undefined;
  15273. this.highlightPost(post, value);
  15274. }
  15275.  
  15276. /**
  15277. * @param {Post} post
  15278. * @param {Boolean} value
  15279. */
  15280. highlightPost(post, value) {
  15281. if (post.root !== undefined) {
  15282. post.root.classList.toggle("tag-modifier-selected", value);
  15283. }
  15284. }
  15285.  
  15286. /**
  15287. * @param {Post} post
  15288. * @returns {Boolean}
  15289. */
  15290. isSelectedForModification(post) {
  15291. return post.selectedForTagModification !== undefined;
  15292. }
  15293.  
  15294. /**
  15295. * @param {String} tags
  15296. * @returns
  15297. */
  15298. removeContentTypeTags(tags) {
  15299. return tags
  15300. .replace(/(?:^|\s*)(?:video|animated|mp4)(?:$|\s*)/g, "");
  15301. }
  15302.  
  15303. addTagsToSelected() {
  15304. this.modifyTagsOfSelected(false);
  15305. }
  15306.  
  15307. removeTagsFromSelected() {
  15308. this.modifyTagsOfSelected(true);
  15309. }
  15310.  
  15311. /**
  15312. * @param {Boolean} remove
  15313. */
  15314. modifyTagsOfSelected(remove) {
  15315. const tags = this.favoritesUI.textarea.value.toLowerCase();
  15316. const tagsWithoutContentTypes = this.removeContentTypeTags(tags);
  15317. const tagsToModify = Utils.removeExtraWhiteSpace(tagsWithoutContentTypes);
  15318. const statusPrefix = remove ? "Removed tag(s) from" : "Added tag(s) to";
  15319. let modifiedTagsCount = 0;
  15320.  
  15321. if (tagsToModify === "") {
  15322. return;
  15323. }
  15324.  
  15325. for (const post of Post.allPosts.values()) {
  15326. if (this.isSelectedForModification(post)) {
  15327. const additionalTags = remove ? post.removeAdditionalTags(tagsToModify) : post.addAdditionalTags(tagsToModify);
  15328.  
  15329. TagModifier.tagModifications.set(post.id, additionalTags);
  15330. modifiedTagsCount += 1;
  15331. }
  15332. }
  15333.  
  15334. if (modifiedTagsCount === 0) {
  15335. return;
  15336. }
  15337.  
  15338. if (tags !== tagsWithoutContentTypes) {
  15339. alert("Warning: video, animated, and mp4 tags are unchanged.\nThey cannot be modified.");
  15340. }
  15341. this.showStatus(`${statusPrefix} ${modifiedTagsCount} favorite(s)`);
  15342. dispatchEvent(new Event("modifiedTags"));
  15343. Utils.setCustomTags(tagsToModify);
  15344. this.storeTagModifications();
  15345. }
  15346.  
  15347. createDatabase(event) {
  15348. /**
  15349. * @type {IDBDatabase}
  15350. */
  15351. const database = event.target.result;
  15352.  
  15353. database
  15354. .createObjectStore(TagModifier.objectStoreName, {
  15355. keyPath: "id"
  15356. });
  15357. }
  15358.  
  15359. storeTagModifications() {
  15360. const request = indexedDB.open(TagModifier.databaseName, 1);
  15361.  
  15362. request.onupgradeneeded = this.createDatabase;
  15363. request.onsuccess = (event) => {
  15364. /**
  15365. * @type {IDBDatabase}
  15366. */
  15367. const database = event.target.result;
  15368. const objectStore = database
  15369. .transaction(TagModifier.objectStoreName, "readwrite")
  15370. .objectStore(TagModifier.objectStoreName);
  15371. const idsWithNoTagModifications = [];
  15372.  
  15373. for (const [id, tags] of TagModifier.tagModifications) {
  15374. if (tags === "") {
  15375. idsWithNoTagModifications.push(id);
  15376. objectStore.delete(id);
  15377. } else {
  15378. objectStore.put({
  15379. id,
  15380. tags
  15381. });
  15382. }
  15383. }
  15384.  
  15385. for (const id of idsWithNoTagModifications) {
  15386. TagModifier.tagModifications.delete(id);
  15387. }
  15388. database.close();
  15389. };
  15390. }
  15391.  
  15392. loadTagModifications() {
  15393. const request = indexedDB.open(TagModifier.databaseName, 1);
  15394.  
  15395. request.onupgradeneeded = this.createDatabase;
  15396. request.onsuccess = (event) => {
  15397. /**
  15398. * @type {IDBDatabase}
  15399. */
  15400. const database = event.target.result;
  15401. const objectStore = database
  15402. .transaction(TagModifier.objectStoreName, "readonly")
  15403. .objectStore(TagModifier.objectStoreName);
  15404.  
  15405. objectStore.getAll().onsuccess = (successEvent) => {
  15406. const tagModifications = successEvent.target.result;
  15407.  
  15408. for (const record of tagModifications) {
  15409. TagModifier.tagModifications.set(record.id, record.tags);
  15410. }
  15411. this.restoreMissingCustomTags();
  15412. };
  15413. database.close();
  15414. };
  15415. }
  15416.  
  15417. restoreMissingCustomTags() {
  15418. // const allCustomTags = Array.from(TagModifier.tagModifications.values()).join(" ");
  15419. // const allUniqueCustomTags = new Set(allCustomTags.split(" "));
  15420.  
  15421. // Utils.setCustomTags(Array.from(allUniqueCustomTags).join(" "));
  15422. }
  15423.  
  15424. resetTagModifications() {
  15425. if (!confirm("Are you sure you want to delete all tag modifications?")) {
  15426. return;
  15427. }
  15428. Utils.customTags.clear();
  15429. indexedDB.deleteDatabase("AdditionalTags");
  15430. Post.allPosts.forEach(post => {
  15431. post.resetAdditionalTags();
  15432. });
  15433. dispatchEvent(new Event("modifiedTags"));
  15434. localStorage.removeItem("customTags");
  15435. }
  15436.  
  15437. exportTagModifications() {
  15438. const modifications = JSON.stringify(Utils.mapToObject(TagModifier.tagModifications));
  15439.  
  15440. navigator.clipboard.writeText(modifications);
  15441. alert("Copied tag modifications to clipboard");
  15442. }
  15443.  
  15444. importTagModifications() {
  15445. let modifications;
  15446.  
  15447. try {
  15448. const object = JSON.parse(this.favoritesUI.textarea.value);
  15449.  
  15450. if (!(typeof object === "object")) {
  15451. throw new TypeError(`Input parsed as ${typeof (object)}, but expected object`);
  15452. }
  15453. modifications = Utils.objectToMap(object);
  15454. } catch (error) {
  15455. if (error.name === "SyntaxError" || error.name === "TypeError") {
  15456. alert("Import Unsuccessful. Failed to parse input, JSON object format expected.");
  15457. } else {
  15458. throw error;
  15459. }
  15460. return;
  15461. }
  15462. console.error(modifications);
  15463. }
  15464. }
  15465.  
  15466. class AwesompleteImplementation {
  15467. static decodeEntities = (function() {
  15468. // this prevents any overhead from creating the object each time
  15469. const element = document.createElement("div");
  15470.  
  15471. function decodeHTMLEntities(str) {
  15472. if (str && typeof str === "string") {
  15473. // strip script/html tags
  15474. str = str.replace(/<script[^>]*>([\S\s]*?)<\/script>/gmi, "");
  15475. str = str.replace(/<\/?\w(?:[^"'>]|"[^"]*"|'[^']*')*>/gmi, "");
  15476. element.innerHTML = str;
  15477. str = element.textContent;
  15478. element.textContent = "";
  15479. }
  15480. return str;
  15481. }
  15482. return decodeHTMLEntities;
  15483. }());
  15484.  
  15485. static {
  15486. Utils.addStaticInitializer(() => {
  15487. // Awesomplete - Lea Verou - MIT license
  15488. !(function() {
  15489. function t(t) {
  15490. const e = Array.isArray(t) ? {
  15491. label: t[0],
  15492. value: t[1]
  15493. } : typeof t === "object" && t != null && "label" in t && "value" in t ? t : {
  15494. label: t,
  15495. value: t
  15496. };
  15497.  
  15498. this.label = e.label || e.value, this.value = e.value, this.type = e.type;
  15499. }
  15500.  
  15501. function e(t, e, i) {
  15502. for (const n in e) {
  15503. const s = e[n],
  15504. r = t.input.getAttribute(`data-${n.toLowerCase()}`);
  15505.  
  15506. typeof s === "number" ? t[n] = parseInt(r) : !1 === s ? t[n] = r !== null : s instanceof Function ? t[n] = null : t[n] = r, t[n] || t[n] === 0 || (t[n] = n in i ? i[n] : s);
  15507. }
  15508. }
  15509.  
  15510. function i(t, e) {
  15511. return typeof t === "string" ? (e || document).querySelector(t) : t || null;
  15512. }
  15513.  
  15514. function n(t, e) {
  15515. return o.call((e || document).querySelectorAll(t));
  15516. }
  15517.  
  15518. function s() {
  15519. n("input.awesomplete").forEach((t) => {
  15520. new r(t);
  15521. });
  15522. }
  15523.  
  15524. var r = function(t, n) {
  15525. const s = this;
  15526.  
  15527. this.isOpened = !1, this.input = i(t), this.input.setAttribute("autocomplete", "off"), this.input.setAttribute("aria-autocomplete", "list"), n = n || {}, e(this, {
  15528. minChars: 2,
  15529. maxItems: 20,
  15530. autoFirst: !1,
  15531. data: r.DATA,
  15532. filter: r.FILTER_CONTAINS,
  15533. sort: !1 !== n.sort && r.SORT_BYLENGTH,
  15534. item: r.ITEM,
  15535. replace: r.REPLACE
  15536. }, n), this.index = -1, this.container = i.create("div", {
  15537. className: "awesomplete",
  15538. around: t
  15539. }), this.ul = i.create("ul", {
  15540. hidden: "hidden",
  15541. inside: this.container
  15542. }), this.status = i.create("span", {
  15543. className: "visually-hidden",
  15544. role: "status",
  15545. "aria-live": "assertive",
  15546. "aria-relevant": "additions",
  15547. inside: this.container
  15548. }), this._events = {
  15549. input: {
  15550. input: this.evaluate.bind(this),
  15551. blur: this.close.bind(this, {
  15552. reason: "blur"
  15553. }),
  15554. keypress(t) {
  15555. const e = t.keyCode;
  15556.  
  15557. if (s.opened) {
  15558.  
  15559. switch (e) {
  15560. case 13: // RETURN
  15561. if (s.selected == true) {
  15562. t.preventDefault();
  15563. s.select();
  15564. break;
  15565. }
  15566.  
  15567. case 66:
  15568. break;
  15569.  
  15570. case 27: // ESC
  15571. s.close({
  15572. reason: "esc"
  15573. });
  15574. break;
  15575. }
  15576. }
  15577. },
  15578. keydown(t) {
  15579. const e = t.keyCode;
  15580.  
  15581. if (s.opened) {
  15582. switch (e) {
  15583. case 9: // TAB
  15584. if (s.selected == true) {
  15585. t.preventDefault();
  15586. s.select();
  15587. break;
  15588. }
  15589.  
  15590. case 38: // up arrow
  15591. t.preventDefault();
  15592. s.previous();
  15593. break;
  15594.  
  15595. case 40:
  15596. t.preventDefault();
  15597. s.next();
  15598. break;
  15599. }
  15600. }
  15601. }
  15602. },
  15603. form: {
  15604. submit: this.close.bind(this, {
  15605. reason: "submit"
  15606. })
  15607. },
  15608. ul: {
  15609. mousedown(t) {
  15610. let e = t.target;
  15611.  
  15612. if (e !== this) {
  15613. for (; e && !(/li/i).test(e.nodeName);) e = e.parentNode;
  15614. e && t.button === 0 && (t.preventDefault(), s.select(e, t.target));
  15615. }
  15616. }
  15617. }
  15618. }, i.bind(this.input, this._events.input), i.bind(this.input.form, this._events.form), i.bind(this.ul, this._events.ul), this.input.hasAttribute("list") ? (this.list = `#${this.input.getAttribute("list")}`, this.input.removeAttribute("list")) : this.list = this.input.getAttribute("data-list") || n.list || [], r.all.push(this);
  15619. };
  15620. r.prototype = {
  15621. set list(t) {
  15622. if (Array.isArray(t)) this._list = t;
  15623. else if (typeof t === "string" && t.indexOf(",") > -1) this._list = t.split(/\s*,\s*/);
  15624. else if ((t = i(t)) && t.children) {
  15625. const e = [];
  15626.  
  15627. o.apply(t.children).forEach((t) => {
  15628. if (!t.disabled) {
  15629. const i = t.textContent.trim(),
  15630. n = t.value || i,
  15631. s = t.label || i;
  15632.  
  15633. n !== "" && e.push({
  15634. label: s,
  15635. value: n
  15636. });
  15637. }
  15638. }), this._list = e;
  15639. }
  15640. document.activeElement === this.input && this.evaluate();
  15641. },
  15642. get selected() {
  15643. return this.index > -1;
  15644. },
  15645. get opened() {
  15646. return this.isOpened;
  15647. },
  15648. close(t) {
  15649. this.opened && (this.ul.setAttribute("hidden", ""), this.isOpened = !1, this.index = -1, i.fire(this.input, "awesomplete-close", t || {}));
  15650. },
  15651. open() {
  15652. this.ul.removeAttribute("hidden"), this.isOpened = !0, this.autoFirst && this.index === -1 && this.goto(0), i.fire(this.input, "awesomplete-open");
  15653. },
  15654. destroy() {
  15655. i.unbind(this.input, this._events.input), i.unbind(this.input.form, this._events.form);
  15656. const t = this.container.parentNode;
  15657.  
  15658. t.insertBefore(this.input, this.container), t.removeChild(this.container), this.input.removeAttribute("autocomplete"), this.input.removeAttribute("aria-autocomplete");
  15659. const e = r.all.indexOf(this);
  15660.  
  15661. e !== -1 && r.all.splice(e, 1);
  15662. },
  15663. next() {
  15664. const t = this.ul.children.length;
  15665.  
  15666. this.goto(this.index < t - 1 ? this.index + 1 : t ? 0 : -1);
  15667. },
  15668. previous() {
  15669. const t = this.ul.children.length,
  15670. e = this.index - 1;
  15671.  
  15672. this.goto(this.selected && e !== -1 ? e : t - 1);
  15673. },
  15674. goto(t) {
  15675. const e = this.ul.children;
  15676.  
  15677. this.selected && e[this.index].setAttribute("aria-selected", "false"), this.index = t, t > -1 && e.length > 0 && (e[t].setAttribute("aria-selected", "true"), this.status.textContent = e[t].textContent, this.ul.scrollTop = e[t].offsetTop - this.ul.clientHeight + e[t].clientHeight, i.fire(this.input, "awesomplete-highlight", {
  15678. text: this.suggestions[this.index]
  15679. }));
  15680. },
  15681. select(t, e) {
  15682. if (t ? this.index = i.siblingIndex(t) : t = this.ul.children[this.index], t) {
  15683. const n = this.suggestions[this.index];
  15684.  
  15685. i.fire(this.input, "awesomplete-select", {
  15686. text: n,
  15687. origin: e || t
  15688. }) && (this.replace(n), this.close({
  15689. reason: "select"
  15690. }), i.fire(this.input, "awesomplete-selectcomplete", {
  15691. text: n
  15692. }));
  15693. }
  15694. },
  15695. evaluate() {
  15696. const e = this,
  15697. i = this.input.value;
  15698.  
  15699. i.length >= this.minChars && this._list.length > 0 ? (this.index = -1, this.ul.innerHTML = "", this.suggestions = this._list.map((n) => {
  15700. return new t(e.data(n, i));
  15701. }).filter((t) => {
  15702. return e.filter(t, i);
  15703. }), !1 !== this.sort && (this.suggestions = this.suggestions.sort(this.sort)), this.suggestions = this.suggestions.slice(0, this.maxItems), this.suggestions.forEach((t) => {
  15704. e.ul.appendChild(e.item(t, i));
  15705. }), this.ul.children.length === 0 ? this.close({
  15706. reason: "nomatches"
  15707. }) : this.open()) : this.close({
  15708. reason: "nomatches"
  15709. });
  15710. }
  15711. }, r.all = [], r.FILTER_CONTAINS = function(t, e) {
  15712. return RegExp(i.regExpEscape(e.trim()), "i").test(t);
  15713. }, r.FILTER_STARTSWITH = function(t, e) {
  15714. return RegExp(`^${i.regExpEscape(e.trim())}`, "i").test(t);
  15715. }, r.SORT_BYLENGTH = function(t, e) {
  15716. return t.length !== e.length ? t.length - e.length : t < e ? -1 : 1;
  15717. }, r.ITEM = function(t, e) {
  15718. return i.create("li", {
  15719. innerHTML: e.trim() === "" ? t : t.replace(RegExp(i.regExpEscape(e.trim()), "gi"), "<mark>$&</mark>"),
  15720. "aria-selected": "false"
  15721. });
  15722. }, r.REPLACE = function(t) {
  15723. this.input.value = t.value;
  15724. }, r.DATA = function(t) {
  15725. return t;
  15726. }, Object.defineProperty(t.prototype = Object.create(String.prototype), "length", {
  15727. get() {
  15728. return this.label.length;
  15729. }
  15730. }), t.prototype.toString = t.prototype.valueOf = function() {
  15731. return `${this.label}`;
  15732. };
  15733. var o = Array.prototype.slice;
  15734. i.create = function(t, e) {
  15735. const n = document.createElement(t);
  15736.  
  15737. for (const s in e) {
  15738. const r = e[s];
  15739.  
  15740. if (s === "inside") i(r).appendChild(n);
  15741. else if (s === "around") {
  15742. const o = i(r);
  15743.  
  15744. o.parentNode.insertBefore(n, o), n.appendChild(o);
  15745. } else s in n ? n[s] = r : n.setAttribute(s, r);
  15746. }
  15747. return n;
  15748. }, i.bind = function(t, e) {
  15749. if (t) for (const i in e) {
  15750. var n = e[i];
  15751. i.split(/\s+/).forEach((e) => {
  15752. t.addEventListener(e, n);
  15753. });
  15754. }
  15755. }, i.unbind = function(t, e) {
  15756. if (t) for (const i in e) {
  15757. var n = e[i];
  15758. i.split(/\s+/).forEach((e) => {
  15759. t.removeEventListener(e, n);
  15760. });
  15761. }
  15762. }, i.fire = function(t, e, i) {
  15763. const n = document.createEvent("HTMLEvents");
  15764.  
  15765. n.initEvent(e, !0, !0);
  15766.  
  15767. for (const s in i) n[s] = i[s];
  15768. return t.dispatchEvent(n);
  15769. }, i.regExpEscape = function(t) {
  15770. return t.replace(/[-\\^$*+?.()|[\]{}]/g, "\\$&");
  15771. }, i.siblingIndex = function(t) {
  15772. for (var e = 0; t = t.previousElementSibling; e++);
  15773. return e;
  15774. }, typeof Document !== "undefined" && (document.readyState !== "loading" ? s() : document.addEventListener("DOMContentLoaded", s)), r.$ = i, r.$$ = n, typeof self !== "undefined" && (self.Awesomplete_ = r), typeof module === "object" && module.exports && (module.exports = r);
  15775. }());
  15776. });
  15777. }
  15778. }
  15779.  
  15780. class AwesompleteWrapper {
  15781. static preferences = {
  15782. savedSearchSuggestions: "savedSearchSuggestions"
  15783. };
  15784.  
  15785. /**
  15786. * @type {Boolean}
  15787. */
  15788. static get disabled() {
  15789. return !Utils.onFavoritesPage();
  15790. }
  15791.  
  15792. /**
  15793. * @type {Boolean}
  15794. */
  15795. showSavedSearchSuggestions;
  15796.  
  15797. constructor() {
  15798. if (AwesompleteWrapper.disabled) {
  15799. return;
  15800. }
  15801. this.initializeFields();
  15802. this.insertHTML();
  15803. this.addAwesompleteToInputs();
  15804. }
  15805.  
  15806. initializeFields() {
  15807. this.showSavedSearchSuggestions = Utils.getPreference(AwesompleteWrapper.preferences.savedSearchSuggestions, false);
  15808. }
  15809.  
  15810. insertHTML() {
  15811. Utils.createFavoritesOption(
  15812. "show-saved-search-suggestions",
  15813. "Saved Suggestions",
  15814. "Show saved search suggestions in autocomplete dropdown",
  15815. this.showSavedSearchSuggestions,
  15816. (event) => {
  15817. this.showSavedSearchSuggestions = event.target.checked;
  15818. Utils.setPreference(AwesompleteWrapper.preferences.savedSearchSuggestions, event.target.checked);
  15819. },
  15820. false
  15821. );
  15822. }
  15823.  
  15824. addAwesompleteToInputs() {
  15825. document.querySelectorAll("textarea").forEach((textarea) => {
  15826. this.addAwesompleteToInput(textarea);
  15827. });
  15828. document.querySelectorAll("input").forEach((input) => {
  15829. if (input.hasAttribute("needs-autocomplete")) {
  15830. this.addAwesompleteToInput(input);
  15831. }
  15832. });
  15833. }
  15834.  
  15835. /**
  15836. * @param {HTMLElement} input
  15837. */
  15838. addAwesompleteToInput(input) {
  15839. const awesomplete = new Awesomplete_(input, {
  15840. minChars: 1,
  15841. list: [],
  15842. filter: (suggestion, _) => {
  15843. // eslint-disable-next-line new-cap
  15844. return Awesomplete_.FILTER_STARTSWITH(suggestion.value, this.getCurrentTag(awesomplete.input));
  15845. },
  15846. sort: false,
  15847. item: (suggestion, tags) => {
  15848. const html = tags.trim() === "" ? suggestion.label : suggestion.label.replace(RegExp(Awesomplete_.$.regExpEscape(tags.trim()), "gi"), "<mark>$&</mark>");
  15849. return Awesomplete_.$.create("li", {
  15850. innerHTML: html,
  15851. "aria-selected": "false",
  15852. className: `tag-type-${suggestion.type}`
  15853. });
  15854. },
  15855. replace: (suggestion) => {
  15856. Utils.insertSuggestion(awesomplete.input, Utils.removeSavedSearchPrefix(decodeEntities(suggestion.value)));
  15857. }
  15858. });
  15859.  
  15860. input.addEventListener("keydown", (event) => {
  15861. switch (event.key) {
  15862. case "Tab":
  15863. if (!awesomplete.isOpened || awesomplete.suggestions.length === 0) {
  15864. return;
  15865. }
  15866. awesomplete.next();
  15867. awesomplete.select();
  15868. event.preventDefault();
  15869. break;
  15870.  
  15871. case "Escape":
  15872. Utils.hideAwesomplete(input);
  15873. break;
  15874.  
  15875. default:
  15876. break;
  15877. }
  15878. });
  15879.  
  15880. input.oninput = () => {
  15881. this.populateAwesompleteList(input.id, this.getCurrentTagWithHyphen(input), awesomplete);
  15882. };
  15883. }
  15884.  
  15885. getSavedSearchesForAutocompleteList(inputId, prefix) {
  15886. if (Utils.onMobileDevice() || !this.showSavedSearchSuggestions || inputId !== "favorites-search-box") {
  15887. return [];
  15888. }
  15889. return Utils.getSavedSearchesForAutocompleteList(prefix);
  15890. }
  15891.  
  15892. /**
  15893. * @param {String} inputId
  15894. * @param {String} prefix
  15895. * @param {Awesomplete_} awesomplete
  15896. */
  15897. populateAwesompleteList(inputId, prefix, awesomplete) {
  15898. if (prefix.trim() === "") {
  15899. return;
  15900. }
  15901. const savedSearchSuggestions = this.getSavedSearchesForAutocompleteList(inputId, prefix);
  15902.  
  15903. prefix = prefix.replace(/^-/, "");
  15904.  
  15905. fetch(`https://ac.rule34.xxx/autocomplete.php?q=${prefix}`)
  15906. .then((response) => {
  15907. if (response.ok) {
  15908. return response.text();
  15909. }
  15910. throw new Error(response.status);
  15911. })
  15912. .then((suggestions) => {
  15913.  
  15914. const mergedSuggestions = Utils.addCustomTagsToAutocompleteList(JSON.parse(suggestions), prefix);
  15915.  
  15916. awesomplete.list = mergedSuggestions.concat(savedSearchSuggestions);
  15917. });
  15918. }
  15919.  
  15920. /**
  15921. * @param {HTMLInputElement | HTMLTextAreaElement} input
  15922. * @returns {String}
  15923. */
  15924. getCurrentTag(input) {
  15925. return this.getLastTag(input.value.slice(0, input.selectionStart));
  15926. }
  15927.  
  15928. /**
  15929. * @param {String} searchQuery
  15930. * @returns {String}
  15931. */
  15932. getLastTag(searchQuery) {
  15933. const lastTag = searchQuery.match(/[^ -][^ ]*$/);
  15934. return lastTag === null ? "" : lastTag[0];
  15935. }
  15936.  
  15937. /**
  15938. * @param {HTMLInputElement | HTMLTextAreaElement} input
  15939. * @returns {String}
  15940. */
  15941. getCurrentTagWithHyphen(input) {
  15942. return this.getLastTagWithHyphen(input.value.slice(0, input.selectionStart));
  15943. }
  15944.  
  15945. /**
  15946. * @param {String} searchQuery
  15947. * @returns {String}
  15948. */
  15949. getLastTagWithHyphen(searchQuery) {
  15950. const lastTag = searchQuery.match(/[^ ]*$/);
  15951. return lastTag === null ? "" : lastTag[0];
  15952. }
  15953. }
  15954.  
  15955. Utils.initialize();
  15956. const favoritesLoader = new FavoritesLoader();
  15957. const favoritesMenu = new FavoritesMenu();
  15958. const gallery = new Gallery();
  15959. const tooltip = new Tooltip();
  15960. const savedSearches = new SavedSearches();
  15961. const caption = new Caption();
  15962. const tagModifier = new TagModifier();
  15963. const awesompleteWrapper = new AwesompleteWrapper();
  15964.  
  15965. Utils.postProcess();