Rule34 Favorites Search Gallery

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

// ==UserScript==
// @name         Rule34 Favorites Search Gallery
// @namespace    bruh3396
// @version      1.06
// @description  Search, View, and Play Rule34 Favorites (Dekstop/Androiod/iOS)
// @author       bruh3396
// @compatible Chrome
// @compatible Edge
// @compatible Firefox
// @compatible Safari
// @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";
const FLAGS = {
  onPostPage: undefined,
  usingFirefox: undefined,
  onMobileDevice: undefined
};
const DEFAULTS = {
  columnCount: 6,
  resultsPerPage: 1000
};

/**
 * @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 delayIncrement = 500;

  setTimeout(() => {
    fetch((url))
      .then((response) => {
        if (response.status === 503) {
          requestPageInformation(url, callback, delay + delayIncrement);
        }
        return response.text();
      })
      .then((html) => {
        callback(html);
      });
  }, 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.<HTMLElement>}
 */
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() {
  if (onMobileDevice()) {
    return;
  }
  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 (FLAGS.onPostPage === undefined) {
    FLAGS.onPostPage = location.href.includes("page=post");
  }
  return FLAGS.onPostPage;
}

/**
 * @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;
    }

    .thumb-node,
    .thumb {
      >a,
      >span,
      >div {
        &:hover {
          outline: 3px solid #0075FF !important;
        }
      }
    }

    input[type=number] {
      border: 1px solid #767676;
      border-radius: 2px;
    }

    .size-calculation-div {
      position: absolute !important;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      visibility: hidden;
      transition: none !important;
      transform: scale(1.1, 1.1);
    }
  `, "utilities-common-styles");

  setTimeout(() => {
    if (onPostPage()) {
      removeInlineImgStyles();
    }
    configureVideoOutlines();
  }, 100);
}

/**
 * @param {Boolean} value
 */
function toggleFancyImageHovering(value) {
  if (onMobileDevice() || onPostPage()) {
    value = false;
  }

  if (!value) {
    const style = document.getElementById("fancy-image-hovering");

    if (style !== null) {
      style.remove();
    }
    return;
  }
  injectStyleHTML(`
    #content {
      padding: 40px 40px 30px !important;
      grid-gap: 2.5em !important;
    }

    .thumb-node,
    .thumb {
      >a,
      >span,
      >div {
        box-shadow: 0 1px 2px rgba(0,0,0,0.15);
        transition: all 0.3s ease-in-out;
        position: relative;


        &::after {
          content: '';
          position: absolute;
          z-index: -1;
          width: 100%;
          height: 100%;
          opacity: 0;
          top: 0;
          left: 0;
          border-radius: 5px;
          box-shadow: 5px 10px 15px rgba(0,0,0,0.45);
          transition: opacity 0.3s ease-in-out;
        }

        &:hover {
          outline: none !important;
          transform: scale(1.1, 1.1);
          z-index: 10;

          img {
            outline: none !important;
          }

          &::after {
            opacity: 1;
          }
        }
      }
    }
    `, "fancy-image-hovering");
}

