Rule34 Favorites Search Gallery

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

// ==UserScript==
// @name         Rule34 Favorites Search Gallery
// @namespace    bruh3396
// @version      1.17.7
// @description  Search, View, and Play Rule34 Favorites (Desktop/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==

class Utils {
  static utilitiesHTML = `
<style>
  .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;
  }

  .not-highlightable {
    -webkit-touch-callout: none;
    -webkit-user-select: none;
    -khtml-user-select: none;
    -moz-user-select: none;
    -ms-user-select: none;
    user-select: none;
  }

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

  .number {
    white-space: nowrap;
    position: relative;
    margin-top: 5px;
    border: 1px solid;
    padding: 0;
    border-radius: 20px;
    background-color: white;

    >hold-button,
    button {
      position: relative;
      top: 0;
      left: 0;
      font-size: inherit;
      outline: none;
      background: none;
      cursor: pointer;
      border: none;
      margin: 0px 8px;
      padding: 0;

      &::after {
        content: '';
        position: absolute;
        top: 50%;
        left: 50%;
        transform: translate(-50%, -50%);
        width: 200%;
        height: 100%;
        /* outline: 1px solid greenyellow; */
        /* background-color: hotpink; */
      }

      &:hover {
        >span {
          color: #0075FF;
        }

      }

      >span {
        font-weight: bold;
        font-family: Verdana, Geneva, Tahoma, sans-serif;
        position: relative;
        pointer-events: none;
        border: none;
        outline: none;
        top: 0;
        z-index: 5;
        font-size: 1.2em !important;
      }

      &.number-arrow-up {
        >span {
          transition: left .1s ease;
          left: 0;
        }

        &:hover>span {
          left: 3px;
        }
      }

      &.number-arrow-down {
        >span {
          transition: right .1s ease;
          right: 0;
        }

        &:hover>span {
          right: 3px;
        }
      }
    }

    >input[type="number"] {
      font-size: inherit;
      text-align: center;
      width: 2ch;
      padding: 0;
      margin: 0;
      font-weight: bold;
      padding: 3px;
      background: none;
      border: none;

      &:focus {
        outline: none;
      }
    }

    >input[type="number"]::-webkit-outer-spin-button,
    >input[type="number"]::-webkit-inner-spin-button {
      -webkit-appearance: none;
      appearance: none;
      margin: 0;
    }
  }

  .fullscreen-icon {
    position: fixed;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    z-index: 10010;
    pointer-events: none;
    width: 30vw;
  }

  input[type="checkbox"] {
    accent-color: #0075FF;
  }

  .thumb {
    >a {
      pointer-events: none;

      >img {
        pointer-events: all;
      }
    }
  }
</style>
`;
  static localStorageKeys = {
    imageExtensions: "imageExtensions"
  };
  static settings = {
    extensionsFoundBeforeSavingCount: 100
  };
  static favoritesSearchGalleryContainer = Utils.createFavoritesSearchGalleryContainer();
  static idsToRemoveOnReloadLocalStorageKey = "recentlyRemovedIds";
  static tagBlacklist = Utils.getTagBlacklist();
  static preferencesLocalStorageKey = "preferences";
  static 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
    },
    galleryEnabled: {
      set: false,
      value: undefined
    }
  };
  static icons = {
    delete: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"feather feather-trash\"><polyline points=\"3 6 5 6 21 6\"></polyline><path d=\"M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2\"></path></svg>",
    edit: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"feather feather-edit\"><path d=\"M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7\"></path><path d=\"M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z\"></path></svg>",
    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>",
    play: "<svg id=\"autoplay-play-button\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 -960 960 960\" fill=\"white\"><path d=\"M320-200v-560l440 280-440 280Zm80-280Zm0 134 210-134-210-134v268Z\" /></svg>",
    pause: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 -960 960 960\" fill=\"white\"><path d=\"M520-200v-560h240v560H520Zm-320 0v-560h240v560H200Zm400-80h80v-400h-80v400Zm-320 0h80v-400h-80v400Zm0-400v400-400Zm320 0v400-400Z\"/></svg>",
    changeDirection: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 -960 960 960\" fill=\"white\"><path d=\"M280-160 80-360l200-200 56 57-103 103h287v80H233l103 103-56 57Zm400-240-56-57 103-103H440v-80h287L624-743l56-57 200 200-200 200Z\"/></svg>",
    changeDirectionAlt: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 -960 960 960\" fill=\"#0075FF\"><path d=\"M280-160 80-360l200-200 56 57-103 103h287v80H233l103 103-56 57Zm400-240-56-57 103-103H440v-80h287L624-743l56-57 200 200-200 200Z\"/></svg>",
    tune: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 -960 960 960\" fill=\"white\"><path d=\"M440-120v-240h80v80h320v80H520v80h-80Zm-320-80v-80h240v80H120Zm160-160v-80H120v-80h160v-80h80v240h-80Zm160-80v-80h400v80H440Zm160-160v-240h80v80h160v80H680v80h-80Zm-480-80v-80h400v80H120Z\"/></svg>",
    settings: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 -960 960 960\" fill=\"white\"><path d=\"m370-80-16-128q-13-5-24.5-12T307-235l-119 50L78-375l103-78q-1-7-1-13.5v-27q0-6.5 1-13.5L78-585l110-190 119 50q11-8 23-15t24-12l16-128h220l16 128q13 5 24.5 12t22.5 15l119-50 110 190-103 78q1 7 1 13.5v27q0 6.5-2 13.5l103 78-110 190-118-50q-11 8-23 15t-24 12L590-80H370Zm70-80h79l14-106q31-8 57.5-23.5T639-327l99 41 39-68-86-65q5-14 7-29.5t2-31.5q0-16-2-31.5t-7-29.5l86-65-39-68-99 42q-22-23-48.5-38.5T533-694l-13-106h-79l-14 106q-31 8-57.5 23.5T321-633l-99-41-39 68 86 64q-5 15-7 30t-2 32q0 16 2 31t7 30l-86 65 39 68 99-42q22 23 48.5 38.5T427-266l13 106Zm42-180q58 0 99-41t41-99q0-58-41-99t-99-41q-59 0-99.5 41T342-480q0 58 40.5 99t99.5 41Zm-2-140Z\"/></svg>"
  };
  static defaults = {
    columnCount: 6,
    resultsPerPage: 200
  };
  static addedFavoriteStatuses = {
    error: 0,
    alreadyAdded: 1,
    notLoggedIn: 2,
    success: 3
  };
  static styles = {
    thumbHoverOutline: `
    .favorite,
    .thumb {
      >a,
      >span,
      >div {
        &:hover {
          outline: 3px solid #0075FF !important;
        }
      }
    }`,
    thumbHoverOutlineDisabled: `
    .favorite,
    .thumb {
      >a,
      >span,
      >div:not(:has(img.video)) {
        &:hover {
          outline: none;
        }
      }
    }`
  };
  static typeableInputs = new Set([
    "color",
    "email",
    "number",
    "password",
    "search",
    "tel",
    "text",
    "url",
    "datetime"
  ]);
  static clickCodes = {
    left: 0,
    middle: 1,
    right: 2
  };
  static customTags = Utils.loadCustomTags();
  static favoriteItemClassName = "favorite";
  static imageExtensions = Utils.loadDiscoveredImageExtensions();
  /**
   * @type {Cooldown}
   */
  static imageExtensionAssignmentCooldown;
  static recentlyDiscoveredImageExtensionCount = 0;
  static extensionDecodings = {
    0: "jpg",
    1: "png",
    2: "jpeg",
    3: "gif"
  };
  static extensionEncodings = {
    "jpg": 0,
    "png": 1,
    "jpeg": 2,
    "gif": 3
  };
  /**
   * @type {Function[]}
   */
  static staticInitializers = [];

  /**
   * @type {Boolean}
   */
  static get disabled() {
    if (Utils.onPostPage()) {
      return true;
    }

    if (Utils.onFavoritesPage()) {
      return false;
    }
    const enabledOnSearchPages = Utils.getPreference("enableOnSearchPages", false);
    return !enabledOnSearchPages;
  }

  /**
   * @type {Boolean}
   */
  static get enabled() {
    return !Utils.disabled;
  }

  static initialize() {
    if (Utils.disabled) {
      throw new Error("Favorites Search Gallery disabled");
    }
    Utils.invokeStaticInitializers();
    Utils.removeUnusedScripts();
    Utils.insertCommonStyleHTML();
    Utils.setupCustomWebComponents();
    Utils.toggleFancyImageHovering(true);
    Utils.setTheme();
    Utils.prepareSearchPage();
    Utils.prefetchAdjacentSearchPages();
    Utils.setupOriginalImageLinksOnSearchPage();
    Utils.initializeImageExtensionAssignmentCooldown();
  }

  /**
   * @param {String} key
   * @param {any} value
   */
  static 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}
   */
  static 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
   */
  static setPreference(key, value) {
    const preferences = JSON.parse(localStorage.getItem(Utils.preferencesLocalStorageKey) || "{}");

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

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

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

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

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

  /**
   * @returns {Boolean}
   */
  static userIsOnTheirOwnFavoritesPage() {
    if (!Utils.flags.userIsOnTheirOwnFavoritesPage.set) {
      Utils.flags.userIsOnTheirOwnFavoritesPage.value = Utils.getUserId() === Utils.getFavoritesPageId();
      Utils.flags.userIsOnTheirOwnFavoritesPage.set = true;
    }
    return Utils.flags.userIsOnTheirOwnFavoritesPage.value;
  }

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

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

  /**
   * @param {Boolean} value
   */
  static 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}
   */
  static getRemoveFavoriteButtonFromThumb(thumb) {
    return thumb.querySelector(".remove-favorite-button");
  }

  /**
   * @param {HTMLElement} thumb
   * @returns {String | null}
   */
  static getAddFavoriteButtonFromThumb(thumb) {
    return thumb.querySelector(".add-favorite-button");
  }

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

  /**
   * @param {HTMLImageElement} image
   * @returns {HTMLElement}
   */
  static getThumbFromImage(image) {
    const className = Utils.onSearchPage() ? "thumb" : Utils.favoriteItemClassName;
    return image.closest(`.${className}`);
  }

  /**
   * @param {HTMLElement} thumb
   * @returns {HTMLImageElement}
   */
  static getImageFromThumb(thumb) {
    return thumb.querySelector("img");
  }

  /**
   * @returns {HTMLElement[]}
   */
  static getAllThumbs() {
    const className = Utils.onSearchPage() ? "thumb" : Utils.favoriteItemClassName;
    return Array.from(document.getElementsByClassName(className));
  }

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

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

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

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

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

  /**
   * @param {HTMLElement | Post} thumb
   * @returns {Set.<String>}
   */
  static getTagsFromThumb(thumb) {
    if (Utils.onSearchPage()) {
      const image = Utils.getImageFromThumb(thumb);
      const tags = image.hasAttribute("tags") ? image.getAttribute("tags") : image.title;
      return Utils.convertToTagSet(tags);
    }
    const post = Post.allPosts.get(thumb.id);
    return post === undefined ? new Set() : new Set(post.tagSet);
  }

  /**
   * @param {String} tag
   * @param {Set.<String>} tags
   * @returns
   */
  static includesTag(tag, tags) {
    return tags.has(tag);
  }

  /**
   * @param {HTMLElement | Post} thumb
   * @returns {Boolean}
   */
  static isVideo(thumb) {
    const tags = Utils.getTagsFromThumb(thumb);
    return tags.has("video") || tags.has("mp4");
  }

  /**
   * @param {HTMLElement | Post} thumb
   * @returns {Boolean}
   */
  static isGif(thumb) {
    if (Utils.isVideo(thumb)) {
      return false;
    }
    const tags = Utils.getTagsFromThumb(thumb);
    return tags.has("gif") || tags.has("animated") || tags.has("animated_png") || Utils.hasGifAttribute(thumb);
  }

  /**
   * @param {HTMLElement | Post} thumb
   * @returns {Boolean}
   */
  static hasGifAttribute(thumb) {
    if (thumb instanceof Post) {
      return false;
    }
    return Utils.getImageFromThumb(thumb).hasAttribute("gif");
  }

  /**
   * @param {HTMLElement | Post} thumb
   * @returns {Boolean}
   */
  static isImage(thumb) {
    return !Utils.isVideo(thumb) && !Utils.isGif(thumb);
  }

  /**
   * @param {Number} maximum
   * @returns {Number}
   */
  static getRandomInteger(maximum) {
    return Math.floor(Math.random() * maximum);
  }

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

    while (maxIndex > 0) {
      randomIndex = Utils.getRandomInteger(maxIndex);
      maxIndex -= 1;
      [
        array[maxIndex],
        array[randomIndex]
      ] = [
          array[randomIndex],
          array[maxIndex]
        ];
    }
  }

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

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

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

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

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

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

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

    if (!Utils.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
   */
  static 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
   * @param {String} optionHint
   * @returns {HTMLElement | null}
   */
  static createFavoritesOption(optionId, optionText, optionTitle, optionIsChecked, onOptionChanged, optionIsVisible, optionHint = "") {
    const id = Utils.onMobileDevice() ? "favorite-options" : "dynamic-favorite-options";
    const placeToInsert = document.getElementById(id);
    const checkboxId = `${optionId}-checkbox`;

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

    if (optionIsVisible === undefined || optionIsVisible) {
      optionIsVisible = "block";
    } else {
      optionIsVisible = "none";
    }
    placeToInsert.insertAdjacentHTML("beforeend", `
    <div id="${optionId}" style="display: ${optionIsVisible}">
      <label class="checkbox" title="${optionTitle}">

      <input id="${checkboxId}" type="checkbox"> ${optionText}<span class="option-hint"> ${optionHint}</span></label>
    </div>
  `);
    const newOptionsCheckbox = document.getElementById(checkboxId);

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

  /**
   * @returns {Boolean}
   */
  static onSearchPage() {
    if (!Utils.flags.onSearchPage.set) {
      Utils.flags.onSearchPage.value = location.href.includes("page=post&s=list");
      Utils.flags.onSearchPage.set = true;
    }
    return Utils.flags.onSearchPage.value;
  }

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

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

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

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

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

  static clearIdsToDeleteOnReload() {
    localStorage.removeItem(Utils.idsToRemoveOnReloadLocalStorageKey);
  }

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

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

    if (id !== undefined) {
      id += "-fsg-style";
      const oldStyle = document.getElementById(id);

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

  static getTagDistribution() {
    const images = Utils.getAllThumbs().map(thumb => Utils.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 = Utils.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}}
   */
  static 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]
    }));
  }

  static insertCommonStyleHTML() {
    Utils.insertStyleHTML(Utils.utilitiesHTML, "common");
    Utils.toggleThumbHoverOutlines(false);
    setTimeout(() => {
      if (Utils.onSearchPage()) {
        Utils.removeInlineImgStyles();
      }
      Utils.configureVideoOutlines();
    }, 100);
  }

  /**
   * @param {Boolean} value
   */
  static toggleFancyImageHovering(value) {
    if (Utils.onMobileDevice() || Utils.onSearchPage()) {
      value = false;
    }
    let html = "";

    if (value) {
      html = `
    #favorites-search-gallery-content {
      padding: 40px 40px 30px !important;
      grid-gap: 2.5em !important;
    }

    .favorite,
    .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.05, 1.05);
          z-index: 10;

          img {
            outline: none !important;
          }

          &::after {
            opacity: 1;
          }
        }
      }
    }
    `;
    }
    Utils.insertStyleHTML(html, "fancy-image-hovering");
  }

  static configureVideoOutlines() {
    const size = Utils.onMobileDevice() ? 2 : 3;
    const videoSelector = Utils.onFavoritesPage() ? "&:has(img.video)" : ">img.video";
    const gifSelector = Utils.onFavoritesPage() ? "&:has(img.gif)" : ">img.gif";

    Utils.insertStyleHTML(`
    .favorite, .thumb {

      >a,
      >div {
        ${videoSelector} {
            outline: ${size}px solid blue;
        }

        ${gifSelector} {
          outline: 2px solid hotpink;
        }
      }
    }
    `, "video-gif-borders");
  }

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

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

          Utils.insertStyleHTML(`
          input[type=number] {
            background-color: #303030;
            color: white;
          }

          .number {
            background-color: #303030;

            >hold-button,
            button {
              color: white;
            }
          }

          #favorites-pagination-container {
            >button {
              border: 1px solid white !important;
              color: white !important;
            }
          }
          `, "dark-theme");
        }
      }
    }, 10);
  }

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

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

    if (alreadyPrefetched) {
      return;
    }
    const container = document.createElement("div");

    try {
      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(Utils.createPrefetchLink(sibling.href));
        }
      }
      container.id = "search-page-prefetch";
      document.head.appendChild(container);
    } catch (error) {
      console.error(error);
    }
  }

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

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

  }

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

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

  /**
   * @returns {Boolean}
   */
  static galleryEnabled() {
    if (!Utils.flags.galleryEnabled.set) {
      Utils.flags.galleryEnabled.value = document.getElementById("gallery-container") !== null;
      Utils.flags.galleryEnabled.set = true;
    }
    return Utils.flags.galleryEnabled.value;
  }

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

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

  /**
   * @param {Number} n
   * @param {Number} number
   */
  static roundToNDecimalPlaces(n, number) {
    const x = 10 ** n;
    return Math.round((number + Number.EPSILON) * x) / x;
  }

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

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

  /**
   * @param {String[]} postId
   * @param {Boolean} endingAnimation
   * @param {Boolean} smoothTransition
   */
  static scrollToThumb(postId, endingAnimation, smoothTransition) {
    const element = document.getElementById(postId);
    const elementIsNotAThumb = element === null || (!element.classList.contains("thumb") && !element.classList.contains(Utils.favoriteItemClassName));

    if (elementIsNotAThumb) {
      return;
    }
    const rect = element.getBoundingClientRect();
    const menu = document.getElementById("favorites-search-gallery-menu");
    const favoritesSearchHeight = menu === null ? 0 : menu.getBoundingClientRect().height;

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

    if (!endingAnimation) {
      return;
    }
    const image = Utils.getImageFromThumb(element);

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

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

    Utils.setContentType(image, Utils.getContentType(tags));
  }

  /**
   * @param {HTMLImageElement} image
   * @param {String} type
   */
  static setContentType(image, type) {
    image.classList.remove("image");
    image.classList.remove("gif");
    image.classList.remove("video");
    image.classList.add(type);
  }

  /**
   * @param {String} tags
   * @returns {String}
   */
  static 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";
  }

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

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

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

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

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

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

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

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

  /**
   * @param {String} tagName
   * @returns {Promise.<Boolean>}
   */
  static 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
   */
  static openSearchPage(searchQuery) {
    window.open(`https://rule34.xxx/index.php?page=post&s=list&tags=${encodeURIComponent(searchQuery)}`);
  }

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

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

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

  /**
   * @param {String} id
   * @returns {Promise.<Number>}
   */
  static 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 Utils.addedFavoriteStatuses.error;
      });
  }

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

  /**
   * @param {HTMLInputElement | HTMLTextAreaElement} input
   * @param {String} suggestion
   */
  static 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 = Utils.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
   */
  static hideAwesomplete(input) {
    Utils.getAwesompleteFromInput(input).querySelector("ul").setAttribute("hidden", "");
  }

  /**
   * @param {String} svg
   * @param {Number} duration
   */
  static 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);
    document.body.appendChild(svgOverlay);
    setTimeout(() => {
      svgOverlay.remove();
    }, duration);
  }

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

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

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

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

  /**
   * @param {KeyboardEvent} event
   * @returns {Boolean}
   */
  static isHotkeyEvent(event) {
    return !event.repeat && !Utils.isTypeableInput(event.target);
  }

  /**
   * @param {Set} a
   * @param {Set} b
   * @returns {Set}
   */
  static union(a, b) {
    const c = new Set(a);

    for (const element of b.values()) {
      c.add(element);
    }
    return c;
  }

  /**
   * @param {Set} a
   * @param {Set} b
   * @returns {Set}
   */
  static difference(a, b) {
    const c = new Set(a);

    for (const element of b.values()) {
      c.delete(element);
    }
    return c;
  }

  static removeUnusedScripts() {
    if (!Utils.onFavoritesPage()) {
      return;
    }
    const scripts = Array.from(document.querySelectorAll("script"));

    for (const script of scripts) {
      if ((/(?:fluidplayer|awesomplete)/).test(script.src || "")) {
        script.remove();
      }
    }
  }

  /**
   * @param {String} tagString
   * @returns {Set.<String>}
   */
  static convertToTagSet(tagString) {
    tagString = Utils.removeExtraWhiteSpace(tagString);

    if (tagString === "") {
      return new Set();
    }
    return new Set(tagString.split(" ").sort());
  }

  /**
   * @param {Set.<String>} tagSet
   * @returns {String}
   */
  static convertToTagString(tagSet) {
    if (tagSet.size === 0) {
      return "";
    }
    return Array.from(tagSet).join(" ");
  }

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

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

  static setupCustomWebComponents() {
    Utils.setupCustomNumberWebComponents();
  }

  static async setupCustomNumberWebComponents() {
    await Utils.sleep(400);
    const numberComponents = Array.from(document.querySelectorAll(".number"));

    for (const element of numberComponents) {
      const numberComponent = new NumberComponent(element);
    }
  }

  /**
   * @param {Number} milliseconds
   * @returns {Number}
   */
  static millisecondsToSeconds(milliseconds) {
    return Utils.roundToTwoDecimalPlaces(milliseconds / 1000);
  }

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

  /**
   * @param {String} tags
   */
  static async setCustomTags(tags) {
    for (const tag of Utils.removeExtraWhiteSpace(tags).split(" ")) {
      if (tag === "" || Utils.customTags.has(tag)) {
        continue;
      }
      const isAnOfficialTag = await Utils.isOfficialTag(tag);

      if (!isAnOfficialTag) {
        Utils.customTags.add(tag);
      }
    }
    localStorage.setItem("customTags", JSON.stringify(Array.from(Utils.customTags)));
  }

  /**
   * @returns {String[]}
   */
  static getSavedSearchValues() {
    return Array.from(document.getElementsByClassName("save-search-label"))
      .map(element => element.innerText);
  }

  /**
   * @param {{label: String, value: String, type: String}[]} officialTags
   * @param {String} searchQuery
   * @returns {{label: String, value: String, type: String}[]}
   */
  static addCustomTagsToAutocompleteList(officialTags, searchQuery) {
    const customTags = Array.from(Utils.customTags);
    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;
  }

  /**
   * @param {String} searchTag
   * @param {String} savedSearch
   * @returns {Boolean}
   */
  static savedSearchMatchesSearchTag(searchTag, savedSearch) {
    const sanitizedSavedSearch = Utils.removeExtraWhiteSpace(savedSearch.replace(/[~())]/g, ""));
    const savedSearchTagList = sanitizedSavedSearch.split(" ");

    for (const savedSearchTag of savedSearchTagList) {
      if (savedSearchTag.startsWith(searchTag)) {
        return true;
      }
    }
    return false;
  }

  /**
   * @param {String} tag
   * @returns {String}
   */
  static removeStartingHyphen(tag) {
    return tag.replace(/^-/, "");
  }

  /**
   * @param {String} searchTag
   * @returns {{label: String, value: String, type: String}[]}
   */
  static getSavedSearchesForAutocompleteList(searchTag) {
    const minimumSearchTagLength = 3;

    if (searchTag.length < minimumSearchTagLength) {
      return [];
    }
    const maxMatchedSavedSearches = 5;
    const matchedSavedSearches = [];
    let i = 0;

    for (const savedSearch of Utils.getSavedSearchValues()) {
      if (Utils.savedSearchMatchesSearchTag(searchTag, savedSearch)) {
        matchedSavedSearches.push({
          label: `${savedSearch}`,
          value: `${searchTag}_saved_search ${savedSearch}`,
          type: "saved"
        });
        i += 1;
      }

      if (matchedSavedSearches.length > maxMatchedSavedSearches) {
        break;
      }
    }
    return matchedSavedSearches;
  }

  static removeSavedSearchPrefix(suggestion) {
    return suggestion.replace(/^\S+_saved_search /, "");
  }

  /**
   * @param {Boolean} value
   */
  static toggleThumbHoverOutlines(value) {
    // insertStyleHTML(value ? STYLES.thumbHoverOutlineDisabled : STYLES.thumbHoverOutline, "thumb-hover-outlines");
  }

  /**
   * @param {Number} timestamp
   * @returns {String}
   */
  static convertTimestampToDate(timestamp) {
    const date = new Date(timestamp);
    const day = date.getDate();
    const month = date.getMonth() + 1;
    const year = date.getFullYear();
    return `${year}-${month}-${day}`;
  }

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

  /**
   * @returns {HTMLDivElement}
   */
  static createFavoritesSearchGalleryContainer() {
    const container = document.createElement("div");

    container.id = "favorites-search-gallery";
    document.body.appendChild(container);
    return container;
  }

  /**
   * @param {HTMLElement} element
   * @param {InsertPosition} position
   * @param {String} html
   */
  static insertHTMLAndExtractStyle(element, position, html) {
    const dom = new DOMParser().parseFromString(html, "text/html");
    const styles = Array.from(dom.querySelectorAll("style"));

    for (const style of styles) {
      Utils.insertStyleHTML(style.innerHTML);
      style.remove();
    }
    element.insertAdjacentHTML(position, dom.body.innerHTML);
  }

  /**
   * @param {InsertPosition} position
   * @param {String} html
   */
  static insertFavoritesSearchGalleryHTML(position, html) {
    Utils.insertHTMLAndExtractStyle(Utils.favoritesSearchGalleryContainer, position, html);
  }

  /**
   * @param {String} str
   * @returns {String}
   */
  static removeNonNumericCharacters(str) {
    return str.replaceAll(/\D/g, "");
  }

  /**
   * @param {HTMLElement} thumb
   * @returns {String}
   */
  static getIdFromThumb(thumb) {
    const id = thumb.getAttribute("id");

    if (id !== null) {
      return Utils.removeNonNumericCharacters(id);
    }
    const anchor = thumb.querySelector("a");

    if (anchor !== null && anchor.hasAttribute("id")) {
      return Utils.removeNonNumericCharacters(anchor.id);
    }

    if (anchor !== null && anchor.hasAttribute("href")) {
      const match = (/id=(\d+)$/).exec(anchor.href);

      if (match !== null) {
        return match[1];
      }
    }
    const image = thumb.querySelector("img");
    const match = (/\?(\d+)$/).exec(image.src);
    return match[1];
  }

  static 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(FavoritesDatabaseWrapper.databaseName);
    }
  }

  /**
   * @param {String} id
   * @returns {String}
   */
  static getPostPageURL(id) {
    return `https://rule34.xxx/index.php?page=post&s=view&id=${id}`;
  }

  /**
   * @param {String} id
   */
  static openPostInNewTab(id) {
    window.open(Utils.getPostPageURL(id), "_blank");
  }

  /**
   * @param {Function} initializer
   */
  static addStaticInitializer(initializer) {
    Utils.staticInitializers.push(initializer);
  }

  static invokeStaticInitializers() {
    for (const initializer of Utils.staticInitializers) {
      initializer();
    }
    Utils.staticInitializers = null;
  }

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

  /**
   * @param {Set} a
   * @param {Set} b
   * @returns {Set}
   */
  static symmetricDifference(a, b) {
    return Utils.union(Utils.difference(a, b), Utils.difference(b, a));
  }

  static clearOriginalFavoritesPage() {
    const thumbs = Array.from(document.getElementsByClassName("thumb"));
    let content = document.getElementById("content");

    if (content === null && thumbs.length > 0) {
      content = thumbs[0].closest("body>div");
    }

    if (content !== null) {
      content.remove();
    }
    setTimeout(() => {
      dispatchEvent(new CustomEvent("originalFavoritesCleared", {
        detail: thumbs
      }));
    }, 1000);
  }

  /**
   * @param {String} id
   * @returns {String}
   */
  static getPostAPIURL(id) {
    return `https://api.rule34.xxx//index.php?page=dapi&s=post&q=index&id=${id}`;
  }

  /**
   * @returns {Promise<String>}
   */
  static getImageExtensionFromThumb(thumb) {
    if (Utils.isVideo(thumb)) {
      return "mp4";
    }

    if (Utils.isGif(thumb)) {
      return "gif";
    }

    if (Utils.extensionIsKnown(thumb.id)) {
      return Utils.getImageExtension(thumb.id);
    }
    return Utils.fetchImageExtension(thumb);
  }

  /**
   * @param {HTMLElement} thumb
   * @returns {Promise<String>}
   */
  static fetchImageExtension(thumb) {
    return fetch(Utils.getPostAPIURL(thumb.id))
      .then((response) => {
        return response.text();
      })
      .then((html) => {
        const dom = new DOMParser().parseFromString(html, "text/html");
        const metadata = dom.querySelector("post");
        const extension = Utils.getExtensionFromImageURL(metadata.getAttribute("file_url"));

        Utils.assignImageExtension(thumb.id, extension);
        return extension;
      })
      .catch((error) => {
        console.error(error);
        return "jpg";
      });
  }

  /**
   * @param {HTMLElement} thumb
   * @returns {Promise<String>}
   */
  static async getOriginalImageURLWithExtension(thumb) {
    const extension = await Utils.getImageExtensionFromThumb(thumb);
    return Utils.getOriginalImageURL(thumb.querySelector("img").src).replace(".jpg", `.${extension}`);
  }

  /**
   * @param {HTMLElement} thumb
   */
  static async openOriginalImageInNewTab(thumb) {
    try {
      const imageURL = await Utils.getOriginalImageURLWithExtension(thumb);

      window.open(imageURL);
    } catch (error) {
      console.error(error);
    }
  }

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

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

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

  static findImageExtensionsOnSearchPage() {
    const searchPageAPIURL = Utils.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 tags = post.getAttribute("tags");
          const id = post.getAttribute("id");
          const originalImageURL = post.getAttribute("file_url");
          const tagSet = Utils.convertToTagSet(tags);
          const thumb = document.getElementById(id);

          if (!tagSet.has("video") && originalImageURL.endsWith("mp4") && thumb !== null) {
            const image = Utils.getImageFromThumb(thumb);

            image.setAttribute("tags", `${image.getAttribute("tags")} video`);
            Utils.setContentType(image, "video");
          } else if (!tagSet.has("gif") && originalImageURL.endsWith("gif") && thumb !== null) {
            const image = Utils.getImageFromThumb(thumb);

            image.setAttribute("tags", `${image.getAttribute("tags")} gif`);
            Utils.setContentType(image, "gif");
          }
          const isAnImage = Utils.getContentType(tags) === "image";
          const isBlacklisted = originalImageURL === "https://api-cdn.rule34.xxx/images//";

          if (!isAnImage || isBlacklisted) {
            continue;
          }
          const extension = Utils.getExtensionFromImageURL(originalImageURL);

          Utils.assignImageExtension(id, extension);
        }
      })
      .catch((error) => {
        console.error(error);
      });
  }

  static async setupOriginalImageLinksOnSearchPage() {
    if (!Utils.onSearchPage()) {
      return;
    }

    if (Gallery.disabled) {
      await Utils.findImageExtensionsOnSearchPage();
      Utils.setupOriginalImageLinksOnSearchPageHelper();
    } else {
      window.addEventListener("foundExtensionsOnSearchPage", () => {
        Utils.setupOriginalImageLinksOnSearchPageHelper();
      }, {
        once: true
      });
    }
  }

  static async setupOriginalImageLinksOnSearchPageHelper() {
    try {
      for (const thumb of Utils.getAllThumbs()) {
        await Utils.setupOriginalImageLinkOnSearchPage(thumb);
      }
    } catch (error) {
      console.error(error);
    }
  }

  /**
   * @param {HTMLElement} thumb
   */
  static async setupOriginalImageLinkOnSearchPage(thumb) {
    const anchor = thumb.querySelector("a");
    const imageURL = await Utils.getOriginalImageURLWithExtension(thumb);
    const thumbURL = anchor.href;

    anchor.href = imageURL;
    anchor.onclick = (event) => {
      if (!event.ctrlKey) {
        event.preventDefault();
      }
    };
    anchor.onmousedown = (event) => {
      if (!event.ctrlKey) {
        if (event.button === Utils.clickCodes.left && Gallery.disabled) {
          document.location = thumbURL;
        } else if (event.button === Utils.clickCodes.middle) {
          window.open(thumbURL);
        }
        event.preventDefault();
      }
    };
  }

  static prepareSearchPage() {
    if (!Utils.onSearchPage()) {
      return;
    }

    for (const thumb of Utils.getAllThumbs()) {
      Utils.removeTitleFromImage(Utils.getImageFromThumb(thumb));
      Utils.assignContentType(thumb);
      thumb.id = Utils.removeNonNumericCharacters(Utils.getIdFromThumb(thumb));
    }
  }

  /**
   * @returns {Object.<String, Number>}
   */
  static loadDiscoveredImageExtensions() {
    return JSON.parse(localStorage.getItem(Utils.localStorageKeys.imageExtensions)) || {};
  }

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

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

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

  static updateStoredImageExtensions() {
    Utils.recentlyDiscoveredImageExtensionCount += 1;

    if (Utils.recentlyDiscoveredImageExtensionCount >= Utils.settings.extensionsFoundBeforeSavingCount) {
      this.storeAllImageExtensions();
    }
  }

  static storeAllImageExtensions() {
    if (!Utils.onFavoritesPage()) {
      return;
    }
    Utils.recentlyDiscoveredImageExtensionCount = 0;
    localStorage.setItem(Utils.localStorageKeys.imageExtensions, JSON.stringify(Utils.imageExtensions));
  }

  static isAnAnimatedExtension(extension) {
    return extension === "mp4" || extension === "gif";
  }

  /**
   * @param {String} id
   * @param {String} extension
   */
  static assignImageExtension(id, extension) {
    if (Utils.extensionIsKnown(id) || Utils.isAnAnimatedExtension(extension)) {
      return;
    }
    Utils.imageExtensionAssignmentCooldown.restart();
    Utils.setImageExtension(id, extension);
    Utils.updateStoredImageExtensions();
  }

  static initializeImageExtensionAssignmentCooldown() {
    Utils.imageExtensionAssignmentCooldown = new Cooldown(1000);
    Utils.imageExtensionAssignmentCooldown.onCooldownEnd = () => {
      if (Utils.recentlyDiscoveredImageExtensionCount > 0) {
        Utils.storeAllImageExtensions();
      }
    };
  }
}

