Rule34 Favorites Search Gallery

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

// ==UserScript==
// @name         Rule34 Favorites Search Gallery
// @namespace    bruh3396
// @version      1.16.1
// @description  Search, View, and Play Rule34 Favorites (Dekstop/Androiod/iOS)
// @author       bruh3396
// @compatible Chrome
// @compatible Edge
// @compatible Firefox
// @compatible Safari
// @compatible Opera
// @match        https://rule34.xxx/index.php?page=favorites&s=view&id=*
// @match        https://rule34.xxx/index.php?page=post&s=list*
// ==/UserScript==

// utilities.js

/* eslint-disable max-classes-per-file */
class Cooldown {
  /**
   * @type {Number}
  */
  timeout;
  /**
   * @type {Number}
  */
  waitTime;
  /**
   * @type {Boolean}
  */
  skipCooldown;
  /**
   * @type {Boolean}
  */
  debounce;
  /**
   * @type {Boolean}
  */
  debouncing;
  /**
   * @type {Function}
  */
  onDebounceEnd;
  /**
   * @type {Function}
  */
  onCooldownEnd;

  get ready() {
    if (this.skipCooldown) {
      return true;
    }

    if (this.timeout === null) {
      this.start();
      return true;
    }

    if (this.debounce) {
      this.debouncing = true;
      clearTimeout(this.timeout);
      this.start();
    }
    return false;
  }

  /**
   * @param {Number} waitTime
   * @param {Boolean} debounce
   */
  constructor(waitTime, debounce = false) {
    this.timeout = null;
    this.waitTime = waitTime;
    this.skipCooldown = false;
    this.debounce = debounce;
    this.debouncing = false;
    this.onDebounceEnd = () => { };
    this.onCooldownEnd = () => { };
  }

  start() {
    this.timeout = setTimeout(() => {
      this.timeout = null;

      if (this.debouncing) {
        this.onDebounceEnd();
        this.debouncing = false;
      }
      this.onCooldownEnd();
    }, this.waitTime);
  }

  stop() {
    if (this.timeout === null) {
      return;
    }
    clearTimeout(this.timeout);
  }

  restart() {
    this.stop();
    this.start();
  }

}

class MetadataSearchExpression {
  /**
   * @type {String}
  */
  metric;
  /**
   * @type {String}
  */
  operator;
  /**
   * @type {String | Number}
  */
  value;

  /**
   * @param {String} metric
   * @param {String} operator
   * @param {String} value
   */
  constructor(metric, operator, value) {
    this.metric = metric;
    this.operator = operator;
    this.value = this.setValue(value);
  }

  /**
   * @param {String} value
   * @returns {String | Number}
   */
  setValue(value) {
    if (!isNumber(value)) {
      return value;
    }

    if (this.metric === "id" && this.operator === ":") {
      return value;
    }
    return parseInt(value);
  }
}

const IDS_TO_REMOVE_ON_RELOAD_KEY = "recentlyRemovedIds";
const TAG_BLACKLIST = getTagBlacklist();
const PREFERENCES_LOCAL_STORAGE_KEY = "preferences";
const FLAGS = {
  set: false,
  onSearchPage: {
    set: false,
    value: undefined
  },
  onFavoritesPage: {
    set: false,
    value: undefined
  },
  onPostPage: {
    set: false,
    value: undefined
  },
  usingFirefox: {
    set: false,
    value: undefined
  },
  onMobileDevice: {
    set: false,
    value: undefined
  },
  userIsOnTheirOwnFavoritesPage: {
    set: false,
    value: undefined
  },
  usingRenderer: {
    set: false,
    value: undefined
  }
};
const 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>",
  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>",
  heartPlus: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 -960 960 960\" fill=\"#FF69B4\"><path d=\"M440-501Zm0 381L313-234q-72-65-123.5-116t-85-96q-33.5-45-49-87T40-621q0-94 63-156.5T260-840q52 0 99 22t81 62q34-40 81-62t99-22q81 0 136 45.5T831-680h-85q-18-40-53-60t-73-20q-51 0-88 27.5T463-660h-46q-31-45-70.5-72.5T260-760q-57 0-98.5 39.5T120-621q0 33 14 67t50 78.5q36 44.5 98 104T440-228q26-23 61-53t56-50l9 9 19.5 19.5L605-283l9 9q-22 20-56 49.5T498-172l-58 52Zm280-160v-120H600v-80h120v-120h80v120h120v80H800v120h-80Z\"/></svg>",
  heartMinus: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 -960 960 960\" fill=\"#FF0000\"><path d=\"M440-501Zm0 381L313-234q-72-65-123.5-116t-85-96q-33.5-45-49-87T40-621q0-94 63-156.5T260-840q52 0 99 22t81 62q34-40 81-62t99-22q84 0 153 59t69 160q0 14-2 29.5t-6 31.5h-85q5-18 8-34t3-30q0-75-50-105.5T620-760q-51 0-88 27.5T463-660h-46q-31-45-70.5-72.5T260-760q-57 0-98.5 39.5T120-621q0 33 14 67t50 78.5q36 44.5 98 104T440-228q26-23 61-53t56-50l9 9 19.5 19.5L605-283l9 9q-22 20-56 49.5T498-172l-58 52Zm160-280v-80h320v80H600Z\"/></svg>",
  heartCheck: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 -960 960 960\" fill=\"#51b330\"><path d=\"M718-313 604-426l57-56 57 56 141-141 57 56-198 198ZM440-501Zm0 381L313-234q-72-65-123.5-116t-85-96q-33.5-45-49-87T40-621q0-94 63-156.5T260-840q52 0 99 22t81 62q34-40 81-62t99-22q81 0 136 45.5T831-680h-85q-18-40-53-60t-73-20q-51 0-88 27.5T463-660h-46q-31-45-70.5-72.5T260-760q-57 0-98.5 39.5T120-621q0 33 14 67t50 78.5q36 44.5 98 104T440-228q26-23 61-53t56-50l9 9 19.5 19.5L605-283l9 9q-22 20-56 49.5T498-172l-58 52Z\"/></svg>",
  error: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 -960 960 960\" fill=\"#FF0000\"><path d=\"M480-280q17 0 28.5-11.5T520-320q0-17-11.5-28.5T480-360q-17 0-28.5 11.5T440-320q0 17 11.5 28.5T480-280Zm-40-160h80v-240h-80v240Zm40 360q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z\"/></svg>",
  warning: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 -960 960 960\" fill=\"#DAB600\"><path d=\"m40-120 440-760 440 760H40Zm138-80h604L480-720 178-200Zm302-40q17 0 28.5-11.5T520-280q0-17-11.5-28.5T480-320q-17 0-28.5 11.5T440-280q0 17 11.5 28.5T480-240Zm-40-120h80v-200h-80v200Zm40-100Z\"/></svg>",
  empty: "<button>123</button>"
};
const DEFAULTS = {
  columnCount: 6,
  resultsPerPage: 200
};
const ADDED_FAVORITE_STATUS = {
  error: 0,
  alreadyAdded: 1,
  notLoggedIn: 2,
  success: 3
};
const STYLES = {
  thumbHoverOutline: `
    .thumb-node,
    .thumb {
      >a,
      >span,
      >div {
        &:hover {
          outline: 3px solid #0075FF;
        }
      }
    }`,
  thumbHoverOutlineDisabled: `
    .thumb-node,
    .thumb {
      >a,
      >span,
      >div:not(:has(img.video)) {
        &:hover {
          outline: none;
        }
      }
    }`
};
const TYPEABLE_INPUTS = new Set([
  "color",
  "email",
  "number",
  "password",
  "search",
  "tel",
  "text",
  "url",
  "datetime"
]);

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

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

/**
 * @param {String} key
 * @param {any} defaultValue
 * @returns {String | null}
 */