function configureVideoOutlines() {
  const size = onMobileDevice() ? 2 : 3;

  injectStyleHTML(`img.video {outline: ${size}px 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");

        injectStyleHTML(`
          input[type=number] {
            background-color: #303030;
            color: white;
          }
          `, "dark-theme-number-input");
      }
    }
  }, 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() {
  const enableOnSearchPages = getPreference("enableOnSearchPages", true);

  if (!enableOnSearchPages && onPostPage()) {
    throw new Error("Disabled on search pages");
  }

  injectCommonStyles();
  toggleFancyImageHovering(true);
  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() {
  if (onMobileDevice()) {
    return null;
  }
  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() {
  if (onMobileDevice()) {
    return null;
  }
  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 hasVideoTag = (/(?:^|\s)video(?:$|\s)/).test(tags);
  const hasAnimatedTag = (/(?:^|\s)animated(?:$|\s)/).test(tags);
  const isAnimated = hasAnimatedTag || hasVideoTag;
  const isAGif = hasAnimatedTag && !hasVideoTag;
  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 (FLAGS.usingFirefox === undefined) {
    FLAGS.usingFirefox = navigator.userAgent.toLowerCase().includes("firefox");
  }
  return FLAGS.usingFirefox;
}

/**
 * @returns  {Boolean}
 */
function onMobileDevice() {
  if (FLAGS.onMobileDevice === undefined) {
    FLAGS.onMobileDevice = (/iPhone|iPad|iPod|Android/i).test(navigator.userAgent);
  }
  return FLAGS.onMobileDevice;
}

function getPerformanceProfile() {
  return parseInt(getPreference("performanceProfile", 0));
}

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");
const CANVAS_HTML = getPerformanceProfile() > 0 ? "" : "<canvas></canvas>";
const REMOVE_BUTTON_HTML = userIsOnTheirOwnFavoritesPage() ? "<button class=\"remove-button light-green-gradient\" style=\"visibility: hidden;\">Remove</button>" : "";

THUMB_NODE_TEMPLATE.className = "thumb-node";

THUMB_NODE_TEMPLATE.innerHTML = `
    <div>
      <img loading="lazy">
      ${REMOVE_BUTTON_HTML}
      ${CANVAS_HTML}
    </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 {Map.<String, ThumbNode>}
   */
  static allThumbNodes = new Map();

  /**
   * @type {Map.<String, ThumbNode>}
   */
  static get thumbNodesMatchedBySearch() {
    const thumbNodes = new Map();

    for (const [id, thumbNode] of ThumbNode.allThumbNodes.entries()) {
      if (thumbNode.matchedByMostRecentSearch) {
        thumbNodes.set(id, thumbNode);
      }
    }
    return thumbNodes;
  }

  /**
   * @type {HTMLDivElement}
   */
  root;
  /**
   * @type {String}
   */
  id;
  /**
   * @type {HTMLElement}
   */
  container;
  /**
   * @type {HTMLImageElement}
   */
  image;
  /**
   * @type {HTMLButtonElement}
   */
  removeButton;
  /**
   * @type {String}
   */
  originalTags;
  /**
   * @type {String}
   */
  additionalTags;
  /**
   * @type {PostTags}
   */
  postTags;
  /**
   * @type {Boolean}
   */
  matchedByMostRecentSearch;

  /**
   * @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.originalTags.split(" ");
  }

  /**
   * @type {{id: String, tags: String, src: String}}
   */
  get databaseRecord() {
    return {
      id: this.id,
      tags: this.originalTags,
      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();
    this.setFlags();
    this.addInstanceToAllThumbNodes();
  }

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

  setupRemoveButton() {
    if (this.removeButton === null) {
      return;
    }
    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.additionalTags = TagModifier.tagModifications.get(this.id) || "";
    this.updateTags();
  }

  /**
   * @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.originalTags = record.tags;
    this.image.classList.add(record.type);
  }

  /**
   * @param {HTMLElement} thumb
   */
  createFromHTMLElement(thumb) {
    if (onMobileDevice()) {
      const noScript = thumb.querySelector("noscript");

      if (noScript !== null) {
        thumb.children[0].insertAdjacentElement("afterbegin", noScript.children[0]);
      }
    }
    const imageElement = thumb.children[0].children[0];

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

  setupOnClickLink() {
    if (usingRenderer()) {
      this.container.setAttribute("href", this.href);
    } else {
      this.container.setAttribute("onclick", `window.open("${this.href}")`);
    }
  }

  /**
   * @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";
  }

  setFlags() {
    this.matchedByMostRecentSearch = true;
  }

  addInstanceToAllThumbNodes() {
    if (!ThumbNode.allThumbNodes.has(this.id)) {
      ThumbNode.allThumbNodes.set(this.id, this);
    }
  }

  /**
   * @param {Boolean} value
   */
  toggleMatched(value) {
    if (value === undefined) {
      this.matchedByMostRecentSearch = !this.matchedByMostRecentSearch;
    } else {
      this.matchedByMostRecentSearch = value;
    }
  }

  /**
   *
   * @param {String} oldTags
   * @param {String} newTags
   * @returns {String}
   */
  mergeTags(oldTags, newTags) {
    oldTags = removeExtraWhiteSpace(oldTags);
    newTags = removeExtraWhiteSpace(newTags);
    const finalTags = new Set(oldTags.split(" "));

    for (const newTag of newTags.split(" ")) {
      if (newTag !== "") {
        finalTags.add(newTag);
      }
    }
    return finalTags.size > 0 ? removeExtraWhiteSpace(Array.from(finalTags.keys()).join(" ")) : "";
  }

  updateTags() {
    const finalTags = this.mergeTags(this.originalTags, this.additionalTags);

    this.image.setAttribute("tags", finalTags);
    this.postTags = new PostTags(finalTags);
  }

  /**
   * @param {String} newTags
   * @returns {String}
   */
  addAdditionalTags(newTags) {
    this.additionalTags = this.mergeTags(this.additionalTags, newTags);
    this.updateTags();
    return this.additionalTags;
  }

  /**
 * @param {String} tagsToRemove
 * @returns {String}
 */
  removeAdditionalTags(tagsToRemove) {
    const tagsToRemoveList = tagsToRemove.split(" ");

    this.additionalTags = Array.from(this.additionalTags.split(" "))
      .filter(tag => !tagsToRemoveList.includes(tag))
      .join(" ");
    this.updateTags();
    return this.additionalTags;
  }

  resetAdditionalTags() {
    if (this.additionalTags === "") {
      return;
    }
    this.additionalTags = "";
    this.updateTags();
  }
}


// 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 {Number}
   */
  static currentLoadState = FavoritesLoader.loadState.notStarted;

  /**
   * @type {{highestInsertedPageNumber : Number, emptying: Boolean, insertionQueue: {pageNumber: Number, thumbNodes: ThumbNode[], searchResults: ThumbNode[]}[]}}
   */
  fetchedThumbNodes;

  /**
   * @type {ThumbNode[]}
   */
  allThumbNodes;
  /**
   * @type {Number}
   */
  finalPageNumber;
  /**
   * @type {HTMLLabelElement}
   */
  matchCountLabel;
  /**
   * @type {Number}
   */
  matchingFavoritesCount;
  /**
   * @type {Number}
   */
  maxNumberOfFavoritesToDisplay;
  /**
   * @type {[{url: String, pageNumber: Number, retries: Number}]}
   */
  failedFetchRequests;
  /**
   * @type {Number}
   */
  expectedFavoritesCount;
  /**
   * @type {Boolean}
   */
  expectedFavoritesCountFound;
  /**
   * @type {String}
   */
  searchQuery;
  /**
   * @type {String}
   */
  previousSearchQuery;
  /**
   * @type {Worker}
   */
  databaseWorker;
  /**
   * @type {Boolean}
   */
  searchResultsAreShuffled;
  /**
  /**
   * @type {Boolean}
   */
  searchResultsAreInverted;
  /**
   * @type {HTMLTextAreaElement}
   */
  favoritesSearchInput;
  /**
   * @type {Number}
   */
  currentFavoritesPageNumber;
  /**
   * @type {HTMLElement}
   */
  paginationContainer;
  /**
   * @type {HTMLLabelElement}
   */
  paginationLabel;
  /**
   * @type {DOMParser}
   */
  parser;
  /**
   * @type {Boolean}
   */
  foundEmptyFavoritesPage;
  /**
   * @type {ThumbNode[]}
   */
  searchResultsWhileFetching;
  /**
   * @type {Number}
   */
  recentlyChangedMaxNumberOfFavoritesToDisplay;
  /**
   * @type {Number}
   */
  maxPageNumberButtonCount;
  /**
   * @type {Boolean}
   */
  newPageNeedsToBeCreated;
  /**
   * @type {Boolean}
   */
    tagsWereRecentlyModified;

  /**
   * @type {Boolean}
   */
  get databaseAccessIsAllowed() {
    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.searchResultsWhileFetching = [];
    this.finalPageNumber = this.getFinalFavoritesPageNumber();
    this.matchCountLabel = document.getElementById("match-count-label");
    this.maxNumberOfFavoritesToDisplay = getPreference("resultsPerPage", DEFAULTS.resultsPerPage);
    this.fetchedThumbNodes = {};
    this.failedFetchRequests = [];
    this.expectedFavoritesCount = 53;
    this.expectedFavoritesCountFound = false;
    this.searchResultsAreShuffled = false;
    this.searchResultsAreInverted = false;
    this.foundEmptyFavoritesPage = false;
    this.newPageNeedsToBeCreated = false;
    this.tagsWereRecentlyModified = false;
    this.recentlyChangedMaxNumberOfFavoritesToDisplay = false;
    this.matchingFavoritesCount = 0;
    this.maxPageNumberButtonCount = onMobileDevice() ? 3 : 5;
    this.searchQuery = "";
    this.databaseWorker = new Worker(getWorkerURL(FavoritesLoader.webWorkers.database));
    this.favoritesSearchInput = document.getElementById("favorites-search-box");
    this.paginationContainer = this.createPaginationContainer();
    this.currentFavoritesPageNumber = 1;
    this.parser = new DOMParser();
    this.addEventListeners();
    this.createDatabaseMessageHandler();
    this.loadFavorites();
  }

  addEventListeners() {
    window.addEventListener("modifiedTags", () => {
      this.tagsWereRecentlyModified = true;
    });
  }

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

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

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

        default:
          break;
      }
    };
  }

  loadFavorites() {
    this.clearOriginalContent();
    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);
      });
  }

  clearOriginalContent() {
    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"));
    setTimeout(() => {
      this.searchResultsAreShuffled = false;
      this.searchResultsAreInverted = false;
    }, 50);

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

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

      case FavoritesLoader.loadState.indexedDB:
        break;

      default:
        this.showSearchResultsBeforeStartedLoading();
    }
  }

  showSearchResultsAfterStartedLoading() {
    this.searchResultsWhileFetching = this.getSearchResults(this.allThumbNodes);
    this.paginateSearchResults(this.searchResultsWhileFetching);
  }

  showSearchResultsAfterFinishedLoading() {
    this.paginateSearchResults(this.getSearchResults(this.allThumbNodes));
  }

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

    this.databaseWorker.postMessage({
      command: "create",
      objectStoreName: FavoritesLoader.objectStoreName,
      version: databaseStatus.version
    });

    if (databaseStatus.objectStoreIsNotEmpty) {
      this.loadFavoritesFromDatabase();
    } else {
      this.startFetchingFavorites();
    }
  }

  /**
   * @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) {
      const thumbNode = thumbNodes[i];

      if (postTagsMatchSearch(searchCommand, thumbNodes[i].postTags)) {
        results.push(thumbNodes[i]);
        thumbNode.toggleMatched(true);
      } else {
        thumbNode.toggleMatched(false);
      }
    }
    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;
    const searchCommand = getSearchCommand(this.finalSearchQuery);

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

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

        if (favoriteIsNotNew) {
          allNewFavoritesFound = true;
          break;
        }

        if (postTagsMatchSearch(searchCommand, thumbNode.postTags)) {
          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);
      this.updateMatchCount(this.allThumbNodes.filter(thumbNode => thumbNode.isVisible).length);
    } else {
      this.databaseWorker.terminate();
    }
  }

  initializeFetchedThumbNodesInsertionQueue() {
    this.fetchedThumbNodes.highestInsertedPageNumber = -1;
    this.fetchedThumbNodes.insertionQueue = [];
  }

  startFetchingFavorites() {
    const currentPageNumber = 0;

    FavoritesLoader.currentLoadState = FavoritesLoader.loadState.started;
    this.toggleContentVisibility(true);
    this.insertPaginationContainer();
    this.updatePaginationUi(1, []);
    this.initializeFetchedThumbNodesInsertionQueue();
    setTimeout(() => {
      dispatchEvent(new Event("startedFetchingFavorites"));
    }, 50);

    this.fetchFavorites();
  }

  updateProgressWhileFetching() {
    let progressText = `Saving Favorites ${this.allThumbNodes.length}`;

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

  async fetchFavorites() {
    let currentPageNumber = 0;

    while (FavoritesLoader.currentLoadState === FavoritesLoader.loadState.started) {
      if (this.failedFetchRequests.length > 0) {
        const failedRequest = this.failedFetchRequests.shift();
        const waitTime = (7 ** (failedRequest.retries + 1)) + 300;

        this.fetchFavoritesFromSinglePage(currentPageNumber, failedRequest);
        await sleep(waitTime);
      } else if (currentPageNumber <= this.finalPageNumber && !this.foundEmptyFavoritesPage) {
        this.fetchFavoritesFromSinglePage(currentPageNumber);
        currentPageNumber += 1;
        await sleep(200);
      } else if (this.finishedFetching(currentPageNumber)) {
        this.onAllFavoritesLoaded();
        this.storeFavorites();
      } else {
        await sleep(10000);
      }
    }
  }

  /**
   * @param {Number} pageNumber
   * @returns {Boolean}
   */
  finishedFetching(pageNumber) {
    pageNumber *= 50;
    let done = this.allThumbNodes.length >= this.expectedFavoritesCount - 2;

    done = done || this.foundEmptyFavoritesPage || pageNumber >= (this.finalPageNumber * 2) + 1;
    return done && this.failedFetchRequests.length === 0;
  }

  /**
   * @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, pageNumber: Number, retries: Number}} failedRequest
   */
  fetchFavoritesFromSinglePage(pageNumber, failedRequest) {
    const refetching = failedRequest !== undefined;

    pageNumber = refetching ? failedRequest.pageNumber : pageNumber * 50;
    const favoritesPageURL = refetching ? failedRequest.url : `${document.location.href}&pid=${pageNumber}`;
    return fetch(favoritesPageURL)
      .then((response) => {
        if (response.ok) {
          return response.text();
        }

        if (refetching) {
          failedRequest.retries += 1;
        } else {
          failedRequest = this.getFailedFetchRequest(response, pageNumber);
        }
        this.failedFetchRequests.push(failedRequest);
        throw new Error(response.status);
      })
      .then((html) => {
        const {thumbNodes, searchResults} = this.extractFavoritesPage(html);

        this.addFetchedThumbNodesToInsertionQueue(pageNumber, thumbNodes, searchResults);
        this.foundEmptyFavoritesPage = thumbNodes.length === 0;
      })
      .catch((error) => {
        console.error(error);
      });
  }

  /**
   * @param {Number} pageNumber
   * @param {ThumbNode[]} thumbNodes
   * @param {ThumbNode[]} searchResults
   */
  addFetchedThumbNodesToInsertionQueue(pageNumber, thumbNodes, searchResults) {
    pageNumber = Math.floor(parseInt(pageNumber) / 50);
    this.fetchedThumbNodes.insertionQueue.push({
      pageNumber,
      thumbNodes,
      searchResults
    });
    this.fetchedThumbNodes.insertionQueue.sort((a, b) => a.pageNumber - b.pageNumber);
    this.emptyInsertionQueue();
  }

  emptyInsertionQueue() {
    if (this.fetchedThumbNodes.emptying) {
      return;
    }
    this.fetchedThumbNodes.emptying = true;

    while (this.fetchedThumbNodes.insertionQueue.length > 0) {
      const element = this.fetchedThumbNodes.insertionQueue[0];

      if (this.previousPageNumberIsPresent(element.pageNumber)) {
        this.processFetchedThumbNodes(element.thumbNodes, element.searchResults);
        this.fetchedThumbNodes.insertionQueue.shift();
        this.fetchedThumbNodes.highestInsertedPageNumber += 1;
      } else {
        break;
      }
    }

    this.fetchedThumbNodes.emptying = false;
  }

  /**
   * @param {Number} pageNumber
   * @returns {Boolean}
   */
  previousPageNumberIsPresent(pageNumber) {
    return this.fetchedThumbNodes.highestInsertedPageNumber + 1 === pageNumber;
  }

  /**
   * @param {ThumbNode[]} thumbNodes
   * @param {ThumbNode[]} searchResults
   */
  processFetchedThumbNodes(thumbNodes, searchResults) {
    this.searchResultsWhileFetching = this.searchResultsWhileFetching.concat(searchResults);
    this.updateMatchCount(this.searchResultsWhileFetching.length);
    dispatchEvent(new CustomEvent("favoritesFetched", {
      detail: thumbNodes.map(thumbNode => thumbNode.root)
    }));
    this.allThumbNodes = this.allThumbNodes.concat(thumbNodes);
    this.addFavoritesToContent(searchResults);
    this.updateProgressWhileFetching();
  }

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

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

  invertSearchResults() {
    this.resetMatchCount();
    this.allThumbNodes.forEach((thumbNode) => {
      thumbNode.toggleMatched();
    });
    const invertedSearchResults = this.getThumbNodesMatchedByLastSearch();

    this.searchResultsAreInverted = true;
    this.paginateSearchResults(invertedSearchResults);
    window.scrollTo(0, 0);
  }

  shuffleSearchResults() {
    const matchedThumbNodes = this.getThumbNodesMatchedByLastSearch();

    shuffleArray(matchedThumbNodes);
    this.searchResultsAreShuffled = true;
    this.paginateSearchResults(matchedThumbNodes);
  }

  getThumbNodesMatchedByLastSearch() {
    return this.allThumbNodes.filter(thumbNode => thumbNode.matchedByMostRecentSearch);
  }

  onAllFavoritesLoaded() {
    FavoritesLoader.currentLoadState = FavoritesLoader.loadState.finished;
    this.toggleLoadingUI(false);
    dispatchEvent(new CustomEvent("favoritesLoaded", {
      detail: this.allThumbNodes
    }));
  }

  /**
   * @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 {ThumbNode[]}}
   */
  reconstructContent(databaseRecords) {
    if (databaseRecords === null) {
      return null;
    }
    const searchCommand = getSearchCommand(this.finalSearchQuery);
    const searchResults = [];

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

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

  loadFavoritesFromDatabase() {
    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() {
    const message = `
Are you sure you want to reset?
This will delete all cached favorites, and preferences.
    `;

    if (confirm(message)) {
      const savedSearches = JSON.parse(localStorage.getItem("savedSearches"));

      localStorage.clear();
      indexedDB.deleteDatabase(FavoritesLoader.databaseName);
      indexedDB.deleteDatabase("AdditionalTags");
      localStorage.setItem("savedSearches", JSON.stringify(savedSearches));
    }
  }

  /**
   * @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) {
    this.paginateSearchResults(this.reconstructContent(databaseRecords));
    this.onAllFavoritesLoaded();
  }

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

    newThumbNodes.reverse();

    for (const thumbNode of newThumbNodes) {
      if (postTagsMatchSearch(searchCommand, thumbNode.postTags)) {
        thumbNode.insertInDocument(content, "afterbegin");
      }
    }
    this.updatePaginationUi(this.currentFavoritesPageNumber, this.getThumbNodesMatchedByLastSearch());
    setTimeout(() => {
      dispatchEvent(new CustomEvent("newFavoritesFetchedOnReload", {
        detail: newThumbNodes.map(thumbNode => thumbNode.root)
      }));
    }, 250);
  }

  /**
   * @param {ThumbNode[]} thumbNodes
   */
  addFavoritesToContent(thumbNodes) {
    const pageNumberButtons = document.getElementsByClassName("pagination-number");
    const lastPageButtonNumber = pageNumberButtons.length > 0 ? parseInt(pageNumberButtons[pageNumberButtons.length - 1].textContent) : 1;
    const pageCount = this.getPageCount(this.searchResultsWhileFetching.length);
    const needsToCreateNewPage = pageCount > lastPageButtonNumber;
    const alreadyAtMaxPageNumberButtons = document.getElementsByClassName("pagination-number").length >= this.maxPageNumberButtonCount;

    if (needsToCreateNewPage && !alreadyAtMaxPageNumberButtons) {
      this.updatePaginationUi(this.currentFavoritesPageNumber, this.searchResultsWhileFetching);
    }

    const onLastPage = (pageCount === this.currentFavoritesPageNumber);
    const missingThumbNodeCount = this.maxNumberOfFavoritesToDisplay - getAllThumbs().length;

    if (!onLastPage) {
      if (missingThumbNodeCount > 0) {
        thumbNodes = thumbNodes.slice(0, missingThumbNodeCount);
      } else {
        return;
      }
    }

    const content = document.getElementById("content");

    for (const thumbNode of thumbNodes) {
      content.appendChild(thumbNode.root);
    }
  }

  /**
   * @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 {Boolean} value
   */
  toggleTagBlacklistExclusion(value) {
    FavoritesLoader.tagNegation.useTagBlacklist = value;
  }

  /**
   * @param {Number} searchResultsLength
   * @returns {Number}
   */
  getPageCount(searchResultsLength) {
    return Math.floor(searchResultsLength / this.maxNumberOfFavoritesToDisplay) + 1;
  }

  /**
   * @param {ThumbNode[]} searchResults
   */
  paginateSearchResults(searchResults) {
    this.updateMatchCount(searchResults.length);
    this.insertPaginationContainer();
    this.changeResultsPage(1, searchResults);
  }

  insertPaginationContainer() {
    if (document.getElementById(this.paginationContainer.id) === null) {
      const topRowButtons = Array.from(document.getElementById("left-favorites-panel-top-row").querySelectorAll("button"));
      const placeToInsertPagination = topRowButtons[topRowButtons.length - 1];

      placeToInsertPagination.insertAdjacentElement("afterend", this.paginationContainer);
    }
  }

  /**
   * @returns {HTMLElement}
   */
  createPaginationContainer() {
    const container = document.createElement("span");

    if (usingDarkTheme()) {
      injectStyleHTML(`
        #favorites-pagination-container {
          >button {
            border: 1px solid white !important;
            color: white !important;
          }
        }
      `, "pagination-style");
    }
    container.id = "favorites-pagination-container";
    return container;
  }

  /**
   * @param {Number} currentPageNumber
   * @param {ThumbNode[]} searchResults
   */
  createPageNumberButtons(currentPageNumber, searchResults) {
    const pageCount = this.getPageCount(searchResults.length);
    let numberOfButtonsCreated = 0;

    for (let i = currentPageNumber; i <= pageCount && numberOfButtonsCreated < this.maxPageNumberButtonCount; i += 1) {
      numberOfButtonsCreated += 1;
      this.createPageNumberButton(currentPageNumber, i, searchResults);
    }

    if (numberOfButtonsCreated >= this.maxPageNumberButtonCount) {
      return;
    }

    for (let j = currentPageNumber - 1; j >= 1 && numberOfButtonsCreated < this.maxPageNumberButtonCount; j -= 1) {
      numberOfButtonsCreated += 1;
      this.createPageNumberButton(currentPageNumber, j, searchResults, "afterbegin");
    }
  }

  /**
   * @param {Number} currentPageNumber
   * @param {Number} pageNumber
   * @param {ThumbNode[]} searchResults
   * @param {String} position
   */
  createPageNumberButton(currentPageNumber, pageNumber, searchResults, position = "beforeend") {
    const pageNumberButton = document.createElement("button");
    const selected = currentPageNumber === pageNumber;

    pageNumberButton.id = `favorites-page-${pageNumber}`;
    pageNumberButton.title = `Goto page ${pageNumber}`;
    pageNumberButton.className = "pagination-number";
    pageNumberButton.classList.toggle("selected", selected);
    pageNumberButton.onclick = () => {
      this.changeResultsPage(pageNumber, searchResults);
    };
    this.paginationContainer.insertAdjacentElement(position, pageNumberButton);
    pageNumberButton.textContent = pageNumber;
  }

  /**
   * @param {ThumbNode[]} searchResults
   */
  createPageTraversalButtons(searchResults) {
    const pageCount = this.getPageCount(searchResults.length);
    const previousPage = document.createElement("button");
    const firstPage = document.createElement("button");
    const nextPage = document.createElement("button");
    const finalPage = document.createElement("button");

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

    this.updateVisibilityOfPageTraversalButtons(previousPage, firstPage, nextPage, finalPage, this.getPageCount(searchResults.length));
  }

  /**
   * @param {ThumbNode[]} searchResults
   */
  createGotoSpecificPageInputs(searchResults) {
    if (this.firstPageNumberExists() && this.lastPageNumberExists(this.getPageCount(searchResults.length))) {
      return;
    }
    const html = `
      <input type="number" placeholder="page" style="width: 4em;">
      <button>Go</button>
    `;
    const container = document.createElement("span");

    container.innerHTML = html;
    const input = container.children[0];
    const button = container.children[1];

    input.onkeydown = (event) => {
      if (event.key === "Enter") {
        button.click();
      }
    };
    button.onclick = () => {
      const pageNumber = clamp(parseInt(input.value), 1, this.getPageCount(searchResults.length));

      this.changeResultsPage(pageNumber, searchResults);
    };
    this.paginationContainer.appendChild(container);
  }

  /**
   * @param {Number} pageNumber
   * @param {ThumbNode[]} searchResults
   */
  changeResultsPage(pageNumber, searchResults) {
    if (this.onSamePage(pageNumber)) {
      return;
    }

    const {start, end} = this.getPaginationStartEndIndices(pageNumber);

    this.tagsWereRecentlyModified = false;
    this.previousSearchQuery = this.searchQuery;
    this.currentFavoritesPageNumber = pageNumber;
    this.updatePaginationUi(pageNumber, searchResults);
    this.createPaginatedFavoritesPage(searchResults, start, end);
    this.reAddAllThumbNodeEventListeners();

    if (FavoritesLoader.currentLoadState !== FavoritesLoader.loadState.indexedDB) {
      dispatchEventWithDelay("changedPage");
    }
  }

  getPaginationStartEndIndices(pageNumber) {
    return {
      start: this.maxNumberOfFavoritesToDisplay * (pageNumber - 1),
      end: this.maxNumberOfFavoritesToDisplay * pageNumber
    };
  }

  /**
   * @param {Number} pageNumber
   * @param {ThumbNode[]} searchResults
   */
  updatePaginationUi(pageNumber, searchResults) {
    const {start, end} = this.getPaginationStartEndIndices(pageNumber);
    const searchResultsLength = searchResults.length;

    this.setPaginationLabel(start, end, searchResultsLength);
    this.paginationContainer.innerHTML = "";
    this.createPageNumberButtons(pageNumber, searchResults);
    this.createPageTraversalButtons(searchResults);
    this.createGotoSpecificPageInputs(searchResults);
  }

  reAddAllThumbNodeEventListeners() {
    for (const thumbNode of this.allThumbNodes) {
      thumbNode.setupRemoveButton();
    }
  }

  /**
   * @param {ThumbNode[]} searchResults
   * @param {Number} start
   * @param {Number} end
   * @returns
   */
  createPaginatedFavoritesPage(searchResults, start, end) {
    const newThumbNodes = searchResults.slice(start, end);
    const content = document.getElementById("content");
    const newContent = document.createDocumentFragment();

    for (const thumbNode of newThumbNodes) {
      newContent.appendChild(thumbNode.root);
    }

    content.innerHTML = "";
    content.appendChild(newContent);
    window.scrollTo(0, 0);
  }

  /**
   * @param {Number} pageNumber
   * @returns {Boolean}
   */
  onSamePage(pageNumber) {
    return (this.currentFavoritesPageNumber === pageNumber) &&
      (this.searchQuery === this.previousSearchQuery) &&
      !this.searchResultsAreShuffled &&
      !this.searchResultsAreInverted &&
      FavoritesLoader.currentLoadState === FavoritesLoader.loadState.finished &&
      !this.recentlyChangedMaxNumberOfFavoritesToDisplay &&
      !this.tagsWereRecentlyModified;
  }

  /**
   * @param {Number} start
   * @param {Number} end
   * @param {Number} searchResults
   * @returns
   */
  setPaginationLabel(start, end, searchResultsLength) {
    end = Math.min(end, searchResultsLength);

    if (this.paginationLabel === undefined) {
      this.paginationLabel = document.getElementById("pagination-label");
    }

    if (searchResultsLength <= this.maxNumberOfFavoritesToDisplay) {
      this.paginationLabel.textContent = "";
      return;
    }

    this.paginationLabel.textContent = `${start} - ${end}`;
  }

  /**
   * @returns {Boolean}
   */
  firstPageNumberExists() {
    return document.getElementById("favorites-page-1") !== null;
  }

  /**
   * @param {Number} pageCount
   * @returns {Boolean}
   */
  lastPageNumberExists(pageCount) {
    return document.getElementById(`favorites-page-${pageCount}`) !== null;
  }

  /**
   * @param {HTMLButtonElement} previousPage
   * @param {HTMLButtonElement} firstPage
   * @param {HTMLButtonElement} nextPage
   * @param {HTMLButtonElement} finalPage
   * @param {Number} pageCount
   */
  updateVisibilityOfPageTraversalButtons(previousPage, firstPage, nextPage, finalPage, pageCount) {
    const firstNumberExists = this.firstPageNumberExists();
    const lastNumberExists = this.lastPageNumberExists(pageCount);

    if (firstNumberExists && lastNumberExists) {
      previousPage.style.display = "none";
      firstPage.style.display = "none";
      nextPage.style.display = "none";
      finalPage.style.display = "none";
    } else {
      if (firstNumberExists) {
        previousPage.style.visibility = "hidden";
        firstPage.style.visibility = "hidden";
      }

      if (lastNumberExists) {
        nextPage.style.visibility = "hidden";
        finalPage.style.visibility = "hidden";
      }
    }
  }
  /**
   * @param {Number} value
   */
  updateMaxNumberOfFavoritesToDisplay(value) {
    this.maxNumberOfFavoritesToDisplay = value;
    this.recentlyChangedMaxNumberOfFavoritesToDisplay = true;
    this.searchFavorites();
    this.recentlyChangedMaxNumberOfFavoritesToDisplay = false;
  }
}

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

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

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

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

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

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

    .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: 9990;
      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;

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

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

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

        >a>div {
          height: 100%;
        }
      }



      &.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;
      min-width: 50%;
      >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 {
      padding: 0px 10px 0px 10px;
      >button {
        background: transparent;
        margin: 0px 2px;
        padding: 2px 6px;
        border: 1px solid black;
        cursor: pointer;
        font-size: 14px;
        color: black;
        font-weight: normal;

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

        &.selected {
          border: none !important;
          font-weight: bold;
          pointer-events: none;
        }
      }
    }

    #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;

      > div {
        flex: 1;
      }
    }

    #additional-favorite-options {
      >div {
        padding-top: 6px;
      }
    }

    #performance-profile {
      width: 150px;
    }

    #results-per-page-input {
      width: 136px;
    }

    #show-ui-div{
      max-width: 400px;

      &.ui-hidden {
        max-width: 100vw;
        text-align: center;
        align-content: center;
      }
    }



  </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: 5px;">
        <label id="match-count-label"></label>
        <label id="pagination-label" style="margin-left: 10px;"></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 of rule34 in a new tab" id="search-button">Search</button>
        <button title="Show results not matched by search" id="invert-button">Invert</button>
        <button title="Randomize order of search results" id="shuffle-button">Shuffle</button>
        <button title="Empty the search box" id="clear-button">Clear</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>
        <button title="Remove cached favorites and preferences" id="reset-button">Reset</button>
        <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 with Tags and/or IDs"
          spellcheck="false"></textarea>
      </div>

      <div id="left-favorites-panel-bottom-row">
        <div id="favorite-options-container">
          <div id="show-options"><label class="checkbox" title="Show more options"><input type="checkbox"
                id="options-checkbox"> More Options</label></div>
          <div id="favorite-options">
            <div><label class="checkbox" title="Enable gallery and other features on search pages"><input type="checkbox" id="enable-on-search-pages">
                Enhance Search Pages</label></div>
            <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><label class="checkbox" title="Enable fancy image hovering (experimental)"><input type="checkbox"
                  id="fancy-image-hovering-checkbox"> Fancy Hovering</label></div>
          </div>
          <div id="additional-favorite-options">
            <div id="performance-profile-container" title="Improve performance by disabling features">
              <label>Performance Profile</label>
              <br>
              <select id="performance-profile">
                <option value="0">Normal</option>
                <option value="1">Low (no gallery)</option>
                <option value="2">Potato (only search)</option>
              </select>
            </div>
            <div id="results-per-page-container" title="Set the maximum number of search results to display on each page\nLower numbers improve responsiveness">
                <label id="results-per-page-label">Results per Page</label>
                <br>
                <input type="number" id="results-per-page-input" min="50" max="10000" step="500">
            </div>
            <div id="column-resize-container" title="Set the number of favorites per row">
              <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">
          <div id="show-ui-div"><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: 1;"></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_PREFERENCES = {
  textareaWidth: "searchTextareaWidth",
  textareaHeight: "searchTextareaHeight",
  showRemoveButtons: "showRemoveButtons",
  showOptions: "showOptions",
  filterBlacklist: "filterBlacklistCheckbox",
  searchHistory: "favoritesSearchHistory",
  findFavorite: "findFavorite",
  thumbSize: "thumbSize",
  columnCount: "columnCount",
  showUI: "showUI",
  performanceProfile: "performanceProfile",
  resultsPerPage: "resultsPerPage",
  fancyImageHovering: "fancyImageHovering",
  enableOnSearchPages: "enableOnSearchPages"
};
const FAVORITE_LOCAL_STORAGE = {
  searchHistory: "favoritesSearchHistory"
};
const FAVORITE_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_CHECKBOXES = {
  showOptions: document.getElementById("options-checkbox"),
  showRemoveButtons: document.getElementById("show-remove-buttons"),
  filterBlacklist: document.getElementById("filter-blacklist-checkbox"),
  showUI: document.getElementById("show-ui"),
  fancyImageHovering: document.getElementById("fancy-image-hovering-checkbox"),
  enableOnSearchPages: document.getElementById("enable-on-search-pages")
};
const FAVORITE_INPUTS = {
  searchBox: document.getElementById("favorites-search-box"),
  findFavorite: document.getElementById("find-favorite-input"),
  columnCount: document.getElementById("column-resize-input"),
  performanceProfile: document.getElementById("performance-profile"),
  resultsPerPage: document.getElementById("results-per-page-input")
};
const FAVORITE_SEARCH_LABELS = {
  findFavorite: document.getElementById("find-favorite-label")
};
let searchHistory = [];
let searchHistoryIndex = 0;
let lastSearchQuery = "";

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