class HoldButton extends HTMLElement {
  static {
    Utils.addStaticInitializer(() => {
      customElements.define("hold-button", HoldButton);
    });
  }

  /**
   * @type {Number}
   */
  static defaultPollingTime = 100;
  /**
   * @type {Number}
   */
  static minPollingTime = 40;
  /**
   * @type {Number}
   */
  static maxPollingTime = 500;

  /**
   * @type {Number}
   */
  intervalId;
  /**
   * @type {Number}
   */
  timeoutId;
  /**
   * @type {Number}
   */
  pollingTime = HoldButton.defaultPollingTime;
  /**
   * @type {Boolean}
   */
  holdingDown = false;

  connectedCallback() {
    if (Utils.onMobileDevice()) {
      return;
    }
    this.addEventListeners();
    this.setPollingTime(this.getAttribute("pollingtime"));
  }

  attributeChangedCallback(name, oldValue, newValue) {
    switch (name) {
      case "pollingtime":
        this.setPollingTime(newValue);
        break;

      default:
        break;
    }
  }

  /**
   * @param {String} newValue
   */
  setPollingTime(newValue) {
    this.stopPolling();
    const pollingTime = parseFloat(newValue) || HoldButton.defaultPollingTime;

    this.pollingTime = Utils.clamp(Math.round(pollingTime), HoldButton.minPollingTime, HoldButton.maxPollingTime);
  }

  addEventListeners() {
    this.addEventListener("mousedown", (event) => {
      if (event.button === 0) {
        this.holdingDown = true;
        this.startPolling();
      }
    }, {
      passive: true
    });

    this.addEventListener("mouseup", (event) => {
      if (event.button === 0) {
        this.holdingDown = false;
        this.stopPolling();
      }
    }, {
      passive: true
    });

    this.addEventListener("mouseleave", () => {
      if (this.holdingDown) {
        this.onMouseLeaveWhileHoldingDown();
        this.holdingDown = false;
      }
      this.stopPolling();
    }, {
      passive: true
    });
  }

  startPolling() {
    this.timeoutId = setTimeout(() => {
      this.intervalId = setInterval(() => {
        this.onmousehold();
      }, this.pollingTime);
    }, this.pollingTime);
  }

  stopPolling() {
    clearTimeout(this.timeoutId);
    clearInterval(this.intervalId);
  }

  onmousehold() {
  }

  onMouseLeaveWhileHoldingDown() {
  }
}

class NumberComponent {
  /**
   * @type {HTMLInputElement}
   */
  input;
  /**
   * @type {HoldButton}
   */
  upArrow;
  /**
   * @type {HoldButton}
   */
  downArrow;
  /**
   * @type {Number}
   */
  increment;

  /**
   * @type {Boolean}
   */
  get allSubComponentsConnected() {
    return this.input !== null && this.upArrow !== null && this.downArrow !== null;
  }

  /**
   * @param {HTMLDivElement} element
   */
  constructor(element) {
    this.connectSubElements(element);
    this.initializeFields();
    this.addEventListeners();
  }

  initializeFields() {
    if (!this.allSubComponentsConnected) {
      return;
    }
    this.increment = Utils.roundToTwoDecimalPlaces(parseFloat(this.input.getAttribute("step")) || 1);

    if (this.input.onchange === null) {
      this.input.onchange = () => { };
    }
  }

  /**
   * @param {HTMLDivElement} element
   */
  connectSubElements(element) {
    this.input = element.querySelector("input");
    this.upArrow = element.querySelector(".number-arrow-up");
    this.downArrow = element.querySelector(".number-arrow-down");
  }

  addEventListeners() {
    if (!this.allSubComponentsConnected) {
      return;
    }
    this.upArrow.onmousehold = () => {
      this.incrementInput(true);
    };
    this.downArrow.onmousehold = () => {
      this.incrementInput(false);
    };
    this.upArrow.addEventListener("mousedown", (event) => {
      if (event.button === 0) {
        this.incrementInput(true);
      }
    }, {
      passive: true
    });
    this.downArrow.addEventListener("mousedown", (event) => {
      if (event.button === 0) {
        this.incrementInput(false);
      }
    }, {
      passive: true
    });
    this.upArrow.addEventListener("mouseup", () => {
      this.input.onchange();
    }, {
      passive: true
    });
    this.downArrow.addEventListener("mouseup", () => {
      this.input.onchange();
    }, {
      passive: true
    });
    this.upArrow.onMouseLeaveWhileHoldingDown = () => {
      this.input.onchange();
    };
    this.downArrow.onMouseLeaveWhileHoldingDown = () => {
      this.input.onchange();
    };
  }

  /**
   * @param {Boolean} add
   */
  incrementInput(add) {
    const currentValue = parseFloat(this.input.value) || 1;
    const incrementedValue = add ? currentValue + this.increment : currentValue - this.increment;

    this.input.value = Utils.clamp(incrementedValue, 0, 9999);
  }
}

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.startDebounce();
    }
    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 = () => { };
  }

  startDebounce() {
    this.debouncing = true;
    clearTimeout(this.timeout);
    this.start();
  }

  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);
    this.timeout = null;
  }

  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 (!Utils.isNumber(value)) {
      return value;
    }

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