function getPreference(key, defaultValue) {
  const preferences = JSON.parse(localStorage.getItem(PREFERENCES_LOCAL_STORAGE_KEY) || "{}");
  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() {
  if (!FLAGS.userIsOnTheirOwnFavoritesPage.set) {
    FLAGS.userIsOnTheirOwnFavoritesPage.value = getUserId() === getFavoritesPageId();
    FLAGS.userIsOnTheirOwnFavoritesPage.set = true;
  }
  return FLAGS.userIsOnTheirOwnFavoritesPage.value;
}

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

  setTimeout(() => {
    fetch((url))
      .then((response) => {
        if (response.status === 503) {
          requestPageInformation(url, onSuccess, delay + delayIncrement);
        }
        return response.text();
      })
      .then((html) => {
        onSuccess(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} value
 */
function forceHideCaptions(value) {
  for (const caption of document.getElementsByClassName("caption")) {
    if (value) {
      caption.classList.add("remove");
      caption.classList.add("inactive");
    } else {
      caption.classList.remove("remove");
    }
  }
}

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

/**
 * @param {HTMLElement} thumb
 * @returns {String | null}
 */
function getAddFavoriteButtonFromThumb(thumb) {
  return thumb.querySelector(".add-favorite-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 = onSearchPage() ? "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} imageURL
 * @returns {String}
 */
function getExtensionFromImageURL(imageURL) {
  try {
    return (/\.(png|jpg|jpeg|gif)/g).exec(imageURL)[1];

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

/**
 * @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) {
  if (onSearchPage()) {
    const image = getImageFromThumb(thumb);
    return image.hasAttribute("tags") ? image.getAttribute("tags") : image.title;
  }
  const thumbNode = ThumbNode.allThumbNodes.get(thumb.id);
  return thumbNode === undefined ? "" : thumbNode.finalTags;
}

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

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

/**
 * @param {HTMLInputElement | HTMLTextAreaElement} input
 * @returns {HTMLDivElement | null}
 */
function getAwesompleteFromInput(input) {
  const awesomplete = input.parentElement;

  if (awesomplete === null || awesomplete.className !== "awesomplete") {
    return null;
  }
  return awesomplete;
}

/**
 * @param {HTMLInputElement | HTMLTextAreaElement} input
 * @returns {Boolean}
 */
function awesompleteIsVisible(input) {
  const awesomplete = getAwesompleteFromInput(input);

  if (awesomplete === null) {
    return false;
  }
  const awesompleteSuggestions = awesomplete.querySelector("ul");
  return awesompleteSuggestions !== null && !awesompleteSuggestions.hasAttribute("hidden");
}

/**
 *
 * @param {HTMLInputElement | HTMLTextAreaElement} input
 * @returns
 */
function awesompleteIsUnselected(input) {
  const awesomplete = getAwesompleteFromInput(input);

  if (awesomplete === null) {
    return true;
  }

  if (!awesompleteIsVisible(input)) {
    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 | HTMLTextAreaElement} 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);
  }
}

/**
 * @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 onSearchPage() {
  if (!FLAGS.onSearchPage.set) {
    FLAGS.onSearchPage.value = location.href.includes("page=post&s=list");
    FLAGS.onSearchPage.set = true;
  }
  return FLAGS.onSearchPage.value;
}

/**
 * @returns {Boolean}
 */
function onFavoritesPage() {
  if (!FLAGS.onFavoritesPage.set) {
    FLAGS.onFavoritesPage.value = location.href.includes("page=favorites");
    FLAGS.onFavoritesPage.set = true;
  }
  return FLAGS.onFavoritesPage.value;
}

/**
 * @returns {Boolean}
 */
function onPostPage() {
  if (!FLAGS.onPostPage.set) {
    FLAGS.onPostPage.value = location.href.includes("page=post&s=view");
    FLAGS.onPostPage.set = true;
  }
  return FLAGS.onPostPage.value;
}

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

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

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

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

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

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

  injectStyleHTML(STYLES.thumbHoverOutline, "thumb-hover-outlines");

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

/**
 * @param {Boolean} value
 */
function toggleFancyImageHovering(value) {
  if (onMobileDevice() || onSearchPage()) {
    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: transform 0.2s 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(`
    .thumb-node, .thumb {

      >a,
      >div {
        &:has(img.video) {
            outline: ${size}px solid blue;
        }

        &:has(img.gif) {
          outline: 2px solid hotpink;
        }

      }
    }
    `, "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");
        injectStyleHTML(`
            #favorites-pagination-container {
              >button {
                border: 1px solid white !important;
                color: white !important;
              }
            }
          `, "pagination-style");
      }
    }
  }, 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 getThumbById(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() {
  if (onPostPage()) {
    return;
  }
  const enableOnSearchPages = getPreference("enableOnSearchPages", false) && getPerformanceProfile() === 0;

  if (!enableOnSearchPages && onSearchPage()) {
    throw new Error("Disabled on search pages");
  }
  injectCommonStyles();
  toggleFancyImageHovering(true);
  setTheme();
  removeBlacklistedThumbs();
  prefetchAdjacentSearchPages();
}

function prefetchAdjacentSearchPages() {
  if (!onSearchPage()) {
    return;
  }
  const id = "search-page-prefetch";
  const alreadyPrefetched = document.getElementById(id) !== null;

  if (alreadyPrefetched) {
    return;
  }
  const container = document.createElement("div");
  const currentPage = document.getElementById("paginator").children[0].querySelector("b");

  for (const sibling of [currentPage.previousElementSibling, currentPage.nextElementSibling]) {
    if (sibling !== null && sibling.tagName.toLowerCase() === "a") {
      container.appendChild(createPrefetchLink(sibling.href));
    }
  }
  container.id = "search-page-prefetch";
  document.head.appendChild(container);
}

/**
 * @param {String} url
 * @returns {HTMLLinkElement}
 */
function createPrefetchLink(url) {
  const link = document.createElement("link");

  link.rel = "prefetch";
  link.href = url;
  return link;

}

function removeBlacklistedThumbs() {
  if (!onSearchPage()) {
    return;
  }
  const blacklistedThumbs = Array.from(document.getElementsByClassName("blacklisted-image"));

  for (const thumb of blacklistedThumbs) {
    thumb.remove();
  }
}

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

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

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

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

/**
 * @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\s+/g, " ");
}

/**
 * @param {String} string
 * @param {String} replacement
 * @returns {String}
 */
function replaceLineBreaks(string, replacement = "") {
  return string.replace(/(\r\n|\n|\r)/gm, replacement);
}

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

/**
 * @returns {Boolean}
 */
function usingFirefox() {
  if (!FLAGS.usingFirefox.set) {
    FLAGS.usingFirefox.value = navigator.userAgent.toLowerCase().includes("firefox");
    FLAGS.usingFirefox.set = true;
  }
  return FLAGS.usingFirefox.value;
}

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

/**
 * @returns {Number}
 */
function getPerformanceProfile() {
  return parseInt(getPreference("performanceProfile", 0));
}

/**
 * @param {String} tagName
 * @returns {Promise.<Boolean>}
 */
function isOfficialTag(tagName) {
  const tagPageURL = `https://rule34.xxx/index.php?page=tags&s=list&tags=${tagName}`;
  return 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");
      return columnOfFirstRow.length === 3;
    })
    .catch((error) => {
      console.error(error);
      return false;
    });
}

/**
 * @param {String} searchQuery
 */
function openSearchPage(searchQuery) {
  window.open(`https://rule34.xxx/index.php?page=post&s=list&tags=${encodeURIComponent(searchQuery)}`);
}

/**
 * @param {Map} map
 * @returns {Object}
 */
function mapToObject(map) {
  return Array.from(map).reduce((object, [key, value]) => {
    object[key] = value;
    return object;
  }, {});
}

/**
 * @param {Object} object
 * @returns {Map}
 */
function objectToMap(object) {
  return new Map(Object.entries(object));
}

/**
 * @param {String} string
 * @returns {Boolean}
 */
function isNumber(string) {
  return (/^\d+$/).test(string);
}

/**
 * @param {String} id
 * @returns {Promise.<Number>}
 */
function addFavorite(id) {
  fetch(`https://rule34.xxx/index.php?page=post&s=vote&id=${id}&type=up`);
  return fetch(`https://rule34.xxx/public/addfav.php?id=${id}`)
    .then((response) => {
      return response.text();
    })
    .then((html) => {
      return parseInt(html);
    })
    .catch(() => {
      return ADDED_FAVORITE_STATUS.error;
    });
}

/**
 * @param {String} id
 */
function removeFavorite(id) {
  setIdToBeRemovedOnReload(id);
  fetch(`https://rule34.xxx/index.php?page=favorites&s=delete&id=${id}`);
}

/**
 * @param {HTMLInputElement | HTMLTextAreaElement} input
 * @param {String} suggestion
 */
function insertSuggestion(input, suggestion) {
  const cursorAtEnd = input.selectionStart === input.value.length;
  const firstHalf = input.value.slice(0, input.selectionStart);
  const secondHalf = input.value.slice(input.selectionStart);
  const firstHalfWithPrefixRemoved = firstHalf.replace(/(\s?)(-?)\S+$/, "$1$2");
  const combinedHalves = removeExtraWhiteSpace(`${firstHalfWithPrefixRemoved}${suggestion} ${secondHalf}`);
  const result = cursorAtEnd ? `${combinedHalves} ` : combinedHalves;
  const selectionStart = firstHalfWithPrefixRemoved.length + suggestion.length + 1;

  input.value = result;
  input.selectionStart = selectionStart;
  input.selectionEnd = selectionStart;
}

/**
 * @param {HTMLInputElement | HTMLTextAreaElement} input
 */
function hideAwesomplete(input) {
  getAwesompleteFromInput(input).querySelector("ul").setAttribute("hidden", "");
}

/**
 * @param {String} svg
 * @param {Number} duration
 */
function showFullscreenIcon(svg, duration = 500) {
  const svgDocument = new DOMParser().parseFromString(svg, "image/svg+xml");
  const svgElement = svgDocument.documentElement;
  const svgOverlay = document.createElement("div");

  svgOverlay.classList.add("fullscreen-icon");
  svgOverlay.innerHTML = new XMLSerializer().serializeToString(svgElement);
  svgOverlay.width = "90vw";
  document.body.appendChild(svgOverlay);
  setTimeout(() => {
    svgOverlay.remove();
  }, duration);
}

/**
 * @param {String} svg
 * @returns {String}
 */
function createObjectURLFromSvg(svg) {
  const blob = new Blob([svg], {
    type: "image/svg+xml"
  });
  return URL.createObjectURL(blob);
}

/**
 * @param {HTMLElement} element
 * @returns {Boolean}
 */
function isTypeableInput(element) {
  const tagName = element.tagName.toLowerCase();

  if (tagName === "textarea") {
    return true;
  }

  if (tagName === "input") {
    return TYPEABLE_INPUTS.has(element.getAttribute("type"));
  }
  return false;
}

initializeUtilities();


// metadata.js

class FavoriteMetadata {
  /**
   * @type {Map.<String, FavoriteMetadata>}
  */
  static allMetadata = new Map();
  static parser = new DOMParser();
  /**
   * @type {FavoriteMetadata[]}
  */
  static missingMetadataFetchQueue = [];
  /**
   * @type {FavoriteMetadata[]}
  */
  static deletedPostFetchQueue = [];
  static currentlyFetchingFromQueue = false;
  static allFavoritesLoaded = false;
  static fetchDelay = {
    normal: 10,
    deleted: 300
  };
  static postStatisticsRegex = /Posted:\s*(\S+\s\S+).*Size:\s*(\d+)x(\d+).*Rating:\s*(\S+).*Score:\s*(\d+)/gm;

  /**
   * @param {FavoriteMetadata} favoriteMetadata
   */
  static async fetchMissingMetadata(favoriteMetadata) {
    if (favoriteMetadata !== undefined) {
      FavoriteMetadata.missingMetadataFetchQueue.push(favoriteMetadata);
    }

    if (FavoriteMetadata.currentlyFetchingFromQueue) {
      return;
    }
    FavoriteMetadata.currentlyFetchingFromQueue = true;

    while (FavoriteMetadata.missingMetadataFetchQueue.length > 0) {
      const metadata = this.missingMetadataFetchQueue.pop();

      if (metadata.postIsDeleted) {
        metadata.populateMetadataFromPost();
      } else {
        metadata.populateMetadataFromAPI(true);
      }
      await sleep(metadata.fetchDelay);
    }
    FavoriteMetadata.currentlyFetchingFromQueue = false;
  }

  /**
   * @param {String} rating
   * @returns {Number}
   */
  static encodeRating(rating) {
    return {
      "Explicit": 4,
      "E": 4,
      "e": 4,
      "Questionable": 2,
      "Q": 2,
      "q": 2,
      "Safe": 1,
      "S": 1,
      "s": 1
    }[rating] || 4;
  }

  static {
    if (!onPostPage()) {
      window.addEventListener("favoritesLoaded", () => {
        FavoriteMetadata.allFavoritesLoaded = true;
        FavoriteMetadata.missingMetadataFetchQueue = FavoriteMetadata.missingMetadataFetchQueue.concat(FavoriteMetadata.deletedPostFetchQueue);
        FavoriteMetadata.fetchMissingMetadata();
      }, {
        once: true
      });
    }
  }
  /**
   * @type {String}
  */
  id;
  /**
   * @type {Number}
  */
  width;
  /**
   * @type {Number}
  */
  height;
  /**
   * @type {Number}
  */
  score;
  /**
   * @type {Number}
  */
  rating;
  /**
   * @type {Number}
  */
  creationTimestamp;
  /**
   * @type {Number}
  */
  lastChangedTimestamp;
  /**
   * @type {Boolean}
  */
  postIsDeleted;

  /**
   * @returns {String}
  */

  get apiURL() {
    return `https://api.rule34.xxx//index.php?page=dapi&s=post&q=index&id=${this.id}`;
  }

  get postURL() {
    return `https://rule34.xxx/index.php?page=post&s=view&id=${this.id}`;
  }

  get fetchDelay() {
    return this.postIsDeleted ? FavoriteMetadata.fetchDelay.deleted : FavoriteMetadata.fetchDelay.normal;
  }

  /**
   * @type {String}
   */
  get json() {
    return JSON.stringify({
      width: this.width,
      height: this.height,
      score: this.score,
      rating: this.rating,
      create: this.creationTimestamp,
      change: this.lastChangedTimestamp,
      deleted: this.postIsDeleted
    });
  }

  /**
   * @type {Number}
   */
  get pixelCount() {
    return this.width * this.height;
  }

  /**
   * @param {String} id
   * @param {Object.<String, String>} record
   */
  constructor(id, record) {
    this.id = id;
    this.setDefaults();
    this.populateMetadata(record);
    this.addInstanceToAllMetadata();
  }

  setDefaults() {
    this.width = 0;
    this.height = 0;
    this.score = 0;
    this.creationTimestamp = 0;
    this.lastChangedTimestamp = 0;
    // this.rating = 4;
    this.postIsDeleted = false;
  }

  /**
   * @param {Number} rating
   */
  presetRating(rating) {
    this.rating = rating;
  }

  /**
   * @param {Object.<String, String>} record
   */
  populateMetadata(record) {
    if (record === undefined) {
      this.populateMetadataFromAPI();
    } else if (record === null) {
      FavoriteMetadata.fetchMissingMetadata(this, true);
    } else {
      this.populateMetadataFromRecord(JSON.parse(record));

      if (this.isEmpty()) {
        FavoriteMetadata.fetchMissingMetadata(this, true);
      }
    }
  }

  /**
   * @param {Boolean} missingInDatabase
   */
  populateMetadataFromAPI(missingInDatabase = false) {
    fetch(this.apiURL)
      .then((response) => {
        return response.text();
      })
      .then((html) => {
        const dom = FavoriteMetadata.parser.parseFromString(html, "text/html");
        const metadata = dom.querySelector("post");

        if (metadata === null) {
          throw new Error(`metadata is null - ${this.apiURL}`, {
            cause: "DeletedMetadata"
          });
        }
        this.width = parseInt(metadata.getAttribute("width"));
        this.height = parseInt(metadata.getAttribute("height"));
        this.score = parseInt(metadata.getAttribute("score"));
        this.rating = FavoriteMetadata.encodeRating(metadata.getAttribute("rating"));
        this.creationTimestamp = Date.parse(metadata.getAttribute("created_at"));
        this.lastChangedTimestamp = parseInt(metadata.getAttribute("change"));

        const extension = getExtensionFromImageURL(metadata.getAttribute("file_url"));

        if (extension !== "mp4") {
          dispatchEvent(new CustomEvent("favoriteMetadataFetched", {
            detail: {
              id: this.id,
              extension
            }
          }));
        }

        if (missingInDatabase) {
          dispatchEvent(new CustomEvent("missingMetadata", {
            detail: this.id
          }));
        }
      })
      .catch((error) => {
        if (error.cause === "DeletedMetadata") {
          this.postIsDeleted = true;
          FavoriteMetadata.deletedPostFetchQueue.push(this);
        } else if (error.message === "Failed to fetch") {
          FavoriteMetadata.missingMetadataFetchQueue.push(this);
        } else {
          console.error(error);
        }
      });
  }

  /**
   * @param {Object.<String, String>} record
  */
  populateMetadataFromRecord(record) {
    this.width = record.width;
    this.height = record.height;
    this.score = record.score;
    this.rating = record.rating;
    this.creationTimestamp = record.create;
    this.lastChangedTimestamp = record.change;
    this.postIsDeleted = record.deleted;
  }

  populateMetadataFromPost() {
    fetch(this.postURL)
      .then((response) => {
        return response.text();
      })
      .then((html) => {
        const dom = FavoriteMetadata.parser.parseFromString(html, "text/html");
        const statistics = dom.getElementById("stats");

        if (statistics === null) {
          return;
        }
        const textContent = replaceLineBreaks(statistics.textContent.trim(), " ");
        const match = FavoriteMetadata.postStatisticsRegex.exec(textContent);

        FavoriteMetadata.postStatisticsRegex.lastIndex = 0;

        if (!match) {
          return;
        }
        this.width = parseInt(match[2]);
        this.height = parseInt(match[3]);
        this.score = parseInt(match[5]);
        this.rating = FavoriteMetadata.encodeRating(match[4]);
        this.creationTimestamp = Date.parse(match[1]);
        this.lastChangedTimestamp = this.creationTimestamp / 1000;

        if (FavoriteMetadata.allFavoritesLoaded) {
          dispatchEvent(new CustomEvent("missingMetadata", {
            detail: this.id
          }));
        }
      });
  }

  /**
   * @returns {Boolean}
   */
  isEmpty() {
    return this.width === 0 && this.height === 0;
  }

  /**
   * @param {{metric: String, operator: String, value: String, negated: Boolean}[]} filters
   * @returns {Boolean}
   */
  satisfiesAllFilters(filters) {
    for (const expression of filters) {
      if (!this.satisfiesExpression(expression)) {
        return false;
      }
    }
    return true;
  }

  /**
   * @param {MetadataSearchExpression} expression
   * @returns {Boolean}
   */
  satisfiesExpression(expression) {
    const metricMap = {
      "id": this.id,
      "width": this.width,
      "height": this.height,
      "score": this.score
    };
    const metricValue = metricMap[expression.metric] || 0;
    const value = metricMap[expression.value] || expression.value;
    return this.evaluateExpression(metricValue, expression.operator, value);
  }

  /**
   * @param {Number} metricValue
   * @param {String} operator
   * @param {Number} value
   * @returns {Boolean}
   */
  evaluateExpression(metricValue, operator, value) {
    let result = false;

    switch (operator) {
      case ":":
        result = metricValue === value;
        break;

      case ":<":
        result = metricValue < value;
        break;

      case ":>":
        result = metricValue > value;
        break;

      default:
        break;
    }
    return result;
  }

  addInstanceToAllMetadata() {
    if (!FavoriteMetadata.allMetadata.has(this.id)) {
      FavoriteMetadata.allMetadata.set(this.id, this);
    }
  }
}


// thumb_node.js

class ThumbNode {
  /**
   * @type {Map.<String, ThumbNode>}
   */
  static allThumbNodes = new Map();
  /**
   * @type {RegExp}
  */
  static thumbSourceExtractionRegex = /thumbnails\/\/([0-9]+)\/thumbnail_([0-9a-f]+)/;
  /**
   * @type {DOMParser}
  */
  static parser = new DOMParser();
  /**
   * @type {HTMLElement}
  */
  static template;
  /**
   * @type {String}
  */
  static removeFavoriteButtonHTML;
  /**
   * @type {String}
  */
  static addFavoriteButtonHTML;

  static {
    if (!onPostPage()) {
      this.createTemplates();
      this.addEventListeners();
    }
  }

  static createTemplates() {
    ThumbNode.template = ThumbNode.parser.parseFromString("<div class=\"thumb-node\"></div>", "text/html").createElement("div");
    const canvasHTML = getPerformanceProfile() > 0 ? "" : "<canvas></canvas>";
    const heartPlusBlobURL = createObjectURLFromSvg(ICONS.heartPlus);
    const heartMinusBlobURL = createObjectURLFromSvg(ICONS.heartMinus);
    const heartPlusImageHTML = `<img src=${heartPlusBlobURL}>`;
    const heartMinusImageHTML = `<img src=${heartMinusBlobURL}>`;

    ThumbNode.removeFavoriteButtonHTML = `<button class="remove-favorite-button auxillary-button">${heartMinusImageHTML}</button>`;
    ThumbNode.addFavoriteButtonHTML = `<button class="add-favorite-button auxillary-button">${heartPlusImageHTML}</button>`;
    const auxillaryButtonHTML = userIsOnTheirOwnFavoritesPage() ? ThumbNode.removeFavoriteButtonHTML : ThumbNode.addFavoriteButtonHTML;

    ThumbNode.template.className = "thumb-node";
    ThumbNode.template.innerHTML = `
        <div>
          <img loading="lazy">
          ${auxillaryButtonHTML}
          ${canvasHTML}
        </div>
    `;
  }

  static addEventListeners() {
    window.addEventListener("favoriteAddedOrDeleted", (event) => {
      const id = event.detail;
      const thumbNode = this.allThumbNodes.get(id);

      if (thumbNode !== undefined) {
        thumbNode.swapAuxillaryButton();
      }
    });
  }

  /**
   * @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 = onSearchPage() ? thumb : thumb.children[0];
    return elementWithId.id.substring(1);
  }

  /**
   * @param {HTMLElement} thumb
   * @returns {Number}
   */
  static extractRatingFromThumb(thumb) {
    const rating = (/'rating':'(\S)/).exec(thumb.nextSibling.textContent)[1];
    return FavoriteMetadata.encodeRating(rating);
  }

  /**
   * @param {String} id
   * @returns {Number}
   */
  static getPixelCount(id) {
    const thumbNode = ThumbNode.allThumbNodes.get(id);

    if (thumbNode === undefined || thumbNode.metadata === undefined) {
      return 0;
    }
    return thumbNode.metadata.pixelCount;
  }

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

  /**
   * @param {String} id
   * @returns {String}
   */
  static getExtensionFromThumbNode(id) {
    const thumbNode = ThumbNode.allThumbNodes.get(id);

    if (thumbNode === undefined) {
      return undefined;
    }

    if (thumbNode.metadata.isEmpty()) {
      return undefined;
    }
    return thumbNode.metadata.extension;
  }

  /**
   * @type {HTMLDivElement}
   */
  root;
  /**
   * @type {String}
   */
  id;
  /**
   * @type {HTMLElement}
   */
  container;
  /**
   * @type {HTMLImageElement}
   */
  image;
  /**
   * @type {HTMLButtonElement}
   */
  auxillaryButton;
  /**
   * @type {String}
   */
  additionalTags;
  /**
   * @type {Set.<String>}
   */
  tagSet;
  /**
   * @type {Boolean}
   */
  matchedByMostRecentSearch;
  /**
   * @type {FavoriteMetadata}
  */
  metadata;

  /**
   * @type {String}
   */
  get href() {
    return `https://rule34.xxx/index.php?page=post&s=view&id=${this.id}`;
  }

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

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

  /**
   * @type {String}
  */
  get originalTags() {
    return Array.from(this.tagSet).join(" ");
  }

  /**
   * @type {String}
  */
  get finalTags() {
    return this.mergeTags(this.originalTags, this.additionalTags);
  }

  /**
   * @param {HTMLElement | {id: String, tags: String, src: String, type: String}} thumb
   * @param {Boolean} fromRecord
   */
  constructor(thumb, fromRecord) {
    this.instantiateTemplate();
    this.populateAttributes(thumb, fromRecord);
    this.setupAuxillaryButton();
    this.setupClickLink();
    this.setMatched(true);
    this.addInstanceToAllThumbNodes();
  }

  instantiateTemplate() {
    this.root = ThumbNode.template.cloneNode(true);
    this.container = this.root.children[0];
    this.image = this.root.children[0].children[0];
    this.auxillaryButton = this.root.children[0].children[1];
  }

  setupAuxillaryButton() {
    if (userIsOnTheirOwnFavoritesPage()) {
      this.auxillaryButton.onclick = this.removeFavoriteButtonOnClick.bind(this);
    } else {
      this.auxillaryButton.onclick = this.addFavoriteButtonOnClick.bind(this);
    }
  }

  /**
   * @param {MouseEvent} event
   */
  removeFavoriteButtonOnClick(event) {
    event.stopPropagation();
    removeFavorite(this.id);
    this.swapAuxillaryButton();
  }
  /**
   * @param {MouseEvent} event
   */
  addFavoriteButtonOnClick(event) {
    event.stopPropagation();
    addFavorite(this.id);

    this.swapAuxillaryButton();
  }

  swapAuxillaryButton() {
    const isRemoveFavoriteButton = this.auxillaryButton.classList.contains("remove-favorite-button");

    if (isRemoveFavoriteButton) {
      this.auxillaryButton.outerHTML = ThumbNode.addFavoriteButtonHTML;
      this.auxillaryButton = this.root.children[0].children[1];
      this.auxillaryButton.onclick = this.addFavoriteButtonOnClick.bind(this);
    } else {
      this.auxillaryButton.outerHTML = ThumbNode.removeFavoriteButtonHTML;
      this.auxillaryButton = this.root.children[0].children[1];
      this.auxillaryButton.onclick = this.removeFavoriteButtonOnClick.bind(this);
    }
  }

  /**
   * @param {HTMLElement | {id: String, tags: String, src: String, type: String, metadata: String}} thumb
   * @param {Boolean} fromDatabaseRecord
   */
  populateAttributes(thumb, fromDatabaseRecord) {
    if (fromDatabaseRecord) {
      this.populateAttributesFromDatabaseRecord(thumb);
    } else {
      this.populateAttributesFromHTMLElement(thumb);
    }
    this.root.id = this.id;
    this.additionalTags = TagModifier.tagModifications.get(this.id) || "";
    this.updateTags();
  }

  /**
   * @param {{id: String, tags: String, src: String, type: String, metadata: String}} record
   */
  populateAttributesFromDatabaseRecord(record) {
    this.image.src = ThumbNode.decompressThumbSource(record.src, record.id);
    this.id = record.id;
    this.tagSet = this.createTagSet(record.tags);
    this.image.className = record.type;

    if (record.metadata === undefined) {
      record.metadata = null;
    }
    this.metadata = new FavoriteMetadata(this.id, record.metadata);
  }

  /**
   * @param {HTMLElement} thumb
   */
  populateAttributesFromHTMLElement(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);

    const thumbTags = `${correctMisspelledTags(imageElement.title)} ${this.id}`;

    this.tagSet = this.createTagSet(thumbTags);
    this.image.classList.add(getContentType(thumbTags));
    this.metadata = new FavoriteMetadata(this.id);
    this.metadata.presetRating(ThumbNode.extractRatingFromThumb(thumb));
  }

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

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

  toggleMatched() {
    this.matchedByMostRecentSearch = !this.matchedByMostRecentSearch;
  }

  /**
   * @param {Boolean} value
   */
  setMatched(value) {
    this.matchedByMostRecentSearch = value;
  }

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

    for (const newTag of newTags.split(" ")) {
      if (newTag !== "") {
        finalTags.add(newTag);
      }
    }

    if (finalTags.size === 0) {
      return "";
    }
    return removeExtraWhiteSpace(Array.from(finalTags.keys()).join(" "));
  }

  /**
   *
   * @param {String} tags
   * @returns {Set.<String>}
   */
  createTagSet(tags) {
    return new Set(removeExtraWhiteSpace(tags).split(" ").sort());
  }

  updateTags() {
    if (this.additionalTags !== "") {
      this.tagSet = this.createTagSet(this.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();
  }
}


// match.js

/* eslint-disable max-classes-per-file */
class SearchTag {
  /**
   * @type {String}
  */
  value;
  /**
   * @type {Boolean}
  */
  negated;

  /**
   * @type {Number}
  */
  get cost() {
    return 0;
  }

  /**
   * @type {Number}
  */
  get finalCost() {
    return this.negated ? this.cost + 1 : this.cost;
  }

  /**
   * @param {String} searchTag
   * @param {Boolean} inOrGroup
   */
  constructor(searchTag, inOrGroup) {
    this.negated = inOrGroup ? false : searchTag.startsWith("-");
    this.value = this.negated ? searchTag.substring(1) : searchTag;
  }

  /**
   * @param {ThumbNode} thumbNode
   * @returns {Boolean}
   */
  matches(thumbNode) {
    if (thumbNode.tagSet.has(this.value)) {
      return !this.negated;
    }
    return this.negated;
  }
}

class WildCardSearchTag extends SearchTag {
  static unmatchableRegex = /^\b$/;
  static startsWithRegex = /^[^*]*\*$/;

  /**
   * @type {RegExp}
  */
  regex;
  /**
   * @type {Boolean}
  */
  equivalentToStartsWith;
  /**
   * @type {String}
  */
  startsWithPrefix;

  /**
   * @type {Number}
  */
  get cost() {
    return this.equivalentToStartsWith ? 10 : 20;
  }

  /**
   * @param {String} searchTag
   * @param {Boolean} inOrGroup
   */
  constructor(searchTag, inOrGroup) {
    super(searchTag, inOrGroup);
    this.regex = this.createWildcardRegex();
    this.equivalentToStartsWith = WildCardSearchTag.startsWithRegex.test(searchTag);
    this.startsWithPrefix = this.value.slice(0, -1);
  }

  /**
   * @returns {RegExp}
   */
  createWildcardRegex() {
    try {
      return new RegExp(`^${this.value.replaceAll(/\*/g, ".*")}$`);
    } catch {
      return WildCardSearchTag.unmatchableRegex;
    }
  }

  /**
   * @param {ThumbNode} thumbNode
   * @returns {Boolean}
   */
  matches(thumbNode) {
    if (this.equivalentToStartsWith) {
      return this.matchesPrefix(thumbNode);
    }
    return this.matchesWildcard(thumbNode);
  }

  /**
   * @param {ThumbNode} thumbNode
   * @returns {Boolean}
   */
  matchesPrefix(thumbNode) {
    for (const tag of thumbNode.tagSet.values()) {
      if (tag.startsWith(this.startsWithPrefix)) {
        return !this.negated;
      }

      if (this.startsWithPrefix < tag) {
        break;
      }
    }
    return this.negated;
  }

  /**
   * @param {ThumbNode} thumbNode
   * @returns {Boolean}
   */
  matchesWildcard(thumbNode) {
    for (const tag of thumbNode.tagSet.values()) {
      if (this.regex.test(tag)) {
        return !this.negated;
      }
    }
    return this.negated;
  }
}

class MetadataSearchTag extends SearchTag {
  static regex = /^-?(score|width|height|id)(:[<>]?)(\d+|score|width|height|id)$/;

  /**
   * @type {MetadataSearchExpression}
  */
  expression;

  /**
   * @type {Number}
  */
  get cost() {
    return 0;
  }

  /**
   * @param {String} searchTag
   * @param {Boolean} inOrGroup
   */
  constructor(searchTag, inOrGroup) {
    super(searchTag, inOrGroup);
    this.expression = this.createExpression(searchTag);
  }

  /**
   * @param {String} searchTag
   * @returns {MetadataSearchExpression}
   */
  createExpression(searchTag) {
    const extractedExpression = MetadataSearchTag.regex.exec(searchTag);
    return new MetadataSearchExpression(
      extractedExpression[1],
      extractedExpression[2],
      extractedExpression[3]
    );
  }

  /**
   * @param {ThumbNode} thumbNode
   * @returns {Boolean}
   */
  matches(thumbNode) {
    const metadata = FavoriteMetadata.allMetadata.get(thumbNode.id);

    if (metadata === undefined) {
      return false;
    }

    if (metadata.satisfiesExpression(this.expression)) {
      return !this.negated;
    }
    return this.negated;
  }
}

/**
 * @param {String[]} searchTagValues
 * @param {Boolean} isOrGroup
 * @returns {SearchTag[]}
 */
function createSearchTagGroup(searchTagValues, isOrGroup) {
  const uniqueSearchTagValues = new Set();
  const searchTags = [];

  for (const searchTagValue of searchTagValues) {
    if (uniqueSearchTagValues.has(searchTagValue)) {
      continue;
    }
    uniqueSearchTagValues.add(searchTagValue);
    searchTags.push(createSearchTag(searchTagValue, isOrGroup));
  }
  return searchTags;
}

/**
 * @param {String} searchTagValue
 * @param {Boolean} inOrGroup
 * @returns {SearchTag}
 */
function createSearchTag(searchTagValue, inOrGroup) {
  if (MetadataSearchTag.regex.test(searchTagValue)) {
    return new MetadataSearchTag(searchTagValue, inOrGroup);
  }

  if (searchTagValue.includes("*")) {
    return new WildCardSearchTag(searchTagValue, inOrGroup);
  }
  return new SearchTag(searchTagValue, inOrGroup);
}

class SearchCommand {
  /**
   * @param {SearchTag[]} searchTags
   */
  static sortByLeastExpensive(searchTags) {
    searchTags.sort((a, b) => {
      return a.finalCost - b.finalCost;
    });
  }

  /**
   * @type {SearchTag[][]}
  */
  orGroups;
  /**
   * @type {SearchTag[]}
  */
  remainingSearchTags;
  /**
   * @type {Boolean}
  */
  isEmpty;

  /**
   * @param {String} searchQuery
   */
  constructor(searchQuery) {
    this.orGroups = [];
    this.remainingSearchTags = [];
    this.isEmpty = searchQuery.trim() === "";

    if (this.isEmpty) {
      return;
    }
    const {orGroups, remainingSearchTags} = extractTagGroups(searchQuery);

    for (const orGroup of orGroups) {
      this.orGroups.push(createSearchTagGroup(orGroup, true));
    }
    this.remainingSearchTags = createSearchTagGroup(remainingSearchTags, false);
    this.optimizeSearchCommand();
  }

  optimizeSearchCommand() {
    for (const orGroup of this.orGroups) {
      SearchCommand.sortByLeastExpensive(orGroup);
    }
    SearchCommand.sortByLeastExpensive(this.remainingSearchTags);
    this.orGroups.sort((a, b) => {
      return a.length - b.length;
    });
  }
}

/**
 * @param {SearchCommand} searchCommand
 * @param {ThumbNode} thumbNode
 * @returns {Boolean}
 */
function matchesSearch(searchCommand, thumbNode) {
  if (searchCommand.isEmpty) {
    return true;
  }

  if (!matchesAllRemainingSearchTags(searchCommand.remainingSearchTags, thumbNode)) {
    return false;
  }
  return matchesAllOrGroups(searchCommand.orGroups, thumbNode);
}

/**
 * @param {SearchTag[]} remainingSearchTags
 * @param {ThumbNode} thumbNode
 * @returns {Boolean}
 */
function matchesAllRemainingSearchTags(remainingSearchTags, thumbNode) {
  for (const searchTag of remainingSearchTags) {
    if (!searchTag.matches(thumbNode)) {
      return false;
    }
  }
  return true;
}

/**
 * @param {SearchTag[][]} orGroups
 * @param {ThumbNode} thumbNode
 * @returns {Boolean}
 */
function matchesAllOrGroups(orGroups, thumbNode) {
  for (const orGroup of orGroups) {
    if (!atLeastOneThumbNodeTagIsInOrGroup(orGroup, thumbNode)) {
      return false;
    }
  }
  return true;
}

/**
 * @param {SearchTag[]} orGroup
 * @param {ThumbNode} thumbNode
 * @returns {Boolean}
 */
function atLeastOneThumbNodeTagIsInOrGroup(orGroup, thumbNode) {
  for (const orTag of orGroup) {
    if (orTag.matches(thumbNode)) {
      return true;
    }
  }
  return false;
}

/**
 * @param {String} searchTag
 * @param {String[]} tags
 * @returns {Boolean}
 */
function tagsMatchWildcardSearchTag(searchTag, tags) {
  const wildcardRegex = new RegExp(`^${searchTag.replaceAll(/\*/g, ".*")}$`);
  return tags.some(tag => wildcardRegex.test(tag));
}


// loader.js

/* eslint-disable no-bitwise */
class FavoritesLoader {
  static loadingState = {
    initial: 0,
    fetchingFavorites: 1,
    allFavoritesLoaded: 2,
    loadingFavoritesFromDatabase: 3
  };
  static currentLoadingState = FavoritesLoader.loadingState.initial;
  static databaseName = "Favorites";
  static objectStoreName = `user${getFavoritesPageId()}`;
  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, metadata: String}]} favorites
   */
  storeFavorites(favorites) {
    this.openConnection()
      .then((connectionEvent) => {
        /**
         * @type {IDBDatabase}
         */
        const database = connectionEvent.target.result;
        const transaction = database.transaction(this.objectStoreName, "readwrite");
        const objectStore = transaction.objectStore(this.objectStoreName);

        transaction.oncomplete = () => {
          postMessage({
            response: "finishedStoring"
          });
          database.close();
        };

        transaction.onerror = (event) => {
          console.error(event);
        };

        favorites.forEach(favorite => {
          this.addContentTypeToFavorite(favorite);
          objectStore.put(favorite);
        });

      })
      .catch((event) => {
        const error = event.target.error;

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

  /**
   * @param {String[]} idsToDelete
   */
  async loadFavorites(idsToDelete) {
    let loadedFavorites = {};

    await this.openConnection()
      .then(async(connectionEvent) => {
        /**
         * @type {IDBDatabase}
        */
        const database = connectionEvent.target.result;
        const transaction = database.transaction(this.objectStoreName, "readwrite");
        const objectStore = transaction.objectStore(this.objectStoreName);
        const index = objectStore.index("id");

        transaction.onerror = (event) => {
          console.error(event);
        };
        transaction.oncomplete = () => {
          postMessage({
            response: "finishedLoading",
            favorites: loadedFavorites
          });
          database.close();
        };

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

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

            if (primaryKey !== undefined) {
              objectStore.delete(primaryKey);
            }
          }).catch((error) => {
            console.error(error);
          });
        }
        const getAllRequest = objectStore.getAll();

        getAllRequest.onsuccess = (event) => {
          loadedFavorites = event.target.result.reverse();
        };
        getAllRequest.onerror = (event) => {
          console.error(event);
        };
      });
  }

  /**
 * @param {[{id: String, tags: String, src: String, metadata: String}]} favorites
 */
  updateFavorites(favorites) {
    this.openConnection()
      .then((event) => {
        /**
         * @type {IDBDatabase}
        */
        const database = event.target.result;
        const favoritesObjectStore = database
          .transaction(this.objectStoreName, "readwrite")
          .objectStore(this.objectStoreName);
        const objectStoreIndex = favoritesObjectStore.index("id");
        let updatedCount = 0;

        favorites.forEach(favorite => {
          const index = objectStoreIndex.getKey(favorite.id);

          this.addContentTypeToFavorite(favorite);
          index.onsuccess = (indexEvent) => {
            const primaryKey = indexEvent.target.result;

            favoritesObjectStore.put(favorite, primaryKey);
            updatedCount += 1;

            if (updatedCount >= favorites.length) {
              database.close();
            }
          };
        });
      })
      .catch((event) => {
        const error = event.target.error;

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

  /**
   * @param {{id: String, tags: String, src: String, metadata: 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.idsToDelete);
      break;

    case "update":
      favoritesDatabase.updateFavorites(request.favorites);
      break;

    default:
      break;
  }
};

`
  };
  static tagNegation = {
    useTagBlacklist: true,
    negatedTagBlacklist: negateTags(TAG_BLACKLIST)
  };
  static parser = new DOMParser();

  static get disabled() {
    return !onFavoritesPage();
  }

  /**
   * @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 {Boolean}
   */
  searchResultsWereShuffled;
  /**
  /**
   * @type {Boolean}
   */
  searchResultsWereInverted;
  /**
   * @type {Number}
   */
  currentFavoritesPageNumber;
  /**
   * @type {HTMLElement}
   */
  paginationContainer;
  /**
   * @type {HTMLLabelElement}
   */
  paginationLabel;
  /**
   * @type {Boolean}
   */
  foundEmptyFavoritesPage;
  /**
   * @type {ThumbNode[]}
   */
  searchResultsWhileFetching;
  /**
   * @type {Number}
   */
  recentlyChangedMaxNumberOfFavoritesToDisplay;
  /**
   * @type {Number}
   */
  maxPageNumberButtonCount;
  /**
   * @type {Boolean}
   */
  newPageNeedsToBeCreated;
  /**
   * @type {Boolean}
   */
  tagsWereModified;
  /**
   * @type {Boolean}
   */
  excludeBlacklistClicked;
  /**
   * @type {Boolean}
  */
  sortingParametersChanged;
  /**
   * @type {Boolean}
  */
  allowedRatingsChanged;
  /**
   * @type {Number}
  */
  allowedRatings;
  /**
   * @type {String[]}
  */
  idsRequiringMetadataDatabaseUpdate;
  /**
   * @type {Number}
  */
  newMetadataReceivedTimeout;

  /**
   * @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 (FavoritesLoader.disabled) {
      return;
    }
    this.initializeFields();
    this.addEventListeners();
    this.initialize();
  }

  initializeFields() {
    this.allThumbNodes = [];
    this.searchResultsWhileFetching = [];
    this.idsRequiringMetadataDatabaseUpdate = [];
    this.finalPageNumber = this.getFinalFavoritesPageNumber();
    this.matchCountLabel = document.getElementById("match-count-label");
    this.maxNumberOfFavoritesToDisplay = getPreference("resultsPerPage", DEFAULTS.resultsPerPage);
    this.allowedRatings = loadAllowedRatings();
    this.fetchedThumbNodes = {};
    this.failedFetchRequests = [];
    this.expectedFavoritesCount = 53;
    this.expectedFavoritesCountFound = false;
    this.searchResultsAreShuffled = false;
    this.searchResultsAreInverted = false;
    this.searchResultsWereShuffled = false;
    this.searchResultsWereInverted = false;
    this.foundEmptyFavoritesPage = false;
    this.newPageNeedsToBeCreated = false;
    this.tagsWereModified = false;
    this.recentlyChangedMaxNumberOfFavoritesToDisplay = false;
    this.excludeBlacklistClicked = false;
    this.sortingParametersChanged = false;
    this.allowedRatingsChanged = false;
    this.matchingFavoritesCount = 0;
    this.maxPageNumberButtonCount = onMobileDevice() ? 3 : 5;
    this.searchQuery = "";
    this.databaseWorker = new Worker(getWorkerURL(FavoritesLoader.webWorkers.database));
    this.paginationContainer = this.createPaginationContainer();
    this.currentFavoritesPageNumber = 1;
  }

  addEventListeners() {
    window.addEventListener("modifiedTags", () => {
      this.tagsWereModified = true;
    });
    window.addEventListener("missingMetadata", (event) => {
      this.addNewFavoriteMetadata(event.detail);
    });
    this.createDatabaseMessageHandler();
  }

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

      switch (message.response) {
        case "finishedLoading":
          this.paginateSearchResults(this.reconstructContent(message.favorites));
          this.onAllFavoritesLoaded();
          await sleep(100);
          this.findNewFavoritesOnReload(this.getAllFavoriteIds(), 0, []);
          break;

        case "finishedStoring":
          break;

        default:
          break;
      }
    };
  }

  initialize() {
    this.setExpectedFavoritesCount();
    this.clearOriginalFavoritesPage();
    this.searchFavorites();
  }

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

    fetch(profileURL)
      .then((response) => {
        if (response.ok) {
          return response.text();
        }
        throw new Error(response.status);
      })
      .then((html) => {
        const table = FavoritesLoader.parser.parseFromString(html, "text/html").querySelector("table");
        const favoritesURL = Array.from(table.querySelectorAll("a")).find(a => a.href.includes("page=favorites&s=view"));
        const favoritesCount = parseInt(favoritesURL.textContent);

        this.expectedFavoritesCountFound = true;
        this.expectedFavoritesCount = favoritesCount;
      })
      .catch(() => {
        console.error(`Could not find total favorites count from ${profileURL}, are you logged in?`);
      });
  }

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

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

  /**
   * @param {String} searchQuery
   */
  searchFavorites(searchQuery) {
    this.setSearchQuery(searchQuery);
    this.resetMatchCount();
    dispatchEvent(new Event("searchStarted"));
    this.showSearchResults();
  }

  /**
   * @param {String} searchQuery
   */
  setSearchQuery(searchQuery) {
    if (searchQuery !== undefined) {
      this.searchQuery = searchQuery;
    }
  }

  showSearchResults() {
    switch (FavoritesLoader.currentLoadingState) {
      case FavoritesLoader.loadingState.fetchingFavorites:
        this.showSearchResultsWhileFetchingFavorites();
        break;

      case FavoritesLoader.loadingState.allFavoritesLoaded:
        this.showSearchResultsAfterAllFavoritesLoaded();
        break;

      case FavoritesLoader.loadingState.loadingFavoritesFromDatabase:
        break;

      case FavoritesLoader.loadingState.initial:
        this.retrieveFavorites();
        break;

      default:
        console.error(`Invalid FavoritesLoader state: ${FavoritesLoader.currentLoadingState}`);
        break;
    }
  }

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

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

  async retrieveFavorites() {
    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 = new SearchCommand(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 (matchesSearch(searchCommand, thumbNode)) {
        results.push(thumbNode);
        thumbNode.setMatched(true);
      } else {
        thumbNode.setMatched(false);
      }
    }
    return results;
  }

  /**
   * @param {Object.<String, ThumbNode>} allFavoriteIds
   * @param {Number} currentPageNumber
   * @param {ThumbNode[]} newFavoritesToAdd
   */
  findNewFavoritesOnReload(allFavoriteIds, currentPageNumber, newFavoritesToAdd) {
    const favoritesURL = `${document.location.href}&pid=${currentPageNumber}`;
    const exceededFavoritesPageNumber = currentPageNumber > this.finalPageNumber;
    let allNewFavoritesFound = false;

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

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

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

      if (allNewFavoritesFound || exceededFavoritesPageNumber) {
        this.allThumbNodes = newFavoritesToAdd.concat(this.allThumbNodes);
        this.addNewFavoritesOnReload(newFavoritesToAdd);
      } else {
        this.findNewFavoritesOnReload(allFavoriteIds, currentPageNumber + 50, newFavoritesToAdd);
      }
    });
  }

  /**
   * @param {ThumbNode[]} newFavorites
   */
  addNewFavoritesOnReload(newFavorites) {
    if (newFavorites.length === 0) {
      dispatchEvent(new CustomEvent("newFavoritesFetchedOnReload", {
        detail: {
          empty: true,
          thumbs: []
        }
      }));
      return;
    }
    this.storeFavorites(newFavorites);
    this.insertNewFavorites(newFavorites);
    this.toggleLoadingUI(false);
  }

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

  startFetchingFavorites() {
    FavoritesLoader.currentLoadingState = FavoritesLoader.loadingState.fetchingFavorites;
    this.toggleContentVisibility(true);
    this.insertPaginationContainer();
    this.updatePaginationUi(1, []);
    this.initializeFetchedThumbNodesInsertionQueue();
    dispatchEvent(new Event("readyToSearch"));
    setTimeout(() => {
      dispatchEvent(new Event("startedFetchingFavorites"));
    }, 50);
    this.fetchFavorites();
  }

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

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

  async fetchFavorites() {
    let currentPageNumber = 0;

    while (FavoritesLoader.currentLoadingState === FavoritesLoader.loadingState.fetchingFavorites) {
      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 * 50 <= this.finalPageNumber && !this.foundEmptyFavoritesPage) {
        this.fetchFavoritesFromSinglePage(currentPageNumber);
        currentPageNumber += 1;
        await sleep(210);
      } else if (this.isFinishedFetching(currentPageNumber)) {
        this.onAllFavoritesLoaded();
        this.storeFavorites();
      } else {
        await sleep(10000);
      }
    }
  }

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

    pageNumber = refetching ? failedRequest.pageNumber : pageNumber * 50;
    const favoritesPage = refetching ? failedRequest.url : `${document.location.href}&pid=${pageNumber}`;
    return fetch(favoritesPage)
      .then((response) => {
        if (response.ok) {
          return response.text();
        }
        throw new Error(`${response.status}: Favorite page failed to fetch, ${favoritesPage}`);
      })
      .then((html) => {
        const thumbNodes = this.extractThumbNodesFromFavoritesPage(html);
        const searchResults = this.getSearchResults(thumbNodes);

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

        if (refetching) {
          failedRequest.retries += 1;
        } else {
          failedRequest = this.getFailedFetchRequest(favoritesPage, pageNumber);
        }
        this.failedFetchRequests.push(failedRequest);
      });
  }

  /**
   * @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);
    const searchResultsWhileFetchingWithAllowedRatings = this.getResultsWithAllowedRatings(this.searchResultsWhileFetching);

    this.updateMatchCount(searchResultsWhileFetchingWithAllowedRatings.length);
    dispatchEvent(new CustomEvent("favoritesFetched", {
      detail: thumbNodes.map(thumbNode => thumbNode.root)
    }));
    this.allThumbNodes = this.allThumbNodes.concat(thumbNodes);
    this.addFavoritesToContent(searchResults);
    this.updateProgressWhileFetching();
  }

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

  /**
   * @param {String} response
   * @returns {ThumbNode[]}
   */
  extractThumbNodesFromFavoritesPage(response) {
    const dom = FavoritesLoader.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() {
    dispatchEvent(new Event("readyToSearch"));
    FavoritesLoader.currentLoadingState = FavoritesLoader.loadingState.allFavoritesLoaded;
    this.toggleLoadingUI(false);
    dispatchEvent(new CustomEvent("favoritesLoaded", {
      detail: this.allThumbNodes.map(thumbNode => thumbNode.root)
    }));
  }

  /**
   * @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 = new SearchCommand(this.finalSearchQuery);
    const searchResults = [];

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

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

  loadFavoritesFromDatabase() {
    FavoritesLoader.currentLoadingState = FavoritesLoader.loadingState.loadingFavoritesFromDatabase;
    this.toggleLoadingUI(true);
    let idsToDelete = [];

    if (userIsOnTheirOwnFavoritesPage()) {
      idsToDelete = getIdsToDeleteOnReload();
      clearIdsToDeleteOnReload();
    }
    this.databaseWorker.postMessage({
      command: "load",
      idsToDelete
    });
  }

  /**
   * @param {ThumbNode[]} thumbNodes
   */
  async storeFavorites(thumbNodes) {
    const storeAll = thumbNodes === undefined;

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

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

  /**
   * @param {ThumbNode[]} thumbNodes
   */
  updateFavorites(thumbNodes) {
    this.databaseWorker.postMessage({
      command: "update",
      favorites: thumbNodes.map(thumbNode => thumbNode.databaseRecord)
    });
  }

  /**
   * @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.
Tag modifications and saved searches will be preserved.
    `;

    if (confirm(message)) {
      const persistentLocalStorageKeys = new Set(["customTags", "savedSearches"]);

      Object.keys(localStorage).forEach((key) => {
        if (!persistentLocalStorageKeys.has(key)) {
          localStorage.removeItem(key);
        }
      });
      indexedDB.deleteDatabase(FavoritesLoader.databaseName);
    }
  }

  /**
   * @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 {Number} value
   */
  updateMatchCount(value) {
    if (!this.matchCountLabelExists) {
      return;
    }
    this.matchingFavoritesCount = value === undefined ? this.getSearchResults(this.allThumbNodes).length : value;
    this.matchCountLabel.textContent = `${this.matchingFavoritesCount} Matches`;
  }

  /**
   * @param {Number} 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 {ThumbNode[]} newThumbNodes
   */
  async insertNewFavorites(newThumbNodes) {
    const content = document.getElementById("content");
    const searchCommand = new SearchCommand(this.finalSearchQuery);
    const insertedThumbNodes = [];
    const metadataPopulateWaitTime = 1000;

    newThumbNodes.reverse();

    if (this.allowedRatings !== 7) {
      await sleep(metadataPopulateWaitTime);
    }

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

  /**
   * @param {ThumbNode[]} thumbNodes
   */
  addFavoritesToContent(thumbNodes) {
    thumbNodes = this.getResultsWithAllowedRatings(thumbNodes);
    const searchResultsWhileFetchingWithAllowedRatings = this.getResultsWithAllowedRatings(this.searchResultsWhileFetching);
    const pageNumberButtons = document.getElementsByClassName("pagination-number");
    const lastPageButtonNumber = pageNumberButtons.length > 0 ? parseInt(pageNumberButtons[pageNumberButtons.length - 1].textContent) : 1;
    const pageCount = this.getPageCount(searchResultsWhileFetchingWithAllowedRatings.length);
    const needsToCreateNewPage = pageCount > lastPageButtonNumber;
    const nextPageButton = document.getElementById("next-page");
    const alreadyAtMaxPageNumberButtons = document.getElementsByClassName("pagination-number").length >= this.maxPageNumberButtonCount &&
      nextPageButton !== null && nextPageButton.style.display !== "none" &&
      nextPageButton.style.visibility !== "hidden";

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

    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>}
   */
  getAllFavoriteIds() {
    const favoriteIds = {};

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

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

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

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

  insertPaginationContainer() {
    if (document.getElementById(this.paginationContainer.id) === null) {

      if (onMobileDevice()) {
        document.getElementById("favorites-top-bar-panels").insertAdjacentElement("afterbegin", this.paginationContainer);
      } else {
        const placeToInsertPagination = document.getElementById("favorites-pagination-placeholder");

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

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

    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.id = "previous-page";
    firstPage.id = "first-page";
    nextPage.id = "next-page";
    finalPage.id = "final-page";

    previousPage.onclick = () => {
      if (this.currentFavoritesPageNumber - 1 >= 1) {
        this.changeResultsPage(this.currentFavoritesPageNumber - 1, searchResults);
      }
    };
    firstPage.onclick = () => {
      this.changeResultsPage(1, searchResults);
    };
    nextPage.onclick = () => {
      if (this.currentFavoritesPageNumber + 1 <= pageCount) {
        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 = () => {
      let pageNumber = parseInt(input.value);

      if (!isNumber(pageNumber)) {
        return;
      }
      pageNumber = clamp(pageNumber, 1, this.getPageCount(searchResults.length));

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

  /**
   * @param {Number} pageNumber
   * @param {ThumbNode[]} searchResults
   */
  changeResultsPage(pageNumber, searchResults) {
    if (!this.aNewSearchWillProduceDifferentResults(pageNumber)) {
      return;
    }
    const {start, end} = this.getPaginationStartEndIndices(pageNumber);

    this.currentFavoritesPageNumber = pageNumber;
    this.updatePaginationUi(pageNumber, searchResults);
    this.createPaginatedFavoritesPage(searchResults, start, end);
    this.reAddAllThumbNodeEventListeners();
    this.resetFlagsThatImplyDifferentSearchResults();

    if (FavoritesLoader.currentLoadingState !== FavoritesLoader.loadingState.loadingFavoritesFromDatabase) {
      dispatchEventWithDelay("changedPage");
    }
  }

  resetFlagsThatImplyDifferentSearchResults() {
    this.searchResultsWereShuffled = this.searchResultsAreShuffled;
    this.searchResultsWereInverted = this.searchResultsAreInverted;
    this.tagsWereModified = false;
    this.excludeBlacklistClicked = false;
    this.sortingParametersChanged = false;
    this.allowedRatingsChanged = false;
    this.searchResultsAreShuffled = false;
    this.searchResultsAreInverted = false;
    this.previousSearchQuery = this.searchQuery;
  }

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

  /**
   * @param {ThumbNode[]} searchResults
   * @param {Number} start
   * @param {Number} end
   * @returns
   */
  createPaginatedFavoritesPage(searchResults, start, end) {
    const newThumbNodes = this.sortThumbNodes(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}
   */
  aNewSearchWillProduceDifferentResults(pageNumber) {
    return this.currentFavoritesPageNumber !== pageNumber ||
      this.searchQuery !== this.previousSearchQuery ||
      FavoritesLoader.currentLoadingState !== FavoritesLoader.loadingState.allFavoritesLoaded ||
      this.searchResultsAreShuffled ||
      this.searchResultsAreInverted ||
      this.searchResultsWereShuffled ||
      this.searchResultsWereInverted ||
      this.recentlyChangedMaxNumberOfFavoritesToDisplay ||
      this.tagsWereModified ||
      this.excludeBlacklistClicked ||
      this.sortingParametersChanged ||
      this.allowedRatingsChanged;
  }

  /**
   * @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.visibility = "hidden";
      firstPage.style.visibility = "hidden";
      nextPage.style.visibility = "hidden";
      finalPage.style.visibility = "hidden";
    } 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;
  }

  /**
   * @param {ThumbNode[]} thumbNodes
   * @returns {ThumbNode[]}
   */
  sortThumbNodes(thumbNodes) {
    if (!FavoritesLoader.loadingState.allFavoritesLoaded) {
      alert("Wait for all favorites to load before changing sort method");
      return thumbNodes;
    }
    const sortedThumbNodes = thumbNodes.slice();
    const sortingMethod = this.getSortingMethod();

    if (sortingMethod !== "default") {
      sortedThumbNodes.sort((b, a) => {
        switch (sortingMethod) {
          case "score":
            return a.metadata.score - b.metadata.score;

          case "width":
            return a.metadata.width - b.metadata.width;

          case "height":
            return a.metadata.height - b.metadata.height;

          case "create":
            return a.metadata.creationTimestamp - b.metadata.creationTimestamp;

          case "change":
            return a.metadata.lastChangedTimestamp - b.metadata.lastChangedTimestamp;

          case "id":
            return a.metadata.id - b.metadata.id;

          default:
            return 0;
        }
      });
    }

    if (this.sortAscending()) {
      sortedThumbNodes.reverse();
    }
    return sortedThumbNodes;
  }

  /**
   * @returns {String}
   */
  getSortingMethod() {
    if (this.searchResultsAreShuffled) {
      return "default";
    }
    const sortingMethodSelect = document.getElementById("sorting-method");
    return sortingMethodSelect === null ? "default" : sortingMethodSelect.value;
  }

  /**
   * @returns {Boolean}
   */
  sortAscending() {
    const sortFavoritesAscending = document.getElementById("sort-ascending");
    return sortFavoritesAscending === null ? false : sortFavoritesAscending.checked;
  }

  onSortingParametersChanged() {
    this.sortingParametersChanged = true;
    const matchedThumbNodes = this.getThumbNodesMatchedByLastSearch();

    this.paginateSearchResults(matchedThumbNodes);
  }

  /**
   * @param {Number} allowedRatings
   */
  onAllowedRatingsChanged(allowedRatings) {
    this.allowedRatings = allowedRatings;
    this.allowedRatingsChanged = true;
    const matchedThumbNodes = this.getThumbNodesMatchedByLastSearch();

    this.paginateSearchResults(matchedThumbNodes);
  }

  /**
   * @param {String} postId
   */
  addNewFavoriteMetadata(postId) {
    if (!ThumbNode.allThumbNodes.has(postId)) {
      return;
    }
    const batchSize = 500;
    const waitTime = 1000;

    clearTimeout(this.newMetadataReceivedTimeout);
    this.idsRequiringMetadataDatabaseUpdate.push(postId);

    if (this.idsRequiringMetadataDatabaseUpdate.length >= batchSize) {
      this.updateFavoriteMetadataInDatabase();
      return;
    }
    this.newMetadataReceivedTimeout = setTimeout(() => {
      this.updateFavoriteMetadataInDatabase();
    }, waitTime);
  }

  updateFavoriteMetadataInDatabase() {
    this.updateFavorites(this.idsRequiringMetadataDatabaseUpdate.map(id => ThumbNode.allThumbNodes.get(id)));
    this.idsRequiringMetadataDatabaseUpdate = [];
  }

  /**
   * @param {ThumbNode} thumbNode
   * @returns {Boolean}
  */
  ratingIsAllowed(thumbNode) {
    return (thumbNode.metadata.rating & this.allowedRatings) > 0;
  }

  /**
   * @param {ThumbNode[]} searchResults
   * @returns {ThumbNode[]}
   */
  getResultsWithAllowedRatings(searchResults) {
    if (this.allowedRatings === 7) {
      return searchResults;
    }
    return searchResults.filter(thumbNode => this.ratingIsAllowed(thumbNode));
  }

  /**
   * @param {{orGroups: String[][], remainingSearchTags: String[], isEmpty: Boolean}} searchCommand
   * @param {ThumbNode} thumbNode
   * @returns
   */
  matchesSearchAndRating(searchCommand, thumbNode) {
    return this.ratingIsAllowed(thumbNode) && matchesSearch(searchCommand, thumbNode);
  }
}

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;

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

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

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

        >button[disabled] {
          filter: none !important;
          cursor: wait !important;
        }
      }
    }

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

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

    button,
    input[type="checkbox"] {
      cursor: pointer;
    }

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

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

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

    .auxillary-button {
      position: absolute;
      left: 0px;
      top: 0px;
      width: 40%;
      font-weight: bold;
      background: none;
      border: none;
      z-index: 2;
      filter: grayscale(50%);

      &:active,
      &:hover {
        filter: none !important;
      }
    }

    .remove-favorite-button {
      color: red;
    }

    .add-favorite-button {
      >svg {
        fill: hotpink;
      }
    }

    .thumb-node {
      position: relative;
      -webkit-touch-callout: none;
      -webkit-user-select: none;
      -khtml-user-select: none;
      -moz-user-select: none;
      -ms-user-select: none;
      user-select: none;

      >a,
      >div {
        overflow: hidden;
        position: relative;

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

        &:has(.auxillary-button:hover) {
          outline-style: solid !important;
          outline-width: 5px !important;
        }

        &:has(.remove-favorite-button:hover) {
          outline-color: red !important;

          >.remove-favorite-button {
            color: red;
          }
        }

        &:has(.add-favorite-button:hover) {
          outline-color: hotpink !important;

          >.add-favorite-button {
            svg {
              fill: hotpink;
            }
          }
        }

        >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"] {
      -moz-appearance: textfield;
      appearance: none;
      width: 15px;
    }

    #column-resize-container {
      >div {
        align-content: center;

        >button {
          width: 30px;
          height: 30px;
          padding: 0;
          margin: 0;
        }
      }
    }

    #column-resize-input {
      margin: 0;
      position: relative;
      bottom: 9px;
      width: 30px;
      height: 25px;
      font-size: larger;
    }

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

    #whats-new-link {
      cursor: pointer;
      padding: 0;
      position: relative;
      font-weight: bolder;
      font-style: italic;
      background: none;
      text-decoration: none !important;

      &.hidden:not(.persistent)>div {
        display: none;
      }

      &.persistent,
      &:hover {
        &.light-green-gradient {
          color: black;
        }

        &:not(.light-green-gradient) {
          color: white;
        }
      }
    }

    #whats-new-container {
      z-index: 10;
      top: 20px;
      left: 0px;
      font-style: normal;
      font-weight: normal;
      /* left: 50%; */
      /* transform: translateX(-50%); */
      white-space: nowrap;
      max-width: 100vw;
      padding: 5px 20px;
      position: absolute;
      pointer-events: none;
      text-shadow: none;
      border-radius: 2px;

      &.light-green-gradient {
        outline: 2px solid black;

      }

      &:not(.light-green-gradient) {
        outline: 1.5px solid white;
      }

      ul {
        padding-left: 20px;

        >li {
          list-style: none;
        }
      }

      h5,
      h6 {
        color: rgb(255, 0, 255);
      }
    }

    .hotkey {
      font-weight: bolder;
      color: orange;
    }

    #left-favorites-panel-bottom-row {
      display: flex;
      flex-flow: row wrap;
      margin-top: 10px;

      >div {
        flex: 1;
      }
    }

    #additional-favorite-options {
      >div:first-child {
        margin-top: 6px;
      }

      >div:not(:first-child) {
        margin-top: 11px;
      }

      select {
        cursor: pointer;
      }
    }

    #performance-profile {
      width: 150px;
    }

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

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

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

    #rating-container {
      margin-top: 3px !important;
    }

    #allowed-ratings {
      margin-top: 5px;
      font-size: 12px;

      >label {
        -webkit-touch-callout: none;
        -webkit-user-select: none;
        -khtml-user-select: none;
        -moz-user-select: none;
        -ms-user-select: none;
        user-select: none;
        outline: 1px solid;
        padding: 3px;
        cursor: pointer;
        opacity: 0.5;
        position: relative;
      }

      >label[for="explicit-rating-checkbox"] {
        border-radius: 7px 0px 0px 7px;
      }

      >label[for="questionable-rating-checkbox"] {
        margin-left: -3px;
      }

      >label[for="safe-rating-checkbox"] {
        margin-left: -3px;
        border-radius: 0px 7px 7px 0px;
      }

      >input[type="checkbox"] {
        display: none;

        &:checked+label {
          background-color: #0075FF;
          color: white;
          opacity: 1;
        }
      }
    }

    #sort-ascending {
      margin: 0;
      bottom: -6px;
      position: relative;
    }

    .auxillary-button {
      visibility: hidden;
    }

    #favorites-load-status {
      >label {
        display: inline-block;
        width: 140px;
      }
    }

    #favorites-fetch-progress-label {
      color: #3498db;
    }
  </style>
  <div id="favorites-top-bar-panels" style="display: flex;">
    <div id="left-favorites-panel">
      <h2 style="display: inline;">Search Favorites</h2>
      <span id="favorites-load-status" 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="padding-left: 20px; color: #3498db;"></label>
      </span>
      <div id="left-favorites-panel-top-row">
        <button title="Search favorites\nctrl+click/right-click: Search all of rule34 in a new tab"
          id="search-button">Search</button>
        <button title="Randomize order of search results" id="shuffle-button">Shuffle</button>
        <button title="Show results not matched by search" id="invert-button">Invert</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="favorites-pagination-placeholder"></span>
        <span id="help-links-container">
          <a href="https://github.com/bruh3396/favorites-search-gallery#controls" target="_blank">Help</a>
          |
          <a href="https://sleazyfork.org/en/scripts/504184-rule34-favorites-search-gallery/feedback"
            target="_blank">Feedback</a>
          |
          <a href="https://github.com/bruh3396/favorites-search-gallery/issues" target="_blank">Report Issue</a>
          |
          <a id="whats-new-link" href="" class="hidden light-green-gradient">What's new?
            <div id="whats-new-container" class="light-green-gradient">
              <h4>1.14:</h4>
              <h5>Features:</h5>
              <ul>
                <li>Search with meta tags: score, width, height, id</li>
                <li>Examples:</li>
                <ul>
                  <li>score:&gt;50 score:&lt;100 -score:55</li>
                  <li>height:&gt;width</li>
                  <li>( width:height ~ height:1920 ) id:&lt;999 </li>
                </ul>
                <li>Notes:</li>
                <ul>
                  <li> "12345" and "id:12345" are equivalent</li>
                  <li>Wildcard "*" does not work with meta tags</li>
                </ul>
              </ul>
              <h4>1.13:</h4>
              <h5>Features:</h5>
              <ul>
                <li>Wildcard search now works anywhere in tag</li>
                <li>Examples:</li>
                <ul>
                  <li>a*ple*auce</li>
                  <li>-*apple*</li>
                  <li>*ine*pple</li>
                </ul>
                <li>Blacklisted images removed from search pages</li>
              </ul>
              <h5>Performance:</h5>
              <ul>
                <li>Improved search speed</li>
                <li>Fixed mobile gallery orientation</li>
              </ul>
              <h4>1.11:</h4>
              <h5>Features:</h5>
              <ul>
                <li>Sort by score, upload date, etc.</li>
                <li>"Add favorite" buttons on other users' favorites pages</li>
                <li>Filter by rating</li>
              </ul>


              <h5>Gallery Hotkeys:</h5>
              <ul>
                <li><span class="hotkey">F</span> -- Add favorite</li>
                <li><span class="hotkey">X</span> -- Remove favorite</li>
                <li><span class="hotkey">M</span> -- Mute/unmute video</li>
                <li><span class="hotkey">B</span> -- Toggle background</li>
              </ul>

              <h5>Other Controls:</h5>
              <ul>
                <li><span class="hotkey">Shift + Scroll Wheel</span> -- Change column count</li>
                <li><span class="hotkey">T</span> -- Toggle tooltips</li>
                <li><span class="hotkey">D</span> -- Toggle details</li>
              </ul>
              <span style="display: none;">
                <h5>Performance:</h5>
                <ul>
                  <li>Reduced memory/network usage</li>
                  <li>Reduced load time</li>
                  <li>Seamless video playback (desktop)</li>
                </ul>

                <h5>Planned Features:</h5>
                <ul>
                  <li>Edit custom tags (basically folders/pools) on:</li>
                  <ul>
                    <li>search pages</li>
                    <li>post pages</li>
                  </ul>
                  <li>Fix comic strips</li>
                  <li>Gallery autoplay</li>
              </span>

              </ul>
            </div>
          </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 style="display: none;"><label class="checkbox" title="Toggle remove buttons"><input type="checkbox"
                  id="show-remove-favorite-buttons">
                Remove Buttons</label></div>
            <div style="display: none;"><label class="checkbox" title="Toggle add favorite buttons"><input
                  type="checkbox" id="show-add-favorite-buttons">
                Add Favorite 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="sort-container" title="Sort order of search results">
              <div>
                <label style="margin-right: 22px;" for="sorting-method">Sort By</label>
                <label style="margin-left:  22px;" for="sort-ascending">Ascending</label>
              </div>
              <div style="position: relative; bottom: 4px;">
                <select id="sorting-method" style="width: 150px;">
                  <option value="default">Default</option>
                  <option value="score">Score</option>
                  <option value="width">Width</option>
                  <option value="height">Height</option>
                  <option value="create">Date Uploaded</option>
                  <option value="change">Date Changed</option>
                  <!-- <option value="id">ID</option> -->
                </select>
                <input type="checkbox" id="sort-ascending">
              </div>
            </div>
            <div id="rating-container" title="Filter search results by rating">
              <label>Rating</label>
              <br>
              <div id="allowed-ratings">
                <input type="checkbox" id="explicit-rating-checkbox" checked>
                <label for="explicit-rating-checkbox">Explicit</label>
                <input type="checkbox" id="questionable-rating-checkbox" checked>
                <label for="questionable-rating-checkbox">Questionable</label>
                <input type="checkbox" id="safe-rating-checkbox" checked>
                <label for="safe-rating-checkbox" style="margin: -3px;">Safe</label>
              </div>
            </div>
            <div id="performance-profile-container" title="Improve performance by disabling features">
              <label for="performance-profile">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" for="results-per-page-input">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">
              <div>
                <label>Columns</label>
                <br>
                <button id="column-resize-minus">
                  <svg xmlns="http://www.w3.org/2000/svg" id="Isolation_Mode" data-name="Isolation Mode"
                    viewBox="0 0 24 24">
                    <rect x="6" y="10.5" width="12" height="3" />
                  </svg>
                </button>
                <input type="number" id="column-resize-input" min="2" max="20">
                <button id="column-resize-plus">
                  <svg xmlns="http://www.w3.org/2000/svg" id="Isolation_Mode" data-name="Isolation Mode"
                    viewBox="0 0 24 24">
                    <polygon
                      points="18 10.5 13.5 10.5 13.5 6 10.5 6 10.5 10.5 6 10.5 6 13.5 10.5 13.5 10.5 18 13.5 18 13.5 13.5 18 13.5 18 10.5" />
                  </svg>
                </button>
              </div>
            </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>