function loadFavoritesPagePreferences() {
  const height = getPreference(FAVORITE_PREFERENCES.textareaHeight);
  const width = getPreference(FAVORITE_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_PREFERENCES.showRemoveButtons, false) && userIsOnTheirOwnFavoritesPage();

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

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

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

  if (searchHistory.length > 0) {
    FAVORITE_INPUTS.searchBox.value = searchHistory[0];
  }
  FAVORITE_INPUTS.findFavorite.value = getPreference(FAVORITE_PREFERENCES.findFavorite, "");
  FAVORITE_INPUTS.columnCount.value = getPreference(FAVORITE_PREFERENCES.columnCount, DEFAULTS.columnCount);
  changeColumnCount(FAVORITE_INPUTS.columnCount.value);

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

  FAVORITE_CHECKBOXES.showUI.checked = showUI;
  toggleUI(showUI);

  const performanceProfile = getPerformanceProfile();

  for (const option of FAVORITE_INPUTS.performanceProfile.children) {
    if (parseInt(option.value) === performanceProfile) {
      option.selected = "selected";
    }
  }

  const resultsPerPage = parseInt(getPreference(FAVORITE_PREFERENCES.resultsPerPage, DEFAULTS.resultsPerPage));

  changeResultsPerPage(resultsPerPage, false);

  if (onMobileDevice()) {
    toggleFancyImageHovering(false);
    FAVORITE_CHECKBOXES.fancyImageHovering.parentElement.style.display = "none";
  } else {
    const fancyImageHovering = getPreference(FAVORITE_PREFERENCES.fancyImageHovering, false);

    FAVORITE_CHECKBOXES.fancyImageHovering.checked = fancyImageHovering;
    toggleFancyImageHovering(fancyImageHovering);
  }

  const enableOnSearchPages = getPreference(FAVORITE_PREFERENCES.enableOnSearchPages, true);

  FAVORITE_CHECKBOXES.enableOnSearchPages.checked = enableOnSearchPages;
}

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_BUTTONS.search.onclick = (event) => {
    const query = FAVORITE_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_INPUTS.searchBox.addEventListener("keydown", (event) => {
    switch (event.key) {
      case "Enter":
        if (awesompleteIsUnselected(FAVORITE_INPUTS.searchBox)) {
          event.preventDefault();
          FAVORITE_BUTTONS.search.click();
        } else {
          clearAwesompleteSelection(FAVORITE_INPUTS.searchBox);
        }
        break;

      case "ArrowUp":

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

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

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

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

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

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

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

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

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

  FAVORITE_INPUTS.performanceProfile.onchange = () => {
    setPreference(FAVORITE_PREFERENCES.performanceProfile, parseInt(FAVORITE_INPUTS.performanceProfile.value));
    window.location.reload();
  };

  FAVORITE_INPUTS.resultsPerPage.onchange = () => {
    changeResultsPerPage(parseInt(FAVORITE_INPUTS.resultsPerPage.value));
  };

  if (!onMobileDevice()) {
    FAVORITE_CHECKBOXES.fancyImageHovering.onchange = () => {
      toggleFancyImageHovering(FAVORITE_CHECKBOXES.fancyImageHovering.checked);
      setPreference(FAVORITE_PREFERENCES.fancyImageHovering, FAVORITE_CHECKBOXES.fancyImageHovering.checked);
    };
  }

  FAVORITE_CHECKBOXES.enableOnSearchPages.onchange = () => {
    setPreference(FAVORITE_PREFERENCES.enableOnSearchPages, FAVORITE_CHECKBOXES.enableOnSearchPages.checked);
  };
}

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

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

function updateLastSearchQuery() {
  if (FAVORITE_INPUTS.searchBox.value !== lastSearchQuery) {
    lastSearchQuery = FAVORITE_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_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_INPUTS.searchBox.value = lastSearchQuery;
    } else {
      FAVORITE_INPUTS.searchBox.value = searchHistory[searchHistoryIndex];
    }
  }
}

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

/**
 * @param {Number} count
 */
function changeColumnCount(count) {
  count = parseInt(count);

  if (isNaN(count)) {
    FAVORITE_INPUTS.columnCount.value = getPreference(FAVORITE_PREFERENCES.columnCount, DEFAULTS.columnCount);
    return;
  }
  count = clamp(parseInt(count), 4, 20);
  injectStyleHTML(`
    #content {
      grid-template-columns: repeat(${count}, 1fr) !important;
    }
    `, "columnCount");
  FAVORITE_INPUTS.columnCount.value = count;
  setPreference(FAVORITE_PREFERENCES.columnCount, count);
}

/**
 * @param {Number} resultsPerPage
 * @param {Boolean} search
 */
function changeResultsPerPage(resultsPerPage, search = true) {
  resultsPerPage = parseInt(resultsPerPage);

  if (isNaN(resultsPerPage)) {
    FAVORITE_INPUTS.resultsPerPage.value = getPreference(FAVORITE_PREFERENCES.resultsPerPage, DEFAULTS.resultsPerPage);
    return;
  }
  resultsPerPage = clamp(resultsPerPage, 50, 5000);
  FAVORITE_INPUTS.resultsPerPage.value = resultsPerPage;
  setPreference(FAVORITE_PREFERENCES.resultsPerPage, resultsPerPage);

  if (search) {
    favoritesLoader.updateMaxNumberOfFavoritesToDisplay(resultsPerPage);
  }

}

/**
 * @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";
  }
  showUIDiv.classList.toggle("ui-hidden", !value);
  setPreference(FAVORITE_PREFERENCES.showUI, value);
}

function configureMobileUi() {
  if (onMobileDevice()) {
    FAVORITE_INPUTS.performanceProfile.parentElement.style.display = "none";
    injectStyleHTML(`
      .thumb, .thumb-node {
        > div > canvas {
          display: none;
        }
      }

      .checkbox {
        input[type="checkbox"] {
          margin-right: 10px;
        }
      }

      #favorites-top-bar-panels {
        >div {
          textarea {
            width: 95% !important;
          }
        }
      }

      #container {
        position: fixed !important;
        z-index: 30;
        width: 100vw;

      }

      #content {
        margin-top: 300px !important;
      }

      #show-ui-container {
        display: none;
      }

      #favorite-options-container {
        display: block !important;
      }

      #favorites-top-bar-panels {
        display: block !important;
      }

      #right-favorites-panel {
        margin-left: 0px !important;
      }

      #left-favorites-panel-bottom-row {
        margin-left: 10px !important;
      }
      `);
      const container = document.createElement("div");

      container.id = "container";

      document.body.insertAdjacentElement("afterbegin", container);
      container.appendChild(document.getElementById("header"));
      container.appendChild(document.getElementById("favorites-top-bar"));

  } else {
    injectStyleHTML(`
      .checkbox {

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

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

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


// gallery.js

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

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

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


    }

    >div>canvas {
        position: absolute;
        top: 0;
        left: 0;
        pointer-events: none;
        z-index: 1;
        visibility: hidden;
      }

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

  #fullscreen-canvas {
    opacity: .05;
  } */

  /* .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%);
  }

  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 Gallery {
  static clickCodes = {
    leftClick: 0,
    middleClick: 1
  };
  static galleryDirections = {
    d: "d",
    a: "a",
    right: "ArrowRight",
    left: "ArrowLeft"
  };
  static galleryTraversalCooldown = {
    timeout: null,
    waitTime: 200,
    skipCooldown: false,
    get ready() {
      if (this.skipCooldown) {
        return true;
      }

      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",
    enlargeOnClick: "enlargeOnClick"
  };
  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
 */
async function getImageBitmap(imageURL, extension, postId) {
  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,
        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) {
  try {
    return (/\.(png|jpg|jpeg|gif)/g).exec(imageURL)[1];

  } catch (error) {
    return "jpg";
  }
}

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

onmessage = async(message) => {
  message = message.data;
  let extension;

  switch (message.action) {
    case "render":
      getImageBitmap(message.imageURL, message.extension, message.postId);
      break;

    case "renderMultiple":
      for (const image of message.images) {
        getImageBitmap(image.imageURL, image.extension, image.postId);
        await sleep(image.fetchDelay);
      }
    break;

    case "findExtension":
      extension = await getImageExtensionFromPostId(message.postId);

      postMessage({
        foundExtension: extension,
        postId: message.postId
      });
      break;

    default:
      break;
  }
};

`,
    thumbnailRenderer:
      `
/**
 * @type {Map.<String, OffscreenCanvas>}
 */
let 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();
}

function deleteAllCanvases() {
  for (const id of OFFSCREEN_CANVASES.keys()) {
    let offscreenCanvas = OFFSCREEN_CANVASES.get(id);

    offscreenCanvas.getContext("2d").clearRect(0, 0, offscreenCanvas.width, offscreenCanvas.height);
    offscreenCanvas = null;
    OFFSCREEN_CANVASES.set(id, offscreenCanvas);
    OFFSCREEN_CANVASES.delete(id);
  }
  OFFSCREEN_CANVASES.clear();
  OFFSCREEN_CANVASES = new Map();
}

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 "deleteAll":
      deleteAllCanvases();
      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
  };
  static swipe = {
    threshold: 60,
    touchStart: {
      x: 0,
      y: 0
    },
    touchEnd: {
      x: 0,
      y: 0
    },
    get deltaX() {
      return this.touchStart.x - this.touchEnd.x;
    },
    get deltaY() {
      return this.touchStart.y - this.touchEnd.y;
    },
    get right() {
      return this.deltaX < -this.threshold;
    },
    get left() {
      return this.deltaX > this.threshold;
    },
    get up() {
      return this.deltaY > this.threshold;
    },
    get down() {
      return this.deltaY < -this.threshold;
    },
    /**
     * @param {TouchEvent} touchEvent
     * @param {Boolean} atStart
     */
    set(touchEvent, atStart) {
      if (atStart) {
        this.touchStart.x = touchEvent.changedTouches[0].screenX;
        this.touchStart.y = touchEvent.changedTouches[0].screenY;
      } else {
        this.touchEnd.x = touchEvent.changedTouches[0].screenX;
        this.touchEnd.y = touchEvent.changedTouches[0].screenY;
      }
    }
  };
  static settings = {
    mobileMemory: 120,
    desktopMemory: 400,
    imageFetchDelay: 250,
    maxImagesToRenderOverflowScale: 1.3,
    renderAroundFraction: 2,
    upscaledThumbResolutionFraction: 3.5,
    upscaledAnimatedThumbResolutionFraction: 5.5,
    extensionsFoundBeforeSavingCount: 5,
    animatedThumbsToUpscaleRange: 20,
    animatedThumbsToUpscaleDiscrete: 10
  };

  /**
   * @type {HTMLCanvasElement}
   */
  fullscreenCanvas;
  /**
   * @type {CanvasRenderingContext2D}
   */
  fullscreenContext;
  /**
   * @type {HTMLImageElement}
   */
  fullscreenCanvasPlaceholder;
  /**
   * @type {Map.<String, ImageBitmap>}
   */
  imageBitmaps;
  /**
   * @type {Worker[]}
   */
  imageBitmapFetchers;
  /**
   * @type {Worker}
   */
  thumbUpscaler;
  /**
   * @type {Map.<String, HTMLCanvasElement>}
   */
  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}
   */
  movedForwardInGallery;
  /**
   * @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;
  /**
   * @type {Boolean}
   */
  enlargeOnClickOnMobile;
  /**
   * @type {Boolean}
   */
  usingLandscapeOrientation;
  /**
   * @type {leftPostPage}
   */
  /**
   * @type {HTMLElement}
   */
  thumbUnderCursor;
  /**
   * @type {HTMLElement}
   */
  lastEnteredThumb;

  constructor() {
    const galleryDisabled = (onMobileDevice() && onPostPage()) || getPerformanceProfile() > 0;

    if (galleryDisabled) {
      return;
    }
    this.initializeFields();
    this.createWebWorkers();
    this.createFullscreenCanvasStaticPlaceholder();
    this.createVideoBackground();
    this.setFullscreenCanvasResolution();
    this.addEventListeners();
    this.loadDiscoveredImageExtensions();
    this.preparePostPage();
    this.injectHTML();
    this.updateBackgroundOpacity(getPreference(Gallery.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 Map();
    this.upscaleRequests = [];
    this.currentlyUpscaling = false;
    this.imageFetchDelay = Gallery.settings.imageFetchDelay;
    this.extensionAlreadyKnownFetchSpeed = 8;
    this.recentlyDiscoveredImageExtensionCount = 0;
    this.currentlySelectedThumbIndex = 0;
    this.imageBitmapFetcherIndex = 0;
    this.lastSelectedThumbIndexBeforeEnteringGalleryMode = 0;
    this.inGallery = false;
    this.movedForwardInGallery = true;
    this.recentlyExitedGalleryMode = false;
    this.stopRendering = false;
    this.currentlyRendering = false;
    this.usingLandscapeOrientation = true;
    this.thumbUnderCursor = null;
    this.lastEnteredThumb = null;
    this.leftPostPage = false;
    this.content = document.getElementById("content");
    this.finishedLoading = onPostPage();
    this.showOriginalContentOnHover = getPreference(Gallery.preferences.showOnHover, true);
    this.enlargeOnClickOnMobile = getPreference(Gallery.preferences.enlargeOnClick, true);
  }

  createWebWorkers() {
    this.imageBitmapFetchers = [];
    this.thumbUpscaler = new Worker(getWorkerURL(Gallery.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(Gallery.webWorkers.imageFetcher)));
    }
  }

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

  injectStyleHTML() {
    injectStyleHTML(galleryHTML);
  }

  injectOptionsHTML() {
    let optionId = Gallery.preferences.showOnHover;
    let optionText = "Fullscreen on Hover";
    let optionTitle = "View full resolution images or play videos and GIFs when hovering over a thumbnail";
    let optionIsChecked = this.showOriginalContentOnHover;
    let onOptionChanged = (event) => {
      setPreference(Gallery.preferences.showOnHover, event.target.checked);
      this.toggleAllVisibility();
    };

    if (onMobileDevice()) {
      optionId = "open-post-in-new-page-on-mobile";
      optionText = "Enlarge on Click";
      optionTitle = "View full resolution images/play videos when a thumbnail is clicked";
      optionIsChecked = this.enlargeOnClickOnMobile;
      onOptionChanged = (event) => {
        setPreference(Gallery.preferences.enlargeOnClick, event.target.checked);
        this.enlargeOnClickOnMobile = event.target.checked;
      };
    }

    addOptionToFavoritesPage(
      optionId,
      optionText,
      optionTitle,
      optionIsChecked,
      onOptionChanged,
      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(false);
  }

  addEventListeners() {
    document.addEventListener("mousedown", (event) => {
      const clickedOnAnImage = event.target.tagName.toLowerCase() === "img";
      const clickedOnAThumb = clickedOnAnImage && getThumbFromImage(event.target).className.includes("thumb");
      const thumb = clickedOnAThumb ? getThumbFromImage(event.target) : null;

      switch (event.button) {
        case Gallery.clickCodes.leftClick:
          if (this.inGallery) {
            if (isVideo(this.getSelectedThumb()) && !onMobileDevice()) {
              return;
            }
            this.exitGallery();
            this.toggleAllVisibility(false);
            return;
          }

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

          if (onMobileDevice()) {
            if (!this.enlargeOnClickOnMobile) {
              this.openPostInNewPage(thumb);
              return;
            }
            this.deleteAllRenders();
          }
          this.toggleAllVisibility(true);
          this.showOriginalContent(thumb);
          this.enterGallery();
          break;

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

          if (thumb !== null || this.inGallery) {
            this.openPostInNewPage();
          } else if (!this.inGallery) {
            this.toggleAllVisibility();
          }
          break;

        default:

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

        this.traverseGallery.bind(this)(direction, false);
      } else if (this.thumbUnderCursor !== null && this.showOriginalContentOnHover) {
        let opacity = parseFloat(getPreference(Gallery.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 Gallery.galleryDirections.a:

          case Gallery.galleryDirections.d:

          case Gallery.galleryDirections.left:

          case Gallery.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");

        this.renderImagesInTheBackground();

        if (thumb !== null && !this.finishedLoading) {
          this.upscaleAnimatedVisibleThumbsAround(thumb);
        }
      }, 650);
    }, {
      once: true
    });
    window.addEventListener("favoritesLoaded", (event) => {
      this.finishedLoading = true;
      this.initializeThumbsForHovering.bind(this)();
      this.enumerateVisibleThumbs();
      this.renderImagesInTheBackground();
      this.assignImageExtensionsInTheBackground(event.detail);
    });
    window.addEventListener("changedPage", async() => {
      this.clearFullscreenCanvas();
      this.toggleOriginalContentVisibility(false);
      this.deleteAllUpscaledCanvases();
      await this.deleteAllRenders();
      // Array.from(getAllThumbs()).forEach((thumb) => thumb.classList.remove("loaded"));
      this.initializeThumbsForHovering.bind(this)();
      this.enumerateVisibleThumbs();
      this.renderImagesInTheBackground();
    });
    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;
        }
      };
    }

    if (onMobileDevice()) {
      window.addEventListener("blur", () => {
        this.deleteAllRenders();
      });
      document.addEventListener("touchstart", (event) => {
        if (!this.inGallery) {
          return;
        }
        event.preventDefault();
        Gallery.swipe.set(event, true);
      }, {
        passive: false
      });
      document.addEventListener("touchend", (event) => {
        if (!this.inGallery) {
          return;
        }
        event.preventDefault();
        Gallery.swipe.set(event, false);

        if (Gallery.swipe.up) {
          this.exitGallery();
          this.toggleAllVisibility(false);
        } else if (Gallery.swipe.left) {
          this.traverseGallery(Gallery.galleryDirections.right, false);
        } else if (Gallery.swipe.right) {
          this.traverseGallery(Gallery.galleryDirections.left, false);
        } else {
          this.exitGallery();
          this.toggleAllVisibility;
        }
      }, {
        passive: false
      });
    }
  }

  /**
   * @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, Gallery.settings.upscaledThumbResolutionFraction);

    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 hoveringOverSameThumb = (this.thumbUnderCursor !== null) && this.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 || onMobileDevice()) {
      return;
    }
    const offscreenCanvas = this.getOffscreenCanvasFromThumb(thumb);
    const message = {
      action: "draw",
      offscreenCanvas,
      id: thumb.id,
      imageBitmap,
      maxResolutionFraction
    };

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

  /**
   * @param {HTMLElement} thumb
   * @returns {HTMLCanvasElement}
   */
  getCanvasFromThumb(thumb) {
    return thumb.querySelector("canvas");
  }

  /**
   * @param {HTMLElement} thumb
   * @returns {HTMLCanvasElement}
   */
  getOffscreenCanvasFromThumb(thumb) {
    let canvas = this.getCanvasFromThumb(thumb);

    if (canvas === null) {
      canvas = document.createElement("canvas");
      thumb.children[0].appendChild(canvas);
    }
    this.upscaledThumbs.set(thumb.id, canvas);
    return canvas.transferControlToOffscreen();
  }

  deleteAllUpscaledCanvases() {
    this.thumbUpscaler.postMessage({
      action: "deleteAll"
    });

    for (const id of this.upscaledThumbs.keys()) {
      this.upscaledThumbs.get(id).remove();
      this.upscaledThumbs.set(id, null);
      this.upscaledThumbs.delete(id);
    }
    this.upscaledThumbs.clear();
  }

  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 >= Gallery.settings.extensionsFoundBeforeSavingCount) {
      this.recentlyDiscoveredImageExtensionCount = 0;

      if (!onPostPage()) {
        localStorage.setItem(Gallery.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");
      }
    }
  }

  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.leftPostPage = true;
      this.deleteAllRenders();
    };
    window.onfocus = () => {
      if (this.leftPostPage) {
        this.leftPostPage = false;
        this.renderImagesInTheBackground();
      }
    };
    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.set(id, null);
    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(Gallery.attributes.thumbIndex, index);
  }

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

    image.onmouseover = (event) => {
      if (this.inGallery || this.recentlyExitedGalleryMode) {
        return;
      }

      if (!enteredOverCaptionTag(event)) {
        this.thumbUnderCursor = thumb;
        this.lastEnteredThumb = thumb;
      }
      this.showOriginalContent(thumb);
    };
    image.onmouseout = (event) => {
      this.thumbUnderCursor = null;

      if (this.inGallery || enteredOverCaptionTag(event)) {
        return;
      }
      this.hideOriginalContent(thumb);
    };
  }

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

  /**
   *
   * @param {HTMLElement} thumb
   */
  openPostInNewPage(thumb) {
    thumb = thumb === undefined ? this.getSelectedThumb() : thumb;
    const firstChild = thumb.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";

    if (isVideo(selectedThumb)) {
      this.toggleCursorVisibility(true);
      this.toggleVideoControls(true);
    }
    this.inGallery = true;
    dispatchEvent(new CustomEvent("showOriginalContent", {
      detail: 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.recentlyExitedGallery = true;
    setTimeout(() => {
      this.recentlyExitedGalleryMode = false;
    }, 300);
    this.inGallery = false;
    this.showLockIcon();
  }

  /**
   * @param {String} direction
   * @param {Boolean} keyIsHeldDown
   */
  traverseGallery(direction, keyIsHeldDown) {
    if (keyIsHeldDown && !Gallery.galleryTraversalCooldown.ready) {
      return;
    }
    this.clearOriginalContentSources();
    this.setNextSelectedThumbIndex(direction);
    const selectedThumb = this.getSelectedThumb();

    this.renderInAdvanceWhileTraversingGallery(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 === Gallery.galleryDirections.left || direction === Gallery.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.showOriginalContentOnHover);
    this.showEyeIcon();

    if (this.thumbUnderCursor !== null) {
      this.toggleBackgroundVisibility();
      this.toggleScrollbarVisibility();
    }
    dispatchEvent(new CustomEvent("showOriginalContent", {
      detail: this.showOriginalContentOnHover
    }));
    const showOnHoverCheckbox = document.getElementById("showImagesWhenHoveringCheckbox");

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

  /**
   * @param {HTMLElement} thumb
   */
  hideOriginalContent(thumb) {
    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() {
    if (this.thumbUnderCursor === null) {
      return false;
    }
    return isVideo(this.thumbUnderCursor);
  }

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

    const animatedThumbsToUpscale = this.getAdjacentVisibleThumbs(thumb, Gallery.settings.animatedThumbsToUpscaleRange, (_) => {
      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 * Gallery.settings.maxImagesToRenderOverflowScale) {
      const iterator = this.imageBitmaps.keys().next();

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

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

    if (onMobileDevice() && !this.enlargeOnClickOnMobile) {
      return;
    }

    if (this.currentlyRendering) {
      if (this.thumbInRenderRange(initialThumb)) {
        return;
      }
      await this.pauseRendering(this.imageFetchDelay);
    }
    this.currentlyRendering = true;
    const amountToRender = Math.max(2, Math.ceil(this.maxNumberOfImagesToRender / Gallery.settings.renderAroundFraction));

    const imageThumbsToRender = this.getAdjacentVisibleThumbs(initialThumb, amountToRender, (thumb) => {
      return isImage(thumb) && this.isNotRendered(thumb);
    });
    const indicesOfImageThumbsToRender = imageThumbsToRender.map(imageThumb => parseInt(imageThumb.getAttribute(Gallery.attributes.thumbIndex)));

    this.setRenderRange(indicesOfImageThumbsToRender);

    if (this.isNotRendered(initialThumb)) {
      imageThumbsToRender.unshift(initialThumb);
    }

    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 = this.inGallery ? this.movedForwardInGallery : true;

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

      traverseForward = this.getTraversalDirection(previousThumb, traverseForward, nextThumb);
      currentThumb = traverseForward ? nextThumb : previousThumb;

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

  /**
   * @param {HTMLElement} previousThumb
   * @param {HTMLElement} traverseForward
   * @param {HTMLElement} nextThumb
   * @returns {Boolean}
   */
  getTraversalDirection(previousThumb, traverseForward, nextThumb) {
    if (this.inGallery) {
      return this.movedForwardInGallery;
    }

    if (previousThumb === null) {
      traverseForward = true;
    } else if (nextThumb === null) {
      traverseForward = false;
    }
    return !traverseForward;
  }

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

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

  /**
   * @param {HTMLElement} thumb
   * @param {Boolean} forward
   * @returns {HTMLElement}
   */
  getAdjacentThumb(thumb, forward) {
    return forward ? 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
   * @returns {{imageURL: String, postId: String, extension: String}}
   */
  getRenderMessage(thumb) {
    return {
      imageURL: getOriginalImageURLFromThumb(thumb),
      postId: thumb.id,
      extension: this.getImageExtension(thumb.id),
      fetchDelay: this.getImageFetchDelay(thumb.id)
    };
  }

  /**
   * @param {{imageURL: String, postId: String, extension: String}} renderMessage
   */
  postRenderMessage(renderMessage) {
    renderMessage.action = "render";
    this.imageBitmapFetchers[this.imageBitmapFetcherIndex].postMessage(renderMessage);
    this.imageBitmapFetcherIndex += 1;
    this.imageBitmapFetcherIndex = this.imageBitmapFetcherIndex < this.imageBitmapFetchers.length ? this.imageBitmapFetcherIndex : 0;
  }

  /**
   * @param {HTMLElement} thumb
   */
  renderOriginalImage(thumb) {
    this.postRenderMessage(this.getRenderMessage(thumb));
    this.createFullscreenCanvasLowResolutionPlaceHolder(thumb);
  }

  /**
   * @param {HTMLElement[]} thumbs
   */
  renderMultipleOriginalImages(thumbs) {
    const messages = [];

    for (const thumb of thumbs) {
      messages.push(this.getRenderMessage(thumb));
    }
    this.imageBitmapFetchers[0].postMessage({
      action: "renderMultiple",
      images: messages
    });
  }

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

    if (!imageIsLoaded(image)) {
      return;
    }

    try {
      createImageBitmap(image)
        .then((imageBitmap) => {
          if (this.imageBitmaps.get(thumb.id) === undefined) {
            const selectedThumb = this.getSelectedThumb();

            if (thumb === null || thumb === undefined || selectedThumb === null || selectedThumb === undefined) {
              return;
            }
            this.imageBitmaps.set(thumb.id, imageBitmap);

            if (selectedThumb !== null && selectedThumb.id === thumb.id) {
              this.drawFullscreenCanvas(imageBitmap);
            }
          }
        });
    } catch (error) {
      1;
    }
  }

  /**
   * @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(Gallery.preferences.backgroundOpacity, opacity);
  }

  /**
   * @returns {Number}
   */
  getIndexOfThumbUnderCursor() {
    return this.thumbUnderCursor === null ? null : parseInt(this.thumbUnderCursor.getAttribute(Gallery.attributes.thumbIndex));
  }

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

  /**
   * @param {ImageBitmap} imageBitmap
   */
  drawFullscreenCanvas(imageBitmap) {
    this.resizeFullscreenCanvas();
    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
    );
  }

  resizeFullscreenCanvas() {
    if (!onMobileDevice()) {
      return;
    }
    const windowInLandscapeOrientation = window.innerWidth > window.innerHeight;
    const usingIncorrectOrientation = windowInLandscapeOrientation !== this.usingLandscapeOrientation;

    if (usingIncorrectOrientation) {
      const temp = this.fullscreenCanvas.width;

      this.fullscreenCanvas.width = this.fullscreenCanvas.height;
      this.fullscreenCanvas.height = temp;
      this.usingLandscapeOrientation = !this.usingLandscapeOrientation;
    }
  }

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

  showEyeIcon() {
    const eyeIcon = document.getElementById("svg-eye");
    const svg = this.showOriginalContentOnHover ? Gallery.icons.openEye : Gallery.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 ? Gallery.icons.closedLock : Gallery.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;
  }

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

    if (onMobileDevice() && !this.enlargeOnClickOnMobile) {
      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(Gallery.attributes.thumbIndex);
      this.renderedThumbRange.maxIndex = imageThumbsToRender[imageThumbsToRender.length - 1].getAttribute(Gallery.attributes.thumbIndex);
    }
    await this.renderImages(imageThumbsToRender);
    this.currentlyRendering = false;
  }

  /**
   * @param {HTMLElement[]} imagesToRender
   */
  async renderImages(imagesToRender) {
    // this.renderMultipleOriginalImages(imagesToRender);

    for (const thumb of imagesToRender) {
      if (this.stopRendering && !onMobileDevice()) {
        break;
      }
      this.renderOriginalImage(thumb);
      await sleep(this.getImageFetchDelay(thumb.id));
    }
  }

  /**
   * @param {HTMLElement} animatedThumbs
   */
  async upscaleAnimatedThumbs(animatedThumbs) {
    if (onMobileDevice()) {
      return;
    }

    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, Gallery.settings.upscaledAnimatedThumbResolutionFraction);
            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 = onMobileDevice() ? Gallery.settings.mobileMemory : Gallery.settings.desktopMemory;
    const averageImageSize = 20;
    const maxImagesToRender = Math.floor(availableMemory / averageImageSize);
    return onPostPage() ? 50 : maxImagesToRender;
  }

  /**
   * @param {HTMLElement} thumb
   * @returns {Boolean}
   */
  thumbInRenderRange(thumb) {
    const index = parseInt(thumb.getAttribute(Gallery.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(Gallery.preferences.resolution, Gallery.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(Gallery.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() ? Gallery.defaultResolutions.postPage : getPreference(Gallery.preferences.resolution, Gallery.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
   */
  renderInAdvanceWhileTraversingGallery(thumb, direction) {
    const lookahead = this.getLookahead();
    const forward = direction === Gallery.galleryDirections.right;
    let nextThumbToRender = this.getAdjacentVisibleThumb(thumb, forward);

    this.movedForwardInGallery = forward;

    for (let i = 0; i < lookahead; i += 1) {
      if (nextThumbToRender === null) {
        break;
      }

      if (!isImage(nextThumbToRender)) {
        nextThumbToRender = this.getAdjacentVisibleThumb(nextThumbToRender, forward);
        continue;
      }

      if (this.isNotRendered(nextThumbToRender)) {
        break;
      }
      nextThumbToRender = this.getAdjacentVisibleThumb(nextThumbToRender, forward);
    }

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

    if (this.isNotRendered(nextThumbToRender) && isImage(nextThumbToRender)) {
      this.upscaleAnimatedVisibleThumbsAround(nextThumbToRender);
      this.renderImagesAround(nextThumbToRender);
    }
  }

  /**
   * @returns {Number}
   */
  getLookahead() {
    return Math.max(3, Math.min(10, Math.round(this.maxNumberOfImagesToRender / 2) - 2));
  }

  /**
   * @param {HTMLElement} thumb
   */
  upscaleAnimatedVisibleThumbsAround(thumb) {
    const animatedThumbsToUpscale = this.getAdjacentVisibleThumbs(thumb, Gallery.settings.animatedThumbsToUpscaleDiscrete, (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(Gallery.attributes.thumbIndex)));
    }
    this.setRenderRange(indices);
  }

  /**
   * @param {ThumbNode[]} thumbNodes
   */
  async assignImageExtensionsInTheBackground(thumbNodes) {
    const postIdsWithUnknownExtensions = this.getPostIdsWithUnknownExtensions(thumbNodes);

    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({
            action: "findExtension",
            postId
          });
          await sleep(10);
        }
      }
    }
  }

  /**
   * @param {ThumbNode[]} thumbNodes
   * @returns {String[]}
   */
  getPostIdsWithUnknownExtensions(thumbNodes) {
    return thumbNodes
      .map(thumbNode => thumbNode.root)
      .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));
    });
  }

  createFullscreenCanvasStaticPlaceholder() {
    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 Gallery.extensionDecodings[this.imageExtensions[parseInt(postId)]];
  }

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

const gallery = new Gallery();


// 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.05em;
    }

    #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() {
    if (onMobileDevice() || getPerformanceProfile() > 1) {
      return;
    }
    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("changedPage", () => {
        this.addEventListenersToThumbs.bind(this)();
      });
      window.addEventListener("thumbUnderCursorOnLoad", (event) => {
        this.showOnLoadIfHoveringOverThumb(event.detail);
      }, {
        once: true
      });
      window.addEventListener("newFavoritesFetchedOnReload", (event) => {
        this.addEventListenersToThumbs.bind(this)(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);

      image.onmouseenter = (event) => {
        if (enteredOverCaptionTag(event)) {
          return;
        }

        if (this.enabled) {
          this.show(image);
        }
      };
      image.onmouseleave = (event) => {
        if (!enteredOverCaptionTag(event)) {
          this.hide();
        }
      };
    }
  }

  /**
   * @param {HTMLImageElement} image
   */
  setPosition(image) {
    const imageChangesSizeOnHover = document.getElementById("fancy-image-hovering") !== null;
    let rect;

    if (imageChangesSizeOnHover) {
      const imageContainer = image.parentElement;
      const sizeCalculationDiv = document.createElement("div");

      sizeCalculationDiv.className = "size-calculation-div";
      imageContainer.appendChild(sizeCalculationDiv);
      rect = sizeCalculationDiv.getBoundingClientRect();
      sizeCalculationDiv.remove();
    } else {
      rect = image.getBoundingClientRect();
    }
    const offset = 7;
    let tooltipRect;

    this.tooltip.style.top = `${rect.bottom + offset + window.scrollY}px`;
    this.tooltip.style.left = `${rect.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 = `${rect.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 = `${rect.top + window.scrollY + (rect.height / 2) - offset}px`;

    if (tooltipIsLeftOfCenter) {
      this.tooltip.style.left = `${rect.right + offset}px`;
    } else {
      this.tooltip.style.left = `${rect.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 tags when hovering over a thumbnail and see which ones were matched by a search",
      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 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>
    <button title="Save result ids as search" id="save-results-button">Save Results</button>
  </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() || onMobileDevice()) {
      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.saveSearchResultsAsCustomSearch();
    };
  }

  /**
   * @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();
    }
  }

  saveSearchResultsAsCustomSearch() {
    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?`)) {
        return;
      }
    }
    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 .35s ease;
    -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-list {
    padding-left: 5px;
  }

  .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: #EFA1CF;
  }

  .metadata-tag {
    color: #8FD9ED;
  }

  .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: "unknown",
    3: "copyright",
    4: "character",
    5: "metadata"
  };
  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() {
    const captionDisabled = (onPostPage() || onMobileDevice()) || getPerformanceProfile() > 1;

    if (captionDisabled) {
      return;
    }
    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
    );
  }

  /**
   * @param {HTMLElement[]} thumbs
   */
  async addEventListenersToThumbs(thumbs) {
    await sleep(500);
    thumbs = thumbs === undefined ? getAllThumbs() : thumbs;

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

      imageContainer.onmouseenter = () => {
        this.show(thumb);
      };

      imageContainer.onmouseleave = () => {
        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 = this.caption.querySelector("#caption-id");
    const captionIdTag = document.createElement("li");

    captionIdTag.className = "caption-tag";
    captionIdTag.textContent = thumb.id;
    captionIdTag.onclick = (event) => {
      event.stopPropagation();
      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;
    }

    if (thumb !== null && thumb !== undefined) {
      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 = (event) => {
      event.stopPropagation();
      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");
    });
    window.addEventListener("showOriginalContent", (event) => {
      const thumb = caption.parentElement;

      if (event.detail) {
        this.hide(thumb);

        this.caption.classList.add("hide");
      } else {
        this.caption.classList.remove("hide");
      }
    });

    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("changedPage", () => {
        this.addEventListenersToThumbs.bind(this)();
        const tagNames = this.getTagNamesWithUnknownCategories(getAllVisibleThumbs().slice(0, 100));

        this.findTagCategories(tagNames, 3, () => {
          this.saveTags();
        });

      });
      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);
      });
      window.addEventListener("originalContentCleared", (event) => {
        const thumbs = event.detail;
        const tagNames = this.getTagNamesWithUnknownCategories(thumbs);

        this.findTagCategories(tagNames, 3, () => {
          this.saveTags();
        });
      });
      window.addEventListener("originalContentCleared", (event) => {
        const thumbs = event.detail;
        const tagNames = this.getTagNamesWithUnknownCategories(thumbs);

        this.findTagCategories(tagNames, 3, () => {
          this.saveTags();
        });
      });
      window.addEventListener("newFavoritesFetchedOnReload", (event) => {
        this.addEventListenersToThumbs.bind(this)(event.detail);
      }, {
        once: true
      });
    }
  }

  /**
   * @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)}`;

      try {
        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] = parseInt(encoding);

            if (tagName === lastTagName && onAllCategoriesFound !== undefined) {
              onAllCategoriesFound();
            }
          });
      } catch (error) {
        if (error.name !== "TypeError") {
          throw error;
        }
      }
      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("favoritesLoaded", () => {
      const allTagNames = this.getTagNamesWithUnknownCategories(getAllThumbs);

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

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


// tag_modifier.js

const tagModifierHTML = `<div id="tag-modifier-container">
  <style>
    #tag-modifier-ui-container {
      display: none;

      >* {
        margin-top: 10px;
      }
    }

    #tag-modifier-ui-textarea {
      width: 80%;
    }

    .thumb-node.tag-modifier-selected {
      outline: 2px dashed white !important;
      >div {
        opacity: 1;
        filter: grayscale(0%);
      }
    }

    #tag-modifier-ui-status-label {
      visibility: hidden;
    }

    .tag-type-custom>a, .tag-type-custom {
      color: #0075FF;
    }
  </style>
  <div id="tag-modifier-option-container">
    <label class="checkbox" title="Add or Remove custom or official tags to favorites">
      <input type="checkbox" id="tag-modifier-option-checkbox">Modify Tags
    </label>
  </div>
  <div id="tag-modifier-ui-container">
    <label id="tag-modifier-ui-status-label">No Status</label>
    <textarea id="tag-modifier-ui-textarea" placeholder="tags"></textarea>
    <div id="tag-modifier-ui-modification-buttons">
      <button id="tag-modifier-ui-add" title="Add tags to all selected favorites">Add</button>
      <button id="tag-modifier-remove" title="Remove tags to all selected favorites">Remove</button>
    </div>
    <div id="tag-modifier-ui-selection-buttons">
      <button id="tag-modifier-ui-select-all" title="Select all favorites for tag modification">Select all</button>
      <button id="tag-modifier-ui-un-select-all" title="Unselect all favorites for tag modification">Unselect all</button>
    </div>
    <div id="tag-modifier-ui-reset-button-container">
      <button id="tag-modifier-reset" title="Reset tag modifications">Reset</button>
    </div>
    <div id="tag-modifier-ui-configuration" style="display: none;">
      <button id="tag-modifier-import" title="Import modified tags">Import</button>
      <button id="tag-modifier-export" title="Export modified tags">Export</button>
    </div>
  </div>
</div>`;

class TagModifier {
  /**
   * @type {String}
   */
  static databaseName = "AdditionalTags";
  /**
   * @type {String}
   */
  static objectStoreName = "additionalTags";
  /**
   * @type {Boolean}
   */
  static get currentlyModifyingTags() {
    return document.getElementById("tag-edit-mode") !== null;
  }

  /**
   * @type {Map.<String, String>}
   */
  static tagModifications = new Map();
  /**
   * @type {{container: HTMLDivElement, checkbox: HTMLInputElement}}
   */
  favoritesOption;

  /**
   * @type { {container: HTMLDivElement,
   * textarea:  HTMLTextAreaElement,
   * statusLabel: HTMLLabelElement,
   * add: HTMLButtonElement,
   * remove: HTMLButtonElement,
   * reset: HTMLButtonElement,
   * selectAll: HTMLButtonElement,
   * unSelectAll: HTMLButtonElement}}
   */
  ui;

  /**
   * @type {ThumbNode[]}
   */
  selectedThumbNodes;

  constructor() {
    if (onPostPage() || onMobileDevice()) {
      return;
    }
    this.favoritesOption = {};
    this.ui = {};
    this.selectedThumbNodes = [];
    this.loadTagModifications();
    this.injectHTML();
    this.addEventListeners();
  }

  injectHTML() {
    document.getElementById("left-favorites-panel-bottom-row").lastElementChild.insertAdjacentHTML("beforebegin", tagModifierHTML);
    this.favoritesOption.container = document.getElementById("tag-modifier-container");
    this.favoritesOption.checkbox = document.getElementById("tag-modifier-option-checkbox");
    this.ui.container = document.getElementById("tag-modifier-ui-container");
    this.ui.statusLabel = document.getElementById("tag-modifier-ui-status-label");
    this.ui.textarea = document.getElementById("tag-modifier-ui-textarea");
    this.ui.add = document.getElementById("tag-modifier-ui-add");
    this.ui.remove = document.getElementById("tag-modifier-remove");
    this.ui.reset = document.getElementById("tag-modifier-reset");
    this.ui.selectAll = document.getElementById("tag-modifier-ui-select-all");
    this.ui.unSelectAll = document.getElementById("tag-modifier-ui-un-select-all");
  }

  addEventListeners() {
    this.favoritesOption.checkbox.onchange = (event) => {
      this.toggleTagEditMode(event.target.checked);
    };
    this.ui.selectAll.onclick = this.selectAll.bind(this);
    this.ui.unSelectAll.onclick = this.unSelectAll.bind(this);
    this.ui.add.onclick = this.addTagsToSelected.bind(this);
    this.ui.remove.onclick = this.removeTagsFromSelected.bind(this);
    this.ui.reset.onclick = this.resetTagModifications.bind(this);
    window.addEventListener("searchStarted", () => {
      this.unSelectAll();
    });
  }

  /**
   * @param {Boolean} value
   */
  toggleTagEditMode(value) {
    this.toggleThumbInteraction(value);
    this.toggleUi(value);
    this.toggleTagEditModeEventListeners(value);
    this.ui.unSelectAll.click();
  }

  /**
   * @param {Boolean} value
   */
  toggleThumbInteraction(value) {
    if (!value) {
      const tagEditModeStyle = document.getElementById("tag-edit-mode");

      if (tagEditModeStyle !== null) {
        tagEditModeStyle.remove();
      }
      return;
    }
    injectStyleHTML(`
      .thumb-node  {
        cursor: pointer;
        outline: 1px solid black;
        -webkit-touch-callout: none;
        -webkit-user-select: none;
        -khtml-user-select: none;
        -moz-user-select: none;
        -ms-user-select: none;
        user-select: none;

        > div {
          outline: none !important;

          > img {
            outline: none !important;
          }

          pointer-events:none;
          opacity: 0.6;
          filter: grayscale(90%);
          transition: none !important;
        }
      }
    `, "tag-edit-mode");
  }

  /**
   * @param {Boolean} value
   */
  toggleUi(value) {
    this.ui.container.style.display = value ? "block" : "none";
  }

  /**
   * @param {Boolean} value
   */
  toggleTagEditModeEventListeners(value) {
    for (const thumbNode of ThumbNode.allThumbNodes.values()) {
      if (value) {
        thumbNode.root.onclick = () => {
          this.toggleThumbSelection(thumbNode.root);
        };
      } else {
        thumbNode.root.onclick = null;
      }
    }
  }

  /**
   * @param {String} text
   */
  showStatus(text) {
    this.ui.statusLabel.style.visibility = "visible";
    this.ui.statusLabel.textContent = text;
    setTimeout(() => {
      const statusHasNotChanged = this.ui.statusLabel.textContent === text;

      if (statusHasNotChanged) {
        this.ui.statusLabel.style.visibility = "hidden";
      }
    }, 1000);
  }

  unSelectAll() {
    for (const thumbNode of ThumbNode.allThumbNodes.values()) {
      this.toggleThumbSelection(thumbNode.root, false);
    }
  }

  selectAll() {
    for (const thumbNode of ThumbNode.thumbNodesMatchedBySearch.values()) {
      this.toggleThumbSelection(thumbNode.root, true);
    }
  }

  /**
   * @param {HTMLElement} thumb
   * @param {Boolean} value
   */
  toggleThumbSelection(thumb, value) {
    if (value === undefined) {
      thumb.classList.toggle("tag-modifier-selected");
    } else {
      thumb.classList.toggle("tag-modifier-selected", value);
    }
  }

  /**
   * @param {String} tags
   * @returns
   */
  removeContentTypeTags(tags) {
    return tags
      .replace(/(?:^|\s*)(?:video|animated|mp4)(?:$|\s*)/g, "");
  }

  addTagsToSelected() {
    this.modifyTagsOfSelected(false);
  }

  removeTagsFromSelected() {
    this.modifyTagsOfSelected(true);
  }

  /**
   *
   * @param {Boolean} remove
   */
  modifyTagsOfSelected(remove) {
    const tags = this.ui.textarea.value;
    const tagsWithoutContentTypes = this.removeContentTypeTags(tags);
    const tagsToModify = removeExtraWhiteSpace(tagsWithoutContentTypes);
    const statusPrefix = remove ? "Removed tag(s) from" : "Added tag(s) to";
    let modifiedTagsCount = 0;

    if (tagsToModify === "") {
      return;
    }

    for (const [id, thumbNode] of ThumbNode.allThumbNodes.entries()) {
      if (thumbNode.root.classList.contains("tag-modifier-selected")) {
        const additionalTags = remove ? thumbNode.removeAdditionalTags(tagsToModify) : thumbNode.addAdditionalTags(tagsToModify);

        TagModifier.tagModifications.set(id, additionalTags);
        modifiedTagsCount += 1;
      }
    }

    if (modifiedTagsCount === 0) {
      return;
    }

    if (tags !== tagsWithoutContentTypes) {
      alert("Warning: video, animated, and mp4 tags are unchanged.\nThey cannot be modified.");
    }
    this.showStatus(`${statusPrefix} ${modifiedTagsCount} favorite(s)`);
    dispatchEvent(new Event("modifiedTags"));
    setCustomTags(tagsToModify);
    this.storeTagModifications();
  }

  createDatabase(event) {
    /**
      * @type {IDBDatabase}
     */
    const database = event.target.result;

    database
      .createObjectStore(TagModifier.objectStoreName, {
        keyPath: "id"
      });
  }

  storeTagModifications() {
    const request = indexedDB.open(TagModifier.databaseName, 1);

    request.onupgradeneeded = this.createDatabase;
    request.onsuccess = (event) => {
      /**
       * @type {IDBDatabase}
      */
      const database = event.target.result;
      const objectStore = database
        .transaction(TagModifier.objectStoreName, "readwrite")
        .objectStore(TagModifier.objectStoreName);

      for (const [id, tags] of TagModifier.tagModifications) {
        objectStore.put({
          id,
          tags
        });
      }
      database.close();
    };
  }

  loadTagModifications() {
    const request = indexedDB.open(TagModifier.databaseName, 1);

    request.onupgradeneeded = this.createDatabase;
    request.onsuccess = (event) => {
      /**
       * @type {IDBDatabase}
      */
      const database = event.target.result;
      const objectStore = database
        .transaction(TagModifier.objectStoreName, "readonly")
        .objectStore(TagModifier.objectStoreName);

      objectStore.getAll().onsuccess = (successEvent) => {
        const tagModifications = successEvent.target.result;

        for (const record of tagModifications) {
          TagModifier.tagModifications.set(record.id, record.tags);
        }
      };
      database.close();
    };
  }

  resetTagModifications() {
    CUSTOM_TAGS.clear();
    indexedDB.deleteDatabase("AdditionalTags");
    ThumbNode.allThumbNodes.forEach(thumbNode => {
      thumbNode.resetAdditionalTags();
    });
    dispatchEvent(new Event("modifiedTags"));
  }
}

