Rule34 Favorites Search Gallery

Search, View, and Play Favorites All on One Page

Versão de: 19/08/2024. Veja: a última versão.

// ==UserScript==
// @name         Rule34 Favorites Search Gallery
// @namespace    bruh3396
// @version      1.0
// @description  Search, View, and Play Favorites All on One Page
// @author       bruh3396
// @match        https://rule34.xxx/index.php?page=favorites&s=view&id=*
// @match        https://rule34.xxx/index.php?page=post&s=list*
// ==/UserScript==

// utilities.js

const IDS_TO_REMOVE_ON_RELOAD_KEY = "recentlyRemovedIds";
const TAG_BLACKLIST = getTagBlacklist();
const CURSOR_POSITION = {
  X: 0,
  Y: 0
};
const PREFERENCES = "preferences";
let onPostPageFlag;
let usingFirefoxFlag;

/**
 * @param {String} key
 * @param {any} value
 */
function setCookie(key, value) {
  let cookieString = `${key}=${value || ""}`;
  const expirationDate = new Date();

  expirationDate.setFullYear(expirationDate.getFullYear() + 1);
  cookieString += `; expires=${expirationDate.toUTCString()}`;
  cookieString += "; path=/";
  document.cookie = cookieString;
}

/**
 * @param {String} key
 * @param {any} defaultValue
 * @returns {String | null}
 */
function getCookie(key, defaultValue) {
  const nameEquation = `${key}=`;
  const cookies = document.cookie.split(";").map(cookie => cookie.trimStart());

  for (const cookie of cookies) {
    if (cookie.startsWith(nameEquation)) {
      return cookie.substring(nameEquation.length, cookie.length);
    }
  }
  return defaultValue === undefined ? null : defaultValue;
}

/**
 * @param {String} key
 * @param {any} value
 */
function setPreference(key, value) {
  const preferences = JSON.parse(localStorage.getItem(PREFERENCES) || "{}");

  preferences[key] = value;
  localStorage.setItem(PREFERENCES, JSON.stringify(preferences));
}

/**
 * @param {String} key
 * @param {any} defaultValue
 * @returns {String | null}
 */
function getPreference(key, defaultValue) {
  const preferences = JSON.parse(localStorage.getItem(PREFERENCES) || "{}");
  const preference = preferences[key];

  if (preference === undefined) {
    return defaultValue === undefined ? null : defaultValue;
  }
  return preference;
}

/**
 * @returns {String | null}
 */
function getUserId() {
  return getCookie("user_id");
}

/**
 * @returns {String | null}
 */
function getFavoritesPageId() {
  const match = (/(?:&|\?)id=(\d+)/).exec(window.location.href);
  return match ? match[1] : null;
}

/**
 * @returns {Boolean}
 */
function userIsOnTheirOwnFavoritesPage() {
  return getUserId() === getFavoritesPageId();
}

/**
 * @param {String} url
 * @param {Function} callback
 * @param {Number} delayIncrement
 * @param {Number} delay
 */
function requestPageInformation(url, callback, delay = 0) {
  const httpRequest = new XMLHttpRequest();
  const delayIncrement = 500;

  httpRequest.open("GET", url, true);
  httpRequest.onreadystatechange = () => {
    if (httpRequest.readyState === 4) {
      if (httpRequest.status === 503) {
        requestPageInformation(url, callback, delay + delayIncrement);
      }
      return callback(httpRequest.responseText);
    }
    return null;
  };
  setTimeout(() => {
    httpRequest.send();
  }, delay);
}

/**
 * @param {Number} value
 * @param {Number} min
 * @param {Number} max
 * @returns {Number}
 */
function clamp(value, min, max) {
  if (value <= min) {
    return min;
  } else if (value >= max) {
    return max;
  }
  return value;
}

/**
 * @param {Number} milliseconds
 * @returns
 */
function sleep(milliseconds) {
  return new Promise(resolve => setTimeout(resolve, milliseconds));
}

/**
 * @param {Boolean} removeButtonsAreVisible
 */
function hideCaptionsWhenRemoveButtonsAreVisible(removeButtonsAreVisible) {
  for (const caption of document.getElementsByClassName("caption")) {
    if (removeButtonsAreVisible) {
      caption.classList.add("remove");
    } else {
      caption.classList.remove("remove");
    }
  }
}

function updateVisibilityOfAllRemoveButtons() {
  const removeButtonCheckbox = document.getElementById("show-remove-buttons");

  if (removeButtonCheckbox === null) {
    return;
  }
  const removeButtonsAreVisible = removeButtonCheckbox.checked;
  const visibility = removeButtonsAreVisible ? "visible" : "hidden";

  injectStyleHTML(`
      .remove-button {
        visibility: ${visibility} !important;
      }
    `, "remove-button-visibility");
  hideCaptionsWhenRemoveButtonsAreVisible(removeButtonsAreVisible);
}

/**
 * @param {HTMLElement} thumb
 * @returns {String | null}
 */
function getRemoveLinkFromThumb(thumb) {
  return thumb.querySelector(".remove-button");
}

/**
 * @param {HTMLImageElement} image
 */
function removeTitleFromImage(image) {
  if (image.hasAttribute("title")) {
    image.setAttribute("tags", image.title);
    image.removeAttribute("title");
  }
}

/**
 * @param {HTMLImageElement} image
 * @returns {HTMLElement}
 */
function getThumbFromImage(image) {
  return image.parentNode.parentNode;
}

/**
 * @param {HTMLElement} thumb
 * @returns {HTMLImageElement}
 */
function getImageFromThumb(thumb) {
  return thumb.children[0].children[0];
}

/**
 * @returns {HTMLCollectionOf.<Element>}
 */
function getAllThumbs() {
  const className = onPostPage() ? "thumb" : "thumb-node";
  return document.getElementsByClassName(className);
}

/**
 * @returns {HTMLElement[]}
 */
function getAllVisibleThumbs() {
  return Array.from(getAllThumbs())
    .filter(thumbNodeElement => thumbNodeElement.style.display !== "none");
}

/**
 * @param {HTMLElement} thumb
 * @returns {String}
 */
function getOriginalImageURLFromThumb(thumb) {
  return getOriginalImageURL(getImageFromThumb(thumb).src);
}

/**
 * @param {String} thumbURL
 * @returns {String}
 */
function getOriginalImageURL(thumbURL) {
  return thumbURL
    .replace("thumbnails", "/images")
    .replace("thumbnail_", "")
    .replace("us.rule34", "rule34");
}

/**
 * @param {String} originalImageURL
 * @returns {String}
 */