`;/* eslint-disable no-bitwise */

if (onFavoritesPage()) {
  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 = {
  showAuxillaryButtons: userIsOnTheirOwnFavoritesPage() ? "showRemoveButtons" : "showAddFavoriteButtons",
  showOptions: "showOptions",
  filterBlacklist: "filterBlacklistCheckbox",
  searchHistory: "favoritesSearchHistory",
  findFavorite: "findFavorite",
  thumbSize: "thumbSize",
  columnCount: "columnCount",
  showUI: "showUI",
  performanceProfile: "performanceProfile",
  resultsPerPage: "resultsPerPage",
  fancyImageHovering: "fancyImageHovering",
  enableOnSearchPages: "enableOnSearchPages",
  sortAscending: "sortAscending",
  sortingMethod: "sortingMethod",
  allowedRatings: "allowedRatings"
};
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"),
  showAuxillaryButtons: userIsOnTheirOwnFavoritesPage() ? document.getElementById("show-remove-favorite-buttons") : document.getElementById("show-add-favorite-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"),
  sortAscending: document.getElementById("sort-ascending"),
  explicitRating: document.getElementById("explicit-rating-checkbox"),
  questionableRating: document.getElementById("questionable-rating-checkbox"),
  safeRating: document.getElementById("safe-rating-checkbox")
};
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"),
  sortingMethod: document.getElementById("sorting-method"),
  allowedRatings: document.getElementById("allowed-ratings")
};
const FAVORITE_SEARCH_LABELS = {
  findFavorite: document.getElementById("find-favorite-label")
};
const columnWheelResizeCaptionCooldown = new Cooldown(500, true);

let searchHistory = [];
let searchHistoryIndex = 0;
let lastSearchQuery = "";

function initializeFavoritesPage() {
  setMainButtonInteractability(false);
  addEventListenersToFavoritesPage();
  loadFavoritesPagePreferences();
  removePaginatorFromFavoritesPage();
  configureAuxillaryButtonOptionVisibility();
  configureMobileUI();
  configureDesktopUI();
  setupWhatsNewDropdown();
}

function loadFavoritesPagePreferences() {
  const userIsLoggedIn = getUserId() !== null;
  const showAuxillaryButtonsDefault = !userIsOnTheirOwnFavoritesPage() && userIsLoggedIn;
  const auxillaryFavoriteButtonsAreVisible = getPreference(FAVORITE_PREFERENCES.showAuxillaryButtons, showAuxillaryButtonsDefault);

  FAVORITE_CHECKBOXES.showAuxillaryButtons.checked = auxillaryFavoriteButtonsAreVisible;
  setTimeout(() => {
    toggleAuxillaryButtons();
  }, 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";
    FAVORITE_CHECKBOXES.enableOnSearchPages.parentElement.style.display = "none";
  } else {
    const fancyImageHovering = getPreference(FAVORITE_PREFERENCES.fancyImageHovering, false);

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

  FAVORITE_CHECKBOXES.enableOnSearchPages.checked = getPreference(FAVORITE_PREFERENCES.enableOnSearchPages, false);
  FAVORITE_CHECKBOXES.sortAscending.checked = getPreference(FAVORITE_PREFERENCES.sortAscending, false);

  const sortingMethod = getPreference(FAVORITE_PREFERENCES.sortingMethod, "default");

  for (const option of FAVORITE_INPUTS.sortingMethod) {
    if (option.value === sortingMethod) {
      option.selected = "selected";
    }
  }
  const allowedRatings = loadAllowedRatings();

  FAVORITE_CHECKBOXES.explicitRating.checked = (allowedRatings & 4) === 4;
  FAVORITE_CHECKBOXES.questionableRating.checked = (allowedRatings & 2) === 2;
  FAVORITE_CHECKBOXES.safeRating.checked = (allowedRatings & 1) === 1;
  preventUserFromUncheckingAllRatings(allowedRatings);
}

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

      openSearchPage(queryWithFormattedIds);
    } else {
      hideAwesomplete(FAVORITE_INPUTS.searchBox);
      favoritesLoader.searchFavorites(query);
      addToFavoritesSearchHistory(query);
    }
  };
  FAVORITE_BUTTONS.search.addEventListener("contextmenu", (event) => {
    const queryWithFormattedIds = FAVORITE_INPUTS.searchBox.value.replace(/(?:^|\s)(\d+)(?:$|\s)/g, " id:$1 ");

    openSearchPage(queryWithFormattedIds);
    event.preventDefault();
  });
  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 (awesompleteIsVisible(FAVORITE_INPUTS.searchBox)) {
          updateLastSearchQuery();
        } else {
          event.preventDefault();
          traverseFavoritesSearchHistory(event.key);
        }
        break;

      default:
        updateLastSearchQuery();
        break;
    }
  });
  FAVORITE_INPUTS.searchBox.addEventListener("wheel", (event) => {
    if (event.shiftKey || event.ctrlKey) {
      return;
    }
    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);
  };

  FAVORITE_CHECKBOXES.showAuxillaryButtons.onchange = () => {
    toggleAuxillaryButtons();
    setPreference(FAVORITE_PREFERENCES.showAuxillaryButtons, FAVORITE_CHECKBOXES.showAuxillaryButtons.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);
  };

  FAVORITE_CHECKBOXES.sortAscending.onchange = () => {
    setPreference(FAVORITE_PREFERENCES.sortAscending, FAVORITE_CHECKBOXES.sortAscending.checked);
    favoritesLoader.onSortingParametersChanged();
  };

  FAVORITE_INPUTS.sortingMethod.onchange = () => {
    setPreference(FAVORITE_PREFERENCES.sortingMethod, FAVORITE_INPUTS.sortingMethod.value);
    favoritesLoader.onSortingParametersChanged();
  };

  FAVORITE_INPUTS.allowedRatings.onchange = () => {
    changeAllowedRatings();
  };

  window.addEventListener("wheel", (event) => {
    if (!event.shiftKey) {
      return;
    }
    const delta = (event.wheelDelta ? event.wheelDelta : -event.deltaY);
    const columnAddend = delta > 0 ? -1 : 1;

    if (columnWheelResizeCaptionCooldown.ready) {
      forceHideCaptions(true);
    }
    changeColumnCount(parseInt(FAVORITE_INPUTS.columnCount.value) + columnAddend);
  }, {
    passive: true
  });
  columnWheelResizeCaptionCooldown.onDebounceEnd = () => {
    forceHideCaptions(false);
  };
  columnWheelResizeCaptionCooldown.onCooldownEnd = () => {
    if (!columnWheelResizeCaptionCooldown.debouncing) {
      forceHideCaptions(false);
    }
  };

  window.addEventListener("readyToSearch", () => {
    setMainButtonInteractability(true);
  }, {
    once: true
  });

  document.addEventListener("keydown", (event) => {
    if (event.key.toLowerCase() !== "r" || event.repeat || isTypeableInput(event.target)) {
      return;
    }
    FAVORITE_CHECKBOXES.showAuxillaryButtons.click();
  }, {
    passive: true
  });
}

function configureAuxillaryButtonOptionVisibility() {
  FAVORITE_CHECKBOXES.showAuxillaryButtons.parentElement.parentElement.style.display = "block";
}

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

function toggleAuxillaryButtons() {
  const value = FAVORITE_CHECKBOXES.showAuxillaryButtons.checked;

  toggleAuxillaryButtonVisibility(value);
  hideThumbHoverOutlines(value);
  forceHideCaptions(value);

  if (!value) {
    dispatchEvent(new Event("captionOverrideEnd"));
  }
}

/**
 * @param {Boolean} hideOutlines
 */
function hideThumbHoverOutlines(hideOutlines) {
  const style = hideOutlines ? STYLES.thumbHoverOutlineDisabled : STYLES.thumbHoverOutline;

  injectStyleHTML(style, "thumb-hover-outlines");
}

/**
 * @param {Boolean} value
 */
function toggleAuxillaryButtonVisibility(value) {
  const visibility = value ? "visible" : "hidden";

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

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

  const helpLinksContainer = document.getElementById("help-links-container");

  if (helpLinksContainer !== null) {
    helpLinksContainer.innerHTML = "<a href=\"https://github.com/bruh3396/favorites-search-gallery#controls\" target=\"_blank\">Help</a>";
  }

}

function configureDesktopUI() {
  if (onMobileDevice()) {
    return;
  }
  injectStyleHTML(`
    .checkbox {

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

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

    #sort-ascending {
      width: 20px;
      height: 20px;
    }
  `);
}

function setupWhatsNewDropdown() {
  if (onMobileDevice()) {
    return;
  }
  const whatsNew = document.getElementById("whats-new-link");

  if (whatsNew === null) {
    return;
  }
  whatsNew.onclick = () => {
    if (whatsNew.classList.contains("persistent")) {
      whatsNew.classList.remove("persistent");
      whatsNew.classList.add("hidden");
    } else {
      whatsNew.classList.add("persistent");
    }
    return false;
  };

  whatsNew.onblur = () => {
    whatsNew.classList.remove("persistent");
    whatsNew.classList.add("hidden");
  };

  whatsNew.onmouseenter = () => {
    whatsNew.classList.remove("hidden");
  };

  whatsNew.onmouseleave = () => {
    whatsNew.classList.add("hidden");
  };
}

/**
 * @returns {Number}
 */
function loadAllowedRatings() {
  return parseInt(getPreference("allowedRatings", 7));
}

function changeAllowedRatings() {
  let allowedRatings = 0;

  if (FAVORITE_CHECKBOXES.explicitRating.checked) {
    allowedRatings += 4;
  }

  if (FAVORITE_CHECKBOXES.questionableRating.checked) {
    allowedRatings += 2;
  }

  if (FAVORITE_CHECKBOXES.safeRating.checked) {
    allowedRatings += 1;
  }

  setPreference(FAVORITE_PREFERENCES.allowedRatings, allowedRatings);
  favoritesLoader.onAllowedRatingsChanged(allowedRatings);
  preventUserFromUncheckingAllRatings(allowedRatings);
}

/**
 * @param {Number} allowedRatings
 */
function preventUserFromUncheckingAllRatings(allowedRatings) {
  if (allowedRatings === 4) {
    FAVORITE_CHECKBOXES.explicitRating.nextElementSibling.style.pointerEvents = "none";
  } else if (allowedRatings === 2) {
    FAVORITE_CHECKBOXES.questionableRating.nextElementSibling.style.pointerEvents = "none";
  } else if (allowedRatings === 1) {
    FAVORITE_CHECKBOXES.safeRating.nextElementSibling.style.pointerEvents = "none";
  } else {
    FAVORITE_CHECKBOXES.explicitRating.nextElementSibling.removeAttribute("style");
    FAVORITE_CHECKBOXES.questionableRating.nextElementSibling.removeAttribute("style");
    FAVORITE_CHECKBOXES.safeRating.nextElementSibling.removeAttribute("style");
  }
}

function setMainButtonInteractability(value) {
  const container = document.getElementById("left-favorites-panel-top-row");

  if (container === null) {
    return;
  }
  const mainButtons = Array.from(container.children).filter(child => child.tagName.toLowerCase() === "button" && child.textContent !== "Reset");

  for (const button of mainButtons) {
    button.disabled = !value;
  }
}

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

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

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

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

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

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

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

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

if (onFavoritesPage()) {
  initializeFavoritesPage();
}


// gallery.js

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

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

  #original-content-container {
    >canvas,img {
      float: left;
      overflow: hidden;
      pointer-events: none;
      position: fixed;
      height: 100vh;
      margin: 0;
      top: 50%;
      left: 50%;
      transform: translate(-50%, -50%);
    }
  }

  #original-video-container {
    video {
      display: none;
      position:fixed;
      z-index:9998;
      pointer-events:none;
    }
  }

  #low-resolution-canvas {
    z-index: 9996;
  }

  #main-canvas {
    z-index: 9997;
  }

  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 {
        width: 100%;
        position: absolute;
        top: 0;
        left: 0;
        pointer-events: none;
        z-index: 1;
      }
    }
  }

  .fullscreen-icon {
    position: fixed;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    z-index: 10000;
    pointer-events: none;
    width: 30%;
  }
</style>
`;/* eslint-disable no-useless-escape */