const tagModifier = new TagModifier();


// awesomplete.js

/* eslint-disable new-cap */
const CUSTOM_TAGS = loadCustomTags();

/**
 * @returns {Set.<String>}
 */
function loadCustomTags() {
  return new Set(JSON.parse(localStorage.getItem("customTags")) || []);
}

/**
 * @param {String} tags
 */
function setCustomTags(tags) {
  for (const tag of removeExtraWhiteSpace(tags).split(" ")) {
    if (tag !== "") {
      CUSTOM_TAGS.add(tag);
    }
  }
  localStorage.setItem("customTags", JSON.stringify(Array.from(CUSTOM_TAGS)));
}

function isOfficial(tag) {

}

/**
 * @param {{label: String, value: String, type: String}[]} officialTags
 * @returns {{label: String, value: String, type: String}[]}
 */
function mergeOfficialTagsWithCustomTags(officialTags) {
  const customTags = Array.from(CUSTOM_TAGS);
  const officialTagValues = new Set(officialTags.map(officialTag => officialTag.value));
  const mergedTags = officialTags;

  for (const customTag of customTags) {
    if (!officialTagValues.has(customTag)) {
      mergedTags.unshift({
        label: `${customTag} (custom)`,
        value: customTag,
        type: "custom"
      });
    }
  }
  return mergedTags;
}

class AwesompleteWrapper {
  constructor() {
    document.querySelectorAll("textarea").forEach((textarea) => {
      this.addAwesompleteToInput(textarea);
    });
    document.querySelectorAll("input").forEach((input) => {
      if (input.hasAttribute("needs-autocomplete")) {
        this.addAwesompleteToInput(input);
      }
    });
  }

  /**
   * @param {HTMLElement} 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://ac.rule34.xxx/autocomplete.php?q=${prefix}`)
      .then((response) => {
        if (response.ok) {
          return response.text();
        }
        throw new Error(response.status);
      })
      .then((suggestions) => {
        // const mergedSuggestions = mergeOfficialTagsWithCustomTags(JSON.parse(suggestions));
        // awesomplete.list = mergedSuggestions;
        awesomplete.list = JSON.parse(suggestions);
      });
  }

  /**
   * @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();