function getThumbURL(originalImageURL) {
  return originalImageURL
    .replace(/\/images\/\/(\d+)\//, "thumbnails/$1/thumbnail_")
    .replace(/(?:gif|jpeg|png)/, "jpg")
    .replace("us.rule34", "rule34");
}

/**
 * @param {HTMLElement} thumb
 * @returns {String}
 */
function getTagsFromThumb(thumb) {
  const image = getImageFromThumb(thumb);
  return image.hasAttribute("tags") ? image.getAttribute("tags") : image.title;
}

/**
 * @param {String} tag
 * @param {String} tags
 * @returns
 */
function includesTag(tag, tags) {
  return tags.includes(` ${tag} `) || tags.endsWith(` ${tag}`) || tags.startsWith(`${tag} `);
}

/**
 * @param {HTMLElement} thumb
 * @returns {Boolean}
 */
function isVideo(thumb) {
  if (thumb.classList.contains("video")) {
    return true;
  }
  const tags = getTagsFromThumb(thumb);
  return includesTag("video", tags) || includesTag("mp4", tags);
}

/**
 * @param {HTMLElement} thumb
 * @returns {Boolean}
 */
function isGif(thumb) {
  if (isVideo(thumb)) {
    return false;
  }
  const tags = getTagsFromThumb(thumb);
  return includesTag("gif", tags) || includesTag("animated", tags) || includesTag("animated_png", tags) || getImageFromThumb(thumb).hasAttribute("gif");
}

/**
 * @param {HTMLElement} thumb
 * @returns {Boolean}
 */
function isImage(thumb) {
  return !isVideo(thumb) && !isGif(thumb);
}

/**
 * @param {String} svgContent
 * @param {String} id
 * @param {Number} newWidth
 * @param {Number} newHeight
 * @param {String} position
 * @param {Number} duration
 */
function showOverlayingIcon(svgContent, id, newWidth, newHeight, position = "center", duration = 500) {
  const skip = true;

  if (skip) {
    return;
  }
  const svgDocument = new DOMParser().parseFromString(svgContent, "image/svg+xml");
  const svgElement = svgDocument.documentElement;
  const zoomLevel = getZoomLevel();

  svgElement.setAttribute("width", Math.round(newWidth / zoomLevel));
  svgElement.setAttribute("height", Math.round(newHeight / zoomLevel));

  if (document.getElementById(id) !== null) {
    return;
  }
  const svgOverlay = document.createElement("div");

  svgOverlay.id = id;

  switch (position) {
    case "center":
      svgOverlay.style.cssText = "position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 9999;";
      break;

    case "bottom-left":
      svgOverlay.style.cssText = "position: fixed; bottom: 0; left: 0; z-index: 9999;";
      break;

    case "bottom-right":
      svgOverlay.style.cssText = "position: fixed; bottom: 0; right: 0; z-index: 9999;";
      break;

    case "top-left":
      svgOverlay.style.cssText = "position: fixed; top: 0; left: 0; z-index: 9999;";
      break;

    case "top-right":
      svgOverlay.style.cssText = "position: fixed; top: 0; right: 0; z-index: 9999;";
      break;

    default:
      svgOverlay.style.cssText = "position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 9999;";
  }
  svgOverlay.style.cssText += " pointer-events:none;";
  svgOverlay.innerHTML = new XMLSerializer().serializeToString(svgElement);
  // document.body.appendChild(svgOverlay);
  setTimeout(() => {
    svgOverlay.remove();
  }, duration);
}

/**
 * @param {any[]} array
 */
function shuffleArray(array) {
  let maxIndex = array.length;
  let randomIndex;

  while (maxIndex > 0) {
    randomIndex = Math.floor(Math.random() * maxIndex);
    maxIndex -= 1;
    [
      array[maxIndex],
      array[randomIndex]
    ] = [
        array[randomIndex],
        array[maxIndex]
      ];
  }
}

/**
 * @returns {Number}
 */
function getZoomLevel() {
  const zoomLevel = window.outerWidth / window.innerWidth;
  return zoomLevel;
}

/**
 * @param {String} tags
 * @returns {String}
 */
function negateTags(tags) {
  return tags.replace(/(\S+)/g, "-$1");
}

/**
 * @param {HTMLInputElement} input
 * @returns {Boolean}
 */
function awesompleteIsHidden(input) {
  if (input.parentElement.className === "awesomplete") {
    return input.parentElement.children[1].hasAttribute("hidden");
  }
  return false;
}

function awesompleteIsUnselected(input) {
  const awesomplete = input.parentElement;

  if (awesomplete === null) {
    return true;
  }
  const searchSuggestions = Array.from(awesomplete.querySelectorAll("li"));

  if (searchSuggestions.length === 0) {
    return true;
  }
  const somethingIsSelected = searchSuggestions.map(li => li.getAttribute("aria-selected"))
    .some(element => element === true || element === "true");
  return !somethingIsSelected;
}

/**
 * @param {HTMLInputElement} input
 * @returns
 */
function clearAwesompleteSelection(input) {
  const awesomplete = input.parentElement;

  if (awesomplete === null) {
    return;
  }
  const searchSuggestions = Array.from(awesomplete.querySelectorAll("li"));

  if (searchSuggestions.length === 0) {
    return;
  }

  for (const li of searchSuggestions) {
    li.setAttribute("aria-selected", false);
  }
}

function trackCursorPosition() {
  document.addEventListener("mousemove", (event) => {
    CURSOR_POSITION.X = event.clientX;
    CURSOR_POSITION.Y = event.clientY;
  });
}

/**
 * @param {String} optionId
 * @param {String} optionText
 * @param {String} optionTitle
 * @param {Boolean} optionIsChecked
 * @param {Function} onOptionChanged
 * @param {Boolean} optionIsVisible
 * @returns {HTMLElement | null}
 */
function addOptionToFavoritesPage(optionId, optionText, optionTitle, optionIsChecked, onOptionChanged, optionIsVisible) {
  const favoritesPageOptions = document.getElementById("favorite-options");
  const checkboxId = `${optionId}Checkbox`;

  if (favoritesPageOptions === null) {
    return null;
  }

  if (optionIsVisible === undefined || optionIsVisible) {
    optionIsVisible = "block";
  } else {
    optionIsVisible = "none";
  }
  favoritesPageOptions.insertAdjacentHTML("beforeend", `
    <div id="${optionId}" style="display: ${optionIsVisible}">
      <label class="checkbox" title="${optionTitle}">
      <input id="${checkboxId}" type="checkbox" > ${optionText}</label>
    </div>
  `);
  const newOptionsCheckbox = document.getElementById(checkboxId);

  newOptionsCheckbox.checked = optionIsChecked;
  newOptionsCheckbox.onchange = onOptionChanged;
  return document.getElementById(optionId);
}

/**
 * @returns {Boolean}
 */
function onPostPage() {
  if (onPostPageFlag === undefined) {
    onPostPageFlag = location.href.includes("page=post");
  }
  return onPostPageFlag;
}

/**
 * @returns {String[]}
 */
function getIdsToRemoveOnReload() {
  return JSON.parse(localStorage.getItem(IDS_TO_REMOVE_ON_RELOAD_KEY)) || [];
}

/**
 * @param {String} postId
 */
function setIdToBeRemovedOnReload(postId) {
  const idsToRemoveOnReload = getIdsToRemoveOnReload();

  idsToRemoveOnReload.push(postId);
  localStorage.setItem(IDS_TO_REMOVE_ON_RELOAD_KEY, JSON.stringify(idsToRemoveOnReload));
}

function clearRecentlyRemovedIds() {
  localStorage.removeItem(IDS_TO_REMOVE_ON_RELOAD_KEY);
}

/**
 * @param {String} html
 * @param {String} id
 */
function injectStyleHTML(html, id) {
  const style = document.createElement("style");

  style.textContent = html.replace("<style>", "").replace("</style>", "");

  if (id !== undefined) {
    const oldStyle = document.getElementById(id);

    if (oldStyle !== null) {
      oldStyle.remove();
    }
    style.id = id;
  }
  document.head.appendChild(style);
}

/**
 * @param {HTMLElement} content
 */
function populateMetadata(content) {
  const scripts = Array.from(content.getElementsByTagName("script"));

  scripts.shift();
  scripts.forEach((script) => {
    // eval(script.innerHTML);
  });
}

/**
 * @param {HTMLElement} image
 */
function addMetaDataToThumb(image) {
  const thumb = getThumbFromImage(image);
  const metadata = posts[thumb.id];

  thumb.setAttribute("rating", metadata.rating);
  thumb.setAttribute("score", metadata.score);
}

/**
 * @param {HTMLElement} thumb
 * @param {String} appropriateRating
 * @returns {Boolean}
 */
function hasAppropriateRating(thumb, appropriateRating) {
  const ratings = {
    "Safe": 0,
    "Questionable": 1,
    "Explicit": 2
  };
  return ratings[thumb.getAttribute("rating")] <= ratings[appropriateRating];
}

/**
 * @param {String} appropriateRating
 */
function removeInappropriatelyRatedContent(appropriateRating) {
  Array.from(getAllThumbs()).forEach((thumb) => {
    if (!hasAppropriateRating(thumb, appropriateRating)) {
      // setThumbDisplay(thumb, false);
    }
  });
}

function getTagDistribution() {
  const images = Array.from(getAllThumbs()).map(thumb => getImageFromThumb(thumb));
  const tagOccurrences = {};

  images.forEach((image) => {
    const tags = image.getAttribute("tags").replace(/ \d+$/, "").split(" ");

    tags.forEach((tag) => {
      const occurrences = tagOccurrences[tag];

      tagOccurrences[tag] = occurrences === undefined ? 1 : occurrences + 1;
    });
  });
  const sortedTagOccurrences = sortObjectByValues(tagOccurrences);
  let result = "";
  let i = 0;
  const max = 50;

  sortedTagOccurrences.forEach(item => {
    if (i < max) {
      result += `${item.key}: ${item.value}\n`;
    }
    i += 1;
  });
}

/**
 * @param {{key: any, value: any}} obj
 * @returns {{key: any, value: any}}
 */
function sortObjectByValues(obj) {
  const sortable = Object.entries(obj);

  sortable.sort((a, b) => b[1] - a[1]);
  return sortable.map(item => ({
    key: item[0],
    value: item[1]
  }));
}

function injectCommonStyles() {
  injectStyleHTML(`
    .light-green-gradient {
        background: linear-gradient(to bottom, #aae5a4, #89e180);
        color: black;
    }

    .dark-green-gradient {
        background: linear-gradient(to bottom, #5e715e, #293129);
        color: white;
    }

    img {
      border: none !important;
    }
  `, "lightDarkTheme");
  setTimeout(() => {
    if (onPostPage()) {
      removeInlineImgStyles();
    }
    configureVideoOutlines();
  }, 100);
}

function configureVideoOutlines() {
  injectStyleHTML("img.video {outline: 4px solid blue;}", "video-border");
}

function removeInlineImgStyles() {
  for (const image of document.getElementsByTagName("img")) {
    image.removeAttribute("style");
  }
}

function setTheme() {
  setTimeout(() => {
    if (usingDarkTheme()) {
      for (const element of document.querySelectorAll(".light-green-gradient")) {
        element.classList.remove("light-green-gradient");
        element.classList.add("dark-green-gradient");
      }
    }
  }, 10);
}

/**
 * @param {String} eventName
 * @param {Number} delay
 * @returns
 */
function dispatchEventWithDelay(eventName, delay) {
  if (delay === undefined) {
    dispatchEvent(new Event(eventName));
    return;
  }
  setTimeout(() => {
    dispatchEvent(new Event(eventName));
  }, delay);
}

/**
 * @param {String} postId
 * @returns
 */
function getThumbByPostId(postId) {
  return document.getElementById(postId);
}

/**
 * @param {String} content
 * @returns {Blob | MediaSource}
 */
function getWorkerURL(content) {
  return URL.createObjectURL(new Blob([content], {
    type: "text/javascript"
  }));
}

function initializeUtilities() {
  injectCommonStyles();
  trackCursorPosition();
  setTheme();
}

/**
 * @returns {String}
 */
function getTagBlacklist() {
  let tags = getCookie("tag_blacklist", "");

  for (let i = 0; i < 3; i += 1) {
    tags = decodeURIComponent(tags).replace(/(?:^| )-/, "");
  }
  return tags;
}

/**
 * @returns {HTMLElement | null}
 */
function getThumbUnderCursor() {
  const elementUnderCursor = document.elementFromPoint(CURSOR_POSITION.X, CURSOR_POSITION.Y);

  if (elementUnderCursor !== undefined && elementUnderCursor !== null && elementUnderCursor.nodeName.toLowerCase() === "img") {
    return getThumbFromImage(elementUnderCursor);
  }
  return null;
}

/**
 * @returns {Boolean}
 */
function hoveringOverThumb() {
  return getThumbUnderCursor() !== null;
}

/**
 * @returns {Boolean}
 */
function usingCaptions() {
  const result = document.getElementById("captionList") !== null;
  return result;
}

/**
 * @returns {Boolean}
 */
function usingRenderer() {
  return document.getElementById("original-content-container") !== null;
}

function getThumbUnderCursorOnLoad() {
  const thumbNodeElement = getThumbUnderCursor();

  dispatchEvent(new CustomEvent("thumbUnderCursorOnLoad", {
    detail: thumbNodeElement
  }));
}

/**
 * @param {String} word
 * @returns {String}
 */
function capitalize(word) {
  return word.charAt(0).toUpperCase() + word.slice(1);
}

/**
 * @param {Number} number
 * @returns {Number}
 */
function roundToTwoDecimalPlaces(number) {
  return Math.round((number + Number.EPSILON) * 100) / 100;
}

/**
 * @returns {Boolean}
 */
function usingDarkTheme() {
  return getCookie("theme", "") === "dark";
}

/**
 * @param {Event} event
 * @returns {Boolean}
 */
function enteredOverCaptionTag(event) {
  return event.relatedTarget !== null && event.relatedTarget.classList.contains("caption-tag");
}

/**
 * @param {String[]} postId
 * @param {Boolean} doAnimation
 */
function scrollToThumb(postId, doAnimation = true) {
  const element = document.getElementById(postId);
  const elementIsNotAThumb = element === null || (!element.classList.contains("thumb") && !element.classList.contains("thumb-node"));

  if (elementIsNotAThumb) {
    if (postId === "") {
      alert("Please enter a post ID");
    } else {
      alert(`Favorite with post ID ${postId} not found`);
    }
    return;
  }
  const rect = element.getBoundingClientRect();
  const favoritesHeader = document.getElementById("favorites-top-bar");
  const favoritesSearchHeight = favoritesHeader === null ? 0 : favoritesHeader.getBoundingClientRect().height;

  window.scroll({
    top: rect.top + window.scrollY + (rect.height / 2) - (window.innerHeight / 2) - (favoritesSearchHeight / 2),
    behavior: "smooth"
  });

  if (!doAnimation) {
    return;
  }
  const image = getImageFromThumb(element);

  image.classList.add("found");
  setTimeout(() => {
    image.classList.remove("found");
  }, 2000);
}

/**
 * @param {HTMLElement} thumb
 */
function assignContentType(thumb) {
  const image = getImageFromThumb(thumb);
  const tagAttribute = image.hasAttribute("tags") ? "tags" : "title";
  const tags = image.getAttribute(tagAttribute);

  image.classList.add(getContentType(tags));
}

/**
 * @param {String} tags
 * @returns {String}
 */
function getContentType(tags) {
  tags += " ";
  const isAnimated = tags.includes("animated ") || tags.includes("video ");
  const isAGif = isAnimated && !tags.includes("video ");
  return isAGif ? "gif" : isAnimated ? "video" : "image";
}

function correctMisspelledTags(tags) {
  if ((/vide(?:\s|$)/).test(tags)) {
    tags += " video";
  }
  return tags;
}

/**
 * @param {String} searchQuery
 * @returns {{orGroups: String[][], remainingSearchTags: String[]}}
 */
function extractTagGroups(searchQuery) {
  searchQuery = searchQuery.toLowerCase();
  const orRegex = /\( (.*?) \)/g;
  const orGroups = Array.from(removeExtraWhiteSpace(searchQuery)
    .matchAll(orRegex))
    .map((orGroup) => orGroup[1].split(" ~ "));
  const remainingSearchTags = removeExtraWhiteSpace(searchQuery
    .replace(orRegex, ""))
    .split(" ")
    .filter((searchTag) => searchTag !== "");
  return {
    orGroups,
    remainingSearchTags
  };
}

/**
 * @param {String} string
 * @returns {String}
 */
function removeExtraWhiteSpace(string) {
  return string.trim().replace(/\s+/g, " ");
}

/**
 *
 * @param {HTMLImageElement} image
 * @returns {Boolean}
 */
function imageIsLoaded(image) {
  return image.complete || image.naturalWidth !== 0;
}

/**
 * @returns {Boolean}
 */
function usingFirefox() {
  if (usingFirefoxFlag === undefined) {
    usingFirefoxFlag = navigator.userAgent.toLowerCase().includes("firefox");
  }
  return usingFirefoxFlag;
}

initializeUtilities();


// match.js

class PostTags {
  /**
   * @type {Set.<String>}
   */
  set;
  /**
   * @type {String[]}
   */
  array;

  /**
   * @param {String} tags
   */
  constructor(tags) {
    this.create(tags);
  }

  /**
   * @param {String} tags
   */
  create(tags) {
    this.array = removeExtraWhiteSpace(tags)
      .split(" ")
      .sort();
    this.set = new Set(this.array);
  }
}

/**
 * @param {String} searchQuery
 * @returns {{orGroups: String[][], remainingSearchTags: String[], isEmpty: Boolean}}
 */
function getSearchCommand(searchQuery) {
  const {orGroups, remainingSearchTags} = extractTagGroups(searchQuery);
  return {
    orGroups,
    remainingSearchTags,
    isEmpty: searchQuery.trim() === ""
  };
}

/**
 * @param {{orGroups: String[][], remainingSearchTags: String[], isEmpty: Boolean}} searchCommand
 * @param {PostTags} postTags
 * @returns {Boolean}
 */
function postTagsMatchSearch(searchCommand, postTags) {
  if (searchCommand.isEmpty) {
    return true;
  }

  if (!postTagsMatchAllRemainingSearchTags(searchCommand.remainingSearchTags, postTags)) {
    return false;
  }
  return postTagsMatchAllOrGroups(searchCommand.orGroups, postTags);
}

/**
 * @param {String[]} remainingSearchTags
 * @param {PostTags} postTags
 * @returns {Boolean}
 */
function postTagsMatchAllRemainingSearchTags(remainingSearchTags, postTags) {
  for (const searchTag of remainingSearchTags) {
    if (!postTagsMatchSearchTag(searchTag, postTags, false)) {
      return false;
    }
  }
  return true;
}

/**
 * @param {String[][]} orGroups
 * @param {PostTags} postTags
 * @returns {Boolean}
 */
function postTagsMatchAllOrGroups(orGroups, postTags) {
  for (const orGroup of orGroups) {
    if (!atLeastOnePostTagIsInOrGroup(orGroup, postTags)) {
      return false;
    }
  }
  return true;
}

/**
 * @param {String[]} orGroup
 * @param {PostTags} postTags
 * @returns {Boolean}
 */
function atLeastOnePostTagIsInOrGroup(orGroup, postTags) {
  for (const orTag of orGroup) {
    if (postTagsMatchSearchTag(orTag, postTags, true)) {
      return true;
    }
  }
  return false;
}

/**
 * @param {String} searchTag
 * @param {PostTags} postTags
 * @param {Boolean} inOrGroup
 * @returns {Boolean}
 */
function postTagsMatchSearchTag(searchTag, postTags, inOrGroup) {
  const isNegated = inOrGroup ? false : searchTag.startsWith("-");
  const isWildcard = searchTag.endsWith("*");

  searchTag = isWildcard ? searchTag.slice(0, -1) : searchTag;
  searchTag = isNegated ? searchTag.substring(1) : searchTag;
  const postTagsContainSearchTag = postTags.set.has(searchTag);

  if (postTagsContainSearchTag) {
    return !isNegated;
  }

  if (isWildcard && binarySearchStartsWith(searchTag, postTags.array)) {
    return !isNegated;
  }
  return isNegated;
}

/**
 * @param {String} target
 * @param {String[]} array
 * @returns {Boolean}
 */
function binarySearchStartsWith(target, array) {
  let left = 0;
  let right = array.length - 1;

  while (left <= right) {
    const mid = Math.floor((left + right) / 2);

    if (array[mid].startsWith(target)) {
      return true;
    } else if (array[mid] < target) {
      left = mid + 1;
    } else {
      right = mid - 1;
    }
  }
  return false;
}


// thumb_node.js

const THUMB_NODE_TEMPLATE = new DOMParser().parseFromString("<div></div>", "text/html").createElement("div");

THUMB_NODE_TEMPLATE.className = "thumb-node";
THUMB_NODE_TEMPLATE.innerHTML = `
    <div>
      <img loading="lazy">
      <button class="remove-button light-green-gradient" style="visibility: hidden;">Remove</button>
      <canvas></canvas>
    </div>
`;

class ThumbNode {
  static baseURLs = {
    post: "https://rule34.xxx/index.php?page=post&s=view&id=",
    remove: "https://rule34.xxx/index.php?page=favorites&s=delete&id="
  };
  static thumbSourceExtractionRegex = /thumbnails\/\/([0-9]+)\/thumbnail_([0-9a-f]+)/;

  /**
   * @param {String} compressedSource
   * @param {String} id
   * @returns {String}
   */
  static decompressThumbSource(compressedSource, id) {
    compressedSource = compressedSource.split("_");
    return `https://us.rule34.xxx/thumbnails//${compressedSource[0]}/thumbnail_${compressedSource[1]}.jpg?${id}`;
  }

  /**
   * @param {HTMLElement} thumb
   * @returns {String}
   */
  static getIdFromThumb(thumb) {
    const elementWithId = onPostPage() ? thumb : thumb.children[0];
    return elementWithId.id.substring(1);
  }

  /**
   * @type {HTMLDivElement}
   */
  root;
  /**
   * @type {String}
   */
  id;
  /**
   * @type {HTMLElement}
   */
  container;
  /**
   * @type {HTMLImageElement}
   */
  image;
  /**
   * @type {String}
   */
  tags;
  /**
   * @type {PostTags}
   */
  postTags;
  /**
   * @type {HTMLButtonElement}
   */
  removeButton;

  /**
   * @type {String}
   */
  get removeURL() {
    return ThumbNode.baseURLs.remove + this.id;
  }

  /**
   * @type {String}
   */
  get href() {
    return ThumbNode.baseURLs.post + this.id;
  }

  /**
   * @type {String[]}
   */
  get tagList() {
    return this.tags.split(" ");
  }

  /**
   * @type {{id: String, tags: String, src: String}}
   */
  get databaseRecord() {
    return {
      id: this.id,
      tags: this.tags,
      src: this.compressedThumbSource
    };
  }

  /**
   * @type {String}
   */
  get compressedThumbSource() {
    return this.image.src.match(ThumbNode.thumbSourceExtractionRegex).splice(1).join("_");
  }

  /**
   * @type {Boolean}
   */
  get isVisible() {
    return this.root.style.display !== "none";
  }

  /**
   * @param {HTMLElement | {id: String, tags: String, src: String, type: String}} thumb
   * @param {Boolean} fromRecord
   */
  constructor(thumb, fromRecord) {
    this.instantiateTemplate();
    this.populateAttributes(thumb, fromRecord);
    this.setupRemoveButton();
    this.setupOnClickLink();
  }

  instantiateTemplate() {
    this.root = THUMB_NODE_TEMPLATE.cloneNode(true);
    this.container = this.root.children[0];
    this.image = this.root.children[0].children[0];
    this.removeButton = this.root.children[0].children[1];
  }

  setupRemoveButton() {
    if (userIsOnTheirOwnFavoritesPage()) {
      this.removeButton.onclick = (event) => {
        event.stopPropagation();
        setIdToBeRemovedOnReload(this.id);
        fetch(this.removeURL);
        this.removeButton.remove();
      };
    }
  }

  /**
   * @param {HTMLElement | {id: String, tags: String, src: String, type: String}} thumb
   * @param {Boolean} fromDatabaseRecord
   */
  populateAttributes(thumb, fromDatabaseRecord) {
    if (fromDatabaseRecord) {
      this.createFromDatabaseRecord(thumb);
    } else {
      this.createFromHTMLElement(thumb);
    }
    this.root.id = this.id;
    this.image.setAttribute("tags", this.tags);
    this.postTags = new PostTags(this.tags);
  }

  /**
   * @param {{id: String, tags: String, src: String, type: String}} record
   */
  createFromDatabaseRecord(record) {
    this.image.src = ThumbNode.decompressThumbSource(record.src, record.id);
    this.id = record.id;
    this.tags = record.tags;
    this.image.classList.add(record.type);
  }

  /**
   * @param {HTMLElement} thumb
   */
  createFromHTMLElement(thumb) {
    const imageElement = thumb.children[0].children[0];

    this.image.src = imageElement.src;
    this.id = ThumbNode.getIdFromThumb(thumb);
    this.tags = `${correctMisspelledTags(imageElement.title)} ${this.id}`;
    this.image.classList.add(getContentType(this.tags));
  }

  setupOnClickLink() {
    if (usingRenderer()) {
      this.container.setAttribute("href", this.href);
    } else {
      this.container.onclick = () => {
        window.open(this.href, "_blank");
      };
      this.container.addEventListener("mousedown", (event) => {
        const middleClick = 1;

        if (event.button === middleClick) {
          event.preventDefault();
          window.open(this.href, "_blank");
        }
      });
    }
  }

  /**
   * @param {HTMLElement} element
   * @param {String} position
   */
  insertInDocument(element, position) {
    element.insertAdjacentElement(position, this.root);
  }

  /**
   * @param {Boolean} value
   */
  toggleVisibility(value) {
    this.root.style.display = value ? "" : "none";
  }
}


// load.js

class FavoritesLoader {
  static loadState = {
    notStarted: 0,
    started: 1,
    finished: 2,
    indexedDB: 3
  };
  static objectStoreName = `user${getFavoritesPageId()}`;
  static databaseName = "Favorites";
  static webWorkers = {
    database:
`
/* eslint-disable prefer-template */
/**
 * @param {Number} milliseconds
 * @returns {Promise}
 */
function sleep(milliseconds) {
  return new Promise(resolve => setTimeout(resolve, milliseconds));
}

class FavoritesDatabase {
  /**
   * @type {String}
   */
  name = "Favorites";
  /**
   * @type {String}
   */
  objectStoreName;
  /**
   * @type {Number}
   */
  version;

  /**
   * @param {String} objectStoreName
   * @param {Number | String} version
   */
  constructor(objectStoreName, version) {
    this.objectStoreName = objectStoreName;
    this.version = version;
    this.createObjectStore();
  }

  createObjectStore() {
    this.openConnection((event) => {
      /**
       * @type {IDBDatabase}
       */
      const database = event.target.result;
      const objectStore = database
        .createObjectStore(this.objectStoreName, {
          autoIncrement: true
        });

      objectStore.createIndex("id", "id", {
        unique: true
      });
    }).then((event) => {
      event.target.result.close();
    });
  }

  /**
   * @param {Function} onUpgradeNeeded
   * @returns {Promise}
   */
  openConnection(onUpgradeNeeded) {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open(this.name, this.version);

      request.onsuccess = resolve;
      request.onerror = reject;
      request.onupgradeneeded = onUpgradeNeeded;
    });
  }

  /**
   * @param {[{id: String, tags: String, src: String}]} favorites
   */
  storeFavorites(favorites) {
    this.openConnection()
      .then((event) => {
        const database = event.target.result;
        const favoritesObjectStore = database
          .transaction(this.objectStoreName, "readwrite")
          .objectStore(this.objectStoreName);

        favorites.forEach(favorite => {
          this.addContentTypeToFavorite(favorite);
          favoritesObjectStore.add(favorite);
          database.close();
        });

        postMessage({
          response: "finishedStoring"
        });
      })
      .catch((event) => {
        const error = event.target.error;
        const errorType = error.name;

        if (errorType === "VersionError") {
          this.version += 1;
          this.storeFavorites(favorites);
        } else {
          console.error(error);
        }
      });
  }

  /**
   * @param {String[]} idsToDelete
   */
  async loadFavorites(idsToDelete) {
    await this.openConnection()
      .then(async(event) => {
        const database = event.target.result;
        const objectStore = database
        .transaction(this.objectStoreName, "readwrite")
        .objectStore(this.objectStoreName);
      const index = objectStore.index("id");

      for (const id of idsToDelete) {
        const deleteRequest = index.getKey(id);

        await new Promise((resolve, reject) => {
          deleteRequest.onsuccess = resolve;
          deleteRequest.onerror = reject;
        }).then((event1) => {
          const primaryKey = event1.target.result;

          if (primaryKey !== undefined) {
            objectStore.delete(primaryKey);
          }
        });
      }
      objectStore.getAll().onsuccess = (successEvent) => {
        const results = successEvent.target.result.reverse();

        postMessage({
          response: "finishedLoading",
          favorites: results
        });
      };
      database.close();
      });

  }

  /**
   * @param {{id: String, tags: String, src: String}} favorite
   */
  addContentTypeToFavorite(favorite) {
    const tags = favorite.tags + " ";
    const isAnimated = tags.includes("animated ") || tags.includes("video ");
    const isGif = isAnimated && !tags.includes("video ");

    favorite.type = isGif ? "gif" : isAnimated ? "video" : "image";
  }
}

/**
 * @type {FavoritesDatabase}
 */
let favoritesDatabase;

onmessage = (message) => {
  const request = message.data;

  switch (request.command) {
    case "create":
      favoritesDatabase = new FavoritesDatabase(request.objectStoreName, request.version);
      break;

    case "store":
      favoritesDatabase.storeFavorites(request.favorites);
      break;

    case "load":
      favoritesDatabase.loadFavorites(request.deletedIds);
      break;

    default:
      break;
  }
};

`
  };
  static tagNegation = {
    useTagBlacklist: true,
    negatedTagBlacklist: negateTags(TAG_BLACKLIST)
  };
  /**
   * @type {ThumbNode[]}
   */
  allThumbNodes;
  /**
   * @type {Number}
   */
  finalPageNumber;
  /**
   * @type {HTMLLabelElement}
   */
  matchCountLabel;
  /**
   * @type {Number}
   */
  matchingFavoritesCount;
  /**
   * @type {Number}
   */
  maxNumberOfFavoritesToDisplay;
  /**
   * @type {[{url: String, indexToInsert: Number}]}
   */
  failedFetchRequests;
  /**
   * @type {LoadState}
   */
  currentLoadState;
  /**
   * @type {Number}
   */
  expectedFavoritesCount;
  /**
   * @type {Boolean}
   */
  expectedFavoritesCountFound;
  /**
   * @type {String}
   */
  searchQuery;
  /**
   * @type {Worker}
   */
  databaseWorker;
  /**
   * @type {Boolean}
   */
  searchResultsAreShuffled;
  /**
   * @type {HTMLTextAreaElement}
   */
  favoritesSearchInput;
  /**
   * @type {Number}
   */
  currentFavoritesPageNumber;

  /**
   * @type {Boolean}
   */
  get databaseAccessIsAllowed() {
    // return userIsOnTheirOwnFavoritesPage();
    return true;
  }

  /**
   * @type {String}
   */
  get finalSearchQuery() {
    if (FavoritesLoader.tagNegation.useTagBlacklist) {
      return `${this.searchQuery} ${FavoritesLoader.tagNegation.negatedTagBlacklist}`;
    }
    return this.searchQuery;
  }

  /**
   * @type {Boolean}
   */
  get matchCountLabelExists() {
    if (this.matchCountLabel === null) {
      this.matchCountLabel = document.getElementById("match-count-label");

      if (this.matchCountLabel === null) {
        return false;
      }
    }
    return true;
  }

  constructor() {
    if (onPostPage()) {
      return;
    }
    this.allThumbNodes = [];
    this.finalPageNumber = this.getFinalFavoritesPageNumber();
    this.matchCountLabel = document.getElementById("match-count-label");
    this.maxNumberOfFavoritesToDisplay = 100000;
    this.failedFetchRequests = [];
    this.currentLoadState = FavoritesLoader.loadState.notStarted;
    this.expectedFavoritesCount = 53;
    this.expectedFavoritesCountFound = false;
    this.matchingFavoritesCount = 0;
    this.searchQuery = "";
    this.databaseWorker = new Worker(getWorkerURL(FavoritesLoader.webWorkers.database));
    this.favoritesSearchInput = document.getElementById("favorites-search-box");
    this.currentFavoritesPageNumber = 0;
    this.createDatabaseMessageHandler();
    this.loadFavoritesPage();
  }

  createDatabaseMessageHandler() {
    this.databaseWorker.onmessage = (message) => {
      message = message.data;

      switch (message.response) {
        case "finishedLoading":
          this.currentLoadState = FavoritesLoader.loadState.indexedDB;
          this.attachSavedFavoritesToDocument(message.favorites);
          this.updateSavedFavorites();
          break;

          case "finishedStoring":
            setTimeout(() => {
              this.databaseWorker.terminate();
            }, 5000);

        default:
          break;
      }
    };
  }

  loadFavoritesPage() {
    this.clearContent();
    this.setFavoritesCount();
    this.searchFavorites();
  }

  setFavoritesCount() {
    const profilePage = `https://rule34.xxx/index.php?page=account&s=profile&id=${getFavoritesPageId()}`;

    fetch(profilePage)
      .then((response) => {
        if (response.ok) {
          return response.text();
        }
        throw new Error(response.status);
      })
      .then((html) => {
        const table = new DOMParser().parseFromString(`<div>${html}</div>`, "text/html").querySelector("table");

        if (table === null) {
          return;
        }

        for (const row of table.querySelectorAll("tr")) {
          const cells = row.querySelectorAll("td");

          if (cells.length >= 2 && cells[0].textContent.trim() === "Favorites") {
            this.expectedFavoritesCountFound = true;
            this.expectedFavoritesCount = parseInt(cells[1].textContent.trim());
            return;
          }
        }
      })
      .catch((error) => {
        console.error(error);
      });
  }

  clearContent() {
    const thumbs = Array.from(document.getElementsByClassName("thumb"));

    setTimeout(() => {
      dispatchEvent(new CustomEvent("originalContentCleared", {
        detail: thumbs
      }));
    }, 1000);
    document.getElementById("content").innerHTML = "";
  }

  /**
   * @param {String} searchQuery
   */
  searchFavorites(searchQuery) {
    this.searchQuery = searchQuery === undefined ? this.searchQuery : searchQuery;
    this.hideAwesomplete();
    this.resetMatchCount();
    dispatchEvent(new Event("searchStarted"));

    switch (this.currentLoadState) {
      case FavoritesLoader.loadState.started:
        this.showSearchResultsAfterStartedLoading();
        break;

      case FavoritesLoader.loadState.finished:
        this.showSearchResultsAfterFinishedLoading();
        break;

      case FavoritesLoader.loadState.indexedDB:
        break;

      default:
        this.showSearchResultsBeforeStartedLoading();
    }
  }

  showSearchResultsAfterStartedLoading() {
    const resultIds = this.getSearchResultIds(this.allThumbNodes, this.allThumbNodes.length - 1);

    for (const thumbNode of this.allThumbNodes) {
      if (resultIds[thumbNode.id] === undefined) {
        thumbNode.toggleVisibility(false);
      } else {
        thumbNode.toggleVisibility(true);
        this.incrementMatchCount();
      }
    }
  }

  showSearchResultsAfterFinishedLoading() {
    const resultIds = this.getSearchResultIds(this.allThumbNodes);
    let addedFavoritesCount = 0;

    this.unShuffleSearchResults();

    for (const thumbNode of this.allThumbNodes) {
      if (resultIds[thumbNode.id] === undefined || addedFavoritesCount >= this.maxNumberOfFavoritesToDisplay) {
        thumbNode.toggleVisibility(false);
      } else {
        thumbNode.toggleVisibility(true);
        this.incrementMatchCount();
        addedFavoritesCount += 1;
      }
    }
    dispatchEventWithDelay("finishedSearching");
  }

  async showSearchResultsBeforeStartedLoading() {
    if (!this.databaseAccessIsAllowed) {
      this.fetchFavorites();
      return;
    }
    const databaseStatus = await this.getDatabaseStatus();

    this.databaseWorker.postMessage({
      command: "create",
      objectStoreName: FavoritesLoader.objectStoreName,
      version: databaseStatus.version
    });
    const eventThatFavoritesBecomeVisibleAt = databaseStatus.objectStoreExists ? "favoritesLoaded" : "load";

    this.broadcastThumbUnderCursorOnLoadWhenAvailable(eventThatFavoritesBecomeVisibleAt);

    if (databaseStatus.objectStoreIsNotEmpty) {
      this.loadFavorites();
    } else {
      this.fetchFavorites();
    }
  }

  /**
   * @returns {{version: Number, objectStoreIsNotEmpty: Boolean}}
   */
  getDatabaseStatus() {
    return window.indexedDB.databases()
      .then((rule34Databases) => {
        const favoritesDatabases = rule34Databases.filter(database => database.name === FavoritesLoader.databaseName);

        if (favoritesDatabases.length !== 1) {
          return {
            version: 1,
            objectStoreIsNotEmpty: false
          };
        }
        const foundDatabase = favoritesDatabases[0];
        return new Promise((resolve, reject) => {
          const databaseRequest = indexedDB.open(FavoritesLoader.databaseName, foundDatabase.version);

          databaseRequest.onsuccess = resolve;
          databaseRequest.onerror = reject;
        }).then((event) => {
          const database = event.target.result;
          const objectStoreExists = database.objectStoreNames.contains(FavoritesLoader.objectStoreName);
          const version = database.version;

          if (!objectStoreExists) {
            database.close();
            return {
              version: database.version + 1,
              objectStoreIsNotEmpty: false
            };
          }
          const countRequest = database
            .transaction(FavoritesLoader.objectStoreName, "readonly")
            .objectStore(FavoritesLoader.objectStoreName).count();
          return new Promise((resolve, reject) => {
            countRequest.onsuccess = resolve;
            countRequest.onerror = reject;
          }).then((countEvent) => {
            database.close();
            return {
              version,
              objectStoreIsNotEmpty: countEvent.target.result > 0
            };
          });
        });
      });
  }

  /**
   * @param {ThumbNode[]} thumbNodes
   * @param {Number} stopIndex
   * @returns {ThumbNode[]}
   */
  getSearchResults(thumbNodes, stopIndex) {
    const searchCommand = getSearchCommand(this.finalSearchQuery);
    const results = [];

    stopIndex = stopIndex === undefined ? thumbNodes.length : stopIndex;
    stopIndex = Math.min(stopIndex, thumbNodes.length);

    for (let i = 0; i < stopIndex; i += 1) {
      if (postTagsMatchSearch(searchCommand, thumbNodes[i].postTags)) {
        results.push(thumbNodes[i]);
      }
    }
    return results;
  }

  /**
   * @param {ThumbNode[]} thumbNodes
   * @param {Number} stopIndex
   * @returns {Object.<String, Number>}
   */
  getSearchResultIds(thumbNodes, stopIndex) {
    const results = {};

    for (const thumbNode of this.getSearchResults(thumbNodes, stopIndex)) {
      results[thumbNode.id] = 0;
    }
    return results;
  }

  fetchNewFavoritesWithoutReloadingPage() {
    const previousFavoriteCount = this.expectedFavoritesCount;
    let currentFavoritesCount = 0;

    this.setFavoritesCount();
    setTimeout(() => {
      currentFavoritesCount = getIdsToRemoveOnReload().length + this.expectedFavoritesCount;
      const newFavoritesCount = currentFavoritesCount - previousFavoriteCount;

      if (newFavoritesCount > 0) {
        this.updateSavedFavorites();
        this.setProgressText(`Fetching ${newFavoritesCount} new favorite${newFavoritesCount > 1 ? "s" : ""}`);
        this.showProgressText(true);
      }
    }, 800);
  }

  updateSavedFavorites() {
    setTimeout(() => {
      this.addNewFavoritesToSavedFavorites(this.getAllFavoriteIds(), 0, []);
    }, 100);
  }

  /**
   * @param {Object.<string, ThumbNode>} allFavoriteIds
   * @param {Number} currentPageNumber
   * @param {ThumbNode[]} newFavoritesToAdd
   */
  addNewFavoritesToSavedFavorites(allFavoriteIds, currentPageNumber, newFavoritesToAdd) {
    const favoritesPageURL = `${document.location.href}&pid=${currentPageNumber}`;
    let allNewFavoritesFound = false;

    requestPageInformation(favoritesPageURL, (response) => {
      const thumbNodes = this.extractThumbNodesFromFavoritesPage(response);

      for (const thumbNode of thumbNodes) {
        const favoriteIsNotNew = allFavoriteIds[thumbNode.id] !== undefined;

        if (favoriteIsNotNew) {
          allNewFavoritesFound = true;
          break;
        }
        newFavoritesToAdd.push(thumbNode);
      }

      if (!allNewFavoritesFound && currentPageNumber < this.finalPageNumber) {
        this.addNewFavoritesToSavedFavorites(allFavoriteIds, currentPageNumber + 50, newFavoritesToAdd);
      } else {
        this.allThumbNodes = newFavoritesToAdd.concat(this.allThumbNodes);
        this.finishUpdatingSavedFavorites(newFavoritesToAdd);
      }
    });
  }

  /**
   * @param {ThumbNode[]} newThumbNodes
   */
  finishUpdatingSavedFavorites(newThumbNodes) {
    if (newThumbNodes.length > 0) {
      this.insertNewFavoritesAfterReloadingPage(newThumbNodes);
      this.storeFavorites(newThumbNodes);
      this.toggleLoadingUI(false);
    } else {
      this.databaseWorker.terminate();
    }
    this.updateMatchCount(getAllVisibleThumbs().length);
  }

  async fetchFavorites() {
    let currentPageNumber = 0;

    this.currentLoadState = FavoritesLoader.loadState.started;
    this.toggleContentVisibility(true);
    setTimeout(() => {
      dispatchEvent(new Event("startedFetchingFavorites"));
    }, 50);

    while (this.currentLoadState === FavoritesLoader.loadState.started) {
      await this.fetchFavoritesStep(currentPageNumber * 50);
      let progressText = `Saving Favorites ${this.allThumbNodes.length}`;

      if (this.expectedFavoritesCountFound) {
        progressText = `${progressText} / ${this.expectedFavoritesCount}`;
      }
      this.setProgressText(progressText);
      currentPageNumber += 1;
    }
  }

  /**
   * @param {Number} currentPageNumber
   */
  async fetchFavoritesStep(currentPageNumber) {
    let finishedLoading = this.allThumbNodes.length >= this.expectedFavoritesCount - 2;

    finishedLoading = finishedLoading || currentPageNumber >= (this.finalPageNumber * 2) + 1;
    finishedLoading = finishedLoading && this.failedFetchRequests.length === 0;

    if (currentPageNumber <= this.finalPageNumber) {
      await this.fetchFavoritesFromSinglePage(currentPageNumber);
    } else if (this.failedFetchRequests.length > 0) {
      const failedRequest = this.failedFetchRequests.shift();

      await this.fetchFavoritesFromSinglePage(currentPageNumber, failedRequest);
    } else if (finishedLoading) {
      this.onAllFavoritesLoaded();
      this.storeFavorites();
    }
  }

  /**
   * @param {String} html
   * @returns {{thumbNodes: ThumbNode[], searchResults: ThumbNode[]}}
   */
  extractFavoritesPage(html) {
    const thumbNodes = this.extractThumbNodesFromFavoritesPage(html);
    const searchResults = this.getSearchResults(thumbNodes);
    return {
      thumbNodes,
      searchResults
    };
  }

  /**
   * @param {Number} pageNumber
   * @param {{url: String, indexToInsert: Number}} failedRequest
   */
  fetchFavoritesFromSinglePage(pageNumber, failedRequest) {
    const refetching = failedRequest !== undefined;
    const favoritesPageURL = refetching ? failedRequest.url : `${document.location.href}&pid=${pageNumber}`;
    return fetch(favoritesPageURL)
      .then((response) => {
        if (response.ok) {
          return response.text();
        }
        failedRequest = refetching ? failedRequest : this.getFailedFetchRequest(response, pageNumber);
        this.failedFetchRequests.push(failedRequest);
        throw new Error(response.status);
      })
      .then((html) => {
        const {thumbNodes, searchResults} = this.extractFavoritesPage(html);

        setTimeout(() => {
          dispatchEvent(new CustomEvent("favoritesFetched", {
            detail: thumbNodes.map(thumbNode => thumbNode.root)
          }));
        }, 250);

        if (refetching) {
          this.allThumbNodes.splice(failedRequest.indexToInsert, 0, ...thumbNodes);
        } else {
          this.allThumbNodes = this.allThumbNodes.concat(thumbNodes);
        }

        if (this.allThumbNodes.length < this.maxNumberOfFavoritesToDisplay) {
          this.incrementMatchCount(searchResults.length);
          this.addFavoritesToContent(searchResults, failedRequest);
        }
      })
      .catch((error) => {
        console.error(error);
      });
  }

  /**
   * @param {String} response
   * @param {Number} pageNumber
   * @returns {{url: String, indexToInsert: Number}}
   */
  getFailedFetchRequest(response, pageNumber) {
    return {
      url: response.url,
      indexToInsert: pageNumber - 1
    };
  }

  /**
   * @param {String} response
   * @returns {ThumbNode[]}
   */
  extractThumbNodesFromFavoritesPage(response) {
    const dom = new DOMParser().parseFromString(`<div>${response}</div>`, "text/html");
    return Array.from(dom.getElementsByClassName("thumb")).map(thumb => new ThumbNode(thumb, false));
  }

  invertSearchResults() {
    this.resetMatchCount();
    let addedFavoritesCount = 0;

    for (const thumbNode of this.allThumbNodes) {
      if (addedFavoritesCount < this.maxNumberOfFavoritesToDisplay && !thumbNode.isVisible) {
        thumbNode.toggleVisibility(true);
        this.incrementMatchCount();
        addedFavoritesCount += 1;
      } else {
        thumbNode.toggleVisibility(false);
      }
    }
    window.scrollTo(0, 0);
    dispatchEventWithDelay("finishedSearching");
  }

  shuffleSearchResults() {
    const thumbs = Array.from(getAllVisibleThumbs());
    const content = document.getElementById("content");

    shuffleArray(thumbs);

    for (const thumb of thumbs) {
      content.insertBefore(thumb, content.firstChild);
    }
    this.searchResultsAreShuffled = true;
    dispatchEventWithDelay("shuffle");
  }

  unShuffleSearchResults() {
    if (!this.searchResultsAreShuffled) {
      return;
    }
    const content = document.getElementById("content");

    for (const thumbNode of this.allThumbNodes) {
      content.appendChild(thumbNode.root);
    }
    this.searchResultsAreShuffled = false;
  }

  onAllFavoritesLoaded() {
    this.currentLoadState = FavoritesLoader.loadState.finished;
    this.toggleLoadingUI(false);
    dispatchEventWithDelay("favoritesLoaded");
  }

  /**
   * @param {Boolean} value
   */
  toggleLoadingUI(value) {
    this.showLoadingWheel(value);
    this.toggleContentVisibility(!value);
    this.setProgressText(value ? "Loading Favorites" : "All Favorites Loaded");

    if (!value) {
      setTimeout(() => {
        this.showProgressText(false);
      }, 500);
    }
  }

  /**
   * @param {[{id: String, tags: String, src: String}]} databaseRecords
   * @returns {{ content: HTMLElement | null, searchResults: ThumbNode[]}}
   */
  reconstructContent(databaseRecords) {
    if (databaseRecords === null) {
      return null;
    }
    const dom = new DOMParser().parseFromString("<div id=\"content\"></div>", "text/html");
    const content = dom.getElementById("content");
    const searchCommand = getSearchCommand(this.finalSearchQuery);
    const searchResults = [];

    let addedFavoritesCount = 0;

    for (const record of databaseRecords) {
      const thumbNode = new ThumbNode(record, true);
      const underMaximumFavorites = addedFavoritesCount < this.maxNumberOfFavoritesToDisplay;
      const isBlacklisted = !postTagsMatchSearch(searchCommand, thumbNode.postTags);

      if (isBlacklisted) {
        if (userIsOnTheirOwnFavoritesPage()) {
          thumbNode.toggleVisibility(false);
        } else {
          continue;
        }
      } else {
        searchResults.push(thumbNode);
      }
      this.allThumbNodes.push(thumbNode);

      if (underMaximumFavorites) {

        addedFavoritesCount += 1;
        content.appendChild(thumbNode.root);
      }
    }
    return {
      content,
      searchResults
    };
  }

  loadFavorites() {
    this.toggleLoadingUI(true);
    let recentlyRemovedFavoriteIds = [];

    if (this.databaseAccessIsAllowed && userIsOnTheirOwnFavoritesPage()) {
      recentlyRemovedFavoriteIds = getIdsToRemoveOnReload();
      clearRecentlyRemovedIds();
    }
    this.databaseWorker.postMessage({
      command: "load",
      deletedIds: recentlyRemovedFavoriteIds
    });
  }

  /**
   * @param {ThumbNode[]} thumbNodes
   */
  storeFavorites(thumbNodes) {
    if (!this.databaseAccessIsAllowed) {
      return;
    }
    const storeAll = thumbNodes === undefined;

    thumbNodes = storeAll ? this.allThumbNodes : thumbNodes;
    const records = thumbNodes.map(thumbNode => thumbNode.databaseRecord);

    if (storeAll) {
      records.reverse();
    }
    this.databaseWorker.postMessage({
      command: "store",
      favorites: records
    });
  }

  hideAwesomplete() {
    if (this.favoritesSearchInput === null) {
      this.favoritesSearchInput = document.getElementById("favorites-search-box");
    }

    if (this.favoritesSearchInput === null) {
      return;
    }
    this.favoritesSearchInput.blur();
    setTimeout(() => {
      this.favoritesSearchInput.focus();
    }, 100);
  }

  /**
   * @returns {Number}
   */
  getFinalFavoritesPageNumber() {
    const lastPage = document.getElementsByName("lastpage")[0];

    if (lastPage === undefined) {
      return 0;
    }
    return parseInt(lastPage.getAttribute("onclick").match(/pid=([0-9]*)/)[1]);
  }

  deletePersistentData() {
    localStorage.clear();
    indexedDB.deleteDatabase("Favorites");
  }

  /**
   * @param {Boolean} value
   */
  showLoadingWheel(value) {
    document.getElementById("loading-wheel").style.display = value ? "flex" : "none";
  }

  /**
   * @param {Boolean} value
   */
  showProgressText(value) {
    document.getElementById("favorites-fetch-progress-label").style.display = value ? "inline-block" : "none";
  }

  /**
   * @param {String} text
   */
  setProgressText(text) {
    document.getElementById("favorites-fetch-progress-label").textContent = text;
  }

  resetMatchCount() {
    this.updateMatchCount(0);
  }

  /**
   * @param {Boolean} value
   */
  updateMatchCount(value) {
    if (!this.matchCountLabelExists) {
      return;
    }
    this.matchingFavoritesCount = value === undefined ? this.getSearchResults(this.allThumbNodes).length : value;
    this.matchCountLabel.textContent = `${this.matchingFavoritesCount} Matches`;
  }

  /**
   * @param {Boolean} value
   */
  incrementMatchCount(value) {
    if (!this.matchCountLabelExists) {
      return;
    }
    this.matchingFavoritesCount += value === undefined ? 1 : value;
    this.matchCountLabel.textContent = `${this.matchingFavoritesCount} Matches`;
  }

  /**
   * @param {Boolean} value
   */
  toggleContentVisibility(value) {
    document.getElementById("content").style.display = value ? "" : "none";
  }

  /**
   * @param {[{id: String, tags: String, src: String}]} databaseRecords
   */
  attachSavedFavoritesToDocument(databaseRecords) {
    const {content, searchResults} = this.reconstructContent(databaseRecords);

    document.getElementById("content").remove();
    document.body.appendChild(content);
    this.paginateSearchResults(searchResults, false);
    this.updateMatchCount(getAllVisibleThumbs().length);
    this.onAllFavoritesLoaded();
  }

  /**
   * @param {ThumbNode[]} newThumbNodes
   */
  insertNewFavoritesAfterReloadingPage(newThumbNodes) {
    const content = document.getElementById("content");
    const searchCommand = getSearchCommand(this.searchQuery);

    newThumbNodes.reverse();

    for (const thumbNode of newThumbNodes) {
      thumbNode.insertInDocument(content, "afterbegin");

      if (!postTagsMatchSearch(searchCommand, thumbNode.postTags)) {
        thumbNode.toggleVisibility(false);
      }
    }
    setTimeout(() => {
      dispatchEvent(new CustomEvent("newFavoritesFetchedOnReload", {
        detail: newThumbNodes.map(thumbNode => thumbNode.root)
      }));
    }, 250);
  }

  /**
   * @param {ThumbNode[]} thumbNodes
   * @param {{url: String, indexToInsert: Number}} failedRequest
   */
  addFavoritesToContent(thumbNodes, failedRequest) {
    const content = document.getElementById("content");
    let elementToInsertAround = content;
    let placeToInsert = "beforeend";

    if (failedRequest !== undefined) {
      elementToInsertAround = getAllThumbs()[failedRequest.indexToInsert];
      placeToInsert = "afterend";
      thumbNodes = Array.from(thumbNodes).reverse();
    }

    for (const thumbNode of thumbNodes) {
      thumbNode.insertInDocument(elementToInsertAround, placeToInsert);
    }
  }

  /**
   * @returns {Object.<String, ThumbNode>}
   */
  getVisibleFavoriteIds() {
    const ids = {};

    for (const thumbNode of this.allThumbNodes) {
      if (thumbNode.isVisible) {
        ids[thumbNode.id] = thumbNode;
      }
    }
    return ids;
  }

  /**
   * @returns {Object.<String, ThumbNode>}
   */
  getAllFavoriteIds() {
    const favoriteIds = {};

    for (const thumbNode of this.allThumbNodes) {
      favoriteIds[thumbNode.id] = thumbNode;
    }
    return favoriteIds;
  }

  /**
   * @param {String} eventName
   */
  broadcastThumbUnderCursorOnLoadWhenAvailable(eventName) {
    window.addEventListener(eventName, () => {
      setTimeout(() => {
        getThumbUnderCursorOnLoad();
      }, 500);
    }, {
      once: true
    });
  }

  /**
   * @param {Boolean} value
   */
  toggleTagBlacklistExclusion(value) {
    FavoritesLoader.tagNegation.useTagBlacklist = value;
  }

  /**
   * @param {ThumbNode[]} searchResults
   */
  paginateSearchResults(searchResults, clearAllContent = true) {
    const favoritesPagination = document.getElementById("favorites-pagination-container");
    const content = document.getElementById("content");

    if (favoritesPagination !== null) {
      favoritesPagination.remove();
    }

    if (searchResults.length < this.maxNumberOfFavoritesToDisplay) {
      return;
    }
    const pageCount = Math.floor(searchResults.length / this.maxNumberOfFavoritesToDisplay) + 1;
    const favoritesPerPage = this.maxNumberOfFavoritesToDisplay;
    const placeToInsertPagination = document.getElementById("left-favorites-panel-top-row");
    const container = document.createElement("span");

    if (clearAllContent) {
      content.innerHTML = "";
    }
    container.id = "favorites-pagination-container";
    placeToInsertPagination.appendChild(container);
    this.currentFavoritesPageNumber = 1;

    for (let pageNumber = 1; pageNumber <= pageCount; pageNumber += 1) {
      const isMiddlePage = pageNumber > 2 && pageNumber < pageCount;

      if (isMiddlePage) {
        continue;
      }
      const pageNavigationButton = document.createElement("button");

      pageNavigationButton.id = `favorites-page-${pageNumber}`;
      pageNavigationButton.onclick = () => {
        this.changeResultsPage(pageNumber, favoritesPerPage, searchResults, container);
      };
      container.appendChild(pageNavigationButton);
      pageNavigationButton.textContent = pageNumber;
    }
    this.createPageTraversalButtons(favoritesPerPage, searchResults, pageCount, container);
  }

  /**
   * @param {Number} favoritesPerPage
   * @param {ThumbNode[]} searchResults
   * @param {Number} pageCount
   * @param {HTMLElement} container
   */
  createPageTraversalButtons(favoritesPerPage, searchResults, pageCount, container) {
    const previousPage = document.createElement("button");
    const firstPage = document.createElement("button");
    const nextPage = document.createElement("button");
    const finalPage = document.createElement("button");

    previousPage.style.display = "none";
    firstPage.style.display = "none";
    previousPage.textContent = "<";
    firstPage.textContent = "<<";
    nextPage.textContent = ">";
    finalPage.textContent = ">>";
    previousPage.onclick = () => {
      this.changeResultsPage(this.currentFavoritesPageNumber - 1, favoritesPerPage, searchResults, container);
    };
    firstPage.onclick = () => {
      this.changeResultsPage(1, favoritesPerPage, searchResults, container);
    };
    nextPage.onclick = () => {
      this.changeResultsPage(this.currentFavoritesPageNumber + 1, favoritesPerPage, searchResults, container);
    };
    finalPage.onclick = () => {
      this.changeResultsPage(pageCount, favoritesPerPage, searchResults, container);
    };
    container.insertAdjacentElement("afterbegin", previousPage);
    container.insertAdjacentElement("afterbegin", firstPage);
    container.appendChild(nextPage);
    container.appendChild(finalPage);

  }

  /**
   * @param {Number} pageNumber
   * @param {Number} favoritesPerPage
   * @param {ThumbNode[]} searchResults
   * @param {HTMLElement} container
   */
  changeResultsPage(pageNumber, favoritesPerPage, searchResults, container) {
    const start = favoritesPerPage * (pageNumber - 1);
    const end = favoritesPerPage * pageNumber;
    const newThumbNodes = searchResults.slice(start, end);

    this.updateVisibilityOfPageTraversalButtons(end, searchResults, pageNumber, container);
    this.currentFavoritesPageNumber = pageNumber;
    content.innerHTML = "";

    for (const thumbNode of newThumbNodes) {
      content.appendChild(thumbNode.root);
    }
    window.scrollTo(0, 0);
    dispatchEventWithDelay("favoritesLoaded");
  }

  /**
   * @param {Number} end
   * @param {ThumbNode[]} searchResults
   * @param {Number} pageNumber
   * @param {HTMLElement} container
   */
  updateVisibilityOfPageTraversalButtons(end, searchResults, pageNumber, container) {
    const onFinalPage = end >= searchResults.length;
    const onFirstPage = pageNumber === 1;

    const pageButtons = Array.from(container.children);

    for (const element of pageButtons.slice(0, 2)) {
      element.style.display = onFirstPage ? "none" : "";
    }

    for (const element of pageButtons.slice(-2)) {
      element.style.display = onFinalPage ? "none" : "";
    }
  }
}

const favoritesLoader = new FavoritesLoader();


// ui.js

const uiHTML = `<div id="favorites-top-bar" class="light-green-gradient">
  <style>
    #favorites-top-bar {
      position: sticky;
      top: 0;
      padding: 10px;
      z-index: 30;
      margin-bottom: 10px;
      -webkit-touch-callout: none;
      -webkit-user-select: none;
      -khtml-user-select: none;
      -moz-user-select: none;
      -ms-user-select: none;
      user-select: none;

    }

    #favorites-top-bar-panels {
      >div {
        flex: 1;

        textarea {
          max-width: 100%;
          height: 50px;
          width: 95%;
          padding: 10px;
          border-radius: 6px;
          resize: vertical;
        }
      }
    }

    #left-favorites-panel {
      >div:first-of-type {
        margin-bottom: 5px;
        min-width: 560px;

        >label {
          align-content: center;
          margin-right: 5px;
          margin-top: 4px;
        }

        button {
          height: 35px;
          border: none;
          border-radius: 4px;
          cursor: pointer;

          &:hover {
            filter: brightness(140%);
          }
        }
      }
    }

    #right-favorites-panel {
      margin-left: 10px;
    }

    .checkbox {
      display: block;
      cursor: pointer;
      padding: 2px 6px 2px 0px;
      border-radius: 4px;
      margin-left: -3px;
      height: 27px;

      &:hover {
        color: #000;
        background: #93b393;
        text-shadow: none;
        cursor: pointer;
      }

      input {
        vertical-align: -5px;
        cursor: pointer;
      }

      input[type="checkbox"] {
        width: 20px;
        height: 20px;
      }
    }

    .loading-wheel {
      border: 16px solid #f3f3f3;
      border-top: 16px solid #3498db;
      border-radius: 50%;
      width: 120px;
      height: 120px;
      animation: spin 1s ease-in-out infinite;
      pointer-events: none;
      z-index: 9998;
      position: fixed;
      max-height: 100vh;
      margin: 0;
      top: 50%;
      left: 50%;
      transform: translate(-50%, -50%);
    }

    @keyframes spin {
      0% {
        transform: rotate(0deg);
      }

      100% {
        transform: rotate(360deg);
      }
    }

    .remove-button {
      position: absolute;
      left: 50%;
      transform: translateX(-50%);
      width: 100%;
      cursor: pointer;
      color: #0075FF;
      font-weight: bold;
      font-size: 20px;
      height: 40px;
      background: white;
      border: none;
      z-index: 2;
    }

    .thumb-node {
      position: relative;

      >a,
      >div {
        position: relative;

        &:has(.remove-button:hover) {
          outline: 3px solid red;

          >.remove-button {
            box-shadow: 0px 3px 0px 0px red;
            color: red;
          }
        }
      }

      img, canvas {
        width: 100%;
        z-index: 1;
      }

      &.hidden {
        display: none;
      }
    }

    .found {
      opacity: 1;
      animation: wiggle 2s;
    }

    @keyframes wiggle {

      10%,
      90% {
        transform: translate3d(-2px, 0, 0);
      }

      20%,
      80% {
        transform: translate3d(4px, 0, 0);
      }

      30%,
      50%,
      70% {
        transform: translate3d(-8px, 0, 0);
      }

      40%,
      60% {
        transform: translate3d(8px, 0, 0);
      }
    }

    #favorite-options-container {
      display: flex;
      flex-flow: row wrap;
      width: 60%;

      >div {
        flex: 1;
        padding-right: 6px;
        flex-basis: 45%;
      }
    }

    input::-webkit-outer-spin-button,
    input::-webkit-inner-spin-button {
      -webkit-appearance: none;
      margin: 0;
    }

    input[type="number"] {
      appearance: none;
      -moz-appearance: textfield;
      width: 15px;
    }

    #column-resize-container {
      margin-top: 10px;

      >span {
        >button {
          width: 20px;
          text-align: center;
          align-items: center;
        }
      }
    }

    #find-favorite {
      margin-top: 7px;

      >input {
        border-radius: 6px;
        height: 35px;
        width: 75px;
        border: 1px solid;
      }
    }

    #favorites-pagination-container {
      >button {
        background: transparent;
        margin: 0px 2px;
        padding: 2px 6px;
        border: 1px solid white;
        cursor: pointer;
        font-size: 14px;
        color: white;
        font-weight: normal;

        &:hover {
          background-color: #93b393;
        }

        &.selected {
          border: none;
          font-weight: bold;
        }
      }
    }

    #content {
      display: grid !important;
      grid-template-columns: repeat(10, 1fr);
      grid-gap: 1em;
    }

    #help-links-container {
      margin-top: 17px;
    }

    #left-favorites-panel-bottom-row {
      display: flex;
      flex-flow: row wrap;
      margin-top: 10px;
    }
  </style>
  <div id="favorites-top-bar-panels" style="display: flex;">
    <div id="left-favorites-panel">
      <h2 style="display: inline;">Search Favorites</h2>
      <span style="margin-left: 10px;">
        <label id="match-count-label"></label>
        <label id="favorites-fetch-progress-label" style="color: #3498db;"></label>
      </span>
      <div id="left-favorites-panel-top-row">
        <button title="Search favorites\nctrl+click: Search all posts" id="search-button">Search</button>
        <button title="Show results not matched by search" id="invert-button">Invert</button>
        <button title="Shuffle order of search results" id="shuffle-button">Shuffle</button>
        <button title="Clear the search box" id="clear-button">Clear</button>
        <button title="Reset saved favorites" id="reset-button">Reset</button>
        <span id="find-favorite" class="light-green-gradient" style="display: none;">
          <button title="Scroll to favorite using its ID" id="find-favorite-button"
            style="white-space: nowrap; ">Find</button>
          <input type="number" id="find-favorite-input" type="text" placeholder="ID">
        </span>
        <span id="help-links-container">
          <a href="https://github.com/bruh3396/favorites-search-gallery#controls" target="_blank">Help</a>
        </span>
      </div>
      <div>
        <textarea name="tags" id="favorites-search-box" placeholder="Search by Tags or IDs"
          spellcheck="false"></textarea>
      </div>

      <div id="left-favorites-panel-bottom-row" style="display: flex; flex-flow: row-wrap;">
        <div id="favorite-options-container">
          <div id="show-options"><label class="checkbox" title="Toggle options"><input type="checkbox"
                id="options-checkbox"> Options</label></div>
          <div id="favorite-options">
            <div><label class="checkbox" title="Toggle remove buttons"><input type="checkbox" id="show-remove-buttons">
                Remove Buttons</label></div>
            <div><label class="checkbox" title="Exclude blacklisted tags from search"><input type="checkbox"
                  id="filter-blacklist-checkbox"> Exclude Blacklist</label></div>
          </div>
          <div id="additional-favorite-options">
            <div id="column-resize-container">
              <span>
                <label>Columns</label>
                <button id="column-resize-minus">-</button>
                <input type="number" id="column-resize-input" min="2" max="20">
                <button id="column-resize-plus">+</button>
              </span>
            </div>
          </div>
        </div>
        <div id="show-ui-container" style="width: 35%;">
          <div id="show-ui-div" style="max-width: 400px;"><label class="checkbox" title="Toggle UI"><input type="checkbox" id="show-ui">UI</label></div>
        </div>
      </div>
    </div>
    <div id="right-favorites-panel" style="flex: 2;"></div>
  </div>
  <div class="loading-wheel" id="loading-wheel" style="display: none;"></div>
</div>
`;

if (!onPostPage()) {
  document.getElementById("content").insertAdjacentHTML("beforebegin", uiHTML);
}
const FAVORITE_OPTIONS = [document.getElementById("favorite-options"), document.getElementById("additional-favorite-options")];
const MAX_SEARCH_HISTORY_LENGTH = 100;
const FAVORITE_SEARCH_PREFERENCES = {
  textareaWidth: "searchTextareaWidth",
  textareaHeight: "searchTextareaHeight",
  showRemoveButtons: "showRemoveButtons",
  showOptions: "showOptions",
  filterBlacklist: "filterBlacklistCheckbox",
  searchHistory: "favoritesSearchHistory",
  findFavorite: "findFavorite",
  thumbSize: "thumbSize",
  columnCount: "columnCount",
  showUI: "showUI"
};
const FAVORITE_SEARCH_LOCAL_STORAGE = {
  searchHistory: "favoritesSearchHistory"
};
const FAVORITE_SEARCH_BUTTONS = {
  search: document.getElementById("search-button"),
  shuffle: document.getElementById("shuffle-button"),
  clear: document.getElementById("clear-button"),
  invert: document.getElementById("invert-button"),
  reset: document.getElementById("reset-button"),
  findFavorite: document.getElementById("find-favorite-button"),
  columnPlus: document.getElementById("column-resize-plus"),
  columnMinus: document.getElementById("column-resize-minus")
};
const FAVORITE_SEARCH_CHECKBOXES = {
  showOptions: document.getElementById("options-checkbox"),
  showRemoveButtons: document.getElementById("show-remove-buttons"),
  filterBlacklist: document.getElementById("filter-blacklist-checkbox"),
  showUI: document.getElementById("show-ui")
};
const FAVORITE_SEARCH_INPUTS = {
  searchBox: document.getElementById("favorites-search-box"),
  findFavorite: document.getElementById("find-favorite-input"),
  columnCount: document.getElementById("column-resize-input")
};
const FAVORITE_SEARCH_LABELS = {
  findFavorite: document.getElementById("find-favorite-label")
};
let searchHistory = [];
let searchHistoryIndex = 0;
let lastSearchQuery = "";

function initializeFavoritesPage() {
  addEventListenersToFavoritesPage();
  loadFavoritesPagePreferences();
  removePaginatorFromFavoritesPage();
  hideRemoveLinksWhenNotOnOwnFavoritesPage();
}

function loadFavoritesPagePreferences() {
  const height = getPreference(FAVORITE_SEARCH_PREFERENCES.textareaHeight);
  const width = getPreference(FAVORITE_SEARCH_PREFERENCES.textareaWidth);

  if (height !== null && width !== null) {
    /*
     * FAVORITE_SEARCH_INPUTS.searchBox.style.width = width + "px"
     * FAVORITE_SEARCH_INPUTS.searchBox.style.height = height + "px"
     */
  }
  const removeButtonsAreVisible = getPreference(FAVORITE_SEARCH_PREFERENCES.showRemoveButtons, false) && userIsOnTheirOwnFavoritesPage();

  FAVORITE_SEARCH_CHECKBOXES.showRemoveButtons.checked = removeButtonsAreVisible;
  setTimeout(() => {
    updateVisibilityOfAllRemoveButtons();
  }, 100);
  const showOptions = getPreference(FAVORITE_SEARCH_PREFERENCES.showOptions, false);

  FAVORITE_SEARCH_CHECKBOXES.showOptions.checked = showOptions;
  toggleFavoritesOptions(showOptions);

  if (userIsOnTheirOwnFavoritesPage()) {
    FAVORITE_SEARCH_CHECKBOXES.filterBlacklist.checked = getPreference(FAVORITE_SEARCH_PREFERENCES.filterBlacklist, false);
    favoritesLoader.toggleTagBlacklistExclusion(FAVORITE_SEARCH_CHECKBOXES.filterBlacklist.checked);
  } else {
    FAVORITE_SEARCH_CHECKBOXES.filterBlacklist.checked = true;
    FAVORITE_SEARCH_CHECKBOXES.filterBlacklist.parentElement.style.display = "none";
  }
  searchHistory = JSON.parse(localStorage.getItem(FAVORITE_SEARCH_LOCAL_STORAGE.searchHistory)) || [];

  if (searchHistory.length > 0) {
    FAVORITE_SEARCH_INPUTS.searchBox.value = searchHistory[0];
  }
  FAVORITE_SEARCH_INPUTS.findFavorite.value = getPreference(FAVORITE_SEARCH_PREFERENCES.findFavorite, "");
  FAVORITE_SEARCH_INPUTS.columnCount.value = getPreference(FAVORITE_SEARCH_PREFERENCES.columnCount, 6);
  changeColumnCount(FAVORITE_SEARCH_INPUTS.columnCount.value);

  const showUI = getPreference(FAVORITE_SEARCH_PREFERENCES.showUI, true);

  FAVORITE_SEARCH_CHECKBOXES.showUI.checked = showUI;
  toggleUI(showUI);
}

function removePaginatorFromFavoritesPage() {
  const paginator = document.getElementById("paginator");
  const pi = document.getElementById("pi");

  if (paginator !== null) {
    paginator.style.display = "none";
  }

  if (pi !== null) {
    pi.remove();
  }
}

function addEventListenersToFavoritesPage() {
  FAVORITE_SEARCH_BUTTONS.search.onclick = (event) => {
    const query = FAVORITE_SEARCH_INPUTS.searchBox.value;

    if (event.ctrlKey) {
      const queryWithFormattedIds = query.replace(/(?:^|\s)(\d+)(?:$|\s)/g, " id:$1 ");
      const postPageURL = `https://rule34.xxx/index.php?page=post&s=list&tags=${encodeURIComponent(queryWithFormattedIds)}`;

      window.open(postPageURL);
    } else {
      favoritesLoader.searchFavorites(query);
      addToFavoritesSearchHistory(query);
    }
  };
  FAVORITE_SEARCH_INPUTS.searchBox.addEventListener("keydown", (event) => {
    switch (event.key) {
      case "Enter":
        if (awesompleteIsUnselected(FAVORITE_SEARCH_INPUTS.searchBox)) {
          event.preventDefault();
          FAVORITE_SEARCH_BUTTONS.search.click();
        } else {
          clearAwesompleteSelection(FAVORITE_SEARCH_INPUTS.searchBox);
        }
        break;

      case "ArrowUp":

      case "ArrowDown":
        if (awesompleteIsHidden(FAVORITE_SEARCH_INPUTS.searchBox)) {
          event.preventDefault();
          traverseFavoritesSearchHistory(event.key);
        } else {
          updateLastSearchQuery();
        }
        break;

      case "Tab":
        {
          event.preventDefault();
          const awesomplete = FAVORITE_SEARCH_INPUTS.searchBox.parentElement;
          const searchSuggestions = Array.from(awesomplete.querySelectorAll("li")) || [];

          if (!awesompleteIsUnselected(FAVORITE_SEARCH_INPUTS.searchBox)) {
            const selectedSearchSuggestion = searchSuggestions.find(suggestion => suggestion.getAttribute("aria-selected") === "true");

            completeSearchSuggestion(selectedSearchSuggestion);
          } else if (!awesompleteIsHidden(FAVORITE_SEARCH_INPUTS.searchBox)) {
            completeSearchSuggestion(searchSuggestions[0]);
          }
          break;
        }

      case "Escape":
        if (!awesompleteIsHidden(FAVORITE_SEARCH_INPUTS.searchBox)) {
          favoritesLoader.hideAwesomplete();
        }
        break;

      default:
        updateLastSearchQuery();
        break;
    }
  });
  FAVORITE_SEARCH_INPUTS.searchBox.addEventListener("wheel", (event) => {
    const direction = event.deltaY > 0 ? "ArrowDown" : "ArrowUp";

    traverseFavoritesSearchHistory(direction);
    event.preventDefault();
  });
  FAVORITE_SEARCH_CHECKBOXES.showOptions.onchange = () => {
    toggleFavoritesOptions(FAVORITE_SEARCH_CHECKBOXES.showOptions.checked);
    setPreference(FAVORITE_SEARCH_PREFERENCES.showOptions, FAVORITE_SEARCH_CHECKBOXES.showOptions.checked);
  };
  const resizeObserver = new ResizeObserver(entries => {
    for (const entry of entries) {
      setPreference(FAVORITE_SEARCH_PREFERENCES.textareaWidth, entry.contentRect.width);
      setPreference(FAVORITE_SEARCH_PREFERENCES.textareaHeight, entry.contentRect.height);
    }
  });

  resizeObserver.observe(FAVORITE_SEARCH_INPUTS.searchBox);
  FAVORITE_SEARCH_CHECKBOXES.showRemoveButtons.onchange = () => {
    updateVisibilityOfAllRemoveButtons();
    setPreference(FAVORITE_SEARCH_PREFERENCES.showRemoveButtons, FAVORITE_SEARCH_CHECKBOXES.showRemoveButtons.checked);
  };
  FAVORITE_SEARCH_BUTTONS.shuffle.onclick = () => {
    favoritesLoader.shuffleSearchResults();
  };
  FAVORITE_SEARCH_BUTTONS.clear.onclick = () => {
    FAVORITE_SEARCH_INPUTS.searchBox.value = "";
  };
  FAVORITE_SEARCH_CHECKBOXES.filterBlacklist.onchange = () => {
    setPreference(FAVORITE_SEARCH_PREFERENCES.filterBlacklist, FAVORITE_SEARCH_CHECKBOXES.filterBlacklist.checked);
    favoritesLoader.toggleTagBlacklistExclusion(FAVORITE_SEARCH_CHECKBOXES.filterBlacklist.checked);
    favoritesLoader.searchFavorites();
  };
  FAVORITE_SEARCH_BUTTONS.invert.onclick = () => {
    favoritesLoader.invertSearchResults();
  };
  FAVORITE_SEARCH_BUTTONS.reset.onclick = () => {
    favoritesLoader.deletePersistentData();
  };
  FAVORITE_SEARCH_INPUTS.findFavorite.addEventListener("keydown", (event) => {
    if (event.key === "Enter") {
      scrollToThumb(FAVORITE_SEARCH_INPUTS.findFavorite.value);
      setPreference(FAVORITE_SEARCH_PREFERENCES.findFavorite, postId);
    }
  });
  FAVORITE_SEARCH_BUTTONS.findFavorite.onclick = () => {
    scrollToThumb(FAVORITE_SEARCH_INPUTS.findFavorite.value);
    setPreference(FAVORITE_SEARCH_PREFERENCES.findFavorite, postId);
  };
  FAVORITE_SEARCH_BUTTONS.columnPlus.onclick = () => {
    changeColumnCount(parseInt(FAVORITE_SEARCH_INPUTS.columnCount.value) + 1);
  };
  FAVORITE_SEARCH_BUTTONS.columnMinus.onclick = () => {
    changeColumnCount(parseInt(FAVORITE_SEARCH_INPUTS.columnCount.value) - 1);
  };
  FAVORITE_SEARCH_INPUTS.columnCount.onchange = () => {
    changeColumnCount(parseInt(FAVORITE_SEARCH_INPUTS.columnCount.value));
  };

  FAVORITE_SEARCH_CHECKBOXES.showUI.onchange = () => {
    toggleUI(FAVORITE_SEARCH_CHECKBOXES.showUI.checked);
  };
}

function completeSearchSuggestion(suggestion) {
  suggestion = suggestion.innerText.replace(/ \([0-9]+\)$/, "");
  favoritesLoader.hideAwesomplete();
  FAVORITE_SEARCH_INPUTS.searchBox.value = FAVORITE_SEARCH_INPUTS.searchBox.value.replace(/\S+$/, `${suggestion} `);
}

function hideRemoveLinksWhenNotOnOwnFavoritesPage() {
  if (!userIsOnTheirOwnFavoritesPage()) {
    FAVORITE_SEARCH_CHECKBOXES.showRemoveButtons.parentElement.style.display = "none";
  }
}

function updateLastSearchQuery() {
  if (FAVORITE_SEARCH_INPUTS.searchBox.value !== lastSearchQuery) {
    lastSearchQuery = FAVORITE_SEARCH_INPUTS.searchBox.value;
  }
  searchHistoryIndex = -1;
}

/**
 * @param {String} newSearch
 */
function addToFavoritesSearchHistory(newSearch) {
  newSearch = newSearch.trim();
  searchHistory = searchHistory.filter(search => search !== newSearch);
  searchHistory.unshift(newSearch);
  searchHistory.length = Math.min(searchHistory.length, MAX_SEARCH_HISTORY_LENGTH);
  localStorage.setItem(FAVORITE_SEARCH_LOCAL_STORAGE.searchHistory, JSON.stringify(searchHistory));
}

/**
 * @param {String} direction
 */
function traverseFavoritesSearchHistory(direction) {
  if (searchHistory.length > 0) {
    if (direction === "ArrowUp") {
      searchHistoryIndex = Math.min(searchHistoryIndex + 1, searchHistory.length - 1);
    } else {
      searchHistoryIndex = Math.max(searchHistoryIndex - 1, -1);
    }

    if (searchHistoryIndex === -1) {
      FAVORITE_SEARCH_INPUTS.searchBox.value = lastSearchQuery;
    } else {
      FAVORITE_SEARCH_INPUTS.searchBox.value = searchHistory[searchHistoryIndex];
    }
  }
}

function toggleFavoritesOptions(value) {
  for (const option of FAVORITE_OPTIONS) {
    option.style.display = value ? "block" : "none";
  }
}

function changeColumnCount(count) {
  count = clamp(parseInt(count), 4, 20);
  injectStyleHTML(`
    #content {
      grid-template-columns: repeat(${count}, 1fr) !important;
    }
    `, "columnCount");
  FAVORITE_SEARCH_INPUTS.columnCount.value = count;
  setPreference(FAVORITE_SEARCH_PREFERENCES.columnCount, count);
}

/**
 * @param {Boolean} value
 */
function toggleUI(value) {
  const favoritesTopBar = document.getElementById("favorites-top-bar");
  const favoritesTopBarPanels = document.getElementById("favorites-top-bar-panels");
  const header = document.getElementById("header");
  const showUIContainer = document.getElementById("show-ui-container");
  const showUIDiv = document.getElementById("show-ui-div");

  if (value) {
    header.style.display = "";
    showUIContainer.appendChild(showUIDiv);
    favoritesTopBarPanels.style.display = "flex";
  } else {
    favoritesTopBar.appendChild(showUIDiv);
    header.style.display = "none";
    favoritesTopBarPanels.style.display = "none";
  }
  setPreference(FAVORITE_SEARCH_PREFERENCES.showUI, value);
}

async function findSomeoneWithMoreThanXFavorites(X) {
  const alreadyCheckedUserIds = {
    "2": null
  };
  const commentsAPIURL = "https://api.rule34.xxx/index.php?page=dapi&s=comment&q=index&post_id=";

  for (const thumb of getAllThumbs()) {
    const user = await fetch(commentsAPIURL + thumb.id)
      .then((response) => {
        return response.text();
      })
      .then(async(html) => {
        let userWithMostFavorites = 2;
        let mostFavoritesSeen = -1;
        const dom1 = new DOMParser().parseFromString(`<div>${html}</div>`, "text/html");
        const userIds = Array.from(dom1.getElementsByTagName("comment")).map(comment => comment.getAttribute("creator_id"));

        for (const userId of userIds) {
          if (alreadyCheckedUserIds[userId] !== undefined) {
            break;
          }
          alreadyCheckedUserIds[userId] = null;
          const favoritesCount = await fetch(`https://rule34.xxx/index.php?page=account&s=profile&id=${userId}`)
            .then((response) => {
              return response.text();
            })
            .then((responseHTML) => {
              const dom2 = new DOMParser().parseFromString(`<div>${responseHTML}</div>`, "text/html");
              const tableElement = dom2.querySelector("table");

              if (tableElement) {
                const rows = tableElement.querySelectorAll("tr");
                const targetItem = "Favorites";

                for (const row of rows) {
                  const cells = row.querySelectorAll("td");

                  if (cells.length >= 2 && cells[0].textContent.trim() === targetItem) {
                    return parseInt(cells[1].textContent.trim());
                  }
                }
              }
              return 0;
            });

          if (favoritesCount > mostFavoritesSeen) {
            mostFavoritesSeen = favoritesCount;
            userWithMostFavorites = userId;
          }
        }
        return {
          id: userWithMostFavorites,
          count: mostFavoritesSeen
        };
      });

    if (user.count > X) {
      alert(`https://rule34.xxx/index.php?page=account&s=profile&id=${user.id}`);
      return;
    }
  }
  alert(`Could not find user with more than ${X} favorites`);
}

if (!onPostPage()) {
  initializeFavoritesPage();
}


// render.js

const renderHTML = `<style>
  body {
    width: 99.5vw;
    overflow-x: hidden;
  }

  /* .thumb,
  .thumb-node {
    &.loaded {

      .image {
        outline: 2px solid transparent;
        animation: outlineGlow 1s forwards;
        opacity: 1;
      }
    }

    .image {
      opacity: 0.4;
      transition: transform 0.1s ease-in-out, opacity 0.5s ease;
    }

  }

  .image.loaded {
    animation: outlineGlow 1s forwards;
    opacity: 1;
  }

  @keyframes outlineGlow {
    0% {
      outline-color: transparent;
    }

    100% {
      outline-color: turquoise;
    }
  } */

  /* .image {
    outline: 2px solid slategrey;
  } */

  .gif {
    outline: 2px solid hotpink;
  }

  .focused {
    transition: none;
    float: left;
    overflow: hidden;
    z-index: 9997;
    pointer-events: none;
    position: fixed;
    height: 100vh;
    margin: 0;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
  }

  #fullscreen-canvas {
    float: left;
    overflow: hidden;
    z-index: 9998;
    pointer-events: none;
    position: fixed;
    height: 100vh;
    margin: 0;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
  }

  .thumb-node.selected,
  .thumb.selected {

    >a>img,
    >span>img,
    >div>img {
      outline: 3px solid yellow !important;
    }
  }

  a.hide {
    cursor: default;
  }

  option {
    font-size: 15px;
  }

  #resolution-dropdown {
    text-align: center;
    width: 160px;
    height: 25px;
    cursor: pointer;
  }

  .thumb-node,
  .thumb {

    >div,
    >a {
      >canvas {
        position: absolute;
        top: 0;
        left: 0;
        pointer-events: none;
        z-index: 1;
      }
    }
  }
</style>`;/* eslint-disable no-useless-escape */

class Renderer {
  static clickCodes = {
    leftClick: 0,
    middleClick: 1
  };
  static galleryDirections = {
    d: "d",
    a: "a",
    right: "ArrowRight",
    left: "ArrowLeft"
  };
  static galleryTraversalCooldown = {
    timeout: null,
    waitTime: 200,
    get ready() {
      if (this.timeout === null) {
        this.timeout = setTimeout(() => {
          this.timeout = null;
        }, this.waitTime);
        return true;
      }
      return false;
    }
  };
  static icons = {
    openEye: "<svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\" stroke-width=\"1.5\" stroke=\"white\" class=\"w-6 h-6\"> <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z\" /><path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M15 12a3 3 0 11-6 0 3 3 0 016 0z\" /></svg>",
    closedEye: "<svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\" stroke-width=\"1.5\" stroke=\"white\" class=\"w-6 h-6\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88\" /></svg>",
    openLock: "<svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\" stroke-width=\"1.5\" stroke=\"white\" class=\"w-6 h-6\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M13.5 10.5V6.75a4.5 4.5 0 119 0v3.75M3.75 21.75h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H3.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z\" /></svg>",
    closedLock: "<svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\" stroke-width=\"1.5\" stroke=\"white\" class=\"w-6 h-6\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z\" /></svg>"
  };
  static preferences = {
    showOnHover: "showImagesWhenHovering",
    backgroundOpacity: "galleryBackgroundOpacity",
    resolution: "galleryResolution"
  };
  static localStorageKeys = {
    imageExtensions: "imageExtensions"
  };
  static webWorkers = {
    imageFetcher:
      `
/* eslint-disable prefer-template */
const RETRY_DELAY_INCREMENT = 100;
let retryDelay = 0;

/**
 * @param {String} imageURL
 * @param {String} extension
 * @param {String} postId
 * @param {Number} thumbIndex
 */
async function getImageBitmap(imageURL, extension, postId, thumbIndex) {
  const extensionAlreadyFound = extension !== null && extension !== undefined;
  let newExtension = extension;

  if (extensionAlreadyFound) {
    imageURL = imageURL.replace("jpg", extension);
  } else {
    imageURL = await getOriginalImageURL(postId);
    newExtension = getExtensionFromImageURL(imageURL);
  }
  const result = await fetchImage(imageURL);

  if (result) {
    const imageBitmap = await createImageBitmap(result.blob);

    setTimeout(() => {
      postMessage({
        newExtension,
        postId,
        thumbIndex,
        extensionAlreadyFound,
        imageBitmap
      });
    }, 50);
  }
}

/**
 * @param {Number} milliseconds
 * @returns {Promise}
 */
function sleep(milliseconds) {
  return new Promise(resolve => setTimeout(resolve, milliseconds));
}

/**
 * @param {String} postId
 * @returns {String}
 */
function getOriginalImageURLFromPostPage(postId) {
  const postPageURL = "https://rule34.xxx/index.php?page=post&s=view&id=" + postId;
  return fetch(postPageURL)
    .then((response) => {
      if (response.ok) {
        return response.text();
      }
      throw new Error(response.status + ": " + postPageURL);
    })
    .then((html) => {
      return (/itemprop="image" content="(.*)"/g).exec(html)[1].replace("us.rule34", "rule34");
    }).catch(async(error) => {
      if (!error.message.includes("503")) {
        console.error(error);
        return "https://rule34.xxx/images/r34chibi.png";
      }
      await sleep(retryDelay);
      retryDelay += RETRY_DELAY_INCREMENT;

      if (retryDelay > RETRY_DELAY_INCREMENT * 5) {
        retryDelay = RETRY_DELAY_INCREMENT;
      }
      return getOriginalImageURLFromPostPage(postPageURL);
    });
}

/**
 * @param {String} postId
 * @returns {String}
 */
function getOriginalImageURL(postId) {
  const apiURL = "https://api.rule34.xxx//index.php?page=dapi&s=post&q=index&id=" + postId;
  return fetch(apiURL)
    .then((response) => {
      if (response.ok) {
        return response.text();
      }
      throw new Error(response.status + ": " + postId);
    })
    .then((html) => {
      return (/ file_url="(.*?)"/).exec(html)[1].replace("api-cdn.", "");
    }).catch(() => {
      return getOriginalImageURLFromPostPage(postId);
    });
}

/**
 *
 * @param {String} imageURL
 * @returns {{url: String, blob: Blob} | {url: String, error: String}}
 */
async function fetchImage(imageURL) {
  const response = await fetch(imageURL);

  if (response.ok) {
    const blob = await response.blob();
    return {
      url: imageURL,
      blob
    };
  }
  return {
    url: imageURL,
    error: response.statusText
  };
}

/**
 * @param {String} imageURL
 * @returns {String}
 */
function getExtensionFromImageURL(imageURL) {
  return (/\.(png|jpg|jpeg|gif)/g).exec(imageURL)[1];
}

/**
 * @param {String} postId
 * @returns {String}
 */
async function getImageExtensionFromPostId(postId) {
  const imageURL = await getOriginalImageURL(postId);
  return getExtensionFromImageURL(imageURL);
}

onmessage = async(message) => {
  const request = message.data;

  if (request.findExtension) {
    const extension = await getImageExtensionFromPostId(request.postId);

    postMessage({
      foundExtension: extension,
      postId: request.postId
    });
  } else {
    await getImageBitmap(request.imageURL, request.extension, request.postId, request.thumbIndex);
  }
};

`,
    thumbnailRenderer:
      `
/**
 * @type {Map.<String, OffscreenCanvas>}
 */
const OFFSCREEN_CANVASES = new Map();
let screenWidth = 1080;

/**
 * @param {OffscreenCanvas} offscreenCanvas
 * @param {ImageBitmap} imageBitmap
 * @param {String} id
 * @param {Number} maxResolutionFraction
 */
function draw(offscreenCanvas, imageBitmap, id, maxResolutionFraction) {
  OFFSCREEN_CANVASES.set(id, offscreenCanvas);
  setOffscreenCanvasDimensions(offscreenCanvas, imageBitmap, maxResolutionFraction);
  drawOffscreenCanvas(offscreenCanvas, imageBitmap);
}

/**
 * @param {OffscreenCanvas} offscreenCanvas
 * @param {ImageBitmap} imageBitmap
 * @param {Number} maxResolutionFraction
 */
function setOffscreenCanvasDimensions(offscreenCanvas, imageBitmap, maxResolutionFraction) {
  const newWidth = screenWidth / maxResolutionFraction;
  const ratio = newWidth / imageBitmap.width;
  const newHeight = ratio * imageBitmap.height;

  offscreenCanvas.width = newWidth;
  offscreenCanvas.height = newHeight;
}

/**
 * @param {OffscreenCanvas} offscreenCanvas
 * @param {ImageBitmap} imageBitmap
 */
function drawOffscreenCanvas(offscreenCanvas, imageBitmap) {
  const context = offscreenCanvas.getContext("2d");
  const ratio = Math.min(offscreenCanvas.width / imageBitmap.width, offscreenCanvas.height / imageBitmap.height);
  const centerShiftX = (offscreenCanvas.width - (imageBitmap.width * ratio)) / 2;
  const centerShiftY = (offscreenCanvas.height - (imageBitmap.height * ratio)) / 2;

  context.clearRect(0, 0, offscreenCanvas.width, offscreenCanvas.height);
  context.drawImage(
    imageBitmap, 0, 0, imageBitmap.width, imageBitmap.height,
    centerShiftX, centerShiftY, imageBitmap.width * ratio, imageBitmap.height * ratio
  );
  imageBitmap.close();
}

onmessage = (message) => {
  message = message.data;

  switch (message.action) {
    case "draw":
      draw(message.offscreenCanvas, message.imageBitmap, message.id, message.maxResolutionFraction);
      break;

    case "setScreenWidth":
      screenWidth = message.screenWidth;
      break;

    case "delete":
      break;

    default:
      break;
  }
};

`

  };
  static defaultResolutions = {
    postPage: "7680x4320",
    favoritesPage: "7680x4320"
  };
  static attributes = {
    thumbIndex: "index"
  };
  static extensionDecodings = {
    0: "jpg",
    1: "png",
    2: "jpeg",
    3: "gif"
  };
  static extensionEncodings = {
    "jpg": 0,
    "png": 1,
    "jpeg": 2,
    "gif": 3
  };

  /**
   * @type {HTMLCanvasElement}
   */
  fullscreenCanvas;
  /**
   * @type {CanvasRenderingContext2D}
   */
  fullscreenContext;
  /**
   * @type {Map.<String, ImageBitmap>}
   */
  imageBitmaps;
  /**
   * @type {HTMLImageElement}
   */
  fullscreenCanvasPlaceholder;
  /**
   * @type {Worker[]}
   */
  imageBitmapFetchers;
  /**
   * @type {Worker}
   */
  thumbUpscaler;
  /**
   * @type {Set}
   */
  upscaledThumbs;
  /**
   * @type {Object[]}
   */
  upscaleRequests;
  /**
   * @type {Boolean}
   */
  currentlyUpscaling;
  /**
   * @type {{minIndex: Number, maxIndex: Number}}
   */
  renderedThumbRange;
  /**
   * @type {HTMLElement[]}
   */
  visibleThumbs;
  /**
   * @type {Object.<Number, String>}
   */
  imageExtensions;
  /**
   * @type {Number}
   */
  imageFetchDelay;
  /**
   * @type {Number}
   */
  extensionAlreadyKnownFetchSpeed;
  /**
   * @type {Number}
   */
  recentlyDiscoveredImageExtensionCount;
  /**
   * @type {Number}
   */
  currentlySelectedThumbIndex;
  /**
   * @type {Number}
   */
  imageBitmapFetcherIndex;
  /**
   * @type {Number}
   */
  lastSelectedThumbIndexBeforeEnteringGalleryMode;
  /**
   * @type {Boolean}
   */
  inGallery;
  /**
   * @type {Boolean}
   */
  recentlyExitedGalleryMode;
  /**
   * @type {Boolean}
   */
  stopRendering;
  /**
   * @type {Boolean}
   */
  currentlyRendering;
  /**
   * @type {Boolean}
   */
  finishedLoading;
  /**
   * @type {Number}
   */
  maxNumberOfImagesToRender;
  /**
   * @type {Boolean}
   */
  showOriginalContentOnHover;
  /**
   * @type {HTMLVideoElement}
   */
  videoContainer;
  /**
   * @type {HTMLImageElement}
   */
  gifContainer;
  /**
   * @type {HTMLDivElement}
   */
  background;

  constructor() {
    this.initializeFields();
    this.createWebWorkers();
    this.createFullscreenCanvasImagePlaceholder();
    this.createVideoBackground();
    this.setFullscreenCanvasResolution();
    this.addEventListeners();
    this.loadDiscoveredImageExtensions();
    this.preparePostPage();
    this.injectHTML();
    this.updateBackgroundOpacity(getPreference(Renderer.preferences.backgroundOpacity, 1));
  }

  initializeFields() {
    this.fullscreenCanvas = document.createElement("canvas");
    this.fullscreenContext = this.fullscreenCanvas.getContext("2d");
    this.imageBitmaps = new Map();
    this.renderedThumbRange = {
      minIndex: 0,
      maxIndex: 0
    };
    this.visibleThumbs = [];
    this.imageExtensions = {};
    this.upscaledThumbs = new Set();
    this.upscaleRequests = [];
    this.currentlyUpscaling = false;
    this.imageFetchDelay = 200;
    this.extensionAlreadyKnownFetchSpeed = 8;
    this.recentlyDiscoveredImageExtensionCount = 0;
    this.currentlySelectedThumbIndex = 0;
    this.imageBitmapFetcherIndex = 0;
    this.lastSelectedThumbIndexBeforeEnteringGalleryMode = 0;
    this.inGallery = false;
    this.recentlyExitedGalleryMode = false;
    this.stopRendering = false;
    this.currentlyRendering = false;
    this.finishedLoading = onPostPage();
    this.showOriginalContentOnHover = window.location.href.includes("favorites") ? getPreference(Renderer.preferences.showOnHover, true) : false;
  }

  createWebWorkers() {
    this.imageBitmapFetchers = [];
    this.thumbUpscaler = new Worker(getWorkerURL(Renderer.webWorkers.thumbnailRenderer));
    this.thumbUpscaler.postMessage({
      action: "setScreenWidth",
      screenWidth: window.screen.width
    });

    for (let i = 0; i < 1; i += 1) {
      this.imageBitmapFetchers.push(new Worker(getWorkerURL(Renderer.webWorkers.imageFetcher)));
    }
  }

  injectHTML() {
    this.injectStyleHTML();
    this.injectOptionsHTML();
    this.injectOriginalContentContainerHTML();
  }

  injectStyleHTML() {
    injectStyleHTML(renderHTML);
  }

  injectOptionsHTML() {
    addOptionToFavoritesPage(
      Renderer.preferences.showOnHover,
      "Enlarge On Hover",
      "View full resolution images/play videos when hovering over any thumbnail (Middle mouse click)",
      this.showOriginalContentOnHover, (element) => {
        setPreference(Renderer.preferences.showOnHover, element.target.checked);
        this.toggleAllVisibility();
      },
      true
    );
    this.injectImageResolutionOptionsHTML();
  }

  injectOriginalContentContainerHTML() {
    const originalContentContainerHTML = `
          <div id="original-content-container">
              <video id="original-video-container" width="90%" height="90%" autoplay muted loop style="display: none; top:5%; left:5%; position:fixed; z-index:9998;pointer-events:none;">
              </video>
              <img id="original-gif-container" class="focused"></img>
              <div id="original-content-background" style="position: fixed; top: 0px; left: 0px; width: 100%; height: 100%; background: black; z-index: 999; display: none; pointer-events: none;"></div>
          </div>
      `;

    document.body.insertAdjacentHTML("afterbegin", originalContentContainerHTML);
    const originalContentContainer = document.getElementById("original-content-container");

    originalContentContainer.insertBefore(this.fullscreenCanvas, originalContentContainer.firstChild);
    this.background = document.getElementById("original-content-background");
    this.videoContainer = document.getElementById("original-video-container");
    this.gifContainer = document.getElementById("original-gif-container");
    this.fullscreenCanvas.id = "fullscreen-canvas";
    this.toggleOriginalContentVisibility(this.showOriginalContentOnHover);
  }

  addEventListeners() {
    document.addEventListener("mousedown", (event) => {
      let thumb;

      switch (event.button) {
        case Renderer.clickCodes.leftClick:
          if (this.inGallery) {
            if (isVideo(this.getSelectedThumb())) {
              return;
            }
            this.exitGallery();
            this.toggleAllVisibility(false);
            return;
          }
          thumb = getThumbUnderCursor();

          if (thumb === null) {
            return;
          }
          this.toggleAllVisibility(true);
          this.showOriginalContent(thumb);
          this.enterGallery();
          break;

        case Renderer.clickCodes.middleClick:
          event.preventDefault();

          if (hoveringOverThumb() || this.inGallery) {
            this.openPostInNewPage();
          } else if (!this.inGallery) {
            this.toggleAllVisibility();
          }
          break;

        default:

          break;
      }
    });
    window.addEventListener("auxclick", (event) => {
      if (event.button === Renderer.clickCodes.middleClick) {
        event.preventDefault();
      }
    });
    document.addEventListener("wheel", (event) => {
      if (this.inGallery) {
        const delta = (event.wheelDelta ? event.wheelDelta : -event.deltaY);
        const direction = delta > 0 ? Renderer.galleryDirections.left : Renderer.galleryDirections.right;

        this.traverseGallery.bind(this)(direction, false);
      } else if (hoveringOverThumb() && this.showOriginalContentOnHover) {
        let opacity = parseFloat(getPreference(Renderer.preferences.backgroundOpacity, 1));

        opacity -= event.deltaY * 0.0005;
        opacity = clamp(opacity, "0", "1");
        this.updateBackgroundOpacity(opacity);
      }
    }, {
      passive: true
    });
    document.addEventListener("contextmenu", (event) => {
      if (this.inGallery) {
        event.preventDefault();
        this.exitGallery();
      }
    });
    document.addEventListener("keydown", (event) => {
      if (this.inGallery) {
        switch (event.key) {
          case Renderer.galleryDirections.a:

          case Renderer.galleryDirections.d:

          case Renderer.galleryDirections.left:

          case Renderer.galleryDirections.right:
            event.preventDefault();
            this.traverseGallery(event.key, event.repeat);
            break;

          case "X":

          case "x":
            this.unFavoriteSelectedContent();
            break;

          case "M":

          case "m":
            if (isVideo(this.getSelectedThumb())) {
              this.videoContainer.muted = !this.videoContainer.muted;
            }
            break;

          case "Escape":
            this.exitGallery();
            this.toggleAllVisibility(false);
            break;

          default:
            break;
        }
      }
    });
    window.addEventListener("load", () => {
      if (onPostPage()) {
        this.initializeThumbsForHovering.bind(this)();
        this.enumerateVisibleThumbs();
      }
      this.hideCaptionsWhenShowingOriginalContent();
    }, {
      once: true,
      passive: true
    });
    window.addEventListener("favoritesFetched", (event) => {
      this.initializeThumbsForHovering.bind(this)(event.detail);
      this.enumerateVisibleThumbs();
    });
    window.addEventListener("newFavoritesFetchedOnReload", (event) => {
      this.initializeThumbsForHovering.bind(this)(event.detail);
      this.enumerateVisibleThumbs();

      if (event.detail.length > 0) {
        const thumb = event.detail[0];

        this.upscaleAnimatedVisibleThumbsAround(thumb);
        this.renderImagesAround(thumb);
      }
    });
    window.addEventListener("startedFetchingFavorites", () => {
      setTimeout(() => {
        const thumb = document.querySelector(".thumb-node");

        if (thumb !== null && !this.finishedLoading) {
          this.renderImagesAround(thumb, 10);
          this.upscaleAnimatedVisibleThumbsAround(thumb);
        }
      }, 650);
    }, {
      once: true
    });
    window.addEventListener("favoritesLoaded", () => {
      this.finishedLoading = true;
      this.initializeThumbsForHovering.bind(this)();
      this.enumerateVisibleThumbs();
      this.renderImagesInTheBackground();
      this.assignImageExtensionsInTheBackground();
    });
    window.addEventListener("finishedSearching", () => {
      this.enumerateVisibleThumbs();
      this.onFavoritesSearch();
    });
    window.addEventListener("shuffle", async() => {
      this.enumerateVisibleThumbs();
      await this.deleteAllRenders();
      this.renderImagesInTheBackground();
    });
    this.imageBitmapFetchers.forEach((renderer) => {
      renderer.onmessage = (message) => {
        this.handleRendererMessage(message.data);
      };
    });

    if (this.thumbUpscaler !== undefined) {
      this.thumbUpscaler.onmessage = (message) => {
        message = message.data;

        switch (message.action) {
          default:

            break;
        }
      };
    }
  }

  /**
   * @param {HTMLElement[]} thumbs
   */
  initializeThumbsForHovering(thumbs) {
    const thumbElements = thumbs === undefined ? getAllThumbs() : thumbs;

    for (const thumbElement of thumbElements) {
      this.addEventListenersToThumb(thumbElement);
    }
  }

  /**
   * @param {Object} message
   */
  handleRendererMessage(message) {
    if (message.foundExtension) {
      this.assignExtension(message.postId, message.foundExtension);
      return;
    }
    this.onRenderFinished(message);
  }

  /**
   * @param {Object} message
   */
  onRenderFinished(message) {
    this.deleteOldestRender();

    const thumb = document.getElementById(message.postId);

    this.imageBitmaps.set(message.postId, message.imageBitmap);

    if (thumb === null) {
      return;
    }
    thumb.classList.add("loaded");
    this.upscaleThumbResolution(thumb, message.imageBitmap, 4);

    if (message.extension === "gif") {
      getImageFromThumb(thumb).setAttribute("gif", true);
      return;
    }

    if (!message.extensionAlreadyFound) {
      this.assignExtension(message.postId, message.newExtension);
    }

    if (this.inGallery) {
      if (this.getSelectedThumb().id === message.postId) {
        this.showOriginalContent(thumb);
      }
    } else if (this.showOriginalContentOnHover) {
      const thumbUnderCursor = getThumbUnderCursor();
      const hoveringOverSameThumb = (thumbUnderCursor !== null) && thumbUnderCursor.id === message.postId;

      if (hoveringOverSameThumb) {
        this.showOriginalContent(thumb);
      }
    }
  }

  /**
   * @param {HTMLElement} thumb
   * @param {ImageBitmap} imageBitmap
   * @param {Number} maxResolutionFraction
   */
  upscaleThumbResolution(thumb, imageBitmap, maxResolutionFraction) {
    if (onPostPage() || this.upscaledThumbs.has(thumb.id) || this.thumbUpscaler === undefined) {
      return;
    }
    this.upscaledThumbs.add(thumb.id);
    const message = {
      action: "draw",
      id: thumb.id,
      offscreenCanvas: thumb.querySelector("canvas").transferControlToOffscreen(),
      imageBitmap,
      maxResolutionFraction
    };

    // this.upscaleRequests.push(message);
    // this.dispatchThumbResolutionUpscaleRequests();
    this.thumbUpscaler.postMessage(message, [message.offscreenCanvas]);
  }

  async dispatchThumbResolutionUpscaleRequests() {
    if (this.currentlyUpscaling) {
      return;
    }
    this.currentlyUpscaling = true;

    while (this.upscaleRequests.length > 0) {
      await sleep(25);
      const message = this.upscaleRequests.shift();

      this.thumbUpscaler.postMessage(message, [message.offscreenCanvas]);
    }
    this.currentlyUpscaling = false;
  }

  /**
   * @param {HTMLElement} thumb
   * @returns {Boolean}
   */
  isUpscaled(thumb) {
    return this.upscaledThumbs.has(thumb.id);
  }

  /**
   * @param {String} postId
   * @param {String} extension
   */
  assignExtension(postId, extension) {
    this.setImageExtension(postId, extension);
    this.recentlyDiscoveredImageExtensionCount += 1;

    if (this.recentlyDiscoveredImageExtensionCount >= 3) {
      this.recentlyDiscoveredImageExtensionCount = 0;

      if (!onPostPage()) {
        localStorage.setItem(Renderer.localStorageKeys.imageExtensions, JSON.stringify(this.imageExtensions));
      }
    }
  }

  hideCaptionsWhenShowingOriginalContent() {
    for (const caption of document.getElementsByClassName("caption")) {
      if (this.showOriginalContentOnHover) {
        caption.classList.add("hide");
      } else {
        caption.classList.remove("hide");
        const thumb = getThumbUnderCursor();

        if (thumb !== null) {
          dispatchEvent(new CustomEvent("showCaption", {
            detail: thumb
          }));
        }
      }
    }
  }

  async preparePostPage() {
    if (!onPostPage()) {
      return;
    }
    const imageList = document.getElementsByClassName("image-list")[0];
    const thumbs = Array.from(imageList.querySelectorAll(".thumb"));

    for (const thumb of thumbs) {
      removeTitleFromImage(getImageFromThumb(thumb));
      assignContentType(thumb);
      thumb.id = thumb.id.substring(1);
    }
    window.addEventListener("unload", () => {
      this.deleteAllRenders();
    });
    window.onblur = () => {
      this.deleteAllRenders();
    };
    await this.findImageExtensionsOnPostPage();
    this.renderImagesInTheBackground();
  }

  async deleteAllRenders() {
    await this.pauseRendering(10);

    for (const id of this.imageBitmaps.keys()) {
      this.deleteRender(id);
    }
    this.imageBitmaps.clear();
  }

  deleteRender(id) {
    const thumb = document.getElementById(id);

    if (thumb !== null) {
      thumb.classList.remove("loaded");
    }
    this.imageBitmaps.get(id).close();
    this.imageBitmaps.delete(id);
  }

  findImageExtensionsOnPostPage() {
    const postPageAPIURL = this.getPostPageAPIURL();
    return fetch(postPageAPIURL)
      .then((response) => {
        if (response.ok) {
          return response.text();
        }
        return null;
      }).then((html) => {
        if (html === null) {
          console.error(`Failed to fetch: ${postPageAPIURL}`);
        }
        const dom = new DOMParser().parseFromString(`<div>${html}</div>`, "text/html");
        const posts = Array.from(dom.getElementsByTagName("post"));

        for (const post of posts) {
          const originalImageURL = post.getAttribute("file_url");
          const isAnImage = getContentType(post.getAttribute("tags")) === "image";
          const isBlacklisted = originalImageURL === "https://api-cdn.rule34.xxx/images//";

          if (!isAnImage || isBlacklisted) {
            continue;
          }
          const postId = post.getAttribute("id");
          const extension = (/\.(png|jpg|jpeg|gif)/g).exec(originalImageURL)[1];

          this.assignExtension(postId, extension);
        }
      });
  }

  /**
   * @returns {String}
   */
  getPostPageAPIURL() {
    const postsPerPage = 42;
    const apiURL = `https://api.rule34.xxx/index.php?page=dapi&s=post&q=index&limit=${postsPerPage}`;
    let blacklistedTags = ` ${negateTags(TAG_BLACKLIST)}`.replace(/\s-/g, "+-");
    let pageNumber = (/&pid=(\d+)/).exec(location.href);
    let tags = (/&tags=([^&]*)/).exec(location.href);

    pageNumber = pageNumber === null ? 0 : Math.floor(parseInt(pageNumber[1]) / postsPerPage);
    tags = tags === null ? "" : tags[1];

    if (tags === "all") {
      tags = "";
      blacklistedTags = "";
    }
    return `${apiURL}&tags=${tags}${blacklistedTags}&pid=${pageNumber}`;
  }

  enumerateVisibleThumbs() {
    this.visibleThumbs = Array.from(getAllVisibleThumbs());

    for (let i = 0; i < this.visibleThumbs.length; i += 1) {
      this.enumerateThumb(this.visibleThumbs[i], i);
    }
    this.indexRenderRange();
  }

  /**
   * @param {HTMLElement} thumb
   * @param {Number} index
   */
  enumerateThumb(thumb, index) {
    thumb.setAttribute(Renderer.attributes.thumbIndex, index);
  }

  /**
   * @param {HTMLElement} thumb
   */
  addEventListenersToThumb(thumb) {
    const image = getImageFromThumb(thumb);

    image.onmouseover = () => {
      if (this.inGallery || this.recentlyExitedGalleryMode) {
        return;
      }
      this.showOriginalContent(thumb);
    };
    image.onmouseout = (event) => {
      if (this.inGallery || enteredOverCaptionTag(event)) {
        return;
      }
      this.hideOriginalContent(thumb);
    };
  }

  loadDiscoveredImageExtensions() {
    this.imageExtensions = JSON.parse(localStorage.getItem(Renderer.localStorageKeys.imageExtensions)) || {};
  }

  openPostInNewPage() {
    const firstChild = this.getSelectedThumb().children[0];

    if (firstChild.hasAttribute("href")) {
      window.open(firstChild.getAttribute("href"), "_blank");
    } else {
      firstChild.click();
    }
  }

  unFavoriteSelectedContent() {
    const removeLink = getRemoveLinkFromThumb(this.getSelectedThumb());

    if (removeLink === null || removeLink.style.visibility === "hidden") {
      return;
    }
    removeLink.click();
  }

  enterGallery() {
    const selectedThumb = this.getSelectedThumb();

    this.lastSelectedThumbIndexBeforeEnteringGalleryMode = this.currentlySelectedThumbIndex;
    this.background.style.pointerEvents = "auto";
    this.highlightThumb(selectedThumb, true);

    if (isVideo(selectedThumb)) {
      this.toggleCursorVisibility(true);
      this.toggleVideoControls(true);
    }
    this.inGallery = true;
    this.showLockIcon();
  }

  exitGallery() {
    this.toggleVideoControls(false);
    this.background.style.pointerEvents = "none";
    const thumbIndex = this.getIndexOfThumbUnderCursor();

    if (thumbIndex !== this.lastSelectedThumbIndexBeforeEnteringGalleryMode) {
      this.hideOriginalContent(this.getSelectedThumb());

      if (thumbIndex !== null && this.showOriginalContentOnHover) {
        this.showOriginalContent(this.visibleThumbs[thumbIndex]);
      }
    }
    this.recentlyExitedGalleryMode = true;
    setTimeout(() => {
      this.recentlyExitedGalleryMode = false;
    }, 300);
    this.inGallery = false;
    this.showLockIcon();
  }

  /**
   * @param {String} direction
   * @param {Boolean} keyIsHeldDown
   */
  traverseGallery(direction, keyIsHeldDown) {
    if (keyIsHeldDown && !Renderer.galleryTraversalCooldown.ready) {
      return;
    }

    let selectedThumb = this.getSelectedThumb();

    this.clearOriginalContentSources();
    this.highlightThumb(selectedThumb, false);
    this.setNextSelectedThumbIndex(direction);
    selectedThumb = this.getSelectedThumb();
    this.highlightThumb(selectedThumb, true);
    this.renderInAdvanceWhileTraversingInGalleryMode(selectedThumb, direction);

    if (!usingFirefox()) {
      scrollToThumb(selectedThumb.id, false);
    }

    if (isVideo(selectedThumb)) {
      this.toggleCursorVisibility(true);
      this.toggleVideoControls(true);
      this.showOriginalVideo(selectedThumb);
    } else if (isGif(selectedThumb)) {
      this.toggleCursorVisibility(false);
      this.toggleVideoControls(false);
      this.toggleOriginalVideo(false);
      this.showOriginalGIF(selectedThumb);
    } else {
      this.toggleCursorVisibility(false);
      this.toggleVideoControls(false);
      this.toggleOriginalVideo(false);
      this.showOriginalImage(selectedThumb);
    }
  }

  /**
   * @param {String} direction
   */
  setNextSelectedThumbIndex(direction) {
    if (direction === Renderer.galleryDirections.left || direction === Renderer.galleryDirections.a) {
      this.currentlySelectedThumbIndex -= 1;
      this.currentlySelectedThumbIndex = this.currentlySelectedThumbIndex < 0 ? this.visibleThumbs.length - 1 : this.currentlySelectedThumbIndex;
    } else {
      this.currentlySelectedThumbIndex += 1;
      this.currentlySelectedThumbIndex = this.currentlySelectedThumbIndex >= this.visibleThumbs.length ? 0 : this.currentlySelectedThumbIndex;
    }
  }

  /**
   * @param {Boolean} value
   */
  toggleAllVisibility(value) {
    this.showOriginalContentOnHover = value === undefined ? !this.showOriginalContentOnHover : value;
    this.toggleOriginalContentVisibility();
    this.showEyeIcon();

    if (hoveringOverThumb()) {
      this.toggleBackgroundVisibility();
      this.toggleScrollbarVisibility();
    }
    this.hideCaptionsWhenShowingOriginalContent();
    const showOnHoverCheckbox = document.getElementById("showImagesWhenHoveringCheckbox");

    if (showOnHoverCheckbox !== null) {
      setPreference(Renderer.preferences.showOnHover, this.showOriginalContentOnHover);
      showOnHoverCheckbox.checked = this.showOriginalContentOnHover;
    }
  }

  /**
   * @param {HTMLElement} thumb
   */
  hideOriginalContent(thumb) {
    this.highlightThumb(thumb, false);
    this.toggleBackgroundVisibility(false);
    this.toggleScrollbarVisibility(true);
    this.toggleCursorVisibility(true);
    this.clearOriginalContentSources();
    this.toggleOriginalVideo(false);
    this.toggleOriginalGIF(false);
  }

  clearOriginalContentSources() {
    this.clearFullscreenCanvas();
    this.videoContainer.src = "";
    this.gifContainer.src = "";
  }

  /**
   * @returns {Boolean}
   */
  currentlyHoveringOverVideoThumb() {
    const thumb = getThumbUnderCursor();

    if (thumb === null) {
      return false;
    }
    return isVideo(thumb);
  }

  /**
   * @param {HTMLElement} thumb
   * @param {Boolean} value
   */
  highlightThumb(thumb, value) {
    thumb.classList.toggle("selected", value);
  }

  /**
   * @param {HTMLElement} thumb
   */
  showOriginalContent(thumb) {
    this.highlightThumb(thumb, true);
    this.currentlySelectedThumbIndex = parseInt(thumb.getAttribute(Renderer.attributes.thumbIndex));

    const animatedThumbsToUpscale = this.getAdjacentVisibleThumbs(thumb, 20, (_) => {
      return true;
    }).filter(t => !isImage(t) && !this.isUpscaled(t));

    this.upscaleAnimatedThumbs(animatedThumbsToUpscale);

    if (isVideo(thumb)) {
      this.showOriginalVideo(thumb);
    } else if (isGif(thumb)) {
      this.showOriginalGIF(thumb);
    } else {
      this.showOriginalImage(thumb);
    }

    if (this.showOriginalContentOnHover) {
      this.toggleCursorVisibility(false);
      this.toggleBackgroundVisibility(true);
      this.toggleScrollbarVisibility(false);
    }
  }

  /**
   * @param {HTMLElement} thumb
   */
  showOriginalVideo(thumb) {
    if (!this.showOriginalContentOnHover) {
      return;
    }
    this.toggleFullscreenCanvas(false);
    this.videoContainer.style.display = "block";
    this.playOriginalVideo(thumb);

    if (!this.inGallery) {
      this.toggleVideoControls(false);
    }
  }

  /**
   * @param {HTMLElement} thumb
   * @returns {String}
   */
  getVideoSource(thumb) {
    return getOriginalImageURLFromThumb(thumb).replace("jpg", "mp4");
  }

  /**
   * @param {HTMLElement} thumb
   */
  playOriginalVideo(thumb) {
    this.videoContainer.src = this.getVideoSource(thumb);
    this.videoContainer.play().catch(() => { });
  }

  /**
   * @param {HTMLElement} thumb
   */
  showOriginalGIF(thumb) {
    const extension = includesTag("animated_png", getTagsFromThumb(thumb)) ? "png" : "gif";
    const originalSource = getOriginalImageURLFromThumb(thumb).replace("jpg", extension);

    this.gifContainer.src = originalSource;

    if (this.showOriginalContentOnHover) {
      this.gifContainer.style.visibility = "visible";
    }
  }

  /**
   * @param {HTMLElement} thumb
   */
  showOriginalImage(thumb) {
    if (this.isNotRendered(thumb)) {
      this.renderOriginalImage(thumb);
      this.renderImagesAround(thumb);
    } else {
      this.drawFullscreenCanvas(this.imageBitmaps.get(thumb.id));
    }
    this.toggleOriginalContentVisibility(this.showOriginalContentOnHover);
  }

  deleteOldestRender() {
    if (this.imageBitmaps.size > this.maxNumberOfImagesToRender) {
      const iterator = this.imageBitmaps.keys().next();

      if (!iterator.done) {
        this.deleteRender(iterator.value);
      }
    }
  }

  /**
   * @param {HTMLElement} initialThumb
   */
  async renderImagesAround(initialThumb) {
    if (onPostPage()) {
      return;
    }

    if (this.currentlyRendering) {
      if (this.thumbInRenderRange(initialThumb)) {
        return;
      }
      await this.pauseRendering(this.imageFetchDelay);
    }
    this.currentlyRendering = true;
    const amountToRender = Math.ceil(this.maxNumberOfImagesToRender / 6);
    const imageThumbsToRender = this.getAdjacentVisibleThumbs(initialThumb, amountToRender, (thumb) => {
      return isImage(thumb) && this.isNotRendered(thumb);
    });
    const indicesOfImageThumbsToRender = imageThumbsToRender.map(imageThumb => parseInt(imageThumb.getAttribute(Renderer.attributes.thumbIndex)));

    this.setRenderRange(indicesOfImageThumbsToRender);
    await this.renderImages(imageThumbsToRender);
    this.currentlyRendering = false;
  }

  /**
   * @param {HTMLElement} initialThumb
   * @param {Number} limit
   * @param {Function} additionalQualifier
   * @returns {HTMLElement[]}
   */
  getAdjacentVisibleThumbs(initialThumb, limit, additionalQualifier) {
    const adjacentVisibleThumbs = [];
    let currentThumb = initialThumb;
    let previousThumb = initialThumb;
    let nextThumb = initialThumb;
    let traverseForward = true;

    while (currentThumb !== null && adjacentVisibleThumbs.length < limit) {
      if (traverseForward) {
        nextThumb = this.getAdjacentVisibleThumb(nextThumb, true);
      } else {
        previousThumb = this.getAdjacentVisibleThumb(previousThumb, false);
      }

      if (previousThumb === null) {
        traverseForward = true;
      } else if (nextThumb === null) {
        traverseForward = false;
      } else {
        traverseForward = !traverseForward;
      }
      currentThumb = traverseForward ? nextThumb : previousThumb;

      if (currentThumb !== null) {
        if (this.isVisible(currentThumb) && additionalQualifier(currentThumb)) {
          adjacentVisibleThumbs.push(currentThumb);
        }
      }
    }
    return adjacentVisibleThumbs;
  }

  /**
   * @param {HTMLElement} thumb
   * @param {Boolean} traverseForward
   * @returns {HTMLElement}
   */
  getAdjacentVisibleThumb(thumb, traverseForward) {
    let adjacentThumb = this.getAdjacentThumb(thumb, traverseForward);

    while (adjacentThumb !== null && !this.isVisible(adjacentThumb)) {
      adjacentThumb = this.getAdjacentThumb(adjacentThumb, traverseForward);
    }
    return adjacentThumb;
  }

  /**
   * @param {HTMLElement} thumb
   * @param {Boolean} traverseForward
   * @returns {HTMLElement}
   */
  getAdjacentThumb(thumb, traverseForward) {
    return traverseForward ? thumb.nextElementSibling : thumb.previousElementSibling;
  }

  /**
   * @param {HTMLElement} thumb
   * @returns {Boolean}
   */
  isVisible(thumb) {
    return thumb.style.display !== "none";
  }

  /**
   * @param {HTMLElement} thumb
   * @returns {Boolean}
   */
  isNotRendered(thumb) {
    return this.imageBitmaps.get(thumb.id) === undefined;
  }

  /**
   * @param {HTMLElement} thumb
   */
  renderOriginalImage(thumb) {
    const renderMessage = {
      imageURL: getOriginalImageURLFromThumb(thumb),
      postId: thumb.id,
      thumbIndex: thumb.getAttribute(Renderer.attributes.thumbIndex),
      extension: this.getImageExtension(thumb.id)
    };

    this.imageBitmapFetchers[this.imageBitmapFetcherIndex].postMessage(renderMessage);
    this.imageBitmapFetcherIndex += 1;
    this.imageBitmapFetcherIndex = this.imageBitmapFetcherIndex < this.imageBitmapFetchers.length ? this.imageBitmapFetcherIndex : 0;

    const image = getImageFromThumb(thumb);

    if (!imageIsLoaded(image)) {
      return;
    }
    createImageBitmap(image)
      .then((imageBitmap) => {
        if (this.imageBitmaps.get(thumb.id) === undefined) {
          this.imageBitmaps.set(thumb.id, imageBitmap);
        }
      });
  }

  /**
   * @param {Boolean} value
   */
  toggleOriginalContentVisibility(value) {
    this.toggleFullscreenCanvas(value);
    this.toggleOriginalGIF(value);

    if (!value) {
      this.toggleOriginalVideo(false);
    }
  }

  /**
   * @param {Boolean} value
   */
  toggleBackgroundVisibility(value) {
    if (value === undefined) {
      this.background.style.display = this.background.style.display === "block" ? "none" : "block";
      return;
    }
    this.background.style.display = value ? "block" : "none";
  }

  /**
   * @param {Boolean} value
   */
  toggleScrollbarVisibility(value) {
    if (value === undefined) {
      document.body.style.overflowY = document.body.style.overflowY === "auto" ? "hidden" : "auto";
      return;
    }
    document.body.style.overflowY = value ? "auto" : "hidden";
  }

  /**
   * @param {Boolean} value
   */
  toggleCursorVisibility(value) {
    // const image = getImageFromThumb(this.getSelectedThumb());

    // if (value === undefined) {
    //   image.style.cursor = image.style.cursor === "pointer" ? "none" : "pointer";
    //   return;
    // }

    // if (value) {
    //   image.style.cursor = "pointer";
    //   document.body.style.cursor = "pointer";
    // } else {
    //   image.style.cursor = "none";
    //   document.body.style.cursor = "none";
    // }
  }

  /**
   * @param {Boolean} value
   */
  toggleVideoControls(value) {
    if (value === undefined) {
      this.videoContainer.style.pointerEvents = this.videoContainer.style.pointerEvents === "auto" ? "none" : "auto";
      this.videoContainer.style.controls = this.videoContainer.style.controls === "controls" ? false : "controls";
    } else {
      this.videoContainer.style.pointerEvents = value ? "auto" : "none";
      this.videoContainer.controls = value ? "controls" : false;
    }
  }

  /**
   * @param {Boolean} value
   */
  toggleFullscreenCanvas(value) {
    if (value === undefined) {
      this.fullscreenCanvas.style.visibility = this.fullscreenCanvas.style.visibility === "visible" ? "hidden" : "visible";
    } else {
      this.fullscreenCanvas.style.visibility = value ? "visible" : "hidden";
    }
  }

  /**
   * @param {Boolean} value
   */
  toggleOriginalVideo(value) {
    if (value !== undefined && this.videoContainer.src !== "") {
      this.videoContainer.style.display = value ? "block" : "none";
      return;
    }

    if (!this.currentlyHoveringOverVideoThumb() || this.videoContainer.style.display === "block") {
      this.videoContainer.style.display = "none";
    } else {
      this.videoContainer.style.display = "block";
    }
  }

  /**
   * @param {Boolean} value
   */
  toggleOriginalGIF(value) {
    if (value === undefined) {
      this.gifContainer.style.visibility = this.gifContainer.style.visibility === "visible" ? "hidden" : "visible";
    } else {
      this.gifContainer.style.visibility = value ? "visible" : "hidden";
    }
  }

  /**
   * @param {Number} opacity
   */
  updateBackgroundOpacity(opacity) {
    this.background.style.opacity = opacity;
    setPreference(Renderer.preferences.backgroundOpacity, opacity);
  }

  /**
   * @returns {Number}
   */
  getIndexOfThumbUnderCursor() {
    const thumb = getThumbUnderCursor();
    return thumb === null ? null : parseInt(thumb.getAttribute(Renderer.attributes.thumbIndex));
  }

  /**
   * @returns {HTMLElement}
   */
  getSelectedThumb() {
    return this.visibleThumbs[this.currentlySelectedThumbIndex];
  }

  /**
   * @param {ImageBitmap} imageBitmap
   */
  drawFullscreenCanvas(imageBitmap) {
    const ratio = Math.min(this.fullscreenCanvas.width / imageBitmap.width, this.fullscreenCanvas.height / imageBitmap.height);
    const centerShiftX = (this.fullscreenCanvas.width - (imageBitmap.width * ratio)) / 2;
    const centerShiftY = (this.fullscreenCanvas.height - (imageBitmap.height * ratio)) / 2;

    this.fullscreenContext.clearRect(0, 0, this.fullscreenCanvas.width, this.fullscreenCanvas.height);
    this.fullscreenContext.drawImage(
      imageBitmap, 0, 0, imageBitmap.width, imageBitmap.height,
      centerShiftX, centerShiftY, imageBitmap.width * ratio, imageBitmap.height * ratio
    );
  }

  clearFullscreenCanvas() {
    this.fullscreenContext.clearRect(0, 0, this.fullscreenCanvas.width, this.fullscreenCanvas.height);
  }

  showEyeIcon() {
    const eyeIcon = document.getElementById("svg-eye");
    const svg = this.showOriginalContentOnHover ? Renderer.icons.openEye : Renderer.icons.closedEye;

    if (eyeIcon) {
      eyeIcon.remove();
    }
    showOverlayingIcon(svg, "svg-eye", 100, 100, "bottom-right");
  }

  showLockIcon() {
    const lockIcon = document.getElementById("svg-lock");
    const svg = this.inGallery ? Renderer.icons.closedLock : Renderer.icons.openLock;

    if (lockIcon) {
      lockIcon.remove();
    }
    showOverlayingIcon(svg, "svg-lock", 100, 100, "bottom-left");
  }

  /**
   * @returns {HTMLElement[]}
   */
  getVisibleUnrenderedImageThumbs() {
    let thumbs = Array.from(getAllVisibleThumbs()).filter((thumb) => {
      return isImage(thumb) && this.isNotRendered(thumb);
    });

    if (onPostPage()) {
      thumbs = thumbs.filter(thumb => !thumb.classList.contains("blacklisted-image"));
    }
    return thumbs;
  }

  async onFavoritesSearch() {
    this.deleteRendersNotIncludedInNewSearch();
    await this.pauseRendering(50);
    this.renderImagesInTheBackground();
  }

  deleteRendersNotIncludedInNewSearch() {
    for (const id of this.imageBitmaps.keys()) {
      const thumb = document.getElementById(id);

      if (thumb !== null && !this.isVisible(thumb)) {
        this.deleteRender(thumb.id);
      }
    }
  }

  async renderImagesInTheBackground() {
    if (this.currentlyRendering) {
      return;
    }
    this.currentlyRendering = true;
    const unrenderedImageThumbs = this.getVisibleUnrenderedImageThumbs();
    const imageThumbsToRender = [];
    const imagesAlreadyRenderedCount = this.imageBitmaps.size;

    const animatedThumbsToUpscale = Array.from(getAllVisibleThumbs())
      .slice(0, this.maxNumberOfImagesToRender / 2)
      .filter(thumb => !isImage(thumb) && !this.isUpscaled(thumb));

    this.upscaleAnimatedThumbs(animatedThumbsToUpscale);

    for (let i = 0; i < unrenderedImageThumbs.length && i + imagesAlreadyRenderedCount < this.maxNumberOfImagesToRender; i += 1) {
      imageThumbsToRender.push(unrenderedImageThumbs[i]);
    }

    if (imageThumbsToRender.length > 0) {
      this.renderedThumbRange.minIndex = imageThumbsToRender[0].getAttribute(Renderer.attributes.thumbIndex);
      this.renderedThumbRange.maxIndex = imageThumbsToRender[imageThumbsToRender.length - 1].getAttribute(Renderer.attributes.thumbIndex);
    }
    await this.renderImages(imageThumbsToRender);
    this.currentlyRendering = false;
  }

  /**
   * @param {HTMLElement[]} imagesToRender
   */
  async renderImages(imagesToRender) {
    for (const thumb of imagesToRender) {
      if (this.stopRendering) {
        break;
      }
      this.renderOriginalImage(thumb);
      await sleep(this.getImageFetchDelay(thumb.id));
    }
  }

  /**
   * @param {HTMLElement} animatedThumbs
   */
  async upscaleAnimatedThumbs(animatedThumbs) {
    for (const thumb of animatedThumbs) {
      if (this.isUpscaled(thumb)) {
        continue;
      }
      const image = getImageFromThumb(thumb);
      let newImage = new Image();
      let source = getOriginalImageURL(image.src);

      if (isGif(thumb)) {
        source = source.replace("jpg", "gif");
      }
      newImage.src = source;
      newImage.onload = () => {
        createImageBitmap(newImage)
          .then((imageBitmap) => {
            this.upscaleThumbResolution(thumb, imageBitmap, 5.5);
            newImage = null;
          });
      };
      await sleep(this.imageFetchDelay);
    }
  }

  /**
   * @param {String} postId
   * @returns {Number}
   */
  getImageFetchDelay(postId) {
    return this.extensionIsKnown(postId) ? this.imageFetchDelay / this.extensionAlreadyKnownFetchSpeed : this.imageFetchDelay;
  }

  /**
   *
   * @param {String} postId
   * @returns {Boolean}
   */
  extensionIsKnown(postId) {
    return this.getImageExtension(postId) !== undefined;
  }

  /**
   * @returns {Number}
   */
  getMaxNumberOfImagesToRender() {
    const availableMemory = 1200;
    const averageImageSize = 20;
    const maxImagesToRender = Math.floor(availableMemory / averageImageSize);
    return maxImagesToRender;
  }

  /**
   * @param {HTMLElement} thumb
   * @returns {Boolean}
   */
  thumbInRenderRange(thumb) {
    const index = parseInt(thumb.getAttribute(Renderer.attributes.thumbIndex));
    return index >= this.renderedThumbRange.minIndex && index <= this.renderedThumbRange.maxIndex;
  }

  injectImageResolutionOptionsHTML() {
    const additionalFavoriteOptions = document.getElementById("additional-favorite-options");

    if (additionalFavoriteOptions === null) {
      return;
    }
    const scale = 40;
    const width = 16 * scale;
    const height = 9 * scale;
    const defaultResolution = getPreference(Renderer.preferences.resolution, Renderer.defaultResolutions.favoritesPage);
    const container = document.createElement("div");

    container.style.paddingTop = "8px";
    const resolutionLabel = document.createElement("label");
    const resolutionDropdown = document.createElement("select");

    resolutionLabel.textContent = "Image Resolution";
    resolutionDropdown.id = "resolution-dropdown";

    for (let i = 1; i <= 7680 / width; i += 1) {
      const resolution = `${i * width}x${i * height}`;
      const resolutionOption = document.createElement("option");

      if (resolution === defaultResolution) {
        resolutionOption.selected = "selected";
      }
      resolutionOption.textContent = resolution;
      resolutionDropdown.appendChild(resolutionOption);
    }
    resolutionDropdown.onchange = () => {
      setPreference(Renderer.preferences.resolution, resolutionDropdown.value);
      this.setFullscreenCanvasResolution();
    };
    container.appendChild(resolutionLabel);
    container.appendChild(document.createElement("br"));
    container.appendChild(resolutionDropdown);
    additionalFavoriteOptions.insertAdjacentElement("afterbegin", container);
    container.style.display = "none";
  }

  setFullscreenCanvasResolution() {
    const resolution = onPostPage() ? Renderer.defaultResolutions.postPage : getPreference(Renderer.preferences.resolution, Renderer.defaultResolutions.favoritesPage);
    const dimensions = resolution.split("x").map(dimension => parseFloat(dimension));

    this.fullscreenCanvas.width = dimensions[0];
    this.fullscreenCanvas.height = dimensions[1];
    this.maxNumberOfImagesToRender = this.getMaxNumberOfImagesToRender();
  }

  /**
   * @param {HTMLElement} thumb
   * @param {String} direction
   * @returns
   */
  renderInAdvanceWhileTraversingInGalleryMode(thumb, direction) {
    const currentThumbIndex = parseInt(thumb.getAttribute(Renderer.attributes.thumbIndex));
    const lookahead = Math.min(12, Math.round(this.maxNumberOfImagesToRender / 2) - 2);
    let possiblyUnrenderedThumbIndex;

    if (direction === Renderer.galleryDirections.left || direction === Renderer.galleryDirections.a) {
      possiblyUnrenderedThumbIndex = currentThumbIndex - lookahead;
    } else {
      possiblyUnrenderedThumbIndex = currentThumbIndex + lookahead;
    }

    if (possiblyUnrenderedThumbIndex < 0 || possiblyUnrenderedThumbIndex >= this.visibleThumbs.length) {
      return;
    }
    const possiblyUnrenderedThumb = this.visibleThumbs[possiblyUnrenderedThumbIndex];

    if (this.isNotRendered(possiblyUnrenderedThumb)) {
      this.upscaleAnimatedVisibleThumbsAround(possiblyUnrenderedThumb);
      this.renderImagesAround(possiblyUnrenderedThumb);
    }
  }

  /**
   * @param {HTMLElement} thumb
   */
  upscaleAnimatedVisibleThumbsAround(thumb) {
    const animatedThumbsToUpscale = this.getAdjacentVisibleThumbs(thumb, 10, (t) => {
      return !isImage(t) && !this.isUpscaled(t);
    });

    this.upscaleAnimatedThumbs(animatedThumbsToUpscale);
  }

  /**
   * @param {Number[]} indices
   */
  setRenderRange(indices) {
    indices.sort((a, b) => {
      return a - b;
    });
    this.renderedThumbRange.minIndex = indices[0];
    this.renderedThumbRange.maxIndex = indices[indices.length - 1];
  }

  indexRenderRange() {
    if (this.imageBitmaps.size === 0) {
      return;
    }
    const indices = [];

    for (const postId of this.imageBitmaps.keys()) {
      const thumb = getThumbByPostId(postId);

      if (thumb === null) {
        break;
      }
      indices.push(parseInt(thumb.getAttribute(Renderer.attributes.thumbIndex)));
    }
    this.setRenderRange(indices);
  }

  /**
   * @param {String[]} ids
   */
  async assignImageExtensionsInTheBackground(ids) {
    const postIdsWithUnknownExtensions = ids === undefined ? this.getPostIdsWithUnknownExtensions() : ids;

    while (postIdsWithUnknownExtensions.length > 0) {
      await sleep(4000);

      while (postIdsWithUnknownExtensions.length > 0 && this.finishedLoading && !this.currentlyRendering) {
        const postId = postIdsWithUnknownExtensions.pop();

        if (postId !== undefined && postId !== null && !this.extensionIsKnown(postId)) {
          this.imageBitmapFetchers[0].postMessage({
            findExtension: true,
            postId
          });
          await sleep(10);
        }
      }
    }
  }

  /**
   * @returns {String[]}
   */
  getPostIdsWithUnknownExtensions() {
    return Array.from(getAllThumbs())
      .filter(thumb => isImage(thumb))
      .filter(thumb => !this.extensionIsKnown(thumb.id))
      .map(thumb => thumb.id);
  }

  createVideoBackground() {
    const canvas = document.createElement("canvas");
    const context = canvas.getContext("2d");

    context.fillStyle = "black";
    context.fillRect(0, 0, canvas.width, canvas.height);
    canvas.toBlob((blob) => {
      this.videoContainer.setAttribute("poster", URL.createObjectURL(blob));
    });
  }

  createFullscreenCanvasImagePlaceholder() {
    this.fullscreenCanvasPlaceholder = document.createElement("img");
    this.fullscreenCanvasPlaceholder.src = "https://rule34.xxx/images/header2.png";
  }

  /**
   * @param {Number} duration
   */
  async pauseRendering(duration) {
    this.stopRendering = true;
    await sleep(duration);
    this.stopRendering = false;
  }

  /**
   * @param {String | Number} postId
   * @returns {String}
   */
  getImageExtension(postId) {
    return Renderer.extensionDecodings[this.imageExtensions[parseInt(postId)]];
  }

  /**
   * @param {String | Number} postId
   * @param {String} extension
   */
  setImageExtension(postId, extension) {
    this.imageExtensions[parseInt(postId)] = Renderer.extensionEncodings[extension];
  }
}

const renderer = new Renderer();


// tooltip.js

const tooltipHTML = `<div id="tooltip-container">
  <style>
    #tooltip {
      max-width: 750px;
      border: 1px solid black;
      padding: 0.25em;
      position: absolute;
      box-sizing: border-box;
      z-index: 25;
      pointer-events: none;
      visibility: hidden;
      opacity: 0;
      transition: visibility 0s, opacity 0.25s linear;
      font-size: 1.3em;
    }

    #tooltip.visible {
      visibility: visible;
      opacity: 1;
    }
  </style>
  <span id="tooltip" class="light-green-gradient"></span>
</div>`;

class Tooltip {
  /**
   * @type {HTMLDivElement}
   */
  tooltip;
  /**
   * @type {String}
   */
  defaultTransition;
  /**
   * @type {Boolean}
   */
  enabled;
  /**
   * @type {Object.<String,String>}
   */
  tagColorCodes;
  /**
   * @type {HTMLTextAreaElement}
   */
  searchBox;
  /**
   * @type {String}
   */
  previousSearch;

  constructor() {
    this.enabled = getPreference("showTooltip", true);
    document.body.insertAdjacentHTML("afterbegin", tooltipHTML);
    this.tooltip = document.getElementById("tooltip");
    this.defaultTransition = this.tooltip.style.transition;
    this.tagColorCodes = {};
    this.setTheme();
    this.addEventListeners();
    this.assignColorsToMatchedTags();
  }

  addEventListeners() {
    if (onPostPage()) {
      window.addEventListener("load", () => {
        this.addEventListenersToThumbs.bind(this)();
      });
    } else {
      this.addFavoritesOptions();
      window.addEventListener("favoritesFetched", (event) => {
        this.addEventListenersToThumbs.bind(this)(event.detail);
      });
      window.addEventListener("favoritesLoaded", () => {
        this.addEventListenersToThumbs.bind(this)();
      });
      window.addEventListener("thumbUnderCursorOnLoad", (event) => {
        this.showOnLoadIfHoveringOverThumb(event.detail);
      }, {
        once: true
      });
    }
  }

  setTheme() {
    if (usingDarkTheme()) {
      this.tooltip.classList.remove("light-green-gradient");
      this.tooltip.classList.add("dark-green-gradient");
    }
  }

  assignColorsToMatchedTags() {
    if (onPostPage()) {
      this.assignColorsToMatchedTagsOnPostPage();
    } else {
      this.searchBox = document.getElementById("favorites-search-box");
      this.assignColorsToMatchedTagsOnFavoritesPage();
      this.searchBox.addEventListener("input", () => {
        this.assignColorsToMatchedTagsOnFavoritesPage();
      });
      window.addEventListener("searchStarted", () => {
        this.assignColorsToMatchedTagsOnFavoritesPage();
      });

    }
  }

  /**
   * @param {HTMLCollectionOf.<Element>} thumbs
   */
  addEventListenersToThumbs(thumbs) {
    thumbs = thumbs === undefined ? getAllThumbs() : thumbs;

    for (const thumb of thumbs) {
      const image = getImageFromThumb(thumb);

      if (image.hasAttribute("hasTooltipListener")) {
        return;
      }
      image.onmouseenter = () => {
        if (this.enabled) {
          this.show(image);
        }
      };
      image.onmouseleave = (event) => {
        if (!enteredOverCaptionTag(event)) {
          this.hide();
        }
      };
      image.setAttribute("hasTooltipListener", true);
    }
  }

  /**
   * @param {HTMLImageElement} image
   */
  setPosition(image) {
    const imageRect = image.getBoundingClientRect();
    let tooltipRect;
    const offset = 7;

    this.tooltip.style.top = `${imageRect.bottom + offset + window.scrollY}px`;
    this.tooltip.style.left = `${imageRect.x - 3}px`;
    this.tooltip.classList.toggle("visible", true);
    tooltipRect = this.tooltip.getBoundingClientRect();
    const toolTipIsClippedAtBottom = tooltipRect.bottom > window.innerHeight;

    if (!toolTipIsClippedAtBottom) {
      return;
    }
    this.tooltip.style.top = `${imageRect.top - tooltipRect.height + window.scrollY - offset}px`;
    tooltipRect = this.tooltip.getBoundingClientRect();
    const favoritesTopBar = document.getElementById("favorites-top-bar");
    const elementAboveTooltip = favoritesTopBar === null ? document.getElementById("header") : favoritesTopBar;
    const elementAboveTooltipRect = elementAboveTooltip.getBoundingClientRect();
    const toolTipIsClippedAtTop = tooltipRect.top < elementAboveTooltipRect.bottom;

    if (!toolTipIsClippedAtTop) {
      return;
    }
    const tooltipIsLeftOfCenter = tooltipRect.left < (window.innerWidth / 2);

    this.tooltip.style.top = `${imageRect.top + window.scrollY + (imageRect.height / 2) - offset}px`;

    if (tooltipIsLeftOfCenter) {
      this.tooltip.style.left = `${imageRect.right + offset}px`;
    } else {
      this.tooltip.style.left = `${imageRect.left - 750 - offset}px`;
    }
  }

  /**
   * @param {String} tags
   */
  setText(tags) {
    this.tooltip.innerHTML = this.formatHTML(tags);
  }

  /**
   * @param {HTMLImageElement} image
   */
  show(image) {
    let tags = image.hasAttribute("tags") ? image.getAttribute("tags") : image.getAttribute("title");

    tags = this.removeIdFromTags(image, tags);
    this.setText(tags);
    this.setPosition(image);
  }

  hide() {
    this.tooltip.style.transition = "none";
    this.tooltip.classList.toggle("visible", false);
    setTimeout(() => {
      this.tooltip.style.transition = this.defaultTransition;
    }, 5);
  }

  /**
   * @param {HTMLImageElement} image
   * @param {String} tags
   * @returns
   */
  removeIdFromTags(image, tags) {
    const id = getThumbFromImage(image).id;

    if (this.tagColorCodes[id] === undefined) {
      tags = tags.replace(` ${id}`, "");
    }
    return tags;
  }

  /**
   * @returns {String}
   */
  getRandomColor() {
    const letters = "0123456789ABCDEF";
    let color = "#";

    for (let i = 0; i < 6; i += 1) {
      if (i === 2 || i === 3) {
        color += "0";
      } else {
        color += letters[Math.floor(Math.random() * letters.length)];
      }
    }
    return color;
  }

  /**
   * @param {String[]} allTags
   */
  formatHTML(allTags) {
    let html = "";
    const tags = allTags.split(" ");

    for (let i = tags.length - 1; i >= 0; i -= 1) {
      const tag = tags[i];
      const tagColor = this.getColorCode(tag);
      const tagWithSpace = `${tag} `;

      if (tagColor !== undefined) {
        html = `<span style="color:${tagColor}"><b>${tagWithSpace}</b></span>${html}`;
      } else if (includesTag(tag, TAG_BLACKLIST)) {
        html += `<span style="color:red"><s><b>${tagWithSpace}</b></s></span>`;
      } else {
        html += tagWithSpace;
      }

    }
    return html === "" ? allTags : html;
  }

  /**
   * @param {String} searchQuery
   */
  assignTagColors(searchQuery) {
    searchQuery = this.removeNotTags(searchQuery);
    const {orGroups, remainingSearchTags} = extractTagGroups(searchQuery);

    this.tagColorCodes = {};
    this.assignColorsToOrGroupTags(orGroups);
    this.assignColorsToRemainingTags(remainingSearchTags);
  }

  /**
   * @param {String[][]} orGroups
   */
  assignColorsToOrGroupTags(orGroups) {

    for (const orGroup of orGroups) {
      const color = this.getRandomColor();

      for (const tag of orGroup) {
        this.addColorCodedTag(tag, color);
      }
    }
  }

  /**
   * @param {String[]} remainingTags
   */
  assignColorsToRemainingTags(remainingTags) {
    for (const tag of remainingTags) {
      this.addColorCodedTag(tag, this.getRandomColor());
    }
  }

  /**
   * @param {String} tags
   * @returns {String}
   */
  removeNotTags(tags) {
    return tags.replace(/(?:^| )-\S+/gm, "");
  }

  sanitizeTags(tags) {
    return tags.toLowerCase().trim();
  }

  addColorCodedTag(tag, color) {
    tag = this.sanitizeTags(tag);

    if (this.tagColorCodes[tag] === undefined) {
      this.tagColorCodes[tag] = color;
    }
  }

  /**
   * @param {String} tag
   * @returns {String | null}
   */
  getColorCode(tag) {
    if (this.tagColorCodes[tag] !== undefined) {
      return this.tagColorCodes[tag];
    }

    for (const [tagPrefix, _] of Object.entries(this.tagColorCodes)) {
      if (tagPrefix.endsWith("*")) {
        if (tag.startsWith(tagPrefix.replace(/\*$/, ""))) {
          return this.tagColorCodes[tagPrefix];
        }
      }
    }
    return undefined;
  }

  addFavoritesOptions() {
    addOptionToFavoritesPage(
      "show-tooltip",
      " Tooltips",
      "Show related tags when hovering over a thumbnail",
      this.enabled, (event) => {
        setPreference("showTooltip", event.target.checked);
        this.setVisible(event.target.checked);
      },
      true
    );
  }

  /**
   * @param {HTMLElement | null} thumb
   */
  showOnLoadIfHoveringOverThumb(thumb) {
    if (thumb !== null) {
      this.show(getImageFromThumb(thumb));
    }
  }

  /**
   * @param {Boolean} value
   */
  setVisible(value) {
    this.enabled = value;
  }

  assignColorsToMatchedTagsOnPostPage() {
    const searchQuery = document.getElementsByName("tags")[0].getAttribute("value");

    this.assignTagColors(searchQuery);
  }

  assignColorsToMatchedTagsOnFavoritesPage() {
    if (this.searchBox.value === this.previousSearch) {
      return;
    }
    this.previousSearch = this.searchBox.value;
    this.assignTagColors(this.searchBox.value);
  }
}

const tooltip = new Tooltip();


// saved_searches.js

const savedSearchesHTML = `<div id="saved-searches">
  <style>
    #saved-searches-container {
      margin: 0;
      display: flex;
      flex-direction: column;
      padding: 0;
    }

    #saved-searches-input-container {
      margin-bottom: 10px;
    }

    #saved-searches-input {
      flex: 15 1 auto;
      margin-right: 10px;
    }

    #savedSearches {
      max-width: 100%;

      button {
        flex: 1 1 auto;
        cursor: pointer;
      }
    }

    #saved-searches-buttons button {
      margin-right: 1px;
      margin-bottom: 5px;
      border: none;
      border-radius: 4px;
      cursor: pointer;
      height: 35px;

      &:hover {
        filter: brightness(140%);
      }
    }

    #saved-search-list-container {
      direction: rtl;
      max-height: 200px;
      overflow-y: auto;
      overflow-x: hidden;
      margin: 0;
      padding: 0;
    }

    #saved-search-list {
      direction: ltr;
      >li {
        display: flex;
        flex-direction: row;
        cursor: pointer;
        background: rgba(0, 0, 0, .1);

        &:nth-child(odd) {
          background: rgba(0, 0, 0, 0.2);
        }

        >div {
          padding: 4px;
          align-content: center;

          svg {
            height: 20px;
            width: 20px;
          }
        }
      }
    }

    .save-search-label {
      flex: 1000 30px;
      text-align: left;

      &:hover {
        color: white;
        background: #0075FF;
      }
    }

    .edit-saved-search-button {
      text-align: center;
      flex: 1 20px;

      &:hover {
        color: white;
        background: slategray;
      }
    }

    .remove-saved-search-button {
      text-align: center;
      flex: 1 20px;

      &:hover {
        color: white;
        background: #f44336;
      }
    }

    .move-saved-search-to-top-button {
      text-align: center;

      &:hover {
        color: white;
        background: steelblue;
      }
    }
  </style>
  <h2>Saved Searches</h2>
  <div id="saved-searches-buttons">
    <button title="Save custom search" id="save-custom-search-button">Save</button>
    <button title="Save results as search" id="save-results-button">Save Results</button>
    <button id="stop-editing-saved-search-button" style="display: none;">Cancel</button>
    <span>
      <button id="export-saved-search-button">Export</button>
      <button id="import-saved-search-button">Import</button>
    </span>
  </div>
  <div id="saved-searches-container">
    <div id="saved-searches-input-container">
      <textarea id="saved-searches-input" spellcheck="false" style="width: 97%;"
        placeholder="Save Custom Search"></textarea>
    </div>
    <div id="saved-search-list-container">
      <div id="saved-search-list"></div>
    </div>
  </div>
</div>
`;

class SavedSearches {
  static preferences = {
    textareaWidth: "savedSearchesTextAreaWidth",
    textareaHeight: "savedSearchesTextAreaHeight",
    savedSearches: "savedSearches",
    visibility: "savedSearchVisibility",
    tutorial: "savedSearchesTutorial"
  };
  static localStorageKeys = {
    savedSearches: "savedSearches"
  };
  static icons = {
    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>",
    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>",
    copy: "<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-copy\"><rect x=\"9\" y=\"9\" width=\"13\" height=\"13\" rx=\"2\" ry=\"2\"></rect><path d=\"M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1\"></path></svg>",
    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>"
  };
  /**
   * @type {HTMLTextAreaElement}
   */
  textarea;
  /**
   * @type {HTMLElement}
   */
  savedSearchesList;
  /**
   * @type {HTMLButtonElement}
   */
  stopEditingButton;
  /**
   * @type {HTMLButtonElement}
   */
  saveButton;
  /**
   * @type {HTMLButtonElement}
   */
  importButton;
  /**
   * @type {HTMLButtonElement}
   */
  exportButton;
  /**
   * @type {HTMLButtonElement}
   */
  saveSearchResultsButton;

  constructor() {
    if (onPostPage()) {
      return;
    }
    this.initialize();
  }

  initialize() {
    this.insertHTMLIntoDocument();
    this.saveButton = document.getElementById("save-custom-search-button");
    this.textarea = document.getElementById("saved-searches-input");
    this.savedSearchesList = document.getElementById("saved-search-list");
    this.stopEditingButton = document.getElementById("stop-editing-saved-search-button");
    this.importButton = document.getElementById("import-saved-search-button");
    this.exportButton = document.getElementById("export-saved-search-button");
    this.saveSearchResultsButton = document.getElementById("save-results-button");
    this.addEventListeners();
    this.loadSavedSearches();
  }

  insertHTMLIntoDocument() {
    const showSavedSearches = getPreference(SavedSearches.preferences.visibility, true);
    let placeToInsertSavedSearches = document.getElementById("right-favorites-panel");

    if (placeToInsertSavedSearches === null) {
      placeToInsertSavedSearches = document.getElementById("favorites-top-bar");
    }
    placeToInsertSavedSearches.insertAdjacentHTML("beforeend", savedSearchesHTML);
    document.getElementById("saved-searches").style.display = showSavedSearches ? "block" : "none";
    const options = addOptionToFavoritesPage(
      "savedSearchesCheckbox",
      "Saved Searches",
      "Toggle saved searches",
      showSavedSearches,
      (e) => {
        document.getElementById("saved-searches").style.display = e.target.checked ? "block" : "none";
        setPreference(SavedSearches.preferences.visibility, e.target.checked);
      },
      true
    );

    document.getElementById("show-options").insertAdjacentElement("afterend", options);
  }

  addEventListeners() {
    this.saveButton.onclick = () => {
      this.saveSearch(this.textarea.value.trim());
    };
    this.textarea.addEventListener("keydown", (event) => {

      switch (event.key) {
        case "Enter":
          if (awesompleteIsUnselected(this.textarea)) {
            event.preventDefault();
            this.saveButton.click();
            this.textarea.blur();
            setTimeout(() => {
              this.textarea.focus();
            }, 100);
          }
          break;

        case "Escape":
          if (awesompleteIsUnselected(this.textarea) && this.stopEditingButton.style.display === "block") {
            this.stopEditingButton.click();
          }
          break;

        default:
          break;
      }
    });
    this.exportButton.onclick = () => {
      this.exportSavedSearches();
    };
    this.importButton.onclick = () => {
      this.importSavedSearches();
    };
    this.saveSearchResultsButton.onclick = () => {
      this.copySearchResultIdsToClipboard();
    };
  }

  /**
   * @param {String} newSavedSearch
   */
  saveSearch(newSavedSearch) {
    if (newSavedSearch === "" || newSavedSearch === undefined) {
      return;
    }
    const newListItem = document.createElement("li");
    const savedSearchLabel = document.createElement("div");
    const editButton = document.createElement("div");
    const removeButton = document.createElement("div");
    const moveToTopButton = document.createElement("div");

    savedSearchLabel.innerText = newSavedSearch;
    editButton.innerHTML = SavedSearches.icons.edit;
    removeButton.innerHTML = SavedSearches.icons.delete;
    moveToTopButton.innerHTML = SavedSearches.icons.upArrow;
    savedSearchLabel.className = "save-search-label";
    editButton.className = "edit-saved-search-button";
    removeButton.className = "remove-saved-search-button";
    moveToTopButton.className = "move-saved-search-to-top-button";
    newListItem.appendChild(removeButton);
    newListItem.appendChild(editButton);
    newListItem.appendChild(moveToTopButton);
    newListItem.appendChild(savedSearchLabel);
    this.savedSearchesList.insertBefore(newListItem, this.savedSearchesList.firstChild);
    savedSearchLabel.onclick = () => {
      const searchBox = document.getElementById("favorites-search-box");

      navigator.clipboard.writeText(savedSearchLabel.innerText);

      if (searchBox === null) {
        return;
      }

      if (searchBox.value !== "") {
        searchBox.value += " ";
      }
      searchBox.value += savedSearchLabel.innerText;
    };
    removeButton.onclick = () => {
      if (this.inEditMode()) {
        alert("Cancel current edit before removing another search");
        return;
      }

      if (confirm(`Remove Saved Search: ${savedSearchLabel.innerText} ?`)) {
        this.savedSearchesList.removeChild(newListItem);
        this.storeSavedSearches();
      }
    };
    editButton.onclick = () => {
      if (this.inEditMode()) {
        alert("Cancel current edit before editing another search");
      } else {
        this.editSavedSearches(savedSearchLabel, newListItem);
      }
    };
    moveToTopButton.onclick = () => {
      if (this.inEditMode()) {
        alert("Cancel current edit before moving this search to the top");
        return;
      }
      newListItem.parentElement.insertAdjacentElement("afterbegin", newListItem);
    };
    this.stopEditingButton.onclick = () => {
      this.stopEditingSavedSearches(newListItem);
    };
    this.textarea.value = "";
    this.storeSavedSearches();
  }

  /**
   * @param {HTMLLabelElement} savedSearchLabel
   */
  editSavedSearches(savedSearchLabel) {
    this.textarea.value = savedSearchLabel.innerText;
    this.saveButton.textContent = "Save Changes";
    this.textarea.focus();
    this.exportButton.style.display = "none";
    this.importButton.style.display = "none";
    this.stopEditingButton.style.display = "";
    this.saveButton.onclick = () => {
      savedSearchLabel.innerText = this.textarea.value.trim();
      this.storeSavedSearches();
      this.stopEditingButton.click();
    };
  }

  /**
   * @param {HTMLElement} newListItem
   */
  stopEditingSavedSearches(newListItem) {
    this.saveButton.textContent = "Save";
    this.saveButton.onclick = () => {
      this.saveSearch(this.textarea.value.trim());
    };
    this.textarea.value = "";
    this.exportButton.style.display = "";
    this.importButton.style.display = "";
    this.stopEditingButton.style.display = "none";
    newListItem.style.border = "";
  }

  storeSavedSearches() {
    const savedSearches = JSON.stringify(Array.from(document.getElementsByClassName("save-search-label"))
      .map(element => element.innerText));

    localStorage.setItem(SavedSearches.localStorageKeys.savedSearches, savedSearches);
  }

  loadSavedSearches() {
    const savedSearches = JSON.parse(localStorage.getItem(SavedSearches.localStorageKeys.savedSearches)) || [];
    const firstUse = getPreference(SavedSearches.preferences.tutorial, true);

    setPreference(SavedSearches.preferences.tutorial, false);

    if (firstUse && savedSearches.length === 0) {
      this.createTutorialSearches();
      return;
    }

    for (let i = savedSearches.length - 1; i >= 0; i -= 1) {
      this.saveSearch(savedSearches[i]);
    }
  }

  createTutorialSearches() {
    const searches = [];

    window.addEventListener("startedFetchingFavorites", async() => {
      await sleep(1000);
      const postIds = getAllVisibleThumbs().map(thumb => thumb.id);

      shuffleArray(postIds);

      const exampleSearch = `( EXAMPLE: ~ ${postIds.slice(0, 9).join(" ~ ")} ) ( male* ~ female* ~ 1boy ~ 1girls )`;

      searches.push(exampleSearch);

      for (let i = searches.length - 1; i >= 0; i -= 1) {
        this.saveSearch(searches[i]);
      }
    });
  }

  /**
   * @returns {Boolean}
   */
  inEditMode() {
    return this.stopEditingButton.style.display !== "none";
  }

  exportSavedSearches() {
    const savedSearchString = Array.from(document.getElementsByClassName("save-search-label")).map(search => search.innerText).join("\n");

    navigator.clipboard.writeText(savedSearchString);
    alert("Copied saved searches to clipboard");
  }

  importSavedSearches() {
    const doesNotHaveSavedSearches = this.savedSearchesList.querySelectorAll("li").length === 0;

    if (doesNotHaveSavedSearches || confirm("are you sure you want to import saved searches? ( this will overwrite current saved searches )")) {
      const savedSearches = this.textarea.value.split("\n");

      this.savedSearchesList.innerHTML = "";

      for (let i = savedSearches.length - 1; i >= 0; i -= 1) {
        this.saveSearch(savedSearches[i]);
      }
      this.storeSavedSearches();
    }
  }

  copySearchResultIdsToClipboard() {
    const resultIds = [];

    for (const thumb of getAllVisibleThumbs()) {
      resultIds.push(thumb.id);
    }

    if (resultIds.length === 0) {
      return;
    }

    if (resultIds.length > 300) {
      if (!confirm(`Are you sure you want to save ${resultIds.length} ids as one search?`));
    }

    const customSearch = `( ${resultIds.join(" ~ ")} )`;

    this.saveSearch(customSearch);
  }
}

const savedSearches = new SavedSearches();


// caption.js

const captionHTML = `<style>
  .caption {
    overflow: hidden;
    pointer-events: none;
    background: rgba(0, 0, 0, .75);
    z-index: 15;
    position: absolute;
    width: 100%;
    height: 100%;
    top: -100%;
    left: 0px;
    top: 0px;
    text-align: left;
    transform: translateX(-100%);
    /* transition: transform .3s cubic-bezier(.26,.28,.2,.82); */
    transition: transform .4s ease;
    padding-left: 5px;
    -webkit-touch-callout: none;
    -webkit-user-select: none;
    -khtml-user-select: none;
    -moz-user-select: none;
    -ms-user-select: none;
    user-select: none;

    h6 {
      display: block;
      color: white;
      padding-top: 0px;
    }

    li {
      width: fit-content;
      list-style-type: none;
      display: inline-block;
    }

    &.active {
        transform: translateX(0%);
    }

    &.transition-completed {
      .caption-tag {
        pointer-events: all;
      }
    }
  }

  .caption.hide {
    display: none;
  }

  .caption.inactive {
    display: none;
  }

  .caption-tag {
    pointer-events: none;
    color: #6cb0ff;
    word-wrap: break-word;

    &:hover {
      text-decoration-line: underline;
      cursor: pointer;
    }
  }

  .artist-tag {
    color: #f0a0a0;
  }

  .character-tag {
    color: #f0f0a0;
  }

  .copyright-tag {
    color: #e73ee7;
  }

  .metadata-tag {
    color: #FF8800;
  }

  .caption-wrapper {
    pointer-events: none;
    position: absolute !important;
    overflow: hidden;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    display: block !important;
  }
</style>`;

class Caption {
  static preferences = {
    visibility: "showCaptions"
  };
  static localStorageKeys = {
    tagCategories: "tagCategories"
  };
  static importantTagCategories = new Set([
    "copyright",
    "character",
    "artist"
    // "metadata"
  ]);
  static tagCategoryEncodings = {
    0: "general",
    1: "artist",
    2: "metadata",
    3: "copyright",
    4: "character"
  };
  static template = `
     <ul id="caption-list">
         <li id="caption-id" style="display: block;"><h6>ID</h6></li>
         ${Caption.getCategoryHeaderHTML()}
     </ul>
 `;

  /**
   * @returns {String}
   */
  static getCategoryHeaderHTML() {
    let html = "";

    for (const category of Caption.importantTagCategories) {
      const capitalizedCategory = capitalize(category);
      const header = capitalizedCategory === "Metadata" ? "Meta" : capitalizedCategory;

      html += `<li id="caption${capitalizedCategory}" style="display: none;"><h6>${header}</h6></li>`;
    }
    return html;
  }

  /**
   * @param {String} tagCategory
   * @returns {Number}
   */
  static getTagCategoryEncoding(tagCategory) {
    for (const [encoding, category] of Object.entries(Caption.tagCategoryEncodings)) {
      if (category === tagCategory) {
        return encoding;
      }
    }
    return 0;
  }

  /**
   * @type {Boolean}
   */
  get disabled() {
    return this.caption.classList.contains("hide") || this.caption.classList.contains("disabled") || this.caption.classList.contains("remove");
  }

  /**
   * @type {HTMLDivElement}
   */
  captionWrapper;
  /**
   * @type {HTMLDivElement}
   */
  caption;
  /**
   * @type {Object.<String, Number>}
   */
  tagCategoryAssociations;
  /**
   * @type {String[]}
   */
  problematicTags;
  /**
   * @type {Boolean}
   */
  currentlyCorrectingProblematicTags;
  /**
   * @type {String}
   */
  currentThumbId;

  constructor() {
    this.tagCategoryAssociations = this.loadSavedTags();
    this.problematicTags = [];
    this.currentlyCorrectingProblematicTags = false;
    this.previousThumb = null;
    this.currentThumbId = null;
    this.findCategoriesOfAllTags();
    this.create();
    this.injectHTML();
    this.setVisibility(this.getVisibilityPreference());
    this.addEventListeners();
  }

  create() {
    this.captionWrapper = document.createElement("div");
    this.captionWrapper.className = "caption-wrapper";
    this.caption = document.createElement("div");
    this.caption.className = "caption";
    this.captionWrapper.appendChild(this.caption);
    document.head.appendChild(this.captionWrapper);
    this.caption.innerHTML = Caption.template;
  }

  injectHTML() {
    injectStyleHTML(captionHTML);
    addOptionToFavoritesPage(
      "show-captions",
      "Details",
      "Show details when hovering over thumbnail",
      this.getVisibilityPreference(),
      (event) => {
        this.setVisibility(event.target.checked);
      },
      true
    );
  }

  async addEventListenersToThumbs() {
    await sleep(500);
    const thumbs = getAllThumbs();

    for (const thumb of thumbs) {
      const imageContainer = getImageFromThumb(thumb).parentElement;

      if (imageContainer.hasAttribute("has-caption-listener")) {
        continue;
      }
      imageContainer.setAttribute("has-caption-listener", true);
      imageContainer.addEventListener("mouseenter", () => {
        this.show(thumb);
      });
      imageContainer.addEventListener("mouseleave", () => {
        this.hide(thumb);
      });
    }
  }

  /**
   * @param {HTMLElement} thumb
   */
  show(thumb) {
    if (this.disabled || thumb === null) {
      return;
    }
    thumb.querySelectorAll(".caption-wrapper-clone").forEach(element => element.remove());
    this.caption.classList.remove("inactive");
    this.caption.innerHTML = Caption.template;
    this.captionWrapper.removeAttribute("style");
    const captionIdHeader = document.getElementById("caption-id");
    const captionIdTag = document.createElement("li");

    captionIdTag.className = "caption-tag";
    captionIdTag.textContent = thumb.id;
    captionIdTag.onclick = () => {
      this.tagOnClick(thumb.id);
    };
    captionIdTag.addEventListener("contextmenu", (event) => {
      event.preventDefault();
      this.tagOnClick(`-${thumb.id}`);
    });
    captionIdHeader.insertAdjacentElement("afterend", captionIdTag);
    thumb.children[0].appendChild(this.captionWrapper);
    this.populateTags(thumb);
  }

  /**
   * @param {HTMLElement} thumb
   */
  hide(thumb) {
    if (this.disabled) {
      return;
    }
    this.animateExit(thumb);
    this.animate(false);
    this.caption.classList.add("inactive");
    this.caption.classList.remove("transition-completed");
  }

  /**
   * @param {HTMLElement} thumb
   */
  animateExit(thumb) {
    const captionWrapperClone = this.captionWrapper.cloneNode(true);
    const captionClone = captionWrapperClone.children[0];

    thumb.querySelectorAll(".caption-wrapper-clone").forEach(element => element.remove());
    captionWrapperClone.classList.add("caption-wrapper-clone");
    captionWrapperClone.querySelectorAll("*").forEach(element => element.removeAttribute("id"));
    captionClone.ontransitionend = () => {
      captionWrapperClone.remove();
    };
    thumb.children[0].appendChild(captionWrapperClone);
    setTimeout(() => {
      captionClone.classList.remove("active");
    }, 4);
  }

  /**
   * @param {HTMLElement} thumb
   */
  resizeFont(thumb) {
    const imageRect = getImageFromThumb(thumb).getBoundingClientRect();
    const captionListRect = this.caption.children[0].getBoundingClientRect();
    const ratio = imageRect.height / captionListRect.height;
    const scale = ratio > 1 ? Math.sqrt(ratio) : ratio * 0.85;

    this.caption.parentElement.style.fontSize = `${roundToTwoDecimalPlaces(scale)}em`;
  }

  /**
   * @param {String} tagCategory
   * @param {String} tagName
   */
  addTag(tagCategory, tagName) {
    if (!Caption.importantTagCategories.has(tagCategory)) {
      return;
    }
    const header = document.getElementById(this.getCategoryHeaderId(tagCategory));
    const tag = document.createElement("li");

    tag.className = `${tagCategory}-tag caption-tag`;
    tag.textContent = this.replaceUnderscoresWithSpaces(tagName);
    header.insertAdjacentElement("afterend", tag);
    header.style.display = "block";
    tag.onmouseover = (event) => {
      event.stopPropagation();
    };
    tag.onclick = () => {
      this.tagOnClick(tagName);
    };
    tag.addEventListener("contextmenu", (event) => {
      event.preventDefault();
      this.tagOnClick(`-${this.replaceSpacesWithUnderscores(tag.textContent)}`);
    });
  }

  addEventListeners() {
    this.caption.addEventListener("transitionend", () => {
      if (this.caption.classList.contains("active")) {
        this.caption.classList.add("transition-completed");
      }
      this.caption.classList.remove("transitioning");
    });
    this.caption.addEventListener("transitionstart", () => {
      this.caption.classList.add("transitioning");
    });

    if (onPostPage()) {
      window.addEventListener("load", () => {
        this.addEventListenersToThumbs.bind(this)();
      }, {
        once: true,
        passive: true
      });
    } else {
      window.addEventListener("favoritesLoaded", this.addEventListenersToThumbs.bind(this)(), {
        once: true
      });
      window.addEventListener("favoritesFetched", () => {
        this.addEventListenersToThumbs.bind(this)();
      });
      window.addEventListener("thumbUnderCursorOnLoad", (event) => {
        const showOnHoverCheckbox = document.getElementById("showOnHover");

        if (showOnHoverCheckbox !== null && showOnHoverCheckbox.checked) {
          this.show(event.detail);
        }
      });
      window.addEventListener("showCaption", (event) => {
        this.show(event.detail);
      });
    }
  }

  /**
   * @returns {Object.<String, Number>}
   */
  loadSavedTags() {
    return JSON.parse(localStorage.getItem(Caption.localStorageKeys.tagCategories) || "{}");
  }

  saveTags() {
    localStorage.setItem(Caption.localStorageKeys.tagCategories, JSON.stringify(this.tagCategoryAssociations));
  }

  /**
   * @param {String} value
   */
  tagOnClick(value) {
    const searchBox = onPostPage() ? document.getElementsByName("tags")[0] : document.getElementById("favorites-search-box");
    const searchBoxDoesNotIncludeTag = true;
    // const searchBoxDoesNotIncludeTag = searchBox !== null && !searchBox.value.includes(` ${value}`);

    navigator.clipboard.writeText(value);

    if (searchBoxDoesNotIncludeTag) {
      searchBox.value += ` ${value}`;
      searchBox.focus();
      value = searchBox.value;
      searchBox.value = "";
      searchBox.value = value;
    }
  }

  /**
   * @param {String} tagName
   * @returns {String}
   */
  replaceUnderscoresWithSpaces(tagName) {
    return tagName.replace(/_/gm, " ");
  }

  /**
   * @param {String} tagName
   * @returns {String}
   */
  replaceSpacesWithUnderscores(tagName) {
    return tagName.replace(/\s/gm, "_");
  }

  /**
   * @param {Boolean} value
   */
  setVisibility(value) {
    if (value) {
      this.caption.classList.remove("disabled");
    } else if (!this.caption.classList.contains("disabled")) {
      this.caption.classList.add("disabled");
    }
    setPreference(Caption.preferences.visibility, value);
  }

  /**
   * @returns {Boolean}
   */
  getVisibilityPreference() {
    return getPreference(Caption.preferences.visibility, true);
  }

  /**
   * @param {Boolean} value
   */
  animate(value) {
    this.caption.classList.toggle("active", value);
  }

  /**
   * @param {String} tagCategory
   * @returns {String}
   */
  getCategoryHeaderId(tagCategory) {
    return `caption${capitalize(tagCategory)}`;
  }

  /**
   * @param {HTMLElement} thumb
   */
  populateTags(thumb) {
    const tagNames = getTagsFromThumb(thumb).replace(/\s\d+$/, "")
      .split(" ");
    const unknownThumbTags = tagNames
      .filter(tag => this.tagCategoryAssociations[tag] === undefined);

    this.currentThumbId = thumb.id;

    if (unknownThumbTags.length > 0) {
      this.findTagCategories(unknownThumbTags, 1, () => {
        this.addTags(tagNames, thumb);
      });
      return;
    }
    this.addTags(tagNames, thumb);
  }

  /**
   * @param {String[]} tagNames
   * @param {HTMLElement} thumb
   */
  addTags(tagNames, thumb) {
    this.saveTags();

    if (this.currentThumbId !== thumb.id) {
      return;
    }

    if (thumb.getElementsByClassName("caption-tag").length > 1) {
      return;
    }

    for (const tagName of tagNames) {
      const category = this.getTagCategory(tagName);

      this.addTag(category, tagName);
    }
    this.resizeFont(thumb);
    this.animate(true);
  }

  /**
   * @param {String} tagName
   * @returns {String}
   */
  getTagCategory(tagName) {
    const encoding = this.tagCategoryAssociations[tagName];

    if (encoding === undefined) {
      return "general";
    }
    return Caption.tagCategoryEncodings[encoding];
  }

  /**
   * @param {String} problematicTag
   */
  async correctProblematicTag(problematicTag) {
    this.problematicTags.push(problematicTag);

    if (this.currentlyCorrectingProblematicTags) {
      return;
    }
    this.currentlyCorrectingProblematicTags = true;

    while (this.problematicTags.length > 0) {
      const tagName = this.problematicTags.pop();
      const tagPageURL = `https://rule34.xxx/index.php?page=tags&s=list&tags=${tagName}`;

      await fetch(tagPageURL)
        .then((response) => {
          if (response.ok) {
            return response.text();
          }
          throw new Error(response.statusText);
        })
        .then((html) => {
          const dom = new DOMParser().parseFromString(html, "text/html");
          const columnOfFirstRow = dom.getElementsByClassName("highlightable")[0].getElementsByTagName("td");

          if (columnOfFirstRow.length !== 3) {
            this.tagCategoryAssociations[tagName] = 0;
            this.saveTags();
            return;
          }
          const category = columnOfFirstRow[2].textContent.split(",")[0].split(" ")[0];

          this.tagCategoryAssociations[tagName] = Caption.getTagCategoryEncoding(category);
          this.saveTags();
        });
    }
    this.currentlyCorrectingProblematicTags = false;
  }

  /**
   * @param {String[]} tagNames
   * @param {Number} fetchDelay
   * @param {Function} onAllCategoriesFound
   */
  async findTagCategories(tagNames, fetchDelay, onAllCategoriesFound) {
    const parser = new DOMParser();
    const lastTagName = tagNames[tagNames.length - 1];
    const uniqueTagNames = new Set(tagNames);

    for (const tagName of uniqueTagNames) {
      const apiURL = `https://api.rule34.xxx//index.php?page=dapi&s=tag&q=index&name=${encodeURIComponent(tagName)}`;

      fetch(apiURL)
        .then((response) => {
          if (response.ok) {
            return response.text();
          }
          throw new Error(response.statusText);
        })
        .then((html) => {
          const dom = parser.parseFromString(html, "text/html");
          const encoding = dom.getElementsByTagName("tag")[0].getAttribute("type");

          if (encoding === "array") {
            this.correctProblematicTag(tagName);
            return;
          }

          this.tagCategoryAssociations[tagName] = encoding;

          if (tagName === lastTagName && onAllCategoriesFound !== undefined) {
            onAllCategoriesFound();
          }
        });
      await sleep(fetchDelay);
    }
  }

  /**
   * @param {HTMLElement[]} thumbs
   * @returns {String[]}
   */
  getTagNamesWithUnknownCategories(thumbs) {
    return Array.from(thumbs)
      .map(thumb => getTagsFromThumb(thumb).replace(/ \d+$/, ""))
      .join(" ")
      .split(" ")
      .filter(tagName => this.tagCategoryAssociations[tagName] === undefined);
  }

  findCategoriesOfAllTags() {
    window.addEventListener("originalContentCleared", (event) => {
      const thumbs = event.detail;
      const tagNames = this.getTagNamesWithUnknownCategories(thumbs);

      this.findTagCategories(tagNames, 3, () => {
        this.saveTags();
      });
    });
    window.addEventListener("favoritesLoaded", () => {
      const allTagNames = this.getTagNamesWithUnknownCategories(getAllThumbs);

      if (allTagNames.length === 0) {
        return;
      }
      this.findTagCategories(allTagNames, 2, () => {
        this.saveTags();
      });
    });
  }
}

if (!onPostPage()) {
  const caption = new Caption();
}


// awesomplete.min.js

// Awesomplete - Lea Verou - MIT license
!(function () {
    function t(t) {
        const e = Array.isArray(t) ? {
            label: t[0],
            value: t[1]
        } : typeof t === "object" && t != null && "label" in t && "value" in t ? t : {
            label: t,
            value: t
        };

        this.label = e.label || e.value, this.value = e.value, this.type = e.type;
    }

    function e(t, e, i) {
        for (const n in e) {
            const s = e[n],
                r = t.input.getAttribute(`data-${n.toLowerCase()}`);

            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);
        }
    }

    function i(t, e) {
        return typeof t === "string" ? (e || document).querySelector(t) : t || null;
    }

    function n(t, e) {
        return o.call((e || document).querySelectorAll(t));
    }

    function s() {
        n("input.awesomplete").forEach((t) => {
            new r(t);
        });
    }

    var r = function (t, n) {
        const s = this;

        this.isOpened = !1, this.input = i(t), this.input.setAttribute("autocomplete", "off"), this.input.setAttribute("aria-autocomplete", "list"), n = n || {}, e(this, {
            minChars: 2,
            maxItems: 10,
            autoFirst: !1,
            data: r.DATA,
            filter: r.FILTER_CONTAINS,
            sort: !1 !== n.sort && r.SORT_BYLENGTH,
            item: r.ITEM,
            replace: r.REPLACE
        }, n), this.index = -1, this.container = i.create("div", {
            className: "awesomplete",
            around: t
        }), this.ul = i.create("ul", {
            hidden: "hidden",
            inside: this.container
        }), this.status = i.create("span", {
            className: "visually-hidden",
            role: "status",
            "aria-live": "assertive",
            "aria-relevant": "additions",
            inside: this.container
        }), this._events = {
            input: {
                input: this.evaluate.bind(this),
                blur: this.close.bind(this, {
                    reason: "blur"
                }),
                keypress(t) {
                    const e = t.keyCode;

                    if (s.opened) {

                        switch (e) {
                            case 13: // RETURN
                                if (s.selected == true) {
                                    t.preventDefault();
                                    s.select();
                                    break;
                                }

                            case 66:
                                break;

                            case 27: // ESC
                                s.close({
                                    reason: "esc"
                                });
                                break;
                        }
                    }
                },
                keydown(t) {
                    const e = t.keyCode;

                    if (s.opened) {
                        switch (e) {
                            case 9: // TAB
                                if (s.selected == true) {
                                    t.preventDefault();
                                    s.select();
                                    break;
                                }

                            case 38: // up arrow
                                t.preventDefault();
                                s.previous();
                                break;

                            case 40:
                                t.preventDefault();
                                s.next();
                                break;
                        }
                    }
                }
            },
            form: {
                submit: this.close.bind(this, {
                    reason: "submit"
                })
            },
            ul: {
                mousedown(t) {
                    let e = t.target;

                    if (e !== this) {
                        for (; e && !(/li/i).test(e.nodeName);) e = e.parentNode;
                        e && t.button === 0 && (t.preventDefault(), s.select(e, t.target));
                    }
                }
            }
        }, 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);
    };
    r.prototype = {
        set list(t) {
            if (Array.isArray(t)) this._list = t;
            else if (typeof t === "string" && t.indexOf(",") > -1) this._list = t.split(/\s*,\s*/);
            else if ((t = i(t)) && t.children) {
                const e = [];

                o.apply(t.children).forEach((t) => {
                    if (!t.disabled) {
                        const i = t.textContent.trim(),
                            n = t.value || i,
                            s = t.label || i;

                        n !== "" && e.push({
                            label: s,
                            value: n
                        });
                    }
                }), this._list = e;
            }
            document.activeElement === this.input && this.evaluate();
        },
        get selected() {
            return this.index > -1;
        },
        get opened() {
            return this.isOpened;
        },
        close(t) {
            this.opened && (this.ul.setAttribute("hidden", ""), this.isOpened = !1, this.index = -1, i.fire(this.input, "awesomplete-close", t || {}));
        },
        open() {
            this.ul.removeAttribute("hidden"), this.isOpened = !0, this.autoFirst && this.index === -1 && this.goto(0), i.fire(this.input, "awesomplete-open");
        },
        destroy() {
            i.unbind(this.input, this._events.input), i.unbind(this.input.form, this._events.form);
            const t = this.container.parentNode;

            t.insertBefore(this.input, this.container), t.removeChild(this.container), this.input.removeAttribute("autocomplete"), this.input.removeAttribute("aria-autocomplete");
            const e = r.all.indexOf(this);

            e !== -1 && r.all.splice(e, 1);
        },
        next() {
            const t = this.ul.children.length;

            this.goto(this.index < t - 1 ? this.index + 1 : t ? 0 : -1);
        },
        previous() {
            const t = this.ul.children.length,
                e = this.index - 1;

            this.goto(this.selected && e !== -1 ? e : t - 1);
        },
        goto(t) {
            const e = this.ul.children;

            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", {
                text: this.suggestions[this.index]
            }));
        },
        select(t, e) {
            if (t ? this.index = i.siblingIndex(t) : t = this.ul.children[this.index], t) {
                const n = this.suggestions[this.index];

                i.fire(this.input, "awesomplete-select", {
                    text: n,
                    origin: e || t
                }) && (this.replace(n), this.close({
                    reason: "select"
                }), i.fire(this.input, "awesomplete-selectcomplete", {
                    text: n
                }));
            }
        },
        evaluate() {
            const e = this,
                i = this.input.value;

            i.length >= this.minChars && this._list.length > 0 ? (this.index = -1, this.ul.innerHTML = "", this.suggestions = this._list.map((n) => {
                return new t(e.data(n, i));
            }).filter((t) => {
                return e.filter(t, i);
            }), !1 !== this.sort && (this.suggestions = this.suggestions.sort(this.sort)), this.suggestions = this.suggestions.slice(0, this.maxItems), this.suggestions.forEach((t) => {
                e.ul.appendChild(e.item(t, i));
            }), this.ul.children.length === 0 ? this.close({
                reason: "nomatches"
            }) : this.open()) : this.close({
                reason: "nomatches"
            });
        }
    }, r.all = [], r.FILTER_CONTAINS = function (t, e) {
        return RegExp(i.regExpEscape(e.trim()), "i").test(t);
    }, r.FILTER_STARTSWITH = function (t, e) {
        return RegExp(`^${i.regExpEscape(e.trim())}`, "i").test(t);
    }, r.SORT_BYLENGTH = function (t, e) {
        return t.length !== e.length ? t.length - e.length : t < e ? -1 : 1;
    }, r.ITEM = function (t, e) {
        return i.create("li", {
            innerHTML: e.trim() === "" ? t : t.replace(RegExp(i.regExpEscape(e.trim()), "gi"), "<mark>$&</mark>"),
            "aria-selected": "false"
        });
    }, r.REPLACE = function (t) {
        this.input.value = t.value;
    }, r.DATA = function (t) {
        return t;
    }, Object.defineProperty(t.prototype = Object.create(String.prototype), "length", {
        get() {
            return this.label.length;
        }
    }), t.prototype.toString = t.prototype.valueOf = function () {
        return `${this.label}`;
    };
    var o = Array.prototype.slice;
    i.create = function (t, e) {
        const n = document.createElement(t);

        for (const s in e) {
            const r = e[s];

            if (s === "inside") i(r).appendChild(n);
            else if (s === "around") {
                const o = i(r);

                o.parentNode.insertBefore(n, o), n.appendChild(o);
            } else s in n ? n[s] = r : n.setAttribute(s, r);
        }
        return n;
    }, i.bind = function (t, e) {
        if (t) for (const i in e) {
            var n = e[i];
            i.split(/\s+/).forEach((e) => {
                t.addEventListener(e, n);
            });
        }
    }, i.unbind = function (t, e) {
        if (t) for (const i in e) {
            var n = e[i];
            i.split(/\s+/).forEach((e) => {
                t.removeEventListener(e, n);
            });
        }
    }, i.fire = function (t, e, i) {
        const n = document.createEvent("HTMLEvents");

        n.initEvent(e, !0, !0);

        for (const s in i) n[s] = i[s];
        return t.dispatchEvent(n);
    }, i.regExpEscape = function (t) {
        return t.replace(/[-\\^$*+?.()|[\]{}]/g, "\\$&");
    }, i.siblingIndex = function (t) {
        for (var e = 0; t = t.previousElementSibling; e++);
        return e;
    }, 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);
}());