class PostMetadata {
  /**
   * @type {Map.<String, PostMetadata>}
   */
  static allMetadata = new Map();
  static parser = new DOMParser();
  /**
   * @type {PostMetadata[]}
   */
  static missingMetadataFetchQueue = [];
  /**
   * @type {PostMetadata[]}
   */
  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;
  static settings = {
    verifyTags: true
  };
  /**
   * @param {PostMetadata} missingMetadata
   */
  static async fetchMissingMetadata(missingMetadata) {
    if (missingMetadata !== undefined) {
      PostMetadata.missingMetadataFetchQueue.push(missingMetadata);
    }

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

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

      if (metadata.postIsDeleted) {
        metadata.populateMetadataFromPost();
      } else {
        metadata.populateMetadataFromAPI(true);
      }
      await Utils.sleep(metadata.fetchDelay);
    }
    PostMetadata.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 {
    Utils.addStaticInitializer(() => {
      if (Utils.onFavoritesPage()) {
        window.addEventListener("favoritesLoaded", () => {
          PostMetadata.allFavoritesLoaded = true;
          PostMetadata.missingMetadataFetchQueue = PostMetadata.missingMetadataFetchQueue.concat(PostMetadata.deletedPostFetchQueue);
          PostMetadata.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;

  /**
   * @type {String}
   */
  get apiURL() {
    return Utils.getPostAPIURL(this.id);
  }

  /**
   * @type {String}
   */
  get postURL() {
    return Utils.getPostPageURL(this.id);
  }

  /**
   * @type {Number}
   */
  get fetchDelay() {
    return this.postIsDeleted ? PostMetadata.fetchDelay.deleted : PostMetadata.fetchDelay.normal;
  }

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

  /**
   * @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 {Object.<String, String>} record
   */
  populateMetadata(record) {
    if (record === undefined) {
      this.populateMetadataFromAPI();
    } else if (record === null) {
      PostMetadata.fetchMissingMetadata(this, true);
    } else {
      this.populateMetadataFromRecord(JSON.parse(record));

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

  /**
   * @param {Boolean} missingInDatabase
   */
  populateMetadataFromAPI(missingInDatabase = false) {
    fetch(this.apiURL)
      .then((response) => {
        return response.text();
      })
      .then((html) => {
        const dom = PostMetadata.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 = PostMetadata.encodeRating(metadata.getAttribute("rating"));
        this.creationTimestamp = Date.parse(metadata.getAttribute("created_at"));
        this.lastChangedTimestamp = parseInt(metadata.getAttribute("change"));

        if (PostMetadata.settings.verifyTags) {
          Post.verifyTags(this.id, metadata.getAttribute("tags"), metadata.getAttribute("file_url"));
        }
        const extension = Utils.getExtensionFromImageURL(metadata.getAttribute("file_url"));

        if (extension !== "mp4") {
          Utils.assignImageExtension(this.id, extension);
        }

        if (missingInDatabase) {
          dispatchEvent(new CustomEvent("missingMetadata", {
            detail: this.id
          }));
        }
      })
      .catch((error) => {
        if (error.cause === "DeletedMetadata") {
          this.postIsDeleted = true;
          PostMetadata.deletedPostFetchQueue.push(this);
        } else if (error.message === "Failed to fetch") {
          PostMetadata.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 = PostMetadata.parser.parseFromString(html, "text/html");
        const statistics = dom.getElementById("stats");

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

        PostMetadata.postStatisticsRegex.lastIndex = 0;

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

        if (PostMetadata.allFavoritesLoaded) {
          dispatchEvent(new CustomEvent("missingMetadata", {
            detail: this.id
          }));
        }
      })
      .catch((error) => {
        console.error(error);
      });
  }

  /**
   * @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 (!PostMetadata.allMetadata.has(this.id)) {
      PostMetadata.allMetadata.set(this.id, this);
    }
  }
}

class InactivePost {
  /**
   * @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}`;
  }

  /**
   * @type {String}
   */
  id;
  /**
   * @type {String}
   */
  tags;
  /**
   * @type {String}
   */
  src;
  /**
   * @type {String}
   */
  metadata;
  /**
   * @type {Boolean}
   */
  fromRecord;

  /**
   * @param {HTMLElement | Object} favorite
   */
  constructor(favorite, fromRecord) {
    this.fromRecord = fromRecord;

    if (fromRecord) {
      this.populateAttributesFromDatabaseRecord(favorite);
    } else {
      this.populateAttributesFromHTMLElement(favorite);
    }
  }

  /**
   * @param {{id: String, tags: String, src: String, metadata: String}} record
   */
  populateAttributesFromDatabaseRecord(record) {
    this.id = record.id;
    this.tags = record.tags;
    this.src = InactivePost.decompressThumbSource(record.src, record.id);
    this.metadata = record.metadata;
  }

  /**
   * @param {HTMLElement} element
   */
  populateAttributesFromHTMLElement(element) {
    this.id = Utils.getIdFromThumb(element);
    const image = Utils.getImageFromThumb(element);

    this.src = image.src || image.getAttribute("data-cfsrc");
    this.tags = this.preprocessTags(image);
  }

  /**
   * @param {HTMLImageElement} image
   * @returns {String}
   */
  preprocessTags(image) {
    const tags = Utils.correctMisspelledTags(image.title || image.getAttribute("tags"));
    return Utils.removeExtraWhiteSpace(tags).split(" ").sort().join(" ");
  }

  instantiateMetadata() {
    if (this.fromRecord) {
      return new PostMetadata(this.id, this.metadata || null);
    }
    const favoritesMetadata = new PostMetadata(this.id);
    return favoritesMetadata;
  }

  clear() {
    this.id = null;
    this.tags = null;
    this.src = null;
    this.metadata = null;
  }
}

class Post {
  /**
   * @type {Map.<String, Post>}
   */
  static allPosts = new Map();
  /**
   * @type {RegExp}
   */
  static thumbSourceCompressionRegex = /thumbnails\/\/([0-9]+)\/thumbnail_([0-9a-f]+)/;
  /**
   * @type {HTMLElement}
   */
  static template;
  /**
   * @type {String}
   */
  static removeFavoriteButtonHTML;
  /**
   * @type {String}
   */
  static addFavoriteButtonHTML;
  static currentSortingMethod = Utils.getPreference("sortingMethod", "default");
  static settings = {
    deferHTMLElementCreation: true
  };

  static {
    Utils.addStaticInitializer(() => {
      if (Utils.onFavoritesPage()) {
        Post.createTemplates();
        Post.addEventListeners();
      }
    });
  }

  static createTemplates() {
    Post.removeFavoriteButtonHTML = `<img class="remove-favorite-button add-or-remove-button" src=${Utils.createObjectURLFromSvg(Utils.icons.heartMinus)}>`;
    Post.addFavoriteButtonHTML = `<img class="add-favorite-button add-or-remove-button" src=${Utils.createObjectURLFromSvg(Utils.icons.heartPlus)}>`;
    const buttonHTML = Utils.userIsOnTheirOwnFavoritesPage() ? Post.removeFavoriteButtonHTML : Post.addFavoriteButtonHTML;
    const canvasHTML = Utils.getPerformanceProfile() > 0 ? "" : "<canvas></canvas>";
    const containerTagName = "a";

    Post.template = new DOMParser().parseFromString("", "text/html").createElement("div");
    Post.template.className = Utils.favoriteItemClassName;
    Post.template.innerHTML = `
        <${containerTagName}>
          <img loading="lazy">
          ${buttonHTML}
          ${canvasHTML}
        </${containerTagName}>
    `;
  }

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

      if (post !== undefined) {
        post.swapAddOrRemoveButton();
      }
    });
    window.addEventListener("sortingParametersChanged", () => {
      Post.currentSortingMethod = Utils.getSortingMethod();
      const posts = Utils.getAllThumbs().map(thumb => Post.allPosts.get(thumb.id));

      for (const post of posts) {
        post.createMetadataHint();
      }
    });
  }

  /**
   * @param {String} id
   * @returns {Number}
   */
  static getPixelCount(id) {
    const post = Post.allPosts.get(id);

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

  /**
   * @param {String} id
   * @returns {String}
   */
  static getExtensionFromPost(id) {
    const post = Post.allPosts.get(id);

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

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

  /**
   * @param {String} id
   * @param {String} apiTags
   * @param {String} fileURL
   */
  static verifyTags(id, apiTags, fileURL) {
    const post = Post.allPosts.get(id);

    if (post === undefined) {
      return;
    }
    const postTagSet = new Set(post.originalTagSet);
    const apiTagSet = Utils.convertToTagSet(apiTags);

    if (fileURL.endsWith("mp4")) {
      apiTagSet.add("video");
    } else if (fileURL.endsWith("gif")) {
      apiTagSet.add("gif");
    } else if (!apiTagSet.has("animated_png")) {
      if (apiTagSet.has("video")) {
        apiTagSet.delete("video");
      }

      if (apiTagSet.has("animated")) {
        apiTagSet.delete("animated");
      }
    }
    postTagSet.delete(id);

    if (Utils.symmetricDifference(apiTagSet, postTagSet).size > 0) {
      post.initializeTags(Utils.convertToTagString(apiTagSet));
    }
  }

  /**
   * @type {Map.<String, Post>}
   */
  static get postsMatchedBySearch() {
    const posts = new Map();

    for (const [id, post] of Post.allPosts.entries()) {
      if (post.matchedByMostRecentSearch) {
        posts.set(id, post);
      }
    }
    return posts;
  }

  /**
   * @type {String}
   */
  id;
  /**
   * @type {HTMLDivElement}
   */
  root;
  /**
   * @type {HTMLAnchorElement}
   */
  container;
  /**
   * @type {HTMLImageElement}
   */
  image;
  /**
   * @type {HTMLImageElement}
   */
  addOrRemoveButton;
  /**
   * @type {HTMLDivElement}
   */
  statisticHint;
  /**
   * @type {InactivePost}
   */
  inactivePost;
  /**
   * @type {Boolean}
   */
  essentialAttributesPopulated;
  /**
   * @type {Boolean}
   */
  htmlElementCreated;
  /**
   * @type {Set.<String>}
   */
  tagSet;
  /**
   * @type {Set.<String>}
   */
  additionalTags;
  /**
   * @type {Number}
   */
  originalTagsLength;
  /**
   * @type {Boolean}
   */
  matchedByMostRecentSearch;
  /**
   * @type {PostMetadata}
   */
  metadata;

  /**
   * @type {String}
   */
  get href() {
    return Utils.getPostPageURL(this.id);
  }

  /**
   * @type {String}
   */
  get compressedThumbSource() {
    const source = this.inactivePost === null ? this.image.src : this.inactivePost.src;
    return source.match(Post.thumbSourceCompressionRegex).splice(1).join("_");
  }

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

  /**
   * @type {Set.<String>}
   */
  get originalTagSet() {
    const originalTags = new Set();
    let count = 0;

    for (const tag of this.tagSet.values()) {
      if (count >= this.originalTagsLength) {
        break;
      }
      count += 1;
      originalTags.add(tag);
    }
    return originalTags;
  }

  /**
   * @type {Set.<String>}
   */
  get originalTagsString() {
    return Utils.convertToTagString(this.originalTagSet);
  }

  /**
   * @type {String}
   */
  get additionalTagsString() {
    return Utils.convertToTagString(this.additionalTags);
  }

  /**
   * @param {HTMLElement | Object} thumb
   * @param {Boolean} fromRecord
   */
  constructor(thumb, fromRecord) {
    this.initializeFields();
    this.initialize(new InactivePost(thumb, fromRecord));
    this.setMatched(true);
    this.addInstanceToAllPosts();
  }

  initializeFields() {
    this.inactivePost = null;
    this.essentialAttributesPopulated = false;
    this.htmlElementCreated = false;
  }

  /**
   * @param {InactivePost} inactivePost
   */
  initialize(inactivePost) {
    if (Post.settings.deferHTMLElementCreation) {
      this.inactivePost = inactivePost;
      this.populateEssentialAttributes(inactivePost);
    } else {
      this.createHTMLElement(inactivePost);
    }
  }

  /**
   * @param {InactivePost} inactivePost
   */
  populateEssentialAttributes(inactivePost) {
    if (this.essentialAttributesPopulated) {
      return;
    }
    this.essentialAttributesPopulated = true;
    this.id = inactivePost.id;
    this.metadata = inactivePost.instantiateMetadata();
    this.initializeTags(inactivePost.tags);
    this.deleteConsumedProperties(inactivePost);
  }

  /**
   * @param {InactivePost} inactivePost
   */
  createHTMLElement(inactivePost) {
    if (this.htmlElementCreated) {
      return;
    }
    this.htmlElementCreated = true;
    this.instantiateTemplate();
    this.populateEssentialAttributes(inactivePost);
    this.populateHTMLAttributes(inactivePost);
    this.setupAddOrRemoveButton(Utils.userIsOnTheirOwnFavoritesPage());
    this.setupClickLink();
    this.deleteInactivePost();
  }

  activateHTMLElement() {
    if (this.inactivePost !== null) {
      this.createHTMLElement(this.inactivePost);
    }
  }

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

  /**
   * @param {Boolean} isRemoveButton
   */
  setupAddOrRemoveButton(isRemoveButton) {
    if (isRemoveButton) {
      this.addOrRemoveButton.onmousedown = (event) => {
        event.stopPropagation();

        if (event.button === Utils.clickCodes.left) {
          this.removeFavorite();
        }
      };
    } else {
      this.addOrRemoveButton.onmousedown = (event) => {
        event.stopPropagation();

        if (event.button === Utils.clickCodes.left) {
          this.addFavorite();
        }
      };
    }
  }

  removeFavorite() {
    Utils.removeFavorite(this.id);
    this.swapAddOrRemoveButton();
  }

  addFavorite() {
    Utils.addFavorite(this.id);
    this.swapAddOrRemoveButton();
  }

  swapAddOrRemoveButton() {
    const isRemoveButton = this.addOrRemoveButton.classList.contains("remove-favorite-button");

    this.addOrRemoveButton.outerHTML = isRemoveButton ? Post.addFavoriteButtonHTML : Post.removeFavoriteButtonHTML;
    this.addOrRemoveButton = this.root.children[0].children[1];
    this.setupAddOrRemoveButton(!isRemoveButton);
  }

  /**
   * @param {InactivePost} inactivePost
   */
  async populateHTMLAttributes(inactivePost) {
    this.image.src = inactivePost.src;
    this.image.classList.add(Utils.getContentType(inactivePost.tags || Utils.convertToTagString(this.tagSet)));
    this.root.id = inactivePost.id;

    if (!Utils.onMobileDevice()) {
      this.container.href = await Utils.getOriginalImageURLWithExtension(this.root);
    }
  }

  /**
   * @param {String} tags
   */
  initializeTags(tags) {
    this.tagSet = Utils.convertToTagSet(`${this.id} ${tags}`);
    this.originalTagsLength = this.tagSet.size;
    this.initializeAdditionalTags();
  }

  initializeAdditionalTags() {
    this.additionalTags = Utils.convertToTagSet(TagModifier.tagModifications.get(this.id) || "");

    if (this.additionalTags.size !== 0) {
      this.combineOriginalAndAdditionalTags();
    }
  }

  /**
   * @param {InactivePost} inactivePost
   */
  deleteConsumedProperties(inactivePost) {
    inactivePost.metadata = null;
    inactivePost.tags = null;
  }

  setupClickLink() {
    if (!Utils.onFavoritesPage()) {
      return;
    }
    this.container.addEventListener("mousedown", (event) => {
      if (event.ctrlKey) {
        return;
      }
      const middleClick = event.button === Utils.clickCodes.middle;
      const leftClick = event.button === Utils.clickCodes.left;

      if (middleClick || (leftClick && !Utils.galleryEnabled())) {
        Utils.openPostInNewTab(this.id);
      }
    });
  }

  deleteInactivePost() {
    if (this.inactivePost !== null) {
      this.inactivePost.clear();
      this.inactivePost = null;
    }
  }

  /**
   * @param {HTMLElement} content
   */
  insertAtEndOfContent(content) {
    if (this.inactivePost !== null) {
      this.createHTMLElement(this.inactivePost, true);
    }
    this.createMetadataHint();
    content.appendChild(this.root);
  }

  /**
   * @param {HTMLElement} content
   */
  insertAtBeginningOfContent(content) {
    if (this.inactivePost !== null) {
      this.createHTMLElement(this.inactivePost, true);
    }
    this.createMetadataHint();
    content.insertAdjacentElement("afterbegin", this.root);
  }

  addInstanceToAllPosts() {
    if (!Post.allPosts.has(this.id)) {
      Post.allPosts.set(this.id, this);
    }
  }

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

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

  combineOriginalAndAdditionalTags() {
    this.tagSet = this.originalTagSet;
    this.tagSet = Utils.union(this.tagSet, this.additionalTags);
  }

  /**
   * @param {String} newTags
   * @returns {String}
   */
  addAdditionalTags(newTags) {
    const newTagsSet = Utils.convertToTagSet(newTags);

    if (newTagsSet.size > 0) {
      this.additionalTags = Utils.union(this.additionalTags, newTagsSet);
      this.combineOriginalAndAdditionalTags();
    }
    return this.additionalTagsString;
  }

  /**
   * @param {String} tagsToRemove
   * @returns {String}
   */
  removeAdditionalTags(tagsToRemove) {
    const tagsToRemoveSet = Utils.convertToTagSet(tagsToRemove);

    if (tagsToRemoveSet.size > 0) {
      this.additionalTags = Utils.difference(this.additionalTags, tagsToRemoveSet);
      this.combineOriginalAndAdditionalTags();
    }
    return this.additionalTagsString;
  }

  resetAdditionalTags() {
    if (this.additionalTags.size === 0) {
      return;
    }
    this.additionalTags = new Set();
    this.combineOriginalAndAdditionalTags();
  }

  /**
   * @returns {HTMLDivElement}
   */
  getMetadataHintElement() {
    return this.container.querySelector(".statistic-hint");
  }

  /**
   * @returns {Boolean}
   */
  hasStatisticHint() {
    return this.getMetadataHintElement() !== null;
  }

  /**
   * @returns {String}
   */
  getMetadataHintValue() {
    switch (Post.currentSortingMethod) {
      case "score":
        return this.metadata.score;

      case "width":
        return this.metadata.width;

      case "height":
        return this.metadata.height;

      case "create":
        return Utils.convertTimestampToDate(this.metadata.creationTimestamp);

      case "change":
        return Utils.convertTimestampToDate(this.metadata.lastChangedTimestamp * 1000);

      default:
        return this.id;
    }
  }

  async createMetadataHint() {
    // await sleep(200);
    // let hint = this.getMetadataHintElement();

    // if (hint === null) {
    //   hint = document.createElement("div");
    //   hint.className = "statistic-hint";
    //   this.container.appendChild(hint);
    // }
    // hint.textContent = this.getMetadataHintValue();
  }
}

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
   */
  constructor(searchTag) {
    this.negated = searchTag.startsWith("-");
    this.value = this.negated ? searchTag.substring(1) : searchTag;
  }

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

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

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

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

  /**
   * @param {String} searchTag
   */
  constructor(searchTag) {
    super(searchTag);
    this.matchRegex = this.createWildcardRegex();
    this.startsWithPrefix = this.value.slice(0, -1);
    this.equivalentToStartsWith = WildcardSearchTag.startsWithRegex.test(searchTag);
    this.matches = this.equivalentToStartsWith ? this.matchesPrefix : this.matchesWildcard;
  }

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

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

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

  /**
   * @param {Post} post
   * @returns {Boolean}
   */
  matchesWildcard(post) {
    for (const tag of post.tagSet.values()) {
      if (this.matchRegex.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) {
    super(searchTag);
    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 {Post} post
   * @returns {Boolean}
   */
  matches(post) {
    const metadata = PostMetadata.allMetadata.get(post.id);

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

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

class SearchCommand {
  /**
   * @param {String} tag
   * @returns {SearchTag}
   */
  static createSearchTag(tag) {
    if (MetadataSearchTag.regex.test(tag)) {
      return new MetadataSearchTag(tag);
    }

    if (tag.includes("*")) {
      return new WildcardSearchTag(tag);
    }
    return new SearchTag(tag);
  }

  /**
   * @param {String[]} tags
   * @param {Boolean} isOrGroup
   * @returns {SearchTag[]}
   */
  static createSearchTagGroup(tags) {
    const uniqueTags = new Set();
    const searchTags = [];

    for (const tag of tags) {
      if (!uniqueTags.has(tag)) {
        uniqueTags.add(tag);
        searchTags.push(SearchCommand.createSearchTag(tag));
      }
    }
    return searchTags;
  }

  /**
   * @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.isEmpty = searchQuery.trim() === "";

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

    this.orGroups = orGroups.map(orGroup => SearchCommand.createSearchTagGroup(orGroup));
    this.remainingSearchTags = SearchCommand.createSearchTagGroup(remainingSearchTags);
    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 {Post} post
   * @returns {Boolean}
   */
  matches(post) {
    if (this.isEmpty) {
      return true;
    }

    if (!this.matchesAllRemainingSearchTags(post)) {
      return false;
    }
    return this.matchesAllOrGroups(post);
  }

  /**
   * @param {Post} post
   * @returns {Boolean}
   */
  matchesAllRemainingSearchTags(post) {
    for (const searchTag of this.remainingSearchTags) {
      if (!searchTag.matches(post)) {
        return false;
      }
    }
    return true;
  }

  /**
   * @param {Post} post
   * @returns {Boolean}
   */
  matchesAllOrGroups(post) {
    for (const orGroup of this.orGroups) {
      if (!this.atLeastOnePostTagIsInOrGroup(orGroup, post)) {
        return false;
      }
    }
    return true;
  }

  /**
   * @param {SearchTag[]} orGroup
   * @param {Post} post
   * @returns {Boolean}
   */
  atLeastOnePostTagIsInOrGroup(orGroup, post) {
    for (const orTag of orGroup) {
      if (orTag.matches(post)) {
        return true;
      }
    }
    return false;
  }
}

class FavoritesPageRequest {
  /**
   * @type {Number}
   */
  pageNumber;
  /**
   * @type {Number}
   */
  retryCount;
  /**
   * @type {Post[]}
   */
  fetchedFavorites;

  /**
   * @type {String}
   */
  get url() {
    return `${document.location.href}&pid=${this.pageNumber * 50}`;
  }

  /**
   * @type {Number}
   */
  get retryDelay() {
    return (7 ** (this.retryCount)) + 200;
  }

  /**
   * @param {Number} pageNumber
   */
  constructor(pageNumber) {
    this.pageNumber = pageNumber;
    this.retryCount = 0;
    this.fetchedFavorites = [];
  }

  onFail() {
    this.retryCount += 1;
  }
}

class FavoritesParser {
  static parser = new DOMParser();

  /**
   * @param {String} favoritesPageHTML
   * @returns {Post[]}
   */
  static extractFavorites(favoritesPageHTML) {
    const elements = FavoritesParser.extractFavoriteElements(favoritesPageHTML);
    return elements.map(element => new Post(element, false));
  }

  /**
   * @param {String} favoritesPageHTML
   * @returns {HTMLElement[]}
   */
  static extractFavoriteElements(favoritesPageHTML) {
    const dom = FavoritesParser.parser.parseFromString(favoritesPageHTML, "text/html");
    const thumbs = Array.from(dom.getElementsByClassName("thumb"));

    if (thumbs.length > 0) {
      return thumbs;
    }
    return Array.from(dom.getElementsByTagName("img"))
      .filter(image => image.src.includes("thumbnail_"))
      .map(image => image.parentElement);
  }
}

class FetchedFavoritesQueue {
  /**
   * @type {FavoritesPageRequest[]}
   */
  queue;
  /**
   * @type {Function}
   */
  onDequeue;
  /**
   * @type {Number}
   */
  lastDequeuedPageNumber;
  /**
   * @type {Boolean}
   */
  dequeuing;

  /**
   * @type {Number}
   */
  get lowestEnqueuedPageNumber() {
    return this.queue[0].pageNumber;
  }

  /**
   * @type {Number}
   */
  get nextPageNumberToDequeue() {
    return this.lastDequeuedPageNumber + 1;
  }

  /**
   * @type {Boolean}
   */
  get allPreviousPagesWereDequeued() {
    return this.nextPageNumberToDequeue === this.lowestEnqueuedPageNumber;
  }

  /**
   * @type {Boolean}
   */
  get isEmpty() {
    return this.queue.length === 0;
  }

  /**
   * @type {Boolean}
   */
  get canDequeue() {
    return !this.isEmpty && this.allPreviousPagesWereDequeued;
  }

  /**
   * @param {Function}
   */
  constructor(onDequeue) {
    this.onDequeue = onDequeue;
    this.lastDequeuedPageNumber = -1;
    this.queue = [];
  }

  /**
   * @param {FavoritesPageRequest} request
   */
  enqueue(request) {
    this.queue.push(request);
    this.sortByLowestPageNumber();
    this.dequeueAll();
  }

  sortByLowestPageNumber() {
    this.queue.sort((request1, request2) => request1.pageNumber - request2.pageNumber);
  }

  dequeueAll() {
    if (this.dequeuing) {
      return;
    }
    this.dequeuing = true;

    while (this.canDequeue) {
      this.dequeue();
    }
    this.dequeuing = false;
  }

  dequeue() {
    this.lastDequeuedPageNumber += 1;
    this.onDequeue(this.queue.shift());
  }
}

class FavoritesFetcher {
  /**
   * @type {Function}
   */
  onAllFavoritesPageRequestsCompleted;
  /**
   * @type {Function}
   */
  onFavoritesPageRequestCompleted;
  /**
   * @type {FavoritesPageRequest[]}
   */
  failedFavoritesPageRequests;
  /**
   * @type {Set.<String>}
   */
  storedFavoriteIds;
  /**
   * @type {Number}
   */
  currentFavoritesPageNumber;
  /**
   * @type {Boolean}
   */
  fetchedAnEmptyFavoritesPage;

  /**
   * @type {Boolean}
   */
  get hasFailedFavoritesPageRequests() {
    return this.failedFavoritesPageRequests.length > 0;
  }

  /**
   * @type {Boolean}
   */
  get hasNotFetchedAllFavoritesPages() {
    return !this.fetchedAnEmptyFavoritesPage;
  }

  /**
   * @type {Boolean}
   */
  get allFavoritesPageRequestsHaveNotCompleted() {
    return this.hasNotFetchedAllFavoritesPages || this.hasFailedFavoritesPageRequests;
  }

  /**
   * @type {FavoritesPageRequest}
   */
  get oldestFailedFavoritesPageFetchRequest() {
    return this.failedFavoritesPageRequests.shift();
  }

  /**
   * @type {FavoritesPageRequest}
   */
  get newFavoritesPageFetchRequest() {
    const request = new FavoritesPageRequest(this.currentFavoritesPageNumber);

    this.currentFavoritesPageNumber += 1;
    return request;
  }

  /**
   * @type {FavoritesPageRequest | null}
   */
  get currentFetchRequest() {
    if (this.hasFailedFavoritesPageRequests) {
      return this.oldestFailedFavoritesPageFetchRequest;
    }

    if (this.hasNotFetchedAllFavoritesPages) {
      return this.newFavoritesPageFetchRequest;
    }
    return null;
  }

  /**
   * @param {Function} onAllFavoritesPageRequestsCompleted
   * @param {Function} onFavoritesPageRequestCompleted
   */
  constructor(onAllFavoritesPageRequestsCompleted, onFavoritesPageRequestCompleted) {
    this.onAllFavoritesPageRequestsCompleted = onAllFavoritesPageRequestsCompleted;
    this.onFavoritesPageRequestCompleted = onFavoritesPageRequestCompleted;
    this.storedFavoriteIds = new Set();
    this.failedFavoritesPageRequests = [];
    this.currentFavoritesPageNumber = 0;
    this.fetchedAnEmptyFavoritesPage = false;
  }

  async fetchAllFavorites() {
    while (this.allFavoritesPageRequestsHaveNotCompleted) {
      await this.fetchFavoritesPage(this.currentFetchRequest);
    }
    this.onAllFavoritesPageRequestsCompleted();
  }

  /**
   * @param {Set.<String>} storedFavoriteIds
   */
  async fetchAllNewFavoritesOnReload(storedFavoriteIds) {
    this.storedFavoriteIds = storedFavoriteIds;
    let favorites = [];

    while (true) {
      const {allNewFavoritesFound, newFavorites} = await this.fetchNewFavoritesOnReload();

      favorites = favorites.concat(newFavorites);

      if (allNewFavoritesFound) {
        this.storedFavoriteIds = null;
        this.onAllFavoritesPageRequestsCompleted(favorites);
        return;
      }
    }
  }

  /**
   * @returns {Promise.<{allNewFavoritesFound: Boolean, newFavorites: Post[]}>}
   */
  fetchNewFavoritesOnReload() {
    return fetch(this.newFavoritesPageFetchRequest.url)
      .then((response) => {
        return response.text();
      })
      .then((html) => {
        return this.extractNewFavorites(html);
      });
  }

  /**
   * @param {String} html
   * @returns {{allNewFavoritesFound: Boolean, newFavorites: Post[]}}
   */
  extractNewFavorites(html) {
    const newFavorites = [];
    const fetchedFavorites = FavoritesParser.extractFavorites(html);
    let allNewFavoritesFound = fetchedFavorites.length === 0;

    for (const favorite of fetchedFavorites) {
      if (this.storedFavoriteIds.has(favorite.id)) {
        allNewFavoritesFound = true;
        break;
      }
      newFavorites.push(favorite);
    }
    return {
      allNewFavoritesFound,
      newFavorites
    };
  }

  /**
   * @param {FavoritesPageRequest} request
   */
  async fetchFavoritesPage(request) {
    if (request === null) {
      console.error("Error: null fetch request");
      return;
    }
    fetch(request.url)
      .then((response) => {
        return this.onFavoritesPageRequestResponse(response);
      })
      .then((html) => {
        this.onFavoritesPageRequestSuccess(request, html);
      })
      .catch((error) => {
        this.onFavoritesPageRequestFail(request, error);
      });
    await Utils.sleep(request.retryDelay);
  }

  /**
   * @param {Response} response
   * @returns {Promise.<String>}
   */
  onFavoritesPageRequestResponse(response) {
    if (response.ok) {
      return response.text();
    }
    throw new Error(`${response.status}: Failed to fetch, ${response.url}`);
  }

  /**
   * @param {FavoritesPageRequest} request
   * @param {String} html
   */
  onFavoritesPageRequestSuccess(request, html) {
    request.fetchedFavorites = FavoritesParser.extractFavorites(html);
    const favoritesPageIsEmpty = request.fetchedFavorites.length === 0;

    this.fetchedAnEmptyFavoritesPage = this.fetchedAnEmptyFavoritesPage || favoritesPageIsEmpty;

    if (!favoritesPageIsEmpty) {
      this.onFavoritesPageRequestCompleted(request);
    }
  }

  /**
   * @param {FavoritesPageRequest} request
   * @param {Error} error
   */
  onFavoritesPageRequestFail(request, error) {
    console.error(error);
    request.onFail();
    this.failedFavoritesPageRequests.push(request);
  }
}

class FavoritesPaginator {
  /**
   * @type {HTMLDivElement}
   */
  content;
  /**
   * @type {HTMLElement}
   */
  paginationMenu;
  /**
   * @type {HTMLLabelElement}
   */
  paginationLabel;
  /**
   * @type {Number}
   */
  currentPageNumber;
  /**
   * @type {Number}
   */
  maxFavoritesPerPage;
  /**
   * @type {Number}
   */
  maxPageNumberButtons;

  constructor() {
    this.content = this.createContentContainer();
    this.paginationMenu = this.createPaginationMenuContainer();
    this.currentPageNumber = 1;
    this.favoritesPerPage = Utils.getPreference("resultsPerPage", Utils.defaults.resultsPerPage);
    this.maxPageNumberButtons = Utils.onMobileDevice() ? 3 : 5;
  }

  /**
   * @returns {HTMLDivElement}
   */
  createContentContainer() {
    const content = document.createElement("div");

    content.id = "favorites-search-gallery-content";
    Utils.favoritesSearchGalleryContainer.appendChild(content);
    return content;
  }

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

    container.id = "favorites-pagination-container";
    return container;
  }

  insertPaginationMenuContainer() {
    if (document.getElementById(this.paginationMenu.id) === null) {

      if (Utils.onMobileDevice()) {
        document.getElementById("favorites-search-gallery-menu").insertAdjacentElement("afterbegin", this.paginationMenu);
      } else {
        const placeToInsertPagination = document.getElementById("favorites-pagination-placeholder");

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

  /**
   * @param {Post[]} favorites
   */
  paginate(favorites) {
    this.insertPaginationMenuContainer();
    this.changePage(1, favorites);
  }

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

    if (needsToCreateNewPage && !alreadyAtMaxPageNumberButtons) {
      this.createPaginationMenu(this.currentPageNumber, favorites);
    } else {
      this.updateTraversalButtonEventListeners(favorites);
      this.updatePageNumberButtonEventListeners(favorites);
    }
    const onLastPage = (pageCount === this.currentPageNumber);

    if (!onLastPage) {
      return;
    }
    const range = this.getPaginationRange(this.currentPageNumber);
    const favoritesToAdd = favorites.slice(range.start, range.end)
      .filter(favorite => document.getElementById(favorite.id) === null);

    for (const favorite of favoritesToAdd) {
      favorite.insertAtEndOfContent(this.content);
    }
    this.setPaginationLabel(this.currentPageNumber, favorites.length);
  }

  /**
   * @param {Number} pageNumber
   * @param {Post[]} favorites
   */
  changePage(pageNumber, favorites) {
    this.currentPageNumber = pageNumber;
    this.createPaginationMenu(pageNumber, favorites);
    this.showFavorites(pageNumber, favorites);

    if (FavoritesLoader.currentState !== FavoritesLoader.states.loadingFavoritesFromDatabase) {
      dispatchEvent(new Event("changedPage"));
    }
  }

  /**
   * @param {Number} pageNumber
   * @param {Post[]} favorites
   */
  createPaginationMenu(pageNumber, favorites) {
    this.paginationMenu.innerHTML = "";
    this.setPaginationLabel(pageNumber, favorites.length);
    this.createPageNumberButtons(pageNumber, favorites);
    this.createPageTraversalButtons(favorites);
    this.createGotoSpecificPageInputs(favorites);
  }

  /**
   * @param {Number} pageNumber
   * @param {Number} favoriteCount
   */
  setPaginationLabel(pageNumber, favoriteCount) {
    const range = this.getPaginationRange(pageNumber);
    const start = range.start;
    const end = Math.min(range.end, favoriteCount);

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

    if (favoriteCount <= this.maxFavoritesPerPage || isNaN(start) || isNaN(end)) {
      this.paginationLabel.textContent = "";
      return;
    }
    this.paginationLabel.textContent = `${start + 1} - ${end}`;
  }

  /**
   * @param {Number} pageNumber
   * @returns {{start: Number, end: Number}}
   */
  getPaginationRange(pageNumber) {
    return {
      start: this.maxFavoritesPerPage * (pageNumber - 1),
      end: this.maxFavoritesPerPage * pageNumber
    };
  }

  /**
   * @param {Number} favoriteCount
   * @returns {Number}
   */
  getPageCount(favoriteCount) {
    if (favoriteCount === 0) {
      return 1;
    }
    const pageCount = favoriteCount / this.maxFavoritesPerPage;

    if (favoriteCount % this.maxFavoritesPerPage === 0) {
      return pageCount;
    }
    return Math.floor(pageCount) + 1;
  }

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

    for (let i = pageNumber; i <= pageCount && numberOfButtonsCreated < this.maxPageNumberButtons; i += 1) {
      numberOfButtonsCreated += 1;
      this.createPageNumberButton(pageNumber, i, favorites);
    }

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

    for (let j = pageNumber - 1; j >= 1 && numberOfButtonsCreated < this.maxPageNumberButtons; j -= 1) {
      numberOfButtonsCreated += 1;
      this.createPageNumberButton(pageNumber, j, favorites, "afterbegin");
    }
  }

  /**
   * @param {Number} currentPageNumber
   * @param {Number} pageNumber
   * @param {Post[]} favorites
   * @param {InsertPosition} position
   */
  createPageNumberButton(currentPageNumber, pageNumber, favorites, 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.changePage(pageNumber, favorites);
    };
    this.paginationMenu.insertAdjacentElement(position, pageNumberButton);
    pageNumberButton.textContent = pageNumber;
  }

  /**
   * @param {Post[]} favorites
   */
  updatePageNumberButtonEventListeners(favorites) {
    const pageNumberButtons = document.getElementsByClassName("pagination-number");

    for (const pageNumberButton of pageNumberButtons) {
      const pageNumber = parseInt(Utils.removeNonNumericCharacters(pageNumberButton.id));

      pageNumberButton.onclick = () => {
        this.changePage(pageNumber, favorites);
      };
    }
  }

  /**
   * @param {Post[]} favorites
   */
  createPageTraversalButtons(favorites) {
    const pageCount = this.getPageCount(favorites.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.title = "Goto previous page";
    firstPage.title = "Goto first page";
    nextPage.title = "Goto next page";
    finalPage.title = "Goto last page";

    previousPage.onclick = () => {
      if (this.currentPageNumber - 1 >= 1) {
        this.changePage(this.currentPageNumber - 1, favorites);
      }
    };
    firstPage.onclick = () => {
      this.changePage(1, favorites);
    };
    nextPage.onclick = () => {
      if (this.currentPageNumber + 1 <= pageCount) {
        this.changePage(this.currentPageNumber + 1, favorites);
      }
    };
    finalPage.onclick = () => {
      this.changePage(pageCount, favorites);
    };
    this.paginationMenu.insertAdjacentElement("afterbegin", previousPage);
    this.paginationMenu.insertAdjacentElement("afterbegin", firstPage);
    this.paginationMenu.appendChild(nextPage);
    this.paginationMenu.appendChild(finalPage);

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

  /**
   * @param {Post[]} favorites
   */
  createGotoSpecificPageInputs(favorites) {
    if (this.firstPageNumberButtonExists() && this.lastPageNumberButtonExists(this.getPageCount(favorites.length))) {
      return;
    }
    const html = `
      <input type="number" placeholder="page" style="width: 4em;" id="goto-page-input">
      <button id="goto-page-button">Go</button>
    `;
    const container = document.createElement("span");

    container.title = "Goto specific page";
    container.innerHTML = html;
    const input = container.children[0];
    const button = container.children[1];

    input.onkeydown = (event) => {
      if (event.key === "Enter") {
        button.click();
      }
    };
    this.paginationMenu.appendChild(container);
    this.updateTraversalButtonEventListeners(favorites);
  }

  /**
   * @param {Post[]} favorites
   */
  updateTraversalButtonEventListeners(favorites) {
    const gotoPageButton = document.getElementById("goto-page-button");
    const finalPageButton = document.getElementById("final-page");
    const input = document.getElementById("goto-page-input");
    const pageCount = this.getPageCount(favorites.length);

    if (gotoPageButton === null || finalPageButton === null || input === null) {
      return;
    }

    gotoPageButton.onclick = () => {
      let pageNumber = parseInt(input.value);

      if (!Utils.isNumber(pageNumber)) {
        return;
      }
      pageNumber = Utils.clamp(pageNumber, 1, pageCount);
      this.changePage(pageNumber, favorites);

    };
    finalPageButton.onclick = () => {
      this.changePage(pageCount, favorites);
    };
  }

  /**
   * @param {Number} pageNumber
   * @param {Post[]} favorites
   */
  showFavorites(pageNumber, favorites) {
    const {start, end} = this.getPaginationRange(pageNumber);
    const newContent = document.createDocumentFragment();

    for (const favorite of favorites.slice(start, end)) {
      favorite.insertAtEndOfContent(newContent);
    }
    this.content.innerHTML = "";
    this.content.appendChild(newContent);
    window.scrollTo(0, 0);
  }

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

  /**
   * @param {Number} pageCount
   * @returns {Boolean}
   */
  lastPageNumberButtonExists(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.firstPageNumberButtonExists();
    const lastNumberExists = this.lastPageNumberButtonExists(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 {String} direction
   * @param {Post[]} favorites
   */
  changePageWhileInGallery(direction, favorites) {
    const pageCount = this.getPageCount(favorites.length);
    const onLastPage = this.currentPageNumber === pageCount;
    const onFirstPage = this.currentPageNumber === 1;
    const onlyOnePage = onFirstPage && onLastPage;

    if (onlyOnePage) {
      dispatchEvent(new CustomEvent("didNotChangePageInGallery", {
        detail: direction
      }));
      return;
    }

    if (onLastPage && direction === "ArrowRight") {
      this.changePage(1, favorites);
      return;
    }

    if (onFirstPage && direction === "ArrowLeft") {
      this.changePage(pageCount, favorites);
      return;
    }
    const newPageNumber = direction === "ArrowRight" ? this.currentPageNumber + 1 : this.currentPageNumber - 1;

    this.changePage(newPageNumber, favorites);
  }

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

  /**
   * @param {Post} favorite
   */
  insertNewFavorite(favorite) {
    favorite.insertAtBeginningOfContent(this.content);
  }

  /**
   * @param {Number} id
   */
  findFavorite(id) {
    // const favorites = this.latestSearchResults;
    // const favoriteIds = favorites.map(favorite => favorite.id);
    // const index = favoriteIds.indexOf(id);

    // if (index === -1) {
    //   return;
    // }
    // const pageNumber = Math.floor(index / this.favoritesPerPage) + 1;

    // dispatchEvent(new CustomEvent("foundFavorite", {
    //   detail: id
    // }));
    // this.changePage(pageNumber, favorites);
    // setTimeout(() => {
    //   scrollToThumb(id, true, false);
    // }, 600);
  }
}

class FavoritesSearchFlags {
  /**
   * @type {Boolean}
   */
  searchResultsAreShuffled;
  /**
   * @type {Boolean}
   */
  searchResultsAreInverted;
  /**
   * @type {Boolean}
   */
  searchResultsWereShuffled;
  /**
   * @type {Boolean}
   */
  searchResultsWereInverted;
  /**
   * @type {Boolean}
   */
  recentlyChangedResultsPerPage;
  /**
   * @type {Boolean}
   */
  tagsWereModified;
  /**
   * @type {Boolean}
   */
  excludeBlacklistWasClicked;
  /**
   * @type {Boolean}
   */
  sortingParametersWereChanged;
  /**
   * @type {Boolean}
   */
  allowedRatingsWereChanged;
  /**
   * @type {String}
   */
  searchQuery;
  /**
   * @type {String}
   */
  previousSearchQuery;

  /**
   * @type {Boolean}
   */
  get onFirstPage() {
    const firstPageNumberButton = document.getElementById("favorites-page-1");
    return firstPageNumberButton !== null && firstPageNumberButton.classList.contains("selected");
  }

  /**
   * @type {Boolean}
   */
  get notOnFirstPage() {
    return !this.onFirstPage;
  }

  /**
   * @type {Boolean}
   */
  get aNewSearchCouldProduceDifferentResults() {
    return this.searchQuery !== this.previousSearchQuery ||
      FavoritesLoader.currentState !== FavoritesLoader.states.allFavoritesLoaded ||
      this.searchResultsAreShuffled ||
      this.searchResultsAreInverted ||
      this.searchResultsWereShuffled ||
      this.searchResultsWereInverted ||
      this.recentlyChangedResultsPerPage ||
      this.tagsWereModified ||
      this.excludeBlacklistWasClicked ||
      this.sortingParametersWereChanged ||
      this.allowedRatingsWereChanged ||
      this.notOnFirstPage;
  }

  constructor() {
    this.searchResultsAreShuffled = false;
    this.searchResultsAreInverted = false;
    this.searchResultsWereShuffled = false;
    this.searchResultsWereInverted = false;
    this.recentlyChangedResultsPerPage = false;
    this.tagsWereModified = false;
    this.excludeBlacklistWasClicked = false;
    this.sortingParametersWereChanged = false;
    this.allowedRatingsWereChanged = false;
    this.searchQuery = "";
    this.previousSearchQuery = "";
  }

  resetFlagsImplyingDifferentSearchResults() {
    this.searchResultsWereShuffled = this.searchResultsAreShuffled;
    this.searchResultsWereInverted = this.searchResultsAreInverted;
    this.tagsWereModified = false;
    this.excludeBlacklistWasClicked = false;
    this.sortingParametersWereChanged = false;
    this.allowedRatingsWereChanged = false;
    this.searchResultsAreShuffled = false;
    this.searchResultsAreInverted = false;
    this.recentlyChangedResultsPerPage = false;
    this.previousSearchQuery = this.searchQuery;
  }
}

class FavoritesDatabaseWrapper {
  static databaseName = "Favorites";
  static objectStoreName = `user${Utils.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;
  }

  createObjectStore() {
    return 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 = {};
    let database;

    await this.openConnection()
      .then(async(connectionEvent) => {
        /**
         * @type {IDBDatabase}
         */
        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);
        };
      }).catch(async(error) => {
        this.version += 1;

        if (error.name === "NotFoundError") {
          database.close();
          await this.createObjectStore();
        }
        this.loadFavorites(idsToDelete);
      });
  }

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

`
  };

  /**
   * @type {Function}
   */
  onFavoritesStored;
  /**
   * @type {Function}
   */
  onFavoritesLoaded;
  /**
   * @type {Worker}
   */
  databaseWorker;
  /**
   * @type {String[]}
   */
  favoriteIdsRequiringMetadataDatabaseUpdate;
  /**
   * @type {Number}
   */
  newMetadataReceivedTimeout;

  /**
   * @param {Function} onFavoritesStored
   * @param {Function} onFavoritesLoaded
   */
  constructor(onFavoritesStored, onFavoritesLoaded) {
    this.onFavoritesStored = onFavoritesStored;
    this.onFavoritesLoaded = onFavoritesLoaded;
    this.favoriteIdsRequiringMetadataDatabaseUpdate = [];
    this.addEventListeners();
    this.initializeDatabase();
  }

  addEventListeners() {
    window.addEventListener("missingMetadata", (event) => {
      this.addNewMetadata(event.detail);
    });
  }

  initializeDatabase() {
    this.databaseWorker = new Worker(Utils.getWorkerURL(FavoritesDatabaseWrapper.webWorkers.database));
    this.databaseWorker.onmessage = (message) => {
      switch (message.data.response) {
        case "finishedLoading":
          this.onFavoritesLoaded(message.data.favorites);
          break;

        case "finishedStoring":
          this.onFavoritesStored();
          break;

        default:
          break;
      }
    };
    this.databaseWorker.postMessage({
      command: "create",
      objectStoreName: FavoritesDatabaseWrapper.objectStoreName,
      version: 1
    });
  }

  /**
   * @returns {String[]}
   */
  getIdsToDeleteOnReload() {
    if (Utils.userIsOnTheirOwnFavoritesPage()) {
      const idsToDelete = Utils.getIdsToDeleteOnReload();

      Utils.clearIdsToDeleteOnReload();
      return idsToDelete;
    }
    return [];
  }

  /**
   * @param {Post[]} favorites
   */
  storeAllFavorites(favorites) {
    this.storeFavorites(favorites.slice().reverse());
  }

  /**
   * @param {Post[]} favorites
   */
  async storeFavorites(favorites) {
    await Utils.sleep(500);

    this.databaseWorker.postMessage({
      command: "store",
      favorites: favorites.map(post => post.databaseRecord)
    });
  }

  loadAllFavorites() {
    this.databaseWorker.postMessage({
      command: "load",
      idsToDelete: this.getIdsToDeleteOnReload()
    });
  }

  /**
   * @param {String} postId
   */
  addNewMetadata(postId) {
    if (!Post.allPosts.has(postId)) {
      return;
    }
    const batchSize = 500;
    const waitTime = 1000;

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

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

  updateMetadataInDatabase() {
    this.updateFavorites(this.favoriteIdsRequiringMetadataDatabaseUpdate.map(id => Post.allPosts.get(id)));
    this.favoriteIdsRequiringMetadataDatabaseUpdate = [];
  }

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

class FavoritesLoader {
  static states = {
    initial: 0,
    fetchingFavorites: 1,
    loadingFavoritesFromDatabase: 2,
    allFavoritesLoaded: 3
  };
  static currentState = FavoritesLoader.states.initial;
  static tagNegation = {
    useTagBlacklist: true,
    negatedTagBlacklist: Utils.negateTags(Utils.tagBlacklist)
  };

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

  /**
   * @type {Post[]}
   */
  allFavorites;
  /**
   * @type {Post[]}
   */
  latestSearchResults;
  /**
   * @type {HTMLLabelElement}
   */
  matchCountLabel;
  /**
   * @type {Number}
   */
  searchResultCount;
  /**
   * @type {Number | null}
   */
  expectedTotalFavoritesCount;
  /**
   * @type {String}
   */
  searchQuery;
  /**
   * @type {Post[]}
   */
  searchResultsWhileFetching;
  /**
   * @type {Number}
   */
  allowedRatings;
  /**
   * @type {FavoritesFetcher}
   */
  fetcher;
  /**
   * @type {FetchedFavoritesQueue}
   */
  fetchedQueue;
  /**
   * @type {FavoritesPaginator}
   */
  paginator;
  /**
   * @type {FavoritesSearchFlags}
   */
  searchFlags;
  /**
   * @type {FavoritesDatabaseWrapper}
   */
  database;

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

  /**
   * @type {Set.<String>}
   */
  get allFavoriteIds() {
    return new Set(Array.from(this.allFavorites.values()).map(post => post.id));
  }

  /**
   * @type {Post[]}
   */
  get getFavoritesMatchedByLastSearch() {
    return this.allFavorites.filter(post => post.matchedByMostRecentSearch);
  }

  constructor() {
    if (FavoritesLoader.disabled) {
      return;
    }
    this.initializeFields();
    this.initializeComponents();
    this.addEventListeners();
    this.setExpectedFavoritesCount();
    Utils.clearOriginalFavoritesPage();
    this.searchFavorites();
  }

  initializeFields() {
    this.allFavorites = [];
    this.latestSearchResults = [];
    this.searchResultsWhileFetching = [];
    this.matchCountLabel = document.getElementById("match-count-label");
    this.allowedRatings = Utils.loadAllowedRatings();
    this.expectedTotalFavoritesCount = null;
    this.searchResultCount = 0;
    this.searchQuery = "";
  }

  initializeComponents() {
    this.fetchedQueue = new FetchedFavoritesQueue((request) => {
      this.processFetchedFavorites(request.fetchedFavorites);
    });
    this.fetcher = new FavoritesFetcher(() => {
      this.onAllFavoritesFetched();
    }, (request) => {
      this.fetchedQueue.enqueue(request);
    });
    this.paginator = new FavoritesPaginator();
    this.searchFlags = new FavoritesSearchFlags();
    this.database = new FavoritesDatabaseWrapper(() => {
      this.onFavoritesStoredToDatabase();
    }, (favorites) => {
      this.onAllFavoritesLoadedFromDatabase(favorites);
    });
  }

  addEventListeners() {
    window.addEventListener("modifiedTags", () => {
      this.searchFlags.tagsWereModified = true;
    });
    window.addEventListener("reachedEndOfGallery", (event) => {
      this.paginator.changePageWhileInGallery(event.detail, this.latestSearchResults);
    });
  }

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

    fetch(profileURL)
      .then((response) => {
        if (response.ok) {
          return response.text();
        }
        throw new Error(response.status);
      })
      .then((html) => {
        const table = new DOMParser().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.expectedTotalFavoritesCount = favoritesCount;
      })
      .catch(() => {
        console.error(`Could not find total favorites count from ${profileURL}, are you logged in?`);
      });
  }

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

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

  showSearchResults() {
    switch (FavoritesLoader.currentState) {
      case FavoritesLoader.states.initial:
        this.loadAllFavoritesFromDatabase();
        break;

      case FavoritesLoader.states.fetchingFavorites:
        this.showSearchResultsWhileFetchingFavorites();
        break;

      case FavoritesLoader.states.loadingFavoritesFromDatabase:
        break;

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

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

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

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

  /**
   * @param {Post[]} posts
   * @returns {Post[]}
   */
  getSearchResults(posts) {
    const searchCommand = new SearchCommand(this.finalSearchQuery);
    const results = [];

    for (const post of posts) {
      if (searchCommand.matches(post)) {
        results.push(post);
        post.setMatched(true);
      } else {
        post.setMatched(false);
      }
    }
    return results;
  }

  fetchNewFavoritesOnReload() {
    this.fetcher.onAllFavoritesPageRequestsCompleted = (newFavorites) => {
      this.addNewFavoritesOnReload(newFavorites);
    };
    this.fetcher.fetchAllNewFavoritesOnReload(this.allFavoriteIds);
  }

  /**
   * @param {Post[]} newFavorites
   */
  addNewFavoritesOnReload(newFavorites) {
    this.allFavorites = newFavorites.concat(this.allFavorites);
    this.latestSearchResults = newFavorites.concat(this.latestSearchResults);

    if (newFavorites.length === 0) {
      dispatchEvent(new CustomEvent("newFavoritesFetchedOnReload", {
        detail: {
          empty: true,
          thumbs: []
        }
      }));
      this.toggleStatusText(false);
      return;
    }
    this.setStatusText(`Found ${newFavorites.length} new favorite${newFavorites.length === 1 ? "" : "s"}`);
    this.toggleStatusText(false, 1000);
    this.database.storeFavorites(newFavorites);
    this.insertNewFavorites(newFavorites);
  }

  fetchAllFavorites() {
    FavoritesLoader.currentState = FavoritesLoader.states.fetchingFavorites;
    this.paginator.toggleContentVisibility(true);
    this.paginator.insertPaginationMenuContainer();
    this.paginator.createPaginationMenu(1, []);
    this.fetcher.fetchAllFavorites();
    dispatchEvent(new Event("readyToSearch"));
    setTimeout(() => {
      dispatchEvent(new Event("startedFetchingFavorites"));
    }, 50);
  }

  updateStatusWhileFetching() {
    let statusText = `Fetching Favorites ${this.allFavorites.length}`;

    if (this.expectedTotalFavoritesCount !== null) {
      statusText = `${statusText} / ${this.expectedTotalFavoritesCount}`;
    }
    this.setStatusText(statusText);
  }

  /**
   * @param {Post[]} favorites
   */
  processFetchedFavorites(favorites) {
    const matchedFavorites = this.getSearchResults(favorites);

    this.searchResultsWhileFetching = this.searchResultsWhileFetching.concat(matchedFavorites);
    const searchResultsWhileFetchingWithAllowedRatings = this.getResultsWithAllowedRatings(this.searchResultsWhileFetching);

    this.updateMatchCount(searchResultsWhileFetchingWithAllowedRatings.length);
    this.allFavorites = this.allFavorites.concat(favorites);
    this.addFetchedFavoritesToContent(searchResultsWhileFetchingWithAllowedRatings);
    this.updateStatusWhileFetching();
    dispatchEvent(new CustomEvent("favoritesFetched", {
      detail: favorites.map(post => post.root)
    }));
  }

  invertSearchResults() {
    this.resetMatchCount();
    this.allFavorites.forEach((post) => {
      post.toggleMatched();
    });
    const invertedSearchResults = this.getFavoritesMatchedByLastSearch;

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

  shuffleSearchResults() {
    const matchedPosts = this.getFavoritesMatchedByLastSearch;

    Utils.shuffleArray(matchedPosts);
    this.searchFlags.searchResultsAreShuffled = true;
    this.paginateSearchResults(matchedPosts);
  }

  onAllFavoritesFetched() {
    this.latestSearchResults = this.getResultsWithAllowedRatings(this.searchResultsWhileFetching);
    dispatchEvent(new CustomEvent("newSearchResults", {
      detail: this.latestSearchResults
    }));
    this.onAllFavoritesLoaded();
    this.database.storeAllFavorites(this.allFavorites);
    this.setStatusText("Saving favorites");
  }

  /**
   * @param {Object[]} records
   */
  onAllFavoritesLoadedFromDatabase(records) {
    this.toggleLoadingUI(false);

    if (records.length === 0) {
      this.fetchAllFavorites();
      return;
    }
    this.setStatusText("All favorites loaded");
    this.paginateSearchResults(this.deserializeFavorites(records));
    this.onAllFavoritesLoaded();
    setTimeout(() => {
      this.fetchNewFavoritesOnReload();
    }, 100);
  }

  onFavoritesStoredToDatabase() {
    this.setStatusText("All favorites saved");
    this.toggleStatusText(false, 1000);
  }

  onAllFavoritesLoaded() {
    dispatchEvent(new Event("readyToSearch"));
    dispatchEvent(new Event("favoritesLoaded"));
    FavoritesLoader.currentState = FavoritesLoader.states.allFavoritesLoaded;
  }

  /**
   * @param {Boolean} value
   */
  toggleLoadingUI(value) {
    this.showLoadingWheel(value);
    this.paginator.toggleContentVisibility(!value);
  }

  /**
   * @param {Object[]} records
   * @returns {Post[]}}
   */
  deserializeFavorites(records) {
    const searchCommand = new SearchCommand(this.finalSearchQuery);
    const searchResults = [];

    for (const record of records) {
      const post = new Post(record, true);
      const isBlacklisted = !searchCommand.matches(post);

      if (isBlacklisted) {
        if (!Utils.userIsOnTheirOwnFavoritesPage()) {
          continue;
        }
        post.setMatched(false);
      } else {
        searchResults.push(post);
      }
      this.allFavorites.push(post);
    }
    return searchResults;
  }

  loadAllFavoritesFromDatabase() {
    FavoritesLoader.currentState = FavoritesLoader.states.loadingFavoritesFromDatabase;
    this.toggleLoadingUI(true);
    this.setStatusText("Loading favorites");
    this.database.loadAllFavorites();
  }

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

  /**
   * @param {Boolean} value
   * @param {Number} delay
   */
  async toggleStatusText(value, delay) {
    if (delay !== undefined && delay > 0) {
      await Utils.sleep(delay);
    }
    document.getElementById("favorites-load-status-label").style.display = value ? "inline-block" : "none";
  }

  /**
   * @param {String} text
   * @param {Number} delay
   */
  async setStatusText(text, delay) {
    if (delay !== undefined && delay > 0) {
      await Utils.sleep(delay);
    }
    document.getElementById("favorites-load-status-label").textContent = text;
  }

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

  /**
   * @param {Number} value
   */
  updateMatchCount(value) {
    if (!this.matchCountLabelExists) {
      return;
    }
    this.searchResultCount = value === undefined ? this.getSearchResults(this.allFavorites).length : value;
    const suffix = this.searchResultCount === 1 ? "Match" : "Matches";

    this.matchCountLabel.textContent = `${this.searchResultCount} ${suffix}`;
  }

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

  /**
   * @param {Post[]} newPosts
   */
  async insertNewFavorites(newPosts) {
    const searchCommand = new SearchCommand(this.finalSearchQuery);
    const insertedPosts = [];
    const metadataPopulateWaitTime = 1000;

    newPosts.reverse();

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

    for (const post of newPosts) {
      if (this.matchesSearchAndRating(searchCommand, post)) {
        this.paginator.insertNewFavorite(post);
        insertedPosts.push(post);
      }
    }
    this.paginator.createPaginationMenu(this.paginator.currentPageNumber, this.getFavoritesMatchedByLastSearch);
    setTimeout(() => {
      dispatchEvent(new CustomEvent("newFavoritesFetchedOnReload", {
        detail: {
          empty: false,
          thumbs: insertedPosts.map(post => post.root)
        }
      }));
    }, 250);
    dispatchEvent(new CustomEvent("newSearchResults", {
      detail: this.latestSearchResults
    }));
  }

  /**
   * @param {Post[]} favorites
   */
  addFetchedFavoritesToContent(favorites) {
    this.paginator.paginateWhileFetching(favorites);
  }

  /**
   * @param {Post[]} searchResults
   */
  paginateSearchResults(searchResults) {
    if (!this.searchFlags.aNewSearchCouldProduceDifferentResults) {
      return;
    }
    searchResults = this.sortPosts(searchResults);
    searchResults = this.getResultsWithAllowedRatings(searchResults);
    this.latestSearchResults = searchResults;
    this.updateMatchCount(searchResults.length);
    this.paginator.paginate(searchResults);
    this.searchFlags.resetFlagsImplyingDifferentSearchResults();
    dispatchEvent(new CustomEvent("newSearchResults", {
      detail: searchResults
    }));
  }

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

  /**
   * @param {Number} value
   */
  updateResultsPerPage(value) {
    this.paginator.maxFavoritesPerPage = value;
    this.searchFlags.recentlyChangedResultsPerPage = true;
    this.searchFavorites();
  }

  /**
   * @param {Post[]} posts
   * @returns {Post[]}
   */
  sortPosts(posts) {
    if (this.searchFlags.searchResultsAreShuffled) {
      return posts;
    }
    const sortedPosts = posts.slice();
    const sortingMethod = Utils.getSortingMethod();

    if (sortingMethod !== "default") {
      sortedPosts.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()) {
      sortedPosts.reverse();
    }
    return sortedPosts;
  }

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

  onSortingParametersChanged() {
    this.searchFlags.sortingParametersWereChanged = true;
    const matchedPosts = this.getFavoritesMatchedByLastSearch;

    this.paginateSearchResults(matchedPosts);
    dispatchEvent(new Event("sortingParametersChanged"));
  }

  /**
   * @param {Number} allowedRatings
   */
  onAllowedRatingsChanged(allowedRatings) {
    this.allowedRatings = allowedRatings;
    this.searchFlags.allowedRatingsWereChanged = true;
    const matchedPosts = this.getFavoritesMatchedByLastSearch;

    this.paginateSearchResults(matchedPosts);
  }

  /**
   * @returns {Boolean}
   */
  allRatingsAreAllowed() {
    return this.allowedRatings === 7;
  }

  /**
   * @param {Post} post
   * @returns {Boolean}
   */
  ratingIsAllowed(post) {
    if (this.allRatingsAreAllowed()) {
      return true;
    }
    // eslint-disable-next-line no-bitwise
    return (post.metadata.rating & this.allowedRatings) > 0;
  }

  /**
   * @param {Post[]} searchResults
   * @returns {Post[]}
   */
  getResultsWithAllowedRatings(searchResults) {
    if (this.allRatingsAreAllowed()) {
      return searchResults;
    }
    return searchResults.filter(post => this.ratingIsAllowed(post));
  }

  /**
   * @param {SearchCommand} searchCommand
   * @param {Post} post
   * @returns {Boolean}
   */
  matchesSearchAndRating(searchCommand, post) {
    return this.ratingIsAllowed(post) && searchCommand.matches(post);
  }

  /**
   * @param {String} id
   */
  findFavorite(id) {
    this.paginator.findFavorite(id);
  }
}

class FavoritesMenu {
  static uiHTML = `
<div id="favorites-search-gallery-menu" class="light-green-gradient not-highlightable">
  <style>
    #favorites-search-gallery-menu {
      position: sticky;
      top: 0;
      padding: 10px;
      z-index: 30;
      margin-bottom: 10px;

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

    #favorites-search-gallery-menu-panels {
      >div {
        flex: 1;
      }
    }

    #left-favorites-panel {
      flex: 10 !important;

      >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 {
      flex: 9 !important;
      margin-left: 30px;
      display: none;
    }

    textarea {
      max-width: 100%;
      height: 50px;
      width: 99%;
      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);
      }
    }

    .add-or-remove-button {
      position: absolute;
      left: 0;
      top: 0;
      width: 40%;
      font-weight: bold;
      background: none;
      border: none;
      z-index: 2;
      filter: grayscale(70%);

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

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

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

    .statistic-hint {
      position: absolute;
      z-index: 3;
      text-align: center;
      right: 0;
      top: 0;
      background: white;
      color: #0075FF;
      font-weight: bold;
      /* font-size: 18px; */
      pointer-events: none;
      font-size: calc(8px + (20 - 8) * ((100vw - 300px) / (3840 - 300)));
      width: 55%;
      padding: 2px 0px;
      border-bottom-left-radius: 4px;
    }

    img {
      -webkit-user-drag: none;
      -khtml-user-drag: none;
      -moz-user-drag: none;
      -o-user-drag: none;
    }

    .favorite {
      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 {
        display: block;
        overflow: hidden;
        position: relative;
        cursor: default;

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

        /* &:has(.add-or-remove-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);
      }
    }

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

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

      >input {
        width: 75px;
        /* border-radius: 6px;
        height: 35px;
        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;
        }
      }
    }

    #favorites-search-gallery-content {
      padding: 0px 20px 30px 20px;
      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;
      right: 0;
      transform: translateX(25%);
      font-style: normal;
      font-weight: normal;
      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;
      }

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

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

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

      >div {
        flex: 1;
      }

      .number {
        font-size: 16px;

        >input {
          width: 5ch;
        }
      }
    }

    #additional-favorite-options {
      >div:not(:last-child) {
        padding-bottom: 10px;
      }

      select {
        cursor: pointer;
        min-height: 25px;
      }
    }

    .number-label-container {
      display: inline-block;
      min-width: 130px;
    }

    #performance-profile {
      width: 150px;
    }

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

    #rating-container {
      white-space: nowrap;
    }

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

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

    .add-or-remove-button {
      visibility: hidden;
      cursor: pointer;
    }

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

    #favorites-load-status-label {
      /* color: #3498db; */
      padding-left: 20px;
    }

    #main-favorite-options-container {
      display: flex;
      flex-wrap: wrap;
      flex-direction: row;

      >div {
        flex-basis: 45%;
      }
    }

    #sort-ascending {
      position: absolute;
      top: -2px;
      left: 150px;
    }

    #find-favorite-input {
      border: none !important;
    }

    div#header {
      margin-bottom: 0 !important;
    }

    body {

      &:fullscreen,
      &::backdrop {
        background-color: var(--c-bg);
      }
    }
  </style>
  <div id="favorites-search-gallery-menu-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-load-status-label"></label>
      </span>
      <div id="left-favorites-panel-top-row">
        <button title="Search favorites
ctrl+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>
        <button title="Delete cached favorites and reset 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.17.2:</h4>
              <h5>Features:</h5>
              <ul>
                <li>Redirect to original image</li>
                <ul>
                  <li>Ctrl+Click on thumbnail: redirect to original image, but stay on current tab</li>
                  <li>Ctrl+Shift+Click on thumbnail: redirect to original image</li>
                  <li>Works on both favorites and search pages</li>
                  <li>Works both in and out of gallery</li>
                </ul>
              </ul>
              <h4>1.17:</h4>
              <h5>Features:</h5>
              <ul>
                <li>Added autoplay</li>
                <li>Added new hotkeys and hints for them</li>
                <li>Gallery now auto changes to next/previous page rather tha looping to start of same page</li>
                <ul>
                  <li sty>Basically, you can view every single favorite without ever exiting the gallery</li>
                </ul>
                <li>Middle click on tag in "details" to quickly search for it</li>
                <li>Changed UI</li>
              </ul>
              <h5> Notes/Fixes:</h5>
              <ul>
                <li>
                  <strong>
                    A large site update is ongoing, creating new bugs
                  </strong>
                </li>
                <li>I'm fixing anything I find, but please report any issues you find also</li>
              </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="bottom-panel-1">
          <label class="checkbox" title="Show more options">
            <input type="checkbox" id="options-checkbox"> More Options
            <span class="option-hint"> (O)</span>
          </label>
          <div class="options-container">
            <div id="main-favorite-options-container">
              <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
                    <span class="option-hint"> (R)</span>
                  </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
                    <span class="option-hint"> (R)</span>
                  </label>
                </div>
                <div>
                  <label class="checkbox" title="Exclude favorites with 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 style="display: none;">
                  <label class="checkbox" title="Enable fancy image hovering (experimental)">
                    <input type="checkbox" id="statistic-hint-checkbox"> Statistics
                    <span class="option-hint"> (S)</span>
                  </label>
                </div>
                <div id="show-hints-container">
                  <label class="checkbox" title="Show hotkeys and shortcuts">
                    <input type="checkbox" id="show-hints-checkbox"> Hotkey Hints
                    <span class="option-hint"> (H)</span>
                  </label>
                </div>
              </div>
              <div id="dynamic-favorite-options">
              </div>
            </div>
          </div>
        </div>

        <div id="bottom-panel-2">
          <div id="additional-favorite-options-container" class="options-container">
            <div id="additional-favorite-options">
              <div id="sort-container" title="Change sorting order of search results">
                <label style="margin-right: 22px;" for="sorting-method">Sort By</label>
                <label style="margin-left:  22px;" for="sort-ascending">Ascending</label>
                <div style="position: relative;">
                  <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>
                  </select>
                  <input type="checkbox" id="sort-ascending">
                </div>
              </div>

              <div>
                <div id="results-per-page-container" style="display: inline-block;"
                  title="Set the maximum number of search results to display on each page
Lower numbers improve responsiveness">
                  <span class="number-label-container">
                    <label id="results-per-page-label" for="results-per-page-input">Results per Page</label>
                  </span>
                  <br>
                  <span class="number">
                    <hold-button class="number-arrow-down" pollingtime="50">
                      <span>&lt;</span>
                    </hold-button>
                    <input type="number" id="results-per-page-input" min="100" max="10000" step="50">
                    <hold-button class="number-arrow-up" pollingtime="50">
                      <span>&gt;</span>
                    </hold-button>
                  </span>
                </div>
                <div id="column-resize-container" title="Set the number of favorites per row"
                  style="display: inline-block;">
                  <div>
                    <span class="number-label-container">
                      <label>Columns</label>
                    </span>
                    <br>
                    <span class="number">
                      <hold-button class="number-arrow-down" pollingtime="50">
                        <span>&lt;</span>
                      </hold-button>
                      <input type="number" id="column-resize-input" min="2" max="20">
                      <hold-button class="number-arrow-up" pollingtime="50">
                        <span>&gt;</span>
                      </hold-button>
                    </span>
                  </div>
                </div>
              </div>
              <div id="rating-container" title="Filter search results by rating">
                <label>Rating</label>
                <br>
                <div id="allowed-ratings" class="not-highlightable">
                  <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>
          </div>
        </div>

        <div id="bottom-panel-3">
          <div id="show-ui-div">
            <label class="checkbox" title="Toggle UI">
              <input type="checkbox" id="show-ui">UI
              <span class="option-hint"> (U)</span>
            </label>
          </div>
          <div class="options-container">
            <span id="find-favorite" style="display: none;">
              <button title="Find favorite favorite using its ID" id="find-favorite-button"
                style="white-space: nowrap;">Find</button>
              <input type="number" id="find-favorite-input" placeholder="ID">
            </span>
          </div>
        </div>

        <div id="bottom-panel-4">

        </div>
      </div>
    </div>
    <div id="right-favorites-panel"></div>
  </div>
  <div class="loading-wheel" id="loading-wheel" style="display: none;"></div>
</div>
`;

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

  static {
    Utils.addStaticInitializer(() => {
      if (Utils.onFavoritesPage()) {
        Utils.insertFavoritesSearchGalleryHTML("afterbegin", FavoritesMenu.uiHTML);
      }
    });
  }

  /**
   * @type {Number}
   */
  maxSearchHistoryLength;
  /**
   * @type {Object.<PropertyKey, String>}
   */
  preferences;
  /**
   * @type {Object.<PropertyKey, String>}
   */
  localStorageKeys;
  /**
   * @type {Object.<PropertyKey, HTMLButtonElement>}
   */
  buttons;
  /**
   * @type {Object.<PropertyKey, HTMLInputElement}
   */
  checkboxes;
  /**
   * @type {Object.<PropertyKey, HTMLInputElement}
   */
  inputs;
  /**
   * @type {Cooldown}
   */
  columnWheelResizeCaptionCooldown;
  /**
   * @type {String[]}
   */
  searchHistory;
  /**
   * @type {Number}
   */
  searchHistoryIndex;
  /**
   * @type {String}
   */
  lastSearchQuery;

  constructor() {
    if (FavoritesMenu.disabled) {
      return;
    }
    this.configureMobileUI();
    this.initializeFields();
    this.extractUIElements();
    this.setMainButtonInteractability(false);
    this.addEventListenersToFavoritesPage();
    this.loadFavoritesPagePreferences();
    this.removePaginatorFromFavoritesPage();
    this.configureAddOrRemoveButtonOptionVisibility();
    this.configureDesktopUI();
    this.addEventListenersToWhatsNewMenu();
    this.addHintsOption();
  }

  initializeFields() {
    this.maxSearchHistoryLength = 100;
    this.searchHistory = [];
    this.searchHistoryIndex = 0;
    this.lastSearchQuery = "";
    this.preferences = {
      showAddOrRemoveButtons: Utils.userIsOnTheirOwnFavoritesPage() ? "showRemoveButtons" : "showAddFavoriteButtons",
      showOptions: "showOptions",
      excludeBlacklist: "excludeBlacklist",
      searchHistory: "favoritesSearchHistory",
      findFavorite: "findFavorite",
      thumbSize: "thumbSize",
      columnCount: "columnCount",
      showUI: "showUI",
      performanceProfile: "performanceProfile",
      resultsPerPage: "resultsPerPage",
      fancyImageHovering: "fancyImageHovering",
      enableOnSearchPages: "enableOnSearchPages",
      sortAscending: "sortAscending",
      sortingMethod: "sortingMethod",
      allowedRatings: "allowedRatings",
      showHotkeyHints: "showHotkeyHints",
      showStatisticHints: "showStatisticHints"
    };
    this.localStorageKeys = {
      searchHistory: "favoritesSearchHistory"
    };
    this.columnWheelResizeCaptionCooldown = new Cooldown(500, true);
  }

  extractUIElements() {
    this.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")
    };
    this.checkboxes = {
      showOptions: document.getElementById("options-checkbox"),
      showAddOrRemoveButtons: Utils.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"),
      showHotkeyHints: document.getElementById("show-hints-checkbox"),
      showStatisticHints: document.getElementById("statistic-hint-checkbox")
    };
    this.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")
    };
  }

  loadFavoritesPagePreferences() {
    const userIsLoggedIn = Utils.getUserId() !== null;
    const showAddOrRemoveButtonsDefault = !Utils.userIsOnTheirOwnFavoritesPage() && userIsLoggedIn;
    const addOrRemoveFavoriteButtonsAreVisible = Utils.getPreference(this.preferences.showAddOrRemoveButtons, showAddOrRemoveButtonsDefault);

    this.checkboxes.showAddOrRemoveButtons.checked = addOrRemoveFavoriteButtonsAreVisible;
    setTimeout(() => {
      this.toggleAddOrRemoveButtons();
    }, 100);

    const showOptions = Utils.getPreference(this.preferences.showOptions, false);

    this.checkboxes.showOptions.checked = showOptions;
    this.toggleFavoritesOptions(showOptions);

    if (Utils.userIsOnTheirOwnFavoritesPage()) {
      this.checkboxes.filterBlacklist.checked = Utils.getPreference(this.preferences.excludeBlacklist, false);
      favoritesLoader.toggleTagBlacklistExclusion(this.checkboxes.filterBlacklist.checked);
    } else {
      this.checkboxes.filterBlacklist.checked = true;
      this.checkboxes.filterBlacklist.parentElement.style.display = "none";
    }
    this.searchHistory = JSON.parse(localStorage.getItem(this.localStorageKeys.searchHistory)) || [];

    if (this.searchHistory.length > 0) {
      this.inputs.searchBox.value = this.searchHistory[0];
    }
    this.inputs.findFavorite.value = Utils.getPreference(this.preferences.findFavorite, "");
    this.inputs.columnCount.value = Utils.getPreference(this.preferences.columnCount, Utils.defaults.columnCount);
    this.changeColumnCount(this.inputs.columnCount.value);

    const showUI = Utils.getPreference(this.preferences.showUI, true);

    this.checkboxes.showUI.checked = showUI;
    this.toggleUI(showUI);

    const performanceProfile = Utils.getPerformanceProfile();

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

    const resultsPerPage = parseInt(Utils.getPreference(this.preferences.resultsPerPage, Utils.defaults.resultsPerPage));

    this.changeResultsPerPage(resultsPerPage);

    if (Utils.onMobileDevice()) {
      Utils.toggleFancyImageHovering(false);
      this.checkboxes.fancyImageHovering.parentElement.style.display = "none";
      this.checkboxes.enableOnSearchPages.parentElement.style.display = "none";
    } else {
      const fancyImageHovering = Utils.getPreference(this.preferences.fancyImageHovering, false);

      this.checkboxes.fancyImageHovering.checked = fancyImageHovering;
      Utils.toggleFancyImageHovering(fancyImageHovering);
    }

    this.checkboxes.enableOnSearchPages.checked = Utils.getPreference(this.preferences.enableOnSearchPages, false);
    this.checkboxes.sortAscending.checked = Utils.getPreference(this.preferences.sortAscending, false);

    const sortingMethod = Utils.getPreference(this.preferences.sortingMethod, "default");

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

    // eslint-disable-next-line no-bitwise
    this.checkboxes.explicitRating.checked = (allowedRatings & 4) === 4;
    // eslint-disable-next-line no-bitwise
    this.checkboxes.questionableRating.checked = (allowedRatings & 2) === 2;
    // eslint-disable-next-line no-bitwise
    this.checkboxes.safeRating.checked = (allowedRatings & 1) === 1;
    this.preventUserFromUncheckingAllRatings(allowedRatings);

    const showStatisticHints = Utils.getPreference(this.preferences.showStatisticHints, false);

    this.checkboxes.showStatisticHints.checked = showStatisticHints;
    this.toggleStatisticHints(showStatisticHints);
  }

  removePaginatorFromFavoritesPage() {
    if (!Utils.onFavoritesPage()) {
      return;
    }
    const paginator = document.getElementById("paginator");
    const pi = document.getElementById("pi");

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

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

  addEventListenersToFavoritesPage() {
    this.buttons.search.onclick = (event) => {
      const query = this.inputs.searchBox.value;

      if (event.ctrlKey) {
        const queryWithFormattedIds = query.replace(/(?:^|\s)(\d+)(?:$|\s)/g, " id:$1 ");

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

      Utils.openSearchPage(queryWithFormattedIds);
      event.preventDefault();
    });
    this.inputs.searchBox.addEventListener("keydown", (event) => {
      switch (event.key) {
        case "Enter":
          if (Utils.awesompleteIsUnselected(this.inputs.searchBox)) {
            event.preventDefault();
            this.buttons.search.click();
          } else {
            Utils.clearAwesompleteSelection(this.inputs.searchBox);
          }
          break;

        case "ArrowUp":

        case "ArrowDown":
          if (Utils.awesompleteIsVisible(this.inputs.searchBox)) {
            this.updateLastSearchQuery();
          } else {
            event.preventDefault();
            this.traverseFavoritesSearchHistory(event.key);
          }
          break;

        default:
          this.updateLastSearchQuery();
          break;
      }
    });
    this.inputs.searchBox.addEventListener("wheel", (event) => {
      if (event.shiftKey || event.ctrlKey) {
        return;
      }
      const direction = event.deltaY > 0 ? "ArrowDown" : "ArrowUp";

      this.traverseFavoritesSearchHistory(direction);
      event.preventDefault();
    });
    this.checkboxes.showOptions.onchange = () => {
      this.toggleFavoritesOptions(this.checkboxes.showOptions.checked);
      Utils.setPreference(this.preferences.showOptions, this.checkboxes.showOptions.checked);
    };
    this.checkboxes.showAddOrRemoveButtons.onchange = () => {
      this.toggleAddOrRemoveButtons();
      Utils.setPreference(this.preferences.showAddOrRemoveButtons, this.checkboxes.showAddOrRemoveButtons.checked);
    };
    this.buttons.shuffle.onclick = () => {
      favoritesLoader.shuffleSearchResults();
    };
    this.buttons.clear.onclick = () => {
      this.inputs.searchBox.value = "";
    };
    this.checkboxes.filterBlacklist.onchange = () => {
      Utils.setPreference(this.preferences.excludeBlacklist, this.checkboxes.filterBlacklist.checked);
      favoritesLoader.toggleTagBlacklistExclusion(this.checkboxes.filterBlacklist.checked);
      favoritesLoader.searchFavorites();
    };
    this.buttons.invert.onclick = () => {
      favoritesLoader.invertSearchResults();
    };
    this.buttons.reset.onclick = () => {
      Utils.deletePersistentData();
    };
    this.inputs.findFavorite.addEventListener("keydown", (event) => {
      if (event.key === "Enter") {
        this.buttons.findFavorite.click();
      }
    });
    this.buttons.findFavorite.onclick = () => {
      favoritesLoader.findFavorite(this.inputs.findFavorite.value);
      Utils.setPreference(this.preferences.findFavorite, this.inputs.findFavorite.value);
    };
    this.inputs.columnCount.onchange = () => {
      this.changeColumnCount(parseInt(this.inputs.columnCount.value));
    };
    this.checkboxes.showUI.onchange = () => {
      this.toggleUI(this.checkboxes.showUI.checked);
    };
    this.inputs.performanceProfile.onchange = () => {
      Utils.setPreference(this.preferences.performanceProfile, parseInt(this.inputs.performanceProfile.value));
      window.location.reload();
    };
    this.inputs.resultsPerPage.onchange = () => {
      this.changeResultsPerPage(parseInt(this.inputs.resultsPerPage.value), false);
    };

    if (!Utils.onMobileDevice()) {
      this.checkboxes.fancyImageHovering.onchange = () => {
        Utils.toggleFancyImageHovering(this.checkboxes.fancyImageHovering.checked);
        Utils.setPreference(this.preferences.fancyImageHovering, this.checkboxes.fancyImageHovering.checked);
      };
    }
    this.checkboxes.enableOnSearchPages.onchange = () => {
      Utils.setPreference(this.preferences.enableOnSearchPages, this.checkboxes.enableOnSearchPages.checked);
    };
    this.checkboxes.sortAscending.onchange = () => {
      Utils.setPreference(this.preferences.sortAscending, this.checkboxes.sortAscending.checked);
      favoritesLoader.onSortingParametersChanged();
    };
    this.inputs.sortingMethod.onchange = () => {
      Utils.setPreference(this.preferences.sortingMethod, this.inputs.sortingMethod.value);
      favoritesLoader.onSortingParametersChanged();
    };
    this.inputs.allowedRatings.onchange = () => {
      this.changeAllowedRatings();
    };
    window.addEventListener("wheel", (event) => {
      if (!event.shiftKey) {
        return;
      }
      const delta = (event.wheelDelta ? event.wheelDelta : -event.deltaY);
      const columnAddend = delta > 0 ? -1 : 1;

      if (this.columnWheelResizeCaptionCooldown.ready) {
        Utils.forceHideCaptions(true);
      }
      this.changeColumnCount(parseInt(this.inputs.columnCount.value) + columnAddend);
    }, {
      passive: true
    });
    this.columnWheelResizeCaptionCooldown.onDebounceEnd = () => {
      Utils.forceHideCaptions(false);
    };
    this.columnWheelResizeCaptionCooldown.onCooldownEnd = () => {
      if (!this.columnWheelResizeCaptionCooldown.debouncing) {
        Utils.forceHideCaptions(false);
      }
    };
    window.addEventListener("readyToSearch", () => {
      this.setMainButtonInteractability(true);
    }, {
      once: true
    });
    document.addEventListener("keydown", (event) => {
      if (!Utils.isHotkeyEvent(event)) {
        return;
      }

      switch (event.key.toLowerCase()) {
        case "r":
          this.checkboxes.showAddOrRemoveButtons.click();
          break;

        case "u":
          this.checkboxes.showUI.click();
          break;

        case "o":
          this.checkboxes.showOptions.click();
          break;

        case "h":
          this.checkboxes.showHotkeyHints.click();
          break;

        case "s":
          // this.FAVORITE_CHECKBOXES.showStatisticHints.click();
          break;

        default:
          break;
      }
    }, {
      passive: true
    });
    window.addEventListener("load", () => {
      this.inputs.searchBox.focus();
    }, {
      once: true
    });
    this.checkboxes.showStatisticHints.onchange = () => {
      this.toggleStatisticHints(this.checkboxes.showStatisticHints.checked);
      Utils.setPreference(this.preferences.showStatisticHints, this.checkboxes.showStatisticHints.checked);
    };
    window.addEventListener("searchForTag", (event) => {
      this.inputs.searchBox.value = event.detail;
      this.buttons.search.click();
    });
  }

  configureAddOrRemoveButtonOptionVisibility() {
    this.checkboxes.showAddOrRemoveButtons.parentElement.parentElement.style.display = "block";
  }

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

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

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

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

  /**
   * @param {Boolean} value
   */
  toggleFavoritesOptions(value) {
    document.querySelectorAll(".options-container").forEach((option) => {
      option.style.display = value ? "block" : "none";
    });
  }

  toggleAddOrRemoveButtons() {
    const value = this.checkboxes.showAddOrRemoveButtons.checked;

    this.toggleAddOrRemoveButtonVisibility(value);
    Utils.toggleThumbHoverOutlines(value);
    Utils.forceHideCaptions(value);

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

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

    Utils.insertStyleHTML(`
        .add-or-remove-button {
          visibility: ${visibility} !important;
        }
      `, "add-or-remove-button-visibility");
  }

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

    if (isNaN(count)) {
      this.inputs.columnCount.value = Utils.getPreference(this.preferences.columnCount, Utils.defaults.columnCount);
      return;
    }
    count = Utils.clamp(parseInt(count), 4, 20);
    Utils.insertStyleHTML(`
      #favorites-search-gallery-content {
        grid-template-columns: repeat(${count}, 1fr) !important;
      }
      `, "column-count");
    this.inputs.columnCount.value = count;
    Utils.setPreference(this.preferences.columnCount, count);
  }

  /**
   * @param {Number} resultsPerPage
   */
  changeResultsPerPage(resultsPerPage) {
    resultsPerPage = parseInt(resultsPerPage);

    if (isNaN(resultsPerPage)) {
      this.inputs.resultsPerPage.value = Utils.getPreference(this.preferences.resultsPerPage, Utils.defaults.resultsPerPage);
      return;
    }
    resultsPerPage = Utils.clamp(resultsPerPage, 50, 5000);
    this.inputs.resultsPerPage.value = resultsPerPage;
    Utils.setPreference(this.preferences.resultsPerPage, resultsPerPage);
    favoritesLoader.updateResultsPerPage(resultsPerPage);
  }

  /**
   * @param {Boolean} value
   */
  toggleUI(value) {
    const menu = document.getElementById("favorites-search-gallery-menu");
    const menuPanels = document.getElementById("favorites-search-gallery-menu-panels");
    const header = document.getElementById("header");
    const showUIDiv = document.getElementById("show-ui-div");
    const showUIContainer = document.getElementById("bottom-panel-3");

    if (value) {
      header.style.display = "";
      showUIContainer.insertAdjacentElement("afterbegin", showUIDiv);
      menuPanels.style.display = "flex";
      menu.removeAttribute("style");
    } else {
      menu.appendChild(showUIDiv);
      header.style.display = "none";
      menuPanels.style.display = "none";
      menu.style.background = getComputedStyle(document.body).background;
    }
    showUIDiv.classList.toggle("ui-hidden", !value);
    Utils.setPreference(this.preferences.showUI, value);
  }

  configureMobileUI() {
    if (!Utils.onMobileDevice()) {
      return;
    }
    Utils.insertStyleHTML(`
      #performance-profile-container, #show-hints-container {
        display: none !important;
      }
        .thumb, .favorite {
          > div > canvas {
            display: none;
          }
        }

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

        #favorites-search-gallery-menu-panels {
          >div {
            textarea {
              width: 95% !important;
            }
          }
        }

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

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

        #favorites-search-gallery-menu-panels {
          display: block !important;
        }

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

        #left-favorites-panel-bottom-row {
          display: block !important;
          margin-left: 10px !important;
        }

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

        #sort-ascending {
          width: 25px;
          height: 25px;
        }
        `, "mobile");

    const mobileUIContainer = document.createElement("div");

    mobileUIContainer.id = "mobile-container";
    mobileUIContainer.appendChild(document.getElementById("header"));
    mobileUIContainer.appendChild(document.getElementById("favorites-search-gallery-menu"));
    Utils.insertFavoritesSearchGalleryHTML("afterbegin", mobileUIContainer.innerHTML);

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

  configureDesktopUI() {
    if (Utils.onMobileDevice()) {
      return;
    }
    Utils.insertStyleHTML(`
      .checkbox {
        &:hover {
          color: #000;
          background: #93b393;
          text-shadow: none;
          cursor: pointer;
        }

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

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

  addEventListenersToWhatsNewMenu() {
    if (Utils.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");
    };
  }

  changeAllowedRatings() {
    let allowedRatings = 0;

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

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

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

    Utils.setPreference(this.preferences.allowedRatings, allowedRatings);
    favoritesLoader.onAllowedRatingsChanged(allowedRatings);
    this.preventUserFromUncheckingAllRatings(allowedRatings);
  }

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

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

  /**
   * @param {Boolean} value
   */
  toggleOptionHints(value) {
    const html = value ? "" : ".option-hint {display:none;}";

    Utils.insertStyleHTML(html, "option-hint-visibility");
  }

  async addHintsOption() {
    this.toggleOptionHints(false);

    await Utils.sleep(50);

    if (Utils.onMobileDevice()) {
      return;
    }
    const optionHintsEnabled = Utils.getPreference(this.preferences.showHotkeyHints, false);

    this.checkboxes.showHotkeyHints.checked = optionHintsEnabled;
    this.checkboxes.showHotkeyHints.onchange = () => {
      this.toggleOptionHints(this.checkboxes.showHotkeyHints.checked);
      Utils.setPreference(this.preferences.showHotkeyHints, this.checkboxes.showHotkeyHints.checked);
    };
    this.toggleOptionHints(optionHintsEnabled);
  }

  /**
   * @param {Boolean} value
   */
  toggleStatisticHints(value) {
    const html = value ? "" : ".statistic-hint {display:none;}";

    Utils.insertStyleHTML(html, "statistic-hint-visibility");
  }
}

class AutoplayListenerList {
  /**
   * @type {Function}
   */
  onEnable;
  /**
   * @type {Function}
   */
  onDisable;
  /**
   * @type {Function}
   */
  onPause;
  /**
   * @type {Function}
   */
  onResume;
  /**
   * @type {Function}
   */
  onComplete;
  /**
   * @type {Function}
   */
  onVideoEndedBeforeMinimumViewTime;

  /**
   * @param {Function} onEnable
   * @param {Function} onDisable
   * @param {Function} onPause
   * @param {Function} onResume
   * @param {Function} onComplete
   * @param {Function} onVideoEndedEarly
   */
  constructor(onEnable, onDisable, onPause, onResume, onComplete, onVideoEndedEarly) {
    this.onEnable = onEnable;
    this.onDisable = onDisable;
    this.onPause = onPause;
    this.onResume = onResume;
    this.onComplete = onComplete;
    this.onVideoEndedBeforeMinimumViewTime = onVideoEndedEarly;
  }
}

class Autoplay {
  static autoplayHTML = `
<div id="autoplay-container">
  <style>
    #autoplay-container {
      visibility: hidden;
    }

    #autoplay-menu {
      position: fixed;
      left: 50%;
      transform: translate(-50%);
      bottom: 5%;
      padding: 0;
      margin: 0;
      background: rgba(40, 40, 40, 1);
      border-radius: 4px;
      white-space: nowrap;
      z-index: 10000;
      opacity: 0;
      transition: opacity .25s ease-in-out;

      &.visible {
        opacity: 1;
      }

      &.persistent {
        opacity: 1 !important;
        visibility: visible !important;
      }

      >div>img {
        color: red;
        position: relative;
        height: 75px;
        cursor: pointer;
        background-color: rgba(128, 128, 128, 0);
        margin: 5px;
        background-size: 10%;
        z-index: 3;
        border-radius: 4px;


        &:hover {
          background-color: rgba(200, 200, 200, .5);
        }
      }
    }

    .autoplay-progress-bar {
      position: absolute;
      top: 0;
      left: 0;
      width: 0%;
      height: 100%;
      background-color: steelblue;
      z-index: 1;
    }

    #autoplay-video-progress-bar {
      background-color: royalblue;
    }

    #autoplay-settings-menu {
      visibility: hidden;
      position: absolute;
      top: 0;
      left: 50%;
      transform: translate(-50%, -105%);
      border-radius: 4px;
      font-size: 10px !important;
      background: rgba(40, 40, 40, 1);

      &.visible {
        visibility: visible;
      }

      >div {
        font-size: 30px;
        display: flex;
        justify-content: space-between;
        align-items: center;
        padding: 5px 10px;
        color: white;


        >label {
          padding-right: 20px;
        }

        >.number {
          background: none;
          outline: 2px solid white;

          >hold-button,
          >button {
            &::after {
              width: 200%;
              height: 130%;
            }
          }

          >input[type="number"] {
            color: white;
            width: 7ch;
          }
        }
      }
    }

    #autoplay-settings-button.settings-menu-opened {
      filter: drop-shadow(6px 6px 3px #0075FF);
    }


    #autoplay-change-direction-mask {
      filter: drop-shadow(2px 2px 3px #0075FF);
    }

    #autoplay-play-button:active {
      filter: drop-shadow(2px 2px 10px #0075FF);
    }

    #autoplay-change-direction-mask-container {
      pointer-events: none;
      opacity: 0.75;
      height: 75px;
      width: 75px;
      margin: 5px;
      border-radius: 4px;
      right: 0;
      bottom: 0;
      z-index: 4;
      position: absolute;
      clip-path: polygon(0% 0%, 0% 100%, 100% 100%);

      &.upper-right {
        clip-path: polygon(0% 0%, 100% 0%, 100% 100%);
      }
    }

    .autoplay-settings-menu-label {
      pointer-events: none;
    }
  </style>
  <div id="autoplay-menu" class="not-highlightable">
    <div id="autoplay-buttons">
      <img id="autoplay-settings-button" title="Autoplay settings">
      <img id="autoplay-play-button" title="Pause autoplay">
      <img id="autoplay-change-direction-button" title="Change autoplay direction">
      <div id="autoplay-change-direction-mask-container">
        <img id="autoplay-change-direction-mask" title="Change autoplay direction">
      </div>
    </div>
    <div id="autoplay-image-progress-bar" class="autoplay-progress-bar"></div>
    <div id="autoplay-video-progress-bar" class="autoplay-progress-bar"></div>
    <div id="autoplay-settings-menu">
      <div>
        <label for="autoplay-image-duration-input">Image/GIF Duration</label>
        <span class="number">
          <hold-button class="number-arrow-down" pollingtime="100"><span>&lt;</span></hold-button>
          <input type="number" id="autoplay-image-duration-input" min="1" max="60" step="1">
          <hold-button class="number-arrow-up" pollingtime="100"><span>&gt;</span></hold-button>
        </span>
      </div>
      <div>
        <label for="autoplay-minimum-video-duration-input">Minimum Video Duration</label>
        <span class="number">
          <hold-button class="number-arrow-down" pollingtime="100"><span>&lt;</span></hold-button>
          <input type="number" id="autoplay-minimum-animated-duration-input" min="1" max="60" step="1">
          <hold-button class="number-arrow-up" pollingtime="100"><span>&gt;</span></hold-button>
        </span>
      </div>
    </div>
  </div>
</div>
`;
  static preferences = {
    active: "autoplayActive",
    paused: "autoplayPaused",
    imageDuration: "autoplayImageDuration",
    minimumVideoDuration: "autoplayMinimumVideoDuration",
    direction: "autoplayForward"
  };
  static menuIconImageURLs = {
    play: Utils.createObjectURLFromSvg(Utils.icons.play),
    pause: Utils.createObjectURLFromSvg(Utils.icons.pause),
    changeDirection: Utils.createObjectURLFromSvg(Utils.icons.changeDirection),
    changeDirectionAlt: Utils.createObjectURLFromSvg(Utils.icons.changeDirectionAlt),
    tune: Utils.createObjectURLFromSvg(Utils.icons.tune)
  };
  static settings = {
    imageViewDuration: Utils.getPreference(Autoplay.preferences.imageDuration, 3000),
    minimumVideoDuration: Utils.getPreference(Autoplay.preferences.minimumVideoDuration, 5000),
    menuVisibilityDuration: 500,
    moveForward: Utils.getPreference(Autoplay.preferences.direction, true),

    get imageViewDurationInSeconds() {
      return Utils.millisecondsToSeconds(this.imageViewDuration);
    },

    get minimumVideoDurationInSeconds() {
      return Utils.millisecondsToSeconds(this.minimumVideoDuration);
    }
  };

  /**
   * @type {Boolean}
   */
  static get disabled() {
    return Utils.onMobileDevice();
  }

  /**
   * @type {{
   * container: HTMLDivElement,
   * menu: HTMLDivElement,
   * settingsButton: HTMLImageElement,
   * settingsMenu: {
   *  container: HTMLDivElement
   *  imageDurationInput: HTMLInputElement,
   *  minimumVideoDurationInput: HTMLInputElement,
   * }
   * playButton: HTMLImageElement,
   * changeDirectionButton: HTMLImageElement,
   * changeDirectionMask: {
   *   container: HTMLDivElement,
   *   image: HTMLImageElement
   * },
   * imageProgressBar: HTMLDivElement
   * videoProgressBar: HTMLDivElement
   * }}
   */
  ui;
  /**
   * @type {AutoplayListenerList}
   */
  events;
  /**
   * @type {AbortController}
   */
  eventListenersAbortController;
  /**
   * @type {HTMLElement}
   */
  currentThumb;
  /**
   * @type {Cooldown}
   */
  imageViewTimer;
  /**
   * @type {Cooldown}
   */
  menuVisibilityTimer;
  /**
   * @type {Cooldown}
   */
  videoViewTimer;
  /**
   * @type {Boolean}
   */
  active;
  /**
   * @type {Boolean}
   */
  paused;
  /**
   * @type {Boolean}
   */
  menuIsPersistent;
  /**
   * @type {Boolean}
   */
  menuIsVisible;

  /**
   * @param {AutoplayListenerList} events
   */
  constructor(events) {
    if (Autoplay.disabled) {
      return;
    }
    this.initializeEvents(events);
    this.initializeFields();
    this.initializeTimers();
    this.insertHTML();
    this.setMenuIconImageSources();
    this.loadAutoplaySettingsIntoUI();
    this.addEventListeners();
  }

  /**
   * @param {AutoplayListenerList} events
   */
  initializeEvents(events) {
    this.events = events;

    const onComplete = events.onComplete;

    this.events.onComplete = () => {
      if (this.active && !this.paused) {
        onComplete();
      }
    };
  }

  initializeFields() {
    this.ui = {
      settingsMenu: {},
      changeDirectionMask: {}
    };
    this.eventListenersAbortController = new AbortController();
    this.currentThumb = null;
    this.active = Utils.getPreference(Autoplay.preferences.active, false);
    this.paused = Utils.getPreference(Autoplay.preferences.paused, false);
    this.menuIsPersistent = false;
    this.menuIsVisible = false;
  }

  initializeTimers() {
    this.imageViewTimer = new Cooldown(Autoplay.settings.imageViewDuration);
    this.menuVisibilityTimer = new Cooldown(Autoplay.settings.menuVisibilityDuration);
    this.videoViewTimer = new Cooldown(Autoplay.settings.minimumVideoDuration);

    this.imageViewTimer.onCooldownEnd = () => { };
    this.menuVisibilityTimer.onCooldownEnd = () => {
      this.hideMenu();
      setTimeout(() => {
        if (!this.menuIsPersistent && !this.menuIsVisible) {
          this.toggleSettingMenu(false);
        }
      }, 100);
    };
  }

  insertHTML() {
    this.insertMenuHTML();
    this.insertOptionHTML();
    this.insertImageProgressHTML();
    this.insertVideoProgressHTML();
  }

  insertMenuHTML() {
    Utils.insertFavoritesSearchGalleryHTML("afterbegin", Autoplay.autoplayHTML);
    this.ui.container = document.getElementById("autoplay-container");
    this.ui.menu = document.getElementById("autoplay-menu");
    this.ui.settingsButton = document.getElementById("autoplay-settings-button");
    this.ui.settingsMenu.container = document.getElementById("autoplay-settings-menu");
    this.ui.settingsMenu.imageDurationInput = document.getElementById("autoplay-image-duration-input");
    this.ui.settingsMenu.minimumVideoDurationInput = document.getElementById("autoplay-minimum-animated-duration-input");
    this.ui.playButton = document.getElementById("autoplay-play-button");
    this.ui.changeDirectionButton = document.getElementById("autoplay-change-direction-button");
    this.ui.changeDirectionMask.container = document.getElementById("autoplay-change-direction-mask-container");
    this.ui.changeDirectionMask.image = document.getElementById("autoplay-change-direction-mask");
    this.ui.imageProgressBar = document.getElementById("autoplay-image-progress-bar");
    this.ui.videoProgressBar = document.getElementById("autoplay-video-progress-bar");
  }

  insertOptionHTML() {
    Utils.createFavoritesOption(
      "autoplay",
      "Autoplay",
      "Enable autoplay in gallery",
      this.active,
      (event) => {
        this.toggle(event.target.checked);
      },
      true
    );
  }

  insertImageProgressHTML() {
    Utils.insertStyleHTML(`
      #autoplay-image-progress-bar.animated {
          transition: width ${Autoplay.settings.imageViewDurationInSeconds}s linear;
          width: 100%;
      }
      `, "autoplay-image-progress-bar-animation");
  }

  insertVideoProgressHTML() {
    Utils.insertStyleHTML(`
      #autoplay-video-progress-bar.animated {
          transition: width ${Autoplay.settings.minimumVideoDurationInSeconds}s linear;
          width: 100%;
      }
      `, "autoplay-video-progress-bar-animation");
  }

  setMenuIconImageSources() {
    this.ui.playButton.src = this.paused ? Autoplay.menuIconImageURLs.play : Autoplay.menuIconImageURLs.pause;
    this.ui.settingsButton.src = Autoplay.menuIconImageURLs.tune;
    this.ui.changeDirectionButton.src = Autoplay.menuIconImageURLs.changeDirection;
    this.ui.changeDirectionMask.image.src = Autoplay.menuIconImageURLs.changeDirectionAlt;
    this.ui.changeDirectionMask.container.classList.toggle("upper-right", Autoplay.settings.moveForward);
  }

  loadAutoplaySettingsIntoUI() {
    this.ui.settingsMenu.imageDurationInput.value = Autoplay.settings.imageViewDurationInSeconds;
    this.ui.settingsMenu.minimumVideoDurationInput.value = Autoplay.settings.minimumVideoDurationInSeconds;
  }

  addEventListeners() {
    this.addMenuEventListeners();
    this.addSettingsMenuEventListeners();
  }

  addMenuEventListeners() {
    this.ui.settingsButton.onclick = () => {
      this.toggleSettingMenu();
    };
    this.ui.playButton.onclick = () => {
      this.pause();
    };
    this.ui.changeDirectionButton.onclick = () => {
      Autoplay.settings.moveForward = !Autoplay.settings.moveForward;
      this.ui.changeDirectionMask.container.classList.toggle("upper-right", Autoplay.settings.moveForward);
      Utils.setPreference(Autoplay.preferences.direction, Autoplay.settings.moveForward);
    };

    this.ui.menu.onmouseenter = () => {
      this.toggleMenuPersistence(true);
    };
    this.ui.menu.onmouseleave = () => {
      this.toggleMenuPersistence(false);
    };
  }

  addSettingsMenuEventListeners() {
    this.ui.settingsMenu.imageDurationInput.onchange = () => {
      this.setImageViewDuration();

      if (this.currentThumb !== null && Utils.isImage(this.currentThumb)) {
        this.startViewTimer(this.currentThumb);
      }
    };
    this.ui.settingsMenu.minimumVideoDurationInput.onchange = () => {
      this.setMinimumVideoViewDuration();

      if (this.currentThumb !== null && !Utils.isImage(this.currentThumb)) {
        this.startViewTimer(this.currentThumb);
      }
    };
  }

  /**
   * @param {Boolean} value
   */
  toggleMenuPersistence(value) {
    this.menuIsPersistent = value;
    this.ui.menu.classList.toggle("persistent", value);
  }

  /**
   * @param {Boolean} value
   */
  toggleMenuVisibility(value) {
    this.menuIsVisible = value;
    this.ui.menu.classList.toggle("visible", value);
  }

  /**
   * @param {Boolean} value
   */
  toggleSettingMenu(value) {
    if (value === undefined) {
      this.ui.settingsMenu.container.classList.toggle("visible");
      this.ui.settingsButton.classList.toggle("settings-menu-opened");
    } else {
      this.ui.settingsMenu.container.classList.toggle("visible", value);
      this.ui.settingsButton.classList.toggle("settings-menu-opened", value);
    }
  }

  /**
   * @param {Boolean} value
   */
  toggle(value) {
    Utils.setPreference(Autoplay.preferences.active, value);
    this.active = value;

    if (value) {
      this.events.onEnable();
    } else {
      this.events.onDisable();
    }
  }

  setImageViewDuration() {
    let durationInSeconds = parseFloat(this.ui.settingsMenu.imageDurationInput.value);

    if (isNaN(durationInSeconds)) {
      durationInSeconds = Autoplay.settings.imageViewDurationInSeconds;
    }
    const duration = Math.round(Utils.clamp(durationInSeconds * 1000, 1000, 60000));

    Utils.setPreference(Autoplay.preferences.imageDuration, duration);
    Autoplay.settings.imageViewDuration = duration;
    this.imageViewTimer.waitTime = duration;
    this.ui.settingsMenu.imageDurationInput.value = Autoplay.settings.imageViewDurationInSeconds;
    this.insertImageProgressHTML();
  }

  setMinimumVideoViewDuration() {
    let durationInSeconds = parseFloat(this.ui.settingsMenu.minimumVideoDurationInput.value);

    if (isNaN(durationInSeconds)) {
      durationInSeconds = Autoplay.settings.minimumVideoDurationInSeconds;
    }
    const duration = Math.round(Utils.clamp(durationInSeconds * 1000, 0, 60000));

    Utils.setPreference(Autoplay.preferences.minimumVideoDuration, duration);
    Autoplay.settings.minimumVideoDuration = duration;
    this.videoViewTimer.waitTime = duration;
    this.ui.settingsMenu.minimumVideoDurationInput.value = Autoplay.settings.minimumVideoDurationInSeconds;
    this.insertVideoProgressHTML();
  }

  /**
   * @param {HTMLElement} thumb
   */
  startViewTimer(thumb) {
    if (thumb === null) {
      return;
    }
    this.currentThumb = thumb;

    if (!this.active || Autoplay.disabled || this.paused) {
      return;
    }

    if (Utils.isVideo(thumb)) {
      this.startVideoViewTimer();
    } else {
      this.startImageViewTimer();
    }
  }

  startImageViewTimer() {
    this.stopVideoProgressBar();
    this.stopVideoViewTimer();
    this.startImageProgressBar();
    this.imageViewTimer.restart();
  }

  stopImageViewTimer() {
    this.imageViewTimer.stop();
    this.stopImageProgressBar();
  }

  startVideoViewTimer() {
    this.stopImageViewTimer();
    this.stopImageProgressBar();
    this.startVideoProgressBar();
    this.videoViewTimer.restart();
  }

  stopVideoViewTimer() {
    this.videoViewTimer.stop();
    this.stopVideoProgressBar();
  }

  /**
   * @param {HTMLElement} thumb
   */
  start(thumb) {
    if (!this.active || Autoplay.disabled) {
      return;
    }
    this.addAutoplayEventListeners();
    this.ui.container.style.visibility = "visible";
    this.showMenu();
    this.startViewTimer(thumb);
  }

  stop() {
    if (Autoplay.disabled) {
      return;
    }
    this.ui.container.style.visibility = "hidden";
    this.removeAutoplayEventListeners();
    this.stopImageViewTimer();
    this.stopVideoViewTimer();
    this.forceHideMenu();
  }

  pause() {
    this.paused = !this.paused;
    Utils.setPreference(Autoplay.preferences.paused, this.paused);

    if (this.paused) {
      this.ui.playButton.src = Autoplay.menuIconImageURLs.play;
      this.ui.playButton.title = "Resume Autoplay";
      this.stopImageViewTimer();
      this.stopVideoViewTimer();
      this.events.onPause();
    } else {
      this.ui.playButton.src = Autoplay.menuIconImageURLs.pause;
      this.ui.playButton.title = "Pause Autoplay";
      this.startViewTimer(this.currentThumb);
      this.events.onResume();
    }
  }

  onVideoEnded() {
    if (this.videoViewTimer.timeout === null) {
      this.events.onComplete();
    } else {
      this.events.onVideoEndedBeforeMinimumViewTime();
    }
  }

  addAutoplayEventListeners() {
    this.imageViewTimer.onCooldownEnd = () => {
      this.events.onComplete();
    };
    document.addEventListener("mousemove", () => {
      this.showMenu();
    }, {
      signal: this.eventListenersAbortController.signal
    });
    document.addEventListener("keydown", (event) => {
      if (!Utils.isHotkeyEvent(event)) {
        return;
      }

      switch (event.key.toLowerCase()) {
        case "p":
          this.showMenu();
          this.pause();
          break;

        default:
          break;
      }
    }, {
      signal: this.eventListenersAbortController.signal
    });
  }

  removeAutoplayEventListeners() {
    this.imageViewTimer.onCooldownEnd = () => { };
    this.eventListenersAbortController.abort();
    this.eventListenersAbortController = new AbortController();
  }

  showMenu() {
    this.toggleMenuVisibility(true);
    this.menuVisibilityTimer.restart();
  }

  hideMenu() {
    this.toggleMenuVisibility(false);
  }

  forceHideMenu() {
    this.toggleMenuPersistence(false);
    this.toggleMenuVisibility(false);
    this.toggleSettingMenu(false);
  }

  startImageProgressBar() {
    this.stopImageProgressBar();
    setTimeout(() => {
      this.ui.imageProgressBar.classList.add("animated");
    }, 10);
  }

  stopImageProgressBar() {
    this.ui.imageProgressBar.classList.remove("animated");
  }

  startVideoProgressBar() {
    this.stopVideoProgressBar();
    setTimeout(() => {
      this.ui.videoProgressBar.classList.add("animated");
    }, 10);
  }

  stopVideoProgressBar() {
    this.ui.videoProgressBar.classList.remove("animated");
  }
}

class Gallery {
  static 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%);
  }

  #gallery-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 {
    cursor: default;

    video {
      top: 0;
      left: 0;
      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;
  }

  .favorite,
  .thumb {

    >div,
    >a {
      >canvas {
        width: 100%;
        position: absolute;
        top: 0;
        left: 0;
        pointer-events: none;
        z-index: 1;
      }
    }
  }

  #original-content-background {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background: black;
    z-index: 999;
    display: none;
    pointer-events: none;
    cursor: default;
    -webkit-user-drag: none;
    -khtml-user-drag: none;
    -moz-user-drag: none;
    -o-user-drag: none;
  }
</style>
`;
  static galleryDebugHTML = `
  .thumb,
  .favorite {
    &.debug-selected {
      outline: 3px solid #0075FF !important;
    }

    &.loaded {

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

      .image {
        opacity: 1;
      }
    }

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

  `;
  static directions = {
    d: "d",
    a: "a",
    right: "ArrowRight",
    left: "ArrowLeft"
  };
  static preferences = {
    showOnHover: "showImagesWhenHovering",
    backgroundOpacity: "galleryBackgroundOpacity",
    resolution: "galleryResolution",
    enlargeOnClick: "enlargeOnClick",
    videoVolume: "videoVolume",
    videoMuted: "videoMuted"
  };
  static webWorkers = {
    renderer:
`
/* eslint-disable prefer-template */
/**
 * @param {Number} milliseconds
 * @returns {Promise}
 */
function sleep(milliseconds) {
  return new Promise(resolve => setTimeout(resolve, milliseconds));
}

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 {Number}
   */
  get estimatedMegabyteSize() {
    const rgb = 3;
    const bytes = rgb * this.pixelCount;
    const numberOfBytesInMegabyte = 1048576;
    return bytes / numberOfBytesInMegabyte;
  }

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

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

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

  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.originalRenderRequests = this.renderRequests;
    this.truncateRenderRequestsExceedingMemoryLimit();
  }

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

    for (const request of this.renderRequests) {
      const overMemoryLimit = currentMegabyteSize < BatchRenderRequest.settings.megabyteMemoryLimit;
      const underMinimumRequestCount = truncatedRequests.length < BatchRenderRequest.settings.minimumRequestCount;

      if (overMemoryLimit || underMinimumRequestCount) {
        truncatedRequests.push(request);
        currentMegabyteSize += request.estimatedMegabyteSize;
      } else {
        postMessage({
          action: "renderDeleted",
          id: request.id
        });
      }
    }
    this.renderRequests = truncatedRequests;
  }
}

class ImageFetcher {
  /**
   * @type {Set.<String>}
   */
  static idsToFetchFromPostPages = new Set();

  /**
   * @type {Number}
   */
  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")) {
          return ImageFetcher.getOriginalImageURLFromPostPage(id);
        }
        console.error({
          error,
          url: postPageURL
        });
        return "https://rule34.xxx/images/r34chibi.png";
      });
  }

  /**
   * @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.upscale(request, imageBitmap);
            });
        });
      await sleep(50);
    }
  }

  /**
   * @param {RenderRequest} request
   * @param {ImageBitmap} imageBitmap
   */
  upscale(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.originalRenderRequests.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;

  /**
   * @type {Boolean}
   */
  get hasRenderRequest() {
    return this.renderRequest !== undefined &&
      this.renderRequest !== null;
  }

  /**
   * @type {Boolean}
   */
  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;
    this.configureCanvasQuality();
  }

  configureCanvasQuality() {
    this.context.imageSmoothingEnabled = true;
    this.context.imageSmoothingQuality = "high";
    this.context.lineJoin = "miter";
  }

  renderMultipleImages(message) {
    const batchRenderRequest = new BatchRenderRequest(message);

    this.thumbUpscaler.collectCanvases(batchRenderRequest);
    this.abortOutdatedFetchRequests(batchRenderRequest);
    this.deleteRendersNotInNewRequests(batchRenderRequest);
    this.removeStartedRenderRequests(batchRenderRequest);
    this.batchRenderRequest = batchRenderRequest;
    this.renderMultipleImagesHelper(batchRenderRequest);
  }

  /**
   * @param {BatchRenderRequest} batchRenderRequest
   */
  async renderMultipleImagesHelper(batchRenderRequest) {
    for (const request of batchRenderRequest.renderRequests) {
      if (this.renders.has(request.id)) {
        continue;
      }
      this.renders.set(request.id, {
        completed: false,
        imageBitmap: undefined,
        request
      });
    }

    for (const request of batchRenderRequest.renderRequests) {
      this.renderImage(request);
      await sleep(request.fetchDelay);
    }
  }

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

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

    this.renders.set(request.id, {
      completed: true,
      imageBitmap,
      request
    });
    this.incompleteRenderRequests.delete(request.id);
    this.thumbUpscaler.upscale(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}
   */
  renderHasCompleted(id) {
    const render = this.renders.get(id);
    return render !== undefined && render.completed;
  }

  /**
   * @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
   */
  deleteRendersNotInNewRequests(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} batchRenderRequest
   */
  removeStartedRenderRequests(batchRenderRequest) {
    batchRenderRequest.renderRequests = batchRenderRequest.renderRequests
      .filter(request => !this.renders.has(request.id));
  }
  /**
   * @param {BatchRenderRequest} batchRenderRequest
   */
  removeCompletedRenderRequests(batchRenderRequest) {
    batchRenderRequest.renderRequests = batchRenderRequest.renderRequests
      .filter(request => !this.renderHasCompleted(request.id));
  }

  upscaleAllRenderedThumbs() {
    for (const render of this.renders.values()) {
      this.thumbUpscaler.upscale(render.request, render.imageBitmap);
    }
  }

  onmessage(message) {
    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":
        this.renderMultipleImages(message);
        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;

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

      default:
        break;
    }
  }
}

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

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

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

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

`
  };
  static mainCanvasResolutions = {
    search: Utils.onMobileDevice() ? "7680x4320" : "3840x2160",
    favorites: "7680x4320"
  };
  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: Utils.onMobileDevice() ? 2 : 50,
    megabyteLimit: Utils.onMobileDevice() ? 0 : 400,
    minImagesToRender: Utils.onMobileDevice() ? 3 : 8,
    imageFetchDelay: 250,
    throttledImageFetchDelay: 400,
    imageFetchDelayWhenExtensionKnown: 25,
    upscaledThumbResolutionFraction: 4,
    upscaledAnimatedThumbResolutionFraction: 6,
    animatedThumbsToUpscaleRange: 20,
    animatedThumbsToUpscaleDiscrete: 20,
    traversalCooldownTime: 300,
    renderOnPageChangeCooldownTime: 2000,
    addFavoriteCooldownTime: 250,
    cursorVisibilityCooldownTime: 500,
    imageExtensionAssignmentCooldownTime: 1000,
    additionalVideoPlayerCount: Utils.onMobileDevice() ? 0 : 2,
    renderAroundAggressively: true,
    loopAtEndOfGalleryValue: false,
    get loopAtEndOfGallery() {
      if (!Utils.onFavoritesPage() || !Gallery.finishedLoading) {
        return true;
      }
      return this.loopAtEndOfGalleryValue;
    },
    debugEnabled: false
  };
  static keyHeldDownTraversalCooldown = new Cooldown(Gallery.settings.traversalCooldownTime);
  static backgroundRenderingOnPageChangeCooldown = new Cooldown(Gallery.settings.renderOnPageChangeCooldownTime, true);
  static addOrRemoveFavoriteCooldown = new Cooldown(Gallery.settings.addFavoriteCooldownTime, true);
  static cursorVisibilityCooldown = new Cooldown(Gallery.settings.cursorVisibilityCooldownTime);
  static finishedLoading = Utils.onSearchPage();
  /**
   * @returns {Boolean}
   */
  static get disabled() {
    return (Utils.onMobileDevice() && Utils.onSearchPage()) || Utils.getPerformanceProfile() > 0 || Utils.onPostPage();
  }

  /**
   * @type {Autoplay}
   */
  autoplayController;
  /**
   * @type {HTMLDivElement}
   */
  originalContentContainer;
  /**
   * @type {HTMLCanvasElement}
   */
  mainCanvas;
  /**
   * @type {HTMLCanvasElement}
   */
  lowResolutionCanvas;
  /**
   * @type {CanvasRenderingContext2D}
   */
  lowResolutionContext;
  /**
   * @type {HTMLAnchorElement}
   */
  videoContainer;
  /**
   * @type {HTMLVideoElement[]}
   */
  videoPlayers;
  /**
   * @type {HTMLImageElement}
   */
  gifContainer;
  /**
   * @type {HTMLAnchorElement}
   */
  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 {Map.<String, String>}
   */
  enumeratedThumbs;
  /**
   * @type {HTMLElement[]}
   */
  visibleThumbs;
  /**
   * @type {Post[]}
   */
  latestSearchResults;
  /**
   * @type {Object.<Number, String>}
   */
  imageExtensions;
  /**
   * @type {String}
   */
  foundFavoriteId;
  /**
   * @type {String}
   */
  changedPageInGalleryDirection;
  /**
   * @type {Number}
   */
  recentlyDiscoveredImageExtensionCount;
  /**
   * @type {Number}
   */
  currentlySelectedThumbIndex;
  /**
   * @type {Number}
   */
  lastSelectedThumbIndexBeforeEnteringGallery;
  /**
   * @type {Number}
   */
  currentBatchRenderRequestId;
  /**
   * @type {Boolean}
   */
  inGallery;
  /**
   * @type {Boolean}
   */
  recentlyEnteredGallery;
  /**
   * @type {Boolean}
   */
  recentlyExitedGallery;
  /**
   * @type {Boolean}
   */
  leftPage;
  /**
   * @type {Boolean}
   */
  favoritesWereFetched;
  /**
   * @type {Boolean}
   */
  showOriginalContentOnHover;
  /**
   * @type {Boolean}
   */
  enlargeOnClickOnMobile;

  /**
   * @type {Boolean}
   */
  get changedPageWhileInGallery() {
    return this.changedPageInGalleryDirection !== null;
  }

  constructor() {
    if (Gallery.disabled) {
      return;
    }
    this.createAutoplayController();
    this.initializeFields();
    this.initializeTimers();
    this.setMainCanvasResolution();
    this.createWebWorkers();
    this.createVideoBackgrounds();
    this.addEventListeners();
    this.createImageRendererMessageHandler();
    this.prepareSearchPage();
    this.insertHTML();
    this.updateBackgroundOpacity(Utils.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.enumeratedThumbs = new Map();
    this.visibleThumbs = [];
    this.latestSearchResults = [];
    this.imageExtensions = {};
    this.foundFavoriteId = null;
    this.changedPageInGalleryDirection = null;
    this.recentlyDiscoveredImageExtensionCount = 0;
    this.currentlySelectedThumbIndex = 0;
    this.lastSelectedThumbIndexBeforeEnteringGallery = 0;
    this.currentBatchRenderRequestId = 0;
    this.inGallery = false;
    this.recentlyEnteredGallery = false;
    this.recentlyExitedGallery = false;
    this.leftPage = false;
    this.favoritesWereFetched = false;
    this.showOriginalContentOnHover = Utils.getPreference(Gallery.preferences.showOnHover, true);
    this.enlargeOnClickOnMobile = Utils.getPreference(Gallery.preferences.enlargeOnClick, true);
  }

  initializeTimers() {
    Gallery.backgroundRenderingOnPageChangeCooldown.onDebounceEnd = () => {
      this.onPageChange();
    };
  }

  setMainCanvasResolution() {
    const resolution = Utils.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(Utils.getWorkerURL(Gallery.webWorkers.renderer));
    this.imageRenderer.postMessage({
      action: "initialize",
      canvas: offscreenCanvas,
      onMobileDevice: Utils.onMobileDevice(),
      screenWidth: window.screen.width,
      megabyteLimit: Gallery.settings.megabyteLimit,
      minimumImagesToRender: Gallery.settings.minImagesToRender,
      onSearchPage: Utils.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.addMobileEventListeners();
    this.addMemoryManagementEventListeners();
  }

  addGalleryEventListeners() {
    window.addEventListener("load", () => {
      if (Utils.onSearchPage()) {
        this.initializeThumbsForHovering.bind(this)();
        this.enumerateThumbs();
      }
      this.hideCaptionsWhenShowingOriginalContent();
    }, {
      once: true,
      passive: true
    });

    // eslint-disable-next-line complexity
    document.addEventListener("mousedown", (event) => {
      const autoplayMenu = document.getElementById("autoplay-menu");

      if (autoplayMenu !== null && autoplayMenu.contains(event.target)) {
        return;
      }
      const clickedOnAnImage = event.target.tagName.toLowerCase() === "img" && !event.target.parentElement.classList.contains("add-or-remove-button");
      const clickedOnAThumb = clickedOnAnImage && (Utils.getThumbFromImage(event.target).className.includes("thumb") || Utils.getThumbFromImage(event.target).className.includes(Utils.favoriteItemClassName));
      const clickedOnACaptionTag = event.target.classList.contains("caption-tag");
      const thumb = clickedOnAThumb ? Utils.getThumbFromImage(event.target) : null;

      if (clickedOnAThumb) {
        this.currentlySelectedThumbIndex = this.getIndexFromThumb(thumb);
      }

      if (event.ctrlKey && event.button === Utils.clickCodes.left) {
        return;
      }

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

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

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

        case Utils.clickCodes.middle:
          event.preventDefault();

          if (this.inGallery) {
            this.openPostInNewPage();
            return;
          }

          if (clickedOnAThumb && Utils.onSearchPage()) {
            this.openPostInNewPage();
            return;
          }

          if (!clickedOnAThumb && !clickedOnACaptionTag) {
            this.toggleAllVisibility();
            Utils.setPreference(Gallery.preferences.showOnHover, this.showOriginalContentOnHover);
          }
          break;

        default:
          break;
      }
    });
    window.addEventListener("auxclick", (event) => {
      if (event.button === Utils.clickCodes.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(Utils.getPreference(Gallery.preferences.backgroundOpacity, 1));

        opacity -= event.deltaY * 0.0005;
        opacity = Utils.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;

        case " ":
          if (Utils.isVideo(this.getSelectedThumb())) {
            const video = this.getActiveVideoPlayer();

            if (video === document.activeElement) {
              return;
            }

            if (video.paused) {
              video.play().catch(() => { });
            } else {
              video.pause();
            }
          }
          break;

        default:
          break;
      }
    }, {
      passive: true
    });
    window.addEventListener("keydown", async(event) => {
      if (!this.inGallery) {
        return;
      }
      const zoomedIn = document.getElementById("main-canvas-zoom") !== null;

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

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

        case "M":

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

        case "B":

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

        case "n":
          this.toggleCursorVisibility(true);
          Gallery.cursorVisibilityCooldown.restart();
          break;

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

        case "Control":
          this.setGalleryCursor(zoomedIn ? "zoom-out" : "zoom-in");
          break;

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

      switch (event.key) {
        case "Control":
          this.setGalleryCursor("default");
          break;

        default:
          break;
      }
    });
  }

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

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

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

        this.renderImagesInTheBackground();

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

      if (!this.favoritesWereFetched) {
        this.renderImagesInTheBackground();
      }
    }, {
      once: true
    });
    window.addEventListener("newSearchResults", (event) => {
      this.latestSearchResults = event.detail;
    });
    window.addEventListener("changedPage", () => {
      this.initializeThumbsForHovering.bind(this)();
      this.enumerateThumbs();

      if (this.changedPageWhileInGallery) {
        setTimeout(() => {
          this.imageRenderer.postMessage({
            action: "upscaleAllRenderedThumbs"
          });
        }, 100);
      } else {
        this.clearMainCanvas();
        this.clearVideoSources();
        this.toggleOriginalContentVisibility(false);
        this.deleteAllRenders();

        if (Gallery.settings.debugEnabled) {
          Utils.getAllThumbs().forEach((thumb) => {
            thumb.classList.remove("loaded");
            thumb.classList.remove("debug-selected");
          });
        }
      }
      this.onPageChange();
    });
    window.addEventListener("foundFavorite", (event) => {
      this.foundFavoriteId = event.detail;
    });
    window.addEventListener("shuffle", () => {
      this.enumerateThumbs();
      this.deleteAllRenders();
      this.renderImagesInTheBackground();
    });
    // window.addEventListener("metadataFetched", (event) => {
    //   Gallery.assignImageExtension(event.detail.id, event.detail.extension);
    // });
    window.addEventListener("didNotChangePageInGallery", (event) => {
      if (this.inGallery) {
        this.setNextSelectedThumbIndex(event.detail);
        this.traverseGalleryHelper();
      }
    });
  }

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

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

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

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

        default:
          break;
      }
    };
  }

  addMobileEventListeners() {
    if (!Utils.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(false);
      }
    }, {
      passive: false
    });

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

  setMainCanvasOrientation() {
    if (!Utils.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 (Utils.onFavoritesPage()) {
      return;
    }
    window.addEventListener("blur", () => {
      this.leftPage = true;
      this.deleteAllRenders();
      this.clearInactiveVideoSources();
    });
    window.addEventListener("focus", () => {
      if (this.leftPage) {
        this.renderImagesInTheBackground();
        this.leftPage = false;
      }
    });
  }

  async prepareSearchPage() {
    if (!Utils.onSearchPage()) {
      return;
    }
    await Utils.findImageExtensionsOnSearchPage();
    dispatchEvent(new Event("foundExtensionsOnSearchPage"));
    this.renderImagesInTheBackground();
  }

  insertHTML() {
    this.insertStyleHTML();
    this.insertDebugHTML();
    this.insertOptionsHTML();
    this.insertOriginalContentContainerHTML();

  }

  insertStyleHTML() {
    Utils.insertStyleHTML(Gallery.galleryHTML, "gallery");
  }

  insertDebugHTML() {
    if (Gallery.settings.debugEnabled) {
      Utils.insertStyleHTML(Gallery.galleryDebugHTML, "gallery-debug");
    }
  }

  insertOptionsHTML() {
    this.insertShowOnHoverOption();
  }

  insertShowOnHoverOption() {
    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) => {
      Utils.setPreference(Gallery.preferences.showOnHover, event.target.checked);
      this.toggleAllVisibility(event.target.checked);
    };

    if (Utils.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) => {
        Utils.setPreference(Gallery.preferences.enlargeOnClick, event.target.checked);
        this.enlargeOnClickOnMobile = event.target.checked;
      };
    }
    Utils.createFavoritesOption(
      optionId,
      optionText,
      optionTitle,
      optionIsChecked,
      onOptionChanged,
      true
      // "(Middle Click)"
    );
  }

  insertOriginalContentContainerHTML() {
    const originalContentContainerHTML = `
          <div id="gallery-container">
              <a id="original-video-container">
                <video id="video-player-0" width="100%" height="100%" autoplay muted loop controlsList="nofullscreen" active></video>
              </a>
              <img id="original-gif-container" class="focused"></img>
              <a id="original-content-background"></a>
          </div>
      `;

    Utils.insertFavoritesSearchGalleryHTML("afterbegin", originalContentContainerHTML);
    this.originalContentContainer = document.getElementById("gallery-container");
    this.originalContentContainer.insertBefore(this.lowResolutionCanvas, this.originalContentContainer.firstChild);
    this.originalContentContainer.insertBefore(this.mainCanvas, this.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.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);
    this.addBackgroundEventListeners();

    if (Autoplay.disabled || !this.autoplayController.active || this.autoplayController.paused) {
      this.toggleVideoLooping(true);
    } else {
      this.toggleVideoLooping(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() {
    this.videoContainer.onclick = (event) => {
      if (!event.ctrlKey) {
        event.preventDefault();
      }
    };

    for (const video of this.videoPlayers) {
      video.addEventListener("mousemove", () => {
        if (!video.hasAttribute("controls")) {
          video.setAttribute("controls", "");
        }
      }, {
        passive: true
      });
      video.addEventListener("click", (event) => {
        if (event.ctrlKey) {
          return;
        }

        if (video.paused) {
          video.play().catch(() => { });
        } else {
          video.pause();
        }
      }, {
        passive: true
      });
      video.addEventListener("volumechange", (event) => {
        if (!event.target.hasAttribute("active")) {
          return;
        }
        Utils.setPreference(Gallery.preferences.videoVolume, video.volume);
        Utils.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.autoplayController.onVideoEnded();
      }, {
        passive: true
      });
      video.addEventListener("dblclick", () => {
        if (this.inGallery && !this.recentlyEnteredGallery) {
          this.exitGallery();
          this.toggleAllVisibility(false);
        }
      });
    }
  }

  addBackgroundEventListeners() {
    if (Utils.onMobileDevice()) {
      return;
    }
    this.background.addEventListener("mousemove", () => {
      Gallery.cursorVisibilityCooldown.restart();
      this.toggleCursorVisibility(true);
    }, {
      passive: true
    });
    Gallery.cursorVisibilityCooldown.onCooldownEnd = () => {
      if (this.inGallery) {
        this.toggleCursorVisibility(false);
      }
    };
  }

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

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

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

  createAutoplayController() {
    const subscribers = new AutoplayListenerList(
      () => {
        this.toggleVideoLooping(false);
      },
      () => {
        this.toggleVideoLooping(true);
      },
      () => {
        this.toggleVideoLooping(true);
      },
      () => {
        this.toggleVideoLooping(false);
      },
      () => {
        if (this.inGallery) {
          const direction = Autoplay.settings.moveForward ? Gallery.directions.right : Gallery.directions.left;

          this.traverseGallery(direction, false);
        }
      },
      () => {
        if (this.inGallery && Utils.isVideo(this.getSelectedThumb())) {
          this.playOriginalVideo(this.getSelectedThumb());
        }
      }
    );

    this.autoplayController = new Autoplay(subscribers);
  }

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

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

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

    this.upscaleAnimatedThumbs(animatedThumbsToUpscale);

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

    this.renderImages(imageThumbsToRender);
  }

  onPageChange() {
    this.onPageChangeHelper();
    this.foundFavoriteId = null;
    this.changedPageInGalleryDirection = null;
  }

  onPageChangeHelper() {
    if (this.visibleThumbs.length <= 0) {
      return;
    }

    if (this.changedPageInGalleryDirection !== null) {
      this.onPageChangedInGallery();
      return;
    }

    if (this.foundFavoriteId !== null) {
      this.onFavoriteFound();
      return;
    }
    setTimeout(() => {
      if (Gallery.backgroundRenderingOnPageChangeCooldown.ready) {
        this.renderImagesInTheBackground();
      }
    }, 100);
  }

  onPageChangedInGallery() {
    if (this.changedPageInGalleryDirection === "ArrowRight") {
      this.currentlySelectedThumbIndex = 0;
    } else {
      this.currentlySelectedThumbIndex = this.visibleThumbs.length - 1;
    }
    this.traverseGalleryHelper();
  }

  onFavoriteFound() {
    const thumb = document.getElementById(this.foundFavoriteId);

    if (thumb !== null) {
      this.renderImagesAround(thumb);
    }
  }

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

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

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

  /**
   * @param {Object} message
   */
  onRenderCompleted(message) {
    const thumb = document.getElementById(message.id);

    this.completedRenders.add(message.id);

    if (Gallery.settings.debugEnabled) {

      if (Gallery.settings.loopAtEndOfGallery) {
        if (thumb !== null) {
          thumb.classList.add("loaded");
        }
      } else {
        const post = Post.allPosts.get(message.id);

        if (post !== undefined && post.root !== undefined) {
          post.root.classList.add("loaded");
        }
      }
    }

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

  /**
   * @param {HTMLElement} thumb
   */
  drawMainCanvasOnRenderCompleted(thumb) {
    if (thumb === null) {
      return;
    }
    const mainCanvasIsVisible = this.showOriginalContentOnHover || this.inGallery;

    if (!mainCanvasIsVisible) {
      return;
    }
    const selectedThumb = this.getSelectedThumb();
    const selectedThumbIsImage = selectedThumb !== undefined && Utils.isImage(selectedThumb);

    if (!selectedThumbIsImage) {
      return;
    }

    if (selectedThumb.id === thumb.id) {
      this.drawMainCanvas(thumb);
    }
  }

  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) {
      if (Gallery.settings.loopAtEndOfGallery) {
        for (const thumb of this.visibleThumbs) {
          thumb.classList.remove("loaded");
        }
      } else {
        for (const post of Post.allPosts.values()) {
          if (post.root !== undefined) {
            post.root.classList.remove("loaded");
          }
        }
      }
    }
  }

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

    for (const id of this.transferredCanvases.keys()) {
      this.transferredCanvases.get(id).remove();
      this.transferredCanvases.delete(id);
    }
    this.transferredCanvases.clear();
  }

  /**
   * @returns {HTMLElement[]}
   */
  getUnrenderedImageThumbs() {
    const thumbs = Utils.getAllThumbs().filter((thumb) => {
      return Utils.isImage(thumb) && !this.renderHasStarted(thumb);
    });
    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");
      }
    }
  }

  async findImageExtensionsInTheBackground() {
    await Utils.sleep(1000);
    const idsWithUnknownExtensions = this.getIdsWithUnknownExtensions(Array.from(Post.allPosts.values()));

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

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

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

  enumerateThumbs() {
    this.visibleThumbs = Utils.getAllThumbs();
    this.enumeratedThumbs.clear();

    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) {
    this.enumeratedThumbs.set(thumb.id, index);
  }

  /**
   * @param {HTMLElement} thumb
   * @returns {Number | null}
   */
  getIndexFromThumb(thumb) {
    return this.enumeratedThumbs.get(thumb.id) || 0;
  }

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

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

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

  /**
   * @param {HTMLElement} thumb
   */
  openPostInNewPage(thumb) {
    thumb = thumb === undefined || thumb === null ? this.getSelectedThumb() : thumb;
    Utils.openPostInNewTab(Utils.getIdFromThumb(thumb));
  }

  unFavoriteSelectedContent() {
    if (!Utils.userIsOnTheirOwnFavoritesPage()) {
      return;
    }
    const selectedThumb = this.getSelectedThumb();

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

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

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

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

    if (!showRemoveFavoriteButtons.checked) {
      Utils.showFullscreenIcon(Utils.icons.warning, 1000);
      setTimeout(() => {
        alert("The \"Remove Buttons\" option must be checked to use this hotkey");
      }, 20);
      return;
    }
    Utils.showFullscreenIcon(Utils.icons.heartMinus);
    this.onFavoriteAddedOrDeleted(selectedThumb.id);
    Utils.removeFavorite(selectedThumb.id);
  }

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

    this.lastSelectedThumbIndexBeforeEnteringGallery = this.currentlySelectedThumbIndex;
    this.background.style.pointerEvents = "auto";

    if (Utils.isVideo(selectedThumb)) {
      this.toggleVideoControls(true);
    }
    this.inGallery = true;
    dispatchEvent(new CustomEvent("showOriginalContent", {
      detail: true
    }));
    this.autoplayController.start(selectedThumb);
    Gallery.cursorVisibilityCooldown.restart();
    this.recentlyEnteredGallery = true;
    setTimeout(() => {
      this.recentlyEnteredGallery = false;
    }, 300);
    this.setupOriginalImageLinkInGallery();
  }

  exitGallery() {
    if (Gallery.settings.debugEnabled) {
      Utils.getAllThumbs().forEach(thumb => thumb.classList.remove("debug-selected"));
    }
    this.toggleCursorVisibility(true);
    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.autoplayController.stop();
    this.setGalleryCursor("default");
    document.dispatchEvent(new Event("mousemove"));
  }

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

    if (keyIsHeldDown && !Gallery.keyHeldDownTraversalCooldown.ready) {
      return;
    }

    if (!Gallery.settings.loopAtEndOfGallery && this.reachedEndOfGallery(direction) && Gallery.finishedLoading) {
      this.changedPageInGalleryDirection = direction;
      dispatchEvent(new CustomEvent("reachedEndOfGallery", {
        detail: direction
      }));
      return;
    }
    this.setNextSelectedThumbIndex(direction);
    this.traverseGalleryHelper();
  }

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

    this.autoplayController.startViewTimer(selectedThumb);
    this.clearOriginalContentSources();
    this.stopAllVideos();

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

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

    if (Utils.isVideo(selectedThumb)) {
      this.toggleVideoControls(true);
      this.showOriginalVideo(selectedThumb);
    } else if (Utils.isGif(selectedThumb)) {
      this.toggleVideoControls(false);
      this.toggleOriginalVideoContainer(false);
      this.showOriginalGIF(selectedThumb);
    } else {
      this.toggleVideoControls(false);
      this.toggleOriginalVideoContainer(false);
      this.showOriginalImage(selectedThumb);
    }
    this.setupOriginalImageLinkInGallery();
  }

  /**
   * @param {String} direction
   * @returns {Boolean}
   */
  reachedEndOfGallery(direction) {
    if (direction === Gallery.directions.right && this.currentlySelectedThumbIndex >= this.visibleThumbs.length - 1) {
      return true;
    }

    if (direction === Gallery.directions.left && this.currentlySelectedThumbIndex <= 0) {
      return true;
    }
    return false;
  }

  /**
   * @param {String} direction
   * @returns {Boolean}
   */
  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;
    }
    return false;
  }

  /**
   * @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
    }));
    Utils.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.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 Utils.isVideo(this.thumbUnderCursor);
  }

  /**
   * @param {HTMLElement} thumb
   */
  showOriginalContent(thumb) {
    this.currentlySelectedThumbIndex = this.getIndexFromThumb(thumb);
    this.upscaleAnimatedThumbsAroundDiscrete(thumb);

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

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

    if (this.showOriginalContentOnHover) {
      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.getAdjacentVideoThumbs(initialThumb, inactiveVideoPlayers.length);
    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} initialThumb
   * @param {Number} limit
   * @returns {HTMLElement[]}
   */
  getAdjacentVideoThumbs(initialThumb, limit) {
    if (Gallery.settings.loopAtEndOfGallery) {
      return this.getAdjacentVideoThumbsOnCurrentPage(initialThumb, limit);
    }
    return this.getAdjacentVideoThumbsThroughoutAllPages(initialThumb, limit);
  }

  /**
   * @param {HTMLElement} initialThumb
   * @param {Number} limit
   * @returns {HTMLElement[]}
   */
  getAdjacentVideoThumbsOnCurrentPage(initialThumb, limit) {
    return this.getAdjacentThumbsLooped(
      initialThumb,
      limit,
      (t) => {
        return Utils.isVideo(t) && t.id !== initialThumb.id;
      }
    );

  }
  /**
   * @param {HTMLElement} initialThumb
   * @param {Number} limit
   * @returns {HTMLElement[]}
   */
  getAdjacentVideoThumbsThroughoutAllPages(initialThumb, limit) {
    return this.getAdjacentSearchResults(
      initialThumb,
      limit,
      (t) => {
        return Utils.isVideo(t) && t.id !== initialThumb.id;
      }
    );
  }

  /**
   * @param {HTMLElement} thumb
   * @returns {String}
   */
  getVideoSource(thumb) {
    return Utils.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.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");
  }

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

    this.gifContainer.src = originalSource;

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

  /**
   * @param {HTMLElement} thumb
   */
  showOriginalImage(thumb) {
    if (this.renderIsCompleted(thumb)) {
      this.clearLowResolutionCanvas();
      this.drawMainCanvas(thumb);
    } else if (this.renderHasStarted(thumb)) {
      this.drawLowResolutionCanvas(thumb);
      this.clearMainCanvas();
      this.drawMainCanvas(thumb);
    } else {
      this.drawLowResolutionCanvas(thumb);
      this.renderOriginalImage(thumb);

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

  /**
   * @param {HTMLElement} initialThumb
   */
  renderImagesAround(initialThumb) {
    if (Utils.onSearchPage() || (Utils.onMobileDevice() && !this.enlargeOnClickOnMobile)) {
      return;
    }
    this.renderImages(this.getAdjacentImageThumbs(initialThumb));
  }

  /**
   * @param {HTMLElement} initialThumb
   * @returns {HTMLElement[]}
   */
  getAdjacentImageThumbs(initialThumb) {
    const adjacentImageThumbs = Utils.isImage(initialThumb) ? [initialThumb] : [];

    if (Gallery.settings.loopAtEndOfGallery || this.latestSearchResults.length === 0) {
      return adjacentImageThumbs.concat(this.getAdjacentImageThumbsOnCurrentPage(initialThumb));
    }
    return adjacentImageThumbs.concat(this.getAdjacentImageThumbThroughoutAllPages(initialThumb));
  }

  /**
   * @param {HTMLElement} initialThumb
   * @returns {HTMLElement[]}
   */
  getAdjacentImageThumbsOnCurrentPage(initialThumb) {
    return this.getAdjacentThumbsLooped(
      initialThumb,
      Gallery.settings.maxImagesToRenderAround,
      (thumb) => {
        return Utils.isImage(thumb);
      }
    );
  }

  /**
   * @param {HTMLElement} initialThumb
   * @returns {HTMLElement[]}
   */
  getAdjacentImageThumbThroughoutAllPages(initialThumb) {
    return this.getAdjacentSearchResults(
      initialThumb,
      Gallery.settings.maxImagesToRenderAround,
      (post) => {
        return Utils.isImage(post);
      }
    );
  }

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

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

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

  /**
   * @param {HTMLElement} initialThumb
   * @param {Number} limit
   * @param {Function} additionalQualifier
   * @returns {HTMLElement[]}
   */
  getAdjacentThumbsLooped(initialThumb, limit, additionalQualifier) {
    const adjacentThumbs = [];
    const discoveredIds = new Set();
    let currentThumb = initialThumb;
    let previousThumb = initialThumb;
    let nextThumb = initialThumb;
    let traverseForward = true;

    while (currentThumb !== null && adjacentThumbs.length < limit) {
      if (traverseForward) {
        nextThumb = this.getAdjacentThumbLooped(nextThumb, true);
      } else {
        previousThumb = this.getAdjacentThumbLooped(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)) {
        adjacentThumbs.push(currentThumb);
      }
    }
    return adjacentThumbs;
  }

  /**
   * @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}
   */
  getAdjacentThumbLooped(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} initialThumb
   * @param {Number} limit
   * @param {Function} additionalQualifier
   * @returns {HTMLElement[]}
   */
  getAdjacentSearchResults(initialThumb, limit, additionalQualifier) {
    const initialSearchResultIndex = this.latestSearchResults.findIndex(post => post.id === initialThumb.id);

    if (initialSearchResultIndex === -1) {
      return [];
    }
    const adjacentSearchResults = [];
    const discoveredIds = new Set();

    let currentSearchResult;
    let currentIndex;
    let forward = true;
    let previousIndex = initialSearchResultIndex;
    let nextIndex = initialSearchResultIndex;

    while (adjacentSearchResults.length < limit) {
      if (forward) {
        nextIndex = this.getAdjacentSearchResultIndex(nextIndex, true);
        currentIndex = nextIndex;
        forward = false;
      } else {
        previousIndex = this.getAdjacentSearchResultIndex(previousIndex, false);
        currentIndex = previousIndex;
        forward = true;
      }
      currentSearchResult = this.latestSearchResults[currentIndex];

      if (discoveredIds.has(currentSearchResult.id)) {
        break;
      }
      discoveredIds.add(currentSearchResult.id);

      if (additionalQualifier(currentSearchResult)) {
        adjacentSearchResults.push(currentSearchResult);
      }
    }

    for (const searchResult of adjacentSearchResults) {
      searchResult.activateHTMLElement();
    }
    return adjacentSearchResults.map(post => post.root);
  }

  /**
   * @param {Number} i
   * @param {Boolean} forward
   * @returns {Number}
   */
  getAdjacentSearchResultIndex(i, forward) {
    if (forward) {
      i += 1;
      i = i >= this.latestSearchResults.length ? 0 : i;
    } else {
      i -= 1;
      i = i < 0 ? this.latestSearchResults.length - 1 : i;
    }
    return i;
  }

  /**
   * @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}
   */
  renderIsCompleted(thumb) {
    return this.completedRenders.has(thumb.id);
  }

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

  /**
   * @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: Utils.getOriginalImageURLFromThumb(thumb),
      id: thumb.id,
      extension: Utils.getImageExtension(thumb.id),
      fetchDelay: this.getBaseImageFetchDelay(thumb.id),
      thumbURL: Utils.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 (Utils.onMobileDevice()) {
      request.windowDimensions = {
        width: window.innerWidth,
        height: window.innerHeight
      };
    }
    return request;
  }

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

  /**
   * @param {HTMLElement} thumb
   */
  renderOriginalImage(thumb) {
    if (Utils.onSearchPage()) {
      return;
    }

    if (this.canvasIsTransferrable(thumb)) {
      const request = this.getRenderRequest(thumb);

      this.imageRenderer.postMessage(request, [request.canvas]);
    } else {
      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 html = `
    //   #original-content-background {
    //     cursor: ${value ? "auto" : "none"};
    //   }
    // `;

    // insertStyleHTML(html, "gallery-cursor-visibility");
  }

  /**
   * @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 (Utils.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 : this.getIndexFromThumb(this.thumbUnderCursor);
  }

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

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

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

      if (Utils.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 (Utils.onFavoritesPage() && !Gallery.finishedLoading) {
      return Gallery.settings.throttledImageFetchDelay;
    }

    if (Utils.extensionIsKnown(id)) {
      return Gallery.settings.imageFetchDelayWhenExtensionKnown;
    }
    return Gallery.settings.imageFetchDelay;
  }

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

    this.upscaleAnimatedThumbs(animatedThumbsToUpscale);
  }

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

    this.upscaleAnimatedThumbs(animatedThumbsToUpscale);
  }

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

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

    if (!Utils.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 (Utils.onMobileDevice()) {
      return;
    }
    this.lowResolutionContext.clearRect(0, 0, this.lowResolutionCanvas.width, this.lowResolutionCanvas.height);
  }

  /**
   * @param {Boolean} value
   */
  toggleVideoLooping(value) {
    for (const video of this.videoPlayers) {
      video.toggleAttribute("loop", value);
    }
  }

  loadVideoClips() {
  }

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

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

    switch (addedFavoriteStatus) {
      case Utils.addedFavoriteStatuses.alreadyAdded:
        svg = Utils.icons.heartCheck;
        break;

      case Utils.addedFavoriteStatuses.success:
        svg = Utils.icons.heartPlus;
        this.onFavoriteAddedOrDeleted(selectedThumb.id);
        break;

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

  /**
   * @param {String} id
   */
  onFavoriteAddedOrDeleted(id) {
    dispatchEvent(new CustomEvent("favoriteAddedOrDeleted", {
      detail: id
    }));
  }

  /**
   * @param {String} cursor
   */
  setGalleryCursor(cursor) {
    // this.background.style.cursor = cursor;
  }

  /**
   * @param {MouseEvent} event
   */
  zoomOnMainCanvas(event) {
    const style = document.getElementById("main-canvas-zoom-fsg-style");

    if (style !== null) {
      style.remove();
      return;
    }
    const x = 100 * Utils.roundToTwoDecimalPlaces(event.clientX / window.innerWidth);
    const y = 100 * Utils.roundToTwoDecimalPlaces(event.clientY / window.innerHeight);

    Utils.insertStyleHTML(`
      #gallery-container {
        canvas,
        img {
          transform-origin: ${x}% ${y}%;
          transform: translate(-50%, -50%) scale(4) !important;
        }
      }
    `, "main-canvas-zoom");
  }

  async setupOriginalImageLinkInGallery() {
    const thumb = this.getSelectedThumb();

    if (thumb === null || thumb === undefined) {
      return;
    }
    const imageURL = await Utils.getOriginalImageURLWithExtension(thumb);
    const container = Utils.isVideo(thumb) ? this.videoContainer : this.background;

    container.href = imageURL;
  }
}

class Tooltip {
  static 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>
`;
  /**
   * @type {Boolean}
   */
  static get disabled() {
    return Utils.onMobileDevice() || Utils.getPerformanceProfile() > 1 || Utils.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 = Utils.getPreference("showTooltip", true);
    Utils.insertFavoritesSearchGalleryHTML("afterbegin", Tooltip.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" || !Utils.isHotkeyEvent(event)) {
        return;
      }

      if (Utils.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 (Utils.onSearchPage()) {
        this.toggleVisibility();

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

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

  addFavoritesPageEventListeners() {
    if (!Utils.onFavoritesPage()) {
      return;
    }
    window.addEventListener("favoritesFetched", () => {
      this.addEventListenersToThumbs.bind(this)();
    });
    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 (Utils.usingDarkTheme()) {
      this.tooltip.classList.remove("light-green-gradient");
      this.tooltip.classList.add("dark-green-gradient");
    }
  }

  assignColorsToMatchedTags() {
    if (Utils.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 ? Utils.getAllThumbs() : thumbs;

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

      if (image.onmouseenter !== null) {
        continue;
      }

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

        if (this.visible) {
          this.show(image);
        }
      };
      image.onmouseleave = (event) => {
        if (!Utils.enteredOverCaptionTag(event)) {
          this.currentImage = null;
          this.hide();
        }
      };
    }
  }

  /**
   * @param {HTMLImageElement} image
   */
  setPosition(image) {
    const fancyHoveringStyle = document.getElementById("fancy-image-hovering-fsg-style");
    const imageChangesSizeOnHover = fancyHoveringStyle !== null && fancyHoveringStyle.textContent !== "";
    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 menu = document.getElementById("favorites-search-gallery-menu");
    const elementAboveTooltip = menu === null ? document.getElementById("header") : menu;
    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 {HTMLImageElement} image
   */
  show(image) {
    this.tooltip.innerHTML = this.formatHTML(this.getTags(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}
   */
  getTags(image) {
    const thumb = Utils.getThumbFromImage(image);
    const tags = Utils.getTagsFromThumb(thumb);

    if (this.searchTagColorCodes[thumb.id] === undefined) {
      tags.delete(thumb.id);
    }
    return Array.from(tags).sort().join(" ");
  }

  /**
   * @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} tags
   */
  formatHTML(tags) {
    let unmatchedTagsHTML = "";
    let matchedTagsHTML = "";

    const tagList = Utils.removeExtraWhiteSpace(tags).split(" ");

    for (let i = 0; i < tagList.length; i += 1) {
      const tag = tagList[i];
      const tagColor = this.getColorCode(tag);
      const tagWithSpace = `${tag} `;

      if (tagColor !== undefined) {
        matchedTagsHTML += `<span style="color:${tagColor}"><b>${tagWithSpace}</b></span>`;
      } else if (Utils.includesTag(tag, new Set(Utils.tagBlacklist.split(" ")))) {
        unmatchedTagsHTML += `<span style="color:red"><s><b>${tagWithSpace}</b></s></span>`;
      } else {
        unmatchedTagsHTML += tagWithSpace;
      }
    }
    const html = matchedTagsHTML + unmatchedTagsHTML;

    if (html === "") {
      return tags;
    }
    return html;
  }

  /**
   * @param {String} searchQuery
   */
  assignTagColors(searchQuery) {
    searchQuery = this.removeNotTags(searchQuery);
    const {orGroups, remainingSearchTags} = Utils.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 (Utils.tagsMatchWildcardSearchTag(searchTag, [tag])) {
        return this.searchTagColorCodes[searchTag];
      }
    }
    return undefined;
  }

  addFavoritesOptions() {
    Utils.createFavoritesOption(
      "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,
      "(T)"
    );
  }

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

  /**
   * @param {HTMLElement | null} thumb
   */
  showOnLoadIfHoveringOverThumb(thumb) {
    if (thumb !== null) {
      this.show(Utils.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);
  }
}

class SavedSearches {
  static 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;
      }
    }

    /* .tag-type-saved>a,
    .tag-type-saved {
      color: lightblue;
    } */
  </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 title="Export all saved searches" id="export-saved-search-button">Export</button>
      <button title="Import saved searches" 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>
<script>
</script>
`;
  static preferences = {
    textareaWidth: "savedSearchesTextAreaWidth",
    textareaHeight: "savedSearchesTextAreaHeight",
    savedSearches: "savedSearches",
    visibility: "savedSearchVisibility",
    tutorial: "savedSearchesTutorial"
  };
  static localStorageKeys = {
    savedSearches: "savedSearches"
  };
  /**
   * @type {Boolean}
   */
  static get disabled() {
    return !Utils.onFavoritesPage() || Utils.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.insertHTML();
    this.extractHTMLElements();
    this.addEventListeners();
    this.loadSavedSearches();
  }

  insertHTML() {
    const showSavedSearches = Utils.getPreference(SavedSearches.preferences.visibility, false);
    const savedSearchesContainer = document.getElementById("right-favorites-panel");

    Utils.insertHTMLAndExtractStyle(savedSearchesContainer, "beforeend", SavedSearches.savedSearchesHTML);
    document.getElementById("right-favorites-panel").style.display = showSavedSearches ? "block" : "none";
    const options = Utils.createFavoritesOption(
      "show-saved-searches",
      "Saved Searches",
      "Toggle saved searches",
      showSavedSearches,
      (e) => {
        savedSearchesContainer.style.display = e.target.checked ? "block" : "none";
        Utils.setPreference(SavedSearches.preferences.visibility, e.target.checked);
      },
      true
    );

    document.getElementById("bottom-panel-2").insertAdjacentElement("afterbegin", options);
  }

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

  addEventListeners() {
    this.saveButton.onclick = () => {
      this.saveSearch(this.textarea.value.trim());
    };
    this.textarea.addEventListener("keydown", (event) => {
      switch (event.key) {
        case "Enter":
          if (Utils.awesompleteIsUnselected(this.textarea)) {
            event.preventDefault();
            this.saveButton.click();
            this.textarea.blur();
            setTimeout(() => {
              this.textarea.focus();
            }, 100);
          }
          break;

        case "Escape":
          if (Utils.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 = Utils.icons.edit;
    removeButton.innerHTML = Utils.icons.delete;
    moveToTopButton.innerHTML = Utils.icons.upArrow;
    editButton.title = "Edit";
    removeButton.title = "Delete";
    moveToTopButton.title = "Move to top";
    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() {
    localStorage.setItem(SavedSearches.localStorageKeys.savedSearches, JSON.stringify(Utils.getSavedSearchValues()));
  }

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

    Utils.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 Utils.sleep(1000);
      const postIds = Utils.getAllThumbs().map(thumb => thumb.id);

      Utils.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(Post.allPosts.values())
      .filter(post => post.matchedByMostRecentSearch)
      .map(post => post.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);
  }
}

class Caption {
  static 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: 0.5ch;
    padding-left: 7px;

    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>
`;
  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);
  static saveTagCategoriesCooldown = new Cooldown(1000);
  /**
   * @type {Object.<String, Number>}
   */
  static tagCategoryAssociations;
  static settings = {
    tagFetchDelayAfterFinishedLoading: 5,
    tagFetchDelayBeforeFinishedLoading: 40
  };
  static flags = {
    finishedLoading: false
  };

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

    for (const category of Caption.importantTagCategories) {
      const capitalizedCategory = Utils.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 !Utils.onFavoritesPage() || Utils.onMobileDevice() || Utils.getPerformanceProfile() > 1;
  }

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

  /**
   * @type {Number}
   */
  static get tagFetchDelay() {
    if (Caption.flags.finishedLoading) {
      return Caption.settings.tagFetchDelayAfterFinishedLoading;
    }
    return Caption.settings.tagFetchDelayBeforeFinishedLoading;
  }

  /**
   * @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.insertHTML();
    this.toggleVisibility(this.getVisibilityPreference());
    this.addEventListeners();
  }

  initializeFields() {
    Caption.tagCategoryAssociations = this.loadSavedTags();
    Caption.findCategoriesOnPageChangeCooldown.onDebounceEnd = () => {
      this.findTagCategoriesOnPageChange();
    };
    Caption.saveTagCategoriesCooldown.onCooldownEnd = () => {
      this.saveTagCategories();
    };
    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 not-highlightable";
    this.captionWrapper.appendChild(this.caption);
    document.head.appendChild(this.captionWrapper);
    this.caption.innerHTML = Caption.template;
  }

  insertHTML() {
    Utils.insertStyleHTML(Caption.captionHTML, "caption");
    Utils.createFavoritesOption(
      "show-captions",
      "Details",
      "Show details when hovering over thumbnail",
      this.getVisibilityPreference(),
      (event) => {
        this.toggleVisibility(event.target.checked);
      },
      true,
      "(D)"
    );
  }

  /**
   * @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");
    }
    Utils.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 = this.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" || !Utils.isHotkeyEvent(event)) {
        return;
      }

      if (Utils.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 (Utils.onSearchPage()) {
        // this.toggleVisibility();
      }
    }, {
      passive: true
    });
  }

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

  addFavoritesPageEventListeners() {
    window.addEventListener("favoritesLoaded", () => {
      this.addEventListenersToThumbs.bind(this)();
      Caption.flags.finishedLoading = true;
      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) {
        setTimeout(() => {
          this.findTagCategoriesOnPageChange();
        }, 100);
      }
    });
    window.addEventListener("originalFavoritesCleared", (event) => {
      const thumbs = event.detail;
      const tagNames = Array.from(thumbs)
        .map(thumb => Utils.getImageFromThumb(thumb).title)
        .join(" ")
        .split(" ")
        .filter(tagName => !Utils.isNumber(tagName) && Caption.tagCategoryAssociations[tagName] === undefined);

      this.findTagCategories(tagNames, Caption.tagFetchDelay, () => {
        Caption.saveTagCategoriesCooldown.restart();
      });
    }, {
      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 Utils.sleep(500);
    thumbs = thumbs === undefined ? Utils.getAllThumbs() : thumbs;

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

      if (imageContainer.onmouseenter !== null) {
        continue;
      }
      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.preventDefault();
      event.stopPropagation();
    };
    captionIdTag.addEventListener("contextmenu", (event) => {
      event.preventDefault();
      event.stopPropagation();
    });

    captionIdTag.onmousedown = (event) => {
      event.preventDefault();
      event.stopPropagation();
      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 = Utils.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 = `${Utils.roundToTwoDecimalPlaces(scale)}em`;
  }

  /**
   * @param {HTMLElement} thumb
   * @returns {Boolean}
   */
  thumbMetadataExists(thumb) {
    if (Utils.onSearchPage()) {
      return false;
    }
    const post = Post.allPosts.get(thumb.id);

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

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

    if (post.metadata.width <= 0 || post.metadata.width <= 0) {
      return false;
    }
    return true;
  }

  /**
   * @param {HTMLElement} thumb
   * @param {HTMLInputElement} columnInput
   * @returns {Number}
   */
  estimateThumbHeightFromMetadata(thumb, columnInput) {
    const post = Post.allPosts.get(thumb.id);
    const gridGap = 16;
    const columnCount = Math.max(1, parseInt(columnInput.value));
    const thumbWidthEstimate = (window.innerWidth - (columnCount * gridGap)) / columnCount;
    const thumbWidthScale = post.metadata.width / thumbWidthEstimate;
    return post.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();
      event.preventDefault();
    };
    tag.addEventListener("contextmenu", (event) => {
      event.preventDefault();
      event.stopPropagation();
    });
    tag.onmousedown = (event) => {
      event.preventDefault();
      event.stopPropagation();
      this.tagOnClick(tagName, event);
    };
  }

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

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

  /**
   * @param {String} tagName
   * @param {MouseEvent} event
   */
  tagOnClick(tagName, event) {
    switch (event.button) {
      case Utils.clickCodes.left:
        this.tagOnClickHelper(tagName, event);
        break;

      case Utils.clickCodes.middle:
        dispatchEvent(new CustomEvent("searchForTag", {
          detail: tagName
        }));
        break;

      case Utils.clickCodes.right:
        this.tagOnClickHelper(`-${tagName}`, event);
        break;

      default:
        break;
    }
  }

  /**
   * @param {String} value
   * @param {MouseEvent} mouseEvent
   */
  tagOnClickHelper(value, mouseEvent) {
    if (mouseEvent.ctrlKey) {
      Utils.openSearchPage(value);
      return;
    }
    const searchBox = Utils.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 Utils.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${Utils.capitalize(tagCategory)}`;
  }

  /**
   * @param {HTMLElement} thumb
   */
  populateTags(thumb) {
    const tagNames = Utils.getTagsFromThumb(thumb);

    tagNames.delete(thumb.id);
    const unknownThumbTags = Array.from(tagNames)
      .filter(tagName => this.tagCategoryIsUnknown(thumb, tagName));

    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) {
    Caption.saveTagCategoriesCooldown.restart();

    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(Utils.getPostPageURL(thumb.id))
      .then((response) => {
        return response.text();
      })
      .then((html) => {
        const tagCategoryMap = this.getTagCategoryMapFromPostPage(html);

        for (const [tagName, tagCategory] of tagCategoryMap.entries()) {
          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) {
    if (Caption.tagCategoryAssociations[tag] === undefined && !Utils.customTags.has(tag)) {
      this.problematicTags.add(tag);
    }
  }

  findTagCategoriesOnPageChange() {
    const tagNames = this.getTagNamesWithUnknownCategories(Utils.getAllThumbs().slice(0, 200));

    this.findTagCategories(tagNames, Caption.tagFetchDelay, () => {
      Caption.saveTagCategoriesCooldown.restart();
    });
  }

  /**
   * @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 (Utils.isNumber(tagName) && tagName.length > 5) {
        Caption.tagCategoryAssociations[tagName] = 0;
        continue;
      }

      if (tagName.includes("'")) {
        this.setAsProblematic(tagName);
      }

      if (this.problematicTags.has(tagName)) {
        if (tagName === lastTagName) {
          onAllCategoriesFound();
        }
        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();
            }
          }).catch(() => {
            onAllCategoriesFound();
          });
      } catch (error) {
        console.error(error);
      }
      await Utils.sleep(fetchDelay);
    }
  }

  /**
   * @param {HTMLElement[]} thumbs
   * @returns {String[]}
   */
  getTagNamesWithUnknownCategories(thumbs) {
    const tagNamesWithUnknownCategories = new Set();

    for (const thumb of thumbs) {
      const tagNames = Array.from(Utils.getTagsFromThumb(thumb));

      for (const tagName of tagNames) {
        if (this.tagCategoryIsUnknown(thumb, tagName)) {
          tagNamesWithUnknownCategories.add(tagName);
        }
      }
    }
    return Array.from(tagNamesWithUnknownCategories);
  }

  /**
   * @param {HTMLElement} thumb
   * @param {String} tagName
   * @returns
   */
  tagCategoryIsUnknown(thumb, tagName) {
    return tagName !== thumb.id && Caption.tagCategoryAssociations[tagName] === undefined && !Utils.customTags.has(tagName);
  }
}

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

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

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

    .favorite.tag-modifier-selected {
      outline: 2px dashed white !important;

      >div, >a {
        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<span class="option-hint"></span>
    </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-buttons">
      <span 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>
      </span>
      <span 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>
      </span>
    </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>
`;
  /**
   * @type {String}
   */
  static databaseName = "AdditionalTags";
  /**
   * @type {String}
   */
  static objectStoreName = "additionalTags";
  /**
   * @type {Map.<String, String>}
   */
  static tagModifications = new Map();
  static preferences = {
    modifyTagsOutsideFavoritesPage: "modifyTagsOutsideFavoritesPage"
  };

  /**
   * @type {Boolean}
   */
  static get currentlyModifyingTags() {
    return document.getElementById("tag-edit-mode") !== null;
  }

  /**
   * @type {Boolean}
   */
  static get disabled() {
    if (Utils.onMobileDevice()) {
      return true;
    }

    if (Utils.onFavoritesPage()) {
      return false;
    }
    return Utils.getPreference(TagModifier.preferences.modifyTagsOutsideFavoritesPage, false);
  }

  /**
   * @type {AbortController}
   */
  tagEditModeAbortController;
  /**
   * @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}}
   */
  favoritesUI;
  /**
   * @type {Post[]}
   */
  selectedPosts;
  /**
   * @type {Boolean}
   */
  atLeastOneFavoriteIsSelected;

  constructor() {
    if (TagModifier.disabled) {
      return;
    }
    this.tagEditModeAbortController = new AbortController();
    this.favoritesOption = {};
    this.favoritesUI = {};
    this.selectedPosts = [];
    this.atLeastOneFavoriteIsSelected = false;
    this.loadTagModifications();
    this.insertHTML();
    this.addEventListeners();
  }

  insertHTML() {
    this.insertFavoritesPageHTML();
    this.insertSearchPageHTML();
    this.insertPostPageHTML();
  }

  insertFavoritesPageHTML() {
    if (!Utils.onFavoritesPage()) {
      return;
    }
    Utils.insertHTMLAndExtractStyle(document.getElementById("bottom-panel-4"), "beforeend", TagModifier.tagModifierHTML);
    this.favoritesOption.container = document.getElementById("tag-modifier-container");
    this.favoritesOption.checkbox = document.getElementById("tag-modifier-option-checkbox");
    this.favoritesUI.container = document.getElementById("tag-modifier-ui-container");
    this.favoritesUI.statusLabel = document.getElementById("tag-modifier-ui-status-label");
    this.favoritesUI.textarea = document.getElementById("tag-modifier-ui-textarea");
    this.favoritesUI.add = document.getElementById("tag-modifier-ui-add");
    this.favoritesUI.remove = document.getElementById("tag-modifier-remove");
    this.favoritesUI.reset = document.getElementById("tag-modifier-reset");
    this.favoritesUI.selectAll = document.getElementById("tag-modifier-ui-select-all");
    this.favoritesUI.unSelectAll = document.getElementById("tag-modifier-ui-un-select-all");
    this.favoritesUI.import = document.getElementById("tag-modifier-import");
    this.favoritesUI.export = document.getElementById("tag-modifier-export");
  }

  insertSearchPageHTML() {
    if (!Utils.onSearchPage()) {
      return;
    }
    1;
  }

  insertPostPageHTML() {
    if (!Utils.onPostPage()) {
      return;
    }
    const contentContainer = document.querySelector(".flexi");
    const originalAddToFavoritesLink = Array.from(document.querySelectorAll("a")).find(a => a.textContent === "Add to favorites");

    const html = `
      <div style="margin-bottom: 1em;">
        <h4 class="image-sublinks">
        <a href="#" id="add-to-favorites">Add to favorites</a>
        |
        <a href="#" id="add-custom-tags">Add custom tag</a>
        <select id="custom-tags-list"></select>
        </h4>
      </div>
    `;

    if (contentContainer === null || originalAddToFavoritesLink === undefined) {
      return;
    }
    contentContainer.insertAdjacentHTML("beforebegin", html);

    const addToFavorites = document.getElementById("add-to-favorites");
    const addCustomTags = document.getElementById("add-custom-tags");
    const customTagsList = document.getElementById("custom-tags-list");

    for (const customTag of Utils.customTags.values()) {
      const option = document.createElement("option");

      option.value = customTag;
      option.textContent = customTag;
      customTagsList.appendChild(option);
    }
    addToFavorites.onclick = () => {
      originalAddToFavoritesLink.click();
      return false;
    };

    addCustomTags.onclick = () => {
      return false;
    };
  }

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

  addFavoritesPageEventListeners() {
    if (!Utils.onFavoritesPage()) {
      return;
    }
    this.favoritesOption.checkbox.onchange = (event) => {
      this.toggleTagEditMode(event.target.checked);
    };
    this.favoritesUI.selectAll.onclick = this.selectAll.bind(this);
    this.favoritesUI.unSelectAll.onclick = this.unSelectAll.bind(this);
    this.favoritesUI.add.onclick = this.addTagsToSelected.bind(this);
    this.favoritesUI.remove.onclick = this.removeTagsFromSelected.bind(this);
    this.favoritesUI.reset.onclick = this.resetTagModifications.bind(this);
    this.favoritesUI.import.onclick = this.importTagModifications.bind(this);
    this.favoritesUI.export.onclick = this.exportTagModifications.bind(this);
    window.addEventListener("searchStarted", () => {
      this.unSelectAll();
    });
    window.addEventListener("changedPage", () => {
      this.highlightSelectedThumbsOnPageChange();
    });
  }

  addSearchPageEventListeners() {
    if (!Utils.onSearchPage()) {
      return;
    }
    1;
  }

  addPostPageEventListeners() {
    if (!Utils.onPostPage()) {
      return;
    }
    1;
  }

  highlightSelectedThumbsOnPageChange() {
    if (!this.atLeastOneFavoriteIsSelected) {
      return;
    }
    const posts = Utils.getAllThumbs()
      .map(thumb => Post.allPosts.get(thumb.id));

    for (const post of posts) {
      if (post === undefined) {
        return;
      }

      if (this.isSelectedForModification(post)) {
        this.highlightPost(post, true);
      }
    }
  }

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

  /**
   * @param {Boolean} value
   */
  toggleThumbInteraction(value) {
    let html = "";

    if (value) {
      html =
        `
      .favorite  {
        cursor: pointer;
        outline: 1px solid black;

        > div,
        >a
         {
          outline: none !important;

          > img {
            outline: none !important;
          }

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

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

  /**
   * @param {Boolean} value
   */
  toggleTagEditModeEventListeners(value) {
    if (!value) {
      this.tagEditModeAbortController.abort();
      this.tagEditModeAbortController = new AbortController();
      return;
    }

    document.addEventListener("click", (event) => {
      if (!event.target.classList.contains(Utils.favoriteItemClassName)) {
        return;
      }
      const post = Post.allPosts.get(event.target.id);

      if (post !== undefined) {
        this.toggleThumbSelection(post);
      }
    }, {
      signal: this.tagEditModeAbortController.signal
    });

  }

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

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

  unSelectAll() {
    if (!this.atLeastOneFavoriteIsSelected) {
      return;
    }

    for (const post of Post.allPosts.values()) {
      this.toggleThumbSelection(post, false);
    }
    this.atLeastOneFavoriteIsSelected = false;
  }

  selectAll() {
    for (const post of Post.postsMatchedBySearch.values()) {
      this.toggleThumbSelection(post, true);
    }
  }

  /**
   * @param {Post} post
   * @param {Boolean} value
   */
  toggleThumbSelection(post, value) {
    this.atLeastOneFavoriteIsSelected = true;

    if (value === undefined) {
      value = !this.isSelectedForModification(post);
    }
    post.selectedForTagModification = value ? true : undefined;
    this.highlightPost(post, value);
  }

  /**
   * @param {Post} post
   * @param {Boolean} value
   */
  highlightPost(post, value) {
    if (post.root !== undefined) {
      post.root.classList.toggle("tag-modifier-selected", value);
    }
  }

  /**
   * @param {Post} post
   * @returns {Boolean}
   */
  isSelectedForModification(post) {
    return post.selectedForTagModification !== undefined;
  }

  /**
   * @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.favoritesUI.textarea.value.toLowerCase();
    const tagsWithoutContentTypes = this.removeContentTypeTags(tags);
    const tagsToModify = Utils.removeExtraWhiteSpace(tagsWithoutContentTypes);
    const statusPrefix = remove ? "Removed tag(s) from" : "Added tag(s) to";
    let modifiedTagsCount = 0;

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

    for (const post of Post.allPosts.values()) {
      if (this.isSelectedForModification(post)) {
        const additionalTags = remove ? post.removeAdditionalTags(tagsToModify) : post.addAdditionalTags(tagsToModify);

        TagModifier.tagModifications.set(post.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"));
    Utils.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;
    }
    Utils.customTags.clear();
    indexedDB.deleteDatabase("AdditionalTags");
    Post.allPosts.forEach(post => {
      post.resetAdditionalTags();
    });
    dispatchEvent(new Event("modifiedTags"));
    localStorage.removeItem("customTags");
  }

  exportTagModifications() {
    const modifications = JSON.stringify(Utils.mapToObject(TagModifier.tagModifications));

    navigator.clipboard.writeText(modifications);
    alert("Copied tag modifications to clipboard");
  }

  importTagModifications() {
    let modifications;

    try {
      const object = JSON.parse(this.favoritesUI.textarea.value);

      if (!(typeof object === "object")) {
        throw new TypeError(`Input parsed as ${typeof (object)}, but expected object`);
      }
      modifications = Utils.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);
  }
}

// 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: 20,
            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;
}());

class AwesompleteWrapper {
  static preferences = {
    savedSearchSuggestions: "savedSearchSuggestions"
  };

  /**
   * @type {Boolean}
   */
  static get disabled() {
    return !Utils.onFavoritesPage();
  }

  /**
   * @type {Boolean}
   */
  showSavedSearchSuggestions;

  constructor() {
    if (AwesompleteWrapper.disabled) {
      return;
    }
    this.initializeFields();
    this.insertHTML();
    this.addAwesompleteToInputs();
  }

  initializeFields() {
    this.showSavedSearchSuggestions = Utils.getPreference(AwesompleteWrapper.preferences.savedSearchSuggestions, false);
  }

  insertHTML() {
    Utils.createFavoritesOption(
      "show-saved-search-suggestions",
      "Saved Suggestions",
      "Show saved search suggestions in autocomplete dropdown",
      this.showSavedSearchSuggestions,
      (event) => {
        this.showSavedSearchSuggestions = event.target.checked;
        Utils.setPreference(AwesompleteWrapper.preferences.savedSearchSuggestions, event.target.checked);
      },
      false
    );
  }

  addAwesompleteToInputs() {
    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, _) => {
        // eslint-disable-next-line new-cap
        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) => {
        Utils.insertSuggestion(awesomplete.input, Utils.removeSavedSearchPrefix(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":
          Utils.hideAwesomplete(input);
          break;

        default:
          break;
      }
    });

    input.oninput = () => {
      this.populateAwesompleteList(input.id, this.getCurrentTagWithHyphen(input), awesomplete);
    };
  }

  getSavedSearchesForAutocompleteList(inputId, prefix) {
    if (Utils.onMobileDevice() || !this.showSavedSearchSuggestions || inputId !== "favorites-search-box") {
      return [];
    }
    return Utils.getSavedSearchesForAutocompleteList(prefix);
  }

  /**
   * @param {String} inputId
   * @param {String} prefix
   * @param {Awesomplete_} awesomplete
   */
  populateAwesompleteList(inputId, prefix, awesomplete) {
    if (prefix.trim() === "") {
      return;
    }
    const savedSearchSuggestions = this.getSavedSearchesForAutocompleteList(inputId, prefix);

    prefix = prefix.replace(/^-/, "");

    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 = Utils.addCustomTagsToAutocompleteList(JSON.parse(suggestions), prefix);

        awesomplete.list = mergedSuggestions.concat(savedSearchSuggestions);
      });
  }

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

  /**
   * @param {HTMLInputElement | HTMLTextAreaElement} input
   * @returns {String}
   */
  getCurrentTagWithHyphen(input) {
    return this.getLastTagWithHyphen(input.value.slice(0, input.selectionStart));
  }

  /**
   * @param {String} searchQuery
   * @returns {String}
   */
  getLastTagWithHyphen(searchQuery) {
    const lastTag = searchQuery.match(/[^ ]*$/);
    return lastTag === null ? "" : lastTag[0];
  }
}

Utils.initialize();
const favoritesLoader = new FavoritesLoader();
const favoritesMenu = new FavoritesMenu();
const gallery = new Gallery();
const tooltip = new Tooltip();
const savedSearches = new SavedSearches();
const caption = new Caption();
const tagModifier = new TagModifier();
const awesompleteWrapper = new AwesompleteWrapper();