const galleryDebugHTML = `
  .thumb,
  .thumb-node {
    &.debug-selected {
      outline: 3px solid #0075FF !important;
    }

    &.loaded {

      div {
        outline: 2px solid transparent;
        animation: outlineGlow 1s forwards;
      }

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

  #main-canvas, #low-resolution-canvas {
    opacity: 0.25;
  }

  #original-video-container {
    video {
      opacity: 0.15;
    }
  }

  `;

class Gallery {
  static clickTypes = {
    left: 0,
    middle: 1
  };
  static directions = {
    d: "d",
    a: "a",
    right: "ArrowRight",
    left: "ArrowLeft"
  };
  static preferences = {
    showOnHover: "showImagesWhenHovering",
    backgroundOpacity: "galleryBackgroundOpacity",
    resolution: "galleryResolution",
    enlargeOnClick: "enlargeOnClick",
    autoplay: "autoplay",
    videoVolume: "videoVolume",
    videoMuted: "videoMuted"
  };
  static localStorageKeys = {
    imageExtensions: "imageExtensions"
  };
  static webWorkers = {
    renderer:
      `
/* eslint-disable max-classes-per-file */
/* eslint-disable prefer-template */
/**
 * @param {Number} milliseconds
 * @returns {Promise}
 */
function sleep(milliseconds) {
  return new Promise(resolve => setTimeout(resolve, milliseconds));
}

/**
 * @param {Number} pixelCount
 * @returns {Number}
 */
function estimateMegabyteSize(pixelCount) {
  const rgb = 3;
  const bytes = rgb * pixelCount;
  const numberOfBytesInMegabyte = 1048576;
  return bytes / numberOfBytesInMegabyte;
}

class RenderRequest {
  /**
   * @type {String}
  */
  id;
  /**
   * @type {String}
  */
  imageURL;
  /**
   * @type {String}
  */
  extension;
  /**
   * @type {String}
  */
  thumbURL;
  /**
   * @type {String}
  */
  fetchDelay;
  /**
   * @type {Number}
  */
  pixelCount;
  /**
   * @type {OffscreenCanvas}
  */
  canvas;
  /**
   * @type {Number}
  */
  resolutionFraction;
  /**
   * @type {AbortController}
  */
  abortController;
  /**
   * @type {Boolean}
  */
  alreadyStarted;

  /**
  * @param {{id: String, imageURL: String, extension: String, thumbURL: String, fetchDelay: String, pixelCount: Number, canvas: OffscreenCanvas, resolutionFraction: Number}} request
  */
  constructor(request) {
    this.id = request.id;
    this.imageURL = request.imageURL;
    this.extension = request.extension;
    this.thumbURL = request.thumbURL;
    this.fetchDelay = request.fetchDelay;
    this.pixelCount = request.pixelCount;
    this.canvas = request.canvas;
    this.resolutionFraction = request.resolutionFraction;
    this.abortController = new AbortController();
    this.alreadyStarted = false;
  }
}

class BatchRenderRequest {
  static settings = {
    megabyteMemoryLimit: 1000,
    batchSizeMinimum: 10
  };

  /**
   * @type {String}
  */
  id;
  /**
   * @type {String}
  */
  requestType;
  /**
   * @type {RenderRequest[]}
  */
  renderRequests;
  /**
   * @type {RenderRequest[]}
  */
  allRenderRequests;

  get renderRequestIds() {
    return new Set(this.renderRequests.map(request => request.id));
  }

  /**
   * @param {{
   *  id: String,
   *  requestType: String,
   *  renderRequests: {id: String, imageURL: String, extension: String, thumbURL: String, fetchDelay: String, pixelCount: Number, canvas: OffscreenCanvas, resolutionFraction: Number}[]
   * }} batchRequest
   */
  constructor(batchRequest) {
    this.id = batchRequest.id;
    this.requestType = batchRequest.requestType;
    this.renderRequests = batchRequest.renderRequests.map(r => new RenderRequest(r));
    this.allRenderRequests = this.renderRequests;
    this.truncateRenderRequestsExceedingMemoryLimit();
  }

  truncateRenderRequestsExceedingMemoryLimit() {
    const truncatedRequest = [];
    let currentMegabyteSize = 0;

    for (const request of this.renderRequests) {
      if (currentMegabyteSize < BatchRenderRequest.settings.megabyteMemoryLimit || truncatedRequest.length < BatchRenderRequest.settings.batchSizeMinimum) {
        truncatedRequest.push(request);
        currentMegabyteSize += estimateMegabyteSize(request.pixelCount);
      } else {
        postMessage({
          action: "renderDeleted",
          id: request.id
        });
      }
    }
    this.renderRequests = truncatedRequest;
  }

}

class ImageFetcher {
  /**
   * @type {Set.<String>}
  */
  static idsToFetchFromPostPages = new Set();
  static get postPageFetchDelay() {
    return ImageFetcher.idsToFetchFromPostPages.size * 250;
  }
  /**
   * @param {RenderRequest} request
   */
  static async setOriginalImageURLAndExtension(request) {
    if (request.extension !== null && request.extension !== undefined) {
      request.imageURL = request.imageURL.replace("jpg", request.extension);
    } else {
      // eslint-disable-next-line require-atomic-updates
      request.imageURL = await ImageFetcher.getOriginalImageURL(request.id);
      request.extension = ImageFetcher.getExtensionFromImageURL(request.imageURL);
    }
  }

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

  /**
   * @param {String} id
   * @returns {String}
   */
  static async getOriginalImageURLFromPostPage(id) {
    const postPageURL = "https://rule34.xxx/index.php?page=post&s=view&id=" + id;

    ImageFetcher.idsToFetchFromPostPages.add(id);
    await sleep(ImageFetcher.postPageFetchDelay);
    return fetch(postPageURL)
      .then((response) => {
        if (response.ok) {
          return response.text();
        }
        throw new Error(response.status + ": " + postPageURL);
      })
      .then((html) => {
        ImageFetcher.idsToFetchFromPostPages.delete(id);
        return (/itemprop="image" content="(.*)"/g).exec(html)[1].replace("us.rule34", "rule34");
      }).catch((error) => {
        if (!error.message.includes("503")) {
          console.error({
            error,
            url: postPageURL
          });
          return "https://rule34.xxx/images/r34chibi.png";
        }
        return ImageFetcher.getOriginalImageURLFromPostPage(id);
      });
  }

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

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

  /**
   * @param {RenderRequest} request
   * @returns {Promise}
  */
  static fetchImage(request) {
    return fetch(request.imageURL, {
      signal: request.abortController.signal
    });
  }

  /**
   * @param {RenderRequest} request
   * @returns {Blob}
  */
  static async fetchImageBlob(request) {
    const response = await ImageFetcher.fetchImage(request);
    return response.blob();
  }

  /**
   * @param {String} id
   * @returns {String}
   */
  static async findImageExtensionFromId(id) {
    const imageURL = await ImageFetcher.getOriginalImageURL(id);
    const extension = ImageFetcher.getExtensionFromImageURL(imageURL);

    postMessage({
      action: "extensionFound",
      id,
      extension

    });
  }
}

class ThumbUpscaler {
  static settings = {
    maxCanvasHeight: 16000
  };
  /**
   * @type {Map.<String, OffscreenCanvas>}
   */
  canvases = new Map();
  /**
   * @type {Number}
  */
  screenWidth;
  /**
   * @type {Boolean}
  */
  onSearchPage;

  /**
   * @param {Number} screenWidth
   * @param {Boolean} onSearchPage
   */
  constructor(screenWidth, onSearchPage) {
    this.screenWidth = screenWidth;
    this.onSearchPage = onSearchPage;
  }

  /**
   * @param {{id: String, imageURL: String, canvas: OffscreenCanvas, resolutionFraction: Number}[]} message
   */
  async upscaleMultipleAnimatedCanvases(message) {
    const requests = message.map(r => new RenderRequest(r));

    requests.forEach((request) => {
      this.collectCanvas(request);
    });

    for (const request of requests) {
      ImageFetcher.fetchImage(request)
        .then((response) => {
          return response.blob();
        })
        .then((blob) => {
          createImageBitmap(blob)
            .then((imageBitmap) => {
              this.upscaleCanvas(request, imageBitmap);
            });
        });
      await sleep(50);
    }
  }

  /**
   * @param {RenderRequest} request
   * @param {ImageBitmap} imageBitmap
   */
  upscaleCanvas(request, imageBitmap) {
    if (this.onSearchPage || imageBitmap === undefined || !this.canvases.has(request.id)) {
      return;
    }
    this.setCanvasDimensions(request, imageBitmap);
    this.drawCanvas(request.id, imageBitmap);
  }

  /**
   * @param {RenderRequest} request
   * @param {ImageBitmap} imageBitmap
   */
  setCanvasDimensions(request, imageBitmap) {
    const canvas = this.canvases.get(request.id);
    let width = this.screenWidth / request.resolutionFraction;
    let height = (width / imageBitmap.width) * imageBitmap.height;

    if (width > imageBitmap.width) {
      width = imageBitmap.width;
      height = imageBitmap.height;
    }

    if (height > ThumbUpscaler.settings.maxCanvasHeight) {
      width *= (ThumbUpscaler.settings.maxCanvasHeight / height);
      height = ThumbUpscaler.settings.maxCanvasHeight;
    }
    canvas.width = width;
    canvas.height = height;
  }

  /**
   * @param {String} id
   * @param {ImageBitmap} imageBitmap
   */
  drawCanvas(id, imageBitmap) {
    const canvas = this.canvases.get(id);
    const context = canvas.getContext("2d");

    context.clearRect(0, 0, canvas.width, canvas.height);
    context.drawImage(
      imageBitmap, 0, 0, imageBitmap.width, imageBitmap.height,
      0, 0, canvas.width, canvas.height
    );
  }

  deleteAllCanvases() {
    for (const [id, canvas] of this.canvases.entries()) {
      this.deleteCanvas(id, canvas);
    }
    this.canvases.clear();
  }

  /**
   * @param {String} id
   * @param {OffscreenCanvas} canvas
   */
  deleteCanvas(id, canvas) {
    const context = canvas.getContext("2d");

    context.clearRect(0, 0, canvas.width, canvas.height);
    canvas.width = 0;
    canvas.height = 0;
    canvas = null;
    this.canvases.set(id, canvas);
    this.canvases.delete(id);
  }

  /**
   * @param {RenderRequest} request
   */
  collectCanvas(request) {
    if (request.canvas === undefined) {
      return;
    }

    if (!this.canvases.has(request.id)) {
      this.canvases.set(request.id, request.canvas);
    }
  }

  /**
   * @param {BatchRenderRequest} batchRequest
   */
  collectCanvases(batchRequest) {
    batchRequest.allRenderRequests.forEach((request) => {
      this.collectCanvas(request);
    });
  }
}

class ImageRenderer {
  /**
  * @type {OffscreenCanvas}
  */
  canvas;
  /**
   * @type {CanvasRenderingContext2D}
  */
  context;
  /**
   * @type {ThumbUpscaler}
  */
  thumbUpscaler;
  /**
   * @type {RenderRequest}
  */
  renderRequest;
  /**
   * @type {BatchRenderRequest}
  */
  batchRenderRequest;
  /**
   * @type {Map.<String, RenderRequest>}
  */
  incompleteRenderRequests;
  /**
   * @type {Map.<String, {completed: Boolean, imageBitmap: ImageBitmap, request: RenderRequest}>}
   */
  renders;
  /**
   * @type {String}
  */
  lastRequestedDrawId;
  /**
   * @type {String}
  */
  currentlyDrawnId;
  /**
   * @type {Boolean}
  */
  onMobileDevice;
  /**
   * @type {Boolean}
  */
  onSearchPage;
  /**
   * @type {Boolean}
   */
  usingLandscapeOrientation;

  get hasRenderRequest() {
    return this.renderRequest !== undefined &&
      this.renderRequest !== null;
  }

  get hasBatchRenderRequest() {
    return this.batchRenderRequest !== undefined &&
      this.batchRenderRequest !== null;
  }

  /**
   *
   * @param {{canvas: OffscreenCanvas, screenWidth: Number, onMobileDevice: Boolean, onSearchPage: Boolean }} message
   */
  constructor(message) {
    this.canvas = message.canvas;
    this.context = this.canvas.getContext("2d");
    this.thumbUpscaler = new ThumbUpscaler(message.screenWidth, message.onSearchPage);
    this.renders = new Map();
    this.incompleteRenderRequests = new Map();
    this.lastRequestedDrawId = "";
    this.currentlyDrawnId = "";
    this.onMobileDevice = message.onMobileDevice;
    this.onSearchPage = message.onSearchPage;
    this.usingLandscapeOrientation = true;
  }

  /**
   * @param {BatchRenderRequest} batchRenderRequest
   */
  async renderMultipleImages(batchRenderRequest) {
    const batchRequestId = batchRenderRequest.id;

    batchRenderRequest.renderRequests = batchRenderRequest.renderRequests
      .filter(request => !this.renderIsFinished(request.id));

    for (const request of batchRenderRequest.renderRequests) {
      if (request.alreadyStarted || this.renders.has(request.id)) {
        continue;
      }
      this.renders.set(request.id, {
        completed: false,
        imageBitmap: undefined,
        request
      });
    }

    for (const request of batchRenderRequest.renderRequests) {
      if (this.isApartOfOutdatedBatchRequest(batchRequestId)) {
        continue;
      }

      if (request.alreadyStarted) {
        continue;
      }
      this.renderImage(request, batchRequestId);
      await sleep(request.fetchDelay);
    }
  }

  /**
   * @param {RenderRequest} request
   * @param {*} batchRequestId
   */
  async renderImage(request, batchRequestId) {
    this.incompleteRenderRequests.set(request.id, request);
    await ImageFetcher.setOriginalImageURLAndExtension(request);
    let blob;

    if (this.isApartOfOutdatedBatchRequest(batchRequestId)) {
      return;
    }

    try {
      blob = await ImageFetcher.fetchImageBlob(request);
    } catch (error) {
      if (error.name === "AbortError") {
        this.deleteRender(request.id);
      } else {
        console.error({
          error,
          request
        });
      }
      return;
    }

    if (this.isApartOfOutdatedBatchRequest(batchRequestId)) {
      return;
    }

    const imageBitmap = await createImageBitmap(blob);

    if (this.isApartOfOutdatedBatchRequest(batchRequestId)) {
      return;
    }
    this.renders.set(request.id, {
      completed: true,
      imageBitmap,
      request
    });
    this.incompleteRenderRequests.delete(request.id);
    this.thumbUpscaler.upscaleCanvas(request, imageBitmap);
    postMessage({
      action: "renderCompleted",
      extension: request.extension,
      id: request.id
    });

    if (this.lastRequestedDrawId === request.id) {
      this.drawCanvas(request.id);
    }
  }

  /**
   * @param {String} id
   * @returns {Boolean}
   */
  renderIsFinished(id) {
    const render = this.renders.get(id);
    return render !== undefined && render.completed;
  }

  /**
   * @param {String} id
   * @returns {Boolean}
   */
  isApartOfOutdatedBatchRequest(id) {
    if (id === undefined || id === null) {
      return false;
    }

    if (!this.hasBatchRenderRequest) {
      return true;
    }
    return this.batchRenderRequest.renderRequestIds.has(id);
  }

  /**
   * @param {String} id
   */
  drawCanvas(id) {
    const render = this.renders.get(id);

    if (render === undefined || render.imageBitmap === undefined) {
      this.clearCanvas();
      return;
    }

    if (this.currentlyDrawnId === id) {
      return;
    }

    if (render.completed) {
      this.currentlyDrawnCanvasId = id;
    }
    const ratio = Math.min(this.canvas.width / render.imageBitmap.width, this.canvas.height / render.imageBitmap.height);
    const centerShiftX = (this.canvas.width - (render.imageBitmap.width * ratio)) / 2;
    const centerShiftY = (this.canvas.height - (render.imageBitmap.height * ratio)) / 2;

    this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
    this.context.drawImage(
      render.imageBitmap, 0, 0, render.imageBitmap.width, render.imageBitmap.height,
      centerShiftX, centerShiftY, render.imageBitmap.width * ratio, render.imageBitmap.height * ratio
    );
  }

  /**
   * @param {Boolean} usingLandscapeOrientation
   */
  changeCanvasOrientation(usingLandscapeOrientation) {
    if (usingLandscapeOrientation !== this.usingLandscapeOrientation) {
      this.swapCanvasOrientation();
    }
  }

  swapCanvasOrientation() {
    const temp = this.canvas.width;

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

  clearCanvas() {
    this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
  }

  deleteAllRenders() {
    this.thumbUpscaler.deleteAllCanvases();
    this.abortAllFetchRequests();

    for (const id of this.renders.keys()) {
      this.deleteRender(id, true);
    }
    this.batchRenderRequest = undefined;
    this.renderRequest = undefined;
    this.renders.clear();
  }

  /**
   * @param {BatchRenderRequest} newBatchRenderRequest
   */
  deleteRendersNotInNewRequest(newBatchRenderRequest) {
    const idsToRender = newBatchRenderRequest.renderRequestIds;

    for (const id of this.renders.keys()) {
      if (!idsToRender.has(id)) {
        this.deleteRender(id);
      }
    }
  }

  /**
   * @param {String} id
   * @param {Boolean} initiatedByMainThread
   */
  deleteRender(id, initiatedByMainThread = false) {
    if (!this.renders.has(id)) {
      return;
    }
    const imageBitmap = this.renders.get(id).imageBitmap;

    if (imageBitmap !== null && imageBitmap !== undefined) {
      imageBitmap.close();
    }
    this.renders.set(id, null);
    this.renders.delete(id);

    if (initiatedByMainThread) {
      return;
    }
    postMessage({
      action: "renderDeleted",
      id
    });
  }

  /**
   * @param {BatchRenderRequest} newBatchRenderRequest
   */
  abortOutdatedFetchRequests(newBatchRenderRequest) {
    const newIds = newBatchRenderRequest.renderRequestIds;

    for (const [id, request] of this.incompleteRenderRequests.entries()) {
      if (!newIds.has(id)) {
        request.abortController.abort();
        this.incompleteRenderRequests.delete(id);
      }
    }
  }

  abortAllFetchRequests() {
    for (const request of this.incompleteRenderRequests.values()) {
      request.abortController.abort();
    }
    this.incompleteRenderRequests.clear();
  }

  /**
   * @param {BatchRenderRequest} newBatchRenderRequest
   */
  removeDuplicateRenderRequests(newBatchRenderRequest) {
    if (!this.hasBatchRenderRequest) {
      return;
    }
    const oldIds = this.batchRenderRequest.renderRequestIds;

    newBatchRenderRequest.renderRequests.forEach((request) => {
      request.alreadyStarted = oldIds.has(request.id);
    });
  }

  onmessage(message) {
    let batchRenderRequest;

    switch (message.action) {
      case "render":
        this.renderRequest = new RenderRequest(message);
        this.lastRequestedDrawId = message.id;
        this.thumbUpscaler.collectCanvas(this.renderRequest);
        this.renderImage(this.renderRequest);
        break;

      case "renderMultiple":
        batchRenderRequest = new BatchRenderRequest(message);
        this.thumbUpscaler.collectCanvases(batchRenderRequest);
        this.abortOutdatedFetchRequests(batchRenderRequest);
        this.removeDuplicateRenderRequests(batchRenderRequest);
        this.deleteRendersNotInNewRequest(batchRenderRequest);
        this.batchRenderRequest = batchRenderRequest;
        this.renderMultipleImages(batchRenderRequest);
        break;

      case "deleteAllRenders":
        this.deleteAllRenders();
        break;

      case "drawMainCanvas":
        this.lastRequestedDrawId = message.id;
        this.drawCanvas(message.id);
        break;

      case "clearMainCanvas":
        this.clearCanvas();
        break;

      case "upscaleAnimatedThumbs":
        this.thumbUpscaler.upscaleMultipleAnimatedCanvases(message.upscaleRequests);
        break;

      case "changeCanvasOrientation":
        this.changeCanvasOrientation(message.usingLandscapeOrientation);
        break;

      default:
        break;
    }
  }
}

/**
 * @type {ImageRenderer}
*/
let imageRenderer;

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

  switch (message.action) {
    case "initialize":
      BatchRenderRequest.settings.megabyteMemoryLimit = message.megabyteLimit;
      BatchRenderRequest.settings.batchSizeMinimum = message.minimumImagesToRender;
      imageRenderer = new ImageRenderer(message);
      break;

    case "findExtension":
      ImageFetcher.findImageExtensionFromId(message.id);
      break;

    default:
      imageRenderer.onmessage(message);
      break;
  }
};

`
  };
  static mainCanvasResolutions = {
    search: onMobileDevice() ? "7680x4320" : "3840x2160",
    favorites: "7680x4320"
  };
  static htmlAttributes = {
    thumbIndex: "index"
  };
  static extensionDecodings = {
    0: "jpg",
    1: "png",
    2: "jpeg",
    3: "gif"
  };
  static extensionEncodings = {
    "jpg": 0,
    "png": 1,
    "jpeg": 2,
    "gif": 3
  };
  static swipeControls = {
    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 = {
    maxImagesToRenderInBackground: 50,
    maxImagesToRenderAround: onMobileDevice() ? 2 : 50,
    megabyteLimit: onMobileDevice() ? 0 : 375,
    minImagesToRender: onMobileDevice() ? 3 : 8,
    imageFetchDelay: 250,
    imageFetchDelayWhenExtensionKnown: 25,
    upscaledThumbResolutionFraction: 5,
    upscaledAnimatedThumbResolutionFraction: 5,
    extensionsFoundBeforeSavingCount: 5,
    animatedThumbsToUpscaleRange: 20,
    animatedThumbsToUpscaleDiscrete: 20,
    traversalCooldownTime: 300,
    renderOnPageChangeCooldownTime: 2000,
    autoplayTime: 5000,
    addFavoriteCooldownTime: 250,
    additionalVideoPlayerCount: onMobileDevice() ? 0 : 2,
    renderAroundAggressively: true,
    debugEnabled: false,
    developerMode: false
  };
  static traversalCooldown = new Cooldown(Gallery.settings.traversalCooldownTime);
  static renderOnPageChangeCooldown = new Cooldown(Gallery.settings.renderOnPageChangeCooldownTime, true);
  static autoplayCooldown = new Cooldown(Gallery.settings.autoplayTime);
  static changeFavoriteCooldown = new Cooldown(Gallery.settings.addFavoriteCooldownTime, true);

  /**
   * @returns {Boolean}
   */
  static get disabled() {
    return (onMobileDevice() && onSearchPage()) || getPerformanceProfile() > 0 || onPostPage();
  }

  /**
   * @type {HTMLCanvasElement}
   */
  mainCanvas;
  /**
   * @type {HTMLCanvasElement}
   */
  lowResolutionCanvas;
  /**
   * @type {CanvasRenderingContext2D}
   */
  lowResolutionContext;
  /**
   * @type {HTMLDivElement}
   */
  videoContainer;
  /**
   * @type {HTMLVideoElement[]}
   */
  videoPlayers;
  /**
   * @type {HTMLImageElement}
   */
  gifContainer;
  /**
   * @type {HTMLDivElement}
   */
  background;
  /**
   * @type {HTMLElement}
   */
  thumbUnderCursor;
  /**
   * @type {HTMLElement}
   */
  lastEnteredThumb;
  /**
   * @type {Worker}
   */
  imageRenderer;
  /**
   * @type {Set.<String>}
  */
  startedRenders;
  /**
   * @type {Set.<String>}
  */
  completedRenders;
  /**
   * @type {Map.<String, HTMLCanvasElement>}
  */
  transferredCanvases;
  /**
   * @type {Map.<String, {start: Number, end:Number}>}
  */
  videoClips;
  /**
   * @type {HTMLElement[]}
   */
  visibleThumbs;
  /**
   * @type {Object.<Number, String>}
   */
  imageExtensions;
  /**
   * @type {Number}
   */
  recentlyDiscoveredImageExtensionCount;
  /**
   * @type {Number}
   */
  currentlySelectedThumbIndex;
  /**
   * @type {Number}
   */
  lastSelectedThumbIndexBeforeEnteringGallery;
  /**
   * @type {Number}
  */
  currentBatchRenderRequestId;
  /**
   * @type {Boolean}
   */
  inGallery;
  /**
   * @type {Boolean}
   */
  recentlyExitedGallery;
  /**
   * @type {Boolean}
  */
  leftPage;
  /**
   * @type {Boolean}
  */
  favoritesWereFetched;
  /**
   * @type {Boolean}
   */
  finishedLoading;
  /**
   * @type {Boolean}
   */
  showOriginalContentOnHover;
  /**
   * @type {Boolean}
   */
  enlargeOnClickOnMobile;
  /**
   * @type {Boolean}
  */
  autoplayEnabled;

  constructor() {
    if (Gallery.disabled) {
      return;
    }
    this.initializeFields();
    this.setMainCanvasResolution();
    this.createWebWorkers();
    this.createVideoBackgrounds();
    this.addEventListeners();
    this.loadDiscoveredImageExtensions();
    this.prepareSearchPage();
    this.injectHTML();
    this.updateBackgroundOpacity(getPreference(Gallery.preferences.backgroundOpacity, 1));
    this.loadVideoClips();
    this.setMainCanvasOrientation();
  }

  initializeFields() {
    this.mainCanvas = document.createElement("canvas");
    this.lowResolutionCanvas = document.createElement("canvas");
    this.lowResolutionContext = this.lowResolutionCanvas.getContext("2d");
    this.thumbUnderCursor = null;
    this.lastEnteredThumb = null;
    this.startedRenders = new Set();
    this.completedRenders = new Set();
    this.transferredCanvases = new Map();
    this.videoClips = new Map();
    this.visibleThumbs = [];
    this.imageExtensions = {};
    this.recentlyDiscoveredImageExtensionCount = 0;
    this.currentlySelectedThumbIndex = 0;
    this.lastSelectedThumbIndexBeforeEnteringGallery = 0;
    this.currentBatchRenderRequestId = 0;
    this.inGallery = false;
    this.recentlyExitedGallery = false;
    this.leftPage = false;
    this.favoritesWereFetched = false;
    this.finishedLoading = onSearchPage();
    this.showOriginalContentOnHover = getPreference(Gallery.preferences.showOnHover, true);
    this.enlargeOnClickOnMobile = getPreference(Gallery.preferences.enlargeOnClick, true);
    // this.autoplayEnabled = getPreference(Gallery.preferences.autoplay, false);
    this.autoplayEnabled = false;
    Gallery.renderOnPageChangeCooldown.onDebounceEnd = () => {
      this.renderImagesInTheBackground();
    };
  }

  setMainCanvasResolution() {
    const resolution = onSearchPage() ? Gallery.mainCanvasResolutions.search : Gallery.mainCanvasResolutions.favorites;
    const dimensions = resolution.split("x").map(dimension => parseFloat(dimension));

    this.mainCanvas.width = dimensions[0];
    this.mainCanvas.height = dimensions[1];
  }

  createWebWorkers() {
    const offscreenCanvas = this.mainCanvas.transferControlToOffscreen();

    this.imageRenderer = new Worker(getWorkerURL(Gallery.webWorkers.renderer));
    this.imageRenderer.postMessage({
      action: "initialize",
      canvas: offscreenCanvas,
      onMobileDevice: onMobileDevice(),
      screenWidth: window.screen.width,
      megabyteLimit: Gallery.settings.megabyteLimit,
      minimumImagesToRender: Gallery.settings.minImagesToRender,
      onSearchPage: onSearchPage()
    }, [offscreenCanvas]);
  }

  createVideoBackgrounds() {
    document.createElement("canvas").toBlob((blob) => {
      const videoBackgroundURL = URL.createObjectURL(blob);

      for (const video of this.videoPlayers) {
        video.setAttribute("poster", videoBackgroundURL);
      }
    });
  }

  addEventListeners() {
    this.addGalleryEventListeners();
    this.addFavoritesLoaderEventListeners();
    this.addWebWorkerMessageHandlers();
    this.addMobileEventListeners();
    this.addMemoryManagementEventListeners();
  }

  addGalleryEventListeners() {
    window.addEventListener("load", () => {
      if (onSearchPage()) {
        this.initializeThumbsForHovering.bind(this)();
        this.enumerateVisibleThumbs();
      }
      this.hideCaptionsWhenShowingOriginalContent();
    }, {
      once: true,
      passive: true
    });
    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.clickTypes.left:
          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.enterGallery();
          this.showOriginalContent(thumb);
          break;

        case Gallery.clickTypes.middle:
          event.preventDefault();

          if (thumb !== null || this.inGallery) {
            this.openPostInNewPage();
          } else if (!this.inGallery) {
            this.toggleAllVisibility();
            setPreference(Gallery.preferences.showOnHover, this.showOriginalContentOnHover);
          }
          break;

        default:

          break;
      }
    });
    window.addEventListener("auxclick", (event) => {
      if (event.button === Gallery.clickTypes.middle) {
        event.preventDefault();
      }
    });
    document.addEventListener("wheel", (event) => {
      if (event.shiftKey) {
        return;
      }

      if (this.inGallery) {
        if (event.ctrlKey) {
          return;
        }
        const delta = (event.wheelDelta ? event.wheelDelta : -event.deltaY);
        const direction = delta > 0 ? Gallery.directions.left : Gallery.directions.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) {
        return;
      }

      switch (event.key) {
        case Gallery.directions.a:

        case Gallery.directions.d:

        case Gallery.directions.left:

        case Gallery.directions.right:
          this.traverseGallery(event.key, event.repeat);
          break;

        case "X":

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

        default:
          break;
      }
    }, {
      passive: true
    });
    window.addEventListener("keydown", async(event) => {
      if (!this.inGallery) {
        return;
      }

      switch (event.key) {
        case "F":

        case "f":
          await this.addFavoriteInGallery(event);
          break;

        case "M":

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

        case "B":

        case "b":
          this.toggleBackgroundOpacity();
          break;

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

        default:
          break;
      }
    }, {
      passive: true
    });
  }

  addFavoritesLoaderEventListeners() {
    if (onSearchPage()) {
      return;
    }
    window.addEventListener("favoritesFetched", (event) => {
      this.initializeThumbsForHovering.bind(this)(event.detail);
      this.enumerateVisibleThumbs();
    });
    window.addEventListener("newFavoritesFetchedOnReload", (event) => {
      if (event.detail.empty) {
        return;
      }
      this.initializeThumbsForHovering.bind(this)(event.detail.thumbs);
      this.enumerateVisibleThumbs();
      /**
       * @type {HTMLElement[]}
      */
      const thumbs = event.detail.thumbs.reverse();

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

        this.upscaleAnimatedThumbsAround(thumb);
        this.renderImages(thumbs
          .filter(t => isImage(t))
          .slice(0, 20));
      }
    }, {
      once: true
    });
    window.addEventListener("startedFetchingFavorites", () => {
      this.favoritesWereFetched = true;
      setTimeout(() => {
        const thumb = document.querySelector(".thumb-node");

        this.renderImagesInTheBackground();

        if (thumb !== null && !this.finishedLoading) {
          this.upscaleAnimatedThumbsAround(thumb);
        }
      }, 650);
    }, {
      once: true
    });
    window.addEventListener("favoritesLoaded", (event) => {
      Gallery.renderOnPageChangeCooldown.waitTime = 1000;
      this.finishedLoading = true;
      this.initializeThumbsForHovering.bind(this)();
      this.enumerateVisibleThumbs();
      this.findImageExtensionsInTheBackground(event.detail);

      if (!this.favoritesWereFetched) {
        this.renderImagesInTheBackground();
      }
    }, {
      once: true
    });
    window.addEventListener("changedPage", () => {
      this.clearMainCanvas();
      this.clearVideoSources();
      this.toggleOriginalContentVisibility(false);
      this.deleteAllRenders();

      if (Gallery.settings.debugEnabled) {
        Array.from(getAllThumbs()).forEach((thumb) => {
          thumb.classList.remove("loaded");
          thumb.classList.remove("debug-selected");
        });
      }
      this.initializeThumbsForHovering.bind(this)();
      this.enumerateVisibleThumbs();

      if (Gallery.renderOnPageChangeCooldown.ready) {
        this.renderImagesInTheBackground();
      }
    });
    window.addEventListener("shuffle", () => {
      this.enumerateVisibleThumbs();
      this.deleteAllRenders();
      this.renderImagesInTheBackground();
    });
    window.addEventListener("favoriteMetadataFetched", (event) => {
      this.assignImageExtension(event.detail.id, event.detail.extension);
    });
  }

  addWebWorkerMessageHandlers() {
    this.imageRenderer.onmessage = (message) => {
      message = message.data;

      switch (message.action) {
        case "renderCompleted":
          this.onRenderCompleted(message);
          break;

        case "renderDeleted":
          this.onRenderDeleted(message);
          break;

        case "extensionFound":
          this.assignImageExtension(message.id, message.extension);
          break;

        default:
          break;
      }
    };
  }

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

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

    window.addEventListener("orientationchange", () => {
      if (this.imageRenderer !== null && this.imageRenderer !== undefined) {
        this.setMainCanvasOrientation();
      }
    }, {
      passive: true
    });
  }

  setMainCanvasOrientation() {
    if (!onMobileDevice()) {
      return;
    }
    const usingLandscapeOrientation = window.screen.orientation.angle === 90;

    this.imageRenderer.postMessage({
      action: "changeCanvasOrientation",
      usingLandscapeOrientation
    });

    if (!this.inGallery) {
      return;
    }

    const thumb = this.getSelectedThumb();

    if (thumb === undefined || thumb === null) {
      return;
    }
    this.imageRenderer.postMessage(this.getRenderRequest(thumb));
  }

  addMemoryManagementEventListeners() {
    // if (Gallery.settings.developerMode && onFavoritesPage()) {
    if (onFavoritesPage()) {
      return;
    }
    window.onblur = () => {
      this.leftPage = true;
      this.deleteAllRenders();
      this.clearInactiveVideoSources();
    };
    window.onfocus = () => {
      if (this.leftPage) {
        this.renderImagesInTheBackground();
        this.leftPage = false;
      }
    };
  }

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

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

    for (const thumb of thumbs) {
      removeTitleFromImage(getImageFromThumb(thumb));
      assignContentType(thumb);
      thumb.id = thumb.id.substring(1);
    }

    for (const script of scripts) {
      script.remove();
    }
    await this.findImageExtensionsOnSearchPage();
    this.renderImagesInTheBackground();
  }

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

  injectStyleHTML() {
    injectStyleHTML(galleryHTML);
  }

  injectDebugHTML() {
    if (Gallery.settings.debugEnabled) {
      injectStyleHTML(galleryDebugHTML, "gallery-debug");
    }
  }

  injectOptionsHTML() {
    this.injectShowOnHoverOption();
    // this.injectAutoplayOption();
  }

  injectShowOnHoverOption() {
    let optionId = "show-content-on-hover";
    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(event.target.checked);
    };

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

  injectAutoplayOption() {
    addOptionToFavoritesPage(
      "autoplay",
      "Autoplay",
      "Enable autoplay in gallery.",
      this.autoplayEnabled,
      (event) => {
        this.toggleAutoplay(event.target.checked);
      },
      true
    );
  }

  injectOriginalContentContainerHTML() {
    const originalContentContainerHTML = `
          <div id="original-content-container">
              <div id="original-video-container">
                <video id="video-player-0" width="100%" height="100%" autoplay muted loop controlsList="nofullscreen" active></video>
              </div>
              <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.lowResolutionCanvas, originalContentContainer.firstChild);
    originalContentContainer.insertBefore(this.mainCanvas, originalContentContainer.firstChild);
    this.background = document.getElementById("original-content-background");
    this.videoContainer = document.getElementById("original-video-container");
    this.addAdditionalVideoPlayers();
    this.videoPlayers = Array.from(this.videoContainer.querySelectorAll("video"));
    this.addVideoPlayerEventListeners();
    this.loadVideoVolume();
    this.toggleAutoplay(this.autoplayEnabled);
    this.gifContainer = document.getElementById("original-gif-container");
    this.mainCanvas.id = "main-canvas";
    this.lowResolutionCanvas.id = "low-resolution-canvas";
    this.lowResolutionCanvas.width = this.mainCanvas.width;
    this.lowResolutionCanvas.height = this.mainCanvas.height;
    this.toggleOriginalContentVisibility(false);
  }

  addAdditionalVideoPlayers() {
    const videoPlayerHTML = "<video width=\"100%\" height=\"100%\" autoplay muted loop controlsList=\"nofullscreen\"></video>";

    for (let i = 0; i < Gallery.settings.additionalVideoPlayerCount; i += 1) {
      this.videoContainer.insertAdjacentHTML("beforeend", videoPlayerHTML);
    }
  }

  addVideoPlayerEventListeners() {
    for (const video of this.videoPlayers) {
      video.addEventListener("mousemove", () => {
        if (!video.hasAttribute("controls")) {
          video.setAttribute("controls", "");
        }
      }, {
        passive: true
      });
      video.addEventListener("click", () => {
        if (video.paused) {
          video.play().catch(() => { });
        } else {
          video.pause();
        }
      }, {
        passive: true
      });
      video.addEventListener("volumechange", (event) => {
        if (!event.target.hasAttribute("active")) {
          return;
        }
        setPreference(Gallery.preferences.videoVolume, video.volume);
        setPreference(Gallery.preferences.videoMuted, video.muted);

        for (const v of this.getInactiveVideoPlayers()) {
          v.volume = video.volume;
          v.muted = video.muted;
        }
      }, {
        passive: true
      });
      video.addEventListener("ended", () => {
        this.doAutoplay();
      }, {
        passive: true
      });
    }
  }

  loadVideoVolume() {
    const video = this.getActiveVideoPlayer();

    video.volume = parseFloat(getPreference(Gallery.preferences.videoVolume, 1));
    video.muted = getPreference(Gallery.preferences.videoMuted, true);
  }

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

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

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

  renderImagesInTheBackground() {
    if (onMobileDevice() && !this.enlargeOnClickOnMobile) {
      return;
    }
    const animatedThumbsToUpscale = Array.from(getAllVisibleThumbs())
      .slice(0, Gallery.settings.animatedThumbsToUpscaleDiscrete)
      .filter(thumb => !isImage(thumb));

    this.upscaleAnimatedThumbs(animatedThumbsToUpscale);

    const imageThumbsToRender = this.getVisibleUnrenderedImageThumbs()
      .slice(0, Gallery.settings.maxImagesToRenderInBackground);

    this.renderImages(imageThumbsToRender, "background");
  }

  /**
   * @param {HTMLElement[]} imagesToRender
   * @param {String} requestType
   */
  renderImages(imagesToRender, requestType) {
    const renderRequests = imagesToRender.map(image => this.getRenderRequest(image));
    const canvases = onSearchPage() ? [] : renderRequests
      .filter(request => request.canvas !== undefined)
      .map(request => request.canvas);

    this.imageRenderer.postMessage({
      action: "renderMultiple",
      id: this.currentBatchRenderRequestId,
      renderRequests,
      requestType
    }, canvases);
    this.currentBatchRenderRequestId += 1;

    if (this.currentBatchRenderRequestId >= 1000) {
      this.currentBatchRenderRequestId = 0;
    }
  }

  /**
   * @param {Object} message
   */
  onRenderCompleted(message) {
    this.completedRenders.add(message.id);

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

    if (thumb !== null) {
      if (Gallery.settings.debugEnabled) {
        thumb.classList.add("loaded");
      }

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

  onRenderDeleted(message) {
    const thumb = document.getElementById(message.id);

    if (thumb !== null) {
      if (Gallery.settings.debugEnabled) {
        thumb.classList.remove("loaded");
      }
    }
    this.startedRenders.delete(message.id);
    this.completedRenders.delete(message.id);
  }

  deleteAllRenders() {
    this.startedRenders.clear();
    this.completedRenders.clear();
    this.deleteAllTransferredCanvases();
    this.imageRenderer.postMessage({
      action: "deleteAllRenders"
    });

    if (Gallery.settings.debugEnabled) {
      this.visibleThumbs.forEach((thumb) => {
        thumb.classList.remove("loaded");
      });
    }
  }

  deleteAllTransferredCanvases() {
    if (onSearchPage()) {
      return;
    }

    for (const id of this.transferredCanvases.keys()) {
      this.transferredCanvases.get(id).remove();
      this.transferredCanvases.delete(id);
    }
    this.transferredCanvases.clear();
    setTimeout(() => {
    }, 1000);
  }

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

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

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

    if (canvas === null) {
      canvas = document.createElement("canvas");
      thumb.children[0].appendChild(canvas);
    }
    return canvas;
  }

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

    this.transferredCanvases.set(thumb.id, canvas);
    return canvas.transferControlToOffscreen();
  }

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

  findImageExtensionsOnSearchPage() {
    const searchPageAPIURL = this.getSearchPageAPIURL();
    return fetch(searchPageAPIURL)
      .then((response) => {
        if (response.ok) {
          return response.text();
        }
        return null;
      }).then((html) => {
        if (html === null) {
          console.error(`Failed to fetch: ${searchPageAPIURL}`);
        }
        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 id = post.getAttribute("id");
          const extension = getExtensionFromImageURL(originalImageURL);

          this.assignImageExtension(id, extension);
        }
      });
  }

  /**
   * @param {HTMLElement[]} thumbs
   */
  async findImageExtensionsInTheBackground(thumbs) {
    await sleep(1000);
    const idsWithUnknownExtensions = this.getIdsWithUnknownExtensions(thumbs);

    while (idsWithUnknownExtensions.length > 0) {
      await sleep(3000);

      while (idsWithUnknownExtensions.length > 0 && this.finishedLoading) {
        const id = idsWithUnknownExtensions.pop();

        if (id !== undefined && id !== null && !this.extensionIsKnown(id)) {
          this.imageRenderer.postMessage({
            action: "findExtension",
            id
          });
          await sleep(10);
        }
      }
    }
    Gallery.settings.extensionsFoundBeforeSavingCount = 0;
  }

  /**
   * @param {String} id
   * @param {String} extension
   */
  assignImageExtension(id, extension) {
    if (this.imageExtensions[parseInt(id)] !== undefined) {
      return;
    }
    this.setImageExtension(id, extension);
    this.recentlyDiscoveredImageExtensionCount += 1;

    if (this.recentlyDiscoveredImageExtensionCount >= Gallery.settings.extensionsFoundBeforeSavingCount) {
      this.recentlyDiscoveredImageExtensionCount = 0;

      if (!onSearchPage()) {
        this.storeAllImageExtensions();
      }
    }
  }

  storeAllImageExtensions() {
    localStorage.setItem(Gallery.localStorageKeys.imageExtensions, JSON.stringify(this.imageExtensions));
  }

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

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

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

  /**
   * @returns {String}
   */
  getSearchPageAPIURL() {
    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);
    }
  }

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

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

    image.onmouseover = (event) => {
      if (this.inGallery || this.recentlyExitedGallery || enteredOverCaptionTag(event)) {
        return;
      }
      this.thumbUnderCursor = thumb;
      this.lastEnteredThumb = thumb;
      this.showOriginalContent(thumb);
    };
    image.onmouseout = (event) => {
      this.thumbUnderCursor = null;

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

  /**
   *
   * @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() {
    if (!userIsOnTheirOwnFavoritesPage()) {
      return;
    }
    const selectedThumb = this.getSelectedThumb();

    if (selectedThumb === null) {
      return;
    }
    const removeFavoriteButton = getRemoveFavoriteButtonFromThumb(selectedThumb);

    if (removeFavoriteButton === null) {
      return;
    }
    const showRemoveFavoriteButtons = document.getElementById("show-remove-favorite-buttons");

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

    if (!Gallery.changeFavoriteCooldown.ready) {
      return;
    }

    if (!showRemoveFavoriteButtons.checked) {
      showFullscreenIcon(ICONS.warning, 1000);
      setTimeout(() => {
        alert("The \"Remove Buttons\" option must be checked to use this hotkey");
      }, 20);
      return;
    }
    showFullscreenIcon(ICONS.heartMinus);
    removeFavoriteButton.click();
  }

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

    this.lastSelectedThumbIndexBeforeEnteringGallery = 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.startAutoplay(selectedThumb);
  }

  exitGallery() {
    if (Gallery.settings.debugEnabled) {
      getAllVisibleThumbs().forEach(thumb => thumb.classList.remove("debug-selected"));
    }
    this.toggleVideoControls(false);
    this.background.style.pointerEvents = "none";
    const thumbIndex = this.getIndexOfThumbUnderCursor();

    if (thumbIndex !== this.lastSelectedThumbIndexBeforeEnteringGallery) {
      this.hideOriginalContent();

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

  /**
   * @param {String} direction
   * @param {Boolean} keyIsHeldDown
   */
  traverseGallery(direction, keyIsHeldDown) {
    if (Gallery.settings.debugEnabled) {
      this.getSelectedThumb().classList.remove("debug-selected");
    }

    if (keyIsHeldDown && !Gallery.traversalCooldown.ready) {
      return;
    }
    this.setNextSelectedThumbIndex(direction);
    const selectedThumb = this.getSelectedThumb();

    // if (this.autoplayEnabled) {
    //   if (isVideo(selectedThumb)) {
    //     Gallery.autoplayCooldown.stop();
    //   } else {
    //     Gallery.autoplayCooldown.restart();
    //   }
    // }
    this.clearOriginalContentSources();
    this.stopAllVideos();

    if (Gallery.settings.debugEnabled) {
      selectedThumb.classList.add("debug-selected");
    }
    this.upscaleAnimatedThumbsAround(selectedThumb);
    this.renderImagesAround(selectedThumb);
    this.preloadInactiveVideoPlayers(selectedThumb);

    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.toggleOriginalVideoContainer(false);
      this.showOriginalGIF(selectedThumb);
    } else {
      this.toggleCursorVisibility(false);
      this.toggleVideoControls(false);
      this.toggleOriginalVideoContainer(false);
      this.showOriginalImage(selectedThumb);
    }
  }

  /**
   * @param {String} direction
   */
  setNextSelectedThumbIndex(direction) {
    if (direction === Gallery.directions.left || direction === Gallery.directions.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);

    if (this.thumbUnderCursor !== null) {
      this.toggleBackgroundVisibility();
      this.toggleScrollbarVisibility();
    }
    dispatchEvent(new CustomEvent("showOriginalContent", {
      detail: this.showOriginalContentOnHover
    }));
    setPreference(Gallery.preferences.showOnHover, this.showOriginalContentOnHover);

    const showOnHoverCheckbox = document.getElementById("show-content-on-hover-checkbox");

    if (showOnHoverCheckbox !== null) {
      showOnHoverCheckbox.checked = this.showOriginalContentOnHover;
    }
  }

  hideOriginalContent() {
    this.toggleBackgroundVisibility(false);
    this.toggleScrollbarVisibility(true);
    this.toggleCursorVisibility(true);
    this.clearOriginalContentSources();
    this.stopAllVideos();
    this.clearMainCanvas();
    this.toggleOriginalVideoContainer(false);
    this.toggleOriginalGIF(false);
  }

  clearOriginalContentSources() {
    this.mainCanvas.style.visibility = "hidden";
    this.lowResolutionCanvas.style.visibility = "hidden";
    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.htmlAttributes.thumbIndex));

    this.upscaleAnimatedThumbsAroundDiscrete(thumb);

    if (!this.inGallery && Gallery.settings.renderAroundAggressively) {
      this.renderImagesAround(thumb);
    }

    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.toggleMainCanvas(false);
    this.videoContainer.style.display = "block";
    this.playOriginalVideo(thumb);

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

  /**
   * @param {HTMLElement} initialThumb
   */
  preloadInactiveVideoPlayers(initialThumb) {
    if (!this.inGallery || Gallery.settings.additionalVideoPlayerCount < 1) {
      return;
    }
    this.setActiveVideoPlayer(initialThumb);
    const inactiveVideoPlayers = this.getInactiveVideoPlayers();
    const videoThumbsAroundInitialThumb = this.getAdjacentVisibleThumbsLooped(initialThumb, inactiveVideoPlayers.length, (t) => {
      return isVideo(t) && t.id !== initialThumb.id;
    });
    const loadedVideoSources = new Set(inactiveVideoPlayers
      .map(video => video.src)
      .filter(src => src !== ""));
    const videoSourcesAroundInitialThumb = new Set(videoThumbsAroundInitialThumb.map(thumb => this.getVideoSource(thumb)));
    const videoThumbsNotLoaded = videoThumbsAroundInitialThumb.filter(thumb => !loadedVideoSources.has(this.getVideoSource(thumb)));
    const freeInactiveVideoPlayers = inactiveVideoPlayers.filter(video => !videoSourcesAroundInitialThumb.has(video.src));

    for (let i = 0; i < freeInactiveVideoPlayers.length && i < videoThumbsNotLoaded.length; i += 1) {
      this.setVideoSource(freeInactiveVideoPlayers[i], videoThumbsNotLoaded[i]);
    }
    this.stopAllVideos();
  }

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

  /**
   * @param {HTMLVideoElement} video
   * @param {HTMLElement} thumb
   */
  setVideoSource(video, thumb) {
    if (this.videoPlayerHasSource(video, thumb)) {
      return;
    }
    this.createVideoClip(video, thumb);
    video.src = this.getVideoSource(thumb);
  }

  /**
   * @param {HTMLVideoElement} video
   * @param {HTMLElement} thumb
   */
  createVideoClip(video, thumb) {
    const clip = this.videoClips.get(thumb.id);

    if (clip === undefined) {
      video.ontimeupdate = null;
      return;
    }
    video.ontimeupdate = () => {
      if (video.currentTime < clip.start || video.currentTime > clip.end) {
        video.removeAttribute("controls");
        video.currentTime = clip.start;
      }
    };
  }

  clearVideoSources() {
    for (const video of this.videoPlayers) {
      video.src = "";
    }
  }

  clearInactiveVideoSources() {
    const videoPlayers = this.inGallery ? this.getInactiveVideoPlayers() : this.videoPlayers;

    for (const video of videoPlayers) {
      video.src = "";
    }
  }

  /**
   * @param {HTMLVideoElement} video
   * @returns {String | null}
   */
  getSourceIdFromVideo(video) {
    const regex = /\.mp4\?(\d+)/;
    const match = regex.exec(video.src);

    if (match === null) {
      return null;
    }
    return match[1];
  }
  /**
   * @param {HTMLElement} thumb
   */
  playOriginalVideo(thumb) {
    // this.setActiveVideoPlayer(thumb);
    // this.preloadInactiveVideoPlayers(thumb);
    this.stopAllVideos();
    const video = this.getActiveVideoPlayer();

    this.setVideoSource(video, thumb);
    video.style.display = "block";
    video.play().catch(() => { });
    this.toggleVideoControls(true);
  }

  stopAllVideos() {
    for (const video of this.videoPlayers) {
      this.stopVideo(video);
    }
  }

  stopAllInactiveVideos() {
    for (const video of this.getInactiveVideoPlayers()) {
      this.stopVideo(video);
    }
  }

  /**
   * @param {HTMLVideoElement} video
   */
  stopVideo(video) {
    video.style.display = "none";
    video.pause();
    video.removeAttribute("controls");
    // video.currentTime = 0;
  }

  /**
   * @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.isCompletelyRendered(thumb)) {
      this.clearLowResolutionCanvas();
      this.drawMainCanvas(thumb);
    } else if (this.renderHasStarted(thumb)) {
      this.drawLowResolutionCanvas(thumb);
      this.clearMainCanvas();
      this.drawMainCanvas(thumb);
    } else {
      this.renderOriginalImage(thumb);

      if (!this.inGallery && !Gallery.settings.renderAroundAggressively) {
        this.renderImagesAround(thumb);
      }
    }
    this.toggleOriginalContentVisibility(this.showOriginalContentOnHover);
  }

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

    if (onMobileDevice() && !this.enlargeOnClickOnMobile) {
      return;
    }
    const amountToRender = Gallery.settings.maxImagesToRenderAround;
    const imageThumbsToRender = this.getAdjacentVisibleThumbsLooped(initialThumb, amountToRender, (thumb) => {
      return isImage(thumb);
    });

    if (!this.renderHasStarted(initialThumb)) {
      imageThumbsToRender.unshift(initialThumb);
    }
    this.renderImages(imageThumbsToRender, "adjacent");
  }

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

    while (currentThumb !== null && adjacentVisibleThumbs.length < limit) {
      if (traverseForward) {
        nextThumb = this.getAdjacentVisibleThumb(nextThumb, true);
      } else {
        previousThumb = this.getAdjacentVisibleThumb(previousThumb, false);
      }
      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} initialThumb
   * @param {Number} limit
   * @param {Function} additionalQualifier
   * @returns {HTMLElement[]}
   */
  getAdjacentVisibleThumbsLooped(initialThumb, limit, additionalQualifier) {
    const adjacentVisibleThumbs = [];
    const discoveredIds = new Set();
    let currentThumb = initialThumb;
    let previousThumb = initialThumb;
    let nextThumb = initialThumb;
    let traverseForward = true;

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

      if (currentThumb === undefined || discoveredIds.has(currentThumb.id)) {
        break;
      }
      discoveredIds.add(currentThumb.id);

      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 (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}
   */
  getAdjacentVisibleThumbLooped(thumb, forward) {
    let adjacentThumb = this.getAdjacentThumb(thumb, forward);

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

    if (adjacentThumb === null) {
      adjacentThumb = forward ? this.visibleThumbs[0] : this.visibleThumbs[this.visibleThumbs.length - 1];
    }
    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}
   */
  renderHasStarted(thumb) {
    return this.startedRenders.has(thumb.id);
  }

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

  /**
   * @param {HTMLElement} thumb
   * @returns {Boolean}
   */
  canvasIsTransferrable(thumb) {
    return !onMobileDevice() && !onSearchPage() && !this.transferredCanvases.has(thumb.id);
  }

  /**
   * @param {HTMLElement} thumb
   * @returns {{
   *  action: String,
   *  imageURL: String,
   *  id: String,
   *  extension: String,
   *  fetchDelay: Number,
   *  thumbURL: String,
   *  pixelCount: Number,
   *  canvas: OffscreenCanvas
   *  resolutionFraction: Number
   *  windowDimensions: {width: Number, height:Number}
   * }}
   */
  getRenderRequest(thumb) {
    const request = {
      action: "render",
      imageURL: getOriginalImageURLFromThumb(thumb),
      id: thumb.id,
      extension: this.getImageExtension(thumb.id),
      fetchDelay: this.getBaseImageFetchDelay(thumb.id),
      thumbURL: getImageFromThumb(thumb).src.replace("us.rule", "rule"),
      pixelCount: this.getPixelCount(thumb),
      resolutionFraction: Gallery.settings.upscaledThumbResolutionFraction
    };

    this.startedRenders.add(thumb.id);

    if (this.canvasIsTransferrable(thumb)) {
      request.canvas = this.getOffscreenCanvasFromThumb(thumb);
    }

    if (onMobileDevice()) {
      request.windowDimensions = {
        width: window.innerWidth,
        height: window.innerHeight
      };
    }
    return request;
  }

  /**
   * @param {HTMLElement} thumb
   * @returns {Number}
   */
  getPixelCount(thumb) {
    if (onSearchPage()) {
      return 0;
    }
    const defaultPixelCount = 2073600;
    const pixelCount = ThumbNode.getPixelCount(thumb.id);
    return pixelCount === 0 ? defaultPixelCount : pixelCount;
  }

  /**
   * @param {HTMLElement} thumb
   */
  renderOriginalImage(thumb) {
    if (this.canvasIsTransferrable(thumb)) {
      const request = this.getRenderRequest(thumb);

      this.imageRenderer.postMessage(request, [request.canvas]);
    } else if (!onSearchPage()) {
      this.imageRenderer.postMessage(this.getRenderRequest(thumb));
    }
  }

  /**
   * @param {HTMLElement} thumb
   */
  drawMainCanvas(thumb) {
    this.imageRenderer.postMessage({
      action: "drawMainCanvas",
      id: thumb.id
    });
  }

  clearMainCanvas() {
    this.imageRenderer.postMessage({
      action: "clearMainCanvas"
    });
  }

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

    if (!value) {
      this.toggleOriginalVideoContainer(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
   */
  toggleBackgroundOpacity(value) {
    if (value !== undefined) {
      if (value) {
        this.updateBackgroundOpacity(1);
      } else {
        this.updateBackgroundOpacity(0);
      }
      return;
    }
    const opacity = parseFloat(this.background.style.opacity);

    if (opacity < 1) {
      this.updateBackgroundOpacity(1);
    } else {
      this.updateBackgroundOpacity(0);
    }
  }

  /**
   * @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) {
    const video = this.getActiveVideoPlayer();

    if (value === undefined) {
      video.style.pointerEvents = video.style.pointerEvents === "auto" ? "none" : "auto";

      if (video.hasAttribute("controls")) {
        video.removeAttribute("controls");
      }
      return;
    }
    video.style.pointerEvents = value ? "auto" : "none";

    if (onMobileDevice()) {
      video.controls = value ? "controls" : false;
    } else if (!value) {
      video.removeAttribute("controls");
    }
  }

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

  /**
   * @param {Boolean} value
   */
  toggleOriginalVideoContainer(value) {
    if (value !== undefined) {
      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 {HTMLElement} thumb
   */
  setActiveVideoPlayer(thumb) {
    for (const video of this.videoPlayers) {
      video.removeAttribute("active");
    }

    for (const video of this.videoPlayers) {
      if (this.videoPlayerHasSource(video, thumb)) {
        video.setAttribute("active", "");
        return;
      }
    }
    this.videoPlayers[0].setAttribute("active", "");
  }
  /**
   * @returns {HTMLVideoElement}
   */
  getActiveVideoPlayer() {
    return this.videoPlayers.find(video => video.hasAttribute("active")) || this.videoPlayers[0];
  }

  /**
   * @param {HTMLVideoElement} video
   * @param {HTMLElement} thumb
   * @returns {Boolean}
   */
  videoPlayerHasSource(video, thumb) {
    return video.src === this.getVideoSource(thumb);
  }

  /**
   * @returns {HTMLVideoElement[]}
   */
  getInactiveVideoPlayers() {
    return this.videoPlayers.filter(video => !video.hasAttribute("active"));
  }
  /**
   * @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";
    }
  }

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

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

  /**
   * @param {HTMLElement[]} animatedThumbs
   */
  upscaleAnimatedThumbs(animatedThumbs) {
    if (onMobileDevice()) {
      return;
    }
    const upscaleRequests = [];

    for (const thumb of animatedThumbs) {
      if (!this.canvasIsTransferrable(thumb)) {
        continue;
      }
      let imageURL = getOriginalImageURL(getImageFromThumb(thumb).src);

      if (isGif(thumb)) {
        imageURL = imageURL.replace("jpg", "gif");
      }
      upscaleRequests.push({
        id: thumb.id,
        imageURL,
        canvas: this.getOffscreenCanvasFromThumb(thumb),
        resolutionFraction: Gallery.settings.upscaledAnimatedThumbResolutionFraction
      });
    }

    this.imageRenderer.postMessage({
      action: "upscaleAnimatedThumbs",
      upscaleRequests
    }, upscaleRequests.map(request => request.canvas));
  }

  /**
   * @param {String} id
   * @returns {Number}
   */
  getBaseImageFetchDelay(id) {
    if (this.extensionIsKnown(id)) {
      return Gallery.settings.imageFetchDelayWhenExtensionKnown;
    }
    return Gallery.settings.imageFetchDelay;
  }

  /**
   * @param {HTMLElement} thumb
   */
  upscaleAnimatedThumbsAround(thumb) {
    if (!onFavoritesPage() || onMobileDevice()) {
      return;
    }
    const animatedThumbsToUpscale = this.getAdjacentVisibleThumbs(thumb, Gallery.settings.animatedThumbsToUpscaleRange, (t) => {
      return !isImage(t) && !this.transferredCanvases.has(t.id);
    });

    this.upscaleAnimatedThumbs(animatedThumbsToUpscale);
  }

  /**
   * @param {HTMLElement} thumb
   */
  upscaleAnimatedThumbsAroundDiscrete(thumb) {
    if (!onFavoritesPage() || onMobileDevice()) {
      return;
    }
    const animatedThumbsToUpscale = this.getAdjacentVisibleThumbs(thumb, Gallery.settings.animatedThumbsToUpscaleDiscrete, (_) => {
      return true;
    }).filter(t => !isImage(t) && !this.transferredCanvases.has(t.id));

    this.upscaleAnimatedThumbs(animatedThumbsToUpscale);
  }

  /**
   * @param {HTMLElement[]} thumbs
   * @returns {String[]}
   */
  getIdsWithUnknownExtensions(thumbs) {
    return thumbs
      .filter(thumb => isImage(thumb) && !this.extensionIsKnown(thumb.id))
      .map(thumb => thumb.id);
  }

  /**
   * @param {String} id
   */
  drawLowResolutionCanvas(thumb) {
    if (onMobileDevice()) {
      return;
    }
    const image = getImageFromThumb(thumb);

    if (!imageIsLoaded(image)) {
      return;
    }
    const ratio = Math.min(this.lowResolutionCanvas.width / image.naturalWidth, this.lowResolutionCanvas.height / image.naturalHeight);
    const centerShiftX = (this.lowResolutionCanvas.width - (image.naturalWidth * ratio)) / 2;
    const centerShiftY = (this.lowResolutionCanvas.height - (image.naturalHeight * ratio)) / 2;

    this.clearLowResolutionCanvas();
    this.lowResolutionContext.drawImage(
      image, 0, 0, image.naturalWidth, image.naturalHeight,
      centerShiftX, centerShiftY, image.naturalWidth * ratio, image.naturalHeight * ratio
    );
  }

  clearLowResolutionCanvas() {
    if (onMobileDevice()) {
      return;
    }
    this.lowResolutionContext.clearRect(0, 0, this.lowResolutionCanvas.width, this.lowResolutionCanvas.height);
  }

  /**
   * @param {Boolean} value
   */
  toggleAutoplay(value) {
    // setPreference(Gallery.preferences.autoplay, value);
    // this.autoplayEnabled = value;

    // if (value) {
    //   this.videoContainer.removeAttribute("loop");
    // } else {
    //   this.videoContainer.setAttribute("loop", "");
    // }
  }

  /**
   * @param {HTMLElement} selectedThumb
   */
  startAutoplay(selectedThumb) {
    if (!this.autoplayEnabled) {
      return;
    }
    Gallery.autoplayCooldown.onCooldownEnd = () => {
      this.doAutoplay();
    };

    if (isImage(selectedThumb)) {
      Gallery.autoplayCooldown.start();
    }
  }

  stopAutoplay() {
    Gallery.autoplayCooldown.onCooldownEnd = () => { };
    Gallery.autoplayCooldown.stop();
  }

  doAutoplay() {
    if (!this.autoplayEnabled || !this.inGallery) {
      return;
    }
    this.traverseGallery(Gallery.directions.right, false);
  }

  loadVideoClips() {
  }

  /**
   * @param {KeyboardEvent} event
   */
  async addFavoriteInGallery(event) {
    if (!this.inGallery || event.repeat || !Gallery.changeFavoriteCooldown.ready) {
      return;
    }
    const selectedThumb = this.getSelectedThumb();

    if (selectedThumb === undefined || selectedThumb === null) {
      showFullscreenIcon(ICONS.error);
      return;
    }
    const addedFavoriteStatus = await addFavorite(selectedThumb.id);
    let svg = ICONS.error;

    switch (addedFavoriteStatus) {
      case ADDED_FAVORITE_STATUS.alreadyAdded:
        svg = ICONS.heartCheck;
        break;

      case ADDED_FAVORITE_STATUS.success:
        svg = ICONS.heartPlus;
        dispatchEvent(new CustomEvent("favoriteAddedOrDeleted", {
          detail: selectedThumb.id
        }));
        break;

      default:
        break;
    }
    showFullscreenIcon(svg);
  }
}

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 {Boolean}
  */
  static get disabled() {
    return onMobileDevice() || getPerformanceProfile() > 1 || onPostPage();
  }

  /**
   * @type {HTMLDivElement}
   */
  tooltip;
  /**
   * @type {String}
   */
  defaultTransition;
  /**
   * @type {Boolean}
   */
  visible;
  /**
   * @type {Object.<String,String>}
   */
  searchTagColorCodes;
  /**
   * @type {HTMLTextAreaElement}
   */
  searchBox;
  /**
   * @type {String}
   */
  previousSearch;
  /**
   * @type {HTMLImageElement}
  */
  currentImage;

  constructor() {
    if (Tooltip.disabled) {
      return;
    }
    this.visible = getPreference("showTooltip", true);
    document.body.insertAdjacentHTML("afterbegin", tooltipHTML);
    this.tooltip = document.getElementById("tooltip");
    this.defaultTransition = this.tooltip.style.transition;
    this.searchTagColorCodes = {};
    this.currentImage = null;
    this.setTheme();
    this.addEventListeners();
    this.addFavoritesOptions();
    this.assignColorsToMatchedTags();
  }

  addEventListeners() {
    this.addAllPageEventListeners();
    this.addSearchPageEventListeners();
    this.addFavoritesPageEventListeners();
  }

  addAllPageEventListeners() {
    document.addEventListener("keydown", (event) => {
      if (event.key.toLowerCase() !== "t" || event.repeat || isTypeableInput(event.target)) {
        return;
      }

      if (onFavoritesPage()) {
        const showTooltipsCheckbox = document.getElementById("show-tooltips-checkbox");

        if (showTooltipsCheckbox !== null) {
          showTooltipsCheckbox.click();

          if (this.currentImage !== null) {
            if (this.visible) {
              this.show(this.currentImage);
            } else {
              this.hide();
            }
          }
        }
      } else if (onSearchPage()) {
        this.toggleVisibility();

        if (this.currentImage !== null) {
          this.hide();
        }
      }
    }, {
      passive: true
    });
  }

  addSearchPageEventListeners() {
    if (!onSearchPage()) {
      return;
    }
    window.addEventListener("load", () => {
      this.addEventListenersToThumbs.bind(this)();
    }, {
      once: true,
      passive: true
    });
  }

  addFavoritesPageEventListeners() {
    if (!onFavoritesPage()) {
      return;
    }
    window.addEventListener("favoritesFetched", (event) => {
      this.addEventListenersToThumbs.bind(this)(event.detail);
    });
    window.addEventListener("favoritesLoaded", () => {
      this.addEventListenersToThumbs.bind(this)();
    }, {
      once: true
    });
    window.addEventListener("changedPage", () => {
      this.currentImage = null;
      this.addEventListenersToThumbs.bind(this)();
    });
    window.addEventListener("newFavoritesFetchedOnReload", (event) => {
      if (!event.detail.empty) {
        this.addEventListenersToThumbs.bind(this)(event.detail.thumbs);
      }
    }, {
      once: true
    });
  }

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

  assignColorsToMatchedTags() {
    if (onSearchPage()) {
      this.assignColorsToMatchedTagsOnSearchPage();
    } 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;
        }
        this.currentImage = image;

        if (this.visible) {
          this.show(image);
        }
      };
      image.onmouseleave = (event) => {
        if (!enteredOverCaptionTag(event)) {
          this.currentImage = null;
          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) {
    this.setText(this.getTagsFromImageWithIdRemoved(image));
    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
   * @returns {String}
   */
  getTagsFromImageWithIdRemoved(image) {
    const thumb = getThumbFromImage(image);
    let tags = getTagsFromThumb(thumb);

    if (this.searchTagColorCodes[thumb.id] === undefined) {
      tags = removeExtraWhiteSpace(tags.replace(thumb.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.searchTagColorCodes = {};
    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.searchTagColorCodes[tag] === undefined) {
      this.searchTagColorCodes[tag] = color;
    }
  }

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

    for (const searchTag of Object.keys(this.searchTagColorCodes)) {
      if (tagsMatchWildcardSearchTag(searchTag, [tag])) {
        return this.searchTagColorCodes[searchTag];
      }
    }
    return undefined;
  }

  addFavoritesOptions() {
    addOptionToFavoritesPage(
      "show-tooltips",
      " Tooltips",
      "Show tags when hovering over a thumbnail and see which ones were matched by a search",
      this.visible, (event) => {
        this.toggleVisibility(event.target.checked);
      },
      true
    );
  }

  /**
   * @param {Boolean} value
   */
  toggleVisibility(value) {
    if (value === undefined) {
      value = !this.visible;
    }
    setPreference("showTooltip", value);
    this.visible = value;
  }

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

  assignColorsToMatchedTagsOnSearchPage() {
    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">
      <ul id="saved-search-list"></ul>
    </div>
  </div>
</div>
`;

class SavedSearches {
  static preferences = {
    textareaWidth: "savedSearchesTextAreaWidth",
    textareaHeight: "savedSearchesTextAreaHeight",
    savedSearches: "savedSearches",
    visibility: "savedSearchVisibility",
    tutorial: "savedSearchesTutorial"
  };
  static localStorageKeys = {
    savedSearches: "savedSearches"
  };
  /**
   * @type {Boolean}
  */
  static get disabled() {
    return !onFavoritesPage() || onMobileDevice();
  }
  /**
   * @type {HTMLTextAreaElement}
   */
  textarea;
  /**
   * @type {HTMLElement}
   */
  savedSearchesList;
  /**
   * @type {HTMLButtonElement}
   */
  stopEditingButton;
  /**
   * @type {HTMLButtonElement}
   */
  saveButton;
  /**
   * @type {HTMLButtonElement}
   */
  importButton;
  /**
   * @type {HTMLButtonElement}
   */
  exportButton;
  /**
   * @type {HTMLButtonElement}
   */
  saveSearchResultsButton;

  constructor() {
    if (SavedSearches.disabled) {
      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, false);
    const savedSearchesContainer = document.getElementById("right-favorites-panel");

    savedSearchesContainer.insertAdjacentHTML("beforeend", savedSearchesHTML);
    document.getElementById("right-favorites-panel").style.display = showSavedSearches ? "block" : "none";
    const options = addOptionToFavoritesPage(
      "show-saved-searches",
      "Saved Searches",
      "Toggle saved searches",
      showSavedSearches,
      (e) => {
        savedSearchesContainer.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;
      }
    }, {
      passive: true
    });
    this.exportButton.onclick = () => {
      this.exportSavedSearches();
    };
    this.importButton.onclick = () => {
      this.importSavedSearches();
    };
    this.saveSearchResultsButton.onclick = () => {
      this.saveSearchResultsAsCustomSearch();
    };
  }

  /**
   * @param {String} newSavedSearch
   * @param {Boolean} updateLocalStorage
   */
  saveSearch(newSavedSearch, updateLocalStorage = true) {
    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 = ICONS.edit;
    removeButton.innerHTML = ICONS.delete;
    moveToTopButton.innerHTML = 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.storeSavedSearches();
    };
    this.stopEditingButton.onclick = () => {
      this.stopEditingSavedSearches(newListItem);
    };
    this.textarea.value = "";

    if (updateLocalStorage) {
      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], false);
    }
  }

  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]);
      }
    }, {
      once: true
    });
  }

  /**
   * @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 searchResultIds = Array.from(ThumbNode.allThumbNodes.values())
      .filter(thumbNode => thumbNode.matchedByMostRecentSearch)
      .map(thumbNode => thumbNode.id);

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

    if (searchResultIds.length > 300) {
      if (!confirm(`Are you sure you want to save ${searchResultIds.length} ids as one search?`)) {
        return;
      }
    }
    const customSearch = `( ${searchResultIds.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;
    padding-top: 4px;
    padding-left: 7px;
    -webkit-touch-callout: none;
    -webkit-user-select: none;
    -khtml-user-select: none;
    -moz-user-select: none;
    -ms-user-select: none;
    user-select: none;

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

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

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

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

  .caption.hide {
    display: none;
  }

  .caption.inactive {
    display: none;
  }

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

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

  .artist-tag {
    color: #f0a0a0;
  }

  .character-tag {
    color: #f0f0a0;
  }

  .copyright-tag {
    color: #EFA1CF;
  }

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

  .caption-wrapper {
    pointer-events: none;
    position: absolute !important;
    overflow: hidden;
    top: -1px;
    left: -1px;
    width: 102%;
    height: 102%;
    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>
 `;
  static findCategoriesOnPageChangeCooldown = new Cooldown(3000, true);
  /**
  * @type {Object.<String, Number>}
  */
  static tagCategoryAssociations;

  /**
   * @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 encodeTagCategory(tagCategory) {
    for (const [encoding, category] of Object.entries(Caption.tagCategoryEncodings)) {
      if (category === tagCategory) {
        return encoding;
      }
    }
    return 0;
  }

  /**
   * @type {Boolean}
   */
  static get disabled() {
    return !onFavoritesPage() || onMobileDevice() || getPerformanceProfile() > 1;
  }

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

  /**
   * @type {HTMLDivElement}
   */
  captionWrapper;
  /**
   * @type {HTMLDivElement}
   */
  caption;
  /**
   * @type {HTMLElement}
   */
  currentThumb;
  /**
   * @type {Set.<String>}
   */
  problematicTags;
  /**
   * @type {String}
   */
  currentThumbId;
  /**
   * @type {AbortController}
  */
  abortController;

  constructor() {
    if (Caption.disabled) {
      return;
    }
    this.initializeFields();
    this.createHTMLElement();
    this.injectHTML();
    this.toggleVisibility(this.getVisibilityPreference());
    this.addEventListeners();
  }

  initializeFields() {
    Caption.tagCategoryAssociations = this.loadSavedTags();
    Caption.findCategoriesOnPageChangeCooldown.onDebounceEnd = () => {
      this.findTagCategoriesOnPageChange();
    };
    this.currentThumb = null;
    this.problematicTags = new Set();
    this.currentThumbId = null;
    this.abortController = new AbortController();
  }

  createHTMLElement() {
    this.captionWrapper = document.createElement("div");
    this.captionWrapper.className = "caption-wrapper";
    this.caption = document.createElement("div");
    this.caption.className = "caption inactive";
    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.toggleVisibility(event.target.checked);
      },
      true
    );
  }

  /**
   * @param {Boolean} value
   */
  toggleVisibility(value) {
    if (value === undefined) {
      value = this.caption.classList.contains("disabled");
    }

    if (value) {
      this.caption.classList.remove("disabled");
    } else if (!this.caption.classList.contains("disabled")) {
      this.caption.classList.add("disabled");
    }
    setPreference(Caption.preferences.visibility, value);
  }

  addEventListeners() {
    this.addAllPageEventListeners();
    this.addSearchPageEventListeners();
    this.addFavoritesPageEventListeners();
  }

  addAllPageEventListeners() {
    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.removeFromThumb(thumb);

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

    document.addEventListener("keydown", (event) => {
      if (event.key.toLowerCase() !== "d" || event.repeat || isTypeableInput(event.target)) {
        return;
      }

      if (onFavoritesPage()) {
        const showCaptionsCheckbox = document.getElementById("show-captions-checkbox");

        if (showCaptionsCheckbox !== null) {
          showCaptionsCheckbox.click();

          if (this.currentThumb !== null && !this.caption.classList.contains("remove")) {
            if (showCaptionsCheckbox.checked) {
              this.attachToThumbHelper(this.currentThumb);
            } else {
              this.removeFromThumbHelper(this.currentThumb);
            }
          }
        }
      } else if (onSearchPage()) {
        // this.toggleVisibility();
      }
    }, {
      passive: true
    });
  }

  addSearchPageEventListeners() {
    if (!onSearchPage()) {
      return;
    }
    window.addEventListener("load", () => {
      this.addEventListenersToThumbs.bind(this)();
    }, {
      once: true,
      passive: true
    });
  }

  addFavoritesPageEventListeners() {
    window.addEventListener("favoritesLoaded", () => {
      this.addEventListenersToThumbs.bind(this)();
      Caption.findCategoriesOnPageChangeCooldown.waitTime = 1000;
    }, {
      once: true
    });
    window.addEventListener("favoritesFetched", () => {
      this.addEventListenersToThumbs.bind(this)();
    });
    window.addEventListener("changedPage", () => {
      this.addEventListenersToThumbs.bind(this)();
      this.abortController.abort("ChangedPage");
      this.abortController = new AbortController();

      if (Caption.findCategoriesOnPageChangeCooldown.ready) {
        this.findTagCategoriesOnPageChange();
      }
    });
    window.addEventListener("originalFavoritesCleared", (event) => {
      const thumbs = event.detail;
      const tagNames = Array.from(thumbs)
        .map(thumb => getImageFromThumb(thumb).title)
        .join(" ")
        .split(" ")
        .filter(tagName => !isNumber(tagName) && Caption.tagCategoryAssociations[tagName] === undefined);

      this.findTagCategories(tagNames, 10, () => {
        this.saveTags();
      });
    }, {
      once: true
    });
    window.addEventListener("newFavoritesFetchedOnReload", (event) => {
      if (!event.detail.empty) {
        this.addEventListenersToThumbs.bind(this)(event.detail.thumbs);
      }
    }, {
      once: true
    });
    window.addEventListener("captionOverrideEnd", () => {
      if (this.currentThumb !== null) {
        this.attachToThumb(this.currentThumb);
      }
    });
  }

  /**
   * @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.currentThumb = thumb;
        this.attachToThumb(thumb);
      };

      imageContainer.onmouseleave = () => {
        this.currentThumb = null;
        this.removeFromThumb(thumb);
      };
    }
  }

  /**
   * @param {HTMLElement} thumb
   */
  attachToThumb(thumb) {
    if (this.hidden || thumb === null) {
      return;
    }
    this.attachToThumbHelper(thumb);
  }

  attachToThumbHelper(thumb) {
    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, event);
    };
    captionIdTag.addEventListener("contextmenu", (event) => {
      event.preventDefault();
      this.tagOnClick(`-${thumb.id}`, event);
    });
    captionIdHeader.insertAdjacentElement("afterend", captionIdTag);
    thumb.children[0].appendChild(this.captionWrapper);
    this.populateTags(thumb);
  }

  /**
   * @param {HTMLElement} thumb
   */
  removeFromThumb(thumb) {
    if (this.hidden) {
      return;
    }

    this.removeFromThumbHelper(thumb);
  }

  /**
   * @param {HTMLElement} thumb
   */
  removeFromThumbHelper(thumb) {
    if (thumb !== null && thumb !== undefined) {
      this.animateRemoval(thumb);
    }
    this.animate(false);
    this.caption.classList.add("inactive");
    this.caption.classList.remove("transition-completed");
  }

  /**
   * @param {HTMLElement} thumb
   */
  animateRemoval(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 columnInput = document.getElementById("column-resize-input");
    const heightCanBeDerivedWithoutRect = this.thumbMetadataExists(thumb) && columnInput !== null;
    let height;

    if (heightCanBeDerivedWithoutRect) {
      height = this.estimateThumbHeightFromMetadata(thumb, columnInput);
    } else {
      height = getImageFromThumb(thumb).getBoundingClientRect().height;
    }
    const captionListRect = this.caption.children[0].getBoundingClientRect();
    const ratio = height / captionListRect.height;
    const scale = ratio > 1 ? Math.sqrt(ratio) : ratio * 0.85;

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

  /**
   * @param {HTMLElement} thumb
   * @returns {Boolean}
   */
  thumbMetadataExists(thumb) {
    if (onSearchPage()) {
      return false;
    }
    const thumbNode = ThumbNode.allThumbNodes.get(thumb.id);

    if (thumbNode === undefined) {
      return false;
    }

    if (thumbNode.metadata === undefined) {
      return false;
    }

    if (thumbNode.metadata.width <= 0 || thumbNode.metadata.width <= 0) {
      return false;
    }
    return true;
  }

  /**
   * @param {HTMLElement} thumb
   * @param {HTMLInputElement} columnInput
   * @returns {Number}
   */
  estimateThumbHeightFromMetadata(thumb, columnInput) {
    const thumbNode = ThumbNode.allThumbNodes.get(thumb.id);
    const gridGap = 16;
    const columnCount = Math.max(1, parseInt(columnInput.value));
    const thumbWidthEstimate = (window.innerWidth - (columnCount * gridGap)) / columnCount;
    const thumbWidthScale = thumbNode.metadata.width / thumbWidthEstimate;
    return thumbNode.metadata.height / thumbWidthScale;
  }

  /**
   * @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, event);
    };
    tag.addEventListener("contextmenu", (event) => {
      event.preventDefault();
      this.tagOnClick(`-${this.replaceSpacesWithUnderscores(tag.textContent)}`, event);
    });
  }

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

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

  /**
   * @param {String} value
   * @param {MouseEvent} mouseEvent
   */
  tagOnClick(value, mouseEvent) {
    if (mouseEvent.ctrlKey) {
      openSearchPage(value);
      return;
    }
    const searchBox = onSearchPage() ? document.getElementsByName("tags")[0] : document.getElementById("favorites-search-box");
    const searchBoxDoesNotIncludeTag = true;

    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.replaceAll(/_/gm, " ");
  }

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

  /**
   * @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 = removeExtraWhiteSpace(getTagsFromThumb(thumb).replace(thumb.id, ""))
      .split(" ");
    const unknownThumbTags = tagNames
      .filter(tag => Caption.tagCategoryAssociations[tag] === undefined && !CUSTOM_TAGS.has(tag));

    this.currentThumbId = thumb.id;

    if (this.allTagsAreProblematic(unknownThumbTags)) {
      this.correctAllProblematicTagsFromThumb(thumb, () => {
        this.addTags(tagNames, thumb);
      });
      return;
    }

    if (unknownThumbTags.length > 0) {
      this.findTagCategories(unknownThumbTags, 3, () => {
        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 = Caption.tagCategoryAssociations[tagName];

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

  /**
   * @param {String[]} tags
   * @returns {Boolean}
   */
  allTagsAreProblematic(tags) {
    for (const tag of tags) {
      if (!this.problematicTags.has(tag)) {
        return false;
      }
    }
    return tags.length > 0;
  }

  /**
   * @param {HTMLElement} thumb
   * @param {Function} onProblematicTagsCorrected
   */
  correctAllProblematicTagsFromThumb(thumb, onProblematicTagsCorrected) {
    fetch(`https://rule34.xxx/index.php?page=post&s=view&id=${thumb.id}`)
      .then((response) => {
        return response.text();
      })
      .then((html) => {
        const tagCategoryMap = this.getTagCategoryMapFromPostPage(html);

        for (const [tagName, tagCategory] of tagCategoryMap.entries()) {
          if (this.problematicTags.has(tagName)) {
            Caption.tagCategoryAssociations[tagName] = Caption.encodeTagCategory(tagCategory);
            this.problematicTags.delete(tagName);
          }
        }
        onProblematicTagsCorrected();
      })
      .catch((error) => {
        console.error(error);
      });
  }

  /**
   * @param {String} html
   * @returns {Map.<String, String>}
   */
  getTagCategoryMapFromPostPage(html) {
    const dom = new DOMParser().parseFromString(html, "text/html");
    return Array.from(dom.querySelectorAll(".tag"))
    .reduce((map, element) => {
        const tagCategory = element.classList[0].replace("tag-type-", "");
        const tagName = this.replaceSpacesWithUnderscores(element.children[1].textContent);

        map.set(tagName, tagCategory);
        return map;
    }, new Map());
  }

  /**
   * @param {String} tag
   */
  setAsProblematic(tag) {
    this.problematicTags.add(tag);
  }

  findTagCategoriesOnPageChange() {
    const tagNames = this.getTagNamesWithUnknownCategories(getAllVisibleThumbs().slice(0, 200));

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

  /**
   * @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) {
      if (isNumber(tagName) && tagName.length > 5) {
        Caption.tagCategoryAssociations[tagName] = 0;
        continue;
      }

      if (tagName.includes("'")) {
        this.setAsProblematic(tagName);
        continue;
      }
      const apiURL = `https://api.rule34.xxx//index.php?page=dapi&s=tag&q=index&name=${encodeURIComponent(tagName)}`;

      try {
        fetch(apiURL, {
          signal: this.abortController.signal
        })
          .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.setAsProblematic(tagName);
              return;
            }
            Caption.tagCategoryAssociations[tagName] = parseInt(encoding);

            if (tagName === lastTagName && onAllCategoriesFound !== undefined) {
              onAllCategoriesFound();
            }
          }).catch(() => {
            if (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 => !isNumber(tagName) && Caption.tagCategoryAssociations[tagName] === undefined);
  }
}

const caption = new Caption();


// 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: hotpink;
    }
  </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" spellcheck="false"></textarea>
    <div id="tag-modifier-ui-modification-buttons">
      <button id="tag-modifier-ui-add" title="Add tags to selected favorites">Add</button>
      <button id="tag-modifier-remove" title="Remove tags from 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 {Boolean}
  */
  static get disabled() {
    return !onFavoritesPage();
  }

  /**
   * @type {{container: HTMLDivElement, checkbox: HTMLInputElement}}
   */
  favoritesOption;
  /**
   * @type { {container: HTMLDivElement,
   * textarea:  HTMLTextAreaElement,
   * statusLabel: HTMLLabelElement,
   * add: HTMLButtonElement,
   * remove: HTMLButtonElement,
   * reset: HTMLButtonElement,
   * selectAll: HTMLButtonElement,
   * unSelectAll: HTMLButtonElement,
   * import: HTMLButtonElement,
   * export: HTMLButtonElement}}
   */
  ui;
  /**
   * @type {ThumbNode[]}
   */
  selectedThumbNodes;
  /**
   * @type {Boolean}
  */
  atLeastOneFavoriteIsSelected;

  constructor() {
    if (TagModifier.disabled) {
      return;
    }
    this.favoritesOption = {};
    this.ui = {};
    this.selectedThumbNodes = [];
    this.atLeastOneFavoriteIsSelected = false;
    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");
    this.ui.import = document.getElementById("tag-modifier-import");
    this.ui.export = document.getElementById("tag-modifier-export");
  }

  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);
    this.ui.import.onclick = this.importTagModifications.bind(this);
    this.ui.export.onclick = this.exportTagModifications.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() {
    if (!this.atLeastOneFavoriteIsSelected) {
      return;
    }

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

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

  /**
   * @param {HTMLElement} thumb
   * @param {Boolean} value
   */
  toggleThumbSelection(thumb, value) {
    this.atLeastOneFavoriteIsSelected = true;

    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.toLowerCase();
    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);
      const idsWithNoTagModifications = [];

      for (const [id, tags] of TagModifier.tagModifications) {
        if (tags === "") {
          idsWithNoTagModifications.push(id);
          objectStore.delete(id);
        } else {
          objectStore.put({
            id,
            tags
          });
        }
      }

      for (const id of idsWithNoTagModifications) {
        TagModifier.tagModifications.delete(id);
      }
      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() {
    if (!confirm("Are you sure you want to delete all tag modifications?")) {
      return;
    }
    CUSTOM_TAGS.clear();
    indexedDB.deleteDatabase("AdditionalTags");
    ThumbNode.allThumbNodes.forEach(thumbNode => {
      thumbNode.resetAdditionalTags();
    });
    dispatchEvent(new Event("modifiedTags"));
    localStorage.removeItem("customTags");
  }

  exportTagModifications() {
    const modifications = JSON.stringify(mapToObject(TagModifier.tagModifications));

    navigator.clipboard.writeText(modifications);
    alert("Copied tag modifications to clipboard");
  }

  importTagModifications() {
    let modifications;

    try {
      const object = JSON.parse(this.ui.textarea.value);

      if (!(typeof object === "object")) {
        throw new TypeError(`Input parsed as ${typeof (object)}, but expected object`);
      }
      modifications = objectToMap(object);
    } catch (error) {
      if (error.name === "SyntaxError" || error.name === "TypeError") {
        alert("Import Unsuccessful. Failed to parse input, JSON object format expected.");
      } else {
        throw error;
      }
      return;
    }
    console.error(modifications);
  }
}

const tagModifier = new TagModifier();


// awesomplete.min.js

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

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

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

            typeof s === "number" ? t[n] = parseInt(r) : !1 === s ? t[n] = r !== null : s instanceof Function ? t[n] = null : t[n] = r, t[n] || t[n] === 0 || (t[n] = n in i ? i[n] : s);
        }
    }

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

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

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

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

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

                    if (s.opened) {

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

                            case 66:
                                break;

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

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

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

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

                    if (e !== this) {
                        for (; e && !(/li/i).test(e.nodeName);) e = e.parentNode;
                        e && t.button === 0 && (t.preventDefault(), s.select(e, t.target));
                    }
                }
            }
        }, i.bind(this.input, this._events.input), i.bind(this.input.form, this._events.form), i.bind(this.ul, this._events.ul), this.input.hasAttribute("list") ? (this.list = `#${this.input.getAttribute("list")}`, this.input.removeAttribute("list")) : this.list = this.input.getAttribute("data-list") || n.list || [], r.all.push(this);
    };
    r.prototype = {
        set list(t) {
            if (Array.isArray(t)) this._list = t;
            else if (typeof t === "string" && t.indexOf(",") > -1) this._list = t.split(/\s*,\s*/);
            else if ((t = i(t)) && t.children) {
                const e = [];

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

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

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

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

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

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

            this.selected && e[this.index].setAttribute("aria-selected", "false"), this.index = t, t > -1 && e.length > 0 && (e[t].setAttribute("aria-selected", "true"), this.status.textContent = e[t].textContent, this.ul.scrollTop = e[t].offsetTop - this.ul.clientHeight + e[t].clientHeight, i.fire(this.input, "awesomplete-highlight", {
                text: this.suggestions[this.index]
            }));
        },
        select(t, e) {
            if (t ? this.index = i.siblingIndex(t) : t = this.ul.children[this.index], t) {
                const n = this.suggestions[this.index];

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

            i.length >= this.minChars && this._list.length > 0 ? (this.index = -1, this.ul.innerHTML = "", this.suggestions = this._list.map((n) => {
                return new t(e.data(n, i));
            }).filter((t) => {
                return e.filter(t, i);
            }), !1 !== this.sort && (this.suggestions = this.suggestions.sort(this.sort)), this.suggestions = this.suggestions.slice(0, this.maxItems), this.suggestions.forEach((t) => {
                e.ul.appendChild(e.item(t, i));
            }), this.ul.children.length === 0 ? this.close({
                reason: "nomatches"
            }) : this.open()) : this.close({
                reason: "nomatches"
            });
        }
    }, r.all = [], r.FILTER_CONTAINS = function (t, e) {
        return RegExp(i.regExpEscape(e.trim()), "i").test(t);
    }, r.FILTER_STARTSWITH = function (t, e) {
        return RegExp(`^${i.regExpEscape(e.trim())}`, "i").test(t);
    }, r.SORT_BYLENGTH = function (t, e) {
        return t.length !== e.length ? t.length - e.length : t < e ? -1 : 1;
    }, r.ITEM = function (t, e) {
        return i.create("li", {
            innerHTML: e.trim() === "" ? t : t.replace(RegExp(i.regExpEscape(e.trim()), "gi"), "<mark>$&</mark>"),
            "aria-selected": "false"
        });
    }, r.REPLACE = function (t) {
        this.input.value = t.value;
    }, r.DATA = function (t) {
        return t;
    }, Object.defineProperty(t.prototype = Object.create(String.prototype), "length", {
        get() {
            return this.label.length;
        }
    }), t.prototype.toString = t.prototype.valueOf = function () {
        return `${this.label}`;
    };
    var o = Array.prototype.slice;
    i.create = function (t, e) {
        const n = document.createElement(t);

        for (const s in e) {
            const r = e[s];

            if (s === "inside") i(r).appendChild(n);
            else if (s === "around") {
                const o = i(r);

                o.parentNode.insertBefore(n, o), n.appendChild(o);
            } else s in n ? n[s] = r : n.setAttribute(s, r);
        }
        return n;
    }, i.bind = function (t, e) {
        if (t) for (const i in e) {
            var n = e[i];
            i.split(/\s+/).forEach((e) => {
                t.addEventListener(e, n);
            });
        }
    }, i.unbind = function (t, e) {
        if (t) for (const i in e) {
            var n = e[i];
            i.split(/\s+/).forEach((e) => {
                t.removeEventListener(e, n);
            });
        }
    }, i.fire = function (t, e, i) {
        const n = document.createEvent("HTMLEvents");

        n.initEvent(e, !0, !0);

        for (const s in i) n[s] = i[s];
        return t.dispatchEvent(n);
    }, i.regExpEscape = function (t) {
        return t.replace(/[-\\^$*+?.()|[\]{}]/g, "\\$&");
    }, i.siblingIndex = function (t) {
        for (var e = 0; t = t.previousElementSibling; e++);
        return e;
    }, typeof Document !== "undefined" && (document.readyState !== "loading" ? s() : document.addEventListener("DOMContentLoaded", s)), r.$ = i, r.$$ = n, typeof self !== "undefined" && (self.Awesomplete_ = r), typeof module === "object" && module.exports && (module.exports = r);
}());