var decodeEntities = (function () {
    // this prevents any overhead from creating the object each time
    const element = document.createElement("div");

    function decodeHTMLEntities(str) {
        if (str && typeof str === "string") {
            // strip script/html tags
            str = str.replace(/<script[^>]*>([\S\s]*?)<\/script>/gmi, "");
            str = str.replace(/<\/?\w(?:[^"'>]|"[^"]*"|'[^']*')*>/gmi, "");
            element.innerHTML = str;
            str = element.textContent;
            element.textContent = "";
        }
        return str;
    }
    return decodeHTMLEntities;
}());


// awesomplete.js

/* eslint-disable new-cap */
class AwesompleteWrapper {
  constructor() {
    document.querySelectorAll("textarea").forEach((textarea) => {
      this.addAwesompleteToInput(textarea);
    });
  }

  /**
   * @param {HTMLTextAreaElement} input
   */
  addAwesompleteToInput(input) {
    const awesomplete = new Awesomplete_(input, {
      minChars: 1,
      list: [],
      filter: (suggestion, _) => {
        return Awesomplete_.FILTER_STARTSWITH(suggestion.value, this.getCurrentTag(awesomplete.input));
      },
      sort: false,
      item: (suggestion, tags) => {
        const html = tags.trim() === "" ? suggestion.label : suggestion.label.replace(RegExp(Awesomplete_.$.regExpEscape(tags.trim()), "gi"), "<mark>$&</mark>");
        return Awesomplete_.$.create("li", {
          innerHTML: html,
          "aria-selected": "false",
          className: `tag-type-${suggestion.type}`
        });
      },
      replace: (suggestion) => {
        this.insertSuggestion(awesomplete.input, decodeEntities(suggestion.value));
      }
    });

    input.oninput = () => {
      this.populateAwesompleteList(this.getCurrentTag(input), awesomplete);
    };
  }

  /**
   * @param {String} prefix
   * @param {Awesomplete_} awesomplete
   */
  populateAwesompleteList(prefix, awesomplete) {
    fetch(`https://rule34.xxx/autocomplete.php?q=${prefix}`)
      .then((response) => {
        if (response.ok) {
          return response.text();
        }
        throw new Error(response.status);
      })
      .then((suggestions) => {
        awesomplete.list = JSON.parse(suggestions);
      }).catch(() => {
      });
  }

  /**
   * @param {HTMLInputElement | HTMLTextAreaElement} input
   * @param {String} suggestion
   */
  insertSuggestion(input, suggestion) {
    const firstHalf = input.value.slice(0, input.selectionStart);
    const secondHalf = input.value.slice(input.selectionStart);
    const firstHalfWithPrefixRemoved = firstHalf.replace(/(?:^|\s)(-?)\S+$/, " $1");
    const result = removeExtraWhiteSpace(`${firstHalfWithPrefixRemoved}${suggestion} ${secondHalf} `);
    const newSelectionStart = firstHalfWithPrefixRemoved.length + suggestion.length + 1;

    input.value = `${result} `.replace(/^\s+/, "");
    input.selectionStart = newSelectionStart;
    input.selectionEnd = newSelectionStart;
  }

  /**
   * @param {HTMLInputElement | HTMLTextAreaElement} input
   * @returns {String}
   */
  getCurrentTag(input) {
    return this.getLastTag(input.value.slice(0, input.selectionStart));
  }

  /**
   * @param {String} searchQuery
   * @returns {String}
   */
  getLastTag(searchQuery) {
    const lastTag = searchQuery.match(/[^ -][^ ]*$/);
    return lastTag === null ? "" : lastTag[0];
  }
}

const awesomplete = new AwesompleteWrapper();