var decodeEntities = (function () {
    // this prevents any overhead from creating the object each time
    const element = document.createElement("div");

    function decodeHTMLEntities(str) {
        if (str && typeof str === "string") {
            // strip script/html tags
            str = str.replace(/<script[^>]*>([\S\s]*?)<\/script>/gmi, "");
            str = str.replace(/<\/?\w(?:[^"'>]|"[^"]*"|'[^']*')*>/gmi, "");
            element.innerHTML = str;
            str = element.textContent;
            element.textContent = "";
        }
        return str;
    }
    return decodeHTMLEntities;
}());


// awesomplete.js

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

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

/**
 * @param {String} tags
 */
async function setCustomTags(tags) {
  for (const tag of removeExtraWhiteSpace(tags).split(" ")) {
    if (tag === "" || CUSTOM_TAGS.has(tag)) {
      continue;
    }
    const isAnOfficialTag = await isOfficialTag(tag);

    if (!isAnOfficialTag) {
      CUSTOM_TAGS.add(tag);
    }
  }
  localStorage.setItem("customTags", JSON.stringify(Array.from(CUSTOM_TAGS)));
}

/**
 * @param {{label: String, value: String, type: String}[]} officialTags
 * @param {String} searchQuery
 * @returns {{label: String, value: String, type: String}[]}
 */
function mergeOfficialTagsWithCustomTags(officialTags, searchQuery) {
  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) && customTag.startsWith(searchQuery)) {
      mergedTags.unshift({
        label: `${customTag} (custom)`,
        value: customTag,
        type: "custom"
      });
    }
  }
  return mergedTags;
}

class AwesompleteWrapper {
  /**
   * @type {Boolean}
  */
  static get disabled() {
    return !onFavoritesPage();
  }
  constructor() {
    if (AwesompleteWrapper.disabled) {
      return;
    }
    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) => {
        insertSuggestion(awesomplete.input, decodeEntities(suggestion.value));
      }
    });

    input.addEventListener("keydown", (event) => {
      switch (event.key) {
        case "Tab":
          if (!awesomplete.isOpened || awesomplete.suggestions.length === 0) {
            return;
          }
          awesomplete.next();
          awesomplete.select();
          event.preventDefault();
          break;

        case "Escape":
          hideAwesomplete(input);
          break;

        default:
          break;
      }
    });

    input.oninput = () => {
      this.populateAwesompleteList(this.getCurrentTag(input), awesomplete);
    };
  }

  /**
   * @param {String} prefix
   * @param {Awesomplete_} awesomplete
   */
  populateAwesompleteList(prefix, awesomplete) {
    if (prefix.trim() === "") {
      return;
    }
    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), prefix);

        awesomplete.list = mergedSuggestions;
      });
  }

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